1. 题目背景与需求分析
P3870 [TJOI2009] 开关这道题目出自TJOI2009(天津省队选拔赛),是信息学奥赛中典型的区间操作问题。题目描述一个由n个灯组成的初始全灭的序列,需要处理两种操作:
- 区间开关灯(将[a,b]区间内所有灯的状态取反)
- 查询区间亮灯数量(统计[a,b]区间内亮着的灯的数量)
这类问题在信奥赛中具有代表性,主要考察选手对线段树或树状数组等高级数据结构的掌握程度。实际比赛中,n的范围通常会达到1e5量级,这就要求算法的时间复杂度必须控制在O(nlogn)级别。
提示:虽然题目表面是开关灯问题,但本质考察的是区间修改与区间查询的高效实现,这正是线段树最典型的应用场景。
2. 数据结构选型与原理
2.1 线段树解决方案
线段树之所以成为本题的首选方案,源于其三个关键特性:
- 区间操作效率:线段树的每个节点代表一个区间,区间修改和查询都可以在O(logn)时间内完成
- 懒标记机制:通过延迟传播修改操作,避免不必要的递归
- 空间优化:采用完全二叉树存储,空间复杂度为O(n)
对于开关灯问题,线段树节点需要维护:
cpp复制struct Node {
int l, r; // 节点代表的区间
int sum; // 区间内亮灯数量
bool tag; // 懒标记(是否被翻转)
} tr[N * 4];
2.2 树状数组的替代方案
虽然线段树是更直观的选择,但树状数组配合差分思想也能解决本题。需要维护两个树状数组:
- 记录区间被翻转次数的奇偶性
- 维护原始序列的前缀和
不过这种方法实现起来较为复杂,在比赛中除非对树状数组特别熟悉,否则建议优先选择线段树。
3. 核心代码实现详解
3.1 线段树建树
建树过程采用标准的递归分治策略:
cpp复制void build(int u, int l, int r) {
tr[u] = {l, r, 0, false};
if (l == r) return;
int mid = (l + r) >> 1;
build(u << 1, l, mid);
build(u << 1 | 1, mid + 1, r);
}
建树时间复杂度为O(n),空间消耗为4n级别。注意这里的tr[u]初始化时sum为0(所有灯初始为灭状态),tag为false(无待处理操作)。
3.2 懒标记处理
懒标记的向下传递是线段树的核心难点:
cpp复制void pushdown(int u) {
if (tr[u].tag) {
Node &left = tr[u << 1], &right = tr[u << 1 | 1];
left.tag ^= 1;
left.sum = (left.r - left.l + 1) - left.sum;
right.tag ^= 1;
right.sum = (right.r - right.l + 1) - right.sum;
tr[u].tag = false;
}
}
这里的关键点在于:
- 使用异或操作(
^=1)来切换标记状态 - 更新子节点sum值时,用区间长度减去原sum值(因为开关操作会反转所有灯的状态)
- 最后必须清空当前节点的标记
3.3 区间修改实现
区间修改采用标准的线段树递归模式:
cpp复制void modify(int u, int l, int r) {
if (tr[u].l >= l && tr[u].r <= r) {
tr[u].sum = (tr[u].r - tr[u].l + 1) - tr[u].sum;
tr[u].tag ^= 1;
return;
}
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
if (l <= mid) modify(u << 1, l, r);
if (r > mid) modify(u << 1 | 1, l, r);
pushup(u);
}
其中pushup操作负责合并子节点信息:
cpp复制void pushup(int u) {
tr[u].sum = tr[u << 1].sum + tr[u << 1 | 1].sum;
}
3.4 区间查询实现
查询操作与修改操作结构类似:
cpp复制int query(int u, int l, int r) {
if (tr[u].l >= l && tr[u].r <= r) return tr[u].sum;
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
int res = 0;
if (l <= mid) res += query(u << 1, l, r);
if (r > mid) res += query(u << 1 | 1, l, r);
return res;
}
4. 完整AC代码与注释
cpp复制#include <iostream>
using namespace std;
const int N = 1e5 + 10;
struct Node {
int l, r;
int sum;
bool tag;
} tr[N * 4];
int n, m;
void pushup(int u) {
tr[u].sum = tr[u << 1].sum + tr[u << 1 | 1].sum;
}
void pushdown(int u) {
if (tr[u].tag) {
Node &left = tr[u << 1], &right = tr[u << 1 | 1];
left.tag ^= 1;
left.sum = (left.r - left.l + 1) - left.sum;
right.tag ^= 1;
right.sum = (right.r - right.l + 1) - right.sum;
tr[u].tag = false;
}
}
void build(int u, int l, int r) {
tr[u] = {l, r, 0, false};
if (l == r) return;
int mid = (l + r) >> 1;
build(u << 1, l, mid);
build(u << 1 | 1, mid + 1, r);
}
void modify(int u, int l, int r) {
if (tr[u].l >= l && tr[u].r <= r) {
tr[u].sum = (tr[u].r - tr[u].l + 1) - tr[u].sum;
tr[u].tag ^= 1;
return;
}
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
if (l <= mid) modify(u << 1, l, r);
if (r > mid) modify(u << 1 | 1, l, r);
pushup(u);
}
int query(int u, int l, int r) {
if (tr[u].l >= l && tr[u].r <= r) return tr[u].sum;
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
int res = 0;
if (l <= mid) res += query(u << 1, l, r);
if (r > mid) res += query(u << 1 | 1, l, r);
return res;
}
int main() {
scanf("%d%d", &n, &m);
build(1, 1, n);
while (m--) {
int op, a, b;
scanf("%d%d%d", &op, &a, &b);
if (op == 0) {
modify(1, a, b);
} else {
printf("%d\n", query(1, a, b));
}
}
return 0;
}
5. 常见问题与调试技巧
5.1 段错误排查
线段树常见的段错误原因包括:
- 数组开太小(至少开4倍空间)
- 递归边界条件错误(如l>r的情况)
- 子节点访问越界(u<<1超过数组范围)
调试技巧:在递归函数入口处打印u,l,r参数,观察递归过程是否合理。
5.2 答案错误分析
当出现答案错误时,建议检查:
- 懒标记是否在适当的时候下传
- pushup操作是否正确合并了子节点信息
- 区间修改时的sum计算是否正确(特别是
(r-l+1)-sum这个操作)
5.3 性能优化建议
对于1e5量级的数据:
- 使用scanf/printf代替cin/cout(速度差异可达5倍)
- 避免在递归函数中定义局部变量(可能引起栈溢出)
- 确保线段树深度不超过20层(对应n=1e6)
6. 同类题目拓展
掌握本题后,可以尝试以下相似题目:
- P3372 【模板】线段树 1(区间加、区间和)
- P3373 【模板】线段树 2(区间加乘混合操作)
- P2574 XOR的艺术(类似开关问题,但使用位运算)
- P5057 [CQOI2006]简单题(一维开关问题的简化版)
这些题目都能巩固线段树的基本操作,特别是懒标记的处理技巧。建议按照从易到难的顺序逐个攻克,逐步提升对线段树的理解深度。
