[笔记]数位 DP

大概一年半前写过数位 DP 的笔记,忘挺快的,并且考虑到之前的 \(2\) 篇笔记( )语言稚嫩、公式混乱、码风清奇(其实还好),有些许地方言不达意,所以打算从头梳理一下,并且对若干题目进行方法归纳、深入思考。

会不定期更新。

附上题单:https://www.luogu.com.cn/training/82023

算法简述

数位 DP 一般用于求解 \([L,R]\) 中满足条件 \(f(i)\)\(i\) 的个数、数字和、总和等等。

通常 \(L,R\) 会很大,甚至超过 long long,直达 \(10^{10^5}\) 之类的天文数字。所以数位 DP 可能算最容易辨认的 DP 了(真的

顾名思义,数位 DP 的核心思想就是按位 DP,因此复杂度通常与数的位数有关,而非数的大小。

许多 \(f(i)\) 的判定需要记录很多的上下文信息,因此我们一般使用记忆化搜索来实现数位 DP。

例题

P4999 烦人的数学作业

我们写数位 DP,通常会利用前缀和的思想,将 \([L,R]\) 的答案转化为 \([1,R]\) 的答案减去 \([1,L-1]\) 的答案,故我们仅需考虑 \([1,x]\) 内的答案如何计算。

由于是按位进行,我们必须在搜索过程中记录当前正在填写的位置 \(pos\)。一般从最高位 \(len\) 填起,到第 \(1\) 位结束。

为了要让枚举的数都 \(\le x\),我们需要额外记录一个 bool 类型的 \(limit\),表示当前位的填写有没有限制。

\(x=520\) 举例:

  • 如果最高位填写 \(0\sim 4\),那么后面可以随便填没有限制。
  • 如果最高位填写 \(5\),那么下一位就要受限,如果填 \(0\sim 1\) 就是第一条,填 \(2\) 就是第二条,这样循环下去直到填完……

另外我们要记录 \(sum\),表示 \(pos+1\) 到最高位的数位和是多少,在递归结束时返回。

然而我们把代码一写,这不还是暴搜嘛?

其实我们发现,两个状态的 \(pos,sum\) 都分别相等时,它们的返回值也将完全一样。因此我们可以用 \(f[pos][sum]\) 来记忆化。

不过需要注意的是,只有 \(limit=0\) 时才可以记忆化,因为填写是否受限会导致答案不同。这样做能保证时间不退化,是因为 \(limit=1\) 的状态在递归树上只是一条链而已。

数位 DP 的时间复杂度就是状态数量\(f\) 可能被用到的位置个数),乘上转移的复杂度。

这样,此算法的时间复杂度就是 \(O(B\times \log_B N)\times O(B)+O(T)=O(T+B^2\log_B N)\),可以通过。

其中 \(N\)\(L,R\) 同阶,\(B=10\) 表示进制。

点击查看代码 - R233800485
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=20,P=1e9+7;
int f[N][9*N],a[N],t,l,r;
int dfs(int p,bool lim,int s){//pos,limit,sum
	if(!p) return s;
	if(!lim&&f[p][s]) return f[p][s];
	int rig=lim?a[p]:9,ans=0;
	for(int i=0;i<=rig;i++){
		ans+=dfs(p-1,lim&&i==rig,s+i);
	}
	ans%=P;
	if(!lim) f[p][s]=ans;
	return ans;
}
int solve(int x){//将 x 的每一位存在 a 中,更方便
	int len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,1,0);
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	cin>>t;
	while(t--){
		cin>>l>>r;
		cout<<(solve(r)-solve(l-1)+P)%P<<"\n";
	}
	return 0;
}

学到的:用前缀和思想来统计;数位 DP 的基本框架;数位 DP 的思考方向(先打暴搜,再考虑记忆化)。

Tip:上下文信息中如果有数组,我们一般将其定义为全局数组,利用回溯来传递,而非作为参数传递,以节省空间。

P2602 [ZJOI2010] 数字计数

依照上题的思路,我们先把暴搜一写,再想怎么记忆化。

不过这暴搜思路也有两种:

  • 一次暴搜同时统计 \(10\) 个位置。
  • \(10\) 次暴搜,每次统计一位。

我们发现,前者将 \(10\) 个数字的填写情况看作整体,状态数量太多,不容易记忆化。相比之下,后者仅需记录某个数字的填写次数 \(cnt\),更容易优化。

这样我们的记忆化数组顺理成章地写成 \(f[pos][cnt]\)

需要注意的是统计 \(0\) 的答案时,前导 \(0\) 不应记入 \(cnt\)。为此我们在上下文信息中额外记录一个 bool 类型的 \(zero\),表示 \(p+1\) 到最高位是否全都是 \(0\)

相应地,只有 \(zero=0\)\(limit=0\) 时才能使用记忆化,原因同上。

时间复杂度 \(O(B)\times O(\log^2_B N)\times O(B)=O(B^2\log^2_B N)\)

点击查看代码 - R233311222
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=15;
int x,a[N],f[N][N];
int dfs(int p,int c,bool lim,bool zro){//pos,cnt,limit,zero
	if(!p) return c;
	if(!lim&&!zro&&(~f[p][c])) return f[p][c];
	int rig=lim?a[p]:9,ans=0;
	for(int i=0;i<=rig;i++){
		ans+=dfs(p-1,c+(i==x&&(i||!zro)),lim&&i==rig,zro&&!i);
	}
	if(!lim&&!zro) f[p][c]=ans;
	return ans;
}
int solve(int x){
	int len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,0,1,1);
}
signed main(){
	int l,r;
	cin>>l>>r;
	for(x=0;x<=9;x++){
		memset(f,-1,sizeof f);
		cout<<solve(r)-solve(l-1)<<" ";
	}
}

学到的:逐个数字计算答案;对前导 \(0\) 的处理。

P6218 [USACO06NOV] Round Numbers S

\(dif\) 记录 \(pos+1\) 到最高位 \(0\)\(1\) 个数的差值。

\(f[pos][dif]\) 来记忆化即可,具体实现中由于 \(dif\) 可能存在负数所以需要加上一个偏移量。

