动态规划进阶:转移方程优化技巧全解
-
- 一、优化的核心目标与前提
-
- [1.1 常见可优化的转移方程形式](#1.1 常见可优化的转移方程形式)
- [1.2 优化的前提](#1.2 优化的前提)
- 二、单调队列优化:区间最值的高效查询
-
- [2.1 适用场景](#2.1 适用场景)
- [2.2 核心思想](#2.2 核心思想)
- [2.3 案例:滑动窗口最大值的DP版本](#2.3 案例:滑动窗口最大值的DP版本)
- 三、斜率优化:线性函数的最值求解
-
- [3.1 适用场景](#3.1 适用场景)
- [3.2 核心思想](#3.2 核心思想)
- [3.3 案例:任务安排问题](#3.3 案例:任务安排问题)
- 四、前缀和优化:区间累加的快速计算
-
- [4.1 适用场景](#4.1 适用场景)
- [4.2 核心思想](#4.2 核心思想)
- [4.3 案例:最大子段和(带长度限制)](#4.3 案例:最大子段和(带长度限制))
- 五、状态压缩与滚动数组:空间维度的优化
-
- [5.1 适用场景](#5.1 适用场景)
- [5.2 核心思想](#5.2 核心思想)
- [5.3 案例:0/1背包的空间优化](#5.3 案例:0/1背包的空间优化)
- 六、矩阵快速幂:线性递推的加速
-
- [6.1 适用场景](#6.1 适用场景)
- [6.2 核心思想](#6.2 核心思想)
- [6.3 案例:斐波那契数列第n项](#6.3 案例:斐波那契数列第n项)
- 七、优化技巧的选择与总结
动态规划(DP)的核心是"定义状态+推导转移方程",但很多时候,基础的转移方程会导致时间复杂度偏高(如 O ( n 2 ) O(n^2) O(n2)),难以应对大规模输入,此时转移方程的优化就成为突破性能瓶颈的关键。
一、优化的核心目标与前提
在深入技巧之前,我们需要明确:转移方程优化的本质是减少冗余计算。当基础转移方程中存在"重复查询区间最值""线性函数最值""累加区间和"等操作时,就有优化的空间。
1.1 常见可优化的转移方程形式
-
区间最值型 : d p i = max j ∈ i − k , i − 1 ( d p j + w ( i , j ) ) dpi = \max_{j \in i-k, i-1} (dpj + w(i,j)) dpi=maxj∈i−k,i−1(dpj+w(i,j))(如滑动窗口类DP);
-
线性函数型 : d p i = min j < i ( a i ⋅ b j + c j + d i ) dpi = \min_{j < i} (ai \cdot bj + cj + di) dpi=minj<i(ai⋅bj+cj+di)(如任务安排问题);
-
区间累加型 : d p i = ∑ j = l i r i d p j + w i dpi = \sum_{j = li}^{ri} dpj + wi dpi=∑j=liridpj+wi(如区间和依赖的DP);
-
线性递推型 : d p i = k 1 ⋅ d p i − 1 + k 2 ⋅ d p i − 2 + ⋯ + k m ⋅ d p i − m dpi = k_1 \cdot dpi-1 + k_2 \cdot dpi-2 + \dots + k_m \cdot dpi-m dpi=k1⋅dpi−1+k2⋅dpi−2+⋯+km⋅dpi−m(如斐波那契数列的高阶扩展)。
1.2 优化的前提
- 能从转移方程中提取出"可复用的结构"(如单调关系、线性函数、区间和等);
- 优化后能减少状态转移的计算量(如从 O ( n ) O(n) O(n) per step降至 O ( 1 ) O(1) O(1)或 O ( log n ) O(\log n) O(logn))。
二、单调队列优化:区间最值的高效查询
单调队列优化适用于**转移方程中需要查询"滑动窗口内最值"**的场景,其核心是用一个"单调队列"维护候选状态 j j j,使每次查询最值的时间从 O ( n ) O(n) O(n)降至 O ( 1 ) O(1) O(1)。
2.1 适用场景
转移方程形如:
d p i = max j ∈ L ( i ) , R ( i ) ( d p j + w ( i , j ) ) dpi = \max_{j \in L(i), R(i)} (dpj + w(i,j)) dpi=maxj∈L(i),R(i)(dpj+w(i,j))
其中 L ( i ) L(i) L(i)和 R ( i ) R(i) R(i)是关于 i i i的单调函数(如 L ( i ) = i − k L(i) = i - k L(i)=i−k, R ( i ) = i − 1 R(i) = i - 1 R(i)=i−1),即窗口范围随 i i i单调变化。
2.2 核心思想
- 维护单调队列 :队列中存储候选 j j j的索引,且对应的 d p j + w ( i , j ) dpj + w(i,j) dpj+w(i,j)保持单调(递增或递减);
- 窗口裁剪 :当 j j j超出窗口范围 L ( i ) , R ( i ) L(i), R(i) L(i),R(i)时,从队首移除;
- 高效更新 :新 j j j加入队列时,从队尾移除所有"比当前 j j j差"的候选(即 d p j + w ( i , j ) dpj + w(i,j) dpj+w(i,j)不优的),确保队列单调性。
2.3 案例:滑动窗口最大值的DP版本
问题 :给定数组 n u m s nums nums和窗口大小 k k k,求每个位置 i i i对应的窗口 i − k + 1 , i i-k+1, i i−k+1,i内的最大值(用DP思想实现)。
基础转移方程:
d p i = max j = i − k + 1 i n u m s j dpi = \max_{j = i-k+1}^i numsj dpi=maxj=i−k+1inumsj( d p i dpi dpi表示以 i i i为结尾的窗口最大值)
优化思路:用单调队列存储窗口内元素的索引,队列保持元素值递减,队首即为当前窗口最大值。
java
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] dp = new int[n - k + 1]; // 结果数组
Deque<Integer> deque = new ArrayDeque<>(); // 单调队列(存储索引)
for (int i = 0; i < n; i++) {
// 1. 移除窗口外的元素(索引 <= i - k)
while (!deque.isEmpty() && deque.peekFirst() <= i - k) {
deque.pollFirst();
}
// 2. 移除队尾比当前元素小的元素(它们不可能是最大值)
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
// 3. 加入当前元素索引
deque.offerLast(i);
// 4. 窗口形成后,记录最大值(队首元素)
if (i >= k - 1) {
dp[i - k + 1] = nums[deque.peekFirst()];
}
}
return dp;
}
复杂度分析 :每个元素入队和出队各一次,总时间 O ( n ) O(n) O(n),空间 O ( k ) O(k) O(k)(队列最大存储 k k k个元素)。
三、斜率优化:线性函数的最值求解
斜率优化适用于转移方程可转化为线性函数最值问题 的场景,通过将状态 j j j映射为直线,将"求 d p i dpi dpi"转化为"求直线在某点的最值",再用凸包或单调队列维护直线集合。
3.1 适用场景
转移方程形如:
d p i = min j < i ( a i ⋅ b j + c j + d i ) dpi = \min_{j < i} (ai \cdot bj + cj + di) dpi=minj<i(ai⋅bj+cj+di)
其中 a i ai ai和 d i di di是仅与 i i i相关的函数, b j bj bj和 c j cj cj是仅与 j j j相关的函数。可改写为:
d p i = d i + min j < i ( b j ⋅ a i + c j ) dpi = di + \min_{j < i} (bj \cdot ai + cj) dpi=di+minj<i(bj⋅ai+cj)
此时,每个 j j j对应一条直线 y = b j ⋅ x + c j y = bj \cdot x + cj y=bj⋅x+cj, d p i dpi dpi即为 x = a i x = ai x=ai时所有直线的最小值(加 d i di di)。
3.2 核心思想
- 直线映射 :将每个 j j j映射为直线 y = k j ⋅ x + b j y = k_j \cdot x + b_j y=kj⋅x+bj( k j = b j k_j = bj kj=bj, b j = c j b_j = cj bj=cj);
- 维护有效直线集 :若直线 l 1 l_1 l1在所有 x x x上均不如 l 2 l_2 l2优,则 l 1 l_1 l1可被淘汰(凸包优化);
- 查询最值 :对于 x = a i x = ai x=ai,在有效直线集中找到最优直线(如斜率单调时用单调队列,否则用二分查找)。
3.3 案例:任务安排问题
问题 : n n n个任务按顺序执行,每次启动机器需支付固定成本 S S S,第 i i i个任务的成本为 t i ti ti,且成本随启动后时间累积(启动后第 k k k个任务的成本为 t i ⋅ k ti \cdot k ti⋅k)。求总最小成本。
基础转移方程:
d p i = min j < i ( d p j + S + ∑ k = j + 1 i t k ⋅ ( k − j ) ) dpi = \min_{j < i} (dpj + S + \sum_{k = j+1}^i tk \cdot (k - j)) dpi=minj<i(dpj+S+∑k=j+1itk⋅(k−j))
通过前缀和优化 ∑ \sum ∑项后,可转化为:
d p i = min j < i ( d p j − s u m T j ⋅ j ) + s u m T i ⋅ i + S − s u m T j dpi = \min_{j < i} (dpj - sumTj \cdot j) + sumTi \cdot i + S - sumTj dpi=minj<i(dpj−sumTj⋅j)+sumTi⋅i+S−sumTj
其中 s u m T i sumTi sumTi是 t t t的前缀和。
令 a i = s u m T i ai = sumTi ai=sumTi, b j = j bj = j bj=j, c j = d p j − s u m T j ⋅ j − s u m T j cj = dpj - sumTj \cdot j - sumTj cj=dpj−sumTj⋅j−sumTj,则:
d p i = a i ⋅ i + S + min j < i ( − b j ⋅ a i + c j ) dpi = ai \cdot i + S + \min_{j < i} ( -bj \cdot ai + cj ) dpi=ai⋅i+S+minj<i(−bj⋅ai+cj)
符合线性函数形式,可通过斜率优化求解。
java
public long minCost(int[] t, int S) {
int n = t.length;
long[] sumT = new long[n + 1]; // 前缀和:sumT[i] = t[0]+...+t[i-1]
for (int i = 1; i <= n; i++) {
sumT[i] = sumT[i - 1] + t[i - 1];
}
long[] dp = new long[n + 1];
Deque<Line> deque = new ArrayDeque<>();
// 初始直线:j=0时,k=-b[0]=0,b=c[0]=dp[0] - sumT[0]*0 - sumT[0] = 0 - 0 - 0 = 0
deque.offerLast(new Line(0, 0));
for (int i = 1; i <= n; i++) {
long x = sumT[i]; // 当前x = a[i] = sumT[i]
// 1. 弹出队首无效直线(斜率递增时,前面的直线在x处已不是最优)
while (deque.size() >= 2) {
Line l1 = deque.pollFirst();
Line l2 = deque.peekFirst();
if (getY(l1, x) >= getY(l2, x)) {
// l1不如l2优,移除
} else {
deque.addFirst(l1); // 恢复l1,退出
break;
}
}
// 2. 计算当前dp[i]
Line best = deque.peekFirst();
dp[i] = x * i + S + getY(best, x);
// 3. 加入当前j=i对应的直线
long k = -i; // k_j = -b[j] = -j
long b = dp[i] - sumT[i] * i - sumT[i]; // c[j] = dp[j] - sumT[j]*j - sumT[j]
Line newLine = new Line(k, b);
// 4. 弹出队尾无效直线(确保队列斜率递增,且新直线更优)
while (deque.size() >= 2) {
Line l2 = deque.pollLast();
Line l1 = deque.peekLast();
// 若l1与l2的交点 >= l2与newLine的交点,则l2无效
if (intersectionX(l1, l2) >= intersectionX(l2, newLine)) {
// 移除l2
} else {
deque.addLast(l2); // 恢复l2,退出
break;
}
}
deque.addLast(newLine);
}
return dp[n];
}
// 直线类:y = k*x + b
static class Line {
long k, b;
Line(long k, long b) {
this.k = k;
this.b = b;
}
}
// 计算直线在x处的y值
static long getY(Line l, long x) {
return l.k * x + l.b;
}
// 计算两条直线的交点x坐标(分数形式避免精度问题,此处简化为double)
static double intersectionX(Line l1, Line l2) {
return (double) (l2.b - l1.b) / (l1.k - l2.k);
}
复杂度分析 :每个 i i i对应的直线入队和出队各一次,总时间 O ( n ) O(n) O(n),空间 O ( n ) O(n) O(n)。
四、前缀和优化:区间累加的快速计算
前缀和优化适用于转移方程中需要累加区间和 的场景,通过预处理前缀和数组,将 O ( n ) O(n) O(n)的区间累加转为 O ( 1 ) O(1) O(1)查询。
4.1 适用场景
转移方程形如:
d p i = ∑ j = l i r i d p j + w i dpi = \sum_{j = li}^{ri} dpj + wi dpi=∑j=liridpj+wi
其中 l i li li和 r i ri ri是 i i i的函数,区间和的计算是瓶颈。
4.2 核心思想
-
预处理前缀和 :定义 p r e i = ∑ j = 0 i d p j prei = \sum_{j=0}^i dpj prei=∑j=0idpj,则区间和 ∑ j = l r d p j = p r e r − p r e l − 1 \sum_{j=l}^{r} dpj = prer - prel-1 ∑j=lrdpj=prer−prel−1;
-
替换转移方程 :用前缀和表达式替代原区间和,将每次转移的计算量从 O ( n ) O(n) O(n)降至 O ( 1 ) O(1) O(1)。
4.3 案例:最大子段和(带长度限制)
问题 :给定数组 n u m s nums nums和最大长度 k k k,求长度不超过 k k k的连续子数组的最大和。
基础转移方程:
d p i = max j = max ( 0 , i − k ) i − 1 ( d p j + n u m s i ) dpi = \max_{j = \max(0, i-k)}^{i-1} (dpj + numsi) dpi=maxj=max(0,i−k)i−1(dpj+numsi)( d p i dpi dpi表示以 i i i结尾的最大和)
优化思路 :用前缀和 p r e i prei prei记录 d p 0.. i dp0..i dp0..i的和,但更优的是结合单调队列维护区间最大值(同时用前缀和优化累加)。
java
public int maxSubarraySumWithLimit(int[] nums, int k) {
int n = nums.length;
int[] dp = new int[n];
dp[0] = nums[0];
int maxSum = dp[0];
// 前缀和数组
int[] pre = new int[n];
pre[0] = dp[0];
for (int i = 1; i < n; i++) {
pre[i] = pre[i - 1] + dp[i];
}
Deque<Integer> deque = new ArrayDeque<>();
deque.offer(0);
for (int i = 1; i < n; i++) {
// 移除窗口外的j(j < i - k)
while (!deque.isEmpty() && deque.peekFirst() < i - k) {
deque.pollFirst();
}
// dp[i] = max(dp[j] for j in [i-k, i-1]) + nums[i]
// 用单调队列的队首获取max(dp[j])
dp[i] = nums[i] + (deque.isEmpty() ? 0 : dp[deque.peekFirst()]);
// 维护单调队列(递减)
while (!deque.isEmpty() && dp[i] >= dp[deque.peekLast()]) {
deque.pollLast();
}
deque.offer(i);
maxSum = Math.max(maxSum, dp[i]);
}
return maxSum;
}
复杂度分析 :前缀和优化后,区间和查询为 O ( 1 ) O(1) O(1),结合单调队列维护最值,总时间 O ( n ) O(n) O(n)。
五、状态压缩与滚动数组:空间维度的优化
状态压缩适用于转移方程中当前状态仅依赖有限个前驱状态的场景,通过减少DP数组的维度(如二维→一维),降低空间复杂度。
5.1 适用场景
- 二维DP中, d p i j dpij dpij仅依赖 d p i − 1 j dpi-1j dpi−1j和 d p i j − 1 dpij-1 dpij−1(如0/1背包、最长公共子序列);
- 高维DP中,状态仅依赖前 k k k层(如 d p i j k dpijk dpijk仅依赖 d p i − 1 j k dpi-1jk dpi−1jk)。
5.2 核心思想
- 识别依赖关系:确定当前状态依赖的前驱状态范围(如仅前一行或前一列);
- 复用空间:用一维数组替代二维数组,通过"滚动"更新覆盖旧状态(注意遍历顺序,避免覆盖未使用的前驱状态)。
5.3 案例:0/1背包的空间优化
基础二维转移方程:
d p i j = max ( d p i − 1 j , d p i − 1 j − w \[ i ] + v i ) dpij = \max(dpi-1j, dpi-1j - w\[i] + vi) dpij=max(dpi−1j,dpi−1j−w\[i]+vi)(前 i i i个物品,容量 j j j的最大价值)
优化后一维转移方程:
d p j = max ( d p j , d p j − w \[ i ] + v i ) dpj = \max(dpj, dpj - w\[i] + vi) dpj=max(dpj,dpj−w\[i]+vi)(逆序遍历容量 j j j,避免重复使用同一物品)
java
public int knapsack(int[] w, int[] v, int C) {
int n = w.length;
int[] dp = new int[C + 1];
for (int i = 0; i < n; i++) {
// 逆序遍历容量,防止同一物品被多次选择
for (int j = C; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[C];
}
复杂度分析 :空间复杂度从 O ( n ⋅ C ) O(n \cdot C) O(n⋅C)降至 O ( C ) O(C) O(C),时间复杂度仍为 O ( n ⋅ C ) O(n \cdot C) O(n⋅C)。
六、矩阵快速幂:线性递推的加速
矩阵快速幂适用于线性递推关系 的DP问题(如斐波那契数列、线性 recurrence),当 n n n很大时(如 n ≤ 1 0 9 n \leq 10^9 n≤109),可将时间复杂度从 O ( n ) O(n) O(n)降至 O ( log n ) O(\log n) O(logn)。
6.1 适用场景
转移方程形如线性递推:
d p i = a 1 ⋅ d p i − 1 + a 2 ⋅ d p i − 2 + ⋯ + a k ⋅ d p i − k dpi = a_1 \cdot dpi-1 + a_2 \cdot dpi-2 + \dots + a_k \cdot dpi-k dpi=a1⋅dpi−1+a2⋅dpi−2+⋯+ak⋅dpi−k
可表示为矩阵乘法: d p \[ i d p i − 1 ⋮ d p i − k + 1 ] = M ⋅ d p \[ i − 1 d p i − 2 ⋮ d p i − k ] \begin{bmatrix} dpi \\ dpi-1 \\ \vdots \\ dpi-k+1 \end{bmatrix} = M \cdot \begin{bmatrix} dpi-1 \\ dpi-2 \\ \vdots \\ dpi-k \end{bmatrix} dpidpi−1⋮dpi−k+1 =M⋅ dpi−1dpi−2⋮dpi−k ,其中 M M M是转移矩阵。
6.2 核心思想
- 构建转移矩阵:将递推关系转化为矩阵乘法形式;
- 矩阵幂加速 :用快速幂计算转移矩阵的 n n n次幂,再与初始向量相乘,得到 d p n dpn dpn。
6.3 案例:斐波那契数列第n项
递推关系 : d p i = d p i − 1 + d p i − 2 dpi = dpi-1 + dpi-2 dpi=dpi−1+dpi−2( d p 0 = 0 dp0=0 dp0=0, d p 1 = 1 dp1=1 dp1=1)
矩阵形式 : d p \[ i d p i − 1 ] = 1 1 1 0 ⋅ d p \[ i − 1 d p i − 2 ] \begin{bmatrix} dpi \\ dpi-1 \end{bmatrix} = \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix} \cdot \begin{bmatrix} dpi-1 \\ dpi-2 \end{bmatrix} dp\[idpi−1]=1110⋅dp\[i−1dpi−2]
java
public int fib(int n) {
if (n <= 1) return n;
// 转移矩阵:[[1,1],[1,0]]
int[][] mat = {{1, 1}, {1, 0}};
// 计算mat^(n-1)
int[][] matPow = matrixPower(mat, n - 1);
// 初始向量[dp[1], dp[0]] = [1, 0]
return matPow[0][0] * 1 + matPow[0][1] * 0;
}
// 矩阵快速幂:计算mat^power
int[][] matrixPower(int[][] mat, int power) {
int n = mat.length;
// 初始化单位矩阵
int[][] result = new int[n][n];
for (int i = 0; i < n; i++) {
result[i][i] = 1;
}
while (power > 0) {
if (power % 2 == 1) {
result = matrixMultiply(result, mat);
}
mat = matrixMultiply(mat, mat);
power /= 2;
}
return result;
}
// 矩阵乘法:a * b
int[][] matrixMultiply(int[][] a, int[][] b) {
int n = a.length;
int[][] res = new int[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
for (int k = 0; k < n; k++) {
res[i][j] += a[i][k] * b[k][j];
}
}
}
return res;
}
复杂度分析 :矩阵乘法时间为 O ( k 3 ) O(k^3) O(k3)( k k k为矩阵维度),快速幂迭代 O ( log n ) O(\log n) O(logn)次,总时间 O ( k 3 log n ) O(k^3 \log n) O(k3logn),适用于 n n n极大的场景。
七、优化技巧的选择与总结
| 优化技巧 | 适用场景 | 时间复杂度优化 | 核心工具/思想 |
|---|---|---|---|
| 单调队列优化 | 区间最值查询(窗口单调) | O ( n 2 ) → O ( n ) O(n^2) \to O(n) O(n2)→O(n) | 单调队列维护候选集 |
| 斜率优化 | 线性函数最值( d p i = a i ⋅ b j + ... dpi = ai \cdot bj + \dots dpi=ai⋅bj+...) | O ( n 2 ) → O ( n ) O(n^2) \to O(n) O(n2)→O(n) | 凸包/单调队列维护直线集 |
| 前缀和优化 | 区间累加求和 | O ( n 2 ) → O ( n ) O(n^2) \to O(n) O(n2)→O(n) | 前缀和数组快速查询 |
| 状态压缩 | 状态依赖有限前驱 | 空间 O ( n 2 ) → O ( n ) O(n^2) \to O(n) O(n2)→O(n) | 滚动数组复用空间 |
| 矩阵快速幂 | 线性递推( n n n极大) | O ( n ) → O ( log n ) O(n) \to O(\log n) O(n)→O(logn) | 矩阵乘法与快速幂 |
选择策略
- 若转移方程涉及"区间最值"且窗口单调:优先用单调队列优化;
- 若转移方程可化为线性函数形式:用斜率优化;
- 若涉及"区间和累加":用前缀和优化;
- 若空间复杂度过高且状态依赖简单:用状态压缩;
- 若 n n n极大且是线性递推:用矩阵快速幂。
That's all, thanks for reading~~
觉得有用就
点个赞、收进收藏夹吧!关注我,获取更多干货~