线段树知识详解

线段树(Segment Tree)

Part 1 线段树的引入

1.1 线段树的基本概念

学习线段树之前,先明确一下线段树的概念:

线段树是一种二叉搜索树 ,与区间树相似,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点。 使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为 O ( log ⁡ N ) \mathcal {O}(\log N) O(logN)

既然他是一种二叉搜索树,那么他肯定满足二叉树的性质:一个结点最多有两棵子树。在线段树上的每个结点都储存的是一个区间,而区间也可以看做一条线段,所以将其叫为线段树。搜索就是指你可以在这些区间上进行搜索或者修改,得到你想要的结果。

1.2 线段树的功能

线段树适用范围十分广泛,在最基本的修改功能上,常见的有求出某一个区间的最大值、所有数字的和等。对于线段树,每次更新或者查询的时间复杂度均为 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn)

1.3 线段树与其他 RMQ 算法的区别

之前学过的另一种 RMQ 算法是 ST 表,二者进行预处理的时间都是 O ( n log ⁡ n ) \mathcal {O}(n \log n) O(nlogn),ST 表的单次查询操作的时间复杂度是 O ( 1 ) \mathcal {O}(1) O(1),显然要优于线段树的 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn),但线段树支持动态的区间查询,也就是查询过程中,数字可能会发生修改。


Part 2 线段树的基本操作

2.1 建树

线段树的思想在于如何将区间上的信息用树的结点来进行表示。我们对于一个长度为 n n n 的区间进行线段树构建时,把区间 [ 1 , n ] [1,n] [1,n] 视作根节点。对于一个表示区间 [ l , r ] [l,r] [l,r] 的结点( l ≠ r l\ne r l=r),设 m i d = ⌊ l + r 2 ⌋ mid=\left \lfloor \frac{l+r}{2} \right \rfloor mid=2l+r,将 [ l , m i d ] [l,mid] [l,mid] [ m i d + 1 , r ] [mid+1,r] [mid+1,r] 分别作为这个结点的左子结点和右子结点。例如对于一个长度为 10 10 10 的序列,建好的线段树和各个结点所代表的区间如下图所示:

线段树示意

观察上图中的线段树,不难得到以下性质:

1.对于线段树上任意一个结点,它要么没有子结点,要么有两个子结点,不存在只有一个子结点的情况。

2.对于一个长度为 n n n 的序列,它所建立的线段树只有 ( 2 n − 1 ) (2n-1) (2n1) 个结点。

3.对于一个长度为 n n n 的序列,它所建立的线段树的深度为 log ⁡ n \log n logn

4.将根节点定义为 1 1 1 号结点,则对于编号为 i i i 的结点,它的左子结点编号为 2 i 2i 2i,右子结点编号为 2 i + 1 2i+1 2i+1

5.对于一个长度为 n n n 的序列,结点的最大编号不超过 4 n − 1 4n-1 4n1

树是递归定义的,因此可以使用递归的方式建立线段树:如果这个区间左端点等于右端点,说明是叶子结点,其数据的赋值为对应数列元素的值;否则将这个区间分为左右两段分别递归建立线段树,然后将左右两个区间的数据进行汇总处理。这篇文章统一以 洛谷 P3372 为例,来进行讲解:

//建立线段树 
void build(int idx, int l, int r) {
	if (l == r) {//左右端点相等,为叶子结点
		tree[idx] = num[l];//赋值为序列内元素的值
		return;
	}
	int mid = (l + r) / 2;//定义分界点
	build(idx * 2, l, mid), build(idx * 2 + 1, mid + 1, r);//递归建立左右子树
	tree[idx] = tree[idx * 2] + tree[idx * 2 + 1];//tree这里储存的是区间和,后面两项分别为左右子树
}

在上面的代码中,nowpoint 代表的是当前线段树结点的编号,segment_tree 是线段树所维护的信息,也就是区间的和。当我们到达叶子结点的时候,区间和很明显就是对应位置的数字,直接进行赋值即可;否则递归构造左右子树,并将左右子树的信息合并到父节点。对于每一次 build_the_tree,就新建了一个线段树节点,因此 build_the_tree 函数的时间复杂度为 O ( n ) \mathcal {O}(n) O(n)


