动态规划系列(二)—其他类型DP


1. 石子合并问题(区间DP)

1.1 题目描述

N堆石子, 编号1,2,3,…,N。
每次只能合并相邻两堆石子。N堆石子合并成一堆,求合并的最小代价(合并过程中,总和累计最小)。

1.2 思路

  • 最终一定是左边连续的一部分,右边连续的一部分。
  • 有限集。所有可能组合(n - 1)! 数量量太大。 考虑DP。

1.3 代码

#include <iostream>

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];
        s[i] += s[i - 1];
    }
    
    for (int len = 2; len <= n; len ++) { // 区间长度为2时,最小体力代价为0
        for (int i = 1; i + len - 1 <= n; i ++) { // 遍历左端点
            int j = i + len - 1; // 右端点
            f[i][j] = 1e8; // 要取最小值,所以先给个很大的数
            for (int k = i; k < j; k ++) { // 至少两堆,所以k != j
                f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + s[j] - s[i - 1]);
            }
        }
    }
    
    cout << f[1][n] << endl;
    return 0;
}

2. 整数划分(计数类DP)

2.1 题目描述

一个正整数n可以表示成若干个正整数之和,给定一个正整数n, 求n共有多少种不同的划分方法。

2.2 思路

可以看成容量是N的背包,物品的体积1-N, 每种物品无限个,恰好装满背包的方案数。完全背包问题。

// f[i][j] = f[i - 1][j] + f[i - 1][j - i] + f[i - 1][j - i * 2] + ... + f[i - 1][j - i * s]
// f[i][j - i] =            f[i - 1][j - i] + f[i - 1][j - i * 2] + ... + f[i - 1][j - i * s]
// f[i][j] = f[i - 1][j] + f[i][j - i]
// f[j] = f[j] + f[j - i]


2.3 代码

#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;
}
#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;
}

3.计数问题(数位统计DP)

3.1 题目描述

给定两个整数a和b,求a和b之间的所有数字中0~9出现次数。

3.2 思路

分情况讨论:

  • [a, b]区间内,求0~9每个数出现的次数
  • count(n, x) 1~n中x出现的次数 x取值范围[a, b]
  • count(b, x) - count(a - 1, x)

假设区间[1, n], x = 1, abcdefg,分别求出每一位上出现的次数。
求1在第4位上出现的次数。1 <= xxx1yyy <= abcdef

  • xxx 位于区间 [001, abc - 1], yyy取值范围[000, 999], 总共取法:abc * 1000
  • xxx = abc
    • d = 0, abc1yyy > adc0efg, 方案数:0
    • d = 1, yyy = [000, efg], 方案数:efg+1
    • d > 1, yyy = [000, 999] 方案数:1000

3.3 代码

#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

/*
001~abc-1, 999
abc:
    1. num[i] < x, 0 
    2. num[i] == x, 0~efg
    3. num[i] > x, 0~999
*/

int get(vector<int> num, int l, int r) {
    int res = 0;
    for (int i = l; i >= r; i --)
        res = res * 10 + num[i];
    return res;
}

int power10(int x) {
    int res = 1;
    while (x --) 
        res *= 10;
    return res;
}

int count(int n, int x) { //  1~n中x出现的次数
    if (!n) { // n为0, 1~0中x 出现次数0。 
        return 0;
    }
    
    // 把n各个位数字存起来
    vector<int> num;
    while (n) {
        num.push_back(n % 10);
        n /= 10;
    }
    n = num.size();
    
    int res = 0;
    for (int i = n - 1 - !x; i >= 0; i --) { // !x 表示对等于0的情况进行特判 与if (!x) 对应 
        if (i < n - 1) { // 第一种情况 只有当i < n - 1
            res += get(num, n - 1, i + 1) * power10(i);
            if (!x) res -= power10(i); // x为0 少一个数 原本应该是 (get(num, n - 1, i + 1) - 1) * power10(i);
            
        }
        if (num[i] == x) res += get(num, i - 1, 0) + 1; // 情况二
        else if (num[i] > x) res += power10(i); // 情况三 
    }
    
    return res;
}

int main() {
    int a, b;
    
    while (cin >> a >> b, a) {
        if (a > b) swap(a, b);
        
        for (int i = 0; i <= 9; i ++) {
            cout << count(b, i) - count(a - 1, i) << ' '; // 前缀和
        }
        cout << endl;
    }
    
    return 0;
}

4. 蒙德里安的梦想(状态压缩DP)

4.1 题目描述

求把NM的棋盘分割成若干个12的长方形,有多少种方案。

4.2 思路

核心:

  • 先放横着的方块,再放竖着的方块。
  • 总方案数 = 只放横着的小方块的合法的方案数
  • 如何判断当前方案是否合法?所有剩余位置,能否填充满竖着的小方块。可以按列来看,每一列内部所有连续的空着的小方块,需要是偶数个。

