代码随想录:动态规划

基础篇

斐波那契数

509. 斐波那契数

1
2
3
4
5
6
7
8
func main() {
	n := 10
	r := fib(n)    // 0 1 1 2 3 5 8 13 21 34 ...
	fmt.Println(r) // 55
}
func fib(n int) int {
    // ......
}
分析

动态规划五部曲:

  1. 确定 dp 数组元素 dp [i] 的含义:每个斐波拉且数列的值。
  2. 确定递推公式:dp [i]=dp [i-1]+dp [i-2]。
  3. dp 数组的初始化:dp [0]=1,dp [1]=1。
  4. 遍历顺序:从前往后。
  5. 打印 dp 数组。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func fib(n int) int {
    if n <= 0 {
       return 0
    }
    if n <= 2 {
       return 1
    }
    dp := make([]int, n+1)
    dp[0], dp[1] = 0, 1
    for i := 2; i <= n; i++ {
       dp[i] = dp[i-1] + dp[i-2]
       //fmt.Printf("%d ", dp[i])
    }
    return dp[n]
}

对上面的改进:

由于我们只需要知道 dp [i-1] 和 dp [i-2] 的值,就可以求出 dp [i] 的值,而且题目也不需要我们输出全部的斐波拉且数列,只需要第 n 位置的值,所有我们可以定义一个变量 sum 来存 dp [i-1] 和 dp [i-2] 的和,而不需要保存完整的数组。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func fib(n int) int {
    if n <= 0 {
       return 0
    }
    if n <= 2 {
       return 1
    }
    dp := make([]int, 2)
    sum := 0
    dp[0], dp[1] = 0, 1

    for i := 2; i <= n; i++ {
       sum = dp[0] + dp[1] // 保存dp[0]和dp[1]的和
       dp[0] = dp[1]       // 将dp[0]后移1位
       dp[1] = sum         // 将dp[1]设置为前两个位置的和
       //fmt.Printf("%d ", sum)
    }
    return sum
}

爬楼梯

70. 爬楼梯

1
2
3
4
5
6
7
8
func main() {
	n := 8
	r := climbStairs(n)
	fmt.Println(r) // 34
}
func climbStairs(n int) int {
    // ......
}
分析

第 i 位置的方法数量是 i-1 位置的数量 + i-2 位置的数量。因为到达 i 位置,只能从 i-1 位置迈一步或者 i-2 位置迈两步到达。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func climbStairs(n int) int {
    if n <= 2 {
       return n
    }
    methodCount := make([]int, 3)
    // 初始条件:n=1时,methodCount=1,n=2时,methodCount=2
    methodCount[1] = 1
    methodCount[2] = 2
    methodSum := 0
    for i := 3; i <= n; i++ {
       methodSum = methodCount[1] + methodCount[2] // 记录前两个位置的和
       methodCount[1] = methodCount[2]             // 后移1位置
       methodCount[2] = methodSum                  // 将2位置置为前两个位置的和
       //fmt.Printf("%d ", methodSum)
    }
    return methodSum
}

使用最小花费爬楼梯

746. 使用最小花费爬楼梯

1
2
3
4
5
6
7
8
func main() {
    cost := []int{1, 100, 1, 1, 1, 100, 1, 1, 100, 1}
    r := minCostClimbingStairs(cost)
    fmt.Println(r) // 6
}
func minCostClimbingStairs(cost []int) int {
    // ......
}
分析

动态规划五部曲:

  1. 确定 dp 数组元素 dp [i] 的含义:到达 i 位置所需要的最小花费(注意:最后需要跳到 len (cost) 的位置(下标从 0 开始))

  2. 确定递推公式:dp [i]=min (dp [i-1]+cost [i-1],dp [i-2]+cost [i-2]),cost [i-1] 表示从 i-1 位置往上跳所需要的花费

  3. dp 数组的初始化:dp [0]=0,dp [1]=0,因为我们可以选择从 0 位置或者从 1 位置开始跳,而站在初始位置是不需要任何花费的,所以初始值都是 0。

  4. 遍历顺序:从前往后。

  5. 打印 dp 数组。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func minCostClimbingStairs(cost []int) int {
    if len(cost) <= 1 {
       return 0
    }
    minCost := make([]int, len(cost)+1) // minCost数组的长度比cost数组多1个
    minCost[0] = 0
    minCost[1] = 0
    i := 2
    for ; i < len(cost); i++ {
       minCost[i] = min(minCost[i-1]+cost[i-1], minCost[i-2]+cost[i-2])
       //fmt.Println(i, minCost[i])
    }
    minCost[i] = min(minCost[i-1]+cost[i-1], minCost[i-2]+cost[i-2]) // 由于需要跳到的位置为cost数组长度的后一个位置,所以这里还需要计算跳到最后一个位置需要的最小步数
    return minCost[i]
}

不同路径

62. 不同路径

1
2
3
4
5
6
7
8
func main() {
    m, n := 3, 7
    r := uniquePaths(m, n)
    fmt.Println(r) // 28
}
func uniquePaths(m int, n int) int {
    // ......
}
分析

由于本题是一个二维的网格,所以我们需要定义一个二维的 dp 数组。

本题的 dp [i][j] 表示从 start 位置(0,0)出发,到(i,j)位置可以走的路径数。

由于机器人每次只能往右走 1 步,或者往下走 1 步,则 dp [i][j] 的递推公式为:dp [i-1][j]+dp [i][j-1]。

我们还需要对 dp 数组进行初始化,也就是当 i=0 和 j=0 时,dp 数组的值。

dp [0][j] 表示一直往右走,路径数为 1;dp [i][0] 表示一直往下走,路径数也为 1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func uniquePaths(m int, n int) int {
    dp := make([][]int, m)
    for i := 0; i < m; i++ {
       dp[i] = make([]int, n)
    }
    for i := 0; i < m; i++ {
       dp[i][0] = 1 // 路径数为1
    }
    for j := 0; j < n; j++ {
       dp[0][j] = 1 // 路径数也为1
    }
    for i := 1; i < m; i++ { // i,j都从1开始
       for j := 1; j < n; j++ {
          dp[i][j] = dp[i-1][j] + dp[i][j-1]
       }
    }
    return dp[m-1][n-1]
}

不同路径 II

63. 不同路径 II

1
2
3
4
5
6
7
8
func main() {
    obstacleGrid := [][]int{{0, 0, 0}, {0, 1, 0}, {0, 0, 0}}
    r := uniquePathsWithObstacles(obstacleGrid)
    fmt.Println(r) // 2
}
func uniquePathsWithObstacles(obstacleGrid [][]int) int {
    // ......
}
分析

和上题不同的是,本题的 obstacleGrid 数组中,可能存在障碍,是无法通行的。

所以在计算 dp 数组时,需要在递推公式上加上一个条件判断,如果 obstacleGrid [i]==0(非障碍物),才计算前两个位置的和,否则为默认值 0。

dp 数组的初始化也需要进行修改,在水平或者垂直方向上(i==0 或 j==0),当遇到障碍物之后,后面的位置应该设置为 0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func uniquePathsWithObstacles(obstacleGrid [][]int) int {
    m := len(obstacleGrid)                                      // 行数
    n := len(obstacleGrid[0])                                   // 列数
    if obstacleGrid[0][0] == 1 || obstacleGrid[m-1][n-1] == 1 { // 剪枝策略:当起始位置或终止位置为障碍物时,直接返回0
       return 0
    }
    dp := make([][]int, m)
    for i := 0; i < m; i++ {
       dp[i] = make([]int, n)
    }
    for i := 0; i < m && obstacleGrid[i][0] == 0; i++ { // 当遇到obstacleGrid值为1时,置1操作停止
       dp[i][0] = 1
    }
    for j := 0; j < n && obstacleGrid[0][j] == 0; j++ {
       dp[0][j] = 1
    }
    for i := 1; i < m; i++ {
       for j := 1; j < n; j++ {
          if obstacleGrid[i][j] == 0 { // 添加当前位置不是障碍物的条件
             dp[i][j] = dp[i-1][j] + dp[i][j-1]
          }
       }
    }
    return dp[m-1][n-1]
}

整数拆分

343. 整数拆分

1
2
3
4
5
6
7
8
func main() {
    n := 10 // 10=3+3+4,3*3*4=36
    r := integerBreak(n)
    fmt.Println(r) // 36
}
func integerBreak(n int) int {
    // ......
}
分析

我们需要先确定 dp 数组中 dp [i] 的含义:表示将 i 拆成 m 个数之后,这些数的最大乘积。

要得到 dp [i] 的值,可以从对 i 进行拆解,j 从 1 开始,到 i-1 结束。

我们有两种方式得到 dp [i] 的值。

  • j*(i-j)(拆分为两个数的乘积);
  • j*dp[i-j](dp [i-j] 表示拆分后 dp 数组的值,复用前面计算好的值),我们需要取二者中较大的值。

dp [i]=max (dp [i],max (j*(i-j),j*dp [i-j])),注意:我们还需要把 dp [i] 放进去作比较,因为可能越拆分得到的乘积越小,为了不让后面的覆盖前面的值,所以我们需要保留之前 dp [i] 的值参与比较。

对 dp 数组进行初始化:i=0 和 i=1 时,dp [i]=0;i=2 时,dp [i]=1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func integerBreak(n int) int {
    dp := make([]int, n+1) // 多了一个0元素
    dp[0], dp[1], dp[2] = 0, 0, 1
    for i := 3; i <= n; i++ { // i从3开始
       for j := 1; j <= i/2; j++ { // j最大拆分为二分之i(因为i拆分为两个相等的数,乘积最大),再往上就没有任何意义了
          dp[i] = max(dp[i], max(j*(i-j), j*dp[i-j]))
       }
    }
    return dp[n]
}

不同的二叉搜索树

96. 不同的二叉搜索树

1
2
3
4
5
6
7
8
func main() {
    n := 3
    r := numTrees(n)
    fmt.Println(r) // 5
}
func numTrees(n int) int {
    // ......
}
分析

本题稍微有点抽象,我们以举一个具体的例子来推导 dp 的递推公式。比如:n=3 时,我们如何通过前面的 dp 数组求出当前的 dp 数组值?

我们先明确 dp 数组中 dp [i] 的含义:表示有 i 个结点的 BST 有多少种构造方法。

当 BST 有 3 个结点时,分为 3 种情况:

  1. 左边 0 个结点,右边 2 个结点
  2. 左边 1 个结点,右边 1 个结点
  3. 2 个节点,右边 0 个结点

使用公式表示为:dp [3] = dp [0]*dp [2] + dp [1]*dp [1] + dp [2]*dp [0]。

注意:左边的数量和右边的数左边量应该相乘,得到总的数量,而不是相加。

观察 dp [3] 的公式,我们容易得到 dp [i] 的递推公式为:

j 从 0 到 i-1,dp [i]=dp [j]*dp [i-j-1]。

dp 数组的初始化:dp [0]=1, dp [1]=1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func numTrees(n int) int {
    dp := make([]int, n+1) // 包含0元素
    dp[0], dp[1] = 1, 1
    for i := 2; i <= n; i++ {
       tempSum := 0 // 当i变化时,将tempSum重置为0
       for j := 0; j <= i-1; j++ {
          tempSum += dp[j] * dp[i-j-1] // 累加总共的构造方法
       }
       dp[i] = tempSum
    }
    return dp[n]
}

背包问题

01 背包

(本题在 Leetcode 上没有原题)

有 n 件物品和一个最多能背重量为 w 的背包。第 i 件物品的重量是 weight [i],第 i 件物品的价值是 value [i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

01背包问题图

示例 1:

重量 价值
物品 0 1 15
物品 1 3 20
物品 2 4 30

输入:n = 3,w = 4,weight = [1, 3, 4],value = [15, 20, 30]

输出:35。背包最多能背的重量为 4,放入物品 0(重量为 1)和物品 1(重量为 3),最大价值为:15+20=35。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    n, w := 3, 4 // n表示物品数量,w表示背包最大承受重量
    weight := []int{1, 3, 4}
    value := []int{15, 20, 30}
    r := backpackMaxValue(n, w, weight, value)
    fmt.Println(r) // 35
}
func backpackMaxValue(n, w int, weight, value []int) int {
    // ......
}
分析

方式 1:使用二维 dp 数组

dp [i][j]:从下标为 [0-i] 的物品里任意取,放进容量为 j 的背包,价值总和最大是多少。(这一点很重要)

确定递推公式:dp [i][j] 可以由两个情况来得到:

1. 不放 i,即:dp [i-1][j]

2. 放 i,需要先将背包的重量减去物品 i 的重量,然后再加上物品 i 的价值,即:dp [i-1][j-weight [i]]+value [i]

但情况 2 需要注意的是,j-weight [i] 必须要大于 0,背包总容量小于 0 (也就是放入物品 i 之后,超出了背包的容量限制)就没有任何意义了。

我们需要取上面两种情况的较大值,即:dp [i][j]=max (dp [i-1][j],dp [i-1][j-weight [i]]+value [i])

dp 数组初始化:由于 dp [i][j] 需要由 dp [i-1][j](上方)和 dp [i-1][j-weight [i]](左上方)的元素求得,

所以我们需要对第 0 行和第 0 列的元素进行初始化。

dp [0][j],即:i 为 0,存放物品 0 的时候,各个容量的背包所能存放的最大价值。

需要分情况讨论:如果 j 小于物品 0 的重量,则最大价值为 0,否则最大价值为物品 0 的价值。

dp [i][0],即:j 为 0,背包容量为 0,则能放的价值也都是 0,dp [i][0] 始终为 0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func backpackMaxValue(n, w int, weight, value []int) int {
    dp := make([][]int, n) // 行表示背包的物品数量
    for i := 0; i < n; i++ {
       dp[i] = make([]int, w+1) // 列表示背包容量,这里加1是因为还有容量为0的情况
    }
    // dp数组的初始值都为0
    for j := 0; j <= w; j++ { // j表示背包容量
       if j >= weight[0] { // 背包容量可以容纳物品0
          dp[0][j] = value[0]
       }
    }
    for i := 1; i < n; i++ { // 先遍历物品
       for j := 1; j <= w; j++ { // 再遍历背包
          if j < weight[i] { // 放入物品i超出背包的容量了,就只能不放物品i了
             dp[i][j] = dp[i-1][j]
          } else {
             dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i])
          }
       }
    }
    return dp[n-1][w] // 物品编号从0开始
}

方式 2:使用一维 dp 数组

我们对前面的二维数组进行压缩,使用一维数组。

在一维 dp 数组中,dp [j] 表示:容量为 j 的背包,所背的物品价值可以最大为 dp [j]。

dp [j] 为 容量为 j 的背包所背的最大价值,那么如何推导 dp [!] 呢?

dp [j] 可以通过 dp [j-weight [i]] 推导出来,dp [j-weight [i]] 表示容量为 j-weight [i] 的背包所背的最大价值。

dp [j-weight [i]]+ value [i] 表示 容量为 j - 物品 i 重量 的背包加上物品 i 的价值。(也就是容量为 j 的背包,放入物品 i 了之后的价值即:dp [j])

此时 dp [j] 有两个选择,一个是取自己 dp [j] 相当于 二维 dp 数组中的 dp [i-1][j],即不放物品 i;一个是取 dp [j-weight [i]]+ value [i]],即放物品 i,指定是取最大的,毕竟是求最大价值,所以递归公式为: dp [j]= max (dp [j],dp [j-weight [i]]+ value [i]);

可以看出相对于二维 dp 数组的写法,就是把 dp [i][j] 中 i 的维度去掉了。

对于 0-1 背包问题,使用一维动态规划数组(dp [i] 表示容量为 i 的背包所能达到的最大价值)时,从后往前遍历背包容量(即逆序遍历)的原因是为了避免重复计算。 如果我们从前往后遍历背包容量,并尝试将每个物品放入背包中,就可能会遇到一个问题:当我们尝试将一个物品放入背包时,我们需要知道如果不放入这个物品,之前的最大价值是多少。但是,如果我们从前往后遍历,并且之前已经尝试将其他物品放入了相同容量的背包,那么 dp [i] 的值就已经被更新过了,这会导致我们得到错误的 “如果不放入这个物品” 的最大价值。

为了避免这种情况,我们从后往前遍历背包容量。这样,当我们考虑将一个物品放入背包时,我们使用的 dp [i-weight [j]] 的值还没有被更新(因为我们还没有遍历到 i-weight [j] 这个容量),所以它仍然表示 “如果不放入这个物品” 时的最大价值。这样我们就可以正确地更新 dp [i] 的值了。

dp 数组初始化:容量为 j 的背包,所背的物品价值可以最大为 dp [j],那么 dp [0] 就应该是 0,因为背包容量为 0 所背的物品的最大价值就是 0。

1
2
3
4
5
6
7
8
9
func backpackMaxValue(n, w int, weight, value []int) int {
    dp := make([]int, w+1)
    for i := 0; i < n; i++ { // 物品
       for j := w; j >= weight[i]; j-- { // 背包容量,j从后往前遍历,同时为了让dp[j-weight[i]]的下标不小于0,j应该大于等于weight[i]
          dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
       }
    }
    return dp[w]
}

分割等和子集

416. 分割等和子集

1
2
3
4
5
6
7
8
func main() {
    nums := []int{1, 5, 11, 5}
    r := canPartition(nums)
    fmt.Println(r) // true
}
func canPartition(nums []int) bool {
    // ......
}
分析

本题可以转换为:01 背包问题,每个元素只能使用一次。

target=sum(nums)/2。

判断容量为 target 的背包能不能被装满,如果能,说明存在子集和为 target,返回 true,否则返回 false。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func canPartition(nums []int) bool {
    sum, maxVal, target := 0, 0, 0
    for i := 0; i < len(nums); i++ {
       sum += nums[i]
       if nums[i] > maxVal {
          maxVal = nums[i]
       }
    }
    target = sum / 2
    // 剪枝策略:数组中的最大值大于了sum/2 或者 sum为奇数,也直接返回false
    if maxVal > target || sum%2 != 0 {
       return false
    }
    dp := make([]int, target+1)      // dp[i]表示容量为i的背包的最大价值
    for i := 0; i < len(nums); i++ { // 物品
       for j := target; j >= nums[i]; j-- { // 背包容量
          dp[j] = max(dp[j], dp[j-nums[i]]+nums[i])
       }
    }
    return dp[target] == target
}

最后一块石头的重量 II

1049. 最后一块石头的重量 II

1
2
3
4
5
6
7
8
func main() {
    stones := []int{2, 7, 4, 1, 8, 1}
    r := lastStoneWeightII(stones)
    fmt.Println(r) // 1
}
func lastStoneWeightII(stones []int) int {
    // ......
}
分析

本题也可以转换为 01 背包问题,和 分割等和子集 有异曲同工之妙。

dp [i] 表示容量为 i 的背包最大能装的价值。

我们可以把 stones 数组分成差值尽量小的两个数组,也就是背包的容量为 sum/2,我们尽量让这个背包装满(不用完全装满)

显然,剩下的重量是大于等于背包中的重量的,用剩下的重量减去背包中的重量就是我们的目标结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func lastStoneWeightII(stones []int) int {
    sum, target := 0, 0
    for i := 0; i < len(stones); i++ {
       sum += stones[i]
    }
    target = sum / 2
    dp := make([]int, target+1)
    for i := 0; i < len(stones); i++ { // 先遍历物品
       for j := target; j >= stones[i]; j-- { // 再遍历背包容量,从大到小遍历
          dp[j] = max(dp[j], dp[j-stones[i]]+stones[i])
       }
    }
    return sum - dp[target] - dp[target]
}

目标和

494. 目标和

1
2
3
4
5
6
7
8
9
func main() {
    nums := []int{1, 1, 1, 1, 1}
    target := 3
    r := findTargetSumWays(nums, target)
    fmt.Println(r) // 5
}
func findTargetSumWays(nums []int, target int) int {
    // ......
}

本题较难,建议多加练习和理解。

分析

我们可以将 nums 分成正负两个数组(一个数组 A 中的符号为正,另一个数组 B 的符号为负)。

则有两个等式:sumA+sumB=sum,sumA-sumB=target(假设数组 A 中的和始终更大)。

将两个式子化简,求得 sumA=(target+sum)/2。

我们现在需要求装满容量为 sumA 的背包有多少种方法?

(注意 sumA 不能由 target+sum 向下取整得到,如果 target+sum 为奇数,则无法让表达式的和为 target)

现在需要明确 dp 数组的含义:dp [j] 表示装满容量为 j 的背包有 dp [i] 种方法。

dp [j] 的的递推式:dp [j] 需要根据 dp [j-nums [i]] 得到,如:dp [5] 如何得到呢?

1. 如果遇到的元素值为 1,则有 dp [4] 种方法可以凑成 dp [5](1+4=5);

2. 如果遇到的元素值为 2,则有 dp [3] 种方法可以凑成 dp [5](2+3=5);

3. 如果遇到的元素值为 3,则有 dp [2] 种方法可以凑成 dp [5](3+2=5);

4. 如果遇到的元素值为 4,则有 dp [1] 种方法可以凑成 dp [5](4+1=5);

5. 如果遇到的元素值为 5,则有 dp [0] 种方法可以凑成 dp [5](0+5=5)。

所以我们需要遍历 nums 数组,把每种遍历到的 dp [j-nums [i]] 都加起来,得到 dp [j]。

dp 数组初始化:dp [0]=1(装满容量为 0 的背包有 1 种方法)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func findTargetSumWays(nums []int, target int) int {
    sumA, sum := 0, 0
    for i := 0; i < len(nums); i++ {
       sum += nums[i]
    }
    if (target+sum)%2 != 0 { // 如果target+sum为奇数,则无法让表达式的和为target
       return 0
    }
    if target+sum < 0 { // 如果target为负数,且target小于了-sum,也无法让表达式的和为target
       return 0
    }
    sumA = (target + sum) / 2
    // 现在需要计算装满容量为sumA的背包有多少种方法
    dp := make([]int, sumA+1) // dp[j]表示装满容量为i的背包有dp[j]种方法
    dp[0] = 1
    for i := 0; i < len(nums); i++ { // 先遍历物品
       for j := sumA; j >= nums[i]; j-- { // 再遍历背包
          dp[j] += dp[j-nums[i]]
       }
    }
    return dp[sumA]
}

一和零

474. 一和零

本题较难,建议多加练习和理解。

1
2
3
4
5
6
7
8
9
func main() {
    strs := []string{"10", "0001", "111001", "1", "0"}
    m, n := 5, 3
    r := findMaxForm(strs, m, n)
    fmt.Println(r) // 4。满足条件的子集为:["10","0001","1","0"]
}
func findMaxForm(strs []string, m int, n int) int {
    // ......
}
分析

本题的 strs 子集中最多有 m 个 0 和 n 个 1,要求子集的最大长度。

这可以转换为 01 背包问题,但和前面的题不同的地方在于,物品的重量变成了两个维度(0 的个数、1 的个数);背包的容量也相应地变成了两个维度。

由于物品的重量变成了两个维度,所以我们需要使用二维 dp 数组。

dp [i][j]:表示容量为 i 个 0 和 j 个 1 的背包最大能容纳的的元素个数。

递推表达式:dp [i][j]=max (dp [i][j], dp [i-x][j-y]+1)(x 表示加入元素中 0 的个数,y 表示加入元素中 1 的个数)。

初始化:dp [0][0]=0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func findMaxForm(strs []string, m int, n int) int {
    dp := make([][]int, m+1)
    for i := 0; i <= m; i++ {
       dp[i] = make([]int, n+1)
    }
    for _, str := range strs { // 先遍历物品
       x, y := 0, 0                    // 遍历每个物品时,x和y重置
       for i := 0; i < len(str); i++ { // 计算添加的物品中0的个数和1的个数
          if str[i] == '0' {
             x++
          } else {
             y++
          }
       }
       for i := m; i >= x; i-- { // 后遍历背包容量(有两个维度)
          for j := n; j >= y; j-- {
             dp[i][j] = max(dp[i][j], dp[i-x][j-y]+1)
          }
       }
    }
    return dp[m][n]
}

完全背包问题

完全背包和 01 背包的不同点在于,在完全背包问题中,每个物品可以使用无限次。

在具体代码实现时,遍历背包需要从前往后遍历,而且遍历背包和遍历物品的 for 循环可以交换顺序。

零钱兑换 II

518. 零钱兑换 II

1
2
3
4
5
6
7
8
9
func main() {
    coins := []int{1, 2, 5}
    amount := 5
    r := change(amount, coins)
    fmt.Println(r) // 4。[[1,1,1,1,1], [1,1,1,2], [1,2,2], [5]]
}
func change(amount int, coins []int) int {
	// ......
}
解析

这就是一个完全背包问题,每个物品可以使用无限次。

本题和 目标和 这道题基本上是一样的,不过本题的物品可以使用无限次。

dp [j] 表示装满容量为 i 的背包,有 dp [j] 种方法。

递推表达式:遍历物品 coins [i],dp [j]+=dp [j-coins [i]]

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func change(amount int, coins []int) int {
    dp := make([]int, amount+1)
    dp[0] = 1
    for i := 0; i < len(coins); i++ { // 先遍历物品
       for j := coins[i]; j <= amount; j++ { // 再遍历背包容量(每个物品可以使用无限次,所以这里从小到大遍历)
          dp[j] += dp[j-coins[i]]
       }
    }
    return dp[amount]
}

组合总和 Ⅳ

377. 组合总和 Ⅳ

1
2
3
4
5
6
7
8
9
func main() {
    nums := []int{1, 2, 3}
    target := 4
    r := combinationSum4(nums, target)
    fmt.Println(r) // 7
}
func combinationSum4(nums []int, target int) int {
    // ......
}
解析

本题和 零钱兑换 II 的区别是:之前那道题是不强调元素顺序的(组合),本题是强调元素顺序的(排列)

1. 完全背包问题中求组合

在完全背包问题中求组合时,我们通常关注的是如何达到某个背包总容量,而不关心物品被放入背包的顺序。换句话说,只要背包内的物品总价值或总重量相同,物品的顺序是可以任意交换的。

为了解决这个问题,我们通常采用先遍历物品(外层循环),后遍历背包容量(内层循环)的方式。这样做的原因是,外层循环遍历每一种物品,内层循环则尝试将当前物品放入背包中,直到达到或超过背包的容量。由于我们关心的是组合,所以不需要考虑物品的顺序,因此先遍历物品是合理的。

2. 完全背包问题中求排列

然而,在完全背包问题中求排列时,我们关注的是物品放入背包的顺序。换句话说,不同的物品顺序被视为不同的解决方案。

为了解决这个问题,我们需要先遍历背包容量(外层循环),后遍历物品(内层循环)。这样做的原因是,外层循环控制背包的当前容量,而内层循环则尝试在当前容量下放入不同的物品。由于我们关心的是排列,所以需要先确定背包的容量(即先确定一个 “位置”),然后再选择放入哪个物品(即确定该位置上的 “元素”)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func combinationSum4(nums []int, target int) int {
    dp := make([]int, target+1)
    dp[0] = 1
    // 对于排列问题,我们只需要交换两个循环的遍历顺序,先遍历背包容量,在遍历物品
    for j := 0; j <= target; j++ { // 先遍历背包容量(每个物品可以使用无限次,所以这里从小到大遍历)
       for i := 0; i < len(nums); i++ { // 再遍历物品
          if j-nums[i] >= 0 {
             dp[j] += dp[j-nums[i]]
          }
       }
    }
    return dp[target]
}

零钱兑换

322. 零钱兑换

1
2
3
4
5
6
7
8
9
func main() {
    coins := []int{1, 2, 5}
    amount := 11
    r := coinChange(coins, amount)
    fmt.Println(r) // 3
}
func coinChange(coins []int, amount int) int {
    // ......
}
解析

dp [j] 表示装满容量为 j 的背包,最少需要的物品数量。

递推表达式:dp [j]=min (dp [j],dp [j-coins [i]]+1)。

dp 数组初始化:dp [0]=0,由于递推式中求的是每次的最小值,为了不让初始值覆盖掉我们真正求的值,使用其他位置应该初始化为 math.MaxInt。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func main() {
    coins := []int{1, 2, 5}
    amount := 11
    r := coinChange(coins, amount)
    fmt.Println(r) // 3
}
func coinChange(coins []int, amount int) int {
    dp := make([]int, amount+1)
    //minVal := 0
    for i := 0; i <= amount; i++ {
       if i == 0 {
          dp[i] = 0
       } else {
          dp[i] = math.MaxInt
       }
    }
    // 由于本题的dp数组值与物品的排列组合没有关系,所以先遍历物品或先遍历背包容量都可以
    for i := 0; i < len(coins); i++ { // 先遍历物品
       for j := coins[i]; j <= amount; j++ { // 完全背包问题,从小到大遍历
          if dp[j-coins[i]] != math.MaxInt { // dp[j-coins[i]]是初始时的最大值,则不需要更新dp[j]
             dp[j] = min(dp[j], dp[j-coins[i]]+1)
          }
       }
    }
    if dp[amount] == math.MaxInt {
       return -1
    }
    return dp[amount]
}

完全平方数

279. 完全平方数

1
2
3
4
5
6
7
8
func main() {
    n := 12
    r := numSquares(n)
    fmt.Println(r) // 3,12 = 4 + 4 + 4
}
func numSquares(n int) int {
    // ......
}
解析

本题和 零钱兑换 是差不多的,都是问装满容量为 n 的背包需要的最少元素个数。

dp [j] 表示装满容量为 j 的背包最小需要 dp [j] 个元素。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func numSquares(n int) int {
    nums := make([]int, 0) // 存放候选的平方数
    for i := 1; i*i <= n; i++ {
       nums = append(nums, i*i)
    }
    if nums[len(nums)-1] == n { // n本身就是完全平方数
       return 1
    }
    dp := make([]int, n+1)
    dp[0] = 0
    for i := 1; i <= n; i++ {
       dp[i] = math.MaxInt
    }
    for i := 0; i < len(nums); i++ {
       for j := nums[i]; j <= n; j++ {
          dp[j] = min(dp[j], dp[j-nums[i]]+1)
       }
    }
    return dp[n]
}

对上面代码的优化,不使用额外的候选数组。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func numSquares(n int) int {
	dp := make([]int, n+1)
	dp[0] = 0
	for i := 1; i <= n; i++ {
		dp[i] = math.MaxInt
	}
    // 遍历背包容量 和 遍历物品 的顺序可以交换
	for i := 1; i <= n; i++ { // 先遍历背包容量
		for j := 1; j*j <= i; j++ { // 再遍历物品
			dp[i] = min(dp[i], dp[i-j*j]+1)
		}
	}
	return dp[n]
}

先遍历物品,再遍历背包容量(这样整体的运行时间会少一点)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func numSquares(n int) int {
	dp := make([]int, n+1)
	dp[0] = 0
	for i := 1; i <= n; i++ {
		dp[i] = math.MaxInt
	}
	for i := 1; i*i <= n; i++ { // 先遍历物品
		for j := i * i; j <= n; j++ { // 再遍历背包容量
			dp[j] = min(dp[j], dp[j-i*i]+1)
		}
	}
	return dp[n]
}

其他

单词拆分

139. 单词拆分

本题转换为完全背包问题有点抽象,建议多加理解。

1
2
3
4
5
6
7
8
9
func main() {
    s := "leetcode"
    wordDict := []string{"leet", "code"}
    r := wordBreak(s, wordDict)
    fmt.Println(r) // true
}
func wordBreak(s string, wordDict []string) bool {
    // ......
}
解析

本题判断 s 能否由 wordDict 组成,我们可以转换为完全背包问题。

dp [j] 表示容量(字符串长度)为 j 的背包能否被 wordDict 组成,如果能值为 true,否则值为 false。

我们使用 dict(map 类型)来保存 wordDict 中出现的字符串

递推表达式:如果 dp [i] && dict [s [i:j]],dp [j] = true

(也就是 i 位置之前可以被 wordDict 中的字符串表示,同时 i 位置到 j 位置的字符串也可以被 wordDict 中的字符串表示)

遍历顺序:由于字典中的字符串可以使用多次,所以是完全背包问题,我们要先遍历背包容量,再遍历物品。

初始化 dp 数组:dp [0]=true,其余位置的初始值为 false(防止初始值覆盖真正的计算值)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func wordBreak(s string, wordDict []string) bool {
    n := len(s)
    dp := make([]bool, n+1)
    dp[0] = true
    dict := make(map[string]bool, len(wordDict))
    for i := 0; i < len(wordDict); i++ {
       dict[wordDict[i]] = true
    }
    for j := 1; j <= n; j++ { // 先遍历背包容量
       for i := 0; i < j; i++ { // 再遍历物品
          if dp[i] && dict[s[i:j]] { // 判断某个位置能否对s进行切割,使切割后的子字符串在dict中,如果找到了,直接返回true,并结束本物品的循环
             dp[j] = true
             break
          }
       }
    }
    return dp[n]
}

打家劫舍

198. 打家劫舍

1
2
3
4
5
6
7
8
func main() {
    nums := []int{2, 1, 1, 2}
    r := rob(nums)
    fmt.Println(r) // 4
}
func rob(nums []int) int {
    // ......
}
解析

本题的要求是不能偷两个相邻房间的金币,其他的可以随便偷。

dp [i]:表示考虑 i 位置及 i 之前位置,所能偷的最大金额为 dp [i]。

递推公式,dp [i] 可以通过两个方式计算得到:

1. 偷 i 位置,那么 i-1 位置就一定不能偷(考虑范围为 i-2 及其前面位置),则能偷的金额为:dp [i-2]+nums [i];

2. 不偷 i 位置,那么我们的考虑范围为 i-1 及其前面位置,能偷的金额为:dp [i-1]。

我们要计算前面两个方式的较大值,即:dp [i]=max (dp [i-1], dp [i-2]+nums [i])

初始化 dp 数组:dp [0] 表示考虑 0 位置所能偷的最大金额,则:dp [0]=nums [0];

dp [1] 表示考虑 1 及其前面的位置,则 dp [1]=max (nums [0], nums [1])。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func rob(nums []int) int {
    if len(nums) == 1 {
       return nums[0]
    }
    n := len(nums)
    dp := make([]int, n)
    dp[0], dp[1] = nums[0], max(nums[0], nums[1])
    for i := 2; i < n; i++ { // 从位置2开始
       dp[i] = max(dp[i-1], dp[i-2]+nums[i])
    }
    return dp[n-1]
}

打家劫舍 II

213. 打家劫舍 II

1
2
3
4
5
6
7
8
func main() {
    nums := []int{2, 3, 2}
    r := rob2(nums)
    fmt.Println(r) // 3
}
func rob2(nums []int) int {
    // ......
}
解析

本题和 打家劫舍 不同的地方在于,本题的首尾数组元素设定为相邻的了,也就是不能不同选首尾的元素。

我们可以把本题转换为下面的三种情况:

1. 不考虑首尾元素,则中间元素的选择和 打家劫舍 中的策略是完全一样的。

2. 不考虑尾元素,则前面元素的选择和 打家劫舍 中的策略也是完全一样的。

3. 不考虑首元素,则后面元素的选择和 打家劫舍 中的策略也是完全一样的。

但其实情况 2 已经包含情况 1 了,因为如果选择的元素在情况 1 中,那么情况 2 也一定包含了这些选择的元素,所以我们可以简化为后面两种情况。

取后面两种情况的较大值就可以了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
    nums := []int{2, 3, 2}
    r := rob2(nums)
    fmt.Println(r) // 3
}
func rob2(nums []int) int {
    if len(nums) == 1 {
       return nums[0]
    }
    rob1 := func(nums []int) int {
       if len(nums) == 1 {
          return nums[0]
       }
       n := len(nums)
       dp := make([]int, n)
       dp[0], dp[1] = nums[0], max(nums[0], nums[1])
       for i := 2; i < n; i++ { // 从位置2开始
          dp[i] = max(dp[i-1], dp[i-2]+nums[i])
       }
       return dp[n-1]
    }
    return max(rob1(nums[1:]), rob1(nums[:len(nums)-1]))
}

打家劫舍 III

337. 打家劫舍 III

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type TreeNode struct {
    Val   int
    Left  *TreeNode
    Right *TreeNode
}
func main() {
    node1 := &TreeNode{3, nil, nil}
    node2 := &TreeNode{4, nil, nil}
    node3 := &TreeNode{5, nil, nil}
    node4 := &TreeNode{1, nil, nil}
    node5 := &TreeNode{3, nil, nil}
    node6 := &TreeNode{1, nil, nil}
    node1.Left = node2
    node1.Right = node3
    node2.Left = node4
    node2.Right = node5
    node3.Right = node6
    // 树的结构为:
    //    3
    //   4  5
    // 1  3   1
    // 选择的结点为:4和5
    r := rob3(node1)
    fmt.Println(r) // 9
}
func rob3(root *TreeNode) int {
    // ......
}
解析

本题中不能选择两个相邻的二叉树结点,也就是如果选择了结点 A,则 A 的左右孩子都不能被选择

但我们也不能直接简单粗暴地取 层序遍历 取最大值,因为可能存在选择的结点是层级错位的情况。

所以我们还是需要使用 动态规划 的思想。

本题的难点在于如何将 动态规划树的遍历 结合起来,这道题就是典型的 树形 DP 的案例。

首先,在遍历二叉树时,我们应该使用后序遍历,因为父结点是否选择,去决定它左右孩子结点中存的值。

每个孩子结点中应该存什么值呢?应该存选择该结点和不选择该结点分别可以获得的最大金额。

也就是递归函数的返回值应该是一个二值数组 NodeMaxVal,表示该二叉树所能获得的最大金额。

在主函数中调用时,应该返回根结点为 root 的二叉树的 NodeMaxVal 数组中,较大的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func rob3(root *TreeNode) int {
    var traversal func(root *TreeNode) [2]int // 返回值的第0个元素表示不选择该结点能获得的最大金额,第1个元素表示选择该结点能获得的最大金额
    traversal = func(root *TreeNode) [2]int {
       if root == nil { // 如果root为空,则选不选择root的最大金额都是0
          return [2]int{0, 0}
       }
       leftMaxVal := traversal(root.Left)
       rightMaxVal := traversal(root.Right)
       // 采用后序遍历
       val1 := root.Val + leftMaxVal[0] + rightMaxVal[0]                               // 选择root结点,则左右孩子结点就不能选择了
       val2 := max(leftMaxVal[0], leftMaxVal[1]) + max(rightMaxVal[0], rightMaxVal[1]) // 不选择root结点,则要选择root的左右孩子,但还需要去左、右孩子各自的最大金额,然后相加
       return [2]int{val2, val1}                                                       // 不选择在前面
    }
    return max(traversal(root)[0], traversal(root)[1]) // 返回 不选择 和 选择 root的情况中更大的值
}

买卖股票

买卖股票的最佳时机

121. 买卖股票的最佳时机

1
2
3
4
5
6
7
8
func main() {
    prices := []int{7, 1, 5, 3, 6, 4}
    r := maxProfit(prices)
    fmt.Println(r) // 5。在第2天(股票价格=1)的时候买入,在第5天(股票价格=6)的时候卖出,最大利润=6-1=5 。
}
func maxProfit(prices []int) int {
    // ......
}
解析

方法 1:贪心算法

贪心算法的思想:遍历 prices 数组,找到数组中的最小值,同时,更新最大利润。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func maxProfit(prices []int) int {
    minPrice := prices[0]
    maxProfitVal := 0
    for i := 1; i < len(prices); i++ {
       if prices[i] < minPrice { // 更新最小值
          minPrice = prices[i]
       }
       if prices[i]-minPrice > maxProfitVal { // 更新最大利润
          maxProfitVal = prices[i] - minPrice
       }
    }
    return maxProfitVal
}

方法 2:动态规划

在每一天,股票都有两种状态:持有不持有

使用 dp [i][0] 表示第 i 天持有股票能获得的最大利润为 dp [i][0],dp [i][1] 表示第 i 天不持有股票能获得的最大利润为 dp [i][1]。

递推表达式:dp [i] 可以由前一天的 dp [i-1] 计算得到:

  1. dp [i][0] 表示第 i 天持有股票能获得的最大利润,那么可以有两种情况:

    1. 第 i-1 天持有股票:dp [i-1][0];

    2. 第 i-1 天不持有股票,那么第 i 天就要买入股票,由于本题只能买入一次股票,则第 i 天就要买入股票就一定是第 1 次买入股票,当前的最大利润为 -prices [i]。

    dp [i][0] 应该取上面两个情况的最大值,也就是:dp [i][0]=max (dp [i-1][0],-prices [i])。

  2. dp [i][1] 表示第 i 天不持有股票能获得的最大利润,也可以有两种情况:

    1. 第 i-1 天就不持有股票:dp [i-1][1];
    2. 第 i-1 天持有股票,在第 i 天卖出了股票:dp [i-1][0]+prices [i];

    同样的,dp [i][1] 也应该取上面两个情况的最大值,也就是:dp [i][1]=max (dp [i-1][1],dp [i-1][0]+prices [i])。

初始化 dp 数组:dp [0][0]=-prices [0],dp [0][1]=0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func maxProfit(prices []int) int {
    n := len(prices)
    dp := make([][2]int, n)
    dp[0][0], dp[0][1] = -prices[0], 0
    for i := 1; i < n; i++ {
       dp[i][0] = max(dp[i-1][0], -prices[i])
       dp[i][1] = max(dp[i-1][1], dp[i-1][0]+prices[i])
    }
    return max(dp[n-1][0], dp[n-1][1]) // 取第n-1天持有和不持有的较大值
}

买卖股票的最佳时机 II

122. 买卖股票的最佳时机 II

1
2
3
4
5
6
7
8
func main() {
    nums := []int{7, 1, 5, 3, 6, 4}
    r := maxProfit2(nums)
    fmt.Println(r) // 7
}
func maxProfit2(prices []int) int {
    // ......
}
解析

方法 1:贪心算法

贪心策略:只计算利润为正的情况。

1
2
3
4
5
6
7
8
9
func maxProfit2(prices []int) int {
	sum := 0
	for i := 1; i < len(prices); i++ {
		if prices[i] > prices[i-1] {
			sum += prices[i] - prices[i-1]
		}
	}
	return sum
}

方法 2:动态规划

本题和 买卖股票的最佳时机 不同的地方在于:股票可以进行多次买卖操作。

和前面一样,在每一天,股票都有两种状态:持有 和 不持有。

使用 dp [i][0] 表示第 i 天持有股票能获得的最大利润为 dp [i][0],dp [i][1] 表示第 i 天不持有股票能获得的最大利润为 dp [i][1]。

  1. dp [i][0] 表示第 i 天持有股票能获得的最大利润,那么可以有两种情况:

    1. 第 i-1 天持有股票:dp [i-1][0];
    2. 第 i-1 天不持有股票,那么第 i 天就要买入股票,和前面不同,股票可以进行多次买卖,则当前的最大利润为 dp [i-1][1]-prices [i]。

    dp [i][0] 应该取上面两个情况的最大值,也就是:dp [i][0]=max (dp [i-1][0],-prices [i])。

  2. dp [i][1] 的计算方式和 买卖股票的最佳时机 相同,都是:dp [i][1]=max (dp [i-1][1],dp [i-1][0]+prices [i])。

初始化 dp 数组:dp [0][0]=-prices [0],dp [0][1]=0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func maxProfit2(prices []int) int {
    n := len(prices)
    dp := make([][2]int, n)
    dp[0][0], dp[0][1] = -prices[0], 0
    for i := 1; i < n; i++ {
       dp[i][0] = max(dp[i-1][0], dp[i-1][1]-prices[i])
       dp[i][1] = max(dp[i-1][1], dp[i-1][0]+prices[i])
    }
    return max(dp[n-1][0], dp[n-1][1]) // 取第n-1天持有和不持有的较大值
}

买卖股票的最佳时机 III

123. 买卖股票的最佳时机 III

【困难】本题较难,建议多加理解和练习。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    prices := []int{3, 3, 5, 0, 0, 3, 1, 4}
    r := maxProfit3(prices)
    fmt.Println(r) // 6。
    // dp数组:[[-3 0 -3 0] [-3 0 -3 0] [-3 2 -3 2] [0 2 2 2] [0 2 2 2] [0 3 2 5] [0 3 2 5] [0 4 2 6]]

}
func maxProfit3(prices []int) int {
	// ......
}
解析

本题和 买卖股票的最佳时机 II 不同的地方在于:最多可以完成两笔交易。

现在股票就有 4 种状态了:

  1. 第 1 次持有;
  2. 第 1 次不持有;
  3. 第 2 次持有;
  4. 第 2 次不持有。

dp [i][0]、dp [i][1]、dp [i][2]、dp [i][3] 分别表示上面的 4 种状态。

递推表达式:dp [i][0] 表示第 i 天第 1 次持有股票所能获得的最大利润,它可以由两方面得到:

  1. 第 i-1 天就已经是第 1 次持有股票的状态了,即:dp [i-1][0];

  2. 第 i-1 天未持有股票,第 i 天第 1 次买入了,即:-prices [i]。

​ 则:dp [i][0]=max (dp [i-1][0],-prices [i])。

同理,可以得到其他 3 个状态的递推表达式为:

​ dp[i][1]=max(dp[i-1][1],dp[i-1][0]+prices[i]);

​ dp[i][2]=max(dp[i-1][2],dp[i-1][1]-prices[i]);

​ dp[i][3]=max(dp[i-1][3],dp[i-1][2]+prices[i])。

初始化 dp 数组:dp [0][0]=-prices [0],dp [0][1]=-prices [0],0,-prices [0],0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func maxProfit3(prices []int) int {
    n := len(prices)
    dp := make([][4]int, n)
    dp[0][0], dp[0][1], dp[0][2], dp[0][3] = -prices[0], 0, -prices[0], 0
    for i := 1; i < n; i++ {
       dp[i][0] = max(dp[i-1][0], -prices[i])           //前一天就第1次持有/前一天未持有
       dp[i][1] = max(dp[i-1][1], dp[i-1][0]+prices[i]) //前一天就第1次未持有/前一天第1次持有
       dp[i][2] = max(dp[i-1][2], dp[i-1][1]-prices[i]) //前一天就第2次持有/前一天第2次未持有
       dp[i][3] = max(dp[i-1][3], dp[i-1][2]+prices[i]) //前一天就第2次未持有/前一天第2次持有
    }
    return dp[n-1][3] // 返回第n-1天第2次不持有时的最大利润
}

买卖股票的最佳时机 IV

188. 买卖股票的最佳时机 IV

1
2
3
4
5
6
7
8
9
func main() {
    prices := []int{3, 2, 6, 5, 0, 3}
    k := 2
    r := maxProfit4(k, prices)
    fmt.Println(r) // 7
}
func maxProfit4(k int, prices []int) int {
    // ......
}
解析

本题和 买卖股票的最佳时机 III 的区别在于:最多可以完成 k 笔交易。

所以我们就不能枚举出所有买卖的情况了,需要使用功能 for 循环来遍历第二个变量(也就是股票的交易状态)。

买卖股票的最佳时机 III 中的四种状态

现在股票就有 4 种状态了:

  1. 第 1 次持有;
  2. 第 1 次不持有;
  3. 第 2 次持有;
  4. 第 2 次不持有。

dp [i][0]、dp [i][1]、dp [i][2]、dp [i][3] 分别表示上面的 4 种状态。

递推表达式:dp [i][0] 表示第 i 天第 1 次持有股票所能获得的最大利润,它可以由两方面得到:

  1. 第 i-1 天就已经是第 1 次持有股票的状态了,即:dp [i-1][0];

  2. 第 i-1 天未持有股票,第 i 天第 1 次买入了,即:-prices [i]。

​ 则:dp [i][0]=max (dp [i-1][0],-prices [i])。

同理,可以得到其他 3 个状态的递推表达式为:

​ dp[i][1]=max(dp[i-1][1],dp[i-1][0]+prices[i]);

​ dp[i][2]=max(dp[i-1][2],dp[i-1][1]-prices[i]);

​ dp[i][3]=max(dp[i-1][3],dp[i-1][2]+prices[i])。

初始化 dp 数组:dp [0][0]=-prices [0],dp [0][1]=-prices [0],0,-prices [0],0。

我们可以根据前面的例子,得到下面的结论:

最多进行 k 次交易,可以有 2*k 种状态,且 dp [i][j] 取决于 dp [i-1][j]。

所以我们可以把交易过程写在 for 循环中,循环中记录第 j 次买和卖的交易。

dp 数组初始化:如果 j 的下标为偶数(持有),dp [0][j] 值为 -prices [0],否则为 0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func maxProfit4(k int, prices []int) int {
    n := len(prices)
    dp := make([][]int, n)
    for i := 0; i < n; i++ {
       dp[i] = make([]int, 2*k)
    }
    for j := 0; j < 2*k; j++ { // 初始化dp数组
       if j%2 == 0 {
          dp[0][j] = -prices[0]
       }
    }
    for i := 1; i < n; i++ { // i从1开始
       for j := 0; j < 2*k; j += 2 {
          if j == 0 {
             dp[i][j] = max(dp[i-1][j], -prices[i])
          } else {
             dp[i][j] = max(dp[i-1][j], dp[i-1][j-1]-prices[i])
          }
          dp[i][j+1] = max(dp[i-1][j+1], dp[i-1][j]+prices[i])
       }
    }
    return dp[n-1][2*k-1]
}

买卖股票的最佳时机含冷冻期

309. 买卖股票的最佳时机含冷冻期

1
2
3
4
5
6
7
8
func main() {
    prices := []int{1, 2, 3, 0, 2}
    r := maxProfit5(prices)
    fmt.Println(r) // 3。对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]
}
func maxProfit5(prices []int) int {
	// ......
}
分析

本题和 买卖股票的最佳时机 II 不同的地方在于:添加了交易冷冻期,在冷冻期期间,不能进行交易。

冷冻期表示:卖出股票的后一天,不能进行交易。

所以本题的股票状态分为:持有股票、保持卖出股票、当天卖出股票、冷冻期。

上面的 4 个状态分别对应:dp [i][0]、dp [i][1]、dp [i][2]、dp [i][3]。

递推表达式:dp [i][0]=max (dp [i-1][0],dp [i-1][1]-prices [i],dp [i][3]-prices [i]);

dp[i][1]=max(dp[i-1][1],dp[i-1][3]);

dp[i][2]=dp[i-1][0]+prices[i];

dp[i][3]=dp[i-1][2]。

初始化 dp 数组:dp [0][0],dp [0][1],dp [0][2],dp [0][3]=-prices [0],0,0,0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func maxProfit5(prices []int) int {
    n := len(prices)
    dp := make([][4]int, n)
    dp[0][0], dp[0][1], dp[0][2], dp[0][3] = -prices[0], 0, 0, 0
    for i := 1; i < n; i++ {
       dp[i][0] = max(dp[i-1][0], dp[i-1][1]-prices[i], dp[i-1][3]-prices[i])
       dp[i][1] = max(dp[i-1][1], dp[i-1][3])
       dp[i][2] = dp[i-1][0] + prices[i]
       dp[i][3] = dp[i-1][2]
    }
    return max(dp[n-1][1], dp[n-1][2], dp[n-1][3])
}

买卖股票的最佳时机含手续费

714. 买卖股票的最佳时机含手续费

1
2
3
4
5
6
7
8
9
func main() {
    prices := []int{1, 3, 2, 8, 4, 9}
    fee := 2
    r := maxProfit6(prices, fee)
    fmt.Println(r) // 8。((8-1)-2)+((9-4)-2)=8
}
func maxProfit6(prices []int, fee int) int {
    // ......
}
分析

本题和 买卖股票的最佳时机 II 不同的地方在于:本题在完成一次股票交易后,需要支付交易费用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func maxProfit6(prices []int, fee int) int {
    n := len(prices)
    dp := make([][2]int, n)
    dp[0][0], dp[0][1] = -prices[0], 0
    for i := 1; i < n; i++ {
       dp[i][0] = max(dp[i-1][0], dp[i-1][1]-prices[i])
       dp[i][1] = max(dp[i-1][1], dp[i-1][0]+prices[i]-fee)
    }
    return max(dp[n-1][0], dp[n-1][1])
}

子序列问题

最长递增子序列

300. 最长递增子序列

1
2
3
4
5
6
7
8
func main() {
    nums := []int{10, 9, 2, 5, 3, 7, 101, 18}
    r := lengthOfLIS(nums)
    fmt.Println(r) // 4
}
func lengthOfLIS(nums []int) int {
    // ......
}
分析

我们需要计算最长子序列的长度。dp [i] 表示以 nums [i] 为结尾的数组的最长子序列的长度。

递推表达式:使用变量 j 遍历从 0 到 i 的每个位置,如果 nums [j]<nums [i],则统计次数加 1,也就是:dp [i]=max (dp [i],dp [j]+1)。

初始化 dp 数组:dp [0]=1。

最后需要返回的不是 dp [n-1],因为要求的最长子序列不一定是以 nums [n-1] 结尾的,我们需要遍历 dp 数组,找到最大值返回。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func lengthOfLIS(nums []int) int {
    n := len(nums)
    maxLen := 1
    dp := make([]int, n)
    for i := range dp { // 给dp数组赋初值1(这点很重要)
       dp[i] = 1
    }
    for i := 1; i < n; i++ {
       for j := 0; j < i; j++ { // 遍历0到i之间的所有元素,如果小于nums[i],则统计次数加1
          if nums[j] < nums[i] {
             dp[i] = max(dp[i], dp[j]+1)
          }
       }
       if dp[i] > maxLen { // 更新最大子数组长度的值
          maxLen = dp[i]
       }
    }
    return maxLen
}
2024.05.24 补充

上面的时间复杂度为 O(n2)O(n^2),有一种时间复杂度为 O(n)O(n) 的写法,思路如下:

维护一个单调递增的数组(dp),在遍历元素 num 时,使用二分查找,确定 num 应该插入到 dp 数组中的位置 index:

  • 如果 index 大于 len (dp),说明遇到的元素大于 dp 数组的最后一个元素,则直接将 num 加入到 dp 数组中;
  • 否则,将 index 位置的值更新为 num(始终维持一个 dp 数组的单调性)。

