[GZOI2017] 小z玩游戏

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

把每个兴奋值看成一个状态值,若某个兴奋值 x 能选择一个结束兴奋值为 y 的游戏,就连边 x->y;某个游戏能玩两次,当且仅当它的 e_i 能回到某个整除 w_i 的同 SCC 状态。

OJ: luogu

题目 ID: P5676

难度:提高+/省选-

标签:图论强连通分量tarjan

日期: 2026-06-20 02:22

题意

N 个游戏。

  • i 个游戏的“看上去有趣程度”是 w_i
  • 玩完它以后,兴奋程度会变成 e_i

小 z 只会玩那些满足下面条件的游戏:

  • 当前兴奋程度 x 能整除这个游戏的 w_i

初始兴奋程度是 1

问有多少个游戏“有可能被玩两次”。

这里的“可能”表示:

  • 存在某种选择顺序
  • 让这个游戏在整条序列中出现至少两次

样例图

用样例二来画状态转移图更直观。
样例二的 5 个游戏是:

  • w = [2, 3, 5, 35, 21]
  • e = [7, 11, 7, 3, 2]

这张图展示“玩完一个游戏后,下一步还能选哪些游戏”:

digraph G {
  1 -> 5;
  3 -> 5;
  4 -> 2;
  4 -> 5;
  5 -> 4;
}

其中 2,4,5 之间能绕成环,所以它们可能被再次玩到。 而 13 虽然能走出去,但回不来,所以不能玩第二次。

思路

先看一个最直接的小数据图论版:

cpp
// brute.cpp:直接在“游戏之间”的图上建边,再求强连通分量。
// 如果一个游戏所在的强连通分量大小大于 1,或者自己有自环,
// 那么它就可能在某条游玩顺序里被玩两次。
#include <bits/stdc++.h>
using namespace std;

int t;
int n;
vector<int> w, e_val;
vector< vector<int> > g, rg;
vector<int> order, scc_id, scc_size;
vector<char> vis, self_loop;

void dfs1(int u) {
    vis[u] = 1;
    for (size_t i = 0; i < g[u].size(); i++) {
        int v = g[u][i];
        if (!vis[v]) {
            dfs1(v);
        }
    }
    order.push_back(u);
}

void dfs2(int u, int color) {
    scc_id[u] = color;
    scc_size[color]++;
    for (size_t i = 0; i < rg[u].size(); i++) {
        int v = rg[u][i];
        if (scc_id[v] == 0) {
            dfs2(v, color);
        }
    }
}

int solve_case() {
    cin >> n;

    w.assign(n + 1, 0);
    e_val.assign(n + 1, 0);

    for (int i = 1; i <= n; i++) {
        cin >> w[i];
    }
    for (int i = 1; i <= n; i++) {
        cin >> e_val[i];
    }

    g.assign(n + 1, vector<int>());
    rg.assign(n + 1, vector<int>());
    self_loop.assign(n + 1, 0);

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            if (w[j] % e_val[i] == 0) {
                g[i].push_back(j);
                rg[j].push_back(i);
                if (i == j) {
                    self_loop[i] = 1;
                }
            }
        }
    }

    vis.assign(n + 1, 0);
    order.clear();
    order.reserve(n);

    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            dfs1(i);
        }
    }

    scc_id.assign(n + 1, 0);
    scc_size.assign(n + 1, 0);

    int scc_cnt = 0;
    for (int i = (int)order.size() - 1; i >= 0; i--) {
        int u = order[i];
        if (scc_id[u] == 0) {
            scc_cnt++;
            dfs2(u, scc_cnt);
        }
    }

    int answer = 0;
    for (int i = 1; i <= n; i++) {
        if (scc_size[scc_id[i]] > 1 || self_loop[i]) {
            answer++;
        }
    }

    return answer;
}

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

    cin >> t;
    while (t--) {
        cout << solve_case() << '\n';
    }

    return 0;
}

brute.cpp 的想法是:

  1. 把每个游戏看成一个点
  2. 如果玩完游戏 i 后,兴奋值 e_i 能整除 w_j,就连边 i -> j
  3. 某个游戏能玩两次,当且仅当它所在的图里存在回路能回到自己

