「数组」快速排序 / 随机值优化|小区间插入优化(C++)

概述

在上一篇文章中,我们介绍了三种基本的暴力排序:

「数组」冒泡排序|选择排序|插入排序 / 及优化方案(C++)

他们的时间复杂度均为O(n²)级别。

在这篇文章,我们将介绍其中的第一个算法:冒泡排序的究极promax进化版:快速排序。

经过重重优化的冒泡排序几乎是最快的排序算法,可谓是从冒泡这个几乎最慢的排序彻底脱胎换骨。

思路

我们先回想一下冒泡排序的算法过程:

第i轮循环 他总把第i大的数 放在数组末尾。这带来的影响是什么?

那就是它的下一次循环的遍历范围比这次循环范围只小了1个数而已。

那如果我们不在第i轮循环找第i大的数会发生什么?

在一个大小为n的无序数组中,

假设我们在第一次循环能够把第k小中位数(或者一个很靠近中间的数)放在位置k上,也就是它在排好后的有序数组里所处的位置

我们发现:只需要分别对它的左侧和右侧进行遍历就可以了**,**而这两次遍历的范围都至于都缩小到了n/2。

而他们又分别能使得第i小的数放在位置i上,第j小的数放在位置j上(0<=i<k<j<=n-1)。

我们发现:将这三个数(i,j,k)在乱序数组中排好序(即放在数组彻底有序时他们所应该在的位置),前三次遍历的范围是:n+n/2+n/2=2n

而冒泡的前三次遍历的范围是: n+n-1+n-2=3n-3

在n极其巨大,这个提升是极其显著的,并且,对于k所划分出的左右两个小区间,这两个小区间的首次遍历所划分出的小小区间也有这样的性质,一直可以递推到区间长度为1,它们都有这样的性质。

这就是分治

算法过程

这个过程往往依靠递归实现。

quick_sort()

我们给出函数quick_sort,它接收一个范围,它只干两件事:

①将这个范围中的一个数放在它应该在的位置

②这个数划分出的左右两个小范围传入quick_sort

*注意* :如果你首次接触递归,那么你应该认识到上一级quick_sort和下一级quick_sort并不是同一个函数,他们只是有相同的名字,执行相同的功能。

一直到小范围长度减少到1为止(1个数天生有序)。

在这里,我们用partition函数执行这个分区操作。

cpp 复制代码
void quick_sort(int arr[], int l,int r) {
	if (r-l<=1)return ;
	int pos = partition(arr, l, r);
	quick_sort(arr, l, pos);
	quick_sort(arr, pos + 1, r);
}

pos表示分区完成后某个已安放好的数所处的位置。

*注意*:根据代码语言传统,l和r往往是一个左闭右开区间,即[l,r),表示范围取到l,但取不到r。


partition()

我们给出函数partition(),它接收一个范围,它只干一件事:

在区间里找个数,给它安放好,然后返回安放位置。

一般我们把这个数称为pivot(基准数)。

朴素的快速排序直接选择第一个数当基准数。

我们这样理解安放基准数:那无非就是把比他大的数放在它右边,比它小的数放在它左边,只有左边范围和右边范围的内部是否有序,我不关心。

同向双指针游走实现这件事:

先把要安放的基准数放在数组末尾,防止它干扰我们划分。(现在数组末尾就是基准数)

i和j一开始都指向范围内的第一个数:

①如果第一个数大于基准数,j前进,i不动,随后j发现的所有大于基准数的数,如此直到j发现了小于等于基准数的数:那就交换j指向的数和i指向的数,然后i前进一位,j继续往前走。

②如果第一个数小于等于基准数,那么i和j齐头并进继续走(在代码层面上其实这里一直在发生自己与自己交换),直到i和j指向的数比基准数大,j与i发生分离,i留下来维护这个数,j继续向前探索。

这样理解:

在一轮循环后,

i总是指向第一个大于基准数的数,i身后的数都小于等于基准数;

j总是指向未知区域的第一个数,j身后一直到i的范围是已经探明的比基准数大的数。

cpp 复制代码
某次循环结束后(p为基准数位置)
  
              |
            | |     | 
            | | | | | |
    |     | | | | | | |   |   |
    | |   | | | | | | |   |   |
  | | | | | | | | | | | | | | |
------------------------------------
  l         i           j     p  r

*注意*:位置r不在这个区间范围内,p才是最后一个位置。
cpp 复制代码
int partition(int arr[], int l, int r) {
	swap(arr[l], arr[r-1]);
	int i, j;
	for (i = l, j = l; j <= r - 1; j++) 
		if (arr[j] <= arr[r - 1])swap(arr[i++], arr[j]);
	return i-1;
}

在j抵达基准数后,基准数被向前交换到了它需要被安放的位置。

注意此时由于i++,我们返回i-1。


Code

cpp 复制代码
int partition(int arr[], int l, int r) {
	swap(arr[l], arr[r-1]);
	int i, j;
	for (i = l, j = l; j <= r - 1; j++) 
		if (arr[j] <= arr[r - 1])swap(arr[i++], arr[j]);
	return i-1;
}
void quick_sort(int arr[], int l,int r) {
	if (r-l<=1)return ;
	int pos = partition(arr, l, r);
	quick_sort(arr, l, pos);
	quick_sort(arr, pos + 1, r);
}

优化方案

随机数优化

还记得快速排序是冒泡排序的究极promax进化版吗?它可是会退化的

