复赛测试 15 · T3 —— 状态机 + 区间分组,从 O(n²) 暴力讲到 O(n log n)
题目里两人都只能按顺序连续看,所以任何时刻,"已看过"的集合一定是 {1, 2, …, v} 这种前缀。
于是我们只需要记住一个数 v:两人都已经看完第 1…v 集,下一集是 v+1。开始时 v = 0。
第 i 天,Alice 会不会看?规则说"已经看过 1…ai−1,但没看过 ai"——这正好等价于 ai = v+1。Bob 同理。
调整三个下拉框,看当天会不会出问题。
选一个起点 L,点“播放”,看状态 v 怎么被推着往前走,又是哪一天突然断掉。
格子里的两个数字:蓝 = Alice 播的集 ai,橙 = Bob 播的集 bi。
下面每一行是一个起点 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))。
注意:中间那些"两人都不碰"的天数完全不重要,可以直接跳过。这就是为什么我们只盯 T_v 表,而不用一天一天扫。
关键观察:从 L 出发,第一个碰到第 1 集的位置,只跟"L 落在 T1 的哪一段空隙里"有关。
把 T1 写成 j1 < j2 < …,那么所有 L ∈ (jk−1, jk] 的起点,第一个撞到的都是 jk,它们之后的命运完全一样。所以可以打包成一个组 [lo, hi],只记一个 pos(当前已消费到的位置)。
下面用样例 4 完整走一遍。点"下一步"看每一步发生了什么。
nxt[nn-1].hi + 1 == lo 就是在挡这个坑。
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⁵ 的输入更快读完。