数据结构 | 排序算法——冒泡排序与快速排序【史上最全】

简介: 70多张算法图解与DeBug步步调试教程,附带动画展示。带你全面理解冒泡排序与【⭐快速排序⭐】

在这里插入图片描述

上一文给大家讲解了排序算法中的选择排序与堆排序,今天,我们来进入交换排序,学习新的两种排序算法——冒泡排序与快速排序:mortar_board:

🌳冒泡排序

对于冒泡排序,大家应该是经常有听到过,也就是选定一个数与其后面的数作比较,将大的数或是小的数冒上来

🐚循序渐进的双层循环

  • 冒泡排序大家是很熟,但你是不是总有一个困惑,就是这个边界值老是处理不对,内层循环到底是从0开始还是1开始呢,到n - i结束还是n - i - 1结束呢,这一小节,就带大家从零开始慢慢地实现冒泡排序,了解其循环的开始和终止条件

    • 首先我们从内层循环开始,将最大的一个数冒到最后
//先写一个单趟的内部排序
for (int j = 1; j < n; ++j)
{
    if (arr[j - 1] > arr[j])
    {
        swap(arr[j - 1], arr[j]);
    }
}
  • 可以看到,这个内部的循环是从1开始到n - 1结束,也就是要比较n次,因此下面可以写成arr[j - 1] > arr[j],但如果你想从0开始也是可以的,那就要到n - 1结束,否则的话比较到最后数组就会越界了
  • 若是前一个数比后一个数大,那么就交换它们的位置
说完内层循环,接下去来说说套在外层的循环

在这里插入图片描述

  • 从图中可以看到,第一次比较,冒到最后一个位置是n次,而到倒数第二个位置是n - 次,第二次是N - 2此,依次类推,排序就会进行n - i此的比较,于是我们就可以写出如下代码
for (int i = 0; i < n - 1; i++)
{
    //先写一个单趟的内部排序
    for (int j = 1; j < n - i; ++j)
    {
        if (arr[j - 1] > arr[j])
        {
            swap(arr[j - 1], arr[j]);
        }
    }
    PrintArray(arr, n);
}

小结:

  • 外层for循环控制的是循环的次数
  • 内层for循环控制地是交换的次数

运行结果如下
在这里插入图片描述

🐚冒泡排序优化

了解了边界如何去计算,但是从上图我们可以看出,其实这个数字在进行了四次排序后就已经结束了,完成了升序排序,但是因为==外部的循环还没有到末尾==,因此还会进行一个继续的比较,我们来将其优化一下吧:point_left:
  • 首先要定义一个exchanged变量,将其初始化为0,然后在内部循环里判断其是否发生了变化,若是,则将其值置为1,接着在内部循环结束后取判断,看内层循环是否发生了交换,若没有发生交换,则表示此时的数组已经是有序的了,则直接break跳出循环即可

代码

for (int i = 0; i < n - 1; i++)
{
    int exchanged = 0;
    //先写一个单趟的内部排序
    for (int j = 0; j < n - i - 1; ++j)
    {
        if (arr[j] > arr[j + 1])
        {
            swap(arr[j], arr[j + 1]);
            exchanged = 1;
        }
        //PrintArray(arr, n);
    }
    /*优化,若是已经有序,则跳出循环*/
    if (!exchanged)    break;    
    PrintArray(arr, n);
}

运行结果
在这里插入图片描述

🐚清晰的算法图解与DeBug调试

就这样看代码可能也没有对内部的实现有一个很好的体会,接下来我们通过步步图解以及DeBug调试来感受一下==机器思维==
  • 一开始进入循环,我们设置DeBug监视窗口调试,可以看到首先arr[j - 1]是指向首位,arr[j]是指向第二位
在这里插入图片描述
  • 很明显,不需要交换,所以打印出来的还是原来的数组

在这里插入图片描述

  • 然后j++,进入下一次的比较,此时的arr[j - 1]与arr[j]便后移换位
在这里插入图片描述
  • 很明显7 > 4,所以会进入if判断,执行swap函数,然后置这个exchanged为1,可以看到,此时的7便被交换到了后面

在这里插入图片描述

  • 后面也是一样,新的arr[j - 1]变为7,arr[j]变为2,然后交换
在这里插入图片描述
  • 7 与 10 比一定不会交换,接着后面的10会依次与6 与 8 进行一个交换,这也就完成了一次整体的循环遍历以及交换
  • 接着这个就会进入下一个外层循环,也就是控制i的循环,从图示我们可以看出,随着新一轮遍历的开始,arr[j - 1]与arr[j] 又从头开始了它们的比较
在这里插入图片描述
  • 可以看出,此时5 > 4,一定会进行一个交换,依次类推,后面的5和2也是一样
在这里插入图片描述
  • 这是这一次遍历完后的结果
在这里插入图片描述
  • 然后i++开始新一轮的遍历
在这里插入图片描述
  • 很明显可以看出但这个4交换到2的后面时,就不需要再排序了【这个是我DeBug观察做的打印】,大家可以把内层的Print去掉,这样出来的就是四个简洁明了的步骤
在这里插入图片描述

  • 看完了分步骤的图解,对冒泡排序已经有了基本的思维接下来用动画来看一下吧

在这里插入图片描述

🌳快速排序【综合性能较优】✈

讲完了冒泡排序,接着我们来讲一种大家很喜欢用的排序——快速排序✈

1、挖坑法【经典】

📕思路分析

