☰
环形传球计数问题:从动态规划到组合公式与矩阵快速幂
2026/10/14 5:11:29 网站建设 项目流程

把小学课间那个“传一圈球”的游戏搬进算法题里,就成了一个很典型的组合计数模型:n个人围成一圈,编号0到n-1,球从0号人手里出发,每次只能传给相邻的人,问恰好传m次后,球回到0号人手里的路线一共有多少种。第一次接触“环状路径计数”的人,很容易直接DFS枚举所有路线,m=20就跑不动了。我辅导A同学时遇到过这个题,他一开始就是DFS,后来把问题拆成“状态叠加”的递推,才发现核心其实很规整。

这篇文章把完整的思考过程写下来,包括最稳妥的动态规划、适合中等规模的组合公式、以及面向超大步数的矩阵快速幂,最后还会列几个特别容易被忽视的边界坑。内容适合刚开始接触计数类算法题的读者,也适合准备面试复盘的人。

1. 传球游戏为什么会变成一个计数难题

1.1 题目不长,但直接枚举会爆炸

先把题目压缩成一行:环上有n个点,从0出发,每步只能走到相邻点,求走m步回到0的方案数。

每传一次球,就有“向左”或“向右”两种选择。m次传球对应2^m条完整路线:m=10时是1024条,还能手数;m=30时超过十亿,直接跑不动;m=60时已经相当于把全人类的传球动作都枚举一遍。而且环形路径上大量路线是重复对称的,看起来毫无规律,这也是不少人一开始不敢动手的原因。

1.2 但换个角度看,问题突然变简单了

先看一个最小例子:n=4,m=2。从0出发走两步回到0,只有两种走法:0→1→0,0→3→0。再看n=4,m=3,无论怎么走都回不来,因为每一步都在改变球与0号之间的距离奇偶性,三步之后距离一定不是偶数,更不可能是0。

这个“奇偶性”观察可以砍掉一半情况,但它还没有触及本质。

真正的关键跳跃在于:虽然完整路线很多,但在第t步,我们并不需要知道球是怎么走到的——只需要知道球此刻在谁手里。因为下一步怎么走,只取决于当前人是谁,跟前面走过的历史没有任何关系。这就是计数问题里的“无后效性”。

承认这一点之后,解题思路就从“枚举路线”切换成“叠加方案数”:记录每个位置在第t步分别有多少种到达方式。这就是动态规划的雏形,也是最不容易出错的基础解法。

2. 环状模型的数学抽象:把“传”翻译成状态转移

2.1 环图、邻接与取模索引

把n个人看成一个环状图,每个人是一个顶点,相邻关系是一条边。每次传球等于沿环的边走一步。因为环是首尾相接的,0号的左边是n-1,右边是1;n-1号的左边是n-2,右边是0。

把“球在第t步停在i号人手里”的方案数记为dp[t][i]。转移关系写成:

dp[t+1][i] = dp[t][(i-1+n)%n] + dp[t][(i+1)%n]

这里先加n再取模,是为了在C/C++这类语言里避免负数取模的坑。用Python写可以少担心这件事,但代码里统一写成(i-1+n)%n也是好习惯。

2.2 为什么状态空间只有n维

有人会问:为什么只需要n维状态,不需要记住球是从哪边传来的?因为传球规则只由“当前人是谁”决定,历史不影响未来。这看着简单,其实是很多计数题的命门。

如果题目改成“不能把球传回刚刚接球的人”,那状态就必须记录“上一手是谁”,否则没法判断下一步能不能传。状态空间会从n维膨胀到n×(n-1)维,甚至更多。这个对比说明:状态设计不是凭空来的,它由“什么信息会影响未来决策”决定。

2.3 环的特殊性:平移不变性

环状结构带来一个额外好处:每个顶点的度都是2,转移表高度规整。如果把整个递推写成矩阵,每一行只有两个位置是1,其他全是0,而且每一行都是上一行平移一位得到的。

这种“平移不变性”不是巧合,它是后面矩阵快速幂、循环矩阵特征值法以及组合公式得以成立的根本原因。换成树或随机图,就没有这么漂亮的结构了。

3. 动态规划递推:最朴素也最不容易错的解法

3.1 递推式与滚动数组实现

用Python写基础版本非常短:

MOD = 10**9 + 7 def solve_dp(n, m): if m == 0: return 1 # 不传,球已经在0号手里 dp = [0] * n dp[0] = 1 for _ in range(m): ndp = [0] * n for i in range(n): ndp[i] = (dp[(i - 1) % n] + dp[(i + 1) % n]) % MOD dp = ndp return dp[0]

两层循环,外层m次,内层n次,时间复杂度O(nm)。空间用滚动数组降到了O(n),不会被n和m的乘积拖垮内存。

当n和m都在几百或者几千的量级时,这个写法最稳,也是我建议先写的版本。

3.2 手推n=4,m=4的完整状态

拿n=4,m=4验证一下。初始状态dp[0] = 1,其余是0:

tdp[0]dp[1]dp[2]dp[3]
01000
10101
22020
30404
48080

答案就是最左边的dp[0] = 8。这个表也是后面所有优化公式的“基准答案”,我用它手动验证组合公式很多次。

3.3 先写DP的最大价值:当对拍基准

很多同学一上来就套组合公式,结果k的枚举边界写错,算出的答案自己都解释不了,最后还得回头补一个DP版本对拍。

我的建议是:无论最终要写什么高级解法,都先把DP写出来当“标准答案”。DP逻辑直白,不容易写错,小数据下可以快速验证其他方法的正确性。这个习惯在算法题里能救命。

4. 组合数学公式:从 O(nm) 到 O(m) 的关键跳跃

4.1 把传球序列看成“左右二选一”

从另一个角度观察:m次传球,本质上就是m次独立选择——向左还是向右。

设向右次数为R,向左次数为L,那么R+L=m。环上坐标要模n,从0回到0,等价于向右净位移减去向左净位移是n的整数倍:

R - L = k·n

把R和L解出来:

R = (m + k·n) / 2
L = (m - k·n) / 2

只要这两个数都落在[0,m]且为整数,那么“具体哪几步向右”可以在m个位置里任意选,贡献C(m, R)种路线。把满足条件的所有k求和,就是最终答案:

ans = Σ C(m, R)

4.2 代码实现:枚举R而不是枚举k

理论上可以枚举k,但枚举R更稳:

def solve_comb(n, m): if m == 0: return 1 if m % 2 == 1: # 奇偶性剪枝 return 0 fact = [1] * (m + 1) for i in range(1, m + 1): fact[i] = fact[i - 1] * i % MOD inv_fact = [1] * (m + 1) inv_fact[m] = pow(fact[m], MOD - 2, MOD) for i in range(m, 0, -1): inv_fact[i - 1] = inv_fact[i] * i % MOD def C(n, k): if k < 0 or k > n: return 0 return fact[n] * inv_fact[k] % MOD * inv_fact[n - k] % MOD ans = 0 for R in range(m + 1): L = m - R if (R - L) % n == 0: ans = (ans + C(m, R)) % MOD return ans

这里故意枚举R,而不是枚举k。虽然两者复杂度都是O(m),但枚举R时L自动落在[0,m],不需要纠结k的上下界,更不需要担心R超出范围,逻辑上少了一半出错机会。

4.3 公式的适用范围与一个例子

这个公式需要预处理到m的阶乘和逆元,所以m在10^6到10^7量级时用起来很舒服。m如果再大,阶乘数组根本开不出来。

看一个具体例子:n=8,m=6。因为m<n,k只能取0,所以R=L=3,答案就是C(6,3)=20。这也很好理解:6步之内绕不完一圈,能不能回到0完全取决于“直线上左右各走3步”,跟环的存在没有关系。

再看n=4,m=6:k可以取0、1、-1。k=0对应R=3,L=3,贡献C(6,3)=20;k=1对应R=5,L=1,贡献C(6,5)=6;k=-1对应R=1,L=5,贡献C(6,1)=6。总数32,和DP结果完全一致。

5. 矩阵快速幂与循环矩阵:当传球次数暴涨时怎么办

5.1 把递推写成转移矩阵

当m大到DP跑不动、组合公式又开不出数组时,需要矩阵快速幂。

把状态写成向量v_t = (dp[t][0], dp[t][1], ..., dp[t][n-1])^T,转移关系就是:

v_{t+1} = M · v_t

其中M是环的邻接矩阵:M[i][(i-1+n)%n] = 1,M[i][(i+1)%n] = 1,其余为0。

于是v_m = M^m · v_0。球回到0号的方案数,恰好是M^m第0行第0列的元素。

5.2 通用矩阵快速幂模板

def mat_mul(A, B, n): C = [[0] * n for _ in range(n)] for i in range(n): for k in range(n): if A[i][k]: aik = A[i][k] Bk = B[k] for j in range(n): C[i][j] = (C[i][j] + aik * Bk[j]) % MOD return C def mat_pow(M, p, n): res = [[1 if i == j else 0 for j in range(n)] for i in range(n)] while p: if p & 1: res = mat_mul(res, M, n) M = mat_mul(M, M, n) p >>= 1 return res def solve_matrix(n, m): M = [[0] * n for _ in range(n)] for i in range(n): M[i][(i - 1) % n] = 1 M[i][(i + 1) % n] = 1 P = mat_pow(M, m, n) return P[0][0]

复杂度O(n^3 log m)。n=100,m=10^18时,大约是100^3×60≈6000万次核心运算,常规评测环境下可以接受。

5.3 循环矩阵:从“够用”到“优雅”

通用矩阵快速幂在小n下够用,但n到500或1000就会力不从心。这时候要利用M的特殊结构:M是循环矩阵,每一行都是上一行向右平移一位。

