Leetcode专项学习计划:动态规划(基础版)

题目来自:Leetcode专项学习计划——动态规划(基础版),共 50 道题。

本文章只记录当前(2024.05.22)掌握得不好的题目,共 16 道,完整的题单请参考上面的链接。

删除并获得点数

740. 删除并获得点数

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

使用 sum 数组存放每个相同元素值的和,找到 nums 中的最大值 maxVal。

开辟空间为 maxVal+1 的 sum 数组,数组下标表示元素值,数组值表示该元素值的和。

这里使用数组而不是 map 的原因是,数组下标是自动有序的,不用再进行排序。

现在将问题转换为打家劫舍问题(不能选择 sum 数组中两个相邻的元素)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func deleteAndEarn(nums []int) int {
    maxVal := nums[0]
    for i := 0; i < len(nums); i++ {
       if nums[i] > maxVal {
          maxVal = nums[i]
       }
    }
    sum := make([]int, maxVal+1)
    for _, num := range nums { // 这里的处理是很巧妙的
       sum[num] += num
    }
    rob := func(sum []int) int { // 计算打家劫舍问题能获得的最大金额
       dp := make([]int, len(sum))  // dp[i]表示考虑0到i位置,最大能获得的金额
       dp[0], dp[1] = sum[0], max(sum[0], sum[1])
       for i := 2; i < len(sum); i++ {
          dp[i] = max(dp[i-2]+sum[i], dp[i-1])
       }
       return dp[len(sum)-1]
    }
    return rob(sum)
}

改进:

对上面方法的改进,dp 数组改为迭代变化的两个变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func deleteAndEarn(nums []int) int {    
    // 前面部分省略......
    // 将dp数组改为迭代变化的变量
    rob := func(sum []int) int {
       first, second := sum[0], max(sum[0], sum[1])
       for i := 2; i < len(sum); i++ {
          first, second = second, max(first+sum[i], second) // 将first和second变量指针后移
       }
       return second
    }
    return rob(sum)
}

最大正方形

221. 最大正方形

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    matrix := [][]byte{{'1', '0', '1', '0', '0'},
       {'1', '0', '1', '1', '1'},
       {'1', '1', '1', '1', '1'},
       {'1', '0', '0', '1', '0'}}
    //matrix := [][]byte{{'0', '1'}}
    r := maximalSquare(matrix)
    fmt.Println(r) // 4
}
func maximalSquare(matrix [][]byte) int {
    //......
}
分析

参考了答案,这里的递推表达式是非常关键的。

参考:1277. 统计全为 1 的正方形子矩阵的官方题解

dp[i][j] 表示以 (i, j) 为右下角,且只包含 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 maximalSquare(matrix [][]byte) int {
    maxSide := 0 // 最大的正方形边长
    m := len(matrix)
    n := len(matrix[0])
    dp := make([][]int, m)
    // 初始化dp数组
    for i := 0; i < m; i++ {
       dp[i] = make([]int, n)
       for j := 0; j < n; j++ {
          if matrix[i][j] == '1' {
             dp[i][j] = 1
             maxSide = 1 // 当matrix数组中出现1时,maxSide更新为1
          }
       }
    }
    for i := 1; i < m; i++ {
       for j := 1; j < n; j++ {
          if matrix[i][j] == '1' {
             dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
             maxSide = max(maxSide, dp[i][j])
          }
       }
    }
    return maxSide * maxSide
}

最长递增子序列的个数

本题过于复杂,暂时先跳过。

最长数对链

646. 最长数对链

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

方法1:贪心算法

参考了官方题解:要挑选最长数对链的第一个数对时,最优的选择是挑选第二个数字最小的,这样能给挑选后续的数对留下更多的空间。

先按照 pairs 数组的第 1 维进行升序排列,题目已经说明 pairs 数组第 0 维的值小于第 1 维的值。

按照上面这样排序之后,只需要满足下一个数对的 1 位置值大于下一个数对的 0 位置值,就可以将 count++。

