文章目录
1. 基本思想
快速排序是 Hoare 于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
将区间按照基准值划分为左右两半部分的常见方式有以下三种:
- hoare 法: 选择待排序序列的最后一个元素作为基准值,但是若待排序序列的最后一个元素是最值或者接近最值的情况,就会使排序后的左右序列个数相差巨大,效率降低,所以我们一般采用三数取中法,找出序列开始节点、序列之间节点、序列最后的节点,三数的中间值作为基准值,具体实现有如图以下步骤:
- 挖坑法: 与 hoare 找基准值的方法相同,具体实现有如图以下步骤:
- 前后指针法: 与 hoare 找基准值的方法相同,具体实现有如图以下步骤:
2. 代码实现
2.1 基准值三数取中
int BaseNumber(int array[], int begin, int end) {
int mid = begin + ((end - begin) >> 1); // 采用位运算效率高
if (array[begin] > array[mid]) { // 逻辑判断取出中位数的下标,当然排序及其它方法均可
if (array[begin] > array[end]) {
if (array[mid] > array[end]) {
return mid;
}
else {
return end;
}
}
else {
return begin;
}
}
else {
if (array[mid] > array[end]) {
if (array[begin] > array[end]) {
return begin;
}
else {
return end;
}
}
else {
return mid;
}
}
}
2.2 交换两值的经典方式
三次异或进行两整数交换
void Swap(int *x, int *y) {
//int tmp = *x;
//*x = *y;
//*y = tmp;
*x = ((*x) ^ (*y));
*y = ((*x) ^ (*y));
*x = ((*x) ^ (*y));
}
2.3 hoare法区间划分方式
hoare法要点:
- 首先进行三数去中确定基准下标
- 将基准值与数组最后一个元素作交换,即数组最后一个元素为基准
key
,设置两个指针begin
、end
begin
从前往后移动遇到比基准key
大的停止end
从后往前移动遇到比基准key
小的停止begin
和end
所对应的数据交换- 重复直至
begin==end
int QuickSort1(int array[], int begin, int end) {
// 找基准
// 由于每次规划取到最大值或最小值的概率都非常高,
// 这样容易使树变成单支树,所以采用三数取中法来降低取到最值的概率
int index = BaseNumber(array, begin, end); // 基准值在数组中的下标
if (index != end) {
Swap(&array[index], &array[end]); // 将基准值与最后一个数字进行值交换
}
// 基准值
int key = array[end];
// 基准值的下标
int k = end;
// 两个指针、begin从0开始,end从size-1开始
while (begin != end) {
// begin向后移动,找比基准值大的元素,且begin不能大于end
// 如果array[begin]比key小,则++begin
while (array[begin] <= key && (begin < end)) {
++begin;
}
// end向前移动,找比基准值key小的元素,且end不能小于begin
// 如果array[end]比key大,则--end
while (array[end] >= key && (begin < end)) {
--end;
}
// 如果下标begin和下标end不相等,则交换所对应的数组元素值
if (begin != end) {
Swap(&array[begin], &array[end]);
}
}
// 如果begin的最终位置就是基准的位置则不用交换
if (begin != k) {
// 将基准值挪到相应位置上
Swap(&array[begin], &array[k]);
}
return begin;
}
采用hoare法
讲解时的图片作为测试用例:
测试数据:int array[] = { 0,5,4,9,3,6,8,7,1,2 };
其中选取的三数为0,3,2
,故基准值为 2,划分后的区间应为0,1,2,9,3,6,8,7,5,4
返回begin = 2
,且基准值左边全部小于基准值,右边全部大于基准值。
2.4 挖坑法区间划分方式
挖坑法要点:
- 首先进行三数去中确定基准下标
- 将基准值与数组最后一个元素作交换,即数组最后一个元素为基准
key
- 将最后一个元素标记设置为第一个坑
- 设置两个指针
begin
、end
begin
从左边开始找比关键字大的元素将其入坑,begin
所在位置变为坑end
从右边开始找比关键字小的元素将其入坑,end
所在位置变为坑begin
和end
所对应的数据交换- 重复直至
begin==end
将标记的第一个元素入坑
int QuickSort2(int array[], int begin, int end) {
// 依旧三数取中法确定基准
int index = BaseNumber(array, begin, end);
if (index != end) {
Swap(&array[index], &array[end]);
}
// 第一个坑
int key = array[end];
int k = end;
while (begin != end) {
// begin从左边开始找比关键字大的元素将其入坑
// begin所在位置变为坑
while (array[begin] <= key && begin < end) {
++begin;
}
if (begin != end) {
array[end] = array[begin];
--end;
}
// end从右开始找比关键字小的元素将其入begin坑
while (array[end] >= key && begin < end) {
--end;
}
if (begin != end) {
array[begin] = array[end];
++begin;
}
}
if (begin != k) {
array[begin] = key;
}
return begin;
}
采用挖坑法
讲解时的图片作为测试用例:
测试数据:int array[] = { 0,5,4,9,3,6,8,7,1,2 };
其中选取的三数为0,3,2
,故基准值为 2,划分后的区间应为0,1,2,9,3,6,8,7,4,5
返回begin = 2
,且基准值左边全部小于基准值,右边全部大于基准值。
仔细观察可以发现,得到的区间划分与 hoare
不一样了,提示:最后一位数字~
2.5 前后指针法区间划分方式
前后指针法:
- 首先进行三数去中确定基准下标
- 将基准值与数组最后一个元素作交换,即数组最后一个元素为基准
key
cur
在待排序序列的最左侧perv=cur-1
- 如果
arr[cur]<key
,则prev
和cur
一起向后移动 - 如果
arr[cur]>key
,则prev
停止,cur
向后移动,直至遇到arr[cur]<key
,cur
停止 cur
与prev + 1
对应的元素交换,++cur
- 重复上述,直至
cur
超过待排序序列的长度,返回prev
prev
左边就是比基准值小的序列,右边就是比基准值大的序列
int QuickSort3(int array[], int begin, int end) {
int index = BaseNumber(array, begin, end);
int cur = begin, prev = begin - 1;
if (index != end) {
Swap(&array[index], &array[end]);
}
int key = array[end];
// cur不能超过序列长度
while (cur <= end) {
if (array[cur] <= key && ++prev != cur) {
Swap(&array[cur], &array[prev]);
}
++cur;
}
return prev;
}
采用前后指针法
讲解时的图片作为测试用例:
测试数据:int array[] = { 0,5,4,9,3,6,8,7,1,2 };
其中选取的三数为0,3,2
,故基准值为 2,划分后的区间应为0,1,2,9,3,6,8,7,5,4
返回begin = 2
,且基准值左边全部小于基准值,右边全部大于基准值。
在此可见与 挖坑法 产生的区间方法又有一点点区别,提示:最后一位数字。
2.5 快排主函数(递归实现)
若待排序的序列较长,使用递归的方法一层一层调用极易造成栈溢出,由于当待排序的序列长度逐渐减小时,元素已经接近有序,使用插入排序会更加快捷,算是快速排序的一个优化点。
// 快速排序(递增)
void QuickSort(int array[], int left, int right) {
/*
// 由于快速排序是递归调用,容易产生栈溢出
// 但是快速排序排到最后元素也接近有序,则采用插入排序
if (right - left < 2) {
InsertSort(array + left, right - left);
}
*/
// 数据较小时,直接判断即可
if (left == right) {
return; // 区间内只有一个数
}
if (left > right) {
return; // 区间内没有数
}
// 基准值是array[right]
int pos;
// 仅修改QuickSort1,1,2,3即可完成测试
pos = QuickSort1(array, left, right - 1);
QuickSort(array, 0, pos); // 快速排序基准值左侧
QuickSort(array, pos + 1, right); //快速排序基准值右侧
}
测试数据:int array[] = { 3, 9, 1, 4, 2, 8, 2, 7, 5, 3, 6, 11, 9, 4, 2, 5, 0, 6 };
2.6 快排借用栈+循环的非递归实现
快速排序的递归实现在之前都已经叙述清楚了,但若是待排序的数量非常大且杂乱无章,每层循环都使用递归调用,会很容易造成栈溢出,所以可以将快速排序设计为非递归实现来避免这个问题。
总结一下递归实现快速排序算法重点:
- 快速排序是从序列中选择一个基准值,按照某种方式将该区间分成两部分,基准值左侧所有元素比基准值小,基准值右侧所有元素比基准值大。然后递归排序基准值左侧部分,排序基准值右侧部分即可。
若想把递归的快速排序改成非递归的循环最重要的是将基准值右侧区间的下标记住,排序完左边序列之后排序右边序列。
步骤如下:
- 申请一个栈,存放排序数组的起始位置和终点位置
- 将整个数组的起始位置 0 和终点位置
size
入栈 - 由于栈的特性是:后进先出,
size
后进栈,所以size
先出栈。定义一个right
接收栈顶元素,出栈操作、定义一个left
接收栈顶元素,出栈操作。 - 对数组进行一次快排,返回基准值的下标
- 这时候需要排基准值左边的序列。如果只将基准值左边序列的起始位置和终点位置存入栈中,等左边排序完将找不到后边的区间。所以先将右边序列的起始位置和终点位置存入栈中,在将左边的起始位置和终点位置存入栈中。
- 判断栈是否为空,若不为空,重复 4、5 步、若为空则排序完成。
这个确实不好叙述,但是只要对着下面的代码,拿个纸笔一画清楚无比,确实是鬼才思想~~
在这为了复习下数据结构,还模拟实现了一个栈~~
// 快排非递归实现
typedef struct Stack {
int *data;
int size;
}stack;
void InitStack(stack *s) {
int *data = (int*)malloc(20 * sizeof(int));
if (data == NULL) {
assert(0);
return;
}
s->data = data;
s->size = 0;
}
void PushStack(stack *s, int d) {
assert(s);
if (s->size > 20) {
return;
}
else {
s->data[s->size++] = d;
}
}
void PopStack(stack *s) {
assert(s);
if (s->size == 0) {
return;
}
else {
s->size--;
}
}
int TopStack(stack *s) {
assert(s);
return s->data[s->size - 1];
}
int EmptyStack(stack *s) {
assert(s);
return s->size == 0;
}
void QuickSortStack(int array[], int size) {
stack s;
int pos, left = 0, right = 0;
InitStack(&s);
PushStack(&s, 0);
PushStack(&s, size - 1);
while (!EmptyStack(&s)) {
right = TopStack(&s);
PopStack(&s);
left = TopStack(&s);
PopStack(&s);
if (left >= right) {
continue;
}
else {
// 注意在此的 right 传参
pos = QuickSort1(array, left, right);
//先快排基准左侧,则先将后侧的下标入栈
if ((right - left) > pos + 1) {
PushStack(&s, pos + 1);
PushStack(&s, right - left);
}
if (pos > 0) {
PushStack(&s, 0);
PushStack(&s, pos - 1);
}
}
}
}
Somehow, everything just work. 你不知道他怎么想的,但他就是想到了
测试数据:int array[] = { 3, 9, 1, 4, 2, 8, 2, 7, 5, 3, 6, 11, 9, 4, 2, 5, 0, 6 };
3. 简单的实现快排
上述实现已经很是完美了,在找以往资料的时候发现以前写的一个简单的快排,没考虑三数取中,用的 stl
中的stack
实现,这个写起来快,也算是个精炼版吧:
#include <stdio.h>
void Swap(int *a, int *b) {
int t = *a; *a = *b; *b = t;
}
// 快速排序
// 不能保证 left 一定是 0
int Partition_1(int array[], int left, int right) {
int begin = left; // 不要写成 begin = 0;
int end = right; // end 不能是 right - 1,反例 { 1,2, 3, 4 }
while (begin < end) {
// 基准值在右边,先走左边
// 否则反例 { 1, 7, 8, 4 }
// array[begin] 和 array[right] 比较必须有 ==
// 反例 { 1, 1, 1 }
while (begin < end && array[begin] <= array[right]) {
begin++;
}
// 意味着 array[begin] > array[right]
while (begin < end && array[end] >= array[right]) {
end--;
}
// 意味着 array[end] < array[right]
Swap(array + begin, array + end);
}
// 意味着区间被分成 3 份,分别是 { 小 , 大, 基准值 }
Swap(array + begin, array + right);
// 返回当前基准值所在下标
return begin;
}
int Partition_2(int array[], int left, int right) {
int begin = left;
int end = right;
int pivot = array[right];
while (begin < end) {
while (begin < end && array[begin] <= pivot) {
begin++;
}
array[end] = array[begin];
while (begin < end && array[end] >= pivot) {
end--;
}
array[begin] = array[end];
}
array[begin] = pivot;
return begin;
}
int Partition_3(int array[], int left, int right) {
int d = left;
for (int i = left; i < right; i++) {
if (array[i] < array[right]) {
Swap(array + i, array + d);
d++;
}
}
Swap(array + d, array + right);
return d;
}
// array[left, right]
void __QuickSort(int array[], int left, int right) {
if (left == right) {
// 区间内只有一个数
return;
}
if (left > right) {
// 区间内没有数
return;
}
// 基准值是 array[right]
int div; // 用来保存最终基准值所在的下标
div = Partition_1(array, left, right); // 遍历 array[left, right]
// 把小的放左,大的放右
// 返回最后基准值所在的下标
// 区间被分成
// [left, div - 1] 比基准值小 *
// [div, div] 基准值 已经在最终位置
// [div + 1, right] 比基准值大 *
__QuickSort(array, left, div - 1);
__QuickSort(array, div + 1, right);
}
#include <stack> // 栈的头文件
void QuickSortNor(int array[], int size) {
std::stack<int> stack;
stack.push(size - 1); // right
stack.push(0); // left
while (!stack.empty()) {
int left = stack.top(); stack.pop();
int right = stack.top(); stack.pop();
if (left >= right) {
continue;
}
else {
int d = Partition_2(array, left, right);
// [d + 1, right]
stack.push(right);
stack.push(d + 1);
// [left, d - 1]
stack.push(d - 1);
stack.push(left);
}
}
}
void QuickSort(int array[], int size) {
__QuickSort(array, 0, size - 1);
}
上面程序写这篇博文的时候测过了,通过了,有些注释还是值得细品的~。
4. 性能分析
快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
时间复杂度
- 最坏 O ( n 2 ) O(n^2) O(n2) 快排可以退化成冒泡,如果一旦每一趟快排,不幸的选择出最大或最小元素作为枢纽元素,那么递归深度将变成n,则时间复杂度变成了 O ( n 2 ) O(n^2) O(n2) ,此时快排的效率降到最低,退化为冒泡。如果用树画出来,得到的将会是一棵单斜树,也就是说所有所有的节点只有左(右)节点的树。递归型冒泡也是单斜树。
- 平均 O ( n l o g n ) O(nlogn) O(nlogn)
- 最好
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn) 已经有序
递归型冒泡是单斜树。而理想的快速排序,将产生一个平衡的二叉树,递归深度为 O ( l o g n ) O(logn) O(logn),所以快排是对冒泡的一个巨大的改进。
空间复杂度
- O ( l o g n ) O(logn) O(logn)
排序稳定性
- 稳定