八种常用的排序算法
八种常用的排序算法
对各种常用的排序算法的代码实现、时间复杂度、空间复杂度、稳定性做简要分析。
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) | 稳定 |
说明:
- 稳定性:对于相同的两个元素,经某排序算法排序后相对位置不变,此排序算法稳定,反之则不稳定。
- 以下排序的测试数据均为:[3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]
- 以下排序均按照数据依次递增的方式进行
算法的时间复杂度
度量一个程序(算法)执行时间的两种方法:
-
事后统计的方法
这种方式,要在同一台计算机的相同状态下运行,才能比较那个算法速度更快。
-
事前估算的方法
通过分析某个算法的时间复杂度来判断哪个算法更优。
-
时间频度:一个算法花费的时间与算法中语句的执行次数成正比。一个算法中的语句的执行次数称为语句频度或时间频度,记为
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
判断元素是否进行过交换,从而减少不必要的交换。
-
算法步骤
- 比较两个相邻的元素,如果第一个比第二个大,就交换这两个元素。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
-
动图演示
-
代码实现
/* * 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)
;稳定性:稳定
选择排序
选择排序是是从欲排序的数据中,按指定的规则选出某一元素,再依规定交换位置,达到排序的目的。
-
算法步骤
- 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
- 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
- 重复第二步,直到所有元素均排序完毕。
-
动图演示
-
代码实现
/* * 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)
;稳定性:不稳定
插入排序
插入排序的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。
-
算法步骤
-
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
-
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
-
-
动图演示
-
代码实现
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时,整个文件恰被分成一组,算法便终止。
-
算法步骤
-
选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
-
按增量序列个数 k,对序列进行 k 趟排序;
-
每趟排序,根据对应的增量 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)
;稳定性:不稳定
快速排序
快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法,是冒泡算法的一种改进。
-
基本思想
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,以达到整个数据变成有序序列。
-
算法步骤
- 从数列中挑出一个元素,称为 "基准"(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(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)的一个非常典型的应用。
作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:
自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
自下而上的迭代;
-
算法步骤
- 将数组
A[1,n]
排序问题分解为A[1,n/2]
和A[n/2 + 1,n]
排序问题 - 递归解决子问题得到两个有序的子数组
- 将两个有序的子数组合并为一个有序数组
- 将数组
-
动图演示
-
代码实现
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)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
-
算法步骤
- 创建一个堆 H[0……n-1];
- 把堆首(最大值)和堆尾互换;
- 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
- 重复步骤 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)。顾名思义,它是通过键值的各个位的值,将待排序的元素分配至某些桶中,达到排序的作用。基数排序是桶排序的扩展。
-
算法步骤
- 将所有带比较的数值统一为同样的数位长度,数位较短的前面补零。
- 从最低为开始,依次进行一次排序,直到最高位排序完成以后,整个序列有序。
-
动图演示
-
代码实现
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)
;稳定性:稳定