数据结构与算法 | 青训营笔记

106 阅读5分钟

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

1. 例子引入

抖音直播排行榜

某个时间段内,直播间礼物数Top10的房间获得奖励,每个房间展示排行榜

解决方案:

  • 礼物数量存储在Redis-zset中,使用skiplist使得元素整体有序
  • 使用Redis集群,避免单机压力过大,使用主从算法、分片算法
  • 保证集群原信息的稳定,使用一致性算法
  • 后端使用缓存算法(LRU)降低Redis压力,展示房间排行榜

2. 各语言最快的排序方法

  • Python:timesort
  • C++:introsort
  • Rust:pdqsort
  • GO:introsort,1.19默认排序算法

3.排序算法回顾

  • 选择排序、希尔排序、快速排序和堆排序都是不稳定的

  • 稳定排序:排序前后两个相等的数位置相对不变;常见的稳定排序算法:插入,冒泡,归并

  • 排序方式

    • in-place 占用常数内存,不占用额外内存
    • out-place 占用额外内存(归并,计数,桶排序和基数排序)
  • 快排优化:

    • 3 种快排基准选择方法: 随机(rand函数)、固定(队首、队尾)、三数取中(队首、队中和队尾的中间数)

      • hoare版本(左右指针法)
      • 挖坑法
      • 前后指针法
    • 4种优化方式:

      • 好优化1:当待排序序列的长度分割到一定大小后,使用插入排序
      • 优化2:在一次分割结束后,可以把与Key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割
      • 优化3:优化递归操作
      • 优化4:使用并行或多线程处理子序列

4. pdqsort

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

4.1Version 1

  • 结合三种排序方法的优点

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

    • 短序列插入,长序列快速,快速表现不佳,使用堆排序
  • 细节

    • 短序列的长度——12-32,反省版本选定24
    • 如何得知快速排序表现不佳,何时切换?——当最终的pivot的位置离序列两端很接近时(距离小于length/8)判定其表现不佳,当这种情况的次数达到limit(即bits.Len(length))时切换成堆排序了
  • 优化

    • 尽量使快速排序的pivot为序列的中位数——改进choose pivot
    • Partition速度更快——改进Partition

4.2Version 2

  • pivot如何选择?

    • 如果使用首个元素:实现简单,但是效果不好
    • 遍历数组,寻找中位数:代价很高,性能不好
  • 需要平衡寻找pivot的开销和pivot带来的性能优化,寻找近似中位数

  • 优化Pivot的选择

    • 短序列(<=8),选择固定元素
    • 中序列(<=50),采样三个元素
    • 长序列(>50),采样九个元素
    • Pivot的采样方式使得我们有探知序列当前状态的能力,如果逆序可以翻转,如果顺序可以直接使用插入排序
  • 优化点

    1. 升级pivot选择策略(近似中位数)
    2. 发现序列可能逆序,反转序列(reverse)
    3. 发现序列可能有序,使用有限插入排序(sorted)

4.3final version

  • 元素重复度较高的情况

    • 解决方法

      1. 采样pivot的时候检测重复度

      • 采样数量有限,不一定能够采样到相同的元素

      1. 如果两次的partition生成的pivot相同,即partiotion进行了无效分割,此时认为pivot的值为重复元素

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

image-20220522102320208

总结

优化点:

  • 短序列:使用插入排序(v1)
  • 极端情况,使用堆排序保证算法的可行性(v1)
  • 完全随机的情况(random),更好的pivot选择策略(v2)
  • 有序/逆序的情况(reverse and sorted):根据序列状态翻转或者插入排序(v2)
  • 元素重复度较高的情况(mod8):(v3)

5.Q&A

高性能的排序算法是如何设计的?

  • 根据不同的情况选择不同的策略,取长补短

  • 生产环境和课本上的排序算法区别

    • 生产环境:面对不同的场景,更加注重实践性能
    • 理论算法:注意理论性能,例如时间,空间复杂度
  • Go语言(<=1.18)的排序算法是快速排序么?

    • 一直都是混合排序算法,主体是快速排序
    • 区别:fallback时机,pivot选择策略,是否有针对不同pattern优化

6. 作业

完成 PPT 中 pdqsort v1 版本,可以正确对元素排序

  • 根据stl的IntroSort算法进行优化

STL的sort算法的优化策略:

1、 数据量大时采用QuickSort,分段递归排序。

2、 一旦分段后的数据量小于某个门槛,为避免Quick Sort的递归调用带来的额外负荷,就改用Insertion Sort。

3、 如果层次过深,还会改用HeapSort

