Skip to content

基础 DP 问题

入门级 DP 问题,帮助理解核心思想。

1. 斐波那契数列

问题

F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2)

解法对比

typescript
// ❌ 递归 - 指数级复杂度,有大量重复计算
function fib1(n: number): number {
  if (n <= 1) return n;
  return fib1(n - 1) + fib1(n - 2);
}

// ✅ DP 自底向上 - O(n) 时间, O(1) 空间
function fib2(n: number): number {
  if (n <= 1) return n;
  let prev = 0, curr = 1;
  for (let i = 2; i <= n; i++) {
    [prev, curr] = [curr, prev + curr];
  }
  return curr;
}

// ✅ DP 记忆化递归 - O(n) 时间, O(n) 空间
function fib3(n: number, memo: number[] = []): number {
  if (n <= 1) return n;
  if (memo[n]) return memo[n];
  memo[n] = fib3(n - 1, memo) + fib3(n - 2, memo);
  return memo[n];
}

复杂度对比

方法时间空间
递归O(2^n)O(n)
记忆化O(n)O(n)
迭代O(n)O(1)

2. 爬楼梯

问题

  • 每次爬 1 或 2 级台阶,到第 n 级有多少种方法?

状态转移

typescript
// dp[i] = 到达第 i 级的方法数
// dp[i] = dp[i-1] + dp[i-2]

