八种常用的排序算法

八种常用的排序算法

对各种常用的排序算法的代码实现、时间复杂度、空间复杂度、稳定性做简要分析。

Author: Msuenb

Date: 2023-01-31


概述

排序也称排序算法(Sort Algorithm),排序是将一组数据,依指定的顺序进行排列的过程。

排序算法主要分为两类:内部排序和外部排序。

  • 内部排序是将待排序的数据全部加载到内存中,然后在内存中进行排序;
  • 外部排序是在待排序数据规模比较大的情况下,数据无法全部加载到内存中进行排序,需要将待排序的数据存储在外存中,排序时再把数据一部分一部分地调入内存进行排序。

外部排序需要进行多次内存与外存之间的交换。外部排序常用与对大文件的排序,其主要的时间代价来自于磁盘IO次数,为减少排序中读写磁盘的次数,通常的排序方法是归并排序。

在此仅对常用的内部排序算法进行讨论,常见的内部排序算法有:冒泡排序、选择排序、插入排序、希尔排序、快速排序、归并排序、堆排序、基数排序等。

名称 时间复杂度 空间复杂度 稳定性
冒泡排序 O(n^2) O(1) 稳定
选择排序 O(n^2) O(1) 不稳定
插入排序 O(n^2) O(1) 稳定
希尔排序 O(nlogn) O(1) 不稳定
快速排序 O(nlogn) O(logn) 不稳定
归并排序 O(nlogn) O(n) 稳定
堆排序 O(nlogn) O(1) 不稳定
基数排序 O(n*k) O(n+k) 稳定

说明:

  1. 稳定性:对于相同的两个元素,经某排序算法排序后相对位置不变,此排序算法稳定,反之则不稳定。
  2. 以下排序的测试数据均为:[3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]
  3. 以下排序均按照数据依次递增的方式进行

算法的时间复杂度

度量一个程序(算法)执行时间的两种方法:

  1. 事后统计的方法

    这种方式,要在同一台计算机的相同状态下运行,才能比较那个算法速度更快。

  2. 事前估算的方法

    通过分析某个算法的时间复杂度来判断哪个算法更优。

  • 时间频度:一个算法花费的时间与算法中语句的执行次数成正比。一个算法中的语句的执行次数称为语句频度或时间频度,记为T(n)。例如计算1-100所有数字之和,有如下两种算法:

    int total = 0;
    int end = 100;
    
    // 第一种:T(n) = n + 1;
    for (int i = 1; i <= 100; i++) { 
    	total += i;
    }
    // 第二种:T(n) = 1
    total = (1 + end) * end / 2;
    

    随着n值的不断变大,次方式变得重要,而

    时间频度的常数项可以忽略:T(2n+10) => T(2n)

    时间频度的低次项可以忽略:T(2n^2+3n+10) => T(2n^2)

    时间频度的系数项可以忽略:T(2n^2+3n+10) => T(n^2)

  • 时间复杂度:一般情况下,算法中的基本操作语句的重复执行次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n) / f(n) 的极限值为不等于0的常数,则称f(n)T(n)的同量级函数,记作T(n) = O(f(n)),称O(f(n))为算法的时间复杂度。如:T(n) = n^2 + 2n + 10,时间复杂度为O(n^2)

  • 常见的时间复杂度:O(1)、O(n)、O(log n)、O(nlog n)、O(n^2)、O(n^3)、O(n^k)、O(2^n)

  • 平均时间复杂度和最坏时间复杂度:平均时间复杂度是指所有可能的输入实例,均以等概率出现的情况下,该算法运行的时间。一般讨论的是最坏情况下的时间复杂度,这样做可以保证算法的运行时间的下界。

  • 空间复杂度:类似与时间复杂度的讨论,一个算法的空间复杂度定义为该算法所耗费的存储空间,它也是问题规模n的函数。在做算法分析时,主要讨论的是算法的时间复杂度。

冒泡排序

冒泡排序(Bubble Sort)的基本思想是:通过对待排序序列从前向后(从下标小的元素开始),依次比较相邻元素的值,若发现逆序则交换,使值较大的元素逐渐从前移向后部,就像水底的气泡一样逐渐向上冒。

因为排序的过程中,各元素不断接近自己的位置,如果一趟比较下来没有进行过交换,就说明序列有序,因此要在排序过程中设置一个标志flag判断元素是否进行过交换,从而减少不必要的交换。

  • 算法步骤

    1. 比较两个相邻的元素,如果第一个比第二个大,就交换这两个元素。
    2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
    3. 针对所有的元素重复以上的步骤,除了最后一个。
    4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
  • 动图演示

  • 代码实现

    /*
     * num长度为num.length() 相邻元素有num.length()对 每轮比较后确定一个元素的位置(最后元素最大)
     * 共需要比较num.length()-1轮 每轮比较的次数为num.length()-1-k次
     * k表示当前的轮次 k=0表示第一轮
     */
    public static void bubble_sort(int[] num) {
        int tmp;
    //  boolean flag = false;
        for (int i = num.length - 1; i > 0; i--) {
    
           	for (int j = 0; j < i; j++) {
            	if (num[j] > num[j + 1]) {
    //              flag = true;
                    tmp = num[j];
                    num[j] = num[j + 1];
                    num[j + 1] = tmp;
                }
            }
    
    //      if (!flag)    break;
    //      else  flag = false;
        }
    }
    
  • 时间复杂度:o(n^2);空间复杂度:o(1);稳定性:稳定

