【数据结构】——排序

排序

基本概念

排序(Sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。

输入:n个记录R1,R2,…Rn,对应的关键字为K1,K2,…Kn。

输出:输入序列的一个重排R1′,R2′,…Rn′,使得有K1′≤K2′≤…≤Kn′(也可递减)

  • 排序算法的评价指标——时间复杂度,空间复杂度,算法的稳定性(选择题常考)(即关键字相同的元素在排序之前是什么样的位置关系(即谁在前,谁在后),在排序之后也是这样的关系,那么就是稳定的,否则就不是稳定的)

    image-20230801145658422

    注:但是稳定的不一定就比不稳定的排序算法要好,还是要结合实际情况来进行考虑!

  • 排序算法的分类

    image-20230801150203053

注:排序算法的内部排序需要关注时间复杂度,空间复杂度,外部排序还需要而外关注如何使读/写磁盘次数更少!

  • 知识回顾

    image-20230801150614350

插入排序

定义

算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。

image-20230802161418472

image-20230802161227104

首先从下标为1的开始插入,因为下标为0的位置只有一个元素,所以是默认排序好了的(图中绿色部分是已经排好序了的),然后我们依次检查后面的元素,发现比前一个小的话,那么就将前一个元素后移,自己插入到前面去,若是比起前一个大的话,那么就直接放到后面就可以了!

代码实现
//直接插入排序
void InsertSort(int A[],int n)
{
    int i,j,temp;//这里for循环不用处理比关键字大的情况,比它们大的不用处理,直接就放在后面了!
    for(i=1;i<n;i++)//将各元素插入已排好序的序列中
        if(A[i]<A[i-1])//若A[i]关键字小于前驱
        {
            temp=A[i];//用temp暂存A[i]
            for(j=i-1;j>=0&&A[j]>temp;--j)//检查所有前面已排好序的元素
                A[j+1]=A[j];//所有大于temp的元素都往后挪位
            A[j+1]=temp;//复制到插入位置
        }
}//经过最终处理,最小的在第一个,最大的在最后一个!
  • 算法实现(带哨兵)

    //直接插入排序(带哨兵)
    void InsertSort(int A[],int n)
    {
        int i,j;
        for(i=2;i<=n;i++)//依次将A[2]-A[n]插入到前面已排序序列
            if(A[i]<A[i-1])//若A[i]关键字小于其前驱,将A[i]插入有序表
            {
                A[0]=A[i];//复制为哨兵,A[0]不存放元素
                for(j=i-1;A[0]<A[j];--j)//从后往前查找待插入位置
                    A[j+1]=A[j];//向后挪位
                A[j+1]=A[0];//复制到插入位置
            }
    }
    

    注:哨兵也就是下标为0的位置不存元素,存放的是哨兵,这里的哨兵和上面的那个temp变量的作用其实是一样的!(但是还是有优点——不用每轮循环都判断j>=0)

算法性能分析

空间复杂度:O(1),因为i,j,temp都是常数级的,和问题规模(n)没有关系!

时间复杂度:主要来自对比关键字,移动元素若有n个元素,则需要(n-1)趟处理

  • 最好情况——(即本身就是有序的序列)共(n-1)趟处理,每一趟只需要对比关键字1次,不用移动元素,那么此时的时间复杂度是O(n)
  • 最坏情况——(即原本是逆序的),那么第n趟,就需要对比关键字(n+1)次,需要移动元素(n+2)次,那么此时的时间复杂度是O(n²)
  • 平均时间复杂度——O(n²)

算法的稳定性:稳定

  • 优化——折半插入排序

​ 思路:先用折半查找找到应该插入的位置,再移动元素

image-20230802171626344

注:当low>high时折半查找停止,应将[low,i-1](若是low大于i-1的话,那就不用移动了)内的元素全部右移,并将A[0]复制到low所指位置

注:折半查找只能在有序的序列中进行,图中绿色的部分是已经排好序了的!

image-20230802172031073

注:当A[mid]==A[0]时,为了保证算法的“稳定性”,应继续在mid所指位置右边寻找插入位置

  • 折半插入排序(代码)

    //折半插入排序
    void InsertSort(int A[],int n)
    {
        int i,j,low,high;
        for(i=2;i<=n;i++)//依次将A[2]-A[n]插入前面的已排序序列
        {
            A[0]=A[i];//将A[i]暂存到A[0]
            low=1;
            high=i-1;//设置折半查找的范围
            while(low<=high)//折半查找(默认递增有序)
            {
                mid=(low+high)/2;//取中间点,这里用left+(right-left)/2更好,不会溢出int的范围!
                if(A[mid]>A[0])
                    high=mid-1;//查找左半子表
                else
                    low=mid+1;//查找右半子表,同时处理了A[mid]==A[0]的情况,保证了算法的稳定性!
            }
            for(j=i-1;j>=high+1;--j)
                A[j+1]=A[j];//统一后移元素,空出插入位置
            A[high+1]=A[0];//插入操作
        }
    }
    

    注:比起“直接插入排序”,比较关键字的次数减少了,但是移动元素的次数没有变,整体来看时间复杂度依然是O(n²)

  • 插入排序——链表

注:Leetcode第147题

给定单个链表的头 head ,使用 插入排序 对链表进行排序,并返回 排序后链表的头 。

插入排序 算法的步骤:

  1. 插入排序是迭代的,每次只移动一个元素,直到所有元素可以形成一个有序的输出列表。
  2. 每次迭代中,插入排序只从输入数据中移除一个待排序的元素,找到它在序列中适当的位置,并将其插入。
  3. 重复直到所有输入数据插入完为止。

下面是插入排序算法的一个图形示例。部分排序的列表(黑色)最初只包含列表中的第一个元素。每次迭代时,从输入数据中删除一个元素(红色),并就地插入已排序的列表中。

对链表进行插入排序。

  • 示例一

img

  • 示例二

    img

代码:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* insertionSortList(ListNode* head) {
        if(!head)
        {
            return head;
        }
        ListNode* dummyHead=new ListNode(0);//定义一个哑结点
        dummyHead->next=head;
        ListNode* lastSorted=head;//维护lastSorted为已排序链表的最后一个结点,并使其初始化!
        ListNode* curr=head->next;
        while(curr != NULL)
        {
            if(lastSorted->val <= curr->val)//进入这个if语句说明排序正确,所以在找下一对元素进行排序即可!
            {     // 说明curr应该位于lastSorted之后
                lastSorted = lastSorted->next;   // 将lastSorted后移一位,curr变成新的lastSorted
            }
            else//进入了这个语句之后,说明前面一定有一个大的在前面,所以后面的while循环里面一定能够找到那个curr应该插入的位置!
            {                              // 否则,从链表头结点开始向后遍历链表中的节点
                ListNode* prev = dummyHead;      // 从链表头开始遍历 prev是插入节点curr位置的前一个节点,为什么pre要设置成curr的前一个结点呢,是为了方便插入,后面的while循环是不会使得prev->next->val==curr->val的这种情况发生的,因为前面一定存在一个比curr->val的值更大的数!
                while(prev->next->val <= curr->val)
                { // 循环退出的条件是找到curr应该插入的位置
                    prev = prev->next;
                }
                // 以下三行是为了完成对curr的插入(配合题解动图可以直观看出)
                lastSorted->next = curr->next;//这三行式子即可以实现对元素的插入,自己画图进行理解!
                curr->next = prev->next;
                prev->next = curr;
            }
            curr = lastSorted->next; // 此时 curr 为下一个待插入的元素
        }
        // 返回排好序的链表
        return dummyHead->next;
    }
};

