樹狀數組詳解

簡介

樹狀數組和下面的線段樹但是親兄弟了,但他倆畢竟還有一些區別:
樹狀數組能有的操做,線段樹必定有;
線段樹有的操做,樹狀數組不必定有。html

這麼看來選擇 線段樹 不就 「得天下了」ios

事實上,樹狀數組的代碼要比線段樹短得多,思惟也更清晰,在解決一些單點修改的問題時,樹狀數組是不二之選。c++


原理

若是要具體瞭解樹狀數組的工做原理,請看下面這張圖:git

這個結構的思想和線段樹有些相似:用一個大節點表示一些小節點的信息,進行查詢的時候只須要查詢一些大節點而不是更多的小節點。github

最下面的八個方塊 (標有數字的方塊) 就表明存入 \(a\) 中的八個數,如今都是十進制。算法

他們上面的良莠不齊的剩下的方塊就表明 \(a\) 的上級—— \(c\) 數組。數組

很顯然看出:
\(c[2]\) 管理的是 \(a[1]\) & \(a[2]\)
\(c[4]\) 管理的是 \(a[1]\) & \(a[2]\) & \(a[3]\) & \(a[4]\)
\(c[6]\) 管理的是 \(a[5]\) & \(a[6]\)\(c[8]\) 則管理所有 \(8\) 個數。函數

因此,若是你要算區間和的話,好比說要算 \(a[51]\) ~ \(a[91]\) 的區間和,暴力算固然能夠,那上百萬的數,那就 RE 嘍。優化

那麼這種相似於跳一跳的連續跳到中心點而分值不斷變大的原理是同樣的(倍增)。spa

你從 \(91\) 開始往前跳,發現 \(c[n]\)\(n\) 我也不肯定是多少,算起來太麻煩,就意思一下)只管 \(a[91]\) 這個點,那麼你就會找 \(a[90]\) ,發現 \(c[n - 1]\) 管的是 \(a[90]\) & \(a[89]\) ;那麼你就會直接跳到 \(a[88]\)\(c[n - 2]\) 就會管 \(a[81]\) ~ \(a[88]\) 這些數,下次查詢從 \(a[80]\) 往前找,以此類推。


用法及操做

那麼問題來了,你是怎麼知道 \(c\) 管的 \(a\) 的個數分別是多少呢?你那個 \(1\) 個, \(2\) 個, \(8\) 個……是怎麼來的呢?
這時,咱們引入一個函數—— lowbit

int lowbit(int x) {
    //算出x二進制的從右往左出現第一個1以及這個1以後的那些0組成數的二進制對應的十進制的數
    return x & -x;
}

lowbit 的意思註釋說明了,我們就用這個說法來證實一下 \(a[88]\)
\(88_{(10)}=1011000_{(2)}\)
發現第一個 \(1\) 以及他後面的 \(0\) 組成的二進制是 \(1000\)
\(1000_{(2)} = 8_{(10)}\)
\(1000\) 對應的十進制是 \(8\) ,因此 \(c\) 一共管理 \(8\)\(a\)

這就是 lowbit 的用處,僅此而已(但也至關有用)。

你可能又問了:x & -x 是什麼意思啊?

在通常狀況下,對於 int 型的正數,最高位是 0,接下來是其二進制表示;而對於負數 (-x),表示方法是把 x 按位取反以後再加上 1 (補碼知識)。

例如 :
\(x =88_{(10)}=01011000_{(2)}\)
\(-x = -88_{(10)} = (10100111_{(2)} + 1_{(2)}) =10101000_{(2)}\)
\(x\ \& \ (-x) = 1000_{(2)} = 8_{(10)}\)

那麼對於 單點修改 就更輕鬆了:

void add(int x, int k) {
    while (x <= n) {  //不能越界
        c[x] = c[x] + k;
        x = x + lowbit(x);
    }
}

每次只要在他的上級那裏更新就行,本身就能夠不用管了。

int getsum(int x) {  // a[1]……a[x]的和
    int ans = 0;
    while (x >= 1) {
        ans = ans + c[x];
        x = x - lowbit(x);
    }
    return ans;
}

區間加 & 區間求和

若維護序列 \(a\) 的差分數組 \(b\) ,此時咱們對 \(a\) 的一個前綴 \(r\) 求和,即 \(\sum_{i=1}^{r} a_i\) ,由差分數組定義得 \(a_i=\sum_{j=1}^i b_j\)

進行推導

\[\sum_{i=1}^{r} a_i\\=\sum_{i=1}^r\sum_{j=1}^i b_j\\=\sum_{i=1}^r b_i\times(r-i+1) \\=\sum_{i=1}^r b_i\times (r+1)-\sum_{i=1}^r b_i\times i \]

區間和能夠用兩個前綴和相減獲得,所以只須要用兩個樹狀數組分別維護 \(\sum b_i\)\(\sum i \times b_i\) ,就能實現區間求和。

代碼以下

int t1[MAXN], t2[MAXN], n;

inline int lowbit(int x) { return x & (-x); }

void add(int k, int v) {
    int v1 = k * v;
    while (k <= n) {
        t1[k] += v, t2[k] += v;
        k += lowbit(k);
    }
}

int getsum(int* t, int k) {
    int ret = 0;
    while (k) {
        ret += t[k];
        k -= lowbit(k);
    }
    return ret;
}

