banner
NEWS LETTER

线性动态规划与背包问题 | 学习笔记

  • Home
  • dp_and_knapsack
Scroll down

基础动态规划 $Dynamic\ Programming$

主要思想

类似于递推, 将当前状态的属性(最大值 $max$, 最小值 $min$, 解的个数 $nums$)由上一个状态转移而来, 从而继承上一个状态的属性, 具有最优子结构属性, 由此得出状态转移方程.

$\rm dp$ 的状态转移方程可以有两种思考方式:顺推和逆推. 顺推即考虑当前状态能向后推出何状态, 逆推即考虑当前状态由哪些状态转移而来.

状态转移方程的推导方式

openjudge4008-糖果 为例.

1. 顺推

1
2
f[i + 1][j] = f[i][j]; // 不选当前第 i + 1 件物品
f[i + 1][(j + a[i + 1] % k)] = f[i][j] + a[i + 1]; // 选当前第 i + 1 件物品

值得注意的是, 在顺推中 $f_{i, j}$ 表示未计算 $i, j$ 值的状态, 因此顺推的目标解为 $f_{n + 1, k}$.

2. 逆推

1
2
f[i][j] = f[i - 1][j]; // 不选当前第 i 件物品
f[i][j] = f[i - 1][((j - a[i]) % k + k) % k] + a[i]; // 选当前第 i 件物品, + k % k 意在保证下标的恒正性

对于两种状态取 $max$ 即可.

同时, 该状态需要具有无后效性的特点, 即当前状态的选择无法影响下一状态的决策.

另外值得注意的是, 我们需要对dp边界的初始值进行特判.

动态规划问题的时间复杂度分析

状态数量 * 转移计算量


背包问题:

给定 $n$ 个物体, 和一个容量是 $v$ 的背包, 每个物体的体积是 $vi$, 权重是 $wi$, 求如何装物品使得总权重最大

在背包问题中, 一般用 $f_{i, j}$ 表示所有从前 $i$ 个物品中选并且总体积不超过 $j$ 的所有选法中 最大 / 最小 的总价值

经典背包问题特点

背包问题 特点
$0/1$ 背包 每件物品最多只能用一次
完全背包 每件物品可以用无限次
多重背包 每件物品可以使用有限次
分组背包 将所有物品分为若干组, 每组物品中最多只能选一个物品

另外为了方便叙述, 在以下的讲解中, 各数组含义如下:

1
2
3
int n, m; // n 表示所有物品的个数, m 表示背包容量
int v[N + 10], w[N + 10]; // v 表示各个物品所占的体积, w 表示各个物品的价值
int f[N + 10][N + 10]; // f_{i, j} 表示选 i 件物品, 体积为 j 的价值最大值

$0/1$ 背包

尝试考虑暴力:

1
2
3
4
5
6
7
8
9
// for (int j = 0; j <= m; j ++) f[0][j] = 0;
// 一件物品都不选, 可省略
for (int i = 1; i <= n; i ++)
for (int j = 1; j <= m; j ++) {
f[i][j] = f[i - 1][j]; // 不取 i 的情况
if (j >= v[i]) f[i][j] = max(f[i][j], f[i - 1][j - v[i]] + w[i]);
// 能够装下 i 时取 i 的情况
}
cout << f[n][m] << endl;

在 dp 每一层的状态计算中不难发现, 每一层第一维度的值都和上一层的值有关 f[i][j] = f[i - 1][j] , 第二维度的值只可能出现 jj - v[i], 那么可以利用滚动数组对空间进行优化:

1
2
3
4
5
6
for (int i = 1; i <= n; i ++)
for (int j = m; j >= v[i]; j --)
// 将 j 倒序计算, 则可以保证 j - v[i] 为 i - 1 层的值
// f[i][j] = f[i - 1][j]; 删除
// if (j >= v[i]) j 直接从 v[i] 开始, 则无需判断
f[j] = max(f[j], f[j - v[i]] + w[i]);

看到这里, 如果您对于为何倒序枚举的问题仍不是很明白, 那么可以尝试使用暴力枚举的方法做一下 $\rm Luogu\ P2347$ - 砝码称重. 通过思考这道题的枚举顺序, 能够加深您对背包枚举顺序的理解.

不难发现, 分别枚举砝码种类, 砝码个数, 在可用的重量上加上当前种类的重量即可.

1
2
3
4
for (int i = 0; i < 6; i ++)
for (int j = 0; j < nums[i]; j ++)
for (int k = 1000; k >= 0; k --)
if (f[k]) f[k + w[i]] = true;

在枚举砝码的时候, 如果 $k$ 正向枚举, 会出现刚刚用过的砝码继续在下一层重量循环累积, 而一个砝码在同一个砝码循环是只能用一次的. 而由于 $f[k+w[i]]=1$ 一定是大于 $f[k]$ 的, 因此使用倒序枚举会有效规避掉此问题.

同时我们发现, 当 $k$ 正向枚举时, 会将同一个砝码重复使用若干次. 于是, 完全背包的一维优化由此延伸, 使用正序枚举即可, 让我们接着往下读!


完全背包

完全背包的状态计算:

类比于 $0/1$ 背包将当前情况分为 选 ( $1$ ) 或 不选 ( $0$ ), 在完全背包中可以按照选多少个物品分类

对于各个集合中状态的表示, 设当前物品选了 $k$ 个, 即有状态转移方程:

$$ f_{i, j} = f[i - 1][j - k * v_i] + k * w_i $$

将每个情况取最大值, 即可求出当前情况的价值最大值

暴力

1
2
3
4
5
for (int i = 1; i <= n; i ++)
for (int j = 1; j <= m; j ++)
for (int k = 0; k * v[i] <= j; k ++)
// 枚举物品数量, 保证总体积不超过 j
f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i]);

一维优化:

当对 $f_{i, j}$ 进行枚举时, 进行的过程是这样的:

$$ f[i, j] = max(f[i, j], f[i, j - v_i] + w_i, f[i, j - 2 * v_i] + 2 * w_i, …) $$

在枚举 $f_{i, j}$ 时, 我们已经完成了对 $f_{i, j - v_i}$ 的枚举, 当时进行的过程是这样的:

$$ f[i][j - v_i] = max(f[i][j - v_i], f[i - 1][j - 2 * v_i] + w_i, …) $$

此时我们发现, 计算 $f_{i, j}$ 的过程与计算 f[i][j - v[i]] 的过程非常相似, 规律可以表述为:

max(f[i, j - v[i]] + w[i], f[i, j - 2 * v[i]] + 2 * w[i], ...) = f[i, j - v[i]] + w[i]

那么我们可以将状态转移方程优化为:

1
2
3
4
5
for (int i = 1; i <=n; i ++)
for (int j = 0; j <= m; j ++) {
f[i][j] = f[i - 1][j];
if (j >= v[i]) f[i][j] = max(f[i][j], f[i][j - v[i]] + w[i]);
}

此时的状态转移方程为:
f[i][j] = max(f[i][j], f[i][j - v[i] + w[i]])

而在01背包中的状态转移方程为:
f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i] + w[i]])

类比于 $0/1$ 背包的一维优化, 我们可以将完全背包优化为:

1
2
3
for (int i = 1; i <=n; i ++)
for (int j = v[i]; j <= m; j ++)
f[j] = max(f[j], f[j - v[i]] + w[i]);

到此为止, 我们已经将完全背包的空间复杂度优化到了 $O(n)$

此时我们会惊奇地发现, 完全背包的一维优化与 $01$ 背包的一维优化极其相似. 关键点在于 $j$ 的枚举顺序, 倒序枚举表示当前情况中不包含当前这一层物品的选择, 也就保证了当前层物品选择的唯一性 ( $0$ / $1$ ). 而在完全背包中, 任何一件物品都有无数件, 因此从前向后转移就包含了前面对于这一层物品的选择, 从而达到最优


多重背包

对于每个物品都有个数限制 $s_i$, 只需枚举物品个数 $k$ 并使 $k$ 满足 $ k \le s_i$ 且 $k * v_i \le j$

