[CSP-S 2024] 擂台游戏

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

把赛程看成满二叉树,预处理确定赢家与自由前缀,再用差分统计每个叶子可能夺冠的前缀区间。

OJ: luogu

题目 ID: P11234

难度:省选/NOI-

标签:树形结构动态规划

日期: 2026-06-22 18:45

题意

n 位已报名选手,第 i 位选手编号为 i,能力为 a_i。对某个前缀 c,只收到前 c 位选手,需要补充最少的人,使总人数变成 2 的幂。

比赛是固定赛程的淘汰赛。每场比赛会指定左边或右边的胜者作为擂主。若擂主能力至少为当前轮次,擂主获胜;否则另一方获胜。

补充选手能力可以任意选择。如果补充选手可能成为冠军,也要计入答案。对每个询问 c_i,求所有可能冠军编号之和,最后按题目要求输出加权 xor。

思路

先看一个小数据暴力:对补充选手枚举能力值,再模拟整棵比赛树,收集所有可能冠军。

cpp
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 20;

int n, m, max_k;
int base_ability[MAXN], current_ability[MAXN], query_c[MAXN];
int draw_round[10][MAXN];
set<int> possible_winner;

int ceil_power_log(int x) {
    int k = 0;
    while ((1 << k) < x) {
        k++;
    }
    return k;
}

int simulate_tournament(int total_players, int k, int ability[]) {
    vector<int> player;
    for (int i = 1; i <= total_players; i++) {
        player.push_back(i);
    }

    for (int round_id = 1; round_id <= k; round_id++) {
        vector<int> next_player;
        int games = (int)player.size() / 2;
        for (int g = 1; g <= games; g++) {
            int left = player[(g - 1) * 2];
            int right = player[(g - 1) * 2 + 1];
            int lord, other;

            if (draw_round[round_id][g] == 0) {
                lord = left;
                other = right;
            } else {
                lord = right;
                other = left;
            }

            if (ability[lord] >= round_id) {
                next_player.push_back(lord);
            } else {
                next_player.push_back(other);
            }
        }
        player = next_player;
    }

    return player[0];
}

void enumerate_unknown(int pos, int total_players, int known_count, int k, int ability[]) {
    if (pos > total_players) {
        possible_winner.insert(simulate_tournament(total_players, k, ability));
        return;
    }

    if (pos <= known_count) {
        enumerate_unknown(pos + 1, total_players, known_count, k, ability);
        return;
    }

    // 补充选手只需要枚举 0..k,能力超过 k 与 k 等价。
    for (int value = 0; value <= k; value++) {
        ability[pos] = value;
        enumerate_unknown(pos + 1, total_players, known_count, k, ability);
    }
}

long long champion_sum_for_prefix(int prefix_len) {
    int k = ceil_power_log(prefix_len);
    int total_players = 1 << k;
    int ability[MAXN];

    for (int i = 1; i <= total_players; i++) {
        if (i <= prefix_len) {
            ability[i] = current_ability[i];
        } else {
            ability[i] = 0;
        }
    }

    possible_winner.clear();
    enumerate_unknown(1, total_players, prefix_len, k, ability);

    long long sum = 0;
    for (set<int>::iterator it = possible_winner.begin(); it != possible_winner.end(); ++it) {
        sum += *it;
    }
    return sum;
}

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

    cin >> n >> m;
    max_k = ceil_power_log(n);

    for (int i = 1; i <= n; i++) {
        cin >> base_ability[i];
    }
    for (int i = 1; i <= m; i++) {
        cin >> query_c[i];
    }

    for (int round_id = 1; round_id <= max_k; round_id++) {
        string s;
        cin >> s;
        for (int j = 0; j < (int)s.size(); j++) {
            draw_round[round_id][j + 1] = s[j] - '0';
        }
    }

    int test_count;
    cin >> test_count;
    while (test_count--) {
        int mask_value[4];
        for (int i = 0; i < 4; i++) {
            cin >> mask_value[i];
        }

        for (int i = 1; i <= n; i++) {
            current_ability[i] = base_ability[i] ^ mask_value[i & 3];
        }

        long long ans = 0;
        for (int i = 1; i <= m; i++) {
            ans ^= 1LL * i * champion_sum_for_prefix(query_c[i]);
        }

        cout << ans << '\n';
    }

    return 0;
}

补充选手能力超过总轮数 K 后没有区别,所以暴力只枚举 0..K。但补充人数可能很多,仍然是指数级。

