最近在刷题的时候,遇到一个涉及到线段树的问题。之前没接触过,看了几遍题解才看懂。这里简单介绍下入门的过程。
高级数据结构,线段树入门
一、线段树的基本思想
线段树是一种常用来维护区间信息的数据结构,它适用于对区间内进行单点查询、更新、求最值等操作,且时间复杂度能控制到 O(logN)。它的构建过程用到了二分的思想,通过不断的二分将区间分成两段,并分别对应左孩子和右孩子。
下面举例来说明:比如有一个数组 [1, 2, 5, 7, 8, 10,12,18],它的长度是 8,所以范围是 [1, 8]。如果用二分的思想来分解构造出的线段树如下所示:
接下来我们来看看怎么定义线段树的数据结构。通常有两种方式,一种方式是定义一个 class,一种方式是使用连续的数组。首先我们来看下自定义 class 的方式,这里使用 Python 代码:
class SegTree:
这种定义方式比较直接,但遍历起来稍麻烦一点。而第二种方式是使用连续的数组。从上图我们构造的线段树可以看出,抛开叶子结点,树是一个满二叉树,所以可以使用连续的数组来存储,且父子结点的关系为
parent[i]
使用数组时需要注意,叶子结点其实就是对应的给定数组的值,但数组的长度不一定能满足满二叉树叶子结点的个数,这个时候代码编写上就比较灵活了,一般有两种方式:
使用满二叉树的数组个数,不足处补 0
这种思路的其实相对比较好理解,因为给定的数组都需要放到叶子结点,那如果想要树是一棵满二叉树,则叶子结点的个数必须是 2^n。所以我们需要找到第一个大于等于数组长度的 2 的 n 次幂。对于求第一个大于等于数组长度的 2 的 n 次幂的方法有很多,通过几个位运算就能实现的,可以参考 Java HashMap 的源码,也可以看 Integer 的 highestOneBit 方法,代码如下(这里不解释具体原因):
public static int highestOneBit(int i) {
而用一个我们比较好理解的方法,如下:
1
找到这个数值之后,就可以进行初始化:
# 因为 n 是第一个大于或等于 len(nums) 的 2 次幂,它是等于它之前所有结点和 + 1 的
根据推导规律,在保证父子结点关系的情况下,初始化 2*n 长度的数组
因为如果长度为 n 的数组都需要放到叶子结点上,则它的上层有 n/2 个结点,再上层 n/4…,根据等比求和公式很容易得出所有结点个数一定小于 2n。所以我们整个线段树数组的值设置为 2n 就足够使用了。代码如下:
n = len(nums)
self._n = n
self._tree = [0] * (n <1)
# 将最后 n 位放置到叶子结点,也就是数组的最后 n 位
for i in range(n, len(self._tree)):
self._tree[i] = nums[i - n]
for i in range(n - 1, 0, -1):
# 父结点 = 左结点(父结点序号*2) + 右结点(父结果序号*2+1)
self._tree[i] = self._tree[i <1] + self._tree[i <1 | 1]
此外,线段树常用的方法有:
# 将第 i 个位置的数值更新为 val
这里就不一一实现这些方法,通过两个题目来具体实践下。
二、实践
首先来看一个简单点的题目:
给定一个整数数组 nums,求出数组从索引 i 到 j (i ≤ j) 范围内元素的总和,包含 i, j 两点。
update(i, val) 函数可以通过将下标为 i 的数值更新为 val,从而对数列进行修改。
示例:
Given nums = [1, 3, 5]
sumRange(0, 2) -> 9
update(1, 2)
sumRange(0, 2) -> 8
说明:
数组仅可以在 update 函数下进行修改。
你可以假设 update 函数与 sumRange 函数的调用次数是均匀分布的。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/range-sum-query-mutable
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
这个题目其实很简单,首先抛开线段树的思想,其实直接通过 Python list 就可以实现,也是可以 AC 的:
from typing
那如果使用线段树呢?这里我们使用数组存储的方式来实现。首先线段数数组初始化可以直接套用上面说到的两种方式,关键是 update 与 sumRange。而对于上面提到的两种方式其实 update 和 sumRange 在解决的的时候实际代码是一样的。这里我们以第二种方式初始化方式为例(毕竟会减少空间的消耗):
class NumArray:
接下搂我们来看下 update 方法:
def update(self, i: int, val: int) -> None:
update 方法其实思路也比较简单,先去更新叶子结点上的数值,并记录改变差值,然后依次更新父结点的记录和。我们再来看下 sumRange:
def sumRange(self, l: int, r: int) -> int:
我个人认为 sumRange 比 update 要难理解一点,它的主要思想在于如果当前要求值的范围比当前结点记录的范围要大(即既需要左孩子,也需要右孩子),则找父结点,如果只需要当前结点,就加上当前结点。
至此,这个题目就解决了。使用数组的话,代码在理解上会复杂一点,主要是要对父子关系的灵活运用。
接下来,我们来看另外一个题目,我也是在刷这个题目时了解到线段树这个数据结构:
给定一个整数数组 nums,返回区间和在 [lower, upper] 之间的个数,包含 lower 和 upper。
区间和 S(i, j) 表示在 nums 中,位置从 i 到 j 的元素之和,包含 i 和 j (i ≤ j)。
说明:
最直观的算法复杂度是 O(n2) ,请在此基础上优化你的算法。
示例:
输入: nums = [-2,5,-1], lower = -2, upper = 2,
输出: 3
解释: 3个区间分别是: [0,0], [2,2], [0,2],它们表示的和分别为: -2, -1, 2。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/count-of-range-sum
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
这个题目在分析时,我们需要将式子做一个转换,一旦做了这个转换,基本就成功一半了。转换关系如下:
lower <= sum(i, j) <=
当我们分析到这一步时,我们可以发现,其实我们要求的就是当给定一个数值,然后求在一个范围内的数值中,在指定范围的数值有多少个。比如题目中的例子:nums = [-2,5,-1], lower = -2, upper = 2,前缀和数组为 [-2, 3, 2],因此我们可以遍历前缀和数组,如果是从后往前遍历,则是利用 lower + prefixSum[i-1] <= prefixSum[j] <= upper + prefixSum[i-1] 这个转换,如果是从前往后遍历,则是使用 prefixSum[j] - upper <= prefixSum[i-1] <= prefixSum[j] - lower 转换(主要是需要确定范围,所以固定的是式子左右两边的变量)。
不管使用哪种方式,其实思路都是一样的。我们首先找到一个基准的前缀和,然后从当前这个基准向前(或向后)找在范围内的个数,找到之后,将当前这个基准加入到某种数据结构中,在这个数据结构里记录的就是当前基准以前所有的前缀和。而且我们需要这个数据结构来保证,在这个数据结构中查询在指定范围内的数值个数时,性能很高,此外因为还会不断做插入,也要保证插入的性能。
因此,我们明确了,解题需要前缀和数组,和一个能在区间内快速做查询和插入(也可以是更新)的数据结构。显然和我们线段树的适用范围是很相似的。直接看代码吧。
# 使用线段树,第一种移动方式,即 lower + prefixSum[i-1] <= prefixSum[j] <= upper + prefixSum[i-1]
代码中需要注意 allNumber 和 nums_map 的理解,这里线段树主要记录的是在 left,right 范围内的数值个数,因为前缀和可能比较散乱,所以对数值做了映射处理,将它映射到一个连接的数组中。
在题解中也看到了另外一种解决方法,使用的是有序数组+二分来代替的这种线段树这种数据结构。代码如下:
# 作者:fan-cai
三、总结
线段树采用了二分的思想,适用在区间范围内做查询、更新,见到类似在区间内获取和、最值等问题,都可以使用线段树
个人认为线段树问题难点在于如果构造线段树。而如果采用连续数组的方式来存储,要充分利用数组要存放在叶子结点这一特性
leetcode官方题解比较难理解(可能因为都是高手写的),关键还是需要多看代码,多 debug
看过关于线段树的其实实现版本,有做懒更新与懒插入,后续有机会再详细总结下
灵活使用位运行,2*n与2*n+1,以及快速求大于 n 的第一个 2 的 n 次幂等
四、参考资料
为了解决上面说的第二个问题,花了几天时间,主要是连别人的题解都看不懂,参考了一些别人的解决思路,最后 debug 官方的 java 实现版本,才终于理解:
https://oi-wiki.org/ds/seg/
https://leetcode-cn.com/problems/count-of-range-sum/solution/qu-jian-he-de-ge-shu-by-leetcode-solution/
https://leetcode-cn.com/problems/count-of-range-sum/solution/xian-ren-zhi-lu-ru-he-xue-xi-ke-yi-jie-jue-ben-ti-/
https://leetcode-cn.com/problems/count-of-range-sum/solution/qian-zhui-he-xian-duan-shu-by-halfrost-2/ (其实这个人是写得比较清楚的,但他在画图解释插入的过程没有太说清楚,看完官方代码之后回过头来看就很清晰了)
https://blog.csdn.net/qq_28468707/article/details/103284027 (这篇对于为什么可以用二分,讲得比较清楚)