比较器,堆结构,堆排序

比较器:

1)比较器的实质就是重载比较运算符

2)比较器可以很好的应用在特殊标准的排序上

3)比较器可以很好的应用在根据特殊标准排序的结构上

4)写代码变得异常容易,还用于泛型编程

比较器的统一约定

@Override
public int compare(T o1,T o2){
    //返回负数的时候,就是o1比o2优先的情况
    //返回正数的时候,就是o2比o1优先的情况
    //返回0的情况,就是o1和o2同样优先的情况
}
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;
    }
}
public static class IdAscendingComparator implements Comparator<Student>{
    //返回负数的时候,第一个参数排在前面
    //返回正数的时候,第二个参数放在前面
    //返回0的时候,谁在前都可以
    @Override
    public int compare(Student o1,Student o2){
        return o1.id - o2.id;//id升序    
    }
}
public static class IdShengAgeJiangOrder implements Comparator<Student> {
    // 根据id从小到大,但是如果id一样,按照年龄从大到小
    @Override
    public int compare(Student o1,Student o2){
        return o1.id != o2.id ? (o1.id - o2.id) : (o2.age - o1.age);    
    }
}
//TreeMap中的比较器
//我们改用lamda表达式来进行改写
TreeMap<Student,String> treeMap = new TreeMap<>((a,b) -> a.id - b.id);
//里面的表示也是id升序的表达方式。

还需要注意的是,比较器只有id定义,所以加入相同的id的时候,只会认为是第一条数据。

堆:

堆就是heap,各个语言中,heap使用层次的名字叫做PriorityQueue:优先队列

首先看看什么是完全二叉树:

其实简单的说,一个树要么这层是满的,在不满的层(最后一层)从左往右依次变满的状态中

比较器,堆结构,堆排序 

 

空树是完全二叉树,一个孤零零的节点也是完全二叉树

怎么表示一颗完全二叉树呢?

我们可以使用数组来表示二叉树,从零出发的一段连续的数组可以被认为是一棵完全二叉树,当我们用0来表示i = 0完全二叉树的头结点的时候,他的左子树节点就为2 * i + 1,右子树节点为2*i+2,父节点为i - 1/2.

什么是堆呢?

简单的说,堆结构就是一颗完全二叉树,堆又分为大根堆,即每一颗子树上的最大值都是自己头结点的值,还有小根堆,任何一个节点为头的子树,这个节点所在树的值就是最小值

一个大小为N的堆,高度是logN,新加一个数调整代价为logN

现在看看如何在堆中插入一个元素,也就是heapInsert

//新加进来的数,现在停在index位置,请依次往上移动
//移动到零位置,或者干不掉自己的父亲了,停止
private void heapInsert(int[] arr,int index){
    while(arr[index] > arr[(index - 1)/2]){
        swap(arr,index,(index - 1)/2);
        index = (index - 1) / 2;    
    }
}

当我们删除了大根堆中最大的那个树,那么又该如何保证,新的这个树也是一个大根堆呢,涉及到的过程叫做堆化

//从index位置,往下看,不断的下沉
//当较大的孩子都不再比Index位置的数大,或者已经没有孩子了,此时就停止
public void heapify(inr[] arr,int index,int heapSize){
    //heapSize用来记录堆中元素的大小
    //找到左子树的位置
    int left = index * 2 + 1;
    while(left < heapSize){//如果有左孩子可能有有孩子,也可能没用右孩子
        //把较大孩子的下标交给largest
        int largest = left + 1 < heapSize && arr[left + 1] > arr[left] ? left + 1: left;
        //较大孩子跟父pk
        largest = arr[largest] > arr[index] ? largest : index;
        if(largest == index){
            break;        
        }
        swap(arr,largest,index);
        index = largest;
        left = index * 2 + 1;
    }
}
//左孩子越界,右孩子一定越界

如果在堆中的某个元素x变化为x'。举两个例子,比较器,堆结构,堆排序

这两张图的意思是说,如果x位置的元素,他的值变大了,那么只可能发生heapInsert过程,如果x的位置他的值变小了,那么只会发生heapify过程,同时调用heapInsert和heapify检查该位置,两者只调用一个。

系统默认的堆是小根堆即PriorityQueue,优先队列,要想变为大根堆,需要定义比较器。

堆排序:

1) 先整个数组N把所有数调成大根堆  假设一个一个数给你

2) 最大值在0位置跟N-1位置的数交换(最大值到N-1), 堆大小减1, 0位置做heapify

3)0~N-2位置是大根堆, 再0~N-3, 一直到heapsize减到0, 整个数组全有序了

 

