基于比较的七种常见排序算法

Posted 庸人冲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了基于比较的七种常见排序算法相关的知识,希望对你有一定的参考价值。

文章目录

前言

本文主要介绍基于比较的七种常见排序算法,分别为:选择排序法,插入排序法,希尔排序法,冒泡排序法,堆排序法,归并排序法,快速排序法。

基于比较的排序算法是指对于元素的排序必须是建立在元素之间是可以比较的。体现在 j a v a java java​​​ 语言中为待排序的元素类型是实现了 C o m p a r a b l e Comparable Comparable​​​​​ 接口的类型。

本文所涉及的复杂度分析都是基于现有结论加上自己的简单的理解,所以可能非常不严谨,大家看看就好,不过最终的结论都是对的。

在分析复杂度的同时也对排序算法的稳定性进行了分析,下面先对排序算法的稳定性先做一些简单的解释。

排序算法的稳定性

在一组数据中,排序前相等的两个元素,在排序后相对的位置不变,这样的排序算法就被称为稳定的排序算法。

如果排序的元素只存在一个数据域(属性),那么对于排序算法的稳定性可以没有太高的要求。但是排序的元素如果超过一个数据域,对于排序算法的稳定性可能就有要求了。

例如:对于一组学生的成绩进行降序排列,学生不仅有成绩这一个属性还有学生姓名等其它属性,那么在排列的过程中对于成绩相同的学生而言,最终的排列次序就取决于排序算法的稳定性。

稳定的排序算法可以 100 % 100\\% 100%​ 的保证每次排序的结果中相同元素的相对位置没有发生任何变化。

不稳定的排序算法不能 100 % 100\\% 100% 的保证每次排序的结果中相同元素的相对位置没有发生任何变化。

注: 本文中出现的所有动态图片全部来自于:visualgo 这个网站,感兴趣的同学可以浏览一下,网站中提供了大量数据结构相关的动态图,并且还配有教程,十分不错。

选择排序

基本思路

选择排序(Selection sort),是最基础的排序算法之一,也是通常最容易想到的一种排序算法,基本思路为对一组有 n n n​​​ 个元素的数据,每一轮都选择一个最小(默认升序排列)的元素放置在待排序区间的第一个位置。经过 n − 1 n - 1 n1​​​​​​​ 轮的选择后,所有元素都被放置在它应该处于的位置。

代码实现

具体实现上,需要使用两层循环来遍历数组每一个元素,外层循环控制整个排序需要的轮数,定义外层循环变量 i ,初始指向数组第一个元素,也就是下标为 0 的元素,i 需要维护的循环不变量为,每次进入循环时 [0 , i - 1]区间为有序区间,[i, n - 1] 区间为无序区间(n 表示元素个数),当 i == n - 1 时,无序区间只存在一个元素,而其它元素都是已经被放置它们最终的位置所以最后一个元素在整个数组中也一定是有序的,此时可以排序完毕,退出整个循环。

内层循环负责在本轮循环中到待排序区间找到最小元素并纪录该元素的索引,定义内层循环变量 j 用于扫描待排序区间的每一个元素,初始指向 i + 1的位置,i 进入循环时指向待排序区间的第一个元素,用变量 min 纪录 i 的位置,表示默认 i 索引上的元素为区间内的最小元素,j 通过扫描其后的所有元素与 min 上的元素比较,当发现存在小于 min 位置上的元素时,就将 min 的值更为较小值的索引 j,直到 j 遍历完数组最后一个元素时,退出内层循环。当内层循环结束时,将 min 索引上的最小元素与待排序区间第一个元素 (i 索引上的元素) 进行交换。

public class SelectionSort 

    /**
     * 选择排序(升序),  每一次遍历都找到待排序元素中最小的一个元素,并将该元素放置在待排序元素的第一个位置。
     */
    public static <E extends Comparable<E>> void sort(E[] array) 
        int n = array.length;
        int min;  // min 记录了每次内层循环结束后待排序元素中最小元素的索引
        for (int i = 0; i < n - 1; i++) 
            min = i; // 将索引 i 的元素默认为本轮选择排序的最小元素。

            for (int j = i + 1; j < n; j++)   // 遍历[i + 1, n) 区间元素。
                if (array[j].compareTo(array[min]) < 0)   // 区间内发现比 arr[min] 更小的元素时
                    min = j; // 将 min 更新为 j
                
            
            if (min != i)   // 内层循环结束时, min != i 则说明 [i + 1, n)区间 存在更小的元素
                swap(array, i, min);  // 则交换2个索引上的元素
            
        
    

    private static <E> void swap(E[] arr, int i, int j) 
        E temp = arr[j];
        arr[j] = arr[i];
        arr[i] = temp;
    

