[CSP-S 2020] 贪吃蛇

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

用双队列维护强弱顺序,先处理必吃局面,再递归判断冒险吃。

OJ: luogu

题目 ID: P7078

难度:提高+/省选-

标签:贪心博弈双端队列模拟

创建: 2026-07-06 08:46

更新: 2026-10-07 10:45

形式化题目

有 nn 条蛇,第 ii 条蛇有体力值 aia_i 和编号 ii。强弱比较规则:体力值大者强;体力值相同则编号大者强。

决斗进行若干轮。每轮由当前最强蛇决定是否吃掉当前最弱蛇:若吃,最强蛇的体力值减去最弱蛇的体力值,最弱蛇退出,决斗继续;若不吃,决斗立刻结束。每条蛇的目标是:先保证自己不被吃,在此前提下尽量多吃别的蛇。

求决斗结束时存活蛇的数量。有多组测试数据,每组在上一组的体力值上做若干单点修改后重新求解。

解法路线

题面按数据规模给分:n=3n = 3 得 20 分,n⩽10n \leqslant 10 得 40 分,n⩽2000n \leqslant 2000 得 55 分,n⩽5×104n \leqslant 5 \times 10^4 得 70 分,n⩽106n \leqslant 10^6 得 100 分。三层解法与档位对应:

  1. 暴力解法(20/40 分档):完全按题面规则递归模拟决斗,不加任何结论。
  2. 子任务解法(55/70 分档):证明两条贪心结论,把"敢不敢吃"变成一条递归判断链,用 std::set 维护强弱顺序。
  3. 正解(100 分档):复用同样的贪心结论,用双队列代替有序集合,把单组复杂度降到 O(n)O(n)。

后一层直接复用前一层的观察,依次消除的瓶颈是"暴力搜索"和"取最大最小的容器开销"。正式主解是 main.cpp。

暴力解法:直接模拟决斗(20/40 分档)

适用范围

n⩽10n \leqslant 10。数据再大时递归层数与状态规模都会超时。

思路

直接翻译题面规则:当前最强蛇先假设自己吃掉最弱蛇,然后递归看它在后续游戏中是否还能存活。如果能存活,它就会吃(多吃一条总是更优);否则它会停手,决斗结束。

代码

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-01 22:33
 * update_at: 2026-10-01 22:33
 */
// subtask_40.cpp:20/40 分档(n <= 10)解法。完全按题面规则递归模拟决斗:
// 最强蛇若吃掉最弱蛇后自己仍能存活到最后就吃,否则选择不吃、决斗立刻结束。
// 不依赖任何贪心结论,直接翻译题面规则,适合小数据。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 15;

struct Snake {
    ll value;
    int id;
};

int T, n;
ll a[MAXN]; // a[i]:第 i 条蛇的体力值

// 比较两条蛇的强弱:体力值大者强,相同则编号大者强。
// 参数用 x/y,避开全局体力值数组 a[]。
bool weaker_than(const Snake &x, const Snake &y) {
    if (x.value != y.value) {
        return x.value < y.value;
    }
    return x.id < y.id;
}

// 判断编号为 id 的蛇是否在存活集合中。
bool contains_id(const vector<Snake> &alive, int id) {
    for (int i = 0; i < (int)alive.size(); i++) {
        if (alive[i].id == id) {
            return true;
        }
    }
    return false;
}

// 递归模拟决斗,返回最终存活的蛇。
// 当前最强蛇先尝试吃:吃完后若自己仍能存活到最后就吃,否则选择不吃、决斗结束。
vector<Snake> play_game(vector<Snake> state) {
    sort(state.begin(), state.end(), weaker_than);
    if ((int)state.size() == 1) {
        return state;
    }

    Snake weakest = state.front();
    Snake strongest = state.back();

    // 假设最强蛇吃掉最弱蛇,构造下一状态。
    vector<Snake> next_state;
    for (int i = 1; i + 1 < (int)state.size(); i++) {
        next_state.push_back(state[i]);
    }
    Snake changed;
    changed.value = strongest.value - weakest.value;
    changed.id = strongest.id;
    next_state.push_back(changed);

    // 递归求"吃完之后"的存活集合。
    vector<Snake> alive_after_eat = play_game(next_state);
    if (contains_id(alive_after_eat, strongest.id)) {
        return alive_after_eat; // 吃了能活到最后,就吃
    }
    return state; // 吃了会死,选择不吃,决斗结束
}

