闫式dp法
但是也没有每一道题都可以硬套,只能说动态规划的题都很灵活
最基本的模型,可以参考著名博客背包九讲
在具体代码中,还需要考虑初始化的问题
#include
using namespace std;
const int N = 1010;
int n,m;
int v[N],w[N];
int dp[N][N];
int main(){
cin >> n >> m;
for(int i = 1;i <= n;i++) cin >> v[i] >> w[i];
//dp[0][0~m] = 0,但是静态变量自动是0
for(int i = 1;i <=n ; i++){
for(int j = 0;j <= m;j++){
dp[i][j] = dp[i-1][j];//这是因为不选第i个物品的方案一定存在
if(j >= v[i]) dp[i][j] = max(dp[i][j],dp[i-1][j-v[i]]+w[i]);//只有当前背包能放下第i个物品时,能用第i个物品进行更新
}
}
cout << dp[n][m] << endl;
}
这是注意到 d p [ i ] [ . . . ] dp[i][...] dp[i][...]只由 d p [ i − 1 ] [ . . . ] dp[i-1][...] dp[i−1][...]更新来,那i-1之前那么多数,存着也是没用。
那直接把这一维在空间中删去,我将其理解为“把第一维从空间的序列转化到时间的序列”
for(int i = 1;i <=n ; i++){
for(int j = 0;j <= m;j++){
dp[j] = dp[j];//这是因为不选第i个物品的方案一定存在
if(j >= v[i]) dp[j] = max(dp[j],dp[j-v[i]]+w[i]);//只有当前背包能放下第i个物品时,能用第i个物品进行更新
}
}
好像还能删点,dp[j] = dp[j]是废话,下面的if语句可以和for合并
for(int i = 1;i <=n ; i++)//变成一个记录时间先后的维了
for(int j = v[i];j <= m;j++)//直接从vi开始枚举体积
dp[j] = max(dp[j],dp[j-v[i]]+w[i]);
现在检查一下,这样做对吗。很遗憾的是,把代码跑一遍,发现答案不对。我呢提出在哪?
我个人的理解是,滚动数组的思想是 用旧数据更新 新数据,新数据再覆盖旧数据
问题就出在,由于 j j j从小到大进行枚举,所以在计算到 d p [ j ] dp[j] dp[j]时, d p [ j − v [ i ] ] dp[j-v[i]] dp[j−v[i]]是已经被更新过的“新数据”。但我们想要的并不是用新数据来更新 .
所以解决方法也很简单,那就是要更新 d p [ i ] [ j ] dp[i][j] dp[i][j]时,让比 j j j小的那些数还没被更新过就可以了,具体来说就是我们从大到小枚举 j j j
for(int i = 1;i <=n ; i++)//变成一个记录时间先后的维了
for(int j = m;j >= v[i];j--)//从大到小枚举体积
dp[j] = max(dp[j],dp[j-v[i]]+w[i]);
还有一种向一维进行优化的就是使用“滚动数组”,其实出发点是一样的,既然我们每次只是使用上一次的运算结果来更新这一次,那不妨就只开一个二维的数组 d p [ 2 ] [ m ] dp[2][m] dp[2][m],其中一维部分是一个循环数组。
for(int i = 1;i <=n ; i++){
for(int j = 0;j <= m;j++){
dp[i%2][j] = dp[(i-1)%2][j];
if(j >= v[i]) dp[i%2][j] = max(dp[i%2][j],dp[(i-1)%2][j-v[i]]+w[i]);
}
}
cout << dp[n%2][m] << endl;
同样的,这启发我们只要是递推时,第 i i i次只跟 i − k , i − k + 1 , . . . , i − 1 i-k,i-k+1,...,i-1 i−k,i−k+1,...,i−1次相关,就只需要开一个大小为 k + 1 k+1 k+1的循环数组
直接查看博客
基本上没有任何差别
初始化同01背包
这是个朴素的做法,需要三重循环
#include
using namespace std;
const int N = 1010;
int n,m;
int v[N],w[N];
int dp[N][N];
int main(){
cin >> n >> m;
for(int i = 1;i <= n;i++) cin >> v[i] >> w[i];
//dp[0][0~m] = 0,但是静态变量自动是0
for(int i = 1;i <=n ; i++)
for(int j = 0;j <= m;j++)
for(int k = 0;k*v[i]<j;k++)//枚举数量
dp[i][j] = max(dp[i][j],dp[i-1][j-k*v[i]]+k*w[i]);
cout << dp[n][m] << endl;
}
这个时间复杂度很大
优化的思路是,有没有什么东西是我们重复计算了呢
注意下面的对比
d p ( i , j ) = M a x { d p ( i − 1 , j ) , M a x { d p ( i − 1 , j − k v i ) + ( k − 1 ) w i + w i ∣ k ∈ [ 1 , j v i ] } } dp(i,j) = Max\{dp(i-1,j), \ \ Max\{dp(i-1,j-kv_i)+(k-1)w_i+w_i \ | \ k \in [1,\frac{j}{v_i}]\} \ \} dp(i,j)=Max{dp(i−1,j), Max{dp(i−1,j−kvi)+(k−1)wi+wi ∣ k∈[1,vij]} }
d p ( i , j − v i ) = M a x { d p ( i − 1 , j − k ′ v i ) + ( k ′ − 1 ) w i ∣ k ′ ∈ [ 1 , j v i ] } dp(i,j-v_i) = \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ Max\{dp(i-1,j-k^{'}v_i)+(k^{'}-1)w_i \ \ \ \ \ \ \ \ | \ k^{'} \in [1,\frac{j}{v_i}]\} dp(i,j−vi)= Max{dp(i−1,j−k′vi)+(k′−1)wi ∣ k′∈[1,vij]}
直接观察对比,我们有
d p ( i , j ) = M a x ( d p ( i − 1 , j ) , d p ( i , j − v i ) + w i ) dp(i,j)=Max(dp(i-1,j),dp(i,j-v_i)+w_i) dp(i,j)=Max(dp(i−1,j),dp(i,j−vi)+wi)
这简直就和01背包一模一样了
#include
using namespace std;
const int N = 1010;
int n,m;
int v[N],w[N];
int dp[N][N];
int main(){
cin >> n >> m;
for(int i = 1;i <= n;i++) cin >> v[i] >> w[i];
//dp[0][0~m] = 0,但是静态变量自动是0
for(int i = 1;i <=n ; i++){
for(int j = 0;j <= m;j++){
dp[i][j] = dp[i-1][j];
if(j >= v[i]) dp[i][j] = max(dp[i][j],dp[i][j-v[i]]+w[i]);
}
}
cout << dp[n][m] << endl;
}
同样的,我们可以继续把空间优化成1维,但是令人惊奇的是,完全背包的第 i i i回合的更新用到的竟然都是第 i i i回的数据
这意思就是说,这里总是用已被更新过的来继续进行更新别的,那我们就不用再反向循环了
for(int i = 1;i <=n ; i++)//变成一个记录时间先后的维了
for(int j = v[i];j <= m;j++)//从大到小枚举体积
dp[j] = max(dp[j],dp[j-v[i]]+w[i]);
朴素版本的多重背包简直就和完全背包问题一模一样
#include
using namespace std;
const int N = 1010;
int n,m;
int v[N],w[N],s[N];
int dp[N][N];
int main(){
cin >> n >> m;
for(int i = 1;i <= n;i++) cin >> v[i] >> w[i] >> s[i];
//dp[0][0~m] = 0,但是静态变量自动是0
for(int i = 1;i <=n ; i++)
for(int j = 0;j <= m;j++)
for(int k = 0;k <= s[i] && k*v[i]<j;k++)//枚举数量
dp[i][j] = max(dp[i][j],dp[i-1][j-k*v[i]]+k*w[i]);
cout << dp[n][m] << endl;
}
一个朴素的想法是模仿完全背包问题进行优化
d p ( i , j ) = M a x { d p ( i − 1 , j ) , M a x { d p ( i − 1 , j − k v i ) + ( k − 1 ) w i + w i ∣ k ∈ [ 1 , s i ] } } dp(i,j) \ \ \ \ \ \ \ \ = Max\{\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ dp(i-1,j), \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ Max\{dp(i-1,j-kv_i)+(k-1)w_i+w_i \ | \ k \in [1,s_i]\} \ \} dp(i,j) =Max{ dp(i−1,j), Max{dp(i−1,j−kvi)+(k−1)wi+wi ∣ k∈[1,si]} }
d p ( i , j − v i ) = M a x { d p ( i − 1 , j − ( s i + 1 ) v i ) + s i w , M a x { d p ( i − 1 , j − k ′ v i ) + ( k ′ − 1 ) w i ∣ k ′ ∈ [ 1 , s i ] } dp(i,j-v_i) = Max\{dp(i-1,j-(s_i+1)v_i)+s_iw, \ \ Max\{dp(i-1,j-k^{'}v_i)+(k^{'}-1)w_i \ \ \ \ \ \ \ \ | \ k^{'} \in [1,s_i]\} dp(i,j−vi)=Max{dp(i−1,j−(si+1)vi)+siw, Max{dp(i−1,j−k′vi)+(k′−1)wi ∣ k′∈[1,si]}
然后发现多出来一项,寄了
只能换一种方法:二进制优化。总而言之就是进行打包
具体来说就是把 s i s_i si个物品进行打包,每包的个数分别是 { 2 0 , 2 1 , … , 2 l , s i + 1 − 2 l + 1 } \{2^0,2^1,\dots,2^l,s_i+1-2^{l+1}\} {20,21,…,2l,si+1−2l+1}
类似某个数的二进制表示,我们可以选择这些“打包”中的某些包,表示出 [ 0 , s i ] [0,s_i] [0,si]中的每一个整数
注意到这样的“包”都是要么选,要么不选的性质。这就把一个多重背包转化成一个01背包
关于算法的正确性证明:其实就是 { 2 0 , 2 1 , … , 2 l , s i + 1 − 2 l + 1 } \{2^0,2^1,\dots,2^l,s_i+1-2^{l+1}\} {20,21,…,2l,si+1−2l+1}能不能表示出 [ 0 , s i ] [0,s_i] [0,si]中的每一个整数
这里我们要求 2 l + 1 − 1 < s i ≤ 2 l + 2 − 1 2^{l+1}-1 < s_i \leq 2^{l+2}-1 2l+1−1<si≤2l+2−1
由二进制数的性质,容易得 { 2 0 , 2 1 , … , 2 l } \{2^0,2^1,\dots,2^l\} {20,21,…,2l}一定能表示出 [ 0 , 2 l + 1 − 1 ] [0,2^{l+1}-1] [0,2l+1−1]中所有整数
∀ x ∈ ( 2 l + 1 − 1 , s i ] , 令 c = s i + 1 − 2 l + 1 \forall x \in (2^{l+1}-1,s_i],令c = s_i+1-2^{l+1} ∀x∈(2l+1−1,si],令c=si+1−2l+1显然我们有 0 < c ≤ 2 l + 1 0
这说明 x − c x-c x−c是可表示的,进而 x x x是可表示的,进而 [ 0 , s i ] [0,s_i] [0,si]都是可表示的
#include
using namespace std;
const int N = 25000;//把多重背包搞成01背包,要算nlogk
const int M = 2010;
int n, m;
int v[N], w[N];
int dp[N];
int main() {
cin >> n >> m;
//拆分成01背包
int cnt = 0;//拆分数量,也即拆分成01物品的数量
for (int i = 1; i <= n; i++) {
int vv, ww, s; //当前物品的,体积,价值,个数
cin >> vv >> ww >> s;
int k = 1;//打包容量
while (k <= s) {
cnt++;
v[cnt] = vv * k;
w[cnt] = ww * k;
s -= k;
k *= 2;
}
if (s > 0) {
cnt++;
v[cnt] = vv * s;
w[cnt] = ww * k;
}
}
//01背包
for (int i = 1; i <= cnt; i++)
for (int j = m; j >= v[i]; j--)
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
cout << dp[m] << endl;
}
朴素做法
#include
using namespace std;
const int N=110;
int dp[N][N];
int v[N][N],w[N][N],s[N];
int n,m,k;
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>s[i];
for(int j=0;j<s[i];j++){
cin>>v[i][j]>>w[i][j];
}
}
for(int i=1;i<=n;i++){
for(int j=0;j<=m;j++){
dp[i][j]=dp[i-1][j];
for(int k=0;k<s[i];k++){
if(j>=v[i][k]) dp[i][j]=max(dp[i][j],dp[i-1][j-v[i][k]]+w[i][k]);
}
}
}
cout<<dp[n][m]<<endl;
}
同样的,同01背包,用i-1次更新i次,反向循环优化成一维
for(int i=1;i<=n;i++)
for(int j=m;j>=0;j--)
for(int k=0;k<s[i];k++)
if(j>=v[i][k]) dp[j]=max(dp[j],dp[j-v[i][k]]+w[i][k]);
cout<<dp[m]<<endl;
同时存在01,多重,完全背包
#include
using namespace std;
const int N = 1001;
int f[N];
int n,m;
int main(){
cin >> n >> m;
for(int i = 0;i < n;i++){
int v,w,s;
cin >> v >> w >> s;
if(s == -1) s = 1;
//多重背包
if(s == 0){
for(int j = v;j <= m;j++){
f[j] = max(f[j],f[j-v]+w);
}
}
//将01背包也作为多重背包
else{
for(int k = 1;k <= s;k*=2){//打包的数量
//打包后做01背包
for(int j = m;j >= k*v;j--){
f[j] = max(f[j],f[j-k*v]+k*w);
}
s -= k;
}
if(s != 0){
for(int j = m;j >= s*v;j--){
f[j] = max(f[j],f[j-s*v]+s*w);
}
}
}
}
cout << f[m] << endl;
}
dp递推具有一定线性顺序·
从上到下分别是1,2,3…行,每行从左到右分别是1,2,3,4,…列
初始化:把所有状态初始化为负无穷(就是比任何数都小)
这是为了应对边界条件
#include
using namespace std;
const int N = 510, INF = 1e9;
int n;
int a[N][N];
int f[N][N];
int main(){
scanf("%d",&n);
for(int i = 1;i <= n;i++)
for(int j = 1; j <= i;j++)
scanf("%d",&a[i][j]);
//初始化
for(int i = 0;i <= n;i++)
for(int j = 0; j <= i+1;j++)
f[i][j] = -INF;
f[1][1] = a[1][1];
for(int i = 2;i <= n;i++)
for(int j = 1;j <= i;j++)
f[i][j] = max(f[i-1][j-1],f[i-1][j])+a[i][j];
int ans = -INF;
//遍历最后一行
for(int i = 1;i <= n;i++) ans = max(ans,f[n][i]);
printf("%d\n",ans);
return 0;
}
注:本题还有笼另外的方式,上面的做法是自顶向底,其实也可以自底向上,好处是最后不用遍历一遍求最大值了
#include
using namespace std;
const int N = 1010;
int n;
int a[N],f[N];
int main(){
scanf("%d",&n);
for(int i = 1;i <= n;i++) scanf("%d",&a[i]);
for(int i = 1;i <= n;i++){
f[i] = 1;//至少含有a[i]一个数
for(int j = 1;j <= n;j++)
if(a[j] < a[i]) f[i] = max(f[i],f[j]+1);
}
//遍历
int res = 0;
for(int i = 1;i <= n;i++) res = max(res,f[i]);
printf("%d\n",res);
return 0;
}
定理
对于偏序集 < A , ≤ > ,设其中最长链长度为 n ,则若将 A 分解成不相交的反链,反链个数至少为 n 对于偏序集,设其中最长链长度为n,则若将A分解成不相交的反链,反链个数至少为n 对于偏序集<A,≤>,设其中最长链长度为n,则若将A分解成不相交的反链,反链个数至少为n
说明:
证明:设反链的最小个数为 p p p
于是,在一个序列 { a i } \{a_i\} {ai}取偏序关系是,序列中两个数,同时满足其中一个数的下标和值都大于另一个数,则这两个数存在一个关系。
于是,在该偏序关系下,链是序列的上升子序列,反链是下降子序列,于是根据该定理我们可以断言,最长上升子序列长度=最小下降子序列覆盖数
方案的记录一般与状态转移这一步结合,具体来说就是记录一下每个新状态是由哪个旧状态转移来的
#include
using namespace std;
const int N = 1010;
int n;
int a[N],f[N];
int g[N];
int main(){
scanf("%d",&n);
for(int i = 1;i <= n;i++) scanf("%d",&a[i]);
for(int i = 1;i <= n;i++){
f[i] = 1;
for(int j = 1;j <= n;j++){
if(a[j] < a[i]){
if(f[j]+1 > f[i]){
f[i] = f[j]+1;
g[i] = j;//代表i状态由j状态转移来
}
}
}
}
//遍历
int length = 0,ending;
for(int i = 1;i <= n;i++){
if(f[i]>length){
ending = i;
length = f[i];
}
}
stack<int> path;//逆序输出
for(int i = 0,k = ending;i<length;i++,k = g[k]){//从尾部回溯
path.push(k);
}
while(path.size()){
printf("%d ",path.top());
path.pop();
}
return 0;
}
更换状态表示,使得在状态转移时用二分,时间复杂度缩减为 O ( n l o g n ) O(nlogn) O(nlogn)
#include
using namespace std;
const int N = 1010;
int n, cnt;
int a[N], dp[N];
int main() {
cin >> n;
for (int i = 0 ; i < n; i++) cin >> a[i];
dp[++cnt] = a[0];//dp[1]
for (int i = 1; i < n; i++) {
if (a[i] > dp[cnt]) dp[++cnt] = a[i];
else {
int l = 0, r = cnt - 1;
while (l < r) {
int mid = (l + r) >> 1;
if (dp[mid] >= a[i]) r = mid;
else l = mid + 1;
}
dp[r] = a[i];
}
}
cout << cnt << endl;
return 0;
}
#include
using namespace std;
const int N = 1010;
int n, m;
char a[N], b[N];
int dp[N][N];
int main() {
scanf("%d%d",&n,&m);
scanf("%s%s",a+1,b+1);
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (a[i] == b[j]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
printf("%d\n",dp[n][m]);
return 0;
}
通常该类题的状态表示的是区间的信息
区间 [ i , j ] ⟶ d p ( i , j ) 区间[i,j] \longrightarrow dp(i,j) 区间[i,j]⟶dp(i,j)
整体的方法是枚举区间划分点
本题有一点需要注意就是如何遍历所有状态,该顺序需要保证每次进行状态转移时用来进行状态转移的状态应该是已经被计算过的
注意到进行区间dp时,用来进行状态转移的区间都是比当前区间短的区间,所以枚举所有状态时应该按照长度从小到大进行枚举
#include
using namespace std;
const int N = 310;
int n;
int s[N];//前缀和,快速查询总和
int dp[N][N];
int main(){
scanf("%d",&n);
for(int i = 1;i <= n;i++) scanf("%d",&s[i]);
for(int i = 1;i <= n;i++) s[i] += s[i-1];//处理前缀和
memset(dp,0x3f,sizeof dp);//初始化为无穷大
for(int len = 1;len <= n;len++){//从小到大进行枚举区间长度
for(int i = 1;i +len -1 <= n;i++){//枚举起点
int l = i, r = i + len - 1;
if(len == 1) dp[l][r] = 0;//边缘条件:一堆不需要合并
else{
for(int k = l;k < r;k++){//枚举分割点
dp[l][r] = min(dp[l][r],dp[l][k]+dp[k+1][r]+s[r]-s[l-1]);
}
}
}
}
printf("%d\n",dp[1][n]);
}
这个标题名是我瞎起的,意思是这类dp问题中,用于标识状态的参数并不是那么显然了
此类问题一般能够使用一个类似于有限状态机的模型来表示整个事件,其组成元素包括
在进行实现时,通常是在原有的状态数组上再开一维表示位于何状态的数组,其流程依旧是初始化->遍历->状态转移->查询答案
,与常规动规的区别在于“状态转移”时需要按照状态机图来,见例子
给出状态机:
给出状态表示: 现在是第 i 天,已经完成 j 次交易,不持有股票 f ( i , j , 0 ) ;持有股票 f ( i , j , 1 ) 现在是第i天,已经完成j次交易,不持有股票f(i,j,0);持有股票f(i,j,1) 现在是第i天,已经完成j次交易,不持有股票f(i,j,0);持有股票f(i,j,1)所达到的最大利润
给出状态转移
#include
using namespace std;
const int N = 1e5+10,K= 110;
int f[N][K][2];
int n,k;
int main(){
cin >> n >> k;
memset(f,0xcf,sizeof f);//先假设全不合法
for(int i = 0;i <= n;i++) f[i][0][0] = 0;//合法的结果初始化成0
for(int i = 1;i <= n ;i++) {
int w;
cin >> w;
for(int j = 1;j <= k;j++){
f[i][j][0] = max(f[i - 1][j][0], f[i - 1][j][1] + w);
f[i][j][1] = max(f[i - 1][j][1], f[i - 1][j - 1][0] - w);
}
}
int ans = 0;
for(int j = 0;j <= k;j++) ans = max(f[n][j][0],ans);
cout << ans << endl;
}
我们有时候需要对状态进行记录,后面的“树上dp”也是对状态进行记录的例子
用状态函数的某一个参数记录了当前状态,通过压缩,一般是以二进制数的形式出现
首先我们断定,假如所有横着的方片已经全放进去了,那竖着的放的方式是唯一的
所以只用考虑放横着的方片
状态表示 d p ( i , j ) dp(i,j) dp(i,j):
状态转移:对于 d p ( i , j ) dp(i,j) dp(i,j),首先枚举所有可能的状态 j j j,判断该状态能否由 d p ( i − 1 , k ) dp(i-1,k) dp(i−1,k)转移
#include
using namespace std;
const int N = 12,M = 1<<N;
int n,m;
long long int dp[N][M];
bool st[M];
int main(){
int n,m;
while(cin>>n>>m,n||m){
memset(dp,0,sizeof dp);
for(int i = 0;i < 1<<n;i++){//预处理出所有不含奇数连续0的状态
st[i] = true;
int cnt = 0;
for(int j = 0;j < n;j++){
if(i>>j & 1){//如果当前位是1
if(cnt & 1) st[i] = false;//如果之前连续的0数量是奇数
cnt = 0;
}
else cnt++;
}
if(cnt & 1) st[i] = false;
}
//0~m-1 列 是图片所在的位置
dp[0][0] = 1;//不可能从-1列放横向方片,所以第二维是0,dp[0][0]代表什么都不干
for(int i = 1;i <= m;i++){
for(int j = 0;j < 1<<n;j++){
for(int k = 0;k < 1<<n;k++){
if((j&k)==0 && st[j|k]){
dp[i][j] += dp[i-1][k];
}
}
}
}
cout << dp[m][0] << endl;//输出dp[m][0]
}
}
例:最短哈密尔顿路径
状态表示 d p ( i , j ) dp(i,j) dp(i,j):
状态转移:按照是从那个点走到 j j j来分类,遍历所有可能的k点, d p ( i − { j } , k ) + a k , j dp(i-\{j\},k)+a_{k,j} dp(i−{j},k)+ak,j。意思是该状态只能从"从i走到k,且没有经过j这个点"的状态转移过来
#include
using namespace std;
const int N = 20, M = 1 << N;
int n;
int w[N][N];
int dp[M][N];
int main(){
cin>>n;
for(int i = 0;i < n;i++)
for(int j = 0;j < n;j++)
cin >> w[i][j];
memset(dp,0x3f,sizeof dp);
dp[1][0] = 0;
for(int i = 0;i < 1<<n;i++){
for(int j = 0;j < n;j++){
if(i>>j&1){//至少应该经过j这个点
for(int k = 0;k < n;k++){//枚举上一个点
if((i - (1<<j))>>k & 1){//路径内同时满足包含j和包含k
dp[i][j] = min(dp[i][j],dp[i-(1<<j)][k]+w[k][j]);
}
}
}
}
}
cout << dp[(1<<n)-1][n-1] << endl;
}
感觉上很类似状态压缩dp
按照儿子或者父亲的信息进行dp
遍历树的方法:dfs
状态表示 d p ( u , j ) dp(u,j) dp(u,j):
状态转移:对于 u u u,遍历其所有孩子 s s s
#include
using namespace std;
const int N = 6010;
int n;
int happy[N];
int h[N],e[N],ne[N],idx;
int dp[N][2];
int indegree[N];//入度数组,判断根节点
void add(int a,int b){
e[idx] = b;ne[idx] = h[a];h[a] = idx++;
}
void dfs(int u){
dp[u][1] = happy[u];
for(int i = h[u];i != -1;i++){
int cur = e[i];
dfs(cur);
dp[u][0] += max(dp[cur][0],dp[cur][1]);
dp[u][1] += dp[cur][0];
}
}
int main(){
scanf("%d",&n);
for(int i = 1;i <= n;i++) scanf("%d",&happy[i]);
memset(h,-1,sizeof h);
for(int i = 0;i < n-1;i++){
int a,b;
scanf("%d%d",&a,&b);
indegree[a]++;
add(b,a);
}
int root = 1;
while(indegree[root++]);//求根节点
dfs(root);
printf("%d\n",max(dp[root][0],dp[root][1]));
}
动态规划专门解决子问题重复的问题
正常的动态规划是从小问题逐渐组合成为大问题的解
记忆化搜索就是反方向,从大问题向小问题递归,但每次遇到问题时
所以记忆化搜索也被称为备忘录方法。
例题:
状态表示和状态转移斌没有本质上的差别,只有求解所有状态时有区别
状态表示 d p ( i , j ) dp(i,j) dp(i,j):
状态转移:分别判断能否向,前后左右滑雪,
现在采用递归的实现方式:
#include
using namespace std;
const int N = 310;
int n,m;
int h[N][N];
int f[N][N];//动态规划数组
int dx[4] = {-1,0,1,0};
int dy[4] = {0,1,0,-1};
int dp(int x,int y){
int &v = f[x][y];
if(v != -1) return v;//之前算过,直接返回
v = 1;
for(int i = 0;i < 4;i++){
int a = x+dx[i],b = y+dy[i];
if(a >= 1 && a<=n && b>=1 && b<=n && h[a][b] < h[x][y]){
v = max(v,dp(a,b)+1);
}
}
return v;
}
int main(){
scanf("%d %d",&n,&m);
for(int i = 1;i <= n;i++){
for(int j = 1;j <= m;j++){
scanf("%d",&h[i][j]);
}
}
memset(f,-1,sizeof f);
int res = 0;
for(int i = 1;i <= n;i++){
for(int j = 1;j <= n;j++){
res = max(res,dp(i,j));
}
}
printf("%d\n",res);
}
优点:
缺点:
for(int k = 2; k <= 2*n; k++) {
for(int i1 = 1; i1 <= n; i1++) {
for(int i2 = 1; i2 <= n; i2++) {
int j1 = k-i1, j2 = k-i2;
if(j1>=1&&j1<=n&&j2>=1&&j2<=n) {
int &x = f[k][i1][i2];
int t = w[i1][j1];
if(i1!=i2) t += w[i2][j2];
x = max(x, f[k-1][i1-1][i2-1]+t);
x = max(x, f[k-1][i1-1][i2]+t);
x = max(x, f[k-1][i1][i2-1]+t);
x = max(x, f[k-1][i1][i2]+t);
}
}
}
}
直接正向做一遍反向做一遍最长上升子序列
(反向也可以看作是做大下降子序列)
本题是另一种形状,即先单调上升,后单调下降暂且称之为反"V",自然的,对于这种形状的子序列,我们想到按照转折点是谁来进行分类
可以观察到
以 a [ i ] 结尾的反 V 最大长度 = 以 a [ i ] 结尾的最大上升子序列 + 倒过来看以 a [ i ] 结尾的最大上升子序列 − 1 以a[i]结尾的反V最大长度=以a[i]结尾的最大上升子序列+倒过来看以a[i]结尾的最大上升子序列-1 以a[i]结尾的反V最大长度=以a[i]结尾的最大上升子序列+倒过来看以a[i]结尾的最大上升子序列−1
所以思路是,先正向做一遍最大上升子序列,把结果保存到 f [ i ] f[i] f[i],再反向做最大上升子序列,把结果保存到 g [ i ] g[i] g[i],最后从头遍历一遍,找到最大的 f [ i ] + g [ i ] f[i]+g[i] f[i]+g[i]。
自行构建序列,选取一边的坐标作为自变量,取另一边的坐标作为因变量,以自变量为依据,将因变量排序,我们得到一个序列,该序列上的每一个上升子序列就对应一个合理的航线安排
更换状态数组存储的东西,令 f [ i ] f[i] f[i]表示以 a [ i ] a[i] a[i]结尾的上升子序列的和的最大值,状态转移与最大上升子序列相同
第一问:最长下降子序列
第二问:贪心
贪心正确性说明:设贪心解是A,最优解是B
第二问代码实现
根据 D i l w o r t h Dilworth Dilworth定理。其实第二问就是求最大上升子序列个数
贪心+爆搜(dfs)
同时维护一个上升子序列集合的末尾数列和一个下降子序列集合的末尾数列,每次dfs时进行决策,是将当前数字加入上升子序列还是下降子序列
#include
#include
using namespace std;
const int N = 55;
int n;
int q[N];
int up[N],down[N]; //上升子序列和下降子序列集合
int ans;
void dfs(int u,int su,int sd){ // 当前遍历到几,有几个上升子序列了,有几个下降子序列了
if(su+sd >= ans) return; //剪枝
if(u == n){//遍历结束
ans = su+sd;
return;
}
//将当前数放入上升子序列中
int k = 0;
while(k < su && up[k] >= q[u]) k++;
int t = up[k];//存储变化前的状态
up[k] = q[u];
if(k < su) dfs(u+1,su,sd);//未增加序列
else dfs(u+1,su+1,sd);
up[k] = t;//dfs后恢复现场
//将当前数放入下降子序列中
k = 0;
while(k < sd && down[k] <= q[u]) k++;
t = up[k];//存储变化前的状态
down[k] = q[u];
if(k < sd) dfs(u+1,su,sd);//未增加序列
else dfs(u+1,su,sd+1);
down[k] = t;//dfs后恢复现场
}
int main(){
while(cin>>n , n){
for(int i = 0;i < n;i++) cin >> q[i];
ans = n;
dfs(0,0,0);
cout << ans << endl;
}
return 0;
}
就是将两个问题结合到一起,从集合的观点来看,是两个问题的笛卡尔积
状态表示 f [ i , j ] f[i,j] f[i,j]:由第一个序列的前 i i i个字母,第二个序列的前 j j j个字母中,且以 b [ j ] b[j] b[j] 结尾的 所有公共上升子序列的最大长度
状态转移:进行集合划分,然后对每一种情况取max。于是对于 f [ i , j ] f[i,j] f[i,j],能分成以下情况
最后由于强行限制了 b [ j ] b[j] b[j]结尾,需要遍历所有可能的 j j j,找到全局最大值
for (int i = 1; i <= n; i ++ ) {
for (int j = 1; j <= n; j ++ ) {
f[i][j] = f[i - 1][j];//不包含a[i]
if (a[i] == b[j]) {//包含a[i]
int maxv = 1;//所有情况情况最烂是长度是1
for (int k = 1; k < j; k ++ )
if (b[j] > b[k])
maxv = max(maxv, f[i - 1][k] + 1);
f[i][j] = max(f[i][j], maxv);
}
}
}
int res = 0;
for (int i = 1; i <= n; i ++ ) res = max(res, f[n][i]);
三重循环,可以对其进行优化。首先注意到如果有a[i]==b[j]
,则第三重循环只是循环中点与j
有关。
观察maxv
的行为,发现其是求了一个条件前缀最大值 M a x { f [ i − 1 ] [ k = 1 : j − 1 ] s . t . a [ i ] > b [ k ] } Max\{f[i-1][k = \ \ 1:j-1] \ \ s.t.a[i]>b[k]\} Max{f[i−1][k= 1:j−1] s.t.a[i]>b[k]}
所以完全可以把整个对maxv
的求解提出来
for (int i = 1; i <= n; i ++ ) {
int maxv = 1;
for (int j = 1; j <= n; j ++ ) {
f[i][j] = f[i - 1][j];
if (a[i] == b[j]) f[i][j] = max(f[i][j], maxv);
if (a[i] > b[j]) maxv = max(maxv, f[i - 1][j] + 1);
}
}
关于上述代码的正确性:每次对ff[i][j]
进行更新时,正好maxv
也已经针对前j-1
进行了更新,与上述我们要求的结果一致
面向题目建模时,重要的是找到什么是“重量”,什么是“价值”
#include
using namespace std;
const int V = 20000;
int n,v;
int f[V];
int main(){
cin >> v >> n;
for(int i = 1;i <= n;i++){
int w;
cin >> w;
for(int j = v;j >= w;j--) f[j] = max(f[j],f[j-w]+w);
}
cout << v-f[v] << endl;
}
把第一个维度滚掉
#include
#include
using namespace std;
const int N = 1010, M = 510;
int n, V1, V2;
int f[N][M];
int main()
{
cin >> V1 >> V2 >> n;
for (int i = 0; i < n; i ++ )
{
int v1, v2;
cin >> v1 >> v2;
for (int j = V1; j >= v1; j -- )
for (int k = V2 - 1; k >= v2; k -- )
f[j][k] = max(f[j][k], f[j - v1][k - v2] + 1);
}
cout << f[V1][V2 - 1] << ' ';
int k = V2 - 1;
while (k > 0 && f[V1][k - 1] == f[V1][V2 - 1]) k -- ;
cout << V2 - k << endl;
return 0;
}
还能再优化,参考题解
多维重量+限制反转,费用1:氧气;费用二:氮气;价值:重量
状态表示: 选前 i 罐,氧气恰好是 j ,氮气恰好是 k ,最小的重量 f ( i , j , k ) = M i n ( f ( i − 1 , j , k ) , f ( i − 1 , j − a i , k − b i ) + c i ) 选前i罐,氧气恰好是j,氮气恰好是k,最小的重量f(i,j,k)=Min(f(i-1,j,k),f(i-1,j-a_i,k-b_i)+c_i) 选前i罐,氧气恰好是j,氮气恰好是k,最小的重量f(i,j,k)=Min(f(i−1,j,k),f(i−1,j−ai,k−bi)+ci) 这么做是过不的
#include
#include
#include
using namespace std;
const int N = 50, M = 160;//错
int n,m,K;
int f[N][M];
int main()
{
cin >> n >> m >> K;
memset(f,0x3f,sizeof(f));
f[0][0] = 0;
for (int i = 0; i < K; i ++ )
{
int v1, v2, w;
cin >> v1 >> v2 >> w;
for (int j = N-1; j >= v1; j -- )
for (int k = M - 1; k >= v2; k -- )
f[j][k] = min(f[j][k], f[j - v1][k - v2] + w);
}
int res = 1e9;
for(int i = n;i < N;i++){
for(int j = m;m < M;j++){
res = min(res,f[i][j]);
}
}
cout << res << endl;
return 0;
}
初始化:其目的主要是为了初始化所有的 f ( 0 , j , k ) f(0,j,k) f(0,j,k)为正无穷, f ( 0 , 0 , 0 ) f(0,0,0) f(0,0,0)为0
按理说循环应该发生在区间 [ m , + ∞ ) [m,+\infty) [m,+∞),但是实际上只用枚举到 f ( n × M a x { a i } , m × M a x { b i } ) f(n\times Max\{a_i\},m \times Max\{b_i\}) f(n×Max{ai},m×Max{bi})
获得最终答案时,需要手动遍历所有合理的状态,获得最小值。
状态表示: 选前 i 罐,氧气不少于 j ,氮气不少于 k ,最小的重量 f ( i , j , k ) = M i n ( f ( i − 1 , j , k ) , f ( i − 1 , j − a i , k − b i ) + c i ) 选前i罐,氧气不少于j,氮气不少于k,最小的重量f(i,j,k)=Min(f(i-1,j,k),f(i-1,j-a_i,k-b_i)+c_i) 选前i罐,氧气不少于j,氮气不少于k,最小的重量f(i,j,k)=Min(f(i−1,j,k),f(i−1,j−ai,k−bi)+ci)
#include
#include
using namespace std;
const int N = 22, M = 80;
int n, m, K;
int f[N][M];
int main()
{
cin >> n >> m >> K;
memset(f, 0x3f, sizeof f);
f[0][0] = 0;
while (K -- )
{
int v1, v2, w;
cin >> v1 >> v2 >> w;
for (int i = n; i >= 0; i -- )
for (int j = m; j >= 0; j -- )
f[i][j] = min(f[i][j], f[max(0, i - v1)][max(0, j - v2)] + w);
}
cout << f[n][m] << endl;
return 0;
}
初始化还是一样的,把合法的状态初始化成无穷大
为什么不用像上一个状态表示一样循环到更大的地方?
为什么要循环到0?
接下来我们假想一个一维重量的情形。第 i i i个物品的重量是 w i w_i wi,价值是 q i q_i qi。
目前遇到有一个情形,目前是针对第 i i i个物品进行更新,该物品的重量是 4 4 4,试图更新的是f[2]
f[2]
,即不选择这个物品。f[2]
代表的状态的所有物品价值之和还要少。所以更新的方式是,只选这个物品,把之前的全扔掉。即把f[2]
更新成值 q i q_i qi意思是在之前的状态表示里, f [ ≤ w i ] f[\leq w_i] f[≤wi]的状态是没必要进行更新的。但是在“不大于”的状态表示里,这些状态都有可能被更新
f[i][j] = min(f[i][j], f[max(0, i - v1)][max(0, j - v2)] + w)
什么操作?
f[0]
被初始化为0
f[负数]
状态与f[0]
没有区别。因为在物品重量非负的情况下,“不少于一个负数重量”和“重量不少于0”是等价的。背包问题+树形bp
f[i][v[i]~m]
初始化为w[i]
f[son][0~m]
f[son][k]
来进行状态转移for(int j = m;j >= v[i];j--)
f[i][j]
k
取[0~j-v[i]]
#include
using namespace std;
const int N = 110;
int f[N][N];
vector<int> h[N];
int v[N],w[N];
int n,m,root;
void dfs(int u){
for(int i = v[u];i <= m;i++) f[u][i] = w[u];
for(auto son:h[u]){
dfs(son);
for(int j = m;j >= v[u];j--){
for(int k = 0;k <= j-v[u];k++){
f[u][j] = max(f[u][j],f[u][j-k]+f[son][k]);
}
}
}
}
int main(){
cin >> n >> m;
for(int i = 1;i <= n;i++){
int p;
cin >> v[i] >> w[i] >> p;
if(p == -1) root = i;
else h[p].push_back(i);
}
dfs(root);
cout << f[root][m] << endl;
}
贪心+动规
该题涉及到了两个维度,一个是时间上的排列,一个是集合子集的选取。将该题分成两步走
这么做的原因是第一步可以通过贪心解决,参考耍杂技的牛
#include
#include
#include
#include
using namespace std;
const int N = 105, S = 10005;
int n;
int f[S];
struct Node{
int s, e, l;
bool operator < (const Node &x) const{
return s * x.l < x.s * l;
}
}a[N];
int main() {
int T, cnt = 0; scanf("%d", &T);
while(T--) {
memset(f, 0xcf, sizeof f);
scanf("%d", &n);
int t = 0;
for(int i = 1, s, e, l; i <= n; i++) {
scanf("%d%d%d", &s, &e, &l);
t += s; a[i] = (Node) { s, e, l };
}
sort(a + 1, a + 1 + n);
f[0] = 0;
for(int i = 1; i <= n; i++) {
for(int j = t; j >= a[i].s; j--)
f[j] = max(f[j], f[j - a[i].s] + max(0, a[i].e - (j - a[i].s) * a[i].l));
}
int res = 0;
for(int i = 1; i <= t; i++) res = max(res, f[i]);
printf("Case #%d: %d\n", ++cnt, res);
}
return 0;
}