所以在这个“游戏图”里,答案就是:

  • 处在非平凡强连通分量里的点
  • 或者自己有自环的点

这个建模是对的,但如果直接建游戏图,边数最坏可能是 O(n2)O(n^2),撑不住。

关键优化是:
后续能选什么游戏,只和“当前兴奋程度”有关,和你刚才玩的是哪个游戏无关。

于是把点改成“兴奋值”会更自然:

  • 图上的一个点表示某个可能出现的兴奋值
  • 如果当前兴奋值是 x,存在某个游戏满足 x | w_i,且玩完后兴奋值变成 e_i
  • 那么就在值图里连边 x -> e_i

现在考虑某个具体游戏 i 什么时候能玩两次。

第一次玩完它以后,兴奋值会变成 e_i
如果以后还能再次玩到它,说明中间经过若干步后,当前兴奋值一定变成了某个 x,并且:

  • x 能整除 w_i

这样才能再次选择游戏 i

所以游戏 i 能玩两次,当且仅当:

  • 从值 e_i 出发,能够走到某个整除 w_i 的值 x

而一旦有这样的 x,由于“再次玩游戏 i”本身就对应一条边 x -> e_i,所以:

  • xe_i 一定在同一个强连通分量里

于是判定条件就变成了:

  • 枚举 w_i 的所有约数里,哪些本身是某个出现过的兴奋值
  • 只要其中有一个约数值和 e_i 属于同一个 SCC,游戏 i 就能玩两次

这样我们只需要在“不同兴奋值个数”这层图上跑一次 SCC,复杂度就降下来了。

代码

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

struct Frame {
    int u;          // 当前点
    int iter_edge;  // 当前枚举到哪条边
};

int t;
int n;
vector<int> w, e_val;

// 缩点后的“兴奋程度值图”。
vector<int> head, to, nxt;
int edge_cnt;

vector<int> dfn, low, parent_node, scc_id;
vector<int> tarjan_stack;
vector<char> in_stack;
int dfs_clock, scc_cnt;

unordered_map<int, int> value_id;
unordered_map<int, vector<int> > divisor_cache;

void add_edge(int u, int v) {
    to.push_back(v);
    nxt.push_back(head[u]);
    edge_cnt++;
    head[u] = edge_cnt;
}

// 找出 w 的所有“同时也是某个 e 值”的约数,对应的图节点编号。
vector<int> &get_divisor_nodes(int x) {
    unordered_map<int, vector<int> >::iterator it = divisor_cache.find(x);
    if (it != divisor_cache.end()) {
        return it->second;
    }

    vector<int> res;
    for (int d = 1; 1LL * d * d <= x; d++) {
        if (x % d != 0) {
            continue;
        }

        unordered_map<int, int>::iterator it1 = value_id.find(d);
        if (it1 != value_id.end()) {
            res.push_back(it1->second);
        }

        int d2 = x / d;
        if (d2 != d) {
            unordered_map<int, int>::iterator it2 = value_id.find(d2);
            if (it2 != value_id.end()) {
                res.push_back(it2->second);
            }
        }
    }

    divisor_cache[x] = res;
    return divisor_cache[x];
}

// 非递归 Tarjan 求强连通分量,避免值图退化成长链时爆栈。
void tarjan_scc(int start) {
    vector<Frame> call_stack;

    call_stack.push_back({start, head[start]});
    parent_node[start] = 0;
    dfn[start] = low[start] = ++dfs_clock;
    tarjan_stack.push_back(start);
    in_stack[start] = 1;

    while (!call_stack.empty()) {
        Frame &cur = call_stack.back();
        int u = cur.u;

        if (cur.iter_edge != 0) {
            int e = cur.iter_edge;
            cur.iter_edge = nxt[e];
            int v = to[e];

            if (!dfn[v]) {
                parent_node[v] = u;
                dfn[v] = low[v] = ++dfs_clock;
                tarjan_stack.push_back(v);
                in_stack[v] = 1;
                call_stack.push_back({v, head[v]});
                continue;
            }

            if (in_stack[v]) {
                low[u] = min(low[u], dfn[v]);
            }
            continue;
        }

        call_stack.pop_back();

        if (parent_node[u] != 0) {
            int p = parent_node[u];
            low[p] = min(low[p], low[u]);
        }

        if (low[u] == dfn[u]) {
            scc_cnt++;
            while (true) {
                int x = tarjan_stack.back();
                tarjan_stack.pop_back();
                in_stack[x] = 0;
                scc_id[x] = scc_cnt;
                if (x == u) {
                    break;
                }
            }
        }
    }
}

