动态规划进阶:转移方程优化技巧全解

动态规划进阶:转移方程优化技巧全解

动态规划(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 核心思想

  1. 维护单调队列 :队列中存储候选 j j j的索引,且对应的 d p j + w ( i , j ) dpj + w(i,j) dpj+w(i,j)保持单调(递增或递减);
  2. 窗口裁剪 :当 j j j超出窗口范围 L ( i ) , R ( i ) L(i), R(i) L(i),R(i)时,从队首移除;
  3. 高效更新 :新 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 核心思想

  1. 直线映射 :将每个 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);
  2. 维护有效直线集 :若直线 l 1 l_1 l1在所有 x x x上均不如 l 2 l_2 l2优,则 l 1 l_1 l1可被淘汰(凸包优化);
  3. 查询最值 :对于 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 核心思想

  1. 预处理前缀和 :定义 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

  2. 替换转移方程 :用前缀和表达式替代原区间和,将每次转移的计算量从 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 核心思想

  1. 识别依赖关系:确定当前状态依赖的前驱状态范围(如仅前一行或前一列);
  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 核心思想

  1. 构建转移矩阵:将递推关系转化为矩阵乘法形式;
  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]=1110dp\[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) 矩阵乘法与快速幂

选择策略

  1. 若转移方程涉及"区间最值"且窗口单调:优先用单调队列优化
  2. 若转移方程可化为线性函数形式:用斜率优化
  3. 若涉及"区间和累加":用前缀和优化
  4. 若空间复杂度过高且状态依赖简单:用状态压缩
  5. 若 n n n极大且是线性递推:用矩阵快速幂

That's all, thanks for reading~~

觉得有用就点个赞、收进收藏夹吧!关注我,获取更多干货~

相关推荐
wabs6667 小时前
关于动态规划【力扣63.不同路径II与62.不同路径的区别(C++)】自我总结
动态规划
三千里7 小时前
路径规划算法-备忘
算法·自动驾驶·动态规划
2601_961845159 小时前
新高考一卷真题2025|真题PDF全科整理
线性代数·矩阵·pdf·动态规划·概率论·高考
随意起个昵称9 小时前
线性dp-LIS题目5(导弹拦截,二分优化)
c++·算法·动态规划
8Qi812 小时前
LeetCode 5:最长回文子串(Longest Palindromic Substring)—— 题解
算法·leetcode·职场和发展·动态规划
8Qi81 天前
LeetCode 1143 & 718:最长公共子序列 / 最长重复子数组
算法·leetcode·职场和发展·动态规划
随意起个昵称1 天前
线性dp-综合刷题1(Not Alone)
算法·动态规划
-森屿安年-1 天前
1137. 第 N 个泰波那契数
c++·动态规划
8Qi81 天前
LeetCode 115 & 392:不同子序列 / 判断子序列
算法·leetcode·职场和发展·动态规划
8Qi81 天前
LeetCode 72:编辑距离(Edit Distance)—— 题解
算法·leetcode·职场和发展·动态规划