第八章-排序


文章目录


一、 插入排序

直接插入排序

直接插入排序,适用于顺序存储和链式存储的线性表,当采用链式存储时不需要移动元素。性能:

● 空间复杂度O(1)

● 时间复杂度,最好的情况下是表中已经有序,时间复杂度为O(n);最坏的情况下是每次插入都需要对比有序表中的每个元素,时间复杂度为O(n2)。综合为O(n2)

● 是一种稳定的算法。

c 复制代码
/**
    直接插入排序
*/
void InsertSort(int arr[], int length){
    for(int i = 1; i < length ;i++){ // 从第二个元素开始遍历,第一个元素视为已排序
        int tmp = arr[i]; // 暂存当前元素

        for(int j = i-1; j >= 0; j--){ // 将指针指向i的后一个元素,也是已排序集合中的最后一个元素
            if(tmp > arr[j]){   // 如果比已排序的最后一个元素还大,则跳出循环直接插入即可(若是降序则改成小于)
                break;
            }else{ // 将比他大的元素分别后移一位,为他腾出空间。
                arr[j+1] = arr[j];
            }
        }

        arr[j+1] = tmp; // 最后插入它的位置需要加1
    }
}

折半插入排序

折半插入排序,减少了直接插入排序时的元素比较次数(在查找待插入元素的插入位置时使用折半查找,然后再统一移动元素),在数据量不大的情况下效果很好,仅适用于顺序存储的线性表。代码太难直接放了,性能:

● 时间复杂度仍为O(n^2)

● 是一种稳定的算法

希尔排序

希尔排序,也称缩小增量排序。依据"直接插入排序适合基本有序且数据量不大的情况"进行改进。依次选择某一增量序列di(若给出则用,如(d1=5,d2=3,d3=1);若没给出,算法默认为2/n向下取整,然后依次/=2),然后划分子表(每间隔d个元素为1组),最后对每个子表进行直接插入排序。仅适用于顺序存储的线性表,性能:

● 空间复杂度为O(1)

● 时间复杂度取决于增量函数,当n在某个特定范围时,时间复杂度为O(n1.3),最坏的情况为O(n2)

● 是一种不稳定的算法

c 复制代码
/*
    shell排序
*/
void ShellSort(int arr[], int length){
    if(arr == null || length  <= 1) 
        return;

    for(int d = length/2; d > 0; d /= 2){
        
        /**
            每次确定d后,开始对每个分组进行直接插入排序
            依次对L0+d,L1+d,L2+d...Ld-1+d为首的表进行排序
            因为是直接插入排序,所以每次指向的是表的第二个元素,第一个默认已有序
        */
        for(int i = d ; i < length; i++){ 
            int tmp = arr[i];
            
            for(int j = i-d; j>0; j-=d){
                if(tmp > arr[j]){
                    break;
                }else{
                    arr[j+d] = arr[j];
                }
            }
            arr[j+d] = tmp;
        }  
    }  
}

二、交换排序

冒泡排序

冒泡排序,若是升序,每次会确定一个最大的;若是降序每次会确定一个最小的。这样最多n-1趟就能排好。适用顺序存储和链式存储的线性表。

性能:

● 若正好与算法相反(最差的情况),则进行n-1趟,对比

,移动"前者三倍"(交换两个元素需要移动三次)次元素。时间复杂度O(n^2),空间复杂度为O(1)。

● 当正好是与算法相同升/降序(最好的情况),则只进行了第一趟,对比n-1次,移动0次元素,时间复杂度O(n),空间复杂度为O(1)。

● 是一个稳定的算法。

c 复制代码
void BubbleSort(int a[], int n){
    for(int i = 0; i < n-1; i++){ // 只需遍历到倒数第二个元素
        bool flag = false;

        for(int j = 0;j < n-1-i; j++){
            if(a[j] > a[j+1]){  // 改成"<"则变为降序
                int temp = a[j];
                a[j] = a[j+1];
                a[j+1] = temp;
                flag = true;
            }
        }

        if(flag == false){ // 若某一趟没发生交换,则说明已经有序了。
            return;
        }
    }
}

