線段樹(shù)(Segment Tree)和樹(shù)狀數(shù)組(Fenwick Tree)

前言

在刷題過(guò)程中,經(jīng)常會(huì)遇到求數(shù)組某區(qū)間之和的問(wèn)題:給出數(shù)組a[0...n-1]完疫,求數(shù)組下標(biāo)i~j的元素之和a[i]+...+a[j]扔涧,0<=i<=j<n。
純暴力做法是O(n)旨椒,即把區(qū)間的數(shù)加起來(lái)。
好一點(diǎn)辦法的是創(chuàng)建一個(gè)前綴和(prefix sum)數(shù)組p堵漱,使得p[i]=a[0]+...+a[i]综慎,那么所求結(jié)果就是p[j]-p[i-1],當(dāng)然i=0需要特別處理一下勤庐。這樣準(zhǔn)備工作是O(n)示惊,查詢只需要O(1)。
一般來(lái)說(shuō)愉镰,數(shù)組不變的情況下方法二已經(jīng)夠用了米罚。但是當(dāng)數(shù)組有可能改變的時(shí)候,每次改變都需要更新前綴和數(shù)組丈探,效率不高录择。
好在,有專門(mén)的數(shù)據(jù)結(jié)構(gòu)來(lái)滿足這種操作需求碗降。今天我們就來(lái)簡(jiǎn)單學(xué)習(xí)一下線段樹(shù)(Segment Tree)和樹(shù)狀數(shù)組(Fenwick Tree)隘竭。

線段樹(shù)

顧名思義,線段樹(shù)是樹(shù)遗锣。具體一點(diǎn)货裹,二叉樹(shù)。那么“線段”二字如何體現(xiàn)精偿?是把i-j區(qū)間之和想象成i-j的線段(我瞎猜的)弧圆。簡(jiǎn)而言之赋兵,線段樹(shù)就是把區(qū)間之和用樹(shù)來(lái)顯示。
很自然地搔预,根是整個(gè)數(shù)組區(qū)間霹期,然后左右分半,依次類推拯田。奇數(shù)情況下是左邊多還是右邊多呢历造?其實(shí)都可以,不過(guò)考慮區(qū)間[a,b]船庇,最自然的寫(xiě)法可分為[a,(a+b)/2]與[(a+b)/2,b]吭产,當(dāng)b-a是奇數(shù)時(shí)右邊多,這樣寫(xiě)起來(lái)更自然方便些鸭轮。
從網(wǎng)上搜了個(gè)圖臣淤,這樣更直觀:


image.png

怎么構(gòu)造這棵樹(shù)呢?
首先要決定樹(shù)的節(jié)點(diǎn)窃爷。左右子節(jié)點(diǎn)等屬性是很自然的邑蒋。同時(shí)需要存儲(chǔ)表示的區(qū)間,以及區(qū)間之和按厘。
需不需要指針指向parent節(jié)點(diǎn)呢医吊?大可不必。
至于重要的幾個(gè)操作逮京,構(gòu)造卿堂,查詢,更新造虏,抓住遞歸的特點(diǎn)御吞,自下而上地進(jìn)行,一切都顯得自然了漓藕。
構(gòu)造:遞歸,先構(gòu)造左右挟裂,再構(gòu)造自己享钞,這樣其實(shí)是從底層往上構(gòu)造。
查詢:同樣遞歸诀蓉,假如查詢的區(qū)間就是自己馬上返回栗竖,否則就有三種情況:查詢左邊,查詢右邊渠啤,兩邊都有狐肢。
更新:和查詢類似,不同的是一次只更新一個(gè)元素沥曹,所以只會(huì)走一邊份名。路徑相當(dāng)于查詢[i,i]碟联,下到最底層更新值。注意路徑之上的節(jié)點(diǎn)也要跟著更新和僵腺。
當(dāng)然了鲤孵,start和end在這里是inclusive的,和java傳統(tǒng)慣例有點(diǎn)不一樣辰如,但是這系列的習(xí)慣好像就這樣普监,畢竟要表示單個(gè)元素。樹(shù)的高度是O(logn)琉兜,而查詢操作近似遍歷樹(shù)凯正,所以也是O(logn)。更新操作類似豌蟋,同樣是O(logn)漆际。構(gòu)造的話,要把節(jié)點(diǎn)都過(guò)一遍夺饲,所以是O(n)奸汇。代碼如下:

class SegmentTree {

    private static class TreeNode {
        int start, end, sum;
        TreeNode left, right;

        TreeNode(int start, int end) {
            this.start = start;
            this.end = end;
        }
    }

