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

推荐订阅源

C
Comments on: Blog
S
Schneier on Security
Microsoft Azure Blog
Microsoft Azure Blog
T
Tor Project blog
V
Visual Studio Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Spread Privacy
Spread Privacy
月光博客
月光博客
罗磊的独立博客
Cisco Talos Blog
Cisco Talos Blog
P
Privacy International News Feed
T
Tenable Blog
阮一峰的网络日志
阮一峰的网络日志
AWS News Blog
AWS News Blog
T
ThreatConnect
博客园 - 三生石上(FineUI控件)
Recorded Future
Recorded Future
Hugging Face - Blog
Hugging Face - Blog
T
Tailwind CSS Blog
博客园 - 叶小钗
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
A
Arctic Wolf
L
LINUX DO - 最新话题
美团技术团队
大猫的无限游戏
大猫的无限游戏
I
Intezer
博客园 - 司徒正美
酷 壳 – CoolShell
酷 壳 – CoolShell
量子位
小众软件
小众软件
T
Threatpost
V
V2EX
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
宝玉的分享
宝玉的分享
The Register - Security
The Register - Security
Project Zero
Project Zero
J
Java Code Geeks
Cyberwarzone
Cyberwarzone
IT之家
IT之家
MyScale Blog
MyScale Blog
T
Threat Research - Cisco Blogs
T
The Blog of Author Tim Ferriss
腾讯CDC
S
SegmentFault 最新的问题
F
Fox-IT International blog
S
Security Archives - TechRepublic
Last Week in AI
Last Week in AI
G
GRAHAM CLULEY
M
MIT News - Artificial intelligence

蛮荆

如何获取更多的免费服务器 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 应用最佳实践 - 开篇 缓存策略和模式
动态规划简明教程 - 5
2022-06-25 · via 蛮荆

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

❓ 最大正方形

在一个由 ‘0’ 和 ‘1’ 组成的二维矩阵内,找到只包含 ‘1’ 的最大正方形,并返回其面积。

图片来源: https://leetcode.cn/

如图所示的的矩阵中,最大正方形面积为 4。

图片来源: https://leetcode.cn/

如图所示的的矩阵中,最大正方形面积为 1。

💡 解题过程

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

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

解题方法函数原型:

func maximalSquare(matrix [][]byte) int {}

单元测试

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

func Test_maximalSquare(t *testing.T) {
	type args struct {
		matrix [][]byte
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		{
			"test-1",
			args{nil},
			0,
		},
		{
			"test-2",
			args{[][]byte{
				{'0'},
			}},
			0,
		},
		{
			"test-3",
			args{[][]byte{
				{'1'},
			}},
			1,
		},
		{
			"test-4",
			args{[][]byte{
				{'0', '1'},
				{'1', '0'},
			}},
			1,
		},
		{
			"test-5",
			args{[][]byte{
				{'1', '1'},
				{'1', '1'},
			}},
			4,
		},
		{
			"test-6",
			args{[][]byte{
				{'1', '0', '1', '0', '0'},
				{'1', '0', '1', '1', '1'},
				{'1', '1', '1', '1', '1'},
				{'1', '0', '0', '1', '0'},
			}},
			4,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := maximalSquare(tt.args.matrix); got != tt.want {
				t.Errorf("maximalSquare() = %v, want %v", got, tt.want)
			}
		})
	}
}

💪 暴力搜索

解题思路:

正方形的特征为四条边长度相等,所以一个最简单可行的方案为:

  1. 从矩阵左上角开始、到右下角结束、逐个元素遍历搜索 (也就是矩阵的逐行逐列遍历)
  2. 以当前元素作为待检测正方形的左上角开始检测
  3. 边长初始化为 1
  4. 如果以当前元素作为正方形左上角、当前边长为正方形边长可以形成的 “新的正方形” 中的元素全部为 1,当前边长 + 1
  5. 重复第 3, 4 步,直到 “新的正方形” 中的元素包含 0
  6. 更新已知的最大正方形面积
  7. 开始遍历矩阵的下一个元素
  8. 重复第 1 - 7 步,直到遍历到矩阵的最后一个元素 (右下角元素)

暴力搜索示例

