分治法
分治法把问题分成若干同型子问题,分别求解后再合并。它和递归经常一起出现:递归是执行方式,分治是“怎么分、怎么合”的结构。本页给出分治的适用条件,并用寻找最小值、同时求最值、归并排序和二进制加法说明分解与合并如何落地。递归本身的写法见递归;子问题大量重叠时,应改用动态规划。
定义
分治法把复杂问题分解成两个或更多相同或相似的子问题,直到子问题可以简单地直接求解,原问题的解由子问题的解合并而成。一次分治包含三步:
- 分解:把规模为 的实例划分成若干规模更小的同类实例。
- 求解:对子问题递归使用同一方法;规模足够小时直接求解。
- 合并:把子问题的解组合成原问题的解。
这三步描述的是算法结构,不是代码模板。有的问题合并只需一次比较,有的问题需要按顺序拼接整段结果;合并方式不同,分治的收益也不同。
适用条件
分治能成立,通常需要同时满足以下条件:
- 子问题与原问题同型:可以用同一个函数处理,否则只是普通的任务拆分。
- 子问题相互独立:同一个子问题不会被重复求解。若子问题大量重叠,分治会反复计算同一批状态,应改用动态规划。
- 规模确实缩小:分解后的每个子问题都严格小于原问题,递归才能终止。
- 合并的代价可控:如果合并比重新计算还昂贵,分解就没有收益。
与递归、动态规划的关系
分治描述“怎么分解、怎么合并”,递归是常见的实现方式;两者不是同义词。动态规划也常写成递推式,但它会保存子问题的答案。判断时先看子问题是否重叠:重叠就用动态规划,不重叠再考察分治的合并是否划算。
代价的估计
分治不保证更快。寻找最小值无论用循环还是分治,都至少检查每个元素一次;归并排序的收益来自“合并两个有序列表”这一步,代价是 的时间和 的额外空间。分析时分别估计分解、递归调用和合并的工作量,不要只数递归调用的层数。
例子
寻找最小值:循环扫描
def M(a): m = a[0] for i in range(1, len(a)): if a[i] < m: m = a[i] return m
a = [4, 1, 3, 5]print(M(a))循环维护“目前已扫描部分的最小值”,每读入一个元素比较一次。对 个元素需要 次比较。
寻找最小值:线性递归
def M(a): print(a) if len(a) == 1: return a[0] return min(a[len(a) - 1], M(a[0 : len(a) - 1]))
L = [4, 1, 3, 5]print(M(L))每层把最后一个元素与“前面部分的最小值”比较。print(a) 用来观察每层处理的区间如何缩短,实际使用时可以删除。这个写法仍然是线性递归:每层只调用一次自身,比较次数与循环版相同。
寻找最小值:二分递归
def M(a): if len(a) == 1: return a[0] return min(M(a[0 : len(a) // 2]), M(a[len(a) // 2 : len(a)]))
L = [4, 1, 3, 5]print(M(L))把区间从中间分成两半,分别求最小值,再对两个结果取较小者。递归式为 ,展开后仍是 次比较;分治在这里没有减少比较次数,只是把“扫描”换成了“分解与合并”。切片会复制子列表,额外空间也更多。
三种写法解决的是同一个问题,差异在结构而不在结果。选择哪种写法要看后续是否需要并行、是否需要保留子问题的解,而不是看“分治”这个名字。
同时寻找最小值和最大值
分别调用两次“找最小值”需要约 次比较。把区间分成两半,让每个子问题同时返回最小值和最大值,可以在合并时把两个量一起处理,而不必分别扫描两遍。
A = [3, 8, 9, 4, 10, 5, 1, 17]
def Smin_max(a): if len(a) == 1: return (a[0], a[0]) elif len(a) == 2: return (min(a), max(a)) m = len(a) // 2 lmin, lmax = Smin_max(a[:m]) rmin, rmax = Smin_max(a[m:]) return min(lmin, rmin), max(lmax, rmax)
print("Minimum and Maximum:%d,%d" % (Smin_max(A)))基线有两种规模:一个元素时最小值和最大值都是它本身;两个元素时直接调用min和max得到两者。合并时左、右两半各自已经给出了一对值,只需再比较两个最小值、两个最大值各一次。
归并排序
归并排序先递归地把列表分成两半,分别排序,再用归并操作把两个有序列表合并。其中的merge定义在递归的归并操作中。
def msort(L): k = len(L) if k == 0: return L if k == 1: return L X1 = L[0 : k // 2] X2 = L[k // 2 : k] X1 = msort(X1) X2 = msort(X2) return merge(X1, X2)
print(msort([3, 1, 2]))长度为0或1的列表已经有序,直接返回。对长度 的列表,分解出两个规模约为 的子列表,递归排序后合并。设合并的代价为 ,则总时间为 ;归并需要 的辅助空间。
二进制加法
用True/False的列表表示二进制数,列表首元素是高位。全加器FA接收两个位和低位进位,输出本位和与向高位的进位。
def FA(a, b, c): # Full adder carry = (a and b) or (b and c) or (a and c) sum = ( (a and b and c) or (a and (not b) and (not c)) or ((not a) and b and (not c)) or ((not a) and (not b) and c) ) return carry, sum分治版先把两个数补成等长,再从中间切开。右半部分是低位,必须先算,因为它产生的进位要传给左半部分(高位)。
def add_divide(x, y, c=False): # x、y是True/False列表,c是低位进位 # 返回向更高位的进位和x+y的结果列表 while len(x) < len(y): x = [False] + x while len(y) < len(x): y = [False] + y if len(x) == 1: ctemp, stemp = FA(x[0], y[0], c) return (ctemp, [stemp]) if len(x) == 0: return c, [] c1, s1 = add_divide(x[len(x) // 2 : len(x)], y[len(y) // 2 : len(y)], c) c2, s2 = add_divide(x[0 : len(x) // 2], y[0 : len(y) // 2], c1) # 依赖低位进位 return (c2, s2 + s1)计算过程可以分成三步:
-
补齐长度
在较短的数前面补
False(高位补0),使两个列表等长。这样切分出的左右两半位数一致。 -
先算低位,再算高位
右半部分整体是低位,先递归计算,得到本位结果和向高位的进位;把该进位作为参数传给左半部分的递归调用。两个子问题的计算有先后依赖,不能交换顺序。
-
拼接结果
左半部分返回的进位是最终向更高位的进位;把左半部分的结果(高位)与右半部分的结果(低位)按顺序拼接,得到完整的结果列表。
例如add_divide([True, False, True], [True, True]) 计算 ,返回 (True, [False, False, False]):结果列表加上最高位的进位是1000,即十进制的8。
交互探索
对两段已排序的数据逐步执行合并,比较两段首元素并保持输出有序。
两段有序数据怎样合并?
逐步选择较小的首元素,合并两个有序序列。
正在准备演示…
练习
分治法的一次完整过程按什么顺序进行?
参考解答
先把原问题分成子问题,再递归求解子问题,最后把子问题的解合并成原问题的解。
对
L = [4, 1, 3, 5]调用分治版的M(L),返回值是 。参考解答
二分递归只是在比较两个子区间的最小值,结果与循环版相同,都是列表中的最小值
1。对
A = [3, 8, 9, 4, 10, 5, 1, 17]调用Smin_max(A),返回值是:参考解答
合并时左、右子区间分别返回各自的最小值和最大值,再对两个最小值取
min、两个最大值取max,得到(1, 17)。msort([3, 1, 2])的输出是:参考解答
列表分成
[3]和[1, 2];[1, 2]再分成两个单元素列表并合并为[1, 2];最后merge([3], [1, 2])得到[1, 2, 3]。add_divide([True, False, True], [True, True])的结果表示的十进制数是 。参考解答
列表
[True, False, True]是二进制101,即5;[True, True]补齐为011,即3;返回的进位与结果列表合起来是1000,即8。下列哪种情形最不适合继续使用分治法?
参考解答
子问题重叠时,分治会重复计算同一批状态,应该保存子问题的答案,改用动态规划。其余三项都是分治的有利条件。