非线性时间比较类排序
算法 | 时间复杂度 | 空间复杂度 | 稳定 | 思想 | 注意事项 |
---|---|---|---|---|---|
冒泡 | O(n²) | O(1) | Y | 比较 | 最好情况需要额外判断 |
选择 | O(n²) | O(1) | N | 比较 | 交换次数一般少于冒泡 |
堆 | O(nlogn) | O(1) | N | 选择 | 堆排序的辅助性较强,理解前先理解堆的数据结构 |
插入 | O(n²) | O(1) | Y | 比较 | 插入排序对于近乎有序的数据处理速度比较快,复杂度有所下降,可以提前结束 |
希尔 | O(nlogn) | O(1) | N | 插入 | gap序列的构造有多种方式,不同方式处理的数据复杂度可能不同 |
归并 | O(nlogn) | O(n) | Y | 分治 | 需要额外的O(n)的存储空间 |
快速 | O(nlogn) | O(logn) | N | 分治 | 快排可能存在最坏情况,需要把枢轴值选取得尽量随机化来缓解最坏情况下的时间复杂度 |
稳定 vs 不稳定
我们说一个算法稳不稳定,也就是看排序前后会不会把一些相同元素的顺序给打乱。排序后取值相同的元素顺序没有被打乱,称之为稳定算法。排序后取值相同的元素顺序被打乱了,称之为不稳定算法。
冒泡排序
排序思想
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系的要求。如果不满足就让它俩互换位置。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。
- 每轮冒泡不断比较相邻的两个元素,如果它们是逆序的,则交换它们的位置。
- 下一轮冒泡,可以调整未排序的右边界,减少不必要的比较。
动画演示
优化:每次循环时,若能确定更合适的右边界,则可以减少冒泡轮数。
代码实现
private static void bubble(int[] a) {
// 初始右边界
int j = a.length - 1;
while (true) {
// 记录右边界
int x = 0;
for (int i = 0; i < j; i++) {
if (a[i] > a[i + 1]) {
int t = a[i];
a[i] = a[i + 1];
a[i + 1] = t;
x = i;
}
}
// 每轮冒泡结束 将右边界赋值给j
j = x;
// 右边界为0时 排序结束
if (j == 0) {
break;
}
}
}
快速排序
快速排序也是利用分治思想。如果要排序一组数据,我们先选择这组数据中任意一个数据作为分区点pivot,然后遍历这组数据,将小于分区点pivot的放到左边,大于分区点pivot的放到右边,将pivot放到中间。然后再分别对左右两部分进行排序。
快排在基于比较的算法里是速度最快的,而且数据量越大效果越明显。
单边快排
lomuto 洛穆托分区方案
排序思想
- 每轮找到一个基准元素,把比它小的放到它左边,比它大的放到它右边,这称为分区
- 选择最右侧元素作为基点元素
- j 指针负责找比基准点小的,i 负责指针找比基准点大的,一旦找到,二者进行交换
- 交换时机:j 找到小的,且与 i 不相等
- i 找到 >= 基准点元素后,不应自增
- 最后基准点与 i 交换,i 即为基准点最终索引
图例
i 和 j 都从左边出发向右查找,i 找到比基准点4大的5,j找到比基准点小的2,停下来交换
i 找到了比基准点大的5,j 找到比基准点小的3,停下来交换
j 到达right 处结束,right 与 i 交换,一轮分区结束
代码实现
public class QuickSortLomuto {
private static void quick(int[] a, int left, int right) {
if (left >= right) {
return;
}
// p 代表基准点元素索引
int p = partition(a, left, right);
quick(a, left, p - 1);
quick(a, p + 1, right);
}
private static int partition(int[] a, int left, int right) {
// 基准点元素值
int pv = a[right];
int i = left;
int j = left;
while (j < right) {
// j找到比基准点小的
if (a[j] < pv) {
// 若i和j指向同一个位置则不需要交换
if (i != j) {
swap(a, i, j);
}
i++;
}
j++;
}
swap(a, i, right);
return i;
}
private static void swap(int[] a, int i, int j) {
int t = a[i];
a[i] = a[j];
a[j] = t;
}
}
双边快排
排序思想
- 选择最左侧元素作为基准点
- j 找比基准点小的,i 找比基准点大的,一旦找到,二者进行交换
- i 从左向右
- j 从右向左
- 最后基准点与 i 交换,i 即为基准点最终索引
动画演示
双边快排 - 动画演示
随机基准点:使用随机数作为基准点,避免万一最大值或最小值作为基准点导致的分区不均衡
处理重复值:如果重复值较多,则原来算法中的分区效果也不好,如下图中左侧所示 ,需要想办法改为右侧的分区效果
代码实现
public class QuickSortHandleDuplicate {
public static void sort(int[] a) {
quick(a, 0, a.length - 1);
}
private static void quick(int[] a, int left, int right) {
if (left >= right) {
return;
}
int p = partition(a, left, right);
quick(a, left, p - 1);
quick(a, p + 1, right);
}
private static int partition(int[] a, int left, int right) {
int idx = ThreadLocalRandom.current().nextInt(right - left + 1) + left;
swap(a, left, idx);
int pv = a[left];
int i = left + 1;
int j = right;
while (i <= j) {
// i 从左向右找大的或者相等的
while (i <= j && a[i] < pv) {
i++;
}
// j 从右向左找小的或者相等的
while (i <= j && a[j] > pv) {
j--;
}
if (i <= j) {
swap(a, i, j);
i++;
j--;
}
}
swap(a, j, left);
return j;
}
private static void swap(int[] a, int i, int j) {
int t = a[i];
a[i] = a[j];
a[j] = t;
}
}
选择排序
排序思想
-
每一轮选择,找出最大(最小)的元素,并把它交换到合适的位置
动画演示
代码实现
// 每轮选择出最大的元素
public static void sort(int[] a) {
// 1. 选择轮数 a.length - 1
// 2. 交换的索引位置(right) 初始 a.length - 1, 每次递减
for (int right = a.length - 1; right > 0 ; right--) {
int max = right;
for (int i = 0; i < right; i++) {
if (a[i] > a[max]) {
max = i;
}
}
// 每轮循环结束 将选择的值交换到合适的位置
if(max != right) {
swap(a, max, right);
}
}
}
private static void swap(int[] a, int i, int j) {
int t = a[i];
a[i] = a[j];
a[j] = t;
}
堆排序
选择排序是先选择最大值,然后再把它交换到合适的位置。其中选择最大值时间复杂度是O(n),交换的时间复杂度是O(n)。嵌套起来就是O(n²)。
堆排序就是对选择排序的一种优化,借助堆的数据结构将选择大值得时间复杂度变成O(㏒n),这样堆排序的时间复杂度就是O(n㏒n)。
什么是堆
堆是一种特殊的树,它满足需要满足两个条件:
- 堆是一种完全二叉树,也就是除了最后一层,其他层的节点个数都是满的,最后一个节点都靠左排列。
- 堆中每一个节点的值都必须大于等于(或小于等于)其左右子节点的值。
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作“小顶堆”。
排序思想
-
建立大顶堆
-
每次将堆顶元素(最大值)交换到末尾,调整堆顶元素,让它重新符合大顶堆特性
图例
建堆
交换,下潜调整
代码实现
public class HeapSort {
public static void sort(int[] a) {
// 建堆
heapify(a, a.length);
// 外层循环 O(n)
for (int right = a.length - 1; right > 0; right--) {
swap(a, 0, right);
// 内层有循环 O(nlogn)
down(a, 0, right);
}
}
// 建堆 O(n)
// 1.找到最后一个非叶子节点 (size << 1) - 1
// 2.从后向前,对每个叶子节点执行下潜
private static void heapify(int[] array, int size) {
for (int i = size / 2 - 1; i >= 0; i--) {
down(array, i, size);
}
}
// 下潜
// leetcode 上数组排序题目用堆排序求解,非递归实现比递归实现大约快 6ms
private static void down(int[] array, int parent, int size) {
while (true) {
// 左子节点 2n + 1
int left = parent * 2 + 1;
// 右子节点 2n + 2
int right = left + 1;
// 记录较大值
int max = parent;
// 跟左子节点比较
if (left < size && array[left] > array[max]) {
max = left;
}
// 跟右子节点比较
if (right < size && array[right] > array[max]) {
max = right;
}
// 父节点比左右子节点都大 则不用下潜 跳出循环
if (max == parent) {
break;
}
swap(array, max, parent);
parent = max;
}
}
// 交换
private static void swap(int[] a, int i, int j) {
int t = a[i];
a[i] = a[j];
a[j] = t;
}
}
插入排序
排序思想
将数组的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。算法的核心思想就是,取未排序区间中的元素,在已排序区间中找到合适的位置将其插入,并保证已排序区间的数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
动画演示
代码实现
public class InsertionSort {
public static void sort(int[] a) {
// 外层循环 O(n)
for (int low = 1; low < a.length; low++) {
// 将 low 位置的元素插入至 [0..low-1] 的已排序区域
int t = a[low];
int i = low - 1; // 已排序区域指针
// 内层循环 O(n)
while (i >= 0 && t < a[i]) { // 没有找到插入位置
a[i + 1] = a[i]; // 空出插入位置
i--;
}
// 找到插入位置
if (i != low - 1) {
a[i + 1] = t;
}
}
}
}
希尔排序
排序思想
希尔排序是简单插入排序的改进版。他与插入排序的不同之处在于,它会优先比较较远的元素。希尔排序又叫缩小增量排序。希尔排序的核心在于间隔序列的设定(也就是增量)。既可以提前设定好间隔序列,也可以动态定义间隔序列。
- 分组实现插入,每组元素间隙称为 gap
- 每轮排序后 gap 逐渐变小,直至 gap 为 1 完成排序
- 对插入排序的优化,让元素更快速地交换到最终位置
动画演示
代码实现
public class ShellSort {
public static void sort(int[] a) {
for (int gap = a.length >> 1; gap >= 1; gap = gap >> 1) {
// 内层是插入排序
for (int low = gap; low < a.length; low++) {
int t = a[low];
int i = low - gap;
while (i >= 0 && t < a[i]) {
a[i + gap] = a[i];
i -= gap;
}
if (i != low - gap) {
a[i + gap] = t;
}
}
}
}
}
归并排序
排序思想
归并排序的采用分治思想,如果要排序一个数组,我们先把数组从中间分成前后两个部分,然后对前后两个部分分别进行排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
- 分 - 每次从中间切一刀,处理的数据少一半
- 治 - 当数据仅剩一个时可以认为有序
- 合 - 两个有序的结果,可以进行两个有序数组的合并排序
动画演示
代码实现
递归 — 自顶至下
// 自顶至下
public class MergeSortTopDown {
public static void sort(int[] a1) {
int[] a2 = new int[a1.length];
split(a1, 0, a1.length - 1, a2);
}
private static void split(int[] a1, int left, int right, int[] a2) {
// 2.治
// 递归结束条件
if (left == right) {
return;
}
// 1. 分
int m = (left + right) >>> 1;
split(a1, left, m, a2);
split(a1, m + 1, right, a2);
// 3. 合
merge(a1, left, m, m + 1, right, a2);
System.arraycopy(a2, left, a1, left, right - left + 1);
}
/**
* 有序数组合并
*
* a1 原始数组
* i ~ iEnd 第一个有序范围
* j ~ jEnd 第二个有序范围
* a2 临时数组
*/
private static void merge(int[] a1, int i, int iEnd, int j, int jEnd, int[] a2) {
int k = i;
while (i <= iEnd && j <= jEnd) {
if (a1[i] < a1[j]) {
a2[k] = a1[i];
i++;
} else {
a2[k] = a1[j];
j++;
}
k++;
}
if (i > iEnd) {
System.arraycopy(a1, j, a2, k, jEnd - j + 1);
}
if (j > jEnd) {
System.arraycopy(a1, i, a2, k, iEnd - i + 1);
}
}
}
非递归 — 自下至上
// 自下至上
public class MergeSortBottomUp {
private static final int SIZE = 10000000;
public static void main(String[] args) {
int[] a = new int[SIZE];
Random r = new Random();
for (int i = 0; i < SIZE; i++) {
a[i] = r.nextInt(SIZE);
}
long s = System.currentTimeMillis();
sort(a); // 1301
System.out.println("归并-非递归:" + (System.currentTimeMillis() - s) + "毫秒");
}
public static void sort(int[] a1) {
int n = a1.length;
int[] a2 = new int[n];
// width 代表有序宽度区间,取值依次是 1、2、4、8 ...
for (int width = 1; width < n; width = width << 1) {
// [left right] 分别代表待合并区的左右边界
for (int left = 0; left < n; left += 2 * width) {
// 右边界为下次左边界减1
int right = Integer.min(left + 2 * width - 1, n - 1); // 防止越界
// 中间索引为左边界加上宽度减1
int m = Integer.min(left + width - 1, n - 1); // 防止越界
merge(a1, left, m, m + 1, right, a2);
}
System.arraycopy(a2, 0, a1, 0, n);
}
}
/**
* 有序数组合并
*
* a1 原始数组
* i ~ iEnd 第一个有序范围
* j ~ jEnd 第二个有序范围
* a2 临时数组
*/
public static void merge(int[] a1, int i, int iEnd, int j, int jEnd, int[] a2) {
int k = i;
while (i <= iEnd && j <= jEnd) {
if (a1[i] < a1[j]) {
a2[k] = a1[i];
i++;
} else {
a2[k] = a1[j];
j++;
}
k++;
}
if (i > iEnd) {
System.arraycopy(a1, j, a2, k, jEnd - j + 1);
}
if (j > jEnd) {
System.arraycopy(a1, i, a2, k, iEnd - i + 1);
}
}
}
归并 + 插入
小数据量且有序度高时,插入排序效果高,大数据量用归并效果好,可以结合二者。
public class MergeInsertionSort {
private static final int SIZE = 10000000;
public static void main(String[] args) {
int[] a = new int[SIZE];
Random r = new Random();
for (int i = 0; i < SIZE; i++) {
a[i] = r.nextInt(SIZE);
}
long s = System.currentTimeMillis();
sort(a); // 968毫秒
System.out.println("归并+插入:" + (System.currentTimeMillis() - s) + "毫秒");
}
public static void sort(int[] a1) {
int[] a2 = new int[a1.length];
split(a1, 0, a1.length - 1, a2);
}
private static void split(int[] a1, int left, int right, int[] a2) {
// 2. 治
if (right == left) {
return;
}
// 当分到小于128时 使用插入排序
if (right - left <= 128) {
insertion(a1, left, right);
return;
}
// 1. 分
int m = (left + right) >>> 1;
split(a1, left, m, a2);
split(a1, m + 1, right, a2);
// 3. 合
merge(a1, left, m, m + 1, right, a2);
System.arraycopy(a2, left, a1, left, right - left + 1);
}
/**
* 有序数组合并
*
* a1 原始数组
* i ~ iEnd 第一个有序范围
* j ~ jEnd 第二个有序范围
* a2 临时数组
*/
public static void merge(int[] a1, int i, int iEnd, int j, int jEnd, int[] a2) {
int k = i;
while (i <= iEnd && j <= jEnd) {
if (a1[i] < a1[j]) {
a2[k] = a1[i];
i++;
} else {
a2[k] = a1[j];
j++;
}
k++;
}
if (i > iEnd) {
System.arraycopy(a1, j, a2, k, jEnd - j + 1);
}
if (j > jEnd) {
System.arraycopy(a1, i, a2, k, iEnd - i + 1);
}
}
// 插入排序
private static void insertion(int[] a, int left, int right) {
for (int low = left + 1; low <= right; low++) {
int t = a[low];
int i = low - 1;
while (i >= left && t < a[i]) {
a[i + 1] = a[i];
i--;
}
if (i != low - 1) {
a[i + 1] = t;
}
}
}
}
线性时间非比较类排序
非比较排序算法 | 时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|
计数排序 | O(n+k) | O(n+k) | 稳定 |
桶排序 | O(n+k) | O(n+k) | 稳定 |
基数排序 | O(d*(n+k)) | O(n+k) | 稳定 |
注:n 是数组长度、 k 是桶长度、 d 是基数位数 |
计数排序
排序思想
- 找到原数组中的最大值和最小值,创建一个长度为(最大值减最小值+1)的 count 数组
- 让原始数组的最小值映射到 count[0],最大值映射到 count 最右侧
- 原始数组元素 - 最小值 = count 索引
- count 索引 + 最小值 = 原始数组元素
动画演示
计数排序 - 动画演示
代码实现
public class CountingSort {
public static void sort2(int[] a) {
int min = a[0];
int max = a[0];
// 找到数组中的最大值和最小值
for (int i : a) {
if (i > max) {
max = i;
} else if (i < min) {
min = i;
}
}
// 创建Counting数组
int[] counting = new int[max - min + 1];
// 将数组中的元素映射到Counting数组中
for (int i : a) {
counting[i - min]++;
}
// 基于Counting数组排序
int k = 0;
for (int i = 0; i < counting.length; i++) {
while (counting[i] > 0) {
a[k] = i + min;
counting[i]--;
k++;
}
}
}
}
针对 byte [],因为数据范围已知,省去了求最大、最小值的过程,java 中对 char[]、short[]、byte[] 的排序都可能采用 counting 排序。
桶排序
排序思想
桶排序的核心思想就是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶排序完之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
图例
代码实现
public class BucketSortGeneric {
public static void main(String[] args) {
int[] ages = {20, 10, 28, 66, 25, 31, 67, 30, 70};
sort(ages, 3);
System.out.println(Arrays.toString(ages));
}
public static void sort(int[] a, int range) {
int max = a[0];
int min = a[0];
for (int i = 1; i < a.length; i++) {
if (a[i] > max) {
max = a[i];
}
if (a[i] < min) {
min = a[i];
}
}
// 1. 准备桶
List<Integer>[] buckets = new ArrayList[(max - min) / range + 1];
for (int i = 0; i < buckets.length; i++) {
buckets[i] = new ArrayList<>();
}
// 2. 放入年龄数据
for (int age : a) {
buckets[(age - min) / range].add(age);
}
int k = 0;
for (List<Integer> bucket : buckets) {
// 3. 排序桶内元素
int[] array = new int[bucket.size()];
for (int i = 0; i < bucket.size(); i++) {
array[i] = bucket.get(i);
}
InsertionSort.sort(array);
// 4. 把每个桶排序好的内容,依次放入原始数组
for (int v : array) {
a[k++] = v;
}
}
}
}
基数排序
排序思想
基数排序与前面的排序算法不一样,它不基于比较和移动元素来进行排序,而是基于多关键字排序的思想,将一个逻辑关键字分为多个关键字,它是基于关键字各位的大小进行排序的。基数排序有两种实现方式:最高位优先法
和最低位优先法
,分别是按关键字高次位排序和低次位排序。
图例
下面通过最低位优先法,对给定的关键字序列 {110,119,007,911,114,120,122} 进行排序
1、该序列的链式结构如下:
2、首先按照关键字的个位数字大小进行第一趟基数排序:
3、根据第一趟的顺序,按照关键字的十位数字大小进行第二趟基数排序:
4、根据第二趟的顺序,按照关键字的百位数字大小进行第三趟基数排序:
代码实现
public class RadixSort {
public static void radixSort(String[] a, int length) {
ArrayList<String>[] buckets = new ArrayList[10];
for (int i = 0; i < buckets.length; i++) {
buckets[i] = new ArrayList<>();
}
for (int i = length - 1; i >= 0; i--) {
for (String s : a) {
buckets[s.charAt(i)].add(s);
}
int k = 0;
for (ArrayList<String> bucket : buckets) {
for (String s : bucket) {
a[k++] = s;
}
bucket.clear();
}
}
}
public static void main(String[] args) {
String[] phoneNumbers = new String[10];
phoneNumbers[0] = "138";
phoneNumbers[1] = "139";
phoneNumbers[2] = "136";
phoneNumbers[3] = "137";
phoneNumbers[4] = "135";
phoneNumbers[5] = "134";
phoneNumbers[6] = "150";
phoneNumbers[7] = "151";
phoneNumbers[8] = "152";
phoneNumbers[9] = "157";
RadixSort.radixSort(phoneNumbers, 3);
for (String phoneNumber : phoneNumbers) {
System.out.println(phoneNumber);
}
}
}
Java中的排序
java中的排序主要是 Arrays.sort 方法
JDK 7~13 中的排序实现
排序目标 | 条件 | 采用算法 |
---|---|---|
int[] long[] float[] double[] | size < 47 | 混合插入排序 (pair) |
size < 286 | 双基准点快排 | |
有序度高 | 归并排序 | |
有序度低 | 双基准点快排 | |
byte[] | size > 29 | 计数排序 |
size <= 29 | 插入排序 | |
char[] short[] | size > 3200 | 计数排序 |
size < 47 | 插入排序 | |
size < 286 | 双基准点快排 | |
有序度高 | 归并排序 | |
有序度低 | 双基准点快排 | |
Object[] | -Djava.util.Arrays.useLegacyMergeSort=true | 传统归并排序 |
TimSort |
JDK 14~20 中的排序实现
排序目标 | 条件 | 采用算法 |
---|---|---|
int[] long[] float[] double[] | size < 65 并不是最左侧 | 混合插入排序 (pin) |
size < 44 并位于最左侧 | 插入排序 | |
递归次数超过 384 | 堆排序 | |
对于整个数组或非最左侧 size > 4096,有序度高 | 归并排序 | |
有序度低 | 双基准点快排 | |
byte[] | size > 64 | 计数排序 |
size <= 64 | 插入排序 | |
char[] short[] | size > 1750 | 计数排序 |
size < 44 | 插入排序 | |
递归次数超过 384 | 计数排序 | |
不是上面情况 | 双基准点快排 | |
Object[] | -Djava.util.Arrays.useLegacyMergeSort = true | 传统归并排序 |
TimSort |
-
其中 TimSort 是用 归并 + 二分插入排序 的混合排序算法
-
值得注意的是从 JDK 8 开始支持 Arrays.parallelSort 并行排序
-
根据最新的提交记录来看 JDK 21 可能会引入基数排序等优化