把接龙过程建成有颜色的边,用两个接龙人压缩颜色状态,并用扫描窗口批量处理连续子序列转移。
OJ: luogu
题目 ID: P11230
难度:普及+/提高
标签:建模动态规划状态压缩滑动窗口模拟
日期: 2026-07-05 21:24
题意
有
规则是:
- 第一轮的接龙序列必须以
开头; - 之后每轮的接龙序列必须以上一轮的最后一个数开头;
- 相邻两轮不能由同一个人接龙。
每个询问给出
思路
先看一个可以直接验证想法的朴素解:
// brute.cpp:小数据暴力解,显式枚举每个人词库产生的所有有色边。
#include <bits/stdc++.h>
using namespace std;
struct Edge {
int to;
int person;
};
const int MAXV = 205;
vector<Edge> edges[MAXV];
bool cur[MAXV][15], nxt[MAXV][15];
bool answer[105][MAXV];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n, k, q;
cin >> n >> k >> q;
for (int i = 0; i < MAXV; i++) {
edges[i].clear();
for (int p = 0; p < 15; p++) {
cur[i][p] = false;
nxt[i][p] = false;
}
for (int r = 0; r < 105; r++) {
answer[r][i] = false;
}
}
vector<vector<int> > seq(n + 1);
for (int p = 1; p <= n; p++) {
int len;
cin >> len;
seq[p].resize(len + 1);
for (int i = 1; i <= len; i++) {
cin >> seq[p][i];
}
for (int l = 1; l <= len; l++) {
for (int r = l + 1; r <= len && r <= l + k - 1; r++) {
int from = seq[p][l];
int to = seq[p][r];
if (from < MAXV && to < MAXV) {
edges[from].push_back({to, p});
}
}
}
}
vector<int> query_r(q + 1), query_c(q + 1);
int max_r = 0;
for (int i = 1; i <= q; i++) {
cin >> query_r[i] >> query_c[i];
max_r = max(max_r, query_r[i]);
}
cur[1][0] = true;
for (int round = 1; round <= max_r; round++) {
for (int v = 0; v < MAXV; v++) {
for (int p = 0; p < 15; p++) {
nxt[v][p] = false;
}
}
for (int from = 1; from < MAXV; from++) {
for (int i = 0; i < (int)edges[from].size(); i++) {
int to = edges[from][i].to;
int person = edges[from][i].person;
bool ok = false;
for (int last = 0; last <= n; last++) {
if (cur[from][last] && last != person) {
ok = true;
}
}
if (ok) {
nxt[to][person] = true;
answer[round][to] = true;
}
}
}
for (int v = 0; v < MAXV; v++) {
for (int p = 0; p < 15; p++) {
cur[v][p] = nxt[v][p];
}
}
}
for (int i = 1; i <= q; i++) {
int r = query_r[i];
int c = query_c[i];
if (c < MAXV && answer[r][c]) {
cout << 1 << '\n';
} else {
cout << 0 << '\n';
}
}
}
return 0;
}下面是另一种「状态搜索」风格的暴力写法。它把状态写成“当前轮数、当前数值、上一轮接龙人”,再递归枚举下一条能走的接龙边:
另一种暴力写法:状态搜索
// brute_01_style.cpp:状态搜索风格暴力,递归枚举每一轮能走的接龙边。
#include <bits/stdc++.h>
using namespace std;
struct Edge {
int to;
int person;
};
int n, k, q;
int max_value;
int target_round, target_value;
vector<vector<int> > seq;
vector<vector<Edge> > edges;
map<long long, int> memo; // 0 表示未知,1 表示失败,2 表示成功。
long long make_key(int round, int value, int last_person) {
return ((long long)round * (max_value + 1) + value) * (n + 1) + last_person;
}
bool dfs_game(int round, int value, int last_person) {
if (round == target_round) {
return value == target_value;
}
if (value < 0 || value > max_value) {
return false;
}
long long key = make_key(round, value, last_person);
auto it = memo.find(key);
if (it != memo.end()) {
return it->second == 2;
}
// 下一轮可以选择一条从当前值出发、且不是同一个人的边。
for (int i = 0; i < (int)edges[value].size(); i++) {
int to = edges[value][i].to;
int person = edges[value][i].person;
if (person == last_person) {
continue;
}
if (dfs_game(round + 1, to, person)) {
memo[key] = 2;
return true;
}
}
memo[key] = 1;
return false;
}
void build_edges() {
edges.assign(max_value + 1, vector<Edge>());
for (int person = 1; person <= n; person++) {
int len = (int)seq[person].size() - 1;
for (int l = 1; l <= len; l++) {
for (int r = l + 1; r <= len && r <= l + k - 1; r++) {
int from = seq[person][l];
int to = seq[person][r];
if (from <= max_value && to <= max_value) {
Edge e;
e.to = to;
e.person = person;
edges[from].push_back(e);
}
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
cin >> n >> k >> q;
seq.assign(n + 1, vector<int>());
max_value = 1;
for (int person = 1; person <= n; person++) {
int len;
cin >> len;
seq[person].resize(len + 1);
for (int i = 1; i <= len; i++) {
cin >> seq[person][i];
max_value = max(max_value, seq[person][i]);
}
}
vector<int> query_r(q + 1), query_c(q + 1);
for (int i = 1; i <= q; i++) {
cin >> query_r[i] >> query_c[i];
max_value = max(max_value, query_c[i]);
}
build_edges();
for (int i = 1; i <= q; i++) {
target_round = query_r[i];
target_value = query_c[i];
memo.clear();
if (dfs_game(0, 1, 0)) {
cout << 1 << '\n';
} else {
cout << 0 << '\n';
}
}
}
return 0;
}这题不是那种只靠一个漂亮公式就能解决的题。更准确地说,它是一个“把暴力模型压到可实现”的题:
- 先把接龙过程建模清楚;
- 再发现暴力建边会爆;
- 最后用两个压缩技巧,把边数和状态数都压下来。
brute.cpp 对小数据显式枚举每个人序列中所有合法连续子序列,把每个连续子序列看成一条有颜色的边:
起点数值 -> 终点数值,颜色 = 接龙人编号然后做恰好
正解也沿用这个模型,但不真的建出所有边。原因是:如果某个人的序列长度为
所以本题真正值得抓住的思维点只有两个。
思维点 1:颜色状态压缩
第
最后一个数值是 value,上一轮是哪个人接龙下一轮如果想使用人
当前 value 有没有一种到达方式,使得上一轮接龙人不是 p这句话很关键。它说明我们不需要保存“所有能到达
| 当前 |
下一轮用人 |
原因 |
|---|---|---|
| 没有人 | 不可行 | |
| 可行 | ||
| 可行 | 上一轮不是 |
|
| 不可行 | 会连续使用同一个人 | |
| 可行 | 可以选择上一轮为 |
因此,对每个值
:第一个代表人; :第二个不同的代表人。
如果只有一个代表人且正好等于当前人
思维点 2:连续子序列批量转移
固定某个人
如果位置
i+1, i+2, ..., min(len, i+k-1)也就是说,一个开头位置会覆盖后面一段结尾位置。我们不必枚举这段里的每一条边,只需要维护当前已经被覆盖的结尾区间:
[range_start, range_end]扫描到位置
- 如果
能作为开头,就把它贡献的结尾区间加入当前窗口; - 如果
落在 中,就说明它能作为某个合法连续子序列的结尾; - 把
作为本轮可达结尾值,并记录当前接龙人 。
注意窗口的左端不能随便丢掉。当前位置
完整转移过程
每一轮做一次状态转移:
- 上一轮状态保存在
、 中; - 本轮新状态写到
、 中; - 对每个人
扫描他的序列; - 用
判断某个值能不能作为本轮开头; - 用
判断当前位置能不能作为本轮结尾; - 本轮结束后,把
、 复制回 、 。
第一轮之前,认为结尾值是
所有询问的
状态公式
本题的“DP”本质是逐轮可达性状态机。设第
这里集合大小最多为
判断某个值
如果扫描人
实现时这个集合只保留两个不同的人;超过两个也不需要继续保存,因为判断“是否存在一个人不等于
样例 1 第一轮扫描
以样例 1 为例:
第 0 轮只有状态:
S_0[1] = {0}第一轮扫描过程如下:
| 接龙人 | 可作为开头的位置 | 覆盖的结尾位置 | 本轮产生的 |
|---|---|---|---|
| 人 1: |
pos=1,值为 1 | pos=2…3 | |
| 人 2: |
pos=1,值为 1 | pos=2…3 | |
| 人 3: |
pos=2,值为 1 | pos=3 |
所以第一轮结束后的代表状态是:
| 值 |
第一轮结束时保存的接龙人 |
|---|---|
| 2 | |
| 3 | |
| 5 | |
| 6 |
这张表展示了两个核心点:值
询问验证:
| 询问 |
第 |
输出 |
|---|---|---|
| 第 1 轮值 2 可达 ✓ | 1 | |
| 第 1 轮值 4 不可达 ✗ | 0 | |
| 第 2 轮值 4 可达 ✓ | 1 | |
| 第 3 轮值 4 不可达 ✗ | 0 | |
| 第 6 轮值 6 可达 ✓ | 1 | |
| 接龙序列长度 ≥ 2,值 1 不可达 ✗ | 0 | |
| 值 7 从未出现 ✗ | 0 |
代码
/**
* 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-07-05 21:47
* update_at: 2026-07-08 23:20
*/
// main.cpp:按人扫描序列做轮次 DP,用 reach_until 技巧批量标记可达值。
#include <bits/stdc++.h>
using namespace std;
const int MAXV = 200005; // 值的范围
const int MAXN = 100005; // 人数
const int MAXE = 200005; // 序列总长度
const int MAXR = 105; // 最大轮数(游戏状态在此之内必收敛)
// ---------- 所有人序列的平铺存储 ----------
int seq_vals[MAXE + 5]; // 所有人的序列值拼接
int seq_start[MAXN + 5]; // seq_start[p] = 第 p 个人的序列起始下标(1-indexed)
int seq_len[MAXN + 5]; // seq_len[p] = 第 p 个人序列的长度
// reachable[r][v]:第 r 轮结束时值 v 是否可达
bool reachable[MAXR][MAXV];
// ---------- 轮次状态 ----------
// last1[v]:值 v 在上一轮的第一个生产者
// last2[v]:值 v 在上一轮的第二个生产者(同一轮中另一个不同的人)
// -1 不可达,0 第 0 轮(初始状态,任何人都可用)
int last1[MAXV], last2[MAXV];
// 判断值 v 是否可以作为本轮 person 的开头:
// 上轮必须有人以 v 结尾,且本轮的人不能与上轮第一生产者相同
// 除非上轮还有另一个不同的人也以 v 结尾
bool can_start(int v, int person) {
if (last1[v] == -1) return false;
if (last1[v] == 0) return true;
if (last1[v] != person) return true;
return last2[v] != -1;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n, k, q;
cin >> n >> k >> q;
// 读入每个人的序列
int cur = 1;
for (int p = 1; p <= n; p++) {
int len;
cin >> len;
seq_start[p] = cur;
seq_len[p] = len;
for (int j = 1; j <= len; j++) {
cin >> seq_vals[cur];
cur++;
}
}
// 读入所有查询,确定最大轮数
int query_r[MAXN], query_c[MAXN];
int max_r = 0;
for (int i = 1; i <= q; i++) {
cin >> query_r[i] >> query_c[i];
if (query_r[i] > max_r) max_r = query_r[i];
}
// 初始化第 0 轮状态
memset(last1, -1, sizeof(last1));
memset(last2, -1, sizeof(last2));
memset(reachable, 0, sizeof(reachable));
last1[1] = 0; // 第 0 轮值 1 可达
// ---- 逐轮 DP ----
int next1[MAXV], next2[MAXV]; // 本轮新产生的值的第一、第二生产者
for (int round = 1; round <= max_r; round++) {
memset(next1, -1, sizeof(next1));
memset(next2, -1, sizeof(next2));
for (int p = 1; p <= n; p++) {
// 当前已经被某些开头位置覆盖的结尾区间 [range_start, range_end]。
// 一个开头 pos 只能覆盖 pos + 1 到 pos + k - 1,保证长度至少为 2。
int range_start = 1;
int range_end = 0;
int base = seq_start[p] - 1;
int len = seq_len[p];
for (int pos = 1; pos <= len; pos++) {
int v = seq_vals[base + pos];
// 步骤 1:判断 pos 能否做开头,若可以则拉宽结尾窗口。
if (can_start(v, p)) {
int new_end = min(len, pos + k - 1);
if (range_end < pos) {
range_start = pos + 1;
range_end = new_end;
} else {
range_end = max(range_end, new_end);
}
}
// 步骤 2:判断 pos 能否做结尾。
if (pos >= range_start && pos <= range_end) {
if (next1[v] == -1) {
next1[v] = p;
} else if (next2[v] == -1 && next1[v] != p) {
next2[v] = p;
}
reachable[round][v] = true;
}
}
}
// 本轮状态 → 下一轮的 "上一轮" 状态
memcpy(last1, next1, sizeof(last1));
memcpy(last2, next2, sizeof(last2));
}
// ---- 回答查询 ----
for (int i = 1; i <= q; i++) {
int r = query_r[i];
int c = query_c[i];
if (c < MAXV && reachable[r][c]) {
cout << 1 << '\n';
} else {
cout << 0 << '\n';
}
}
}
return 0;
}复杂度
设值域
按代码逐行拆解
读入:
初始化:
合计
每轮 DP(
| 代码行 | 操作 | 次数 |
|---|---|---|
| 本轮状态清零 | ||
| 第二轮生产者清零 | ||
| 扫描所有人序列 | 每位置的判断与更新 | |
| 轮末状态搬迁 | ||
| 同 |
每轮 =
部分( )约为 次连续 int 读写(≈ 3.2MB),靠 CPU SIMD + 缓存预取,常数极小; 部分的序列扫描是真正耗时处:每个位置调一次 ,再判断 是否落在 中, 个位置做 轮约为 次迭代。这部分的内存访问模式是按人随机跳,cache miss 是主要瓶颈。
单轮复杂度:
总 DP:
| 来源 | 总操作量 | 特点 |
|---|---|---|
| 连续内存,SIMD 优化 | ||
| 序列扫描(逐位判定) | 主耗时,cache miss 高 | |
| 一次性 |
初始化,不在循环内 |
答查询:
总时间
约
总结
本题更像一道建模和实现题,不是特别优雅的推导题。真正要学的地方有两个:一是把相邻不同人的限制压成“每个值只保存两个上一轮接龙人”;二是把连续子序列的所有结尾压成扫描窗口,避免显式枚举所有边。理解这两点后,剩下就是把状态数组和窗口边界写稳。