快速排序

快速排序,pivot枢轴(或称基准)指每个子表排序时默认选择首元素,用于递归的划分。比基准元素大的放右边,小的放左边。用pivotpas获得基准元素的最终放入位置,然后划分子表,递归调用快排。仅适用于顺序存储的线性表。

c 复制代码
void QuickSort(int A[], int low, int high){
    if(low < high){
        int pivotpos = Parttition(A, low, high); // 划分操作
        QuickSort(A, low, pivotpos - 1); // 递归左右子表
        QuickSort(A, pivotpos + 1, high);
    }
}

int Parttition(int A[], int low, int high){
    int pivot = A[low]; // 选择当前表的第一个为基准元素

    while(low < high){
        while(low < high && A[high] >= pivot) --high; // 大的就不用管了,右指针直接减1
        A[low] = A[high]; // 否则,high发现违法元素,赋值给low(low此时指向的元素没价值)

        while(low < high && A[low] <= pivot) ++low; // 小的就不用管了,左指针直接加1
        A[high] = A[low]; // 否则,low发现违法元素,赋值给high(high此时指向的元素没价值)
    }
    A[low] = pivot; // 或者 A[high] = pivot;
    return low; // 或者 return high;
}

以第一趟为例,
外循环第一次:首先选择首元素49为基准元素。分别设置low和high。此时low指向的元素已经无意义了(可被覆盖)。由算法,起先high发现自己指向的元素27违法了,直接将该元素赋值给low(low此时指向的元素无意义,所以可以被27覆盖)。接着进行第二个while,由low向右(经过两次循环)发现一个违法元素65,将该违法元素赋值给high(high此时指向的元素27已被赋值给曾经的low,可以被65覆盖)。

接着对比low<high,外循环通过,开始外循环第二次:high在第一个循环中加了1,然后发现所指向的元素违法了,将违法元素13赋值给low(此时low指向的是无价值的65,65已经被赋值给曾经的high)。接着low在第二个循环中加了1,发现所指元素97违法了,将97赋值给high(同理)。

继续对比low<high,外循环通过,开始外循环的第三次:high在第一个循环中加了2,发现此时low==high了,跳出第一个循环,不进入第二个循环,再跳出外循环。
外循环结束后,将数组A中low=high的位置赋值基准元素pivot,return low或者high。

然后进行第二趟,先从pivotpos的左侧继续递归调用。该树的先序遍历是依次排好的元素的先后顺序(也是递归调用的顺序),中序遍历就是排好的结果。


性能,取决于基准元素的选择(划分操作好坏),408中默认当前表第一个元素:

● 空间复杂度,快排是递归,需借助工作栈。最好情况下是O(logn);最坏情况进行n-1次递归(序列中的元素已经接近十分有限,类似二叉排序树构建的是一条直线),此时为O(n)。

● 时间复杂度,最坏情况是每次都需要交换位置(每个子表的元素最大的在左边,最小的在右边),此时为O(n^2);最好的情况(平均性能接近最好的)是每次中枢都能将表中数据中分(树最矮),此时为O(nlogn)。

快排最坏的情况,序列基本有序,产生一颗歪把子树,时间复杂度为O(n^2)
C

● 不稳定的算法。(相等元素的相对位置发生变化)

那么如何提高快排的效率?选择合适的基准元素(都是为了使树矮)。①尽可能选择能让数据按大小等分的元素,②随机选择元素,使最坏情况几乎不可能发生。

1,元素已基本有序应选择直接插入排序

2,最快的情况是每次划分(根据最终基准元素确定的位置)都将表划分等长或类似等长;最坏的情况就是二叉排序树的最坏情况,基准元素划分表直接形成一个单支树。本质还是考的快排模拟,算一下试一试。

A、D

