学习线段树(Segment Tree)

线段树是一种二叉搜索树,用于维护区间信息,支持单点修改和区间查询操作,时间复杂度为O(logN)。本文介绍了线段树的定义、功能、结构、常见操作以及在C++中的实现,包括基础线段树、区间极值查询、单点更新和区间更新,详细解析了建树、查询和更新的模板代码。
摘要由CSDN通过智能技术生成

Segment Tree 线段树

Segment ABC

What is segment tree 什么是线段树

线段树是一种二叉搜索树,什么叫做二叉搜索树,首先满足二叉树,每个结点度小于等于二,即每个结点最多有两颗子树,何为搜索,我们要知道,线段树的每个结点都存储了一个区间,也可以理解成一个线段,而搜索,就是在这些线段上进行搜索操作得到你想要的答案。
下图是一个长度为 7 7 7 的线段树样子。
在这里插入图片描述
在这里插入图片描述

What can segment tree do

线段树是算法竞赛中常用的用来维护 区间信息 的数据结构。

线段树可以在 O ( N l o g N ) O(NlogN) O(NlogN) 的时间复杂度内实现单点修改、区间修改、区间查询(区间求和,求区间最大值,求区间最小值)等操作。

线段树结构

首先线段树是一棵二叉树, 平常我们所指的线段树都是指一维线段树,如下图所示。 故名思义, 线段树能解决的是线段上的问题, 这个线段也可指区间. 我们先来看线段树的逻辑结构。

在这里插入图片描述

一颗线段树的构造就是根据区间的性质的来构造的, 如下是一棵区间[0, 3]的线段树,每个[start, end]都是一个二叉树中的节点。

          [0,3]
         /     \
    [0,1]       [2,3]
    /   \       /   \
 [0,0] [1,1] [2,2] [3,3]

下面我们用最大值问题为例,来看一下线段树。

对于 A[1:6] = {1,8,6,4,3,5} 来说,线段树如下图所示,红色代表每个结点存储的区间,蓝色代表该区间最值。
在这里插入图片描述

可以发现,每个叶子结点的值就是数组的值,每个非叶子结点的度都为二,且左右两个孩子分别存储父亲一半的区间。每个父亲的存储的值也就是两个孩子存储的值的最大值。

对于上述线段树,我们增加绿色数字为每个结点的下标。

在这里插入图片描述

则每个结点下标如上所示,这里你可能会问,为什么最下一排的下标直接从 9 9 9 跳到了 12 12 12,道理也很简单,中间其实是有两个空间的呀!!虽然没有使用,但是他已经开了两个空间,这也是为什么无优化的线段树建树需要 2 ∗ 2 k   ( 2 k − 1 < n < 2 k ) 2*2^k\ (2^{k-1} < n < 2^k) 22k (2k1<n<2k) 空间,一般会开到 4 × N 4 \times N 4×N 的空间防止 RE。