注意处理前导 \(0\)

时间复杂度 \(O(B\log^2_B n)\)

点击查看代码 - R233314031
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=35;
int a[N],f[N][N<<1];
int dfs(int p,int d,bool lim,bool zro){
	if(!p) return d>=0;
	if(!lim&&!zro&&(~f[p][d+N])) return f[p][d+N];
	int rig=lim?a[p]:1,ans=0;
	for(int i=0;i<=rig;i++){
		ans+=dfs(p-1,d+(!zro||i)*(i?-1:1),lim&&i==rig,zro&&!i);
	}
	if(!lim&&!zro) f[p][d+N]=ans;
	return ans;
}
int solve(int x){
	int len=0;
	while(x) a[++len]=x&1,x>>=1;
	return dfs(len,0,1,1);
}
signed main(){
	memset(f,-1,sizeof f);
	int l,r;
	cin>>l>>r;
	cout<<solve(r)-solve(l-1);
	return 0;
}

P13085 [SCOI2009] windy 数(加强版)

我们发现每一位的填写情况仅与上一位 \(last\) 有关,所以用 \(f[pos][last]\) 来记忆化即可。

注意处理前导 \(0\) 的问题,在 \(pos+1\) 到最高位全都是 \(0\) 的情况下,\(pos\) 可以随意填,与 \(last\) 无关。

时间复杂度 \(O(B\log n)\times O(B)=O(B^2\log N)\)

点击查看代码 - R233813690
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=20;
int a[N],f[N][10];
int dfs(int p,int lst,bool lim,bool zro){
	if(!p) return 1;
	if(!lim&&!zro&&(~f[p][lst])) return f[p][lst];
	int rig=lim?a[p]:9,ans=0;
	for(int i=0;i<=rig;i++){
		if(!zro&&abs(i-lst)<2) continue;
		ans+=dfs(p-1,i,lim&&i==rig,zro&&!i);
	}
	if(!lim&&!zro) f[p][lst]=ans;
	return ans;
}
int solve(int x){
	int len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,0,1,1);//一开始 lst 随便写就可以
}
signed main(){
	memset(f,-1,sizeof f);
	int l,r;
	cin>>l>>r;
	cout<<solve(r)-solve(l-1)<<"\n";
	return 0;
}

CF1036C Classy Numbers

显然可以用 \(f[pos][cnt]\) 记忆化,其中 \(cnt\) 是截至目前填写的非零数字个数。

不需要处理前导 \(0\)

点击查看代码 - #336491804
#include<bits/stdc++.h>
#define pc(x) __builtin_popcount(x)
using namespace std;
typedef long long ll;
const int N=20;
ll t,l,r,f[N][N];
int len,a[N];
inline ll dfs(int p,bool lim,int cnt){
	if(cnt>3) return 0;
	if(!p) return 1;
	if(!lim&&(~f[p][cnt])) return f[p][cnt];
	int rig=lim?a[p]:9;ll ans=0;
	for(int i=0;i<=rig;i++){
		ans+=dfs(p-1,lim&&i==rig,cnt+(i!=0));
	}
	if(!lim) f[p][cnt]=ans;
	return ans;
}
inline ll solve(ll x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,1,0);
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	memset(f,-1,sizeof f);
	cin>>t;
	while(t--){
		cin>>l>>r;
		cout<<solve(r)-solve(l-1)<<"\n"; 
	}
	return 0;
}

CF628D Magic Numbers

奇数偶数位的限制仅需在搜索的同时限制一下就行了。

\(m\mid x\) 的条件,仅需搜索过程中记录当前填写的数值 \(\bmod\) \(m\) 的结果,记为 \(num\)\(f[pos][num]\) 用于记忆化就可以了。注意这种写法仅适用于单测,若为多测则需要额外添加一维,表示当前位数的奇偶性。

实现细节上,注意 \(l,r\) 足足有 \(2000\) 位,所以需要用字符串存,然而字符串 \(-1\) 不好实现,所以可以先计算 solve(r)-solve(l),然后特判 \(l\) 是否合法,合法就额外 \(+1\)

点击查看代码 - #336496736
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=2e3+2,M=2001,P=1e9+7;
int m,d,len,a[N],f[N][2][M];//因为没有多测,所以第二维可以去掉 
string l,r;
inline int dfs(int p,bool lim,int s){
	if(!p) return !s;
	int& g=f[p][(len-p)&1][s];
	if(!lim&&(~g)) return g;
	int rig=lim?a[p]:9;ll ans=0;
	if((len-p)&1){
		if(d<=rig) ans=dfs(p-1,lim&&d==rig,(s*10+d)%m);
	}else{
		for(int i=0;i<=rig;i++){
			if(i==d) continue;
			ans+=dfs(p-1,lim&&i==rig,(s*10+i)%m);
		}
	}
	ans%=P;
	if(!lim) g=ans;
	return ans;
}
inline int solve(string x){
	len=x.size();
	for(int i=0;i<len;i++) a[len-i]=x[i]-'0';
	return dfs(len,1,0);
}
inline bool check(string x){
	len=x.size();
	for(int i=1;i<len;i+=2) if(x[i]-'0'!=d) return 0;
	for(int i=0;i<len;i+=2) if(x[i]-'0'==d) return 0;
	int s=0;
	for(char i:x) s=(s*10+i-'0')%m;
	return !s;
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	memset(f,-1,sizeof f);
	cin>>m>>d>>l>>r;
	cout<<(solve(r)-solve(l)+check(l)+P)%P<<"\n";
	return 0;
}

学到的:数位太多存不下时,可以先计算 solve(r)-solve(l),然后特判 \(l\) 是否合法。

SP10606 BALNUM - Balanced Numbers

很暴力也很好想的记忆化思路,记数字 \(i\)访问情况\(vis[i]\)访问次数的奇偶性\(sta[i]\)

\(vis,sta\) 都压成 \([0,2^{10})\) 的整数,然后用 \(f[pos][vis][sta]\) 来记忆化,即可通过。

注意前导 \(0\) 的处理,并开 ull

