同余最短路学习笔记

同余最短路是用来解决:形如「给定 \(n\) 个整数,求这 \(n\) 个整数能拼凑出多少的其他整数(\(n\) 个整数可以重复取)」,以及「给定 \(n\) 个整数,求这 \(n\) 个整数不能拼凑出的最小(最大)的整数」,或者「至少要拼几次才能拼出模 \(k\) 余 \(p\) 的数」的问题时使用的算法。
核心思想是把无限多个状态压缩到有限个余数里。
先来看模板题:P3403 跳楼机
询问用 \(x,y,z\) 三个数能表示出多少个 \([0,h-1]\) 内的数。\(x,y,z \le 10^5\),\(h \le 2^{63}-1\)。
显然把所有数的状态都搞出来是不可能的,先考虑只用 \(x\) 这一个数表示。那所有数就都写成了 \(ax+k\) 的形式,那只要我们让 \(by+cz \equiv k (\bmod x)\),就可以表示余数为 \(k\) 的数了,那么就建立图论模型,\(i\) 号点表示余数为 \(i\) 的数,\(dis_i\) 表示最小的能用 \(y,z\) 表示的同余 \(i\) 的数,我们让 \(i\) 向 \((i+y) \bmod x\) 连边,边权为 \(y\),\(i\) 向 \((i+z) \bmod x\) 连边,边权为 \(z\),跑最短路就得到每个 \(dis_i\)。
那么 \(\frac{h-dis_i}{x}+1\),就是每个同余系的大小了。

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define _int __int128
#define ull unsigned long long
#define pii pair<int,int>
#define fst first
#define scd second
#define pq priority_queue
#define mkp make_pair
#define popcount(x) __builtin_popcount(x)
#define endl '\n'
int h,x,y,z;
const int N = 1e5+10;
struct edge{
    int v,w;
};
vector<edge>g[N];
int dis[N],vis[N];
void dijkstra(){
    memset(dis,0x3f,sizeof(dis));
    pq<pii,vector<pii>,greater<pii> >q;
    dis[1%x]=1;
    q.push({1,1%x});
    while(!q.empty()){
        int u=q.top().scd,d=q.top().fst;
        q.pop();
        if(vis[u])continue;
        vis[u]=1;
        for(auto [v,w]:g[u]){
            if(dis[v]>dis[u]+w){
                dis[v]=dis[u]+w;
                q.push({dis[v],v});
            }
        }
    }
}
signed main(){
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
    cin>>h>>x>>y>>z;
    if(x==1||y==1||z==1){
        cout<<h<<endl;
        return 0;
    }
    for(int i=0;i<x;i++){
        g[i].push_back({(i+y)%x,y});
        g[i].push_back({(i+z)%x,z});
    }
    dijkstra();
    int ans=0;
    for(int i=0;i<x;i++){
        if(h>=dis[i])ans+=(h-dis[i])/x+1;
    }
    cout<<ans;
    return 0;
}

例题:ARC084B
从 \(1\) 开始,我们可以通过加一来让数位之和加一,乘十则数位之和不变来表示出所有数。
那么每个余数代表一整个同余系的最小值,只要 \(i\) 向 \((i+1) \bmod k\) 连权值为 \(1\) 的边,\(i\) 向 \((i \times 10) \bmod k\) 连权值为 \(0\) 的边,然后就随便处理了。答案为 \(dis_0\)。

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define _int __int128
#define ull unsigned long long
#define pii pair<int,int>
#define fst first
#define scd second
#define pq priority_queue
#define mkp make_pair
#define popcount(x) __builtin_popcount(x)
#define endl '\n'
int k;
const int N = 1e5+10;
struct edge{
    int v,w;
};
vector<edge>g[N];
int dis[N],vis[N];
void dijkstra(){
    memset(dis,0x3f,sizeof(dis));
    pq<pii,vector<pii>,greater<pii> >q;
    dis[1]=1;
    q.push({1,1});
    while(!q.empty()){
        int u=q.top().scd,d=q.top().fst;
        q.pop();
        if(vis[u])continue;
        vis[u]=1;
        for(auto [v,w]:g[u]){
            if(dis[v]>dis[u]+w){
                dis[v]=dis[u]+w;
                q.push({dis[v],v});
            }
        }
    }
}
signed main(){
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
    cin>>k;
    for(int i=0;i<k;i++){
        g[i].push_back({(i+1)%k,1});
        g[i].push_back({(i*10)%k,0});
    }
    dijkstra();
    cout<<dis[0]<<endl;
    return 0;
}

