20261007 190334 动态规划系统课程1

20261007_190334_动态规划系统课程1.md

从 0 开始学习动态规划:中学生系统掌握 DP

适合对象: 有 C++ 基础、正在学习算法与信息学竞赛的中学生
目标: 从“完全不知道什么是动态规划”,逐步达到能够独立分析、设计状态、写出 DP,并进一步解决较复杂的竞赛题。

动态规划(Dynamic Programming,简称 DP)是中学信息学竞赛中最重要、也最容易“感觉会了但遇到新题不会”的算法之一。

真正掌握 DP,不是背几十个模板,而是掌握下面这句话:

把一个大问题分解成许多规模更小的相同类型的问题,用状态保存已经计算过的结果,再按照状态转移关系逐步求出答案。


一、先建立动态规划的整体知识体系

我们先把动态规划进行分类。

动态规划 DP
│
├── 1. 线性 DP
│   ├── 最长上升子序列 LIS
│   ├── 最大子段和
│   └── 不同走法 / 爬楼梯
│
├── 2. 背包 DP
│   ├── 01 背包
│   ├── 完全背包
│   └── 多重背包
│
├── 3. 网格 DP
│   ├── 网格走法
│   ├── 最小路径和
│   └── 带障碍物路径
│
├── 4. 区间 DP
│   ├── 石子合并
│   ├── 回文串
│   └── 矩阵链乘法
│
├── 5. 树形 DP
│   ├── 树上最大独立集
│   ├── 树的最大路径
│   └── 树上背包
│
├── 6. 状态压缩 DP
│   ├── 集合状态
│   ├── 旅行商问题
│   └── 棋盘放置
│
├── 7. DAG / 拓扑 DP
│   ├── DAG 最长路
│   ├── DAG 最短路
│   └── 计数 DP
│
└── 8. 数位 DP
    ├── 统计数字
    ├── 数字和
    └── 相邻数字限制

对于中学生,我建议按照:

线性 DP → 网格 DP → 背包 DP → 区间 DP → DAG DP → 树形 DP → 状态压缩 DP → 数位 DP

这个顺序学习。


二、第一关:什么是动态规划?

2.1 一个最简单的问题

假设有楼梯:

        ┌───┐
        │ 4 │
    ┌───┤   │
    │ 3 │   │
┌───┤   │   │
│ 2 │   │   │
│   │   │   │
└───┴───┴───┴──
  1   2   3   4

每次可以走:

  • 1 阶
  • 2 阶

问:

走到第 n 阶有多少种不同的方法?

例如:

n = 1

只有:

1

所以:

f[1] = 1

n = 2

两种:

1 + 1
2

所以:

f[2] = 2

n = 3

最后一步有两种可能:

从第 2 阶走 1 步
从第 1 阶走 2 步

所以:

f[3] = f[2] + f[1]

于是:

f[3] = 2 + 1 = 3

继续:

f[4] = f[3] + f[2]

即:

f[4] = 3 + 2 = 5

三、动态规划最重要的 5 个问题

以后遇到 DP 题,不要马上写代码。

先问自己:

问题 1:状态是什么?

例如:

dp[i]

可能表示:

到达第 i 个位置时的最优答案。


问题 2:最后一步是什么?

这是 DP 中非常重要的思维。

例如爬楼梯:

到达第 i 阶:

第 i 阶
  ↑
 / \
i-1 i-2

因此:

dp[i] = dp[i-1] + dp[i-2]

问题 3:状态如何转移?

也就是:

当前状态由哪些以前的状态得到?

例如:

dp[i] = dp[i - 1] + dp[i - 2];

问题 4:初始状态是什么?

例如:

dp[1] = 1;
dp[2] = 2;

问题 5:最终答案在哪里?

例如:

cout << dp[n];

四、动态规划的核心公式

可以把 DP 总结成:

状态
 ↓
状态转移
 ↓
初始状态
 ↓
计算顺序
 ↓
最终答案

也就是:

dp[当前] = 根据若干 dp[之前] 计算

第一类:线性 DP

线性 DP 是最适合入门的 DP。

典型特征:

状态通常按照 1 → 2 → 3 → ... → n 的顺序计算。


题目 1:爬楼梯

题目

有 n 阶楼梯,每次可以爬:

  • 1 阶
  • 2 阶

问到达第 n 阶有多少种不同方法。


1. 状态设计

定义:

dp[i]

表示:

到达第 i 阶的方法数量。


2. 状态转移

到达第 i 阶,最后一步只有两种情况:

i-1 → i

i-2 → i

所以:

dp[i] = dp[i-1] + dp[i-2]

3. 初始值

dp[1] = 1
dp[2] = 2

4. C++ 完整代码

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

int main() {
    int n;
    cin >> n;

    vector<long long> dp(n + 1);

    if (n >= 1)
        dp[1] = 1;

    if (n >= 2)
        dp[2] = 2;

    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }

    cout << dp[n] << endl;

    return 0;
}

