这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记。今天的直播课老师为我们讲了许多关于排序算法的知识,并以pbqsort为例为我们讲解了工业情景下的算法。课后留下了作业,即实现一个简单的pbqsort排序算法,在此记录一下~
经典排序算法
| 算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 稳定性 |
|---|---|---|---|---|
| 直接插入排序 | O(n) | O(n2) | O(n2) | 稳定 |
| 堆排序 | O(nlog n) | O(nlog n) | O(nlog n) | 不稳定 |
| 快速排序 | O(nlog n) | O(nlog n) | O(n2) | 不稳定 |
稳定性:对于相等的元素,排序后不会改变其相对位置则为稳定。
混合算法 pbqsort (pattern-defeating-quicksort)
结合了三种经典算法的优点:
- 对短序列(小于一定长度)使用插入排序
- 其他情况使用快速排序,保证整体性能
- 当快排表现不好时,使用堆排序兜底,保证最坏情况时间复杂度仍为O(nlog n)
具体流程如下:
- 对于短序列(<= 24)使用插入排序
- 其他情况使用快排,(选择固定的pivot,如数组左端)
- 当快排表现不佳(limit <= 0)时,改用堆排序
算法的实现
这里我们不考虑pivot选择、有序的判断、较多重复元素情况以及划分(partition)的优化等,仅实现一个简单的pbqsort算法。
pbqsort使用到了经典的插入排序和堆排序,首先实现这两个排序算法。
直接插入排序
// 直接插入排序
func InsertionSort(v []int) {
for cur := 0; cur < len(v); cur++ {
for j := cur; j > 0 && v[j-1] > v[j]; j-- {
// 交换以前移数据
v[j-1], v[j] = v[j], v[j-1]
}
}
}
堆排序
// 堆排序
func HeapSort(v []int) {
// 初始化大根堆
for i := (len(v) - 1) / 2; i >= 0; i-- {
siftDown(v, i)
}
// 将堆顶的元素与末端元素交换,并重新调整堆
for i := len(v) - 1; i >= 1; i-- {
v[0], v[i] = v[i], v[0]
// "v[:i]" 交换到末尾的元素将置于堆外,因为其已经有序
siftDown(v[:i], 0)
}
}
// 向下调整堆
func siftDown(v []int, node int) {
for {
// 计算左子节点下标
child := 2*node + 1
if child >= len(v) {
// 无子节点,则直接返回
break
}
if child+1 < len(v) && v[child] < v[child+1] {
// 如果存在右子节点,且右子节点比左子节点大,则选择右子节点
child++
}
if v[node] >= v[child] {
// 如果当前节点不小于子节点,则直接返回
return
}
// 当前节点小于选择的子节点,则交换父子节点
v[node], v[child] = v[child], v[node]
// 然后向下递归此过程
node = child
}
}
然后结合以上两种算法类来实现我们的混合排序算法。
pbqsort version1
// pbqsort 混合快速排序
func PDQSortV1(v []int) {
// limit值由数组长度的二进制表示所需最少位数决定
// 例如:长度为8(100b),则limit为3
recurseV1(v, bits.Len(uint(len(v))))
}
// pbqsort 递归过程
func recurseV1(v []int, limit int) {
const maxInsertion = 24 // 长度阈值,数组长度不超过此值时,直接采用插入排序
var wasBalanced = true // 最后一次快排划分是否平衡(代表了枢轴的选择是否够好,体现了最后一次排序的效率)
for {
length := len(v)
// 数组长度小于阈值,直接插入排序并返回
if length <= maxInsertion {
InsertionSort(v)
return
}
// limit值降至0,即出现了足够多次较差的枢轴选择(即表示进行了足够多次低效的排序)
// 此情况快排效率已经开始有下降的趋势,因此改用堆排序
if limit == 0 {
HeapSort(v)
return
}
// 上一次划分不平衡,则使limit值下降1
if !wasBalanced {
limit--
}
// 选择枢轴
pivotidx := choosePivotV1(v)
// 进行一次快排划分
mid := partitionv1(v, pivotidx)
left, right := v[:mid], v[mid+1:]
if len(left) < len(right) { // 左半边更短,则递归左半边,循环处理右半边
// 若枢轴距左边界距离小于整个数组的八分之一,则认为其划分不平衡
wasBalanced = len(left) >= len(v)/8
recurseV1(left, limit)
v = right
} else { // 右半边更短,则递归右半边,循环处理左半边
// 若枢轴距右边界距离小于整个数组的八分之一,则认为其划分不平衡
wasBalanced = len(right) >= len(v)/8
recurseV1(right, limit)
v = left
}
// 按以上方式减少一半的递归调用,且尽量递归短的一边,以降低递归深度
}
}
// 从数组中选择一个枢轴(pivot)
func choosePivotV1(v []int) int {
// 简单起见,直接选择数组中点
return len(v) / 2
}
// 快排划分数组
func partitionv1(v []int, pivotidx int) int {
// 经典快排实现
pivot := v[pivotidx]
v[0], v[pivotidx] = v[pivotidx], v[0]
i, j := 1, len(v)-1
for {
for i <= j && v[i] < pivot {
i++
}
for i <= j && v[j] >= pivot {
j--
}
if i > j {
// 提前退出,避免多余的交换
break
}
v[i], v[j] = v[j], v[i]
i++
j--
}
v[j], v[0] = v[0], v[j]
return j
}
对以上代码进行测试(这里随便写了个长度为24的数组作为测试数据),结果如下
看起来还不错。