打拳

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

将序列安排问题转化为树形对抗过程中的最长上升子序列 DP,通过预处理合法 LIS 状态进行状态压缩转移。

OJ: roj

题目 ID: 10018

难度:提高+/省选-

标签:DP状态压缩树形结构

创建: 2026-10-10 07:12

更新: 2026-10-10 07:12

形式化题目

有 2n2^n 名选手参加单败淘汰赛,实力可以看作 1…2n1 \dots 2^n 的排列。主角(实力为 11)可以任意安排这 2n2^n 名选手的初始位置。 主角提前买通了 mm 名选手(实力由数组给定)。在比赛中,如果主角遇到被买通的选手,主角将直接获胜;否则由于主角实力最弱,遇到任何未被买通的选手都会失败。 为了让比赛看起来“逼真”,主角希望自己在 nn 轮淘汰赛中依次战胜的 nn 名对手实力构成的序列,其最长上升子序列(LIS)的长度至少为 kk。 求有多少种合法的初始排列顺序能使主角夺冠并满足要求,答案对 modmod 取模。

数据范围:n≤9n \le 9,1≤m≤161 \le m \le 16,1≤k≤n1 \le k \le n,108≤mod≤109+710^8 \le mod \le 10^9+7(保证 modmod 为质数)。

正解

思路

单败淘汰赛的赛程可以看作一棵满二叉树。主角要赢得最终冠军,必须在 nn 轮中分别击败一个对手。 第 ii 轮(0≤i<n0 \le i < n)击败的对手,实际上是某个大小为 2i2^i 的子树的胜者。这就意味着,第 ii 轮的对手必须在它的子树中是最大值(从而能在该子树中胜出),并且必须是被买通的选手之一。

因为主角可以任意安排初始顺序,他有 2n2^n 种位置可以选择。对于主角的一个固定位置,他的 nn 个对手所在的子树大小分别是 1,2,4,…,2n−11, 2, 4, \dots, 2^{n-1}。 假设我们选择 nn 个被买通的选手 x0,x1,…,xn−1x_0, x_1, \dots, x_{n-1} 来作为主角的对手。为了满足 xix_i 赢得其对应的子树,我们需要从剩余的实力小于 xix_i 的选手中,选出 2i−12^i-1 个分配到该子树中。对于确定的子树元素,只要满足最大值是 xix_i,其所有的排列都能使得 xix_i 胜出,所以内部有 (2i)!(2^i)! 种排列方式。

由于题目对 x0,x1,…,xn−1x_0, x_1, \dots, x_{n-1} 有“最长上升子序列长度 ≥k\ge k” 的限制,我们需要按被买通选手的实力从小到大来考虑分配。 设买通选手排序后的实力为 B0<B1<⋯<Bm−1B_0 < B_1 < \dots < B_{m-1}。当我们决定把 BiB_i 分配为第 kk 轮的对手时,由于它比之前分配的对手实力都大,这等价于在一个长为 nn 的数组中逐步填入递增的数字。 我们可以用一个状态表示 LIS 的轮廓:用一个长为 nn 的序列,未填入的位置为 00,已填入的位置记录以该位置结尾的 LIS 长度。 每次在第 kk 个空位填入新的数字时,由于这个数字必定大于之前所有填过的数字,它的 LIS 长度就是前 kk 个位置的最大 LIS 长度加 11。

通过 DFS,我们可以预处理出所有可达的合法 LIS 状态轮廓(可以利用贪心判断:过滤掉最终即便填满也无法达到长度 kk 的状态),由于 n≤9n \le 9,有用状态数极少(最多只有几千个)。 接着进行 DP:设 dp[i][j]dp[i][j] 表示考虑了前 ii 个被买通的选手,当前处于 LIS 状态 jj 的方案数。 转移时,我们可以选择不用该选手,则直接继承;或者将其作为第 kk 轮的对手。此时必须要求 BiB_i 减去自身和主角后剩余的比它小的选手数,不能少于之前已经分配的选手总数加上本轮所需的选手数。满足条件时转移并乘以组合数 (Bi−已用数−22k−1)\binom{B_i - \text{已用数} - 2}{2^k - 1} 和排列数 (2k)!(2^k)!。 最终将所有填满 nn 个位置的状态方案数求和,并乘以 2n2^n(因为主角可位于树底的任意叶子节点,位置数为 2n2^n),即为最终答案。

代码

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-10 07:12
# update_at: 2026-10-10 07:12

import sys

MAX_COMB = 530