注:移动元素的次数变少了,但是关键字对比的次数依然是O(n²)这个数量级,整体来看时间复杂度依然是O(n²)

知识回顾

image-20230802173954212

希尔排序

定义

希尔排序:先追求表中部分元素有序,再逐渐逼近全局有序

概念:先将待排序表分割成若干形如L[i,i+d,i+2d,…i+kd]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量d,重复上述过程,直到d=1为止。

image-20230803161448157

注:下标为1的元素,增量又是4,那么就对上了下标为5的元素,依次类推,下标为2对上下标为6…下标为4对上下标为8,再依次对各个子表进行插入排序即可!

  • 第一趟处理完成

image-20230803161723292

  • 第二趟处理

    image-20230830201056299

    image-20230803161845829

  • 最后一趟处理(d==1)

    image-20230803162000151

    image-20230803162043321

  • 总步骤

    image-20230803162239188

    注:增量一般都是首个增量是元素个数除以2,然后接下来的就是每次在原来的基础上除以2就可以了!

    image-20230803162619054

    注:在考试中可能会以各种增量来考查你,可能会出现的考查方式就是给你一个序列,然后给你一个增量,你需要给出按照这个增量的这一趟排序之后是个什么样的状态(也就是原来的序列,经过这一趟处理之后,变成了什么样!)

代码实现
  • 代码实现方式1
//希尔排序
void ShellSort(int A[],int n)
{
    int d,i,j;
    //A[0]知识暂存单元,不是哨兵,当j>=0时,插入位置已到
    for(d=n/2;d>=1;d=d/2)//步长变化
        for(i=d+1;i<=n;++i)
            if(A[i]<A[i-d])//需将A[i]插入有序增量子表
            {
                A[0]=A[i];//暂存在A[0]
                for(j=i-d;j>0&&A[0]<A[j];j-=d)
                    A[j+d]=A[j];//记录后移,查找插入的位置
                A[j+d]=A[0];//插入
            }//if
}

注:自己加上前面的例子再来看这个代码,你就会清晰易懂!

注:有人说把i++换成i+=d也可以,这有待商榷!

  • 代码实现方式2
