来源:算法讲解066【必备】从递归入手一维动态规划 · 2026-10-31
依据:无字幕,部分内容由我补充(已标注)
一维动态规划(1D Dynamic Programming)就是把「大问题」拆成「只依赖前面若干个位置的小问题」,用数组 dp[i] 记录「以 i 结尾 / 前 i 个元素」的最优值,按 i 从小到大递推,每个状态只花 O(1)(有时 O(k))转移。
核心特征:状态是一维的,转移只依赖 dp[i-1]、dp[i-2] 或 dp[j] (j<i),不涉及二维区间。
f(n) = f(n-1) + f(n-2) 就是递归。memo[] 存下来,算过就直接返回。这是「自顶向下」的 DP。dp[0], dp[1], ... 一路算到 dp[n]。这是「自底向上」的 DP,也就是通常说的一维 DP。dp[i] 表示什么。定义错了后面全错。dp[i] 怎么由前面的 dp 推出来。dp[0]、dp[1] 这种最小规模的值,必须手动初始化。dp[i] 一旦算出来,后面不会再改它,也不依赖「怎么走到 i 的」,只依赖数值本身。以斐波那契为例:f(n) = f(n-1) + f(n-2)。直接递归的调用树是指数级的,因为 f(n-2) 会被算很多遍。
关键观察:f(k) 只由 k 决定,跟「谁调用它」无关。所以算一次存起来,之后 O(1) 取用。
为什么从小到大填表是对的:转移方程里 dp[i] 只依赖下标比 i 小的项,那么按 i 递增的顺序填,填到 dp[i] 时它依赖的项一定已经填好了。这就是拓扑序(Topological Order)——一维下标天然就是拓扑序。
为什么可以滚动数组:如果 dp[i] 只依赖 dp[i-1] 和 dp[i-2],那更早的值永远用不到了,两个变量 prev2, prev1 就够,空间从 O(n) 降到 O(1)。
直觉总结:DP = 递归 + 不重复算 + 按依赖顺序算。一维 DP 就是「依赖顺序恰好是下标顺序」的最简单情形。
dp[i] 的含义(「前 i 个」还是「以 i 结尾」,两者转移完全不同)。dp[i] 由哪些 dp[j] (j<i) 转移来,取 max/min/sum。dp[0]、dp[1] 手动赋值;注意下标从 0 还是 1 开始。for i = 1..n,内层若需枚举 j 则 for j = 0..i-1。dp[n] 还是 max(dp[0..n])(「以 i 结尾」型通常要取全局 max)。fill)。最大子段和:a = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
定义 dp[i] = 以 a[i] 结尾的最大子段和。
转移:dp[i] = max(a[i], dp[i-1] + a[i])。
答案:max(dp[0..n-1])。
| i | a[i] | dp[i-1]+a[i] | dp[i] | 当前全局 max |
|---|------|--------------|-------|--------------|
| 0 | -2 | — | -2 | -2 |
| 1 | 1 | -1 | 1 | 1 |
| 2 | -3 | -2 | -2 | 1 |
| 3 | 4 | 2 | 4 | 4 |
| 4 | -1 | 3 | 3 | 4 |
| 5 | 2 | 5 | 5 | 5 |
| 6 | 1 | 6 | 6 | 6 |
| 7 | -5 | 1 | 1 | 6 |
| 8 | 4 | 5 | 5 | 6 |
答案 = 6(子段 [4,-1,2,1])。
Python(最大子段和,好读版)
def max_subarray(a):
n = len(a)
if n == 0:
return 0
dp = [0] * n
dp[0] = a[0]
ans = dp[0]
for i in range(1, n):
dp[i] = max(a[i], dp[i - 1] + a[i])
ans = max(ans, dp[i])
return ans
# 滚动数组版
def max_subarray_roll(a):
prev = a[0]
ans = a[0]
for x in a[1:]:
prev = max(x, prev + x)
ans = max(ans, prev)
return ans
C++(竞赛风,最大子段和,滚动数组)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<long long> a(n);
for (auto &x : a) cin >> x;
long long prev = a[0]; // dp[i-1]
long long ans = a[0]; // 全局最大
for (int i = 1; i < n; ++i) {
prev = max(a[i], prev + a[i]); // dp[i] = max(a[i], dp[i-1]+a[i])
ans = max(ans, prev);
}
cout << ans << '\n';
return 0;
}
模板 1:最大子段和(滚动)
// 输入 n 和数组 a,输出最大子段和(至少选一个元素)
long long maxSubarray(const vector<long long>& a) {
long long prev = a[0], ans = a[0];
for (int i = 1; i < (int)a.size(); ++i) {
prev = max(a[i], prev + a[i]);
ans = max(ans, prev);
}
return ans;
}
模板 2:最长上升子序列 LIS(O(n log n),严格上升)
// 返回 LIS 长度;tails[k] = 长度为 k+1 的上升子序列的最小结尾
int lisLength(const vector<int>& a) {
vector<int> tails;
for (int x : a) {
auto it = lower_bound(tails.begin(), tails.end(), x); // 严格上升用 lower_bound
if (it == tails.end()) tails.push_back(x);
else *it = x;
}
return (int)tails.size();
}
// 非严格上升(允许相等)把 lower_bound 换成 upper_bound
模板 3:打家劫舍(不能选相邻)
// dp[i] = max(dp[i-1], dp[i-2] + a[i])
long long rob(const vector<long long>& a) {
long long prev2 = 0, prev1 = 0; // dp[i-2], dp[i-1]
for (long long x : a) {
long long cur = max(prev1, prev2 + x);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
模板 4:解码方法(计数,取模)
const int MOD = 1'000'000'007;
// s 为数字串,返回解码方案数
int numDecodings(const string& s) {
int n = (int)s.size();
vector<long long> dp(n + 1, 0);
dp[0] = 1;
dp[1] = (s[0] != '0') ? 1 : 0;
for (int i = 2; i <= n; ++i) {
if (s[i - 1] != '0') dp[i] = (dp[i] + dp[i - 1]) % MOD;
int two = (s[i - 2] - '0') * 10 + (s[i - 1] - '0');
if (two >= 10 && two <= 26) dp[i] = (dp[i] + dp[i - 2]) % MOD;
}
return (int)dp[n];
}
max(dp[0..n-1]),不是 dp[n-1])。dp 开 n+1 用 1-based 更省心;开 n 用 0-based 时注意 dp[0] 是边界。a[0] 越界。a[0] 或 -INF。dp 用 long long,取模前别溢出。%;减法取模要 (x % MOD + MOD) % MOD。vector 重新构造或 fill(dp.begin(), dp.end(), 0);别忘清 ans。lower_bound 严格上升,upper_bound 非严格,写反直接 WA。prev2 = prev1; prev1 = cur; 顺序写反就错,先更新 prev2 再更新 prev1。dpMax[i], dpMin[i] 两个一维数组。lower_bound 改 upper_bound。dp[i] = sum(dp[i-1..i-k]),用前缀和/滑动窗口优化到 O(n)。* 通配**:多分支讨论,仍是 O(n) 一维。dp[i] = 到 i 的最少步数,或贪心 BFS 层数。dp[n-1],忘了取全局 max。dp[0] 时没考虑题目是否允许「空段」(允许空段才初始化为 0)。tails 数组不是真正的 LIS,只是长度对,别拿它还原序列(要还原得存前驱)。vector<int> dp(n+1) 每次重建没问题,但全局数组忘了 memset。int 存方案数,中途溢出再取模,结果错。题 1:给长度 n 的数组 a(可正可负),求最大子段和,要求子段非空。n ≤ 1e6,|a[i]| ≤ 1e9。
滚动数组:prev = a[0], ans = a[0],for i=1..n-1: prev = max(a[i], prev+a[i]); ans = max(ans, prev)。时间 O(n),空间 O(1)。注意用 long long,全负时 ans 是最大负数而非 0。
</details>
题 2:给长度 n 的数组 a,求最长严格上升子序列长度。n ≤ 1e5。
维护 tails 数组(长度为 k+1 的上升子序列的最小结尾),对每个 x 用 lower_bound 找第一个 ≥ x 的位置替换,找不到就 push_back。答案 = tails.size()。时间 O(n log n),空间 O(n)。非严格上升改 upper_bound。
</details>
题 3:n 级台阶,每次可走 1 或 2 级,求走到第 n 级的方案数,模 1e9+7。n ≤ 1e6。
dp[0]=1, dp[1]=1,dp[i] = (dp[i-1] + dp[i-2]) % MOD。滚动两个变量即可,空间 O(1)。注意 n=0 时答案为 1(空方案),n=1 时为 1。用 long long 存中间和再取模。
</details>