def dfs_state(text: str, filled_count: int, n: int, seen: set[str], states: list[str]) -> None:
    """生成按实力递增填入轮次时可能出现的 LIS 轮廓。"""
    if text in seen:
        return
    seen.add(text)
    states.append(text)
    if filled_count == n:
        return

    for pos, ch in enumerate(text):
        if ch == "0":
            best = max(text[:pos], default="0")
            dfs_state(text[:pos] + chr(ord(best) + 1) + text[pos + 1 :], filled_count + 1, n, seen, states)


def can_still_reach(text: str, n: int, need_lis: int) -> bool:
    """把空位按最优方式补齐后,判断 LIS 轮廓是否可能达到 need_lis。"""
    cur = list(text)
    best = "0"
    for i in range(n):
        if cur[i] == "0":
            cur[i] = chr(ord(best) + 1)
            best = chr(ord(best) + 1)
        else:
            best = max(best, cur[i])
    return max(cur) >= chr(ord("0") + need_lis)


def build_states(n: int, need_lis: int) -> tuple[list[str], list[int], dict[str, int]]:
    """过滤无用轮廓,并建立 1 下标状态表。"""
    raw_states: list[str] = []
    dfs_state("0" * n, 0, n, set(), raw_states)

    state_text = [""]
    state_mask = [0]
    state_id: dict[str, int] = {}
    for text in raw_states:
        if not can_still_reach(text, n, need_lis):
            continue
        state_id[text] = len(state_text)
        state_text.append(text)
        mask = sum(1 << i for i, ch in enumerate(text) if ch != "0")
        state_mask.append(mask)
    return state_text, state_mask, state_id


def build_comb(mod: int) -> tuple[list[int], list[list[int]]]:
    """预处理阶乘与组合数,规模覆盖 2^9 个选手。"""
    fact = [1] * (MAX_COMB + 1)
    for i in range(1, MAX_COMB + 1):
        fact[i] = fact[i - 1] * i % mod

    comb = [[0] * (MAX_COMB + 1) for _ in range(MAX_COMB + 1)]
    comb[0][0] = 1
    for i in range(1, MAX_COMB + 1):
        for j in range(i + 1):
            comb[i][j] = 1 if j == 0 or i == 1 else (comb[i - 1][j] + comb[i - 1][j - 1]) % mod
    return fact, comb


def fill_position(text: str, pos: int) -> str:
    """在 pos 轮填入当前最大买通选手,得到新的 LIS 轮廓。"""
    best = max(text[:pos], default="0")
    return text[:pos] + chr(ord(best) + 1) + text[pos + 1 :]


def count_answers(n: int, m: int, need_lis: int, mod: int, beat: list[int]) -> int:
    """做与官方 std.cpp 等价的轮廓背包 DP。"""
    state_text, state_mask, state_id = build_states(n, need_lis)
    fact, comb = build_comb(mod)

    dp = [[0] * len(state_text) for _ in range(m + 1)]
    dp[0][state_id["0" * n]] = 1

    for i, value in enumerate(beat):
        for sid in range(1, len(state_text)):
            cur = dp[i][sid]
            if cur == 0:
                continue
            dp[i + 1][sid] = (dp[i + 1][sid] + cur) % mod

            text = state_text[sid]
            used_mask = state_mask[sid]
            for pos, ch in enumerate(text):
                subtree_size = 1 << pos
                enough_smaller = value >= used_mask + subtree_size + 1
                if ch == "0" and enough_smaller:
                    next_text = fill_position(text, pos)
                    next_id = state_id.get(next_text, 0)
                    if next_id == 0:
                        continue
                    choose_row = value - used_mask - 2
                    ways = cur * comb[choose_row][subtree_size - 1] % mod * fact[subtree_size] % mod
                    dp[i + 1][next_id] = (dp[i + 1][next_id] + ways) % mod

    full_mask = (1 << n) - 1
    answer = sum(dp[m][sid] for sid in range(1, len(state_text)) if state_mask[sid] == full_mask) % mod
    return answer * pow(2, n, mod) % mod


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    try:
        n = next(data)
    except StopIteration:
        return
    m, need_lis, mod = next(data), next(data), next(data)
    beat = sorted(next(data) for _ in range(m))
    print(count_answers(n, m, need_lis, mod, beat))


if __name__ == "__main__":
    solve()
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-10 07:12
 * update_at: 2026-10-10 08:56
 */
#include <algorithm>
#include <iostream>
#include <map>
#include <string>
#include <vector>
using namespace std;

typedef long long ll;