复杂度分析

时间复杂度: O(n2),每一轮比较确定一个元素的最终位置,也就是去掉一个待排序元素,直到待排序区间没有元素时停止,那么第一轮需要扫描 n 个元素,第二轮扫描 n - 1,n - 2,n - 3 … 1 总共扫描 n(n - 1) / 2 次,所以时间复杂度为 O(n2)

空间复杂度: O(1),选择排序不需要申请额外的内层空间进行辅助排序,不管数据规模多大,使用的辅助变量都说是固定的,因此空间复杂度为 O(1)

稳定性: 选择排序算法是不稳定的排序算法。假设当前未排序的第一个元素 a1 后存在一个相同的元素a2,在 a2 之后存在一个未排序的最小元素 b1 那么当 a1b1 交换位置后,a1 就位于了 a2 之后,所以选择排序的交换是跳跃式的,会改变两个相同元素间原本的位置关系。

插入排序

基本思想

插入排序(Insertion Sort),也是一种基础的排序算法,其基本思想是将待排序区间中的一个元素插入到有序区间的合理位置上,使得有序区间内的元素增加,待排序区间中的元素减少,直到待排序区间没有元素位置。

代码实现

具体实现上,同样需要两个循环解决问题(默认升序排列),定义外层循环变量 i 指向数组中未排序区间的第一个元素,也就是本轮需要插入至有序区间中的元素,同时 i 需要维护的循环不变量为每次进入循环时 [0, i - 1] 为有序区间,[i, n - 1] 为无序区间(n 表示元素个数),初始时,i = 0 表示有序区间没有元素。每次进入外层循环时,将 i 位置元素用临时变量 temp 保存,用于与 i 之前的有序元素进行比较。

定义内层循环变量 jj 初始指向 i ,用于纪录 temp 元素应该插入的位置,在内存循环中每一次将 tempj - 1 位置上的元素进行比较,如果 temp < j - 1 位置上的元素,那么 temp 就应该插入到 j - 1 位置,而 j - 1 位置的元素也应该后移到 j 位置上,这里直接将 j - 1 位置上的元素覆盖到 j 位置上,并用 j 来纪录 temp 应该插入的位置,也就是 j = j - 1 。因为 j 的指向已经前移,那么就继续比较更新后j - 1 这个位置上的元素,如果 temp < j - 1位置上的元素,重复上述操作。直到 temp >= j - 1 位置上的元素,或者 j 将有序区间中所有元素都比较一遍( j == 0) 时退出内层循环,此时 j 存储的位置即为 temp 应该插入的位置,将 temp 插入到该位置即可。

重复上述操作,直到 i == n,也就是无序区间 [i, n - 1] 为空区间时,退出整个循环,数组排序完成。

代码实现:

public class InsertionSort 
    public static <T extends Comparable<T>> void sort(T[] arr) 
        int n = arr.length;
        for (int i = 0; i < n; i++) 
            T temp = arr[i];   // 保存待插入元素
            int j;
            for (j = i; j > 0 && arr[j - 1].compareTo(temp) > 0; j--)  // 当j > 0 && j arr[j-1] > temp
                arr[j] = arr[j - 1];  // arr[j-1]的元素后移
            
            // 当退出循环时,arr[j]就是temp应该存入的位置
            arr[j] = temp;  // 将temp赋值给arr[j]
        
    

复杂度分析

时间复杂度: O(n2),插入排序算法在最坏情况下,也就是数组完全逆序的情况下,后面的元素肯定小于前面的所有元素,所以每一轮待插入的元素对需要与有序区间内所有的元素进行比较,并最终插入到有序区间的第一个位置。当 i = 0 时,没有有序元素进行比较,所以不会进入内层循环,当 i = 1 时,有序区间存在一个元素,需要比较 1 次,并将索引 1 位置的元素放到索引 0 的位置上,那么当 i = 2 时,就需要比较 2 次,i = 3,比较 3 次,一直到 i = n - 1 需要比较 n - 1 次,所以总共比较的次数为 1 + 2 + 3 + … + n - 1 = n(n - 1)/2。那么最坏时间复杂度为 O(n2),而一般普通算法的时间复杂度都是取最坏时间复杂度,所以插入排序的时间复杂度为 O(n2)

