线段树(Segment Tree)也叫区间树,其本质上是一种二分搜索树,不一样点在于线段树中每一个节点再也不是存放单纯的元素,而是存放了一个能够表示区间的值,一般是该区间合并后的值。而且每一个区间会被平均分为2个子区间,做为它的左右子节点。好比说根节点存放了区间 [1,10]
,那么就会被分为区间 [1,5]
做为左子节点,区间 [6,10]
做为右子节点。java
例如,咱们能够将这样一个数组所表示的区间构形成线段树:数组
而且指定区间合并规则为区间内的元素求和,那么构造出来的线段树表示以下:bash
关于线段树的一个经典问题就是:区间染色。假设有一面墙,长度为 n,每次选择一段儿墙进行染色。在 m 次操做后,咱们能够在 [i, j]
区间内看见多少中颜色?数据结构
对于这个问题,咱们可使用一个数组来实现:app
对于染色操做(更新区间)咱们能够遍历数组找到目标区间进行染色,时间复杂度是 $O(n)$。对于查询操做(查询区间)也是遍历数组便可,一样时间复杂度为 $O(n)$。显然用线性结构来解决这类问题的时间复杂度要更高一些,此时线段树就派上用场了,由于树形结构的时间复杂度一般在 $O(logn)$。ide
除此以外,线段树的另外一个经典问题就是:区间查询。查询一个区间 [i, j]
的最大值和最小值,或者区间数字之和。例如,在实际业务中很常见的基于区间的统计查询:2017年注册用户中消费最高的用户?消费最少的用户?学习时间最长的用户?某个太空区间中天体总量?学习
对于静态区间数据(区间内的数据不会发生变化)来讲,是比较好解决的,但以上所提到的问题都是动态的区间数据(区间内的数据在不断的变化),此时线段树就是一个比较好的选择。测试
经过以上的介绍,咱们能总结出线段树的两个核心操做:ui
[i, j]
的最大值、最小值,或者区间数字之和线段树虽然不像堆那样是一棵彻底二叉树,但线段树因为其特性知足平衡二叉树(左右子树高度相差不超过1),因此依然可使用数组进行表示。咱们能够将其看作是一颗满二叉树,空节点就当作叶子节点便可。以下示例:this
既然能够用数组来表示一棵线段树,那么若是区间有 n 个元素,此时应该建立多大容量的数组来构建一颗线段树呢?对于这个问题,咱们先来看如何求一棵满二叉树的节点:假设这棵树有 h 层,那么这棵树就一共有 $2^h-1$ 个节点(大约是 $2^h$)。对于最后一层($h - 1$ 层)来讲,就有 $2^{(h-1)}$ 个节点。所以,最后一层的节点数大体等于前面全部层节点之和。
了解了如何求满二叉树的节点数量后,回到以前的问题,若是区间有 n 个元素,此时应该开多大空间的数组?咱们能够分红两种状况:
一般来讲,咱们的线段树不考虑添加元素,即区间固定(区间内的数据能够是不固定的),那么使用 $4n$ 的静态空间便可。这也是广泛构造线段树时,使用的一个通用值。除非对内存有严格要求,不然通常开辟 $4n$ 的数组空间便可。并且对于内存有要求的状况下,通常也不会采用数组来表示,此时链式结会是更优的选择。
接下来,咱们就实现一下线段树的基础结构代码:
package tree; /** * 线段树 - 基于数组的表示实现 * * @author 01 * @date 2021-01-27 **/ public class SegmentTree<E> { /** * 保存原始数组,即须要被构形成线段树的区间 */ private E[] data; /** * 线段树的数组表示 */ private E[] tree; public SegmentTree(E[] arr) { this.data = (E[]) new Object[arr.length]; System.arraycopy(arr, 0, this.data, 0, arr.length); // 开辟 4n 的数组空间用于构造线段树 this.tree = (E[]) new Object[4 * arr.length]; } 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) { return 2 * index + 1; } /** * 返回彻底二叉树的数组表示中,一个索引所表示的元素的右子节点的索引 */ private int rightChild(int index) { return 2 * index + 2; } }
在本小节中,咱们来根据以前实现的基础代码,完成建立线段树逻辑的编写。须要说明一下的是,在本例中,线段树每一个节点所存储的元素是区间合并后的值。具体的实现代码以下:
/** * 用户自定义的区间合并逻辑 */ private final Merger<E> merger; public SegmentTree(E[] arr, Merger<E> merger) { this.merger = merger; this.data = (E[]) new Object[arr.length]; System.arraycopy(arr, 0, this.data, 0, arr.length); // 开辟 4n 的数组空间用于构建线段树 this.tree = (E[]) new Object[4 * arr.length]; // 构建线段树,传入根节点索引,以及区间的左右端点 buildSegmentTree(0, 0, data.length - 1); } /** * 在treeIndex的位置建立表示区间[left...right]的线段树 */ private void buildSegmentTree(int treeIndex, int left, int right) { // 区间中只有一个元素,表明递归到底了 if (left == right) { tree[treeIndex] = data[left]; return; } int leftTreeIndex = leftChild(treeIndex); int rightTreeIndex = rightChild(treeIndex); // 计算中间点,须要避免整型溢出 int mid = left + (right - left) / 2; // 构建左子树 buildSegmentTree(leftTreeIndex, left, mid); // 构建右子树 buildSegmentTree(rightTreeIndex, mid + 1, right); // 对于两个区间的合并规则是与业务相关的,因此要调用用户自定义的逻辑来完成 tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]); } /** * 遍历打印树中节点中值信息。 * * @return String */ @Override public String toString() { StringBuilder res = new StringBuilder(); res.append('['); for (int i = 0; i < tree.length; i++) { if (tree[i] != null) { res.append(tree[i]); } else { res.append("null"); } if (i != tree.length - 1) { res.append(", "); } } res.append(']'); return res.toString(); }
用户传入的 Merger
是一个接口,其定义以下:
package tree; /** * 合并器接口 * * @author 01 * @date 2021-01-27 **/ public interface Merger<E> { /** * 用户自定义的区间合并逻辑 * * @param a 区间a * @param b 区间b * @return 合并后的结果 */ E merge(E a, E b); }
最后,咱们来编写一个简单的测试用例进行一下测试:
package tree; /** * 测试SegmentTree * * @author 01 */ public class SegmentTreeTests { public static void main(String[] args) { Integer[] nums = {-2, 0, 3, -5, 2, -1}; SegmentTree<Integer> segTree = new SegmentTree<>( nums, Integer::sum // 对两个区间中的值进行求和 ); System.out.println(segTree); } }
输出结果以下:
[-3, 1, -4, -2, 3, -3, -1, -2, 0, null, null, -5, 2, null, null, null, null, null, null, null, null, null, null, null]
-3
,由于对整个数组的求和结果就是 -3
。左子节点为 1
,由于 -2 + 0 + 3 = 1
。右子节点为 -4
,同理,由于 -5 + 2 + -1 = -4
,其他以此类推。结果符合预期,证实咱们实现的线段树没有问题。例如,咱们要对以下这棵线段树查询 [2, 5]
这个区间:
因为咱们以前传入的 Merger
实现的是求和逻辑,那么这至关于查询2 ~ 5区间全部元素的和。从根节点开始往下,咱们知道分割位置,左节点查询 [2, 3]
,右节点查询 [4, 5]
,找到两个节点以后合并就能够了。
具体的实现代码以下:
/** * 查询区间[queryLeft, queryRight]的值,如[2, 5] */ public E query(int queryLeft, int queryRight) { if (queryLeft < 0 || queryLeft >= data.length || queryRight < 0 || queryRight >= data.length || queryLeft > queryRight) { throw new IllegalArgumentException("Index is illegal"); } return query(0, 0, data.length - 1, queryLeft, queryRight); } /** * 在以treeIndex为根的线段树中[left...right]的范围里,搜索区间[queryLeft...queryRight]的值 */ private E query(int treeIndex, int left, int right, int queryLeft, int queryRight) { // 找到了目标区间 if (left == queryLeft && right == queryRight) { return tree[treeIndex]; } int leftTreeIndex = leftChild(treeIndex); int rightTreeIndex = rightChild(treeIndex); // 计算中间点,须要避免整型溢出 int mid = left + (right - left) / 2; if (queryLeft >= mid + 1) { // 目标区间不在左子树中,查找右子树 return query(rightTreeIndex, mid + 1, right, queryLeft, queryRight); } else if (queryRight <= mid) { // 目标区间不在右子树中,查找左子树 return query(leftTreeIndex, left, mid, queryLeft, queryRight); } // 目标区间一部分在右子树中,一部分在左子树中,则两个子树都须要找 E leftResult = query(leftTreeIndex, left, mid, queryLeft, mid); E rightResult = query(rightTreeIndex, mid + 1, right, mid + 1, queryRight); // 找到目标区间的值,将其合并后返回 return merger.merge(leftResult, rightResult); }
进行一个简单的测试:
public static void main(String[] args) { Integer[] nums = {-2, 0, 3, -5, 2, -1}; SegmentTree<Integer> segTree = new SegmentTree<>( nums, Integer::sum // 对两个区间中的值进行求和 ); System.out.println(segTree.query(0,2)); System.out.println(segTree.query(2,5)); System.out.println(segTree.query(0,5)); }
输出结果以下:
1 -1 -3
咱们使用线段树来解决区间相关的问题,主要是针对区间内的数据是动态变化的状况,若是是静态区间通常不须要用到线段树。因此在本小节,咱们就来实现线段树中的更新操做。
实际上线段树中的更新操做,本质上是在二分查找。由于根据线段树的特性,待更新的目标节点确定是一个叶子节点,咱们只须要找到这个叶子节点并进行更新便可。咱们查找待更新节点的依据是数组的索引,而数组的索引是从 0 ~ n 有序的,因此在一个有序的区间中查找某个特定的值,妥妥的就是二分查找了。
知道了咱们在更新线段树中某个节点时,要找的这个待更新节点是一个叶子节点,而且找到这个叶子节点的过程本质上是一个二分查找,那么这个思路就很清晰了。
首先,将找到叶子节点的条件做为递归的退出条件。而后计算中间点,并将线段树数组划分为 [left...mid]
和 [mid+1...right]
两个区间。接着判断要找的数组索引落在哪一个区间,就继续往哪一个区间递归查找。最后,将区间的值进行合并。如此一来,就完成了目标节点的更新操做。
具体的实现代码以下:
/** * 将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为根的线段树中更新index的值为e */ private void set(int treeIndex, int left, int right, int index, E e) { // 找到了叶子节点 if (left == right) { // 进行更新 tree[treeIndex] = e; return; } int mid = left + (right - left) / 2; // 将线段树数组划分为[left...mid]和[mid+1...right]两个区间 int leftTreeIndex = leftChild(treeIndex); int rightTreeIndex = rightChild(treeIndex); if (index >= mid + 1) { // index在右子树 set(rightTreeIndex, mid + 1, right, index, e); } else { // index在左子树 set(leftTreeIndex, left, mid, index, e); } tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]); }
在本文的最后,咱们来使用本身实现的线段树解决一个Leetcode上的307号问题:
该问题的主要需求是更新数组下标对应的值,以及查询数组中某个区间内的元素总和。像这种对区间内数据有更新需求的,会使得区间内数据动态变化的,就很适合使用线段树来解决。具体的实现代码以下:
package tree.solution; import tree.SegmentTree; /** * Leetcode 307. Range Sum Query - Mutable * https://leetcode.com/problems/range-sum-query-mutable/description/ */ class NumArray { private SegmentTree<Integer> segTree; public NumArray(int[] nums) { if (nums.length != 0) { Integer[] data = new Integer[nums.length]; for (int i = 0; i < nums.length; i++) { data[i] = nums[i]; } segTree = new SegmentTree<>(data, Integer::sum); } } public void update(int i, int val) { if (segTree == null) { throw new IllegalArgumentException("Error"); } segTree.set(i, val); } public int sumRange(int i, int j) { if (segTree == null) { throw new IllegalArgumentException("Error"); } return segTree.query(i, j); } }