朴素 $O(nms)$

1
2
3
4
5
for (int i = 1; i <= n; i ++)
for (int j = 1; j <= m; j ++)
for (int k = 0; k <= s[i] and k * v[i] <= j; k ++)
// 保证总体积不超过 j 的同时使 k 不超过限制个数
f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i]);

多重背包的二进制拆分优化 $O(w \cdot n \cdot \log s)$:

基本原理:

假设s = 1023, 如按照朴素方式枚举, 则需要枚举 1023 次. 此时我们将 1 ~ 1023 分为十组, 每组分别是:

$2^0, 2^1, 2^2, 2^3, \cdots, 2^9$

即:$1, 2, 4, 8, …, 512$

易证对于 $[1, 1023]$ 区间中的每一个数字, 都可以用以上 $10$ 个数表示

当 $s$ 不为 $2$ 的正整数次方时, 假设 $s = 200$, 此时我们将 $200$ 分为以下几组:

$2^0, 2^1, 2^2, 2^3, 2^4, 2^5, 2^6, 73$

此时即可以用以上几组数据将 [1, 200] 区间中所有的数字表示出来

将 $s$ 推广为一般数字:

$2^0, 2^1, 2^2,…, 2^k, s - 2^k$

由此可知, 对于任意一个 $s$ , 都可以拆分为 $logs$ 个组, 通过判断选组的情况即可转化为 $01$ 背包 问题

1
2
3
4
5
6
7
8
9
10
for (int i = 1; i <= n; ++ i) {
input(v), input(w), input(c);
for (int j = 1; j <= c; j <<= 1) {
for (int k = m; k >= j * v; -- k)
f[k] = max(f[k], f[k - j * v] + j * w);
c -= j;
}
if (c) for (int k = m; k>= c * v; -- k)
f[k] = max(f[k], f[k - c * v] + c * w);
}

线性多重背包 $O(n)$

我们发现二进制优化的多重背包至多将每个物品分为 $logn$ 个物品, 因此时间复杂度为 $O(n \cdot logn)$. 而多重背包转移具有决策单调性, 使用单调队列可以优化至 $O(n)$ 线性, 详见 $\rm dp$ 优化.


分组背包

将所有物品分为 $n$ 组, 每组物品中最多只能选一个物品.

枚举第 $i$ 组中选第 $k$ 物品即可

可得状态转移方程:

$$f_{i, j} = max(f_{i, j}, f_{i - 1, j - v{i, k}} + w_{i, k})$$

($v_{i, k}$ 表示第 $i$ 组物品中第 $k$ 个物品的体积, 以此类推)

模板:

1
2
3
4
5
for (int i = 1; i <= n; i ++)
for (int j = m; j >= 0; j --)
for (int k = 0; k < s[i]; k ++)
if (v[i][k] <= j)
f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);

依赖背包

选 $A$ 物品需要购买 $B$ 物品, 或选 $A$ 物品才可以买 $B, C$ 物品

$\rm Luogu\ P1064$ - 金明的预算方案

基本的解决思路是, 我们将两种绑定的物品转化为分组背包, 举个例子, 若买 $A$ 之后才可以买 $B$, 会出现以下三种情况

  1. 选 $A$ 不选 $B$
  2. 选 $A$ 选 $B$
  3. 不选 $A$ 不选 $B$

对于每一种情况, 进行 $dfs$ 深度搜索绑定为一个组, 再按照分组背包的二进制解即可

我们可以发现, 当有 $n$ 件物品依附于一件物品时, 分出组的个数为 $2^n + 1$ 时间复杂度起飞 ~

当物品的绑定关系较为复杂时, 需要使用 树形$\rm dp$ 解决以上问题

扩展: 输出背包方案

当解决最优化问题时, 题目可能要求输出最优化状态下的方案. 通过记录该状态从哪个状态转移而来, 输出方案时倒推到初始状态即可.


线性动态规划

经典应用:

1. 最长上升子序列

暴力版本 $O(n^2)$

集合

