动态规划2——常见题型整理

动态规划

  • 如果涉及到i - 1这种下标,状态表示从索引1开时,索引0设置成边界
  • 时间复杂度:状态数量 * 转移计算量

背包问题

1. 0-1背包

  • 每个物品只有一个

  • 状态定义:f(i, j)

    • 集合:所有从前i种物品中选,总重量为j的集合(i和j是下标)
    • 属性:该集合中各种情况的最大价值(元素的值)
  • 状态计算:f(i, j) = max( f(i - 1, j), f(i - 1,j- V_i) + W_i )

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N][N];

int main() {
    cin >> n >> m;
    
    for(int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    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 - 1][j - v[i]] + w[i]);
        }
    }
    
    cout << f[n][m] << endl;
    return 0;
}
//用滚动数组优化空间复杂度
#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N];

int main() {
    cin >> n >> m;
    
    for(int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    for(int i = 1; i <= n; i ++ ) { //依次枚举物品
        for(int j = m; j >= v[i]; j -- ) { //从大到小枚举,因为状态转移时计算当前值需要上一层的值,所以从后往前算,保证计算第i层的f[j]时,f[j - v[i]]中存储的是还没有被更新过的上一层的值,即二维状态表示中的f[i - 1][j -v[i]],而不是f[i][j - v[i]]
			f[j] = max(f[j], f[j - v[i]] + w[i]);
        }
    }
    
    cout << f[m] << endl;
    return 0;
}

2.完全背包

  • 状态表示:f(i, j)
    • 集合:所有只考虑前i个物品,且总体积不大于j的选法
    • 属性:集合中各种选法的最大价值
  • 状态计算: f[i][j] = max(f[i - 1][j - v[i] * k] + w[i] * k)
//朴素做法(评测系统超时)
#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N][N];

int main() {
    cin >> n >> m;
    for(int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    for(int i = 1; i <= n; i ++ )
        for(int j = 0; j <= m; j ++ )
            for(int k = 0; k * v[i] <= j; k ++)
                f[i][j] = max(f[i][j], f[i - 1][j - v[i] * k] + w[i] * k);//第i个物品选k个
            
    cout << f[n][m];
    
    return 0;
}
//优化做法
#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N];

int main() {
    cin >> n >> m;
    for(int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    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]); //因为总体积是从小到大递推,所以在计算选择k个i物品的价值时,选择k - 1个i物品的价值已经被计算过了,而且被更新到了f[j - v[i]]中  就像要信任递归一样,也要信任自己的递推公式
            
    cout << f[m];
    
    return 0;
}

3. 多重背包

  • 每件物品的个数是有限制的

  • 状态表示:f(i, j)

    • 集合:所有只从前i个物品中选,并且总体积不超过j的选法
    • 属性:集合中所有选法的最大价值
  • 状态计算: f[i][j] = max(f[i - 1][j - v[i] * k] + w[i] * k) k = 0, 1, 2, ..., s[i]

//朴素版本, 时间复杂度O(n^3)
#include <iostream>

using namespace std;

const int N = 110;

int n, m;
int v[N], w[N], s[N];
int f[N][N];

int main() {
    cin >> n >> m;
    for(int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i] >> s[i];
    
    for(int i = 1; i <= n; i ++ )
        for(int j = 0; j <= m; j ++ )
            for(int k = 0; k * v[i] <= j && k <= s[i]; k ++ )
                f[i][j] = max(f[i][j], f[i - 1][j - v[i] * k] + w[i] * k);
                
    cout << f[n][m] << endl;
    
    return 0;
}
//优化写法
//二进制优化:将每种物品按照指数级进行分组拆分,并将拆分后的各个组“包装”成“新的物品”,只要拆分策略保证拆分后新的物品能拼凑出原来物品能取到的物品个数比,那么完全可以将新问题当作0-1背包问题来处理
//朴素版本, 时间复杂度O(n^3)
#include <iostream>

using namespace std;

const int N = 110;

int n, m;
int v[N], w[N], s[N];
int f[N][N];

int main() {
    cin >> n >> m;
    for(int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i] >> s[i];
    
    for(int i = 1; i <= n; i ++ )
        for(int j = 0; j <= m; j ++ )
            for(int k = 0; k * v[i] <= j && k <= s[i]; k ++ )
                f[i][j] = max(f[i][j], f[i - 1][j - v[i] * k] + w[i] * k);
                
    cout << f[n][m] << endl;
    
    return 0;
}

4. 分组背包问题

  • 状态表示:f(i, j)
    • 集合:只从前i种物品中选,并且总体积不大于j的所有选法
    • 属性:集合中所有选法的最大价值
  • 状态计算:f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i, k]] + w[i, k])
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 110;