这样得到的 count 就是要求的最大数对数量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func findLongestChain(pairs [][]int) int {
    sort.Slice(pairs, func(i, j int) bool {
       return pairs[i][1] < pairs[j][1]
    })
    count := 0
    // 这样写是不正确的,因为中间有些数对是不选择的,如:pairs = [[1,2], [2,3], [3,4]]
    // 中间的[2,3]就是不选择的,就不能参与判断,我们应该只让选择的数对参与判断。
    // 用一个变量cur来记录上一个已经选择数对1位置的值,而不是变量pairs数组的每一个位置。
    //for i := 1; i < len(pairs); i++ {
    // if pairs[i][0] > pairs[i-1][1] {
    //    count++
    // }
    //}
    cur := math.MinInt
    for _, p := range pairs {
       if cur < p[0] {
          cur = p[1] // 使用cur记录上一个数对1位置的值
          count++
       }
    }
    return count
}

方法2:动态规划

dp[i] 表示以 pairs[i] 结尾的最长数对链的长度。

使用 j 遍历 0 到 i-1 位置的所有元素,如果当前数对的左边界大于前一个数对的右边界,则可以组成数对链

即:dp[i]=max(dp[i], dp[j]+1)。

在计算 dp 数组值之前,需要对 pairs 数组按照左边界排序

初始化 dp 数组:每个数对本身至少可以形成一个长度为 1 的数对链,所以 dp[i]=1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func findLongestChain(pairs [][]int) int {
    maxLen := 1
    sort.Slice(pairs, func(i, j int) bool {
       return pairs[i][0] < pairs[j][0]
    })
    dp := make([]int, len(pairs))
    for i := range dp {
       dp[i] = 1
    }
    for i := 0; i < len(pairs); i++ {
       for j := 0; j < i; j++ { // j遍历0到i-1位置的元素
          if pairs[i][0] > pairs[j][1] { // 如果当前数对的左边界大于前一个数对的右边界,则可以组成数对链
             dp[i] = max(dp[i], dp[j]+1)
          }
       }
       maxLen = max(maxLen, dp[i])
    }
    return maxLen
}

最长定差子序列

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

1218. 最长定差子序列

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

普通动态规划:

自己写的版本,时间复杂度为 $O(n^2)$。逻辑上是正确的,但存在测试用例运行超时,说明时间复杂度还需要进一步优化。

dp[i] 表示以 arr[i] 结尾的数组的最长定长子序列的长度。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func longestSubsequence(arr []int, difference int) int {
    maxLen := 1
    dp := make([]int, len(arr))
    for i := range dp {
       dp[i] = 1
    }
    for i := 1; i < len(arr); i++ {
       for j := i - 1; j >= 0; j-- {
          if arr[j] == arr[i]-difference { // 从右往左查找i位置左侧最近的一个arr[j],但当数组很长且一直没有找到满足条件的元素时,运行时间就会很长。在Leetcode上提交,运行超时:测试用例通过了,但耗时太长。
             dp[i] = max(dp[i], dp[j]+1)
             maxLen = max(maxLen, dp[i])
             break
          }
       }
    }
    fmt.Println(dp)
    return maxLen
}

改进:map存放dp数组的值

参考了答案,使用 map 来对上面的方法进行改进。这里使用 map 来处理还是比较巧妙的,值得学习。

由于我们只需要找到左侧第 1 个符合条件的元素,所以我们使用 map 存放以 nums[i] 结尾的数组的最长定差子序列的长度。

1
2
3
4
5
6
7
8
9
func longestSubsequence(arr []int, difference int) int {
    maxLen := 1
    dp := make(map[int]int)
    for _, num := range arr {
       dp[num] = dp[num-difference] + 1
       maxLen = max(maxLen, dp[num])
    }
    return maxLen
}

最长等差数列

1027. 最长等差数列

1
2
3
4
5
6
7
8
func main() {
    nums := []int{9, 4, 7, 2, 10}
    r := longestArithSeqLength(nums)
    fmt.Println(r) // 3。最长等差数列为:[4,7,10]
}
func longestArithSeqLength(nums []int) int {
    // ......
}
分析

本题和 最长定差子序列 差不多,不同的地方在于两个元素之间的差值不是固定的,而是变化的。

我们先获取 nums 数组的最小值 minVal 和最大值 maxVal,计算差值 diff=maxVal-minVal。

元素之间差值的范围就是 -diff 到 diff,我们需要遍历所有可能的差值。

也就是在 最长定差子序列 的基础上,多套一层循环,遍历不同的 diff 值。

普通动态规划代码:

这样写有 3 层 for 循环,会导致运行超时,还是需要和 最长定差子序列 一样,将里面的两层 for 循环改成 map 存放以 nums[i] 结尾的数组的最长等差数列的长度。

