我借鑒了這個視頻中的講解的填坑法,我認為非常易於理解。有翻牆能力和基本英語聽力能力請直接去看視頻,並不需要繼續閱讀。
naive 算法
考慮一個這樣的場景: 給定一個int數組, 我們想知道它的連續子序列的累加和。比如這個數組長度為N, 求數組中下標0~N-1
, 2~3
, 0~N/2
的和。 如果直接計算,易知在平均情況下,我們給出一個N長度數組的子序列累加和都需要~N
的數組訪問次數和相加操作。
如果用最少的計算時間給出結果? 我們容易想到設一個記錄累加和的數組(不考慮可能的溢出情況): 比如數組[1, 2, 3, 4, 5]
, 我們為它生成一個累加和數組[1, 3, 6, 10, 15]
。生成這個數組需要一次遍歷,即N次訪問操作。而我們給出累加和結果的時候,可以1常數次訪問累加和數組直接給出結果。
但是,當我們需要更新數組中的元素的時候呢? 直接在數組上操作需要~1次訪問。 如果我們使用了累加數組,則需要對累加數組也進行更新,平均每次更新需要對累加和數組進行~N次訪問。 所以我們可以給出以下的表格:
update
更新操作
sum
獲取子序列累加值操作
construct
構建初始輔助數據結構
操作 | construct | sum | update |
---|---|---|---|
暴力相加 | 0 | ~N | ~1 |
累加數組 | ~N | ~1 | ~N |
在頻繁操作的情況下, 尤其是sum和update的操作次數接近的時候, 暴力相加和使用了累加數組的效率是相近的。 考慮操作M次, M接近於N的情況下, 這兩種處理方式的時間復雜度都是 ~M * N
是一個平方級的算法。 在很多場景中,平方級的算法都被認為是不可接受的。
這種場景中,使用 Binary Indexed Tree 和 Segment Tree 都能對效率起到數量級的提升作用。 尤其是 Binary Indexed Tree, 易於理解, 實現起來也短小精美。
通過填坑法理解Binary Idexed Tree
首先,Binary Indexed Tree的實現上, 並不是我們從字面意思上想到的編程實現了一個樹形結構。 叫它是 Binary Indexed Tree 是因為對它進行操作的時候體現了邏輯層次上的層次關系。
給定以下場景, 一個長度為N的int數組,想對它進行求子序列和操作。 我們基於這個數組構建的Binary Indexed Tree 也是一個存儲在長度為N的int數組(此處不考慮溢出)。而如何構建這個長度為N的序列即Binary Indexed Tree, 是理解Binary Indexed Tree的關鍵。
我們給出一個數組:
數組:[ 0, 2, 0, 1, 1, 1, 0, 4, 4, 0, 1, 0, 1, 2, 3, 0 ]
下標: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
為了在構建Binary Indexed Tree的時候方便(將簡化一些位操作代碼), 我們把它由從0開始的初始下標改為從1開始的初始下標:
數組:[ 0, 2, 0, 1, 1, 1, 0, 4, 4, 0, 1, 0, 1, 2, 3, 0 ]
下標: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
Binary Indexed Tree的思想是,每一個整數都可以由幾個二進制指數的相加和唯一表示:
例如: 11 = 2^3 + 2^1 + 2^0
這是因為每個數的二進制表示都是唯一的: 11 的二進制表示 1011
即對應了上面的式子.
Binary Indexed Tree 要處理的這個數,就是下標。 按照Binary Indexed Tree的邏輯, 我們要計算下標1~11
的元素的累加和,應該分別計算頭2^3
個元素的的累加和(即1~8
的累加和), 然后加上接下來的2^1
個元素的累加和(即9~10
的累加和), 最后加上2^0
個元素的累加和(11~11
的累加和)。 下面表示更清晰:
數字 11 = 2^3 + 2^1 + 2^0
數字 11 = 8 + 2 + 1
序列 1~11 = 1~8 + 9~10 + 11
序列和 SUM(11)= BIT[8] + BIT[10] + BIT[11]
構建Binary Indexed Tree之后,設構建的數組名為BIT, 下標從1開始,長度為N。
接下來要談的就是如何構建Binary Indexed Tree. Fenwick的論文中和網上很多教程中給出了這樣的圖片。
我個人認為對於初學者並不好理解。 而這個視頻中的講解的填坑法,我認為非常易於理解。有翻牆能力和基本英語聽力能力請直接去看視頻,並不需要繼續閱讀。
如果你還在看。唔,還是這個數組, 我們來構建它的BIT(Binary Indexed Tree)
第一層,我們面對的是1~16
這個區間,我們需要填充1~16
這個區間中所有下標為從1開始計數的2的指數
次 (1, 2, 4, 8, 16)的BIT數組, 填充的值是從1開始到對應下標的的累加值:
第二層,我們要填充的是3~3
, 5~7
, 9~15
三個區間, 同樣,我們填充每個區間從開始下標開始計數為2的指數
次的BIT的元素。 以9~15
區間為例,從9開始, 9計數為1即2^0
,10計數為2即2^1
的元素,12計數為4即2^2
, 所以下標為9, 10, 12的元素就是我們這層要填充的元素,分別對應於區間9~9
, 9~10
, 9~12
的累加值. 另外兩個區間方式類似。
第三層,我們要填充的是, 7~7
, 11~11
, 13~15
區間,用同樣的方法來進行填充
第四層, 我們只有15~15
區間需要填充了, 至此一個BIT數組填充完畢
求1~K區間的累加和的sum操作
上面的過程是通過填充BIT數組,展示了BIT數組各個下標對應的值是怎么來的。那么我們如何利用這個數組來進行求和呢?
我們以求區間1~15
的累加和為例, 展示這種逐層累加來進行求和的方法。 從BIT[15]
開始,距離BIT[15]
最近的上層是BIT[14]
, 距離BIT[14]
最近的上層是BIT[12]
, 距離BIT[12]
最近的上層是BIT[8]
, 至此BIT[8]
已經沒有上層了,我們停止累加。 所以,區間1~15
的累加和就是 BIT[15] + BIT[14] + BIT[12] + BIT[8] = 20
但是,這種找上層最近結點的功能怎么用代碼來實現呢?
我們只需要稍加觀察這些BIT數組下標的二進制表示,就會發現從15 -> 14 -> 12 -> 8
的過程, 實際上就是依次把每個數字的二進制表示的最后(從左往右看)一個1反轉為0的過程。 也正對應了15 = 2^3 + 2^2 + 2^1 + 2^0
, 依次去掉最小的2的指數項的過程。這就是Binary Indexed Tree求累加和的規律。
所以,利用一個BIT數組求1~K
區間的累加和,需要從BIT[K]
項開始, 依次翻轉K
的二進制表示的最后一個1
來獲取下一項, 直到歸零。 然后把所有獲取到的項加起來, 即是1~K
區間上的累加和。
具體來講,如何翻轉最后一個1? 這里我們要講到一個trick:
還是以下標15為例, 8bit字長時(32bit, 64bit情況相似) 15的補碼表示是00001111
, -15的補碼表示是11110001
,我們發現是00001111
和11110001
按位相與就能提取出00000001
, 即是二進制表示的最后一位1。 然后我們用15
直接減去這個提取出的數字,實際上就是進行了翻轉。 即 15 - 15&(-15)
==> 00001111 - (00001111 & 11110001)
==> 00001110
= 14
就得到了下一項。 這是一個非常簡練的式子, BIT[K]
的下一項就是BIT[K - (K & -K)]
。 大家可以自己對照圖片實驗。 由此, 我們可以得到我們的第一段程序:
int sum(int k){
int sum = 0;
while (k > 0){
sum += BIT[k];
k -= (k & -k);
}
return sum;
}
如何衡量利用BIT計算1~K的累加和的效率?
從圖上來說,我們可以看到與形成的BIT的層次有關。 對一個BIT數組進行sum操作, 對BIT數組訪問次數最多的就是層次最深的結點。 從二進制表示的角度來說,至多不超過其下標的二進制表示中最多的1的個數。 這正是一個log的關系,對於長度為N的BIT數組,一次sum操作訪問數組次數的上界就是 logN + 1
對第k項進行更新的update操作
log級別的sum操作確實比線性的暴力操作有不小提升,但是相比於使用累加數組的常數次訪問還是慢。 Binary Indexed Tree的優越性體現在對BIT數組進行update
操作的數組訪問次數也可以縮小到log級別。
考慮對原數組下標為6的元素進行+2
操作,會如何影響BIT數組?
首先我們從圖上看到BIT[6]
表示了5~6
區間元素的累加和, 所以BIT[6]
需要+2
, 然后我們再在BIT數組中找更新元素6影響到的區間, 發現BIT[8]
表示了1~8
區間的累加和包含第六個元素, BIT[8]
也需要+2, 然后BIT[16]
表示了1~16
區間的累加和, 也需要+2
。 BIT數組中再找不到包含第六個元素的區間, 我們對BIT數組的更新就完成了。 如下圖所示:
同樣,我們也想到,如何編程實現找到需要更新的區間呢? update
操作和sum
操作是相反的方向,實際上並不是同一個樹形結構。這里我們再次把下標展開為二進制形式:
從被update的下標K開始, 所有被影響到的BIT數組元素的下標可以由K逐個推得。 這里的規律是, 以6的二進制表示00110
開始, 可以找到的下一個下標為 00110 + 00010 = 01000
即為8, 從8的二進制表示01000
開始, 01000 + 01000 = 10000
即為16。 這次,我們從K開始, 把K加上其二進制表示的最后一位1形成的數字,即推導出下一項的下標。 而我們從sum函數中已經知道的trick是 K & -K
就可以提取出最后一位為1的數。 所以, BIT[K]
的下一項就是BIT[K + (K & -K)]
。 update的代碼實現為:
// N 為BIT數組長度
// val 為更新值
int update(int k, int val){
while (k <= N){
BIT[k] += val;
k += (k & -k);
}
}
由此,我們也可以得到update操作的時間復雜度。 從二進制表示的角度來看,因為每尋找一次下一項都是找最后一位為1的位數更高的, 所以對於長度為N
的BIT數組, 至多需要翻轉logN
次就可以完成一次更新。
至於Binary Indexed Tree的建立
到現在,可能有人有疑問, 我們只講了sum操作和update操作, 並沒有講如何構建一個Binary Indexed Tree(construct操作)啊。 並且,從圖片上看的填坑法雖然容易理解,但是程序寫起來也是比較麻煩的吧。
其實換個思路, 如果我們把初始數組設為全0的數組, 然后依次把數組中的元素都進行一次update操作,不就完成了對BIT數組的構建嗎?
Binary Indexed Tree的效率和拓展
每次update平均消耗logN
次數組訪問操作, 構建一個長度為N的數組的BIT數組的時間復雜度是NlogN
級別的。我們可以更新我們的表格了:
操作 | construct | sum | update |
---|---|---|---|
暴力相加 | 0 | ~N | ~1 |
累加數組 | ~N | ~1 | ~N |
Binary Indexed Tree | ~NlogN | ~logN | ~logN |
還是在頻繁操作的情況下, sum
和update
的操作次數接近的時候, 暴力相加和使用了累加數組的效率是相近的。 考慮操作M次, M接近於N的情況下, 這兩種處理方式的時間復雜度都是 ~M * N
是一個平方級的算法。Binary Indexed Tree可以達到 ~ M * log N
級別的時間復雜度。 這是一個比較大的提升。尤其是,其代碼實現短小精美, 讓人印象深刻。
同時,注意觀察的話, 以1為初始下標, 我們發現BIT數組中的奇數下標的元素都是直接存了原數組元素的值, 而偶數下標的原數組的值也可以通過BIT數組在比較小的消耗的情況下得到(可以看論文和其他介紹了解)。 所以, Binary Indexed Tree在一定效率容忍情境下,可以直接用BIT數組,而不需要保留原數組,這將是一個空間上的原地算法。
並且,BIT數組還可以被拓展到多維的應用。
就我個人的感受, Binary Indexed Tree和紅黑樹都體現了一種結構初看有些復雜,但是代碼實現起來卻非常精煉的算法的美感。 為這樣的"聰明"點贊。
並在文章結尾吐槽一下博客園的Markdown渲染還真是...難看。