动态规划十题通关:从爬楼梯到编辑距离(Python + C++)
动态规划(DP)是算法面试中最重要也最灵活的专题。核心思想是将大问题分解为重叠子问题,通过状态转移避免重复计算。本文整理了10道经典DP题目,每道题包含:题目描述、状态定义、状态转移方程、图解(表格/文本)、Python代码、C++代码、复杂度分析。掌握这些,DP类题目基本通关。
📌 题目清单
| 题号 | 题目 | 核心考点 |
|---|---|---|
| 70 | 爬楼梯 | DP 入门(斐波那契) |
| 118 | 杨辉三角 | 二维 DP |
| 121 | 买卖股票的最佳时机 | 一次交易(记录最小价格) |
| 300 | 最长递增子序列 | O(n²) / 二分贪心 O(n log n) |
| 1143 | 最长公共子序列 | 二维 DP(经典) |
| 5 | 最长回文子串 | 中心扩展 / DP |
| 322 | 零钱兑换 | 完全背包(最少硬币数) |
| 416 | 分割等和子集 | 0-1 背包(是否存在子集和 = sum/2) |
| 198 | 打家劫舍 | 一维 DP(隔房取最大值) |
| 72 | 编辑距离 | 二维 DP(三个操作) |
1. 爬楼梯(LeetCode 70)
题目描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。有多少种不同的方法可以爬到楼顶?
示例 :
输入:n = 3 → 输出:3(1+1+1, 1+2, 2+1)
解题思路
- 定义
dp[i]表示到达第i阶的方法数。 - 状态转移:
dp[i] = dp[i-1] + dp[i-2](因为可以从 i-1 迈1步,或从 i-2 迈2步)。 - 初始化:
dp[0]=1(地面),dp[1]=1(只有1步)。 - 实际上就是斐波那契数列。
图解(n=5)
i: 0 1 2 3 4 5
dp:1 1 2 3 5 8
Python代码
python
def climbStairs(n):
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1] = 1
dp[2] = 2
for i in range(3, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
# 空间优化版
def climbStairs(n):
a, b = 1, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
C++代码
cpp
class Solution {
public:
int climbStairs(int n) {
if (n <= 2) return n;
int a = 1, b = 2;
for (int i = 3; i <= n; ++i) {
int c = a + b;
a = b;
b = c;
}
return b;
}
};
复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(1)(优化后)
2. 杨辉三角(LeetCode 118)
题目描述
给定一个非负整数 numRows,生成杨辉三角的前 numRows 行。
示例 :
输入:numRows = 5
输出:[[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]
解题思路
- 使用二维列表
dp,dp[i][j]表示第i行第j个元素。 - 状态转移:
dp[i][j] = dp[i-1][j-1] + dp[i-1][j](当 j 不在首尾时)。 - 每行首尾均为1。
图解
行0: 1
行1: 1 1
行2: 1 (1+1) 1 → 1,2,1
行3: 1 (1+2) (2+1) 1 → 1,3,3,1
...
Python代码
python
def generate(numRows):
res = []
for i in range(numRows):
row = [1] * (i + 1)
for j in range(1, i):
row[j] = res[i-1][j-1] + res[i-1][j]
res.append(row)
return res
C++代码
cpp
class Solution {
public:
vector<vector<int>> generate(int numRows) {
vector<vector<int>> res;
for (int i = 0; i < numRows; ++i) {
vector<int> row(i+1, 1);
for (int j = 1; j < i; ++j) {
row[j] = res[i-1][j-1] + res[i-1][j];
}
res.push_back(row);
}
return res;
}
};
复杂度分析
- 时间复杂度:O(numRows²)
- 空间复杂度:O(numRows²)(输出所需)
3. 买卖股票的最佳时机(LeetCode 121)
题目描述
给定一个数组 prices,其中 prices[i] 是第 i 天的股票价格。最多只能完成一笔交易(买入一次,卖出一次),求最大利润。
示例 :
输入:[7,1,5,3,6,4] → 输出:5(第2天买入,第5天卖出,利润5)
解题思路
- 维护历史最低价
minPrice,以及当前最大利润maxProfit。 - 遍历每一天,更新最低价,并计算以当天卖出能获得的利润,更新最大利润。
- 等价于 DP:
dp[i] = max(dp[i-1], prices[i] - minPrice)。
图解
prices: 7 1 5 3 6 4
min: 7 1 1 1 1 1
profit: 0 0 4 2 5 3 → max=5
Python代码
python
def maxProfit(prices):
min_price = float('inf')
max_profit = 0
for p in prices:
min_price = min(min_price, p)
max_profit = max(max_profit, p - min_price)
return max_profit
C++代码
cpp
class Solution {
public:
int maxProfit(vector<int>& prices) {
int minPrice = INT_MAX, maxProfit = 0;
for (int p : prices) {
minPrice = min(minPrice, p);
maxProfit = max(maxProfit, p - minPrice);
}
return maxProfit;
}
};
复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(1)
4. 最长递增子序列(LeetCode 300)
题目描述
给定一个整数数组 nums,找到其中最长的严格递增子序列的长度。
示例 :
输入:[10,9,2,5,3,7,101,18] → 输出:4([2,3,7,101])
解题思路
- 方法一:DP O(n²)
dp[i]表示以nums[i]结尾的最长递增子序列长度。
转移:dp[i] = max(dp[j]) + 1,其中j < i且nums[j] < nums[i]。
最终答案max(dp)。 - 方法二:贪心 + 二分 O(n log n)
维护一个数组tails,tails[i]表示长度为 i+1 的递增子序列的最小末尾值。
遍历每个数,用二分找到第一个大于等于它的位置替换,若找不到则追加。
图解(DP)
nums: 10 9 2 5 3 7 101 18
dp: 1 1 1 2 2 3 4 4
max=4
Python代码(DP O(n²))
python
def lengthOfLIS(nums):
n = len(nums)
dp = [1] * n
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp) if n else 0
C++代码(二分贪心 O(n log n))
cpp
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> tails;
for (int x : nums) {
auto it = lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x);
else *it = x;
}
return tails.size();
}
};
复杂度分析
- 时间复杂度:O(n²)(DP) / O(n log n)(二分贪心)
- 空间复杂度:O(n)
5. 最长公共子序列(LeetCode 1143)
题目描述
给定两个字符串 text1 和 text2,返回它们的最长公共子序列的长度。
示例 :
输入:text1 = "abcde", text2 = "ace" → 输出:3("ace")
解题思路
- 定义
dp[i][j]表示text1[0..i-1]与text2[0..j-1]的最长公共子序列长度。 - 状态转移:
- 如果
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])
- 如果
- 初始化
dp[0][j] = dp[i][0] = 0。
图解(表格)
"" a c e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2
e 0 1 2 3
Python代码
python
def longestCommonSubsequence(text1, text2):
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
C++代码
cpp
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for (int i = 1; i <= m; ++i) {
for (int 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] = max(dp[i-1][j], dp[i][j-1]);
}
}
return dp[m][n];
}
};
复杂度分析
- 时间复杂度:O(m × n)
- 空间复杂度:O(m × n)(可优化为 O(min(m,n)))
6. 最长回文子串(LeetCode 5)
题目描述
给定一个字符串 s,找到 s 中最长的回文子串。
示例 :
输入:"babad" → 输出:"bab" 或 "aba"
解题思路
- 方法一:中心扩展 (推荐,O(n²))
枚举每个中心(单字符或双字符),向两边扩展找到最大回文。 - 方法二:DP
定义dp[i][j]表示s[i..j]是否为回文。
转移:dp[i][j] = (s[i]==s[j]) and (j-i<3 or dp[i+1][j-1])。
图解(中心扩展)
s = "babad"
以 i=2 'b' 为中心,扩展得 "bab" 长度3
以 i=1 'a'? 实际中心有单双,最终最长 "bab"
Python代码(中心扩展)
python
def longestPalindrome(s):
if not s:
return ""
start, end = 0, 0
def expand(l, r):
while l >= 0 and r < len(s) and s[l] == s[r]:
l -= 1
r += 1
return l+1, r-1
for i in range(len(s)):
l1, r1 = expand(i, i)
l2, r2 = expand(i, i+1)
if r1 - l1 > end - start:
start, end = l1, r1
if r2 - l2 > end - start:
start, end = l2, r2
return s[start:end+1]
C++代码(DP)
cpp
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n < 2) return s;
vector<vector<bool>> dp(n, vector<bool>(n, false));
int start = 0, maxLen = 1;
for (int i = 0; i < n; ++i) dp[i][i] = true;
for (int len = 2; len <= n; ++len) {
for (int i = 0; i + len <= n; ++i) {
int j = i + len - 1;
if (s[i] == s[j]) {
if (len == 2 || dp[i+1][j-1]) {
dp[i][j] = true;
if (len > maxLen) {
maxLen = len;
start = i;
}
}
}
}
}
return s.substr(start, maxLen);
}
};
复杂度分析
- 时间复杂度:O(n²)
- 空间复杂度:O(1)(中心扩展)/ O(n²)(DP)
7. 零钱兑换(LeetCode 322)
题目描述
给定不同面额的硬币 coins 和一个总金额 amount,计算可以凑成总金额所需的最少硬币个数。如果无法凑成,返回 -1。每种硬币无限使用。
示例 :
输入:coins = [1,2,5], amount = 11 → 输出:3(11 = 5+5+1)
解题思路
- 完全背包问题(硬币无限)。
- 定义
dp[i]表示凑成金额i所需的最少硬币数。 - 初始化
dp[0]=0,其他为inf。 - 对每个金额
i,遍历硬币c,如果i >= c,则dp[i] = min(dp[i], dp[i-c] + 1)。 - 最终
dp[amount]若为inf则返回 -1。
图解(amount=11, coins=[1,2,5])
dp[0]=0
i=1: dp[1]=min(dp[0]+1)=1
i=2: dp[2]=min(dp[1]+1, dp[0]+1)=min(2,1)=1
i=3: dp[3]=min(dp[2]+1, dp[1]+1)=2
i=4: dp[4]=min(dp[3]+1, dp[2]+1)=2
i=5: dp[5]=min(dp[4]+1, dp[3]+1, dp[0]+1)=min(3,3,1)=1
i=6: dp[6]=min(dp[5]+1, dp[4]+1, dp[1]+1)=2
...
i=11: dp[11]=min(dp[10]+1, dp[9]+1, dp[6]+1)=3
Python代码
python
def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for c in coins:
if i >= c:
dp[i] = min(dp[i], dp[i - c] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
C++代码
cpp
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int i = 1; i <= amount; ++i) {
for (int c : coins) {
if (i >= c && dp[i - c] != INT_MAX) {
dp[i] = min(dp[i], dp[i - c] + 1);
}
}
}
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
};
复杂度分析
- 时间复杂度:O(amount × len(coins))
- 空间复杂度:O(amount)
8. 分割等和子集(LeetCode 416)
题目描述
给定一个只包含正整数的非空数组,判断是否可以将数组分割成两个子集,使得两个子集的元素和相等。
示例 :
输入:[1,5,11,5] → 输出:true([1,5,5]和[11])
输入:[1,2,3,5] → 输出:false
解题思路
- 先求和,若和为奇数则 false。
- 目标:是否存在子集和为
target = sum/2。 - 0-1 背包问题:
dp[j]表示能否凑出和为j。 - 初始化
dp[0] = true,遍历每个数num,倒序更新dp[j] = dp[j] or dp[j - num]。
图解([1,5,11,5], target=11)
初始 dp[0]=true
num=1: dp[1]=true
num=5: dp[5]=true, dp[6]=true (5+1)
num=11: dp[11]=true
num=5: dp[?] 已满足
最终 dp[11]=true
Python代码
python
def canPartition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for j in range(target, num - 1, -1):
dp[j] = dp[j] or dp[j - num]
return dp[target]
C++代码
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % 2) return false;
int target = sum / 2;
vector<bool> dp(target + 1, false);
dp[0] = true;
for (int num : nums) {
for (int j = target; j >= num; --j) {
dp[j] = dp[j] || dp[j - num];
}
}
return dp[target];
}
};
复杂度分析
- 时间复杂度:O(n × target)
- 空间复杂度:O(target)
9. 打家劫舍(LeetCode 198)
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内藏有一定的现金,但不能偷相邻的房屋,否则会触发警报。计算能偷窃到的最高金额。
示例 :
输入:[1,2,3,1] → 输出:4(偷1号(1)和3号(3)=4)
解题思路
- 定义
dp[i]表示前i间房屋能偷到的最高金额(i从0开始)。 - 转移:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])。 - 边界:
dp[0]=nums[0],dp[1]=max(nums[0], nums[1])。 - 空间优化:只需两个变量。
图解
nums: 1,2,3,1
dp0=1
dp1=max(1,2)=2
i=2: dp2=max(2, 1+3)=4
i=3: dp3=max(4, 2+1)=4
Python代码
python
def rob(nums):
if not nums:
return 0
if len(nums) == 1:
return nums[0]
dp = [0] * len(nums)
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, len(nums)):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[-1]
# 空间优化
def rob(nums):
prev, curr = 0, 0
for num in nums:
prev, curr = curr, max(curr, prev + num)
return curr
C++代码
cpp
class Solution {
public:
int rob(vector<int>& nums) {
int prev = 0, curr = 0;
for (int num : nums) {
int temp = curr;
curr = max(curr, prev + num);
prev = temp;
}
return curr;
}
};
复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(1)
10. 编辑距离(LeetCode 72)
题目描述
给你两个单词 word1 和 word2,请返回将 word1 转换成 word2 所使用的最少操作数。你可以对一个单词进行三种操作:插入一个字符、删除一个字符、替换一个字符。
示例 :
输入:word1 = "horse", word2 = "ros" → 输出:3
(horse → rorse(替换h为r)→ rose(删除r)→ ros(删除e))
解题思路
- 定义
dp[i][j]表示word1的前i个字符转换成word2的前j个字符所需的最少操作数。 - 转移:
- 如果
word1[i-1] == word2[j-1],dp[i][j] = dp[i-1][j-1] - 否则
dp[i][j] = min( dp[i-1][j] (删除), dp[i][j-1] (插入), dp[i-1][j-1] (替换) ) + 1
- 如果
- 初始化:
dp[i][0] = i(删除i次),dp[0][j] = j(插入j次)。
图解(表格)
"" r o s
"" 0 1 2 3
h 1 1 2 3
o 2 2 1 2
r 3 2 2 2
s 4 3 3 2
e 5 4 4 3
右下角 dp[5][3] = 3
Python代码
python
def minDistance(word1, word2):
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
return dp[m][n]
C++代码
cpp
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for (int i = 0; i <= m; ++i) dp[i][0] = i;
for (int j = 0; j <= n; ++j) dp[0][j] = j;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (word1[i-1] == word2[j-1]) {
dp[i][j] = dp[i-1][j-1];
} else {
dp[i][j] = min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]}) + 1;
}
}
}
return dp[m][n];
}
};
复杂度分析
- 时间复杂度:O(m × n)
- 空间复杂度:O(m × n)(可优化为 O(min(m,n)))
🎯 总结
| 题目 | 核心技巧 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 70. 爬楼梯 | 斐波那契 | O(n) | O(1) |
| 118. 杨辉三角 | 二维递推 | O(n²) | O(n²) |
| 121. 买卖股票 | 记录最小价格 | O(n) | O(1) |
| 300. 最长递增子序列 | DP / 二分贪心 | O(n log n) | O(n) |
| 1143. 最长公共子序列 | 二维DP | O(mn) | O(mn) |
| 5. 最长回文子串 | 中心扩展 / DP | O(n²) | O(1) |
| 322. 零钱兑换 | 完全背包 | O(amount×len) | O(amount) |
| 416. 分割等和子集 | 0-1背包 | O(n×target) | O(target) |
| 198. 打家劫舍 | 一维DP(隔房) | O(n) | O(1) |
| 72. 编辑距离 | 二维DP(编辑操作) | O(mn) | O(mn) |
动态规划的核心步骤:
- 定义状态(dp数组的含义)
- 找到状态转移方程
- 确定初始化和边界条件
- 确定遍历顺序
- 优化空间(可选)
多做、多画表、多总结模式(背包、序列、区间、状态机等),DP会变得有迹可循。