2.2 单点查询

我们如何精确地定位到我们要找到结点呢?假设我们要定位的结点是 f i n d p o i n t findpoint findpoint,实际上就是要找到 [ f i n d p o i n t , f i n d p o i n t ] [findpoint,findpoint] [findpoint,findpoint] 这个区间。初始时,这个结点一定在根节点 [ 1 , n ] [1,n] [1,n] 的子树中。记区间的分界点 m i d d l e p o i n t = ⌊ 1 + n 2 ⌋ middlepoint=\left \lfloor \frac{1+n}{2} \right \rfloor middlepoint=21+n,则根节点的左子结点为 [ 1 , m i d d l e p o i n t ] [1,middlepoint] [1,middlepoint],右子结点为 [ m i d d l e p o i n t + 1 , n ] [middlepoint+1,n] [middlepoint+1,n],如果 f i n d p i n t ≤ m i d d l e p o i n t findpint \leq middlepoint findpintmiddlepoint,那么目标可定在左子树中,向左递归即可,否则目标在右子树中,需要向右递归:

//单点查询 
long long query_point(int idx, int l, int r, int point) {
	if (l == r) return tree[idx];//到达叶子结点了,返回
	int mid = (l + r) / 2;
	if (mid >= point) return query_point(idx * 2, l, mid, point);//查询位置在左子树中
	else return query_point(idx * 2 + 1, mid + 1, r, point);//查询位置在右子树中
}

2.3 单点修改

和单点查询类似,单点修改只有一点需要注意,就是返回时要一路更新父节点的信息,来保证线段树信息的正确性:

//单点修改 
void update_point(int idx, int l, int r, int point, long long x) {
	if (l == r) tree[idx] = x;//到达叶子结点了,返回
	else {
		int mid = (l + r) / 2;
		if (mid >= point) update_point(idx * 2, l, mid, point, x);//修改位置在左子树中
		else update_point(idx * 2 + 1, mid + 1, r, point, x);//修改位置在右子树中
		tree[idx] = tree[idx * 2] + tree[idx * 2 + 1];//更新父节点的信息
	}
}

以上两部分代码,每调用一次递归函数,都会在线段树上下移一层。由于线段树的树高是 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn),所以递归函数最坏只会调用 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn) 次。也就是说,线段树的单点操作的时间复杂度为 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn)


2.4 区间查询

只支持单点操作是远远不够的,因为这无法体现出线段树的优势所在。接下来要介绍的就是使用线段树快速维护区间信息。对于这道例题来说,就是求出给定区间 [ l , r ] [l,r] [l,r] 内所有数字的和。

我们考虑从根结点开始进行递归。如果当前结点所代表的区间 [ L , R ] [L,R] [L,R] 被要查询的区间 [ l , r ] [l,r] [l,r] 所包含,那么直接返回当前区间的区间和;如果两个区间没有交集的话,就直接返回 0 0 0;如果没有被包含且两个区间存在交集,则递归处理左右子结点即可。

什么意思呢?以本题为例,当我们查询区间 [ 2 , 5 ] [2,5] [2,5] 上所有数字的和的时候,相当于查询 [ 2 , 2 ] , [ 3 , 3 ] , [ 4 , 5 ] [2,2],[3,3],[4,5] [2,2],[3,3],[4,5] 这些区间的数据,然后再加以汇总:

//判断[L,r]是否被[l,r]完全包含,返回true为完全包含,返回false为不完全包含
bool in_range(int L, int R, int l, int r) { return (l <= L) && (R <= r); }
//判断是否有交集,返回false为有交集,返回true为没有交集
bool out_range(int L, int R, int l, int r) { return (L > r) || (R < l); }
//制作标记+修改 
void make_tag(int idx, int lenth, long long x) {
	tag[idx] += x, tree[idx] += lenth * x;//修改标记区间和
}
//下放标记 
void push_down(int idx, int L, int R) {
	int mid = (L + R) / 2;
	make_tag(idx * 2, mid - L + 1, tag[idx]);//给左子树加上延迟标记
	make_tag(idx * 2 + 1, R - mid, tag[idx]);//给右子树加上延迟标记
	tag[idx] = 0;//记得清空延迟标记,否则会重复修改
}
//区间查询 
long long find_range(int idx, int L, int R, int l, int r) {
	if (r < L || l > R) return 0;//判空 
	if (in_range(L, R, l, r)) return tree[idx];//判断[L,r]是否被[l,r]包含,如果完全包含则直接返回区间和
	else if (out_range(L, R, l, r)==0) {//判断是否有交集,有的话递归处理
		int mid = (L + R) / 2;
		push_down(idx, L, R);//查询的时候也需要下放延迟标记
		return find_range(idx * 2, L, mid, l, r) + find_range(idx * 2 + 1, mid + 1, R, l, r);
	} else return 0;//否则一定是完全没有交集
}

对于区间查询函数,每一层只会最多新建两个递归函数,而树高是 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn),所以总的复杂度仍为 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn)


2.5 区间修改

之前的部分都十分好理解,现在就要进入线段树最难理解也是最抽象的一段——区间修改。

在区间修改时,肯定不能使用暴力修改每个结点,那样的话会造成超时。所以我们引入一个新的定义——延迟标记(lazy_tag),用延迟标记记录一些区间修改的信息。当递归至一个完全被包含的区间时,在这个区间上打上一个延迟标记,记录修改操作,直接修改该节点的区间和并返回,不再向下递归。当新访问到一个节点时,将延迟标记下放后进行递归即可。

这样操作可以保证信息的正确性,并且保证时间复杂度仍为 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn)

//判断[L,r]是否被[l,r]完全包含,返回true为完全包含,返回false为不完全包含
bool in_range(int L, int R, int l, int r) { return (l <= L) && (R <= r); }
//判断是否有交集,返回false为有交集,返回true为没有交集
bool out_range(int L, int R, int l, int r) { return (L > r) || (R < l); }
//制作标记+修改 
void make_tag(int idx, int lenth, long long x) {
	tag[idx] += x, tree[idx] += lenth * x;//修改标记区间和
}
//下放标记 
void push_down(int idx, int L, int R) {
	int mid = (L + R) / 2;
	make_tag(idx * 2, mid - L + 1, tag[idx]);//给左子树加上延迟标记
	make_tag(idx * 2 + 1, R - mid, tag[idx]);//给右子树加上延迟标记
	tag[idx] = 0;//记得清空延迟标记,否则会重复修改
}
//区间修改 
void update_range(int idx, int L, int R, int l, int r, long long x) {
	if (r < L || l > R) return;//判空 
	if (in_range(L, R, l, r)) make_tag(idx, R - L + 1, x);//完全包含,直接打标记
	else if (out_range(L, R, l, r)==0) {
		int mid = (L + R) / 2;
		push_down(idx, L, R);//下放
		update_range(idx * 2, L, mid, l, r, x), update_range(idx * 2 + 1, mid + 1, R, l, r, x);
		tree[idx] = tree[idx * 2] + tree[idx * 2 + 1];
	}
}

以上就是线段树的最基本的用法,例题的主函数代码如下:

int main() {
	ios::sync_with_stdio(false);
	cin.tie(0),cout.tie(0);
	cin >> n >> m;
	for (int i = 1; i <= n; i++) cin >> num[i];
	build(1, 1, n);
	for (int t = 1; t <= m; t++) {
		cin >> op;
		if (op == 1) {
			cin >> x >> y >> k;
			update_range(1, 1, n, x, y, k);
		} else {
			cin >> x >> y;
			cout << find_range(1, 1, n, x, y) << endl;
		}
	}
	return 0;
}
  • 28
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值