字符串出现转区间覆盖 + 每步取能延伸最远的区间 + 记录选择回溯方案
OJ: codeforces
题目 ID: 1714D
难度:1600
标签:贪心动态规划字符串
创建: 2026-11-09 14:51
更新: 2026-11-10 10:40
形式化题目
给定一个字符串
一次操作:选一个字符串编号
目标是用最少的操作次数让
解法总览
题目规模很小(
把每个
| 解法 | 核心观察 | 复杂度 | 适用场景 | 对应代码 |
|---|---|---|---|---|
| 解法一:贪心 | 从左往右推进,每步在左端点已被覆盖的区间里取能延伸最远的右端点 | 只求最少步数、顺带记录方案,实现最短 | main.cpp、main.py |
|
| 解法二:DP | 以“已涂红前缀长度”为状态,逐层推进能到达的最远前缀 | 需要最短路式建模、方案回溯更稳妥 | main2.cpp、main2.py |
两者答案必然相同。每个区间代价都是 main.cpp,它对本题最直接、代码最少。
解法一:贪心(最少区间覆盖整段)
从待覆盖光标出发,每步选能延伸最远的半开区间。
把每个
思路
落成区间覆盖之后,问题就是选最少的区间盖住半开区间
相比全闭区间
- 子串从下标
开始、长为 ,其半开区间右端点自然是 ,与字符串切片直觉一致,不需要 ; - 我们用一个光标
表示当前进度:前缀 已经全红,下一个必须被覆盖的字符下标正是 ; - 推进时,选定右端点为
的区间后,覆盖范围直接扩展到 ,下一步光标直接是 ,不需要 。
任何可行解都必须有一个区间覆盖当前光标
算法流程很直接:
- 预处理找出所有子串匹配,得到半开区间集合
,其中 ; - 初始化
; - 当
时,在所有满足 的区间里寻找 最大的区间: - 若最大的
,说明当前位置无法被任何可用区间越过(出现断层),输出 ; - 否则记录方案
,并将光标更新为 。
- 若最大的
这里的候选条件是“
代码
同一算法的 C++ 与 Python 实现,逻辑一致。
C++ 版本:
/**
* 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-11-09 17:35
* update_at: 2026-11-09 22:39
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 105;
// 区间结构体:记录字符串匹配出的一个可用线段,采用半开区间 [l, r)
struct Segment {
int l; // 区间左端点(0-indexed)
int r; // 区间右开端点(0-indexed,覆盖 [l, r))
int id; // 对应第几个字符串(1-indexed)
int pos; // 在原串 t 中的起始位置(1-indexed)
};
// 操作方案记录
struct Operation {
int id;
int pos;
};
int n;
string t;
string s[MAXN];
vector<Segment> segs; // 存储所有匹配出来的区间
vector<Operation> ans; // 存储最终选择的操作步骤
void read_input() {
cin >> t >> n;
for (int i = 1; i <= n; i++) {
cin >> s[i];
}
}
// 预处理:利用 string::find 找出所有字符串在文本 t 中的出现区间 [left, right)
void build_segments() {
segs.clear();
for (int i = 1; i <= n; i++) {
int len_s = s[i].size();
size_t p = t.find(s[i], 0);
while (p != string::npos) {
int left = p;
int right = left + len_s; // 半开区间右端点:无需减 1
segs.push_back({left, right, i, left + 1});
p = t.find(s[i], p + 1); // 允许重叠,从 p + 1 继续寻找下一个出现位置
}
}
}
void solve() {
build_segments();
ans.clear();
int len_t = t.size();
int cur = 0; // 当前待覆盖的起始位置,即 [0, cur) 已全部被染红
// 每次在所有覆盖 cur(l <= cur < r)的区间里,选择右端点 r 最大的那一个
while (cur < len_t) {
int max_r = -1;
Segment best_seg;
for (const auto &seg : segs) {
if (seg.l <= cur && seg.r > max_r) {
max_r = seg.r;
best_seg = seg;
}
}
// 如果找不到任何能延伸超过 cur 的区间,说明出现断层,无解
if (max_r <= cur) {
cout << -1 << "\n";
return;
}
ans.push_back({best_seg.id, best_seg.pos});
cur = max_r; // 直接推进到新的右边界,无需加 1
}
cout << ans.size() << "\n";
for (const auto &op : ans) {
cout << op.id << " " << op.pos << "\n";
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int q;
if (cin >> q) {
while (q--) {
read_input();
solve();
}
}
return 0;
}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-11-09 17:35
# update_at: 2026-11-10 10:25
import sys
from collections.abc import Iterator
# 匹配区间:(左端点 l, 右端点 r, 单词序号 w, 1 起起始位置 p),半开区间 [l, r)
type Match = tuple[int, int, int, int]
type Plan = list[tuple[int, int]]
def occurrences(t: str, s: str) -> Iterator[int]:
"""产出子串 s 在 t 中的所有起始位置(允许重叠)。"""
p = t.find(s)
while p != -1:
yield p
p = t.find(s, p + 1)
def min_color(t: str, words: list[str]) -> Plan | None:
"""计算将 t 涂红的最少区间方案;若无法涂满返回 None。"""
matches: list[Match] = [
(p, p + len(s), w, p + 1)
for w, s in enumerate(words, 1)
for p in occurrences(t, s)
]
cur, plan = 0, []
while cur < len(t):
valid = [(r, w, p) for l, r, w, p in matches if l <= cur < r]
if not valid:
return None
r, w, p = max(valid) # 贪心选择右开端点 r 最大的区间
plan.append((w, p))
cur = r
return plan
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
out: list[str] = []
q = int(next(data))
for _ in range(q):
t = next(data).decode()
n = int(next(data))
words = [next(data).decode() for _ in range(n)]
plan = min_color(t, words)
if plan is None:
out.append("-1")
else:
out.append(str(len(plan)))
out.extend(f"{w} {p}" for w, p in plan)
print("\n".join(out))
if __name__ == "__main__":
solve()复杂度
区间总数
解法二:DP(覆盖前缀的最少步数)
以“已涂红前缀长度”为状态,采用推模式 DP 逐层向后更新。
令
思路
贪心每步只保留“最远右端点”一个状态,这是把问题压缩后的结果。与其一开始就相信这个压缩,不如保留所有可能的前缀终点,用 DP 把方案空间完整扫一遍,答案自然浮现,方案也好回溯。
状态定义
涂红过程是单调向右扩张的:一旦前缀
定义
转移方程:推模式 DP
本题采用推模式 DP(Push DP,即“刷表法”):不是站在后继点去拉取前驱,而是用当前已确定的前缀点
当遍历到当前位置
图示清晰展现了这一推模式 DP 的核心思想:
- 当前点
作为发射源:已知 的最优代价为 ; - 区间
作为跳板:只要 ,区间就能无缝跨过当前必须染红的位置 ; - 后继点
被主动刷新:用 的代价去松弛 ,并记录转移前驱。
把 pre[] 记下每个后继状态是由哪个前驱
用样例 ba 的 aba 的
| 当前前缀点 |
当前 |
选用区间 |
推进到的后继点 |
更新结果 |
|---|---|---|---|---|
最终 pre[] 回溯得到的三步正好对应上表的三行。
若最终
代码
同一算法的 C++ 与 Python 实现,逻辑一致。
C++ 版本:
/**
* 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-11-09 17:35
* update_at: 2026-11-09 22:39
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 105;
const int INF = 1e9;
// 区间结构体:记录字符串匹配出的一个可用线段
struct Segment {
int l; // 区间左端点(0-indexed)
int r; // 区间右端点(0-indexed)
int id; // 对应第几个字符串(1-indexed)
int pos; // 在原串 t 中的起始位置(1-indexed)
};
// 状态前驱记录:用于回溯最优方案
struct Step {
int prev_idx; // 上一个状态位置
int id; // 本次使用的字符串编号
int pos; // 本次使用的起始位置
};
int n;
string t;
string s[MAXN];
int dp[MAXN]; // dp[i] 表示完全覆盖前缀 t[0..i-1] 所需的最少操作次数
Step pre[MAXN]; // pre[i] 记录 dp[i] 是从哪个状态转移而来的
vector<Segment> segs; // 存储所有匹配出来的区间
void read_input() {
cin >> t >> n;
for (int i = 1; i <= n; i++) {
cin >> s[i];
}
}
// 预处理:利用 string::find 找出所有字符串在文本 t 中的所有出现区间(允许重叠)
void build_segments() {
segs.clear();
for (int i = 1; i <= n; i++) {
int len_s = s[i].size();
size_t p = t.find(s[i], 0);
while (p != string::npos) {
int left = p;
int right = left + len_s - 1;
segs.push_back({left, right, i, left + 1});
p = t.find(s[i], p + 1); // 允许重叠,从 p + 1 继续寻找下一个出现位置
}
}
}
void solve() {
build_segments();
int len_t = t.size();
for (int i = 0; i <= len_t; i++) {
dp[i] = INF;
}
dp[0] = 0;
// dp[i]:前缀 [0..i-1] 已经全部覆盖
for (int i = 0; i < len_t; i++) {
if (dp[i] == INF) continue;
// 枚举所有可以接在当前前缀后面的区间(要求区间的左端点落在已覆盖区域内,即 seg.l <= i)
for (const auto &seg : segs) {
if (seg.l <= i) {
int next_idx = max(i, seg.r + 1); // 新覆盖到达的前缀边界
if (dp[next_idx] > dp[i] + 1) {
dp[next_idx] = dp[i] + 1;
pre[next_idx] = {i, seg.id, seg.pos};
}
}
}
}
// 无法覆盖整串
if (dp[len_t] == INF) {
cout << -1 << "\n";
return;
}
// 从终点向前回溯提取方案
vector<pair<int, int>> ans;
int cur = len_t;
while (cur > 0) {
ans.push_back({pre[cur].id, pre[cur].pos});
cur = pre[cur].prev_idx;
}
// 倒序恢复成从前到后的操作序列
reverse(ans.begin(), ans.end());
cout << ans.size() << "\n";
for (const auto &op : ans) {
cout << op.first << " " << op.second << "\n";
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int q;
if (cin >> q) {
while (q--) {
read_input();
solve();
}
}
return 0;
}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-11-09 17:35
# update_at: 2026-11-10 10:30
import sys
from collections.abc import Iterator
INF = 10**9 # 表示该前缀目前不可达
# 匹配区间:(左端点 l, 右端点 r, 单词序号 w, 1 起起始位置 p),半开区间 [l, r)
type Match = tuple[int, int, int, int]
# 转移前驱:(上一前缀长度 prev_i, 单词序号 w, 1 起起始位置 p)
type Step = tuple[int, int, int]
type Plan = list[tuple[int, int]]
def occurrences(t: str, s: str) -> Iterator[int]:
"""产出子串 s 在 t 中的所有起始位置(允许重叠)。"""
p = t.find(s)
while p != -1:
yield p
p = t.find(s, p + 1)
def min_color_dp(t: str, words: list[str]) -> Plan | None:
"""用 DP 计算将 t 涂红的最少区间方案;若无法涂满返回 None。"""
matches: list[Match] = [
(p, p + len(s), w, p + 1)
for w, s in enumerate(words, 1)
for p in occurrences(t, s)
]
n = len(t)
# dp[i] 表示染红前缀 [0, i) 所需的最少操作步数
dp = [0] + [INF] * n
pre: list[Step | None] = [None] * (n + 1)
for i in range(n):
if dp[i] == INF:
continue
cost = dp[i] + 1
for l, r, w, p in matches:
# 区间覆盖当前末端点 i(l <= i < r),可将前缀延伸到 r
if l <= i < r and cost < dp[r]:
dp[r] = cost
pre[r] = (i, w, p)
if dp[n] == INF:
return None
# 从终点 n 沿前驱回溯最优路径
plan: Plan = []
cur = n
while cur > 0:
assert pre[cur] is not None
cur, w, p = pre[cur]
plan.append((w, p))
return plan[::-1]
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
out: list[str] = []
q = int(next(data))
for _ in range(q):
t = next(data).decode()
n = int(next(data))
words = [next(data).decode() for _ in range(n)]
plan = min_color_dp(t, words)
if plan is None:
out.append("-1")
else:
out.append(str(len(plan)))
out.extend(f"{w} {p}" for w, p in plan)
print("\n".join(out))
if __name__ == "__main__":
solve()复杂度
状态数
复杂度对比
| 解法 | 时间 | 空间 | 方案回溯 |
|---|---|---|---|
| 贪心 | 边推进边记录 | ||
| DP | 前驱数组回溯 |
两种解法复杂度相同,因为本题规模本就很小。选贪心是因为它最短、最贴合“区间覆盖”的直觉;选 DP 是因为状态定义清晰、方案回溯不易出错。若把数据放大到
总结
这题的转折是把“涂红字符”翻译成“选区间覆盖整段”:每个
在此基础上两条路都成立:
- 贪心:当前位置必须被某个区间跨过,选能延伸最远的那个不会让后续变差,于是每步取最远右端点;
- DP:把“已涂红前缀长度”当状态,逐层推进到能到达的最远前缀,方案回溯更直观。
两个容易写错的地方:
- 采用半开区间
思考与实现,状态从头到尾对齐待覆盖光标 ,免除 的差一边界; - 允许区间重叠,所以候选区间的左端点用
(DP 里是 ),而不是要求恰好接缝; - 找不到任何能越过当前位置的区间时(
)输出 ,别漏判无解。