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

頭髮是我最後的倔強發表於2020-10-17

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

題目重現:

給你一個陣列 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要如何計算呢,通過前面的發現,2比上一行的3少一個1,5比上一上的6少個1,所以就等於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);

    }
}

相關文章