我的算法笔记

41223 字
206 分钟
我的算法笔记

前言#

这是一本面向算法实现而非算法理论全景的讲义。它不追求覆盖计算机科学中”算法”这个词能囊括的一切——字符串匹配、计算几何、数论被有意排除在外,因为它们依赖的是各自领域的专门技巧(如自动机理论、几何不变量、模运算的代数结构),混进来只会让全书的分类标准变得四不像。这本书要回答的问题更窄、也更具体:给定一个问题,应该用什么思路构造算法去解决它,用什么结构组织数据去支撑这个算法,以及怎么知道这个算法或这个结构到底好不好。

全书按照这三个问题,分成四个互斥的部分。

Part A 不讲任何具体算法,讲的是”如何说话”——用什么记号衡量一个算法的快慢(渐进记号),怎么从递归式推出复杂度(代入法、递归树、主定理、Akra-Bazzi),怎么证明一段代码真的做了它该做的事(循环不变式、归纳法),怎么算出一连串操作摊下来到底要多少代价(聚合分析、记账法、势能法),以及怎么证明某个问题无论如何都快不过某个下限(决策树模型、对手论证)。这部分是工具箱,后面所有章节引用它,而不是重复证明。

Part B 是这本书的主体,按”用什么技术构造算法”分成七章:分治(把问题拆成互不相关的子问题)、单调性优化(利用问题中的单调关系丢弃不可能更优的状态,涵盖双指针、单调栈、单调队列)、动态规划(子问题有重叠,得把答案存下来)、贪心(每步选眼前最好的,赌它就是全局最好的)、增广/迭代改进(从一个可行解出发,不断找路径去优化它,网络流是典型代表)、非比较技术(如果对数据的值域有额外信息,可以绕开”比较”这个最基本的操作)、回溯与分支限界(搜索空间本质是指数级的,靠剪枝把它压下来)。具体问题——排序、最短路、最小生成树、二分查找、背包——不再单独占一个位置,而是被分散安置到它们各自所属的技术章节里,作为这种技术的实例出现。

Part C 讲”用什么结构组织数据”,按支持的操作集合分成六类:线性结构(数组、链表、栈、队列,外加bitset)、字典结构(哈希表、二叉搜索树、平衡树家族)、优先级结构(堆及其应用)、并查集、图结构(存储与遍历,包括拓扑排序)、区间结构(前缀和与差分这种最朴素的预处理,到树状数组、线段树、ST表这些支持高效查询和修改的进阶结构)。

Part D 面对一个更让人不安的问题:有些问题,不是没找到好算法,而是几乎可以肯定不存在好算法。 P与NP的边界、什么是NP完全、怎么用归约证明一个新问题和已知难题一样难,以及——既然绕不开这些难题——退而求其次,用近似算法换一个”足够好但不是最好”的解,并且能证明这个”足够好”到底有多好。

这本书省略了几乎所有冗长的证明过程(除了Part A,因为那里的证明本身就是被教的内容),换成了代码实现、复杂度分析和直觉性的”为什么这样做是对的”。它假设读者的目标是学会用,不是学会证。如果某天需要补证明,Part A留出的工具应该够用;如果某天发现某个具体问题该用哪种技术、该用哪种结构还是含糊不清,回到对应Part的标准声明,应该能找到答案该在哪一类里。

复杂度记号约定:Part A 涉及严格证明(如主定理的陈述与证明),O/Θ/Ω 按其数学定义精确区分使用。Part B、C、D 默认统一用 O 表示渐进复杂度,不严格区分”上界”与”紧确界”——即使某个复杂度在数学意义上是确定的紧确界(如归并排序的 O(n log n)、DFS/BFS 的 O(V+E)),也按惯例写作 O 而非 Θ,这是工程文献中更常见的写法。唯一例外是直接引用主定理结论的场合(如二分查找的 Θ(log n)),因为此时复杂度本身就是主定理证明过程的直接结果。


目录#

Part A 度量与证明工具#

Part B 算法设计技术#

  • 第6章 分治法:子问题独立可分别求解后合并
  • 第7章 单调性优化技巧:利用问题中的单调性提前丢弃不可能更优的状态
  • 第8章 动态规划:有重叠子问题+最优子结构,子问题最优⟹整体最优。固定步骤:定义状态→写转移方程→边界条件
  • 第9章 贪心法:猜策略→证明局部最优⟹全局最优→不行就换策略
  • 第10章 增广/迭代改进法:不分解子问题,靠不断改进可行解直至达到最优性
  • 第11章 非比较技术:不利用递归/选择结构,而是利用键值域结构跳过比较
  • 第12章 回溯与分支限界:用什么方式剪掉不必要的搜索分支

Part C 数据结构#

  • 第13章 线性结构:栈、队列、链表
  • 第14章 字典结构:哈希表(哈希函数设计、冲突解决)、二叉搜索树、红黑树(性质与旋转证明)
  • 第15章 优先级结构:二叉堆、二项堆、斐波那契堆
  • 第16章 并查集:按秩合并、路径压缩(复杂度结果,证明方法见第4章)
  • 第17章 图结构:表示方法(邻接矩阵/邻接表)、BFS(广度优先搜索)/DFS(深度优先搜索)及其性质(时间戳、边分类)
  • 第18章 区间结构:树状数组(BIT)/线段树(Segment Tree)

Part D 计算的极限#

  • 第19章 NP完全性:P与NP、多项式归约、经典NP完全问题归约证明
  • 第20章 近似算法:近似比定义、经典案例

附录#


Part A 度量与证明工具#

本部分关心”如何度量算法性能、如何证明算法正确”,是后续 Part B/C/D 反复引用的基础工具箱。


第1章 渐进记号体系#

O(大O,渐进上界)

f(n)=O(g(n))f(n) = O(g(n)) 当且仅当存在正常数 ccn0n_0,使得对所有 nn0n \ge n_0,有:

0f(n)cg(n)0 \le f(n) \le c \cdot g(n)

含义:f(n)f(n) 的增长速度不超过 g(n)g(n)(最坏情况上限)。

Ω(大Omega,渐进下界)

f(n)=Ω(g(n))f(n) = \Omega(g(n)) 当且仅当存在正常数 ccn0n_0,使得对所有 nn0n \ge n_0,有:

0cg(n)f(n)0 \le c \cdot g(n) \le f(n)

含义:f(n)f(n) 的增长速度至少与 g(n)g(n) 同阶(渐进下界)。

Θ(大Theta,渐进紧确界)

f(n)=Θ(g(n))f(n) = \Theta(g(n)) 当且仅当存在正常数 c1c_1c2c_2n0n_0,使得对所有 nn0n \ge n_0,有:

0c1g(n)f(n)c2g(n)0 \le c_1 \cdot g(n) \le f(n) \le c_2 \cdot g(n)

含义:f(n)f(n)g(n)g(n) 同阶增长,即同时满足 f(n)=O(g(n))f(n)=O(g(n))f(n)=Ω(g(n))f(n)=\Omega(g(n))

o(小o,非紧上界)

f(n)=o(g(n))f(n) = o(g(n)) 当且仅当对任意正常数 cc,都存在 n0n_0,使得对所有 nn0n \ge n_0,有:

0f(n)<cg(n)0 \le f(n) < c \cdot g(n)

与大O的区别:大O只要求存在某个 cc 使不等式成立;小o要求对所有 cc(无论多小)都成立,意味着 f(n)/g(n)0f(n)/g(n) \to 0,即 ffgg 严格更慢增长。

ω(小omega,非紧下界)

f(n)=ω(g(n))f(n) = \omega(g(n)) 当且仅当对任意正常数 cc,都存在 n0n_0,使得对所有 nn0n \ge n_0,有:

0cg(n)<f(n)0 \le c \cdot g(n) < f(n)

意味着 f(n)/g(n)f(n)/g(n) \to \infty,即 ffgg 严格更快增长。

五种记号的关系

记号类比的不等式含义
O\le上界(可取等)
o<<严格上界(不可取等)
Ω\ge下界(可取等)
ω>>严格下界(不可取等)
Θ==同阶(紧确界)

性质(仅陈述,证明略,均可直接由极限定义验证)

  • 传递性: f=O(g),g=O(h)f=O(h)f=O(g), g=O(h) \Rightarrow f=O(h)(Ω、Θ、o、ω 同样满足)
  • 自反性: f=O(f)f=O(f)f=Θ(f)f=\Theta(f)(O、Ω 满足,o、ω 不满足,因为 o、ω 要求严格)
  • 对称性: f=Θ(g)    g=Θ(f)f=\Theta(g) \iff g=\Theta(f)
  • 转置对称性: f=O(g)    g=Ω(f)f=O(g) \iff g=\Omega(f)f=o(g)    g=ω(f)f=o(g) \iff g=\omega(f)

第2章 递归式求解#

本章解决”已知算法的递归式,如何求出它的渐进阶”,三种方法分别适用于不同复杂程度的递归式,由简到繁:代入法(需要先猜)、递归树法(直观但不严谨,常用于辅助猜测)、主定理(公式化,但只适用于特定形式)、Akra-Bazzi(最一般,但计算量最大)。

2.1 代入法(Substitution Method)#

思路:猜测一个解的形式,然后用数学归纳法验证这个猜测是否成立——如果归纳步骤能推出来,猜测就是对的;如果推不出来,调整猜测(通常是减去一个低阶项)再试。

例:验证 T(n)=2T(n/2)+nT(n) = 2T(n/2) + n 的解是 O(nlogn)O(n\log n)#

猜测: T(n)cnlognT(n) \le cn\log n,对某常数 c>0c>0 和所有 nn 足够大成立。

归纳假设: 假设对所有 m<nm<nT(m)cmlogmT(m) \le cm\log m 成立(强归纳法)。

归纳步骤:

T(n)=2T(n/2)+n2cn2logn2+n=cnlogn2+n=cnlogncn+nT(n) = 2T(n/2)+n \le 2c\frac{n}{2}\log\frac{n}{2} + n = cn\log\frac{n}{2}+n = cn\log n - cn + n

要使最后一步 cnlogn\le cn\log n,只需 cn+n0-cn+n \le 0,即 c1c \ge 1

结论:c1c\ge 1(再单独验证边界情形 n=1n=1 取常数即可吻合),归纳成立,故 T(n)=O(nlogn)T(n)=O(n\log n)

\blacksquare

常见陷阱: 直接猜 T(n)cnT(n)\le cn(缺少 logn\log n 因子)会推导失败——归纳步骤会得到 T(n)cn+nT(n)\le cn+n,无法收敛到 cncn,说明原猜测形式不对,需要调整(这正是代入法”猜测—验证—调整”循环的体现)。

处理边界条件不严格满足的情形#

有时归纳假设 T(m)cmlogmT(m)\le cm\log mmm 很小(如 m=1m=1log1=0\log 1=0)时无法启动归纳。标准技巧: 加强猜测为 T(n)cnlognbnT(n)\le cn\log n - bn(多减去一个低阶项 bnbn),人为制造出归纳步骤所需的”余量”,再单独验证边界值,使整体猜测自洽。

2.2 递归树法(Recursion Tree Method)#

思路: 把递归式展开成一棵树,每个节点代表一次递归调用产生的”非递归部分”代价,将每一层的代价加总,再把所有层加总,得到总代价。这本质上是主定理证明过程中”递归展开”那一步的可视化,不要求 f(n)f(n) 满足主定理三种情况的形式,适用范围更广,常用于辅助猜测代入法需要的解的形式。

例:T(n)=3T(n/4)+n2T(n) = 3T(n/4) + n^2#

层数 节点个数 每节点代价 本层总代价
第0层 1 n^2 n^2
第1层 3 (n/4)^2 3(n/4)^2 = (3/16)n^2
第2层 9 (n/16)^2 9(n/16)^2 = (3/16)^2 n^2
...
第i层 3^i (n/4^i)^2 (3/16)^i n^2

叶子层数:n/4i=1n/4^i = 1 时,即 i=log4ni = \log_4 n,到达叶子。

总代价:

T(n)=i=0log4n(316)in2T(n) = \sum_{i=0}^{\log_4 n} \left(\frac{3}{16}\right)^i n^2

这是公比 3/16<13/16 < 1 的几何级数,由几何级数求和公式,无穷级数收敛于常数:

T(n)n2i=0(316)i=n2113/16=1613n2=O(n2)T(n) \le n^2 \sum_{i=0}^{\infty} \left(\frac{3}{16}\right)^i = n^2 \cdot \frac{1}{1-3/16} = \frac{16}{13}n^2 = O(n^2)

结论: T(n)=O(n2)T(n) = O(n^2)(与主定理情况一直接套用得到的结果一致:nlog43n0.79n^{\log_4 3} \approx n^{0.79}f(n)=n2f(n)=n^2 的低阶,故 T(n)=Θ(n2)T(n)=\Theta(n^2))。

\blacksquare

递归树法的价值:f(n)f(n) 不满足主定理要求的形式(例如 f(n)=n/lognf(n)=n/\log n,既不是任何 nlogba±εn^{\log_b a \pm \varepsilon} 的形式,又不满足正则条件),主定理无法直接套用,但递归树仍然可以展开计算,是比主定理更通用的手段,也是 Akra-Bazzi 方法证明思路的直观来源。

2.3 主定理(Master Theorem)#

主定理用于分析分治算法的时间复杂度,解决形如下式的递归式的 Θ()\Theta(\cdot) 问题:

T(n)={aT(nb)+f(n)nb>1Θ(1)nb1T(n)= \begin{cases} a\,T\left(\dfrac{n}{b}\right)+f(n) & \dfrac{n}{b}>1\\ \Theta(1) & \dfrac{n}{b}\le 1 \end{cases}

logbn\log_b n 层:

T(n)=f(n)+af(nb)+a2f(nb2)++nlogbaf(1)T(n) = f(n) + a f\left(\frac{n}{b}\right) + a^2 f\left(\frac{n}{b^2}\right) + \cdots + n^{\log_b a} f(1)

结论#

情况一:若 f(n)f(n)nlogban^{\log_b a} 的低阶(无穷小),

T(n)=Θ(nlogba)T(n) = \Theta\left(n^{\log_b a}\right)

情况二:若 f(n)f(n)nlogban^{\log_b a} 同阶,

T(n)=Θ(nlogbalogn)T(n) = \Theta\left(n^{\log_b a} \cdot \log n\right)

情况三:若 f(n)f(n)nlogban^{\log_b a} 的高阶,

T(n)=Θ(f(n))T(n) = \Theta\left(f(n)\right)

且满足正则条件:af(nb)kf(n)a f\left(\dfrac{n}{b}\right) \le k f(n),对某 k<1k < 1nn 足够大

正则条件保证每一层的工作量都不超过某个常数倍的 f(n)f(n)

注意: 这三种情况不是穷尽所有可能——f(n)f(n)nlogban^{\log_b a} 之间存在”既非多项式地小、又非多项式地大、也不严格同阶”的间隙情形(如相差一个 loglogn\log\log n 因子),主定理对这类情形不适用,需要用 Akra-Bazzi 方法或递归树直接计算。

证明#

为简化推导,按惯例假设 nnbb 的整数次幂,即 n=bkn=b^k(一般的 nn 通过取整处理,结论渐进等价,不影响阶)。

第一步:递归展开

T(1)=Θ(1)T(1)=\Theta(1) 为常数边界,反复代入:

T(n)=aT(n/b)+f(n)=a2T(n/b2)+af(n/b)+f(n) =akT(1)+j=0k1ajf(n/bj)\begin{aligned} T(n) &= aT(n/b) + f(n) \\ &= a^2 T(n/b^2) + a f(n/b) + f(n) \\ &\ \vdots \\ &= a^k T(1) + \sum_{j=0}^{k-1} a^j f(n/b^j) \end{aligned}

其中 k=logbnk=\log_b n

关键恒等式ak=alogbn=nlogbaa^k = a^{\log_b n} = n^{\log_b a}

证明:两边取 logb\log_b,左边 =logbnlogba=\log_b n \cdot \log_b a,右边 =logbalogbn=\log_b a \cdot \log_b n,相等。由 logb\log_b 单调(一一对应),原等式成立。\blacksquare

于是:

T(n)=Θ(nlogba)+g(n),g(n):=j=0k1ajf(n/bj)T(n) = \Theta\big(n^{\log_b a}\big) + g(n), \qquad g(n) := \sum_{j=0}^{k-1} a^j f(n/b^j)

接下来分三种情况确定 g(n)g(n) 的阶。

情况一f(n)=O(nlogbaε)f(n)=O(n^{\log_b a-\varepsilon}),某 ε>0\varepsilon>0

代入:

ajf(n/bj)=O(aj(n/bj)logbaε)=O(nlogbaεajbjlogbabjε)a^j f(n/b^j) = O\Big(a^j (n/b^j)^{\log_b a-\varepsilon}\Big) = O\Big(n^{\log_b a-\varepsilon}\cdot a^j b^{-j\log_b a}\cdot b^{j\varepsilon}\Big)

blogba=ab^{\log_b a}=a,得 ajbjlogba=ajaj=1a^j b^{-j\log_b a}=a^j a^{-j}=1,化简为:

ajf(n/bj)=O(nlogbaεbjε)a^j f(n/b^j) = O\big(n^{\log_b a-\varepsilon}\cdot b^{j\varepsilon}\big)

求和(等比级数,公比 bε>1b^\varepsilon>1,由末项主导):

g(n)=O(nlogbaεj=0k1bjε)=O(nlogbaεbkε)=O(nlogbaεnε)=O(nlogba)g(n) = O\Big(n^{\log_b a-\varepsilon}\sum_{j=0}^{k-1} b^{j\varepsilon}\Big) = O\big(n^{\log_b a-\varepsilon}\cdot b^{k\varepsilon}\big) = O\big(n^{\log_b a-\varepsilon}\cdot n^{\varepsilon}\big) = O\big(n^{\log_b a}\big)

(用了 bk=nbkε=nεb^{k}=n \Rightarrow b^{k\varepsilon}=n^\varepsilon

代回:T(n)=Θ(nlogba)+O(nlogba)=Θ(nlogba)T(n)=\Theta(n^{\log_b a})+O(n^{\log_b a})=\Theta(n^{\log_b a})\blacksquare

情况二f(n)=Θ(nlogba)f(n)=\Theta(n^{\log_b a})

同样代入:

ajf(n/bj)=Θ(aj(n/bj)logba)=Θ(nlogba)a^j f(n/b^j) = \Theta\big(a^j(n/b^j)^{\log_b a}\big) = \Theta\big(n^{\log_b a}\big)

(与 jj 无关,每层同阶)共 k=logbnk=\log_b n 项求和:

g(n)=Θ(knlogba)=Θ(nlogbalogbn)=Θ(nlogbalogn)g(n) = \Theta\big(k\cdot n^{\log_b a}\big) = \Theta\big(n^{\log_b a}\log_b n\big) = \Theta\big(n^{\log_b a}\log n\big)

logbn=logn/logb\log_b n = \log n/\log blogb\log b 为常数,不改变阶)

代回:T(n)=Θ(nlogba)+Θ(nlogbalogn)=Θ(nlogbalogn)T(n)=\Theta(n^{\log_b a})+\Theta(n^{\log_b a}\log n)=\Theta(n^{\log_b a}\log n)(第二项渐进更大,主导结果)。\blacksquare

情况三f(n)=Ω(nlogba+ε)f(n)=\Omega(n^{\log_b a+\varepsilon}),且满足正则条件 af(n/b)cf(n)af(n/b)\le c f(n)c<1c<1nn 足够大)

上界:对正则条件归纳应用:

ajf(n/bj)cjf(n)a^j f(n/b^j) \le c^j f(n)

j=0j=0 显然;设 jj 成立,则 aj+1f(n/bj+1)=aajf(n/bj+1)acjf(n/b)cjaf(n/b)cj+1f(n)a^{j+1}f(n/b^{j+1}) = a\cdot a^j f(n/b^{j+1}) \le a\cdot c^j f(n/b) \le c^j\cdot a f(n/b) \le c^{j+1}f(n)

求和,由 c<1c<1 几何级数收敛:

g(n)f(n)j=0k1cjf(n)j=0cj=f(n)1c=O(f(n))g(n) \le f(n)\sum_{j=0}^{k-1} c^j \le f(n)\sum_{j=0}^{\infty} c^j = \frac{f(n)}{1-c} = O(f(n))

下界g(n)g(n) 的各项非负,取 j=0j=0 一项即得:

g(n)f(n)g(n)=Ω(f(n))g(n) \ge f(n) \Rightarrow g(n)=\Omega(f(n))

上下界合并:g(n)=Θ(f(n))g(n)=\Theta(f(n))

代回:T(n)=Θ(nlogba)+Θ(f(n))T(n) = \Theta(n^{\log_b a}) + \Theta(f(n))。由 f(n)=Ω(nlogba+ε)f(n)=\Omega(n^{\log_b a+\varepsilon})f(n)f(n) 多项式地大于 nlogban^{\log_b a},故第二项主导:

T(n)=Θ(f(n))T(n) = \Theta(f(n)) \qquad \blacksquare

2.4 Akra-Bazzi 方法#

适用场景: 主定理要求所有子问题规模相同(都是 n/bn/b)且系数为整数 aa;当递归式形如多个不同规模的子问题相加、或者分割比例非整数时,主定理不适用,需要更一般的 Akra-Bazzi 方法。

一般形式:

T(n)=i=1kaiT(n/bi)+f(n)T(n) = \sum_{i=1}^{k} a_i T(n/b_i) + f(n)

其中 ai>0a_i > 0bi>1b_i > 1 为常数,f(n)f(n) 满足一定光滑性条件(多项式增长即可,不要求严格单调)。

结论(仅陈述,证明依赖积分估计,超出本讲义范围):pp 是方程

i=1kaibip=1\sum_{i=1}^{k} a_i b_i^{-p} = 1

的唯一实数解(可以证明这样的 pp 存在且唯一),则:

T(n)=Θ(np(1+1nf(u)up+1du))T(n) = \Theta\left(n^p\left(1+\int_1^n \frac{f(u)}{u^{p+1}}\,du\right)\right)

例:验证主定理情况二可以被 Akra-Bazzi 还原#

T(n)=aT(n/b)+f(n)T(n)=aT(n/b)+f(n)(单一子问题,对应主定理设定),方程 abp=1a\cdot b^{-p}=1 解得 p=logbap=\log_b a,与主定理中的 nlogban^{\log_b a} 完全吻合,说明 Akra-Bazzi 是主定理的严格推广。

例:非整数分割的递归式#

T(n)=T(n/3)+T(2n/3)+nT(n) = T(n/3) + T(2n/3) + n

这种”不均匀分割”的递归式不能直接套主定理(因为两个子问题规模不同),但可以用 Akra-Bazzi:方程为 (1/3)p+(2/3)p=1(1/3)^p + (2/3)^p = 1,先猜 p=1p=1 验证:(1/3)1+(2/3)1=1(1/3)^1 + (2/3)^1 = 1,恰好成立,所以 p=1p=1。代入公式(f(u)=uf(u)=u):

T(n)=Θ(n(1+1nuu2du))=Θ(n(1+lnn))=Θ(nlogn)T(n) = \Theta\left(n\left(1+\int_1^n \frac{u}{u^2}du\right)\right) = \Theta\left(n(1+\ln n)\right) = \Theta(n\log n)

实用判断: 这类”不均匀分割但仍能保证 O(nlogn)O(n\log n)“的结果,正是快速排序最坏情况之外、各种非均匀分割场景(如带权的分治)复杂度分析的标准工具,第6章快速排序部分提到的”期望情况”分析背后也依赖类似的不均匀分割求和思想。

