一、最长回文子串

此题为leetcode第5题
思路:此题的“中心扩展法”在前面已经讲过,这里解释动态规划解法。设状态dp[i][j]的含义是在区间[i, j]的字符串是否为回文串。如果[i+1, j-1]区间内已经是回文串了,那么只要s[i] == s[j]那么[i, j]区间的字符串便是回文串。另外要主要内外循环的次序。一般是先循环i再循环j,但在这里会导致参考的状态还没有被计算出来。比如当下计算dp[i][j],会用到dp[i+1][j-1],由于先循环的i所以这个状态还没有被计算出来。因此要先循环j再循环i。

class Solution:
    def longestPalindrome(self, s):
        n = len(s)
        if n <= 1:
            return s
        
        dp = [[False for _ in range(n)] for _ in range(n)]
        for i in range(n):
            dp[i][i] = True
        
        max_len, start = 1, 0
        for j in range(1, n):
            for i in range(j):
                if j - i <= 2 and s[i] == s[j]:
                    dp[i][j] = True
                elif j - i > 2 and dp[i + 1][j - 1] and s[i] == s[j]:
                    dp[i][j] = True
                
                if dp[i][j] and (j - i + 1) > max_len:
                    max_len = j - i + 1
                    start = i
        return s[start: start + max_len]

二、最大子序和