首先,我们从矩阵 (二维数组) 的左上角开始遍历,按照从左到右、从上到下的顺序,一直遍历到矩阵的最后一个元素。

矩阵遍历顺序

遍历到任意一个元素时,如果当前元素为 0, 直接跳过 (因为 0 无法构成有效正方形),如果当前元素为 1, 我们就以当前元素为 “新的正方形” 的左上角,不断递增边长,试图寻找面积更大的正方形。

例如遍历到矩阵中坐标为 [1, 2] 的元素时,就以 [1, 2] 这个坐标作为 “新的正方形” 的左上角开始寻找:

Tips: 每次试图寻找面积更大的正方形时,只需要检测右侧的元素和底侧的元素,也就是当前正方形的外层元素,因为内层元素已经确认过了。

只需要检测右侧的元素和底侧的元素即可

实现代码

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

// 题解代码

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

	var maxSide int
	rows, cols := len(matrix), len(matrix[0])

	// 按照矩阵逐个元素遍历
	for i := 0; i < rows; i++ {
		for j := 0; j < cols; j++ {
			if matrix[i][j] == '0' {
				continue
			}

			// 以当前坐标作为矩形左上角,检测最大正方形的边长
			// 更新已知的最大正方形面积
			maxSide = max(maxSide, search(matrix, i, j))
		}
	}

	return maxSide * maxSide
}

