同余最短路的转圈技巧

Luogu

众所周知,在同余最短路算法中,我们选取基准物品的体积作为模数 m,并对其它物品的体积 vi 和所有 0j<m,从 j(j+vi)modm 连权值为 vi 的边,跑最短路。

算法介绍

不要从图论的角度考虑问题,而是回归本源:体积模 m 意义下的完全背包。对于体积为 vi 的物品,它在长度为 m 的环上形成 d=gcd(vi,m) 个子环。从一个点出发,不可能绕着子环走一圈再转移回到该点,因为最短路不会经过同一个点两次,否则存在负环。如果重复经过同一个点,那么可以将这两次经过之间加入的所有物品替换为若干基准物品。

因此,往背包加入体积为 vi 的物品时,至多加入 mgcd(vi,m)1 个。对于每一个子环,我们绕着这个环转两圈,即可考虑到所有转移,因为每个点都转移到了子环上其它所有点。时间复杂度 O(nm)

对于普通的完全背包,即边权等于 vi 的问题,我们找到子环上权值最小的点,绕着环转移一圈即可(daklqw)。但是写起来不如转两圈简洁。

例题

P2371 墨墨的等式

以下是经典同余最短路问题「墨墨的等式」的转圈代码。它是普通的完全背包。

#include <bits/stdc++.h>
using namespace std;
constexpr int N = 5e5 + 5;
int n, m, a[N], _a[N];
long long f[N], l, r, ans;
int main() {
  cin >> n >> l >> r;
  for(int i = 1; i <= n; i++) {
    cin >> a[i];
    if(!a[i]) n--, i--;
  }
  if(!n) cout << "0\n", exit(0);
  memset(f, 0x3f, sizeof(f)), f[0] = 0;
  sort(a + 1, a + n + 1), m = a[1];
  for(int i = 1; i <= n; i++) _a[i] = a[i] % m; // 避免多次取模常数太大
  for(int i = 2; i <= n; i++) {
    for(int j = 0, lim = __gcd(a[i], m); j < lim; j++) {
      for(int t = j, c = 0; c < 2; c += t == j) {
        int p = t + _a[i];
        if(p >= m) p -= m;
        f[p] = min(f[p], f[t] + a[i]), t = p;
      }
    }
  }
  for(int i = 0; i < a[1]; i++) {
    if(r >= f[i]) ans += max(0ll, (r - f[i]) / a[1] + 1);
    if(l > f[i]) ans -= max(0ll, (l - 1 - f[i]) / a[1] + 1);
  }
  cout << ans << endl;
  return 0;
}

P9140 背包

本题在完全背包的可行性基础上加入了权值这一维度。

如果我们将 civi 最大的物品选做基准物品,设其体积为 m,价值为 w,那么同样不会经过同一个点,原因是类似的:将一部分其它物品替换为若干基准物品,以最大化单位体积贡献的价值。

对于两组背包方案 (V1,C1)(V2,C2),若 V1V2(modm),该如何衡量这两组方案的优劣呢?

对于一组背包方案 (V,C) 和一次查询 V,若 VV(modm)VV,则其权值为 C+VVmw。因此,对于相同剩余系的所有背包方案 (V,C),我们希望最大化 CVmw,转化为图论就是最长路的 dist。也就是说,当加入物品 (vi,ci)p 转移到 q=(p+vi)modm 时,用于更新 fq 的值为 fp+cip+vimw

根据 wm 的最大性,这样一张包含正负权边的图上不存在正权环(求最长路)。又因为不经过重复点,所以每组剩余系的最优方案对应的 V 不超过 m21010,配合 V1011 的限制保证了正确性。

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int N = 1e5 + 5;
ll n, q, m = 1, w, V, f[N], c[55], v[55], _v[55], _d[55];
int main() {
  ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
  cin >> n >> q;
  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++) _v[i] = v[i] % m, _d[i] = v[i] / m; // 避免多次取模和除法常数太大
  for(int i = 1; i < m; i++) f[i] = -1e18;
  for(int i = 1; i <= n; i++) {
    for(int j = 0, lim = __gcd(v[i], m); j < lim; j++) {
      for(int t = j, _ = 0; _ < 2; _ += t == j) {
        int q = t + _v[i], d = _d[i];
        if(q >= m) q -= m, d++;
        f[q] = max(f[q], f[t] + c[i] - d * w), t = q;
      }
    }
  }
  for(int i = 1; i <= q; i++) {
    cin >> V;
    int p = V % m;
    if(f[p] < -1e17) cout << "-1\n";
    else cout << f[p] + V / m * w << "\n";
  }
  return 0;
}

和其它算法的对比

SPFA 跑同余最短路的复杂度依然是个谜。它的理论上界是 O(|V||E|)O(nm2),但实际表现和小常数 O(nm) 同样优秀。

此外,在可以使用最大体积作为基准元素时,令 fi 除以 m 下取整得 fi,则 fi=fim+i。对于 f,边权只有 01,01-BFS(FZzzz)。

显然,转圈技巧比最短路好写,且适用范围没有任何限制,如 01-BFS 就无法解决第二道例题。

总结

同余最短路的本质是根据单调性值域定义域互换后将完全背包转化为体积模 m 意义下的完全背包。普通完全背包的转移是有向无环图,而体积模 m 的完全背包的转移成环,这让我们想到最短路。最短路不成环,对应原问题就是可以将两次经过同一个点之间添加的所有物品换成若干基准物品。所以,我们可以将完全背包转化为类多重背包问题。

笔者在研究「背包」一题的官方解法时,惊讶于其 “转两圈” 思想的巧妙。翻了一遍经典同余最短路题目,也没找到几篇除了 SPFA 和 Dijkstra 以外的题解,故分享给各位读者。

同余最短路还在写最短路?时代的眼泪!

posted @   qAlex_Weiq  阅读(3321)  评论(3编辑  收藏  举报
相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示