// 求解单组测试数据,返回最终存活蛇的数量。
int solve_current_case() {
    vector<Snake> state;
    for (int i = 1; i <= n; i++) {
        Snake x;
        x.value = a[i];
        x.id = i;
        state.push_back(x);
    }
    vector<Snake> alive = play_game(state);
    return (int)alive.size();
}

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

    cin >> T;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    cout << solve_current_case() << '\n';

    for (int tc = 2; tc <= T; tc++) {
        int k;
        cin >> k;
        for (int i = 1; i <= k; i++) {
            int x;
            ll y;
            cin >> x >> y;
            a[x] = y;
        }
        cout << solve_current_case() << '\n';
    }

    return 0;
}

对拍时使用等价的带记忆化实现 brute.cpp,两者决策逻辑相同。

复杂度与瓶颈

每层递归排序一次当前存活集合,递归深度 O(n)O(n),单组 O(n2log⁡n)O(n^2 \log n)。瓶颈:没有利用任何决策规律,nn 稍大就慢,无法处理 n=106n = 10^6。

子任务解法:贪心结论 + 有序集合(55/70 分档)

适用范围

n⩽5×104n \leqslant 5 \times 10^4。n=106n = 10^6 时 std::set 的对数常数偏大。

思路

这一层的关键是两条贪心结论。

第一个结论:如果最强蛇 xx 吃掉最弱蛇 yy 后,新的 x−yx-y 不是当前最弱蛇,那么 xx 一定会吃。直观理解是:它吃完以后前面至少还有更弱的蛇替它挡刀;后面无论谁停手或继续吃,它都不会因为这一次操作立即变成最危险的目标。

第二个结论:如果 x−yx-y 变成了最弱蛇,那么 xx 是否敢吃,要看下一条最强蛇会不会吃它。下一条蛇如果吃完后也变成最弱蛇,又要继续看再下一条蛇。于是这一段是一个递归反推:

text
下一条蛇会吃  -> 当前蛇不敢吃
下一条蛇不吃  -> 当前蛇敢吃

实现分成两步:先不断执行第一类"必吃"操作,直到某次吃完会变成最弱蛇,或者只剩一条蛇;再对剩余局面做一次递归判断,看这一次"冒险吃"能不能发生。

容器用 std::set 存所有存活蛇,(体力值, 编号) 升序即从弱到强,取最大最小都是 O(log⁡n)O(\log n)。冒险递归时把"试探性吃"直接写进集合再递归:如果递归深处判定这条蛇会被吃掉,当前蛇就不吃;否则吃。

注意一个边界:只剩两条蛇时,最强吃最弱后独自存活,必吃,不需要取"第二弱"。

代码

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-01 22:33
 * update_at: 2026-10-01 22:33
 */
// subtask_55.cpp:55/70 分档解法。先用两条贪心结论把"敢不敢吃"理清
// (吃完不是最弱必吃;吃完变最弱则递归看下一条蛇敢不敢吃),
// 再用 std::set 维护强弱顺序,单组 O(n log n)。n <= 1e6 时建议换双队列 O(n)。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

// (体力值, 编号) 升序即从弱到强:体力值小者弱,相同则编号小者弱。
typedef pair<ll, int> Snake;

int T, n;
ll a[1000005];  // a[i]:第 i 条蛇的体力值
set<Snake> alive; // 当前存活的蛇,begin() 最弱,rbegin() 最强
int eaten_count;  // 已经被吃掉的蛇的数量

// 取出当前最弱蛇。
Snake get_min_snake() {
    Snake result = *alive.begin();
    alive.erase(alive.begin());
    return result;
}

// 取出当前最强蛇。
Snake get_max_snake() {
    set<Snake>::iterator it = alive.end();
    it--;
    Snake result = *it;
    alive.erase(it);
    return result;
}

// 最强蛇吃掉最弱蛇后生成的新蛇:体力值相减,编号保留。
Snake make_after_eat(const Snake &strongest, const Snake &weakest) {
    return make_pair(strongest.first - weakest.first, strongest.second);
}

// 判断 strongest 吃 weakest 后,新蛇是否一定不是当前最弱蛇(即可安全吃)。
bool after_eat_not_weakest(const Snake &strongest, const Snake &weakest, const Snake &second_min) {
    return make_after_eat(strongest, weakest) > second_min;
}

// 第一阶段:处理所有"必吃"局面。
// 只剩两条时不进循环(见 solve_current_case 的特判),保证取第二弱时集合非空。
void solve_forced_part() {
    while ((int)alive.size() > 2) {
        Snake strongest = get_max_snake();
        Snake weakest = get_min_snake();
        Snake second_min = get_min_snake();

        if (after_eat_not_weakest(strongest, weakest, second_min)) {
            eaten_count++;
            alive.insert(make_after_eat(strongest, weakest));
            alive.insert(second_min);
        } else {
            // 吃完会变最弱:把取出来的三条还回去,交给冒险阶段递归判断。
            alive.insert(strongest);
            alive.insert(second_min);
            alive.insert(weakest);
            return;
        }
    }
}