此题为leetcode第53题
思路:设状态dp[i]的含义是以sums[i]为结尾的连续子序数组中最大的和。状态转移方程如下:
d p [ i ] = { n u m s [ i ] , i f d p [ i − 1 ] ≤ 0 d p [ i − 1 ] + n u m s [ i ] , i f d p [ i − 1 ] > 0 dp[i]= \begin{cases} nums[i], \quad if \quad dp[i-1] \leq 0 \\ dp[i-1]+nums[i], \quad if \quad dp[i-1]>0 \end{cases} dp[i]={nums[i],ifdp[i1]0dp[i1]+nums[i],ifdp[i1]>0

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        dp = [0] * len(nums)
        dp[0] = nums[0]
        res = nums[0]
        for i in range(1, len(nums)):
            if dp[i - 1] <= 0:
                dp[i] = nums[i]
            else:
                dp[i] = dp[i - 1] + nums[i]
            
            if dp[i] > res:
                res  = dp[i]
        return res

三、最小路径和

此题为leetcode第64题
思路:设dp[i][j]含义是从(i, j)走到右下角的最小路径和,最后的答案便是dp[0][0]。初始化的时候,dp[-1][-1]就是grid[-1][-1],并且最右边一列只能往下走,最下面一行只能往右走,这三种情况可以先初始化。遍历的顺序是右下角到左上角,状态转移方程为:
d p [ i ] [ j ] = min ⁡ ( d p [ i + 1 ] [ j ] , d p [ i ] [ j + 1 ] ) + g r i d [ i ] [ j ] dp[i][j] = \min(dp[i + 1][j], dp[i][j+1]) + grid[i][j] dp[i][j]=min(dp[i+1][j],dp[i][j+1])+grid[i][j]

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        if m <= 1 or n <= 1:
            return sum(grid[0])
        
        dp = [[0 for _ in range(n)] for _ in range(m)]
        dp[-1][-1] = grid[-1][-1]
        for i in range(m - 2, -1, -1):
            dp[i][-1] = dp[i + 1][-1] + grid[i][-1]
        for i in range(n - 2, -1, -1):
            dp[-1][i] = dp[-1][i + 1] + grid[-1][i]
        
        for i in range(m - 2, -1, -1):
            for j in range(n - 2, -1, -1):
                dp[i][j] = min(dp[i + 1][j], dp[i][j + 1]) + grid[i][j]
        return dp[0][0]

四、不同路径

此题为leetcode第62题
思路:设状态dp[i][j]的含义是从起点到(i, j)有多少条路。可以先将边界初始化,即第0行和第0列。状态转移方程为:

d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + d p [ i ] [ j − 1 ] dp[i][j]=dp[i-1][j]+dp[i][j-1] dp[i][j]=dp[i1][j]+dp[i][j1]

本题还可以将状态压缩,使空间复杂度变为O(n)。我们发现第i行的状态只和第i – 1行的相关,可以把i这个维度去掉。

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [[0 for _ in range(n)] for _ in range(m)]
        for i in range(n):
            dp[0][i] = 1
        for i in range(m):
            dp[i][0] = 1
        
        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = dp[i][j - 1] + dp[i - 1][j]
        return dp[-1][-1]
class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        # 空间复杂度优化成O(n)
        cur = [1] * n
        for i in range(1, m):
            for j in range(1, n):
                cur[j] += cur[j-1]
        return cur[-1]

五、不同路径II

此题为leetcode第63题
思路:和上面的题一样,只需判断一下当前(i, j)是否为障碍物,是障碍物的话continue即可。

class Solution:
    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
        m, n = len(obstacleGrid), len(obstacleGrid[0])
        dp  =[[0 for _ in range(n)] for _ in range(m)]
        for i in range(n):
            if obstacleGrid[0][i] == 1:
                break
            dp[0][i] = 1
        for i in range(m):
            if obstacleGrid[i][0] == 1:
                break
            dp[i][0] = 1
        
        for i in range(1, m):
            for j in range(1, n):
                if obstacleGrid[i][j] == 1:
                    continue
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
        return dp[-1][-1]

六、最大矩形

此题为leetcode第85题
思路:假设对于每一个点,我们向上扩展直到遇上0,然后左右扩展,直到无法容纳矩形最大高度。而最大矩阵就是用这种方式构建的矩阵之一。给定一行 matrix[i],我们通过定义三个数组height,left,和right来记录每个点的高度、左边界和右边界。height[j] 对应matrix[i][j]的高,以此类推。
height的更新:

h e i g h t [ j ] = { o l d _ h e i g h t [ j ] + 1 , i f m a t r i x [ i ] [ j ] = = 1 0 , i f m a t r i x [ i ] [ j ] = = 0 height[j]= \begin{cases} old\_height[j]+1, \quad if \quad matrix[i][j]==1 \\ 0, \quad if \quad matrix[i][j]==0 \end{cases} height[j]={old_height[j]+1,ifmatrix[i][j]==10,ifmatrix[i][j]==0

考虑哪些因素会导致矩形左边界的改变。由于当前行之上的全部0已经考虑在当前版本的left中,唯一能影响left就是在当前行遇到0。更新left:

l e f t [ j ] = { min ⁡ ( o l d _ l e f t [ j ] , c u r r _ l e f t ) , i f m a t r i x [ i ] [ j ] = = 1 0 and c u r r _ l e f t = j + 1 , i f m a t r i x [ i ] [ j ] = = 0 left[j]= \begin{cases} \min(old\_left[j], curr\_left), \quad if \quad matrix[i][j]==1 \\ 0 \quad \text{and} \quad curr\_left=j + 1, \quad if \quad matrix[i][j]==0 \end{cases} left[j]={min(old_left[j],curr_left),ifmatrix[i][j]==10andcurr_left=j+1,ifmatrix[i][j]==0

cur_left是我们遇到的最右边的0的序号加1。当我们将矩形向左 “扩展” ,我们知道,不能超过该点,否则会遇到0。
right的更新和left类似:

r i g h t [ j ] = { min ⁡ ( o l d _ r i g h t [ j ] , c u r r _ r i g h t ) , i f m a t r i x [ i ] [ j ] = = 1 0 and c u r r _ r i g h t = j , i f m a t r i x [ i ] [ j ] = = 0 right[j]= \begin{cases} \min(old\_right[j], curr\_right), \quad if \quad matrix[i][j]==1 \\ 0 \quad \text{and} \quad curr\_right=j, \quad if \quad matrix[i][j]==0 \end{cases} right[j]={min(old_right[j],curr_right),ifmatrix[i][j]==10andcurr_right=j,ifmatrix[i][j]==0

cur_right 是我们遇到的最左边的0的序号。简便起见,我们不把 cur_right 减去1 (就像我们给cur_left加上1那样) ,这样我们就可以用height[j] * (right[j] - left[j]) 而非height[j] * (right[j] + 1 - left[j])来计算矩形面积。

class Solution:
    def maximalRectangle(self, matrix: List[List[str]]) -> int:
        if not matrix:
            return 0
        m, n = len(matrix), len(matrix[0])
        # 对于每个点,记录其最大矩形的左右边界和高度
        left, right, height = [0] * n, [n] * n, [0] * n
        
        res = 0
        for i in range(m):
            curr_left, curr_right = 0, n
            
            # 更新height
            for j in range(n):
                if matrix[i][j] == '1':
                    height[j] += 1
                else:
                    height[j] = 0
                    
            # 更新left
            for j in range(n):
                if matrix[i][j] == '1':
                    left[j] = max(left[j], curr_left)
                else:
                    left[j] = 0
                    curr_left = j + 1
            
            # 更新right
            for j in range(n - 1, -1, -1):
                if matrix[i][j] == '1':
                    right[j] = min(right[j], curr_right)
                else:
                    right[j] = n
                    curr_right = j
            
            # 更新面积
            for j in range(n):
                res = max(res, height[j] * (right[j] - left[j]))
        return res

七、解码方法

此题为leetcode第91题
思路:设dp[i]的含义是以第i个字符结尾的字符串的解码方式总数,长度为n+1,因为会涉及到dp[i-2]的操作。另外,原来字符串的第0个现在是从1开始,因此第i个字符实际上是s[i-1]。根据s[i-1]是否为0划分情况,状态转移方程分别如下:

  • s[i-1] == 0,当前字符为0
    • 如果s[i-2] == 1 or 2,当前字符可以和前一个字符解码,那么dp[i] = dp[I - 2]
    • 否则当前字符无法和前一个字符解码,并且当前字符无法单独解码,那么dp[i] = 0
  • s[i-1] != 0,当前字符不为0
    • 如果s[i- 2] != 0,且当前和前一个组合可以解码,那么dp[i] = dp[i-1] + dp[i-2]
    • 否则的话就是,前一个为0且当前字符可以单独解码,或者前一个和当前无法解码,那么就是dp[i] = dp[i-1]
class Solution:
    def numDecodings(self, s: str) -> int:
        n = len(s)
        if n == 0 or s[0] == '0':
            return 0
        
        dp = [0] * (n + 1)
        dp[0] = 1
        for i in range(1, n + 1):
            if s[i - 1] == '0':     # 当前字符为0
                if s[i - 2] == '1' or s[i - 2] == '2':  # 能和前面的字符解码
                    dp[i] = dp[i - 2]
                else:   # 无法和前一个字符解码,当前为0,无法单独解码
                    dp[i] = 0
            else:   # 当前字符不为0
                # 前一个字符不为0且能和当前字符解码,当前字符可以单独解码
                if s[i - 2] != '0' and 1 <= int(s[i - 2] + s[i - 1]) <= 26:
                    dp[i] = dp[i - 1] + dp[i - 2]
                # 前一个字符为0且当前字符可以单独解码,或者当前和前一个无法解码
                else:
                    dp[i] = dp[i - 1]
        return dp[-1]

八、不同的二叉搜索树

此题为leetcode第96题
我们定义两个函数:

  • G ( n ) G(n) G(n):长度为 n n n的序列的不同二叉搜索树个数
  • F ( i , n ) F(i,n) F(i,n):以 i i i为根的不同二叉搜索树个数( 1 ≤ i ≤ n 1≤i≤n 1in

不同的二叉搜索树的总数G(n),是对遍历所有 i i i ( 1 < = i < = n 1 <= i <= n 1<=i<=n) 的 F ( i , n ) F(i,n) F(i,n)之和,即

G ( i ) = ∑ i = 1 n F ( i , n ) G(i)=\sum_{i=1}^{n}F(i,n) G(i)=i=1nF(i,n)

特别地,当序列长度为1(只有根节点)或0(空树)时,只有一种情况,即 G ( 0 ) = 1 , G ( 1 ) = 1 G(0)=1,G(1)=1 G(0)=1,G(1)=1。举例而言,F(3,7),以 3 为根的不同二叉搜索树个数。为了以 3 为根从序列[1, 2, 3, 4, 5, 6, 7]构建二叉搜索树,我们需要从左子序列[1, 2]构建左子树,从右子序列[4, 5, 6, 7]构建右子树,然后将它们组合。巧妙之处在于,我们可以将 [1,2] 构建不同左子树的数量表示为G(2), 从[4, 5, 6, 7]构建不同右子树的数量表示为G(4)。这是由于G(n)和序列的内容无关,只和序列的长度有关。于是,F(3,7)= G(2)∙G(4)。 概括而言,我们可以得到以下公式:

F ( i , n ) = G ( i − 1 ) ⋅ G ( n − i ) F(i,n)=G(i-1) \cdot G(n-i) F(i,n)=G(i1)G(ni)

最终得到递推公式:

G ( i ) = ∑ i = 1 n G ( i − 1 ) ⋅ G ( n − i ) G(i)=\sum_{i=1}^{n}G(i-1) \cdot G(n-i) G(i)=i=1nG(i1)G(ni)

class Solution:
    def numTrees(self, n: int) -> int:
        G = [0] * (n + 1)
        G[0], G[1] = 1, 1
        for i in range(2, n + 1):
            for j in range(1, i + 1):
                G[i] += G[j - 1] * G[i - j]
        return G[n]

九、单词拆分

此题为leetcode第139题
思路:定义状态dp[i]为前i个字符串s[0, 1, …, i-1]是否符合题意,我们需要枚举s[0, 1, …, j-1]中的分割点j,看s[0, 1, …, j-1]组成的字符串s1(默认j = 0时s1为空串)和s[j, …, i-1]组成的字符串 s2是否都合法。s1是否合法由dp[j]可以得知,只需判断s2是否在字典中。因此可以得到下面状态转移方程:

d p [ i ] = d p [ j ] & & c h a c k ( s [ j , . . . , i − 1 ] ) dp[i] = dp[j] \quad \&\& \quad chack(s[j, ..., i-1]) dp[i]=dp[j]&&chack(s[j,...,i1])

其中check表示s2是否在字典中。对于边界条件,我们定义 dp[0]=true 表示空串且合法。另外可以考虑循环的剪枝,在遍历j的时候,如果当前dp[j]及check都满足,那么剩下的j可以不再遍历。

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        n = len(s)
        word_dict = set(wordDict)
        dp = [False] * (n + 1)
        dp[0] = True
        for i in range(1, n + 1):
            for j in range(i):
                if dp[j] and s[j:i] in word_dict:
                    dp[i] = True
                    break
        return dp[-1]

十、正则表达式匹配

此题为leetcode第10题
思路:设状态dp[i][j]的含义是字符串s中前i个字符和p中前j个字符是否匹配。这里的“前x个字符”是从1开始计数的,因此实际取s或p中的字符时要减去1。状态转移方程如下:

  • 当p[j - 1] == ‘*’时:
    • 当p[j - 2]为’.’或s[i - 1] == p[j - 2]时:dp[i][j] = dp[i][j - 2] or dp[i - 1][j - 2] or dp[i - 1][j]。
    • 否则:dp[i][j] = dp[i][j - 2]
  • 否则,当p[j - 1] == ‘.’或者s[i - 1] == p[j - 1]时:dp[i][j] == dp[i - 1][j - 1]
    数据结构与算法——动态规划系列题目_二叉搜索树
class Solution:
    def isMatch(self, s: str, p: str) -> bool:
        m, n = len(s), len(p)
        dp = [[False for _ in range(n + 1)] for _ in range(m + 1)]
        dp[0][0] = True
        for i in range(1, n + 1):
            if p[i - 1] == '*':
                dp[0][i] = dp[0][i - 2]
        
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if p[j - 1] == '*':
                    if s[i - 1] == p[j - 2] or p[j - 2] == '.':
                        dp[i][j] = dp[i][j - 2] \   # p[j - 2]重复0次
                        or dp[i - 1][j - 2] \       # p[j - 2]重复1次,
                        or dp[i - 1][j]             # p[j - 2]重复大于等于2次,拿出一个和s[i - 1]抵消
                        							# p[i - 1]依然是*
                    else:
                        dp[i][j] = dp[i][j - 2]
                elif s[i - 1] == p[j - 1] or p[j - 1] == '.':
                    dp[i][j] = dp[i - 1][j - 1]
        return dp[-1][-1]

十一、通配符匹配

此题为leetcode第44题
思路:此题和正则表达式匹配比较像。设dp[i][j]的含义是s[:i]和t[:j]是否匹配。状态转移方程如下所示:
d p [ i ] [ j ] = { s [ i − 1 ] = = t [ j − 1 ] a n d d p [ i − 1 ] [ j − 1 ] , i f s [ i ] , t [ j ] 为 小 写 字 母 d p [ i − 1 ] [ j − 1 ] , i f t [ j − 1 ] = = ′ ? ′ i f t [ j − 1 ] = = ′ ∗ ′ : { d p [ i ] [ j − 1 ] , don’t use ′ ∗ ′ o r d p [ i − 1 ] [ j ] , use ′ ∗ ′ dp[i][j] = \begin{cases} s[i-1]==t[j-1] \quad and \quad dp[i-1][j-1], \quad if \quad s[i],t[j]为小写字母 \\ dp[i-1][j-1], \quad if \quad t[j-1] == '?' \\ if t[j-1]=='*': \begin{cases} dp[i][j-1], \text{don't use} \quad '*' \\ or \\ dp[i-1][j], \text{use} \quad '*' \end{cases} \end{cases} dp[i][j]=s[i1]==t[j1]anddp[i1][j1],ifs[i],t[j]dp[i1][j1],ift[j1]==?ift[j1]==:dp[i][j1],don’t useordp[i1][j],use
初始化时,s和t都为空的话可以匹配,dp[0][0]=true;当s为空时,t可以匹配连读多个*。

class Solution:
    def isMatch(self, s: str, t: str) -> bool:
        m, n = len(s), len(t)
        dp = [[False for _ in range(n + 1)] for _ in range(m + 1)]
        dp[0][0] = True
        for i in range(1, n + 1):
            if t[i - 1] == '*':
                dp[0][i] = True
            else:
                break
        
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if 'a' <= s[i - 1] <= 'z' and 'a' <= t[j - 1] <= 'z':
                    dp[i][j] = s[i - 1] == t[j - 1] and dp[i - 1][j - 1]
                if t[j - 1] == '?':
                    dp[i][j] = dp[i - 1][j - 1]
                if t[j - 1] == '*':
                    dp[i][j] = dp[i][j - 1] or dp[i - 1][j] # 对应"不用*"和"用*"
        return dp[-1][-1]

十二、最长公共子序列

此题为leetcode第1143题
思路:设状态dp[i][j]的含义是text1[0:i ]和text2[0:j]的最长公共子序列长度。状态转移方程如下:
d p [ i ] [ j ] = { d p [ i − 1 ] [ j − 1 ] + 1 , i f t e x t 1 [ i − 1 ] = = t e x t 2 [ j − 1 ] max ⁡ ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = \begin{cases} dp[i-1][j-1] + 1, \quad if \quad text1[i-1] == text2[j-1] \\ \max(dp[i-1][j], dp[i][j-1]) \end{cases} dp[i][j]={dp[i1][j1]+1,iftext1[i1]==text2[j1]max(dp[i1][j],dp[i][j1])

此题也可以对状态进行压缩。

# 未优化状态
class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        if not text1 or not text2:
            return 0
        
        m, n = len(text1), len(text2)
        
        dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
        return dp[-1][-1]
# 优化状态为O(N)
class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        if not text1 or not text2:
            return 0
        
        m, n = len(text1), len(text2)
        dp = [0 for _ in range(n + 1)]
        for i in range(1, m + 1):
            last = dp[0]    # 表示dp[i - 1][j - 1]
            for j in range(1, n + 1):
                temp = dp[j]    # 表示dp[i - 1][j]
                if text1[i - 1] == text2[j - 1]:
                    dp[j] = last + 1
                else:
                    dp[j] = max(temp, dp[j - 1])
                last = temp
        return dp[-1]

十三、零钱兑换

此题为leetcode第322题
思路:设状态dp[i]的含义是总金额为i时所需最少的硬币个数,状态转移方程为:

d p [ i ] = m i n ⁡ ( d p [ i ] , d p [ i − c o i n s [ j ] ] ) for j in range(len(coins)) dp[i]=min⁡(dp[i], dp[i-coins[j]]) \quad \text{for} \quad j \quad \text{in} \quad \text{range(len(coins))} dp[i]=min(dp[i],dp[icoins[j]])forjinrange(len(coins))

注意i应该大于coins[j]

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        dp = [amount + 1] * (amount + 1)
        dp[0] = 0
        for i in range(1, amount + 1):
            for c in coins:
                if i >= c:
                    dp[i] = min(dp[i], dp[i - c] + 1)
        if dp[-1] > amount:
            return -1
        else:
            return dp[-1]

十四、零钱兑换II

此题为leetcode第518题
思路:和上一个题求最少的硬币个数不同,这道题求组合个数。如果用上一题的方式做,结果会大于正确结果,因为上一题的代码计算的结果是排列数,而不是组合数,也就是代码会把1,2和2,1当做两种情况,需要重新定义子问题。正确的子问题定义应该是,problem(k, i) = problem(k-1, i) + problem(k, i-k),即前k个硬币凑齐金额i的组合数等于前k-1个硬币凑齐金额i的组合数加上在原来i-k的基础上使用硬币的组合数。problem(k-1, i)的含义是不使用第k个硬币凑齐金额i,problem(k, i-k)的含义是使用第k个硬币凑齐金额i。状态转移方程为:

d p [ k ] [ i ] = { d p [ k − 1 ] [ i ] + d p [ k ] [ i − k ] , i f i ≥ k d p [ k − 1 ] [ i ] , e l s e dp[k][i]= \begin{cases} dp[k-1][i]+dp[k][i-k], \quad if \quad i≥k \\ dp[k-1][i], \quad else \end{cases} dp[k][i]={dp[k1][i]+dp[k][ik],ifikdp[k1][i],else

状态可以进一步压缩,将k这个维度去掉

class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        dp = [0 for _ in range(amount + 1)]
        dp[0] = 1
        for c in coins:
            for i in range(1, amount + 1):
                if i >= c:
                    dp[i] += dp[i - c]
        return dp[-1]