复杂度估计和排序算法(下)
1)荷兰国旗问题
2)随机快速排序
3)堆结构与堆排序
4)认识排序算法的稳定性
5)认识比较器
6)桶排序
7)计数排序
8)基数排序
9)数组排序后的最大差值问题
10)排序算法在工程中的应用
荷兰国旗问题
给定一个数组arr,和一个数num,请把小于num的数放在数组的左边,等于num的数放在数组的中间,大于num的数放在数组的右边。
要求额外空间复杂度为O(1),时间复杂度为O(N)
解决思想:
partition过程:
有一个数组,L~R。初始的时候,less = L-1,more = R+1,cur 是当前数组的值。
L~less表示小于num区域(当less初始为L-1时,意味着这个区域还不存在)
more~R表示大于num区域(当more初始为R+1时,意味着这个区域还不存在)
less~more区域为等于num区域
处理规则:
- cur < num, 把数组中当前位置和小于区域下一个数进行交换,小于区域扩一下
- cur = num, cur直接跳下一个
- cur > num, 当前位置和大于区域的前一个位置交换,然后考察换过来的数cur(>num/=num/<num)
- 如果cur = more时,停止
算法代码:
/**
* 荷兰国旗问题
*/
public class NetherlandsFlag {
//在arr[L...R]范围上,划分成<p,=p,>p的三部分
public static int[] partition(int[] arr,int L,int R,int p){
int less = L-1;//小于p区域右边界
int more = R+1;//大于p区域左边界
int index = L;//当前下标
while (index<more){//当前位置index,不和大于p区域装上,while继续
if (arr[index]<p){
swap(arr,++less,index++);
}else if (arr[index]>p){
swap(arr,--more,index);
}else {
index++;
}
}
return new int[]{less+1,more-1};
}
public static void swap(int[] arr,int i,int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
经典快速排序
思想:
用待排数组的最后一个位置的数字x做划分,划分为小于x,等于x,大于x三个区域,然后等于区域不动,继续用左部分小于x区域和右部分大于x区域两部分做如上的划分,递归这个过程。
改善的经典快排存在的问题:
时间复杂度与数据状况有关
如:最差的情况:等于区域打偏,如:待排数组为 1 2 3 4 5 6 7
每次partition的过程只搞定一个数,partition一次的时间复杂度时O(N),那么这种情况下的时间复杂度为O(N^2)。
最好的情况:等于区域正好在中间。那么根据master公式
T(N) = 2T(N/2)+O(N) (整个问题被分为两个同等规模的子过程,然后还有一个partition过程)
由于经典快排与数据状况有关。为了解决这种问题,有两种常规的规避数据状况的手段 1)随机 2)哈希
算法代码:
/**
* 快速排序
*/
public class QuickSort {
public static void quickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
process(arr, 0, arr.length - 1);
}
//把arr[L...R]上排好序
public static void process(int[] arr, int L, int R) {
if (L < R) {
int[] p = partition(arr, L, R);//p数组是等于区域的[左位置,右位置]
process(arr, L, p[0] - 1);//p[0]等于区域的最左位置
process(arr, p[1] + 1, R);//p[1]等于区域的最右位置
}
}
/**
* 默认以arr[R]作为划分值,在arr[L...R]上,划分为大于、等于、小于这三部部分
* 返回一个数字,int[] res,长度一定是2,res[0]代表等于区域的左边界,res[1]代表等于区域的右边界
*
* @param arr
* @param L
* @param R
* @return
*/
public static int[] partition(int[] arr, int L, int R) {
int less = L - 1;//小于区域的右边界
int more = R;//大于区域的左边界
int index = L;//当前数的下标
while (index < more) {//arr[R]做划分
if (arr[index] < arr[R]) {
swap(arr, ++less, index++);
} else if (arr[index] > arr[R]) {
swap(arr, --more, index);
} else {
index++;
}
}
return new int[]{less+1,more-1};
}
public static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
随机快速排序
思想:
随机选择数组中的一个数与数组的最后一个数做交换,然后用随机选择的数做划分,这样最好的数据状况和最坏的数据状况都是一个概率事件。
时间复杂度是O(NlogN),额外空间复杂度O(logN)
(只有随机快排是这样,最好是O(N),最差是O(NlogN),最差和最好是一个概率事件,期望是O(logN)。原因在于需要记录每次partition之后的断点位置)
工程上:
工程上常用随机快排,因为随机快排一次能搞定中间等于区域很多数,常数项比较低
工程上的随机快排是非递归行为,需要自己把递归行为改成非递归行为。
算法代码:
//把arr[L...R]上排好序
public static void process(int[] arr, int L, int R) {
if (L < R) {//arr[L...R]上不只一个数
swap(arr, L + (int) (Math.random() * (R - L + 1)), R);
int[] p = partition(arr, L, R);//p数组是等于区域的[左位置,右位置]
process(arr, L, p[0] - 1);//p[0]等于区域的最左位置
process(arr, p[1] + 1, R);//p[1]等于区域的最右位置
}
}
堆结构
完全二叉树:
堆就是一棵完全二叉树
- 满二叉树
- 从左到右依次补齐
引入:给定一个数组,可以在逻辑上产生与之对应的二叉树
逻辑对应关系:
给定位置 i ,左孩子在数组中的位置是2i + 1,右孩子在数组中的位置是2i + 2
给定一个孩子节点的位置为j,那么这个孩子节点对应的父节点位置为(j - 1)/2
因此,数组和一棵完全二叉树的关系是一一对应的。
堆结构
1)大根堆:任何一棵子树的最大值是子树的头部
2)小根堆: 任何一棵字数的最小值是字数的头部
问题:
数组对应的堆不一定是一个大根堆,那么如何调整成大根堆呢?
思想:
0~i的位置已经是一个大根堆了,新加入的一个节点,重新调整成大根堆。直到把整个数组对应的原始的堆调节成一个大根堆
新加入的节点,根据数组和堆对应的逻辑关系,找到自己的父节点,与父节点位置的数进行比较,如果大于父节点,则向上调整,否则不调整。重复这个过程。那么时间复杂度log1+log2+log3+... = O(N)
heapInsert过程(加堆过程)
经历一个新节点加入堆里面同时向上调整的过程
heapify过程
建立好的大根堆中的某个值变小,需要重新调整成大根堆。那么根据逻辑关系,找到变小的这个值的孩子节点,找到两个孩子中最大的那个与之比较,如果小于孩子节点,则与孩子节点进行交换,逐渐向下调整
减堆过程(弹出堆顶)
把堆顶位置的元素和堆的最后一个位置互换,然后heapsize--(heapsize是已经形成的堆的大小),重新调整成大根堆
加堆和减堆的时间复杂度
都只与堆的层数有关,只需要在需要变化的路径上进行调整。与其他部分无关。因此一个元素加堆或者减堆时间复杂度为O(logN)。
优先级队列结构实际就是堆结构
堆排序
过程
1)首先把待排的数组调节成一个大根堆
2)将堆的最后一个位置和堆顶元素交换,heapsize--,进行减堆操作
3)0~heapsize范围内做heapify操作,重新调整成一个大根堆
4)repeat step 2 until heapsize = 0
思想:
相当于每次找头部,因为头部是当前堆最大的,找到一个最大的头部,再找次大的头部
时间复杂度
堆排序的时间复杂度,主要在初始化堆过程和每次选取最大数后重新建堆的过程
初始化建堆过程时间:O(n)
更改堆元素后重建堆时间:O(nlogn)
额外空间复杂度
O(1)
算法代码:
/**
* 堆排序
*/
public class HeapSort {
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 0; i < arr.length; i++) {//O(N)次
heapInsert(arr, i);//0~i调成大根堆 O(logN)
}
int size = arr.length;//堆的有效size
swap(arr, 0, --size);//最大值,和堆中最后一个数交换,堆有效size-1
while (size > 0) {//O(N)次
heapify(arr, 0, size);//O(logN)
swap(arr, 0, --size);
}
}
//前提:arr[0...index-1]已经是大根堆了,arr[index]位置是新加的数
//arr[0...index]调成大根堆
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;
}
}
//arr[i]位置的值发生了变化,并且是变小,堆大小是size(arr[0~size-1]),请重新调整成大根堆
public static void heapify(int[] arr, int index, int size) {
int left = index * 2 + 1;//左孩子的位置
while (left < size) {//如果当前位置的数有孩子的话,循环发生
//如果右孩子有的话,和左孩子的值比较,最大的值的下标,作为largest的值
int largest = left + 1 < size //右孩子如果不越界
&& arr[left + 1] > arr[left]//右孩子的值大于左孩子的值
? left + 1 : left;
//两个孩子和父结点的值比较,最大的值的下标,作为largest的值
largest = arr[largest] > arr[index] ? largest : index;
//如果最大的值的下标已经是父结点的位置,过程停止
if (largest == index) {
break;
}
//选出来的largest的位置一定不是i位置,是i位置的左右两个孩子中,较大数的下标
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
排序算法回顾
一、冒泡排序
每一轮相邻位置比较,判断交换与否,找到该轮最大/最小,放到相应位置,执行下一轮。
二、选择排序
每次找最大、最小放到相应位置。
三、插入排序
每次判断一个数跟前边所有的值得大小,找到合适位置,插入。
四、归并排序
分最多的组,两两合并,使组越来越小,每次合并按照目的顺序合并。关键在组的合并函数(为两个组设置标志,移动标志位去判断该让哪一个数进新组)
五、快速排序
随机选择某数,按该数分成左右两组。对左右两组再进行以上划分。
六、堆排序
建堆过程从n/2位置向前每一位进行与左右孩子的比较与交换。将堆顶与最后一个值交换,出最大值。堆调整,从堆顶(此时为不符合的情况)一直往下交换至大于/小于子节点为止,调整结束。
七、希尔排序
插排改良
插排往前比较一位。希尔而是一个设置步长。步长降低,直到步长为一。
八、桶排序
计数排序、基数排序。
时间复杂度:
- 冒泡排序、选择排序、插入排序 :
- 归并排序、快速排序(随机)、堆排序、希尔排序: N*logN
- 桶排序 :N
额外空间复杂度:
- O(1):冒泡排序、选择排序、插入排序、堆排序、希尔排序
- O(logN)-O(N):快速排序
- O(N) 归并排序
- O(M)桶排序 (桶的数量)
稳定性(待排序中相同元素的相对次序是否改变):
- 稳定: 冒泡排序、插入排序、归并排序、桶排序
- 不稳定:选择排序、快速排序、堆排序、希尔排序
快排为啥叫快排:
不是说优良,而是在最好情况下,它的渐进复杂度与堆排序和归并排序相同,只是快速排序的常量系数比较小。
工程上排序是综合排序。
- 数组小,插排
- 数组大,快排或者o(N*logN)的排序。
总结:
一般来说,当数组长度小于60时,插排的时间复杂度为O(N^2),常数项较低,直接用插排。
当数组长度较大时,若数据为基础类型,则使用快排,若数据为自定义类型,由于稳定性较好,使用归并排序。
快速排序本身不能做到稳定,01 stable sort 很难,是超纲问题
有关排序问题的补充:
- 归并排序的额外空间复杂度可以变成O(1),但是非常难,不需要掌握,可以搜“归并排序,内部缓存法”
- 快速排序可以做到稳定性问题,但是非常难,不需要掌握,可以搜“01 stable sort”
- 有一道题目,是奇数放在数组左边,偶数放在数组右边,还要求原始的相对次序不变,碰到这个问题,可以怼面试官。面试官非良人。
桶排序:一种数据状况出现的次数
工作的原理是将数组分到有限数量的桶子里。每个桶子再个别排序
桶排序、计数排序、基数排序的介绍:
- 非基于比较的排序,与被排序的样本的实际数据状况很有关系,所以实际中并不经常使用
- 时间复杂度O(N),额外空间复杂度O(N)
- 稳定的排序
比较器
用哪种方法进行比较
//compare方法中,返回负的时候,认为第一个参数应该排在前面
//compare方法中,返回正的时候,认为第二个参数应该排在前面
//compare方法中,返回0的时候,认为谁放在前面都行
public static class IdAscendingComparator implements java.util.Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o1.id - o2.id;
}
}
import java.util.Arrays;
public class Comparator {
public static class Student {
public String name;
public int id;
public int age;
public Student(String name, int id, int age) {
this.name = name;
this.id = id;
this.age = age;
}
}
//compare方法中,返回负的时候,认为第一个参数应该排在前面
//compare方法中,返回正的时候,认为第二个参数应该排在前面
//compare方法中,返回0的时候,认为谁放在前面都行
public static class IdAscendingComparator implements java.util.Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o1.id - o2.id;
}
}
public static class IdDescendingComparator implements java.util.Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o2.id - o1.id;
}
}
public static class AgeAscendingComparator implements java.util.Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o1.age - o2.age;
}
}
public static class AgeDescendingComparator implements java.util.Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o2.age - o1.age;
}
}
public static void printStudents(Student[] students) {
for (Student student : students) {
System.out.println("Name : " + student.name + ", Id : " + student.id + ", Age : " + student.age);
}
System.out.println("===========================");
}
public static void main(String[] args) {
Student student1 = new Student("A", 1, 23);
Student student2 = new Student("B", 2, 21);
Student student3 = new Student("C", 3, 22);
Student[] students = new Student[]{student3, student2, student1};
printStudents(students);
Arrays.sort(students, new IdAscendingComparator());
printStudents(students);
Arrays.sort(students, new IdDescendingComparator());
printStudents(students);
Arrays.sort(students, new AgeAscendingComparator());
printStudents(students);
Arrays.sort(students, new AgeDescendingComparator());
printStudents(students);
}
}
补充问题
题目:
给定一个数组, 求如果排序之后, 相邻两数的最大差值, 要求时
间复杂度O(N), 且要求不能用非基于比较的排序。
思路:
如果用排序法实现,其时间复杂度为O(NlogN),而如果利用桶排序的思想(不是桶排序),可以做到O(N),额外空间复杂度为O(N)。遍历arr找到最大值max和最小值min。如果arr的长度为N,准备N+1个桶,把max单独放在第N+1个桶中,[min,max)范围上的数放在1~N号桶里,对于1~N号桶中的每一个桶来说,负责的区间为(max-min)/N。如果一个数为num,它应该分配进(num-min)*len/(max-min)。
arr一共有N个数,min一定会放进1号桶中,max一定会放进最后的桶中,所以,如果把所有的数放进N+1个桶中,必然有桶是空的。产生最大差值的相邻数来自不同桶。所以只要计算桶之间数的间距可以,也就是只用记录每个桶的最大值和最小值,最大差值只可能来自某个非空桶的最小值减去前一个非空桶的最大值。
注意:
答案并不一定来自与空桶相邻的两个非空桶的最小值前去前一个非空桶的最大值!
代码:
/**
* 给定一个数组, 求如果排序之后, 相邻两数的最大差值, 要求时
* 间复杂度O(N), 且要求不能用非基于比较的排序。
*/
public class MaxGap {
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.min(max, nums[i]);
}
if (min == max) {
return 0;
}
boolean[] hasNum = new boolean[len + 1];//hasNum[i]
int[] maxs = new int[len + 1];//maxs[i]
int[] mins = new int[len + 1];//mins[i]
int bid = 0;
for (int i = 0; i < len; i++) {
bid = bucket(nums[i], len, min, max);
//判断桶是否为空,如果为空,则将第i个数填进去,否则和桶中的最大值或最小值比较
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;
}
//返回数字应该进几号桶
private static int bucket(int num, int len, int min, int max) {
return (int) ((num - min) * len / (max - min));
}
}