如果你选择的基准数总是区间最左侧的数,而整个数组本身相当有序,那么你的划分得到的左侧和右侧范围长度就是1和n-1。如此一来快排就退化回了冒泡。。

处理方法也很简单:在范围里随机取个数当基准数,而不是固定取最左边,这样就实现了随机分区函数。

关于C++标准库中的random文件的简单应用可参考:「数组」Knuth洗牌算法|C++random库简单介绍 / LeetCode 384(C++)

cpp 复制代码
mt19937 mt;
int random_partition(int arr[], int l, int r) {
	int pos = mt() % (r - l) + l;
	swap(arr[pos], arr[r - 1]);
	int i, j;
	for (i = l, j = l; j <= r - 1; j++)
		if (arr[j] <= arr[r - 1])swap(arr[i++], arr[j]);
	return i - 1;
}

小区间插入优化

最底层的quick_sort会调用大量的分区函数,而他们分区的范围却极小,这是不必要的,而且会相当被动地占用大量空间(函数的调用会占用空间资源)。

插入排序在小区间的表现要好于快速分区,当分区小到某个程度时,我们直接转发给插入排序。

cpp 复制代码
void QKsort(int arr[], int l, int r) {
	if (r - l <= 20) {
		insertion_sort(&arr[l],r-l); 
		return;
	}
	int pos = random_partition(arr, l, r);
	QKsort(arr, l, pos);
	QKsort(arr, pos + 1, r);
}

Code(pro)

cpp 复制代码
mt19937 mt;
int random_partition(int arr[], int l, int r) {
	int pos = mt() % (r - l) + l;
	swap(arr[pos], arr[r - 1]);
	int i, j;
	for (i = l, j = l; j <= r - 1; j++)
		if (arr[j] <= arr[r - 1])swap(arr[i++], arr[j]);
	return i - 1;
}
void QKsort(int arr[], int l, int r) {
	if (r - l <= 20) {
		insertion_sort(&arr[l],r-l); 
		return;
	}
	int pos = random_partition(arr, l, r);
	QKsort(arr, l, pos);
	QKsort(arr, pos + 1, r);
}

复杂度

时间复杂度:O(nlogn)

空间复杂度:O(logn)

复杂度分析

时间分析:

在理想情况下,每次都正好实现了区域均分。

总用时为T(n),那么

T(n)=T(n/2)+T(n/2)+n。···①

(n为本次分区所用时间,两个T(n/2)为下一级的总时间)

注意到,

T(n/2)=T(n/4)+T(n/4)+n/2。···②,代入①得到:

T(n)=T(n/4)+T(n/4)+n/2+T(n/4)+T(n/4)+n/2+n

=4T(n/4)+2n

如此迭代得到:T(n)=2^mT(1)+mn

(m为迭代折半从n得到1的次数,即m=logn)

即T(n)=nT(1)+nlogn=n+nlogn,省去小量,得到O(nlogn)。


空间分析:

在分割数组时,观察:

cpp 复制代码
--------------------------------------
----------------- --------------------
--------- ------- ------ -------------
--- ----- --- --  -- --- --- ---------
- - - --- - - --  -- - - - - ---- ----
- - - - - - - - - - - - - - - - - - --
共有logn层

*注意*:同一层的每次小函数结束后空间会被释放,下次函数都会再次占据这块空间
空间复用使得每一层的复杂度都是O(1),整体为O(logn) 。

因此空间复杂度是logn级别的。

百万数量级抗压测试

cpp 复制代码
int main()
{   int nums = 5000000;
	int* arr1 = new int[nums];
	int* arr2 = new int[nums];
	for (int i = 0; i < nums; i++) {
		int x = mt();
		arr1[i] = x;
		arr2[i] = x;
	}

	DWORD tick1 = GetTickCount64();
	quick_sort(arr1, 0,nums);//show(arr1, nums);
	DWORD tick2 = GetTickCount64();
	cout << "原始快速排序(霍尔法)(ms):" << tick2 - tick1 << endl;
		
	DWORD tick3 = GetTickCount64();
	QKsort(arr2, 0, nums); //show(arr2, nums);
	DWORD tick4 = GetTickCount64();
	cout << "随机并转发小区间优化(ms):" << tick4 - tick3 << endl;

	delete[]arr1;
	delete[]arr2;
	return 0;
}
相关推荐
Aileen_0v010 分钟前
【AI驱动的数据结构:包装类的艺术与科学】
linux·数据结构·人工智能·笔记·网络协议·tcp/ip·whisper
是小胡嘛11 分钟前
数据结构之旅:红黑树如何驱动 Set 和 Map
数据结构·算法
m0_7482550215 分钟前
前端常用算法集合
前端·算法
呆呆的猫38 分钟前
【LeetCode】227、基本计算器 II
算法·leetcode·职场和发展
Tisfy39 分钟前
LeetCode 1705.吃苹果的最大数目:贪心(优先队列) - 清晰题解
算法·leetcode·优先队列·贪心·
余额不足121381 小时前
C语言基础十六:枚举、c语言中文件的读写操作
linux·c语言·算法
ragnwang2 小时前
C++ Eigen常见的高级用法 [学习笔记]
c++·笔记·学习
yuanManGan2 小时前
数据结构漫游记:静态链表的实现(CPP)
数据结构·链表
火星机器人life3 小时前
基于ceres优化的3d激光雷达开源算法
算法·3d
虽千万人 吾往矣3 小时前
golang LeetCode 热题 100(动态规划)-更新中
算法·leetcode·动态规划