第3章 不变式证明法#

核心思想:要证明一个算法(尤其是循环、递归结构)是正确的,找到一个在执行过程中始终保持为真的性质(不变式),并证明它在结束时能推出我们想要的结论。

3.1 循环不变式#

定义与三个性质#

循环不变式是一个关于循环变量和数据状态的命题 PP,要用它证明循环正确性,需要验证三个性质:

  1. 初始化(Initialization):循环开始前,PP 为真。
  2. 保持(Maintenance):如果某次循环开始时 PP 为真,那么这次循环结束后(即下一次循环开始前),PP 仍为真。
  3. 终止(Termination):循环结束时,PP 仍为真,并且此时 PP 加上循环终止的条件,能推出算法的正确性。

这三步本质上是数学归纳法的翻版:初始化对应基础情形,保持对应归纳步骤,终止对应把归纳结论应用到循环退出的那一刻。

例:插入排序的正确性证明#

void insertionSort(vector<int>& arr) {
for (int i = 1; i < arr.size(); i++) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}

不变式 P(i)P(i) 在外层循环每次开始时(即将处理下标 ii 之前),子数组 arr[0..i-1] 是原数组对应位置元素的一个有序排列。

初始化: i=1i=1 时,子数组 arr[0..0] 只有一个元素,单元素数组天然有序,P(1)P(1) 成立。

保持: 假设 P(i)P(i) 成立,即 arr[0..i-1] 有序。本次循环把 arr[i] 取出存为 key,通过 while 循环把 arr[0..i-1] 中所有大于 key 的元素依次后移一位,再把 key 插入空出的位置。由于 arr[0..i-1] 本身有序,“大于 key 的元素都在末尾连续一段”,后移后再插入 key,得到的 arr[0..i] 仍然有序。于是循环结束、ii 自增后,P(i+1)P(i+1) 成立。

终止: 外层循环在 i=ni = n(数组长度)时终止,此时 P(n)P(n) 成立,即 arr[0..n-1] 整体有序——这正是排序算法要证明的结论。

\blacksquare

不变式证明法的通用模板#

1. 明确循环变量是什么,不变式P应该用这个变量参数化(如P(i))
2. 初始化:验证循环第一次进入时P成立
3. 保持:假设P在某次循环开始时成立,证明执行完循环体(包括变量更新)后,
P在下一次循环开始时依然成立
4. 终止:写出循环退出的条件,结合P,推导出算法的最终正确性

适用范围: 不止排序算法,二分查找的”答案一定在 [low, high] 区间内”、图遍历中”已访问集合外的点到起点没有更短路径”(Dijkstra 的正确性论证骨架)等,都是循环不变式的应用实例。

3.2 数学归纳法在递归算法中的应用#

循环不变式证明的是”迭代过程中性质始终保持”;当算法是递归结构时,自然的证明工具是对输入规模做数学归纳法:假设对所有规模小于 nn 的输入算法都正确,证明规模为 nn 时算法也正确。这与循环不变式的”保持”步骤是同一思想在递归结构上的体现。

例:归并排序正确性的归纳证明#

命题: 对任意长度为 nn 的数组,mergeSort 返回一个有序数组。

基础情形: n1n \le 1 时,数组天然有序,mergeSort 直接返回,正确。

归纳假设: 假设对所有长度小于 nn 的数组,mergeSort 都能正确排序。

归纳步骤: 对长度为 nn 的数组,mergeSort 把它分成两个长度均小于 nn 的子数组,分别递归调用。由归纳假设,两个子数组的递归调用结果都是有序的。剩下只需证明:两个有序数组用 merge 函数合并后的结果有序——这是一个独立的、不依赖归纳假设的局部性质(可以单独用一个简单的循环不变式证明:merge 每一步都取两个指针当前较小的元素放入结果,结果数组在任意时刻都是已放入元素中最小的那些,按顺序排列)。

因此长度为 nnmergeSort 的结果也有序,归纳成立。 \blacksquare

两种证明方式的关系: 循环不变式处理的是”同一层级内,状态如何随迭代演化”;数学归纳法处理的是”规模如何随递归层级缩小,子问题的正确性如何传递到父问题”。很多算法的完整正确性证明需要两者配合——例如上面归并排序的证明,“分解+递归”这一层用归纳法,“合并”这一步内部又用了一次局部的循环不变式。

第4章 摊还分析(Amortized Analysis)#

核心思想:单次操作的最坏情况时间,有时无法反映这个操作在一个操作序列中的真实代价。摊还分析考察的是”一连串操作的总代价”,再把总代价平摊到每次操作上,得到一个比”逐次取最坏情况再相加”更紧的上界。

与平均情况分析的区别: 平均情况分析依赖输入的概率分布(如随机快速排序);摊还分析不假设任何概率分布,是关于任意输入序列的确定性保证——保证的是”最坏情况下,n 次操作的总代价是多少”,而不是”某次操作期望花多少时间”。

4.1 聚合分析(Aggregate Analysis)#

思路: 直接计算 nn 次操作的总代价上界 T(n)T(n),然后令摊还代价 =T(n)/n= T(n)/n

例:动态数组扩容(容量翻倍策略)#

问题: 动态数组初始容量为 1,每次满了就扩容为当前容量的 2 倍(复制所有已有元素),求连续 nnpush_back 操作的摊还代价。

总代价计算: 设扩容发生在数组大小达到 1,2,4,8,1, 2, 4, 8, \dots 时。第 ii 次扩容(数组大小从 2i12^{i-1} 变为 2i2^i)需要复制 2i12^{i-1} 个元素。一共会发生 log2n\lfloor \log_2 n \rfloor 次扩容。

总复制代价=i=0log2n2i<22log2n2n\text{总复制代价} = \sum_{i=0}^{\lfloor \log_2 n \rfloor} 2^i < 2 \cdot 2^{\lfloor \log_2 n \rfloor} \le 2n

(这是公比为 2 的等比数列求和,末项主导,总和小于末项的 2 倍)

加上 nn 次普通插入本身的代价(每次 O(1)O(1)):

T(n)=O(n)+O(n)=O(n)T(n) = O(n) + O(n) = O(n)

摊还代价:

T(n)n=O(1)\frac{T(n)}{n} = O(1)

结论: 尽管某一次 push_back 触发扩容时最坏要花 O(n)O(n) 时间,但 nn 次操作的总代价是 O(n)O(n),平摊到每次操作只有 O(1)O(1)

\blacksquare

聚合分析的局限: 它给所有操作分配相同的摊还代价,无法区分”这次操作摊还代价高、那次低”,当一个数据结构上有多种不同代价的操作混合出现时,聚合分析不够精细,这时需要下面两种方法。

4.2 记账法(Accounting Method)#

思路: 给每种操作人为指定一个摊还代价(可以和实际代价不同),要求:

  1. 对任意操作序列,摊还代价之和 \ge 实际代价之和(即”总账不能透支”)
  2. 直观上:代价低于摊还代价的操作”多付的钱”作为信用(credit)存起来,代价高于摊还代价的操作从信用里”取钱”支付差额,信用余额任何时刻不能为负

例:动态数组扩容的记账法分析#

指定摊还代价: 每次 push_back 的摊还代价记为 3(实际代价是 1,多余的 2 作为信用存到刚插入的这个元素上)。

验证信用不会透支: 每个元素插入时存入 2 点信用。当扩容发生(数组从大小 mm 变为 2m2m)时,需要复制 mm 个元素,实际代价是 mm。这 mm 个元素中,“上一次扩容之后插入的元素”正好有 m/2m/2 个(因为扩容前数组刚好填满,而上一次扩容后容量是 m/2m/2,从 m/2m/2 填到 mm 又插入了 m/2m/2 个新元素),每个存有 2 点信用,总信用 =m/2×2=m= m/2 \times 2 = m,恰好够支付这次扩容的复制代价 mm,信用余额不会变负。

结论: 每次操作摊还代价为常数 3,故 nn 次操作总摊还代价为 O(n)O(n),由记账法的总账不等式,总实际代价也是 O(n)O(n),摊还代价 O(1)O(1)

\blacksquare

4.3 势能法(Potential Method)#

思路: 定义一个势函数 Φ\Phi,把它看作数据结构当前状态储存的”潜在能量”。对第 ii 次操作,定义:

c^i=ci+Φ(Di)Φ(Di1)\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1})

其中 cic_i 是该操作的实际代价,DiD_i 是操作后的数据结构状态,c^i\hat{c}_i 是摊还代价。

总摊还代价与总实际代价的关系:nn 次操作求和,中间项telescope式相消:

i=1nc^i=i=1nci+Φ(Dn)Φ(D0)\sum_{i=1}^{n} \hat{c}_i = \sum_{i=1}^{n} c_i + \Phi(D_n) - \Phi(D_0)

只要保证 Φ(D0)=0\Phi(D_0) = 0Φ(Di)0\Phi(D_i) \ge 0 对所有 ii 成立,就有 Φ(Dn)Φ(D0)0\Phi(D_n) - \Phi(D_0) \ge 0,从而:

i=1ncii=1nc^i\sum_{i=1}^{n} c_i \le \sum_{i=1}^{n} \hat{c}_i

即”总摊还代价”始终是”总实际代价”的一个有效上界——这正是势能法的有效性所在。

例:栈的 multipop 操作#

问题: 栈支持 push(代价1)、pop(代价1)、multipop(k)(弹出 k 个元素,或栈空则停止,代价 = 实际弹出元素个数)。求 nn 次混合操作序列的总代价上界。

朴素分析的问题: multipop(k) 单次最坏代价是 O(n)O(n)(栈里有 nn 个元素时一次弹空),如果直接对每次操作取最坏情况再相加,会得到 O(n2)O(n^2) 的(过于宽松的)上界。

势函数设计: Φ(D)=\Phi(D) = 栈中当前元素个数。显然 Φ(D0)=0\Phi(D_0) = 0,且 Φ(Di)0\Phi(D_i) \ge 0 始终成立,满足势能法的前提条件。

逐操作计算摊还代价:

  • push: 实际代价 ci=1c_i = 1,势能变化 Φ(Di)Φ(Di1)=+1\Phi(D_i)-\Phi(D_{i-1}) = +1。摊还代价 c^i=1+1=2\hat c_i = 1+1 = 2
  • pop: 实际代价 ci=1c_i = 1,势能变化 =1= -1。摊还代价 c^i=11=0\hat c_i = 1-1 = 0
  • multipop(k): 设实际弹出 k=min(k,Di1)k' = \min(k, |D_{i-1}|) 个元素,实际代价 ci=kc_i = k',势能变化 =k= -k'。摊还代价 c^i=kk=0\hat c_i = k' - k' = 0

结论: 每种操作的摊还代价都是 O(1)O(1) 常数,因此 nn 次任意混合操作的总实际代价:

cic^i=O(n)\sum c_i \le \sum \hat c_i = O(n)

远好于朴素分析得到的 O(n2)O(n^2)

\blacksquare

应用:并查集路径压缩+按秩合并的复杂度#

第16章并查集一节提到,路径压缩配合按秩合并后,单次 find/union 的摊还复杂度是 O(α(n))O(\alpha(n))α\alpha 为反阿克曼函数,增长极慢)。这一结果的严格证明正是通过构造一个基于秩、层级分组的势函数,套用上面同样的"cic^i\sum c_i \le \sum \hat c_i"框架得到的——证明本身较长(涉及对秩按 logn\log^* n 量级分组),此处不展开,只指出它在方法论上属于势能法的应用,而不是一种独立的分析技巧。

4.4 三种方法的关系#

方法分配方式优点适用场景
聚合分析所有操作平摊相同的摊还代价最简单操作种类单一、序列总代价好直接算
记账法人为给每种操作指定摊还代价直观,类比”预存信用”操作种类不多,信用关系容易设计
势能法用势函数刻画”状态储存的潜力”最通用,可处理复杂状态依赖操作对数据结构状态影响复杂时

三者在数学本质上是等价的(都可以证明给出同样的渐进上界),区别只在于哪种”记账方式”对特定问题最直观、最容易构造。

第5章 下界证明技术#

前几章关心的是”某个具体算法要花多少时间”(上界),本章关心一个更难的问题:“解决某个问题,任何算法最少要花多少时间”(下界)。下界证明的对象是问题本身,不是某个具体算法。

5.1 决策树模型#

适用范围: 只对”基于比较”的算法适用(如比较排序、基于比较的查找)——算法的每一步行为都归结为对两个元素做一次比较,并根据结果(小于/大于/等于)决定下一步走向。

决策树的构造: 把算法的执行过程画成一棵二叉树:每个内部节点代表一次比较,两条分支代表比较的两种可能结果,每个叶子节点代表算法终止时得到的一个确定输出。

3个元素a,b,c比较排序的部分决策树:
a<b?
/ \
是 否
/ \
b<c? a<c?
/ \ / \
是 否 是 否
a<b<c ... ... ...

关键观察: 决策树的叶子节点数 \ge 问题所有可能输出的个数(因为每种输出至少要对应一条从根到叶子的路径,否则算法无法区分这种输出该不该发生);而决策树的高度,正是算法在最坏情况下需要做的比较次数——因为最坏情况对应树中最深的那条路径。

5.2 比较排序的信息论下界#

问题: nn 个互不相同的元素,比较排序最坏情况最少需要多少次比较?

叶子数下界: nn 个元素共有 n!n! 种可能的排列,排序算法必须能区分所有这些排列(输出不同的排列对应不同的叶子),所以决策树至少有 n!n! 个叶子。

树高下界(证明): 设决策树高度为 hh,由于是二叉树,叶子数最多为 2h2^h。结合上面”叶子数 n!\ge n!”:

2hn!    hlog2(n!)2^h \ge n! \implies h \ge \log_2(n!)

用求和展开 log2(n!)\log_2(n!)

log2(n!)=k=1nlog2kk=n/2nlog2kn2log2n2=Ω(nlogn)\log_2(n!) = \sum_{k=1}^n \log_2 k \ge \sum_{k=n/2}^{n} \log_2 k \ge \frac{n}{2}\log_2 \frac{n}{2} = \Omega(n \log n)

(取后一半的项,每项至少是 log2(n/2)\log_2(n/2),共 n/2n/2 项相加)

结论:

h=Ω(nlogn)h = \Omega(n \log n)

任何基于比较的排序算法,最坏情况下都至少需要 Ω(nlogn)\Omega(n \log n) 次比较。

\blacksquare

意义: 这解释了为什么归并排序、堆排序的 O(nlogn)O(n\log n) 已经是比较排序能达到的最优阶——不是因为没找到更聪明的比较算法,而是这个下界对所有基于比较的算法都成立,无法突破。这也解释了为什么第11章的非比较排序(计数排序等)能做到 O(n)O(n) 并不矛盾:它们不属于”基于比较”的算法模型,下界对它们不适用。

5.3 对手论证(Adversary Argument)#

思路: 想象一个”对手”,它不预先固定输入,而是在算法运行过程中,每次看到算法做了什么比较,就选择对算法最不利的回答方式(只要这个回答方式不和之前的回答自相矛盾),从而构造出一个让算法走最长路径的输入。如果能证明对手总能这样”拖延”到某个比较次数,就得到了一个下界。

例:求最大值的下界#

问题: nn 个元素中找最大值,最少需要多少次比较?

对手策略: 把每个元素看作一个”尚未被淘汰”的候选者。每次算法比较两个元素 x,yx, y 时,对手回答”xx 更大”(任意选一种不矛盾的回答即可),并把 yy 标记为”被淘汰”。

论证: 要确定最大值,除了最大值本身,其余 n1n-1 个元素都必须在某次比较中被判定为”较小”,从而被淘汰——因为如果某个元素从未在任何比较中输给别人,对手可以让它也成为候选的最大值(不矛盾),算法就无法确定唯一的最大值。每次比较最多淘汰 1 个元素(败者),所以至少需要 n1n-1 次比较才能淘汰掉所有 n1n-1 个非最大值元素。

比较次数n1=Ω(n)\text{比较次数} \ge n - 1 = \Omega(n)

与上界的匹配: 朴素的”线性扫描求最大值”算法正好用 n1n-1 次比较,与这个下界紧密匹配,说明该算法在比较次数意义下已经是最优的。

\blacksquare

5.4 三种下界技术的关系#

技术证明对象核心论证典型应用
决策树模型基于比较的算法整体叶子数 \ge 输出种类数 \Rightarrow 树高下界比较排序 Ω(nlogn)\Omega(n\log n)
信息论下界决策树模型的具体计算用信息量(log\log 输出种类数)衡量最少需要的”区分能力”排序、查找问题
对手论证不限于比较模型构造最坏输入,使算法被迫多做工作求最值、选择问题等

信息论下界本质上是决策树模型的一种计算手段(用叶子数取对数得到树高),对手论证则是更一般的技术,不要求问题必须能被建模成二叉决策树,适用范围更广,但构造对手策略需要针对具体问题单独设计,没有像决策树那样的统一公式。


Part B 算法设计技术#

本部分聚焦”怎么用”:问题特征、算法构造思路、代码实现、复杂度。不展开严格正确性证明。


第6章 分治法(Divide and Conquer)#

核心思想:把问题拆成若干独立子问题,分别求解后合并。“独立”是关键——子问题之间不重叠,否则属于动态规划的范畴(见第8章)。

6.1 归并排序(子问题规模均衡型)#

思路#

graph TD A["[8, 3, 5, 1, 9, 2]"] A -->|分| B["[8, 3, 5]"] A -->|分| C["[1, 9, 2]"] B -->|分| D["[8]"] B -->|分| E["[3, 5]"] C -->|分| F["[1]"] C -->|分| G["[9, 2]"] E -->|排序| E2["[3, 5]"] G -->|排序| G2["[2, 9]"] D --- H["[3, 5, 8]"] E2 --- H F --- I["[1, 2, 9]"] G2 --- I H -->|合并| J["[1, 2, 3, 5, 8, 9]"] I -->|合并| J

每次把数组从中间切开,递归排序两半,再把两个有序数组合并成一个有序数组。

实现#

void merge(vector<int>& arr, int l, int mid, int r) {
vector<int> temp;
int i = l, j = mid + 1;
while (i <= mid && j <= r) {
if (arr[i] <= arr[j]) temp.push_back(arr[i++]);
else temp.push_back(arr[j++]);
}
while (i <= mid) temp.push_back(arr[i++]);
while (j <= r) temp.push_back(arr[j++]);
for (int k = 0; k < temp.size(); k++) arr[l + k] = temp[k];
}
void mergeSort(vector<int>& arr, int l, int r) {
if (l >= r) return;
int mid = l + (r - l) / 2;
mergeSort(arr, l, mid);
mergeSort(arr, mid + 1, r);
merge(arr, l, mid, r);
}

复杂度分析#

递归式:

T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n)
操作时间空间
排序O(n log n)O(n)

特点: 稳定排序,时间复杂度与输入无关(最好/最坏/平均都是 O(n log n)),但需要额外 O(n) 空间。


6.2 快速排序(子问题规模依赖输入型)#

为不稳定排序。事实上,快速排序在同级别复杂度的算法里几乎跑得最快,因为它的内部循环可以在大部分的架构上效率更高。

思路#

选一个主元(pivot),把数组分成”小于 pivot”和”大于 pivot”两部分,递归排序两部分。

graph TD A["[8, 3, 5, 1, 9, 2] pivot = 5"] A -->|分区| B["[3, 1, 2]"] A -->|分区| P["5"] A -->|分区| C["[8, 9]"] B -->|递归| B2["[1, 2, 3]"] C -->|递归| C2["[8, 9]"] B2 --- R["[1, 2, 3, 5, 8, 9]"] P --- R C2 --- R

实现(Lomuto 分区)#

int partition(vector<int>& arr, int l, int r) {
int pivot = arr[r];
int i = l - 1;
for (int j = l; j < r; j++) {
if (arr[j] < pivot) {
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[r]);
return i + 1;
}
void quickSort(vector<int>& arr, int l, int r) {
if (l >= r) return;
int p = partition(arr, l, r);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}

随机化主元#

问题: 如果数组本身有序或接近有序,固定取最后一个元素作 pivot 会导致每次分区都是 1:(n-1),退化为 O(n²)。

解决: 随机选 pivot,再交换到末尾,避免对手构造的最坏输入。

int randomizedPartition(vector<int>& arr, int l, int r) {
int randIdx = l + rand() % (r - l + 1);
swap(arr[randIdx], arr[r]);
return partition(arr, l, r);
}

复杂度分析#

最坏情况(每次分区都是 1:(n-1)):T(n) = T(n-1) + O(n) ⟹ O(n²)

期望情况(随机主元,分区接近均衡):O(n log n)

情况时间复杂度辅助空间复杂度(递归)
最坏情况O(n²)O(n)
期望情况O(n log n)O(log n)
最优情况O(n log n)O(log n)

6.3 Strassen 矩阵乘法(合并代价主导型)#

问题#

两个 n×n 矩阵相乘,朴素算法是三重循环,O(n³):

for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
for (int k = 0; k < n; k++)
C[i][j] += A[i][k] * B[k][j];

分治思路#

把每个 n×n 矩阵分成 4 个 n/2 × n/2 子矩阵 A11、A12、A21、A22 和 B11、B12、B21、B22。

朴素分治: 需要 8 次子矩阵乘法 + 4 次加法:T(n) = 8T(n/2) + O(n²) ⟹ O(n³),没有改善。

Strassen 的技巧: 通过 7 次乘法 + 18 次加减法构造出所有子块:

M1 = (A11+A22)(B11+B22)
M2 = (A21+A22)B11
M3 = A11(B12-B22)
M4 = A22(B21-B11)
M5 = (A11+A12)B22
M6 = (A21-A11)(B11+B12)
M7 = (A12-A22)(B21+B22)
C11 = M1+M4-M5+M7 C12 = M3+M5
C21 = M2+M4 C22 = M1-M2+M3+M6

复杂度分析#

T(n) = 7T(n/2) + O(n²),由 Master Theorem

T(n)=O(nlog27)O(n2.81)T(n) = O(n^{\log_2 7}) \approx O(n^{2.81})
算法时间复杂度
朴素矩阵乘法O(n³)
StrassenO(n^2.81)

实用提示: Strassen 常数因子大,且数值稳定性较差,实践中只在矩阵足够大时才有优势。


6.4 二分查找(子问题规模减半,无需合并)#

思路:有序数组中查找目标值,每次比较中间元素,根据大小关系丢弃一半区间,只在剩下一半中继续查找。与归并排序、快速排序不同,二分查找不需要合并步骤——子问题的答案直接就是整个问题的答案。

[1, 3, 5, 7, 9, 11, 13, 15] target = 11
第1次:[1,3,5,7,9,11,13,15] mid=9(下标4),9<11,丢弃左半
第2次:[11,13,15] mid=13(下标6),13>11,丢弃右半
第3次:[11] mid=11,命中

基础实现#

int binarySearch(vector<int>& arr, int target) {
int l = 0, r = arr.size() - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (arr[mid] == target) return mid;
else if (arr[mid] < target) l = mid + 1;
else r = mid - 1;
}
return -1; // 未找到
}
// 时间:O(log n),空间:O(1)

复杂度分析: 递归式 T(n)=T(n/2)+O(1)T(n) = T(n/2) + O(1),对应 a=1,b=2,f(n)=O(1)a=1,b=2,f(n)=O(1)nlogba=n0=1=Θ(f(n))n^{\log_b a}=n^0=1=\Theta(f(n)),由主定理情况二:

T(n)=Θ(logn)T(n) = \Theta(\log n)

查找左边界(右边界)#

arr = [1, 2, 2, 2, 3, 4, 5] target = 2
左边界:下标1
右边界:下标3
cppreference

