经典排序算法之快速排序 C++

快速排序


之前介绍的关于插入排序的基本操作是将一个记录插入到已经排好序的有序表。这一次,要讨论的是一类借助"交换"操作进行排序的方法。

1.冒泡排序(Bubble Sort)
1.1 算法描述

冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较相邻的两个元素,若为逆序,就把它们交换过来(默认以从小到大排序为例)。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮到"数列的顶端,好比水中的气泡逐趟向上漂浮。
具体算法流程如下:

  • 1.比较相邻的元素。如果第一个比第二个大,就交换它们两个;
  • 2.对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
  • 3.针对所有的元素重复以上的步骤,每次交换截至到上一次已经交换好的最大的数为止;
  • 4.重复步骤1~3,直到排序完成。
    冒泡排序示例
1.2 复杂度
  • 时间复杂度 O ( n 2 ) O(n^{2}) O(n2)
  • 空间复杂度 O ( 1 ) O(1) O(1)
1.3 代码实现
  • 写法一 : 从数组顶部开始,大的元素下沉
//冒泡排序,从数组头部开始,逐渐将最大的元素后移
void bubble_sort(int* arr, int len) {
	//默认需要进行交换,若中途已经排好序,则提前停止
	int change = 1; 
	for (int i = len - 1; i >= 1 && change; i--) {
		change = 0;
		for (int j = 0; j < i; j++) {
			if (arr[j] > arr[j + 1]) {
				int temp = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = temp;
				change = 1;
			}
		}
	}
}
  • 写法二: 从数组底部开始,小的元素上浮
//冒泡排序,从数组尾部开始,逐渐将最小的元素前移
void bubble_sort(int* arr, int len) {
	//默认需要进行交换,若中途已经排好序,则提前停止
	int change = 1; 
	for (int i = 0; i < len && change;i++) {
		change = 0;
		for (int j = len - 1; j > i;j--) {
			if (arr[j] < arr[j - 1]) {
				int temp = arr[j];
				arr[j] = arr[j - 1];
				arr[j - 1] = temp;
				change = 1;
			}
		}
	}
}
  • 写法三:
    或许有些版本是这样写的。
void bubble_sort(int* arr, int len) {
	int change = 1;
	for (int i = 0; i < len && change; i++) { //已经排好的元素数目
		change = 0;
		for (int j = 0; j < len - 1 - i ; j++) {
			if (arr[j] > arr[j + 1]) {
				int temp = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = temp;
				change = 1;
			}
		}
	}
}
2.快速排序(Quick Sort)
2.1 算法描述

快速排序是对冒泡排序的一种改进。它的基本思想是:通过一趟排序将待排记录分成独立的两个部分,其中一个部分记录均比另一个记录小,则可以分别对这两部分记录继续进行排序,已达到整个序列有序。
快速排序使用分治法来把一个串(list)分为两个子串(sub-list),具体流程如下:

  • 从数列中挑出一个元素(最简单的是选择第一个记录),称为 “基准”或者"枢轴"(pivot);
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。最后,以该基准记录最后所在的位置 i i i 作分界线,将序列分割成两个子序列。这个过程称为一趟快速排序或者一次划分(partition)操作;
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序,直到每部分元素个数为1时,显然已经有序。

一趟快排如下图:
一趟快排示例

2.2 复杂度
  • 时间复杂度:
    快速排序的平均时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn);
    但是在初始记录有序或者基本有序的情况下,快速排序将蜕变为冒泡排序,其时间复杂度为 O ( n 2 ) O(n^2) O(n2)
  • 空间复杂度:
    快速排序需要一个栈空间来实现递归,若每一趟排序都将记录序列比较均匀地分割成长度相接近的两个子序列,则栈的最大深度为 ⌊ l o g 2 n ⌋ + 1 \left \lfloor log_{2} n \right \rfloor+1 log2n+1,但是若每趟排序之后,基准偏向子序列的一端,则为最坏情况,栈的最大深度为 n n n
    若改写下面的快速排序算法,在一趟排序后比较分割所得的两部分序列长度,且先对长度短的子序列中的记录进行快速排序,则栈的最大深度可降为 O ( l o g n ) O(logn) O(logn)
2.3 代码实现

一趟快速排序

//划分操作
int partition(int* arr, int low, int high) {
	int pivot = arr[low]; //用子序列的第一个记录作为基准
	while (low < high) {
	//两路快排的方式,使得有大量重复元素的时候,依然能较平均的分布在两个子序列中
	// 使用“挖坑法”
		while (high > low && arr[high] >= pivot) high--;
		arr[low] = arr[high];
		while (low < high && arr[low] <= pivot) low++;
		arr[high] = arr[low];
	}
	arr[low] = pivot; //基准记录到位
	return low; //返回基准所在位置
}

