9.3.2、排序-->交换排序-->快速排序(超详细)

前言:本片文章的内容结构参考于 算法(第四版),因为本人更熟悉C++,所以代码全程用C++实现,原作以JAVA实现,看不懂C++的推荐去看原作。不过,算法是脱离具体语言的,不同语言的实现在结构上相同,只有少部分细节不同。另外需要大家学习一下模板,稍稍了解即可,无需深入。

目录

一、基本算法

1、定义:

2、特点:

3、算法分析:

4、几个细节问题:

4.1、原地切分

4.2、别越界

4.3、保持随机性

4.4、终止循环

4.5、处理切分元素值有重复的情况

4.6、终止递归

二、性能特点

1、一般情况下的时间复杂度

2、最坏情况的时间复杂度

三、算法改进

1、切换到插入排序

2、三取样切分

3、熵最优的排序

3.1、优化复杂度的证明:


一、基本算法

1、定义:

快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两个部分独立地排序。


2、特点:

1)是原地排序(只需要一个很小的辅助栈);

2)长度为N的数组排序所需的时间和NlgN成正比;

3)内循环比大多数排序算法都要短小(意味着无论是理论上还是实际中都要更快)。

主要缺点:非常脆弱,现实时要非常小心才能避免低劣的性能(错误的操作可能导致性能变为平方级别),稳定性差

稳定性差:不是指算法的时间复杂度不固定稳定性好的指标是 值相同的多个元素 多次通过同一种算法排序,每一次排序后每一个元素的位置是不变的反之,则稳定性差。例如:对【4,6,7,3,9,3,2】进行两次快排,第一次可能得到的结果是【2,33,4,6,7,9】,第二次是【2,33,4,6,7,9】,虽然从数值上看没什么不同,但是元素的位置是不一样的。


3、算法分析:

问题:将【Q、U、I、C、K、S、O、R、T、E、X、A、M、P、L、E】按字母序大小排序

 

快速排序:

// 快速排序代码样例
#include <iostream>
#include <algorithm>
#include <random>

using namespace std;

#define size 16

template <typename T>
class Quick {
public:
	void sort(T a[]) {
		int length = size;
		shuffle(a, a + length, mt19937(random_device()()));
		sort(a, 0, length - 1);
	}
	

private:
	
	void sort(T a[], int lo, int hi) {
		if (hi <= lo) return;

		int j = partition(a, lo, hi);
		sort(a, lo, j - 1);
		sort(a, j + 1, hi);
	}
	
	int partition(T a[], int lo, int hi) {
		//********************
	}

};



int main() {
	char a[size] = { 'Q','U','I','C','K' ,'S','O' ,'R','T' ,'E','X' ,'A','M' ,'P','L' ,'E' };
	Quick<char> p1;
	p1.sort(a);

	for (int i=0;i<size;++i) {
		cout << a[i] << " ";
	}

	return 0;
}

原理及实现方法:

        因为切分过程总是能排定一个元素,用归纳法不难证明递归能够正确的将数组排序:如果左子数组和右子数组都是有序的,那么由左子数组(有序且没有任何元素大于切分元素),切分元素和右子数组(有序且没有任何元素大于切分元素)组成的结果数组也一定是有序的。这是一个随机化的算法,因为它在将数组排序之前会将其随机打乱。

        要完成这个实现,需要实现切分方法。一般策略是先随意的取 a[lo] 作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置,如此继续我们就可以保证左指针 i 的左侧元素都不大于切分元素,右指针 j 的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素 a[lo] 和左子数组最左右的元素( a[ j ])交换然后返回 j 即可。切分方法的大致过程如图:

 切分函数代码实现:

//样例,无法通过编译
int partition(T a[], int lo, int hi) {
    int i = lo;
    int j = hi + 1;
    T pivot = a[lo];
    while (true) {
        while (a[++i] < pivot)    if (i == hi)    break;
        while (pivot < a[--j])    if (j == lo)    break;
        if (i >= j)        break;
            swap(a[i], a[j]);
        }

        swap(a[lo], a[j]);
        return j;
    }
}

完整代码:

// 快速排序代码样例
#include <iostream>
#include <algorithm>
#include <random>

using namespace std;

#define size 16

template <typename T>
class Quick {
public:
	void sort(T a[]) {
		int length = size;
		shuffle(a, a + length, mt19937(random_device()()));
		sort(a, 0, length - 1);
	}
	

private:
	