//暂存在A[0]void ShellSort(int A[],int n)
{
    int d,i,j,k;
    //A[0]知识暂存单元,不是哨兵,当j>=0时,插入位置已到
    for(d=n/2;d>=1;d=d/2)//步长变化
        for(i=1;i<=d;i++)
            for(k=i;k<=n;k+=d)
                if(A[k]>A[k+d])//需将A[k+d]插入有序增量子表
                {
                    A[0]=A[k+d];//暂存在A[0]
                    for(j=k;j>0&&A[0]<A[j];j-=d)
                        A[j+d]=A[j];//记录后移,查找插入的位置
                    A[j+d]=A[0];
                }
}
算法性能分析

image-20230803171746469

时间复杂度:和增量序列d1,d2,d3…的选择有关,目前无法用数学手段证明确切的时间复杂度,当d1=1时,就会退化为插入排序!

算法的稳定性:不稳定

适用性:仅适用于顺序表(具有随机访问的特性),不适用于链表

知识回顾

image-20230803172228325

冒泡排序

知识总览

image-20230804132628648

注:冒泡排序和快速排序都是属于一个大类,也就是交换排序

基于“交换”的排序:根据序列中两个元素关键字的比较结果来交换这两个记录在序列中的位置

定义

冒泡排序:从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换他们,直到序列比较完。称这样过程为“一趟”冒泡排序。

  • 第一躺处理

    image-20230804134030920

    image-20230804134051382

    注:第一趟排序使关键字值最小的一个元素“冒”到最前面

    注:后续的处理也是类似的情况,比如说第二次处理就是将第二小的元素往前移,最终处理完所有的元素之后,就完成了一个排序(递增序列)

    注:若某一趟排序没有发生交换,就说明此时已经整体有序,那么此时就可以结束了!

代码实现
//冒泡排序
void BubbleSort(int A[],int n)
{
    for(int i=0;i<n-1;i++)//i所指位置之前的元素都已有序
    {
        bool flag=false;//表示本趟冒泡是否发生排序的标志
        for(int j=n-1;j>i;j--)//一趟冒泡过程
            if(A[j-1]>A[j])//若为逆序,也说明了算法是稳定的!(只有大于才交换,相等不会交换)
            {
                swap(A[j-1],A[j]);//交换
                flag=true;
            }
        if(flag==false)//这已经优化了最初的冒泡排序
            return;//本趟遍历后没有发生交换,说明表已经有序
    }
}

//交换
void swap(int& a,int&b)
{
    int temp=a;
    a=b;
    b=temp;
}

注:可以通过三次异或进行值交换,无需中间变量而且效率更高!

算法性能分析

空间复杂度:O(1)

时间复杂度:最好情况:有序,比较次数n-1,交换次数0,最好时间复杂度:O(n)

​ 最坏情况:逆序,比较次数等于交换次数=n(n-1)/2(即n-1,n-2,…加到1),最坏时间复杂度是O(n²)

​ 平均时间复杂度:O(n²)

image-20230804141248460

注:交换次数和移动元素次数不是一样的,每次交换都需要移动元素3次

  • 冒泡排序可以适用于链表

    image-20230804141816507

    注:这里可能是指的是直接交换数据域,但是还有一种交换方式,就是交换指针!

知识回顾

image-20230804142103703

快速排序

定义

