☰
单调队列优化DP:烽火传递问题从O(n²)到O(n)的完整推导
2026/10/15 17:59:27 网站建设 项目流程

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 维护单调性的标准操作

对于求窗口最小值,我们要维护一个单调递增的队列。具体操作分三步:

  1. 弹出过期队首:当q.front() < i - m时,说明队首已经不在窗口内,弹出。
  2. 维护单调性:当dp[q.back()] >= dp[i-1]时,说明队尾元素不如新元素优(值更大且更早过期),弹出队尾。重复直到队列为空或队尾值小于新元素。
  3. 加入新元素:把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]"这两个问题。

我的建议是,遇到这类题不要急着套模板,先把状态定义想清楚,把转移方程的每个边界都推导一遍,然后再考虑用什么数据结构优化。模板能帮你快速写出代码,但只有理解了推导过程,遇到变种题才不会慌。

另外,调试这类题的时候,强烈建议写一个暴力版本做对拍。随机生成小数据,两个版本跑出来对比,能快速发现边界错误。我第二遍做的时候就是靠对拍发现了最终答案统计范围的问题,比盯着代码看效率高多了。

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

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

立即咨询