动态规划-计数、数位统计、状态压缩、树形、记忆化搜索Dp

一、前言

在上几篇博客中,我们介绍了Dp中较为简单的几种分类:背包问题、线性Dp、区间Dp。这篇博客中将会介绍较为困难的Dp分类:计数类Dp、数位统计Dp、状态压缩Dp、树形Dp、记忆化搜索

二、计数类Dp-整数划分

1.题目描述

一个正整数 n 可以表示成若干个正整数之和,形如:n=n1+n2+…+nk,其中 n1≥n2≥…≥nk,k≥1。

我们将这样的一种表示称为正整数 n 的一种划分。

现在给定一个正整数 n,请你求出 n 共有多少种不同的划分方法。

输入格式

共一行,包含一个整数 n。

输出格式

共一行,包含一个整数,表示总划分数量。

由于答案可能很大,输出结果请对 109+7 取模。

数据范围

1≤n≤1000

输入样例:
5
输出样例:
7

2.完全背包算法

  • 将该题转化成完全背包问题,即将1…n这n个数填空一个背包,使得背包质量刚好为n,且每个数可以无限用。f[i][j]表示用1…i这几个数刚好拼凑成j的方法总数
  • 由于是刚好为n,所以要用等号,而非一般的背包问题用max或者min求最优解
  • f[i][j]=f[i-1][j]+f[i-1][j-i]+f[i-1][j-2i]+f[i-1][j-3i]+…+f[i-1][j-si]
  • f[i][j-i]=f[i-1][j-i]+f[i-1][j-2i]+f[i-1][j-3i]+…+f[i-1][j-si]
  • 优化:f[i][j]=f[i-1][j]+f[i][j-i]
  • 化为一维:f[j]=f[j]+f[j-i]
代码
#include<iostream>
#include<algorithm>

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

3.其他算法

  • 状态表示:f[i][j]表示所有总和为i,并且恰好表示成j个数的和的方案总数
  • 状态计算:可以把f[i][j]分为两种情况,第一种是最小值是1,第二种是最小值大于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]
  • 得结果需要累加:ans=f[n][1]+f[n][2]+f[n][2]+…+f[n][n]
代码
#include<iostream>
#include<algorithm>

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-计数问题

1.题目描述

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

例如,a=1024,b=1032,则 a 和 b 之间共有 99 个数如下:

1024 1025 1026 1027 1028 1029 1030 1031 1032

其中 0 出现 10 次,1 出现 10 次,2 出现 7 次,3 出现 3 次等等…

输入格式

输入包含多组测试数据。

每组测试数据占一行,包含两个整数 a 和 b。

当读入一行为 0 0 时,表示输入终止,且该行不作处理。

输出格式

每组数据输出一个结果,每个结果占一行。

每个结果包含十个用空格隔开的数字,第一个数字表示 0 出现的次数,第二个数字表示 1 出现的次数,以此类推。

数据范围

0<a,b<100000000

输入样例:
1 10
44 497
346 542
1199 1748
1496 1403
1004 503
1714 190
1317 854
1976 494
1001 1960
0 0
输出样例:
1 2 1 1 1 1 1 1 1 1
85 185 185 185 190 96 96 96 95 93
40 40 40 93 136 82 40 40 40 40
115 666 215 215 214 205 205 154 105 106
16 113 19 20 114 20 20 19 19 16
107 105 100 101 101 197 200 200 200 200
413 1133 503 503 503 502 502 417 402 412
196 512 186 104 87 93 97 97 142 196
398 1375 398 398 405 499 499 495 488 471
294 1256 296 296 296 296 287 286 286 247

2.算法

  • 状态表示:count[n][x]表示前n个数中x的出现次数,用前缀和表示出结果
  • 状态计算:分类讨论
  • 注意前导0的不可取
代码
#include <iostream>
#include <cstring>
#include <algorithm>
#include <vector>

using namespace std;

int get(vector<int> num,int l,int r)//因为我们举的分类中,有需要求出一串数字中某个区间的数字,例如abcdefg有一个分类需要求出efg+1
{
    int res=0;
    for(int i=l;i>=r;i--) res=res*10+num[i];//这里从小到大枚举是因为下面count的时候读入数据是从最低位读到最高位,那么此时在num里,最高位存的就是数字的最低位,那么假如我们要求efg,那就是从2算到0
    return res;
}

int power10(int i)//这里有power10是因为有一个分类需要求得十次方的值,例如abc*10^3
{
    int res=1;
    while(i--) res*=10;
    return res;
}

