将序列安排问题转化为树形对抗过程中的最长上升子序列 DP,通过预处理合法 LIS 状态进行状态压缩转移。
OJ: roj
题目 ID: 10018
难度:提高+/省选-
标签:DP状态压缩树形结构
创建: 2026-10-10 07:12
更新: 2026-10-10 07:12
目录
形式化题目
有
数据范围:
正解
思路
单败淘汰赛的赛程可以看作一棵满二叉树。主角要赢得最终冠军,必须在
因为主角可以任意安排初始顺序,他有
由于题目对
通过 DFS,我们可以预处理出所有可达的合法 LIS 状态轮廓(可以利用贪心判断:过滤掉最终即便填满也无法达到长度
代码
#!/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()/**
* 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 状态数
。 时间复杂度为 。 ★ 实测 (R2 验证,与此前声称的「数千」相差两个数量级): 7 8 9( ) $ S $ 4140 ★ 注意
MAX_STATE取120000,只比实测最大115975大 4025(极紧)。 -
空间复杂度:DP 表
20 × 120000 × 8B ≈ 19MB,组合数表540² × 8B ≈ 2.3MB, 加上状态轮廓到 ID 的映射 —— 合计仍 < 128MB(config.json的"memory": 128)。
总结
- 本题巧妙地将二叉树上胜者的组合计数转化为了按顺序选择元素的背包 DP。
- 判定 LIS 长度
限制时,利用“从小到大插入,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.cpp0.080s 49.9MB std0.080s 49.8MB main.py0.640s 61.2MB 限额
1000ms / 128MB⇒ C++ 余量 12×; ★ Python 余量仅 ~1.5×(偏紧但通过)(冷启动一次测到cpp0.449s,仍 < 1s)
与 std.cpp 的逐行差异点(7 条,均语义等价)
- 主解
dp[0][state_id["00..0"]] = 1(显式)vsstddp[0][1] = 1(硬编码下标) —— ★ 等价且双证:全零轮廓恒过k过滤且 DFS 序排第一 ⇒S[1] == "00..0"(数学 + 实测) - 主解加
if(next_id == 0) continue(防map隐式插入)vsstd写dp[i+1][0]垃圾槽 —— 等价 (槽 0 从不被读) ll mask |= 1LL<<jvsint cnt |= 1<<j——n ≤ 9等价dp[20][120000]/beat[20]vsstddp[17][120000]/beat[105]—— 主解对越界m > 16更稳(out-of-spec)- 主解
if(!(cin>>n>>m>>k>>mod)) return 0空输入安全;std空输入是 UB can_still_reach/init_comb/ 转移式 /×2^n循环 —— 逐字符同语义- 其余(注释、命名、
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 的语义内容) |