返回指向范围 [first, last) 中第一个不小于 (>=) find: value 的元素的迭代器,如果没有则返回 last
左闭右开区间 [lo, hi) 的写法没有任何特例,找不到自然返回 end

int* lower_bound(int* start, int* end, int find) {
int* lo = start;
int* hi = end;
while (lo < hi) {
int* mid = lo + (hi - lo) / 2; // 用指针运算,天然防溢出
if (*mid < find) lo = mid + 1; // mid 及左边都 < find,排除
else hi = mid; // mid 可能是答案,保留
}
return lo; // 第一个 >= find 的位置;都比它小则正好是 end
}
Note

upper_bound - lower_bound 就是 find 在数组里出现的次数,[lower_bound, upper_bound) 正好框住所有等于 find 的元素

hi = mid 的含义

*mid == find:mid 本身就是合法的 find 出现位置。虽然区间写成左闭右开 [lo, hi),但令 hi = mid 之后,mid 作为”目前已知最靠左的 find”被保留了下来,并没有被排除:

  • 若 mid 左边不再有别的 == find 的元素,二分只会不断把 lo 向 mid 推进,最终 lo == hi 收敛于 mid,区间退化为 [find, find),此时返回的正是 mid。
  • 若 mid 左边还存在更靠左的 == find,算法会在某个 mid' < mid 处再次命中 *mid' == find,同样令 hi = mid',把候选进一步收紧。原先的 mid 被取代,但这正是 lower_bound 该有的结果——要找的本来就是最左边那一个。

*mid > find:mid 本身不是 find,但当数组里不存在任何等于 find 的元素时,lower_bound 的定义退化为”第一个严格大于 find 的位置”,mid 此时正是这个候选上界,处理方式与上一种情况完全一致:hi = mid,保留 mid,继续向左确认有没有更靠左的、同样满足 >= find 的位置。

两种情况合并写成同一个判断 *mid >= find 并统一执行 hi = mid,正是因为它们要做的事一致:收敛。每一次 hi = mid 都不会丢弃候选,只会把它替换为更靠左、同样满足 >= find 的候选,因此 lo == hi 时,hi 携带的正是历史上出现过的最靠左的合法候选——这就是 lower_bound 的返回值。

查找右边界:对称

int* upper_bound(int* start, int* end, int find) {
int* lo = start;
int* hi = end;
while (lo < hi) {
int* mid = lo + (hi - lo) / 2;
if (*mid <= find) lo = mid + 1; // mid 及左边都 <= find,排除
else hi = mid; // mid > find,可能是答案,保留
}
return lo;
}

用 STL 实现同样的功能:

// ranges 版(C++20)
auto pos = ranges::lower_bound(v, target);
int index = distance(v.begin(), pos); // 转为下标
auto left = lower_bound(arr.begin(), arr.end(), target); // 第一个>=target的位置
auto right = upper_bound(arr.begin(), arr.end(), target); // 第一个>target的位置
// [left, right) 就是所有等于target的元素的区间

二分答案(在”答案的取值范围”上二分,而非在数组上二分)#

核心思想: 有些问题的答案本身不是直接查出来的,但如果”答案是否满足某条件”这个判定具有单调性(一旦某个候选值满足条件,所有比它大/小的候选值也一定满足/不满足),就可以对答案的取值范围做二分,每次用一个判定函数检查猜测的答案是否可行。

例: 木材切割问题——给定 n 根木材的长度,要切出 k 段相同长度的木材,求能切出的最大长度。

木材长度: [5, 9, 11] 需要切出 k=4 段
判定函数check(len):每根木材能切出floor(length/len)段,加总是否>=k
check(5): 5/5=1, 9/5=1, 11/5=2,共4段,>=4,可行
check(6): 5/6=0, 9/6=1, 11/6=1,共2段,<4,不可行
答案为5
bool check(vector<int>& lengths, int len, int k) {
int count = 0;
for (int l : lengths) count += l / len;
return count >= k;
}
int maxCutLength(vector<int>& lengths, int k) {
int l = 1, r = *max_element(lengths.begin(), lengths.end());
int result = 0;
while (l <= r) {
int mid = l + (r - l) / 2;
if (check(lengths, mid, k)) {
result = mid;
l = mid + 1; // 尝试更大的长度
} else {
r = mid - 1;
}
}
return result;
}
// 时间:O(n log(maxLength)),每次判定O(n),二分O(log(maxLength))次

判断能否用二分答案的关键: 答案的可行性必须对取值单调——“长度越短,能切出的段数越多”这种单调关系成立,才能通过二分不断缩小范围;如果可行性不单调,二分答案不适用。

二分查找类型适用场景时间复杂度
标准二分查找有序数组中查找确定值O(log n)
左右边界二分有序数组中查找重复值的范围O(log n)
二分答案答案不直接可查,但可行性单调O(f(n) · log(范围))

6.5 三分查找(子问题规模缩至三分之二,适用单峰函数)#

与二分查找的核心区别: 二分查找要求数据本身有序(单调);三分查找要求的是函数单峰(先增后减,或先减后增),不要求整个定义域上有序——单峰函数在极值点两侧的单调方向相反,这一点是二分查找处理不了的,因为二分的每一步判断依赖”目标在mid的左边还是右边”这个基于全局单调性的推理,单峰函数不满足全局单调,但仍然可以通过缩小区间来逼近极值点。

思路: 在区间 [l, r] 内取两个三等分点 m1m2,比较 f(m1)f(m2) 的大小,根据单峰函数的性质,可以排除掉三分之一的区间(不可能包含极值点的那一段),重复直到区间足够小。

求单峰函数(先增后减)的最大值,区间[l, r]
第1次:取m1 = l + (r-l)/3,m2 = r - (r-l)/3
如果f(m1) < f(m2),说明极值点在m1右侧(因为函数先增后减,
m1比m2更靠左、值却更小,m1左侧不可能是极值点附近),排除[l, m1)
如果f(m1) > f(m2),则极值点在m2左侧,排除(m2, r]
第2次:在缩小后的区间重复上述过程
...
直到区间长度小于精度要求

实现(以求单峰函数最大值为例)#

double ternarySearch(function<double(double)> f, double l, double r, double eps) {
while (r - l > eps) {
double m1 = l + (r - l) / 3;
double m2 = r - (r - l) / 3;
if (f(m1) < f(m2)) {
l = m1; // 极值点在m1右侧,排除[l, m1)
} else {
r = m2; // 极值点在m2左侧,排除(m2, r]
}
}
return f((l + r) / 2); // 返回近似最大值
}
// 时间:O(log((r-l)/eps)),每次迭代排除1/3区间,收敛速度与二分同阶(只是常数不同)

离散版本(整数域上的单峰序列):

int ternarySearchDiscrete(vector<int>& arr, int l, int r) { // 求单峰数组的最大值下标
while (r - l > 2) {
int m1 = l + (r - l) / 3;
int m2 = r - (r - l) / 3;
if (arr[m1] < arr[m2]) {
l = m1 + 1;
} else {
r = m2 - 1;
}
}
int maxIdx = l;
for (int i = l + 1; i <= r; i++) {
if (arr[i] > arr[maxIdx]) maxIdx = i;
}
return maxIdx;
}
// 时间:O(log n)

注意: 离散版本的边界处理比连续版本更容易出错——当区间缩小到只剩两三个元素时,m1m2 可能重合或相邻,此时直接对剩下的几个元素线性扫描,比继续按三分逻辑收缩更安全,这是上面代码里 r - l > 2 这个退出条件的原因。

复杂度对比: 三分查找每次排除 1/3 区间,收敛速度是 O(log3/2n)O(\log_{3/2} n),二分查找每次排除 1/2 区间,收敛速度是 O(log2n)O(\log_2 n)——三分查找单次迭代需要计算两次函数值(f(m1)f(m2)),二分只需一次,所以在相同问题规模下,三分查找的常数因子更大;三分查找存在的意义不是比二分更快,而是二分查找对不满足全局单调性的单峰函数根本不适用

查找类型适用条件每次排除比例每次迭代计算次数
二分查找数据整体有序(单调)1/21次比较
三分查找函数单峰(不要求整体有序)1/32次函数求值

典型应用场景: 求凸函数/凹函数的极值(如二次函数最小值、抛物线轨迹的最高点)、机器学习中简单损失函数的最优参数搜索(仅限单峰情形,不适用于有多个局部极值的函数)。


6.6 黄金分割查找(Golden Section Search,解决与三分查找同一类问题,但选点方式不同)#

解决的问题和三分查找完全一样: 求单峰函数的极值,同样不要求整体有序、同样每次排除一部分区间。区别只在于”取哪两个点来比较”——三分查找每次都用固定的三等分点,黄金分割查找用一个特殊比例(黄金比例)选点,换来的好处是能复用上一轮已经算过的函数值,不需要每轮都重新计算两次函数值。

为什么三分查找有浪费?#

回顾三分查找每一轮:取 m1m2,比较后排除一段区间,进入下一轮。问题在于:假设这一轮排除了 [l, m1),新区间是 [m1, r]——但下一轮的 m1'm2' 是在新区间 [m1, r] 上重新三等分算出来的,和上一轮的 m2(在旧区间里)位置并不重合,所以 f(m2) 这个刚算完的值,在下一轮完全用不上,必须重新算两个新的函数值。三分查找每一轮都要付出2次函数求值的代价,一次都省不下来。

为什么三分已经是极限?#

三分查找的本质是:每次取两个分割点 m1m2,通过比较 f(m1)f(m2) 判断极值点在哪一侧,从而舍弃一段区间。这个判断依据是单峰函数只有一个极值点这一性质——两点比较就足够定位极值点所在的大致方向。

如果分成四份(三个分割点 m1 < m2 < m3),比较 f(m1)f(m2)f(m3) 三个值:

  • 单峰函数的这三个值本身就应该单调地靠近极值点再远离(比如求最大值时可能是递增后递减)
  • 但比较三个点得到的信息量,并不比比较两个点更多——极值点所在区间的判断,两点比较已经充分

也就是说,多出来的第三个分割点不提供额外的可舍弃区间信息,只是浪费了一次函数求值。

黄金分割查找如何避免这种浪费#

核心思想: 选点比例不再是”三等分”,而是按黄金比例 ϕ=5120.618\phi = \dfrac{\sqrt5-1}{2} \approx 0.618 来选:在区间 [l, r] 内取

m1=rϕ(rl),m2=l+ϕ(rl)m_1 = r - \phi(r-l), \qquad m_2 = l + \phi(r-l)

这个比例经过设计,使得排除一段区间后,留在新区间里的那个点,正好等于按同样比例在新区间上会选出的一个点——也就是说,上一轮”没被排除”的那个函数值,可以直接原样搬到下一轮当作 m1m2 之一继续使用,不需要重新计算。

黄金分割查找的选点关系(示意):
第1轮:[l ----m1----m2---- r],比较f(m1)和f(m2),假设排除[l, m1)
第2轮新区间:[m1 ----?----m2---- r]
由黄金比例的性质,m2在新区间里的相对位置,
恰好就是新区间该取的"较靠右的分割点",
所以第2轮只需要新算一个点(新区间里较靠左的那个),
f(m2)这个值直接复用,不用重新算

实现#

double goldenSectionSearch(function<double(double)> f, double l, double r, double eps) {
const double phi = (sqrt(5.0) - 1) / 2; // 黄金比例 ≈ 0.618
double m1 = r - phi * (r - l);
double m2 = l + phi * (r - l);
double f1 = f(m1);
double f2 = f(m2);
while (r - l > eps) {
if (f1 < f2) {
l = m1;
m1 = m2; // 上一轮的m2,直接变成这一轮的m1
f1 = f2; // 对应的函数值也直接复用,不重新计算
m2 = l + phi * (r - l);
f2 = f(m2); // 只需要新算这一个点
} else {
r = m2;
m2 = m1;
f2 = f1;
m1 = r - phi * (r - l);
f1 = f(m1); // 只需要新算这一个点
}
}
return f((l + r) / 2);
}
// 时间:O(log((r-l)/eps)),与三分查找同阶,但每轮只需1次新的函数求值(三分查找每轮需要2次)

与三分查找的对比#

特性三分查找黄金分割查找
解决的问题单峰函数求极值单峰函数求极值(完全相同)
选点方式固定三等分按黄金比例选点
每轮函数求值次数2次(每轮都重新算)1次(另一个值从上一轮直接复用)
每次排除的区间比例固定1/3约0.382(1ϕ1-\phi),比三分略少但换来求值次数减半
实现复杂度简单,容易写对需要小心维护”哪个值该复用”的状态,容易写错

什么时候该选黄金分割查找而不是三分查找#

唯一的决定因素是:函数求值的代价有多贵。 如果 f(x) 只是一次简单的算术运算(比如一个二次函数),三分查找和黄金分割查找的实际运行时间几乎没有区别——省下一次函数求值根本不值一提,这时候写起来更简单、更不容易出错的三分查找是更好的选择。但如果 f(x) 本身很昂贵(比如需要跑一次完整的模拟、一次数值积分、甚至是一次机器学习模型的训练),三分查找每轮浪费的那一次函数求值,可能就是几秒到几小时的代价,这时候黄金分割查找省下一半函数求值次数,才是有实际意义的加速。

三者的关系总结: 二分查找、三分查找、黄金分割查找并不是难度递进或效果递进的三个版本——二分查找解决”数据整体有序”的问题,三分和黄金分割解决的是另一类问题”函数单峰”(这一点上两者与二分查找不可互相替代);三分和黄金分割解决的是完全相同的问题,区别只在于”选点方式是否能省下重复的函数求值”,选择哪一个,只取决于函数求值本身贵不贵。


第7章 单调性优化技巧(Monotonicity-based Optimization)#

核心思想:如果能证明问题中某些状态一旦被新的更优状态”压制”就永远不可能再被用到,可以把它们直接丢弃,不必重复比较。这种”压制关系”通常表现为某个量上的单调性,利用它可以把本来需要 O(n²) 甚至更高的暴力做法降到 O(n)。这不属于分治(子问题不独立)、动态规划(没有显式的状态转移表)、贪心(不是”选择不回头”)中任何一类,是单独的第七种技术。

7.1 双指针 / 滑动窗口#

核心思想: 用两个指针(或一个固定大小的窗口)在一次遍历中维护一段连续区间,根据区间满足的条件移动指针,避免对每个起点都重新扫描一遍。

例:最长不含重复字符的子串#

问题: 给定字符串,求最长的不包含重复字符的连续子串长度。

s = "abcabcbb"
最长不重复子串: "abc",长度3

朴素做法: 对每个起点 i,向右扫描到第一个重复字符为止,记录最大长度,时间 O(n²)。

双指针做法: 维护窗口 [left, right],用一个哈希集合记录窗口内已出现的字符。right 不断右移;如果新字符已经在窗口里,就不断右移 left(同时从集合中移除字符),直到窗口内不再有重复,整个过程 rightleft 都只单向移动,各自最多移动 n 次。

int lengthOfLongestSubstring(string s) {
unordered_set<char> window;
int left = 0, maxLen = 0;
for (int right = 0; right < s.size(); right++) {
while (window.count(s[right])) {
window.erase(s[left]);
left++;
}
window.insert(s[right]);
maxLen = max(maxLen, right - left + 1);
}
return maxLen;
}
// 时间:O(n),因为left和right合计最多各移动n次

为什么 left 不需要回退: 一旦窗口 [left, right] 不含重复字符,把 right 右移后产生的重复,只可能由新字符 s[right] 造成,所以只需要把 left 移动到”上一次该字符出现位置的下一位”,不需要把 left 移回更早的位置重新检查——这正是单调性所在:left 的最优位置随 right 增大单调不减。

例:固定窗口的滑动窗口最大值(与单调队列配合,见7.3节)#

固定大小窗口的问题通常和单调队列一起使用,这里先给出窗口移动的框架,具体的”如何快速维护窗口最大值”留给 7.3 节。

// 框架示意:窗口大小固定为k,每次右移一位
for (int right = 0; right < n; right++) {
// 1. 将arr[right]加入窗口
// 2. 如果窗口大小超过k,移除最左边的元素(left++)
// 3. 查询当前窗口的某个统计量(最大值、和等)
}
问题类型时间复杂度关键点
可变窗口O(n)两个指针都只单向移动
固定窗口O(n)每次移动窗口代价 O(1)(朴素维护最大值除外,见7.3节单调队列

7.2 单调栈(Monotonic Stack)#

核心思想: 维护一个栈,栈内元素始终保持单调(递增或递减)。当新元素破坏单调性时,弹出栈顶元素——这些被弹出的元素,新元素就是”第一个比它们更优(更大/更小)“的元素,弹出的同时正好完成了一次有效计算。

例:每日温度(下一个更大元素)#

问题: 给定每天的温度,对每一天求”要等多少天才会出现比当天更高的温度”,如果不存在则为 0。

温度: [73, 74, 75, 71, 69, 72, 76, 73]
结果: [1, 1, 4, 2, 1, 1, 0, 0]

朴素做法: 对每天向后扫描直到找到更高温度,时间 O(n²)。

单调栈做法: 维护一个存储”下标”的栈,栈中下标对应的温度从栈底到栈顶递减。遍历每一天时,只要当前温度比栈顶下标对应的温度高,就说明栈顶那天等到了”更高温度”,弹出并计算天数差;否则把当前下标压入栈。

vector<int> dailyTemperatures(vector<int>& temps) {
int n = temps.size();
vector<int> result(n, 0);
stack<int> st; // 存下标,对应温度从栈底到栈顶递减
for (int i = 0; i < n; i++) {
while (!st.empty() && temps[i] > temps[st.top()]) {
int prevDay = st.top();
st.pop();
result[prevDay] = i - prevDay;
}
st.push(i);
}
return result;
}
// 时间:O(n)

为什么是 O(n): 每个下标最多入栈一次、出栈一次,所有 while 循环弹出操作加起来不会超过 n 次,所以总时间是 O(n),而不是看起来的”嵌套循环 O(n²)“。

例:柱状图中最大的矩形#

问题: 给定一排柱子的高度,求柱状图中面积最大的矩形(矩形宽度必须是连续若干根柱子,高度由其中最矮的柱子决定)。

高度: [2, 1, 5, 6, 2, 3]
最大矩形: 高度5和6构成的部分,面积 = 2 × 5 = 10

单调栈做法: 维护一个高度单调递增的栈(存下标)。当新柱子比栈顶矮时,说明栈顶柱子向右的”管辖范围”到此结束,弹出并计算它能构成的最大矩形面积(高度=该柱子高度,宽度=从它在栈中的前一个柱子到当前柱子之间的距离)。

int largestRectangleArea(vector<int>& heights) {
stack<int> st;
int maxArea = 0;
heights.push_back(0); // 哨兵,确保最后所有柱子都被弹出计算
for (int i = 0; i < heights.size(); i++) {
while (!st.empty() && heights[i] < heights[st.top()]) {
int h = heights[st.top()];
st.pop();
int width = st.empty() ? i : i - st.top() - 1;
maxArea = max(maxArea, h * width);
}
st.push(i);
}
return maxArea;
}
// 时间:O(n)
问题单调方向弹出时机
下一个更大元素递减栈新元素更大时弹出
下一个更小元素递增栈新元素更小时弹出
柱状图最大矩形递增栈新元素更小时弹出并计算面积

7.3 单调队列(Monotonic Queue)#

核心思想: 和单调栈类似,但需要支持”两端都能操作”——新元素从队尾加入时维护单调性(弹出队尾不再可能是答案的元素),队首元素如果”过期”(超出窗口范围)则从队首弹出。最典型的应用是滑动窗口最大值

例:滑动窗口最大值#

问题: 给定数组和窗口大小 k,求每个窗口内的最大值。

arr = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
窗口[1,3,-1] 最大值3
窗口[3,-1,-3] 最大值3
窗口[-1,-3,5] 最大值5
窗口[-3,5,3] 最大值5
窗口[5,3,6] 最大值6
窗口[3,6,7] 最大值7

朴素做法: 对每个窗口扫描求最大值,时间 O(nk)。

单调队列做法: 维护一个存储”下标”的双端队列,下标对应的值从队首到队尾递减

  • 新元素加入前,从队尾弹出所有比它小的元素(这些元素不可能再成为后续任何窗口的最大值,因为新元素比它们大、又比它们靠后,永远会”压制”它们)
  • 队首如果是已经滑出窗口的下标,从队首弹出
  • 队首即为当前窗口最大值
vector<int> maxSlidingWindow(vector<int>& arr, int k) {
deque<int> dq; // 存下标,对应值从队首到队尾递减
vector<int> result;
for (int i = 0; i < arr.size(); i++) {
// 维护队尾单调性
while (!dq.empty() && arr[dq.back()] <= arr[i]) {
dq.pop_back();
}
dq.push_back(i);
// 移除已滑出窗口的队首
if (dq.front() <= i - k) {
dq.pop_front();
}
if (i >= k - 1) {
result.push_back(arr[dq.front()]);
}
}
return result;
}
// 时间:O(n),每个下标最多入队一次、出队一次

为什么队首一定是窗口最大值: 队列内下标对应的值严格递减,且都在窗口范围内,所以队首既是窗口内的元素,又是其中最大的——任何比它后面的较小值都已经在维护单调性时被提前清除。

7.4 三种技巧的对比#

技巧维护的结构解决什么问题时间复杂度
双指针一段连续区间的左右边界满足某条件的最长/最短连续子区间O(n)
单调栈单调的栈(只在一端操作)每个元素”下一个更大/更小”的位置O(n)
单调队列单调的双端队列滑动窗口内的最大值/最小值O(n)

共同点: 三者都依赖”一旦某个元素被证明不可能再是答案,就永久丢弃它”这一单调性论证,从而保证总操作次数是 O(n)(每个元素只入、出一次),而不是看起来的双重循环 O(n²)。


第8章 动态规划(Dynamic Programming, DP)#

这套方法论本身可以套到几乎任何”有重叠子问题+最优子结构”的问题上,是真正的通用设计套路。
把问题拆成子问题,但子问题之间有重叠,用空间换时间,把每个子问题的解存下来,避免重复计算。
但”最优子结构是否成立”这个证明依然要针对每个问题单独做——这点上DP和贪心其实是同一类负担(都要证明问题的数学性质),只是DP的失败模式更宽松:贪心要求”局部最优⟹全局最优”,DP只要求”子问题最优⟹整体最优”,后者成立的问题范围远大于前者,所以DP能覆盖的问题集合远大于贪心。

8.1 基本框架#

三要素#

固定步骤:定义状态→写转移方程→边界条件

  1. 状态:用什么变量描述一个子问题
  2. 转移:状态之间如何递推
  3. 边界:递推的起点

与分治的区别#

特性分治动态规划
子问题关系互不重叠重叠
求解方式各自独立递归求解求解一次,存起来复用
典型例子归并排序斐波那契数列、LCS

两种实现方式#

记忆化(自顶向下):

unordered_map<int, long long> memo;
long long fib(int n) {
if (n <= 1) return n;
if (memo.count(n)) return memo[n];
return memo[n] = fib(n - 1) + fib(n - 2);
}

自底向上(递推):

long long fib(int n) {
vector<long long> dp(n + 1);
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
方式优点缺点
记忆化代码贴近原始递归,好写递归调用有额外开销
自底向上没有递归开销,可滚动数组省空间需要理清填表顺序

8.2 线性状态空间#

dp[i] 只依赖于下标比 i 小的状态。

最长递增子序列(LIS)#

问题: 给定数组,求最长的严格递增子序列长度。

状态: dp[i] = 以 arr[i] 结尾的最长递增子序列长度

转移: dp[i]=max(dp[j]+1)dp[i] = \max(dp[j] + 1),其中 j<ij < iarr[j]<arr[i]arr[j] < arr[i]

int lengthOfLIS(vector<int>& arr) {
int n = arr.size();
vector<int> dp(n, 1);
int ans = 1;
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (arr[j] < arr[i]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
ans = max(ans, dp[i]);
}
return ans;
}
// 时间:O(n^2),空间:O(n)

优化到 O(n log n): 维护数组 tailstails[k] 表示长度为 k+1 的递增子序列的最小结尾值,对每个新元素用二分查找它能替换的位置。

int lengthOfLIS_fast(vector<int>& arr) {
vector<int> tails;
for (int x : arr) {
int pos = lower_bound(tails.begin(), tails.end(), x) - tails.begin();
if (pos == tails.size()) tails.push_back(x);
else tails[pos] = x;
}
return tails.size();
}
// 时间:O(n log n)

编辑距离 / 最长公共子序列(LCS)#

问题(LCS): 给定两个字符串,求最长公共子序列长度。

状态: dp[i][j] = s1[0..i)s2[0..j) 的最长公共子序列长度

转移:

dp[i][j]={dp[i1][j1]+1s1[i1]=s2[j1]max(dp[i1][j], dp[i][j1])s1[i1]s2[j1]dp[i][j] = \begin{cases} dp[i-1][j-1] + 1 & s1[i-1] = s2[j-1] \\ \max(dp[i-1][j],\ dp[i][j-1]) & s1[i-1] \neq s2[j-1] \end{cases}
int longestCommonSubsequence(string s1, string s2) {
int m = s1.size(), n = s2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s1[i-1] == s2[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
else dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
return dp[m][n];
}
// 时间:O(mn),空间:O(mn)

编辑距离: 思路相同,转移变为:

dp[i][j]={dp[i1][j1]s1[i1]=s2[j1]1+min(dp[i1][j], dp[i][j1], dp[i1][j1])s1[i1]s2[j1]dp[i][j] = \begin{cases} dp[i-1][j-1] & s1[i-1] = s2[j-1] \\ 1 + \min(dp[i-1][j],\ dp[i][j-1],\ dp[i-1][j-1]) & s1[i-1] \neq s2[j-1] \end{cases}
问题时间复杂度空间复杂度
LISO(n log n)O(n)
LCSO(mn)O(mn)
编辑距离O(mn)O(mn)

8.3 区间状态空间#

dp[i][j] 表示区间 [i, j] 上的最优解,转移时枚举区间内的”分割点”。

矩阵链乘法#

问题: n 个矩阵相乘,不同括号方式计算量不同,求最少乘法次数。

状态: dp[i][j] = 矩阵 Ai…Aj 相乘的最少乘法次数

转移: 枚举分割点 kkdp[i][j]=min(dp[i][k]+dp[k+1][j]+p[i1]p[k]p[j])dp[i][j] = \min(dp[i][k] + dp[k+1][j] + p[i-1] \cdot p[k] \cdot p[j])

int matrixChainOrder(vector<int>& p) {
int n = p.size() - 1;
vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
for (int len = 2; len <= n; len++) {
for (int i = 1; i <= n - len + 1; i++) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; k++) {
int cost = dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j];
dp[i][j] = min(dp[i][j], cost);
}
}
}
return dp[1][n];
}
// 时间:O(n^3),空间:O(n^2)

区间 DP 的通用模式: 按区间长度从小到大枚举,每个区间枚举分割点,保证用到的子区间已经算好。

区间 DP 通用模型:石子合并#

问题: n 堆石子排成一行,每次合并相邻两堆,代价是两堆石子数之和,求合并成一堆的最小总代价。

int mergeStones(vector<int>& stones) {
int n = stones.size();
vector<int> prefix(n + 1, 0);
for (int i = 0; i < n; i++) prefix[i+1] = prefix[i] + stones[i];
vector<vector<int>> dp(n, vector<int>(n, 0));
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; k++) {
int cost = dp[i][k] + dp[k+1][j] + (prefix[j+1] - prefix[i]);
dp[i][j] = min(dp[i][j], cost);
}
}
}
return dp[0][n-1];
}
// 时间:O(n^3)

