算法
参考链接
复杂度分析
迭代
迭代(iteration)是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。
for循环
for 循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用。
以下函数基于 for 循环实现了求和 1+2+⋯+𝑛 ,求和结果使用变量 res 记录。需要注意的是,Python 中 range(a, b) 对应的区间是“左闭右开”的,对应的遍历范围为 𝑎,𝑎+1,…,𝑏−1 :
1 | /* for 循环 */ |

此求和函数的操作数量与输入数据大小 𝑛 成正比,或者说成“线性关系”。实际上,时间复杂度描述的就是这个“线性关系”。
while循环
与 for 循环类似,while 循环也是一种实现迭代的方法。在 while 循环中,程序每轮都会先检查条件,如果条件为真,则继续执行,否则就结束循环。
下面我们用 while 循环来实现求和 1+2+⋯+𝑛 :
1 | /* while 循环 */ |
while 循环比 for 循环的自由度更高。在 while 循环中,我们可以自由地设计条件变量的初始化和更新步骤。
例如在以下代码中,条件变量 𝑖 每轮进行两次更新,这种情况就不太方便用 for 循环实现:
1 | /* while 循环(两次更新) */ |
总的来说,for 循环的代码更加紧凑,while 循环更加灵活,两者都可以实现迭代结构。选择使用哪一个应该根据特定问题的需求来决定。
嵌套循环
我们可以在一个循环结构内嵌套另一个循环结构,下面以 for 循环为例:
1 | /* 双层 for 循环 */ |

在这种情况下,函数的操作数量与 $n^2$ 成正比,或者说算法运行时间和输入数据大小 𝑛 成“平方关系”。
我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方关系”,以此类推。
递归
递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
- 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
- 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
而从实现的角度看,递归代码主要包含三个要素。
- 终止条件:用于决定什么时候由“递”转“归”。
- 递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
- 返回结果:对应“归”,将当前递归层级的结果返回至上一层。
观察以下代码,我们只需调用函数 recur(n) ,就可以完成 1+2+⋯+𝑛 的计算:
1 | /* 递归 */ |

虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式。
- 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
- 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
以上述求和函数为例,设问题 𝑓(𝑛)=1+2+⋯+𝑛 。
- 迭代:在循环中模拟求和过程,从 1 遍历到 𝑛 ,每轮执行求和操作,即可求得 𝑓(𝑛) 。
- 递归:将问题分解为子问题 𝑓(𝑛)=𝑛+𝑓(𝑛−1) ,不断(递归地)分解下去,直至基本情况 𝑓(1)=1 时终止。
调用栈
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间。
- 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
在触发终止条件前,同时存在 𝑛 个未返回的递归函数,递归深度为 𝑛 。

在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。
尾递归
有趣的是,如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。
- 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
- 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
以计算 1+2+⋯+𝑛 为例,我们可以将结果变量 res 设为函数参数,从而实现尾递归:
1 | /* 尾递归 */ |
对比普通递归和尾递归,两者的求和操作的执行点是不同的。
- 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
- 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。

[!tip]
请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题。
递归树
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。
问题:
给定一个斐波那契数列0,1,2,3,4…,求该数列的第n个数字
设斐波那契数列的第 𝑛 个数字为 𝑓(𝑛) ,易得两个结论。
- 数列的前两个数字为 𝑓(1)=0 和 𝑓(2)=1 。
- 数列中的每个数字是前两个数字的和,即 𝑓(𝑛)=𝑓(𝑛−1)+𝑓(𝑛−2) 。
按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n) 即可得到斐波那契数列的第 𝑛 个数字:
1 | /* 斐波那契数列:递归 */ |
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。这样不断递归调用下去,最终将产生一棵层数为 𝑛 的递归树(recursion tree)。

从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。
- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。
时间复杂度
运行时间可以直观且准确地反映算法的效率。如果我们想准确预估一段代码的运行时间,应该如何操作呢?
- 确定运行平台,包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。
- 评估各种计算操作所需的运行时间,例如加法操作
+需要 1 ns ,乘法操作*需要 10 ns ,打印操作print()需要 5 ns 等。 - 统计代码中所有的计算操作,并将所有操作的执行时间求和,从而得到运行时间。
例如在以下代码中,输入数据大小为 𝑛 :
1 | // 在某运行平台下 |
根据以上方法,可以得到算法的运行时间为 (6𝑛+12) ns :
$$
1+1+10+(1+5)×𝑛=6𝑛+12
$$
但实际上,统计算法的运行时间既不合理也不现实。首先,我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。
统计时间增长趋势
时间复杂度分析统计的不是算法运行时间,==而是算法运行时间随着数据量变大时的增长趋势==。
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 𝑛 ,给定三个算法 A、B 和 C :
1 | // 算法 A 的时间复杂度:常数阶 |
三个函数的时间复杂度为:
- 算法
A只有 1 个打印操作,算法运行时间不随着 𝑛 增大而增长。我们称此算法的时间复杂度为“常数阶”。 - 算法
B中的打印操作需要循环 𝑛 次,算法运行时间随着 𝑛 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。 - 算法
C中的打印操作需要循环 1000000 次,虽然运行时间很长,但它与输入数据大小 𝑛 无关。因此C的时间复杂度和A相同,仍为“常数阶”。