	void sort(T a[], int lo, int hi) {
		if (hi <= lo) return;

		int j = partition(a, lo, hi);
		sort(a, lo, j - 1);
		sort(a, j + 1, hi);
	}
	
	int partition(T a[], int lo, int hi) {
		int i = lo;
		int j = hi + 1;
		T pivot = a[lo];

		while (true) {
			while (a[++i] < pivot)	if (i == hi)	break;
			while (pivot < a[--j])	if (j == lo)	break;
			if (i >= j)		break;
			swap(a[i], a[j]);
		}

		swap(a[lo], a[j]);
		return j;
	}

};



int main() {
	char a[size] = { 'Q','U','I','C','K' ,'S','O' ,'R','T' ,'E','X' ,'A','M' ,'P','L' ,'E' };
	Quick<char> p1;
	p1.sort(a);

	for (int i=0;i<size;++i) {
		cout << a[i] << " ";
	}

	return 0;
}


4、几个细节问题:

4.1、原地切分

为什么原地切分?如果使用一个辅助数组,切分将变得非常容易实现,但是将切分后的数组复制回去的开销也会降低性能。

4.2、别越界

如果切分元素是数组中最小或最大的那个元素,我们就要小心别让扫描指针跑出数组的边界。partition()实现可进行明确的检测来预防这种情况。测试条件(j == 1o)是冗余的,因为切分元素就是a[10],它不可能比自己小。数组右端也有相同的情况,它们都是可以去掉的。

4.3、保持随机性

数组元素的顺序是被打乱过的。因为上述算法对所有的子数组都一视同仁,它的所有子数组也都是随机排序的。这对于预测算法的运行时间很重要。保持随机性的另一种方法是在partition()中随机选择一个切分元素 。

4.4、终止循环

有经验的程序员都知道保证循环结束需要格外小心,快速排序的切分循环也不例外。正确地检测指针是否越界需要一点技巧,并不像看上去那么容易。一个最常见的错误是没有考虑到数组中可能包含和切分元素的值相同的其他元素。

4.5、处理切分元素值有重复的情况

左侧扫描最好是在遇到大于等于切分元素值的元素时停下,右侧扫描则是遇到小于等于切分元素值的元素时停下。尽管这样可能会不必要地将一些等值的元素交换,但在某些典型应用中,它能够避免算法的运行时间变为平方级别。

4.6、终止递归

实现快速排序时一个常见的错误就是不能保证将切分元素放入正确的位置,从而导致程序在切分元素正好是子数组的最大或是最小元素时陷人了无限的递归循环之中。



二、性能特点

【注】以下为复杂度证明过程,直接贴上书中的图片,如果你不喜欢数学,可以跳过这个部分

1、一般情况下的时间复杂度


2、最坏情况的时间复杂度

使用随机函数:

尽管快速排序有很多优点,它的基本实现仍有一个潜在的缺点:在切分不平衡时这个程序可能会极为低效。例如,如果第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,每次调用只会移除一个元素。这会导致一个大子数组需要切分很多次。我们要在快速排序前将数组随机排序的主要原因就是要避免这种情况。它能够使产生糟糕的切分的可能性降到极低。



三、算法改进

说明:如果排序代码会被执行很多次或者会被用在大型数组上 (特别是如果它会被发布成一个库函数,排序的对象数组的特性是未知的)。

1、切换到插入排序

(优化排序方式)

和大多数递归排序算法的优化一样,改进快速排序性能的一个简单方法基于以下两点:

  • 对于小数组,插入排序比快速排序快。
  • 因为递归,快速排序的sort( )方法在小数组中也会调用自己。

因此,在排序小数组时应该切换到插入排序,简单地改动算法可以做到;将Quick类中sort( )的语句

if (hi <= lo) return;

 替换成下面语句

if(hi<=lo + M){

        Insertion<具体类型> p1;            //插入排序
        p1.sort(a,lo,hi);
        return;

}

转换参数M的最佳值和系统相关,但是5~15之间的任意值在大多数情况下都能令人满意。

// 快速排序代码样例
#include <iostream>
#include <algorithm>
#include <random>

using namespace std;

#define size 26