题目 2:最大子段和

题目

给定:

-2 1 -3 4 -1 2 1 -5 4

找出一个连续子数组,使它的和最大。

答案:

4 + (-1) + 2 + 1 = 6

所以答案是:

6

1. 状态设计

定义:

dp[i]

表示:

必须以第 i 个元素结尾的最大连续子段和。

这是一个非常重要的 DP 技巧:

状态定义必须足够精确。


2. 考虑第 i 个元素

假设当前:

a[i]

有两种选择。

情况一

自己重新开始:

a[i]

情况二

接在前面的最大子段后面:

dp[i-1] + a[i]

因此:

dp[i] = max(a[i], dp[i-1] + a[i])

3. C++ 完整代码

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

int main() {
    int n;
    cin >> n;

    vector<long long> a(n + 1);
    vector<long long> dp(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    dp[1] = a[1];

    long long answer = dp[1];

    for (int i = 2; i <= n; i++) {
        dp[i] = max(a[i], dp[i - 1] + a[i]);
        answer = max(answer, dp[i]);
    }

    cout << answer << endl;

    return 0;
}

题目 3:最长上升子序列 LIS

题目

给出:

1 5 2 4 3 7

求最长严格上升子序列长度。

例如:

1 2 3 7

长度为:

4

注意:

子序列不要求连续。


1. 状态设计

定义:

dp[i]

表示:

以 a[i] 结尾的最长上升子序列长度。


2. 状态转移

如果:

a[j] < a[i]

那么:

dp[i] = max(dp[i], dp[j] + 1)

其中:

j < i

3. C++ 完整代码

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

int main() {
    int n;
    cin >> n;

    vector<int> a(n + 1);
    vector<int> dp(n + 1, 1);

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    int answer = 1;

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j < i; j++) {
            if (a[j] < a[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }

        answer = max(answer, dp[i]);
    }

    cout << answer << endl;

    return 0;
}

复杂度:

时间:O(n²)
空间:O(n)

第二类:网格 DP

网格 DP 是中学生非常容易理解的一类 DP。

例如:

┌───┬───┬───┬───┐
│   │   │   │   │
├───┼───┼───┼───┤
│   │   │   │   │
├───┼───┼───┼───┤
│   │   │   │   │
└───┴───┴───┴───┘

典型特征:

状态通常是 dp[i][j]。


题目 1:网格走法

只能:

向右 →
向下 ↓

问从左上角走到右下角有多少种方法。


状态

dp[i][j]

表示:

从 (1,1) 走到 (i,j) 的方案数。

到达 (i,j):

左边 → 当前
上边 ↓ 当前

所以:

dp[i][j] = dp[i-1][j] + dp[i][j-1]

C++ 完整代码

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

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(m + 1, 0)
    );

    dp[1][1] = 1;

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (i == 1 && j == 1)
                continue;

            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }

    cout << dp[n][m] << endl;

    return 0;
}

题目 2:最小路径和

每个格子有一个数字,只能向右或向下。

求从左上角到右下角的最小数字和。

例如:

1 3 1
1 5 1
4 2 1

最佳路线:

1 → 3 → 1 → 1 → 1

答案:

7

状态

dp[i][j]

表示:

到达 (i,j) 的最小路径和。

转移:

dp[i][j]
=
min(dp[i-1][j], dp[i][j-1])
+ a[i][j]

C++ 完整代码

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

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<long long>> a(
        n + 1,
        vector<long long>(m + 1)
    );

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> a[i][j];
        }
    }

    const long long INF = 4e18;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(m + 1, INF)
    );

    dp[1][1] = a[1][1];

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (i == 1 && j == 1)
                continue;

            dp[i][j] = min(
                dp[i - 1][j],
                dp[i][j - 1]
            ) + a[i][j];
        }
    }

    cout << dp[n][m] << endl;

    return 0;
}

题目 3:带障碍物的网格

有些格子不能走。

例如:

S . .
. # .
. . T

其中:

S:起点
T:终点
#:障碍

求方案数。


状态

仍然是:

dp[i][j]

但是:

如果是障碍:
dp[i][j] = 0

否则:

dp[i][j]
=
dp[i-1][j]
+
dp[i][j-1]

C++ 完整代码

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

int main() {
    int n, m;
    cin >> n >> m;

    vector<string> grid(n + 1);

    for (int i = 1; i <= n; i++) {
        string s;
        cin >> s;
        grid[i] = " " + s;
    }

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(m + 1, 0)
    );

    dp[1][1] = 1;

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {

            if (grid[i][j] == '#') {
                dp[i][j] = 0;
                continue;
            }

            if (i == 1 && j == 1)
                continue;

            dp[i][j] =
                dp[i - 1][j] +
                dp[i][j - 1];
        }
    }

    cout << dp[n][m] << endl;

    return 0;
}

第三类:背包 DP

背包 DP 是信息学竞赛中最重要的 DP 类型之一。

核心思想:

有限资源 + 选择物品 + 求最优值/方案数。


题目 1:01 背包

有 n 个物品。

每个物品:

重量 w[i]
价值 v[i]

背包容量为:

W

每个物品:

最多只能选择一次。

求最大价值。


状态

dp[j]

表示:

容量不超过 j 时能够获得的最大价值。


转移

对于物品 i:

不选

dp[j]

选择

dp[j-w[i]] + v[i]

所以:

dp[j]
=
max(dp[j], dp[j-w[i]] + v[i])

最关键的问题:为什么 j 要倒序?

for (int j = W; j >= w[i]; j--)

因为如果正序:

j = w[i] → W

当前物品可能被重复使用。

倒序可以保证:

当前物品只使用一次。

这是 01 背包最重要的代码特征。


C++ 完整代码

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

int main() {
    int n, W;
    cin >> n >> W;

    vector<int> w(n + 1);
    vector<int> v(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> v[i];
    }

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {

        for (int j = W; j >= w[i]; j--) {

            dp[j] = max(
                dp[j],
                dp[j - w[i]] + v[i]
            );
        }
    }

    cout << dp[W] << endl;

    return 0;
}

题目 2:完全背包

与 01 背包不同:

每个物品可以无限次选择。

例如:

物品:
重量 价值

2    3
3    4
4    5

容量:

7

状态转移

仍然:

dp[j]
=
max(dp[j], dp[j-w[i]] + v[i])

但是循环方向改变:

for (int j = w[i]; j <= W; j++)

也就是:

完全背包正序。


C++ 完整代码

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

int main() {
    int n, W;
    cin >> n >> W;

    vector<int> w(n + 1);
    vector<int> v(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> v[i];
    }

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {
        for (int j = w[i]; j <= W; j++) {

            dp[j] = max(
                dp[j],
                dp[j - w[i]] + v[i]
            );
        }
    }

    cout << dp[W] << endl;

    return 0;
}

题目 3:多重背包

每种物品有数量限制:

重量 w
价值 v
最多有 c 件

例如:

物品1:重量2,价值3,最多4件
物品2:重量3,价值5,最多2件

最直观的方法

枚举当前物品选择:

0 件
1 件
2 件
...
c 件

状态:

dp[j]

转移

dp[j]
=
max(
    dp[j],
    dp[j-k*w] + k*v
)

其中:

0 <= k <= c

C++ 完整代码

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

int main() {
    int n, W;
    cin >> n >> W;

    vector<int> w(n + 1);
    vector<int> v(n + 1);
    vector<int> c(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> v[i] >> c[i];
    }

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {

        for (int j = W; j >= 0; j--) {

            for (int k = 1;
                 k <= c[i] && k * w[i] <= j;
                 k++) {

                dp[j] = max(
                    dp[j],
                    dp[j - k * w[i]] + k * v[i]
                );
            }
        }
    }

    cout << dp[W] << endl;

    return 0;
}

三种背包必须记住

类型 每件物品使用次数 j 的方向
01 背包 1 次 从大到小
完全背包 无限 从小到大
多重背包 有限制 通常需要额外处理

最重要的口诀:

01 背包倒序,完全背包正序。


第四类:区间 DP

区间 DP 是难度明显上升的一类。

核心特点:

状态表示一个区间 [l,r]。

因此状态通常:

dp[l][r]

题目 1:石子合并

有一排石子:

a1 a2 a3 ... an

每次只能合并相邻的两堆。

合并费用:

两堆石子的总数量

求把所有石子合成一堆的最小费用。


为什么是区间 DP?

因为最后一定是:

[l, r]

整个区间合并成一堆。

假设最后一次合并的位置是:

k

那么:

[l,k]

和:

[k+1,r]

分别已经合并好了。

因此:

dp[l][r]
=
min(
    dp[l][k]
    +
    dp[k+1][r]
    +
    sum(l,r)
)

C++ 完整代码

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

int main() {
    int n;
    cin >> n;

    vector<long long> a(n + 1);
    vector<long long> prefix(n + 1, 0);

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        prefix[i] = prefix[i - 1] + a[i];
    }

    auto sum = [&](int l, int r) {
        return prefix[r] - prefix[l - 1];
    };

    const long long INF = 4e18;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(n + 1, 0)
    );

    for (int len = 2; len <= n; len++) {

        for (int l = 1; l + len - 1 <= n; l++) {

            int r = l + len - 1;

            dp[l][r] = INF;

            for (int k = l; k < r; k++) {

                dp[l][r] = min(
                    dp[l][r],
                    dp[l][k]
                    + dp[k + 1][r]
                    + sum(l, r)
                );
            }
        }
    }

    cout << dp[1][n] << endl;

    return 0;
}

