文章目录
- 线段树Segment Tree
- 一、线段树介绍
- 二、线段树基础实现
- 三、创建线段树(支持自定义逻辑)
- 四、线段树的区间查询
- 五、线段树的点更新
线段树Segment Tree
一、线段树介绍
在竞赛题目中,线段树(区间树)是经常出现的一类题目。LeetCode上面也有线段树的问题。
普通的树是以一个个元素作为结点的,而线段树是以一个个区间作为结点的,它适用于对区间进行操作的题目。
一个很有意思的问题是——染色问题: 对于一面墙,长度为
,每次选择一段墙进行染色。有多次染色。
次操作后,我们可以看见多少种颜色?
次操作后,我们可以在
其实就是两种操作,染色操作(更新区间)和查询操作(查询区间)。我们很容易的想到可以用数组进行模拟,但是这样的话,这两种操作的复杂度就是 ,
次就是
。对于大数据的问题就无可奈何了。此时,线段树就大有用武之地了。
另一类问题是区间查询: 如果我们不断的更新数据,然后对相应区间的和、最大值、最小值进行统计查询。这种更新和查询有多次。对于这种区间的、动态的查询,用静态的数据结构很麻烦,基于区间的线段树是很有用的。
总结一下线段树的经典操作:
- 更新:更新区间中一个元素或者一个区间的值;
- 查询:查询一个区间中的最大值、最小值、区间和等等。
这两种操作都是 的。同时,我们需要知道的是:线段树面对的区间是固定的,我们不考虑添加新的元素。
对于一个大小为 的数组,我们可以构建如下的一棵树,叶节点就是每个元素——或者说长度为
以求和为例,要查询 的区间和,我们一步就可以查询到了:
当然,不是所有的区间都可以直接得到,比如说查找 的和,我们需要访问两个区间的和并相加,尽管如此,这比对整个区间进行操作仍然快得多。
二、线段树基础实现
有一个结论:线段树不一定是完全二叉树;但是线段树一定是平衡二叉树。这样,线段树就几乎不会出现最坏的情况,它不会退化成一个链表,这就是它的优势。
为什么呢?原因很简单,我们每次将一个区间一分为二,两个区间的元素数量要么相等,要么相差 个元素的数量,这样到叶子结点的时候,左右区间最多相差一层(多
虽然线段树不一定是完全二叉树,但是这样一棵平衡二叉树,我们仍然可以使用数组来表示,就将它看做一棵满二叉树,那些不存在的元素就当做 空 就行了。
尽然使用数组来表示,那么对于一棵满二叉树,有 层,总结点数量是多少呢——
个结点。我们就将其作为
,这样一定可以装下一棵满二叉树。同时,满二叉树最后一层有
个结点,大致等于前面所有的结点数量之和
那么,如果区间有 个元素,数组表示需要开多大的空间,需要多少个结点?假设
,即最后一层的大小为
,那么根据前面的情况,此时我们存储整个二叉树,只需要
当然,通常 不一定等于
,可能为
,这意味着
的空间不一定能够存放叶子结点。最坏情况,叶子结点可能到达下一层,我们加一层,emmmm,假设为满二叉树,则最后一层的结点数量大致等于前面所有的结点数量之和,因此最后我们需要
结论:
个元素的区间,构建线段树最大需要
如果我们使用指针,可以完全避免这种浪费,平时可以这样实现,不过做题的时候用指针容易出错,因此建议用数组实现。
基础的代码如下:
public class SegmentTree<E> {
private E[] data;
private E[] tree;
public SegmentTree(E[] arr) {
data = (E[])new Object[arr.length];
for (int i = 0; i < arr.length; ++i)
data[i] = arr[i];
tree = (E[])new Object[arr.length * 4];
buildSegmentTree(0, 0, data.length - 1); //treeIndex, l, r
}
public int getSize() {
return data.length;
}
public E get(int index) {
if (index < 0 || index >= data.length)
throw new IllegalArgumentException("Index is illegal.");
return data[index];
}
//返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子结点的索引
private int leftChild(int index) { //从0开始
return 2 * index + 1;
}
//返回一个索引所表示的左孩子的索引
private int rightChild(int index) {
return 2 * index + 2;
}
}
三、创建线段树(支持自定义逻辑)
用一个接口 Merger<E>
,可以自定义两个区间“合并”的逻辑。
public interface Merger<E> {
E merge(E a, E b); //将两个E转换为一个E返回去
}
代码如下:
public class SegmentTree<E> {
private E[] data; //原始数据
private E[] tree;
private Merger<E> merger; //融合器
public SegmentTree(E[] arr, Merger<E> merger) {
data = (E[])new Object[arr.length];
for (int i = 0; i < arr.length; ++i)
data[i] = arr[i];
tree = (E[])new Object[arr.length * 4];
this.merger = merger;
buildSegmentTree(0, 0, data.length - 1); //treeIndex, l, r
}
private void buildSegmentTree(int treeIndex, int l, int r) {
if (l == r) { //只有一个元素时,创建叶子结点
tree[treeIndex] = data[l];
return;
}
int leftTreeIndex = leftChild(treeIndex);
int rightTreeIndex = rightChild(treeIndex);
int mid = l + (r - l) / 2;
buildSegmentTree(leftTreeIndex, l, mid); //先构建两棵子树
buildSegmentTree(rightTreeIndex, mid + 1, r);
//区间和就是用+; 最大值最小值就是max,min
//问题是E上面不一定定义了加法; 同时, 我们希望用户根据业务场景自由组合逻辑使用线段树
tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]);
}
......
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append('[');
for (int i = 0; i < tree.length; ++i) {
if (tree[i] != null)
sb.append(tree[i]);
else
sb.append("null");
if (i != tree.length - 1) sb.append(' ');
}
return sb.toString();
}
}
四、线段树的区间查询
比如要在下面的线段树中查询一个区间 ,我们需要分别到左右两边的子树查询,并合并结果。
事实上,区间查询是很简单的。由于每次我们都是将区间折半,因此我们很容易可以算出区间的 以及
。如果我们要查询的区间
在中轴
左边或者右边,就分别到两边的子树去查询;如果
查询
:
的
,到根节点
的
,同时
的
,因此同时向
的左子树查询
,向右子树查询
;
的
,因此到
的右区间查询
;
的
,因此到
的左区间查询
;
- 得到结果。
代码如下:
//返回[queryL, queryR]区间的值
public E query(int queryL, int queryR) {
if (queryL < 0 || queryL >= data.length
|| queryR < 0 || queryR >= data.length || queryL > queryR)
throw new IllegalArgumentException("Index is illegal.");
//treeIndex, l, r, queryL, queryR
return query(0, 0, data.length - 1, queryL, queryR);
}
//在以treeindex为根的线段树[l...r]的范围中,搜索区间[queryL...queryR]的值
//区间范围也可以包装为一个内部类
private E query(int treeIndex, int l, int r, int queryL, int queryR) {
if (l == queryL && r == queryR) //是用户关注的区间
return tree[treeIndex];
int mid = l + (r - l) / 2;
int leftTreeIndex = leftChild(treeIndex);
int rightTreeIndex = rightChild(treeIndex);
if (queryL >= mid + 1) //用户关心的区间与左区间无关, 到右区间去查询
return query(rightTreeIndex, mid + 1, r, queryL, queryR);
else if (queryR <= mid) //用户关心的区间与右区间无关, 到左区间去查询
return query(leftTreeIndex, l, mid, queryL, queryR);
E leftResult = query(leftTreeIndex, l, mid, queryL, mid); //把用户关心的区间也分成两半
E rightResult = query(rightTreeIndex, mid + 1, r, mid + 1, queryR);
return merger.merge(leftResult, rightResult); //两半区间融合用merger
}
//一个小小的测试用例
public static void main(String[] args) {
Integer[] nums = {-2, 0, 3, -5, 2, -1};
SegmentTree<Integer> segTree = new SegmentTree<>(nums, (a, b) -> a + b); //lambda表达式
System.out.println(segTree.query(0, 2)); //计算区间[1,2]的和-2+0+3=1
System.out.println(segTree.query(2, 5)); //-1
System.out.println(segTree.query(0, 5)); //-3从
}
五、线段树的点更新
修改元素,直接修改叶子结点上元素的值,然后从底部往上更新线段树,操作次数也是 。
//将index位置的元素更新为e
public void set(int index, E e) {
if (index < 0 || index >= data.length)
throw new IllegalArgumentException("Index is illegal.");
data[index] = e;
set(0, 0, data.length - 1, index, e); //treeIndex, l,r, index, e
}
//在以treeIndex为根的线段树中更新index的值为e
private void set(int treeIndex, int l, int r, int index, E e) {
if (l == r) { //直接修改叶子结点上元素的值
tree[treeIndex] = e;
return;
}
int mid = l + (r - l) / 2;
int leftTreeIndex = leftChild(treeIndex);
int rightTreeIndex = rightChild(treeIndex);
if (index >= mid + 1)
set(rightTreeIndex, mid + 1, r, index, e);
else //index <= mid
set(rightTreeIndex, l, mid, index, e);
//从底部往上更新线段树
tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]); //两半区间融合用merger
}