【從蛋殼到滿天飛】JS 資料結構解析和演算法實現-線段樹

哎喲迪奧發表於2019-03-20

思維導圖

前言

【從蛋殼到滿天飛】JS 資料結構解析和演算法實現,全部文章大概的內容如下: Arrays(陣列)、Stacks(棧)、Queues(佇列)、LinkedList(連結串列)、Recursion(遞迴思想)、BinarySearchTree(二分搜尋樹)、Set(集合)、Map(對映)、Heap(堆)、PriorityQueue(優先佇列)、SegmentTree(線段樹)、Trie(字典樹)、UnionFind(並查集)、AVLTree(AVL 平衡樹)、RedBlackTree(紅黑平衡樹)、HashTable(雜湊表)

原始碼有三個:ES6(單個單個的 class 型別的 js 檔案) | JS + HTML(一個 js 配合一個 html)| JAVA (一個一個的工程)

全部原始碼已上傳 github,點選我吧,光看文章能夠掌握兩成,動手敲程式碼、動腦思考、畫圖才可以掌握八成。

本文章適合 對資料結構想了解並且感興趣的人群,文章風格一如既往如此,就覺得手機上看起來比較方便,這樣顯得比較有條理,整理這些筆記加原始碼,時間跨度也算將近半年時間了,希望對想學習資料結構的人或者正在學習資料結構的人群有幫助。

線段樹(區間樹)

  1. 堆(Heap)是一種樹結構
  2. 線段樹(Segment Tree)也是一種樹結構
    1. 也叫做區間樹(Interval Tree)

線段樹簡介

為什麼使用線段樹

  1. 為什麼使用線段樹,線段樹解決什麼樣的特殊問題
    1. 對於有一類的問題,只需要關心的是一個線段(或者區間),
    2. 有一道競賽的題目,也是最經典的線段樹問題:區間染色,
    3. 它是一個非常好的應用線段樹的場景。
    4. 有一面牆,長度為 n,每次選擇一段兒牆進行染色,
    5. m 次操作後,可以看到多少種顏色?
    6. m 次操作後,可以在[i,j]區間內看到多少種顏色?
    7. 染色操作(更新區間)、查詢操作(查詢區間)。
  2. 完全可以使用陣列實現來解決這個問題
    1. 如果你要對某一段區間進行染色,
    2. 那麼就遍歷這一段區間,把相應的值修改成新的元素就 ok 了,
    3. 染色操作(更新區間)相應的複雜度就是O(n)級別的,
    4. 查詢操作(查詢區間)也只需要遍歷一遍這個區間就 ok 了,
    5. 相應的複雜度也是O(n)級別的,
    6. 如果對於一種資料結構,其中某一些操作是O(n)級別的話,
    7. 如果動態的使用這種資料結構,相應的效能很有可能是不夠的,
    8. 在實際的環境中很有可能需要效能更加好的這樣一種時間複雜度,
    9. 這就是使用陣列來實現區間染色問題相應的一個侷限性
  3. 在這樣的問題中主要關注的是區間或一個個的線段,
    1. 所以此時線段樹這樣的資料結構就有用武之地了,
    2. 在平時使用計算機來處理資料的時候,
    3. 有一類很經典的同時也是應用範圍非常廣的的問題,
    4. 就是進行區間查詢,類似統計操作的查詢,
    5. 查詢一個區間[i,j]的最大值、最小值,或者區間數字和。
  4. 實質:基於區間的統計查詢
    1. 問題一:2017 年註冊使用者中到現在為止消費最高的使用者、消費最少的使用者、
    2. 學習時間最長的使用者?
    3. 2017 年註冊的到現在為止,這個資料其實還在不斷的變化,
    4. 是一種動態的情況,此時線段樹就是一個好的選擇。
    5. 問題二:某個太空區間中天體總量?
    6. 由於天體不斷的在運動,總會有一個天體從一個區間來到另外一個區間,
    7. 甚至發生爆炸消失之類的物理現象,在某個區間中或某幾個區間中
    8. 都多了一些天體,會存在這樣的現象的,所以就需要使用線段樹了。
  5. 對於這面牆有一個不停的對一個區間進行染色這樣的一個操作,
    1. 就是更新這個操作,於此同時基於整個資料不時在不停的在更新,
    2. 還需要進行查詢這樣的兩個操作,同理其實對於這些問題,
    3. 可以使用陣列來實現,不過它的複雜度都是O(n)級別的,
    4. 但是如果使用線段樹的話,那麼在區間類的統計查詢這一類的問題上,
    5. 更新和查詢這兩個操作都可以在O(logn)這個複雜度內完成。
  6. 對於線段樹來說它一定也是一種二叉樹的結構
    1. 對於線段樹抽象出來就是解決這樣的一類問題,
    2. 對於一個給定的區間,相應的要支援兩個操作,
    3. 更新:更新區間中一個元素或者一個區間的值,
    4. 查詢:查詢一個區間[i,j]的最大值、最小值、或者區間數字和,
    5. 對於一個區間可以查詢的內容會很多,要根據實際的業務邏輯進調整,
    6. 不過整體是基於一個區間進行這種統計查詢的。
  7. 如何使用 logn 複雜度去實現這一點
    1. 首先對於給定的陣列進行構建,假如有八個元素,
    2. 把它們構建成一棵線段樹,對於線段樹來說,
    3. 不考慮往線段樹中新增元素或者刪除元素的。
    4. 在大多數情況下線段樹所解決的問題它的區間是固定的,
    5. 比如一面牆進行區間染色,那面牆本身是固定的,
    6. 不去考慮這面牆後面又建起了新的一面牆這種情況,
    7. 只考慮給定的這一面牆進行染色;
    8. 比如統計 2017 年註冊的使用者,那麼這個區間是固定的;
    9. 或者觀察天體,觀察的外太空所劃分的區間已經固定了,
    10. 只是區間中的元素可能會發生變化;
    11. 所以對於這個陣列直接使用靜態陣列就好了。
  8. 線段樹也是一棵樹,每一個節點表示一個區間內相應的資訊
    1. 比如 以線段樹來統計區間內的和為例,
    2. 在這樣的情況下,
    3. 線段樹每一個節點儲存的就是一段區間的數字和,
    4. 根節點儲存的就是整個區間相應的數字和,
    5. 之後從根節點平均將整個的區間分成兩段,
    6. 這兩段代表著兩個節點,相應的這兩個節點會再分出兩個區間,
    7. 直至最後每一個葉子節點只會存一個元素,
    8. 從區間的角度上來講,每個元素本身就是一個區間,
    9. 只不過每一個區間的長度為 1 而已,
    10. 也就是對於整棵線段樹來說每一個節點儲存的是
    11. 一個區間中相應的統計值,比如使用線段樹求和,
    12. 每個節點儲存的是一個區間中數字和,
    13. 當你查詢某個區間的話,相應的你只需要找到對應的某個節點即可,
    14. 一步就到了這個節點,並不需要將所有的元素全部都遍歷一遍了,
    15. 但是並不是所有的節點每次都滿足這樣的條件,
    16. 所以有時候需要到兩個節點,將兩個節點進行相應的結合,
    17. 結合之後就可以得到你想要的結果,儘管如此,當資料量非常大的時候,
    18. 依然可以通過線段樹非常快的找到你所關心的那個區間對應的一個或者多個節點,
    19. 然後在對那些節點的內容進行操作,
    20. 而不需要對那個區間中所有的元素中每一個元素相應的進行一次遍歷,
    21. 這一點也是線段樹的優勢。