题目 2:最长回文子序列

例如:

bbbab

最长回文子序列:

bbbb

答案:

4

状态

dp[l][r]

表示:

字符串 [l,r] 的最长回文子序列长度。


情况 1

如果:

s[l] == s[r]

那么:

dp[l][r]
=
dp[l+1][r-1] + 2

情况 2

如果:

s[l] != s[r]

那么至少舍弃一个端点:

dp[l][r]
=
max(
    dp[l+1][r],
    dp[l][r-1]
)

C++ 完整代码

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

int main() {
    string s;
    cin >> s;

    int n = s.size();

    vector<vector<int>> dp(
        n,
        vector<int>(n, 0)
    );

    for (int i = 0; i < n; i++) {
        dp[i][i] = 1;
    }

    for (int len = 2; len <= n; len++) {

        for (int l = 0; l + len - 1 < n; l++) {

            int r = l + len - 1;

            if (s[l] == s[r]) {

                if (len == 2)
                    dp[l][r] = 2;
                else
                    dp[l][r] = dp[l + 1][r - 1] + 2;

            } else {

                dp[l][r] = max(
                    dp[l + 1][r],
                    dp[l][r - 1]
                );
            }
        }
    }

    cout << dp[0][n - 1] << endl;

    return 0;
}

题目 3:矩阵链乘法

给出:

A1 × A2 × A3 × ... × An

矩阵乘法满足结合律:

(A1 × A2) × A3

和:

A1 × (A2 × A3)

结果相同。

但是计算次数可能不同。

求最少的乘法次数。


状态

dp[l][r]

表示:

计算矩阵 l 到 r 的最小乘法次数。

最后一次分割:

l ... k | k+1 ... r

所以:

dp[l][r]
=
min(
    dp[l][k]
    +
    dp[k+1][r]
    +
    cost
)

C++ 完整代码

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

int main() {
    int n;
    cin >> n;

    vector<long long> p(n + 1);

    for (int i = 0; i <= n; i++) {
        cin >> p[i];
    }

    const long long INF = 4e18;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(n + 1, 0)
    );

    for (int len = 2; len <= n; len++) {

        for (int l = 1; l + len - 1 <= n; l++) {

            int r = l + len - 1;

            dp[l][r] = INF;

            for (int k = l; k < r; k++) {

                long long cost =
                    dp[l][k]
                    + dp[k + 1][r]
                    + p[l - 1] * p[k] * p[r];

                dp[l][r] = min(
                    dp[l][r],
                    cost
                );
            }
        }
    }

    cout << dp[1][n] << endl;

    return 0;
}

第五类:DAG / 拓扑 DP

DAG:

Directed Acyclic Graph
有向无环图。

如果一个问题可以表示成:

状态 A → 状态 B

并且不存在环,就可以考虑 DP。


题目 1:DAG 最长路径

给定一个 DAG。

求从 1 出发能够达到的最长路径。


状态

dp[v]

表示:

到达节点 v 的最长路径长度。

对于边:

u → v

有:

dp[v] = max(dp[v], dp[u] + w)

C++ 完整代码

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

struct Edge {
    int to;
    int weight;
};

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<Edge>> graph(n + 1);
    vector<int> indegree(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;

        graph[u].push_back({v, w});
        indegree[v]++;
    }

    queue<int> q;

    for (int i = 1; i <= n; i++) {
        if (indegree[i] == 0)
            q.push(i);
    }

    vector<int> dp(n + 1, 0);

    while (!q.empty()) {

        int u = q.front();
        q.pop();

        for (auto e : graph[u]) {

            int v = e.to;

            dp[v] = max(
                dp[v],
                dp[u] + e.weight
            );

            indegree[v]--;

            if (indegree[v] == 0)
                q.push(v);
        }
    }

    int answer = 0;

    for (int i = 1; i <= n; i++) {
        answer = max(answer, dp[i]);
    }

    cout << answer << endl;

    return 0;
}

题目 2:DAG 路径计数

从节点 1 到节点 n 有多少条路径?

状态:

dp[v]

表示:

从 1 到 v 的路径数量。

对于:

u → v

有:

dp[v] += dp[u]

C++ 完整代码

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

const long long MOD = 1000000007;

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<int>> graph(n + 1);
    vector<int> indegree(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;

        graph[u].push_back(v);
        indegree[v]++;
    }

    queue<int> q;

    for (int i = 1; i <= n; i++) {
        if (indegree[i] == 0)
            q.push(i);
    }

    vector<long long> dp(n + 1, 0);

    dp[1] = 1;

    while (!q.empty()) {

        int u = q.front();
        q.pop();

        for (int v : graph[u]) {

            dp[v] += dp[u];
            dp[v] %= MOD;

            indegree[v]--;

            if (indegree[v] == 0)
                q.push(v);
        }
    }

    cout << dp[n] << endl;

    return 0;
}