相较于直接统计算法的运行时间,时间复杂度分析有哪些特点呢?
- 时间复杂度能够有效评估算法效率。例如,算法
B的运行时间呈线性增长,在 𝑛>1 时比算法A更慢,在 𝑛>1000000 时比算法C更慢。事实上,只要输入数据大小 𝑛 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势的含义。 - 时间复杂度的推算方法更简便。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。
- 时间复杂度也存在一定的局限性。例如,尽管算法
A和C的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法B的时间复杂度比C高,但在输入数据大小 𝑛 较小时,算法B明显优于算法C。对于此类情况,我们时常难以仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
函数渐进上界
给定一个输入大小为n的函数:
1 | void algorithm(int n) { |
设算法的操作数量是一个关于输入数据大小 𝑛 的函数,记为 𝑇(𝑛) ,则以上函数的操作数量为:
$$
𝑇(𝑛)=3+2𝑛
$$
𝑇(𝑛) 是一次函数,说明其运行时间的增长趋势是线性的,因此它的时间复杂度是线性阶。
我们将线性阶的时间复杂度记为 𝑂(𝑛) ,这个数学符号称为大 𝑂 记号(big-𝑂 notation),表示函数 𝑇(𝑛) 的渐近上界(asymptotic upper bound)。
时间复杂度分析本质上是计算“操作数量 𝑇(𝑛)”的渐近上界,它具有明确的数学定义。
[!TIP]
若存在正实数 𝑐 和实数 𝑛~0~ ,使得对于所有的 𝑛>𝑛~0~ ,均有 𝑇(𝑛)≤𝑐⋅𝑓(𝑛) ,则可认为 𝑓(𝑛) 给出了 𝑇(𝑛) 的一个渐近上界,记为 𝑇(𝑛)=𝑂(𝑓(𝑛)) 。
计算渐近上界就是寻找一个函数 𝑓(𝑛) ,使得当 𝑛 趋向于无穷大时,𝑇(𝑛) 和 𝑓(𝑛) 处于相同的增长级别,仅相差一个常数项 𝑐 的倍数。

推算方法
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。我们可以先掌握推算方法,在不断的实践中,就可以逐渐领悟其数学意义。
根据定义,确定 𝑓(𝑛) 之后,我们便可得到时间复杂度 𝑂(𝑓(𝑛)) 。那么如何确定渐近上界 𝑓(𝑛) 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。
统计操作数量
针对代码,逐行从上到下计算即可。然而,由于上述 𝑐⋅𝑓(𝑛) 中的常数项 𝑐 可以取任意大小,因此操作数量 𝑇(𝑛) 中的各种系数、常数项都可以忽略。根据此原则,可以总结出以下计数简化技巧。
- 忽略 𝑇(𝑛) 中的常数项。因为它们都与 𝑛 无关,所以对时间复杂度不产生影响。
- 省略所有系数。例如,循环 2𝑛 次、5𝑛+1 次等,都可以简化记为 𝑛 次,因为 𝑛 前面的系数对时间复杂度没有影响。
- 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第
1.点和第2.点的技巧。
给定一个函数,我们可以用上述技巧来统计操作数量:
1 | void algorithm(int n) { |
以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为 $O(n^2)$ 。
$$
T(n)=2n(n+1)+(5n+1)+2=2n^2+7n+3\
T(n)=n^2+n
$$
判断渐近上界
时间复杂度由 𝑇(𝑛) 中最高阶的项来决定。这是因为在 𝑛 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
下表展示了一些栗子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 𝑛 趋于无穷大时,这些常数变得无足轻重。
| 操作数量T(n) | 时间复杂度 O(f(n)) |
|---|---|
| 100000 | O(1) |
| 3n+2 | O(n) |
| 2$n^2$+ 3n + 2 | O($n^2$) |
| $n^2$+10000$n^2$ | O(n) |
| $2^n+10000n^{10000}$ | O($2^n$) |
常见类型
设输入数据大小为 𝑛 ,常见的时间复杂度类型如下(按照从低到高的顺序排列)。
$$
O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(2^n)<O(n!)\
常数阶<对数阶<线性阶<线性对数阶<平方阶<指数阶<阶乘阶
$$

常数阶
常数阶的操作数量与输入数据大小 𝑛 无关,即不随着 𝑛 的变化而变化。
在以下函数中,尽管操作数量 size 可能很大,但由于其与输入数据大小 𝑛 无关,因此时间复杂度仍为 𝑂(1) :
1 | /* 常数阶 */ |
线性阶
线性阶的操作数量相对于输入数据大小 𝑛 以线性级别增长。线性阶通常出现在单层循环中:
1 | /* 线性阶 */ |
遍历数组和遍历链表等操作的时间复杂度均为 𝑂(𝑛) ,其中 𝑛 为数组或链表的长度:
1 | /* 线性阶(遍历数组) */ |
[!note]
值得注意的是,输入数据大小 𝑛 需根据输入数据的类型来具体确定。比如在第一个示例中,变量 𝑛 为输入数据大小;在第二个示例中,数组长度 𝑛 为数据大小。
平方阶
平方阶的操作数量相对于输入数据大小 𝑛 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 𝑂(𝑛) ,因此总体的时间复杂度为 𝑂($n^2$) :
1 | /* 平方阶 */ |

以冒泡排序为例,外层循环执行 𝑛−1 次,内层循环执行 𝑛−1、𝑛−2、…、2、1 次,平均为 𝑛/2 次,因此时间复杂度为 $𝑂((𝑛−1)𝑛/2)=𝑂(𝑛^2)$ :
1 | /* 平方阶(冒泡排序) */ |
指数阶
生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为 1 个细胞,分裂一轮后变为 2 个,分裂两轮后变为 4 个,以此类推,分裂 𝑛 轮后有 $2^𝑛$ 个细胞。
以下代码模拟了细胞分裂的过程,时间复杂度为 𝑂($2^n$) :
1 | /* 指数阶(循环实现) */ |

在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 𝑛 次分裂后停止:
1 | /* 指数阶(递归实现) */ |
[!important]
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
对数阶
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 𝑛 ,由于每轮缩减到一半,因此循环次数是 $log _2𝑛$ ,即$2^n$的反函数。
以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 𝑂($log _2𝑛$) ,简记为 𝑂(log𝑛) :
1 | /* 对数阶(循环实现) */ |

与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一棵高度为 $log _2𝑛 $的递归树:
1 | /* 对数阶(递归实现) */ |
对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。
[!note]
$O(log n)$的底数是多少?
准确来说,“一分为 𝑚”对应的时间复杂度是$ 𝑂(log _𝑚𝑛)$ 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度:
$$
𝑂(log_𝑚𝑛)=𝑂(log_𝑘𝑛/log_𝑘𝑚)=𝑂(log_𝑘𝑛)
$$
也就是说,底数 𝑚 可以在不影响复杂度的前提下转换。因此我们通常会省略底数 𝑚 ,将对数阶直接记为 𝑂(log𝑛) 。
线性对数阶
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 𝑂(log𝑛) 和 𝑂(𝑛) 。相关代码如下:
1 | /* 线性对数阶 */ |
下图展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为 𝑛 ,树共有$ log_2𝑛+1 $层,因此时间复杂度为 𝑂(𝑛log𝑛) 。

主流排序算法的时间复杂度通常为 𝑂(𝑛log𝑛) ,例如快速排序、归并排序、堆排序等。
阶乘阶
阶乘阶对应数学上的“全排列”问题。给定 𝑛 个互不重复的元素,求其所有可能的排列方案,方案数量为:
$$
𝑛!=𝑛×(𝑛−1)×(𝑛−2)×⋯×2×1
$$
阶乘通常使用递归实现。第一层分裂出 𝑛 个,第二层分裂出 𝑛−1 个,以此类推,直至第 𝑛 层时停止分裂:
1 | /* 阶乘阶(递归实现) */ |

请注意,因为当 𝑛≥4 时恒有 𝑛!>2𝑛 ,所以阶乘阶比指数阶增长得更快,在 𝑛 较大时也是不可接受的。
算法的时间效率往往不是固定的,而是与输入数据的分布有关。假设输入一个长度为 𝑛 的数组 nums ,其中 nums 由从 1 至 𝑛 的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 1 的索引。我们可以得出以下结论。
- 当
nums = [?, ?, ..., 1],即当末尾元素是 1 时,需要完整遍历数组,达到最差时间复杂度 𝑂(𝑛) 。 - 当
nums = [1, ?, ?, ...],即当首个元素为 1 时,无论数组多长都不需要继续遍历,达到最佳时间复杂度 Ω(1) 。
“最差时间复杂度”对应函数渐近上界,使用大 𝑂 记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用 Ω 记号表示:
最差、最佳、平均时间复杂度
算法的时间效率往往不是固定的,而是与输入数据的分布有关。假设输入一个长度为 𝑛 的数组 nums ,其中 nums 由从 1 至 𝑛 的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 1 的索引。我们可以得出以下结论。
- 当
nums = [?, ?, ..., 1],即当末尾元素是 1 时,需要完整遍历数组,达到最差时间复杂度 𝑂(𝑛) 。 - 当
nums = [1, ?, ?, ...],即当首个元素为 1 时,无论数组多长都不需要继续遍历,达到最佳时间复杂度 Ω(1) 。
“最差时间复杂度”对应函数渐近上界,使用大 𝑂 记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用 Ω 记号表示:
1 | /* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */ |
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。而最差时间复杂度更为实用,因为它给出了一个效率安全值,让我们可以放心地使用算法。
从上述示例可以看出,最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,平均时间复杂度可以体现算法在随机输入数据下的运行效率,用 Θ 记号来表示。
对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 1 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 𝑛/2 ,平均时间复杂度为 Θ(𝑛/2)=Θ(𝑛) 。
但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
空间复杂度
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。
算法相关空间
算法在运行过程中使用的内存空间主要包括以下几种。
- 输入空间:用于存储算法的输入数据。
- 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
- 输出空间:用于存储算法的输出数据。
一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。
暂存空间可以进一步划分为三个部分。
- 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
- 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分。

1 | /* 函数 */ |
推算方法
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。
而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
观察以下代码,最差空间复杂度中的“最差”有两层含义。
- 以最差输入数据为准:当 𝑛<10 时,空间复杂度为 𝑂(1) ;但当 𝑛>10 时,初始化的数组
nums占用 𝑂(𝑛) 空间,因此最差空间复杂度为 𝑂(𝑛) 。 - 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 𝑂(1) 空间;当初始化数组
nums时,程序占用 𝑂(𝑛) 空间,因此最差空间复杂度为 𝑂(𝑛) 。
1 | void algorithm(int n) { |
在递归函数中,需要注意统计栈帧空间。观察以下代码:
1 | int func() { |
函数 loop() 和 recur() 的时间复杂度都为 𝑂(𝑛) ,但空间复杂度不同。
- 函数
loop()在循环中调用了 𝑛 次function(),每轮中的function()都返回并释放了栈帧空间,因此空间复杂度仍为 𝑂(1) 。 - 递归函数
recur()在运行过程中会同时存在 𝑛 个未返回的recur(),从而占用 𝑂(𝑛) 的栈帧空间。
常见类型
设输入数据大小为 𝑛 ,下面展示了常见的空间复杂度类型(从低到高排列)。
$$
O(1)<O(1ogn)<O(n)<O(n^3)<O(2^n)\
常数阶<对数阶<线性阶<平方阶<指数阶
$$

常数阶
常数阶常见于数量与输入数据大小 𝑛 无关的常量、变量、对象。
==需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 𝑂(1)== :
1 | /* 函数 */ |
线性阶
线性阶常见于元素数量与 𝑛 成正比的数组、链表、栈、队列等:
1 | /* 哈希表 */ |
下面的函数的递归深度为 𝑛 ,即同时存在 𝑛 个未返回的 linear_recur() 函数,使用 𝑂(𝑛) 大小的栈帧空间:
1 | /* 线性阶(递归实现) */ |

平方阶
平方阶常见于矩阵和图,元素数量与 𝑛 成平方关系:
1 | /* 平方阶 */ |
下面的函数的递归深度为 𝑛 ,在每个递归函数中都初始化了一个数组,长度分别为 𝑛、𝑛−1、…、2、1 ,平均长度为 𝑛/2 ,因此总体占用 𝑂($𝑛^2$) 空间:
1 | /* 平方阶(递归实现) */ |

指数阶
指数阶常见于二叉树。层数为 𝑛 的“满二叉树”的节点数量为 $2^𝑛−1 $,占用 𝑂($2^𝑛$) 空间:
1 | /* 指数阶(建立满二叉树) */ |

对数阶
对数阶常见于分治算法。例如归并排序,输入长度为 𝑛 的数组,每轮递归将数组从中点处划分为两半,形成高度为 log𝑛 的递归树,使用 𝑂(log𝑛) 栈帧空间。
再例如将数字转化为字符串,输入一个正整数 𝑛 ,它的位数为 ⌊log10𝑛⌋+1 ,即对应字符串长度为 ⌊log10𝑛⌋+1 ,因此空间复杂度为 𝑂(log10𝑛+1)=𝑂(log𝑛) 。
权衡时间与空间
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常非常困难。
降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。
选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。数据结构
数据结构
数据结构分类
常见的数据结构包括数组、链表、栈、队列、哈希表、树、堆、图,它们可以从“逻辑结构”和“物理结构”两个维度进行分类。
逻辑结构:线性与非线性
逻辑结构揭示了数据元素之间的逻辑关系。
- 在数组和链表中,数据按照一定顺序排列,体现了数据之间的线性关系;
- 而在树中,数据从顶部向下按层次排列,表现出“祖先”与“后代”之间的派生关系;
- 图则由节点和边构成,反映了复杂的网络关系。
逻辑结构可分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
- 线性数据结构:数组、链表、栈、队列、哈希表,元素之间是一对一的顺序关系。
- 非线性数据结构:树、堆、图、哈希表。
非线性数据结构可以进一步划分为树形结构和网状结构。
- 树形结构:树、堆、哈希表,元素之间是一对多的关系。
- 网状结构:图,元素之间是多对多的关系。

物理结构:连续与分散
当算法程序运行时,正在处理的数据主要存储在内存中。下图展示了一个计算机内存条,其中每个黑色方块都包含一块内存空间。我们可以将内存想象成一个巨大的 Excel 表格,其中每个单元格都可以存储一定大小的数据。
系统通过内存地址来访问目标位置的数据。计算机根据特定规则为表格中的每个单元格分配编号,确保每个内存空间都有唯一的内存地址。有了这些地址,程序便可以访问内存中的数据。

内存是所有程序的共享资源,当某块内存被某个程序占用时,则通常无法被其他程序同时使用了。因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在分散的内存空间内。
物理结构反映了数据在计算机内存中的存储方式,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,两种物理结构在时间效率和空间效率方面呈现出互补的特点。

值得说明的是,所有数据结构都是基于数组、链表或二者的组合实现的。例如,栈和队列既可以使用数组实现,也可以使用链表实现;而哈希表的实现可能同时包含数组和链表。
- 基于数组可实现:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 ≥3 的数组)等。
- 基于链表可实现:栈、队列、哈希表、树、堆、图等。
链表在初始化后,仍可以在程序运行过程中对其长度进行调整,因此也称“动态数据结构”。数组在初始化后长度不可变,因此也称“静态数据结构”。值得注意的是,数组可通过重新分配内存实现长度变化,从而具备一定的“动态性”。
基本数据类型
当谈及计算机中的数据时,我们会想到文本、图片、视频、语音、3D 模型等各种形式。尽管这些数据的组织形式各异,但它们都由各种基本数据类型构成。
基本数据类型是 CPU 可以直接进行运算的类型,在算法中直接被使用,主要包括以下几种。
- 整数类型
byte、short、int、long。 - 浮点数类型
float、double,用于表示小数。 - 字符类型
char,用于表示各种语言的字母、标点符号甚至表情符号等。 - 布尔类型
bool,用于表示“是”与“否”判断。
基本数据类型以二进制的形式存储在计算机中。一个二进制位即为 1 比特。在绝大多数现代操作系统中,1 字节(byte)由 8 比特(bit)组成。
基本数据类型的取值范围取决于其占用的空间大小。下面以 Java 为例。
- 整数类型
byte占用 1 字节 = 8 比特 ,可以表示 28 个数字。 - 整数类型
int占用 4 字节 = 32 比特 ,可以表示 232 个数字。
下表列举了 Java 中各种基本数据类型的占用空间、取值范围和默认值。此表格无须死记硬背,大致理解即可,需要时可以通过查表来回忆。
| 类型 | 符号 | 占用空间 | 最小值 | 最大值 | 默认值 |
|---|---|---|---|---|---|
| 整数 | byte | 1字节 | $-2^7(-128)$ | $2^7 - 1(127)$ | 0 |
| : | short | 2字节 | $-2^{15}$ | $2^{15} -1$ | 0 |
| : | int | 4字节 | $-2^{31}$ | $2^{31} -1$ | 0 |
| : | long | 8字节 | $-2^{63}$ | $2^{63}-1$ | 0 |
| 浮点数 | float | 4字节 | $1.175 × 10^{-38}$ | $3.403 × 10^{38}$ | 0.0f |
| : | double | 8字节 | $2.225 × 10^{-308}$ | $1.798 × 10^{308}$ | 0.0 |
| 字符 | char | 2字节 | 0 | $2^{16} - 1$ | 0 |
| 布尔 | bool | 1字节 | false | true | false |
请注意,上表针对的是 Java 的基本数据类型的情况。每种编程语言都有各自的数据类型定义,它们的占用空间、取值范围和默认值可能会有所不同。
- 在 Python 中,整数类型
int可以是任意大小,只受限于可用内存;浮点数float是双精度 64 位;没有char类型,单个字符实际上是长度为 1 的字符串str。 - C 和 C++ 未明确规定基本数据类型的大小,而因实现和平台各异。表 3-1 遵循 LP64 数据模型,其用于包括 Linux 和 macOS 在内的 Unix 64 位操作系统。
- 字符
char的大小在 C 和 C++ 中为 1 字节,在大多数编程语言中取决于特定的字符编码方法。 - 即使表示布尔量仅需 1 位(0 或 1),它在内存中通常也存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。
那么,基本数据类型与数据结构之间有什么联系呢?
我们知道,数据结构是在计算机中组织与存储数据的方式。这句话的主语是“结构”而非“数据”。
如果想表示“一排数字”,我们自然会想到使用数组。这是因为数组的线性结构可以表示数字的相邻关系和顺序关系,但至于存储的内容是整数
int、小数float还是字符char,则与“数据结构”无关。换句话说,基本数据类型提供了数据的“内容类型”,而数据结构提供了数据的“组织方式”。例如以下代码,我们用相同的数据结构(数组)来存储与表示不同的基本数据类型,包括
int、float、char、bool等。
原码、反码和补码
在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 byte 的取值范围是 [−128,127] 。这个现象比较反直觉,它的内在原因涉及原码、反码、补码的相关知识。
首先需要指出,==数字是以“补码”的形式存储在计算机中的==。在分析这样做的原因之前,首先给出三者的定义。
- 原码:我们将数字的二进制表示的最高位视为符号位,其中 0 表示正数,1 表示负数,其余位表示数字的值。
- 反码:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。
- 补码:正数的补码与其原码相同,负数的补码是在其反码的基础上加 1 。
下图展示了原码、反码和补码之间的转换方法。

原码(sign-magnitude)虽然最直观,但存在一些局限性。一方面,负数的原码不能直接用于运算。例如在原码下计算 1+(−2) ,得到的结果是 −3 ,这显然是不对的。
$$
1+(-2)\
→00000001+10000010\
=10000011\
→-3
$$
为了解决此问题,计算机引入了反码(1’s complement)。如果我们先将原码转换为反码,并在反码下计算 1+(−2) ,最后将结果从反码转换回原码,则可得到正确结果 −1 。
$$
1+(-2)\
→00000001(原码)+10000010(原码)\
=00000001(反码)+11111101(反码)\
=11111110(反码)\
=10000001(原码)\
→-1
$$
另一方面,数字零的原码有 +0 和 −0 两种表示方式。这意味着数字零对应两个不同的二进制编码,这可能会带来歧义。比如在条件判断中,如果没有区分正零和负零,则可能会导致判断结果出错。而如果我们想处理正零和负零歧义,则需要引入额外的判断操作,这可能会降低计算机的运算效率。
$$
+0→00000000\
-0→10000000
$$
与原码一样,反码也存在正负零歧义问题,因此计算机进一步引入了补码(2’s complement)。我们先来观察一下负零的原码、反码、补码的转换过程:
$$
-0→10000000(原码)\
=11111111(反码)\
=100000000(补码)
$$
在负零的反码基础上加 1 会产生进位,但 byte 类型的长度只有 8 位,因此溢出到第 9 位的 1 会被舍弃。也就是说,负零的补码为 00000000 ,与正零的补码相同。这意味着在补码表示中只存在一个零,正负零歧义从而得到解决。
还剩最后一个疑惑:byte 类型的取值范围是 [−128,127] ,多出来的一个负数 −128 是如何得到的呢?我们注意到,区间 [−127,+127] 内的所有整数都有对应的原码、反码和补码,并且原码和补码之间可以互相转换。
然而,补码 10000000 是一个例外,它并没有对应的原码。根据转换方法,我们得到该补码的原码为 00000000 。这显然是矛盾的,因为该原码表示数字 0 ,它的补码应该是自身。计算机规定这个特殊的补码 10000000 代表 −128 。实际上,(−1)+(−127) 在补码下的计算结果就是 −128 。
$$
(-127)+(-1)\
→11111111(原码)+10000001(原码)\
=10000000(反码)+11111110(反码)\
=10000001(补码)+11111111(补码)\
=10000000(补码)\
→-128
$$
你可能已经发现了,上述所有计算都是加法运算。这暗示着一个重要事实:计算机内部的硬件电路主要是基于加法运算设计的。这是因为加法运算相对于其他运算(比如乘法、除法和减法)来说,硬件实现起来更简单,更容易进行并行化处理,运算速度更快。
请注意,这并不意味着计算机只能做加法。通过将加法与一些基本逻辑运算结合,计算机能够实现各种其他的数学运算。例如,计算减法 a−b 可以转换为计算加法 a+(−b) ;计算乘法和除法可以转换为计算多次加法或减法。
现在我们可以总结出计算机使用补码的原因:基于补码表示,计算机可以用同样的电路和操作来处理正数和负数的加法,不需要设计特殊的硬件电路来处理减法,并且无须特别处理正负零的歧义问题。这大大简化了硬件设计,提高了运算效率。
浮点数编码
细心的你可能会发现:int 和 float 长度相同,都是 4 字节 ,但为什么 float 的取值范围远大于 int ?这非常反直觉,因为按理说 float 需要表示小数,取值范围应该变小才对。
实际上,这是因为浮点数 float 采用了不同的表示方式。记一个 32 比特长度的二进制数为:
$$
b_{31}b_{30}b_{29}…b_2b_1b_0
$$
根据 IEEE 754 标准,32-bit 长度的 float 由以下三个部分构成。
- 符号位 S :占 1 位 ,对应 $b_{31}$ 。
- 指数位 E :占 8 位 ,对应 $b_{30}b_{29}…b_{23}$ 。
- 分数位 N :占 23 位 ,对应$ b_{22}b_{21}…b_0$ 。
二进制数 float 对应值的计算方法为:
$$
val=(-1)^s \times 2^{(b_{30}b_{29}…b_{23})2-127}×(1.b{22}b_{21}…b_0)2
$$
转化到十进制下的计算公式为:
$$
val=(-1)^s\times 2^{E-127}×(1+N)
$$
其中各项的取值范围为:
$$
S\in{0,1},E\in{1,2,.,254}\
(1+N)=(1+\sum^{23}{i=1}b_{23-i}2^{-i})\subset[1,2,-2^{-23}]
$$
给定一个示例数据 $S=0 ,E=124 ,N=2^{−2}+2^{−3}=0.375$ ,则有:
$$
val=(-1)^0 \times 2^{124-127} \times (1+0.375) = 0.171875
$$

现在我们可以回答最初的问题:float 的表示方式包含指数位,导致其取值范围远大于 int 。根据以上计算,float 可表示的最大正数为 $2^{254−127}\times(2−2^{−23})≈3.4×1038$ ,切换符号位便可得到最小负数。
尽管浮点数 float 扩展了取值范围,但其副作用是牺牲了精度。整数类型 int 将全部 32 比特用于表示数字,数字是均匀分布的;而由于指数位的存在,浮点数 float 的数值越大,相邻两个数字之间的差值就会趋向越大。
如下表所示,指数位 E=0 和 E=255 具有特殊含义,用于表示零、无穷大、NaN 等。
| 指数位E | 分数位N=0 | 分数位N≠0 | 计算公式 |
|---|---|---|---|
| 0 | ±0 | 次正规数 | $(-1)^s \times 2^{-126} × (0.N)$ |
| 1,2,…,254 | 正规数 | 正规数 | $(-1)^s \times 2^{(E-127)} × (1.N)$ |
| 255 | $\pm\infty$ | NaN |
字符编码
在计算机中,所有数据都是以二进制数的形式存储的,字符 char 也不例外。为了表示字符,我们需要建立一套“字符集”,规定每个字符和二进制数之间的一一对应关系。有了字符集之后,计算机就可以通过查表完成二进制数到字符的转换。
ASCII字符集
ASCII 码是最早出现的字符集,其全称为 American Standard Code for Information Interchange(美国标准信息交换代码)。它使用 7 位二进制数(一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如图所示,ASCII 码包括英文字母的大小写、数字 0 ~ 9、一些标点符号,以及一些控制字符(如换行符和制表符)。

然而,ASCII 码仅能够表示英文。随着计算机的全球化,诞生了一种能够表示更多语言的 EASCII 字符集。它在 ASCII 的 7 位基础上扩展到 8 位,能够表示 256 个不同的字符。
在世界范围内,陆续出现了一批适用于不同地区的 EASCII 字符集。这些字符集的前 128 个字符统一为 ASCII 码,后 128 个字符定义不同,以适应不同语言的需求。
GBK字符集
后来人们发现,EASCII 码仍然无法满足许多语言的字符数量要求。比如汉字有近十万个,光日常使用的就有几千个。中国国家标准总局于 1980 年发布了 GB2312 字符集,其收录了 6763 个汉字,基本满足了汉字的计算机处理需要。
然而,GB2312 无法处理部分罕见字和繁体字。GBK 字符集是在 GB2312 的基础上扩展得到的,它共收录了 21886 个汉字。在 GBK 的编码方案中,ASCII 字符使用一个字节表示,汉字使用两个字节表示。
Unicode字符集
随着计算机技术的蓬勃发展,字符集与编码标准百花齐放,而这带来了许多问题。一方面,这些字符集一般只定义了特定语言的字符,无法在多语言环境下正常工作。另一方面,同一种语言存在多种字符集标准,如果两台计算机使用的是不同的编码标准,则在信息传递时就会出现乱码。
那个时代的研究人员就在想:如果推出一个足够完整的字符集,将世界范围内的所有语言和符号都收录其中,不就可以解决跨语言环境和乱码问题了吗?在这种想法的驱动下,一个大而全的字符集 Unicode 应运而生。
Unicode 的中文名称为“统一码”,理论上能容纳 100 多万个字符。它致力于将全球范围内的字符纳入统一的字符集之中,提供一种通用的字符集来处理和显示各种语言文字,减少因为编码标准不同而产生的乱码问题。
自 1991 年发布以来,Unicode 不断扩充新的语言与字符。截至 2022 年 9 月,Unicode 已经包含 149186 个字符,包括各种语言的字符、符号甚至表情符号等。在庞大的 Unicode 字符集中,常用的字符占用 2 字节,有些生僻的字符占用 3 字节甚至 4 字节。
Unicode 是一种通用字符集,本质上是给每个字符分配一个编号(称为“码点”),但它并没有规定在计算机中如何存储这些字符码点。我们不禁会问:当多种长度的 Unicode 码点同时出现在一个文本中时,系统如何解析字符?例如给定一个长度为 2 字节的编码,系统如何确认它是一个 2 字节的字符还是两个 1 字节的字符?
对于以上问题,一种直接的解决方案是将所有字符存储为等长的编码。如图所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复这个短语的内容了。

然而 ASCII 码已经向我们证明,编码英文只需 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。
UTF-8编码
目前,UTF-8 已成为国际上使用最广泛的 Unicode 编码方法。它是一种可变长度的编码,使用 1 到 4 字节来表示一个字符,根据字符的复杂性而变。ASCII 字符只需 1 字节,拉丁字母和希腊字母需要 2 字节,常用的中文字符需要 3 字节,其他的一些生僻字符需要 4 字节。
UTF-8 的编码规则并不复杂,分为以下两种情况。
- 对于长度为 1 字节的字符,将最高位设置为 0 ,其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,UTF-8 编码可以向下兼容 ASCII 码。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。
- 对于长度为 𝑛 字节的字符(其中 𝑛>1),将首个字节的高 𝑛 位都设置为 1 ,第 𝑛+1 位设置为 0 ;从第二个字节开始,将每个字节的高 2 位都设置为 10 ;其余所有位用于填充字符的 Unicode 码点。
下图展示了“Hello算法”对应的 UTF-8 编码。

观察发现,由于最高 𝑛 位都设置为 1 ,因此系统可以通过读取最高位 1 的个数来解析出字符的长度为 𝑛 。但为什么要将其余所有字节的高 2 位都设置为 10 呢?实际上,这个 10 能够起到校验符的作用。假设系统从一个错误的字节开始解析文本,字节头部的 10 能够帮助系统快速判断出异常。
之所以将 10 当作校验符,是因为在 UTF-8 编码规则下,不可能有字符的最高两位是 10 。这个结论可以用反证法来证明:假设一个字符的最高两位是 10 ,说明该字符的长度为 1 ,对应 ASCII 码。而 ASCII 码的最高位应该是 0 ,与假设矛盾。
除了 UTF-8 之外,常见的编码方式还包括以下两种。
- UTF-16 编码:使用 2 或 4 字节来表示一个字符。所有的 ASCII 字符和常用的非英文字符,都用 2 字节表示;少数字符需要用到 4 字节表示。对于 2 字节的字符,UTF-16 编码与 Unicode 码点相等。
- UTF-32 编码:每个字符都使用 4 字节。这意味着 UTF-32 比 UTF-8 和 UTF-16 更占用空间,特别是对于 ASCII 字符占比较高的文本。
从存储空间占用的角度看,使用 UTF-8 表示英文字符非常高效,因为它仅需 1 字节;使用 UTF-16 编码某些非英文字符(例如中文)会更加高效,因为它仅需 2 字节,而 UTF-8 可能需要 3 字节。
从兼容性的角度看,UTF-8 的通用性最佳,许多工具和库优先支持 UTF-8 。
编程语言的字符编码
对于以往的大多数编程语言,程序运行中的字符串都采用 UTF-16 或 UTF-32 这类等长编码。在等长编码下,我们可以将字符串看作数组来处理,这种做法具有以下优点。
- 随机访问:UTF-16 编码的字符串可以很容易地进行随机访问。UTF-8 是一种变长编码,要想找到第 𝑖 个字符,我们需要从字符串的开始处遍历到第 𝑖 个字符,这需要 𝑂(𝑛) 的时间。
- 字符计数:与随机访问类似,计算 UTF-16 编码的字符串的长度也是 𝑂(1) 的操作。但是,计算 UTF-8 编码的字符串的长度需要遍历整个字符串。
- 字符串操作:在 UTF-16 编码的字符串上,很多字符串操作(如分割、连接、插入、删除等)更容易进行。在 UTF-8 编码的字符串上,进行这些操作通常需要额外的计算,以确保不会产生无效的 UTF-8 编码。
实际上,编程语言的字符编码方案设计是一个很有趣的话题,涉及许多因素。
- Java 的
String类型使用 UTF-16 编码,每个字符占用 2 字节。这是因为 Java 语言设计之初,人们认为 16 位足以表示所有可能的字符。然而,这是一个不正确的判断。后来 Unicode 规范扩展到了超过 16 位,所以 Java 中的字符现在可能由一对 16 位的值(称为“代理对”)表示。 - JavaScript 和 TypeScript 的字符串使用 UTF-16 编码的原因与 Java 类似。当 1995 年 Netscape 公司首次推出 JavaScript 语言时,Unicode 还处于发展早期,那时候使用 16 位的编码就足以表示所有的 Unicode 字符了。
- C# 使用 UTF-16 编码,主要是因为 .NET 平台是由 Microsoft 设计的,而 Microsoft 的很多技术(包括 Windows 操作系统)都广泛使用 UTF-16 编码。
由于以上编程语言对字符数量的低估,它们不得不采取“代理对”的方式来表示超过 16 位长度的 Unicode 字符。这是一个不得已为之的无奈之举。一方面,包含代理对的字符串中,一个字符可能占用 2 字节或 4 字节,从而丧失了等长编码的优势。另一方面,处理代理对需要额外增加代码,这提高了编程的复杂性和调试难度。
出于以上原因,部分编程语言提出了一些不同的编码方案。
- Python 中的
str使用 Unicode 编码,并采用一种灵活的字符串表示,存储的字符长度取决于字符串中最大的 Unicode 码点。若字符串中全部是 ASCII 字符,则每个字符占用 1 字节;如果有字符超出了 ASCII 范围,但全部在基本多语言平面(BMP)内,则每个字符占用 2 字节;如果有超出 BMP 的字符,则每个字符占用 4 字节。 - Go 语言的
string类型在内部使用 UTF-8 编码。Go 语言还提供了rune类型,它用于表示单个 Unicode 码点。 - Rust 语言的
str和String类型在内部使用 UTF-8 编码。Rust 也提供了char类型,用于表示单个 Unicode 码点。
需要注意的是,以上讨论的都是字符串在编程语言中的存储方式,这和字符串如何在文件中存储或在网络中传输是不同的问题。在文件存储或网络传输中,我们通常会将字符串编码为 UTF-8 格式,以达到最优的兼容性和空间效率。
数组与链表
数组
数组(array)是一种线性数据结构,其将相同类型的元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的索引(index)。下图展示了数组的主要概念和存储方式。

数组的常用操作
初始化数组
我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 0 :
1 | /* 初始化数组 */ |
访问元素
数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(首元素内存地址)和某个元素的索引,我们可以使用下图所示的公式计算得到该元素的内存地址,从而直接访问该元素。

观察上图,我们发现数组首个元素的索引为 0 ,这似乎有些反直觉,因为从 1 开始计数会更自然。但从地址计算公式的角度看,索引本质上是内存地址的偏移量。首个元素的地址偏移量是 0 ,因此它的索引为 0 是合理的。
在数组中访问元素非常高效,我们可以在 𝑂(1) 时间内随机访问数组中的任意一个元素。
1 | /* 随机访问元素 */ |
插入元素
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如图所示,如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。

值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”。我们将这个问题的解决方案留在“列表”章节中讨论。
1 | /* 在数组的索引 index 处插入元素 num */ |
删除元素
同理,若想删除索引 𝑖 处的元素,则需要把索引 𝑖 之后的元素都向前移动一位。

请注意,删除元素完成后,原先末尾的元素变得“无意义”了,所以我们无须特意去修改它。
1 | /* 删除索引 index 处的元素 */ |
总的来看,数组的插入与删除操作有以下缺点。
- 时间复杂度高:数组的插入和删除的平均时间复杂度均为 𝑂(𝑛) ,其中 𝑛 为数组长度。
- 丢失元素:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。
- 内存浪费:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做会造成部分内存空间浪费。
遍历数组
在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素:
1 | /* 遍历数组 */ |
查找元素
在数组中查找指定元素需要遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。
因为数组是线性数据结构,所以上述查找操作被称为“线性查找”。
1 | /* 在数组中查找指定元素 */ |
扩容数组
在复杂的系统环境中,程序难以保证数组之后的内存空间是可用的,从而无法安全地扩展数组容量。因此在大多数编程语言中,数组的长度是不可变的。
如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次复制到新数组。这是一个 𝑂(𝑛) 的操作,在数组很大的情况下非常耗时。代码如下所示:
1 | /* 扩展数组长度 */ |
数组的优点与局限性
数组存储在连续的内存空间内,且元素类型相同。这种做法包含丰富的先验信息,系统可以利用这些信息来优化数据结构的操作效率。
- 空间效率高:数组为数据分配了连续的内存块,无须额外的结构开销。
- 支持随机访问:数组允许在 𝑂(1) 时间内访问任何元素。
- 缓存局部性:当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
连续空间存储是一把双刃剑,其存在以下局限性。
- 插入与删除效率低:当数组中元素较多时,插入与删除操作需要移动大量的元素。
- 长度不可变:数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
- 空间浪费:如果数组分配的大小超过实际所需,那么多余的空间就被浪费了。
数组的典型应用
数组是一种基础且常见的数据结构,既频繁应用在各类算法之中,也可用于实现各种复杂数据结构。
- 随机访问:如果我们想随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现随机抽样。
- 排序和搜索:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数组上进行。
- 查找表:当需要快速查找一个元素或其对应关系时,可以使用数组作为查找表。假如我们想实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
- 机器学习:神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。
- 数据结构实现:数组可以用于实现栈、队列、哈希表、堆、图等数据结构。例如,图的邻接矩阵表示实际上是一个二维数组。
链表
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。
链表(linked list)是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。

在上图中,链表的组成单位是节点(node)对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。
- 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
- 尾节点指向的是“空”,它在 Java、C++ 和 Python 中分别被记为
null、nullptr和None。 - 在 C、C++、Go 和 Rust 等支持指针的语言中,上述“引用”应被替换为“指针”。
如以下代码所示,链表节点 ListNode 除了包含值,还需额外保存一个引用(指针)。因此在相同数据量下,链表比数组占用更多的内存空间。
1 | /* 链表节点结构体 */ |
链表常用操作
初始化链表
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建节点之间的引用关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 next 依次访问所有节点。
1 | /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */ |
数组整体是一个变量,比如数组 nums 包含元素 nums[0] 和 nums[1] 等,而链表是由多个独立的节点对象组成的。我们通常将头节点当作链表的代称,比如以上代码中的链表可记作链表 n0 。
插入节点
在链表中插入节点非常容易。如下图所示,假设我们想在相邻的两个节点 n0 和 n1 之间插入一个新节点 P ,则只需改变两个节点引用(指针)即可,时间复杂度为 𝑂(1) 。相比之下,在数组中插入元素的时间复杂度为 𝑂(𝑛) ,在大数据量下的效率较低。

1 | /* 在链表的节点 n0 之后插入节点 P */ |
删除节点
如图所示,在链表中删除节点也非常方便,只需改变一个节点的引用(指针)即可。
请注意,尽管在删除操作完成后节点 P 仍然指向 n1 ,但实际上遍历此链表已经无法访问到 P ,这意味着 P 已经不再属于该链表了。

1 | /* 删除链表的节点 n0 之后的首个节点 */ |
访问节点
在链表中访问节点的效率较低。如上一节所述,我们可以在 𝑂(1) 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 𝑖 个节点需要循环 𝑖−1 轮,时间复杂度为 𝑂(𝑛) 。
1 | /* 访问链表中索引为 index 的节点 */ |
查找节点
遍历链表,查找其中值为 target 的节点,输出该节点在链表中的索引。此过程也属于线性查找。代码如下所示:
1 | /* 在链表中查找值为 target 的首个节点 */ |
数组VS链表
下表总结了数组和链表的各项特点并对比了操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
| 数组 | 链表 |
|---|---|
| 存储方式 连续内存空间 | 分散内存空间 |
| 容量扩展 长度不可变 | 可灵活扩展 |
| 内存效率 元素占用内存少、但可能浪费空间 | 元素占用内存多 |
| 访问元素 O(1) | O(n) |
| 添加元素 O(n) | O(1) |
| 删除元素 O(n) | O(1) |
常见链表类型
如图所示,常见的链表类型包括三种。
- 单向链表:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空
None。 - 环形链表:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
- 双向链表:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。

1 | /* 双向链表节点结构体 */ |
链表的典型应用
单向链表通常用于实现栈、队列、哈希表和图等数据结构。
- 栈与队列:当插入和删除操作都在链表的一端进行时,它表现的特性为先进后出,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现的特性为先进先出,对应队列。
- 哈希表:链式地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
- 图:邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
双向链表常用于需要快速查找前一个和后一个元素的场景。
- 高级数据结构:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
- 浏览器历史:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
- LRU 算法:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。
环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。
- 时间片轮转调度算法:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环操作可以通过环形链表来实现。
- 数据缓冲区:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。
列表
列表(list)是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无须使用者考虑容量限制的问题。列表可以基于链表或数组实现。
- 链表天然可以看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
- 数组也支持元素增删查改,但由于其长度不可变,因此只能看作一个具有长度限制的列表。
当使用数组实现列表时,长度不可变的性质会导致列表的实用性降低。这是因为我们通常无法事先确定需要存储多少数据,从而难以选择合适的列表长度。若长度过小,则很可能无法满足使用需求;若长度过大,则会造成内存空间浪费。
为解决此问题,我们可以使用动态数组(dynamic array)来实现列表。它继承了数组的各项优点,并且可以在程序运行过程中进行动态扩容。
实际上,许多编程语言中的标准库提供的列表是基于动态数组实现的,例如 Python 中的 list 、Java 中的 ArrayList 、C++ 中的 vector 和 C# 中的 List 等。在接下来的讨论中,我们将把“列表”和“动态数组”视为等同的概念。
列表常用操作
初始化列表
我们通常使用“无初始值”和“有初始值”这两种初始化方法:
1 | /* 初始化列表 */ |
访问元素
列表本质上是数组,因此可以在 𝑂(1) 时间内访问和更新元素,效率很高。
1 | /* 访问元素 */ |
插入与删除元素
相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 𝑂(1) ,但插入和删除元素的效率仍与数组相同,时间复杂度为 𝑂(𝑛) 。
1 | /* 清空列表 */ |
遍历列表
与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
1 | /* 通过索引遍历列表 */ |
拼接列表
给定一个新列表 nums1 ,我们可以将其拼接到原列表的尾部。
1 | /* 拼接两个列表 */ |
排序列表
完成列表排序后,我们便可以使用在数组类算法题中经常考查的“二分查找”和“双指针”算法。
1 | /* 排序列表 */ |
列表实现
许多编程语言内置了列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
为了加深对列表工作原理的理解,我们尝试实现一个简易版列表,包括以下三个重点设计。
- 初始容量:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
- 数量记录:声明一个变量
size,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。 - 扩容机制:若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
1 | /* 列表类 */ |
内存和缓存
物理结构在很大程度上决定了程序对内存和缓存的使用效率,进而影响算法程序的整体性能。
计算机存储设备
计算机中包括三种类型的存储设备:硬盘(hard disk)、内存(random-access memory, RAM)、缓存(cache memory)。下表展示了它们在计算机系统中的不同角色和性能特点。
| 硬盘 | 内存 | 缓存 | |
|---|---|---|---|
| 用途 | 长期存储数据,包括操作系统、程序、文件等 | 临时存储当前运行的程序 和正在处理的数据 | 存储经常访问的数据和指令,减 少CPU访问内存的次数 |
| 易失性 | 断电后数据不会丢失 | 断电后数据会丢失 | 断电后数据会丢失 |
| 容量 | 较大,TB级别 | 较小,GB级别 | 非常小,MB级别 |
| 速度 | 较慢,几百到几千 MB/s | 较快,几十GB/s | 非常快,几十到几百GB/s |
| 价格 | 较便宜,几毛到几元/GB | 较贵,几十到几百元/GB | 非常贵,随CPU打包计价 |
我们可以将计算机存储系统想象为金字塔结构。越靠近金字塔顶端的存储设备的速度越快、容量越小、成本越高。这种多层级的设计并非偶然,而是计算机科学家和工程师们经过深思熟虑的结果。
- 硬盘难以被内存取代。首先,内存中的数据在断电后会丢失,因此它不适合长期存储数据;其次,内存的成本是硬盘的几十倍,这使得它难以在消费者市场普及。
- 缓存的大容量和高速度难以兼得。随着 L1、L2、L3 缓存的容量逐步增大,其物理尺寸会变大,与 CPU 核心之间的物理距离会变远,从而导致数据传输时间增加,元素访问延迟变高。在当前技术下,多层级的缓存结构是容量、速度和成本之间的最佳平衡点。

总的来说,硬盘用于长期存储大量数据,内存用于临时存储程序运行中正在处理的数据,而缓存则用于存储经常访问的数据和指令,以提高程序运行效率。三者共同协作,确保计算机系统高效运行。
程序运行时,数据会从硬盘中被读取到内存中,供 CPU 计算使用。缓存可以看作 CPU 的一部分,它通过智能地从内存加载数据,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。

数据结构的内存效率
在内存空间利用方面,数组和链表各自具有优势和局限性。
一方面,内存是有限的,且同一块内存不能被多个程序共享,因此我们希望数据结构能够尽可能高效地利用空间。数组的元素紧密排列,不需要额外的空间来存储链表节点间的引用(指针),因此空间效率更高。然而,数组需要一次性分配足够的连续内存空间,这可能导致内存浪费,数组扩容也需要额外的时间和空间成本。相比之下,链表以“节点”为单位进行动态内存分配和回收,提供了更大的灵活性。
另一方面,在程序运行时,随着反复申请与释放内存,空闲内存的碎片化程度会越来越高,从而导致内存的利用效率降低。数组由于其连续的存储方式,相对不容易导致内存碎片化。相反,链表的元素是分散存储的,在频繁的插入与删除操作中,更容易导致内存碎片化。
数据结构的缓存效率
缓存虽然在空间容量上远小于内存,但它比内存快得多,在程序执行速度上起着至关重要的作用。由于缓存的容量有限,只能存储一小部分频繁访问的数据,因此当 CPU 尝试访问的数据不在缓存中时,就会发生缓存未命中(cache miss),此时 CPU 不得不从速度较慢的内存中加载所需数据。
显然,“缓存未命中”越少,CPU 读写数据的效率就越高,程序性能也就越好。我们将 CPU 从缓存中成功获取数据的比例称为缓存命中率(cache hit rate),这个指标通常用来衡量缓存效率。
为了尽可能达到更高的效率,缓存会采取以下数据加载机制。
- 缓存行:缓存不是单个字节地存储与加载数据,而是以缓存行为单位。相比于单个字节的传输,缓存行的传输形式更加高效。
- 预取机制:处理器会尝试预测数据访问模式(例如顺序访问、固定步长跳跃访问等),并根据特定模式将数据加载至缓存之中,从而提升命中率。
- 空间局部性:如果一个数据被访问,那么它附近的数据可能近期也会被访问。因此,缓存在加载某一数据时,也会加载其附近的数据,以提高命中率。
- 时间局部性:如果一个数据被访问,那么它在不久的将来很可能再次被访问。缓存利用这一原理,通过保留最近访问过的数据来提高命中率。
实际上,数组和链表对缓存的利用效率是不同的,主要体现在以下几个方面。
- 占用空间:链表元素比数组元素占用空间更多,导致缓存中容纳的有效数据量更少。
- 缓存行:链表数据分散在内存各处,而缓存是“按行加载”的,因此加载到无效数据的比例更高。
- 预取机制:数组比链表的数据访问模式更具“可预测性”,即系统更容易猜出即将被加载的数据。
- 空间局部性:数组被存储在集中的内存空间中,因此被加载数据附近的数据更有可能即将被访问。
总体而言,数组具有更高的缓存命中率,因此它在操作效率上通常优于链表。这使得在解决算法问题时,基于数组实现的数据结构往往更受欢迎。
需要注意的是,高缓存效率并不意味着数组在所有情况下都优于链表。实际应用中选择哪种数据结构,应根据具体需求来决定。例如,数组和链表都可以实现“栈”数据结构(下一章会详细介绍),但它们适用于不同场景。
- 在做算法题时,我们会倾向于选择基于数组实现的栈,因为它提供了更高的操作效率和随机访问的能力,代价仅是需要预先为数组分配一定的内存空间。
- 如果数据量非常大、动态性很高、栈的预期大小难以估计,那么基于链表实现的栈更加合适。链表能够将大量数据分散存储于内存的不同部分,并且避免了数组扩容产生的额外开销。
栈和队列
栈
栈(stack)是一种遵循先入后出逻辑的线性数据结构。
我们可以将栈类比为桌面上的一摞盘子,如果想取出底部的盘子,则需要先将上面的盘子依次移走。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈这种数据结构。
如图所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫作“入栈”,删除栈顶元素的操作叫作“出栈”。

栈的常用操作
栈的常用操作如表所示,具体的方法名需要根据所使用的编程语言来确定。在此,我们以常见的 push()、pop()、peek() 命名为例。
| 方法 | 描述 | 时间复杂度 |
|---|---|---|
| push() | 元素入栈(添加至栈顶) | O(1) |
| pop() | 栈顶元素出栈 | O(1) |
| peek() | 访问栈顶元素 | O(1) |
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”当作栈来使用,并在程序逻辑上忽略与栈无关的操作。
1 | /* 初始化栈 */ |
栈的实现
为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。
栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,因此栈可以视为一种受限制的数组或链表。换句话说,我们可以“屏蔽”数组或链表的部分无关操作,使其对外表现的逻辑符合栈的特性。
基于链表的实现
使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。
如图所示,对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。



1 | /* 基于链表实现的栈 */ |
基于数组的实现
使用数组实现栈时,我们可以将数组的尾部作为栈顶。如图所示,入栈与出栈操作分别对应在数组尾部添加元素与删除元素,时间复杂度都为 𝑂(1) 。



由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。以下为示例代码:
1 | /* 基于数组实现的栈 */ |
两种实现的对比
支持操作
两种实现都支持栈定义中的各项操作。数组实现额外支持随机访问,但这已超出了栈的定义范畴,因此一般不会用到。
时间效率
在基于数组的实现中,入栈和出栈操作都在预先分配好的连续内存中进行,具有很好的缓存本地性,因此效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 𝑂(𝑛) 。
在基于链表的实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率。
综上所述,当入栈与出栈操作的元素是基本数据类型时,例如 int 或 double ,我们可以得出以下结论。
- 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高。
- 基于链表实现的栈可以提供更加稳定的效率表现。
空间效率
在初始化列表时,系统会为列表分配“初始容量”,该容量可能超出实际需求;并且,扩容机制通常是按照特定倍率(例如 2 倍)进行扩容的,扩容后的容量也可能超出实际需求。因此,基于数组实现的栈可能造成一定的空间浪费。
然而,由于链表节点需要额外存储指针,因此链表节点占用的空间相对较大。
综上,我们不能简单地确定哪种实现更加节省内存,需要针对具体情况进行分析。
栈的典型应用
- 浏览器中的后退与前进、软件中的撤销与反撤销。每当我们打开新的网页,浏览器就会对上一个网页执行入栈,这样我们就可以通过后退操作回到上一个网页。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
- 程序内存管理。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会不断执行出栈操作。
队列
队列(queue)是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列尾部,而位于队列头部的人逐个离开。
如图所示,我们将队列头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。

队列的常见操作
队列的常见操作如下表所示。需要注意的是,不同编程语言的方法名称可能会有所不同。我们在此采用与栈相同的方法命名。
| 方法名 | 描述 | 时间复杂度 |
|---|---|---|
| push() | 元素入队,即将元素添加至队尾 | O(1) |
| pop() | 队首元素出队 | O(1) |
| peek() | 访问队首元素 | O(1) |
我们可以直接使用编程语言中现成的队列类:
1 | /* 初始化队列 */ |
队列实现
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素,链表和数组都符合要求。
基于链表实现
如图所示,我们可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。



1 | /* 基于链表实现的队列 */ |
基于数组实现
在数组中删除首元素的时间复杂度为 𝑂(𝑛) ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
我们可以使用一个变量 front 指向队首元素的索引,并维护一个变量 size 用于记录队列长度。定义 rear = front + size ,这个公式计算出的 rear 指向队尾元素之后的下一个位置。
基于此设计,数组中包含元素的有效区间为 [front, rear - 1],各种操作的实现方法如图所示。
- 入队操作:将输入元素赋值给
rear索引处,并将size增加 1 。 - 出队操作:只需将
front增加 1 ,并将size减少 1 。
可以看到,入队和出队操作都只需进行一次操作,时间复杂度均为 𝑂(1) 。



你可能会发现一个问题:在不断进行入队和出队的过程中,front 和 rear 都在向右移动,当它们到达数组尾部时就无法继续移动了。为了解决此问题,我们可以将数组视为首尾相接的“环形数组”。
对于环形数组,我们需要让 front 或 rear 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示:
1 | /* 基于环形数组实现的队列 */ |
以上实现的队列仍然具有局限性:其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的读者可以尝试自行实现。
两种实现的对比结论与栈一致,在此不再赘述。
队列典型应用
- 淘宝订单。购物者下单后,订单将加入队列中,系统随后会根据顺序处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。
- 各类待办事项。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等,队列在这些场景中可以有效地维护处理顺序。
双向队列
在队列中,我们仅能删除头部元素或在尾部添加元素。如图所示,双向队列(double-ended queue)提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。

双向队列的常用操作
双向队列的常用操作如表所示,具体的方法名称需要根据所使用的编程语言来确定。
| 方法名 | 描述 | 时间复杂度 |
|---|---|---|
| push_first() | 将元素添加至队首 | O(1) |
| push_last() | 将元素添加至队尾 | O(1) |
| pop_first() | 删除队首元素 | O(1) |
| pop_last() | 删除队尾元素 | O(1) |
| peek_first() | 访问队首元素 | O(1) |
| peek_last() | 访问队尾元素 | O(1) |
同样地,我们可以直接使用编程语言中已实现的双向队列类:
1 | /* 初始化双向队列 */ |
双向队列的实现
基于双向链表的实现
回顾上一节内容,我们使用普通单向链表来实现队列,因为它可以方便地删除头节点(对应出队操作)和在尾节点后添加新节点(对应入队操作)。
对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用“双向链表”作为双向队列的底层数据结构。
如图所示,我们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。





1 | /* 双向链表节点 */ |
基于数组的实现
与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。





在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法:
1 | /* 基于环形数组实现的双向队列 */ |
双向队列的应用
双向队列兼具栈与队列的逻辑,因此它可以实现这两者的所有应用场景,同时提供更高的自由度。
我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作 push 到栈中,然后通过 pop 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存 50 步)。当栈的长度超过 50 时,软件需要在栈底(队首)执行删除操作。但栈无法实现该功能,此时就需要使用双向队列来替代栈。请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。
哈希表
简介
哈希函数(Hash Function)是一种将任意大小的数据映射到固定大小的数据的函数。这种函数接收一个输入(通常称为消息或数据),并返回一个通常固定大小的字符串,这个返回的字符串就叫做哈希值(Hash Value)、哈希码(Hash Code)、哈希和(Hash Sum)或哈希(Hash)。
哈希函数有以下特性:
- 不可逆性:从哈希值不能反向推导出原始数据
- 难以破解性:如果要得到算法细节,暴力枚举可能是唯一的方法。
- 发散性:即使原始数据只修改了一个比特,最后得到的哈希值也会大不相同。
- 抗碰撞性:对于不同的原始数据,哈希值相同的概率非常小。
- 高效性:针对较长的文本也能快速计算出哈希值。
哈希表(hash table),又称散列表,它通过建立键 key 与值 value 之间的映射,实现高效的元素查询。具体而言,我们向哈希表中输入一个键 key ,则可以在 𝑂(1) 时间内获取对应的值 value 。
现给定 𝑛 个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能。
除哈希表外,数组和链表也可以实现查询功能。
- 添加元素:仅需将元素添加至数组(链表)的尾部即可,使用 𝑂(1) 时间。
- 查询元素:由于数组(链表)是乱序的,因此需要遍历其中的所有元素,使用 𝑂(𝑛) 时间。
- 删除元素:需要先查询到元素,再从数组(链表)中删除,使用 𝑂(𝑛) 时间。
| 数组 | 链表 | 哈希表 |
|---|---|---|
| 查找元素 O(n) | O(n) | O(1) |
| 添加元素 O(1) | O(1) | O(1) |
| 删除元素 O(n) | O(n) | O(1) |
[!TIP]
哈希的增删查改的时间复杂度都均为O(1)
简单实现
我们先考虑最简单的情况,仅用一个数组来实现哈希表。在哈希表中,我们将数组中的每个空位称为桶(bucket),每个桶可存储一个键值对。因此,查询操作就是找到 key 对应的桶,并在桶中获取 value 。
那么,如何基于 key 定位对应的桶呢?这是通过哈希函数(hash function)实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 key ,输出空间是所有桶(数组索引)。换句话说,输入一个 key ,我们可以通过哈希函数得到该 key 对应的键值对在数组中的存储位置。
输入一个 key ,哈希函数的计算过程分为以下两步。
-
通过某种哈希算法
hash()计算得到哈希值。 -
将哈希值对桶数量(数组长度)
capacity取模,从而获取该key对应的数组索引index。1
index = hash(key) % capacity
随后,我们就可以利用 index 在哈希表中访问对应的桶,从而获取 value 。
设数组长度 capacity = 100、哈希算法 hash(key) = key ,易得哈希函数为 key % 100 。下图以 key 学号和 value 姓名为例,展示了哈希函数的工作原理。

以下代码实现了一个简单的hash表,我们将key和value封装成一个类Pair,以表示键值对
1 | /* 键值对 int->string */ |
C++代码:
1 | /* 键值对 */ |
hash冲突
从本质上看,哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,理论上一定存在“多个输入对应相同输出”的情况。
[!TIP]
对于两个数据元素的关键字Ki和Kj(i != j),有Ki != Kj ( i != j) ,但HashFun( Ki ) ==HashFun( Kj ) ,将该种现象称为哈希冲突或哈希碰撞。
对于上述示例中的哈希函数,当输入的 key 后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,我们得到:
1 | 12836 % 100 = 36 |
两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为哈希冲突(hash collision)。

当出现哈希冲突的时候,容易想到的就是扩充容量,哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为了解决该问题,每当遇到哈希冲突时,我们就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
- 改良哈希表数据结构,使得哈希表可以在出现哈希冲突时正常工作。
- 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。
扩容
容易想到,哈希表容量 n 越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以通过扩容哈希表来减少哈希冲突。扩容前键值对 (136, A) 和 (236, D) 发生冲突,扩容后冲突消失。

类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步增加了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
负载因子(load factor)是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,也常作为哈希表扩容的触发条件。例如在 Java 中,当负载因子超过 0.75 时,系统会将哈希表扩容至原先的 2 倍。
链式地址
在原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。

基于链式地址实现的哈希表的操作方法发生了以下变化。
- 查询元素:输入
key,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比key以查找目标键值对。 - 添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
- 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。
链式地址存在以下局限性。
- 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
- 查询效率降低:因为需要线性遍历链表来查找对应元素。
以下代码给出了链式地址哈希表的简单实现,需要注意两点。
- 使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
- 以下实现包含哈希表扩容方法。当负载因子超过 23 时,我们将哈希表扩容至原先的 2 倍。
1 | /* 链表节点 */ |
值得注意的是,当链表很长时,查询效率 O(n) 很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而将查询操作的时间复杂度优化至 O(logn) 。
开放寻址法
线性探测
线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。
- 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 1 ),直至找到空桶,将元素插入其中。
- 查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回
value即可;如果遇到空桶,说明目标元素不在哈希表中,返回None。
下图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 key 都会被映射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。

然而,线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
值得注意的是,我们不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶 None ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。

为了解决该问题,我们可以采用懒删除(lazy deletion)机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶。在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。
然而,懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着 TOMBSTONE 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE 才能找到目标元素。
为此,考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历。
1 | /* 开放寻址哈希表 */ |
平方探测法
平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即 1,4,9,… 步。
平方探测主要具有以下优势。
- 平方探测通过跳过探测次数平方的距离,试图缓解线性探测的聚集效应。
- 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。
然而,平方探测并不是完美的。
- 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
- 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。
多次哈希
顾名思义,多次哈希方法使用多个哈希函数 f1(x)、f2(x)、f3(x)、… 进行探测。
- 插入元素:若哈希函数 f1(x) 出现冲突,则尝试 f2(x) ,以此类推,直到找到空位后插入元素。
- 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回
None。
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。
[!note]
请注意,开放寻址(线性探测、平方探测和多次哈希)哈希表都存在“不能直接删除元素”的问题。
哈希算法
前两节介绍了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链式地址,它们只能保证哈希表可以在发生冲突时正常工作,而无法减少哈希冲突的发生。
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。对于链式地址哈希表,理想情况下键值对均匀分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都存储到同一个桶中,时间复杂度退化至 O(n) 。

键值对的分布情况由哈希函数决定。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:
1 | index = hash(key) % capacity |
观察以上公式,当哈希表容量 capacity 固定时,哈希算法 hash() 决定了输出值,进而决定了键值对在哈希表中的分布情况。
这意味着,为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法 hash() 的设计上。
哈希函数的目标
为了实现“既快又稳”的哈希表数据结构,哈希算法应具备以下特点。
- 确定性:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
- 效率高:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
- 均匀分布:哈希算法应使得键值对均匀分布在哈希表中。分布越均匀,哈希冲突的概率就越低。
实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。
- 密码存储:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。
- 数据完整性检查:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整。
对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全特性。
- 单向性:无法通过哈希值反推出关于输入数据的任何信息。
- 抗碰撞性:应当极难找到两个不同的输入,使得它们的哈希值相同。
- 雪崩效应:输入的微小变化应当导致输出的显著且不可预测的变化。
请注意,“均匀分布”与“抗碰撞性”是两个独立的概念,满足均匀分布不一定满足抗碰撞性。例如,在随机输入 key 下,哈希函数 key % 100 可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的 key 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 key ,从而破解密码。
哈希算法设计
哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简单的哈希算法。
- 加法哈希:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
- 乘法哈希:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
- 异或哈希:将输入数据的每个元素通过异或操作累积到一个哈希值中。
- 旋转哈希:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。
1 | /* 加法哈希 */ |
观察发现,每种哈希算法的最后一步都是对大质数==1000000007== 取模,以确保哈希值在合适的范围内。值得思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。
先抛出结论:使用大质数作为模数,可以最大化地保证哈希值的均匀分布。因为质数不与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
举个例子,假设我们选择合数 9 作为模数,它可以被 3 整除,那么所有可以被 3 整除的 key 都会被映射到 0、3、6 这三个哈希值。
$$
modulus=9\
key={0,3,6,9,12,15,18,21,24,27,30,33,…}\
hash={0,3,6,0,3,6,0,3,6,0,3,6,…}
$$
如果输入 key 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将 modulus 替换为质数 13 ,由于 key 和 modulus 之间不存在公约数,因此输出的哈希值的均匀性会明显提升。
$$
modulus=13\
key={0,3,6,9,12,15,18,21,24,27,30,33,…}\
hash={0,3,6,9,12,2,5,8,11,1,4,7,…}
$$
值得说明的是,如果能够保证 key 是随机均匀分布的,那么选择质数或者合数作为模数都可以,它们都能输出均匀分布的哈希值。而当 key 的分布存在某种周期性时,对合数取模更容易出现聚集现象。
总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算法的稳健性。
常见的哈希算法
不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2 和 SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。
- MD5 和 SHA-1 已多次被成功攻击,因此它们被各类安全应用弃用。
- SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常用在各类安全应用与协议中。
- SHA-3 相较 SHA-2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA-2 系列。
| MD5 | SHA-1 | SHA-2 | SHA-3 | |
|---|---|---|---|---|
| 推出时间 | 1992 | 1995 | 2002 | 2008 |
| 输出长度 | 128 bit | 160 bit | 256/512 bit | 224/256/384/512 bit |
| 哈希冲突 | 较多 | 较多 | 很少 | 很少 |
| 安全等级 | 低,已被成功攻击 | 低,已被成功攻击 | 高 | 高 |
| 应用 | 已被弃用,仍用于数据完整性检查 | 已被弃用 | 加密货币交易验证、数字签名等 | 可用于替代SHA-2 |
树
二叉树
二叉树(binary tree)是一种非线性数据结构,代表“祖先”与“后代”之间的派生关系,体现了“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含值、左子节点引用和右子节点引用。
1 | /* 二叉树节点结构体 */ |
每个节点都有两个引用(指针),分别指向左子节点(left-child node)和右子节点(right-child node),该节点被称为这两个子节点的父节点(parent node)。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的左子树(left subtree),同理可得右子树(right subtree)。
在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树。如图所示,如果将“节点 2”视为父节点,则其左子节点和右子节点分别是“节点 4”和“节点 5”,左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。

二叉树常见术语
二叉树的常用术语:
- 根节点(root node):位于二叉树顶层的节点,没有父节点。
- 叶节点(leaf node):没有子节点的节点,其两个指针均指向
None。 - 边(edge):连接两个节点的线段,即节点引用(指针)。
- 节点所在的层(level):从顶至底递增,根节点所在层为 1 。
- 节点的度(degree):节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
- 二叉树的高度(height):从根节点到最远叶节点所经过的边的数量。
- 节点的深度(depth):从根节点到该节点所经过的边的数量。
- 节点的高度(height):从距离该节点最远的叶节点到该节点所经过的边的数量。

[!tip]
请注意,我们通常将“高度”和“深度”定义为“经过的边的数量”,但有些题目或教材可能会将其定义为“经过的节点的数量”。在这种情况下,高度和深度都需要加 1 。
二叉树的基本操作
初始化二叉树
与链表类似,首先初始化节点,然后构建引用(指针)。
1 | /* 初始化二叉树 */ |
插入与删除节点
与链表类似,在二叉树中插入与删除节点可以通过修改指针来实现。下图给出了一个示例。

1 | /* 插入与删除节点 */ |
[!tip]
需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除通常是由一套操作配合完成的,以实现有实际意义的操作。
常见的二叉树类型
完美二叉树(满二叉树)
如图所示,完美二叉树(perfect binary tree)所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 0 ,其余所有节点的度都为 2 ;若树的高度为 ℎ ,则节点总数为$ 2^{ℎ+1}−1 $,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。

完全二叉树
如图所示,完全二叉树(complete binary tree)只有最底层的节点未被填满,且最底层节点尽量靠左填充。请注意,完美二叉树也是一棵完全二叉树。

完满二叉树
如图所示,完满二叉树(full binary tree)除了叶节点之外,其余所有节点都有两个子节点。

平衡二叉树
如图所示,平衡二叉树(balanced binary tree)中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。

二叉树的退化
下图展示了二叉树的理想结构与退化结构。当二叉树的每层节点都被填满时,达到“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”。
- 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势。
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 𝑂(𝑛) 。

如表所示,在最佳结构和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大值或极小值。
| 完美二叉树 | 链表 | |
|---|---|---|
| 第i层的节点数量 | $2^i-1$ | 1 |
| 高度为h的树的叶节点数量 | $2^h$ | 1 |
| 高度为h的树的节点总数 | $2^{h+1}-1$ | h+1 |
| 节点总数为n的树的高度 | $log_2(n + 1) $- 1 | n-1 |
二叉树遍历
从物理结构的角度来看,树是一种基于链表的数据结构,因此其遍历方式是通过指针逐个访问节点。然而,树是一种非线性数据结构,这使得遍历树比遍历链表更加复杂,需要借助搜索算法来实现。
二叉树常见的遍历方式包括层序遍历、前序遍历、中序遍历和后序遍历等。
层序遍历
如图所示,层序遍历(level-order traversal)从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
层序遍历本质上属于广度优先遍历(breadth-first traversal),也称广度优先搜索(breadth-first search, BFS),它体现了一种“一圈一圈向外扩展”的逐层遍历方式。

广度优先遍历通常借助“队列”来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。实现代码如下:
1 | /* 层序遍历 */ |
其中:
- 时间复杂度为 𝑂(𝑛) :所有节点被访问一次,使用 𝑂(𝑛) 时间,其中 𝑛 为节点数量。
- 空间复杂度为 𝑂(𝑛) :在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在 (𝑛+1)/2 个节点,占用 𝑂(𝑛) 空间。
前序、中序、后序遍历
相应地,前序、中序和后序遍历都属于深度优先遍历(depth-first traversal),也称深度优先搜索(depth-first search, DFS),它体现了一种“先走到尽头,再回溯继续”的遍历方式。
下图展示了对二叉树进行深度优先遍历的工作原理。深度优先遍历就像是绕着整棵二叉树的外围“走”一圈,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。

递归实现为:
1 | /* 前序遍历 */ |
下图展示了前序遍历二叉树的递归过程,其可分为“递”和“归”两个逆向的部分。
- “递”表示开启新方法,程序在此过程中访问下一个节点。
- “归”表示函数返回,代表当前节点已经访问完毕。

复杂度分析
- 时间复杂度为 𝑂(𝑛) :所有节点被访问一次,使用 𝑂(𝑛) 时间。
- 空间复杂度为 𝑂(𝑛) :在最差情况下,即树退化为链表时,递归深度达到 𝑛 ,系统占用 𝑂(𝑛) 栈帧空间。
二叉树的数组表示
在链表表示下,二叉树的存储单元为节点 TreeNode ,节点之间通过指针相连接。上一节介绍了链表表示下的二叉树的各项基本操作。
那么,我们能否用数组来表示二叉树呢?答案是肯定的。
表示完美二叉树
先分析一个简单案例。给定一棵完美二叉树,我们将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。
根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:若某节点的索引为 𝑖 ,则该节点的左子节点索引为 2𝑖+1 ,右子节点索引为 2𝑖+2 。下图展示了各个节点索引之间的映射关系。

映射公式的角色相当于链表中的节点引用(指针)。给定数组中的任意一个节点,我们都可以通过映射公式来访问它的左(右)子节点。
表示任意二叉树
完美二叉树是一个特例,在二叉树的中间层通常存在许多 None 。由于层序遍历序列并不包含这些 None ,因此我们无法仅凭该序列来推测 None 的数量和分布位置。这意味着存在多种二叉树结构都符合该层序遍历序列。
如图所示,给定一棵非完美二叉树,上述数组表示方法已经失效。

为了解决此问题,我们可以考虑在层序遍历序列中显式地写出所有 None 。如下图所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。示例代码如下:
1 | /* 二叉树的数组表示 */ |

值得说明的是,完全二叉树非常适合使用数组来表示。回顾完全二叉树的定义,None 只出现在最底层且靠右的位置,因此所有 None 一定出现在层序遍历序列的末尾。
这意味着使用数组表示完全二叉树时,可以省略存储所有 None ,非常方便。下图给出了一个例子。

以下代码实现了一棵基于数组表示的二叉树,包括以下几种操作。
- 给定某节点,获取它的值、左(右)子节点、父节点。
- 获取前序遍历、中序遍历、后序遍历、层序遍历序列。
1 | /* 数组表示下的二叉树结构体 */ |
优点与局限性
二叉树的数组表示主要有以下优点。
- 数组存储在连续的内存空间中,对缓存友好,访问与遍历速度较快。
- 不需要存储指针,比较节省空间。
- 允许随机访问节点。
然而,数组表示也存在一些局限性。
- 数组存储需要连续内存空间,因此不适合存储数据量过大的树。
- 增删节点需要通过数组插入与删除操作实现,效率较低。
- 当二叉树中存在大量
None时,数组中包含的节点数据比重较低,空间利用率较低。
二叉搜索树
如图所示,二叉搜索树(binary search tree)满足以下条件。
- 对于根节点,左子树中所有节点的值 < 根节点的值 < 右子树中所有节点的值。
- 任意节点的左、右子树也是二叉搜索树,即同样满足条件
1.。

二叉搜索树的操作
我们将二叉搜索树封装为一个类 BinarySearchTree ,并声明一个成员变量 root ,指向树的根节点。
查找节点
给定目标节点值 num ,可以根据二叉搜索树的性质来查找。如图所示,我们声明一个节点 cur ,从二叉树的根节点 root 出发,循环比较节点值 cur.val 和 num 之间的大小关系。
- 若
cur.val < num,说明目标节点在cur的右子树中,因此执行cur = cur.right。 - 若
cur.val > num,说明目标节点在cur的左子树中,因此执行cur = cur.left。 - 若
cur.val = num,说明找到目标节点,跳出循环并返回该节点。

二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 𝑂(log𝑛) 时间。示例代码如下:
1 | /* 查找节点 */ |
插入节点
给定一个待插入元素 num ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作流程如下图所示。
- 查找插入位置:与查找操作相似,从根节点出发,根据当前节点值和
num的大小关系循环向下搜索,直到越过叶节点(遍历至None)时跳出循环。 - 在该位置插入节点:初始化节点
num,将该节点置于None的位置。

在代码实现中,需要注意以下两点。
- 二叉搜索树不允许存在重复节点,否则将违反其定义。因此,若待插入节点在树中已存在,则不执行插入,直接返回。
- 为了实现插入节点,我们需要借助节点
pre保存上一轮循环的节点。这样在遍历至None时,我们可以获取到其父节点,从而完成节点插入操作。
1 | /* 插入节点 */ |
与查找节点相同,插入节点使用 𝑂(log𝑛) 时间。
删除节点
先在二叉树中查找到目标节点,再将其删除。与插入节点类似,我们需要保证在删除操作完成后,二叉搜索树的“左子树 < 根节点 < 右子树”的性质仍然满足。因此,我们根据目标节点的子节点数量,分 0、1 和 2 三种情况,执行对应的删除节点操作。
如下图所示,当待删除节点的度为 0 时,表示该节点是叶节点,可以直接删除。

如图所示,当待删除节点的度为 1 时,将待删除节点替换为其子节点即可。

当待删除节点的度为 2 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,因此这个节点可以是右子树的最小节点或左子树的最大节点。
假设我们选择右子树的最小节点(中序遍历的下一个节点),则删除操作流程如下图所示。
- 找到待删除节点在“中序遍历序列”中的下一个节点,记为
tmp。 - 用
tmp的值覆盖待删除节点的值,并在树中递归删除节点tmp。

删除节点操作同样使用 𝑂(log𝑛) 时间,其中查找待删除节点需要 𝑂(log𝑛) 时间,获取中序遍历后继节点需要 𝑂(log𝑛) 时间。示例代码如下:
1 | /* 删除节点 */ |
中序遍历有序
如下图所示,二叉树的中序遍历遵循“左 → 根 → 右”的遍历顺序,而二叉搜索树满足“左子节点 < 根节点 < 右子节点”的大小关系。
这意味着在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:二叉搜索树的中序遍历序列是升序的。
利用中序遍历升序的性质,我们在二叉搜索树中获取有序数据仅需 𝑂(𝑛) 时间,无须进行额外的排序操作,非常高效。

二叉搜索树的效率
给定一组数据,我们考虑使用数组或二叉搜索树存储。观察下表,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能。只有在高频添加、低频查找删除数据的场景下,数组比二叉搜索树的效率更高。
| 无序数组 | 二叉搜索树 | |
|---|---|---|
| 查找元素 | O(n) | O(logn) |
| 插入元素 | O(1) | O(log n) |
| 删除元素 | O(n) | O(log n) |
在理想情况下,二叉搜索树是“平衡”的,这样就可以在 log𝑛 轮循环内查找任意节点。
然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为下图所示的链表,这时各种操作的时间复杂度也会退化为 𝑂(𝑛) 。

二叉搜索树常见应用
- 用作系统中的多级索引,实现高效的查找、插入、删除操作。
- 作为某些搜索算法的底层数据结构。
- 用于存储数据流,以保持其有序状态。
AVL树
在“二叉搜索树”章节中我们提到,在多次插入和删除操作后,二叉搜索树可能退化为链表。在这种情况下,所有操作的时间复杂度将从 𝑂(log𝑛) 劣化为 𝑂(𝑛) 。
如下图所示,经过两次删除节点操作,这棵二叉搜索树便会退化为链表。

再例如,在下图所示的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之劣化。

1962 年 G. M. Adelson-Velsky 和 E. M. Landis 在论文“An algorithm for the organization of information”中提出了 AVL 树。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 𝑂(log𝑛) 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。
AVL常见术语
AVL 树既是二叉搜索树,也是平衡二叉树,同时满足这两类二叉树的所有性质,因此是一种平衡二叉搜索树(balanced binary search tree)。
节点高度
由于 AVL 树的相关操作需要获取节点高度,因此我们需要为节点类添加 height 变量:
1 | /* AVL 树节点结构体 */ |
“节点高度”是指从该节点到它的最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 −1 。我们将创建两个工具函数,分别用于获取和更新节点的高度:
1 | /* 获取节点高度 */ |
节点平衡因子
节点的平衡因子(balance factor)定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用:
1 | /* 获取平衡因子 */ |
[!tip]
设平衡因子为 𝑓 ,则一棵 AVL 树的任意节点的平衡因子皆满足 −1≤𝑓≤1 。
AVL树旋转
AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,旋转操作既能保持“二叉搜索树”的性质,也能使树重新变为“平衡二叉树”。
我们将平衡因子绝对值 >1 的节点称为“失衡节点”。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面详细介绍这些旋转操作。
右旋
如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 node ,其左子节点记为 child ,执行“右旋”操作。完成右旋后,子树恢复平衡,并且仍然保持二叉搜索树的性质。

如下图所示,当节点 child 有右子节点(记为 grand_child )时,需要在右旋中添加一步:将 grand_child 作为 node 的左子节点。

“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示:
1 | /* 右旋操作 */ |
左旋
相应地,如果考虑上述失衡二叉树的“镜像”,则需要执行下图所示的“左旋”操作。

同理,如下图所示,当节点 child 有左子节点(记为 grand_child )时,需要在左旋中添加一步:将 grand_child 作为 node 的右子节点。

可以观察到,右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的。基于对称性,我们只需将右旋的实现代码中的所有的 left 替换为 right ,将所有的 right 替换为 left ,即可得到左旋的实现代码:
1 | /* 左旋操作 */ |
先左旋后右旋
对于下图中的失衡节点 3 ,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先对 child 执行“左旋”,再对 node 执行“右旋”。

先右旋后左旋
如下图所示,对于上述失衡二叉树的镜像情况,需要先对 child 执行“右旋”,再对 node 执行“左旋”。

旋转的选择
下图展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、先左旋后右旋、先右旋后左旋、左旋的操作。

如下表所示,我们通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于上图中的哪种情况。
| 失衡节点的平衡因子 | 子节点的平衡因子 | 应采用的旋转方法 |
|---|---|---|
| >1 (左偏树) | ≥0 | 右旋 |
| >1 (左偏树) | <0 | 先左旋后右旋 |
| <-1 (右偏树) | ≤0 | 左旋 |
| <-1 (右偏树) | >0 | 先右旋后左旋 |
为了便于使用,我们将旋转操作封装成一个函数。有了这个函数,我们就能对各种失衡情况进行旋转,使失衡节点重新恢复平衡。代码如下所示:
1 | /* 执行旋转操作,使该子树重新恢复平衡 */ |
AVL树常用操作
插入节点
AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡。代码如下所示:
1 | /* 插入节点 */ |
删除节点
类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶执行旋转操作,使所有失衡节点恢复平衡。代码如下所示:
1 | /* 删除节点 */ |
查找节点
AVL树的节点查找操作和二叉搜索树一致。
AVL树的经典应用
- 组织和存储大型数据,适用于高频查找、低频增删的场景。
- 用于构建数据库中的索引系统。
- 红黑树也是一种常见的平衡二叉搜索树。相较于 AVL 树,红黑树的平衡条件更宽松,插入与删除节点所需的旋转操作更少,节点增删操作的平均效率更高。
堆
堆
堆(heap)是一种满足特定条件的完全二叉树,主要可分为两种类型,如下图所示。
- 小顶堆(min heap):任意节点的值 ≤ 其子节点的值。
- 大顶堆(max heap):任意节点的值 ≥ 其子节点的值。

堆作为完全二叉树的一个特例,具有以下特性。
- 最底层节点靠左填充,其他层的节点都被填满。
- 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
- 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。
堆的常用操作
需要指出的是,许多编程语言提供的是优先队列(priority queue),这是一种抽象的数据结构,定义为具有优先级排序的队列。
实际上,堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。因此,统一称作“堆”。
堆的常用操作见下表 ,方法名需要根据编程语言来确定。
| 方法名 | 描述 | 时间复杂度 |
|---|---|---|
| push() | 元素入堆 | O(log n) |
| pop() | 堆顶元素出堆 | O(log n) |
| peek() | 访问堆顶元素 (对于大/小顶堆分别为最大/小值) | O(1) |
| size() | 获取堆的元素数量 | O(1) |
| isEmpty() | 判断堆是否为空 | O(1) |
在实际应用中,我们可以直接使用编程语言提供的堆类(或优先队列类)。
类似于排序算法中的“从小到大排列”和“从大到小排列”,我们可以通过设置一个 flag 或修改 Comparator 实现“小顶堆”与“大顶堆”之间的转换。代码如下所示:
1 | /* 初始化堆 */ |
堆的实现
下文实现的是大顶堆。若要将其转换为小顶堆,只需将所有大小逻辑判断进行逆转(例如,将 ≥ 替换为 ≤ )。
堆的存储和表示
“二叉树”章节讲过,完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,==因此我们将采用数组来存储堆==。
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。节点指针通过索引映射公式来实现。
如下图所示,给定索引 𝑖 ,其左子节点的索引为 2𝑖+1 ,右子节点的索引为 2𝑖+2 ,父节点的索引为 (𝑖−1)/2(向下整除)。当索引越界时,表示空节点或节点不存在。

我们可以将索引映射公式封装成函数,方便后续使用:
1 | /* 获取左子节点的索引 */ |
访问堆顶元素
堆顶元素即为二叉树的根节点,也就是列表的首个元素:
1 | /* 访问堆顶元素 */ |
元素入堆
给定元素 val ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏,因此需要修复从插入节点到根节点的路径上的各个节点,这个操作被称为堆化(heapify)。
考虑从入堆节点开始,从底至顶执行堆化。如下图所示,我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。

设节点总数为 𝑛 ,则树的高度为 𝑂(log𝑛) 。由此可知,堆化操作的循环轮数最多为 𝑂(log𝑛) ,元素入堆操作的时间复杂度为 𝑂(log𝑛) 。代码如下所示:
1 | /* 元素入堆 */ |
堆顶元素出堆
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化进行修复变得困难。为了尽量减少元素索引的变动,我们采用以下操作步骤。
- 交换堆顶元素与堆底元素(交换根节点与最右叶节点)。
- 交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)。
- 从根节点开始,从顶至底执行堆化。
如下图所示,“从顶至底堆化”的操作方向与“从底至顶堆化”相反,我们将根节点的值与其两个子节点的值进行比较,将最大的子节点与根节点交换。然后循环执行此操作,直到越过叶节点或遇到无须交换的节点时结束。

与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为 𝑂(log𝑛) 。代码如下所示:
1 | /* 元素出堆 */ |
堆的常见应用
- 优先队列:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 𝑂(log𝑛) ,而建堆操作为 𝑂(𝑛) ,这些操作都非常高效。
- 堆排序:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见“堆排序”章节。
- 获取最大的 𝑘 个元素:这是一个经典的算法问题,同时也是一种典型应用,例如选择热度前 10 的新闻作为微博热搜,选取销量前 10 的商品等。
建堆操作
在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为“建堆操作”。
借助入堆操作实现
我们首先创建一个空堆,然后遍历列表,依次对每个元素执行“入堆操作”,即先将元素添加至堆的尾部,再对该元素执行“从底至顶”堆化。
每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆是“自上而下”构建的。
设元素数量为 𝑛 ,每个元素的入堆操作使用 𝑂(log𝑛) 时间,因此该建堆方法的时间复杂度为 𝑂(𝑛log𝑛) 。
通过遍历堆化实现
实际上,我们可以实现一种更为高效的建堆方法,共分为两步。
- 将列表所有元素原封不动地添加到堆中,此时堆的性质尚未得到满足。
- 倒序遍历堆(层序遍历的倒序),依次对每个非叶节点执行“从顶至底堆化”。
每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆。而由于是倒序遍历,因此堆是“自下而上”构建的。
之所以选择倒序遍历,是因为这样能够保证当前节点之下的子树已经是合法的子堆,这样堆化当前节点才是有效的。
值得说明的是,由于叶节点没有子节点,因此它们天然就是合法的子堆,无须堆化。如以下代码所示,最后一个非叶节点是最后一个节点的父节点,我们从它开始倒序遍历并执行堆化:
1 | /* 构造函数,根据切片建堆 */ |
复杂度分析
下面,我们来尝试推算第二种建堆方法的时间复杂度。
- 假设完全二叉树的节点数量为 𝑛 ,则叶节点数量为 (𝑛+1)/2 ,其中 / 为向下整除。因此需要堆化的节点数量为 (𝑛−1)/2 。
- 在从顶至底堆化的过程中,每个节点最多堆化到叶节点,因此最大迭代次数为二叉树高度 log𝑛 。
将上述两者相乘,可得到建堆过程的时间复杂度为 𝑂(𝑛log𝑛) 。但这个估算结果并不准确,因为我们没有考虑到二叉树底层节点数量远多于顶层节点的性质。
接下来我们来进行更为准确的计算。为了降低计算难度,假设给定一个节点数量为 𝑛 、高度为 ℎ 的“完美二叉树”,该假设不会影响计算结果的正确性。

如上图所示,节点“从顶至底堆化”的最大迭代次数等于该节点到叶节点的距离,而该距离正是“节点高度”。因此,我们可以对各层的“节点数量 × 节点高度”求和,得到所有节点的堆化迭代次数的总和。
$$
𝑇(ℎ)=2^0ℎ+2^1(ℎ−1)+2^2(ℎ−2)+⋯+2^{(ℎ−1)}×1
$$
化简上式需要借助中学的数列知识,先将 𝑇(ℎ) 乘以 2 ,得到:
$$
𝑇(ℎ)=2^0ℎ+2^1(ℎ−1)+2^2(ℎ−2)+⋯+2^{ℎ−1}×1\
2𝑇(ℎ)=2^1ℎ+2^2(ℎ−1)+2^3(ℎ−2)+⋯+2^ℎ×1
$$
使用错位相减法,用下式 2𝑇(ℎ) 减去上式 𝑇(ℎ) ,可得:
$$
2𝑇(ℎ)−𝑇(ℎ)=𝑇(ℎ)=−2^0ℎ+2^1+2^2+⋯+2^{ℎ−1}+2^ℎ
$$
观察上式,发现 𝑇(ℎ) 是一个等比数列,可直接使用求和公式,得到时间复杂度为:
$$
𝑇(ℎ)=2\frac{1−2^ℎ}{1−2}−ℎ\
=2^{ℎ+1}−ℎ−2\
=𝑂(2^ℎ)
$$
进一步,高度为 ℎ 的完美二叉树的节点数量为 $𝑛=2^{ℎ+1}−1$ ,易得复杂度为 $𝑂(2^ℎ)=𝑂(𝑛)$ 。以上推算表明,输入列表并建堆的时间复杂度为 𝑂(𝑛) ,非常高效。
Top-k问题
[!note]
给定一个长度为 𝑛 的无序数组
nums,请返回数组中最大的 𝑘 个元素。
对于该问题,我们先介绍两种思路比较直接的解法,再介绍效率更高的堆解法。
方法一:遍历选择
我们可以进行下图所示的 𝑘 轮遍历,分别在每轮中提取第 1、2、…、𝑘 大的元素,时间复杂度为 𝑂(𝑛𝑘) 。
此方法只适用于 𝑘≪𝑛 的情况,因为当 𝑘 与 𝑛 比较接近时,其时间复杂度趋向于 $𝑂(𝑛^2)$ ,非常耗时。

[!tip]
当 𝑘=𝑛 时,我们可以得到完整的有序序列,此时等价于“选择排序”算法。
方法二:排序
如下图所示,我们可以先对数组 nums 进行排序,再返回最右边的 𝑘 个元素,时间复杂度为 𝑂(𝑛log𝑛) 。
显然,该方法“超额”完成任务了,因为我们只需找出最大的 𝑘 个元素即可,而不需要排序其他元素。

方法三:建堆
我们可以基于堆更加高效地解决 Top-k 问题,流程如下图所示。
- 初始化一个小顶堆,其堆顶元素最小。
- 先将数组的前 𝑘 个元素依次入堆。
- 从第 𝑘+1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。
- 遍历完成后,堆中保存的就是最大的 𝑘 个元素。

1 | /* 元素入堆 */ |
总共执行了 𝑛 轮入堆和出堆,堆的最大长度为 𝑘 ,因此时间复杂度为 𝑂(𝑛log𝑘) 。该方法的效率很高,当 𝑘 较小时,时间复杂度趋向 𝑂(𝑛) ;当 𝑘 较大时,时间复杂度不会超过 𝑂(𝑛log𝑛) 。
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大的 𝑘 个元素的动态更新。
图
图
图(graph)是一种非线性数据结构,由顶点(vertex)和边(edge)组成。我们可以将图 𝐺 抽象地表示为一组顶点 𝑉 和一组边 𝐸 的集合。以下示例展示了一个包含 5 个顶点和 7 条边的图。
$$
𝑉={1,2,3,4,5}\
𝐸={(1,2),(1,3),(1,5),(2,3),(2,4),(2,5),(4,5)}\
𝐺={𝑉,𝐸}
$$
如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作一种从链表拓展而来的数据结构。如下图所示,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,因而更为复杂。

图的常见类型和术语
根据边是否具有方向,可分为无向图(undirected graph)和有向图(directed graph),如下图所示。
- 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。
- 在有向图中,边具有方向性,即 𝐴→𝐵 和 𝐴←𝐵 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。

根据所有顶点是否连通,可分为连通图(connected graph)和非连通图(disconnected graph),如下图所示。
- 对于连通图,从某个顶点出发,可以到达其余任意顶点。
- 对于非连通图,从某个顶点出发,至少有一个顶点无法到达。

我们还可以为边添加“权重”变量,从而得到如下图所示的有权图(weighted graph)。例如在《王者荣耀》等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。

图数据结构包含以下常用术语。
- 邻接(adjacency):当两顶点之间存在边相连时,称这两顶点“邻接”。在上图中,顶点 1 的邻接顶点为顶点 2、3、5。
- 路径(path):从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在上图中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。
- 度(degree):一个顶点拥有的边数。对于有向图,入度(in-degree)表示有多少条边指向该顶点,出度(out-degree)表示有多少条边从该顶点指出。
图的表示
图的常用表示方式包括“邻接矩阵”和“邻接表”。以下使用无向图进行举例。
邻接矩阵
设图的顶点数量为 𝑛 ,邻接矩阵(adjacency matrix)使用一个 𝑛×𝑛 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 1 或 0 表示两个顶点之间是否存在边。
如下图所示,设邻接矩阵为 𝑀、顶点列表为 𝑉 ,那么矩阵元素 𝑀[𝑖,𝑗]=1 表示顶点 𝑉[𝑖] 到顶点 𝑉[𝑗] 之间存在边,反之 𝑀[𝑖,𝑗]=0 表示两顶点之间无边。

邻接矩阵具有以下特性。
- 在简单图中,顶点不能与自身相连,此时邻接矩阵主对角线元素没有意义。
- 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线对称。
- 将邻接矩阵的元素从 1 和 0 替换为权重,则可表示有权图。
使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查改操作的效率很高,时间复杂度均为 𝑂(1) 。然而,矩阵的空间复杂度为 𝑂($𝑛^2$) ,内存占用较多。
邻接表
邻接表(adjacency list)使用 𝑛 个链表来表示图,链表节点表示顶点。第 𝑖 个链表对应顶点 𝑖 ,其中存储了该顶点的所有邻接顶点(与该顶点相连的顶点)。下图展示了一个使用邻接表存储的图的示例。

邻接表仅存储实际存在的边,而边的总数通常远小于 $𝑛^2$ ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。
观察上图 ,邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 𝑂(𝑛) 优化至 𝑂(log𝑛) ;还可以把链表转换为哈希表,从而将时间复杂度降至 𝑂(1) 。
图的常见应用
如下表所示,许多现实系统可以用图来建模,相应的问题也可以约化为图计算问题。
| 顶点 | 边 | 图计算问题 | |
|---|---|---|---|
| 社交网络 | 用户 | 好友关系 | 潜在好友推荐 |
| 地铁线路 | 站点 | 站点间的连通性 | 最短路线推荐 |
| 太阳系 | 星体 | 星体间的万有引力作用 | 行星轨道计算 |
图的基础操作
图的基础操作可分为对“边”的操作和对“顶点”的操作。在“邻接矩阵”和“邻接表”两种表示方法下,实现方式有所不同。
基于邻接矩阵的实现
给定一个顶点数量为 𝑛 的无向图,则各种操作的实现方式如下图所示。
- 添加或删除边:直接在邻接矩阵中修改指定的边即可,使用 𝑂(1) 时间。而由于是无向图,因此需要同时更新两个方向的边。
- 添加顶点:在邻接矩阵的尾部添加一行一列,并全部填 0 即可,使用 𝑂(𝑛) 时间。
- 删除顶点:在邻接矩阵中删除一行一列。当删除首行首列时达到最差情况,需要将 $(𝑛−1)^2 $个元素“向左上移动”,从而使用 $𝑂(𝑛^2)$ 时间。
- 初始化:传入 𝑛 个顶点,初始化长度为 𝑛 的顶点列表
vertices,使用 𝑂(𝑛) 时间;初始化 𝑛×𝑛 大小的邻接矩阵adjMat,使用 $𝑂(𝑛^2) $时间。

以下是基于邻接矩阵表示图的实现代码:
1 | /* 基于邻接矩阵实现的无向图结构体 */ |
基于邻接表的实现
设无向图的顶点总数为 𝑛、边总数为 𝑚 ,则可根据下图所示的方法实现各种操作。
- 添加边:在顶点对应链表的末尾添加边即可,使用 𝑂(1) 时间。因为是无向图,所以需要同时添加两个方向的边。
- 删除边:在顶点对应链表中查找并删除指定边,使用 𝑂(𝑚) 时间。在无向图中,需要同时删除两个方向的边。
- 添加顶点:在邻接表中添加一个链表,并将新增顶点作为链表头节点,使用 𝑂(1) 时间。
- 删除顶点:需遍历整个邻接表,删除包含指定顶点的所有边,使用 𝑂(𝑛+𝑚) 时间。
- 初始化:在邻接表中创建 𝑛 个顶点和 2𝑚 条边,使用 𝑂(𝑛+𝑚) 时间。

以下是邻接表的代码实现。对比上图,实际代码有以下不同。
- 为了方便添加与删除顶点,以及简化代码,我们使用列表(动态数组)来代替链表。
- 使用哈希表来存储邻接表,
key为顶点实例,value为该顶点的邻接顶点列表(链表)。
另外,我们在邻接表中使用 Vertex 类来表示顶点,这样做的原因是:如果与邻接矩阵一样,用列表索引来区分不同顶点,那么假设要删除索引为 𝑖 的顶点,则需遍历整个邻接表,将所有大于 𝑖 的索引全部减 1 ,效率很低。而如果每个顶点都是唯一的 Vertex 实例,删除某一顶点之后就无须改动其他顶点了。
1 | /* 节点结构体 */ |
效率对比
设图中共有 𝑛 个顶点和 𝑚 条边,下表对比了邻接矩阵和邻接表的时间效率和空间效率。
| 邻接矩阵 | 邻接表 (链表) | 邻接表 (哈希表) | |
|---|---|---|---|
| 判断是否邻接 | O(1) | O(m) | O(1) |
| 添加边 | O(1) | O(1) | O(1) |
| 删除边 | O(1) | O(m) | O(1) |
| 添加顶点 | O(n) | O(1) | O(1) |
| 删除顶点 | $O(n^2)$ | O(n + m) | O(n) |
| 内存空间占用 | $O(n^2)$ | O(n + m) | O(n+ m) |
观察上表 ,似乎邻接表(哈希表)的时间效率与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。
图的遍历
树代表的是“一对多”的关系,而图则具有更高的自由度,可以表示任意的“多对多”关系。因此,我们可以把树看作图的一种特例。显然,树的遍历操作也是图的遍历操作的一种特例。
图和树都需要应用搜索算法来实现遍历操作。图的遍历方式也可分为两种:
- 广度优先遍历
- 深度优先遍历。
广度优先遍历
广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张。如下图所示,从左上角顶点出发,首先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。

算法实现:
BFS 通常借助队列来实现,代码如下所示。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。
- 将遍历起始顶点
startVet加入队列,并开启循环。 - 在循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所有邻接顶点加入到队列尾部。
- 循环步骤
2.,直到所有顶点被访问完毕后结束。
为了防止重复遍历顶点,我们需要借助一个哈希集合 visited 来记录哪些节点已被访问。
[!tip]
哈希集合可以看作一个只存储
key而不存储value的哈希表,它可以在 𝑂(1) 时间复杂度下进行key的增删查改操作。根据key的唯一性,哈希集合通常用于数据去重等场景。
1 | /* 节点队列结构体 */ |

[!NOTE]
广度优先遍历的序列是否唯一?
不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,而多个相同距离的顶点的遍历顺序允许被任意打乱。以上图为例,顶点 1、3 的访问顺序可以交换,顶点 2、4、6 的访问顺序也可以任意交换。
复杂度分析
- 时间复杂度:所有顶点都会入队并出队一次,使用 𝑂(|𝑉|) 时间;在遍历邻接顶点的过程中,由于是无向图,因此所有边都会被访问 2 次,使用 𝑂(2|𝐸|) 时间;总体使用 𝑂(|𝑉|+|𝐸|) 时间。
- 空间复杂度:列表
res,哈希集合visited,队列que中的顶点数量最多为 |𝑉| ,使用 𝑂(|𝑉|) 空间。
深度优先遍历
深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。如上图所示,从左上角顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。

算法实现:
这种“走到尽头再返回”的算法范式通常基于递归来实现。与广度优先遍历类似,在深度优先遍历中,我们也需要借助一个哈希集合 visited 来记录已被访问的顶点,以避免重复访问顶点。
1 | /* 检查顶点是否已被访问 */ |
深度优先遍历的算法流程如下图所示。
- 直虚线代表向下递推,表示开启了一个新的递归方法来访问新顶点。
- 曲虚线代表向上回溯,表示此递归方法已经返回,回溯到了开启此方法的位置。
为了加深理解,建议将下图与代码结合起来,在脑中模拟(或者用笔画下来)整个 DFS 过程,包括每个递归方法何时开启、何时返回。

[!tip]
深度优先遍历的序列是否唯一?
与广度优先遍历类似,深度优先遍历序列的顺序也不是唯一的。给定某顶点,先往哪个方向探索都可以,即邻接顶点的顺序可以任意打乱,都是深度优先遍历。
以树的遍历为例,“根 → 左 → 右”“左 → 根 → 右”“左 → 右 → 根”分别对应前序、中序、后序遍历,它们展示了三种遍历优先级,然而这三者都属于深度优先遍历。
时间复杂度:
- 时间复杂度:所有顶点都会被访问 1 次,使用 𝑂(|𝑉|) 时间;所有边都会被访问 2 次,使用 𝑂(2|𝐸|) 时间;总体使用 𝑂(|𝑉|+|𝐸|) 时间。
- 空间复杂度:列表
res,哈希集合visited顶点数量最多为 |𝑉| ,递归深度最大为 |𝑉| ,因此使用 𝑂(|𝑉|) 空间。
搜索
二分查找
二分查找(binary search)是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮缩小一半搜索范围,直至找到目标元素或搜索区间为空为止。
给定一个长度为 𝑛 的数组
nums,元素按从小到大的顺序排列且不重复。请查找并返回元素target在该数组中的索引。若数组不包含该元素,则返回 −1 。示例如下图所示。
如下图所示,我们先初始化指针 𝑖=0 和 𝑗=𝑛−1 ,分别指向数组首元素和尾元素,代表搜索区间 [0,𝑛−1] 。请注意,中括号表示闭区间,其包含边界值本身。接下来,循环执行以下两步。
- 计算中点索引 𝑚=⌊(𝑖+𝑗)/2⌋ ,其中 ⌊⌋ 表示向下取整操作。
- 判断nums[m]和target的大小关系,分为以下三种情况。
- 当
nums[m] < target时,说明target在区间 [𝑚+1,𝑗] 中,因此执行 𝑖=𝑚+1 。 - 当
nums[m] > target时,说明target在区间 [𝑖,𝑚−1] 中,因此执行 𝑗=𝑚−1 。 - 当
nums[m] = target时,说明找到target,因此返回索引 𝑚 。
- 当
若数组不包含目标元素,搜索区间最终会缩小为空。此时返回 −1 。

值得注意的是,由于 𝑖 和 𝑗 都是 int 类型,因此 𝑖+𝑗 可能会超出 int 类型的取值范围。为了避免大数越界,我们通常采用公式 𝑚=⌊𝑖+(𝑗−𝑖)/2⌋ 来计算中点。代码如下所示:
1 | /* 二分查找(双闭区间) */ |
时间复杂度为 𝑂(log𝑛) :在二分循环中,区间每轮缩小一半,因此循环次数为$ log_2𝑛 $。
空间复杂度为 𝑂(1) :指针 𝑖 和 𝑗 使用常数大小空间。
区间表示法
除了上述双闭区间外,常见的区间表示还有“左闭右开”区间,定义为 [0,𝑛) ,即左边界包含自身,右边界不包含自身。在该表示下,区间 [𝑖,𝑗) 在 𝑖=𝑗 时为空。我们可以基于该表示实现具有相同功能的二分查找算法:
1 | /* 二分查找(左闭右开区间) */ |
如下图所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
由于“双闭区间”表示中的左右边界都被定义为闭区间,因此通过指针 𝑖 和指针 𝑗 缩小区间的操作也是对称的。这样更不容易出错,因此一般建议采用“双闭区间”的写法。

优点与局限性
二分查找在时间和空间方面都有较好的性能。
- 二分查找的时间效率高。在大数据量下,对数阶的时间复杂度具有显著优势。例如,当数据大小 $𝑛=2^{20}$ 时,线性查找需要$ 2^{20}=1048576 $轮循环,而二分查找仅需 $log_22^{20}=20 $轮循环。
- 二分查找无须额外空间。相较于需要借助额外空间的搜索算法(例如哈希查找),二分查找更加节省空间。
然而,二分查找并非适用于所有情况,主要有以下原因。
- ==二分查找仅适用于有序数据==。若输入数据无序,为了使用二分查找而专门进行排序,得不偿失。因为排序算法的时间复杂度通常为 𝑂(𝑛log𝑛) ,比线性查找和二分查找都更高。对于频繁插入元素的场景,为保持数组有序性,需要将元素插入到特定位置,时间复杂度为 𝑂(𝑛) ,也是非常昂贵的。
- ==二分查找仅适用于数组==。二分查找需要跳跃式(非连续地)访问元素,而在链表中执行跳跃式访问的效率较低,因此不适合应用在链表或基于链表实现的数据结构。
- 小数据量下,线性查找性能更佳。在线性查找中,每轮只需 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,当数据量 𝑛 较小时,线性查找反而比二分查找更快。
二分查找插入点
二分查找不仅可用于搜索目标元素,还可用于解决许多变种问题,比如搜索目标元素的插入位置。
无重复元素的情况
给定一个长度为 𝑛 的有序数组
nums和一个元素target,数组不存在重复元素。现将target插入数组nums中,并保持其有序性。若数组中已存在元素target,则插入到其左方。请返回插入后target在数组中的索引。示例如下图所示。
如果想复用上一节的二分查找代码,则需要回答以下两个问题。
问题一:当数组中包含 target 时,插入点的索引是否是该元素的索引?
题目要求将 target 插入到相等元素的左边,这意味着新插入的 target 替换了原来 target 的位置。也就是说,当数组包含 target 时,插入点的索引就是该 target 的索引。
问题二:当数组中不存在 target 时,插入点是哪个元素的索引?
进一步思考二分查找过程:当 nums[m] < target 时 𝑖 移动,这意味着指针 𝑖 在向大于等于 target 的元素靠近。同理,指针 𝑗 始终在向小于等于 target 的元素靠近。
因此二分结束时一定有:𝑖 指向首个大于 target 的元素,𝑗 指向首个小于 target 的元素。易得当数组不包含 target 时,插入索引为 𝑖 。代码如下所示:
1 | /* 二分查找插入点(无重复元素) */ |
存在重复元素的情况
在上一题的基础上,规定数组可能包含重复元素,其余不变。
假设数组中存在多个
target,则普通二分查找只能返回其中一个target的索引,而无法确定该元素的左边和右边还有多少target。题目要求将目标元素插入到最左边,所以我们需要查找数组中最左一个
target的索引。初步考虑通过下图所示的步骤实现。
- 执行二分查找,得到任意一个
target的索引,记为 𝑘 。- 从索引 𝑘 开始,向左进行线性遍历,当找到最左边的
target时返回。
此方法虽然可用,但其包含线性查找,因此时间复杂度为 𝑂(𝑛) 。当数组中存在很多重复的
target时,该方法效率很低。现考虑拓展二分查找代码。如下图所示,整体流程保持不变,每轮先计算中点索引 𝑚 ,再判断
target和nums[m]的大小关系,分为以下几种情况。
- 当
nums[m] < target或nums[m] > target时,说明还没有找到target,因此采用普通二分查找的缩小区间操作,从而使指针 𝑖 和 𝑗 向target靠近。- 当
nums[m] == target时,说明小于target的元素在区间 [𝑖,𝑚−1] 中,因此采用 𝑗=𝑚−1 来缩小区间,从而使指针 𝑗 向小于target的元素靠近。循环完成后,𝑖 指向最左边的
target,𝑗 指向首个小于target的元素,因此索引 𝑖 就是插入点。
观察以下代码,判断分支 nums[m] > target 和 nums[m] == target 的操作相同,因此两者可以合并。
即便如此,我们仍然可以将判断条件保持展开,因为其逻辑更加清晰、可读性更好。
1 | /* 二分查找插入点(存在重复元素) */ |
总的来看,二分查找无非就是给指针 𝑖 和 𝑗 分别设定搜索目标,目标可能是一个具体的元素(例如 target ),也可能是一个元素范围(例如小于 target 的元素)。
在不断的循环二分中,指针 𝑖 和 𝑗 都逐渐逼近预先设定的目标。最终,它们或是成功找到答案,或是越过边界后停止。
二分查找边界
查找左边界
给定一个长度为 𝑛 的有序数组
nums,其中可能包含重复元素。请返回数组中最左一个元素target的索引。若数组中不包含该元素,则返回 −1 。
回忆二分查找插入点的方法,搜索完成后 𝑖 指向最左一个 target ,因此查找插入点本质上是在查找最左一个 target 的索引。
考虑通过查找插入点的函数实现查找左边界。请注意,数组中可能不包含 target ,这种情况可能导致以下两种结果。
- 插入点的索引 𝑖 越界。
- 元素
nums[i]与target不相等。
当遇到以上两种情况时,直接返回 −1 即可。代码如下所示:
1 | /* 二分查找最左一个 target */ |
查找右边界
那么如何查找最右一个 target 呢?最直接的方式是修改代码,替换在 nums[m] == target 情况下的指针收缩操作。代码在此省略,有兴趣的读者可以自行实现。
下面我们介绍两种更加取巧的方法。
复用查找左边界
实际上,我们可以利用查找最左元素的函数来查找最右元素,具体方法为:将查找最右一个 target 转化为查找最左一个 target + 1。
如下图所示,查找完成后,指针 𝑖 指向最左一个 target + 1(如果存在),而 𝑗 指向最右一个 target ,因此返回 𝑗 即可。

请注意,返回的插入点是 𝑖 ,因此需要将其减 1 ,从而获得 𝑗 :
1 | /* 二分查找最右一个 target */ |
转化为查找元素
我们知道,当数组不包含 target 时,最终 𝑖 和 𝑗 会分别指向首个大于、小于 target 的元素。
因此,如下图所示,我们可以构造一个数组中不存在的元素,用于查找左右边界。
- 查找最左一个
target:可以转化为查找target - 0.5,并返回指针 𝑖 。 - 查找最右一个
target:可以转化为查找target + 0.5,并返回指针 𝑗 。

代码在此省略,以下两点值得注意。
- 给定数组不包含小数,这意味着我们无须关心如何处理相等的情况。
- 因为该方法引入了小数,所以需要将函数中的变量
target改为浮点数类型(Python 无须改动)。
哈希优化策略
在算法题中,我们常通过将线性查找替换为哈希查找来降低算法的时间复杂度。我们借助一个算法题来加深理解。
给定一个整数数组
nums和一个目标元素target,请在数组中搜索“和”为target的两个元素,并返回它们的数组索引。返回任意一个解即可。
线性查找
==以时间换空间==,考虑直接遍历所有可能的组合。如下图所示,我们开启一个两层循环,在每轮中判断两个整数的和是否为 target ,若是,则返回它们的索引。

1 | /* 方法一:暴力枚举 */ |
此方法的时间复杂度为 𝑂(𝑛2) ,空间复杂度为 𝑂(1) ,在大数据量下非常耗时。
哈希查找
==以空间换时间==,考虑借助一个哈希表,键值对分别为数组元素和元素索引。循环遍历数组,每轮执行下图所示的步骤。
- 判断数字
target - nums[i]是否在哈希表中,若是,则直接返回这两个元素的索引。 - 将键值对
nums[i]和索引i添加进哈希表。

实现代码如下所示,仅需单层循环即可:
1 | /* 哈希表 */ |
此方法通过哈希查找将时间复杂度从 𝑂(𝑛2) 降至 𝑂(𝑛) ,大幅提升运行效率。
由于需要维护一个额外的哈希表,因此空间复杂度为 𝑂(𝑛) 。尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法。
重拾搜索算法
搜索算法(searching algorithm)用于在数据结构(例如数组、链表、树或图)中搜索一个或一组满足特定条件的元素。
搜索算法可根据实现思路分为以下两类。
- 通过遍历数据结构来定位目标元素,例如数组、链表、树和图的遍历等。
- 利用数据组织结构或数据包含的先验信息,实现高效元素查找,例如二分查找、哈希查找和二叉搜索树查找等。
不难发现,这些知识点都已在前面的章节中介绍过,因此搜索算法对于我们来说并不陌生。在本节中,我们将从更加系统的视角切入,重新审视搜索算法。
暴力搜索
暴力搜索通过遍历数据结构的每个元素来定位目标元素。
- “线性搜索”适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到目标元素或到达另一端仍没有找到目标元素为止。
- “广度优先搜索”和“深度优先搜索”是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。深度优先搜索从初始节点开始,沿着一条路径走到头,再回溯并尝试其他路径,直到遍历完整个数据结构。
暴力搜索的优点是简单且通用性好,无须对数据做预处理和借助额外的数据结构。
然而,此类算法的时间复杂度为 𝑂(𝑛) ,其中 𝑛 为元素数量,因此在数据量较大的情况下性能较差。
自适应搜索
自适应搜索利用数据的特有属性(例如有序性)来优化搜索过程,从而更高效地定位目标元素。
- “二分查找”利用数据的有序性实现高效查找,仅适用于数组。
- “哈希查找”利用哈希表将搜索数据和目标数据建立为键值对映射,从而实现查询操作。
- “树查找”在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除节点,从而定位目标元素。
此类算法的优点是效率高,时间复杂度可达到 𝑂(log𝑛) 甚至 𝑂(1) 。
然而,使用这些算法往往需要对数据进行预处理。例如,二分查找需要预先对数组进行排序,哈希查找和树查找都需要借助额外的数据结构,维护这些数据结构也需要额外的时间和空间开销。
[!tip]
自适应搜索算法常被称为查找算法,主要用于在特定数据结构中快速检索目标元素。
搜索方法选取
给定大小为 𝑛 的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法从中搜索目标元素。各个方法的工作原理如下图所示。

上述几种方法的操作效率与特性如下表所示。
| 线性搜索 | 二分查找 | 树查找 | 哈希查找 | |
|---|---|---|---|---|
| 查找元素 | $O(n)$ | $O(logn)$ | $O(logn)$ | $O(1)$ |
| 插入元素 | $O(1)$ | $O(n)$ | $O(logn)$ | $O(1)$ |
| 删除元素 | $O(n)$ | $O(n)$ | $O(logn)$ | $O(1)$ |
| 额外空间 | $O(1)$ | $O(1)$ | $O(n)$ | $O(n)$ |
| 数据预处理 | / | 排序$O(nlogn)$ | 建树$O(nlogn)$ | 建哈希表$O(n)$ |
| 数据是否有序 | 无序 | 有序 | 有序 | 无序 |
搜索算法的选择还取决于数据体量、搜索性能要求、数据查询与更新频率等。
线性搜索
- 通用性较好,无须任何数据预处理操作。假如我们仅需查询一次数据,那么其他三种方法的数据预处理的时间比线性搜索的时间还要更长。
- 适用于体量较小的数据,此情况下时间复杂度对效率影响较小。
- 适用于数据更新频率较高的场景,因为该方法不需要对数据进行任何额外维护。
二分查找
- 适用于大数据量的情况,效率表现稳定,最差时间复杂度为 𝑂(log𝑛) 。
- 数据量不能过大,因为存储数组需要连续的内存空间。
- 不适用于高频增删数据的场景,因为维护有序数组的开销较大。
哈希查找
- 适合对查询性能要求很高的场景,平均时间复杂度为 𝑂(1) 。
- 不适合需要有序数据或范围查找的场景,因为哈希表无法维护数据的有序性。
- 对哈希函数和哈希冲突处理策略的依赖性较高,具有较大的性能劣化风险。
- 不适合数据量过大的情况,因为哈希表需要额外空间来最大程度地减少冲突,从而提供良好的查询性能。
树查找
- 适用于海量数据,因为树节点在内存中是分散存储的。
- 适合需要维护有序数据或范围查找的场景。
- 在持续增删节点的过程中,二叉搜索树可能产生倾斜,时间复杂度劣化至 𝑂(𝑛) 。
- 若使用 AVL 树或红黑树,则各项操作可在 𝑂(log𝑛) 效率下稳定运行,但维护树平衡的操作会增加额外的开销。
排序
排序算法
排序算法(sorting algorithm)用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更高效地查找、分析和处理。
如下图所示,排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。

评价维度
运行效率:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(时间复杂度中的常数项变小)。对于大数据量的情况,运行效率显得尤为重要。
就地性:顾名思义,原地排序通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
稳定性:稳定排序在完成排序后,相等元素在数组中的相对顺序不发生改变。
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,非稳定排序可能导致输入数据的有序性丧失:
1 | # 输入数据是按照姓名排序好的 |
自适应性:自适应排序能够利用输入数据已有的顺序信息来减少计算量,达到更优的时间效率。自适应排序算法的最佳时间复杂度通常优于平均时间复杂度。
是否基于比较:基于比较的排序依赖比较运算符(<、=、>)来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 𝑂(𝑛log𝑛) 。而非比较排序不使用比较运算符,时间复杂度可达 𝑂(𝑛) ,但其通用性相对较差。
理想排序算法
运行快、原地、稳定、自适应、通用性好。显然,迄今为止尚未发现兼具以上所有特性的排序算法。因此,在选择排序算法时,需要根据具体的数据特点和问题需求来决定。
选择排序
选择排序(selection sort)的工作原理非常简单:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
设数组的长度为 𝑛 ,选择排序的算法流程如下图所示。
- 初始状态下,所有元素未排序,即未排序(索引)区间为 [0,𝑛−1] 。
- 选取区间 [0,𝑛−1] 中的最小元素,将其与索引 0 处的元素交换。完成后,数组前 1 个元素已排序。
- 选取区间 [1,𝑛−1] 中的最小元素,将其与索引 1 处的元素交换。完成后,数组前 2 个元素已排序。
- 以此类推。经过 𝑛−1 轮选择与交换后,数组前 𝑛−1 个元素已排序。
- 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。

在代码中,我们用K来记录未排序区间内的最小元素:
1 | /* 选择排序 */ |
- 时间复杂度为 𝑂($𝑛^2)$、非自适应排序:外循环共 𝑛−1 轮,第一轮的未排序区间长度为 𝑛 ,最后一轮的未排序区间长度为 2 ,即各轮外循环分别包含 𝑛、𝑛−1、…、3、2 轮内循环,求和为 (𝑛−1)(𝑛+2)2 。
- 空间复杂度为 𝑂(1)、原地排序:指针 𝑖 和 𝑗 使用常数大小的额外空间。
- 非稳定排序:如下图所示,元素
nums[i]有可能被交换至与其相等的元素的右边,导致两者的相对顺序发生改变。

冒泡排序
冒泡排序(bubble sort)通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。
如下图所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换二者。遍历完成后,最大的元素会被移动到数组的最右端。

设数组的长度为 𝑛 ,冒泡排序的步骤如下图所示。
- 首先,对 𝑛 个元素执行“冒泡”,将数组的最大元素交换至正确位置。
- 接下来,对剩余 𝑛−1 个元素执行“冒泡”,将第二大元素交换至正确位置。
- 以此类推,经过 𝑛−1 轮“冒泡”后,前 𝑛−1 大的元素都被交换至正确位置。
- 仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。

1 | /* 冒泡排序 */ |
我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 flag 来监测这种情况,一旦出现就立即返回。
经过优化,冒泡排序的最差时间复杂度和平均时间复杂度仍为 𝑂($𝑛^2$) ;但当输入数组完全有序时,可达到最佳时间复杂度 𝑂(𝑛) 。
1 | /* 冒泡排序(标志优化)*/ |
- 时间复杂度为 𝑂(𝑛2)、自适应排序:各轮“冒泡”遍历的数组长度依次为 𝑛−1、𝑛−2、…、2、1 ,总和为 (𝑛−1)𝑛/2 。在引入
flag优化后,最佳时间复杂度可达到 𝑂(𝑛) 。 - 空间复杂度为 𝑂(1)、原地排序:指针 𝑖 和 𝑗 使用常数大小的额外空间。
- 稳定排序:由于在“冒泡”中遇到相等元素不交换。
插入排序
插入排序(insertion sort)是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。
下图展示了数组插入元素的操作流程。设基准元素为 base ,我们需要将从目标索引到 base 之间的所有元素向右移动一位,然后将 base 赋值给目标索引。

插入排序的整体流程如下图所示。
- 初始状态下,数组的第 1 个元素已完成排序。
- 选取数组的第 2 个元素作为
base,将其插入到正确位置后,数组的前 2 个元素已排序。 - 选取第 3 个元素作为
base,将其插入到正确位置后,数组的前 3 个元素已排序。 - 以此类推,在最后一轮中,选取最后一个元素作为
base,将其插入到正确位置后,所有元素均已排序。

1 | /* 插入排序 */ |
- 时间复杂度为 𝑂($𝑛^2$)、自适应排序:在最差情况下,每次插入操作分别需要循环 𝑛−1、𝑛−2、…、2、1 次,求和得到 (𝑛−1)𝑛/2 ,因此时间复杂度为 𝑂($𝑛^2$) 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 𝑂(𝑛) 。
- 空间复杂度为 𝑂(1)、原地排序:指针 𝑖 和 𝑗 使用常数大小的额外空间。
- 稳定排序:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。
插入排序的时间复杂度为 𝑂(𝑛2) ,而我们即将学习的快速排序的时间复杂度为 𝑂(𝑛log𝑛) 。尽管插入排序的时间复杂度更高,但在数据量较小的情况下,插入排序通常更快。
这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类 𝑂(𝑛log𝑛) 的算法属于基于分治策略的排序算法,往往包含更多单元计算操作。而在数据量较小时,𝑛2 和 𝑛log𝑛 的数值比较接近,复杂度不占主导地位,每轮中的单元操作数量起到决定性作用。
实际上,许多编程语言(例如 Java)的内置排序函数采用了插入排序,大致思路为:对于长数组,采用基于分治策略的排序算法,例如快速排序;对于短数组,直接使用插入排序。
虽然冒泡排序、选择排序和插入排序的时间复杂度都为 𝑂(𝑛2) ,但在实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序,主要有以下原因。
- 冒泡排序基于元素交换实现,需要借助一个临时变量,共涉及 3 个单元操作;插入排序基于元素赋值实现,仅需 1 个单元操作。因此,冒泡排序的计算开销通常比插入排序更高。
- 选择排序在任何情况下的时间复杂度都为 𝑂($𝑛^2$) 。如果给定一组部分有序的数据,插入排序通常比选择排序效率更高。
- 选择排序不稳定,无法应用于多级排序。
快速排序
快速排序(quick sort)是一种基于分治策略的排序算法,运行高效,应用广泛。
快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程如下图所示。
- 选取数组最左端元素作为基准数,初始化两个指针
i和j分别指向数组的两端。 - 设置一个循环,在每轮中使用
i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。 - 循环执行步骤
2.,直到i和j相遇时停止,最后将基准数交换至两个子数组的分界线。

哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 ≤ 基准数 ≤ 右子数组任意元素”。因此,我们接下来只需对这两个子数组进行排序。
1 | /* 元素交换 */ |
[!note]
快速排序的分治策略:
哨兵划分的实质是将一个较长数组的排序问题简化为两个较短数组的排序问题。
1 | /* 元素交换 */ |
算法流程
快速排序的整体流程如下图所示。
- 首先,对原数组执行一次“哨兵划分”,得到未排序的左子数组和右子数组。
- 然后,对左子数组和右子数组分别递归执行“哨兵划分”。
- 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。

1 | /* 快速排序 */ |
算法特性
- 时间复杂度为 𝑂(𝑛log𝑛)、非自适应排序:在平均情况下,哨兵划分的递归层数为 log𝑛 ,每层中的总循环数为 𝑛 ,总体使用 𝑂(𝑛log𝑛) 时间。在最差情况下,每轮哨兵划分操作都将长度为 𝑛 的数组划分为长度为 0 和 𝑛−1 的两个子数组,此时递归层数达到 𝑛 ,每层中的循环数为 𝑛 ,总体使用 𝑂($𝑛^2$) 时间。
- 空间复杂度为 𝑂(𝑛)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度 𝑛 ,使用 𝑂(𝑛) 栈帧空间。排序操作是在原数组上进行的,未借助额外数组。
- 非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
[!tip]
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,主要有以下原因。
- 出现最差情况的概率很低:虽然快速排序的最差时间复杂度为 𝑂($𝑛^2$) ,没有归并排序稳定,但在绝大多数情况下,快速排序能在 𝑂(𝑛log𝑛) 的时间复杂度下运行。
- 缓存使用效率高:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
- 复杂度的常数系数小:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。
基准数优化
快速排序在某些输入下的时间效率可能降低。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 𝑛−1、右子数组长度为 0 。如此递归下去,每轮哨兵划分后都有一个子数组的长度为 0 ,分治策略失效,快速排序退化为“冒泡排序”的近似形式。
为了尽量避免这种情况发生,我们可以优化哨兵划分中的基准数的选取策略。例如,我们可以随机选取一个元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意。
需要注意的是,编程语言通常生成的是“伪随机数”。如果我们针对伪随机数序列构建一个特定的测试样例,那么快速排序的效率仍然可能劣化。
为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选元素的中位数作为基准数。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 𝑂($n^2$) 的概率大大降低。示例代码如下:
1 | /* 选取三个候选元素的中位数 */ |
尾递归优化
在某些输入下,快速排序可能占用空间较多。以完全有序的输入数组为例,设递归中的子数组长度为 𝑚 ,每轮哨兵划分操作都将产生长度为 0 的左子数组和长度为 𝑚−1 的右子数组,这意味着每一层递归调用减少的问题规模非常小(只减少一个元素),递归树的高度会达到 𝑛−1 ,此时需要占用 𝑂(𝑛) 大小的栈帧空间。
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。由于较短子数组的长度不会超过 𝑛/2 ,因此这种方法能确保递归深度不超过 log𝑛 ,从而将最差空间复杂度优化至 𝑂(log𝑛) 。代码如下所示:
1 | /* 快速排序(尾递归优化) */ |
归并排序
归并排序(merge sort)是一种基于分治策略的排序算法,包含下图所示的“划分”和“合并”阶段。
- 划分阶段:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。
- 合并阶段:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。

如下图所示,“划分阶段”从顶至底递归地将数组从中点切分为两个子数组。
- 计算数组中点
mid,递归划分左子数组(区间[left, mid])和右子数组(区间[mid + 1, right])。 - 递归执行步骤
1.,直至子数组区间长度为 1 时终止。
“合并阶段”从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为 1 的子数组开始合并,合并阶段中的每个子数组都是有序的。

观察发现,==归并排序与二叉树后序遍历的递归顺序是一致的==。
- 后序遍历:先递归左子树,再递归右子树,最后处理根节点。
- 归并排序:先递归左子数组,再递归右子数组,最后处理合并。
归并排序的实现如以下代码所示。请注意,nums 的待合并区间为 [left, right] ,而 tmp 的对应区间为 [0, right - left] 。
1 | /* 合并左子数组和右子数组 */ |
- 时间复杂度为 𝑂(𝑛log𝑛)、非自适应排序:划分产生高度为 log𝑛 的递归树,每层合并的总操作数量为 𝑛 ,因此总体时间复杂度为 𝑂(𝑛log𝑛) 。
- 空间复杂度为 𝑂(𝑛)、非原地排序:递归深度为 log𝑛 ,使用 𝑂(log𝑛) 大小的栈帧空间。合并操作需要借助辅助数组实现,使用 𝑂(𝑛) 大小的额外空间。
- 稳定排序:在合并过程中,相等元素的次序保持不变。
对于链表,归并排序相较于其他排序算法具有显著优势,可以将链表排序任务的空间复杂度优化至 𝑂(1) 。
- 划分阶段:可以使用“迭代”替代“递归”来实现链表划分工作,从而省去递归使用的栈帧空间。
- 合并阶段:在链表中,节点增删操作仅需改变引用(指针)即可实现,因此合并阶段(将两个短有序链表合并为一个长有序链表)无须创建额外链表。
堆排序
堆排序(heap sort)是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序。
- 输入数组并建立小顶堆,此时最小元素位于堆顶。
- 不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列。
以上方法虽然可行,但需要借助一个额外数组来保存弹出的元素,比较浪费空间。在实际中,我们通常使用一种更加优雅的实现方式。
设数组的长度为 𝑛 ,堆排序的流程如下图所示。
- 输入数组并建立大顶堆。完成后,最大元素位于堆顶。
- 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。完成交换后,堆的长度减 1 ,已排序元素数量加 1 。
- 从堆顶元素开始,从顶到底执行堆化操作(sift down)。完成堆化后,堆的性质得到修复。
- 循环执行第
2.步和第3.步。循环 𝑛−1 轮后,即可完成数组排序。

在代码实现中,我们使用了与“堆”章节相同的从顶至底堆化 sift_down() 函数。值得注意的是,由于堆的长度会随着提取最大元素而减小,因此我们需要给 sift_down() 函数添加一个长度参数 𝑛 ,用于指定堆的当前有效长度。代码如下所示:
1 | /* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */ |
- 时间复杂度为 𝑂(𝑛log𝑛)、非自适应排序:建堆操作使用 𝑂(𝑛) 时间。从堆中提取最大元素的时间复杂度为 𝑂(log𝑛) ,共循环 𝑛−1 轮。
- 空间复杂度为 𝑂(1)、原地排序:几个指针变量使用 𝑂(1) 空间。元素交换和堆化操作都是在原数组上进行的。
- 非稳定排序:在交换堆顶元素和堆底元素时,相等元素的相对位置可能发生变化。
桶排序
前述几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 𝑂(𝑛log𝑛) 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性阶。
桶排序(bucket sort)是分治策略的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。
考虑一个长度为 𝑛 的数组,其元素是范围 [0,1) 内的浮点数。桶排序的流程如下图所示。
- 初始化 𝑘 个桶,将 𝑛 个元素分配到 𝑘 个桶中。
- 对每个桶分别执行排序(这里采用编程语言的内置排序函数)。
- 按照桶从小到大的顺序合并结果。

1 | /* 桶排序 */ |
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。
- 时间复杂度为 𝑂(𝑛+𝑘) :假设元素在各个桶内平均分布,那么每个桶内的元素数量为 𝑛𝑘 。假设排序单个桶使用 𝑂(𝑛𝑘log𝑛𝑘) 时间,则排序所有桶使用 𝑂(𝑛log𝑛𝑘) 时间。当桶数量 𝑘 比较大时,时间复杂度则趋向于 𝑂(𝑛) 。合并结果时需要遍历所有桶和元素,花费 𝑂(𝑛+𝑘) 时间。在最差情况下,所有数据被分配到一个桶中,且排序该桶使用 𝑂(𝑛2) 时间。
- 空间复杂度为 𝑂(𝑛+𝑘)、非原地排序:需要借助 𝑘 个桶和总共 𝑛 个元素的额外空间。
- 桶排序是否稳定取决于排序桶内元素的算法是否稳定。
桶排序的时间复杂度理论上可以达到 𝑂(𝑛) ,关键在于将元素均匀分配到各个桶中,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 个,各个桶中的商品数量差距会非常大。
为实现平均分配,我们可以先设定一条大致的分界线,将数据粗略地分到 3 个桶中。分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等。
如下图所示,这种方法本质上是创建一棵递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。

如果我们提前知道商品价格的概率分布,则可以根据数据概率分布设置每个桶的价格分界线。值得注意的是,数据分布并不一定需要特意统计,也可以根据数据特点采用某种概率模型进行近似。
如下图所示,我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中。

计数排序
计数排序(counting sort)通过统计元素数量来实现排序,通常应用于整数数组。
先来看一个简单的例子。给定一个长度为 𝑛 的数组 nums ,其中的元素都是“非负整数”,计数排序的整体流程如下图所示。
- 遍历数组,找出其中的最大数字,记为 𝑚 ,然后创建一个长度为 𝑚+1 的辅助数组
counter。 - 借助
counter统计nums中各数字的出现次数,其中counter[num]对应数字num的出现次数。统计方法很简单,只需遍历nums(设当前数字为num),每轮将counter[num]增加 1 即可。 - 由于
counter的各个索引天然有序,因此相当于所有数字已经排序好了。接下来,我们遍历counter,根据各数字出现次数从小到大的顺序填入nums即可。

1 | /* 计数排序 */ |
[!note]
从桶排序的角度看,我们可以将计数排序中的计数数组
counter的每个索引视为一个桶,将统计数量的过程看作将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
细心的读者可能发现了,如果输入数据是对象,上述步骤 3. 就失效了。假设输入数据是商品对象,我们想按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
那么如何才能得到原数据的排序结果呢?我们首先计算 counter 的“前缀和”。顾名思义,索引 i 处的前缀和 prefix[i] 等于数组前 i 个元素之和:
$$
prefix[i]=\sum_{j=0}^{i}counter[j]
$$
前缀和具有明确的意义,prefix[num] - 1 代表元素 num 在结果数组 res 中最后一次出现的索引。这个信息非常关键,因为它告诉我们各个元素应该出现在结果数组的哪个位置。接下来,我们倒序遍历原数组 nums 的每个元素 num ,在每轮迭代中执行以下两步。
- 将
num填入数组res的索引prefix[num] - 1处。 - 令前缀和
prefix[num]减小 1 ,从而得到下次放置num的索引。
遍历完成后,数组 res 中就是排序好的结果,最后使用 res 覆盖原数组 nums 即可。下图展示了完整的计数排序流程。

计数排序的实现代码如下所示:
1 | /* 计数排序 */ |
- 时间复杂度为 𝑂(𝑛+𝑚)、非自适应排序 :涉及遍历
nums和遍历counter,都使用线性时间。一般情况下 𝑛≫𝑚 ,时间复杂度趋于 𝑂(𝑛) 。 - 空间复杂度为 𝑂(𝑛+𝑚)、非原地排序:借助了长度分别为 𝑛 和 𝑚 的数组
res和counter。 - 稳定排序:由于向
res中填充元素的顺序是“从右向左”的,因此倒序遍历nums可以避免改变相等元素之间的相对位置,从而实现稳定排序。实际上,正序遍历nums也可以得到正确的排序结果,但结果是非稳定的。
看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序。然而,使用计数排序的前置条件相对较为严格。
计数排序只适用于非负整数。若想将其用于其他类型的数据,需要确保这些数据可以转换为非负整数,并且在转换过程中不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去。
计数排序适用于数据量大但数据范围较小的情况。比如,在上述示例中 𝑚 不能太大,否则会占用过多空间。而当 𝑛≪𝑚 时,计数排序使用 𝑂(𝑚) 时间,可能比 𝑂(𝑛log𝑛) 的排序算法还要慢。
基数排序
上一节介绍了计数排序,它适用于数据量 𝑛 较大但数据范围 𝑚 较小的情况。假设我们需要对 𝑛=106 个学号进行排序,而学号是一个 8 位数字,这意味着数据范围 𝑚=108 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
基数排序(radix sort)的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。
以学号数据为例,假设数字的最低位是第 1 位,最高位是第 8 位,基数排序的流程如下图所示。
- 初始化位数 𝑘=1 。
- 对学号的第 𝑘 位执行“计数排序”。完成后,数据会根据第 𝑘 位从小到大排序。
- 将 𝑘 增加 1 ,然后返回步骤
2.继续迭代,直到所有位都排序完成后结束。

下面剖析代码实现。对于一个 𝑑 进制的数字 𝑥 ,要获取其第 𝑘 位 $𝑥_𝑘$ ,可以使用以下计算公式:
$$
𝑥_𝑘=\lfloor 𝑥\times 𝑑^{𝑘−1}\rfloor mod \ 𝑑
$$
其中 $\lfloor a \rfloor$ 表示对浮点数 𝑎 向下取整,而 $mod \ 𝑑$ 表示对 𝑑 取模(取余)。对于学号数据,𝑑=10 且 𝑘∈[1,8] 。
此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 𝑘 位进行排序:
1 | /* 获取元素 num 的第 k 位,其中 exp = 10^(k-1) */ |
相较于计数排序,基数排序适用于数值范围较大的情况,但前提是数据必须可以表示为固定位数的格式,且位数不能过大。例如,浮点数不适合使用基数排序,因为其位数 𝑘 过大,可能导致时间复杂度 𝑂(𝑛𝑘)≫𝑂(𝑛2) 。
- 时间复杂度为 𝑂(𝑛𝑘)、非自适应排序:设数据量为 𝑛、数据为 𝑑 进制、最大位数为 𝑘 ,则对某一位执行计数排序使用 𝑂(𝑛+𝑑) 时间,排序所有 𝑘 位使用 𝑂((𝑛+𝑑)𝑘) 时间。通常情况下,𝑑 和 𝑘 都相对较小,时间复杂度趋向 𝑂(𝑛) 。
- 空间复杂度为 𝑂(𝑛+𝑑)、非原地排序:与计数排序相同,基数排序需要借助长度为 𝑛 和 𝑑 的数组
res和counter。 - 稳定排序:当计数排序稳定时,基数排序也稳定;当计数排序不稳定时,基数排序无法保证得到正确的排序结果。
算法对比

分治
概述
分治(divide and conquer),全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包括“分”和“治”两个步骤。
- 分(划分阶段):递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
- 治(合并阶段):从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。
如下图所示,“归并排序”是分治策略的典型应用之一。
- 分:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。
- 治:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)。