说到快速排序,首先要给大家介绍的就是最经典的挖坑法
  • 什么是挖坑法呢?就是不断地进行挖坑,不过这也是通俗的讲法,具体地我们还是要通过代码和算法图示来了解
  • 首先我们需要去定义两个指针一个指向首部,一个指向尾部,然后我们又需要一个标记值,也就是我们所说的坑位,这个坑位是需要随着值的交换去不断更新的
  • 那我们要怎么去更新每次的数字呢,这就要使用到一个key关键值去首选记录下你要对照的那个值,这个值获取的位置可以是begin首部,也可以是end尾部
  • 若你key值取的是begin,那么就要从end开始向前比较; 若你key值取的是end,那么就要从begin开始向后比较;
int begin = 0, end = n - 1;
int pivot = begin;
int key = a[begin];            //对照值
那具体应该怎么去进行一个比较呢?我们来看一下代码
while (begin < end)
{
    //右边找小,放到左边
    while (a[end] >= key)
    {
        --end;
    }
    a[pivot] = a[end];
    pivot = end;

    //左边找大,放到右边
    while (a[begin] <= key)
    {
        ++begin;
    }
    a[pivot] = a[begin];
    pivot = begin;
}
  • 观望整体代码我们可以看出,有三个循环,一个外层的大循环,两个内层的小循环
  • 对于内层的小循环,主要是用于当前位置的值与key关键值的大小比较,我们key值取的是begin,所以从end开始做比较
  • 可以看到,我们取出这个key值,是要==将比其小的数放到它的左边,将比其大的数放到它的右边==
  • 所以我们来看第一个循环,因为我们从右边是要找出一个比key小的数放到左边的坑位,所以当找到比key大的数时,就将尾指针end前移,直至找到一个比key小的数
  • 若是找到了一个,那么就将此时坑位中的值替换为我们在后面寻找到的值,然后更新此时的坑位值,也就是pivot,将其更新为end所在的位置
  • 然后第二个循环也是类似,便不做讲解,具体的我们到算法图解中看
  • 最后是外层的while循环,也就是当两个前后指针相遇的时候,便退出for循环,这个时候便需要执行这句话,因为此时两者交界的地方是没有数字的,此时的坑位也在此,所以应该将我们前面标记好的key值放入此处
  • 这样以key值作为分隔就形成了两个区域
pivot = begin;
a[pivot] = key;
我们来看一下运行结果吧

在这里插入图片描述

  • 很明显,从图中可以看出,g了,因为7在这个6的前面
那这个时候应该怎么办呢?那就又要出动我们的DeBug了,开始步步带大家调试💻

📕DeBug调试排错【视频版】

专门给大家录了个视频讲解,可以更加清晰地感受这个交换的逻辑,温馨提示:微信端看不到,可能有一些杂音
https://live.csdn.net/v/embed/244085

📕递归分治进化【内含原理图示】

上面我们只完成了第一步,也就是保持key值的左区间比它小,保持key值的右区间比它大,那接下去我们要怎么进行排序呢?
  • 在这里插入图片描述
  • 要知道,是一个区间有序,那就要让它的左右子区间有序,那怎么让它的左右子区间有序呢,就是让它的左子区间的左右子区间再有序,然后一步步递归下去,直到一个区间的数的个数为1时,便开始回调,因为一个数肯定是有序的
  • 这其实就是一种==分治递归==的思想,一层层的左右子区间可以看做是二叉树那样的模型,寻找一个结点的左子树,然后左子树中找结点变为根节点再找它的左右子树,一直这么遍历下去,直到遍历到叶子结点为止
  • 那因为这是一个区间的形式,所以肯定有左端点和右端点,所以需要一直更新其左右端点,因为在函数中,我们需要传入的是left和right这两个端点值,之后在递归遍历的时候再去更新,具体代码如下
void QuickSort(int* a, int left, int right)
{
    if (left >= right)
    {
        return;
    }
    int begin = left, end = right;
    int pivot = begin;
    int key = a[begin];            //对照值

    while (begin < end)
    {
        //右边找小,放到左边
        while (begin < end && a[end] >= key)
        {
            --end;
        }
        a[pivot] = a[end];
        pivot = end;

        //左边找大,放到右边
        while (begin < end && a[begin] <= key)
        {
            ++begin;
        }
        a[pivot] = a[begin];
        pivot = begin;
    }
    pivot = begin;
    a[pivot] = key;

    //[left,right]
    //[left,pivot - 1] pivot [pivot + 1,right]
    //左子区间和右子区间有序,我们就有序了,如何有序?分治递归
    QuickSort(a, left, pivot - 1);
    QuickSort(a, pivot + 1, right);
}

以下是原理图

在这里插入图片描述
  • 具体来看最后一段,两次递归调用,左半部分的右端点为中间值-1,右半部分的左端点为中间值+1,每一次整体循环遍历结束后,便开始遍历其左右子区间,然后看到最上面的一个判断
  • 若是left >= right时,便表示不形成一个区间,直接return
  • 以下就是排序后的结果
在这里插入图片描述
  • 具体大家如果想理解更加深刻的==也可以去自己DeBug一下==,看看程序是如何运行的,因为递归这个东西是比较抽象的,你需要通过自己画图然后结合DeBug去调试,才可以更好地理解程序

📕动画展示

  • 最后我们来通过动画来形象记忆,加深理解

在这里插入图片描述

🍞时间复杂度分析【很详细】

