大家好,我是知识汲取者,今天要给大家带来一篇有关排序的文章,相信大家一定在工作中或生活中接触过不少有关排序的问题吧,比如:生活中,我们在课间做体操时的排队(根据身高排序)、考试的排名(根据分数排序)、报道后老师点名(根据序号排序)……工作中,我们需要对某个表按照id进行排序、或者按照姓名的首字母进行排序,这些都是很常见的,当然这些都是可以直接手动调用一个函数就能一键完成,并不需要我们去具体实现,但是大家难道就不好奇为什么我点一下就能直接实现这个排序功能呢?它是怎么实现的呢?假如让我来制定一个排序规则,我又该如何去实现它呢?如果您有这样或那样的疑惑的话,我相信您一定能有所收获的
本文适合人群:想系统了解十大基础排序算法、还没有接触或对与排序算法不熟练的读者。本文将详细介绍十大经典排序算法:①直接插入排序、②希尔排序、③直接选择排序、④堆排序、⑤冒泡排序、⑥快速排序、⑦归并排序、⑧计数排序、⑨桶排序、⑩基数排序。通过本文,您不仅能学到十大排序的思想、具体实现,同时还能了解它们各自的特点,比如:应用场景、排序算法的事件复杂度等。让我们一起冲冲冲(●ˇ∀ˇ●)(备注:本文所有代码实现均以升序为例)
PS:文中部分图片摘自菜鸟教程,如有侵权还请及时告知,笔者将立即删除
相关推荐:
- 知识汲取者的主页:欢迎参观,希望能对你有所帮助
- 算法与数据结构专栏:包含博主所有对算法的学习笔记
- 算法和数据结构导学:很适合算法初学者
- 算法题解专栏:包含博主部分算法题解
- 算法刷题站点:LeetCode | 牛客
- 代码仓库:Gitee | Github
什么是排序?
排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列按照某种规则调整为“有序”的记录序列
排序的目的是什么?
- 方便查找数据
- 有利于观察数据的规律性
……
什么是排序算法?
所谓排序算法,就是能够解决排序问题的算法,而算法(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中,排序过程需要涉及内存外数据的交换
适用于规模很大、不能一次性装入内存中的数据
比较排序:比较排序算法是通过比较元素之间的大小关系来确定它们的顺序。
非比较排序:非比较排序算法是一类不基于元素之间比较的排序算法。
对于排序的实现,这里我统一都采用对数组进行一个增序排序(从小到大进行排序),也就是数组的元素随着索引的递增而递增,而对于排序的目标数组直接采用 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));
}
每次将一个待排序的记录,按其大小插入已经排好序的子列表的适当位置,直到记录插入完成,也就说明排序已完成
直接插入排序(Straight Insertion Sort)是一种简单且常用的排序算法。它的基本思想是将待排序的元素依次插入已经排好序的部分,使得插入后仍然保持有序。
实现步骤:
示意图:
/**
* 直接插入排序
* @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)
即可
希尔排序(Shell Sort)也称缩小增量排序,是对直接插入排序的改进版本,它通过引入步长的概念,先将待排序序列分割成若干个子序列,对每个子序列进行直接插入排序,然后逐步缩小步长进行排序,最终使整个序列有序。
实现步骤:
示意图:
/**
* 希尔排序
* @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)
即可
每一趟从待排序的记录中选出最大或最小的记录,让后按顺序放在已排序的子列表的最后,直到所有记录排完
选择排序(Selection Sort)是一种简单直观的排序算法,它的基本思想是每次从未排序的部分中选取最小(或最大)的元素,放置在已排序部分的末尾,逐步构建有序序列。选择排序的时间复杂度为O(n^2),不稳定,但是由于其操作简单,对于小规模数据或部分有序的数据仍然具有一定的实用价值。
实现步骤:
每次遍历都寻找出剩余元素中的最小值,将最小值放到前面,思路很简单
示意图:
/**
* 选择排序
*
* @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])
即可
堆排序(Heap Sort)是一种基于二叉堆(Binary Heap)的排序算法。它通过构建最大堆(Max Heap)或最小堆(Min Heap)来进行排序。
如果你对堆这个数据结构不太了解到话,这个排序可能一下很难看懂,关于堆的详解可以参考这篇文章:【数据结构篇】堆,把堆搞懂了,再来看这个堆排序可能会变得很简单
实现步骤:
示意图:
初始状态:[5, 3, 9, 1, 7, 2, 8, 4, 6, 11]
构建最大堆:
n/2-1
下方是构建最大堆的示意图(红色部分是当前正在进行下浮的父节点):
最核心的步骤就是构建最大堆,最大堆已经实现了,现在只需要按照下面的步骤即可得到一个升序数组
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
进行计算得到,这个的由来也很简单,画一个草图就能理解了,关于数学证明推导可以参考文末的文章链接两两相互比较,当发现两个记录的次序相反时就进行交换,直到没有反序的记录为止
冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地遍历待排序的元素列表,比较相邻两个元素的大小,并按照规定的顺序交换它们,直到整个列表排序完成。
实现步骤:
示意图:
第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)
即可
快速排序(Quicksort)是 C.R.A.Hoare 于1962年提出一种分区交换排序,采用分治策略。快速排序的基本思想:从序列中选取一个主元,并以该主元为基准,通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
快速排序是目前已知的平均速度最快的一种排序方法,是对冒泡排序的一种改进。
主要实现步骤:
快排是实现方式有很多种,可以选取固定主元,比如:每次都选取区间第一个元素作为主元,每次都选取区间最后一个元素作为主元,或者每次随机化选取主元。后面的代码实现,我都会讲解一下三种不同的实现
温馨提示:可以通过随机化主元让快速排序的时间复杂度更加稳定,这在一些算法题中是比较有用的
示意图:
方式一:两个指针都往后遍历,每次主元选择区间最右侧元素
方式二:两个指针相向遍历,每次选取区间最右侧元素作为主元
方式三:以方式一为基础,随机化主元
图略……
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
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];
归并排序法(Merge Sort,以下简称MS)是分治法思想运用的一个典范。归并排序的基本思想:先将一个序列按照划分成一个个的有序子序列(每个子序列都只有一个元素),然后两两进行合并(前提是两个子列表都有序),形成一个新的有序列表,再重复进行两两合并,直到所有子列表合并成一个有序列表,也就说明排序完成了。通常采用二路归并排序实现,所谓的二路归并排序是指将相邻两个有序子序列合并为一个有序列表。
归并排序的特点:
主要实现步骤:
常见归并排序的实现方式
自顶向下归并排序(Top-down Merge Sort):
自底向上归并排序(Bottom-up Merge Sort):
原地归并排序:
PS:我平常用的最多的是自顶向下实现的归并排序
示意图
自顶向下的递归实现:
可以看到自顶向下是先分解后合并
自底向上的迭代实现:
可以看到自底向上是两两合并,没有进行分界
原地归并排序实现:
原地和自底向上的过程类似,通过元素交换来实现排序,没有使用辅助数组
方式一:自顶向下的递归实现
/**
* 归并排序
*
* @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++;
}
}
非比较排序是不需要将记录进行两两比较的
计数排序(Counting Sort)是一种线性时间复杂度的排序算法,适用于一定范围内的整数排序。它不基于比较,而是通过统计每个元素出现的次数,然后根据统计信息将元素排列在正确的位置上。
实现步骤:
示意图:
/**
* 计数排序
*
* @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数组是核心
arr[i] - min-1
,对应的 arr 元素的索引,减一的是因为索引是从0开始的count[index]
对应的是当前元素前还有多少个元素桶排序(Bucket Sort)是一种线性时间复杂度的排序算法,它通过将待排序元素分布到不同的桶中,并对每个桶内的元素进行排序,最后按照桶的顺序将各个桶中的元素依次合并得到有序结果。
实现步骤:
示意图:
方式一:基于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;
}
}
}
基数排序(Radix Sort)是一种非比较性的排序算法,它根据元素的位数进行排序。基数排序将待排序的元素按照个位、十位、百位等位数进行分组,并依次对每个位数进行稳定的排序操作,最终得到有序的结果。
实现步骤:
示意图:
/**
* 基数排序
*
* @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();
}
备注:
其中最为常见的排序算法有:快速排序、归并排序
以上是对十大常见排序算法的一个基础讲解,关于十大排序算法的水还是很深的,其中有很多的扩展和变种,要想完全吃透十大常见的排序算法还需要进一步学习,感兴趣的可以直接到 LeetCode 上搜索对应的排序算法题进行专项练习,一次来提高自己对这十大排序算法的熟练度,以及更加深入的理解
参考文章:
- 数据结构(Java语言版,雷军环、吴名星编著)
- 十大经典排序算法
- 十大经典排序,你全都会了吗?(附源码、动图、万字详解)
- 十大经典排序算法(动图演示)
- 堆排序(完全二叉树)最后一个非叶子节点的序号是n/2-1的原因 - 为了得到而努力 - 博客园 (cnblogs.com)
- 【算法总结】快速排序及边界问题分析_快速排序边界分析_Ethan-Code的博客-CSDN博客
- 三分钟搞懂桶排序 - 知乎 (zhihu.com)
在此致谢(^_^ゞ
内存也称主存,是计算机的内部存储器,是CPU与外存沟通的桥梁 ↩︎
外存也称辅存,是计算机的外部存储器,常见的U盘、移动硬盘都是外存 ↩︎
自顶向下:自顶向下的方法是从原始问题开始,通过将问题分解为更小的子问题,并递归地解决这些子问题,最终得到整体的解。在这种方法中,我们先处理原始问题,然后逐步地将问题分解为更小的子问题,并通过递归调用解决这些子问题。自顶向下的方法通常使用递归的方式来实现 ↩︎
自底向上:自底向上的方法是从解决最基本、最小规模的子问题开始,逐步合并得到整体的解。在这种方法中,我们先处理较小规模的子问题,然后将子问题的解合并起来,直到达到原始问题的解。自底向上的方法通常使用迭代或者循环的方式来实现 ↩︎