还需要注意的是,为了不让不同的 diff 之间互相影响,我们应该在遍历不同的 diff 时,重新初始化 dp 数组。

 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
func longestArithSeqLength(nums []int) int {
	minVal, maxVal, maxLen := nums[0], nums[0], 1
	for _, num := range nums[1:] {
		if num < minVal {
			minVal = num
		} else if num > maxVal {
			maxVal = num
		}
	}
	for diff := minVal - maxVal; diff <= maxVal-minVal; diff++ {
		dp := make([]int, len(nums)) // 当diff不同时,需要重新初始化dp数组,为了不被前一个diff的dp数组影响
		for i := range dp {
			dp[i] = 1
		}
		for i := 1; i < len(nums); i++ {
			for j := i - 1; j >= 0; j-- {
				if nums[j] == nums[i]-diff { // 从右往左查找i位置左侧最近的一个arr[j]
					dp[i] = max(dp[i], dp[j]+1)
					maxLen = max(maxLen, dp[i])
					break
				}
			}
		}
	}
	return maxLen
}

改进:map存放dp数组的值

最长定差子序列 一样,将里面的两层 for 循环改成 map 存放以 nums[i] 结尾的数组的最长等差数列的长度。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func longestArithSeqLength(nums []int) int {
    // 求最大值和最小值的代码和前面一样,略。
    numDiff := maxVal - minVal
    for diff := -numDiff; diff <= numDiff; diff++ {
       // 在这里使用map对上面的方法进行改进
       dp := make(map[int]int, maxVal+1) // 这里给出map的初始容量,防止频繁地扩容
       for _, num := range nums {
          dp[num] = dp[num-diff] + 1
          maxLen = max(maxLen, dp[num])
       }
    }
    return maxLen
}

俄罗斯套娃信封问题

354. 俄罗斯套娃信封问题

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

计算逻辑是正确的,但第 85 个测试用例运行超时了,说明时间复杂度过高。

本题的 $1 <= envelopes.length <= 10^5$,说明如果时间复杂度是 $O(n^2)$,肯定会运行超时。

本题需要考虑两个方面,下一个元素的 0 位置(w)和 1 位置(h)都必须大于前一个元素。

我们先按照 envelopes 数组 0 位置的元素(w)进行升序排列,我们要根据 w 选择最大递增子数组。

但存在 w 相同,但 h 不同的情况,如:[1,1], [1,2], [1,3], [1,4],这时候我们应该如何确定我们的选择规则呢?

根据题目的要求,我们也需要根据 h 选择最大递增子数组。

由于上面的 4 种情况我们只能选择 1 种,所以为了不让选择的结果出现多个 w 相同,但 h 不同的情况,当 w 相同时,我们要让 envelopes 数组根据 h 降序排列(这样根据 h 选择的最大递增子数组长度就只能是 1 了)

排序完成后,我们就可以忽略 w 维度,只考虑 h 维度,选择 envelopes 数组的最大递增子数组了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func maxEnvelopes(envelopes [][]int) int {
    sort.Slice(envelopes, func(i, j int) bool {
       if envelopes[i][0] != envelopes[j][0] {
          return envelopes[i][0] < envelopes[j][0] // 按w升序
       }
       return envelopes[i][1] > envelopes[j][1] // 当w相同时,按h降序
    })
    maxLen := 1
    dp := make([]int, len(envelopes))
    for i := range dp {
       dp[i] = 1
    }
    for i := 0; i < len(envelopes); i++ {
       for j := 0; j < i; j++ {
          if envelopes[j][1] < envelopes[i][1] {
             dp[i] = max(dp[i], dp[j]+1)
             maxLen = max(maxLen, dp[i])
          }
       }
    }
    return maxLen
}

对上面的方法进行优化,使用二分查找。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func maxEnvelopes(envelopes [][]int) int {
    // 前面排序envelopes数组的代码相同,省略。
    // 参考:https://leetcode.cn/problems/russian-doll-envelopes/solutions/633231/e-luo-si-tao-wa-xin-feng-wen-ti-by-leetc-wj68/
    var dp []int // 使用dp记录当前符合条件的h值
    for _, envelope := range envelopes {
       h := envelope[1] // 新插入到数组的信封高度为h,对原dp数组进行二分查找
       // 由题意可知,dp数组中的元素是单调递增的,使用sort.SearchInts(dp,h)查找h在原数组中应该插入的位置
       // 如果返回值小于dp数组的长度,说明插入位置在dp数组的中间,更新该位置的值为h
       // 如果返回值等于dp数组的长度,说明应该将h添加到dp数组的末尾
       if i := sort.SearchInts(dp, h); i < len(dp) {
          dp[i] = h
       } else {
          dp = append(dp, h)
       }
    }
    return len(dp)
}