    private TreeNode buildTree(int[] nums, int start, int end) {
        if (start > end) return null;
        TreeNode cur = new TreeNode(start, end);
        if (start == end) cur.sum = nums[start];
        else {
            int mid = start + (end - start) / 2;
            cur.left = buildTree(nums, start, mid);
            cur.right = buildTree(nums, mid + 1, end);
            cur.sum = cur.left.sum + cur.right.sum;
        }
        return cur;
    }

    private void updateTree(TreeNode node, int i, int val) {
        if (node.start == node.end) {
            node.sum = val;
        } else {
            int mid = node.start + (node.end - node.start) / 2;
            if (i <= mid) updateTree(node.left, i, val);
            else updateTree(node.right, i, val);
            node.sum = node.left.sum + node.right.sum;
        }
    }

    private int queryTree(TreeNode node, int i, int j) {
        if (node.start == i && node.end == j) return node.sum;
        else {
            int mid = node.start + (node.end - node.start) / 2;
            if (j <= mid) {
                return queryTree(node.left, i, j);
            } else if (i >= (mid + 1)) {
                return queryTree(node.right, i, j);
            } else {
                return queryTree(node.left, i, mid) + queryTree(node.right, mid + 1, j);
            }
        }
    }

    private TreeNode root;

    SegmentTree(int[] nums) {
        root = buildTree(nums, 0, nums.length - 1);
    }

    public void update(int i, int val) {
        updateTree(root, i, val);
    }

    public int sumRange(int i, int j) {
        return queryTree(root, i, j);
    }
}

代碼量還是不小的,畢竟用了樹(shù)往声。

樹(shù)狀數(shù)組

樹(shù)狀數(shù)組(也叫Binary Indexed Tree)就是指用數(shù)組來(lái)表示一棵樹(shù)擂找,這樣空間上更節(jié)約。類似的還有binary heap浩销。
那么樹(shù)怎么放進(jìn)數(shù)組呢贯涎?根據(jù)維基百科,對(duì)于數(shù)組里的下標(biāo)i進(jìn)行一個(gè)特定的位運(yùn)算慢洋,就可以獲得其父節(jié)點(diǎn)所在的下標(biāo)塘雳。聽(tīng)起來(lái)很神奇?剛開(kāi)始我也不知所云普筹,直到看了Competitive Programming Algorithms的解釋:

image.png

翻譯一下败明。本質(zhì)上我們需要的就是一個(gè)函數(shù)g,使得0<=g(i)<=i太防,這樣的話假如T[i]=A[g(i)]+...+A[i]妻顶,我們想要求i的前綴和,因?yàn)橐呀?jīng)有了g(i)之后的了蜒车,于是往前看讳嘱,右邊界就是g(i)-1,那么左邊界是哪里呢酿愧?自然還是利用g函數(shù)沥潭,也就是說(shuō)前一段是A[g(g(i)-1)]+...+A[g(i)-1],根據(jù)定義這就是T[g(i)-1]嬉挡。然后依次類推直至到0钝鸽。
這樣時(shí)間復(fù)雜度主要取決于g函數(shù)汇恤。假如g(i)=i,那么T=A寞埠;假如g(i)=0屁置,那么T就是前綴和數(shù)組。這些我們都知道并不是最優(yōu)選擇仁连。所以這個(gè)數(shù)據(jù)結(jié)構(gòu)巧妙的地方就在于g函數(shù)的設(shè)定蓝角,使得查詢和更新都能達(dá)到O(logn)的效率。
g(i)的定義:將i二進(jìn)制表示的末尾的連續(xù)的1全部換成0饭冬,例如g(0b1001)=0b1000使鹅,g(0b1100)=0b1100,g(0b1111)=0昌抠』贾欤或者可以用位運(yùn)算闡釋:g(i)=i&(i+1)
除此之外炊苫,我們還需要一個(gè)運(yùn)算來(lái)更新裁厅。因?yàn)榧偃缫耰下標(biāo),我們得知道這個(gè)i到底在哪一段上侨艾,也就是說(shuō)要找到所有的j执虹,使得g(j)<=i<=j,然后更新T[j]唠梨。假設(shè)這個(gè)運(yùn)算為h袋励。h(j)=j|(j+1)。以i=10為例当叭,第一個(gè)j自然是i本身茬故,然后就是h(0b1010)=0b1011=11,而g(0b1011)=0b1000=8小于10滿足條件蚁鳖;下一個(gè)是h(0b1011)=15磺芭,同樣滿足條件。通過(guò)幾個(gè)例子可以看出才睹,j|(j+1)肯定是大于等于j的徘跪。
同樣引用cp-algo里面的圖,綠色代表T涵蓋的范圍:
image.png

