使用Java实现线段树

线段树结构

线段树的每个节点都存储着一个线段,在Java中,该线段泛指数组区间

根节点的范围最广,是整个数组;
其左儿子是整个数组的左半部分,其右儿子是整个数组的右半部分;
以此类推,每个子节点都是它父节点的一半;
直到最后的叶子节点,区间只有一个元素。

如下有一个数组,和它对应的线段树:
数组和线段树

用二分法构建出来的树有啥用?

对于数组容器,要想修改某一位置的元素,可以通过索引直接定位,复杂度O(1);
但是,想要查找某元素,复杂度为O(n);

对于修改元素,数组的O(1)要快于二分的O(log n),
对于查找元素,二分的O(log n)要快于数组的O(n)。

线段树,就是以二分的方式,中和了修改、查找的平均复杂度,毕竟使用数组这种容器,多数都是用来遍历,查找的,特别是在频繁查询,数组容量又特别大的情况下,O(log n)要明显快于O(n)的。

示例

对于上述的数组 [1,3,5,7,9,11] 现在有两个操作

  • 随机修改其中的一个元素
  • 统计任意区间内元素之和

方式一

什么也不做,直接使用原生方式实现

int[] array = {1, 3, 5, 7, 9, 11};

//修改元素
void modify(int index, int newValue){
	array[index] = newValue;
}

//查询元素
int sum(int start, int end){
	int sum = 0;
	for(int i = start; i <= end; i++){
		sum += array[i];
	}
	return sum;
}

这种方式修改的复杂度为O(1),求和的复杂度是O(n);
对于修改m次,复杂度升级为O(m),求和复杂度升级为O(nm)。

方式二

使用额外的数组,每个位置记录之前所有元素之和
累和数组

int[] array = {1, 3, 5, 7, 9, 11};
int[] addSum;

//预先初始化
void init(){
	addSum = new int[array.length];
	addSum[0] = array[0];
	for(int i = 1; i < array.length; i++){
		addSum[i] = addSum{i - 1} + array[i];
	}
}

void modify(int index, int newValue){
	array[index] = newValue;
}

int sum(int start, int end){
	return addSum[end] - addSum[start] + array[start];
}

这样看上去貌似实现了修改和查询都是O(1),但是,如果先修改0号索引的值,再次查询之前,就需要将addSum数组整个翻新,复杂度又升为O(n)。

方式三

构建线段树,使每个节点保存当前区间的和,相当于分段保存元素之和

线段树

1.初始化

线段树不是真的树,它是用数组模拟出来的,有点像二叉堆。
那我们需要创建多长的初始数组呢?这个和原始数组大小有关,找规律可以得出原始数组元素个数与所需 “节点” 数量,有如下关系

原始数组长度最多节点个数
11
23
3~47
5~815
…………
2n-1+1 ~ 2n2n+1 - 1

上面的关系用笨方法(不会位运算)可以这么做

//@nums:原始数组长度
//@return:最多需要多少“节点”
int treeNodes(int nums){
    for(int i = 1; ; i <<= 1)
        if(i >= nums)
            return 2 * i - 1;
}

于是就完成了第一步——开辟树数组

private int[] array = {1, 3, 5, 7, 9, 11};
private int[] tree;

public void initTree(){
	tree = new int[treeNodes(array.length)];
}
private int treeNodes(int nums){
    for(int i = 1; ; i <<= 1)
        if(i >= nums)
            return 2 * i - 1;
}

2.构建线段树

接下来构建线段树(往tree数组里填值)

线段树

  1. 按上图给节点编号,如果令当前节点的编号为 a,那么左儿子的编号就是 2a + 1,右儿子的编号是 2a + 2注意:灰色节点只是占位用的,不是真实存在的
  2. 线段树的叶子节点恰好是原始数组的某个值
  3. tree 数组的索引 对应 array 数组的区间

根据以上三点,就可以构建出线段树啦