对于快速排序,它的时间复杂度是多少呢?我们一起来分析一下
  • 首先来看内部的单趟排序,虽然是循环里面嵌套循环,但是大家不要人为地人为就是O(n^2^),时间复杂度不是这么来看的,而是要看这个算法的具体内部实现
  • 从上面的分析我们可以看出,快排它是从两侧不断往中间寻找的一个过程,begin往后和end往前合起来其实就是遍历了N个数,因此这个内部遍历的单趟排序是一个O(n)的时间复杂度
while (begin < end)
{
    //右边找小,放到左边
    while (begin < end && a[end] >= key)
    {
        --end;
    }
    a[pivot] = a[end];
    pivot = end;

    //左边找大,放到右边
    while (begin < end && a[begin] <= key)
    {
        ++begin;
    }
    a[pivot] = a[begin];
    pivot = begin;
}
  • 那这个整体的时间复杂度又是多少呢,我们在挖坑法涉及到了【分治递归】的思想,每次将一个数的左右两次变为有序后,又进行一个折半(不一定每次都是),然后去继续递归将其左右子区间变为有序,那这个过程是不是和我们讲的【折半插入排序】很相似,因此这个分治递归的部分时间复杂度就是O(logN)
  • 我们通过一张算法图来看一下

在这里插入图片描述

  • 从以上图我们可以看出,随着这个左右子区间的不断缩小,最后两侧的数都会变为1,上面说了,就和二分法很相似,为O(logN)的时间复杂度
  • 然后因为要遍历N次,每次遍历折半区间减少的复杂度为O(logN),因此整体的时间复杂度变为==O(NlogN)==
但是我们从下面这张【排序复杂度分析图】看出,快速排序我们上次讲的堆排很类似,但是快排的最坏情况却是O(n^2^),这是为什么呢?我们来探究一下::mag:

在这里插入图片描述

  • 我们来画个图分析一下,刚开始我们需要选出一个key值,然后去前后地遍历,将比它小的放到它的左边,比它大的放到它的右边
  • 但是假设我们第一次最左边选到的就是最小的数呢,然后它的右边都呈现一个顺序的状态,那其实就不好弄了,因为这个递归的层次就会很深,每次都要去比较N个数,那N次遍历中嵌套N次比较,这个最坏情况就会和冒泡一样了,就会是O(n^2^)的时间复杂度

在这里插入图片描述

📕排序算法性能测试分析

上面讲到了快排在数据已经有序的情况下时间复杂度会提升到O(n^2^),但光是这样说大家可能不太信服,我们来测试一下
  • 首先是我们前面讲过的各种排序算法的罗列
void swap(int& x, int& y)
{
    int t = x;
    x = y;
    y = t;
}

void PrintArray(int* arr, int n)
{
    for (int i = 0; i < n; ++i)
    {
        cout << arr[i] << " ";
    }
    cout << endl;
}

/*直接插入排序*/
void InsertSort2(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;

        //PrintArray(a, n);
    }
}

/*希尔排序*/
void Shell_Sort2(int* arr, int n)
{
    int gap = n;
    while (gap > 1)
    {
        gap /= 2;        //log2N
        /*
         * gap > 1时都是预排序 —— 接近有序
         * gaop = 1 时为直接插入排序 —— 有序
        */

        //gap很大时,下面预排序时间复杂度O(N)
        //gap很小时,数组已经接近有序,这时差不多也是(N)
        //把间隔为gap的多组数据同时排
        for (int i = 0; i < n - gap; ++i)
        {
            int end = i;
            int tmp = arr[end + gap];
            while (end >= 0)
            {
                if (tmp < arr[end]) {
                    arr[end + gap] = arr[end];
                    end -= gap;
                }
                else {
                    break;
                }
            }
            arr[end + gap] = tmp;
        }
        //PrintArray(arr, n);
    }

}

/*选择排序*/
void Select_Sort(int* a, int n)
{
    int begin = 0;
    int end = n - 1;
    while (begin < end)
    {
        int mini = begin;
        int maxi = end;
        for (int i = begin; i < end; ++i)
        {
            /*更新最大最小值*/
            if (a[i] < a[mini])
            {
                mini = i;
            }
            if (a[i] > a[maxi])
            {
                maxi = i;
            }

        }
        //将最小值放在最前面,将最大值放在最后面
        swap(a[begin], a[mini]);
        swap(a[end], a[maxi]);
        begin++;
        end--;
    }
}

/*堆排序*/
void Adjust_Down(int* a, int n, int root)
{
    int parent = root;
    int child = parent * 2 + 1;
    while (child < n)
    {
        //选出左右孩子中小的那一个
        if (child + 1 < n && a[child + 1] > a[child])
        {    //考虑到右孩子越界的情况
            child += 1;
            //若右孩子来的小,则更新孩子结点为小的那个
        }
        //交换父亲节点和小的那个孩子结点
        if (a[child] > a[parent]) {
            swap(a[child], a[parent]);
            //重置父亲节点和孩子结点
            parent = child;
            child = parent * 2 + 1;
        }
        else {        //若已是小根堆,则不交换
            break;
        }
    }
}


void Heap_Sort(int* a, int n)
{
    //建堆 O(N)
    for (int i = (n - 1 - 1) / 2; i >= 0; --i)
        Adjust_Down(a, n, i);

    //排升序,建大堆
    int end = n - 1;        //获取最后一个叶子结点
    while (end > 0)
    {
        swap(a[0], a[end]);        //将第一个数与最后一个数交换
        Adjust_Down(a, end, 0);        //向下调整,选出次大的数,再和倒数第二个数交换
        end--;        //最后一个数前移,上一个交换完后的数不看做堆中的数
    }
}

