文章目录
前言
一、堆 (简单)
【注】(并不难,不涉及递归、了解了堆化的思路就能写,可以说很简单—关键在于思路,代码没难度)
1 堆的基本概念
堆(heap)是一种满足特定条件的完全二叉树,主要可分为两种类型,如图所示。
- 小顶堆(min heap):任意节点的值<=其子节点的值。
- 大顶堆(max heap):任意节点的值>=其子节点的值。
由于堆是一种满足特定条件的完全二叉树,那么完全二叉树的性质堆也是满足的,因此堆有以下特性:
- 最底层节点靠左填充,其他层的节点都被填满。
- 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
- 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。
2 堆的常用操作(Python内置heapq最小堆模块)
需要指出的是,许多编程语言提供的是优先队列(priority queue),这是一种抽象的数据结构,定义为具有优先级排序的队列。
实际上,堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。因此,本书对两者不做特别区分,统一称作“堆”。
另外Python 内置的 heapq 模块实现的是小顶堆。
(1)常用操作
堆的常见操作如下表,方法名要根据编程语言来确定
方法名 | 描述 | 时间复杂度 |
---|---|---|
push() | 元素入堆 | O ( l o g n ) O(log n) O(logn) |
pop() | 堆顶元素出堆 | O ( l o g n ) O(log n) O(logn) |
peek() | 访问堆顶元素 | O ( 1 ) O(1) O(1) |
size() | 获取堆的元素数量 | O ( 1 ) O(1) O(1) |
isEmpty() | 判断堆是否为空 | O ( 1 ) O(1) O(1) |
【注】:无论是元素入堆还是弹出堆顶元素,剩下的元素都会重新堆化,这样就能一直保持堆的性质
(2)Python内置heapq最小堆模块
heapq模块常见操作入下表
方法名 | 描述 | 具体说明 |
---|---|---|
heapq.heapify(heap:List) | 堆化 | 将列表堆化,原地操作;需要注意的是堆化后的数据类型还是list,list本来的性质哪些还是可以使用的 |
heapq.heappush(heap,item) | 元素入堆 | 将元素item插入堆heap中;入堆后所有元素重新排列,保持最小堆的性质 |
heapq.heappop(heap) | 堆顶元素出堆 | 弹出堆顶元素,剩下元素重新排列保持最小堆性质 |
heap[0] | 访问堆顶元素 | 只访问,不弹出(其实这是保持了list的性质) |
len(heap) | 获取堆的元素数量 | 同样的直接使用Python内置的len函数即可 |
min_heap = [2,1,5,6,89,5,0,0,4,7,9,0,3,4,3]
# 将列表进行堆化
heapq.heapify(min_heap)
print(type(min_heap)) # <class 'list'> 可以看到列表堆化后还是list类型
print(min_heap) # [0, 0, 0, 1, 7, 2, 3, 6, 4, 89, 9, 5, 3, 4, 5] 堆化只是保证了最小堆性质(根节点一定最小,其余同济左右不要求),
# 所以不保证堆化后直接打印小到大的,只是保证了根节点最小,必须要不断弹出根节点才能得到小到大的
print(min_heap[0]) # 0 访问堆顶元素
length = len(min_heap) # 15
heapq.heappush(min_heap,-88) # -88 入堆
for _ in range(length):
cur = heapq.heappop(min_heap)
print(cur,end=' ') # -88 0 0 0 1 2 3 3 4 4 5 5 6 7 9 89 可以看到是 小到大的,每次弹出后剩下的元素会重新排列保存最小堆性质不变
另外,只要是封装了__lt__方法的自定义类型都是可以放进列表里面进行堆化操作的
###### 堆里面节点是任何东西,链表都行,不过必须要求能够比较大小(必须实现__lt__方法)
class ListNode:
def __init__(self,val = 0, next = None):
self.val: int = val
self.next: Union[ListNode,None] = next
def __lt__(self, other):
# 堆里面节点(元素)必须要能够比较大小才行
return self.val < other.val
node1 = ListNode(3)
node2 = ListNode(1)
node3 = ListNode(4)
node4 = ListNode(2)
min_node_heap = [node1,node2,node3,node4]
heapq.heapify(min_node_heap)
print(min_node_heap) # [<__main__.ListNode object at 0x000001F3D3D3D4C0>, <__main__.ListNode object at 0x000001F3D3D3D490>,
# <__main__.ListNode object at 0x000001F3D3D3D460>, <__main__.ListNode object at 0x000001F3D3D3D430>]
len = len(min_node_heap)
for node in min_node_heap:
print(node.val,end=' ') # 1 2 4 3 可以看到直接遍历堆化后的顺序,不是小到大的,只是保证了根节点最小,必须要不断弹出根节点才能得到小到大的
for _ in range(len):
cur = heapq.heappop(min_node_heap)
print(cur.val,end=' ') # 1 2 3 4 可以看到是 小到大的,每次弹出后剩下的元素会重新排列保存最小堆性质不变
3 堆的实现
上面已经介绍了堆的常用方法和Python内置的最小堆模块,为了更加深入的理解堆这种数据结构,我们对最大堆来进行一个粗略的实现。
(1)堆的存储与表示
“二叉树”章节讲过,完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,因此我们将采用数组来存储堆。
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。节点指针通过索引映射公式来实现。
如图所示,给定索引 i i i,其左子节点的索引为 2 i + 1 2i+1 2i+1 ,右子节点的索引为 2 i + 2 2i+2 2i+2 ,父节点的索引为 ( i − 1 ) / 2 (i-1)/2 (i−1)/2(向下整除)。当索引越界时,表示空节点或节点不存在(这个越界问题会在具体的代码中进行处理,不用担心)
【注】:在二叉树那一节专门讲过完全二叉树的数组表示有一点特别
值得说明的是,完全二叉树非常适合使用数组来表示。回顾完全二叉树的定义,None 只出现在最底层且靠右的位置,因此所有 None 一定出现在层序遍历序列的末尾。这意味着使用数组表示完全二叉树时,可以省略存储所有 None
我们可以将索引映射公式封装成函数,方便后续使用:
def left(self, i: int):
'''获取左子节点索引'''
return 2*i + 1
def right(self, i: int):
'''获取右子节点索引'''
return 2* + 2
def parent(self, i :int):
'''获取父节点索引'''
return (i - 1)//2
# 向下整除
(2)访问堆顶元素
堆顶元素即为二叉树的根节点,也就是列表的首个元素:
def peek(self) -> int:
'''访问堆顶元素'''
return self.max_heap[0]
(3)元素入堆(入堆堆化)
给定元素 val ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏,因此需要修复从插入节点到根节点的路径上的各个节点,这个操作被称为堆化(heapify)。
【注】:入堆和栈顶元素出堆的堆化过程是不一样的过程,本质上入堆和栈顶元素出堆都是堆化的过程。
考虑从入堆节点开始,从底至顶执行堆化。如下图所示(hello算法上面动图更加清晰),我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。
设节点总数为 n n n,则树的高度为 l o g n logn logn。由此可知,堆化操作的循环轮数最多为 O ( l o g n ) O(logn) O(logn),元素入堆操作的时间复杂度为
O ( l o g n ) O(logn) O(logn)。代码如下所示:
def swap(self, i: int, j: int):
'''交换元素'''
self.max_heap[i], self.max_heap[j] = self.max_heap[j], self.max_heap[i]
def push(self, val: int):
'''元素入堆'''
# 添加节点
self.max_heap.append(val)
# 自底向上堆化
self.shft_up(len(self.max_heap) - 1)
def shft_up(self, i: int):
'''从节点索引i开始,自底向上堆化'''
while True:
parent_index = self.parent(i)
if parent_index < 0 or self.max_heap[i] <= self.max_heap[parent_index]:
break
self.swap(i,parent_index)
i = parent_index
可以看到这个堆化的过程并不难,清楚了思路就能写出来。
(4)堆顶元素出堆(堆顶元素出堆堆化)
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化进行修复变得困难。为了尽量减少元素索引的变动,我们采用以下操作步骤。
- (1)交换堆顶元素与堆底元素(交换根节点与最右叶节点)。
- (2)交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)。
- (3)从根节点开始,从顶至底执行堆化。
如下图所示,“从顶至底堆化”的操作方向与“从底至顶堆化”相反,我们将根节点的值与其两个子节点的值进行比较,将最大的子节点与根节点交换。然后循环执行此操作,直到越过叶节点或遇到无须交换的节点时结束。
与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为 O ( l o g n ) O(logn) O(logn) 。代码如下所示:
def pop(self):
'''元素出堆'''
# 判空处理
if not self.max_heap:
return IndexError('堆为空')
# 交换堆顶和堆底元素
self.swap(0, self.size() - 1)
# 删除堆底元素
cur = self.max_heap.pop()
# 从顶到底进行堆化
self.shft_down(0)
# 返回堆顶元素
return cur
def shft_down(self, i: int):
'''从节点 i索引开始,从顶向底堆化'''
while True:
# 判断节点索引 i, l, r 中最大节点索引,记为ma
l, r, ma = self.left(i), self.right(i), i
if l < self.size() and self.max_heap[l] > self.max_heap[ma]:
ma = l
if r < self.size() and self.max_heap[r] > self.max_heap[ma]:
ma = r
# 判断 节点 i 最大或 l,r越界,退出循环
if (l > self.size() -1 and r > self.size() -1) or ma == i:
break
self.swap(i,ma)
i = ma
(5)建堆操作(对列表进行堆化)
在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为“建堆操作”。
有两种建堆操作,一种比较容易想到,另一种需要再脑袋里面想一想(其实想一下就明白了,也不难)。
----借助入堆操作实现
我们首先创建一个空堆,然后遍历列表,依次对每个元素执行“入堆操作”,即先将元素添加至堆的尾部,再对该元素执行“从底至顶”堆化。
每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆是“自上而下”构建的。
设元素数量为 n n n ,每个元素的入堆操作使用 O ( l o g n ) O(logn) O(logn)时间,因此该建堆方法的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。
很显然,这种建堆操作很自然就能想到。
----通过遍历堆化实现
实际上,我们可以实现一种更为高效的建堆方法,共分为两步。
- 将列表所有元素原封不动地添加到堆中,此时堆的性质尚未得到满足。
- 倒序遍历堆(层序遍历的倒序),依次对每个非叶节点执行“从顶至底堆化”。
【注】:理解 倒序遍历堆(层序遍历的倒序)这个里面层序遍历的倒序结合下面代码这个才是关键,能理解这句话这个就没问题了
每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆。而由于是倒序遍历,因此堆是“自下而上”构建的。
之所以选择倒序遍历,是因为这样能够保证当前节点之下的子树已经是合法的子堆,这样堆化当前节点才是有效的。
值得说明的是,由于叶节点没有子节点,因此它们天然就是合法的子堆,无须堆化。如以下代码所示,最后一个非叶节点是最后一个节点的父节点,我们从它开始倒序遍历并执行堆化:
class MaxHeap:
def __init__(self, heap: List[int]):
self.max_heap = heap
# 列表堆化操作
for i in range(self.parent(self.size()-1),-1,-1):
self.shft_down(i)
【注】:在hello算法上面还写了很多推导出这个建堆操作的时间复杂度为 O ( n ) O(n) O(n),比第一种不断入堆的复杂度要低
----堆排序
趁热打铁,蹭着前面的建堆、自顶向下堆化这些还没忘记,赶紧将堆排序一起看了,不然以后再单独看堆排序又要回来看这个很麻烦的
堆排序
(6)代码汇总
from typing import Union,List
class TreeNode:
def __init__(self,val = 0, left = None, right = None):
self.val: int = val
self.left: Union[TreeNode,None] = left
self.right: Union[TreeNode,None] = right
############################################# 打印树和堆结构区 ########################################################
class Trunk:
def __init__(self, prev, string: Union[str, None] = None):
self.prev = prev
self.str = string
def show_trunks(p: Union[Trunk, None]):
if p is None:
return
show_trunks(p.prev)
print(p.str, end="")
def print_tree(
root: Union[TreeNode, None], prev: Union[Trunk, None] = None, is_right: bool = False
):
"""
打印二叉树
This tree printer is borrowed from TECHIE DELIGHT
https://www.techiedelight.com/c-program-print-binary-tree/
"""
if root is None:
return
prev_str = " "
trunk = Trunk(prev, prev_str)
print_tree(root.right, trunk, True)
if prev is None:
trunk.str = "———"
elif is_right:
trunk.str = "/———"
prev_str = " |"
else:
trunk.str = "\———"
prev.str = prev_str
show_trunks(trunk)
print(" " + str(root.val))
if prev:
prev.str = prev_str
trunk.str = " |"
print_tree(root.left, trunk, False)
def print_heap(heap: List[int]):
"""打印堆"""
print("堆的数组表示:", heap)
print("堆的树状表示:")
root: Union[TreeNode, None] = list_to_tree(heap)
print_tree(root)
def list_to_tree(arr: List[int]) -> Union[TreeNode, None]:
"""将列表反序列化为二叉树"""
return list_to_tree_dfs(arr, 0)
def list_to_tree_dfs(arr: List[int], i: int) -> Union[TreeNode, None]:
"""将列表反序列化为二叉树:递归"""
# 如果索引超出数组长度,或者对应的元素为 None ,则返回 None
if i < 0 or i >= len(arr) or arr[i] is None:
return None
# 构建当前节点
root = TreeNode(arr[i])
# 递归构建左右子树
root.left = list_to_tree_dfs(arr, 2 * i + 1)
root.right = list_to_tree_dfs(arr, 2 * i + 2)
return root
############################################# 打印树结构区 ########################################################
class MaxHeap:
def __init__(self, heap: List[int]):
self.max_heap = heap
# 列表堆化操作
for i in range(self.parent(self.size()-1),-1,-1):
self.shft_down(i)
def left(self, i: int):
'''获取左子节点索引'''
return 2*i + 1
def right(self, i: int):
'''获取右子节点索引'''
return 2* + 2
def parent(self, i :int):
'''获取父节点索引'''
return (i - 1)//2
# 向下整除
def swap(self, i: int, j: int):
'''交换元素'''
self.max_heap[i], self.max_heap[j] = self.max_heap[j], self.max_heap[i]
def size(self) -> int:
'''获取堆大小'''
return len(self.max_heap)
def is_empty(self) -> bool:
return self.size() == 0
def peek(self) -> int:
'''访问堆顶元素'''
return self.max_heap[0]
def push(self, val: int):
'''元素入堆'''
# 添加节点
self.max_heap.append(val)
# 自底向上堆化
self.shft_up(len(self.max_heap) - 1)
def shft_up(self, i: int):
'''从节点索引i开始,自底向上堆化'''
while True:
parent_index = self.parent(i)
if parent_index < 0 or self.max_heap[i] <= self.max_heap[parent_index]:
break
self.swap(i,parent_index)
i = parent_index
def pop(self):
'''元素出堆'''
# 判空处理
if not self.max_heap:
return IndexError('堆为空')
# 交换堆顶和堆底元素
self.swap(0, self.size() - 1)
# 删除堆底元素
cur = self.max_heap.pop()
# 从顶到底进行堆化
self.shft_down(0)
# 返回堆顶元素
return cur
def shft_down(self, i: int):
'''从节点 i索引开始,从顶向底堆化'''
while True:
# 判断节点索引 i, l, r 中最大节点索引,记为ma
l, r, ma = self.left(i), self.right(i), i
if l < self.size() and self.max_heap[l] > self.max_heap[ma]:
ma = l
if r < self.size() and self.max_heap[</