分治、贪心、回溯、动态规划
它们解决问题的方式完全不同,但本质上都是在想办法把一个复杂问题变简单。
一、分治:把大问题拆成小问题
**分治(Divide and Conquer)**的核心思想很简单:
把一个大问题拆成多个规模更小、相互独立的子问题,分别解决,再合并结果。
基本过程:
markdown
大问题
↓
拆分
↓
子问题1 子问题2 子问题3
↓ ↓ ↓
分别解决
↓ ↓ ↓
合并结果
经典例子
归并排序就是典型的分治算法。
css
[8, 4, 5, 7, 1, 3, 6, 2]
↓ 分
[8,4,5,7] [1,3,6,2]
↓ ↓
[4,8] [5,7] [1,3] [2,6]
↓ 合并
[1,2,3,4,5,6,7,8]
归并排序不断把数组拆成更小的部分,然后将排序后的结果合并。
时间复杂度:
scss
O(n log n)
常见分治算法
- 归并排序
- 快速排序
- 二分查找
- 大整数乘法
- 最近点对问题
关键词:拆分 + 独立解决 + 合并
二、贪心:每一步都选择当前最优
**贪心(Greedy)**的思想是:
每一步都选择当前看起来最好的方案,希望最终得到全局最优解。
例如:
markdown
有 10 个活动
当前选择结束时间最早的活动
↓
再选择下一个能参加的活动
↓
继续选择
↓
得到最多的活动数量
经典问题是活动选择问题。
假设有很多活动,每个活动都有开始时间和结束时间,希望参加尽可能多的活动。
贪心策略:
每次选择结束时间最早的活动。
为什么?
因为结束得越早,给后面的活动留下的时间越多。
贪心的特点
贪心并不是"看到最优就选"。
真正重要的是:
当前的最优选择能够保证最终得到全局最优解。
例如:
- 活动选择
- 区间调度
- 霍夫曼编码
- 最小生成树
- Dijkstra(非负权图)
关键词:局部最优 → 全局最优
不过需要注意:
不是所有问题都适合贪心。
三、回溯:不断尝试,走不通就退回来
**回溯(Backtracking)**可以理解成:
做选择 → 继续尝试 → 如果发现走不通,就撤销选择,换一条路。
它通常会构建一棵决策树。
例如全排列:
css
[]
/ | \
1 2 3
/ \ ...
12 13
↓
123
代码通常长这样:
go
func backtrack(path []int) {
if 满足条件 {
保存结果
return
}
for _, choice := range choices {
if 不能选择 {
continue
}
path = append(path, choice)
backtrack(path)
// 撤销选择
path = path[:len(path)-1]
}
}
这里最关键的就是:
选择
↓
递归
↓
撤销选择
经典问题
- 全排列
- 组合
- 子集
- N 皇后
- 数独
- 迷宫
- 组合求和
回溯的本质其实就是:
暴力搜索 + 剪枝
如果搜索空间非常大,通常需要通过剪枝减少无效搜索。
关键词:尝试 + 递归 + 撤销
四、动态规划:把重复计算保存下来
**动态规划(Dynamic Programming,DP)**主要解决的是:
一个大问题可以拆成很多子问题,而且这些子问题会重复出现。
例如经典的斐波那契数列:
scss
f(n) = f(n-1) + f(n-2)
如果直接递归:
scss
f(5)
/ \
f(4) f(3)
/ \ / \
f(3) f(2) f(2) f(1)
可以发现:
scss
f(3)
f(2)
被重复计算了。
动态规划的思路就是:
计算过一次,就保存下来,下次直接使用。
例如:
css
dp[0] = 0
dp[1] = 1
for i := 2; i <= n; i++ {
dp[i] = dp[i-1] + dp[i-2]
}
这样时间复杂度从递归的指数级降低到:
scss
O(n)
五、四种算法到底有什么区别?
可以用一句话理解:
| 算法思想 | 核心思路 |
|---|---|
| 分治 | 把大问题拆成小问题 |
| 贪心 | 每一步选择当前最优 |
| 回溯 | 不断尝试,走不通就撤销 |
| 动态规划 | 保存重复子问题的结果 |
再换一种方式:
分治
大问题
↓
拆成独立的小问题
↓
分别解决
↓
合并
贪心
当前状态
↓
选择当前最优
↓
继续
回溯
当前状态
↓
做一个选择
↓
继续搜索
↓
不行?
↓
撤销选择
动态规划
大问题
↓
拆成子问题
↓
发现子问题重复
↓
保存结果
↓
避免重复计算
六、分治和动态规划最容易混淆
两者都是"把大问题拆成小问题",但有一个非常重要的区别:
分治
子问题通常是相互独立的。
例如归并排序:
左半部分
右半部分
左边怎么排序,不影响右边。
动态规划
子问题通常存在重复计算和依赖关系。
例如:
scss
f(5)
├── f(4)
│ ├── f(3)
│ └── f(2)
└── f(3)
f(3) 被重复使用。
所以可以简单记:
独立子问题 → 分治
重复子问题 → 动态规划
七、回溯和动态规划也经常一起出现
回溯是在:
搜索所有可能性
动态规划是在:
避免重复搜索相同状态
例如一个问题可以先写成回溯:
css
选择 A
选择 B
选择 C
...
如果发现:
很多不同路径
最终都到达同一个状态
那么就可以考虑:
这个状态能不能缓存?
如果可以,就可能从"回溯搜索"进一步优化成"动态规划"。
八、如何判断使用哪一种?
遇到算法题时,可以按照下面的思路判断:
① 能不能拆成独立的子问题?
可以 → 考虑分治
例如:
归并排序
快速排序
二分查找
② 每一步是否存在一个可靠的局部最优选择?
是 → 考虑贪心
例如:
活动选择
区间调度
最小生成树
③ 是否需要枚举大量可能的选择?
是 → 考虑回溯
例如:
全排列
N 皇后
数独
组合问题
④ 子问题是否存在大量重复?
是 → 考虑动态规划
例如:
背包问题
最长公共子序列
最长递增子序列
编辑距离
股票问题
九、最后总结
这四种算法思想可以这样记:
分治:拆
贪心:选
回溯:试
动态规划:存
它们并不是完全独立的。
很多复杂算法题实际上会组合使用:
回溯 + 剪枝
分治 + 递归
动态规划 + 贪心思想
分治 + 动态规划
真正学习算法时,不应该只记"某道题使用什么算法",而应该理解:
为什么这个问题适合这种算法思想。
掌握了这一点,遇到新的算法题时,才有能力自己判断解题方向。
如果你准备接着写算法系列,我建议下一篇可以专门写 「回溯 vs 动态规划:从暴力搜索到状态转移」,这两个最值得放在一起对比。