算法讲解069 必备 从递归入手三维动态规划

来源:算法讲解069【必备】从递归入手三维动态规划 · 2026-11-03
依据:视频字幕 2000 字

一句话概括

三维动态规划(3D DP)就是在二维 DP 的基础上再多一个可变参数,用「递归 → 记忆化搜索 → 严格位置依赖 → 空间压缩」这条固定流水线处理多约束选择问题(典型:多维费用背包)。

它解决什么问题

给定若干物品,每个物品有多个维度的「费用」(比如 0 的个数、1 的个数),每个维度的总费用有上限,问最多能选多少个物品(或最大价值)。本质是多维费用 0-1 背包:每个物品选/不选,多个维度都不能超限。

基础知识(前置概念,用大白话解释)

原理(为什么这么做是对的,尽量给直觉)

对每个物品只有两种命运:选或不选。

两者取 max,就是「从第 i 个往后自由选择、在剩余容量 z、o 下最多能选几个」。

为什么可以记忆化:f(i, z, o) 的返回值只由这三个参数决定,与「怎么走到这个状态」无关(无后效性),所以同一个三元组算一次就够。

为什么能压维:f(i, ·, ·) 只用到 f(i+1, ·, ·),所以从 i = n-1 倒着推到 i = 0,只需保留「下一层」的二维表,滚动更新即可。注意 0-1 背包压维时内层容量要倒序枚举,否则同一物品会被重复选(变成完全背包)。

步骤(编号,可照着写)

  1. 预处理每个字符串的 0 个数 zeros[i]、1 个数 ones[i]。
  2. 写暴力递归 f(i, z, o):i == n 返回 0;不选 = f(i+1, z, o);能选则 max(不选, 1 + f(i+1, z-zeros[i], o-ones[i]))。
  3. 加记忆化:三维 memo[i][z][o],初值 -1,算过直接返回。
  4. 改严格位置依赖:从 i = n-1 到 0 倒推,dp[i][z][o] = max(dp[i+1][z][o], 1 + dp[i+1][z-zeros[i]][o-ones[i]])(能选时)。
  5. 空间压缩:只留两层 cur[z][o]、next[z][o],或直接一维滚动(内层 z、o 倒序)。
  6. 答案 = dp[0][M][N]。

样例(一个具体小例子,带每步变化)

strs = ["10","0","1"],M=1(最多 1 个 0),N=1(最多 1 个 1)。

预处理:zeros = [1,1,0],ones = [1,0,1]。

从 i=2 倒推(dp[i][z][o],z∈[0,1],o∈[0,1]):

- dp[2][z][o]:o≥1 时可选 → 1;o=0 时 → 0。

- 表:dp[2][0][0]=0, dp[2][0][1]=1, dp[2][1][0]=0, dp[2][1][1]=1。

- dp[1][0][0]=0;dp[1][0][1]=dp[2][0][1]=1;

- dp[1][1][0]=max(dp[2][1][0], 1+dp[2][0][0])=max(0,1)=1;

- dp[1][1][1]=max(dp[2][1][1], 1+dp[2][0][1])=max(1,2)=2。

- dp[0][1][1]=max(dp[1][1][1], 1+dp[1][0][0])=max(2,1)=2。

答案 = dp[0][1][1] = 2(选 "0" 和 "1",或 "10" 单独一个,最大是 2)。✓

代码(Python 一份:好读、用于理解;C++ 一份:竞赛风,能直接抄进比赛,带注释,不用玄学宏)

Python(记忆化,好读):

def findMaxForm(strs, M, N):
    n = len(strs)
    zeros = [s.count('0') for s in strs]
    ones  = [s.count('1') for s in strs]
    from functools import lru_cache

    @lru_cache(maxsize=None)
    def f(i, z, o):
        if i == n:
            return 0
        best = f(i + 1, z, o)                      # 不选
        if zeros[i] <= z and ones[i] <= o:         # 选
            best = max(best, 1 + f(i + 1, z - zeros[i], o - ones[i]))
        return best

    return f(0, M, N)

C++(严格位置依赖 + 空间压缩,竞赛风):

#include <bits/stdc++.h>
using namespace std;

// 一和零:最多 M 个 0、N 个 1,最多能选多少个字符串
int findMaxForm(vector<string>& strs, int M, int N) {
    int n = (int)strs.size();
    vector<int> zeros(n), ones(n);
    for (int i = 0; i < n; i++) {
        for (char c : strs[i]) {
            if (c == '0') zeros[i]++;
            else          ones[i]++;
        }
    }
    // dp[z][o]:当前层(i+1 往后)在容量 z、o 下的最大选择数
    vector<vector<int>> dp(M + 1, vector<int>(N + 1, 0));
    for (int i = n - 1; i >= 0; i--) {
        int z0 = zeros[i], o0 = ones[i];
        // 0-1 背包压维:z、o 必须倒序,防止同一物品被重复选
        for (int z = M; z >= z0; z--) {
            for (int o = N; o >= o0; o--) {
                dp[z][o] = max(dp[z][o], 1 + dp[z - z0][o - o0]);
            }
        }
    }
    return dp[M][N];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    vector<string> strs = {"10", "0", "1"};
    cout << findMaxForm(strs, 1, 1) << "\n";  // 2
    return 0;
}

复杂度(时间/空间,最好/平均/最坏,顺带说常数大不大)

竞赛里什么时候用(看到什么题面特征就该想到它)

竞赛模板(C++,可直接背下来的写法;变量名清楚)

// 通用三维 0-1 背包(多维费用)压维模板
// items: 每个物品的费用向量 cost[i] = {c1, c2, ...}
// cap:   每个维度的容量上限
// 返回:最多能选多少个物品
int multiDimKnapsack(const vector<vector<int>>& cost, const vector<int>& cap) {
    int n = (int)cost.size();
    int d = (int)cap.size();
    // 这里以 d == 2 为例,d 更大时用扁平数组或递归处理
    int C1 = cap[0], C2 = cap[1];
    vector<vector<int>> dp(C1 + 1, vector<int>(C2 + 1, 0));
    for (int i = 0; i < n; i++) {
        int a = cost[i][0], b = cost[i][1];
        for (int x = C1; x >= a; x--)
            for (int y = C2; y >= b; y--)
                dp[x][y] = max(dp[x][y], 1 + dp[x - a][y - b]);
    }
    return dp[C1][C2];
}

边界与易错点(WA 的常见原因,越具体越好)

同类题 / 变形(说清楚怎么改)

常见坑

拓展(变体、实际用途、面试常问)

自测 3 题(题目 + 折叠答案)

题 1:strs = ["10","0001","111001","1","0"],M=5,N=3,最多选几个?

答案

4。选 "10","0001","1","0":0 共 5 个、1 共 3 个,不超限。标准答案 4。

</details>

题 2:把上题改成「每个字符串有价值 val[i],求最大总价值」,DP 怎么改?

答案

把 1 + dp[z-z0][o-o0] 改成 val[i] + dp[z-z0][o-o0],其余不变;压维内层仍倒序。

</details>

题 3:若物品可重复选(完全背包版),压维循环方向怎么改?为什么?

答案

内层 z、o 改正序。正序时 dp[z-z0][o-o0] 已是本层更新过的值,等价于允许同一物品再选一次;倒序则保证用的是上一层(未选本物品)的值。

</details>