☰
CF1452D题解:信号塔覆盖为何对应斐波那契数列
2026/10/13 4:49:02 网站建设 项目流程

CF1452D 这道题,剥掉信号塔那层包装后,核心就一句话:把 n 拆成若干个正奇数段的和,问有序拆分方案数是多少。答案落在斐波那契数列上,前几项打出来就是 1、1、2、3、5、8。我第一次看到这个结论时其实是有点懵的——整数拆分的计数怎么就和一个兔子数列扯上关系?后来把递推、生成函数都完整走了一遍,才觉得这事理所当然。这篇文章就把“为什么是斐波那契”讲透,再给出赛场上能直接落地的代码方案,给还在刷 Codeforces 的组合计数题的同学做个参考。

1. 从信号塔覆盖到奇数拆分:题目的本质是什么

1.1 先把原题场景还原一下

CF1452D 的题面不长:有 n 个城市排成一排,编号 1 到 n。你可以决定在一些城市放信号塔,每个塔都会以自己为中心,向左右两个方向广播相同长度的范围。换句话说,如果一个塔放在城市 i,它覆盖的是一段以 i 为中点、长度为某个正奇数的连续区间。最后要求所有城市“恰好被一个塔覆盖”,不能漏,也不能重,问一共有多少种放置方案,答案对 998244353 取模。

这里第一个要抓住的点是:为什么覆盖区间一定是奇数长度?因为塔的左右广播距离是相同的。设它向单侧广播 x 个城市,那覆盖区间就是向左 x 个、向右 x 个、再加自己,总长度 2x+1。x 可以取 0,也就是塔只覆盖自己所在城市,此时长度是 1。所以每个塔的覆盖区间一定是奇数长度:1、3、5、7 这样往下走。

第二个点在于“恰好被一个塔覆盖”。这句话同时约束了两件事:一是任意两个塔的覆盖区间不能有交集,否则交集部分会被覆盖两次;二是整个 [1, n] 范围不能被漏掉任何城市,否则不满足“每个城市恰好被覆盖”。于是所有塔的覆盖区间拼接起来,必须不多不少地铺满整条线段。

到这一步,问题其实已经从“放置塔”变成了“把一条长度为 n 的线段切成若干段,每段长度必须是奇数”。这个转化是整个题目的题眼,认不出来就卡死,认出来了后半程就是水到渠成的事。

1.2 为什么覆盖问题能变成拆分问题

我们需要确认两件事之间是一一对应关系:一个合法的塔放置方案,对应一个唯一的有序奇数拆分;反过来,一个有序奇数拆分,也对应唯一一个合法的塔放置方案。

从方案到拆分很简单:从左到右看每个塔覆盖的城市区间,依次记下它们的长度。每个区间长度都是奇数,总长度加起来正好是 n,这就是一个有序奇数拆分。注意顺序是有意义的,因为城市是一字排开的,先出现长度 3 的区间再出现长度 1 的区间,和先出现长度 1 再出现长度 3,是完全不同的两种覆盖。

从拆分到方案同样直接:给定一个奇数拆分,比如 [3, 1, 1],从左边界城市 1 开始,先划出长度为 3 的段 [1, 2, 3],再划出长度为 1 的段 [4],最后划出长度为 1 的段 [5],然后在每段的中点放置一个塔。奇数段的中点必然落在整数城市上,且不可能越界,所以这个塔放在城市 2、城市 4、城市 5,每个塔半径分别为 1、0、0,恰好覆盖整条线段。

这里有个容易忽略的细节:在一个奇数段内部,塔的位置是唯一的,就是段的中点。所以给定拆分后,塔的位置不会被“多段共用一个塔”或“一段放多个塔”所干扰。正是这种唯一性保证了方案数没有被放大或缩小,计数就是严格的拆分计数。

我还记得当时自己犯过的一个混乱:以为拆分 [1, 3, 1] 和 [1, 1, 3] 可能对应同一个放塔方案。其实不对,前者的塔分别覆盖城市 1、城市 2~4、城市 5,后者的塔分别覆盖城市 1、城市 2、城市 3~5,布局完全不同。所以有序拆分中的顺序非常关键,不能把它当成无序的整数分拆。

