usaco-6.1.3 奶牛异或

GitHub跳转原题关系图返回列表

前缀异或把子段异或化为两前缀配对,01-Trie 贪心求与历史前缀的最大异或,严格大于更新保最早结尾、叶子覆盖保同值最大下标得到最短子段。

OJ: roj

题目 ID: 2096

难度:普及+/提高-

标签:字典树位运算贪心python

创建: 2026-10-01 08:40

更新: 2026-10-07 13:50

形式化题目

给定长度 NN(1⩽N⩽100 0001 \leqslant N \leqslant 100\,000)的整数序列 a1,a2,…,aNa_1, a_2, \dots, a_N,每个数互不相同且 0⩽ai<2210 \leqslant a_i < 2^{21}。求一个非空连续子段 al⊕al+1⊕⋯⊕ara_l \oplus a_{l+1} \oplus \cdots \oplus a_r,按以下三层规则取最优:

  1. 异或值最大;
  2. 并列时,结尾 rr 最小(奶牛序号越小社会等级越高,"结尾奶牛社会等级最高"即序号最小);
  3. 仍并列时,子段最短(等价于同 rr 下 ll 最大)。

输出最大异或值及其起点 ll 与终点 rr。样例 a=[1,0,5,4,2]a = [1, 0, 5, 4, 2] 中,子段 [4,5][4, 5] 的异或 4⊕2=64 \oplus 2 = 6 是全部子段里最大的,输出 6 4 5。

正解

思路

朴素做法与瓶颈:枚举全部 Θ(N2)\Theta(N^2) 个子段求异或,即便配合前缀异或把单段求值降到 O(1)O(1),N=105N = 10^5 时仍有约 5×1095 \times 10^9 次配对,完全不可行。瓶颈在于:每个结尾 rr 都把全部起点重扫了一遍,前一个结尾的信息没有被复用。真正需要回答的子问题是——在历史前缀集合 {p0,…,pr−1}\{p_0, \dots, p_{r-1}\} 中,快速找出与 prp_r 异或最大的成员。

第一层优化:前缀异或。 记 p0=0p_0 = 0,pi=a1⊕⋯⊕aip_i = a_1 \oplus \cdots \oplus a_i,异或的消去律给出

xor(l,r)=pr⊕pl−1\mathrm{xor}(l, r) = p_r \oplus p_{l-1}

于是"选子段"变成"选配对 (j,r)(j, r),0⩽j<r0 \leqslant j < r",答案取 (pr⊕pj, j+1, r)(p_r \oplus p_j,\ j{+}1,\ r) 按三层规则的最优者;其中 j=0j = 0 对应起点 1,所以空前缀哨兵 p0=0p_0 = 0 必须参与配对,否则起点为 1 的子段全部漏掉。但每个结尾仍要扫描 O(r)O(r) 个历史前缀,总代价还是 Θ(N2)\Theta(N^2),需要更快的"最大异或查询"。

第二层优化:01-Trie 上的贪心。 把 p0,…,pr−1p_0, \dots, p_{r-1} 按 21 位二进制从高位到低位插入一棵 01-Trie(每层一个二进位,0/1 各一个儿子)。查询 prp_r 时逐层决策:设当前位为 bb,若"反向位 1−b1-b"的儿子存在就走过去(该位异或得 1),否则只能顺 bb 的儿子走(该位得 0)。贪心的正确性来自高位权重:第 ss 位为 1 的收益 2s2^s 严格大于所有低位之和 2s−12^s - 1,所以"高位能取 1 就先取 1"不会被任何低位组合反超。单次插入、查询都是 O(B)O(B),B=21B = 21 为值域位数,与 NN 解耦。