/*冒泡排序*/
void Bubble_Sort3(int* arr, int n)
{
    for (int i = 0; i < n - 1; i++)
    {
        int exchanged = 0;
        //先写一个单趟的内部排序
        for (int j = 0; j < n - i - 1; ++j)
        {
            if (arr[j] > arr[j + 1])
            {
                swap(arr[j], arr[j + 1]);
                exchanged = 1;
            }
            //PrintArray(arr, n);
        }
        /*优化,若是已经有序,则跳出循环*/
        if (!exchanged)    break;
        //PrintArray(arr, n);
    }
}

/*快速排序*/
void QuickSort(int* a, int left, int right)
{
    if (left >= right)
    {
        return;
    }
    int begin = left, end = right;
    int pivot = begin;
    int key = a[begin];            //对照值

    while (begin < end)
    {
        //右边找小,放到左边
        while (begin < end && a[end] >= key)
        {
            --end;
        }
        a[pivot] = a[end];
        pivot = end;

        //左边找大,放到右边
        while (begin < end && a[begin] <= key)
        {
            ++begin;
        }
        a[pivot] = a[begin];
        pivot = begin;
    }
    pivot = begin;
    a[pivot] = key;

    //[left,right]
    //[left,pivot - 1] pivot [pivot + 1,right]
    //左子区间和右子区间有序,我们就有序了,如何有序?分治递归
    QuickSort(a, left, pivot - 1);
    QuickSort(a, pivot + 1, right);
}
  • 然后是测试排序算法性能的代码,都给到大家
void TestOP()
{
    srand(time(0));
    const int N = 50000;
    int* a1 = (int*)malloc(sizeof(int) * N);
    int* a2 = (int*)malloc(sizeof(int) * N);
    int* a3 = (int*)malloc(sizeof(int) * N);
    int* a4 = (int*)malloc(sizeof(int) * N);
    int* a5 = (int*)malloc(sizeof(int) * N);
    int* a6 = (int*)malloc(sizeof(int) * N);
    for (int i = 0; i < N; ++i)
    {
        a1[i] = rand();
        a2[i] = a1[i];
        a3[i] = a1[i];
        a4[i] = a1[i];
        a5[i] = a1[i];
        a6[i] = a1[i];
    }
    int begin1 = clock();
    InsertSort2(a1, N);        //直接插入排序
    int end1 = clock();

    int begin2 = clock();
    Shell_Sort2(a2, N);            //希尔排序
    int end2 = clock();

    int begin3 = clock();
    Select_Sort(a3, N);        //选择排序
    int end3 = clock();

    int begin4 = clock();
    Heap_Sort(a4, N);            //堆排序
    int end4 = clock();

    int begin5 = clock();
    Bubble_Sort3(a5, N);        //冒泡排序
    int end5 = clock();
    
    int begin6 = clock();
    QuickSort(a6, 0, N - 1);    //快速排序
    int end6 = clock();

    printf("InsertSort:%d\n", end1 - begin1);
    printf("ShellSort:%d\n", end2 - begin2);
    printf("SelectSort:%d\n", end3 - begin3);
    printf("HeapSort:%d\n", end4 - begin4);
    printf("Bubble_Sort3:%d\n", end5 - begin5);
    printf("QuickSort:%d\n", end6 - begin6);
    free(a1);
    free(a2);
    free(a3);
    free(a4);
    free(a5);
    free(a6);
}
在这里插入图片描述
  • 从上可以看出我们之前所学的==六大排序算法的优劣==,可以看出快排还是处于比较优先的位置,和希尔排序、堆排序这些并肩,这个时候我们做一个操作,不要眨眼👀
int begin5 = clock();
Bubble_Sort3(a5, N);        //冒泡排序
int end5 = clock();

int begin6 = clock();
QuickSort(a5, 0, N - 1);    //快速排序
int end6 = clock();
  • 可以看到,我将对快速排序传入的数组从a6变成了a5,也就是用冒泡排序已经排好的数组在进行一个排序,此时数组已经是有序的,这个时候我们来验证一下快速排序它的复杂度是否有所提升
在这里插入图片描述
  • 可以看到,从3ms提升到了140ms,虽然毫秒这个单位很小,但是对于这里来说,快排已经从O(NlogN)提升到了O(N^2^),事实证明对已经有序的数据去进行快速排序确实会提高其时间复杂度,大家下去也可以自己试一下

注意事项:测试性能记得在Release版本下,会有一些优化,DeBug版本数据太大可能会出现栈溢出的,亲测/(ㄒoㄒ)/~~

但是尽管是这样,快速排序使用的人还是那么多,这是为什么呢?==我们把数据量改到100000==

在这里插入图片描述

  • 可以看到,虽然此时数据量到达10万,但是快速排序还是当仁不让地快,甚至是比希尔和堆排都要来的快,当然不一定每次都这样
  • 所以这就是快速排序用得这么多的原因,包括像在C语言里的qsort【quick sort】,C++/Java库里用的Sort()排序底层也都是快排
  • 从上可以看出来,快速排序的应用是蛮广泛的🐟

2、左右指针法【与挖坑法类似】

📕代码与算法图解析

讲完一种快速排序的方法,并且用它与其他排序算法完成了性能测试,接下来我们再来学习一种快速排序的方法,叫做【左右指针法】,它是挖坑法延伸出来的,与挖坑法非常得类似
  • 首先我们对刚才写的代码再优化一下,将单趟排序封装为一个函数