正解把赛程看成一棵满二叉树。叶子是选手编号,内部结点是一场比赛。对某个前缀 c,前 c 个叶子是已知选手,后面的叶子是补充选手。

我们希望反过来统计:每个叶子在哪些前缀长度下可能成为冠军。若一个编号 id 能在前缀区间 [l,r] 中成为冠军,就把 id 加到这个区间上。最后对差分数组做前缀和,就得到每个 c 的答案。

需要维护几个树上信息:

变量 含义
fixed_winner[u] 子树 u 完全确定时,最终赢家的能力
free_time[u] 子树 u 最后仍可能受补充选手影响的前缀位置
known_limit[leaf] 已知选手能力能支持它通过的最高补齐层级
front_limit[leaf] 从根往下看,这个叶子不被确定赢家挡住的最大前缀

fixed_winnerfree_time 可以自底向上计算。叶子如果是已知选手,它在前缀达到自己之前还不可用,所以自由时刻是 pos - 1;补充叶子一直可以自由选择能力。

known_limit 处理的是已知选手自身能力够不够。沿着叶子到根的路径,如果它所在的一侧被抽为擂主,就必须满足对应轮次的能力要求。

front_limit 处理的是“会不会被对面确定赢家挡住”。对每个补齐规模 2^W,从对应子树根向下传播限制:若某一侧确定赢家已经足够赢当前轮,就会压缩另一侧叶子的可行前缀范围。

最后枚举补齐规模 W 和叶子:

  • 如果叶子是已知选手,且 known_limit 支持它打到这一层,就把它能成为冠军的前缀区间加入差分;
  • 如果叶子是补充选手,只要它所在位置在当前前缀之后,且没有被 front_limit 挡住,也把它的前缀区间加入差分。

对差分数组做前缀和后,answer_prefix[c] 就是前缀 c 的可能冠军编号和。

代码

cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
const int MAXNODE = 300005;

int n, m, max_k, base_size;
int ability[MAXN], query_c[MAXN];
int draw_side[MAXNODE];       // 内部结点抽到 0/1,0 表示左边为擂主
int log_floor_value[MAXNODE]; // 堆编号的 floor(log2)
int fixed_winner[MAXNODE];    // 子树完全确定时,赢家的能力值
int free_time[MAXNODE];       // 子树最后仍可能自由变化的前缀时刻
int father_need[MAXNODE];     // 从当前结点往上,选手作为擂主需要满足的最高轮次
int known_limit[MAXNODE];     // 已知选手能力能通过的祖先限制
int front_limit[MAXNODE];     // 从上往下 DP 得到的可贡献前缀上界
long long answer_prefix[MAXN + 5];

int min_int(int x, int y) {
    return x < y ? x : y;
}

int max_int(int x, int y) {
    return x > y ? x : y;
}

void prepare_tree_info() {
    base_size = 1 << max_k;

    for (int i = 2; i < base_size * 2; i++) {
        log_floor_value[i] = log_floor_value[i >> 1] + 1;
    }

    father_need[1] = max_k;
    for (int i = 2; i < base_size * 2; i++) {
        int parent = i >> 1;
        int is_right_child = i & 1;

        // 如果这个孩子所在的半区是擂主,记录跳到这里后还需要满足的层级限制。
        if (is_right_child == draw_side[parent]) {
            father_need[i] = max_k - log_floor_value[i];
        } else {
            father_need[i] = father_need[parent];
        }
    }
}

void build_fixed_winner() {
    for (int pos = 1; pos <= base_size; pos++) {
        int node = base_size + pos - 1;
        if (pos <= n) {
            free_time[node] = pos - 1;
            fixed_winner[node] = ability[pos];
        } else {
            free_time[node] = base_size;
            fixed_winner[node] = 0;
        }
    }

    for (int pos = 1; pos <= n; pos++) {
        int node = base_size + pos - 1;
        int climb = ability[pos] < max_k ? ability[pos] : max_k;
        known_limit[node] = father_need[node >> climb];
    }

    for (int node = base_size - 1; node >= 1; node--) {
        int left = node << 1;
        int right = left | 1;
        int round_id = max_k - log_floor_value[node];

        if (draw_side[node] == 0) {
            if (fixed_winner[left] < round_id) {
                free_time[node] = free_time[right];
                fixed_winner[node] = fixed_winner[right];
            } else {
                free_time[node] = free_time[left];
                fixed_winner[node] = fixed_winner[left];
            }
        } else {
            // 右边是擂主。自由性来自右边擂主是否能跨过当前轮,所以始终继承右子树的自由时刻。
            free_time[node] = free_time[right];
            if (fixed_winner[right] >= round_id) {
                fixed_winner[node] = fixed_winner[right];
            } else {
                fixed_winner[node] = fixed_winner[left];
            }
        }
    }
}