题目 3:DAG 最短路

状态:

dp[v]

表示:

从起点到 v 的最短距离。

对于边:

u → v

:

dp[v]
=
min(dp[v], dp[u] + w)

这与 DAG 最长路的区别只有:

max

变成:

min

第六类:树形 DP

树形 DP 是信息学竞赛中非常重要的一类。

假设:

        1
       / \
      2   3
     / \
    4   5

每个节点都有自己的状态。


题目 1:树上最大独立集

要求:

选择尽可能多的节点,但是父子节点不能同时选择。

例如:

        1
       / \
      2   3
     / \
    4   5

可以选择:

1 4 5

也可以:

2 3

状态

定义:

dp[u][0]
dp[u][1]

含义:

dp[u][0]:
不选择 u 时,子树最大答案

dp[u][1]:
选择 u 时,子树最大答案

转移

如果选择 u:

dp[u][1]
=
1
+
所有 dp[v][0]

因为:

选择父节点 u,就不能选择孩子 v。

如果不选择 u:

dp[u][0]
=
sum(max(dp[v][0], dp[v][1]))

C++ 完整代码

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

vector<vector<int>> graph;
vector<vector<int>> dp;

void dfs(int u, int parent) {

    dp[u][0] = 0;
    dp[u][1] = 1;

    for (int v : graph[u]) {

        if (v == parent)
            continue;

        dfs(v, u);

        dp[u][0] += max(
            dp[v][0],
            dp[v][1]
        );

        dp[u][1] += dp[v][0];
    }
}

int main() {
    int n;
    cin >> n;

    graph.resize(n + 1);
    dp.resize(n + 1, vector<int>(2));

    for (int i = 1; i < n; i++) {

        int u, v;
        cin >> u >> v;

        graph[u].push_back(v);
        graph[v].push_back(u);
    }

    dfs(1, 0);

    cout << max(
        dp[1][0],
        dp[1][1]
    ) << endl;

    return 0;
}

题目 2:树的最大路径和

每个节点有一个权值:

        5
       / \
      2   8
         / \
        3   4

求树中任意两个节点之间路径上的最大权值和。


核心思想

对于节点 u:

记录:

从 u 向下面走能够得到的最大路径。

然后考虑:

左边最长 + u + 右边最长

C++ 完整代码

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

vector<vector<int>> graph;
vector<long long> value;
long long answer = LLONG_MIN;

long long dfs(int u, int parent) {

    long long best1 = 0;
    long long best2 = 0;

    for (int v : graph[u]) {

        if (v == parent)
            continue;

        long long child = dfs(v, u);

        if (child > best1) {
            best2 = best1;
            best1 = child;
        }
        else if (child > best2) {
            best2 = child;
        }
    }

    answer = max(
        answer,
        value[u] + best1 + best2
    );

    return value[u] + best1;
}

int main() {
    int n;
    cin >> n;

    value.resize(n + 1);
    graph.resize(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> value[i];
    }

    for (int i = 1; i < n; i++) {

        int u, v;
        cin >> u >> v;

        graph[u].push_back(v);
        graph[v].push_back(u);
    }

    dfs(1, 0);

    cout << answer << endl;

    return 0;
}

题目 3:树上选择问题

要求:

选择一些节点,父子节点不能同时选择,并且每个节点具有价值,求最大价值。

这实际上就是题目 1 的加权版本。

状态仍然:

dp[u][0]
dp[u][1]

但是:

dp[u][1] = value[u]

然后:

dp[u][1] += dp[v][0]

第七类:状态压缩 DP

这是从中级进入高级的重要一步。

核心思想:

用一个整数的二进制位表示一个集合。

例如:

5 = 0101

可以表示:

第 0 个元素选了
第 2 个元素选了

题目 1:旅行商问题 TSP

有 n 个城市。

从城市 0 出发:

每个城市访问恰好一次,最后回到 0。

求最小费用。


状态

dp[mask][i]

表示:

已经访问了 mask 中的城市,并且当前位于城市 i 时的最小费用。

例如:

mask = 01011

表示访问:

0、1、3

转移

从:

i

前往:

j

:

newMask = mask | (1 << j)

然后:

dp[newMask][j]
=
min(
    dp[newMask][j],
    dp[mask][i] + cost[i][j]
)

C++ 完整代码

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