//插入排序
template <typename T>
class Insertion {
public:
	void sort(T a[],int lo,int hi) {
		//将a[]按升序排列
		int N = size;
		for (int i = lo; i <= hi; i++) {
			//将a[i]插入到a[i-1],a[i-2],a[i-3]...之中
			T temp = a[i];
			int j = i;
			while (j > 0 && temp < a[j - 1]) {
				a[j] = a[j - 1];
				j--;
			}
			a[j] = temp;
		}
	}
};



//快速排序 
template <typename T>
class Quick {
public:
	void sort(T a[]) {
		int length = size;
		shuffle(a, a + length, mt19937(random_device()()));
		sort(a, 0, length - 1);
	}
	

private:
	
	void sort(T a[], int lo, int hi) {
		/*替换该段代码 
		if (hi <= lo) return;
		*/
		
		//替换为 插入排序 ,M在 5~15 之间 
		int M=5;	 
		if(hi<=lo + M){
			Insertion<T> p1;
			p1.sort(a,lo,hi);
			return;
		}
		 
		int j = partition(a, lo, hi);
		sort(a, lo, j - 1);
		sort(a, j + 1, hi);
	}
	
	int partition(T a[], int lo, int hi) {
		int i = lo;
		int j = hi + 1;
		T pivot = a[lo];

		while (true) {
			while (a[++i] < pivot)	if (i == hi)	break;
			while (pivot < a[--j])	if (j == lo)	break;
			if (i >= j)		break;
			swap(a[i], a[j]);
		}

		swap(a[lo], a[j]);
		return j;
	}

};



int main() {
	
	char a[size] = { 'J','F','E','P','Z','W','B','Y','D','K','U','G','V','X','M','C','I','A','N','L','Q','O','R','T','S','H' };
	Quick<char> p1;
	p1.sort(a);

	for (int i=0;i<size;++i) {
		cout << a[i] << " ";
	}

	return 0;
}

2、三取样切分

(优化切分的方法)

第二个办法:使用子数组的一部分元素的中位数来切分数组。这样做得到的切分更好,但代价是需要计算中位数。人们发现将取样大小设为3并用大小居中的元素切分的效果最好。还可以将取样元素放在数组末尾作为"哨兵"来去掉 partition()中的数组边界测试。

【注】这里图有点抽象,直接分析代码就可以理解了

// 快速排序代码样例
#include <iostream>
#include <algorithm>
#include <random>

using namespace std;

#define size 26


//插入排序 
template <typename T>
class Insertion {
public:
	void sort(T a[],int lo,int hi) {
		//将a[]按升序排列
		int N = size;
		for (int i = lo; i <= hi; i++) {
			//将a[i]插入到a[i-1],a[i-2],a[i-3]...之中
			T temp = a[i];
			int j = i;
			while (j > 0 && temp < a[j - 1]) {
				a[j] = a[j - 1];
				j--;
			}
			a[j] = temp;
		}
	}
};



//快速排序 
template <typename T>
class Quick {
public:
	void sort(T a[]) {
		int length = size;
		shuffle(a, a + length, mt19937(random_device()()));
		sort(a, 0, length - 1);
	}
	

private:
	
	void sort(T a[], int lo, int hi) {
	
		//插入排序 ,M在 5~15 之间 
		int M=5;	 
		if(hi<=lo + M){
			Insertion<T> p1;
			p1.sort(a,lo,hi);
			return;
		}
		 
		int j = partition(a, lo, hi);
		sort(a, lo, j - 1);
		sort(a, j + 1, hi);
	}
	
	
	int partition(T a[], int lo, int hi) {
		int i = lo;
		int j = hi + 1;
		int newVIndex=ThreeMedianIndex(a,lo,hi);
		swap(a[lo],a[newVIndex]);
		
		T pivot = a[lo];
		
		while (true) {
			while (a[++i] < pivot)	if (i == hi)	break;
			while (pivot < a[--j])	if (j == lo)	break;
			if (i >= j)		break;
			swap(a[i], a[j]);
		}

		swap(a[lo], a[j]);
		return j;
	}
	
