蛮荆

动态规划简明教程 - 3

2022-06-15

❓ 最长回文子串

给你一个字符串 s,找到 s 中最长的回文子串。

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

输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

示例 2:

输入:s = "cbbd"
输出:"bb"

💡 解题过程

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

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

解题方法函数原型:

func longestPalindrome(s string) string {}

单元测试

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

func Test_longestPalindrome(t *testing.T) {
	type args struct {
		s string
	}
	tests := []struct {
		name string
		args args
		want string
	}{
		{
			"test an empty string",
			args{""},
			"",
		},
		{
			"test a single character string",
			args{"a"},
			"a",
		},
		{
			"test a string that contains only one duplicate character",
			args{"bbbbb"},
			"bbbbb",
		},
		{
			"No.1 test a string that contains multiple duplicate characters",
			args{"bcabad"},
			"aba",
		},
		{
			"No.2 test a string that contains multiple duplicate characters",
			args{"cbbd"},
			"bb",
		},
		{
			"No.3 test a string that contains multiple duplicate characters",
			args{"abccbefg"},
			"bccb",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := longestPalindrome(tt.args.s); got != tt.want {
				t.Errorf("longestPalindrome() = %v, want %v", got, tt.want)
			}
		})
	}
}

💪 暴力搜索

解题思路:

回文字符串的检测方式,可以单独写一个方法来实现,下面是一个简单的实现。

// 判断一个字符串是否为回文字符串
func isPalindrome(s string) bool {
	n := len(s)
	for i := 0; i < n>>1; i++ {
		if s[i] != s[n-i-1] {
			return false
		}
	}

	return true
}

有了检测回文字符串的方法之后,我们可以按照如下思路来实现暴力搜索方法:

  1. 遍历字符串
  2. 以每个当前字符为起始位置 j,检测到字符串结尾可以形成的最大回文字符串长度 j - i
  3. 如果以当前字符开始可以形成更长的回文字符串,更新 已知的最大回文字符串长度 maxLen
  4. 重复第 2, 3 步骤,直到计算到 s[i + maxLen], 后面的不需要再计算了,因为不可能形成比当前的 maxLen 更大的子回文字符串了

实现代码

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

// 题解代码

// 暴力解法(不会超时)
func longestPalindromeBruteForce(s string) string {
	if len(s) <= 1 {
		return s
	}

	res := ""
	maxLen := 0

	for i := 0; i < len(s); i++ {
		// 从当前字符开始,直到字符串结尾
		// 检测可以形成的最大回文字符串长度
		for j := len(s); j > i+maxLen; j-- {
			if isPalindrome(s[i:j]) && j-i > maxLen {
				maxLen = j - i
				res = s[i:j]
			}
		}
	}

	return res
}

// 判断一个字符串是否为回文字符串
func isPalindrome(s string) bool {
	n := len(s)
	for i := 0; i < n>>1; i++ {
		if s[i] != s[n-i-1] {
			return false
		}
	}

	return true
}

提交超时 ?

运行单元测试:

$ go test -v -count=1 . 

输出结果如下:

=== RUN   Test_longestPalindrome
=== RUN   Test_longestPalindrome/test_an_empty_string
=== RUN   Test_longestPalindrome/test_a_single_character_string
=== RUN   Test_longestPalindrome/test_a_string_that_contains_only_one_duplicate_character
=== RUN   Test_longestPalindrome/No.1_test_a_string_that_contains_multiple_duplicate_characters
=== RUN   Test_longestPalindrome/No.2_test_a_string_that_contains_multiple_duplicate_characters
=== RUN   Test_longestPalindrome/No.3_test_a_string_that_contains_multiple_duplicate_characters
--- PASS: Test_longestPalindrome (0.00s)
    --- PASS: Test_longestPalindrome/test_an_empty_string (0.00s)
    --- PASS: Test_longestPalindrome/test_a_single_character_string (0.00s)
    --- PASS: Test_longestPalindrome/test_a_string_that_contains_only_one_duplicate_character (0.00s)
    --- PASS: Test_longestPalindrome/No.1_test_a_string_that_contains_multiple_duplicate_characters (0.00s)
    --- PASS: Test_longestPalindrome/No.2_test_a_string_that_contains_multiple_duplicate_characters (0.00s)
    --- PASS: Test_longestPalindrome/No.3_test_a_string_that_contains_multiple_duplicate_characters (0.00s)
PASS
ok      leetcode/0005.Longest-Palindromic-Substring     0.002s

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

提交测试通过

提交代码到官方后,暴力算法居然通过了 (可能是官方没有提供足够的复杂测试?),接下来,我们来分析一下,暴力搜索代码运行时间是否存在优化空间。


📝 记忆化搜索

有了前面几篇文章的基础,我们可以直接将暴力搜索改造为记忆化搜索。

下面是对应的实现代码:

// 记忆化搜索
func longestPalindromeMemo(s string) string {
	n := len(s)
	if n <= 1 {
		return s
	}

	res := ""
	maxLen := 0

	// -1: 初始状态
	// 0: 表示 s[i:j] 不是回文串
	// 1: 表示 s[i:j] 是回文串
	memo := make([][]int, n)
	for i := range memo {
		memo[i] = make([]int, n)
		for j := range memo[i] {
			memo[i][j] = -1
		}
	}

	for i := 0; i < n; i++ {
		for j := len(s); j > i+maxLen; j-- {
			if isPalindromeMemo(s, i, j-1, memo) > 0 && j-i > maxLen {
				maxLen = j - i
				res = s[i:j]
			}
		}
	}

	return res
}

