前言
在学习数据结构的过程中,必然有这样一个专题:排序,一般都会接触到以下几种排序:
作为排序算法中效率最高的快速排序有着O(N*logN)的时间复杂度,同时它其中涉及的思想:分治也是十分的有用,因此无论是各种考试还是IT公司的面试一般都少不了它。更重要的是,快速排序被称为20世纪十大算法之一,所以我们有什么理由不好好去学习理解快排呢?
目录
这篇博客将会根据我自己的理解,尽量写完整有关快排常见的要点(包括库函数的使用等),希望对大家有帮助,如果有错误,也请指出。本文将会讲述以下几点:
- 简要讲述C语言库中的qsort和STL中sort使用
- 快排单趟排序(1.挖坑法 2.前后指针法 3.左右指针法)
- 快速排序的递归和非递归实现
- 快排的时间空间复杂度以及优化
C语言库中的qsort和STL中sort使用
C语言中的qsort和STL中的sort都基于快排的排序算法,当然不完全是快排,也包含了其他的一些排序,以及一些优化,尤其是STL中对快排的优化,一般来说,几乎不会有人能写个快排被它还快了,因此能用现成的当然是最好的(不要重复造轮子,嘿嘿。。。),当然被面试写个快排还是要会的 = =。
这两者原理等讲快排的时候再细看,我们先看看他们是怎么用的。
qsort
废话不多说,直接看它的接口:
void qsort( void *base, size_t num, size_t width, int (__cdecl *compare )(const void *elem1, const void *elem2 ) );
int compare (const void *elem1, const void *elem2 ) );
相对来说,qsort对于C语言新手有点不好用,因为它从某种程度实现了类似C++泛型的功能,能够对很多数据类型进行升序或者降序排序,而我们只需要传一个函数指针就可以了。现在我们来看一看qsort的各个参数意思:
第一个参数 base 是需要排序的目标数组名(或者也可以理解成开始排序的地址,可以写成&s[i])
第二个参数 num 是参与排序的目标数组元素个数
第三个参数 width 是单个元素的大小(或者目标数组中每一个元素长度),推荐使用sizeof(s[0])这样的表达式
第四个参数 compare 就是函数指针,也就是所谓的比较函数。
我们以一个例子来看看qsort是怎么使用:
排序一个数组,以升序或者降序,代码如下:
int Cmp1(const void* left, const void* right)
{
return *(int*)left - *(int*)right;
// if left element > right element then return 1 and excute the swap operation
// so it's in ascending order
}
int Cmp2(const void* left, const void* right)
{
return *(int*)right - *(int*)left;
// if right element > left element then return 1 and excute the swap operation
// so it's in descending order
}
void TestQsort()
{
int a[] = { 2, 5, 4, 9, 3, 6, 8, 7, 1, 0 };
int n = sizeof(a) / sizeof(a[0]);
PrintArray(a, n);
cout << endl;
cout << "in ascending order :" << endl;
qsort(a, n, sizeof(a[0]), Cmp1);
PrintArray(a, n);
cout << "in descending order :" << endl;
qsort(a, n, sizeof(a[0]), Cmp2);
PrintArray(a, n);
}
qsort的各个参数上面都写了,重点和难点就是最后一个参数cmp的比较函数int (__cdecl *compare )(const void *elem1, const void *elem2 )。
其实这个参数就是一个函数指针,指向一个返回值为int,参数为(const void *elem1, const void *elem2 )的函数,它的功能就是为了现在升序或者降序。因此我们需要自己写个这样的函数,比如上述代码:
int Cmp1(const void* left, const void* right)
{
return *(int*)left - *(int*)right;
// if left element > right element then return 1 and excute the swap operation
// so it's in ascending order
}
就是一个升序版本的。为什么是升序???我们来看看这个函数,它的参数为const void*,这是为了访问到任意的数据类型,因此我们需要强转类型,比如对于int类型,我们先转换(int *)left将这个指针转换指向int类型,然后解引用得到这个值。现在这个函数可以这么理解了return leftValue - rightValue,如果左边的值比右边的小,那么return 负数,相等return 0,否则return 正数。
还记得冒泡排序的交换吗?因为快排和冒泡都属于交换排序(见上图),我们可以这么理解:如果左边的数比右边的数大,那么返回正数,在冒泡排序中,这需要交换两个数了。因此大的数就被放到右边了,小数就被放到左边了,从左到右就是升序的过程了。
为了便于理解qsort,我写了个bubblesort带函数指针的版本,参数和qsort一样,只不过用的bubblesort算法相对来说理解更为简单。用法和qsort完全一样。
//swap实现letft和right指针指向的地方的值的互换
void swap(char *left, char *right, int width)//width为每个元素占的字节数
{
char *temp = (char *)malloc(sizeof(char)* width);
if (temp == NULL)
{
printf("malloc operation fails\n");
exit(1);
}
for (int i = 0; i < width; ++i)
{
temp[i] = left[i];
left[i] = right[i];
right[i] = temp[i];
}
}
void BubbleSort(void *base, size_t num, size_t width, int(*cmp)(const void* left, const void* right))
{
char* a = (char*)base;
for (int i = 0; i < (int)num; ++i)
{
for (int j = 0; j < (int)num - i - 1; ++j)
{
// equal to cmp(a[j],a[j+1])
if (cmp(a + j * width, a + (j + 1) * width) > 0)
{
swap(a + j * width, a + (j + 1) * width, width);
}
}
}
}
void TestBubbleSort()
{
int a[] = { 2, 5, 4, 9, 3, 6, 8, 7, 1, 0 };
int n = sizeof(a) / sizeof(a[0]);
PrintArray(a, n);
cout << endl;
cout << "in ascending order :" << endl;
BubbleSort(a, n, sizeof(a[0]), Cmp1);
PrintArray(a, n);
cout << "in descending order :" << endl;
BubbleSort(a, n, sizeof(a[0]), Cmp2);
PrintArray(a, n);
}
STL中的sort
相对来说,我更喜欢用STL中sort,因为用起来相对方便点,在这里,我只介绍最基础的使用,因为本文不是讲解sort的。。。
sort算法的参数都需要输入一个范围,[begin, end)。这里使用的迭代器(iterator)都需是随机迭代器(RadomAccessIterator), 也就是说可以随机访问的迭代器。比如:
void TestSort()
{
int a[] = { 2, 5, 4, 9, 3, 6, 8, 7, 1, 0 };
int sz = sizeof(a) / sizeof(a[0]);
vector<int> v(a, a + sz);
PrintVector(v);
cout << "in ascending order:" << endl;
sort(v.begin(), v.end());//相当于sort(v.begin(), v.end(), less<int>()); 默认升序
PrintVector(v);
cout << "in descending order:" << endl;//sort需要#include <algorithm>
sort(a, a + sz, greater<int>());//greater需要#include <functional>
PrintArray(a, sz);
}
只需要调用一个sort就可以实现数组,vector内的元素排序,十分方便,当然自定义类型仍然需要传一个仿函数等等,这些可参考详细解说 STL 排序(Sort)。最后的话,其实sort可以看作是qsort的一个升级版,但是尽量使用sort而非c语言库里面的qsort。
快排单趟排序(1.挖坑法 2.前后指针法 3.左右指针法)
终于到了快速排序的算法讲解,前面扯了那么多,实际上也是我自己对于库里面的函数/算法的复习,毕竟如果参加线上笔试,使用现成的轮子sort肯定是比自己手写一个快排好多了,无论是时间,效率还是错误等等。
快速排序的基本思想:通过一趟排序将待排序记录分割成独立的两部分,其中一部分的关键字均比另一部分的关键字小,则分别对这两部分采用相同的方法排序,以达到有序
步骤如下:
1. 从数列中取出一个数作为基准数(pivot)。
2. 分区,将比这个数大的数全放到它的右边,小于它的数全放到它的左边,等于它的数随便放它左边或者右边。
3. 再对左右区间分别重复第二步,直到各区间只有一个数。
这里关键的一步就是第2步分区的过程,如何把大数放在右边,小边放在左边,这里我们介绍三种方法:
1. 左右指针法
2. 前后指针法
3. 挖坑法
注意:为了重点讲第二个步骤,我们一律选pivot为最右边的元素,比如下图,刚开始的基准数为5
待排序的数据如下:
左右指针法
刚开始设置基准值pivot为最右边的元素也就是5。
设置left指向第一个元素2,right指向最后一个元素5。
- 如果v[left] < pivot,将left指针右移,直到v[left] > pivot
- 如果v[right] > pivot,将left指针左移,直到v[left] < pivot
- 此时交换v[left]和v[right]
- 继续重复1,2,3的过程知道left和right相遇,此时left == right,交换v[left]和pivot的值,返回left或者right
下面是图示:
此时序列已经变成了 2 0 4 1 3 5 8 7 9 6
左边的值都比5小,右边的值都比5大,一趟排序之后达到近似有序,然后分别对左半区间和右半区间进行相同的调整即可。
//左右指针法--交换
int Partition(vector<int>& v, int left, int right)
{
int& pivot = v[right];
while (left < right)
{
while (left < right && v[left] <= pivot)
{
++left;
}
while (left < right && v[right] >= pivot)
{
--right;
}
swap(v[left], v[right]);
}
swap(pivot, v[left]);
return left;
}
前后指针法
依旧是刚才的数据,刚开始设置基准值pivot为最右边的元素也就是5。
刚开始设置cur指向第一个元素2,prev指向空。
- 如果v[cur] < pivot,那么让prev指针右移,如果prev不等于cur,那么交换v[cur]和v[prev]的值
- cur指针右移
- 重复1,2的过程直到cur指针指到最右边,此时让prev再右移一次,最后交换pivot(也就是v[cur])和v[prev]的值,返回prev
下面是图示:
代码如下:
//前后指针法
int Partition(vector<int>& v, int left, int right)
{
int prev = left - 1;
int cur = left;
int pivot& = v[right];
while (cur < right)
{
if (v[cur] < pivot && ++prev != cur)
{
swap(v[cur], v[prev]);
}
++cur;
}
swap(v[cur], v[++prev]);
return prev;
}
挖坑法
依旧是刚才的数据,刚开始设置基准值pivot为最右边的元素也就是5。
刚开始,将最右边设置为“坑”。
同时设置left指向第一个元素2,right指向最后一个元素5。
- 如果v[left] <= pivot,那么left指针右移直到v[left] > pivot停下,那么此时需要填坑,将v[right]也就是pivot初始坑填满,v[right] = v[left],并且此时设置v[left]为坑
- 现在我们移动right,如果v[right] >=pivot,right指针左移直到v[right] < pivot停下,同理此时填坑,v[left] = v[right],并且设置v[right]为坑
- 重复1,2过程直到left,right相遇,此时将v[right] = pivot填满,最后返回right
图示如下:(填坑后的结果)
代码如下:
//挖坑法
int Partition2(vector<int>& v, int left, int right)
{
int pivot = v[right];
while (left < right)
{
while (left < right && v[left] <= pivot)
{
++left;
}
v[right] = v[left];
while (left < right && v[right] >= pivot)
{
--right;
}
v[left] = v[right];
}
v[left] = pivot;
return left;
}
快速排序的递归和非递归实现
我们之前说了快排的步骤:
- 从数列中取出一个数作为基准数(pivot)。
- 分区,将比这个数大的数全放到它的右边,小于它的数全放到它的左边,等于它的数随便放它左边或者右边。
- 再对左右区间分别重复第二步,直到各区间只有一个数。
我们上面实现了某个区间的的分区过程,以pivot为界,左边区间的数都比它小,右边区间都比它大,我们可以重复这一过程,在左右区间再次实行分区,这样依次递归下去,最终可以完成排序的功能。
因此快排的核心代码如下:
void QuickSort(vector<int>& v, int left, int right)
{
assert(v.size() > 0);
if (left >= right)
{
return;
}
int div = Partition(v, left, right);
QuickSort(v, left, div - 1);
QuickSort(v, div + 1, right);
}
显然这是一个递归的写法,我们知道递归就带来效率的低下,而且一旦递归的层次很深有可能出现递归所用的栈溢出,因此我们可以用一个栈来模拟递归的过程。代码如下:
void QuickSortNonRecursion(vector<int>& v, int left, int right)
{
stack<int> s;
if (left >= right)
{
return;
}
s.push(right);
s.push(left);
while (!s.empty())
{
int low = s.top();
s.pop();
int high = s.top();
s.pop();
int div = Partition2(v, low, high);
if (low < div - 1)
{
s.push(div - 1);
s.push(low);
}
if (high > div + 1)
{
s.push(high);
s.push(div + 1);
}
}
}
快排的时间空间复杂度以及优化
快排的时间性能取决于快速排序递归的深度,可以用递归树来描述算法的执行情况,比如20,10,90,30,70,40,80,60,50,由于第一个关键字为50正好是序列的中间值,因此递归树是相对平衡的,性能比较好。
最好时间复杂度
最优的情况下,Partition每次划分所取的基准都是当前无序区的”中值”记录,划分的比较均匀,有点类似于二分,这样一来,递归树的深度为 logN + 1,仅需递归logn次,而每次Partition都需要对当前区间扫描一遍,次数必然不大于n次,那么时间复杂度为O(nlogn)
最差时间复杂度
最差的情况下,序列正序或者逆序,每次划分仅比上一次划分少一个一个记录,注意另一个区间为空,这样的递归树是一个斜树,此时需要执行n - 1次递归调用,每次的比较次数依然不大于n次,那么时间复杂度为O(n*n)
空间复杂度
空间复杂度主要是递归栈所用的空间,若每次划分较为均匀,则其递归树的高度为O(logn),故递归后需栈空间为O(logn)。最坏情况下,递归树的高度为O(n),所需的栈空间为O(n),平均情况,空间复杂度为O(logn)
由于关键字的比较交换是跳跃进行的,因此快速排序是一种不稳定的排序方法。例如27 23 27 3
以第一个27作为pivot中心点,则27与后面那个3交换,形成3 23 27 27,排序经过一次结束,但最后那个27在排序之初先于初始位置3那个27,所以不稳定。
优化–三数取中法
为了尽量获得最好的时间复杂度,如果每次划分的pivot都是当前序列的中间值,那么划分肯定相对均匀,性能也就最好。
最常见的优化方法就是三数取中法,取左端,右端和中间的三个数,将他们中间值作为枢轴pivot,这样pivot肯定不是最大或者最小的数,从概率上讲,取三个数均为最小或者最大数也是很小的,因此尽量保证好的时间复杂度。当然,如果数据量这样的方法不足以保证能够选择出一个好的pivot,这时候可以采用九数取中法,从数组中分三次取样,每次取三个数得到中数,再从这三个中数取一个中数作为pivot。原理和三叔取中法类似,在这里我们给出三数取中法的实现代码:
//三数取中法
int FindMidIndex(vector<int>& v, int left, int right)
{
int mid = left + ((right - left) >> 1);
if (v[left] > v[mid])
{
if (v[mid] > v[right]) //left > mid > right
{
return mid;
}
else //left > mid mid <right
{
if (v[left] > v[right])
return right;
else
return left;
}
}
else //mid > left
{
if (v[left] > v[right]) //mid > left > right
{
return left;
}
else //mid > left left < right
{
if (v[mid] > v[right])
return right;
else
return mid;
}
}
}
//左右指针法调用三数取中法,其他类似
int Partition1(vector<int>& v, int left, int right)
{
int midIndex = FindMidIndex(v, left, right);
swap(v[midIndex], v[right]);
int key = right;
while (left < right)
{
while (left < right && v[left] <= v[key])
{
++left;
}
while (left < right && v[right] >= v[key])
{
--right;
}
swap(v[left], v[right]);
}
swap(v[key], v[left]);
return left;
}
优化–小区间优化法
前面我们提到,快速排序用到了递归,如果是数据量很大的排序,递归带来的性能对于它整体的算法优势可以忽略,但是如果本来数据记录就很少,我们用快速排序就有点大材小用了,此时还不如用直接插入排序来得好(直接插入排序是简单排序中性能最好的),因此当数据量很小的时候,比如小于13等等,因此快速排序改进一下,我们写出如下的代码:
void QuickSort(vector<int>& v, int left, int right)
{
assert(v.size() > 0);
if (right - low > 13)
{
int div = Partition3(v, left, right);
QuickSort(v, left, div - 1);
QuickSort(v, div + 1, right);
}
else
{
InsertSort(v, right - left + 1);
}
}
PS:关于排序专题的代码可见我的github之Sort,求star。。。