不过需要注意的是,插入排序在完全有序的情况下,时间复杂度可以达到 O(n) 级别。不难理解,当待排序元素与前一个元素进行比较时,前一个元素一定是小于待排序元素的,因此每轮的内层循环只执行了一次,所以总的时间复杂度为 O(n)。而一般而言,数据规模越小时,数据有序的可能性越大,并且插入排序所要比较的次数也越少,所以插入排序经常被用于高级排序算法的优化,用于处理小规模数据时的排序工作。

空间复杂度: O(1), 原地排序算法。

稳定性:插入排序法是稳定的排序算法。假设 a1 为未排序的元素,如果之前存在相同元素 a2,那么 a1 是不会插入到 a2 之前的,这是因为 a1逐个向前与元素进行比较,当遇到 a2时根据插入排序的比较逻辑就会停在 a2 的后一个位置,这样就保证了2个元素的相对位置不会发生改变。

不过将如果将内层循环的比较逻辑改成包含等于的情况,那么插入排序法也会变成不稳定的排序算法。所以对于稳定的排序算法而言,它是可以被修改为不稳定的排序算法,但是对于不稳定的排序算法而言,无论怎样修改都不能成为稳定的排序算法。

希尔排序

基本思想

希尔排序(Shell Sort)是对插入排序算法的优化,在介绍插入排序时提到过插入排序算法在数据完全有序或者基本有序的情况下时间复杂度可以达到 O(n) 级别,而一般来说数据规模越小时,数据有序的可能性越大。不过在实际的应用中一组数据的排列不可能以我们想要的方式呈现,而希尔排序算法就是为了创造这些条件而诞生的。其基本思想为:让待排序的数据变得越来越有序

代码实现

具体实现是将一组数据分成若干个子序列,对每一个子序列进行插入排序,那么经过这一轮排序过后,整组数据就比之前变得更加有序。接着再进行下一轮的排序,继续将整组数据分成若干个子序列不过这一次的分组数量要小于上一次的分组数量,当整组数组基本有序时,最后再将整组数据看成一组进行插入排序,操作结束后整组数据也就排序完毕。

需要注意的是,所谓的基本有序是指在一组数据中,较小的元素都在靠前的位置,较大的元素都在靠后的位置,而不大不小的元素都在靠中间的位置。所以,对于一组数据的分组并不是将一段连续的子序列划分为一组,因为这样划分后每个子序列在进行插入排序后,所移动的空间是有限的,无法使得原本靠前的较大元素移动到靠后的位置,同样也无法将原本靠后的较小元素移动到较大的位置。

为了达到整组数组越来越有序,正确的分组方式是将整组数据里相距为某个增量的元素划分为一组子序列,这样在对每一个子序列进行插入排序时,如果两个相差一个增量的元素间存在逆序关系,在进行插入时逆序元素的移动范围也会更大,也就更加靠近它应该处于的范围。最终数组数据也会变的越来越有序。

增量的选择决定了每一轮希尔排序时,数据被划分为了多少组,同时增量的选择也会影响希尔排序的性能。增量的选择目前还没有得到一个最优解,这属于计算机科学界未解的一个难题。常用的增量选择每次为原增量的 1/2,或者是每次取原增量的 1/3 + 1。这里以取原增量的 1/2 举例:

首次选择的增量为整组数据 n 的一半,也就是 n/2,那么整组数据也就被划分为了 n / 2组,其中每一组中有两个元素。对这 n / 2 组数据进行插入排序后。下一次的增量为 n / 4 ,n / 8,…,一直到 1 。最后一次增量值必须唯一,也就是将整组数据划分为 1 组进行插入排序。

其实大体的逻辑与插入排序基本一致,主要是每轮插入排序后需要更新增量为之前的一半,并且比较前一个元素元素不是固定为 -1 位置上的元素,而是减去增量后位置上的元素。

具体过程如下图:

代码实现:

public class ShellSort    
	public static <E extends Comparable<E>> void sort(E[] array) 
        if (array == null)
            return;
        int n = array.length;
        int gap = n / 2;    // 初始增量为 n/2
        while (gap >= 1)   
            for (int i = gap; i < n; i++)    // i 等于初始增量
                E t = array[i];     // 保存要插入到前面有序区间的元素
                int j = i;
                for (; j - gap >= 0 && array[j - gap].compareTo(t) > 0; j -= gap)   // j 每一步的偏移量为 gap 
                    array[j] = array[j - gap];
                
                array[j] = t;
            
            gap = gap / 2;  // 本轮插入排序完毕后, 缩小增量继续下一轮插入排序, 直到 gap < 0
        
    

复杂度分析

