樹狀陣列(BIT)—— 一篇就夠了
前言、內容梗概
本文旨在講解:
- 樹狀陣列的原理(起源,原理,模板程式碼與需要注意的一些知識點)
- 樹狀陣列的優勢,缺點,與比較(eg:線段樹)
- 樹狀陣列的經典例題及其技巧(普通離散化,二分查詢離散化)
什麼是 BIT ?
起源與介紹
樹狀陣列或二元索引樹(英語:Binary Indexed Tree),又以其發明者命名為 \(\mathrm{Fenwick}\) 樹。最早由 \(\mathrm{Peter\; M. Fenwick}\) 於1994年以 《A New Data Structure for Cumulative Frequency Tables[1]》為題發表在 《SOFTWARE PRACTICE AND EXPERIENCE》。其初衷是解決資料壓縮裡的累積頻率(Cumulative Frequency)的計算問題,現多用於高效計算數列的字首和, 區間和。它可以以 \(\mathcal{O(\log n)}\) 的時間得到任意字首和(區間和)。
很多初學者肯定和我一樣,只知曉 BIT 程式碼精煉,語法簡明。對於原理好像瞭解,卻又如霧裡探花總感覺隔著些什麼。
按照 Peter M. Fenwick 的說法,BIT 的產生源自整數與二進位制的類比。
Each integer can be represented as sum of powers of two. In the same way, cumulative frequency can be represented as sum of sets of subfrequencies. In our case, each set contains some successive number of non-overlapping frequencies.
簡單翻一下:每個整數可以用二進位制來進行表示,在某些情況下,序列累和(這裡沒有翻譯為頻率)也可以用一組子序列累和來表示。在本例子中,每個集合都有一些連續不重疊的子序列構成。
實際上, BIT 也是採用類似的想法,將序列累和類比為整數的二進位制拆分,每個字首和拆分為多個不重疊序列和,再利用二進位制的方法進行表示。這與 Integer 的位運算非常相似。
之所以命名為: Binary Indexed Tree,在論文中 Fenwick 有如下解釋:
In recognition of the close relationship between the tree traversal algorithms and the binary representation of an element index,the name "binar indexed tree" is proposed for the new structure.
也就是考慮到:樹的遍歷方法與二值表示之間的緊密聯絡,因此將其命名為二元索引樹。
BIT 的原理
在介紹原理之前先對於一些關鍵的符號做出定義:
- 第一步:思考整數二進位制拆分與序列字首和的類比
在學習 BIT 時,很容易忽略 BIT 設計的思想,而僅僅停留在對於其程式碼簡潔精煉的讚歎上,所以第一步我們將體會 BIT 是如何類比;如何設計;如何實現的。
如上圖所示:我們給定一個整數: \(num = 13\)
我們嘗試將 \(num\) 用二進位制進行表示: \(1101_2 = 1000_2 + 100_2 + 1_2\) 。可以看到 \(num\) 可以由\(3\)個二進位制陣列成。且拆分的個數總是 \(\mathcal{O(\log_2n)}\) 級的,因此我猜想Fenwick便開始思考如何將一個子序列,藉助二進位制的特點快速的表示出來。
首先,依據最簡單的拆分方法(即與二進位制拆分相同)如圖左示。顯然這個方法具有缺陷,某些序列會被重複計算,而有些序列則沒有被包含在內,因此解決問題的關鍵,同時也是 BIT 的核心思想便是如何基於編號,構件一個不重疊的子序列集合。
如右圖所示,該拆分方案能很好的實現不重疊的子序列集合,我們嘗試將其列出以發現其中的規律:
經過觀察:
- \(子序列_1\) 表示的範圍在 \([0001_1, 1000_2] \rightarrow [0000_2 + 1, 0000_2 + 1000_2]\)。
- \(子序列_2\) 的表示範圍在 \([1001_2, 1100_2] \rightarrow [1000_2 + 1, 1000_2 + 0100_2]\)。
- \(子序列_3\) 的表示範圍在 \([1101_2, 1101_2] \rightarrow [1100_2 + 1, 1100_2 + 0001_2]\)。
設某編號的二進位制為 \(\mathrm{XXX}bit\mathrm{XXX}_2\) ,設 \(bit\) 為當前需要考慮的位\((bit=1)\),\(\mathrm{X}\) 為\(0 \;or\; 1\) ,則其表示的範圍是:
\([XXX0000_2 + 1, XXX0000_2 + bit000_2]\) ,換一句話說:假如序列編號在 \(bit\) 位為1,則其代表的子序列具有如下性質:
- 子序列的基準量為:\(base = 將二進位制編號中bit及其之後所有位置0代表的值\quad eg: num=1101_2,bit=第3位(1-index), 則base = 1000_2\)
- 子序列的偏移量:\(offset=1<<(bit-1)\)
- 子序列的下界為:\(lower = base + 1\)
- 子序列的上界為:\(upper=base+offset\)
- 子序列包含的元素位:\(tot = offset\)
假如我們逆序的看待之前\(num=13=1101_2\)的例子:
首先處理\(bit=1\)這一位,其代表的範圍是:\([1100_2 + 0001_2, 1100_2 + 0001_2]\)。然後在\(num\)上減去他:\(num -= (1 << (bit-1)) = 1100_2\)
然後,我們處理\(bit=3\)這一位:其代表的範圍是:\([1000_2 + 0001_2, 1000_2 + 0100_2]\)。同樣,我們在\(num\)上減去它。
最後我們處理\(bit=4\)這一位:其代表的範圍是:\([0000_2 + 0001_2, 0000_2 + 1000_2]\)。至此,處理結束。
我們回顧整個處理流程,可以驚訝的發現,如果我們按照逆序處理,我們每次處理的\(bit\)都是當前編號的最後的為1位。我們將每次處理的\(bit\)定義為 \(\mathrm{lowbit}\) (note:這是 BIT 中重要的概念)
用通俗的語言:每個 \(\mathrm{lowbit}\) 都代表其管轄的某一段子序列,又因為 \(\mathrm{lowbit}\) 的值會隨著處理不斷增大,其控制的範圍也會不斷增大。其控制範圍為:\([cur - lowbit(cur) + 1, cur]\)
如:\(c[13] = tree[13] + tree[12] + tree[8]\)
- \(tree[13] = f[13] \quad \mathrm{lowbit(13) = 1}\)
- \(tree[12] = f[9] + f[10] + f[11] + f[12] \quad \mathrm{lowbit}(12) = 4\)
- \(tree[8] = f[1] + f[2] + \cdots +f[8] \quad \mathrm{lowbit}(8) = 8\)
因此,我們可以做出如下總結:
-
BIT 的原理類比自 Integer 的二進位制表示。
-
BIT 對應的陣列 \(tree[i] := 子序列 i 的值\) ,每個 \(tree[i]\) 控制 \([i - \mathrm{lowbit(i)}+1, i]\) 範圍內的\(f[i]\)值。
-
利用BIT計算 \(c[i]\) 時,通過類似整數的二進位制拆分,將 \(c[i]\) 拆分為 \(\mathcal{O(\log_2 n)}\) 個 \(tree[j]\) 進行求解。求解的流程為不斷累加 \(tree[i]\) 並置 $ i \leftarrow i - \mathrm{lowbit(i)}$
-
計算流程的虛擬碼: let ans <- 0 while i > 0: sub_sum <- tree[i] // 獲取子序列累和 i <- i - lowbit(i) // 更新 i ans <- ans + sub_sum return ans
上圖是樹狀陣列非常經典的展示圖,通過此圖可以快速的瞭解:\(tree[i] := \sum \limits_{i - \mathrm{lowbit}(i)+1}^{i}f[i]\) 對應的含義。
到這裡還是不禁感嘆一句:“文章本天成,妙手偶得之”,BIT 這個資料結構實在是精巧。
BIT 的詢問,更新操作及其程式碼實現
query
定義 bitcnt(x) := x二進位制中 1 的個數
,則根據前文的分析,計算 \(c[i]\) 時類比整數的二進位制拆分,我們只需要計算 \(bitcnt(i)\) 個子序列的和。每個子序列通過不斷進行 \(\mathrm{lowbit}\) 運算進行獲取。
\(\mathrm{lowbit}\) 運算為取數 \(x\) 的最低位的 1 ,最常用的方法為:\(\mathrm{lowbit(x)= (x \& (-x))}\)
上圖展示了一個大小為 \(16\) 的 BIT,可以通過圖示清楚的理解 BIT query 的原理:即不斷詢問當前 \(i\) 指示的子序列和(\(tree[i]\)),並通過 \(\mathrm{lowbit}\) 運算指向下一個子序列和。
其 C++
程式碼如下:
T tree[maxn];
template <typename T>
T query(int i){
T res = 0;
while (i > 0){
res += tree[i];
i -= lowbit(i);
}
return res;
}
update
update 實際上可以看成 query 的逆過程,簡單來說即是:若要將 \(f[i] += x\),則從 \(tree[i]\) 開始不斷向上更新直到達到 BIT 的上界。
上圖展示了 BIT 更新的流程,這裡主要說明其中一個需要注意的點:為什麼我們首先需要更新 \(tree[i]\) 而不是其他的,如何保證這就是起始點?(可以自己思考一下)
這是我曾在學習 BIT 的過程中比較困惑的一個點:答案在於 \(tree[i]\) 所管轄的子序列範圍,我們知道 \(tree[i] 管轄 [i - lowbit(i) + 1, i]\) 這個範圍,因此 \(tree[i]\) 是第一個管轄 \(f[i]\) 的元素,所以我們只需要從這個位置不斷向上更新即可。
其 C++
程式碼如下:
int n; // BIT 的大小, BIT index 從 1 開始
T tree[maxn];
template <typename T>
void add(int i, T x){
while (i <= n){
tree[i] += x;
i += lowbit(i);
}
}
模板
template<typename T>
struct BIT{
#ifndef lowbit
#define lowbit(x) (x & (-x));
#endif
static const int maxn = 1e3+50;
int n;
T t[maxn];
BIT<T> () {}
BIT<T> (int _n): n(_n) { memset(t, 0, sizeof(t)); }
BIT<T> (int _n, T *a): n(_n) {
memset(t, 0, sizeof(t));
/* 從 1 開始 */
for (int i = 1; i <= n; ++ i){
t[i] += a[i];
int j = i + lowbit(i);
if (j <= n) t[j] += t[i];
}
}
void add(int i, T x){
while (i <= n){
t[i] += x;
i += lowbit(i);
}
}
/* 1-index */
T sum(int i){
T ans = 0;
while (i > 0){
ans += t[i];
i -= lowbit(i);
}
return ans;
}
/* 1-index [l, r] */
T sum(int i, int j){
return sum(j) - sum(i - 1);
}
/*
href: https://mingshan.fun/2019/11/29/binary-indexed-tree/
note:
C[i] --> [i - lowbit(i) + 1, i]
father of i --> i + lowbit(i)
node number of i --> lowbit(i)
*/
};
BIT 的優缺點,比較與應用場景
優缺點
樹狀陣列(BIT)的主要優勢在於:
- 程式碼精煉,實現輕鬆。
query
與update
操作時間複雜度都只需要 \(\mathcal{O(\log n)}\) 。- 演算法常數小,相比於線段樹更快(
lazy tag
也存在影響)。
而缺點在於:
- 應用場景有限:較為複雜的區間操作無法實現,只能使用線段樹(稍後會講為什麼不能實現)
應用場景與比較
樹狀陣列一般用於解決大部分基於區間上的更新以及求和問題。
下面來談一談線段樹和樹狀陣列在使用上的不同:
線段樹與樹狀陣列的區別 線段樹和樹狀陣列的基本功能都是在某一滿足結合律的操作(比如加法,乘法,最大值,最小值)下,\(\mathcal{O}(\log n)\)的時間複雜度內修改單個元素並且維護區間資訊。
不同的是,樹狀陣列只能維護字首“操作和”(字首和,字首積,字首最大最小),而線段樹可以維護區間操作和。但是某些操作是存在逆元的(即:可以用一個操作抵消部分影響,減之於加,除之於乘),這樣就給人一種樹狀陣列可以維護區間資訊的錯覺:維護區間和,模質數意義下的區間乘積,區間 \(\mathrm{xor}\) 和。能這樣做的本質是取右端點的字首結果,然後對左端點左邊的字首結果的逆元做一次操作,所以樹狀陣列的區間詢問其實是在兩次字首和詢問。
所以我們能看到樹狀陣列能維護一些操作的區間資訊但維護不了另一些的:最大/最小值,模非質數意義下的乘法,原因在於這些操作不存在逆元,所以就沒法用兩個字首和做。
總結來說:線段樹只需要保證區間操作的可結合性,可加性(即一個大區間的結果可以由較小區間的結果計算得到);而樹狀陣列除了需要滿足上述條件,還需要滿足可抵消性,也就是可以通過一個操作抵消掉不需要區間的貢獻(因為 BIT 只能維護字首結果)。僅為個人見解
樹狀陣列的經典例題及其技巧
模板題:單點修改,區間查詢
思路:
非常簡單,只需要套模板即可。
程式碼:
// 上述模板部分省略
using ll = long long;
const int maxn = 1e6+50;
ll f[maxn];
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
int n; cin >> n;
int q; cin >> q;
for (int i = 1; i <= n; ++ i) cin >> f[i];
BIT<ll> bit(f, n);
for (int i = 0; i < q; ++ i){
int type; cin >> type;
if (type == 1){
int i, x;
cin >> i >> x;
bit.add(i, (ll) x);
}else {
int l, r;
cin >> l >> r;
cout << bit.sum(l, r) << '\n';
}
}
return 0;
}
模板題:區間修改,區間查詢
思路:
該模板題則難上許多,需要對問題分析建模。
我們需要考慮如何建模表示 \(tree\) 陣列。
首先,設更新操作為:在 \([l, r]\) 上增加 \(x\)。我們考慮如何建模維護新的區間字首和 \(c^{\prime}[i]\)。
下面分情況討論:
- \(i < l\)
這種情況下,不需要任何處理, \(c^{\prime}[i] = c[i]\)
- \(l <= i <= r\)
這種情況下,\(c^{\prime}[i] = c[i] + (i - l + 1) \cdot x\)
- \(i > r\)
這種情況下,\(c^{\prime}[i]=c[i] + (r-l+1)\cdot x\)
因此如下圖所示,我們可以設兩個 BIT,那麼\(c^{\prime}[i] = \mathrm{sum(bit_1,i)+sum(bit_2,i) \cdot i}\),對於區間修改等價於:
- 在 \(bit_1\) 的 \(l\) 位置加上 \(-x(l-1)\),在 \(bit_1\) 的 \(r\) 位置加上 \(rx\)。
- 在 \(bit_2\) 的 \(l\) 位置加上 \(x\) 的 \(r\) 位置加上 \(-x\)。
程式碼
#include <bits/stdc++.h>
using namespace std;
// 模板程式碼省略
// 這裡做的是單點查詢,但是實現的為區間查詢
using ll = long long;
ll get_sum(BIT<ll> &a, BIT<ll> &b, int l, int r){
auto sum1 = a.sum(r) * r + b.sum(r);
auto sum2 = a.sum(l - 1) * (l - 1) + b.sum(l - 1);
return sum1 - sum2;
}
int n, q;
const int maxn = 1e6 + 50;
ll f[maxn];
int main(){
// ios::sync_with_stdio(0);
// cin.tie(0);
cin >> n >> q;
BIT<ll> bit1, bit2;
for (int i = 1; i <= n; ++ i) cin >> f[i];
bit1.init(n), bit2.init(f, n);
for (int i = 0; i < q; ++ i){
int type; cin >> type;
if (type == 1){
int l, r, x;
cin >> l >> r >> x;
bit2.add(l, (ll) -1 * (l - 1) * x), bit2.add(r + 1, (ll) r * x);
bit1.add(l, (ll) x), bit1.add(r + 1, (ll) -1 * x);
}else {
int i; cin >> i;
cout << get_sum(bit1, bit2, i, i) << '\n';
}
}
return 0;
}
逆序對 簡單版
思路
BIT 求解逆序對是非常方便的,在初學時我沒有想到過 BIT 還能用於求解逆序對。在這裡我借逆序對來引出一個小技巧:離散化
BIT 求逆序對的方法非常簡單,逆序對指:i < j and a[i] > a[j]
,統計逆序對實際上就是統計在該元素 a[i]
之前有多少元素大於他。
我們可以初始化一個大小為 \(maxn\) 的空 BIT(全為0)。隨後:
- 我們順序訪問陣列中的每個元素
a[i]
,計算區間[1, a[i]]
的和,更新答案ans = i - sum([1, a[i]])
- 然後,我們更新 BIT 中座標
a[i]
的值,tree[a[i]] <- tree[a[i]] + 1
舉個例子:
eg: [2,1,3,4]
BIT: 0, 0, 0, 0
>2, sum(2) = 0, ans += 0 - sum(2) -> ans = 0
BIT: 0, 1, 0, 0
>1, sum(1) = 0, ans += 1 - sum(1) -> ans = 1
BIT: 1, 1, 0, 0
>3, sum(3) = 2, ans += 2 - sum(3) -> ans = 1
BIT: 1, 1, 1, 0
>4, sum(4) = 3, ans += 3 - sum(4) -> ans = 1
實際上,便是藉助 BIT 高效計算字首和的性質實現了快速打標記,先統計在我之前有多少個標記(這些都是合法對),再將自己所在位置的標記加 \(1\)。
因此,很容易寫出這段程式碼:
程式碼一
// 僅保留核心程式碼
int reversePairs(vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
int mx = *max_element(nums.begin(), nums.end());
BIT<int> bit(mx); // 因為最大隻到最大值的位置
int ans(0);
for (int i = 0; i < n; ++ i){
ans += (i - bit.sum(nums[i]));
bit.add(nums[i], 1);
}
return ans;
}
但是這個程式碼有非常嚴重的問題,首先假如 mx = 1e9
就會出現段錯誤;或者假如 nums[i] < 0
則會出現訪問越界的問題,但是實際上題目中說明了:陣列最多隻有 50000個元素,也就是我們需要想辦法將座標離散化,保留其大小順序即可。
程式碼二
#define lb lower_bound
#define all(x) x.begin(), x.end()
const int maxn = 5e4 + 50;
struct node{
int v, id;
}f[maxn]; // 離散化結構體
int arr[maxn];
bool cmp(const node&a, const node &b){
return a.v < b.v;
}
class Solution {
public:
int reversePairs(vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
BIT<int> bit(n);
for (int i = 1; i <= n; ++ i){
f[i].v = nums[i - 1], f[i].id = i; // 賦值用於排序
}
sort(f + 1, f + 1 + n, cmp);
int cnt = 1, i = 1;
while (i <= n){
/* 用於去重,當有相同元素時其對應的 cnt 應該相同 */
if (f[i].v == f[i - 1].v || i == 1) arr[f[i].id] = cnt;
else arr[f[i].id] = ++cnt;
++ i;
}
int ans = 0;
for (int i = 0; i < n; ++ i){
int pos = arr[i + 1];
ans += i - bit.sum(pos);
bit.add(pos, 1);
}
return ans;
}
};
上面的方法是離散化操作的一種方式,有一點複雜,需要注意的細節比較多。
實際上,該方法便是通過保留每個元素的所在位置,並將其排序,排序後自己在第 \(i\) 個則將其值 arr[id] = i
離散化為 \(i\) 。這樣既可以避免負數,過大的數造成的訪問或者記憶體錯誤,也充分的保留了各元素之間的大小關係。
離散化的複雜度為 \(\mathcal{O(\log n)}\) ,實際上也就是排序的複雜度。
總結:離散化--結構體方法
通用性:★★
- 設定結構體
node
,包含屬性val
與id
,初始化結構體陣列f
和離散化陣列arr
。- 排序
f
,並從1
開始遍歷,arr[f[i].id] = i
,將val
值更新為k-th min
也就是其在元素中按大小排列的編號。
可以發現,結構體方法對於空間要求較大,且在去重方面需要下功夫,稍後我們會講解另一種離散化方法,你也可以試試用後文的離散化方法再次解決這題。
逆序對加強版: 翻轉對
思路
可以看到這題與逆序對的區別在於,翻轉對的定義是:i < j
且 a[i] > 2*a[j]
。其大小關係發生了變化,不再是原來單純的大小關係,而存在值的變化。
我們可以思考下能否用結構體進行離散化,簡單思考後發現:假如第 i
個元素離散化之後的編號為 id1
,則我們無法確定編號為 2 * id1
所對應元素的 val
值之間的關係。可能出現如下情況:
id1 = 1, val = 2
2 * id1 = 2, val' = 3
所以,我們需要思考一個新的方法來進行離散化。需要注意的是,我們的關鍵點在於:如何快速的詢問一個元素在一個陣列中是第幾大的元素。比如,在陣列中快速詢問某個值的兩倍是第幾大的。
實際上,稍微有基礎的話答案便非常清晰:二分查詢,我們可以首先將陣列進行排序,利用 \(lower_{bound}\) 快速找到第一個大於等於該元素所對應的位置,用程式碼來說的話:pos = lower_bound(nums.begin(), nums.end(), x) - nums.begin() + 1
。
eg: nums = [3, 2, 4, 7]
farr = sort(nums) -> farr = [2, 3, 4, 7]
pos(4) = lower_bound(..., 4) - farr.begin() + 1 = 3
便可以快速找到 4 的編號為 3 (1-index)
但是,有一個問題需要注意:
eg: nums = [3, 2, 5, 7]
farr = sort(nums) -> farr = [2, 3, 5, 7]
pos(4) = lower_bound(...,4) - farr.beign() + 1 = 3
但實際上,5 > 4,這次詢問錯誤了!!!
為什麼會出現詢問錯誤的情況呢?(因此我們需要找到的是最後一個小於等於元素 x
的對應位置,而二分查詢是大於等於 x
的第一個元素,當原陣列中不存在 x
時,便會出現詢問出錯的情況。)
有多種方法可以解決這個問題,但是最為方便的還是直接將需要查詢的元素全部加進去,也就是 2 * x
全部新增到陣列中,從而保證一定存在該元素,又因為 lower_bound
的性質,我們無需去重。
程式碼
using vi = vector<int>;
using vl = vector<ll>;
#define complete_unique(x) (x.erase(unique(x.begin(), x.end()), x.end()))
#define lb lower_bound
class Solution {
public:
int reversePairs(vector<int>& nums) {
vl tarr;
for (auto &e: nums){
tarr.push_back(e);
tarr.push_back(2ll * e); // 直接把需要離散化的對應元素加入
}
sort(tarr.begin(), tarr.end());
int n = nums.size();
BIT<int> bit(2 * n); // 注意,因為加入了兩倍的元素,所以對應也要開大一點
int res = 0;
for (int i = 0; i < n; ++ i){
res += i - bit.sum(lb(tarr.begin(), tarr.end(), 2ll * nums[i]) - tarr.begin() + 1);
bit.add(lb(tarr.begin(), tarr.end(), nums[i]) - tarr.begin() + 1, 1);
}
return res;
}
};
總結:離散化--二分查詢方法
通用性:★★★★★
- 初始化陣列
farr
,將元素以及需要尋找的元素都加入其中- 二分查詢即可。
二維BIT:區間查詢,單點修改
思路
二維 BIT 實際上就是套娃,一層層套即可。
其複雜度為 \(\mathcal{O(\log n \times \log m)}\) ,\(n,m\)分別為每個維度 BIT 的個數,這裡不再贅述。
程式碼
#include <bits/stdc++.h>
using namespace std;
// 模板程式碼省略
using ll = long long;
int n, m, q;
const int maxn = 5e3 + 50;
BIT<ll> f[maxn]; // 二維BIT
void add(int i, int j, ll x){
while (i <= n){
f[i].add(j, x);
i += lowbit(i);
}
}
ll sum(int i, int j){
ll res(0);
while (i > 0){
res += f[i].sum(j);
i -= lowbit(i);
}
return res;
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0);
cin >> n >> m;
for (int i = 1; i <= n; ++ i) f[i] = BIT<ll>(m);
int type;
while (cin >> type){
if (type == 1){
int x, y, k; cin >> x >> y >> k;
add(x, y, (ll) k);
}else {
int a, b, c, d; cin >> a >> b >> c >> d;
cout << sum(c, d) - sum(c, b - 1) - sum(a - 1, d) + sum(a - 1, b - 1) << '\n';
}
}
return 0;
}
後記
這是我耗時最長的一篇部落格,也是我花費心血最多的一次,也希望自己能好好掌握 BIT
附上參考連結: