一道算法題聊透矩陣動態規劃

背景

23年某司代碼大賽編程題出了一道很經典矩陣動態規劃題,雖然本人使用(蠻力)循環法解出,但代碼效率不高,在“請教”了搜索引擎之後,發現此題設計非常巧,要想高效地解決此問題,多種優化算法,故此總結之。

題目內容

給出倉儲區的地圖warehouseswarehouses[i][j]表示倉儲區第ij列倉庫的風險估值。現計劃對其中一塊 正方形倉儲區 中的所有倉庫進行tb,要求其中的風險估值總和 不大於 limit。請返回能夠tb的正方形區域的 最長邊長,若不存在這樣的區域,則返回0

示例1:

[2 1 3] 3
[1 5 6] 4
[1 2 4] 8
1 8 3 2
輸入: warehouses = [[2,1,3,3], [1,5,6,4],[1,2,4,8],[1,8,3,2]],limit = 30
輸出: 3
解釋: 總和小於或等於 30 的正方形的最大邊長爲 3,如圖所示。

示例2:

輸入: warehouses =[[3,3,3], [4,4,4], [5,5,5]],limit = 2
輸出: 0

提示:

  • 1 <= warehouses.length, warehouses[i].length <= 300
  • 0 <= warehouses[i][j] <= 10^4
  • 0 <= limit <= 10^5

方法1——man力循環法

看到本題之後,我最先想到的,自然是邊長從1到n(n=最小值(倉庫長,倉庫寬)),倉庫從第一行、第一列開始,一直循環到最後一行、最後一列,不停地計算正方形元素之和,找出和<limit的最大值所在正方形的邊長。

class Solution1 {
    public int maxInsuredArea(int[][] warehouses, int limit) {
        int rowCount = warehouses.length;
        int colCount = warehouses[0].length;
        // 正方形邊長不大於倉庫長、寬中最小的那個
        int upBound = Math.min(rowCount, colCount);

        // 滿足條件的最大邊長
        int maxSideLen = 0;

        // 從1到n計算各種邊長的價值
        loop_side_length: // 邊長提前跳出點
        for (int squreLength = 1; squreLength <= upBound; squreLength++) {
            // 從左到右、從上到下循環倉庫元素做爲起點
            for (int left = 0; left < rowCount - squreLength + 1; left++) {
                for (int top = 0; top < colCount - squreLength + 1; top++) {
                    int sum = sumSquare(warehouses, left, top, squreLength);
                    //只取價值小於limit的值
                    if (sum <= limit && squreLength > maxSideLen) {
                        maxSideLen = squreLength;
                        // 只要當前邊長出現一個滿足條件的值,則可以繼續下一邊長循環,無須計算餘下的值
                        continue loop_side_length;
                    }
                }
            }
        }
        return maxSideLen;
    }

    // 計算數組中子正方形的數值和
    private int sumSquare(int[][] ary, int left, int top, int len) {
        int result = 0;
        for (int r = 0; r < len; r++) {
            for (int c = 0; c < len; c++) {
                result += ary[left + r][top + c];
            }
        }
        return result;
    }
}

這種解法簡單易懂,但是其時間複雜度達到了驚人的O(n^5),當倉庫矩陣大小=200*200左右時,實際執行時間已超過了3秒,這必然不是出題者的本意。

方法2——二維前綴和

一維數組局部求和問題

在“請教”了之後,我們得到了一個此題的關鍵算法——前綴和。 那麼什麼是前綴和呢?我們從一維數組開始講起:

假如有一個一維數組a,如何求第a[5]到a[8]元素之和?

a[0] a[1] a[2] a[3] a[4] a[5] a[6] a[7] a[8] a[9] ... a[n]
  • 答案:sum(a[5]...a[8])=a[5]+a[6]+a[7]+a[8]
  • 總結成公式:sum(a[i]...a[j])=a[i]+a[i+1]...+a[j]
  • 這個求和算法的時間複雜度是O(n),其中n=待求和的元素個數

如果問題變成,求第1..4, 3..5, 5..8 ... 多次求和呢?

此時問題就變成了O(m*n),即O(n^2),其中m=求和次數,n=待求和的元素個數