时间复杂度: O(nlogn) ~ O(n2),希尔排序的性能取决于增量的设置单从代码层面来看它的时间复杂度应该是 O(n2),但是实际上要比 *O(n2)*快上许多,在我的电脑上测试数据规模 10 万可以和 O(nlogn) 的排序算法性能接近,对于百万级规模的数据,也能在 2s 之内完成排序,所以它的时间复杂度应该是介于 O(nlogn) ~ O(n2 ,不过对于希尔排序的具体时间复杂度分析非常复杂,也超过了博主的能力范围,因此这里只是给出了一个大概的范围。有些书上也会说,在取特定的增量时,希尔排序的时间复杂度在 O(n1.3) 左右,这里了解即可。

空间复杂度: O(1),原地排序算法。

稳定性: 希尔排序法是不稳定的排序算法。希尔排序算法会将数据进行分组,对相距某一增量的一组数据进行插入排序,那么在排序的过程中因为是跳跃式的比较,也就很可能将相同元素的相对位置进行改变。

冒泡排序

基本思想

冒泡排序*(Bubble Sort)* 是一种交换排序算法,基本思想为:对一组有 n 个元素的数据,每次比较相邻两个元素的大小,如果是逆序排列,则交换两个元素的位置,直到所有元素有序为止。

代码实现

以上图为例,对 [5, 3, 7, 1, 2, 6, 4, 8]这组数据进行排序 ,首先比较 53 的大小,5 > 3 因此交换 53 的位置,得到[3, 5, 7, 1, 2, 6, 4, 8]

接着再比较 57 的大小,5 <= 7 则不交换。

继续比较后面相邻的两个元素大小, 并按照相同的判断来决定两个元素是否交换,直到遍历完未排序元素中最后两个元素时,本轮比较完毕。

可以发现通过一轮比较可以将未排序元素中最大的元素放置在未排序元素的最后一个位置。并且这个元素所处的位置也是整个排序完毕之后应该处于的位置。

所以对于给出的这组数据,只需要进行 7 轮比较就能确定 7 个较大元素的最终位置,而最后一个元素也自然是处于其最终位置上 。那么如果是一组有 n 个元素的数据,只需要进行 n - 1 轮比较就可以完成排序。并且,因为每一轮都确定了一个元素的最终位置,所以每进行下一轮比较时,上一轮确定位置的元素及其后面的所有元素都无需进行比较。假设进行第 i + 1 轮比较,之前就确定了 i 个元素的位置,本轮比较的最后两个元素只需要到 n - i - 1n - i 即可,最终 n - i 位置上也会在放置剩余元素中最大的一个元素。

代码实现:

public class BubbleSort 
    
    public static <E extends Comparable<E>> void bubbleSort1(E[] array) 
        int n = array.length;
        for (int i = 0; i < n - 1; i++)  
            for (int j = 0; j < n - 1 - i; j++)   
                if (array[j].compareTo(array[j + 1]) > 0) 
                    swap(array, j, j + 1);
                
            
        
    
    public static <E extends Comparable<E>> void swap(E[] array, int i, int j) 
        E t = array[i];
        array[i] = array[j];
        array[j] = t;
    

优化版1

对于一组已经基本有序的数据,在经过几轮排序后,整组数据可能已经是完全有序的了,那么也就没必要再对剩余的元素挨个进行比较。所以在每一轮比较开始时都假设数组已经有序,如果在本轮比较中相邻元素间并没有进行交换,那么就可以证明假设是正确的,直接退出循环即可。

具体操作上,在进入外层循环后定义布尔变量 isSorted 初始化为 true,当在内层循环中发生交换行为时,将其置为 false,内层循环结束时,如果 isSorted == true,说明没有发生交换行为,即数组已经有序,则循环终止。如果 isSorted == false,说明发生了交换行为,即数组依然可能无序,继续下次循环。

public class BubbleSort 


    public static <E extends Comparable<E>> void bubbleSort(E[] array) 
        int n = array.length;
        for (int i = 0; i < n - 1; i++) 
            boolean isSorted = true;  // 本轮交换开始假设数组已经有序
            for (int j = 0; j < n - 1 - i; j++) 
                if (array[j].compareTo(array[j + 1]) > 0) 
                    swap(array, j, j + 1);
                    isSorted = false;  // 执行交换操作,则数组依然可能无序
                
            
            if (isSorted)  // isSorted == true, 则数组已经有序
                break;
        
    
    public static <E extends Comparable<E>> void swap(E[] array, int i, int j) 
        E t = array[i];
        array[i] = array[j];
        array[j] = t;
    

优化版2

如果是一组前面无序,而后面有序的数据,对于后面有序区间内的比较是没有必要的,但是根据上面版本的 Bubble Sort 除非数组完全有序,否则依然会将后面已经有序的数据再次比较一遍,而对于有序区间内的比较不会产生交换操作,所以每一轮比较完毕后,最后一次交换操作发生的位置及其后面的所有元素都是有序的。因此,可以纪录每轮比较的最后一次交换操作发生的位置,整组数据的元素总个数 - 最后一次交换的位置 = 有序元素的个数。对于内外两层循环的判断都可以基于有序元素的个数,外层循环根据有序元素的个数决定是否进入循环,内层循环根据有序元素的个数决定本轮比较的边界位置。

public class BubbleSort 

    public static <E extends Comparable<E>> void bubbleSort(E[] array) 
        for (int i = 0; i < array.length - 1; ) 
            int last = 0;  // 纪录本轮最后一次交换位置,初始为0
            for (int j = 0; j < array.length - 1 - i; j++) 
                if (array[j].compareTo(array[j + 1]) > 0) 
                    swap(array, j, j + 1);
                    last = j + 1;     // 纪录交换位置,[j + 1, n) 之间为有序元素
                
            
            i = array.length - last;  // array.length - last,计算已经有序的元素个数,并赋值给 i
        
    
    
    public static <E extends Comparable<E>> void swap(E[] array, int i, int j) 
        E t = array[i];
        array[i] = array[j];
        array[j] = t;
    

复杂度分析

时间复杂度: O(n2),冒泡排序在完全逆序的情况下,需要两两比较无序区间中的所有元素,总的比较次数同样也是 1 + 2 + 3 + … + (n - 1) = n(n-1)/2 ,所以时间复杂度是 O(n2)

空间复杂度: O(1) ,原地排序算法。

稳定性: 冒泡排序法是稳定的排序算法。冒泡排序法每一次都是比较相邻两个元素的大小关系,如果存在逆序关系则交换,所以不是逆序关系的两个元素是不会进行位置的交换,也就保证了相同元素的相对位置不会发生改变。

堆排序

基本思想

堆排序(Heap sort)是借助了堆这种数据结构来完成排序的算法。如果没有了解过堆这种数据结构的同学可以参考这篇博客:堆和优先队列,里面比较详细的介绍了堆排序中所应用的几个函数。

堆排序的基本思想是将待排序的数组构建成一个大堆或小堆(根据需求而定),然后根据堆的特性,交换堆顶元素和堆底最后一个元素(数组最后一个元素)进行交换,那么此时数组最后一个元素就是整个数组中的最大值(以升序排列为例),因为堆顶元素此时不满足堆的性质,所以要对堆顶元素执行下沉(Sift Down)操作,在 Sift Down 的过程中不应该对已经有序的元素进行操作,也就是被交换至数组末尾的元素此时已经不属于这个堆中的元素了,因此在 Sift Down 的具体过程中需要使用一个变量,来控制 Sift Down 所能操作的范围。当操作完毕后再将新的堆顶元素与数组倒数第二个元素进行交换,以此类推直到整个数组排序完毕。

代码实现

public class HeapSort 

    /**
     * 原地堆排序
     * 核心思路:将待排序的数组看成一个堆,使用heapify的方式构建成一个堆,当形成堆以后堆顶的元素为数组中最大的元素,将
     * 这个元素与堆底(数组最后一个)元素进行交换,那么此时数组最大的元素就被放置在了它应该存在的位置。而因为堆顶的元素此
     * 刻不在是堆中最大的元素,应该再次siftDown()将该元素下沉重新形成堆,接着再将新的堆顶元素与新的堆底(数组倒数第二)元
     * 素进行交换,那么此时数组第二大的元素就被放置再了它应该存在的位置,...以此类推,直到堆中所有元素都被重新放置。
     *
     * 通过上面的分析,需要维护一个指针 end 初始指向数组最后一个元素的位置。整个排序过程中所维持的循环不变量为:
     * [end + 1, arr.len)区间为已经排序的元素,[0,end]区间具备大根堆的特性,并且区间内都是未排序的元素
     */
    public static <E extends Comparable<E>> void sort(E[] arr) 
        System.out.println("****************原地堆排序****************");
        int end = arr.length - 1;
        heapify(arr);  // 将数组转换为堆
        while (end > 0) 
            swap(arr以上是关于基于比较的七种常见排序算法的主要内容,如果未能解决你的问题,请参考以下文章

基于比较的七种常见排序算法

知识分享:程序员必备的七种常见排序算法和搜索算法

常见的七种排序算法(Java实现)

常见的七种排序算法(Java实现)

常见的七种排序算法(Java实现)

常见的七种排序算法(Java实现)