同上。画出划分树,注意根节点不算子表当中,比较次数为7+2+3+1
D

3,快排每一趟都会确定一个元素的位置,对该表从小到大和从大到小排序即可,看看基准元素有没有排在相应的位置上,若没指出基准元素是谁,那就都试一试。
C

三、选择排序

简单选择排序

每次去找未排序数组中的最小数(或最大)的下标,然后交换当前选中的元素的位置。类似冒泡,从左至右(从右至左一样)。适用于顺序存储和链式存储的线性表(和冒泡一样)和关键字较少的情况。

性能:

● 空间复杂度O(1)

● 时间复杂度为O(n^2),较之于冒泡,减少了元素移动的次数(使用了min),最坏不会超过3(n-1),最好为0次;但元素对比次数与序列情况无关,与冒泡最差情况相同(冒泡对比次数与序列情况有关)。

● 是一种不稳定的算法。

c 复制代码
void SelectSort(int A[], int n){
    for(int i = 0; i < n - 1; i++){ // 只需遍历到倒数第二个元素
        int min = i;
        for(int j = i + 1; j < n; j++){
            if(A[j] < A[min]){
                min = j; // 更新min的值
            }
        }
        // 此时min指向当前未排序数组中的最小值

        if(min != i){ // 说明有需要交换的元素
            int temp = A[min];
            A[min] = A[i];
            A[i] = temp;
        }
        // 若没有需要交换的元素,继续下一次循环
        // 这里体现了简单选择排序一定会进行n-1趟排序
    }
}

堆排序

堆是逻辑上是一颗完全二叉树,实际存储是顺序存储实现(数组)。大根堆就是根≥左、右;小根堆是根<等于左、右。如果从1编号,任何一个节点编号i,2i就是它的左孩子,2 i+1就是它的右孩子。每个节点j,向下取整(2/j)就是它的父节点编号。适用于顺序存储的线性表。

堆的构建,是将一个顺序存储的完全二叉树堆化。从后往前去调整小树,直到根节点。(在调整过程中,若出现影响了其他小树违法的情况,则将违法元素继续下坠)


堆的删除(规定只能删除堆顶元素),本质是输出堆顶元素(是为了排序,依次输出堆顶元素)。大根堆堆顶元素就是所有元素最大的,小根堆就是最小的。将堆顶元素与最后一个元素互换,然后删去。再将堆调整,方法与堆的构建相同,违法元素不断下坠。例如删除87后:

堆的插入,在最后位置插入,然后不断上升。
堆的排序,就是给定一个序列,直接构建成完全二叉树,然后和构建的过程一样依次调整违法元素,该过程称为调整初始堆。然后才允许一系列操作。时间复杂度为O(nlogn)。以该题为例分析,直接将给定的序列构建成完全二叉树,再调整为初始堆,最后进行操作即可。

性能:适合关键字比较多的情况

● 空间复杂度O(1)

● 建堆的时间复杂度为O(n);堆排序时间复杂度O(nlogn)(最好、最坏、平均)

● 是一种不稳定的算法

1,根堆的构建每次都从叶结点插入,也就是说根堆一定是一颗完全二叉树,所以小根堆的最大的元素只可能出现在叶子结点,也就是最后一层,当然小根堆的最后一层也可能有元素比上面的节点的元素小。
B

四、归并、基数、计数排序

归并排序

归并排序是指将两个或两个以上的有序表合成一个新的有序表,适用于顺序存储和链式存储的线性表。采用分治思想,二路归并排序的分治是:

● 将待排序的线性表不断的切分成若干子表(第一次分割为两个,第二次分割为四个...),直到每个子表只包含一个元素,此时算法认为每个子表是有序的

● 将子表两两合并,每一次合并就会产生一个新的且更长的有序表,重复这一步使得最后只剩下一个子表,也就是排序好的线性表。

