线段树讲义
例题:在自然数,且所有的数不大于30000的范围内讨论一个问题:现在已知n条线段,把端点依次输入告诉你,然后有m个询问,每个询问输入一个点,要求这个点在多少条线段上出现过(0<m,n<30,000)。
最基本的解法当然就是读一个点,就把所有线段比一下,看看在不在线段中;
每次询问都要把n条线段查一次,那么m次询问,就要运算m*n次,复杂度就是O(m*n)
这道题m和n都是30000,那么计算量达到了10^9;而计算机1秒的计算量大约是10^8的数量级,所以这种方法无论怎么优化都是超时
那么有没有什么算法可以完成这个任务呢?——没错,就是线段树。
线段树是一种二叉搜索树,与区间树相似,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点。
对于线段树中的每一个非叶子节点[a,b],它的左儿子表示的区间为[a,(a+b)/2],右儿子表示的区间为[(a+b)/2+1,b]。因此线段树是平衡二叉树,最后的子节点数目为N,即整个线段区间的长度。
使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。而未优化的空间复杂度为2N,因此有时需要离散化让空间压缩。所以,用线段树可以在O(m*logn)的时间内完成这道题目,大概是10^5的数量级,可以承受。
那么线段树到底怎么用呢?
线段树是建立在线段的基础上,每个结点都代表了一条线段[a,b]。长度为1的线段称为元线段。非元线段都有两个子结点,左结点代表的线段为[a,(a + b) / 2],右结点代表的线段为[((a + b) / 2)+1,b]。
下图就是一棵长度范围为[1,5]的线段树。
下面以查找区间内的最小值为例,开始介绍线段树。
-------------------------------------------------------------------------------------------------
给出一个有n个元素的数组A[1..n],你的任务是设计一个数据结构,支持一下两种操作:
● update(x,v): 把A[x]修改成v;
● query(L,R): 计算min{A[L],A[L+1]…,A[R]}。
(数据范围当然不是暴力能够解决的。)
在查询时,我们从根节点开始自顶向下找到待查询线段的左边界和右边界,则“夹在中间”的所有叶子节点不重复不遗漏地覆盖了整个待查询线段。(如查询【2,5】)
[1 2 3 4 56 7 8]
/ \
[1 2 3 4] [5 6 7 8]
/ \ / \
[1 2] [3 4] [5 6] [7 8]
/ \ / \ / \ / \
1 2 3 4 5 6 7 8
从图中不难发现,树的左右各有一条“主线”,虽有分叉,但每层最多只有两个结点继续向下延伸(整棵树的左右子树各一个)。如上图所示[2,5]=[2]+[3,4]+[5]。在后文中,凡是遇到这样的区间分解,就把分解的区间叫做边界区间,因为它们对应与分解过程的递归边界。
如何更新线段树呢?update(x,v)显然需要更新[x]对应的结点,然后还要更新他的所有祖先结点。
下面给出这两个过程的代码(C版的,但pascal应该看得懂)o是当前结点编号,L,R是当前结点的左右端点。查询时,全局变量ql,qr代表查询区间的左右端点,修改时p,v分别代表修改点位置和修改后数值。
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
int
ql,qr; int
query(int o,int
L,int
R){ int
M=L+(R-L)/2 , ans=INF; if(ql<=L && R<=qr)
return minv[o];
if(ql<=M) ans=min(ans,query(o*2,L,M));
if(M<qr) ans=min(ans,query(o*2+1,M+1,R));
return
ans; } int
p,v; void
update(int
o,int L,int
R){ int
M=L+(R-L)/2; if(L==R) minv[o]=v;
else{
if(p<=M) update(o*2,L,M);
else update(o*2+1,M+1,R);
minv[o]=min(minv[o*2],minv[o*2+1]);
}
} |
现在大家对线段树应该有了初步的理解,但是仅仅有点的修改和最小值的查询这两个操作,能做的事还是很少的,我们再来看一下下面的两个操作:
-
add(L,R,v): 把A[L..R]的值全部增加v;
-
query(L,R): 计算子序列A[L..R]的元素和、最小值、最大值。
这里要维护sum,min,max3个值,而且对应的add是修改区间,而不是前面讲过的点修改。点修改只会影响log(n)个结点,但是区间修改在最坏情况下会修改所有结点,那样就和朴素算法没有区别了,应该怎么办呢?
前面讲区间查询时有一个结论:任意区间都能分解成不超过2h个不相交区间的并(h是最大层的编号)。像上个图中[2,5]被分解成[2,2],[3,4],[5,5],所以只要对这三个区间进行add操作就可以了。用sumv[o],minv[o],maxv[o]表示在o结点对应的区间中的和、最大值、最小值,代码如下:
|
1
2
3
4
5
6
7
8
9
10
11
|
//维护结点o,对应区间为[L,R]
void
maintain(int
o,int L,int
R){ int
lc=o*2,rc=o*2+1; if(R>L){
//维护到叶子结点什么的就挂了 sumv[o]=sumv[lc]+sumv[rc];
minv[o]=min(minv[lc],minv[rc]);
maxv[o]=max(maxv[lc],maxv[rc]);
}
minv[o]+=addv[o]; maxv[o]+=addv[o];
sumv[o]+=addv[o]*(R-L+1);
} |
如何在执行add时用上述代码维护线段树呢?只要在add递归返回之前维护对应的结点好了。y1,y2为add的左右区间,代码如下:
|
1
2
3
4
5
6
7
8
9
10
11
|
void
update(int o,int
L,int
R){ int
lc=o*2,rc=o*2+1; if(y1<=L && y2>=R){
addv[o]+=v;
}else{
int
M=L+(R-L)/2; if(y1<=M) update(lc,L,M);
if(y2>M) update(rc,M+1,R);
}
maintain(o,L,R);
} |
构造好了这棵线段树以后,应该如何查询呢?基本的思路和上面的查询是一样的,也是把查询区间递归分解成若干个不相交的子区间,把各个区间的结果加以合并,但是每个结点的结果不能直接用,因为祖先上的add操作是会影响下面所有结点的。我们用add代表当前区间所有祖先结点的add之和,代码如下:
|
1
2
3
4
5
6
7
8
9
10
11
12
|
int
_min,_max,_sum; //保存最小值、最大值、和的全局变量 void
query(int o,int
L,int
R,int add){
if(y1<=L && y2>=R){
_sum+=sumv[o]+add*(R-L+1);
_min=min(_min,minv[o]+add);
_max=max(_max,maxv[o]+add);
}else{
int
M=L+(R-L)/2; if(y1<=M) query(o*2,L,M,add+addv[o]);
if(Y2>M) query(o*2+1,M+1,R,add+addv[o]);
}
} |
怎么样,是不是比点修改复杂多了?还有更复杂的。
-----------------------------------------------------------------
快速序列操作II:给出一个有n个元素的数组A[1..n],你的任务是设计一个数据结构,支持以下两种操作:
-
set(L,R,v):把A[L..R]的值全部修改为v(v>=0);
-
query(L,R):计算自序列A[L..R]的元素和、最小值、最大值。
有了上面两题的基础,应该不难想到把set操作也进行类似的分解,但有一个新问题,
即add的操作时间顺序不改变结果,但set会。比如先执行add(1,4,1)再执行add(2,3,2)和交换顺序等价,但是先执行set(1,4,1)和先执行set(2,3,2)是不等价的,怎么办呢?
完整的解决方案有些复杂,但大体思路是清晰的:在执行新的set操作时,把原来
set“推”到下面的两棵子树中去,新的set遇到完全覆盖某个区间的情况时,把旧的set覆盖掉就可以了。
|
1
2
3
4
5
6
7
8
9
10
11
12
|
void
update(int o,int
L,int
R){ int
lc=o*2,rc=o*2+1; if(y1<=L && y2>=R){
setv[o]=v;
}else{
pushdown(o);
int
M=L+(R-L)/2; if(y1<=M) update(lc,L,M);else
maintain(lc,L,M); if(y2>M) update(rc,M+1,R);else
maintain(rc,M+1,R); }
maintain(o,L,R);
} |
这里和add不同的是,多了2个maintain语句。因为set时把标记下推了,因此子树也会受到影响,所以无论如何应该更新它。
接下来是pushdown()函数
|
1
2
3
4
5
6
7
|
void
pushdown(int
o){ int
lc=o*2,rc=o*2+1; if(setv[o]>=0){
//该结点有标记的话 setv[lc]=setv[rc]=setv[o];
setv[o]=-1;
//清除标记 }
} |
因为pushdown函数是这样实现的,所以会发生子结点的set与父结点的set冲突的情况(考虑一下先执行set(1,3,2),再执行set(1,8,1)会怎样)。所以我们在查询时以祖先的set值为准即可,代码如下:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
void
query(int o,int
L,int
R){ if(setv[o]>=0){
_sum+=setv[o]*(min(R,y2)-max(L,y1)+1);
_min=min(_min,setv[o]);
_max=max(_max,setv[o]);
}else
if(y1<=L && y2>=R){
_sum+=sumv[o];
_min= min(_min,minv[o]);
_max= max(_max,maxv[o]);
}else{
int
M=L+(R-L)/2; if(y1<=M) query(o*2,L,M);
if(y2> M) query(o*2,M+1,R);
}
} |
---------------------------------------------------------------
以上就是线段树的所有基本操作,但是仅仅掌握这些还是不能做什么题目的,像下面这题:
FastMatrix Operations,UVa 11992
操作 备注
1 x1 y1 x2 y2 v 子矩阵(x1,y1,x2,y2)所有元素增加v(v>0)
2 x1 y1 x2 y2 子矩阵(x1,y1,x2,y2)所有元素设为v(v>0)
3 x1 y1 x2 y2 查询(x1,y1,x2,y2)的元素和、最小值、最大值
子矩阵(x1,x2,y1,y2)是指满足x1≤x≤x2,y1≤y≤y2的所有元素(x,y)。
第一行为r,c,m(1<m<20,000),r为行数,c为列数,m是操作个数,接下来是r行c列的矩阵和m个操作。矩阵不超过20行,元素综合不超过10^6。
之前都只是单纯的add或set,而这题需要同时用到2个操作。直接把代码生搬硬套肯定是不行的,需要进行一些改进,使程序能支持所有的操作。并且这题是矩阵,不是上面一直讲的线段。这些问题都是值得考虑的。
注意:比赛的题目不可能出得这么简单,以上的所有的一切都只是基础,光有基础是远远不够的,每个好的题目都有特色的地方,要真正地掌握线段树,还是需要很多时间的努力的。
接下来推荐几道线段树的练习题:
● wikioi 1080-1082(线段树练习I II III):比较基础性的题目。
● wikioi 1217(借教室 noip2012):挺简单,但当时我不会线段树= =。
● tyvj 2042(线段问题):基础题。
更多的题目可以在各种OJ的分类中找到,这里以基础题为主,就不列出来了。
代码综合。
------------------------------------------------------------------------------------------------
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
|
int
ql,qr,v;//查询的左端点和右端点,修改参数 int
_min,_max,_sum; //保存最小值、最大值、和的全局变量 void
update(int o,int
L,int
R);//将[ql,qr]增加v void
query(int o,int
L,int
R,int add);//查询[ql,qr],保存相关值至全局变量
void
maintain(int
o,int L,int
R);//维护结点o,对应区间为[L,R]
void
set_update(int
o,int L,int
R);//设置[ql,qr]为v
void
pushdown(int
o);//下推set标记 void
set_query(int
o,int L,int
R);//set版的查询 void
update(int
o,int L,int
R){ int
lc=o*2,rc=o*2+1; if(y1<=L && y2>=R){
addv[o]+=v;
}else{
int
M=L+(R-L)/2; if(y1<=M) update(lc,L,M);
if(y2>M) update(rc,M+1,R);
}
maintain(o,L,R);
} void
query(int
o,int L,int
R,int
add){ if(y1<=L && y2>=R){
_sum+=sumv[o]+add*(R-L+1);
_min=min(_min,minv[o]+add);
_max=max(_max,maxv[o]+add);
}else{
int
M=L+(R-L)/2; if(y1<=M) query(o*2,L,M,add+addv[o]);
if(Y2>M) query(o*2+1,M+1,R,add+addv[o]);
}
} void
maintain(int
o,int L,int
R){ int
lc=o*2,rc=o*2+1; if(R>L){
//维护到叶子结点什么的就挂了 sumv[o]=sumv[lc]+sumv[rc];
minv[o]=min(minv[lc],minv[rc]);
maxv[o]=max(maxv[lc],maxv[rc]);
}
minv[o]+=addv[o]; maxv[o]+=addv[o];
sumv[o]+=addv[o]*(R-L+1);
} void
set_update(int
o,int
L,int R){
int
lc=o*2,rc=o*2+1; if(y1<=L && y2>=R){
setv[o]=v;
}else{
pushdown(o);
int
M=L+(R-L)/2; if(y1<=M) set_update(lc,L,M);else
maintain(lc,L,M); if(y2>M) set_update(rc,M+1,R);else
maintain(rc,M+1,R); }
maintain(o,L,R);
} void
pushdown(int
o){ int
lc=o*2,rc=o*2+1; if(setv[o]>=0){
//该结点有标记的话 setv[lc]=setv[rc]=setv[o];
setv[o]=-1;
//清除标记 }
} void
set_query(int
o,int
L,int R){
if(setv[o]>=0){
_sum+=setv[o]*(min(R,y2)-max(L,y1)+1);
_min=min(_min,setv[o]);
_max=max(_max,setv[o]);
}else
if(y1<=L && y2>=R){
_sum+=sumv[o];
_min= min(_min,minv[o]);
_max= max(_max,maxv[o]);
}else{
int
M=L+(R-L)/2; if(y1<=M) set_query(o*2,L,M);
if(y2> M) set_query(o*2,M+1,R);
}
} |
2381

被折叠的 条评论
为什么被折叠?