線段樹基礎表示

  1. 線段樹就是二叉樹每一個節點儲存的是一個線段(區間)相應的資訊
    1. 這個相應的資訊不是指把這個區間中所有的元素都存進去,
    2. 比如 以求和操作為例,
    3. 那麼每一個節點相應的儲存的就是這個節點所對應的區間的那個數字和。
  2. 線段樹不一定是滿的二叉樹,
    1. 線段樹也不一定是一棵完全二叉樹,
    2. 線段樹是一棵平衡二叉樹,
    3. 也就是說對於整棵樹來說,最大的深度和最小的深度,
    4. 它們之間的差最多隻能為 1,堆也是一棵平衡二叉樹,
    5. 完全二叉樹本身也是一棵平衡二叉樹,
    6. 線段樹雖然不是一棵完全二叉樹,但是它滿足平衡二叉樹的定義,
    7. 二分搜尋樹不一定是一棵平衡二叉樹,
    8. 因為二分搜尋樹沒有任何機制能夠保證最大深度和最小深度之間差不超過 1。
  3. 平衡二叉樹的優勢
    1. 不會像二分搜尋樹那樣在最差的情況下退化為一個連結串列,
    2. 一棵平衡二叉樹整棵樹的高度和它的節點之間的關係一定是一個 log 之間的關係,
    3. 這使得在平衡二叉樹上搜尋查詢是非常高效的。
  4. 線段樹雖然不是完全二叉樹
    1. 但是這樣的一個平衡二叉樹,
    2. 也可以使用陣列的方式來表示,
    3. 對線段樹來說其實可以把它看作是一棵滿二叉樹,
    4. 但是可能在最後一層很多節點是不存在的,
    5. 對於這些不存在的節點只需要把它看作是空即可,
    6. 這樣一來就是一棵滿二叉樹了,滿二叉樹是一棵特殊的完全二叉樹,
    7. 那麼它就一定可以使用陣列來表示。
  5. 滿二叉樹的性質
    1. 滿二叉樹每層的節點數與層數成次方關係,0 層就是 2^0,1 層就是 2^1,
    2. 最後一層的節點數是 前面所有層的節點之和 然後再加上一
    3. (當前層節點數是 前面所有層節點數的總和 然後另外再加一),
    4. 最後一層的節點數是 前面一層節點的兩倍
    5. (當前層節點數是 前一層節點數的兩倍)
    6. 整棵滿二叉樹實際的節點個數就是2^h-1
    7. (最後一層也就是(h-1層),有2^(h-1)個節點,
    8. 最後一層節點數是 前面所有層節點數的總和 另外再加一,
    9. 所以總節點數也就是2 * 2^(h-1)-1個節點,這樣一來就是2^h-1個)。
  6. 那麼就有一個問題了,如果區間中有 n 個元素
    1. 那麼使用陣列表示時那麼陣列的空間大小是多少,
    2. 也就是這棵線段樹上應該有多少個節點,
    3. 對於一棵滿的二叉樹,這一棵的層數和每一層的節點之間是有規律的,
    4. 第 0 層節點數為 1,第 1 層節點數為 2,第 2 層節點數為 4,第 3 層節點數為 8,
    5. 那麼第(h-1)層節點數為2^(h-1),下層節點的數量是上層節點數量的 2 倍,
    6. 第 3 層的節點數量是第 2 層的節點數量的 2 倍,
    7. 所以對於滿二叉樹來說,h 層,一共有2^h-1個節點(大約是2^h),
    8. 這是等比數列求和的公式,
    9. 那麼當陣列的空間為2^h時一定可以裝下滿二叉樹所有的元素,
    10. 最後一層(h-1層),有2^(h-1)個節點,
    11. 那麼最後一層的節點數大致等於前面所有層節點之和。
  7. 那麼原來的問題是如果區間有 n 個元素,陣列表示需要有多少節點?
    1. 答案是 log 以 2 為底的 n 為多少,也就是 2 的多少次方為 n,
    2. 如果這 n 是 2 的整數次冪,那麼只需要 2n 的空間,
    3. 這是因為除了最後一層之外,上層的所有節點大概也等於 n,
    4. 雖然實際來說是 n-1,但是這一個空間富餘出來沒有關係,
    5. 只需要 2n 的空間就足以儲存整棵樹了,
    6. 但是關鍵是通常這個 n 不一定是 2 的 k 次方冪,
    7. 也就是這個 n 不一定是 2 的整數次冪,如 n=2^k+r,r 肯定不等於 2^k,
    8. 那麼在最壞的情況下,如果 n=2^k+1,
    9. 那麼最後一層不足以儲存整個葉子節點的,
    10. 因為葉子節點的索引範圍會超出 2n 的陣列範圍內,n=2^k+3 就會超出,
    11. 那麼葉子節點肯定是在倒數的兩層的範圍裡,
    12. 那麼就還需要再加一層,加的這一層如果使用滿二叉樹的方式儲存的話,
    13. 那麼就在原來的基礎上再加一倍的空間,此時整棵滿二叉樹需要 4n 的空間,
    14. 這樣才可以儲存所有的節點,對於建立的這棵線段樹來說,
    15. 如果你考慮的這個區間一共有 n 個元素,
    16. 那麼選擇使用陣列的方式進行儲存的話,
    17. 只需要有 4n 的空間就可以儲存整棵線段樹了,
    18. 在這 4n 的空間裡並不是所有的空間都被利用了,
    19. 因為這個計算本身是一個估計值,
    20. 在計算的過程中不是嚴格的正好可以儲存整個線段樹的所有的節點,
    21. 其實做了一些富餘,對於線段樹來說並不一定是一棵滿二叉樹,
    22. 所以才在最後一層的地方,很有可能很多位置都是空的,
    23. 這 4n 的空間有可能是有浪費掉的,
    24. 在最壞的情況下至少有一半的空間是被浪費掉的,
    25. 但是不過度的考慮這些浪費的情況,
    26. 對於現代計算機來說儲存空間本身並不是問題,
    27. 做演算法的關鍵就是使用空間來換時間,希望在時間效能上有巨大的提升,
    28. 這部分浪費本身也是可以避免的,不使用陣列來儲存整棵線段樹,
    29. 而使用鏈式的結構如二分搜尋樹那種節點的方式來儲存整棵線段樹,
    30. 就可以避免這種空間的浪費。
  8. 如果區間有 n 個元素,陣列需要開 4n 的空間就好了,
    1. 於此同時這 4n 的空間是一個靜態的空間,
    2. 因為對於線段樹來說並不考慮新增元素,
    3. 也就是說考慮的整個區間是固定的,這個區間的大小不會再改變了,
    4. 真正改變的是區間中的元素,所以不需要使用自己實現的動態陣列,
    5. 直接開 4n 的靜態空間即可。

程式碼示例(class: MySegmentTree)

  1. MySegmentTree

    // 自定義線段樹 SegmentTree
    class MySegmentTree {
       constructor(array) {
          // 拷貝一份引數陣列中的元素
          this.data = new Array(array.length);
          for (var i = 0; i < array.length; i++) this.data[i] = array[i];
    
          // 初始化線段樹 開4倍的空間 這樣才能在所有情況下儲存線段樹上所有的節點
          this.tree = new Array(4 * this.data.length);
       }
    
       // 獲取線段樹中實際的元素個數
       getSize() {
          return this.data.length;
       }
    
       // 根據索引獲取元素
       get(index) {
          if (index < 0 || index >= this.getSize())
             throw new Error('index is illegal.');
          return this.data[index];
       }
    
       // 輔助函式:返回完全二叉樹的陣列表示中,一個索引所表示的元素的左孩子節點的索引
       // 計算出線段樹中指定索引位置的元素其左孩子節點的索引 -
       calcLeftChildIndex(index) {
          return index * 2 + 1;
       }
    
       // 輔助函式:返回完全二叉樹的陣列表示中,一個索引所表示的元素的右孩子節點的索引
       // 計算出線段樹中指定索引位置的元素其右孩子節點的索引 -
       calcRightChildIndex(index) {
          return index * 2 + 2;
       }
    }
    複製程式碼