//单趟排序
int PartSort1(int* a, int left, int right)
{
    int index = GetMid(a, left, right);        //获取中间值
    swap(a[left], a[index]);        //将中间值换到第一位上

    int begin = left, end = right;
    int pivot = begin;
    int key = a[begin];            //对照值

    while (begin < end)
    {
        //右边找小,放到左边
        while (begin < end && a[end] >= key)
        {
            --end;
        }
        a[pivot] = a[end];
        pivot = end;

        //左边找大,放到右边
        while (begin < end && a[begin] <= key)
        {
            ++begin;
        }
        a[pivot] = a[begin];
        pivot = begin;
    }
    pivot = begin;
    a[pivot] = key;

    return pivot;        //返回坑的位置
}
  • 然后对于挖坑法也需要做一个修改
/*【挖坑法】左右小区间法——小型优化*/
void QuickSort2(int* a, int left, int right)
{
    if (left >= right)
    {
        return;
    }
    
    int keyindex = PartSort1(a, left, right);        //获取坑位

    //[left,right]
    //[left,keyindex - 1] keyindex [keyindex + 1,right]
    //左子区间和右子区间有序,我们就有序了,如何有序?分治递归

    //左子区间
    if (keyindex - 1 - left > 10)
    {            //若数据量 > 10,则继续递归
        QuickSort(a, left, keyindex - 1);
    }
    else
    {        //若数据量 <= 10,则开始优化
        InsertSort2(a + left, keyindex - 1 - left + 1);
                //数组首元素地址    数组个数
    }
    //右子区间
    if (right - (keyindex + 1) > 10)
    {            //若数据量 > 10,则继续递归
        QuickSort(a, keyindex + 1, right);
    }
    else
    {        //若数据量 <= 10,则开始优化
        InsertSort2(a + keyindex + 1, right - (keyindex + 1) + 1);
        //数组首元素地址    数组个数
    }
    
}
接下去我们来说一下左右指针法
  • 首先还是一样,还是需要begin和end两个指针,起初指针数组的首和尾,然后还有一个key的指针,指向begin所在的位置,这个时候end先走,去寻找比key指针位置上所在值小的元素,可以看到4就是,所以不用走
  • 然后begin去找比key指针位置上所在值大的元素,接着交换begin和end上这两个位置上的值即可
在这里插入图片描述
  • 可以看到,此时8和4做了一个交换
在这里插入图片描述
  • 接下去两个前后指针继续移动,根据上面的规则去寻找符合条件的数字
  • 可以看到,找到了9与2这两个数字,则进行一个交换
在这里插入图片描述
  • 此时end先往前做一个移动,移动了两位找到了一个3,然后begin向后移动,很明显是要去找这个4,但是呢,遍历到3的位置时它就与end相遇了,这个时候要注意,我们在写内部循环的之后一定要加上==begin < end==这个条件判断,若是不满足,则直接跳出循环,交换key和begin上位置的数字即可
在这里插入图片描述
  • 我们从下面的DeBug也可以看出,最后返回的keyindex值为4,这个就是下一次要递归循环时我们需要的中间值
在这里插入图片描述
  • 接下来给大家看一下代码逻辑
  • 以下就是左右指针法的代码,供大家测试使用:point_down:
//单趟排序
int PartSort2(int* a, int left, int right)
{
    int index = GetMid(a, left, right);        //获取中间值
    swap(a[left], a[index]);        //将中间值换到第一位上

    int begin = left, end = right;
    int key = begin;        //对照值

    while (begin < end)
    {
        //右边找小,放到左边r
        while (begin < end && a[end] >= a[key])
        {
            --end;
        }
        //左边找大,放到右边
        while (begin < end && a[begin] <= a[key])
        {
            ++begin;
        }
        swap(a[begin], a[end]);
    }
    swap(a[begin], a[key]);

    return begin;
}

📕动画展示

下面是动画展示,更加形象一点

在这里插入图片描述

3、前后指针法【很妙,也很细】

📕代码与算法图解析

讲完了左右指针法,再来讲一种叫做前后指针法,这种方法其实思想也和上两种差不多,也是两个指针在移动,不过这种是==同时向后移动==,让我们一起来看一下
  • 首先我们需要一个prev指针,指向left位置,然后一个key指针也是指向这个位置,然后cur是prev的后一个指针,这就是两个前后指针
  • 然后来说一下它们的移动规则,首先去判断当前cur指针所指位置是否比当前的key所指位置要来的小,若不是,则cur向前移动一位,若是,则将prev前移一位,再与cur位置上的数字交换,然后接着移动cur,知道其碰到数组的右边界时,跳出循环,此时交换prev与key上的值,那么此时prev上的位置即为我们所要返回的中间值
在这里插入图片描述
  • 了解了规则,我们来玩一下这种方法吧,还是比较巧妙的一种方法
  • 首先由上图可知,a[cur] = 8 > 6,则后移,然后看到下图,,a[cur] = 1 < 6,此时我们需要后移prev指针,==然后将这两个前后指针位置上的值做一个交换==
在这里插入图片描述
  • 交换完后,cur指针继续后移寻找符合条件的值,然后找到 了3 < 6,这个时候prev指针后移,交换它们两个指针位置上的值
在这里插入图片描述
  • 交换之后,cur继续向后寻找,这时找到了2 < 6,交换它们两个下标位置上的值
在这里插入图片描述
  • 然后指针后移继续寻找,找到一个4 < 6,然后prev后移一位,同理,两位置交换
在这里插入图片描述
  • 然后我们继续来移动,此时这个cur指针已经越界,超过了数组的right边界,因此我们应该跳出搜索的遍历,将key与prev上的值互换
