先看一个再熟悉不过的算法题:
有一个长度为n的无序数组,元素为整形int值,如何找到前K(K <= n)个最小(或者最大)的元素。
相信任何一个从事IT编程的人都听过这个有趣的问题,俗称 Top K 问题。。。。
我们现在来分析解决这个问题(以得到前K个最小值为例):
(注意,本文是以该问题一点一点引出堆思想,并没有深入优化topk问题的最优解,本文的重点是堆)
1.排序
首先,最容易想到的方法就是对无序数组进行排序,然后很容易得到前K个最小值。这样问题被转化成了数组排序,那么自然而然解决该问题的时间复杂度,就取决于排序算法的时间复杂度了。其实这种方法是一种站在全局整体的角度,一次性获得这K个值。那么自然而然,比较理想的时间复杂度也就是 O(n*log(n))
2.元素冒泡思想
还有一种非常容易想到的方法,就是站在独立的角度,逐个获得这K个值。仍旧以前K个最小值为例,我们遍历一遍数组,得到当前数组中的最小值,然后在原数组中排除掉这个最小值,然后对剩下的数组元素循环这个方法,直到进行K次即可。那么这种思路也比较直观,其时间复杂度为 O(n*k);
3.探究问题本质去优化
(这里说句题外话,作者大学时候,很早就见过这个题目,当时知识有限,还根本不知道有堆这个思想,但是就在这种情况下,根据逻辑一点一点的竟然自己想到了堆(哪怕当时自己都不知道这是堆)的思路,所以说,现在网上搜索top K问题。很多答案直接就把堆思想拿出来,,,其实这并不是我们想学的东西,我们每个人学习的时候,都应该去学习这种思想是如何被找到的,,,我们要的应该是一种逻辑思考的不断迭代以求最优的过程,而不是上来就得有的答案。。。这样,终有一天,我们能够在某个问题或者领域,成为第一人,否则,自己永远会成为这个社会上某个人的copy者,永远没有不可替代性!)
下面我们顺着逻辑思路一点一点去找到堆是如何产生的。
- ①现在数组无序,我们要找到其中的某些值,毫无疑问,最起码一次遍历是肯定要有的,所以这个问题的最理想的时间复杂度也不会低于O(n)。如果真的有人有比O(n)还少的时间复杂度,那请务必留言评论,在下一定深入学习。所以说,我们如何在一次遍历的过程中,就能够得到这K个最小值,就成了问题的核心。
- ②我们顺着这个思路往下想,我们肯定会遍历这个无序数组的前k个值,那么从第 k+1 个值开始,我们要做的工作就来了,我们怎么确定这第 k+1 个值和前面这k个值的大小关系。。。。
- ③上面分析,我们知道,我们遍历数组的前k个值的时候,这k个值中一定有一个最大值max,和一个最小值min,,,而这个区间【min,max】就是无序数组前k个值的元素范围。
- ④我们的问题是找到前k个最小值,所以②中的解决方案就出来了,从第 k+1 个值开始,去比较这个区间范围,从而判断该值是否就是我们的最终结果中的某一个。如果之后遍历(从k+1到最后)的值 比 max大,那就直接忽略,因为无论如何,它已经不可能是我们要求的结果中的元素了。因为已经有 k 个元素比它小了。
- ⑤问题又来了,,,如果之后的值(以下将该值称为index)小于这个max呢???那很显然,这个max就一定不是最终要求的结果中的元素,我们需要更新这个区间了,实际上我们不需要关注min,只关注max。
- ⑥现在到了最核心的时候了,在5中,我们把max去掉,然后把index添加到区间中,,但是,怎么找到这个区间中新的最大值???????????(哈哈,此时的你是不是豁然开朗,没错,堆思想就是这样出现的)
问题转换:如何去更新维护这个max值????
问题出发点是,每一次index < max的时候,将max从区间去除,然后将index添加到区间,并重新计算max值。
1.排序
最容易想到的方法,排序,这样就能得到最大值了。那么放到问题本身,也就是我们始终需要对一个长度为k的数组进行排序。考虑到比较理想的时间复杂度,,,那也是O(k * log(k)),那么总的时间复杂度就是O(n*k*log(k))。显然这种思路,不但没有优化,还增加了逻辑复杂度和时间复杂度。。。
2.冒泡思想
我们考虑1为什么会有这么高的时间复杂度,是因为,我们每次去更新最大值,都要对这k个值全部排序。。。其实是毫无必要的,我们仅仅想要一个最大值而已,其实就用冒泡的思想遍历一遍就能找到最大值,时间复杂度为O(k),显然,总的时间复杂度成为了O(n*k)。注意啊,虽然这种思路的时间复杂度和上面最开始直接使用冒泡思路的时间复杂度一致,,,但是这是两种不同的逻辑,是不一样的。
3.继续优化
2中的冒泡思想的问题出发点是,每一次更新max的时候,都需要独立的进行一次冒泡,来求出本次k个值的最大值。那么有没有一种思路是在某次求解最大值的时候,也能够指引一个第二大的值,以便下次更快的得到最大值。如果我们能维护一种这样的关系,那么问题就迎刃而解了。
此时的你可能会问,既然都已经想到这里了,那我就在这k个值所形成的区间中维护俩值,一个最大值max,一个第二大的值max2,那么当index<max的时候,将max扔掉,然后比较index和max2,将两者中的最大值设为max,这问题不就解决了吗???其实这样想一点问题没有,,,作者也这样想过,但是,我们维护更新了max,那max2怎么更新呢?换句话说,我们下次使用的max2此时应该是多少呢???很显然,应该是index和max3中的较大者,这时候,又需要max3(第三大的值了)。
思考上面的问题,要求我们再每次得到最大值的时候,能够通过某种结构,让第二大值与其关联,从而当max被去除的时候,能够直接维护新的max。
主角登场,堆思想就是这样产生的。
首先,堆是一种数据结构,逻辑上是一棵完全二叉树,其内部有这样的联系:
在这个树逻辑中,任何节点的值总是不大于其父节点的值。。。
先抛开堆的深层研究,我们就拿这个逻辑,回到我们的问题中去,假设我们已经实现了这样一种树逻辑,那么根节点一定是max,当index<max的时候,我们很容易达到新的newMax。如下图所示,根节点max的两个儿子,leftSon和rightSon中的那个较大值,就是我们上述逻辑中的max2,而我们将这个max2和index比较就得到了新的max值,也就是newMax。
假设 leftSon > rightSon 那leftSon就是第二大值max2
这个图是,leftSon > index之后的结果,我们还需要对红框内的关系进行维护
我们继续这个逻辑,当index > max2的时候,直接把index置为max,其余结构不需要任何改变,就维护了新的max值。
那么当 index < max2的时候呢????很明显是吧max2置为max,,,那index放到哪里呢????这就需要我们维护这样一个树形结构,保证index最终落到合适的位置。。。思路很简单,我们只需要维护这个结构的唯一逻辑,保证任何子节点不大于父节点的值。我们假设刚才是 leftSon > rightSon.。很明显此时leftSon已经成为了max,我们就把index放到leftSon位置上,,,然后将这个index与其子节点进行比较就可以了,并且只需要和两个子节点中较大的进行比较,我们用maxSon来表示,当index > maxSon的时候,证明,index大于这两个儿子的值,所以当前位置就是index的位置,但是如果index < maxSon的时候,就交换maxSon和index的位置,然后按照这个逻辑迭代的让index与新位置的儿子进行比较即可。。。。
到此我们可以看到,这种思路就是确定维护了一个max和一个第二大的值max2(max的左右儿子中的较大值一定是max2)。厉害的是,这里面所维护的逻辑关系,能够让我们很轻松的去维护更新这个max和max2。
我们通过一个简单的例子很容易搞懂了堆思想其想解决的问题,也就是什么情况下需要我们用到这种思想,以及这种思想的内部逻辑关系。。。回到top k问题,其实此时的时间复杂度变为了O(n * log(k))。多说一句,这里只是用top k问题引出堆思想,其实top k问题还有其他很厉害的解决方法,感兴趣的自行研究。
在理解了堆思想后,我们进行实战,先看代码,后续解读:
此处实现一个简单的最大优先级队列,使用堆思想。。。
1.这是一个最上层接口,优先级队列的接口,其内部成员泛型化,但是由于,其中涉及到比较大小的逻辑,所以泛型类需要实现Comparable接口,实现比较方法。
package com.MyHeapTest;
/**
* 优先级队列的接口
* 分为最大优先级队列和最小优先级队列
*
* 其内部思路是以堆来实现的
*
* @ author: liu xuanjie
* @ date: Created on 2020/8/4
*/
public interface PriorityQueue<T extends Comparable>
{
/**
* 判断队列是否为空
* @return true标识为空
*/
public boolean isEmpty();
/**
* 返回队列内的元素数量
* @return
*/
public int size();
/**
* 向优先级队列中插入元素
* @param t 插入实例的泛型
*/
public void put(T t);
}
2.我们实现一个最大优先级队列
这里简单说一下,由于堆思想是一种完全二叉树,其节点间有紧密的逻辑联系,所以可以很轻松的通过一个数组来维护这种逻辑,此处选择的是ArrayList
package com.MyHeapTest;
import java.util.ArrayList;
/**
* 实现最大优先级队列
*
* ps:因为优先级队列无论大堆还是小堆,都要涉及元素之间的比较
* 所以要求,泛型类实现Comparable接口,实现大小比较的逻辑
*
* @ Author: liu xuanjie
* @ Date: 2020/8/4
*/
public class MaxPriorityQueue<T extends Comparable> implements PriorityQueue<T>
{
/**
* 用动态数组来保存堆
* (堆是一个完全二叉树,有严格的位置关系,用数组保存最方便)
*/
private ArrayList<T> heapTree = null;
/**
* 构造方法初始化
*/
public MaxPriorityQueue()
{
this.heapTree = new ArrayList<T>();
}
/**
* 接口方法,判断队列是否为空
* @return true标识为空
*/
@Override
public boolean isEmpty()
{
if(null == heapTree || heapTree.isEmpty())
{
return true;
}
return false;
}
/**
* 接口方法,返回队列内的元素数量
* (该数量指的是已有的元素数量,而不是容量)
* @return 0标识没有元素
*/
@Override
public int size()
{
if(null == heapTree)
{
return 0;
}
return heapTree.size();
}
/**
* 接口方法,向最大优先级队列中插入元素
* (所以这是一个最大堆)
*
* 实现方法:
* 1.将新加入元素放到整个链表的队尾,记录位置下标
* 2.根据完全二叉树的位置逻辑关系,得到其父节点的位置下标
* 3.判断其值与父节点的比较,如果大于父节点,交换
* 4.更新新节点的位置下标,循环2-4
* 5.循环结束条件,新节点值小于父节点或者,新节点已经到树的根节点
*
* 这里有两个细节的点,虽然很简单,但逻辑还是需要严谨
* 1.就是新加元素只需要跟父节点比较
* 因为在最大堆中,父节点的值一定大于其两个子节点的值
* 所以新加元素的值只要大于父节点,就一定大于另一个子节点
* 2.当新节点a大于父节点b之后,进行交换,
* 而交换完成后,原来的父节点b到新位置后,其一定也是大于他的子树中任意元素的
* 因为在堆中任意子树也是一个堆
*
* @param t 插入实例的泛型
*/
@Override
public void put(T t)
{
if(null == heapTree)
{
heapTree = new ArrayList<T>();
}
//把需要添加的元素先添加到队尾
heapTree.add(t);
//如果添加完新元素之后只有一个元素,直接返回
int lengthOfQueue = heapTree.size();
if(lengthOfQueue == 1)
{
return;
}
//记录新添加节点的位置索引
int newObjectIndex = lengthOfQueue - 1;
//第一个循环结束条件,新节点的位置是否等于0,也就是到达根节点
while (newObjectIndex > 0)
{
//得到其父节点的位置索引(堆是完全二叉树,位置索引有清晰的逻辑关系)
int parentIndex = (newObjectIndex - 1) / 2;
T newObject = heapTree.get(newObjectIndex);
T parentObject = heapTree.get(parentIndex);
//更新堆的交换逻辑
if(newObject.compareTo(parentObject) > 0)
{
T temp = parentObject;
heapTree.set(parentIndex, newObject);
heapTree.set(newObjectIndex, temp);
newObjectIndex = parentIndex;
}
else
{
//循环结束的第二个条件,新节点已经不大于父节点的值了
return;
}
}
}
/**
* 删除最大优先级队列中的最大元素
* 需要注意的删除较为简单,但是删除后需要重新维护更新堆
*
* 实现方法:
* 1.删除根节点元素,然后把队尾元素(称为A)放到根节点,之后的操作就是针对A来进行
* 2.判断A的左右儿子的大小,让A与左右儿子中较大的(称为B)比较
* 3.A大于B,直接结束。。。A小于B,交换AB的位置
* 4.重复2-4.另一个结束条件就是,A已经没有左右儿子了
*
*/
public void removeMaxValue()
{
if(null == heapTree || heapTree.isEmpty())
{
return;
}
int length = heapTree.size();
if(length == 1)
{
heapTree.remove(0);
return;
}
//尾节点放到根节点,删除尾节点
heapTree.set(0, heapTree.get(length - 1));
heapTree.remove(length - 1);
length --;
int maxIndexOfQueue = length - 1;
int nowIndex = 0;
while(nowIndex <= maxIndexOfQueue)
{
//得到左右儿子的索引
int rightSonIndex = (nowIndex + 1) * 2;
int leftSonIndex = rightSonIndex - 1;
//是否有右儿子
if(rightSonIndex > maxIndexOfQueue)
{
if(leftSonIndex > maxIndexOfQueue)
{
//没有左右儿子
return;
}
else
{
//没有右儿子,有左儿子
this.checkAndEnchange(nowIndex, leftSonIndex);
//这已经到头了,不用继续维护了
return;
}
}
else
{
//有右儿子,那自然就有左儿子
T leftSonObject = this.getObjectOfIndex(leftSonIndex);
T rightSonObject = this.getObjectOfIndex(rightSonIndex);
if(leftSonObject.compareTo(rightSonObject) > 0)
{
this.checkAndEnchange(nowIndex, leftSonIndex);
nowIndex = leftSonIndex;
}
else
{
this.checkAndEnchange(nowIndex, rightSonIndex);
nowIndex = rightSonIndex;
}
}
}
}
/**
* 封装removeMaxValue方法中的重复操作,较少代码重复。
* 判断当前节点与某个子节点的大小
* 如果子节点大,交换两个节点的位置
* @param nowIndex
* @param sonIndex
*/
private void checkAndEnchange(int nowIndex, int sonIndex)
{
T nowObject = heapTree.get(nowIndex);
T sonObject = heapTree.get(sonIndex);
if(sonObject.compareTo(nowObject) > 0)
{
//交换
T temp = nowObject;
heapTree.set(nowIndex, sonObject);
heapTree.set(sonIndex, temp);
}
}
/**
* 得到当前最大优先级队列中的最大元素
* @return 返回值可能为null,外部调用需要判空
*/
public T getMaxValue()
{
if (null == heapTree || heapTree.isEmpty())
{
return null;
}
return heapTree.get(0);
}
/**
* 返回内部链表中指定位置的对象
* @return T 返回结果可能为null,外部调用需要判空
*/
public T getObjectOfIndex(int index)
{
if(null == heapTree || heapTree.isEmpty())
{
return null;
}
if(index < 0 || index >= heapTree.size())
{
return null;
}
return heapTree.get(index);
}
}
3.测试所用的泛型类,很简单,只有一个int类型成员变量
package com.MyHeapTest;
/**
* 测试优先级队列所用的对象
*
* @ Author: liu xuanjie
* @ Date: 2020/8/4
*/
public class TestObject implements Comparable<TestObject>
{
/**
* 唯一保存值的整形成员变量
*/
private int value = 0;
/**
* 构造方法
* @param value
*/
public TestObject(int value)
{
this.value = value;
}
/**
* 接口方法实现比较的逻辑。
* 注意返回值不是boolean,而是一个代表结果的整形值
* @param temp
* @return
*/
public int compareTo(TestObject temp)
{
if(this.value > temp.value)
{
return 1;
}
else if(this.value == temp.value)
{
return 0;
}
else
{
return -1;
}
}
/**
* 私有成员变量的get和set方法
* @return
*/
public int getValue()
{
return value;
}
public void setValue(int value)
{
this.value = value;
}
}
4.来一个测试类,测试一下我们的优先级队列
package com.MyHeapTest;
/**
* 关于优先级队列的测试类
*
* @ Author: liu xuanjie
* @ Date: 2020/8/4
*/
public class ClientTest
{
/**
* 主函数,程序入口
*/
public static void main(String[] args)
{
MaxPriorityQueue<TestObject> maxQueue = new MaxPriorityQueue<TestObject>();
System.out.println(maxQueue.isEmpty());
System.out.println("优先级队列开始时的元素个数:" + maxQueue.size());
maxQueue.put(new TestObject(8));
maxQueue.put(new TestObject(2));
maxQueue.put(new TestObject(10));
maxQueue.put(new TestObject(14));
maxQueue.put(new TestObject(20));
maxQueue.put(new TestObject(15));
maxQueue.removeMaxValue();
int length = maxQueue.size();
for(int i = 0; i < length; i++)
{
TestObject temp = maxQueue.getObjectOfIndex(i);
if (null == temp)
{
continue;
}
System.out.println(temp.getValue());
}
}
}
下面,我们根据这个测试类中的操作,来剖析堆结构进行维护操作的过程。
(代码中的方法对应的注释已经详细的介绍了逻辑,建议配合下面图解进行理解)
如图所示,代码中添加顺序是8.2.10.14.20.15。。。添加8和2比较简单,这里不多赘述,我们从添加10开始,先把10放到队尾,然后与父节点进行比较,按照大小逻辑,判断是否需要交换。
图中虚线表示未判断大小的状态,实线表示最终位置。之后添加元素的逻辑一致。
下图为,这几个元素全部添加完成后最终的树形结构。
仔细观察,不难发现,这里面的几点规律:
-
1.根节点是最大值。
-
2.根节点的左右子节点中较大的那一个是第二大值。
-
3.任何子树又是一个独立的堆逻辑树。
而代码中后续的删除最大节点之后的维护更新,自行研究,也便于检验自己是否真的弄懂了这个简单的堆结构。。。作者不建议你直接网络查询答案,那样毫无意义。。。自己思考,自己编码,自己验证,你怎么保证你网上查的都是正确的???
当完成了这些后,可以运行一下代码,检查代码运行结果和自己的想法是否一致。本文为原创文章,难免出现纰漏或者错误,欢迎指正。。。。