归并排序的路数m指的每次分割操作中将一个表分成m个表,每次合并操作中将m个表合并成一个表。在对每个表进行合并的时候,操作是为每个表设立一个工作指针,依次每次需要对比m个指针指向的元素,然后选择一个元素放到创建的空间中,该空间的长度等于本次需要合并的所有表的长度之和。下图是二路归并的一次合并操作

如2011年408为例,二路归并每次合并的时候,最坏的情况需要对比a+b-1次,a、b分别为合并的两个表的长度。

二路归并性能分析:算法一般采用递归方式实现

● 空间复杂度为O(n),因为每次合并操作需要创建一个总数组去保存要合并的所有表的元素

● 时间复杂度为O(nlogn)

● 是一个稳定的算法

c 复制代码
int *B = (int*)malloc(n * sizeof(int));
void MergeSort(int arr[], int low, int high){
    if(low < high ){
        int mid = (low + high) / 2;
        MergeSort(arr, low, mid);
        MergeSort(arr, mid+1; high);
        Merge(arr, low, high);
    }
}
void Merge(int arr[], int low, int high){
    int i, j, k;
    for(k = low; k <= high; k++){
        B[k] = A[k];
    }

    for(i = low, j = mid+1, k = low; i<=mid && j<=high; k++){
        if(B[i] <= B[j]){
            A[k] = B[i++];
        }else{
            A[k] = B[j++];
        }
    }

    while(i <= mid) A[k++] = B[i++];
    while(j <= high) A[k++] = B[j++];
}

基数排序

基数排序是一种特殊的算法,它不基于比较和移动进行排序,而是基于关键字各个位数的大小。通常有两种方式,①最高位优先MSD法,按优先级也就是权重递减的顺序去为关键字的各个位排序,②最低位优先LSD法,按各个位权重递增的顺序去排序。后者更为常用,通常采用顺序存储和链式存储的线性表。

具体由入队和出队(从队头开始摘)两个操作完成,以下面例子分析链式存储的基础LSD法,

第一趟分配和收集,

第二趟分配和收集,

第三趟分配和收集,

性能分析:

● 空间复杂度为O®,r为关键字的位数,每一趟需要r个辅助队列,需要r个队头指针和r个队尾指针,但以后会重复使用这些空间

● 时间复杂度为O(d*(n+r)),n位关键字的个数,d为趟数。因此基数排序的时间复杂度与初始序列的状态无关。

● 是一种稳定的算法

计数排序

计数排序和基数排序一样也是不基于比较的算法,适用于顺序存储的线性表。它的思想是先通过两步去去构建一个辅助数组,该数组存储的信息用于排序算法,具体是:数组中下标是关键字的值,对应的值代表着待排序元素中有多少个元素小于等于它。
辅助数组的构造(如图):①统计出现的次数,②第一步的数组后,依次对前面所有下标的值进行累加,就是当前下标的值,以此完成第二步完成辅助数组的构造。

通过辅助数组完成排序:创建一个长为n的输出数组表示排序完成后的数组,下标为辅助数组的值,值为辅助数组的下标(就是将辅助数组的键值反转)。注意:反转后可能输出数组还有空缺,瞪眼法根据辅助数组隐含的信息补齐。

性能分析: 计数排序是一种空间换时间的做法

● 空间复杂度为O(n+k),n位关键字的个数也是输出数组的长度,k为辅助数组的长度(与关键字的最大值有关,因为关键字的值就是辅助数组的下标,所以n和k不一定谁大谁小)。若不算输出数组(可以覆盖已有的数组),就是O(k)

● 时间复杂度与k的值有关,当k=O(n)(同阶无穷大),时间复杂度为O(n);当k>O(nlogn)

时(k是n的高阶无穷大),效率为O(n+k),此时反而不如一些基于比较的排序算法。可见计数排序的选择应关注关键字的最值它影响了时间复杂度和空间复杂度。

● 计数排序是一种稳定的算法

五、内部排序总结

