分治法:把一次难题拆成一棵可计价的树
一段连续 17 天的价格记录摆在面前,只允许买入一次、之后卖出一次。最直接的做法是枚举所有买卖日,比较每一对日期的收益。日期数翻倍,候选对数大约会变成四倍。
另一条路是先把相邻两天的价格差写成数组,再寻找和最大的连续片段。问题的表面变了,真正的难点也显出来了:把数组从中间切开以后,最优片段可能完全在左边、完全在右边,也可能横跨切口。前两种可以递归处理,第三种必须专门设计合并步骤。
这正是分治法的工作方式:拆开问题只是起点,合并答案才决定算法是否成立;递推式则记录整棵递归树到底花了多少工作。
图中的分支数对应递推式里的 a a a ,每个节点内部的非递归工作对应 f ( n ) f(n) f ( n ) ;所有节点成本累加后才是 T ( n ) T(n) T ( n ) 。
从“拆成两半”到完整算法
一个分治算法通常包含三个动作。
分解:把规模为 n n n 的问题切成若干个更小的同类问题。切分要让子问题保留原问题的结构,否则递归过程无法复用同一套解法。
求解:对子问题继续执行同样的算法。当规模小到可以直接回答时停止递归,这个停止点是基本情形。
合并:利用子问题的答案构造原问题的答案。合并可能只需一次比较,也可能需要扫描整个区间;这部分成本经常决定最终复杂度。
如果一次调用产生 a a a 个规模约为 n / b n/b n / b 的子问题,并且本层分解、合并及其他非递归工作一共需要 f ( n ) f(n) f ( n ) ,运行时间常写成:
T ( n ) = a T ( n / b ) + f ( n ) T(n)=aT(n/b)+f(n) T ( n ) = a T ( n / b ) + f ( n )
这里的 a a a 不能被“常数可以忽略”这句话删掉。它决定每个节点有多少个孩子,继而决定第 i i i 层有多少个节点。比如 8 T ( n / 2 ) 8T(n/2) 8 T ( n /2 ) 和 T ( n / 2 ) T(n/2) T ( n /2 ) 的递归树宽度完全不同。
递推式还需要一个基本情形。若常数规模输入可以在常数时间内解决,可以写成:
T ( 1 ) = Θ ( 1 ) T(1)=\Theta(1) T ( 1 ) = Θ ( 1 )
分析渐近增长时,通常把 T ( ⌊ n / 2 ⌋ ) T(\lfloor n/2\rfloor) T (⌊ n /2 ⌋) 与 T ( ⌈ n / 2 ⌉ ) T(\lceil n/2\rceil) T (⌈ n /2 ⌉) 简写为 T ( n / 2 ) T(n/2) T ( n /2 ) 。这种简写不等于程序可以忽略边界。实现仍要保证两个子区间都缩小,并且最终会到达基本情形。
并非所有递归都符合上面的等分形式。下面三类递推各自描述了不同的拆分方式:
看到递归代码时先问“子问题是否真正变小、答案是否覆盖全部情况、合并成本是多少”。只写出递归调用,并不能自动得到一个正确或高效的分治算法。
1 递推式 T(n)=4T(n/2)+n 中的系数 4 表示什么?
A 递归深度固定为 4 B 每次产生 4 个半规模子问题 C 每层只做 4 次操作 D 输入必须是 4 的倍数
最大子数组:合并步骤决定答案
设价格为:
[100, 113, 110, 85, 105, 102, 86, 63, 81, 101, 94, 106, 101, 79, 94, 90, 97]
把第 i i i 天相对第 i − 1 i-1 i − 1 天的变化记为 A [ i ] A[i] A [ i ] ,得到:
[13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -5, -22, 15, -4, 7]
若从第 7 天收盘后买入,在第 11 天收盘后卖出,收益就是变化数组下标 7 到 10 的总和。于是买卖问题变成:寻找一个非空、连续且元素和最大的子数组。
三类候选没有遗漏
考虑区间 A [ l o w … h i g h ] A[low\ldots high] A [ l o w … hi g h ] ,中点为 m i d mid mi d 。任意连续片段相对中点只可能处在三个位置之一:
终点不超过 m i d mid mi d ,整个片段位于左半区间。
起点大于 m i d mid mi d ,整个片段位于右半区间。
起点不超过 m i d mid mi d 且终点大于 m i d mid mi d ,片段横跨中点。
图中这次切分的跨中点候选是左侧最大后缀 [20, -3, -16] 与右侧最大前缀 [-23, 18, 20, -7, 12] 的拼接。递归调用还会分别给出左侧候选和右侧候选,三者取和最大者。
左、右候选是规模更小的最大子数组问题,可以递归求解。跨中点候选附带“必须经过切口”的限制,不是原问题的普通缩小版,应放进合并步骤处理。
跨中点候选为什么能线性求出
任何跨中点片段都可以唯一分成两段:以 m i d mid mi d 结尾的左侧后缀,以及从 m i d + 1 mid+1 mi d + 1 开始的右侧前缀。最佳跨中点片段必然使用“和最大的左侧后缀”与“和最大的右侧前缀”。如果其中一边不是最大,把它替换成更大的同类片段就会得到更好的跨中点答案,产生矛盾。
从中点向左累加一次,再从中点右侧向右累加一次,就能找出这两段:
def crossing (a, low, mid, high):
best_left_sum = float ( "-inf" )
total = 0
best_left = mid
for i in range (mid, low - 1 , - 1 ):
total += a[i]
if total > best_left_sum:
best_left_sum = total
best_left = i
best_right_sum
两个循环合计访问 high - low + 1 个元素,因此合并成本是 Θ ( n ) \Theta(n) Θ ( n ) 。
递归实现与复杂度
def max_subarray (a, low = 0 , high = None ):
high = len (a) - 1 if high is None else high
if low == high:
return low, high, a[low]
mid = (low + high) // 2
candidates = (
max_subarray(a, low, mid),
max_subarray(a, mid + 1
两个递归调用各处理一半数组,跨中点扫描是线性的,所以:
T ( n ) = 2 T ( n / 2 ) + Θ ( n ) T(n)=2T(n/2)+\Theta(n) T ( n ) = 2 T ( n /2 ) + Θ ( n )
每层的区间总长度都是 n n n ,树高约为 log 2 n \log_2 n log 2 n ,因此:
T ( n ) = Θ ( n log n ) T(n)=\Theta(n\log n) T ( n ) = Θ ( n log n )
下面的结果来自 Python 3.14.6 的实际运行:
最大连续片段: [18, 20, -7, 12]
下标与总和: (7, 10, 43)
买卖日: (7, 11)
全负数: (3, 3, -2)
best_left_sum 和 best_right_sum 必须初始化为负无穷,而不是 0。题目要求非空子数组时,全负数输入的答案应是最大的单个元素;把初值写成 0 会悄悄允许空片段。
A 左半区间的任意前缀与右半区间的任意后缀 B 以中点结尾的最大后缀与从中点右侧开始的最大前缀 C 左半区间最大子数组与右半区间最大子数组 D 两个长度相等的连续片段
分块矩阵乘法:递归不一定更快
两个 n × n n\times n n × n 矩阵相乘时,结果元素为:
C i j = ∑ k = 1 n A i k B k j C_{ij}=\sum_{k=1}^{n}A_{ik}B_{kj} C ij = k = 1 ∑ n A ik
结果共有 n 2 n^2 n 2 个位置,每个位置要累加 n n n 个乘积,直接算法需要 Θ ( n 3 ) \Theta(n^3) Θ ( n 3 ) 时间。
把每个矩阵分成四个半规模方块:
A = ( A 11 A 12 A 21 A 22 ) , B = ( B 11 B 12 B 21 B 22 ) A=
\begin{pmatrix}
A_{11} & A_{12} \\
A_{21} & A_{22}
\end{pmatrix},
\qquad
B=
\begin{pmatrix}
B_{11} & B_{12} \\
B_{21} & B_{22}
\end{pmatrix} A = ( A 11
普通分块乘法得到:
C 11 = A 11 B 11 + A 12 B 21 C 12 = A 11 B 12 + A 12 B 22 C 21 = A 21 B 11 + A 22 B 21 C 22 = A 21 B 12 + A 22 B 22 \begin{aligned}
C_{11}&=A_{11}B_{11}+A_{12}B_{21} \\
C_{12}&=A_{11}B_{12}+A_{12}B_{22} \\
C_{21}&=A_{21}B_{11}+A_{22}B_{21} \\
C_{22}&=A_{21}B_{12}+A_{22}B_{22}
\end{aligned} C 11
四个结果块各需要两次半规模矩阵乘法,总共是 8 次递归乘法。矩阵加法需要遍历 Θ ( n 2 ) \Theta(n^2) Θ ( n 2 ) 个元素,于是递推式为:
T ( n ) = 8 T ( n / 2 ) + Θ ( n 2 ) T(n)=8T(n/2)+\Theta(n^2) T ( n ) = 8 T ( n /2 ) + Θ ( n 2 )
它的解仍是 Θ ( n 3 ) \Theta(n^3) Θ ( n 3 ) 。这说明“写成递归”不会自动改善复杂度。真正影响指数的是递归树的分支数与缩小比例。
实现分块时还要决定是否复制数据。用行列范围表示子矩阵,切分本身可以做到常数时间;即使复制导致本层多出 Θ ( n 2 ) \Theta(n^2) Θ ( n 2 ) 工作,递推式的渐近解仍不会改变,不过实际常数和内存占用会增加。
3 把普通矩阵乘法改写成四分块递归后,时间复杂度会自动降到 Θ(n² log n)。
Strassen:用加减法换掉一次递归乘法
普通分块方案的瓶颈是 8 个递归乘法。Strassen 的做法是先组合输入块,只计算 7 个乘积,再用这些乘积还原四个输出块。
普通分块路径的 8 个乘法节点使递归树每层按 8 倍扩张;下方路径增加矩阵加减组合,把分支数降为 7。
一种常用的七乘积写法是:
P 1 = A 11 ( B 12 − B 22 ) P 2 = ( A 11 + A 12 ) B 22 P 3 = ( A 21 + A 22 ) B 11 P 4 = A 22 ( B 21 − B 11 ) P 5 = ( A 11 + A 22 ) ( B 11 + B 22 ) P 6 = ( A 12 − A 22 ) ( B 21 + B 22
随后重组结果:
C 11 = P 5 + P 4 − P 2 + P 6 C 12 = P 1 + P 2 C 21 = P 3 + P 4 C 22 = P 5 + P 1 − P 3 − P 7 \begin{aligned}
C_{11}&=P_5+P_4-P_2+P_6 \\
C_{12}&=P_1+P_2 \\
C_{21}&=P_3+P_4 \\
C_{22}&=P_5+P_1-P_3-P_7
\end{aligned} C 11
这些加减法处理的仍是半规模矩阵,本层总成本为 Θ ( n 2 ) \Theta(n^2) Θ ( n 2 ) ;递归乘法从 8 次降到 7 次:
T ( n ) = 7 T ( n / 2 ) + Θ ( n 2 ) T(n)=7T(n/2)+\Theta(n^2) T ( n ) = 7 T ( n /2 ) + Θ ( n 2 )
因此:
T ( n ) = Θ ( n log 2 7 ) ≈ Θ ( n 2.807 ) T(n)=\Theta\left(n^{\log_2 7}\right)\approx\Theta(n^{2.807}) T ( n ) = Θ ( n l o g 2 7 ) ≈ Θ ( n
用下面两个矩阵核对公式:
A = ( 1 3 7 5 ) , B = ( 6 8 4 2 ) A=
\begin{pmatrix}
1&3\\
7&5
\end{pmatrix},
\qquad
B=
\begin{pmatrix}
6&8\\
4&2
\end{pmatrix} A = ( 1 7 3 5
Python 3.14.6 的实际输出为:
普通乘法: [[18, 14], [62, 66]]
七个乘积: [6, 8, 72, -10, 48, -12, -84]
Strassen: [[18, 14], [62, 66]]
指数: 2.807355
两条路径给出同一个结果。小矩阵上,额外加减、临时矩阵与内存访问可能让 Strassen 更慢;工程实现通常设置阈值,小规模时切回普通乘法。若矩阵边长不是 2 的幂,可以补零到不小于 n n n 的最近 2 的幂,计算后再裁掉补出的行列。补零后的边长小于 2 n 2n 2 n ,不会改变渐近阶。
4 Strassen 改善渐近复杂度的直接原因是什么?
A 矩阵加法变成常数时间 B 把每层递归乘法从 8 次降到 7 次 C 删除了所有临时矩阵 D 只计算结果矩阵的一半元素
递归树:逐层把成本加起来
递归树把一个递推式画成调用结构。每个节点标记“该子问题本层的非递归成本”,边表示递归产生的子问题。分析时按三步计价:
求第 i i i 层的节点数。
求该层每个节点的规模与单节点成本。
先算层成本,再把所有层与叶子成本相加。
以最大子数组的递推为例,先把常数省略成:
T ( n ) = 2 T ( n / 2 ) + n T(n)=2T(n/2)+n T ( n ) = 2 T ( n /2 ) + n
第 i i i 层有 2 i 2^i 2 i 个节点,每个节点规模为 n / 2 i n/2^i n / 2 i 。每个节点的扫描成本与自身规模成正比,因此第 i i i 层总成本是:
2 i ⋅ n 2 i = n 2^i\cdot\frac{n}{2^i}=n 2 i ⋅ 2 i n = n
节点内黑色数字表示子问题规模,橙色数字表示该节点本层扫描成本。节点数增加与单节点成本下降正好抵消,所以每层总成本保持为 n n n 。
当子问题规模降到 1 时:
n 2 h = 1 \frac{n}{2^h}=1 2 h n = 1
所以树高 h = log 2 n h=\log_2 n h = log 2 n 。每层成本都是 n n n ,共有约 log 2 n \log_2 n log 2 n 层,叶子总成本也是 Θ ( n ) ,最终得到 。
对 n=64 的实际计算显示前六层成本完全相同:
递归树层成本: [(0, 1, 64, 64), (1, 2, 32, 64), (2, 4, 16, 64), (3, 8, 8, 64), (4, 16, 4, 64), (5, 32, 2, 64)]
另一个形状是:
T ( n ) = 3 T ( n / 4 ) + c n 2 T(n)=3T(n/4)+cn^2 T ( n ) = 3 T ( n /4 ) + c n 2
第 i i i 层有 3 i 3^i 3 i 个节点,每个节点的本层成本是 c ( n / 4 i ) 2 c(n/4^i)^2 c ( n / 4 i ) 2 ,因此层成本为:
3 i ⋅ c ( n 4 i ) 2 = c n 2 ( 3 16 ) i 3^i\cdot c\left(\frac{n}{4^i}\right)^2
=cn^2\left(\frac{3}{16}\right)^i 3 i ⋅ c ( 4 i n )
层成本按比例 3 / 16 3/16 3/16 衰减,所有内部层形成收敛的几何级数,总量与根节点的 c n 2 cn^2 c n 2 同阶。因此根部附近主导,总成本为 Θ ( n 2 ) \Theta(n^2) Θ ( n 2 ) 。
对 T ( n ) = T ( n / 3 ) + T ( 2 n / 3 ) + c n T(n)=T(n/3)+T(2n/3)+cn T ( n ) = T ( n /3 ) + T ( 2 n /3 ) + c n 这类不等规模递推,树的叶子深度并不一致。不能把最长路径当成所有分支的高度,再假设每一层始终有完整的 c n cn c 成本。递归树可以帮助形成上界猜想,但严谨结论还应通过代入法或更合适的工具核验。
5 在 T(n)=3T(n/4)+cn² 的递归树中,第 i 层内部节点的总成本是什么?
A cn²(3/16)^i B cn²(16/3)^i C 3cn²/4^i D cn/4^i
主方法:比较递归增长与本层工作
主方法处理固定数量、固定比例子问题的递推:
T ( n ) = a T ( n / b ) + f ( n ) T(n)=aT(n/b)+f(n) T ( n ) = a T ( n / b ) + f ( n )
其中 a ≥ 1 a\ge 1 a ≥ 1 、b > 1 b\gt 1 b > 1 。先计算基准量:
n log b a n^{\log_b a} n l o g b a
它可以看作“只计算叶子数量时得到的规模”。再把 f ( n ) f(n) f ( n ) 与这个基准作多项式级比较。
第三种情况还要求存在常数 c < 1 c\lt 1 c < 1 ,使足够大的 n n n 满足:
a f ( n / b ) ≤ c f ( n ) af(n/b)\le cf(n) a f ( n / b ) ≤ c f ( n )
这个条件保证本层工作沿递归向下按固定比例衰减,不会出现看似根部很大、下层却反常增大的情况。
三个算法怎样落入三种形状
最大子数组的分治算法有 a = 2 a=2 a = 2 、b = 2 b=2 b = 2 、f ( n ) = Θ ( n ) f(n)=\Theta(n) f ( n ) = Θ ( n ) 。基准也是 n log 2 2 = n n^{\log_2 2}=n n ,属于同阶情况:
T ( n ) = Θ ( n log n ) T(n)=\Theta(n\log n) T ( n ) = Θ ( n log n )
普通分块矩阵乘法有 a = 8 a=8 a = 8 、b = 2 b=2 b = 2 、f ( n ) = Θ ( n 2 ) f(n)=\Theta(n^2) f ( n ) = Θ ( n 2 ) 。基准为 n log 2 8 = n ,递归部分多项式级更大:
T ( n ) = Θ ( n 3 ) T(n)=\Theta(n^3) T ( n ) = Θ ( n 3 )
Strassen 有 a = 7 a=7 a = 7 、b = 2 b=2 b = 2 、f ( n ) = Θ ( n 2 ) f(n)=\Theta(n^2) f ( n ) = Θ ( n 2 ) 。基准为 n log 2 7 n^{\log_2 7} ,仍由递归部分主导:
T ( n ) = Θ ( n log 2 7 ) T(n)=\Theta(n^{\log_2 7}) T ( n ) = Θ ( n l o g 2 7 )
不能直接套用的情形
下面两种递推都不适合直接套这版主方法:
T ( n ) = T ( n / 3 ) + T ( 2 n / 3 ) + Θ ( n ) T(n)=T(n/3)+T(2n/3)+\Theta(n) T ( n ) = T ( n /3 ) + T ( 2 n /3 ) + Θ ( n )
它的两个子问题大小不同,无法写成 a a a 个统一的 n / b n/b n / b 。
T ( n ) = 2 T ( n / 2 ) + n log n T(n)=2T(n/2)+n\log n T ( n ) = 2 T ( n /2 ) + n log n
这里的基准是 n n n ,而 n log n n\log n n log n 虽然更大,却没有大出一个 n ε n^\varepsilon n ε 因子,落在同阶情况与多项式级更大情况之间的空档。不能只凭“看起来更大”就选第三种情况。
6 对 T(n)=9T(n/3)+n,主方法给出的紧确界是什么?
A Θ(n) B Θ(n log n) C Θ(n²) D Θ(n² log n)
常见误区与设计检查
误区:只验证了两个递归分支
最大子数组若只比较左、右结果,就会漏掉跨中点片段。设计分治算法时,应先证明所有答案能被一组互斥且完备的情况覆盖,再为每种情况安排求解路径。
误区:基本情形改变了题意
非空最大子数组在全负数输入上必须返回某个负数。把累计和初值设为 0,等价于把空数组偷偷加入候选。基本情形与初始值都要服从问题定义。
误区:把递推式里的分支数吞掉
8 T ( n / 2 ) 8T(n/2) 8 T ( n /2 ) 中的 8 决定树宽,不能因为它是常数就写成 T ( n / 2 ) T(n/2) T ( n /2 ) 。渐近记号可以忽略的是整项外的常数倍,例如 4 n 2 = Θ ( n 2 ) 4n^2=\Theta(n^2) 4 n 2 = Θ ( n 2 ) ,不是递归调用的数量。
误区:主方法只比较“谁更大”
第一、第三种情况要求多项式级差距;第三种还要检查正则条件。若落入两种情况之间的空档,应改用递归树、代入法或更一般的递推分析工具。
一张可复用的检查表
子问题与原问题是否同类,规模是否严格减小?
基本情形是否覆盖最小合法输入?
候选分类是否互斥且没有遗漏?
合并结果是否足以恢复原问题答案?
本层非递归成本 f ( n ) f(n) f ( n ) 是否计算完整?
递推式是否准确保留子问题个数与规模?
所选递推分析方法是否满足适用条件?
代码中的取整、空输入、全负数与非 2 次幂边长是否处理清楚?
7 下列哪些检查能直接发现一个分治算法的结构性错误?
综合练习与解析
练习一:判断递归树由哪一层主导
分析:
T ( n ) = 4 T ( n / 2 ) + n T(n)=4T(n/2)+n T ( n ) = 4 T ( n /2 ) + n
写出第 i i i 层的节点数、单节点非递归成本、层成本和最终紧确界。
显示解析 第 i i i 层有 4 i 4^i 4 i 个节点,每个节点的规模是 n / 2 i n/2^i n / 2 i ,单节点的非递归成本为 n / 2 i n/2^i n / 2 i 。因此层成本是:
练习二:处理非 2 次幂矩阵
要用 Strassen 计算两个 1000 × 1000 1000\times1000 1000 × 1000 矩阵,可以补零到多大?为什么这不会改变 Θ ( n log 2 7 ) \Theta(n^{\log_2 7}) Θ ( n l o g 2 7 ) 的渐近界?
显示解析 不小于 1000 的最近 2 的幂是 1024,所以补成 1024 × 1024 1024\times1024 1024 × 1024 。一般地,若 2 k − 1 < n ≤ 2 k 2^{k-1}\lt n\le 2^k 2 k − 1 < n ≤ 2 k ,补零后的边长 m = 2 k < 2 n m=2^k\lt 2n 。于是:
练习三:识别主方法的空档
判断主方法能否直接处理:
T ( n ) = 2 T ( n / 2 ) + n log n T(n)=2T(n/2)+n\log n T ( n ) = 2 T ( n /2 ) + n log n
显示解析 这里 a = 2 a=2 a = 2 、b = 2 b=2 b = 2 ,基准量是 n log 2 2 = n n^{\log_2 2}=n n l o g 2 2 = 。 比 大,但比值只有 ,对任意固定 都不是 。因此它不满足第三种情况要求的多项式级差距,也不与基准同阶,不能直接套这版主方法。
练习四:从定义检查全负数数组
对数组 [-8, -3, -6, -2, -5, -4],非空最大子数组是什么?若允许空子数组,答案怎样改变?
显示解析 非空定义下,最大子数组是只含 -2 的片段,下标为 (3, 3),和为 -2。若题目明确允许空子数组且规定空数组和为 0,那么空数组优于所有负数片段,答案改为和为 0 的空片段。两种答案都可能合理,关键是实现必须和题目定义一致。
A T(n)=T(n-1)+Θ(1) B T(n)=2T(n/2)+Θ(n) C T(n)=4T(n/2)+Θ(1) D T(n)=T(n/2)+Θ(n²)