「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」html
点赞关注,再也不迷路,你的支持对我意义重大!前端
🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一块儿成长。(联系方式 & 入群方式在 GitHub)git
这篇文章的内容会涉及如下前置 / 相关知识,贴心的我都帮你准备好了,请享用~github
上一篇文章,咱们使用了「前缀和 + 差分」技巧解决了 303. 区域和检索 - 数组不可变 【题解】 。简单来讲,咱们开辟了一个前缀和数组,存储「元素全部前驱节点的和」。利用这个前缀和数组,能够很快计算出区间 [i, j] 的和: 算法
参考代码:后端
class NumArray(nums: IntArray) {
private val sum = IntArray(nums.size + 1) { 0 }
init {
for (index in nums.indices) {
sum[index + 1] = sum[index] + nums[index]
}
}
fun sumRange(i: Int, j: Int): Int {
return sum[j + 1] - sum[i] // 注意加一
}
}
复制代码
此时,区间查询的时间复杂度是 ,空间复杂度是 ,整体不错。可是,正如前言提到的,目前咱们只考虑了静态数据的场景,若是数据是可修改的会怎么样?数组
咱们须要修正前缀和数组了,例如:将 更新为 10,那么 都须要更新,这个更新操做的时间复杂度为 。要是一次更新操做影响却是不大,但若是更新操做很频繁,算法的均摊时间复杂度就劣化了。为了解决动态数据的场景,就出现了 “线段树” 这种数据结构,它和其余数据结构的复杂度对好比下表:markdown
数据结构 | 构建结构 | 区间更新 | 区间查询 | 空间复杂度 |
---|---|---|---|---|
遍历,不使用数据结构 | O(1) | O(1) | O(n) | O(1) |
前缀和数组 | O(n) | O(n) | O(1) | O(n) |
线段树 | O(n) | O(lgn) | O(lgn) | O(4*n)或O(2*n) |
树状数组 | O(n) | O(lgn) | O(lgn) | O(n) |
能够看到「前缀和数组」的优点是 查询,但不适合动态数据的场景,而线段树彷佛学会了中庸之道,线段树平衡了「区间查询」和「单点更新」两种操做的时间复杂度。它是怎么作到的呢?数据结构
这是由于前缀和数组是线性逻辑结构,修改操做必定须要花费线性时间。为了使得修改操做优于线性时间,那么必定须要构建非线性逻辑结构。app
通常的二叉树节点上存储的是一个值,而线段树上的节点存储的是一个区间 上的聚合信息(例如最大值 / 最小值 / 和),而且子节点的区间合并后正好等同于父节点的区间。例如,对于父节点的区间是 ,那么左子节点的区间是 ,右子节点的区间是 。叶子节点也是一个区间,不过区间端点 ,是一个单点区间。
—— 图片引用自 www.jianshu.com/p/4d9da6745… —— yo1ooo 著
从线段树的逻辑定义能够看出:线段树(Segment Tree)本质上是一棵平衡二叉搜索树,也就是说它同时具有二叉搜索树和平衡二叉树的性质:
二叉搜索树:任意节点的左子树上的节点值都小于根节点的值,右子树上的节点值都大于根节点的值;
平衡二叉树(Balance Tree):任意节点的左右子树高度差不大于 1。
一般,一个二叉树的物理实现能够基于数组,也能够基于链表。不过,由于线段树自己也是平衡二叉树,除了二叉树最后一层节点外,线段树的其它层是满的,因此采用数组的实现空间利用率更高。
那么,怎么实现一个基于数组的线段树呢?其实都是固定套路了:采用数组存储方式时,树的根节点能够分配在数组第 [0] 位,也能够分配在第 [1] 位,两种方式没有明显的区别,主要是计算子节点 / 父节点下标的公式有所不一样:
根节点存储在第 位:
- 对于第 位上的节点,第 位是左节点,第 位是右节点
- 对于第 位上的节点,第 位是父节点
根节点存储在第 位(建议采用,在计算父节点时比较简洁):
- 第 位不存储,根节点存储在第 位
- 对于第 位上的节点,第 位是左节点,第 位是右节点
- 对于第 位上的节点,第 位是父节点
通用实现参考代码:
class SegmentTree<E>(
private val data: Array<E>,
private val merge: (e1: E?, e2: E?) -> E
) {
private val tree: Array<E?>
init {
// 开辟 4 * n 空间
tree = Array<Any?>(4 * data.size) { null } as Array<E?>
buildSegmentTree(0, 0, data.size - 1)
}
/**
* 左子节点的索引
*/
fun leftChildIndex(treeIndex: Int) = 2 * treeIndex + 1
/**
* 右子节点的索引
*/
fun rightChildIndex(treeIndex: Int) = 2 * treeIndex + 2
/**
* 建树
* @param treeIndex 当前线段树索引
* @param left 区间左端点
* @param right 区间右端点
*/
private fun buildSegmentTree(treeIndex: Int, left: Int, right: Int) {
// 见第 3 节
}
/**
* 取原始数据第 index 位元素
*/
fun get(index: Int): E {
if (index < 0 || index > data.size) {
throw IllegalArgumentException("Index is illegal.")
}
return data[index]
}
/**
* 区间查询
* @param left 区间左端点
* @param right 区间右端点
*/
fun query(left: Int, right: Int): E {
if (left < 0 || left >= data.size || right < 0 || right >= data.size || left > right) {
throw IllegalArgumentException("Index is illegal.");
}
// 见第 3 节
}
/**
* 单点更新
* @param index 数据索引
* @param value 新值
*/
fun set(index: Int, value: E) {
if (index < 0 || index >= data.size) {
throw IllegalArgumentException("Index is illegal.");
}
// 见第 3 节
}
}
复制代码
其中 buildSegmentTree()、query()、update() 三个方法的实现咱们在下一节讲。这里咱们着重分析下 为何线段树须要分配 的空间?
todo
理解了线段树的逻辑定义和实现,这一节,我带你一步步实现线段树的三个基本操做 —— 建树 & 区间查询 & 更新。
建树是利用原始数据构建出线段树的数据结构,咱们采用的是 自顶向下 的构建方式,对于线段树上的每个节点,咱们先构建出它的左右子树,而后再根据左右两个子节点来构建当前节点。对于叶子节点(单点区间),只根据当前节点来构建。
参考代码:
init {
tree = Array<Any?>(4 * data.size) { null } as Array<E?>
buildSegmentTree(0, 0, data.size - 1)
}
/**
* 建树
* @param treeIndex 当前线段树索引
* @param treeLeft 节点区间左端点
* @param right treeRight 节点区间右端点
*/
private fun buildSegmentTree(treeIndex: Int, treeLeft: Int, treeRight: Int) {
if (treeLeft == treeRight) {
// 叶子节点
tree[treeIndex] = merge(data[treeLeft], null)
return
}
val mid = (treeLeft + treeRight) ushr 1
val leftChild = leftChildIndex(treeIndex)
val rightChild = rightChildIndex(treeIndex)
// 构建左子树
buildSegmentTree(leftChild, treeLeft, mid)
// 构建右子树
buildSegmentTree(rightChild, mid + 1, treeRight)
tree[treeIndex] = merge(tree[leftChild], tree[rightChild])
}
复制代码
建树复杂度分析:
区间查询是查询一段指望区间的结果,基本思路是递归查询子区间的结果,再经过合并子区间的结果来获得指望区间的结果。逻辑以下:
/**
* 区间查询
*
* @param left 区间左端点
* @param right 区间右端点
*/
fun query(left: Int, right: Int): E {
if (left < 0 || left >= data.size || right < 0 || right >= data.size || left > right) {
throw IllegalArgumentException("Index is illegal.");
}
return query(0, 0, data.size - 1, left, right) // 注意:取数据长度
}
/**
* 区间查询
*
* @param treeIndex 当前节点索引
* @param dataLeft 当前节点左区间
* @param dataRight 当前节点右区间
* @param left 区间左端点
* @param right 区间右端点
*/
private fun query(treeIndex: Int, dataLeft: Int, dataRight: Int, left: Int, right: Int): E {
if (dataLeft == left && dataRight == right) {
// 查询范围正好是线段树节点区间范围
return tree[treeIndex]!!
}
val mid = (dataLeft + dataRight) ushr 1
val leftChild = leftChildIndex(treeIndex)
val rightChild = rightChildIndex(treeIndex)
// 查询区间都在左子树
if (right <= mid) {
return query(leftChild, dataLeft, mid, left, right)
}
// 查询区间都在右子树
if (left >= mid + 1) {
return query(rightChild, mid + 1, dataRight, left, right)
}
// 查询区间横跨两棵子树
val leftResult = query(leftChild, dataLeft, mid, left, mid)
val rightResult = query(rightChild, mid + 1, dataRight, mid + 1, right)
return merge(leftResult, rightResult)
}
复制代码
查询复杂度分析:
单点更新就是在数据变化以后适当调整线段树的结构,基本思路是递归地修改子区间的结果,再经过合并子区间的结果来更新指望当前节点的结果。逻辑以下:
/**
* 单点更新
*
* @param index 数据索引
* @param value 新值
*/
fun set(index: Int, value: E) {
if (index < 0 || index >= data.size) {
throw IllegalArgumentException("Index is illegal.");
}
data[index] = value
set(0, 0, data.size - 1, index, value) // 注意:取数据长度
}
private fun set(treeIndex: Int, dataLeft: Int, dataRight: Int, index: Int, value: E) {
if (dataLeft == dataRight) {
// 叶子节点
tree[treeIndex] = value
return
}
// 先更新左右子树,再更新当前节点
val mid = (dataLeft + dataRight) ushr 1
val leftChild = leftChildIndex(treeIndex)
val rightChild = rightChildIndex(treeIndex)
if (index <= mid) {
set(leftChild, dataLeft, mid, index, value)
} else if (index >= mid + 1) {
set(rightChild, mid + 1, dataRight, index, value)
}
tree[treeIndex] = merge(tree[leftChild], tree[rightChild])
}
复制代码
更新复杂度分析:
到这里,咱们的线段树数据结构就实现完成了,完整代码以下:SegmentTree
给你一个数组 nums ,请你完成两类查询,其中一类查询要求更新数组下标对应的值,另外一类查询要求返回数组中某个范围内元素的总和。
class NumArray(nums: IntArray) {
fun update(index: Int, `val`: Int) {
}
fun sumRange(left: Int, right: Int): Int {
}
}
复制代码
这道题与 【题 303】 是差很少的,区别在于数组是否可变,属于 动态数据 的场景。上一节,咱们已经实现了一个通用的线段树数据结构,咱们直接使用就好啦。
参考代码:
class NumArray(nums: IntArray) {
private val segmentTree = SegmentTree<Int>(nums.toTypedArray()) { e1: Int?, e2: Int? ->
if (null == e1)
e2!!
else if (null == e2)
e1
else
e1 + e2
}
fun update(index: Int, `val`: Int) {
segmentTree.set(index, `val`)
}
fun sumRange(left: Int, right: Int): Int {
return segmentTree.query(left, right)
}
}
复制代码
有点东西~~没几行代码就搞定了,运行结果也比采用前缀树的方法优秀更多。可是单纯从作题的角度,若是每作一道题都要编写这么一大串 SegmentTree 代码,彷佛就太蛋疼了。有没有别的变通方法呢?
定义 SegmentTree 数据结构太花力气,这一节,咱们来讨论一种不须要定义 SegmentTree 的通用解题框架。这个解法仍是很巧妙的,它虽然不严格知足线段树的定义(不是二叉搜索树,但依然是平衡二叉树),可是实现更简单。
参考代码:
class NumArray(nums: IntArray) {
private val n = nums.size
private val tree = IntArray(2 * n) { 0 } // 注意:线段树大小为 2 * n
init {
// 构建叶子节点
for (index in n until 2 * n) {
tree[index] = nums[index - n]
}
// 依次构建父节点
for (index in n - 1 downTo 0) {
tree[index] = tree[index * 2] + tree[index * 2 + 1]
}
}
fun update(index: Int, `val`: Int) {
// 一、先直接更新对应的叶子节点
var treeIndex = index + n
tree[treeIndex] = `val`
while (treeIndex > 0) {
// 二、循环更新父节点,根据当前节点是偶数仍是奇数,判断选择哪两个节点来合并为父节点
val left = if (0 == treeIndex % 2) treeIndex else treeIndex - 1
val right = if (0 == treeIndex % 2) treeIndex + 1 else treeIndex
tree[treeIndex / 2] = tree[left] + tree[right]
treeIndex /= 2
}
}
fun sumRange(i: Int, j: Int): Int {
var sum = 0
var left = i + n
var right = j + n
while (left <= right) {
if (1 == left % 2) {
sum += tree[left]
left++
}
if (0 == right % 2) {
sum += tree[right]
right--
}
left /= 2
right /= 2
}
return sum
}
}
复制代码
这种实现的优势是只须要 2 * n 空间,而不须要 4 * n 空间下面解释下代码。代码主要由三个部分组成:
构建线段树须要初始化一个 空间的数组,采用 自底向上 的方式来构建整棵线段树。首先,构建叶子节点,叶子节点的位于数组区间 ,随后再根据子节点的结果来构建父节点(下标为 的节点,左子节点下标: ,右子节点下标: )。参考如下示意图:
区间查询是查询一段指望区间的结果,相对于常规方法构造的线段树,这种线段树的区间查询过程相对较难理解。基本思路是递归地寻找可以表明该区间的节点。逻辑以下:
一、一开始的区间查询等同于线段树数组 之间的若干个叶子节点 的合并,咱们须要向上一层寻找可以表明这些节点的父节点;
二、对于节点 ,它的左子节点下标: ,右子节点下标: ,这意味着全部左子节点下标是偶数,全部右子节点下标是奇数;
三、 和 则是寻找父节点,若是 指针是奇数,那么 指针节点必定是一个右节点,此时 节点就没法直接表明 指针节点,因而只能单独加上这个 “落单” 的节点。同理,若是 指针是偶数,那么 指针节点必定是一个左节点,,此时 节点就没法直接表明 指针节点,因而只能单独加上这个 “落单” 的节点;
四、最后循环退出前 ,说明当前节点的区间(去除 “落单” 的节点)正好是所求的区间,直接加上。而且下一躺循环 必定大于 ,跳出循环。
单点更新就是在数据变化以后适当调整线段树的结构,基本思路是:先更新目标位置对应的节点,递归地更新父节点。须要注意根据当前节点的索引是偶数或奇数,来肯定选择哪两个节点来合并为父节点。
例如,更新的节点是 “a” 节点,它在线段树数组索引 index 是偶数(下标为 6),那么它的父节点是 “ab” 节点须要经过合并 tree[index] + tree[index+1] 来得到的。
创做不易,你的「三连」是丑丑最大的动力,咱们下次见!