【初阶数据结构与算法】八大排序之非递归系列( 快排(使用栈或队列实现)、归并排序)

*在这里插入图片描述

文章目录

一、非递归版快排

1.使用栈实现非递归版快排

在学习非递归版快排前,建议大家先学习递归版的快排,否则非递归版的快排将很难理解,这里附上本人写的快排的博客解析:【初阶数据结构与算法】八大排序算法之交换排序(冒泡排序,快速排序—hoare、挖坑法、lomuto双指针3种版本)

接下来我们来学习如何使用栈这个数据结构帮我们实现快排,首先我们都知道快排是通过递归实现的,那么问题来了,为什么栈就可以帮我们模拟递归的行为

这就不得不提到递归的本质了,递归是通过函数自己对自己的调用,在内存的栈区开辟新的函数栈帧,执行同样的代码实现递归,此时我们要发现一个重点,就是递归的本质是通过在栈区开辟函数栈帧实现

而栈区的压栈方式与我们数据结构中的压栈类似,所以我们才能通过栈这个数据结构来模拟递归调用的行为,只要理解到这一点,后面的内容将会好懂得多

接着我们来正式实现非递归版的快排,首先我们需要一个栈作为辅助,可以将之前写过的栈导入到当前文件夹进行添加,栈在非递归快排中的任务就是存储下标

因为我们递归划分数组不是真的把它们分开了,而是通过给定左右范围的下标来访问不同区域,以此达到划分数组的效果,所以快排递归最重要的信息是下标,只要我们能将下标保存并能够以类似于递归的方式使用,我们就可以不使用递归实现快排

而栈就可以起到这样的效果,我们将指定数组序列的左右下标存入栈中,为了我们取出来是左右的顺序,我们在入栈时要将右下标先入栈,再入栈左下标,如下:

```c
//非递归版快排
void NonRQuickSort1(int* arr, int left, int right)
{
    ST st;
    STInit(&st);
    STPush(&st, right);
    STPush(&st, left);

    STDestroy(&st);

```

这样我们就可以保证我们取出对应下标时先取出左下标,再取出右下标,接着我们就创建一个循环,只要栈不为空,我们就进入循环取出对应的左右下标,简单进行一下判断,如果左右下标不符合要求就跳过后面的处理,如果左右下标没有问题就使用快排子函数对这段区间进行处理,最后返回处理好的基准值下标,如下:

```c
while (!STEmpty(&st))
{
    //取出区间的下标
    int left = STTop(&st);
    STPop(&st);
    int right = STTop(&st);
    STPop(&st);
    //如果取出的下标不需要处理就直接使用continue跳过
    if (left >= right) continue;
    int keyi = _QuickSort(arr, left, right);
}

```

接着我们就根据返回的基准值的下标对当前的区间进行划分,然后将这些划分后的下标入栈,要注意的是要从右往左将下标入栈,否则到时候取下标的时候可能左右下标相反的,如下:

```c
//非递归版快排
void NonRQuickSort1(int* arr, int left, int right)
{
    ST st;
    STInit(&st);
    STPush(&st, right);
    STPush(&st, left);
    while (!STEmpty(&st))
    {
        int left = STTop(&st);
        STPop(&st);
        int right = STTop(&st);
        STPop(&st);

        if (left >= right) continue;
        int keyi = _QuickSort(arr, left, right);
        //[left, keyi - 1] [keyi + 1, right]
        STPush(&st, right);
        STPush(&st, keyi + 1);
        STPush(&st, keyi - 1);
        STPush(&st, left);
    }
    STDestroy(&st);
}

```

这样我们非递归版本的快排就完成了,如果对比递归版的快排的话,可以发现差别都不大,只是使用栈模拟了递归的行为,其实它还可以进行优化,这个我们后面再讲,现在我们先运行一下代码,看看有没有问题:
在这里插入图片描述
可以看到代码没有问题,接着我们来优化一下上面的代码,在上面的非递归快排代码中,我们是将所有区间都入栈,没有判断它们是否越界等等情况,而是在取出它们之后进行判断,很明显这样入栈没用的区间非常耗费资源
所以我们可以对它进行优化,在入栈之前我们可以判断一下新的左右区间,区间如下:

```c
[left, keyi - 1] [keyi + 1, right]

```

如果left大于或等于keyi - 1,说明此时这个区间只有一个元素,或者根本不存在这个区间,r如果right小于或等于keyi + 1,说明这个区间也只有一个元素,或者这个区间根本不存在,所以我们在入栈前可以做如下判断:

```c
//right满足条件才让右序列的左右下标入栈
if (right > keyi + 1)
{
    STPush(&st, right);
    STPush(&st, keyi + 1);
}
//left满足条件才让左序列的左右下标入栈
if(left < keyi - 1)
{
    STPush(&st, keyi - 1);
    STPush(&st, left);
}

```

这样我们使用了一个简单的条件判断就可以去掉许多无用的入栈、出栈操作,优化后的代码如下:

```c
//非递归版快排
void NonRQuickSort1(int* arr, int left, int right)
{
    ST st;
    STInit(&st);
    STPush(&st, right);
    STPush(&st, left);
    while (!STEmpty(&st))
    {
        int left = STTop(&st);
        STPop(&st);
        int right = STTop(&st);
        STPop(&st);

        //if (left >= right) continue;
        int keyi = _QuickSort(arr, left, right);
        //[left, keyi - 1] [keyi + 1, right]
        if (right > keyi + 1)
        {
            STPush(&st, right);
            STPush(&st, keyi + 1);
        }
        if(left < keyi - 1)
        {
            STPush(&st, keyi - 1);
            STPush(&st, left);
        }
    }
    STDestroy(&st);
}

```

我们来测试一下这段代码:
在这里插入图片描述
可以看到代码没有问题,这就是使用栈实现非递归版快排的所有过程啦,接下来我们开始学习使用队列实现非递归版快排

2.使用队列实现非递归版快排

刚刚的非递归快排就是利用栈模拟递归,因为递归的本质就是在栈区里面开辟新的函数栈帧,这个过程和我们入栈操作类似,所以我们才能使用栈实现非递归快排

那么队列又为什么可以实现非递归快排呢?这个时候我们就要换个角度来看待快排了,我们要以二叉树的角度来理解快排,快排就类似于二叉树中的前序遍历

就是将整个数组看作一颗二叉树的根,先通过子函数处理整个数组,然后再通过返回的基准值下标进行划分左右区间,然后再递归处理左右区间,跟我们的前序遍历相似

那么这时候我们再联系队列可以想到什么呢?是不是就可以想到我们的层序遍历,由于快排的左右区间一旦划分就互不影响,所以我们可以使用层序遍历的思想,将划分出来的左右区间依次进行处理,层序遍历结束,我们的快排也就结束了

所以我们的队列版快排就是模仿层序遍历的过程,但是代码形式其实和栈一模一样,只是将用栈的地方全部换成了队列,但是我们要知道为什么可以使用队列实现非递归的快排,接下来我们就来按照思路写代码,如下:

```c
//非递归版快排(使用队列实现)
void QuickSortNonR2(int* arr, int left, int right)
{
    Queue q;
    QueueInit(&q);
    QueuePush(&q, left);
    QueuePush(&q, right);

    while (!QueueEmpty(&q))
    {
        int left = QueueFront(&q);
        QueuePop(&q);
        int right = QueueFront(&q);
        QueuePop(&q);

        int keyi = _QuickSort(arr, left, right);
        //[left, keyi - 1] [keyi + 1, right]
        if (left < keyi - 1)
        {
            QueuePush(&q, left);
            QueuePush(&q, keyi - 1);
        }
        if (right > keyi + 1)
        {
            QueuePush(&q, keyi + 1);
            QueuePush(&q, right);
        }
    }

    QueueDestroy(&q);
}

```

我们来测试一下队列实现的非递归排序能否帮我们完成排序工作,如下:

在这里插入图片描述
可以看到代码没有问题,接下来我们继续学习非递归版的归并排序

二、非递归版归并排序

在学习非递归版归并排序之前,希望大家先要搞懂递归的归并排序的实现原理,否则学习非递归版的归并排序将会很痛苦,这是本人曾写过的一篇归并排序的博客,如果还没有学过归并排序可以看看:【初阶数据结构与算法】八大排序算法之归并排序与非比较排序(计数排序)

1.非递归版归并排序的实现

如果说快排的形式类似于前序遍历,那么归并排序的形式就类似于后序遍历,那么我们能否使用栈或者是队列来实现非递归版的归并排序呢?

结论是不可以,那么这是为什么呢?因为后序遍历会让左子树递归到最深处,然后对左子树处理后进行返回,然后右子树递归到最深处处理后返回,最后我们处理根的时候,是需要左右子树的信息的

但是如果使用栈的话就模拟不出这个效果,因为栈里面存储的是下标,这个序列的区间下标一用完我们就会将它pop掉,导致我们在最后处理根的时候拿不到左右子树的信息,这就是不能使用栈实现归并的原因

