|
| 1 | +<h1 align=center>SegmentTree 线段树(区间树)</h1> |
| 2 | +<div align="center"> |
| 3 | +<image src="https://img.shields.io/badge/Github-LiYangSir-brightgreen"> |
| 4 | +<image src="https://img.shields.io/badge/author-teaUrn-green"> |
| 5 | +<image src="https://img.shields.io/badge/Language-Java-orange"> |
| 6 | +<image src="https://img.shields.io/badge/Version-1.0-blue"> |
| 7 | +</div> |
| 8 | + |
| 9 | +----- |
| 10 | + |
| 11 | +## 1、为什么使用线段树 |
| 12 | + |
| 13 | +  相信大家都见过一个经典的比赛题目(区间染色):在一个数组结构当中,对某一端区间不断的进行染色。在m次操作后,在这个数组中包含了多少种颜色。以及在m次操作后我们在某一区间可以看见多少种颜色。 |
| 14 | + |
| 15 | +  其中主要包含两种操作,染色操作(更新区间)以及查询操作(查询区间)。我们发现我们主要是针对区间进行操作,而且我们只关心区间的颜色种类的个数,并不关心它的颜色是什么。 |
| 16 | + |
| 17 | +  如果我们采用最基本的数组遍历的操作。使用数组实现,两种操作的时间复杂度均为O(N)级别的操作。这样我们的线段树显得就尤为重要。 |
| 18 | + |
| 19 | +  还有一种经典问题就是区间查询:例如我们在一段数组内查询某一个区间的最大值、最小值或者区间数字和。针对区间进行操作的我们都要想要线段树这种数据结构。由于我们线段树采用的树结构,所以时间复杂度就会很低。 |
| 20 | + |
| 21 | +**时间复杂度:** |
| 22 | + |
| 23 | +||数组实现|线段树实现| |
| 24 | +|:---:|:---:|:---:| |
| 25 | +|更新操作|O(N)|O(log(N))| |
| 26 | +|查询操作|O(N)|O(log(N))| |
| 27 | + |
| 28 | +## 2、线段树的基本结构 |
| 29 | +  在线段树中我们并不考虑增加元素和删除元素,我们只考虑在已有的数组结构中构建线段树这种数据结构。 |
| 30 | + |
| 31 | +<div align=center> |
| 32 | +<img src=https://markdown-liyang.oss-cn-beijing.aliyuncs.com/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/8-SegmentTree/%E5%9F%BA%E6%9C%AC%E7%BB%93%E6%9E%84.png width=70% alt=基本结构> |
| 33 | +</div> |
| 34 | +<br/> |
| 35 | + |
| 36 | +  我们可以看出,一个数组分成了很多区间,基本上都是对半劈开,小伙伴可能会问了,结构变得更加复杂了。在这里正是运用了计算机领域的一句话 : |
| 37 | +> **用空间换取时间** |
| 38 | +
|
| 39 | + |
| 40 | +**注意:** 每一个区间并不是存储一个区间,而是一个值。例如我们以求和为例,==A[0 ··· 3]== 节点存储的是这个区间的和。如果是最大值,那么存储的就是这一个区间的最大值。 |
| 41 | + |
| 42 | +**例子:** 当我们查询A[2-5]区间的和,那我们只需要知道A[2-3]和A[4-5]的和就可以啦,我们并不需要遍历2-5的所有数据。 |
| 43 | + |
| 44 | +### 2.1、线段树的一般结构 |
| 45 | + |
| 46 | +  上面的例子当中,正好数组大小正好是 $2^3 = 8$,所以看起来像是~~满二叉树~~,其实不是。我们看下面的图: |
| 47 | + |
| 48 | +<div align=center> |
| 49 | +<img src=https://markdown-liyang.oss-cn-beijing.aliyuncs.com/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/8-SegmentTree/%E5%9F%BA%E6%9C%AC%E7%BB%93%E6%9E%84-%E9%9D%9E.png width=70% alt=基本结构> |
| 50 | +</div> |
| 51 | +<br/> |
| 52 | +  看得出来,这并不是一个满的二叉树,也不是一个完全二叉树。但是这是一个平衡二叉树。 |
| 53 | + |
| 54 | +> **平衡二叉树:** 最大深度和最小深度之间的差值为 1 。 |
| 55 | +
|
| 56 | +### 2.2、线段树存储所需空间 |
| 57 | + |
| 58 | +  我们知道线段树是我们损失空间来减小时间的,那我们实际过程中,需要多少空间呢。下面我们就来推导一下。 |
| 59 | +|层数|节点个数| |
| 60 | +|:---:|:---:| |
| 61 | +|0|1| |
| 62 | +|1|2| |
| 63 | +|2|4| |
| 64 | +|3|8| |
| 65 | +|···|···| |
| 66 | +|h - 1|$2^{h-1}$| |
| 67 | + |
| 68 | +  对于满的二叉树来说,$h$ 层一共有$2^h-1$个节点。所以大约等于 $2^h$ 个。而且我们看到最后一层的个数为$2^{h-1}$正好等于总个数的一半。于是我们可以得到: |
| 69 | + |
| 70 | +> **最后一层的节点数大致等于(差1)前面所有层数节点之和。** |
| 71 | +
|
| 72 | +  假设我们的数组有 n 个元素,按照满的二叉树的形式来看,最底下一层元素的个数为 n。那么他上面的所有节点和也为n,所以对应的总个数为==2 * n==。这只是还在满二叉树的情况下。如果我们的元素不是2的整数次幂,那么它就构成了平衡二叉树,所以需要向下扩充一层,这一层的个数等于我们上面所有节点的和也就是 2 * n。加起来就是 4 * n。我们可以得出结论: |
| 73 | + |
| 74 | +> **对于存储 n 个元素的线段树,我们需要开辟 4 倍的空间大小。** |
| 75 | +
|
| 76 | +  虽然我们浪费了大量的空间来存储,但是我们在时间复杂度上有着巨大的提升。随着社会的发展,存储已经变得不再是问题,问题变成了时间速度问题,所以说**牺牲空间提升时间**是一件非常有意义的事情。 |
| 77 | + |
| 78 | +## 3、线段树的实现 |
| 79 | + |
| 80 | +### 3.1、Merge 函数 |
| 81 | + |
| 82 | +  在实际过程中底层并不确定用户对线段树执行什么操作,求和,最大值还是最小值操作,所以这里我们引入merge函数,用户来指定他们需要对线段树执行什么操作。 |
| 83 | +**Merge接口函数:** |
| 84 | +```java |
| 85 | +public interface Merger<E> { |
| 86 | + E merge(E a, E b); |
| 87 | +} |
| 88 | +``` |
| 89 | + |
| 90 | +### 3.2、构造函数 |
| 91 | +  在构造函数里面需要用户传入用户设定的Merge实例对象,以对线段树进行用户想要类型的操作。 |
| 92 | +**构造函数实现:** |
| 93 | +```java |
| 94 | +public SegmentTree(E[] arr, Merger<E> merger) { |
| 95 | + |
| 96 | + this.merger = merger; // 指定merge实例 |
| 97 | + |
| 98 | + data = (E[])new Object[arr.length]; //拷贝数组 |
| 99 | + System.arraycopy(arr, 0, data, 0, arr.length); |
| 100 | + |
| 101 | + tree = (E[])new Object[4 * arr.length]; //开启4倍的空间 |
| 102 | + buildSegmentTree(0, 0, data.length - 1); |
| 103 | +} |
| 104 | +``` |
| 105 | +### 3.3、基本操作函数 |
| 106 | +  主要包含数据的接口操作,例如getSize和get操作。 |
| 107 | +```java |
| 108 | +public int getSize() { |
| 109 | + return data.length; |
| 110 | +} |
| 111 | + |
| 112 | +public E get(int index) { |
| 113 | + if (index < 0 || index >= data.length) |
| 114 | + throw new IllegalArgumentException("Index is illegal"); |
| 115 | + return data[index]; |
| 116 | +} |
| 117 | +``` |
| 118 | +  这里我们线段树的底层依然是数组,所以我们依然可以按照我们之前的[ MaxHeap 最大堆 ](/MaxHeap/README.md)那种结构来实现。 |
| 119 | +```java |
| 120 | +private int leftChild(int index) { |
| 121 | + return index * 2 + 1; |
| 122 | +} |
| 123 | + |
| 124 | +private int rightChild(int index) { |
| 125 | + return index * 2 + 2; |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +### 3.4、构建线段树 |
| 130 | + |
| 131 | +  我们需要在treeIndex索引的位置创建数组区间。这里我们采用递归方式进行构建树结构。为什么采用递归的形式呢,因为我们需要慢慢回朔到顶层,而且在构建的时候我们需要采用**后序遍历**的形式,这样才能保证节点最后合并孩子节点。 |
| 132 | + |
| 133 | +```java |
| 134 | +private void buildSegmentTree(int treeIndex, int l, int r) { |
| 135 | + if (l == r) { |
| 136 | + tree[treeIndex] = data[l]; |
| 137 | + return; |
| 138 | + } |
| 139 | + int mid = l + (r - l) / 2; //中间值 |
| 140 | + buildSegmentTree(leftChild(treeIndex), l, mid); |
| 141 | + buildSegmentTree(rightChild(treeIndex), mid + 1, r); |
| 142 | + tree[treeIndex] = merger.merge(tree[leftChild(treeIndex)], tree[rightChild(treeIndex)]); |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +### 3.5、查询操作 |
| 147 | + |
| 148 | +  查询操作主要涉及的问题就是区间查找匹配的问题,所以在递归的问题匹配主要分为三种情况: |
| 149 | ++ 查询区间完全在右孩子区间上 |
| 150 | ++ 查询区间完全在左孩子区间上 |
| 151 | ++ 查询区间部分在左孩子区间上,部分在右孩子区间上 |
| 152 | + |
| 153 | +```java |
| 154 | +public E query(int queryL, int queryR) { |
| 155 | + if (queryL < 0 || queryL >= data.length || queryR < 0 || queryR >= data.length) |
| 156 | + throw new IllegalArgumentException("index is illegal"); |
| 157 | + |
| 158 | + return query(0,0,data.length - 1, queryL, queryR); |
| 159 | +} |
| 160 | + |
| 161 | +private E query(int treeIndex, int l, int r, int queryL, int queryR) { |
| 162 | + if (l == queryL && r == queryR) |
| 163 | + return tree[treeIndex]; |
| 164 | + int mid = l + (r - l) / 2; //中间值 |
| 165 | + int leftTreeIndex = leftChild(treeIndex); //左索引 |
| 166 | + int rightTreeIndex = rightChild(treeIndex); //右索引 |
| 167 | + |
| 168 | + if (queryL >= mid + 1) //去右子树查找 |
| 169 | + return query(rightTreeIndex, mid + 1, r, queryL, queryR); |
| 170 | + else if (queryR <= mid) // 去左子树查找 |
| 171 | + return query(leftTreeIndex, l, mid, queryL, queryR); |
| 172 | + E left = query(leftTreeIndex, l, mid, queryL, mid); // 分开查询 |
| 173 | + E right = query(rightTreeIndex, mid + 1, r, mid + 1, queryR); |
| 174 | + return merger.merge(left, right); //将分开的元素合并 |
| 175 | +} |
| 176 | +``` |
| 177 | + |
| 178 | +### 3.6、更改操作 |
| 179 | + |
| 180 | +  当我们对数组的某一个索引位置进行修改,我们需要先找到这个树结构的索引位置,然后不断回朔,重新合并。 |
| 181 | + |
| 182 | +**程序实现:** |
| 183 | +```java |
| 184 | +public void set(int index, E e) { |
| 185 | + if (index < 0 || index >= data.length) |
| 186 | + throw new IllegalArgumentException("Index is illegal"); |
| 187 | + data[index] = e; |
| 188 | + set(0, 0, data.length - 1, index, e); |
| 189 | +} |
| 190 | + |
| 191 | +private void set(int treeIndex, int l, int r, int index, E e) { |
| 192 | + if (l == r){ |
| 193 | + tree[treeIndex] = e; |
| 194 | + return; |
| 195 | + } |
| 196 | + int mid = l + (l - r) / 2; |
| 197 | + if (index > mid) |
| 198 | + set(rightChild(treeIndex), mid + 1, r, index, e); |
| 199 | + else |
| 200 | + set(leftChild(treeIndex), l, mid, index, e); |
| 201 | + tree[treeIndex] = merger.merge(tree[leftChild(treeIndex)], tree[rightChild(treeIndex)]); |
| 202 | +} |
| 203 | +``` |
| 204 | + |
| 205 | +## 最后 |
| 206 | + |
| 207 | +更多精彩内容,大家可以转到我的主页:[曲怪曲怪的主页](http://quguai.cn:8090/) |
| 208 | + |
| 209 | +或者关注我的微信公众号:**TeaUrn** |
| 210 | + |
| 211 | +或者扫描下方二维码进行关注。里面有惊喜等你哦。 |
| 212 | + |
| 213 | +**源码地址**:可在公众号内回复 **数据结构与算法源码** 即可获得。 |
| 214 | + |
| 215 | +<img src="https://markdown-liyang.oss-cn-beijing.aliyuncs.com/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BA%8C%E7%BB%B4%E7%A0%81.jpg" width=40%> |
| 216 | + |
0 commit comments