function climbStairs(n: number): number {
  if (n <= 2) return n;
  let dp = [0, 1, 2];
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

// 空间优化
function climbStairsOptimized(n: number): number {
  if (n <= 2) return n;
  let prev = 1, curr = 2;
  for (let i = 3; i <= n; i++) {
    [prev, curr] = [curr, prev + curr];
  }
  return curr;
}

扩展

变体

  • 每次爬 1, 2, 或 3 级 → dp[i] = dp[i-1] + dp[i-2] + dp[i-3]
  • 每步代价不同 → 变成最小花费爬楼梯

3. 最小花费爬楼梯

问题

  • 每层楼梯有 cost,爬到顶的最小花费

解法

typescript
function minCostClimbingStairs(cost: number[]): number {
  const n = cost.length;
  // dp[i] = 到达第 i 级的最小花费
  // dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])

  let dp0 = 0, dp1 = 0; // dp[0], dp[1]

  for (let i = 2; i <= n; i++) {
    const dpi = Math.min(dp1 + cost[i - 1], dp0 + cost[i - 2]);
    dp0 = dp1;
    dp1 = dpi;
  }

  return dp1;
}

4. 打家劫舍 I

问题

  • 不能偷连续两间房,最多能偷多少钱

解法

typescript
function rob(nums: number[]): number {
  const n = nums.length;
  if (n === 0) return 0;
  if (n === 1) return nums[0];

  // dp[i] = 前 i 间房能偷的最大金额
  // dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])

  let prev2 = 0, prev1 = nums[0];

  for (let i = 1; i < n; i++) {
    const curr = Math.max(prev1, prev2 + nums[i]);
    prev2 = prev1;
    prev1 = curr;
  }

  return prev1;
}

图解状态转移

房间:  [2, 7, 9, 3, 1]
索引:   0   1   2  3  4

dp[0] = 2              (偷第0间)
dp[1] = max(2, 7) = 7  (偷第1间)
dp[2] = max(7, 2+9) = 11 (偷第0+2间)
dp[3] = max(11, 7+3) = 11 (偷第0+2间)
dp[4] = max(11, 11+1) = 12 (偷第0+2+4间)

复杂度分析

方法时间空间
二维 DPO(n)O(n)
空间优化O(n)O(1)

5. 打家劫舍 II(环形房屋)

问题

  • 房屋围成一圈,首尾相连,不能偷相邻房屋

核心思路

将环形问题拆分为两个线性问题:

情况1: 偷 [0, n-1) → 不偷最后一间
情况2: 偷 [1, n)  → 不偷第一间
答案 = max(情况1, 情况2)

解法

typescript
function robII(nums: number[]): number {
  const n = nums.length;
  if (n === 0) return 0;
  if (n === 1) return nums[0];
  if (n === 2) return Math.max(nums[0], nums[1]);

  // 情况1: 不偷最后一间 (0 到 n-2)
  const robRange = (start: number, end: number): number => {
    let prev2 = 0, prev1 = 0;
    for (let i = start; i <= end; i++) {
      const curr = Math.max(prev1, prev2 + nums[i]);
      prev2 = prev1;
      prev1 = curr;
    }
    return prev1;
  };

  return Math.max(
    robRange(0, n - 2),  // 不偷最后一间
    robRange(1, n - 1)   // 不偷第一间
  );
}

为什么不能直接用打家劫舍 I?

错误做法: dp[i] = max(dp[i-1], dp[i-2] + nums[i])

问题: 房屋0和房屋n-1相邻
       ↙  ↘
    [0] ... [n-1]
    
如果偷了0,就不能偷n-1
dp会错误地允许同时偷0和n-1

6. 打家劫舍 III(二叉树)

问题

  • 二叉树形状的房屋,不能偷相邻节点

解法(树形 DP + 记忆化)

typescript
function robIII(root: TreeNode | null): number {
  const dfs = (node: TreeNode | null): [notRob: number, rob: number] => {
    if (!node) return [0, 0];

    const [leftNotRob, leftRob] = dfs(node.left);
    const [rightNotRob, rightRob] = dfs(node.right);

    // 不偷当前节点 = 左右子树各自最优
    const notRob = leftRob + rightRob;

    // 偷当前节点 = 不偷左右子树的根节点 + 当前值
    const rob = node.val + leftNotRob + rightNotRob;

    return [notRob, rob];
  };

  const [notRob, rob] = dfs(root);
  return Math.max(notRob, rob);
}

状态定义

状态含义
notRob以该节点为根的子树,不偷该节点的最大收益
rob以该节点为根的子树,该节点的最大收益

树形 DP 图解

        3
       / \
      2   3
       \   \
        3   1

返回: [不偷根, 偷根]
  节点3: [3, 3+2+3=8]
  节点2: [0, 2]
  节点3: [1, 3+1=4]

根节点: max(8, 4+3=7) = 8

7. 跳跃游戏 I

问题

  • 数组 nums[i] 表示从位置 i 最远能跳到 i + nums[i]
  • 能否跳到最后一个位置?

解法

typescript
// 方法1: DP(超时风险)
function canJump(nums: number[]): boolean {
  const n = nums.length;
  const dp = Array(n).fill(false);
  dp[0] = true;

  for (let i = 0; i < n; i++) {
    if (!dp[i]) continue;
    for (let j = 1; j <= nums[i] && i + j < n; j++) {
      dp[i + j] = true;
    }
  }

  return dp[n - 1];
}

// 方法2: 贪心(最优)
function canJumpGreedy(nums: number[]): boolean {
  let maxReach = 0;

  for (let i = 0; i < nums.length; i++) {
    if (i > maxReach) return false; // 到不了位置 i
    maxReach = Math.max(maxReach, i + nums[i]);
    if (maxReach >= nums.length - 1) return true;
  }

  return true;
}

贪心核心思想

位置:   0   1   2   3   4
nums:  [2, 3, 1, 1, 4]
reach: 2   4   4   4   5

step 0: maxReach = 0+2 = 2
step 1: maxReach = max(2, 1+3) = 4
step 2: i=2 <= maxReach=4 ✓
step 3: i=3 <= maxReach=4 ✓
step 4: maxReach >= n-1 ✓

8. 跳跃游戏 II(最小跳跃次数)

问题

  • 同上,但要求最小跳跃次数

解法

typescript
function jump(nums: number[]): number {
  let jumps = 0;
  let curEnd = 0;    // 当前跳跃能到达的边界
  let farthest = 0;  // 所有位置能达到的最远距离

  for (let i = 0; i < nums.length - 1; i++) {
    farthest = Math.max(farthest, i + nums[i]);

    // 到达当前跳跃的边界,需要再跳一次
    if (i === curEnd) {
      jumps++;
      curEnd = farthest;
    }
  }

  return jumps;
}

图解

nums: [2, 3, 1, 1, 4]

i=0: farthest=2, curEnd=0, 跳!
     jumps=1, curEnd=2

i=1: farthest=4
i=2: farthest=4, 到达边界 curEnd=2, 再跳!
     jumps=2, curEnd=4

i=3: farthest=4
i=4: 到达终点

最小跳跃次数: 2

关键理解

  • curEnd: 当前这一次跳跃能达到的最远范围
  • farthest: 在 curEnd 范围内,所有位置能达到的最远
  • 当 i 到达 curEnd,意味着需要开始下一次跳跃

9. 等差数列划分

问题

  • 统计数组中所有等差子数组的数量
typescript
function numberOfArithmeticSlices(nums: number[]): number {
  const n = nums.length;
  if (n < 3) return 0;

  let count = 0;
  let dp = 0; // 以 nums[i-2], nums[i-1], nums[i] 结尾的等差数列数

  for (let i = 2; i < n; i++) {
    if (nums[i] - nums[i - 1] === nums[i - 1] - nums[i - 2]) {
      dp = dp + 1; // 新的等差数列可以扩展旧的
      count += dp;
    } else {
      dp = 0; // 重置
    }
  }

  return count;
}

状态转移理解

nums: [1, 2, 3, 4]

i=2: [1,2,3] 是等差 → dp=1, count=1
i=3: [2,3,4] + 扩展 [1,2,3,4] → dp=2, count=3
                      ^^^^^^^^
                      新增的等差子数组

等差子数组: [1,2,3], [2,3,4], [1,2,3,4]

[!summary] 一维 DP 通用模板

typescript
// 基础模板
let dp0 = initial0, dp1 = initial1;
for (let i = 2; i <= n; i++) {
  dpi = max/min(dp[i-1], dp[i-2] + cost[i]);
  dp0 = dp1;
  dp1 = dpi;
}

// 环形处理: 拆分为两个线性问题
// 树形处理: 返回 [不选, 选] 两个状态
// 跳跃问题: 用 curEnd + farthest 模拟

参见: **DP算法索引**

Knowledge4J — Java 知识库