一、优先队列的概念及实现
堆是一种有序的树形结构,说明堆之前需要先说明一下“优先队列”这个重要的缓存结构。
"优先队列"和栈,队列类型,都是保存,访问元素的,但其特点是存入其中的每项数据都有一个"优先程度"的附加数值,即”优先级“。任何时候访问的元素都是结构中优先级最高的。抽象的看,优先队列描述的是一个有序集S=(D,<=),<=表示集合D上的一个全序,表示元素的优先关系。
一种简单实现优先队列的方法,是使用线性表技术。从使用的角度看,用户只关心优先队列的使用特性,并不很关心数据是否按优先级顺序进行存储。所以不难想到存在两种实现方案:
- 在存入数据时,保证表中元素按优先顺序排列。这种形式在存入元素时比较麻烦,访问时方便。
- 存入数据时不作处理,在访问时通过检索找到最优先的元素。这种无组织的方式存入时方便,但访问时麻烦。一种解决方法是可以通过记录优先元素来检索。
使用list来做优先是一种很简便的方式,list对象能根据存储元素的实际需要自动扩大存储区。但必须在合法的范围内使用下标表达式。所以,在需要新插入元素时,必须先确定正确的插入位置。
下面我们使用list结构来分别实现上面描述的两种方式:
第一种方式:
class PrioQue:
def __init__(self, elist=None);
self._elems = list(elist) if elist else []
self._elems.sort(reverse=True)
def enqueue(self, e):
i = len(self._elems) - 1
while i >= 0:
if self._elems[i] <= e:
i -= 1
else:
break
self._elems.insert(i+1, e)
def is_empty(self):
return not self._elems
def peek(self):
if self.is_empty():
raise ValueError
return self._elems[-1]
def dequeue(self):
if self.is_empty():
raise ValueError
return self._elems.pop()
不难分析出该方式的复杂度:插入元素是O(n)操作,其它都是O(1)。
注意:python的list结构由于采用的是动态顺序表实现,所以在插入元素时发现存储区满,会换一块存储区,但即使换,它的复杂度也是O(n)。
第二种方式:
class PrioQue:
def __init__(self, elist=None);
self._elems = list(elist) if elist else []
self._elems.sort(reverse=True)
def enqueue(self, e):
self._elems.append(e)
def is_empty(self):
return not self._elems
def peek(self):
if self.is_empty():
raise ValueError
min = self._elems[0]
for i in range(1, len(self._elems)):
if self._elems[i] < min:
min = self._elems[i]
return min
def dequeue(self):
if self.is_empty():
raise ValueError
min = self._elems[0]
for i in range(1, len(self._elems)):
if self._elems[i] < min:
min = self._elems[i]
self._elems.pop(min)
return min
同样不难分析一出它的时间复杂度:
插入元素是O(1)(替换表存储需要O(n)时间),获取弹出元素是O(n)。
除了采用线性表实现,还可以使用链接表来实现优先队列,但主要的操作复杂度都与连续表类似。
由于线性排列结构无法避免移动元素或链接爬行步骤,所以实现效率都比较低。
由些我们引出了堆。
二、堆
采用树形结构实现优先队列的一种有效技术称为堆。从结构上看,堆就是结点里存储数据的完全二叉树,但堆中数据的存储要满足一种特殊的堆序,即任何一个结点里所存的数据要先于或等于其子结点里的数据,那么可以看出堆有的几个特性:
- 从树根到任何一个叶结点的路径上,各结点所存的数据按规定的优先关系递减
- 最优先的元素必定位于对的根结点里,即堆顶,O(1)时间就能得到
- 树中不同路径上的元素,不关心其顺序关系
除了上面的特性外,堆和完全二叉树还有几个重要的特性:
Q1.在一个堆的最后加上一个元素,整个结构还是完全二叉树,但由于未必满足堆序,所以未必是堆
Q2.去掉堆顶后,其余元素形成两个"子堆",堆序仍然成立
Q3.给去掉堆顶的两个堆加上一个根元素,得到的序列仍然是完全二叉树,但未必是堆
Q4.去掉一个堆最后的元素,剩下的元素仍是构成一个堆
根据堆中元素的大小顺序,可以将堆分为小顶堆和大顶堆。
小元素优先的叫小顶堆,堆中每个结点的数据都<=其子结点数据
大元素优先的叫大顶堆,堆中每个结点的数据都>=其子结点数据
在实现堆结构的过程中,我们需要考虑是否有一种操作可以在向堆插入新元素(Q1)或新的堆顶(Q2)的时候,仍然可以保持正确的堆序。答案是向下筛选和向上筛选。
向下筛选就是从堆顶向下做一次调整堆序的操作,对应的向上筛选就是由子结点向堆顶做堆序调整的操作
下面我们来看一个两个筛选的具体操作
插入元素和向上筛选:
不断用新加入的元素e与其父结点的数据进行比较,如果e较小就交换两个元素的位置,不断上移直到e大于或等于其父结点的数据或到达根结点为止,由于向上筛选操作中比较和交换的次数不会超过树的最长路径,所以可以在O(log n)时间完成
弹出元素和向下筛选:
由于堆顶元素就是最优先元素,所以弹出的元素是就它,根据Q2,剩下两个“子堆”。根据Q3,Q4取原堆最后一个元素放到堆顶,执行一次向下筛选,恢复堆序。
假设e是新的堆顶元素,A,B是子堆,
- 用e与两个“子堆”的顶元素比较,最小的那个作为新的堆顶;若e不是最小,最小的必然为两个子堆的根;设的根最小,移到堆顶,相当于删去了A的顶元素;把e放入去掉堆顶的A;B的根最小情况同理处理
- 如果某次比较中e最小或e落到底,那么以e为顶的树已成为堆,即恢复了堆序
三、使用"堆"实现"优先队列"
回到优先队列上,使用堆来实现,可以分为三个步骤:
1.弹出堆顶O(1)
2.取堆最后一个元素作为完全二叉树新根O(1)
3.向下筛选就是从堆顶向下做一次调整堆序的操作,对应的向上筛选就是由子结点向堆顶做堆序调整的操作O(log n)
我们知道线性结构可以存入一颗完全二叉树,所以也可以存入一个完整堆。
现在使用一个list来定义一个堆,以表首做为堆顶,在表尾加入元素。
class PrioQueue(object):
def __init__(self, elist=None):
self._elems = list(elist) if elist else []
if elist:
self.buildheap()
def is_empty(self):
return not self._elems
def peek(self):
if self.is_empty():
raise ValueError
return self._elems[0]
def enqueue(self, e):
self._elems.append(None)
self.siftup(e, len(self._elems) - 1)
def siftup(self, e, last):
elems, i, j = self._elems, last, (last - 1) // 2
while i > 0 and e < elems[j]:
elems[i] = elems[j]
i, j = j, (j - 1) // 2
elems[i] = e
def dequeue(self):
if self.is_empty():
raise ValueError
elems = self._elems
e0 = elems[0]
e = elems.pop()
if len(elems) > 0:
self.siftdown(e, 0, len(elems))
return e0
def siftdown(self, e, begin, end):
elems, i, j = self._elems, begin, begin * 2 + 1
while j < end:
if j + 1 < end and elems[j+1] < elems[j]:
j += 1
if e < elems[j]:
break
elems[i] = elems[j]
i, j = j, 2 * j + 1
elems[i] = e
def buildheap(self):
end = len(self._elems)
for i in range(end // 2, -1, -1):
self.siftdown(self._elems[i], i, end)
来分析一下复杂度:
设被处理的完全二叉树有n个元素,高度为h,则子树共计大约n/2^h,调整每一颗这样的子树为一个堆,根元素的移动距离不超过h-1,对操作的移动求和,可知堆构建的复杂性是O(n)。
总结一下基于堆构建的优先队列的复杂度:
创建操作的时间复杂度是O(n),该操作仅需做一次。
插入和弹出操作的复杂度是O(log n)
注意:插入可以导致list对象替换元素存储区,最坏出现O(n),操作中只用到了一个简单变量,所以空间复杂度是O(1)
有关堆排序的整理会在“排序”一文作整理。
本文是学习“堆”数据结构时,做的一个整理笔记,由《数据结构与算法,python语言描述》一书内容整理而出,。