// 记忆化检测回文字符串
func isPalindromeMemo(s string, i, j int, memo [][]int) int {
	if i == j {
		return 1
	}
	if memo[i][j] != -1 {
		// 代码不会执行到这里
		return memo[i][j]
	}

	// 默认 s[i:j] 不是回文字符串
	memo[i][j] = 0

	if s[i] == s[j] && (j-i <= 1 || isPalindromeMemo(s, i+1, j-1, memo) > 0) {
		memo[i][j] = 1
	}

	return memo[i][j]
}

提交测试通过

性能变慢分析

通过暴力搜索和记忆化搜索提交后的代码执行时间分析,记忆化搜索并没有像预期中的提升性能,反而下降了很多,问题出在了哪里呢?

简单分析一下记忆化搜索的递归过程,我们会发现,每次调用 (包括递归调用) isPalindromeMemo() 方法时,参数 ij 都是不同的,也就是说,虽然加了备忘录,但是根本没用到,反而因为备忘录使用了多余的内存和方法递归调用,导致代码的执行性能降低了。

举例来说,当计算字符串第一个元素 s[0] 时,会分别递归计算 s[0] -> s[1:n],执行过程如图所示:

记忆化搜索执行示例

同理,当执行到第二个元素 s[0] 时,还是会分别递归计算 s[1] -> s[1:n],以此类推,直到计算到 s[i + maxLen], 后面的不需要再计算了,因为不可能形成比当前的 maxLen 更大的子回文字符串了。


🚀 动态规划

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

状态转移方程

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

根据回文串的定义,对于一个长度大于 2 的回文子串来说,将其首尾字符去掉之后,其仍然是一个回文串。例如回文串 ababa,首尾字符去掉之后变为 bab,仍然是一个回文串。

反过来说,对于一个已知的长度大于 2 的回文串,如果在其首尾加两个相同的字符,其会构成一个新的回文串。例如回文串 bab,首尾加上字符 a 变为 ababa,变成了一个新的回文串。

根据这两个规则条件,可以推导出针对长度大于 2 的回文串的状态转移方程,其中 F(i, j) 表示字符串 s[i..j] 是否是回文串。

已知回文串状态转移方程

对应的状态转移方程如下:

$$ \begin{align*} \ F(i, j) = F(i+1, j-1) \ & \text {, } \text{if }s[i] == s[j] \ \end{align*} $$

那么对于长度小于 2 的字符串 (边界条件),又该如何处理呢?

  1. 长度为 0 或长度为 1, F(i, i) 是回文串
  2. 长度为 2, 如果 s[i] == s[i+1], F(i, i+1) 是回文串

边界情况状态转移方程

对应的状态转移方程如下:

$$ \begin{align*} \begin{cases} \ F(i, i) = true \ & \text { } \text{ } \\ \ F(i, i+1) = true \ & \text { } \text{if }s[i] == s[i+1] \end{cases} \end{align*} $$

根据这两个示例和边界条件,最后合并为如下的状态转移方程:

$$ \begin{align*} \begin{cases} \ F(i, i) = true \ & \text {, } \text{default } \\ \ F(i, i+1) = true \ & \text {, } \text{if }s[i] == s[i+1] \\ \ F(i, j) = F(i+1, j-1) \ & \text {, } \text{if }s[i] == s[j] \end{cases} \end{align*} $$

算法步骤

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

  1. 定义问题状态:计算子字符串 s[i..j] 是否是回文串
  2. 状态转移方程:如前文所示
  3. 初始化:建立状态转移表 (一个二维数组),所有长度为 1 的子串都是回文串
  4. 计算子问题:计算从当前字符开始,每次递增 1,单次可以形成的最大回文串 (向右扩展)
  5. 计算原问题:计算从当前字符开始,可以形成的最大回文串,同时更新当前已知的最大回文串长度

实现代码

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

// 动态规划
func longestPalindromeDP(s string) string {
	n := len(s)
	if n <= 1 {
		return s
	}

	// 最大回文起始位置
	begin := 0
	// 最大回文长度, 通过起始位置+长度, 可以直接返回结果
	maxLen := 1

	// dp[i][j] 表示 s[i..j] 是否是回文串
	dp := [][]bool{}
	for i := 0; i < n; i++ {
		dp = append(dp, make([]bool, n))
	}

	// 初始化:所有长度为 1 的子串都是回文串
	for i := 0; i < n; i++ {
		dp[i][i] = true
	}

	// 递推开始
	// 先枚举子串长度
	for left := 2; left <= n; left++ {
		// 枚举左边界
		for i := 0; i < n; i++ {
			// 由 left 和 i 可以确定右边界,即 j - i + 1 = left
			j := left + i - 1
			if j >= n {
				break
			}

			if s[i] != s[j] {
				dp[i][j] = false
			} else {
				// 边界情况, "aa" "aba" ...
				if j-i < 3 {
					dp[i][j] = true
				} else {
					dp[i][j] = dp[i+1][j-1]
				}
			}

			// 只要 dp[i][j] == true 成立,就表示子串 s[i..j] 是回文
			// 更新最大回文长度和最大回文起始位置
			if dp[i][j] && j-i+1 > maxLen {
				begin = i
				maxLen = j - i + 1
			}
		}
	}

	return s[begin : begin+maxLen]
}

提交通过

下面是动态规划代码的执行过程部分示例。

动态规划执行过程示例


Reference

转载申请

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