建立線段樹

  1. 將線段樹看作是一棵滿的二叉樹
    1. 這樣一來就可以使用陣列來儲存整個線段樹上所有的節點了,
    2. 如果考慮的這個區間中有 n 個元素,那麼這個陣列就需要開 4n 個空間。
  2. 在陣列中儲存什麼才可以構建出一棵線段樹
    1. 這個邏輯是一個非常典型的遞迴邏輯,對於這個線段樹的定義,
    2. 根節點所儲存的資訊實際上就是它的兩個孩子所儲存的資訊相應的一個綜合,
    3. 怎麼去綜合是以業務邏輯去定義的,
    4. 比如是以求和為例,建立這棵線段樹是為了查詢區間中資料的元素和這樣的一個操作,
    5. 相應的每一個節點儲存的就是相應的一個區間中所有元素的和,
    6. 比如有十個元素,那麼根節點儲存的就是這十個元素的和,
    7. 相應它分出兩個孩子節點,左孩子就是這十個元素中前五個元素相應的和,
    8. 右孩子就是這十個元素中後五個元素相應的和,
    9. 這兩個節點下面的左右孩子節點依次再這樣的劃分,直到到達葉子節點。
  3. 這整個過程相當於是建立這棵線段樹根
    1. 建立這棵線段樹的根必須先建立好這個根節點對應的左右兩個子樹,
    2. 只要有了這左右兩個子樹的根節點,
    3. 那麼這個線段樹的根節點對應的這個值就是它的兩個孩子所對應的值進行一下加法運算即可,
    4. 對於左右兩棵子樹的建立也是如此,為了要建立它們的根節點,
    5. 那麼還是要建立這個根節點對應的左右兩個子樹,依此類推,直到遞迴到底為止,
    6. 也就是這個節點所對應的區間不能夠再劃分了,該節點所儲存的這個區間的長度只為 1 了,
    7. 這個區間只有一個元素,對於這一個元素,它的和就是這一個元素本身,那麼就遞迴到底了,
    8. 整體這個遞迴結構就是如此清晰的。
  4. BuildingSegmentTree 的方法
    1. 有三個引數;
    2. 第一個引數是在初始的時候這個線段樹對應的索引,索引應該為 0,表示從 0 開始;
    3. 第二、三引數是指對於這個節點它所表示的那個線段(區間)左右端點是什麼,初始的時候,
    4. 左端點的索引應該為 0,右端點的索引應該為原陣列的長度減 1;
    5. 遞迴使用的時候,
    6. 也就是在 treeIndex 的位置建立表示區間[l...r]的線段樹。
  5. BuildingSegmentTree 的方法的邏輯
    1. 如果真的要表示一個區間的話,那麼相應的處理方式是這樣的,
    2. 先獲取這個區間的左右節點的索引,這個節點一定會有左右孩子,
    3. 先建立和這個節點的左右子樹,基於兩個區間才能建立線段樹,
    4. 計算這個區間的左右範圍,計算公式:mid = (left + right) / 2
    5. 這個計算可能會出現整型溢位的問題,但是概率很低,
    6. 那麼計算公式可以換一種寫法:mid = left + (right - left) / 2
    7. 左子樹區間為 left至mid,右子樹區間為 mid+1至right
    8. 遞迴建立線段樹,之後進行業務處理操作,
    9. 例如 求和、取最大值、取最小值,綜合左右兩個線段的資訊,
    10. 來得到當前的更大的這個線段相應的資訊,如果去綜合,是根據你的業務邏輯來決定的,
    11. 使用一個如何去綜合的介面,這樣一來就會根據你傳入的方法來進行綜合的操作。
    12. 這個和 自定義的優先佇列中的 updateCompare 傳入的 方法的意義是一樣的,
    13. 只不過 updateCompare 是傳入比較的方法,用來在優先佇列中如何比較兩個元素值,
    14. 而 updateMerge 是傳入融合的方法,用來線段樹中構建線段樹時兩個元素如何去融合。

程式碼示例

  1. (class: MySegmentTree, class: Main)

  2. MySegmentTree:線段樹

    // 自定義線段樹 SegmentTree
    class MySegmentTree {
       constructor(array) {
          // 拷貝一份引數陣列中的元素
          this.data = new Array(array.length);
          for (var i = 0; i < array.length; i++) this.data[i] = array[i];
    
          // 初始化線段樹 開4倍的空間 這樣才能在所有情況下儲存線段樹上所有的節點
          this.tree = new Array(4 * this.data.length);
    
          // 開始構建線段樹
          this.buildingSegmentTree(0, 0, this.data.length - 1);
       }
    
       // 獲取線段樹中實際的元素個數
       getSize() {
          return this.data.length;
       }
    
       // 根據索引獲取元素
       get(index) {
          if (index < 0 || index >= this.getSize())
             throw new Error('index is illegal.');
          return this.data[index];
       }
    
       // 構建線段樹
       buildingSegmentTree(treeIndex, left, right) {
          // 解決最基本問題
          // 當一條線段的兩端相同時,說明這個區間只有一個元素,
          // 那麼遞迴也到底了
          if (left === right) {
             this.tree[treeIndex] = this.data[left];
             return;
          }
    
          // 計算當前線段樹的左右子樹的索引
          const leftChildIndex = this.calcLeftChildIndex(treeIndex);
          const rightChildIndex = this.calcRightChildIndex(treeIndex);
    
          // 將一個區間拆分為兩段,然後繼續構建其左右子線段樹
          let middle = Math.floor(left + (right - left) / 2); //(left + right) / 2
    
          // 構建左子線段樹
          this.buildingSegmentTree(leftChildIndex, left, middle);
          // 構建右子線段樹
          this.buildingSegmentTree(rightChildIndex, middle + 1, right);
    
          // 融合左子線段樹和右子線段樹
          this.tree[treeIndex] = this.merge(
             this.tree[leftChildIndex],
             this.tree[rightChildIndex]
          );
       }
    
       // 輔助函式:返回完全二叉樹的陣列表示中,一個索引所表示的元素的左孩子節點的索引
       // 計算出線段樹中指定索引位置的元素其左孩子節點的索引 -
       calcLeftChildIndex(index) {
          return index * 2 + 1;
       }
    
       // 輔助函式:返回完全二叉樹的陣列表示中,一個索引所表示的元素的右孩子節點的索引
       // 計算出線段樹中指定索引位置的元素其右孩子節點的索引 -
       calcRightChildIndex(index) {
          return index * 2 + 2;
       }
    
       // 輔助函式: 融合兩棵線段樹,也就是對線段樹進行業務邏輯的處理
       merge(treeElementA, treeElmentB) {
          // 預設進行求和操作
          return treeElementA + treeElmentB;
       }
    
       // 輔助函式:更新融合的方法,也就是自定義處理線段樹融合的業務邏輯
       updateMerge(mergeMethod) {
          this.merge = mergeMethod;
       }
    
       // @Override toString() 2018-11-7 jwl
       toString() {
          let segmentTreeConsoleInfo = ''; // 控制檯資訊
          let segmentTreePageInfo = ''; // 頁面資訊
    
          // 輸出頭部資訊
          segmentTreeConsoleInfo += 'SegmentTree:';
          segmentTreePageInfo += 'SegmentTree:';
          segmentTreeConsoleInfo += '\r\n';
          segmentTreePageInfo += '<br/><br/>';
    
          // 輸出傳入的資料資訊
          segmentTreeConsoleInfo += 'data = [';
          segmentTreePageInfo += 'data = [';
    
          for (let i = 0; i < this.data.length - 1; i++) {
             segmentTreeConsoleInfo += this.data[i] + ',';
             segmentTreePageInfo += this.data[i] + ',';
          }
    
          if (this.data != null && this.data.length != 0) {
             segmentTreeConsoleInfo += this.data[this.data.length - 1];
             segmentTreePageInfo += this.data[this.data.length - 1];
          }
          segmentTreeConsoleInfo += '],\r\n';
          segmentTreePageInfo += '],<br/><br/>';
    
          // 輸出生成的線段樹資訊
          segmentTreeConsoleInfo += 'tree = [';
          segmentTreePageInfo += 'tree = [';
          let treeSize = 0;
          for (let i = 0; i < this.tree.length - 1; i++) {
             if (this.tree[i] !== undefined) treeSize++;
             segmentTreeConsoleInfo += this.tree[i] + ',';
             segmentTreePageInfo += this.tree[i] + ',';
          }
          if (this.tree != null && this.tree.length != 0) {
             if (this.tree[this.tree.length - 1] !== undefined) treeSize++;
             segmentTreeConsoleInfo += this.tree[this.tree.length - 1];
             segmentTreePageInfo += this.tree[this.tree.length - 1];
          }
          segmentTreeConsoleInfo += '],\r\n';
          segmentTreePageInfo += '],<br/><br/>';
          segmentTreeConsoleInfo += 'originArraySize:' + this.getSize() + ',';
          segmentTreePageInfo += 'originArraySize:' + this.getSize() + ',';
          segmentTreeConsoleInfo +=
             'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
          segmentTreePageInfo +=
             'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
    
          // 返回輸出的總資訊
          document.body.innerHTML += segmentTreePageInfo;
          return segmentTreeConsoleInfo;
       }
    }
    複製程式碼
  3. Main:主函式

    // main 函式
    class Main {
       constructor() {
          this.alterLine('MySegmentTree Area');
          // 初始資料
          const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
          // 初始化線段樹,將初始資料和融合器傳入進去
          let mySegmentTree = new MySegmentTree(nums);
          // 指定線段樹的融合器
          mySegmentTree.updateMerge((a, b) => a + b);
    
          // 輸出
          console.log(mySegmentTree.toString());
       }
    
       // 將內容顯示在頁面上
       show(content) {
          document.body.innerHTML += `${content}<br /><br />`;
       }
    
       // 展示分割線
       alterLine(title) {
          let line = `--------------------${title}----------------------`;
          console.log(line);
          document.body.innerHTML += `${line}<br /><br />`;
       }
    }
    
    // 頁面載入完畢
    window.onload = function() {
       // 執行主函式
       new Main();
    };
    複製程式碼

