面試被問到線段樹,已經這麼捲了嗎?

「本文已參與好文召集令活動,點擊查看:後端、大前端雙賽道投稿,2萬元獎池等你挑戰!html

點贊關注,再也不迷路,你的支持對我意義重大!前端

🔥 Hi,我是醜醜。本文 GitHub · Android-NoteBook 已收錄,這裏有 Android 進階成長路線筆記 & 博客,歡迎跟着彭醜醜一塊兒成長。(聯繫方式 & 入羣方式在 GitHub)git


前言

  • 上一篇文章 咱們討論了前綴和技巧,前綴和是一種很是適合處理 區間查詢 問題的算法思惟。文章最後我提出了一個問題:對於動態數據的區間查詢問題,還可使用前綴和技巧嗎,有沒有更好的方法?
  • 在這篇文章裏,我將一種介紹更加高效的區間查詢數據結構 —— 線段樹(Segment Tree)。若是能幫上忙,請務必點贊加關注,這真的對我很是重要。

目錄

前置知識

這篇文章的內容會涉及如下前置 / 相關知識,貼心的我都幫你準備好了,請享用~github


1. 前綴和數組的缺點

上一篇文章,咱們使用了「前綴和 + 差分」技巧解決了 303. 區域和檢索 - 數組不可變 【題解】 。簡單來講,咱們開闢了一個前綴和數組,存儲「元素全部前驅節點的和」。利用這個前綴和數組,能夠很快計算出區間 [i, j] 的和: n u m s [ i , j ] = p r e S u m [ j + 1 ] p r e S u m [ i ] nums[i, j] = preSum[j + 1] - preSum[i] 算法

參考代碼:後端

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] // 注意加一
    }
}
複製代碼

此時,區間查詢的時間複雜度是 O ( 1 ) O(1) ,空間複雜度是 O ( n ) O(n) ,整體不錯。可是,正如前言提到的,目前咱們只考慮了靜態數據的場景,若是數據是可修改的會怎麼樣?數組

咱們須要修正前綴和數組了,例如:將 n u m [ 2 ] num[2] 更新爲 10,那麼 p r e S u m [ 3 , . . . , 7 ] preSum[3,...,7] 都須要更新,這個更新操做的時間複雜度爲 O ( n ) O(n) 。要是一次更新操做影響卻是不大,但若是更新操做很頻繁,算法的均攤時間複雜度就劣化了。爲了解決動態數據的場景,就出現了 「線段樹」 這種數據結構,它和其餘數據結構的複雜度對好比下表: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)

能夠看到「前綴和數組」的優點是 O ( 1 ) O(1) 查詢,但不適合動態數據的場景,而線段樹彷佛學會了中庸之道,線段樹平衡了「區間查詢」和「單點更新」兩種操做的時間複雜度。它是怎麼作到的呢?數據結構


2. 什麼是線段樹?

這是由於前綴和數組是線性邏輯結構,修改操做必定須要花費線性時間。爲了使得修改操做優於線性時間,那麼必定須要構建非線性邏輯結構app

2.1 線段樹的邏輯定義

通常的二叉樹節點上存儲的是一個值,而線段樹上的節點存儲的是一個區間 [ L , R ] [L, R] 上的聚合信息(例如最大值 / 最小值 / 和),而且子節點的區間合併後正好等同於父節點的區間。例如,對於父節點的區間是 [ L , R ] [L, R] ,那麼左子節點的區間是 [ L , ( L + R ) / 2 ] [L, (L+R)/2] ,右子節點的區間是 [ ( L + R ) / 2 , R ] [(L+R)/2, R] 。葉子節點也是一個區間,不過區間端點 L = = R L == R ,是一個單點區間。

—— 圖片引用自 www.jianshu.com/p/4d9da6745… —— yo1ooo 著

從線段樹的邏輯定義能夠看出:線段樹(Segment Tree)本質上是一棵平衡二叉搜索樹,也就是說它同時具有二叉搜索樹和平衡二叉樹的性質:

  • 二叉搜索樹:任意節點的左子樹上的節點值都小於根節點的值,右子樹上的節點值都大於根節點的值;

  • 平衡二叉樹(Balance Tree):任意節點的左右子樹高度差不大於 1。

