1. 项目背景与题目解析
最近在准备信息学奥赛的同学肯定对P5627和P5751这两道经典题目不陌生。作为NOI1999年的老题,它们至今仍是检验选手对01串处理能力的标杆。这两道题看似简单,实则暗藏玄机,考察了选手对字符串处理、位运算和算法优化的综合掌握程度。
我当年第一次做这两题时,就被它们精巧的设计所折服。P5627主要考察基础字符串操作,而P5751则需要更深入的位运算技巧。下面我就结合自己刷题的经验,详细讲解这两道题的解题思路和C++实现方法。
2. 题目P5627详解
2.1 题目要求分析
P5627题目大意是给定一个01字符串,要求进行一系列操作后输出最终结果。具体操作包括:
- 翻转指定区间的字符(0变1,1变0)
- 查询指定区间内1的个数
- 将整个字符串循环左移k位
2.2 数据结构选择
对于这类区间操作问题,线段树是最合适的数据结构。它能以O(logn)的时间复杂度完成区间更新和查询。考虑到题目中的翻转操作,我们需要在线段树节点中维护:
- 区间内1的个数
- 翻转标记(lazy tag)
cpp复制struct Node {
int l, r;
int cnt; // 1的个数
bool flip; // 翻转标记
} tree[MAXN * 4];
2.3 核心算法实现
2.3.1 建树
首先我们需要根据初始字符串建立线段树:
cpp复制void build(int p, int l, int r, const string &s) {
tree[p].l = l;
tree[p].r = r;
tree[p].flip = false;
if (l == r) {
tree[p].cnt = (s[l] == '1');
return;
}
int mid = (l + r) / 2;
build(p*2, l, mid, s);
build(p*2+1, mid+1, r, s);
tree[p].cnt = tree[p*2].cnt + tree[p*2+1].cnt;
}
2.3.2 区间翻转
翻转操作需要处理懒标记:
cpp复制void push_down(int p) {
if (tree[p].flip) {
tree[p*2].cnt = (tree[p*2].r - tree[p*2].l + 1) - tree[p*2].cnt;
tree[p*2+1].cnt = (tree[p*2+1].r - tree[p*2+1].l + 1) - tree[p*2+1].cnt;
tree[p*2].flip = !tree[p*2].flip;
tree[p*2+1].flip = !tree[p*2+1].flip;
tree[p].flip = false;
}
}
void flip_range(int p, int l, int r) {
if (tree[p].l >= l && tree[p].r <= r) {
tree[p].cnt = (tree[p].r - tree[p].l + 1) - tree[p].cnt;
tree[p].flip = !tree[p].flip;
return;
}
push_down(p);
int mid = (tree[p].l + tree[p].r) / 2;
if (l <= mid) flip_range(p*2, l, r);
if (r > mid) flip_range(p*2+1, l, r);
tree[p].cnt = tree[p*2].cnt + tree[p*2+1].cnt;
}
2.3.3 循环左移处理
循环左移k位可以通过三次翻转实现:
- 翻转前k个字符
- 翻转剩余字符
- 翻转整个字符串
cpp复制void rotate_left(int k) {
k %= n; // 处理k大于字符串长度的情况
if (k == 0) return;
flip_range(1, 0, k-1);
flip_range(1, k, n-1);
flip_range(1, 0, n-1);
}
3. 题目P5751详解
3.1 题目要求分析
P5751题目要求我们找到一个01串,满足:
- 长度为n
- 不包含指定的禁止子串
- 字典序最小
这道题的关键在于如何高效地生成和检查01串,同时保证字典序最小。
3.2 算法选择
这道题可以使用回溯算法结合剪枝策略来解决。为了优化效率,我们可以:
- 按字典序从小到大生成字符串
- 一旦发现当前前缀已经包含禁止子串,立即回溯
- 使用KMP算法快速检查禁止子串
3.3 核心算法实现
3.3.1 KMP预处理
首先预处理禁止子串的next数组:
cpp复制void getNext(const string &pattern, vector<int> &next) {
next.resize(pattern.size());
next[0] = -1;
int i = 0, j = -1;
while (i < pattern.size() - 1) {
if (j == -1 || pattern[i] == pattern[j]) {
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
}
3.3.2 回溯搜索
使用回溯法生成字符串:
cpp复制bool backtrack(string ¤t, int pos, const string &forbidden, const vector<int> &next) {
if (pos == current.size()) {
return true;
}
// 尝试0
current[pos] = '0';
if (!containsForbidden(current, pos, forbidden, next)) {
if (backtrack(current, pos+1, forbidden, next)) {
return true;
}
}
// 尝试1
current[pos] = '1';
if (!containsForbidden(current, pos, forbidden, next)) {
if (backtrack(current, pos+1, forbidden, next)) {
return true;
}
}
return false;
}
3.3.3 禁止子串检查
使用KMP算法检查当前字符串是否包含禁止子串:
cpp复制bool containsForbidden(const string &s, int end, const string &forbidden, const vector<int> &next) {
int i = 0, j = 0;
while (i <= end) {
if (j == -1 || s[i] == forbidden[j]) {
i++;
j++;
if (j == forbidden.size()) {
return true;
}
} else {
j = next[j];
}
}
return false;
}
4. 优化技巧与注意事项
4.1 线段树优化
在实际编码中,线段树的实现有几个容易出错的地方:
- 区间边界处理要小心,特别是当区间长度为1时
- 懒标记的下传时机要正确,在访问子节点前必须下传
- 建树时叶子节点的处理要单独考虑
提示:在调试线段树时,可以添加一个打印函数,输出整个线段树的结构,方便检查错误。
4.2 回溯算法优化
对于P5751,回溯算法的效率至关重要:
- 尽早剪枝,一旦发现当前前缀不合法就立即回溯
- 使用KMP算法而不是简单的字符串匹配,可以显著提高效率
- 对于多个禁止子串的情况,可以考虑使用AC自动机
4.3 常见错误
- 循环左移k位时,忘记处理k大于字符串长度的情况
- 线段树的懒标记没有正确下传,导致查询结果错误
- 回溯算法中没有正确处理字典序,导致找到的不是最小解
- KMP算法的next数组计算错误,导致匹配失败
5. 完整代码示例
5.1 P5627完整代码
cpp复制#include <iostream>
#include <string>
using namespace std;
const int MAXN = 1e5 + 5;
struct Node {
int l, r;
int cnt;
bool flip;
} tree[MAXN * 4];
int n;
string s;
void build(int p, int l, int r) {
tree[p].l = l;
tree[p].r = r;
tree[p].flip = false;
if (l == r) {
tree[p].cnt = (s[l] == '1');
return;
}
int mid = (l + r) / 2;
build(p*2, l, mid);
build(p*2+1, mid+1, r);
tree[p].cnt = tree[p*2].cnt + tree[p*2+1].cnt;
}
void push_down(int p) {
if (tree[p].flip) {
tree[p*2].cnt = (tree[p*2].r - tree[p*2].l + 1) - tree[p*2].cnt;
tree[p*2+1].cnt = (tree[p*2+1].r - tree[p*2+1].l + 1) - tree[p*2+1].cnt;
tree[p*2].flip = !tree[p*2].flip;
tree[p*2+1].flip = !tree[p*2+1].flip;
tree[p].flip = false;
}
}
void flip_range(int p, int l, int r) {
if (tree[p].l >= l && tree[p].r <= r) {
tree[p].cnt = (tree[p].r - tree[p].l + 1) - tree[p].cnt;
tree[p].flip = !tree[p].flip;
return;
}
push_down(p);
int mid = (tree[p].l + tree[p].r) / 2;
if (l <= mid) flip_range(p*2, l, r);
if (r > mid) flip_range(p*2+1, l, r);
tree[p].cnt = tree[p*2].cnt + tree[p*2+1].cnt;
}
int query(int p, int l, int r) {
if (tree[p].l >= l && tree[p].r <= r) {
return tree[p].cnt;
}
push_down(p);
int mid = (tree[p].l + tree[p].r) / 2;
int res = 0;
if (l <= mid) res += query(p*2, l, r);
if (r > mid) res += query(p*2+1, l, r);
return res;
}
void rotate_left(int k) {
k %= n;
if (k == 0) return;
flip_range(1, 0, k-1);
flip_range(1, k, n-1);
flip_range(1, 0, n-1);
}
int main() {
int m;
cin >> n >> m;
cin >> s;
build(1, 0, n-1);
while (m--) {
int op;
cin >> op;
if (op == 1) {
int l, r;
cin >> l >> r;
flip_range(1, l-1, r-1);
} else if (op == 2) {
int l, r;
cin >> l >> r;
cout << query(1, l-1, r-1) << endl;
} else if (op == 3) {
int k;
cin >> k;
rotate_left(k);
}
}
return 0;
}
5.2 P5751完整代码
cpp复制#include <iostream>
#include <vector>
#include <string>
using namespace std;
void getNext(const string &pattern, vector<int> &next) {
next.resize(pattern.size());
next[0] = -1;
int i = 0, j = -1;
while (i < pattern.size() - 1) {
if (j == -1 || pattern[i] == pattern[j]) {
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
}
bool containsForbidden(const string &s, int end, const string &forbidden, const vector<int> &next) {
int i = 0, j = 0;
while (i <= end) {
if (j == -1 || s[i] == forbidden[j]) {
i++;
j++;
if (j == forbidden.size()) {
return true;
}
} else {
j = next[j];
}
}
return false;
}
bool backtrack(string ¤t, int pos, const string &forbidden, const vector<int> &next) {
if (pos == current.size()) {
return true;
}
current[pos] = '0';
if (!containsForbidden(current, pos, forbidden, next)) {
if (backtrack(current, pos+1, forbidden, next)) {
return true;
}
}
current[pos] = '1';
if (!containsForbidden(current, pos, forbidden, next)) {
if (backtrack(current, pos+1, forbidden, next)) {
return true;
}
}
return false;
}
int main() {
int n;
string forbidden;
cin >> n >> forbidden;
vector<int> next;
getNext(forbidden, next);
string result(n, '0');
if (backtrack(result, 0, forbidden, next)) {
cout << result << endl;
} else {
cout << "No solution" << endl;
}
return 0;
}
6. 测试用例与验证
6.1 P5627测试用例
输入样例1:
code复制5 5
10110
1 2 4
2 1 5
3 2
2 1 5
1 1 5
预期输出:
code复制2
3
解释:
- 初始字符串: 10110
- 翻转2-4位后: 11000
- 查询1-5位有2个1
- 循环左移2位: 00011
- 查询1-5位有3个1
- 翻转1-5位: 11100 (不输出)
6.2 P5751测试用例
输入样例1:
code复制5 101
预期输出:
code复制00000
输入样例2:
code复制4 11
预期输出:
code复制0001
7. 性能分析与优化
7.1 P5627性能分析
线段树实现的时间复杂度:
- 建树: O(n)
- 每次操作: O(logn)
- 总体复杂度: O(mlogn),其中m是操作次数
空间复杂度: O(n)
优化空间:
- 使用位压缩技术,将多个字符压缩到一个整型变量中存储
- 对于大规模数据,可以考虑使用块状链表等替代数据结构
7.2 P5751性能分析
回溯算法的时间复杂度最坏情况下是O(2^n),但实际运行中由于剪枝的存在,效率会高很多。
优化建议:
- 对于多个禁止子串的情况,改用AC自动机
- 预处理所有可能的前缀,建立状态转移图
- 使用动态规划方法,记录每个位置的可能状态
8. 扩展思考
这两道题目虽然看似简单,但涉及到的算法思想和优化技巧非常丰富。在实际编程竞赛中,类似的题目变形经常出现,比如:
- 支持更多种类的区间操作(如区间置0、区间置1)
- 禁止子串有多个,或者使用正则表达式描述禁止模式
- 01串变为更一般的字符集
- 在线处理,即字符串可以动态增长
对于想要深入学习的同学,我建议可以尝试实现这些扩展版本,这对提升编程能力大有裨益。我在实际刷题中发现,真正理解一道经典题目背后的思想,比盲目刷大量简单题目要有用得多。