線段樹查詢

  1. 要有兩個查詢方法,一個普通查詢,一個是遞迴查詢。
  2. 普通查詢
    1. 有兩個引數,也就是你要查詢的區間,左端點與右端點的索引,
    2. 先檢查 待查詢的區間左右兩端的索引是否符合要求,有沒有越界,
    3. 然後呼叫遞迴查詢,
    4. 首次遞迴函式呼叫時,需要從根節點開始,也就是第一個引數索引為 0,
    5. 以及搜尋範圍從根節點的左右兩端開始也就是從 0 到原陣列的長度減 1,
    6. 然後就是你要指定要查詢的線段(區間),也就是從一個大範圍內找到一個小線段(區間),
    7. 最後也是獲取這個線段(區間),
    8. 其實就是獲取這個線段(區間)在進行過業務處理操作後得到的結果,
    9. 如 求和、取最大值、取最小值,綜合線段(區間)樹的資訊返回最終結果。
  3. 遞迴查詢
    1. 有五個引數,
    2. 第一個 當前節點所對應的索引,
    3. 第二個第三個 當前節點它所表示的那個線段(區間)左右端點是什麼,
    4. 第四個第五個 待查詢的線段(區間),也就是要查詢的這個線段(區間)的左右端點。
  4. 遞迴查詢的邏輯
    1. 如果查詢範圍的左右端點剛好與待查詢的線段(區間)的左右端點一致,
    2. 那麼就說明當前正好就查詢到了待查詢的這個線段了,那麼直接返回當前當前節點即可,
    3. 不一致的話,說明還需要向下縮小可查詢範圍,從而能夠匹配到待查詢的這個線段(區間)。
    4. 向下縮小範圍的方式,就是當前這個節點的左右端點之和除以 2,獲取左右端點的中間值,
    5. 求出 middle 之後,再繼續遞迴,查詢當前節點的左右孩子節點,
    6. 查詢範圍是當前節點的左端點到 middle 以及 middle+1 到右端點,
    7. 但是查詢之前要判斷待查詢的線段(區間)到底在當前節點左子樹中還是右子樹中,
    8. 如果在左子樹中那麼就直接把查詢範圍定位到當前節點左孩子節點中,
    9. 如果在右子樹中那麼就直接把查詢範圍定位到當前節點右孩子節點中,
    10. 這樣就完成了在一個節點的左子線段樹或右子線段樹中再繼續查詢了,
    11. 這個查詢範圍在很明確情況下開始收縮,
    12. 直到查詢範圍的左右端點剛好與待查詢的線段(區間)的左右端點完全一致,
    13. 遞迴查詢就完畢了,直接返回那個線段(區間)的節點即可。
    14. 但是問題來了,如果待查詢的線段(區間)很不巧的同時分佈在
    15. 某一個線段(區間)左右子線段樹中,這樣一來就永遠都無法匹配到
    16. 查詢範圍的左右端點剛好與待查詢的線段(區間)的左右端點一致的情況,
    17. 那就麻煩了,那麼就需要同時在某一個線段(區間)左右子線段樹中查詢,
    18. 查詢的時候待查詢的線段(區間)也要做相應的縮小,因為查詢的範圍也縮小了,
    19. 如果待查詢的線段(區間)不做相應的縮小,那就會形成死遞迴,
    20. 因為永遠無法完全匹配,隨著查詢的範圍縮小,待查詢的線段(區間)會大於這個查詢範圍,
    21. 待查詢的線段(區間)縮小的方式和查詢範圍縮小的方式一致,
    22. 從待查詢的線段(區間)左端點到 middle 以及 middle+1 到右端點,
    23. 最後將查詢到的兩個結果進行一下融合,最終返回這個融合的結果,
    24. 一樣可以達到如此的效果。

