数据结构与算法 - 实现pbqsort | 青训营笔记

167 阅读3分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第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)

具体流程如下:

image.png

  • 对于短序列(<= 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的数组作为测试数据),结果如下

image.png

看起来还不错。