专栏文章
专栏文章
经典算法专栏
1. 经典算法专栏 #01:图算法 2. 经典算法专栏 #02:排序与搜索 3. 经典算法专栏 #03:动态规划 4. 经典算法专栏 #04:业务场景算法

经典算法专栏 #03:动态规划

发布于 2026-06-10 07:49 👁 4 次阅读
#算法#dynamic-programming

动态规划(DP)是解决最优化问题的核心思想:将大问题分解为相互重叠的子问题,用备忘录避免重复计算。本文深度讲解 DP 三要素、五大类型,每类给出 2-3 道经典题的完整代码。


目录

章节 说明
DP 本质与三要素 最优子结构、无后效性、重叠子问题
解题框架 通用五步法
线性 DP LIS、编辑距离、打家劫舍
区间 DP 最长回文子序列、戳气球
背包 DP 0-1 背包、完全背包、分组背包
树形 DP 最大独立集、树形背包
状态压缩 DP TSP 旅行商、棋盘覆盖
经典加餐 买卖股票系列

DP 本质与三要素

动态规划是一种思想,而非具体算法:利用已计算好的结果推导新的计算,即大规模问题的结果由小规模问题的结果运算得来。

三要素

要素 含义 不满足时的替代
最优子结构 原问题的最优解由子问题的最优解组成 贪心可能有效
无后效性 子问题之间依赖是单向的,已确定的状态不受后续影响 无法用 DP
重叠子问题 子问题被反复计算,可用备忘录加速 分治(无重叠)更合适

适合用 DP 的问题特征

  1. 求最优解(最大值/最小值):先考虑贪心,贪心不行再 DP
  2. 求可行性(True/False):如能否凑出某金额
  3. 求方案总数:如路径数量

两个陷阱:数据可排序时用排序而非 DP;存在全排列时用回溯而非 DP(无重叠子问题)。


解题框架

五步法适用于所有 DP 问题。核心原则:遍历时,所依赖的子问题必须已经计算完毕;遍历终点必须是存储最终答案的位置。

dp framework

步骤 关键问题
① 识别问题类型 是最优解?可行性?方案数?
② 定义状态 dp[i] 或 dp[i][j] 代表什么含义?
③ 状态转移方程 dp[i] = f(dp[j], ...),决策过程是什么?
④ 初始化边界 dp[0] 等边界值是多少?
⑤ 确定计算方向 从左到右?从右到左?从小区间到大区间?
⑥ 空间优化(可选) 是否可以用滚动数组或一维压缩?

线性 DP

线性 DP 的状态沿某个线性顺序(数组下标)推进,dp[i] 依赖前面若干个状态。

最长递增子序列(LIS)

问题:给定无序整数数组,找最长严格递增子序列的长度(子序列不要求连续)。

状态定义dp[i] = 以 nums[i] 结尾的最长递增子序列长度。

为什么要以 nums[i] 结尾? 因为只有固定了末尾元素,才能明确地判断"哪些前驱可以接在它前面"(nums[j] < nums[i]),从而写出确定性的转移方程。

状态转移dp[i] = max(dp[j] + 1),其中 j < inums[j] < nums[i]

dp lis table

public int lengthOfLIS(int[] nums) {
    int n = nums.length;
    int[] dp = new int[n];
    Arrays.fill(dp, 1);          // 每个元素自身就是长度 1 的子序列
    int ans = 1;
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        ans = Math.max(ans, dp[i]);
    }
    return ans;
}
// 时间 O(n²),空间 O(n)
// 进阶:用二分查找可优化到 O(nlogn)

O(nlogn) 优化(耐心排序/贪心 + 二分):

public int lengthOfLIS_nlogn(int[] nums) {
    // tails[i] 表示长度为 i+1 的递增子序列的最小末尾元素
    // 为什么维护最小末尾?末尾越小,后续能接的元素越多,贪心最优
    int[] tails = new int[nums.length];
    int size = 0;
    for (int num : nums) {
        int lo = 0, hi = size;
        while (lo < hi) {          // 二分找第一个 >= num 的位置
            int mid = (lo + hi) / 2;
            if (tails[mid] < num) lo = mid + 1;
            else hi = mid;
        }
        tails[lo] = num;
        if (lo == size) size++;    // 扩展序列长度
    }
    return size;
}

