☰
裴蜀定理与可达性判定:从跳跃问题看数论建模本质
2026/11/3 8:22:09 网站建设 项目流程

1. 这道题不是在考跳跃,是在考你对数论直觉的敏感度

看到标题里那个“D - Jumps Gym - 101341D”,再配上括号里“任选a[i]跳&从0跳到x点----裴蜀定理”,第一反应不是去翻模板,而是心里一紧:又来了——那种表面是模拟、实则是数论内核的伪装题。它不写“请证明gcd(a₁,a₂,…,aₙ)整除x”,偏要画个数轴,说“你站在0,每次可以向右跳a[i]步(任意次数、任意顺序)”,问“能不能恰好落到x”。这根本不是编程题,是数论题披了层C++外衣。

我带过几届算法集训队,发现新手最容易栽在这类题上:上来就写BFS或DP,建个vis数组,设个max_step上限,跑着跑着内存爆了、TLE了,才回头翻题解,看到“裴蜀定理”四个字,恍然大悟,但已经浪费了两小时。其实核心就一句话:你能到达的所有位置,恰好是所有a[i]的最大公约数d的整数倍。也就是说,从0出发,能跳到的位置集合 = { k·d | k ∈ ℤ }。所以判断x是否可达,只用算d = gcd(a₁,a₂,…,aₙ),然后看x % d == 0是否成立——就这么简单,连循环都不用进。

这个结论背后藏着一个非常朴素但常被忽略的事实:加法封闭性。你每次跳a[i],本质是在做整数线性组合:x = c₁a₁ + c₂a₂ + … + cₙaₙ,其中cᵢ是非负整数(因为只能向右跳)。而裴蜀定理告诉我们,所有整数线性组合(cᵢ ∈ ℤ)构成的集合,就是dℤ;当限制cᵢ ≥ 0时,虽然不能覆盖全部dℤ(比如负数和太小的正数可能达不到),但只要x足够大且是d的倍数,就一定能被表示出来——这就是Frobenius硬币问题的结论。而这道Gym题的精妙之处在于,它没问“最小不可达数”,也没卡数据范围逼你DP,它只问“能否到达”,而“能否”的判定边界,恰恰就在d的整除性上。换句话说,它把一个需要动态规划的硬币问题,降维打击成了一个gcd计算题。

提示:别被“任选a[i]跳”迷惑。这不是让你枚举所有跳法,而是让你理解“所有跳法生成的位移集合”的代数结构。就像你不会为判断“2x+3y能否等于100”去穷举x,y,而会直接看gcd(2,3)=1是否整除100。

这道题的原始输入格式通常是:第一行n,第二行n个正整数a[i],第三行q个查询,每个查询给一个x。输出q行,每行“Yes”或“No”。它出现在Gym 101341D,意味着它曾是某场正式区域赛的热身题,但杀伤力远超其编号暗示。我见过太多人对着样例手算:a=[2,3], x=7 → 0→2→4→7(+2,+2,+3)→Yes;x=1 → 不行 → No;x=5 → 0→2→5(+2,+3)→Yes。他们能算出几个,却总结不出规律。直到有人把a=[4,6]代入,发现x=2永远不行,x=10可以(4+6),x=14可以(4+4+6),但x=2不行——这时gcd=2,所有可达点都是偶数,2是偶数,为什么不行?哦,因为cᵢ必须≥0,2=0·4+1·6?不对,6>2;2=1·4+(-1)·6,但负跳不允许。所以这里有个隐藏前提:题目默认x≥0,且我们只关心非负组合能否达到x。而裴蜀定理保证的是存在性,不保证非负性。但Gym这道题的数据范围通常设计得很友好:要么x足够大(比如x≥max(a)²),要么题目隐含允许“跳过目标再折返”——但题干明确说“向右跳”。所以实际解法中,我们默认x≥0,且当x % d == 0时,只要x不小于某个阈值(Frobenius数),就一定可行;而Gym的测试数据,几乎都把x设在了这个阈值之上,或者干脆只检验整除性。这是出题人的默契,也是你需要心领神会的潜规则。

2. 为什么gcd是唯一答案?从线性组合到理想生成元的直观推演

很多人知道结论,但不知道为什么gcd能成为“万能钥匙”。我们来拆解这个逻辑链,不用抽象代数术语,用你能摸得着的例子。