找出到每个位置为止最长的有效障碍赛跑路线

【困难】

1964. 找出到每个位置为止最长的有效障碍赛跑路线

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

普通 dp:超时

本题的 $1 <= n <= 10^5$,说明如果时间复杂度是 $O(n^2)$,肯定会运行超时。

必须包含最后一个障碍,后一个障碍的高度必须大于等于前一个障碍。dp[i] 表示到第 i 位置,最长障碍长度。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func longestObstacleCourseAtEachPosition(obstacles []int) []int {
        dp := make([]int, len(obstacles))
    for i := range dp {
       dp[i] = 1
    }
    for i := 1; i < len(obstacles); i++ {
       for j := 0; j < i; j++ {
          if obstacles[j] <= obstacles[i] {
             dp[i] = max(dp[i], dp[j]+1)
          }
       }
    }
    return dp
}

改进:二分查找

参考:Golang + 贪心 + 二分

理解得还不是很好,需要再多加理解和练习。

 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
32
func longestObstacleCourseAtEachPosition(obstacles []int) []int {
    n := len(obstacles)
    var ans = make([]int, n)  // 最后返回的目标数组
    var dp = make([]int, n+1) // 维护一个单调不减数组,dp[i]表示到i-1位置,能构成赛跑路线的数组障碍物值
    length := 1               // dp数组的有效长度
    ans[0] = 1
    dp[1] = obstacles[0] // dp[0]是没有意义的,所以从下标1开始初始化
    for i := 1; i < n; i++ {
       fmt.Println("dp:", dp)          // 这里可以打印出dp数组的值,可以看出dp数组是不断迭代变化的
       if obstacles[i] >= dp[length] { // 遇到的元素大于等于dp数组的最后一个元素(有效范围内),将obstacles[i]添加到dp数组中
          length++
          dp[length] = obstacles[i]
          ans[i] = length
       } else {
          l, r, pos := 1, length-1, 0
          for l <= r {
             middle := (l + r) / 2
             if dp[middle] > obstacles[i] {
                r = middle - 1
             } else {
                pos = middle   // pos就是obstacles[i]应该插入的位置
                l = middle + 1 // 当dp[middle]和obstacles[i]相等时,也要继续将l右移,因为我们要找到相等时的最后一个元素位置
             }
          }
          ans[i] = pos + 1              // 长度为下标pos加1
          if dp[pos+1] > obstacles[i] { // 如果应该插入位置的后一个位置的dp数组值大于插入的obstacles[i],如果大于,则需要将该位置值更新为obstacles[i]的值,以保持dp数组的单调不减性质。
             dp[pos+1] = obstacles[i]
          }
       }
    }
    return ans
}

让字符串成为回文串的最少插入次数

【困难】

1312. 让字符串成为回文串的最少插入次数

本题较简单,和【困难】的难度不匹配。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
	s := "mbadm"
	//s := "leetcode"  // eee
	//s := "zjveiiwvc" // viiv
	r := minInsertions(s)
	fmt.Println(r) // 2
}
func minInsertions(s string) int {
    // ......
}
分析

本题求最少添加多少个字符可以让 s 变成回文字符串。