可以用 $f_i$ 表示所有以第 $i$ 个数结尾的上升子序列, 维护的属性是长度最大值 $Max$;

  • 那么如何将初始状态转移到 $f_i$ 呢?

我们可以依照 $f_i$ 子序列的倒数第二个数来分类: $0$ 表示当前子序列长度为 $1$, $1$ 表示该子序列的倒数第二个数为总序列第 $1$ 个数 ……以此类推, 直到 $i - 1$ 表示该子序列的倒数第二个数为总序列第 $i - 1$ 个数.

  • 如何判断该状态是否成立呢?

对于 $f_i = j$, 只需判断 $a[j] < a[i]$, 如成立则存在该状态

因此得到的状态转移方程为:

$$f_i = max(f_i, f_j + 1)$$

  • 该解法的时间复杂度?

状态数量 $O(n)$ * 转移计算量 $O(n)$ = $O(n^2)$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int n, a[N];
int f[N];

cin >> n;
for (int i = 1; i <= n; i ++) cin >> a[i];
for (int i = 1; i <= n; i ++) {
f[i] = 1; // 最长上升子序列至少为 1, 即为当前数
for (int j = 1; j <= i - 1; j ++)
if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
}

int res = -1e9;
for (int i = 1; i <= n; i ++) res = max(res, f[i]);
cout << res << endl;

另外, 也可以将选当前位置和不选当前位置单独分离出一个二维状态

若要记录最长上升子序列, 则将主体部分改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int g[N];
// 存储第 i 个点是由哪个下标位置转移而来的

for (int i = 1; i <= n; i ++) {
f[i] = 1, g[i] = 0; // 最长上升子序列至少为 1 ,即为当前数
for (int j = 1; j <= i - 1; j ++)
if (a[j] < a[i] and f[i] < f[j] + 1)
f[i] = f[j] + 1, g[i] = j; // 更新 f 并记录转移方向
}

int k = 1; // 记录最长子序列位置的下标
for (int i = 1; i <= n; i ++)
if (f[k] < f[i]) k = i; // 维护上升子序列长度的最大值
cout << f[k] << endl; // 输出最长上升子序列长度

for (int i = 0, len = f[k]; i < len; i ++) {
cout << a[k] << " ";
k = g[k];
}
// 倒序输出最长上升子序列

单调优化版:$O(n \cdot logn)$

观察性质可以发现, 对于

$3$ $1$ $2$ $1$ $8$ $5$ $6$

这个序列, 当枚举以 $8$ 为结尾的最长上升子序列时, 下标为 $1$ 的数字 $1$ 无疑是比下标为 $0$ 的数字 $3$ 更优的选择 (涵盖的值域更大, 更有机会取得最长上升子序列)

因此我们可以开一个数组 $q$ 存储长度为 $i$ 的上升子序列中结尾元素最小的情况中结尾元素的最小值.

$Q & A$
  • 该最小值是否随结尾值 $i$ 的增大而严格单调递增?

假设对于长度为 $k$ 的末尾最小值为 $n$ , 若 $n$ 小于或等于等于长度为 $k - 1$ 的末尾的最小值, 则对于 $k - 1$ 必有一种更优的、最小值更小的情况, 则该假设不成立

  • 那么如何求出以 $a_i$ 结尾的最长上升子序列的长度的最大值呢?

对于预处理好的不同长度的上升子序列 $q$, 只需在该数组中利用二分查找找到不大于 $a_i$ 的最大的值 $q_k$ , 将 $a_i$ 接在 q[k] 后面即可. 此时还要注意, 由于 $q_k$ 是不大于 $a_i$ 的最大的情况, 所以 $q_{k + 1}$ 的值一定会大于 $a_i$ 因此需要更新 $q_{k + 1}$.

由于对于每个点进行二分查找, 因此该算法的时间复杂度是 $O(n \cdot logn)$

模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int n;
// n 表示元素个数
int a[N], q[N];
// a[] 用于存储数列
// p[] 用于维护所有不同长度的最长上升子序列结尾元素的最小值

cin >> n;
for (int i = 0; i < n; i++) cin >> a[i];

