一个DP题的思路错误分析全过程

  2021-3-6 


orz

《最大子矩阵》

给定一个正整数、负整数和 0 组成的 N × M 矩阵,编写代码找出元素总和最大的子矩阵。

返回一个数组 [r1, c1, r2, c2],其中 r1, c1 分别代表子矩阵左上角的行号和列号,r2, c2 分别代表右下角的行号和列号。若有多个满足条件的子矩阵,返回任意一个均可。

示例:

输入:
[
[-1,0],
[0,-1]
]
输出:[0,1,0,1]

1 <= matrix.length, matrix[0].length <= 200


拿到题先开始分析,属于二维区间DP,这个题显然是LMS的升级版

$dp(i,j)$可能由$dp(i-1,j)$和$dp(i,j-1)$状态转移而来

并且存在状态转移中断的可能

所以我们定义$dp(i,j)$表示以$matrix(i,j)$元素为矩阵右下角(即该元素必选)的最大子矩阵

第一次,完全错了:

初步设想状态转移方式为

①右扩$dp(i-1,j)\to dp(i,j)$:如果右扩,则新增的并不是$matrix(i,j)$,而是$\sum matrix(0…i,j)$,若$\sum matrix(0…i,j)>0$则转移,否则不转移;

②下扩$dp(i,j-1)\to dp(i,j)$:如果下扩,则新增是$\sum matrix(i,0…j)$,若$\sum matrix(i,0…j)>0$则转移,否则不转移。

显然发现这个转移方式有问题:

第二次,然而还是有错:

问题一:没有考虑状态中断。在状态转移中断之后,不是从0开始计算的,而是从上一次中断开始加和,所以需要第三维来记录上一个起点。并且根据题意要求输出坐标,所以也正好需要专门记录。

问题二:和LMS一样容易犯的老错误,状态$dp(i,j)$中$matrix(i,j)$是必选的!不是根据新增元素,以右扩为例,即不是根据$\sum matrix(0…i,j)$大小来判断是否转移,而是应该判断$dp(i-1,j)>0$才能转移,因为根据必须以$matrix(i,j)$为结尾的$dp(i,j)$定义,只要$dp(i-1,j)>0$,那么加上新增元素无论大还是小都比中断状态转移更大。

所以综合下来,状态转移方程为:

①右扩$dp(i-1,j)\to dp(i,j)$:如果右扩,若$dp(i-1,j)>0$则转移:

否则不转移:

②下扩$dp(i,j-1)\to dp(i,j)$:如果下扩,若$dp(i,j-1)>0$则转移:

否则不转移:

代码:

def getMaxMatrix(self, matrix: List[List[int]]) -> List[int]:
    #dp[i][j]:以nums[i][j]为最右下角的矩阵的最小值
    n = len(matrix)  #行
    m = len(matrix[0])  #列
    dp = [[(0,0,0)]*m for i in range(n)]  #(nums,i,j) 分别代表该位值、上一个中断处
        
    #start
    for i in range(n):     
        for j in range(m):
            #右拓:dp(i,j)可能由dp(i,j-1)转移
            right_rec = float('-inf')
            if j>0:  #边界判断
                row_sums = 0
                #【关键点】中断之后,不是从0开始计算的,而是从上一次中断开始加和,所以需要第三维来记录上一个起点。同时根据题意,也需要专门记录。
                for row_index in range(dp[i][j-1][1],i+1):        
                    row_sums += matrix[row_index][j]
                #若和增大,则可以转移,否则不转移(初始值)
                # if row_sums>=0:  #【错误点】:和LIS问题一样,状态转移公式出错,dp[i][j]中matrix[i][j]是必选的!转不转移是由源状态是否>=0来判断!  
                if dp[i][j-1][0]>=0:  
                    right_rec = dp[i][j-1][0] + row_sums
                    
            #下拓:dp(i,j)可能由dp(i-1,j)转移
            down_rec = float('-inf')
            if i>0:
                col_sums = 0
                for col_index in range(dp[i-1][j][2],j+1):
                    col_sums += matrix[i][col_index]
                if dp[i-1][j][0]>=0:  
                    down_rec = dp[i-1][j][0] + col_sums                
            #判断从哪个状态转移或未转移
            if right_rec==float('-inf') and down_rec==float('-inf'):  #错误点,inf少打了个负号...TMD找了半天BUG
                dp[i][j] = (matrix[i][j],i,j)  #未发生转移,转移中断,设置为初始值,即matrix[i][j]
            else:
                #转移成功,这里要记录,本次矩阵起点与上一个转移状态起点一致
                if right_rec>down_rec:   
                    dp[i][j] = (right_rec,dp[i][j-1][1],dp[i][j-1][2])
                else:
                    dp[i][j] = (down_rec,dp[i-1][j][1],dp[i-1][j][2])
        
    #在dp矩阵中找值最大的状态
    max_rec = float('-inf')
    startxy = (0,0)
    endxy = (0,0)
    for i in range(n):
        for j in range(m):
            if dp[i][j][0]>max_rec:
                max_rec = dp[i][j][0]
                startxy = (dp[i][j][1],dp[i][j][2])
                endxy = (i,j)
    return [startxy[0],startxy[1],endxy[0],endxy[1]]

