<二分查詢+雙指標+字首和>解決子陣列和排序後的區間和
題目重現:
給你一個陣列 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
個。
//暴力法
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小的元素,這道題的解題思路有助於我們更好的解決上面的題目。
給定一個 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官方題解)
通過觀察發現mid = (1+16)/2 = 8,大於mid的都分佈在紅線下面,而不大於mid的部分分佈在紅線上面,所以可以使用二分查詢。
沿著圖中藍色箭頭走一邊,就可以計算出上方板塊的大小,即不大於mid的數字的數目,這樣通過二分將mid逐漸逼近第k小的元素。
演算法描述:目的是統計不大於當前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小的數。
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}
,這樣我們可以構造出它的非空連續子陣列的和矩陣如下:
第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)
。
<h5 id="1">flag</h5>
計算第k小的數字時候構造以1開始的字首和陣列sums
,陣列大小為n+1,我們實際有意義的從1開始,陣列的第0個初始化為0,這樣就不需要構建整個二位陣列了,而計算第2行的時候,發現第2行的每列數字對應上一行相應列的數字只是少了sums[1]
,第三行相比第一行來說就是少了sums[2]
,所以只需用第一行的數字依次減去sums[i]
就是第i行的各數,比如第二行的5就等於sums[3] - sums[1] = 6 - 1
。
/**
* 獲取小於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小的數字之和,計算時我們使用字首和陣列,並構造一個字首和的字首和陣列,如下示例:
此時我們已經得到了第k小的數字,要計算前k小的所有數字之和,考慮到第k小的數字會有重複大小的數字,所以分開計算,明確一點:我們已經得到第k小個數字,假設為6,前k小的數字為1,2,3,6,6,6
,可能後面還有幾個6,不過由於k個數的限制,並不納入計算,所以我們先計算嚴格小於6的數字之和以及這些數字的個數記為cnt,然後加上(k-cnt) * 6
。
我們構造出了字首和陣列sums和字首和的字首和陣列ssums
這樣以來如果我們要計算第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要如何計算呢,通過前面的ssums[3] - ssums[1] - 2*1
,其實整個第2行都會比第1行少1,而第i行會比第1行少nums[i]
。
因此對於連續非空子陣列的和構成的陣列我們要求所有嚴格小於第k小的數(記為kth)的和,遍歷每一行,每行都是從小到大遞增,當找到此行比kth小的最後一個數後,只需要根據字首和的字首和陣列就可在O(1)的時間裡算出來,假設第i行的第p列是此行最後一個小於kth的數,則此行小於kth的數字和為ssums[p] - ssum[i] - (p-i)*nums[i]
。
class Solution {
final int MODULO = 1000000007;
//二分+雙指標
/**
* 獲取小於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;
}
/**
* 獲取前k小的數的和
* @param sums 原陣列的字首和
* @param ssums 原陣列字首和的字首和
* @param n 原陣列大小
* @param k k
* @return 返回前k小的數字之和
*/
private long getSum (long[] sums, long[] ssums, int n, int k) {
long res = 0, cnt = 0;
long kth = getKth(sums, n, k); //第k小的數字
//分兩部分計算,考慮到有的數字會重複,所以先計算嚴格小於kth的數字的和與個數cnt,在加上剩餘k-cnt個第k小的數字
for (int i = 0, p = 1; i < n; i++) {
while (p<=n && sums[p]-sums[i] < kth) {
p++;
}
res = (res + ssums[p-1] - ssums[i] - (long)(p-1-i)*sums[i]);
cnt += p-1-i;
}
return (res + (k-cnt)*kth);
}
/**
* 計算
* @param nums
* @param n
* @param left
* @param right
* @return
*/
public int rangeSum (int[] nums, int n, int left, int right) {
long[] sums = new long[n+1];
long[] ssums = new long[n+1];
for (int i = 1; i <= n; i++) {
sums[i] = sums[i-1]+nums[i-1];
ssums[i] = ssums[i-1]+sums[i];
}
long r = getSum(sums, ssums, n, right);
long l = getSum(sums, ssums, n, left-1);
return (int) ((r-l)%MODULO);
}
}