算法思想:算法思想:在待排序表L[1…n]中任取一个元素pivot作为枢轴(或基准, 通常取首元素) , 通过一趟排序将待排序表划分为独立的两部分L[1…k−1]和L[k+1…n],使得L[1…k−1]中的所有元素小于pivot,L[k+1…n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上, 这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。

image-20230806161318830

注:更小的元素都交换到左边,更大的元素都交换到右边,首先让low和high指针指向序列的首末元素!high遇到比49小的,就移到左边,low遇到比49大的,就移动到右边!

注:最后low和high会相等,那么相等的位置就是基准的最终所在地!

注:主要思想就是用第一个元素把待排序序列“划分”为两个部分。左边更小,右边更大。该元素的最终位置已确定

image-20230806161745959

注:经过这么一趟之后,那么49这个元素我们就不用管了,我们只需要按照同样的方法管理49左右两边的子表就可以了

代码实现
//用第一个元素将待排序序列划分成左右两个部分
int Partition(intA[],int low,int high)
{
    int pivot=A[low];//第一个元素作为枢轴
    while(low<high)//用low和high搜索枢轴的最终位置
    {
        while(low<high&&A[high]>=pivot)
            --high;//和high--没什么区别,就是前者效率高那么一点儿
        A[low]=A[high];//比枢轴小的元素移动到左端
        while(low<high&&A[low]<=pivot)
            ++low;
        A[high]=A[low];//比枢轴大的元素移动到右端
    }
    A[low]=pivot;//枢轴元素存放到最终位置A[high]也是一样的,最后low和high位置重合!
    return low;//返回存放枢轴的最终位置
}

//快速排序
void QuickSort(int A[],int low,int high)
{
    if(low<high)//递归跳出的条件
    {
        int pivotpos=Partition(A,low,high);//划分
        QuickSort(A,low,pivotpos-1);//划分左子表
        QuickSort(A,pivotpos+1,high);//划分右子表
    }
}

注:自己可以将上述的例子带入这个代码进行分析,就清楚易懂了!(递归分析使用递归工作栈更好理解)

注:考研经常考这个代码!(要自己手写出来哦)

算法性能分析

image-20230806165501498

注:每一层的QuickSort只需要处理剩余的待排序元素,时间复杂度不超过O(n)

注:总的时间复杂度=O(n*递归层数)

image-20230806165731925

注:空间复杂度是O(递归层数)

image-20230806165940704

注:把n个元素组织成二叉树,二叉树的层数就是递归调用的层数!那么我们就可以知道最好的时间复杂度和最坏的时间复杂度了!

image-20230806170808987

image-20230806170934655

注:若每一次选中的==“枢轴”将待排序序列划分均匀==的两个部分,则递归深度最小,算法效率最高!

image-20230806171352414

注:若每一次选中的==“枢轴”将待排序序列划分很不均匀==的两个部分,则会导致递归深度增加,算法效率变低!

注:若初始序列有序或逆序,则快速排序的性能最差(因为每次选择的都是最靠边的元素)

  • 快速排序算法优化思路

    尽量选择可以把数据中分的枢轴元素

    1. 选头、中、尾三个位置的元素,取中间值作为枢轴元素
    2. 随机选择一个元素作为枢轴元素

image-20230806172003575

注:快速排序算法是所有内部排序算法中平均性能最优的排序算法

稳定性:不稳定

知识回顾

image-20230806172431188

注:408中说,一次划分可以确定一个元素的最终位置,而一趟排序也许可以确定多个元素的最终位置,因此一次划分≠一趟排序!(考408的注意)

简单选择排序

简单选择排序和堆排序属于一个大类,就是选择排序

定义

选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列

  • 步骤

    image-20230805142114825

    注:这一步就是将待排序序列中最小的13放到最前面来,若是只剩下最后一个那么就不用处理了,肯定是最大的!

    注:n个元素的简单选择排需要进行(n-1)趟处理

算法实现
void SelectSort(int A[],int n)
{
   for(int i=0;i<n-1;i++)//一共进行n-1趟
   {
       int min=i;//记录最小元素位置
       for(int j=i+1;j<n;j++)//在A[i...n-1]中选择最小的元素
           if(A[j]<A[min])
               min=j;//更新最小元素位置
       if(min!=i)
           swap(A[i],A[min]);//封装的swap()函数共移动元素3次
   }
}
      
//交换
void swap(int& a,int&b)
{
    int temp=a;
    a=b;
    b=temp;
}

image-20230805142347627

算法性能分析

空间复杂度:O(1)

时间复杂度:O(n²)(不管是有序的,无序的,逆序的都是一样的)

稳定性:不稳定

适用性:既可以适用于顺序表,也可适用于链表(链表的代码找一下)

知识回顾

image-20230805143030169

堆排序

注:考试很喜欢考哦(因为涵盖了以前学习的内容)

定义
  • 堆的定义

​ 若n个关键字序列L[1…n] 满足下面某一条性质, 则称为堆(Heap):
​ ①若满足: L(i)≥L(2i)且L(i)≥L(2i+1) (1≤i≤n/2) ——大根堆(大顶堆)
​ ②若满足: L(i)≤L(2i)且L(i)≤L(2i+1) (1≤i≤n/2) ——小根堆(小顶堆)

  • 知识回顾——二叉树的顺序存储(就是从上至下,从左至右把节点一个一个地存放到数组当中)

    image-20230807160735645

注:从物理的视角来看,堆看起来就像是一个连续存放的数组,但是从逻辑的角度来看,我们应该把堆理解为一颗顺序存储的完全二叉树,编号为1的数字就是根节点,然后根据二叉树的顺序存储的性质,来判断每个编号的数字在二叉树中的具体位置!

image-20230807165752590

注:也可以这么理解,就是大根堆——完全二叉树中,根≥左、右;小根堆——完全二叉树中,根≤左、右

  • 堆排序

    首先我们知道,堆顶元素关键字最大(大根堆),所以我们把一个待排序序列整理成堆这种形式,就可以知道最大的是哪一个,那么我们接下来就要探讨的是怎么建立大根堆?

    image-20230807170338521

    注:在这里声明一点,就是我们拿到了一个待排序序列之后,如果是想使用堆排序的话,那么我们就需要把这个序列想象成一个顺序存储的完全二叉树,所以我们需要用到二叉树的有关知识来解题,所以上图中的二叉树并不奇怪,在实际做题当中,我们并不需要把这个二叉树画出来,只需要在脑海中想象一下就可以了!

    注:那么我们先进行第一步,把所有非终端节点都检查一遍,看看是否满足大根堆的要求,如果不满足,则进行调整!

    注:在这里我们就需要回顾一下顺序存储的二叉树的有关知识,也就是所有非终端节点编号i≤⌊n/2⌋(仔细看,向下取整),那么在这里,由于n是8,所以i≤4,那么我们需要检查编号1到4的所有节点!

    image-20230807171325769

    注:这种方式是先从编号最大的依次往前一个一个检查,也就是先从编号为4的最后再找到编号为1的,这样做有好处,若是从1开始再慢慢的找到4,那可能会出错!(有待商榷!)

    注:检查当前节点是否满足根≥左、右,若不满足,将当前节点与更大的一个孩子互换(找左孩子和右孩子用顺序存储的二叉树的性质)

    注:后续也是这种做法,直到把每个非终端节点都扫描完就可以了!

    image-20230807172239507

    注:若元素互换破坏了下一级的堆,则采用相同的方法继续往下调整(小元素不断“下坠”

    image-20230807172503945

    注:欧克,这就是从一个简单的序列,我们经过手动演练之后,把它变成了大根堆!

代码实现
//建立大根堆
void BuildMaxHeap(int A[],int len)
{
    for(int i=len/2;i>0;i--)//从后往前调整所有非终端节点
        HeadAdjust(A,i,len);
}

//将以k为根的子树调整为大根堆
void HeadAdjust(int A[],int k,int len)
{
    A[0]=A[k];//A[0]暂存子树的根节点
    for(int i=2*k;i<=len;i*=2)//沿key较大的子节点向下筛选(初始时i=2k,就是左孩子)
    {
        if(i<len&&A[i]<A[i+1])//i<len是为了知道i这个结点有没有右兄弟!
            i++;//取key较大的子节点的下标(若左右孩子一样大,优先和左孩子交换)
        if(A[0]>=A[i])
            break;//筛选结束
        else
        {
            A[k]=A[i];//将A[i]调整到双亲结点上
            k=i;//修改key值,以便继续向下筛选
        }
    }
    A[k]=A[0];//被筛选的结点的值放入最终位置
}

//堆排序的完整逻辑(这一段要看了下面的图片加文字讲解才可以看懂)
void HeapSort(int A[],int len)
{
    BuildMaxHeap(A,len);//初始建堆
    for(int i=len;i>0;i--)//n-1趟的交换和建堆过程
    {
        swap(A[i],A[1]);//堆顶元素和堆底元素互换
        HeadAdjust(A,1,i-1);//把剩余的待排序序列整理成堆
    }
}//swap函数的实现有多种方式(位运算,第三变量等)

注:还是老样子,带入刚才的初始序列比较好理解!(len是指元素个数,A[0]中是空的,是用来暂存元素的,防止被覆盖)

image-20230807174459106

注:堆排序——每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换),并将待排序元素序列再次调整为大根堆(小元素不断“下坠”)那么到了这里我们就应该知道该怎么排了,每一次大根堆都可以找出一个待排序序列的最大元素,再将其放置在已排好序的序列里面,就可以确保是有序的,直到处理到只剩一个元素,就全部都排好了!(排好一个之后,len要减一哦!)

  • 最终结果

    image-20230807180230295

算法性能分析

那么,看了代码之后,发现都要调用HeapAdjust函数,那么分析复杂度基本就看它就好了!

image-20230807180433446

image-20230807182850381

注:一个结点,每“下坠”一层,最多只需要对比关键字2次,若树高为h,某节点在第i层,则将这个结点向下调整最多只需要“下坠”h-i层,关键字对比次数不超过2(h-i)

image-20230807183433384

注:我们只要记住,建堆时间复杂度是O(n)

image-20230807183758382

image-20230807183839552

注:堆排序的时间复杂度O(log₂n),空间复杂度O(1)

稳定性:不稳定

堆的插入

image-20230808141106340

注:对于根堆,新元素放到表尾,与父节点对比,若新元素比父节点更小,则将二者互换。新元素就这样一路==“上升”,直到无法继续上升为止,大根堆也差不多,就是不断下坠==呗!

  • 最终结果

    image-20230808141506701

    image-20230808141606343

堆的删除

image-20230808141704241

注:在这里我们是删除了13,那么就使用堆底的元素来进行替代(也就是46,站到了13的位置上),然后让该元素不断==“下坠”==,直到无法下坠为止(这里是小根堆,大根堆自己推理)

  • 最终结果

    image-20230808142354704

    注:对比关键字的次数也是常考题目!(若下方有两个孩子,则下坠一层,需要对比关键字2次,是这样对比的,首先左右孩子相互之间对比,那么就有一次了,然后左右孩子中大的或小的一方再去和父节点对比,就又是一次,所以就有两次,只有一个孩子就是1次)

知识回顾

image-20230807184344763

image-20230808143447336

  • 小练习

    image-20230807184449511

归并排序

定义

归并:把两个或多个已经有序的序列合并成一个(有几个序列合并就是几路归并)

image-20230809153650583

注:首先我们拿到了两个有序的数组,那么接下来我们就要将其合并成一个有序的大数组,那么首先我们需要准备一个长度为这两个数组的长度之和的数组,然后定义三个指针变量i,j,k,然后看i,j所指元素,选择较小的那个放入k所指的位置,这样就可以排好序了!(若是i和j的指向有相等的情况,那么我们可以指定i或者j所指向的元素优先)

image-20230809154033796

注:若是有一个子表已经全部放完了,那么剩余的那个子表就直接把剩余的全部元素放进来就好了!

image-20230809154224844

image-20230809170356358

注:m路归并,每选出一个元素需要对比关键字(m-1)次

  • 归并排序(手算模拟)

    image-20230809171242553

代码实现
int *B=(int*)malloc(n*sizeof(int));//辅助数组B

//A[low...mid]和A[mid+1...high]各自有序,将两个部分归并
void Merge(int A[],int low,int mid,int high)
{
    int i,j,k;
    for(k=low;k<=high;k++)
        B[k]=A[k];//将A中所有元素复制到B中
    for(i=low,j=mid+1,k=i;i<=mid&&j<=high;k++)
    {
        if(B[i]<=B[j])//相等的时候使用的是靠前的元素(稳定性)
            A[k]=B[i++];//将较小值复制到A中
        else
            A[k]=B[j++];
    }//for
    while(j<=mid)//未归并完的就放到尾部
        A[k++]=B[i++];
    while(j<=high)
        A[k++]=B[j++];
}

void MergeSort(int A[],int low,int high)
{
    if(low<high)
    {
        int mid=(low+high)/2;//从中间划分left+(right-left)/2也是可以的,并且不会溢出
        MergeSort(A,low,mid);//对左半部分归并排序(递归)
        MergeSort(A,mid+1,high);//对右半部分归并排序(递归)
        Merge(A,low,mid,high);//归并
    }//if
}

image-20230809180133540

算法性能分析

image-20230809182905985

注:可以把一个二路归并看做是一棵二叉树,那么树的高度减一就是归并处理的趟数,再根据二叉树的有关性质就可以推理出归并排序的时间复杂度!

空间复杂度本来是还要加上一个递归工作栈的,但是递归工作栈归并趟数不会超过log₂n,所以最后相加取大的那一个还是O(n)

知识回顾

image-20230809183145134

基数排序

注:代码基本不考!

定义

image-20230810144423413

注:那么第一趟的话呢,就是以个位进行分配,那么首先我们建立10个辅助队列,Q9到Q0存放的就是每个关键字的个位!

image-20230810144658609

image-20230810144907904

注:在这里我们由于是要求递减,所以我们从个位较大的一方开始收集,也就是Q9,然后依次往后收集即可!(多个元素从队头再到队尾)

image-20230810145313168

注:这里是完成了一次分配和一次收集的动作,然后后面的话,就是重复这两个动作,一开始是个位,后面可以是十位,百位,千位等!

image-20230810145434178

注:进行十位分配的时候,若是两个数的十位数字一样的话,那么谁的个位大,谁就先入队!(因为第一趟可以确保个位大的在前面)

image-20230810145729177

image-20230810145829815

image-20230810145909092

image-20230810145944524

image-20230810150027538

image-20230810150131496

image-20230810151146156

基数排序得到递减递增)序列的过程如下:

  1. 初始化:设置r个空队列,Qr-1,Qr-2,…,Q0(Q0,Q1,…Qr-1)(r是每个关键字位可以取得r个值,那么原题中的每一位都有可能是0至9,即r就是10)
  2. 按照各个关键字位权重递增的次序(个、十、百),对d个关键字位分别做“分配”和“收集”(d指的就是关键字可以被拆成几个部分,原题中是三位数,那么d就是3)
  3. 分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插入Qx队尾
  4. 收集:把Qr-1,Qr-2,…Q0(Q0,Q1,…Qr-1),Q0各个队列中的节点依次出队并链接(使用链表)