int count(int n,int x)
{
    vector<int> num;//num用来存储数中每一位的数字
    while(n)
    {
        num.push_back(n%10);//get里有解释
        n/=10;
    }
    n=num.size();//得出他的长度
    int res=0;
    for(int i=n-1-!x;i>=0;i--)//这里需要注意,我们的长度需要减一,是因为num是从0开始存储,而长度是元素的个数,因此需要减1才能读到正确的数值,而!x出现的原因是因为我们不能让前导零出现,如果此时需要我们列举的是0出现的次数,那么我们自然不能让他出现在第一位,而是从第二位开始枚举
    {
        if(i<n-1)//其实这里可以不用if判断,因为for里面实际上就已经达成了if的判断,但为了方便理解还是加上if来理解,这里i要小于n-1的原因是因为我们不能越界只有7位数就最高从七位数开始读起
        {
            res+=get(num,n-1,i+1)*power10(i);//这里就是第一个分类,000~abc-1,那么此时情况个数就会是abc*10^3,这里的3取决于后面efg的长度,假如他是efgh,那么就是4
            //这里的n-1,i-1,自己将数组列出来然后根据分类标准就可以得出为什么l是n-1,r是i-1
            if(!x) res-=power10(i);//假如此时我们要列举的是0出现的次数,因为不能出现前导零,这样是不合法也不符合我们的分类情况,例如abcdefg我们列举d,那么他就得从001~abc-1,这样就不会直接到efg,而是会到0efg,因为前面不是前导零,自然就可以列举这个时候0出现的次数,所以要减掉1个power10
        }
        //剩下的这两个就直接根据分类标准来就好了
        if(num[i]==x) res+=get(num,i-1,0)+1;
        else if(num[i]>x) res+=power10(i);
    }
     return res;//返回res,即出现次数
}

int main()
{
    int a,b;
    while(cin>>a>>b,a)//读入数据,无论a,b谁是0,都是终止输入,因为不会有数字从零开始(a,b>0)
    {
        if(a>b) swap(a,b);//因为我们需要从小到大,因此如果a大于b,那么就得交换,使得a小于b
        for(int i=0;i<=9;i++)//列举a和b之间的所有数字中 0∼9的出现次数
        cout<<count(b,i)-count(a-1,i)<<' ';//这里有点类似前缀和,要求a和b之间,那么就先求0到a i出现的次数,再求0到b i出现的次数,最后再相减就可以得出a和b之间i出现的次数
        cout<<endl;
    }
    return 0;
}

四、状态压缩Dp-蒙德里安的梦想

1.题目描述

求把 N×M 的棋盘分割成若干个 1×2 的长方形,有多少种方案。

例如当 N=2,M=4时,共有 5 种方案。当 N=2,M=3 时,共有 3 种方案。

如下图所示:

2411_1.jpg

输入格式

输入包含多组测试用例。

每组测试用例占一行,包含两个整数 N 和 M。

当输入用例 N=0,M=0 时,表示输入终止,且该用例无需处理。

输出格式

每个测试用例输出一个结果,每个结果占一行。

数据范围

1≤N,M≤11

输入样例:
1 2
1 3
1 4
2 2
2 3
2 4
2 11
4 11
0 0
输出样例:
1
0
1
2
3
5
144
51205

2.算法

  • 状态压缩Dp的核心思路就是将状态转化为二进制
  • 此题行向摆放确定,则列向摆放确定,所以我们只需要考虑行向即可
  • 状态表示:f[i][j]表示第i列的状态为j的所有方法数
  • j二进制表示状态:j表示每一行是否为1*2条形块的第二个正方形元素
  • j表示当前列状态,k表示上一列状态,存在必须满足的条件:(1)(j&k)== 0表示不会形成有重合部分的1*2条形块(2)j|k不存在连续奇数个0,表示第k层不会有奇数个空白列向元素(不符合摆放满标准)
代码
#include <cstring>
#include <iostream>
#include <algorithm>

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 ++ )
        {
            int cnt = 0;
            st[i] = true;
            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;
        }

        memset(f, 0, sizeof f);
        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 ++ )
                    if ((j & k) == 0 && st[j | k])
                        f[i][j] += f[i - 1][k];

        cout << f[m][0] << endl;
    }
    return 0;
}

五、状态压缩Dp-最短Hamilton路径

1.题目描述

给定一张 n 个点的带权无向图,点从 0∼n−1 标号,求起点 0 到终点 n−1 的最短 Hamilton 路径。

Hamilton 路径的定义是从 00 到 n−1 不重不漏地经过每个点恰好一次。

输入格式

第一行输入整数 n。

接下来 n 行每行 n 个整数,其中第 i 行第 j 个整数表示点 i 到 j 的距离(记为 a[i,j])。

对于任意的 x,y,z,数据保证 a[x,x]=0,a[x,y]=a[y,x],并且 a[x,y]+a[y,z]≥a[x,z]。

输出格式

输出一个整数,表示最短 Hamilton 路径的长度。