如何判断分治问题
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据。
- 问题可以分解:原问题可以分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
- 子问题是独立的:子问题之间没有重叠,互不依赖,可以独立解决。
- 子问题的解可以合并:原问题的解通过合并子问题的解得来。
显然,归并排序满足以上三个判断依据。
- 问题可以分解:递归地将数组(原问题)划分为两个子数组(子问题)。
- 子问题是独立的:每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
- 子问题的解可以合并:两个有序子数组(子问题的解)可以合并为一个有序数组(原问题的解)。
通过分治提升效率
分治不仅可以有效地解决算法问题,往往还可以提升算法效率。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。
那么,我们不禁发问:为什么分治可以提升算法效率,其底层逻辑是什么?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这几步的效率为什么比直接解决原问题的效率更高?这个问题可以从操作数量和并行计算两方面来讨论。
操作数量的优化
以“冒泡排序”为例,其处理一个长度为 𝑛 的数组需要 𝑂($𝑛^2$) 时间。假设我们按照下图所示的方式,将数组从中点处分为两个子数组,则划分需要 𝑂(𝑛) 时间,排序每个子数组需要 $𝑂((\frac{n}{2})^2) $时间,合并两个子数组需要 𝑂(𝑛) 时间,总体时间复杂度为:
$$
O(n+(\frac{n}{2})^2\times 2+n)=O(\frac{n^2}{2}+2n)
$$

