优先队列(priority queue),也是一种重要的缓存结构。从原理上说,这种线性结构与二叉树没有直接关系。但是基于对一类二叉树的认识,可以做出优先队列的一种高效实现。
- 注意,队列和优先队列的选择,要视具体的应用场景而定,两者也有可能共存于一个系统之中,比如海关(Customs)检查站模拟系统:
到达车辆可能排队 ⇒ 队列
事件(不同的事件) ⇒ 优先队列 - 无论是队列、栈还是优先队列,都是从指定的方向进行进入集合和弹出集合的(也即规则是十分明确的),
- 队列:尾部入,头部出;
- 栈:头部入,头部出;
- 优先队列:根元素弹出,新来的元素被调整在合适的位置;
1. 基本概念
作为缓存结构,优先队列与栈和队列类似:
- 可以将数据元素保存其中;
- 可以访问和弹出;
优先队列的特点是存入其中的每项数据都另外附有一个数值,表示这个项的优先程度,称为其优先级。
抽象地看,需要缓存的是一个有序集
这时,就说
- 如果要求保证优先级相同的元素先进先出(希望优先队列同时具有队列的 FIFO 性质),那就只能做出效率较低的实现;
- 如果只要求保证访问(或弹出)的总是当时存在的最优元素中的一个,不要求一定是其中最早进入优先队列的元素,那么就存在效率更高的实现;
2. 基于连续表/链表的实现
class PriQue:
def __init__(self, elist=[]):
self._elems = list(elist)
self._elems.sort(reverse=True)
def enqueue(self, e):
n = len(self._elems) - 1
while n >=0:
if e >= self._elems[n]:
n -= 1
else: break
self._elems.insert(n+1, e)
def dequeue(self):
if not self._elems:
raise ...
self._elems.pop()
总而言之,采用线性表技术实现优先队列,无论采用怎样具体实现技术:
- 连续表
- 链表
在插入元素和取出元素的操作中,总有一种是具有线性复杂度(
3. 树形结构和堆的性质
前文书说过,只要元素根据优先级顺序线性排列,就无法避免线性复杂性问题。这意味着,如果不改变数据的线性顺序存储方式,就无法突破
采用树形结构实现优先队列的一种有效技术成为堆。从结构上看,堆就是结点里存储数据的完全二叉树,但堆中数据的存储要满足一种特殊的堆序:
- 大顶堆
- 小顶堆
保证堆中最优先的元素必定位于二叉树的根节点(堆顶),
在树的性质中,我们知道:一棵完全二叉树可以自然而且信息完全地存入一个连续的线性结构(例如连续表),堆是完全二叉树,因此堆也可以自然地存入一个连续表,以便通过下标即可访问任一节点的父节点、子节点。
4. 堆和完全二叉树的性质
- Q1:在一个堆的最后加上一个元素(在相应连续表的最后增加一个元素),整个结构还是可以看做一棵完全二叉树,但它未必是堆(最后的元素未必满足堆序);
- Q2:一个堆去掉堆顶(对应线性表位置 0 的元素),其余元素形成两个“子堆”;
- Q3:给由 Q2 得到的表(两个子堆)加入一个根元素(存入位置 0),得到的结点序列又可看做完全二叉树,但未必满足堆序;
- Q4:去掉一个堆中的最后一个元素(最下层的最右节点,也是线性表的最后一个元素),剩下的元素仍构成一个堆;
5. 优先队列的堆实现
解决插入和删除的关键操作称为筛选:
插入元素 ⇒ 向上筛选
不断用新加入的元素 e,与其父节点的数据比较,如果 e 较小,就交换两个元素的位置。通过这样的比较和交换,元素 e 不断上移。这一操作一直做到 e 的父节点的数据
≤ e,或者 e 到达根节点时停止。弹出元素 ⇒ 向下筛选
由于堆顶元素就是最优先元素,应该弹出的元素就是它。但弹出堆顶元素后,剩下的元素已经不再是堆:
- 根据性质 Q2,剩余元素可看做两个子堆;
- 又根据 Q3,只需填补一个堆顶元素就可以将它们做成一个完全二叉树,
- 再根据 Q4,从原堆的最后取出一个元素,其余元素仍然是堆,把这个元素放在堆顶就得到一棵完全二叉树。
在这种情况下,恢复堆的操作称为向下筛序:设两个子堆 A 和 B 加上根元素 e 构成一棵完全二叉树,现在需要把他们做成一个堆:
- 用 e 与 A、B 两个子堆的堆顶元素(子树的根),最小者为整个堆的顶;
- 若 e 不是最小,最小的必为 A 或 B 的根,设 A 的根最小,将其移到堆顶,相当于删除了 A 的顶元素;
- 下面考虑把 e 放入去掉堆顶的 A,这是规模更小同一问题;
- 如果某次比较 e 最小,以它为顶的局部树,已经成为堆,整个结构也为堆;
- 或者 e 已经落到底,整个结构成为堆;