数位dp是一个在竞赛中非常常用的思想。本文将从一道经典的竞赛题目出发,详解数位dp的思路及相关知识点的用法,非常适合初学者,简单易懂,知识点涵盖全面。
目录
题目
预备知识1——判断互质
两个数互质即两个数的最大公约数为1
1.如果是c++17及以上版本,可以直接用内置函数gcd(a,b)判断
#include <algorithm>
if(gcd(x,y)==1) flag=true;
2.如果版本不够,可以手写gcd,用辗转相除法实现
(1)原理
可以由数学推导得到,这里不作过多解释
(2)代码实现
//辗转相除法计算最大公约数
int gcd(int a, int b) {
while (b != 0) {
int temp = a % b;
a = b;
b = temp;
}
return a;
}
用递归实现也可以
3.注意
(1)1和任何正整数互质
(2)除了1以外,一个数和它本身的最大公约数等于它本身,因此一个数和它本身不互质
预备知识2 ——二进制位运算存储状态
1.原理
使用一个整数的每一位来存储一个布尔状态,整数的每一位可以表示一个状态
2.操作
- 设置某一位:
x |= (1 << pos)
将x
的第pos
位设置为 1【即1后面有pos个0,因为dp是从第0为开始的】 - 清除某一位:
x &= ~(1 << pos)
将x
的第pos
位设置为 0 - 切换某一位:
x ^= (1 << pos)
将x
的第pos
位取反 - 获取某一位的值:
(x >> pos) & 1
获取x
的第pos
位的值
思路详解
一眼望去是一个非常清晰的搜索题,保证结点不重复+访问全部结点,但是用搜索做的话跑不出来答案,因为时间复杂度太高了,最多可以达到21!
搜索题用暴力做不出来,一般都是考虑用dp优化
1. 如何设置dp数组?
首先,很容易看出来这道题是一个典型的不同状态对应不同的访问方案(即如何来的),所以可以用dp。进一步思考,当前状态由哪些属性构成?
(1)当前所在点的位置
(2)当前已访问的状态
【tips:对于路径行进问题的dp,基本都是由这两个属性构成:当前点+已访问】
所以,设置dp[i][j]
- i:当前访问到第i个结点
- j:二进制,000...1..000,为1的数位表示已经访问过的
【j的空间最小要保证2的21次方,因为至少有21位】
2. 如何设置状态转移方程?
其实这道题就可以看作爬楼梯的变体版,当前新增状态和之前已经“爬过的”状态有关,但是它不像爬楼梯那样只和前一次或两次的状态有关,状态的位置是多变的
状态的位置是多变的,所以这也是这道题考虑状态压缩(数位dp)的原因
但其实本质是一样的,只用看当前点的当前状态可以由哪些上一步点的上一步状态得到,就+=上一步的方案数就可以了
3.状态转移核心流程是什么样的?
for(all 当前状态j)
for(all 当前节点i)
if(当前状态不包括当前点)continue;//说明当前状态和当前点无关
for(当前结点i的所有邻接点k)
{
if(非邻接) continue;
t=可能的上一步状态;//当前状态j去掉当前结点i得到
if(上一步状态不包含上一步邻接点k) continue;
dp[i][j]+=dp[k][t];
}
4.当前状态j的边界条件是什么?
所有结点均已访问
j<(1<<21):即j的max值为1<<21-1
注意:1<<21是1向左边移动了21位,即1的后面有21个0(而不是1位于第21位上,后面只有20个0),所以1<<21的结果是1000000000000000000000,则
(1 << 21) - 1的结果是
0111111111111111111111
,即 21 个 1
代码
#include <iostream>
typedef long long ll;//使用typedef定义
//或者写作#define int long lon,把long long类型写作int
//#define int long long
using namespace std;
int mp[30][30]={0};
const int M=25;
const int N=1<<25;
ll dp[M][N];
//爬楼梯的变体版,当前新增状态和之前已经“爬过的”状态有关
//不像爬楼梯那样只和前一次或两次的状态有关
//状态的位置是多变的,考虑状态压缩
//dp[i][j...]:i:当前访问到i号楼,j:二进制表示访问状态
//gcd
int gcd(int a,int b)
{
while(b!=0)
{
int tmp=a%b;
a=b;
b=tmp;
}
return a;
}
int main()
{
for(int i=1;i<=21;i++)
{
for(int j=1;j<i;j++)
if(gcd(i,j)==1)mp[i][j]=mp[j][i]=1;
}
//初始化起点状态
dp[1][1]=1;
//遍历所有状态
for(int j=1;j<(1<<21);j++)
{
//遍历所有当前点
for(int i=1;i<=21;i++)
{
//当前状态不包含当前点
if(((j>>(i-1))&1)==0) continue;
//遍历当前点的所有邻接点
//看可以从哪些状态得到当前状态
for(int k=1;k<=21;k++)
{
//不邻接
if(mp[i][k]==0) continue;
//邻接
//上一个状态
int t=j-(1<<(i-1));
//如果上一个状态不包含点k(易漏判)
if(((t>>(k-1))&1)==0) continue;
//可以由上一个节点的上一个状态得来
dp[i][j]+=dp[k][t];
}
}
}
//1和所有数互质
//肯定可以重新回到起点
ll ans=0;
for(int i=1;i<=21;i++) ans+=dp[i][(1<<21)-1];
cout<<ans;
return 0;
}
1.注意1:移动时的数位问题
判断第i位时应该是移动i-1位,因为位是从0开始的
j<(1<<21)
2.注意2:最后的答案为什么是这样来的
ll ans=0;
for(int i=1;i<=21;i++) ans+=dp[i][(1<<21)-1];
cout<<ans;
因为答案成立需要满足两个条件:
(1)所有点都已经访问到
(2)最终回到起点
(1<<21)-1表示所有的状态都已经访问到了,只差回到起点就可以了
而1和所有数互质,所以和所有节点都有通路,肯定可以重新回到起点
3.注意3:数据类型重新定义问题
(1)方法一:typedef
typedef long long ll;//使用typedef定义
注意:别忘了;
(2)方法二:define
#define ll long long
注意:①没有;②重新定义后的名字是写在前面
还有一种常见的用法就是, #define int long long,因为我们一般都是写int比较顺手,索性就把int全都改成long long,但非常不建议这么做,一是本来没有必要用long long的全都用了longlong导致空间的浪费,严重的情况下还会导致报错,二是如果这样用了函数的主入口就会被识别成long long main(),会导致编译错误,主入口只能用signed main()代替int main()
4.注意4:运算符优先级问题
对于表达式 ((j>>(i-1))&1)==0,如果不加括号,默认的优先级是什么样的呢?
-
位移运算符
>>
:优先级较高,先于按位与运算符&
和比较运算符==
执行。 -
按位与运算符
&
:优先级次之,先于比较运算符==
执行。 -
比较运算符
==
:优先级最低,最后执行
但是没有必要刻意去记运算符的优先级,很容易出错,所以在写代码时我们统一加上括号