void solve_current_abilities() {
    for (int i = 0; i <= n + 2; i++) {
        answer_prefix[i] = 0;
    }

    build_fixed_winner();

    // W 表示当前前缀补齐到 2^W 人。
    for (int W = 0; W <= max_k; W++) {
        int lower_bound_prefix = (W == 0) ? 0 : (1 << (W - 1));
        int root_offset = (1 << (max_k - W)) - 1;

        front_limit[root_offset + 1] = base_size;

        for (int id = 1; id < (1 << W); id++) {
            int node = (root_offset << log_floor_value[id]) + id;
            int left = node << 1;
            int right = left | 1;
            int round_id = max_k - log_floor_value[node];

            front_limit[left] = front_limit[node];
            front_limit[right] = front_limit[node];

            if (draw_side[node] == 0) {
                if (fixed_winner[left] >= round_id) {
                    front_limit[right] = min_int(front_limit[right], free_time[node]);
                } else {
                    front_limit[left] = min_int(front_limit[left], free_time[left]);
                }
            } else {
                if (fixed_winner[right] >= round_id) {
                    front_limit[left] = min_int(front_limit[left], free_time[node]);
                } else {
                    front_limit[right] = min_int(front_limit[right], free_time[right]);
                }
            }
        }

        for (int i = 0; i < (1 << W); i++) {
            int player_id = i + 1;
            int leaf = base_size + i;

            // 已知选手成为冠军的前缀区间。
            if (player_id <= n && known_limit[leaf] >= W && front_limit[leaf] >= player_id &&
                front_limit[leaf] >= lower_bound_prefix + 1) {
                int left = max_int(player_id, lower_bound_prefix + 1);
                int right = min_int(front_limit[leaf], 1 << W);
                if (left <= right && left <= n) {
                    right = min_int(right, n);
                    answer_prefix[left] += player_id;
                    answer_prefix[right + 1] -= player_id;
                }
            }

            // 补充选手成为冠军的前缀区间。
            if (W > 0 && i > lower_bound_prefix && front_limit[leaf] >= lower_bound_prefix) {
                int left = lower_bound_prefix + 1;
                int right = min_int(i, front_limit[leaf]);
                if (left <= right && left <= n) {
                    right = min_int(right, n);
                    answer_prefix[left] += player_id;
                    answer_prefix[right + 1] -= player_id;
                }
            }
        }
    }

    for (int i = 1; i <= n; i++) {
        answer_prefix[i] += answer_prefix[i - 1];
    }
}

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

    cin >> n >> m;
    while ((1 << max_k) < n) {
        max_k++;
    }
    base_size = 1 << max_k;

    for (int i = 1; i <= n; i++) {
        cin >> ability[i];
    }
    for (int i = 1; i <= m; i++) {
        cin >> query_c[i];
    }

    for (int round_id = 1; round_id <= max_k; round_id++) {
        string s;
        cin >> s;
        int start = 1 << (max_k - round_id);
        for (int j = 0; j < (int)s.size(); j++) {
            draw_side[start + j] = s[j] - '0';
        }
    }

    prepare_tree_info();

    int test_count;
    cin >> test_count;
    while (test_count--) {
        int mask_value[4];
        for (int i = 0; i < 4; i++) {
            cin >> mask_value[i];
        }

        for (int i = 1; i <= n; i++) {
            ability[i] ^= mask_value[i & 3];
        }

        solve_current_abilities();

        long long ans = 0;
        for (int i = 1; i <= m; i++) {
            ans ^= 1LL * i * answer_prefix[query_c[i]];
        }

        for (int i = 1; i <= n; i++) {
            ability[i] ^= mask_value[i & 3];
        }

        cout << ans << '\n';
    }

    return 0;
}

复杂度

K = ceil(log2 n)。每组 xor 测试数据的复杂度为 O(nlogn)O(n log n),空间复杂度为 O(n)O(n)

总结

这题难点不是模拟比赛,而是同时处理所有前缀。核心做法是把“某个选手能否夺冠”转成“某个叶子在哪些前缀中可行”,再用差分统一统计。

树上自底向上的 fixed_winner/free_time 负责确定子树信息,自顶向下的 front_limit 负责传播阻挡条件。两部分合起来,就能得到每个叶子的可行前缀区间。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析