题目
桌子上零散地放着若干个盒子,桌子的后方是一堵墙。如右图所示。现在从桌子的前方射来一束平行光,把盒子的影子投射到了墙上。问影子的总宽度是多少?
这道题目是一个经典的模型。可以把题目抽象地描述如下:x轴上有若干条线段,求线段覆盖的总长度。解决方法有很多:
解法
a.打暴力
设线段坐标范围为[min,max]。使用一个下标范围为[min,max-1]的一维数组,其中数组的元素i表示[i,i+1]的区间。数组元素初始化为0。对于每一条区间为[a,b]的线段,将[a,b]内所有对应的数组元素设为1。最后统计数组中1的个数即可。
此方法的时间复杂度决定于下标范围和修改次数,缺点很明显。当下标的范围很大,并且修改次数很多时,此方法效率太低。
b.离散化
这道题也可以用离散化的思想去做,这里就不详细展开。
C.线段树(进入正题)
在一类问题中,我们需要经常处理可以映射在一个坐标轴上的一些固定线段,例如说映射在X轴上的线段。由于线段是可以互相覆盖的,有时需要动态地取线段的并,例如取得并区间的总长度,或者并区间的个数等等。一个线段是对应于一个区间的,因此线段树也可以叫做区间树。 线段树是一种二叉树形结构,属于平衡树的一种。它将线段区间组织成树形的结构,并用每个节点来表示一条线段。树中的每一个结点表示了一个区间[a,b],每一个叶子节点表示了一个单位区间(如下图)。线段树的根节点表示了所要处理的最大线段区间,对于每一个非叶结点所表示的区间[a,b],其左子节点表示的区间为[a,(a+b)/2],右子节点表示的区间为[(a+b)/2,b]。 为了防止元素重复被取,我们一般用半开半闭的方式记录,这样表示的好处在于,处理的区间可能不是连续区间,可能是离散的点,这样就可以用[a,a+1)的节点来表示一个数组的元素,做到连续、离散的统一。
线段树中,线段即区间亦即树中结点。
可以看到,对于每条要处理的线段,我们能够类似“二分”的进入线段树中处理,使得时间复杂度在 O(logN)量级,这也是线段树之所以高效的原因。
- 线段树是一个平衡树,树的深度为log2(L-1)+1。
- 线段树把区间上的任意一条长度为L的线段都分成不超过2 log L条线段的并。
- 任两个结点要么是包含关系要么没有公共部分, 不可能部分重叠 。
- 给定一个叶子p, 从根到p路径上所有结点代表的区间都包含点p,且其他结点代表的区间都不包含点p。
- 结点数:假设根结点对应区间为[1, n],那么总结点个数不超过2n个。(由于线段树叶结点不超过n个,每个结点必然有2个孩子,所以非叶结点个数必定比叶结点个数少1。故总结点个数不超过2n个。)
在线段树的实际使用中,我们一般要给线段树的节点增加一些域。比如本题,我们可以增加一个cover域来记录本节点是否被完全覆盖。在这些域中保存了某种动态维护的信息,视不同情况而定,使得线段树具有极大的灵活性,可以适应不同的需求。
线段树的数据结构表示
完全二叉树 — 静态存储
Struct aty{
int begin,end; //区间
int cover; //覆盖情况域
}
链式结构 — 静态链/动态指针
//动态链
Struct aty{
int cover; //覆盖情况域
int begin,end; //左右边界
aty *lson , *rson; //左右子节点指针
}
//动态链
Struct aty{
int cover; //覆盖情况域
int begin,end; //左右边界
aty *lson , *rson; //左右子节点指针
}
另外我们也可以额外加上int mid来存储中点,储存这个点的好处是在每次在当前节点需要对线段分解的时候不需要再计算中点。
线段树的基本操作
建立
在对线段树进行操作前,我们需要建立起线段树的结构。我们使用结构体数组来保存线段树,这样对于非叶节点,若它在数组中编号为 num,则其左右子节点的编号为 2 * num,2 * num + 1。由于线段树是二分的树型结构,我们可以用递归的方法,从根节点开始来建立一棵线段树。代码如下所示:
对应不同的题目,我们会在线段树节点中添加另外的数据域,并随着线
段的插入或删除进行维护,要注意在建树过程中将这些数据域初始化。
线段树的插入操作
为了在插入线段后,我们能知道哪些节点上的线段被插入(覆盖)过。我们
需要在节点中添加一个 cover 域,来记录当前节点所表示的线段是否被覆盖。这
样,在建树过程中,我们需要把每个节点的 cover 域置 0;
//l,r分别为当前节点的左右端点,num为节点在数组中的编号
node seg_tree[3 * MAXN];
//由其性质可知,建树所需空间是所需处理最长长度的2倍多,最好开3倍大小的数组
void make(int l, int r, int num){
//l,r分别为当前节点的左右端点,num为节点在数组中的编号
seg_tree[num].left = l;
seg_tree[num].right = r;
seg_tree[num].mid = (l + r) / 2;
if (l + 1 != r){
//若不为叶子节点,则递归的建立左右子树
make(l, seg_tree[num].mid, 2 * num);
make(seg_tree[num].mid, r, 2 * num + 1);
}
}
对应具体题目实现时,我们要注意在建树过程中将额外数据域初始化。
插入
在线段的插入过程中,我们从根节点开始插入,同样采取递归的方法。如果插入的线段完全覆盖了当前节点所代表的线段,则将当前节点的 cover 域变为1并返回。否则,将线段递归进入当前节点的左右子节点进行插入。
void insert(int l, int r, int num){
//l,r分别为插入当前节点线段的左右端点,num为节点在数组中的编号
if (seg_tree[num].left == l && seg_tree[num].right == r){
//若插入的线段完全覆盖当前节点所表示的线段
seg_tree[num].cover = 1;
return;
}
if (r <= seg_tree[num].mid)
//当前节点的左子节点所代表的线段包含插入的线段
insert(l, r, 2 * num);
else if (l >= seg_tree[num].mid)
//当前节点的右子节点所代表的线段包含插入的线段
insert(l, r, 2 * num +1);
else {
//插入的线段跨越了当前节点所代表线段的中点
insert(l, seg_tree[num].mid, 2 * num);
insert(seg_tree[num].mid, r, 2 * num + 1);
}
}
要注意,这样插入线段时,有可能出现以下这种情况,即先插入线段[1,3),再插入线段[1,5)。那么,线段[1,3)的节点以及线段[1,5)的节点的 cover 值均为 1,统计时遇到这种情况,我们可以只统计更靠近根节点的节点。
图示为插入[4,7)的操作
删除
线段树的删除操作跟插入操作不大相同,因为一条线段只有被插入过才能被删除。比如插入一条线段[3,10),则只能删除线段[4,6),不能删除线段
[7,12)。当删除未插入的线段时,操作返回 false 值。
同样地,我们采用递归的方法对线段进行删除,如果当前节点所代表的线段未被覆盖,即 cover 值为 0,则递归进入此节点的左右子节点进行删除。而如果当前节点所代表的线段已被覆盖,即 cover 值为 1,则要考虑两种情况。
1 .删除的线段完全覆盖当前节点所代表的线段,则将当前节点的 cover 值置 0.由于我们在插入线段的时候会出现图 1.3 所示的情况, 所以我们应该递归的在当前节点的子树上所有节点删除线段。
2 .删除的线段未完全覆盖当前节点所代表的线段,比如当前节点代表的线段为[1,10),而要删除的线段为[4,7),则删除后剩下线段[1,4)和[7,10),我们采用的方法是,将当前节点的
cover 置 0,并将其左右子节点的 cover 置 1,然后递归的进入左右子节点进行删除。
bool del(int l, int r, int num){
if (seg_tree[num].left + 1 == seg_tree[num].right){
//删除到叶节点的情况
int f = seg_tree[num].f;
seg_tree[num].f = 0;
return f;
}
if (seg_tree[num].f == 1){
//当前节点不为叶节点且被覆盖
seg_tree[num].f = 0;
seg_tree[2 * num].f = 1;
seg_tree[2 * num + 1].f = 1;
}
if (r <= seg_tree[num].mid) return del(l, r, 2 * num);
else
if (l >= seg_tree[num].mid)
return del(l, r, 2 * num + 1);
else
return del(l, seg_tree[num].mid, 2 * num) &&
del(seg_tree[num].mid, r, 2 * num + 1);
}
统计
对于统计线段覆盖长度的问题,可以采用以下的思路来统计信息,即从根节点开始搜索整棵线段树,如果当前节点所代表的线段已被覆盖,则将统计长度加上当前线段长度。否则,递归进入当前节点的左右子节点进行统计。
int cal(int num){
if (seg_tree[num].f)
return seg_tree[num].right – seg_tree[num].left + 1;
if (seg_tree[num].left + 1 == seg_tree[num].right)
//当遍历到叶节点时返回
return 0;
return cal(2 * num) + cal(2 * num + 1);
cout<<endl;
- 本文部分参考资料:《浅谈线段树在信息学竞赛中的应用》 岳云涛