接下来,我们计算以下不等式,其左边和右边分别为划分前和划分后的操作总数:
$$
𝑛^2>\frac{n^2}{2}+2𝑛\
𝑛^2−\frac{n^2}{2}−2𝑛>0\
𝑛(𝑛−4)>0
$$
这意味着当 𝑛>4 时,划分后的操作数量更少,排序效率应该更高。请注意,划分后的时间复杂度仍然是平方阶$ 𝑂(𝑛^2) $,只是复杂度中的常数项变小了。
进一步想,如果我们把子数组不断地再从中点处划分为两个子数组,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是“归并排序”,时间复杂度为 𝑂(𝑛log𝑛) 。
再思考,如果我们多设置几个划分点,将原数组平均划分为 𝑘 个子数组呢?这种情况与“桶排序”非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 𝑂(𝑛+𝑘) 。
并行计算优化
我们知道,分治生成的子问题是相互独立的,因此通常可以并行解决。也就是说,分治不仅可以降低算法的时间复杂度,还有利于操作系统的并行优化。
并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。
比如在下图所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可将所有桶的排序任务分散到各个计算单元,完成后再合并结果。

分治常见应用
一方面,分治可以用来解决许多经典算法问题。
- 寻找最近点对:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后找出跨越两部分的最近点对。
- 大整数乘法:例如 Karatsuba 算法,它将大整数乘法分解为几个较小的整数的乘法和加法。
- 矩阵乘法:例如 Strassen 算法,它将大矩阵乘法分解为多个小矩阵的乘法和加法。
- 汉诺塔问题:汉诺塔问题可以通过递归解决,这是典型的分治策略应用。
- 求解逆序对:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以利用分治的思想,借助归并排序进行求解。
另一方面,分治在算法和数据结构的设计中应用得非常广泛。
- 二分查找:二分查找是将有序数组从中点索引处分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,并在剩余区间执行相同的二分操作。
- 归并排序:本节开头已介绍,不再赘述。
- 快速排序:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,另一子数组的元素比基准值大,再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
- 桶排序:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的元素依次取出,从而得到一个有序数组。
- 树:例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为分治策略的应用。
- 堆:堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。
- 哈希表:虽然哈希表并不直接应用分治,但某些哈希冲突解决方案间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。
可以看出,分治是一种“润物细无声”的算法思想,隐含在各种算法与数据结构之中。
分治搜索策略
我们已经学过,搜索算法分为两大类。
- 暴力搜索:它通过遍历数据结构实现,时间复杂度为 𝑂(𝑛) 。
- 自适应搜索:它利用特有的数据组织形式或先验信息,时间复杂度可达到 𝑂(log𝑛) 甚至 𝑂(1) 。
实际上,时间复杂度为 𝑂(log𝑛) 的搜索算法通常是基于分治策略实现的,例如二分查找和树。
- 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元素),这个过程一直持续到数组为空或找到目标元素为止。
- 树是分治思想的代表,在二叉搜索树、AVL 树、堆等数据结构中,各种操作的时间复杂度皆为 𝑂(log𝑛) 。
二分查找的分治策略如下所示。
- 问题可以分解:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
- 子问题是独立的:在二分查找中,每轮只处理一个子问题,它不受其他子问题的影响。
- 子问题的解无须合并:二分查找旨在查找一个特定元素,因此不需要将子问题的解进行合并。当子问题得到解决时,原问题也会同时得到解决。
分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选项。
给定一个长度为 𝑛 的有序数组
nums,其中所有元素都是唯一的,请查找元素target。
从分治角度,我们将搜索区间 [𝑖,𝑗] 对应的子问题记为 𝑓(𝑖,𝑗) 。
以原问题 𝑓(0,𝑛−1) 为起始点,通过以下步骤进行二分查找。
- 计算搜索区间 [𝑖,𝑗] 的中点 𝑚 ,根据它排除一半搜索区间。
- 递归求解规模减小一半的子问题,可能为 𝑓(𝑖,𝑚−1) 或 𝑓(𝑚+1,𝑗) 。
- 循环第
1.步和第2.步,直至找到target或区间为空时返回。
下图展示了在数组中二分查找元素 6 的分治过程。