int main() {
    int n;
    cin >> n;

    vector<vector<int>> cost(
        n,
        vector<int>(n)
    );

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            cin >> cost[i][j];
        }
    }

    const long long INF = 4e18;

    int total = 1 << n;

    vector<vector<long long>> dp(
        total,
        vector<long long>(n, INF)
    );

    dp[1][0] = 0;

    for (int mask = 0; mask < total; mask++) {

        for (int u = 0; u < n; u++) {

            if (dp[mask][u] == INF)
                continue;

            if (!(mask & (1 << u)))
                continue;

            for (int v = 0; v < n; v++) {

                if (mask & (1 << v))
                    continue;

                int newMask =
                    mask | (1 << v);

                dp[newMask][v] = min(
                    dp[newMask][v],
                    dp[mask][u] + cost[u][v]
                );
            }
        }
    }

    long long answer = INF;

    int full = (1 << n) - 1;

    for (int i = 1; i < n; i++) {

        answer = min(
            answer,
            dp[full][i] + cost[i][0]
        );
    }

    cout << answer << endl;

    return 0;
}

复杂度:

O(2^n × n²)

因此通常:

n ≤ 20

左右才比较适合。


题目 2:集合选取

有 n 个元素,每个元素有价值。

要求选择一个集合,使:

某些元素之间不能同时出现。

这类题目经常可以转化为:

mask

表示当前已经选择了哪些元素。


题目 3:棋盘状态压缩 DP

例如:

在一个 n × m 棋盘上放置棋子,要求任意两个棋子不能相邻。

可以使用:

mask

表示一行中哪些位置放了棋子。

例如:

01010

表示:

第 2、4 个位置放棋子

然后:

dp[i][mask]

表示:

前 i 行,第 i 行状态为 mask 时的最大方案数。

这是状态压缩 DP 在棋盘问题中的典型形式。


第八类:数位 DP

数位 DP 是中学生 DP 中相对高级的一类。

典型问题:

统计 1 ~ N 中满足某种数字条件的整数数量。

例如:

1 ~ N 中有多少个数字不包含数字 7?


为什么普通枚举不行?

如果:

N = 10^18

显然不能:

for (long long i = 1; i <= N; i++)

所以需要:

按数字位进行 DP。


数位 DP 的典型状态

常见状态:

dp[pos][state][limit]

其中:

pos

表示处理到第几位。

state

表示当前已经记录的信息。

limit

表示:

当前前缀是否受到 N 的限制。


题目 1:统计不含 7 的数字

例如:

N = 100

统计:

1 ~ 100

中不包含数字 7 的数字。


C++ 完整代码

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

string s;

long long memo[20][2];
bool vis[20][2];

long long dfs(
    int pos,
    bool tight
) {
    if (pos == (int)s.size()) {
        return 1;
    }

    if (!tight && vis[pos][0]) {
        return memo[pos][0];
    }

    long long ans = 0;

    int limit = tight
        ? s[pos] - '0'
        : 9;

    for (int digit = 0; digit <= limit; digit++) {

        if (digit == 7)
            continue;

        bool nextTight =
            tight && (digit == limit);

        ans += dfs(
            pos + 1,
            nextTight
        );
    }

    if (!tight) {
        vis[pos][0] = true;
        memo[pos][0] = ans;
    }

    return ans;
}

int main() {
    cin >> s;

    cout << dfs(0, true) << endl;

    return 0;
}

这里需要特别注意:

leading zero

前导零的处理。

在更复杂的数位 DP 中通常还需要:

started

表示:

真正的数字是否已经开始。


题目 2:统计数字和

求:

1 ~ N

中所有数字的数位和。

例如:

1 + 2 + ... + 9 + 10 + 11 + ...

这类题目可以使用:

pos
sum
tight

进行状态设计。


题目 3:不出现连续相同数字

例如统计:

1 ~ N

中满足:

不存在连续两个相同数字

的数字数量。

状态可以设计成:

dp[pos][last][tight]

其中:

pos

当前位。

last

上一位数字。

tight

是否受到 N 限制。

转移时:

if digit == last
    不允许

这就是数位 DP 的典型思想。


九、动态规划中最重要的“状态设计”

学习 DP,真正的难点不是代码。

而是:

如何定义 dp。

下面给出一个非常重要的“状态设计表”。

问题类型 常见状态
爬楼梯 dp[i]
最大子段和 dp[i]
LIS dp[i]
网格 dp[i][j]
01 背包 dp[j]
区间 DP dp[l][r]
树形 DP dp[u][state]
DAG DP dp[u]
状态压缩 dp[mask][state]
数位 DP dp[pos][state][limit]

十、遇到一道新 DP 题,按照这个流程思考

以后给学生做题,我建议强制执行下面的流程。

Step 1:判断是不是 DP

问:

一个大问题能不能拆成很多相同类型的小问题?

如果可以,继续。


Step 2:定义状态

问:

dp[...] 到底表示什么?

必须用一句完整中文说出来。

例如:

dp[i] 表示以第 i 个元素结尾的最长上升子序列长度。

而不是:

dp 是答案。

这是非常重要的区别。


Step 3:寻找最后一步

这是最强的 DP 思维之一。

例如:

dp[i]

问:

到达 i 之前最后在哪里?

例如爬楼梯:

i-1 → i
i-2 → i

LIS:

j → i

区间 DP:

[l,k] + [k+1,r]