仔细观察每个父亲和孩子下标的关系,不难发现,每个左子树的下标都是偶数,右子树的下标都是奇数且为左子树下标 + 1 +1 +1,而且不难发现以下规律

  • l = f a ∗ 2 l = fa*2 l=fa2 (左子树下标为父亲下标的两倍)
  • r = f a ∗ 2 + 1 r = fa*2+1 r=fa2+1(右子树下标为父亲下标的两倍 + 1 +1 +1

具体证明也很简单,把线段树看成一个完全二叉树(空结点也当作使用)对于任意一个结点 k k k 来说,它所在此二叉树的 l o g 2 k log_2k log2k 层,则此层共有 2 l o g 2 k 2^{log_2k} 2log2k 个结点,同样对于 k k k 的左子树那层来说有 2 l o g 2 k + 1 2^{log_2k+1} 2log2k+1个结点,则结点 k k k 和左子树间隔了 2 ∗ 2 l o g 2 k − k + 2 ∗ ( k − 2 l o g 2 k ) 2*2^{log_2k}-k + 2*(k-2^{log_2k}) 22log2kk+2(k2log2k) 个结点,然后这就很简单就得到 k + 2 ∗ 2 l o g 2 k − k + 2 ∗ ( k − 2 l o g 2 k ) = 2 ∗ k k+2*2^{log_2k}-k + 2*(k-2^{log_2k}) = 2*k k+22log2kk+2(k2log2k)=2k 的关系了吧,右子树也就等于左子树结点 + 1 +1 +1

因为左子树都是偶数,所以我们常用位运算来寻找左右子树

  • k<<1(结点 k k k 的左子树下标),其实就是 2*k
  • k<<1|1(结点 k k k 的右子树下标),其实就是 2*k+1

因为左子树都是偶数,所以我们常用位运算

线段树主要操作和时间复杂度

查询 query

查询从 [ L , R ] [L, R] [L,R] 这个区间的数据和,最大值,最小值,公约数。

时间复杂度为 O ( l o g N ) O(logN) O(logN)

更新 update

分为单点更新和区域更新。通过 Lazy 算法,我们可以江时间复杂度也控制在 O ( l o g N ) O(logN) O(logN)

线段树存储空间

在算法竞赛中,我们一般使用一维数组来存储线段树。

对于任何一个长度为 N N N 的数组,对应的线段树深度为 ⌈ l o g 2 N ⌉ ⌈log_2N⌉ log2N,因此对应每层节点数有: 1 + 2 + 4 + . . . + 2 ⌈ l o g 2 N ⌉ < 4 × N 1+2+4+...+2^{⌈log_2N⌉}<4 \times N 1+2+4+...+2log2N<4×N,对应的二叉树深度为 l o g N logN logN

How to implement segment tree in C++

基础线段树

下面我们用区间和功能来展示基础线段树代码。所谓的区间和是

  • 叶子节点存储原始数据。
  • 非叶子节点存储的是和数据。

如下图所示。

定义存储空间
using LL=long long;
const int N=1e5+10;
LL a[N];//保存原来数据
LL seg[4*N];//定义线段树
Build Tree 建树

下面的模板代码,将从原数组 a a a 中,建立 [ l , r ] [l, r] [l,r] 区间的线段树,叶子节点数据为 a a a,其他节点为对应区间和。

例如数组 a a a 内数据为 { 1 , 8 , 3 , 4 , 7 , 1 , 6 , 2 } \{1,8,3,4,7,1,6,2\} {1,8,3,4,7,1,6,2},建立线段树的过程如下图。

在这里插入图片描述

模板代码
/*
从数组a中建立 [l,r] 长度线段树
输入参数:
  node   : 建立线段数的节点位置。一般固定在 1
  l      : 数组的起点位置。一般固定在 1
  r      : 数组的终点位置。一般固定在 n
用法:
  一般都是 build(1,1,n);
*/
void build(LL node, LL l, LL r) {
	if (l>=r) {
        seg[node]=a[l];
        return;
    }
    //使用分治的思路
    LL mid=(l+r)/2;
    LL l_node = 2*node;//左儿子
    build_tree(l_node, l, r);//建立左子树
    LL r_node = 2*node+1;//右儿子
    build_tree(r_node, mid+1, r);//建立右子树
    seg[node]=seg[l_node]+seg[r_node];//更新node的值,这里也就是push up操作
}
时间复杂度

O ( N ) O(N) O(N)

练习题

学习系列——线段树 I —— 建立线段树 - 问题 - MYOJ

查询操作

我们现在有一个数组为 { 1 , 8 , 3 , 4 , 7 , 1 , 6 , 2 } \{1, 8, 3, 4, 7, 1, 6, 2\} {1,8,3,4,7,1,6,2},我们需要查询 [ 1 , 6 ] [1, 6] [1,6] 这个区间的和,如下图所示。

在这里插入图片描述

我们可以发现,其实在每次查询的时候,是不断对当前层级所表示的区间进行二分分治,直到最后每个分块的并集即为待查询区间。
我们需要查询 [ L , R ] [L, R] [L,R] 区间的数据和,也就是 ∑ i = L R a i \sum_{i=L}^{R}a_i i=LRai
如上图所示的动画中,我们只需要查询 [ 1 , 4 ] [1, 4] [1,4] [ 5 , 6 ] [5, 6] [5,6] 这两个区间即可。这样我们得到区间和为 16 + 8 = 24 16+8=24 16+8=24

模板代码
/*
查询指定区间数据
输入参数:
  node : 建立线段数的节点位置。一般固定在 1
  l     : 数组的起点位置。一般固定在 1
  r     : 数组的终点位置。一般固定在 n
  ql    : 查询区间起点
  qr    : 查询区间终点
用法:
  query(1,1,n,ql,qr);
*/
LL query(LL node, LL l, LL r, LL ql, LL qr) {
	if (ql<=l && r<=qr) {
        //区间在查询范围内
		return seg[node];
	} 
	LL mid=(l+r)/2;
    LL res=0;
    if (ql<=mid) {
        //如果在左边查询左边
        res+=query(2*node, l, r, ql, qr);
    }
    if (qr>mid) {
        //如果在右边查询右边
        res+==query(2*node+1, mid+1, r, ql, qr);
    }
	return res;
}
时间复杂度

O ( l o g N ) O(logN) O(logN)

练习题

学习系列——线段树 III —— 查询区间和操作 - 问题 - MYOJ

单点更新

将对线段树上的某个具体位置数据进行修改。一般这个操作称为单点修改

比如我们现在有一个数组为 { 1 , 3 , 5 , 7 , 9 , 11 } \{1, 3, 5, 7, 9, 11\} {1,3,5,7,9,11},我们建立的线段树入下图所示。
在这里插入图片描述

下面我们进行单点修改。假设我要将 a 4 a_4 a4 改为 15 15 15。这样,原数组变为 { 1 , 3 , 5 , 15 , 9 , 11 } \{1, 3, 5, 15, 9, 11\} {1,3,5,15,9,11},对应的线段树变为下图,红色的数字为修改的地方。
在这里插入图片描述

模板代码
/*
单点更新数据
输入参数:
  node : 建立线段数的节点位置。一般固定在 1
  l     : 数组的起点位置。一般固定在 1
  r     : 数组的终点位置。一般固定在 n
  pos  : 需要更新的位置
  val  : 更新后的值
用法:
  update(1,1,n,pos,val);
*/
void update(LL node, LL l, LL r, LL pos, LL val) {
	if (l>=r) {
		seg[node]=val;
		return;
	}
	LL mid=(l+r)/2;
	LL l_node=node*2;
	LL r_node=node*2+1;
	if (pos<=mid) {
        //更新左儿子
		update(l_node, l, mid, pos, val);
	} else {
        //更新右儿子
		update(r_node, mid+1, r, pos, val);
	}
    //更新节点数据
	seg[node]=seg[l_node]+seg[r_node];
}
时间复杂度

O ( l o g N ) O(logN) O(logN)

练习题

学习系列——线段树 II —— 单点更新 - 问题 - MYOJ

线段树其他功能

区间极值

对于最大值最小值问题,在非叶子节点保存极值,而不是和即可。

因此,我们需要在对应的建树、查询、修改代码进行相应的修改。

模板代码

下面的模板代码是保存了最大值。

void build(LL node, LL l, LL r) {
	if (l>=r) {
        seg[node]=a[st];
        return;
    }
    //使用分治的思路
    LL mid=(l+r)/2;
    LL l_node = 2*node;//左儿子
    build_tree(l_node, l, r);//建立左子树
    LL r_node = 2*node+1;//右儿子
    build_tree(r_node, mid+1, r);//建立右子树
    seg[node]=max(seg[l_node],seg[r_node]);//更新node的值
}
LL query(LL node, LL l, LL r, LL ql, LL qr) {
	if (ql<=l && r<=qr) {
        //区间在查询范围内
		return seg[node];
	}
	LL mid=(l+r)/2;
    LL max_l=-9e18;
    if (ql<=mid) {
        //如果在左边查询左边
        max_l=query(2*node, l, mid, ql, qr);
    }
    LL max_r=-9e18;
    if (qr>mid) {
        //如果在右边查询右边
        max_r==query(2*node+1, mid+1, r, ql, qr);
    }
	return max(max_l, max_r);
}

单点更新 update 代码进行对应修改即可。

练习题

学习系列——线段树 IV —— 区间最值查询问题(RMQ)之最大值 - 问题 - MYOJ

学习系列——线段树 V —— 区间最值查询问题(RMQ)之最小值 - 问题 - MYOJ

极值+出现次数

思路还是一样的。数据保存可以使用 pair 即可,即使用 pair 替代原来的 LL。

pair<LL, LL> seg[N];

然后再修改对应 build, query, update 函数。

模板代码
using PLL=pair<LL, LL>;
PLL seg[N];//first表示最最大值, second表示次数

PLL combine(PLL a, PLL b) {
    if (a.first>b.first) {
        return a;
    } else if (a.first<b.first) {
        return b;
    }
    return make_pair(a.first, a.second+b.second);
}

void build(LL node, LL l, LL r) {
	if (l>=r) {
        seg[node]=make_pair(a[st], 1);
        return;
    }
    //使用分治的思路
    LL mid=(l+r)/2;
    LL l_node = 2*node;//左儿子
    build_tree(l_node, l, mid);//建立左子树
    LL r_node = 2*node+1;//右儿子
    build_tree(r_node, mid+1, r);//建立右子树
    seg[node]=combine(seg[l_node],seg[r_node]);//更新node的值
}
PLL query(LL node, LL l, LL r, LL ql, LL qr) {
	if (ql<=l && r<=qr) {
        //区间在查询范围内
		return seg[node];
	}
	LL mid=(l+r)/2;
    PLL max_l=make_pair(0, 0);
    if (ql<=mid) {
        //如果在左边查询左边
        max_l=query(2*node, l, mid, ql, qr);
    }
    PLL max_r=make_pair(0, 0);
    if (qr>mid) {
        //如果在右边查询右边
        max_r==query(2*node+1, mid+1, r, ql, qr);
    }
	return combine(max_l, max_r);
}
区间最大公约数/最小公倍数
统计某个数字出现次数
k k k 次出现某个数字

线段树进阶操作

区间更新

和上面的单点更新不同,现在我们 [ l , r ] [l, r] [l,r] 区域对线段树进行更新。如果我们还是使用单点更新的模式,这样时间复杂度将高达 O ( N l o g N ) O(NlogN) O(NlogN),这个代价是无法接受的。这样,我们就引入一个 Lazy Propagation,国内一般称为“懒惰标志”。
基本的思路是这样的:

父节点:我这里要区间加 4,你们都要加 4。
左子树:好的。
右子树:但是这样太慢了,因为我们子孙也都要加 4 啊。
父节点:嗯确实是这样。哪我标记一下,我们欠你们这些,以后再还(每次 update 和 query 时候)。

这样,我们将引入一个新的 lazy 数组。在每次 update 和 query 的时候进行下传。这样将总时间复杂度控制在 O ( l o g N ) O(logN) O(logN)

现在有一个数组为 { 1 , 8 , 3 , 4 , 7 , 1 , 6 , 2 } \{1, 8, 3, 4, 7, 1, 6, 2\} {1,8,3,4,7,1,6,2},我们需要对线段树进行一个区间更新的操作,将 [ 3 , 6 ] [3, 6] [3,6] 这个区间上的所有数字增加 4 4 4。此时我们的增量数组如图所示:
在这里插入图片描述

为什么要单独用一个属性来记录增量?因为我们上面的图片发现我们需要查的 [ 1 , 6 ] [1, 6] [1,6] 这个区间,其最小颗粒度的查询节点是 [ 1 , 4 ] [1, 4] [1,4] [ 5 , 6 ] [5, 6] [5,6] 这两个节点,所以我们将增量向下更新到这两个节点,就能保证我们查询 [ 1 , 6 ] [1, 6] [1,6] 这个区间的正确性。
于是,通过这个思路我们来思考,当我们需要对一个区间进行批量增减操作的时候,我们只要向下更新到我们所有查询操作的最小粒度即可,而不用完全对整个线段树进行更新,是不是就完成了复杂度的优化!
这就是 O ( l o g N ) O(logN) O(logN) 级别的批量更新思路,这就是算法中的“惰性”(Lazy)思想。

Push Down操作

Push Down 也就是向下更新的意思。因为我们要引入这个增量的记录数组,所以我们需要 Push Down 操作。
在这里插入图片描述

在 Push Down 操作中,我们已经保证了这个更新是最小的可查询的粒度。那么,如果我们在后面要在后面去查询更细的粒度,我们要怎么办呢?其实,思路很简单,当我们查询的时候,也执行 Push Down 按照之前需要更新的范围继续向下更新,是不是就可以了。
同样的,由于 Update 区间操作也需要想查询一样最小的可更新粒度,所以我们在每查询到一个节点时,也对其增加一个 Push Down 操作,如此可以保证下方节点都是最新的更新态。

模板代码

我们还是使用增加一个数组,用于标志下传。

//segment tree
const int MAXN=1e5+10;
int a[MAXN];
int seg[MAXN*4];
int add[MAXN*4];

void build(int node, int l, int r) {
	if (l>=r) {
		seg[node]=a[l];
		return;
	}
	int mid=(l+r)/2;
	int l_node=2*node;
	build(l_node, l, mid);
	int r_node=2*node+1;
	build(r_node, mid+1, r);
	seg[node]=seg[l_node]+seg[r_node];
}

/*
st 表示左儿子区域长度
ed 表示右儿子区域长度
*/
void push_down(int node, int st, int ed) {
	if (add[node]) {
		//左儿子
		int l_node=2*node;
		add[l_node]+=add[node];//标志下传
		st [l_node]+=add[node]*st;//修改数据
		//右儿子
		int r_node=2*node+1;
		add[r_node]+=add[node];
		st [r_node]+=add[node]*ed;
		
		add[node]=0;
	}
}

void update(int node, int l, int r, int ql, int qr, int c) {
	if (ql<=l&&r<=qr) {
		add[node]+=c;//更新标志
		st [node]+=c*(ed-st+1);//修改自己的数据
		return;
	}
	int mid=(l+r)/2;
	push_down(node, mid-l+1, r-mid);//下传
	int l_node=2*node;
	if (ql<=mid) {
		update(l_node, st, r, ql, qr, c);
	}
	int r_node=2*node+1;
	if (qr>mid) {
		update(r_node, mid+1, r, ql, qr, c);
	}
	seg[node]=seg[l_node]+seg[r_node];
}

int query(int node, int l, int r, int ql, int qr) {
	if (ql<=l&&r<=qr) {
		return seg[node];
	}
	int mid=(l+r)/2;
	push_down(node, mid-l+1, r-mid);
	int ret=0;
	if (ql<=mid) {
		ret+=query(2*node,l,mid,ql,qr);
	}
	if (qr>mid) {
		ret+=query(2*node+1,mid+1,r,ql,qr);
	}
	return ret;
}

从上面代码中,我们可以看到,update 和 query 都增加了一个 push_down()。这样,我们就将时间平摊,最终达到 O ( l o g N ) O(logN) O(logN) 级别。因为不需要操作的时候,当前数据是不会影响最终结果的。

终极线段树模板

T.B.C.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

努力的老周

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值