docs: 新增十大动态规划题(Go ACM 模式)详解
Deploy Docs / deploy (push) Successful in 11s

This commit is contained in:
2026-09-05 16:35:30 +08:00
parent 3fbc811e0a
commit 90bab8b786
3 changed files with 660 additions and 0 deletions
+651
View File
@@ -0,0 +1,651 @@
# 十大经典动态规划题(Go · ACM 模式)
!!! note "💡 一句话概述"
精选 10 道由易到难的 LeetCode 经典动态规划题,每道题重点讲解**状态定义、状态转移方程和 base case**,并给出完整的 Go **ACM 模式**(可编译运行的含 `main` 输入输出)题解。
---
## 🔑 核心概念
1. **状态(dp 含义)** — DP 的第一步:明确 `dp[i]` / `dp[i][j]` 代表什么,定义清楚才能写出转移方程
2. **转移方程** — 用子问题递推当前问题,是 DP 的灵魂;从"最后一步"倒推最容易写对
3. **base case(边界)** — 递推的起跑线,通常是 `i=0`、空串、对角线等最小子问题
4. **遍历顺序** — 一维从左到右;二维按行;区间按长度;背包注意 0/1 逆序 vs 完全背包顺序
5. **滚动数组 / 状态压缩** — 当转移只依赖最近几行时,可由二维压成一维,降低空间复杂度
---
## 📝 详细说明
### 动态规划通用思考框架
拿到一道 DP 题,按以下 4 步走(全文每题都按这 4 步展开):
1. **明确状态**:`dp[i]` 表示"以第 i 个元素结尾 / 前 i 个元素"的答案是什么
2. **写出转移**:考虑**最后一个动作**,把问题拆成更小的子问题(如爬到第 i 阶最后一步是 1 阶还是 2 阶)
3. **确定 base**:`dp[0]`、`dp[1]` 或空串、边界行/列的值
4. **决定顺序**:确保计算 `dp[i]` 时依赖的子问题(`dp[i-1]` 等)已经算好
### 10 题难度梯度总览
| 难度 | 题号 | 题目 | DP 范式 | 核心转移方程 |
|------|------|------|---------|--------------|
| ★ | 1 | 爬楼梯 | 一维线性 | `dp[i]=dp[i-1]+dp[i-2]` |
| ★ | 2 | 打家劫舍 | 一维 选/不选 | `dp[i]=max(dp[i-1],dp[i-2]+nums[i])` |
| ★★ | 3 | 最长递增子序列 | 一维序列 | `dp[i]=max(dp[j]+1)`(`nums[j]<nums[i]`)|
| ★★ | 4 | 不同路径 | 二维网格 | `dp[i][j]=dp[i-1][j]+dp[i][j-1]` |
| ★★ | 5 | 最长公共子序列 | 二维序列 | 相同 `+1`,不同取 max 左右 |
| ★★ | 6 | 分割等和子集 | 0/1 背包 | 逆序 `dp[j] \|= dp[j-num]` |
| ★★★ | 7 | 完全平方数 | 完全背包 | 顺序 `dp[j]=min(dp[j],dp[j-s]+1)` |
| ★★★ | 8 | 单词拆分 | 完全背包/匹配 | `dp[i]` 由 `dp[j]`+子串命中推出 |
| ★★★ | 9 | 编辑距离 | 二维序列 三操作 | 增删替三者取 min |
| ★★★ | 10 | 最长回文子序列 | 区间 DP | `dp[i][j]=dp[i+1][j-1]+2` |
---
## 第 1 题:爬楼梯
!!! note "题目"
假设你正在爬楼梯。需要 **n** 阶你才能到楼顶。每次你可以爬 **1** 或 **2** 个台阶。问有多少种不同方法可以爬到楼顶?(LeetCode 70)
### 状态定义与转移
- 状态:`f[i]` 表示爬到第 `i` 阶的方法总数
- 转移:爬到第 `i` 阶,最后一步要么从 `i-1` 走 1 阶,要么从 `i-2` 走 2 阶,因此 **`f[i] = f[i-1] + f[i-2]`**
- base:`f[0]=1`(原地),`f[1]=1`
```mermaid
graph LR
A["f[i-2]"] --> C["f[i] = f[i-1] + f[i-2]"]
B["f[i-1]"] --> C
```
### ACM 题解(Go)
```go
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n) // ACM 模式:读入 n
if n <= 2 {
fmt.Println(n)
return
}
f := make([]int, n+1)
f[1], f[2] = 1, 2
for i := 3; i <= n; i++ {
f[i] = f[i-1] + f[i-2]
}
fmt.Println(f[n])
}
```
---
## 第 2 题:打家劫舍
!!! note "题目"
你是一个专业小偷,沿街的房屋排成一排,相邻房屋连着保安。给定每个房屋的金额 `nums[i]`,求**不偷相邻两家**的前提下能偷到的最大金额。(LeetCode 198)
### 状态定义与 DP
- 状态:`dp[i]` 表示偷到前 **i** 间房子(下标 0..i)能得到的最大金额
- 转移:对第 `i` 间房,**偷**(`nums[i]` + 前 `i-2` 间最优)还是**不偷**(前 `i-1` 间最优),取大者:
**`dp[i] = max(dp[i-1], dp[i-2] + nums[i])`**
- base:`dp[0]=nums[0]`,`dp[1]=max(nums[0],nums[1])`
```mermaid
graph LR
A["dp[i-1] 不偷"] -.-> D{"max"}
B["dp[i-2] + nums[i] 偷"] --> D
D --> E["dp[i]"]
```
### ACM 题解(Go)
```go
package main
import (
"bufio"
"fmt"
"os"
)
func max(a, b int) int {
if a > b {
return a
}
return b
}
func main() {
var n int
fmt.Scan(&n)
nums := make([]int, n)
sc := bufio.NewScanner(os.Stdin)
sc.Split(bufio.ScanWords)
for i := 0; i < n; i++ {
sc.Scan()
fmt.Sscan(sc.Text(), &nums[i])
}
if n == 0 {
fmt.Println(0)
return
}
if n == 1 {
fmt.Println(nums[0])
return
}
dp := make([]int, n)
dp[0], dp[1] = nums[0], max(nums[0], nums[1])
for i := 2; i < n; i++ {
dp[i] = max(dp[i-1], dp[i-2]+nums[i])
}
fmt.Println(dp[n-1])
}
```
---
## 第 3 题:最长递增子序列(LIS)
!!! note "题目说明"
给整数数组 `nums`,求**最长严格递增**的子序列(子序列可不连续,但要保持原顺序)的长度。(LeetCode 300)
### 思路与转移
- 状态:`dp[i]` 表示**以 `nums[i]` 结尾**的最长递增子序列长度
- 转移:从左往右枚举 `j < i`,若 `nums[j] < nums[i]`,则 `nums[i]` 可接到以 `nums[j]` 结尾的子序列后面,故 **dp[i] = max(dp[i], dp[j] + 1)**
- base:每个元素单独成序列,`dp[i]` 初始为 1
```mermaid
graph TD
A["i-th 元素"] --> B{"有 j<i 且 nums[j]<nums[i] ?"}
B -->|是| C["dp[i] = max(dp[i], dp[j]+1)"]
B -->|否| D["dp[i] = 1"]
```
### ACM 题解(Go)
```go
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
var n int
fmt.Scan(&n)
nums := make([]int, n)
sc := bufio.NewScanner(os.Stdin)
sc.Split(bufio.ScanWords)
for i := 0; i < n; i++ {
sc.Scan()
fmt.Sscan(sc.Text(), &nums[i])
}
dp := make([]int, n)
ans := 0
for i := 0; i < n; i++ {
dp[i] = 1
for j := 0; j < i; j++ {
if nums[j] < nums[i] && dp[j]+1 > dp[i] {
dp[i] = dp[j] + 1
}
}
if dp[i] > ans {
ans = dp[i]
}
}
fmt.Println(ans)
}
```
!!! tip "O(n log n) 优化"
用贪心+二分维护"长度为 len 的最小末尾",可将 LIS 优化到 O(n log n),但上面的 O(n²) 已能直观体现 dp 精髓,适合入门。
---
## 第 4 题:不同路径
!!! note "题目说明"
机器人在 `m × n` 网格左上角,每次只能**向右或向下**走一格,问到达右下角有多少条不同路径?(LeetCode 62)
### 状态定义与转移
- 状态:`dp[i][j]` 表示从左上角走到 `(i,j)` 的路径条数
- 转移:到达 `(i,j)` 只能从左边的 `(i,j-1)` 或上边的 `(i-1,j)` 过来,故 **dp[i][j] = dp[i-1][j] + dp[i][j-1]**
- base:第一行只能一路向右、第一列只能一路向下,故均为 1
```mermaid
graph TD
A["(i-1,j) 从上"] --> B["dp[i][j]"]
C["(i,j-1) 从左"] --> B
B --> D["dp[i][j] = dp[i-1][j] + dp[i][j-1]"]
```
### ACM 题解(Go)
```go
package main
import "fmt"
func main() {
var m, n int
fmt.Scan(&m, &n)
dp := make([][]int, m)
for i := range dp {
dp[i] = make([]int, n)
dp[i][0] = 1
}
for j := 0; j < n; j++ {
dp[0][j] = 1
}
for i := 1; i < m; i++ {
for j := 1; j < n; j++ {
dp[i][j] = dp[i-1][j] + dp[i][j-1]
}
}
fmt.Println(dp[m-1][n-1])
}
```
---
## 第 5 题:最长公共子序列(LCS)
!!! note "题目说明"
给定两个字符串 `text1` 和 `text2`,返回它们的最长公共子序列的长度。子序列可以不连续。(LeetCode 1143)
### 二维表转移
- 状态:`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])**
- base:`dp[0][*]=dp[*][0]=0`(空串与任何串的 LCS 为 0)
```mermaid
graph TD
A{"text1[i-1] == text2[j-1] ?"}
A -->|==| B["dp[i][j] = dp[i-1][j-1] + 1"]
A -->|!=| C["dp[i][j] = max(dp[i-1][j], dp[i][j-1])"]
```
### ACM 题解(Go)
```go
package main
import (
"bufio"
"fmt"
"os"
)
func max(a, b int) int {
if a > b {
return a
}
return b
}
func main() {
var a, b string
sc := bufio.NewScanner(os.Stdin)
sc.Split(bufio.ScanWords)
sc.Scan()
a = sc.Text()
sc.Scan()
b = sc.Text()
m, n := len(a), len(b)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if a[i-1] == b[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
}
}
}
fmt.Println(dp[m][n])
}
```
---
## 第 6 题:分割等和子集
!!! note "题目条件"
给定**非空**正整数数组 `nums`,判断能否把它分割成**两个元素和相等的子集**,即能否选出若干个数之和等于总和的一半。(LeetCode 416)
### 转化为 0/1 背包
sum 为奇直接不可能。目标 `target = sum/2`。这就是一个容量为 `target` 的 **0/1 背包问题**:每个数只能取一次,问能否恰好凑出 `target`。
- 状态:`dp[j]` 表示能否选出若干个数之和恰好等于 `j`
- 转移:对每个 `num`,若 `dp[j-num]==true` 则 `dp[j]` 可为 true,故 **dp[j] = dp[j] || dp[j - num]**
- 关键:背包**逆序遍历** `j`,保证 `num` 不会被重复使用(0/1 背包特性)
- base:`dp[0]=true`
```go
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
var n int
fmt.Scan(&n)
nums := make([]int, n)
sc := bufio.NewScanner(os.Stdin)
sc.Split(bufio.ScanWords)
sum := 0
for i := 0; i < n; i++ {
sc.Scan()
fmt.Sscan(sc.Text(), &nums[i])
sum += nums[i]
}
if sum%2 == 1 {
fmt.Println("NO")
return
}
target := sum / 2
dp := make([]bool, target+1)
dp[0] = true
for _, num := range nums {
for j := target; j >= num; j-- { // 必须逆序:0/1 背包,一个数只能用一次
if dp[j-num] {
dp[j] = true
}
}
}
if dp[target] {
fmt.Println("YES")
} else {
fmt.Println("NO")
}
}
```
---
## 第 7 题:完全平方数
**题目描述**:给定正整数 `n`,求**最少数量的完全平方数**(如 1,4,9,16...)使其和为 `n`。(LeetCode 279)
这本质是一个**完全背包**(每种平方数可以重复取)求最小个数:
- 状态:`dp[j]` 表示凑出 `j` 所需的最少完全平方数个数
- 转移(加入一个平方数 `s`):**dp[j] = min(dp[j], dp[j-s] + 1)**
- 注意这是**完全背包**:`j` **顺序**遍历,允许重复取同一个平方数
- base:`dp[0]=0`,其余初始化为无穷大
```go
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
const INF = 1 << 30
dp := make([]int, n+1)
for i := range dp {
dp[i] = INF
}
dp[0] = 0
for s := 1; s*s <= n; s++ { // 物品:完全平方数 s^2
sq := s * s
for j := sq; j <= n; j++ { // 顺序:完全背包,可重复取
if dp[j-sq]+1 < dp[j] {
dp[j] = dp[j-sq] + 1
}
}
}
fmt.Println(dp[n])
}
```
---
## 第 8 题:单词拆分
**题目描述**:给定字符串 `s` 和一个字典 `wordDict`,判断 `s` 是否可以被空格拆分成字典中一个或多个单词。(LeetCode 139)
- 状态:`dp[i]` 表示 `s` 的前 `i` 个字符能否被成功拆分
- 转移:若存在 `j < i` 使得 `dp[j]==true` 且区间 `s[j:i]` 是一个字典单词,则 `dp[i]=true`
即 **dp[i] = dp[j] && s[j:i] ∈ wordDict**(只要任一个 j 成立即可)
- base:`dp[0]=true`(空串可拆分)
```go
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
var n int
var s string
sc := bufio.NewScanner(os.Stdin)
sc.Split(bufio.ScanWords)
sc.Scan()
s = sc.Text()
sc.Scan()
fmt.Sscan(sc.Text(), &n)
words := make(map[string]bool)
for i := 0; i < n; i++ {
sc.Scan()
words[sc.Text()] = true
}
m := len(s)
dp := make([]bool, m+1)
dp[0] = true
for i := 1; i <= m; i++ {
for j := 0; j < i; j++ {
if dp[j] && words[s[j:i]] {
dp[i] = true
break
}
}
}
if dp[m] {
fmt.Println("YES")
} else {
fmt.Println("NO")
}
}
```
---
## 第 9 题:编辑距离
**题目描述**:给定两个字符串 `word1` 和 `word2`,通过对 `word1` 进行以下三种操作——插入、删除、替换一个字符——求把 `word1` 变成 `word2` 的**最少操作数**。(LeetCode 72)
- 状态:`dp[i][j]` 表示把 `word1[0:i]` 变成 `word2[0:j]` 的最少步数
- 转移(看 `word1[i-1]` 与 `word2[j-1]`):
- 相同:**dp[i][j] = dp[i-1][j-1]**(不用动)
- 不同:取三者的最小值加 1
- 删除 word1[i-1]:`dp[i-1][j] + 1`
- 插入 word2[j-1]:`dp[i][j-1] + 1`
- 替换:`dp[i-1][j-1] + 1`
- base:`dp[i][0]=i`(全删),`dp[0][j]=j`(全插)
```go
package main
import (
"bufio"
"fmt"
"os"
)
func min(a, b int) int {
if a < b {
return a
}
return b
}
func main() {
var w1, w2 string
sc := bufio.NewScanner(os.Stdin)
sc.Split(bufio.ScanWords)
sc.Scan()
w1 = sc.Text()
sc.Scan()
w2 = sc.Text()
m, n := len(w1), len(w2)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 0; i <= m; i++ {
dp[i][0] = i
}
for j := 0; j <= n; j++ {
dp[0][j] = j
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
c := dp[i-1][j-1] // 替换的代价(若字符相等则 0)
if w1[i-1] == w2[j-1] {
dp[i][j] = dp[i-1][j-1]
continue
}
dp[i][j] = min(min(dp[i-1][j]+1, dp[i][j-1]+1), c+1)
}
}
fmt.Println(dp[m][n])
}
```
---
## 第 10 题:最长回文子序列
**题目描述**:给定字符串 `s`,找到其中最长的回文子序列的长度(子序列可不连续)。例如 `bbbab` → `bbbb` 长度为 4。(LeetCode 516)
- 状态:`dp[i][j]` 表示子串 `s[i:j+1]` 内的最长回文子序列长度
- 转移(两端字符比较):
- 若 `s[i]==s[j]`:**dp[i][j] = dp[i+1][j-1] + 2**(两端都能加进回文)
- 若不同:**dp[i][j] = max(dp[i+1][j], dp[i][j-1])**(只能从两边去掉一个端)
- base:`dp[i][i]=1`(单个字符是回文)
- **遍历顺序**:区间 DP 要**按区间长度从小到大**计算,保证 `dp[i+1][j-1]` 等更短区间已求出
```go
package main
import (
"bufio"
"fmt"
"os"
)
func max(a, b int) int {
if a > b {
return a
}
return b
}
func main() {
var s string
sc := bufio.NewScanner(os.Stdin)
sc.Split(bufio.ScanWords)
sc.Scan()
s = sc.Text()
n := len(s)
dp := make([][]int, n)
for i := range dp {
dp[i] = make([]int, n)
dp[i][i] = 1
}
for length := 2; length <= n; length++ { // 按区间长度从小到大
for i := 0; i+length-1 < n; i++ {
j := i + length - 1
if s[i] == s[j] {
if i+1 <= j-1 {
dp[i][j] = dp[i+1][j-1] + 2
} else {
dp[i][j] = 2
}
} else {
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
}
}
}
fmt.Println(dp[0][n-1])
}
```
---
## ⚠️ 常见陷阱
!!! warning "0/1 背包 vs 完全背包的遍历顺序"
分割等和子集(每个数只能用一次)必须**逆序**遍历 `j`;完全平方数(每个数可重复)必须**顺序**遍历 `j`。弄反了会导致重复计数或漏算,结果完全错误。
!!! warning "区间 DP 的遍历方向"
第 10 题 `dp[i][j]` 依赖更短的 `dp[i+1][j-1]`,所以必须**按区间长度 level** 从小到大枚举,而不是简单地 i 从 0 到 n。
!!! warning "LIS 子序列 vs 子数组"
dp[i] 定义成"以 nums[i] 结尾",答案应是所有 dp[i] 取 max,而不是直接 dp[n-1]。求最长递增**子数组**(连续)时才看 dp[n-1]。
!!! warning "编辑距离的相等的 case"
当 `word1[i-1]==word2[j-1]` 时直接继承 `dp[i-1][j-1]`,不要额外 +1,否则会把"本来不用改"的情况误算成替换。
---
## 🏋️ 练习题
??? question "练习 1:LeetCode 746 — 最小花费爬楼梯"
数组 `cost` 表示每阶楼梯往上爬的开销,可以从第 0 或第 1 阶开始,每步能走 1 或 2 阶,到顶(越过数组末尾)最少花费多少?请用 dp 写出转移方程。
??? success "答案"
状态 `dp[i]` 表示到达第 i 阶的最小花费,因为可以从 `i-1`(花 `cost[i-1]`)或 `i-2`(花 `cost[i-2]`)到达,因此 `dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2])`,最终取 `min(dp[n], dp[n-1])`。
??? question "练习 2:LeetCode 322 — 零钱兑换"
给定不同面额硬币 `coins` 和总金额 `amount`,问凑成 `amount` 至少需要多少枚硬币。它和第 7 题完全平方数有什么关系?可以仿照完全背包写出吗?
??? success "答案"
类似,`dp[j] = min(dp[j], dp[j-coin]+1)`,且 `coin` 可重复使用,因此顺序遍历。与第 7 题唯一区别是物品从"平方数"换成"硬币面额",解法完全一致。
??? question "练习 3:LeetCode 5 — 最长回文子串(子串 vs 子序列)"
第 10 题求的是**子序列**。如果改成**连续的子串**,状态和转移哪里有区别?请说明并改 dp。
??? success "思路"
子串要求连续,`dp[i][j]` 表示 `s[i:j+1]` 是否回文,转移为:`s[i]==s[j]` 且 `j-i<=1` 或 `dp[i+1][j-1]`。需要**记住最长长度**的起点和结束。子串具有"连续性",子序列则可跳跃,这是关键差别。
---
## 🔗 相关链接
- [LeetCode 70. 爬楼梯](https://leetcode.cn/problems/climbing-stairs/) — 一维斐波那契入门
- [LeetCode 198. 打家劫舍](https://leetcode.cn/problems/house-robber/) — 一维选/不选
- [LeetCode 300. 最长递增子序列](https://leetcode.cn/problems/longest-increasing-subsequence/) — 序列 DP
- [LeetCode 62. 不同路径](https://leetcode.cn/problems/unique-paths/) — 网格 DP
- [LeetCode 1143. 最长公共子序列](https://leetcode.cn/problems/longest-common-subsequence/) — LCS
- [LeetCode 416. 分割等和子集](https://leetcode.cn/problems/partition-equal-subset-sum/) — 0/1 背包
- [LeetCode 279. 完全平方数](https://leetcode.cn/problems/perfect-squares/) — 完全背包
- [LeetCode 139. 单词拆分](https://leetcode.cn/problems/word-break/) — 背包式匹配
- [LeetCode 72. 编辑距离](https://leetcode.cn/problems/edit-distance/) — 三操作二维
- [LeetCode 516. 最长回文子序列](https://leetcode.cn/problems/longest-palindromic-subsequence/) — 区间 DP
+8
View File
@@ -1,3 +1,11 @@
# 算法
算法相关笔记与文档。
## 主题
- 布隆过滤器
- HeavyKeeper
- 二叉树的遍历
- 队列
- 动态规划(十大经典题 · Go ACM 模式)
+1
View File
@@ -122,6 +122,7 @@ nav:
- HeavyKeeper: algorithm/heavykeeper.md
- 二叉树的遍历: algorithm/binary-tree-traversal.md
- 队列: algorithm/queue.md
- 十大动态规划题: algorithm/dynamic-programming-top10.md
- JVM:
- jvm/index.md
- 垃圾回收基础: jvm/garbage-collection.md