注:基数排序不是基于“比较”的排序算法

算法性能分析

image-20230810152523520

注:每收集一个队列,只需要O(1)的时间复杂度,这里有r个队列,所以是O(r)

时间复杂度:O(d(n+r))

image-20230810152734456

注:比如说现在我们要收集Q5的元素,那么我们定义一个指针p,让其指向996,然后p->next=Q[5].front,再进行Q[5]后续的指针置空操作就可以了!

稳定性:稳定(自己分析一个例子就可以了)

应用

image-20230810153841896

注:在这里的话呢,d就是3,但是r一直在变,第一趟r是31,第二趟r是12,第三趟r是15,所以不能以传统的概念去理解基数排序,要结合实际应用来考虑!

基数排序擅长解决的问题:

  1. 数据元素的关键字可以很方便地拆分成d组,且d较小
  2. 每组关键字的取值范围不大,即r较小
  3. 数据元素个数n较大

但是还是要注重于实际应用场景,不要教条化!

知识回顾

image-20230810154728099

外部排序

知识总览

image-20230811171622966

  • 外存与内存之间的数据交换

    操作系统以“块”为单位对磁盘存储空间进行管理,如:每块大小1KB,各个磁盘块内存放着各种各样的数据!

    磁盘的读/写以“块”为单位,数据读入内存后(成为缓冲区里面的一部分)才可以被修改,修改完了还要写回磁盘!

    image-20230811172137576