在这里插入图片描述
  • 此时这个prev上的值即为我们下一次递归所需要的中间值,return即可
在这里插入图片描述
  • 我们再来看一下代码逻辑,可以看到,代码并不复杂,这种方法也是比较高效,推荐大家使用(๑•̀ㅂ•́)و✧
int PartSort3(int* a, int left, int right)
{
    int key = left;
    int prev = left, cur = left + 1;
    while (cur <= right)
    {
        if (a[cur] < a[key])
        {
            prev++;
            swap(a[prev], a[cur]);
        }
        cur++;
    }
    swap(a[prev], a[key]);
    
    return prev;
}

📕动画展示

最后我们通过动画再来过一遍

在这里插入图片描述

🍞快速排序方法的“非递归写法”【⭐校招要求⭐】

📕递归的缺陷分析

看完了上面三种快速排序的,我们可以看出,都是使用递归的方法去实现的,也就是通过一层的遍历找出一个中间值,然后根据这个中间值进行一个左右划分,分别去进行分治递归。可以看出三种方法虽然类似,但都有自己的独特之处

但是大家肯定有一个疑问,既然都已经学了三种方法了,那为什么还要再去学习非递归的写法呢?我们来探究一下:mag:

  • 我们都知道,递归的话是一层嵌套一层,一直递归到结束条件为止然后步步回调,看起来是很有思维逻辑,但是随着这个数据量的增大,==递归的深度也会逐渐地加深==。而且递归它是需要在栈空间中开辟栈帧的,在内存中,这个栈空间是很小的,大家可以进VS里看一下,默认的栈空间只有1M
在这里插入图片描述
  • 所以如果这个数据量一旦增大的话,递归的深度也会不断加深,然后导致栈空间不够用,就导致了我们经常遇到的栈溢出问题【Stack Flow】—— 这就是递归的缺陷
在这里插入图片描述
  • 所以,为什么说校招要考非递归这一块呢,就是想让你进企业后在有些数据量大的地方可以使用非递归来实现,因为在企业中开发的项目通常是很大的一个工程,都是直接面向用户的,所以这个数据量是很庞大的,如果我们用递归来实现,可能会给项目中==安放一些致命的危险==
那非递归改递归这一块要怎么实现呢?我们来看一下

📕非递归代码实现

  • 下面的递归转非递归是借助【数据结构栈】模拟递归的过程,会复杂一些,大家要==重点理解==

先给出整体代码

void QuickSort_NoRecursive(int* a, int n)
{
    /*堆栈的逆序思维*/
    ST st;
    InitStack(&st);
    Push(&st, n - 1);        //先入右区间
    Push(&st, 0);            //再入左区间

    while (!StackEmpty(&st))    //直到栈为空
    {
        //此时栈顶先为左区间,再为右区间
        //先获取左区间
        int left = Top(&st);        
        Pop(&st);

        //再获取右区间
        int right = Top(&st);
        Pop(&st);

        int keyindex = PartSort1(a, left, right);        //传入区间值获取中间值
        //[left,keyindex - 1] keyindex [keyindx + 1,right]

        //若区间还未有序,则继续入栈出栈,使区间有序
        //1.先是右区间
        if (keyindex + 1 < right) 
        {
            Push(&st, right);
            Push(&st, keyindex + 1);
        }

        //2.再是左区间
        if (left < keyindex - 1)
        {
            Push(&st, keyindex - 1);
            Push(&st, left);
        }
    }
}

然后我们来分步讲解一下

  • 首先我们先包含一下堆栈的头文件,用我们前面已经实现过的。这里是用到堆栈去模拟这个递归的过程,本质上看来很像递归,但是却充分展现了堆栈【FILO】的原理
  • 可以看到第一段,我们在初始化栈后先入了右区间再入了左区间,因为我们要先处理左区间,所以在后面出栈栈顶元素的时候便是左边的哪一部分
ST st;
InitStack(&st);
Push(&st, n - 1);        //先入右区间
Push(&st, 0);            //再入左区间
  • 也就是下面这段逻辑,因为栈顶元素是左区间那个部分,所以我们拿left去取,取好Pop完后再取到的就是右区间了
//此时栈顶先为左区间,再为右区间
//先获取左区间
int left = Top(&st);        
Pop(&st);

//再获取右区间
int right = Top(&st);
Pop(&st);
  • 有了left和right这两个端点值后,便可以通过这个端点值去进行单趟排序,然后获取中间那个值
int keyindex = PartSort1(a, left, right);        //传入区间值获取中间值
  • 当我们获取到这个中间值后,则去解决其左右子区间,要怎么解决呢,这个时候就有异于我们的这个递归写法了,而且去判断左右区间是否还有元素个数,若是还有,则将这一半边继续入栈,这样便不会跳出循环,一直会有一个入栈出栈的过程,然后去不断地缩小返回
  • 直至左右子区间只有一个数为止,这时便会去解决另一个左区间或者是右区间,直至两个区间都完成了有序排序,那我们在递归的时候有说过,==两侧有序,则这个整体就是呈现有序的==
  • 这个时候栈也空了,便会跳出循环,自然而然地就完成了排序
//[left,keyindex - 1] keyindex [keyindx + 1,right]
//若区间还未有序,则继续入栈出栈,使区间有序
//1.先是右区间
if (keyindex + 1 < right) 
{
    Push(&st, right);
    Push(&st, keyindex + 1);
}

