动态规划基础原理与题目说明
文章目录
- 动态规划基础原理与题目说明
-
- [一、 什么是动态规划(Dynamic Programming)?](#一、 什么是动态规划(Dynamic Programming)?)
-
- [1.1 动态规划 vs 贪心算法](#1.1 动态规划 vs 贪心算法)
- [1.2 黄金法则:背包问题的遍历方向](#1.2 黄金法则:背包问题的遍历方向)
- [二、 一维基础动态规划](#二、 一维基础动态规划)
-
- [[70. 爬楼梯](https://leetcode.cn/problems/climbing-stairs/)](#70. 爬楼梯)
- [[198. 打家劫舍](https://leetcode.cn/problems/house-robber/)](#198. 打家劫舍)
- [三、 二维与网格动态规划](#三、 二维与网格动态规划)
-
- [[118. 杨辉三角](https://leetcode.cn/problems/pascals-triangle/)](#118. 杨辉三角)
- [[62. 不同路径](https://leetcode.cn/problems/unique-paths/)](#62. 不同路径)
- [[64. 最小路径和](https://leetcode.cn/problems/minimum-path-sum/)](#64. 最小路径和)
- [四、 子串与子序列问题](#四、 子串与子序列问题)
-
- [[5. 最长回文子串](https://leetcode.cn/problems/longest-palindromic-substring/)](#5. 最长回文子串)
- [[300. 最长递增子序列 (LIS)](https://leetcode.cn/problems/longest-increasing-subsequence/)](#300. 最长递增子序列 (LIS))
- [[1143. 最长公共子序列 (LCS)](https://leetcode.cn/problems/longest-common-subsequence/)](#1143. 最长公共子序列 (LCS))
- [[152. 乘积最大子数组](https://leetcode.cn/problems/maximum-product-subarray/)](#152. 乘积最大子数组)
- [五、 背包问题系列](#五、 背包问题系列)
-
- [[416. 分割等和子集](https://leetcode.cn/problems/partition-equal-subset-sum/) (0-1 背包)](#416. 分割等和子集 (0-1 背包))
- [[322. 零钱兑换](https://leetcode.cn/problems/coin-change/) (完全背包)](#322. 零钱兑换 (完全背包))
- [[279. 完全平方数](https://leetcode.cn/problems/perfect-squares/) (完全背包)](#279. 完全平方数 (完全背包))
- [六、 高阶状态转移](#六、 高阶状态转移)
-
- [[139. 单词拆分](https://leetcode.cn/problems/word-break/)](#139. 单词拆分)
- [[72. 编辑距离](https://leetcode.cn/problems/edit-distance/)](#72. 编辑距离)
- [[32. 最长有效括号](https://leetcode.cn/problems/longest-valid-parentheses/)](#32. 最长有效括号)
🔗 查看完整专栏(LeetCode基础算法专栏)

特别说明:
本文为个人的 LeetCode 刷题与学习笔记,内容仅供学习与交流使用,禁止转载或用于商业用途。需要强调的是,文中的题目解法不一定是最优解(可能存在时间或空间复杂度的进一步优化空间),主要目的是分享个人的解题思路与逻辑实现,仅供参考。 笔记内容为个人理解与总结,可能存在疏漏或偏差,欢迎读者自行甄别并交流探讨。
一、 什么是动态规划(Dynamic Programming)?
一句话定义:
动态规划 = 把一个复杂问题拆成有重叠子问题的最优子结构问题,用"存表"(dp 数组)的方式避免重复计算。
动态规划通常具备三个核心特征:
- 最优子结构:大问题的最优解,可以由小问题的最优解推导出来。
- 重叠子问题:在暴力递归中,很多相同的子问题会被反复计算,DP 通过查表来优化。
- 状态转移方程:当前状态可以由之前的状态通过某种固定规则(方程)计算得到。
1.1 动态规划 vs 贪心算法
二者的共通点是都要求问题具备"最优子结构"。但它们的核心决策逻辑完全不同:
- 贪心算法 :要求局部最优天然能推出全局最优(贪心选择性质)。每一步只选择当前看起来最好的,选完不回头,不后悔。
- 动态规划 :每一步会把前面子问题的最优结果拿过来比较,综合评估后再选择全局最优。
| 维度 | 贪心算法 (Greedy) | 动态规划 (DP) |
|---|---|---|
| 做选择 | 只看当下最优,直接选 | 综合所有子问题最优,再做选择 |
| 回溯 / 比较 | 从不回头,不比较其他可行方案 | 会比较所有可能引发当前状态的子方案 |
| 核心前提 | 最优子结构 + 贪心选择性质 | 最优子结构 + 重叠子问题 |
| 时间效率 | 极快,通常 O ( n ) O(n) O(n) | 较慢,常见 O ( n 2 ) , O ( n k ) O(n^2), O(nk) O(n2),O(nk) |
| 适用场景 | 必须严格满足:局部最优 = 全局最优 | 更通用,几乎覆盖所有最优化问题 |
💡 反例演示:凑硬币问题
题目 :用硬币
[1, 3, 4]凑出金额6,最少用几枚?
- 贪心思路(错误) :每次选当前最大的。选 4 → \rightarrow → 剩 2 → \rightarrow → 选 1+1。总共 3 枚 。贪心失败,因为当前最大 ≠ \neq = 全局最优。
- DP 思路(正确) :
dp[6] = min(dp[5]+1, dp[3]+1, dp[2]+1) = min(2+1, 1+1, 2+1) = 2。即选3+3,只需 2 枚。
1.2 黄金法则:背包问题的遍历方向
在解决背包问题时,内层循环(容量 j)的正序与倒序决定了物品能被使用多少次:
- 正序遍历 j j j**(从小到大)** :用于完全背包(物品无限次使用)。因为计算大容量时,会用到刚被当前数字更新过的小容量状态,相当于同一个数字被重复用了多次。
- 倒序遍历 j j j**(从大到小)** :用于0-1 背包(物品只能用 1 次)。因为计算时,用到的小容量状态还没被当前物品更新过,是纯粹的「上一层历史状态」,严格保证了当前物品只被决策 1 次。
二、 一维基础动态规划
70. 爬楼梯
题目描述:
假设你正在爬楼梯。需要 n n n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
解题思路:
爬到第 n n n 阶的方案数可由前两阶的方案数推导而来,属于最经典的斐波那契数列型一维 DP。
- 状态定义 :
dp[i]为爬到第 i i i 阶的方案数。 - 状态转移 :最后一步可走 1 阶或 2 阶,故
dp[i] = dp[i-1] + dp[i-2]。 - 边界条件 :
dp[0] = 1,dp[1] = 1。
核心代码:
py
class Solution:
def climbStairs(self, n: int) -> int:
func = [0] * (n + 1)
func[0] = 1
func[1] = 1
for i in range(2, n + 1):
func[i] = func[i-1] + func[i-2]
return func[n]
198. 打家劫舍
题目描述:
计算在不触动警报装置(不能连偷相邻房屋)的情况下,一夜之内能够偷窃到的最高金额。
解题思路:
- 状态定义 :
func[i]表示前 i + 1 i+1 i+1 间房屋能偷窃到的最高金额。 - 状态转移 :对于第 i i i 间房屋,选择偷(则不能偷 i − 1 i-1 i−1,最高金额为
func[i-2] + nums[i]),选择不偷(最高金额为func[i-1])。取两者最大值:func[i] = max(func[i-1], func[i-2] + nums[i])。
核心代码:
py
from typing import List
class Solution:
def rob(self, nums: List[int]) -> int:
n = len(nums)
if n == 1:
return nums[0]
func = [0] * n
func[0] = nums[0]
func[1] = max(nums[0], nums[1])
for i in range(2, n):
func[i] = max(func[i-1], func[i-2] + nums[i])
return func[-1]
三、 二维与网格动态规划
118. 杨辉三角
解题思路:
利用杨辉三角的核心数学性质:每个非首尾位置的数字等于其上一行相邻两个数字之和。状态转移:fm[i][j] = fm[i-1][j-1] + fm[i-1][j]。
核心代码:
py
from typing import List
class Solution:
def generate(self, numRows: int) -> List[List[int]]:
# 初始化,每行首尾皆为1
fm = [[1] * (i + 1) for i in range(numRows)]
for i in range(2, numRows):
for j in range(1, i):
fm[i][j] = fm[i-1][j-1] + fm[i-1][j]
return fm
62. 不同路径
解题思路:
机器人只能向右或向下走。
- 状态定义 :
fm[i][j]表示从左上角走到坐标 ( i , j ) (i, j) (i,j) 的总路径数。 - 边界初始化:第一行、第一列所有位置路径数都为 1(只能一直右/一直下)。
- 状态转移 :
fm[i][j] = fm[i-1][j] + fm[i][j-1]。
核心代码:
py
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
fm = [[1] * n for _ in range(m)]
for i in range(1, m):
for j in range(1, n):
fm[i][j] = fm[i-1][j] + fm[i][j-1]
return fm[m-1][n-1]
64. 最小路径和
解题思路:
类似于"不同路径",只是求极值。fm[i][j] = min(上方路径和, 左方路径和) + 当前格子值。
核心代码:
py
from typing import List
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
fm = [[float('inf')] * n for _ in range(m)]
fm[0][0] = grid[0][0]
for j in range(1, n):
fm[0][j] = fm[0][j-1] + grid[0][j]
for i in range(1, m):
fm[i][0] = fm[i-1][0] + grid[i][0]
for i in range(1, m):
for j in range(1, n):
fm[i][j] = min(fm[i-1][j], fm[i][j-1]) + grid[i][j]
return fm[m-1][n-1]
四、 子串与子序列问题
5. 最长回文子串
解题思路:
回文串的天然转移特性:如果一个子串首尾字符相同,且去掉首尾后的内部串也是回文,则该子串必是回文。
- 状态转移 :
dp[i][j] = dp[i+1][j-1] and (s[i] == s[j]) - 按子串长度 L L L 从小到大枚举,保证状态转移时子串结果已计算过。
核心代码:
py
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
if n < 2: return s
fm = [[False] * n for _ in range(n)]
for i in range(n):
fm[i][i] = True
start, max_len = 0, 1
for L in range(2, n + 1):
for left in range(n):
right = left + L - 1
if right >= n: break
if s[left] != s[right]:
fm[left][right] = False
else:
if right - left < 2:
fm[left][right] = True
else:
fm[left][right] = fm[left+1][right-1]
if fm[left][right] and right - left + 1 > max_len:
max_len = right - left + 1
start = left
return s[start:start+max_len]
300. 最长递增子序列 (LIS)
解题思路:
- 状态定义 :
func[i]表示以nums[i]结尾的最长严格递增子序列的长度。 - 状态转移 :对于每个元素 i i i,遍历其之前的所有元素 j j j,只要
nums[i] > nums[j],即可尝试将 i i i 接在 j j j 后面,更新func[i] = max(func[i], func[j] + 1)。
核心代码:
py
from typing import List
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
n = len(nums)
if n == 0: return 0
func = [1] * n
for i in range(1, n):
for j in range(0, i):
if nums[i] > nums[j]:
func[i] = max(func[i], func[j] + 1)
return max(func)
1143. 最长公共子序列 (LCS)
解题思路:
- 状态转移 :
- 若
text1[i-1] == text2[j-1],加入 LCS,dp[i][j] = dp[i-1][j-1] + 1 - 若不相同,意味着这两个字符不可能同时出现在 LCS 中,取删除两者其一的最大值:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
- 若
核心代码:
py
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m, n = len(text1), len(text2)
fm = [[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]:
fm[i][j] = fm[i-1][j-1] + 1
else:
fm[i][j] = max(fm[i-1][j], fm[i][j-1])
return fm[m][n]
152. 乘积最大子数组
解题思路:
由于乘法存在负负得正特性,仅维护最大值会丢失最优解(当前的最小负数乘以一个负数可能变成最大正数)。必须同时维护双状态。
max_dp[i] = max(nums[i], max_dp[i-1]*nums[i], min_dp[i-1]*nums[i])
核心代码:
py
from typing import List
class Solution:
def maxProduct(self, nums: List[int]) -> int:
n = len(nums)
if n == 0: return 0
max_func = [0] * n
min_func = [0] * n
max_func[0] = min_func[0] = nums[0]
for i in range(1, n):
max_func[i] = max(nums[i], max_func[i-1] * nums[i], min_func[i-1] * nums[i])
min_func[i] = min(nums[i], max_func[i-1] * nums[i], min_func[i-1] * nums[i])
return max(max_func)
五、 背包问题系列
416. 分割等和子集 (0-1 背包)
解题思路:
问题转化为:给定容量为 sum // 2 的背包,数组元素为物品,每个物品只能用一次(0-1背包),能否恰好装满?
- 0-1 背包必须倒序遍历容量,保证每个数字只被选择一次。
核心代码:
py
from typing import List
class Solution:
def canPartition(self, nums: List[int]) -> bool:
total = sum(nums)
if total % 2 != 0: return False
target = total // 2
if max(nums) > target: return False
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
# 01背包,倒序遍历容量
for j in range(target, num - 1, -1):
dp[j] = dp[j] or dp[j - num]
return dp[target]
322. 零钱兑换 (完全背包)
解题思路:
硬币无限用,属于完全背包 。正序遍历容量。dp[i] = min(dp[i], dp[i-coin] + 1)。
核心代码:
py
from typing import List
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
func = [float('inf')] * (amount + 1)
func[0] = 0
for i in range(1, amount + 1):
for j in coins:
if i >= j and func[i-j] != float('inf'):
func[i] = min(func[i], func[i-j] + 1)
return func[amount] if func[amount] != float('inf') else -1
279. 完全平方数 (完全背包)
解题思路:
与零钱兑换如出一辙,物品变成了 1 , 4 , 9 , 16... 1, 4, 9, 16... 1,4,9,16... 这些完全平方数,由于平方数可以无限重复使用,依然是完全背包问题。
核心代码:
py
class Solution:
def numSquares(self, n: int) -> int:
dp = [float('inf')] * (n + 1)
dp[0] = 0
for i in range(1, n + 1):
# 枚举所有小于等于当前 i 的完全平方数 j*j
j = 1
while j * j <= i:
dp[i] = min(dp[i], dp[i - j * j] + 1)
j += 1
return dp[n]
六、 高阶状态转移
139. 单词拆分
解题思路:
- 状态定义 :
dp[i]表示前i个字符能否被拆分成字典中的单词。 - 转移方程 :只要存在一个切割点
j,使得dp[j] == True并且s[j:i]在字典中,则dp[i] = True。
核心代码:
py
import collections
from typing import List
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
n = len(s)
hashword = set(wordDict)
func = [False] * (n + 1)
func[0] = True
for i in range(1, n + 1):
for j in range(0, i):
if func[j] and s[j:i] in hashword:
func[i] = True
break # 只要找到一种合法拆分即可
return func[n]
72. 编辑距离
解题思路:
编辑距离是机器学习(自然语言处理)评估文本相似度的基石算法。允许操作有插入、删除、替换。
- 状态定义 :
dp[i][j]表示word1前i个字符转换到word2前j个字符的最少步数。 - 核心推导 :
- 若末尾字符相同 :直接继承,
dp[i][j] = dp[i-1][j-1]。 - 若不同,三选一 :
- 插入 :
word1变到word2缺一个尾字符,先转换为word2前j-1的状态,再插入。 → \rightarrow →dp[i][j-1] + 1 - 删除 :
word1多一个尾字符,先用word1前i-1的状态转换过去,再把末尾删掉。 → \rightarrow →dp[i-1][j] + 1 - 替换 :直接把
word1第i个字符替换成word2第j个字符。 → \rightarrow →dp[i-1][j-1] + 1
- 插入 :
- 若末尾字符相同 :直接继承,
核心代码:
py
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
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][j-1] + 1, # 插入
dp[i-1][j] + 1, # 删除
dp[i-1][j-1] + 1) # 替换
return dp[m][n]
32. 最长有效括号
解题思路:
- 状态定义 :
dp[i]表示以s[i]结尾的最长有效括号长度。 - 状态转移 (仅考虑当前是
)的情况,因为以(结尾必然是 0):- 前一个是
(:形如...()。直接匹配,dp[i] = dp[i-2] + 2。 - 前一个是
):形如...))。说明前面有个完整的有效括号串,长度为dp[i-1]。我们需要跳过这串括号,看看它更前面 是不是有个(可以和当前的)配对。- 待配对的位置为
idx = i - dp[i-1] - 1。 - 如果
s[idx] == '(',则配对成功:dp[i] = 中间长度(dp[i-1]) + 当前对(2) + 配对前位置的有效长度(dp[idx-1])。
- 待配对的位置为
- 前一个是
核心代码:
py
class Solution:
def longestValidParentheses(self, s: str) -> int:
n = len(s)
if n < 2: return 0
dp = [0] * n
max_len = 0
for i in range(1, n):
if s[i] == ')':
# 1. 情况一:形如 ()
if s[i-1] == '(':
dp[i] = (dp[i-2] if i >= 2 else 0) + 2
# 2. 情况二:形如 ))
else:
# 寻找可以和当前 ')' 匹配的 '(' 的索引
match_idx = i - dp[i-1] - 1
if match_idx >= 0 and s[match_idx] == '(':
# 配对成功:内部合法长度 + 当前这2个 + 匹配点之前的合法长度
dp[i] = dp[i-1] + 2 + (dp[match_idx-1] if match_idx >= 1 else 0)
max_len = max(max_len, dp[i])
return max_len