GO排序算法学习笔记 | 青训营

118 阅读5分钟

经典排序算法

插入排序

插入排序(Insertion Sort)是一种简单直观的排序算法。该算法通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序的基本思想是将待排序的序列分为两部分:已排序部分和未排序部分。初始时,已排序部分只有一个元素,即序列的第一个元素。然后,依次将未排序部分的元素插入到已排序部分的适当位置,直到所有元素都插入完毕,形成有序序列。

插入排序的具体步骤如下:

  1. 从第一个元素开始,该元素可以认为已经被排序。
  2. 取出下一个元素,在已排序的序列中从后向前扫描。
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置。
  4. 重复步骤 3,直到找到已排序的元素小于或等于新元素的位置。
  5. 将新元素插入到该位置后。
  6. 重复步骤 2~5,直到排序完成。

插入排序的时间复杂度为O(n^2),其中n是待排序序列的长度。它是一种稳定的排序算法,适用于小规模数组或部分有序的数组。最大的优点是当元素有序时,它的时间复杂度是O(n)。

func insertionSort(arr []int) {
    n := len(arr)
    for i := 1; i < n; i++ {
        key := arr[i]
        j := i - 1
        for j >= 0 && arr[j] > key {
            arr[j+1] = arr[j]
            j = j - 1
        }
        arr[j+1] = key
    }
}

快速排序

快速排序(Quick Sort)是一种常用的排序算法,也是一种分治算法。它通过选择一个基准元素,将待排序序列分割成两个子序列,其中一个子序列中的所有元素都小于基准元素,另一个子序列中的所有元素都大于基准元素。然后,对这两个子序列分别进行递归排序,最终将整个序列排序完成。

快速排序的具体步骤如下:

  1. 选择一个基准元素(pivot),一般选择序列的第一个元素或者随机选择一个元素。
  2. 将序列中小于等于基准元素的元素放在基准元素的左边,大于基准元素的元素放在基准元素的右边。这个过程称为分区(Partition)。
  3. 对分区后的两个子序列递归地应用快速排序,直到子序列的长度为1或者0,即已经有序。
  4. 将所有子序列的排序结果合并,即得到最终排序的序列。

快速排序的关键在于分区的过程,一般采用以下方法进行分区:

  1. 设置两个指针,分别指向序列的第一个元素和最后一个元素。
  2. 从左向右移动左指针,直到找到一个大于基准元素的元素。
  3. 从右向左移动右指针,直到找到一个小于基准元素的元素。
  4. 如果左指针仍然在右指针的左侧,交换左指针和右指针所指向的元素。
  5. 重复步骤 2~4,直到左指针超过右指针。
  6. 将基准元素和左指针所指向的元素交换位置,完成一次分区。

快速排序的时间复杂度为O(nlogn),其中n是待排序序列的长度。它是一种原地排序算法,不需要额外的存储空间。然而,快速排序的性能取决于选择的基准元素,如果选择不合适的基准元素,可能导致最坏情况下的时间复杂度达到O(n^2)。为了避免最坏情况的发生,可以采用随机选择基准元素或者使用三数取中法等方法来选择基准元素。
快速排序的优点是没有额外的空间占用,并且平均复杂度好,但注意它是不稳定的排序算法,如果需要稳定的场景,不能使用快速排序。

func quickSort(arr []int, low, high int) {
    if low < high {
        pivot := partition(arr, low, high)
        quickSort(arr, low, pivot-1)
        quickSort(arr, pivot+1, high)
    }
}

func partition(arr []int, low, high int) int {
    pivot := arr[high]
    i := low - 1
    for j := low; j < high; j++ {
        if arr[j] <= pivot {
            i++
            arr[i], arr[j] = arr[j], arr[i]
        }
    }
    arr[i+1], arr[high] = arr[high], arr[i+1]
    return i + 1
}

堆排序

堆排序(Heap Sort)是一种基于堆数据结构的排序算法。它利用堆的性质进行排序,通过将待排序序列构建成一个最大堆(或最小堆),然后反复从堆顶取出最大(或最小)元素,将其放置到已排序部分的末尾,最终得到有序序列。

堆排序的具体步骤如下:

  1. 构建一个最大堆(或最小堆),将待排序序列转换成堆。这个过程称为建堆(Build Heap)。
  2. 将堆顶元素(最大元素或最小元素)与堆的最后一个元素交换位置,然后将堆的大小减1。
  3. 对新的堆顶元素进行堆调整(Heapify),使其满足堆的性质。
  4. 重复步骤 2~3,直到堆的大小为1,即所有元素都被取出并放置到已排序部分。
  5. 反转已排序部分,得到最终的有序序列。

