经典排序和pdqsort | 青训营笔记

391 阅读4分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第10篇笔记

经典排序算法

插入排序

func InsertSort(arr []int)  {
   for curr := 1; curr < len(arr); curr++ {
      for j:=curr; j>0 && arr[j-1] > arr[j]; j-- {
         arr[j-1], arr[j] = arr[j], arr[j-1]
      }
   }
}

时间复杂度

  • Best:O(n)
  • Avg:O(n^2)
  • Worst:O(n^2)

快速排序

func quickSort(arr []int)  {
   if len(arr) > 1 {
      index := partition(arr)
      quickSort(arr[:index])
      quickSort(arr[index+1:])
   }
}
func partition(arr []int) int {
   pivot := arr[0]
   left := 1
   right := len(arr) - 1
   for {
      for left <= right && arr[left] < pivot {
         left++
      }
      for left <= right && arr[right] >= pivot {
         right--
      }
      if left > right {
         break
      }
      arr[left], arr[right] = arr[right], arr[left]
      left++
      right--
   }
   arr[0], arr[right] = arr[right], pivot

   return right
}

时间复杂度

  • Best:O(n*logn)
  • Avg:O(n*logn)
  • Worst:O(n^2)

堆排序

func HeapSort(arr []int)  {
   for i := (len(arr) - 1) / 2; i>=0; i--{
      siftDown(arr, i)
   }
   // 移除堆顶元素
   for i := len(arr) - 1; i>=1; i--{
      arr[0], arr[i] = arr[i], arr[0]
      siftDown(arr[:i], 0)
   }
}
func siftDown(arr []int, node int)  {
   for {
      child := 2 * node + 1
      if child >= len(arr) {
         break
      }
      if child + 1 < len(arr) && arr[child] < arr[child+1]{
         child++
      }
      if arr[node] >= arr[child] {
         break
      }
      arr[node], arr[child] = arr[child], arr[node]
      node = child
   }
}

时间复杂度

  • Best:O(n*logn)
  • Avg:O(n*logn)
  • Worst:O(n*logn)

排序对比

  • 插入排序平均和最坏情况时间复杂度都是O(n^2),性能不好
  • 快速排序整体性能处于中间层次
  • 堆排序性能稳定,“众生平等”

实验对比

实验中根据序列长度的划分进行分别的实验

  • 数据完全随机的情况(random)

    • 插入排序在短序列中速度最快
    • 快速排序在其他情况中速度最快
    • 堆排序速度与最快算法差距不大 image.png
  • 有序/逆序的情况(sorted/reverse)

    • 插入排序在序列已经有序的情况下最快 image.png

结论

  • 所有短序列和元素有序的情况下,插入排序性能最好
  • 在大部分情况下,快速排序有较好的综合性能
  • 几乎在任何情况下,堆排序的表现均比较稳定

pdqsort

简介

pdqsort (pattern-defeating-quicksort),是一种不稳定的混合排序算法,它的不同版本被应用在 C++ BOOST、Rust 以及 Go 1.19 中。它对常见的序列类型做了特殊的优化使得在不同条件下都拥有不错的性能

时间复杂度

  • Best:O(n)
  • Avg:O(n*logn)
  • Worst:O(n*logn)

参考资料

version1

结合三种排序方法的优点

  • 对于短序列(小于一定长度),使用插入排序
  • 其他情况,使用快速排序来保证整体性能
  • 当快速排序表现不佳时,使用堆排序来保证最坏情况下时间复杂度仍然为 O(n*logn)

Q&A

  1. 短序列的具体长度是多少呢?
    • 12~32,在不同语言和场景中会有不同,在泛型版本根据测试选定 24
  2. 如何得知快速排序表现不佳,以及何时切换到堆排序?
    • 当最终 pivot 的位置离序列两端很接近时(距离小于 lenqth/8)判定其表现不佳,当这种情况的次数达到 limit(即 bits.Len(length)) 时,切换到堆排序 image.png

优化角度

  • 改进快速排序中的choose pivot,尽量使得快速排序中的pivot为序列的中位数

version2

关于pivot的选择

  • 使用首个元素作为 pivot (最简单的方案):实现简单,但是往往效果不好,例如在 sorted 情况下性能很差。
  • 遍历数组,寻找真正的中位数:遍历比对代价很高,性能不好
  • 平衡寻找 pivot 所需要的开销及 pivot 带来的性能优化:寻找近似中位数!

根据序列长度的不同,来决定选择策略

  • 短序列(<=8),选择固定元素(在pdqsort中,此时将直接使用插入排序)
  • 中序列(<=50),采样三个元素
  • 长序列(>50),采样九个元素

基于Pivot的采样方式探知序列当前状态--进一步优化

  • 采样的元素都是逆序排列 -> 序列可能已经逆序 -> 翻转整个序列
  • 采样的元素都是顺序排列 -> 序列可能已经有序 -> 使用插入排序 注:插入排序实际使用 partiallnsertionSort,即有限制次数的插入排序

version1 到 version2 的优化总结

  • pivot 选择策略(近似中位数)
  • 发现序列可能逆序,则翻转序列 -> 应对 reverse 场景
  • 发现序列可能有序,使用有限插入排序 -> 应对 sorted 场景 image.png

version3

如何优化重复元素很多的情况?

  • 优化-重复元素较多的情况(partitionEqual)
    • 当检测到此时的 pivot 和上次相同时(发生在 leftSubArray),即partition进行了无效分割,此时认为pivot的值为重复元素,使用 partitionEqual 将重复元素排列在一起,减少重复元素对于 pivot 选择的干扰
  • 优化-当 pivot 选择策略表现不佳时,随机交换元素
    • 避免一些极端情况使得 QuickSort 总是表现不佳,以及一些黑客攻击情况

image.png