蛮荆

动态规划简明教程 - 4

2022-06-19

❓ 最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

# 示例来自: https://leetcode.cn/

示例 1:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4
示例 2:

输入:nums = [0,1,0,3,2,3]
输出:4

示例 3:

输入:nums = [7,7,7,7,7,7,7]
输出:1

💡 解题过程

动态规划的子问题分解属于自顶向下的思想,和前面几篇系列文章一样,这道题也可以按照 暴力搜索 -> 记忆化搜索 -> 动态规划搜索 的顺序来实现代码,这样循序渐进地优化更符合我们的思考过程,也可以降低我们从一开始直接推导状态转移方程的心理压力。

接下来,我们先尝试着给出一个可行的题解,然后在这个基础上慢慢迭代优化。

解题方法函数原型:

func lengthOfLIS(nums []int) int {}

单元测试

为了节省篇幅,这里给出针对本题的单元测试代码,不管 暴力搜索 -> 记忆化搜索 -> 动态规划搜索 哪种实现方式,都可以直接运行该单元测试。

func Test_lengthOfLIS(t *testing.T) {
	type args struct {
		nums []int
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		{
			"test-1",
			args{nil},
			0,
		},
		{
			"test-2",
			args{[]int{1}},
			1,
		},
		{
			"test-3",
			args{[]int{10, 9, 2, 5, 3, 7, 101, 18}},
			4,
		},
		{
			"test-4",
			args{[]int{0, 1, 0, 3, 2, 3}},
			4,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := lengthOfLIS(tt.args.nums); got != tt.want {
				t.Errorf("lengthOfLIS() = %v, want %v", got, tt.want)
			}
		})
	}
}

💪 暴力搜索

解题思路

暴力搜索的思路是尝试找出所有可能的递增子序列,然后找出其中最长的那个,具体的算法步骤可以概括为:

  1. 遍历数组中每个元素
  2. 针对每个当前元素,尝试找出以该元素开头的所有递增子序列
  3. 计算将当前元素包含在子序列中时,可以获得的最大递增子序列长度
  4. 计算跳过当前元素时 (也就是不将当前元素包含在子序列中),可以获得的最大递增子序列长度
  5. 重复第 2, 3, 4 步骤,同时更新已知的最大递增子序列长度,直到数组遍历完成

暴力搜索示例

遍历到数组的任意一个元素时,我们有两种选择:

  1. 将当前元素添加到已有的子序列中
  2. 跳过当前元素,将当前元素的下一个元素添加到已有的子序列中

例如遍历第一个元素 1 时,存在的两种选择:

  1. 1 加入到子序列中,已知的最大递增子序列长度为 2
  2. 跳过 1,已知的最大递增子序列长度为 1

是否将 1 添加到子序列中

实现代码

因为需要 尝试数组中每个元素的所有可能子序列长度 (穷举),所以自然而然,我们会想到使用回溯算法来实现代码。

根据解题思路,可以写出如下的代码:

// 题解代码

// 暴力搜索迭代版本 (超时)
// 超时原因: 重复检测
func lengthOfLISBruteForce(nums []int) int {
	if len(nums) == 0 {
		return 0
	}

	maxLen := 1
	backtrack(nums, -1, 0, &maxLen)

	return maxLen
}

// 回溯穷举每个元素开头的所有递增子序列
// prev: 上个元素的索引
// cur:  当前元素的索引
func backtrack(nums []int, prev, cur int, maxLen *int) int {
	if cur == len(nums) {
		return 0
	}

	taken := 0
	if prev == -1 || nums[cur] > nums[prev] {
		// 计算将当前元素包含在子序列中时
		// 可以获得的最大递增子序列长度
		taken = 1 + backtrack(nums, cur, cur+1, maxLen)
	}
	// 计算跳过当前元素时 (也就是不将当前元素包含在子序列中)
	// 可以获得的最大递增子序列长度
	notTaken := backtrack(nums, prev, cur+1, maxLen)

	// 更新已知的最大递增子序列长度
	*maxLen = max(*maxLen, max(taken, notTaken))

	return max(taken, notTaken)
}

提交超时

运行单元测试:

$ go test -v -count=1 . 

输出结果如下:

=== RUN   Test_lengthOfLIS
=== RUN   Test_lengthOfLIS/test-1
=== RUN   Test_lengthOfLIS/test-2
=== RUN   Test_lengthOfLIS/test-3
=== RUN   Test_lengthOfLIS/test-4
--- PASS: Test_lengthOfLIS (0.00s)
    --- PASS: Test_lengthOfLIS/test-1 (0.00s)
    --- PASS: Test_lengthOfLIS/test-2 (0.00s)
    --- PASS: Test_lengthOfLIS/test-3 (0.00s)
    --- PASS: Test_lengthOfLIS/test-4 (0.00s)
PASS
ok      leetcode/0300-Longest-Increasing-Subsequence    0.002s

单元测试全部通过,表面上来看,我们的暴力搜索实现没有问题,现在去官方提交看看运行结果。

提交超时

提交代码到官方后,提示 “测试超出时间限制”,说明代码的运行时间复杂度太高了,也就是代码运行时间存在优化空间。

超时原因分析

在尝试优化暴力搜索之前,我们先来分析一下刚才的算法为何时间复杂度很高。

对于数组中的每个元素,我们都要检测从其位置开始到数组结尾的最大递增子序列长度,这会产生一个问题: 元素重复检测,也就是因为 “重叠子问题” 导致的 子问题重复计算

举例来说,遍历第一个元素 nums[1] = 1 (0, 1) 时,在回溯 (递归) 过程中会依次检测会出现两条路径:

  1. 将当前元素添加到已有的子序列中,递归检测 (0, 1)
  2. 跳过当前元素,将当前元素的下一个元素添加到已有的子序列中,递归检测 (0, 2)

然而无论哪条路径,都会重复检测 (2, 3) 这个元素组合,以此类推,随着递归的深度不断增加,重复检测的元素组合会越来越多。

元素重复检测示例

📝 记忆化搜索

现在我们已经分析出了暴力搜索的超时原因 (子问题重复计算),和 Fibonacci 问题类似,作为优化方案,可以采用一个 额外的备忘录 来记录已经检测过的元素组合,具体来说:

  1. 回溯 (递归) 过程中检测每个元素的不同路径组合时 (也就是不同的 prev + cur 参数组合)
  2. 如果当前路径已经检测过,直接返回备忘录中的值即可
  3. 如果当前路径未检测过,进行递归检测,并在递归结束后将对应的值添加到备忘录中

下面是对应的实现代码:

// 记忆化搜索
func lengthOfLISMemo(nums []int) int {
	if len(nums) == 0 {
		return 0
	}

	// 初始化备忘录
	// 所有值设置为 -1
	// 表示当前元素组合还未被检测
	// 使用一个二维数组来表示 HashMap
	// 其中 memo[i][j] 表示:
	//    i = 参数 prev + 1 (prev 从 -1 开始)
	//    j = 参数 cur
	memo := make([][]int, len(nums))
	for i := range memo {
		memo[i] = make([]int, len(nums)+1)
		for j := range memo[i] {
			memo[i][j] = -1
		}
	}

	return backtrackMemo(nums, -1, 0, memo)
}

func backtrackMemo(nums []int, prev, cur int, memo [][]int) int {
	// 递归结束条件
	if cur == len(nums) {
		return 0
	}

	// 如果备忘录中已经包含 [当前元素组合]
	// 直接返回
	if memo[prev+1][cur] != -1 {
		return memo[prev+1][cur]
	}

	taken := 0
	if prev == -1 || nums[cur] > nums[prev] {
		// 计算将当前元素包含在子序列中时
		// 可以获得的最大递增子序列长度
		taken = 1 + backtrackMemo(nums, cur, cur+1, memo)
	}
	// 计算跳过当前元素时 (也就是不将当前元素包含在子序列中)
	// 可以获得的最大递增子序列长度
	notTaken := backtrackMemo(nums, prev, cur+1, memo)

	// 更新备忘录中 [当前元素组合] 的值
	memo[prev+1][cur] = max(taken, notTaken)

	return memo[prev+1][cur]
}

提交测试通过

虽然加了备忘录之后代码性能依然较低,但是好在可以通过全部的测试用例了。

代码执行过程示例

如图所示,有了备忘录之后,每个元素组合只会被计算一次,计算量会大大减少。

记忆化搜索执行示例

