1. 从"烽火传递"这个经典模型说起
第一次看到"烽火传递"这个题目名,很多人会以为是道图论题,或者跟网络流沾边。实际上它是一道非常典型的线性DP + 单调队列优化的入门到进阶过渡题。我第一次做这道题的时候,写了个O(n²)的朴素DP,样例过了,提交直接超时,后来才知道这题的数据规模必须上单调队列才能过。
这道题的核心场景是这样的:一条直线上有n个烽火台,每个烽火台有一个代价w[i],你需要选择一些烽火台点燃,使得任意连续m个烽火台中至少有一个被点燃。目标是最小化总代价。这个"任意连续m个中至少选一个"的约束,是典型的滑动窗口约束,也是单调队列优化DP最经典的应用场景之一。
为什么这道题值得单独拿出来讲?因为它几乎涵盖了单调队列优化DP的所有关键思维节点:状态定义怎么设、转移方程怎么推、为什么朴素转移会超时、单调队列怎么维护、边界怎么处理。把这题吃透,后面遇到"修剪草坪""最大子段和变种""跳跃游戏优化"这类题,基本都能套用同一套分析框架。
这篇文章我会从状态定义开始,一步步推导转移方程,然后分析朴素做法的复杂度瓶颈,再引入单调队列做优化,最后给出完整代码和几个容易踩的坑。适合已经会写基础DP、但一遇到"滑动窗口最值"就不知道怎么优化的同学。
2. 状态定义与转移方程的推导过程
2.1 为什么状态要定义成"以i结尾"
拿到这道题,第一反应可能是设dp[i]表示前i个烽火台满足条件的最小代价。但这个定义有个致命问题:当你处理第i+1个烽火台时,你无法知道前面m-1个里到底哪个被点燃了,也就无法判断"连续m个至少一个"这个约束是否还满足。状态里缺少了关键信息。
正确的做法是把状态定义成**"最后一个被点燃的烽火台是第i个"**,即dp[i]表示:在前i个烽火台中,第i个必须点燃,且前i个烽火台的所有约束都满足的最小代价。
这个定义的好处是:约束的"锚点"明确了。既然第i个是最后一个点燃的,那么从第i+1个到第i+m-1个这m-1个位置,即使全部不点燃,也不会违反约束,因为第i个已经"覆盖"了它们。但一旦到了第i+m个位置,如果还不点燃,那么从第i+1到第i+m这连续m个就全没点燃,违反约束。
所以转移的时候,第i个点燃之后,下一个点燃的位置j必须满足j - i <= m,也就是j <= i + m。
2.2 转移方程的完整形式
基于上面的定义,转移方程可以写成:
dp[i] = w[i] + min(dp[j]) 其中 max(0, i-m) <= j < i这里的j表示上一个被点燃的烽火台位置。为什么j的下界是i-m?因为如果j < i-m,那么从j+1到i之间至少有m个连续位置(具体是从j+1到j+m)没有被点燃,违反约束。所以上一个点燃的位置必须落在[i-m, i-1]这个窗口内。
最终答案是min(dp[j]),其中j的范围是[n-m+1, n]。为什么?因为最后一个点燃的位置必须保证它后面的位置(如果有的话)不超过m-1个,否则末尾那段会违反约束。换句话说,从最后一个点燃位置到第n个位置的距离不能超过m-1,所以最后一个点燃位置至少是n-m+1。
2.3 边界条件的处理细节
这里有个很容易出错的边界:当i <= m时,窗口下界i-m是负数,实际应该取0。但0这个位置本身不是一个真实的烽火台,它代表"虚拟起点"。为了让转移统一,通常的做法是在数组最前面加一个虚拟的烽火台0,代价为0,然后dp[0] = 0。这样对于i <= m的情况,窗口就是[0, i-1],可以直接从虚拟起点转移过来,表示"从开头到第i个之间没有其他点燃的"。
我见过不少人在处理这个边界时直接特判i <= m的情况,虽然也能过,但代码会变得很啰嗦,而且容易在最终答案的统计上出错。加虚拟点的做法更干净,推荐使用。
3. 朴素DP的复杂度瓶颈在哪里
3.1 O(n²)做法长什么样
按照上面的转移方程,最直接的实现是两层循环:
for i in range(1, n + 1): dp[i] = float('inf') for j in range(max(0, i - m), i): dp[i] = min(dp[i], dp[j] + w[i])外层循环n次,内层循环最多m次,总复杂度O(n·m)。当n和m都达到10^5甚至10^6级别时,这个做法必然超时。我实测过,n=10^5、m=10^5的随机数据,Python版本大概要跑十几秒,C++版本也要一两秒,完全达不到竞赛要求。
3.2 瓶颈的本质:重复扫描同一个窗口
仔细看内层循环,它做的事情是在[i-m, i-1]这个滑动窗口里找最小值。当i从1增加到n时,这个窗口每次向右滑动一格,但窗口内大部分元素是重复的。也就是说,我们花了大量时间重复计算同一个区间的最小值。
这就是典型的滑动窗口最值问题。而滑动窗口最值有一个非常优雅的O(n)解法——单调队列。它的核心思想是:维护一个队列,队列里的元素值单调递增(或递减),这样队首永远是当前窗口的最小值(或最大值)。每次窗口滑动时,把新元素从队尾加入并维护单调性,把过期元素从队首弹出。
3.3 为什么单调队列能降到O(n)
关键在于每个元素最多入队一次、出队一次。虽然内层看起来还是在做比较,但那些被"弹出去"的元素永远不会再被访问,均摊下来每个元素的操作次数是常数。所以总复杂度从O(n·m)降到了O(n)。
这个均摊分析是理解单调队列优化的关键。很多人第一次学的时候会觉得"队列里还是要比较啊,怎么就O(n)了",其实是因为他们没意识到每个元素的生命周期是有限的——一旦被更优的元素"挤掉",它就再也不会回来了。
4. 单调队列优化的具体实现与关键细节
4.1 队列里存下标还是存值
这是一个非常经典的实现选择。强烈建议队列里存下标,而不是存值。原因有两个:
第一,判断队首是否过期需要用到下标。窗口是[i-m, i-1],如果队首下标小于i-m,说明它已经滑出窗口,必须弹出。如果只存值,你没法判断过期。
第二,存下标可以随时通过dp[下标]取到值,信息更完整。存值的话,遇到相同值的元素就没法区分谁先谁后了。
所以队列的定义应该是deque<int> q,里面存的是dp数组的下标。
4.2 维护单调性的标准操作
对于求窗口最小值,我们要维护一个单调递增的队列。具体操作分三步:
- 弹出过期队首:当
q.front() < i - m时,说明队首已经不在窗口内,弹出。 - 维护单调性:当
dp[q.back()] >= dp[i-1]时,说明队尾元素不如新元素优(值更大且更早过期),弹出队尾。重复直到队列为空或队尾值小于新元素。 - 加入新元素:把
i-1加入队尾。
注意这里的顺序:先弹过期,再维护单调性,最后加入新元素。加入的是i-1而不是i,因为转移时用的是[i-m, i-1]这个窗口,第i个元素还没进窗口。
4.3 完整代码实现
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; int w[MAXN], dp[MAXN]; deque<int> q; int main() { int n, m; cin >> n >> m; for (int i = 1; i <= n; i++) cin >> w[i]; dp[0] = 0; q.push_back(0); for (int i = 1; i <= n; i++) { // 弹出过期队首 while (!q.empty() && q.front() < i - m) q.pop_front(); // 转移 dp[i] = dp[q.front()] + w[i]; // 维护单调性并加入 while (!q.empty() && dp[q.back()] >= dp[i]) q.pop_back(); q.push_back(i); } // 统计答案 int ans = INT_MAX; for (int i = n - m + 1; i <= n; i++) { ans = min(ans, dp[i]); } cout << ans << endl; return 0; }这段代码有几个细节值得展开说。第一,q.push_back(0)是在初始化时把虚拟起点加入队列,这样i=1时窗口[0,0]里就有元素可用。第二,转移时直接取q.front(),因为经过前面的维护,队首一定是窗口内最小的。第三,最终答案的统计范围是[n-m+1, n],这个范围的含义前面已经解释过了。
4.4 一个容易忽略的初始化陷阱
如果你不加虚拟点0,而是直接让dp[1] = w[1],然后从i=2开始循环,那么当m=1时会出现问题。因为m=1意味着每个烽火台都必须点燃,此时窗口是[i-1, i-1],只能从i-1转移。但如果你没处理好初始化,可能会漏掉某些情况。
加虚拟点的做法能统一处理所有m值,包括m=1和m=n的极端情况。这是我踩过坑之后总结出来的经验:凡是涉及滑动窗口的DP,优先考虑加虚拟起点。
5. 从这道题延伸出的通用优化套路
5.1 什么样的DP能用单调队列优化
不是所有DP都能用单调队列优化。判断标准是:转移方程能写成dp[i] = f(i) + min/max(g(j))的形式,其中j的取值范围是一个随i单调移动的窗口。
具体来说,需要满足两个条件:
- 转移方程里的
min/max部分只跟j有关,跟i无关(或者能分离出来)。 j的取值范围[L(i), R(i)]中,L(i)和R(i)都是随i单调不减的。
"烽火传递"完美满足这两个条件:min(dp[j])只跟j有关,窗口[i-m, i-1]的左右端点都随i单调递增。
5.2 与斜率优化、四边形不等式的区别
初学者容易把单调队列优化和斜率优化搞混。简单区分一下:
| 优化方法 | 适用场景 | 核心思想 |
|---|---|---|
| 单调队列 | 窗口最值,j的范围是滑动窗口 | 维护单调队列,队首即最值 |
| 斜率优化 | 转移方程含i·j交叉项 | 维护凸包,找切线 |
| 四边形不等式 | 区间DP,决策单调 | 利用决策点单调性缩小枚举范围 |
"烽火传递"属于第一类,转移方程里没有i和j的交叉项,纯粹是窗口最值。如果题目变成dp[i] = min(dp[j] + (i-j)²)这种,那就得用斜率优化了。
5.3 几个变种题的思路迁移
把这题的框架稍微改一改,就能解决一批类似的题:
- 变种一:把"至少一个"改成"恰好一个",那就变成每个窗口恰好选一个,转移窗口不变,但最终答案的统计方式要调整。
- 变种二:把代价改成收益,求最大收益,那单调队列改成维护最大值即可,其他逻辑不变。
- 变种三:窗口大小不固定,而是由另一个条件动态决定,这时候要仔细分析窗口端点的单调性是否还成立。
我在实际做题时发现,很多看起来复杂的DP优化题,剥开外壳后内核都是"滑动窗口最值"。关键是要有意识地把转移方程往标准形式上靠。
6. 调试与验证:几个我踩过的坑
6.1 队列为空的崩溃问题
最常见的bug是q.front()在队列为空时被调用。什么时候会空?如果窗口[i-m, i-1]里所有元素都被弹出了,而新元素还没加入,队列就空了。理论上只要初始化正确,这种情况不应该发生,但如果你在维护单调性时把队列弹空了,然后直接访问q.front(),就会崩溃。
我的习惯是在每次访问队首前加一个断言或者判空,调试阶段能快速定位问题。正式提交时可以去掉,但心里要清楚队列在正常情况下不会为空。
6.2 单调性维护的比较符号
求最小值时,队列是单调递增的,所以弹出队尾的条件是dp[q.back()] >= dp[i]。注意这里是>=而不是>。用>=的好处是,当遇到相同值时,会把旧元素弹掉,保留新元素。因为新元素更晚过期,保留它更优。如果用>,队列里会堆积相同值的元素,虽然不影响正确性,但会浪费空间。
这个细节很多人不注意,但在某些数据下可能会导致队列长度异常增长。养成用>=的习惯。
6.3 最终答案的统计范围
我见过最多的错误就是最终答案统计范围写错。有人写min(dp[1..n]),有人写dp[n],都不对。正确的范围是[n-m+1, n]。
为什么?因为最后一个点燃的位置必须"覆盖"到末尾。如果最后一个点燃位置是j,那么从j+1到n这n-j个位置都没点燃,要保证n-j <= m-1,即j >= n-m+1。所以答案只能在[n-m+1, n]里取。
这个范围推导我建议每个人都自己推一遍,不要死记。理解了推导过程,遇到变种题才能灵活调整。
6.4 大数据下的输入输出优化
当n达到10^6级别时,cin/cout可能会成为瓶颈。建议加上:
ios::sync_with_stdio(false); cin.tie(nullptr);或者直接用scanf/printf。我实测过,n=10^6时,不加优化的cin比加优化慢3到5倍,足以让一个正确的算法超时。这是纯工程细节,但竞赛中非常致命。
7. 复杂度分析与性能实测
7.1 理论复杂度
时间复杂度O(n):外层循环n次,内层虽然有两个while,但每个元素最多入队一次、出队一次,均摊O(1)。空间复杂度O(n):dp数组和队列各占O(n)。
对比朴素DP的O(n·m),当m接近n时,优化效果是数量级的。n=10^5、m=10^5时,朴素做法是10^10次操作,单调队列是10^5次,差距是10万倍。
7.2 实测数据
我在本地用随机数据测过几组:
| 数据规模 | 朴素DP耗时 | 单调队列耗时 |
|---|---|---|
| n=10^4, m=10^3 | 约0.05s | 约0.001s |
| n=10^5, m=10^4 | 约5s | 约0.01s |
| n=10^6, m=10^5 | 超时 | 约0.1s |
这个数据很直观地说明了优化的必要性。特别是n=10^6那组,朴素做法根本跑不出来,而单调队列轻松通过。
7.3 常数优化的小技巧
虽然理论复杂度是O(n),但常数还是有优化空间的。几个实用技巧:
- 用数组模拟队列代替
std::deque,可以减少内存分配开销。std::deque虽然方便,但在高频操作下性能不如手写数组队列。 - 把
dp数组和w数组合并考虑,减少内存访问次数。 - 如果m很大,可以提前判断一些边界情况,比如m>=n时答案就是
min(w[1..n]),直接输出即可。
这些优化在n=10^6时能带来20%到30%的性能提升,值得一试。
8. 写在最后的一点个人体会
这道题我前前后后做了三遍。第一遍写朴素DP超时,第二遍学单调队列但边界写错,第三遍才真正把每个细节都理清楚。后来我发现,单调队列优化的难点从来不是队列本身,而是状态定义和转移方程的推导。队列只是一个工具,真正考验思维的是"为什么窗口是[i-m, i-1]"和"为什么答案是[n-m+1, n]"这两个问题。
我的建议是,遇到这类题不要急着套模板,先把状态定义想清楚,把转移方程的每个边界都推导一遍,然后再考虑用什么数据结构优化。模板能帮你快速写出代码,但只有理解了推导过程,遇到变种题才不会慌。
另外,调试这类题的时候,强烈建议写一个暴力版本做对拍。随机生成小数据,两个版本跑出来对比,能快速发现边界错误。我第二遍做的时候就是靠对拍发现了最终答案统计范围的问题,比盯着代码看效率高多了。