选择排序

选择排序是是从欲排序的数据中,按指定的规则选出某一元素,再依规定交换位置,达到排序的目的。

  • 算法步骤

    1. 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
    2. 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
    3. 重复第二步,直到所有元素均排序完毕。
  • 动图演示

  • 代码实现

    /*
     * num长度为num.length() 每次从无序序列中选出一个最小的数插入到前面有序序列中
     * 需要进行num.length()-1次选择 选出最小的元素后 将其与第i个位置的元素进行交换
     * i为选择轮次  i=0表示第一轮
     */
    public static void selection_sort(int[] num) {
        int minIndex, tmp;
        for (int i = 0; i < num.length - 1; i++) {
            minIndex = i;
            for (int j = i + 1; j < num.length; j++) {
                if (num[minIndex] > num[j])
                    minIndex = j;       // 保存最小数的索引
            }
            // 将最小值交换到已有序序列的后一个位置
            tmp = num[i];
            num[i] = num[minIndex];
            num[minIndex] = tmp;
        }
    }
    

    时间复杂度:o(n^2);空间复杂度:o(1);稳定性:不稳定

插入排序

插入排序的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。

  • 算法步骤

    1. 将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。

    2. 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

  • 动图演示

  • 代码实现

    public static void insert_sort(int[] num) {
        // 从下标为1的元素开始选择合适的位置插入,因为下标为0的只有一个元素,默认是有序的
         for (int i = 1; i < num.length; i++) {
                int tmp = num[i];   // 记录要插入的数据
    
                // 从已排序的最右边开始比较 找到比tmp小的数
                int j = i;
                while (j > 0 && tmp < num[j - 1]) {
                    num[j] = num[j - 1];
                    j--;
                }
    
                // 存在比tmp小的数 插入
                if (i != j)
                    num[j] = tmp;
    	}
    }
    
  • 时间复杂度:o(n^2);空间复杂度:o(1);稳定性:稳定

  • 存在问题:当待插入的数据是较小的数时,前面有序序列后移次数明显增多,对效率有影响。

希尔排序

希尔排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序

希尔排序是基于插入排序的以下两点性质而提出改进方法的:

  1. 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;

  2. 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;

  • 基本思想

    希尔排序是把记录按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰被分成一组,算法便终止。

  • 算法步骤

    1. 选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;

    2. 按增量序列个数 k,对序列进行 k 趟排序;

    3. 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

  • 动图演示

  • 代码实现

    public static void shell_sort(int[] nums) {
        // 方式1:对有序序列在插入时使用交换法(慢) 
        int temp;
        // 选择增量序列为 length/2  length/4... 1
        for (int step = nums.length / 2; step >= 1; step /= 2) {
            // 对每组使用直接插入排序 每组的第一个元素有序 共step组 从i=step开始比较
            for (int i = step; i < nums.length; i++) {
                // 遍历各组中所有元素 满足条件 执行交换
                for (int j = i - step; j >= 0; j -= step) {
                    if (nums[j] > nums[j + step]) {
                        temp = nums[j];
                        nums[j] = nums[j + step];
                        nums[j + step] = temp;
                    }
                }
            }
        }
        
        // 方式2:对有序序列在插入时使用移位法(快)
        int temp, j;
        for (int step = nums.length; step >= 1; step /= 2) {
            // 对每组使用直接插入排序
            for (int i = step; i < nums.length; i++) {
    
                // 记录当前下标 及要插入的数据
                j = i;
                temp = nums[j];
                while (j - step >= 0 && temp < nums[j - step]) {
                    nums[j] = nums[j - step];
                    j -= step;
                }
    
                if (i != j) nums[j] = temp;
            }
        }
    }
    
  • 时间复杂度:o(n^2);空间复杂度:o(1);稳定性:不稳定

快速排序

快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法,是冒泡算法的一种改进。

  • 基本思想

    通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,以达到整个数据变成有序序列。

  • 算法步骤

    1. 从数列中挑出一个元素,称为 "基准"(pivot);
    2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
    3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
  • 动图演示

  • 代码实现

    public static void quick_sort(int[] nums, int start, int end){
    
        if (start >= end)   return;
    
        int left = start, right = end;
    
        int pivot = nums[(left + right) >> 1];
    
        while (left <= right) {
            while (nums[left] < pivot) left++;
            while (nums[right] > pivot) right--;
    
            if (left <= right)
                swap(nums, left++, right--);
        }
    
        quick_sort(nums, start, right);
        quick_sort(nums, left, end);
    }
    
  • 时间复杂度:o(nlogn);空间复杂度:o(logn);稳定性:不稳定

归并排序