8.4 树形状态空间#

dp[node] 依赖于子节点的 dp 值,通常用 DFS 后序遍历填表。

树上最大独立集#

问题: 给定一棵树,每个节点有权值,选出一个权值和最大的点集,使得集合内任意两点不直接相连。

状态:

  • dp[u][0]:不选 u 时,u 的子树能获得的最大权值和
  • dp[u][1]:选 u 时,u 的子树能获得的最大权值和

转移:

  • dp[u][1]=wu+dp[v][0]dp[u][1] = w_u + \sum dp[v][0]vvuu 的子节点)
  • dp[u][0]=max(dp[v][0], dp[v][1])dp[u][0] = \sum \max(dp[v][0],\ dp[v][1])
vector<vector<int>> adj;
vector<int> weight;
vector<vector<int>> dp;
void dfs(int u, int parent) {
dp[u][1] = weight[u];
dp[u][0] = 0;
for (int v : adj[u]) {
if (v == parent) continue;
dfs(v, u);
dp[u][1] += dp[v][0];
dp[u][0] += max(dp[v][0], dp[v][1]);
}
}
int maxIndependentSet(int root) {
dfs(root, -1);
return max(dp[root][0], dp[root][1]);
}
// 时间:O(n)

8.5 子集状态空间#

用二进制位表示一个子集,dp[mask] 表示选择了 mask 所代表子集时的最优解。适用于 n ≤ 20 左右的组合问题。

0/1 背包#

问题: n 件物品,每件有重量 w[i] 和价值 v[i],背包容量 W,每件物品最多选一次。

状态: dp[i][j] = 前 i 件物品,容量 j 时的最大价值

转移:

dp[i][j]={dp[i1][j]j<wi(放不下)max(dp[i1][j], dp[i1][jwi]+vi)jwidp[i][j] = \begin{cases} dp[i-1][j] & j < w_i \text{(放不下)} \\ \max(dp[i-1][j],\ dp[i-1][j-w_i]+v_i) & j \ge w_i \end{cases}
int knapsack01_optimized(vector<int>& w, vector<int>& v, int W) {
int n = w.size();
vector<int> dp(W + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = W; j >= w[i]; j--) { // 从大到小!
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[W];
}
// 时间:O(nW),空间:O(W)

完全背包#

区别: 每件物品可以选无限次,j 从小到大遍历。

int knapsackUnbounded(vector<int>& w, vector<int>& v, int W) {
int n = w.size();
vector<int> dp(W + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = w[i]; j <= W; j++) { // 从小到大!
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[W];
}
背包类型遍历顺序原因
0/1 背包j 从大到小防止同一物品被多次计入
完全背包j 从小到大允许同一物品被多次计入

多重背包与二进制优化#

问题: 第 i 件物品有数量上限 cic_i(不像0/1背包只能选1次,也不像完全背包能选无限次),求背包能装下的最大价值。

朴素做法: 把”选 cic_i 次”看成 cic_i 件相同的物品,退化成0/1背包,对第 i 种物品循环 cic_i 次:

int knapsackMultipleNaive(vector<int>& w, vector<int>& v, vector<int>& c, int W) {
vector<int> dp(W + 1, 0);
int n = w.size();
for (int i = 0; i < n; i++) {
for (int k = 0; k < c[i]; k++) { // 把第i种物品拆成c[i]个独立的物品
for (int j = W; j >= w[i]; j--) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
}
return dp[W];
}

二进制优化的思路: 不需要把 cic_i 件物品拆成 cic_i 个独立的”件”,而是拆成若干个”组合包”,每个组合包代表”选择这一组物品中任意子集”的效果,组合包的数量只需要 O(logci)O(\log c_i) 个,而不是 O(ci)O(c_i) 个——这是因为二进制表示下,1,2,4,8,,2k1, 2, 4, 8, \dots, 2^{k} 这些组合包能拼出 [0,2k+11][0, 2^{k+1}-1] 范围内的任意整数个物品。

例:某物品数量c_i = 13
13的二进制拆分:13 = 1 + 2 + 4 + 6(最后一个是补足剩余,不一定是8)
拆分成4个"组合包":
组合包1:相当于1件该物品(重量w,价值v)
组合包2:相当于2件该物品(重量2w,价值2v)
组合包3:相当于4件该物品(重量4w,价值4v)
组合包4:相当于6件该物品(重量6w,价值6v,补足13-1-2-4=6)
任意0~13件的组合,都可以通过"选择这4个组合包中的若干个"拼出来
(例如要5件:选组合包1+组合包2,1+2=3,不对——应该选组合包2和组合包3?2+4=6,也不对
正确理解:5 = 1+4,选组合包1和组合包3)
int knapsackMultipleBinary(vector<int>& w, vector<int>& v, vector<int>& c, int W) {
vector<pair<int,int>> items; // 拆分后的(重量, 价值)组合包列表
for (int i = 0; i < w.size(); i++) {
int remain = c[i];
int k = 1;
while (remain > 0) {
int take = min(k, remain); // 这一份代表take件该物品
items.push_back({w[i] * take, v[i] * take});
remain -= take;
k *= 2;
}
}
// 拆分完成后,退化为标准0/1背包,对每个"组合包"做一次0/1背包转移
vector<int> dp(W + 1, 0);
for (auto& [weight, value] : items) {
for (int j = W; j >= weight; j--) {
dp[j] = max(dp[j], dp[j - weight] + value);
}
}
return dp[W];
}

正确性的关键: 二进制拆分保证了任意 00cic_i 之间的整数都能表示成这些组合包子集之和——这是二进制表示法本身的性质(每个正整数都能唯一分解为若干个 2k2^k 之和),拆分后退化为0/1背包并不会丢失任何可能的选择方式,也不会引入选择数量超过 cic_i的非法情况(因为组合包总和恰好为 cic_i)。

做法时间复杂度适用场景
朴素拆分O(WΣci)O(W·Σc_i)cic_i 较小时可用
二进制优化O(WΣlogci)O(W·Σlog c_i)远优于朴素做法,cic_i 较大时显著更快

状压 DP:旅行商问题(TSP)#

问题: n 个城市,求从某点出发,经过所有城市恰好一次再回到起点的最短路径。

状态: dp[mask][i] = 已访问 mask 所表示的城市集合,当前停在城市 i 的最短路径长度

转移: dp[mask][i]=min(dp[mask{i}][j]+dist[j][i])dp[mask][i] = \min(dp[mask \setminus \{i\}][j] + dist[j][i])jmask, jij \in mask,\ j \neq i

int tsp(vector<vector<int>>& dist) {
int n = dist.size();
vector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX));
dp[1][0] = 0;
for (int mask = 1; mask < (1 << n); mask++) {
for (int i = 0; i < n; i++) {
if (!(mask & (1 << i)) || dp[mask][i] == INT_MAX) continue;
for (int j = 0; j < n; j++) {
if (mask & (1 << j)) continue;
int newMask = mask | (1 << j);
dp[newMask][j] = min(dp[newMask][j], dp[mask][i] + dist[i][j]);
}
}
}
int ans = INT_MAX;
int fullMask = (1 << n) - 1;
for (int i = 1; i < n; i++) {
if (dp[fullMask][i] != INT_MAX) {
ans = min(ans, dp[fullMask][i] + dist[i][0]);
}
}
return ans;
}
// 时间:O(n^2 * 2^n),空间:O(n * 2^n)

8.6 图上的 DP#

许多图算法本质上是 DP:状态是”到达某点的最优值”,转移是”松弛”相邻边。

DAG 上的最短路#

按拓扑序进行 DP,不需要处理依赖循环问题。

vector<int> dagShortestPath(vector<vector<pair<int,int>>>& adj, int n, int start) {
vector<int> topoOrder = topologicalSort(adj, n);
vector<int> dp(n, INT_MAX);
dp[start] = 0;
for (int u : topoOrder) {
if (dp[u] == INT_MAX) continue;
for (auto& [v, w] : adj[u]) {
if (dp[u] + w < dp[v]) dp[v] = dp[u] + w;
}
}
return dp;
}
// 时间:O(V + E)

Bellman-Ford(含负权边无负环)#

思路: 不能用拓扑序(普通图可能有环),反复对所有边松弛 V-1 次,因为最短路最多经过 V-1 条边。

vector<int> bellmanFord(vector<vector<int>>& edges, int n, int start) {
vector<int> dist(n, INT_MAX);
dist[start] = 0;
for (int i = 0; i < n - 1; i++) {
for (auto& e : edges) {
int u = e[0], v = e[1], w = e[2];
if (dist[u] != INT_MAX && dist[u] + w < dist[v]) dist[v] = dist[u] + w;
}
}
// 第V次检测:如果还能松弛,说明存在负权环
return dist;
}
// 时间:O(VE)

Floyd-Warshall(多源最短路)#

状态: dp[k][i][j] = 只允许经过编号 ≤ k 的中间节点时,i 到 j 的最短路

转移:

dp[k][i][j]=min{dp[k1][i][j]ij 不经过 kdp[k1][i][k]+dp[k1][k][j]ij 经过 kdp[k][i][j] = \min\begin{cases} dp[k-1][i][j] & i \to j \text{ 不经过 } k \\ dp[k-1][i][k] + dp[k-1][k][j] & i \to j \text{ 经过 } k \end{cases}
void floydWarshall(vector<vector<int>>& dist, int n) {
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
}
// 时间:O(V^3),空间:O(V^2)

注意: k 必须是最外层循环——这是填表顺序的硬性要求。

算法适用条件时间复杂度
DAG 最短路无环图O(V+E)
Bellman-Ford允许负权边,无负权环O(VE)
Floyd-Warshall允许负权边,多源最短路O(V³)

8.7 单调栈/单调队列优化 DP#

核心思想: 有些 DP 的转移方程形如 dp[i]=minj<i(dp[j]+f(i,j))dp[i] = \min_{j<i}(dp[j] + f(i,j)),朴素实现需要枚举所有 jj,单次转移 O(n),总时间 O(n²)。如果能证明转移中”不再可能成为最优”的 jj 可以被永久排除(这正是第7章单调性优化技巧的核心论证方式),就可以用单调栈或单调队列把每次转移降到均摊 O(1),总时间降到 O(n)。

例:最大子数组和的单调队列优化(限定窗口大小)#

问题: 给定数组和窗口大小 k,求每个长度恰好为 k 的子数组中的最大和——可以转化成”前缀和数组上,每个窗口内找最小的前缀和,再与当前前缀和相减”。

转移方程:prefix 为前缀和数组,子数组 [i-k+1, i] 的和 = prefix[i] - prefix[i-k]。如果要求”长度不超过k的子数组的最大和”,相当于对每个右端点 i,要找 prefix[j](j 在窗口 [i-k, i-1] 内)的最小值,再用 prefix[i] - min(prefix[j]) 得到答案。

这正是第7.3节滑动窗口最大值(这里取最小值,思路完全对称)的直接应用:

int maxSubarraySumBounded(vector<int>& arr, int k) {
int n = arr.size();
vector<int> prefix(n + 1, 0);
for (int i = 0; i < n; i++) prefix[i+1] = prefix[i] + arr[i];
deque<int> dq; // 存下标,对应prefix值从队首到队尾递增(单调队列求最小值)
dq.push_back(0);
int ans = INT_MIN;
for (int i = 1; i <= n; i++) {
// 维护窗口:只保留[i-k, i-1]范围内的下标
while (!dq.empty() && dq.front() < i - k) dq.pop_front();
ans = max(ans, prefix[i] - prefix[dq.front()]); // 当前最大子数组和
// 维护队尾单调性(求最小值,所以弹出比当前大的)
while (!dq.empty() && prefix[dq.back()] >= prefix[i]) dq.pop_back();
dq.push_back(i);
}
return ans;
}
// 时间:O(n),对比朴素枚举所有(i,j)对的O(n^2)

为什么能用单调队列优化: 如果两个下标 j1<j2j_1 < j_2,且 prefix[j1]prefix[j2]prefix[j_1] \ge prefix[j_2],那么 j1j_1 永远不可能比 j2j_2 更优——因为 j2j_2 不仅前缀和更小(对求最大子数组和更有利),位置还更靠右(在未来的窗口里能保留更久)。这种”双重压制”关系正是单调队列能够安全丢弃 j1j_1 的依据,与第7.3节滑动窗口最大值的论证完全同构。

单调栈优化DP的识别标志#

特征说明
转移方程形如 dp[i]=min/maxj(dp[j]+f(i,j))dp[i]=\min/\max_j(dp[j]+f(i,j))朴素是双重循环 O(n²)
f(i,j)f(i,j)i,ji,j 可分离,或存在”双重压制”关系存在单调性,可用单调栈/队列优化
优化后复杂度通常降为 O(n)(均摊)或 O(n log n)

与第7章的关系: 本节展示的不是新的算法思想,而是把第7章已经建立的”利用单调性丢弃不可能更优的状态”这一论证方式,应用到动态规划的转移过程内部——DP提供了状态和转移方程的框架,单调性优化技巧负责把转移本身加速。


第9章 贪心法(Greedy Algorithm)#

核心思想:每一步都做当前看起来最好的选择,不回头、不枚举所有可能。

猜策略→证明局部最优⟹全局最优→不行就换策略

9.1 活动选择问题#

问题: n 个活动,每个有开始时间和结束时间,同一时间只能进行一个活动,求最多能安排多少个互不冲突的活动。

贪心策略:结束时间从早到晚排序,每次选择与已选活动不冲突、且结束时间最早的活动。

int activitySelection(vector<pair<int,int>>& activities) {
sort(activities.begin(), activities.end(),
[](auto& a, auto& b) { return a.second < b.second; });
int count = 1;
int lastEnd = activities[0].second;
for (int i = 1; i < activities.size(); i++) {
if (activities[i].first >= lastEnd) {
count++;
lastEnd = activities[i].second;
}
}
return count;
}
// 时间:O(n log n),瓶颈在排序

为什么按结束时间排序是对的: 结束最早的活动给后面留下的时间最多,换成它不会让答案变差。


9.2 拟阵与 Kruskal 算法(最小生成树)#

问题: 给定连通带权图,求一棵生成树,边权总和最小。

贪心策略: 把所有边按权值从小到大排序,依次尝试加入,只要不形成环就加入(用并查集判断)。

struct Edge { int u, v, w; };
int kruskal(int n, vector<Edge>& edges) {
sort(edges.begin(), edges.end(), [](Edge& a, Edge& b) { return a.w < b.w; });
UnionFind uf(n); // 见第16章
int totalWeight = 0, edgeCount = 0;
for (auto& e : edges) {
if (!uf.connected(e.u, e.v)) {
uf.unite(e.u, e.v);
totalWeight += e.w;
edgeCount++;
if (edgeCount == n - 1) break;
}
}
return totalWeight;
}
// 时间:O(E log E)

为什么贪心有效(割性质直觉): 对图的任意一个”割”,连接两部分的最小边一定在某个最小生成树中。

拟阵:贪心有效性的统一视角

许多贪心问题背后的共同结构称为拟阵(Matroid):集合系统 (S, I),I 是若干”独立”子集,满足:

  1. 遗传性:独立集的子集也是独立集
  2. 交换性:A、B 都独立且 |A|<|B|,则存在 B 中元素加入 A 后仍独立

图的”边独立”系统(独立集=不含环的边集)就是拟阵的实例,“每次贪心选权值最小且加入后仍独立的元素”在拟阵上总能得到最优解——这是 Kruskal、活动选择等贪心算法背后的共同原理。


9.3 Prim 算法(最小生成树,另一种贪心策略)#

思路: 从一个节点开始,每次贪心地把”连接当前生成树和外部节点、且权值最小”的边加入树中。

int prim(int n, vector<vector<pair<int,int>>>& adj) {
vector<bool> visited(n, false);
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
pq.push({0, 0});
int totalWeight = 0;
while (!pq.empty()) {
auto [w, u] = pq.top(); pq.pop();
if (visited[u]) continue;
visited[u] = true;
totalWeight += w;
for (auto& [v, weight] : adj[u]) {
if (!visited[v]) pq.push({weight, v});
}
}
return totalWeight;
}
// 时间:O(E log V)
算法适用场景时间复杂度
Kruskal稀疏图,边数少O(E log E)
Prim稠密图,边数多O(E log V)

9.4 Dijkstra 算法(单源最短路,非负权)#

贪心策略: 维护”已确定最短距离”集合,每次选未确定节点中距离最小的节点,纳入集合并松弛其邻居。

vector<int> dijkstra(vector<vector<pair<int,int>>>& adj, int n, int start) {
vector<int> dist(n, INT_MAX);
dist[start] = 0;
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
pq.push({0, start});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue;
for (auto& [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
return dist;
}

为什么不能处理负权边: 贪心的前提是”已确定的最短距离不会再变小”,负权边会打破这一假设。需要用 Bellman-Ford。

算法负权边时间复杂度
Dijkstra不支持O((V+E) log V)
Bellman-Ford支持O(VE)

9.5 Huffman 编码(回顾)#

Huffman 编码的贪心策略(每次合并频率最小的两个节点)已在第15章作为堆的应用详细介绍,此处不再重复。它和 Kruskal、活动选择一样属于”贪心选择+归纳”能验证有效性的问题,但不是拟阵结构。


第10章 增广/迭代改进法(Augmenting / Iterative Improvement)#

本章涉及图的流网络,图的基本概念与存储见第17章。本章关注的核心思想是:不分解子问题,靠不断改进可行解直至达到最优性。

核心思想:从一个可行解开始,不断寻找”改进路径”,沿着它调整解,直到找不到改进路径为止。

10.1 网络流的基本概念#

问题: 给定有向图,每条边有容量上限,求从源点 s 到汇点 t 能流过的最大流量。

流的合法性约束:

  1. 容量约束:每条边的流量 ≤ 容量
  2. 流量守恒:除 s、t 外,每个节点的入流 = 出流

10.2 Ford-Fulkerson 方法#

核心概念:增广路径 —— 从 s 到 t 的一条路径,路径上每条边都还有剩余容量(或者是代表”退流”的反向边)。

思路:

  1. 初始流量为 0
  2. 在残量网络中找一条 s 到 t 的路径
  3. 沿路径增加流量(增加量=路径上最小剩余容量)
  4. 更新残量网络(正向边减少容量,反向边增加容量)
  5. 重复直到找不到增广路径
bool bfsFindPath(vector<vector<int>>& capacity, int n, int s, int t, vector<int>& parent) {
fill(parent.begin(), parent.end(), -1);
parent[s] = s;
queue<int> q;
q.push(s);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v = 0; v < n; v++) {
if (parent[v] == -1 && capacity[u][v] > 0) {
parent[v] = u;
if (v == t) return true;
q.push(v);
}
}
}
return false;
}
int edmondsKarp(vector<vector<int>>& capacity, int n, int s, int t) {
int maxFlow = 0;
vector<int> parent(n);
while (bfsFindPath(capacity, n, s, t, parent)) {
int pathFlow = INT_MAX;
for (int v = t; v != s; v = parent[v]) {
pathFlow = min(pathFlow, capacity[parent[v]][v]);
}
for (int v = t; v != s; v = parent[v]) {
capacity[parent[v]][v] -= pathFlow;
capacity[v][parent[v]] += pathFlow;
}
maxFlow += pathFlow;
}
return maxFlow;
}

这就是 Edmonds-Karp 算法:用 BFS(而非任意方式)寻找增广路径的 Ford-Fulkerson 方法。

10.3 最大流最小割定理#

定理(仅陈述,不证明): 网络的最大流量等于最小割的容量。

意义: 如果残量网络中 s 已经无法到达 t,当前流就是最大流。

10.4 复杂度#

算法找增广路径方式时间复杂度
Ford-Fulkerson任意(如DFS)O(E·f),f为最大流值,可能很慢
Edmonds-KarpBFS(最短路)O(VE²)

10.5 二分图匹配#

转化为网络流: 加超级源点 s 连向左侧所有点(容量1),加超级汇点 t,右侧所有点连向 t(容量1),中间边容量设为1,求最大流即为最大匹配数。

问题转化方式复杂度(用Edmonds-Karp)
二分图匹配源点/汇点 + 单位容量边O(VE²),实践中匈牙利算法更快

第11章 非比较技术(Non-Comparison Sorting)#

核心思想:如果对元素的”值域”有额外信息,可以绕开”比较”这个基本操作,达到 O(n) 排序——这与比较排序的下界并不矛盾(见第5章下界证明技术)。

11.1 计数排序#

适用条件: 元素是整数,值域范围 [0, k] 不太大。

vector<int> countingSortStable(vector<int>& arr, int k) {
vector<int> count(k + 1, 0);
for (int x : arr) count[x]++;
for (int v = 1; v <= k; v++) count[v] += count[v-1];
vector<int> result(arr.size());
for (int i = arr.size() - 1; i >= 0; i--) {
result[--count[arr[i]]] = arr[i];
}
return result;
}
// 时间:O(n + k),空间:O(n + k)
操作时间复杂度空间复杂度
排序O(n + k)O(n + k)

11.2 基数排序#

适用条件: 元素可分解成多个”位”,对每一位分别用稳定排序处理,从最低位到最高位。

void radixSort(vector<int>& arr) {
int maxVal = *max_element(arr.begin(), arr.end());
for (int exp = 1; maxVal / exp > 0; exp *= 10) {
vector<int> count(10, 0);
vector<int> output(arr.size());
for (int x : arr) count[(x / exp) % 10]++;
for (int i = 1; i < 10; i++) count[i] += count[i-1];
for (int i = arr.size() - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[--count[digit]] = arr[i];
}
arr = output;
}
}
// 时间:O(d(n+k)),d是位数

为什么必须用稳定排序处理每一位: 不稳定会打乱已排好的低位顺序。

操作时间复杂度空间复杂度
排序O(d(n+k))O(n+k)

11.3 桶排序#

适用条件: 元素在某范围内近似均匀分布。

vector<float> bucketSort(vector<float>& arr) {
int n = arr.size();
vector<vector<float>> buckets(n);
for (float x : arr) buckets[(int)(n * x)].push_back(x);
for (auto& bucket : buckets) sort(bucket.begin(), bucket.end());
vector<float> result;
for (auto& bucket : buckets)
for (float x : bucket) result.push_back(x);
return result;
}
// 期望时间:O(n),最坏时间:O(n^2)
操作期望时间最坏时间
排序O(n)O(n²)

11.4 线性时间选择#

快速选择(期望线性)#

int quickSelect(vector<int>& arr, int l, int r, int k) {
if (l == r) return arr[l];
int p = randomizedPartition(arr, l, r);
int rank = p - l + 1;
if (rank == k) return arr[p];
else if (k < rank) return quickSelect(arr, l, p - 1, k);
else return quickSelect(arr, p + 1, r, k - rank);
}
// 期望时间:O(n),最坏时间:O(n^2)

中位数的中位数(最坏情况线性,仅作了解)#

每 5 个元素一组求中位数,再递归求这些中位数的中位数作为 pivot,可保证两侧都至少剩 30% 数据,最坏时间 O(n),但常数因子很大,实践中较少使用。

算法最坏时间期望时间实践表现
快速选择O(n²)O(n)常数小,常用
中位数的中位数O(n)O(n)常数大,少用

第12章 回溯与分支限界(Backtracking / Branch and Bound)#

本质是”带剪枝的暴力”,框架(搜索+撤销)通用,正确性继承自暴力穷举(只要剪枝规则是”可证明安全”的)。剪枝规则本身需要针对问题设计和证明,但因为兜底是暴力穷举,剪枝证明错了最多是效率受损,不会像贪心那样直接导致解错误。回溯关注可行性剪枝(这条路走不通,剪掉),分支限界关注最优性剪枝(这条路即使走得通也不可能比当前最优解更好,剪掉)。


12.1 回溯框架#

状态空间树#

把问题的所有可能解组织成一棵树:每一层代表做出一个决策,从根到叶子的一条路径代表一个完整的解。回溯就是对这棵树做 DFS,但在某个节点发现当前路径已经不可能得到合法解时,立即停止往下走,回退到上一层换一个分支

**例:**从 {1,2,3} 中选出和为偶数的子集(演示剪枝思想)

graph TD ROOT["[ ]"] ROOT --> A["[1]"] ROOT --> B["[2]"] ROOT --> C["[3]"] A --> A1["[1,2]"] A --> A2["[1,3]"] A1 --> A11["[1,2,3] 和=6 ✓"] B --> B1["[2,3]"] B1 --> B11["[2,3] 和=5 ✗"] C --> C1["[3] 和=3 ✗"] style A11 fill:#d4edda style B11 fill:#f8d7da style C1 fill:#f8d7da

走到 [1] 后,继续选 2 → [1,2],继续选 3 → [1,2,3](和6,偶数,记录) 撤销,回到[1,2],没有更多选择,撤销…

通用模板#

void backtrack(/* 当前状态 */, /* 已选路径 */) {
if (/* 满足终止条件 */) {
// 记录答案,或继续返回
return;
}
for (/* 遍历当前所有可能的选择 */) {
if (/* 这个选择不合法(剪枝条件) */) {
continue; // 跳过,不再往下走
}
// 做选择
path.push_back(choice);
backtrack(/* 更新后的状态 */);
// 撤销选择(回溯的关键一步)
path.pop_back();
}
}

与普通 DFS 的区别: 普通 DFS 通常用于遍历已经存在的图/树结构;回溯是在一棵虚拟生成的决策树上做 DFS,树本身随着搜索过程动态展开,且每次返回上一层时需要显式撤销对状态的修改。

例:N 皇后问题#

问题: 在 n×n 棋盘上放 n 个皇后,使任意两个皇后不在同一行、同一列、同一条斜线上。

4皇后的一个解:
. Q . .
. . . Q
Q . . .
. . Q .

思路: 按行从上到下放皇后,每一行尝试放在每一列,检查是否与之前放的皇后冲突(剪枝条件),不冲突就递归放下一行,放完 n 行就找到一个解。

bool isValid(vector<int>& cols, int row, int col) {
for (int r = 0; r < row; r++) {
int c = cols[r];
if (c == col) return false; // 同列
if (abs(c - col) == abs(r - row)) return false; // 同斜线
}
return true;
}
void solveNQueens(int n, int row, vector<int>& cols, int& count) {
if (row == n) {
count++; // 找到一个合法解
return;
}
for (int col = 0; col < n; col++) {
if (isValid(cols, row, col)) {
cols[row] = col; // 做选择
solveNQueens(n, row + 1, cols, count);
// cols[row] 在下一次循环会被覆盖,相当于隐式撤销
}
}
}
int totalNQueens(int n) {
vector<int> cols(n);
int count = 0;
solveNQueens(n, 0, cols, count);
return count;
}

剪枝的作用: 不剪枝的朴素穷举是 n^n(每行 n 种选择,n 行);加上”同列""同斜线”的剪枝后,大量不合法分支在很浅的层就被砍掉,实际运行的节点数远小于 n^n,但最坏情况仍是指数级,没有改变问题的复杂度类别。

例:子集生成 / 全排列#

子集生成: 每个元素有”选”或”不选”两种决策。

void subsets(vector<int>& nums, int idx, vector<int>& path, vector<vector<int>>& result) {
if (idx == nums.size()) {
result.push_back(path);
return;
}
// 不选 nums[idx]
subsets(nums, idx + 1, path, result);
// 选 nums[idx]
path.push_back(nums[idx]);
subsets(nums, idx + 1, path, result);
path.pop_back();
}
// 时间:O(2^n),因为本身就有 2^n 个子集,没有可剪枝的空间

全排列: 每一步从剩余元素中选一个放入当前位置。

void permute(vector<int>& nums, vector<bool>& used, vector<int>& path, vector<vector<int>>& result) {
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (used[i]) continue; // 剪枝:已经用过的元素不能再用
used[i] = true;
path.push_back(nums[i]);
permute(nums, used, path, result);
path.pop_back();
used[i] = false;
}
}
// 时间:O(n × n!),n! 个排列,每个排列构造代价O(n)

复杂度小结#

问题状态空间树规模(无剪枝)实际表现
N 皇后O(n^n)剪枝后大幅减少,仍指数级
子集生成O(2^n)无法剪枝(答案本身就是2^n个)
全排列O(n!)无法剪枝(答案本身就是n!个)

何时回溯有意义: 当答案数量远小于朴素状态空间(如 N 皇后的合法解远少于 n^n),剪枝能带来巨大的实际加速;当答案本身就等于状态空间大小(如子集、排列),回溯只是一种系统遍历的手段,不提供加速。


12.2 分支限界#

与回溯的区别#

回溯的剪枝条件是”这个分支已经不可能合法”(可行性剪枝);分支限界的剪枝条件是”这个分支即使合法,也不可能比当前已知的最优解更好”(最优性剪枝),因此分支限界只用于最优化问题(求最大值/最小值),不用于”找出所有解”或”判断是否存在解”这类问题。

分支限界需要一个界函数(bound function):对当前部分解,快速估计”如果把它扩展完整,最好能达到多好”,如果这个估计值还不如当前已知最优解,就可以剪掉,不必继续往下搜索。

以 3 件物品(容量 W=10)为例,说明界函数如何剪枝:

根节点(未选任何物品)
bound=20(贪心上界)
/ \
选物品1 不选物品1
profit=10,w=6 profit=0,w=0
bound=18 bound=15
/ \ / \
选物品2 不选物品2 选物品2 不选物品2
profit=16 profit=10 profit=6 profit=0
w=10 w=6 w=4 w=0
bound=16 bound=11 bound=13 bound=10
↑ ↑
继续展开 bound=10 < 当前最优16
✂ 剪枝

每个节点的 bound 是”当前利润 + 剩余容量的贪心上界”;一旦 bound ≤ 当前已知最优解,整棵子树都可以放弃。

例:0/1 背包的分支限界解法#

问题(与第7章相同): n 件物品,重量 w[i],价值 v[i],背包容量 W,每件最多选一次,求最大价值。

与 DP 解法的对比: 第7章的 DP 解法是 O(nW),依赖 W 不太大;如果 W 非常大(如 W 是一个很大的数,dp 表存不下),DP 不再适用,这时分支限界提供另一条路径——不依赖 W 的大小,而依赖”剪枝效果”。

界函数的构造: 把已选物品的价值,加上剩余容量下,按”单位重量价值”从高到低贪心装剩余物品所能达到的价值上界(允许物品被装”一部分”,这是一个比真实 0/1 约束更宽松的估计,所以一定是上界)。

struct Node {
int level; // 已经决策到第几件物品
int profit; // 当前已选物品的总价值
int weight; // 当前已选物品的总重量
double bound; // 该节点能达到的价值上界
};
double computeBound(Node u, int n, int W, vector<int>& w, vector<int>& v) {
if (u.weight >= W) return 0;
double bound = u.profit;
int totalWeight = u.weight;
int j = u.level + 1;
while (j < n && totalWeight + w[j] <= W) {
totalWeight += w[j];
bound += v[j];
j++;
}
if (j < n) {
bound += (W - totalWeight) * (double)v[j] / w[j]; // 装下剩余容量能装的"部分"
}
return bound;
}
int knapsackBranchBound(int n, int W, vector<int>& w, vector<int>& v) {
// 假设物品已按单位重量价值从高到低排序
queue<Node> q;
Node root = {-1, 0, 0, 0};
root.bound = computeBound(root, n, W, w, v);
q.push(root);
int maxProfit = 0;
while (!q.empty()) {
Node u = q.front(); q.pop();
if (u.bound <= maxProfit) continue; // 关键剪枝:上界不如当前最优,直接放弃
int level = u.level + 1;
if (level >= n) continue;
// 分支1:选第level件物品
Node withItem;
withItem.level = level;
withItem.weight = u.weight + w[level];
withItem.profit = u.profit + v[level];
if (withItem.weight <= W) {
maxProfit = max(maxProfit, withItem.profit);
withItem.bound = computeBound(withItem, n, W, w, v);
if (withItem.bound > maxProfit) q.push(withItem);
}
// 分支2:不选第level件物品
Node withoutItem;
withoutItem.level = level;
withoutItem.weight = u.weight;
withoutItem.profit = u.profit;
withoutItem.bound = computeBound(withoutItem, n, W, w, v);
if (withoutItem.bound > maxProfit) q.push(withoutItem);
}
return maxProfit;
}

剪枝效果的来源: 队列中只保留”上界还有希望超过当前最优解”的节点,大量分支在还没展开成完整解之前就被排除,不需要真正穷举 2^n 种选择方式。

搜索顺序的选择#

分支限界通常用优先队列(按界函数从大到小)代替普通队列/栈,使得”看起来最有希望”的节点先被展开,从而更快找到一个较好的解,让 maxProfit 尽早变大,使后续剪枝更有效:

// 把 queue<Node> 换成按 bound 排序的 priority_queue<Node>
// 即可把上面的"广度优先分支限界"改造成"最佳优先分支限界"
搜索方式数据结构特点
广度优先分支限界队列实现简单
最佳优先分支限界优先队列优先展开最有希望的节点,通常更快找到好解,从而剪枝更早生效

复杂度小结#

方法最坏时间复杂度依赖什么变小
DP(第7章)O(nW)容量 W 不太大
回溯(朴素穷举)O(2^n)
分支限界O(2^n)(最坏)实际表现依赖界函数的紧密程度,界越紧、剪枝越多

实用提示: 分支限界最坏情况和朴素回溯一样是指数级,它不改变问题的理论复杂度,只是在实践中通过剪枝大幅减少需要展开的节点数;界函数估计得越准(越接近真实可达到的最优值),剪枝效果越好。


Part C 数据结构#


第13章 线性结构(Linear Structures)#

数据元素之间是”一对一”的关系,就像排队。

13.1 顺序表/向量/一维张量/数组#

存储原理#

内存地址: 100 104 108 112 116
数组元素: [10] [20] [30] [40] [50]
下标: 0 1 2 3 4
  • 连续的内存空间
  • 通过下标直接计算地址:地址 = 起始地址 + 下标 × 元素大小

基本操作#

访问元素:

int x = arr[i]; // O(1)

插入元素:

// 在位置i插入元素x,需要把i及之后元素后移
for (int j = n-1; j >= i; j--) {
arr[j+1] = arr[j];
}
arr[i] = x;
n++;
// 时间:O(n)

删除元素:

// 删除位置i的元素,需要把i之后元素前移
for (int j = i; j < n-1; j++) {
arr[j] = arr[j+1];
}
n--;
// 时间:O(n)

时间复杂度#

操作时间复杂度
访问O(1)
查找O(n)
插入O(n)
删除O(n)

优缺点#

  • 支持随机访问
  • 内存连续,缓存友好
  • 大小固定
  • 插入删除效率低

13.2 动态数组#

核心思想#

当数组满了,创建一个更大的数组(通常 2 倍),把数据复制过去。

初始容量4:
[10][20][__][__] size=2, capacity=4
插入30, 40后满了:
[10][20][30][40] size=4, capacity=4
插入50,扩容:
[10][20][30][40][50][__][__][__] size=5, capacity=8

手动实现#

为了理解扩容原理,下面给出完整的类实现。实际使用时直接用 vector<int> v; 即可。

class DynamicArray {
private:
int* data;
int size;
int capacity;
void resize(int newCapacity) {
int* newData = new int[newCapacity];
for (int i = 0; i < size; i++) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCapacity;
}
public:
DynamicArray(int initialCapacity = 10) {
data = new int[initialCapacity];
size = 0;
capacity = initialCapacity;
}
~DynamicArray() {
delete[] data;
}
void push_back(int value) {
if (size == capacity) {
resize(capacity * 2); // 容量翻倍
}
data[size++] = value;
}
void pop_back() {
if (size > 0) size--;
}
int& operator[](int index) {
return data[index];
}
int getSize() const { return size; }
};

扩容策略分析#

为什么容量翻倍?

假设初始容量 1,插入 n 个元素:

  • 扩容序列:1 → 2 → 4 → 8 → … → n
  • 扩容次数:log₂(n) 次
  • 复制元素总数:1 + 2 + 4 + … + n/2 = n-1

均摊分析:

总代价 = n次普通插入 + (n-1)次复制 = 2n-1
平均每次插入代价 = (2n-1)/n ≈ 2 = O(1)

虽然单次扩容是 O(n),但均摊下来每次插入仍是 O(1)(严格证明见第4章摊还分析)。

缩容策略#

当使用率很低时缩容:

if (size > 0 && size == capacity / 4) {
resize(capacity / 2);
}

为什么是 1/4 而不是 1/2? 避免”抖动”:

容量4,size在2附近波动:
- 删到size=1,缩容到2
- 加到size=2,扩容到4
- 删到size=1,缩容到2 ← 反复扩缩!
用1/4阈值:
size在1~2之间变化时,容量保持4不变

STL: vector#

vector<int> v;
// 基本操作
v.push_back(10); // 尾部添加
v.pop_back(); // 尾部删除
v[0] = 5; // 访问
v.at(1); // 带边界检查的访问
// 大小相关
v.size(); // 元素个数
v.capacity(); // 容量
v.empty(); // 是否为空
v.clear(); // 清空
// 迭代器操作
v.insert(v.begin()+2, 20); // 在位置2插入
v.erase(v.begin()+1); // 删除位置1
v.front(); // 第一个元素
v.back(); // 最后一个元素
// 遍历
for (int i = 0; i < v.size(); i++) {
cout << v[i] << " ";
}
for (int x : v) {
cout << x << " ";
}

13.3 链表#

存储原理#

数据分散存储,通过指针连接。

class ListNode {
public:
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};

内存示意:

head → [10|next] → [20|next] → [30|nullptr]

节点在内存中是分散的,不连续。

基本操作#

遍历:

ListNode* p = head;
while (p != nullptr) {
cout << p->val << " ";
p = p->next;
}
// 时间:O(n)

插入(在 p 后):

ListNode* newNode = new ListNode(x);
newNode->next = p->next;
p->next = newNode;
// 时间:O(1),前提是已知位置p

示意:

插入前:[10] → [30] → [40]
↑ p
插入后:[10] → [20] → [30] → [40]
↑新节点

删除(删除 p 的下一个):

if (p->next != nullptr) {
ListNode* temp = p->next;
p->next = temp->next;
delete temp;
}
// 时间:O(1)

双向链表#

class ListNode {
public:
int val;
ListNode* prev;
ListNode* next;
ListNode(int x) : val(x), prev(nullptr), next(nullptr) {}
};

示意:

nullptr ← [10] ⇄ [20] ⇄ [30] → nullptr

优势: 可以双向遍历,删除节点不需要前驱。

循环链表#

尾节点指向头节点,形成环。

head → [10] → [20] → [30] → [40] ┐
↑ │
└──────────────────────────┘

用途:循环调度、约瑟夫问题

STL: list#

list<int> lst;
// 头尾操作
lst.push_front(10); // 头部插入
lst.push_back(20); // 尾部插入
lst.pop_front(); // 头部删除
lst.pop_back(); // 尾部删除
// 访问
lst.front(); // 第一个元素
lst.back(); // 最后一个元素
// 大小
lst.size();
lst.empty();
lst.clear();
// 迭代器操作
auto it = lst.begin();
advance(it, 2); // 移动迭代器到位置2
lst.insert(it, 15); // 在it位置插入
lst.erase(it); // 删除it位置
// 遍历
for (int x : lst) {
cout << x << " ";
}

时间复杂度对比#

操作数组链表
随机访问O(1)O(n)
头部插入O(n)O(1)
尾部插入O(1)均摊O(1)
中间插入O(n)O(1)*
空间连续分散+指针

*已知位置


13.4 栈(Stack)#

后进先出(LIFO:Last In First Out)

就像一摞盘子:

  • 只能从顶部放入(push)
  • 只能从顶部取出(pop)
← push/pop
┌────┐
│ 30 │ ← top(栈顶)
├────┤
│ 20 │
├────┤
│ 10 │
└────┘

合法出栈序列问题#

结论:n 个互不相同的元素,按固定顺序入栈, 所有合法的出栈序列共有:

Cn=1n+1C2nn=(2n)!(n+1)!n!C_n = \frac{1}{n+1} C_{2n}^{n} = \frac{(2n)!}{(n+1)!n!}

称为 第 n 个卡特兰数(Catalan Number)

递推公式

Cn=i=0n1Ci×Cni1C_n = \sum_{i=0}^{n-1} C_i \times C_{n-i-1}

手动实现#

class Stack {
private:
vector<int> data;
public:
void push(int x) {
data.push_back(x);
}
void pop() {
if (!data.empty()) {
data.pop_back();
}
}
int top() {
if (!data.empty()) {
return data.back();
}
throw runtime_error("Stack is empty");
}
bool isEmpty() {
return data.empty();
}
int size() {
return data.size();
}
};

时间复杂度#

操作时间复杂度
push(x)O(1)*
pop()O(1)
top()O(1)
isEmpty()O(1)
size()O(1)

说明:

  • push(x): 平均 O(1),最坏情况 O(n)。vector 的 push_back() 在容量足够时是 O(1),但当需要扩容时需要重新分配内存并复制所有元素,此时为 O(n)。不过由于扩容采用倍增策略,摊销时间复杂度为 O(1)。

  • pop(): O(1)。vector 的 pop_back() 只需移除末尾元素,不涉及元素移动。

  • top(): O(1)。直接访问 vector 末尾元素。

  • isEmpty(): O(1)。只需检查 vector 的 empty() 状态。

  • size(): O(1)。vector 内部维护了大小信息。

STL: stack#

stack<int> s;
s.push(10); // 入栈
s.pop(); // 出栈
s.top(); // 栈顶元素
s.empty(); // 是否为空
s.size(); // 大小

13.5 队列(Queue)#

先进先出(FIFO:First In First Out)

就像排队:

  • 从尾部进入(enqueue)
  • 从头部离开(dequeue)
dequeue ←─────────────← enqueue
┌───┬───┬───┐
│10 │20 │30 │
└───┴───┴───┘
front rear

循环队列(数组实现)#

为什么需要循环?

  • 相比普通队列,避免了”假溢出”问题
  • 所有操作都是真正的 O(1) 时间复杂度
  • 空间利用率高,固定使用 O(k) 空间
普通队列问题:
[_][_][20][30][40] ← rear
↑ front
前面空着但无法使用
循环队列:
rear ↓
[40][_][_][_][20]
↑ front
看成环形,rear绕回前面
class CircularQueue {
private:
vector<int> data;
int front, rear, size, capacity;
public:
CircularQueue(int k) : capacity(k), front(0), rear(0), size(0) {
data.resize(k);
}
bool enqueue(int value) {
if (size == capacity) return false;
data[rear] = value;
rear = (rear + 1) % capacity;
size++;
return true;
}
bool dequeue() {
if (size == 0) return false;
front = (front + 1) % capacity;
size--;
return true;
}
int getFront() {
return size == 0 ? -1 : data[front];
}
bool isEmpty() {
return size == 0;
}
bool isFull() {
return size == capacity;
}
};

时间复杂度#

操作时间复杂度
CircularQueue(k)O(k)
enqueue(value)O(1)
dequeue()O(1)
getFront()O(1)
isEmpty()O(1)
isFull()O(1)

说明:

  • CircularQueue(k): O(k)。构造函数中 data.resize(k) 需要分配 k 个元素的空间。
  • enqueue(value): O(1)。直接通过索引访问并赋值,取模运算也是常数时间。
  • dequeue(): O(1)。只需移动 front 指针,不涉及元素移动。
  • getFront(): O(1)。直接通过索引访问队首元素。
  • isEmpty(): O(1)。只需检查 size 是否为 0。
  • isFull(): O(1)。只需比较 size 和 capacity。

STL: queue#

queue<int> q;
q.push(10); // 入队
q.pop(); // 出队
q.front(); // 队首
q.back(); // 队尾
q.empty(); // 是否为空
q.size(); // 大小

STL: deque(双端队列)#

← push/pop push/pop →
┌───┬───┬───┬───┐
│10 │20 │30 │40 │
└───┴───┴───┴───┘
front rear
deque<int> dq;
dq.push_front(10); // 头部插入
dq.push_back(20); // 尾部插入
dq.pop_front(); // 头部删除
dq.pop_back(); // 尾部删除
dq[i]; // 随机访问!
dq.front();
dq.back();

13.6 STL: bitset#

核心思想: bitset 是固定大小的位数组,每个元素只占1位(而不是 vector<bool> 实际上也按位压缩但接口更通用、性能不如专用的 bitset)。位运算(与、或、异或、取反、移位)作用在整个 bitset 上时,底层按机器字长(通常64位)批量处理,使得很多”对每个元素做某种判断”的操作能从 O(n) 降到 O(n/64)。

bitset<8> b1("11010010");
bitset<8> b2("10110001");
b1 & b2 = "10010000" 按位与
b1 | b2 = "11110011" 按位或
b1 ^ b2 = "01100011" 按位异或
~b1 = "00101101" 取反
b1 << 2 = "01001000" 左移2位(高位丢弃,低位补0)
bitset<32> b; // 32位,初始全0
b[3] = 1; // 设置第3位
b.set(5); // 设置第5位为1(等价于b[5]=1)
b.reset(3); // 将第3位置0
b.flip(5); // 翻转第5位
b.count(); // 统计1的个数
b.any(); // 是否存在至少一个1
b.none(); // 是否全为0
b.all(); // 是否全为1
b.test(5); // 检查第5位是否为1(带边界检查)
string s = b.to_string(); // 转字符串
unsigned long x = b.to_ulong(); // 转无符号长整型(位数不能超过其容量)

典型应用:状压DP中的集合表示#

第8章状压DP一节用 int 的二进制位表示子集(如 mask),本质上就是手写的小型 bitset;当状态数量很大(超过 int/long long 能表示的64位)时,可以直接用标准库的 bitset<N> 替代手写位运算,接口更清晰。

典型应用:埃拉托斯特尼筛法的空间优化#

筛素数时用 bitset 代替 vector<bool> 标记”是否被筛掉”,可以把内存占用降到原来的 1/8(每个标记只占1位而非1字节),在数据范围很大(如筛 10810^8 以内的素数)时是常见的空间优化手段。

操作时间复杂度
单点访问/修改O(1)
整体按位运算(与/或/异或)O(n/64)(n为bitset大小,常数除以机器字长)
count(统计1的个数)O(n/64)

vector<bool> 的区别: vector<bool> 也对每个元素做了位压缩,但其接口设计是为了模拟”动态数组”,不直接暴露整体的位运算;bitset 大小固定(编译期确定),但提供了完整的位运算接口,更适合需要频繁做位运算的场景。


第14章 字典结构(Dictionary Structures)#

维护 key(或 key-value)集合,支持查找、插入、删除。

14.1 树的基础概念(前置知识)#

graph TD A["A(根节点)"] A --> B A --> C A --> D["D(A的子节点)"] B --> E["E(叶子节点)"] B --> F["F(叶子节点)"] D --> G["G(叶子节点)"] D --> H["H(叶子节点)"]

术语#

  • 节点(Node): 树中的基本单元,包含数据和指向子节点的指针
  • 根节点(Root): 树的顶端节点,没有父节点(如 A)
  • 父节点(Parent): 有子节点的节点(如 A 是 B、C、D 的父节点)
  • 子节点(Child): 节点的直接后代(如 B、C、D 是 A 的子节点)
  • 叶子节点(Leaf): 没有子节点的节点(如 E、F、G、H)
  • 兄弟节点(Sibling): 拥有相同父节点的节点(如 B、C、D 互为兄弟)
  • 祖先节点(Ancestor): 从根到该节点路径上的所有节点(如 E 的祖先: A、B)
  • 后代节点(Descendant): 节点的子树中的所有节点(如 A 的后代: B、C、D、E、F、G、H)

度量指标#

  • 节点的度(Degree): 节点的子节点个数(如 A 的度为 3,B 的度为 2)
  • 树的度: 树中所有节点度的最大值(该树的度为 3)
  • 深度(Depth): 从根节点到该节点的边数(如 E 的深度为 2)
  • 高度(Height): 从该节点到叶子节点的最长路径的边数(如 A 的高度为 2)
  • 层(Level): 节点的深度 + 1(根节点为第 1 层)

树的性质#

  • 有 n 个节点的树有 n-1 条边
  • 任意两个节点之间有且仅有一条路径
  • 树是无环连通图

树的存储方式#

邻接表#
vector<vector<int>> a(n); // a[i] 存储节点 i 的所有子节点
// 示例:
// 0
// /|\
// 1 2 3
// /|
// 4 5
a[0] = {1, 2, 3};
a[1] = {4, 5};
a[2] = {};
a[3] = {};
a[4] = {};
a[5] = {};

特点:

  • 空间复杂度: O(n)
  • 灵活,适合任意结构的树
  • 适合图转树、多叉树
链式存储#

二叉树节点定义:

class TreeNode {
public:
int val;
TreeNode* left; // 左子节点
TreeNode* right; // 右子节点
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

多叉树节点定义:

class TreeNode {
public:
int val;
vector<TreeNode*> children; // 子节点列表
TreeNode(int x) : val(x) {}
};
// 或者使用"左孩子右兄弟"表示法
class TreeNode {
public:
int val;
TreeNode* firstChild; // 第一个子节点
TreeNode* nextSibling; // 下一个兄弟节点
TreeNode(int x) : val(x), firstChild(nullptr), nextSibling(nullptr) {}
};

特点:

  • 空间复杂度: O(n)
  • 插入/删除灵活
  • 适合动态变化的树
数组存储(适合完全二叉树)#
[1]idx=1
/ \
[2]idx=2 [3]idx=3
/ \ \
[4]idx=4 [5]idx=5 [6]idx=6
数组: [0, 1, 2, 3, 4, 5, 6]
索引: 0 1 2 3 4 5 6

节点 i 的左子 = 2i,右子 = 2i+1,父节点 = i/2(下标从1开始)。

特点:

  • 空间复杂度:
    • 完全二叉树: O(n)
    • 稀疏树: O(2^h),浪费空间
  • 查找父子节点: O(1)
  • 适合堆、完全二叉树
父节点数组#
vector<int> parent(n); // parent[i] 存储节点 i 的父节点
// 示例:
// 0
// /|\
// 1 2 3
// /|
// 4 5
parent = [-1, 0, 0, 0, 1, 1];
// 0 1 2 3 4 5

特点:

  • 空间复杂度: O(n)
  • 向上查找快,向下查找慢
  • 适合并查集、需要频繁查找祖先的场景
三元组表示(较少用)#
class Edge {
public:
int parent;
int child;
int weight; // 可选
};
vector<Edge> edges;
存储方式对比#
存储方式空间复杂度适用场景优点缺点
邻接表O(n)多叉树、图转树灵活、省空间需要额外结构
链式存储O(n)通用灵活、直观指针开销
数组存储O(n)~O(2^h)完全二叉树、堆访问快、无指针稀疏树浪费空间
父节点数组O(n)并查集、祖先查询简单、省空间查找子节点慢

选择建议:

  • 二叉树: 优先链式存储
  • 堆/完全二叉树: 数组存储
  • 多叉树: 链式存储(children 数组)或邻接表
  • 需要快速查找祖先: 父节点数组
  • 树的动态变化: 链式存储

二叉树遍历#

A
/ \
B C
/ \ \
D E F
遍历方式访问顺序示例结果方式
前序(Pre-order)根 → 左 → 右A B D E C FDFS
中序(In-order)左 → 根 → 右D B E A C FDFS
后序(Post-order)左 → 右 → 根D E B F C ADFS
层序(Level-order)逐层从左到右A B C D E FBFS
void preOrder(TreeNode* root) {
if (root == nullptr) return;
cout << root->val << " ";
preOrder(root->left);
preOrder(root->right);
}
void inOrder(TreeNode* root) {
if (root == nullptr) return;
inOrder(root->left);
cout << root->val << " ";
inOrder(root->right);
}
void postOrder(TreeNode* root) {
if (root == nullptr) return;
postOrder(root->left);
postOrder(root->right);
cout << root->val << " ";
}
void levelOrder(TreeNode* root) {
if (root == nullptr) return;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* node = q.front();
q.pop();
cout << node->val << " ";
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}

14.2 二叉搜索树(Binary Search Tree, BST)#

二叉搜索树满足:

  1. 左子树的所有节点值 < 根节点值
  2. 右子树的所有节点值 > 根节点值
  3. 左右子树也都是二叉搜索树
graph TD 8 --> 3 8 --> 10 3 --> 1 3 --> 6 10 --> 14 6 --> 4 6 --> 7 14 --> 13

关键性质: BST 的中序遍历结果是有序递增序列

查找#

思路: 利用 BST 性质,比较目标值与当前节点

  • 目标值 < 当前值 → 去左子树
  • 目标值 > 当前值 → 去右子树
  • 相等 → 找到
TreeNode* search(TreeNode* root, int target) {
if (root == nullptr || root->val == target) {
return root;
}
if (target < root->val) {
return search(root->left, target);
} else {
return search(root->right, target);
}
}

插入#

TreeNode* insert(TreeNode* root, int val) {
if (root == nullptr) {
return new TreeNode(val);
}
if (val < root->val) {
root->left = insert(root->left, val);
} else if (val > root->val) {
root->right = insert(root->right, val);
}
return root;
}

示例:

插入 5:
8 8
/ \ / \
3 10 → 3 10
/ \ / \
1 6 1 6
/
5

删除#

最复杂的操作,需要考虑三种情况:

情况 1: 删除叶子节点 → 直接删除

删除 1:
8 8
/ \ / \
3 10 → 3 10
/ \ \
1 6 6

情况 2: 删除只有一个子节点的节点 → 用子节点替代

删除 10:
8 8
/ \ / \
3 10 → 3 14
\
14

情况 3: 删除有两个子节点的节点 → 找后继节点(右子树最小值)或前驱节点(左子树最大值)替代

删除 3:
8 8
/ \ / \
3 10 → 4 10 (用后继节点4替代3)
/ \ / \
1 6 1 6
/ \ \
4 7 7
TreeNode* deleteNode(TreeNode* root, int key) {
if (root == nullptr) return nullptr;
if (key < root->val) {
root->left = deleteNode(root->left, key);
} else if (key > root->val) {
root->right = deleteNode(root->right, key);
} else {
if (root->left == nullptr) {
TreeNode* temp = root->right;
delete root;
return temp;
} else if (root->right == nullptr) {
TreeNode* temp = root->left;
delete root;
return temp;
}
TreeNode* successor = findMin(root->right);
root->val = successor->val;
root->right = deleteNode(root->right, successor->val);
}
return root;
}
TreeNode* findMin(TreeNode* node) {
while (node->left != nullptr) {
node = node->left;
}
return node;
}

复杂度#

操作平均最坏
查找O(log n)O(n)
插入O(log n)O(n)
删除O(log n)O(n)

14.3 平衡二叉树(Balanced Binary Tree)#

问题引入#

BST 最坏情况会退化成链表:

插入1,2,3,4,5:
1
\
2
\
3
\
4
\
5 ← 退化成链表!查找O(n)

平衡因子 = 左子树高度 - 右子树高度,平衡二叉树要求平衡因子 ∈ {-1, 0, 1}

进化路径:

graph TD BST["BST(无平衡保证)"] AVL["AVL 树(任意节点高度差≤1,查找最快)"] RB["红黑树(弱平衡,插入删除更快,C++ STL采用)"] BT["B 树 / B+ 树(多路平衡树,数据库索引)"] BST -->|严格平衡| AVL AVL -->|放宽限制| RB RB -->|适应磁盘IO| BT

AVL 树#

特点:任意节点平衡因子 ∈ {-1, 0, 1},插入/删除后通过旋转恢复平衡。

4 种失衡情况:

  1. LL:在左子树的左边插入 → 右旋
  2. RR:在右子树的右边插入 → 左旋
  3. LR:在左子树的右边插入 → 先左旋后右旋
  4. RL:在右子树的左边插入 → 先右旋后左旋
右旋 (LL情况):
y x
/ \ 右旋 / \
x C ---> A y
/ \ / \
A B B C
TreeNode* rightRotate(TreeNode* y) {
TreeNode* x = y->left;
TreeNode* B = x->right;
x->right = y;
y->left = B;
y->height = max(height(y->left), height(y->right)) + 1;
x->height = max(height(x->left), height(x->right)) + 1;
return x;
}
TreeNode* leftRotate(TreeNode* x) {
TreeNode* y = x->right;
TreeNode* B = y->left;
y->left = x;
x->right = B;
x->height = max(height(x->left), height(x->right)) + 1;
y->height = max(height(y->left), height(y->right)) + 1;
return y;
}

插入:

AVLNode* insert(AVLNode* node, int key) {
if (node == nullptr)
return new AVLNode(key);
if (key < node->val)
node->left = insert(node->left, key);
else if (key > node->val)
node->right = insert(node->right, key);
else
return node;
node->height = 1 + max(height(node->left), height(node->right));
int balance = height(node->left) - height(node->right);
if (balance > 1 && key < node->left->val)
return rightRotate(node);
if (balance < -1 && key > node->right->val)
return leftRotate(node);
if (balance > 1 && key > node->left->val) {
node->left = leftRotate(node->left);
return rightRotate(node);
}
if (balance < -1 && key < node->right->val) {
node->right = rightRotate(node->right);
return leftRotate(node);
}
return node;
}

复杂度: 查找/插入/删除均 O(log n),严格保证。

红黑树#

应用最广的平衡树(C++ STL map/set, Java TreeMap)

性质

红黑树中”叶子节点”特指 NIL 哨兵节点(空节点),不是通常意义上的最底层数据节点。每个实际节点的空子指针都指向同一个 NIL 哨兵,NIL 节点固定为黑色。

  1. 每个节点(含 NIL)是红色或黑色
  2. 根节点是黑色
  3. NIL 哨兵节点(叶子)是黑色
  4. 红色节点的子节点必须是黑色(不能有连续红节点)
  5. 从任一节点到其后代 NIL 节点的所有路径包含相同数目的黑色节点
graph TD 20B["20(黑)"] 10R["10(红)"] 30R["30(红)"] 5B["5(黑)"] 15B["15(黑)"] 25B["25(黑)"] 35B["35(黑)"] 20B --> 10R 20B --> 30R 10R --> 5B 10R --> 15B 30R --> 25B 30R --> 35B

黑高验证(性质5): 根 20 到任意叶子路径上的黑节点数均为 2(红色节点 10、30 不计入黑高):20→10→5、20→10→15、20→30→25、20→30→35,黑高全部为 2,满足性质5。

特点:近似平衡(最长路径 ≤ 2× 最短路径),插入/删除最多 3 次旋转。

插入修复(3 种情况,N 为新插入节点,P 为父,U 为叔叔,G 为祖父):

  • 叔叔是红色 → 变色(P、U变黑,G变红,问题上移到 G)
  • 叔叔是黑色,N 在 P 右侧 → 左旋转化为下一种情况
  • 叔叔是黑色,N 在 P 左侧 → 右旋 + 变色

具体实现较复杂,实际工程中直接用 STL。

复杂度: 查找/插入/删除均 O(log n)。

B 树 / B+ 树#

多路平衡查找树,用于数据库和文件系统,目标是减少磁盘 IO 次数。

m 阶 B 树性质:

  1. 每个节点最多 m 个子节点
  2. 除根外,每个节点至少⌈m/2⌉个子节点
  3. 所有叶子在同一层
  4. 节点有 k 个 key,就有 k+1 个子节点

3阶B树(每个节点最多2个key,3个子节点):

graph TD R["[10, 20]"] R --> L["[5]"] R --> M["[15]"] R --> RR["[25, 30]"]

插入: 节点未满直接插入;节点已满则分裂,中间 key 提升到父节点。

插入25到已满节点[10, 20, 30]:

  1. 临时插入 [10, 20, 25, 30]
  2. 取中间key(20)分裂
graph TD R["[20](提升到父节点)"] R --> L["[10]"] R --> RR["[25, 30]"]

B+树:所有数据存在叶子节点,内部节点只存索引;叶子节点形成链表,范围查询快。MySQL 等数据库索引采用 B+树。

复杂度: m=1000 的 B 树存 1 亿条记录,树高仅 3-4 层,查找/插入/删除均 O(log_m n)。

平衡树对比#

特性AVL 树红黑树B 树/B+树
平衡程度严格(高度差 ≤1)近似(最长路径 ≤2× 最短路径)完全平衡
查找速度最快较快快(磁盘)
插入/删除较慢(多次旋转)快(最多 3 次旋转)适中
实际应用Windows 内核C++ STL, Linux 内核MySQL, 文件系统

STL: set / map#

set<int> s;
s.insert(30); s.insert(10); s.insert(20);
s.find(20); s.count(10); s.erase(10);
for (int x : s) cout << x << " "; // 自动排序输出
s.begin(); // 最小元素
s.rbegin(); // 最大元素
map<string, int> m;
m["Alice"] = 85;
m.insert({"Charlie", 78});
m.find("Bob"); m.count("Alice"); m.erase("Charlie");
for (auto& p : m) cout << p.first << ": " << p.second << endl;

14.4 哈希表#

通过哈希函数将 key 映射到数组下标,实现 O(1)查找。

想存储学号 → 成绩:
hash(20210001) = 1 → 存在arr[1]
hash(20210002) = 2 → 存在arr[2]

哈希函数#

除留余数法:

int hash(int key, int size) {
return key % size;
}

字符串哈希:

int hash(string str, int size) {
unsigned long hash = 0;
for (char c : str) {
hash = hash * 31 + c;
}
return hash % size;
}

好的哈希函数:计算快、分布均匀、确定性。

哈希冲突#

不同 key 可能映射到同一位置,需要解决方法。

链地址法(Chaining)#

数组每个位置存一个链表。

[0] → nullptr
[1] → [20210001, 85] → [20210011, 92] → nullptr
[2] → [20210002, 90] → nullptr
class HashTable {
private:
class Node {
public:
int key, value;
Node* next;
Node(int k, int v) : key(k), value(v), next(nullptr) {}
};
vector<Node*> table;
int size;
public:
HashTable(int s) : size(s) {
table.resize(s, nullptr);
}
void insert(int key, int value) {
int index = key % size;
Node* curr = table[index];
while (curr) {
if (curr->key == key) {
curr->value = value;
return;
}
curr = curr->next;
}
Node* newNode = new Node(key, value);
newNode->next = table[index];
table[index] = newNode;
}
int search(int key) {
int index = key % size;
Node* curr = table[index];
while (curr) {
if (curr->key == key) return curr->value;
curr = curr->next;
}
return -1;
}
void remove(int key) {
int index = key % size;
Node* curr = table[index];
Node* prev = nullptr;
while (curr) {
if (curr->key == key) {
if (prev == nullptr) table[index] = curr->next;
else prev->next = curr->next;
delete curr;
return;
}
prev = curr;
curr = curr->next;
}
}
};

性能: 平均 O(1 + α),α 为装填因子;最坏 O(n)(全部冲突)。

开放地址法(Open Addressing)#

所有元素都存在数组里,冲突时找下一个空位。

线性探测:

class HashTableOpen {
private:
vector<int> keys, values;
vector<bool> occupied;
int size;
public:
HashTableOpen(int s) : size(s) {
keys.resize(s); values.resize(s); occupied.resize(s, false);
}
void insert(int key, int value) {
int index = key % size, i = 0;
while (occupied[index]) {
if (keys[index] == key) { values[index] = value; return; }
index = (index + 1) % size;
if (++i == size) throw runtime_error("哈希表已满");
}
keys[index] = key; values[index] = value; occupied[index] = true;
}
int search(int key) {
int index = key % size, i = 0;
while (occupied[index]) {
if (keys[index] == key) return values[index];
index = (index + 1) % size;
if (++i == size) break;
}
return -1;
}
};

删除的问题: 不能直接删除,要用”墓碑”标记(Tombstone),否则会截断后续查找路径。

其他探测方法:

  • 二次探测: hash(key), hash(key)+1², hash(key)+2², ...
  • 双重哈希: hash(key), hash(key)+step, hash(key)+2*step, ...

装填因子(Load Factor)#

α = n / size
  • α 越大,冲突越多
  • 链地址法:α 可以 > 1
  • 开放地址法:α 必须 < 1(通常 < 0.7)

扩容: 通常当 α > 0.75 时扩容,重新哈希所有元素。

STL: unordered_set / unordered_map#

unordered_set<int> s;
s.insert(30); s.find(20); s.count(10); s.erase(10);
unordered_map<string, int> m;
m["Alice"] = 85;
m.find("Bob"); m.count("Alice"); m.erase("Charlie");

set/map vs unordered_set/map#

特性set/mapunordered_set/map
底层红黑树哈希表
有序性有序无序
查找O(log n)O(1)平均
最坏O(log n)O(n)

选择原则: 需要有序 → set/map;只需要快速查找 → unordered_set/map(大多数情况更快)。


第15章 优先级结构(Priority Structures)#

维护集合,支持快速取出最值。

15.1 堆(Heap)#

是一种特殊的完全二叉树,满足堆性质:

  • 最大堆:父节点 ≥ 子节点 (如下图所示)
  • 最小堆:父节点 ≤ 子节点
graph TD 100 --> 19 100 --> 36 19 --> 17 19 --> 3 36 --> 25 36 --> 1 17 --> 2 17 --> 7

注意: 堆不是 BST,兄弟节点间无大小关系。

数组存储#

完全二叉树用数组存储很高效:

数组:[90, 80, 70, 50, 40, 30]
下标: 0 1 2 3 4 5

对于索引 i 的节点:左子 2i+1,右子 2i+2,父 (i-1)/2

上浮(插入)#

void up(vector<int>& heap, int index) {
while (index > 0) {
int parent = (index - 1) / 2;
if (heap[index] > heap[parent]) {
swap(heap[index], heap[parent]);
index = parent;
} else {
break;
}
}
}
void push(vector<int>& heap, int val) {
heap.push_back(val);
up(heap, heap.size() - 1);
}
// 时间:O(log n)

下沉(删除堆顶)#

void down(vector<int>& heap, int i) {
int n = heap.size();
while (2 * i + 1 < n) {
int left = 2 * i + 1, right = 2 * i + 2, largest = i;
if (heap[left] > heap[largest]) largest = left;
if (right < n && heap[right] > heap[largest]) largest = right;
if (largest != i) {
swap(heap[i], heap[largest]);
i = largest;
} else {
break;
}
}
}
int pop(vector<int>& heap) {
int maxVal = heap[0];
heap[0] = heap.back();
heap.pop_back();
if (!heap.empty()) down(heap, 0);
return maxVal;
}
// 时间:O(log n)

建堆(Heapify)#

逐个插入: O(n log n)

自底向上(推荐): O(n)

void heapify(vector<int>& arr) {
for (int i = arr.size() / 2 - 1; i >= 0; i--) {
down(arr, i);
}
}

为什么是 O(n)? 叶子节点(约 n/2 个)不需要调整;倒数第二层(约 n/4 个)最多下沉 1 次;倒数第三层(约 n/8 个)最多下沉 2 次;总操作数 n/4×1 + n/8×2 + … = O(n)。

时间复杂度#

操作时间复杂度
插入 (push)O(log n)
删除堆顶 (pop)O(log n)
查看堆顶 (top)O(1)
建堆 (heapify)O(n)

STL: priority_queue#

// 最大堆(默认)
priority_queue<int> maxHeap;
maxHeap.push(30); maxHeap.push(50);
maxHeap.top(); // 50
maxHeap.pop();
// 最小堆
priority_queue<int, vector<int>, greater<int>> minHeap;
// 自定义比较
class Compare {
public:
bool operator()(int a, int b) { return a > b; } // 最小堆
};
priority_queue<int, vector<int>, Compare> customHeap;

15.2 哈夫曼树(Huffman Tree)#

一次性编码结构构造,不支持动态插入与删除。利用堆完成构造,是堆的典型应用。

核心思想#

为了解码无歧义,要求任何字符的编码都不是另一个字符编码的前缀(前缀编码,Prefix-free Code),可用二叉树表示:左分支 0,右分支 1,叶子节点为字符。

带权路径长度(Weighted Path Length, WPL)#

WPL=i=1nwiliWPL = \sum_{i=1}^{n} w_i l_i

其中:

  • wiw_i:第 ii 个叶子结点的权值
  • lil_i:第 ii 个叶子结点的路径长度

目标:构造 WPL 最小的二叉树,使编码总长度最短。

构造算法#

每次选择频率最小的两个节点合并:

  1. 将 n 个字符作为 n 棵单节点树,放入最小堆
  2. 重复:取出权值最小的两棵树,合并为新树(权值 = 两者之和),放回堆
  3. 直到只剩一棵树

示例:构造 ABRACADABRA 的哈夫曼树

初始频率:A:5 B:2 R:2 C:1 D:1
步骤1:合并C(1)和D(1) → 2
步骤2:合并B(2)和[C+D](2) → 4
步骤3:合并R(2)和[B+C+D](4) → 6
步骤4:合并A(5)和[R+B+C+D](6) → 11
最终编码:
A: 0
R: 10
B: 110
C: 1110
D: 1111

时间复杂度#

操作时间
构造哈夫曼树O(n log n)
生成编码表O(n)
编码O(m),m 是文本长度
解码O(k),k 是编码长度

n 个字符需要合并 n-1 次,每次堆操作 O(log n),总时间 O(n log n)。


第16章 并查集(Union-Find)#

维护元素的集合归属关系

操作:

  • find(x):x 属于哪个集合?
  • union(x, y):合并 x 和 y 所在集合

应用: 判断图的连通性、检测环、朋友圈问题。

用树表示集合,根节点代表集合 ID。

三个集合:{1,2,3}, {4,5}, {6}
树表示:
1 4 6
/ \ |
2 3 5
find(2) → 1(2的根是1)
find(5) → 4

问题场景#

朴素方法: 用图存储,DFS/BFS 判断连通性 → O(V+E);n 次查询总复杂度 O(n(V+E))。

并查集优化: 每次查询接近 O(1)。

基础实现#

class UnionFind {
private:
vector<int> parent;
public:
UnionFind(int n) {
parent.resize(n);
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] == x) return x;
return find(parent[x]);
}
void unite(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX != rootY) parent[rootX] = rootY;
}
bool connected(int x, int y) {
return find(x) == find(y);
}
};