可以转换为最少删除多少个字符串可以让 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
func minInsertions(s string) int {
    dp := make([][]int, len(s))
    for i := range dp {
       dp[i] = make([]int, len(s))
       for j := 0; j < len(s); j++ { // 初始化dp数组,当i==j时,dp数组值为0,否则,值为len(s)-1
          if i == j {
             dp[i][j] = 0
          } else {
             dp[i][j] = len(s) - 1
          }
       }
    }
    for i := len(s) - 1; i >= 0; i-- {
       for j := i + 1; j < len(s); j++ {
          if s[i] == s[j] {
             if j == i+1 {
                dp[i][j] = 0
             } else {
                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[0][len(s)-1]
}

不同的二叉搜索树II

95. 不同的二叉搜索树 II

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type TreeNode struct {
    Val   int
    left  *TreeNode
    right *TreeNode
}
func main() {
    n := 3
    r := generateTrees(n)
    fmt.Println(r)
}
func generateTrees(n int) []*TreeNode {
    // ......
}
分析

本题需要构造出 n 个结点的所有 BST。

以 n=3 为例,可以有下面几种情况:

  1. 选第 0 个位置为根结点,则右侧有 2 个结点;

  2. 选第 1 个位置为根结点,则左侧有 1 个结点,右侧有 1 个结点;

  3. 选第 2 个位置为根结点,则左侧有 2 个结点,右侧有 0 个结点。

可以定义一个函数 genBST,参数为 start 和 end,表示需要生成 BST 的左右区间,返回值为生成的 BST 集合。

然后用 i 遍历 1到 n,每次选择 i 作为根结点,然后左右区间递归地调用 genBST 函数,最后组成所有的 BST 集合。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func generateTrees(n int) []*TreeNode {
    if n == 0 {
       return nil
    }
    return genBST(1, n)
}
func genBST(start, end int) []*TreeNode {
    if start > end {
       return []*TreeNode{nil}
    }
    var allTree []*TreeNode
    for i := start; i <= end; i++ {
       leftTree := genBST(start, i-1) // 在i位置处分成两个部分,左右分别构造BST
       rightTree := genBST(i+1, end)
       for _, left := range leftTree {
          for _, right := range rightTree {
             curTree := &TreeNode{i, left, right}
             allTree = append(allTree, curTree)
          }
       }
    }
    return allTree // 逐层向上返回
}

二叉树中的最大路径和

【困难】

124. 二叉树中的最大路径和

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type TreeNode struct {
    Val   int
    Left  *TreeNode
    Right *TreeNode
}
func main() {
    node1 := &TreeNode{15, nil, nil}
    node2 := &TreeNode{7, nil, nil}
    node3 := &TreeNode{20, node1, node2}
    node4 := &TreeNode{9, nil, nil}
    node5 := &TreeNode{-10, node4, node3}
    //   -10
    //  9   20
    //    15  7
    r := maxPathSum(node5)
    fmt.Println(r) // 42。选择的路径为:15->20->7。
}
func maxPathSum(root *TreeNode) int {
    // ......
}
分析

本题的最大路径可能有两种情况:

  1. 路径在同一个二叉树;

  2. 路径不在同一个二叉树。

我们计算每个子树的最大贡献,最大贡献最大路径和 是不一样的。

最大贡献用于向上返回,用于上层结点进行计算,同时为了统计出 情况1 的最大值,我们维护一个全局变量maxSum,统计出 情况1 的最大路径和。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func maxPathSum(root *TreeNode) int {
    maxSum := math.MinInt
    var maxGain func(root *TreeNode) int
    maxGain = func(root *TreeNode) int {
       if root == nil {
          return 0
       }
       leftGain := max(maxGain(root.Left), 0) // 小于0时,我们就不统计了
       rightGain := max(maxGain(root.Right), 0)
       maxSum = max(maxSum, leftGain+rightGain+root.Val) // 更新路径在同一个二叉树上时的最大路径和
       return root.Val + max(leftGain, rightGain) // 向上返回贡献值,只能选择左孩子或者右孩子中的一个
    }
    maxGain(root)
    return maxSum
}

解决智力问题

2140. 解决智力问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    questions := [][]int{{3, 2}, {4, 3}, {4, 4}, {2, 5}} // 5
    //questions := [][]int{{1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}} // 7
    //questions := [][]int{{38, 1}, {60, 2}} // 60
    r := mostPoints(questions)
    fmt.Println(r) // 5
}
func mostPoints(questions [][]int) int64 {
    // ......
}
分析

方法1:动态规划+双重循环

计算逻辑是正确的,但第 48 个测试用例超时。

本题的 $1 <= questions.length <= 10^5$,说明不能使用时间复杂度为 $O(n^2)$ 的算法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func mostPoints(questions [][]int) int64 {
    // dp[i]表示考虑到i位置,最多能获得的分数
    n := len(questions)
    dp := make([]int, n)
    for i := range dp {
       dp[i] = questions[i][0]
    }
    maxScore := questions[0][0]
    for i := 1; i < n; i++ {
       for j := 0; j < i; j++ {
          if i > j+questions[j][1] {
             dp[i] = max(dp[i], dp[j]+questions[i][0])
          }
          maxScore = max(maxScore, dp[i])
       }
    }
    fmt.Println(dp)
    return int64(maxScore)
}

方法2:从后往前遍历dp数组

在 i 位置,我们可以选择 解决不解决 第 i 道题。

这种选择会影响到后面 dp 数组的值,但不会影响前面 dp 数组的值。

如果我们从前往后递推 dp 数组,那么在 i 位置,就需要遍历 0 到 i-1 位置是否处于冷冻期,来计算 i 位置 dp 数组的值,就需要双重循环。

为了能让单层循环就能解决本题,我们可以反方向进行递推,从后往前依次计算 dp 数组。

dp 数组表示 解决第 i 道题目及以后的题目可以获得的最高分数。(和前面的定义方向相反)

在 i 位置,我们可以选择 解决 和 不解决 第 i 道题。

  1. 解决:questions[i][0] + dp[ i+questions[i][1]+1 ];

  2. 不解决:dp[i+1]。

则:dp[i]=max(questions[i][0]+dp[i+questions[i][1]+1], dp[i+1])

这里还需要注意 i+questions[i][1]+1 下标范围不能超过 n-1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func mostPoints(questions [][]int) int64 {
    n := len(questions)
    dp := make([]int, n)
    dp[n-1] = questions[n-1][0]
    for i := n - 2; i >= 0; i-- { // 从n-2位置开始
       if i+questions[i][1]+1 > n-1 { // 超出n-1的范围,dp[i+questions[i][1]+1]的值置为0
          dp[i] = max(questions[i][0], dp[i+1])
       } else {
          dp[i] = max(questions[i][0]+dp[i+questions[i][1]+1], dp[i+1])
       }
    }
    return int64(dp[0])
}

统计构造好字符串的方案数

2466. 统计构造好字符串的方案数

1
2
3
4
5
6
7
8
9
func main() {
    low, high, zero, one := 2, 3, 1, 2 // 5
    //low, high, zero, one := 3, 3, 1, 1 // 8
    r := countGoodStrings(low, high, zero, one)
    fmt.Println(r) // 5。好字符串为"00","11","000","110"和"011"。
}
func countGoodStrings(low int, high int, zero int, one int) int {
    // ......
}
分析

在每次操作时,可以选择添加 zero 次 0 或者 one 次 1。

dp[i] 表示长度为 i 的字符串的构造方案数。

参考了答案:爬楼梯换皮。发现本题和 爬楼梯 是差不多的,第 i 个位置,有两个可能性:

  1. 第 i-zero 位置添加 zero 个 0;

  2. 第 i-one 位置添加 one 个 1。

递推表达式为:dp[i]=dp[i-zero]+dp[i-one]。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func countGoodStrings(low int, high int, zero int, one int) int {
    const mod int = 1e9 + 7
    res := 0
    dp := make([]int, high+1)
    dp[0] = 1
    for i := 1; i <= high; i++ {
       //dp[i] = dp[i-zero] + dp[i-one]  // 不能直接这样写,要注意下标的范围
       if i >= zero {
          dp[i] = dp[i-zero] % mod
       }
       if i >= one {
          dp[i] = (dp[i] + dp[i-one]) % mod
       }
       if i >= low { // 统计长度大于low的字符串构造方案总和
          res = (res + dp[i]) % mod
       }
    }
    return res
}

解码方法

91. 解码方法

1
2
3
4
5
6
7
8
9
func main() {
    s := "226"
    //s := "301" // 0
    r := numDecodings(s)
    fmt.Println(r) // 3
}
func numDecodings(s string) int {
    // ......
}
分析

本题整体来说比较简单,但需要注意能成功解码的条件。

dp[i] 表示到 i 位置,解码方法的数量。

在 i 位置,dp[i] 可以根据 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
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func numDecodings(s string) int {
    if s[0] == '0' {
       return 0
    }
    if len(s) == 1 {
       return 1
    }
    mapping := make(map[string]bool) // 保存所有存在编码的字符串
    for i := 1; i <= 26; i++ {
       mapping[strconv.Itoa(i)] = true
    }
    dp := make([]int, len(s))
    dp[0] = 1
    if s[1] == '0' && !mapping[s[:2]] {
       return 0
    }
    if mapping[s[0:2]] && mapping[s[1:2]] {
       dp[1] = 2
    } else {
       dp[1] = 1
    }
    for i := 2; i < len(s); i++ {
       if s[i] == '0' && !mapping[s[i-1:i+1]] { // 如果i位置为0,那么s[i-1:i+1]必须合法才能解码
          return 0
       }
       if mapping[s[i-1:i+1]] && mapping[s[i:i+1]] { // 注意这里需要添加条件,检查i位置前面1位和2位的字符串是否合法
          dp[i] = dp[i-2] + dp[i-1]
       } else if mapping[s[i-1:i+1]] {
          dp[i] = dp[i-2]
       } else if mapping[s[i:i+1]] {
          dp[i] = dp[i-1]
       }
    }
    return dp[len(s)-1]
}

最低票价

983. 最低票价

1
2
3
4
5
6
7
8
9
func main() {
    days := []int{1, 4, 6, 7, 8, 20}
    costs := []int{2, 7, 15}
    r := mincostTickets(days, costs)
    fmt.Println(r) // 11。第1天买1天的,第4天买7天的,第20天买1天的,2+7+2=11。
}
func mincostTickets(days []int, costs []int) int {
    // ......
}
分析

dp[i] 表示旅行到第 i 天(下标从 1 开始)时需要的最低票价。

这里需要使用到贪心算法的思想,如果第 i 天不是必须出行(也就是 i 不在 days 数组中),那么第 i 天就不需要买票,即:dp[i]=dp[i-1]。

如果 i 在 days 数组中,则我们可以根据第 i-1、i-7 和 i-30 天的票价推导出第 i 天的票价。

即:dp[i]=min(dp[i-1]+costs[0], dp[i-7]+costs[1], dp[i-30]+costs[2])。

最后,dp[len(dp)-1] 就是我们要求的目标值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func mincostTickets(days []int, costs []int) int {
    dp := make([]int, days[len(days)-1]+1)
    fmt.Println(len(dp))
    dayIndex := 0 // 标记下一个必须出行日期的下标
    for i := 1; i < len(dp); i++ {
       if i != days[dayIndex] { // 第i天不用出行
          dp[i] = dp[i-1]
       } else {
          //dp[i] = min(dp[i-1]+costs[0], dp[i-7]+costs[1], dp[i-30]+costs[2]) // 由于这里下标可能小于0,所以采用下面的写法
          dp[i] = min(dp[max(0, i-1)]+costs[0],
             dp[max(0, i-7)]+costs[1],
             dp[max(0, i-30)]+costs[2])
          dayIndex++
          fmt.Println(dp)
       }
    }
    return dp[len(dp)-1]
}

多米诺和托米诺平铺

790. 多米诺和托米诺平铺

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    n := 3
    r := numTilings(n)
    fmt.Println(r) // 5
    //  index: 1 2 3 4  5  6  7
    // result: 1 2 5 11 24 53 117
}
func numTilings(n int) int {
    // ......
}
分析

参考了答案:多米诺和托米诺平铺——方法一:动态规划

考虑第 i 列的几种情况

在第i列,存在下面几种情况:

  1. 一个正方形都没有覆盖,标记为 dp[i][0];
  2. 只有上方的正方形被覆盖,标记为 dp[i][1];
  3. 只有下方的正方形被覆盖,标记为 dp[i][2];
  4. 上下的正方形都被覆盖,标记为 dp[i][3]。

现在要根据 dp[i-1] 的不同情况,再添加上一个 多米诺形 或 托米诺形,让第 i 个位置可以达成 i 位置的每种情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func numTilings(n int) int {
    const mod int = 1e9 + 7
    dp := make([][4]int, n+1)
    dp[0][3] = 1 // 这里的初始值也很重要
    for i := 1; i <= n; i++ {
       // 递推表达式最好结合图形来计算。
       dp[i][0] = dp[i-1][3] % mod
       dp[i][1] = (dp[i-1][0] + dp[i-1][2]) % mod
       dp[i][2] = (dp[i-1][0] + dp[i-1][1]) % mod
       dp[i][3] = (((dp[i-1][0]+dp[i-1][1])%mod+dp[i-1][2])%mod + dp[i-1][3]) % mod
    }
    return dp[n][3]
}

最后的【动态规划-多维】部分需要升级 Plus 会员才能解锁。

2024年05月22日,除会员专属的题目外,其他所有题目都已练习一遍。

除去 最长递增子序列的个数 过于复杂,暂时跳过外,其他题目都应该完全掌握。

0%