1. 程式人生 > ><二分查詢+雙指標+字首和>解決子陣列和排序後的區間和

<二分查詢+雙指標+字首和>解決子陣列和排序後的區間和

# <二分查詢+雙指標+字首和>解決子陣列和排序後的區間和 > 題目重現: > > **給你一個數組 nums ,它包含 n 個正整數。你需要計算所有非空連續子陣列的和,並將它們按升序排序,得到一個新的包含 n * (n + 1) / 2 個數字的陣列。** > > **請你返回在新陣列中下標為 left 到 right (下標從 1 開始)的所有數字和(包括左右端點)。由於答案可能很大,請你將它對 10^9 + 7 取模後返回。** > > 示例 1:輸入:nums = [1,2,3,4], n = 4, left = 1, right = 5 > 輸出:13 > 解釋:所有的子陣列和為 1, 3, 6, 10, 2, 5, 9, 3, 7, 4 。將它們升序排序後,我們得到新的陣列 [1, 2, 3, 3, 4, 5, 6, 7, 9, 10] 。下標從 le = 1 到 ri = 5 的和為 1 + 2 + 3 + 3 + 4 = 13 。 > > 示例 2:輸入:nums = [1,2,3,4], n = 4, left = 3, right = 4 > 輸出:6 > 解釋:給定陣列與示例 1 一樣,所以新陣列為 [1, 2, 3, 3, 4, 5, 6, 7, 9, 10] 。下標從 le = 3 到 ri = 4 的和為 3 + 3 = 6 。 > > 示例 3:輸入:nums = [1,2,3,4], n = 4, left = 1, right = 10 > 輸出:50 > > > 提示: > > * 1 <= nums.length <= 10^3 > * nums.length == n > * 1 <= nums[i] <= 100 > * 1 <= left <= right <= n * (n + 1) / 2 > > 來源:力扣(LeetCode) > 連結:https://leetcode-cn.com/problems/range-sum-of-sorted-subarray-sums ​ 這是在leetcode上碰到的一道題,但由於設定的測試樣例並不是很好,而導致暴力解法也可通過,所以此題只是中等難度。但看過題解的解法思路後覺得有必要做一記錄。由淺入深,先通過暴力解法,然後引出優化的方法。 ## 暴力法 ​ 這道題給出一個數組nums,如果暴力解題,可以先計算出它的所有非空連續子陣列的和,然後進行排序,再計算它下標left到right的和,最後取餘數即可。 ​ 列舉出所有的非空連續子陣列,使用左右雙指標,假設題目給定nums為```1,2,3,4```,那麼先讓左指標指1,右指標從1開始依次滑動過整個陣列後面的數,即可得到以1開頭的子陣列和為```1,3,6,10```,再讓左指標右移一位,繼續按上述可得```2,5,9```......以此類推可得所有子陣列,然後對其進行排序。子陣列和的數目總共為```n*(n+1)/2```個。 ```java //暴力法 class Solution { public int rangeSum(int[] nums, int n, int left, int right) { int[] new_arr = new int[n*(n+1)/2+1]; //定義陣列存放所有子陣列 int index = 1; for (int i = 0; i < nums.length; i++) { int pre = 0; for (int j = i; j < nums.length; j++) { new_arr[index++] = pre+nums[j]; //dp思想,左指標固定後,右指標滑動後的下一個子陣列等於上次加nums[j]之和 pre = pre+nums[j]; //更新pre } } Arrays.sort(new_arr); //對子陣列進行排序 long count = 0; for (int i = left; i <= right; i++) { count+=new_arr[i]; } while (count >= 1000000007) { //取餘數後返回 count -= 1000000007; } return (int)count; } } ``` ## 前置討論 ​ 討論二分查詢+雙指標解法前,先看leetcode的另一道題[378. 有序矩陣中第K小的元素](https://leetcode-cn.com/problems/kth-smallest-element-in-a-sorted-matrix/),這道題的解題思路有助於我們更好的解決上面的題目。 > 給定一個 n x n 矩陣,其中每行和每列元素均按升序排序,找到矩陣中第 k 小的元素。 > 請注意,它是排序後的第 k 小元素,而不是第 k 個不同的元素。 > > > > 示例: > > matrix = [ > [ 1, 5, 9], > [10, 11, 13], > [12, 13, 15] > ], > k = 8, > > 返回 13。 > > 來源:力扣(LeetCode) > 連結:https://leetcode-cn.com/problems/kth-smallest-element-in-a-sorted-matrix ​ 先觀察這個給定的陣列matrix發現:整個陣列的行從左到右遞增,從上到下遞增,剛開始我想的是用優先順序佇列,首先加入一個最小的數(最左上角),然後每次加入佇列頭的右邊的數和下邊的數,周而復始的迴圈k次,佇列頭就是這個數。但由於優先順序佇列的維護本身就是非常耗時的,所以整個程式執行下來時間效率很低,運行了42ms。下面給出優化思路: ​ 二分查詢的思路,以下圖為例:(圖片來自leetcode官方題解) ![](https://gitee.com/vfdxvffd/blogimage/raw/master/378_fig1.png) ​ 通過觀察發現mid = (1+16)/2 = 8,大於mid的都分佈在紅線下面,而不大於mid的部分分佈在紅線上面,所以可以使用二分查詢。 ![](https://gitee.com/vfdxvffd/blogimage/raw/master/378_fig2.png) ​ 沿著圖中藍色箭頭走一邊,就可以計算出上方板塊的大小,即不大於mid的數字的數目,這樣通過二分將mid逐漸逼近第k小的元素。 ![](https://gitee.com/vfdxvffd/blogimage/raw/master/378_fig3.png) ​ 演算法描述:目的是統計不大於當前mid的數的數目,從第0列最後一行開始,如果此列最下面的數都不大於mid,那麼此列所有的數肯定都不大於mid,繼續到下一列,將列指標向右移動,如果此時最後一行的數大於mid,則將指示行的指標上移直到遇到一個不大於mid的數就停止,而這個數上面的數肯定都不大於mid。如果行指標已經滑到0還沒有不大於mid的數出現,那說明後面已經不可能有不大於mid的數了,因為這個陣列向右和向下是遞增的。 ​ 當訪問第j列的時候,如果第i+1行大於mid,而第i行不大於mid,則這列不大於mid的數數目為i+1(考慮第0行)。統計整個陣列中不大於mid的數的數目。如果小於k,則說明mid太小,將left右移至mid+1處,否則將right移至mid處。直到左右指標相遇,此時它們所指向的就是第k小的數。 ```java private static int kthSmallest(int[][] matrix, int k) { int n = matrix.length; int left = matrix[0][0]; int right = matrix[n-1][n-1]; int mid; //二分查詢,找到第k小的數 while (left < right) { mid = left + ((right-left) >> 1); if (check(matrix,mid,k,n)) { right = mid; } else { left = mid + 1; } } return left; } //利用雙指標檢查當前mid是否過大(即是否在陣列matrix中比mid大的數超過了k個) private static boolean check (int[][] matrix, int mid, int k, int n) { int i = n-1; //指示行座標 int j = 0; //指示列座標 int num = 0; while (i >= 0 && j < n) { if (matrix[i][j] <= mid) { j++; num += (i+1); } else { i--; } } return num>=k; } ``` Q1:考慮```check```函式,為什麼要```matrix[i][j] <= mid```而不是```matrix[i][j] < mid```? A:因為陣列matrix中可能會出現重複的數字,加入第k小的數字也重複了,形如```1,2,3,3,3,3,3,5,6```第5小的數字,那第3和第4個數字等於3,當卻確實在第5小的數字之前。
Q2:考慮```check```函式,為什麼要```return num >= k```而不是```return num > k```? A:因為left和right以及mid是從最小到最大的數之間的任意一個數,所以並不能保證它們就一定是陣列中存在的數,如果某個mid能保證小於等於它的數恰好為k個,則第k小的數就是它之前最近的一個存在於陣列中的數。所以當此時不大於mid的數大於或 等於k個時,就可以保證要求的```第k小的數```一定在mid或mid之前,故而將right移動到mid處。
Q3:考慮```kthSmallest```函式,為什麼```check```函式返回為真就左移,假就右移? A:設定第k小的數為res,當mid在res左邊時,此時陣列中不大於mid的數會因為少了res而小於k,因為res是第k個,所以left會右移,以求使mid右移。當mid在res右邊時或者就剛好等於res時,此時陣列中大於等於mid的數會等於或超過k個,由於res為第k個,而mid又在其右或等於,所以此時陣列中不大於mid的數至少為k個,所有使right左移,使得mid左移。
Q4:考慮```kthSmallest```函式,為什麼能保證最後left指向的就是陣列中的元素呢? A:根據```check```函式返回的情況不斷將左右指標逼近res,mid總是在res左右橫跳,帶動left和right逼近res,而mid終會有一次等於res,此時不大於mid的數大於或等於k個,右指標左移到res上,這時的mid總是小於res,而導致不大於mid的數目小於k,左指標右移,左右指標相鄰時,下一次左指標移動,必定移動到res上,```left == right```,跳出迴圈。
Q5:考慮```kthSmallest```函式,為什麼```right = mid```,而```left = mid+1```呢? A:這是由於除法向下取整而導致的二分查詢的特性,假設此時```left=2,right=3```,則```mid=2```,如果```left = mid```,則會一直原地打轉。如果```right = mid-1```,則可能此時的```mid == res```(mid==res時必定是右指標左移,參考Q3),右指標就會移動到res之前,從而錯過正解。 ## 優化解法 ​ 繼續回到這個題,看完前面前置的討論後相信對解答這個題會有很大幫助。如題目給的示例1:```nums = {1,2,3,4}```,這樣我們可以構造出它的非空連續子陣列的和矩陣如下: ![](https://gitee.com/vfdxvffd/blogimage/raw/master/2020-10-09_12-10.png) ​ 第1行是以1開頭的子陣列的和,分別對應```1;1,2;1,2,3;1,2,3,4```,第2行是以2行開始的子陣列的和,以此類推,觀察此陣列發現,這個陣列從左到右以此遞增,從上到下以此遞增,看到這應該就明白了上面那個前置討論的意義了。 ​ 先確定我們的大思路:題目要求構造一個非空連續子陣列的和,在這個新陣列中從left到right的元素之和,那我們可以參考前置討論裡的方法先得到前```left-1```大的數字,然後計算前```left-1```個數字之和記為```f(left-1)```,再同理計算前right個數字之和記為```f(right)```,最後答案就是```f(right) - f(left-1)```。
flag
計算第k小的數字時候構造以1開始的字首和陣列```sums```,陣列大小為n+1,我們實際有意義的從1開始,陣列的第0個初始化為0,這樣就不需要構建整個二位陣列了,而計算第2行的時候,發現第2行的每列數字對應上一行相應列的數字只是少了```sums[1]```,第三行相比第一行來說就是少了```sums[2]```,所以只需用第一行的數字依次減去```sums[i]```就是第i行的各數,比如第二行的5就等於```sums[3] - sums[1] = 6 - 1```。 ```java /** * 獲取小於mid的數的個數 * @param sums 原陣列的字首和 * @param n 原陣列的大小 * @param mid 二分法中的當前mid * @return 返回嚴格小於mid數的個數 */ private int getCnt (long[] sums, int n, int mid) { int res = 0; //返回的個數 for (int i = 0, p = 1; i < n; i++) { while (p <= n && sums[p] - sums[i] <= mid) { p++; } //因為每次符合都對p++,所以當最後一次符合條件後也對p進行了加1操作,而加1後p已經指向了最後一個符合條件的下一個數,所以還要給p-1 res += p-1-i; } return res; } /** * 利用二分查詢獲取第k小的數 * @param sums 原陣列的字首和 * @param n 原陣列的大小 * @param k 第k小 * @return 返回第k小的數 */ private int getKth (long[] sums, int n, int k) { int left = 0, right = Integer.MAX_VALUE; //二分查詢指示左右的兩個指標 while (left < right) { int mid = left + ((right-left) >> 1); if (getCnt(sums, n, mid) >= k) { right = mid; } else { left = mid + 1; } } return left; } ``` ​ 我們設計一個```getSum(k)```這個函式,就是上述的f函式,用來計算前k小的數字之和,計算時我們使用字首和陣列,並構造一個字首和的字首和陣列,如下示例: ![](https://gitee.com/vfdxvffd/blogimage/raw/master/2020-10-09_11-02.png) ​ 此時我們已經得到了第k小的數字,要計算前k小的所有數字之和,考慮到第k小的數字會有重複大小的數字,所以分開計算,明確一點:我們已經得到第k小個數字,假設為6,前k小的數字為```1,2,3,6,6,6```,可能後面還有幾個6,不過由於k個數的限制,並不納入計算,所以我們先計算嚴格小於6的數字之和以及這些數字的個數記為cnt,然後加上```(k-cnt) * 6```。 ​ 我們構造出了字首和陣列sums和字首和的字首和陣列ssums ![](https://gitee.com/vfdxvffd/blogimage/raw/master/2020-10-09_11-31.png) ​ 這樣以來如果我們要計算第1行的```sums[2]+sums[3]```的和,由於```ssums[3] = sums[1] + sums[2] + sums[3]```,而```ssums[1] = sums[1]```,所以```sums[2] + sums[3] = ssums[3] - ssums[1]```。 ​ 但是,我們如果要計算第2行的2+5要如何計算呢,通過