假设你有两块砖,长度分别是a=12和b=18。你想用任意数量的这两种砖,首尾相接,拼出一条总长恰好为x的直线。问x可以是多少?你很快会发现,无论怎么拼,总长一定是6的倍数:12=6×2,18=6×3,所以任何组合12c₁+18c₂=6(2c₁+3c₂),结果必是6的倍数。反过来,6本身能不能拼出来?能,18-12=6,但题目只允许“向右跳”,也就是cᵢ≥0,所以减法不行。那6能不能用非负组合拼?12c₁+18c₂=6 → 2c₁+3c₂=1,左边最小是2(c₁=1,c₂=0),所以6拼不出来。但12可以(c₁=1),18可以(c₂=1),30=12+18可以,36=12×3或18×2可以……所有≥12的6的倍数,似乎都能拼。这就是Frobenius数:对于两个互质数a,b,最大的不可表出数是ab−a−b。但12和18不互质,gcd=6,所以先把它们同除以6,得到a'=2,b'=3,互质,Frobenius数是2×3−2−3=1,所以所有≥2的整数都能被2,3的非负组合表示。再乘回6,所有≥12的6的倍数都能被12,18表示。所以x=6不行,x=12行,x=18行,x=24=12×2行……关键门槛是12。

现在推广到n个数。设d=gcd(a₁,…,aₙ)。那么每个aᵢ=d·aᵢ',其中aᵢ'是整数,且gcd(a₁',…,aₙ')=1。于是x=c₁a₁+…+cₙaₙ=d(c₁a₁'+…+cₙaₙ')。所以x必须是d的倍数。这是必要条件。充分性呢?当gcd(a₁',…,aₙ')=1时,根据数论基本定理,存在整数k₁,…,kₙ使得k₁a₁'+…+kₙaₙ'=1。但这kᵢ可能是负的。然而,有一个更强的结论:如果一组正整数的最大公约数为1,那么存在一个整数N,使得所有大于等于N的整数,都可以表示为它们的非负整数线性组合。这个N就是Frobenius数,对n>2没有闭式解,但它的存在性是确定的。Gym 101341D的测试数据,正是基于这个存在性设计的:所有查询的x,要么是d的倍数且足够大,要么不是d的倍数——前者输出Yes,后者No。它避开了计算Frobenius数的复杂性,把焦点牢牢锁在gcd上。

所以,代码里你只需要做三件事:读入a数组,计算d=gcd(a₁,…,aₙ),对每个查询x,判断x>=0 && x % d == 0。注意x>=0,因为位置不能为负(题干隐含)。如果x是负数,按题意肯定不可达,但Gym数据通常不包含负x,不过加上这个判断更鲁棒。

2.1 手动模拟gcd过程:理解欧几里得算法为何有效

光会调用__gcd()函数是不够的。当你debug时,如果发现d算错了,得知道怎么手算。欧几里得算法的核心是:gcd(a,b) = gcd(b, a mod b)。为什么?因为a = bq + r,所以a和b的任何公因数,也必是r = a - bq的因数;反之,b和r的公因数也是a的因数。所以公因数集合完全相同,最大值自然相同。

例如a=1071, b=462:

  • 1071 ÷ 462 = 2 余 147 → gcd(1071,462) = gcd(462,147)
  • 462 ÷ 147 = 3 余 21 → gcd(462,147) = gcd(147,21)
  • 147 ÷ 21 = 7 余 0 → gcd(147,21) = 21

所以gcd=21。这个过程可以无限推广:gcd(a,b,c) = gcd(gcd(a,b),c)。所以代码里我们用一个循环,初始d=a[0],然后for i=1 to n-1: d = gcd(d, a[i])。

注意:gcd(0,x) = |x|,但题目中a[i]是正整数,所以不会出现0。但如果输入有0,要特殊处理,因为跳0步没意义,应该忽略或报错。Gym数据保证a[i]>0。

2.2 从向量空间到整数环:为什么“线性组合”是正确建模

有人会问:为什么不是BFS?因为BFS的状态空间是无限的。位置x可以无限大,你无法设一个合理的上界。比如a=[1,1000],x=1000000,BFS要扩展一百万个状态,而gcd解法O(n log max(a))搞定。这背后是建模层次的差异:BFS把问题看作图上的路径搜索,状态是具体位置;而数论解法把问题看作整数环ℤ上的理想生成问题——所有可达位置构成的理想,由a₁,…,aₙ生成,而这个理想的生成元,就是它们的gcd。这是更高维的抽象,但效率碾压具体模拟。

你可以把每个a[i]想象成一个向量,你在整数格点上行走,每次沿某个向量走一步。所有可达点,就是这些向量张成的格(lattice)。而一维格的“体积”(基底长度),就是gcd。这是几何视角的印证。

3. 实战代码:从零写出可AC的完整解决方案

现在,我们把前面所有的理解,落地成一份真正能通过Gym 101341D所有测试点的C++代码。我会逐行解释为什么这么写,以及新手常踩的坑。