int n, m;
int v[N][N], w[N][N], s[N];
int f[N];

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 = 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]);//类似0-1背包问题的思路,最后的不同点是“选不选第i组中的第k个物品”
                    
    cout << f[m] << endl;
            
    return 0;
}

线性DP

5. 数字三角形

  • 状态表示:f(i, j)
  • 集合:所有从起点,走到(i, j)的路径
  • 属性:所有这些路径上面数字之和的最大值
  • 状态计算: f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j]
//从顶到底
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 510, INF = 1e9;
int n;
int a[N][N];
int f[N][N];

int main() {
    cin >> n;
    for(int i = 1; i <= n; i ++ ) {
        for(int j = 1; j <= i; j ++ ) {
            cin >> 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 res = -INF;
    for(int i = 1; i <= n; i ++ ) res = max(res, f[n][i]);
    
    cout << res << endl;
    
    return 0;
}
//从底到顶
#include <iostream>

using namespace std;

const int N = 510;

int n;
int f[N][N];

int main() {
    cin >> n;
    for(int i = 1; i <= n; i ++ )
        for(int j = 1; j <= i; j ++ )
            cin >> f[i][j];
        
    for(int i = n - 1; i ; i --) 
        for(int j = 1; j <= i; j ++ )
            f[i][j] = max(f[i + 1][j], f[i + 1][j + 1]) + f[i][j];
            
    cout << f[1][1];
    
    return 0;
}

6.最长上升子序列

  • 状态表示:f[i]
    • 集合:所有以第i个数结尾的上升子序列
    • 属性:所有以第i个数结尾的上升子序列的最大长度
  • 状态计算:按照倒数第二个数是哪个数来对集合分类 : f[i] = f[j] + 1 j = 0, 1,2,...,i - 1
//朴素版本
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n;
int a[N], f[N];

int main() {
    cin >> n;
    for(int i = 1; i <= n; i ++ ) cin >> a[i];
    
    for(int i = 1; i <= n ; i ++ ) {
        f[i] = 1; //只有i一个数
        for(int j = 1; j < i; 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]);
    
    cout << res << endl;
    
    return 0;
}

//输出最长上升子序列
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n;
int a[N], f[N], g[N];

int main() {
    cin >> n;
    for(int i = 1; i <= n; i ++ ) cin >> a[i];
    
    for(int i = 1; i <= n ; i ++ ) {
        f[i] = 1; //只有i一个数
        for(int j = 1; j < i; j ++ )
            if(a[j] < a[i])
			    if(f[i] < f[j] + 1) {
                    f[i] = [j] + 1;
					g[i] = j; //记录f[i]是从哪一个位置转移过来的
				}
    }
    
    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] << endl;
		k = g[k];
	}
    
    return 0;
}
//优化版本
//存储不同长度的子序列中的结尾值中最小的一个
/*
q[r + 1] = a[i];这里并没有选择最小的是因为可以保证“相等长度的上升序列,后来
得到的序列的结尾数值一定小于或等于前面得到的”,假设先前得到的序列xxxa,后来
得到的序列xxxb,a和b满足b在a的后面且b>a,那么显然xxxb一定不是以b结尾的最长
上升子序列,正确序列中一定包含a,所以假设不成立,命题得证
*/
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 100010;

int n;
int a[N];
int q[N];