程式碼示例

  1. (class: MySegmentTree, class: Main)

  2. MySegmentTree:線段樹

    // 自定義線段樹 SegmentTree
    class MySegmentTree {
       constructor(array) {
          // 拷貝一份引數陣列中的元素
          this.data = new Array(array.length);
          for (var i = 0; i < array.length; i++) this.data[i] = array[i];
    
          // 初始化線段樹 開4倍的空間 這樣才能在所有情況下儲存線段樹上所有的節點
          this.tree = new Array(4 * this.data.length);
    
          // 開始構建線段樹
          this.buildingSegmentTree(0, 0, this.data.length - 1);
       }
    
       // 獲取線段樹中實際的元素個數
       getSize() {
          return this.data.length;
       }
    
       // 根據索引獲取元素
       get(index) {
          if (index < 0 || index >= this.getSize())
             throw new Error('index is illegal.');
          return this.data[index];
       }
    
       // 構建線段樹
       buildingSegmentTree(treeIndex, left, right) {
          // 解決最基本問題
          // 當一條線段的兩端相同時,說明這個區間只有一個元素,
          // 那麼遞迴也到底了
          if (left === right) {
             this.tree[treeIndex] = this.data[left];
             return;
          }
    
          // 計算當前線段樹的左右子樹的索引
          const leftChildIndex = this.calcLeftChildIndex(treeIndex);
          const rightChildIndex = this.calcRightChildIndex(treeIndex);
    
          // 將一個區間拆分為兩段,然後繼續構建其左右子線段樹
          let middle = Math.floor(left + (right - left) / 2); //(left + right) / 2
    
          // 構建左子線段樹
          this.buildingSegmentTree(leftChildIndex, left, middle);
          // 構建右子線段樹
          this.buildingSegmentTree(rightChildIndex, middle + 1, right);
    
          // 融合左子線段樹和右子線段樹
          this.tree[treeIndex] = this.merge(
             this.tree[leftChildIndex],
             this.tree[rightChildIndex]
          );
       }
    
       // 查詢指定區間的線段樹資料
       // 返回區間[queryLeft, queryRight]的值
       query(queryLeft, queryRight) {
          if (
             queryLeft < 0 ||
             queryRight < 0 ||
             queryLeft > queryRight ||
             queryLeft >= this.data.length ||
             queryRight >= this.data.length
          )
             throw new Error('queryLeft or queryRight is illegal.');
    
          // 呼叫遞迴的查詢方法
          return this.recursiveQuery(
             0,
             0,
             this.data.length - 1,
             queryLeft,
             queryRight
          );
       }
    
       // 遞迴的查詢方法 -
       // 在以treeIndex為根的線段樹中[left...right]的範圍裡,
       // 搜尋區間[queryLeft...queryRight]的值
       recursiveQuery(treeIndex, left, right, queryLeft, queryRight) {
          // 如果查詢範圍 與 指定的線段樹的區間 相同,那麼說明完全匹配,
          // 直接返回當前這個線段即可,每一個節點代表 一個線段(區間)處理後的結果
          if (left === queryLeft && right === queryRight)
             return this.tree[treeIndex];
    
          // 求出當前查詢範圍的中間值
          const middle = Math.floor(left + (right - left) / 2);
    
          // 滿二叉樹肯定有左右孩子節點
          // 上面的判斷沒有完全匹配,說明需要繼續 縮小查詢範圍,也就是要在左右子樹中進行查詢了
          const leftChildIndex = this.calcLeftChildIndex(treeIndex);
          const rightChildIndex = this.calcRightChildIndex(treeIndex);
    
          // 判斷:
          //    1. 從左子樹中查還是右子樹中查,又或者從左右子樹中同時查,然後將兩個查詢結果融合。
          //    2. 如果 待查詢的區間的左端點大於查詢範圍的中間值,說明只需要從右子樹中進行查詢即可。
          //    3. 如果 待查詢的區間的右端點小於查詢範圍的中間值 + 1,說明只需要從左子樹中進行查詢。
          //    4. 如果 待查詢的區間在左右端點各分部一部分,說明要同時從左右子樹中進行查詢。
          if (queryLeft > middle)
             return this.recursiveQuery(
                rightChildIndex,
                middle + 1,
                right,
                queryLeft,
                queryRight
             );
          else if (queryRight < middle + 1)
             return this.recursiveQuery(
                leftChildIndex,
                left,
                middle,
                queryLeft,
                queryRight
             );
          else {
             // 求出 左子樹中一部分待查詢區間中的值
             const leftChildValue = this.recursiveQuery(
                leftChildIndex,
                left,
                middle,
                queryLeft,
                middle
             );
             // 求出 右子樹中一部分待查詢區間中的值
             const rightChildValue = this.recursiveQuery(
                rightChildIndex,
                middle + 1,
                right,
                middle + 1,
                queryRight
             );
             // 融合左右子樹種的資料並返回
             return this.merge(leftChildValue, rightChildValue);
          }
       }
    
       // 輔助函式:返回完全二叉樹的陣列表示中,一個索引所表示的元素的左孩子節點的索引
       // 計算出線段樹中指定索引位置的元素其左孩子節點的索引 -
       calcLeftChildIndex(index) {
          return index * 2 + 1;
       }
    
       // 輔助函式:返回完全二叉樹的陣列表示中,一個索引所表示的元素的右孩子節點的索引
       // 計算出線段樹中指定索引位置的元素其右孩子節點的索引 -
       calcRightChildIndex(index) {
          return index * 2 + 2;
       }
    
       // 輔助函式: 融合兩棵線段樹,也就是對線段樹進行業務邏輯的處理 -
       merge(treeElementA, treeElmentB) {
          // 預設進行求和操作
          return treeElementA + treeElmentB;
       }
    
       // 輔助函式:更新融合的方法,也就是自定義處理線段樹融合的業務邏輯 +
       updateMerge(mergeMethod) {
          this.merge = mergeMethod;
       }
    
       // @Override toString() 2018-11-7 jwl
       toString() {
          let segmentTreeConsoleInfo = ''; // 控制檯資訊
          let segmentTreePageInfo = ''; // 頁面資訊
    
          // 輸出頭部資訊
          segmentTreeConsoleInfo += 'SegmentTree:';
          segmentTreePageInfo += 'SegmentTree:';
          segmentTreeConsoleInfo += '\r\n';
          segmentTreePageInfo += '<br/><br/>';
    
          // 輸出傳入的資料資訊
          segmentTreeConsoleInfo += 'data = [';
          segmentTreePageInfo += 'data = [';
    
          for (let i = 0; i < this.data.length - 1; i++) {
             segmentTreeConsoleInfo += this.data[i] + ',';
             segmentTreePageInfo += this.data[i] + ',';
          }
    
          if (this.data != null && this.data.length != 0) {
             segmentTreeConsoleInfo += this.data[this.data.length - 1];
             segmentTreePageInfo += this.data[this.data.length - 1];
          }
          segmentTreeConsoleInfo += '],\r\n';
          segmentTreePageInfo += '],<br/><br/>';
    
          // 輸出生成的線段樹資訊
          segmentTreeConsoleInfo += 'tree = [';
          segmentTreePageInfo += 'tree = [';
          let treeSize = 0;
          for (let i = 0; i < this.tree.length - 1; i++) {
             if (this.tree[i] !== undefined) treeSize++;
             segmentTreeConsoleInfo += this.tree[i] + ',';
             segmentTreePageInfo += this.tree[i] + ',';
          }
          if (this.tree != null && this.tree.length != 0) {
             if (this.tree[this.tree.length - 1] !== undefined) treeSize++;
             segmentTreeConsoleInfo += this.tree[this.tree.length - 1];
             segmentTreePageInfo += this.tree[this.tree.length - 1];
          }
          segmentTreeConsoleInfo += '],\r\n';
          segmentTreePageInfo += '],<br/><br/>';
          segmentTreeConsoleInfo += 'originArraySize:' + this.getSize() + ',';
          segmentTreePageInfo += 'originArraySize:' + this.getSize() + ',';
          segmentTreeConsoleInfo +=
             'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
          segmentTreePageInfo +=
             'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
    
          // 返回輸出的總資訊
          document.body.innerHTML += segmentTreePageInfo;
          return segmentTreeConsoleInfo;
       }
    }
    複製程式碼
  3. Main:主函式

    // main 函式
    class Main {
       constructor() {
          this.alterLine('MySegmentTree Area');
          // 初始資料
          const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
          // 初始化線段樹,將初始資料和融合器傳入進去
          let mySegmentTree = new MySegmentTree(nums);
          // 指定線段樹的融合器
          mySegmentTree.updateMerge((a, b) => a + b);
    
          // 輸出
          console.log(mySegmentTree.toString());
          this.show('');
          this.alterLine('MySegmentTree Queue Area');
          console.log('查詢區間[0, 2]:' + mySegmentTree.query(0, 2));
          this.show('查詢區間[0, 2]:' + mySegmentTree.query(0, 2));
          console.log('查詢區間[3, 9]:' + mySegmentTree.query(3, 9));
          this.show('查詢區間[3, 9]:' + mySegmentTree.query(3, 9));
          console.log('查詢區間[0, 9]:' + mySegmentTree.query(0, 9));
          this.show('查詢區間[0, 9]:' + mySegmentTree.query(0, 9));
       }
    
       // 將內容顯示在頁面上
       show(content) {
          document.body.innerHTML += `${content}<br /><br />`;
       }
    
       // 展示分割線
       alterLine(title) {
          let line = `--------------------${title}----------------------`;
          console.log(line);
          document.body.innerHTML += `${line}<br /><br />`;
       }
    }
    
    // 頁面載入完畢
    window.onload = function() {
       // 執行主函式
       new Main();
    };
    複製程式碼

Leetcode 上與線段樹相關的問題

  1. 303.區域和檢索-陣列不可變
    1. https://leetcode-cn.com/problems/range-sum-query-immutable/
    2. 方式一:使用線段樹
    3. 方式二:對陣列進行一定的預處理
  2. 307.區域和檢索 - 陣列可修改
    1. https://leetcode-cn.com/problems/range-sum-query-mutable/
    2. 方式一:對陣列進行一定的預處理,但是效能不是很好

程式碼示例

  1. 303 方式一 和 方式二

    // 答題
    class Solution {
       // leetcode 303. 區域和檢索-陣列不可變
       NumArray(nums) {
          /**
           * @param {number[]} nums
           * 處理方式一:對原陣列進行預處理操作
           */
          var NumArray = function(nums) {
             if (nums.length > 0) {
                this.data = new Array(nums.length + 1);
                this.data[0] = 0;
                for (var i = 0; i < nums.length; i++) {
                   this.data[i + 1] = this.data[i] + nums[i];
                }
             }
          };
    
          /**
           * @param {number} i
           * @param {number} j
           * @return {number}
           */
          NumArray.prototype.sumRange = function(i, j) {
             return this.data[j + 1] - this.data[i];
          };
    
          /**
           * Your NumArray object will be instantiated and called as such:
           * var obj = Object.create(NumArray).createNew(nums)
           * var param_1 = obj.sumRange(i,j)
           */
    
          /**
           * @param {number[]} nums
           * 處理方式二:使用線段樹
           */
          var NumArray = function(nums) {
             if (nums.length > 0) {
                this.mySegmentTree = new MySegmentTree(nums);
             }
          };
    
          /**
           * @param {number} i
           * @param {number} j
           * @return {number}
           */
          NumArray.prototype.sumRange = function(i, j) {
             return this.mySegmentTree.query(i, j);
          };
    
          return new NumArray(nums);
       }
    }
    複製程式碼
  2. 307 方式一

    // 答題
    class Solution {
       // leetcode 307. 區域和檢索 - 陣列可修改
       NumArray2(nums) {
          /**
           * @param {number[]} nums
           * 方式一:對原陣列進行預處理操作
           */
          var NumArray = function(nums) {
             // 克隆一份原陣列
             this.data = new Array(nums.length);
             for (var i = 0; i < nums.length; i++) {
                this.data[i] = nums[i];
             }
    
             if (nums.length > 0) {
                this.sum = new Array(nums.length + 1);
                this.sum[0] = 0;
                for (let i = 0; i < nums.length; i++)
                   this.sum[i + 1] = this.sum[i] + nums[i];
             }
          };
    
          /**
           * @param {number} i
           * @param {number} val
           * @return {void}
           */
          NumArray.prototype.update = function(i, val) {
             this.data[i] = val;
    
             for (let j = 0; j < this.data.length; j++)
                this.sum[j + 1] = this.sum[j] + this.data[j];
          };
    
          /**
           * @param {number} i
           * @param {number} j
           * @return {number}
           */
          NumArray.prototype.sumRange = function(i, j) {
             return this.sum[j + 1] - this.sum[i];
          };
    
          /**
           * Your NumArray object will be instantiated and called as such:
           * var obj = Object.create(NumArray).createNew(nums)
           * obj.update(i,val)
           * var param_2 = obj.sumRange(i,j)
           */
       }
    }
    複製程式碼
  3. Main

    // main 函式
    class Main {
       constructor() {
          this.alterLine('leetcode 303. 區域和檢索-陣列不可變');
          let s = new Solution();
          let nums = [-2, 0, 3, -5, 2, -1];
          let numArray = s.NumArray(nums);
    
          console.log(numArray.sumRange(0, 2));
          this.show(numArray.sumRange(0, 2));
          console.log(numArray.sumRange(2, 5));
          this.show(numArray.sumRange(2, 5));
          console.log(numArray.sumRange(0, 5));
          this.show(numArray.sumRange(0, 5));
       }
    
       // 將內容顯示在頁面上
       show(content) {
          document.body.innerHTML += `${content}<br /><br />`;
       }
    
       // 展示分割線
       alterLine(title) {
          let line = `--------------------${title}----------------------`;
          console.log(line);
          document.body.innerHTML += `${line}<br /><br />`;
       }
    }
    
    // 頁面載入完畢
    window.onload = function() {
       // 執行主函式
       new Main();
    };
    複製程式碼