树形 DP:

父节点 ← 子节点

Step 4:写转移方程

例如:

dp[i] = dp[i-1] + dp[i-2]

或者:

dp[i] = max(dp[i], dp[j] + 1)

或者:

dp[l][r]
=
min(
    dp[l][k]
    + dp[k+1][r]
    + cost
)

Step 5:确定初始化

例如:

dp[1] = 1;

或者:

dp[i][i] = 1;

初始化错误是 DP 最常见的错误之一。


Step 6:确定计算顺序

这是非常重要的一步。

例如:

线性 DP

i = 1 → n

网格 DP

左上 → 右下

区间 DP

区间长度从小到大

DAG DP

拓扑序

树形 DP

后序 DFS

数位 DP

从高位到低位

十一、DP 的三个核心组成部分

可以把任何 DP 题强行写成:

          状态
           ↓
      dp[i] 是什么?
           ↓
       状态转移
           ↓
   dp 如何从以前状态得到?
           ↓
        初始状态
           ↓
       从哪里开始?
           ↓
        计算顺序
           ↓
       最终答案

十二、最容易犯的 10 个错误

错误 1:不知道 dp 表示什么

错误:

dp[i] = ...

却不知道 dp[i] 的具体含义。


错误 2:状态定义不完整

例如 LIS:

错误:

dp[i] = 前 i 个元素的答案

更好的定义:

dp[i] = 以 a[i] 结尾的最长上升子序列长度

错误 3:初始化错误

例如:

vector<int> dp(n);

默认全是:

0

但有些 DP 的初始值必须是:

1

或者:

-INF

或者:

INF

错误 4:01 背包循环方向错误

错误:

for (int j = w[i]; j <= W; j++)

这会让物品重复使用。

01 背包:

for (int j = W; j >= w[i]; j--)

错误 5:区间 DP 顺序错误

不能随便:

for (l)
    for (r)

通常需要:

for (len = 1; len <= n; len++)

保证:

dp[l][k]
dp[k+1][r]

已经计算。


错误 6:忘记考虑“最后一步”

这是最常见的思维障碍。

遇到 DP:

先想最后一步。


错误 7:把 DP 当成暴力搜索

暴力:

枚举所有可能

DP:

相同子问题只计算一次

错误 8:DP 数组开太大

例如:

int dp[100000][100000];

直接爆内存。

需要考虑:

状态压缩
滚动数组
一维 DP

错误 9:没有考虑数据范围

如果:

n = 100000

通常:

O(n²)

可能不行。

如果:

n = 20

那么:

O(2^n n²)

反而可能可以。

所以:

先看数据范围,再决定算法。


错误 10:背模板

这是最危险的。

不要记:

LIS = 这个模板
背包 = 那个模板

而应该记:

为什么这样定义状态?
为什么这样转移?
为什么循环顺序是这样?

十三、中学生 DP 学习路线

建议分成 5 个阶段。


第一阶段:DP 入门

目标:

能看懂简单 DP。

学习:

1. 什么是状态
2. 什么是状态转移
3. 初始化
4. 计算顺序

练习:

爬楼梯
斐波那契
走方格
最大子段和

第二阶段:线性 DP + 网格 DP

学习:

dp[i]

dp[i][j]

重点题型:

LIS
最长公共子序列 LCS
最大子段和
网格路径
最小路径

重点掌握:

“最后一步”分析法。


第三阶段:背包 DP

这是非常重要的阶段。

必须掌握:

01 背包
完全背包
多重背包
分组背包
二维费用背包
背包求方案数
背包求具体方案

重点理解:

为什么 01 背包倒序?

为什么完全背包正序?

第四阶段:高级 DP

学习:

区间 DP
树形 DP
DAG DP
状态压缩 DP
数位 DP

这个阶段开始真正进入竞赛级 DP。


第五阶段:综合 DP

最后学习:

DP + 二分
DP + 贪心
DP + 图论
DP + 数据结构
DP + 单调队列
DP + 线段树
DP 优化

例如:

斜率优化 DP
四边形不等式优化
单调队列优化
分治优化
树上背包

十四、DP 难度阶梯

可以把 DP 学习分成下面几个等级:

Level 0
│
├── 斐波那契
├── 爬楼梯
└── 简单网格
        ↓
Level 1
│
├── 最大子段和
├── LIS
└── LCS
        ↓
Level 2
│
├── 01 背包
├── 完全背包
└── 多重背包
        ↓
Level 3
│
├── 区间 DP
├── DAG DP
└── 树形 DP
        ↓
Level 4
│
├── 状态压缩 DP
├── 数位 DP
└── 树上背包
        ↓
Level 5
│
├── DP 优化
├── 单调队列优化
├── 斜率优化
└── 综合 DP

十五、学生必须掌握的 DP 模板

模板 1:一维 DP

vector<long long> dp(n + 1);