	// 返回三取样切分元素索引 
	int ThreeMedianIndex(T a[], int lo, int hi) {
    	// 子数组少于3个元素时,第一个元素作为切分元素
    	if ((hi - lo + 1) < 3)
        	return lo;

    	// 子数组有3个或以上元素时,取子数组前三个元素的中位数作为切分元素
    	T b[] = { lo, lo + 1, lo + 2 };

    	// 使用插入排序法排序新数组b,按原数组的值进行排序。排序后的结果是原数组中小中大值对应的索引
    	//插入排序 ,M在 5~15 之间 
		Insertion<T> tmp;
		tmp.sort(b,0,2);
		
    	return b[1];
	}
	
};



int main() {
	
	char a[size] = { 'J','F','E','P','Z','W','B','Y','D','K','U','G','V','X','M','C','I','A','N','L','Q','O','R','T','S','H' };
	Quick<char> p1;
	p1.sort(a);

	for (int i=0;i<size;++i) {
		cout << a[i] << " ";
	}

	return 0;
}

代码参考自:Algs4-2.3.18三取样切分 - 修电脑的龙生 - 博客园 (cnblogs.com)


3、熵最优的排序

(优化特殊情况:大量重复元素)

【注:熵是体系混乱程度的度量】

一个元素全部重复的子数组就不需要继续排序了,但我们的算法还会继续将它切分为更小的数组。在有大量重复元素的情况下,快速排序的递归性会使元素全部重复的子数组经常出现,这就有很大的改进潜力,将当前实现的线性对数级的性能提高到线性级别
一个简单的想法是将数组切分为三部分,分别对应小于等于大于切分元素的数组元素。这种切分实现起来比我们目前使用的二分法更复杂,人们为解决它想出了许多不同的办法。这也是E. W. Dijkstra的荷兰国旗问题引发的一道经典的编程练习,因为这就好像用三种可能的主键值将数组排序一样,这三种主键值对应着荷兰国旗上的三种颜色。

Dijkstra的解法如"三向切分的快速排序"中极为简洁的切分代码所示。它从左到右遍历数组一次,维护一个指针 lt 使得 a[lo..lt-1] 中的元素都小于v,一个指针 gt使得 a[gt+1..hi]中的元素都大于v,一个指针 i 使得 a[lt..i-1] 中的元素都等于v a[i..gt]中的元素都还未确定。一开始 i 和 lo 相等。

  • a[i]小于v,将a[lt]和a[i]交换,将It和i加一;
  • a[i]大于v,将a[gt]和a[i]交换,将gt减一;
  • a[i]等于v,将i加一。

 这些操作都会保证数组元素不变且缩小gt-i 的值(这样循环才会结束)。另外,除非和切分元素相等,其他元素都会被交换

//三向切分的快速排序
#include <iostream>
#include <algorithm>
#include <random>


using namespace std;


#define size 40

template <typename T>
class Quick3way{
	
public:
	void sort(T a[]){
		int length=size;
		shuffle(a,a+length,mt19937(random_device()()));
		sort(a,0,length-1);
	}
	
	
private:
	void sort(T a[],int lo,int hi){
		if(hi<=lo) return;
		int lt=lo,i=lo+1,gt=hi;
		T v=a[lo];
		while(i<=gt){
			int cmp=a[i] > v ?1 :-1;
			if		(cmp<0)	swap(a[lt++],a[i++]);
			else if	(cmp>0)	swap(a[i],a[gt--]);
			else			i++;
			
		}	//现在 a[lo..lt-1] < v = a[lt..gt] < a[gt+1..hi]成立
		sort(a,lo,lt-1);
		sort(a,gt+1,hi);
	}
	
};
	
int main() {
	
	char a[size] = { 'J','F','E','P','Z','W','B','Y','D','K','U','G','V','X','M','C','I','A','N','L','Q','O','R','T','S','H','J','J','J','J','J','J','J','J','J','J','J','J','J','J' };
	Quick3way<char> p1;
	p1.sort(a);

	for (int i=0;i<size;++i) {
		cout << a[i] << " ";
	}

	return 0;
}
	
	

这段排序代码的切分能够将和切分元素相等的元素归位,这样它们就不会被包含在递归调用处理的子数组中。对于大量重复元素的数组,这种方法比标准的快速排序的效率高得多。

3.1、优化复杂度的证明:

【注】这一段直接贴书中作者的分析,本人能力有限,看懂证明已经很吃力了。同样地,对数学不感兴趣可以跳过这一部分,只需记住“三向切分的方式处理大量重复元素的数组使得快速排序的时间从线性对数级降低到线性级别”

 



都学到这里了,奖励一下自己

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值