基本介绍
大家好,我的嘉瓦仔。
在平时刷题的过程中我们总会遇到排序问题,想要提高通过率,很多人会对你说:“快排“这个字眼,萌新可能会云里雾里。一看代码就会感到恐惧,看不下去。
注:以下几段完全可以不看,但是为了更好的理解快排,等会了快排的代码后可以再看看。
下面我来解释一下
快排:就是最快的排序方法,请相信无数人的总结,当你学会了快排再和其他算法进行比较后你也会发出这样的惊呼!
快速排序是在冒泡排序的基础上改进而来的,冒泡肯定大家都会吧。就是通过相邻两个元素交换,一点点将最大的挤到最后面。但是整体的效率不高,而快排的交换距离是很大的,不需要一格格的挪,因此比较和交换次数会少很多,速度自然会很快。
其实快速排序在最坏情况下的时间复杂度和冒泡排序一样,都为 O(n2),那就是每次比较都需要交换,但是这种情况并不常见。如果每次比较都需要交换,那么数列的平均时间复杂度是 O(nlogn),事实上在大多数时候,排序的速度依然要快于这个平均时间复杂度。在这里就不能不说到分治法了,也就是分而治之,把问题分为一个个的小部分来分别解决,再把结果组合起来。
快速排序只是使用数组原本的空间进行排序,所以所占用的空间应该是常量级的,但是由于每次划分之后是递归调用,所以递归调用在运行的过程中会消耗一定的空间,在一般情况下的空间复杂度为 O(logn),在最差的情况下,若每次只完成了一个元素,那么空间复杂度为 O(n)。所以我们一般认为快速排序的空间复杂度为 O(logn)。
快速排序是一个不稳定的算法,在经过排序之后,可能会对相同值的元素的相对位置造成改变。(注:算法中的稳定与否通常看相同元素的相对位置是否变化)
快速排序基本上被认为是相同数量级的所有排序算法中,平均性能最好的。
(哈哈哈,看懂了吗?我已经尽可能把文字不换行了。)
作为对读者有好的嘉瓦仔可不想和大家一起在枯燥的文字中解读快排。
哎哎,大家把拳头发下。 咳咳,作为一名现代优质人类,我们要以和为贵。
思想分析
首先快排思想其实很简单的,就是先把想排的数组给拆成单个的元素进行比较分析。
单个元素左边的都比它小,右边的都比它大。如果每个元素都能保证如此,那么整个数组必然保证递增循序的。
我们可以分为三步走
1:找到基准值,默认以左边第一个为主
2:先从右边开始逐元素比较,如果比基准元素小就交换。再从左边逐元素比较,遇到比基准元素大的就交换。
3:重复1,2操作直到数组整体有序。
下面来看一下原理
对于
数组arr[10]={6,8,7,9,0,1,3,2,4,5};
6 | 8 | 7 | 9 | 0 | 1 | 3 | 2 | 4 | 5 |
我们选用第一个数作为基
我们选用做左边第一个元素作为基准值其所在的位置称为坑位,坑位会在循环的过程中来会从左边跑到右边,得到值后又从右边跑到左边。
坑位的准则就是:谁给坑位值,谁将变成坑位。
谁给坑位值:左边比基准值小的,右边比基准值大的。
(也好理解吧,你的东西给别人了,那肯定你自己就少了,被坑了嘛,哈哈哈)
如果你看到前面依然是云里雾里,很正常,下面的内容仔细看,会用代码,自然而然你就懂了。
手撕代码
一开始我们要确保while (begin < end)----------------1且在整个循环中都要保证begin<end这样才有始有终,后面标黄的可以看出其实都一样。
也就是我们循环一定要有底线,当begin=end时跳出循环,begin和end为同一个坐标,而两边也已经能保证都比基准值大,此时我们就可以将基准值赋给他俩共同指的空间,至于为何两边都满足条件,下面我们来看一下
1)
起初坑位的位置在begin处
Key=6 ;
6 | 8 | 7 | 9 | 0 | 1 | 3 | 2 | 4 | 5 |
Begin=0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | End=9 |
利用
while (arr[end] >= key && begin < end)//从右边找到比key小的数就是遇到比key小的数就不再循环
{
--end;//因为是从右边开始的,所以需要减1来进行往左遍历找值
}
arr[begin] = arr[end];//将右边数字小的数字赋给坑位的位置------------2
我们首先从右边开始找发现5 比6 小所以将end头顶的值:5 赋给begin头顶的值。
而这是我们并没有进循环内,因此此时end=9的值不变。
下标9给坑位值了,所以下标9变成了坑位。
这时就变成
key=6 ;
5 | 8 | 7 | 9 | 0 | 1 | 3 | 2 | 4 | 5 |
begin=0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | end=9 |
此时跳出循环2
2)
接着代码运行到
while (arr[begin] <= key && begin < end)//从左边找出比key大的数字
{
++begin;//因为是从左边开始的所以需要加1进行遍历找值
}
arr[end] = arr[begin];//将左边较大的数字填入坑位的位置。----------------3
key=6 ;
此时开始从左边找值,我们发现8 比6 大,所以开始进行填坑,将8填入坑位。
下标1给坑位值了,所以它变成了坑位。
5 | 8 | 7 | 9 | 0 | 1 | 3 | 2 | 4 | 8 |
0 | begin=1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | end=9 |
此时begin<end,因此大的循环没有跳出重新进行上述步骤。
3)
最终一趟循环后会变成这样(中间过程要亲手试试哦)
key=6 ;
5 | 4 | 2 | 3 | 0 | 1 | 3 | 9 | 7 | 8 |
0 | 1 | 2 | 3 | 4 | 5 | Begin=end=6 | 7 | 8 | 9 |
begin=end结束循环
利用arr[begin] = key;
将key的值填入坑位
结果就是
5 | 4 | 2 | 3 | 0 | 1 | 6 | 9 | 7 | 8 |
0 | 1 | 2 | 3 | 4 | 5 | Begin=end=6 | 7 | 8 | 9 |
这是数据结构考试会考的“一趟“的结果!!!
接着我们重新选择基准值还按照默认左边第一个的原则
int key1 = begin;
不过此时我们要把数组分开成两部分,因为中间的6已经固定,所以无需参加后续排序。
QuickSort(arr, left, key1 - 1);
QuickSort(arr, key1 + 1, right);
这两步乍一看好麻烦,不要慌哈,咱们一起搞懂这个,你看看里面构造其实很有规则的
left---------key1-1
key1+1------right
left | …… | key1 - 1 | key1 | key1 + 1 | …… | right |
懂了吗?
key1被固定了其实就是原本begin=end的值被固定了就是前面提到的6。
也就是我们后续就不会在用key1的值了,所以你看在代码中就刻意绕过了它。
我们接着思路分别处理左右数组,再固定值,再划分,再处理,,,。直到我们将每一个值都固定了。就会得到如下结果图。你写的和这个结果一样吗?
代码理解好了吧,这就是完整的代码了。
基本代码
void QuickSort(int* arr, int begin, int end)
{
if (begin >= end)//如果只剩下单个单位就终止,其实想想递归不就是从最小单元开始进行分析,因此对于数组元素分析,要递归到单个元素。
{
return;
}
int left = begin, right = end;
int key = arr[begin];//将最右边的数字作为基准值
while (begin < end)
{
while (arr[end] >= key && begin < end)//从右边找到比坑位小的数
{
--end;
}
arr[begin] = arr[end];//将右边数字小的数字填入坑位
while (arr[begin] <= key && begin < end)//从左边找出比坑位大的数字
{
++begin;
}
arr[end] = arr[begin];//将左边较大的数字填入坑位。
}
arr[begin] = key;
int key1 = begin;
QuickSort(arr, left, key1 - 1);
QuickSort(arr, key1 + 1, right);
}
如果上面对于快排的基本思想懂了,那么下面的就很好懂了,如果上面的还没有懂,那么还是需要多看看,思考思考。
这是三道练习题
1:采用递归方式对顺序表进行快速排序,下列关于递归次数的叙述中,正确的是( )。
A. 递归次数与初始数据的排列次序无关
B. 每次划分后,先处理较长的分区可以减少递归次数
C. 每次划分后,先处理较短的分区可以减少递归次数
D. 递归次数与每次划分后得到的分区处理顺序无关
2:为实现快速排序法,待排序序列宜采用存储方式是( )。
A. 顺序存储 B. 散列存储
C. 链式存储 D. 索引存储
3:请利用基本排序方法对数组47、29、71、99、78、19、24、47进行排序,请写出一趟排序的结果和总的排序的结果(基准值默认为左侧第一个元素)
答案:
1:D 2:A
3:24,29,19,47,78,99,71,47 最后结果:19,24,29,47,47,71,78,99
简单的会了,利用两道题练练手吧。
P1177 【模板】快速排序 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
P1923 【深基9.例4】求第 k 小的数 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
下面是对于优化问题的总结,如有出错及不全的地方,还请大佬批评指正。、
优化1:随机法快速排序
基本思想
选取待排序列中任意一个数作为基准值。
引入的原因:在待排序列是部分有序时,固定选取枢轴使快排效率底下,要缓解这种情况,就引入了随机选取枢轴
算法性能
在整个数组数字全相等时,仍然是最坏情况,时间复杂度是O(n2)。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/2n。所以随机化快速排序可以对于绝大多数输入数据达到O(nlogn)的期望时间复杂度。
时间复杂度
O(nlogn)。
代码
void quickSort(int a[], int left, int right)
{
if (left >= right)
{
return;
}
int i = left, j = right, pivot = rand() % (right - left + 1) + left;//获取随机基准值
swap(a[left], a[pivot]);
while (i < j)
{
while (j > i && a[j] >= a[left])
{
j--;
}
while (i < j && a[i] <= a[left])
{
i++;
}
swap(a[i], (i == j) ? a[left] : a[j]);
}
quickSort(a, left, i - 1);
quickSort(a, j + 1, right);
}
优化2:找寻中值法
算法思想
一组序列的中值(中位数)是基准值的好的选择,这样我们可以将序列均分为两个子序列进一步减少了递归量;但要计算一组数组的中位数就比较耗时,会减慢快排的效率。因此可以通过计算数组的第一个,中间位置,最后一个元素的中值来代替。比如序列:[7,1,4,9,5,3,6,2,7,10]。第一个元素是7,中间(left+right)/2(向下取整)元素为5,最后一个元素为10。中位数是5,n那么我们就选择5作为我们的基准值。这样我们就选择了一个比较好的基准值。
算法性能
较随机基准法仍可提升14%左右的性能
int S_quicksort(int arr[], int i, int j)
{
int mid = i + ((j - i) /2);//计算数组中间的元素的下标
//使用三数取中法选择枢轴
if (arr[mid] > arr[j])//保证arr[mid] <= arr[j]
{
swap(arr[mid], arr[j]);
}
if (arr[i] > arr[j])//保证 arr[i] <= arr[j]
{
swap(arr[i], arr[j]);
}
if (arr[mid] > arr[i]) //保证 arr[i] >= arr[j]
{
swap(arr[mid], arr[i]);
}
int left = i;
//此时,arr[mid] <= arr[i] <= arr[j]
//i的位置上保存这三个位置中间的值
//分割时可以直接使用low位置的元素作为枢轴,而不用改变分割函数了
while (i < j)
{
while (j > i && a[j] >= a[left])
{
j--;
}
while (i < j && a[i] <= a[left])
{
i++;
}
swap(a[i], (i == j) ? a[left] : a[j]);
}
S_quickSort(a, left, i - 1);
S_quickSort(a, j + 1, j);
}
优化3:插入排序结合
当待排序序列的长度分割到一定大小后,使用插入排序
原因:对于很小和部分有序的数组,快排不如插排好。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排
截止范围:待排序序列长度N = 10,虽然在5~20之间任一截止范围都有可能产生类似的结果,这种做法也避免了一些有害的退化情形。
void insertSort(int a[], int left, int right)
{
for (int i = left + 1; i <= right; i++)
for (int j = i; j > 0 && a[j] < a[j - 1]; j--)
swap(a[j], a[j - 1]);
}
void QuickSort(int* arr, int begin, int end)
{
if (begin >= end)//如果只剩下单个单位就终止
{
return;
}
if (end - begin + 1 < 10)
{
insertSort(a, begin, end);
return;
}
int left = begin, right = end;
int key = arr[begin];
while (begin < end)
{
while (arr[end] >= key && begin < end)//从右边找到比坑位小的数
{
--end;
}
arr[begin] = arr[end];//将右边数字小的数字填入坑位
while (arr[begin] <= key && begin < end)//从左边找出比坑位大的数字
{
++begin;
}
arr[end] = arr[begin];//将左边较大的数字填入坑位。
}
arr[begin] = key;
int key_1 = begin;
QuickSort(arr, left, key_1 - 1);
QuickSort(arr, key_1 + 1, right);
}
优化4:聚合重复元素
一个数组当含有大量元素时,没有重复元素是不可能的,而且在实际问题中我们能往往会遇到含有大量的重复元素,因此处理的方法就是在递归的时候将相同元素放到数组两端,递归结束后将与key相同的元素放在key周围......。
预知后事如何,请等待下会更新......。