1动态规划 (Dynamic Programming)
📖 什么是动态规划?
动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解成子问题,并保存子问题的解(避免重复计算)来解决问题的算法思想。
🎯 解决什么问题?
动态规划主要用于解决具有重叠子问题和最优子结构的问题:
| 特征 | 说明 | 示例 |
|---|---|---|
| 重叠子问题 | 子问题被重复计算多次 | 斐波那契数列:F(n) = F(n-1) + F(n-2) |
| 最优子结构 | 问题的最优解包含子问题的最优解 | 最短路径:A→C的最短路径包含A→B的最短路径 |
| 无后效性 | 当前状态确定后,之后的决策只受当前状态影响 | 背包问题:选择第i个物品后,只影响剩余容量 |
🛠️ 解题步骤
- 定义状态:确定DP数组的含义,dp[i]或dp[i][j]表示什么
- 状态转移方程:找出当前状态与之前状态的关系
- 初始化:确定边界条件,dp[0]、dp[0][0]等
- 计算顺序:确定是从小到大还是从大到小计算
- 返回结果:根据DP数组得到最终答案
📝 经典算法题
问题描述:求第n个斐波那契数,F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)
- 定义状态:dp[i]表示第i个斐波那契数
- 状态转移:dp[i] = dp[i-1] + dp[i-2]
- 初始化:dp[0]=0, dp[1]=1
function fib(n) {
if (n < 2) return n;
let prev = 0, curr = 1;
for (let i = 2; i <= n; i++) {
[prev, curr] = [curr, prev + curr];
}
return curr;
}
// 时间复杂度:O(n),空间复杂度:O(1)
问题描述:假设你正在爬楼梯,需要n阶才能到达楼顶。每次可以爬1或2个台阶,有多少种不同的方法可以爬到楼顶?
- 到第i阶的方法数 = 到第i-1阶的方法数 + 到第i-2阶的方法数
- 因为只能从i-1(爬1阶)或i-2(爬2阶)到达i
- 这就是斐波那契数列的变种
function climbStairs(n) {
if (n <= 2) return n;
let dp = new Array(n + 1);
dp[1] = 1;
dp[2] = 2;
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
问题描述:有n件物品和一个容量为W的背包。第i件物品的重量是w[i],价值是v[i]。每件物品只能选一次,求最大总价值。
- 定义状态:dp[i][j]表示前i个物品,容量为j时的最大价值
- 状态转移:
- 不选第i个物品:dp[i][j] = dp[i-1][j]
- 选第i个物品(需要j >= w[i]):dp[i][j] = dp[i-1][j-w[i]] + v[i]
- 取两者最大值
- 空间优化:可以用一维数组从后往前遍历
function knapsack(W, weights, values) {
let n = weights.length;
let dp = new Array(W + 1).fill(0);
for (let i = 0; i < n; i++) {
for (let j = W; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[W];
}
// 时间复杂度:O(nW),空间复杂度:O(W)
问题描述:给定两个字符串text1和text2,返回这两个字符串的最长公共子序列的长度。
- 定义状态:dp[i][j]表示text1[0:i]和text2[0:j]的LCS长度
- 状态转移:
- 如果text1[i-1] == text2[j-1]:dp[i][j] = dp[i-1][j-1] + 1
- 否则:dp[i][j] = max(dp[i-1][j], dp[i][j-1])
function longestCommonSubsequence(text1, text2) {
let m = text1.length, n = text2.length;
let dp = new Array(m + 1).fill(0).map(function() {
return new Array(n + 1).fill(0);
});
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (text1[i - 1] === text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
问题描述:给定一个未排序的整数数组,找到最长递增子序列的长度。
- 方法1(O(n²)):dp[i]表示以nums[i]结尾的最长递增子序列长度
- 对于每个i,遍历j < i,如果nums[j] < nums[i],则dp[i] = max(dp[i], dp[j]+1)
- 方法2(O(nlogn)):维护一个递增数组,用二分查找优化
function lengthOfLIS(nums) {
let n = nums.length;
let dp = new Array(n).fill(1);
let maxLen = 1;
for (let i = 1; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
问题描述:给定两个单词word1和word2,计算出将word1转换成word2所使用的最少操作数。你可以对一个单词进行三种操作:插入、删除、替换。
- 定义状态:dp[i][j]表示word1[0:i]转换成word2[0:j]的最少操作数
- 状态转移:
- 如果word1[i-1] == word2[j-1]:dp[i][j] = dp[i-1][j-1]
- 否则:dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
function minDistance(word1, word2) {
let m = word1.length, n = word2.length;
let dp = new Array(m + 1).fill(0).map(function() {
return new Array(n + 1).fill(0);
});
// 初始化
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i - 1] === word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(
dp[i - 1][j], // 删除
dp[i][j - 1], // 插入
dp[i - 1][j - 1] // 替换
);
}
}
}
return dp[m][n];
}
2贪心算法 (Greedy Algorithm)
📖 什么是贪心算法?
贪心算法(Greedy Algorithm)是一种在每一步选择中都采取当前状态下的最优选择(局部最优),从而希望导致全局最优的算法。
🎯 解决什么问题?
贪心算法适用于具有贪心选择性质和最优子结构的问题:
| 特征 | 说明 | 示例 |
|---|---|---|
| 贪心选择性质 | 全局最优解可以通过局部最优选择达到 | 活动选择:每次选结束最早的活动 |
| 最优子结构 | 问题的最优解包含子问题的最优解 | 最小生成树:去掉一条边后子问题仍最优 |
| 无后效性 | 当前选择不影响之后的选择 | 哈夫曼编码:每次合并最小的两个节点 |
🛠️ 解题步骤
- 问题分解:将问题分解成多个步骤
- 贪心策略:确定每一步的贪心选择标准(什么是最好的选择)
- 证明正确性:证明贪心选择性质成立(这一步很重要!)
- 实现算法:按照贪心策略编写代码
📝 经典算法题
问题描述:给定n个活动的开始时间和结束时间,选择最多的互不重叠的活动。
- 按照活动的结束时间排序
- 每次选择结束时间最早且与已选活动不冲突的活动
- 为什么有效?选择结束最早的活动,给后面留下更多时间
function activitySelection(activities) {
// activities: [{start: 1, end: 4}, {start: 3, end: 5}, ...]
activities.sort(function(a, b) { return a.end - b.end; });
let selected = [];
let lastEnd = 0;
for (let act of activities) {
if (act.start >= lastEnd) {
selected.push(act);
lastEnd = act.end;
}
}
return selected;
}
问题描述:每个孩子有一个胃口值g[i],每个饼干有一个尺寸s[j]。如果饼干尺寸>=孩子胃口,就可以满足该孩子。求最多能满足多少孩子?
- 将孩子胃口和饼干尺寸都从小到大排序
- 用最小的能满足当前孩子的饼干去满足他
- 为什么有效?优先满足小胃口的孩子,节省大饼干给大胃口的孩子
function findContentChildren(g, s) {
g.sort(function(a, b) { return a - b; });
s.sort(function(a, b) { return a - b; });
let i = 0, j = 0;
while (i < g.length && j < s.length) {
if (s[j] >= g[i]) {
i++; // 满足了一个孩子
}
j++; // 饼干被使用(无论是否满足)
}
return i;
}
问题描述:给定一个非负整数数组nums,最初位于数组的第一个下标。每个元素表示你在该位置可以跳跃的最大长度。判断是否能够到达最后一个下标。
- 维护一个变量reach表示当前能到达的最远位置
- 遍历数组,如果当前位置<=reach,则更新reach = max(reach, i+nums[i])
- 如果reach >= n-1,则可以到达终点
function canJump(nums) {
let n = nums.length;
let reach = 0;
for (let i = 0; i < n; i++) {
if (i > reach) return false; // 当前位置不可达
reach = Math.max(reach, i + nums[i]);
if (reach >= n - 1) return true;
}
return true;
}
问题描述:字符串S由小写字母组成。要把字符串分割成尽可能多的片段,同一个字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。
- 先记录每个字母最后出现的位置
- 遍历字符串,当前片段的结束位置 = max(片段中所有字母的最后出现位置)
- 当遍历到结束位置时,完成一个片段
function partitionLabels(S) {
let last = {};
for (let i = 0; i < S.length; i++) {
last[S[i]] = i;
}
let result = [];
let start = 0, end = 0;
for (let i = 0; i < S.length; i++) {
end = Math.max(end, last[S[i]]);
if (i === end) {
result.push(end - start + 1);
start = i + 1;
}
}
return result;
}
问题描述:给定一组字符及其出现频率,构造哈夫曼树,使得编码后的总长度最短。
- 每次从所有节点中选择频率最小的两个节点
- 创建一个新的父节点,频率为两者之和
- 重复直到只剩一个节点(根节点)
- 为什么有效?频率高的字符编码短,频率低的字符编码长,总长度最短
class Node {
constructor(char, freq) {
this.char = char;
this.freq = freq;
this.left = null;
this.right = null;
}
}
function huffmanCoding(chars, freqs) {
let heap = [];
for (let i = 0; i < chars.length; i++) {
heap.push(new Node(chars[i], freqs[i]));
}
heap.sort(function(a, b) { return a.freq - b.freq; });
while (heap.length > 1) {
let left = heap.shift();
let right = heap.shift();
let parent = new Node(null, left.freq + right.freq);
parent.left = left;
parent.right = right;
heap.push(parent);
heap.sort(function(a, b) { return a.freq - b.freq; });
}
return heap[0]; // 返回哈夫曼树的根节点
}
问题描述:给定一个带权无向图,找出一棵生成树,使得所有边的权值之和最小。
- 从任意一个顶点开始,将其加入最小生成树集合
- 每次选择连接集合内和集合外顶点的最小权值边
- 将该边和另一个顶点加入集合
- 重复直到所有顶点都在集合中
function prim(graph) {
let n = graph.length;
let visited = new Array(n).fill(false);
let minCost = new Array(n).fill(Infinity);
minCost[0] = 0;
let totalCost = 0;
for (let i = 0; i < n; i++) {
let u = -1;
for (let j = 0; j < n; j++) {
if (!visited[j] && (u === -1 || minCost[j] < minCost[u])) {
u = j;
}
}
visited[u] = true;
totalCost += minCost[u];
for (let v = 0; v < n; v++) {
if (!visited[v] && graph[u][v] < minCost[v]) {
minCost[v] = graph[u][v];
}
}
}
return totalCost;
}
3动态规划 vs 贪心算法
| 对比维度 | 动态规划 | 贪心算法 |
|---|---|---|
| 核心思想 | 记录所有子问题的解,通过组合得到原问题的解 | 每一步都做出当前最好的选择 |
| 适用范围 | 具有重叠子问题和最优子结构的问题 | 具有贪心选择性质的问题 |
| 全局最优性 | 一定能得到全局最优解 | 不一定能得到全局最优解 |
| 时间复杂度 | 通常较高(需要计算所有子问题) | 通常较低(只需要一次遍历) |
| 空间复杂度 | 通常需要额外的DP数组 | 通常只需要常数空间 |
| 证明难度 | 相对容易(只要定义好状态和转移) | 较难(需要证明贪心选择性质) |
| 示例问题 | 背包问题、LCS、编辑距离 | 活动选择、哈夫曼编码、最小生成树 |
- 如果问题具有贪心选择性质,优先用贪心(效率高)
- 如果贪心不保证最优,或者你不确定,用动态规划(保证最优)
- 如果问题有明显的重叠子问题,用动态规划
- 如果问题要求所有解或最优解,用动态规划
🔍 经典对比案例
0-1背包问题(每个物品只能选一次):
- ❌ 贪心算法失效!(按价值密度排序选择不一定最优)
- ✅ 必须用动态规划
分数背包问题(物品可以选一部分):
- ✅ 贪心算法有效!(按价值密度排序,依次选取)
- ✅ 动态规划也可以,但贪心更高效
4学习建议与练习路径
📚 动态规划学习路径
- 入门:斐波那契数列、爬楼梯、打家劫舍
- 进阶:背包问题、最长公共子序列、最长递增子序列
- 高级:编辑距离、正则表达式匹配、股票买卖系列
- 大师:区间DP、树形DP、状态压缩DP
📚 贪心算法学习路径
- 入门:活动选择、分发饼干、跳跃游戏I
- 进阶:划分字母区间、无重叠区间、用最少数量的箭引爆气球
- 高级:哈夫曼编码、最小生成树、最短路径(Dijkstra)
🎯 刷题建议
- LeetCode:搜索"Dynamic Programming"和"Greedy"标签
- 先看懂:每个经典问题都要能独立写出状态转移方程
- 再刷题:同类问题刷3-5道,形成肌肉记忆
- 总结模板:比如背包问题、LCS、编辑距离都有固定模板
- 手写代码:不要只看答案,要能白板编程
📌 常见错误
- ❌ 状态定义不清晰
- ❌ 状态转移方程写错
- ❌ 初始化条件错误
- ❌ 计算顺序搞反(比如完全背包正序遍历写成倒序)
- ❌ 没有证明贪心选择性质就用贪心
- ❌ 贪心策略和排序策略不匹配
- ❌ 忽略边界条件