//2.再是左区间
if (left < keyindex - 1)
{
    Push(&st, keyindex - 1);
    Push(&st, left);
}
  • 这是排序的结果,可以看出,一样是可以排出来的,而且这种方法更加安全一些🛡

在这里插入图片描述

大家下去可以自己DeBug一下,这里就不带大家做了,自行体会一下它究竟是如何运行的,你就会懂得其中的原理

🍞快速排序优化【⭐⭐⭐⭐⭐】

📕【三数取中法】—— 高性能优化

代码&算法图逻辑分析

分析了快速排序的时间复杂度,知道了它有一个小缺陷,就是当数组有序时,快速排序的时间复杂度会从O(NlogN)上升到O(n^2^),接近与冒泡排序,但是有没有方法可以对齐进行优化呢?那一定是有的,我们一起来看一下吧
  • 这个方法就是我们上面所提到的【三数取中法】,字面意思,就是从三个数中取出中间的那个数,我们设左边的数为left,设右边的数为right,然后它们的中间值为mid
  • 我们通过算法图和代码一步步地来看一下
  • 首先要先取出它的中间值,这里得【>>】是右移运算符,意思就是在二进制位上将其往右移动一位,对于二进制来说就是缩小两倍,也就是/2的意思
int mid = (left + right) >> 1;
  • 然后我们给出第一层判断逻辑
if (a[mid] > a[left])
{
    if (a[mid] < a[right])
    {    //left  mid  right
        return mid;        //此时mid便为中间值,返回即可
    }
    else if (a[mid] > a[right])
    {
        if (a[left] < a[right])
        {
            return right;
        }    //left  right  mid
        else
        {
            return left;
        }    //right  left  mid
    }
}
  • 首先看到第一种,就是这个mid所在位置的值大于left所在位置的值的时候,继续进入判断,若是mid所在位置的值又小于right所在位置的值,那这个mid就处于中间位置了,直接返回即可
在这里插入图片描述
  • 接着若是这个mid值不是小于right,那么它就一定处于最右侧,是最大的,这个时候我们内层的逻辑就是要去判断left和right的大小了

在这里插入图片描述

  • 若是a[right ] > a[left] ,那么right此时便在中间,返回right即可
  • 若是a[left] > a[right ] ,那么left此时便在中间,返回left即可
在这里插入图片描述

  • 看完了第一层逻辑,我们再来看第二层逻辑
  • 也就是当这个left所在位置的值要比mid所在位置的值要大的时候,也是有三种情况需要判断,代码逻辑和上面类似,便不做细讲
else    //a[left] >= a[mid]
{
    if (a[mid] > a[right])
    {    //right  mid  left
        return mid;
    }
    else if (a[left] < a[right])
    {    //mid  left  right
        return left;
    }
    else
    {    //mid  right  left
        return right;
    }
}
在这里插入图片描述

性能测试

上面了解了如何去优化快速排序,接下来我们来测试一下这个代码逻辑是不是真的可以实现性能优化
  • 在QuickSort()快排函数中写上这两句代码,首先去获取那个中间值,然后将这个中间值与左值进行一个交换,因为我们每次拿的key值就是最前面的这个begin所在的位置,因此只要将这个mid所在的值换到此处即可,其他地方都不需要动
int index = GetMid(a, left, right);        //获取中间值
swap(a[left], a[index]);        //将中间值换到第一位上

==优化前==

在这里插入图片描述

==优化后==(这里冒泡注释掉了,换了个地方测试)

在这里插入图片描述
  • 可以看出,从2000ms到2ms,这可谓是一个质的飞跃

📕【左右小区间法】—— 小型优化

运用三数取中法,对快速排序进行了一个优化,接下去我们再来将一种优化方式,叫做左右小区间法
  • ==对于三数取中法,是在开头优化;对于左右小区间法,则是在结尾优化==
  • 好,这里给大家【简单】画了一张图,其实随着这个递归次数的增加,递归的层层深入,这个数据量也会被倍增,那么这个程序所需要消耗的内存就会越多,那我们有没有办法将最后的这几层递归消除呢?
  • 这就要用到这个【左右小区间法】,什么叫左右小区间法呢?也就是随着这个区间被不断地划分,到了最后的那么几个区间,比如说每个区间只剩十个数的时候,我们就考虑将这个区间内的数再进行一个排序
  • 那这个时候还是用快排吗,当然不是?如果用快排的话那和继续递归下去就没什么两样了
  • 我们要使用其他的、用着此处最合适的排序算法,首先排除冒泡、选择,O(N^2^)的肯定不要,堆排序还要建堆,虽然性能可观,但只会增加繁琐度。那用什么,用希尔吗?不,这个地方不能用希尔,因为这个地方的数据量大概只有10个左右,并不多,希尔排序的话最好是用在数据量较大的地方,这样才可以凸显出其优势。一个个排除下来,最后只剩下直接插入排序了,对,就是用它,虽然在有些场合下直接插入排序的性能不是很优,但是在此处只有10个数的情况,我们用==直接插入排序==最为合适
在这里插入图片描述
  • 我们来看一下具体的代码
  • 可以看到,若是这个区间中的数据量大小 > 10时,便继续递归调用,若是当这个数据量 <= 10,则开始优化,使用直接插入排序对这个区间进行一个排序
