文章目录
- 十大排序算法
- ⛅前言
- 🌱1、排序概述
- 🌴2、排序的实现
- 🌵2.1 插入排序
- 🐳2.1.1 直接插入排序
- 算法介绍
- 算法实现
- 🐳2.1.2 希尔排序
- 算法介绍
- 算法实现
- 🌵2.2 选择排序
- 🐳2.2.1 选择排序
- 算法介绍
- 算法实现
- 🐳2.2.2 堆排序
- 算法介绍
- 算法实现
- 🌵2.3 交换排序
- 🐳2.3.1 冒泡排序
- 算法介绍
- 算法实现
- 🐳2.3.2 快速排序
- 算法介绍
- 算法实现
- 🌵2.4 归并排序
- 算法介绍
- 算法实现
- 🌵2.5 非比较排序
- 🐳2.5.1 计数排序
- 算法介绍
- 算法实现
- 🐳.5.2 桶排序
- 算法介绍
- 算法实现
- 🐳2.5.3 基数排序
- 算法介绍
- 算法实现
- 🌳总结
十大排序算法
⛅前言
大家好👋,我是知识汲取者😄,今天要给大家带来一篇有关排序的文章,相信大家一定在工作中或生活中接触过不少有关排序的问题吧,比如:生活中,我们在课间做体操时的排队(根据身高排序)、考试的排名(根据分数排序)、报道后老师点名(根据序号排序)……工作中,我们需要对某个表按照id进行排序、或者按照姓名的首字母进行排序,这些都是很常见的,当然这些都是可以直接手动调用一个函数就能一键完成,并不需要我们去具体实现,但是大家难道就不好奇为什么我点一下就能直接实现这个排序功能呢?它是怎么实现的呢?假如让我来制定一个排序规则,我又该如何去实现它呢?如果您有这样或那样的疑惑的话,我相信您一定能有所收获的😉
本文适合人群:想系统了解十大基础排序算法、还没有接触或对与排序算法不熟练的读者。本文将详细介绍十大经典排序算法:①直接插入排序、②希尔排序、③直接选择排序、④堆排序、⑤冒泡排序、⑥快速排序、⑦归并排序、⑧计数排序、⑨桶排序、⑩基数排序。通过本文,您不仅能学到十大排序的思想、具体实现,同时还能了解它们各自的特点,比如:应用场景、排序算法的事件复杂度等。让我们一起冲冲冲(●ˇ∀ˇ●)(备注:本文所有代码实现均以升序为例)
PS:文中部分图片摘自菜鸟教程,如有侵权还请及时告知,笔者将立即删除
相关推荐📢:
- 知识汲取者的主页:欢迎🥰参观,希望能对你有所帮助
- 算法与数据结构专栏:包含博主所有对算法的学习笔记
- 算法和数据结构导学:很适合算法初学者
- 算法题解专栏:包含博主部分算法题解
- 算法刷题站点:LeetCode | 牛客
- 代码仓库:Gitee | Github
🌱1、排序概述
-
什么是排序?
排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列按照某种规则调整为“有序”的记录序列
-
排序的目的是什么?
- 方便查找数据
- 有利于观察数据的规律性
……
-
什么是排序算法?
所谓排序算法,就是能够解决排序问题的算法,而算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制
-
什么是排序算法的稳定性?
排序算法的稳定性是指,一组数据经过排序后,具有相同值的两个数在排序后,仍然保持相同的次序。
比如在 [ a , b , c , d , e , f ] [a,b,c,d,e,f] [a,b,c,d,e,f] 这组数中 c c c和 e e e具有相同的值,此时 c c c在 e e e的前面;经过排序后变为 [ a , b , e , d , c , f ] [a,b,e,d,c,f] [a,b,e,d,c,f] ,此时 c c c在 e e e的后面,它们的次序发生了改变,则称该排序算法具有不稳定性;同理,如果排序后变为 [ a , b , c , e , d , f ] [a,b,c,e,d,f] [a,b,c,e,d,f] ,此时此时 c c c仍然在 e e e的前面,则称该排序算法具有稳定性。
-
排序算法的分类
备注:
-
内部排序:被排序的数据都是存储在内存1中,且排序过程也是发生在内存中的
适用于规模不是很大的数据
-
外部排序:排需的数据一部分存储在内存中,一部分存储在外存2中,排序过程需要涉及内存外数据的交换
适用于规模很大、不能一次性装入内存中的数据
-
比较排序:比较排序算法是通过比较元素之间的大小关系来确定它们的顺序。
-
非比较排序:非比较排序算法是一类不基于元素之间比较的排序算法。
-
🌴2、排序的实现
对于排序的实现,这里我统一都采用对数组进行一个增序排序(从小到大进行排序),也就是数组的元素随着索引的递增而递增,而对于排序的目标数组直接采用 arr[10]=[5, 3, 9, 1, 7, 2, 8, 4, 6, 0]
public static void main(String[] args) {
// 准备待排序的数组
int[] arr = {5, 3, 9, 1, 7, 2, 8, 4, 6, 0};
// 进行排序
// 输出排序后的数组
System.out.println(Arrays.toString(arr));
}
🌵2.1 插入排序
每次将一个待排序的记录,按其大小插入已经排好序的子列表的适当位置,直到记录插入完成,也就说明排序已完成
- 插入排序的特点
- 稳定性:插入排序是一种稳定的排序算法,即相等元素的相对顺序在排序前后保持不变。
- 原地排序:插入排序是一种原地排序算法,不需要额外的辅助空间,只需使用少量的额外空间进行元素交换。
- 时间复杂度:在平均情况和最坏情况下,插入排序的时间复杂度为O(n^2),其中n是待排序序列的长度。在最好情况下(序列已经部分有序),插入排序的时间复杂度可以达到O(n)。
- 对小规模数据效果好:相比其他复杂度更高的排序算法,插入排序在处理小规模数据时的效果较好。当待排序序列的规模较小时,插入排序的性能优于其他排序算法。
🐳2.1.1 直接插入排序
算法介绍
直接插入排序(Straight Insertion Sort)是一种简单且常用的排序算法。它的基本思想是将待排序的元素依次插入已经排好序的部分,使得插入后仍然保持有序。
-
实现步骤:
- Step1:遍历待排序数组。从第二个元素开始,依次将每个元素插入已排序序列中。
- Step2:比较与插入操作。将当前待插入元素与已排序序列中的元素逐个比较并移动位置,直至找到合适的插入位置或遍历完已排序序列。
- Step3:插入元素。将待插入元素插入到合适的位置,使得插入后的子序列仍然有序。
- Step4:重复上述步骤。继续对下一个未排序元素进行插入操作,直至所有元素都被插入到已排序序列中。
-
示意图:
算法实现
/**
* 直接插入排序
* @param arr 待排序的数组
*/
private static void directInsertionSort(int[] arr) {
// 从第2个元素开始遍历
for (int i = 1; i < arr.length; i++) {
// 待插入的元素
int key = arr[i];
int j = i - 1;
// 将当前元素与前面已经排序好的元素进行比较,直到没有发现比自己大的元素
while (j >= 0 && arr[j] > key) {
// 当发现后一个元素比前一个元素小时,用前一个元素覆盖后一个元素的值
arr[j + 1] = arr[j];
j--;
}
// 将待插入的元素放到对应的位置
arr[j + 1] = key;
}
}
备注:如果想要降序排序,只需要修改为while (j >= 0 && arr[j] < key)
即可
🐳2.1.2 希尔排序
算法介绍
希尔排序(Shell Sort)也称缩小增量排序,是对直接插入排序的改进版本,它通过引入步长的概念,先将待排序序列分割成若干个子序列,对每个子序列进行直接插入排序,然后逐步缩小步长进行排序,最终使整个序列有序。
-
实现步骤:
- Step1:确定步长序列。选择一个步长序列,一般可以使用希尔原始提出的序列(n/2, n/4, …, 1),也可以根据实际情况选择其他步长序列。
- Step2:根据步长分组。根据选定的步长,将待排序序列分成若干个子序列。
- Step3:对各个子序列进行直接插入排序。对每个子序列应用直接插入排序算法,将子序列转化为部分有序序列。
- Step4:不断缩小步长并重复上述步骤。重复步骤2和步骤3,逐步减小步长,直至步长为1,完成最后一次排序。
-
示意图:
算法实现
/**
* 希尔排序
* @param arr 待排序的数组
*/
private static void shellSort(int[] arr) {
int n = arr.length;
// 确定步长序列
int gap = n / 2;
while (gap > 0) {
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j = i;
// 对每个子序列应用直接插入排序算法
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
// 缩小步长
gap /= 2;
}
}
备注:如果想要降序排序,只需要修改为while (j >= gap && arr[j - gap] < temp)
即可
🌵2.2 选择排序
每一趟从待排序的记录中选出最大或最小的记录,让后按顺序放在已排序的子列表的最后,直到所有记录排完
- 选择排序的特点:
- 不稳定性:选择排序是一种不稳定的排序算法,即相等元素的相对顺序可能在排序过程中发生改变。
- 原地排序:选择排序是一种原地排序算法,不需要额外的辅助空间,只需使用少量的额外空间进行元素交换。
- 时间复杂度:选择排序的时间复杂度始终为O(n^2),其中n是待排序序列的长度。无论序列是否有序,每次都需要遍历剩余未排序部分来找到最小(或最大)元素。
- 大规模数据效果差:由于选择排序每次都要从剩余未排序部分中选取最小(或最大)元素,并将其放入已排序部分的末尾,因此在处理大规模乱序数据时效率较低。
🐳2.2.1 选择排序
算法介绍
选择排序(Selection Sort)是一种简单直观的排序算法,它的基本思想是每次从未排序的部分中选取最小(或最大)的元素,放置在已排序部分的末尾,逐步构建有序序列。选择排序的时间复杂度为O(n^2),不稳定,但是由于其操作简单,对于小规模数据或部分有序的数据仍然具有一定的实用价值。
-
实现步骤:
- Step1:遍历数组。从数组的第一个元素开始,依次向后遍历所有元素。
- Step2:寻找最小值。在当前遍历到的元素及其后面的元素中,找到最小的元素。
- Step3:交换位置。将最小值与当前遍历到的元素进行交换位置,即将最小值放到当前遍历的位置上。
- Step4:重复上述步骤。重复步骤2和步骤3,直到遍历完整个数组
每次遍历都寻找出剩余元素中的最小值,将最小值放到前面,思路很简单
-
示意图:
算法实现
/**
* 选择排序
*
* @param arr 待排序的数组
*/
private static void selectSort(int[] arr) {
int n = arr.length;
// 遍历数组
for (int i = 0; i < n - 1; i++) {
// 从前往后遍历,寻找最小值的索引
int minIdx = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIdx]) {
minIdx = j;
}
}
// 将最小值与当前元素交换位置
swap(arr, i, minIdx);
}
}
/**
* 交换 arr 数组中索引为 i 和索引为 j 处的元素
*/
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
备注:如果想要降序排序,只需要修改为if (arr[j] > arr[minIdx])
即可
🐳2.2.2 堆排序
算法介绍
堆排序(Heap Sort)是一种基于二叉堆(Binary Heap)的排序算法。它通过构建最大堆(Max Heap)或最小堆(Min Heap)来进行排序。
如果你对堆这个数据结构不太了解到话,这个排序可能一下很难看懂,关于堆的详解可以参考这篇文章:【数据结构篇】堆,把堆搞懂了,再来看这个堆排序可能会变得很简单
-
实现步骤:
- Step1:构建最大堆。将待排序的序列构建成一个最大堆。
- Step2:将堆顶元素与末尾元素交换。将堆顶元素(最大值)与末尾元素交换,并将交换后的末尾元素排除在堆之外。
- Step3:重新调整堆顶元素位置。通过下沉操作,使交换后的新堆顶元素重新满足最大堆的性质。
- Step4:重复步骤2和步骤3,直到堆中只剩下一个元素为止。
-
示意图:
初始状态:[5, 3, 9, 1, 7, 2, 8, 4, 6, 11]
构建最大堆:
- 定位最后一个非叶子节点,计算公式
n/2-1
- 以最后一个非叶子节点为出发点,从后往前遍历
- 不断递归的下浮元素,确保父节点永远是比子节点大的
- 最终就可以得到一个最大堆
下方是构建最大堆的示意图(红色部分是当前正在进行下浮的父节点):
最核心的步骤就是构建最大堆,最大堆已经实现了,现在只需要按照下面的步骤即可得到一个升序数组
- 循环从 n-1 到 1,每次循环表示将当前未排序的部分中最大的元素放到已排序的部分的末尾。
- 在每一次循环中,i就会减一,也就是堆的最大索引会减一,防止末尾节点参与下浮比较,然后调用
swap
函数将堆的根节点与未排序部分的最后一个元素交换位置。经过交换后,堆的根节点变成了当前未排序部分中的最大值。 - 然后,调用
heapifyDown
函数对剩余的元素进行堆调整。heapifyDown
函数会将交换后的根节点不断下沉,直到满足堆的性质。 - 经过循环操作,每次将最大值放到已排序部分的末尾,直到所有元素都被放置到已排序部分,即完成了排序。
这里我就不画图了,思路在这里,大家可以自行画一个草图
- 定位最后一个非叶子节点,计算公式
算法实现
/**
* 堆排序
*
* @param arr 待排序的数组
*/
public static void heapSort(int[] arr) {
int n = arr.length;
// 构建最大堆(从最后一个非叶子节点开始)
for (int i = n / 2 - 1; i >= 0; i--) {
heapifyDown(arr, n, i);
}
System.out.println(Arrays.toString(arr));
// 排序(从后往前遍历,不断将极大值放到堆的末尾)
for (int i = n - 1; i > 0; i--) {
// 将根节点与最后一个元素交换位置
swap(arr, 0, i);
// 对剩余的元素进行堆调整
heapifyDown(arr, i, 0);
}
}
/**
* 下浮
*
* @param arr
* @param n 数组中的元素,arr.length
* @param i 要下浮元素的索引
*/
private static void heapifyDown(int[] arr, int n, int i) {
// 父节点索引
int parent = i;
// 左节点索引
int left = 2 * i + 1;
// 右节点索引
int right = 2 * i + 2;
// 如果左子节点存在且大于父节点,则更新最大值
if (left < n && arr[left] > arr[parent]) {
parent = left;
}
// 如果右子节点存在且大于父节点或左子节点,则更新最大值
if (right < n && arr[right] > arr[parent]) {
parent = right;
}
// 如果最大值不是父节点,则交换位置并递归调整子树
if (parent != i) {
swap(arr, i, parent);
heapifyDown(arr, n, parent);
}
}
/**
* 交换 arr 数组中索引为 i 和索引为 j 处的元素
*/
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
备注:如果想要降序排序,需要改用最小堆,只需要修改if (right < n && arr[right] < arr[parent])
和if (right < n && arr[right] < arr[parent])
两处即可
几个注意点:
- 对于完全二叉树的最后一个非叶子节点,我们需要通过
n/2-1
进行计算得到,这个的由来也很简单,画一个草图就能理解了,关于数学证明推导可以参考文末的文章链接 - 这里需要理解堆的下浮操作,关于堆的下浮操作可以参考这篇文:【数据结构篇】堆
🌵2.3 交换排序
两两相互比较,当发现两个记录的次序相反时就进行交换,直到没有反序的记录为止
- 交换排序的特点:
- 交换操作:交换排序通过比较相邻元素的大小,并根据需要进行交换操作来达到排序的目的。常见的交换排序算法有冒泡排序和快速排序。
- 原地排序:交换排序通常是原地排序算法,不需要额外的辅助空间。
- 时间复杂度:冒泡排序的平均和最坏时间复杂度为O(n^2),而快速排序的平均时间复杂度为O(nlogn)。快速排序的性能通常优于冒泡排序。
🐳2.3.1 冒泡排序
算法介绍
冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地遍历待排序的元素列表,比较相邻两个元素的大小,并按照规定的顺序交换它们,直到整个列表排序完成。
-
实现步骤:
- Step1:比较。从列表的第一个元素开始,依次比较每个元素和其相邻的下一个元素。
- Step2:交换。如果当前元素大于相邻的下一个元素,则交换这两个元素的位置。
- Step3:继续向后遍历,重复执行上述步骤,直到最后一个元素。
- Step4:重复以上步骤,直到没有任何交换操作发生,即列表已排序完成。
-
示意图:
第1轮: [3, 5, 1, 7, 2, 8, 4, 6, 9, 11] 第2轮: [3, 1, 5, 2, 7, 4, 6, 8, 9, 11] 第3轮: [1, 3, 2, 5, 4, 6, 7, 8, 9, 11] 第4轮: [1, 2, 3, 4, 5, 6, 7, 8, 9, 11] 第5轮: [1, 2, 3, 4, 5, 6, 7, 8, 9, 11] .... 第9轮: [1, 2, 3, 4, 5, 6, 7, 8, 9, 11]
每一轮都会将一个最大值放到末尾,这一点类似于堆排序
算法实现
/**
* 冒泡排序
*
* @param arr 待排序的数组
*/
public static void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 如果发现左侧元素比当前元素小,则进行交换
swap(arr, j , j+1);
}
}
}
}
备注:如果想要降序排序,只需要修改为while (j >= gap && arr[j - gap] < temp)
即可
🐳2.3.2 快速排序
算法介绍
快速排序(Quicksort)是 C.R.A.Hoare 于1962年提出一种分区交换排序,采用分治策略。快速排序的基本思想:从序列中选取一个主元,并以该主元为基准,通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
快速排序是目前已知的平均速度最快的一种排序方法,是对冒泡排序的一种改进。
-
主要实现步骤:
- Step1:选取主元。一般是选取数组的第0个元素或最后一个作为主元
- Step2:划分数组。遍历数组,根据主元将数组划分成两个子数组,如果是增序排序,则左侧的子数组恒大于右侧子数组;如果是降序排序,则左侧的子数组恒小于右侧的子数组
- Step3:递归。Step1和Step2递归进行
快排是实现方式有很多种,可以选取固定主元,比如:每次都选取区间第一个元素作为主元,每次都选取区间最后一个元素作为主元,或者每次随机化选取主元。后面的代码实现,我都会讲解一下三种不同的实现
温馨提示:可以通过随机化主元让快速排序的时间复杂度更加稳定,这在一些算法题中是比较有用的
-
示意图:
方式一:两个指针都往后遍历,每次主元选择区间最右侧元素
方式二:两个指针相向遍历,每次选取区间最右侧元素作为主元
方式三:以方式一为基础,随机化主元
图略……
算法实现
PS:我个人比较喜欢方式一,方式二可能更加简介,但是方式二的注意点比较多,比如边界问题、能否取等问题
-
方式一:两个指针都往后遍历,每次主元选择区间最右侧元素
/** * 快速排序 * * @param arr 待排序的数组 */ private static void quickSort(int[] arr) { if (arr == null || arr.length == 0) { return; } quickSort(arr, 0, arr.length - 1); } /** * 快速排序 * * @param arr 待排序的数组 * @param left 待划分区间的左边界 * @param right 待划分区间的右边界 */ private static void quickSort(int[] arr, int left, int right) { if (left >= right) { // 区间只剩一个元素,停止划分 return; } // 将数组划分为两部分,获取划分点位置 int pivotIndex = partition(arr, left, right); // 对划分的两部分进行递归快排 quickSort(arr, left, pivotIndex - 1); quickSort(arr, pivotIndex + 1, right); } /** * 划分区间 * * @param arr 待排序的数组 * @param left 待划分区间的左边界 * @param right 待划分区间的右边界 * @return */ private static int partition(int[] arr, int left, int right) { // 选择区间最右侧元素作为主元 int pivot = arr[right]; // 双指针从前往后遍历,划分区间(左侧区间 < 主元,右侧区间 >= 主元) int i = left - 1; for (int j = left; j < right; j++) { if (arr[j] < pivot) { // 如果当前元素比主元小就放到 i+1 的左侧 swap(arr, ++i, j); } } // 将主元放到分界点,然后返回主元索引 swap(arr, i + 1, right); return i + 1; } /** * 交换 arr 数组中索引为 i 和索引为 j 处的元素 */ private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }
-
方式二:两个指针相向遍历,每次选取区间最右侧元素作为主元
/** * 快速排序 * * @param arr 待排序的数组 */ private static void quickSort(int[] arr) { if (arr == null || arr.length == 0) { return; } quickSort(arr, 0, arr.length - 1); } /** * 快速排序 * * @param arr 待排序的数组 * @param left 待划分区间的左边界 * @param right 待划分区间的右边界 */ private static void quickSort(int[] arr, int left, int right) { if (left >= right) { // 区间只剩一个元素,停止划分 return; } // 将数组划分为两部分,获取划分点位置 int demarcation = partition(arr, left, right); // 对划分的两部分进行递归快排 quickSort(arr, left, demarcation-1); quickSort(arr, demarcation, right); } /** * 划分区间 * * @param arr 待排序的数组 * @param left 待划分区间的左边界 * @param right 待划分区间的右边界 * @return */ private static int partition(int[] arr, int left, int right) { // 选择区间最右侧元素作为主元 int pivot = arr[right]; // 双指针相向遍历,划分区间(左侧区间 <= 主元,右侧区间 >= 主元) int i = left - 1; int j = right + 1; while (i < j) { // i从前往后遍历,寻找大于等于主元的元素 while (arr[++i] < pivot) ; // j从后往前遍历,寻找小于主元的元素 while (arr[--j] > pivot) ; if (i < j) { // 如果 i<j,说明区间中不止一个元素,需要进行交换 swap(arr, i, j); } } return i; } /** * 交换 arr 数组中索引为 i 和索引为 j 处的元素 */ private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }
注意点:
-
指针移动判断是否取等?
while (arr[++i] < pivot) ; while (arr[--j] > pivot) ;
看代码就知道,肯定是不能取等的,因为如果取等,可能会出现所有的数都相等,那么指针的移动就会出界,从而报索引越界异常。所以得到的区间肯定是 左侧区间 <= 主元,右侧区间 >= 主元
-
移动指针是否在判断之前?
在1.中,我们就已经知道了左侧和右侧区间都可能等于主元,如果我们先判断后移动指针,此时做右区间各有一个等于主元的值,那么此时就陷入了死循环,为了避免这种情况,我们就采用先移动后判断
-
为什么返回的分界点 i,必须放在右侧区间?
这个其实看我画的那个示意图就能够明白了,因为区间划分完成后 i 索引对应的元素可能是大于等于主元的,我们如果将 i 放到左侧区间,那么就不满足左侧区间中的元素全部小于等于主元,这就导致最终快排是无法成功的,可能会一直陷入死循环
-
以上3点是方式二的一些注意点,也解释了为什么要这样写
-
方式三:随机化选取主元(方式二实现)
随机化快速排序,能够在一定程度上让排序的时间更加稳定,不至于因为固定选取主元导致出现极端的情况。
具体实现可以基于前面两种方式,只是将主元进行了随机化:我们可以通过随机选取一个索引,然后将随机的主元与最左侧元素进行交换,这样就又变成了固定主元了。
以下是核心代码:
// 随机选择主元元素,并将其交换到区间右侧 int randomIndex = random.nextInt(right - left + 1) + left; swap(arr, randomIndex, right); int pivot = arr[right];
🌵2.4 归并排序
算法介绍
归并排序法(Merge Sort,以下简称MS)是分治法思想运用的一个典范。归并排序的基本思想:先将一个序列按照划分成一个个的有序子序列(每个子序列都只有一个元素),然后两两进行合并(前提是两个子列表都有序),形成一个新的有序列表,再重复进行两两合并,直到所有子列表合并成一个有序列表,也就说明排序完成了。通常采用二路归并排序实现,所谓的二路归并排序是指将相邻两个有序子序列合并为一个有序列表。
-
归并排序的特点:
- 分治思想:归并排序采用分治思想,将待排序序列不断拆分成更小的子序列,然后将子序列排序并合并成一个有序序列。常见的归并排序算法有自顶向下的递归实现和自底向上的迭代实现。
- 稳定性:归并排序是一种稳定的排序算法,相等元素的相对顺序在排序前后保持不变。
- 非原地排序:归并排序需要额外的辅助空间来存储临时的合并结果,因此它不是原地排序算法。
- 时间复杂度:归并排序的平均和最坏时间复杂度都为O(nlogn),其中n是待排序序列的长度。它的性能稳定且较为优秀。
-
主要实现步骤:
- Step1:划分。通常是使用递归将一个序列按照 n / 2 n/2 n/2划分成一个个的有序的子序列
- Step2:合并。将相邻的子序列两两合并成一个新的有序子序列
-
常见归并排序的实现方式
-
自顶向下归并排序(Top-down Merge Sort):
- 也被称为递归归并排序。
- 使用分治法的思想,将数组不断地分割成较小的子数组,直到每个子数组只包含一个元素。
- 然后按照从底部到顶部的顺序,逐步对已经分割的子数组进行合并,形成有序的更大的子数组,最终得到完全排序的数组。
- 递归实现方式,通过递归调用和合并操作来完成排序。
自底向上归并排序(Bottom-up Merge Sort):
- 直接使用迭代的方式,从单个元素开始,两两合并相邻的子数组。
- 每一次合并都会形成一个更大的有序子数组,直到最后合并成一个完全排序的数组。
- 不需要递归调用,而是通过循环迭代依次合并子数组。
- 具体过程是先两个两个地合并相邻子数组,然后四个四个地合并,依次类推,直到整个数组排序完成。
-
原地归并排序:
- 首先,将待排序数组分割为多个子数组,直到每个子数组只有一个元素。
- 然后,从单个元素开始,两两合并相邻的子数组,并将结果原地存回到原始数组中。这里的关键是通过交换元素的位置,实现原地合并。
- 重复上述合并操作,每次合并的子数组大小翻倍,直到整个数组完全有序。
PS:我平常用的最多的是自顶向下实现的归并排序
-
-
示意图
-
自顶向下的递归实现:
- 初始数组:5, 3, 9, 1, 7, 2, 8, 4, 6, 0
- 递归分解成子数组:5, 3, 9, 1, 7 和 2, 8, 4, 6, 0
- 继续分解子数组:5, 3 和 9, 1, 7,以及 2, 8 和 4, 6, 0
- 继续分解子数组:5 和 3,以及 9 和 1, 7,和 2 和 8,以及 4 和 6,以及 0
- 开始归并操作:
- 归并 5 和 3:3, 5
- 归并 9 和 1, 7:1, 7, 9
- 归并 2 和 8:2, 8
- 归并 4 和 6:4, 6
- 归并 0:0
- 继续归并:
- 归并 3, 5 和 1, 7, 9:1, 3, 5, 7, 9
- 归并 2, 8 和 4, 6:2, 4, 6, 8
- 最终归并:
- 归并 1, 3, 5, 7, 9 和 2, 4, 6, 8:1, 2, 3, 4, 5, 6, 7, 8, 9
可以看到自顶向下是先分解后合并
- 初始数组:5, 3, 9, 1, 7, 2, 8, 4, 6, 0
-
自底向上的迭代实现:
- 初始数组:5, 3, 9, 1, 7, 2, 8, 4, 6, 0
- 第一轮迭代:
- 归并 [5] 和 [3]:3, 5
- 归并 [9] 和 [1]:1, 9
- 归并 [7] 和 [2]:2, 7
- 归并 [8] 和 [4]:4, 8
- 归并 [6] 和 [0]:0, 6
- 第二轮迭代:
- 归并 [3, 5] 和 [1, 9]:1, 3, 5, 9
- 归并 [2, 7] 和 [4, 8]:2, 4, 7, 8
- 归并 [0, 6]:0, 6
- 第三轮迭代:
- 归并 [1, 3, 5, 9] 和 [2, 4, 7, 8]:1, 2, 3, 4, 5, 7, 8, 9
- 归并 [0, 6]:0, 6
- 最终归并:
- 归并 [1, 2, 3, 4, 5, 7, 8, 9] 和 [0, 6]:0, 1, 2, 3, 4, 5, 6, 7, 8, 9
可以看到自底向上是两两合并,没有进行分界
-
原地归并排序实现:
- 第一次合并:
- 归并 [5] 和 [3]:3, 5
- 归并 [9] 和 [1]:1, 9
- 归并 [7] 和 [2]:2, 7
- 归并 [8] 和 [4]:4, 8
- 归并 [6] 和 [0]:0, 6
- 第二次合并:
- 第二次合并:
- 归并 [3, 5] 和 [1, 9]:1, 3, 5, 9
- 归并 [2, 7] 和 [4, 8]:2, 4, 7, 8
- 归并 [0, 6]:0, 6
- 第三次合并:
- 归并 [1, 3, 5, 9] 和 [2, 4, 7, 8]:1, 2, 3, 4, 5, 7, 8, 9
- 归并 [0, 6]:0, 6
- 最终合并:
- 归并 [1, 2, 3, 4, 5, 7, 8, 9] 和 [0, 6]:0, 1, 2, 3, 4, 5, 6, 7, 8, 9
原地和自底向上的过程类似,通过元素交换来实现排序,没有使用辅助数组
- 第一次合并:
-
算法实现
-
方式一:自顶向下的递归实现
/** * 归并排序 * * @param arr 待排序的数组 */ private static void mergeSort(int[] arr) { if (arr == null || arr.length == 0){ return; } mergeSort(arr, 0, arr.length-1); } /** * 归并排序 * * @param arr 待排序的数组 * @param left * @param right */ private static void mergeSort(int[] arr, int left, int right) { if (left >= right){ // 区间只剩一个元素,无需分解 return; } int mid = (left + right) / 2; // 对左半部分进行递归排序 mergeSort(arr, left, mid); // 对右半部分进行递归排序 mergeSort(arr, mid + 1, right); // 合并左右两部分 merge(arr, left, mid, right); } /** * 合并 * * @param arr * @param left * @param mid * @param right */ public static void merge(int[] arr, int left, int mid, int right) { // 临时数组用于存储合并后的结果 int[] temp = new int[right - left + 1]; // 左区间起始索引,用于遍历区间 int i = left; // 右区间起始位置,用于遍历右区间 int j = mid + 1; // 临时数组的起始索引,用于遍历临时数组 int k = 0; // 遍历左右子区间,进行合并 while (i <= mid && j <= right) { // 将左右区间中的较小值放入临时数组中 if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } // 将左区间剩余元素复制到临时数组 while (i <= mid) { temp[k++] = arr[i++]; } // 将右区间剩余元素复制到临时数组 while (j <= right) { temp[k++] = arr[j++]; } // 将临时数组中的元素复制回原数组 for (int m = 0; m < k; m++) { arr[left + m] = temp[m]; } }
备注:关于数组的合并数组拷贝,可以替换成
System.arraycopy(temp, 0, arr, left, k)
,注意不能使用Arrays.copyof()
-
方式二:自底向上的迭代实现
/** * 归并排序 * * @param arr 待排序的数组 */ private static void mergeSort(int[] arr) { if (arr == null || arr.length == 0){ return; } int n = arr.length; // 遍历不同大小的子数组进行两两合并 for (int i = 1; i < n; i *= 2) { for (int j = 0; j < n - i; j += i * 2) { merge(arr, j, j + i - 1, Math.min(j + i * 2 - 1, n - 1)); } } } /** * 合并 * * @param arr * @param left * @param mid * @param right */ public static void merge(int[] arr, int left, int mid, int right) { // 临时数组用于存储合并后的结果 int[] temp = new int[right - left + 1]; // 左区间起始索引,用于遍历区间 int i = left; // 右区间起始位置,用于遍历右区间 int j = mid + 1; // 临时数组的起始索引,用于遍历临时数组 int k = 0; // 遍历左右子区间,进行合并 while (i <= mid && j <= right) { // 将左右区间中的较小值放入临时数组中 if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } // 将左区间剩余元素复制到临时数组 while (i <= mid) { temp[k++] = arr[i++]; } // 将右区间剩余元素复制到临时数组 while (j <= right) { temp[k++] = arr[j++]; } // 将临时数组中的元素复制回原数组 for (int m = 0; m < k; m++) { arr[left + m] = temp[m]; } }
可以看到两者基本上是类似的,特别是合并代码基本上是一模一样的,只是对于区间的划分不同,迭代对于区间的划分可能没有递归那么好理解,迭代:
for (int i = 1; i < n; i *= 2) { for (int j = 0; j < n - i; j += i * 2) { merge(arr, j, j + i - 1, Math.min(j + i * 2 - 1, n - 1)); } }
- 第一个循环是控制步长,也就是区间的大小,区间都是两两合并,所以
i*=2
; - 第二个循环是遍历起始索引,一个区间的元素是
i*2
,那么下一个区间的起始元素就是j+=i*2
- 但是对于最后一个区间,元素的个数可能不足,所以需要进行一个防止越界的判断,也就是
Math.min(j + i * 2 - 1, n - 1)
- 第一个循环是控制步长,也就是区间的大小,区间都是两两合并,所以
-
方式三:原地归并排序
/** * 归并排序 * * @param arr 待排序的数组 */ private static void mergeSort(int[] arr) { if (arr == null || arr.length == 0){ return; } mergeSort(arr, 0, arr.length-1); } /** * 归并排序 * * @param arr 待排序的数组 * @param left * @param right */ private static void mergeSort(int[] arr, int left, int right) { if (left >= right){ // 区间只剩一个元素,无需分解 return; } int mid = (left + right) / 2; // 对左半部分进行递归排序 mergeSort(arr, left, mid); // 对右半部分进行递归排序 mergeSort(arr, mid + 1, right); // 合并左右两部分 merge(arr, left, mid, right); } /** * 合并 * * @param arr * @param left * @param mid * @param right */ public static void merge(int[] arr, int left, int mid, int right) { // 左区间起始索引,用于遍历区间 int i = left; // 右区间起始位置,用于遍历右区间 int j = mid + 1; // 遍历左右子区间,进行合并 while (i <= mid && j <= right) { if (arr[i] > arr[j]) { // 将右半部分的元素移动到左半部分的末尾 int temp = arr[j]; for (int k = j; k > i; k--) { arr[k] = arr[k - 1]; } arr[i] = temp; // 更新指针和中间位置 mid++; j++; } i++; } }
🌵2.5 非比较排序
非比较排序是不需要将记录进行两两比较的
- 非比较排序的特点:
- 不依赖比较操作:非比较排序算法不基于元素之间的比较操作,而是利用其他技巧实现排序。常见的非比较排序算法有计数排序、桶排序和基数排序。
- 时间复杂度:非比较排序算法通常具有较好的时间复杂度。计数排序、桶排序和基数排序的平均和最坏时间复杂度都为O(n+k),其中k是数据的取值范围。
- 适用性局限:非比较排序算法通常对输入数据有一定的限制,比如要求数据是整数或字符串,并且取值范围不过大。在满足条件的情况下,非比较排序算法可以获得更好的性能。
- 稳定性:非比较排序算法中的计数排序和基数排序是稳定的,而桶排序在某些实现方式下可以是稳定的。
🐳2.5.1 计数排序
算法介绍
计数排序(Counting Sort)是一种线性时间复杂度的排序算法,适用于一定范围内的整数排序。它不基于比较,而是通过统计每个元素出现的次数,然后根据统计信息将元素排列在正确的位置上。
-
实现步骤:
- Step1:计算max、min、range。找出待排序数组中的最大值max和最小值min,确定统计数组的大小range。
- Step2:构造count数组。①记录每个元素出现的次数。遍历待排序数组,统计每个元素的出现次数,并将统计结果保存到count数组中。②求前缀和。计算每个元素在排序结果中的位置,即前缀和,使得count[i]表示小于等于元素i的元素个数。
- Step3:构造temp数组。创建一个与待排序数组大小相同的辅助数组temp,用于存储排序结果。
- Step4:映射元素。从后向前遍历待排序数组,根据count数组确定每个元素在排序结果中的位置,并将元素放入temp数组中。
- Step5:拷贝。将temp数组复制回原始数组,完成排序。
-
示意图:
算法实现
/**
* 计数排序
*
* @param arr 待排序的数组
*/
public static void countingSort(int[] arr) {
int n = arr.length;
// 获取数组中的最大值和最小值
int max = Arrays.stream(arr).max().getAsInt();
int min = Arrays.stream(arr).min().getAsInt();
// 计算出数据的范围
int range = max - min + 1;
// 计数数组,用于记录每一个元素出现的次数
int[] count = new int[range];
// 结果数组,用于临时存储排序的结果
int[] temp = new int[n];
// 统计每个元素出现的次数
for (int num : arr) {
// num-min的目的是为了缩小范围,范围从0开始,避免浪费空间
count[num - min]++;
}
// 计算前缀和
for (int i = 1; i < range; i++) {
count[i] += count[i - 1];
}
// 根据count数组确定每个元素在排序结果中的位置
for (int i = n - 1; i >= 0; i--) {
// 通过索引定位到对应的元素,让后将对应的元素映射到对应的位置
int index = count[arr[i] - min] - 1;
temp[index] = arr[i];
// 计数器数组对应索引的元素已经被用过一次了,计数-1
count[arr[i] - min]--;
}
// 将临时数组复制回原始数组
System.arraycopy(temp, 0, arr, 0, n);
}
count数组是核心
- count的索引-1 ,
arr[i] - min-1
,对应的 arr 元素的索引,减一的是因为索引是从0开始的 - count的值
count[index]
对应的是当前元素前还有多少个元素
🐳.5.2 桶排序
算法介绍
桶排序(Bucket Sort)是一种线性时间复杂度的排序算法,它通过将待排序元素分布到不同的桶中,并对每个桶内的元素进行排序,最后按照桶的顺序将各个桶中的元素依次合并得到有序结果。
-
实现步骤:
- Step1:确定桶的数量和范围。根据待排序数据的特点,确定需要的桶的数量。一般来说,桶的数量可以根据数据量的大小进行设置,范围可以根据数据的最大值和最小值来计算。
- Step2:将待排序数组元素分配到不同的桶中。遍历待排序数组,根据元素的大小将其放入相应的桶中。
- Step3:对每个桶内的元素进行排序。可以使用其他排序算法(如插入排序、快速排序等)对每个非空桶进行排序,或者递归地使用桶排序对每个非空桶进行排序。
- Step4:合并桶内的元素。将每个非空桶中的元素按照顺序依次合并到一个临时数组中。
-
示意图:
算法实现
-
方式一:基于
Collections.sort()
实现的桶排序这种方式严格意义来说可能属于比较排序,因为对于桶中的元素是直接使用了其它的比较排序算法,比如:插入排序、快速排序等等方式,通过将桶中的元素排序完之后再进行合并
/** * 桶排序 * * @param arr 待排序的数组 */ public static void bucketSort(int[] arr) { int n = arr.length; if (n == 0) { return; } // 计算最小值和最大值 int min = arr[0]; int max = arr[0]; for (int num : arr) { if (num < min) { min = num; } else if (num > max) { max = num; } } // 计算桶的数量 = (最大元素-最小元素) / 桶的大小 + 1 int bucketCount = (max - min) / n + 1; // 创建桶并将元素分配到桶中 ArrayList<ArrayList<Integer>> buckets = new ArrayList<>(bucketCount); for (int i = 0; i < bucketCount; i++) { buckets.add(new ArrayList<>()); } for (int num : arr) { int bucketIndex = (num - min) / n; buckets.get(bucketIndex).add(num); } // 对每个非空桶进行排序,然后合并结果 int index = 0; for (ArrayList<Integer> bucket : buckets) { if (bucket.isEmpty()) { continue; } // 对桶中的元素进行排序(可以使用其他排序算法对桶内元素进行排序) Collections.sort(bucket); for (int num : bucket) { arr[index++] = num; } } }
-
方式二:基于递归实现的桶排序(自顶向上3的递归)
这种方式算是严格意义上的桶排序了,完全没有进行元素之间的比较,而是直接通过不断将元素划分成不同的桶,最终划分成一个元素之后,所有的元素都按照一定顺序分布在不同的桶中,最后再进行合并即可完成排序
备注:其实之前的计数排序本质也是桶排序,它是自底向上4的桶排序
/** * 桶排序 * * @param arr 待排序的数组 */ public static void bucketSort(int[] arr){ bucketSort(arr, arr.length); } /** * 桶排序 * @param arr 待排序的数组 * @param bucketCount 桶的数量 */ public static void bucketSort(int[] arr, int bucketCount) { if (arr == null || arr.length == 0) { return; } // 查找最小值和最大值(这种方式比 stream 流性能更好) int min = Integer.MAX_VALUE; int max = Integer.MIN_VALUE; for (int num : arr) { min = Math.min(min, num); max = Math.max(max, num); } // 计算桶的大小和范围 int bucketSize = (max - min) / bucketCount + 1; // 创建桶列表 List<List<Integer>> buckets = new ArrayList<>(); for (int i = 0; i < bucketCount; i++) { buckets.add(new ArrayList<>()); } // 分配元素到不同的桶中 for (int num : arr) { int index = (num - min) / bucketSize; buckets.get(index).add(num); } // 递归地对每个非空桶进行排序 for (List<Integer> bucket : buckets) { if (!bucket.isEmpty()) { int[] bucketArr = bucket.stream() .mapToInt(Integer::intValue) .toArray(); bucketSort(bucketArr, bucketCount); // 将排序后的结果放回桶中 for (int i = 0; i < bucketArr.length; i++) { bucket.set(i, bucketArr[i]); } } } // 合并桶的结果得到最终的有序数组 int index = 0; for (List<Integer> bucket : buckets) { for (int num : bucket) { arr[index++] = num; } } }
🐳2.5.3 基数排序
算法介绍
基数排序(Radix Sort)是一种非比较性的排序算法,它根据元素的位数进行排序。基数排序将待排序的元素按照个位、十位、百位等位数进行分组,并依次对每个位数进行稳定的排序操作,最终得到有序的结果。
-
实现步骤:
- Step1:确定待排序数组中的最大值,并确定其位数。这将用于确定排序的轮数。
- Step2:创建 10 个桶,每个桶对应一个数字(0-9)。
- Step3:从最低有效位(个位)开始,按照当前位的值将所有元素分配到相应的桶中。
- Step4:将各个桶中的元素按照顺序合并为一个新的数组。
- Step5:重复步骤 3 和步骤 4,直到遍历完最高有效位(最高位)。
-
示意图:
算法实现
/**
* 基数排序
*
* @param arr 待排序的数组
*/
public static void radixSort(int[] arr) {
if (arr == null || arr.length == 0){
return;
}
// 获取数组中的最大值
int max = Integer.MIN_VALUE;
for (int num : arr) {
max = Math.max(max, num);
}
// 确定最大值的位数
int digits = (int) Math.log10(max) + 1;
// 创建 10 个桶
List<List<Integer>> buckets = new ArrayList<>();
for (int i = 0; i < 10; i++) {
buckets.add(new ArrayList<>());
}
// 进行基数排序
for (int i = 0; i < digits; i++) {
// 分配到桶中
for (int num : arr) {
// 计算num的第(i+1)位上的数值,并将该数存入对应的桶中
int digit = (num / (int) Math.pow(10, i)) % 10;
buckets.get(digit).add(num);
}
// 合并桶中的元素
int index = 0;
for (List<Integer> bucket : buckets) {
for (int num : bucket) {
arr[index++] = num;
}
bucket.clear();
}
}
}
注意:进行桶合并时,一定要按照索引顺序从前往后遍历获取元素,因为通过一位的合并,最小的先入桶,也就是桶中的元素索引越小,值越小
备注:如果想要降序排序,只需要将合并桶元素的代码修改为:
for (int j = buckets.size() - 1; j >= 0; j--) {
List<Integer> bucket = buckets.get(j);
for (int k = bucket.size() - 1; k >= 0; k--) {
arr[index++] = bucket.get(k);
}
bucket.clear();
}
🌳总结
备注:
- n:数据规模,也就是参与排序的元素个数
- k:"桶"的个数
- In-place:占用常数内存,不占用额外内存
- Out-place:占用额外内存
其中最为常见的排序算法有:快速排序、归并排序
以上是对十大常见排序算法的一个基础讲解,关于十大排序算法的水还是很深的,其中有很多的扩展和变种,要想完全吃透十大常见的排序算法还需要进一步学习,感兴趣的可以直接到 LeetCode 上搜索对应的排序算法题进行专项练习,一次来提高自己对这十大排序算法的熟练度,以及更加深入的理解
参考文章:
- 数据结构(Java语言版,雷军环、吴名星编著)
- 十大经典排序算法
- 十大经典排序,你全都会了吗?(附源码、动图、万字详解)
- 十大经典排序算法(动图演示)
- 堆排序(完全二叉树)最后一个非叶子节点的序号是n/2-1的原因 - 为了得到而努力 - 博客园 (cnblogs.com)
- 【算法总结】快速排序及边界问题分析_快速排序边界分析_Ethan-Code的博客-CSDN博客
- 三分钟搞懂桶排序 - 知乎 (zhihu.com)
在此致谢(^_^ゞ
内存也称主存,是计算机的内部存储器,是CPU与外存沟通的桥梁 ↩︎
外存也称辅存,是计算机的外部存储器,常见的U盘、移动硬盘都是外存 ↩︎
自顶向下:自顶向下的方法是从原始问题开始,通过将问题分解为更小的子问题,并递归地解决这些子问题,最终得到整体的解。在这种方法中,我们先处理原始问题,然后逐步地将问题分解为更小的子问题,并通过递归调用解决这些子问题。自顶向下的方法通常使用递归的方式来实现 ↩︎
自底向上:自底向上的方法是从解决最基本、最小规模的子问题开始,逐步合并得到整体的解。在这种方法中,我们先处理较小规模的子问题,然后将子问题的解合并起来,直到达到原始问题的解。自底向上的方法通常使用迭代或者循环的方式来实现 ↩︎