排序算法之快速排序详解

简介

今天介绍排序算法中最重要的快速排序,顾名思义,快速排序之所以能在历史的长河中脱颖而出以“快速”两字命名,就是因为经多年实践证明它是已知最快的泛型排序算法。并且到目前为止还没有哪个算法能撼动其位置。所以它的重要性是不言而喻的,是我们一定要熟悉并掌握的排序算法。

原理

前不久我们刚讲过归并排序(归并排序),快速排序同归并排序一样,也是一种“分治”的递归排序。我们需要慢慢讲解它的原理,首先我们提供一张数据表,选取数据表中一项数据 x x x,然后我们将数据表分为三组,比 x x x小的一组,和 x x x相等的一组,比 x x x大的一组。然后按照相同的方法将第1、3组递归的进行排序,最后三组按序连接起来。这便是快速排序的基础,我们发现这和归并排序及其相似,并且也看不出相比归并排序的优越性。我们还需要对其进行优化,思想是我们避免创造第二组,并且尽量避免使用附加内存(归并排序使用了较多的附加内存),接下来我们描述一下快速排序最经典的实现。

我们将其分为4步,输入一个数组 S S S,并且不使用任何附加内存:

  1. 判断 S S S中元素个数是否大于1,否,直接返回,是,进入下一步;
  2. S S S中任意元素 v v v枢纽元
  3. S − { v } S- \lbrace v \rbrace S{v}( S S S中其他元素)划分为两个集合: S 1 = { x ∈ S − { v } ∣ x ≤ v } S_1=\lbrace x \in S- \lbrace v \rbrace | x \leq v \rbrace S1={xS{v}xv} S 2 = { x ∈ S − { v } ∣ x ≥ v } S_2=\lbrace x \in S- \lbrace v \rbrace | x \geq v \rbrace S2={xS{v}xv}
  4. S 1 、 S 2 S_1、S_2 S1S2进行上述递归调用。

第三步中对等于枢纽元的元素的处理不是唯一的,这需要我们自己设计,一种比较好的思想是将等于枢纽元的元素尽可能平分至两集合。下面我们通过一组数据讲解其过程,我们对元素集合{13,81,92,43,65,31,57,26,75,0}进行快速排序,选取的枢纽元为65。
在这里插入图片描述

由此可知,快速排序划分的两个大小集合数量并不一定相等,这是一个隐患。因此快速排序的高效率取决于枢纽元的选取。通过选取合适的枢纽元来弥补递归调用的不足,也是其效率优于归并排序的原因。下面我们就步骤2、3的两个重要细节进行补充。

枢纽元的选取

方法1:
直观的方法是选取头元素当做枢纽元,但这具有重大的隐患,如果我们拿到的是一个预排序的数组,那将使得数组中的元素不是被分到 S 1 S_1 S1就是 S 2 S_2 S2中,并且这种情况还会发生在后续的递归中,也就意味着枢纽元没有起到应有的作用,因此不能这样选取。选取前两个相异元素的最大者作为枢纽元也不安全,这具有同样的风险。

方法2:
随机选取枢纽元是一个较为安全的方法,这需要使用到随机数发生器,但随机数发生器会带来额外的开销,因为快速排序应用于大量的数据,因此这种开销不会小。另外随机数发生器有可能出现问题(不要以为这种概率很小)。

方法3:
数组( N N N个元素)的中值是枢纽元的最好选择,它是第 ⌈ N ⌉ \lceil N \rceil N个最大值。但这个元素很难找到,可以通过随机选取三个数然后取中值来近似,但随机数其实没什么作用,因此可知直接取头、尾和中间位置的值来近似中值,这种方法称为三数中值分割法,经实践检验,这种方法减少了14%的比较次数。

分割策略

分割策略有很多,我们使用一种已被证实可以得到好的结果的方法。我们使用这种方法排序数组{8,1,4,9,6,3,5,2,7,0},首先我们通过三数中值分割法取枢纽元为6,然后让枢纽元与尾元素交换使其离开要被分割数据段。 i i i指向第一个元素 j j j指向倒数第二个元素。

下 标 0 1 2 3 4 5 6 7 8 9 元 素 8 1 4 9 0 3 5 2 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 8 & 1 & 4 & 9 & 0 & 3 & 5 & 2 & 7 &6 \\ & \uparrow & & & & & & & & \uparrow& \\ 游标 & i & & & & & & & & j & \\ \end{array} 08i1124394053657287j96

我们先假定所有元素互异,然后将元素分割为大于枢纽元和小于枢纽元两部分,当 i < j i<j i<j时让 i i i右移 j j j左移,直至 i i i指向一个大于枢纽元的元素, j j j指向一个小于枢纽元的元素。
下 标 0 1 2 3 4 5 6 7 8 9 元 素 8 1 4 9 0 3 5 2 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 8 & 1 & 4 & 9 & 0 & 3 & 5 & 2 & 7 &6 \\ & \uparrow & & & & & & & \uparrow& \\ 游标 & i & & & & & & & j & \\ \end{array} 08i11243940536572j8796

