以下三种排序的时间复杂度都是O(N*logN),但额外空间复杂度不同,归并排序是O(N)(辅助数组),快速排序是O(logN)(记录断点的深度),堆排序是O(1)。
1. 归并排序的细节讲解与复杂度分析
归并排序是一种递归,递归就是自己调用自己,定义的过程中有一个子过程,过程与子过程中的流程是一致的,但子过程的规模小于过程。
base case:到什么程度不需要再划分了,是递归的终止条件,写递归时候先写base case。
递归底层实现依赖于系统栈,保存了现场,栈中先压入父过程的参数,变量,代码跑到第几行;然后放入子过程的参数,变量,代码跑到第几行……栈的栈顶代表跑完的函数应该回到的位置。
- 左边递归排好,右边递归排好,生成额外空间,将两边排好的统一外排
public static void mergeSort(int[] arr, int L, int R){
if(L==R){
return;
}
int mid = L + ((R-L) >> 1); // L和R中点的位置
mergeSort(arr, L, mid);
mergeSort(arr, mid+1, R);
merge(arr, L, mid, R);
}
public static void merge(int[] arr, int l, int m, int r){
int[] help = new int[r - l + 1];
int i = 0;
int p1 = l;
int p2 = m+1;
while(p1 <= m && p2 <= r){
help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
}
//两个必有且只有一个越界
while(p1 <= m){
help[i++] = arr[p1++];
}
while(p2 <= r){
help[i++] = arr[p2++];
}
for(i = 0; i<help.length; i++){
arr[l + i] = help[i];
}
}
2. 随机快速排序的细节和复杂度分析
快速排序
快速排序又分为经典快速排序和随机快速排序,经典快速排序每次都以数组最后一个元素为划分值,随机快速排序以数组中随机的一个元素为划分值,改进后的快速排序需要用到一个partition过程(荷兰国旗问题)。
荷兰国旗问题
给定一个数组arr,和一个数num,请把小于num的数放在数组的左边,等于num的数放在数组的中间,大于num的数放在数组的右边。(要求额外空间复杂度O(1),时间复杂度O(N))
public static void quickSort(int[] arr, int L, int R) {
if(L < R) {
// 随机一个换到数组最后做划分值
swap(arr, L + (int) (Math.random() * (R - L + 1)), R);
int[] p = partition(arr, L, R);
// 将小于区的范围用于递归,p[0] - 1 为小于区最后一个位置
quickSort(arr, L, p[0] - 1);
// 将大于区的范围用于递归,p[1] + 1 为大于区开始的个位置
quickSort(arr, p[1] + 1, R);
}
}
public static int[] partition(int[] arr, int L, int R) {
int less = L - 1; // 小于区右边界
int more = R; // 大于区左边界
while (L < more){
// L 表示当前考虑的数的下标
if (arr[L] < arr[R]) {
swap(arr, ++less, L++); // 把当前数换到小于区的右边,小于区右扩,L++
} else if (arr[L] > arr[R]) {
swap(arr, --more, L); // 把当前数换到大于区的左边,大于区左扩,L 不变
} else {
L++;
}
}
swap(arr, more, R); // 把标准值换到大于区左边界,即等于区内
return new int[] { less + 1, more }; // 返回的是等于区域的那一段
}
3. 堆排序的细节和复杂度分析
堆就是完全二叉树(按照层次遍历的顺序,中间不能有空的),在完全二叉树对应的数组结构中:
- 左孩子位置:2 * i + 1
- 右孩子位置:2 * i + 2
- 父节点位置:( i - 1 ) / 2
大根堆:一棵树中的每棵子树都以最大值作为头结点。
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 依次对每个节点 heapInsert,建立大根堆
for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i);
}
int heapSize = arr.length;
// 交换最大值到末尾
swap(arr, 0, --heapSize);
while (heapSize > 0) {
// 重新调整大根堆
heapify(arr, 0, heapSize);
swap(arr, 0, --heapSize);
}
}
public static void heapInsert(int[] arr, int index) {
// 如果当前结点大于父节点,就向上交换
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
public static void heapify(int[] arr, int index, int heapSize) {
int left = index * 2 + 1;
while(left < heapSize){
// left + 1 为右孩子,需要检查右孩子是否越界
int largest = left + 1 < heapSize && arr[left + 1] > arr[left] ? left + 1 : left;
// 左右孩子中大的再去跟父节点比,最后得到大的下标 largest
largest = arr[largest] > arr[index] ? largest : index;
// 如果父节点下标为 largest 停止循环
if (largest == index) {
break;
}
// 否则向上交换
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
- 若完全二叉树节点数为 N,树高度为 logN,调整的话只需要一条路径,代价只是 logN,调整非常快。
- 大根堆还有其他的用处,比如给一堆数,让返回这堆数中最大值,就可以用到大根堆……
- 建立大根堆的时间复杂度是 log1 + log2 + log3 +……+ logN ,这在数学上收敛,是 O( N ) 的
- 堆排序的时间复杂度是 O( N * logN ) 的
- 堆排序先是 heapInsert 的过程,将数组建立成大根堆,得到最大值放到排序结果最后;将堆大小减 1,继续得到最大值放在排序结果最后
4. 排序算法的稳定性
一个无序数组变成有序数组之后,其中相同大小的元素的相对顺序不变。
可以实现稳定性:冒泡排序,插入排序,归并排序,
不能实现稳定性:选择排序,快速排序(论文级别可以做到稳定性“01 stable sort”),堆排序
5. 有关排序问题的补充
- 归并排序的额外空间复杂度可以变成O(1),但是非常难,不
需要掌握,可以搜“归并排序 内部缓存法” - 快速排序可以做到稳定性问题,但是非常难,不需要掌握,
可以搜“01 stable sort” - 有一道题目,是奇数放在数组左边,偶数放在数组右边,还
要求原始的相对次序不变,碰到这个问题,可以怼面试官。面试
官非良人(奇偶和比一个数大或小都是一种非0即1的过程,在这种情况下快排实现稳定性,“01 stable sort”)。
6. 桶排序、计数排序、基数排序的介绍
非基于比较的排序,与被排序的样本的实际数据状况很有关系,所以实际中并不经常使用
时间复杂度O(N),额外空间复杂度O(N)
稳定的排序
有几亿个数,他们的范围是0-200,要求排序。
我们准备201个桶,0号桶,1号桶,2号桶……200号桶
数字全部放到桶中之后,依次把桶中的数都倒出来,排序完成。
- 这是一种基于数据状态的排序,不基于比较,工程上应用不多,笔试时可以帮助过CASE。
- 桶排序有计数排序和基数排序
计数排序
每个桶中记录数字出现次数,再依次拷贝回相应个数的数字到数组中。
public static void bucketSort(int[] arr){
if (arr == null || arr.length < 2) {
return;
}
// 先遍历一边找出要排序数据中的最大值
int max = Integer.MIN_VALUE;
for (int i = 0; i < arr.length; i++) {
max = Math.max(max, arr[i]);
}
// 按照数据的范围初始化桶
int[] bucket = new int[max + 1];
// 桶中记录数字出现的次数
for (int i = 0; i < arr.length; i++) {
bucket[arr[i]]++;
}
// 按照桶中记录的数字出现次数拷贝回原数组
int i = 0;
for (int j = 0; j < bucket.length; j++) {
while (bucket[j]-- > 0) {
arr[i++] = j;
}
}
}
7. 桶排序的补充问题
给一个无序数组,求排序之后相邻两数的最大差值,数组是long类型(包括正负和零),要求时间复杂度O(N)
例:输入:3,1,6,0 返回:3
如果是N个数的话,准备N+1个桶,每个桶将范围平分。
- 例如是9个数,范围0-99,那就准备10个桶,1号桶负责范围0-9,2号桶负责范围10-19……10号桶负责范围90-99
因为确定范围时就用到了无序数组的最大值和最小值,所以开始的桶和结束的桶是不可能空的。由于桶的数量比数的数量多一个,所以除去首尾桶中间的桶必有空桶。
因为空桶的存在,一定会有以下情况,则60-69桶中的最小值与40-49桶中的最大值必为相邻数,且他们的min-max的差值一定大于桶的范围。min-max>桶的范围
相邻两数可以像上图那样来自两个不同的桶,也可能处于一个桶内。而上图所示的一定出现的情况中相邻两数的差值是大于桶的范围的,而桶内部相邻两数的情况差值一定是小于等于桶的范围的。所以想要求出相邻两数的最大差值,就不需要考虑桶内相邻两数的情况。
所以每个桶不需要记录每个桶范围上的所有数,只记录进过该桶的最大值与最小值即可。求出所有 后一个非空桶min - 前一个非空桶max 的值,其中最大的即为答案。如下图所示。
public static int maxGap(int[] nums) {
if (nums == null || nums.length < 2) {
return 0;
}
int len = nums.length;
int min = Integer.MAX_VALUE;
int max = Integer.MIN_VALUE;
for (int i = 0; i < len; i++) {
min = Math.min(min, nums[i]);
max = Math.max(min, nums[i]);
}
if (min == max) {
return 0;
}
boolean[] hasNum = new boolean[len + 1];
int[] maxs = new int[len + 1];
int[] mins = new int[len + 1];
int bid = 0;
for (int i = 0; i < len; i++) {
bid = bucket(nums[i], len, min, max);
mins[bid] = hasNum[bid] ? Math.min(mins[bid], nums[i]) : nums[i];
maxs[bid] = hasNum[bid] ? Math.max(maxs[bid], nums[i]) : nums[i];
hasNum[bid] = true;
}
int res = 0;
int lastMax = maxs[0];
int i = 1;
for (; i <= len; i++) {
if (hasNum[i]) {
res = Math.max(res, mins[i] - lastMax);
lastMax = maxs[i];
}
}
return res;
}
/**
* 返回num应该进几号桶
*/
public static int bucket(long num, long len, long min, long max) {
return (int) ((num - min) * len / (max - min));
}
8. Java中的Arrays.sort()和比较器的使用
如果size<60时,会进行一个insertion排序
size>60时会,进行merge排序或者quick排序(当分出来的块的size少于60时再用insert排序,虽然insert排序的时间复杂度是O(N^2),但是常数项低,当N小的时候,常数项的优势就显现出来了)
什么时候用merge,什么时候用quick
对基础类型排序时用quick
不是基础类型的时候用merge
为什么不是基础类型的时候用mergeSort不用quickSort?
当是基础类型时,不关心稳定性,而且quickSort常数项好,更快;当不是基础类型时,mergeSort可以保证稳定性。
例子:一个展示员工信息的表,先按照年龄升序排列
| 国籍 | 年龄 |
|---|---|
| 中国 | 12 |
| 美国 | 13 |
| 中国 | 14 |
| 美国 | 15 |
这时候想要在此基础上按照国籍排序,就需要保持原来的相对次序,新的排序之后如下所示
| 国籍 | 年龄 |
|---|---|
| 中国 | 12 |
| 中国 | 14 |
| 美国 | 13 |
| 美国 | 15 |
public static class IdAscendingComparator implents Comparator<Student> {
public int compare(Student o1, Student o2) {
// 返回负值o1排前面,返回正值o2排前面
return o1.id - o2.id;
}
}
Arrays.sort(students, new IdAscendingComparator());
9. 作业
-
- 快速排序的平均时间复杂度是 O(N*lgN)
- 快速排序的最坏时间复杂度是 O(N^2)
- 最好的 Case:对每次选取的划分值,左边的元素个数都等于右边的元素个数
- 最坏的 Case:对每次选取的划分值,都有一边没有元素
-
- 直接插入排序
- 冒泡排序
- 快速排序
- 直接选择排序 √
i=k=0;
while(k<n){
i++;
k += i;
}
// 求以上程序段的时间复杂度?O ( n ^ ( 1 / 2 ) )
程序一共循环 i 次
k < n
k = 1 + 2 + 3 + …… + i
i * ( i + 1 ) / 2 < n
( i ^ 2 + i ) / 2 < n
i = n ^ (1/2)
https://www.nowcoder.com/questionTerminal/34f757342a5041c996a88639d5d753c6
https://www.nowcoder.com/questionTerminal/60034881d9a34d7f8cb49ab2011b4453
https://www.nowcoder.com/questionTerminal/3d102a974f9143549db4d904ec816f66
713

被折叠的 条评论
为什么被折叠?