归并排序(Merge Sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:

  1. 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);

  2. 自下而上的迭代;

  • 算法步骤

    1. 将数组A[1,n]排序问题分解为A[1,n/2]A[n/2 + 1,n]排序问题
    2. 递归解决子问题得到两个有序的子数组
    3. 将两个有序的子数组合并为一个有序数组
  • 动图演示

  • 代码实现

    public static void merge_sort(int[] nums, int left, int right) {
        if (left >= right)  return;
    
        int mid = (left + right) >> 1;
    
        merge_sort(nums, left, mid);
        merge_sort(nums, mid + 1, right);
    
        merge(nums, left, mid, right);
    }
    
    public static void merge(int[] nums, int left, int mid, int right) {
    
        // 中转数组
        int[] temp = Arrays.copyOf(nums, nums.length);
    
        // 左边起始 i   右边起始 mid + 1
        int i = left, j = mid + 1, k = 0;
    
        // 遍历子数组进行合并
        while (i <= mid && j <= right) {
            if (temp[i] <= temp[j]) {   // 左边的最小者小
                nums[left + k] = temp[i];
                k++; i++;
            } else {                    // 右边的最小者小
                nums[left + k] = temp[j];
                k++; j++;
            }
        }
    
        // 添加剩余元素保证有序
        if (i <= mid) { // 左边有剩余
            for (int l = i; l <= mid; l++) {
                nums[left + k] = temp[l];
                k++;
            }
        } else {    // 右边有剩余
            for (int l = j; l <= right; l++) {
                nums[left + k] = temp[l];
                k++;
            }
        }
    }
    
  • 时间复杂度:o(nlogn);空间复杂度:o(n);稳定性:稳定

堆排序

堆排序(Heap sort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:

  1. 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
  2. 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
  • 算法步骤

    1. 创建一个堆 H[0……n-1];
    2. 把堆首(最大值)和堆尾互换;
    3. 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
    4. 重复步骤 2,直到堆的尺寸为 1。
  • 动图演示

  • 代码实现

    public static void heap_sort(int[] nums) {
        int len = nums.length;
    
        for (int i = 0; i < len - 1; i++) {
    
            build_max_heap(nums, len - 1 - i);
    
            // 将堆顶(最大值)交换到最后
            swap(nums, 0, len - 1 - i);
        }
    }
    
    // 将数组中以nums[root]为根的子堆调整为最大堆
    public static void build_max_heap(int[] nums, int last_index) {
    
        // 从last_index节点的父节点开始
        for (int i = (last_index - 1) / 2; i >= 0; i--) {
            // parent 保存当前正在判断的父节点
            int parent = i, child;
    
            // 如果 parent 的子节点存在
            while ( parent * 2 + 1 <= last_index) {
    
                // 左子节点索引
                child = parent * 2 + 1;
    
                // 如果左子节点存在 且值大于右子节点
                if ( child < last_index && nums[child] < nums[child + 1])
                    child++;    // child 总是指向较大字节的
    
                // 如果父节点值小于 其较大子节点的值
                if (nums[parent] < nums[child]) {
                    swap(nums, parent, child);
    
                    // 保证父节点的值大于左右子节点的值
                    parent = child;
                } else
                    break;
            }
        }
    }
    
  • 时间复杂度:o(nlogn);空间复杂度:o(1);稳定性:稳定

基数排序

基数排序(Radix Sort)属于"分配式排序",又称"桶子法"(bucket sort)。顾名思义,它是通过键值的各个位的值,将待排序的元素分配至某些桶中,达到排序的作用。基数排序是桶排序的扩展。

  • 算法步骤

    1. 将所有带比较的数值统一为同样的数位长度,数位较短的前面补零。
    2. 从最低为开始,依次进行一次排序,直到最高位排序完成以后,整个序列有序。
  • 动图演示

  • 代码实现

    public static void radix_sort(int[] nums) {
    
        // 得到数组中最大的数位数
        int max = Arrays.stream(nums).max().getAsInt();
        int maxLength = (max + "").length();
        System.out.println(max);
    
        // 表示10个桶
        int[][] bucket = new int[10][nums.length];
    
        // 记录每个桶放入多少个数据
        int[] count = new int[10];
    
        for (int i = 0, n = 1; i < maxLength; i++, n *= 10 ) {
            // 对每个元素的每一位进行处理
            for (int j = 0; j < nums.length; j++) {
    
                // 取出每个元素某一位的值
                int digit = nums[j] / n % 10;
    
                // 放到对应的桶中
                bucket[digit][count[digit]] = nums[j];
                count[digit]++;
            }
    
            // 按照桶的顺序 依次取出元素放回原来数组
            int index = 0;
            for (int j = 0; j < count.length; j++) {
                // 如果桶中有数据 才去取
                if (count[j] != 0) {
                    for (int k = 0; k < count[j]; k++) {
                        nums[index++] = bucket[j][k];
                    }
                }
    
                // 取出后 需要将该桶中元素个数置为0
                count[j] = 0;
            }
        }
    }
    
  • 时间复杂度:o(n*k);空间复杂度:o(n+k);稳定性:稳定

posted @ 2023-02-04 21:32  msuenb  阅读(80)  评论(0编辑  收藏  举报