问题: 树可能变得很高(最坏情况退化成链),find 变慢到 O(n)。

路径压缩#

在 find 时,把路径上所有节点直接连到根。

int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}

按秩合并(Union by Rank)#

合并时,把矮的树接到高的树下。

class UnionFind {
private:
vector<int> parent, rank;
public:
UnionFind(int n) {
parent.resize(n); rank.resize(n, 0);
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]);
return parent[x];
}
void unite(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX == rootY) return;
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
}
bool connected(int x, int y) {
return find(x) == find(y);
}
};

复杂度#

两个优化一起用:

操作时间
findO(α(n))
unionO(α(n))

α(n) 是反阿克曼函数(Inverse Ackermann function),增长极慢(α(10^80) ≈ 4),实际中可认为是 O(1)。严格的均摊证明依赖势能法(见4.3)。


第17章 图结构(Graph Structures)#

数据元素之间是”多对多”的关系。本章只讨论存储与遍历机制本身,不讨论基于遍历的具体应用算法(最短路径、连通性判定等具体算法见 Part B)。

17.1 图的基本概念#

图 G = (V, E),V 为顶点集合,E 为边集合。

无向图: 有向图: 带权图:
A --- B A → B A --5-- B
| | ↓ ↓ | |
C --- D C → D 3 2
| |
C --1-- D