堆排序的时间复杂度为O(nlogn),其中n是待排序序列的长度。它是一种原地排序算法,不需要额外的存储空间。像很多语言的优先队列实际上就是堆,和快排一样,堆排序也是一种不稳定的排序算法。

func heapSort(arr []int) {
    n := len(arr)

    // 建堆
    for i := n/2 - 1; i >= 0; i-- {
        heapify(arr, n, i)
    }

    // 排序
    for i := n - 1; i >= 0; i-- {
        arr[0], arr[i] = arr[i], arr[0] // 将堆顶元素与末尾元素交换
        heapify(arr, i, 0) // 对剩余部分进行堆调整
    }
}

func heapify(arr []int, n, i int) {
    largest := i // 假设当前节点为最大节点
    left := 2*i + 1 // 左子节点索引
    right := 2*i + 2 // 右子节点索引

    // 如果左子节点大于最大节点
    if left < n && arr[left] > arr[largest] {
        largest = left
    }

    // 如果右子节点大于最大节点
    if right < n && arr[right] > arr[largest] {
        largest = right
    }

    // 如果最大节点不是当前节点,则交换最大节点与当前节点,并递归调整交换后的子树
    if largest != i {
        arr[i], arr[largest] = arr[largest], arr[i]
        heapify(arr, n, largest)
    }
}

实例:GO中使用堆实现一个优先队列。
container/heap包定义了堆的相关接口和方法,只需要定义出自己的队列,然后实现相应的方法就可以实现优先队列,在一些Leetcode问题中,如果需要优先队列这种数据结构,就要自己实现Push,Pop,Swap,Len和Less方法。

type Item struct {
	value    interface{} // 元素的值
	priority int         // 元素的优先级
	index    int         // 元素在堆中的索引
}

type PriorityQueue []*Item

func (pq PriorityQueue) Len() int {
	return len(pq)
}

func (pq PriorityQueue) Less(i, j int) bool {
	// 优先级越高,Less方法返回值越小
	return pq[i].priority < pq[j].priority
}

func (pq PriorityQueue) Swap(i, j int) {
	pq[i], pq[j] = pq[j], pq[i]
	pq[i].index = i
	pq[j].index = j
}

func (pq *PriorityQueue) Push(x interface{}) {
	item := x.(*Item)
	item.index = len(*pq)
	*pq = append(*pq, item)
}

func (pq *PriorityQueue) Pop() interface{} {
	old := *pq
	n := len(old)
	item := old[n-1]
	item.index = -1
	*pq = old[:n-1]
	return item
}

这里主要注意的是Push和Pop方法,插入时设定好item的index,然后append到最后;Pop时删除掉最后一位。

实际场景benchmark

  • 完全随机 Random
  • 有序和逆序 sorted/reversed
  • 重复度较高 mod8
  • 序列长度划分 小中大 16/128/1024
    通过benchmark的结果,得出结论:
  • 短序列情况和有序的情况,插入排序最快
  • 大部分情况下,快速排序的性能较好
  • 堆排序的表现比较稳定

pdqsort实现

根据上面benchmark的表现,可以根据不同场景,应用不同的算法,从而提高排序算法的性能。 pdqsort —— pattern defeating quicksort。 具体来说,小序列直接使用插入排序,否则使用快速排序,当快速排序表现不佳时,改用堆排序。
这就引出了两个问题,一个是什么是大小序列的分界点,另一个是如何评价快速排序的效果。
序列长度一般是12-32之间,具体语言设置不同。快排的效果是依靠pivot和两段的距离判断的,如果太小,说明这一次快排效果较差,如果出现一定次数的情况,就选择使用堆排序获取稳定的性能。

快排优化

这里主要优化的是pivot的选择,一般的选择是直接选择一个固定位置的,或者生成随机数。当序列有序时,选择首位元素的效果很差,因此选择固定位置不是好选择,总有一些场景效果差;而使用随机数也不是非常好的选择,因为每次生成随机数也要花费时间。
比较现实的策略是采用近似中位数,根据序列长度不同,进行采样:

  • 短序列 <=8 选择固定元素
  • 中等序列 8-50 采样3个元素,选择中位数
  • 长序列 >50 采样9个元素,选择中位数

采样pivot的时候,可以发现元素的状态。
当采样的元素是逆序,说明序列可能是逆序,直接翻转;当采样的元素是有序的,说明序列可能是有序的,采取插入排序(部分插入排序,如果经历了足够次数的交换操作,说明判断可能有误,取消插入排序)。
当检测到两次pivot相同时,将重复元素排在一起减少重复元素对pivot选择的干扰。