//左子区间
if (pivot - 1 - left > 10)
{            //若数据量 > 10,则继续递归
    QuickSort2(a, left, pivot - 1);
}
else
{        //若数据量 <= 10,则开始优化
    InsertSort2(a + left, pivot - 1 - left + 1);
    //数组首元素地址    数组个数
}
//右子区间
if (right - (pivot + 1) > 10)
{            //若数据量 > 10,则继续递归
    QuickSort2(a, pivot + 1, right);
}
else
{        //若数据量 <= 10,则开始优化
    InsertSort2(a + pivot + 1, right - (pivot + 1) + 1);
    //数组首元素地址    数组个数
}
  • 我们再来看一下运行结果

==小区间优化后==

在这里插入图片描述

==小区间优化前==

在这里插入图片描述
  • 可以看到,也只是少了10ms左右,所以说这只是一个【小型优化】,当然每次跑出来的数以肯定不一样,这是要根据你数据量的大小来算的

🌳总结与提炼

  • 总算是讲完了两种交换排序——冒泡与快排,尤其是在快速排序这一块,我花了很大的精力在讲,说到了快排有三种递归的方法,分别是==挖坑法、左右指针法以及前后指针法==,而且还有非递归的写法,为的就是防止递归太深导致的栈溢出问题
  • 而且还给大家分析了我们前面所学过的六种排序算法的性能,通过这个数据的取值去感受了它们在各个场合下的性能到底谁更加优一些
  • 下文我们将介绍两种外部排序算法——归并排序与计数排序,记得关注哦:heart:
最后感谢您对本文的观看,如有问题请于评论区留言或者私信我
相关文章
|
6月前
|
搜索推荐 算法 Go
Go语言数组排序(冒泡排序法)—— 用最直观的方式掌握排序算法
本案例介绍使用冒泡排序对整数数组进行升序排序的实现方法,涵盖输入处理、错误检查与排序逻辑。通过代码演示和算法解析,帮助理解排序原理及Go语言切片操作,为学习更复杂排序算法打下基础。
|
6月前
|
存储 监控 安全
企业上网监控系统中红黑树数据结构的 Python 算法实现与应用研究
企业上网监控系统需高效处理海量数据,传统数据结构存在性能瓶颈。红黑树通过自平衡机制,确保查找、插入、删除操作的时间复杂度稳定在 O(log n),适用于网络记录存储、设备信息维护及安全事件排序等场景。本文分析红黑树的理论基础、应用场景及 Python 实现,并探讨其在企业监控系统中的实践价值,提升系统性能与稳定性。
187 1
|
6月前
|
搜索推荐
冒泡排序与其它排序算法比较
本内容比较了冒泡排序、选择排序和插入排序的特性。三者时间复杂度均为O(n²),但交换次数和稳定性不同。冒泡排序稳定,交换次数多,可优化至O(n);选择排序不稳定,交换次数少;插入排序交换次数最少,且二者均为稳定排序。对于有序数组,冒泡和插入可优化提升效率。
135 0
|
6月前
|
存储 监控 算法
基于跳表数据结构的企业局域网监控异常连接实时检测 C++ 算法研究
跳表(Skip List)是一种基于概率的数据结构,适用于企业局域网监控中海量连接记录的高效处理。其通过多层索引机制实现快速查找、插入和删除操作,时间复杂度为 $O(\log n)$,优于链表和平衡树。跳表在异常连接识别、黑名单管理和历史记录溯源等场景中表现出色,具备实现简单、支持范围查询等优势,是企业网络监控中动态数据管理的理想选择。
186 0
|
9月前
|
算法 搜索推荐
快速排序-数据结构与算法
快速排序(Quick Sort)是一种基于分治法的高效排序算法。其核心思想是通过选择基准(pivot),将数组划分为左右两部分,使得左侧元素均小于基准,右侧元素均大于基准,然后递归地对左右两部分进行排序。时间复杂度平均为 O(n log n),最坏情况下为 O(n²)(如数组已有序)。空间复杂度为 O(1),属于原地排序,但稳定性不佳。 实现步骤包括编写 `partition` 核心逻辑、递归调用的 `quickSort` 和辅助函数 `swap`。优化方法有随机化基准和三数取中法,以减少最坏情况的发生。
634 13
|
10月前
|
存储 算法 Java
算法系列之数据结构-二叉树
树是一种重要的非线性数据结构,广泛应用于各种算法和应用中。本文介绍了树的基本概念、常见类型(如二叉树、满二叉树、完全二叉树、平衡二叉树、B树等)及其在Java中的实现。通过递归方法实现了二叉树的前序、中序、后序和层次遍历,并展示了具体的代码示例和运行结果。掌握树结构有助于提高编程能力,优化算法设计。
335 10
 算法系列之数据结构-二叉树
|
10月前
|
算法 Java
算法系列之数据结构-Huffman树
Huffman树(哈夫曼树)又称最优二叉树,是一种带权路径长度最短的二叉树,常用于信息传输、数据压缩等方面。它的构造基于字符出现的频率,通过将频率较低的字符组合在一起,最终形成一棵树。在Huffman树中,每个叶节点代表一个字符,而每个字符的编码则是从根节点到叶节点的路径所对应的二进制序列。
321 3
 算法系列之数据结构-Huffman树
|
10月前
|
算法 Java
算法系列之数据结构-二叉搜索树
二叉查找树(Binary Search Tree,简称BST)是一种常用的数据结构,它能够高效地进行查找、插入和删除操作。二叉查找树的特点是,对于树中的每个节点,其左子树中的所有节点都小于该节点,而右子树中的所有节点都大于该节点。
442 22
|
11月前
|
存储 机器学习/深度学习 算法
C 408—《数据结构》算法题基础篇—链表(下)
408考研——《数据结构》算法题基础篇之链表(下)。
414 30
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
321 59

热门文章

最新文章