动态规划——核心与经典 — 从零精通算法与数据结构——Google 面试系统备战 第10篇

第10章:动态规划——核心与经典

本章目标

读完本章你会:

  • 区分"最优子结构"和"重叠子问题"——DP 的两个必要条件
  • 掌握记忆化(自顶向下)和表格递推(自底向上)两种写法
  • 建立 DP 识别框架:看到什么信号→用 DP
  • 精通 0/1 背包、无界背包、LCS、LIS、编辑距离五道经典 DP
  • 将 DP 辅助工具添加到 algo_toolkit

知识讲解

从一个生活例子开始

你要爬一段有票价的长途列车,从 A 城到 F 城。中间经过 B、C、D、E。每相邻两城之间有不同价格的票可选(普通、快速、特快)。

暴力解法:枚举所有可能的票组合——2^{n-1} 种。n=100 时宇宙毁灭也算不完。

DP 思维: "从 A 到 F 的最便宜路线" = "到 E 的最便宜路线 + E→F 票价",或者 "到 D 的最便宜路线 + D→F 直达标价"……

每个城市只需要记住"到这儿最便宜是多少钱"。这个值只算一次,被后续所有城市复用。

这就是 DP 的直觉:大问题的最优解,能从子问题的最优解构造出来。且子问题的解被反复问到——存起来别重复算。

工作原理

10.1 DP 的两个条件

1. 最优子结构: 问题的最优解包含其子问题的最优解。

背包问题有最优子结构——如果你知道容量 W 时前 i-1 件物品的最优解,第 i 件物品要么拿(容量变 W-w_i)要么不拿,两者取优。

反例: 最长简单路径问题——在某个图里找最长且不重复经过节点的路径。子问题的最长路径可能和主问题冲突(经过的节点重叠了),没有最优子结构。

2. 重叠子问题: 相同的子问题在递归树中反复出现。

斐波那契:F(n) = F(n-1) + F(n-2),其中 F(3) 在递归树中出现了 O(2ⁿ) 次。带上记忆化后每个只算一次。

面试判断框架: "是否 DP?" → 问三个问题:

  1. 问题能否拆成更小的同类子问题?
  2. 子问题的最优解能组合成原问题的最优解吗?(最优子结构)
  3. 递归树中是否有大量重复子问题?(重叠子问题)

三个都答"是" → DP。

10.2 记忆化 vs 递推

记忆化(自顶向下):

int Fibonacci(int n, std::vector<int>& memo) {
  if (n <= 1) return n;
  if (memo[n] != -1) return memo[n];  // 命中缓存
  return memo[n] = Fibonacci(n-1, memo) + Fibonacci(n-2, memo);
}
  • 按递归顺序计算,只算实际需要的子问题
  • 递归栈开销 O(n)
  • 易于从暴力递归改造

递推(自底向上):

int Fibonacci(int n) {
  if (n <= 1) return n;
  int prev2 = 0, prev1 = 1;
  for (int i = 2; i <= n; ++i) {
    int curr = prev1 + prev2;
    prev2 = prev1;
    prev1 = curr;
  }
  return prev1;  // O(n) 时间,O(1) 空间
}
  • 按依赖顺序计算,所有子问题都算一遍
  • 无递归栈开销,常数小
  • 可优化空间(滚动数组)

面试策略: 先说记忆化版本(容易从暴力推导),再优化到递推(展示工程能力)。如果递推能用滚动数组压空间,面试官会很满意。

10.3 五大经典 DP 问题

1. 0/1 背包: n 件物品,重量 wᵢ,价值 vᵢ,容量 W。每件最多拿一件,最大化总价值。

dp[i][c] = 前 i 件物品、容量 c 时的最大价值

dp[i][c] = max(
    dp[i-1][c],                      // 不拿第 i 件
    dp[i-1][c - w_i] + v_i           // 拿第 i 件(如果 c ≥ w_i)
)

空间优化:dp[c] = max(dp[c], dp[c - w_i] + v_i),c 从 W 到 w_i 倒序

2. 无界背包: 每件物品可以拿无限次。

dp[c] = max(dp[c], dp[c - w_i] + v_i),c 从 w_i 到 W 正序
// 唯一区别:正序(因为可以多次拿同一件物品)

3. 最长公共子序列 (LCS): 两个字符串的最长公共子序列。

dp[i][j] = X[0..i-1] 和 Y[0..j-1] 的 LCS 长度

if X[i-1] == Y[j-1]:
    dp[i][j] = dp[i-1][j-1] + 1
else:
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])

4. 最长递增子序列 (LIS): 一维 DP O(n²),贪心+二分 O(n log n)。

