以升序为例
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法。
基本思想:任取待排序元素序列中的某个元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
例如下图就是将数字“5”放在正确位置上的过程
由于现在还没有数据处在正确的位置,因此排序区间是整个数组的长度
可以将待排数组的第一个数(最左边的数)作为基准值key,同时将这个位置设为坑pivot
设区间最左边的下标为left,最右边的下标为right,此时坑的位置即最左边left
int key = nums[0]; //基准值
int pivot = 0; //坑的位置
int left = 0; //区间最左边
int right = numsSize - 1; //区间最右边
由于是升序排序,要做到基准值key左边的元素全小于key,右边的元素全大于key。而坑pivot现在在左边,因此我们要利用右边的right,通过对其的移动找到小于key的数,并将这个数“挖走”,并填入坑pivot中,此时,被挖走的位置就成了新的坑
//right不断向左移动,找到小于key的数
while (left < right && nums[right] >= key)
right--;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[right];
//被挖走数字的区域变成新的坑
pivot = right;
这样坑pivot就到了右边,右边放的应该是大于基准值key的数,因此我们就要通过移动left来找到小于key的数,将其挖走并填入坑pivot中,同样,被挖走的位置也成了新的坑
//left不断向右移动,找到大于key的数
while (begin < end && nums[begin] <= key)
begin++;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[begin];
//被挖走数字的区域变成新的坑
pivot = begin;
通过循环,不断移动left和right,直到不能满足条件left < right
,此时第一个数就被放到正确的位置了。
我们以数组{5,8,2,9,1,3,7,4,6}
为例:
void QuickSort(int* nums, int numsSize)
{
int key = nums[0]; //基准值
int pivot = 0; //坑的位置
int left = 0; //区间最左边
int right = numsSize - 1; //区间最右边
while (begin < end)
{
//right不断向左移动,找到小于key的数
while (left < right && nums[right] >= key)
right--;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[right];
//被挖走数字的区域变成新的坑
pivot = right;
//left不断向右移动,找到大于key的数
while (begin < end && nums[begin] <= key)
begin++;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[begin];
//被挖走数字的区域变成新的坑
pivot = begin;
}
//将基准值填入最后坑的位置
nums[pivot] = key;
}
[0,pivot - 1]和[pivot + 1, right]
,我们可以利用同样的办法将个子区间继续分割,这样越来越多的元素到了正确的位置,直到每个区间的长度为1或0就可以表明待排序列以已经有序void QuickSort(int* nums, int begin, int end)
{
//如果区间长度为1或0,则表示只有一个数,直接退出
if (begin >= end)
return;
int key = nums[begin]; //基准值
int pivot = begin; //坑的位置
int left = begin; //区间最左边
int right = end; //区间最右边
while (left < right)
{
//right不断向左移动,找到小于key的数
while (left < right && nums[right] >= key)
right--;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[right];
//被挖走数字的区域变成新的坑
pivot = right;
//left不断向右移动,找到大于key的数
while (left < right && nums[left] <= key)
left++;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[left];
//被挖走数字的区域变成新的坑
pivot = left;
}
//将基准值填入最后坑的位置
nums[pivot] = key;
//递归处理子区间
QuickSort(nums, begin, pivot - 1);
QuickSort(nums, pivot + 1, end);
}
左右指针法其实和挖坑法的思想十分类似,同样是先确定一个基准值key,然后通过左边的left找大于key的数和右边的right找小于key的数,然后进行一定的操作,从而达到有序
不同点在于:左右指针法不会挖坑,而是在在右边的right找到小于key的数后,直接让左边的left找大于key的数,然后交换这两个值
//找到小于基准值的数
while (left < right && nums[right] >= key)
right--;
//找到大于基准值的数
while (left < right && nums[left] <= key)
left++;
//交换这两个数
Swap(&nums[right], &nums[left]);
同样的,不断循环,直到不能满足条件left < right
结束,最后再交换基准值和left、right相遇位置的数。
int key = nums[begin]; //基准值
int left = begin;
int right = end;
while (left < right)
{
//找到小于基准值的数
while (left < right && nums[right] >= key)
right--;
//找到大于基准值的数
while (left < right && nums[left] <= key)
left++;
//交换这两个数
Swap(&nums[right], &nums[left]);
}
//交换相遇值和基准值
Swap(&nums[begin], &nums[left]);
void QuickSort(int* nums, int begin, int end)
{
if (begin >= end)
return;
int key = nums[begin]; //基准值
int left = begin;
int right = end;
while (left < right)
{
//找到小于基准值的数
while (left < right && nums[right] >= key)
right--;
//找到大于基准值的数
while (left < right && nums[left] <= key)
left++;
//交换这两个数
Swap(&nums[right], &nums[left]);
}
//交换相遇值和基准值
Swap(&nums[begin], &nums[left]);
QuickSort(nums, begin, left - 1);
QuickSort(nums, left + 1, end);
}
前后指针法和前面两种方法不同,这里要定义指针prev指向待排区域的起始位置,指针cur指向prev的后一个位置
int prev = begin;
int cur = begin + 1;
令cur不断向右移动遍历待排区域,当碰到小于基准值key的数就停止,同时让prev也向右移动一个(即prev++),交换prev和cur位置的数据
不断循环,直到cur遍历完整个数组
while (cur <= end)
{
if (nums[cur] < key)
{
prev++;
Swap(&nums[cur], &nums[prev]);
}
cur++;
}
最后,再将基准值放到正确的位置,即将最后prev和begin位置的元素交换位置
Swap(&nums[begin], &nums[prev]);
可能有小伙伴会疑惑,为什么当nums[cur] < key
时,将prev++,再交换cur和prev位置的数据,就可以将小的数据放在前面,大的数据放在后面,我通过下面这张图来解释:
++prev != cur
,这样就可以避免对一个数字进行交换了void QuickSort(int* nums, int begin, int end)
{
if (begin >= end)
return;
int key = nums[begin];
int prev = begin;
int cur = begin + 1;
while (cur <= end)
{
if (nums[cur] < key && ++prev != cur)
Swap(&nums[cur], &nums[prev]);
cur++;
}
Swap(&nums[begin], &nums[prev]);
//对余下数字进行递归整理
QuickSort(nums, begin, prev - 1);
QuickSort(nums, prev + 1, end);
}
由于C语言的局限性,我们要用到栈,当然就要先创造一个栈,并实现有关其的基本操作。这里不赘述,如有疑问,可以去看看栈的相关操作
在递归解法中,我们是对不断细分的子区间进行数据的整理,同样的,在非递归解法中,我们也需要利用这些不断细分的子区间来进行排序,而要能够像递归一样利用这些子区间,就需要用栈来对这些子区间的左右端的下标进行存储,为了方便讲解,我们先来看看具体的过程展示:
假设我们要对长度为numsSize的数组进行排序
先将数组两端的下标入栈
/*
注意先后顺序
由于栈先入后出的特性
应该先入后面的,再入前面的
*/
StackPush(st, numsSize - 1);
StackPush(st, 0);
进入循环,循环进行的条件为栈不能为空
取出栈顶的两个元素,作为待排区间的左右端
我们可以用挖坑法、前后指针法、左右指针法这三种方法对这一段区间进行一趟排序(即得到一个数正确的位置),同时得到这个正确位置的下标
这样,这个正确位置就将待排序列分割为了两个子序列
如果左边的子序列长度大于一,那么就将这个子序列的左右端入栈,对右序列进行相同的处理
重复上述步骤,直到栈空
//挖坑法的一趟排序(即将一个数放在正确位置)
int PartSort(int* nums, int begin, int end)
{
int key = nums[begin]; //基准值
int pivot = begin; //坑的位置
int left = begin; //区间最左边
int right = end; //区间最右边
while (left < right)
{
//right不断向左移动,找到小于key的数
while (left < right && nums[right] >= key)
right--;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[right];
//被挖走数字的区域变成新的坑
pivot = right;
//left不断向右移动,找到大于key的数
while (left < right && nums[left] <= key)
left++;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[left];
//被挖走数字的区域变成新的坑
pivot = left;
}
//将基准值填入最后坑的位置
nums[pivot] = key;
//返回正确位置的下标
return pivot;
}
void QuickSort(int* nums, int numsSize)
{
ST* st = (ST*)malloc(sizeof(ST));
StackInit(st); //初始化栈
//先将待排序列的左右端点入栈
StackPush(st, numsSize - 1);
StackPush(st, 0);
//当栈不为空进行循环
while (!StackEmpty(st))
{
//出栈,得到序列区间
int begin = StackFront(st);
StackPop(st);
int end = StackFront(st);
StackPop(st);
//进行一趟排序,得到一个数的正确位置
//这一位置将待排序列分割为两个子序列
int key_index = PartSort(nums, begin, end);
//如果右边的子序列长度大于一,那么将左右端点入栈
if (end - key_index > 0)
{
StackPush(st, end);
StackPush(st, key_index + 1);
}
//如果左边的子序列长度大于一,那么将左右端点入栈
if (key_index - begin > 0)
{
StackPush(st, key_index - 1);
StackPush(st, begin);
}
}
}
由于四种方法的思想有共通之处,故拿挖坑法为例
我们先看其一趟排序:
//挖坑法的一趟排序(即将一个数放在正确位置)
int PartSort(int* nums, int begin, int end)
{
int key = nums[begin]; //基准值
int pivot = begin; //坑的位置
int left = begin; //区间最左边
int right = end; //区间最右边
while (left < right)
{
//right不断向左移动,找到小于key的数
while (left < right && nums[right] >= key)
right--;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[right];
//被挖走数字的区域变成新的坑
pivot = right;
//left不断向右移动,找到大于key的数
while (left < right && nums[left] <= key)
left++;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[left];
//被挖走数字的区域变成新的坑
pivot = left;
}
//将基准值填入最后坑的位置
nums[pivot] = key;
//返回正确位置的下标
return pivot;
}
实际上就是left,right两个指针分别从左右遍历一次待排区间,时间复杂度为O(N)
记下来,就是对这一过程进行不断递归,直到待排区间被分割为一个数,我们可以将这个分割过程看成是一棵满二叉树的情况:
因此,递归的时间复杂度就是O(log2N)
综上,快速排序的时间复杂度为O(NlogN)
先下结论:对于快速排序,最坏情况就是当数组为有序时(无论是正序还是逆序)
为了处理类似的情况,我们就要对基准值的取值进行改变,我们一般采用三数取中的方法来进行对key的取值
三数取中:比较待排区间两端点和中间的数,选择不大不小的那一个,和左端点的值交换,再将左端点的值作为基准值key
int GetMid(int* nums, int left, int right)
{
int mid = (right - left) / 2 + left;
if (nums[left] <= nums[mid])
{
if (nums[right] > nums[mid])
return mid;
else if (nums[right] > nums[left])
return right;
else
return left;
}
else //nums[left] > nums[mid]
{
if (nums[right] > nums[left])
return left;
else if (nums[right] > nums[mid])
return right;
else
return mid;
}
}
void QuickSort(int* nums, int begin, int end)
{
if (begin >= end)
return;
/*
为了不改变后序代码的逻辑
三数取中后,应交换中间数和开头数
*/
int index = GetMid(nums, begin, end);
Swap(&nums[index], &nums[begin]);
int key = nums[begin]; //基准值
int pivot = begin; //坑的位置
int left = begin; //区间最左边
int right = end; //区间最右边
while (left < right)
{
//right不断向左移动,找到小于key的数
while (left < right && nums[right] >= key)
right--;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[right];
//被挖走数字的区域变成新的坑
pivot = right;
//left不断向右移动,找到大于key的数
while (left < right && nums[left] <= key)
left++;
//将满足条件的数“挖走”,并填入坑pivot中
nums[pivot] = nums[left];
//被挖走数字的区域变成新的坑
pivot = left;
}
//将基准值填入最后坑的位置
nums[pivot] = key;
QuickSort(nums, begin, pivot - 1);
QuickSort(nums, pivot + 1, end);
}