深入剖析快速排序:原理、实现与性能优化

目录

导言

[1 · 算法原理](#1 · 算法原理)

[2 · 算法实现(C)](#2 · 算法实现(C))

[2 - 1 · 快速排序主框架](#2 - 1 · 快速排序主框架)

[2 - 2 · Hoare版本(C)](#2 - 2 · Hoare版本(C))

[2 - 3 · lomuto前后指针法(C)](#2 - 3 · lomuto前后指针法(C))

[2 - 4 · 挖坑法(C)](#2 - 4 · 挖坑法(C))

[3 · 时间复杂度分析](#3 · 时间复杂度分析)

[3 - 1 · 理想情况](#3 - 1 · 理想情况)

[3 - 2 · 最坏情况](#3 - 2 · 最坏情况)

[4 · 基础版快排的缺陷及优化](#4 · 基础版快排的缺陷及优化)

[4 - 1 · 待排序记录有序时的缺陷](#4 - 1 · 待排序记录有序时的缺陷)

[4 - 2 · 优化取基准值,三数取中法](#4 - 2 · 优化取基准值,三数取中法)

[4 - 3 · 小区间优化](#4 - 3 · 小区间优化)

[5 · 快速排序的非递归实现](#5 · 快速排序的非递归实现)

[5 - 1 · 为什么要考虑非递归](#5 - 1 · 为什么要考虑非递归)

[5 - 2 · 基本思路](#5 - 2 · 基本思路)

[5 - 3 · 代码实现(C)](#5 - 3 · 代码实现(C))

[6 · 三路划分](#6 · 三路划分)

[6 - 1 · 快排性能的关键点](#6 - 1 · 快排性能的关键点)

[6 - 2 · 三路划分思想解析](#6 - 2 · 三路划分思想解析)

[6 - 3 · 代码实现(C)](#6 - 3 · 代码实现(C))

[7 · 自省排序](#7 · 自省排序)

总结


导言

快速排序是一种高效的排序算法,在平均情况下具有优越的性能(时间复杂度通常为,使其在众多排序算法中脱颖而出。它广泛应用于实际系统和算法教学中,尤其在平均性能方面表现优异。

以下将简要概述本文的技术路线,帮助读者系统性地理解快速排序:

  1. 原理:介绍快速排序的核心思想,包括分治法策略和基准元素的选择机制。
  2. 实现:展示代码实现。
  3. 优化:讨论常见优化技巧。

1 · 算法原理

快速排序是Hoare于1962年提出的⼀种⼆叉树结构的交换排序方法。
其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
方便理解,下面是一张Hoare法实现快排的动图


2 · 算法实现(C)

2 - 1 · 快速排序主框架

通过上面的介绍,不难看出,在快速排序中,单趟将一个元素的位置确定好,并分隔出左右子区间,后续再对左右子区间进行快排,循此往复。那么快排的实现,我们可以使用递归。

因此我们可以搭建出一个快排的主框架:

复制代码
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	
	//分成三个部分,[left,meet-1] meet [meet+1, right]
	int meet = PartSort(a, left, right);
	//递归
	QuickSort(a, left, meet - 1);
	QuickSort(a, meet + 1, right);
}

将区间中的元素进行划分的 PartSort 方法主要有以下几种实现方式:


2 - 2 · Hoare版本(C)

基本思路:确定基准值,然后创建两个指针,一个从右开始向左找小,另一个从左开始向右找大,右边的先开始找**(为了确保相遇位置小于基准值)**,如果左右都找到了,那么就交换两处的值,再开始下一轮寻找,直到左右指针相等。此时将基准值处与相遇处交换。

代码实现如下:

复制代码
int PartSort1(int* a, int left, int right)
{
	//霍尔法

	int key = left;

	while (left < right)
	{
		//从右开始,找小,或走到left
		while (left < right && a[right] >= a[key])
		{
			--right;
		}

		//左边找大,或走到right
		while (left < right && a[left] <= a[key])
		{
			++left;
		}
		
		Swap(&a[left], &a[right]);
	}

	//此时key位置与 left 或 right 位置的值交换
	Swap(&a[key], &a[left]);
	return left;
}

2 - 3 · lomuto前后指针法(C)

基本思路:创建两个指针,一般基准值定为最左边的元素,那么一个指针指向最左边(前指针),另一个指向最左边的下一个(后指针)。后指针持续向后查找,如果找到比基准值小的,那么先让前指针+1,再交换两个指针指向处的值;如果找到大于等于基准值的,前指针不动,后指针接着向后找。直至后指针走出范围,将基准处的值与前指针指向处交换,返回前指针处。

代码实现如下:

复制代码
int PartSort2(int* a, int left, int right)
{
	//前后指针法

	int key = a[left];
	int prev = left;
	int cur = prev + 1;

	//快指针找小
	while (cur <= right)
	{
		if (a[cur] < key)
		{
			//找到了让prev的下一个位置与cur位置交换
			prev++;
			Swap(&a[cur], &a[prev]);
		}

		++cur;
	}
	Swap(&a[left], &a[prev]);
	return prev;
}

对上面的代码还能进行优化,防止自己与自己交换:

复制代码
		//防止自己和自己交换的写法
		if (a[cur] < key && ++prev != cur)
		{
			Swap(&a[cur], &a[prev]);
		}

2 - 4 · 挖坑法(C)

基本思路:先记录下基准值,然后将基准值处当作一个坑,创建两个指针,一个从右开始向左找小,另一个从左开始向右找大,先从右开始,如果找到了,将指针指向处的值放入坑中,并且当前位置变成新的坑,随后走另一个指针的查找。直至两指针相遇,此时将基准值放入当前的坑,并返回坑的位置

代码实现如下:

复制代码
int PartSort3(int* a, int left, int right)
{
	//挖坑法

	//先存住left位置数据,将left作为第一个坑
	int key = a[left];
	int hole = left;

	while (left < right)
	{
		//右边找小
		while (left < right && a[right] >= key)
		{
			--right;
		}
		//数据入坑,原位置成为新的坑
		a[hole] = a[right];
		hole = right;

		//左边找大
		while (left < right && a[left] <= key)
		{
			++left;
		}
		//数据入坑,原位置成为新的坑
		a[hole] = a[left];
		hole = left;
	}
	//循环结束,坑位置填上存的值
	a[hole] = key;
	return hole;
}

3 · 时间复杂度分析

3 - 1 · 理想情况

理想的情况是每次分隔都在当前区间的中间位置,此时整体递归情况类似于一棵二叉树,时间复杂度为,即区间遍历 * 二叉树高度。


3 - 2 · 最坏情况

最坏情况是待排序记录有序,此时右指针会一直找不到小,直到左右指针相遇。此时对区间的分隔只分出右区间,而此时快速排序也就退化成了冒泡排序,时间复杂度为


4 · 基础版快排的缺陷及优化

4 - 1 · 待排序记录有序时的缺陷

当待排序记录有序,此时不仅存在上文所说的效率退化的问题,同时存在栈溢出的风险,因为递归的深度太深了。


4 - 2 · 优化取基准值,三数取中法

上面提到的缺陷很大原因是我们固定将最左边的值选作基准值,因此我们在选基准值的时候,比较最左边 ,中间 ,最右边的值,选它们三个的中间值作为基准值,将基准值交换给最左边的值,保证整体逻辑。

代码实现如下:

复制代码
//优化取key ,三数取中
int GetMid(int* a, int left, int right)
{
	int mid = left + (right - left) / 2;
	if (a[left] > a[mid])
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else
		{
			return a[left] < a[right] ? left : right;
		}
	}
	else
	{
		if (a[mid] < a[right])
		{
			return mid;
		}
		else
		{
			return a[left] > a[right] ? left : right;
		}
	}
}

4 - 3 · 小区间优化

将快排整体的递归看作一棵二叉树,那么在区间较小的时候,也就是在二叉树的较底层,此时区间很小,但是需要调用的递归操作很多。

对于一颗完全二叉树,最底层占整体数据的 50% , 倒数第二层占 25%, 倒数第三层占 12.5%,那么对于小区间,持续递归分割其实是很没有必要的,此时我们可以选择一个排序来帮助我们优化。

对于帮忙优化的排序的选取,会首先想到希尔排序和堆排序这两个较快速的排序,但是毕竟是小区间,发挥不出希尔排序的优势,并且堆排序还得先建个堆,在数据量小的情况下效率也没那么理想,因此小区间优化选用的是插入排序。

加上小区间优化,代码如下:

复制代码
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}

	//小区间优化
	if (right - left <= 10)
	{
		InsertSort(a + left, right - left + 1);
	}
	
	//分成三个部分,[left,meet-1] meet [meet+1, right]
	int meet = PartSort(a, left, right);
	//递归
	QuickSort(a, left, meet - 1);
	QuickSort(a, meet + 1, right);
}

**注意:**由于我们插入排序的区间可能会偏离,所以插入排序的第一个参数需要传a + left。比如下面这种情况。


5 · 快速排序的非递归实现

5 - 1 · 为什么要考虑非递归

即使加上了上面的两个优化,但是对于递归太深,栈溢出的风险是没法解决的,这时就需要非递归的实现来优化。


5 - 2 · 基本思路

既然不能递归,但是我们需要模拟递归这个过程,这是就需要借助一个数据结构,栈和队列都可以,为了更贴近与上面递归的过程,这里选择栈。

先将left 和 right 入栈,随后走 PartSort 分割,之后判断分割出的左右区间长度,如果区间长度 <= 1 ,此时就无需对该区间排序了,而如果区间长度 > 1 ,那么这个区间仍需排序,此时将该区间的 left 与 right 入栈。直至栈为空,排序完成。

由于传参时需要知道左右区间,因此入栈时会进行两次入栈操作,一次入left,一次入right,而取数据,出栈也需进行两次,一次取left,一次取right,并且由于栈的性质,后进先出,因此取数据时需要注意顺序。


5 - 3 · 代码实现(C)

复制代码
void QuickSortNonR(int* a, int left, int right)
{
	Stack s;
	StackInit(&s);
	StackPush(&s, right);
	StackPush(&s, left);

	while (!StackEmpty(&s))
	{
		int begin = StackTop(&s);
		StackPop(&s);
		int end = StackTop(&s);
		StackPop(&s);

		int meet = PartSort(a, begin, end);

		if (meet < end - 1)
		{
			StackPush(&s, end);
			StackPush(&s, meet + 1);
		}

		if (meet > begin + 1)
		{
			StackPush(&s, meet - 1);
			StackPush(&s, begin);
		}
	}

	StackDestroy(&s);
}

6 · 三路划分

6 - 1 · 快排性能的关键点

决定快排性能的关键点是每次单趟排序后,key对数组的分割,如果每次选key基本⼆分居中,那么快排的递归树就是颗均匀的满⼆叉树,性能最佳。但是实践中虽然不可能每次都是⼆分居中,但是性能也还是可控的。在用上了上面的优化之后,在绝大数情况下性能已经很好了,但是在一种情况下仍不太行:**数组中有大量重复数据时。**此时效率也会有所退化。


6 - 2 · 三路划分思想解析

三路划分,听名字也知道是划分成3个区域,即分成 小于基准值 等于基准值 大于基准值 三个区域。

实现思路:

  1. 取left处的值作为基准值key
  2. 定义一个指针cur 进行遍历
  3. 当cur 走到小于 key 的地方,将 cur 位置的值与left 位置的值交换,left++,cur++,由于left位置就是key值,无需二次判断
  4. 当cur 走到大于key 的地方,将 cur 位置的值与right位置的值交换,right--,此时cur 不加,因为right位置换过来的值是不确定的,需要再次判断
  5. 当cur 走到等于key 的地方,cur++
  6. 直至 cur > right,遍历结束,此时等于key值的区间为 left,right

6 - 3 · 代码实现(C)

复制代码
typedef struct
{
	int _left;
	int _right;
}WayIndex;

WayIndex PartSort3Way(int* a, int left, int right)
{
	int mid = GetMid(a, left, right);
	Swap(&a[left], &a[mid]);

	int key = a[left];
	//从left+1 开始找
	int cur = left + 1;

	while (cur <= right)
	{
		if (a[cur] < key)
		{
			//找到小与left交换,++left
			//由于换过来的是key ,不用再次判断
			Swap(&a[cur], &a[left]);
			++left;
			++cur;
		}
		else if (a[cur] > key)
		{
			//找到大与right交换,--right,
			// 不确定换过来的值,所以cur不动,再判断一次
			Swap(&a[cur], &a[right]);
			--right;
		}
		else
		{
			++cur;
		}
	}

	WayIndex meet;
	meet._left = left;
	meet._right = right;
	return meet;
}

void QuickSortBy3Way(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}

	//分成三个部分,[left,meet-1] meet [meet+1, right]
	WayIndex meet = PartSort3Way(a, left, right);
	//递归
	QuickSort(a, left, meet._left - 1);
	QuickSort(a, meet._right + 1, right);
}

**注意:**由于最后部分排序最后得到的是一个等于基准值的区间,所以定义了一个结构体来进行存储。


7 · 自省排序

上面的三路划分法在应对大量重复数据的时候有优势,但正常情况下还是要比 hoare 和 lomuto 法要多进行几次交换。

C++的 sgi版本的stl 里面官配的 sort 用的是自省排序,正常情况下是快排,但是当符合某种效率退化的情况,会改变排序方式。

自省排序(introsort) 是introspective sort采用⽤了缩写,他的名字其实表达了他的实现思路,他的思路就是进行自我侦测和反省,快排递归深度太深 (sgi stl中使用的是深度为2倍排序元素数量的对数值)那就说明在这种数据序列下,选key出现了问题,性能在快速退化,那么就不要再进行快排分割递归了,改换为堆排序进行排序 。也有小区间优化 ,当区间长度 < 16 时,改为插入排序


总结

快速排序特别适用于处理大型数据集,在软件工程中被广泛用于标准库中的排序函数实现。它是对一般随机数据非常高效的通用排序算法选择。

理解快速排序的核心在于掌握"分治"思想以及高效率的分区操作。


以上内容如有错误或不准确之处,欢迎指出,或者你有更好的想法,也欢迎交流。

相关推荐
San813_LDD1 小时前
[数据结构]共享栈与双端队列:算法思想分析及C语言实现
java·开发语言·数据结构
阿正的梦工坊1 小时前
【Rust】06-函数、控制流与模块组织
开发语言·算法·rust
阿正的梦工坊1 小时前
【Rust】16-async/await、Future 与执行器模型
网络·算法·rust
阿正的梦工坊1 小时前
【Rust】11-Rust 所有权模型的编译期推理机制
开发语言·算法·rust
风筝在晴天搁浅1 小时前
LeetCode CodeTop 88.合并两个有序数组
算法·leetcode·职场和发展
GuWen_yue1 小时前
吃透二叉树与递归!60分钟掌握树结构核心+解题思路
javascript·算法
happymaker06261 小时前
LeetCodeHot100——3.无重复字符的最长子串
算法
nice_lcj5201 小时前
排序(2)-选择排序专题——简单选择排序与堆排序的结构优化
数据结构·算法·排序算法
nice_lcj5202 小时前
排序(4)-归并排序专题——归并排序的分治美学
java·数据结构·算法·排序算法