在实现代码中,我们声明一个递归函数 dfs() 来求解问题 𝑓(𝑖,𝑗) :
1 | /* 二分查找:问题 f(i, j) */ |
构建树问题
给定一棵二叉树的前序遍历
preorder和中序遍历inorder,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点(如下图所示)。

判断是否为分治问题
原问题定义为从 preorder 和 inorder 构建二叉树,是一个典型的分治问题。
- 问题可以分解:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每棵子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
- 子问题是独立的:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
- 子问题的解可以合并:一旦得到了左子树和右子树(子问题的解),我们就可以将它们链接到根节点上,得到原问题的解。
如何划分子树
根据以上分析,这道题可以使用分治来求解,但如何通过前序遍历 preorder 和中序遍历 inorder 来划分左子树和右子树呢?
根据定义,preorder 和 inorder 都可以划分为三个部分。
- 前序遍历:
[ 根节点 | 左子树 | 右子树 ],例如上图的树对应[ 3 9 2 1 7 ]。 - 中序遍历:
[ 左子树 | 根节点 | 右子树 ],例如上图的树对应[ 9 3 1 2 7 ]。
以上图数据为例,我们可以通过下图所示的步骤得到划分结果。
- 前序遍历的首元素 3 是根节点的值。
- 查找根节点 3 在
inorder中的索引,利用该索引可将inorder划分为[ 9 3 1 2 7 ]。 - 根据
inorder的划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将preorder划分为[ 3 9 2 1 7 ]。