void add1(int l, int r, int v) {
    add(l, v), add(r + 1, -v);  //將區間加差分爲兩個前綴加 ①
}

long long getsum1(int l, int r) {//1ll :表明長整型的 1
    return (r + 1ll) * getsum(t1, r) - 1ll * l * getsum(t1, l - 1) -
        (getsum(t2, r) - getsum(t2, l - 1));
}

/* ------------另外一種寫法 ------------*/
//樹狀數組 2:區間修改,單點查詢 模板AC代碼
#include <bits/stdc++.h>
using namespace std;
#define lowbit(x) (x & -x)
typedef long long ll;
const int maxn = 1e6 + 10;
ll n, q, tr[maxn], a, pre;
void add(int i, int v) {
    for (; i <= n; i += lowbit(i)) tr[i] += v;
}
ll getsum(int i) {
    ll sum = 0;
    for (; i; i -= lowbit(i)) sum += tr[i];
    return sum;
}
int main() {
    // freopen("in.txt", "r", stdin);
    ios::sync_with_stdio(false), cin.tie(0);
    cin >> n >> q;
    for (int i = 1; i <= n; i++) cin >> a, add(i, a - pre), pre = a;
    ll opt, u, v;
    while (q--) {
        cin >> opt >> u;
        if (opt == 1) {
            cin >> v >> a;
            add(u, a), add(v + 1, -a);//維護差分數組
        } else
            cout << getsum(u) << endl;
    }
}

註釋 ①:由於維護的是差分數組。
區間 [l,r] 加 v 就至關於在差分數組的 l 位置加 v ,在 r + 1 位置 -v

維護的是差分數組的前綴信息 \(( \sum_{j=1}^i {i * b_i} 和 \sum_{j=1} ^i {b_i} )\)

Tricks

\(O(n)\) 建樹:

每個節點的值是由全部與本身直接相連的兒子的值求和獲得的。所以能夠倒着考慮貢獻,即每次肯定完兒子的值後,用本身的值更新本身的直接父親。

// O(n)建樹
void init() {
    for (int i = 1; i <= n; ++i) {
        t[i] += a[i];
        int j = i + lowbit(i);
        if (j <= n) t[j] += t[i];
    }
}

\(O(\log n)\) 查詢第 \(k\) 小/大元素。在此處只討論第 \(k\) 小,第 \(k\) 大問題能夠經過簡單計算轉化爲第 \(k\) 小問題。

參考 "可持久化線段樹" 章節中,關於求區間第 \(k\) 小的思想。將全部數字當作一個可重集合,即定義數組 \(a\) 表示值爲 \(i\) 的元素在整個序列重出現了 \(a_i\) 次。找第 \(k\) 大就是找到最小的 \(x\) 剛好知足 \(\sum_{i=1}^{x}a_i \geq k\)

所以能夠想到算法:若是已經找到 \(x\) 知足 \(\sum_{i=1}^{x}a_i \le k\) ,考慮能不能讓 \(x\) 繼續增長,使其仍然知足這個條件。找到最大的 \(x\) 後, \(x+1\) 就是所要的值。
在樹狀數組中,節點是根據 2 的冪劃分的,每次能夠擴大 2 的冪的長度。令 \(sum\) 表示當前的 \(x\) 所表明的前綴和,有以下算法找到最大的 \(x\)

  1. 求出 \(depth=\left \lfloor log_2n \right \rfloor\)
  2. 計算 \(t=\sum_{i=x+1}^{x+2^{depth}}a_i\)
  3. 若是 \(sum+t \le k\) ,則此時擴展成功,將 \(2^{depth}\) 累加到 \(x\) 上;不然擴展失敗,對 \(x\) 不進行操做
  4. \(depth\) 減 1,回到步驟 2,直至 \(depth\) 爲 0
//權值樹狀數組查詢第k小
int kth(int k) {
  int cnt = 0, ret = 0;
  for (int i = log2(n); ~i; --i) {      // i與上文depth含義相同
    ret += 1 << i;                      //嘗試擴展
    if (ret >= n || cnt + t[ret] >= k)  //若是擴展失敗
      ret -= 1 << i;
    else
      cnt += t[ret];  //擴展成功後 要更新以前求和的值
  }
  return ret + 1;
}

時間戳優化:

對付多組數據很常見的技巧。若是每次輸入新數據時,都暴力清空樹狀數組,就可能會形成超時。所以使用 \(tag\) 標記,存儲當前節點上次使用時間(即最近一次是被第幾組數據使用)。每次操做時判斷這個位置 \(tag\) 中的時間和當前時間是否相同,就能夠判斷這個位置應該是 0 仍是數組內的值。

//權值樹狀數組查詢第k小
int kth(int k) {
    int cnt = 0, ret = 0;
    for (int i = log2(n); ~i; --i) {      // i與上文depth含義相同
        ret += 1 << i;                      //嘗試擴展
        if (ret >= n || cnt + t[ret] >= k)  //若是擴展失敗
            ret -= 1 << i;
        else
            cnt += t[ret];  //擴展成功後 要更新以前求和的值
    }
    return ret + 1;
}

例題

其它

文章開源在 Github - blog-articles,點擊 Watch 便可訂閱本博客。 若文章有錯誤,請在 Issues 中提出,我會及時回覆,謝謝。

若是您以爲文章不錯,或者在生活和工做中幫助到了您,不妨給個 Star,謝謝。

(文章完)

相關文章
相關標籤/搜索