/**
* @start @end 控制array数组区间索引
* @index tree数组的索引,与[start, end]对应,可观察上图
*/
private void buildTree(int start, int end, int index){
	//区间只剩一个元素,说明到达了叶子节点
    if(start == end){
        tree[index] = array[start];
    }else{
    	//二分
        int mid = (start + end) / 2;
        //计算tree当前索引的 左、右儿子的索引
        int left = 2 * index + 1;
        int right = 2 * index + 2;
        buildTree(start, mid, left);
        buildTree(mid+1, end, right);
        //回溯加和
        tree[index] = tree[left] + tree[right];
    }
}

此段代码难点在于递归函数的理解和回溯加和的操作,用文字表述其过程就是

首先不断二分,直至区间只剩一个元素,此时该区间必然对应线段树的叶子节点——走if代码块,随后递归回溯;
当left、right都回溯完毕,证明当前节点的左、右两个儿子都已经存好了值,作为他们的父节点,我只需加和即可;之后当前函数结束,为上级调用者加和做准备。

至此,线段树构建完毕,需要额外的空间 O(2n)

3.修改操作

修改操作,即修改线段树的叶子节点,但是别忘了,还要更新该叶子节点到根节点的和(访问该叶子节点的整条路径)。

/**
* @start @end 控制array区间的索引
* @index tree数组与[start, end]对应的索引
* @oriIndex array数组待更新的索引位置
* @val array[oriIndex]的新值
*/
public void update(int start, int end, int index, int oriIndex, int val){
	//如果区间只剩一个元素——走到了叶子节点的位置
    if(start == end){
        tree[index] = array[oriIndex] = val;
    }else{
        int mid = (start + end) / 2;
        int left = 2 * index + 1;
        int right = 2 * index + 2;
        //如果oriIndex在左半部分
        if(oriIndex <= mid){
            update(start, mid, left, oriIndex, val);
        }else{
            update(mid+1, end, right, oriIndex, val);
        }
        //回溯更新路径节点
        tree[index] = tree[left] + tree[right];
    }
}

更新路径的节点不会花费额外的时间,因为更新操作是回溯过程中顺便就完成了的。
因此修改单个元素的时间花费为O(log n)

4.求和操作

有时线段树可以显著提升区间查询的速度,这得益于它分段保存元素之和的特点;
对于此示例而言,如果要查询[2, 4]之间的和,我们只需遍历如下的绿色节点即可
查询
注意第三层的绿色节点,区间 [3, 4] 恰好是所求区间 [2, 4]的完全子区间,这样就可以直接返回该节点的值,而无需继续遍历它的子节点。

/**
* @start @end 控制array区间的索引
* @index tree数组与[start, end]对应的索引
* @from @to 查询区间
*/
public int sum(int start, int end, int index, int from, int to){
	//如果当前区间完全偏离查询区间,直接返回——①
    if(start > to || from > end){
        return 0;
    }
    //如果查询区间完全包含当前区间,直接返回
    if(from <= start && to >= end){
        return tree[index];
    }
    //二分查找
    int mid = (start + end) / 2;
    int left = 2 * index + 1;
    int right = 2 * index + 2;
    return sum(start, mid, left, from, to) + sum(mid+1, end, right, from, to);
}

对于注释中的①再用图来直观的显示一下
超出范围
如图,当 [start, end][from, to] 毫无重叠时,就不必继续二分搜索了。

学习总结

以上用了简单的例子,引出了线段树这种数据结构,实际上,线段树的应用非常灵活,对于处在数据结构与算法边缘的小白我,能实现一颗线段树已经迈出了 “艰难的” 第一步,离灵活运用还有很长的路要探索。

第一次写博客,自己都觉得有些地方描述逻辑不通,缺乏层次结构,还望大家多多指出错误和优化。

©️2020 CSDN 皮肤主题: 深蓝海洋 设计师:CSDN官方博客 返回首页