int main() {
    cin >> n;
    for(int i = 0 ; i < n; i ++ ) cin >> a[i];
    
    int len = 0;
    q[0] = -2e9;
    for(int i = 0; i < n; i ++ ) {
        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;
        }
        len = max(len, r + 1);
        q[r + 1] = a[i];
    }
    
    cout << len << endl;
    return 0;
}

7. 最长公共子序列

  • 状态表示:f[i][j]
    • 集合:所有在第一个序列的前i个字母中出现,且在第二个序列的前j个字母中出现的子序列
    • 属性:集合中子序列长度的最大值
  • 状态计算:划分依据,a[i] 和b[j]是否包含在公共子序列中,划分为4个子集 f[i][j] = max(f[i - 1][j - 1], f[i - 1][j], f[i][j - 1], f[i - 1][j - 1] + 1) 注意这个递推式的中间两部分和划分子集不完全对应,也就是说四种情况之间有重叠,但是由于求最大值,重复不影响结果;上述递推式可以进一步化简为 f[i][j] = max(f[i - 1][j], f[i][j - 1], f[i - 1][j - 1] + 1), 因为第一种子类的情况一定包含于第二种或者第三种子类当中
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n, m;
char a[N], b[N];
int f[N][N];

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

8. 最短编辑距离

  • 状态表示:f[i][j]
    • 集合:所有将a[1~i]变成b[1~j]的操作方式
    • 属性:所有操作方式的最小步数
  • 状态计算:最后一次操作有三种可能的情况:
    • a.删除a[i],对应f[i - 1, j] + 1;
    • b.增加a[i],对应f[i, j - 1] + 1;
    • c.最后一步是改,分两种情况:
    1. a[i] == b[j], 则对应f[i - 1][j - 1]
    2. a[i] != b[j],则对应f[i - 1][j - 1] + 1;

所有情况取最小值

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n, m;
char a[N], b[N];
int f[N][N];

int main() {
    scanf("%d%s", &n, a + 1);
    scanf("%d%s", &m, b + 1);
    
    //处理边界情况
    for(int i = 0; i <= m; i ++ ) f[0][i] = i;
    for(int i = 0; i <= n; i ++ ) f[i][0] = i;
    
    for(int i = 1; i <= n; i ++ )
        for(int j = 1; j <= m; j ++ ) {
            f[i][j] = min(f[i - 1][j] + 1, f[i][j - 1] + 1);
            if (a[i] == b[j]) f[i][j] = min(f[i][j], f[i - 1][j - 1]);
            else f[i][j] = min(f[i][j], f[i - 1][j - 1] + 1);
        }
        
    printf("%d\n", f[n][m]);
    
    return 0;
}

区间DP

9. 石子合并

  • 状态表示:f[i][j]
    • 集合:所有将第i堆石子到第j堆石子合并成一堆的方式
    • 属性:所有这些合并方式的最小代价
  • 状态计算:最后一次合并一定是将两堆石子合并成一堆,所以可以按照最后一次合并时的分界线来分类, f[i][j] = min(f[i][k] + f[k + 1][j] + s[j] - s[i - 1]), k = i ~ j - 1
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 310;

int n;
int s[N];
int f[N][N];

int main() {
    cin >> n;
    for(int i = 1; i <= n; i ++ ) cin >> s[i];
    
    for(int i = 1; i <= n; i ++ ) s[i] += s[i - 1];
    
    for(int len = 2; len <= n; len ++ ) //枚举区间长度
        for(int i = 1; i + len - 1 <= n; i ++ ) { //枚举起点
            int l = i, r = i + len - 1;
            f[l][r] = 2e9;
            for(int k = l; k < r; k ++ )//枚举分界点
                f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + s[r] - s[l - 1]);
        }
        
    cout << f[1][n] << endl;
    
    return 0;
}

计数类DP

10. 整数划分

方法一

