动态规划(Ⅰ)
前言
复健 DP 了。
参考资料:
-
《算法竞赛进阶指南》- 李煜东
-
OI-Wiki
动态规划是什么?动态规划把原问题视作若干个重叠子问题的逐层递进,每个子问题的求解过程都构成一个阶段。在完成前一个阶段的计算后,动态规划才会执行下一阶段的计算。
为了保证这些计算能够按顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响。这个条件也叫做无后效性。从图上来说,每个节点对应着一个状态,图上的边对应状态之间的转移,转移的选取就是动态规划中的决策。且动态规划对状态空间的遍历构成一张 DAG,遍历顺序就是该 DAG 的一个拓扑序。
在很多情况下,动态规划用于求解最优化问题。此时,下一阶段的最优解应该能够由前面各阶段子问题的最优解到处。这个条件被称为最优子结构性质。更广泛来说,动态规划在阶段计算完成时,只会在每个状态上保留与最终解集相关的部分代表信息,这些代表信息应该具有可重复的求解过程,并且能够导出后续阶段的代表信息。
状态,阶段和决策是构成动态规划算法的三要素,而子问题重叠性,无后效性和最优子结构性质是问题能用动态规划求解的三个基本条件。
动态规划算法把相同的计算过程作用于各阶段的同类子问题,就好像把一个固定的公式在格式相同的若干输入数据上运行。因此,我们一般只需要定义出 DP 的计算过程,就可以写代码了。这个计算过程就被称为状态转移方程。
动态规划的艺术在于状态的设计和子结构的挖掘。而动态规划的优化就在于两个部分:状态转移方程中的状态和转移,我们通过观察性质,优化状态空间,减去冗余信息;通过数据结构等,来加快转移的速度。
一般用 \(\color{black}{t}D / \color{black}{e}D\) 描述动态规划规模的类型,其中 \(t\) 表示问题大小,\(e\) 表示转移时依赖子问题的大小。即状态数有 \(n^t\) 个,每个状态依赖于 \(n^e\) 个前驱状态的信息。除非问题有特殊性质,解决 \(\color{black}{t}D / \color{black}{e}D\) 动态规划需要 \(\mathcal O(n^{t+e})\) 的时间复杂度。
-
\(1D / 1D\):最长上升子序列。
-
\(2D / 0D\):最长公共子序列,普通背包问题。
-
\(2D / 1D\):多源最短路 Floyd。
下面整理一下不同类型的 DP
线性 DP
最基本的动态规划,它的定义是具有线性阶段划分的动态规划算法统称为线性 DP。常见的 LIS,LCS,数字三角形等经典 DP 入门问题都属于线性 DP 的范畴。
在线性 DP 问题中,每个状态的求解直接构成一个阶段,这使得 DP 的状态表示自然就是阶段的表示。因此,我们只需要在每个维度上各取一个坐标值作为 DP 的状态,自然就可以描绘出“已求解部分”在状态空间中的轮廓特征,该轮廓的进展就是阶段的推移。
下面以几个例题讲一些常用的优化,最基本的我直接给出了,重点在于优化:
从决策集合的角度优化
给定两个数列,求两个数列的最长公共上升子序列
在此只考虑弱化版,不需要求一个可行解。
设 \(F[i,j]\) 表示 \(A_1\sim A_i\) 与 \(B_1\sim B_j\) 可以构成的以 \(B_j\) 为结尾的 LCIS 的长度,假设 \(A_0 = B_0 = -\infty\)(便于转移)。
-
当 \(A_i \not= B_j\) 时,有 \(F[i,j] = F[i-1,j]\);
-
当 \(A_i = B_j\) 时,有
\[F[i,j] = \max_{0\leq k <j,B_k<B_j}\{F[i-1,k]+1\} = \max_{0\leq k <j,B_k<A_i}\{F[i-1,k]+1\} \]
这样以后你就有了一个 \(\mathcal O(n^3)\) 的算法:
for(re int i=1;i<=n;i++)
{
for(re int j=1;j<=m;j++)
{
if(a[i] == b[j])
{
for(re int k=0;k<j;k++)
if(b[k] < b[j]) f[i][j] = min(f[i][j],f[i-1][k]+1);
}
else f[i][j] = f[i-1][j];
}
}
ans = INF;
for(re int i=1;i<=m;i++) ans = max(ans,f[n][i]);
在转移过程中,我们把 \(0 \leq k < j,B_k < A_i\) 的 \(k\) 构成的集合称为 \(F[i,j]\) 进行状态转移时的决策集合,记为 \(S(i,j)\)。注意到,在变量 \(j\) 进行循环递增的时候,第一层循环 \(i\) 是一个定值,这使得条件 \(B_k < A_i\) 是固定的。因此,当变量 \(j\) 增加 \(1\) 时,\(k\) 的取值范围从 \(0 \leq k < j\) 变为 \(0\leq k < j+1\),即 \(j\) 可能会进入新的决策集合。我们发现,决策集合里的元素个数是只增不减的,也就是说,对于一个新的 \(j\),我们只需要 \(O(1)\) 地检查条件 \(B_j < A_i\) 是否满足,已经在决策集合里的数一定不会被去除。
上面的状态转移便只需要双重循环,\(\mathcal O(n^2)\) 就能解决问题了。
a[0] = b[0] = -1;
for(re int i=1;i<=n;i++)
{
int val = f[i-1][0];
for(re int j=1;j<=m;j++)
{
if(a[i] == b[j]) f[i][j] = val + 1;
else f[i][j] = f[i-1][j];
if(b[j] < a[i]) if(val < f[i-1][j]) val = f[i-1][j];
}
}
这道题告诉我们,在实现状态转移方程时,要注意观察决策集合的范围随着状态的变化情况。对于“决策集合中的元素只增加不减少”的情景,就可以像这个题一样记录决策集合的当前信息,避免重复扫描,把转移的复杂度降低一个量级。
相似题目:P2893 [USACO08FEB] Making the Grade G
从状态表示的角度优化
考虑 \(F[i,x,y,z]\) 表示完成了 \(i\) 个请求后,三个员工分别处于 \(x,y,z\) 时,公司当前最小花费。
再考虑 \(F[i,x,y,z]\) 能够更新 \(i+1\) 阶段的哪些状态。转移显然有 \(3\) 种,就是派三个员工之一去第 \(i+1\) 个请求处:
然后这个题目还要求同一位置不能出现两个员工,所以还要判断一下转移的合法性。
可以看出,这个转移的复杂度是 \(\mathcal O(nl^3)\) 也就是 \(1000 \times 200^3\) 量级的,会超时,思考怎么优化。
仔细观察后发现,在第 \(i\) 个状态完成时,必定是某个员工到了这个位置 \(p_i\) 上,所以我们只需要知道阶段 \(i\) 和另外两个员工的位置信息即可描述出完整的状态空间,处于 \(p_i\) 的员工位置对 DP 来说是冗余信息。
因此,我们用 \(f[i,x,y]\) 表示完成了前 \(i\) 个请求,其中一个员工位于 \(p_i\),另外两个员工位于 \(x,y\) 时,公司的最小花费。三种转移就是分别让位于 \(p_i,x,y\) 的员工前往 \(p_{i+1}\) 处理请求。
设 \(p_0 = 3\),那么初值就可以设置为 \(F[0,1,2] = 0\),目标为 \(F[N,?,?]\),同理,我们需要判断转移的合法性。
可以看出,这样转移复杂度是 \(\mathcal O(nl^2)\),也就是 \(1000 \times 200^2\) 量级的,可以通过。
总结
通过上述两个例题,我们可以得到一些提示性的东西:
-
设计 DP 的状态转移方程时,不一定要以“如何计算出一个状态”的形式给出,也可以考虑“一个已知状态应该更新哪些后续阶段的未知状态”。前者称之为填表法,后者称之为刷表法。
-
求解线性 DP 问题,一般先确定阶段。若阶段不足以表示一个状态,则可以把所需的附加信息也作为状态的维度,如第二个题把三个员工的位置也加入到状态里。
在转移时,若总是从一个阶段转移到另一个阶段(如这两个题的从 \(i-1\) 到 \(i\)),则没有必要关心附加信息维度的大小变化情况,因为无后效性已经由阶段保证。
-
在实现状态转移方程时,要注意观察决策集合的范围随着状态的变化情况。对于“决策集合中的元素只增加不减少”的情景,就可以像这个题一样记录决策集合的当前信息,避免重复扫描,把转移的复杂度降低一个量级。(第一种优化)
-
在确定 DP 状态时,要选择最小的能够覆盖整个状态空间的维度集合。
若 DP 状态由多个维度构成,则应检查这些维度之间能否相互导出,用尽量少的维度覆盖整个状态空间,排除冗余维度,如第二个例题。
-
有时候,我们可以在状态空间中运用等效手法对状态进行缩放,来达到简化复杂度的目的。
背包
背包本质上是线性 DP,但因为它非常重要而特殊,所以单独拎出来了。
01 背包
最基本的背包问题,它的模型是这样的:
有 \(n\) 个物品和一个容量为 \(W\) 的背包,每个物品有重量 \(w_{i}\) 和价值 \(v_{i}\) 两种属性,要求选若干物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。
在上面这个模型中,由于每个物体只有取或者不取两种状态,对应二进制中的 \(0\) 和 \(1\),因此这类问题被称为 01 背包问题。
我们考虑这个问题的阶段,便是考虑到前 \(i\) 个物品放入背包的情况,但这不足以描述一个状态,于是我们把背包中已经放入的物品的总体积作为附加维度。
那也就是说,我们设 \(F[i,j]\) 表示从前 \(i\) 个物品中选出了总体积为 \(j\) 的物品放入背包,物品的最大价值和,那么就有
初值: \(F[0,0] = 0\),其余均为负无穷;目标:\(\displaystyle \max_{0 \leq j \leq W}\{F[n,j]\}\)。
memset(f , -0x3f , sizeof f);
f[0][0] = 0;
for(re int i=1;i<=n;i++)
{
for(re int j=0;j<=m;j++)
f[i][j] = f[i-1][j];
for(re int j=t[i];j<=m;j++)
f[i][j] = max(f[i][j],f[i-1][j-w[i]]+v[i]);
}
滚动数组优化
通过 DP 转移方程我们发现,每一阶段 \(i\) 的状态只由 \(i-1\) 转移而来,因此我们 \(f\) 数组其实第一维只需要两个变量,表示阶段 \(i\) 的状态和阶段 \(i-1\) 的状态即可。
for(re int i=1;i<=n;i++)
{
id ^= 1;
for(re int j=0;j<=m;j++)
f[id][j] = f[id^1][j];
for(re int j=t[i];j<=m;j++)
f[id][j] = max(f[id][j],f[id^1][j-w[i]]+v[i]);
}
这样,虽然时间复杂度还是 \(\mathcal O(nW)\) 的,但是空间复杂度从 \(\mathcal O(nW)\) 优化到了 \(\mathcal O(W)\)。
去掉第一维
在滚动数组的基础上,我们其实可以直接把第一维去掉。
容易发现,在每个阶段开始时,我们其实执行了一次从 \(F[i-1,?]\) 到 \(F[i,?]\) 的拷贝操作。这提示我们可以省略掉 \(F\) 数组的第一维,只用一维数组,即当外层循环循环到第 \(i\) 个物品时,\(F[j]\) 表示背包中放入总体积为 \(j\) 的物品的最大价值和。
f[0] = 0;
for(re int i=1;i<=n;i++)
for(re int j=m;j>=t[i];j--)//here
f[j] = max(f[j],f[j-w[i]]+v[i]);
需要特别注意的一点是,第二层是倒序循环的。因为循环到 \(j\) 时:
-
\(F[j\sim W]\),也就是数组的后半部分,处于第 \(i\) 个阶段,也就是已经考虑过放入第 \(i\) 个物品的情况;
-
\(F[0\sim j-1]\),也就是数组的后半部分,处于第 \(i-1\) 个阶段,也就是还没考虑过放入第 \(i\) 个物品的情况。
接下来 \(j\) 不断减小,我们可以保证它一定是从 第 \(i-1\) 个阶段转移到第 \(i\) 个阶段的,符合线性 DP 的原则,进而保证了第 \(i\) 个物品只会被放入背包一次。
但是,如果采用正向循环,假设 \(F[j]\) 被 \(F[j-v_i]+w_i\) 更新,接下来当 \(j\) 增大到 \(j+v_i\) 时,\(F[j+v_i]\) 有可能被 \(F[j]\) 更新。这也就是说,我用一个在第 \(i\) 个阶段里的状态,更新了另一个在第 \(i\) 个阶段里的状态,相当于第 \(i\) 个物品被用了两次,这显然是不对的。所以,必须采用倒序循环,才能满足 01 背包问题中每个物品是唯一的,只能放入背包一次的要求。
好题推荐
完全背包
完全背包的模型如下:
有 \(n\) 种物品和一个容量为 \(W\) 的背包,每种物品有重量 \(w_{i}\) 和价值 \(v_{i}\) 两种属性,并且每种物品有无数多个,要求选若干物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。
你会发现完全背包和 01 背包的差别就在于,完全背包中每个物品的数量是无限多个的,你可以选无数多个。类似的,我们还是设 \(F[i,j]\) 表示从前 \(i\) 个物品中选出了总体积为 \(j\) 的物品放入背包,物品的最大价值和,和 01 背包是一样的,但是状态转移方程有所不同:
有变化的就在于 \(F[i,j] = F[i,j-w_i]+v_i\) 这个转移,它是同阶段的转移,这也就代表着可以选无限次的思想。
类似于 01 背包的,我们也可以省略掉第一维,然后第二维采用正序循环,对应着每种物品可以选无限次,这也就是 01 背包里提及的错误转移,在完全背包里就是正确的。
f[0] = 0;
for(re int i=1;i<=n;i++)
for(re int j=w[i];j<=m;j++)
f[j] = max(f[j],f[j-w[i]]+v[i]);
多重背包
多重背包的问题模型如下:
有 \(n\) 种物品和一个容量为 \(W\) 的背包,每种物品有重量 \(w_{i}\) 和价值 \(v_{i}\) 两种属性,并且每种物品有 \(C_i\) 个,要求选若干物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量
直接拆分法
求解多重背包的问题的最直接方法就是把第 \(i\) 种物品看作独立的 \(C_i\) 个物品,转化为共有 \(\displaystyle\sum_{i=1}^nC_i\) 个物品的 01 背包进行计算,时间复杂度为 \(\displaystyle \mathcal O(W\sum_{i=1}^nC_i)\)。效率较低。
for(re int i=1;i<=n;i++)
for(re int j=1;j<=c[i];j++)
for(re int k=m;k>=w[i];j--)
f[j] = max(f[j],f[k-w[i]]+v[i]);
二进制拆分法
二进制拆分法运用到了 \(2\) 的次幂的性质:从 \(2^0,2^1,\dots,2^{k-1}\) 这 \(k\) 个 \(2\) 的整数次幂中选出若干个相加,可以表示出 \(0\sim 2^k-1\) 之间的任何整数。
根据这个性质,我们求出满足 \(2^0 + 2^1 + \dots + 2^p \leq C_i\) 的最大整数 \(p\),设 \(R_i = C_i - (2^0 + 2^1 + \dots + 2^p)\),那么我们可以知道的是:
-
根据 \(p\) 的最大性,有 \(2^0 + 2^1 + \dots + 2^{p+1} > C_i\),等式两边同时减去 \((2^0 + 2^1 + \dots + 2^p)\),我们可以得到 \(2^{p+1} > R_i\),因此从 \(2^0,2^1,\dots,2^p\) 中选出若干个相加,可以表示出 \(0\sim R_i\) 之间的任何整数;
-
从 \(2^0,2^1,\dots,2^p\) 以及 \(R_i\) 中选出,若干个相加,可以表示出 \(R_i\sim R_i + 2^{p+1}-1\) 之间的任何整数,而根据 \(R_i\) 的定义,\(R_i + 2^{p+1}-1 = C_i\),这也就是说,用 \(2^0,2^1,\dots,2^p\) 这 \(p+1\) 个数和 \(R_i\),能且仅能表示出 \(0\sim C_i\) 的任何数。
所以我们把数量为 \(C_i\) 的第 \(i\) 种物品拆成 \(p+2\) 个物品,它们的体积分别为:
这 \(p+2\) 个物品就能凑成 \(0\sim C_i\) 之间所有可能选取的结果。该方法仅把每种物品拆成了 \(\log C_i\) 个,效率较高,总复杂度就是 \(\displaystyle \mathcal O(W\sum_{i=1}^n\log C_i)\)。
写代码的时候我一般喜欢预处理二次幂和二次幂的和,大小视范围而定。
mi[0] = 1 , summi[0] = 1;
for(re int i=1;i<=15;i++) mi[i] = mi[i-1] * 2 , summi[i] = mi[i] + summi[i-1];
for(re int i=1;i<=n;i++)
for(re int j=15;j>=0;j--)
{
if(!C[i]) break;
if(C[i] >= summi[j])
{
for(re int k=j;k>=0;k--)
{
v[++cnt] = V[i] * mi[k];
w[cnt] = W[i] * mi[k];
C[i] -= mi[k];
}
if(C[i])
{
v[++cnt] = V[i] * C[i];
w[cnt] = W[i] * C[i];
C[i] = 0;
}
}
}
单调队列优化
使用单调队列优化多重背包,时间复杂度进一步降低到与 01 背包和完全背包相同的 \(\mathcal O(nW)\)。这个放到数据结构优化 DP 部分。
混合背包
混合背包就是把前面三种背包混合起来,有的只能取一次,有的能取 \(k\) 次,有的能取无限次。
这其实就只是个大杂烩,没啥创新的部分,把他们合并在一起就可以了,引用一下 OI-Wiki 上的伪代码
for (循环物品种类)
{
if (是 0 - 1 背包)
套用 0 - 1 背包代码;
else if (是完全背包)
套用完全背包代码;
else if (是多重背包)
套用多重背包代码;
}
事实上,01 背包可以和多重背包合并在一块。并且,虽然说完全背包能够用无限次,但是题目肯定会有一个使用次数的上界,你可以把完全背包中的物品个数设为这个上界,这样整个问题就转化成了多重背包问题。
例题:P1833 樱花
二维费用背包(多维背包)
二维背包就是在一维背包的基础上,再加上另一种价值,我们只需要在 \(F\) 数组上再加一维来表示即可。
如果朴素设的话,空间就是 \(\mathcal O(nW^2)\),要警惕 MLE,所以还是采用降一维的方式把第一维去掉使得空间变成 \(\mathcal O(W^2)\) 保险一点。
分组背包
分组背包的模型如下:
给定 \(n\) 组物品和一个容量为 \(W\) 的背包,每组物品有 \(C_i\) 个,第 \(i\) 组第 \(j\) 个物品的重量为 \(w_{i,j}\) 和价值 \(v_{i,j}\)。要求每组物品最多选一个,使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量
还是从线性 DP 的角度来考虑,我们发现它的阶段就是考虑到了第几组,那么很自然的就有 \(F[i,j]\) 表示考虑到了前 \(i\) 组物品,从中选取总体积为 \(j\) 的物品放入背包,物品的最大价值和,那么就有:
与前面背包模型类似的,我们可以省略掉第一维,用 \(j\) 的倒序循环来保证阶段 \(i\) 只能由阶段 \(i-1\) 转移而来。
for(re int i=1;i<=n;i++)
for(re int j=m;j>=0;j--)
for(re int k=0;k<=c[i];k++)
if(j-w[i][k] >= 0) f[j] = max(f[j],f[j-w[i][k]]+v[i][k]);
除了倒序循环之外,还需要注意的点枚举每组里物品的循环 \(k\) 应该放在循环 \(j\) 的内层。从背包的角度看,因为每组只能选一种物品,所以一个状态 \(F[j]\) 只能被一个物品更新;若交换循环次序,就会类似于多重背包,每组物品在 \(F\) 数组的转移会产生累计,最终可以选择超过 \(1\) 个物品。从动态规划的角度来看,\(i\) 是阶段,\(i\) 和 \(j\) 共同组成状态,\(k\) 是决策,有了状态才能决策;而若是多重背包,\(i,k\) 其实可以合并在一块,\(i,k,j\) 共同组成了状态。
好题推荐
有依赖的背包
金明有 \(n\) 元钱,想要买 \(m\) 个物品,第 \(i\) 件物品的价格为 \(v_i\),重要度为 \(p_i\)。有些物品是从属于某个主件物品的附件,要买这个物品,必须购买它的主件。目标是让所有购买的物品的 \(v_i \times p_i\) 之和最大。
考虑对于每一个主件和它的若干附件,从大面上来说就两种可能:买主件,买主件+一些附件。而且这几种可能性只能选一种,所以可以看成分组背包。
for(re int i=1;i<=cnt;i++)
for(re int j=m;j>=0;j--)
{
int sumw = g[i].w , sumv = g[i].v;
if(j >= sumw) f[j] = max(f[j],f[j-sumw]+sumv);
for(re int k=0;k<(int)attach[i].size();k++)
{
sumw += attach[i][k].w , sumv += attach[i][k].v;
if(j >= sumw) f[j] = max(f[j],f[j-sumw]+sumv);
}//枚举选第一个附件,第二个附件
if(attach[i].size() == 2)
{
sumw -= attach[i][0].w , sumv -= attach[i][0].v;
if(j >= sumw) f[j] = max(f[j],f[j-sumw]+sumv);//如果有第二个附件,不要漏了只选第二个附件的情况
}
}
区间 DP
区间 DP,本质上,还是一种线性 DP。回忆一下,线性 DP 是从初态开始,沿着阶段的扩张不断往规模较大的问题进行递推,直至计算出目标的状态。
区间 DP 也是一样的,它以区间长度作为 DP 的阶段,以两个坐标(区间的左右端点)描述每个状态。在区间 DP 种,一个状态是由若干个比它更小且被它包含的区间所代表的状态转移而来,你可以想成是一种从小区间到大区间的合并过程。因此区间 DP 往往能写出这样的状态来:
设 \(f[i,j]\) 表示将下标位置 \(i\) 到 \(j\) 的所有元素合并能获得的最大价值,那么
其中,\(cost\) 就表示将这两组元素合并起来的代价。最大值只是一种较为普遍的情况,根据题目,\(f[i,j]\) 可以是最小值、元素和等,但是核心的状态转移方程基本就是这样的。
性质
区间 DP 有这样的特点:
-
合并:可以将两个或多个部分进行整合,变成大区间,当然也能将大区间分割成若干个小区间;
-
特征:能将问题分解成两两合并的形式;
-
求解:对整个问题设最优值,枚举合并点,将问题分解成左右两个部分,最后合并两个部分的最优值得到原问题的最优值。
例题
从最经典的入手
设有 \(N(N \le 300)\) 堆石子排成一排,其编号为 \(1,2,3,\cdots,N\)。每堆石子有一定的质量 \(m_i\ (m_i \le 1000)\)。现在要将这 \(N\) 堆石子合并成为一堆。每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻。合并时由于选择的顺序不同,合并的总代价也不相同。试找出一种合理的方法,使总的代价最小,并输出最小代价。
如果最初的第 \(l\) 堆石子和第 \(r\) 堆石子被合并成一堆,则说明 \(l\sim r\) 中的每堆石子都已经被合并到一堆之中,这样,\(l\) 和 \(r\) 才可能合并在一块。我们发现,任意一堆石子都能用一个闭区间 \([l,r]\) 来描述,表示这对石子是由最初的第 \(l\sim r\) 堆石子合并而成的。那么,该按照什么顺序合并,便是我们需要考虑的问题。
我们可以发现,如果 \(l\sim r\) 被合并到了一堆,那么一定存在一个 \(k(l\leq k < r)\),表示在这堆石子形成之前,先有 \([l,k]\) 被合并成一堆,\([k+1,r]\) 被合并成一堆,再有 \([l,r]\) 合并成一堆。
我们发现这符合区间 DP 的性质,这个性质意味着两个长度较小的区间上的信息能够向一个更长的区间发生了转移,断点 \(k\) 就是转移的决策。我们自然可以把区间长度 \(len\) 作为 DP 的阶段。设 \(F[len,l,r]\) 表示长度为 \(len\),左右端点分别为 \(l,r\) 所需要的最小合并费用。但实际上 \(len = r-l+1\),也就是 \(len\) 能被 \(l,r\) 导出。本着之前提到的选择最小的能覆盖状态空间的维度集合的思想,我们只需要用 \(l,r\),就能表示一个状态。
设 \(F[l,r]\) 表示把最初的第 \(l\sim r\) 堆合并成一堆,需要消耗的最少体力,我们容易写出状态转移方程:
后面的这个求和我们可以前缀和预处理,那么最终式子就变成
考虑怎么转移,我们以 \(len\) 作为 DP 的阶段,所以要先枚举 \(len\),再枚举左端点,通过 \(len\) 和 \(l\) 确定 \(r\),再枚举断点 \(k\),这样总复杂度就是 \(O(n^3)\) 的。
初态:\(F[i,i] = 0\),其余均为正无穷。
终态:\(F[1,n]\)。
memset(f , 0x3f , sizeof f);
for(re int i=1;i<=n;i++) a[i] = read() , sum[i] = sum[i-1] + a[i] , f[i][i] = 0;
for(re int len=2;len<=n;len++)
{
for(re int l=1;l<=n-len+1;l++)
{
int r = l + len - 1;
for(re int k=l;k<r;k++)
f[l][r] = min(f[l][r],f[l][k]+f[k+1][r]);
f[l][r] += sum[r] - sum[l-1];
}
}
cout << f[1][n];
对于环的处理
考虑上面这个题的原版,也就是 \(n\) 堆石子是围成一个环的,也就是第一个和第 \(n\) 个最初就能合并在一起。
-
方法 \(1\):枚举断点。由于石子围城一个环,我们可以枚举分开的位置,将这个环转化成一个链,由于要枚举 \(n\) 次,复杂度 \(O(n^4)\)。
-
方法 \(2\):断环为链。我们将这条链延长两倍,其中第 \(i\) 堆和第 \(i+n\) 堆相同。最后,我们取 \(F[1,n],F[2,n+1],\dots,F[n,2n-1]\) 中的最优解,即为最优答案。这就相当于给了 \(n\) 和 \(1\) 连在一块的机会。时间复杂度 \(O(n^3)\)。在做题中,我们比较常用的是第二种方法。
for(re int len=2;len<=n;len++)
{
for(re int l=1;l<=2*n-len+1;l++)
{
int r = l + len - 1;
for(re int k=l;k<r;k++)
f[l][r] = min(f[l][r],f[l][k]+f[k+1][r]);
f[l][r] += sum[r] - sum[l-1];
}
}
ans = 1e9;
for(re int i=1;i<=n;i++) ans = min(ans,f[i][i+n-1]);
好题推荐
有一定难度。
树形 DP
树形 DP,即在树上进行的 DP。由于树固有的递归性质,所以树形 DP 一般都是递归进行的。
给定一棵由 \(n\) 个节点的树(通常是无根树,也就是用 \(n-1\) 条无向边恰好把这 \(n\) 个点连起来)。我们可以任选一个点为根节点,从而定义出每个节点的深度和每棵子树的根。
在树上设计 DP 时,一般就以节点从深到浅(子树从小到大)的顺序作为 DP 的阶段。DP 的状态表示中,第一维通常是节点编号(代表以该节点为根的子树)。大多数时候,我们采用递归的方式实现树形 DP。对于每个节点 \(x\),先递归,在它的每个子节点上进行 DP,在回溯时,从子节点向节点 \(x\) 进行状态转移。
基础
下面给道例题,讲一下树形 DP 的一般过程及思路。
某大学有 \(n\) 个职员,编号为 \(1\sim n\)。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 \(r_i\),但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。
根据上文写的,我们令节点编号(子树的根)作为 DP 状态的第一维。因为一名职员是否愿意参加只跟他的直接上司是否参加有关。因此我们在每棵子树递归完成时,保留两个“代表信息”:根节点参加时整棵子树的最大快乐指数和,以及根节点不参加时整棵子树的最大快乐指数和,就可以满足最优子结构性质了。
设 \(F[x,0/1]\) 表示以 \(x\) 为根的子树中邀请一部分职员参会,并且 \(x\) 参加/不参加舞会的最大快乐指数和。
- 考虑怎么对 \(F[x,0]\) 进行转移:当 \(x\) 不参加舞会是,它的直接下属就可以选择参会,也可以不参会,所以
其中 \(Son(x)\) 就代表 \(x\) 的子节点。
- 在考虑对 \(F[x,1]\) 转移:因为 \(x\) 参加舞会了,它的直接下属,也就是子节点都不能参会,因此
然后这个题因为是一个有根树,所以要先找到根节点 \(root\),最后,\(\max(F[root,0],F[root,1])\) 便是答案。时间复杂度仅是遍历树的 \(\mathcal O(n)\)。
il void dfs(int x)
{
for(re auto y : g[x])
{
dfs(y);
f[x][0] += max(f[y][0],f[y][1]);
f[x][1] += f[y][0];
}
}
signed main()
{
n = read();
for(re int i=1;i<=n;i++) f[i][1] = read();
for(re int i=1;i<=n-1;i++)
{
k = read() , fa = read();
g[fa].push_back(k);
idu[k]++;
}
for(re int i=1;i<=n;i++) if(!idu[i]) { root = i; break; }
dfs(root);
cout << max(f[root][1],f[root][0]);
return 0;
}
树上背包
树上背包问题,又称有树形以来的背包问题,实质上是分组背包和树形 DP 的结合。
需要提前说的一点是,树上背包的状态设计一般是三维的,\(f[u][k][j]\) 表示以 \(u\) 为根的子树中,考虑到了前 \(k\) 个子树,有 \(j\) 个符合要求的什么什么。其中第二维是很关键的,但是因为它可以被类似于背包降维一样的被消掉,所以可能有些地方直接考虑二维的比较难想,这也是一直困扰我的一个点,做了几个题后才有了感觉。
这个点其实在紫书中没有提及,但是在 OI-Wiki 上是有的,这是好的/qiang。
例题
现在有 \(n\) 门课程,第 \(i\) 门课程的学分为 \(a_i\),每门课程有零门或一门先修课,有先修课的课程需要先学完其先修课,才能学习该课程。一位学生要学习 \(m\) 门课程,求其能获得的最多学分数。\(n,m\leq 300\)
每门课最多只有一门先修课的这个特点,与有根树种一个点最多只有一个父亲节点的特点类似。
那我们可以根据这个建树,最终就是一个森林的形式。题目中每棵树的根节点的父亲节点是 \(0\),我们可以建立一个虚点 \(0\),它的学分为 \(0\),作为所有无先修课程的先修课,这样,一个森林就变成了一棵以 \(0\) 号课程为根的树。
我们设 \(F[u,i,j]\) 表示以 \(u\) 号点为根的子树中,已经遍历了 \(u\) 号点的前 \(i\) 棵子树,选了 \(j\) 门课程的最大学分。
转移的过程用到树形 DP 和背包 DP 的思想,我们枚举 \(u\) 点的每个子节点 \(v\),同时枚举以 \(v\) 为根的子树选了几门课程,将子树的结果合并到 \(u\) 上。
记点 \(u\) 的儿子个数为 \(s_u\),以 \(u\) 为根的子树大小为 \(siz_u\),可以写出以下的转移方程:
注意一下上面给出的限制条件,然后采用背包常用的倒序循环来省略掉第二个维度。
需要特别注意的一点是,树形背包的复杂度是 \(\mathcal O(nm)\) 而不是我们可能会认为的 \(O(nm^2)\),即使是三层枚举。具体的证明可以见这篇博客。
il void dfs(int x)
{
for(re auto y : G[x])
{
dfs(y);
for(re int j=m;j>=0;j--)//选当前组的时候,f[x][j-k]的最优解是在不考虑这组的情况下得出的,所以可以相加
for(re int k=0;k<j;k++)//因为x必须选,所以不能全从y里选j门
f[x][j] = max(f[x][j],f[y][k]+f[x][j-k]);
}
}
signed main()
{
n = read() , m = read() + 1;
for(re int i=1;i<=n;i++)
{
fa = read() , f[i][1] = read();
G[fa].push_back(i);
}
dfs(0);
cout << f[0][m];
return 0;
}
类似的题目还有 P1273 有线电视网、P1272 重建道路,我们可以按照上述的思路,从三维开始,寻找状态转移方程,再降维处理。
换根法
换根法,又称二次扫描,通常会不指定根节点,并且根节点的变化会对一些值,例如子节点的深度和、点权和产生影响。对于这种问题,我们通常用两次 dfs 来解决问题,从而将 \(\mathcal O(n^2)\) 的暴力枚举根的复杂度降为 \(\mathcal O(n)\)。
-
第一次扫描,任选一个点为根,在有根树上执行一次树形 DP,也就是在回溯时发生的,自底向上的转移,这一次 DFS 是用来预处理诸如深度、点权和之类的信息。
-
第二次扫描,开始运行我们的换根 DP,我们从刚才选出的一个根出发,对整棵树执行一次 dfs,在每次递归前进行自顶向下的推导,计算出换根后的解。
例题
给定一个 \(n\) 个点的树,请求出一个结点,使得以这个结点为根时,所有结点的深度之和最大。
一个结点的深度之定义为该节点到根的简单路径上边的数量。
\(n \leq 10^6\)。
你可以发现,这个复杂度,\(\mathcal O(n^2)\) 的朴素枚举根的树形 DP 显然会 T 掉。但是我们可以借鉴它的思想,来进行第一次扫描。
首先,我们随便给定一个根 \(root\),然后设 \(siz_u\) 表示以 \(u\) 为根的子树的节点个数,显然我们有状态转移方程 \(\displaystyle siz_u = 1 + \sum_{v\in Son(x)} siz_v\)。同时,我们设 \(sum_u\) 表示以 \(root\) 为根的情况下,以 \(u\) 为根的子树的深度和,那么显然也有 \(\displaystyle sum_u = dep_u + \sum_{v\in Son(u)} sum_v\),其中 \(dep_u\) 表示 \(u\) 的深度,我们可以看出,这两个数组我们都可以通过一次树形 DP 求出。
之后就是第二次扫描了,这次扫描是全局的,也就是用来求解答案的一次扫描。设 \(f_u\) 表示以 \(u\) 为根的情况下,这棵树的深度和,那么显然有 \(f_{root} = sum_{root}\),我们考虑怎么转移。
需要注意的是,下文的换根都是在以 \(root\) 为根的初始形态下考虑的。
对于 \(v\in Son(u)\),我们可以进行换根操作。显然以 \(u\) 为根转移到以 \(v\) 为根后,每个节点的深度都会产生改变,具体的表现为:
-
所有在 \(v\) 的子树上的节点深度都减少 \(1\),那么总深度和就减少了 \(siz_v\);
-
所有不在 \(v\) 的子树上的节点深度都增加 \(1\),那么总深度和就增加了 \(n-siz_v\)。
通过这两个条件,我们就可以推出状态转移方程了。
于是,我们在第二次 dfs 时,遍历整棵树并状态转移,这样就能求出以每个节点为根的深度和了。最后只要遍历一次所有根节点深度和就可以求出答案,时间复杂度 \(\mathcal O(n)\)。
il void dfs1(int x,int fa)
{
siz[x] = 1 , sum[x] = dep[x] = dep[fa] + 1;
for(re auto y : G[x])
{
if(y == fa) continue;
dfs1(y,x);
siz[x] += siz[y] , sum[x] += sum[y];
}
}
il void dfs2(int x,int fa)
{
for(re auto y : G[x])
{
if(y == fa) continue;
f[y] = f[x] - siz[y] + n - siz[y];
dfs2(y,x);
}
}
signed main()
{
n = read();
for(re int i=1;i<n;i++)
{
u = read() , v = read();
G[u].push_back(v) , G[v].push_back(u);
}
dep[0] = -1;
dfs1(root,0);
f[root] = sum[root];
dfs2(root,0);
for(re int i=1;i<=n;i++) if(ans < f[i]) ans = f[i] , id = i;
cout << id;
return 0;
}
P2986 [USACO10MAR] Great Cow Gathering G
、AcWing 287. 积蓄程度也是比较好的换根 DP,并且后者需要一定的特判,需要注意。
可以看出,换根 DP 的关键在第二次扫描,我们一般会把问题拆分成两个部分考虑:\(v\) 的子树和除了 \(v\) 的子树的别的部分,然后再辅以我们通过第一次 DFS 得出的一些数组,来进行转移。