点击查看代码 - R233816509
#include<bits/stdc++.h>
#define int unsigned long long
const int N=20;
using namespace std;
int t,l,r,len,a[N],vis,sta,f[N][1024][1024];
bitset<1024> fv[N][1024];
int dfs(int p,bool lim,bool zro){
	if(!p){
		for(int i=0;i<=9;i++) if(((vis>>i)&1)&&((sta>>i)&1)==(i&1)) return 0;
		return 1;
	}
	if(!lim&&!zro&&fv[p][vis][sta]) return f[p][vis][sta];
	int rig=lim?a[p]:9,ans=0;
	int tv=vis,ts=sta;
	for(int i=0;i<=rig;i++){
		if(!zro||i) vis|=(1<<i),sta^=(1<<i);
		ans+=dfs(p-1,lim&&i==rig,zro&&!i);
		vis=tv,sta=ts;//回溯
	}
	if(!lim&&!zro) fv[p][vis][sta]=1,f[p][vis][sta]=ans;
	return ans;
}
int solve(int x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,1,1);
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	cin>>t;
	while(t--){
		cin>>l>>r;
		cout<<solve(r)-solve(l-1)<<"\n";
	}
	return 0;
}

时间复杂度 \(O(T+3^B\times B\log_B N)\),诶为什么不是 \(4^B\) 呢?因为 \(vis_i=0,sta_i=1\) 是冗余状态,是根本不可能被搜到的~

所以我们完全可以用三进制将后两维替代掉。

空间从原来的 \(20\times 4^{10}=20971520\) 优化到了 \(20\times 3^{10}=1180980\),砍掉了将近 \(95\%\)。很震惊吧~

虽说理论时间复杂度没有优化,但是开数组的时间也随着一起优化了。我们会在本题的最后进行效率的对比。

点击查看代码 - R233820636
#include<bits/stdc++.h>
#define int unsigned long long
const int N=20;
using namespace std;
int t,l,r,len,a[N],sta[N],f[N][59049];//3^10=59049
bitset<59049> fv[N];
//0没访问,1访问奇数次,2访问偶数次
inline int to_num(){
	int ans=0;
	for(int i=0;i<=9;i++) ans=ans*3+sta[i];
	return ans;
}
int dfs(int p,bool lim,bool zro){
	if(!p){
		for(int i=0;i<=9;i++) if(sta[i]&&(sta[i]&1)==(i&1)) return 0;
		return 1;
	}
	int num=to_num();
	if(!lim&&!zro&&fv[p][num]) return f[p][num];
	int rig=lim?a[p]:9,ans=0;
	for(int i=0,ts;i<=rig;i++){
		ts=sta[i];
		if(!zro||i) sta[i]=((sta[i]&1)?2:1);
		ans+=dfs(p-1,lim&&i==rig,zro&&!i);
		sta[i]=ts;//回溯
	}
	if(!lim&&!zro) fv[p][num]=1,f[p][num]=ans;
	return ans;
}
int solve(int x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,1,1);
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	cin>>t;
	while(t--){
		cin>>l>>r;
		cout<<solve(r)-solve(l-1)<<"\n";
	}
	return 0;
}

在朴素做法的基础上,其实只要 posvis奇数位上1的个数vis偶数位上1的个数sta奇数位上1的个数sta偶数位上1的个数 都分别相等,两个状态的答案就是一样的。

原理很好懂,比如你一共填写了 \(n\) 个奇数,其中有 \(m\) 个奇数出现了奇数次,那么你不需要关注这些数具体是几,因为这没有影响。比如说截至目前填写了 \(331132377\),那么我把 \(1\)\(7\) 互换,或者把 \(3\) 都换成 \(9\)……都不会影响结果。偶数同理。

这样空间优化到了 \(20\times 6^4=25920\),时间复杂度也得到了大幅优化,优于洛谷上的题解。

点击查看代码 - R233825477
#include<bits/stdc++.h>
#define int unsigned long long
#define pc(x) __builtin_popcount(x)//x 二进制表示下 1 的个数
#define eve(x) pc((x)&341)//x偶数位上1的个数,原理:341 = 2^0 + 2^2 + 2^4 + 2^6 + 2^8
#define odd(x) pc((x)&682)//x奇数位上1的个数,原理:682 = 2^1 + 2^3 + 2^5 + 2^7 + 2^9
const int N=20;
using namespace std;
int t,l,r,len,a[N],vis,sta,f[N][1296];
bitset<1296> fv[N];
int dfs(int p,bool lim,bool zro){
	if(!p){
		for(int i=0;i<=9;i++) if(((vis>>i)&1)&&((sta>>i)&1)==(i&1)) return 0;
		return 1;
	}
	int num=216*eve(vis)+36*odd(vis)+6*eve(sta)+odd(sta);//压成六进制数
	if(!lim&&!zro&&fv[p][num]) return f[p][num];
	int rig=lim?a[p]:9,ans=0;
	int tv=vis,ts=sta;
	for(int i=0;i<=rig;i++){
		if(!zro||i) vis|=(1<<i),sta^=(1<<i);
		ans+=dfs(p-1,lim&&i==rig,zro&&!i);
		vis=tv,sta=ts;//回溯
	}
	if(!lim&&!zro) fv[p][num]=1,f[p][num]=ans;
	return ans;
}
int solve(int x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,1,1);
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	cin>>t;
	while(t--){
		cin>>l>>r;
		cout<<solve(r)-solve(l-1)<<"\n";
	}
	return 0;
}

运行效率对比

img

从下到上分别是三份代码的运行情况。

SPOJ 数据较少不易看出时间差距,因此我在本地进行了测试,测试数据 \(T=1000\),其他范围同题。

img

学到的:对数位 DP 来说,状态表示越简洁,DP 数组就越小巧,进而时空开销就越少。所以面对一道题,可以先打出暴搜,然后把所有上下文变量都扔进 \(f\) 数组里,在此基础上,考虑如下优化:

  • 逐步思考哪些状态是一样的,进而优化维度或者每一维的大小。
    最重要,优化上限也最高,是数位 dp 的精髓,可以同时优化时间和空间。
  • 搜索中途可能有一些状态需要剪枝
    优化时间,记忆化越强效果越不明显,但是应该作为写搜索的一个习惯。
  • 思考 dp 数组有没有冗余空间(根本搜索不到的那种)。
    优化时空常数,可应对一些卡常的题。

