用 Go 实现经典排序算法(三) | 青训营

104 阅读10分钟

引言

在前面我们已经实现了六种基于比较的排序算法:冒泡排序(bubble sorting)、选择排序(selection sorting)、插入排序(insertion sorting)、希尔排序(shell sorting)、归并排序(merge sorting)和快速排序(quick sorting)。本节我们继续实现剩下的非基于比较的四种排序:桶排序(bucket sorting)、堆排序(heap sorting)、计数排序(counting sorting)、基数排序(radix sorting)。在这一节,我们会详细看到非基于比较的排序算法具体是怎样的。

排序算法

在今天要实现的四种排序算法当中:桶排序(bucket sorting)采用了分治思想;堆排序(heap sorting)基于堆的数据结构而实现;计数排序(counting sorting)利用类似于哈希表的数据结构实现线性时间复杂度的排序;基数排序(radix sorting)利用数字自身的具有的特征来对其进行排序。

桶排序

桶排序注意到这样一个事实:随着数组规模的增大,排序所需要的时间越来越长。桶排序希望将原始数组分成不同的组,组间的数据大小关系是确定的。一旦我们得到了这样的一些组,由于组间的数据大小关系确定,我们只需要分别对组内的数进行排序即可。因为要排序的数据减少了,所以在一定程度上可以减少排序的平均时间。要实现这一想法,关键是确定恰当的映射函数,将原始数据均匀的分配至不同的桶当中。

func BucketSort(arr []int) {
    var min, max int
    for i := 0; i < len(arr); i++ {
        if arr[i] > max {
            max = arr[i]
        }
        if arr[i] < min {
            min = arr[i]
        }
    }
    buckets := make([][]int, 10)
    for _, v := range arr {
        i := ((v - min) / (max - min) * 9)
        buckets[i] = append(buckets[i], v)
    }
    j := 0
    for _, bucket := range buckets {
        if len(bucket) > 0 {
            BubbleSort(bucket)
            copy(arr[j:], bucket)
            j += len(bucket)
        }
    }
}

对于传入的数组,首先遍历一遍找寻数据的最大值和最小值,方便之后我们构建映射函数。之后构建二维数组用于将数据分组。利用隐射函数 (vmin)/(maxmin)9(v - min) / (max - min) * 9 来获得传入数值 v 所在的组的索引。之后数据入组。当所有数据分配完毕后,先对所有的组进行冒泡排序,然后把排序结果复写到原始数组当中,在这一过程当中维护一个指针 j 来确定将数组写入原始数组的什么地方。

堆排序

堆排序是利用堆这种数据结构所设计的算法。堆是一个完全二叉树,同时满足堆的性质:即子节点的键值或索引总是小于(或大于)其父节点。若子节点的键值始终大于其父节点,则称之为小顶堆;若子节点的键值始终小于其父节点,则称之为大顶堆。堆的性质决定了它可以用于排序算法当中。

对于已经建立好的大顶堆,堆排序遵循如下流程:

  • 将堆首(最大值)与堆尾互换位置。
  • 堆的尺寸缩小 1 并让堆顶“下沉”。
  • 重复上述步骤直到堆的尺寸为 1。

对于传入的数组,我们无需开辟新的数据结构来作为堆,而可以利用堆的性质来使得数组这一线性结构隐式具有非线性的树结构。这一性质描述如下:对于任何完全二叉树,假设各个节点的索引从上至下,从左至右地标为 0, 1, 2... 。则对于任何节点 ii,他的左子节点的索引为 2i+12i+1,右子节点的索引为 2i+22i+2

完全二叉树.png

利用这一性质,我们无需像传统的面向对象编程那样先设计“树“类再为其设计各种类方法而可以直接利用数组的索引来隐式实现堆的这一结构。这主要体现在我们“下沉“的操作当中。

func sink(arr []int, index int, length int) {
    max := 0
    for left := index*2 + 1; left < length; left = index*2 + 1 {
    right := left + 1
        if right < length && arr[right] > arr[left] {
            max = right
        } else {
            max = left
        }
        if arr[index] < arr[max] {
            arr[index], arr[max] = arr[max], arr[index]
            index = max
        } else {
            break
        }
    }
}