線段樹中的更新操作

  1. 通過 leetcode 上 303 及 307 號題目可以分析出
    1. 使用陣列實現時,更新的操作是O(n)級別的,
    2. 查詢的操作是O(1)級別的,只不過初始化操作時是O(n)級別的。
    3. 使用線段樹實現時,更新和查詢的操作都是O(logn)級別的,
    4. 但是線段樹在建立的時候是O(n)的複雜度,
    5. 更準確的說是 4 倍的 O(n)的複雜度,
    6. 因為所用的空間是 4n 個,並且要對每個空間進行賦值。
  2. 對於線段樹來說,要考慮區間這樣的這樣的一種資料,
    1. 尤其是要查詢區間相關的統計資訊的時候,
    2. 同時資料是動態的,不時的還需要更新你的資料,在這樣的情況下,
    3. 線段樹是一種非常好的資料結構,不過對於線段樹來說,
    4. 大多數本科甚至是研究生的演算法教材中都不會涉及這種資料結構,
    5. 它本身是一種高階的資料結構,更多的應用於演算法競賽中。
  3. 更新操作和構建線段樹的操作類似。
    1. 如果要修改某個索引位置的值,
    2. 那麼就需要知道這個索引位置所對應的葉子節點,
    3. 遞迴到底後就能夠知道這個葉子節點,這時候只需要賦值一下,
    4. 然後 重新進行融合操作,因為該索引位置所在的區間需要進行更新,
    5. 只有這樣才能夠達到修改線段樹中某一個節點的值後
    6. 也可以改變相應的線段(區間)。

