用Go写算法:求最小可用自然数

77次阅读

共计 2831 个字符,预计需要花费 8 分钟才能阅读完成。

前言
前一段时间在 reddit 上看到有人推广一篇名为 GopherCon 2018 – Demystifying Binary Search Tree Algorithms 的博客,博客中列举了传统大学里学习算法的种种弊端,并强调了用 Go 实现算法是多么简单有趣,然后拿二叉树举了个例子。读完这篇博客以后,我不得不说,真心没看出来 Go 写算法的优势在哪里。但是,配图确实萌翻了,下面盗图一副。

虽然不太信服,还是抱着试试看的心态 Go 做了一些算法实现的尝试。这里我从 “Pears of Functional Algorithms Design” 里拿了一道题:给定一个无序自然数数组 A,求出不在 A 中的最小自然数,约束条件如下:

A 中的元素个数是有限的,每个元素都是自然数,并且互不相同(自然数包含 0 和 正整数)。
假设 A 中元素的大小在 [0, inf] 之间。

Note: 由于计算机本身的限制,我们这里假设 inf = 2^31-1
举几个例子:

A = [1,2,9,4], output = 0
A = [0,1,2], output = 3
A = [0,1,16,8,4,2], output = 3
A = [], output = 0

首先我们声明一个函数来表述这个问题:
func f(A []int) int
然后,我们思考如何解决这个问题。
此处建议思考两分钟,如果身边有笔有纸,建议写写画画。
方案一
首先映入脑海的方法是:声明一个 bool 数组 B,将 A 中元素作为下标,将 B 对应的元素置为 true,遍历 B,返回第一个 false 的下标。这个方法显然不靠谱,在某些条件下,它会使用大量内存。为了更直观,这里把具体实现贴出来:
func f(A []int) int {
maxElem := max(A) // 函数 max 用于获取数组的最大值
B := make([]bool, maxElem+1, maxElem+1)
for _, elem := range A {
B[elem] = true
}
for idx, val := range B {
if !val {
return idx
}
}
return -1 // compile error without it
}
在极限情况下,例如 A = [2^31-1],上面代码中 B 会占用 (2^31-1)/8 ≈ 2^28 byte = 256 Mbyte 内存,显然很不合理。
方案二:引入排序函数
如果引入一个库函数呢,比如 sort。先用 sort 给 A 排序,然后从 0 开始递增,找到第一个不在 A 中的元素。这个方法可以解决问题,假设 n=len(A),排序时间 O(nlogn), 检索效率 O(n)。我们看一下代码实现:
func f(A []int) int {
sort.Ints(A)
for idx, val := range A {
if idx != val {
return idx
}
}
return len(A)
}
这是一个很常规的方法。这里的实现有两个地方需要注意:

if idx != val 避免了创建额外一个变量和对其进行管理的成本

return len(A) 优雅地处理了边界情况

但是引入 sort 的代价也很明显:O(nlogn) 的时间复杂度比较高。有没有时间复杂度为 O(n) 的方法呢?
我们再次思考这个问题本身。对于乱序状态下的 A,对于 [0, inf] 的每一个自然数,搜索的耗时都是 O(n),最多搜索 n 个自然数。要达到总体复杂度为 O(n),一个方法是将搜每个自然数搜索的耗时降低到 O(1),另一个方法是 只搜索 O(1) 个自然数。后一个方法看起来有点难以实现,但是前一个方法只需要我们引入一个 hashmap。
而 Go 语言内置的 map 就是基于 hashmap。
方案三:引入 map
引入 map 以后,大致步骤是:

遍历 A,填充 map。时间和空间复杂度都是 O(n)
遍历 [0, inf],找到第一个不在 map 中的元素

代码如下:
func f(A []int) int {
mapping := make(map[int]struct{}, len(A))
for _, val := range A {
mapping[val] = struct{}{}
}
for i := 0; ; i++ {
if _, ok := mapping[i]; !ok {
return i
}
}
return -1 // compile error without it
}
该方法的时间和空间复杂度都达到了 O(n), 理论上达到了最优。但是从实践的角度考虑,hashmap 对空间的利用略超出了 O(n),超出的范围取决于负载因子。严格来说,这个方法并不是最优 O(n) 解法。那么有没有更优的解法呢?答案是有,但是需要一些想象力,或者归纳能力。
方案四:拿出纸和笔,找规律
我们可以随意举出几个例子,对数组和结果进行分析。假设我们对数组已经排序,会有以下几种情况:
情况一:

A = [1,2,4,9], output = 0
A = [2,3,4,9], output = 0
A = 任意不包含 0 的数组,output = 0

情况二:

A = [], output = 0
A = [0], output = 1
A = [0,1], output = 2
A = [0,1,2], output = 3
A = [0,1,2,…,n-1], output = n

情况三:

A = [0,2,3,…,n-1], output = 1
A = [0,1,2,100,…,n-1], output = 3

对于所有情况,我们会发现一个共性:output <= n。为什么会出现这种情况?我们不妨逆向思考一下。假设有一个连续的自然数数组 [0,1,2,3,…n-1]:

不改变这个数组,则返回值是 n;
要改变一个元素,则必须从中取出一个自然数 i,然后替换成一个大于 n-1 的自然数。改变后的数组返回 i;
改变多个元素,上一条仍然成立;
交换任意两个元素,不影响返回值。

我们回到刚才提到的共性 output <= n,基于这个共性,我们可以认为数组 A 中所有大于 n 的数是无意义的。换句话说,我们只关心 A 中 <= n 的数字。
所以,解决方法来了:我们可以创建一个长度为 n+1 的 bool 数组 B,遍历 A 中所有元素 i 作为下标,设置 B[i]=true;然后找出 B 中第一个 false 的下标。代码如下:
func f(A []int) int {
n := len(A)
B := make([]bool, n+1, n+1)
for _, elem := range A {
if elem <= n {
B[elem] = true
}
}
for idx, val := range B {
if !val {
return idx
}
}
return -1 // compile error without it
}
理论上这个方法的时间和空间复杂度都是 O(n),在实际运行时,都要略优于基于 map 的实现。
小结
有时候,一支笔,一张纸,可能比想破脑袋都好用(高智商人士当我没说)。
开头我们提到一篇博客对 Go 写算法的赞美之词。经过这波实践,依旧没有发现比 C++ 更简洁、更优美(手动狗头)。Go 简洁的语法让每一行都看起来那么短,但强制的大括号增加了代码行数,综合起来和 C ++ 写出来的效果旗鼓相当吧。
相关链接:

GopherCon 2018 – Demystifying Binary Search Tree Algorithms
Go maps in action

扫码关注微信公众号“深入 Go 语言”

正文完
 0