快速排序
一.什么是快速排序
1.快排的本质
快速排序是Koare在1962年提出的一种二叉树结构
的交换
排序,它实际上是一种对于冒泡排序改进
的一种方法。
2.快排的思想
在待排序序列中任意取一个元素
作为基准元素,按照该基准元素将待排序序列分为两个子序列
,左边子序列的值都小于
基准值,右边子序列的值都大于
基准值。然后把左右子序列当做一个子问题
,以同样的方法处理左右子序列,直到所有的元素都排列在相对应的位置上为止。快排是一个递归
问题,它是按照二叉递归树的前序路线
去划分的。
3.代码实现
void QuickSort(int* num, int left, int right)
{
if (num == NULL)
return;
//递归出口
if (left >= right)
return;
//按照基准值将待排序区间划分为两个子区间
int div = PartSort(num, left, right);
//子问题排序左子区间
QuickSort(num, left, div - 1);
//子问题排序右子区间
QuickSort(num, div + 1, right);
}
上边快排的代码只剩下partsort
没有实现,这个函数也是快排的核心,下面我们实现这个函数。
二.一次快排(partsort)常见实现方式
1.hoare版本
int PartSort1(int* num, int begin, int end)
{
int key = num[end];
int last = end;
while (begin < end)
{
//1.左边找到大于基准值的元素
while ((begin < end) && (num[begin] <= key))
begin++;
//2.右边找到小于基准值的元素
while ((begin < end) && (num[end] >= key))
end--;
//3.交换两个值
Swap(&num[begin], &num[end]);
}
//两个下标走到一块的时候,把基准值交换过来
Swap(&num[begin], &num[last]);
//返回基准值的位置
return begin;
}
2.挖坑法
//挖坑法
int PartSort2(int* num, int begin, int end)
{
int key = num[end];//把基准值拿出来(挖一个坑)
while (begin < end)
{
//1.左边找到大于基准值的元素,并放入坑里
while ((begin < end) && (num[begin] <= key))
begin++;
num[end] = num[begin];
//2.右边找到小于基准值的元素,并放入坑里
while ((begin < end) && (num[end] >= key))
end--;
num[begin] = num[end];
}
//3.把拿出来基准值放入坑里
num[begin] = key;
//返回基准值的位置
return begin;
}
3.前后指针法
//前后指针法
int PartSort3(int* num, int begin, int end)
{
int prev = begin - 1;
int cur = begin;
int key = num[end];
while (cur < end)
{
//++prev=cur说明这个元素时第一个元素或者这是一段连续的小于基准的序列
if ((num[cur] < key) && (++prev != cur))
Swap(&num[cur], &num[prev]);
cur++;
}
//把基准元素放在所有小于基准元素的紧邻后边,可以达到基准的左边小于基准,右边大于基准
Swap(&num[++prev], &num[end]);
return prev;
}
三.快排常见的几道面试题
1.快排的最坏和最优场景?时间复杂度分别为多少?
- 最坏场景:待排序列是有序的。既每次选的基准元素将待排序列划分为左右子序列,而左右子序列中必定有一个为空,它的二叉递归树深度是
N
,所以时间复杂度为O(N*N)
。 - 最好场景:每次选的基准都是待排序列最中间的元素,二叉递归树深度为O
(logN)
,所以时间复杂度为O(N*logN)
。
2.快排的空间复杂度?
快排是一个递归的过程,每次函数调用只使用了常数的空间,所以它的空间复杂度就是它递归的深度。
- 最好场景:
O(logN)
- 最坏场景:
O(N)
3.快排的稳定性
(1).什么是排序的稳定性
排序的稳定性是指,在对待排序列排序后,是否改变相同关键字的前后顺序
(既相对位置
)。例如:对【2,3,1(第一个),1(第二个),5,6】
序列排序,如果排序结果为【1(第一个),1(第二个),2,3,5,6】
那么这个排序算法是稳定
的;如果排序结果为【1(第二个),1(第一个),2,3,5,6】
,那么这个排序算法是不稳定
的,因为关键字1
的相对位置变化了。
(2).快排是否稳定
以一个例子分析快排是否稳定:
(3).排序稳定性的应用场景
分析一个排序算法的稳定性到底有什么用呢?,下边用一个场景分析:
假设在一次考试中,有两个同学的考试成绩是相同的,那么我们到底把哪一个同学排在前面呢?这时,年级主任说把学号排在前面的同学的成绩放在前面,因为学号是唯一的,我们可以先用学号把所有同学的成绩排序,在用一个稳定的排序算法在对总成绩排序一次,这时两个成绩相同的同学一定是学号在前的他就排在前面
四.快排的使用场景
不同条件下,排序方法的选择:(n指的是待排关键字的个数)
- 当
n较小
,可采用直接插入或直接选择排序
。当记录规模较小并且基本有序时,直接插入排序较好;否则因为直接选择移动的记录数少于直接插人,应选直接选择排序。 - 当待排序列初始状态
基本有序
,则应选用直接插人、冒泡或随机的快速排序
; - 当
n较大
,则应采用时间复杂度为O(nlgn)的排序方法:快速排序、堆排序或归并排序
。 - 快速排序是目前基于比较的
内部排序
中被认为是最好的方法,当待排序的关键字是随机分布
时,快速排序的平均时间最短
;堆排序所需的辅助空间少于快速排序
,并且不会出现快速排序可能出现的最坏情况
。这两种排序都是不稳定
的。若要求排序稳定,则可选用归并排序
。优先级队列
通常用堆排序
来实现
五.快排的优化
1. 优化一:三数取中
- 思想:这里的优化主要针对
选择基准元素
的优化。在选择基准的时候,当时最坏场景
的时候,左边是最小,右边是最大,我们可以先根据最小和最大元素的下标确定中间的元素下标
,然后在和要选择的基准元素交换位置
;如果是最优场景
,三数取中也可以优化,三个数中取一个中间值
,必然要比随机选择基准好一些。 - 代码实现
//三数取中优化法(找三个值中间大的那个)
int GetMidKey(int* num, int begin, int end)
{
assert(num);
int mid = begin + (end - begin) / 2;
if (num[begin] < num[mid])
{
if (num[mid] < num[end])
return mid;
else
{
if (num[begin]>num[end])
return begin;
else
return end;
}
}
else
{
if (num[begin] < num[end])
return begin;
else
{
if (num[mid]>num[end])
return mid;
else
return end;
}
}
}
//hoare版本(左边找大于基准,右边找小于基准,交换)
int PartSort1(int* num, int begin, int end)
{
//三数取中优化
int index = GetMidKey(num, begin, end);
//和要选的基准交换
Swap(&num[index], &num[end]);
int key = num[end];
int last = end;
while (begin < end)
{
//1.左边找到大于基准值的元素
while ((begin < end) && (num[begin] <= key))
begin++;
//2.右边找到小于基准值的元素
while ((begin < end) && (num[end] >= key))
end--;
//3.交换两个值
Swap(&num[begin], &num[end]);
}
//两个下标走到一块的时候,把基准值交换过来
Swap(&num[begin], &num[last]);
//返回基准值的位置
return begin;
}
2.优化二:小区间优化(把底层的递归替换掉)
快排对n较大的待排序列排序是很快的,但是对于n较小的序列时间复杂度和之间插入排序是差不多
的,而且快排的递归算法还存在函数调用和返回的开销
,所以我们可以考虑将快排递归算法的底层递归用直接插入排序给替换
掉。
void QuickSort1(int* num, int left, int right)
{
if (num == NULL)
return;
//递归出口
if (left >= right)
return;
//小区间优化(替换掉后边几层的递归)
if (right - left + 1 < 10)
InsertSort(num, right - left + 1);
//按照基准值将待排序区间划分为两个子区间
int div = PartSort1(num, left, right);
//子问题排序左子区间
QuickSort1(num, left, div - 1);
//子问题排序右子区间
QuickSort1(num, div + 1, right);
}
六.将递归快排转换为循环快排
1.算法思想
递归算法是对快排的递归二叉树按照前序的路线来排列的,那么我们要把递归算法转换为循环算法,就要利用到栈的后进先出
的特性,从最内层开始处理。
2.代码实现
//快排非递归(按照递归树的前序路线走)
void QuickSortNonR(int* num, int left, int right)
{
if (num == NULL || right <= left)
return;
Stack st;
StackInit(&st);
//先将整个区间压栈
StackPush(&st, left);
StackPush(&st, right);
while (StackEmpty(&st) != 0)
{
//取栈顶并且出栈
int end = StackTop(&st);
StackPop(&st);
int begin = StackTop(&st);
StackPop(&st);
//先划分主区间,固定好一个基准
int div = PartSort1(num, begin, end);
//如果左子序列还有大于1个元素,继续压栈
if (begin < div - 1)
{
StackPush(&st, begin);
StackPush(&st, div - 1);
}
//如果右子序列还有大于1个元素,继续压栈
if (div + 1 < end)
{
StackPush(&st, div + 1);
StackPush(&st, end);
}
}
StackDestroy(&st);
}
关于栈的一些操作实现,可以看我的另一篇博文:
https://blog.csdn.net/hansionz/article/details/81636557