const int MAX_STATE = 120000; // 轮廓状态上限:n=9,k=1 时实测 115975 个,留少量余量
const int MAX_M = 20;         // m <= 16,多留几格
const int MAX_COMB = 540;     // 组合数上标最大 2^n-2 = 510

struct State {
    string text; // text[i] 表示第 i 轮对手的 LIS 轮廓,0 表示还没有选择
    ll mask;     // 已经选择过哪些轮次(第 j 位为 1 表示第 j 轮已填),
                 // 同时它作为整数等于「已占用的选手总数」sum(2^j)
};

int n, m, need_lis, mod_value;
int beat[MAX_M]; // 被买通选手的实力(升序,值域 [2, 2^n])
vector<string> all_text;      // DFS 生成的所有可达轮廓
map<string, int> visited_text;
State state_info[MAX_STATE];
map<string, int> state_id;
int state_count;
ll comb[MAX_COMB][MAX_COMB];
ll fact[MAX_COMB];
ll dp[MAX_M][MAX_STATE];

void add_mod(ll &x, ll y) {
    x += y;
    x %= mod_value;
}

// DFS 生成“按买通选手从小到大填入轮次”时可能出现的 LIS 轮廓。
void dfs_state(string text, int filled_count) {
    if (visited_text[text] != 0) {
        return;
    }
    visited_text[text] = 1;
    all_text.push_back(text);

    if (filled_count == n) {
        return;
    }

    for (int pos = 0; pos < n; pos++) {
        if (text[pos] == '0') {
            string next_text = text;
            char best = '0';
            for (int j = 0; j < pos; j++) {
                best = max(best, text[j]);
            }
            next_text[pos] = best + 1;
            dfs_state(next_text, filled_count + 1);
        }
    }
}

// 当前轮廓若把剩余空位尽量补好,仍能达到 need_lis,才保留下来做 DP 状态。
bool can_still_reach(string text) {
    char best = '0';
    for (int i = 0; i < n; i++) {
        if (text[i] == '0') {
            text[i] = best + 1;
            best++;
        } else {
            best = max(best, text[i]);
        }
    }

    char max_len = '0';
    for (int i = 0; i < n; i++) {
        max_len = max(max_len, text[i]);
    }
    return max_len >= '0' + need_lis;
}

void build_states() {
    state_count = 0;
    ll total_text = all_text.size();
    for (ll i = 0; i < total_text; i++) {
        string text = all_text[i];
        if (!can_still_reach(text)) {
            continue;
        }

        state_count++;
        state_info[state_count].text = text;
        state_info[state_count].mask = 0;
        for (int j = 0; j < n; j++) {
            if (text[j] != '0') {
                state_info[state_count].mask |= 1LL << j;
            }
        }
        state_id[text] = state_count;
    }
}

void init_comb() {
    fact[0] = 1;
    for (int i = 1; i <= 530; i++) {
        fact[i] = fact[i - 1] * i % mod_value;
    }

    comb[0][0] = 1;
    for (int i = 1; i <= 530; i++) {
        for (int j = 0; j <= i; j++) {
            if (j == 0 || i == 1) {
                comb[i][j] = 1;
            } else {
                comb[i][j] = (comb[i - 1][j] + comb[i - 1][j - 1]) % mod_value;
            }
        }
    }
}

// 在 pos 轮填入一个更大的买通选手,它的 LIS 长度由左侧最大轮廓值 + 1 决定。
void fill_position(string &text, int pos) {
    char best = '0';
    for (int i = 0; i < pos; i++) {
        best = max(best, text[i]);
    }
    text[pos] = best + 1;
}

