线段树什么的不是简简单单嘛,我教你!:基础篇
零、序言——万物滴开篇
也许你是苦于笔试的打工人,也许你是步入算法圈不久的小小萌新(我也是萌新) ,也许你是在网上搜索数据结构课设的倒霉学生。不管怎么样,看完本篇文章,希望对您有所帮助。

走起!
观前提醒:看本文章最好有一定的二叉树基础(至少要会递归遍历树的程度)和算法基础(咋的也得知道时间复杂度是什么)
线段树 是算法竞赛中非常常见一种的数据结构,功能强大,学术一点的话说就是:常用的用来维护 区间信息 的数据结构。线段树 可以在较小的时间复杂度内实现单点修改、区间修改、区间查询(区间求和,求区间最大值,求区间最小值)等操作。
没听懂?没事,以前我也听不懂,让我们来——
一、举个栗子
给你一个长度为 n 的数组,有 q 次询问,每次询问给你一个范围:l 和 r ,让你求出数组中第l个数到第r个总和是多少。
对于每次询问,我们可以从第l个数一直累加到第r个数,这样就能轻轻松松得出结果,但是这么做的时间复杂度是O(n) ,一共q次询问,那么总的时间复杂度就是O(n*q) 。对于数据大的情况下,这样是无法通过题目的。但是如果使用 线段树 ,就可以把单次询问的时间复杂度减小到 O(logn) ,这样q次询问下来,总的时间复杂度只有 O(q*logn) ,非常快哦!
说到这里,可能有同学学的比较好,学过 前缀和 算法,他不服气,说使用前缀和,单次询问的复杂度只有 O(1) ,总的复杂度就是 O(q) ,不比这个线段树还要快吗?
如果是当前这一题,那么当然是前缀和比线段树要快,无可否认(线段树:我成🤡了? ),但是,要是给题目加上一个条件之后,你再看:
给你一个长度为 n 的数组,有 q 次操作,操作有两种:
-
1:给你一个下标i和一个数x,需要你把数组中第i个数加上x;
-
2:给你一个范围:l 和 r ,让你求出数组中第l个数到第r个总和是多少。
这一题和上一题不一样的是,多了个修改数组的操作。而我们知道,前缀和是必须要预处理出前缀和数组的,预处理的复杂度是O(n) ,但是正常情况下,只用预处理一次,所以这一点我们一般可以忽略不计。可这里,数组可能随时会发生变化,如果数组发生了变化,那之前预处理出的前缀和数组就不对了,由此得到的区间和答案也不正确。除非每次修改后,重新预处理出前缀和数组,但是这样,单次询问的复杂度就从O(1) 退化到了O(n) ,总复杂度变成了O(n*q) 。
前缀和做法翻车哩!
此时我们回头看看刚刚的小丑线段树,线段树的询问是在O(logn) 中得出结果,但还有一点:线段树的修改也是O(logn) ,并且修改过后得出结果的复杂度仍然是O(logn) 。所以对于线段树来说,这道题总的复杂度依旧是O(logn) !线段树扳回一城!

而就像我们开始说的,不光是求区间和,区间最值什么的也能在O(logn) 的复杂度内得出结果,不说单点修改数组的值,哪怕是修改数组的一段区间,它也可以在O(logn) 的复杂度内完成!
那么,线段树是怎么做到这一点的呢?
二、线段树——什么树?
实际上,线段树其实也就是一个二叉树。
首先,我们先来聊一聊它的基础形态:二叉树。
二叉树算是非常基础的数据结构了,树如其名,每一个节点最多只有两个孩子,一左一右,即二叉。图中就是一个最简单的二叉树:

那么线段树是怎么做到能在O(n*logn) 的复杂度上得到区间和的呢?
- 我们先给上图这二叉树的两个孩子分别赋一个值:x 和 y。
- 那么我们想求出这两个点的总和,只需要:root->left->val + root->right->val。
- root的左右两个孩子都有值了,但root还是空荡荡的,我们不如把这个总和当作root节点的值。
此时,这个二叉树的情况是:

那么之后,如果想知道root的左右俩孩子的值的总和,我们并不需要遍历到这两个点,只要直接遍历到root上,就可以得出结果了。
现在你可能觉得没什么大不了的,也才两个点,我直接遍历他们和遍历一个点也没什么区别。
好,线段树觉得自己被瞧不起了,它开始生长:

现在想知道a、b、c、d四个点的总和,只需要看最上面的那一个点就行了。
还不够?它继续生长:

再长:

再长:
算了吧再长就放不下了。
这就是线段树的运行方式,我们就可以这样,一层一层的继续套下去,最后,如果想知道整个数组的总和,只要走到最上面的那个点就行,而不用遍历整个数组。这效率,显而易见的高。
线段树中的 ”树“ 我们已经体会到了,但是我们还有疑问。
三、线段树——何为线段?
首先说明一点:线段树的叶子,就是数组的元素
(为了方便,我们拿小一点的树来做介绍)
如果此时有一个长度为4的数组,它的元素分别为:a,b,c,d。那么在线段树中表现出来的就是:

然后我们又知道,元素a在数组中的出现范围是 {1,1} ,元素b的出现范围是 {2,2} ,c的是 {3,3} ,d是 {4,4} 。
我们根据这一点,把线段树用另一种形式表现出来:

每个节点上的范围,就表示了这个节点管辖的数组范围。
比如a是{1,1},b是{2,2},那么他们的父亲管辖的范围就是{1,2},如果我们想知道数组中第一个数到第二个数的总和,只需要走到点e就可以了。以此类推。
而这,就是线段树中的——线段。 (说是线段,不如说范围更合适。)
在线段树中,每一个节点都有其负责的一个范围,当我们遇到区间查询问题的时候,只要走到对应的节点就可以得出结果了。
什么,你问我线段树上没有节点负责{1,3}范围,你想问{1,3}范围的总和怎么办?那当然是节点e的结果加上节点c的结果了啊,笨猪猪捏(~ ̄(OO) ̄)ブ。
我们每次询问都是从上往下遍历树的节点,而我们知道,对于一个有n个叶子的二叉树,它的深度是log2(n) ,所以不论是查询还是修改,我们都只用跑log2(n) 层,时间复杂度就为:O(logn) 。
要是看到这里,对线段树终于有所了解,有茅塞顿开之感的同学能不能在屏幕前给咱鼓个掌捏。

