(转载)【数据结构Java实现】线段树【数组实现】

285 阅读5分钟
原文链接:https://blog.csdn.net/myRealization/article/details/105130003

一、线段树介绍

LeetCode上面也有线段树的问题。

普通的树是以一个个元素作为结点的,而线段树是以一个个区间作为结点的,它适用于对区间进行操作的题目。

一个很有意思的问题是——染色问题: e.g. 对于一面墙,长度为 n,每次选择一段墙进行染色。有多次染色。 m 次操作后,我们可以看见多少种颜色? m 次操作后,我们可以在 [i,j] 区间中看见多少种颜色?

其实就是两种操作,染色操作(更新区间)和查询操作(查询区间)。我们很容易的想到可以用数组进行模拟,但是这样的话,这两种操作的复杂度就是 O(n), m 次就是 O(mn)。对于大数据的问题就无可奈何了。此时,线段树就大有用武之地了。

另一类问题是区间查询: e.g. 如果我们不断的更新数据,然后对相应区间的和、最大值、最小值进行统计查询。这种更新和查询有多次。对于这种区间的、动态的查询,用静态的数据结构很麻烦,基于区间的线段树是很有用的。

总结一下线段树的经典操作:

更新:更新区间中一个元素或者一个区间的值; 查询:查询一个区间中的最大值、最小值、区间和等等。 这两种操作都是 O(logn)的。同时,我们需要知道的是:线段树面对的区间是固定的,我们不考虑添加新的元素。

对于一个大小为 8 的数组,我们可以构建如下的一棵树,叶节点就是每个元素——或者说长度为 1 的区间,根节点则是整个区间:

20200326235927811.png

以求和为例,要查询 [4,7] 的区间和,我们一步就可以查询到了:

20200327000450455.png

当然,不是所有的区间都可以直接得到,比如说查找 [2,5] 的和,我们需要访问两个区间的和并相加,尽管如此,这比对整个区间进行操作仍然快得多。

image.png

二、线段树基础实现

有一个结论:线段树不一定是完全二叉树;但是线段树一定是平衡二叉树。这样,线段树就几乎不会出现最坏的情况,它不会退化成一个链表,这就是它的优势。

为什么呢?原因很简单,我们每次将一个区间一分为二,两个区间的元素数量要么相等,要么相差 1 个元素的数量,这样到叶子结点的时候,左右区间最多相差一层(多 1 个元素的那边最后就深一层)。这符合平衡二叉树的定义。

虽然线段树不一定是完全二叉树,但是这样一棵平衡二叉树,我们仍然可以使用数组来表示,就将它看做一棵满二叉树,那些不存在的元素就当做 空 就行了。

屏幕截图 2021-12-14 144001.png 结论: n 个元素的区间,构建线段树最大需要 4n 的空间。

如果我们使用指针,可以完全避免这种浪费,平时可以这样实现,不过做题的时候用指针容易出错,因此建议用数组实现。

基础的代码如下:

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();
	} 
}
 
四、线段树的区间查询
比如要在下面的线段树中查询一个区间 [2,5],我们需要分别到左右两边的子树查询,并合并结果。

事实上,区间查询是很简单的。由于每次我们都是将区间折半,因此我们很容易可以算出区间的 [l,r] 以及 mid。如果我们要查询的区间 target 在中轴 mid 左边或者右边,就分别到两边的子树去查询;如果 target 跨越了中轴,就需要同时到两边的子树查询。

image.png

代码如下:
//返回[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从
}
 


五、线段树的点更新
修改元素,直接修改叶子结点上元素的值,然后从底部往上更新线段树,操作次数也是 O ( l o g 2 n ) 。
 

//将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
}