十大排序算法
总览
排序算法 | 平均时/空复杂度 (对数组排序) | 排序方式 | 稳定性 | 初始序列相关性 | 最优时间复杂度情况 | 最差时间复杂度情况 | 排序思路特点 | 备注 |
---|---|---|---|---|---|---|---|---|
冒泡 | O(n2)/O(1) | 内排序 | 稳定 | 有关,初始逆序最慢 | O(n) | O(n2) | 两两比较,大的后移,一次能定一个元素的最终位置 | |
快速 | O(nlogn)/O(logn) | 内排序 | 不稳定 | 有关,成序最慢,越乱越快 | O(nlogn) | O(n2) | 左小右大,一次定一个 | |
插入 | O(n2)/O(1) | 内排序 | 稳定 | 有关,初始成序最快 | O(n) | O(n2) | 前部先成序,选择后面的数字插到的成序的数列里面 | |
希尔 | O(n1.2-2)/O(1) | 内排序 | 不稳定 | 有关,初始成序最快 | O(n) | O(n2) | ||
选择 | O(n2)/O(1) | 内排序 | 不稳定 | 无关 | O(n2) | O(n2) | 从后面选择最小的放到前面的最后面,一次定一个位置 | |
堆 | O(nlogn)/O(1) | 内排序 | 不稳定 | 无关 | O(nlogn) | O(nlogn) | ||
归并 | O(nlogn)/O(n) | 外排序 | 稳定 | 无关 | O(nlogn) | O(nlogn) | ||
基数 | O(d×(n+r))/O(n+r) | 外排序 | 稳定 | 无关 | O(d×(n+r)) | O(d×(n+r)) | d最长元素的位数, r个队列 | |
计数 | O(n+k)/O(n+k) | 外排序 | 稳定 | 无关 | O(n+k) | O(n+k) | 不是基于比较的排序算法 | n是关键字的个数,k是关键字中最大值减最小值的变化差 |
桶 | O(n)/O(n+k) | 外排序 | 稳定 | 无关 | O(n) | O(nlogn) | 线性排序方法 | k为划分的桶的个数 |
1.快排也可适用于链表148. 排序链表
总结:
1.数据量小,(如在jdk 1.7中如果 n ≤ 47),使用插入排序、折半插入排序
2.中等规模,使用改进的shell排序。(多大算中等规模呢?10w及以下)
3.大规模,使用快速排序(内部)、堆排序(内部)或归并排序(外部)。
4.文件基本成序,折半插入、希尔排序(这两个都属于插入排序)。
5.在中等及大规模数据中,如果:数字的总数 / 数据中不同数字的总数 = 225时,shell和快排效率基本相同;> 225 时,shell排序优于快排; < 250 时快排优于shell (换个思路想,大量重复数字不就代表数据基本成序嘛)
6.TopK三种求法:
- 6.1一次定一个位置的排序算法(冒泡、选择),时空复杂度 O(nK) O(K) (优点是出结果的时候TopK已经排好序了,后面两种:堆获取的是逆序;而随机选择是根本没有排好序,还需要进一步处理)
- 6.2堆排序,最大的K个用小根堆(把小的都拿出来了,剩下的不就是最大的了),最小的K个用大根堆(同理,最大的都拿出来)时空复杂度 O(nlogK) O(K),在解决问题的过程中不需要修改输入数组,所以可以不用将所有的数据都加载内存,内存中长存 K 个,每次读取 1 个就可以完成任务,因此可以处理海量数据。
- 6.3随机选择排序,优化的快排,争取找到第 K 大的元素做基准,进行一次划分后,左侧就是前 K 大的数。时空复杂度 O(n) O(logn) (这个占用空间太大了,处理不了大规模的数据,因为在解决问题的过程中,需要修改原始数据,所以需要将所有的数据都加载到内存,因此不能处理海量数据。
所以综合来看还是用堆排序来求取TopK最优!
补充:
Spark的中是 sortByKey().top(k): 使用Java的默认的排序算法双轴快排或者TimSort对所有每个分区中的所有数据进行排序,求取每个分区的TopK,或者由于每个分区的数据是存储在单机上的所以可以使用自定义堆排序的方法,求取每个分区的TopK,然后再合并所有分区的TopK,求取最终的TopK。)
一、内排序
所谓内部排序是指它们的数据量没有大到内存放不下的程度,所以它们的排序过程还可以在内存中进行。
内部排序的衡量指标一般是时间和空间的复杂度
交换排序
- 交换排序算法的排序过程中会存在两个元素间的位置互换。
1.1 冒泡排序
- 算法思想:
从头向尾两两之间相互比较,将较大的一个元素位置不断向后移。 - 时空复杂度:O(n2) O(1)
- 稳定性:稳定
- 算法的性能和初始数据的相关性:
1.初始序列是逆序的话是最慢的 - 特点:
1.一次能定一个(最大或者最小)元素的位置 - 适合处理的任务:
1.顺序存储或者链式存储的线性表
2.这个可以作为处理 TopK 任务的一种方式,时空复杂度分别是O(nK)和O(1),但不是最好的算法。 - 提升性能的trick:
1.设立交换标志位,可以提前结束排序 - 代码实现:
public void bubbleSort(int[] nums, int len) {
System.out.println("冒泡排序过程:");
boolean hasSwap = true;
for (int i = 0; i < len && hasSwap; i++) {
hasSwap = false;
for (int j = 0; j < len - i - 1; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums, j, j + 1);
hasSwap = true;
}
}
//showSortRes(nums, i + 1);
}
}
1.2 快速排序
- 算法思想:
快速排序是对冒泡排序的改进,其基本思想是基于分治的。
在待排序的序列中“任选”一个元素作为基准,通过一趟快排将序列中的元素划分成两部分,左侧的都小于基准元素,右侧的都大于基准。这样的一个过程称为一趟快排,因此每一趟快排都能定位一个元素到最终位置上。 - 时空复杂度:O(nlogn) O(logn)
- 稳定性:不稳定
- 算法的性能和初始数据的相关性:
1.初始序列越乱越快
2.能适用于顺序存储的线性表或者链表 - 特点:
1.快排是所有内部排序算法中性能最优的排序算法了
2.每轮能够确定一个元素(基准)的最终位置,但是不一定是最大或者最小 - 适合处理的任务:
1.如果对空间,以及稳定性要求不敏感的话,所有的内部排序任务都可以用快排。就是这么厉害!
2.使用快排的分治思想和二分的减治思想,可实现出时间复杂度最优的 TopK 求取算法——随机选择算法。
快排,基准值左侧都比基准大,右侧都比基准小,如果说某一趟的排序中我们所选的基准值其最后所应处于的位置恰好是 K,这就说明:它就是第 K 大的数,而他的左侧都比他大,那 0 ~ K - 1不就是 TopK 嘛!
如果是递归实现的算法那么:时间复杂度O(n),空间复杂度O(logn);
相关题目:剑指 Offer 40. 最小的k个数
// 递归写法
// 求取TopK,随机选择算法,从 A[left, right] 中找到第 K 大的数进行切分
public static int randSelect(int[] A, int left, int right, int K){
if(left == right) return left; // 边界
int p = randPartition(A, left, right); // 使用快排调整基准元素的左侧和右侧元素,返回本趟排序中基准值应处的索引位置。
int M = p - left + 1; // 当前基准值到左侧的长度 M
if(M == K) return p; // 基准值所处的索引位置到左侧的长度恰好等于K,直接返回基准对应的位置
else if(M > K){
return randSelect(A, left, p - 1, K); // M > K, 区间左移
} else {
return randSelect(A, p + 1, right, K - M); // M < K, 区间右移,并修改K值
}
// 每一轮只需要传三个参数,使用递归需要保存函数的调用 logn
// 但如果改成非递归也就是只需要重复保存和弹出操作,空间复杂度能够降到 1
}
- 提升性能的trick:
1.处理好递归调用,或者采用非递归的写法
2.使用较好的基准选择方式:中位数的中位数法、左中右的中间值、或者随机选择基准值
3.当 high - low 的规模较小时,使用插入排序的方式进行处理,或者选取特殊增量的shell排序
代码实现:
private static void quickSort(int[] nums, int l, int h) {
if (l < h) {
if (h - l > 2) { // 三数取中,为了获取更好的基准值
int mid = l + ((h - l) >> 1);
if (nums[l] > nums[h]) {
swap(nums, l, h);
}
if (nums[mid] > nums[h]) {
swap(nums, mid, h);
}
if (nums[l] < nums[mid]) {
swap(nums, l, mid);
}
}
int pivot = nums[l];
int i = l, j = h;
// 先从右往左找到第一个小于基准值元素的位置
// 再从左往右找到第一个大于基准值元素的位置
while (i < j) {
while (i < j && nums[j] >= pivot) { // 找出右侧第一个小于基准的元素的位置
j--;
}
nums[i] = nums[j]; // 交换位置
while (i < j && nums[i] <= pivot) { // 找出左侧第一个大于基准值的元素的位置
i++;
}
nums[j] = nums[i]; // 交换位置
}
nums[i] = pivot; // 基准值归位
quickSort(nums, l,i - 1); // 递归调用
quickSort(nums,i + 1, h);
}
}
// 非递归写法
public void quickSort2(int[] nums, int low, int high) {
int i = 0, l, r, pivotIdx, pivot, k = 1;
int[] idxArr = new int[high + 1]; // 数组栈用于保存索引
if (low >= high)
return;
idxArr[i++] = low;
idxArr[i++] = high;
while (i != 0) {
// 先弹出high,再弹出low
r = idxArr[--i];
l = idxArr[--i];
low = l;
high = r;
// 三数取中,将中间元素放在第一个位置
if (nums[low] > nums[high])
swap(nums, low, high);
if (nums[(low + high) / 2] > nums[high])
swap(nums, (low + high) / 2, high);
if (nums[low] < nums[(low + high) / 2])
swap(nums, (low + high) / 2, low);
pivot = nums[low]; // 用第一个元素作为基准元素
while (low < high) { // 两侧交替向中间扫描
while (low < high && nums[high] >= pivot)
high--;
nums[low] = nums[high];
while (low < high && nums[low] <= pivot)
low++;
nums[high] = nums[low];
}
nums[low] = pivot; // 在中间位置放回基准值
pivotIdx = low; // 返回基准元素所在位置
// 先压low,再压high
if (l < pivotIdx - 1) {
idxArr[i++] = l;
idxArr[i++] = pivotIdx - 1;
}
if (pivotIdx + 1 < r) {
idxArr[i++] = pivotIdx + 1;
idxArr[i++] = r;
}
//showSortRes(nums, k++);
}
}
插入排序
1.3 直接插入排序
- 算法思想:将序列分为前后前两个子序列,前部有序后部无序。前部序列开始只有一个元素,默认有序,然后选取后部序列的第一个元素于前部序列的元素进行比较,不断地后移前部元素,直到寻找到插入位置。
- 时空复杂度:O(n2)O(1)
- 稳定性:稳定
- 算法的性能和初始数据的相关性:
1.算法比较次数和移动次数都有关。(比如说序列已经成序了,只需要比较一次而不需要移动,因为插入的元素刚好在他应该待的地方)
2.当初始序列基本成序时,速度较快 - 特点:
1.序列前部率先成序,但可能不是最终的位置。 - 适合处理的任务:
1.使用顺序存储或链式存储的线性表数据 - 提升性能的trick:
1.使用二分查找寻找元素应该在前部的插入位置,查看折半插入算法。
代码实现:
public void insertSort(int[] nums, int len) {
System.out.println("插入排序:");
int i, j, tmp;
for (i = 1; i < len; i++) {
tmp = nums[i]; // 暂存nums[i]的值
for (j = i - 1; j > -1 && nums[j] > tmp; j--) {
nums[j + 1] = nums[j]; // 如果大于tmp,则将nums[j]的值后移,这个后移会将nums[i]的值破坏,所以需要预先将nums[i]赋给tmp
}
nums[j + 1] = tmp; // 找到了第一个不大于tmp的值,说明这个元素的后面一个位置,即为tmp对应的值应该所处的位置。
// showSortRes(nums, i);
}
}
1.4 折半插入排序
- 算法思想:和插入排序相同,只不过使用二分查找优化了寻早插入位置的过程,减少了比较的次数。
- 时空复杂度:O(n2)O(1)
- 稳定性:稳定
代码实现:
public void binaryInsertSort(int[] nums, int len) {
System.out.println("折半插入排序:");
int i, j, tmp, low, high, mid;
for (i = 1; i < len; i++) {
tmp = nums[i]; // 暂存nums[i]的值
low = 0;
high = i - 1; // 设定折半查找的范围
while (low <= high) { // 使用二分搜索来寻找nums[i]应该插入的位置
mid = low + ((high - low) >> 1);
if (nums[mid] > tmp) {
high = mid - 1;
} else {
low = mid + 1;
}
}
for (j = i - 1; j > high; j--) {
nums[j + 1] = nums[j]; // 如果大于tmp,则将nums[j]的值后移
}
nums[high + 1] = tmp; // 否则找到了第一个不大于tmp的值,说明这个元素的后面一个位置,即为tmp对应的值应该所处的位置。
//showSortRes(nums, i);
}
}
1.5 希尔排序(缩小增量排序)
- 算法思想:增量由原本插入排序的 1 变成了 dk,dk 的大小每次变小一点直到 dk = 1,分别进行直接插入排序,最后当 dk = 1 时,这时就是普通的直接插入排序,只不过经过前面的操作,整个序列已经变得基本成序了,所以速度较快。
- 时空复杂度:希尔排序的时间复杂度比较复杂,和选取的增量序列有关:
当选取的增量序列是默认地希尔增量时,时间复杂度是O(n1.3)
当选取Sedgewick增量序列时,时间复杂度是O(7/6)(虽然但从复杂度函数的分析上比快排等算法的O(nlogn)还小,但是实际表现还是不如快排。) - 稳定性:不稳定
- 算法的性能和初始数据的相关性:
1.算法比较次数有关,移动次数有关。
2.只能是顺序存储的线性表
3.可以是双向链表(但是这样其实没必要,不仅会使一份数据的存储空间变大,而且访问也会变的不方便,需要去遍历,不如直接用快排等其他的算法) - 特点:
1.中小规模的时候,我们的希尔排序甚至比快速排序还要更加的优秀
2.但是一旦数据量增大,要采用O(nlogn)的效率的算法来处理 - 适合处理的任务:
1.只能是顺序存储的线性表(因为这个要数增量) - 提升性能的trick:
1.选取较好的增量序列(具体的各种增量的定义和获取方式自行查找。)
代码实现:
public void shellSort(int[] nums, int len) {
System.out.println("希尔排序:");
int dk, i, tmp, j, k = 1;
//int[] dkArr = getShellDK(len); // 再写一个获取增量的方法,用于获取不同的增量,来优化希尔排序
int[] dkArr = getSedgewickDK(len); // 获取Sedgewick增量
int dkIdx = dkArr.length - 1;
while (dkIdx > -1) { // 选取增量
dk = dkArr[dkIdx];
dkIdx--;
if (dk > len) {
continue;
}
for (i = dk; i < len; i++) { // 增量为dk的插入排序
if (nums[i] < nums[i - dk]) {
tmp = nums[i]; // 暂存nums[i]
for (j = i - dk; j > -1 && nums[j] > tmp; j -= dk) {
nums[j + dk] = nums[j]; // 后移
}
nums[j + dk] = tmp; // 确定位置
//showSortRes(nums, k++);
}
}
}
}
/**
* 求取Sedgewick增量序列
*/
private static int[] getSedgewickDK(int n) {
int i = 0, tmp;
ArrayList<Integer> dk = new ArrayList<>();
while (true) {
tmp = 9 * ((1 << 2 * i) - (1 << i)) + 1;
if (tmp <= n) {
dk.add(tmp);
}
tmp = (1 << 2 * i + 4) - 3 * (1 << i + 2) + 1;
if (tmp <= n) {
dk.add(tmp);
} else {
break;
}
i++;
}
int len = dk.size();
int[] dkArr = new int[len];
i = 0;
for (int a : dk) {
dkArr[i++] = a;
}
return dkArr;
}
/**
* 求取shell增量序列
*/
private static int[] getShellDK(int n) {
int i, j;
ArrayList<Integer> dk = new ArrayList<>();
for (i = n >> 1, j = 0; i > 0; i = i >> 1, j++) {
dk.add(i);
}
int len = dk.size();
int[] dkArr = new int[len];
i = 0;
for (int a : dk) {
dkArr[i++] = a;
}
return dkArr;
}
选择排序
1.6 简单选择排序
- 算法思想:有点类似于冒泡和插入排序的结合,每次从后部未成序的序列中选出最小的放到前部以成序序列的最后位置上。
- 时空复杂度:O(n2) O(1)
- 稳定性:不稳定
- 算法的性能和初始数据的相关性:
1.无关,算法的复杂度始终是n2
2.所有形式的线性表 - 特点:
1.每次定一个元素的位置,且是最终位置
2.相比于冒泡来说,他交换的次数比较少,所以优于冒泡 - 适合处理的任务:
1.可以用来求TopK问题,时间复杂度是 K * n,空间是 1,但是不是最优的方式
代码实现:
public void selectSort(int[] nums, int len) {
System.out.println("简单选择排序:");
int i, j, min, max, k = 1;
for (i = 0; i < len - 1; i++) { // len - 1 是指我们通过选取最小值往前放,那自然最后剩下的一个就是最大值了,不需要再进行排序
min = i; // 假设当前这个idx中的元素是最小的
for (j = i + 1; j < len; j++) { // 遍历后续元素寻找最小值
if (nums[j] < nums[min]) {
min = j; // 更新最小值
}
}
if (min != i) {
swap(nums, i, min); // 交换
showSortRes(nums, k++);
}
}
}
1.7 堆排序
- 算法思想:将顺序存储的线性表看成一颗完全二叉树,整个排序过程分为建堆和调整两个部分。
在建堆时,整个过程很类似于冒泡,每次用父节点的值与子结点中较大的一个进行比较,如果子节点大于父节点,那么就把子节点元素上移,重复此操作,直到子孙节点全部被比较了或者父节点大于子节点了。
建堆完成后,此时堆顶的元素是整个堆中最大的元素。然后在调整时,将堆顶的元素于堆尾进行交换,然后将堆的容量减少一(最大的元素已经定位到序列最后了),然后重新从头对堆进行调整。 - 时空复杂度:O(nlogn) O(1)
- 稳定性:不稳定
- 算法的性能和初始数据的相关性:
1.适合顺序表 - 特点:
1.每次能定下一个最大或者最小元素的位置
2.只能用于顺序存储的线性表
3.不适合小规模的排序 - 适合处理的任务:
1.常用于实现优先队列,用于作业调度
2.求取TopK(使用最小堆求取TopK,原理:维护一个 K 大小的堆,其中存放着当前的 K 个最小值,每次从序列中取出一个元素与堆顶进行比较,如果大于堆顶,我们就把此元素赋值给堆顶(丢掉最小值),然后重新调整堆,重复这个过程,一直到遍历了所有的元素。(这个过程相当于一直在丢弃序列中最小的值,直到序列中剩下了 K 个元素。)此时,堆中的元素就是TopK大的值,但是顺序是逆序排列的。)
相关题目:剑指 Offer 40. 最小的k个数 - 提升性能的trick:
1.Fast-HeapSort:
a.将堆顶的元素拿掉。
b.每次不是将堆底的元素拿到上面去,而是直接比较堆顶(最大)元素的两个儿子,即选出次大的元素,补充到堆顶
c.类似于步骤b,重复利用儿子节点的较大元素补充父亲节点,直至叶节点。
代码实现:
public void heapSort(int[] nums, int len) { // 堆排序在理解的时候,把数组想成从1开始的存储会比较容易理解
System.out.println("堆排序:");
int i, k = 1;
for (i = len >> 1; i > -1; i--) { // O(n)
adjustDown(nums, i, len); // 建堆 O(logn) (n代表堆的容量)
// showSortRes(nums, k++);
}
for (i = len; i > 0; i--) { // O(n)
swap(nums, 0, i); // 将最大或者最小的放到最后i的位置上,也可以认为是输出了
adjustDown(nums, 0, i - 1); // 调整 O(logn)
// showSortRes(nums, k++);
}
}
private void adjustDown(int[] nums, int k, int len) {
int tmp, i;
tmp = nums[k]; // 暂存父节点的值
for (i = k << 1; i <= len; i <<= 1) { // k << 1 是为了获取左子节点
if (i < len && nums[i] < nums[i + 1]) { // 两个子节点之间取较大的那个
i++;
}
if (tmp >= nums[i]) { // 如果较大的子节点也没有父节点大,直接跳出
break;
} else {
nums[k] = nums[i]; // 否则叶子节点上移到父节点
k = i; // 记录子节点的位置,最后将父节点赋值到这里
}
}
nums[k] = tmp; // 父节点赋值
}
/*
发现一个问题,当时写的时候就发现了,但是没仔细想,只是运行之后发现结果是正确的的,然后就没再研究。最近想明白了。
下面这个adjustDown2()和adjustDown()有些许不同,下面adjustDonw2()这个写法才是正确的。
这两个调整的方法最终建立的堆的形状不同,层数不同,adjustDown2()建立的堆比adjustDonw()少一层。
*/
private void adjustDown2(int[] nums, int k, int len) {
int tmp, i;
tmp = nums[k]; // 暂存父节点的值
for (i = (k << 1) + 1; i <= len; i = (i << 1) + 1) { // (k << 1) + 1 是为了获取左子节点
if (i < len && nums[i] < nums[i + 1]) { // 两个子节点之间取较大的那个
i++;
}
if (tmp >= nums[i]) { // 如果较大的子节点也没有父节点大,直接跳出
break;
} else {
nums[k] = nums[i]; // 否则叶子节点上移到父节点
k = i; // 记录子节点的位置,最后将父节点赋值到这里
}
}
nums[k] = tmp; // 父节点赋值
}
备注:adjuDonw()和adjustDown2()两个方法分别建立的堆如下:
对于输入数组[4,5,1,3,2],对应的树形结构如图:
经过adjustDown()方法调整后,所建的堆如下:
经过adjustDown2()方法调整后,所建的堆如下:
两者所建的树高度相差1。主要原因是adjustDown()中求取左子节点的公式有问题。
归并排序
1.8 归并排序
- 算法思想:将两个或以上的有序序列(表)进行归并,形成一个新的有序表的方法。虽然也可以用于将长度为 1 的表进行合并,但是实际使用中一般不这样用。
- 时空复杂度:O(nlogn)(这是二路归并,使用多路归并的话会变成O(nlogk),k为路数),O(n)(若用单链表做存储结构,很容易给出就地的归并排序)
- 稳定性:稳定
- 算法的性能和初始数据的相关性:
1.可以用于顺序表,也可以用于链表,而且当存储数据的结构为链表时,空间复杂度能降到 1 - 特点:
- 适合处理的任务:
1.大规模及超大规模排序。已知的Hadoop中的外部排序算法就是多路归并算法,内部排序算法使用的是快排。 - 提升性能的trick:
1.m路归并时间复杂度会变为nlog(m为底)n
2.如果数据是链表形式存储的,空间复杂度可以降为1
代码实现:
public void mergeSort(int[] nums, int l, int h) {
if (l < h) {
int mid = l + ((h - l) >> 1);
mergeSort(nums, l, mid);
mergeSort(nums, mid + 1, h);
merge(nums, l, mid, h);
}
}
private void merge(int[] nums, int l, int mid, int h) {
int tmpLen = h - l + 1;
int[] tmp = new int[tmpLen];
int i = l, j = mid + 1, k = 0, m = 0;
// 把较小的数先移到新数组中
while (i <= mid && j <= h) {
if (nums[i] < nums[j]) {
tmp[k++] = nums[i++];
} else {
tmp[k++] = nums[j++];
}
}
// 把左边剩余的数移入数组
while (i <= mid) {
tmp[k++] = nums[i++];
}
// 把右边边剩余的数移入数组
while (j <= h) {
tmp[k++] = nums[j++];
}
// 把新数组中的数覆盖nums数组
i = 0;
while (i < tmpLen) {
nums[l++] = tmp[i++];
}
// showSortRes(nums, m++);
}
桶思想
以下三个算法都是基于桶思想的排序:
1.基数排序:根据键值的每位数字来分配桶
2.计数排序:每个桶只存储单一键值
3.桶排序:每个桶存储一定范围的数值
1.9 基数排序
- 算法思想:基数排序可以分为两种高位优先和地位优先(LSD)两种,我们以最低位优先(LSD)的方式讲解:
对于数字这种元素来说,无论多大都是由 0 - 9 这个几个关键字构成的,基数排序就是构造 10 个队列标志着 0 - 9 个关键字,
然后对于要排序的元素从低位(从右往左)开始按照其关键字放入对应的队列中,这个过程叫做分配,完成一次后,进行收集:将所有的队列从低到高首尾相连,形成的序列作为下一轮的输入。注意:此时所有元素在最低位上是成序的。
然后是从右往左第二位、第三位……,每完成一轮排序(收集和分配),从对应的那一位数字开始,不考虑更高位的话,所有元素是成序的。重复操作,直到最长的一个元素所有位都进行了排列。 - 时空复杂度:
需要进行 d(最长元素的位数) 轮比较,查询每个元素本轮应处的位置,需要访问 n 次,在收集时 r个(关键字的数目,代表要访问几个队列) 队列,所以时间复杂度是 O(d * (n + r)),空间复杂度 O(n) - 稳定性:稳定
- 算法的性能和初始数据的相关性:
1.不太适合对浮点数进行比较,但不是不能
2.对数据格式有要求,不是一个通用的算法 - 特点:
1.不是基于比较进行的排序
2.在某些时候,基数排序法的效率高于其它的稳定性排序法。 - 适合处理的任务:
- 提升性能的trick:
代码实现:
public void radixSort(int[] nums, int len) {
System.out.println("基数排序:");
int maxDigitLen = getMaxDigitLen(nums); // 获取最长的元素的位数,用于构建队列
int k = 1;
int mod = 10, dev = 1;
for (int i = 0; i < maxDigitLen; i++, dev *= 10, mod *= 10) { // 分配
// 考虑负数的情况,这里扩展一倍队列数,其中 [0-9]对应负数,[10-19]对应正数 (bucket + 10)
int[][] counter = new int[mod * 2][0]; // 队列
for (int j = 0; j < len; j++) {
int bucket = ((nums[j] % mod) / dev) + mod; // 取当前元素的这一位上的关键字,加上mod是考虑了负数的情况
counter[bucket] = arrayAppend(counter[bucket], nums[j]); // 放到对应的桶中
}
int pos = 0;
for (int[] bucket : counter) { // 收集
for (int value : bucket) {
nums[pos++] = value;
}
}
showSortRes(nums, k++);
}
}
/**
* 获取最长的数字的长度,用来建立队列
*/
private int getMaxDigitLen(int[] nums) {
int maxValue = getMaxValue(nums);
if (maxValue == 0) {
return 1;
}
int lenght = 0;
for (long tmp = maxValue; tmp != 0; tmp /= 10) {
lenght++;
}
return lenght;
}
/**
* 获取数组最大值
*/
private int getMaxValue(int[] arr) {
int maxValue = arr[0];
for (int value : arr) {
if (maxValue < value) {
maxValue = value;
}
}
return maxValue;
}
/**
* 自动扩容,并保存数据
*/
private int[] arrayAppend(int[] nums, int value) {
nums = Arrays.copyOf(nums, nums.length + 1);
nums[nums.length - 1] = value;
return nums;
}
1.10 计数排序
- 算法思想:
1.找出待排序的数组中最大和最小的元素,求出最大和最小之间的距离用做定义数组 C 的长度。
2.统计数组中每个值为 i 的元素出现的次数,存入数组 C 索引为 i 的位置上。
3.计数累加(从 C 中的第一个元素开始,每一项和前一项相加),反向填充目标数组:将每个元素 i 放在新数组的 C(i) 索引处,每放一个元素就将 C(i) 减去 1,如果C(i) == 0,就继续向目标数组填充 C(i + 1) 个 i + 1。
4.直到所有的数都已经填充了。 - 时空复杂度:
当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 O(n + k)。计数排序不是比较排序,排序的速度快于任何基于比较的排序算法。
空间复杂度 O (k + 1),待排序数组的最大值与最小值的差加上 1 - 稳定性:稳定
- 算法的性能和初始数据的相关性:
1.计数排序要求输入的数据必须是有确定范围的整数。 - 特点:
1.不基于比较,过程也与基数排序类似 - 适合处理的任务:
1.计数排序是用来排序 0 到 100 之间的整数数字(年龄)的最好的算法,但是它不适合排字符串。
2.计数排序可以用来排序数据范围很大的数组。
3.进阶:思考一下 1 如果我们不使用对应的数字做索引,而是使用“位bit”来做索引,会怎么样?提示Bitmap。
一个字节是 8bit,那样原本一个数字要使用4字节来表示的,现在只要 1bit 就能表示了,但是缺点是不能对存在重复数字的数据进行排序,因为 1bit 只有两个状态 0 和 1。这个方法也用来做:在海量数据种以 O(1) 的平均时间复杂度确定一个数字是否存在。 - 提升性能的trick:
代码实现:
public void countingSort(int[] nums) {
System.out.println("计数排序:");
int minValue = getMinValue(nums);
int maxValue = getMaxValue(nums);
int bucketLen = maxValue - minValue + 1;
int[] bucket = new int[bucketLen];
for (int value : nums) {
bucket[value - minValue]++;
}
int sortedIndex = 0;
for (int j = 0; j < bucketLen; j++) {
while (bucket[j] > 0) {
nums[sortedIndex++] = j + minValue;
bucket[j]--;
}
}
// showSortRes(nums, 0);
}
/**
* 获取数组最大值
*/
private int getMaxValue(int[] arr) {
int maxValue = arr[0];
for (int value : arr) {
if (maxValue < value) {
maxValue = value;
}
}
return maxValue;
}
/**
* 获取数组最小值
*/
private int getMinValue(int[] arr) {
int minValue = arr[0];
for (int value : arr) {
if (minValue > value) {
minValue = value;
}
}
return minValue;
}
1.11 桶排序
- 算法思想:桶排序是对计数排序的一种优化,计数排序是将相同的数字放到一个“桶”中(数组的位置),桶排序是将一个范围中的数字放到一个桶中,桶与桶之间的范围不能相交。
然后在桶内再进行排序,最后使用和计数排序一样的方式收集回来。
当输入的数据可以均匀的分配到每一个桶中时,速度最快。当输入的数据被分配到了同一个桶中,退化成了桶内排序使用的方法。 - 时空复杂度:O(n)和O(n+k)(k为划分的桶的个数)
- 稳定性:稳定
- 算法的性能和初始数据的相关性:
- 特点:
- 适合处理的任务:
- 提升性能的trick:
1.选用能够将元素尽可能的均匀划分到桶中的映射方法
代码实现:
public void bucketSort(int[] nums, int len) {
System.out.println("桶排序:");
if (len == 0) {
return;
}
int bucketSize = 5; // 定义桶的数目,
int minValue = getMinValue(nums);
int maxValue = getMaxValue(nums);
int bucketCount = (int) Math.floor((maxValue - minValue) / bucketSize) + 1;
int[][] buckets = new int[bucketCount][0];
// 利用映射函数将数据分配到各个桶中
for (int num : nums) {
int index = (int) Math.floor((num - minValue) / bucketSize);
buckets[index] = arrayAppend(buckets[index], num);
}
int arrIndex = 0;
for (int[] bucket : buckets) {
if (bucket.length <= 0) {
continue;
}
// 对每个桶进行排序,这里使用了折半插入排序
binaryInsertSort(bucket);
for (int value : bucket) {
nums[arrIndex++] = value;
}
}
// showSortRes(nums, 0);
}
private void binaryInsertSort(int[] bucket) {
int n = bucket.length, i, j, l, h, mid, tmp;
for (i = 1; i < n; i++) { // 从第二个开始,默认第一个已经成序
tmp = bucket[i];
if (tmp < bucket[i - 1]) { // 如果当前这个元素小于它之前的元素,说明他应该向前移
l = 0;
h = i - 1;
while (l < h) { // 二分查找插入位置
mid = l + ((h - l) >> 1);
if (bucket[mid] > tmp) {
h = mid - 1;
} else {
l = mid + 1;
}
}
for (j = i; j > h; j--) { // 后移元素
bucket[j] = bucket[j - 1];
}
bucket[h] = tmp; // tmp归位
}
}
}
二、外排序
外排序是指大文件的排序,即排序的记录存储在外存储器上,在排序过程中需进行多次的内、外存之间的交换。
外部排序基本上由两个相对独立的阶段组成,通常采用归并排序的思想。
1.按可用内存大小,将外存上含有 n 个记录的文件分成若干长度为 l 的字文件或段。依次读入内存并利用有效的内部排序方法排序,得到有序的文件后存回外存上,直到所有的段都变得局部有序。
2.将排序后得到的有序子文件(称为归并段或顺串),进行逐趟归并,直至得到整个有序文件为止。
在外部排序中实现两两归并,由于不可能将两个有序段及归并结果段同时存放在内存中的缘故,所以不仅要调用归并过程,还需要进行外存的读_写(对外存上信息的读_写是以“物理块”为单位的),所以衡量它们的指标一般是 IO 次数。
https://www.cnblogs.com/XIAOGUAI9/p/15202192.html
三、一些疑问
1.为什么快排比堆排好?
(图中希尔排序的时间复杂度错了应该是使用 shell增量的平均时间复杂度是O(n1.3),空间复杂度O(1);使用 Sedegwick增量的平局时间复杂度是O(n7/6),空间复杂度是O(1))
从图上看,堆排的平均时间复杂度和快拍相等,而空间复杂度低于快排。所以,应该是堆排更优才对,但是实际却不是。
原因是:
- 1.由于每次建堆的过程从堆低拿出元素放到堆顶,破坏了数列中的数字的有序性,导致堆排中存在大量的无效判断和交换,因此导致堆排序的时间复杂度O(nlogn)中省去的常数项比快排的大,所以堆排序的一种改进方法也是针对于每次选取堆顶两个子节点中较大的一个来代替堆顶,而不是选取最后一个,借此来减少无效的判断和交换次数。
参考资料:
https://blog.csdn.net/alw_123/article/details/52141459
https://blog.csdn.net/qq_34768115/article/details/85265140