原理

外部排序:数据元素太多,无法一次全部读入内存进行排序

image-20230811172435426

注:使用“归并排序”的方法,最少只需在内存中分配3块大小的缓冲区即可以对任意一个大文件进行排序!(这里缓冲区的大小和磁盘里的每一小块大小是一样的)

image-20230811172702986

注:“归并排序”要求各个子序列有序,每次读入两个块的内容,进行内部排序后写回磁盘!

image-20230811172849640

image-20230811173042510

注:这两个小块经过内部排序处理好之后,就成为了一个有序的“归并段”

  • 全部处理完之后

    image-20230811173328066

    • 后续操作

    image-20230811173514759

    注:在后续的操作中,首先就是选出两个归并段中最小的两个磁盘块,并且读入内存,然后使用归并排序,当输出缓冲区的大小已经和磁盘块大小一样时,就又读回磁盘。若是输入缓冲区空了的话,就立即使用相对应的归并段的另一块磁盘块来补上,直到这两个归并段合成为一个大归并段(这里归并段说的是含有两个磁盘块,大归并段含有4个磁盘块)

    • 第一趟归并结束

      image-20230811174253137

      注:那么后续的操作就不用多说了,也就是4合成2,2最后合成1就完事了!

      image-20230811174718543

      image-20230811174743433