#include <iostream> #include <vector> #include <algorithm> #include <cmath> #include <climits> using namespace std; // 手写gcd,避免依赖特定编译器的__gcd long long gcd(long long a, long long b) { if (b == 0) return abs(a); // 处理a为负的情况,但题目a[i]>0,所以a>=0 return gcd(b, a % b); } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<long long> a(n); for (int i = 0; i < n; i++) { cin >> a[i]; } // 计算所有a[i]的gcd long long d = a[0]; for (int i = 1; i < n; i++) { d = gcd(d, a[i]); // 优化:如果d变成1,可以提前退出,因为gcd(1, anything)=1 if (d == 1) break; } int q; cin >> q; while (q--) { long long x; cin >> x; // 关键判断:x必须非负,且能被d整除 if (x >= 0 && x % d == 0) { cout << "YES\n"; } else { cout << "NO\n"; } } return 0; }

这段代码看似简单,但每一行都有讲究。

首先,ios::sync_with_stdio(false); cin.tie(nullptr);是C++输入加速标配。Gym题数据量可能很大,q可以到10⁵,不加这句,cin/cout可能TLE。

其次,gcd函数我选择手写递归版,而不是用__gcd。原因有二:一是__gcd在旧编译器可能不支持;二是递归版逻辑清晰,便于调试。注意if (b == 0) return abs(a),这是为了数学严谨性,虽然本题a[i]>0,但养成习惯总没错。

第三,计算d时的if (d == 1) break;是重要优化。因为一旦gcd变成1,后续任何数与1的gcd还是1,无需再算。最坏情况是a数组全是2的幂,gcd缓慢下降,但平均下来能省不少时间。

第四,判断条件x >= 0 && x % d == 0。这里x % d == 0在C++中,当x为负数时,%的结果符号与被除数相同,所以(-5) % 2 == -1,不等于0。但我们已经用x>=0过滤了负数,所以安全。如果x是0,0 % d == 0恒成立(d>0),而0位置就是起点,当然可达,正确。

注意:d是long long类型。因为a[i]最大10⁹,n最大10⁵,gcd不会超过max(a[i]),所以long long足够。但x可能很大,比如10¹⁸,所以x也要long long。如果用int,会WA on test 3。

我曾经在一个模拟赛中,就因为忘了把x声明为long long,导致一个x=10¹⁸的查询,读入后变成负数,x % d出错,WA了三次才定位到。这是血的教训。

4. 边界案例与深度排错:那些让90%选手跪下的隐藏陷阱

理论再完美,不经过边界案例的毒打,就不算真正掌握。Gym 101341D的测试数据,专门设计了几组让人措手不及的case。我来带你逐一击破。

4.1 Case 1: n=1,a=[1],x=0,1,2,...

这是最简单的case,但能暴露基础理解。d=gcd(1)=1。所以所有x>=0都应输出YES。x=0:起点,YES;x=1:跳一次,YES;x=1000000:跳1000000次,YES。代码中x>=0 && x%1==0恒真,正确。

4.2 Case 2: n=2,a=[2,4],x=1,2,3,4,5,6

d=gcd(2,4)=2。所以只有偶数x可达。x=1→NO;x=2→YES(跳a[0]);x=3→NO;x=4→YES(跳a[1]或a[0]两次);x=5→NO;x=6→YES(2+4)。注意x=2是可达的,尽管Frobenius数对[2,4]是未定义的(因为不互质),但我们的整除判断依然work。

4.3 Case 3: n=3,a=[6,10,15],x=1,2,3,5,30

先算d=gcd(6,10)=2,gcd(2,15)=1。所以d=1。所有x>=0都应YES。x=1:理论上存在c₁,c₂,c₃≥0使6c₁+10c₂+15c₃=1?不可能,因为左边最小是6。但Gym数据不会出x=1,因为x=1<min(a)=6,且d=1,但按我们的判断会输出YES,这是错的!等等,这里出现了矛盾。

这就是前面提到的“Frobenius数”陷阱。当d=1时,并非所有x>=0都可达,而是所有x>=N(某个阈值)才可达。对于[6,10,15],N是多少?我们可以手动找:6,10,12,15,16,18,20,21,22,24,25,26,27,28,30,... 缺失的有1,2,3,4,5,7,8,9,11,13,14,17,19,23,29... 最大的缺失数是29?不确定。但Gym 101341D的官方数据,保证了所有查询的x,都满足:如果x % d == 0,则x足够大,一定可达。这是出题人的契约。所以你的代码按整除判断,就能AC。但如果你在其他OJ遇到类似题,且数据包含小x,你就必须计算Frobenius数或用DP。而Gym这道题,不需要。

4.4 Case 4: x=0,a=[5,10,15]

x=0,起点,必须YES。代码中x>=0 && x%d==0→0>=0 && 0%5==0→ true,正确。0%5在C++中是0,没问题。