然而还是有问题…

举个例子:

-1 -2 -9  6
 8 -9 -3 -6
 2  9 -7 -6

在这个输入中,主要关注左下角四个,其dp矩阵为

8  -1
10 10

这里显然错了!

按照上面的算法,dp(2,1)只能来源于dp(2,0)或dp(1,1)。dp(1,1)<0直接跳过,dp(2,0)所得矩阵的起点是(2,0),也就是说是由这两个数组成的矩阵:

8
2

那么在右扩的时候,计算起点只能是这个4个数的方格如下,成为了10+(-9)+9=10:

8 -9
2  9

而正确答案是2+9=11:

2  9

上面的算法没给出这种可能性。所以思路是完全错误了,通过率为50%(悲)。

虽然有错,但是思路是很重要的。

正确答案

正确思路是对矩阵进行降维,将二维问题拆解为一维LMS问题。

实际上是一个巧解…..

对于一个$n×m$的矩阵,有$1+2+\cdots+n$种横向选择方案,我们堆每一种横向选择方案进行列求和,存储进一个$sum[]$数组中,也就是将所有行选法$n×m$降维成了$1×m$,变成了一个一维序列。

然后我们再对每个$sum[]$数组求LMS,求出所有$sum[]$数组最大的那个即可…

不过要注意,由于给出起始与终点位置,所以要记录起始位置,记录方法与上面一样。

eg:

-1 -2 -9  6
 8 -9 -3 -6
 =>选第1行 sum[0]=[-1,-2,-9,6]
 =>选第2行 sum[1]=[8,-9,-3,-6]
 =>选第12行 sum[2]=[7,-11,-12,0]
 然后分别对sum[0],sum[1],sum[2]用DP求LIS,再选出最大那个即可。

其思想就是利用矩阵特性,即子矩阵中每一行等宽,枚举上下边加和成一维,将行列分开计算,实现降维为一维LMS问题:

def getMaxMatrix(self, matrix: List[List[int]]) -> List[int]:
    n = len(matrix)
    m = len(matrix[0])
    sum_matrix = []
    row_info = [] #用于记录sum_matrix每一项对应的加法组合的行的起点和终点

    #降维存入sum数组
    #【错误点】通过双端指针枚举完所有组合法
    for i in range(n):
        for j in range(i+1,n+1):  #错误点:注意这里应该从i+1起始
            new = [0]*m
            for k in range(i,j):
                for l in range(m):
                    new[l] += matrix[k][l]
            sum_matrix.append(new)
            row_info.append((i,j-1))
    #对sum中的每一个子串使用LMS求(最大值,起点,终点),记录在LMS_max中
    LMS_max = []
    for i in range(len(sum_matrix)):
        nums = sum_matrix[i]
        dp = [0]*m
        dp[0] = (nums[0],0,0)
        for j in range(1,m):
            if dp[j-1][0]>0:
                dp[j] = (dp[j-1][0]+nums[j],dp[j-1][1],j)
            else:
                dp[j] = (nums[j],j,j)
        list_res = (float('-inf'),0,0)
        for k in range(0,m):
            if dp[k][0]>list_res[0]:
                list_res = dp[k]
        LMS_max.append(list_res)
    #寻找最大那个
    max_res = float('-inf')
    index = -1
    for i in range(len(sum_matrix)):
        if LMS_max[i][0]>max_res:
            max_res = LMS_max[i][0]
            index = i
    return [row_info[index][0],LMS_max[index][1],row_info[index][1],LMS_max[index][2]]

答案是对的,但是部分答案OOT了,这不科学…

反正思路就是这个思路了,时间哪里应该还能优化,下次补充


且听风吟