「本文已參與好文召集令活動,點擊查看:後端、大前端雙賽道投稿,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] 來得到的。
創做不易,你的「三連」是醜醜最大的動力,咱們下次見!