程式碼示例

  1. (class: MySegmentTree, class: NumArray2, class: Main)

  2. MySegmentTree

    // 自定義線段樹 SegmentTree
    class MySegmentTree {
       constructor(array) {
          // 拷貝一份引數陣列中的元素
          this.data = new Array(array.length);
          for (var i = 0; i < array.length; i++) this.data[i] = array[i];
    
          // 初始化線段樹 開4倍的空間 這樣才能在所有情況下儲存線段樹上所有的節點
          this.tree = new Array(4 * this.data.length);
    
          // 開始構建線段樹
          this.buildingSegmentTree(0, 0, this.data.length - 1);
       }
    
       // 獲取線段樹中實際的元素個數
       getSize() {
          return this.data.length;
       }
    
       // 根據索引獲取元素
       get(index) {
          if (index < 0 || index >= this.getSize())
             throw new Error('index is illegal.');
          return this.data[index];
       }
    
       // 構建線段樹
       buildingSegmentTree(treeIndex, left, right) {
          // 解決最基本問題
          // 當一條線段的兩端相同時,說明這個區間只有一個元素,
          // 那麼遞迴也到底了
          if (left === right) {
             this.tree[treeIndex] = this.data[left];
             return;
          }
    
          // 計算當前線段樹的左右子樹的索引
          const leftChildIndex = this.calcLeftChildIndex(treeIndex);
          const rightChildIndex = this.calcRightChildIndex(treeIndex);
    
          // 將一個區間拆分為兩段,然後繼續構建其左右子線段樹
          let middle = Math.floor(left + (right - left) / 2); //(left + right) / 2
    
          // 構建左子線段樹
          this.buildingSegmentTree(leftChildIndex, left, middle);
          // 構建右子線段樹
          this.buildingSegmentTree(rightChildIndex, middle + 1, right);
    
          // 融合左子線段樹和右子線段樹
          this.tree[treeIndex] = this.merge(
             this.tree[leftChildIndex],
             this.tree[rightChildIndex]
          );
       }
    
       // 查詢指定區間的線段樹資料
       // 返回區間[queryLeft, queryRight]的值
       query(queryLeft, queryRight) {
          if (
             queryLeft < 0 ||
             queryRight < 0 ||
             queryLeft > queryRight ||
             queryLeft >= this.data.length ||
             queryRight >= this.data.length
          )
             throw new Error('queryLeft or queryRight is illegal.');
    
          // 呼叫遞迴的查詢方法
          return this.recursiveQuery(
             0,
             0,
             this.data.length - 1,
             queryLeft,
             queryRight
          );
       }
    
       // 遞迴的查詢方法 -
       // 在以treeIndex為根的線段樹中[left...right]的範圍裡,
       // 搜尋區間[queryLeft...queryRight]的值
       recursiveQuery(treeIndex, left, right, queryLeft, queryRight) {
          // 如果查詢範圍 與 指定的線段樹的區間 相同,那麼說明完全匹配,
          // 直接返回當前這個線段即可,每一個節點代表 一個線段(區間)處理後的結果
          if (left === queryLeft && right === queryRight)
             return this.tree[treeIndex];
    
          // 求出當前查詢範圍的中間值
          const middle = Math.floor(left + (right - left) / 2);
    
          // 滿二叉樹肯定有左右孩子節點
          // 上面的判斷沒有完全匹配,說明需要繼續 縮小查詢範圍,也就是要在左右子樹中進行查詢了
          const leftChildIndex = this.calcLeftChildIndex(treeIndex);
          const rightChildIndex = this.calcRightChildIndex(treeIndex);
    
          // 判斷:
          //    1. 從左子樹中查還是右子樹中查,又或者從左右子樹中同時查,然後將兩個查詢結果融合。
          //    2. 如果 待查詢的區間的左端點大於查詢範圍的中間值,說明只需要從右子樹中進行查詢即可。
          //    3. 如果 待查詢的區間的右端點小於查詢範圍的中間值 + 1,說明只需要從左子樹中進行查詢。
          //    4. 如果 待查詢的區間在左右端點各分部一部分,說明要同時從左右子樹中進行查詢。
          if (queryLeft > middle)
             return this.recursiveQuery(
                rightChildIndex,
                middle + 1,
                right,
                queryLeft,
                queryRight
             );
          else if (queryRight < middle + 1)
             return this.recursiveQuery(
                leftChildIndex,
                left,
                middle,
                queryLeft,
                queryRight
             );
          else {
             // 求出 左子樹中一部分待查詢區間中的值
             const leftChildValue = this.recursiveQuery(
                leftChildIndex,
                left,
                middle,
                queryLeft,
                middle
             );
             // 求出 右子樹中一部分待查詢區間中的值
             const rightChildValue = this.recursiveQuery(
                rightChildIndex,
                middle + 1,
                right,
                middle + 1,
                queryRight
             );
             // 融合左右子樹種的資料並返回
             return this.merge(leftChildValue, rightChildValue);
          }
       }
    
       // 設定指定索引位置的元素 更新操作
       set(index, element) {
          if (index < 0 || index >= this.data.length)
             throw new Error('index is illegal.');
    
          this.recursiveSet(0, 0, this.data.length - 1, index, element);
       }
    
       // 遞迴的設定指定索引位置元素的方法 -
       // 在以treeIndex為根的線段樹中更新index的值為element
       recursiveSet(treeIndex, left, right, index, element) {
          // 解決最基本的問題 遞迴到底了就結束
          // 因為找到了該索引位置的節點了
          if (left === right) {
             this.tree[treeIndex] = element;
             this.data[index] = element;
             return;
          }
    
          // 求出當前查詢範圍的中間值
          const middle = Math.floor(left + (right - left) / 2);
    
          // 滿二叉樹肯定有左右孩子節點
          // 上面的判斷沒有完全匹配,說明需要繼續 縮小查詢範圍,也就是要在左右子樹中進行查詢了
          const leftChildIndex = this.calcLeftChildIndex(treeIndex);
          const rightChildIndex = this.calcRightChildIndex(treeIndex);
    
          // 如果指定的索引大於 查詢範圍的中間值,那就說明 該索引的元素在右子樹中
          // 否則該索引元素在左子樹中
          if (index > middle)
             this.recursiveSet(
                rightChildIndex,
                middle + 1,
                right,
                index,
                element
             );
          // index < middle + 1
          else this.recursiveSet(leftChildIndex, left, middle, index, element);
    
          // 將改變後的左右子樹再進行一下融合,因為遞迴到底時修改了指定索引位置的元素,
          // 那麼指定索引位置所在的線段(區間)也需要再次進行融合操作,
          // 從而達到修改一個值改變 相應的線段(區間)
          this.tree[treeIndex] = this.merge(
             this.tree[leftChildIndex],
             this.tree[rightChildIndex]
          );
       }
    
       // 輔助函式:返回完全二叉樹的陣列表示中,一個索引所表示的元素的左孩子節點的索引
       // 計算出線段樹中指定索引位置的元素其左孩子節點的索引 -
       calcLeftChildIndex(index) {
          return index * 2 + 1;
       }
    
       // 輔助函式:返回完全二叉樹的陣列表示中,一個索引所表示的元素的右孩子節點的索引
       // 計算出線段樹中指定索引位置的元素其右孩子節點的索引 -
       calcRightChildIndex(index) {
          return index * 2 + 2;
       }
    
       // 輔助函式: 融合兩棵線段樹,也就是對線段樹進行業務邏輯的處理 -
       merge(treeElementA, treeElmentB) {
          // 預設進行求和操作
          return treeElementA + treeElmentB;
       }
    
       // 輔助函式:更新融合的方法,也就是自定義處理線段樹融合的業務邏輯 +
       updateMerge(mergeMethod) {
          this.merge = mergeMethod;
       }
    
       // @Override toString() 2018-11-7 jwl
       toString() {
          let segmentTreeConsoleInfo = ''; // 控制檯資訊
          let segmentTreePageInfo = ''; // 頁面資訊
    
          // 輸出頭部資訊
          segmentTreeConsoleInfo += 'SegmentTree:';
          segmentTreePageInfo += 'SegmentTree:';
          segmentTreeConsoleInfo += '\r\n';
          segmentTreePageInfo += '<br/><br/>';
    
          // 輸出傳入的資料資訊
          segmentTreeConsoleInfo += 'data = [';
          segmentTreePageInfo += 'data = [';
    
          for (let i = 0; i < this.data.length - 1; i++) {
             segmentTreeConsoleInfo += this.data[i] + ',';
             segmentTreePageInfo += this.data[i] + ',';
          }
    
          if (this.data != null && this.data.length != 0) {
             segmentTreeConsoleInfo += this.data[this.data.length - 1];
             segmentTreePageInfo += this.data[this.data.length - 1];
          }
          segmentTreeConsoleInfo += '],\r\n';
          segmentTreePageInfo += '],<br/><br/>';
    
          // 輸出生成的線段樹資訊
          segmentTreeConsoleInfo += 'tree = [';
          segmentTreePageInfo += 'tree = [';
          let treeSize = 0;
          for (let i = 0; i < this.tree.length - 1; i++) {
             if (this.tree[i] !== undefined) treeSize++;
             segmentTreeConsoleInfo += this.tree[i] + ',';
             segmentTreePageInfo += this.tree[i] + ',';
          }
          if (this.tree != null && this.tree.length != 0) {
             if (this.tree[this.tree.length - 1] !== undefined) treeSize++;
             segmentTreeConsoleInfo += this.tree[this.tree.length - 1];
             segmentTreePageInfo += this.tree[this.tree.length - 1];
          }
          segmentTreeConsoleInfo += '],\r\n';
          segmentTreePageInfo += '],<br/><br/>';
          segmentTreeConsoleInfo += 'originArraySize:' + this.getSize() + ',';
          segmentTreePageInfo += 'originArraySize:' + this.getSize() + ',';
          segmentTreeConsoleInfo +=
             'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
          segmentTreePageInfo +=
             'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
    
          // 返回輸出的總資訊
          document.body.innerHTML += segmentTreePageInfo;
          return segmentTreeConsoleInfo;
       }
    }
    複製程式碼
  3. NumArray2

    // 答題
    class Solution {
       // leetcode 307. 區域和檢索 - 陣列可修改
       NumArray2(nums) {
          /**
           * @param {number[]} nums
           * 方式一:對原陣列進行預處理操作
           */
          var NumArray = function(nums) {
             // 克隆一份原陣列
             this.data = new Array(nums.length);
             for (var i = 0; i < nums.length; i++) {
                this.data[i] = nums[i];
             }
    
             if (nums.length > 0) {
                this.sum = new Array(nums.length + 1);
                this.sum[0] = 0;
                for (let i = 0; i < nums.length; i++)
                   this.sum[i + 1] = this.sum[i] + nums[i];
             }
          };
    
          /**
           * @param {number} i
           * @param {number} val
           * @return {void}
           */
          NumArray.prototype.update = function(i, val) {
             this.data[i] = val;
    
             for (let j = 0; j < this.data.length; j++)
                this.sum[j + 1] = this.sum[j] + this.data[j];
          };
    
          /**
           * @param {number} i
           * @param {number} j
           * @return {number}
           */
          NumArray.prototype.sumRange = function(i, j) {
             return this.sum[j + 1] - this.sum[i];
          };
    
          /**
           * Your NumArray object will be instantiated and called as such:
           * var obj = Object.create(NumArray).createNew(nums)
           * obj.update(i,val)
           * var param_2 = obj.sumRange(i,j)
           */
    
          /**
           * @param {number[]} nums
           * 方式二:對原陣列進行預處理操作
           */
          var NumArray = function(nums) {
             this.tree = new MySegmentTree(nums);
          };
    
          /**
           * @param {number} i
           * @param {number} val
           * @return {void}
           */
          NumArray.prototype.update = function(i, val) {
             this.tree.set(i, val);
          };
    
          /**
           * @param {number} i
           * @param {number} j
           * @return {number}
           */
          NumArray.prototype.sumRange = function(i, j) {
             return this.tree.query(i, j);
          };
    
          return new NumArray(nums);
       }
    }
    複製程式碼
  4. Main

    // main 函式
    class Main {
       constructor() {
          this.alterLine('leetcode 307. 區域和檢索 - 陣列可修改');
          let s = new Solution();
          let nums = [1, 3, 5];
          let numArray = s.NumArray2(nums);
    
          console.log(numArray.sumRange(0, 2));
          this.show(numArray.sumRange(0, 2));
          numArray.update(1, 2);
          console.log(numArray.sumRange(0, 2));
          this.show(numArray.sumRange(0, 2));
       }
    
       // 將內容顯示在頁面上
       show(content) {
          document.body.innerHTML += `${content}<br /><br />`;
       }
    
       // 展示分割線
       alterLine(title) {
          let line = `--------------------${title}----------------------`;
          console.log(line);
          document.body.innerHTML += `${line}<br /><br />`;
       }
    }
    
    // 頁面載入完畢
    window.onload = function() {
       // 執行主函式
       new Main();
    };
    複製程式碼

