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

*在这里插入图片描述

文章目录

一、非递归版快排

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

相关推荐

  • Microi 吾码与 JavaScript:前端低代码平台的强大组合

    目录 一、引言 二、Microi 吾码概述 三、JavaScript 在 Microi 吾码前端开发中的应用 (一)前端 V8 引擎与 JavaScript (二)接口引擎与 JavaScript 四、JavaScript 在 Microi 吾码后端开发中的协同 (一)与 C# 后端框架的交互 (二)利用 gRPC 实现跨语言通信 五、Microi 吾码中 …

    2025 年 1 月 12 日
    57800
  • Java技术全景——分布式文件系统在科研数据管理中的高效实践(187)

    🌟亲爱的技术爱好者们,诚挚邀请您踏入【云端技术驿站】的知识殿堂!在这个信息爆炸的数字时代,我们致力于打造一个兼具深度与温度的技术交流空间。无论您是来探索前沿技术,还是分享实战心得,这里都将成为您理想的栖息地。期待与您共同编织技术的未来篇章!🌟全平台账号(微信公众号/CSDN/抖音/华为生态/支付宝生活号/微博):云端技术驿站一、立即加入【开发者成长联盟】通道…

    2025 年 5 月 12 日
    35000
  • 数据结构(Java版)第二期:包装类和泛型

    目录 一、包装类 1.1. 基本类型和对应的包装类 1.2. 装箱和拆箱 1.3. 自动装箱和自动拆箱 二、泛型的概念 三、引出泛型 3.1. 语法规则 3.2. 泛型的优点 四、类型擦除 4.1. 擦除的机制 五、泛型的上界 5.1. 泛型的上界的定义 5.2. 语法规则 六、泛型方法 6.1. 定义语法 6.2. 交换方法的实例 七、通配符 包装类和泛型…

    2025 年 1 月 1 日
    53200
  • Java笔记(一)内部类

    这是关于我对内部类理解的笔记,可能写的不怎么好,所以虚心接受大佬的指导 内部类(Nested Class) 定义在一个类中的另一个类被叫做内部类(Inner Class), 内部类有四种类型成员内部类、静态内部类、局部内部类、匿名内部类 成员内部类、局部内部类、匿名内部类中 成员内部类 “`java // inner class public class …

    未分类 2025 年 1 月 7 日
    47300
  • MySQL 面试题

    MySQL 中有哪几种锁? 全局锁、行级锁、自增锁、记录锁、外键锁、间隙锁、表级锁、元数据锁、意向锁、临键锁 MySQL 中有哪些不同的表格? 基础表、临时表、系统表、信息表、性能模式表、分区表、外键表、触发器使用的表、存储过程和函数使用的表 简述在 MySQL 数据库中 MyISAM 和 InnoDB 的区别? 事务支持 InnoDB:支持事务处理,具有提…

    未分类 2025 年 1 月 14 日
    43600

发表回复

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

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

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

关注微信