分治算法完全指南
建议先掌握递归算法,分治是递归的重要应用。预计学习时间 1-2 周。
一、什么是分治算法? 🎯
分治算法(Divide and Conquer)是一种将复杂问题分解为更小的相同子问题,分别解决后再合并结果的算法设计范式。
核心思想
分治 vs 其他策略
| 策略 | 子问题关系 | 典型算法 | 适用场景 |
|---|---|---|---|
| 分治 | 独立 | 归并排序、快速排序 | 子问题独立 |
| 动态规划 | 重叠 | 背包问题、最短路径 | 子问题重叠 |
| 贪心 | 逐步选择 | Dijkstra、霍夫曼编码 | 满足贪心性质 |
| 回溯 | 搜索空间 | N皇后、数独 | 组合优化 |
关键区别
- 分治:子问题相互独立,无重叠
- DP:子问题有重叠,需要记忆化
- 如果子问题重叠却用分治,会导致重复计算,效率低下
二、分治算法模板 📐
通用框架
def divide_and_conquer(problem):
"""
分治算法通用模板
:param problem: 待解决的问题
:return: 问题的解
"""
# 1. 基本情况:问题足够小,直接求解
if is_base_case(problem):
return solve_base_case(problem)
# 2. 分解:将问题分解为子问题
sub_problems = divide(problem)
# 3. 解决:递归求解子问题
sub_results = [divide_and_conquer(p) for p in sub_problems]
# 4. 合并:合并子问题的解
result = combine(sub_results)
return result
模板解析
三、经典分治算法 🏆
1. 归并排序
归并排序是分治算法的经典代表,完美体现了"分-治-合"的思想。
def merge_sort(arr):
"""
归并排序 - 分治算法
:param arr: 待排序数组
:return: 排序后的数组
"""
# 基本情况:数组长度为 0 或 1
if len(arr) <= 1:
return arr
# 分解:找到中点,分成两半
mid = len(arr) // 2
left = arr[:mid]
right = arr[mid:]
# 解决:递归排序两半
left_sorted = merge_sort(left)
right_sorted = merge_sort(right)
# 合并:合并两个有序数组
return merge(left_sorted, right_sorted)
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
# 测试
arr = [38, 27, 43, 3, 9, 82, 10]
print(merge_sort(arr)) # [3, 9, 10, 27, 38, 43, 82]
性能分析:
- 时间复杂度:O(n log n),每层 O(n),共 log n 层
- 空间复杂度:O(n),需要额外空间存储临时数组
- 稳定性:稳定排序
2. 快速排序
快速排序也是分治算法,但它的"合并"步骤非常简单(不需要额外操作)。
def quick_sort(arr, low=0, high=None):
"""
快速排序 - 分治算法
:param arr: 待排序数组
:param low: 起始索引
:param high: 结束索引
:return: 排序后的数组
"""
if high is None:
high = len(arr) - 1
if low < high:
# 分解:分区操作
pivot_index = partition(arr, low, high)
# 解决:递归排序左右两部分
quick_sort(arr, low, pivot_index - 1)
quick_sort(arr, pivot_index + 1, high)
return arr
def partition(arr, low, high):
"""分区操作:将小于基准的放左边,大于基准的放右边"""
pivot = arr[high] # 选择最后一个元素作为基准
i = low - 1
for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
# 将基准放到正确位置
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
# 测试
arr = [10, 7, 8, 9, 1, 5]
print(quick_sort(arr)) # [1, 5, 7, 8, 9, 10]
性能对比:
当数组已经有序且每次都选最后一个元素作为基准时,快速排序退化为 O(n²)。
:
- 随机选择基准
- 三数取中法
- 小数组使用插入排序
3. 二分搜索
二分搜索是分治思想的简化版,每次将搜索范围减半。
def binary_search(arr, target, low=0, high=None):
"""
二分搜索 - 分治算法
:param arr: 有序数组
:param target: 目标值
:param low: 左边界
:param high: 右边界
:return: 目标值的索引,未找到返回 -1
"""
if high is None:
high = len(arr) - 1
# 基本情况:搜索范围为空
if low > high:
return -1
# 分解:计算中间位置
mid = low + (high - low) // 2
# 解决:根据比较结果决定搜索哪一半
if arr[mid] == target:
return mid
elif arr[mid] < target:
# 搜索右半部分
return binary_search(arr, target, mid + 1, high)
else:
# 搜索左半部分
return binary_search(arr, target, low, mid - 1)
# 测试
arr = [1, 3, 5, 7, 9, 11, 13]
print(binary_search(arr, 7)) # 3
print(binary_search(arr, 6)) # -1
时间复杂度:O(log n),每次减半
4. 大整数乘法(Karatsuba 算法)
传统乘法需要 O(n²) 时间,Karatsuba 算法通过分治将复杂度降到 O(n^1.585)。
def karatsuba(x, y):
"""
Karatsuba 大整数乘法
:param x, y: 两个大整数
:return: 乘积
"""
# 基本情况:数字足够小,直接相乘
if x < 10 or y < 10:
return x * y
# 计算数字位数
n = max(len(str(x)), len(str(y)))
m = n // 2
# 分解:将数字分成高位和低位
high_x, low_x = divmod(x, 10**m)
high_y, low_y = divmod(y, 10**m)
# 解决:递归计算三个乘积
z0 = karatsuba(low_x, low_y)
z1 = karatsuba(high_x + low_x, high_y + low_y)
z2 = karatsuba(high_x, high_y)
# 合并:组合结果
return z2 * 10**(2*m) + (z1 - z2 - z0) * 10**m + z0
# 测试
print(karatsuba(1234, 5678)) # 7006652
print(1234 * 5678) # 7006652(验证)
优势:对于非常大的整数,比传统乘法快得多
四、分治算法的性能分析 📊
主定理(Master Theorem)
主定理用于分析分治算法的时间复杂度。
对于递归式:T(n) = aT(n/b) + f(n)
其中:
- a:子问题数量
- b:子问题规模缩小倍数
- f(n):分解和合并的代价
实例分析
| 算法 | 递归式 | a | b | f(n) | 时间复杂度 |
|---|---|---|---|---|---|
| 归并排序 | T(n) = 2T(n/2) + O(n) | 2 | 2 | O(n) | O(n log n) |
| 快速排序 | T(n) = 2T(n/2) + O(n) | 2 | 2 | O(n) | O(n log n) 平均 |
| 二分搜索 | T(n) = T(n/2) + O(1) | 1 | 2 | O(1) | O(log n) |
| Karatsuba | T(n) = 3T(n/2) + O(n) | 3 | 2 | O(n) | O(n^1.585) |
五、分治的应用场景 💼
场景一:大规模数据处理
MapReduce 框架本质上就是分治思想:
- ✅ Map 阶段:分解数据,并行处理
- ✅ Shuffle 阶段:整理中间结果
- ✅ Reduce 阶段:合并结果
场景二:计算机图形学
- 光线追踪:将屏幕分成小块,并行渲染
- 分形生成:递归细分几何形状
- 图像压缩:四叉树分解
场景三:数值计算
- 快速傅里叶变换(FFT):O(n log n) 计算离散傅里叶变换
- 矩阵乘法:Strassen 算法,O(n^2.807)
- 多项式乘法:使用 FFT 加速
def fft_basic(concept_only):
"""
快速傅里叶变换(概念示例)
将 DFT 分解为偶数和奇数部分
X[k] = E[k] + W_N^k * O[k]
X[k+N/2] = E[k] - W_N^k * O[k]
其中:
- E[k] 是偶数部分的 DFT
- O[k] 是奇数部分的 DFT
- W_N^k 是旋转因子
"""
pass # 实际实现较复杂,此处仅展示思路
场景四:最近点对问题
问题:在平面上的 n 个点中,找出距离最近的两个点。
暴力解法:O(n²),检查所有点对
分治解法:O(n log n)
import math
def closest_pair(points):
"""
最近点对问题 - 分治算法
:param points: 点列表 [(x, y), ...]
:return: 最近距离
"""
# 按 x 坐标排序
points.sort()
def divide_and_conquer(pts):
n = len(pts)
# 基本情况:点数少,暴力计算
if n <= 3:
min_dist = float('inf')
for i in range(n):
for j in range(i+1, n):
dist = math.sqrt((pts[i][0]-pts[j][0])**2 +
(pts[i][1]-pts[j][1])**2)
min_dist = min(min_dist, dist)
return min_dist
# 分解:按 x 坐标分成两半
mid = n // 2
mid_point = pts[mid]
# 解决:递归求解左右两部分
left_min = divide_and_conquer(pts[:mid])
right_min = divide_and_conquer(pts[mid:])
# 合并:检查跨越中线的点对
min_dist = min(left_min, right_min)
# 只考虑中线附近 strip 区域内的点
strip = [p for p in pts if abs(p[0] - mid_point[0]) < min_dist]
strip.sort(key=lambda p: p[1]) # 按 y 排序
# 检查 strip 中的点对(最多检查 7 个后续点)
for i in range(len(strip)):
for j in range(i+1, min(i+8, len(strip))):
if strip[j][1] - strip[i][1] >= min_dist:
break
dist = math.sqrt((strip[i][0]-strip[j][0])**2 +
(strip[i][1]-strip[j][1])**2)
min_dist = min(min_dist, dist)
return min_dist
return divide_and_conquer(points)
# 测试
points = [(2, 3), (12, 30), (40, 50), (5, 1), (12, 10), (3, 4)]
print(f"最近距离: {closest_pair(points):.2f}")
六、分治的优缺点 ⚖️
分治算法的子问题相互独立,天然适合并行计算。
应用场景:
- 多核 CPU 并行处理
- GPU 大规模并行
- 分布式计算集群
对于许多问题,分治能显著降低时间复杂度。
例子:
- 排序:从 O(n²) 降到 O(n log n)
- 乘法:从 O(n²) 降到 O(n^1.585)
- 搜索:从 O(n) 降到 O(log n)
递归调用有额外的函数调用开销和栈空间消耗。
优化方法:
- 小问题使用迭代
- 尾递归优化(某些语言支持)
- 手动管理栈
相比简单算法,分治的实现通常更复杂。
建议:
- 先写伪代码理清思路
- 从小规模开始测试
- 添加详细的注释
七、分治 vs 动态规划 🔍
虽然都使用递归,但两者有本质区别:
| 特性 | 分治 | 动态规划 |
|---|---|---|
| 子问题关系 | 独立 | 重叠 |
| 是否需要记忆化 | 否 | 是 |
| 典型问题 | 归并排序、快速排序 | 背包问题、最短路径 |
| 时间复杂度 | 通常较低 | 可能较高(但避免重复) |
| 空间复杂度 | O(log n) 栈空间 | O(n) 表格空间 |
问自己:子问题是否会被重复计算?
- 是 → 用动态规划
- 否 → 用分治
八、练习与挑战 🏆
- ✅ 实现归并排序和快速排序
- ✅ 理解主定理的应用
- ✅ 解决最近点对问题
- ✅ 完成 LeetCode 分治专题
- ✅ 尝试实现 FFT 或 Strassen 算法
推荐练习题
LeetCode 53. 最大子数组和
给定一个整数数组,找到一个具有最大和的连续子数组。 难度:⭐ 中等 可使用分治或 DP
LeetCode 23. 合并K个升序链表
合并 k 个升序链表为一个升序链表。 难度:⭐⭐ 困难 分治思想:两两合并
LeetCode 932. 漂亮数组
对于某些固定的 N,如果数组 A 是整数 1, 2, ..., N 的一个排列,且满足特定条件,则称其为漂亮数组。 难度:⭐⭐ 中等 经典分治问题
LeetCode 315. 计算右侧小于当前元素的个数
给定一个整数数组,返回一个新数组,其中每个元素表示原数组对应位置右侧小于该元素的个数。 难度:⭐⭐ 困难 可使用归并排序思想
九、学习路径建议 🗺️
十、总结 🎯
分治算法是解决许多高效算法的基础,掌握它对提升算法能力至关重要。