暑假第三周笔记
第三周总结
本周主要学习了动态规划,下面来分几个板块逐一介绍
Part $1$ 背包$dp$
引入
在讲何为背包 $dp$ 之前,我们可以想想如下这个问题:
有一个容量为$W$的背包,$n$个物品,第$i$个物品的体积为$vi$,价值为$wi$,求怎样安排装的物品且不超过背包体积的情况下使所有选中的物品的价值最大?
在上述例题中,由于每个物体只有两种可能的状态(取与不取),对应二进制中的 $0$ 和 $1$ ,这类问题便被称为「0-1 背包问题」。
$01$ 背包
解释
例题中已知条件有第 $i$ 个物品的重量 $w_{i}$,价值 $v_{i}$,以及背包的总容量 $W$。
设 DP 状态 $f_{i,j}$ 为在只能放前 $i$ 个物品的情况下,容量为 $j$ 的背包所能达到的最大总价值。
考虑转移。假设当前已经处理好了前 $i-1$ 个物品的所有状态,那么对于第 $i$ 个物品,当其不放入背包时,背包的剩余容量不变,背包中物品的总价值也不变,故这种情况的最大价值为 $f_{i-1,j}$;当其放入背包时,背包的剩余容量会减小 $w_{i}$,背包中物品的总价值会增大 $v_{i}$,故这种情况的最大价值为 $f_{i-1,j-w_{i}}+v_{i}$。
由此可以得出状态转移方程:
$$ f_{i,j}=\max(f_{i-1,j},f_{i-1,j-w_{i}}+v_{i}) $$
这里如果直接采用二维数组对状态进行记录,会出现 $MLE$。可以考虑改用滚动数组的形式来优化。
因为对 $f_i$ 有影响的只有 $f_{i-1}$,可以去掉第一维,直接用 $f_{i}$ 来表示处理到当前物品时背包容量为 $i$ 的最大价值,得出以下方程:
$$ f_j=\max \left(f_j,f_{j-w_i}+v_i\right) $$
务必牢记并理解这个转移方程,因为大部分背包问题的转移方程都是在此基础上推导出来的。
实现
还有一点需要注意的是,很容易写出这样的 错误核心代码:
for (int i = 1; i <= n; i++)
for (int l = 0; l <= W - w[i]; l++)
f[l + w[i]] = max(f[l] + v[i], f[l + w[i]]);
// 由 f[i][l + w[i]] = max(max(f[i - 1][l + w[i]], f[i - 1][l] + w[i]),
// f[i][l + w[i]]); 简化而来
这段代码哪里错了呢?枚举顺序错了。
仔细观察代码可以发现:对于当前处理的物品 $i$ 和当前状态 $f_{i,j}$,在 $j\geqslant w_{i}$ 时,$f_{i,j}$ 是会被 $f_{i,j-w_{i}}$ 所影响的。这就相当于物品 $i$ 可以多次被放入背包,与题意不符。(事实上,这正是完全背包问题的解法)
为了避免这种情况发生,我们可以改变枚举的顺序,从 $W$ 枚举到 $w_{i}$,这样就不会出现上述的错误,因为 $f_{i,j}$ 总是在 $f_{i,j-w_{i}}$ 前被更新。
因此实际核心代码为
for (int i = 1; i <= n; i++)
for (int l = W; l >= w[i]; l--)
f[l] = max(f[l], f[l - w[i]] + v[i]);
完全背包
解释
完全背包模型与 0-1 背包类似,与 0-1 背包的区别仅在于一个物品可以选取无限次,而非仅能选取一次。
我们可以借鉴 0-1 背包的思路,进行状态定义:设 $f_{i,j}$ 为只能选前 $i$ 个物品时,容量为 $j$ 的背包可以达到的最大价值。
需要注意的是,虽然定义与 0-1 背包类似,但是其状态转移方程与 0-1 背包并不相同。
过程
可以考虑一个朴素的做法:对于第 $i$ 件物品,枚举其选了多少个来转移。这样做的时间复杂度是 $O(n^3)$ 的。
状态转移方程如下:
$$ f_{i,j}=\max_{k=0}^{+\infty}(f_{i-1,j-k\times w_i}+v_i\times k) $$
考虑做一个简单的优化。可以发现,对于 $f_{i,j}$,只要通过 $f_{i,j-w_i}$ 转移就可以了。因此状态转移方程为:
$$ f_{i,j}=\max(f_{i-1,j},f_{i,j-w_i}+v_i) $$
理由是当我们这样转移时,$f_{i,j-w_i}$ 已经由 $f_{i,j-2\times w_i}$ 更新过,那么 $f_{i,j-w_i}$ 就是充分考虑了第 $i$ 件物品所选次数后得到的最优结果。换言之,我们通过局部最优子结构的性质重复使用了之前的枚举过程,优化了枚举的复杂度。
与 0-1 背包相同,我们可以将第一维去掉来优化空间复杂度。如果理解了 0-1 背包的优化方式,就不难明白压缩后的循环是正向的(也就是上文中提到的错误优化)。
多重背包
多重背包也是 0-1 背包的一个变式。与 0-1 背包的区别在于每种物品有 $k_i$ 个,而非一个。
一个很朴素的想法就是:把「每种物品选 $k_i$ 次」等价转换为「有 $k_i$ 个相同的物品,每个物品选一次」。这样就转换成了一个 0-1 背包模型,套用上文所述的方法就可已解决。状态转移方程如下:
$$ f_{i,j}=\max_{k=0}^{k_i}(f_{i-1,j-k\times w_i}+v_i\times k) $$
时间复杂度 $O(W\sum_{i=1}^nk_i)$。
二进制分组优化
考虑优化。我们仍考虑把多重背包转化成 0-1 背包模型来求解。
解释
显然,复杂度中的 $O(nW)$ 部分无法再优化了,我们只能从 $O(\sum k_i)$ 处入手。为了表述方便,我们用 $A_{i,j}$ 代表第 $i$ 种物品拆分出的第 $j$ 个物品。
在朴素的做法中,$\forall j\le k_i$,$A_{i,j}$ 均表示相同物品。那么我们效率低的原因主要在于我们进行了大量重复性的工作。举例来说,我们考虑了「同时选 $A_{i,1},A_{i,2}$」与「同时选 $A_{i,2},A_{i,3}$」这两个完全等效的情况。这样的重复性工作我们进行了许多次。那么优化拆分方式就成为了解决问题的突破口。
过程
我们可以通过「二进制分组」的方式使拆分方式更加优美。
具体地说就是令 $A_{i,j}\left(j\in\left[0,\lfloor \log_2(k_i+1)\rfloor-1\right]\right)$ 分别表示由 $2^{j}$ 个单个物品「捆绑」而成的大物品。特殊地,若 $k_i+1$ 不是 $2$ 的整数次幂,则需要在最后添加一个由 $k_i-2^{\lfloor \log_2(k_i+1)\rfloor-1}$ 个单个物品「捆绑」而成的大物品用于补足。
举几个例子:
- $6=1+2+3$
- $8=1+2+4+1$
- $18=1+2+4+8+3$
- $31=1+2+4+8+16$
显然,通过上述拆分方式,可以表示任意 $\le k_i$ 个物品的等效选择方式。将每种物品按照上述方式拆分后,使用 0-1 背包的方法解决即可。
时间复杂度 $O(W\sum_{i=1}^n\log_2k_i)$
实现
index = 0;
for (int i = 1; i <= m; i++) {
int c = 1, p, h, k;
cin >> p >> h >> k;
while (k > c) {
k -= c;
list[++index].w = c * p;
list[index].v = c * h;
c *= 2;
}
list[++index].w = p * k;
list[index].v = h * k;
}
混合背包
混合背包就是将前面三种的背包问题混合起来,有的只能取一次,有的能取无限次,有的只能取 $k$ 次。
这种题目看起来很吓人,可是只要领悟了前面几种背包的中心思想,并将其合并在一起就可以了。下面给出伪代码:
过程
for (循环物品种类) {
if (是 0 - 1 背包)
套用 0 - 1 背包代码;
else if (是完全背包)
套用完全背包代码;
else if (是多重背包)
套用多重背包代码;
}
二维费用背包
例题:
有 $n$ 个任务需要完成,完成第 $i$ 个任务需要花费 $t_i$ 分钟,产生 $c_i$ 元的开支,现在有 $T$ 分钟时间,$W$ 元钱来处理这些任务,求最多能完成多少任务。
这道题是很明显的 0-1 背包问题,可是不同的是选一个物品会消耗两种价值(经费、时间),只需在状态中增加一维存放第二种价值即可。
这时候就要注意,再开一维存放物品编号就不合适了,因为容易 $MLE$。
分组背包
例题: 有 $n$ 件物品和一个大小为 $m$ 的背包,第 $i$ 个物品的价值为 $w_i$,体积为 $v_i$。同时,每个物品属于一个组,同组内最多只能选择一个物品。求背包能装载物品的最大总价值。
这种题怎么想呢?其实是从「在所有物品中选择一件」变成了「从当前组中选择一件」,于是就对每一组进行一次 0-1 背包就可以了。
再说一说如何进行存储。我们可以将 $t_{k,i}$ 表示第 $k$ 组的第 $i$ 件物品的编号是多少,再用 $\mathit{cnt}_k$ 表示第 $k$ 组物品有多少个。
实现
for (int k = 1; k <= ts; k++)
for (int i = m; i >= 0; i--)
for (int j = 1; j <= cnt[k]; j++)
if (i >= w[t[k][j]])
dp[i] = max(dp[i], dp[i - w[t[k][j]]] + c[t[k][j]]); // 像0-1背包一样状态转移
这里要注意:一定不能搞错循环顺序,这样才能保证正确性。
杂项
小优化
根据贪心原理,当费用相同时,只需保留价值最高的;当价值一定时,只需保留费用最低的;当有两件物品 $i,j$ 且 $i$ 的价值大于 $j$ 的价值并且 $i$ 的费用小于 $j$ 的费用时,只需保留 $i$。
背包问题变种
求方案数
对于给定的一个背包容量、物品费用、其他关系等的问题,求装到一定容量的方案总数。
这种问题就是把求最大值换成求和即可。
例如 0-1 背包问题的转移方程就变成了:
$$ \mathit{dp}_i=\sum(\mathit{dp}_i,\mathit{dp}_{i-c_i}) $$
初始条件:$\mathit{dp}_0=1$
因为当容量为 $0$ 时也有一个方案,即什么都不装。
求最优方案总数
要求最优方案总数,我们要对 0-1 背包里的 $\mathit{dp}$ 数组的定义稍作修改,DP 状态 $f_{i,j}$ 为在只能放前 $i$ 个物品的情况下,容量为 $j$ 的背包「正好装满」所能达到的最大总价值。
这样修改之后,每一种 DP 状态都可以用一个 $g_{i,j}$ 来表示方案数。
$f_{i,j}$ 表示只考虑前 $i$ 个物品时背包体积「正好」是 $j$ 时的最大价值。
$g_{i,j}$ 表示只考虑前 $i$ 个物品时背包体积「正好」是 $j$ 时的方案数。
转移方程:
如果 $f_{i,j} = f_{i-1,j}$ 且 $f_{i,j} \neq f_{i-1,j-v}+w$ 说明我们此时不选择把物品放入背包更优,方案数由 $g_{i-1,j}$ 转移过来,
如果 $f_{i,j} \neq f_{i-1,j}$ 且 $f_{i,j} = f_{i-1,j-v}+w$ 说明我们此时选择把物品放入背包更优,方案数由 $g_{i-1,j-v}$ 转移过来,
如果 $f_{i,j} = f_{i-1,j}$ 且 $f_{i,j} = f_{i-1,j-v}+w$ 说明放入或不放入都能取得最优解,方案数由 $g_{i-1,j}$ 和 $g_{i-1,j-v}$ 转移过来。
背包的第 k 优解
普通的 0-1 背包是要求最优解,在普通的背包 DP 方法上稍作改动,增加一维用于记录当前状态下的前 k 优解,即可得到求 0-1 背包第 $k$ 优解的算法。 具体来讲:$\mathit{dp_{i,j,k}}$ 记录了前 $i$ 个物品中,选择的物品总体积为 $j$ 时,能够得到的第 $k$ 大的价值和。这个状态可以理解为将普通 0-1 背包只用记录一个数据的 $\mathit{dp_{i,j}}$ 扩展为记录一个有序的优解序列。转移时,普通背包最优解的求法是 $\mathit{dp_{i,j}}=\max(\mathit{dp_{i-1,j}},\mathit{dp_{i-1,j-v_{i}}}+w_{i})$,现在我们则是要合并 $\mathit{dp_{i-1,j}}$,$\mathit{dp_{i-1,j-v_{i}}}+w_{i}$ 这两个大小为 $k$ 的递减序列,并保留合并后前 $k$ 大的价值记在 $\mathit{dp_{i,j}}$ 里,这一步利用双指针法,复杂度是 $O(k)$ 的,整体时间复杂度为 $O(nmk)$。空间上,此方法与普通背包一样可以压缩掉第一维,复杂度是 $O(mk)$ 的。
Part$2$ 区间$dp$
定义
区间类动态规划是线性动态规划的扩展,它在分阶段地划分问题时,与阶段中元素出现的顺序和由前一阶段的哪些元素合并而来有很大的关系。
令状态 $f(i,j)$ 表示将下标位置 $i$ 到 $j$ 的所有元素合并能获得的价值的最大值,那么 $f(i,j)=\max\{f(i,k)+f(k+1,j)+cost\}$,$cost$ 为将这两组元素合并起来的代价。
性质
区间 DP 有以下特点:
合并:即将两个或多个部分进行整合,当然也可以反过来;
特征:能将问题分解为能两两合并的形式;
求解:对整个问题设最优值,枚举合并点,将问题分解为左右两个部分,最后合并两个部分的最优值得到原问题的最优值。
解释
例题
石子合并 题目大意:在一个环上有 $n$ 个数 $a_1,a_2,\dots,a_n$,进行 $n-1$ 次合并操作,每次操作将相邻的两堆合并成一堆,能获得新的一堆中的石子数量的和的得分。你需要最大化你的得分。
需要考虑不在环上,而在一条链上的情况。
令 $f(i,j)$ 表示将区间 $[i,j]$ 内的所有石子合并到一起的最大得分。
写出 状态转移方程:$f(i,j)=\max\{f(i,k)+f(k+1,j)+\sum_{t=i}^{j} a_t \}~(i\le k<j)$
令 $sum_i$ 表示 $a$ 数组的前缀和,状态转移方程变形为 $f(i,j)=\max\{f(i,k)+f(k+1,j)+sum_j-sum_{i-1} \}$。
怎样进行状态转移
由于计算 $f(i,j)$ 的值时需要知道所有 $f(i,k)$ 和 $f(k+1,j)$ 的值,而这两个中包含的元素的数量都小于 $f(i,j)$,所以我们以 $len=j-i+1$ 作为 DP 的阶段。首先从小到大枚举 $len$,然后枚举 $i$ 的值,根据 $len$ 和 $i$ 用公式计算出 $j$ 的值,然后枚举 $k$,时间复杂度为 $O(n^3)$
怎样处理环
题目中石子围成一个环,而不是一条链,怎么办呢?
方法一:由于石子围成一个环,我们可以枚举分开的位置,将这个环转化成一个链,由于要枚举 $n$ 次,最终的时间复杂度为 $O(n^4)$。
方法二:我们将这条链延长两倍,变成 $2\times n$ 堆,其中第 $i$ 堆与第 $n+i$ 堆相同,用动态规划求解后,取 $f(1,n),f(2,n+1),\dots,f(n-1,2n-2)$ 中的最优值,即为最后的答案。时间复杂度 $O(n^3)$。
实现
for (len = 1; len <= n; len++)
for (i = 1; i <= 2 * n - 1; i++) {
int j = len + i - 1;
for (k = i; k < j && k <= 2 * n - 1; k++)
f[i][j] = max(f[i][j], f[i][k] + f[k + 1][j] + sum[j] - sum[i - 1]);
}
Part$3$ 状压 $dp$
定义
状压 $DP$ 是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的。
例题
在 $N\times N$ 的棋盘里面放 $K$ 个国王($1 \leq N \leq 9, 1 \leq K \leq N \times N$),使他们互不攻击,共有多少种摆放方案?(国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共 $8$ 个格子)。
解释
设 $f(i,j,l)$ 表示前 $i$ 行,第 $i$ 行的状态为 $j$,且棋盘上已经放置 $l$ 个国王时的合法方案数。
对于编号为 $j$ 的状态,我们用二进制整数 $sit(j)$ 表示国王的放置情况,$sit(j)$ 的某个二进制位为 $0$ 表示对应位置不放国王,为 $1$ 表示在对应位置上放置国王;用 $sta(j)$ 表示该状态的国王个数,即二进制数 $sit(j)$ 中 $1$ 的个数。例如,如下图所示的状态可用二进制数 $100101$ 来表示(棋盘左边对应二进制低位),则有 $sit(j)=100101_{(2)}=37, sta(j)=3$。
设当前行的状态为 $j$,上一行的状态为 $x$,可以得到下面的状态转移方程:$f(i,j,l) = \sum f(i-1,x,l-sta(j))$。
设上一行的状态编号为 $x$,在保证当前行和上一行不冲突的前提下,枚举所有可能的 $x$ 进行转移,转移方程:
$$ f(i,j,l) = \sum f(i-1,x,l-sta(j)) $$
Part $4$ $LCS$和$LIS$问题
$1$.最长公共子序列($Longest$ $Common$ $Subsequence,LCS$)
给定一个长度为 $n$ 的序列 $A$ 和一个 长度为 $m$ 的序列 $B$($n,m \leq 5000$),求出一个最长的序列,使得该序列既是 $A$ 的子序列,也是 $B$ 的子序列。
子序列的定义可以参考 子序列。一个简要的例子:字符串 abcde
与字符串 acde
的公共子序列有 a
、c
、d
、e
、ac
、ad
、ae
、cd
、ce
、de
、ade
、ace
、cde
、acde
,最长公共子序列的长度是 4。
设 $f(i,j)$ 表示只考虑 $A$ 的前 $i$ 个元素,$B$ 的前 $j$ 个元素时的最长公共子序列的长度,求这时的最长公共子序列的长度就是 子问题。$f(i,j)$ 就是我们所说的 状态,则 $f(n,m)$ 是最终要达到的状态,即为所求结果。
对于每个 $f(i,j)$,存在三种决策:如果 $A_i=B_j$,则可以将它接到公共子序列的末尾;另外两种决策分别是跳过 $A_i$ 或者 $B_j$。状态转移方程如下:
$$ f(i,j)=\begin{cases}f(i-1,j-1)+1&A_i=B_j\\\max(f(i-1,j),f(i,j-1))&A_i\ne B_j\end{cases} $$
该做法的时间复杂度为 $O(nm)$。
int a[MAXN], b[MAXM], f[MAXN][MAXM];
int dp() {
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (a[i] == b[j])
f[i][j] = f[i - 1][j - 1] + 1;
else
f[i][j] = std::max(f[i - 1][j], f[i][j - 1]);
return f[n][m];
}
$2$.最长不下降子序列($Longest$ $Increasing$ $Subsequence,LIS$)
给定一个长度为 $n$ 的序列 $A$($n \leq 5000$),求出一个最长的 $A$ 的子序列,满足该子序列的后一个元素不小于前一个元素。
解法
设 $f(i)$ 表示以 $A_i$ 为结尾的最长不下降子序列的长度,则所求为 $\max_{1 \leq i \leq n} f(i)$。
计算 $f(i)$ 时,尝试将 $A_i$ 接到其他的最长不下降子序列后面,以更新答案。于是可以写出这样的状态转移方程:$f(i)=\max_{1 \leq j < i, A_j \leq A_i} (f(j)+1)$。
容易发现该算法的时间复杂度为 $O(n^2)$。
int a[MAXN], d[MAXN];
int dp() {
d[1] = 1;
int ans = 1;
for (int i = 2; i <= n; i++) {
d[i] = 1;
for (int j = 1; j < i; j++)
if (a[j] <= a[i]) {
d[i] = max(d[i], d[j] + 1);
ans = max(ans, d[i]);
}
}
return ans;
}