这是我参与「第三届青训营 -后端场」笔记创作活动的第3篇笔记
1、排序算法概述
1.1、排序算法的概念
什么是排序算法
排序也称排序算法,是将一组数据按照指定顺序排列的过程。
排序算法的分类
- 内部排序:将所有需要用到的数据都加载到内存中(常见)
- 外部排序:数据量过大,无法全部加载到内存中。需要借助外部存储进行排序
1.2、常用的排序算法
并不是只有使用这些算法才能完成排序,这些算法都是比较经典聪明的做法。
空间复杂度为O(1)的排序算法,不需要使用额外的内存空间,也称为“原地排序算法”
1.3、排序算法的评定指标
稳定性
待排序数组中,a=b,a在b的前面。如果排序后,a一定依然在b的前面,这个排序算法就是稳定的,反之,就是不稳定的。
各种排序算法的稳定性分析,在最后的总结中。
为什么排序算法要考察稳定性?
比如要求排序订单,按照金额排序,如果金额一样,按照下单时间排序。实现起来比较麻烦,但如果使用稳定排序算法,就可以先根据下单时间进行一趟排序,在这次排序的基础上根据金额再进行一趟排序,就可以得到结果。
是否占用额外内存
如果一个算法在实施的过程中,不是必须使用到额外的内存空间(比如一些辅助的数组),就称这个排序算法不占用额外内存,反之,它就占用了额外内存。
通常,“以空间换时间”的排序算法,需要占用一些额外内存。
2、冒泡排序
2.1、思想
冒泡排序(Bubble Sorting)的思想是:
如果要从小到大排列,从下标较小的元素开始,依次比较相邻元素的值。
如果发现逆序(前面的大,后面的小)则交换,使值较大的元素从头部慢慢挪到尾部,达到从小到大排列的效果。
简单来说,第一组排序,会确定出数组中最大的数,放在倒数第一位;第二组排序,会确定出第二大的数,放在倒数第二位...
直到确立出第(数组长度-1)大的数,放在正数第二位,循环结束。
2.2、图解
总结
- 一共会进行(数组大小-1)组循环
- 每组循环,会确定一个当前“乱序数组”中的最大值
- 每组循环内部,排序次数逐渐减少。
优化空间
每一次冒泡排序,只会确定出一个当前“乱序数组”中的最大值,注意是一个。它的基本思想中,即使确立出一个最大值后,数组就已经是有序的,它也不知道,只能一次一次循环,发现都不需要交换,最后才能知道数组是有序的。
我们可以帮它直到,即设立一个标志flag,
只要有一次循环中,没有发生交换,说明数组已经是有序的,就可以终止排序。
2.3、代码实现(基础版)
package com.zcy.sorting.bubble;
import java.util.concurrent.ForkJoinPool;
/**
* 冒泡排序
*
* @Author: Crucis_chen
* @Date: 2021/10/29 14:08
*/
public class Bubble01 {
public static void main(String[] args) {
int[] array = {5, 1, 3};
bubbleSort(array);
for (int i = 0; i < array.length; i++) {
System.out.print(array[i]+" ");
}
}
//冒泡排序方法(从小到大排序)
static void bubbleSort(int[] array) {
int emp = 0;
for (int i = 0; i < array.length; i++) {
for (int j = 0; j < (array.length - 1) - i; j++) {
//前面的元素 array[j] 后面的元素 array[j+1]
if (array[j] > array[j+1]){
//交换它们
emp = array[j];
array[j] = array[j+1];
array[j+1] = emp;
}
}
}
}
}
2.4、代码实现(优化)
将之前的代码改造,方便看出循环细节。
//冒泡排序方法(从小到大排序)
static void bubbleSort(int[] array) {
//缓存变量
int emp = 0;
//计数,循环组数
int count1 = 0;
//计数,组内循环次数
int count2 = 0;
for (int i = 0; i < array.length; i++) {
System.out.println("--------------------------");
count1++;
count2 = 0;
for (int j = 0; j < (array.length - 1) - i; j++) {
count2++;
//前面的元素 array[j] 后面的元素 array[j+1]
if (array[j] > array[j+1]){
System.out.println("第"+count1+"组,"+"第"+count2+"次排序,发生了一次交换:"+array[j]+","+array[j+1]);
//交换它们
emp = array[j];
array[j] = array[j+1];
array[j+1] = emp;
}else{
//这次排序,一次交换也没发生,此时数组已经有序了
System.out.println("第"+count1+"组,"+"第"+count2+"次排序,没有发生交换");
}
}
}
}
传入一个有序数组,就发现了问题:
public static void main(String[] args) {
int[] array = {1, 2, 3, 4, 5};
bubbleSort(array);
for (int i = 0; i < array.length; i++) {
System.out.print(array[i]+" ");
}
}
输出
可以看出,尽管一次交换都没有发生,但冒泡排序还是忠实地执行了所有循环,这显然不合理。
应该让它判断,
如果一组循环中一次交换也没有发生,就停止循环。
改进后的代码(去除了计数部分)
//冒泡排序
public static void BubbleSort(int[] arr) {
int tmp = 0;
boolean flag = false;
if (arr.length <= 1) return;
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < (arr.length - 1) - i; j++) {
flag = true;
if (arr[j] > arr[j + 1]) {
flag = false;
tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
if (flag) break;
}
}
只要有一组排序内,数组已经有序,就不进行接下来的所有排序。
2.5、重点
冒泡排序的重点,是两次循环的次数。
首先要明确,单纯的两层for循环嵌套,外层循环的最终有效循环次数是取决于内层for循环的。因为只要内层for循环条件不达成,那么相当于外层这一次循环什么也没干。
第一层循环,是要遍历数组,获取每个下标的值,所以循环条件是:i=0;i<array.length
第二层循环,是要交换,当前元素是array[j],下一个元素是array[j+1],那么如果控制不好条件,很容易发生越界。
结合图解,数组长度为3,时:
- 第一组循环(i=0),内层循环2次
- 第二组循环(i=1),内层循环1次
- 第三组循环(i=2),内层不循环了
可以推断出,内层循环的条件应该是,数组长度-i-1。
分析一下,-i是因为,i的值代表已经确定的值的个数,所以还剩(数组长度-1)个数需要确定大小。
而-1是因为,只剩两个数时,只交换一次,而不是两次。所以就是(数组长度-i-1)
3、选择排序
3.1、思想
从待排序的数组中,按指定的规则选出某一元素,再按规定交换位置,达到排序的目的。
比如要从小到大排序,就找到第1位到最后一位中最小的,让它和数组第1位交换,让第1位成为最小的;
继续,找到第2位和最后一位中最小的,让它和第2位交换...
共操作(数组长度-1)次,当数组后两位完成交换,排序结束。
选择排序比冒泡排序,效率高一些
如何知道,指定数字到数组结尾这些数字中,哪个最小?
比如要确定从第1位到最后一位,最小的那个数字:
- 设置一个变量min_index,存放最小数据的下标
- 假设当前数字(第1个)最小
- 将它与后面的每个数进行比较(1比2,1比3...)
- 如果发现有比它更小的数,就将它的下标赋给min_index。
- 比较了一轮之后,array[min_index]就是最小的数据。
3.2、图解
3.3、代码实现
public static void SelectionSort(int[] arr){
int tmp = 0;
//每次循环确定一个最小值
for (int i = 0; i < arr.length; i++) {
//区间内最小数的下标
int index = i;
//找出从当前下标到末尾的最小值
for (int j = i+1; j < arr.length; j++) {
if (arr[j] < arr[index]) index = j;
}
//将最小数与前面的数交换
if (index != i){
tmp = arr[i];
arr[i] = arr[index];
arr[index] = tmp;
}
}
}
3.4、重点
选择排序,重点是找到区间内最小的元素,去交换位置。
也是两层for循环。
第一层循环,用于遍历数组,获取每一个下标(i)
第二层循环,用于寻找区间内最小的元素。做法是,假设当前数是最小的数,然后和当前数的下一个数开始,到数组结尾的每一个数进行比较。
第二层循环,目的是根据每一个数(array[i]),动态地创建区间,比较得出最小数的下标。
在第一层循环内,获取第二层循环得出的最小的数,和指定位置的数交换即可。
4、直接插入排序
4.1、思想
插入式排序,是对待排序的数组,以插入的方式寻找某元素的正确位置,达到排序的目的。
基本思想是,把n个待排序的元素看做一个有序表,和一个无序表。
开始时,有序表只包含一个元素,无序表中包含(n-1)个元素。
排序的过程中,每次从无序表中取出第一个元素,把它的排序码依次与有序表元素的排序码进行比较,将它插入有序表的正确位置,保证有序表仍然有序。
比如斗地主。牌堆是无序表,手牌是有序表。每次摸牌,都在手牌中给它找一个正确的位置,将它插进来,直到牌堆被摸完。
4.2、图解
这里比起摸牌,更像是按顺序洗牌。
把数组前部分当做有序列表,后部分当做无序列表。
既然要插入,那么肯定需要先把要插入的位置腾出来,再去插入,否则会覆盖掉原先的元素。
设置一个缓存变量,用于存储当前要排序的这个数据,那么数组中就空出来一格。
每次循环比较时,如果没找到要插入的地方,需要将当前数据继续与有序列表前面的数据比较,同时,被比较的数据后挪一位。
关于"后挪"
逻辑上来讲,确实是后挪。但实际来讲,其实是
让该数的后一个元素,和它相等,也就是复制了一份,此时有序表是有一个冗余的。下轮循环,前面的数会依次覆盖后面的数,但冗余依然存在。
直到新插入的数完成插入,它覆盖的就是冗余的位置,整个过程没有数据丢失。例如有序表是{1, 3, 5},要插入2:
{1 3 5 5}
{1 3 3 5}
{1 2 3 5}
4.3、代码实现
package com.zcy.sorting.insert;
/**
* 插入排序
* @Author: Crucis_chen
* @Date: 2021/10/29 18:06
*/
public class Insert01 {
public static void main(String[] args) {
int[] array = {1, 5, 3};
insertSort(array);
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
}
//插入排序,从小到大
static void insertSort(int[] array) {
//要插入的数据
int insertVal = 0;
//要插入数据的上一个数的下标(无序表最后一个元素的下标)
int preIndex = 0;
//获取每个数组下标
for (int i = 1; i < array.length; i++) {
//要排序的数,第一个已经排好了
insertVal = array[i];
//这个数的上一个数的下标(无序表最后一个元素的下标)
preIndex = i-1;
//如果左边的这个元素比当前元素大,当前元素就需要左移,直到左边的数比它小
while (preIndex >= 0 && array[preIndex] > insertVal) {
//被比较的元素右移,腾出位置
array[preIndex+1] = array[preIndex];
//当前元素左移
preIndex--;
}
//左边的数比当前元素小,当前元素需要插入左边元素下标的后一位
if (preIndex+1!=i){
array[preIndex+1] = insertVal;
}
}
}
}
更简单的写法
//插入排序
public static void InsertionSort(int[] arr) {
for (int i = 0; i < arr.length; i++) {
//当前值
int value = arr[i];
int j = i - 1;
//寻找插入点
for (; j >= 0; j--) {
//插入arr[j]的后面
if (arr[j] > value) {
//数据移动
arr[j + 1] = arr[j];
} else {
break;
}
}
arr[j + 1] = value;
}
}
4.4、重点
直接插入排序,是把数组的左边看做有序表,右边看做无序表。初始时,有序表只有第一个元素,让后面的每个元素,与有序表中的元素挨个进行比较,最后有序表的规模慢慢扩大,直到整个数组都是有序的,一共循环(数组长度-1次),每次有序表的长度+1。
值得注意的是,有序表维护了插入元素的下标顺序,或者说,整个数组其实依然是连续的!
在处理一个元素时,它是无序表的开头,它的上一个元素,是有序表的最后一位,也就是有序表目前的极值。
插入式排序既然存在插入,那么插入之后,整个数组的下标依然是连续的。或者说,数组的下标一定是连续的
只要了解这一点,循环就很简单了。
外层for循环,目的是获取第二个元素开始,到数组末尾(整个初始无序表)的数组下标,所以初始 i=1,等于数组长度时停止。
内层while循环,目的是让这个数与有序表中的数挨个比较,因为索引是连续的,所以很容易获得每个无序表的元素。注意,如果本轮循环没有找到合适的插入点,需要将被比较的有序表元素后移一位。
最后,如果插入的位置不是原先的位置,那么就插入。
5、时间复杂度
最好情况:数组有序,时间复杂度O(n),这是插入排序的亮点
平均情况:时间复杂度O(n2),太高了
最坏情况:数组逆序,时间复杂度O(n2),太高了
在序列很短或者元素有序时,插入排序的速度非常快。
5、希尔排序
5.1、思想
希尔排序是希尔(Donald Shell)提出的一种,对于直接插入排序的改进算法,也称为缩小增量排序。
直接插入排序存在的问题
从小到大排序,如果要插入的数据比较小,它就需要几乎完整遍历一次有序表。遍历不怎么影响速度,关键是,每次判断,还会发生数组元素的向后复制。如果原始数据是倒序的,效率会非常低。
结论:
直接插入排序,在需要插入的数较小时,效率会受到影响。
希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能。这样可以让一个元素可以一次性地朝最终位置前进一大步。
然后算法再取越来越小的步长进行排序,算法的最后一步就是普通的插入排序,但是到了这步,需排序的数据几乎是已排好的了,这样再进行插入排序就很快了。
5.2、图解
5.3、代码实现(交换式)
希尔排序的核心思想是分组,每组进行排序,然后扩大组的规模。
那么,也可以用交换的方式来进行组内的排序。(不过正统的希尔排序是用的插入排序,效率比这个更高)
package com.zcy.sorting.shell;
import javax.swing.text.GapContent;
/**
* 希尔排序,交换式
*
* @Author: Crucis_chen
* @Date: 2021/10/30 9:28
*/
public class Shell01 {
public static void main(String[] args) {
int[] array = {4, 5, 1, 3, 2};
shell(array);
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
}
//希尔排序
static void shell(int[] array) {
//缓存
int tmp = 0;
//分组
for (int group = array.length / 2; group > 0; group /= 2) {
//遍历每组
for (int i = group; i < array.length; i++) {
//遍历每组的元素
for (int j = i - group; j >= 0; j -= group) {
//如果组内的元素逆序,就交换
if (array[j] > array[j + group]) {
tmp = array[j];
array[j] = array[j + group];
array[j + group] = tmp;
}
}
}
}
}
}
6、快速排序
6.1、思想
快速排序(quicksort)是对冒泡排序的一种改进。
思想:
- 每次找一个基准点,然后设置两个哨兵,分别指向区间的头和尾
- 左侧的哨兵负责寻找比基准点小的数据,右侧的哨兵负责寻找比基准点大的数据,一旦找到,就交换它们对应的元素值
- 直到两个哨兵相遇,说明这个基准点已经归位了
- 递归处理所有区间,直到所有的数都作为基准点归位,排序结束
为什么快速排序比冒泡排序更快
- 冒泡排序只是交换相邻的元素,而快速排序交换的元素之间的跨度更大,所以总的交换次数就少了
- 最坏的情况下,快排每次都是发生相邻元素的交换,就退化成了冒泡排序。
- 注意,快排的一趟排序不仅会确定一个元素的有序下标,还会根据基准点的选取,让区间变得趋向于更有序。
6.2、图解
使用数组中间的数,作为每次分隔的临界点
在分界点左边,存放比临界点小的数。如果左边找到了比它大的,右边找到了比它小的,则二者互换一下位置
6.3、代码实现
技巧:
分为两部分,quicksort和part。
-
quicksort是一个递归划分子区间的方法
每次都用当前的区间范围去调用part获得一个有序下标
再根据这个下标去将现有区间划分成左右两部分,都不包括该下标本身
区间是左闭右闭的,一旦左边界与右边界重合,return
-
part是在区间内找到基准点有序下标的方法
每次都选取一个基准点,比如固定选取区间右侧
启用一个基地指针和一个遍历指针,
一旦遍历指针的元素小于基准点,就判断基地指针和遍历指针是否相同,如果不同就进行交换最后,把基地指针元素和基准点交换,返回基地指针下标
public static void main(String[] args) {
int[] nums = new int[]{3, 5, 1, 7, 9, 2};
quickSort(nums, 0, nums.length - 1);
for (int num : nums) {
System.out.println(num);
}
}
// left:区间左侧下标 right:区间右侧下标 左闭右闭
private static void quickSort(int[] nums, int left, int right) {
if (left >= right) return;
// 确定下来一个基准点的有序下标
int ensure = part(nums, left, right);
// 分治处理其他基准点
quickSort(nums, left, ensure - 1);
quickSort(nums, ensure + 1, right);
}
private static int part(int[] nums, int left, int right) {
// 固定选取基准点为区间最右侧
int target = nums[right];
// base是基地指针,左侧都是小于target的元素,cur用于遍历
int base = left, cur = left;
for (; cur < right; cur++) {
if (nums[cur] < target) {
// 如果两个指针位置相同,就不用修改
if (base != cur) {
int tmp = nums[base];
nums[base] = nums[cur];
nums[cur] = tmp;
}
base++;
}
}
// 确定基准点的位置,此时base指向的就是大于target的第一个下标,交换即可
int tmp = nums[base];
nums[base] = nums[right];
nums[right] = tmp;
return base;
}
6.4、快排的优化
快排的效率最好情况是O(nlogn),但前提是每次选取的基准点都能正好把区间一分为二。
最坏的情况:
- 组已经是有序的,并且每次都选取区间最右侧作为基准点
- 那么需要进行n次分区操作,每次需要扫描n次、n-1次...、1次,平均扫描n/2次,所以时间复杂度退化成O(n2)
合理选取基准点:
- 不要固定选取某个位置作为基准点
- 无法真正选取到区间的中间,因为这就涉及到对数组的遍历,代价太高
方案一:采样三个元素。
- 可以找区间两端的数和中间位置的数,把这三个数的中位数作为基准点,这样可以尽量避免选取的基准点在一端的情况
方案二:可以随机去选基准点,这样总体来说是很分散的,可以规避一些极端情况
- 缺点:生成随机数需要时间、综合效率不稳定
方案三:根据不同情况来选择
- 短序列(<= 8),选择固定位置,因为影响不大
- 中序列(<= 50),采样三个元素
- 长序列(> 50),采样九个元素
这种采样方式还有一个作用:
- 在采样的过程中,我们可以探知序列的有序情况,这对实现更优秀的混合排序算法是很有帮助的。
随机选基准点的快排
private void quickSort(int[] nums, int left, int right){
if (left >= right) return;
int ensure = randomizedPart(nums, left, right);
quickSort(nums, left, ensure - 1);
quickSort(nums, ensure + 1, right);
}
private int randomizedPart(int[] nums, int left, int right) {
// 随机选一个基准点
int index = new Random().nextInt(right - left + 1) + left;
// 把这个基准点放在区间的最右侧,方便取
int tmp = nums[right];
nums[right] = nums[index];
nums[index] = tmp;
return part(nums, left, right);
}
private int part(int[] nums, int left, int right){
int target = nums[right];
int base = left, cur = left;
for (; cur < right; cur++) {
if (nums[cur] < target) {
if (base != cur) {
int tmp = nums[base];
nums[base] = nums[cur];
nums[cur] = tmp;
}
base++;
}
}
int tmp = nums[base];
nums[base] = nums[right];
nums[right] = tmp;
return base;
}
注意:
- 最简单的做法是,在随机方法中随机一个下标,把它换到区间的最右侧,然后调用part
- part不需要做任何改动,直接每次取区间右侧作为基准点即可
这样的优化能带来非常大的效率提升
7、归并排序
7.1、思想
归并排序(merge sort)是利用归并的思想实现的排序算法。
该算法采用经典的分治策略(divide and conquer)。
分治策略
分:分治法将问题分成一些小的问题,然后递归求解(化整为零)
治:将“分”得到的答案总结在一起(化零为整)
即分而治之。
注意:分的时候只管分,治的时候只管治,只需要保证调用正确即可,治的时候不需要考虑分的细节。
7.2、图解
“分”的过程比较简单,只是将原始数组拆分了,为了方便“治”。
“治”的阶段,排序合并数组的细节
- 设置一个临时数组
- 使用双指针遍历传入的两个数组,每次把两个指针中较小的一方放入临时数组
- 如果一方遍历到头了,就把另一方的所有数字存入临时数组
- 使用临时数组替换原有数组,继续与其他数组“治”。
7.3、代码实现
public static void main(String[] args) {
int[] array = new int[] {1, 5, 3, 4, 6, 2};
mergeSort(array, 0, array.length-1);
for (int i : array) {
System.out.println(i);
}
}
private static void mergeSort(int[] array, int left, int right){
if (left < right){
int mid = left + (right - left) / 2;
// 分
mergeSort(array, left, mid);
mergeSort(array, mid+1, right);
// 治
merge(array, left, right, mid);
}
}
private static void merge(int[] array, int left, int right, int mid){
// 左边数组的起始索引,末尾是mid
int l = left;
// 右边数组的起始索引,末尾是right
int r = mid + 1;
// 计算出临时数组的长度
int length = right - left + 1;
int[] tmp = new int[length];
// 临时数组的索引
int index = 0;
// 使用双指针遍历两个数组,选出较小的加入临时数组
while (l <= mid && r <= right){
int num1 = array[l], num2 = array[r];
if (num1 < num2) {
tmp[index++] = num1;
l++;
} else {
tmp[index++] = num2;
r++;
}
}
// 如果有剩余,就加入临时数组
while (l <= mid){
tmp[index++] = array[l++];
}
while (r <= right){
tmp[index++] = array[r++];
}
// 把临时数组的内容替换到array中
index = 0;
int cur = left;
while (cur <= right){
array[cur++] = tmp[index++];
}
}
7.4、重点
归并排序的重点,在“并”(“治”)上。需要理解每个形参的实际含义。
left:要排序合并的左侧数组,的起始索引
mid:要排序合并的左侧数组,的末尾索引
mid+1:要排序合并的右侧数组,的起始索引
right:要排序合并的右侧数组,的末尾索引
在排序的过程中,需要依次比较左右两边对应位置的数据大小,并按顺序存入临时数组中。
如果原始数据是奇数个,会出现某个小数组比其他多一个数据的情况。在比较时,如果有数据剩余,它就是左右两个数组中最大的那个,存入临时数组的末尾。
最后,将原始数组替换成临时数组,接着递归排序。
8、基数排序
8.1、简介
基数排序(radix sort)属于“分配式排序”(distribution sort),又称“桶子法”(bucket sort)。基数排序是桶排序的扩展。
顾名思义,它是通过键值的各个位的值,将要排序的元素分配至某些“桶”中,达到排序的效果
这个排序比较特殊,不需要直接对元素进行相互比较,也不需要将元素相互交换,你需要做的就是对元素进行“分类”。
基数排序是“以空间换时间”的经典算法
8.2、思想
将所有待比较的数据,统一成相同的长度,不够的前面补0。
然后从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成后,数据就有序了。
8.3、图解
设置10个桶(一维数组)。因为每一位的数字可能是0~9
第一轮排序,将数字按个位从小到大排好。第二轮,按十位...
8.4、代码实现
package com.zcy.sorting.radix;
/**
* 基数排序
* @Author: Crucis_chen
* @Date: 2021/11/1 12:53
*/
public class RadixSort01 {
public static void main(String[] args) {
int[] array = {1, 20, 600, 5, 80};
RadixSort(array);
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
}
static void RadixSort(int[] array){
//获取数组中最大数的位数
//获取数组中最大的数
int max = array[0];
for (int num : array) {
if (num > max) {
max = num;
}
}
//获取它的位数
int maxLength = String.valueOf(max).length();
//创建一个二维数组,表示十个桶,每个桶的容量是数组长度(万一传入一万个个位数)
int[][] bucktes = new int[10][array.length];
//创建一个一维数组,记录每个桶每次放入数据的个数,比如bucketElementCounts[0] = 1,代表0号桶放入了一个数据
int[] bucketElementCounts = new int[10];
//记录要操作的位数,首先操作个位
int n = 1;
//每一次循环,操作的位数提高一位(个位-十位-百位...)
for (int i = 0; i < maxLength; i++) {
//对每个元素的这个位,进行分类
for (int j = 0; j < array.length; j++) {
//当前数据对应位上的值
int digitOfElement = array[j] / n % 10;
//放入对应的桶中
bucktes[digitOfElement][bucketElementCounts[digitOfElement]] = array[j];
//该桶的计数+1
bucketElementCounts[digitOfElement] ++;
}
//原始数组的索引
int index = 0;
//全部元素放入完毕,按桶的顺序取出元素,放入原先数组(遍历10次,每次取空一个桶)
for (int k = 0; k < bucketElementCounts.length; k++) {
//如果桶不为空
if (bucketElementCounts[k]!=0){
//将该桶的所有数据取出
for (int l = 0; l <bucketElementCounts[k]; l++) {
array[index] = bucktes[k][l];
index++;
}
}
//这个桶取出完毕,清空这个桶的计数
bucketElementCounts[k] = 0;
}
//要操作的位提高一位
n = n*10;
}
}
}
8.5、重点
基数排序非常好理解,但编写时也需要注意。
如何实现统一所有待排序数的位数
- 进行一次简单的迭代,获得待排序数中的最大数
- 获取它的位数(可以转成字符串,获取字符串的长度)
- 得到的位数,就是操作数中的最大位数。
- 最外层循环,实现的是,每次提升一个操作位数,直到达到最大操作位数。循环内进行这个位数的分类、倾倒。
如何实现“桶”
设置一个二维数组,用于表示10个桶。
int[][] bucktes = new int[10][array.length];10是因为,每位是0~9。每个桶的容量是待排序数的个数
再设置一个一维数组,用于记录每个桶这轮存放了几个数据,方便后期取出。
int[] bucketElementCounts = new int[10];比如bucketElementCounts[0] = 1,代表0号桶放入了一个数据
9、堆排序
9.1、概念
堆排序是利用“堆”这种数据结构进行排序的方法,属于选择排序的一种,是不稳定的。
堆排序是原地排序,时间复杂度O(nlogn)
9.3、堆排序的思想
假设按升序排序。
堆排序大致分为两个部分:建堆(从数列上建堆)、排序
简易步骤:
- 将待排序的数组构建成一个大顶堆,此时,堆的根节点就是整个待排数组的最大值
- 将其与待排数组的末尾元素进行交换,末尾元素就是最大值了
- 将剩余的待排数重新构成一个大顶堆。重复这个过程,就可以得到一个有序序列。
建堆的两种思路
建堆的操作是原地的,不需要新的数组,直接在原数组上操作。
-
第一种,遍历数组,假设初始堆中只有一个数据,将后面的数据依次插入堆中。由于堆化的过程只发生了交换,所以可行。
这种方式对n-1个节点进行了堆化,逻辑上是从前往后处理数据,从下往上构建堆。
-
第二种,从后往前遍历数组,每个数据从上往下堆化。这种方式只对非叶子结点进行堆化,即处理了n/2个节点,效率更高。
性能分析
建堆的时间复杂度是O(n),排序时需要一次堆化,时间复杂度是O(logn),所以堆排序总的时间复杂度是O(nlogn)
9.4、代码实现
package com.zcy.sorting.heap;
/**
* 堆排序
*
* @Author: Crucis_chen
* @Date: 2021/11/4 14:36
*/
public class HeapSort {
public static void main(String[] args) {
int[] array = {4, 6, 8, 5, 9};
heapSort(array);
for (int i : array) {
System.out.print(i + " ");
}
}
//堆排序的方法
public static void heapSort(int[] array) {
//缓存
int tmp = 0;
//将待排数组构建成一个大顶堆
//初始化i为最后一个非叶子结点的下标
for (int i = array.length / 2 - 1; i >= 0; i--) {
adjustHeap(array, i, array.length);
}
//将堆顶元素与待排数组末尾元素互换,array[0]是堆顶元素
for (int j = array.length-1; j > 0; j--) {
//交换
tmp = array[j];
array[j] = array[0];
array[0] = tmp;
//从根节点开始,将整个二叉树重新构成大顶堆
adjustHeap(array, 0, j);
}
}
/**
* 将普通的顺序存储二叉树转换成大顶堆
*
* @param array 要转换的数组
* @param i 以i对应的节点为根节点,将子树调整成一个大顶堆
* @param length 对多少个元素进行调整
*/
public static void adjustHeap(int[] array, int i, int length) {
//缓存当前下标
int tmp = array[i];
//j初始为左节点,每次去找左节点的左节点,迭代指定的次数(length次)保证进行了这次调整,整个堆依然是大顶堆的结构
for (int j = i * 2 + 1; j < length; j = j * 2 + 1) {
//判断左、右子节点,哪个更大,找出最大的子节点
if (j + 1 < length && array[j] < array[j + 1]) {
//右子节点更大
//指向右子节点
j++;
}
//判断最大的子节点(array[j])和父节点(array[i])谁更大
if (array[j] > tmp) {
//子节点更大,让父节点等于子节点的值
array[i] = array[j];
//对子节点的子树进行构建大顶堆
i = j;
} else {
//已经是大顶堆
break;
}
}
//进行到这里,已经将i对应的整颗子树的最大值,放在了顶部。
//将被替换的值,还原到它现在所在的地方。
array[i] = tmp;
}
}
9.5、堆排序与快速排序
快速排序的时间复杂度不稳定,平均为O(nlogn)。堆排序比快排更加稳定,时间复杂度也是O(nlogn),但实际开发中,快速排序比堆排序的性能更好。
为什么快速排序比堆排序性能更好
-
堆排序数据访问方式没有快速排序友好
- 快速排序时,数据是顺序访问的,可以利用CPU缓存。而堆排序,数据是跳着访问的
-
对于同样的数据,在排序过程中,堆排序的数据交换次数要多于快速排序。
- 在建堆的过程中,可能打乱原本就有序的数列。
10、排序算法的总结
9.1、常用算法的稳定性
-
冒泡排序:(稳定)
思想是相邻数据两两交换,如果两个相同的数据不挨着,肯定不会发生位置的交换,最终会挨个归位。如果两个相同数据挨着,不会发 生交换,最终会依次被挪走。
-
选择排序:(不稳定)
思想是依次找出数组中最小的数,然后与对应顺序的数字交换。在交换时,如果相同数的一方被换到了较小数所在的位置,则先后顺序 会发生变化。例如 5, 5, 2,排序后变成了 2, 5, 5。这两个5,顺序发生了交换。
-
直接插入排序:(稳定)
思想是依次把待排数列的数字插入有序列表中,插入时,越过当前数字的条件是,此数据小于当前被比较的有序数。如果等于,它会被 放在该有序数之后,它们的顺序没有发生改变。
-
希尔排序:(不稳定)
思想是每次按照不同步长,进行多次插入排序。一次插入排序是稳定的,但进行多次,就无法保证稳定性。
-
快速排序:(不稳定)
思想是将一个数组分为左、右两部分,如果数据不满足与中间数据的大小关系,就将左右数据进行交换。普通情形下,它是稳定的,但 如果发生了中间数据的交换,相同数据的顺序就可能被打乱。
-
归并排序:(稳定)
思想是先将待排数列“化整为零”,然后按顺序“化零为整”。如果两个数据相等,那么不会发生交换,顺序不变。
-
基数排序:(稳定)
思想是将每个待排数放入“桶”中,再按照桶的顺序取出。如果两个数据大小相等,它们会被放进同一个“桶”中,顺序在前的先被放入,索引较小,所以也先被取出,顺序不变。
总结:
- 稳定的排序算法:冒泡排序、直接插入排序、归并排序、基数排序
- 不稳定的排序算法:选择排序、希尔排序、快速排序、堆排序(快些选一堆)
9.2、常用算法占用的额外内存
需要占用额外内存的排序算法:归并排序(每个零散小数组)、基数排序(桶、桶计数器)
9.3、各排序算法性能的比较图
9.4、常用排序算法的时间复杂度
O(n^2)
平均性能为O(n^2)的有:直接插入排序,选择排序,冒泡排序
在数据规模较小时(9W内),直接插入排序,选择排序差不多。
当数据较大时,冒泡排序算法的时间代价最高。
性能为O(n^2)的算法基本上是相邻元素进行比较,基本上都是稳定的。
O(nlogn)
平均性能为O(nlogn)的有:快速排序,归并排序,希尔排序,堆排序。
其中,快排是最好的,其次是归并和希尔,堆排序在数据量很大时效果明显(堆排序适合处理大数据)。
这四种排序可看作为“高级排序算法”
其中,快排效率最高(大数据就不行了,而且速度有概率性),但在待排序列基本有序的情况下,会变成冒泡排序,接近O(n^2).
希尔排序对增量的标准无法准确表示,总之增量对性能会有影响。
归并排序效率非常不错,在数据规模较大的情况下,比希尔排序和堆排序要好。
多数先进的算法都是因为跳跃式的比较,降低了比较次数,但牺牲了排序的稳定性。
9.5、常用排序算法的适用场景
排序算法没有最优秀的,只有最适合的。
冒泡排序:适合数据量不大(9W内),对稳定性有要求,且数据基本有序的情况。
选择排序:适合数据量不大,对稳定性无要求,且数据基本有序的情况。比冒泡排序更快
直接插入排序:适合数据量不大,对稳定性有要求,且数据基本有序的情况。
希尔排序:适合数据量不大,对稳定性无要求的情况。非常快
快速排序:常用于找出一组数据中,前k大(或小)的数据、
归并排序:适合对稳定性有要求的情况。在数据较多时,要考虑内存空间的开销。
基数排序:适合数据的分布比较集中的情况
6、三大排序算法的 benchmark
在数据随机的情况下,插入排序、快速排序、堆排序的测试结果是:
- 短序列(16个数):插入排序最快
- 中序列(128个数):快排最快,堆排序的耗时也差不多
- 长序列(1024个数):快排最快,堆排序的耗时也差不多
在数据有序的情况下,插入排序、快速排序、堆排序的测试结果是:
- 短序列(16个数):插入排序最快
- 中序列(128个数):插入排序最快
- 长序列(1024个数):插入排序最快
数据有序,插入排序是最快的,快排最慢,堆排序虽然慢,但是比快排快很多
测试结论:
- 在序列较短,或者序列有序时,插入排序非常快
- 大部分情况下,快排的综合性能较好
- 几乎在任何情况,堆排序的性能都比较稳定,不会太慢
11、设计一个更优秀的排序算法
1、思想
插入排序的最好时间复杂度是O(n),但是平均复杂度太高。快速排序和堆排序的平均时间复杂度是O(nlogn)
能够考虑把排序算法结合起来使用,从而吸收各自的优点?
2、pdqsort
1、概述
pdqsort(pattern-defeating-quicksort),它的不同版本应用在C ++、Rust、GO 1.19中。
它是一种不稳定的混合排序算法,对常见的序列类型做了特殊优化,在不同的场景下都有着不错的性能。
2、设计思想
由于插入排序在短序列时效率很高,所以可以先判断待排序列的长度:
- 如果长度小于一个阈值(经测试,24比较合适),就使用插入排序
- 否则,就使用快速排序,保证整体的性能(固定选取区间最右侧作为基准点)
优化点1:快排瓶颈
快排的最坏情况效率很低。
所以合理的方法是,在快排瓶颈时,换成堆排序来提高效率。这产生了一个问题:如何得知快排表现不佳?
处理方式:
- 当基准点的位置离序列两端很近时(距离小于length/8),判断此趟排序表现不佳
- 记录表现不佳的次数,当达到一个阈值后,切换到堆排序来接管剩余的排序工作
优化点2:优化快排
之前相当于提供了一个保底工作,但是先用快排试错的成本较高,还是应该尽量去优化快排。
优化思路就是对基准点的选择,不应该每次都固定选取区间的最右侧,这样可能导致选取的数偏离区间中心很多,造成效率低下。
最合理的选取基准点方式,是采样的方式。比如采样头尾中三个元素,选择它们的中位数作为基准点。
这种采样方式还有一个作用:在采样的过程中,我们可以探知序列的有序情况。
- 如果采样的三个元素是逆序的,就可以猜测整个序列可能整体是逆序的,就对整体进行一次翻转,有几率直接完成排序
- 如果采样的三个元素是有序的,就可以猜测整个序列可能整体是有序的,就切换成插入排序,提高效率
优化点3:监测插入排序
在快排的过程中,如果经过采样发现序列可能是有序的,就使用插入排序。
但是很可能整个序列几乎是逆序的,只有那三个采样点是有序的,如果后续一直使用插入排序,效率就会非常低,因为插入排序不适合逆序情况
这就需要检测插入排序中,发生元素交换的次数。如果元素交换次数超过一个阈值,就放弃插入排序,转而继续使用快速排序。
优化点4:重复元素很多的情况
首先,如果一个序列重复元素很多,是有优化空间的。
- 如果重复元素分散在序列中,不利于快排选取基准点
- 可以先将重复元素紧挨着放在一起,减少重复元素对基准点选取的干扰
那么如何得知待排序列中有很多重复元素?
- 如果利用采样的数据,也不是不行,但是采样的数据量很少,不一定总是能提供有用的信息
合理的方案:
- 如果两次分区,得到的基准点对应的元素值都相同,就认为这个元素值是重复元素
- 这种策略,是经过了两次分区,也就是两次采样,采样的数据量提高了,获得的信息相对更贴近实际一些
优化点5:打散序列
如果有人得知了排序的规则,故意构造一个非常恶心的序列来反复排序,就会触及到整个混合排序算法的瓶颈。
解决方案:
- 在快排表现不佳时,说明基准点选择得不好。
- 在尚未触发转换为堆排序时,可以先选择优化此时的快排
- 具体做法是,每次表现不佳,就随机交换一些元素,相当于把序列打散,更利于下次基准点的选择
3、性能
最好情况:序列有序,使用插入排序,时间复杂度O(n)
最坏情况:序列逆序,使用堆排序,时间复杂度O(nlogn)
平均情况:使用快排,时间复杂度O(nlogn),但是实际比堆排序更快
12、排序算法的常见应用
1、找到第K大元素
1、K趟选择排序
利用选择排序,排序K趟即可,每次都遍历数组,找到最大的元素,交换到数组头部
public static void main(String[] args) {
int[] nums = new int[]{3, 5, 1, 7, 9, 2};
System.out.println(findK(nums, 2));
}
private static int findK(int[] nums, int k){
// 已经有序的数组范围
int ready = 0;
// 进行k趟冒泡排序
for (int i = 0; i < k; i++) {
// 当前趟的最大元素
int max = 0;
// 最大元素对应的下标
int index = 0;
// 遍历数组,找到最大的元素,交换到指定位置
for (int j = ready; j < nums.length; j++) {
if (nums[j] > max) {
max = nums[j];
index = j;
}
}
// 交换元素
int tmp = nums[ready];
nums[ready] = nums[index];
nums[index] = tmp;
ready++;
}
// 返回第k-1个元素
return nums[k-1];
}
适用于k相对n很小的情况。时间复杂度O(k * n)
2、快排+二分
思路:
- 利用快排
- 对整体分区时,如果右侧元素的个数正好为K,说明当前基准点正好对应第K大元素
- 如果右侧元素个数小于K,说明第K大元素位于左半边,只向左半边找即可,同时更新右侧阈值(默认的阈值是K)
- 如果右侧元素个数大于K,说明第K大元素位于右半边,只向右半边找即可
public static void main(String[] args) {
int[] nums = new int[]{3, 1, 2, 1, 2, 3, 5};
//Arrays.sort(nums);
int k = 7;
quickSort(nums, 0, nums.length - 1, k);
System.out.println(nums[nums.length - 1 - k + 1]);
}
private static void quickSort(int[] nums, int left, int right, int k) {
if (left >= right) return;
int ensure = part(nums, left, right);
// 当前基准点对应的是第几大
int cur = nums.length - ensure;
if (cur != k) {
if (cur < k) {
quickSort(nums, left, ensure - 1, k - cur);
} else {
quickSort(nums, ensure + 1, right, k);
}
}
}
private static int part(int[] nums, int left, int right) {
// 固定选取基准点为区间最右侧
int target = nums[right];
// base是基地指针,左侧都是小于target的元素,cur用于遍历
int base = left, cur = left;
for (; cur < right; cur++) {
if (nums[cur] < target) {
// 如果两个指针位置相同,就不用修改
if (base != cur) {
int tmp = nums[base];
nums[base] = nums[cur];
nums[cur] = tmp;
}
base++;
}
}
// 确定基准点的位置,此时base指向的就是大于target的第一个下标,交换即可
int tmp = nums[base];
nums[base] = nums[right];
nums[right] = tmp;
return base;
}
注意:
- 由于快排涉及到递归,很难直接返回结果
- 可以选择调用方法之后,在主程序中主动返回目标位置(K-1)的元素即可
时间复杂度O(n)
3、优先队列(堆)
建立一个大小为k的小根堆,每次更新堆顶元素,遍历一遍数组,堆顶元素就是这K个元素中的最小值,即第K大元素。
public int findKthLargest(int[] nums, int k) {
// 建立一个大小为k的小根堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
// 将前k个元素放入小根堆
for (int i = 0; i < k; i++) {
minHeap.offer(nums[i]);
}
// 遍历剩余的数组
for (int i = k; i < nums.length; i++) {
// 如果当前元素大于堆顶元素,就替换掉堆顶元素
if (nums[i] > minHeap.peek()) {
minHeap.poll();
minHeap.offer(nums[i]);
}
}
return minHeap.peek();
}
如果用堆排序,可能会让自己手写建堆
时间复杂度 O(nlogk),调整堆需要O(logk),遍历数组需要O(n)