dp[i] = 以 nums[i] 结尾的 LIS 长度
dp[i] = 1 + max{ dp[j] | j < i 且 nums[j] < nums[i] }

5. 编辑距离 (Edit Distance / Levenshtein): 把 word1 变 word2 的最少操作数。

dp[i][j] = word1[0..i-1] → word2[0..j-1] 的最少操作

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]
        dp[i][j-1],      // 插入 word2[j-1]
        dp[i-1][j-1]     // 替换
    })

代码实战

include/algo/dynamic_programming.h

#ifndef ALGO_DYNAMIC_PROGRAMMING_H_
#define ALGO_DYNAMIC_PROGRAMMING_H_

#include <cstdint>
#include <string>
#include <vector>

namespace algo {

// ========== 0/1 背包 ==========
// 返回最大价值
int64_t Knapsack01(const std::vector<int>& weights,
                   const std::vector<int>& values,
                   int capacity);

// 返回所选物品的索引
std::vector<int> Knapsack01WithItems(const std::vector<int>& weights,
                                     const std::vector<int>& values,
                                     int capacity);

// ========== 最长公共子序列 ==========
int LongestCommonSubsequence(const std::string& a, const std::string& b);

// ========== 最长递增子序列 ==========
// O(n²) DP 版
int LengthOfLIS(const std::vector<int>& nums);
// O(n log n) 贪心+二分版
int LengthOfLISFast(const std::vector<int>& nums);

// ========== 编辑距离 ==========
int EditDistance(const std::string& word1, const std::string& word2);

}  // namespace algo

#endif  // ALGO_DYNAMIC_PROGRAMMING_H_

include/algo/dynamic_programming_impl.h(摘要)

namespace algo {

inline int64_t Knapsack01(const std::vector<int>& weights,
                          const std::vector<int>& values,
                          int capacity) {
  std::vector<int64_t> dp(capacity + 1, 0);
  for (std::size_t i = 0; i < weights.size(); ++i) {
    // 倒序:保证每件物品只被考虑一次
    for (int c = capacity; c >= weights[i]; --c) {
      dp[c] = std::max(dp[c], dp[c - weights[i]] + values[i]);
    }
  }
  return dp[capacity];
}

inline int64_t LongestCommonSubsequence(const std::string& a,
                                         const std::string& b) {
  int n = static_cast<int>(a.size());
  int m = static_cast<int>(b.size());
  std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1, 0));

  for (int i = 1; i <= n; ++i) {
    for (int j = 1; j <= m; ++j) {
      if (a[i - 1] == b[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = std::max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }
  return dp[n][m];
}

inline int LengthOfLISFast(const std::vector<int>& nums) {
  // tails[k] = 长度为 k+1 的递增子序列的最小末尾值
  std::vector<int> tails;
  for (int x : nums) {
    auto it = std::lower_bound(tails.begin(), tails.end(), x);
    if (it == tails.end()) {
      tails.push_back(x);
    } else {
      *it = x;
    }
  }
  return static_cast<int>(tails.size());
}

inline int EditDistance(const std::string& word1, const std::string& word2) {
  int n = static_cast<int>(word1.size());
  int m = static_cast<int>(word2.size());
  std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1));

  for (int i = 0; i <= n; ++i) dp[i][0] = i;  // 全删除
  for (int j = 0; j <= m; ++j) dp[0][j] = j;  // 全插入

  for (int i = 1; i <= n; ++i) {
    for (int j = 1; j <= m; ++j) {
      if (word1[i - 1] == word2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1];
      } else {
        dp[i][j] = 1 + std::min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]});
      }
    }
  }
  return dp[n][m];
}

}  // namespace algo

本章小结

  1. DP 的两个条件:最优子结构 + 重叠子问题
  2. 记忆化(自顶向下)直观易写,递推(自底向上)省栈空间且常数小
  3. 背包的空间优化:倒序 → 0/1,正序 → 无界
  4. LIS O(n log n) 算法:tails 数组 + 二分替换——面试最优解
  5. DP 通用框架:定义状态 → 找转移方程 → 初始值 → 计算顺序

关键术语

术语 释义
最优子结构 问题的最优解包含其子问题的最优解——DP 必要条件
记忆化 缓存递归函数的返回值,避免重复计算
递推 按依赖顺序自底向上计算,通常比记忆化更高效
滚动数组 只保留最近几行 DP 表,压缩空间从 O(n²) 到 O(n)
状态转移方程 从已知状态推导未知状态的递推公式
posted @ 2026-06-22 01:09  Yobeeo  阅读(4)  评论(0)    收藏  举报