『JROI-5』Color

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

把完全二叉树的不完整部分压缩成“最后一个叶子到根”的一条路径,预处理满树方案数后沿这条路径自底向上递推。

OJ: luogu

题目 ID: P8089

难度:提高+/省选-

标签:树形DP动态规划完全二叉树递推

日期: 2026-06-21 04:50

题意

给一棵 dep 层的完全二叉树。

要求统计有多少个“包含根节点的连通块”,答案对 998244353 取模。

思路

先看一个适合小数据验证的暴力:

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

const int MOD = 998244353;

vector<vector<int> > g;

long long dfs(int u, int fa) {
    long long ret = 1; // 只选当前根
    for (size_t i = 0; i < g[u].size(); i++) {
        int v = g[u][i];
        if (v == fa) {
            continue;
        }
        ret = ret * (dfs(v, u) + 1) % MOD;
    }
    return ret;
}

long long parse_binary(const string &s) {
    long long x = 0;
    for (size_t i = 0; i < s.size(); i++) {
        x = x * 2 + (s[i] - '0');
    }
    return x;
}

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

    // brute.cpp:显式建出小规模完全二叉树,再在真实树上递归统计。
    int T;
    cin >> T;
    while (T--) {
        int dep;
        string s;
        cin >> dep >> s;
        long long last_cnt = parse_binary(s);

        if (dep == 1) {
            cout << 1 << '\n';
            continue;
        }

        vector<long long> exist_pos;
        long long last_begin = 1LL << (dep - 1);
        long long last_end = last_begin + last_cnt - 1;

        long long total_pos = (1LL << dep) - 1;
        vector<int> id(total_pos + 2, 0);
        int tot = 0;

        for (int level = 1; level <= dep - 1; level++) {
            long long l = 1LL << (level - 1);
            long long r = (1LL << level) - 1;
            for (long long pos = l; pos <= r; pos++) {
                id[pos] = ++tot;
            }
        }
        for (long long pos = last_begin; pos <= last_end; pos++) {
            id[pos] = ++tot;
        }

        g.assign(tot + 1, vector<int>());
        for (long long pos = 1; pos <= total_pos; pos++) {
            if (id[pos] == 0) {
                continue;
            }
            long long lc = pos << 1;
            long long rc = lc | 1;
            if (lc <= total_pos && id[lc] != 0) {
                g[id[pos]].push_back(id[lc]);
                g[id[lc]].push_back(id[pos]);
            }
            if (rc <= total_pos && id[rc] != 0) {
                g[id[pos]].push_back(id[rc]);
                g[id[rc]].push_back(id[pos]);
            }
        }

        cout << dfs(1, 0) % MOD << '\n';
    }
    return 0;
}

如果树真的建出来,那么设 f(u) 表示“在 u 子树中,选出的连通块必须包含 u 的方案数”,就有:

f(u) = (f(ls)+1)(f(rs)+1)

因为左右子树都可以:

  • 一个点不选
  • 或者选一个包含对应儿子根节点的连通块

难点不在转移,而在树太大,不能显式建出来。

这题的关键观察是:

  • 完全二叉树除了最后一层外,其余层都是满的
  • 所以整棵树只有“最后一个叶子到根”的那条路径附近是不规则的
  • 路径旁边挂着的子树,不是满树就是空树

于是先预处理:

  • A[h] = 高度为 h 的满二叉树方案数 + 1

满足:

  • A[0] = 1
  • A[h] = A[h-1]^2 + 1

递推公式

A[h] 表示高度为 h 的满二叉树“选一个包含根的连通块,或者整棵不选”的方案数,则:

A[0]=1,A[h]=A[h1]2+1 A[0]=1,\quad A[h]=A[h-1]^2+1

沿最后一个叶子的路径自底向上回推时,若另一侧是高度为 t 的满子树,就有:

valA[t](val+1) val \leftarrow A[t]\cdot (val+1)

然后把“最后一层节点个数”减一,得到最后一个叶子在底层的 0-based 编号。 它的二进制表示,正好告诉我们从根到这个叶子的路径每一层是往左走还是往右走。

下面这张图展示了这种结构:

flowchart TD
  R["当前根"] --> L["继续递推的那一侧"]
  R --> F["另一侧是一棵满子树"]

所以可以自底向上回推:

  • 一边继续带着当前 val
  • 另一边直接乘预处理好的满树方案数

整题就从“指数级建树”变成了“扫一遍二进制路径”。

代码

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

const int MOD = 998244353;
const int MAXD = 1000005;

int T;
int dep_arr[15];
string s_arr[15];
int max_dep;
long long A[MAXD];

string minus_one_binary(string s) {
    int n = (int) s.size();
    for (int i = n - 1; i >= 0; i--) {
        if (s[i] == '1') {
            s[i] = '0';
            for (int j = i + 1; j < n; j++) {
                s[j] = '1';
            }
            return s;
        }
    }
    return s;
}

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

    cin >> T;
    max_dep = 0;
    for (int i = 1; i <= T; i++) {
        cin >> dep_arr[i];
        cin >> s_arr[i];
        if (dep_arr[i] > max_dep) {
            max_dep = dep_arr[i];
        }
    }

    // A[i] = 满 i 层完全二叉树的方案数 + 1。
    // A[0] = 1 对应空树。
    A[0] = 1;
    for (int i = 1; i <= max_dep; i++) {
        A[i] = (A[i - 1] * A[i - 1] + 1) % MOD;
    }

    for (int tc = 1; tc <= T; tc++) {
        int dep = dep_arr[tc];
        string s = s_arr[tc];

        if ((int) s.size() < dep) {
            s = string(dep - (int) s.size(), '0') + s;
        }

        if (dep == 1) {
            cout << 1 << '\n';
            continue;
        }

        // c 表示最后一层节点数,r = c - 1。
        // 对 dep 位二进制做减一后,最高位一定是 0,后面的 dep-1 位
        // 正好描述“最后一个叶子”在底层从左到右的 0-based 位置。
        s = minus_one_binary(s);

        long long val = 1; // 高度为 1 的单点树,只有选根这一种方案。
        int cur_h = 1;

        // 从低位往高位回推,相当于不断把当前子树向上接一层父亲。
        for (int i = dep - 1; i >= 1; i--) {
            if (s[i] == '0') {
                val = A[cur_h - 1] * (val + 1) % MOD;
            } else {
                val = A[cur_h] * (val + 1) % MOD;
            }
            cur_h++;
        }

        cout << val % MOD << '\n';
    }

    return 0;
}

复杂度

预处理 O(maxdep)O(max dep),每组询问 O(dep)O(dep),总空间复杂度 O(maxdep)O(max dep)

总结

这题最值得记住的点是:

  • 完全二叉树的不完整部分,可以压缩成一条路径
  • 路径旁边的大块结构,都能用“满树递推值”一次处理掉

这是处理超大规模完全二叉树计数题时很常见的一类技巧。

一图流解析

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

一图流解析