目录
1 概念
1.1 排序的定义
排序就是把一组数据元素(如数字、字符、对象等)按照某个特定的顺序(如升序或降序)进行重新排列的过程。
排序的依据通常是数据中的某个或某些关键字(可以重复),通过比较这些关键字的大小,将数据把原本无序的数据重新整合成有序序列。例如,将无序数列 [5, 3, 8, 1, 2] 通过排序转换为升序数列[1, 2, 3, 5, 8],整个过程就像是将杂乱的拼图块按规则拼接整齐。排序的目的是为了方便后续的数据处理、筛选和计算,从而提高计算效率。
1.2 排序的稳定性
如果在待排序的序列中,存在多个具有相同关键字的记录,且在排序后这些记录的相对次序保持不变,则称这种排序算法是稳定的;否则为不稳定的。例如,如果原序列中两个相同的元素A1和A2、B1和B2,A1在A2之前、B1在B2之前,排序后A1仍然在A2之前、B1在B2之前,则该排序算法是稳定的。如果在排序之后A2出现在了A1之前,那就意味着该排序算法在处理相同关键字记录时,改变了它们原有的相对次序,便认为这种排序算法是不稳定的。
1.3 排序的分类
内部排序:所有数据都在内存中进行排序,适用于数据量较小的情况。
外部排序:数据量过大,无法全部放入内存,需要在排序过程中借助外部存储(如磁盘)进行数据的读写。
比较类排序算法:通过比较元素的大小来决定它们的相对次序,时间复杂度通常为 O(nlogn) 或更高。
非比较类排序算法:不通过比较元素大小来决定次序,时间复杂度通常为 O(n)。
1.4 排序的评价标准
时间复杂度:衡量排序算法执行所需的时间成本,主要通过统计关键字的比较次数以及元素的移动次数来进行衡量。
空间复杂度:排序过程中除了存储原始数据之外,额外需要的存储空间大小。
稳定性:排序算法是否保持相同关键字的相对次序不变
1.5 常见排序算法
排序算法 | 最好情况时间复杂度 | 最坏情况时间复杂度 | 平均情况时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
希尔排序 | O(n) | O(n²) | O(n^1.3) | O(1) | 不稳定 |
快速排序 | O(n log n) | O(n²) | O(n log n) | O(log n) ~ O(n) | 不稳定 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
计数排序 | O(n + k) | O(n + k) | O(n + k) | O(n + k) | 稳定 |
桶排序 | O(n) | O(n²) | O(n + k) | O(n + m) | 稳定 |
基数排序 | O(n * k) | O(n * k) | O(n * k) | O(n + k) | 稳定 |
计数排序:其中 n 是数组的长度,k 是元素的范围
桶排序:K为桶的数量,m是所有桶中元素数量的最大值
基数排序:k为待排序数据的最大位数
2 插入排序
插入排序(Insertion Sort)是一种简单直观的排序算法,它的基本思想是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
2.1 直接插入排序
直接插入排序(Straight Insertion Sort)是插入排序的一种具体实现方式,也是最基础的插入排序算法。其核心思想是通过逐步将未排序部分的元素插入到已排序部分的正确位置,从而构建完整的有序序列。
2.1.1 直接插入排序的思想(升序)
划分有序和无序部分:初始时,将数组的第一个元素视为已排序部分,其余元素为未排序部分。
逐个插入:从未排序部分取出第一个元素(记为 tmp),与已排序部分的元素从后向前逐一比较,找到 tmp 应该插入的位置。
移动元素并插入:在比较过程中,如果已排序部分的元素大于 tmp,则将该元素向后移动一位,为 tmp 腾出空间。直到找到 tmp 的正确位置,将其插入。
重复操作:重复上述步骤,直到所有未排序部分的元素都被插入到已排序部分,最终得到一个完整的有序数组。
2.1.2 实现(升序)
public void insertSort(int[] array){
//i 表示当前要插入的元素的前一个位置(即已排序部分的末尾)
for (int i = 0; i < array.length - 1; i++) {
int emd = i; //emd 表示当前已排序部分的最后一个元素的下标
int tmp = array[i+1]; // tmp 是要插入的元素的值,保存它的值是为了在元素右移时不被覆盖。
//内层循环,从已排序部分的末尾向前查找插入位置
while (emd >= 0){
//如果当前元素大于插入元素 则进行右移 并继续比较前一个元素
if(array[emd] > tmp){
array[emd + 1] = array[emd];
emd--; // 继续比较前一个元素
} else {
break; // 如果当前元素小于或等于要插入的元素,则找到插入位置,退出循环
}
}
// 将 tmp 插入到正确的位置
array[emd+1] = tmp;
}
}
2.1.2 总结
稳定性:
- 直接插入排序是稳定排序
- 直接插入排序在数据量较小或接近有序时表现良好,但在数据量较大或完全无序时性能较差。
时间复杂度分析:
- 最好情况:当输入数组已经有序时,每次插入操作只需要比较一次,无需移动元素,时间复杂度为 O(n)
- 最坏情况:当输入数组完全逆序时,每次插入操作需要比较和移动所有已排序的元素,时间复杂度为 O(n²)
- 平均情况:对于随机排列的数组,时间复杂度通常为 O(n²)
空间复杂度:
- 直接插入排序的空间复杂度为 O(1),只需要一个额外的临时变量来存储当前要插入的元素。
注意事项:
- 如果一个本身就是稳定的排序 那么 他可以被实现为不稳定的排序,但是 如果一个排序本身就是不稳定的排序 那么他就不可能被实现为稳定的排序。
- 例如上述直接插入排序,当把array[end]>tmp改为array[end]>=tmp就是不稳定排序
插入排序的核心在于逐步构建有序序列,适用于少量数据的排序以及部分有序的数据。
2.2 希尔插入排序
希尔排序(Shell Sort)其核心思想是缩小增量排序,也称为递减增量排序,是一种基于插入排序的高效排序算法,它通过将数组分成若干子序列进行排序,逐步缩小子序列的范围,最终实现对整个数组的排序。
2.2.1 希尔排序的核心思想
分组排序:首先选择一个增量(gap),将数组分成若干子序列,每个子序列包含距离为 gap 的元素。对每个子序列进行直接插入排序,使得每个子序列内部有序
逐步缩小增量:重复上述过程,逐步缩小增量(通常将 gap 减半),直到增量为 1。
当增量为 1 时,整个数组被分成一个子序列,进行一次完整的直接插入排序,此时数组已经接近有序,排序效率较高
最终排序:当 gap = 1 时,希尔排序退化为直接插入排序,但由于之前的预排序,数组已经接近有序,因此排序速度较快
2.2.2实现(升序)
public void shellSort(int[] array){
int grep = array.length; // 初始化增量(gap),通常为数组长度的一半
// 当增量大于1时,继续分组排序;当增量为1时,进行最后一次插入排序
while (grep > 1){
grep /= 2; // 缩小增量,通常将增量减半
//i 表示当前要插入的元素的前一个位置(即已排序部分的末尾)
for (int i = 0; i < array.length - grep; i++) {
int emd = i; //emd 表示当前已排序部分的最后一个元素的下标
int tmp = array[i+grep]; // tmp 是要插入的元素的值,保存它的值是为了在元素右移时不被覆盖。
//内层循环,从已排序部分的末尾向前查找插入位置
while (emd >= 0){
//如果当前元素大于插入元素 则进行右移 并继续比较前一个元素
if(array[emd] > tmp){
array[emd + grep] = array[emd];
emd-=grep; // 继续比较前一个元素
} else {
break; // 如果当前元素小于或等于要插入的元素,则找到插入位置,退出循环
}
}
// 将 tmp 插入到正确的位置
array[emd+grep] = tmp;
}
}
}
2.2.3 总结
- 希尔排序是不稳定的排序算法。因为在分组排序过程中,相同元素的相对顺序可能会改变。
时间复杂度分析
希尔排序的时间复杂度取决于增量序列的选择:
- 最好情况:当数组已经接近有序时,时间复杂度接近 O(n)。
- 最坏情况:时间复杂度为 O(n²),但在使用优化后的增量序列(如 Hibbard 增量、Sedgewick 增量)时,时间复杂度可以降低到 O(n^(3/2)) 或 O(n log n)。
- 平均情况:时间复杂度通常为 O(n log n)
空间复杂度
- 希尔排序的空间复杂度为 O(1),只需要一个额外的临时变量来存储当前要插入的元素
注意事项:
- 增量序列的选择对算法的性能有很大影响。虽然常见的增量序列是将增量减半,但增量序列的设计可以有多种方式,包括减三分之一或其他策略增量序列的设计。
- 增量序列必须最终减少到 1,否则无法保证数组完全有序。
- 增量序列应避免过早减少到 0,例如减三分之一时需要向上取整并确保 gap >= 1。
希尔排序是一种高效的排序算法,特别适合用于中等大小的数组。其分组排序和逐步缩小增量的特性使得排序效率得到提升,尤其是对于大规模数据的排序。
3 选择排序
3.1 选择排序
选择排序(Selection Sort) 是一种简单直观的排序算法,其核心思想是每一趟从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。通过不断选择未排序部分的最小(或最大)元素,并将其与未排序部分的第一个元素交换,逐步构建有序序列。
3.1.1 选择排序的基本思想
初始化:将数组分为已排序部分和未排序部分,初始时已排序部分为空,未排序部分为整个数组。
选择最小元素:在每一趟排序中,从未排序部分中找到最小(或最大)的元素。
交换位置:将找到的最小(或最大)元素与未排序部分的第一个元素交换,将其“固定”到已排序部分的末尾。
重复过程:重复上述步骤,直到未排序部分为空,所有元素均排序完毕
3.1.2 实现(升序)
/**
* 选择排序
*/
public void selectionSort(int[] array){
// 外层循环,遍历数组的每个位置,i表示当前需要确定的最小值的位置
for (int i = 0; i < array.length - 1; i++) {
//假设当前未排序部分的第一个元素是最小值,记录其下标
int minIndex = i;
// 内层循环,从当前未排序部分的第二个元素开始,寻找更小的元素
for (int j = i+1; j < array.length; j++) {
//minIndex本来是最小值下标,找到比当前最小值更小的元素,则更新最小值下标
if(array[minIndex] > array[j]) {
minIndex = j;
}
}
//将找到的最小值与未排序部分的第一个元素交换,将最小值“固定”到正确的位置
swap(array,i,minIndex);
}
}
3.1.3 优化版本
在传统的插入排序中,每次只寻找一个最小元素并将其固定到未排序部分的起始位置。而优化版本通过一次寻找两个元素(最小值和最大值),分别将最小值固定到未排序部分的起始位置,将最大值固定到未排序部分的末尾位置。
具体步骤:
初始化:定义两个指针 begin 和 end,分别指向未排序部分的起始和末尾位置。
寻找最小值和最大值:在未排序部分中,遍历所有元素,找到最小值和最大值的下标 minIndex 和 maxIndex。
交换最小值:将最小值与未排序部分的第一个元素(begin 位置)交换。
处理特殊情况:如果最大值的位置是 begin,说明最大值已经被交换到了 minIndex 的位置,需要更新 maxIndex 为 minIndex。
交换最大值:将最大值与未排序部分的最后一个元素(end 位置)交换。
缩小未排序范围:将 begin 向右移动,end 向左移动,缩小未排序部分的范围。
public static void selectionSort(int[] array){
// 初始化两个指针,begin 指向未排序部分的起始位置,end 指向未排序部分的末尾位置
int begin = 0, end = array.length - 1;
while (begin < end){
//假设未排序部分的第一个元素是最小值,最后一个元素是最大值
int min = begin, max = end;
// 内层循环,遍历未排序部分,寻找最小值和最大值的下标
for(int i = begin; i <= end; i++){
// 如果找到比当前最小值更小的元素,则更新 min
if(array[min] > array[i]) min = i;
// 如果找到比当前最大值更大的元素,则更新 max
if(array[max] < array[i]) max = i;
}
// 将找到的最小值与未排序部分的第一个元素交换,将最小值“固定”到正确的位置
swap(array,begin,min);
// 特殊情况处理:如果最大值的下标是 begin,说明最大值被交换到了 min 的位置
// 因此需要将 max 更新为 min,以便后续交换
if(max == begin) {
max = min;
}
// 将找到的最大值与未排序部分的最后一个元素交换,将最大值“固定”到正确的位置
swap(array,end,max);
// 缩小未排序部分的范围,使begin和end继续指向未排序元素下标
begin++;
end--;
}
}
优化版插入排序通过一次寻找两个元素(最小值和最大值),减少了排序的趟数,从而提高了实际运行效率。尽管时间复杂度仍为 O(n²),但在处理中等规模数据时,其性能优于传统插入排序,这种优化方式特别适合处理中等规模的数据
3.1.4 总结
稳定性:
- 选择排序是不稳定的排序算法,因为在交换过程中可能改变相等元素的相对顺序
时间复杂度:
- 无论输入数组是否有序,选择排序都需要进行 n(n-1)/2 次比较和 n 次交换,因此时间复杂度为 O(n²)。
空间复杂度:
- 选择排序是原地排序算法,仅需常数级别的额外空间,空间复杂度为 O(1)。
选择排序实现简单,但时间复杂度较高,适合处理小规模数据或用于教学场景。通过优化(如同时寻找最小值和最大值),可以在一定程度上提高实际运行效率,但在大规模数据排序中仍不推荐使用。
3.2 堆排序
堆排序(Heap Sort)是基于堆数据结构的排序算法,借助堆的特性达成排序。其核心思路为:先构建堆(最大堆或最小堆),接着反复调整堆结构,不断将堆顶元素与末尾元素交换,以此得到有序序列。
3.2.1 堆的定义
堆是一种完全二叉树,分为两种:
- 最大堆:在其中每个节点的值都大于或等于它的子节点的值,因此根节点代表着整个堆中的最大值。
- 最小堆:每个节点的值均小于或等于其子节点的值,根节点也就成为了整个堆的最小值。
3.2.2 堆排序基本思想
构建最大堆:
- 将待排序的数组构造成一个最大堆,使得数组的根节点是最大元素。
- 从最后一个非叶子节点开始,逐步向上调整每个节点,确保每个子树都满足最大堆的性质。
交换堆顶与末尾元素:
- 将堆顶元素(最大值)与数组的最后一个元素交换,此时末尾元素为最大值。
- 缩小堆的大小,将已交换的元素排除在堆外,因为交换的元素位置已经确认。
调整堆:
- 使用向下调整算法对剩余的堆进行调整,使其重新满足最大堆的性质。
- 重复步骤2和步骤3,反复交换堆顶元素与当前末尾元素,并调整堆,直到堆的大小为1,最终得到一个有序数组
3.2.3 实现(升序)
向下调整源码算法在3.2.4
public void heapSort(int[] array){
// 1. 先建堆 将数组构造成一个最大堆 升序
createHeap(array);
// 2. 初始化 end 指针,指向数组的最后一个元素
int end = array.length - 1;
// 3. 外层循环:从最后一个元素开始,逐步缩小堆的范围
while (end >= 0){
// 4. 将堆顶元素(最大值)与当前末尾元素交换
// 这样,当前末尾元素就是最大值,且被固定到正确的位
swap(array,0,end);
// 5. 对堆顶元素进行向下调整,调整范围为 [0, end-1]
// 因为 end 位置的元素已经是最大值,不需要再参与调整
AdjustDown(array,0,end);
// 6. 缩小堆的范围,将 end 指针向前移动
// 这样,下一轮循环会将次大值固定到正确的位置
end--;
}
}
//建堆
public void createHeap(int[] array){
for (int parent = (array.length-1 - 1)/2; parent >= 0; parent--) {
//调用向下调整算法,进行堆调整
AdjustDown(array,parent,array.length);
}
}
3.2.4 扩展向上调整算法/向下调整算法
向下调整算法和向上调整算法是堆数据结构中两种常用的调整方法,它们的主要区别在于调整方向、应用场景和操作方式。以下是两者的详细对比
应用场景
向下调整算法:
- 删除堆顶元素:删除堆顶元素后,先将堆的最后一个元素移动到堆顶,然后向下调整。
- 建堆操作:从最后一个非叶子节点开始,依次对每个节点执行向下调整,构建堆。
- 堆排序:在堆排序中,每次交换堆顶元素与末尾元素后,需要对新的堆顶元素进行向下调整。
向上调整算法:
- 插入新元素:在堆中插入新元素后,将新元素放在堆的末尾,然后向上调整,让新元素在堆中找到合适位置。
- 修改元素值:如果某个元素的值增大(在最大堆中)或减小(在最小堆中),为保证堆的性质,对该元素需要向上调整。
操作方式
向下调整算法:比较父节点与子节点的值,如果父节点的值小于子节点(在最大堆中)或大于子节点(在最小堆中),则交换父节点与子节点。
持续重复该过程,直到父节点的值满足堆的性质或到达叶子节点,无法再进行交换为止。
向下调整是从父节点向子节点调整,而父节点通常有两个子节点(左子节点和右子节点),因此需要比较左右子节点的大小,选择较大的子节点(在最大堆中)或较小的子节点(在最小堆中)进行交换。
向上调整算法:比较子节点与父节点的值,如果子节点的值大于父节点(在最大堆中)或小于父节点(在最小堆中),则交换子节点与父节点。
持续重复该过程,直到子节点的值满足堆的性质或到达根节点,无法再进行交换为止。
向上调整是从当前子节点向其父节点方向进行的,而每个子节点仅有一个父节点,通过公式 parent = (child - 1) / 2 就能准确计算出父节点位置与子节点是左还是右无关,因此不需要区分左右节点。
例如:在插入新元素的场景下,向上调整的起点就是新插入的节点;若涉及修改某个元素的值,调整的起点便是被修改的节点。这是因为在未进行插入或修改操作前,堆本身是具备堆性质的。当新元素插入到右节点位置时,不需要与左节点进行比较,因为按照堆性质,左节点原本就小于父节点。所以,基于向上调整的起始点特性以及父节点的唯一性,在执行向上调整算法时,没有必要区分左右子节点。
时间复杂度
向下调整算法:时间复杂度为 O(log n),其中 n 是指堆的大小。
向上调整算法:时间复杂度为 O(log n),其中 n 是指堆的大小。
实现
向上调整算法
public void adjustUp(int[] array, int child,int size){
// 1. 计算父节点的下标
int parent = (child-1)/2;
// 2. 当子节点不是根节点时,继续调整
while(child > 0){
// 3. 如果父节点的值小于子节点的值,则交换父节点和子节点
if(array[parent] < array[child]){
swap(array,parent,child);
// 4. 更新子节点和父节点的下标,继续向上调整
child = parent;
parent = (child-1)/2;
} else {
// 5. 如果父节点的值大于等于子节点的值,说明堆性质已经满足,直接退出
break;
}
}
}
向下调整算法
public void adjustDown(int[] array, int parent, int size){
int child = (parent * 2) + 1; // 1. 计算左子节点的下标
// 2. 当左子节点在堆的范围内时,继续调整
while(child <size) {
// 3. 找出左右子节点中的较大节点
if(child + 1 < size && array[child] < array[child + 1]){
child++;
}
// 4. 如果父节点的值小于子节点的值,则交换父节点和子节点
if(array[parent] < array[child]){
swap(array,parent,child);
// 5. 更新父节点和子节点的下标,继续向下调整
parent = child;
child = parent * 2 + 1;
} else {
// 6. 如果父节点的值大于等于子节点的值,说明堆性质已经满足,直接退出
break;
}
}
}
向上调整/向下调整总结
特性 | 向下调整算法 | 向上调整算法 |
---|---|---|
调整方向 | 从父节点向子节点调整,向下移动 | 从子节点向父节点调整,向上移动 |
应用场景 | 删除堆顶元素、建堆、堆排序 | 插入新元素、修改元素值 |
起始点 | 从父节点(通常是堆顶或非叶子节点)开始 | 从子节点(通常是堆的末尾)开始 |
时间复杂度 | O(log n) | O(log n) |
主要操作 | 比较父节点与子节点,交换较大(小)的值 | 比较子节点与父节点,交换较大(小)的值 |
注意:向下调整的前提是当前节点的左右子树都已经是堆(即满足堆的性质),向上调整的前提是当前节点的父节点已经满足堆的性质,这两点必须满足。
3.2.5 总结
稳定性:
- 堆排序是不稳定的排序算法,因为在交换过程中可能改变相同元素的相对顺序
时间复杂度:
- 构建堆的时间复杂度为 O(n)。
- 每次调整堆的时间复杂度为 O(log n)。
- 总体时间复杂度为 O(n log n)。
空间复杂度:
- 堆排序是原地排序算法,空间复杂度为 O(1)。
注意事项:
- 堆排序中,利用最大堆来实现升序排序,最小堆则实现降序排。
- 使用小堆进行升序排序会导致频繁调整,增加时间复杂度
- 向上调整:用于插入新元素时,从新元素开始向上调整。
- 向下调整:适用于建堆和删除堆顶元素时,从父节点开始向下调整 。
推排序通过构建最大堆并反复交换堆顶元素与末尾元素,逐步将数组排序。它是一种高效的排序算法,适合处理大规模数据。尽管堆排序不稳定,但其性能稳定且空间复杂度低,是常用的排序算法之一 。
-
4 归并排序
归并排序(Merge Sort)是一种基于分治法(Divide and Conquer)的排序算法,其核心思想是将一个序列不断分割成规模更小的子序列,直到每个子序列只有一个元素,此时,这些子序列可被视作天然有序。然后再将这些子序列按照顺序两两合并,在合并过程中,依据元素大小进行重新排列,逐步构建出更大的有序序列,最终得到一个完全有序的序列。
4.1 归并排序基本思想
分(Divide):
- 分割序列:将待排序的序列从中间位置分成两个子序列。
- 递归分割:对每个子序列递归地进行分割,直到每个子序列只包含一个元素。此时,单个元素的子序列自然是有序的。
2. 治(Conquer)
- 合并有序子序列:将两个有序的子序列合并成一个更大的有序序列。
- 合并方法:通过比较两个子序列的元素,依次将较小的元素放入新序列中,直到所有元素都合并完成。
4.2 关键点
- 递归分割:通过递归将问题规模不断缩小,直到问题变得非常简单(子序列长度为 1)。
- 有序合并:合并两个有序子序列的过程是归并排序的核心操作,通过比较和移动元素实现排序
下列删除了7和1,进行排序
4.3 实现(升序)
4.3.1 递归实现
/**
* 归并排序 递归法
*/
public void mergeSort(int[] array){
int[] tmp = new int[array.length];
_MergeSort(array,tmp,0,array.length-1);
}
private void _mergeSort(int[] array, int[] tmp,int left, int right) {
// 1. 递归终止条件:如果左边界大于等于右边界,说明子序列只有一个元素或为空,直接返回
if(left >= right){
return;
}
// 2. 分而治之:将序列从中间位置分成两个子序列
int mid = left + (right-left) / 2;
// 3. 递归对左半部分进行排序
_MergeSort(array,tmp,left,mid);
// 4. 递归对右半部分进行排序
_MergeSort(array,tmp,mid+1,right);
// 5. 进行有序归并 begin1左半部分的起始位置 begin2右半部分的起始位置
int begin1 = left, begin2 = mid+1;
int index = left; // tmp 数组的下标,为了容易从tmp数组中把数据拷贝回原数组 tmp下标从 left 开始
while(begin1 <= mid && begin2 <= right){
if(array[begin1] < array[begin2]){
tmp[index++] = array[begin1++];
} else {
tmp[index++] = array[begin2++];
}
}
// 7. 如果左半部分还有剩余元素,直接拷贝到 tmp 中
while(begin1 <= mid){
tmp[index++] = array[begin1++];
}
// 8. 如果右半部分还有剩余元素,直接拷贝到 tmp 中
while(begin2 <= right) {
tmp[index++] = array[begin2++];
}
// 9. 将 tmp 数组中归并后的结果拷贝回原数组
for (int i = left; i <= right ; i++) {
array[i] = tmp[i];
}
}
4.3.2 非递归实现
非递归归并排序的核心是通过 gap 控制子序列的长度,逐步合并相邻的子序列。然而,当数组长度不是 2 的幂时,子序列的边界可能会超出数组范围,导致越界问题。因此,必须对子序列的边界进行修正。
修正 end1:
- 如果 end1 越界,将其设为数组最后一个元素的下标。
修正 begin2 和 end2:
- 如果 begin2 越界,说明第二个子序列不存在,无需合并。
- 如果 begin2 未越界但 end2 越界,将 end2 设为数组最后一个元素的下标
public void mergeSort(int[] array){
int gap = 1;
int size = array.length;
int[] tmp = new int[size];
while(gap < size){
for (int i = 0; i < size; i+= gap*2) {
int begin1= i; // 左子序列的起始位置
int end1 = i + gap - 1; // 左子序列的结束位置
int begin2 = i + gap; // 右子序列的起始位置
int end2 = i + 2*gap - 1; // 右子序列的结束位置
// 边界检查
if(end1 >= size || begin2 >= size){
break; //如果有边界不存在则不用进行合并
}
if(end2 >= size){
end2 = size - 1;
}
int index = i;
while(begin1<= end1 && begin2 <= end2){
if(array[begin1] < array[begin2]){
tmp[index++] = array[begin1++];
} else {
tmp[index++] = array[begin2++];
}
}
// 如果左半部分还有剩余元素,直接拷贝到 tmp 中
while(begin1<= end1){
tmp[index++] = array[begin1++];
}
// 如果右半部分还有剩余元素,直接拷贝到 tmp 中
while(begin2 <= end2) {
tmp[index++] = array[begin2++];
}
// 将 tmp 数组中归并后的结果拷贝回原数组
for (int j = i; j <= end2 ; j++) {
array[j] = tmp[j];
}
}
// 子序列长度翻倍,继续下一轮合并
gap *= 2;
}
}
4.4 总结
特性 | 递归实现 | 非递归实现 |
---|---|---|
实现方式 | 通过函数递归调用,将数组不断分解为更小的子序列,直到子序列长度为1,再逐步合并。 | 通过循环和变量控制,从子序列长度为1开始,逐步合并子序列,直到整个数组有序。 |
代码复杂度 | 代码简洁,逻辑清晰,易于理解。 | 代码相对复杂,需要手动控制子序列的分组和合并逻辑。 |
空间复杂度 | 需要额外的递归栈空间,空间复杂度为 O(log n)。 | 不需要递归栈空间,但需要临时数组,空间复杂度为 O(n)。 |
时间复杂度 | 均为 O(n log n) | 均为 O(n log n) |
边界处理 | 递归终止条件为子序列长度为1,边界处理较为简单。 | 需要手动处理子序列的边界,确保合并时不会越界。 |
适用场景 | 适合小规模数据或对代码简洁性要求较高的场景。 | 适合大规模数据,避免递归栈溢出问题。 |
稳定性 | 稳定排序,相等元素的相对顺序不会改变。 | 稳定排序,相等元素的相对顺序不会改变。 |
优化点 | 递归调用可能导致栈溢出,适合数据规模较小的情况。 | 通过循环实现,适合处理大规模数据,且避免了递归调用栈的开销。 |
稳定性:
- 归并排序是一种稳定的排序算法,即相等元素的相对顺序在排序前后保持不变
时间复杂度:
- 归并排序的时间复杂度为 O(n log n),其中 n 是序列的长度。这是因为分割阶段需要递归地分割序列,时间复杂度为 O(log n),而合并阶段需要对所有元素进行比较和移动,时间复杂度为 O(n)
空间复杂度:
- 归并排序需要额外的空间来存储合并后的序列,因此空间复杂度为 O(n)
注意事项:
1. 临时数组使用要点
- 避免重复创建:应将临时数组 tmp 定义在递归函数外,作为参数传入,而非每次递归都新建,以此减少内存分配与释放开销,提升性能。
- 确定数组大小:临时数组大小需与原数组一致,保证有足够空间存储合并结果。
2. 递归终止条件说明
- 正确设置条件:递归终止条件应为 if (left >= right),表明当前子序列仅一个元素或为空,无需再分割。
- 做好边界检查:递归调用时确保传入参数无误,防止出现 left > right 的情况,避免程序出错。
3. 合并操作关键细节
边界处理注意:合并子序列时,留意 <= 和 < 的使用,保证所有元素正确合并。
- 剩余元素处理:合并中若一个子序列元素全部合并完,需将另一子序列剩余元素拷贝到临时数组。
- 结果回写操作:合并结束后,要将临时数组结果拷贝回原数组,切勿将原数组赋值给临时数组。
4. 非递归并注意事项
- 修正子序列的边界,防止越界。
- 使用临时数组存储合并结果,并正确拷贝回原数组。
- 控制 gap 的增长,逐步合并子序列。
归并排序通过分治法将问题分解为更小的子问题,并通过有序合并解决子问题,最终实现排序。其时间复杂度为 O(n log n),是一种高效且稳定的排序算法,适合处理大规模数据和需要稳定排序的场景
递归实现:代码简洁,适合小规模数据,但递归调用可能导致栈溢出。
非递归实现:代码复杂,适合大规模数据,避免了递归调用栈的开销。
5 交换排序
交换排序是一类借助元素比较与交换操作实现排序的算法,其核心思路在于对序列中的元素展开比较,依据比较结果调整元素位置,直至序列完全有序。常见的交换排序算法有冒泡排序和快速排序
5.1 交换排序基本思想
交换排序的基本思想是:两两比较待排序的元素,如果它们的顺序不符合要求(如升序或降序),则交换它们的位置。通过多轮比较和交换,最终使整个序列有序
5.2 冒泡排序
冒泡排序是交换排序中最简单的算法之一,每轮比较会将当前未排序部分的最大(或最小)元素“冒泡”到序列的末尾,因此称为冒泡排序。
5.2.1 基本思想
- 从序列的起始位置开始,依次比较相邻的两个元素。
- 如果前一个元素大于后一个元素(升序排序),则交换它们的位置。
- 重复上述过程,直到没有需要交换的元素为止。
5.2.2 实现
public void bubbleSort(int[] array){
//因为是和后一个元素比较,所以只需要遍历到最后元素的前一个即可
// 外层循环控制遍历的轮数,每一轮会将当前未排序部分的最大值“冒泡”到末尾
for (int i = 0; i < array.length - 1; i++) {
// 内层循环进行相邻元素的比较和交换,每一轮都会减少一次比较
// 因为每轮结束后,最后的 i 个元素已经是排序好的,不需要再比较
for (int j = 0; j < array.length - i - 1; j++) {
if(array[j] > array[j+1]){
swap(array,j,j+1);
}
}
}
}
优化 增加一个标志位,如果当前没有发生交换则说明该数组已经有序,直接跳出循环即可;
public void bubbleSort(int[] array) {
// 外层循环控制遍历的轮数,每一轮会将当前未排序部分的最大值“冒泡”到末尾
for (int i = 0; i < array.length - 1; i++) {
boolean flag = false; // 标志位,记录本轮是否发生了交换
// 内层循环进行相邻元素的比较和交换,每一轮都会减少一次比较
for (int j = 0; j < array.length - i - 1; j++) {
// 如果当前元素比后一个元素大,则交换它们的位置
if (array[j] > array[j + 1]) {
swap(array, j, j + 1);
flag = true; // 发生交换,标志位置为 true
}
}
// 如果本轮没有发生交换,说明数组已经有序,直接结束排序
if (!flag) {
break;
}
}
}
5.2.3 总结
稳定性:
- 冒泡排序是一种稳定的排序算法。
时间复杂度
- 最好情况:当序列本身已经有序时,只需要进行一趟比较,时间复杂度为 O(n)
- 最坏情况:当序列完全逆序时,需要进行 n−1 趟排序,每趟比较 n−i次,时间复杂度为 O(n²)
- 平均情况:冒泡排序的平均时间复杂度为 O(n²)
空间复杂度
- O(1),因为只需要常量级的额外空间
通过优化提前结束、减少不必要的比较和避免重复排序,可以提高冒泡排序的效率
冒泡排序由于其简单性,通常适用于小规模数据和初学者学习
5.3 快速排序
快速排序(QuickSort)是一种基于分治法的高效排序算法。
5.3.1 快速排序思想
通过选择一个基准元素(pivot),将待排序的序列分为两部分:一部分小于基准元素,另一部分大于基准元素,然后递归地对这两部分进行排序,最终使整个序列有序。
5.3.2 基准元素的选择
从待排序序列中挑选一个元素作为基准(pivot),常见的选择策略有:
固定位置选择:可以选取序列的第一个元素、最后一个元素或者中间位置的元素。
随机选择:通过随机数生成器在序列索引范围内随机选择一个元素作为基准。这种方式能在一定程度上避免最坏情况的发生,特别是当序列本身存在某种有序性时。
为了优化性能,可以使用三数取中法,即选择第一个、最后一个和中间元素的中间值作为基准 。
5.3.3 分区操作
分区操作是快速排序的核心,目标是将序列分为两部分:
左部分:所有元素小于或等于基准元素。
右部分:所有元素大于或等于基准元素。
以下是三种常见的分区方法:
左右指针法(Hoare法)
- 首先定义两个指针,left 指向序列的开头,right 指向序列的末尾。同时选定一个基准元素,将基准元素交换到固定位置(如序列的起始位置),因为在分区过程中,基准元素可能会被移动,导致无法正确划分序列。
- 2.left和right进行扫描序列,left 指针从序列起始位置不断向右移动,right 指针从序列末尾位置不断向左移动。在指针移动时,持续比较它们所指向的元素与基准元素的大小关系。
- 当出现 left 指针指向的元素大于基准元素,且 right 指针指向的元素小于基准元素的情况 ,就交换这两个指针所指向的元素。
- 重复上述指针移动与元素交换的过程,直至 left 和 right 指针相遇。
- 当两指针相遇时,整个序列就被成功地分成了两部分:左边部分的所有元素都小于或等于基准元素,右边部分的所有元素都大于或等于基准元素。
- 最后,将基准元素与 left 和 right 指针相遇位置的元素进行交换,至此完成分区操作。
注意事项:
先移动 right 指针:为了保证 left 和 right 指针相遇时,相遇位置的元素小于或等于基准元素,通常先让 right 指针向左移动 。
挖坑法
- 首先,从待排序序列中选定一个基准元素,并将其值存放到一个临时变量中。此时,基准元素原来所在的位置就形成了一个 “坑”。
- 设定两个指针,left 指向序列的起始位置,right 指向序列的末尾位置。
- 从 right 指针开始,让其向左移动,在移动过程中不断寻找比基准元素小的元素。一旦找到,就将该元素填入之前形成的 “坑” 中。此时,该元素原来所在的位置就会形成一个新的 “坑”。
- 接着,让 left 指针向右移动,在移动过程中持续寻找比基准元素大的元素。当找到后,将该元素填入新形成的 “坑” 中,进而又产生一个新的 “坑”。
- 不断重复上述第 3 步和第 4 步的操作,即 right 指针向左找小元素填坑、left 指针向右找大元素填坑,如此交替进行,直到 left 和 right 指针相遇。
- 当 left 和 right 指针相遇时,将最初保存的基准元素填入此时的 “坑” 中。
- 经过分区后,序列被分成两部分,左边部分的所有元素小于或等于基准元素,右边部分的所有元素大于或等于基准元素(哪部分元素为等于看具体代码实现)。
前后指针法
- 初始化指针:prev 指针指向序列的开头,cur 指针指向 prev 的下一个位置。同时选定一个基准元素,将基准元素交换到固定位置(如序列的起始位置),因为在分区过程中,基准元素可能会被移动,导致无法正确划分序列。
- cur 指针从左到右遍历序列,寻找比基准元素小的元素。当 cur 指针找到比基准元素小的元素时,prev 指针向前移动一位,然后交换 prev 和 cur 指针所指向的元素。
- 通过这种方式,prev 指针始终指向小于基准元素子序列的末尾位置。
- 当 cur 指针遍历完整个序列后,所有小于基准元素的元素都被移动到 prev 指针的左侧。
- 最后,将基准元素与 prev 指针所指的元素交换,完成分区。此时,基准元素左侧的所有元素都小于基准元素,右侧的所有元素都大于或等于基准元素。
5.3.4 实现
三数取中和交换方法实现
//使用三数取中 区间随机值 left 和right 取中
public int getMidIndex(int[] array, int left, int right){
// 生成一个在 [left, right] 范围内的随机索引
int rand =left + new Random().nextInt(right-left+1);
// 比较 arrleay[rand]、array[left] 和 array[right],找出中间值
if(array[rand] > array[left]){
if(array[rand] < array[right]) return rand;
else return array[left] > array[right] ? left : right;
} else {
if(array[rand] > array[right]) return rand;
else return array[left] > array[right] ? left : right;
}
}
//交换方法
public void swap(int[] array,int i,int j){
int tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
挖坑法
/**
* 快速排序 挖坑法
*/
public void partitionHole(int[] array,int left, int right){
// 终止条件:当子序列长度小于等于1时直接返回
if (left >= right) return;
// 1. 使用三数取中法选择基准值,并交换到最左端
int index = getMidIndex(array,left,right);
swap(array, left, index);
int pivot = array[left]; //保存基准值
int begin = left, end = right; // 初始化左右指针
// 2. 分区操作:将序列分为小于基准值和大于等于基准值的两部分
while(begin < end){
// 从右向左扫描,寻找小于基准值的元素
while ( begin < end && array[end] >= pivot) end--;
// 找到 后,将该元素填入“坑”中 此时 end就是新的坑
array[begin] = array[end];
// 从左向右扫描,寻找大于等于基准值的元素
while (begin < end && array[begin] <= pivot ) begin++;
//找到后,将该元素填入“坑”中 此时begin就是新的坑
array[end] = array[begin];
}
// 3. 将基准值放入最后的“坑”中
array[begin] = pivot;
// 4. 递归排序基准值左右两部分 只需要左边有序,右边有序整体就有序了
// 如果子序列长度大于 10,则继续使用快速排序
if(right-left + 1 > 10) {
partitionHole(array,left,begin-1); // 排序左部分
partitionHole(array,begin+1,right); // 排序右部分
} else {
// 如果子序列长度小于等于 10,则使用直接插入排序
insertSort(array,left,right);
}
}
左右指针法
public void partitionHole(int[] array,int left, int right){
// 终止条件:当子序列长度小于等于1时直接返回
if (left >= right) return;
// 1. 使用三数取中法选择基准值,并交换到最左端
int index = getMidIndex(array,left,right);
swap(array, left, index);
int pivot = array[left]; //保存基准值
int begin = left, end = right; // 初始化左右指针
// 2. 分区操作:将序列分为小于基准值和大于等于基准值的两部分
while(begin < end){
// 从右向左扫描,寻找小于基准值的元素要先进行判断begin<end 否则如果begin<end后判断,array[end]会越界
while ( begin < end && array[end] >= pivot ) end--;
// 从左向右扫描,寻找大于等于基准值的元素
while (begin < end && array[begin] <= pivot ) begin++;
// 找到后,交换这两个元素
swap(array,begin,end);
}
// 3. 将基准值和当前begin或end处的值交换,因为此处begin的值一定是小于基准值的
swap(array,begin,left);
// 4. 递归排序基准值左右两部分 只需要左边有序,右边有序整体就有序了
// 如果子序列长度大于 10,则继续使用快速排序
if(right-left + 1 > 10) {
partitionHole(array,left,begin-1); // 排序左部分
partitionHole(array,begin+1,right); // 排序右部分
} else {
// 如果子序列长度小于等于 10,则使用直接插入排序
insertSort(array,left,right);
}
}
前后指针
public void partitionHole(int[] array,int left, int right){
// 终止条件:当子序列长度小于等于1时直接返回
if (left >= right) return;
// 1. 使用三数取中法选择基准值,并交换到最左端
int index = getMidIndex(array,left,right);
swap(array, left, index);
// 2. 初始化前后指针:prev指向最后一个小于基准的位置,cur用于遍历
int prev = left, cur = left+1;
// 3.分区操作:遍历整个子序列
while(cur <= right){
// 从右向左扫描,寻找小于基准值的元素 如果当前元素小于基准值,则prev右移并交换元素
while (array[cur] < array[left] && ++prev != cur) swap(array,prev,cur);
cur++;
}
// 4. 将基准值交换到prev的位置(此时prev左侧均小于基准)
swap(array,left,prev);
// 5. 递归处理左右子序列(结合插入排序优化)
if(right-left + 1 > 10) {
partitionHole(array,left,prev-1); // 排序左部分
partitionHole(array,prev+1,right); // 排序右部分
} else {
// 如果子序列长度小于等于 10,则使用直接插入排序
insertSort(array,left,right);
}
}
非递归实现
使用栈模拟递归过程,避免递归深度过大导致的栈溢出问题
步骤:
- 初始化:使用栈(或队列)存储待排序子数组的边界(left 和 right)。
- 将整个数组的起始和结束下标(0 和 array.length - 1)压栈。
循环处理:
- 从栈中弹出子数组的边界 left 和 right。
- 调用 partition 方法对子数组进行分区,返回基准值的位置 pivot。
将分区后的左右子数组边界压栈:
- 如果 left < pivot - 1说明有一个以上元素,将 left 和 pivot - 1 压栈。
- 如果 pivot + 1 < right说明有一个以上元素,将 pivot + 1 和 right 压栈。
终止条件:
- 当栈为空时,说明所有子数组都已排序,算法结束。
注意事项:
栈的使用顺序:
- 由于栈是后进先出(LIFO)结构,压栈时先压右边界,再压左边界;出栈时先出左边界,再出右边界,确保处理顺序正确
分区方法的选择:
- partition 方法可以采用双指针法、挖坑法或Hoare法,需确保分区逻辑正确,避免最坏情况(如分区不均衡)
2基准值的选择:
- 基准值的选择直接影响性能。可以采用三数取中法(从 left、mid、right 中选择中间值)来避免最坏情况
递归与非递归的对比:
非递归实现通过栈模拟递归过程,避免了递归调用的栈溢出问题,适合大规模数据排序
- 边界条件处理:
在压栈前,需检查子数组的长度是否大于1,避免无效操作
public void quickSort(int[] array){
int left = 0,right=array.length-1;
// 对数组进行分区,返回基准值的位置
int par = partition(array, left, right);
Deque<Integer> stack = new ArrayDeque<>();
//入栈
stack.push(right);
stack.push(left);
//栈不为空就一值循环
while(!stack.isEmpty()){
left = stack.pop(); // 弹出左边界
right = stack.pop(); // 弹出右边界
par = partition(array, left, right); // 对当前子数组进行分区
// 如果右区间有1个以上元素则进行入栈,如果只有一个默认有序不入栈
if(par < right - 1){
//先进行入右端点,再入左端点
stack.push(right);
stack.push(par+1);
}
//如果左区间有1个以上元素 进行入栈
if(par > left+1){
//先入右端点, 再入左端点
stack.push(par-1);
stack.push(left);
}
}
}
//分区方法
public int partition(int[] array,int left,int right){
// 1. 使用三数取中法选择基准值,并交换到最左端
int index = getMidIndex(array,left,right);
swap(array, left, index);
// 2. 初始化前后指针:prev指向最后一个小于基准的位置,cur用于遍历
int prev = left, cur = left+1;
// 3.分区操作:遍历整个子序列
while(cur <= right){
// 从右向左扫描,寻找小于基准值的元素 如果当前元素小于基准值,则prev右移并交换元素
while (array[cur] < array[left] && ++prev != cur) swap(array,prev,cur);
cur++;
}
// 4. 将基准值交换到prev的位置(此时prev左侧均小于基准)
swap(array,left,prev);
return prev;
}
5.4 总结
稳定性:
- 快速排序在分区过程中会交换元素,可能改变相同元素的相对顺序,因此是不稳定的排序算法
时间复杂度:
- 最好情况:每次分区后,两部分长度接近相等,时间复杂度为 O(nlogn)。
- 最坏情况:每次分区后,一部分为空,另一部分为 n−1,时间复杂度退化为 O(n^2)。
- 平均情况:时间复杂度为 O(nlogn),是快速排序的主要优势
空间复杂度
- 原地排序:快速排序是原地排序,不需要额外的存储空间。
- 递归栈空间:递归调用栈的深度决定了空间复杂度。
- 最好情况下为 O(logn),最坏情况下为 O(n)
优化方法
为了提高快速排序的效率,可以采用以下优化策略:
- 随机化基准选择:随机选择基准值,减少分区不均衡的概率
- 尾递归优化:减少递归调用栈的深度,降低空间复杂度
- 三数取中法:选择第一个、最后一个和中间元素(区间随机元素)的中间值作为基准,避免最坏情况(如序列已经有序或逆序),如果不使用三数取中可能会有以下问题。
- 最坏时间复杂度退化为O(n^1) :当基准值选择不当时(如每次都选到最小或最大值),分区极不均衡,递归深度增加。
- 对有序或逆序数据性能差:输入数据有序时,分区效率低下,性能显著下降。
- 基准值选择不稳定:固定选择基准值(如第一个元素)容易导致分区不均衡影响整体效率。
- 递归开销增加:分区不均衡导致递归深度增加,栈空间消耗更大,可能触发栈溢出。
- 小区间优化:当子序列长度较小时,使用插入排序提高效率 ,减少递归调用开销。插入排序在小规模数据时间复杂度接近于O(n),而且当子序列不多时往往是接近于有序,插入排序在处理接近有序的数据比快排快。
- 快速排序对随机分布的数据排序效率最高,而且由于是原地排序,适合内存受限的环境 。
快速排序的平均时间复杂度为 O(nlogn),适合处理大规模数据。
6 计数排序
计数排序是一种非基于比较的排序算法,其核心思想是通过统计数组中每个元素的出现次数,然后根据统计结果将元素放回正确的位置。
6.1 步骤
确定最大值和最小值:
- 遍历数组,找到最大值 max 和最小值 min,用于确定计数数组的范围。
创建并填充计数数组:
- 根据 min 的差值,创建一个长度为 max−min+1 的计数数组 count。
- 遍历数组,统计每个元素出现的次数,并将结果存储在计数数组中。
- 例如,元素 x 的出现次数存储在 count[x−min] 中。
累加计数数组:
- 对计数数组进行累加操作,使得 count[i] 表示小于等于 i+min 的元素个数。
- 根据累加后的计数数组,构建一个与原始数组长度相同的输出数组。在构建过程中,依据计数数组中记录的元素出现次数和顺序信息,将各个元素按序填充到输出数组中
返回排序后的数组:
- 最终,输出数组即为排序后的结果。
6.2 实现
public void countingSort(int[] array){
int len = array.length;
if(len == 0) return;
//求出最大值最小值
int max = array[0];
int min = array[0];
for (int i : array) {
if (max < i) max = i;
if (min > i) min = i;
}
// 2. 创建计数数组,大小为数据的范围(max - min + 1)
int range = max - min + 1;
int[] count = new int[range];
// 3. 统计每个元素出现的次数
for (int num : array) {
// 将元素值映射到计数数组的下标,并增加计数
count[num - min]++;
}
// 4. 根据计数数组,将元素放回原数组
int arrIndex = 0;
for (int i = 0; i < count.length; i++) {
// 将计数数组中的值逐个放回原数组
while(count[i] != 0){
array[arrIndex++] = i+min;
count[i]--;
}
}
}
6.3 总结
稳定性:
- 计数排序是一种稳定的排序算法。
时间复杂度:
- 最好情况: O (n + k),其中 n 是原数组的长度,k 是原数组中元素的取值范围(即最大值与最小值的差值加 1)。在通常情况下,k 和 n 具有线性关系,此时时间复杂度近似为 O (n)。
- 最坏情况: O (n + k),近似为 O (n)。这是计数排序在时间性能上的一大优势,相比一些比较排序算法,不会因为数据分布的特殊性而出现性能大幅下降的情况。
- 平均情况: O (n + k),近似为 O (n)。这是因为无论数据如何分布,计数排序的操作流程和执行次数基本固定,不受元素初始顺序的影响。
空间复杂度:
- 辅助数组空间:计数排序需要创建一个额外的计数数组,其长度为原数组元素的取值范围(最大值 - 最小值 + 1),即 O (k) 的空间开销。
- 其他空间:计数排序的空间复杂度为 O (n + k),其中 n 为原数组长度,k 为元素取值范围。当 k 与 n 同阶时,空间复杂度近似为 O (n)
减去最小值的核心目的:
处理负数:避免负数无法作为下标的问题。
例如,数组 [-3, 1, 2] 中,-3 无法直接作为下标使用。通过减去最小值(如 -3),可以将元素映射到非负范围:-3 - (-3) = 0,1 - (-3) = 4,2 - (-3) = 5
此时计数数组的下标范围变为 [0, 5],可以正确统计和排序
优化空间:减少计数数组的大小,节省内存。
减去最小值后,计数数组的大小从 max + 1 缩减为 max - min + 1。例如,数组 [100, 102, 101],最大值是 102,最小值是 100:
如果不减去最小值,计数数组大小为 103(下标 0 到 102)。
减去最小值后,计数数组大小仅为 3(下标 0 到 2),大大减少了空间开销
统一规则:将元素映射到从 0 开始的范围,方便统计和排序
减去最小值后,所有元素都被映射到一个从 0 开始的范围,方便统一处理。例如,数组 [5, 7, 5, 2],最小值是 2,映射后:5 - 2 = 3,7 - 2 = 5,2 - 2 = 0
这样,计数数组的下标范围是 [0, 5]。
计数排序在数据范围较小、整数排序、稳定性要求高的场景中表现优异。使用时需注意数据范围的优化和稳定性的实现,同时避免数据范围过大导致的空间浪费。
7 基数排序
基数排序(Radix Sort)是一种非比较型的排序算法,它通过将整数按位数切割成不同的数字,然后按每个位数分别排序,最终实现整体有序。基数排序的核心思想是“按位分配,逐位排序”,通常分为两种方法:最低有序列表位优先(LSD)和最高位优先(MSD)。
7.1 基数排序基本思想
基数排序将所有待比较数值统一为同样的数位长度,数位较短的数前面补零。然后从最低位(LSD)或最高位(MSD)开始,依次对每一位进行排序。每趟排序使用稳定的排序算法(如计数排序)来处理当前位上的数字,最终完成整个数组的排序
7.2 基础排序步骤
LSD(最低位优先)基数排序
- 确定最大位数:遍历数组,找出其中的最大值。通过对最大值进行位数计算,确定整个数组中元素的最大位数。这一步是为后续按位排序设定边界,明确需要进行排序的次数。
- 按位排序:从最低位开始,逐位对数组元素进行排序,具体操作如下:
- 创建桶结构:依据基数(例如,对于十进制数,基数为 10,需创建 10 个桶)的数量,创建相应数量的桶。这些桶用于暂存元素,是实现按位分配的关键数据结构。
- 元素分配:遍历当前待排序的数组,将每个元素根据其当前位(如个位、十位等)的值,分配到对应的桶中。比如,对于数字 35,在按个位排序时,会被分配到编号为 5 的桶中。
- 收集元素:按照桶的顺序,依次将各个桶中的元素收集起来,重新形成一个新的序列。这个新序列是基于当前位排序后的结果。
- 重复排序:完成当前位的排序后,针对更高一位重复上述创建桶、分配元素和收集元素的操作,直到对最高位也完成排序。经过对所有位的排序,数组最终达到完全有序状态。
MSD(最高位优先)基数排序
- MSD 基数排序与 LSD 基数排序思路相似,但排序起始位不同。MSD 从最高位开始进行排序。在每一轮对最高位排序后,针对每个桶内的元素,递归地对下一位进行排序。例如,对一组三位数进行排序,先按百位进行排序,将元素分配到对应百位数字的桶中。然后,对每个桶内的元素,递归地按照十位进行排序,再对每个十位桶内的元素按个位排序,通过这样层层递归的方式,直至完成所有位的排序,实现整个数组的有序排列。
7.3 实现
public void radixSort(int[] array){
if(array == null || array.length == 0) return;
//找到最大值
int max = array[0];
for(int i : array){
if(max < i) max = i;
}
int len = array.length;
int[] output = new int[len]; // 输出数组
//创建计数数组
int[] count = new int[10]; //因为是按位排序,10进制只需要10位即可
//从最低位开始,进行排序
for (int i = 1; max/i >0 ; i *= 10) {
// 初始化计数数组为0
Arrays.fill(count, 0);
//计算每个数组出现的个数
for (int j = 0; j < len; j++) {
int digit = (array[j]/i)%10;
count[digit]++;
}
//计算每个元素的实际位置
for (int j = 1; j < 10; j++) {
// count[j] 存储的是 小于或等于 j 的数字的累计出现次数。
// 例如,如果 count[3] = 5,表示在排序后的数组中,数字 3 的最后一个位置是 4(因为数组下标从 0 开始)。
count[j] += count[j-1];
}
// 将元素按当前位的值放入输出数组中
for(int j = len-1;j>=0;j--){
int digit = (array[j]/i)%10; // 获取当前位的数字
output[count[digit] - 1] = array[j]; // 将元素放入输出数组的正确位置
count[digit]--; // 更新计数数组,为下一个相同数字预留位置
}
// 将输出数组复制回原数组
System.arraycopy(output, 0, array, 0, len);
}
}
7.4 总结
稳定性:
- 基数排序是稳定的排序算法,相同元素的相对顺序不会改变
时间复杂度:
- O(d⋅(n+k)),其中 d 是最大位数,n 是数组长度,k 是基数(如十进制为10)。当 d和k 较小时,基数排序接近线性时间复杂度
空间复杂度:
- O(n+k),需要额外的空间存储计数数组和输出数组
注意事项:
count[j] += count[j-1];的作用:
- 通过累加计数数组 count,使得 count[j] 表示 小于或等于 j 的数字的结束位置。
- 例如,如果 count = [2, 3, 1, 0, 0, 0],累加后变为 count = [2, 5, 6, 6, 6, 6],表示:数字 0 的位置范围是 0 到 1。数字 1 的位置范围是 2 到 4。数字 2 的位置范围是 5 到 5。
为什么需要累加:
- 累加的目的是为了在将元素放入输出数组时,能够正确分配位置并保持排序的 稳定性。
- 例如,如果有多个相同的数字,累加后的 count[j] 可以确保这些数字在输出数组中的相对顺序不变。
每一轮排序前初始化计数数组:
- 在每一轮排序中,计数数组 count 需要重新初始化为 0,否则上一轮的计数结果会影响当前轮的计算。
基数排序的应用场景:
- 大规模整数排序:如电话号码、身份证号码、IP地址等
- 高效稳定排序需求:需要保持相等元素原始顺序的场景
- 海量数据处理:适用于数据量大但位数较少的整数排
累加计数数组:确定每个数字在输出数组中的结束位置。
填充输出数组:从后向前遍历原数组,将元素放入输出数组的正确位置,同时保持稳定性。
基数排序是一种高效且稳定的排序算法,特别适用于整数或字符串排序。通过按位分配和逐位排序,基数排序能够在线性时间内完成排序,适合处理大规模数据集
8 桶排序
桶排序(Bucket Sort)是一种基于分治策略的排序算法,通过将数据分配到有限数量的桶中,对每个桶内的数据进行排序,最后合并所有桶中的数据,得到有序序列。
8.1 桶排序的基本思想
- 创建桶:根据数据的范围和分布,确定桶的数量和每个桶的范围。
- 分发数据:将待排序的元素按照一定的规则(如数值大小)分配到对应的桶中。
- 桶内排序:对每个桶内的元素进行排序(可以使用插入排序、快速排序等)。
- 合并桶:按照桶的顺序,将每个桶内的元素依次合并,形成最终的有序序列。
8.2 桶排序的步骤
- 确定桶的数量和范围:找到待排序数组的最大值 max 和最小值 min。
- 计算每个桶的范围:size = (max - min) / 桶数量 + 1。
- 确定桶的数量:bucketCount = (max - min) / size + 1。
- 分发数据到桶中:遍历数组,将每个元素分配到对应的桶中。分配规则为:index = (元素值 - min) / size。
- 对每个桶内的数据进行排序:使用合适的排序算法对每个桶内的数据进行排序。
- 合并桶中的数据:按照桶的顺序,将每个桶内的元素依次合并,得到最终的有序序列。
桶的设计和分配规则确保了桶的编号与数据的大小顺序一致
8.3 桶的有序性
桶排序中,桶的编号是按照数据范围从小到大依次排列的。
例如:桶0负责 [min, min + bucketSize) 范围内的数据。
桶1负责 [min + bucketSize, min + 2 * bucketSize) 范围内的数据。依此类推。假设数组为 [12, 5, 8, 15, 3],最小值为 3,桶大小为 5,则:
桶0:[3, 8),包含 [5, 3]、桶1:[8, 13),包含 [12, 8]、桶2:[13, 18),包含 [15]。
排序后:桶0:[3, 5]、桶1:[8, 12]、桶2:[15]。
最终合并结果为 [3, 5, 8, 12, 15]
桶的编号和数据范围是严格递增的,因此 桶0的数一定比桶1的数小,桶1的数一定比桶2的数小
8.4 实现
public void bucketSort(int[] array, int bucketSize) {
if (array.length == 0) return;
// 找到最大值和最小值
int min = array[0], max = array[0];
for (int num : array) {
if (num < min) min = num;
if (num > max) max = num;
}
// 计算桶的数量 + 1:确保桶的数量足够覆盖所有数据,避免遗漏 并且确保至少有一个桶来存放数据
int bucketCount = (max - min) / bucketSize + 1;
List<List<Integer>> buckets = new ArrayList<>(bucketCount);
for (int i = 0; i < bucketCount; i++) {
buckets.add(new ArrayList<>()); // 初始化每个桶
}
// 分配数据到桶中 遍历数组中的每个元素,将其分配到对应的桶中
for (int num : array) {
// 计算当前元素应该放入哪个桶
//减去最小值和计数排序一样是为了确保非负性和节省空间
int index = (num - min) / bucketSize;
buckets.get(index).add(num); // 将元素添加到对应的桶中
}
// 对每个桶进行排序
for (List<Integer> bucket : buckets) {
Collections.sort(bucket);
}
// 合并桶中的数据
// 遍历每个桶,将桶内的元素按顺序合并回原数组
int index = 0; // 用于跟踪原数组的当前位置
for (List<Integer> bucket : buckets) {
for (int num : bucket) {
array[index++] = num; // 将桶中的元素放回原数组
}
}
}
总结
稳定性:
- 桶排序通常是稳定的,即相等元素的相对顺序在排序后不会发生变化。
时间复杂度:
- 最好情况:当数据均匀分布在各个桶中时,时间复杂度为 O(n)。
- 最坏情况:当所有数据都集中在一个桶中时,时间复杂度为 O(n^2)。
- 平均情况:时间复杂度为 O(n+k),其中 k 是桶的数量。
空间复杂度:
- 桶排序需要额外的空间存储桶和排序结果,空间复杂度为 O(n+k)。
桶排序的适用场景:
- 数据分布均匀:当数据能够均匀分布到各个桶中时,桶排序效率较高。
- 数据范围已知:桶排序适用于数据范围已知且能够映射到有限数量桶中的情况。
- 外部排序:桶排序适合用于外部排序,即数据量较大且无法一次性加载到内存中的情况
bucketSize 的含义:
- 定义:bucketSize 表示每个桶的容量或元素值范围。例如,如果 bucketSize = 5,则每个桶中存储的元素值范围是 5(如 0-4, 5-9, 10-14 等),注意不是表示的一个桶能存放的数量。
- 作用:通过将数据分配到多个桶中,桶排序可以在每个桶内进行局部排序,最终合并所有桶的结果,从而实现整体排序。
bucketSize的确定方法
- 根据数据分布确定:如果数据分布均匀,可以设置 bucketSize 为 (max - min) / bucketCount + 1,其中 bucketCount 是桶的数量。如果数据分布不均匀,可以手动调整 bucketSize,确保每个桶中的元素数量尽量均衡。
- 默认值:在某些实现中,bucketSize 可以设置为一个默认值,例如 5 或 10。如果用户未指定 bucketSize,则使用默认值
- 动态计算:根据数组的最大值 max 和最小值 min,动态计算 bucketSize
bucketSize
是桶排序中用于确定桶的容量或元素范围的关键参数桶排序是一种高效的排序算法,适用于数据分布均匀且范围已知的场景。通过将数据分配到多个桶中,对每个桶进行排序,最后合并结果,桶排序能够在理想情况下实现线性时间复杂度