编辑距离

问题:将 word1 转换为 word2 所需的最少操作数(插入/删除/替换各算 1 步)。

状态定义dp[i][j] = word1 前 i 个字符转换为 word2 前 j 个字符的最少操作数。

为什么用二维? 两个字符串各有一个"进度"变量,必须同时追踪两者的位置,因此需要二维状态。

状态转移

if word1[i-1] == word2[j-1]:
    dp[i][j] = dp[i-1][j-1]          // 字符相同,无需操作
else:
    dp[i][j] = 1 + min(
        dp[i-1][j],    // 删除 word1[i-1](等价于 word1 少一个字符去匹配)
        dp[i][j-1],    // 插入 word2[j-1](等价于 word2 少一个字符去匹配)
        dp[i-1][j-1]   // 替换(两者各少一个字符)
    )

dp edit distance

public int minDistance(String word1, String word2) {
    int m = word1.length(), n = word2.length();
    int[][] dp = new int[m + 1][n + 1];
    // 初始化:空字符串的转换代价
    for (int i = 0; i <= m; i++) dp[i][0] = i;
    for (int j = 0; j <= n; j++) dp[0][j] = j;

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = 1 + Math.min(dp[i - 1][j - 1],
                               Math.min(dp[i - 1][j], dp[i][j - 1]));
            }
        }
    }
    return dp[m][n];
}
// 时间 O(mn),空间 O(mn),可优化到 O(min(m,n))

打家劫舍

问题:一排房子,不能抢相邻两间,求最大金额。

状态定义dp[i] = 抢前 i 间房子的最大金额。

状态转移dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])

直觉:对第 i 间房,只有两种选择——不抢(继承 dp[i-1])或抢(dp[i-2] + 当前价值)。

public int rob(int[] nums) {
    int n = nums.length;
    if (n == 1) return nums[0];
    // 空间优化:只需保留前两个状态
    int prev2 = nums[0];
    int prev1 = Math.max(nums[0], nums[1]);
    for (int i = 2; i < n; i++) {
        int curr = Math.max(prev1, prev2 + nums[i]);
        prev2 = prev1;
        prev1 = curr;
    }
    return prev1;
}
// 时间 O(n),空间 O(1)

区间 DP

区间 DP 的状态定义在某个区间 [i, j] 上,通过枚举分割点,由更小区间的解推导更大区间的解。

计算方向:必须按区间长度从小到大,因为大区间依赖小区间的结果。

最长回文子序列

问题:给定字符串,找最长回文子序列的长度(子序列不要求连续)。

状态定义dp[i][j] = 字符串 s[i..j] 中最长回文子序列的长度。

状态转移

if s[i] == s[j]:
    dp[i][j] = 2 + dp[i+1][j-1]   // 两端字符可以配对,内部子问题贡献加2
else:
    dp[i][j] = max(dp[i+1][j], dp[i][j-1])  // 去掉左端或右端,取较大值
