排序算法汇总III
排序算法汇总 III
计数排序
计数排序就是一种时间复杂度 O(n) 的排序算法,对一定范围内的整数排序时,它的复杂度为O(n+k) 【其中 k 是整数的范围大小】
例如:我们需要对一列数组排序,这个数组中每个元素都是【1,9】区间内的整数。那么我们可以构建一个长度为9 的数组用于计算。记数数组的下标分别对应区间内的 9 个整数。然后遍历待排序的数组,将区间的每个整数出现的次数统计到计数数组中对应下标的位置。然后遍历计数数组,将每个元素输出,输出的次数就是对应位置记录的次数
算法实现如下 (以【1,9】为例)
伪计数排序
代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public static void countingSort(int[] arr){
// 建立长度为 9 的数组,下标 0~8 对应数字 1~9
int[] counting = new int[9];
// 遍历 arr 中的每个元素
for(int element: arr){
// 将每个整数出现的次数统计到计数数组中对应下标的位置
counting[element-1] ++ ;
}
int index = 0;
// 遍历计数数组,将每个元素输出
for(int i=0;i<9;i++){
// 输出的次数就是对应位置记录的次数
while(counting[i] != 0){
arr[index++] = i+1 ;
counting[i]-- ;
}
}
}在纯数字排序中,这个方式看起来无伤大雅,但在实际工作中,这样的排序算法几乎无法使用。因为被排序的对象往往都会携带其他的属性,但这份算法将被排序对象的其他属性都丢失了。
就好比业务部门要求我们将 11 号商品,22 号商品,33 号商品,44 号商品按照价格排序,它们的价格分别为 88 元、66 元,66 元,99 元。 我们告诉业务部门:排序完成后价格为 66 元、 66 元、88 元,99 元,但不知道这些价格对应哪个商品。这显然是不可接受的。伪计数排序 2.0
在统计元素出现的次数时,同时把真实的元素保存到列表中,输出时,从列表中取真实的元素。算法实现如下:
1 | public static void countingSort9(int[] arr) { |
通过队列来保存真实的元素,可以保证排序算法的稳定性。
真正的计数排序
举个例子,班上有 10 名同学:他们的考试成绩分别是:7, 8, 9, 7, 6, 7, 6, 8, 6,6,他们需要按照成绩从低到高坐到 0~9 共 10 个位置上。
一过程需要以下几步:
- 计数,统计出4 名同学考了 6 分,3 名同学考了 7 分,2 名同学考了 8 分,1 名同学考了 9 分;
- 从头遍历数组,第一名同学考了 7 分,共有 4 个人比他分数低,所以第一名同学排在了4号位置 (也就是第5 个)
- 第二名同学考了 8 分,共有 7 个人(4 + 3)比他分数低,所以第二名同学坐在 7 号位置;
- 第三名同学考了 9 分,共有 9 个人(4 + 3 + 2)比他分数低,所以第三名同学坐在 9 号位置;
…依次完成整个排序
【区别就在于计数排序并不是把计数数组的下标直接作为结果输出,而是通过计数的结果,计算出每个元素在排序完成后的位置,然后将元素赋值到对应位置。】
代码如下:
1 | public static void countingSort9(int[] arr) { |
桶排序
思想:
- 将区间划分为 n 个相同大小的区间,每个区间成为一个桶;
- 遍历数组,将每个数字装入桶中。
- 对每个桶内的数字单独排序,这里可以使用其他排序算法
- 最后按照排序将所有桶内的数字合并起来
使用桶排序算法时,我们需要考虑两个因素:
- 设置多少个桶合适
- 桶采用哪种数据结构
- 以数组作为桶
首先找到最大值和最小值1
2
3
4
5
6
7
8
9
10
11
12
13
14public static void bucketSort(int[] arr){
// 防空和防止数组越界
if(arr == null || arr.length<=1) return ;
// 找到最大值,最小值
int max = arr[0];
int min = arr[0];
for(int i=1;i<arr.length;i++){
if(arr[i]>max) max = arr[i];
else if(arr[i]<min) min = arr[i];
}
// 确定取值范围
int range = max - min;
//...
}
这里需要遍历一轮数组。
下一步,开始装桶:
1 | // 设置桶的数量,这里我们设为 100个,根据实际情况修改。 |
说明:
我们将桶的数量设置为 100100 个,这个值可以根据输入数据的实际情况修改。所有的桶是一个二维数组,第一个维度代表桶的编号,第二个维度代表桶内的数字,每个桶中都有一组数字。
由于每个桶的长度都等于待排序数组的长度,所以我们还需要一个单独的数组来记录当前桶内的有效数字数量。
装桶时需要做一些简单的运算:先通过第一步找到的取值范围计算出每个桶之间的间距,再通过当前数字与最小值的距离除以间距计算出桶的编号,最后根据编号把当前数字放入对应的桶中。
下一步是对每个桶内的数字进行单独排序,这一步需要借助其他排序算法:
1 | // 对桶内的数字单独排序 |
我们以插入排序为例, insertSort 函数如下:
1 | // 插入排序 |
这就是以数组作为桶实现的桶排序,它最大的缺点就是每个桶都和待排序数组一样长,非常消耗内存,容易导致「超出内存限制」错误。
完整代码如下:
1 | public static void bucketSort(int[] arr){ |
这里的扩容算法和 ArrayList 扩容很相似,先开辟一个更长的新数组,并将原数组拷贝过来,再加入新数字。但 ArrayList 扩容时,数组长度是先从 00 扩容到 1010,后续再不断乘以 1.51.5 倍,这会造成一定的内存浪费。
- 以链表作为桶首先,我们仍然是找到数组中的最大值和最小值,确定出数据的取值范围,然后划分 100100 个桶,计算出间距。并且把所有的数字都放入 LinkedList 链表中。装桶后,再对链表进行插入排序即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56public static void bucketSort(int[] arr) {
// 判空及防止数组越界
if (arr == null || arr.length <= 1) return;
// 找到最大值,最小值
int max = arr[0];
int min = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) max = arr[i];
else if (arr[i] < min) min = arr[i];
}
// 确定取值范围
int range = max - min;
// 设置桶的数量,这里我们设置为 100 个,可以任意修改。
int bucketAmount = 100;
// 桶和桶之间的间距
double gap = range * 1.0 / (bucketAmount - 1);
HashMap<Integer,LinkedList<Integer>> buckets = new HashMap<>();
// 装桶
for(int val: arr){
int index = (int)((val-min) / gap);
if(!buckets.containsKey(index)){
buckets.put(index,new LinkedList<>());
}
buckets.get(index).add(val);
}
int index = 0;
//对每个桶内的数字进行单独排序
for(int i =0 ;i<bucketAmount;i++){
LinkedList<Integer> bucket = buckets.get(i);
if(bucket == null) continue;
insertSort(bucket);
// 排序完成后将桶内的结果收集起来
for(int num : bucket){
arr[index++] = num;
}
}
// 对链表插入排序
public static void insertSort(LinkedList<Integer> arr) {
// 从第二个数开始,往前插入数字
for (int i = 1; i < arr.size(); i++) {
int currentNumber = arr.get(i);
int j = i - 1;
// 寻找插入位置的过程中,不断地将比 currentNumber 大的数字向后挪
while (j >= 0 && currentNumber < arr.get(j)) {
arr.set(j + 1, arr.get(j));
j--;
}
// 两种情况会跳出循环:1. 遇到一个小于或等于 currentNumber 的数字,跳出循环,currentNumber 就坐到它后面。
// 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNumber 的数字,也会跳出循环,此时 j 等于 -1,currentNumber 就坐到数列头部。
arr.set(j + 1, currentNumber);
}
}
采用 LinkedList,装桶时不会有额外的空间浪费,但装桶后排序会比较耗时,因为访问 LinkedList 链表时,get 和 set 方法都需要从链表头部开始,逐个向后寻找结点,效率较低。
使用链表排序还有一个问题:由于链表中不能存储基本类型,所以我们不得不将链表类型声明为 LinkedList
- 折中的方案: 装桶时用链表,桶内排序用数组
装桶时使用 LinkedList,避免扩容问题,桶内排序时将链表转换为数组,再进行排序,避免 LinkedList 排序较慢的问题和大量 「装箱」和「拆箱」的性能消耗(整个链表中的 Integer 都只需要拆箱一次)。
- 时间复杂度分析
第一步:找到最大值和最小值的过程需要一轮遍历,时间复杂度O(n),空间复杂度O(1)。
第二步:装桶的过程需要遍历一轮数组,时间复杂度O(n),空间复杂度与桶的数量以及数据结构有关,设桶的数量为 k,如果使用 k 个长度为 n 的数组作为桶,则空间复杂度为 O(kn),如果采用 ArrayList 或 LinkedList 来装桶,或者采用初始长度为 0 ,装桶时不断扩容的数组,则空间复杂度为 O(n) 。
第三次:桶内排序的过程和具体的排序算法有关,由于桶排序假设数据从均匀分布,所以每个桶内的数字数量为 n/k ,采用O(=n^2)级的排序算法,则每个桶内的时间复杂度为O((n/k)^2),所有桶完成排序的时间复杂度为 O(n^2/k);
如果采用 O(nlog n)O(nlogn) 级排序算法,每个桶内排序的时间复杂度 O((n/k)log(n/k)),所有桶完成排序的时间复杂度为 O(k(n/k)log (n/k)),即 O(nlog(n/k))。
在桶的数量合适的情况下,时间复杂度 O(n^2/k)和O(nlog(n/k))都约等于O(n)O(n)。