最后,dp 数组的长度就是要求的最长递增子序列的长度。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func lengthOfLIS(nums []int) int {
	dp := make([]int, 0) // 维护一个单调递增的数组
	for _, num := range nums {
		index := sort.SearchInts(dp, num) // 当插入的元素大于dp数组的最后一个值时,返回len(dp)
		if index < len(dp) {
			dp[index] = num
		} else {
			dp = append(dp, num)
		}
	}
	return len(dp)
}

最长连续递增序列

674. 最长连续递增序列

1
2
3
4
5
6
7
8
func main() {
    nums := []int{1, 3, 5, 4, 7}
    r := findLengthOfLCIS(nums)
    fmt.Println(r) // 3
}
func findLengthOfLCIS(nums []int) int {
    // ......
}
分析

贪心算法实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func findLengthOfLCIS(nums []int) int {
    maxLen := 1
    curLen := 1
    for i := 1; i < len(nums); i++ {
       if nums[i] > nums[i-1] {
          curLen++
          if curLen > maxLen {
             maxLen = curLen
          }
       } else {
          curLen = 1
       }
    }
    return maxLen
}

动态规划实现:

本题和 最长递增子序列 的区别在于:上一题不要求子序列的连续性,所以 j 要从 0 到 i 的每个位置都去遍历一遍,然后取出最大值。

在本题中,只需要比较 i 和 i-1 位置即可(连续),如果 nums [i]>nums [i-1],则计数加 1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func findLengthOfLCIS(nums []int) int {
    n := len(nums)
    maxLen := 1
    dp := make([]int, n)
    for i := range dp {
       dp[i] = 1
    }
    for i := 1; i < n; i++ {
       if nums[i] > nums[i-1] {
          dp[i] = max(dp[i], dp[i-1]+1)
       }
       if dp[i] > maxLen {
          maxLen = dp[i]
       }
    }
    return maxLen
}

最长重复子数组

718. 最长重复子数组

1
2
3
4
5
6
7
8
9
func main() {
    nums1 := []int{1, 2, 3, 2, 1}
    nums2 := []int{3, 2, 1, 4, 7}
    r := findLength(nums1, nums2)
    fmt.Println(r) // 3
}
func findLength(nums1 []int, nums2 []int) int {
    // ......
}
分析

本题需要计算两个数组的最大重复子数组的长度。

由于需要比较两个数组的不同位置,所以我们需要使用二维的 dp 数组。

dp [i][j] 表示以 nums1 [i-1] 和 nums2 [j-1] 结尾的两个数组的最大重复子数组的长度。

递推表达式:如果 nums1 [i-1]==nums2 [j-1],则 dp [i][j]=dp [i-1][j-1]+1。

dp 数组初始化:dp [i][0] 和 dp [0][j] 都是没有意义的,应该初始化为 0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func findLength(nums1 []int, nums2 []int) int {
    maxLen := 0
    dp := make([][]int, len(nums1)+1)
    for i := range dp {
       dp[i] = make([]int, len(nums2)+1)
    }
    for i := 1; i <= len(nums1); i++ { // i和j都从1开始
       for j := 1; j <= len(nums2); j++ {
          if nums1[i-1] == nums2[j-1] {
             dp[i][j] = dp[i-1][j-1] + 1
          }
          maxLen = max(maxLen, dp[i][j])
       }
    }
    return maxLen
}

最长公共子序列

1143. 最长公共子序列

1
2
3
4
5
6
7
8
func main() {
    text1, text2 := "abcde", "ace"
    r := longestCommonSubsequence(text1, text2)
    fmt.Println(r) // 3
}
func longestCommonSubsequence(text1 string, text2 string) int {
    // ......
}
分析

本题和 最长重复子数组 的区别在于:本题求的是公共子序列(不用连续),而不是子数组。

dp [i][0] 和 dp [0][j] 都是没有意义的,初始值应该设置为 0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func longestCommonSubsequence(text1 string, text2 string) int {
    dp := make([][]int, len(text1)+1)
    for i := range dp {
       dp[i] = make([]int, len(text2)+1)
    }
    maxLen := 0
    for i := 1; i <= len(text1); i++ {
       for j := 1; j <= len(text2); j++ {
          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])  // 从 text1 或 text2 中删除最后一个元素
          }
          maxLen = max(maxLen, dp[i][j])
       }
    }
    return maxLen
}

不相交的线

1035. 不相交的线

1
2
3
4
5
6
7
8
9
func main() {
    nums1 := []int{1, 4, 2}
    nums2 := []int{1, 2, 4}
    r := maxUncrossedLines(nums1, nums2)
    fmt.Println(r) // 2
}
func maxUncrossedLines(nums1 []int, nums2 []int) int {
    // ......
}
分析

本题看上去很复杂,但其实就是求最长公共子序列,因为公共子序列的元素连接起来一定是不相交的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func maxUncrossedLines(nums1 []int, nums2 []int) int {
    maxLen := 0
    dp := make([][]int, len(nums1)+1)
    for i := range dp {
       dp[i] = make([]int, len(nums2)+1)
    }
    for i := 1; i <= len(nums1); i++ {
       for j := 1; j <= len(nums2); j++ {
          if nums1[i-1] == nums2[j-1] {
             dp[i][j] = dp[i-1][j-1] + 1
          } else {
             dp[i][j] = max(dp[i-1][j], dp[i][j-1])
          }
          maxLen = max(maxLen, dp[i][j])
       }
    }
    return maxLen
}

最大子数组和

53. 最大子数组和

1
2
3
4
5
6
7
8
func main() {
    nums := []int{-2, 1, -3, 4, -1, 2, 1, -5, 4}
    r := maxSubArray(nums)
    fmt.Println(r) // 6
}
func maxSubArray(nums []int) int {
    // ......
}
分析

方法 1:贪心算法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func maxSubArray(nums []int) int {
    // 如果累计和小于0,则从后一个位置重新开始
    sum, maxSum := 0, math.MinInt
    for i := 0; i < len(nums); i++ {
       sum += nums[i]
       if sum > maxSum {
          maxSum = sum
       }
       if sum < 0 {
          sum = 0
       }
    }
    return maxSum
}

方法 2:动态规划

dp [i] 表示 i 位置及之前的数组中,最大子数组(连续)的和为 dp [i]。

递推表达式,在第 i 个位置,有两种选择:

  1. 延续前面的数组(dp [i-1]+nums [i]);
  2. 重新开始新的子数组(nums [i])。

应该取二者中的较大值,即:dp [i]=max (dp [i-1]+nums [i],nums [i])。

dp 数组初始化:dp [0]=nums [0]。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func maxSubArray(nums []int) int {
    maxSum := math.MinInt
    dp := make([]int, len(nums))
    dp[0] = nums[0]
    for i := 1; i < len(nums); i++ {
       dp[i] = max(dp[i-1]+nums[i], nums[i])
       maxSum = max(maxSum, dp[i])
    }
    maxSum = max(maxSum, dp[0])
    return maxSum
}

判断子序列

392. 判断子序列

1
2
3
4
5
6
7
8
9
func main() {
    s := "abc"
    t := "ahbgdc"
    r := isSubsequence(s, t)
    fmt.Println(r)
}
func isSubsequence(s string, t string) bool {
    // ......
}
分析

本题和 最长公共子序列 基本上是一样的,我们只需要求出字符串 t 和 s 的最长公共子序列的长度 maxLen,

然后判断 maxLen 和 len (s) 是否相等,如果相等的话,说明 s 是 t 的子序列。

但在遍历时,我们只能从 t 中删除一些元素,而不能从 s 中删除元素(这是和 最长公共子序列 最大的区别)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func isSubsequence(s string, t string) bool {
    maxLen := 0
    dp := make([][]int, len(s)+1)
    for i := range dp {
       dp[i] = make([]int, len(t)+1)
    }
    for i := 1; i <= len(s); i++ {
       for j := 1; j <= len(t); j++ {
          if s[i-1] == t[j-1] {
             dp[i][j] = dp[i-1][j-1] + 1
          } else {
             dp[i][j] = dp[i][j-1] // t中删除最后一个位置的元素
          }
          maxLen = max(maxLen, dp[i][j])
       }
    }
    return maxLen == len(s)
}

不同的子序列

【困难】115. 不同的子序列

本题较为复杂,建议多加练习。

1
2
3
4
5
6
7
8
func main() {
    s, t := "rabbbit", "rabbit"
    r := numDistinct(s, t)
    fmt.Println(r) // 3
}
func numDistinct(s string, t string) int {
    // ......
}
分析

本题计算有多少种删除 s 字符串的操作,可以使 s 变成 t。

dp [i][j] 表示以 s [i-1] 结尾的数组和以 t [j-1] 结尾的数组,不同子序列的数量为 dp [i][j]。

递推表达式:

如果 s [i-1] 和 t [j-1] 相等,有两种情况:

  1. 同时不考虑 s 和 t 的最后一个元素(dp [i-1][j-1]);

  2. 删除 s 的最后一个元素,因为 s 中可能存在多个和 t 中元素相等的情况,所以可以从 s 中删除最后一个元素,再进行比较(dp [i-1][j])

即:dp [i][j]=dp [i-1][j-1]+dp [i-1][j]

如果 s [i-1] 和 t [j-1] 不相等,则:dp [i][j]=dp [i-1][j](删除 s 的最后一个元素)

