golang 数组组合成最小的整数_用Go写算法:求最小可用自然数

前言

前一段时间在 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++写出来的效果旗鼓相当吧。

相关链接:

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值