转圈技巧
观察这个同余最短路的图,我们会发现存在环。我们把这些数看作一个个加入的,假设模数是 \(m\),当前加入了一个数是 \(x\),则每一个点 \(i\) 向 \((i+x)\bmod m\) 连边,会形成 \(\gcd(x,m)\) 个环。
证明:形成环就是要从当前节点走若干步之后回到远处,则 \(i+k \times x \equiv i(\bmod m)\)。那 \(k \times x\) 就是 \(m\) 的倍数,而我们要找最小的 \(kx\),就是 \(lcm(m,x)\)。又因为 \(lcm(m,x)=\frac{m\times x}{\gcd(m,x)}\),所以环的大小为 \(\frac{m}{\gcd(m,x)}\),那环的个数就是总点数除以每个环的点数,即 \(\gcd(x,m)\)。
那我们就可以在每个环上更新最短路,可以先绕环一圈找到 \(dis\) 最小的节点,从这个节点出发。也可以直接转两圈,保证每个节点都更新到了。
绕一次环是 \(O(m)\) 的,所以总时间复杂度 \(O(nm)\),优于 dijkstra 的 \(O(m \log m)\)。

    m=a[1];
    for(int i=2;i<=n;i++){
        int x=a[i];
        int num=__gcd(x,m);
        for(int j=0;j<num;j++){
            int p=j;
            for(int k=0;k<2*m/num;k++){
                int nx=(p+x)%m;
                dis[nx]=min(dis[nx],dis[p]+x);
                p=nx;
            }
        }
     }

P9140 THUPC2023初赛 背包
这个题在可行性的基础上还加入了权值,选择一个数 \(v_i\) 能获得 \(c_i\) 的利益,那么 \(q\) 次询问恰好拼出 \(V\) 的最大利益是多少,不能拼出输出 \(-1\)。\(v_i \le 10^5\),\(10^{11} \le V \le 10^{12}\)。
因为 \(V\) 很大,我们可以尽可能多的选择性价比最大的物品,即 \(\frac{c_i}{v_i}\) 最大的物品,记其 \(v_i\) 为 \(m\),\(c_i\) 为 \(w\)。因为只选这种物品可能不能刚好拼出 \(V\),所以我们要考虑用其他的物品替换掉一部分 \(i\)。设 \(V \bmod m = r\),那么我们就要去拼出 \(r\) 这个余数,显然有 \(m\) 种。
我们就设 \(f_r\) 表示当余数为 \(r\) 时,其他物品的贡献减去替换掉的 \(i\) 的贡献的最大值。
要令 \(W_总=w \times \lfloor \frac{V-V_{other}}{m} \rfloor + W_{other}\)。因为 \(w \times \lfloor \frac{V}{m} \rfloor\) 是固定的,那么只要考虑后面的 \(W_{other} - w \times \lfloor \frac{V_{other}}{m} \rfloor\),这就是 \(f_r\) 要维护的式子。
如果新加入了物品 \((v_i,c_i)\),令 \(d_i=\frac{v_i}{m}\),那么对它转圈,假设从余数 \(p\) 转移到 \(q\),则 \(q = (p+v_i) \bmod m\),如果 \(q \ge m\),说明我们又替换掉了一整个最佳物品,那 \(d=d_i+1\),所以转移式为 \(f_q=f_p+c_i-d*w\)。记得 \(f_i (i>0)\) 初值都设为 \(-10^{18}\),如果最后依然等于此值说明拼不出 \(r\) 这个余数。
对于每个询问,\(r=V \bmod m\),\(ans = f_r+\frac{V}{m}\times w\)。

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define _int __int128
#define ull unsigned long long
#define pii pair<int,int>
#define fst first
#define scd second
#define pq priority_queue
#define mkp make_pair
#define popcount(x) __builtin_popcount(x)
#define endl '\n'
int n,m,q,w;
const int N = 1e5+10,inf=1e18;
int v[55],c[55],d[55],to[55];
int f[N];
signed main(){
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
    cin>>n>>q;
    m=1;
    for(int i=1;i<=n;i++){
        cin>>v[i]>>c[i];
        if(w*v[i]<m*c[i]){
            w=c[i],m=v[i];
        }
    }
    for(int i=1;i<=n;i++){
        d[i]=v[i]/m;
        to[i]=v[i]%m;
    }
    for(int i=1;i<m;i++)f[i]=-inf;
    for(int i=1;i<=n;i++){
        int num=__gcd(v[i],m);
        for(int j=0;j<num;j++){
            int p=j;
            for(int k=0;k<2*m/num;k++){
                int q=p+to[i],x=d[i];
                if(q>=m)q-=m,x++;
                f[q]=max(f[q],f[p]+c[i]-x*w);
                p=q;
            }
        }
    }
    while(q--){
        int V,ans=0;
        cin>>V;
        int p=V%m;
        ans=f[p]+V/m*w;
        if(ans<-1e17)cout<<-1<<endl;
        else cout<<ans<<endl;
    }
    return 0;
}
posted @ 2026-08-13 21:51  farenara_sayowell  阅读(16)  评论(0)    收藏  举报