快速排序:

//对顺序表arr[low...high]作快速排序
void QuickSort(int* arr, int low, int high) {
	if (low < high) {//当序列长度为1时,返回
		int pivot = partition(arr, low, high);
		QuickSort(arr, low, pivot - 1);
		QuickSort(arr, pivot + 1, high);
	}
}
3.快速排序的优化
3.1 使用插入排序

在子序列比较小的时候,其实插插入排序是比较快的,因为对于有序的序列,插排可以达到 O ( n ) O(n) O(n)的复杂度,如果序列比较小,则和大序列比起来会更加有序,这时候使用插排效率要比快排高。

实现方法:
方法1:
对于小序列,直接用插入排序if(high-low <= M) insertion(a, low, high);,M为子序列的阈值。
方法2:
快排是在子序列元素个数变成1是,才停止递归,我们可以设置一个阈值M,则大于M个元素,子序列继续递归,否则选用插排。
if(high-low<=M) return;
在快速排序完成后,再进行一次整个数组的插入排序,此时,序列已基本上有序。

由于快速排序会递归地调用很多小文件排序,所以对小的文件尽可能使用高效的算法。这个忽略小文件的技术也适用于其他递归算法。

3.2 三值取中划分

当每次分区后,两个分区的元素个数相近时,效率最高。所以找一个比较有代表性的基准值就是关键。通常会采取如下方式:

  • 1.选取分区的第一个元素做为基准值。这种方式在分区基本有序情况下会分区不均。
  • 2.随机快排:每次分区的基准值是该分区的随机元素,这样就避免了有序导致的分布不均的问题
  • 3.平衡快排:取开头、结尾、中间3个数据,通过比较选出其中的中值;
  • 4.中间的中间策略:将数组分成几组,每组取其中值,再在取出的中值中取其中值作为基准

经验证明,采取三者取中的规则可大大改善快速排序在最坏情况下的性能,即当序列基本有序,分区不均时.

代码实现:

风格一:在快速排序中进行改动

  • 取数组的最左右两边的元素a[l] a[r]和中间位置的元素a[(l+r)/2],对这三个数排序;
  • 用a[l+1]和a[(l+r)/2]交换,把arr[l+1]作为基准
  • 对arr[l+1,r-1] 进行划分,因为arr[l]和arr[r]已经放好位置,所以不参与划分,但是要参与递归排序
void swap(int* a, int* b) {
	int temp = *a;
	*a = *b;
	*b = temp;
}

void cmpswap(int* a, int* b) {
	if (*a > *b) swap(a, b); //按非递减排序
}
void quicksort_mid(int* arr, int low, int high) {
	if (low < high) {
		int mid = (low + high) / 2;
		swap(&arr[low + 1], &arr[mid]);
		//利用三交换法,对这三个元素进行排序
		cmpswap(&arr[low], &arr[high]);
		cmpswap(&arr[low], &arr[low+1]);
		cmpswap(&arr[low + 1], &arr[high]);

		//此时,已经有arr[low]<=arr[low+1]<=arr[high]
		//我们把arr[low+1]作为基准继续划分,且已经放好位置的low和high就不参与了
		int pivot = partition(arr, low + 1, high - 1);
		quicksort_mid(arr, low, pivot - 1);
		quicksort_mid(arr, pivot + 1, high);
	}
}

风格二:在划分中进行改动

  • 取数组的最左右两边的元素a[l] a[r]和中间位置的元素a[(l+r)/2],对这三个数排序;
  • 假设arr[mid]为中值,则将其与arr[l]进行互换,其余操作同2.3

代码实现:

//利用三者取中原则进行划分操作
void swap(int* a, int* b) {
	int temp = *a;
	*a = *b;
	*b = temp;
}
void cmpswap(int* a, int* b) {
	if (*a > *b) swap(a, b); //按非递减排序
}
int partition_mid(int* arr,int low,int high) {
	int mid = (low + high) / 2;
	//利用三交换法,对这三个元素进行排序
	cmpswap(&arr[low], &arr[high]);
	cmpswap(&arr[low], &arr[mid]);
	cmpswap(&arr[mid], &arr[high]);

	swap(&arr[low], &arr[mid]);//以中值作为基准
	int pivot = arr[low]; //用子序列的第一个记录作为基准
	while (low < high) {
		while (high > low && arr[high] >= pivot) high--;
		arr[low] = arr[high];
		while (low < high && arr[low] <= arr[high]) low++;
		arr[high] = arr[low];
	}
	arr[low] = pivot; //基准记录到位
	return low; //返回基准所在位置
}