CF1073E Segment Sum

之前的题不是求个数,就是求数位和,然而这道题让我们直接对满足条件的数求和。

由于 \(p+1\) 到最高位的填写情况不同,所以不能像 P4999 那样用 \(sum\) 来记忆化,否则状态数量太多了,跟直接暴搜没什么区别,而且还会炸空间。

根本原因在于我们的 \(sum\) 是自上往下计算的,也就是说当前状态的 \(sum\) 将更高位的贡献也统计进来了。把更高位的填写情况放到上下文里面,就导致了几乎不存在 \(sum\) 相同的两个状态。

为此,我们需要对 \(sum\) 进行修改,使其仅存储每个答案的前 \(p\) 位之和。我们也不再将 \(sum\) 作为参数传递,而是将其作为返回值。这样就做到了自下往上计算。

那么在递归树上,怎么根据子节点的 \(sum\) 来计算当前节点的 \(sum\) 呢?

我们额外记录一个 \(cnt\) 表示答案个数,那么对于状态 \(u\),若令 \(d[i]\)\(pos\) 位填 \(i\) 得到的状态,则有:

\[sum[u]=\sum\limits_{i=0}^{rig} cnt[d[i]]\times i \times 10^{pos-1}+sum[d[i]]\\ cnt[u]=\sum\limits_{i=0}^{rig} cnt[d[i]] \]

可以结合下图来理解(右侧是最高位,箭头上的数字是 \(i \times 10^{pos-1}\))。

这样我们用 \(f[pos][vis]\) 来记忆化就没有问题了,其中 \(vis\)\(0\sim 9\) 的访问情况压缩而成的整数。

点击查看代码 - #336251437
#include<bits/stdc++.h>
#define pc(x) __builtin_popcount(x)
#define int long long
#define PII pair<int,int>//(sum,cnt)
using namespace std;
const int N=20,P=998244353;
int l,r,k,len,a[N],vis,pw[N];
PII f[N][1024];
bitset<1024> fv[N];
PII dfs(int p,bool lim,bool zro){
	if(pc(vis)>k) return {0,0};
	if(!p) return {0,1};
	if((lim^1)&(zro^1)&fv[p][vis]) return f[p][vis];
	int rig=lim?a[p]:9,tv=vis;
	PII t,ans{0,0};
	for(int i=0;i<=rig;i++){
		if(!zro||i) vis|=(1<<i);
		t=dfs(p-1,lim&&i==rig,zro&&!i);
		ans.first+=t.first+t.second*i*pw[p-1]%P;
		ans.second+=t.second;
		vis=tv;
	}
	ans.first%=P,ans.second%=P;
	if((lim^1)&(zro^1)) fv[p][vis]=1,f[p][vis]=ans;
	return ans;
}
int solve(int x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,1,1).first;
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	pw[0]=1;
	for(int i=1;i<N;i++) pw[i]=pw[i-1]*10%P;
	cin>>l>>r>>k;
	cout<<(solve(r)-solve(l-1)+P)%P<<"\n";
	return 0;
}

学到的:自下向上统计答案。

再刷 P4999 烦人的数学作业

能否使用 CF1073E 中“自下向上统计答案”的方法来优化状态空间呢?

当然可以,只需要把上面式子中的 \(10^{pos-1}\) 去掉就行。

时间复杂度从 \(O(T+B^2\log_B N)\) 降到了 \(O(T+B\log N)\)

点击查看代码 - R233900285
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=20,P=1e9+7;
int t,l,r,len,a[N];
bitset<N> fv;
struct Data{int sum,cnt;}f[N];
Data operator+(Data a,Data b){return {(a.sum+b.sum)%P,(a.cnt+b.cnt)%P};}
Data operator*(Data a,int b){return {b*a.sum%P,b*a.cnt%P};}
Data dfs(int p,bool lim){
	if(!p) return {0,1};
	if(!lim&&fv[p]) return f[p];
	int rig=lim?a[p]:9;
	Data ans{0,0};
	for(int i=0;i<=rig;i++){
		Data t=dfs(p-1,lim&&i==rig);
		ans=ans+t,(ans.sum+=i*t.cnt)%=P;
	}
	if(!lim) fv[p]=1,f[p]=ans;
	return ans;
}
inline int solve(int x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,1).sum;
}
signed main(){
	cin>>t;
	while(t--){
		cin>>l>>r;
		cout<<(solve(r)-solve(l-1)+P)%P<<"\n";
	}
	return 0;
}

然后我们发现循环可以去掉,因为 \(i\in[0,rig)\) 时,得到的 \(t\) 都是相同的,所以直接将此区间内的贡献转化成 \(rig\times sum(t)+\frac{rig\times (rig-1)}{2}\times cnt(t)\) 就好;\(i=rig\) 时单独计算。

这样又省去了状态转移的 \(O(B)\),时间复杂度是严格的 \(O(T+\log_B N)\)

点击查看代码 - R233900303
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=20,P=1e9+7;
int t,l,r,len,a[N];
bitset<N> fv;
struct Data{int sum,cnt;}f[N];
Data operator+(Data a,Data b){return {(a.sum+b.sum)%P,(a.cnt+b.cnt)%P};}
Data operator*(Data a,int b){return {b*a.sum%P,b*a.cnt%P};}
Data dfs(int p,bool lim){
	if(!p) return {0,1};
	if(!lim&&fv[p]) return f[p];
	Data ans;
	if(!lim){
		ans=dfs(p-1,0)*10,(ans.sum+=dfs(p-1,0).cnt*45)%=P;
	}else{
		ans=dfs(p-1,0)*a[p];
		(ans.sum+=dfs(p-1,0).cnt*(a[p]*(a[p]-1)/2))%=P;
		Data t=dfs(p-1,1);
		ans=ans+t,(ans.sum+=t.cnt*a[p])%=P;
	}
	if(!lim) fv[p]=1,f[p]=ans;
	return ans;
}
inline int solve(int x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,1).sum;
}
signed main(){
	cin>>t;
	while(t--){
		cin>>l>>r;
		cout<<(solve(r)-solve(l-1)+P)%P<<"\n";
	}
	return 0;
}