值得注意的一點(diǎn)是這里的T下標(biāo)是從0開(kāi)始的琅攘,也可以從1開(kāi)始。
什么意思呢松邪?之前往前推坞琴,我們需要用g(i)-1,那么假如我們直接讓T[i]=|g(i)+1,i|范圍的和逗抑,往前推就直接是g(i)了剧辐,更方便寒亥。但是這樣有一個(gè)問(wèn)題,就是i=0的時(shí)候不滿足之前的假設(shè)荧关。因此溉奕,我們讓T下標(biāo)都增加1,也就是說(shuō)其實(shí)T[1]對(duì)應(yīng)的是A[0]忍啤,T[0]是0加勤。
g和h函數(shù)也要相應(yīng)調(diào)整。g(i)=i?(i & (?i))也就是把最后一個(gè)1變?yōu)?.h(i)=i+(i & (?i))即最后一個(gè)1進(jìn)位同波。此寫(xiě)法顯得更對(duì)稱一些鳄梅,因此網(wǎng)上大多采用這種。
代碼實(shí)現(xiàn):

class BinaryIndexedTree {

    private final int[] BITree, arr;

    public BinaryIndexedTree(int[] arr) {
        this.arr = arr;
        BITree = new int[arr.length + 1];
        for (int i = 0; i < arr.length; i++) updateBIT(i, arr[i]);
    }

    public int sumRange(int i, int j) {
        return getSum(j) - getSum(i - 1);
    }

    public void update(int i, int val) {
        int delta = val - arr[i];
        arr[i] = val;
        updateBIT(i, delta);
    }

    private void updateBIT(int index, int delta) {
        index++;
        while (index < BITree.length) {
            BITree[index] += delta;
            index += index & (-index);
        }
    }

    private int getSum(int index) {
        int sum = 0;
        index++;
        while (index > 0) {
            sum += BITree[index];
            index -= index & (-index);
        }
        return sum;
    }
}

可見(jiàn)未檩,其實(shí)樹(shù)狀數(shù)組并不是簡(jiǎn)單地把線段樹(shù)裝進(jìn)數(shù)組戴尸,而是有另一套計(jì)算方法,差別還是挺明顯的冤狡。
相比之下孙蒙,樹(shù)狀數(shù)組代碼量更少,更高效悲雳,一般來(lái)說(shuō)是更好的選擇挎峦。

例題

Range Sum Query - Mutable - LeetCode
以上兩種實(shí)現(xiàn)改個(gè)名字就可以直接當(dāng)答案了。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末怜奖,一起剝皮案震驚了整個(gè)濱河市浑测,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌歪玲,老刑警劉巖迁央,帶你破解...
    沈念sama閱讀 218,525評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異滥崩,居然都是意外死亡岖圈,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,203評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén)钙皮,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)蜂科,“玉大人,你說(shuō)我怎么就攤上這事短条〉枷唬” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 164,862評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵茸时,是天一觀的道長(zhǎng)贡定。 經(jīng)常有香客問(wèn)我,道長(zhǎng)可都,這世上最難降的妖魔是什么缓待? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,728評(píng)論 1 294
  • 正文 為了忘掉前任蚓耽,我火速辦了婚禮,結(jié)果婚禮上旋炒,老公的妹妹穿的比我還像新娘步悠。我一直安慰自己,他們只是感情好瘫镇,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,743評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布鼎兽。 她就那樣靜靜地躺著,像睡著了一般汇四。 火紅的嫁衣襯著肌膚如雪接奈。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,590評(píng)論 1 305
  • 那天通孽,我揣著相機(jī)與錄音序宦,去河邊找鬼。 笑死背苦,一個(gè)胖子當(dāng)著我的面吹牛互捌,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播行剂,決...
    沈念sama閱讀 40,330評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼秕噪,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了厚宰?” 一聲冷哼從身側(cè)響起腌巾,我...
    開(kāi)封第一講書(shū)人閱讀 39,244評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎铲觉,沒(méi)想到半個(gè)月后澈蝙,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,693評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡撵幽,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,885評(píng)論 3 336
  • 正文 我和宋清朗相戀三年灯荧,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片盐杂。...
    茶點(diǎn)故事閱讀 40,001評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡逗载,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出链烈,到底是詐尸還是另有隱情厉斟,我是刑警寧澤,帶...
    沈念sama閱讀 35,723評(píng)論 5 346
  • 正文 年R本政府宣布强衡,位于F島的核電站捏膨,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏食侮。R本人自食惡果不足惜号涯,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,343評(píng)論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望锯七。 院中可真熱鬧链快,春花似錦、人聲如沸眉尸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,919評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)噪猾。三九已至霉祸,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間袱蜡,已是汗流浹背丝蹭。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,042評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留坪蚁,地道東北人奔穿。 一個(gè)月前我還...
    沈念sama閱讀 48,191評(píng)論 3 370
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像敏晤,于是被迫代替她去往敵國(guó)和親贱田。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,955評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容