然后两数互换。

下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 9 0 3 5 8 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 9 & 0 & 3 & 5 & 8 & 7 &6 \\ & \uparrow & & & & & & & \uparrow& \\ 游标 & i & & & & & & & j & \\ \end{array} 02i11243940536578j8796

重复该过程直至 i i i j j j右边。

下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 9 0 3 5 8 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 9 & 0 & 3 & 5 & 8 & 7 &6 \\ & & & & \uparrow & & & \uparrow& \\ 游标 & & & & i & & & j & \\ \end{array} 02112439i405365j788796

⇓ \Downarrow

下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 5 0 3 9 8 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 5 & 0 & 3 & 9 & 8 & 7 &6 \\ & & & & \uparrow & & & \uparrow& \\ 游标 & & & & i & & & j & \\ \end{array} 02112435i405369j788796

⇓ \Downarrow

下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 5 0 3 9 8 7 6 ↑ ↑ 游 标 j i \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 5 & 0 & 3 & 9 & 8 & 7 &6 \\ & & & & & & \uparrow& \uparrow& \\ 游标 & & & & & & j& i & \\ \end{array} 021124354053j69i788796

此时 i i i j j j右边,不再将两数交换,将 i i i与枢纽元 p i v o t pivot pivot交换,一次分割完成,之后对两部分递归调用其过程即可。
下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 5 0 3 6 8 7 9 ↑ ↑ 游 标 i p i v o t \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 5 & 0 & 3 & 6 & 8 & 7 &9 \\ & & & & & & & \uparrow&&& \uparrow\\ 游标 & & & & & & & i & &&pivot\\ \end{array} 02112435405366i788799pivot

现在我们考虑如果数组中存在相同元素怎么办,当 i i i j j j遇到与枢纽元元素相同的元素是否应该停止并交换呢?答案是 i i i j j j都应该停止并交换,回顾一下前面所说的,我们应将等于枢纽元的元素尽可能平分至两集合,只有这样做才能达到这样的效果。

代码实现

快速排序不适合数组长度太小的情况,因此当数组长度小于10时使用插入排序。(完整代码见:https://github.com/kfcyh/sort/tree/master/quicksort)

#include <iostream>
#include <vector>
#include <algorithm>
#include <stdlib.h>
using namespace std;

 inline void insertsort(vector<int>& L,int start,int end)
{

	for (int i = start+1; i <= end ; ++i)
	{
		int tmp = std::move(L[i]);
		int j=0;
	
		for (j = i; j>0 && (L[j - 1] > tmp); --j)
		{
			L[j] = std::move(L[j - 1]);
		}
		L[j] = std::move(tmp);
	}
}

inline void swap(vector<int>& L, int i, int j)
{
	int temp = L[i];
	L[i] = L[j];
	L[j] = temp;
}


/**********************计算枢纽元*********************/
inline const int& Partition(vector<int>& L, int low, int high)
{
	/*取头、尾和中间值中的中值*/
	int m = (high + low) / 2;
	if (L[m] < L[low])
		swap(L, low, m);
	if (L[high] < L[low])
		swap(L, low, high);
	if (L[high] < L[m])
		swap(L, high, m);
	/**************************/
	swap(L, m, high - 1); //将枢纽元置于high-1处
	return L[high - 1]; //返回枢纽元
}

/*************************快速排序************************/
void QuickSort(vector<int>& L, int low, int high)
{
	if (low + 10 <= high) 
	{
		const int pivot = Partition(L, low, high); //计算本次枢纽元
		int i = low, j = high - 1; //取需要划分元素段的头尾游标
		while (1)
		{
			while (L[++i] < pivot) {}	//i指向大于枢纽元的元素
			while (L[--j] > pivot) {}	//j指向小于枢纽元的元素
			if (i < j)
				swap(L, i, j);		//如果i在j左边交换两元素
			else
				break;					//如果i在j右边退出
		}
		swap(L, i, high - 1);			//恢复枢纽元
		QuickSort(L, low, i - 1);		//将小于枢纽元的部分排序
		QuickSort(L, i + 1, high);		//将大于枢纽元的部分排序
	}
	else //若长度小于10则使用插入排序
	{
		insertsort(L,low,high);
	}

}

/***********外部接口**********/
void QuickSort(vector<int>& L)
{
	QuickSort(L, 0, L.size() - 1);
}

性能分析

快速排序的平均时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),最好情况为 O ( n l o g n ) O(nlogn) O(nlogn),最坏情况为 O ( n 2 ) O(n^2) O(n2),经过优化其最好情况很难达到,并且需要的辅助空间较归并排序较少,综合各项指标,快速排序是性能最好的排序算法,但具体使用时还应根据实际情况考虑。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值