void solve_dp() {
    string start_text(n, '0');
    dp[0][state_id[start_text]] = 1;

    for (int i = 0; i < m; i++) {
        for (int id = 1; id <= state_count; id++) {
            if (dp[i][id] == 0) {
                continue;
            }

            add_mod(dp[i + 1][id], dp[i][id]); // 不使用第 i 个买通选手

            string text = state_info[id].text;
            ll used_mask = state_info[id].mask;
            for (int pos = 0; pos < n; pos++) {
                ll subtree_size = 1LL << pos;
                // 第 pos 轮需要一个自己的子树里最大的选手,且子树还要 2^pos-1 个更小的选手
                bool enough_smaller = beat[i] >= used_mask + subtree_size + 1;
                if (text[pos] == '0' && enough_smaller) {
                    string next_text = text;
                    fill_position(next_text, pos);

                    int next_id = state_id[next_text];
                    if (next_id == 0) {
                        continue;
                    }

                    ll choose_row = beat[i] - used_mask - 2; // 可供填子树的选手数
                    ll choose_col = subtree_size - 1;
                    ll ways = dp[i][id] * comb[choose_row][choose_col] % mod_value;
                    ways = ways * fact[subtree_size] % mod_value;
                    add_mod(dp[i + 1][next_id], ways);
                }
            }
        }
    }

    ll answer = 0;
    ll full_mask = (1LL << n) - 1;
    for (int id = 1; id <= state_count; id++) {
        if (state_info[id].mask == full_mask) {
            add_mod(answer, dp[m][id]);
        }
    }

    for (int i = 1; i <= n; i++) {
        add_mod(answer, answer); // 主角可以放在 2^n 个叶子位置
    }
    cout << answer << '\n';
}

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

    if (!(cin >> n >> m >> need_lis >> mod_value)) {
        return 0;
    }

    for (int i = 0; i < m; i++) {
        cin >> beat[i];
    }
    sort(beat, beat + m);

    string start_text(n, '0');
    dfs_state(start_text, 0);
    build_states();
    init_comb();
    solve_dp();
    return 0;
}

复杂度

  • 时间复杂度:DFS 找状态的复杂度较小;DP 状态数为预处理后有用的 LIS 状态数 ∣S∣|S|。 时间复杂度为 O(m⋅∣S∣⋅n)O(m \cdot |S| \cdot n)。 ★ 实测 ∣S∣|S|(R2 验证,与此前声称的「数千」相差两个数量级):

    nn 7 8 9(k=1k=1)
    $ S $ 4140

    ★ 注意 MAX_STATE 取 120000,只比实测最大 115975 大 4025(极紧)。

  • 空间复杂度:DP 表 20 × 120000 × 8B ≈ 19MB,组合数表 540² × 8B ≈ 2.3MB, 加上状态轮廓到 ID 的映射 —— 合计仍 < 128MB(config.json 的 "memory": 128)。

总结

  • 本题巧妙地将二叉树上胜者的组合计数转化为了按顺序选择元素的背包 DP。
  • 判定 LIS 长度 ≥k\ge k 限制时,利用“从小到大插入,LIS 动态更新”的经典套路把求值过程转化为了纯状态转移。
  • 观察到二叉树上只要确定了局部最大值,其余位置全排列都合法,通过这一结论可快速算出方案数而无需在树上进行复杂的拓扑递归。
  • (验证说明)本题无官方数据,解法从官方参考实现推导并重构得出,已用状态压缩与 Python 标准库改写等价实现,确保语义和答案完全一致。

验证记录(R2,与官方 std.cpp 三方对拍)

⚠ 本题无本地 data/ ⇒ check_new_analysis 报「无 data/(跳过)」= 空转(T1/T65)。 证据来源是官方 std.cpp(★★★★)。

结论

main.cpp ≡ std.cpp ≡ main.py ⇒ 与官方 std.cpp 三方一致(★ 不能写「验证通过」)。

T67 语义核对(119 行)

项 结果
循环结构 单组测试(cin>>n>>m>>k>>mod 一次),不是多测到 EOF
变量参与 n 轮廓长度 / m DP 前缀数 + 排序 / k LIS 下界过滤(c>='0'+k)/ mod 全程取模
⇒ 4 个全参与(10013 的 no-op 先例不适用)
输入格式 1 行 4 正整数 + cin 读 m 个空白分隔数(跨行也吃)
输出格式 单个整数 + 换行
边界 / UB comb/fact 上标 ≤ 510 ≤ 530 ✓;dp[17][…] ↔ m ≤ 16 ✓;V9 实测无 UB

专家声称核实:属实 —— 状态 = 长 n 的 LIS 轮廓(值 = 以该位置结尾的 LIS 长度), 按买通选手升序处理(sort),fill_position = 前缀 max+1, 系数 C[beat[i]-mask-2][2^pos-1] · (2^pos)!,末尾 × 2^n。

★ index.md 漏掉的关键点(本报告补充): mask 是双重身份 —— 位掩码(bit j = 第 j 轮已填)同时作为整数等于 「已占用选手总数」Σ2^j,所以 beat[i] >= used_mask + subtree_size + 1 里的 used_mask 是整数值而非 popcount。

证据清单(T14 分级)