1,①稳定性:选艾希 堆攻速 下路不稳(简单选择、希尔排序、堆排序、快速排序不稳定,剩下的全部稳定),② Ⅱ Ⅵ Ⅶ ③Ⅰ Ⅳ

六、外部排序

外部排序的目的就是在不让内部排序影响到总性能的前提下通过外部排序减少磁盘IO。通常采用归并排序,通过增大归并路数k或减少初始归并段数r都能够减少树高,从而减少归并趟数,减少磁盘IO,达到目的。

注:但两者都不是能无脑增加或减少的,

● 过多的增加归并路数k:①会增加内部排序的对比次数,从而增加内部排序的时间;②需要开辟更多的缓冲区

去存储归并段,造成缓冲区的硬件开销。所以用败者树。

● 过少的归并段数也会让段内元素的总数增加,从而引发内部排序时间增加

多路平衡归并与败者树

以前去进行k路归并时都是要每次对比k-1个归并段的关键字,然后去选择最小的关键字。而通过败者树这一数据结构,首先让k路初始归并段的第一个节点去构造一个败者树,然后后续每次选取k路归并段中最小的关键字数仅仅只需要logk次,大大减少了关键字的对比次数。

败者树是一颗完全二叉树,

首先是败者树的构造,每个归并段的第一个节点上去,构造败者树。若是选举最小的元素值,则具体是:谁大(失败了)谁留在原地,然后推举另一个比较的关键字上去。这就选出了第一个最小的元素值。注:每个分支节点只记录失败关键字所在的归并段号,并不是记录失败的关键字的值。

接下来选择第二个最小的关键字,只需要让第一次选择的那个关键字所在归并段号的第二个关键字上败者树比较,就可以了。

败者树的代码实现思路,整个败者树就是作为完全二叉树的去存放在数组当中的,而且所有的叶子结点是虚拟的(不存储),每个数组的下标为归并段号(0是选举出的关键字所在的段号)。

置换选择排序

==内存工作区WA能存放多少记录,一个初始归并段就包含多少记录,他决定了n个文件刚开始被分成多少初始归并段。==分析下题:(1)内存区容量就是一个初始归并段的容量,故一个初始归并段可容纳450个记录,共4500个记录,所以可以建立10个初始归并段;(2)内存区可容纳450个记录,说明内存区占6个块,故采用5+1,即五路归并,所以每趟读各5个内存区,也就是读写各60个块。

置换选择排序,去生成初始归并段(让初始归并段尽可能少)来减少趟数。关键是每次选择比MINMAX大的最小的关键字输出。

D

最佳归并树

最佳归并树,优化置换选择排序生成归并段长度不等造成的内部排序开销。严格的叉k哈夫曼树,需要构造虚节点个数等于a,u=(n0-1)%(k-1),则a=k-u-1。通过此哈夫曼树规定了每个归并段在进行内部排序的次序(哪个段和哪个段先内部排序,以此减少段长不等造成的开销)。


相关推荐
靠沿2 小时前
Java数据结构初阶——七大排序算法及“非比较”排序
java·数据结构·排序算法
源代码•宸2 小时前
Leetcode—146. LRU 缓存【中等】(哈希表+双向链表)
后端·算法·leetcode·缓存·面试·golang·lru
郭涤生2 小时前
AWB算法基础理解
人工智能·算法·计算机视觉
hetao17338372 小时前
2026-01-21~22 hetao1733837 的刷题笔记
c++·笔记·算法
Hcoco_me2 小时前
大模型面试题91:合并访存是什么?原理是什么?
人工智能·深度学习·算法·机器学习·vllm
2501_901147832 小时前
零钱兑换——动态规划与高性能优化学习笔记
学习·算法·面试·职场和发展·性能优化·动态规划·求职招聘
充值修改昵称4 小时前
数据结构基础:B树磁盘IO优化的数据结构艺术
数据结构·b树·python·算法
程序员-King.10 小时前
day158—回溯—全排列(LeetCode-46)
算法·leetcode·深度优先·回溯·递归