将该问题转化成一个完全背包问题模型,n表示背包容量,有体积为1~n的一系列物品,物品数量无限

  • 状态表示:f[i][j]
    • 集合:从1~i中选,并且总体积恰好为j的方案
    • 属性:方案的数量
  • 状态计算:按照最后一个数字i选取的数量分类,对所有情况求和
    f[i][j] = f[i - 1][j - i] + f[i - 1][j - 2 * i] + ... + f[i - 1][j - s * i]
    等价变形后:
    f[i][j] = f[i - 1][j] + f[i][j - i]
    空间优化:
    f[j] = f[j] + f[j - i]
    时间复杂度=状态数量 * 转移操作 O(n^2logn)
#include <iostream>

using namespace std;

const int N = 1010, mod = 1e9 + 7;

int n;
int f[N];

int main() {
    cin >> n;
    
    f[0] = 1;
    for(int i = 1; i <= n; i ++ )
        for(int j = i; j <= n; j ++ )
            f[j] = (f[j] + f[j - i]) % mod;
            
    cout << f[n] << endl;
    
    return 0;
}

方法二

  • 状态表示:f[i][j]
    • 集合:所有总和是i并且恰好可以划分成j个数的方案
    • 属性:方案的数量
  • 状态计算:按照划分后的数字序列的最小值是否为1划分为两组。如果最小值是1,则将该组中的所有划分序列去掉一个1之后的情况,可以用f[i - 1][j - 1]来表示;如果最小值大于1,则对于该组中的任何一个方案序列,都可以对方案中的所有数字减去1,减完后的情况可以用f[i - j][j]来表示,综上,状态转移方程为:
    f[i][j] = f[i - 1][j - 1] + f[i - j][j]
#include <iostream>

using namespace std;

const int N = 1010, mod = 1e9 + 7;

int n;
int f[N][N];

int main() {
    cin >> n;
    
    f[0][0] = 1;
    for(int i = 1; i <= n; i ++ )
        for(int j = 1; j <= i; j ++ )
            f[i][j] = (f[i - 1][j - 1] + f[i - j][j]) % mod;
    
    int res = 0;
    for(int i = 1; i <= n; i ++ ) res = (res + f[n][i]) % mod;
    
    cout << res << endl;
    
    return 0;
}

数位统计DP

11. 计数问题

状态压缩DP

12. 蒙德里安的梦想

  • 将所有横向矩形放完后,纵向矩形的方法就确定了,所以整体的方案数就是横向矩阵摆放方案的数量
#include <iostream>
#include <cstring>

using namespace std;

const int N = 12, M = 1 << N;

int n, m;
long long f[N][M];
bool st[M];

int main() {
    while(cin >> n >> m, n || m) {
        memset(f, 0, sizeof f);
        //预处理,排除有连续奇数个零的状态
        for(int i = 0; i < 1 << n; i ++ ) {
            st[i] = true;
            int cnt = 0;
            for(int j = 0; j < n; j ++ ) {
                if(i >> j & 1){
                    if(cnt & 1) st[i] = false;
                    cnt = 0;
                }
                else cnt ++;
            }
            if(cnt & 1) st[i] = false;
        }
        f[0][0] = 1;
        //枚举所有列
        for(int i = 1; i <= m; i ++ ) 
            for(int j = 0; j < 1 << n; j ++ ) //枚举所有状态
                for(int k = 0; k < 1 << n; k ++ ) //枚举第i - 1列的所有状态
                    if((j & k) == 0 && st[j | k])
                        f[i][j] += f[i - 1][k];
        
        //从第m列看,前一列需要没有方块伸出来才满足要求
        printf("%lld\n", f[m][0]);
    }
    
    return 0;
}

13.最短Hamilton路径

  • 朴素搜索的复杂度是n!
  • 分析发现在任何阶段,我们不需要关注之前经过点的顺序,可以进行如下优化:
    只关注两个因素:
    1)哪些点被用过 2 ^ n
    2)目前停在了哪个点 n
    总共的状态数量变成了2 ^ 20 * n
  • 状态表示:f[i][j]
    • 集合:所有从0走到j,走过的所有点是i的路径
    • 属性:路径的最小值
  • 状态计算:按照倒数第2个点的编号分类
    f[i][j] = min(f[i - j][k] + a[k][j])

#include <iostream>
#include <cstring>

using namespace std;

const int N = 20, M = 1 << N;

int n;
int w[N][N];
int f[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(f, 0x3f, sizeof f);
    f[1][0] = 0;
    for(int i = 0; i < 1 << n; i ++ ) 
        for(int j = 0; j < n; j ++ )
            if(i >> j & 1)
                for(int k = 0; k < n; k ++ )
                    if((i - (1 << j)) >> k & 1)
                        f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]);
    
    cout << f[(1 << n) - 1][n - 1] << endl;
    
    return 0;
}

树形DP

14.没有上司的舞会

  • 状态表示
    • 集合
      f[u][0]:所有从以u为根为的子树中选择,并且不选择u的方案
      f[u][1]:所有从以u为根为的子树中选择,并且选择u的方案
    • 属性: 最大值
  • 状态计算:假设已经得到u的子树的最大值为si,则
    f[u][0] = f[u][0] + max(f[si][0], f[si][1])
    f[u][1] = f[u][1] + f[si][0]
    以上两个式子都要遍历当前节点的所有儿子
  • 时间复杂度O(n)
#include <iostream>
#include <cstring>

using namespace std;

const int N = 6010;

int n;
int happy[N];
int h[N], e[N], ne[N], idx;
int f[N][2];
bool has_father[N];

void add(int a, int b) {
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

void dfs(int u) {
    f[u][1] = happy[u];
    for(int i = h[u]; i != -1; i = ne[i]) {
        int j = e[i];
        dfs(j);
        f[u][0] += max(f[j][0], f[j][1]);
        f[u][1] += f[j][0];
    }
}

int main(){
    cin >> n;
    for(int i = 1; i <= n; i ++ ) cin >> happy[i];
    
    memset(h, -1, sizeof h);
    
    for(int i = 0; i < n - 1; i ++ ) {
        int a, b;
        cin >> a >> b;
        has_father[a] = true;
        add(b, a);
    }    
    int root = 1;
    while(has_father[root]) root ++ ;
    
    dfs(root);
    
    cout << max(f[root][0], f[root][1]);
    
    return 0;
}

记忆化搜索

15.滑雪

  • 状态表示:f[i][j]
    • 集合:所以从[i][j]开始滑的路径
    • 属性:路径的最大长度
  • 状态计算:按照第一步滑向哪个方向,将集合划分为四种情况;
    f[i][j] = max(f[i][j - 1], f[i][j + 1], f[i - 1][j], f[i + 1][j]) + 1 //注意有些情况可能不存在
  • 注意这道题的情境下不可能出现环
//动态规划的另一种方式:递归实现,借助记忆化搜索
#include <iostream>
#include <cstring>

using namespace std;

const int N = 310;

int n, m;
int h[N][N];
int f[N][N];

int dp(int x, int y) {
    int &v = f[x][y];
    if(v != -1) return v;
    v = 1;
    int dx[] = {0, 1, 0, -1}, dy[] = {1, 0, -1, 0};
    for(int i = 0; i < 4; i ++ ) {
        int a = x + dx[i], b = y + dy[i];
        if(a >= 1 && a <= n && b >= 1 && b <= m && h[a][b] < h[x][y]) {
            v = max(v, dp(a, b) + 1);
        }
    }
    return v;
}

int main() {
    cin >> n >> m;
    for(int i = 1; i <= n; i ++ )
        for(int j = 1; j <= m; j ++ ) 
            cin >> h[i][j];
    
    memset(f, -1, sizeof f);
    
    int res = 0;
    for(int i = 1; i <= n; i ++ )
        for(int j = 1; j <= m; j ++ )
            res = max(res, dp(i, j));
    
    cout << res << endl;
    
    return 0;
}
posted @ 2021-04-07 15:53  呼_呼  阅读(65)  评论(1编辑  收藏  举报