CF55D Beautiful Numbers

\(p+1\) 到最高位的内容为 \(num\),所填写数字的 \(\text{lcm}\) 值为 \(lcm\),则可以用 \(f[pos][num][lcm]\) 来记忆化,不过这样开销太大,光是 \(num\) 这一维就已经吃不消了,考虑优化。

如果令我们填过的数为 \(a_1,\dots,a_k\),则 \(num\equiv x\pmod{a_1,a_2,\dots,a_k}\) 的充要条件是 \(num\equiv x\pmod{\text{lcm}\{a_1,\dots,a_k\}}\)

因此我们可以仅记录 \(num \bmod 2520\),而且 \(2520\) 是可选的最小的模数。这是因为对于所有 \(a\) 序列的选择,都满足 \(num\equiv x\pmod y \Longrightarrow num\equiv x\pmod{\text{lcm}\{a_1,\dots,a_k\}}\)\(y\) 值,最小就是 \(2520\) 了。

然而这样还是会爆。不过我们发现 \(lcm\) 的取值个数其实就是 \(2520\) 的因数个数,只有 \(48\) 而已。所以我们将所有可能的 \(lcm\) 取值进行离散化。状态个数大约是 \(18\times 2520\times 48\),可以通过。

点击查看代码 - #336277615
#include<bits/stdc++.h>
#include<ext/pb_ds/assoc_container.hpp>
#include<ext/pb_ds/hash_policy.hpp>
#define int long long
using namespace std;
using namespace __gnu_pbds;
const int N=20;
gp_hash_table<int,int> ma;
int t,l,r,len,a[N],f[N][2525][50];
inline int __lcm(int x,int y){return x/__gcd(x,y)*y;}
int dfs(int p,int num,int lcm,bool lim){
	if(!p) return num%lcm==0;
	int &g=f[p][num%2520][ma[lcm]];
	if(!lim&&(~g)) return g;
	int rig=lim?a[p]:9,ans=0;
	for(int i=0;i<=rig;i++){
		ans+=dfs(p-1,num*10+i,i?__lcm(lcm,i):lcm,lim&&i==rig);
	}
	if(!lim) g=ans;
	return ans;
}
inline int solve(int x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,0,1,1);
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	memset(f,-1,sizeof f);
	for(int i=1,idx=0;i*i<=2520;i++) if(2520%i==0)
		ma[i]=++idx,ma[2520/i]=++idx;
	cin>>t;
	while(t--){
		cin>>l>>r;
		cout<<solve(r)-solve(l-1)<<"\n";
	}
	return 0;
}

P4127 [AHOI2009] 同类分布

和 CF55D 很相似,不过 \(\text{lcm}\) 变成了 \(+\) 运算,所以我们无法像 CD55D 一样找到 \(2520\) 这样固定的模数。

不妨枚举这个模数,也即枚举数位和 \(sum\)。递归结束时若当前的数位和 \(cur\) 等于 \(sum\),并且原数 \(num\) 能被 \(sum\) 整除则返回 \(1\),否则返回 \(0\)

记忆化数组 \(f[pos][num\bmod sum][cur]\),和 CF55D 类似。

时间复杂度 \(O(B\log_B N)\times O(B^3\log^2_B N)\times O(B)=O(B^5\log^3_B N)\),因为跑不满而且加了剪枝(见代码,1.84s \(\rightarrow\) 0.77s)所以能过。

点击查看代码 - R234028060
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=19;
ll l,r,f[N][163][163];//为 sum 开一维会 MLE
int a[N],len,sum;
inline ll dfs(int p,bool lim,int num,int cur){
	if(cur+9*p<sum) return 0;//剪枝 1
	if(!lim&&(~f[p][cur][num])) return f[p][cur][num];
	if(!p) return cur==sum&&!num;
	int rig=lim?a[p]:9;ll ans=0;
	for(int i=0;i<=rig&&i+cur<=sum;i++)//剪枝 2
		ans+=dfs(p-1,lim&&i==rig,(num*10+i)%sum,cur+i);
	if(!lim) f[p][cur][num]=ans;
	return ans;
}
inline ll solve(ll x){
	len=0;
	ll ans=0;
	while(x) a[++len]=x%10,x/=10;
	for(sum=1;sum<=9*len;sum++) memset(f,-1,sizeof f),ans+=dfs(len,1,0,0);
	return ans;
}
signed main(){
	cin>>l>>r;
	cout<<solve(r)-solve(l-1)<<"\n";
	return 0;
}

P3413 萌数

先打暴搜,参数有 \(pos,limit,zero\)

可以选择额外添加一个 \(hui\) 表示到当前是否有回文。这样就可以剪一下枝 if(!limit&&hui) return pow10[pos],而且递归结束直接返回 hui 即可,无需循环判断。

接下来考虑如何记忆化,令填写情况为 \(sta\),我们发现当前的状态仅与 \(pos,sta[pos+1],sta[pos+2]\) 有关。在 \(pos\) 相同的情况下分类讨论:

  • \(2\) 位都不是前导 \(0\),而且都不一样。
  • \(2\) 位都不是前导 \(0\),而且都一样。
  • \(2\) 位中最高位是前导 \(0\)
  • \(2\) 位都是前导 \(0\)

我们又可以发现,\(2,3\) 两种情况本质是一样的,所以 \(pos\) 相同时最多只有 \(3\) 种答案。用 \(f[pos][stanum]\) 来记忆化,状态数为 \(1000\times 3\)

注意此处我们记忆化的条件仅有 \(limit=0\),没有 \(zero\)。因为我们有意考虑了前导 \(0\) 的情况。

代码实现中,我们用 \(-1\) 表示 \(sta\) 数组中的前导 \(0\),这样就不需要 \(zero\) 参数了。

点击查看代码 - R233899166
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e3+10,P=1e9+7;
string l,r;
int a[N],len,sta[N],pw[N],f[N][3];
inline int check(string s){
	int n=s.size();
	for(int i=0;i<n-2;i++) if(s[i]==s[i+1]||s[i]==s[i+2]) return 1;
	return s[n-2]==s[n-1];
}
int dfs(int p,bool hui,bool lim){
	if(!lim&&hui) return pw[p];
	if(!p) return hui;
	int rig=lim?a[p]:9,ans=0,stanum;
	stanum=((~sta[p+1])?(~sta[p+2])?0:1:2);
	if(!lim&&(~f[p][stanum])) return f[p][stanum];
	for(int i=0;i<=rig;i++){
		sta[p]=(sta[p+1]==-1&&!i)?-1:i;
		ans+=dfs(p-1,hui||(sta[p+1]==i)||(sta[p+2]==i),lim&&i==rig);
	}
	ans%=P;
	if(!lim) f[p][stanum]=ans;
	return ans;
}
inline int solve(string s){
	len=s.size();
	for(int i=1;i<=len;i++) a[i]=s[len-i]-'0';
	sta[len+1]=sta[len+2]=-1;
	return dfs(len,0,1);
}
signed main(){
	memset(f,-1,sizeof f);
	pw[0]=1;
	for(int i=1;i<N;i++) pw[i]=pw[i-1]*10%P;
	cin>>l>>r;
	cout<<(solve(r)-solve(l)+check(l)+P)%P<<"\n";//沿用 CF628D 的技巧
	return 0;
}

双倍经验:P6754 [BalticOI 2013] Palindrome-Free Numbers (Day1)

CF855E Salazar Slytherin's Locket

http://codeforces.com/problemset/problem/855/E

水蓝,压位到整数 \(sta\) 中统计每个值出现多少的奇偶性,注意处理前导 \(0\),最终 \(sta=0\) 说明全为偶数。

记忆化数组:\(f[b][pos][sta]\),其中 \(b\) 表示进制。
时间复杂度 \(O(2^B\times B\times N)\times O(B)+O(T)=O(T+2^B\times B^2\times N)\),其中 \(N\) 是位数。

容易发现,\(sta\) 也可以用 \(\text{popcount}(sta)\) 代替。
时间复杂度 \(O(B^2\times N)\times O(B)+O(T)=O(T+B^3\times N)\)

两种写法均可通过,但后者更优。

上面复杂度是建立在为每一个 \(b\) 值都开一个 DP 数组从而无需清空的基础上。如果每个询问都清空,时间复杂度就是 \(O(T\times 2^B\times B\times N)\)\(O(T\times B^2\times N)\)

#1 - #336324490
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=62;
int q,b,l,r,a[N],len,sta,f[11][N][1024];
int dfs(int p,bool lim,bool zro){
	if(!p) return !sta;
	if(!lim&&!zro&&(~f[b][p][sta])) return f[b][p][sta];
	int rig=lim?a[p]:b-1,ans=0,ts=sta;
	for(int i=0;i<=rig;i++){
		if(!zro||i) sta^=(1<<i);
		ans+=dfs(p-1,lim&&i==rig,zro&&!i);
		sta=ts;
	}
	if(!lim&&!zro) f[b][p][sta]=ans;
	return ans;
}
inline int solve(int x){
	len=0;
	while(x) a[++len]=x%b,x/=b;
	return dfs(len,1,1);
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	memset(f,-1,sizeof f);
	cin>>q;
	while(q--){
		cin>>b>>l>>r;
		cout<<solve(r)-solve(l-1)<<"\n";
	}
	return 0;
}
#2 - #336324647
#include<bits/stdc++.h>
#define pc(x) __builtin_popcount(x)
#define int long long
using namespace std;
const int N=62;
int q,b,l,r,a[N],len,sta,f[11][N][11];
int dfs(int p,bool lim,bool zro){
	if(!p) return !sta;
	int& g=f[b][p][pc(sta)];
	if(!lim&&!zro&&(~g)) return g;
	int rig=lim?a[p]:b-1,ans=0,ts=sta;
	for(int i=0;i<=rig;i++){
		if(!zro||i) sta^=(1<<i);
		ans+=dfs(p-1,lim&&i==rig,zro&&!i);
		sta=ts;
	}
	if(!lim&&!zro) g=ans;
	return ans;
}
inline int solve(int x){
	len=0;
	while(x) a[++len]=x%b,x/=b;
	return dfs(len,1,1);
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	memset(f,-1,sizeof f);
	cin>>q;
	while(q--){
		cin>>b>>l>>r;
		cout<<solve(r)-solve(l-1)<<"\n";
	}
	return 0;
}

P4124 [CQOI2016] 手机号码

先打暴搜,如果填到某一位同时存在 \(4,8\) 我们就直接返回 \(0\)

接下来考虑记忆化。不难发现,只要下面的量相同,两个状态的答案就是相同的:

  • 当前填到第几位。
  • 是否填写过 \(4\)
  • 是否填写过 \(8\)
  • 当前位的前面第 \(1\) 位填写的是什么(如果不存在此位,视作 \(-1\))。
  • 当前位的前面第 \(2\) 位填写的是什么(如果不存在此位,视作 \(-1\))。
  • 是否出现过三连(即 \(3\) 个连续相同)。

都扔到 \(f\) 数组里即可。我为了图省事直接把所有数的填写状态压成一个 \(2^{11}\) 内的整数(不是 \(2^{10}\) 是因为包括 \(-1\))代替第 \(2,3\) 条扔进去了,状态数乘上转移,大概是 \(3\times 10^7\) 左右,是可以通过的(水死了

考虑优化。记 \(v_{i}\) 为我们填写的第 \(i\) 位,令不存在的位为 \(-1\)

\(f_{pos,sta1,sta2}\) 为我们的记忆化数组,其中

  • \(sta1\) 的取值和对应的情况:
    • 未出现过三连:
      • \(0\)\(v_{pos+1}=v_{pos+2}=-1\)
      • \(1\)\(v_{pos+1}=v_{pos+2}\neq -1\)
      • \(2\)\(\text{otherwise}\)
    • \(3\):出现过三连
  • \(sta2\) 的取值和对应情况:
    • \(1\):填写过 \(4\) 或者 \(8\)
    • \(0\):都没有填写过

显然,这样记忆化是正确的。代码中将它们压在一起了,状态数是 \(8\times n\)

由于电话号码不能有前导 \(0\),搜索过程中,可以让最高位从 \(1\) 填起,效率会稍有提升。不过相应也需要注意主函数调用 solve(r)-solve(l-1)\(l-1\) 可能变成 \(10\) 位数,此时 solve() 需要特判返回 \(0\)

点击查看代码 - R233913577
#include<bits/stdc++.h>
#define int long long
const int N=12;
using namespace std;
int l,r,len,a[N],sta[N+2],vis,f[N][4][2];
int dfs(int p,bool is,bool lim,bool zro){
	if(((vis>>4)&1)&&((vis>>8)&1)) return 0;
	if(!p) return is;
	int& g=f[p]
		[is?3:(sta[p+1]==sta[p+2]?((~sta[p+1])?1:0):2)]
		[((vis>>4)&1)||((vis>>8)&1)];
	if(!lim&&!zro&&(~g)) return g;
	int rig=lim?a[p]:9,ans=0,tv=vis;
	for(int i=(p==len);i<=rig;i++){
		bool tz=(zro&&!i);
		if(tz) sta[p]=-1;
		else sta[p]=i,vis|=(1<<i);
		ans+=dfs(p-1,is||(i==sta[p+1]&&i==sta[p+2]),lim&&i==rig,tz);
		vis=tv;
	}
	if(!lim&&!zro) g=ans;
	return ans;
}
inline int solve(int x){
	if(x<1e10) return 0;
	len=0;
	while(x) a[++len]=x%10,x/=10;
	sta[len+1]=sta[len+2]=-1;
	return dfs(len,0,1,1);
}
signed main(){
	memset(f,-1,sizeof f);
	cin>>l>>r;
	cout<<solve(r)-solve(l-1)<<"\n";
	return 0;
}

P2518 [HAOI2010] 计数

考虑在搜索的过程中记录所有数字的选择次数,然而状态数太多,难以优化。

实际上,只要 \(limit=0\)\(pos\) 及其之前的位置就可以随便填。

这就是一个多重集上的全排列问题。具体来说,记正在填写第 \(pos\) 位,\(limit=0\),数字 \(i\) 的剩余个数为 \(b_i\),则方案数为:

\[\frac{\left(\sum\limits_{i=0}^9 b_i\right)!} {\prod\limits_{i=0}^9(b_i!)} \]

这样就完成了。

实现细节上,阶乘数量级过大,但是题目保证答案不超过 \(2^{63}-1\),所以我们不妨找一个 \(2^{63}-1\) 以上的质数 \(P\),计算模 \(P\) 意义下的答案,就可以使用逆元了,注意需要使用 __int128

点击查看代码 - R234102036
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=52;
__int128 P,fact[N],inv[N];
int a[N],b[10],len;
string s;
__int128 qp(__int128 x,__int128 n){
	__int128 a=1;
	while(n){
		if(n&1) a=a*x%P;
		x=x*x%P,n>>=1;
	}
	return a;
}
inline ll calc(){
	int x=0;__int128 y=1;
	for(int i=0;i<=9;i++) x+=b[i],y=y*inv[b[i]]%P;
	return fact[x]*y%P;
}
inline ll dfs(int p,bool lim){
	if(!p) return 1;
	if(!lim) return calc();
	int rig=lim?a[p]:9;__int128 ans=0;
	for(int i=0;i<=rig;i++){
		if(!b[i]) continue;
		b[i]--;
		(ans+=dfs(p-1,lim&&i==rig))%=P;
		b[i]++;
	}
	return ans;
}
inline ll solve(string s){
	len=s.size();
	memset(b,0,sizeof b);
	for(int i=1;i<=len;i++) b[a[i]=s[len-i]-'0']++;
	return dfs(len,1);
}
signed main(){
	P=922337203ll;
	P*=10000000000ll;
	P+=6854775837ll;
	fact[0]=fact[1]=1;
	for(int i=2;i<N;i++) fact[i]=fact[i-1]*i%P;
	inv[N-1]=qp(fact[N-1],P-2);
	for(int i=N-1;i;i--) inv[i-1]=inv[i]*i%P;
	cin>>s;
	cout<<solve(s)-1<<"\n";
	return 0;
}

另外一种方法,因为 \(N\le 50\) ,所以上式的质因数一定不超过 \(50\)。我们可以将 \(50\) 以内的质数打出来,将每个乘数 / 除数拆成质因数分解的形式再累加。代码可以参考此题解

P2106 Sam 数

可以用 \(f[p][lst]\)\(lst\) 为上一位填的值)来记忆化,然而第一维足足有 \(10^{18}\)

这体现出了 DFS 写法相较递推写法的一个弊端:无法滚掉 \(p\) 这一维。

那么我们只好考虑递推怎么写。

\[f[i][j]=\sum\limits_{x\in[0,9]\cap[j-2,j+2]} f[i-1][x] \]

然后就可以矩阵加速,只需构造转移矩阵:

\[\begin{bmatrix} 1&1&1&0&0&0&0&0&0&0\\ 1&1&1&1&0&0&0&0&0&0\\ 1&1&1&1&1&0&0&0&0&0\\ 0&1&1&1&1&1&0&0&0&0\\ 0&0&1&1&1&1&1&0&0&0\\ 0&0&0&1&1&1&1&1&0&0\\ 0&0&0&0&1&1&1&1&1&0\\ 0&0&0&0&0&1&1&1&1&1\\ 0&0&0&0&0&0&1&1&1&1\\ 0&0&0&0&0&0&0&1&1&1\\ \end{bmatrix} \]

时间复杂度 \(O(B^3\log K)\)

