【数据结构Java实现】线段树【数组实现】

线段树Segment Tree

一、线段树介绍

在竞赛题目中,线段树(区间树)是经常出现的一类题目。LeetCode上面也有线段树的问题。

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

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

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

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

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

  • 更新:更新区间中一个元素或者一个区间的值;
  • 查询:查询一个区间中的最大值、最小值、区间和等等。

这两种操作都是 O ( l o g n ) O(logn) O(logn)的。同时,我们需要知道的是:线段树面对的区间是固定的,我们不考虑添加新的元素。

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

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

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

二、线段树基础实现

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

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

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

尽然使用数组来表示,那么对于一棵满二叉树,有 h h h 层,总结点数量是多少呢—— 2 h − 1 2^h-1 2h1 个结点。我们就将其作为 2 h 2^h 2h,这样一定可以装下一棵满二叉树。同时,满二叉树最后一层有 2 h − 1 2^{h-1} 2h1 个结点,大致等于前面所有的结点数量之和 2 h − 1 − 1 2^{h-1} - 1 2h11

那么,如果区间有 n n n 个元素,数组表示需要开多大的空间,需要多少个结点?假设 n = 2 k n = 2^k n=2k,即最后一层的大小为 2 k = n 2^k = n 2k=n,那么根据前面的情况,此时我们存储整个二叉树,只需要 2 n 2n 2n 的空间。

当然,通常 n n n 不一定等于 2 k 2^k 2k,可能为 2 k + 1 2^{k+1} 2k+1,这意味着 2 n 2n 2n 的空间不一定能够存放叶子结点。最坏情况,叶子结点可能到达下一层,我们加一层,emmmm,假设为满二叉树,则最后一层的结点数量大致等于前面所有的结点数量之和,因此最后我们需要 4 n 4n 4n 的空间(前面的空间实际上有很大的富余),就可以存储所有的结点。

结论: n n n 个元素的区间,构建线段树最大需要 4 n 4n 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 ] [2,5] [2,5],我们需要分别到左右两边的子树查询,并合并结果。

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

e . g . e.g. e.g. 查询 [ 1 , 2 ] [1,2] [1,2]

  • [ 1 , 2 ] [1,2] [1,2] r = 2 ≤ 3 ( m i d ) r = 2 \le 3(mid) r=23(mid),到根节点 A [ 0...7 ] A[0...7] A[0...7] 的左子树查询;
  • [ 1 , 2 ] [1,2] [1,2] l = 1 ≤ 1 ( m i d ) l = 1 \le 1(mid) l=11(mid),同时 [ 1 , 2 ] [1,2] [1,2] r = 2 > 1 ( m i d ) r = 2 \gt 1(mid) r=2>1(mid),因此同时向 A [ 0...3 ] A[0...3] A[0...3] 的左子树查询 [ 1 , 1 ] [1,1] [1,1],向右子树查询 [ 2 , 2 ] [2,2] [2,2]
  • [ 1 , 1 ] [1,1] [1,1] l = 1 > 0 ( m i d ) l= 1\gt 0(mid) l=1>0(mid),因此到 A [ 0...1 ] A[0...1] A[0...1] 的右区间查询 [ 1 , 1 ] [1,1] [1,1] [ 2 , 2 ] [2,2] [2,2] r = 2 ≤ 2 ( m i d ) r = 2 \le 2(mid) r=22(mid),因此到 A [ 2...3 ] A[2...3] A[2...3] 的左区间查询 [ 2 , 2 ] [2,2] [2,2]
  • 得到结果。

代码如下:

//返回[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 ) O(log_2n) O(log2n)

//将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
}
©️2020 CSDN 皮肤主题: 游动-白 设计师:上身试试 返回首页