其实大致总结一下,如果递归的形式像前序遍历,那么我们才能使用栈来模拟它的行为,如果类似于中序和后序遍历就不行,根本原因还是前序遍历会先处理根,不需要左右子树的信息,而中序和后序遍历时,处理根就会需要左子树或者左右子树的信息,而栈只能模拟先处理根的情况

那么我们怎么才能实现非递归版的归并排序呢?这就要求我们透过归并排序看清它的本质了,它的本质就是对数组进行分组,然后两两一组进行排序,最后进行合并,如图:
在这里插入图片描述
所以我们就可以写代码来模拟这个过程,首先将数组的一个元素划分为一组,然后两组两组进行合并,只不过将数组两组两组合并完之后,我们要将数组重新进行分组,变成两个元素划分为一组再进行合并,反复进行上面的操作,直到数组不能再划分

所以我们首先定义一个变量gap来表示一个组有多少个元素,默认情况下为1,然后我们就开始两组两组进行合并,合并的逻辑和递归版的归并排序合并逻辑相似,这里不再赘述

当我们将以gap个元素为一组的情况,两组两组合并完之后,就让gap * 2来调整每组元素的个数,用新的gap来划分数组,然后又两组两组对数组进行合并,直到gap大于或者等于n

上面也就是整个非递归版归并排序的大致逻辑了,还有一些重要的细节我们稍后再说,我们先将上面说的逻辑大致实现一下,如下:

```c
//非递归版归并排序
void MergeSortNonR(int* arr, int n)
{
    int* tmp = (int*)malloc(n * sizeof(n));
    if (tmp == NULL)
    {
        perror("malloc");
        return;
    }
    //默认1个元素为一组
    int gap = 1;
    while (gap < n)
    {
        //接下两两组两组对数组进行合并,一次循环就合并两组
        //所以要注意i每次要跳过两组,也就是i += 2 * gap
        for (int i = 0; i < n; i += 2 * gap)
        {
            //排序两组的逻辑,和递归版归并排序合并的逻辑相同
            //注意下面begin1和end1的取值
            int begin1 = i, end1 = i + gap - 1;
            int begin2 = i + gap, end2 = i + 2 * gap - 1;
            int j = begin1;
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (arr[begin1] < arr[begin2])
                    tmp[j++] = arr[begin1++];
                else
                    tmp[j++] = arr[begin2++];
            }
            while (begin1 <= end1)
            {
                tmp[j++] = arr[begin1++];
            }
            while (begin2 <= end2)
            {
                tmp[j++] = arr[begin2++];
            }
            for (int j = i; j <= end2; j++)
            {
                arr[j] = tmp[j];
            }
        }
        //当我们将以gap个元素为一组的情况,两组两组合并完之后
        //就让gap * 2来调整每组元素的个数,用新的gap来划分数组
        gap *= 2;
    }
}

```

上面就是非递归归并排序的大致逻辑,但是如果我们直接使用上面的代码进行排序时就会发现它有问题,因为上面的代码对数组进行分组时,gap都是2的倍数,如1, 2, 4等等,如果我们数组元素的个数是奇数就会越界

所以接下来的细节问题就是如何处理越界问题,首先我们来分析begin1、end1,、begin2以及end2这几个变量哪些可能越界,结论就是begin1不会越界,其它3个变量都可能越界,因为begin1就是i,只要进入循环i就不会越界

如果i越界,就不会进入循环,begin1也就不会存在,只要进入了循环begin1,就会将i赋值给begin1,所以begin1就不会越界,其它三个变量则都可能越界,我们接下来一一进行分析,首先我们来画图将这些情况画出来,如下:
在这里插入图片描述
接着我们一一来处理一下这些边界情况,当end1越界时,说明begin2这个组全部越界了,那么这两组就不会进行合并,我们直接break掉就可以

当begin2越界时,也说明了后面这个组全部越界了,不会进行合并,直接break掉就可以了,当end2越界时,很明显前面begin2到数组最后还有一部分可以合并的区间,所以这时我们不能直接break,可以直接将end2改成n - 1,这样就能正常完成合并

然后我们稍加观察一下,其实end1和begin2越界这两种情况可以写到一起,因为它们都是因为后面这个组全部越界了,导致不需要再合并直接break,所以可以直接写到一起,接下来我们就将刚刚讲到的细节补到上面的代码中,写出一个完整的非递归归并排序,如下:

```c
//非递归版归并排序
void MergeSortNonR(int* arr, int n)
{
    int* tmp = (int*)malloc(n * sizeof(n));
    if (tmp == NULL)
    {
        perror("malloc");
        return;
    }
    //默认1个元素为一组
    int gap = 1;
    while (gap < n)
    {
        //接下两两组两组对数组进行合并,一次循环就合并两组
        //所以要注意i每次要跳过两组,也就是i += 2 * gap
        for (int i = 0; i < n; i += 2 * gap)
        {
            //排序两组的逻辑,和递归版归并排序合并的逻辑相同
            //注意下面begin1和end1的取值
            int begin1 = i, end1 = i + gap - 1;
            int begin2 = i + gap, end2 = i + 2 * gap - 1;
            //如果end1或者begin2越界,说明后面这个组整体越界
            //不需要进行合并,直接break掉
            if (end1 >= n || begin2 >= n)
            {
                break;
            }
            //如果只是end2越界的话,就将end2改成n - 1
            else if (end2 >= n)
            {
                end2 = n - 1;
            }
            int j = begin1;
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (arr[begin1] < arr[begin2])
                    tmp[j++] = arr[begin1++];
                else
                    tmp[j++] = arr[begin2++];
            }
            while (begin1 <= end1)
            {
                tmp[j++] = arr[begin1++];
            }
            while (begin2 <= end2)
            {
                tmp[j++] = arr[begin2++];
            }
            for (int j = i; j <= end2; j++)
            {
                arr[j] = tmp[j];
            }
        }
        //当我们将以gap个元素为一组的情况,两组两组合并完之后
        //就让gap * 2来调整每组元素的个数,用新的gap来划分数组
        gap *= 2;
    }
}

```

接下来我们来测试一下非递归的归并排序:
在这里插入图片描述
可以看到代码帮我们完成了排序工作

那么今天非递归排序就介绍到这里,如果有什么问题欢迎私信我,下一篇我们总结一下排序算法,接着就可以进入C++篇章了,敬请期待吧!
bye~

文章整理自互联网,只做测试使用。发布者:Lomu,转转请注明出处:https://www.it1024doc.com/5685.html

(0)
LomuLomu
上一篇 2025 年 1 月 11 日 上午12:16
下一篇 2025 年 1 月 11 日 上午1:17

相关推荐

  • JavaScript 拖拽与观察者模式的实现及应用

    前言 本文将通过几个具体的代码片段,深入探讨 JavaScript 中的拖拽功能和观察者模式(发布-订阅模式)的实现及其应用场景。 这些代码片段不仅展示了如何实现这些功能,还解释了其背后的原理和实际用途。通过阅读本文,读者可以更好地理解 JavaScript 的高级特性,并将其应用到实际项目中。 1. 拖拽功能的实现 代码片段 “`html Documen…

    2025 年 1 月 19 日
    11400
  • 《重构:改善既有代码的设计(第2版)》PDF、EPUB免费下载

    电子版仅供预览,下载后24小时内务必删除,支持正版,喜欢的请购买正版书籍 点击原文去下载 书籍信息 作者: [美] Martin Fowler出版社: 人民邮电出版社出品方: 异步图书副标题: 改善既有代码的设计原作名: Refactoring: Improving the Design of Existing Code,Second Edition译者: …

    2025 年 1 月 10 日
    11400
  • 寻找缺失的整数

    11.寻找缺失的整数 题目 在一个无序数组里有99个不重复的正整数,范围是1100,唯独缺少一个1100的整数。然后找出这个缺失的整数。 思路 1.对无序数组,进行升序排序,先判断首位是否为2或99,如果是则得到缺失值,否则,不连续的两个元素中间即为,缺失值。时间复杂度,为排序算法的时间复杂度,空间复杂度为O(1)。代码略 2.求出无序数组的和,用1+2+.…

    未分类 2025 年 1 月 15 日
    13200
  • java: JDK isn‘t specified for module ‘product-service‘问题解决

    目录 问题 解决方法 1.打开File->Project Structure… 2.将Project SDK修改为17 Oracle OpenJDK 17.0.12,并Apply,OK 问题 添加module后报错:java: JDK isn’t specified for module ‘product-service’ 查看pom.xml文件也添加了…

    2025 年 1 月 11 日
    13600
  • 用 Cursor 写出第一个程序

    大家好,我是汤师爷 最近几个月,Cursor迅速走红,成为一款强大的编程助手。Cursor不仅使用简单,而且通过集成各种大模型技术,编程能力一流。 Cursor是什么? Cursor是一个类似VSCode的编辑器,集成了GPT-4、Claude 3.5等LLM模型。它本质上是在VSCode的基础上添加了AI辅助编程功能。 从界面布局到操作方式都与VSCode…

    2025 年 1 月 1 日
    21900

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信