1.3 两个容易忽略的约束细节

第一个细节是覆盖范围是否可以超出城市边界。答案是可以。比如城市 1 放一个半径为 1 的塔,它覆盖的范围在数轴上可能是 [-1, 1],但实际有意义的城市只有 1。这个“越界”不会带来额外方案,因为在拆分视角下它仍然对应一个长度为 1 的段。更准确地说,一个合法的最终覆盖方案,其有效覆盖部分一定是把 [1, n] 完整剖分,越界部分只是覆盖了不存在的城市,不参与计数。

第二个细节是塔不能放在城市之外。这一点在拆分视角下自动满足:任何奇数段的中点一定落在段内,而段又完全落在 [1, n] 内,所以中点一定是某个城市编号。不需要在代码里做任何额外判断,这也是这个转化干净利落的地方。

如果把这两个细节想清楚,原题就彻底变成一道纯粹的计数题:n 可以拆成多少个有序的正奇数序列之和。这个问题和信号塔、无线电、城市分布统统没有关系了。

2. 为什么方案数恰好是斐波那契数列

2.1 最直观的递推:按第一段长度分类

设 f[n] 为长度为 n 的线段的有序奇数拆分方案数。考虑拆出来的第一段,它的长度 k 必须是正奇数,范围从 1 到 n。第一段定了之后,剩下长度为 n-k 的部分仍然是同样的问题,所以可以写出:

f[n] = f[n-1] + f[n-3] + f[n-5] + ...

这个式子本身是正确且自然的,但它不是一个标准递推,而是一长串累加。如果每次都用这个式子算,复杂度是 O(n^2),不够优雅。关键的一步是把 f[n-2] 也写出来:

f[n-2] = f[n-3] + f[n-5] + f[n-7] + ...

对比两个式子会发现,除了 f[n-1] 单独出现在第一个式子中,剩下的项完全一样。于是两式相减直接得到:

f[n] = f[n-1] + f[n-2]

这就是斐波那契递推。这个推导非常干净,根本不需要讨论拆分的内部结构,只需要对“第一段长度”做分类。

这里的边界约定是 f[0] = 1,代表长度 0 的唯一“空拆分”。为什么需要它?因为当第一段长度恰好等于 n 时,剩下的长度是 0,这个情况必须算一种方案。比如 n=3 时,f[3] = f[2] + f[0] = 1 + 1 = 2,对应 [3] 和 [1,1,1] 两种拆分,不多不少。

不过要注意,这个 f[0] = 1 的约定不能无脑代入 f[2] = f[1] + f[0],那样会得到 2,而实际 f[2] = 1(只有 [1,1] 这一种)。隔项相减的推导适用于 n≥3,n=2 需要单独处理。这个坑在第四部分会专门展开。

2.2 换一个视角:把最后一段减 2

如果觉得“第一段长度分类再隔项相减”还是有点抽象,可以换到最后一个段上去看,这个视角会更直观。

把长度为 n 的一个奇数拆分拿出来,看最后一段:

情况一:最后一段是 1。直接从拆分里删掉这个 1,剩下的是一个长度为 n-1 的拆分。反过来,任意长度为 n-1 的拆分,在末尾接上一个 1,都能得到长度为 n 的拆分。所以这一类恰好有 f[n-1] 个。

情况二:最后一段是某个大于 1 的奇数。既然是奇数,它至少是 3。把这个段减掉 2,它依然是一个正奇数。比如 3 变成 1,5 变成 3,7 变成 5。于是整个拆分变成长度为 n-2 的拆分。反过来,任意一个长度为 n-2 的拆分,把它最后一段加上 2,也能得到一个最后一段大于 1 的长度为 n 的拆分。这两个操作互逆,所以这一类恰好有 f[n-2] 个。

这个“减 2”的证明很漂亮,它本质上利用了奇数之间的最小差值是 2。奇数减 2 仍然保持奇偶性,这是整个映射成立的基石。但需要特别说明:n=2 时不能用这个递推,因为此时最后一段大于 1 的情况根本不存在,而长度为 0 的拆分也没有“最后一段”可以拿来加 2。所以初值必须定义为 f[1]=1、f[2]=1,递推从 n=3 开始。

