线段树详解、常见应用与拓展

线段树详解、常见应用与拓展

写在前面的话,我也只是新手,这篇博客仅仅为本人个人整理与复习所用,能力有限,错误是在所难免的!因此如发现错误还请指出一同学习!

索引

一、定义

二、基本结构

三、常见应用

  1. 求区间和

  2. 求区间最大元素

四、拓展

  1. 离散化

  2. 多lazy标记

  3. dfs序

  4. 空间优化

  5. 区间合并

  6. 扫描线

  7. 主席树

  8. RMQ

  9. zkw

一、定义

线段树是一种二叉搜索树,与区间树相似,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点。

对于线段树中的每一个非叶子节点[a,b],它的左儿子表示的区间为[a,(a+b)/2],右儿子表示的区间为[(a+b)/2+1,b]。因此线段树是平衡二叉树,最后的子节点数目为N,即整个线段区间的长度。

使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。而未优化的空间复杂度为2N,在实际的应用中往往要开到4N来避免越界, 因此有时需要离散化让空间压缩。

上方叙述引用自百度百科——线段树

https://baike.baidu.com/item/线段树/10983506?fr=aladdin

注意:有的时候划分左右子树区间的时候不一定是 [left, mid] 和 [mid+1, right],也有可能是 [left, mid] 和 [mid, right],后者可用于求平面矩阵交的面积或者立体交的体积这样的经典线段树问题(后面会提到),他们的最小单元是非零长度的区间而不是单点。

线段树,本质就是一棵树,树上存储的是区间的信息,我们构建这样一棵树,主要是用来查询区间信息,因为在树上进行查询的时间复杂度是优秀的log2N。

二、基本结构

线段树详解、常见应用与拓展_第1张图片

我们举一个非常无脑的例子,我们要求 1 2 3 4 5 这五个数字中的数字个数(什么??你自己都说5个了,还要求啥),虽然完全可以不用线段树来完成这个傻傻的问题,不过这也是一种求区间的信息的问题,即 [1, 5] 这个区间中元素的个数。

图中红色的数字表示每个节点所保存的区间信息,在这里保存的就是区间中元素的个数。

构造出这颗线段树,不仅可以求出 [1, 5] 中的元素个数,其他所有区间的元素个数我们都可以得到,而且每次询问的时间复杂度都为 log2N。询问每次从根节点 1 开始,如果访问到的区间被包含在询问的区间 [a, b] 之中,那么直接加上该区间保存的数值并且返回,否则 if(a <= mid) 访问左孩子, if(b > mid) 访问右孩子。

除了这种建树区间查询以外,我们还可以动态地修改区间信息修改单点信息查询单点信息,线段树相关的题目再怎么改造修改,最后还是基于这五种线段树的基本操作。

下面我们来说说线段树的结点:

struct Node {
    int left, right;  // 用来保存该区间的左右端点
    int w;  // 即区间保存的信息
    int f;  // lazy标记
}tree[4*maxn];