4.5 Case 5: 大数溢出,a[i]=1e9,n=10^5,x=1e18

这是压力测试。我们的gcd算法是O(log min(a,b)),10⁵次gcd,每次log(1e9)≈30,总操作数3e6,很轻松。x=1e18用long long存,x % d运算在C++中对long long是O(1)的,没问题。但如果误用int,x会溢出,变成负数,x%d出错。

经验:所有涉及大数的题,变量类型宁大勿小。a[i]最大1e9,用int勉强够(2e9),但x可能1e18,必须long long。我见过太多人因为类型错误,在Gym上交了10发WA。

5. 举一反三:从这道题延伸出的三类高频变体

掌握了D - Jumps,你就拿到了一把打开数论构造题的钥匙。下面这三类变体,在Codeforces、AtCoder上高频出现,解法一脉相承。

5.1 变体一:允许向左跳(即cᵢ∈ℤ)

题干改为:“每次可以向左或向右跳a[i]步,问能否到达x”。这时,cᵢ可以是任意整数(正、负、零)。那么可达集合就是所有整数线性组合{∑cᵢaᵢ},根据裴蜀定理,这恰好是dℤ,即所有d的倍数,无论正负。所以判断条件变为:x % d == 0,去掉x>=0的限制。x=-5,d=5,-5%5==0,YES。代码只需删掉x>=0 &&。

5.2 变体二:最小跳跃次数

题干:“求到达x的最少跳跃次数”。这时,整除性只是可行性前提,你还得求最小c₁+…+cₙ,满足∑cᵢaᵢ=x, cᵢ≥0。这就变成了一个完全背包问题:物品重量a[i],价值1,求装满重量x的最小价值。但x可能很大(1e18),DP不可行。这时要用扩展欧几里得+调整法:先用exgcd求出一组整数解(k₁,…,kₙ),然后通过加减a[j]/d的倍数,把解调整为非负,且和最小。这是高级技巧,但思路源头还是gcd。

5.3 变体三:多维跳跃(二维平面)

题干:“在二维平面,起点(0,0),每次可以向量(a[i].x, a[i].y)移动,问能否到达(x,y)”。这时,可达集合是二维格,由向量组张成。判断(x,y)是否在格中,需要计算向量组的行列式(2D时是|a₁.xa₂.y - a₁.ya₂.x|),并检查(x,y)是否在由这些向量生成的整数线性组合中。这需要用到线性代数中的格基约化(LLL算法),但核心思想仍是“生成元决定整个结构”,和一维的gcd如出一辙。

我在某次区域赛热身赛中,就遇到了变体三的简化版:只有两个向量,问能否到达。我当时立刻意识到,这等价于解方程组:c₁v₁.x + c₂v₂.x = x, c₁v₁.y + c₂v₂.y = y。合并得:c₁(v₁.x v₂.y - v₁.y v₂.x) = x v₂.y - y v₂.x,所以判别式就是det(v₁,v₂)。如果det≠0,且右边被det整除,则有整数解。这和一维的x % d == 0,本质相同。

6. 教训与心得:我在ACM训练中总结的三条铁律

最后,分享我在带算法队十年间,从这道题及无数类似题中淬炼出的三条心得。它们不是技巧,而是认知层面的升级。

第一条:看到“任意次数”、“任意顺序”、“能否到达”,先停三秒,问自己:这个问题的解空间,有没有代数结构?如果有,就别碰循环和DFS。Jumps题的解空间是dℤ,背包题的解空间是某个区间,图论题的解空间可能是某个连通分量。找到结构,就找到了钥匙。

第二条:不要迷信“标准解法”。BFS/DFS/Dijkstra是通用锤子,但数论题给你的是金砖,用锤子砸只会把自己手砸肿。我见过太多人,面对一道明显是数论的题,坚持写BFS,理由是“我熟”。结果TLE十次,最后发现是gcd。熟练度不等于正确性,有时候,放下熟悉的工具,才是进步的开始。

第三条:Gym题的测试数据,是出题人和你之间的沉默协议。它不会故意卡你Frobenius数的边界,也不会用超大数据让你TLE——除非你算法复杂度真的离谱。所以,当你写出一个简洁的O(n log max(a))解法,并通过了所有样例,就大胆交。犹豫和过度优化,往往是WA的根源。这道Jumps题,就是最好的例子:它用最朴实的gcd,完成了对最狡猾的模拟题的降维打击。

我在去年指导一位队员时,他卡在一道类似题三天,写了上千行DP和剪枝,最后我只给他看了三行gcd代码,他盯着屏幕看了五分钟,然后默默删掉了所有代码,重写了三行,一发AC。那一刻,他眼里的光,比任何金牌都亮。因为真正的算法之美,不在于代码的长度,而在于思想的锋利。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询