点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int B=10,P=1e9+7;
int k,f[B][B],bs[B][B],res[B][B],tmp[B][B],ans;
inline void mul(int res[B][B],int a[B][B],int b[B][B]){
	memset(tmp,0,sizeof tmp);
	for(int i=0;i<B;i++)
		for(int j=0;j<B;j++)
			for(int k=0;k<B;k++)
				(tmp[i][j]+=a[i][k]*b[k][j])%=P;
	memcpy(res,tmp,sizeof tmp);
}
inline void qp(int x){
	for(int i=0;i<B;i++) res[i][i]=1;
	while(x){
		if(x&1) mul(res,res,bs);
		mul(bs,bs,bs),x>>=1;
	}
}
signed main(){
	cin>>k;
	for(int i=1;i<B;i++) f[0][i]=1;
	for(int i=0;i<B;i++) for(int j=0;j<B;j++)
		bs[i][j]=(abs(i-j)<=2);
	qp(k-1),mul(f,f,res);
	for(int i=0;i<B;i++) ans+=f[0][i];
	cout<<ans%P+(k==1);//k=1时需要算入0 
	return 0;
}

学到的:在必要时使用递推写法。

P6371 [COCI 2006/2007 #6] V

\(K\) 较小的时候很显然可以开一维表示填过数位在模 \(K\) 下的值。

然而此题的 \(K\)\(10^{11}\),怎么办呢?

我们发现,\(K\) 较大的时候,\(O(\dfrac{N}{K})\) 的暴力反而更优秀。

因此我们只需要选择一个分界点,来决定用哪种方法求解就好了。

这种技巧奇技淫巧也称作——数据点分治

这里选择分界点为 \(10^5\),两种做法的上限都在 \(10^8\) 以内(实测 \(10^3\) 更快,不过可能被卡)。

点击查看代码
#include<bits/stdc++.h>
#define int long long
#define eb emplace_back
using namespace std;
const int N=15,mX=1e5+5,B=10;
int x,l,r,len,a[N],f[N][mX],tmp[N];
bitset<B> vis;
string s;
inline int dfs(int p,int s,bool lim,bool zro){
	if(!p) return !s;
	if(!lim&&!zro&&(~f[p][s])) return f[p][s];
	int ans=0,rig=lim?a[p]:9;
	for(int i=0;i<=rig;i++){
		if(!vis[i]&&(i||(!i&&!zro))) continue;
		tmp[p]=i;
		ans+=dfs(p-1,(s*10+i)%x,lim&&i==a[p],zro&&!i);
	}
	if(!lim&&!zro) f[p][s]=ans;
	return ans;
}
inline int solve(int x){
	len=0;
	while(x) a[++len]=x%10,x/=10;
	return dfs(len,0,1,1);
}
inline bool check(int x){
	string s=to_string(x);
	for(char i:s) if(!vis[i-'0']) return 0;
	return 1;
}
signed main(){
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	memset(f,-1,sizeof f);
	cin>>x>>l>>r>>s;
	for(char i:s) vis[i-'0']=1;
	if(x<mX){
		cout<<solve(r)-solve(l-1)<<"\n";
	}else{
		int ans=0;
		for(int i=(l+x-1)/x*x;i<=r;i+=x) ans+=check(i);
		cout<<ans<<"\n";
	}
	return 0;
}

学到的:使用数据点分治解决问题。

P14094 [ICPC 2023 Seoul R] Special Numbers

考虑使用 \(f[pos][g]\) 记忆化,其中:

  • \(pos\) 表示当前填到第几位。
  • \(g\) 表示填过位置的乘积与 \(k\)\(\gcd\)

根据这个表格我们知道,\(10^{17}\) 内的因数最多的数(即 Highly Composite Numbers,表格数据来源)只有不到 \(65536\) 个因数。所以 \(g\) 的取值不超过 \(65536\) 种,将第二维离散化一下就可以了。

代码中,第二位是用哈希表(gp_hash_table)现用现算的,常数不小,但是目前谷上最优解。

点击查看代码
#include<bits/stdc++.h>
#include<ext/pb_ds/assoc_container.hpp>
#include<ext/pb_ds/hash_policy.hpp>
#define int long long
using namespace std;
using namespace __gnu_pbds;
const int N=25,P=1e9+7;
int k,len,a[N],f[N][65536],idx;
string l,r;
gp_hash_table<int,int> ma;
inline int gcd(__int128 a,int b){
	int r=a%b;
	while(r) a=b,b=r,r=a%b;
	return b;
}
inline int dfs(int p,__int128 fc,bool zro,bool lim){
	int g=gcd(fc,k),gg;
	if(!p) return g==k;
	if(ma.find(g)==ma.end()) ma[g]=gg=idx++;
	else gg=ma[g];
	if(!zro&&!lim&&(~f[p][gg])) return f[p][gg];
	int rig=lim?a[p]:9,ans=0;
	for(int i=0;i<=rig;i++){
		bool tzro=zro&&!i;
		ans+=dfs(p-1,tzro?1:fc*i,tzro,lim&&i==rig);
	}
	ans%=P;
	if(!zro&&!lim) f[p][gg]=ans;
	return ans;
}
inline int solve(string& s){
	len=s.size();
	for(int i=0;i<len;i++) a[len-i]=s[i]-'0';
	return dfs(len,1,1,1);
}
inline bool check(string& s){
	int f=1;
	for(int i:s) if(!((f*=i-'0')%=k)) return 1;
	return 0;
}
signed main(){
	memset(f,-1,sizeof f);
	cin>>k>>l>>r;
	cout<<(solve(r)-solve(l)+check(l)+P)%P<<"\n";
	//字符串-1不好做,左端点单独处理(其实int128就可以)
	return 0;
}

另一种做法是考虑到 \(k\) 若有某个超过 \(10\) 的质因数,则一定无解。因此只需要用质因子 \(2,3,5,7\) 的指数进行记忆化即可。

学到的:用离散化压缩空间规模。

posted @ 2025-09-04 18:16  Sinktank  阅读(73)  评论(0)    收藏  举报
★CLICK FOR MORE INFO★ TOP-BOTTOM-THEME
Enable/Disable Transition
Copyright © 2023 ~ 2025 Sinktank - 1328312655@qq.com
Illustration from 稲葉曇『リレイアウター/Relayouter/中继输出者』,by ぬくぬくにぎりめし.