动态规划(下)
文章目录
这是接上一期的dp再深入讲解的,如果还没有看过可以戳这里~!
好啦,那我们就继续开始深入了解dp吧~!
四.背包问题—线性dp
1. 0/1背包
①小明的背包1
思路分析:
①确定dp数组及其下标:
分析题目中变量:
- 当前有 i件物品(将N件物品依次取出);
- 当前给定背包限定的最大容量为 j(0~V);
- 当前加入的物品 i 的价值 w;
- 当前加入的物品 i 的体积 v;
正常情况下,dp数组为四维数组,但我们可以通过命名方式进行降维,dp数组表示的是背包内物品的价值,我们可以将加入物品的价值 w 和加入物品的体积 v 分离开,分别变为一个单独的数组,因此:
😄 状态定义为:1.当前有i 件物品可选择; 2.当前的背包最大容量为 j
②确定状态转移方程:
当我们在可选范围内新增一个物品 i 时(增加一个物品实际上是加上了它的体积和重量,它的性质才是应该关注的点,所以这里增加i实际上顺带也增加了状态w[i]和v[i]),我们有两个选择——加或不加:
- 若不想加该物品(可能价值很低),保持之前一段:
dp[i][j]=dp[i-1][j]
- 若想要加入该物品(可能是价值很高),则要考虑是否能加入:
-
当前背包最大容量足够加入该物品时:
dp[i][j]=dp[i-1][j-v[i]]+w[i]
-
当前背包最大容量不足以加入该物品时,也只能选择继承之前的状态:
dp[i][j]=dp[i-1][j]
-
递推公式:
dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i]]+w[i])
③最后结果:
当可选背包数为N时,范围达到最大,递推到了所有背包容量情况下的值,所以结果为:max(dp[N])
但其实,第N+1列上的元素值,最终都会递推到dp[N][V]上,由于取的是max,所以最大值即为:dp[N][V]
图解算法:
💡 空间优化:由于状态i 只能由i-1递推而来,也就是说,其中一个状态严格满足步长为1地进行递推,所以,可以减少这一状态,将二维数组优化为一维数组
- 此时相当于虚化了i的实际意义,表示每增加一层i,取当前 j 位置上列的最大值
图解算法:
代码实现:
# 1.二维数组
N,V=map(int,input().split())
dp=[[0 for j in range(V+1)] for i in range(N+1)]
w=[0]*(N+1)
v=[0]*(N+1)
for i in range(1,N+1):
v[i],w[i]=map(int,input().split())
for i in range(1,N+1):
for j in range(0,V+1):
if j>=v[i]: # 如果能装进物品,更新
dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i]]+w[i])
else: # 如果装不进去,继承
dp[i][j] = dp[i-1][j]
print(dp[N][V])
# 2.空间优化——一维数组
N,V=map(int,input().split())
dp=[0 for _ in range(V+1)]
w=[0]*(N+1)
v=[0]*(N+1)
for i in range(1,N+1):
v[i],w[i]=map(int,input().split())
for i in range(1,N+1):
for j in range(V,-1,-1):
if j>=v[i]:
dp[j]=max(dp[j],dp[j-v[i]]+w[i])
print(dp[V])
②装箱问题
这里和上题类似,只是题目要求物品能放入的最大体积,因此dp数组的含义为装下物品的最大体积
💡 总结:题目要求的值即为dp数组的含义
代码实现:
V=int(input())
n=int(input())
dp=[0 for i in range(V+1)]
c=[0]*(n+1)
for i in range(1,n+1):
c[i]=int(input())
for i in range(1,n+1):
for j in range(V,-1,-1):
if j>=c[i]:
dp[j]=max(dp[j-c[i]]+c[i],dp[j])
print(V-dp[V])
③2022
思路分析:
将拆解2022为10个数的和转化为背包问题:
其中2022表示背包的最大容量,10个数表示物品个数,每个数的值表示物品价值
因此,我们可以确定状态:
😄 状态定义为:1.当前新增 i 这个数; 2.当前选定 j个数;3.当前和为 k
所以构造数组dp[i][j][k]——i表示加入的物品体积 j表示前j个物品 k表示物品容量总和
由于题目要求方案数,因此:这里dp数组表示方案数
①确定状态转移方程:
当加入一个新物品时,我们可以选择加或不加:
- 若不加该物品,保持之前一段的状态:
dp[i][j][k]=dp[i-1][j][k]
(也就是继承没有新加入i时的状态) - 若想要加入该物品,则要考虑是否能加入:
- 当能加入该物品时:
dp[i][j][k]=dp[i-1][j-1][k-i]
(等于不加 i时的状态) - 当加入该物品后超出总和时,也只能选择继承之前的状态:
dp[i][j][k]=dp[i-1][j][k]
- 当能加入该物品时:
因为要求方案数,所以为两种情况的总和:
d p [ i ] [ j ] [ k ] = d p [ i − 1 ] [ j ] [ k ] + d p [ i − 1 ] [ j − 1 ] [ k − i ] dp[i][j][k]=dp[i-1][j][k]+dp[i-1][j-1][k-i] dp[i][j][k]=dp[i−1][j][k]+dp[i−1][j−1][k−i]
②dp数组的初始化:
-
这里要注意它的初始化:dp[i][0][0]=1,因为当选定个数为0,和为0时,所选数字 i 可以为任意值,并且,我们由递推式理解:
dp[i][j][k]=dp[i-1][j][k]+dp[i-1][j-1][k-i]
假设当前加入数字1:dp[1][1][1]=dp[0][1][1]+dp[0][0][0]
表示不加数字1时,一个数字加起来就已经为1了(当然这里不存在,为0);和加上数字1时的情况数dp[0][0][0],因此,必须要对 j=k=0的条件初始化
🧐 注意:当我们增加一个数 i 时,我们实际上是要关注这个数的性质——这里 i 表示数的值
因此,我们同样可以进行空间优化:也就是将 i虚化,我们只需要关注它的值即可
图解算法:
代码实现:
# 1.三维数组
dp=[[[0]*2023 for j in range(11)] for i in range(2023)] # dp为三维数组
# 初始化
for i in range(2023):
dp[i][0][0] = 1
**# 因为要继承下一层的数,而i,j,k均从1开始,必定能装进一个1 否则将全为0
# 比如i=j=k=1时,可以装入一个,则dp[0][1][1]+dp[0][0][0]**
**# 表示不装这一个 表示装这一个
# 所以当j=k=0时 即取的个数和容量均为0(j-1=0,k-i=0)时 要将其值变为1 (这时表示装入了一个1体积的物体)**
for i in range(1,2023):
for j in range(1,11):
for k in range(1,2023):
if i>k:
dp[i][j][k]=dp[i-1][j][k]
else:
# **容量不变:放入的情况加上不放的情况**
dp[i][j][k]=dp[i-1][j][k]+dp[i-1][j-1][k-i]
print(dp[2022][10][2022])
# 2.空间优化
dp=[[0 for k in range(2023)] for j in range(11)]
dp[0][0]=1
for i in range(1,2023):
for j in range(10,0,-1):
for k in range(1,2023):
if i<=k:
dp[j][k]=dp[j-1][k-i]+dp[j][k]
print(dp[10][2022])
2.完全背包(高阶背包问题)
①小明的背包2
思路分析:
①确定dp数组的下标及其含义:
相较于0/1背包,这里增加了物品加入的数量这一状态,也就是在新加物品数量i和背包最大容量的基础上增加一个维数,但这里我们同样可以用命名方式降低这个维数k:
😄 状态定义为:1.新加入物品种类; 2.背包最大容量; (3.新加入物品选择加入的个数k)
因为 k≤ j / v[i],所以只需限定范围进行遍历,找到最大即可,无需加到dp数组的维数中
②确定状态转移方程:
和0/1背包一样,当加入一个物品时我们可以选择加或不加:
- 当不加该物品时,继承之前的状态:
dp[i][j]=dp[i-1][j]
- 当加入该物品时,则要考虑是否能加入——遍历所加物品的数量:
- 当前背包最大容量 j不足以加入该物品时(k=1):
dp[i][j]=dp[i-1][j]
- 当前背包最大容量 j足以加入k(≥1)个该物品时:
dp[i][j]=dp[i-1][j-k*c[i]]+k*w[i]
- 当前背包最大容量 j不足以加入该物品时(k=1):
递推公式:
d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − k c [ i ] ] + k ∗ w [ i ] ) dp[i][j]=max(dp[i-1][j],dp[i-1][j-kc[i]]+k*w[i]) dp[i][j]=max(dp[i−1][j],dp[i−1][j−kc[i]]+k∗w[i])
代码实现:
# 1.交替数组
def solve(n,C):
for i in range(1,n+1): **# 表示前n种物品**
for j in range(0,C+1): **# 表示容量为0-C**
dp[i][j]=dp[i-1][j] **# 继承上一行的数据**
for k in range(0,j//c[i]+1): **# 物品的个数改变 找到当前物品种类和容量下的最大价值**
dp[i][j]=max(dp[i-1][j],dp[i-1][j-k*c[i]]+k*w[i])
return dp[n][C]
n, C = map(int, input().split())
dp=[[0]*(C+1) for i in range(n+1)] **# 初始化全为0**
w=[0]*(n+1); c=[0]*(n+1)
for i in range(1,n+1):
c[i],w[i]=map(int,input().split())
print(solve(n,C))
# 2.空间优化
def solve(n,C):
for i in range(1,n+1):
**# c[i]为新增的物品容量 只需遍历到c[i] 前面的数只用继承上一行 因为不可能加进c[i]**
for j in range(C,c[i]-1,-1): # 记得反向
for k in range(0,j//c[i]+1): **# 当前能放的最大个数**
dp[j]=max(dp[j],dp[j-k*c[i]]+k*w[i])
return dp[C]
n, C = map(int, input().split())
dp=[0 for i in range(C+1)]
w=[0]*(n+1); c=[0]*(n+1)
for i in range(1,n+1):
c[i],w[i]=map(int,input().split())
print(solve(n,C))
注意:
- 剪枝:使用空间优化时,c[i]为新增的物品容量 只需遍历到c[i] 前面的数只用继承上一行 因为j<c[i]时不可能加进c[i]
- 加入的k为当前限定的背包容量下能加入的数量:j/c[i]
3.多重背包(高阶背包问题)
①多重背包问题
多重背包问题只是在完全背包的基础上限定了k的个数,思路类似
- 用dp数组求解
思路与完全背包一致:
def solve(n,C):
for i in range(1,n+1):
for j in range(C,c[i]-1,-1):
for k in range(0,m[i]+1): # 第i个物品最多只能有m[i]个**
if j>=k*c[i]
dp[j]=max(dp[j],dp[j-k*c[i]]+k*w[i])
return dp[C]
n, C = map(int, input().split())
dp=[0 for i in range(C+1)]
w=[0]*(n+1); c=[0]*(n+1); m=[0]*(n+1) # m[i]为第i个物品的最大数目
for i in range(1,n+1):
c[i],w[i],m[i]=map(int,input().split())
print(solve(n,C))
- 二进制拆分优化
💡 二进制拆分原理:任何一个整数都可以转换成一个若干个2^k数相加的形式——因为任意一个整数都可以转化为二进制数
即一个数字,我们可以按照二进制来分解为1 + 2 + 4 + 8 …… +2^n + 余数
由上述定理受启发:
- 我们可以将每种物品的多个物品分成几堆后看成一个
- 得到的这个”新“的物品(为k个原物品)容量:
v_new=k*v[i]
,价值:w_new=k*w[i]
因此转化 0/1背包 问题为:有 xn 件物品和一个容量是 C 的背包。每件物品只能使用一次,第 i 件物品的体积是 xc[i],价值是 xw[i] 求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大,输出最大价值
使用注意:
代码实现:
# 1.初始化
N=100010
n,V=map(int,input().split())
w=[0]*(n+1);v=[0]*(n+1);s=[0]*(n+1)
for i in range(1,n+1):
v[i],w[i],s[i]=map(int,input().split())
# 新价值 新容量
xw=[0]*(sum(s)+1);xv=[0]*(sum(s)+1)
# 2.二进制拆分
xn=0 # 二进制拆分后新物品总数
for i in range(1,n+1):
j=1
while j<=s[i]:
s[i]-=j # 减去已经拆分的容量
xn+=1
xv[xn]=j*v[i]
xw[xn]=j*w[i]
j<<=1 # 将二进制数左移一位 j+=2^(n-1)
if s[i]>0: # 最后一个新物品 个数为余数
xn+=1
xv[xn]=s[i]*v[i]
xw[xn]=s[i]*w[i]
# 3.滚动数组--->变为0/1背包
dp=[0]*N
for i in range(1,xn+1): # xn为新个数
for j in range(V,xv[i]-1,-1):
dp[j]=max(dp[j],dp[j-xv[i]]+xw[i])
print(dp[V])
五.状态压缩dp
💡 状态压缩:将n个数的状态压成一个二进制数,把一个状态压缩成数字里的一位,要处理题意里的状态之间的相互关系,必须使用位运算
1.状态压缩中的位运算
①为了更好的理解状态压缩dp,首先介绍位运算相关的知识:
-
按位与—’&’符号,x&y,会将两个十进制数在二进制下进行与运算,然后返回其十进制下的值
如:3(11) & 2(10)=2(10)
-
按位或—’|’符号,x|y,会将两个十进制数在二进制下进行或运算,然后返回其十进制下的值
如:3(11) | 2(10)=3(11)
-
按位异或—’^ ’符号,x^y,会将两个十进制数在二进制下进行异或运算,然后返回十进制下的值
如:3(11) ^ 2(10)=1(01)
-
移位—’<<’符号,左移操作,x<<2,将x在二进制下的每一位向左移动两位,最右边用0填充,x<<2相当于让x乘以4,相应的,’>>’是右移操作,x>>1相当于给x/2,去掉x二进制下的最有一位
②这四种运算在状压dp中有着广泛的应用,常见的应用如下:
-
判断一个数字x二进制下第i位是不是等于1
if ( ( ( 1 << ( i - 1 ) ) & x ) > 0)
将1左移i-1位,相当于制造了一个只有第i位上是1,其他位上都是0的二进制数。然后与x做与运算,如果结果>0,说明x第i位上是1,反之则是0
-
将一个数字x二进制下第i位更改成1
x = x | ( 1<<(i-1) )
-
把一个数字二进制下最靠右的第一个1去掉
x=x&(x-1)
-
初始化:将特定位置上的数字在二进制中表示为1
tmp=0;
for i in list:
x=i<<(i-1);
tmp | = x
注意:这里不能用tmp=tmp | x,此时是表示创建了一个新的对象 对象名也叫 tmp
-
构造二进制数,有n个1
N=(1<<n)-1
实例:
M种口味 N包糖果 一包K颗糖果
-
初始化:
要表示每一包糖果有哪些口味 可用二进制表示 如2,3,5—>10110
初始化二进制可用’移位‘操作: 如加入2—1<<(2-1) 10 | 0 = 10
加入3—1<<(3-1) 100 | 10 = 110
加入5—1<<(5-1) 10000 | 110 =10110 -
dp=[-1]*(1<<20) —口味数 M<=20
状态压缩每一包糖果 并使对应的二进制数字标记为1—dp[num]=1
将每包糖果对应的二进制数填入列表—长度为n -
当有一个存在的口味 对其进行判断:
- 与每一袋糖果进行按位或 —表示加入后的新口味
dp[newcase]=dp[i]+1
如果该口味没有出现过—标记该口味为当前的口味所需要的糖果袋数+1;
如果该口味出现过—但是 原来所需的糖果袋数 比 加入新糖果的袋数+1要大(因为新加入的糖果与其中的一袋糖果加起来就能达到相同的效果)
代码实现:
n,m,k=map(int,input().split())
li=[]
for i in range(n):
li.append(''.join(['%s'%i for i in input().split()])) # 将每包糖果以字符串形式表示
tot=(1<<m)-1 # 二进制上有m个1 表示有m种口味
dp=[-1]*(1<<20)
# 状态压缩
now=[0]*n
cnt=0
for i in li:
tmp=0
for j in i:
x=1 <<(int(j)-1) # 表示有第i种糖果
tmp|=x # 将每一包糖果转为二进制数
dp[tmp]=1 # 标记为1 表示已经存在此品种
now[cnt]=tmp # 储存n包糖果
cnt+=1
# dp
for i in range(1<<20):
if dp[i]!=-1: # 如果有这种品种
for j in range(n):
if dp[j]>n: continue # 不能超过总的糖果数(总共n包糖果)
newcase = i|now[j] # 将品种dp[i]与已有的每一包糖果做按位或--->得到新品种
if dp[newcase]==-1 or dp[newcase]>dp[i]+1: # 如果新品种没有出现过 或者 出现过 但没有现在的方案好
dp[newcase]=dp[i]+1 # 让该品种加一包糖果
print(dp[tot])
2.状态压缩与哈密尔顿回路
哈密尔顿问题:在一个正十二面体的二十个顶点上,标注了伦敦,巴黎,莫斯科等世界著名大城市,正十二面体的棱表示连接着这些城市的路线。要求游戏参与者从某个城市出发,把所有的城市都走过一次,且仅走过一次,然后回到出发点
哈密尔顿回路是指,从图中的一个顶点出发,沿着边行走,经过图的每个顶点,且每个顶点仅访问一次,之后再回到起始点的一条路径
区别:
- 哈密尔顿回路(Hamilton Loop)要求从起始点出发并能回到起始点,其路径是一个环
- 哈密尔顿路径(Hamilton Path)并不要求从起始点出发能够回到起始点,也就是说:起始顶点和终止顶点之间不要求有一条边。
如何求解路径长度?
暴力解法:枚举 n 个点的全排列,共 n !个全排列。一个全排列就是一条路径,计算这个全排列的路径长度,需要做 n 次加法,在所有路径中找最短的路径,总复杂度是 O ( n × n ! )
Hamilton问题是NP问题,没有多项式复杂度的解法,不过,用状态压缩DP求解,能把复杂度降低到 O ( n 2 × 2 n )
当 n= 20时, O(n2×2n) ≈ 4亿,比暴力法好很多
首先定义DP,设 S 是图的一个子集,用dp[S][j]表示“集合S内的最短Hamilton路径”,即从起点0出发经过 S 中所有点,到达终点 j时的最短路径;集合 S 中包括 j点。根据DP的思路,让 S从最小的子集逐步扩展到整个图,最后得到的 d p [ N ] [ n − 1 ]就是答案, N表示包含图上所有点的集合
①如何求 d p [ S ] [ j ]?
可以从小问题 S − j 递推到大问题S。其中 S − j 表示从集合 S中去掉j,即不包含 j 点的集合
②如何从 S − j递推到 S ?
设 k是 S−j中一个点,把从0到 j的路径分为两部分: (0→…→k)+(k→j)。以 k为变量枚举 S − j中所有的点,找出最短的路径,状态转移方程是:
dp[S][j] = min {dp [S−j] [k] + dist (j,k)}
若用状态压缩表示,则状态转移方程为:
d p [ S ] [ j ] = m i n d p [ S − ( 1 < < j ) ] [ k ] + d i s t [ j ] [ k ] dp[S][j] = min {dp[S-(1<<j)][k]+dist[j][k]} dp[S][j]=mindp[S−(1<<j)][k]+dist[j][k]
其中dist可表示图中两点之间的连通性或两点之间距离
其中 k 属于集合 S − j
集合 S 的初始情况只包含起点0,然后逐步将图中的点包含进来,直到最后包含所有的点,这个过程用状态转移方程实现
图解算法:
六.树形dp
💡 树形dp是一种dp思想,将dp建立在树状结构的基础上。它是先算子树然后进行合并,即:先遍历子树,遍历完之后把子树的值合并给父亲,即得到结果
树形dp的关键方法是dfs,从根节点出发,向子节点做深度优先搜索,并由其子节点的最优解合并得到该节点的最优解后又从叶子节点返回至父节点进行动态规划
1.树的价值(结点权值最大的子树)
结点上有值 边无权值
①生命之树
思路分析:
-
此树为一个无根树 要求权值最大的子树 以任意一点做树根都可以
-
dp[i] :表示以i为父结点的能达到最大权值的子树
-
如果该子节点的最大权值>0,则可以将它加入到父节点中以增加权值:
dp[u]+=dp[son]
-
如果子节点权值<0,则跳过
💡 递归思想:该父节点的最大权值==子节点的最大权值+父结点权值
-
-
二维数组 t:
建立关系网 将有关系的两个结点数字互相放在嵌套列表中,互存为子节点
-
用 dfs对每一个点进行递归:
根据关系网 满足条件 则以它为父节点再找它的子节点 找到最底层的叶子节点后 向上返回权值
若dp[v]>0,表示该子节点对权值有贡献:dp[u]+=dp[v]
在递归之后 更新最大值:ans=max(dp[u],ans)
图解算法:
如图即为最大的一棵子树
代码实现:
# dfs & dp
import sys
sys.setrecursionlimit(100000)
def dfs(v,u): # v为子节点 u为父节点
global ans
for s in t[v]: # 根据关系网遍历1的所有子节点
if s!=u: # 因为关系网列表中互相存了关系 所以访问该子节点的子节点时可能回溯到该子节点的父节点
# 如dfs(3,1)--->for i in t[3]:(i==1) i==u 则跳过
a=dfs(s,v) # 递归dfs子节点的子节点
if a>0: # 得到dp[s]最大值
dp[v]+=dp[s] # 父节点的最大权值==子节点的最大权值+父节点权值
ans=max(dp[v],ans)
return ans
# 初始化及关系网的构建
n=int(input())
dp=[0]+list(map(int,input().split())) # dp数组存放节点值 也用于动态规划 第一个不存
t=[[] for i in range(6)] # 关系网 第一个不存
for i in range(1,n):
u,v=map(int,input().split())
# 因为无根 所以将两个结点互相作为父子结点存放在各自的列表中 互为子节点
t[u].append(v)
t[v].append(u)
# 如:[[], [3, 2], [4, 1, 5], [1], [2], [2]]
ans=0
dfs(1,0) # 设1为根 而1无父节点 传入0
print(ans)
2.树的直径(边权值能达到最大的子树)
结点无权值 边有权值
①大臣的旅费
思路分析:
-
问题转换:即求路径上权值最大的子树
-
二维数组t(关系网):
但此时t要记录权值 用列表套元组的方式记录相邻点和权值(以元组方式存储) -
vis[u]: 表示访问过该点 也就是防止子结点dfs时出现回溯
-
dp数组:dp[i]表示i为父节点的子树 以i为起点出发能达到的最大距离
-
dfs:
dfs(u)表示对以u为父节点的子树深根 得到dpu,在不断遍历其子结点v时不断更新
dp[u]=max(dp[u],dp[v]+d(u,v))
-
ans:
但dp[u]仅表示一边的最大长度,而ans表示子树内部两点的最长权值,而非单独一边,所以每一次dfs时都要对每一颗子树做处理 不断更新树的最大直径:
ans=max(ans,dp[u]+dp[v]+w(u,v))
选定各自子树最长的一边相加 再加上两点间距 看是否比最大的子树内部两点间距离大
由小子树逐渐扩展至大子树
代码实现:
# dfs & dp
def dfs(u):
global ans
vis[u]=True
for v,w in t[u]: # 依次取出子节点及其边长
if vis[v]!=True: # 防止回溯
dfs(v)
# 这里要注意 ans的改变在dp[u]改变之前 否则会多加
ans=max(ans,dp[u]+dp[v]+w)
dp[u]=max(dp[u],dp[v]+w)
# 初始化
ans=0
n=int(input())
t=[[] for i in range(n+1)]
for i in range(1,n):
u,v,w=map(int,input().split())
t[u].append((v,w))
t[v].append((u,w)) # 表示结点v与相邻的一个子节点u的边长为w
dp=[0]*(n+1)
vis=[False for i in range(n+1)] # 记录访问情况
dfs(1)
七.数位dp
数位dp:即dp对象为数的每一位,暴力的枚举整数的每一位,在枚举的过程中用dp[i][j]记录枚举过的可能,从而避免重复
-
数位dp用于求解:
求取区间[l,r]中满足某个条件P(i)的数字的个数,l和r很大,一般为数十亿
-
数位dp基本思路:
1. 将区间[l,r]划分为[1,l-1]和[1,r]。此时求解[l,r]中满足条件的数字个数就是求解
[0,r]-[0,l-1]
2. 从最高位开始枚举数字i的每一位,用采用深度优先遍历,将[0,r]和[0,l-1] 中所有的数字列举出来,并记录满足条件的数字个数
3. 记忆优化,在(2)中暴力枚举每一个数,在枚举的过程中有些组合是重复的,对于这些重复的组合我们可以使用dp来存储
4. 利用(2),(3)求取的[1,r]和[1,l-1]直接相减得到最终结果
1.统计区间内数码个数:
题意:统计[1,b]范围内,每个数码(0,1,2,…,9)出现次数
✅ 对于0-9各个数码 i表示i位数
0-9:dp[1]=1
00-99:dp[2]=110+110=20
000-999:dp[3]=2010+1100
…
由递推—>dp[i]=dp[i-1]+10^(i-1)
思路分析:
-
拆分区间:对于大区间,可以用dp结果拆解为小区间—>[000,299]+[300,367],如在[00,99][100,199][200,299] 这些区间上都可以用dp[2]的结果先计算十位和个位上0-9的出现次数(即为3*20),之后再计算百位,而最后一个[300,367]要特判;
-
数位限制:小区间[000,099]最高位为0 出现了100次;[100,199]最高位为1 出现了100次; [200,299]最高位为2 出现了100次;[300,367]最高位为3 出现了68次
这里的最高次称为数位限制,需要特别判断
-
删前导0:对于前导0,即000,099这类要去掉;
代码实现:
def solve(x):
n=tuple(map(int,str(x))) # 将数字变为元组存储
n=n[::-1] # 将数字倒置 如367->763 这样索引就对应了高位数-1
for i in range(len(n)-1,-1,-1): # 从高位开始处理
**# 1.根据dp结果计算小于最高位数字在降一位之后在其位上的数码个数**
for j in range(n[i]):
cnt[j]+=dp[i]*n[i]
# 如763->n[2]=3 可以由dp[2]计算数字0,1,2在[00,99][100,199][200,299]下降为十位数(及个位数)后
# 变为3个[00,99] 即cnt[0/1/2]+=20*3
**# 2.在计算完降了一位后 现在要计算小于最高位数字在最高位上的个数**
for j in range(n[i]):
cnt[j]+=ten[i]
# 把0,1,2百位上的个数加上(+100)
**# 3.现在要特判数位限制 即[300,367]**
u=0
for j in range(i-1,-1,-1):
u=u*10+n[j]
cnt[n[i]]+=u+1 # 如300-367 3出现了68次 u=(n[1]+0)*10+n[0]=67 67+1=68
**# 4.删去前导0**
cnt[0]-=ten[i] # 减去百位0(000-099),十位0(00-09),个位0(0)
ten=[0]*15
ten[0]=1 # ten[i]表示10的i次方
# 构造dp数组
dp=[0]*15 # 假定最高有15位数
for i in range(1,15):
dp[i]=dp[i-1]*10+ten[i-1]
ten[i]=ten[i-1]*10
b=int(input())
cnt=[0]*10 # cnt[i]记录数字i出现了多少次
solve(b)
for i in range(10):
print(cnt[i],end=' ')
2.求区间中满足条件的数
①K好数
题意:如果一个自然数N的K进制表示中任意的相邻的两位都不是相邻的数字,那么我们就说这个数是K好数,求L位K进制数中K好数的数目
思路分析
- k相当于限制了每位上可以取的数的范围位0-k-1 l相当于限制了位数 满足条件的数可以从低位逐渐推到至高位
- dp[i][j]:i表示当前有i位数 j表示当前首位上的数字
若x满足与j不相邻 即:x!=j+1 and x!=j-1
状态转移方程为:
dp[i][j]+=dp[i-1][x]
代码实现:
# 初始化
num=1e9+7 # 大数取模 防止溢出
k,l=map(int,input().split())
dp=[[0 for j in range(k)] for i in range(l+1)] # 第一行不存
for i in range(k):
dp[1][i]=1 # 初始化当数为1位时 0-k-1所有数字可行 均有1种
# dp
for i in range(2,l+1): # dp思想:由低位至高位 下一位的数由前一位数得来
for j in range(k): # j是当前位数的首位数字
for x in range(k): # 一一遍历x x为前一位数上的首位数字
if x!=j+1 and x!=j-1: # 不相邻
dp[i][j]+=dp[i-1][x] # 将之前满足条件的情况加起来得到当前情况数
dp[i][j]%=num # 及时取模防溢出
# 除去首位为0的情况
ans=0
for i in range(1,k):
ans+=dp[l][i]
ans%=num # 及时取模防溢出
print(int(ans)) # 注意取模之后要变为整型
3.二进制问题
①二进制问题
题意:1 到 N 中有多少个数满足其二进制表示中恰好有 K 个 1 (1≤N≤10^18, 1 ≤ K ≤ 50)
思路分析:
分析题目可以知道我们需要求解区间[1, N]满足对应的二进制数字中有K个1的数目,根据求解某一个区间满足某一种性质数的个数的特点可以知道这是一道经典的数位dp的题目,直接求1-N,遍历的数字很大,不易求解,我们可以近似的将其转化为二进制位数,思路如下:
💡
要求解[1,N]上有多少个对应二进制含K个1的数,可以先确定N对应二进制的位数,假设为n位二进制数,易得,1~ N之间是包含全部1~n-1位的二进制数的,只是第n位上二进制个数不确定,所以对n位二进制数,可以拆分为[1,n-1]+[n-1,n]这两个区间进行求解,如5=101(2)可以转化为[000,011]和[100,101]分区间求解
-
dp[i][j]数组:表示0~位数为i的所有二进制数中,含有j个1的方案个数
状态转移方程为:
j=0: dp[i][j]=dp[i-1][j];
j≠0: dp[i][j]=dp[i-1][j]+dp[i-1][j-1]
-
递归:
我们对N进行处理,将N的二进制数不断去除最高位(移位操作),设当前为第i位(i≤n),last为已经消耗掉的1,res为结果,此时,有两种情况:
- 首位数字为1时(只有为1时才有意义):
-
选取0:
则要在剩下的[1,i-1]位的二进制数中找到k-last个1的方案总数,即求位数为[1,i-1]的所有二进制数中1的个数为k-last的情况数——dp[i-1][k-last]
递推式为:
res+=dp[i-1][k-last]
-
选取1:
则消耗的1的个数last+1,继续递归,如果消耗的1的个数比指定的k个数多,则结束
-
首位数字为0时(特判):
当最后一位为0 且 前面的1的个数已经满足条件时 为一种情况 应该特判——因为最后一位为0时无法进入上一个if中 所以应该单独开设条件 加上dp[0][0]
🧐 当首位为1时,出现选与不选两种情况,产生了新的分支,而每个分支下对应的剩余1的个数不同,所以各个情况之间不会有交集且dp[i-1][k-last]也不会重复,所以可以直接加上此种情况下的dp[i-1][k-last]
注意:对于每一位的值而言,只有当值为1时,才会需要进一步判断,因为面对数字为1时,可以有两种选择,选择这个1和不选这个1,有操作的空间
举个例子:
若此时数字为0111001,首位数字为0,相当于最高位为0,没有意义,继续递归下一位,如果选择1,则数字只会比当前数字要大,无形中加大了范围,所以不满足
图解算法:
代码实现:
# 代码实现
def n_num(N):
global num
a=bin(N)[2:] # 将N转化为二进制后去除前面的0b
for i in str(a):
num.insert(0,int(i)) # 从左端插入,倒置的二进制位上的数
num.insert(0,-1) # 第一位不存数据
def dp_init(n):
global dp
for i in range(1,n+1):
for j in range(n+1):
if j==0:
dp[i][j]=dp[i-1][j]
else:
dp[i][j]=dp[i-1][j]+dp[i-1][j-1]
def getnum():
res=0 # 结果
last=0 # 表示当前消耗掉的1的个数
for i in range(len(num)-1,0,-1): # 表示第i位到第1位
# 只有首位为1才可以进行判断
if num[i]==1:
**# 因为选0和1两者是并行的 所以通过更新res与last+1的顺序来体现两种情况**
# 1.选取0时
res+=dp[i-1][k-last]
# 2.选取1时
last+=1
if last>k:
break # 如果消耗掉1的个数已经比k大 结束
# 特判:当最后一位为0且前面的1的个数已经满足条件时 为一种情况
# 因为最后一位为0时无法进入上一个if中 所以应该加上dp[0][0]
if i==1 and last==k:
res+=1
return res
N,k=map(int,input().split())
# 1.得到N的二进制数的每一位,存入数组num
num=[]
n_num(N)
# 2.实际N的二进制数的位数
l=len(num)-1
# 3.构造dp数组
dp=[[0]*(l+1) for i in range(l+1)]
dp[0][0]=1
dp_init(l)
# 4.得到结果res
res=getnum()
print(res)
--->7 2
3
八.总结
本篇讲解了动态规划的深入理解与应用,dp在诸多方面都有相关的实例,不止上述提到的,大家可以继续深入了解,其思想十分重要,以上就是dp的全部内容啦,如有错误,欢迎指正~~,感谢!!
觉得本篇有帮助的话,就赏个三连吧~