Given an integer array nums, find the sum of the elements between indices i and j (i ≤ j), 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:
- The array is only modifiable by the update function.
- 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 ...)有如下關系:
那么是如何確定某個位置到底是有幾個數組成的呢,原來是根據坐標的最低位 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 0111 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 - Immutable
參考資料:
https://leetcode.com/problems/range-sum-query-mutable/
