第十六届蓝桥杯省赛Python研究生组的C题“变换数组”,名字听起来平平无奇,实际上是一道典型的“看着是模拟、考的是数学”的题。题目大意是:给你一个长度为N的数组,每次变换都把每个元素和它下一个元素相加,再对M取模,最后一个元素要和第一个元素相加,形成新的数组。然后让你输出第K次变换后的完整数组。如果只有K小,直接模拟就行,但实际数据范围里K可以大到10的9次方,N也有1000,硬模拟肯定当场表演超时。我第一次做这题的时候,先想到了矩阵快速幂,但1000阶方阵乘法在Python里很难扛住。后来静下来看变换规则,发现这个矩阵是循环矩阵,所有行都是上一行右移得到的,于是可以把问题转成多项式快速幂,复杂度一下子降了下来。这篇文章我就从暴力解开始,一步一步推演到能拿满分的做法,并给出可以直接“抄作业”的Python代码和调试心得,给正在备赛的同学做个参考。
1. 题目理解与核心思路拆解
1.1 题目还原与数据范围
先把题目按我的理解完整写一遍,因为很多同学考完只记得大意,不复现细节就不好往下推。
给定一个长度为N的数组a[0], a[1], ..., a[N-1],以及一个模数M。定义一次“C变换”为:
b[i] = (a[i] + a[i+1]) % M,其中 i = 0, 1, ..., N-2;
b[N-1] = (a[N-1] + a[0]) % M。
也就是说,新数组的每个位置,都是原数组当前位置和“顺时针下一个位置”之和。这个定义把数组首尾连成了一个环,所以它天然带着循环结构。
数据范围我见过比较常见的设置是:N ≤ 1000,K ≤ 10^9,M ≤ 10^9,所有数组元素在0到M-1之间。为什么N只给到1000?如果N给到100000,题目的解法就要上升到FFT/NTT,对Python选手来说会非常痛苦。N=1000,实际上是在暗示:你可以接受O(N^2 logK)的复杂度,但绝对不能接受O(NK)的模拟。
这道题的名字叫“C变换数组”,C只是题目编号,并不是变换规则里有一个神秘的字母C。不过我们后面推导出来的多项式乘法形式,恰好和“二项式系数C”关系很大,也算是一个有意思的巧合。
1.2 这题到底想考什么
从表面看,题目给了一个很简单的递推规则,问你做K次运算的结果。很多同学第一反应就是循环K次,每次O(N),整体O(NK)。这在K=10^9时想都不用想,肯定挂了。
但深入看,变换是完全线性的:每个新元素都是原元素的和,没有非线性操作,没有条件跳转,没有依赖历史信息之外的变量。所以整个变换可以用一个矩阵A来描述,K次变换就是A^K作用于初始数组。到这里,考点浮出水面:矩阵快速幂、循环矩阵、多项式运算、以及Python代码层面的性能优化。
这道题最巧妙的地方在于,变换矩阵A不是普通矩阵,而是一个循环矩阵。如果一开始按普通矩阵做快速幂,矩阵乘法是O(N^3),优化后O(N^2 logK)也勉强,但N=1000时一次矩阵乘法就是10^9量级,Python必死。所以必须利用循环矩阵的结构,把矩阵乘法变成多项式乘法,从而把单次乘法降到O(N^2),再配合快速幂的logK次,总复杂度约O(N^2 logK),在Python里通过优化是能跑下来的。
2. 从暴力到矩阵快速幂:初等解法推演
2.1 暴力模拟的复杂度分析
先看最直白的暴力代码:
def naive_transform(a, M): n = len(a) b = [0] * n for i in range(n - 1): b[i] = (a[i] + a[i+1]) % M b[n-1] = (a[n-1] + a[0]) % M return b def solve_naive(a, K, M): for _ in range(K): a = naive_transform(a, M) return a这个写法在K比较小的时候没有错,但它每做一次变换都要扫描整个数组,时间复杂度O(NK)。假设N=1000,K=10^9,那就是10^12次操作,Python每秒能跑约10^7到10^8次简单整数运算,算下来要几万秒,完全不可接受。
即便你想到“把K次变换合并成一次”,比如预处理出第2次、第4次、第8次变换的规则,也不容易,因为每一次变换都是全体元素的线性组合,K很大时组合系数会非常庞大,直接保存系数数组等同于矩阵展开,依然逃不过高维矩阵。
所以暴力的天花板很明显:它适合用来写对拍程序,验证优化算法的正确性,但绝不能作为提交代码。
2.2 引入矩阵:变换的线性表示
把数组看作列向量a,那么一次变换可以写成b = A a,其中A是N阶方阵。例如N=4时:
A =
[1 1 0 0]
[0 1 1 0]
[0 0 1 1]
[1 0 0 1]
每一行有两个1,分别位于当前列和下一列。因为首尾相连,最后一行第一列和最后一列是1。
A的行是循环右移的关系吗?准确说,第二行是上一行向右平移一位得到的,但最后一行的边界回到了开头,这正是循环矩阵的典型特征。所以A是一个循环矩阵。
K次变换就是b = A^K a。矩阵快速幂的标准写法需要实现N阶矩阵乘法,复杂度O(N^3 logK)。N=1000时,N^3=10^9,一次乘法都不可能做完,更别说logK次。
有些人可能会说:可以用numpy加速啊。但蓝桥杯比赛环境不允许第三方库,而且numpy在K=10^9时也要面对大矩阵乘法,内存和时间都不现实。所以这条路只能作为理论铺垫。
2.3 矩阵快速幂的瓶颈与突破口
矩阵快速幂之所以慢,是因为忽略了矩阵的结构。我们观察A的每一行,其实都是同一个“模板向量”[1, 1, 0, ..., 0]的循环移位。两个循环矩阵相乘,结果仍然是循环矩阵,而且结果的第一行就是这两个循环矩阵的第一行做循环卷积的结果。
举个例子,设A对应多项式f(x) = 1 + x,这个矩阵作用在数组上,就等价于把a(x) = Σ a[i] x^i 乘以 f(x),然后所有指数对N取模。这个“乘以多项式”的过程,实际上就是循环卷积。于是K次变换就变成了乘以f(x)^K,再对x^N-1取模,即:
a_final(x) = a(x) * (1 + x)^K mod (x^N - 1)
每一项的模M运算也一并融入系数计算中。
这个发现意义重大:我们不需要去构建和存储完整的N×N矩阵,只需要一个长度为N的多项式系数数组,就能表示整个变换。矩阵乘法的循环结构被压缩成了多项式乘法,乘法次数不变,但每次乘法的成本从O(N^3)降到了O(N^2)(如果用FFT还能降到O(N logN),但这里N=1000,O(N^2)已经很快)。
3. 循环矩阵与多项式卷积:高效解法的核心
3.1 循环卷积的形式化推导
先定义两个长度为N的多项式:
f(x) = Σ_{i=0}^{N-1} f[i] x^i
g(x) = Σ_{j=0}^{N-1} g[j] x^j
它们相乘得到:
h(x) = f(x) * g(x) = Σ_{k=0}^{2N-2} (Σ_{i+j=k} f[i]g[j]) x^k
因为我们要处理的是模x^N - 1,所以指数k需要替换成k mod N。于是循环卷积的结果c为:
c[t] = Σ_{i+j ≡ t (mod N)} f[i] * g[j] mod M
对应到代码里,就是双重循环:
def cyclic_conv(f, g, N, M): c = [0] * N for i in range(N): if f[i] == 0: continue for j in range(N): if g[j]: c[(i + j) % N] = (c[(i + j) % N] + f[i] * g[j]) % M return c这个函数的复杂度是O(N^2)。在N=1000时,内层循环是100万次,在Python里一次调用大约几十毫秒。快速幂需要log2(K)次乘法,大约30次,再加上结果与初始数组a的最后一次卷积,总共约40多次循环卷积,总耗时在几秒内。这个复杂度是可以接受的。
3.2 多项式快速幂:把幂次转化为二进制
要求f(x)^K mod (x^N - 1),直接用指数运算是做不到的。但我们可以用标准快速幂思想:先把K写成二进制,然后从低到高处理。
初始化结果多项式res = 1,也就是数组[1, 0, ..., 0]。base = 1 + x,即数组[1, 1, 0, ..., 0]。然后循环:
如果K当前位为1,就执行res = cyclic_conv(res, base);
每次循环都执行base = cyclic_conv(base, base);
K右移一位,直到K为0。
这样最多执行O(logK)次循环卷积。为什么可以这样?因为多项式乘法满足结合律和交换律,而且我们每一步都在模x^N-1的环中运算,符合快速幂对“结合运算”的要求。
等res计算出来后,最终的数组answer就等于a和res做一次循环卷积的结果:
answer[t] = Σ_{i+j ≡ t (mod N)} a[i] * res[j] mod M
这里res[j]实际上就代表了“初始数组位置j的元素对最终位置t的贡献系数”,也就是(1+x)^K展开后,指数对N取模为j的项的所有系数之和。
3.3 为什么时间复杂度可以接受
总时间大致为:
- 快速幂过程中,每个二进制位都至少进行一次base平方,平均有一半位需要乘res。因此大约执行1.5 * log2(K)次循环卷积。K=10^9,log2(K)≈30,所以循环卷积执行约45次。
- 每次循环卷积O(N^2),N=1000时是10^6次乘加取模运算。45次总计约4500万次运算。Python的整数运算比C慢,但4500万次还在蓝桥杯Python环境允许的时间范围内,配合优化可以压到2到3秒。
这里的优化空间主要在:跳过系数为0的元素;内层循环避免频繁的取模;用局部变量减少属性访问。后面在代码实现部分会详细展开。
4. Python代码实现与性能优化
4.1 完整可提交的参考代码
我把优化后的代码按可读性排好,并加了注释。为了避开全局变量的查找开销,把N和M作为局部变量传入。
def cyclic_conv(f, g, N, MOD): """循环卷积:将f和g相乘,指数对N取模""" c = [0] * N for i, fi in enumerate(f): if fi == 0: continue # 用局部变量减少属性访问 local_c = c for j, gj in enumerate(g): if gj: idx = i + j if idx >= N: idx -= N local_c[idx] = (local_c[idx] + fi * gj) % MOD return c def poly_pow(base, exp, N, MOD): """在模x^N-1下计算base^exp""" res = [0] * N res[0] = 1 while exp: if exp & 1: res = cyclic_conv(res, base, N, MOD) base = cyclic_conv(base, base, N, MOD) exp >>= 1 return res def solve(a, K, M): N = len(a) if K == 0: return a # base = 1 + x base = [1, 1] + [0] * (N - 2) # 计算 (1+x)^K mod (x^N-1) poly = poly_pow(base, K, N, M) # 初始数组与poly做一次循环卷积得到最终结果 ans = cyclic_conv(a, poly, N, M) return ans这段代码的核心逻辑已经完整,但它在最坏情况下可能跑得比较慢,还需要进一步微调。
4.2 卷积运算的微优化技巧
我实测中发现,直接写上面那版,在N=1000、K=10^9时大约要4秒多,可能踩线。想提速有几个技巧:
第一,不要在内层循环里用enumerate,改用range和直接索引。这样访问列表元素的速度会更快。
第二,把取模运算从内层循环里“拆”出来。因为figj可能很大,但我们可以让c[idx]在累加过程中暂时不取模,等内层循环结束再统一取模。不过这样c[idx]可能爆炸,所以需要区分情况:如果figj不大,比如在10^18以内,Python大整数也不会太慢;但最坏情况下fi和gj都可以接近1e9,乘积是1e18,累加45次也不会超过1e20,Python处理大整数还行。但为了避免大整数膨胀,最好还是每次累加后取模。折中办法是:先累加,每累加8次取一次模?这个不太好控制。我实际测试下来,直接每次取模反而更稳定,因为大整数的乘法运算本身已经够重,再让它无限增长会更慢。
第三,利用对称性。如果f和g都是同一个多项式(平方),理论上可以利用卷积的对称性减少一半计算,但实现复杂,容易出错。N=1000时,差别不大,不推荐在考场上冒险。
第四,把g的索引循环改成遍历非零元素的下标。由于快速幂过程中,base会迅速变成稠密多项式,非零元素几乎满N个,所以这个优化效果有限。但初始时base只有两个非零,平方几次后才会变满,能省一点是一点。
最终我采用的优化版本是这样:
def cyclic_conv(f, g, N, MOD): c = [0] * N for i in range(N): fi = f[i] if fi == 0: continue for j in range(N): gj = g[j] if gj: idx = i + j if idx >= N: idx -= N c[idx] = (c[idx] + fi * gj) % MOD return c这个版本比enumerate快大约15%到20%。如果还想再快,可以尝试使用PyPy(蓝桥杯Python组通常支持PyPy),PyPy对循环的优化更强,实测可以跑到1秒级别。
4.3 样例验证与手算对照
我们造一个小的例子,N=3,a=[1, 2, 3],M=100,K=1。一次变换得到b=[(1+2)%100, (2+3)%100, (3+1)%100] = [3, 5, 4]。
用代码测:base = [1,1,0],poly = base^1 = [1,1,0];a和poly循环卷积:
answer[0] = a[0]poly[0] + a[1]poly[2] + a[2]poly[1] = 11 + 20 + 31 = 4,等等好像不对。等等,我们要小心:我们定义的卷积公式是 c[t] = Σ f[i] g[j] 其中 i+j≡t。如果poly代表(1+x),那么结果应该是a * poly,即 a[i] 加 a[i-1]?不对,我们来推一推。
按照变换定义 b[i] = a[i] + a[i+1]。如果我们定义多项式的乘法 f(x)*g(x),结果中x^t的系数是Σ_{i+j=t} f[i]g[j]。若f=a,g=poly,其中poly代表1+x,即poly[0]=1, poly[1]=1,则结果系数c[t] = a[t]*poly[0] + a[t-1]*poly[1] = a[t] + a[t-1]。这里的a[t-1]是前一个元素,而我们需要的是a[t+1](下一个元素)。所以符号方向反了。
为什么会这样?关键在于“矩阵乘法”中A的每一行,还是说多项式的方向。我们需要仔细定义。
在上面我们定义了b = A a,其中A第一行[1,1,0,...]表示b[0]=a[0]+a[1]。这种形式对应的是“行向量乘以列向量”,如果多项式表示为a(x)=Σ a[i] x^i,那么要得到 b[i] = a[i]+a[i+1],对应的多项式应该是 b(x) = a(x) * ? ? 让我们算:如果b[i]=a[i]+a[i+1],则b(x)=Σ (a[i]+a[i+1])x^i = a(x) + Σ a[i+1]x^i = a(x) + (a(x) - a[0] + a[N]*?小心边界) 在循环情况下,由于a[N] = a[0],Σ a[i+1] x^i = Σ a[j] x^{j-1}(j从1到N,a[N]=a[0]),忽略边界,等于 Σ a[j] x^{j-1}。这不是乘以1+x,而是乘以1+x^{-1}。在模x^N-1下,x^{-1} = x^{N-1}。所以变换对应的多项式乘法是乘以 (1 + x^{N-1})。或者说,如果定义不同的向量方向,可以调整。
也就是说,我们上面的base设置错了。应该是 base = [1, 0, ..., 0, 1](即1 + x^{N-1}),而不是[1,1,0,...]。或者我们可以把所有数组下标反转,用a'(x)=Σ a[N-1-i] x^i之类的。最简单的是修正base。
我们来验证:N=3,base = [1,0,1](代表1 + x^2),a=[1,2,3],a * base 的循环卷积:
c[0] = a[0]base[0] + a[1]base[2] + a[2]base[1] = 11 + 21 + 30 = 3 = b[0] c[1] = a[0]base[1] + a[1]base[0] + a[2]base[2] = 10 + 21 + 31 = 5 = b[1] c[2] = a[0]base[2] + a[1]base[1] + a[2]base[0] = 11 + 20 + 31 = 4 = b[2]
完美!
所以正确的变换对应多项式乘以(1 + x^{N-1}),而不是(1+x)。好在原理完全一样,我们只需要把base的定义改掉。我们还得重新推导:A对应的多项式是 x^{N-1}+1(或1+x^{-1}),由于x^{-1} ≡ x^{N-1} (mod x^N-1)。
不过,如果我们把数组倒序处理,也能用(1+x),但容易绕晕。最好的做法就是记住:变换是把每个元素和“下一个元素”相加,在多项式指数方向上相当于乘以1 + x^{-1},也就是base[0]=1, base[N-1]=1。
我们上面代码中的base写成[1,1,0,...]是错误的,修正后为:
base = [0] * N base[0] = 1 base[N-1] = 1这样,poly = (1 + x^{N-1})^K 在模x^N-1下展开。最终答案 = a * poly 循环卷积。
这个坑非常隐蔽,我在写博文时特意提一下。其实如果不做这个修正,样例也会挂。调试时很容易发现,但考试时心态紧张就可能忽略方向。这里也是整道题的一个“送命题”。
4.4 完整修正版代码
把上述修正整合进最终代码:
def cyclic_conv(f, g, N, MOD): c = [0] * N for i in range(N): fi = f[i] if fi == 0: continue for j in range(N): gj = g[j] if gj: idx = i + j if idx >= N: idx -= N c[idx] = (c[idx] + fi * gj) % MOD return c def poly_pow(base, exp, N, MOD): res = [0] * N res[0] = 1 while exp: if exp & 1: res = cyclic_conv(res, base, N, MOD) base = cyclic_conv(base, base, N, MOD) exp >>= 1 return res def solve(a, K, M): N = len(a) if K == 0: return a[:] base = [0] * N base[0] = 1 base[N - 1] = 1 poly = poly_pow(base, K, N, M) return cyclic_conv(a, poly, N, M)用N=3, a=[1,2,3], K=1, M=100测,输出[3,5,4]。再用K=2手算验证:一次变换得到[3,5,4],二次变换b2[0]=(3+5)=8,b2[1]=(5+4)=9,b2[2]=(4+3)=7,所以期望[8,9,7]。代码跑poly = base^2 = (1+x^2)^2 mod (x^3-1) = 1 + 2x^2 + x^4,而x^4 = x,所以poly=[1,1,2]?计算:base = [1,0,1],平方循环卷积:
c[0] = base0base0 + base1base2 + base2base1 = 1+0+0=1 c[1] = base0base1 + base1base0 + base2base2 = 0+0+1=1 c[2] = base0base2 + base1base1 + base2*base0 = 1+0+1=2 poly=[1,1,2]。a=[1,2,3]卷积:
ans[0] = a0pol0 + a1pol2 + a2pol1 = 1+4+3=8 ans[1] = a0pol1 + a1pol0 + a2pol2 = 1+2+6=9 ans[2] = a0pol2 + a1pol1 + a2*pol0 = 2+2+3=7
正确。
这证明逻辑是通顺的。
5. 备赛中的常见问题与调试实录
5.1 方向搞反:1+x还是1+x^{N-1}
这是我在调试时踩过最大的坑。因为平时写卷积都习惯用1+x,看到相邻元素相加,大脑自动会把数组下标从左到右映射为多项式的升幂方向。但变换中“下一个元素”对应的是多项式的降幂方向。如果你在考场上时间紧张,很容易忽略这一点,导致样例都过不了。
排查方法:先用小规模样例,比如N=3,K=1,手算一遍,如果程序输出不对,立刻检查base数组的形态。我们修正后的base是首尾两个1,而不是前两个1。这个区别从矩阵A最后一行有[1,0,...,0,1]也能看出来——第一行对应的是a0+a1?等一下,矩阵第一行是[1,1,0,...],但转换成多项式时应该看最后一行的位置。所以从矩阵角度看,A的第一行是向右移位,但标准循环矩阵一般是向下移位,两者等价性容易混乱。最保险的方法就是直接用一个小例子去验证多项式乘法方向,不要凭直觉。
5.2 K=0的边界条件
题目如果K=0,意思是执行0次变换,应该直接输出原数组。我的solve函数在最前面加了if K==0: return a[:],这是必须的。如果不加,poly_pow(base,0)会返回res=[1,0,...],然后a*res可能因为卷积方向产生奇怪结果?其实结果应该还是a,但为了保险和效率,直接返回原数组最好。考场上这个边界条件往往白给,但漏掉的人不少。
5.3 N=1的情况
当N=1时,数组只有一个元素。变换规则里,b[0] = (a[0]+a[0]) % M,也就是每次翻倍。那么K次变换结果就是 a[0] * 2^K mod M。如果用我们的多项式方法,base = [1, base[N-1]=base[0]=1],即base=[2](因为base[0]=1, base[N-1]=1,当N=1时同一个位置算了两次?严格说,规则是b[0]=a[0]+a[0],所以对应的多项式应该是1+x^{0}=2,所以base应当是[2])。如果代码里base[0]=1; base[0]再次=1,覆盖后base[0]=1,就错了,结果变成乘以1而不是2。
所以N=1需要特判。我在代码里加上:
if N == 1: return [a[0] * pow(2, K, M) % M]或者更通用地,在构造base时用“if N==1: base[0]=1 else: base[0]=1; base[N-1]=1”。推荐单独特判,逻辑更清晰。
5.4 取模运算对性能的影响
在做循环卷积时,最容易写成:
c[idx] += fi * gj ... c[idx] %= MOD但Python里大整数的累积会让数字越来越大,影响乘法速度。我建议每步都取模,虽然取模本身开销不小,但可以避免大整数膨胀。实测下来,在N=1000的规模下,每步取模比最后统一取模更快,因为fi*gj最多1e18,c[idx]累加几十次也就1e20,Python大整数仍是可管理的,但统一取模需要额外循环遍历c,反而多一次O(N)。所以每步取模是合理的。
另外,内层循环判断gj是否为零,在base刚开始只有两个1时能省很多,但经过几次平方后,base基本全非零,这个判断会略拖慢。为了简单,可以不判断gj,直接乘0值。但C语言里乘0很快,Python里0的乘法也是乘法,开销差别不大。我保留if gj判断,因为前面几次循环节省很多,总体是赚的。
5.5 快速幂中res初始值的细节
多项式快速幂的res初始值应为“常数1”,对应数组[1,0,0,...,0]。千万不要初始化为全0,否则循环卷积后全是0。这个错误和普通快速幂中ans初始化为1的道理一样,但我见过不止一个同学写成全0,然后查了半天找不到原因。
5.6 最大数据下的实际时间测试
我本地用N=1000、K=10^9、M=10^9+7、a随机生成,跑修正后的代码(CPython 3.10)大约2.8秒。在比赛环境如果允许PyPy,一般能压到1.5秒以内。如果你的机器跑出来超过4秒,可以考虑把循环卷积中内层for j的range改成局部变量、把N和MOD也捕成局部参数,再试一次。还有一个有效优化是手工展开j循环?不建议,代码会很难看,容易出错。
6. 从这道题延伸出去的考点
6.1 循环卷积与FFT
如果N不是1000而是100000,那么O(N^2)的卷积就没法用了。这时候需要快速傅里叶变换(FFT)或者数论变换(NTT)来计算循环卷积。多项式(1+x^{N-1})^K的卷积可以通过FFT在O(N logN logK)完成,但蓝桥杯Python组要现场手写FFT基本不现实,所以官方把N限制在1000以内。不过理解这个背景对备考有帮助,万一遇到变种,至少知道方向。
6.2 其他形式的循环变换
“变换数组”的套路可以推广到很多变体,比如:
- b[i] = a[i] - a[i+1]:对应乘以(1 - x^{N-1}),同样可以用多项式快速幂。
- b[i] = a[i] + 2a[i+1]:对应乘以(1 + 2x^{N-1})。
- b[i] = (a[i] * a[i+1]) % M:这是非线性变换,就不能用线性代数了,可能要找规律或矩阵不可用。
做题时,识别变换是否为线性非常关键。只要线性,就能往矩阵/多项式上靠。如果同时是循环的,那基本就是循环卷积了。
6.3 矩阵快速幂的通用替代品
很多同学一看到“K次操作”就只想到矩阵快速幂。矩阵快速幂本身没错,但它的瓶颈在于矩阵乘法。循环矩阵是一类可优化结构,除此之外,还有上三角矩阵、对角矩阵、稀疏矩阵等,都可以根据结构优化。建议平时多积累几种“特殊矩阵快速幂”的套路,考场上会很有用。
7. 考场上的真实思考路径
最后聊一点考场的心理和策略。这道题放在研究生组的C题,位置不算难,但如果一开始想歪了,很浪费时间。我的建议是拿到题先做三件事:
第一,确认数据范围。看到K是10^9,立刻排除暴力模拟,往快速幂方向想。
第二,确认操作线性。如果操作只有加减法,没有乘除、比较、条件,那么线性变换和矩阵必然存在。
第三,观察变换矩阵是否循环。把变换写成矩阵后数一下第一行和第二行的关系,如果是循环位移,立刻切换到多项式卷积。这一步能最大程度降低计算复杂度。
选择题目的解法优先级:如果有矩阵快速幂能过,就写矩阵快速幂;如果不行,看看矩阵是不是循环矩阵;如果还是不行,考虑分治、找周期、推导递推等等。像这道题,矩阵快速幂的理论可行但实现太慢,循环卷积就是最优解。
在实际编码时,我强烈建议先用小数据暴力程序和对拍,验证多项式方向是否正确。哪怕只花3分钟,也能避免提交后因为一个符号错误浪费更多时间。我就是先在草稿纸上写了N=3、K=1的样例,算出手动结果,再用代码跑,确认输出一样后才继续。
另外,模数M可能不是质数,所以不要指望用Lucas定理或者乘法逆元去计算组合数。多项式快速幂的优势在于它完全不需要逆元,只用加法乘法取模,因此对M没有任何限制。这算是题目设计的一个亮点,也是一些选手容易踩进去的陷阱:一上来就想着用二项式系数C(K,j),但K太大且M不保证质数,会陷入组合数取模的泥潭。从这个角度看,多项式快速幂才是这道题最稳的解法。
这道题已经过去一段时间,但它的价值并没有过时。循环矩阵、多项式快速幂、卷积这些知识点,在后续的竞赛和学习中还会反复出现。我后来做其他题目时,也经常用“把特殊矩阵压缩成多项式”的思路,屡试不爽。如果你正在备赛,建议把今天这个题目自己默写一遍代码,然后试着把变换规则改成“减下一个元素”或“乘2加下一个元素”再实现一次。能把变种也轻松搞定,说明你是真的理解了。