// 第二阶段:递归判断当前局面下最强蛇是否敢冒险吃最弱蛇。
// 吃完变最弱时模拟吃完的后果:下一条蛇会吃它 -> 它不敢吃;否则敢吃。
bool can_eat_in_risky_part(int alive_count) {
    if (alive_count <= 1) {
        return false;
    }
    if (alive_count == 2) {
        return true;
    }

    Snake strongest = get_max_snake();
    Snake weakest = get_min_snake();
    Snake second_min = get_min_snake();

    if (after_eat_not_weakest(strongest, weakest, second_min)) {
        return true;
    }

    alive.insert(second_min);
    alive.insert(make_after_eat(strongest, weakest));
    return !can_eat_in_risky_part(alive_count - 1);
}

// 求解单组测试数据,返回最终存活蛇的数量。
int solve_current_case() {
    alive.clear();
    eaten_count = 0;
    for (int i = 1; i <= n; i++) {
        alive.insert(make_pair(a[i], i));
    }

    solve_forced_part();

    int alive_count = (int)alive.size();
    if (alive_count == 2) {
        // 只剩两条:最强吃最弱后独自存活,必吃。
        eaten_count++;
    } else if (can_eat_in_risky_part(alive_count)) {
        eaten_count++;
    }

    return n - eaten_count;
}

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

    cin >> T;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    cout << solve_current_case() << '\n';

    for (int tc = 2; tc <= T; tc++) {
        int k;
        cin >> k;
        for (int i = 1; i <= k; i++) {
            int x;
            ll y;
            cin >> x >> y;
            a[x] = y;
        }
        cout << solve_current_case() << '\n';
    }

    return 0;
}

复杂度与瓶颈

必吃阶段每条蛇进出集合常数次,O(nlog⁡n)O(n \log n);冒险递归深度 O(n)O(n),每层 O(log⁡n)O(\log n)。单组 O(nlog⁡n)O(n \log n)。瓶颈:有序集合常数大,n=106n = 10^6 时不够快。

正解:双队列线性扫描(100 分档)

关键观察

输入保证 aia_i 以不降顺序给出(修改后依然不降),所以初始所有蛇天然是有序的;吃出来的新蛇也能用另一个双端队列维护,不会打乱两个队列各自的有序性。于是"取全局最弱、最强"不需要平衡树,只要比较两个队列的队首或队尾。

思路

贪心结论与上一层完全相同,只换容器:原始蛇放队列 1,吃出来的新蛇放队列 2,两个队列都按从弱到强排列。

  • 取最弱:比较两队列队首,取较弱者;
  • 取最强:比较两队列队尾,取较强者;
  • 吃完生成的新蛇放队列 2 的弱端,被还回的第二弱蛇放回原队列弱端。

因为每次都在当前约束下做最优决策,前面数字的最优选择不会被后面破坏。每条蛇在一次测试数据中最多被取出、放入常数次,单组 O(n)O(n)。

代码

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-01 21:11
 * update_at: 2026-10-01 22:33
 */
// main.cpp:双队列维护强弱顺序,先处理必吃局面,再递归判断冒险吃。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 1000005;

struct Snake {
    ll value;
    int id;
    int from_queue;
};

int T, n;
ll a[MAXN];
Snake q1[MAXN * 2], q2[MAXN * 2];
int l1, r1, l2, r2;
int eaten_count;

// 比较两条蛇的强弱:体力值大者强,相同则编号大者强。
// 参数用 x/y,避开全局体力值数组 a[]。
bool weaker_than(const Snake &x, const Snake &y) {
    if (x.value != y.value) {
        return x.value < y.value;
    }
    return x.id < y.id;
}

bool stronger_than(const Snake &x, const Snake &y) {
    if (x.value != y.value) {
        return x.value > y.value;
    }
    return x.id > y.id;
}

// 从两个队列的队首取出较弱者。
Snake get_min_snake() {
    Snake result;
    if (l1 <= r1 && l2 <= r2) {
        if (weaker_than(q1[l1], q2[l2])) {
            result = q1[l1++];
        } else {
            result = q2[l2++];
        }
    } else if (l1 <= r1) {
        result = q1[l1++];
    } else {
        result = q2[l2++];
    }
    return result;
}

// 从两个队列的队尾取出较强者。
Snake get_max_snake() {
    Snake result;
    if (l1 <= r1 && l2 <= r2) {
        if (stronger_than(q1[r1], q2[r2])) {
            result = q1[r1--];
        } else {
            result = q2[r2--];
        }
    } else if (l1 <= r1) {
        result = q1[r1--];
    } else {
        result = q2[r2--];
    }
    return result;
}