算法性能分析

image-20230811185050715

注:想要优化的话,就必须得在归并趟数上下功夫!

性能优化

image-20230811185903237

注:那么四路归并的话,和二路归并的操作方法其实是一样的,注意哦,当缓冲区有些空了的时候,就需要读入此归并段的下一个磁盘块了哦!

image-20230811190520148

注:k越大,r越小,则归并趟数越少,读写磁盘次数越少

但是多路归并还是有很多影响的,比如说:

  1. k路归并时,需要开辟k个输入缓冲区,内存开销增加。
  2. 每挑选一个关键字需要对比关键字(k-1)次,内部归并所需时间增加

k也不可能越大越好,所以我们要在r身上下功夫

image-20230811190801346

image-20230811191004980

注:生成初始归并段的“内存工作区”越大,初始归并段越长!

image-20230811193837089

注:若能增加初始归并段的长度,则可减少初始归并段数量r

知识回顾

image-20230811194307513

  • 纠正一个理解性的问题

image-20230811194449782

k路平衡归并需要满足的条件:

  1. 最多只能有k个段归并为一个
  2. 每一趟归并中,若有m个归并段参与归并,则经过这一趟处理得到⌈m/k⌉个新的归并段

败者树

注:考研不考代码,最多就是手算!

  • 回顾一下上个小节的问题

    image-20230812143204443

    image-20230812150648256

    image-20230812150826078

    注:败者树——可视为一颗完全二叉树(多了一个头头,可以视作这个头头为根节点,也可以视作头头下面的一个结点是根节点)。k个叶节点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续比较,一直到根节点。

    那么现在,假设冠军走了,接下来由派大星来接替它的位置,那么是否还是要像之前那样打7场比赛呢?

    答案:不需要

    image-20230812151249738

    注:首先右边这4个人是不需要打了的,因为右边这4个人当中最强的已经选出来了,接下里就在左边打,由于阿乐是输给了之前的那个冠军,所以阿乐和派大星之间谁更强不知道,所以首先他们之间要打一场,然后后面一直往上打就可以了!

    image-20230812151530977

败者树的应用

image-20230812151612562

image-20230812151907713

注:每个叶子节点对应一个归并段,分支结点记录失败者来自于哪个归并段,根节点记录冠军来自于哪个归并段,分支结点不需要记录晋级或者失败的是那个数据元素,只需要记录失败者来自于哪个归并段就好了(这里是找到最小的元素,所以小的元素晋级,大的元素失败)

image-20230812152205425

注:第一次处理完之后,找到了最小的元素1,然后由于1之前所在的结点空了,所以就需要使用1所对应的那个归并段的下一个元素来顶替!

image-20230812152743037

注:那么好,我们来看看这个结论是怎么得出来的呢?首先我们知道一颗二叉树得第h层共有2的h-1次方个结点,那么这里又有k个归并序列,所以k肯定是小于等于2的h-1次方的,所以就可以得出h-1等于以2为底k的对数,又因为h-1就是分支结点的层数,而分支结点有多少层,我们就需要对比关键字多少次(由之前的结论可以得出),就知道了上图中的对比关键字次数的结论(注意:有了败者树之后,是最多对比关键字以2为底的k的对数(向上取整)的次数,有的时候可能比这个值还少一点)!

代码思路(不考)

image-20230812153611126

注:数组下标为0对应的是冠军节点,其余的1到7则都是失败节点!