基于变量描述子树区间
根据以上划分方法,我们已经得到根节点、左子树、右子树在 preorder 和 inorder 中的索引区间。而为了描述这些索引区间,我们需要借助几个指针变量。
- 将当前树的根节点在
preorder中的索引记为 𝑖 。 - 将当前树的根节点在
inorder中的索引记为 𝑚 。 - 将当前树在
inorder中的索引区间记为 [𝑙,𝑟] 。
如下表所示,通过以上变量即可表示根节点在 preorder 中的索引,以及子树在 inorder 中的索引区间。
| 根节点在preorder中的索引 | 子树在inorder 中的索引区间 | |
|---|---|---|
| 当前树 | i | [l,r] |
| 左子树 | i+1 | [l,m - 1] |
| 右子树 | i+1+ (m- l) | [m + 1,r] |
请注意,右子树根节点索引中的 (𝑚−𝑙) 的含义是“左子树的节点数量”,建议结合下图理解。

代码实现
为了提升查询 𝑚 的效率,我们借助一个哈希表 hmap 来存储数组 inorder 中元素到索引的映射:
1 | /* 构建二叉树:分治 */ |
下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(引用)是在向上“归”的过程中建立的。

每个递归函数内的前序遍历 preorder 和中序遍历 inorder 的划分结果如下图所示。