void push_back_original(const Snake &x) {
    q1[++r1] = x;
}

void push_front_original(const Snake &x) {
    q1[--l1] = x;
}

void push_back_new(const Snake &x) {
    q2[++r2] = x;
}

void push_front_new(const Snake &x) {
    q2[--l2] = x;
}

void restore_front(const Snake &x) {
    if (x.from_queue == 1) {
        push_front_original(x);
    } else {
        push_front_new(x);
    }
}

void restore_back(const Snake &x) {
    if (x.from_queue == 1) {
        push_back_original(x);
    } else {
        push_back_new(x);
    }
}

// 最强蛇吃掉最弱蛇后生成的新蛇。
Snake make_after_eat(const Snake &strongest, const Snake &weakest) {
    Snake result;
    result.value = strongest.value - weakest.value;
    result.id = strongest.id;
    result.from_queue = 2;
    return result;
}

// 判断 strongest 吃 weakest 后,新蛇是否一定不是当前最弱蛇。
bool after_eat_not_weakest(const Snake &strongest, const Snake &weakest, const Snake &second_min) {
    Snake changed = make_after_eat(strongest, weakest);
    return stronger_than(changed, second_min);
}

// 第一阶段:处理所有"必吃"局面。
// 若最强蛇吃完后不是最弱,则它一定会吃,继续循环。
// 只剩两条时不进循环(见 solve_current_case 的特判),保证取第二弱时队列非空。
void solve_forced_part() {
    while (n - eaten_count > 2) {
        Snake strongest = get_max_snake();
        Snake weakest = get_min_snake();
        Snake second_min = get_min_snake();

        if (after_eat_not_weakest(strongest, weakest, second_min)) {
            eaten_count++;
            Snake changed = make_after_eat(strongest, weakest);

            // 新蛇在这一阶段不会成为最弱;为了维持双队列顺序,先放新蛇,再还回第二弱蛇。
            push_front_new(changed);
            restore_front(second_min);
        } else {
            restore_back(strongest);
            restore_front(second_min);
            restore_front(weakest);
            return;
        }
    }
}

// 第二阶段:递归判断当前局面下,最强蛇是否敢冒险吃最弱蛇。
bool can_eat_in_risky_part(int alive_count) {
    if (alive_count <= 1) {
        return false;
    }
    if (alive_count == 2) {
        return true;
    }

    Snake strongest = get_max_snake();
    Snake weakest = get_min_snake();
    Snake second_min = get_min_snake();

    if (after_eat_not_weakest(strongest, weakest, second_min)) {
        return true;
    }

    Snake changed = make_after_eat(strongest, weakest);
    restore_front(second_min);
    push_front_new(changed);

    // 如果下一条蛇会吃掉它,那么当前蛇就不能冒险;否则当前蛇可以吃。
    return !can_eat_in_risky_part(alive_count - 1);
}

// 求解单组测试数据,返回最终存活蛇的数量。
int solve_current_case() {
    l1 = MAXN;
    r1 = MAXN - 1;
    l2 = MAXN;
    r2 = MAXN - 1;
    eaten_count = 0;

    for (int i = 1; i <= n; i++) {
        Snake x;
        x.value = a[i];
        x.id = i;
        x.from_queue = 1;
        push_back_original(x);
    }

    solve_forced_part();
    int alive_count = n - eaten_count;
    if (alive_count == 2) {
        // 只剩两条:最强吃最弱后独自存活,必吃。
        eaten_count++;
    } else if (can_eat_in_risky_part(alive_count)) {
        eaten_count++;
    }

    return n - eaten_count;
}

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

    cin >> T;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    cout << solve_current_case() << '\n';

    for (int tc = 2; tc <= T; tc++) {
        int k;
        cin >> k;
        for (int i = 1; i <= k; i++) {
            int x;
            ll y;
            cin >> x >> y;
            a[x] = y;
        }
        cout << solve_current_case() << '\n';
    }

    return 0;
}

复杂度

  • 时间复杂度:单组 O(n)O(n)。多组数据在修改体力值后重新计算,整体为 O(Tn+修改数)O(Tn + \text{修改数})。
  • 空间复杂度:O(n)O(n),两个双端队列各开 2n2n 的容量。

总结

本题的难点不是维护最大最小,而是判断"最强蛇是否敢吃"。只要抓住"吃完不是最弱就必吃;吃完变最弱就递归看下一条蛇"的博弈结构,剩下就是容器选型:std::set 足够 55/70 分档,双端队列利用输入有序性把 100 分档做到 O(n)O(n)。

对拍时,brute.cpp 使用完整博弈递归搜索,可以验证小数据下贪心结论的实现是否正确。