平局规则怎么落地——本题比一般"最大异或对"多了两层比较,各对应一个实现细节:

  • 结尾最早:按 r=1,2,…,Nr = 1, 2, \dots, N 从左到右扫描,候选值严格大于当前答案才更新——并列时先扫到的(最小 rr)自然保留。
  • 同结尾最短:固定 rr 后,能达到最大值 VV 的配对值是唯一的 v=pr⊕Vv = p_r \oplus V,即所有并列的 jj 都指向同一个前缀值 vv、落在同一个叶子上。叶子在同值重复插入时直接覆盖为更大的 jj,起点 l=j+1l = j + 1 随之最大,子段最短。
  • 子段非空:先查后插——查询 rr 时树里只有 p0…pr−1p_0 \dots p_{r-1},保证 j<rj < r 且右端恰为 rr;查完再把 prp_r 插入供更晚的结尾使用。

样例逐结尾推演(p=[0,1,1,4,0,2]p = [0, 1, 1, 4, 0, 2],表中"已有"列出查询时树里的前缀值及叶子登记的下标,"当前答案"记录扫描到的最优三元组):

结尾 rr prp_r 树中已有的 pj (j<r)p_j\,(j < r) 最优 jj pr⊕pjp_r \oplus p_j 当前答案(最大值 ll rr)
1 1 {0 (j=0)}\{0\,(j{=}0)\} 0 1 1 1 1
2 1 {0 (j=0), 1 (j=1)}\{0\,(j{=}0),\ 1\,(j{=}1)\} 0 1 1 1 1(并列,保最早结尾)
3 4 {0 (j=0), 1 (j=2)}\{0\,(j{=}0),\ 1\,(j{=}2)\} 2 5 5 3 3
4 0 {0 (j=0), 1 (j=2), 4 (j=3)}\{0\,(j{=}0),\ 1\,(j{=}2),\ 4\,(j{=}3)\} 3 4 5 3 3(4<54 < 5)
5 2 {0 (j=4), 1 (j=2), 4 (j=3)}\{0\,(j{=}4),\ 1\,(j{=}2),\ 4\,(j{=}3)\} 3 6 6 4 5

表里有两处平局细节值得注意:r=2r = 2 时候选值与已有答案同为 1,因"严格大于"不更新,最早结尾 r=1r=1 保留;r=3r = 3 时 p1=p2=1p_1 = p_2 = 1 两个下标并列得到 5,叶子已覆盖成 j=2j = 2,所以取子段 [3,3][3,3] 而不是 [2,3][2,3]——正是"最短"规则。r=5r = 5 时 2⊕4=62 \oplus 4 = 6 刷新答案,叶子给出 j=3j = 3,即 l=4,r=5l = 4, r = 5,与样例输出一致。

下图展示 r=5r = 5 查询 p5=2=0102p_5 = 2 = 010_2 时的贪心走位(此时树中为 p0…p4p_0 \dots p_4,值 0 的叶子已覆盖成 j=4j = 4):

graph TD
    R["根"] -->|"0"| A["结点 A"]
    R -->|"1:位2 反向存在,走"| C["结点 C"]
    A -->|"0"| B["结点 B"]
    B -->|"0"| L0["叶:值 0,j=4"]
    B -->|"1"| L1["叶:值 1,j=2"]
    C -->|"0:位1 反向存在,走"| D["结点 D"]
    D -->|"0:位0 反向 1 不存在,顺走"| L4["叶:值 4,j=3"]

三条带文字说明的边构成查询路径,三个决策位依次异或出 11、11、00:位 2 走反向 1(贡献 22=42^2 = 4)、位 1 走反向 0(贡献 21=22^1 = 2)、位 0 反向 1 不存在只能顺走(贡献 0),累计 1102=6=2⊕4110_2 = 6 = 2 \oplus 4,落点正是值 4 的叶子、下标 j=3j = 3。读者可对照表格最后一行:贪心路径给出的“最优 jj”与逐一枚举历史前缀得到的结果相同。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 09:01
# update_at: 2026-10-01 09:01

import sys
from array import array

BITS = 21  # 数值范围 0..2^21-1,从第 20 位往下逐位决策