4、 “三点中值”获取好的分割

  • Version1:短序列插入,长序列快速,快速表现不佳,使用堆排序
const threshold = 24func BenchmarkTestIntroSort(b *testing.B) {
   demo := []int{1, 3, 5, 7, 9, 8, 2}
   introSort(demo, len(demo))
   //fmt.Println(demo)
}
​
type sortSlice []intfunc (a sortSlice) Len() int           { return len(a) }
func (a sortSlice) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a sortSlice) Less(i, j int) bool { return a[i] < a[j] }
func BenchmarkTestSort(b *testing.B) {
   demo := []int{1, 3, 5, 7, 9, 8, 2}
   sort.Sort(sortSlice(demo))
   //fmt.Println(demo)
}
​
func introSort(array []int, len int) {
   if len != 1 {
      introSortLoop(array, 0, len-1, lg(len)*2)
      insertionSort(array, len)
   }
}
​
// 第一层函数//计算最大容忍的递归深度
func lg(n int) int {
   var k int
   for k = 0; n > 1; n >>= 1 {
      k++
   }
   return k
}
​
//递归的对数组进行分割排序
func introSortLoop(array []int, begin, end, depthLimit int) {
   for (end - begin + 1) > threshold { //子数组数据量大小,则交给后面的插入排序进行处理
      if depthLimit == 0 { //递归深度过大,则由堆排序代替
         heapSort(array, begin, end)
         return
      }
      depthLimit--
      //使用quick sort进行排序
      cut := partition(array, begin, end, median3(array, begin, begin+(end-begin)/2, end))
      introSortLoop(array, cut, end, depthLimit)
      end = cut //对左半段进行递归的sort
   }
}
​
//快速排序用到的辅助函数//对数组分割
func partition(array []int, left, right, p int) int {
​
   index := left //选择最右侧的元素作为分割标准
   swap(array, p, right)
   pivot := array[right]
   //将所有小于标准的点移动到index的左侧
   for i := left; i < right; i++ {
      if array[i] < pivot {
         swap(array, index, i)
         index++
      }
   }
   //将标准与index指向的元素交换,返回index,即分割位置
   swap(array, right, index)
   return index
}
​
//三点中值
func median3(array []int, first, median, end int) int {
   if array[first] < array[median] {
      if array[median] < array[end] {
         return median
      } else if array[first] < array[end] {
         return end
      } else {
         return first
      }
​
   } else if array[first] < array[end] {
      return first
   } else if array[median] < array[end] {
      return end
   } else {
      return median
   }
}
​
//堆排序用到的辅助函数
func parent(i int) int {
   return (int)((i - 1) / 2)
}
​
func left(i int) int {
   return 2*i + 1
}
​
func right(i int) int {
   return 2*i + 2
}
​
func heapShiftDown(heap []int, i, begin, end int) {
   l := left(i-begin) + begin
   r := right(i-begin) + begin
   largest := i
   //找出左右字节点与父节点中的最大者
   if l < end && heap[l] > heap[largest] {
      largest = l
   }
   if r < end && heap[r] > heap[largest] {
      largest = r
   }
   //若最大者不为父节点,则需交换数据,并持续向下滚动至满足最大堆特性
   if largest != i {
      swap(heap, largest, i)
      heapShiftDown(heap, largest, begin, end)
   }
}
​
//自底向上的开始建堆,即从堆的倒数第二层开始
func buildHeap(heap []int, begin, end int) {
   for i := int(begin+end) / 2; i >= begin; i-- {
      heapShiftDown(heap, i, begin, end)
   }
}
​
//堆排序
func heapSort(heap []int, begin, end int) {
   buildHeap(heap, begin, end)
   for i := end; i > begin; i-- {
      swap(heap, begin, i)
      heapShiftDown(heap, begin, begin, i)
   }
}
​
// 插入排序
func insertionSort(array []int, len int) {
   var i, j, temp int
   for i = 1; i < len; i++ {
      temp = array[i]                              //store the original sorted array in temp
      for j = i; j > 0 && temp < array[j-1]; j-- { //compare the new array with temp(maybe -1?)
​
         array[j] = array[j-1] //all larger elements are moved one pot to the right
      }
      array[j] = temp
   }
}
​
func swap(s []int, i, j int) {
   s[i], s[j] = s[j], s[i]
}

参考链接

快排的优化:blog.csdn.net/weixin_4290…

快速排序的几种方法:zhuanlan.zhihu.com/p/419568256

前后指针法:blog.csdn.net/weixin_4567…

golang中sort包实现与应用

STL经典算法集锦<八>之IntroSort