2.2 線段樹的物理實現

一般,一個二叉樹的物理實現能夠基於數組,也能夠基於鏈表。不過,由於線段樹自己也是平衡二叉樹,除了二叉樹最後一層節點外,線段樹的其它層是滿的,因此採用數組的實現空間利用率更高。

那麼,怎麼實現一個基於數組的線段樹呢?其實都是固定套路了:採用數組存儲方式時,樹的根節點能夠分配在數組第 [0] 位,也能夠分配在第 [1] 位,兩種方式沒有明顯的區別,主要是計算子節點 / 父節點下標的公式有所不一樣:

根節點存儲在第 [ 0 ] [0] 位:

  • 對於第 [ i ] [i] 位上的節點,第 [ 2 i + 1 ] [2 * i +1] 位是左節點,第 [ 2 i + 2 ] [2 * i + 2] 位是右節點
  • 對於第 [ i ] [i] 位上的節點,第 [ ( i 1 ) / 2 ] [(i-1) / 2] 位是父節點

根節點存儲在第 [ 1 ] [1] (建議採用,在計算父節點時比較簡潔):

  • [ 0 ] [0] 位不存儲,根節點存儲在第 [ 1 ] [1]
  • 對於第 [ i ] [i] 位上的節點,第 [ 2 i ] [2 * i] 位是左節點,第 [ 2 i + 1 ] [2 * i + 1] 位是右節點
  • 對於第 [ i ] [i] 位上的節點,第 [ i / 2 ] [i / 2] 位是父節點

通用實現參考代碼:

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() 三個方法的實現咱們在下一節講。這裏咱們着重分析下 爲何線段樹須要分配 4 n 4n 的空間?

todo


3. 線段樹的基本操做

理解了線段樹的邏輯定義和實現,這一節,我帶你一步步實現線段樹的三個基本操做 —— 建樹 & 區間查詢 & 更新。

3.1 建樹

建樹是利用原始數據構建出線段樹的數據結構,咱們採用的是 自頂向下 的構建方式,對於線段樹上的每個節點,咱們先構建出它的左右子樹,而後再根據左右兩個子節點來構建當前節點。對於葉子節點(單點區間),只根據當前節點來構建。

參考代碼:

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])
}
複製代碼

建樹複雜度分析:

  • 時間複雜度: O ( n ) O(n)
  • 空間複雜度: O ( 4 n ) O(4 * n) = O ( n ) O(n)

3.2 區間查詢

區間查詢是查詢一段指望區間的結果,基本思路是遞歸查詢子區間的結果,再經過合併子區間的結果來獲得指望區間的結果。邏輯以下:

  • 0、從根節點開始查找(根節點是整個區間),遞歸執行如下步驟:
  • 一、若是查找範圍正好等於節點區間範圍,直接返回節點聚合數據;
  • 二、若是查找範圍正好落在左子樹區間範圍,那麼遞歸地在左子樹查找;
  • 三、若是查找範圍正好落在右子樹區間範圍,那麼遞歸地在右子樹查找;
  • 四、若是查找範圍橫跨兩棵子樹,那麼拆分爲兩次遞歸查找,查找完成後 合併 結果。
