本博文是排序算法的第二篇,前作指路:【算法】JAVA实现常用排序算法一(冒泡排序、选择排序、插入排序、堆排序、快速排序)
学习算法最绕不开的就是排序,虽然这是个信息爆炸的时代,但搜索到的毕竟是别人的,特此总结了一下常用的几种排序,并根据自己的理解用Java实现出来。若存在理解不到位或者有更好的优化,欢迎指出。
先列出测试类和用到的一些方法,主要是简化执行排序时交换等步骤。
public class MySort {
public static void main(String[] args) {
int[] t1 = { 2, 1, 5, 3, 9 };
int[] t2 = { 25223, 21888, 5345, 98951247, 129, 6545464, 1212, 154 };
writeSort(t1);
writeSort(t2);
}
// 交换
public static void swap(int[] li, int a, int b) {
int t = li[a];
li[a] = li[b];
li[b] = t;
}
// 打印
public static void writeList(int[] li) {
System.out.print("[ ");
for (int i : li) {
System.out.print(i + " ");
}
System.out.println("]");
}
// 批量打印
public static void writeSort(int[] li) {
System.out.print("原序列:");
writeList(li);
System.out.print("希尔排序:");
writeList(shell(li));
System.out.print("归并排序:");
writeList(merge(li));
System.out.print("计数排序:");
writeList(count(li));
System.out.print(" 桶排序:");
writeList(bucket(li, 5));
System.out.print("基数排序:");
writeList(base(li));
}
}
和快速排序优化冒泡排序一样,希尔排序是对插入排序的优化,希尔排序的核心思想就是增量分组排序,而后减少增量。
虽然在增量的一个循环中还嵌套着一个插入排序,但增量的开始时length/2,插入排序的基数很小,随着增量的减少,数组趋于有序,插入排序的效率也大大提升。
增量 | 当前序列 | 排序序列 | 排序后 |
---|---|---|---|
2 | 2 1 5 3 9 | 2 - 5 - 9 | 2 - 5 - 9 |
2 | 2 1 5 3 9 | - 1 - 3 - | - 1 - 3 - |
1 | 2 1 5 3 9 | 2 1 5 3 9 | 1 2 3 5 9 |
这个经典例子好像看不出什么来,这时就是希尔排序的最坏情况了,小数量时无法调整,导致最后大数量时调整量很大,我们可以换个例子:
增量 | 当前序列 | 排序序列 | 排序后 |
---|---|---|---|
3 | 6 2 0 3 5 1 | 6 - - 3 - - | 3 - - 6 - - |
3 | 3 2 0 6 5 1 | - 2 - - 5 - | - 2 - - 5 - |
3 | 3 2 0 6 5 1 | - - 0 - - 1 | - - 0 - - 1 |
1 | 3 2 0 6 5 1 | 3 2 0 6 5 1 | 0 1 2 3 5 6 |
/*
* 希尔排序
* 平均时间复杂度:O(n^1.3)
* 最坏时间复杂度:O(n^2)
* 空间复杂度:O(1)
* 稳定性:不稳定
*/
public static int[] shell(int[] li) {
int[] c = copy(li);
// 从序列长度的一半作为初始增量,之后逐渐减少增量
for (int i = c.length / 2; i > 0; i = i / 2) {
// 内循环使用插入排序
for (int j = i; j <= c.length - i; j += i) {
for (int k = j; k > 0 && c[k] < c[k - i]; k -= i) {
swap(c, k, k - i);
}
}
}
return c;
}
归并排序根据字面意思,就是通过递归-合并实现的排序,核心思想就是将两个已有序的序列合并成一个新的有序序列。通俗来讲就是以序列中单个元素作为一个有序序列,通过递归不断合并成一个大的有序系列,最后完成整个序列的排序。
首先就是将原序列不断对分,使需要合并的两个序列为单元素,而后不断扩大再合并,有关对分的递归就是设定一个中间值center=(left+right)/2,左子序列为[left, canter],右子序列为[center+1, right]。
列表中不显示left==right时的序列,因为这是递归的结束条件。
left | right | center | 当前序列 | 左子序列 | 右子序列 |
---|---|---|---|---|---|
0 | 4 | 2 | 2 1 5 3 9 | 2 1 5 | 3 9 |
0 | 2 | 1 | 2 1 5 | 2 1 | 5 |
0 | 1 | 0 | 2 1 | 2 | 1 |
3 | 4 | 3 | 3 9 | 3 | 9 |
自上而下递归分割,自下而上合并排序。这里合并就举一个例子,不全部流程走一遍了。
左子序列 | 右子序列 | 选中元素 | 新序列 |
---|---|---|---|
1 2 5 | 3 9 | 9 | - |
1 2 5 | 3 | 5 | 9 |
1 2 | 3 | 3 | 5 9 |
1 2 | - | 2 | 3 5 9 |
1 | - | 1 | 2 3 5 9 |
- | - | - | 1 2 3 5 9 |
/*
* 归并排序
* 平均时间复杂度:O(nlogn)
* 最坏时间复杂度:O(nlogn)
* 空间复杂度:O(n)
* 稳定性:稳定
*/
public static int[] merge(int[] li) {
int[] c = copy(li);
resursion(c, 0, c.length - 1);
return c;
}
public static void resursion(int[] li, int left, int right) {
// 单元素后结束递归
if (left >= right) {
return;
}
int center = (left + right) / 2;
// 从中间位置分为左右两个子序列
resursion(li, left, center);
resursion(li, center + 1, right);
mergeSort(li, left, center, right);
}
public static void mergeSort(int[] li, int left, int center, int right) {
// 开辟空间存储排好序的新序列
int length = right - left;
int[] result = new int[length + 1];
// 左子序列起点
int aIndex = center;
// 右子序列起点
int bIndex = right;
// 逆推将最大元素放置在新序列最后位置
while (length >= 0) {
if (aIndex >= left && (bIndex <= center || li[aIndex] >= li[bIndex])) {
result[length--] = li[aIndex--];
} else if (bIndex > center && (aIndex < left || li[aIndex] < li[bIndex])) {
result[length--] = li[bIndex--];
}
}
// 将排好序的元素放回原序列
for (int i = left; i <= right; i++) {
li[i] = result[i - left];
}
}
以上的七种排序都是基于比较实现的,后面这三种排序则是线性非比较的排序。
计数排序需要额外开辟k(k=max-min+1)块空间用来存放序列中的元素,每一块空间代表一个元素值,而空间中的值计数这个元素的个数,只需遍历一遍原序列和一遍新序列就可以得到一个有序的序列。
这种排序很容易造成空间浪费,特别是序列元素分布不均匀时,如[1, 2, 3, 2, 1, 5, 99999999],这种很离谱的序列若真的执行就要分配长度为100000000的空间用来7个数的排序,这也是计数排序和桶排序的通病。所以使用时还是需要分析一下使用场景。
对于[2, 1, 5, 3, 9]这个序列a,需要先找到最大值9和最小值1,然后新建一个长度为(9-1+1)=9的数组b,然后遍历一次原序列:
原序列 | 新序列 |
---|---|
2 1 5 3 9 | 1 1 1 0 1 0 0 0 1 |
之后再遍历一次新序列,新序列中存入共b[i]个(i+min)。
之后就可以得到排好序的新序列[1, 2, 3, 5, 9]。
/*
* 计数排序
* 平均时间复杂度:O(n+k)
* 最坏时间复杂度:O(n+k)
* 空间复杂度:O(n+k)
* 稳定性:稳定
*/
public static int[] count(int[] li) {
int[] c = new int[li.length];
// 寻找最大值和最小值
int max = li[0];
int min = li[0];
for (int i : li) {
max = Math.max(max, i);
min = Math.min(min, i);
}
// 差值为计数序列的长度
int[] count = new int[max - min + 1];
for (int i : li) {
count[i - min]++;
}
// 提取为有序序列
for (int i = 0, k = 0; i < count.length; i++) {
while (count[i]-- > 0) {
c[k++] = i + min;
}
}
return c;
}
计数排序一口气开辟(max-min+1)块空间显然是不太高效的做法,桶排序就是计数排序与基于比较的排序的结合,全体上采用非比较的思想将一定区间的元素放入桶中,在局部的桶中采用比较的思想进行小数量的排序。
和计数排序一样,如果遇到比较离谱的序列,让绝大部分元素堆积在一个桶里面,而其余的桶都是空闲或者极少元素的,“空间换时间”的思路就得不偿失了。
桶排序的桶数量是由自己决定的,桶越多就越接近计数排序,桶越少就越接近桶中排序采用的比较排序。
常用的21539数量太少了不能很直观表现桶排序的思想,这里就用[9, 5, 3, 5, 7, 2, 8, 4, 1, 6]这个序列,然后分配3个桶,因为序列的区间是[1,9],所以桶的区间是[1,3],[4,6]和[7,9]。
桶排序一共分三步:第一步遍历原序列,将元素装进桶里;第二步遍历桶,将桶内元素排序;第三步从桶里依次取出元素得到有序序列。
步骤 | 桶A | 桶B | 桶C |
---|---|---|---|
1 | 3 2 1 | 5 5 4 6 | 9 7 8 |
2 | 1 2 3 | 4 5 5 6 | 7 8 9 |
然后就得到了有序序列[1, 2, 3, 4, 5, 5, 6, 7, 8, 9]
/*
* 桶排序
* 平均时间复杂度:O(n+k)
* 最坏时间复杂度:O(n+k)
* 空间复杂度:O(n+k)
* 稳定性:稳定
*/
public static int[] bucket(int[] li, int bucketCount) {
int[] c = new int[li.length];
List<List<Integer>> bucket = new ArrayList<>();
// 初始化
for (int i = 0; i < bucketCount; i++) {
bucket.add(new ArrayList<Integer>());
}
// 遍历序列放入桶中
for (int i : li) {
bucket.get(i / 5).add(i);
}
//排序并取出
for (int i = 0, index = 0; i < bucketCount; i++) {
bucket.get(i).sort(null);
for (int j : bucket.get(i)) {
c[index++] = j;
}
}
return c;
}
基数排序可以理解为优化后的桶排序,并且确定了桶数量和取消了桶内再排序。
计数排序固定了一个长度为10的空间,代表了0~9。然后从最低位(个位)开始将元素放入桶中,然后提取元素成新序列,继续升一位放入桶中,这样可以保证仅看当前位数桶中元素都是有序的,到最高位时最后一次提取元素可以直接得到有序序列。
最能体现基数排序的自然时有多位数的序列,我最爱的21539自然用不了了,这里使用[22, 1, 55, 332, 93, 80, 301, 89, 169, 1052]。
步骤 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
个位 | 80 | 1,301 | 22,332,1052 | 93 | - | 55 | - | - | - | 89,169 |
提取得到新序列:80,1,301,22,332,1052,93,55,89,169
步骤 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
十位 | 1,301 | - | 22 | 332 | - | 1052,55 | 169 | - | 80.89 | 93 |
提取得到新序列:1,301,22,332,1052,55,169,80,89,93
步骤 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
百位 | 1,22,1052,55,80,89,93 | 169 | - | 301,332 | - | - | - | - | - | - |
提取得到新序列:1,22,1052,55,80,89,93,169,301,332
步骤 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
千位 | 1,22,55,80,89,93,169,301,332 | 1052 | - | - | - | - | - | - | - | - |
提取得到新序列:1,22,55,80,89,93,169,301,332,1052
至此,基数排序结束,得到有序序列。
/*
* 基数排序
* 平均时间复杂度:O(n+k)
* 最坏时间复杂度:O(n+k)
* 空间复杂度:O(n+k)
* 稳定性:稳定
*/
public static int[] base(int[] li) {
int[] c = copy(li);
List<List<Integer>> count = new ArrayList<>();
// 查找最大值
int max = 0;
for (int i : li) {
max = max >= i ? max : i;
}
// 初始化
for (int i = 0; i < 10; i++) {
count.add(new ArrayList<>());
}
for (int base = 1; max / base > 0; base *= 10) {
// 根据位数存入
for (int i = 0; i < c.length; i++) {
count.get(c[i] / base % 10).add(c[i]);
}
// 取出得到新排序
int index = 0;
for (List<Integer> list : count) {
for (int i : list) {
c[index++] = i;
}
// 取出后清空数组
list.clear();
}
}
return c;
}
整理知识点的计划拖了很久很久,或是懒惰或是忙碌,唯独不变的就是拖延计划的借口每次都是很多。直到开始准备实习后处处碰壁,这才下定决心开始做一个属于自己的知识板块,而不是习惯性在搜索框中键入、回车、看完了事。
这定是一条漫长的路,能有一个平台可以成为我的目标的载体也算是生在这个时代的美丽。如果我这个偏笔记向的文章有帮助到各位而且那么还能看到这么后面,倒是我的荣幸了。
算法篇——排序至此告一段落。
只有成功者才能标榜自己的努力,其余皆是哀叹。哀叹者为唯成功论举旗,成功者为努力至上举杯。他们并无二致,我们截然不同。——沃朔德