FIP W07 · 分治
理论课内容
课程材料:26FIP7 分治算法.pdf
减治与分治
递归是思考和实现子问题的方式;减治与分治描述的是子问题怎样产生、怎样得到原问题的答案。
| 方法 | 子问题 | 例子 |
|---|---|---|
| 减治 | 只做缩小后的一个问题 | 二分查找比较中点后舍弃另一半 |
| 分治 | 多个子问题都要做,再处理它们之间的关系 | 归并排序分别排好两半,再合并 |
递归二分查找
二分查找前提是列表已排序。
def binary_search_rec(lst, target, left=0, right=None):
if right is None:
right = len(lst) - 1
if left > right:
return -1
mid = left + (right - left) // 2
if lst[mid] == target:
return mid
if lst[mid] < target:
return binary_search_rec(lst, target, mid + 1, right)
return binary_search_rec(lst, target, left, mid - 1)e.g. 在[1,3,5,7,9]中找7:先看下标2的5,缩到右半区间[3,4];再看下标3,返回3。每次减半,时间为\(O(\log n)\)。递归调用后的结果直接返回,所以这是尾递归;递归栈为\(O(\log n)\)。
不要为了“切一半”反复写列表切片:lst[:mid]会创建并复制新列表,还要重新换算原列表下标;保留原列表、只改边界更直接。若每层切片,全部复制成本可达\(O(n)\)。
归并排序:先分,再合
只排好左半,整表还不一定有序。归并排序先把列表拆到长度至多为1,回归时相信左右两半都已排好,再用两个指针合并:
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)merge([1,3,5],[2,4,6])依次取1,2,3,4,5,左边耗尽后还须追加右边剩余的[6]。漏掉extend会丢元素。相等时用<=先取左边,这样相等元素的原有顺序不变,归并是稳定排序。
递归树深度约为\(\log_2 n\);每层总共合并\(n\)个元素,所以时间\(O(n\log n)\)。结果缓冲需要\(O(n)\)额外空间;这里的arr[:mid]、arr[mid:]也会复制。下标区间版可省去分割切片,但合并仍需要缓冲。
自底向上的纯循环版
对于归并这种非线性递归,其转化为循环的方式有一点不一样。 可以从长度为\(1\)的有序段开始,两两合并成长度约为\(2,4,8,\ldots\)的有序段。每轮用上面的merge,没有递归调用:
def merge_sort_iter(arr):
result = arr[:]
width = 1
while width < len(result):
merged = []
for start in range(0, len(result), 2 * width):
left = result[start:start + width]
right = result[start + width:start + 2 * width]
merged.extend(merge(left, right))
result = merged
width *= 2
return result两版时间都是\(O(n\log n)\);这个循环版仍通过切片和merged构造新列表,额外空间为\(O(n)\),只是省掉了递归调用栈。
最大子串和
连续子串的最大和可能完全在左边、完全在右边,或跨过中点。前两类由递归给出;第三类必须在当前层计算:从mid-1向左找最大后缀,从mid向右找最大前缀,两者相加。
def max_crossing(arr, lo, mid, hi):
total = best_left = arr[mid - 1]
for i in range(mid - 2, lo - 1, -1):
total += arr[i]
best_left = max(best_left, total)
total = best_right = arr[mid]
for j in range(mid + 1, hi):
total += arr[j]
best_right = max(best_right, total)
return best_left + best_right
def max_subarray(arr, lo=0, hi=None):
if hi is None:
hi = len(arr)
if hi <= lo:
raise ValueError("需要非空区间")
if hi - lo == 1:
return arr[lo]
mid = (lo + hi) // 2
return max(
max_subarray(arr, lo, mid),
max_subarray(arr, mid, hi),
max_crossing(arr, lo, mid, hi),
)例如[-2,3,-1,4]切成[-2,3] | [-1,4],左、右各自最优是\(3\)和\(4\);跨中点为左后缀\(3\)加右前缀\(-1+4=3\),得到\(6\)。这一写法求非空子串,单元素就是Base Case;空列表要另行约定。每层扫描合计\(O(n)\),时间\(O(n\log n)\)。
Kadane算法
如果不必用分治,也可以从左到右扫描。到元素\(a_i\)时,以它结尾的最大子段和要么从\(a_i\)重新开始,要么接在前一个最优后缀后面:
\[ E_i=\max(a_i,E_{i-1}+a_i),\qquad M_i=\max(M_{i-1},E_i) \]
其中\(E_i\)是以\(i\)结尾的最大子串和,\(M_i\)是前\(i\)个位置中出现过的最大值。
def max_subarray_linear(arr):
current = best = arr[0]
for i in range(1, len(arr)):
current = max(arr[i], current + arr[i])
best = max(best, current)
return best对[-2,3,-1,4],current依次为\(-2,3,2,6\),最终返回\(6\)。用首元素初始化,而非初始化为\(0\),才能正确处理全负数列表。只扫描一次,时间\(O(n)\),额外空间\(O(1)\)。
前缀和思路
设\(P_0=0\),\(P_j\)是前\(j\)个元素的和。以第\(j\)个元素结尾的子串和为\(P_j-P_i\)(\(i<j\));要让它最大,就减去此前最小的\(P_i\)。
def max_sum(lst):
min_prefix = 0
prefix = 0
best = lst[0]
for x in lst:
prefix += x
best = max(best, prefix - min_prefix)
min_prefix = min(min_prefix, prefix)
return bestmin_prefix初值为\(P_0=0\),所以子串可以从第一个元素开始。先用旧的min_prefix更新答案,再纳入当前前缀,才能保证\(i<j\)、子段非空;如果反过来,当前前缀可以减去自己,得到空子段的和\(0\),全负数时就会出错。这与Kadane是同一个\(O(n)\)问题的两种视角,额外空间都是\(O(1)\)。
逆序对:合并同时计数
若下标\(i<j\)且\(a_i>a_j\),就有一对逆序。递归分别统计左右内部的c1,c2;合并时若当前右元素小于左元素,左边尚未取走的元素都与它构成逆序,一次增加len(left)-i。
def merge_count(left, right):
result = []
i = j = count = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
count += len(left) - i
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result, count
def inversions(arr):
if len(arr) <= 1:
return arr, 0
mid = len(arr) // 2
left, c1 = inversions(arr[:mid])
right, c2 = inversions(arr[mid:])
merged, c3 = merge_count(left, right)
return merged, c1 + c2 + c3[3,1,4,2]有\((3,1)\)、\((4,2)\)和\((3,2)\),共\(3\)对。相等时取左边,不计逆序;只返回跨半的\(c_3\)则会漏掉左右内部的\(c_1+c_2\)。时间仍是\(O(n\log n)\)。return result, count实际返回元组,调用处可拆成两个变量。
key、lambda与高阶函数
key接收一个函数,先把每个元素映射成比较用的值;它不改变结果里保存的原元素。
max([-7, 4, 2], key=abs) # -7,按绝对值比较
sorted(nodes, key=lambda x: x.freq) # 按 freq 排序已有函数可直接传函数名;只用一次的短规则可用lambda;规则复杂就先用def定义。把函数作为参数传入,属于高阶函数的用法。
杨辉三角:两个递归调用也未必是分治
W6的pascal(n)返回整行;这里的pascal(n,k)只求第\(n\)行第\(k\)个数。两侧为1,中间等于上一行相邻两项之和:
def pascal(n, k, memo=None):
if not 0 <= k <= n:
raise ValueError("需要 0 <= k <= n")
if memo is None:
memo = {}
if k == 0 or k == n:
return 1
key = (n, k)
if key not in memo:
memo[key] = pascal(n - 1, k - 1, memo) + pascal(n - 1, k, memo)
return memo[key]\(\operatorname{pascal}(4,2)=6\)。左右分支会重复遇到同一状态,如pascal(2,1),所以用元组(n,k)作缓存键。这里不是折半处理两段,而是规模每次减\(1\)的重叠子问题;不能因为有两次递归调用就把它当成归并式分治。
递归总结
| 问题 | 缩小方式 | 当前层还要做什么 | 时间 |
|---|---|---|---|
| 二分查找 | 只走一半 | 比较中点 | \(O(\log n)\) |
| 归并排序 | 两半都做 | 合并有序列表 | \(O(n\log n)\) |
| 最大子段和 | 两半都做 | 扫描跨中点情况 | \(O(n\log n)\) |
| 逆序对 | 两半都做 | 合并时计跨半逆序 | \(O(n\log n)\) |
| 格子版杨辉三角 | 两个重叠状态,各减1 |
两值相加、记忆化 | 不是折半分治 |
研讨课内容
课程材料:7 分治算法-研讨课.pdf
归并与快排:工作在不同阶段
手推merge([1,3],[2,4]):依次取1,2,3后,左边已空;必须追加剩余的4。归并是先排好两半,再合并;快排则是先按基准分区,再递归排两侧。
| 归并排序 | 快速排序 | |
|---|---|---|
| 当前层重点 | 合并两个已排序的列表 | 选pivot,把小于它的放左边,其余放右边 |
| 递归后 | 做merge |
直接拼接两侧和基准 |
| 容易出错 | 忘记追加剩余段 | 基准又进入子问题,或漏掉相等元素 |
简洁快速排序
以首元素为pivot。arr[1:]排除基准;左边取< pivot,右边取>= pivot,相等元素才不会丢:
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[0]
left = [x for x in arr[1:] if x < pivot]
right = [x for x in arr[1:] if x >= pivot]
return quick_sort(left) + [pivot] + quick_sort(right)[3,1,4,1,5,9,2,6]以3为基准时,左边是[1,1,2],右边是[4,5,9,6];右边仍需递归排序。验证结果时除了看是否有序,还要确认元素和出现次数都没有变化。
首元素基准的退化与三路分区
对已排序的\([1,2,\ldots,n]\),首元素每次都是最小值:左边为空,右边只少一个元素。于是递归深度从平均的\(O(\log n)\)变成\(O(n)\),时间最坏\(O(n^2)\),Python中还可能触及递归深度限制。随机选基准能降低固定坏输入的影响,但不能保证次次平衡。
重复值很多时,把== pivot单独放在中间,相等项无需再递归:
def quick_sort_3way(arr):
if len(arr) <= 1:
return arr
pivot = arr[0]
less = [x for x in arr if x < pivot]
equal = [x for x in arr if x == pivot]
greater = [x for x in arr if x > pivot]
return quick_sort_3way(less) + equal + quick_sort_3way(greater)例如[8,3,5,1,7,2,9,4]以5为基准,分为[3,1,2,4] | 5 | [8,7,9]。简洁版快排的列表推导式和拼接会创建新列表;它只是为了把分区思路写清楚,不等同于常见的原地快排。归并时间稳定在\(O(n\log n)\);快排平均\(O(n\log n)\)、最坏\(O(n^2)\)。
检查分治代码,重点看三处:Base Case能否结束;子问题是否真的缩小;当前层有没有漏掉必须处理的部分——归并的剩余段、最大子段和的跨中点情况、快排的相等元素与基准排除。
评论
评论暂未开放。