在划分中进行改动的好处是:
以后依然可以保持排序函数不变而改动划分函数来改变划分方式。

3.3 重复关键字
3.3.1 相等元素的处理

之间写的常规划分程序在碰到等于pivot的记录是直接跳过的,但是,这会造成在下次进行划分时,还要再次进行比较,尤其是在数组所有元素都相同的时候,比较次数退化到最差情况的 O ( n 2 ) O(n^{2}) O(n2)。所以,尽管增加了交换次数,但是我们依然可以选择在遇到想等元素的时候停止并交换,即在和pivot比较时,去掉"="

3.3.2 三路划分

快排是二路划分的算法。如果待排序列中重复元素过多,也会大大影响排序的性能。这时候,如果采用三路划分,将重复元素进行处理,则会很好的避免这个问题。
实现方法:
先选取一个pivot,设为T,那么数列可以分为三部分:小于T,等于T,大于T,那么等于T的部分就不用参与后续递归了:
三路划分示例
方式一:将数组划分为3个区域:小于pivot,等于pivot以及大于pivot
fat partition
这里,定义几个边界:v=arr[l]为基准,变量 i i i为遍历指针, l t lt lt为小于等于v的分界点, g t gt gt为未遍历元素和大于v的分界点,指向未遍历元素。

void quick_sort_three(int* arr, int low, int high) {
	int i, lt, gt;
	if (low < high) {
		i = low + 1;
		lt = low;
		gt = high;

		while (i <= gt) {
			if (arr[i] < arr[low]) {
				swap(&arr[lt+1], &arr[i]);
				lt++;
				i++;
			}
			else if (arr[i] > arr[low])
			{
				swap(&arr[i], &arr[gt]);
				gt--;
			}
			else i++;
		}
		swap(&arr[low], &arr[gt]);
		quick_sort_three(arr, low, lt);
		quick_sort_three(arr, gt + 1, high);
	}
}

但是这种方式只用了一个工作指针i,且实现比较复杂,效率低效。

方法二:将左子序列中和基准元素相等的放在序列最左边,将右子序列中和基准元素相等的放在序列最右边,最后将左右相等的元素交换到中间。
三路快排1
三路快排2
这个算法称为split-end,且可以和两路快排结合起来,即两端同时进行扫描,但是遇到相等的元素不是进行互换,而是各自交换到两端。

void swap(int* a, int* b) {
	int temp = *a;
	*a = *b;
	*b = temp;
}
//vecswap方法用于批量交换一批数据,将a位置(包括a)之后n个元素与b位置(包括b)之后n个元素进行交换
void vecswap(int x[], int a, int b, int n) {
	for (int i = 0; i < n; i++, a++, b++)
		swap(&x[a],&x[b]);
}
/*三路法快速排序--可适用于重复关键字(需要最后使用插入排序)*/
void quick_sort_3_way(int* arr, int low, int high) {
	if (high <= low) return;
	int pivot = arr[low];
	// a,b进行左端扫描,c,d进行右端扫描
	int a = low, b = a, c =high , d = c;
	while (true) {
		// 尝试找到大于pivot的元素
		while (b <= c && arr[b] <= pivot) {
			// 与pivot相同的交换到左端
			if (arr[b] == pivot)
				swap(&arr[a++], &arr[b]);
			b++;
		}
		// 尝试找到小于pivot的元素
		while (c >= b && arr[c] >= pivot) {
			// 与pivot相同的交换到右端
			if (arr[c] == pivot)
				swap(&arr[c], &arr[d--]);
			c--;
		}
		if (b > c)
			break;
		// 交换找到的元素
		swap(&arr[b++],&arr[c--]);
	}

	// 将相同的元素交换到中间
	int s, n = high;
	s = (a - low) < (b - a) ? (a - low) : (b - a);
	vecswap(arr, low, b - s, s);
	s = (d - c) < (n - d) ? (d - c) : (n - d);
	vecswap(arr, b, n - s, s);

	// 递归调用子序列
	s = b - a;
	quick_sort_3_way(arr, low, s + low - 1);
	s = d - c;
	quick_sort_3_way(arr, high - s +1, high);
}
参考资料

1.严蔚敏 吴伟民.《数据结构 10.3快速排序》(C语言版)。
2.Sartaj Sahni. 《数据结构,算法与应用 18.2.3快速排序》(C++语言描述)
3.十大经典排序算法(动态演示)
https://www.cnblogs.com/onepixel/articles/7674659.html
4.快速排序及优化
http://www.blogjava.net/killme2008/archive/2010/09/08/quicksort_optimized.html
5.排序算法之三路划分的快速排序
https://blog.csdn.net/jlqCloud/article/details/46939703

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值