目录
一、快排额外空间复杂度
在最差的情况下,每次都只有单侧区间,这样栈的深度就是O(N) => 额外空间O(N)
在比较好的情况,取的划分值恰好在中点附近,这样就是一棵二叉树,栈的深度是O(NlogN) => 额外空间O(NlogN) (在一个节点的左枝递归结束后释放的空间可以被右枝复用)
快排额外空间复杂度与划分值的选择有关,而划分值的选择是概率事件。在等概率的情况下,数学上的累加期望是O(NlogN) => 一般认为快排的额外(栈)空间为O(NlogN)
二、堆排序
1.完全二叉树
堆在逻辑结构上其实是一棵完全二叉树
完全二叉树:每层都是满的 or 在不满的最后一层也是从左到右变满的
连续的数组 可以想象成一棵完全二叉树
(下标从0开始)
位置i的左孩子2*i+1 右孩子2*i+2 父亲(i-1)/2
(下标从1开始)
位置i的左孩子2*i 右孩子2*i+1 父亲i/2
2.堆排序的实现
大顶堆:每一棵(子)树的根是该树的最大值
以6为根的树最大值是6
以5为根的子树的最大值时5
……
小顶堆同理
堆排序实现如下:
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); //O(logN)
}
int heapSize=arr.length;
swap(arr, 0, --heapSize);
while(heapSize>0) { //O(N)
heapify(arr, 0, heapSize); //O(logN)
swap(arr, 0, --heapSize); //O(1)
}
}
//用户给我一个数字让我插入到堆中
public static void heapInsert(int[] arr, int index) {
while(index-1>0&&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) { //下方还有孩子
//两个孩子中,谁的值打,把下标给largest
int largest=left+1<heapSize&&arr[left+1]>arr[left]?left+1:left;
//父和孩子之间,谁的值大,白下标给largest
largest=arr[largest]>arr[index]?largest:index;
if(largest==index) {
break;
}
swap(arr, largest, index);
index=largest;
left=index*2+1;
}
}
public static void swap(int[] arr, int x, int y) {
int tmp=arr[x];
arr[x]=arr[y];
arr[y]=tmp;
}
heapInsert方法是处理加入数字放置位置
heapify方法可以从任意位置index开始进行堆调整
上述两个方法都是O(logN)级别的操作
3.堆排序拓展题目
已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离可以不超过k,并且k相对于数组来说比较小。请选择一个合适的排序算法针对这个数据进行排序。
只需要维护一个大小为6(k)的小顶堆。
一开始,先将0-5的数全部加入小顶堆,然后选出最小的放在0位置,此时该位置的数值一定是最小的(应该放在0位置的数值一定在[0,5]这些位置中)
随后,加入8位置的数(替换掉堆顶已经用过的最小值)并调整堆,然后取出最小值应该放在1位置上……下面同理
最后,数组所有数值取完后,只需要将小根堆的数不断堆调并取出堆顶元素即可。
时间复杂度O(N*logK) => 如果k很小,这几乎是一个O(N)的算法
public void sortArrayDistanceLessK(int[] arr, int k) {
//默认小根堆
PriorityQueue<Integer> heap=new PriorityQueue<>();
int index=0;
for(;index<=Math.min(arr.length, k);index++) {
heap.add(arr[index]);
}
int i=0;
for(;index<arr.length;i++,index++) {
heap.add(arr[index]);
arr[i]=heap.poll();
}
while(!heap.isEmpty()) {
arr[i++]=heap.poll();
}
}
小顶堆也可以不用自己实现,可以直接调用自带的优先队列,优先队列的本质是堆。堆结构扩容均摊下来是O(logN)代价。
简单使用堆排序可以直接使用优先队列。但是如果要灵活修改调整或完成某个具体功能需要自己手写堆。
三.比较器的使用
- 比较器的实质是重载比较运算符
- 比较器可以很好的应用在特殊标准的排序上
- 比较器可以很好应用在根据特殊标准排序的结构上
public static class AComp implements Comparator<Integer>{
public int compare(Integer arg0, Integer arg1) {
return arg1-arg0;
}
}
public static void main(String[] args) {
Integer[] arr= {5,4,3,2,7,9,1,0};
Arrays.sort(arr, new AComp());
for(int i=0;i<arr.length;i++) {
System.out.println(arr[i]);
}
}
在java中的比较器,在C++中其实就是重载运算符。
public static class AComp implements Comparator<Integer>{
public int compare(Integer arg0, Integer arg1) {
return arg1-arg0;
}
}
public static void main(String[] args) {
PriorityQueue<Integer> heap=new PriorityQueue<>(new AComp());
heap.add(8);
heap.add(4);
heap.add(4);
heap.add(9);
heap.add(10);
heap.add(3);
while(!heap.isEmpty()) {
System.out.println(heap.poll());
}
}
比较器可以用在堆排序中,优先队列默认的是小顶堆。如果如上述代码按照降序规定,就可以得到大顶堆。
四.桶排序
冒泡、选择、插入、归并、快速、堆排序都是基于比较的排序,而桶排序是不基于比较的排序。
1.计数排序
上述计数排序,统计每个数出现的次数,最后再从左到右遍历词频数组将每个数依次取出词频个。上述这种方法仅适用数值范围较小的情况,如果数据范围很大则不适用。
2.基数排序
为什么桶排序可以区分百位?十位?个位?
百位是最后排序的,其优先级最高;十位是倒数第二排序的,其优先级次高;而个位是最早排序的,其优先级最低。因此,可以很好区分每个位。
该方法比计数排序好,桶的个数不会太多,如果是K进制数,则只需要准备K个桶即可。
只要不是基于比较的排序,都和数据高度相关,比如这里桶的数量需要根据数据的进制确定,再比如计数排序的词频数组大小和数据的大小有关。
先按照个位将数组的数依次放进桶内,随后从0号桶将这些数依次倒出来
第二次按照十位数字进桶,然后从0号桶依次倒出来
第三次按照百位数字进桶,然后导出来就有序了。
count是一个前缀和数组,用于统计小于等于当前(数值d位)的数有多少个(实现d位不同数字的分片,将d位相同的分到了一片),相当于一次入桶。
随后从右向左遍历数组每个数字,然后根据(数值d位)的count数字来确定出桶后应该处在的位置。
从后向前遍历是可以让后进的数值比较靠后(count值还比较大),从而保证出桶满足先进先出,后进后出的原则。
public static void radixSort(int[] arr) {
if(arr==null||arr.length<2)return;
radixSort(arr,0,arr.length-1,maxbits(arr));
}
//返回数组中最大值有几个位
public static int maxbits(int[] arr) {
int max=Integer.MIN_VALUE;
for(int i=0;i<arr.length;i++) {
max=Math.max(max, arr[i]);
}
int res=0;
while(max!=0) {
res++;
max/=10;
}
return res;
}
public static int getDigit(int x, int d) {
return ((x/((int)Math.pow(10, d-1)))%10);
}
public static void radixSort(int[] arr, int L, int R, int digit) {
final int radix=10;
int i=0, j=0;
//有多少个数准备多少个辅助空间
int[] bucket=new int[R-L+1];
for(int d=1;d<=digit;d++) { //有多少个位就进出几次
//10个空间
//count[0] 当前位(d位)是0的数字有多少个
//count[1] 当前位(d位)是(0和1)的数字有多少个
//count[2] 当前位(d位)是(0、1和2)的数字有多少个
//count[i] 当前位(d位)是(0~i)的数字有多少个
int[] count=new int[radix]; //count[0..9]
for(i=L;i<=R;i++) {
j=getDigit(arr[i],d);
count[j]++;
}
for(i=1;i<radix;i++) {
count[i]=count[i-1]+count[i];
}
for(i=R;i>=L;i--) {
j=getDigit(arr[i],d);
bucket[count[j]-1]=arr[i];
count[j]--;
}
for(i=L,j=0;i<=R;i++,j++) {
arr[i]=bucket[j];
}
}
}
五.排序算法的稳定性及其汇总
稳定性:同样值之间,不因为排序而改变相对次序。
不具备稳定性的排序:选择排序、快速排序、堆排序
具备稳定性的排序:冒泡排序、插入排序、归并排序、一切桶思想下的排序
目前没有找到时间复杂度O(NlogN),额外空间复杂度O(1),又稳定的排序
如上图,标号为1的数字1排序前后都在标号2的数字1前面,其他相同数字之间的相对次序也没有改变,那么这个排序算法就是稳定的。(排序算法的稳定性可以保留相对次序)
稳定性在实际生活中可能比较有用,比如我可以对购买的商品按照价格排序一次,然后再按照好评率排一次,这样在最上面的就是一些物美价廉的商品。
1.选择排序
如上图,在0~N-1找到最小的1和0位置的3交换,这一交换就破坏了3之间的相对次序(不具备稳定性)
2.冒泡排序
冒泡排序可以保持稳定性,比如数值5在冒泡过程中遇到了5,可以不用交换,直接用后边的那个5继续往后冒泡。(具有稳定性)
3.插入排序
如上图插入2的时候,遇到相同元素是就直接停下,可以不发生交换。(具有稳定性)
4.归并排序
归并排序遇到相等的数时,可以每次都让左边的数先进,这样一样可以保证最终相同数之间的相对次序。(具有稳定性)
5.快速排序
如上图所示,当指针指到3的时候需要移入<=区域,这时就是发生3和最前的6发生交换,这时就破坏的相同数之间的相对次序。(不具有稳定性)
6.堆排序
如上图,插入数字6的时候,发现6比4大,因此需要将6与它的父亲4进行交换。看数组可以发现,这样一交换,数组中两个4的相对次序就发生了改变,破坏了稳定性。(不具有稳定性)
7.排序比较
O(N^2)中,冒泡、插入优于选择。
O(NlogN)中,归并用于需要稳定的情况、堆排用于省时省空间、快排可以用于省时。
8.常见的坑
- 归并排序的额外空间复杂度可以变成O(1),但是肥肠难,不需要掌握,有兴趣可以搜“归并排序 内部缓存法”
- “原地归并排序”的帖子都是垃圾,会让归并排序的时间复杂度变成O(N^2)
- 快速排序可以做到稳定性问题,但是肥肠难,不需要掌握,可以搜“01 stable sort”
- 所有的改进都不重要,因为目前没有找到时间复杂度O(NlogN),额外空间复杂度O(1),又稳定的排序
- 有一道题目,是奇数放在数组左边,偶数放在数组右边,还要求原始的相对次序不变,碰到这个问题,可以怼面试官(把一种情况放在左边,把另一种情况放在右边,这是经典的01基准的paratition(快排1.0),很难做到保持相对次序)
9.工程上对排序进行改进
利用各自排序的优势进行拼装。
充分利用O(NlogN)和O(N^2)各自的优势,大调度上用O(NlogN),小样本利用O(N^2)(在小样本上的常数比较小)
public static void quickSort(int[] arr, int L, int R) {
if(l==r)return;
if(l>r-60) {
在arr[l..r]插入排序
O(N^2)小样本量的时候,跑的快
return
}
swap(arr, L+(int)(Math.random())*(R-L+1),R);
int[] p=partition(arr, L, R);
quickSort(arr, L, p[0]-1); //《区
quickSort(arr, p[1]+1, R); //>区
}