有沒有辦法降低上面算法的時間複雜度呢?

我們可以推導出這樣一個算式: a[i]+a[i+1]...+a[j] = sum(a[0]...a[j])-sum(a[0]...a[i-1])

a[0] ... a[i-1] a[i] ... a[j]
a[0] ... a[i-1]

然而,這個版式有什麼用呢?此時,只要我們先構造一個前綴和數組s:

a[0] a[0]+a[1] a[0]+..+a[2] a[0]+..+a[3] a[0]+..+a[4] a[0]+..+a[5] a[0]+..+a[6] a[0]+..+a[7] a[0]+..+a[8] a[0]+..+a[9] ...

那麼數據元素a[i]..a[j]之和的問題,就簡化成了兩數之差:s[j]-s[i-1]

爲簡化i=0時,數組越界問題,通常會空間換時間,讓s.length=a.length+1,s第一個元素留空;這樣問題就簡化成:sum(a[i]..a[j])=s[j+1]-s[i]

而構造s數組的過程可以簡化爲:

0 s[0]+a[0] s[1]+a[1] s[2]+a[2] ... s[n]+a[n]

這個構造過程的時間複雜度是O(n),其中n=數組長度

進而,前面m次求數組第i..j元素之和的時間複雜度就降低爲了O(n+m*2),即O(n)。

二維數組(矩陣)局部求和問題

理解了一維數據的前綴和之後,我們就很容易將方法推廣到二維數組: 假設有二維數組a:

2 1 3 3
1 5 6 4
1 2 4 8
1 8 3 2

我們可以按照此算法構造二維前綴和數組s:

0 0 0 0 0
0 2+0+0-0=2 1+0+2-0=3 3+0+3-0=6 3+0+6-0=9
0 1+2+0-0=3 5+3+3-2=9 6+6+9-3=18 4+9+18-6=25
0 1+3+0-0=4 2+9+4-3=12 4+18+12-9=25 8+25+25-18=40
0 1+4+0-0=5 8+12+5-4=21 3+25+21-12=37 2+40+37-25=54

算法簡介:從左上原點開始到某行某列的元素和=當前元素值+前一行同一列的元素和+同一行前一列的元素和-前一行前一列的元素和(行、列加了兩次)

0 0 0 ... 0
0 a[0,0]+s[0,1]+s[1,0]-s[0,0] a[0,1]+s[0,2]+s[1,1]-s[0,1] ... a[0,j-1]+s[0,j]+s[1,j-1]-s[0,j-1]
0 a[1,0]+s[1,1]+s[2,0]-s[1,0] a[1,1]+s[1,2]+s[2,1]-s[1,1] ... a[1,j-1]+s[1,j]+s[2,j-1]-s[1,j-1]
... ... ... ... ...
0 a[i-1,0]+s[i-1,1]+s[i,0]-s[i-1,0] a[i-1,1]+s[i-1,1]+s[i,1]-s[i-1,1] ... a[i-1,j-1]+s[i-1,j]+s[i,j-1]-s[i-1,j-1]

有了這樣一個二維前綴和矩陣之後,要計算其中任意子矩形的元素和,就簡單了: sum(a[top,left]...a[bottom,right])=s[bottom+1,right+1]-s[top,right+1]-s[bottom+1,left]+s[top,left]

驗算:

2 1 3 3
1 [5 6 4]
1 [2 4 8]
1 [8 3 2]

累加法:5+6+4+2+4+8+8+3+2=42 前綴和法:54-9-5+2=42 求矩陣和這種時間複雜度爲O(n^2)的算法,直接被優化成了O(1)。

解題代碼

在掌握了以上知識點之後,利用前綴和改進man力求解法的代碼就呼之欲出了:

class Solution2 {
    // 爲二維數組生成前綴和
    private int[][] calcPrefixSum(int[][] matrix) {
        int rowCount = matrix.length;
        int colCount = matrix[0].length;
        int[][] result = new int[rowCount + 1][colCount + 1];
        //計算二維前綴和
        for (int i = 1; i <= rowCount; i++) {
            for (int j = 1; j <= colCount; j++) {
                result[i][j] = result[i - 1][j] + result[i][j - 1] + matrix[i - 1][j - 1] - result[i - 1][j - 1];
            }
        }
        return result;
    }

