[LeetCode] 307. Range Sum Query - Mutable 區域和檢索 - 可變


 

Given an integer array nums, find the sum of the elements between indices i and j (ij), inclusive.

The update(i, val) function modifies nums by updating the element at index i to val.

Example:

Given nums = [1, 3, 5]

sumRange(0, 2) -> 9
update(1, 2)
sumRange(0, 2) -> 8

Note:

  1. The array is only modifiable by the update function.
  2. You may assume the number of calls to update and sumRange function is distributed evenly.

 

這道題是之前那道 Range Sum Query - Immutable 的延伸,之前那道題由於數組的內容不會改變,所以我們只需要建立一個累計數組就可以支持快速的計算區間值了,而這道題說數組的內容會改變,如果我們還是用之前的方法建立累計和數組,那么每改變一個數字,之后所有位置的數字都要改變,這樣如果有很多更新操作的話,就會十分不高效,估計很難通過吧。But,被 OJ 分分鍾打臉, brute force 完全沒有問題啊,這年頭,裝個比不容易啊。直接就用個數組 data 接住 nums,然后要更新就更新,要求區域和,就遍歷求區域和,就這樣 naive 的方法還能 beat 百分之二十多啊,這不科學啊,參見代碼如下:

 

解法一:

class NumArray {
public:
    NumArray(vector<int> nums) {
        data = nums;
    }
    
    void update(int i, int val) {
        data[i] = val;
    }
    
    int sumRange(int i, int j) {
        int sum = 0;
        for (int k = i; k <= j; ++k) {
            sum += data[k];
        }
        return sum;
    }
    
private:
    vector<int> data;
};

 

咳咳,下面就開始閃亮的裝比時間了,光芒必將蓋過坂本大佬。上面的方法最大的問題,就是求區域和不高效,如果數組很大很大,每次求一個巨型的區間的和,都要一個一個的遍歷去累加,累啊~但是一般的累加數組又無法應對這里的 update 操作,隨便修改一個數字的話,那么其之后的所有累加和都會發生改變。所以解決方案就是二者折中一下,分塊累加,各不干預。就是將原數組分為若干塊,怎么分呢,這里就讓每個 block 有 sqrt(n) 個數字就可以了,這個基本是讓 block 的個數跟每個 blcok 中數字的個數盡可能相同的分割方法。然后我們就需要一個大小跟 block 個數相同的數組,來保存每個 block 的數字之和。在需要更新的時候,我們就先確定要更新的位置在哪個 block 里,然后只更新該 block 的和。而對於求區域和操作,我們還是要分別確定i和j分別屬於哪個 block,若屬於同一個 block,那么直接遍歷累加即可,若屬於不同的,則先從i累加到該 blcok 的末尾,然后中間橫跨的那些 block 可以直接將和累加,對於j所在的 blcok,則從該 block 的開頭遍歷累加到j即可,參見代碼如下:

 

解法二:

class NumArray {
public:
    NumArray(vector<int> nums) {
        if (nums.empty()) return;
        data = nums;
        double root = sqrt(data.size());
        len = ceil(data.size() / root);
        block.resize(len);
        for (int i = 0; i < data.size(); ++i) {
            block[i / len] += data[i];
        }
    }
    
    void update(int i, int val) {
        int idx = i / len;
        block[idx] += val - data[i];
        data[i] = val;
    }
    
    int sumRange(int i, int j) {
        int sum = 0;
        int start = i / len, end = j / len;
        if (start == end) {
            for (int k = i; k <= j; ++k) {
                sum += data[k];
            }
            return sum;
        }
        for (int k = i; k < (start + 1) * len; ++k) {
            sum += data[k];
        }
        for (int k = start + 1; k < end; ++k) {
            sum += block[k];
        }
        for (int k = end * len; k <= j; ++k) {
            sum += data[k];
        }
        return sum;
    }
    
private:
    int len;
    vector<int> data, block;
};

 

同樣是利用分塊區域和的思路,下面這種方法使用了一種新的數據結構,叫做 樹狀數組Binary Indexed Tree,又稱 Fenwick Tree,這是一種查詢和修改復雜度均為 O(logn) 的數據結構。這個樹狀數組比較有意思,所有的奇數位置的數字和原數組對應位置的相同,偶數位置是原數組若干位置之和,假如原數組 A(a1, a2, a3, a4 ...),和其對應的樹狀數組 C(c1, c2, c3, c4 ...)有如下關系:

C1 = A1
C2 = A1 + A2
C3 = A3
C4 = A1 + A2 + A3 + A4
C5 = A5
C6 = A5 + A6
C7 = A7
C8 = A1 + A2 + A3 + A4 + A5 + A6 + A7 + A8
...

那么是如何確定某個位置到底是有幾個數組成的呢,原來是根據坐標的最低位 Low Bit 來決定的,所謂的最低位,就是二進制數的最右邊的一個1開始,加上后面的0(如果有的話)組成的數字,例如1到8的最低位如下面所示:

坐標          二進制       最低位

1               0001          1

2               0010          2

3               0011          1

4               0100          4

5               0101          1

6               0110          2

7               011         1

8               1000          8

...

最低位的計算方法有兩種,一種是 x&(x^(x–1)),另一種是利用補碼特性 x&-x。

這道題我們先根據給定輸入數組建立一個樹狀數組 bit,比如,對於 nums = {1, 3, 5, 9, 11, 13, 15, 17},建立出的 bit 數組為:

bit -> 0 1 4 5 18 11 24 15 74

注意到我們給 bit 數組開頭 padding 了一個0,這樣我們在利用上面的樹狀數組的性質時就不用進行坐標轉換了。可以發現bit數組中奇數位上的數字跟原數組是相同的,參見上面標記藍色的數字。偶數位則是之前若干位之和,符合上圖中的規律。

現在我們要更新某一位數字時,比如將數字5變成2,即 update(2, 2),那么現求出差值 diff = 2 - 5 = -3,然后我們需要更新樹狀數組 bit,根據最低位的值來更新后面含有這一位數字的地方,一般只需要更新部分偶數位置的值即可。由於我們在開頭 padding了個0,所以我們的起始位置要加1,即 j=3,然后現將 bit[3] 更新為2,然后更新下一位,根據圖中所示,並不是 bit[3] 后的每一位都需要更新的,下一位需要更新的位置的計算方法為 j += (j&-j),這里我們的j是3,則 (j&-j) = 1,所以下一位需要更新的是 bit[4],更新為15,現在j是4,則 (j&-j) = 4,所以下一位需要更新的是 bit[8],更新為71,具體的變換過程如下所示:

0 1 4 5 18 11 24 15 74

0 1 4 2 18 11 24 15 74

0 1 4 2 15 11 24 15 74

0 1 4 2 15 11 24 15 71

接下來就是求區域和了,直接求有些困難,我們需要稍稍轉換下思路。比如若我們能求出前i-1個數字之和,跟前j個數字之和,那么二者的差值就是要求的區間和了。所以我們先實現求前任意i個數字之和,當然還是要利用樹狀數組的性質,此時正好跟 update 函數反過來,我們的j從位置i開始,每次將 bit[j] 累加到 sum,然后更新j,通過 j -= (j&-j),這樣就能快速的求出前i個數字之和了,從而也就能求出任意區間之和了,參見代碼如下:

 

解法三:

class NumArray {
public:
    NumArray(vector<int> nums) {
        data.resize(nums.size());
        bit.resize(nums.size() + 1);
        for (int i = 0; i < nums.size(); ++i) {
            update(i, nums[i]);
        }
    }

    void update(int i, int val) {
        int diff = val - data[i];
        for (int j = i + 1; j < bit.size(); j += (j&-j)) {
            bit[j] += diff;
        }
        data[i] = val;
    }

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

    int getSum(int i) {
        int res = 0;
        for (int j = i; j > 0; j -= (j&-j)) {
            res += bit[j];
        }
        return res;
    }

private:
    vector<int> data, bit;
};

 

下面這種方法使用了 線段樹 Segment Tree 來做,對線段樹不是很了解的童鞋可以參見 網上這個帖子,在博主看來,線段樹就是一棵加了些額外信息的滿二叉樹,比如可以加子樹的結點和,或者最大值,最小值等等,這樣,當某個結點的值發生變化時,只需要更新一下其所有祖先結點的信息,而並不需要更新整棵樹,這樣就極大的提高了效率。比如對於 [1 3 5 7] 這個數組,我們可以根據其坐標划分,組成一個線段樹:

           [0, 3]
             16
       /            \
    [0, 1]        [2, 3]
      4             12
   /     \       /     \
[0, 0] [1, 1] [2, 2] [3, 3]
   1      3      5      7

其中,中括號表示是區間的范圍,下方的數字是區間和,這樣如果我們如果要更新區間 [2, 2] 中的數字5,那么之后只要再更新 [2, 3] 和 [0, 3] 兩個區間即可,並不用更新所有的區間。而如果要求區間和,比如 [1, 3] 的話,那么只需要加上這兩個區間 [1, 1] 和 [2, 3] 的和即可,感覺跟解法二的核心思想很類似。

將線段樹的核心思想理解了之后,我們就要用它來解題了。這里,我們並不用建立專門的線段樹結點,因為博主十分的不喜歡在 Solution 類中新建其他類,博主追求的是簡約時尚的代碼風格,所以我們可以只用一個數組來模擬線段樹。大小是多少呢,首先肯定要包換 nums 中的n個數字了,對於一個有n個葉結點的平衡的滿二叉樹,其總結點個數不會超過2n個,不要問博主怎么證明,因為我也不會。但你可以任意舉例子來驗證,都是正確的。所以我們用一個大小為 2n 的 tree 數字,然后就要往里面填數字了。填數字的方式先給 tree 數組的后n個數字按順序填上 nums 數字,比如對於 nums = [1 3 5 7],那么 tree 數組首先填上:

_ _ _ _ 1 3 5 7

然后從 i=3 開始,每次填上 tree[2*n] + tree[2*n+1],那么以此為:

_ _ _ 12 1 3 5 7

_ _ 4 12 1 3 5 7

_ 16 4 12 1 3 5 7

那么最終的 tree 數組就是 [0 16 4 12 1 3 5 7],tree[0] 其實沒啥作用,所以不用去管它。

接下來看 update 函數,比如我們想把5換成2,即調用 update(2, 2),由於 nums 數組在 tree 數組中是從位置n開始的,所以i也要加n,變成了6。所以先把 tree[6] 換乘2,那么此時 tree 數組為:

0 16 4 12 1 3 2 7

然后還要更新之前的數字,做法是若i大於0,則進行 while 循環,因為我們知道 tree 數組中i位置的父結點是在 tree[i/2],所以我們要更新 tree[i/2],那么要更新父結點值,就要知道左右子結點值,而此時我們需要知道i是左子結點還是右子結點么?其實可以使用一個小 trick,就是對於結點i,跟其成對兒的另一個結點位置是 i^1,根據異或的性質,當i為奇數,i^1 為偶數,當i為偶數,i^1 為奇數,二者一直成對出現,這樣左右子結點有了,直接更新父結點 i/2 即可,然后i自除以2,繼續循環。tree數組的之后變化過程為:

0 16 4 9 1 3 2 7

0 13 4 9 1 3 2 7

13 13 4 9 1 3 2 7

可以看到,tree[0] 也被更新,但其實並沒有什么卵用,不用理它。

接下來就是求區域和了,比如我們要求 sumRange(1, 3),對於更新后的數組 nums = [1 3 2 7],我們可以很快算出來,是 12。那么對於 tree = [13 13 4 9 1 3 2 7],我們如何計算呢。當然還是要先進行坐標變換,i變為5,j變為7。然后進行累加,我們的策略是,若i是左子結點,那么跟其成對兒出現的右邊的結點就在要求的區間里,則此時直接加上父結點值即可,若i是右子結點,那么只需要加上結點i本身即可。同理,若j是左子結點,那么只需要加上結點j本身,若j是右子結點,那么跟其成對兒出現的左邊的結點就在要求的區間里,則此時直接加上父結點值即可。具體的實現方法是,判斷若i是奇數,則說明其是右子結點,則加上 tree[i] 本身,然后i自增1;再判斷若j是偶數,則說明其是左子結點,則加上 tree[j] 本身,然后j自減1。那么你可能有疑問,i是偶數,和j是奇數的情況就不用處理了么,當然不是,這兩種情況都是要加父結點的,我們可以到下一輪去加,因為每一輪后,i和j都會除以2,那么i一定會有到奇數的一天,所以不用擔心會有值丟失,一定會到某一個父結點上把值加上的,參見代碼如下:

 

解法四:

class NumArray {
public:
    NumArray(vector<int> nums) {
        n = nums.size();
        tree.resize(n * 2);
        buildTree(nums);
    }

    void buildTree(vector<int>& nums) {
        for (int i = n; i < n * 2; ++i) {
            tree[i] = nums[i - n];
        }
        for (int i = n - 1; i > 0; --i) {
            tree[i] = tree[i * 2] + tree[i * 2 + 1];
        }
    }

    void update(int i, int val) {
        tree[i += n] = val;
        while (i > 0) {
            tree[i / 2] = tree[i] + tree[i ^ 1];
            i /= 2;
        }
    }

    int sumRange(int i, int j) {
        int sum = 0;
        for (i += n, j += n; i <= j; i /= 2, j /= 2) {
            if ((i & 1) == 1) sum += tree[i++];
            if ((j & 1) == 0) sum += tree[j--];
        }
        return sum;
    }    

private:
    int n;
    vector<int> tree;
};

 

討論:通過介紹上面四種不同的方法,我們應該如何處理可變的區間和問題有了深刻的認識了。除了第一種 brute force 的方法是無腦遍歷之外,后面三種方法其實都進行了部分數字的和累加,都將整體拆分成了若干的小的部分,只不過各自的拆分方法各不相同,解法二是平均拆分,解法三樹狀數組是變着花樣拆分,解法四線段樹是二分法拆分,八仙過海,各顯神通吧。

 

Github 同步地址:

https://github.com/grandyang/leetcode/issues/307

 

類似題目:

Range Sum Query 2D - Mutable

Range Sum Query 2D - Immutable

Range Sum Query - Immutable

 

參考資料:

https://leetcode.com/problems/range-sum-query-mutable/

https://leetcode.com/problems/range-sum-query-mutable/discuss/75763/7ms-Java-solution-using-bottom-up-segment-tree

https://leetcode.com/problems/range-sum-query-mutable/discuss/75785/Share-my-c%2B%2B-solution%3A-1700ms-using-tree-array

 

LeetCode All in One 題目講解匯總(持續更新中...)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM