0


[ 数据结构 -- 手撕排序算法第五篇 ] 快速排序 <包含hoare法,挖坑法,前后指针法> 及其算法优化

手撕排序算法系列之第五篇:快速排序。

从本篇文章开始,我会介绍并分析常见的几种排序,大致包括直接插入排序,冒泡排序,希尔排序,选择排序,堆排序,快速排序,归并排序等。

大家可以点击此链接阅读其他排序算法:排序算法_大合集(data-structure_Sort)

本篇主要来手撕快速排序算法~

1.常见的排序算法

1.1交换排序

快速排序属于一种交换排序,因此我们在了解快速排序之前,先了解一下交换排序的基本思想。

基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动(默认升序排序)。

2.快速排序的实现方法

快排的实现方法有:

(1) 递归法:hoare法,挖坑法,前后指针法。

(2)非递归法:使用栈(Stack)解决

2.1快排的基本思想方法

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止

**将区间按照基准值划分为左右两半部分的常见方式有: **

1:hoare法

2:挖坑法

3:前后指针法

3.hoare法

3.1hoare法的逻辑过程

我们可以观看这段视频了解一下hoare法的具体过程:

hoare 快排

3.2hoare法详解

经过视频的步骤,可以总结出hoare的步骤如下:

1、****选出一个key,有可能选最左边有可能最右边具体没有规定。

2、假设选最左边key,经过一次hoare排序后得到的序列是左边的数字都小于key,右边的数字都大于key,此时key其实已经来到了最终的位置(因为左边都比他小,右边都比他大)。

3、此时整个数组就会被分为key左边,key,key右边3部分。我们对左边和右边的区间进行hoare排序,重新选择对应的key值,经过排序后也会来到最终的位置。

4、循环对左区间,右区间进行hoare排序,当最小区间为空时说明排序完成。因此我们可以通过递归来实现。

配合理解:我们画图分析一趟过程

至此,我们理解了hoare的思路,但是在单趟hoare排序中当key在左边时,为什么要让右边先走?如果当key选在最右边时,是让左边先走吗?

答:这是因为要保证相遇的值一定要比key小。我们思考:如果说最终相遇的地方的值比key大,最后一步要让当前位置和key交换,那么在交换后就不能保证key的左边都比key小,右边都比key大。因此为了保证相遇的值一定要比key小,我们需要让右边先走。R先走就会保证R先停下来,L走去遇到R,相遇的位置是比key小的;刚交换完,R先走,R没有找到比key小的直接跟L相遇,相遇的位置也是比key小的。

如果是当key在最右边,为了保证最终相遇位置的值一定要比key大,因此要确保左边值先走,逻辑同上。具体逻辑是:左边先走,左边找比key大的数字停止。然后让右边走,右边找比key小的数字,找到后left和right交换,然后重复走,最终相遇时让相遇位置的值和key交换。

3.3hoare代码实现

// hoare
int PartSort1(int* a, int left, int right)
{
    int keyi = left;
    while (left < right)
    {
        // 找小
        while (left < right && a[right] >= a[keyi])
            --right;

        // 找大
        while (left < right && a[left] <= a[keyi])
            ++left;

        Swap(&a[left], &a[right]);
    }

    Swap(&a[keyi], &a[left]);

    return left;
}
void QuickSort(int* a, int begin, int end)
{
    // 子区间相等只有一个值或者不存在那么就是递归结束的子问题
    if (begin >= end)
        return;
    
    int keyi = PartSort1(a, begin, end);
    // [begin, keyi-1] keyi [keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

3.4hoare代码测试


//打印数组
void PrintArray(int* a, int n)
{
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
    printf("\n");
}
//交换
void Swap(int* pa, int* pb)
{
    int tmp = *pa;
    *pa = *pb;
    *pb = tmp;
}
// hoare
int PartSort1(int* a, int left, int right)
{
    int keyi = left;
    while (left < right)
    {
        // 找小
        while (left < right && a[right] >= a[keyi])
            --right;

        // 找大
        while (left < right && a[left] <= a[keyi])
            ++left;

        Swap(&a[left], &a[right]);
    }

    Swap(&a[keyi], &a[left]);

    return left;
}
void QuickSort(int* a, int begin, int end)
{
    // 子区间相等只有一个值或者不存在那么就是递归结束的子问题
    if (begin >= end)
        return;
    
    int keyi = PartSort1(a, begin, end);
    // [begin, keyi-1] keyi [keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}
void TestQuickSort()
{
    int a[] = { 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 };
    QuickSort(a, 0, sizeof(a) / sizeof(int) - 1);

    PrintArray(a, sizeof(a) / sizeof(int));
}
int main()
{
    //快速排序
    TestQuickSort();

    return 0;
}

测试结果:

4. 挖坑法

4.1挖坑法的逻辑过程

我们可以观看这段视频了解一下挖坑法的具体过程:

挖坑法 -- 快速排序

4.2挖坑法详解

经过视频的步骤,可以总结出挖坑的步骤如下:

  1. 选出key,有可能选最左边也可能是最右边。假设选最左边的值为坑位。
  2. 让key保存最左边的数,然后right先走,找到比key小的数字停止,让right对应的值放在左边的坑位上,然后right所对应的位置为新的坑位。
  3. 然后让左边的走,找到比key值大的数字停止,然后让left对应的值放在right所对应的坑位下,left所对应的位置成为新的坑位。
  4. 循环走,当left和right相遇时有一个坑位,让一开始保存的key值放在这个坑位即可。此时也可以保证key值所在位置为最终的位置,左边的值都比key小,右边的值都比key大。

挖坑法相比于hoare的区别:

1、相比于hoare法,挖坑法不需要理解为什么最终向与位置比key小,因为最终相遇的位置为一个坑位,直接让key值填入坑内即可。

2、不需要理解为什么左边做key,右边先走。因为左边为坑,让右边先走找到一个值来填坑生成新的坑位这是显而易见的,不需要过多的思考。

配合理解,我们画图走一趟挖坑法:

我们可以发现,hoare和挖坑法虽然思路不同,但是所做的事情的效率是不相上下的。

4.3挖坑法代码实现

//挖坑法
int PartSort2(int* a, int left, int  right)
{
    int key =a[left];
    //定义坑
    int pit = left;
    while (left < right)
    {
        //右边先走找小
        while (left < right && a[right] >= key)
        {
            right--;
        }
        a[pit] = a[right];
        pit = right;

        //左边走,找大
        while (left < right && a[left] <= key)
        {
            left++;
        }
        a[pit] = a[left];
        pit = left;
    }
    a[pit] = key;
    return pit;
}
void QuickSort(int* a, int begin, int end)
{
    // 子区间相等只有一个值或者不存在那么就是递归结束的子问题
    if (begin >= end)
        return;

    int keyi = PartSort2(a, begin, end);
    // [begin, keyi-1]keyi[keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

4.4挖坑法代码测试


//打印数组
void PrintArray(int* a, int n)
{
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
    printf("\n");
}

//挖坑法
int PartSort2(int* a, int left, int  right)
{
    int key =a[left];
    //定义坑
    int pit = left;
    while (left < right)
    {
        //右边先走找小
        while (left < right && a[right] >= key)
        {
            right--;
        }
        a[pit] = a[right];
        pit = right;

        //左边走,找大
        while (left < right && a[left] <= key)
        {
            left++;
        }
        a[pit] = a[left];
        pit = left;
    }
    a[pit] = key;
    return pit;
}

void QuickSort(int* a, int begin, int end)
{
    // 子区间相等只有一个值或者不存在那么就是递归结束的子问题
    if (begin >= end)
        return;
    
    int keyi = PartSort2(a, begin, end);
    // [begin, keyi-1] keyi [keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

void TestQuickSort()
{
    int a[] = { 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 };
    QuickSort(a, 0, sizeof(a) / sizeof(int) - 1);

    PrintArray(a, sizeof(a) / sizeof(int));
}

int main()
{
    //快速排序
    TestQuickSort();

    return 0;
}

测试结果:

5.前后指针法

5.1前后指针法的逻辑过程

我们可以观看这段视频了解一下前后指针法的具体过程:

前后指针法 -- 快速排序

5.2前后指针法详解

经过视频的步骤,可以总结出前后指针法的步骤如下:

  1. 选出key,有可能选最左边也可能选最右边,假设选最左边为key。
  2. 定义一个prev 和 一个 cur指针,初始时,prev指针指向序列开头,cur指针指向prev的下一个位置。cur先走,cur找比key小的值,找到之后让prev++,然后交换prev和cur的值,当cur找到的值不比key小时,cur继续往后找,找到后在让prev++,然后交换prev和cur的值。
  3. 当cur走完整个数组到空的时候,让prev的值和最左边的key值交换。

我们依然画图来方便理解:

我们观察前后指针发现,其实cur在控制比key小的数字,选出后往前抛,为什么cur选出来之后要先让prev++再交换,是因为如果prev和cur之间由间隔,之间间隔的数字都是比key大的数字,因为prev++交换就会把prev++所对应的比key大的值交换到cur对应的比key小的值得位置,cur的小的值因此就被抛在前面。

最终当cur等于空的时候,让prev直接和key交换的原因是,prev所对应的值一定是比key小的。

我们再来简单的画图分析一下如果key在右边的情况:

5.3前后指针法代码实现

//前后指针法 key在left
int PartSort3(int* a, int left, int  right)
{
    int keyi = left;
    int prev = left;
    int cur = left+1;

    while (cur <= right)
    {
        if (a[cur] < a[keyi] && a[++prev] != a[cur])
            Swap(&a[cur], &a[prev]);
        cur++;
    }
    //prev所在地方和key交换
    Swap(&a[prev], &a[keyi]);
    return prev;
}
void QuickSort(int* a, int begin, int end)
{
    // 子区间相等只有一个值或者不存在那么就是递归结束的子问题
    if (begin >= end)
        return;

    int keyi = PartSort3(a, begin, end);
    // [begin, keyi-1]keyi[keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

下面这种写法是key在右边

//前后指针key在right
int PartSort4(int* a, int left, int  right)
{
    int keyi = right;
    int prev = left-1;
    int cur = left;

    while (cur < right)
    {
        if (a[cur] < a[keyi] && a[++prev] != a[cur])
            Swap(&a[cur], &a[prev]);
        cur++;
    }
    //prev所在地方和key交换
    Swap(&a[++prev], &a[keyi]);
    return prev;
}

void QuickSort(int* a, int begin, int end)
{
    // 子区间相等只有一个值或者不存在那么就是递归结束的子问题
    if (begin >= end)
        return;

    int keyi = PartSort4(a, begin, end);
    // [begin, keyi-1]keyi[keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

5.4前后指针法代码测试

//打印数组
void PrintArray(int* a, int n)
{
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
    printf("\n");
}

void Swap(int* pa, int* pb)
{
    int tmp = *pa;
    *pa = *pb;
    *pb = tmp;
}
//前后指针法 key在left
int PartSort3(int* a, int left, int  right)
{
    int keyi = left;
    int prev = left;
    int cur = left+1;

    while (cur <= right)
    {
        if (a[cur] < a[keyi] && a[++prev] != a[cur])
            Swap(&a[cur], &a[prev]);
        cur++;
    }
    //prev所在地方和key交换
    Swap(&a[prev], &a[keyi]);
    return prev;
}
void QuickSort(int* a, int begin, int end)
{
    // 子区间相等只有一个值或者不存在那么就是递归结束的子问题
    if (begin >= end)
        return;

    int keyi = PartSort3(a, begin, end);
    // [begin, keyi-1]keyi[keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}
//快速排序
void TestQuickSort()
{
    int a[] = { 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 };
    QuickSort(a, 0, sizeof(a) / sizeof(int) - 1);

    PrintArray(a, sizeof(a) / sizeof(int));
}

int main()
{
    //快速排序
    TestQuickSort();

    return 0;
}

测试结果:

6.快速排序法的时间复杂度

6.1最好情况

经过上述3种方法我们可以分析得出快排的最好情况时间复杂度是O(N*logN)。

当每次选的key值最终都被排到了中间的位置,就像一颗二叉树一样需要递归logN次,其中一次快排算法内部排序是O(N),因此最好情况就是O(N*logN)。

6.2最坏情况

最坏情况就是我们每次选的key值都是最左或者最右边,此时时间复杂度就是O(N^2)。因为每次都在边,需要递归N次,内部遍历一遍O(N),因此最坏就是O(N^2)。

那么我们当然是要将快排优化到O(N*logN)最好,接下来我们对快排进行优化。

7.快速排序的优化

要优化快速排序我们可有两种思路进行优化

1:针对选key进行优化即可,争取避免选到两个边。

2:减少递归次数,小区间优化,使用插入排序对小区间直接排。

7.1针对选key进行优化

首先我们来对选key进行优化,我们可以有两种思路:

1:随机数选key,避免原数组是有序或者相对有序情况。

2:三数取中。(选数组最左边和最右边以及最中间的数字选择这三个数字中的中位数作为key)

我们明显可以看出第一种思路明显不可控,因此我们来实现第二种方法,三数取中,这个实现也比较简单。

我们让这个中位数成为key值即可具体代码如下:

//三数取中选K
int GetMidIndex(int* a, int left, int right)
{
    int mid = left + (right - left) / 2;
    if (a[left] < a[mid])
    {
        if (a[mid] < a[right])
        {
            return mid;
        }
        else if (a[left] > a[right])
        {
            return left;
        }
        else
        {
            return right;
        }
    }
    else
    {
        if (a[mid] > a[right])
        {
            return mid;
        }
        else if (a[left] < a[right])
        {
            return left;
        }
        else
        {
            return right;
        }
    }
}
//前后指针法 key在left
int PartSort3(int* a, int left, int  right)
{
    int midi = GetMidIndex(a, left, right);
    Swap(&a[midi], &a[left]);
    int keyi = left;
    int prev = left;
    int cur = left+1;

    while (cur <= right)
    {
        if (a[cur] < a[keyi] && a[++prev] != a[cur])
            Swap(&a[cur], &a[prev]);
        cur++;
    }
    //prev所在地方和key交换
    Swap(&a[prev], &a[keyi]);
    return prev;
}

这里我们使用前后指针法进行快排,通过三数选中优化之后。假如测试的数组是本身有序,如果不优化,快排的时间复杂度是O(n^2),但是经过三数取中后,我们直接拿到了中位数,直接让复杂度变成为最优的O(n*logn)可喂是直接鸟枪换大炮。

7.2小区间优化

我们知道,快排递归的调用展开简化图就是一颗二叉树,当我们所分的区间很小时,我们仅仅是排几个数字就要递归几百次甚至更多次。因此当区间很小时,我们不再使用递归调用,选择直接进行插入排序。

一般情况下,我们当区间元素个数小于等于10的时候就不再递归,直接小区间插入排序。

实现代码:

//插入排序
void InsertSort(int* a, int n)
{
    for (int i = 0; i < n - 1; ++i)
    {
        int end = i;
        //单趟排序:由后往前插入,插入后调整让其依旧有序
        int tmp = a[end + 1];
        while (end >= 0)
        {
            if (tmp < a[end])
            {
                a[end + 1] = a[end];
                --end;
            }
            else
            {
                break;
            }
        }
        a[end + 1] = tmp;
    }
}
//快速排序
//三数取中选K
int GetMidIndex(int* a, int left, int right)
{
    int mid = left + (right - left) / 2;
    if (a[left] < a[mid])
    {
        if (a[mid] < a[right])
        {
            return mid;
        }
        else if (a[left] > a[right])
        {
            return left;
        }
        else
        {
            return right;
        }
    }
    else
    {
        if (a[mid] > a[right])
        {
            return mid;
        }
        else if (a[left] < a[right])
        {
            return left;
        }
        else
        {
            return right;
        }
    }
}
//前后指针法 key在left
int PartSort3(int* a, int left, int  right)
{
    int midi = GetMidIndex(a, left, right);
    Swap(&a[midi], &a[left]);
    int keyi = left;
    int prev = left;
    int cur = left+1;

    while (cur <= right)
    {
        if (a[cur] < a[keyi] && a[++prev] != a[cur])
            Swap(&a[cur], &a[prev]);
        cur++;
    }
    //prev所在地方和key交换
    Swap(&a[prev], &a[keyi]);
    return prev;
}
void QuickSort(int* a, int begin, int end)
{
    // 子区间相等只有一个值或者不存在那么就是递归结束的子问题
    if (begin >= end)
        return;
    //小区间优化
    if (end - begin + 1 <= 10)
    {
        InsertSort(a + begin, end - begin + 1);
    }
    int keyi = PartSort3(a, begin, end);
    // [begin, keyi-1]keyi[keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

7.3优化总结

通过对key值的合适选取以及小区间优化,大大提高了快速排序的效率,尽可能的让时间复杂度趋近于O(n*logN)。因此通过优化的快速排序是非常高效的。

8.结语

以上是快速排序的递归方法,还有非递归方法在下篇博客,还请大家持续关注

快速排序 非递归实现

(本篇完)


本文转载自: https://blog.csdn.net/qq_58325487/article/details/124302804
版权归原作者 小白又菜 所有, 如有侵权,请联系我们删除。

“[ 数据结构 -- 手撕排序算法第五篇 ] 快速排序 <包含hoare法,挖坑法,前后指针法> 及其算法优化”的评论:

还没有评论