前置内容
学习线段树前,你需要掌握二叉搜索树,只补充一个内容,就是关于二叉搜索树如何编号。
二叉搜索树的根节点编号为1,对于每个节点,假如其编号为N,它的左儿子编号为2N,右儿子编号为2N+1。因此,整个二叉搜索树的编号如下:
上图当中,结点上方的数字是结点的编号,后续为了简单,把编号写在结点内不。
有读者可能要问了,为什么3的儿子是6和7,而不是4和5呢?这是因为虽然节点4和节点5不存在,但是仍然应该为他们保留4和5这2个编号,你可以把这棵树看成这样:
线段树的概念
线段树,英文名称是Segment Tree,其本质也是一个二叉搜索树,区别在于线段树的每一个节点记录的都是一个区间,每个区间都被平均分为2个子区间,作为它的左右儿子。比如说区间[1,10],被分为区间[1,5]作为左儿子,区间[6,10]作为右儿子:
为什么要设计这样奇怪的数据结构呢?
线段树主要适用于某些相对罕见的应用场景:
比如给定了若干元素,要求统计出不同区间范围内,元素的个数。
现在我们已经知道了什么是线段树,那么看一个利用线段树的例子。
线段树的存储与建造
这是一个序列:
现在我们要用它完成一个区间求和的任务。
区间求和就是指求序列中一段区间的所有元素之和。比如说上面的序列,区间[1,5]的和为元素1+元素2+元素3+元素4+元素5,也就是14。再举一个例子,区间[9,10]的和为9。
在学习线段树的概念的时候,我们就知道线段树的每个节点都存储了一个区间。比如说对于[1,10]这个节点,也就是这棵线段树的根节点,那么它的值为1+5+1+3+4+2+0+9+0+9=34。看我们把这棵树填完:
(当一个区间的左右边界已经相等时,比如[1,1],表示这个区间内只有一个元素了,此时不能再分割,因此它就没有左右儿子节点了)
现在就让我们用代码实现线段树:
【代码片段 1】 用一个类Node表示线段树的节点:
class Node {
int l; // l是区间左边界
int r; // r是区间右边界
int sum; // sum是区间元素和
public Node (int l, int r, int sum){
this.l = l;
this.r = r;
this.sum = sum;
}
}
【代码解析 1】 线段树的任意节点都有3个属性:
-
区间的左边界l
-
区间的右边界r
-
区间的元素和sum
比如说在上面的线段树中,区间[1,10]这个元素:
-
左边界为1
-
右边界为10
-
元素和为34
【代码片段 2】 定义元素个数、原序列和线段树
static int n = 10; // n是元素个数
static int[] array = {0, 1, 5, 1, 3, 4, 2, 0, 9, 0, 9};
// array是原序列(第一个0是占array[0]位的)
static Node[] tree = new Node[4*n];
static void initTree (){
for(int i = 0; i < tree.length; i++){
tree[i] = new Node(0, 0, 0, 0);
}
}
【代码解析 2】 首先我们在上文已经定义了元素个数和原序列。他们的值如下:
元素个数为10个
原序列为[0,1,5,1,3,4,2,0,9,0,9]
现在问题在于,存储线段树的数组应该开多大的空间?根据证明发现,一个有n个元素的序列,所对应的线段树至少需要大小为4n的数组来存储。这一类证明网上有很多,读者可以自行查阅一下。
我们用inittree这个函数进行线段树初始化(tree数组初始值为null,不初始化会报错,我在这个地方卡了好久)
【代码片段 3】 updateNode函数负责更新节点的值:
static void updateNode (int num) { // num是当前节点序号
tree[num].sum = tree[num * 2].sum + tree[num * 2 + 1].sum;
}
【代码解析 3】 仔细观察前面的线段树可以发现,每一个节点的值都等于其左右儿子值的和。我们刚刚学会,一个编号为n的节点,其左右儿子分别为2n和2n+1。因此我们把num的值更新为2num+2num+1,也就是其左右儿子的和。
【代码片段 4】 build函数建造线段树:
static void build (int l, int r, int num) { // 建树
tree[num].l = l;
tree[num].r = r;
if (l == r) { // l = r说明到达叶子节点
tree[num].sum = array[l];
return;
}
int mid = (l + r) / 2;
build(l, mid, num * 2); // 递归左儿子
build(mid + 1, r, num * 2 + 1); // 递归右儿子
updateNode(num);
}
线段树的单点修改
现在假如我们需要把第6个元素从2修改为3:
那么就会有很多的区间相应的改变。比如说区间[5,7],从4+2+0=6变成了4+3+0=7。现在让我们手动模拟一下线段树的单点修改过程。这里假设我们需要把元素6从2变成3:
首先,从根节点开始遍历,发现含有元素6的区间是根节点的右儿子,与左儿子没有关系。因此将修改目标锁定到右儿子:
第二步,发现含有6的区间是左儿子,因此把目标放到左儿子上:
第三步同理:
第四步同理:
此时发现这是一个叶子节点,因此对它进行更新,从2变成3:
返回到上一层:
接下去同理:
然后我们跳过演示,读者可以自己试试看用同样的方法修改这棵树。最后修改完应该是这样的:
根节点最后应该从34变成35,我经常会忘记修改它的值,大家千万不要忘记修改它。
演示完以后我们分析一下时间复杂度。如果我们使用线段树修改元素,每次都是折半操作,相当于二分查找的速度,时间复杂度仅仅是对数级别,也就是 。
【代码片段 5】 modify函数实现单点修改:
static void modify (int i, int value, int num) { // 把元素i修改为值value
if (tree[num].l == tree[num].r) { // 到达叶子节点
tree[num].sum = value;
return;
}
int mid = (tree[num].l + tree[num].r) / 2;
if (i <= mid) {
modify(i, value, num * 2); // 递归左儿子
}
else {
modify(i, value, num * 2 + 1); // 递归右儿子
}
updateNode(num);
}
【代码解析 5】 这一段代码也不是很难。每一次我们都从根开始递归遍历。我们先判断要更改的元素属于当前节点的左儿子还是右儿子,并且递归到该节点。递归结束后更新当前节点的值。假如遍历到叶子节点,说明我们已经遍历到了想要修改的元素,那么我们直接把该节点的值修改为value就可以了。
线段树的区间修改
首先让我们明确一下区间修改的概念:
单点修改,大致是以下两个步骤:
-
找到需要修改的点
-
修改这个点
而区间修改是这样两个步骤:
-
找到需要修改的区间
-
修改这段区间内的所有点
好的,概念我们明白了,现在要知道如何实现这个功能。首先我们看一看区间修改可能的情况:
-
需要修改的区间包含在儿子之内:
为大家画个图:
-
原文地址:https://blog.csdn.net/bjweimengshu/article/details/106990678