快排作为一个广泛应用的排序算法,顾名思义,时间复杂度较低。在c++标准库stdlib.h中提供了这个函数(qsort)。现在让我们对快排进行一些了解。
一、算法思想
快排采用分治法的思想。时间复杂度平均为O(nlogn),最坏情况下为O(n²)。
将快排分为两部分:
①:找到一个区间的数,将其居中。 O(n)
②:对若干区间重复一过程。 O(logn)~ O(n)
为什么要分为这两个部分呢?
以该数组为例:
假设我们取最左边的数(4),将其居中:(先不考虑除4以外其它数之间的位置顺序,等一下会进行说明)(①操作)
在一次居中后:4已经排列到它最终应该所在的位置。而此时,区间被分为两部分:比4大、比4小。(②操作)
我们再对两个区间执行①操作:
2、4、5都排列到最终位置。依此类推,当区间长度为1时不可再分,且每个数字都已经排列到它最终所在的位置,排序完成。
相信大家已经注意到了,我们以区间为单位进行划分(而不是以单个数字为单位O(n)),每次都将区间对半分,再对各个区间进行操作,这就是分治法的思想。
那么,我们应该如何实现呢?
二、代码实现(以从小到大排序为例)
①:
在这里,我运用两种方法实现①操作
(1)hoare方法 也是快排发明者本人的方法
思想:
将最左边的数记为key
定义两个指针,分别指向头部(left)和尾部(right)。
左指针向右移动,直到左指针大于key;右指针向左移动,直到右指针小于key。
将左右指针指向的数互换。
重复以上过程,直到左指针和右指针重合。
最后将key和重合处互换即可。
图解:
代码实现:
void MySwap(int* num1, int* num2)
{
int temp = *num1;
*num1 = *num2;
*num2 = temp;
}
int FindKey(int* nums, int left, int right)//hoare方法
{
int key = left;
while (left < right) {
if (nums[key] <= nums[right]) { //right指向的数大于等于key就--,直到right指向的数小于key
right--;
}
else if (nums[key] >= nums[left]) { //left指向的数小于等于key就++,直到left指向的数大于key
left++;
}
else { //当left指向的数大于key,right指向的数小于key时,交换位置
MySwap(&nums[left], &nums[right]);
}
}
//最后退出循环必然是left == right,且由于right判断在前,最后必然是right走向left,重合处为left指向的数(小于key)
//因此,最后将key与left交换(必然小于key) 注:当key初始取left时,right判断在前;key初始取right时,left判断在前。
//因为当key初始取left时,right判断在前的话,最后重合处为right指向的数(大于key),所以要和right - 1(小于key)交换
//可能引发越界等问题。比如只有一个数时,一开始就重合,而key要和right - 1(-1)互换,导致越界。
MySwap(&nums[key], &nums[left]);
key = left;
return key;
}
在使用hoare方法时,要注意是左指针先判断还是右指针先判断。
因为当key初始取left时,right判断在前的话,最后重合处为right指向的数(大于key),所以要和right - 1(小于key)交换。
而这时可能引发越界等问题。比如只有一个数时,一开始就重合,而key要和right - 1(-1)互换,导致越界。
结论:当key初始取left时,right判断在前;key初始取right时,left判断在前。
(2)快慢指针法
思想:
以最左边为key。
fast一直往右遍历,
当fast指向的数小于key指向的数,将fast指向的数和slow交换,slow++。
直到fast遍历完成。
最终将key和slow - 1互换。
这保证了slow - 1永远指向小于等于key的数,小于等于key的数都会被换到左边。
图解:
代码:
int FindKey(int *nums,int left, int right)//快慢指针法
{
int key = left;
int fast = left + 1, slow = left + 1; //快慢指针初始指向key的下一个
while (fast <= right) {
if (nums[key] > nums[fast]) {
MySwap(&nums[slow], &nums[fast]); //慢指针指向的数小于等于key指向的数,
slow++; //保证了慢指针左边小于等于key,右边大于key
}
fast++; //fast始终在移动
}
MySwap(&nums[slow - 1], &nums[key]); //最后将key和慢指针互换,注意是slow - 1,因为slow++了一次
key = slow - 1;
return key;
}
②
void SortArray(int* nums,int left,int right)
{
if (left >= right)return; // >= 而不是 == ,因为有可能right < left
int key = FindKey(nums, left, right);
SortArray(nums, left, key - 1); //key位置固定,不能再直接传入key,以免死循环
SortArray(nums, key + 1, right);
}
三、优化
刚刚我们提到,快排在最坏情况下时间复杂度为O(n²)。
①部分为O(n),②部分为O(logn)~O(n)
那么很显然,问题出在②。
②部分是对若干个区间进行操作,而区间的划分未必是二等分的。
在我们选取key值时,key的值未必是区间的中位数,这导致了区间的长度不一样。
而最坏的情况下,我们每次选取key值都为区间的最值。
设区间长度为n,那么每次划分的区间长度分别为n - 1和 1,而不是n/2和n/2,这样就相当于每次排列一个数字,排列n次,O(n)。
针对这种情况,我们不难想到,能不能每次取key时都找区间的中位数。答案是可以的。但是考虑到寻找中位数还要花费许多时间,我们退而求其次,每次比较区间的两端和中点,选取三者的中值(O(1)),也能达到很好的效果.
代码实现:
int GetMidNum(int* arr, int left, int right)//三者取中值
{
int mid = (left + right) / 2;
// 先取出 最左端、最右端、中间的数据
// 比较出大小后返回的是下标
if (arr[left] > arr[right])
{
if (arr[mid] < arr[right])
{
return right;
}
else if (arr[left] > arr[mid])
{
return mid;
}
else
{
return left;
}
}
else
{
if (arr[left] > arr[mid])
{
return left;
}
else if (arr[right] > arr[mid])
{
return mid;
}
else
{
return right;
}
}
}
int FindKey(int *nums,int left, int right)//快慢指针法
{
int Mid = GetMidNum(nums, left, right);
MySwap(&nums[Mid], &nums[left]);
int key = left;
int fast = left + 1, slow = left + 1; //快慢指针初始指向key的下一个
while (fast <= right) {
if (nums[key] > nums[fast]) {
MySwap(&nums[slow], &nums[fast]); //慢指针指向的数小于等于key指向的数,
slow++; //保证了慢指针左边小于等于key,右边大于key
}
fast++; //fast始终在移动
}
MySwap(&nums[slow - 1], &nums[key]); //最后将key和慢指针互换,注意是slow - 1,因为slow++了一次
key = slow - 1;
return key;
}
这样就使时间复杂度平均在O(nlogn)。