// 搜索以当前坐标为最上角的最大正方形边长
func search(matrix [][]byte, x, y int) int {
	rows, cols := len(matrix), len(matrix[0])

	curSide := 1

	// 边长从 2 开始递增
	for length := 2; x+length <= rows && y+length <= cols; length++ {
		for i := 0; i < length; i++ {
			// 如果当前 “新的正方形” 中右侧的元素包含 0
			// 直接返回对应的边长
			if matrix[x+i][y+curSide] == '0' {
				return curSide
			}
		}
		for i := 0; i < length; i++ {
			// 如果当前 “新的正方形” 中底侧的元素包含 0
			// 直接返回对应的边长
			if matrix[x+curSide][y+i] == '0' {
				return curSide
			}
		}

		curSide = length
	}

	return curSide
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

提交超时

运行单元测试:

$ go test -v -count=1 . 

输出结果如下:

=== RUN   Test_maximalSquare
=== RUN   Test_maximalSquare/test-1
=== RUN   Test_maximalSquare/test-2
=== RUN   Test_maximalSquare/test-3
=== RUN   Test_maximalSquare/test-4
=== RUN   Test_maximalSquare/test-5
=== RUN   Test_maximalSquare/test-6
--- PASS: Test_maximalSquare (0.00s)
    --- PASS: Test_maximalSquare/test-1 (0.00s)
    --- PASS: Test_maximalSquare/test-2 (0.00s)
    --- PASS: Test_maximalSquare/test-3 (0.00s)
    --- PASS: Test_maximalSquare/test-4 (0.00s)
    --- PASS: Test_maximalSquare/test-5 (0.00s)
    --- PASS: Test_maximalSquare/test-6 (0.00s)
PASS
ok      leetcode/0221-Maximal-Square    0.002s

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

提交超时

提交代码到官方后,给出的提示很明确:“测试用例通过了,但是耗时太长”,说明我们的算法实现是没有问题的,但是运行太慢了,说明代码的运行时间复杂度太高了,换句话说就是,代码运行时间存在优化空间。

超时原因分析

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

例如遍历到矩阵中坐标为 [1, 2] 的元素时,边长从 2 开始递增,开始检测当前元素是否可以形成边长为 2 的 “新正方形” 。

边长为 2 时形成的新正方形

如图所示,边长为 2 时,可以形成的新的正方形,将变长递增到 3。

边长为 3 时无法形成的新正方形

如图所示,边长为 3 时,无法形成的新的正方形,因为坐标 [3, 2] 和 坐标 [3, 4] 的元素值为 0

到这里,坐标为 [1, 2] 的元素就检测完了 (最大正方形面积为 4),接下来开始检测下一个元素,坐标为 [1, 3] 的元素,从边长 2 开始检测。

边长为 2 时形成的新正方形

边长为 2 时,坐标为 [1, 3] 的元素时形成的 “新正方形” 的 4 个坐标为: [1, 3] [1, 4] [2, 3] [2, 4],仔细观察你会发现,这 4 个 坐标在检测上一个坐标 [1, 2] 元素时已经检测过了。

4 个坐标被重复检测

到了这里,代码超时的原因终于找到了: 元素重复检测,也就是因为 “重叠子问题” 导致的 子问题重复计算

递归代码

下面是暴力搜索的递归版本实现代码,和迭代版本代码一样,提交后也会提示执行超时。

提交超时

// 暴力搜索递归版本 (超时)
// 超时原因: 重复检测
func maximalSquare(matrix [][]byte) int {
	if len(matrix) == 0 {
		return 0
	}

	m, n := len(matrix), len(matrix[0])
	maxSide := 0

	for i := 0; i < m; i++ {
		for j := 0; j < n; j++ {
			maxSide = max(maxSide, searchRecursive(matrix, i, j))
		}
	}

	return maxSide * maxSide
}

// 搜索以当前坐标 [x, y] 为左上角的最大正方形边长
func searchRecursive(matrix [][]byte, x, y int) int {
	if x >= len(matrix) || y >= len(matrix[0]) {
		return 0
	}
	if matrix[x][y] == '0' {
		return 0
	}

	// 搜索以 [当前坐标的底侧元素] 为坐标 的最大正常形边长
	down := searchRecursive(matrix, x+1, y)
	// 搜索以 [当前坐标的右侧元素] 为坐标 的最大正常形边长
	right := searchRecursive(matrix, x, y+1)
	// 搜索以 [当前坐标的右下侧元素] 为坐标 的最大正常形边长
	diag := searchRecursive(matrix, x+1, y+1)

	return 1 + min(down, right, diag)
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func min(x, y, z int) int {
	if x < y && x < z {
		return x
	}
	if y < z {
		return y
	}
	return z
}

📝 记忆化搜索

现在我们已经分析出了暴力搜索的超时原因 (子问题重复计算),作为优化方案,可以采用一个 额外的备忘录 来记录每个坐标的最大边长,具体来说:

  1. 遍历每个坐标元素时,需要分别计算该坐标的 底侧坐标、右侧坐标、右下侧坐标 三个坐标为左上角时,可以形成的最大正方形边长
  2. 递归过程中:
    1. 对于当前坐标,如果备忘录中已经包含其最大正方形边长,直接返回
    2. 对于当前坐标,记录其可以形成的最大正方形边长到备忘录中

下面是对应的实现代码:

// 记忆化搜索
func maximalSquare(matrix [][]byte) int {
	if len(matrix) == 0 {
		return 0
	}

	m, n := len(matrix), len(matrix[0])
	memo := make([][]int, m)
	for i := range memo {
		memo[i] = make([]int, n)
	}

	maxSide := 0
	for i := 0; i < m; i++ {
		for j := 0; j < n; j++ {
			maxSide = max(maxSide, searchMemo(matrix, i, j, memo))
		}
	}

	return maxSide * maxSide
}

func searchMemo(matrix [][]byte, x, y int, memo [][]int) int {
	if x >= len(matrix) || y >= len(matrix[0]) {
		return 0
	}
	if matrix[x][y] == '0' {
		return 0
	}

	// 如果备忘录中已经包含 [当前坐标] 的边长
	// 直接返回
	if memo[x][y] != 0 {
		return memo[x][y]
	}

	// 搜索以 [当前坐标的底侧元素] 为坐标 的最大正常形边长
	down := searchMemo(matrix, x+1, y, memo)
	// 搜索以 [当前坐标的右侧元素] 为坐标 的最大正常形边长
	right := searchMemo(matrix, x, y+1, memo)
	// 搜索以 [当前坐标的右下侧元素] 为坐标 的最大正常形边长
	diag := searchMemo(matrix, x+1, y+1, memo)

	// 更新备忘录中 [当前坐标] 的边长
	memo[x][y] = 1 + min(down, right, diag)

	return memo[x][y]
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func min(x, y, z int) int {
	if x < y && x < z {
		return x
	}
	if y < z {
		return y
	}
	return z
}

提交测试通过

代码执行过程示例

有了备忘录之后,每个坐标只会被计算一次,计算量会大大减少。

记忆化搜索执行示例

举例来说,当递归计算坐标 [1, 2] 时,会计算出其相邻的 4 个坐标可以形成的最大正方形并记录在备忘录中,当后续遍历到作标 [1, 3] 和坐标 [2, 3] 时,就不需要再次计算了,直接返回已有的备忘录中的值 (边长) 即可。


🚀 动态规划

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

状态转移方程

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

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

自顶向下时,当前元素作为 “新的正方形” 左上角

自底向上时,当前元素作为 “新的正方形” 右下角 (和自顶向下正好相反,形成镜像)

遍历矩阵元素时,对应每个当前元素,如果确定其可以构成的最大正方形面积呢?既然当前元素是作为 “新的正方形” 的右下角,那么只要需要计算出其 上侧,左侧、左上角 的正方形边长,然后取三者中的最小值,最后加一 (当前元素的边长),就是当前元素可以构成的最大正方形边长。

举例来说,当计算坐标 [1, 2] 时,只要需要计算出其 上侧,左侧、左上角 的正方形边长:

  • 上侧: [0, 2], 可以构成的最大正方形边长 = 0
  • 左侧: [1, 1], 可以构成的最大正方形边长 = 0
  • 左上角: [0, 1], 可以构成的最大正方形边长 = 0

计算坐标 1, 2 可以构成的最大正方形

所以坐标 [1, 2] 可以构成的最大正方形边长等于:

$$ \begin{align*} min(0, 0, 0) + 1 = 1 \end{align*} $$

再比如,当计算坐标 [2, 3] 时,只要需要计算出其 上侧,左侧、左上角 的正方形边长:

  • 上侧: [1, 3], 可以构成的最大正方形边长 = 1
  • 左侧: [2, 2], 可以构成的最大正方形边长 = 1
  • 左上角: [1, 2], 可以构成的最大正方形边长 = 1

计算坐标 2, 3 可以构成的最大正方形

所以坐标 [2, 3] 可以构成的最大正方形边长等于:

$$ \begin{align*} min(1, 1, 1) + 1 = 2 \end{align*} $$

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

$$ \begin{align*} F(i, j) = \begin{cases} \min( F(i-1, j), F(i, j-1), F(i-1, j-1) \ ) + 1 & \text {, } \text{matrix}[i][j] = 1 \\ 0 & \text{, } \text{matrix}[i][j] = 0 \end{cases} \end{align*} $$

当前坐标可以构成的最大正方形边长 等于其 上侧,左侧、左上角 的正方形边长中的最小值加一。

算法步骤

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

  1. 定义问题状态:以矩阵中的每个元素作为 “新的正方形” 的右下角坐标,计算可以构成的 “最大正方形” 边长
  2. 状态转移方程F(i, j) = min{ F(i−1,j), F(i,j−1), F(i−1,j−1) } + 1
  3. 初始化:建立状态转移表 (一个二维数组),数组中所有元素初始化为 0, 表示以当前元素为坐标可以构成的最大正方形边长为 0
  4. 计算子问题:计算当前坐标的 上侧,左侧、左上角 的 “最大正方形” 边长,并保存结果
  5. 计算原问题:从矩阵左上角开始一直遍历到右下角,逐个计算当前元素的 “最大正方形” 边长,同时更新目前已知的 “最大正方形” 边长 (变量 maxSide)

实现代码

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

// 动态规划
func maximalSquare(matrix [][]byte) int {
	m := len(matrix)
	if m == 0 {
		return 0
	}
	n := len(matrix[0])

	// dp[i][j]: 以 i 行 j 列为右下角所能构成的最大正方形边长
	// dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
	dp := make([][]int, m+1)
	dp[0] = make([]int, n+1)
	maxSide := 0

	for i := 1; i <= m; i++ {
		dp[i] = make([]int, n+1)

		for j := 1; j <= n; j++ {
			if matrix[i-1][j-1] == '1' {
				dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
				maxSide = max(maxSide, dp[i][j])
			}
		}
	}

	return maxSide * maxSide
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func min(x, y, z int) int {
	if x < y && x < z {
		return x
	}
	if y < z {
		return y
	}
	return z
}

提交通过

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

动态规划执行过程示例


Reference