引言
熟悉一门语言的最好办法就是实战。之前在学习 C/C++ 的时候就有这种感觉,当学习完 C/C++ 的基础语法后,可能还需要一定的训练来熟悉这门编程语言的一些特性。而我对于 C/C++ 的熟练掌握是在我用 C/C++ 实现完各种数据结构之后,我想这也同样适用于对于 Go 语言的学习。在我们直接开始利用 Go 编写一些酷炫的程序之前,可以先做一个小的 demo 来练练手。事实上这确实很有用,我在利用 Go 实现经典排序算法的过程中逐渐熟悉了 Go 的包管理方式、一些语法糖以及内存分配方式等的知识。我的全部代码可以在我的 GitHub 仓库获取,目前项目仍在开发中。在开发的同时,我决定分享部分经验在这里。
目标
我计划实现的经典排序算法有:冒泡排序(bubble sorting)、桶排序(bucket sorting)、计数排序(counting sorting)、堆排序(heap sorting)、插入排序(insertion sorting)、归并排序(merge sorting)、快速排序(quick sorting)、基数排序(radix sorting)、选择排序(selection sorting)和希尔排序(shell sorting)。这实在是太多了,所以我决定将内容分成几个章节,每个章节只会讲 3 到 4 个排序算法。
除了排序算法,我还希望实现一个统一的调度函数,能够对排序算法进行测试和评估。评估的指标包括有算法的正确性、时间复杂度和空间复杂度等等。这是一个非常宏大的工程,所以我的笔记中可能不会涉及到这部分内容。然而,我还是会做一个简单的测试文件,它会生成指定数量和范围的随机数组,并用它来测试算法的可行性,就像下面展示的那样。
$ ./gosort --help
Usage of ./gosort:
-l int
the length of the array to be sorted. (default 20)
-m int
the maximum of the array's number. (default 100)
-n string
the name of sort method used to sort the numbers. 10 names are avaible:
bubble, bucket, counting, heap, insertion, merge, quick, radix, selection, shell (default "bubble")
测试函数
万事开头难,要实现一个简单的测试函数,我们需要利用 Go 的标准库中的 flag 包,这是用于解析命令行参数的。其次,我们还需要 fmt 用来在命令行打印提示信息。最后,我们需要 strings 包来处理一些少量的字符串操作。我们可以把这个测试函数写进 main 包的 main 函数,这样在编译时就会生成我们想要的可执行文件。在设想中,生成随机数组的函数 RandArr() 和选择排序算法的函数 SelMethod() 都放在 utils 包当中,而 go.mod 指定 module gosort 以利用相对路径引包。
package main
import (
"flag"
"fmt"
"gosort/utils"
"strings"
)
在这里我们让 flag 的参数指定和解析在 init() 函数当中进行。在 Go 当中, init() 会先于 main() 函数运行。这样做的好处是避免每次调用 main() 函数时都需要重新指定参数。
func init() {
flag.IntVar(&length, "l", 20, "the length of the array to be sorted.")
flag.IntVar(&maximum, "m", 100, "the maximum of the array's number.")
flag.StringVar(&name, "n", "bubble", "the name of sort method used to sort the numbers. 10 names are avaible:\n"+strings.Join(avaible, ", "))
flag.Parse()
}
之后就是 main() 函数流程,我们首先根据指定的 length 和 maximum 来生成一个随机数组,这借由 utils 包中的 RandArr() 函数来实现。然后我们根据指定的 name 来选择我们的排序算法,这借由 utils 包中的 SelMethod() 函数来实现。这两个函数的流程比较简单,感兴趣的朋友可以到我的 GitHub 仓库去看源码,我就不在这里贴代码了。值得一提的是,SelMethod() 实现了一个简单的函数闭包,尽管它并不维护某个共享变量。以下是我的 main() 函数。
func main() {
fmt.Printf("Generating the random array...")
arr := utils.RandArr(length, maximum)
fmt.Printf("Success!\n")
fmt.Printf("The random array is:\n")
fmt.Printf("%v\n", arr)
method := utils.SelMethod(name)
method(arr)
fmt.Printf("Success!\n")
fmt.Printf("The sorted array is:\n")
fmt.Printf("%v\n", arr)
}
可以看到,这个函数十分的简单,以至于可以使用“简陋”来形容它。因为它只忠实的返回排序过后的结果,并不做排序结果正确性的检验,也不对排序的效率进行评估。但是对于我们刚刚开始编写排序算法,只需要这样的一个简单的测试函数就足够用了。
排序函数
接下来我们可以一门心思的写我们的排序算法了。今天在这里我将会介绍最经典的冒泡排序(bubble sorting)、选择排序(selection sorting)和插入排序(insertion sorting)。这三种排序算法相对比较简单和易于实现,因此我们可以少花一些精力在他们的讲解上面。
冒泡排序
冒泡排序经历 N 次遍历。第 1 次遍历将最大的元素置于数列的倒数第 1 位,第 2 次遍历将次大的元素置于数列的倒数第 2 位,第 3 次遍历将第三大的元素置于数列的的倒数第 3 位...依次类推,直到全部元素排序完毕。由于每第 i 次遍历都要逐一比较剩下的 N-i 个元素,因此时间复杂度是 。
func BubbleSort(arr []int) {
length := len(arr)
for i := 0; i < length; i++ {
for j := 0; j < length-i-1; j++ {
if arr[j] > arr[j+1] {
arr[j], arr[j+1] = arr[j+1], arr[j]
}
}
}
}
选择排序
选择排序相较冒泡排序减少了交换元素的时间,但它需要开辟一个用于缓存最值索引的变量,事实上是通过提升空间复杂度来降低时间的开销。选择排序同样要经过 N 次遍历且第 i 次遍历时需要逐一比较剩下的 N-i 个元素,因此它的时间复杂度仍然是 。
func SelectionSort(arr []int) {
length := len(arr)
for i := 0; i < length-1; i++ {
min := i
for j := i + 1; j < length; j++ {
if arr[min] > arr[j] {
min = j
}
}
arr[i], arr[min] = arr[min], arr[i]
}
}
插入排序
插入排序维护一个已排序好的子序列并不断将新的元素填入这个已排序好的子序列。每当新填入元素,子序列都需要重新进行排序。与冒泡排序和选择排序相比,它将对原始数据的排序任务划分为规模从小增长到大的子任务,并利用子序列的已序化的性质来提高其排序效率。然而,由于需要对子序列进行多次重复的排序,其时间复杂度仍然是 。
func InsertionSort(arr []int) {
length := len(arr)
for i := 0; i < length; i++ {
for j := 0; j < i; j++ {
if arr[j] > arr[i] {
arr[i], arr[j] = arr[j], arr[i]
}
}
}
}
总结
在这篇文章中,我实现了一个简单的测试函数用于调度之后我们要写的排序算法,虽然十分简陋但已经足够使用了。我们还用 Go 实现了冒泡排序(bubble sorting)、选择排序(selection sorting)和插入排序(insertion sorting),回顾了他们的算法思想并训练了与 Go 的相关基础语法。在接下来的文章,我将继续实现剩下的排序算法。敬请期待!