循环矩阵与自身相乘、求幂,结果仍然是循环矩阵。所以做快速幂时不需要保存完整n×n矩阵,只需维护第一行,乘法可以看成循环卷积,用NTT优化后复杂度降到O(n log n log m)。这个优化实战中用得不算多,但它解释了“环”这个结构为什么特殊——不是任何图的邻接矩阵都这么规整。

理解层面还有一个视角:循环矩阵的特征值是λ_j = 2cos(2πj/n),j=0到n-1。M^m的特征值就是λ_j^m,因此答案可以写成(1/n)Σλ_j^m。这个式子和组合公式能互相印证,但浮点计算容易有精度问题,真实代码更推荐NTT或矩阵快速幂。

6. 边界条件与经典坑位排查

6.1 第一坑:奇偶性剪枝不要误伤n

m为奇数时,答案一定为0,因为每一步都会让球与0号的距离奇偶性翻转。这个剪枝在任何解法前都可以加。

但别以为这样就完了。当n是奇数时,绕一整圈需要奇数步。比如n=3,m=3,答案不是0,而是2:0→1→2→0和0→2→1→0。奇偶性剪枝只对m有效,不要顺手把n也剪进去。

6.2 第二坑:n=2时的“方向”和“人”语义冲突

这是最值得说清的坑。n=2时,0号的左边和右边都是1号,1号的左边和右边都是0号。如果题目语义是“顺时针/逆时针各算一种传法”,那么n=2时每一步都有两个等价选择,0和1之间的方案数会以2的幂爆炸。

比如m=2,按方向序列算是4种:(左,左)、(左,右)、(右,左)、(右,右);但按“传给另一个人只有一种方式”理解,答案应该是2。递推式默认的是前者,因为它把0号的左右两端当成两个独立方向。很多题目为了避免歧义,会直接保证n≥3,或规定传球相邻且方向可区分。做题前务必先确认语义,否则n=2的测试数据会把代码打爆。

6.3 第三坑:组合公式的越界和奇偶判断

枚举R的实现中,L=m-R自动落在[0,m],越界问题少;但如果换成枚举k,就要小心R=(m+k·n)/2可能算出负数或超过m的值,还要判断分子是否为偶数。我的习惯是组合公式只枚举R,然后判断(R-L) % n == 0,用最简单的方式绕开边界。

6.4 第四坑:逆元不要重复求

预处理阶乘逆元时,inv_fact[m] = pow(fact[m], MOD-2, MOD)只算一次,然后从后往前递推。不要对每个阶乘都单独求逆元,那样会白白增加O(m log MOD)的耗时。

6.5 对拍脚本是最可靠的兜底

写一个随机测试脚本,n取2到10,m取0到12,用DP结果当标准答案,组合公式和矩阵快速幂的结果必须完全一致。我靠这个脚本抓出过自己公式里的三个bug,其中两个都和n=2的语义相关。多花五分钟对拍,能省下一晚上排查。

7. 从“环”到“任意图”:一类计数题的底层通用套路

7.1 规则稍微一变,状态设计就跟着变

前面全部建立在“只能传给相邻的人”这条规则上,题目改几个字,模型就要改:

  • 每次可以传给任意一个人,且不能传给自己:这是完全图,答案直接是(n-1)^m。
  • 不能把球传回上一手:状态要从“当前人”扩展成“上一手+当前人”,递推用二维数组。
  • 顺时针传有a种方式、逆时针有b种:递推式变成ndp[i] = a·dp[(i-1)%n] + b·dp[(i+1)%n],组合公式也升级成带权二项式系数。

这些变化印证了前文的结论:状态由“什么信息会影响未来决策”决定,而不是照搬模板。

7.2 我遇到这类题目的固定思考顺序

先花两分钟手推一个最小例子,比如n=4、m=4,把答案算出来;然后写朴素的DP版本,用它把所有边界情况验证一遍;再根据n和m的范围决定优化方式:m小就用DP,m中等用组合公式,m巨大用矩阵快速幂;最后保留DP作为对拍器。

自测题可以来一道:n=5,m=7,答案是多少?用DP跑一遍,再用组合公式交叉验证。如果两边结果一致,并且n=2、m奇数这些用例没有报错,就基本可以放心了。

7.3 最后的实操体会

这类环状路径计数题我前前后后处理过好几次,踩坑最多的不是递推本身,而是对“一次传球到底有几种动作”的理解不一致。所以现在拿到题的第一件事,不是急着写代码,而是先把n=2和m=0这两个极端用例想清楚——它们最能暴露底层模型的定义问题。

组合公式的枚举边界和奇偶判断,我习惯单独提出来写成小函数并加注释,不和主逻辑混在一起。这些细节看着琐碎,但调试时会替你省下大量时间。把一个简单的传球游戏讲透之后,环形图上的随机游走、带权转移、高次矩阵幂这些概念就都串起来了,下次遇到类似题目,第一反应会从“枚举”直接跳到“建模”,这才是这篇内容真正想留给你的东西。

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

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

立即咨询