下沉 sink() 是这样一种操作:传入节点的索引 index 以及堆的尺寸 length,sink() 会将当前索引所指的元素与其左、右子节点进行比较,将左、右子节点中的较大值节点与当前节点交换并更新索引以追踪所指节点,sink() 会重复执行如上操作,直至无法将当前节点与其左、右子节点中任何节点进行交换或者节点的左子节点超过了 length(即当前节点为叶子节点)。

下沉 sink() 会带来如下效果:

  • 效果一:若原堆已经为大顶堆,则所指节点不会下沉。
  • 效果二:若原堆非大顶堆,但除堆顶节点的其他节点都满足大顶堆的性质,则下沉堆顶节点会得到大顶堆。
  • 效果三:若原堆非大顶堆,且除堆顶节点的其他节点有不满足大顶堆性质的,则下沉任何节点都不一定能够得到大顶堆。

基于 sink() 的性质和实现,我们最终实现的堆排序如下展示。

func HeapSort(arr []int) {
    length := len(arr)
    for i := length/2 - 1; i >= 0; i-- {
        sink(arr, i, length)
    }
    for i := length - 1; i >= 0; i-- {
        arr[0], arr[i] = arr[i], arr[0]
        sink(arr, 0, i)
    }
}

直观地,它总共进行了两次 for 循环。第一次循环用于建堆,通过对所有非叶子节点的节点,从下至上地依次下沉,我们可以确保得到一个建立好的最大堆。这是因为,当我们回顾 sink() 的效果二,我们会发现:当我们想使得到堆是大顶堆,则我们需保证除堆顶节点的其他节点都满足大顶堆性质,也即需使左、右子树都是大顶堆。而我们想要左、右子树都是大顶堆,可以假定左、右子树各自的左、右子树已经是大顶堆,这样我们只需对左、右子树的堆顶节点下沉即可。这样的思路可以一直递推,并建立一个依赖链。

下沉节点 0 得到大顶堆 依赖于\xrightarrow{依赖于} 下沉节点 1, 2 得到大顶堆 依赖于\xrightarrow{依赖于} 下沉节点 3, 4, 5, 6 得到大顶堆 依赖于\xrightarrow{依赖于} 下沉节点 7, 8, 9, 10, 11, 12, 13, 14 得到大顶堆 ...\xrightarrow{...} 下沉倒数第二层的所有节点

因此,从下至上地依次下沉所有的非叶子节点的节点可以确保得到一个建立好的大顶堆,此时堆顶元素即为数组的最大值。堆建立后,进入第二个循环。第二次循环执行堆排序的主体流程:交换堆顶与堆尾后下沉堆顶节点,然后堆的尺寸减 1(用 i 来存储堆的尺寸并传给 sink() 函数)。

计数排序

计数排序的想法十分地简单。它利用类似于哈希表的数据结构实现了稳定的线性时间排序。计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。计数排序要求输入的数据必须是有确定范围的整数。计数排序的基本思想是:建立一个长度为 N 的数据,N 为数据的最大值,之后统计数组中每个值为 i 的的元素的出现的次数,存入数组的第 i 项。遍历统计后的数组,将 i 填充 M 次,M 的值与数组中的第 i 项相等。

func CountingSort(arr []int) {
    length := countLen(arr)
    counts := make([]int, length)
    for _, num := range arr {
        counts[num] += 1
    }
    sorted := 0
    for i := 0; i < length; i++ {
        for counts[i] > 0 {
            arr[sorted] = i
            sorted += 1
            counts[i] -= 1
        }
    }
}

func countLen(arr []int) int {
    var max int
    for _, num := range arr {
        if num > max {
            max = num
        }
    }
    return max + 1
}

计数排序的速度相当快,非常适合于数据范围较小且分布均匀的整数序列。但是,计数排序需要大量内存,当数据范围很大或不均匀时表现很差。但是计数排序胜在其稳定性,与其他的算法不同,计数排序在最好情况和最坏情况下往往具有着相近的时间复杂度。

基数排序

基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