看到这,发现left、right 以及 w 都好理解,可是 lazy 懒标记是什么?我们后面再讲。

  1. 建树 BuildTree

    ​ 通常包含了初始化+输入原始数据两个步骤,有时只需要进行初始化即可。

    ​ 大致过程就是从根节点开始初始化到底部,在叶子节点存储区间的信息,然后逐级向上反馈,合并状态。

    void build(int k, int l, int r){
        // 对三个变量进行初始化
        tree[k].left = l;
        tree[k].right = r;
        tree[k].f = 0;
        // 输入原始数字都叶子结点并且返回
        if(tree[k].left == tree[k].right){
            // 有时没有输入的步骤,而是直接放到上面的初始化中
            scanf("%d", &tree[k].w);
            return;
        }
        // 对左右子树分别进行建树
        int mid = (tree[k].left+tree[k].right)/2;
        build(2*k, l, mid);
        build(2*k+1, mid+1, r);
        // 状态合并,有时候可以省略
        tree[k].w = tree[2*k].w+tree[2*k+1].w;
    }
    
  2. 单点查询 Ask_Point

    ​ 已知单点的信息全部存储于叶子节点,因此类似于二分法,从根节点出发,每次选择一个子树进行递归,直到找到叶子节点,时间复杂度log2N。

    void ask_point(int k, int x){
        // 找到则返回,按照个人习惯使用 return tree[k].w 或者 ans = tree[k].w
        // 本人使用后者,这样方便后续的代码统一
        if(tree[k].left == tree[k].right){
            ans = tree[k].w;
            return;
        }
        int mid = (tree[k].left+tree[k].right)/2;
        if(x <= mid) ask_point(2*k, x);
        else ask_point(2*k+1, x);
    }
    
  3. 单点修改 Change_Point

    与单点查询非常相似,利用二分的思想找到单点并且修改,之后逐级向上反馈,状态合并。由于要从叶子节点修改到根节点,因此共有 log2N 个结点的区间信息被修改。

    void change_point(int k, int x, int c){
        // 找到叶子节点后修改并返回
        if(tree[k].left == tree[k].right){
            tree[k].w += c;
            return;
        }
        int mid = (tree[k].left+tree[k].right)/2;
        if(x <= mid) change_point(2*k, x, c);
        else change_point(2*k+1, x, c);
        // 逐级向上状态合并
        tree[k].w = tree[2*k].w+tree[2*k+1].w;
    }
    

    线段树是用来处理区间信息的,而单点属于特殊的区间,因此操作还是比较容易的,重难点在于区间查询以及区间修改,基本上线段树的题目都要在这两种操作上面下功夫。

  4. 区间查询 Ask_Interval

    在这里开始可能会有点难理解,那么使用一些图来帮助解读。

    img

    img

    图片引用自https://blog.csdn.net/qq_42712462/article/details/84028237

    具体来讲一共有四种情况:

    ​ ① x <= l && r <= y,如图1,直接加上,剩余没加的部分会在后续的搜索中加上

    ​ ② x <= l && r > y,如图2,左边有部分可加,搜索左子树

    ​ ③ x > l && r <= y,类似于图2,右边有部分可加,搜索右子树

    ​ ④ x > l && r > y ,如图3,通常左右两边都可加,搜索两颗子树

    void ask_interval(int k, int l, int r){
        // 情况①
        if(tree[k].left >= l && tree[k].right <= r){
            // 这里就是上面ask_point提到的代码统一
            ans += tree[k].w;
            return;
        }
        int mid = (tree[k].left+tree[k].right)/2;
        // 情况③通常下面两个都要执行
        // 情况②
        if(l <= mid) ask_interval(2*k, l, r);
        // 情况③
        if(r > mid) ask_interval(2*k+1, l, r);
    }
    
  5. 区间修改 Change_Interval

    理解了上面的区间查询的话,这里也就不难理解了,找到要修改的区间或者子区间进行修改,之后逐级向上反馈,状态合并。

    但是这里我们就要用到上面提到的 lazy 懒标记了!为什么呢?因为在每次区间修改的过程中我们都把区间中的每个点就进行一次修改然后进行状态合并,那么如果面对庞大的树的话,这样做的效率是非常低的!

    那么 lazy 标记是怎么运作的呢,打个比方:过年了,亲戚想要给一个大户人家的孩子们压岁钱,由于孩子众多,一个个给很麻烦,就先寄存在母亲大人手里。而母亲大人呢,想到孩子们现在还小给了压岁钱也没用,就先放在自己身上,等到真正要用的时候,比如给孩子们交学费,就拿出来给孩子交学费去了。

    (想必大家都有这种遭遇吧…

    而 lazy 标记就相当于母亲大人,等到孩子要使用的时候再分发下去,这样可以节省大量的时间。

    如此一来,不论我们以何种方式访问结点,在那之前需要先将其“母亲大人”手中的 lazy 分发下来才可以保证正确,所以需要用到 down 函数,并且还要加到上面给出的四种操作中,后面会给出加入 lazy 标志之后的5种操作。

    void down(int k){
        // 跳过叶子节点,避免越界
        if(tree[k].left == tree[k].right) return;
        // 懒标记下传
        tree[2*k].f += tree[k].f;
        tree[2*k+1].f += tree[k].f;
        // 下面的式子同样是根据题目信息变动的,若调用f则需要注意调用tree[k].f
        tree[2*k].w += tree[k].f*(tree[2*k].right-tree[2*k].left+1);
        tree[2*k+1].w += tree[k].f*(tree[2*k+1].right-tree[2*k+1].left+1);
        // 归零
        tree[k].f = 0;
    }
    
    void change_interval(int k, int l, int r, int c){
        // 找到需要修改的区间
        if(tree[k].left >= l && tree[k].right <= r){
            // 下面这个式子都是根据实际问题的区间信息变动的,不仅是这里,上面的代码中也是
            tree[k].w += (tree[k].right-tree[k].left+1)*c;
            // 懒标记累计
            tree[k].f += c;
            return;
        }
        // 有积攒的f还没有分发,分发下去
        if(tree[k].f) down(k);
        int mid = (tree[k].left+tree[k].right)/2;
        if(l <= mid) change_interval(2*k, l, r, c);
        if(r > mid) change_interval(2*k+1, l, r, c);
        // 修改的操作之后都要进行状态合并
        tree[k].w = tree[2*k].w+tree[2*k+1].w;
    }
    

这里给出加入 lazy 标记之后的线段树代码:

#include
using namespace std; 

const int maxn = 1e5+10;

struct Node {
	int left, right;
	int w;
	int f;
}tree[4*maxn];

int ans;

// 通常把状态合并的步骤独立成这样的pushUp函数
// 上传
void pushUp(int k){
    // 随具体题目改变
    tree[k].w = tree[2*k].w+tree[2*k+1].w;
}

// 建树
void build(int k, int l, int r){
    tree[k].left = l;
    tree[k].right = r;
    tree[k].f = 0;
    if(tree[k].left == tree[k].right){
        scanf("%d", &tree[k].w);
        return;
    }
    int mid = (tree[k].left+tree[k].right)/2;
    build(2*k, l, mid);
    build(2*k+1, mid+1, r);
    pushUp(k);
}

// 下传
void down(int k){
    if(tree[k].left == tree[k].right) return;
    tree[2*k].f += tree[k].f;
    tree[2*k+1].f += tree[k].f;
    tree[2*k].w += tree[k].f*(tree[2*k].right-tree[2*k].left+1);
    tree[2*k+1].w += tree[k].f*(tree[2*k+1].right-tree[2*k+1].left+1);
    tree[k].f = 0;
}

// 单点查询
void ask_point(int k, int x){
    if(tree[k].left == tree[k].right){
        ans = tree[k].w;
        return;
    }
    if(tree[k].f) down(k);
    int mid = (tree[k].left+tree[k].right)/2;
    if(x <= mid) ask_point(2*k, x);
    else ask_point(2*k+1, x);
}

// 单点修改
void change_point(int k, int x, int c){
    if(tree[k].left == tree[k].right){
        tree[k].w += c;
        return;
    }
    if(tree[k].f) down(k);
    int mid = (tree[k].left+tree[k].right)/2;
    if(x <= mid) change_point(2*k, x, c);
    else change_point(2*k+1, x, c);
   	pushUp(k);
}

// 区间查询
void ask_interval(int k, int l, int r){
    if(tree[k].left >= l && tree[k].right <= r){
        ans += tree[k].w;
        return;
    }
    if(tree[k].f) down(k);
    int mid = (tree[k].left+tree[k].right)/2;
    if(l <= mid) ask_interval(2*k, l, r);
    if(r > mid) ask_interval(2*k+1, l, r);
}

// 区间修改
void change_interval(int k, int l, int r, int c){
    if(tree[k].left >= l && tree[k].right <= r){
        tree[k].w += (tree[k].right-tree[k].left+1)*c;
        tree[k].f += c;
        return;
    }
    if(tree[k].f) down(k);
    int mid = (tree[k].left+tree[k].right)/2;
    if(l <= mid) change_interval(2*k, l, r, c);
    if(r > mid) change_interval(2*k+1, l, r, c);
    pushUp(k);
}

三、常见应用

这里给出三种常见的线段树应用,带修改的区间和、区间和以及区间第k大问题,当然线段树的应用远多于这些。

如果不是需要进行修改区间内的元素的话,直接使用前缀和求区间元素和、使用贪心法求最大区间和、使用变形快排求区间第k大问题,但是加上对区间进行修改的话,这两种方法就不好用了。

(事实上,线段树的题目一般都比较有特点:1. TimeLimit 一般 > 1s 2. 数据范围 N <= 1e5 3. 带修改+大量询问 4. 需要求的是区间的信息)

  1. 区间和

    给定一个整形数列,要求指定区间内的元素和。

    假定 N 表示元素的个数,Q 表示操作的次数。

    有三种操作:

    · Query l r :表示查询区间 [l, r] 中的元素和。

    · Add l r c :表示对区间 [l, r] 中的每个元素加上 c 。

    · Sub l r c :表示对区间 [l, r] 中的每个元素减去 c 。

    (1 <= N, Q <= 100000, 1 <= l <= r <= N, 0 <= c <= 1000)

    Sample input: Sample output:

    5 5 12

    4 2 6 5 7 11

    Query 1 3 22

    Add 2 4 1

    Sub 1 5 1

    Query 3 4

    Query 1 5

    思路:线段树非常简单的应用,几乎可以用上面的代码直接解决这个问题。每个节点保存的时候这个区间中所有元素之和,需要注意在 change_interval 的时候 tree[k].sum 的改变,详见代码。

    (线段树问题的输入数据量一般都不小,因此建议使用 scanf 而不是 cin)

    #include
    #include
    using namespace std;
    
    const int maxn = 1e5+10;
    int N, Q, ans;
    
    struct Node {
    	int left, right;
    	int sum;
    	int f;
    }tree[4*maxn];
    
    void pushUp(int k){
    	tree[k].sum = tree[2*k].sum+tree[2*k+1].sum;
    }
    
    void build(int k, int l, int r){
    	tree[k].left = l;
    	tree[k].right = r;
    	tree[k].f = 0;
    	if(l == r){
    		scanf("%d", &tree[k].sum);
    		return;
    	}
    	int mid = (l+r)/2;
    	build(2*k, l, mid);
    	build(2*k+1, mid+1, r);
    	pushUp(k);
    }
    
    void down(int k){
    	if(tree[k].left == tree[k].right) return;
    	tree[2*k].f += tree[k].f;
    	tree[2*k+1].f += tree[k].f;
        // 这里的lazy标记使用tree[k]的而不是自己的
        // 因为自己的lazy标记可能没有下传一直在累计,不等于tree[k].f
    	tree[2*k].sum += tree[k].f*(tree[2*k].right-tree[2*k].left+1);
    	tree[2*k+1].sum += tree[k].f*(tree[2*k+1].right-tree[2*k+1].left+1); 
    	tree[k].f = 0;
    }
    
    void ask_interval(int k, int l, int r){
    	if(tree[k].left >= l && tree[k].right <= r){
    		ans += tree[k].sum;
    		return;
    	}
    	if(tree[k].f) down(k);
    	int mid = (tree[k].left+tree[k].right)/2;
    	if(l <= mid) ask_interval(2*k, l, r);
    	if(mid < r) ask_interval(2*k+1, l, r);
    }
    
    void change_interval(int k, int l, int r, int c){
    	if(tree[k].left >= l && tree[k].right <= r){
            // 注意这里不是tree[k].sum += c
            // 因为这个区间中元素不止一个,因此需要乘上元素个数
    		tree[k].sum += c*(tree[k].right-tree[k].left+1);
    		tree[k].f += c;
    		return;
    	}
    	if(tree[k].f) down(k);
    	int mid = (tree[k].left+tree[k].right)/2;
    	if(l <= mid) change_interval(2*k, l, r, c);
    	if(mid < r) change_interval(2*k+1, l, r, c);
    	pushUp(k);
    }
    
    int main(){
    	while(scanf("%d%d", &N, &Q) == 2){
    		build(1, 1, N);
    		for(int i = 0; i < Q; i++){
    			char ch[10];
    			scanf("%s", ch);
    			int l, r, c;
                // 判断首字符即可,而不用strcmp
    			if(ch[0] == 'Q'){
    				ans = 0;
    				scanf("%d%d", &l, &r);
    				ask_interval(1, l, r);
    				printf("%d\n", ans);
    			}
    			else if(ch[0] == 'A'){
    				scanf("%d%d%d", &l, &r, &c);
    				change_interval(1, l, r, c);
    			}
    			else{
    				scanf("%d%d%d", &l, &r, &c);
    				change_interval(1, l, r, -c);
    			}
    		}
    	}
    	return 0;
    } 
    
  2. 区间最大元素

    给定一个整形数列,要求指定区间内的最大元素。

    假定 N 表示元素的个数,Q 表示操作的次数。

    有三种操作:

    · Query l r :表示查询区间 [l, r] 中的元素和。

    · Add l r c :表示对区间 [l, r] 中的每个元素加上 c 。

    · Sub l r c :表示对区间 [l, r] 中的每个元素减去 c 。

    (1 <= N, Q <= 100000, 1 <= l <= r <= N, 0 <= c <= 1000)

Sample input:

5 5                                                  
2 -3 1 4 -1				      
Query 1 5				      
Add 1 2 4
Query 1 3 
Sub 1 5 1
Query 1 5

Sample output:

4
6
5

思路:同样是非常简单的线段树应用,只要在上面的代码中稍微改动,把区间求和改成求左右孩子的最大值即可。本来想要加上乘法运算的,但是这样就需要提前使用到了后文会提到的多 lazy 标志来处理乘法标记 fmul 与加减标记fadd 的关系,因此这里还是只涉及到了加减法让大家来理解一下线段树的使用方法。

#include
#include
#include
using namespace std;

const int maxn = 1e5+10;
int N, Q, ans;

struct Node {
	int left, right;
	int maxValue;
	int f;
}tree[4*maxn];

void pushUp(int k){
	if(tree[k].left == tree[k].right) return;
	tree[k].maxValue = max(tree[2*k].maxValue, tree[2*k+1].maxValue);
}

void build(int k, int l, int r){
	tree[k].left = l;
	tree[k].right = r;
	tree[k].f = 0;
	if(l == r){
		scanf("%d", &tree[k].maxValue);
		return;
	}
	int mid = (l+r)/2;
	build(2*k, l, mid);
	build(2*k+1, mid+1, r);
	pushUp(k);
}

void down(int k){
	if(tree[k].left == tree[k].right) return;
	tree[2*k].f += tree[k].f;
	tree[2*k+1].f += tree[k].f;
	tree[2*k].maxValue += tree[k].f;
	tree[2*k+1].maxValue += tree[k].f;
	tree[k].f = 0;
}

void ask_interval(int k, int l, int r){
	if(tree[k].left >= l && tree[k].right <= r){
        // 求最大值
		ans = max(ans, tree[k].maxValue);
		return;
	}
	if(tree[k].f) down(k);
	int mid = (tree[k].left+tree[k].right)/2;
	if(l <= mid) ask_interval(2*k, l, r);
	if(mid < r) ask_interval(2*k+1, l, r);
}

void change_interval(int k, int l, int r, int c){
	if(tree[k].left >= l && tree[k].right <= r){
		tree[k].maxValue += c;
		tree[k].f += c;
		return;
	}
	if(tree[k].f) down(k);
	int mid = (tree[k].left+tree[k].right)/2;
	if(l <= mid) change_interval(2*k, l, r, c);
	if(mid < r) change_interval(2*k+1, l, r, c);
	pushUp(k);
}

int main(){
	while(scanf("%d%d", &N, &Q) == 2){
		build(1, 1, N);
		char ch[10];
		int l, r, c;
		for(int i = 0; i < Q; i++){
			scanf("%s", ch);
			scanf("%d%d", &l, &r);
			if(ch[0] == 'Q'){
				ans = 0;
				ask_interval(1, l, r);
				printf("%d\n", ans);
			}
			else if(ch[0] == 'A'){
				scanf("%d", &c);
				change_interval(1, l, r, c);
			}
			else{
				scanf("%d", &c);
				change_interval(1, l, r, -c);
			}
		}
	}	
	return 0;
} 

四、拓展

  1. 离散化

    https://blog.csdn.net/qq_41765114/article/details/90179868

  2. 多lazy标记

    https://blog.csdn.net/qq_41765114/article/details/90180073

  3. dfs序

    https://blog.csdn.net/qq_41765114/article/details/90180096

  4. 空间优化

    https://blog.csdn.net/qq_41765114/article/details/90181187

  5. 区间合并

    https://blog.csdn.net/qq_41765114/article/details/90180618

  6. 扫描线

    https://blog.csdn.net/qq_41765114/article/details/90181252

  7. 主席树

    https://blog.csdn.net/qq_41765114/article/details/90181320

  8. RMQ

    https://blog.csdn.net/qq_41765114/article/details/90181352

  9. zkw

    https://blog.csdn.net/qq_41765114/article/details/90181400

【END】感谢观看!

你可能感兴趣的:(线段树)