举例来说,当递归计算元素组合 [0, 1] 时 (prev = 0, cur = 1),会出现两个组合 (路径):

  1. 将当前元素添加到已有的子序列中,对应组合 [1, 2]
  2. 跳过当前元素,将当前元素的下一个元素添加到已有的子序列中,对应组合 [0, 2]

接下来,递归计算组合 [1, 2] 时,会计算其子组合 [2, 3][1, 3] 并在计算完成后更新到备忘录中。

在递归计算组合 [0, 2] 时,也会计算子组合 [2, 3],因为 [2, 3] 之前已经计算过了,所以直接从备忘录中取得计算结果即可,无需重复计算。


🚀 动态规划

现在实现了暴力搜索和记忆化搜索代码之后,我们来思考如何编写动态规划代码。

状态转移方程

设计正确的状态转移方程是解决动态规划问题的前提。

自顶向下时,当前元素作为 “当前子序列的末尾元素”,可以选择加入或者不加入到子序列中

自底向上时,根据已知的子序列状态,将当前元素作为 “已知的子序列状态” 末尾元素,计算可以得到的子序列长度,(和自顶向下正好相反)

和前文中暴力搜索和记忆化搜索的 自顶向下 的实现不同,对于动态规划,我们考虑如何 自定向上 的实现。

遍历数组时,对于每个当前元素 nums[i],我们需要存储以 nums[i] 为结尾可以形成的最大递增子序列长度,记录为 dp[i]

这样在向后遍历其他元素时,每次只需要根据已有的子问题解 dp[...] 来计算出当前元素 dp[i] 的解即可,具体来说:

  1. nums[i] 默认为 1,表示由其自身构成的最大递增子序列长度
  2. 遍历 nums[0] ... nums[i], 每个当前元素记录为 nums[j],执行下列操作:
    1. 如果 nums[j] >= nums[i] , 说明 nums[i] 连接 nums[j]无法形成递增子序列,直接跳过即可
    2. 如果 nums[j] < nums[i] , 说明 nums[i] 连接 nums[j]可以形成递增子序列,此时更新 dp[i] 对应的最大长度解,也就是 dp[i] (当前值) 和 dp[j] + 1 (连接 nums[j] 之后) 中的较大值

举例来说:

nums[0] = 0 构成的最大递增子序列长度为 1 (0),当遍历到下一个元素 nums[1] = 1 时,因为 nums[0] < nums[1], 可以形成递增子序列,此时将 nums[1] = 1 可以构成的最大递增子序列长度更新为 2 (0, 1)。

同理,以 nums[1] = 1 构成的最大递增子序列长度为 2 (0, 1),当遍历到下一个元素 nums[2] = 0 时,因为 nums[2] >= nums[1], 无法形成递增子序列,直接跳过即可。

状态转移示例

根据这两个示例和边界条件,可以推导出如下的状态转移方程:

$$ \begin{align*} F(i) = \begin{cases} \max\ ( F(i), F(j) + 1 \ ) \ & \text {, } \text{F}(j) < F(i), j \in [0:i) \\ 1 & \text{, } \text{}default \end{cases} \end{align*} $$

算法步骤

根据上面的分析过程和状态转移过程,可以得到如下的算法步骤:

  1. 定义问题状态:计算数组中以每个元素结尾的最长子序列长度
  2. 状态转移方程F(i) = max{F(i), F(j) + 1}
  3. 初始化:建立状态转移表 (一维数组)
  4. 计算子问题:计算子数组 nums[0:i] 中以每个元素结尾的最长子序列长度
  5. 计算原问题:计算以当前元素的结尾的最长子序列长度

实现代码

计算出状态转移方程之后,剩下的事情就是将算法步骤直接翻译为代码就可以了,下面的是动态规划的实现代码。

// 动态规划
func lengthOfLIS(nums []int) int {
	n := len(nums)
	if n == 0 {
		return 0
	}

	maxLen := 0
	// dp[i] 的值代表以 nums[i] 结尾的最长子序列长度
	dp := make([]int, n)

	for i := 0; i < n; i++ {
		// 默认为 1
		dp[i] = 1

		for j := 0; j < i; j++ {
			if nums[j] < nums[i] {
				dp[i] = max(dp[i], dp[j]+1)
			}
		}
		maxLen = max(maxLen, dp[i])
	}

	return maxLen
}

提交通过

下面是动态规划代码的执行过程图解。

动态规划执行过程


Reference

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。