基数排序有两种方式:LSD(最低有效位优先)和 MSD(最高有效位优先)。LSD 的排序方式是从元素的最低位开始,依次进行一轮排序,直到最高位结束。MSD 的排序方式是从元素的最高位开始,依次进行一轮排序,然后根据每轮排序的结果将元素分成若干组,再对每组元素递归地进行 MSD 排序,直到每组只有一个元素为止。

考虑到计算机中的数据都是由二进制存储的,利用二进制的位 bit 而不是十进制的个、十、百位来进行基数排序显然是一种更加直观和自然的选择。在 Go 中 int 类型的位数是不固定的,取决于编译时所使用的架构,因此有必要先利用强制类型转换将 int 转换为 int64。现在的架构尚还没有超过 64 位的,我们有理由相信将 int 转换为 int64 将不会损失原始数据的精度。同样的,在返回结果时我们同样需要将 int64 转换为 int 类型。以下是基数排序算法的具体实现。

import (
	"bytes"
	"encoding/binary"
)

const digit = 8
const maxbit = -1 << 63

func radixsort(data []int64) {
    buf := bytes.NewBuffer(nil)
    ds := make([][]byte, len(data))
    for i, e := range data {
        binary.Write(buf, binary.LittleEndian, e^maxbit)
        b := make([]byte, digit)
        buf.Read(b)
        ds[i] = b
    }
    countingSort := make([][][]byte, 256)
    for i := 0; i < digit; i++ {
        for _, b := range ds {
            countingSort[b[i]] = append(countingSort[b[i]], b)
        }
        j := 0
        for k, bs := range countingSort {
            copy(ds[j:], bs)
            j += len(bs)
            countingSort[k] = bs[:0]
        }
    }
    var w int64
    for i, b := range ds {
        buf.Write(b)
        binary.Read(buf, binary.LittleEndian, &w)
        data[i] = w ^ maxbit
    }
}

这段代码中,首先定义了一些常量和变量:

  • maxbit 是常量,它表示 64 位均是 1 的数,也即 -2^63。
  • digit 也是常量,它表示每个 int64 占用的字节数,也就是 8。
  • buf 是一个 bytes.Buffer 类型的变量,它用于存储和读取二进制数据。
  • ds 是一个[][]byte类型的切片,它用于存储每个数字,每个数字由 8 字节表示。
  • countingSort 是一个[][][]byte类型的切片,它以一个字节的 256 个值为索引,以一个 ds 为值。

接下来,代码进入第一个 for 循环,它遍历 data 中的每个元素,将其转换为字节表示,并存储在 ds 中。为了避免负数的影响,每个元素都与 maxbit 进行异或(xor)运算,这相当于将每个位取反。

然后,代码进入第二个 for循环,它从 0 到 digit-1 遍历每个字节位。在每次循环中,它执行以下操作:

  • 遍历 ds 中的每个字节切片 b,并根据 b[i] 的值将 b 放入 countingSort[b[i]] 中。这相当于按照第 i 个字节位的值对 ds 进行分配。
  • 初始化一个变量 j 为 0,并遍历 countingSort 中的每个切片 bs。将 bs 复制到 ds[j:] 中,并更新 j 的值。然后将 countingSort[k] 清空。这相当于按照第 i 个字节位的值对ds进行收集,并保持相对顺序不变。
  • 这样,经过一次循环后,ds中的元素就按照第 i 个字节位的值进行了排序。

最后,代码进入最后的 for 循环,它遍历 ds 中的每个字节切片 b,并将其写入 buf 中。然后从 buf 中读取一个 int64 类型的变量 w,并将其与 maxbit 进行异或运算,恢复原来的数字。然后将 w 赋值给data[i]。

这样,经过 digit 次循环后,data 中的元素就按照从低到高的每一位进行了排序。

总结

到目前为止,我们的“用 Go 实现经典排序算法“的十个经典排序算法就全部介绍完了:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、桶排序、堆排序、计数排序、基数排序。其中既有基于比较的排序,又有基于非比较的排序,他们的实现方法各不相同,具有各自的优缺点,分别适用于不同的应用场景。经过对这十个排序算法的实现,我们对 Go 语言的基本语法有了更加熟练的掌握,这为我们在之后利用 Go 语言实现项目打下了良好的基础。