schedule:把 n 个起点压成几段区间

复赛测试 15 · T3 —— 状态机 + 区间分组,从 O(n²) 暴力讲到 O(n log n)

状态只有一个数 v T_v 表 = 谁是"地雷" 命运相同的 L 合并成区间 组数单调不增
一句话先说结论:对每个起点 L,只要算出它“最远能连续看到哪一天”,把这个终点记作 f(L),答案就是 Σ max(0, f(L) − L + 1)。 而 f(L) 只取决于 L 第一次“撞上第 1 集”的位置——所以命运完全相同的 L 可以打包成一段区间,一起往前走。

①关键一步:把"看过哪些集"压缩成一个数字

题目里两人都只能按顺序连续看,所以任何时刻,"已看过"的集合一定是 {1, 2, …, v} 这种前缀。

于是我们只需要记住一个数 v:两人都已经看完第 1…v 集,下一集是 v+1。开始时 v = 0。

第 i 天,Alice 会不会看?规则说"已经看过 1…ai−1,但没看过 ai"——这正好等价于 ai = v+1。Bob 同理。

动手玩一下判定器

调整三个下拉框,看当天会不会出问题。

ALICE
—
BOB
—
…—
记住这三种结局:两人都看 → 同看第 v+1 集,状态加一;只有一人看 → 当场违规,区间必须在这里断;两人都不看 → 什么都没变,继续下一天。

②从一个起点 L 出发,逐天走一遍

选一个起点 L,点“播放”,看状态 v 怎么被推着往前走,又是哪一天突然断掉。

格子里的两个数字:蓝 = Alice 播的集 ai,橙 = Bob 播的集 bi。

起点 L =
当前状态 v
0
已扫描到第几天
—
最远能到 f(L)
—
这个 L 贡献的区间数
—
最容易踩的坑:区间从 L 开始,L 之前的日子他们什么都没看。所以即使第 2 天播的是第 2 集,从 L=2 开始时两人的“下一集”仍然是第 1 集,第 2 天他们照样不看。把 L 切换到 2,再按“单步”亲手走一遍就清楚了。
注意:某一天断掉之后,不是"换一天重来",而是这个 L 的最远右端点就定死了 = 断点前一天的编号。所以对每个 L,我们只要算出这个 f(L),它贡献的合法区间就是 [L,L]、[L,L+1] … [L,f(L)],共 f(L) − L + 1 个。

③每个 L 都算一遍:答案是所有小条的长度和

下面每一行是一个起点 L,蓝色条从 L 一直画到 f(L)。答案就是所有蓝色格子的总数。

—

灰色行表示 f(L) < L,也就是这个起点连一天都撑不过去,贡献 0。

④暴力太慢,先换个问法:谁会"撞"到我?

状态从 v 前进到 v+1,唯一的办法是某一天 ai 或 bi 正好等于 v+1。

定义 T_v = { i | ai = v 或 bi = v },也就是所有"会碰到第 v 集"的位置,按从小到大排好。

那么:从某个起点出发、当前已经处理到位置 pos 时,下一个会被碰到的位置就是 T_{v+1} 里第一个比 pos 大的元素(这个查找用 upper_bound,一次 O(log n))。

三种命运,只看这一个位置 p:
· p 不存在 → 之后再也没有人碰 v+1,永远安全,f(L) = n
· ap = bp = v+1 → 两人同看,状态推进,继续找下一个
· 只有一个人等于 v+1 → 这一天就断了,f(L) = p − 1

注意:中间那些"两人都不碰"的天数完全不重要,可以直接跳过。这就是为什么我们只盯 T_v 表,而不用一天一天扫。

⑤核心技巧:命运相同的 L,合并成一段区间一起走

关键观察:从 L 出发,第一个碰到第 1 集的位置,只跟"L 落在 T1 的哪一段空隙里"有关。

把 T1 写成 j1 < j2 < …,那么所有 L ∈ (jk−1, jk] 的起点,第一个撞到的都是 jk,它们之后的命运完全一样。所以可以打包成一个组 [lo, hi],只记一个 pos(当前已消费到的位置)。

下面用样例 4 完整走一遍。点"下一步"看每一步发生了什么。

准备开始
点下面的「下一步」开始。

T 表(第 i 列 = 第 i 天)

当前存活的分组

对应代码

