惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Spread Privacy
Spread Privacy
T
Threat Research - Cisco Blogs
C
Cyber Attacks, Cyber Crime and Cyber Security
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Cloudbric
Cloudbric
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
SecWiki News
SecWiki News
Schneier on Security
Schneier on Security
人人都是产品经理
人人都是产品经理
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
WordPress大学
WordPress大学
S
Secure Thoughts
V
Visual Studio Blog
Microsoft Azure Blog
Microsoft Azure Blog
Attack and Defense Labs
Attack and Defense Labs
T
The Blog of Author Tim Ferriss
Vercel News
Vercel News
The Last Watchdog
The Last Watchdog
L
LINUX DO - 最新话题
T
Tailwind CSS Blog
C
Cybersecurity and Infrastructure Security Agency CISA
Scott Helme
Scott Helme
博客园 - Franky
I
InfoQ
Cisco Talos Blog
Cisco Talos Blog
Stack Overflow Blog
Stack Overflow Blog
MongoDB | Blog
MongoDB | Blog
N
Netflix TechBlog - Medium
Help Net Security
Help Net Security
M
MIT News - Artificial intelligence
GbyAI
GbyAI
B
Blog
K
Kaspersky official blog
博客园 - 【当耐特】
AWS News Blog
AWS News Blog
O
OpenAI News
A
About on SuperTechFans
F
Fortinet All Blogs
PCI Perspectives
PCI Perspectives
G
Google Developers Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
A
Arctic Wolf
酷 壳 – CoolShell
酷 壳 – CoolShell
Application and Cybersecurity Blog
Application and Cybersecurity Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
C
CXSECURITY Database RSS Feed - CXSecurity.com
Apple Machine Learning Research
Apple Machine Learning Research
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Google DeepMind News
Google DeepMind News

蛮荆

如何获取更多的免费服务器 Kubernetes 调度器队列 - 设计与实现 Kubernetes 调度器 - 核心流程 Kubernetes Networking Model & CNI Kubernetes 控制器管理总结 Kubernetes CronJob 设计与实现 Kubernetes Job 设计与实现 Kubernetes HPA 设计与实现 Kubernetes Deployment 滚动更新实现原理 Kubernetes GC 设计与实现 Kubernetes Pod 驱逐 - 设计与实现 Kubernetes Daemonset 设计与实现 Kubernetes ReplicaSet 设计与实现 Kubernetes EndPoint 设计与实现 Kubernetes Informer 设计与实现 降本增效之应用优化 (三) 日志存储与检索 Kubernetes Pod 设计与实现 - 创建流程 Kubernetes 探针设计与实现 Unix 编程艺术名句摘录 Kubernetes - CRI 概述 Golang 编译速度为什么这么快? Kubernetes Pod 设计与实现 - Pause 容器 Kubernetes - kube-proxy 代理模式工程优化 Kubernetes 应用最佳实践 - 优雅关闭长连接 Kubernetes Service 类型和会话亲和性 Kubernetes 为什么需要 Ingress Kubernetes 架构 - 控制平面和数据平面 降本增效之应用优化 (二) 大报表 Go 语言如何获取 CPU 利用率 降本增效之应用优化 (一) Redis 业务规则引擎演变过程简述 微服务中的熔断算法 漏桶算法和令牌桶算法 jsonparser 为什么比标准库的 encoding/json 快 10 倍 ? zap 高性能设计与实现 HTTP Router 算法演进 fastcache 高性能设计与实现 Web 常见的三个安全问题 ants Code Reading Go 线程安全 map 方案选型 布隆过滤器 死锁、活锁、饥饿、自旋锁 sync.Pool Code Reading Go 内存管理概述 Go netpoll Code Reading goroutine 泄漏与检测 time/Timer Code Reading GMP Scheduler Code Reading Go channel 的 15 条规则和底层实现 为什么 Linux “一切皆文件” context.Context Code Reading runtime/HACKING.md Goland 最佳实践 互联网开发与金庸武学 为什么 Redis 6.0 引入多线程模型? Kubernetes 应用最佳实践 - 金丝雀发布 容器中如何正确配置 GOMAXPROCS ? singleflight Code Reading sync.Map Code Reading sync.Cond Code Reading sync.WaitGroup Code Reading sync.RWMutex Code Reading sync.Mutex Code Reading sync.Once Code Reading Go 无锁编程 sync/atomic Code Reading goroutine 交替打印奇偶数 GODEBUG Go 并发模式 Go 汇编 UUID 通用技术选型 Kubernetes 应用最佳实践 - 水平自动伸缩 Go 高性能 Tips fasthttp 为什么比标准库 net/http 快 10 倍 ? 技术文章配图指南 ChatGPT 初体验 Docker 网络原理概览 iptables 的五表五链 Kubernetes 应用最佳实践 - 亲和性和污点容忍度 Go 的反射与三大定律 Docker 官方提供的最佳实践 Go 语言内置的设计模式 HTTP1 到 HTTP3 的工程优化 Kubernetes 应用最佳实践 - Sidecar 模式 Kubernetes 应用最佳实践 - init 容器和钩子函数 为什么 recover 必须在 defer 中调用? 为什么 defer 的执行顺序和注册顺序不同? Go map 设计与实现 Go 切片扩容底层实现 Go 语言中的零拷贝 Go Delve 云原生和边缘计算简介 Kubernetes Pod 服务质量等级 Kubernetes 应用最佳实践 - 探针 Kubernetes 应用最佳实践 - 资源请求和限制 CDN 原理 Kubernetes 应用最佳实践 - 开篇 缓存策略和模式 Go 内存模型 Kubernetes 核心概念
动态规划简明教程 - 3
2022-06-15 · via 蛮荆

2022-06-15 算法 动态规划 LeetCode

❓ 最长回文子串

给你一个字符串 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
}

提交超时 ?

运行单元测试:

输出结果如下:

=== 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