好哩,这下线段树的原理都解释清楚啦!!!接下来终于要到了——
四、代码是如何实现的?
因为掘金的各位友友可能都是非算法竞赛选手,比起数组形式的树可能更习惯结构体形式,所以这一环节我会用结构体形式的代码做讲解(数组形式的代码我会在最后面说一下)
这是最重要也最难的一节啦(因为代码太多了,可能有点看不过来)!通过这一节你就成功啦!加油!!
我们还是先回到二叉树,一般我们写二叉树的代码是:
别问我为啥不用力扣的二叉树结构体,指针那么恶心的玩意我不想碰
struct TreeNode {int val;//可能有同学不理解为什么这两个指向孩子的是整形变量不是指针//left和right是数组Node的下标,所以准确来说这个节点的孩子不是left,而是Node[left]int left,right;}Node[N];
相较于普通的二叉树,线段树的结构体代码多出了两个变量,即代表范围的:l(表示范围左端点) 和 r(表示范围右端点)
代码如下:
struct TreeNode {int val;int left,right;int l,r;}Node[N];
总节点(就是最顶上的那一个),它的下标为1,即Node[1]。
对于线段树类型的题(像我们第一节举那个的栗子),一般都会先给我们一个数组。那么,我们首先要做的就是利用这个数组来生成线段树:
- 如果数组的长度为n,那么总节点(Node[1])的最初范围是 {1,n}
- 然后我们将范围一分为二,左孩子的范围就是: {1,mid} ,右孩子的范围就是: {mid+1,n} 。
- 到了孩子节点,我们继续分,以此类推。
- 当到了某个节点,范围变成了 {l,l} (即左右端点相等,表示这个点只代表一个数),就说明这个节点就是叶子节点,我们把数组的值赋给它(范围是啥就把对于的值给它,可不是乱赋值嗷)
- 当某个节点的左右节点都赋完值了,他们的父亲节点的值,就是这两个孩子节点的值的总和。
代码如下:
struct TreeNode {int val;int left, right;int l, r;
}Node[N];
//a是题目给的数组;idx是建树过程中,用于给各个树打上序号
int a[N],idx = 1;
void build_tree(int pos)
{//如果左右端点相同,说明这是叶子节点if (Node[pos].l == Node[pos].r){//把数组的值赋给他Node[pos].val = a[Node[pos].l];return;}//如果不相同,说明范围还能往下分int mid = (Node[pos].l + Node[pos].r) / 2;//左右孩子的下标int left = ++idx, right = ++idx;Node[pos].left = left;Node[pos].right = right;
//左孩子的范围Node[left].l = Node[pos].l;Node[left].r = mid;//右孩子的范围Node[right].l = mid + 1;Node[right].r = Node[pos].r;
//递归到下一层build_tree(left);build_tree(right);//当前节点的值,就是两个孩子的值相加Node[pos].val = Node[left].val + Node[right].val;
}
至于修改,我们也是遍历到对应的点,比如要改的是数组的第3个值,那么我们就找到树中,范围为{3,3}的那个叶子并修改。
void revise(int pos, int l, int x)
{//如果这就是我们要找的范围,修改这个节点的值if (Node[pos].l == l && Node[pos].r == l){Node[pos].val += x;return;}//如果不是,我们就看我们要找的点,是当前点的左孩子还是右孩子int mid = (Node[pos].l + Node[pos].r) / 2;if (l <= mid)revise(Node[pos].left, l, x);else revise(Node[pos].right, l, x);//因为叶子节点被修改,那么上面所有受影响的点的值都要更新int left = Node[pos].left, right = Node[pos].right;Node[pos].val = Node[left].val + Node[right].val;
}
最后是询问一整个区间的和。
int calc(int pos, int l, int r)
{//如果这就是我们要找的范围,返回这个节点的值if (Node[pos].l == l && Node[pos].r == r){return Node[pos].val;}//如果不是,就根据当前节点的范围,判断我们下一步该往左走还是右走int mid = (Node[pos].l + Node[pos].r) / 2;//如果范围全在左边,就直接去左节点if (r <= mid)return calc(Node[pos].left, l, r);else//如果范围全在右边,就直接去右节点if (l > mid)return calc(Node[pos].right, l, r);else{//如果范围既在左又在右,则分开跑。注意这里要修改范围。int x = calc(Node[pos].left, l, mid);int y = calc(Node[pos].right, mid + 1, r);//返回两边的结果return x + y;}
}
到此,这就是线段树:单点修改+区间查询的全部模板代码了。
我们来提交一下这一题:P3374 【模板】树状数组 1(别管为什么题目叫树状数组)
题目总代码:
#include
using namespace std;
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include

哈!满分!太棒啦!
为了对比,我们使用暴力做法看看能不能通过:

可以看出,小型数据还是可以通过的,但是数据大了就会超时。
顺带一提,实际上结构体做线段树不太优,更常见的做法是用数组模拟,具体细节可以看注释,代码如下:
#include
using namespace std;
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include

我们对比一下结构体线段树的用时和内存会发现,数组模拟线段树是优于结构体线段树的,特别是在内存方面
至此,就是线段树的入门篇的全部内容啦!是不是感觉自己学习到了一个很厉害的新知识而开心不已呢?
不过先别急着走鸭,学会了新本领,就要找地方练练,不然岂不是白瞎了!
所以,我们来——
五、写题!我要打写10个!
别被题目吓到了哦,并不是真的有10个题(笑
关于线段树,其实本身并不难,难的是玩出花来。有时候可能会遇到:”我焯?这也能用线段树?“的情况。
所以一定要多写题训练!!
关于写题练习线段树,个人推荐Codeforces平台的EDU的题单,基本各种常见用法基础题型都有:



(注意看,是part1,part2是区间修改型线段树,现在我们讲的程度还做不出来,会自闭的)
A - Segment Tree, part 1 - Codeforces
这一题和前面那一题很像,只不过前面的是:给第i个数加上x,而这里是:把第i个数变成x。
其实就是修改函数的一点点不一样罢了,代码如下:
void revise(int k, int l, int r, int x, int y)
{if (l == r){//这是原来的代码//f[k] += y;//这是现在的代码f[k]=y;return;}int mid = (l + r) / 2;if (x <= mid)revise(k + k, l, mid, x, y);else revise(k + k + 1, mid + 1, r, x, y);f[k] = f[k + k] + f[k + k + 1];
}
还有要注意的一点是,前面那一题我们的范围是1到n,这一题的范围是0到n-1,记得稍加修改。
AC代码
#include
using namespace std;
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
B - Segment Tree, part 1 - Codeforces
这一题中,问的不再是区间和了,而是区间中的最小值。
我们可以想下,区间和中,父亲节点的值是:左孩子节点的值+右孩子节点的值
现在我们要的是区间中的最小值,那么只要把父亲节点的值修改成:min(左孩子节点的值,右孩子节点的值) 。即可。
AC代码
#include
using namespace std;
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
C - Segment Tree, part 1 - Codeforces
这一题中,不光需要你求出区间内的最小值,还要求出区间内有多少个这个最小值。
在这一题,我们可以额外开一个数组cnt。f[i]表示当前区间的最小值是多少,cnt[i]表示当前区间有多少个最小值,很明显的,每个叶子的cnt[i]初始为1。
那么对于父亲节点来说:
- 如果左孩子和右孩子的最小值一样,父亲节点的最小值就是他们,而父亲节点的cnt,就是两个孩子的cnt加在一起。
- 如果左孩子的最小值小于右孩子,父亲节点的最小值就是左孩子最小值,父亲节点的cnt就是左孩子的cnt。
- 如果左孩子的最小值大于右孩子,父亲节点的最小值就是右孩子最小值,父亲节点的cnt就是右孩子的cnt。
在询问操作中,当遇到区间分开的情况,我们也要重复如上操作。
为此,这里我把询问函数的返回值从单个整数改成了一个数对:first是最小值,second是个数cnt。
AC代码
#include
using namespace std;
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
练习部分取了三道题给大家做讲解,这个题单中还有许多其它题等着各位去训练,只要耐心把题都学会,你一定会有所收获!
(你不会真的打算让我讲10题吧,不会吧不会吧)
那么,最后就是我们的——
六、拜拜了您内
码字不易QAQ,如果各位同学看到这里,感觉有所收获的话,能否给一个小小的赞支持一下下,您的支持就是我的动力。
要是能顺便留下您的评论让我知道对您有收获那我就更开心啦。

(希望能被官方推一下吧求求官方哩呜呜呜)
各位再见!如果明天见不到你的话,就祝你早上、中午、晚上都好