合并的坑:两个组推进到同一个 pos 时看起来可以合并,但必须额外检查它们原本的 L 区间是相邻的。如果中间夹着早就死掉的 L,合并会把死掉的 L 也包进来,之后给它们写 f(L) 时就会写错值。代码里那句 nxt[nn-1].hi + 1 == lo 就是在挡这个坑。

⑥为什么这样就够快了

暴力:每个 L 都扫一遍
O(n²)
n = 5×10⁵ 时约 2.5×10¹¹ 次操作,必然超时。
分组推进
O(n log n)
总共约 1×10⁷ 次操作,实测 2 秒限制下很轻松。

两个数字撑住这个复杂度

1. 组数每轮不增。每一轮处理完,只会发生"整组死亡"或"整组推进",不会凭空多出组。

2. 每组在同一轮只会吃掉 T_v 里一个不同的元素。所以把每一轮的组数加起来,总量不超过所有 T_v 的元素总数 = O(n)。每一组每轮花一次 upper_bound,代价 O(log n)。

两者相乘,就是 O(n log n)。这不是玄学,而是"每个组每轮必须消耗一个不同的位置"这个单调性保证的。

代码地图:别被长度吓到

整份代码里真正属于算法的只有下面 5 块,其余几十行是快读(gc / readInt)和数组清空。

代码位置在做什么对应上面的哪一步
cntv / off / curp / flat用 CSR 结构建出所有 Tv第 ④ 步的 T 表,省内存、天然有序
for (int L = 1; L <= n; ++L) fv[L] = n;默认"安全到结尾"Tv 里再也找不到位置的情况
第一个 for (int idx = off[1] ...)按 T1 切段,决定每个 L 的第一个位置 j第 ⑤ 步开头,组是怎么诞生的
主循环 for (int v = 2; v <= n && cn > 0; ++v)所有存活组一起往下一集推进第 ⑤ 步的动画主体
ans += fv[L] - L + 1把每条小条的长度加起来第 ③ 步的条形图

精简后的核心代码

// 1) 建 T_v 的 CSR 表:off[v] 是起点,flat[off[v]..off[v+1]) 是升序位置
for (i = 1; i <= n; i++) { cntv[a[i]]++; if (b[i] != a[i]) cntv[b[i]]++; }
off[0] = 0;
for (v = 1; v <= n + 1; v++) off[v] = off[v - 1] + cntv[v - 1];
for (i = 1; i <= n; i++) {
    flat[curp[a[i]]++] = i;
    if (b[i] != a[i]) flat[curp[b[i]]++] = i;
}

// 2) 第一阶段:按 T_1 切段,第一撞是 j 的 L 全部 [prev+1, j]
for (idx = off[1]; idx < off[2]; idx++) {
    j = flat[idx]; lo = prev + 1; hi = j; prev = j;
    if (a[j] == b[j] && a[j] == 1) { // 两人同看第 1 集 → 活着
        cur[cn++] = {lo, hi, j};
    } else {                                    // 只有一人看 → 段内全部死在这
        for (L = lo; L <= hi; L++) fv[L] = j - 1;
    }
}

// 3) 主循环:所有存活组一起推进 v → v+1
for (v = 2; v <= n && cn > 0; v++) {
    s = off[v]; e = off[v + 1];
    if (s == e) break;               // 没人播 v:全体永远安全
    nn = 0;
    for (each group g = {lo, hi, p0}) {
        k = upper_bound(flat + s, flat + e, p0);   // 第一个 > p0 的位置
        if (k == e - s) continue;            // 之后不碰 v:安全,丢弃
        pos = flat[s + k];
        if (a[pos] == b[pos] && a[pos] == v) {
            if (nn > 0 && nxt[nn-1].pos == pos && nxt[nn-1].hi + 1 == lo)
                nxt[nn-1].hi = hi;              // 同 pos 且区间相邻 → 合并
            else
                nxt[nn++] = {lo, hi, pos};
        } else {
            for (L = lo; L <= hi; L++) fv[L] = pos - 1;   // 一人看 → 整段死
        }
    }
    cur = nxt; cn = nn;
}

// 4) 统计
ans = 0;
for (L = 1; L <= n; L++) ans += fv[L] - L + 1;
我想看完整代码(含快读,与提交版本一致)

快读部分与算法无关,只是为了让 5×10⁵ 的输入更快读完。