术语:

  • 顶点/节点(Vertex/Node): 图中的基本单元(如 A、B、C、D)
  • 边(Edge): 连接两个顶点的线   - 无向边: 双向连接,如 A---B   - 有向边: 单向连接,如 A→B
  • 邻接(Adjacent): 两个顶点之间有边直接相连
  • 邻居(Neighbor): 所有与该节点直接由一条边相连的节点
  • 度(Degree):   - 无向图: 与该顶点相连的边数   - 有向图:     - 入度(In-degree): 指向该顶点的边数     - 出度(Out-degree): 从该顶点发出的边数
  • 权重(Weight): 边上的数值,表示代价、距离等
  • 路径(Path): 顶点序列,相邻顶点间有边连接
  • 简单路径: 路径中顶点不重复
  • 环/回路(Cycle): 起点和终点相同的路径

分类:

  • 无向图(Undirected Graph): 边没有方向
  • 有向图(Directed Graph/Digraph): 边有方向
  • 加权图(Weighted Graph): 边带有权重
  • 完全图: 任意两个顶点之间都有边   - n 个顶点的无向完全图有 n(n-1)/2 条边   - n 个顶点的有向完全图有 n(n-1) 条边
  • 连通图: 任意两个顶点之间都有路径可达
  • 稀疏图: 边数远小于完全图(|E| << |V|²)
  • 稠密图: 边数接近完全图

17.2 图的存储#

邻接矩阵#

用 n×n 矩阵表示顶点间的连接关系。

graph LR A --- B A --- C B --- C B --- D C --- D
A B C D
A[0 1 1 0]
B[1 0 1 1]
C[1 1 0 1]
D[0 1 1 0]

无向图矩阵恒为对称矩阵;带权图用权值 wijw_{ij} 代替 1,用 ∞ 表示无边。

操作时间空间
判断有无边O(1)O(V²)
获取邻居O(V)

适用: 稠密图。

邻接表#

每个顶点维护一个表,存储相邻顶点。

邻接表:
A(0) → [B, C]
B(1) → [A, D]
C(2) → [A, D]
D(3) → [B, C]
操作时间空间
判断有无边O(degree)O(V+E)
获取邻居O(degree)

适用: 稀疏图(大多数实际图都是稀疏图,因此邻接表更常用)。

17.3 图的遍历#

深度优先搜索(Depth-First Search, DFS)#

“一条路走到黑”,沿一条路径尽可能深入,无路可走时回溯。

graph LR A --- B A --- C B --- D C --- D

从 A 开始 DFS:访问 A → 访问 B → 访问 D → 回溯到 A → 访问 C,顺序:A → B → D → C

伪代码示例:

DFS(节点v):
    标记v为已访问
    使用v
    对v的每个未访问邻居u:
        DFS(u)

树:

void dfs(int x){
    cout << x << ' ';
    for (auto& y : a[x]) dfs(y);
}

递归实现:

void dfs(int x){
output.push_back(x); vis[x] = true;
for (auto& y : g[x]){
if (!vis[y]) dfs(y);
};
}

自定义类:

class Graph {
private:
int numVertices;
vector<vector<int>> adjList;
void DFSHelper(int v, vector<bool>& visited) {
visited[v] = true;
cout << v << " ";
for (int neighbor : adjList[v]) {
if (!visited[neighbor]) {
DFSHelper(neighbor, visited);
}
}
}
public:
Graph(int n) : numVertices(n) { adjList.resize(n); }
void addEdge(int u, int v) {
adjList[u].push_back(v);
adjList[v].push_back(u);
}
void DFS(int start) {
vector<bool> visited(numVertices, false);
DFSHelper(start, visited);
}
};

迭代实现(显式栈):

void DFS_iterative(int start) {
vector<bool> visited(numVertices, false);
stack<int> s;
s.push(start);
while (!s.empty()) {
int v = s.top(); s.pop();
if (visited[v]) continue;
visited[v] = true;
cout << v << " ";
for (int i = adjList[v].size() - 1; i >= 0; i--) {
if (!visited[adjList[v][i]]) s.push(adjList[v][i]);
}
}
}

广度优先搜索(Breadth-First Search, BFS)#

“层层推进”,先访问离起点近的节点,再逐层向外扩展。常用于求 有/无向无权图 的最短路径,时间复杂度 O(V+E)。

graph LR A --- B A --- C B --- D C --- D

从 A 开始 BFS:第 0 层:A;第 1 层:B、C(A 的邻居);第 2 层:D(B 和 C 的邻居);顺序:A → B → C → D

方式一:入队时标记(mark-on-enqueue)#

核心思想:节点一旦被发现(即将入队),立即标记为已访问,从而保证队列中不会出现重复节点,出队后无需再做已访问检查。

伪代码

BFS(起点s):
队列q = [s]
标记s为已访问
while q不为空:
v = q.dequeue()
使用v
对v的每个未访问邻居u:
标记u为已访问
q.enqueue(u)

C++ 实现

void bfs(int root){
vector<bool> vis(numVertices, false);
queue<int> q;
q.push(root);
vis[root] = true;
while (!q.empty()) {
int node = q.front();
q.pop();
output.push_back(node);
for (auto& child : g[node]) {
if (!vis[child]) {
vis[child] = true;
q.push(child);
}
}
}
}
方式二:出队时标记(mark-on-dequeue)#

核心思想:节点入队时不立即标记,而是在出队、即将处理时才标记为已访问。因此同一节点可能被多次放入队列,需要在出队后检查是否已访问过,若已访问则跳过。

伪代码

BFS(起点s):
队列q = [s]
while q不为空:
v = q.dequeue()
if v已访问: continue
标记v为已访问
使用v
对v的每个未访问邻居u:
q.enqueue(u)

C++ 实现

void bfs(int root){
vector<bool> vis(numVertices, false);
queue<int> q;
q.push(root);
while (!q.empty()) {
int node = q.front();
q.pop();
if (vis[node]) continue;
vis[node] = true;
output.push_back(node);
for (auto& child : g[node]) {
if (!vis[child]) q.push(child);
}
}
}

区别

  • 队列中节点是否重复:方式2可能重复,方式1不会重复。
  • 标记时机:方式2在出队时标记,方式1在入队时标记。
  • 去重检查位置:方式2在出队后检查,方式1在入队前检查,隐含在 if (!vis[child]) 中。
  • 队列最大长度:方式2理论上可能更长,方式1的队列长度严格不超过图中节点总数。
  • 方式2的队列中虽然可能存在重复节点,但每条边 (node, child) 最多只会导致一次入队尝试,所以同样是 O(V+E) 量级,只是常数因子可能略大。
BFS 求最短距离(无权图)#
vector<int> bfs_distance(int start) {
vector<int> dist(numVertices, -1);
vector<bool> visited(numVertices, false);
queue<int> q;
visited[start] = true;
dist[start] = 0;
q.push(start);
while (!q.empty()) {
int v = q.front(); q.pop();
for (int neighbor : adjList[v]) {
if (!visited[neighbor]) {
visited[neighbor] = true;
dist[neighbor] = dist[v] + 1;
q.push(neighbor);
}
}
}
return dist;
}

时间复杂度#

BFS 和 DFS 的时间复杂度相同,取决于图的存储结构:

存储结构时间复杂度原因
邻接表O(V + E)每个顶点入队/访问一次,每条边处理一次
邻接矩阵O(V²)每个顶点访问时需扫描整行,共 V 行

其中 V = 顶点数,E = 边数。

直觉:遍历的本质是”每个顶点访问一次 + 每条边检查一次”,代价由边如何组织决定。

DFS vs BFS#

特性DFSBFS
数据结构栈(递归)队列
空间O(h),h=深度O(w),w=宽度
最短路径不保证保证(无权图)
应用连通性、环检测最短路径、层次

17.4 拓扑排序(Topological Sort)#

问题: 给定一个有向无环图(DAG),找出顶点的一种线性排序,使得对每条边 (u,v)(u,v)uu 都排在 vv 之前。

课程依赖关系(有向边表示"先修后修"):
高中数学 → 微积分 → 线性代数 → 机器学习
一个合法的拓扑序:高中数学 → 微积分 → 线性代数 → 机器学习

注意: 拓扑序不一定唯一;有环的图不存在拓扑序(环上的点互相要求对方先完成,无法排出线性顺序)。

Kahn 算法(基于入度,用队列实现)#

思路: 反复找出当前”入度为0”的顶点(没有任何前驱、可以立即处理),把它加入拓扑序,并把它指向的所有边”移除”(即把它邻居的入度都减1),如果邻居入度变为0就加入队列,重复直到所有顶点都被处理。

vector<int> topologicalSort(vector<vector<int>>& adj, int n) {
vector<int> inDegree(n, 0);
for (int u = 0; u < n; u++) {
for (int v : adj[u]) inDegree[v]++;
}
queue<int> q;
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) q.push(i);
}
vector<int> result;
while (!q.empty()) {
int u = q.front(); q.pop();
result.push_back(u);
for (int v : adj[u]) {
inDegree[v]--;
if (inDegree[v] == 0) q.push(v);
}
}
// 如果result.size() < n,说明图中存在环,不存在拓扑序
return result;
}
// 时间:O(V + E),空间:O(V)

判断图中是否有环: 如果最终 result 中顶点数小于 n,说明有些顶点的入度永远无法减到0(被困在环里),图中存在环,拓扑排序不存在。这是判断有向图是否有环的标准方法之一。

基于 DFS 的实现#

思路: 对每个未访问的顶点做 DFS,在递归返回时把当前顶点加入结果(后序),最后把结果整体反转就是拓扑序——因为一个顶点的所有”后继”在 DFS 中必然先于它自己被加入结果,反转后就变成了”前驱在前”。

void dfsTopo(int u, vector<vector<int>>& adj, vector<bool>& visited, vector<int>& result) {
visited[u] = true;
for (int v : adj[u]) {
if (!visited[v]) dfsTopo(v, adj, visited, result);
}
result.push_back(u); // 后序:递归返回时才加入
}
vector<int> topologicalSortDFS(vector<vector<int>>& adj, int n) {
vector<bool> visited(n, false);
vector<int> result;
for (int i = 0; i < n; i++) {
if (!visited[i]) dfsTopo(i, adj, visited, result);
}
reverse(result.begin(), result.end());
return result;
}
// 时间:O(V + E),空间:O(V)

两种实现的对比: Kahn 算法(入度+队列)天然支持”检测是否有环”,且过程更直观;DFS 实现代码更短,但需要额外反转结果,且检测环需要在 DFS 过程中额外维护”递归栈中的顶点”集合(区分”正在访问”和”已访问完成”两种状态)才能判断。

算法数据结构检测环时间复杂度
Kahn 算法队列+入度天然支持O(V+E)
DFS 后序反转栈(递归)需额外状态O(V+E)

与第8章的关联: 第8章”DAG上的最短路”一节直接调用了拓扑排序作为前置步骤——必须先把顶点按拓扑序排好,才能保证”计算某点的最短距离时,它的所有前驱都已经计算完毕”,这正是拓扑排序在动态规划中最常见的用途之一。


第18章 区间结构(Interval Structures)#

维护数组,支持区间查询与单点/区间修改。

18.1 前缀和与差分(Prefix Sum & Difference Array)#

核心思想: 用预处理换查询速度。如果数组本身不再变化,频繁查询区间和是没必要每次都重新加一遍的;如果数组只做”区间整体加一个值”这种修改,也没必要每次都把区间内每个元素都改一遍。这是树状数组、线段树之前最朴素、最常用的两个技巧。

前缀和(支持O(1)区间和查询,但不支持修改)#

思路: 预处理出 prefix[i] = arr[0] + arr[1] + ... + arr[i-1],区间 [l, r] 的和就是 prefix[r+1] - prefix[l]

arr = [3, 1, 4, 1, 5, 9, 2, 6]
prefix = [0, 3, 4, 8, 9, 14, 23, 25, 31]
↑ prefix[i] = arr[0..i-1]之和
查询[2,5]的和(即arr[2]+arr[3]+arr[4]+arr[5] = 4+1+5+9 = 19):
prefix[6] - prefix[2] = 23 - 4 = 19 ✓
vector<int> buildPrefixSum(vector<int>& arr) {
int n = arr.size();
vector<int> prefix(n + 1, 0);
for (int i = 0; i < n; i++) {
prefix[i + 1] = prefix[i] + arr[i];
}
return prefix;
}
int rangeSum(vector<int>& prefix, int l, int r) { // 查询arr[l..r]之和
return prefix[r + 1] - prefix[l];
}
// 建表:O(n),单次查询:O(1)

局限: 如果 arr 本身被修改(哪怕只改一个元素),整张 prefix 表从修改点之后全部需要重算,单次修改代价退化为 O(n)——这正是树状数组、线段树要解决的问题(用 O(log n) 的代价支持修改后仍能快速查询)。

差分数组(支持O(1)区间修改,但不支持中途查询单点)#

思路: 前缀和解决的是”频繁查询、不修改”;差分数组解决的是反过来的场景——“频繁做区间整体加值的修改,最后一次性还原”。定义 diff[i] = arr[i] - arr[i-1]diff[0] = arr[0]),对 arr 做一次”区间 [l,r] 整体加 val”的修改,只需要在差分数组上改两个位置:

arr[l..r] 整体加 val 等价于:
diff[l] += val
diff[r+1] -= val
arr = [0, 0, 0, 0, 0]
diff = [0, 0, 0, 0, 0]
操作1:arr[1..3]整体加5 → diff[1]+=5, diff[4]-=5 → diff=[0,5,0,0,-5]
操作2:arr[2..4]整体加3 → diff[2]+=3, diff[5]-=3(越界忽略) → diff=[0,5,3,0,-5]
最后对diff求前缀和还原arr:
arr = [0, 5, 8, 8, 3]
验证:arr[1..3]=[5,8,8],确实都比初始多了5(操作1);arr[2..4]=[8,8,3],都比操作1后的结果多了3(操作2)✓
vector<int> buildDiff(vector<int>& arr) {
int n = arr.size();
vector<int> diff(n + 1, 0);
diff[0] = arr[0];
for (int i = 1; i < n; i++) diff[i] = arr[i] - arr[i-1];
return diff;
}
void rangeAdd(vector<int>& diff, int l, int r, int val) { // arr[l..r]整体加val
diff[l] += val;
if (r + 1 < diff.size()) diff[r + 1] -= val;
}
vector<int> restoreFromDiff(vector<int>& diff) { // 把差分数组还原成原数组
vector<int> arr(diff.size());
arr[0] = diff[0];
for (int i = 1; i < arr.size(); i++) arr[i] = arr[i-1] + diff[i];
return arr;
}
// 单次区间修改:O(1),全部修改完后一次性还原:O(n)

局限: 差分数组只适合”所有修改做完之后再统一查询”的场景;如果在修改过程中需要随时查询某个区间的和,差分数组无法做到树状数组/线段树那样的 O(log n) 混合支持。

适用场景对比#

技巧修改代价查询代价适用场景
前缀和O(n)O(1)数组基本不变,频繁查询区间和
差分数组O(1)O(n)(需先还原)频繁区间整体修改,最后统一查询
树状数组/线段树(见18.2、18.3节)O(log n)O(log n)修改和查询都频繁交替进行

18.2 树状数组(Binary Indexed Tree, BIT / Fenwick Tree)#

问题场景#

方法单点修改区间查询问题
直接遍历O(1)O(n)查询太慢
前缀和O(n)O(1)修改需要重算整个前缀和

树状数组: 修改和查询都是 O(log n)。

核心思想#

利用二进制特性,每个位置存储一段区间的和:

规律: bit[i] 管辖 lowbit(i) 个元素
lowbit(i) = i & (-i) // 提取最低位的1
下标 二进制 lowbit 管辖范围
1 001 1 [1, 1]
2 010 2 [1, 2]
4 100 4 [1, 4]
6 110 2 [5, 6]
8 1000 8 [1, 8]

实现#

class BIT {
private:
vector<int> tree;
int n;
int lowbit(int x) { return x & (-x); }
public:
BIT(int size) : n(size) {
tree.resize(n + 1, 0);
}
// 单点修改: arr[index] += val
void update(int index, int val) {
index++;
while (index <= n) {
tree[index] += val;
index += lowbit(index);
}
}
// 前缀和: sum(arr[0]...arr[index])
int query(int index) {
index++;
int sum = 0;
while (index > 0) {
sum += tree[index];
index -= lowbit(index);
}
return sum;
}
// 区间和: sum(arr[L]...arr[R])
int rangeQuery(int L, int R) {
return query(R) - (L > 0 ? query(L - 1) : 0);
}
};

O(n) 建树:

BIT(vector<int>& arr) : n(arr.size()) {
tree.resize(n + 1, 0);
for (int i = 1; i <= n; i++) {
tree[i] += arr[i - 1];
int j = i + lowbit(i);
if (j <= n) tree[j] += tree[i];
}
}

时间复杂度#

操作时间复杂度
建树O(n)
单点修改O(log n)
区间和查询O(log n)
空间O(n)

优缺点#

优点: 代码简洁,常数因子小,空间占用少。

缺点: 只能处理”可逆”操作(如加法),无法处理区间最值、区间 GCD 等;区间修改需要差分数组技巧。


18.3 线段树(Segment Tree)#

为什么需要线段树#

树状数组只能处理”可加”操作:区间和可以,区间最大值/GCD 不行(没有”减法”撤销),区间修改需要额外技巧。

线段树的优势: 支持任意可结合的操作(和、最值、GCD、乘积等),原生支持区间修改(懒惰标记)。

核心思想#

线段树是一棵完全二叉树,每个节点代表一个区间:根节点代表整个区间,左右子节点代表左右半段,叶子节点代表单个元素。

数组:arr = [1, 3, 5, 7, 9, 11]

graph TD R["[0-5]: 36"] L["[0-2]: 9"] RR["[3-5]: 27"] LL["[0-1]: 4"] LR["[2]: 5"] RRL["[3-4]: 16"] RRR["[5]: 11"] LLL["[0]: 1"] LLR["[1]: 3"] RRLL["[3]: 7"] RRLR["[4]: 9"] R --> L R --> RR L --> LL L --> LR RR --> RRL RR --> RRR LL --> LLL LL --> LLR RRL --> RRLL RRL --> RRLR

实现#

class SegmentTreeNode {
public:
int start, end, sum;
SegmentTreeNode *left, *right;
SegmentTreeNode(int s, int e) : start(s), end(e), sum(0),
left(nullptr), right(nullptr) {}
};

建树:

SegmentTreeNode* build(int start, int end) {
SegmentTreeNode* node = new SegmentTreeNode(start, end);
if (start == end) {
node->sum = arr[start];
return node;
}
int mid = start + (end - start) / 2;
node->left = build(start, mid);
node->right = build(mid + 1, end);
node->sum = node->left->sum + node->right->sum;
return node;
}
// 时间:O(n)

区间查询:

int queryHelper(SegmentTreeNode* node, int L, int R) {
if (node->start >= L && node->end <= R) {
return node->sum; // 完全包含
}
if (node->start > R || node->end < L) {
return 0; // 无交集
}
return queryHelper(node->left, L, R) +
queryHelper(node->right, L, R); // 部分重叠
}
// 时间:O(log n)

单点修改:

void updateHelper(SegmentTreeNode* node, int index, int val) {
if (node->start == node->end) {
node->sum = val;
return;
}
int mid = node->start + (node->end - node->start) / 2;
if (index <= mid) updateHelper(node->left, index, val);
else updateHelper(node->right, index, val);
node->sum = node->left->sum + node->right->sum;
}
// 时间:O(log n)

区间修改(懒惰标记)#

朴素逐个修改是 O(n log n);用懒惰标记优化:修改时只更新涉及的节点并打标记,查询时再向下传递。

class SegmentTreeNode {
public:
int start, end, sum, lazy;
SegmentTreeNode *left, *right;
};
void pushDown(SegmentTreeNode* node) {
if (node->lazy == 0) return;
int len = node->end - node->start + 1;
// len/2 向下取整得右半段长度,左半段 = len - len/2(≥ 右半段)
int leftLen = len - len / 2, rightLen = len / 2;
node->left->sum += node->lazy * leftLen;
node->left->lazy += node->lazy;
node->right->sum += node->lazy * rightLen;
node->right->lazy += node->lazy;
node->lazy = 0;
}
void updateRange(SegmentTreeNode* node, int L, int R, int val) {
if (node->start >= L && node->end <= R) {
int len = node->end - node->start + 1;
node->sum += val * len;
node->lazy += val;
return;
}
if (node->start > R || node->end < L) return;
pushDown(node);
updateRange(node->left, L, R, val);
updateRange(node->right, L, R, val);
node->sum = node->left->sum + node->right->sum;
}
int query(SegmentTreeNode* node, int L, int R) {
if (node->start >= L && node->end <= R) return node->sum;
if (node->start > R || node->end < L) return 0;
pushDown(node); // 先下推标记
return query(node->left, L, R) + query(node->right, L, R);
}

时间复杂度: 区间修改和查询都是 O(log n)。

扩展:其他类型的线段树#

// 区间最大值/最小值:只需修改合并逻辑
node->maxVal = max(node->left->maxVal, node->right->maxVal);
// 区间 GCD
node->gcdVal = gcd(node->left->gcdVal, node->right->gcdVal);

复杂度与对比#

操作时间空间
建树O(n)O(4n)
单点/区间修改O(log n)-
区间查询O(log n)-
特性树状数组线段树
代码复杂度简单复杂
空间O(n)O(4n)
适用操作可逆操作(加法)任意可结合操作
区间修改需要差分技巧原生支持(懒惰标记)

选择建议: 只需要区间和 + 单点修改 → 树状数组;需要区间最值/区间修改/复杂操作 → 线段树。


C++ 标准库没有直接的线段树/树状数组实现,需要手写。


18.4 离散化(Discretization)#

问题: 树状数组、线段树的下标本质上是数组下标,要求值域不能太大(否则数组开不下)。但实际问题中数据范围常常很大(比如坐标可以是 10910^9 级别),而实际出现的不同数值个数可能很少。离散化就是把这些”稀疏分布、范围很大”的数值,映射成”紧凑排列、从0开始连续编号”的小范围下标,使得后面可以用树状数组/线段树正常处理。

原始数据: [100000000, 5, 999999999, 5, 42]
离散化步骤:
1. 排序去重: [5, 42, 100000000, 999999999]
2. 建立映射: 5→0, 42→1, 100000000→2, 999999999→3
3. 原数组变为: [2, 0, 3, 0, 1]
vector<int> discretize(vector<int>& arr) {
vector<int> sorted_unique = arr;
sort(sorted_unique.begin(), sorted_unique.end());
sorted_unique.erase(unique(sorted_unique.begin(), sorted_unique.end()), sorted_unique.end());
vector<int> result(arr.size());
for (int i = 0; i < arr.size(); i++) {
// 二分查找arr[i]在去重排序后数组中的位置,即为离散化后的下标
result[i] = lower_bound(sorted_unique.begin(), sorted_unique.end(), arr[i]) - sorted_unique.begin();
}
return result;
}
// 时间:O(n log n),排序+去重O(n log n),每个元素二分查找O(log n)

典型应用场景: 求逆序对个数(配合树状数组,需要把值映射到下标范围内才能用树状数组统计”已出现的比当前值小的元素个数”)、处理坐标范围极大但数据点稀疏的区间问题。

注意: 离散化本身不是一种独立的数据结构,而是一种预处理技巧,通常和树状数组、线段树搭配使用,让后者能够正常工作。


18.5 ST表(Sparse Table)#

适用条件: 只需要支持区间查询(最值、GCD等”可重复贡献”的运算,即重叠区间取两次结果不影响正确性的运算),不需要修改——数组一旦建好就不再变化。

核心思想: 用倍增的思想预处理出所有”长度为 2k2^k“的区间的答案,查询任意区间时,把它拆成至多两个(可以重叠的)长度为 2k2^k 的区间,分别取已经预处理好的答案再合并。

ST表预处理示意(以区间最大值为例):
st[i][0] = arr[i] (长度1的区间)
st[i][k] = max(st[i][k-1], st[i + 2^(k-1)][k-1]) (长度2^k的区间,由两个长度2^(k-1)的区间合并)
arr = [2, 4, 1, 5, 3, 9, 0, 7]
st[i][0]: [2, 4, 1, 5, 3, 9, 0, 7] 长度1
st[i][1]: [4, 4, 5, 5, 9, 9, 7] 长度2,st[i][1]=max(arr[i],arr[i+1])
st[i][2]: [5, 5, 9, 9] 长度4
st[i][3]: [9] 长度8
class SparseTable {
private:
vector<vector<int>> st;
vector<int> logTable;
public:
SparseTable(vector<int>& arr) {
int n = arr.size();
int maxLog = log2(n) + 1;
st.assign(n, vector<int>(maxLog));
logTable.assign(n + 1, 0);
for (int i = 2; i <= n; i++) logTable[i] = logTable[i / 2] + 1;
for (int i = 0; i < n; i++) st[i][0] = arr[i];
for (int k = 1; k < maxLog; k++) {
for (int i = 0; i + (1 << k) <= n; i++) {
st[i][k] = max(st[i][k-1], st[i + (1 << (k-1))][k-1]);
}
}
}
int query(int l, int r) { // 查询arr[l..r]的最大值,闭区间
int k = logTable[r - l + 1];
return max(st[l][k], st[r - (1 << k) + 1][k]);
}
};
// 建表:O(n log n),单次查询:O(1)

为什么查询可以重叠: 取区间最大值时,即使两个长度为 2k2^k 的子区间有重叠部分,重叠部分被算了两次也不影响”最大值”这个结果的正确性(重复取最大值不改变答案)。但如果换成”区间和”,重叠部分会被重复计入,答案就错了——这正是 ST表只适用于”可重复贡献”运算(最大值、最小值、GCD、按位与/或等)、不适用于”区间和”这类运算的原因。

三种区间结构的选择#

结构支持修改查询复杂度建表复杂度适用运算
前缀和不支持O(1)O(n)区间和
树状数组支持O(log n)O(n)可逆运算(如加法)
线段树支持O(log n)O(n)任意可结合运算
ST表不支持O(1)O(n log n)可重复贡献运算(最值、GCD等)

选择原则: 数据不变、只查最值/GCD → ST表(查询最快);数据不变、只查区间和 → 前缀和(最简单);数据会修改 → 树状数组(运算可逆时)或线段树(通用)。


附录:复杂度总表#

线性结构#

数据结构访问查找插入删除空间
数组O(1)O(n)O(n)O(n)O(n)
动态数组O(1)O(n)O(1)均摊O(n)O(n)
链表O(n)O(n)O(1)*O(1)*O(n)
--O(1)O(1)O(n)
队列--O(1)O(1)O(n)

*已知位置

字典与优先级结构#

数据结构查找插入删除空间备注
BSTO(log n)O(log n)O(log n)O(n)最坏 O(n)
AVL 树O(log n)O(log n)O(log n)O(n)严格平衡
红黑树O(log n)O(log n)O(log n)O(n)弱平衡
B/B+树O(log n)O(log n)O(log n)O(n)磁盘友好
哈希表O(1)平均O(1)平均O(1)平均O(n)最坏 O(n)
O(n)O(log n)O(log n)O(n)查看堆顶 O(1)
并查集O(α(n))≈O(1)O(α(n))≈O(1)-O(n)

区间结构#

数据结构查找插入删除空间备注
线段树O(log n)O(log n)-O(4n)区间操作
树状数组O(log n)O(log n)-O(n)区间和

#

存储方式空间判断边获取邻居
邻接矩阵O(V²)O(1)O(V)
邻接表O(V+E)O(度)O(度)
遍历方式时间(邻接表)时间(邻接矩阵)空间
DFSO(V+E)O(V²)O(V)
BFSO(V+E)O(V²)O(V)

Part D 计算的极限#

前面三个 Part 关心”怎么解决问题”;本章关心一个更根本的问题:哪些问题在已知的算法技术下没有高效(多项式时间)解法,以及面对这类问题时能做到的最好程度是什么。


第19章 NP完全性(NP-Completeness)#

19.1 P 类与 NP 类#

判定问题: 输出只有”是”或”否”的问题(如”这个图是否存在哈密顿回路”)。

P 类: 存在一个算法,能在多项式时间内解决该判定问题。

P={LL 可以在 O(nk) 时间内被判定,k 为某个常数}P = \{\, L \mid L \text{ 可以在 } O(n^k) \text{ 时间内被判定,} k \text{ 为某个常数} \,\}

NP 类: 不要求能在多项式时间内找到答案,只要求:如果给定一个”证书”(一个候选解),能在多项式时间内验证这个证书是否正确。

NP={L存在多项式时间的验证算法,对 "是" 实例存在多项式长度的证书可以验证通过}NP = \{\, L \mid \text{存在多项式时间的验证算法,对 "是" 实例存在多项式长度的证书可以验证通过} \,\}

例: “图 GG 是否存在一个大小为 kk 的团(clique)“——直接找这样一个团,目前没有已知多项式算法;但如果有人给出一组 kk 个点,验证”这 kk 个点是否两两相连”只需 O(k2)O(k^2) 时间,是多项式的。所以这个问题在 NP 中。

显然 PNPP \subseteq NP(能在多项式时间解决的问题,当然也能在多项式时间内验证:直接重新跑一遍算法即可)。PP 是否等于 NPNP 是计算机科学最著名的未解决问题之一(不在本讲义讨论范围内)。

19.2 多项式归约#

定义: 问题 AA 多项式归约到问题 BB(记作 ApBA \le_p B),是指存在一个多项式时间可计算的函数 ff,把 AA 的任意输入 xx 转化为 BB 的输入 f(x)f(x),使得:

x 是 A 的 "是" 实例    f(x) 是 B 的 "是" 实例x \text{ 是 } A \text{ 的 "是" 实例} \iff f(x) \text{ 是 } B \text{ 的 "是" 实例}

归约的意义: 如果 ApBA \le_p B,且 BB 有多项式算法,那么 AA 也有多项式算法(先用 ff 转化,再调用 BB 的算法,两步都是多项式时间,合起来仍是多项式时间)。反过来,如果已知 AA “很难”(没有多项式算法),且 ApBA \le_p B,则可以推出 BB 至少不会比 AA 更容易——这是证明一个新问题”难”的标准手段:把一个已知难的问题归约到它。

19.3 NP 完全的定义#

一个问题 LLNP 完全(NP-Complete) 的,如果同时满足:

  1. LNPL \in NP
  2. 对任意 LNPL' \in NP,都有 LpLL' \le_p L(即 LL 至少和 NP 中所有问题一样难,称为 NP 难,NP-hard

Cook-Levin 定理(只陈述,不证明): SAT 问题(布尔可满足性问题:给定一个布尔表达式,是否存在一组变量赋值使表达式为真)是 NP 完全的。这是第一个被证明 NP 完全的问题,证明思路是把任意 NP 问题的多项式时间验证过程,模拟成一个等价的布尔表达式(证明本身涉及图灵机模拟,篇幅很长,超出本讲义范围)。

有了第一个 NP 完全问题之后: 要证明新问题 LL 是 NP 完全的,不需要再从头模拟图灵机,只需:

  1. 证明 LNPL \in NP(通常容易:给出验证算法)
  2. 找一个已知的 NP 完全问题 L0L_0,证明 L0pLL_0 \le_p L(归约方向:把已知难的问题转化成新问题)

19.4 归约证明范例#

范例一:顶点覆盖 与 独立集 的互补关系#

独立集问题(Independent Set):G=(V,E)G=(V,E),是否存在大小为 kk 的点集 SS,使得 SS 中任意两点之间都没有边?

顶点覆盖问题(Vertex Cover): 是否存在大小为 kk 的点集 CC,使得每条边至少有一个端点在 CC 中?

命题: SSGG 的独立集     \iff VSV \setminus SGG 的顶点覆盖。

证明:SS 是独立集。对任意一条边 (u,v)E(u,v) \in E,由于 SS 内任意两点无边,u,vu,v 不可能同时在 SS 中,所以至少有一个在 VSV\setminus S 中,即 VSV\setminus S 覆盖了这条边。由于这对任意边都成立,VSV\setminus S 是顶点覆盖。

反过来,设 VSV\setminus S 是顶点覆盖。若 SS 中存在一条边 (u,v)(u,v)(即 u,vu,v 都在 SS 中),由于 VSV\setminus S 覆盖了所有边,(u,v)(u,v) 也应被 VSV\setminus S 覆盖,即 uuvvVSV\setminus S 中——但 u,vu,v 都在 SS 中,矛盾。所以 SS 内不存在边,SS 是独立集。 \blacksquare

由此得到归约:GG 是否存在大小 k\ge k 的独立集” p\le_pGG 是否存在大小 Vk\le |V|-k 的顶点覆盖”,转化函数 ff 就是”取补集”,显然多项式时间可计算。这说明独立集问题和顶点覆盖问题在多项式归约意义下是同样难度的。

范例二:3-SAT 归约到顶点覆盖(归约构造,仅给出构造思路)#

3-SAT: 布尔表达式是若干个”子句”的合取,每个子句是 3 个文字(变量或其否定)的析取,问是否存在赋值使整个表达式为真。

归约构造(标准教科书构造):

  1. 变量部件: 对每个变量 xix_i,构造一条边 (xi,¬xi)(x_i, \neg x_i)。顶点覆盖必须至少选择这两个点之一(覆盖这条边),这对应”给 xix_i 赋值为真或假”。
  2. 子句部件: 对每个子句 (l1l2l3)(l_1 \vee l_2 \vee l_3),构造一个三角形(3 个点两两相连),三个点分别标记为 l1,l2,l3l_1, l_2, l_3 的”该子句中的副本”。顶点覆盖要覆盖三角形内部的 3 条边,至少需要选 2 个点。
  3. 跨部件连接: 子句中每个文字副本,与变量部件中对应的同名文字相连。

覆盖大小的设定: 设总共 nn 个变量、mm 个子句,目标顶点覆盖大小设为 n+2mn + 2m(每个变量部件贡献1个,每个子句部件贡献2个)。

正确性的直觉(不做完整证明): 如果 3-SAT 可满足,每个子句至少有一个文字为真,覆盖时优先覆盖变量部件中”为真”的那个点,再在每个子句三角形中覆盖”除了那个为真文字对应顶点外”的两个点,正好用 n+2mn+2m 个点覆盖所有边;反过来,如果存在大小 n+2mn+2m 的顶点覆盖,可以反推出一个满足所有子句的赋值。完整的双向证明需要仔细处理”跨部件连接边”的覆盖关系,此处从略。

意义: 这个归约展示了 NP 完全性证明的标准范式——用图的局部”部件(gadget)“模拟逻辑约束,是许多其他归约证明(如归约到哈密顿回路、图着色等)共享的构造思想。

19.5 复杂度小结#

概念含义
P多项式时间可解
NP多项式时间可验证
NP-hard至少和所有 NP 问题一样难(不要求本身在NP中)
NP-CompleteNP ∩ NP-hard

各类问题的集合关系(在 PNPP \ne NP 假设下):

实用判断: 如果一个新问题能被证明是 NP 完全的,意味着(在 PNPP \ne NP 的广泛猜测下)不存在多项式时间的精确算法,这时应该考虑近似算法(第20章)、启发式算法,或针对特殊输入结构的高效算法。


第20章 近似算法(Approximation Algorithms)#

当一个问题是 NP 难时,精确求解在大规模输入下不现实。近似算法放弃”一定找到最优解”,转而追求”在多项式时间内,找到一个与最优解相差有界的解”,并提供该误差界的证明。

20.1 近似比的定义#

对最小化问题,设算法给出的解的代价是 CC,最优解的代价是 CC^*,称算法是 ρ\rho-近似算法,如果对任意输入:

CρCC \le \rho \cdot C^*

(对最大化问题方向相反:CC/ρC \ge C^* / \rho)。ρ1\rho \ge 1 越接近 1,近似效果越好。

20.2 顶点覆盖的 2-近似算法#

算法: 重复”任取一条尚未被覆盖的边 (u,v)(u,v),把 u,vu,v 都加入覆盖集合,删除所有与 uuvv 相连的边”,直到没有边剩下。

vector<int> vertexCoverApprox(int n, vector<pair<int,int>>& edges) {
vector<bool> covered(edges.size(), false);
vector<bool> inCover(n, false);
for (int i = 0; i < edges.size(); i++) {
if (covered[i]) continue;
auto [u, v] = edges[i];
inCover[u] = true;
inCover[v] = true;
// 标记所有与u或v相连的边为已覆盖
for (int j = 0; j < edges.size(); j++) {
if (edges[j].first == u || edges[j].second == u ||
edges[j].first == v || edges[j].second == v) {
covered[j] = true;
}
}
}
vector<int> result;
for (int i = 0; i < n; i++) if (inCover[i]) result.push_back(i);
return result;
}
// 时间:O(E^2)(朴素实现,可优化)

近似比证明:

设算法选出的边集合为 M={(u1,v1),(u2,v2),}M = \{(u_1,v_1), (u_2,v_2), \dots\}(即每一轮选中的那条边)。由于每选一条边就删除所有与其端点相连的边,MM 中任意两条边都不共享端点(否则后一条边在前一条边被处理时就已经被标记为覆盖,不会被选中)——也就是说 MM 是图的一个匹配(matching)

最优顶点覆盖 CC^* 必须覆盖 MM 中的每一条边(因为 MM 中的边也是原图的边),而 MM 中的边两两不共享端点,所以覆盖 MM 中的 M|M| 条边,至少需要 M|M| 个不同的点(每条边至少贡献一个独立的覆盖点,不能像普通边集那样”一个点覆盖两条边”,因为边不共享端点):

CM|C^*| \ge |M|

算法选出的覆盖集合大小是 M|M| 条边的所有端点,即 2M2|M|MM 中每条边贡献 2 个不同的点):

算法输出=2M|\text{算法输出}| = 2|M|

两式相除:

算法输出C2MM=2\frac{|\text{算法输出}|}{|C^*|} \le \frac{2|M|}{|M|} = 2

所以这是一个 2-近似算法

\blacksquare

20.3 度量空间 TSP 的 2-近似算法(基于最小生成树)#

问题限定: 旅行商问题在一般图上没有常数近似比(除非 P=NP),但如果限定边权满足三角不等式w(u,v)w(u,x)+w(x,v)w(u,v) \le w(u,x)+w(x,v),称为”度量 TSP”),可以构造近似算法。

算法:

  1. 求图的最小生成树(MST)(见第9章贪心法
  2. 对 MST 做一次 DFS 前序遍历,得到一个访问所有点的序列(允许重复经过同一点)
  3. 把序列中重复出现的点去重(保留第一次出现的位置),得到最终回路
vector<int> tspApprox(vector<vector<pair<int,int>>>& mstAdj, int start) {
vector<bool> visited(mstAdj.size(), false);
vector<int> tour;
function<void(int)> dfs = [&](int u) {
visited[u] = true;
tour.push_back(u);
for (auto& [v, w] : mstAdj[u]) {
if (!visited[v]) dfs(v);
}
};
dfs(start);
tour.push_back(start); // 回到起点
return tour;
}

近似比证明:

设最优 TSP 回路代价为 CC^*关键观察: 从最优回路中去掉任意一条边,得到一条哈密顿路径,这条路径本身就是一棵生成树,所以最小生成树的总权值满足:

w(MST)Cw(\text{MST}) \le C^*

(最小生成树是所有生成树中权值最小的,而最优回路去掉一条边后的路径只是其中一棵生成树,自然不会比最小的更小)

DFS 遍历 MST 时,每条树边恰好被经过两次(一次”下去”,一次回溯”上来”),所以遍历的总长度是:

2w(MST)2 \cdot w(\text{MST})

最后一步”去重”利用了三角不等式:跳过中间已访问的点直接连到下一个新点,边权不会比绕路经过的总和更大(这正是三角不等式 w(u,v)w(u,x)+w(x,v)w(u,v)\le w(u,x)+w(x,v) 的直接应用),所以去重后的回路总长度:

C2w(MST)2CC \le 2 \cdot w(\text{MST}) \le 2C^*

所以这是一个 2-近似算法

\blacksquare

改进: 更精细的 Christofides 算法(结合最小生成树与最小权完美匹配)能把比值改进到 1.5,但构造和证明更复杂,此处不展开,仅作为”近似比可以被进一步优化”的提及。

20.4 集合覆盖的贪心近似(仅提及)#

问题: 给定全集 UU 和若干子集 S1,,SmUS_1,\dots,S_m \subseteq U,求覆盖 UU 所需的最少子集数。

贪心策略: 每次选择”能覆盖最多尚未被覆盖元素”的子集。可以证明这个贪心算法是 O(lnn)O(\ln n)-近似的(n=Un=|U|),且这个比值在最坏情况下是紧的(即不存在更好的多项式近似算法,除非 P=NP 的某些更强假设被打破)。完整证明依赖对调和级数 Hn=1/ilnnH_n = \sum 1/i \approx \ln n 的逐步累计论证,篇幅较长,此处仅作结论性提及。

20.5 近似算法小结#

问题近似比核心证明工具
顶点覆盖2匹配的下界性质
度量 TSP(MST法)2MST 是哈密顿路径的下界 + 三角不等式
度量 TSP(Christofides)1.5最小生成树 + 最小权完美匹配(不展开)
集合覆盖O(lnn)O(\ln n)调和级数累计(不展开)

近似算法设计的常见模式: 找一个”容易计算、且可以证明是最优解某种下界(或上界)“的中间量(如本章中的匹配、最小生成树),再证明算法输出与这个中间量之间的倍数关系,从而推出与真实最优解的近似比——这是绝大多数近似比证明共享的论证结构。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

我的算法笔记
https://cialo.site/posts/algorithm/
作者
洛璃
发布于
2026-06-28
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
洛璃
初春的离去,晚樱的谢幕
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
34
分类
11
标签
123
总字数
140,689
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.13.5
文章许可
CC BY-NC-SA 4.0

文章目录