更多線段樹相關的話題

  1. 在 leetcode 上可以找到線段樹相關的題目
    1. https://leetcode-cn.com/tag/segment-tree/
    2. 題目總體難度都是困難的,所以線段樹是一種高階資料結構,
    3. 在一般的面試環節是不會看到線段樹的影子的,
    4. 線段樹的題目整體是有一定的難度的,
    5. 尤其是這些問題在具體使用線段樹的時候,
    6. 不一定是直接的使用線段樹,很有可能需要繞幾個彎子,
    7. 如果你不去參加演算法競賽的話,線段樹不是一個重點。
  2. 線段樹雖然不是一個完全二叉樹,但是可以把它看作是一棵滿二叉樹,
    1. 進而可以使用陣列的方式去儲存這些結構,
    2. 這和之前實現的堆相應的儲存方式是一致的,
    3. 對線段樹的學習可以深入理解樹這種結構,
    4. 當節點中儲存的內容不一樣的時候它所表示的意義也不一樣的時候,
    5. 相應的就可以來解決各種各樣的問題,
    6. 它使用的範圍是非常廣泛的,對於線段樹的構建,
    7. 對於線段樹節點儲存的是什麼,它的左右子樹代表的是什麼意思,
    8. 其實和二分搜尋樹是完全不同的,
    9. 當你賦予這種結構合理的定義之後,就可以非常高效的處理一些特殊的問題,
    10. 比如說對於線段樹來說,就可以非常高效的處理了和線段(區間)有關的問題。
  3. 自定義線段樹實現了三個方法
    1. 建立線段樹、查詢線段樹、更新線段樹中一個元素,
    2. 這三個方法都使用了遞迴的操作,
    3. 同時這個遞迴的寫法在有一些層面和之前的二分搜尋樹是不同的,
    4. 很大程度的不同是表現在遞迴之後
    5. 最終還是要對線段樹中左右兩個孩子的節點進行一個融合的操作,
    6. 這實際上是一種後序遍歷的思想。
  4. 遞迴的程式碼無論是在巨集觀的角度上還是從微觀的角度
    1. 都能夠更深一步的對遞迴有進一步的認識。
  5. 對於線段樹來說其實還有很多可以深入挖掘的東西
    1. 例如對線段樹中一個區間進行更新,對應的時間複雜度是O(n)級別的,
    2. 因為這個區間裡所有的元素都要訪問到,這個操作相對來說是比較慢的,
    3. 為了解決這個問題,線上段樹中有一個專門的方式來解決它,
    4. 對應的方法通常稱之為懶惰更新,也可以叫做懶惰的傳播,
    5. 在自己實現的動態陣列中有一個縮容的操作,
    6. 就有使用到懶惰的這個概念,線上段樹中也可以使用這樣的思想,
    7. 在更新了中間節點的時候其實還要更新下面的葉子節點,
    8. 但是先不進行這個更新,這就是懶的地方,
    9. 先使用另外一個叫做 lazy 的陣列記錄這次未更新的內容,
    10. 有了這個記錄,就不需要實際的去更新這些節點,
    11. 當你再有一次更新或者查詢操作的時候,
    12. 也就是當你再碰到這些節點的時候,
    13. 那麼碰到這些節點之前都要先查一下已經記錄的這個 lazy 陣列中
    14. 是否有之前需要更新的內容,如果沒有更新,那麼在訪問它們之前,
    15. 先將 lazy 陣列中記錄的未更新的內容進行一下更新,
    16. 更新以後再來進行應該進行的訪問操作,這樣做在更新一個區間的內容的時候,
    17. 就又變成了 logn 的複雜度了,只需要訪問到中間節點就夠了,
    18. 不需要對底層的所有節點都進行訪問,
    19. 於此同時對於其他的查詢或者新的更新操作,也依然是這樣的一個複雜度,
    20. 只不過碰到相應的節點的時候看一下 lazy 陣列中有沒有記錄相應的內容就好了,
    21. 這個思想在實現的時候,有相應的很多細節需要注意,
    22. 這一點也是一個比較高階的話題,有一個印象有一個概念就 ok。
  6. 自己實現的線段樹本質上是一個一維的線段樹
    1. 線段樹還可以擴充到二維,
    2. 一維線段樹就是指處理的空間是在一個一維空間中,是在一個座標軸中,
    3. 如果根節點是一個線段的話,左邊半段就是它的左節點,
    4. 右邊半段就是它的右節點,但是可以把這個思想擴充套件成二維空間中,
    5. 對於根節點可以記錄的是一個矩陣的內容,然後對這個矩陣進行分塊兒,
    6. 把它分成四塊兒,分別是左上、右上、左下、右下這四塊兒,
    7. 這樣一來就可以讓每一個節點有四個孩子,
    8. 對於這四個孩子每個孩子表示這個矩陣中相應的一塊兒,
    9. 對於每一個孩子它們依舊是一個更小的矩陣,
    10. 對於這個更小的矩陣又可以把它分成四塊兒,
    11. 相應的每一個節點有四個孩子,依此類推,
    12. 直到在葉子節點的時候每一個節點只表示一個元素,
    13. 這樣的一種線段樹就叫做二維線段樹,所以對於二維區間相應的查詢問題,
    14. 也可以使用線段樹這樣的思路來解決。
    15. 所以不僅是二維線段樹,其實也可以設計出三維線段樹,
    16. 那麼對於一個三維的矩陣,或者是對於一個立方體上的資料,
    17. 可以把這個立方體切成八塊兒,那麼每一個節點可以分成八個節點,
    18. 對於每一個小的節點,它是一個更小的立方體,然後可以這樣繼續細分下去,
  7. 線段樹本身它就是一個思想,是在如何使用樹這種資料結構,
    1. 將一個大的資料單元拆分成不同的小的資料單元,遞迴的來表示這些資料,
    2. 同時利用這種遞迴的結構可以高效的進行訪問,
    3. 從而進行諸如像更新查詢這樣的操作,這本身就是樹這種結構的一個實質。
  8. 自己實現的線段樹是一個陣列的儲存方式,
    1. 使用陣列的儲存方式,相應的就會出現如果你有 n 個元素,
    2. 那麼就需要開闢 4n 個儲存空間,在這個空間中其實有很多空間是被浪費的,
    3. 對於線段樹其實可以使用鏈式的方式進行儲存,
    4. 可以設計一個單獨的線段樹所使用的節點類,
    5. 在這個節點類中就可以儲存所表示的區間
    6. 它的左邊界是誰、右邊界是誰、相應的元素值是誰、以及它的左右孩子,
    7. 對於這樣的一個節點也可以使用鏈式的方式也可以建立出這個線段樹,
    8. 在這種情況下,不需要浪費任何的空間,
    9. 如果你的線段樹要處理的節點非常多的話,
    10. 有可能開 4n 的空間對你的電腦的儲存資源負擔比較大,
    11. 這時候就可以考慮使用鏈式這種所謂動態線段樹。
  9. 實際上對於動態線段樹來說有一個更加重要的應用
    1. 在自己所實現的線段樹,
    2. 對於一個區間中相應的每一個元素都要使用一個節點來表達,
    3. 這樣的結果就是整個線段樹所佔的空間大小是 4n,
    4. 如果想要探討的這個區間特別大的話,例如有一億這麼大的一個區間,
    5. 但是其實很有可能你並不會對這麼大的一個區間中每一個長度為 1 的子區間都感興趣,
    6. 在這種情況下很有可能不需要一上來就建立一個巨大的線段樹,
    7. 就從根節點開始,初始的時候就這一個節點,它表示從 0 到一億這樣的一個區間,
    8. 如果你關注[5,16]這樣的一個區間,在這種情況下再開始動態的建立這個線段樹,
    9. 那麼這個動態建立的方法,可能是首先將這個線段樹根節點分成兩部分,
    10. 左孩子表示[0,4]這樣的一個區間,右孩子表示 5 到一億這樣的一個區間,
    11. 進而對 5 到一億這樣的區間再給分成兩部分,左半部分表示[5,16]
    12. 右半部分表示 17 到一億這個區間。至此對於這棵線段樹來說,
    13. 只有 5 個節點,也可以非常快速的關注到[5,16]這個區間相應的內容,
    14. 那麼使用這樣的方式,如果你的區間非常大,
    15. 但是你關注的區間其實並不會分佈到這個大區間中每一個小部分的時候,
    16. 可以實現這樣的一個動態線段樹,因為更加的有利。

區間操作相關-另外一個重要的資料結構

  1. 樹狀陣列(Binary Index Tree)
    1. 對區間這種資料進行操作時,就可能會使用到這種資料結構了,
    2. 也就是樹狀陣列,也被簡稱為 BIT,也叫二叉索引樹,
    3. 樹狀陣列也是一個非常經典的樹狀結構,
    4. 也是演算法競賽中的常客,在某一些問題上樹狀陣列解決的問題和線段樹是重疊的,
    5. 不過在另外一些問題上樹狀陣列也有它獨特的優勢。

區間相關的問題

  1. 對於區間相關的問題不一定使用線段樹或者樹狀陣列這樣的專門的資料結構來解決
    1. 和區間相關的有一類非常經典的問題,叫做 RMQ(Range Minimum Query),
    2. 也就是在一個區間中去相應的查詢最小值,
    3. 其實使用自己實現的線段樹完全可以解決這個問題,
    4. 不過對於 RMQ 問題由於它太過經典,
    5. 有非常多個研究相應的也產生了非常多的其它辦法來解決這個問題,
    6. 而不僅僅是使用線段樹或者是使用樹狀陣列。

相關文章