考虑按列摆放,某列的各行用0或1表示摆放状态。
如果某行是1,表示横放,并且向下一列伸出;
如果某行是0,表示竖放,或者由前一列伸出。

image.png

  • 状态表示:f[i, j]表示摆放第i列,状态为j时的方案数。
  • 状态转移:

f [ i − 1 , k ] − > f [ i , j ] f[i - 1, k] -> f[i, j] f[i1,k]>f[i,j]

  • 状态计算:

f [ i , j ] = ∑ f [ i − 1 , k ] f[i, j] = \sum f[i - 1, k] f[i,j]=f[i1,k]

  • 初始值: f[0, 0] = 1, 其他为0
  • 目标值: f[m, 0]

image.png

// 预处理:判断合并列的状态i是否合法
// 如果合并列的某行是1表示横放,是0表示竖放
// 如果合并列不存在连续奇数个0,即为合法状态
for (int i = 0; i < 1 << n; i ++) { // 2^n个状态进行枚举
  st[i] = true;
  int cnt = 0; // 记录合并列中连续0的个数
  for (int j = 0; j < n; j ++) { // 移位 n次移位
    if (i >> j & 1) { // 如果该位是1
      if (cnt & 1) { // 如果连续0的个数是奇数
        st[i] = false;
        break; // 不合法 跳出
      }
    } else {
      cnt ++; // 如果是0, 记录0的个数
    }
  }
  if (cnt & 1) st[i] = false; // 处理高位0的个数 高位有奇数个0 非法
}
// 状态计算 
memset(f, 0, sizeof f);
f[0][0] = 1; // 第0列不横放是一种合法的方案
for (int i = 1; i <= m; i ++) { // 枚举列
	for (int j = 0; j < 1 << n; j ++) { // 状态:枚举第i列的状态 
		for (int k = 0; k < 1 << n; k ++) { // 状态:枚举第i - 1列的状态 
			// 两列状态兼容: 不出现重叠的1 不出现连续的奇数个0
			if ((j & k) == 0 && st[j | k]) {
				f[i][j] += f[i - 1][k];
			}
		}
	} 
}
cout << f[m][0] << endl;

image.png

4.3 代码

朴素版本写法

#include <bits/stdc++.h>

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) {
        for (int i = 0; i < 1 << n; i ++) { // 对所有状态进行枚举
            st[i] = true;
            int cnt = 0; // 记录合并列中连续0的个数
            for (int j = 0; j < n; j ++) { // 分别对每一位进行判断
                if (i >> j & 1) { // 如果该位是1
                    if (cnt & 1) { // 如果连续0的个数是奇数 不合法
                        st[i] = false;
                        break;
                    }
                } else {
                    cnt ++; // 记录0的个数
                }
            }
            if (cnt & 1) { // 处理高位0 奇数个0 非法 比如4 0100 上面内层for循环结束st[i]仍未true 需要对高位0进行判断
                st[i] = false;
            }
        }
        // 状态计算
        memset(f, 0, sizeof f);
        f[0][0] = 1;
        for (int i = 1; i <= m; i ++) { // 枚举列
            for (int j = 0; j < 1 << n; j ++) { // 第i列状态
                for (int k = 0; k < 1 << n; k ++) { // 第i - 1列状态
                    // 不出现重叠的1  不出现连续的奇数个0
                    if ((j & k) == 0 && st[j | k]) {
                        f[i][j] += f[i - 1][k];
                    }
                }
            }
        }
        cout << f[m][0] << endl;
    }
    return 0;
}

去除无效状态的优化写法

#include <bits/stdc++.h>

using namespace std;

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

int n, m;

long long f[N][M];  // 所有方案组合数
bool st[M]; // 符合条件的状态
vector<int> state[M]; 
int main() {
    while (cin >> n >> m, n || m) {
        for (int i = 0; i < 1 << n; i ++) { // 对所有状态进行枚举
            st[i] = true;
            int cnt = 0; // 记录合并列中连续0的个数
            for (int j = 0; j < n; j ++) { // 分别对每一位进行判断
                if (i >> j & 1) { // 如果该位是1
                    if (cnt & 1) { // 如果连续0的个数是奇数 不合法
                        st[i] = false;
                        break;
                    }
                } else {
                    cnt ++; // 记录0的个数
                }
            }
            if (cnt & 1) { // 处理高位0 奇数个0 非法 比如4 0100 上面内层for循环结束st[i]仍未true 需要对高位0进行判断
                st[i] = false;
            }
        }
        for (int i = 0; i < 1 << n; i ++) {
            state[i].clear();
            for (int j = 0; j < 1 << n; j ++) {
                if ((i & j) == 0 && st[i | j]) {
                    state[i].push_back(j);
                }
            }
        }
        // 状态计算
        memset(f, 0, sizeof f);
        f[0][0] = 1;
        for (int i = 1; i <= m; i ++) { // 枚举列
            for (int j = 0; j < 1 << n; j ++) { // 第i列状态
                for (auto k : state[j]) {
                    f[i][j] += f[i - 1][k];
                }
                
            }
        }
        cout << f[m][0] << endl;
    }
    
    
    return 0;
}

