好消息,坏消息

OJ: luogu

题目 ID: P2629

标签: 单调队列

日期: 2025-12-26 21:49

题解 P2629 【好消息,坏消息】(断环成链 + 单调队列)

1. 题目简述

Uim 有 nn 条消息,每条消息有一个好坏度 AiA_i。老板的心情初始为 00,依次听完消息后心情会累加。如果过程中老板的心情一旦小于 00,Uim 就会被炒鱿鱼。 Uim 可以选择从第 kk 条消息开始汇报(1kn1 \le k \le n),顺序为 k,k+1,,n,1,,k1k, k+1, \dots, n, 1, \dots, k-1。 问有多少个 kk 可以保证汇报过程中老板的心情始终 0\ge 0

数据范围: n106n \le 10^6103Ai103-10^3 \le A_i \le 10^3

2. 思路分析

难点一:环形处理(断环成链)

题目中可以选择任意起点 kk 绕一圈,这是一种典型的环形问题。 处理环形问题最常用的技巧是 断环成链: 将原数组 AA 复制一份接在后面,形成一个长度为 2n2n 的新数组。

A={A1,A2,,An,A1,A2,,An1}A' = \{A_1, A_2, \dots, A_n, A_1, A_2, \dots, A_{n-1}\}
这样,原数组中从 kk 开始长度为 nn 的环形序列,就对应新数组中下标 [k,k+n1][k, k+n-1] 的连续子段。

难点二:转化条件(前缀和)

SSAA' 的前缀和数组,即 Si=j=1iAjS_i = \sum_{j=1}^i A'_j。 对于一个起始位置 kk(在 AA' 中对应区间 [k,k+n1][k, k+n-1]),要保证过程中心情始终 0\ge 0,意味着对于区间内任意位置 jj (kjk+n1k \le j \le k+n-1),都要满足:

区间和(k,j)0区间和(k, j) \ge 0
用前缀和表示就是:
SjSk10    SjSk1S_j - S_{k-1} \ge 0 \implies S_j \ge S_{k-1}
这个条件必须对区间内所有jj 都成立。 显然,如果区间内最小的那个 SjS_j 都满足条件,其他肯定也满足。 所以条件转化为:
minkjk+n1{Sj}Sk1\min_{k \le j \le k+n-1} \{S_j\} \ge S_{k-1}

难点三:滑动窗口最小值(单调队列)

我们需要枚举每一个可能的 kk (1kn1 \le k \le n)。 对于每一个 kk,我们需要查询区间 [k,k+n1][k, k+n-1]SS 的最小值。 随着 kk 的增加,这个长度为 nn 的区间在不断向右滑动。

这正是 单调队列(滑动窗口最小值) 的标准模型!

算法流程:

  1. 构造长度为 2n2n 的数组,计算前缀和 SS
  2. 使用一个单调递增的队列(存下标),维护当前窗口内的 SS 值。
  3. 遍历 ii112n12n-1
    • 入队:如果 S[i]S[i] 比队尾小,队尾就没用了,弹出(维护单调性)。
    • 出队:如果队头下标 pos<in+1pos < i - n + 1,说明滑出窗口了,弹出。
    • 判断:当 ini \ge n 时,说明窗口长度已达到 nn。此时对应的起点是 k=in+1k = i - n + 1。 检查队头(即窗口内最小值):如果 S[q.front()]S[k1]S[q.front()] \ge S[k-1],则该起点 kk 合法,答案 +1+1

时间复杂度: O(N)O(N)

3. 样例推演

输入:4,数组:-3 5 1 2 断环成链后的数组:-3 5 1 2 -3 5 1 2 前缀和 SS (从下标1开始): -3, 2, 3, 5, 2, 7, 8, 10,且 S[0]=0S[0]=0

我们需要滑动窗口大小为 4。

i 当前 S[i] 队列状态 (存下标) 窗口范围 (对应起点 k) 窗口最小值 (S[head]) 基准值 S[k-1] 判断 S[head] >= S[k-1]
1 -3 {1} - - - -
2 2 {1, 2} - - - -
3 3 {1, 2, 3} - - - -
4 5 {1, 2, 3, 4} [1,4]k=1[1, 4] \to k=1 S[1]=3S[1] = -3 S[0]=0S[0]=0 3<0-3 < 0 (❌)
5 2 队列调整: 2<5(弹4), 2<3(弹3), 2<2(不弹) \to {1, 2, 5}
队头1过期(1 < 5-4+1=2) \to {2, 5}
[2,5]k=2[2, 5] \to k=2 S[2]=2S[2] = 2 S[1]=3S[1]=-3 232 \ge -3 (✅)
6 7 {2, 5, 6} [3,6]k=3[3, 6] \to k=3 S[2]=2S[2] = 2 S[2]=2S[2]=2 222 \ge 2 (✅)
7 8 {2, 5, 6, 7}
队头2过期 \to {5, 6, 7}
[4,7]k=4[4, 7] \to k=4 S[5]=2S[5] = 2 S[3]=3S[3]=3 2<32 < 3 (❌)

统计 ✅ 数量:2。输出 2。

4. AC 代码

cpp
#include <iostream>
#include <vector>
#include <deque>

using namespace std;

// 数据范围 N <= 10^6,开大一点
const int MAXN = 2000005; 
int a[MAXN];
long long s[MAXN]; // 前缀和用 long long 防止溢出(虽然这题int大概率够用)

int main() {
    // 开启 IO 加速
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);

    int n;
    if (!(cin >> n)) return 0;

    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        // 断环成链:复制一份到后面
        a[i + n] = a[i];
    }

    // 计算前缀和,计算范围到 2*n - 1 即可覆盖所有可能的长度为 n 的窗口
    s[0] = 0;
    for (int i = 1; i < 2 * n; ++i) {
        s[i] = s[i - 1] + a[i];
    }

    deque<int> q;
    int ans = 0;

    // 遍历前缀和数组
    for (int i = 1; i < 2 * n; ++i) {
        // 1. 维护单调递增队列 (找最小值)
        // 如果当前 s[i] 比队尾小,队尾显然不是最小值的候选了
        while (!q.empty() && s[q.back()] >= s[i]) {
            q.pop_back();
        }
        q.push_back(i);

        // 2. 窗口形成判断 (i >= n)
        if (i >= n) {
            // 当前窗口对应的起始位置 k
            // 窗口范围是 [i - n + 1, i]
            int k = i - n + 1;

            // 3. 移除过期元素
            // 如果队头下标小于窗口左边界,弹出
            if (!q.empty() && q.front() < k) {
                q.pop_front();
            }

            // 4. 判断条件
            // 区间内最小的前缀和必须 >= 之前的基准前缀和 s[k-1]
            if (s[q.front()] - s[k - 1] >= 0) {
                ans++;
            }
        }
    }

    cout << ans << endl;

    return 0;
}

5. 总结

  • 断环成链:解决循环数组问题的首选方法。
  • 问题转化:将“全程非负”转化为“区间内最小前缀和 \ge 初始前缀和”。
  • 单调队列:在 O(N)O(N) 时间内解决滑动窗口最值问题。
  • 注意细节S[i]S[i] 的计算范围,以及 kkii 的下标对应关系。