优先队列(自己动手写API系列二)
前言:假设你有n个数据 然后输入一个m 删除其中前m大的值
有人就想到了 遍历嘛 找到一个最大的 一删.
没错 这样可以 但是如果这个n和m都很大呢? 是不是要跑的很慢
有人又想了 那就排个序嘛 这样也可以 其实也不是很好
所以 接下来就有了 这个数据结构--优先队列
这里会给大家先介绍一下优先队列 然后给大家讲一下堆排序
先来看一下优先队列. 其实优先队列的原型有点像二叉树
但是其实我们在底层是用数组实现的 不会二叉树的也别怕
这是一个子树 其中结点的数字代表这个结点的编号
多画两个
让我们接下来看一个细思极恐的事情 先来看 4号结点和5号结点
4号结点除2是几? 4/2 = 2 对吗?
5号结点除2是几? 5/2 = 2.5 但是我们在里面用int型保存 所以 你得到的结果是整除
也就是 5/2 = 2
而 2号结点是什么呢? 是他们的父结点?是不是有点恐怖
巧合? 不存在的 再让我们看 2 和 3号结点
2/2 = 1 3/2 = 1
怎么样?
至于 再多结点
你可以验证一下
好了 当前结点除2就回到了父结点
那怎么从父结点去到子结点呢?
其实就是 乘2 和乘2+1
有人就想了 为什么回到父结点只是/2 去子结点要乘 2 或者 乘2+1呢?
因为当前结点的父结点只有1个 而子结点却有两个啊
我们现在有一个约定
1.父结点比左右结点都大.
2.当一个结点有子结点 有右儿子 必须有左儿子
也就是不容许这种情况
不容许啊 也就是我们必须依次从左向右填充元素
按照我们刚才的约定 其实你应该已经发现了 1号结点就是最大的.
你要是想维持最小的也可以
好了 你先看到这里 让我们看一下 关于自己动手写API系列二 优先队列的方法
public class MaxPQ<Key extends Comparable<Key>> //类的声明 其实你接受过来的对象比如实现了Comparable接口
MaxPQ() //无参构造方法
MaxPQ(int size) // 带大小的构造方法
public boolean isEmpty()
//判断队列是否为空 是返回true
public int size()//返回队列大小
public void insert(Key Date)//插入一个数
public Key delMax() //删除最大值
这是所有的共有方法 因为 队列其实在内部处理了很多 主要是私有方法 让我们先看一下 很多东西都要要讲
private void resize(int max)
//调整数组大小 提高数组利用率 待会讲
private void swap(int i, int j)
//交换下表为 i 和 j的两个值
private boolean cmp(int i, int j)
//比较两个值的大小
private void swim(int k)//上浮操作
private void sink(int k)//下沉操作
其中上述方法 核心是上浮和下沉操作
让我们慢慢来讲.
先来看一下讲的顺序(我觉得这样会比较好理解)
1.两个构造方法
2.isEmpty() 和 size()方法
3.resize()方法 swap()方法 cmp()方法
4.swim() 和 sink() 方法
5.insert() 和 delMax()方法
先给大家看一下 成员变量把
private Key[] elements; // 对象数组
private int N = 0; // 你记录的个数
private int size; // resize的时候会用到 你数组的开辟的空间
1.两个构造方法
MaxPQ() {
this.size = 10;
elements = (Key[]) new Comparable[size];
}
MaxPQ(int size) {
this.size = size;
elements = (Key[]) new Comparable[this.size];
}
就是创建数组 默认的开始大小为10 第二个 有设置大小.
这个应该很好理解 就是给数组开辟空间
2.isEmpty() 和 size()方法
public boolean isEmpty() { return N == 0; }
public int size() { return N; }
然后这也是两个简单的方法
第一个判断队列是不是空 是空的返回true 当N==0代表没有任何元素 他就是0
第二个 返回队列的大小 其实就是N的个数 因为我们用的就是N在记录
3.resize()方法 swap()方法 cmp()方法
a>resize()方法 其实我之前系列一 中写的比较详细 下面贴出来 在这里再讲一下
https://blog.csdn.net/qq_42011541/article/details/80671518
private void resize(int max) {
this.size = max;
Key[] temp = (Key[]) new Comparable[this.size];
for (int i = 1; i <= N; i++) {
temp[i] = elements[i];
}
elements = temp;
}
其实就是对数组扩容 用temp 建造一个 传过来要扩容为max大小的数组
再把之前的复制进来 让elements 的引用 指向temp 这个时候之前的数组就会被当做垃圾
JVM会去回收你的垃圾
通俗点讲解 本来你现在大小只有10 现在你又放进来一个数据 但是你发现你的数组填满了
所以你需要调用这个resize()方法 参数传20就可以 这样就实现了扩容
private void swap(int i, int j) {
Key temp = elements[i];
elements[i] = elements[j];
elements[j] = temp;
}
swap()方法这个简单的可追溯到有两个数a,b用一个c去完成交换a,b
不说了把
private boolean cmp(int i, int j) {
return elements[i].compareTo(elements[j]) < 0;
}
cmp这个方法也很简单 comparaTo()这个方法你觉得实在绕 就自己写.
其实就是v.comparaTo(m) v 比 m 小 返回 小于0的值
相等 返回0 v 比 m大 返回大于0的值
其实就是做判断的 一般和swap() 连起来用
4.swim() 和 sink() 方法
这两个方法 我一开始其实有提到
我们想一想 k/2的那个操作 回到父结点
而swim() 这个方法其实就是上浮的意思.
想一想
如果你有一个新数 肯定被排到了末尾了 而这个数非常大呢?
他应该在的位置是不是不应该在末尾啊
所以 让他上浮到他应有的位置就好了.
其实我每进来一个数 都让他上浮 其实 本来就是遵循我们的约定的
随便画几个给你看一下过程
画图理解一下
注:这下结点里面的数字我就填写数字的大小了!!
然后现在有一个新的结点 数值为17
明显他不应该在这里 他其实 应该最大的 在最上层对吧
所以 第一步 先问一下 我17比父节点大还是小
然后发现我比我父结点都大 那我做父结点把
然后 现在我还得再问 我17比我的父结点10哪个大.
嗯. 我比你大. 那还是我做父结点吧
然后继续问 因为我没有碰到一个比我大我的 我也还没有碰到头
假设 如果我不是17 是 9的话 是不是和8换一次就停止了?
但是 现在我大 我还得换 发现又比13大了
最后就是这样的了 你发现换完 其实 结点还都是符合我们的约定的
父结点都比子结点大
其实 每一个进来都上浮 不会破坏这个队列的结构
所以 swim() 代码 你可以理解了嘛? 这里 你可以大概先写一下 不要着急往后看
源码
private void swim(int k) {
while (k > 1 && cmp(k/2, k)) {
swap(k/2,k);
k = k/2;
}
}
看看这个k是不是已经是最大的父结点了 所以 k>1 这个很重要
而且 k>1 &&cmp(k/2, k)的顺序不能颠倒
你可以试试为什么
可能会报一个空指针异常 原理是什么 动一下小脑子就明白了
cmp()判断一下k/2(父结点) 是不是比 k(子结点)小了
成立我就交换 然后 k = k/2 继续搜索
sink() 可能会比swim()稍微难一下 不过也并没有那么难
我们怎么删除最大的 你看看我这样做合理不?
1.我第一个结点也就是最大的了.我让第一个结点直接等于null
然后判断一下 左儿子大还是右儿子 让大的当父结点
嗯...貌似有点道理. 但是这是错的!!! 我们这里是数组啊.. 并不是链表
有人可能说了 你可以全部往前挪一位嘛. 那这样是不是还要跑一个线性(也就是遍历一遍)
这样就破坏了我们 高效的初衷
最好的操作应该是.
第一步 : 用一个临时变量保存最大的.
第二步 : 交换最大的和最后一位.
第三步 : 让最后一位变成null 因为第二步的时候 最大的已经到最后一位了
我们现在这个第三步就相当于删除了
第四步 : 让现在的第一位也就是后换上去的 那个 进行 下沉操作 下沉到他应该有的地方
看一下图解
还是这个样子 我现在要删除17 你看怎么办?
第一步 先找个临时变量 保存 最大的
然后 最后一个和第一个交换位置
外面多出来的那个就临时的
然后 最后一位和第一位交换位置
然后 让 最后一位 为空
然后就是让 8 的那个节点 下沉了
我现在要问的就是 我 8 和我的左儿子(13) 右儿子(12) 有没有比我大的 有的话 我肯定得换一个最大的
才能完美的保证那个约定
所以 我第一步判断一下我的左儿子大还是我的右儿子大.
左儿子大 就拿左二子和父结点比较 否则拿右儿子 和 父结点比较
我发现 我左儿子最大 且比父结点大 所以换位置
还得继续判断啊 发现左二子 10大 而且也比8大 所以交换
看一下我还有儿子 发现 我儿子都没我大 返回 这是不是又归位了? 看一下 8个数我只比较了3次
完美的全是了 log2N的时间复杂度
想一想看看自己能不能写出来 我要扔源码了
private void sink(int k) {
while ( k*2 <= N ) {//判断还有没有左儿子 因为你有儿子肯定有左儿子 不一定有右儿子
int temp = k * 2; //temp 指向左儿子的结点
if (temp < N && cmp(temp,temp+1)) temp++; // 判断一下是不是只有左儿子 并且哪个儿子大
if (cmp(temp, k)) break;//判断一下最大的儿子和父结点大还是小
swap(temp, k);
k = temp;
}
}
还是一样的
if (temp < N && cmp(temp,temp+1)) temp++;
这一行的判断顺序很重要
然后temp就是你最大的儿子的那个结点编号了
问一下父结点和最大儿子哪个大 儿子大 交换 否则返回
好了 sink() 讲完了
其实我的insert() 和 delMax() 也讲完了
哈哈
public void insert(Key Date) {
if (N + 1 == size) resize(size << 1);
elements[++N] = Date;
swim(N);
}
public Key delMax() {
Key temp = elements[1];
swap(1, N);
elements[N--] = null;
sink(1);
if(N < size/4) resize(size >> 1);
return temp;
}
看一下 是不是我们之前讲的步骤?
其实 你在这里多理解一下sink和 swim的真谛 堆排序真的就很简单了
public static void sort(Comparable[] arr) {
int n = arr.length;
for (int k = n/2; k >= 1; k--) {
sink(arr, k, n);
}
while (n > 1) {
swap(arr,1,n--);
sink(a,1,n);
}
}
这是堆排代码 我之前sink 你改一下 参数就好 加了个数组对象也就是之前的elements
n代表个数.
其实 第一个for循环只是在维护一个无序数组变成一个有序的堆.也就是我们队列那样的约束
然后 后面遍历每一个去排序
这里可以自己试试