2.3 生成函数:把结论验证到毫无悬念

如果递推证明还不能让你完全放心,生成函数是最后的验证手段。单个奇数段的生成函数是:

O(x) = x + x^3 + x^5 + ... = x / (1 - x^2)

一个有序拆分等价于任意多个奇数段拼接成的序列,所以拆分方案的生成函数是:

F(x) = 1 + O(x) + O(x)^2 + O(x)^3 + ... = 1 / (1 - O(x))

代入 O(x) 并化简:

F(x) = 1 / (1 - x/(1-x^2)) = (1 - x^2) / (1 - x - x^2)

分母 1 - x - x^2 一出现,斐波那契递推已经写在脸上了。再看展开系数:

1/(1-x-x^2) = 1 + x + 2x^2 + 3x^3 + 5x^4 + 8x^5 + ...

乘以 (1 - x^2) 之后:

F(x) = 1 + x + x^2 + 2x^3 + 3x^4 + 5x^5 + 8x^6 + 13x^7 + ...

x^n 的系数就是 f[n]:从 f[1]=1、f[2]=1、f[3]=2 开始,一路走标准斐波那契。这里的 x^0 系数是 1,对应空拆分,再次说明 f[0] 这个约定在生成函数里很自然,但它不能直接影响 f[2] 的赋值。

生成函数的好处是,它把“为什么递推跨度和是 1 和 2”解释得清清楚楚:奇数段生成函数的分母是 1-x^2,加上“任意多个段拼接”的取倒数操作,最终把分母变成了 1-x-x^2。整个过程没有黑魔法,每一步都是序列拼接计数的标准操作。

2.4 打表结果一览

用 DFS 暴力枚举前几项,可以得到下面这个表:

n方案数所有有序奇数拆分
11[1]
21[1,1]
32[3], [1,1,1]
43[1,3], [3,1], [1,1,1,1]
55[5], [1,1,3], [1,3,1], [3,1,1], [1,1,1,1,1]
68不逐一列举

这张表基本上已经把答案写在了脸上:1、1、2、3、5、8,严格对应斐波那契数列。赛场上如果时间紧张,完全可以靠打表先锁定结论,再回头补证明。后面我会说,这是一种非常值得养成的解题习惯。

3. 赛场代码方案:从 O(n) 到 O(log n)

3.1 滚动变量递推:n 在 2e5 级别的首选

在原题的数据范围下,n 最大到 2e5,O(n) 递推是完全够用的,代码也最短。因为递推只依赖前两项,不需要开数组,用两个滚动变量就行。

C++ 版本:

#include <bits/stdc++.h> using namespace std; const long long MOD = 998244353; int main() { int n; cin >> n; if (n <= 2) { cout << 1 << '\n'; return 0; } long long a = 1, b = 1; // a = f[n-2], b = f[n-1],初始对应 f[1] = 1, f[2] = 1 for (int i = 3; i <= n; i++) { long long c = (a + b) % MOD; a = b; b = c; } cout << b << '\n'; return 0; }

Python 版本:

MOD = 998244353 n = int(input()) if n <= 2: print(1) exit() a, b = 1, 1 # f[1], f[2] for _ in range(3, n + 1): a, b = b, (a + b) % MOD print(b)

一个新手容易犯的错是用 int 存加法结果。998244353 这个模数下,两个小于模数的数相加最大接近 2e9,int 通常能装下(如果 int 是 32 位的话极限也就 2.1e9 出头),但一旦中间步骤出现两次累加,比如 a + b + c,就很容易溢出。所以 C++ 里一律用 long long,Python 没有这个问题但也建议每次加法后立刻取模。养成“每步取模”的习惯会让你少交很多发 WA。

3.2 矩阵快速幂:n 大到离谱时的后手

如果数据范围变成 n ≤ 1e9 甚至 1e18,O(n) 就不可行了。此时用矩阵快速幂把斐波那契递推写成矩阵形式:

[f[n] ] = [1 1]^(n-2) [f[2]] [f[n-1]] [1 0] [f[1]]

代入 f[1] = f[2] = 1,只需要对 2×2 矩阵做快速幂。C++ 模板:

struct Mat { long long a[2][2]; Mat(bool unit = false) { memset(a, 0, sizeof(a)); if (unit) a[0][0] = a[1][1] = 1; } Mat operator*(const Mat &o) const { Mat c; for (int i = 0; i < 2; i++) for (int j = 0; j < 2; j++) for (int k = 0; k < 2; k++) c.a[i][j] = (c.a[i][j] + a[i][k] * o.a[k][j]) % MOD; return c; } }; long long fib(long long n) { if (n <= 2) return 1; Mat base; base.a[0][0] = base.a[0][1] = base.a[1][0] = 1; Mat res(true); long long e = n - 2; while (e) { if (e & 1) res = res * base; base = base * base; e >>= 1; } return (res.a[0][0] + res.a[0][1]) % MOD; }

矩阵快速幂背后的原理其实和普通快速幂一模一样:把指数拆成二进制,利用乘法结合律,通过矩阵自乘来拼出高次幂。它不改变递推公式本身,只是让计算复杂度从 O(n) 降到 O(log n)。这道题用不上,但它是斐波那契类问题里必须掌握的基本功。

3.3 组合数公式:另一种 O(n) 预处理写法

斐波那契递推不是唯一出口,我们还能从拆分结构直接推出组合数公式。

假设一个拆分包含 k 个奇数段。每一段长度都是奇数,可以写成 2a_i + 1,其中 a_i ≥ 0。总和为:

sum_{i=1}^{k} (2a_i + 1) = 2 * sum(a_i) + k = n

于是 sum(a_i) = (n - k) / 2。要让这个值是非负整数,n 和 k 必须同奇偶。现在问题变成:把 S = (n-k)/2 个不可区分的“单位”分配给 k 个有序变量,允许某些变量为 0,方案数是多少?经典的隔板法给出:

C(S + k - 1, k - 1) = C((n + k) / 2 - 1, k - 1)

因此最终答案是:

ans = sum_{1 ≤ k ≤ n, k ≡ n (mod 2)} C((n+k)/2 - 1, k - 1)

用 n=5 验证一下:k 取 1、3、5。

k=1 时 C((5+1)/2 - 1, 0) = C(2,0) = 1,对应 [5];k=3 时 C((5+3)/2 - 1, 2) = C(3,2) = 3,对应 [1,1,3]、[1,3,1]、[3,1,1];k=5 时 C((5+5)/2 - 1, 4) = C(4,4) = 1,对应 [1,1,1,1,1]。合计 5,完全正确。

这个写法需要预处理阶乘和逆元,代码量比滚动递推大,但它的好处是完全跳过了“看出斐波那契”这一步,纯靠组合计数也能把答案算出来。如果赛场上你一时没反应过来是斐波那契,这个公式就是救命通道。

4. 踩过的坑与这类转化题的识别套路

4.1 边界坑:f[0] 是约定不是递推项

这是 CF1452D 最容易 WA 的地方,我见过不少同学在本地把 dp[0] = 1、dp[1] = 1,然后从 2 开始循环,结果 n=2 输出 2,而正确答案是 1。

原因前面已经反复强调:f[0] = 1 是为了在“第一段长度等于 n”或生成函数中让空拆分有定义,它不是一个能直接代入 f[n] = f[n-1] + f[n-2] 的初值。如果你坚持要开数组递推,最稳妥的写法是单独初始化 dp[1] = dp[2] = 1,然后从 i=3 循环。如果 n 可能为 1,就再加一个 if (n <= 2) 的特判。

这个问题放在斐波那契里特别隐蔽,因为 1、1、2、3、5 看起来如此自然,很多人会默认 f[0]=1 和 f[2]=1 可以同时成立,却忽略了递推式的适用范围。实际比赛中,边界错误是所有错误里最磨人的一种,因为例子里 n 常常给得很大,你根本看不出问题在 n=2。

4.2 别在赛场上用 Binet 公式

斐波那契有闭式解 (phi^n - psi^n) / sqrt(5),高中学数学的时候觉得很酷,但竞赛里用这个公式是给自己找麻烦。首先它涉及浮点运算,n 一超过 70 左右精度就开始下降;其次题目要取模,你总不能先算个浮点数再取模吧?就算用矩阵快速幂能解决精度问题,也没必要绕这么大一圈。

