动态规划进阶:转移方程优化技巧全解
动态规划进阶:转移方程优化技巧全解
- 一、优化的核心目标与前提
- 1.1 常见可优化的转移方程形式
- 1.2 优化的前提
- 二、单调队列优化:区间最值的高效查询
- 2.1 适用场景
- 2.2 核心思想
- 2.3 案例:滑动窗口最大值的DP版本
- 三、斜率优化:线性函数的最值求解
- 3.1 适用场景
- 3.2 核心思想
- 3.3 案例:任务安排问题
- 四、前缀和优化:区间累加的快速计算
- 4.1 适用场景
- 4.2 核心思想
- 4.3 案例:最大子段和(带长度限制)
- 五、状态压缩与滚动数组:空间维度的优化
- 5.1 适用场景
- 5.2 核心思想
- 5.3 案例:0/1背包的空间优化
- 六、矩阵快速幂:线性递推的加速
- 6.1 适用场景
- 6.2 核心思想
- 6.3 案例:斐波那契数列第n项
- 七、优化技巧的选择与总结
- 选择策略
动态规划(DP)的核心是“定义状态+推导转移方程”,但很多时候,基础的转移方程会导致时间复杂度偏高(如O(n2)O(n^2)O(n2)),难以应对大规模输入,此时转移方程的优化就成为突破性能瓶颈的关键。
一、优化的核心目标与前提
在深入技巧之前,我们需要明确:转移方程优化的本质是减少冗余计算。当基础转移方程中存在“重复查询区间最值”“线性函数最值”“累加区间和”等操作时,就有优化的空间。
1.1 常见可优化的转移方程形式
-
区间最值型:dp[i]=maxj∈[i−k,i−1](dp[j]+w(i,j))dp[i] = \max_{j \in [i-k, i-1]} (dp[j] + w(i,j))dp[i]=maxj∈[i−k,i−1](dp[j]+w(i,j))(如滑动窗口类DP);
-
线性函数型:dp[i]=minj<i(a[i]⋅b[j]+c[j]+d[i])dp[i] = \min_{j < i} (a[i] \cdot b[j] + c[j] + d[i])dp[i]=minj<i(a[i]⋅b[j]+c[j]+d[i])(如任务安排问题);
-
区间累加型:dp[i]=∑j=l[i]r[i]dp[j]+w[i]dp[i] = \sum_{j = l[i]}^{r[i]} dp[j] + w[i]dp[i]=∑j=l[i]r[i]dp[j]+w[i](如区间和依赖的DP);
-
线性递推型:dp[i]=k1⋅dp[i−1]+k2⋅dp[i−2]+⋯+km⋅dp[i−m]dp[i] = k_1 \cdot dp[i-1] + k_2 \cdot dp[i-2] + \dots + k_m \cdot dp[i-m]dp[i]=k1⋅dp[i−1]+k2⋅dp[i−2]+⋯+km⋅dp[i−m](如斐波那契数列的高阶扩展)。
1.2 优化的前提
- 能从转移方程中提取出“可复用的结构”(如单调关系、线性函数、区间和等);
- 优化后能减少状态转移的计算量(如从O(n)O(n)O(n) per step降至O(1)O(1)O(1)或O(logn)O(\log n)O(logn))。
二、单调队列优化:区间最值的高效查询
单调队列优化适用于**转移方程中需要查询“滑动窗口内最值”**的场景,其核心是用一个“单调队列”维护候选状态jjj,使每次查询最值的时间从O(n)O(n)O(n)降至O(1)O(1)O(1)。
2.1 适用场景
转移方程形如:
dp[i]=maxj∈[L(i),R(i)](dp[j]+w(i,j))dp[i] = \max_{j \in [L(i), R(i)]} (dp[j] + w(i,j))dp[i]=maxj∈[L(i),R(i)](dp[j]+w(i,j))
其中L(i)L(i)L(i)和R(i)R(i)R(i)是关于iii的单调函数(如L(i)=i−kL(i) = i - kL(i)=i−k,R(i)=i−1R(i) = i - 1R(i)=i−1),即窗口范围随iii单调变化。
2.2 核心思想
- 维护单调队列:队列中存储候选jjj的索引,且对应的dp[j]+w(i,j)dp[j] + w(i,j)dp[j]+w(i,j)保持单调(递增或递减);
- 窗口裁剪:当jjj超出窗口范围[L(i),R(i)][L(i), R(i)][L(i),R(i)]时,从队首移除;
- 高效更新:新jjj加入队列时,从队尾移除所有“比当前jjj差”的候选(即dp[j]+w(i,j)dp[j] + w(i,j)dp[j]+w(i,j)不优的),确保队列单调性。
2.3 案例:滑动窗口最大值的DP版本
问题:给定数组numsnumsnums和窗口大小kkk,求每个位置iii对应的窗口[i−k+1,i][i-k+1, i][i−k+1,i]内的最大值(用DP思想实现)。
基础转移方程:
dp[i]=maxj=i−k+1inums[j]dp[i] = \max_{j = i-k+1}^i nums[j]dp[i]=maxj=i−k+1inums[j](dp[i]dp[i]dp[i]表示以iii为结尾的窗口最大值)
优化思路:用单调队列存储窗口内元素的索引,队列保持元素值递减,队首即为当前窗口最大值。
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)(队列最大存储kkk个元素)。
三、斜率优化:线性函数的最值求解
斜率优化适用于转移方程可转化为线性函数最值问题的场景,通过将状态jjj映射为直线,将“求dp[i]dp[i]dp[i]”转化为“求直线在某点的最值”,再用凸包或单调队列维护直线集合。
3.1 适用场景
转移方程形如:
dp[i]=minj<i(a[i]⋅b[j]+c[j]+d[i])dp[i] = \min_{j < i} (a[i] \cdot b[j] + c[j] + d[i])dp[i]=minj<i(a[i]⋅b[j]+c[j]+d[i])
其中a[i]a[i]a[i]和d[i]d[i]d[i]是仅与iii相关的函数,b[j]b[j]b[j]和c[j]c[j]c[j]是仅与jjj相关的函数。可改写为:
dp[i]=d[i]+minj<i(b[j]⋅a[i]+c[j])dp[i] = d[i] + \min_{j < i} (b[j] \cdot a[i] + c[j])dp[i]=d[i]+minj<i(b[j]⋅a[i]+c[j])
此时,每个jjj对应一条直线y=b[j]⋅x+c[j]y = b[j] \cdot x + c[j]y=b[j]⋅x+c[j],dp[i]dp[i]dp[i]即为x=a[i]x = a[i]x=a[i]时所有直线的最小值(加d[i]d[i]d[i])。
3.2 核心思想
- 直线映射:将每个jjj映射为直线y=kj⋅x+bjy = k_j \cdot x + b_jy=kj⋅x+bj(kj=b[j]k_j = b[j]kj=b[j],bj=c[j]b_j = c[j]bj=c[j]);
- 维护有效直线集:若直线l1l_1l1在所有xxx上均不如l2l_2l2优,则l1l_1l1可被淘汰(凸包优化);
- 查询最值:对于x=a[i]x = a[i]x=a[i],在有效直线集中找到最优直线(如斜率单调时用单调队列,否则用二分查找)。
3.3 案例:任务安排问题
问题:nnn个任务按顺序执行,每次启动机器需支付固定成本SSS,第iii个任务的成本为t[i]t[i]t[i],且成本随启动后时间累积(启动后第kkk个任务的成本为t[i]⋅kt[i] \cdot kt[i]⋅k)。求总最小成本。
基础转移方程:
dp[i]=minj<i(dp[j]+S+∑k=j+1it[k]⋅(k−j))dp[i] = \min_{j < i} (dp[j] + S + \sum_{k = j+1}^i t[k] \cdot (k - j))dp[i]=minj<i(dp[j]+S+∑k=j+1it[k]⋅(k−j))
通过前缀和优化∑\sum∑项后,可转化为:
dp[i]=minj<i(dp[j]−sumT[j]⋅j)+sumT[i]⋅i+S−sumT[j]dp[i] = \min_{j < i} (dp[j] - sumT[j] \cdot j) + sumT[i] \cdot i + S - sumT[j]dp[i]=minj<i(dp[j]−sumT[j]⋅j)+sumT[i]⋅i+S−sumT[j]
其中sumT[i]sumT[i]sumT[i]是ttt的前缀和。
令a[i]=sumT[i]a[i] = sumT[i]a[i]=sumT[i],b[j]=jb[j] = jb[j]=j,c[j]=dp[j]−sumT[j]⋅j−sumT[j]c[j] = dp[j] - sumT[j] \cdot j - sumT[j]c[j]=dp[j]−sumT[j]⋅j−sumT[j],则:
dp[i]=a[i]⋅i+S+minj<i(−b[j]⋅a[i]+c[j])dp[i] = a[i] \cdot i + S + \min_{j < i} ( -b[j] \cdot a[i] + c[j] )dp[i]=a[i]⋅i+S+minj<i(−b[j]⋅a[i]+c[j])
符合线性函数形式,可通过斜率优化求解。
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 = 0deque.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] = -jlong 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);
}
复杂度分析:每个iii对应的直线入队和出队各一次,总时间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 适用场景
转移方程形如:
dp[i]=∑j=l[i]r[i]dp[j]+w[i]dp[i] = \sum_{j = l[i]}^{r[i]} dp[j] + w[i]dp[i]=∑j=l[i]r[i]dp[j]+w[i]
其中l[i]l[i]l[i]和r[i]r[i]r[i]是iii的函数,区间和的计算是瓶颈。
4.2 核心思想
-
预处理前缀和:定义pre[i]=∑j=0idp[j]pre[i] = \sum_{j=0}^i dp[j]pre[i]=∑j=0idp[j],则区间和∑j=lrdp[j]=pre[r]−pre[l−1]\sum_{j=l}^{r} dp[j] = pre[r] - pre[l-1]∑j=lrdp[j]=pre[r]−pre[l−1];
-
替换转移方程:用前缀和表达式替代原区间和,将每次转移的计算量从O(n)O(n)O(n)降至O(1)O(1)O(1)。
4.3 案例:最大子段和(带长度限制)
问题:给定数组numsnumsnums和最大长度kkk,求长度不超过kkk的连续子数组的最大和。
基础转移方程:
dp[i]=maxj=max(0,i−k)i−1(dp[j]+nums[i])dp[i] = \max_{j = \max(0, i-k)}^{i-1} (dp[j] + nums[i])dp[i]=maxj=max(0,i−k)i−1(dp[j]+nums[i])(dp[i]dp[i]dp[i]表示以iii结尾的最大和)
优化思路:用前缀和pre[i]pre[i]pre[i]记录dp[0..i]dp[0..i]dp[0..i]的和,但更优的是结合单调队列维护区间最大值(同时用前缀和优化累加)。
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中,dp[i][j]dp[i][j]dp[i][j]仅依赖dp[i−1][j]dp[i-1][j]dp[i−1][j]和dp[i][j−1]dp[i][j-1]dp[i][j−1](如0/1背包、最长公共子序列);
- 高维DP中,状态仅依赖前kkk层(如dp[i][j][k]dp[i][j][k]dp[i][j][k]仅依赖dp[i−1][j][k]dp[i-1][j][k]dp[i−1][j][k])。
5.2 核心思想
- 识别依赖关系:确定当前状态依赖的前驱状态范围(如仅前一行或前一列);
- 复用空间:用一维数组替代二维数组,通过“滚动”更新覆盖旧状态(注意遍历顺序,避免覆盖未使用的前驱状态)。
5.3 案例:0/1背包的空间优化
基础二维转移方程:
dp[i][j]=max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])dp[i][j] = \max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])dp[i][j]=max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])(前iii个物品,容量jjj的最大价值)
优化后一维转移方程:
dp[j]=max(dp[j],dp[j−w[i]]+v[i])dp[j] = \max(dp[j], dp[j - w[i]] + v[i])dp[j]=max(dp[j],dp[j−w[i]]+v[i])(逆序遍历容量jjj,避免重复使用同一物品)
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),当nnn很大时(如n≤109n \leq 10^9n≤109),可将时间复杂度从O(n)O(n)O(n)降至O(logn)O(\log n)O(logn)。
6.1 适用场景
转移方程形如线性递推:
dp[i]=a1⋅dp[i−1]+a2⋅dp[i−2]+⋯+ak⋅dp[i−k]dp[i] = a_1 \cdot dp[i-1] + a_2 \cdot dp[i-2] + \dots + a_k \cdot dp[i-k]dp[i]=a1⋅dp[i−1]+a2⋅dp[i−2]+⋯+ak⋅dp[i−k]
可表示为矩阵乘法:[dp[i]dp[i−1]⋮dp[i−k+1]]=M⋅[dp[i−1]dp[i−2]⋮dp[i−k]]\begin{bmatrix} dp[i] \\ dp[i-1] \\ \vdots \\ dp[i-k+1] \end{bmatrix} = M \cdot \begin{bmatrix} dp[i-1] \\ dp[i-2] \\ \vdots \\ dp[i-k] \end{bmatrix}dp[i]dp[i−1]⋮dp[i−k+1]=M⋅dp[i−1]dp[i−2]⋮dp[i−k],其中MMM是转移矩阵。
6.2 核心思想
- 构建转移矩阵:将递推关系转化为矩阵乘法形式;
- 矩阵幂加速:用快速幂计算转移矩阵的nnn次幂,再与初始向量相乘,得到dp[n]dp[n]dp[n]。
6.3 案例:斐波那契数列第n项
递推关系:dp[i]=dp[i−1]+dp[i−2]dp[i] = dp[i-1] + dp[i-2]dp[i]=dp[i−1]+dp[i−2](dp[0]=0dp[0]=0dp[0]=0,dp[1]=1dp[1]=1dp[1]=1)
矩阵形式:[dp[i]dp[i−1]]=[1110]⋅[dp[i−1]dp[i−2]]\begin{bmatrix} dp[i] \\ dp[i-1] \end{bmatrix} = \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix} \cdot \begin{bmatrix} dp[i-1] \\ dp[i-2] \end{bmatrix}[dp[i]dp[i−1]]=[1110]⋅[dp[i−1]dp[i−2]]
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(k3)O(k^3)O(k3)(kkk为矩阵维度),快速幂迭代O(logn)O(\log n)O(logn)次,总时间O(k3logn)O(k^3 \log n)O(k3logn),适用于nnn极大的场景。
七、优化技巧的选择与总结
优化技巧 | 适用场景 | 时间复杂度优化 | 核心工具/思想 |
---|---|---|---|
单调队列优化 | 区间最值查询(窗口单调) | O(n2)→O(n)O(n^2) \to O(n)O(n2)→O(n) | 单调队列维护候选集 |
斜率优化 | 线性函数最值(dp[i]=a[i]⋅b[j]+…dp[i] = a[i] \cdot b[j] + \dotsdp[i]=a[i]⋅b[j]+…) | O(n2)→O(n)O(n^2) \to O(n)O(n2)→O(n) | 凸包/单调队列维护直线集 |
前缀和优化 | 区间累加求和 | O(n2)→O(n)O(n^2) \to O(n)O(n2)→O(n) | 前缀和数组快速查询 |
状态压缩 | 状态依赖有限前驱 | 空间O(n2)→O(n)O(n^2) \to O(n)O(n2)→O(n) | 滚动数组复用空间 |
矩阵快速幂 | 线性递推(nnn极大) | O(n)→O(logn)O(n) \to O(\log n)O(n)→O(logn) | 矩阵乘法与快速幂 |
选择策略
- 若转移方程涉及“区间最值”且窗口单调:优先用单调队列优化;
- 若转移方程可化为线性函数形式:用斜率优化;
- 若涉及“区间和累加”:用前缀和优化;
- 若空间复杂度过高且状态依赖简单:用状态压缩;
- 若nnn极大且是线性递推:用矩阵快速幂。
That’s all, thanks for reading~~
觉得有用就点个赞
、收进收藏
夹吧!关注
我,获取更多干货~