背景
23年某司代碼大賽編程題出了一道很經典矩陣動態規劃題,雖然本人使用(蠻力)循環法解出,但代碼效率不高,在“請教”了搜索引擎之後,發現此題設計非常巧,要想高效地解決此問題,多種優化算法,故此總結之。
題目內容
給出倉儲區的地圖warehouses
,warehouses[i][j]
表示倉儲區第i
行j
列倉庫的風險估值。現計劃對其中一塊 正方形倉儲區 中的所有倉庫進行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:
- n/2邊長是否有滿足和小於limit的解?
- n/2無解時n/2/2邊長時是否有解?
- n/2有解時(n/2+n)邊長是否有解?
- ...
按此思路,第二個循環可以使用二分查找法優化:
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的時間——又快了一倍!
總結
通過以上算法改進過程,我們可以總結出動態規劃題的基本套路:
- 觀察題目,總結某一結果與前面結果的關係;
- 從一維數組開始,逐步推導到二維甚至多維數組;
- 從小到大或從大到小的判斷,可以優化爲二分查找