5. 最短Hamilton路径(状态压缩DP)

5.1 题目描述

给定一张n个点的带权无向图,点从0~n-1标号,求起点0到终点n-1的最短Hamilton路径。Hamilton路径的定义是从0到n-1不重不漏地经过每个点恰好一次。

5.2 思路

比如有5个点,分别是0,1,2,3,4,5。其中有以下两种路径:

  • case 1: 0->1->2->3->4
  • case 2:0->2->1->3->4

这两种情况下,计算点0到点3的路径时,不必关心经过的点的顺序,只需要两种路径中的较小值。所以只用记录两个属性:当前经过的点,当前到了哪个点。
用二进制来表示要走的所有情况的路径,假设用i来代替,
例如走0,1,2,4这三个点,则表示:10111;走0,2,3这三个点:1101

  • 状态表示:f[i][j]
  • 集合:所有从0走到j, 走过的所有点是i(i代表所有状态)的所有路径
  • 属性:min
  • 状态计算:假设当前从k到j转移。走到点k的路径不能经过点j。状态转移方程为:

f [ i ] [ j ] = m i n ( f [ i ] [ j ] , f [ i ∧ ( 1 < < j ) ] [ k ] + w [ k ] [ j ] ) f[i][j] = min(f[i][j], f[i\land(1 <<j)][k] + w[k][j]) f[i][j]=min(f[i][j],f[i(1<<j)][k]+w[k][j])
i ∧ ( 1 < < j ) i \land (1 <<j) i(1<<j)表示i的第j位改变后的值,即:

  • 如果第j为时1,那么变为0
  • 如果第j位为0,那么变为1
  • 状态转移方程:

f [ i ] [ j ] = m i n ( f [ i ] [ j ] , f [ i ∧ ( 1 < < j ) ] [ k ] + w [ k ] [ j ] ) f[i][j] = min(f[i][j], f[i\land(1 <<j)][k] + w[k][j]) f[i][j]=min(f[i][j],f[i(1<<j)][k]+w[k][j])

5.3 代码

#include <bits/stdc++.h>
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) { // i的第j位为 1 才有意义
                for (int k = 0; k < n; k ++) { // 枚举所有的第k位
                    if (i ^ (1 << j) >> k & 1) { // i的状态位中除去j这一位的状态
                        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;
}

6.没有上司的舞会(树形DP)

6.1 题目描述

某大学N名职员,编号1~N。父节点是子节点的直接上司。每个职员有一个快乐指数,用Hi给出。现要召开一场周年庆宴会,不过没有职员愿意和直接上司一起参会。满足这个前提下,使得所有参会职员的快乐指数总和最大,求这个最大值。

6.2 思路


核心:

  • 选择当前节点,则所有儿子不能选
  • 不选当前节点u, 则所有儿子既可以选也可以不选

6.3 代码

#include <bits/stdc++.h>
using namespace std;

const int N = 6010;

int n;
int happy[N]; // 高兴度
int f[N][2]; // 选 不选 两种状态表示集合
int h[N], e[N], ne[N], idx; // 链表 模拟建树
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]; // 选了当前节点u
    for (int i = h[u]; ~i; i = ne[i]) { // 遍历树
        int j = e[i];
        dfs(j); // 回溯
        f[u][0] += max(f[j][1], f[j][0]); // 所有以u为根的子树中选择,不选u这个点的方案
        f[u][1] += f[j][0]; // 所有以u为根的子树中选择,选u这个点的方案 
    }
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i ++)
        cin >> happy[i]; 
    memset(h, -1, sizeof h);
    for (int i = 1; i < n; i ++) {
        int a, b;
        cin >> a >> b;
        has_father[a] = true; // a有父节点b 
        add(b, a); // 建树
    }
    
    int root = 1;
    while (has_father[root]) root ++; // 找根结点
    dfs(root); // 从根结点开始搜索
    printf("%d\n", max(f[root][0], f[root][1]));
    
    return 0;
}

7.滑雪(记忆化搜索)

7.1 题目描述

R行C列的矩形网格滑雪场,一个人从滑雪场中的某个区域出发,每次可以上下左右任意一个方向滑动一个单位距离。能够滑动到相邻区域前提是该区域的高度低于自己目前所在区域的高度。求最长滑雪轨迹。

7.2 思路


7.3 代码

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 310;

int n, m;
int f[N][N];
int h[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; // 赋初始值1
    for (int i = 0; i < 4; i ++) { // 四个方向
        int a = x + dx[i];
        int b = y + dy[i];
        if (a >= 1 && a <= n && b >= 1 && b <= m && h[x][y] > h[a][b]) {
            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;
}

参考

acwing 动态规划专题

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值