左神基础算法笔记-二

以下三种排序的时间复杂度都是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)

  • 稳定的排序

  1. 有几亿个数,他们的范围是0-200,要求排序。

  2. 我们准备201个桶,0号桶,1号桶,2号桶……200号桶

  3. 数字全部放到桶中之后,依次把桶中的数都倒出来,排序完成。

    • 这是一种基于数据状态的排序,不基于比较,工程上应用不多,笔试时可以帮助过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

  1. 如果是N个数的话,准备N+1个桶,每个桶将范围平分。

    • 例如是9个数,范围0-99,那就准备10个桶,1号桶负责范围0-9,2号桶负责范围10-19……10号桶负责范围90-99
  2. 因为确定范围时就用到了无序数组的最大值和最小值,所以开始的桶和结束的桶是不可能空的。由于桶的数量比数的数量多一个,所以除去首尾桶中间的桶必有空桶。

  3. 因为空桶的存在,一定会有以下情况,则60-69桶中的最小值与40-49桶中的最大值必为相邻数,且他们的min-max的差值一定大于桶的范围。min-max>桶的范围

  1. 相邻两数可以像上图那样来自两个不同的桶,也可能处于一个桶内。而上图所示的一定出现的情况中相邻两数的差值是大于桶的范围的,而桶内部相邻两数的情况差值一定是小于等于桶的范围的。所以想要求出相邻两数的最大差值,就不需要考虑桶内相邻两数的情况。

  2. 所以每个桶不需要记录每个桶范围上的所有数,只记录进过该桶的最大值与最小值即可。求出所有 后一个非空桶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()和比较器的使用

  1. 如果size<60时,会进行一个insertion排序

  2. size>60时会,进行merge排序或者quick排序(当分出来的块的size少于60时再用insert排序,虽然insert排序的时间复杂度是O(N^2),但是常数项低,当N小的时候,常数项的优势就显现出来了)

  3. 什么时候用merge,什么时候用quick

    • 对基础类型排序时用quick

    • 不是基础类型的时候用merge

为什么不是基础类型的时候用mergeSort不用quickSort?

当是基础类型时,不关心稳定性,而且quickSort常数项好,更快;当不是基础类型时,mergeSort可以保证稳定性。

例子:一个展示员工信息的表,先按照年龄升序排列

国籍年龄
中国12
美国13
中国14
美国15

这时候想要在此基础上按照国籍排序,就需要保持原来的相对次序,新的排序之后如下所示

国籍年龄
中国12
中国14
美国13
美国15
public static class IdAscendingComparator implents Comparator<Student> {

@Override
public int compare(Student o1, Student o2) {
// 返回负值o1排前面,返回正值o2排前面
return o1.id - o2.id;
}

}

Arrays.sort(students, new IdAscendingComparator());

9. 作业

  1. 快速排序的平均时间复杂度和最坏时间复杂度是?

    • 快速排序的平均时间复杂度是 O(N*lgN)
    • 快速排序的最坏时间复杂度是 O(N^2)
    • 最好的 Case:对每次选取的划分值,左边的元素个数都等于右边的元素个数
    • 最坏的 Case:对每次选取的划分值,都有一边没有元素
  2. 在下列排序算法中,哪一个算法的时间复杂度与初始序列无关?

    • 直接插入排序
    • 冒泡排序
    • 快速排序
    • 直接选择排序 √
  3. 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)

  4. https://www.nowcoder.com/questionTerminal/34f757342a5041c996a88639d5d753c6

  5. https://www.nowcoder.com/questionTerminal/60034881d9a34d7f8cb49ab2011b4453

  6. https://www.nowcoder.com/questionTerminal/3d102a974f9143549db4d904ec816f66

  7. 进制转换

  8. 末尾0的个数

  9. 餐馆

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值