设树的节点数量为 𝑛 ,初始化每一个节点(执行一个递归函数 dfs() )使用 𝑂(1) 时间。因此总体时间复杂度为 𝑂(𝑛) 。
哈希表存储 inorder 元素到索引的映射,空间复杂度为 𝑂(𝑛) 。在最差情况下,即二叉树退化为链表时,递归深度达到 𝑛 ,使用 𝑂(𝑛) 的栈帧空间。因此总体空间复杂度为 𝑂(𝑛) 。
汉诺塔问题
在归并排序和构建二叉树中,我们都是将原问题分解为两个规模为原问题一半的子问题。然而对于汉诺塔问题,我们采用不同的分解策略。
给定三根柱子,记为
A、B和C。起始状态下,柱子A上套着 𝑛 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 𝑛 个圆盘移到柱子C上,并保持它们的原有顺序不变(如下图所示)。在移动圆盘的过程中,需要遵守以下规则。
- 圆盘只能从一根柱子顶部拿出,从另一根柱子顶部放入。
- 每次只能移动一个圆盘。
- 小圆盘必须时刻位于大圆盘之上。
我们将规模为 𝑖 的汉诺塔问题记作 𝑓(𝑖) 。例如 𝑓(3) 代表将 3 个圆盘从 A 移动至 C 的汉诺塔问题。
考虑基本情况
如下图所示,对于问题 𝑓(1) ,即当只有一个圆盘时,我们将它直接从 A 移动至 C 即可。

如下图所示,对于问题 𝑓(2) ,即当有两个圆盘时,由于要时刻满足小圆盘在大圆盘之上,因此需要借助 B 来完成移动。
- 先将上面的小圆盘从
A移至B。 - 再将大圆盘从
A移至C。 - 最后将小圆盘从
B移至C。

解决问题 𝑓(2) 的过程可总结为:将两个圆盘借助 B 从 A 移至 C 。其中,C 称为目标柱、B 称为缓冲柱。
子问题分解
对于问题 𝑓(3) ,即当有三个圆盘时,情况变得稍微复杂了一些。
因为已知 𝑓(1) 和 𝑓(2) 的解,所以我们可从分治角度思考,将 A 顶部的两个圆盘看作一个整体,执行下图所示的步骤。这样三个圆盘就被顺利地从 A 移至 C 了。
- 令
B为目标柱、C为缓冲柱,将两个圆盘从A移至B。 - 将
A中剩余的一个圆盘从A直接移动至C。 - 令
C为目标柱、A为缓冲柱,将两个圆盘从B移至C。

从本质上看,我们将问题 𝑓(3) 划分为两个子问题 𝑓(2) 和一个子问题 𝑓(1) 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解可以合并。
至此,我们可总结出下图所示的解决汉诺塔问题的分治策略:将原问题 𝑓(𝑛) 划分为两个子问题 𝑓(𝑛−1) 和一个子问题 𝑓(1) ,并按照以下顺序解决这三个子问题。
- 将 𝑛−1 个圆盘借助
C从A移至B。 - 将剩余 1 个圆盘从
A直接移至C。 - 将 𝑛−1 个圆盘借助
A从B移至C。
对于这两个子问题 𝑓(𝑛−1) ,可以通过相同的方式进行递归划分,直至达到最小子问题 𝑓(1) 。而 𝑓(1) 的解是已知的,只需一次移动操作即可。

代码实现
在代码中,我们声明一个递归函数 dfs(i, src, buf, tar) ,它的作用是将柱 src 顶部的 𝑖 个圆盘借助缓冲柱 buf 移动至目标柱 tar :
1 | /* 移动一个圆盘 */ |
如下图所示,汉诺塔问题形成一棵高度为 𝑛 的递归树,每个节点代表一个子问题,对应一个开启的 dfs() 函数,因此时间复杂度为 𝑂($2^𝑛$) ,空间复杂度为 𝑂(𝑛) 。

[!tip]
汉诺塔问题源自一个古老的传说。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 64 个大小不一的金圆盘。僧侣们不断地移动圆盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。
然而,即使僧侣们每秒钟移动一次,总共需要大约 264≈1.84×1019 秒,合约 5850 亿年,远远超过了现在对宇宙年龄的估计。所以,倘若这个传说是真的,我们应该不需要担心世界末日的到来。
回溯
回溯算法
回溯算法(backtracking algorithm)是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。
回溯算法通常采用“深度优先搜索”来遍历解空间。在“二叉树”章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
例题一:
给定一棵二叉树,搜索并记录所有值为 7 的节点,请返回节点列表。
对于此题,我们前序遍历这棵树,并判断当前节点的值是否为 7 ,若是,则将该节点的值加入结果列表 res 之中。相关过程实现如下图和以下代码所示:
1 | /* 前序遍历:例题一 */ |

尝试与回退
之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。
对于例题一,访问每个节点都代表一次“尝试”,而越过叶节点或返回父节点的 return 则表示“回退”。
值得说明的是,回退并不仅仅包括函数返回。为解释这一点,我们对例题一稍作拓展。
例题二:
在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径。
在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 7 的节点时,则复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解。代码如下所示:
1 | /* 前序遍历:例题二 */ |
在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从 path 中弹出,以恢复本次尝试之前的状态。
观察下图所示的过程,我们可以将尝试和回退理解为“前进”与“撤销”,两个操作互为逆向。

剪枝
复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”。
例题三:
在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为 3 的节点。
为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 3 的节点,则提前返回,不再继续搜索。代码如下所示:
1 | /* 前序遍历:例题三 */ |
“剪枝”是一个非常形象的名词。如下图所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。

框架代码
接下来,我们尝试将回溯的“尝试、回退、剪枝”的主体框架提炼出来,提升代码的通用性。
在以下框架代码中,state 表示问题的当前状态,choices 表示当前状态下可以做出的选择:
1 | /* 回溯算法框架 */ |
接下来,我们基于框架代码来解决例题三。状态 state 为节点遍历路径,选择 choices 为当前节点的左子节点和右子节点,结果 res 是路径列表:
1 | /* 判断当前状态是否为解 */ |
根据题意,我们在找到值为 7 的节点后应该继续搜索,因此需要将记录解之后的 return 语句删除。下图对比了保留或删除 return 语句的搜索过程。

相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰唆,但通用性更好。实际上,许多回溯问题可以在该框架下解决。我们只需根据具体问题来定义 state 和 choices ,并实现框架中的各个方法即可。
常用术语
为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例,如下表所示。
| 名词 | 定义 | 例题三 |
|---|---|---|
| 解(solution) | 解是满足问题特定条件的答案,可能有一个或多个 | 根节点到节点7的满足约束条件的所有 路径 |
| 约束条件 (constraint) | 约束条件是问题中限制解的可行性的条件, 通常用于剪枝 | 路径中不包含节点3 |
| 状态(state) | 状态表示问题在某一时刻的情况,包括已经做出的选择 降 | 当前已访问的节点路径,即path 节点 列表 |
| 尝试(attempt) | 尝试是根据可用选择来探索解空间的过程, 包括做出选择,更新状态,检查是否为解 | 递归访问左(右)子节点,将节点添加 1 进 path,判断节点的值是否为7 |
| 回退 (backtracking) | 回退指遇到不满足约束条件的状态时,撤销前面做出的选择,回到上一个状态 | 当越过叶节点、结束节点访问、遇到值 为3的节点时终止搜索,函数返回 |
| 剪枝(pruning) | 剪枝是根据问题特性和约束条件避免无意义 的搜索路径的方法,可提高搜索效率 | 当遇到值为3的节点时,则不再继续搜 索 |
[!tip]
问题、解、状态等概念是通用的,在分治、回溯、动态规划、贪心等算法中都有涉及。
优点与局限
回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优点在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。
- 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
- 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何优化效率,常见的效率优化方法有两种。
- 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
- 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
回溯典型例题
回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。
搜索问题:这类问题的目标是找到满足特定条件的解决方案。
- 全排列问题:给定一个集合,求出其所有可能的排列组合。
- 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
- 汉诺塔问题:给定三根柱子和一系列大小不同的圆盘,要求将所有圆盘从一根柱子移动到另一根柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
约束满足问题:这类问题的目标是找到满足所有约束条件的解。
- 𝑛 皇后:在 𝑛×𝑛 的棋盘上放置 𝑛 个皇后,使得它们互不攻击。
- 数独:在 9×9 的网格中填入数字 1 ~ 9 ,使得每行、每列和每个 3×3 子网格中的数字不重复。
- 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。
组合优化问题:这类问题的目标是在一个组合空间中找到满足某些条件的最优解。
- 0-1 背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选择物品使得总价值最大。
- 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
- 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。
请注意,对于许多组合优化问题,回溯不是最优解决方案。
- 0-1 背包问题通常使用动态规划解决,以达到更高的时间效率。
- 旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等。
- 最大团问题是图论中的一个经典问题,可用贪心算法等启发式算法来解决。
全排列问题
全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出其中元素的所有可能的排列。
下表列举了几个示例数据,包括输入数组和对应的所有排列。
| 输入数组 | 所有排列 |
|---|---|
| [1] | [1] |
| [1,2] | [1,2], [2,1] |
| [1,2,3] | [1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1] |
无相等元素
输入一个整数数组,其中不包含重复元素,返回所有可能的排列。
从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果。假设输入数组为 [1,2,3] ,如果我们先选择 1 ,再选择 3 ,最后选择 2 ,则获得排列 [1,3,2] 。回退表示撤销一个选择,之后继续尝试其他选择。
从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。请注意,每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的。
如下图所示,我们可以将搜索过程展开成一棵递归树,树中的每个节点代表当前状态 state 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。

为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 selected ,其中 selected[i] 表示 choices[i] 是否已被选择,并基于它实现以下剪枝操作。
- 在做出选择
choice[i]后,我们就将selected[i]赋值为 True ,代表它已被选择。 - 遍历选择列表
choices时,跳过所有已被选择的节点,即剪枝。
如下图所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1 和元素 3 的分支。

该剪枝操作将搜索空间大小从$ 𝑂(𝑛^𝑛) $减小至 $𝑂(𝑛!)$ 。
1 | /* 回溯算法:全排列 I */ |
有相等元素
输入一个整数数组,数组中可能包含重复元素,返回所有不重复的排列。
假设输入数组为 [1,1,2] 。为了方便区分两个重复元素 1 ,我们将第二个 1 记为$\widehat{1}$ 。
如下图所示,上述方法生成的排列有一半是重复的。

那么如何去除重复的排列呢?最直接地,考虑借助一个哈希集合,直接对排列结果进行去重。然而这样做不够优雅,因为生成重复排列的搜索分支没有必要,应当提前识别并剪枝,这样可以进一步提升算法效率。
观察下图,在第一轮中,选择 1 或选择 $\widehat{1}$ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把$\hat{1}$剪枝。
同理,在第一轮选择 2 之后,第二轮选择中的 1 和$\hat{1}$也会产生重复分支,因此也应将第二轮的$\hat{1}$剪枝。
从本质上看,我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次。

在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希集合 duplicated ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝:
1 | /* 回溯算法:全排列 II */ |
假设元素两两之间互不相同,则 𝑛 个元素共有 𝑛! 种排列(阶乘);在记录结果时,需要复制长度为 𝑛 的列表,使用 𝑂(𝑛) 时间。因此时间复杂度为 𝑂(𝑛!𝑛) 。
最大递归深度为 𝑛 ,使用 𝑂(𝑛) 栈帧空间。selected 使用 𝑂(𝑛) 空间。同一时刻最多共有 𝑛 个 duplicated ,使用 $𝑂(𝑛^2)$ 空间。因此空间复杂度为$ 𝑂(𝑛^2)$ 。
两种剪枝对比
请注意,虽然 selected 和 duplicated 都用于剪枝,但两者的目标不同。
- 重复选择剪枝:整个搜索过程中只有一个
selected。它记录的是当前状态中包含哪些元素,其作用是避免某个元素在state中重复出现。 - 相等元素剪枝:每轮选择(每个调用的
backtrack函数)都包含一个duplicated。它记录的是在本轮遍历(for循环)中哪些元素已被选择过,其作用是保证相等元素只被选择一次。
下图展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。

子集和问题
无重复元素
给定一个正整数数组
nums和一个目标正整数target,请找出所有可能的组合,使得组合中的元素和等于target。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中不应包含重复组合。
例如,输入集合 {3,4,5} 和目标整数 9 ,解为 {3,3,3},{4,5} 。需要注意以下两点。
- 输入集合中的元素可以被无限次重复选取。
- 子集不区分元素顺序,比如 {4,5} 和 {5,4} 是同一个子集。
-
参考全排列解法:
类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于
target时,就将子集记录至结果列表。而与全排列问题不同的是,本题集合中的元素可以被无限次选取,因此无须借助
selected布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30/* 回溯算法:子集和 I */
void backtrack(int target, int total, int *choices, int choicesSize) {
// 子集和等于 target 时,记录解
if (total == target) {
for (int i = 0; i < stateSize; i++) {
res[resSize][i] = state[i];
}
resColSizes[resSize++] = stateSize;
return;
}
// 遍历所有选择
for (int i = 0; i < choicesSize; i++) {
// 剪枝:若子集和超过 target ,则跳过该选择
if (total + choices[i] > target) {
continue;
}
// 尝试:做出选择,更新元素和 total
state[stateSize++] = choices[i];
// 进行下一轮选择
backtrack(target, total + choices[i], choices, choicesSize);
// 回退:撤销选择,恢复到之前的状态
stateSize--;
}
}
/* 求解子集和 I(包含重复子集) */
void subsetSumINaive(int *nums, int numsSize, int target) {
resSize = 0; // 初始化解的数量为0
backtrack(target, 0, nums, numsSize);
}向以上代码输入数组 [3,4,5] 和目标元素 9 ,输出结果为 [3,3,3],[4,5],[5,4] 。虽然成功找出了所有和为 9 的子集,但其中存在重复的子集 [4,5] 和 [5,4] 。
这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如下图所示,先选 4 后选 5 与先选 5 后选 4 是不同的分支,但对应同一个子集。

为了去除重复子集,一种直接的思路是对结果列表进行去重。但这个方法效率很低,有两方面原因。
- 当数组元素较多,尤其是当
target较大时,搜索过程会产生大量的重复子集。 - 比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。
- 当数组元素较多,尤其是当
-
重复子集剪枝:
我们考虑在搜索过程中通过剪枝进行去重。观察下图 ,重复子集是在以不同顺序选择数组元素时产生的,例如以下情况。
- 当第一轮和第二轮分别选择 3 和 4 时,会生成包含这两个元素的所有子集,记为 [3,4,…] 。
- 之后,当第一轮选择 4 时,则第二轮应该跳过 3 ,因为该选择产生的子集 [4,3,…] 和第
1.步中生成的子集完全重复。
在搜索过程中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。
- 前两轮选择 3 和 5 ,生成子集 [3,5,…] 。
- 前两轮选择 4 和 5 ,生成子集 [4,5,…] 。
- 若第一轮选择 5 ,则第二轮应该跳过 3 和 4 ,因为子集 [5,3,…] 和 [5,4,…] 与第
1.步和第2.步中描述的子集完全重复。

总结来看,给定输入数组 $[𝑥_1,𝑥_2,…,𝑥_𝑛]$ ,设搜索过程中的选择序列为 $[𝑥_{𝑖_1},𝑥_{𝑖_2},…,𝑥_{𝑖_𝑚}]$ ,则该选择序列需要满足 $𝑖_1≤𝑖_2≤⋯≤𝑖_𝑚 $,不满足该条件的选择序列都会造成重复,应当剪枝。
-
代码实现:
为实现该剪枝,我们初始化变量
start,用于指示遍历起始点。当做出选择$ 𝑥_𝑖$ 后,设定下一轮从索引 𝑖 开始遍历。这样做就可以让选择序列满足 $𝑖_1≤𝑖_2≤⋯≤𝑖_𝑚$ ,从而保证子集唯一。除此之外,我们还对代码进行了以下两项优化。
- 在开启搜索前,先将数组
nums排序。在遍历所有选择时,当子集和超过target时直接结束循环,因为后边的元素更大,其子集和一定超过target。 - 省去元素和变量
total,通过在target上执行减法来统计元素和,当target等于 0 时记录解。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34/* 回溯算法:子集和 I */
void backtrack(int target, int *choices, int choicesSize, int start) {
// 子集和等于 target 时,记录解
if (target == 0) {
for (int i = 0; i < stateSize; ++i) {
res[resSize][i] = state[i];
}
resColSizes[resSize++] = stateSize;
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
for (int i = start; i < choicesSize; i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (target - choices[i] < 0) {
break;
}
// 尝试:做出选择,更新 target, start
state[stateSize] = choices[i];
stateSize++;
// 进行下一轮选择
backtrack(target - choices[i], choices, choicesSize, i);
// 回退:撤销选择,恢复到之前的状态
stateSize--;
}
}
/* 求解子集和 I */
void subsetSumI(int *nums, int numsSize, int target) {
qsort(nums, numsSize, sizeof(int), cmp); // 对 nums 进行排序
int start = 0; // 遍历起始点
backtrack(target, nums, numsSize, start);
} - 在开启搜索前,先将数组
重复元素
给定一个正整数数组
nums和一个目标正整数target,请找出所有可能的组合,使得组合中的元素和等于target。给定数组可能包含重复元素,每个元素只可被选择一次。请以列表形式返回这些组合,列表中不应包含重复组合。
相比于上题,本题的输入数组可能包含重复元素,这引入了新的问题。例如,给定数组 $[4,\hat{4},5] $和目标元素 9 ,则现有代码的输出结果为 [4,5],$[\hat{4},5] $,出现了重复子集。
造成这种重复的原因是相等元素在某轮中被多次选择。在下图中,第一轮共有三个选择,其中两个都为 4 ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个4也会产生重复子集。

-
相等元素剪枝
为解决此问题,我们需要限制相等元素在每一轮中只能被选择一次。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。
与此同时,本题规定每个数组元素只能被选择一次。幸运的是,我们也可以利用变量
start来满足该约束:当做出选择 $𝑥_𝑖 $后,设定下一轮从索引 𝑖+1 开始向后遍历。这样既能去除重复子集,也能避免重复选择元素。 -
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39/* 回溯算法:子集和 II */
void backtrack(int target, int *choices, int choicesSize, int start) {
// 子集和等于 target 时,记录解
if (target == 0) {
for (int i = 0; i < stateSize; i++) {
res[resSize][i] = state[i];
}
resColSizes[resSize++] = stateSize;
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
// 剪枝三:从 start 开始遍历,避免重复选择同一元素
for (int i = start; i < choicesSize; i++) {
// 剪枝一:若子集和超过 target ,则直接跳过
if (target - choices[i] < 0) {
continue;
}
// 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
if (i > start && choices[i] == choices[i - 1]) {
continue;
}
// 尝试:做出选择,更新 target, start
state[stateSize] = choices[i];
stateSize++;
// 进行下一轮选择
backtrack(target - choices[i], choices, choicesSize, i + 1);
// 回退:撤销选择,恢复到之前的状态
stateSize--;
}
}
/* 求解子集和 II */
void subsetSumII(int *nums, int numsSize, int target) {
// 对 nums 进行排序
qsort(nums, numsSize, sizeof(int), cmp);
// 开始回溯
backtrack(target, nums, numsSize, 0);
}下图展示了数组 [4,4,5] 和目标元素 9 的回溯过程,共包含四种剪枝操作。请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。

N皇后问题
根据国际象棋的规则,皇后可以攻击与同处一行、一列或一条斜线上的棋子。给定 𝑛 个皇后和一个 𝑛×𝑛 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
如下图所示,当 𝑛=4 时,共可以找到两个解。从回溯算法的角度看,𝑛×𝑛 大小的棋盘共有 𝑛2 个格子,给出了所有的选择 choices 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 state 。

下图展示了本题的三个约束条件:多个皇后不能在同一行、同一列、同一条对角线上。值得注意的是,对角线分为主对角线 \ 和次对角线 / 两种。

-
逐行放置策略
皇后的数量和棋盘的行数都为 𝑛 ,因此我们容易得到一个推论:棋盘每行都允许且只允许放置一个皇后。
也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
下图所示为 4 皇后问题的逐行放置过程。受画幅限制,图中仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。

从本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。
-
列与对角线剪枝
为了满足列约束,我们可以利用一个长度为 𝑛 的布尔型数组
cols记录每一列是否有皇后。在每次决定放置前,我们通过cols将已有皇后的列进行剪枝,并在回溯中动态更新cols的状态。[!tip]
请注意,矩阵的起点位于左上角,其中行索引从上到下增加,列索引从左到右增加。
那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 (𝑟𝑜𝑤,𝑐𝑜𝑙) ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,即主对角线上所有格子的 𝑟𝑜𝑤−𝑐𝑜𝑙 为恒定值。
也就是说,如果两个格子满足 𝑟𝑜𝑤1−𝑐𝑜𝑙1=𝑟𝑜𝑤2−𝑐𝑜𝑙2 ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助下图所示的数组
diags1记录每条主对角线上是否有皇后。同理,次对角线上的所有格子的 𝑟𝑜𝑤+𝑐𝑜𝑙 是恒定值。我们同样也可以借助数组
diags2来处理次对角线约束。
-
代码实现
请注意,𝑛 维方阵中 𝑟𝑜𝑤−𝑐𝑜𝑙 的范围是 [−𝑛+1,𝑛−1] ,𝑟𝑜𝑤+𝑐𝑜𝑙 的范围是 [0,2𝑛−2] ,所以主对角线和次对角线的数量都为 2𝑛−1 ,即数组
diags1和diags2的长度都为 2𝑛−1 。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51/* 回溯算法:n 皇后 */
void backtrack(int row, int n, char state[MAX_SIZE][MAX_SIZE], char ***res, int *resSize, bool cols[MAX_SIZE],
bool diags1[2 * MAX_SIZE - 1], bool diags2[2 * MAX_SIZE - 1]) {
// 当放置完所有行时,记录解
if (row == n) {
res[*resSize] = (char **)malloc(sizeof(char *) * n);
for (int i = 0; i < n; ++i) {
res[*resSize][i] = (char *)malloc(sizeof(char) * (n + 1));
strcpy(res[*resSize][i], state[i]);
}
(*resSize)++;
return;
}
// 遍历所有列
for (int col = 0; col < n; col++) {
// 计算该格子对应的主对角线和次对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
// 剪枝:不允许该格子所在列、主对角线、次对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = 'Q';
cols[col] = diags1[diag1] = diags2[diag2] = true;
// 放置下一行
backtrack(row + 1, n, state, res, resSize, cols, diags1, diags2);
// 回退:将该格子恢复为空位
state[row][col] = '#';
cols[col] = diags1[diag1] = diags2[diag2] = false;
}
}
}
/* 求解 n 皇后 */
char ***nQueens(int n, int *returnSize) {
char state[MAX_SIZE][MAX_SIZE];
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
state[i][j] = '#';
}
state[i][n] = '\0';
}
bool cols[MAX_SIZE] = {false}; // 记录列是否有皇后
bool diags1[2 * MAX_SIZE - 1] = {false}; // 记录主对角线上是否有皇后
bool diags2[2 * MAX_SIZE - 1] = {false}; // 记录次对角线上是否有皇后
char ***res = (char ***)malloc(sizeof(char **) * MAX_SIZE);
*returnSize = 0;
backtrack(0, n, state, res, returnSize, cols, diags1, diags2);
return res;
}逐行放置 𝑛 次,考虑列约束,则从第一行到最后一行分别有 𝑛、𝑛−1、…、2、1 个选择,使用 𝑂(𝑛!) 时间。当记录解时,需要复制矩阵
state并添加进res,复制操作使用$ 𝑂(𝑛^2) $时间。因此,总体时间复杂度为 $𝑂(𝑛!\times 𝑛^2)$ 。实际上,根据对角线约束的剪枝也能够大幅缩小搜索空间,因而搜索效率往往优于以上时间复杂度。数组
state使用 $𝑂(𝑛^2)$ 空间,数组cols、diags1和diags2皆使用 𝑂(𝑛) 空间。最大递归深度为 𝑛 ,使用 𝑂(𝑛) 栈帧空间。因此,空间复杂度为 $𝑂(𝑛^2)$ 。
动态规划
初识动态规划
动态规划(dynamic programming)是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
在本节中,我们从一个经典例题入手,先给出它的暴力回溯解法,观察其中包含的重叠子问题,再逐步导出更高效的动态规划解法。
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,请问有多少种方案可以爬到楼顶?
如下图所示,对于一个 3 阶楼梯,共有 3 种方案可以爬到楼顶。