public int longestPalindromeSubseq(String s) {
    int n = s.length();
    int[][] dp = new int[n][n];
    for (int i = 0; i < n; i++) dp[i][i] = 1; // 单个字符是长度 1 的回文

    // 按区间长度从小到大枚举(计算方向:从右下往左上,保证 dp[i+1][j-1] 已计算)
    for (int i = n - 1; i >= 0; i--) {
        for (int j = i + 1; j < n; j++) {
            if (s.charAt(i) == s.charAt(j)) {
                dp[i][j] = 2 + dp[i + 1][j - 1];
            } else {
                dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[0][n - 1];
}
// 时间 O(n²),空间 O(n²)

戳气球

问题:n 个气球排成一排,每个有数字 nums[i]。戳破气球 i 得到 nums[i-1]*nums[i]*nums[i+1] 金币,求戳破所有气球的最大金币数。

关键技巧:反向思维——不考虑"先戳哪个",而是考虑"最后戳哪个"。

为什么反向? 正向思维中,戳破一个气球后相邻关系会变化,子问题之间有依赖,无法独立求解。反向思维固定最后一个被戳的气球 k,此时 (i,k)(k,j) 两个子问题完全独立。

状态定义dp[i][j] = 戳破开区间 (i, j) 内所有气球的最大金币(两端是虚拟边界)。

状态转移:枚举最后一个被戳破的气球 k:

dp[i][j] = max over k in (i+1, j-1) of:
    dp[i][k] + dp[k][j] + nums[i]*nums[k]*nums[j]
public int maxCoins(int[] nums) {
    int n = nums.length;
    // 添加虚拟边界 1
    int[] arr = new int[n + 2];
    arr[0] = arr[n + 1] = 1;
    for (int i = 0; i < n; i++) arr[i + 1] = nums[i];
    int m = n + 2;

    int[][] dp = new int[m][m];
    // 区间长度从 2 开始(至少包含一个真实气球)
    for (int len = 2; len < m; len++) {
        for (int i = 0; i + len < m; i++) {
            int j = i + len;
            for (int k = i + 1; k < j; k++) {
                dp[i][j] = Math.max(dp[i][j],
                    dp[i][k] + dp[k][j] + arr[i] * arr[k] * arr[j]);
            }
        }
    }
    return dp[0][m - 1];
}
// 时间 O(n³),空间 O(n²)

背包 DP

背包问题是 DP 的经典模型,核心在于"选或不选"的决策。一维空间优化是面试重点。

0-1 背包 vs 完全背包:遍历方向的本质区别

dp knapsack direction

维度 0-1 背包 完全背包
每件物品 最多选 1 次 可选无限次
内层遍历方向 从 W 到 w[i](从后往前 从 w[i] 到 W(从前往后
原因 dp[j-w] 必须是上一轮的值(未选当前物品) dp[j-w] 可以是本轮的值(允许重复选)
状态转移 依赖 dp[i-1][j-w] 依赖 dp[i][j-w]

0-1 背包

问题:n 件物品,重量 w[i]、价值 v[i],背包容量 W,每件只能选一次,求最大价值。

状态定义dp[j] = 容量为 j 时的最大价值(一维优化版)。

public int knapsack01(int[] w, int[] v, int n, int W) {
    int[] dp = new int[W + 1];
    for (int i = 0; i < n; i++) {
        for (int j = W; j >= w[i]; j--) {    // 从后往前!防止同一物品被选多次
            dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[W];
}
// 时间 O(nW),空间 O(W)

二维原始版(便于理解):

// dp[i][j] = 前 i 件物品、容量 j 时的最大价值
// dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])  if j >= w[i]
// dp[i][j] = dp[i-1][j]                                 if j < w[i]

完全背包

问题:每件物品可选无限次,求最大价值。

public int knapsackComplete(int[] w, int[] v, int n, int W) {
    int[] dp = new int[W + 1];
    for (int i = 0; i < n; i++) {
        for (int j = w[i]; j <= W; j++) {    // 从前往后!允许重复选同一物品
            dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[W];
}
// 时间 O(nW),空间 O(W)

经典应用:零钱兑换(完全背包变体)

// 给定硬币面值数组,凑出 amount 的最少硬币数
public int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);    // 初始化为不可达(任何合法值都小于 amount+1)
    dp[0] = 0;
    for (int coin : coins) {
        for (int j = coin; j <= amount; j++) {
            dp[j] = Math.min(dp[j], dp[j - coin] + 1);
        }
    }
    return dp[amount] > amount ? -1 : dp[amount];
}

分组背包

问题:物品分成若干组,每组最多选一件,求最大价值。

关键:枚举组内物品时,容量必须从后往前(同 0-1 背包),保证每组只选一件。

public int knapsackGroup(int[][] groups, int[] groupSize, int W) {
    // groups[i][j] = 第 i 组第 j 件物品的 [weight, value]
    int[] dp = new int[W + 1];
    for (int i = 0; i < groups.length; i++) {
        for (int j = W; j >= 0; j--) {          // 外层容量从后往前
            for (int k = 0; k < groupSize[i]; k++) { // 枚举组内物品
                int wi = groups[i][k * 2];
                int vi = groups[i][k * 2 + 1];
                if (j >= wi) {
                    dp[j] = Math.max(dp[j], dp[j - wi] + vi);
                }
            }
        }
    }
    return dp[W];
}

树形 DP

树形 DP 在树结构上进行动态规划,通常通过 DFS 后序遍历,从叶节点向根节点汇聚状态。

为什么用后序遍历? 父节点的状态依赖子节点的状态,必须先算完所有子节点再处理父节点。

树的最大独立集

问题:给定一棵树,选出一些节点使得没有两个相邻节点都被选中,且所选节点权值之和最大。

状态定义

int[][] dp;
int[] val;
List<List<Integer>> children;

void dfs(int u) {
    dp[u][1] = val[u];    // 选 u,初始值为 u 的权值
    dp[u][0] = 0;         // 不选 u
    for (int v : children.get(u)) {
        dfs(v);
        dp[u][1] += dp[v][0];                        // 选 u,则子节点不能选
        dp[u][0] += Math.max(dp[v][0], dp[v][1]);    // 不选 u,子节点任意
    }
}

public int maxIndependentSet(int root) {
    dfs(root);
    return Math.max(dp[root][0], dp[root][1]);
}

树形背包(选课问题)

问题:树形结构的课程,选某门课前必须先选其前置课程,每门课有学分,在选 m 门课的约束下求最大学分。

思路:将树形背包转化为分组背包,每个节点的子树视为一组,DFS 时合并背包。

// dp[u][j] = 以 u 为根的子树中,选 j 门课的最大学分
// 合并子树时:枚举分配给当前子树的课程数 k
void dfs(int u, int m) {
    dp[u][1] = score[u];   // 至少选 u 本身
    for (int v : children.get(u)) {
        dfs(v, m);
        // 逆序合并,防止重复
        for (int j = m; j >= 1; j--) {
            for (int k = 0; k < j; k++) {
                dp[u][j] = Math.max(dp[u][j], dp[u][j - k] + dp[v][k]);
            }
        }
    }
}

状态压缩 DP

当状态可以用一个整数的二进制位表示时,使用状态压缩 DP。典型场景:集合中元素的"选/未选"状态,n 通常 ≤ 20。

为什么用位运算? n 个元素的选择状态有 2ⁿ 种,用一个整数的二进制位表示,空间紧凑且操作高效。

旅行商问题(TSP)

问题:从城市 0 出发,经过所有城市恰好一次后回到 0,求最短路径。

状态定义dp[mask][i] = 已访问城市集合为 mask、当前在城市 i 时的最短路径。

public int tsp(int[][] dist) {
    int n = dist.length;
    int full = 1 << n;
    int[][] dp = new int[full][n];
    // 初始化为无穷大
    for (int[] row : dp) Arrays.fill(row, Integer.MAX_VALUE / 2);
    dp[1][0] = 0;    // 从城市 0 出发,初始只访问了城市 0(mask = 0001)

    for (int mask = 1; mask < full; mask++) {
        for (int i = 0; i < n; i++) {
            if ((mask & (1 << i)) == 0) continue;  // i 不在当前路径中
            if (dp[mask][i] == Integer.MAX_VALUE / 2) continue;
            for (int j = 0; j < n; j++) {
                if ((mask & (1 << j)) != 0) continue;  // j 已访问
                int newMask = mask | (1 << j);
                dp[newMask][j] = Math.min(dp[newMask][j], dp[mask][i] + dist[i][j]);
            }
        }
    }
    // 回到城市 0
    int ans = Integer.MAX_VALUE;
    for (int i = 1; i < n; i++) {
        ans = Math.min(ans, dp[full - 1][i] + dist[i][0]);
    }
    return ans;
}
// 时间 O(2^n * n²),空间 O(2^n * n)

棋盘覆盖问题(骨牌铺满)

问题:用 1×2 的骨牌铺满 m×n 的棋盘,求方案数。

思路:逐列处理,用 mask 表示当前列向下一列"突出"的格子状态,通过 DFS 枚举合法的骨牌放置。

// 经典状压 DP:m 行 n 列棋盘铺满 1x2 骨牌的方案数
// dp[j][mask] = 处理到第 j 列,当前列向右突出的状态为 mask 时的方案数
public long dominoTiling(int m, int n) {
    long[] dp = new long[1 << m];
    dp[0] = 1;
    for (int col = 0; col < n; col++) {
        long[] newDp = new long[1 << m];
        // 枚举上一列的突出状态 prevMask 和当前列的竖向骨牌状态 curMask
        for (int prevMask = 0; prevMask < (1 << m); prevMask++) {
            if (dp[prevMask] == 0) continue;
            dfsPlace(0, m, prevMask, 0, dp[prevMask], newDp);
        }
        dp = newDp;
    }
    return dp[0];
}

void dfsPlace(int row, int m, int prevMask, int curMask,
              long ways, long[] newDp) {
    if (row == m) {
        newDp[curMask] += ways;
        return;
    }
    // 当前行被上一列突出的骨牌占据,只能放竖骨牌(向右突出)
    if ((prevMask >> row & 1) == 1) {
        dfsPlace(row + 1, m, prevMask, curMask | (1 << row), ways, newDp);
    } else {
        // 放横骨牌(当前列不突出)
        dfsPlace(row + 1, m, prevMask, curMask, ways, newDp);
        // 放竖骨牌(当前列向右突出),需要下一行也未被占
        if (row + 1 < m && (prevMask >> (row + 1) & 1) == 0) {
            dfsPlace(row + 2, m, prevMask, curMask | (1 << row) | (1 << (row + 1)),
                     ways, newDp);
        }
    }
}

经典加餐

买卖股票系列

买卖股票是 DP 中的经典系列,核心是用三维状态 dp[i][j][k] 建模:

最多两次交易(LeetCode 123):

public int maxProfit(int[] prices) {
    int m = prices.length;
    // dp[i][j][k]: 第i天,j=是否持股,k=已卖出次数(0/1/2)
    int[][][] dp = new int[m][2][3];
    dp[0][0][0] = 0;
    dp[0][1][0] = -prices[0];
    dp[0][1][1] = -prices[0];
    dp[0][1][2] = Integer.MIN_VALUE / 2;  // 不可能的状态

    for (int i = 1; i < m; i++) {
        dp[i][0][0] = 0;
        dp[i][0][1] = Math.max(dp[i-1][1][0] + prices[i], dp[i-1][0][1]);
        dp[i][0][2] = Math.max(dp[i-1][1][1] + prices[i], dp[i-1][0][2]);
        dp[i][1][0] = Math.max(dp[i-1][0][0] - prices[i], dp[i-1][1][0]);
        dp[i][1][1] = Math.max(dp[i-1][0][1] - prices[i], dp[i-1][1][1]);
        dp[i][1][2] = Integer.MIN_VALUE / 2;
    }
    return Math.max(dp[m-1][0][1], dp[m-1][0][2]);
}

通用模板(最多 k 次交易 + 冻结期 t + 手续费 c):

dp[i][0][k] = max(dp[i-1][1][k-1] + prices[i],  dp[i-1][0][k])
dp[i][1][k] = max(dp[i-1-t][0][k] - prices[i] - c, dp[i-1][1][k])

DP 解题 Checklist

在写 DP 代码前,先确认:


参考资料

  • 极客时间《动态规划面试宝典》— 卢誉声
  • 《算法导论》— 第 15 章动态规划
  • LeetCode 专题:动态规划
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论