int len = 0;
// 存储当前枚举到的最长上升子序列的长度, 即 q 数组中的元素个数
q[0] = -2e9; // 初始化边界
for (int i = 0; i < n; i ++) {
// 二分查找不大于 a[i] 的最大值在 p 中的元素位置
int l = 0, r = len;
while (l < r) {
int mid = l + r + 1 >> 1;
if (q[mid] < a[i]) l = mid;
else r = mid - 1;
}
// r 返回不大于 a[i] 的最大值的下标(即长度)
len = max(len, r + 1);
// 将 a[i] 连接在不大于 a[i] 的最大值的最长上升子序列的后面, 表示长度, 即 r + 1
q[r + 1] = a[i];
}
cout << len << endl;

2. 最长公共子序列

集合

使用 $f_{i, j}$ 表示所有 第一个字符串的前 $i$ 个字母构成的子序列第二个字符串前 $j$ 个字母构成的子序列 的公共子序列长度

属性

长度最大值

集合划分

将全集依照 是否选择 $a_i$ 和 $b_j$ 分为四个子集:

$00$:$a_i$ 与 $b_j$ 均不选
$01$:$a_i$ 不选而 $b_j$ 选
$10$:$a_i$ 选而 $b_j$ 不选
$11$:$a_i$ 与 $b_j$ 选

状态表示

对于第一种状态, 可以表示为 $f_{i - 1, j - 1}$

对于第二种状态, 可以表示为 $f_{i - 1, j}$, 但由于 f 数组表示的是子序列长度, 所以并不能达到 “不包含 $a_i$ 而包含 $b_j$ 的情况” . 因此这种情况是包含第一种状态的, 但由于所求属性为长度最大值, 这种冲突并不会影响最终结果

同理, 对于第三种状态, 可以表示为 $f_{i, j - 1}$

对于第四种状态, 可以表示为 $f_{i - 1, j - 1}$
因此, $f$ 的状态计算只需取后三种情况的最大值即可

模板

1
2
3
4
5
6
7
8
9
10
11
12
int n, m;
char a[N], b[N];
int f[N][N];

cin >> n >> m;
cin >> a + 1 >> b + 1;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j ++) {
f[i][j] = max(f[i][j - 1], f[i - 1][j]);
if (a[i] == b[j]) f[i][j] = max(f[i][j], f[i - 1][j - 1] + 1);
}
cout << f[n][m] << endl;

时间复杂度 $O(n^2)$

Other Articles
cover
栈和队列 | 学习笔记
  • 22/10/08
  • 14:16
  • 数据结构
Article table of contents TOP
  1. 1. 基础动态规划 $Dynamic\ Programming$
    1. 1.1. 主要思想
    2. 1.2. 状态转移方程的推导方式
      1. 1.2.1. 1. 顺推
      2. 1.2.2. 2. 逆推
    3. 1.3. 动态规划问题的时间复杂度分析
    4. 1.4. 背包问题:
      1. 1.4.1. 经典背包问题特点
      2. 1.4.2. $0/1$ 背包
      3. 1.4.3. 完全背包
        1. 1.4.3.1. 暴力
        2. 1.4.3.2. 一维优化:
      4. 1.4.4. 多重背包
        1. 1.4.4.1. 朴素 $O(nms)$
        2. 1.4.4.2. 多重背包的二进制拆分优化 $O(w \cdot n \cdot \log s)$:
        3. 1.4.4.3. 线性多重背包 $O(n)$
      5. 1.4.5. 分组背包
      6. 1.4.6. 依赖背包
      7. 1.4.7. 扩展: 输出背包方案
    5. 1.5. 线性动态规划
      1. 1.5.1. 1. 最长上升子序列
        1. 1.5.1.1. 暴力版本 $O(n^2)$
        2. 1.5.1.2. 单调优化版:$O(n \cdot logn)$
      2. 1.5.2. 2. 最长公共子序列
        1. 1.5.2.1. 集合
        2. 1.5.2.2. 属性
        3. 1.5.2.3. 集合划分
        4. 1.5.2.4. 状态表示
Please enter keywords to search