本题的目标是求解方案数量,我们可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 1 阶或 2 阶,每当到达楼梯顶部时就将方案数量加 1 ,当越过楼梯顶部时就将其剪枝。代码如下所示:
1 | /* 回溯 */ |
方法一:暴力搜索
回溯算法通常并不显式地对问题进行拆解,而是将求解问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
我们可以尝试从问题分解的角度分析这道题。设爬到第 𝑖 阶共有 𝑑𝑝[𝑖] 种方案,那么 𝑑𝑝[𝑖] 就是原问题,其子问题包括:
$$
𝑑𝑝[𝑖−1],𝑑𝑝[𝑖−2],…,𝑑𝑝[2],𝑑𝑝[1]
$$
由于每轮只能上 1 阶或 2 阶,因此当我们站在第 𝑖 阶楼梯上时,上一轮只可能站在第 𝑖−1 阶或第 𝑖−2 阶上。换句话说,我们只能从第 𝑖−1 阶或第 𝑖−2 阶迈向第 𝑖 阶。
由此便可得出一个重要推论:爬到第 𝑖−1 阶的方案数加上爬到第 𝑖−2 阶的方案数就等于爬到第 𝑖 阶的方案数。公式如下:
$$
𝑑𝑝[𝑖]=𝑑𝑝[𝑖−1]+𝑑𝑝[𝑖−2]
$$
这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。下图展示了该递推关系。

我们可以根据递推公式得到暴力搜索解法。以 𝑑𝑝[𝑛] 为起始点,递归地将一个较大问题拆解为两个较小问题的和,直至到达最小子问题 𝑑𝑝[1] 和 𝑑𝑝[2] 时返回。其中,最小子问题的解是已知的,即 𝑑𝑝[1]=1、𝑑𝑝[2]=2 ,表示爬到第 1、2 阶分别有 1、2 种方案。
观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁:
1 | /* 搜索 */ |
下图展示了暴力搜索形成的递归树。对于问题 𝑑𝑝[𝑛] ,其递归树的深度为 𝑛 ,时间复杂度为 $𝑂(2^𝑛) $。指数阶属于爆炸式增长,如果我们输入一个比较大的 𝑛 ,则会陷入漫长的等待之中。

观察下图,指数阶的时间复杂度是“重叠子问题”导致的。例如 𝑑𝑝[9] 被分解为 𝑑𝑝[8] 和 𝑑𝑝[7] ,𝑑𝑝[8] 被分解为 𝑑𝑝[7] 和 𝑑𝑝[6] ,两者都包含子问题 𝑑𝑝[7] 。
以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的子问题上。
方法二:记忆化搜索
为了提升算法效率,我们希望所有的重叠子问题都只被计算一次。为此,我们声明一个数组 mem 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝。
- 当首次计算 𝑑𝑝[𝑖] 时,我们将其记录至
mem[i],以便之后使用。 - 当再次需要计算 𝑑𝑝[𝑖] 时,我们便可直接从
mem[i]中获取结果,从而避免重复计算该子问题。
代码如下所示:
1 | /* 记忆化搜索 */ |
观察下图,经过记忆化处理后,所有重叠子问题都只需计算一次,时间复杂度优化至 𝑂(𝑛) ,这是一个巨大的飞跃。

方法三:动态规划
记忆化搜索是一种“从顶至底”的方法:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯逐层收集子问题的解,构建出原问题的解。
与之相反,动态规划是一种“从底至顶”的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。
由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 dp 来存储子问题的解,它起到了与记忆化搜索中数组 mem 相同的记录作用:
1 | /* 爬楼梯:动态规划 */ |
下图模拟了以上代码的执行过程。

与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 𝑖 。
根据以上内容,我们可以总结出动态规划的常用术语。
- 将数组
dp称为 dp 表,𝑑𝑝[𝑖] 表示状态 𝑖 对应子问题的解。 - 将最小子问题对应的状态(第 1 阶和第 2 阶楼梯)称为初始状态。
- 将递推公式 𝑑𝑝[𝑖]=𝑑𝑝[𝑖−1]+𝑑𝑝[𝑖−2] 称为状态转移方程。
空间优化
细心的读者可能发现了,由于 𝑑𝑝[𝑖] 只与 𝑑𝑝[𝑖−1] 和 𝑑𝑝[𝑖−2] 有关,因此我们无须使用一个数组 dp 来存储所有子问题的解,而只需两个变量滚动前进即可。代码如下所示:
1 | /* 爬楼梯:空间优化后的动态规划 */ |
观察以上代码,由于省去了数组 dp 占用的空间,因此空间复杂度从 𝑂(𝑛) 降至 𝑂(1) 。
在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过“降维”来节省内存空间。这种空间优化技巧被称为“滚动变量”或“滚动数组”。
DP问题特性
在上一节中,我们学习了动态规划是如何通过子问题分解来求解原问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
- 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
- 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作一个子问题。
实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。
最优子结构
我们对爬楼梯问题稍作改动,使之更加适合展示最优子结构概念。
爬楼梯的最小代价:
给定一个楼梯,你每步可以上 1 阶或者 2 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 𝑐𝑜𝑠𝑡 ,其中 𝑐𝑜𝑠𝑡[𝑖] 表示在第 𝑖 个台阶需要付出的代价,𝑐𝑜𝑠𝑡[0] 为地面(起始点)。请计算最少需要付出多少代价才能到达顶部?
如下图所示,若第 1、2、3 阶的代价分别为 1、10、1 ,则从地面爬到第 3 阶的最小代价为 2 :

设 𝑑𝑝[𝑖] 为爬到第 𝑖 阶累计付出的代价,由于第 𝑖 阶只可能从 𝑖−1 阶或 𝑖−2 阶走来,因此 𝑑𝑝[𝑖] 只可能等于 𝑑𝑝[𝑖−1]+𝑐𝑜𝑠𝑡[𝑖] 或 𝑑𝑝[𝑖−2]+𝑐𝑜𝑠𝑡[𝑖] 。为了尽可能减少代价,我们应该选择两者中较小的那一个:
$$
𝑑𝑝[𝑖]=min(𝑑𝑝[𝑖−1],𝑑𝑝[𝑖−2])+𝑐𝑜𝑠𝑡[𝑖]
$$
这便可以引出最优子结构的含义:原问题的最优解是从子问题的最优解构建得来的。
本题显然具有最优子结构:我们从两个子问题最优解 𝑑𝑝[𝑖−1] 和 𝑑𝑝[𝑖−2] 中挑选出较优的那一个,并用它构建出原问题 𝑑𝑝[𝑖] 的最优解。
那么,上一节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,虽然题目修改前后是等价的,但最优子结构浮现出来了:第 𝑛 阶最大方案数量等于第 𝑛−1 阶和第 𝑛−2 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
根据状态转移方程,以及初始状态 𝑑𝑝[1]=𝑐𝑜𝑠𝑡[1] 和 𝑑𝑝[2]=𝑐𝑜𝑠𝑡[2] ,我们就可以得到动态规划代码:
1 | /* 爬楼梯最小代价:动态规划 */ |

本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 𝑂(𝑛) 降至 𝑂(1) :
1 | /* 爬楼梯最小代价:空间优化后的动态规划 */ |
无后效性
无后效性是动态规划能够有效解决问题的重要特性之一,其定义为:给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关。
以爬楼梯问题为例,给定状态 𝑖 ,它会发展出状态 𝑖+1 和状态 𝑖+2 ,分别对应跳 1 步和跳 2 步。在做出这两种选择时,我们无须考虑状态 𝑖 之前的状态,它们对状态 𝑖 的未来没有影响。
然而,如果我们给爬楼梯问题添加一个约束,情况就不一样了。
带约束爬楼梯:
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,但不能连续两轮跳 1 阶,请问有多少种方案可以爬到楼顶?
如下图所示,爬上第 3 阶仅剩 2 种可行方案,其中连续三次跳 1 阶的方案不满足约束条件,因此被舍弃。

在该问题中,如果上一轮是跳 1 阶上来的,那么下一轮就必须跳 2 阶。这意味着,下一步选择不能由当前状态(当前所在楼梯阶数)独立决定,还和前一个状态(上一轮所在楼梯阶数)有关。
不难发现,此问题已不满足无后效性,状态转移方程 𝑑𝑝[𝑖]=𝑑𝑝[𝑖−1]+𝑑𝑝[𝑖−2] 也失效了,因为 𝑑𝑝[𝑖−1] 代表本轮跳 1 阶,但其中包含了许多“上一轮是跳 1 阶上来的”方案,而为了满足约束,我们就不能将 𝑑𝑝[𝑖−1] 直接计入 𝑑𝑝[𝑖] 中。
为此,我们需要扩展状态定义:状态 [𝑖,𝑗] 表示处在第 𝑖 阶并且上一轮跳了 𝑗 阶,其中 𝑗∈{1,2} 。此状态定义有效地区分了上一轮跳了 1 阶还是 2 阶,我们可以据此判断当前状态是从何而来的。
- 当上一轮跳了 1 阶时,上上一轮只能选择跳 2 阶,即 𝑑𝑝[𝑖,1] 只能从 𝑑𝑝[𝑖−1,2] 转移过来。
- 当上一轮跳了 2 阶时,上上一轮可选择跳 1 阶或跳 2 阶,即 𝑑𝑝[𝑖,2] 可以从 𝑑𝑝[𝑖−2,1] 或 𝑑𝑝[𝑖−2,2] 转移过来。
如下图所示,在该定义下,𝑑𝑝[𝑖,𝑗] 表示状态 [𝑖,𝑗] 对应的方案数。此时状态转移方程为:
$$
\begin{cases}
dp[i,1]=dp[i-1,2]\
dp[i,2]=dp[i-2,1]+dp[i-2,2]
\end{cases}
$$

最终,返回 𝑑𝑝[𝑛,1]+𝑑𝑝[𝑛,2] 即可,两者之和代表爬到第 𝑛 阶的方案总数:
1 | /* 带约束爬楼梯:动态规划 */ |
在上面的案例中,由于仅需多考虑前面一个状态,因此我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
爬楼梯与障碍生成
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶。规定当爬到第 𝑖 阶时,系统自动会在第 2𝑖 阶上放上障碍物,之后所有轮都不允许跳到第 2𝑖 阶上。例如,前两轮分别跳到了第 2、3 阶上,则之后就不能跳到第 4、6 阶上。请问有多少种方案可以爬到楼顶?
在这个问题中,下次跳跃依赖过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。
实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。
DP解题思路
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题。
- 如何判断一个问题是不是动态规划问题?
- 求解动态规划问题该从何处入手,完整步骤是什么?
问题判断
总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常适合用动态规划求解。然而,我们很难从问题描述中直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决。
适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。
换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。
在此基础上,动态规划问题还有一些判断的“加分项”。
- 问题包含最大(小)或最多(少)等最优化描述。
- 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。
相应地,也存在一些“减分项”。
- 问题的目标是找出所有可能的解决方案,而不是找出最优解。
- 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。
如果一个问题满足决策树模型,并具有较为明显的“加分项”,我们就可以假设它是一个动态规划问题,并在求解过程中验证它。
问题求解步骤
动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 𝑑𝑝 表,推导状态转移方程,确定边界条件等。
为了更形象地展示解题步骤,我们使用一个经典问题“最小路径和”来举例。
给定一个 𝑛×𝑚 的二维网格
grid,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回从左上角到右下角的最小路径和。
下图展示了一个例子,给定网络的最小路径和为13。

第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
本题的每一轮的决策就是从当前格子向下或向右走一步。设当前格子的行列索引为 [𝑖,𝑗] ,则向下或向右走一步后,索引变为 [𝑖+1,𝑗] 或 [𝑖,𝑗+1] 。因此,状态应包含行索引和列索引两个变量,记为 [𝑖,𝑗] 。
状态 [𝑖,𝑗] 对应的子问题为:从起始点 [0,0] 走到 [𝑖,𝑗] 的最小路径和,解记为 𝑑𝑝[𝑖,𝑗] 。
至此,我们就得到了下图所示的二维 𝑑𝑝 矩阵,其尺寸与输入网格 𝑔𝑟𝑖𝑑 相同。

[!note]
动态规划和回溯过程可以描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。
每个状态都对应一个子问题,我们会定义一个 𝑑𝑝 表来存储所有子问题的解,状态的每个独立变量都是 𝑑𝑝 表的一个维度。从本质上看,𝑑𝑝 表是状态和子问题的解之间的映射。
第二步:找出最优子结构,进而推导出状态转移方程
对于状态 [𝑖,𝑗] ,它只能从上边格子 [𝑖−1,𝑗] 和左边格子 [𝑖,𝑗−1] 转移而来。因此最优子结构为:到达 [𝑖,𝑗] 的最小路径和由 [𝑖,𝑗−1] 的最小路径和与 [𝑖−1,𝑗] 的最小路径和中较小的那一个决定。
根据以上分析,可推出下图所示的状态转移方程:
$$
dp[i,j]=min(dp[i-1,j],dp[i,j-1])+grid[i,j]
$$

第三步:确定边界条件和状态转移顺序
在本题中,处在首行的状态只能从其左边的状态得来,处在首列的状态只能从其上边的状态得来,因此首行 𝑖=0 和首列 𝑗=0 是边界条件。
如下图所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用循环来遍历矩阵,外循环遍历各行,内循环遍历各列。

[!note]
边界条件在动态规划中用于初始化 𝑑𝑝 表,在搜索中用于剪枝。
状态转移顺序的核心是要保证在计算当前问题的解时,所有它依赖的更小子问题的解都已经被正确地计算出来。
根据以上分析,我们已经可以直接写出动态规划代码。然而子问题分解是一种从顶至底的思想,因此按照“暴力搜索 → 记忆化搜索 → 动态规划”的顺序实现更加符合思维习惯。
暴力搜索
从状态 [𝑖,𝑗] 开始搜索,不断分解为更小的状态 [𝑖−1,𝑗] 和 [𝑖,𝑗−1] ,递归函数包括以下要素。
- 递归参数:状态 [𝑖,𝑗] 。
- 返回值:从 [0,0] 到 [𝑖,𝑗] 的最小路径和 𝑑𝑝[𝑖,𝑗] 。
- 终止条件:当 𝑖=0 且 𝑗=0 时,返回代价 𝑔𝑟𝑖𝑑[0,0] 。
- 剪枝:当 𝑖<0 时或 𝑗<0 时索引越界,此时返回代价 +∞ ,代表不可行。
实现代码如下:
1 | /* 最小路径和:暴力搜索 */ |
下图给出了以 𝑑𝑝[2,1] 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 grid 的尺寸变大而急剧增多。
从本质上看,造成重叠子问题的原因为:存在多条路径可以从左上角到达某一单元格。

每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 𝑚+𝑛−2 步,所以最差时间复杂度为 𝑂($2^{𝑚+𝑛}$) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择,因此实际的路径数量会少一些。
记忆化搜索
我们引入一个和网格 grid 相同尺寸的记忆列表 mem ,用于记录各个子问题的解,并将重叠子问题进行剪枝:
1 | /* 最小路径和:记忆化搜索 */ |
如下图所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 𝑂(𝑛𝑚) 。

动态规划
基于迭代实现动态规划解法,代码如下所示:
1 | /* 最小路径和:动态规划 */ |
下图展示了最小路径和的状态转移过程,其遍历了整个网格,因此时间复杂度为 𝑂(𝑛𝑚) 。
数组 dp 大小为 𝑛×𝑚 ,因此空间复杂度为 𝑂(𝑛𝑚) 。

空间优化
由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 𝑑𝑝 表。
请注意,因为数组 dp 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行时更新它:
1 | /* 最小路径和:空间优化后的动态规划 */ |
0-1背包问题
背包问题是一个非常好的动态规划入门题目,是动态规划中最常见的问题形式。其具有很多变种,例如 0-1 背包问题、完全背包问题、多重背包问题等。
在本节中,我们先来求解最常见的 0-1 背包问题。
问题
给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖−1]、价值为 𝑣𝑎𝑙[𝑖−1] ,和一个容量为 𝑐𝑎𝑝 的背包。每个物品只能选择一次,问在限定背包容量下能放入物品的最大价值。
由于物品编号 𝑖 从 1 开始计数,数组索引从 0 开始计数,因此物品 𝑖 对应重量 𝑤𝑔𝑡[𝑖−1] 和价值 𝑣𝑎𝑙[𝑖−1] 。

我们可以将 0-1 背包问题看作一个由 𝑛 轮决策组成的过程,对于每个物体都有不放入和放入两种决策,因此该问题满足决策树模型。
该问题的目标是求解“在限定背包容量下能放入物品的最大价值”,因此较大概率是一个动态规划问题。
-
第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。由此可得状态定义:当前物品编号 𝑖 和背包容量 𝑐 ,记为 [𝑖,𝑐] 。状态 [𝑖,𝑐] 对应的子问题为:前 𝑖 个物品在容量为 𝑐 的背包中的最大价值,记为 𝑑𝑝[𝑖,𝑐] 。待求解的是 𝑑𝑝[𝑛,𝑐𝑎𝑝] ,因此需要一个尺寸为 (𝑛+1)×(𝑐𝑎𝑝+1) 的二维 𝑑𝑝 表。
-
第二步:找出最优子结构,进而推导出状态转移方程
当我们做出物品 𝑖 的决策后,剩余的是前 𝑖−1 个物品决策的子问题,可分为以下两种情况。
- 不放入物品 𝑖 :背包容量不变,状态变化为 [𝑖−1,𝑐] 。
- 放入物品 𝑖 :背包容量减少 𝑤𝑔𝑡[𝑖−1] ,价值增加 𝑣𝑎𝑙[𝑖−1] ,状态变化为 [𝑖−1,𝑐−𝑤𝑔𝑡[𝑖−1]] 。
上述分析向我们揭示了本题的最优子结构:最大价值 𝑑𝑝[𝑖,𝑐] 等于不放入物品 𝑖 和放入物品 𝑖 两种方案中价值更大的那一个。由此可推导出状态转移方程:
$$
𝑑𝑝[𝑖,𝑐]=max(𝑑𝑝[𝑖−1,𝑐],𝑑𝑝[𝑖−1,𝑐−𝑤𝑔𝑡[𝑖−1]]+𝑣𝑎𝑙[𝑖−1])
$$
需要注意的是,若当前物品重量 𝑤𝑔𝑡[𝑖−1] 超出剩余背包容量 𝑐 ,则只能选择不放入背包。 -
第三步:确定边界条件和状态转移顺序
当无物品或背包容量为 0 时最大价值为 0 ,即首列 𝑑𝑝[𝑖,0] 和首行 𝑑𝑝[0,𝑐] 都等于 0 。
当前状态 [𝑖,𝑐] 从上方的状态 [𝑖−1,𝑐] 和左上方的状态 [𝑖−1,𝑐−𝑤𝑔𝑡[𝑖−1]] 转移而来,因此通过两层循环正序遍历整个 𝑑𝑝 表即可。
根据以上分析,我们接下来按顺序实现暴力搜索、记忆化搜索、动态规划解法。
暴力搜索
搜索代码包含以下要素。
- 递归参数:状态 [𝑖,𝑐] 。
- 返回值:子问题的解 𝑑𝑝[𝑖,𝑐] 。
- 终止条件:当物品编号越界 𝑖=0 或背包剩余容量为 0 时,终止递归并返回价值 0 。
- 剪枝:若当前物品重量超出背包剩余容量,则只能选择不放入背包。
1 | /* 0-1 背包:暴力搜索 */ |
如下图所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 𝑂(2𝑛) 。
观察递归树,容易发现其中存在重叠子问题,例如 𝑑𝑝[1,10] 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。

记忆化搜索
为了保证重叠子问题只被计算一次,我们借助记忆列表 mem 来记录子问题的解,其中 mem[i][c] 对应 𝑑𝑝[𝑖,𝑐] 。
引入记忆化之后,时间复杂度取决于子问题数量,也就是 𝑂(𝑛×𝑐𝑎𝑝) 。实现代码如下:
1 | /* 0-1 背包:记忆化搜索 */ |
下图展示了在记忆化搜索中被剪掉的搜索分支。

动态规划
动态规划实质上就是在状态转移中填充 𝑑𝑝 表的过程,代码如下所示:
1 | /* 0-1 背包:动态规划 */ |
如下图所示,时间复杂度和空间复杂度都由数组 dp 大小决定,即 𝑂(𝑛×𝑐𝑎𝑝) 。














空间优化
由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $𝑂(𝑛^2)$ 降至 𝑂(𝑛) 。
进一步思考,我们能否仅用一个数组实现空间优化呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当开始遍历第 𝑖 行时,该数组存储的仍然是第 𝑖−1 行的状态。
- 如果采取正序遍历,那么遍历到 𝑑𝑝[𝑖,𝑗] 时,左上方 𝑑𝑝[𝑖−1,1] ~ 𝑑𝑝[𝑖−1,𝑗−1] 值可能已经被覆盖,此时就无法得到正确的状态转移结果。
- 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。
下图展示了在单个数组下从第 𝑖=1 行转换至第 𝑖=2 行的过程。请思考正序遍历和倒序遍历的区别。






在代码实现中,我们仅需将数组 dp 的第一维 𝑖 直接删除,并且把内循环更改为倒序遍历即可:
1 | /* 0-1 背包:空间优化后的动态规划 */ |