我实际测试过,当 n 到 2e5 时,用浮点闭式解算出来的 f[n] 会和正确答案差好几位数,完全不可用。整数递推或矩阵快速幂永远更稳,也更符合竞赛习惯。这个坑虽然看起来“只有新人才会踩”,但我在压力比较大的比赛里也干过为了秀公式而浪费时间的事,后来学乖了:能整数就不浮点。

4.3 打表找规律是正当且有价值的手段

很多同学觉得打表是“作弊”,比赛里应该直接推公式。但我的习惯是:遇到计数题,先写一个暴力的 DFS 或者 DP,把 n=1 到 n=12 的结果打出来。看到 1、1、2、3、5、8,立刻就知道是斐波那契,接下来再去想证明就非常有方向感。

比如 CF1452D,暴力 DFS 只需要十几行:

def brute(n): if n == 0: return 1 res = 0 for first in range(1, n + 1, 2): res += brute(n - first) return res for n in range(1, 13): print(n, brute(n))

输出就是 1、1、2、3、5、8、13、21、34、55、89、144。看到这个数列,后面所有推导都有了目标。一旦你知道答案是斐波那契,证明递推式就变得非常自然;反之,如果你连答案长什么样都不清楚,很容易在组合细节里迷失。

这不是投机取巧,而是组合计数题的标准解题姿势。先观察,再证明。很多老选手都会这么做。

4.4 问题速查表

把实际操作中可能遇到的问题整理成一张速查表:

症状原因解决办法
n=2 输出 2f[0] 被错误代入递推单独初始化 f[1]=f[2]=1,从 i=3 开始
C++ 相加时 WAint 溢出中间结果用 long long,每步取模
用浮点闭式解算大 n精度丢失改用整数递推或矩阵快速幂
递推公式推不出来没往“奇数拆分”方向想打表前几项,锁定斐波那契再回头证明
组合数公式结果不对n 和 k 奇偶性判断失误检查 (n-k)%2 是否为 0,k 上限不超过 n

4.5 这类“覆盖转拆分”题目的通用识别标志

吃透 CF1452D 之后,更重要的是把这套转化思路内化,因为 Codeforces 上有一大类题都是同一个套路:给你一个一维线段或排列,上面放一些“有作用范围”的东西,范围形状固定,要求全覆盖且不重叠。这种题十有八九可以翻译成某种整数拆分,然后落到某个常见数列上。

识别标志大概是这几条:

  • 覆盖区间是固定形状,比如“奇数长度”“2 的幂”“质数长度”,这时拆分元素就是这些特定数。
  • “恰好覆盖一次”这个条件,对应拆分中区间首尾相接、不重叠。
  • 求方案数、模数很大,意味着你需要递推或者组合公式。
  • 有顺序要求,因为直线上的位置天然有先后次序。

遇到这种题,先打表、再猜数列、再用生成函数或组合计数验证,是一个非常高效的流水线。CF1452D 是所有这类题目里最标准的例子,做熟它,之后遇到变体你会条件反射地想到“这不就是拆分成奇数段吗”。

5. 最后说点我的实操体会

做这道题的时候,我一开始写的是 O(n^2) 的 DP:枚举第一段长度,累加 f[n-k]。代码能过,但完全没看到斐波那契。后来逼自己把表打出来,才意识到规律这么漂亮。真正让我想透彻的,是“最后一段减 2”那个映射——奇数减 2 还是奇数,这个性质太巧妙了,它让长度为 n-2 的拆分和长度为 n 的拆分之间形成了无缝合一的对应。

从那以后我养成了两个习惯:一是计数题先打表,二是遇到“区间不重叠全覆盖”的条件,第一反应就是画一条线段,想象怎么切分。这道题给我留下的印象远超它本身的难度,因为它让我体会到,很多组合计数题的本质不是复杂计算,而是把一个看似有物理意义的问题,翻译成一个纯粹的数字结构。你一旦完成这个翻译,答案往往已经写在某个经典数列里等着你了。

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

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

立即咨询