另外,叶子节点是虚拟的,真实不存在的,所以它们不需要使用数组来存储有关信息,只需要在脑中有个大概就可以了!

知识回顾

image-20230812154124840

  • 致敬青春

image-20230812154254337

置换-选择排序

  • 上上节知识回顾

    image-20230812221837750

    注:我们今天所学的这个“置换-选择排序”可以进一步减少初始归并段数量(也就是可以构造更长的初始归并段),进而提升外部排序的效率!

    image-20230812222101824

  • 方法1

    image-20230812222340337

    注:用于内部排序的内存工作区WA可容纳l个记录,则每个初始归并段也只能包含l个记录,若文件共有n个记录,则初始归并段的数量r=n/l;

    那么置换选择排序就可以使得每个初始归并段都包含l个记录!

原理

image-20230812222956621

注:首先我们需要读入三个元素,把它们放进内存工作区WA中,然后在三个之中找到最小的那个,并将最小的用一个变量(MINIMAX)记录下来,然后将最小的值,放入左边的初始归并段输出文件FO中,接着内存工作区WA中就少了一个元素,那么就又从右边读入一个元素,然后在内存工作区中再次找到最小的放进左边(若是此时找到的最小的,比之前MINIMAX记录的还要大的话,那么就更替MINIMAX的值,并将这个值放进左边的文件,若是此时的最下的,比之前MINIMAX的还要小,那么就要找到第二小的再去和MINIMAX比较,只有第一个比MINIMAX大的才可以放到左边,放进左边之后,MINIMAX就会变成刚才放进左边文件的那个值)

image-20230812223515417

image-20230812223706560

注:若WA内的关键字都比MINIMAX更小,则该归并段在此截止!

image-20230812223938323

注:当初始待排序文件里面没有数据元素的时候,那么就可以按照顺序把内存工作区里面的所有元素都放进归并段里面!

注:使用置换-选择排序,可以让每个初始归并段的长度超越内存工作区大小的限制,进而增加初始归并段的长度,也就是说减少了初始归并段的数量,那么就可以减少读写磁盘的次数,提高了效率!

image-20230812224455711

注:其实这里这个初始归并段输出文件FO是在磁盘里面的,这个过程背后的真正原理是一个一个的记录首先被读入到一个输出缓冲区,等到数量凑够了一个磁盘块之后,才会读入到外存当中,这里是为了方便理解,才一个一个记录读入的(其实真实情况是一次会读入好几个记录,也就是一块一块的读,然后再一个一个记录的读到内存工作区的)!

知识回顾

image-20230812225015615

最佳归并树

神秘性质

image-20230812230555617

注:每个绿色结点里面的数字表示的是这个归并段共占有多少个磁盘块!

注:每个初始归并段看作一个叶子节点,归并段的长度作为结点权值,则上面这个归并树的带权路径长度WPL=2*1+(5+1+6+2) *3=44=都磁盘次数=写磁盘次数(上图就是是一颗倒过来的二叉树,那么WPL就是叶子节点到根节点的距离,那么结点2到根节点就是1(一个箭头),其余叶子节点到根节点就是3(3个箭头)!

重要结论:归并过程中的磁盘I/O次数=归并树的WPL*2

注:要想磁盘的I/O次数最少,那么就要使归并树的WPL最小,也就是哈夫曼树

构造最佳归并树
  • 构造2路归并的最佳归并树

    image-20230812231314011

    构造方法不用多说,很简单,每次选择两个最小的结点组成一个新节点就可以了,直到最后!

    image-20230812231550550

    注:WPL的计算这里不再多说,不了解的看哈夫曼树那一章!

  • 多路归并的情况

    • 普通方法

      image-20230812231840296

      注:这显然不是最好的选择!

    • 最佳方法

      image-20230812232026689

      注:方法和2路归并差不多,之前是选择两个最小的元素,那么现在我们就选择3个最小的元素不就得了,然后再按照之前的方法也是一样的!

      但是有个小问题,那就是若是减少一个归并段呢?那就有可能凑不够3个结点!

      • 减去30结点之后

        image-20230812232354403

        注:减去30结点之后,由于在最后进行合并的时候,结点不够了,那么只能进行二路归并了,那么就导致了这个树不是最佳归并树(因为3路归并,是除了叶子节点之外,其余的结点都要有3个分叉)

    • 正确做法

      image-20230812232736437

      注:对于k叉归并,若初始归并段的数量无法构成严格的k叉归并树,则需要补充几个长度为0的“虚段”,再进行k叉哈夫曼树的构造

      注:解释一下这个“虚段”,在处理这个初始归并段的时候,若是遇到了虚段,那么就会把它当作是一个已经处理完了的归并段,然后就会接着去处理下一个归并段,这就是其背后的一个原理!

      那么到底要添加几个虚段呢?

      首先,k叉归并树一定是一颗严格的k叉树,即树中只包含度为k,度为0的结点。

      image-20230812233748438

知识回顾

image-20230812234053518

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值