樹狀數組(Binary Indexed Tree,BIT)
樹狀數組(Binary Indexed Tree)
前面幾篇文章我們分享的都是關於區間求和問題的幾種解決方案,同時也介紹了線段樹這樣的數據結構,我們從中可以體會到合理解決方案帶來的便利,對於大部分區間問題,線段樹都有其絕對的優勢,今天這篇文章,我們就來欣賞由線段樹變形的另外一個數據結構--樹狀數組,樹狀數組通常也用於解決區間求和、單點更新的問題,而且效率比線段樹高一些(樹狀數組區間求和和單點更新的時間復雜度均為o(log n)),相對而言,線段樹的應用範圍可能更廣泛一些。但不得不承認,樹狀數組確實也是一種優雅高效的結構。接下來,我們就一起來揭開它的神秘面紗。
第一點,樹狀數組的結構和線段樹類似,但是比線段樹的節點少,第二點,樹狀數組的每個節點中存儲的也是對應範圍的元素和,同時與線段樹一樣,樹狀數組也是采用數組存儲結構,第三點,樹狀數組與線段樹的下標定義規則不同。如下圖所示,這是線段樹的存儲圖:
我們從上篇文章(文章鏈接:線段樹第二彈(區間更新))中可以發現,線段樹的下標編碼規則是由上而下、從左至右依次編碼,在樹狀數組中,不再采用這樣的編碼方式,具體如何編碼稍後將會解釋,現在我們先來觀察一下線段樹的特征。
我們假設,此線段樹中每個節點存儲的是相應的區間範圍內所有元素的和。這時我們可以發現,對於每個右孩子節點的值,我們總能通過其父親節點值減去左兄弟節點值來計算,這就意味著,即使沒有右節點,也絲毫不影響我們求解對應區間的區間和,那我們何不節省空間,但這樣又引發了另外一個問題,在去掉所有的右節點之後,之前的下標編碼方式肯定是用不了了,這時候我們就需要一套新的下標編碼方式,既能節省這部分空間,又不會將原來的問題復雜化,最好能將問題進一步簡化,這就是樹狀數組的產生背景。至於新的一套編碼方式一路走來經歷怎樣的探索過程,不是我們今天要說明的重點,在此我就不作過多的解釋,現在我就直接拋出樹狀數組最後確定版的下標編碼方式,使用這個方式的原因當然是:它簡單啊、好用啊、優雅啊,何以見得?等看完這篇文章,你大概就能體會到它的魅力了。
如下圖所示為樹狀數組的邏輯結構,其中每個節點中存儲的依然是對應區間的元素和:
這是樹狀數組的邏輯結構,其中方塊中的數字表示對應區間的下標範圍,紅色字體表示節點的下標,圖中的藍色粗線條將每個節點和其對應的下標連接(線條這麽粗,大概不會有人看不清楚了吧)。乍一看覺得節點下標的編碼方式似乎有點無理取鬧,同一層級的兩個節點竟然下標不相連,這是什麽邏輯?每當我們感覺走投無路的時候,也就是我們需要重新審視手裏掌握的所有線索的時候,只有不放過任何一個細微的線索,才能找到破解之法。不妨我們就將所有能觀察到的線索一一列出。
上面我們介紹的其實是樹狀數組的邏輯結構,它的物理存儲就是一個一維的數組。我們將上圖的特征制表如下:
觀察上表,我們可以得出如下結論:
一、節點下標為 i 時,節點中對應的最後一個元素下標為 i
二、節點下標對應的二進制數末尾有 k 個 0 ,節點中對應的元素個數為 2 ^ k
三、節點中對應的元素下標是連續的
四、樹狀數組的節點個數和原數據元素個數相等
以上便是樹狀數組的主要基本特征,知道了這些特征之後,我們可以發現,要改變原數據數組中的一個元素值,在樹狀數組中最多需要更改 o(log n)個節點值,因此單點更新的時間復雜度為 o(log n)。單點更新的具體實現怎麽做,在文章末尾會向大家展示,現在先繼續討論接下來的問題。
這時候我們會發現,剛才所列出來的所有特征,似乎沒什麽用得上的,就像在生活中我們手裏掌握的零碎的知識、技能、人脈等看起來是一片散沙,我們不知道什麽時候才會用得到,甚至窮盡一生也不可能全都用得到,但是一旦有機會用,我們才能真正意識到那些是多麽的重要,其中的聯系是多麽緊密。與其說學習算法是在學一門技術,不如說是在學習一門藝術,因為在此期間接觸到的很多方法都可以從生活中找到影子。所以我們暫時不要灰心,繼續研究,也許更深入些,這些瑣碎的特征就會變得有用。
樹狀數組方便處理的其實是“前 i 個元素和”這種問題,
以上圖為例:
前 1 個元素和為
sum[1] = sum[0001] = tree[1]= tree[0001]
前 2 個元素和為
sum[2] = sum[0010] = tree[2]=tree[0010]
前 3 個元素和為
sum[3] = sum[0011] = tree[2]+tree[3]= tree[0011] + tree[0010]
前 4 個元素和為
sum[4] =sum[0100] = tree[4]= tree[0100]
前 5 個元素和為
sum[5] = sum[0101]= tree[4]+tree[5]=tree[0101]+tree[0100]
前 6 個元素和為
sum[6] = sum[0110] = tree[4]+tree[6]= tree[0110] + tree[0100]
前 7 個元素和為
sum[7]=sum[0111]=tree[7]+tree[6]+tree[4]=tree[0111]+tree[0110]+tree[0100]
前 8 個元素和為
sum[8] = sum[1000] = tree[8]= tree[1000]
紅色部分是下標的二進制表示形式,我覺得到目前為止,我們可能真的是走投無路,才無所不用其極,連下標也不放過。仔細觀察這些下標,似乎還是有一定的規律可循的,現在我們就挑一個表達式最長,能說明問題的來研究一下
sum[7]=sum[0111]=tree[7]+tree[6]+tree[4]=tree[0111]+tree[0110]+tree[0100]
由上述表達式可以發現,前7個元素和 sum[0111] 的加數包括 tree[0111], 在此基礎上,每次將下標從右向左數第一位 1 抹去作為下一個加數的下標, 直到數字變為 0 結束,0111 抹去最後一位 1 得到 0110,0110 抹去最後一位 1 得到 0100,0100 抹去最後一位 1 得到 0000 結束運算。這似乎勉強可以算作一個規律吧,經過驗證發現,以上所有的表達式均符合這個規律。在此我可以告訴大家,經過無數的高手驗證,這個規律確實存在,所以我們可以大膽的使用。
但是,在我們用話語描述的時候,可以說抹掉最後一位 1 ,在實際的實現中,我們就需要用規範的語言來表達,要想達到抹掉最後一位 1 的效果,就需要減去一個數 x ,將 0111 抹掉最後一位 1 得到 0110 時,x = 0001,將0110抹掉最後一位 1 得到 0100 時,x = 0010 ,將 0100 抹掉最後一位 1 時,x = 0100 。也就意味著,原數值需要抹掉哪一位,那麽 x 的哪一位就為 1 ,其余各位均為 0 。如何求原數值最後一位 1 是哪一位呢?我們可以發現原數值末位有 k 個 0 時,x = 2 ^ k,現在,我們前面列出來的特征就有聯系了。目前我們的任務就是求 k 的值,當然對於人來說,一眼看出一個數末尾有幾個0簡直易如反掌,但對於計算機,似乎沒那麽容易,這時候如果將原數值的二進制數看作整體,似乎不合理,我們需要將其各位分離,這就用到了位運算。對於當前的問題,有一個求解技巧可以分享給大家,我們都知道在計算機內部兩數的運算用的是補碼實現的,對於正數來說,補碼和原碼形式是一樣的,但對於負數來說,補碼便是將原碼按位取反後在末位加 1 ,這就導致一個負數絕對值的補碼和這個負數的補碼在形式上滿足:以從右向左數第一個非零位為界,在左側,負數絕對值的補碼和負數的補碼各位均不相同,在右側,負數絕對值的補碼和負數的補碼各位均為0,將兩數做 and 運算得到的數字剛好就是我們需要求的 x ,我們知道 負數的絕對值和負數本身互為相反數,同時也說明一個正數的補碼與其相反數的補碼做 and 運算 得到的數字就是 x 。
驗證實例如下:
1 &(-1)補碼運算 : 0001 & 1111 = 0001
2 &(-2)補碼運算 : 0010 & 1110 = 0010
3 &(-3)補碼運算 : 0011 & 1101 = 0001
4 &(-4)補碼運算 : 0100 & 1100 = 0100
5 &(-5)補碼運算 : 0101 & 1011 = 0001
6 &(-6)補碼運算 : 0110 & 1010 = 0010
7 &(-7)補碼運算 : 0111 & 1001 = 0001
8 &(-8)補碼運算 : 1000 & 1000 = 1000
由此,我們得到每次的減數 x =i & ( -i )
至此,求 前 n 項和的規律已經找到,我們的任務就是將這個規律用規範的語句描述並嘗試著用代碼實現。
求和具體實現描述如下:
假設 當前求前 i 項 之和
第一步:判斷 i > 0 是否成立。如果成立進行下一步,否則退出循環
第二步:sum = sum + tree[ i ] , x= i &(-i)
第三步:i = i - x ,回到第一步
知道了前 i 項和的求解方法之後,要求解區間和相對來說就容易多了,例如求解區間為 i ~ j ,顯然,我們就可以得知
sum[ i~j ] = sum [ j ] - sum [ i ] 。
解決了前 i 項和的問題之後,現在我們再回過頭來分析單點更新的問題,還是剛才的圖:
假設我們現在要修改的元素在原數據中下標為 3 ,那麽在樹狀數組中,我們需要修改的節點下標分別為 3 、4、8
還是按照剛才的分析方法,在原數據中待修改元素下標為 3 = 0011
在樹狀數組中,需要修改的節點下標為 3 = 0011、4 =0100、8 =1000,
觀察發現,0011 +0001 = 0100 ,0100 +0100 = 1000 ,發現規律了嗎?x 值依然是剛才的求法,現在是每次給當前的下標值加 x 就得到下一個加數的下標值,直到下標值大於元素的總個數停止。具體的實例不過多贅述,大家可以自己私下驗證。
單點更新的具體描述如下:(假設更新的規則是給 下標為 i 的元素加 y )
假設待更新元素下標為 i ,元素總個數為 n
第一步:判斷 i <= n 是否成立,成立則進行下一步,不成立結束循環
第二步:tree[ i ] = tree[ i ] + y , x = i & ( -i ) ,進行下一步
第三步:i = i + x ,跳回第一步
以上就是今天內容的理論部分,下面為大家奉上核心代碼實現部分,希望今天的分享能讓大家有收獲。代碼如下:
除了並查集,這應該是見過的最精簡最優雅最高效的代碼了。
還沒有關註公眾號的朋友可以長按下圖識別圖中二維碼關註我。
老規矩,打開網頁http://paste.ubuntu.com/25548013/查看網頁版代碼。
樹狀數組(Binary Indexed Tree,BIT)