数据范围

1≤n≤20
0≤a[i,j]≤107

输入样例:
5
0 2 4 5 1
2 0 6 5 3
4 6 0 8 3
5 5 8 0 5
1 3 3 5 0
输出样例:
18

2.算法

  • 状态表示:f[i][j]所有从0走到j,且走过的点二进制(和上一题相似)为i的方案集合中,路径长度最小的值
  • 状态计算:根据j的上一个点是谁来进行划分
代码
#include <cstring>
#include <iostream>
#include <algorithm>

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 >> k & 1)
                        f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]);

    cout << f[(1 << n) - 1][n - 1];

    return 0;
}

六、树形Dp-没有上司的舞会

1.题目描述

Ural 大学有 N 名职员,编号为 1∼N。

他们的关系就像一棵以校长为根的树,父节点就是子节点的直接上司。

每个职员有一个快乐指数,用整数 Hi 给出,其中 1≤i≤N。

现在要召开一场周年庆宴会,不过,没有职员愿意和直接上司一起参会。

在满足这个条件的前提下,主办方希望邀请一部分职员参会,使得所有参会职员的快乐指数总和最大,求这个最大值。

输入格式

第一行一个整数 N。

接下来 N 行,第 i 行表示 i 号职员的快乐指数 Hi。

接下来 N−1 行,每行输入一对整数 L,K,表示 K 是 L 的直接上司。(注意一下,后一个数是前一个数的父节点,不要搞反)。

输出格式

输出最大的快乐指数。

数据范围

1≤N≤6000,
−128≤Hi≤127

输入样例:
7
1
1
1
1
1
1
1
1 3
2 3
6 4
7 4
4 5
3 5
输出样例:
5

2.算法

  • 状态表示:f[u][0]表示以u为根节点且不包括u的所有方法数,f[u][1]表示以u为根节点且包括u的所有方法数
  • 状态计算:用si表示u的下一层中所有节点(1)f[u][0]=max(f[si][0],f[si][1])的累加(2)f[u][1]=f[si][0]的累加
  • 需要使用邻接表表示树,还需要使用到dfs的内容
代码
#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 6010;

int n;
int h[N], e[N], ne[N], idx;
int happy[N];
int f[N][2];
bool has_fa[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; i = ne[i])
    {
        int j = e[i];
        dfs(j);

        f[u][1] += f[j][0];
        f[u][0] += max(f[j][0], f[j][1]);
    }
}

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);
        add(b, a);
        has_fa[a] = true;
    }

    int root = 1;
    while (has_fa[root]) root ++ ;

    dfs(root);

    printf("%d\n", max(f[root][0], f[root][1]));

    return 0;
}

七、记忆化搜索-滑雪

给定一个 R 行 C 列的矩阵,表示一个矩形网格滑雪场。

矩阵中第 i 行第 j 列的点表示滑雪场的第 i 行第 j 列区域的高度。

一个人从滑雪场中的某个区域内出发,每次可以向上下左右任意一个方向滑动一个单位距离。

当然,一个人能够滑动到某相邻区域的前提是该区域的高度低于自己目前所在区域的高度。

下面给出一个矩阵作为例子:

 1  2  3  4 5

16 17 18 19 6

15 24 25 20 7

14 23 22 21 8

13 12 11 10 9

在给定矩阵中,一条可行的滑行轨迹为 24−17−2−1。

在给定矩阵中,最长的滑行轨迹为 25−24−23−…−3−2−1,沿途共经过 25 个区域。

现在给定你一个二维矩阵表示滑雪场各区域的高度,请你找出在该滑雪场中能够完成的最长滑雪轨迹,并输出其长度(可经过最大区域数)。

输入格式

第一行包含两个整数 R 和 C。

接下来 R 行,每行包含 C 个整数,表示完整的二维矩阵。

输出格式

输出一个整数,表示可完成的最长滑雪长度。

数据范围

1≤R,C≤300,
0≤矩阵中整数≤10000

输入样例:
5 5
1 2 3 4 5
16 17 18 19 6
15 24 25 20 7
14 23 22 21 8
13 12 11 10 9
输出样例:
25

2.算法

  • 状态分析:f[i][j]表示从(i,j)开始的所有滑雪方案集合中,取路径长度最大的值
  • 状态计算:分为四种方案,下一步是往四个方向哪一个方向走
代码
#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 310;

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

int dx[4] = {-1, 0, 1, 0}, 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 <= m && g[x][y] > g[a][b])
            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", &g[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));

    printf("%d\n", res);

    return 0;
}

八、尾声

本篇博客介绍的几道题都是Dp中思路较为困难的题目,但是由于时间关系,本篇博客算法部分,思路介绍并非详细,在一段时间后,将会对每道题分别写详细题解

  • 19
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值