CH = array("i", [0, 0])  # 01 字典树孩子表:节点 u 的 0/1 孩子存在 CH[2u]/CH[2u+1],0 表示空
IDX = array("i", [-1])   # 节点值表:叶子记录该前缀值已插入的最大下标,非叶子恒为 -1


def insert(value: int, j: int) -> None:
    """把前缀值 value 插入 01 字典树,并在叶子登记下标 j。"""
    u = 0
    for k in range(BITS - 1, -1, -1):
        bit = value >> k & 1
        nxt = CH[2 * u + bit]
        if not nxt:                     # 该分支还没建,补一个叶子链节点
            nxt = len(IDX)
            CH.extend((0, 0))
            IDX.append(-1)
            CH[2 * u + bit] = nxt
        u = nxt
    # 同一个值后插入的下标更大,直接覆盖即保留"最大下标"→ 同值下子段最短
    IDX[u] = j


def query(value: int) -> int:
    """贪心走"异或后该位为 1"的分支,返回与 value 异出最大值的那个前缀下标。"""
    u = 0
    for k in range(BITS - 1, -1, -1):
        bit = value >> k & 1
        u = CH[2 * u + (bit ^ 1)] or CH[2 * u + bit]  # 对立位存在就走,保证该位异或为 1
    return IDX[u]


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n = next(data)

    # 前缀异或 b[0]=0 是"空前缀"哨兵:子段 [l,r] 的异或 = b[r] ^ b[l-1]
    b = [0] * (n + 1)
    for i in range(1, n + 1):
        b[i] = b[i - 1] ^ next(data)

    insert(0, 0)  # 哨兵空前缀:值 0、下标 0(对应起点 1)
    best_v, best_l, best_r = -1, 1, 1
    for r in range(1, n + 1):
        j = query(b[r])               # 只查 j < r,所以先查再插
        cand = b[r] ^ b[j]
        if cand > best_v:             # 严格大于:并列时先保留最早结尾,同结尾由叶子的最大下标保最短
            best_v, best_l, best_r = cand, j + 1, r
        insert(b[r], r)

    print(best_v, best_l, best_r)


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-06 13:28
 * update_at: 2026-10-06 13:28
 */
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 100005;
const int BITS = 21;                              // 数值范围 0..2^21-1,从第 20 位往低位决策
const int MAXNODE = MAXN * (BITS + 1) + 5;        // 每个前缀最多新增 BITS+1 个结点

ll n;               // 奶牛数量
ll pre[MAXN];       // pre[i] = a[1]^...^a[i];pre[0]=0 是空前缀哨兵,对应起点 1

int child[MAXNODE * 2]; // 01 字典树孩子表:结点 u 的 0/1 孩子是 child[2u]/child[2u+1],0 表示空
ll leaf_id[MAXNODE];    // 叶子登记该前缀值已插入的最大下标,非叶子恒为 -1
int node_cnt;           // 已用结点数,结点 0 作根

// 把前缀值 val 插入字典树,并在叶子登记下标 j;同值重复插入时覆盖为更大的 j。
void trie_insert(ll val, ll j) {
    int u = 0;
    for (int k = BITS - 1; k >= 0; k--) {
        ll b = (val >> k) & 1;
        int v = child[2 * u + b];
        if (v == 0) {
            v = node_cnt;
            node_cnt++;
            child[2 * v] = 0;
            child[2 * v + 1] = 0;
            leaf_id[v] = -1;
            child[2 * u + b] = v;
        }
        u = v;
    }
    leaf_id[u] = j; // 后插入的下标更大,直接覆盖即保留最大下标 -> 同值下子段最短
}

// 贪心走“异或后该位为 1”的分支,返回与 val 异或最大的那个前缀下标。
ll trie_query(ll val) {
    int u = 0;
    for (int k = BITS - 1; k >= 0; k--) {
        ll b = (val >> k) & 1;
        int other = child[2 * u + (b ^ 1)];
        if (other != 0) {
            u = other; // 反向位存在就走,该位异或得 1
        } else {
            u = child[2 * u + b]; // 反向位不存在只能顺走,该位异或得 0
        }
    }
    return leaf_id[u];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    pre[0] = 0;
    for (ll i = 1; i <= n; i++) {
        ll x;
        cin >> x;
        pre[i] = pre[i - 1] ^ x;
    }

    // 建树:清空根,插入空前缀哨兵(值 0、下标 0)。
    child[0] = 0;
    child[1] = 0;
    leaf_id[0] = -1;
    node_cnt = 1;
    trie_insert(0, 0);

    ll best_v = -1; // 最大异或值
    ll best_l = 1;  // 最优子段起点
    ll best_r = 1;  // 最优子段终点
    for (ll r = 1; r <= n; r++) {
        ll j = trie_query(pre[r]); // 先查后插,保证 j < r、子段非空
        ll cand = pre[r] ^ pre[j];
        if (cand > best_v) { // 严格大于:并列时保留最早结尾
            best_v = cand;
            best_l = j + 1;
            best_r = r;
        }
        trie_insert(pre[r], r);
    }

    cout << best_v << ' ' << best_l << ' ' << best_r << '\n';
    return 0;
}

实现要点:

  • Trie 用两个 array('i') 平坦存储:CH 每结点两个孩子槽(CH[2u] / CH[2u+1]),IDX 叶子登记该值最大下标,按需扩容——避免 Python 对象开销撑爆 128MB 内存。
  • CH[2 * u + (bit ^ 1)] or CH[2 * u + bit] 一行完成"反向位存在就走、否则顺走"的贪心。
  • 叶子 IDX[u] = j 覆盖式登记承担"同值取最大 jj → 最短子段";主循环 cand > best_v 严格大于才更新承担"最早结尾";query 在 insert 之前承担"子段非空"。三处对应题目的三层规则。
  • b(即正文的 pp)以 b[0] = 0 作空前缀哨兵,在主循环前单独 insert(0, 0),对应起点 1。

复杂度

  • 时间:O(N⋅B)O(N \cdot B),B=21B = 21。NN 个前缀各一次 O(B)O(B) 查询加一次 O(B)O(B) 插入,合计约 2NB≈4.2×1062NB \approx 4.2 \times 10^6 层操作(N=105N = 10^5)。与 C++ 正解(前缀异或 + 01-Trie)同阶。
  • 空间:O(N⋅B)O(N \cdot B) 结点上界,实际共享前缀远少于 21N21N;array('i') 每结点 8 字节孩子槽加 4 字节下标,实测峰值约 40MB,低于 128MB 限制。
  • 实测:数据仓全部 20 组真实数据(NN 从 1 到 10510^5)输出全部一致,单组最大约 0.35s、峰值约 40MB。OJ 限时 1s,Python 在本地较快机器上 0.35s、OJ 上有 TLE 风险,属 python-oj-short 允许范围;算法复杂度与标准 C++ 正解同阶,不是暴力。

总结

  • 前缀异或把"选子段"化为"选配对":xor(l,r)=pr⊕pl−1\mathrm{xor}(l,r) = p_r \oplus p_{l-1},空前缀哨兵 p0=0p_0 = 0 对应起点 1,必须最先插入。
  • 01-Trie 贪心用 O(B)O(B) 回答"与历史前缀的最大异或":高位收益 2s2^s 大于所有低位之和,反向位存在就走;先查后插保证 j<rj < r、子段非空。
  • 平局三规则各有归宿:从左到右扫描 + 严格大于更新 → 最早结尾;叶子同值覆盖保最大 jj → 同结尾最短;插入顺序 → 非空。
  • 边界:N=1N = 1 时只有哨兵可配对;前缀值可重复(样例 p1=p2=1p_1 = p_2 = 1),靠"叶子保最大下标"正确处理,这正是本题区别于裸"最大异或对"的细节。