for (int i = 1; i <= n; i++) {

    // 根据以前的状态计算 dp[i]

}

模板 2:二维 DP

vector<vector<long long>> dp(
    n + 1,
    vector<long long>(m + 1)
);

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {

        // 状态转移

    }
}

模板 3:01 背包

for (int i = 1; i <= n; i++) {

    for (int j = W; j >= w[i]; j--) {

        dp[j] = max(
            dp[j],
            dp[j - w[i]] + v[i]
        );
    }
}

模板 4:完全背包

for (int i = 1; i <= n; i++) {

    for (int j = w[i]; j <= W; j++) {

        dp[j] = max(
            dp[j],
            dp[j - w[i]] + v[i]
        );
    }
}

模板 5:区间 DP

for (int len = 2; len <= n; len++) {

    for (int l = 1; l + len - 1 <= n; l++) {

        int r = l + len - 1;

        for (int k = l; k < r; k++) {

            dp[l][r] = min(
                dp[l][r],
                dp[l][k]
                + dp[k + 1][r]
                + cost
            );
        }
    }
}

模板 6:树形 DP

void dfs(int u, int parent) {

    for (int v : graph[u]) {

        if (v == parent)
            continue;

        dfs(v, u);

        // dp[u] 根据 dp[v] 转移
    }
}

模板 7:状态压缩 DP

for (int mask = 0; mask < (1 << n); mask++) {

    for (int i = 0; i < n; i++) {

        if (mask & (1 << i)) {

            // 状态转移

        }
    }
}

十六、真正掌握 DP 的标准

学生学完之后,不应该只是能够写:

dp[i] = ...

而应该能够面对一道陌生题,按照下面的顺序说出来:

① 我认为这是一个 DP 问题。

② 我定义 dp[...] 表示……

③ 考虑最后一步,当前状态可以由……转移过来。

④ 所以状态转移方程是……

⑤ 初始状态是……

⑥ 根据依赖关系,计算顺序应该是……

⑦ 状态数量是……

⑧ 每个状态转移需要……时间。

⑨ 因此总时间复杂度是……。

⑩ 最终答案是 dp[...]。

如果一个中学生能够完整说出这十步,才可以认为他真正开始掌握动态规划。


十七、建议的系统课程安排

如果把动态规划真正教成一门课程,我建议安排 12 个阶段:

阶段 内容 目标
01 DP 基本思想 理解什么是 DP
02 一维线性 DP 掌握 dp[i]
03 二维/网格 DP 掌握 dp[i][j]
04 LIS/LCS 掌握序列 DP
05 01 背包 掌握选择模型
06 完全/多重背包 掌握背包变形
07 区间 DP 掌握 dp[l][r]
08 DAG DP 掌握图上的 DP
09 树形 DP 掌握 DFS + DP
10 状态压缩 DP 掌握集合状态
11 数位 DP 掌握数字限制
12 DP 优化 进入竞赛提高阶段

十八、最重要的一张思维导图

最后把整个动态规划体系压缩成一张图:

                         动态规划
                            │
              ┌─────────────┴─────────────┐
              │                           │
           状态设计                    状态转移
              │                           │
       ┌──────┼──────┐             ┌──────┼──────┐
       │      │      │             │      │      │
      一维    二维   集合          加法    最大值   最小值
       │      │      │
      dp[i] dp[i][j] dp[mask]
       │      │      │
       ├──────┼──────┼──────────────────┐
       │      │      │                  │
      线性    网格   背包             状态压缩
       │      │      │                  │
       │      │   01/完全/多重           │
       │      │                         │
       └──────┴──────────┬──────────────┘
                          │
                     更高级的结构
                          │
             ┌────────────┼────────────┐
             │            │            │
           区间 DP      树形 DP      DAG DP
             │            │            │
             └────────────┼────────────┘
                          │
                     高级 DP
                          │
                 ┌────────┴────────┐
                 │                 │
              数位 DP          DP 优化
                                   │
                     ┌─────────────┼─────────────┐
                     │             │             │
                  单调队列       斜率优化      分治优化

十九、给中学生的最终口诀

学习 DP 时,可以让学生背下面这段口诀:

先判断,后定义;
找状态,想含义;
看最后,找转移;
定初值,排顺序;
算复杂,查边界;
写代码,再验证。

而面对一道真正的 DP 题,最应该反复问自己的只有三个问题:

① dp 到底表示什么?

② 当前状态从哪里来?

③ 为什么所有情况都被考虑到了?

只要这三个问题能够回答清楚,代码实际上只是把数学关系翻译成 C++。

如果按照中学生信息学奥赛的标准继续深入,下一步最值得系统学习的是 “线性 DP → LIS/LCS → 01 背包 → 完全背包 → 多重背包 → 区间 DP”,并通过由易到难的题目训练“状态定义”和“状态转移”能力,而不是继续单纯增加模板数量。