前言
提及算法,可能会有很多前端同学觉得这是一个距离自己日常工作较远的领域,认为算法并没有那么重要。事实上,这种看法是片面的,算法不仅仅是计算机科学中的一个重要概念,在前端开发中也有着广泛的应用和巨大的价值。
一个精心设计的算法可以大幅度提高应用的性能和效率,例如:如何在大量数据中快速找到指定信息、如何高效地处理用户输入、如何在动画效果中保持流畅的用户体验、如何让页面加载更快、响应更灵敏等等场景,这些都依赖于对算法的理解和应用。掌握算法能够让我们在面对复杂问题时,具备更强的分析能力和解决策略。
本文是一篇对前端同学相对友好的入门算法文章,提供一条易于理解的学习路径,从经典的排序、搜索等基础算法开始,逐步深入,再到一些高级算法设计思想,结合 LeetCode 真题实战案例分析,帮助你理解算法背后的实现逻辑,以及如何将其灵活应用于实际的前端开发场景中。
学习算法,不仅能提高编程能力,对求职面试也有很大帮助,微软、字节跳动、腾讯等公司就特别喜欢问算法。
排序
简单来说,排序算法用于将一组乱序的元素按照升序或降序的顺序重新排列。其性能通常通过时间复杂度、空间复杂度、稳定性等指标来衡量。
JavaScript 语言中的自带的排序:数组的 sort
方法。
冒泡排序
冒泡排序(Bubble Sort)是一种简单的比较排序算法。它重复地遍历待排序数组,每次比较相邻的两个元素,如果顺序相反则进行交换。这样,每一轮遍历都会将最大(或最小)的元素“冒泡”到顶端,直到整个数组都排序完成,最终达到完全有序。
步骤:
- 遍历数组:从头到尾遍历数组,比较相邻的两个元素。
- 比较相邻元素:每次比较相邻的两个元素,如果它们的顺序不正确(比如,前一个元素大于后一个元素),则交换它们。
- 重复遍历:重复上述步骤,直到没有任何一对元素需要交换,即数组已经完全排序。
代码实现:
1 | function bubbleSort(array) { |
冒泡排序有几种可以优化的空间:
- 优化遍历范围:在每一轮排序中,可以观察到最后一次交换发生的位置之后的元素已经是有序的,因此可以将下一轮排序的范围限定在上一轮最后一次交换的位置之前。这样可以减少不必要的比较和交换操作。
- 添加标志位:如果在一轮排序过程中没有发生任何元素的交换,说明数组已经是有序的,可以提前结束排序过程。
- 针对部分有序数组的优化:如果数组在初始状态下已经接近有序,可以记录下每轮排序中最后一次交换的位置,然后下一轮排序时只需要遍历到该位置即可,这样可以大大减少排序的比较次数。
- 鸡尾酒排序(双向冒泡排序):在一次排序过程中,既从左到右比较交换,又从右到左比较交换,可以在某些特定情况下提升效率。
时间复杂度:
最优时间复杂度:O(n)
当输入数据已经是有序时,冒泡排序可以通过设置一个标志变量来检测是否发生了交换操作,如果在某一趟排序中没有交换操作发生,说明数组已经有序,因此可以提前结束排序过程。此时,最优时间复杂度为 O(n)。最坏时间复杂度:O(n^2)
在最坏情况下,输入数据是逆序的,此时需要进行 n-1 趟排序,每一趟排序中需要进行的比较次数逐渐减少,总比较次数为 n(n-1)/2,因此最坏时间复杂度为 O(n^2)。平均时间复杂度:O(n^2)
在一般情况下,冒泡排序的比较和交换操作的次数与输入数据的初始排列状态有关,但总体而言其时间复杂度仍为 O(n^2)。
空间复杂度:
冒泡排序是一种原地排序算法,它在排序过程中只需要常数级的额外空间,即只使用了少量的辅助变量,因此其空间复杂度为 O(1)。
稳定性:
冒泡排序是一种稳定排序算法。在排序过程中,如果两个相等的元素相互比较,它们不会交换位置,因此相等元素的相对位置不会改变。
冒泡排序由于其简单易懂的特性,常用于教学和小规模数据集的排序,但由于其较低的效率,通常不适合大规模数据集的排序任务。
选择排序
选择排序(Selection Sort)是一种简单的比较排序算法。它的基本思想是在未排序数组中找到最小(或最大)的元素,然后将其放置到数组的起始位置,接着在剩余的未排序部分中继续寻找最小(或最大)的元素,依次类推,直到所有元素都排序完成。
步骤:
初始状态: 将整个序列看作两部分,一部分是未排序的,一部分是已排序的(初始时已排序部分为空)。
遍历未排序部分: 遍历未排序部分,找到最小(或最大)的元素。
交换元素: 将找到的最小(或最大)元素与未排序部分的第一个元素交换位置,使得找到的最小元素成为已排序部分的最后一个元素。
扩大已排序部分: 将已排序部分的长度增加 1,未排序部分的长度减少 1。
重复: 重复以上步骤,直到所有元素都已经排序完毕。
这个过程类似于每次从一堆未排序的卡片中选出最小(或最大)的卡片,然后放到已排序的卡片堆中。选择排序的特点是每次遍历都只进行一次交换操作,因此相对于其他排序算法,它的交换次数较少。
代码实现:
1 | function selectionSort(array) { |
时间复杂度:
最优时间复杂度:O(n^2)
无论输入数据的初始排列状态如何,选择排序总是需要进行 n(n-1)/2 次比较,因此最优时间复杂度为 O(n^2)。最坏时间复杂度:O(n^2)
同样地,在最坏情况下,选择排序仍需要进行 n(n-1)/2 次比较,所以最坏时间复杂度为 O(n^2)。平均时间复杂度:O(n^2)
由于选择排序每一趟排序所需的比较次数固定,因此其平均时间复杂度也为 O(n^2)。
空间复杂度:
选择排序是一种原地排序算法,只需要常数级的辅助空间(通常是用于交换元素的临时变量),因此其空间复杂度为 O(1)。
稳定性:
选择排序通常不是稳定排序。在选择排序过程中,每次从未排序部分选择最小(或最大)元素并将其与未排序部分的第一个元素交换时,如果相等元素存在,原有的相对顺序可能会被打破。例如:
- 初始数组:[3, 2, 2, 1]
- 第一次选择:选择最小元素 1,与第一个元素 3 交换,结果:[1, 2, 2, 3]
- 第二次选择:选择最小元素 2,与第二个元素 2 交换,结果:[1, 2, 2, 3]
虽然这个例子没有改变相同元素的相对顺序,但在某些情况下,如处理:[2, 3, 1, 2],第二个“2”会被提前,与第一个“2”交换,导致顺序改变。
选择排序由于其简单性和恒定的空间复杂度,适用于对内存空间要求较高但对时间效率要求不高的场景。然而,由于其 O(n^2) 的时间复杂度,选择排序在处理大规模数据集时效率较低,通常不作为首选的排序算法。
插入排序
插入排序(Insertion Sort)是一种简单的比较排序算法。它的基本思想是将待排序数组分成已排序和未排序两部分,初始时已排序部分只有一个元素(即数组的第一个元素),然后从未排序部分依次取出元素,将其插入到已排序部分的正确位置,直到所有元素都被插入完成。
插入排序类似扑克牌思想,想象在打扑克牌,拿起来第一张,放哪里无所谓,再拿起来一张,比第一张小,放左边,继续拿,可能是中间数,就插在中间,依次把牌拿完。
步骤:
- 初始已排序部分:初始时,将待排序数组的第一个元素视为已排序部分,其余元素视为未排序部分。
- 遍历未排序部分:从第二个元素开始,依次遍历未排序部分的元素。
- 插入到已排序部分:对于每个未排序部分的元素,将其与已排序部分的元素逐个比较,找到正确的插入位置。
- 重复插入:将元素插入到已排序部分的正确位置后,已排序部分的长度增加 1,未排序部分的长度减少 1,继续重复上述步骤,直到所有元素都被插入完成。
代码实现:
1 | function insertSort(array) { |
时间复杂度:
最优时间复杂度:O(n)
当输入数据已经有序时,插入排序每次只需要比较一次即可确定元素的位置,无需进行交换操作。此时,最优时间复杂度为 O(n)。最坏时间复杂度:O(n^2)
在最坏情况下,输入数据是逆序的。此时,插入排序需要进行大量的比较和移动操作,每次插入元素时都需要将其与已经排序的部分进行比较并移动其他元素。因此最坏时间复杂度为 O(n^2)。平均时间复杂度:O(n^2)
在一般情况下,插入排序的比较和移动操作次数与输入数据的初始排列状态有关,但总体而言,其平均时间复杂度为 O(n^2)。
空间复杂度:
插入排序是一种原地排序算法,它在排序过程中只需要常数级的额外空间(用于存储待插入的元素的临时变量),因此其空间复杂度为 O(1)。
稳定性:
插入排序是一种稳定排序算法。在插入过程中,如果待插入的元素与已排序部分的某个元素相等,插入排序会将待插入的元素放在相等元素的后面,从而保持相等元素的相对顺序不变。
插入排序由于其简单性和对小规模数据集的高效性,常用于对小型数组进行排序或在其他更复杂的排序算法(如快速排序、归并排序)的过程中处理小数据块。然而,由于其 O(n^2) 的时间复杂度,插入排序在处理大规模数据集时效率较低。
希尔排序
希尔排序(Shell Sort)是一种改进的插入排序算法,也被称为“缩小增量排序”。它的基本思想是通过定义一个间隔序列(称为增量序列),将待排序数组分成若干个子序列,对每个子序列进行插入排序。随着排序的进行,增量序列逐渐缩小,直到增量为 1,最后对整个数组进行插入排序。
步骤:
- 选择增量序列:定义一个增量序列,确定每个增量值(间隔),通常以递减的方式选择。
- 分组排序:将待排序数组根据当前增量值分成若干个子序列,对每个子序列进行插入排序。
- 逐步缩小增量:重复上述步骤,逐步缩小增量值,直到增量为 1。
- 最终排序:当增量为 1 时,对整个数组进行一次插入排序,完成排序过程。
代码实现:
1 | function hillSort(array) { |
时间复杂度:
希尔排序的时间复杂度较为复杂,与所选的增量序列(gap sequence)有很大关系。常见的增量序列有希尔增量序列、Hibbard 增量序列、Sedgewick 增量序列等。以下是几种常见增量序列的时间复杂度分析:
希尔增量序列(gap = n/2, n/4, …, 1):最坏时间复杂度:O(n^2)
Hibbard 增量序列(gap = 2^k - 1):最坏时间复杂度:O(n^(3/2))
Sedgewick 增量序列(一种较为复杂的序列):最坏时间复杂度:O(n^(4/3))
更优的增量序列:有些优化过的增量序列可以达到 O(n log^2 n) 的最坏时间复杂度。
由于增量序列的选择对希尔排序的时间复杂度有很大的影响,所以具体的时间复杂度因实现而异,但通常在 O(n^2) 和 O(n log^2 n) 之间。
空间复杂度:
希尔排序是一种原地排序算法,其空间复杂度为 O(1),只需要常数级的额外空间。
稳定性:
希尔排序不是稳定排序。在排序过程中,元素可能会跨越多个位置进行交换,因此相同元素的相对顺序可能会被打乱。
希尔排序由于其高效性和相对简单的实现,在实际应用中有一定的优势,特别是在数据规模较大时。它通过对插入排序的改进,大大减少了数据移动的次数,从而提高了排序的效率。
归并排序
归并排序(Merge Sort)是一种基于分治法的高效排序算法。其基本思想是将数组分成更小的子数组,分别对这些子数组进行排序,然后再将它们合并起来,以得到一个有序的数组。
步骤:
- 分割(Divide):将数组从中间分成两个子数组(递归地分割直到子数组的长度为 1)。
- 排序(Conquer):对每个子数组进行排序。因为子数组的长度为 1,所以它们是有序的。
- 合并(Combine):将两个有序的子数组合并成一个有序的数组。重复这个过程,直到所有的子数组被合并成一个完整的有序数组。
代码实现:
1 | function mergeSort(array) { |
基本过程:
- 分割:将待排序数组分成两半。
- 递归排序:对每一半分别进行递归排序。
- 合并:合并两个有序子数组以形成一个有序的整体。
时间复杂度:
- 最优时间复杂度:O(n log n)
- 最坏时间复杂度:O(n log n)
- 平均时间复杂度:O(n log n)
归并排序在最优、最坏和平均情况下的时间复杂度都是 O(n log n),因为它始终将数组分成两半,然后对每一半进行排序,再合并结果。
空间复杂度:
归并排序的空间复杂度为 O(n),这是因为归并排序在合并过程中需要一个额外的数组来暂存数据。对于递归实现,还需要考虑递归调用的栈空间,但总的额外空间仍然是 O(n)。
稳定性:
归并排序是一种稳定排序算法。在合并两个有序子数组的过程中,如果两个元素相等,先将前一个数组的元素放入结果数组中,从而保持相等元素的相对顺序不变。
归并排序由于其稳定性和 O(n log n) 的时间复杂度,常用于处理大规模数据集,尤其是在需要稳定排序的情况下。虽然归并排序的空间复杂度较高,但其分治策略使其在许多应用中表现出色。
快速排序
快速排序(Quick Sort)是一种高效的排序算法,基于分治法。它通过选择一个”基准”(pivot)元素,并将数组分成两部分,其中一部分的所有元素都小于基准,另一部分的所有元素都大于基准。然后递归地对这两部分进行排序。
步骤:
- 选择基准:从数组中选择一个元素作为基准(pivot)。
- 分区:重新排列数组,使得所有小于基准的元素在基准的左边,所有大于基准的元素在基准的右边(相等的元素可以放在任一侧)。此时基准元素处于其正确的位置。
- 递归排序:递归地对基准左边的子数组和右边的子数组进行排序。
代码实现:
1 | function quickSort(array) { |
时间复杂度:
最优时间复杂度:O(n log n)
当每次划分的子数组都比较均匀时,递归树的高度为 log n,每层的操作复杂度为 O(n),所以最优时间复杂度为 O(n log n)。最坏时间复杂度:O(n^2)
在最坏情况下,每次划分的子数组高度不均匀,例如每次选择的基准(pivot)是最大或最小元素,这会导致递归树退化为链表形式,时间复杂度为 O(n^2)。平均时间复杂度:O(n log n)
在实际应用中,快速排序的平均性能通常很好,期望时间复杂度为 O(n log n),因为随机选择基准或使用“三数取中”等方法可以有效避免最坏情况。
空间复杂度:
快速排序的空间复杂度主要取决于递归调用栈的深度:
平均空间复杂度:O(log n)
在理想情况下,递归调用栈的深度为 log n,因此空间复杂度为 O(log n)。最坏空间复杂度:O(n)
在最坏情况下,递归调用栈的深度为 n,因此空间复杂度为 O(n)。
稳定性:
快速排序不是稳定排序。在排序过程中,元素的相对顺序可能会被改变,因为基准元素的交换可能会使得相等的元素顺序颠倒。
快速排序因其高效性和较好的平均性能,广泛应用于各种排序任务。通过随机选择基准或“三数取中”等方法,可以有效地改善其性能,避免最坏情况的发生。
堆排序
堆排序(Heap Sort)是一种基于二叉堆数据结构的比较排序算法。堆排序可以分为两个阶段:构建初始堆和在堆上进行排序操作。
步骤:
- 构建最大堆:将无序数组构建成一个最大堆(max heap),最大堆是一个完全二叉树,其中每个节点的值都大于或等于其子节点的值。
- 排序:交换堆顶元素(最大值)和堆的最后一个元素,并将堆的大小减少 1。然后对堆的根节点进行调整,使其重新成为最大堆。
重复上述步骤,直到堆中剩余元素只剩一个,即完成排序。
1 | function heapSort(array) { |
时间复杂度:
最优时间复杂度:O(n log n)
在最优情况下,堆排序的时间复杂度为 O(n log n),因为构建最大堆和进行堆排序的时间复杂度都是 O(n log n)。最坏时间复杂度:O(n log n)
在最坏情况下,堆排序的时间复杂度也是 O(n log n)。无论输入数据的顺序如何,都需要将数据构建成最大堆,然后进行排序。平均时间复杂度:O(n log n)
空间复杂度:
堆排序是一种原地排序算法,它只需要常数级别的额外空间来存储堆的数据结构,因此其空间复杂度为 O(1)。
稳定性:
堆排序不是稳定排序算法。在堆排序中,可能会破坏相同元素的相对顺序,因此它不是稳定的排序算法。
堆排序由于其高效性和原地排序的特性,常用于需要稳定且较高性能的排序任务。虽然堆排序的实现相对复杂,但它的时间复杂度稳定在 O(n log n),在实践中具有较好的性能表现。
基数排序
基数排序(Radix Sort)是一种非比较性的排序算法,它根据关键字的每个位的值来排序。基数排序适用于元素都是整数的数组,其中每个元素都有相同的位数或范围。基本思想是将待排序的元素按照位数进行分组,然后按照每一位的顺序依次排序。
步骤:
按照最低有效位进行排序:从最低位(个位)开始,将元素按照该位的值进行分组(例如 0 到 9),并按照顺序重新排列。
依次对更高位进行排序:对每一位重复上述排序过程,直到按照最高位排序完成。
合并分组:每次按照位数排序后,将所有分组合并为一个数组,形成新的待排序数组。
重复步骤 1~3,直到所有位都被处理完毕。
示例:
假设我们有一个无序数组 [170, 45, 75, 90, 802, 24, 2, 66]
,使用基数排序对其进行排序:
按照个位进行排序:
将数字按照个位的值进行分组:[170, 90, 802, 2], [24], [45, 75], [66]
,并按照顺序重新排列:[170, 90, 802, 2, 24, 45, 75, 66]
。按照十位进行排序:
将数字按照十位的值进行分组:[802, 2], [24], [45, 66], [75], [170, 90]
,并按照顺序重新排列:[802, 2, 24, 45, 66, 75, 170, 90]
。按照百位进行排序(如果有的话)。
排序完成,得到有序数组
[2, 24, 45, 66, 75, 90, 170, 802]
。
代码实现:
1 | // 获取数字的指定位数上的数字 |
时间复杂度:
最优时间复杂度:O(n * k)
最优情况下,每个关键字的位数相同,基数排序的时间复杂度为 O(n * k),其中 n 是元素个数,k 是关键字的位数。最坏时间复杂度:O(n * k)
最坏情况下,基数排序的时间复杂度仍然为 O(n * k)。平均时间复杂度:O(n * k)
基数排序的平均时间复杂度也为 O(n * k),其中 k 通常为常数。
基数排序的时间复杂度主要取决于关键字的位数和元素个数,与元素的大小范围无关。
空间复杂度:
基数排序的空间复杂度取决于辅助存储空间的使用,通常需要一个额外的数组来存储中间结果。因此,其空间复杂度为 O(n + k),其中 n 是元素个数,k 是关键字的范围(通常是 10)。
稳定性:
基数排序是一种稳定排序算法。在基数排序过程中,相同位数的元素根据其原始顺序进行排序,不会改变相等元素的相对位置,因此是稳定的。
基数排序适用于处理整数或字符串等具有固定位数的元素集合。它的时间复杂度相对较低,并且是稳定排序算法,因此在一些特定的排序场景中具有一定的优势。
计数排序
计数排序(Counting Sort)是一种非比较性的排序算法,适用于待排序元素都属于一个有限范围的整数。计数排序的基本思想是通过统计待排序数组中每个元素出现的次数,然后根据统计信息将元素放置到正确的位置上。
步骤:
- 统计元素出现次数:遍历待排序数组,统计每个元素出现的次数,存储在一个辅助数组中。
- 累加统计次数:对统计数组进行累加,使得每个位置存储的值表示小于等于该值的元素的个数。
- 根据统计信息排序:遍历待排序数组,根据统计数组中的信息,将元素放置到正确的位置上。
示例:
假设我们有一个无序数组 [4, 2, 2, 8, 3, 3, 1]
,使用计数排序对其进行排序:
统计元素出现次数:统计数组中每个元素的出现次数:
[1:1, 2:2, 3:2, 4:1, 8:1]
。累加统计次数:将统计数组中的值进行累加:
[1:1, 2:3, 3:5, 4:6, 8:7]
,表示小于等于每个元素的个数。根据统计信息排序:根据累加统计次数,将待排序数组中的元素放置到正确的位置上,得到有序数组
[1, 2, 2, 3, 3, 4, 8]
。
代码实现:
1 | function countingSort(array) { |
时间复杂度:
最优时间复杂度:O(n + k)
最优情况下,计数排序的时间复杂度为 O(n + k),其中 n 是元素个数,k 是元素的范围。最坏时间复杂度:O(n + k)
最坏情况下,计数排序的时间复杂度仍然为 O(n + k)。平均时间复杂度:O(n + k)
计数排序的平均时间复杂度也为 O(n + k)。
计数排序的时间复杂度主要取决于元素的范围,而与元素的个数无关。
空间复杂度:
计数排序的空间复杂度取决于额外的计数数组和输出数组。因此,其空间复杂度为 O(n + k),其中 n 是元素个数,k 是元素的范围。
稳定性:
计数排序是一种稳定排序算法。在计数排序中,相同元素的相对顺序不会改变,因此是稳定的。
计数排序适用于对一定范围内的整数进行排序,并且适合于元素范围不是很大的情况下。由于其时间复杂度和空间复杂度均为线性,因此在一些特定的排序场景中具有较好的性能表现。
搜索
搜索算法简单来说就是用于找出数组中某个元素的下标。
JavaScript 语言中的自带的搜索:数组的 indexOf
方法。
顺序搜索
顺序搜索(Sequential Search)算法是一种简单的搜索算法,它按顺序检查列表中的每个元素,直到找到目标元素或遍历完整个列表。
代码实现:
1 | function sequentialSearch(array, target) { |
顺序搜索的时间复杂度为 O(n),其中 n 是列表的长度。
二分搜索
二分搜索(Binary Search)是一种高效的搜索算法,适用于有序数组。该算法通过重复将搜索范围缩小为一半来找到目标值。
1 | function binarySearch(arr, target) { |
二分搜索的时间复杂度为 O(log n),其中 n 是数组的长度。
算法设计思想
分而治之
分而治之(分治法)是一种常见的算法设计思想,其核心是将一个大问题分解成小的子问题,分别解决这些子问题,然后将子问题的解合并起来得到原问题的解。这种思想在很多算法中都有广泛的应用,特别是在解决递归问题时很常见。
基本步骤
- 分解(Divide):将原问题划分成若干个规模较小的子问题。
- 解决(Conquer):递归地解决这些子问题,如果子问题规模足够小,则直接求解。
- 合并(Combine):将子问题的解合并成原问题的解。
使用场景
- 排序算法:如归并排序和快速排序。
- 搜索算法:如二分搜索。
- 数据压缩:如哈夫曼编码。
- 分布式计算:如 MapReduce 等。
分而治之的应用
多数元素
题目来源:LeetCode #169 简单
给定一个大小为 n
的数组 nums
,返回其中的多数元素。多数元素是指在数组中出现次数大于 ⌊ n/2 ⌋
的元素。
你可以假设数组是非空的,并且给定的数组总是存在多数元素。
示例 1:
输入:nums = [3, 2, 3]
输出:3
示例 2:
输入:nums = [2, 2, 1, 1, 1, 2, 2]
输出:2
提示:
n == nums.length
1 <= n <= 5 * 104
-10^9 <= nums[i] <= 10^9
解题步骤:
- 分解:将数组分成左右两部分。
- 解决子问题:递归地在左右两部分中分别找出多数元素。
- 合并:
- 如果左右部分的多数元素相同,则该元素即为整个数组的多数元素。
- 如果左右部分的多数元素不同,则需要统计这两个元素在整个数组中的出现次数,出现次数较多的元素即为多数元素。
代码实现:
1 | function majorityElement(nums) { |
时间复杂度:O(n log n),每次递归将数组分为两部分,类似于归并排序,每层的合并操作需要线性时间,递归深度为 log n,因此总时间复杂度为 O(n log n)。
空间复杂度:O(log n),递归调用栈的深度为 log n,因此空间复杂度为 O(log n)(不包括输入和输出所占用的空间)。
排序数组
题目来源:LeetCode #912 中等
给你一个整数数组 nums
,请你将该数组升序排列。
示例 1:
输入:nums = [5, 2, 3, 1]
输出:[1, 2, 3, 5]
示例 2:
输入:nums = [5, 1, 1, 2, 0, 0]
输出:[0, 0, 1, 1, 2, 5]
要将一个整数数组进行排序,我们可以使用分而治之的思想,这里我们选择归并排序作为实现方法,归并排序是一种稳定的排序算法。
解题步骤:
- 递归终止条件:当数组长度小于等于 1 时,返回数组本身,因为它已经是有序的。
- 分解数组:找到数组的中点,将数组分成左右两部分。
- 递归排序:递归地对左右两部分进行排序。
- 合并:合并两个有序的子数组成一个有序的数组。
代码实现:
1 | function sortArray(nums) { |
- 时间复杂度:O(N log N),因为数组每次都被分成两部分,并且每次合并操作的时间复杂度为 O(N)。
- 空间复杂度:O(N),因为归并排序需要额外的空间来存储合并后的数组。
最大子数组和
题目来源:LeetCode #53 中等
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组是数组中的一个连续部分。
示例 1:
输入:nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
输出:6
解释:连续子数组 [4, -1, 2, 1] 的和最大,为 6。
示例 2:
输入:nums = [1]
输出:1
示例 3:
输入:nums = [5, 4, -1, 7, 8]
输出:23
解决最大子数组和问题,分而治之的思想是一种有效的方法。分而治之的基本思想是将问题分解成子问题,分别解决这些子问题,然后合并这些子问题的解来得到原问题的解。在这个问题中,我们将数组分成两个部分,然后递归地求解左右两部分的最大子数组和,并合并两部分的结果。
解题步骤:
- 递归终止条件:当数组长度为 1 时,返回数组的唯一元素。
- 分解数组:找到数组的中点,将数组分成左右两部分。
- 递归求解:递归地计算左半部分和右半部分的最大子数组和。
- 合并:计算跨越中间的最大子数组和,包括:
- 从中点向左扫描的最大子数组和。
- 从中点向右扫描的最大子数组和。
- 跨越中点的最大子数组和等于左半部分的最大和加上右半部分的最大和。
代码实现:
1 | function maxSubArray(nums) { |
- 时间复杂度:O(n log n):每次分治将问题分成两个子问题,类似于归并排序,每次合并时需要线性的时间来计算跨越中间的最大子数组和。
- 空间复杂度:O(log n):递归调用的深度为 log n,因此空间复杂度为 O(log n)(不包括输入和输出所占用的空间)。
数组中的第 K 个最大元素
题目来源:LeetCode #215 中等
给定整数数组 nums
和整数 k
,请返回数组中第 k
个最大的元素。
请注意,你需要找的是数组排序后的第 k
个最大的元素,而不是第 k
个不同的元素。
你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。
示例 1:
输入: [3, 2, 1, 5, 6, 4], k = 2
输出:5
示例 2:
输入: [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4
输出:4
为了找到数组中的第 k
个最大元素,并且实现时间复杂度为 O(n) 的算法,我们可以使用快速选择算法(Quickselect)。快速选择算法是快速排序的变种,通过分而治之的方法来选择特定的第 k
个元素。
解题步骤:
- 选择一个主元(pivot):通常选择数组的最后一个元素作为主元。
- 分区:使用 Lomuto 分区方案,将数组重新排列,使得主元的位置是其最终位置,同时确保左边的元素都小于等于主元,右边的元素都大于主元。
- 递归搜索:
- 如果主元的位置正好是我们需要找的位置,直接返回主元。
- 如果主元的位置大于目标位置,在左半部分继续搜索。
- 如果主元的位置小于目标位置,在右半部分继续搜索。
代码实现:
1 | function findKthLargest(nums, k) { |
- 时间复杂度:平均情况为 O(n),因为每次分区都会将数组分成两部分。然而,在最坏情况下(例如数组已经有序时),时间复杂度可能达到 O(n^2)。
- 空间复杂度:O(1),因为快速选择是就地排序的算法,不需要额外的空间来存储数组。递归调用的栈空间为 O(log n)。
动态规划
动态规划是一种解决复杂问题的方法,通过将问题分解成更小的子问题,并利用子问题的重叠性,避免重复计算,从而提高效率。动态规划的核心思想是利用已计算的结果来构建解决方案,从而减少不必要的计算。
基本步骤
- 定义子问题:将原问题分解为若干子问题,确定这些子问题的状态和状态之间的转移关系。
- 确定状态转移方程:根据子问题的定义,找出当前状态与之前状态的关系,即状态转移方程。
- 初始化:确定初始状态的值。
- 填表计算:利用状态转移方程,从初始状态出发,逐步计算每个子问题的值,通常使用一个表格(数组)来存储子问题的解。
- 返回结果:根据问题的要求,从表格中提取最终的结果。
使用场景
动态规划主要用于解决以下几类问题:
- 最优化问题:如最短路径、最大子序列和等问题。
- 计数问题:如统计符合某些条件的方案数量。
- 序列问题:如最长递增子序列、最长公共子序列等问题。
- 划分问题:如背包问题、划分等问题。
动态规划的应用
爬楼梯
题目来源:LeetCode #70 简单
假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2 阶 + 1 阶
解题步骤:
- 定义状态:定义一个数组
dp
,其中dp[i]
表示达到第i
阶的方法总数。 - 初始条件:知道
dp[0] = 1
(到达第 0 阶的方法是站在原地)和dp[1] = 1
(到达第 1 阶的方法只有一种)。 - 状态转移方程:为了到达第
i
阶,可以从第i-1
阶迈一步或者从第i-2
阶迈两步,所以dp[i] = dp[i-1] + dp[i-2]
。 - 最终结果:
dp[n]
表示达到第n
阶的方法总数。
代码实现:
1 | function climbStairs(n) { |
- 时间复杂度:O(n),因为只需遍历一次数组。
- 空间复杂度:O(n),需要一个长度为
n+1
的数组来存储每一阶的方法数。
最长递增子序列
题目来源:LeetCode #300 中等
给你一个整数数组 nums
,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3, 6, 2, 7]
是数组 [0, 3, 1, 6, 2, 2, 7]
的子序列。
示例 1:
输入:nums = [10, 9, 2, 5, 3, 7, 101, 18]
输出:4
解释:最长递增子序列是 [2, 3, 7, 101],因此长度为 4。
示例 2:
输入:nums = [0, 1, 0, 3, 2, 3]
输出:4
示例 3:
输入:nums = [7, 7, 7, 7, 7, 7, 7]
输出:1
解题步骤:
- 定义状态:定义一个数组
dp
,其中dp[i]
表示以nums[i]
结尾的最长递增子序列的长度。 - 初始化:每个位置的初始值为 1,因为每个位置都至少可以是一个长度为 1 的子序列。
- 状态转移方程:对于每个
nums[i]
,遍历其之前的元素nums[j]
(0 ≤ j < i),如果nums[i] > nums[j]
,则更新dp[i] = max(dp[i], dp[j] + 1)
,表示在nums[j]
的子序列上追加nums[i]
。 - 最终结果:数组
dp
中的最大值即为最长递增子序列的长度。
代码实现:
1 | function lengthOfLIS(nums) { |
- 时间复杂度:O(n^2),因为我们需要嵌套循环遍历每个元素对。
- 空间复杂度:O(n),需要一个长度为
n
的数组来存储每个位置的最长子序列长度。
打家劫舍
题目来源:LeetCode #198 中等
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1, 2, 3, 1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4。
示例 2:
输入:[2, 7, 9, 3, 1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12。
解题步骤:
- 定义状态:定义一个数组
dp
,其中dp[i]
表示到达第i
个房子时可以偷窃到的最高金额。 - 初始条件:如果只有一个房子,那么可以偷窃的最高金额就是该房子的金额,即
dp[0] = nums[0]
。如果有两个房子,则可以偷窃的最高金额是这两个房子中金额较大的那个,即dp[1] = Math.max(nums[0], nums[1])
。 - 状态转移方程:对于每个房子
i
,有两种选择:偷窃该房子(然后加上i-2
房子的最高金额)或者不偷窃该房子(直接取i-1
房子的最高金额)。状态转移方程为dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i])
。 - 最终结果:数组
dp
中的最后一个值即为可以偷窃到的最高金额。
代码实现:
1 | function rob(nums) { |
- 时间复杂度:O(n),因为需要遍历一次数组。
- 空间复杂度:O(n),因为需要一个长度为
n
的数组来存储每个位置的最高金额。
优化空间复杂度:
注意到我们在状态转移时,只需要前两个状态的值,所以可以将空间复杂度优化为 O(1)。
1 | function rob(nums) { |
优化后的算法分析:
- 时间复杂度:O(n),因为需要遍历一次数组。
- 空间复杂度:O(1),因为只需要常量空间来存储前两个状态的值。
通过上述方法,我们可以有效地计算出不触动警报装置的情况下,可以偷窃到的最高金额。优化后的代码在空间复杂度上更高效。
零钱兑换
题目来源:LeetCode #322 中等
给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
示例 3:
输入:coins = [1], amount = 0
输出:0
解题步骤:
- 定义状态:定义一个数组
dp
,其中dp[i]
表示凑成金额i
所需的最少硬币个数。 - 初始化:
dp[0] = 0
,因为凑成金额0
所需的硬币数是0
。其他dp[i]
初始化为一个较大的值(如Infinity
),表示还没有计算出结果。 - 状态转移方程:对于每个金额
i
,尝试使用每种硬币coin
,更新dp[i] = Math.min(dp[i], dp[i - coin] + 1)
,表示从金额i - coin
加上一个coin
的硬币数量。 - 最终结果:如果
dp[amount]
仍然是初始值Infinity
,则表示无法凑成该金额,返回-1
,否则返回dp[amount]
。
代码实现:
1 | function coinChange(coins, amount) { |
- 时间复杂度:O(n * m),其中
n
是金额amount
,m
是硬币种类数。 - 空间复杂度:O(n),需要一个长度为
amount + 1
的数组来存储每个金额的最少硬币数。
贪心算法
贪心算法是一种在每一步选择中都采取在当前状态下最好或最优的选择,从而希望导致结果是全局最优的算法。贪心算法的核心是贪心选择性质,即每一步的局部最优选择最终能够导致全局最优解。
基本步骤
- 建立数学模型:将问题抽象为数学模型,明确所需的解和约束条件。
- 选择贪心策略:根据问题的特性,选择一个贪心策略,即在每一步选择中,采取局部最优的选择。
- 证明贪心选择的正确性:证明所选的贪心策略能够得到问题的最优解,通常通过数学归纳法或反证法证明。
- 实施贪心算法:从初始状态开始,按照贪心策略逐步推进,直到达到问题的约束条件或无法继续推进为止。
- 构造解:根据选择的步骤,构造出问题的解。
使用场景
贪心算法通常用于以下几类问题:
- 最优化问题:如最小生成树、最短路径等问题。
- 活动选择问题:如区间调度、任务安排等问题。
- 资源分配问题:如背包问题的某些变种、最大子序列和等问题。
- 图论问题:如 Dijkstra 算法求最短路径,Kruskal 算法和 Prim 算法求最小生成树。
贪心算法的应用
分发饼干
题目来源:LeetCode #455 简单
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。
对每个孩子 i
,都有一个胃口值 g[i]
,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j
,都有一个尺寸 s[j]
。如果 s[j] >= g[i]
,我们可以将这个饼干 j
分配给孩子 i
,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
示例 1:
输入: g = [1, 2, 3], s = [1, 1]
输出:1
解释:
你有三个孩子和两块小饼干,3 个孩子的胃口值分别是:1, 2, 3。
虽然你有两块小饼干,由于他们的尺寸都是 1,你只能让胃口值是 1 的孩子满足。
所以你应该输出 1。
示例 2:
输入: g = [1, 2], s = [1, 2, 3]
输出:2
解释:
你有两个孩子和三块小饼干,2 个孩子的胃口值分别是 1, 2。
你拥有的饼干数量和尺寸都足以让所有孩子满足。
所以你应该输出 2。
先对孩子的满足度和饼干的大小排序,然后依次为每个孩子分配满足其满足度的最小饼干。
解题步骤:
- 排序:将孩子的胃口数组
g
和饼干尺寸数组s
分别进行排序。 - 匹配:使用两个指针,一个指向孩子的胃口数组,另一个指向饼干尺寸数组。依次尝试用当前最小的饼干去满足当前最小的胃口。
- 更新指针:如果当前饼干可以满足当前孩子的胃口,两个指针都移动到下一个。如果不能,则只移动饼干的指针,尝试用下一个较大的饼干去满足当前孩子的胃口。
- 结束条件:当两个指针都到达数组末尾时,匹配结束,返回满足孩子的数量。
代码实现:
1 | function findContentChildren(g, s) { |
- 时间复杂度:O(n log n + m log m),其中
n
是孩子数组的长度,m
是饼干数组的长度。这是因为排序需要 O(n log n) 和 O(m log m)。 - 空间复杂度:O(1),只需要常量级别的额外空间。
柠檬水找零
题目来源:LeetCode #860 简单
在柠檬水摊上,每一杯柠檬水的售价为 5
美元。顾客排队购买你的产品(按账单 bills
支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5
美元、10
美元或 20
美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5
美元。
注意,一开始你手头没有任何零钱。
给你一个整数数组 bills
,其中 bills[i]
是第 i
位顾客付的账。如果你能给每位顾客正确找零,返回 true
,否则返回 false
。
示例 1:
输入:bills = [5, 5, 5, 10, 20]
输出:true
解释:
前 3 位顾客那里,我们按顺序收取 3 张 5 美元的钞票。
第 4 位顾客那里,我们收取一张 10 美元的钞票,并返还 5 美元。
第 5 位顾客那里,我们找还一张 10 美元的钞票和一张 5 美元的钞票。
由于所有客户都得到了正确的找零,所以我们输出 true。
示例 2:
输入:bills = [5, 5, 10, 10, 20]
输出:false
解释:
前 2 位顾客那里,我们按顺序收取 2 张 5 美元的钞票。
对于接下来的 2 位顾客,我们收取一张 10 美元的钞票,然后返还 5 美元。
对于最后一位顾客,我们无法退回 15 美元,因为我们现在只有两张 10 美元的钞票。
由于不是每位顾客都得到了正确的找零,所以答案是 false。
遍历顾客给的钱,优先使用手中的大额钞票找零,从而保留小额钞票以应对后续的找零需求。
解题步骤:
- 初始化钱箱:使用两个变量
five
和ten
来分别表示手中拥有的 5 美元和 10 美元的数量,初始值为 0。 - 遍历账单:遍历顾客付的每一张账单。
- 处理账单:
- 如果顾客付的是 5 美元,直接收下。
- 如果顾客付的是 10 美元,需要找零,检查是否有足够的 5 美元找零,如果有则找零,否则无法找零,返回 false。
- 如果顾客付的是 20 美元,需要找零,优先使用 10 美元找零,然后再用 5 美元找零,如果都无法找零,则返回 false。
- 返回结果:遍历结束后,如果能够给每个顾客正确找零,则返回 true,否则返回 false。
代码实现:
1 | function lemonadeChange(bills) { |
- 时间复杂度:O(n),其中 n 是账单的数量,我们需要遍历一次账单数组。
- 空间复杂度:O(1),只需要常数级别的额外空间来存储 5 美元和 10 美元的数量。
跳跃游戏
题目来源:LeetCode #55 中等
给你一个非负整数数组 nums
,你最初位于数组的第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 true
;否则,返回 false
。
示例 1:
输入:nums = [2, 3, 1, 1, 4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
示例 2:
输入:nums = [3, 2, 1, 0, 4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0,所以永远不可能到达最后一个下标。
从前往后遍历数组,维护当前能够到达的最远位置,如果在某一步能够到达或超过数组的最后一个位置,则返回 true
。
解题步骤:
- 初始化:定义一个变量
maxReach
,表示当前能够到达的最远位置,初始值为0
。 - 遍历数组:从头到尾遍历数组的每个位置,检查当前位置是否能够到达。如果当前位置大于
maxReach
,说明不能到达当前位置,返回false
。 - 更新最远可达位置:如果当前位置在可达范围内,更新
maxReach
为max(maxReach, i + nums[i])
。 - 检查是否可达:如果在遍历过程中,
maxReach
大于或等于数组的最后一个下标,返回true
。
代码实现:
1 | function canJump(nums) { |
canJump
函数:主函数,判断是否能够到达最后一个下标。- **初始化
maxReach
**:定义变量maxReach
表示当前能够到达的最远位置,初始值为0
。 - 遍历数组:
- 对于每个位置
i
,检查是否超过了maxReach
。如果是,返回false
,表示不能到达该位置。 - 否则,更新
maxReach
为max(maxReach, i + nums[i])
,表示当前能够到达的最远位置。
- 对于每个位置
- 检查终止条件:如果
maxReach
已经大于或等于数组的最后一个下标,返回true
。 - 返回结果:遍历结束后,如果没有返回
true
,则返回false
。
- 时间复杂度:O(n),因为我们需要遍历一次数组。
- 空间复杂度:O(1),只需要常量级别的额外空间。
无重叠区间
题目来源:LeetCode #435 中等
给定一个区间的集合 intervals
,其中 intervals[i] = [starti, endi]
。返回需要移除区间的最小数量,使剩余区间互不重叠。
示例 1:
输入: intervals = [[1, 2], [2, 3], [3, 4], [1, 3]]
输出:1
解释: 移除 [1, 3] 后,剩下的区间没有重叠。
示例 2:
输入: intervals = [[1, 2], [1,2], [1,2]]
输出:2
解释: 你需要移除两个 [1, 2] 来使剩下的区间没有重叠。
示例 3:
输入: intervals = [[1, 2], [2, 3]]
输出:0
解释:你不需要移除任何区间,因为它们已经是无重叠的了。
先按照区间的结束时间排序,然后依次选择结束时间最早且不与前一个选择的区间重叠的区间。对于这个问题,我们要尽可能多地保留区间,从而使得需要移除的区间数量最小。
解题步骤:
- 排序:首先将区间按照结束时间
end
进行排序。这样可以保证每次选择的区间结束时间尽可能早,以便留出更多的空间给后面的区间。 - 贪心选择:使用一个变量
end
来记录上一个选择的区间的结束时间。初始化end
为负无穷大。 - 遍历区间:依次遍历排序后的区间,如果当前区间的起始时间
start
大于等于end
,说明这个区间可以保留,同时更新end
为当前区间的结束时间end
。否则,这个区间需要移除。 - 统计结果:遍历结束后,计算需要移除的区间数量。
代码实现:
1 | function eraseOverlapIntervals(intervals) { |
eraseOverlapIntervals
函数:主函数,计算需要移除的区间数量。- 排序:将区间按照结束时间进行排序,使得每次选择的区间结束时间尽可能早。
- 初始化变量:
count
用于记录需要移除的区间数量,end
初始化为负无穷大。 - 遍历区间:
- 如果当前区间的起始时间
start
大于等于end
,说明这个区间可以保留,并更新end
为当前区间的结束时间finish
。 - 否则,这个区间与上一个区间重叠,需要移除,增加
count
计数器。
- 如果当前区间的起始时间
- 返回结果:遍历结束后,返回需要移除的区间数量
count
。
- 时间复杂度:O(n log n),因为我们需要对区间进行排序。
- 空间复杂度:O(1),不需要额外的空间,除了用于存储输入的区间列表。
分发糖果
题目来源:LeetCode #135 困难
n
个孩子站成一排。给你一个整数数组 ratings
表示每个孩子的评分。
你需要按照以下要求,给这些孩子分发糖果:
- 每个孩子至少分配到
1
个糖果。 - 相邻两个孩子评分更高的孩子会获得更多的糖果。
请你给每个孩子分发糖果,计算并返回需要准备的最少糖果数目。
示例 1:
输入:ratings = [1, 0, 2]
输出:5
解释:你可以分别给第一个、第二个、第三个孩子分发 2、1、2 颗糖果。
示例 2:
输入:ratings = [1, 2, 2]
输出:4
解释:你可以分别给第一个、第二个、第三个孩子分发 1、2、1 颗糖果。
第三个孩子只得到 1 颗糖果,这满足题面中的两个条件。
先从左到右扫描数组,确保右边的评分更高的孩子获得更多糖果;再从右到左扫描数组,确保左边的评分更高的孩子获得更多糖果。
解题步骤:
- 初始化:创建一个数组
candies
,初始化每个孩子的糖果数为 1,表示每个孩子至少有一个糖果。 - 从左到右遍历:检查每个孩子与前一个孩子的评分,如果当前孩子的评分比前一个孩子高,则更新当前孩子的糖果数为
candies[i-1] + 1
。 - 从右到左遍历:检查每个孩子与后一个孩子的评分,如果当前孩子的评分比后一个孩子高且糖果数不大于后一个孩子,则更新当前孩子的糖果数为
candies[i+1] + 1
。 - 计算总糖果数:遍历
candies
数组,求和得到最少需要的糖果数。
代码实现:
1 | function candy(ratings) { |
candy
函数:主函数,计算最少需要的糖果数。- 初始化
candies
数组:每个孩子至少分配 1 个糖果。 - 从左到右遍历:
- 如果当前孩子的评分高于前一个孩子,则当前孩子的糖果数等于前一个孩子的糖果数加 1。
- 从右到左遍历:
- 如果当前孩子的评分高于后一个孩子且糖果数不大于后一个孩子,则更新当前孩子的糖果数为
candies[i + 1] + 1
。
- 如果当前孩子的评分高于后一个孩子且糖果数不大于后一个孩子,则更新当前孩子的糖果数为
- 计算总糖果数:通过遍历
candies
数组求和得到最少需要的糖果数。
- 时间复杂度:O(n),因为我们需要遍历两次数组,每次遍历的时间复杂度都是 O(n)。
- 空间复杂度:O(n),因为我们需要额外的数组
candies
来存储每个孩子的糖果数。
回溯算法
回溯算法是一种通过逐步构建解决方案的方法,当遇到某一步无法继续前进时,回溯算法会回退到上一步,尝试其他的选择,直到找到问题的解决方案或确定无解。回溯算法通常通过深度优先搜索的方式实现。
基本步骤
- 选择决策树:将问题抽象成一个决策树,每个节点代表一个决策点。
- 深度优先搜索:从根节点开始,采用深度优先搜索的方式探索决策树的所有分支。
- 做出选择:在每个节点处,根据问题的限制条件,做出一个选择。
- 检查约束条件:检查当前选择是否满足问题的约束条件,如果满足则继续探索,否则回溯到上一步。
- 标记路径:在探索过程中,记录已经探索过的路径,避免重复探索。
- 撤销选择:在回溯时,撤销当前节点的选择,回到上一层继续探索其他分支。
- 判断终止条件:当到达叶子节点或者无法继续探索时,判断是否找到了问题的解决方案。
使用场景
回溯算法通常用于以下几类问题:
- 组合问题:如组合总和、组合总和 II 等问题。
- 排列问题:如全排列、字符串的全排列等问题。
- 搜索问题:如解数独、N 皇后问题等。
- 子集问题:如子集、子集 II 等问题。
回溯算法的应用
全排列
题目来源:LeetCode #46 中等
给定一个不含重复数字的数组 nums
,返回其所有可能的全排列。你可以按任意顺序返回答案。
示例 1:
输入:nums = [1, 2, 3]
输出:[[1, 2, 3], [1, 3, 2], [2, 1 ,3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
示例 2:
输入:nums = [0, 1]
输出:[[0, 1], [1, 0]]
示例 3:
输入:nums = [1]
输出:[[1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums
中的所有整数互不相同
解题步骤:
- 初始化结果集:创建一个结果数组
result
来存储所有的排列。 - 回溯函数:定义一个回溯函数
backtrack
,参数为当前路径path
和剩余可选择的数字choices
。 - 终止条件:当路径长度等于输入数组长度时,表明我们找到了一种排列,将其加入结果集。
- 选择和探索:遍历剩余可选择的数字,将每个数字加入当前路径,并递归调用回溯函数,传递更新后的路径和剩余可选择的数字。
- 回溯:在递归调用结束后,撤销上一步选择,进行下一轮选择和探索。
代码实现:
1 | function permute(nums) { |
permute
函数:主函数,接收输入数组nums
,返回所有的排列。- 初始化结果集:创建一个空数组
result
来存储所有的排列结果。 backtrack
函数:递归函数,构建排列。参数path
表示当前路径,choices
表示当前剩余的可选择数字。- 终止条件:当路径长度等于输入数组长度时,将当前路径加入结果集。
- 选择和探索:遍历剩余可选择的数字,将每个数字加入当前路径,递归调用
backtrack
函数,并传递更新后的路径和剩余可选择的数字。 - 回溯:在递归调用结束后,撤销上一步选择,通过
path.pop()
将最后一个元素移除,进行下一轮选择和探索。
- 时间复杂度:O(n * n!),其中 n 是输入数组的长度。总共有 n! 种排列,每种排列需要 O(n) 的时间来构建。
- 空间复杂度:O(n),用于存储递归调用栈和临时路径。
子集
题目来源:LeetCode 78 中等
给你一个整数数组 nums
,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。
解集不能包含重复的子集。你可以按任意顺序返回解集。
示例 1:
输入:nums = [1, 2, 3]
输出:[[], [1], [2], [1,2], [3], [1, 3], [2, 3], [1, 2, 3]]
示例 2:
输入:nums = [0]
输出:[[], [0]]
提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums
中的所有元素互不相同
解题步骤:
- 初始化结果集:创建一个结果数组
result
来存储所有的子集。 - 回溯函数:定义一个回溯函数
backtrack
,参数为当前路径path
和起始索引start
。 - 添加当前路径到结果集:将当前路径
path
的拷贝加入结果集result
。 - 选择和探索:从起始索引开始遍历
nums
数组,将每个数字加入当前路径,并递归调用回溯函数,传递更新后的路径和新的起始索引。 - 回溯:在递归调用结束后,撤销上一步选择,进行下一轮选择和探索。
代码实现:
1 | function subsets(nums) { |
subsets
函数:主函数,接收输入数组nums
,返回所有的子集。- 初始化结果集:创建一个空数组
result
来存储所有的子集结果。 backtrack
函数:递归函数,构建子集。参数path
表示当前路径,start
表示起始索引。- 添加当前路径到结果集:将当前路径
path
的拷贝加入结果集result
。 - 选择和探索:从起始索引开始遍历
nums
数组,将每个数字加入当前路径path
,递归调用backtrack
函数,并传递更新后的路径和新的起始索引i + 1
。 - 回溯:在递归调用结束后,撤销上一步选择,通过
path.pop()
将最后一个元素移除,进行下一轮选择和探索。
- 时间复杂度:O(n * 2^n),其中 n 是输入数组的长度。总共有 2^n 个子集,每个子集的平均长度为 n/2。
- 空间复杂度:O(n),用于存储递归调用栈和临时路径。
单词拆分 II
题目来源:LeetCode #140 困难
给定一个字符串 s
和一个字符串字典 wordDict
,在字符串 s
中增加空格来构建一个句子,使得句子中所有的单词都在词典中。以任意顺序返回所有这些可能的句子。
注意:词典中的同一个单词可能在分段中被重复使用多次。
示例 1:
输入:s = “catsanddog”, wordDict = [“cat”, “cats”, “and”, “sand”, “dog”]
输出:[“cats and dog”, “cat sand dog”]
示例 2:
输入:s = “pineapplepenapple”, wordDict = [“apple”, “pen”, “applepen”, “pine”, “pineapple”]
输出:[“pine apple pen apple”, “pineapple pen apple”, “pine applepen apple”]
解释:注意你可以重复使用字典中的单词。
示例 3:
输入:s = “catsandog”, wordDict = [“cats”, “dog”, “sand”, “and”, “cat”]
输出:[]
提示:
1 <= s.length <= 20
1 <= wordDict.length <= 1000
1 <= wordDict[i].length <= 10
s
和wordDict[i]
仅有小写英文字母组成wordDict
中所有字符串都不同
要解决这个问题,我们可以使用回溯算法结合动态规划进行优化。具体来说,我们需要递归地尝试在字符串 s
中插入空格来构成单词,同时使用缓存来存储已经计算过的结果,避免重复计算。
解题步骤:
- 定义缓存:使用一个对象
memo
来存储已经计算过的子问题的结果。 - 定义回溯函数:递归函数
backtrack
,参数为当前子字符串s
。 - 递归终止条件:如果当前子字符串
s
在缓存中,直接返回缓存中的结果;如果s
为空字符串,返回包含一个空字符串的数组。 - 遍历匹配:遍历词典中的单词,如果当前子字符串
s
以该单词开头,则递归处理剩余部分的字符串,并将结果组合起来。 - 更新缓存:将当前子字符串的结果存入缓存中,并返回该结果。
代码实现:
1 | function wordBreak(s, wordDict) { |
wordBreak
函数:主函数,接收字符串s
和词典wordDict
,返回所有可能的句子。- 初始化:将词典转化为集合
wordSet
,用于快速查找;定义缓存memo
。 backtrack
函数:递归函数,处理当前子字符串s
,返回其所有可能的句子组合。- 缓存查询:如果当前子字符串
s
在缓存中,直接返回缓存中的结果。 - 递归终止条件:如果子字符串
s
为空,返回包含一个空字符串的数组。 - 遍历词典:对于每个单词,如果当前子字符串
s
以该单词开头,则递归处理剩余部分的字符串s.slice(word.length)
。 - 组合结果:将当前单词和递归结果组合成新的句子,并添加到结果集中。
- 更新缓存:将当前子字符串
s
的结果存入缓存memo
,避免重复计算。 - 返回结果:主函数调用
backtrack
函数,返回最终结果。
- 时间复杂度:最坏情况下为 O(n^2 * k),其中 n 是字符串
s
的长度,k 是词典wordDict
的大小。每个子字符串的计算会涉及到对词典的遍历,并且需要组合结果。 - 空间复杂度:O(n^2),用于缓存子字符串的结果和存储递归栈。
最后
感谢大家看到最后,本文篇幅较长,难免会有错误,还望同学们多指正。看完本文后还没能理解透算法实现原理的同学,也不用灰心,掌握算法不是一朝半夕的事,勤加练习才能突破。
另外,作者组建了氛围特别好的前端交流群 & 自由程序猿交流群,欢迎同学们一起来交流吐槽。由于群人数较多,需要添加作者才能邀请进群。