「Go语言引入的新型排序算法pdqsort」笔记 | 青训营笔记

614 阅读3分钟

「Go语言引入的新型排序算法pdqsort」笔记 | 青训营笔记

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

lnsertion Sort 插入排序

插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

image-20220526102727571.png

将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。

从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

时间复杂度:

  • 最优:O(n)
  • 平均:O(n^2)
  • 最差:O(n^2)

代码实现:

Go

 func insertionSort(arr []int) []int {
         for i := range arr {
                 preIndex := i - 1
                 current := arr[i]
                 for preIndex >= 0 && arr[preIndex] > current {
                         arr[preIndex+1] = arr[preIndex]
                         preIndex -= 1
                 }
                 arr[preIndex+1] = current
         }
         return arr
 }

Java

 public class InsertSort implements IArraySort {
 ​
     @Override
     public int[] sort(int[] sourceArray) throws Exception {
         // 对 arr 进行拷贝,不改变参数内容
         int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);
         // 从下标为1的元素开始选择合适的位置插入,下标为0看作第一个元素,默认是有序的
         for(int i = 1; i < arr.length; i++){
             // 记录要插入的数据
             int tmp = arr[i];
             int j = i;
             // 从已排序序列最右边的开始比较,找到比其小的数
             while(j > 0 && tmp < arr[j - 1]){
                 arr[j] = arr[j - 1];
                 j--;
             }
             // 存在比其小的数,插入
             if(j != i){
                 arr[j] = tmp;
             }
         }
         return arr;
     }
 }

Quick Sort 快速排序

  1. 从数列中挑出一个元素,称为 "基准"(pivot);
  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;

时间复杂度:

  • 最优:O(nlogn)
  • 平均:O(nlogn)
  • 最差:O(n^2)

Heap Sort 堆排序

堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:

  1. 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
  2. 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;

堆排序的平均时间复杂度为 Ο(nlogn)。

  1. 创建一个堆 H[0……n-1];
  2. 把堆首(最大值)和堆尾互换;
  3. 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
  4. 重复步骤 2,直到堆的尺寸为 1。

时间复杂度:

  • 最优:O(nlogn)
  • 平均:O(nlogn)
  • 最差:O(nlogn)

实际场景Benchmark

image-20220526164005630.png

根据序列元素排列情况划分

  • 完全随机的情况(random)
  • 有序/逆序的情况(sorted/reverse)
  • 元素重复度较高的情况(mod8)

在此基础上,还需要根据序列长度的划分(16/128/1024)

Benchmark-random

image-20220526164257090.png

  • 插入排序在短序列中速度最快
  • 快速排序在其他情况中速度最快
  • 堆排序速度于最快算法差距不大

Benchmark-sorted

image-20220526164410903.png

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

结论:

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

pdqsort算法

简介

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

pdqsort - version1

image-20220526165049575.png

  • 对于短序列(<=24)我们使用插入排序
  • 其他情况,使用快速排序(选择首个元素作为pivot))来保证整体性能
  • 当快速排序表现不佳时(limit==0) ,使用堆排序来保证最坏情况下时间复杂度仍然为O(nlogn)

当最终 pivot的位置离序列两端很接近时(距离小于length/8)判定其表现不佳,当这种情况的次数达到 limit (即 bits.Len(length))时,切换到堆排序

如何让pdqsort速度更快?

  • 尽量使得QuickSort的pivot为序列的中位数->改进choose pivot
  • Partition速度更快->改进partition ,但是此优化在Go表现并不好,不做过多描述。

pdqsort - version2

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

优化Pivot的选择

  • 短序列(<=8),选择固定元素
  • 中序列(<=50),采样三个元素,median of three
  • 长序列(>50).采样九个元素,median of medians

Pivot的采样方式使得我们有探知序列当前状态的能力

采样的元素都是逆序排列 -> 序列可能已经逆序 -> 翻转整个序列

采样的元素都是顺序排列 -> 序列可能已经有序 -> 使用插入排序

插入排序实际使用partiallnsertionSort,即有限制次数的插入排序

image-20220526165929187.png

Version1升级到version2优化总结

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

pdqsort - final version(Go1.19 default)

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

解决方案︰如果两次partition生成的pivot相同,即partition进行了无效分割,此时认为pivot的值为重复元素。

优化 - 重复元素较多的情况(partitionEqual)

当检测到此时的 pivot和上次相同时(发生在leftSubArray),使用partitionEqual将重复元素排列在一起,减少重复元素对于pivot选择的干扰。

优化-当pivot选择策略表现不佳时,随机交换元素

避免一些极端情况使得QuickSort总是表现不佳,以及一些黑客攻击情况。

image-20220526171028161.png

时间复杂度:

image-20220526171105760.png