p4 堆排序、桶排序、排序总结
堆
堆是一个完全二叉树。用数组对应完全二叉树,则i位置左孩子为2*i+1,右孩子为2*i+2,父为(i-1)/2。
堆结构重要性大于堆排序
大根堆小根堆
对于每一颗子树,其最大值都是头节点的值。
构建堆的过程:heapInsert
对于每一个新节点,来了之后,递归的和自己父亲比较,比父亲大则交换,直到到头或者没父亲大了。时间复杂度O(logN)
调整堆:heap
对于一个需要调整的节点(如删除堆顶时,将堆顶与堆尾进行交换,此时堆顶需要调整),要寻找其左右孩子,获得大的进行交换。持续进行,直至没有孩子或者孩子都没自己大。时间复杂度O(logN)
堆排序
对于一个数组而言,先将其构建为大根堆。代码如下
class HeapSort {
public static void heapSort(int[] nums, int left, int right){
if(nums==null||nums.length<2){
return;
}
for(int i=0;i<nums.length;i++){
heapInsert(nums, i);
}
int heapSize = nums.length;
swap(nums, 0, --heapSize);
while(heapSize>0){
heapify(nums, 0, heapSize);
swap(nums, 0, --heapSize);
}
}
public static void heapInsert(int[] nums, int index){
while(nums[index]>nums[(index-1)/2]){
swap(nums, index, (index-1)/2));
index = (index-1)/2;
}
}
public static void heapify(int[] nums, int index, int heapSize) {
int left = index*2+1; // 左孩子下标
while(left<heapSize){ // 左孩子不越界,那么右孩子也不会越界,即存在孩子
int maxIndex = (left+1)<heapSize && nums[left]<nums[left+1] ? left:left+1;
maxIndex = nums[maxIndex] > nums[index]? maxIndex:index;
if(maxIndex == index){
break;
}
swap(nums, maxIndex, index);
index = maxIndex;
left = idnex*2+1;
}
}
public static void swap(int[] nums, int index1, int index2){
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
public static void main(String[] args) throws Exception {
int[] nums = new int[]{9,5,7,3,1,6,8,4,2};
mergeSort(nums, 0, nums.length-1);
for(int num:nums){
System.out.println(num);
}
}
}
补充:假设一开始就有整个数组
则构建大根堆的过程,可以不按照从头到尾依次heapInsert。可以从尾到头,依次进行heapify。这个过程会更好一些。
分析:对于叶子节点,共N次,每次时间1;倒数第二层,共N/2次,每次时间2;倒数第三层,共N/4,每次时间3;。。。。
T(N) = N/2 *1 + N/4 *2 + N/8 3 + …
2T(N) = N/2 *2 + N/2 *2 + N/4 3 + …
2T(N) - T(N) = N + N/2 + N/4 + … = O(N)
故排序算法可以这样写:实际上总时间复杂度没有变化,但是构建最大堆的过程变快了
public static void heapSort(int[] nums, int left, int right){
if(nums==null||nums.length<2){
return;
}
// for(int i=0;i<nums.length;i++){
// heapInsert(nums, i);
// }
for(int i=nums.length-1;i>=0;i--){
heapify(nums, i, nums.length);
}
int heapSize = nums.length;
swap(nums, 0, --heapSize);
while(heapSize>0){
heapify(nums, 0, heapSize);
swap(nums, 0, --heapSize);
}
}
堆拓展题目
已知一个几乎有序的数组,请选择一个合适的排序算法对其排序。几乎有序:如果把数组排序后,每个元素移动的距离不超过k,且k针对于数组而言比较小。
解:设定一个长度为k+1的小根堆,先放前7个数,进行heapInsert,将堆顶弹出放于数组[0];然后将第8个数加入堆,调整堆结构,将堆顶放置于数组[1],周而复始,直至没有数可以压入堆。此时依次弹出堆中元素,放于数组之中。时间复杂度O(N*logK)
public void sortedArrDistanceLessK(int[] nums, int k){
PriorityQueue<Integer> heap = new PriorityQueue<>();
int index = 0;
for(;index<=Math.min(nums.length, k);index++){
heap.add(nums[index]);
}
int i = 0;
for(;index<nums.length;i++, index++){
heap.add(nums[index]);
nums[i] = heap.poll();
}
while(!heap.isEmpty()){
nums[i++] = heap.poll();
}
}
堆的细节
扩容:堆的底层是个数组,那么数组耗尽时需要扩容。java扩容时每次大小翻倍,即100->200->400。每次扩容后需要拷贝,此时O(N),但是扩容次数时O(LogN),那么平均单次扩容代价为O(N*logN)/N = O(logN)
操作:对系统内部提供的堆,只能使用add、poll,不能单独改动对某一个节点进行改动。自己手写的是可以支持的。所以部分题目需要手写堆
比较器
调用sort函数的时候,如果希望人为的定义比较的规则,需要参数中加一个 new Comparator(),这个Comparator方法是自己定义的,但是定义时也有潜规则。
- 返回负数,则第一个参数排前面
- 返回正数,则第二个参数放前面
- 返回0,无所谓
比较器亦可使用在有序结构中,如堆。那么堆的构造函数就应该传入一个比较器。它的潜规则如下:
- 返回负数,则第一个参数排上面
- 返回正数,则第二个参数放上面
- 返回0,无所谓
计数排序
此前的排序都是基于比大小。而即将讲的排序应用范围较窄,但是适用于特定数据状态的数据,实际上是一种定制算法。
例:数组nums,数据范围为0200,解:开辟0201的数组,下标代表源数组中值为下标的元素的个数。遍历一遍源数组,获得词频数组;然后将词频数组还原,即排序完成。
但是不适用于数据范围过大的数组,如-231~231-1
基数排序
举例:[17,25,72,13,100]数据最高三位,所以补齐成三位:[017,025,072,013,100]。准备十个桶:0~9。首先根据个位数字进桶
0 1 2 3 … 5 … 7
100 072 013 025 017
从前往后出桶:100,072,013,025,017,根据十位数字进桶
0 1 2 3 … 5 … 7
100 013 025 072
017
出桶:100 013 017 025 072,根据百位数字进桶
0 1 …
013 100
017
025
072
出桶:013 017 025 072 100,排序完毕
但是如果排的数字没有进制,即不可使用基数排序。
基数排序优化后的代码
本来应该按照位数上的数字分别进桶,然后从0桶到9桶依次出桶,然后判断下一位置。但是可以优化成词频数组来代替桶。
举例:[13,21,11,52,62]
词频数组:[0,2,2,1,0…]
改造为前缀和数组:[0,2,4,5,5…],意义:[2] = 4,某位数上小于等于2的数有4个。那么最后入桶的(最右边的)一定在下标3(4-1)的位置上。因为先入桶的先出桶。这里模拟成了后入桶的后出桶。
从右向左遍历原数组,遇到62,找前缀和数组[2] = 4那么62应该放在数组的4-1=3的位置,前缀和数组更新;同理52应放在3-1=2的位置
最终:[21,11,52,62,13]
再在十位上进行该操作,最终结果[11,13,21,52,62]
本质上利用count数组来分片,通过从右向左遍历,完成了入桶出桶的操作。例如:[2] = 4,某位数上小于等于2的数有4个。那么最后入桶的(最右边的)一定在下标3(4-1)的位置上。因为先入桶的先出桶。这里模拟成了后入桶的后出桶。
排序的总结
不稳定排序:选择、快排、堆排
稳定:冒泡、插入、归并、一切桶排序思想下的排序
目前未找到O(NlogN)且空间O(1)又稳定的排序
堆排:O(NlogN)且空间O(1)但不稳定
选择 N^2 1 不稳定
冒泡 N^2 1 稳定
插入 N^2 1 稳定
归并 NLogN N 稳定 稳定,但是空间大
快排 NLogN logN 不稳定 常数项快,但不稳定,空间大
堆 N*LogN 1 不稳定 空间使用小,但不稳定
问题1:基于比较的排序,能不能时间小于 NlogN, 解:目前看来不行
问题2:基于比较的排序,时间小于 NlogN,空间N以下,能不能稳定,解:不行
常见坑:
- 归并排序额外空间复杂度可以为 O(1),但是难,而且会破坏稳定性。可以搜索"归并排序 内部缓存法"
- 原地归并排序帖子,会是时间为 N^2
- 快排可以稳定,但是会上升空间复杂度为 N
- 题目:奇数放在左边,偶数放在右边,且原始相对次序不变,而且要求原地,遇到这个问题可以怼面试官。解:快排中的划分行为,将小的放左边,大的放右边,本质上是一种非0即1的行为。那么也可以对应为奇数放左边,偶数放右边。但是快排做不到稳定,所以我也不会,面试官你来讲讲
排序改进
NLogN 可以和 N^2结合。如快排中,如果左右范围小于某个指标,如60,此处可以将这部分范围改为插入排序。因为小范围内时间差别不明显,而常数项间插入排序显然小,所以可以综合两者优势,在大范围上用快排调度,小范围上用插入排序,最终提高排序速度
Arrays.sort()如果发现排序的数据是基础类型,则会使用快排,如果是非基础类型,则会使用归并,为什么?解:稳定性。