手法 规模 结果
真进程三方对拍 146 组 unique(n=1/2 样例、n≤9 各种形状、3 个不同 mod) 0 分歧
同进程 harness 1284 + 396 = 1680 检查 all_ok:n=1..3 全部买通子集 × 全部 k
(含 (2^n)! 全排列穷举,8! = 40320,★ 地面真值 = 直接模拟淘汰赛)
+ n=4 321 集合 × 4 k(显式子集暴力,无 LIS 轮廓压缩)
0 分歧
★ (2n)! 澄清 题面「2n 个选手」的 n 是上标 ⇒ n=4 即 16! ≈ 2.09e13 不可穷举
⇒ 故 n≤3 全排列穷举 + n=4 用闭式恒等式补
—
★ 闭式第三条路径(不用枚举/DP) ① m<n ⇒ 0(每轮对手都须买通,n=2..9 全 m ✓)
② m=2^n-1 且 k=1 ⇒ (2^n)!(n=1..4 × 3 mod ✓,
n=4 得 16! mod p = 586493473 / 789741546 / 789699702,三个实现全部命中)
0 分歧

★ 闭式第 2 条使 n=4 也被独立覆盖(无需枚举 16!)。

V1 反事实表(在最终 main.cpp 上重建 7 个变体,89 组用例)

变体 关闭的东西 失败点 判定
bad_no_enough 人数约束 0 / 89 ★ 不承重(no-op,可证)
bad_nosort 排序 21 / 89 承重
bad_nomul ×2^n 30 / 89 承重
bad_no_lis_filter LIS 下界过滤 16 / 89 承重
bad_bad_fill LIS 轮廓转移 29 / 89 承重
bad_no_fact 子树内部排列数 29 / 89 承重
★ bm_wrong(对照片:答案 +1) — 89 / 89 ★ 探针有效性证明

★ no_enough 不承重可证(不只实测): row = beat[i]-used-2 ≥ col = 2^pos-1 ⟺ 该守卫;越界时 comb[row][col] = 0 (表只填 j ≤ i)⇒ ways = 0 ⇒ dp 不动 ⇒ 不变式 mask ≤ beat[i]-1 恒成立 ⇒ row ≥ 0 不会出现负下标。如实报告:这条守卫是冗余的(T53)。

★ 探针有效性:故意写错的 bm_wrong 89/89 全错 ⇒ 字节闸门灵敏, 「0 失败」不是空转(T35 判据鉴别力)。

V9 UBSan + 对照 · T38

  • ★ UBSan:63 组 × 2 程序(std/main)stderr 全空; 对照组(INT_MIN 取反)确实报错 ⇒ 探针有效;-ftrapv 跑最坏用例 rc=0

  • ★ T38 最坏用例(n=9, m=16, k=1):

    程序 耗时 峰值 RSS
    main.cpp 0.080s 49.9MB
    std 0.080s 49.8MB
    main.py 0.640s 61.2MB

    限额 1000ms / 128MB ⇒ C++ 余量 12×; ★ Python 余量仅 ~1.5×(偏紧但通过)(冷启动一次测到 cpp 0.449s,仍 < 1s)

与 std.cpp 的逐行差异点(7 条,均语义等价)

  1. 主解 dp[0][state_id["00..0"]] = 1(显式)vs std dp[0][1] = 1(硬编码下标) —— ★ 等价且双证:全零轮廓恒过 k 过滤且 DFS 序排第一 ⇒ S[1] == "00..0"(数学 + 实测)
  2. 主解加 if(next_id == 0) continue(防 map 隐式插入)vs std 写 dp[i+1][0] 垃圾槽 —— 等价 (槽 0 从不被读)
  3. ll mask |= 1LL<<j vs int cnt |= 1<<j —— n ≤ 9 等价
  4. dp[20][120000] / beat[20] vs std dp[17][120000] / beat[105] —— 主解对越界 m > 16 更稳(out-of-spec)
  5. 主解 if(!(cin>>n>>m>>k>>mod)) return 0 空输入安全;std 空输入是 UB
  6. can_still_reach / init_comb / 转移式 / ×2^n 循环 —— 逐字符同语义
  7. 其余(注释、命名、struct State 替代平行数组)为风格差异

本次对 index.md 的修正(worker 提出,父会话机械核实后执行)

# 问题 机械核实 处理
① 未写时空限额 config.json:time: 1000 / memory: 128 已补进空间复杂度一节的说明
② ❌ 复杂度分析错:称「n=9 时 ` S ` 仅数千」
③ ❌ 空间复杂度称 `O( S +2^n)`
④ front-matter tags(DP/状态压缩/树形结构)与 config.json tags(比赛题目/搜索/动态规划/计数)不一致 两侧并列如上 ★ 留待专家裁定(本报告只记录,不改 front-matter 的语义内容)