如果结果不如你所愿,那就在尘埃落定前奋力一搏。 – 《夏目友人帐》
目录
一.快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
1.单趟过程
1.1Hoare
单趟基本过程及动图展示:
基本过程:📝
- 1)先选基准值:一般选择最左边或最右边的值为key;right和left分别从右端和左端向中间进行移动。
- 2)left去找比key值大的位置,right去找比key值小的位置。若选择最左边为key,右端right先走,同理若选择最右端为key,左端left先走。
- 3)当两者都找到后,交换两者的值,然后接着移动。
- 4)当两者相遇时交换key位置和相遇位置的值,并把相遇位置设置为新的key。
代码实现:
//Hoare
int PartSort1(int* a, int begin, int end)
{
int left = begin, right = end;
int key = left;//key选最左侧
while (left < right)
{
//key在最左侧,right先走,找到小于key的位置
while (left < right && a[right] >= a[key])
{
right--;
}
//left移动,找到大于key的位置
while (left < right && a[left] <= a[key])
{
left++;
}
//交换left和right位置的值
swap(&a[left], &a[right]);
}
//相遇后交换key和相遇位置的值
swap(&a[left], &a[key]);
key = left;//将相遇位置设置成新的key值
return key;
}
📝易错点:
-
- while (left < right && a[right] > a[key]):这样写会引起程序的死循环
例如:
left选择大于6的,right选择小于6的,这样left和right一直不会动,所以会引起死循环会引起
- 2.while ( a[right] >= a[key]):这样写会引起越界
例如:
key在左边,right先走,找小,结果没有比key更小的,就会引起越界。
1.2挖坑法:
单趟基本过程及动图展示:
基本过程:📝
- 1) 先选坑位:一般选择最左边或最右边的位置为坑位,其值为key;right和left分别从右端和左端向中间进行移动。
- 2)left去找比key值大的位置,right去找比key值小的位置。若选择最左边为坑位,右端right先走,同理若选择最右端为坑位,左端left先走。
- 3)假设right先走,当right找到比key小的值,将right的值给坑位,新的坑位设置在right处。
- 4)然后left找到比key大的值,将left的值给坑位,新的坑位设置在left处。
- 5)当两者相遇时将key的值给相遇位置(坑位),最后返回坑位。
代码实现:
//挖坑法
int PartSort2(int* a, int begin, int end)
{
int left = begin, right = end;
int key = a[left];
int hole = left;
while (left < right)
{
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];//将right位置的值给坑位
hole = right;//将right位置设置为坑位
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];//将left位置的值给坑位
hole = left;//将leftt位置设置为坑位
}
a[left] = key;//最后将key的值放置在坑位
return hole;//返回坑位
}
1.3前后指针法:
单趟基本过程及动图展示:
基本过程:📝
- 1)定义两个指针prev和cur,分别指向起始位置。key选择最开始。
- 2)cur先走,找比key小的值,遇到大于key就一直++cur,找到小于key的就++prev,然后交换cur和prev的值。
- 3)直到cur超过右边界,退出循环,交换prev与key的值,最后将key的位置设置在prev处。
代码实现:
int PartSort3(int* a, int begin, int end)
{
int key = begin;
int prev = begin, cur = begin ;
while (cur <= end)
{
if (a[cur] < a[key] && ++prev != cur)
{
swap(&a[cur], &a[prev]);
}
cur++;
}
swap(&a[prev], &a[key]);
key = prev;
return key;
}
++prev != cur的原因是:
当cur == prev时,没必要进行交换
- 把交换的条件设置为cur小于key且prev的下一个位置不等于cur
- 我们发现其实cur找到小交换后cur++,cur找大后也++,所以cur++拿到了if条件外。
if (a[cur] < a[key] && ++prev != cur)
{
swap(&a[cur], &a[prev]);
}
cur++;
2.整体过程:
- 将key分为两边,分别进行递归,类似二叉树的前序遍历。
- 单趟排序得到key后,==将key的左区间 【begin,key-1】,key的右区间【key+1,end】==再分别走单趟排序,直到begin >= end。
- 那我们一直重复这个单趟的排序,就可以实现对整个数组的排序了,这是一个递归分治的思想。
2.1整体的实现:
void QuickSort(int* a, int begin,int end)
{
if (begin >= end)
{
return;
}
int key = PartSort1(a, begin, end);
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
key的左区间 【begin,key-1】key的右区间【key+1,end】进行递归
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
3.复杂度分析
【时间复杂度】:
O
(
N
l
o
g
N
)
\color{#FF0000}{【时间复杂度】:O(NlogN)}
【时间复杂度】:O(NlogN)
【空间复杂度】:
O
(
l
o
g
N
)
\color{#FF0000}{【空间复杂度】:O(logN)}
【空间复杂度】:O(logN)
在理想状况下,我们选择的key都是比较中间大小的数值,这样在最后key会处在中间的位置。
时间复杂度:
每一次的递归就都是一个二分,也可以看成是一个二叉树的样子,高度就是logN
随着key的不断确定,每次遍历的次数都在减小,但我们知道大o渐进法,这些减少的都可以看作常数级别,
因此N-1,N-3,N-7都可以看作N。
因此时间复杂度就是:O(NlogN)
空间复杂度:
主要是递归造成的栈空间的使用,最好情况,递归树的深度为logn
其空间复杂度也就为 O(logn)
但这只是理想情况下。。。。
快排缺陷一:处理有序序列
- 假如对于一个逆序序列,选择最左边也就是最大的那个数做为key,右边right找小,但是右边没有比key要小的,需要遍历N次,下一次right遍历N-1次,N-2次,N-3次,N-4次。。。。很显然是一个等差数列时间复杂度是O(n2)
- 对于一个逆序序列,会递归调用N-1次,其空间复杂度为O(n)。
所以时间复杂度和空间复杂度都因处理有序序列退化了好多。
快排缺陷二:递归层数过深栈溢出
- 既然是递归,那就需要建立栈帧,但是我们知道内存中的栈空间容量是有限的,调用次数过多就会导致栈溢出
看到这里不要放弃对快排的学习,下面我们介绍一些快排的优化方案,可以有效弥补上面快排的缺陷。
快排缺陷三:key大量重复
例如下面很极端的场景,当数据都为重复的时候,时间复杂度退化成了O(n2)。
key大量重复时,性能也会出现下降。
4.快排的优化
4.1三数取中法(针对缺陷一)
三数取中法可以解决快排缺陷一:处理有序序列的不足。
我们设置三个数begin在数组最左端,end在数组最右端,mid在数组中间。
三数取中就是从begin,end,mid三个数中选择中间大小的值。
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
return mid;
else if (a[begin] > a[end])
return begin;
else
return end;
}
else //a[begin] > a[mid]
{
if (a[mid] > a[end])
return mid;
else if (a[begin] > a[end])
return end;
else
return begin;
}
}
下面a[begin] < a[mid]a简化为begin < mid来说:
- 前提begin<mid
-if (a[mid] < a[end]):说明mid为中间数,可以返回 - 如果到判断else if (a[begin] > a[end]),此时有两个前提begin<mid,mid>end,再加上此时if条件为begin>end,说明begin为中间数
- 最后else,此时有三个前提:begin<mid,mid>end,begin < end,说明end为中间数
if (a[begin] < a[mid])
{
if (a[mid] < a[end])//说明mid为中间值
return mid;
else if (a[begin] > a[end])
return begin;
else
return end;
}
- 前提begin > mid
- if (a[mid] > a[end]),说明mid为中间数
- 如果到判断else if (a[begin] > a[end]),此时有两个前提begin>mid,mid<end,再加上此时if条件为begin>end,说明end为中间数
- 最后else,此时有三个前提:begin>mid,mid<end,begin < end,说明begin为中间数
else //a[begin] > a[mid]
{
if (a[mid] > a[end])
return mid;
else if (a[begin] > a[end])
return end;
else
return begin;
}
4.2左右小区间法(针对缺陷二)
左右小区间法针对缺陷二:递归层数过深栈溢出
假设我们对1000个数据进行排序,2^10 = 1024,递归调用的高度差不多是10。
大小 | 层数 |
---|---|
20 = 1 | 第一层 |
21= 2 | 第二层 |
22= 4 | 第三层 |
23= 8 | 第四层 |
24= 16 | 第五层 |
25= 32 | 第六层 |
26= 64 | 第七层 |
27= 128 | 第八层 |
28= 256 | 第九层 |
29= 512 | 第十层 |
我们可以发现后三层的数据量占比很大,内存消耗很多,我们应该想办法将最后的这几层递归消除掉。
【左右小区间法】,就是到最后10几个数的时候,我们放弃快排,去选择其他排序方法。
- 冒泡,选择:时间复杂度O(n2),不考虑
- 堆排序:虽然性能不错,但是还需要建堆,不考虑
- 希尔排序:对于10多个数,没必要再进行预排序,有些杀鸡用牛刀
所以经过排除使用选择排序
if ((end - begin) < 15)
{
InsertSort(a + begin, end - begin + 1);
}
else
{
int key = PartSort1(a, begin, end);
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
在小于15个数据量的时候使用插入排序,大于继续使用快排。
4.3 三路划分法(针对缺陷三)
三路划分法针对缺陷三:key大量重复
- 我们之前的方法都是两路划分,即key的左边都比key小,key的右边都比key大
- 三路划分:分为三路,小于key的,等于key的,大于key的
算法思路:
- 定义三个指针left,cur,right一个变量key。left指向最左端,right指向最右端,cur指向left的下一位,key为最左端的值
- 当a[cur] < key,交换left和cur位置的值,left++,cur++。
- 当a[cur] == key ,cur++
- 当a[cur] > key,交换cur和right位置的值,right–
- 注意:此时的cur不++,因为还要判断right交换来的值。
cur位置的值为8 > key ,交换cur和right位置的值,right–
cur = key,cur++
cur = key,cur++
cur = key,cur++
cur位置的值小于key,交换left和cur位置的值,cur++,left++
cur位置的值小于key,交换left和cur位置的值,cur++,left++
最后我们可以看到
-
和key相同的就是left和right之间的值,小于key的在left的左边,大于key的在right的右边
-
当cur位置超过right时结束
核心思想:
- 跟key相等的往后推
- 比key小的往左边甩
- 比key大的往右边甩
- 跟key相等的就在中间
代码实现:
void QSThreeDivision(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
if ((end - begin) < 15)
{
InsertSort(a, end - begin + 1);
}
else
{
int mid = GetMidIndex(a, begin, end);
swap(&a[mid], &a[begin]);
int key = a[begin];
int right = end, left = begin, cur = begin + 1;
while (cur <= right)
{
if (a[cur] < key)
{
swap(&a[cur], &a[left]);
cur++;
left++;
}
else if (a[cur] > key)
{
swap(&a[cur], &a[right]);
right--;
}
else
{
cur++;
}
}
//[begin,left-1][left,right][right+1,end]
QSThreeDivision(a, begin, left - 1);
QSThreeDivision(a, right + 1, end);
}
}
5.快速排序的非递归算法
我们之前说过,递归层数太多会引起栈溢出,所以下面我们学习一下非递归算法是很有必要的。
- 我们需要借助栈的辅助(当然队列也可以)
基本过程:
我们模拟递归,先处理左半边
我们先入0,再入9,
0 9 |
---|
- 我们知道栈的特点是后进先出。所以我们先出右边界9,再出左边界0,我们对0,9进行单趟排序,得到key为5。我们划分左右边界,左边0 ~ 4,右边6 ~ 9,由于我们先处理左边,所以我们先入6,9;再入0,4.
6 9 0 4 |
---|
- 此时栈不为空,我们取出0,4,对0,4进行单趟排序,得到key为3.我们划分左右边界,左边0 ~ 2,右边4 ~ 4,4~4不需要入栈,我们入0,2.
6 9 0 2 |
---|
- 此时栈不为空,我们取出0,2,对0,2进行单趟排序,得到key为1我们划分左右边界,左边0 ~ 0,右边2 ~ 2,均不需要入栈
6 9 |
---|
之后就是对右半边进行排序。。
代码实现:
void QuickSortNoR(int* a, int begin, int end)
{
ST st;
StackInit(&st);
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
int key = PartSort1(a,left,right);
//[left key - 1] key [key + 1 right]
if (key+1 < right)//若是区间的值 > 1,则继续入栈
{
StackPush(&st, key + 1);
StackPush(&st, right);
}
if (left < key - 1)//若是区间的值 > 1,则继续入栈
{
StackPush(&st, left);
StackPush(&st, key - 1);
}
}
StackDestroy(&st);
}
感谢您的阅读