/**
 * 區間查詢
 *
 * @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)
}
複製代碼

查詢複雜度分析:

  • 時間複雜度:取決於樹的高度,爲 O ( l g n ) O(lgn)
  • 空間複雜度: O ( 1 ) O(1)

3.3 單點更新

單點更新就是在數據變化以後適當調整線段樹的結構,基本思路是遞歸地修改子區間的結果,再經過合併子區間的結果來更新指望當前節點的結果。邏輯以下:

  • 0、更新原數據(data 數組),而後從根節點開始更新值(根節點是整個區間),遞歸執行如下步驟:
  • 一、若是是葉子節點(left = right),直接更新;
  • 二、若是更新節點正好落在左子樹區間範圍,那麼遞歸地在左子樹更新;
  • 三、若是更新節點正好落在右子樹區間範圍,那麼遞歸地在右子樹更新;
  • 四、更新左右子樹以後,再經過合併子樹信息來更新當前節點。
/**
 * 單點更新
 *
 * @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])
}
複製代碼

更新複雜度分析:

  • 時間複雜度:取決於樹的高度,爲 O ( l g n ) O(lgn)
  • 空間複雜度: O ( 1 ) O(1)

到這裏,咱們的線段樹數據結構就實現完成了,完整代碼以下:SegmentTree


4. 典型例題 · 區域和檢索 - 數組可變

307. 區域和檢索 - 數組可變 【題解】

給你一個數組 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 代碼,彷佛就太蛋疼了。有沒有別的變通方法呢?


5. 線段樹的解題框架

定義 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 空間下面解釋下代碼。代碼主要由三個部分組成:

5.1 建樹

構建線段樹須要初始化一個 2 n 2*n 空間的數組,採用 自底向上 的方式來構建整棵線段樹。首先,構建葉子節點,葉子節點的位於數組區間 [ n , 2 n 1 ] [n,2n -1] ,隨後再根據子節點的結果來構建父節點(下標爲 i n d e x index 的節點,左子節點下標: 2 i n d e x 2*index ,右子節點下標: 2 i n d e x + 1 2*index+1 )。參考如下示意圖:

5.2 區間查詢

區間查詢是查詢一段指望區間的結果,相對於常規方法構造的線段樹,這種線段樹的區間查詢過程相對較難理解。基本思路是遞歸地尋找可以表明該區間的節點。邏輯以下:

  • 一、一開始的區間查詢等同於線段樹數組 [ n , 2 n 1 ] [n,2n-1] 之間的若干個葉子節點 [ l e f t , r i g h t ] [left,right] 的合併,咱們須要向上一層尋找可以表明這些節點的父節點;

  • 二、對於節點 i n d e x index ,它的左子節點下標: 2 i n d e x 2*index ,右子節點下標: 2 i n d e x + 1 2*index+1 ,這意味着全部左子節點下標是偶數,全部右子節點下標是奇數;

  • 三、 l e f t / = 2 left /= 2 r i g h t / = 2 right /= 2 則是尋找父節點,若是 l e f t left 指針是奇數,那麼 l e f t left 指針節點必定是一個右節點,此時 l e f t / 2 left/2 節點就沒法直接表明 l e f t left 指針節點,因而只能單獨加上這個 「落單」 的節點。同理,若是 r i g h t right 指針是偶數,那麼 r i g h t t rightt 指針節點必定是一個左節點,,此時 r i g h t / 2 right /2 節點就沒法直接表明 r i g h t right 指針節點,因而只能單獨加上這個 「落單」 的節點;

  • 四、最後循環退出前 l e f t = = r i g h t left == right ,說明當前節點的區間(去除 「落單」 的節點)正好是所求的區間,直接加上。而且下一躺循環 l e f t left 必定大於 r i g h t right ,跳出循環。

5.3 單點更新

單點更新就是在數據變化以後適當調整線段樹的結構,基本思路是:先更新目標位置對應的節點,遞歸地更新父節點。須要注意根據當前節點的索引是偶數或奇數,來肯定選擇哪兩個節點來合併爲父節點。

例如,更新的節點是 「a」 節點,它在線段樹數組索引 index 是偶數(下標爲 6),那麼它的父節點是 「ab」 節點須要經過合併 tree[index] + tree[index+1] 來得到的。


6. 總結

  • 前綴和數組與線段樹都適用與區間查詢問題,前者在數據更新頻繁時總體性能會降低,後者平衡了更新與查詢二者的時間複雜度,複雜度都是 O ( l g n ) O(lgn)
  • 從解題的角度,常規的構建線段樹的方法太複雜,能夠採用反常規的線段樹構建方式,代碼會更加簡潔,空間複雜度也更優秀;
  • 除了線段樹,你還知道什麼相似的數據結構擅長於區間查詢和單點更新嗎?

參考資料


創做不易,你的「三連」是醜醜最大的動力,咱們下次見!

相關文章
相關標籤/搜索