初始化 dp 数组:dp [i][0]=1, dp [0][j]=0, dp [0][0]=1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func numDistinct(s string, t string) int {
    dp := make([][]int, len(s)+1)
    for i := range dp {
       dp[i] = make([]int, len(t)+1)
    }

    dp[0][0] = 1 // 初始化dp数组
    for i := 1; i <= len(s); i++ {
       dp[i][0] = 1
    }
    for j := 1; j <= len(t); j++ {
       dp[0][j] = 0
    }

    for i := 1; i <= len(s); i++ {
       for j := 1; j <= len(t); j++ {
          if s[i-1] == t[j-1] {
             dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
          } else {
             dp[i][j] = dp[i-1][j]
          }
       }
    }
    return dp[len(s)][len(t)]
}

两个字符串的删除操作

583. 两个字符串的删除操作

1
2
3
4
5
6
7
8
func main() {
    word1, word2 := "eat", "sea"
    r := minDistance(word1, word2)
    fmt.Println(r) // 2
}
func minDistance(word1 string, word2 string) int {
	// ......
}
分析

本题计算:使得字符串 word1 和字符串 word2 相同所需的最小步数。

dp [i][j] 表示以 word1 [i-1] 结尾的数组和以 word2 [j-1] 结尾的数组,使得它们二者相同所需要的最小步数。

递推表达式:如果 word1 [i-1]==word2 [j-1],dp [i][j]=dp [i-1][j-1]。

否则,有两种情况:

  1. 从 word1 中删除最后一个位置的元素,删除步数加 1,;

  2. 从 word2 中删除最后一个位置的元素,删除步数加 1。

同时从 word1 中和 word2 中删除最后一个位置的元素包含在了前面两种情况中,不再单独列出。

则递推表达式为:dp [i][j]=min (dp [i-1][j]+1,dp [i][j-1]+1)。

初始化 dp 数组:dp [0][j]=j, dp [i][0]=i。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func minDistance(word1 string, word2 string) int {
    dp := make([][]int, len(word1)+1)
    for i := range dp {
       dp[i] = make([]int, len(word2)+1)
    }

    for i := 1; i <= len(word1); i++ { // 初始化dp数组
       dp[i][0] = i
    }
    for j := 1; j <= len(word2); j++ {
       dp[0][j] = j
    }

    for i := 1; i <= len(word1); i++ {
       for j := 1; j <= len(word2); j++ {
          if word1[i-1] == word2[j-1] {
             dp[i][j] = dp[i-1][j-1]
          } else {
             dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1)
          }
       }
    }
    return dp[len(word1)][len(word2)]
}

编辑距离

72. 编辑距离

1
2
3
4
5
6
7
8
func main() {
    word1, word2 := "horse", "ros"
    r := minDistance2(word1, word2)
    fmt.Println(r) // 3
}
func minDistance2(word1 string, word2 string) int {
	// ......
}
分析

本题可以对 word1 和 word2 进行增 1删 1改 1 的操作,计算最少需要多少步可以让两个字符串相同。

dp [i][j] 表示以 word1 [i-1] 和 word2 [j-1] 结尾的字符串,最少需要 dp [i][j] 步可以让两个字符串相同。

递推表达式,如果 word1 [i-1]==word2 [j-1],则 dp [i][j]=dp [i-1][j-1]。

否则,分为几种情况:

  1. 从 word1 中删除最后一个元素(dp [i-1][j]+1);

  2. 从 word2 中删除最后一个元素(dp [i][j-1]+1)(这里将 word1 增加元素的操作转换成了 word2 删除元素的操作);

  3. 替换 word1 的最后一个元素为 word2 的最后一个元素(dp [i-1][j-1]+1)。

我们需要取上面三种情况的最小值,即:dp [i][j]=min (dp [i-1][j]+1,dp [i][j-1]+1,dp [i-1][j-1]+1)。

初始化 dp 数组:dp [i][0]=i, dp [0][j]=j。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func main() {
    word1, word2 := "horse", "ros"
    r := minDistance2(word1, word2)
    fmt.Println(r) // 3
}
func minDistance2(word1 string, word2 string) int {
    dp := make([][]int, len(word1)+1)
    for i := range dp {
       dp[i] = make([]int, len(word2)+1)
    }
    for i := 0; i <= len(word1); i++ {
       dp[i][0] = i
    }
    for j := 0; j <= len(word2); j++ {
       dp[0][j] = j
    }

    for i := 1; i <= len(word1); i++ {
       for j := 1; j <= len(word2); j++ {
          if word1[i-1] == word2[j-1] {
             dp[i][j] = dp[i-1][j-1]
          } else {
             dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+1)
          }
       }
    }
    return dp[len(word1)][len(word2)]
}

回文子串

647. 回文子串

本题较难,建议多加练习。

1
2
3
4
5
6
7
8
func main() {
    s := "abc"
    r := countSubstrings(s)
    fmt.Println(r) // 3
}
func countSubstrings(s string) int {
    // ......
}
分析

方法 1,暴力搜索:先获得 s 的所有子字符串,然后判断每个子字符串是否是回文的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func countSubstrings(s string) int {
    count := 0
    subString := func(s string) []string { // 生成字符串s的所有子字符串
       var result []string
       for i := 0; i < len(s); i++ { // 起始位置从0到len(s)-1
          for j := i + 1; j <= len(s); j++ { // 长度从i+1到len(s),相当于取到结尾
             result = append(result, s[i:j]) // 截取子字符串s[i:j]
          }
       }
       return result
    }
    subStr := subString(s)
    isPalindrome := func(str string) bool {
       left := 0
       right := len(str) - 1
       for left < right {
          if str[left] != str[right] {
             return false
          }
          left++
          right--
       }
       return true
    }
    for i := range subStr {
       if isPalindrome(subStr[i]) {
          count++
       }
    }
    return count
}

方法 2:动态规划

本题使用动态规划,需要重点明确 dp 数组的含义,以及如何进行递推。

dp [i][j] 表示 i 到 j(包含)范围内的字符串是否是回文字符串,如果是 dp [i][j] 值为 true,否则为 false。

递推表达式:

如果 s [i]==s [j],分为 3 种情况:

  1. 如果 j==i,也就是只有 1 个元素,dp [i][j]=true;

  2. 如果 j==i+1,也就是只有 2 个元素,dp [i][j]=true;

  3. 如果 j>i+1,也就是至少有 3 个元素,则 dp [i][j] 需要根据 dp [i-1][j-1] 的值进行计算。

    如果 dp [i+1][j-1] 值为 true,则 dp [i][j]=true。

我们使用 count 变量统计遇到的回文子串的数量,也就是每次 dp [i][j]==1 时,count++。

dp 数组初始化:dp 数组默认值为 false。

遍历顺序:由于 dp [i][j] 需要根据 dp [i+1][j-1] 的值进行计算。

那么我们在遍历 i 时需要从大到小,遍历 j 时需要从小到大。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func countSubstrings(s string) int {
    count := 0
    dp := make([][]bool, len(s))
    for i := range dp {
       dp[i] = make([]bool, len(s))
    }
    for i := len(s) - 1; i >= 0; i-- { // i从大到小遍历
       for j := i; j < len(s); j++ { // j从小到大遍历,从i开始
          if s[i] == s[j] { // 如果s[i]和s[j]相等,才进一步计算,看能否赋值为true,如果不相等,就使用默认值false
             if j-i <= 1 { // 前两种情况
                dp[i][j] = true
                count++
             } else {
                if dp[i+1][j-1] {
                   dp[i][j] = true
                   count++
                }
             }
          }
       }
    }
    return count
}

最长回文子序列

516. 最长回文子序列

1
2
3
4
5
6
7
8
func main() {
    s := "bbbab"
    r := longestPalindromeSubseq(s)
    fmt.Println(r) // 4
}
func longestPalindromeSubseq(s string) int {
    // ......
}
分析

本题需要计算最长的回文子序列。

dp [i][j] 和上一题一样,都表示 i 位置到 j 位置的字符串中,最长的回文子序列的长度为 dp [i][j]。

递推表达式:

  • 如果 s [i]==s [j],则 dp [i][j]=dp [i+1][j-1]+2(长度加 2);

  • 否则,分别删去当前区间中最前面的元素和最后面的元素,然后取较大值,即:dp [i][j]=max (dp [i+1][j], dp [i][j-1])

遍历方向:由于 dp [i][j] 需要根据 dp [i+1][j-1]、dp [i+1][j]、dp [i][j-1] 三个值计算得到,所以在遍历 i 时需要从大到小,遍历 j 时需要从小到大。

初始化 dp 数组:由于递推表达式 i 一直加 1,j 一直减 1,则移动到最中间的时候,应该要初始化,也就是 i 等于 j 时,dp [i][i]=1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func longestPalindromeSubseq(s string) int {
    dp := make([][]int, len(s))
    for i := range dp {
       dp[i] = make([]int, len(s))
    }
    for i := 0; i < len(s); i++ { // 初始化dp数组
       dp[i][i] = 1
    }
    for i := len(s) - 1; i >= 0; i-- {
       for j := i + 1; j < len(s); j++ {
          if s[i] == s[j] {
             dp[i][j] = dp[i+1][j-1] + 2
          } else {
             dp[i][j] = max(dp[i+1][j], dp[i][j-1])
          }
       }
    }
    return dp[0][len(s)-1]
}
0%