int solve_case() {
    cin >> n;

    w.assign(n + 1, 0);
    e_val.assign(n + 1, 0);

    for (int i = 1; i <= n; i++) {
        cin >> w[i];
    }
    for (int i = 1; i <= n; i++) {
        cin >> e_val[i];
    }

    vector<int> values;
    values.reserve(n);
    for (int i = 1; i <= n; i++) {
        values.push_back(e_val[i]);
    }
    sort(values.begin(), values.end());
    values.erase(unique(values.begin(), values.end()), values.end());

    int value_cnt = values.size();

    value_id.clear();
    value_id.reserve(value_cnt * 2 + 5);
    for (int i = 0; i < value_cnt; i++) {
        value_id[values[i]] = i + 1;
    }

    divisor_cache.clear();
    divisor_cache.reserve(n * 2 + 5);

    head.assign(value_cnt + 1, 0);
    to.assign(1, 0);
    nxt.assign(1, 0);
    edge_cnt = 0;

    // 建值图:x -> e_i,表示当前兴奋值是 x 时,可以选择第 i 个游戏。
    for (int i = 1; i <= n; i++) {
        int target = value_id[e_val[i]];
        vector<int> &div_nodes = get_divisor_nodes(w[i]);
        for (size_t j = 0; j < div_nodes.size(); j++) {
            add_edge(div_nodes[j], target);
        }
    }

    dfn.assign(value_cnt + 1, 0);
    low.assign(value_cnt + 1, 0);
    parent_node.assign(value_cnt + 1, 0);
    scc_id.assign(value_cnt + 1, 0);
    in_stack.assign(value_cnt + 1, 0);
    tarjan_stack.clear();
    tarjan_stack.reserve(value_cnt);
    dfs_clock = 0;
    scc_cnt = 0;

    for (int i = 1; i <= value_cnt; i++) {
        if (!dfn[i]) {
            tarjan_scc(i);
        }
    }

    int answer = 0;

    // 游戏 i 能玩两次:
    // e_i 可以走到某个 x,并且 x 能再次选择 i。
    // 后者等价于 x | w_i,所以只要找到一个与 e_i 同 SCC 的约数值 x 即可。
    for (int i = 1; i <= n; i++) {
        int my_scc = scc_id[value_id[e_val[i]]];
        vector<int> &div_nodes = get_divisor_nodes(w[i]);

        bool ok = false;
        for (size_t j = 0; j < div_nodes.size(); j++) {
            if (scc_id[div_nodes[j]] == my_scc) {
                ok = true;
                break;
            }
        }

        if (ok) {
            answer++;
        }
    }

    return answer;
}

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

    cin >> t;
    while (t--) {
        cout << solve_case() << '\n';
    }

    return 0;
}

复杂度

设不同兴奋值个数为 K

值图上的节点数是 K,建边时只会枚举每个 w_i 的约数,所以:

  • 时间复杂度主要是 O(总约数枚举+K+边数)O(总约数枚举 + K + 边数)
  • 在本题范围内可以视为线性到近线性

空间复杂度:

  • O(K+边数)O(K + 边数)

总结

这题最重要的转化不是 Tarjan 本身,而是:

  • 后续可选游戏只取决于当前兴奋值,不取决于上一个游戏是谁

一旦把“游戏图”压成“兴奋值图”,问题就变成了一个很标准的 SCC 判定:

  1. 建值图
  2. 跑强连通分量
  3. 对每个游戏检查:w_i 的某个约数值,是否和 e_i 在同一个 SCC

这样就把原本看起来像博弈/搜索的问题,改写成了纯图论题。

一图流解析

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

一图流解析