public static void heapSort(int[] arr){
    if(arr == null || arr.length < 2){
        return;    
    }
    //O(N * logN)
    //每个位置的数都认为是新加的数
    for(int i = 0;i < arr.length;i++){
        heapInsert(arr,i);    
    }
    //从下往上建堆,O(N)
    for(int i = arr.length - 1;i >= 0;i--){
        heapify(arr,i,arr.length);    
    }
    int heapSize = arr.length;
    swap(arr,0,--heapSize);//全局最大换到N-1位置
    //O(N*logN)
    while(heapSize > 0){//O(N)
        heapify(arr,0,heapSize);//O(logN)
        swap(arr,0,--heapSize);//O(1)//断连的每一步都是当前最大值该去的位置    
    }
}

估算复杂度

可以用定性分析,还有一种方式就是当我增加数据量的时候,它这个整个过程的复杂度还是不是原来那个用这样一种方式来跟你的时间复杂度

你严格来说你建立一大堆的过程,时间复杂度是,单次是logN,但是你一共要把 N 个数建立成大根堆,所以第一步建立大大根对的过程是N*logN的,但是仔细一想,我们会发现稍微有点问题,

因为数据量是由小变到大的,而我中间的某个数据量,它可能每一次不是严格的logn,是LOG很小的一个量,只是它逐渐加最后几个节点它的复杂度才是logN 那么高的层数(N是总个数, 是最终的数据量).

不能说我加入的第一个数的时候,我承担了一个logN的代价,因为它没有到那么高那的高度它一开始完全二叉树就只有自己,没有到logN一个高度,一共加入了 N 个数,我们只说最大的高度是logN,你就说加入 N 个数的过程中是 N*logN, 这件事,似乎它有点够呛。

如果面对这种数量波动的时候,应该怎么去算它的时间角度?

可以用数据量增加常数法。

数据量增加常数法

我既然是时间复杂度是忽略了常数项的, 那么当你的数据量是 N 的时候,它的时间复杂度N*logN。

当我数据量只扩了一倍变成 2N 的时候,时间复杂度应该还是N*logN,因为时间复杂度他是忽略了常数项的。

所以,我加入 N 个数的时候和我加入 2N 个数,变成一个大的 2N 的堆的时候,它的时间复杂度应该是不变的当数量为 N 的时候,假设时间复杂度不会超过 N*logN。就如我们所说,你每一个数都承担 logN 的代价,它才是 O(N*logN)。所以我们知道O(N*logN)是我们上限, 这明明是动态的log1+log2+log3,最后logN, 这个数很明显比N*logN小的,所以我们说 N 个数搞成大根堆,它的上限是O(N*logN),下面我们只要在证明它的下限也是O(N*logN)就可以了.

它的下限怎么确定? 2 N 的时候, 可以分为把前 N 个数加到堆上去,和后N的数加到同样的堆上去.把前 N 个数加到堆上去, 高度已经是logN, 把后 N 个数加到堆上上去,每一个数乘的代价,其实下回就是 logN+1再下回是logN+2,所以这后 N 个数要想加到同样的堆上去, 他承担代价不会比 O(N*logN)要低,因为N个数加到小根堆上去,它高度至少这么多logN,之后 N 个数加上去每一个高度都不比它小对。这就是下限. 前N个数加到堆里去的复杂度不知道, 认为是O(N?), 前N个数加到堆里去, 已经形成logN的高度, 后N个数继续向同一个堆里加数, 高度来到log2N,后面N个数, 按最好情况的复杂度估计为O(N*logN), 事实上不可能低于它.

我在数据量 N 的时候先假设出一个上限, 数据量增了一个常数倍, 我发现我的下限也是它, 那我的复杂度不就是它O(N*logN)

现在看一个例题

已知一个几乎有序的数组。几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离一定不超过k,并且k相对于数组长度来说是比较小的。

请选择一个合适的排序策略,对这个数组进行排序。

题意

无序数组, 每个数真排完序后, 每个数移动距离不超过k

题解

例子: 假如K是5, 看原始数组中哪些位置移动距离是5  原始数组中0~5的原始数字, 其中最小值一定是0位置,  只有下标0~5的数能去0位置, 5再往后都不可能来到0位置

k是多少, 就把前k+1个数先放入小根堆里每次弹出最小值, 然后放入一个新数

谁会成为0位置的数?  只有原数组0~5位置的数, 6位置以后违反设定了

1位置: 0~6的数都有可能性, 但是要放弃0这种可能性, 因为上一步已经把0位置的数搞定了

public static void sort(int[] arr,int k){
    if(k == 0){
        return;    
    }
    PriorityQueue<Integer> heap = new PriorityQueue<>();
    int index = 0;
    for(;index <= Math.min(arr.length - 1,k - 1);index++){
        //k-1之前的数加入堆中
        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();    
    }
}

上一篇:WC2022 讲课 钱易 杂题选讲


下一篇:线程安全的List