排序算法——快速排序

简介: 排序算法——快速排序

快速排序

以升序为例


何为快速排序

  • 快速排序是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位置的数据,就可以将小的数据放在前面,大的数据放在后面,我通过下面这张图来解释:

一趟循环具体过程

改进

  • 如果待排数组是这样的:
  • 那么会出现这样的情况:

  • 因此我们可以对if判断多加一个条件:++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);
}
相关文章
|
7天前
|
搜索推荐 C语言
【排序算法】快速排序升级版--三路快排详解 + 实现(c语言)
本文介绍了快速排序的升级版——三路快排。传统快速排序在处理大量相同元素时效率较低,而三路快排通过将数组分为三部分(小于、等于、大于基准值)来优化这一问题。文章详细讲解了三路快排的实现步骤,并提供了完整的代码示例。
28 4
|
1月前
|
算法 搜索推荐 Shell
数据结构与算法学习十二:希尔排序、快速排序(递归、好理解)、归并排序(递归、难理解)
这篇文章介绍了希尔排序、快速排序和归并排序三种排序算法的基本概念、实现思路、代码实现及其测试结果。
20 1
|
1月前
|
搜索推荐 Java Go
深入了解快速排序算法
深入了解快速排序算法
30 2
|
1月前
|
存储 搜索推荐 算法
【排序算法(二)】——冒泡排序、快速排序和归并排序—>深层解析
【排序算法(二)】——冒泡排序、快速排序和归并排序—>深层解析
|
1月前
|
算法 Python
Python算法编程:冒泡排序、选择排序、快速排序
Python算法编程:冒泡排序、选择排序、快速排序
|
1月前
|
搜索推荐 C语言 C++
【C语言】指针篇-精通库中的快速排序算法:巧妙掌握技巧(4/5)
【C语言】指针篇-精通库中的快速排序算法:巧妙掌握技巧(4/5)
|
3月前
|
搜索推荐 算法 Java
现有一个接口DataOperation定义了排序方法sort(int[])和查找方法search(int[],int),已知类QuickSort的quickSort(int[])方法实现了快速排序算法
该博客文章通过UML类图和Java源码示例,展示了如何使用适配器模式将QuickSort类和BinarySearch类的排序和查找功能适配到DataOperation接口中,实现算法的解耦和复用。
39 1
现有一个接口DataOperation定义了排序方法sort(int[])和查找方法search(int[],int),已知类QuickSort的quickSort(int[])方法实现了快速排序算法
|
3月前
|
算法 搜索推荐
算法设计 (分治法应用实验报告)基于分治法的合并排序、快速排序、最近对问题
这篇文章是关于分治法应用的实验报告,详细介绍了如何利用分治法实现合并排序和快速排序算法,并探讨了使用分治法解决二维平面上的最近对问题的方法,包括伪代码、源代码实现及时间效率分析,并附有运行结果和小结。
|
4月前
|
算法 搜索推荐 编译器
算法高手养成记:Python快速排序的深度优化与实战案例分析
【7月更文挑战第11天】快速排序是编程基础,以O(n log n)时间复杂度和原址排序著称。其核心是“分而治之”,通过选择基准元素分割数组并递归排序两部分。优化包括:选择中位数作基准、尾递归优化、小数组用简单排序。以下是一个考虑优化的Python实现片段,展示了随机基准选择。通过实践和优化,能提升算法技能。**
56 3
|
5月前
|
搜索推荐 算法 Java
Java中的快速排序、归并排序和堆排序是常见的排序算法。
【6月更文挑战第21天】Java中的快速排序、归并排序和堆排序是常见的排序算法。快速排序采用分治,以基准元素划分数组并递归排序;归并排序同样分治,先分割再合并有序子数组;堆排序通过构建堆来排序,保持堆性质并交换堆顶元素。每种算法各有优劣:快排平均高效,最坏O(n²);归并稳定O(n log n)但需额外空间;堆排序O(n log n)且原地排序,但不稳定。
46 3