    public int maxInsuredArea(int[][] warehouses, int limit) {
        int rowCount = warehouses.length;
        int colCount = warehouses[0].length;
        int maxLength = Math.min(rowCount, colCount);

        //構造前綴和
        int[][] prefixSumMatrix = calcPrefixSum(warehouses);

        // 邊長從大到小開始循環,從而大邊長滿足條件時,可以直接返回
        for (int sl = maxLength; sl > 0; sl--) {
            // 循環行列
            for (int i = 0; i <= rowCount - sl; i++) {
                for (int j = 0; j <= colCount - sl; j++) {
                    // 使用前綴和快速計算方形區域元素和
                    int sum = prefixSumMatrix[i + sl][j + sl] - prefixSumMatrix[i][j + sl] - prefixSumMatrix[i + sl][j] + prefixSumMatrix[i][j];
                    //如果此邊長下,矩陣和滿足條件,則可以直接返回邊長大小
                    if (sum <= limit) {
                        return sl;
                    }
                }
            }
        }
        return 0;
    }
}

使用此方法後,時間複雜度一下子由O(n^5)下降到O(n^2+n^3),即O(n^3),對200*200的矩陣運行測試只需要16ms的結果來看,此優化取得得非常大的效果。

方法3——前綴和+二分查找法

上面的算法還有沒有優化空間呢?仔細分析,其實對尋到合適邊長的問題,我們還可以把它優化爲一個二分查找問題,假如矩陣能容納的最大正方形邊長爲n:

  1. n/2邊長是否有滿足和小於limit的解?
  2. n/2無解時n/2/2邊長時是否有解?
  3. n/2有解時(n/2+n)邊長是否有解?
  4. ...

按此思路,第二個循環可以使用二分查找法優化:

class Solution3 {
    private int[][] calcPrefixSum(int[][] matrix) {
        int rowCount = matrix.length;
        int colCount = matrix[0].length;
        int[][] result = new int[rowCount + 1][colCount + 1];
        //計算二維前綴和
        for (int i = 1; i <= rowCount; i++) {
            for (int j = 1; j <= colCount; j++) {
                result[i][j] = result[i - 1][j] + result[i][j - 1] + matrix[i - 1][j - 1] - result[i - 1][j - 1];
            }
        }
        return result;
    }

    public int maxInsuredArea(int[][] warehouses, int limit) {
        int rowCount = warehouses.length;
        int colCount = warehouses[0].length;
        int[][] prefixSumMatrix = calcPrefixSum(warehouses);

        //使用二分查找,找到合適的邊長
        int lbound = 0;
        int ubound = Math.min(rowCount, colCount);
        int bestLen = 0;
        while (lbound <= ubound) {
            // 取上下界的中間值
            int sl = (lbound + ubound) / 2;
            boolean ok = false;
            loop_sl:
            for (int i = 0; i <= rowCount - sl; i++) {
                for (int j = 0; j <= colCount - sl; j++) {
                    int sum = prefixSumMatrix[i + sl][j + sl] - prefixSumMatrix[i][j + sl] - prefixSumMatrix[i + sl][j] + prefixSumMatrix[i][j];
                    if (sum <= limit) {
                        ok = true;
                        break loop_sl;
                    }
                }
            }
            //如果當前邊長<=上界值,則可以加大下界繼續查找,否則就減少上界繼續
            if (ok) {
                lbound = sl + 1;
                bestLen = sl;
            } else {
                ubound = sl - 1;
            }
        }
        return bestLen;
    }
}

通過二分查找法,該題的時間複雜度進一步被優化成了O(n^2+log(n)*n^2),也即O(n^2*log(n)),時間複雜度進一步降低,使用200*200矩陣測試,只需要8ms的時間——又快了一倍!

總結

通過以上算法改進過程,我們可以總結出動態規劃題的基本套路:

  1. 觀察題目,總結某一結果與前面結果的關係;
  2. 從一維數組開始,逐步推導到二維甚至多維數組;
  3. 從小到大或從大到小的判斷,可以優化爲二分查找
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章