一点题外话
上次在我的公众号给大家做了一个小调查《投出你想要的题解编程语言吧~》。以下是调查的结果:
由于 Java 和 Python 所占比例已经超过了 60%,这次我尝试一下 Java 和 Python 双语言来写,感谢 @CaptainZ 提供的 Java 代码。同时为了不让文章又臭又长,我将 Java 本文所有代码(Java 和 Python)都放到了力扣加加官网上,网站地址:https://leetcode-solution.cn/solution-code
正文
大家好,我是 lucifer。今天给大家带来的是《堆》专题。先上下本文的提纲,这个是我用 mindmap 画的一个脑图,之后我会继续完善,将其他专题逐步完善起来。
大家也可以使用 vscode blink-mind 打开源文件查看,里面有一些笔记可以点开查看。源文件可以去我的公众号《力扣加加》回复脑图获取,以后脑图也会持续更新更多内容。vscode 插件地址:https://marketplace.visualstudio.com/items?itemName=awehook.vscode-blink-mind
本次是下篇,没有看过上篇的同学强烈建议先阅读上篇几乎刷完了力扣所有的堆题,我发现了这些东西。。。(第一弹)
这是第二部分,后面的内容更加干货,分别是三个技巧和四大应用。这两个主题是专门教你怎么解题的。掌握了它,力扣中的大多数堆的题目都不在话下(当然我指的仅仅是题目中涉及到堆的部分)。
警告: 本章的题目基本都是力扣 hard 难度,这是因为堆的题目很多标记难度都不小,关于这点在前面也介绍过了。
一点说明
在上主菜之前,先给大家来个开胃菜。
这里给大家介绍两个概念,分别是元组和模拟大顶堆 。之所以进行这些说明就是防止大家后面看不懂。
元组
使用堆不仅仅可以存储单一值,比如 [1,2,3,4] 的 1,2,3,4 分别都是单一值。除了单一值,也可以存储复合值,比如对象或者元组等。
这里我们介绍一种存储元组的方式,这个技巧会在后面被广泛使用,请务必掌握。比如 [(1,2,3), (4,5,6), (2,1,3),(4,2,8)]。
h = [(1,2,3), (4,5,6), (2,1,3),(4,2,8)]
heapq.heappify(h) # 堆化(小顶堆)
heapq.heappop() # 弹出 (1,2,3)
heapq.heappop() # 弹出 (2,1,3)
heapq.heappop() # 弹出 (4,2,8)
heapq.heappop() # 弹出 (4,5,6)
简单解释一下上面代码的执行结果。
使用元组的方式,默认将元组第一个值当做键来比较。如果第一个相同,继续比较第二个。比如上面的 (4,5,6) 和 (4,2,8),由于第一个值相同,因此继续比较后一个,又由于 5 比 2 大,因此 (4,2,8)先出堆。
使用这个技巧有两个作用:
- 携带一些额外的信息。 比如我想求二维矩阵中第 k 小数,当然是以值作为键。但是处理过程又需要用到其行和列信息,那么使用元组就很合适,比如 (val, row, col)这样的形式。
- 想根据两个键进行排序,一个主键一个副键。这里面又有两种典型的用法,
2.1 一种是两个都是同样的顺序,比如都是顺序或者都是逆序。
2.2 另一种是两个不同顺序排序,即一个是逆序一个是顺序。
由于篇幅原因,具体就不再这里展开了,大家在平时做题过程中留意可以一下,有机会我会单独开一篇文章讲解。
如果你所使用的编程语言没有堆或者堆的实现不支持元组,那么也可以通过简单的改造使其支持,主要就是自定义比较逻辑即可。
模拟大顶堆
由于 Python 没有大顶堆。因此我这里使用了小顶堆进行模拟实现。即将原有的数全部取相反数,比如原数字是 5,就将 -5 入堆。经过这样的处理,小顶堆就可以当成大顶堆用了。不过需要注意的是,当你 pop 出来的时候, 记得也要取反,将其还原回来哦。
代码示例:
h = []
A = [1,2,3,4,5]
for a in A:
heapq.heappush(h, -a)
-1 * heapq.heappop(h) # 5
-1 * heapq.heappop(h) # 4
-1 * heapq.heappop(h) # 3
-1 * heapq.heappop(h) # 2
-1 * heapq.heappop(h) # 1
铺垫就到这里,接下来进入正题。
三个技巧
技巧一 - 固定堆
这个技巧指的是固定堆的大小 k 不变,代码上可通过每 pop 出去一个就 push 进来一个来实现。而由于初始堆可能是 0,我们刚开始需要一个一个 push 进堆以达到堆的大小为 k,因此严格来说应该是维持堆的大小不大于 k。
固定堆一个典型的应用就是求第 k 小的数。其实求第 k 小的数最简单的思路是建立小顶堆,将所有的数先全部入堆,然后逐个出堆,一共出堆 k 次。最后一次出堆的就是第 k 小的数。
然而,我们也可不先全部入堆,而是建立大顶堆(注意不是上面的小顶堆),并维持堆的大小为 k 个。如果新的数入堆之后堆的大小大于 k,则需要将堆顶的数和新的数进行比较,并将较大的移除。这样可以保证堆中的数是全体数字中最小的 k 个,而这最小的 k 个中最大的(即堆顶)不就是第 k 小的么?这也就是选择建立大顶堆,而不是小顶堆的原因。
简单一句话总结就是固定一个大小为 k 的大顶堆可以快速求第 k 小的数,反之固定一个大小为 k 的小顶堆可以快速求第 k 大的数。比如力扣 2020-02-24 的周赛第三题5663. 找出第 K 大的异或坐标值就可以用固定小顶堆技巧来实现(这道题让你求第 k 大的数)。
这么说可能你的感受并不强烈,接下来我给大家举两个例子来帮助大家加深印象。
技巧二 - 多路归并
这个技巧其实在前面讲超级丑数的时候已经提到了,只是没有给这种类型的题目一个名字。
其实这个技巧,叫做多指针优化可能会更合适,只不过这个名字实在太过朴素且容易和双指针什么的混淆,因此我给 ta 起了个别致的名字 - 多路归并。
- 多路体现在:有多条候选路线。代码上,我们可使用多指针来表示。
- 归并体现在:结果可能是多个候选路线中最长的或者最短,也可能是第 k 个等。因此我们需要对多条路线的结果进行比较,并根据题目描述舍弃或者选取某一个或多个路线。
这样描述比较抽象,接下来通过几个例子来加深一下大家的理解。
这里我给大家精心准备了四道难度为 hard 的题目。 掌握了这个套路就可以去快乐地 AC 这四道题啦。
1439.有序矩阵中的第 k 个最小数组和
题目描述:
给你一个 m * n 的矩阵 mat,以及一个整数 k ,矩阵中的每一行都以非递减的顺序排列。
你可以从每一行中选出 1 个元素形成一个数组。返回所有可能数组中的第 k 个 最小 数组和。
示例 1:
输入:mat = [[1,3,11],[2,4,6]], k = 5
输出:7
解释:从每一行中选出一个元素,前 k 个和最小的数组分别是:
[1,2], [1,4], [3,2], [3,4], [1,6]。其中第 5 个的和是 7 。
示例 2:
输入:mat = [[1,3,11],[2,4,6]], k = 9
输出:17
示例 3:
输入:mat = [[1,10,10],[1,4,5],[2,3,6]], k = 7
输出:9
解释:从每一行中选出一个元素,前 k 个和最小的数组分别是:
[1,1,2], [1,1,3], [1,4,2], [1,4,3], [1,1,6], [1,5,2], [1,5,3]。其中第 7 个的和是 9 。
示例 4:
输入:mat = [[1,1,10],[2,2,9]], k = 7
输出:12
提示:
m == mat.length
n == mat.length[i]
1 <= m, n <= 40
1 <= k <= min(200, n ^ m)
1 <= mat[i][j] <= 5000
mat[i] 是一个非递减数组
思路
一个朴素的想法是使用多指针来解。对于这道题来说就是使用 m 个指针,分别指向 m 个一维数组,指针的位置表示当前选取的是该一维数组中第几个。
以题目中的 mat = [[1,3,11],[2,4,6]], k = 5 为例。
- 先初始化两个指针 p1,p2,分别指向两个一维数组的开头,代码表示就是全部初始化为 0。
- 此时两个指针指向的数字和为 1 + 2 =3,这就是第 1 小的和。
- 接下来,我们移动其中一个指针。此时我们可以移动 p1,也可以移动 p2。
- 那么第 2 小的一定是移动 p1 和 移动 p2 这两种情况的较小值。而这里移动 p1 和 p2 实际上都会得到 5,也就是说第 2 和第 3 小的和都是 5。
到这里已经分叉了,出现了两种情况(注意看粗体的位置,粗体表示的是指针的位置):
- [1,3,11],[2,4,6] 和为 5
- [1,3,11],[2,4,6] 和为 5
接下来,这两种情况应该齐头并进,共同进行下去。
对于情况 1 来说,接下来移动又有两种情况。
- [1,3,11],[2,4,6] 和为 13
- [1,3,11],[2,4,6] 和为 7
对于情况 2 来说,接下来移动也有两种情况。
- [1,3,11],[2,4,6] 和为 7
- [1,3,11],[2,4,6] 和为 7
我们通过比较这四种情况,得出结论: 第 4,5,6 小的数都是 7。但第 7 小的数并不一定是 13。原因和上面类似,可能第 7 小的就隐藏在前面的 7 分裂之后的新情况中,实际上确实如此。因此我们需要继续执行上述逻辑。
进一步,我们可以将上面的思路拓展到一般情况。
上面提到了题目需要求的其实是第 k 小的和,而最小的我们是容易知道的,即所有的一维数组首项和。我们又发现,根据最小的,我们可以推导出第 2 小,推导的方式就是移动其中一个指针,这就一共分裂出了 n 种情况了,其中 n 为一维数组长度,第 2 小的就在这分裂中的 n 种情况中,而筛选的方式是这 n 种情况和最小的,后面的情况也是类似。不难看出每次分裂之后极值也发生了变化,因此这是一个明显的求动态求极值的信号,使用堆是一个不错的选择。
那代码该如何书写呢?
上面说了,我们先要初始化 m 个指针,并赋值为 0。对应伪代码:
# 初始化堆
h = []
# sum(vec[0] for vec in mat) 是 m 个一维数组的首项和
# [0] * m 就是初始化了一个长度为 m 且全部填充为 0 的数组。
# 我们将上面的两个信息组装成元祖 cur 方便使用
cur = (sum(vec[0] for vec in mat), [0] * m)
# 将其入堆
heapq.heappush(h, cur)
接下来,我们每次都移动一个指针,从而形成分叉出一条新的分支。每次从堆中弹出一个最小的,弹出 k 次就是第 k 小的了。伪代码:
for 1 to K:
# acc 当前的和, pointers 是指针情况。
acc, pointers = heapq.heappop(h)
# 每次都粗暴地移动指针数组中的一个指针。每移动一个指针就分叉一次, 一共可能移动的情况是 n,其中 n 为一维数组的长度。
for i, pointer in enumerate(pointers):
# 如果 pointer == len(mat[0]) - 1 说明到头了,不能移动了
if pointer != len(mat[0]) - 1:
# 下面两句话的含义是修改 pointers[i] 的指针 为 pointers[i] + 1
new_pointers = pointers.copy()
new_pointers[i] += 1
# 将更新后的 acc 和指针数组重新入堆
heapq.heappush(h, (acc + mat[i][pointer + 1] - mat[i][pointer], new_pointers))
这是多路归并问题的核心代码,请务必记住。
代码看起来很多,其实去掉注释一共才七行而已。
上面的伪代码有一个问题。比如有两个一维数组,指针都初始化为 0。第一次移动第一个一维数组的指针,第二次移动第二个数组的指针,此时指针数组为 [1, 1],即全部指针均指向下标为 1 的元素。而如果第一次移动第二个一维数组的指针,第二次移动第一个数组的指针,此时指针数组仍然为 [1, 1]。这实际上是一种情况,如果不加控制会被计算两次导致出错。
一个可能的解决方案是使用 hashset 记录所有的指针情况,这样就避免了同样的指针被计算多次的问题。为了做到这一点,我们需要对指针数组的使用做一些微调,即使用元组代替数组。原因在于数组是无法直接哈希化的。具体内容请参考代码区。
多路归并的题目,思路和代码都比较类似。为了后面的题目能够更高地理解,请务必搞定这道题,后面我们将不会这么详细地进行分析。
class Solution:
def kthSmallest(self, mat, k: int) -> int:
h = []
cur = (sum(vec[0] for vec in mat), tuple([0] * len(mat)))
heapq.heappush(h, cur)
seen = set(cur)
for _ in range(k):
acc, pointers = heapq.heappop(h)
for i, pointer in enumerate(pointers):
if pointer != len(mat[0]) - 1:
t = list(pointers)
t[i] = pointer + 1
tt = tuple(t)
if tt not in seen:
seen.add(tt)
heapq.heappush(h, (acc + mat[i][pointer + 1] - mat[i][pointer], tt))
return acc
719.找出第 k 小的距离对
题目描述
给定一个整数数组,返回所有数对之间的第 k 个最小距离。一对 (A, B) 的距离被定义为 A 和 B 之间的绝对差值。
示例 1:
输入:
nums = [1,3,1]
k = 1
输出:0
解释:
所有数对如下:
(1,3) -> 2
(1,1) -> 0
(3,1) -> 2
因此第 1 个最小距离的数对是 (1,1),它们之间的距离为 0。
提示:
2 <= len(nums) <= 10000.
0 <= nums[i] < 1000000.
1 <= k <= len(nums) * (len(nums) - 1) / 2.
思路
不难看出所有的数对可能共
C
n
2
C_n^2
Cn2 个,也就是
n
×
(
n
−
1
)
÷
2
n\times(n-1)\div2
n×(n−1)÷2。
因此我们可以使用两次循环找出所有的数对,并升序排序,之后取第 k 个。
实际上,我们可使用固定堆技巧,维护一个大小为 k 的大顶堆,这样堆顶的元素就是第 k 小的,这在前面的固定堆中已经讲过,不再赘述。
class Solution:
def smallestDistancePair(self, nums: List[int], k: int) -> int:
h = []
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
a, b = nums[i], nums[j]
# 维持堆大小不超过 k
if len(h) == k and -abs(a - b) > h[0]:
heapq.heappop(h)
if len(h) < k:
heapq.heappush(h, -abs(a - b))
return -h[0]
(代码 1.3.5)
不过这种优化意义不大,因为算法的瓶颈在于
N
2
N^2
N2 部分的枚举,我们应当设法优化这一点。
如果我们将数对进行排序,那么最小的数对距离一定在 nums[i] - nums[i - 1] 中,其中 i 为从 1 到 n 的整数,究竟是哪个取决于谁更小。接下来就可以使用上面多路归并的思路来解决了。
如果 nums[i] - nums[i - 1] 的差是最小的,那么第 2 小的一定是剩下的 n - 1 种情况和 nums[i] - nums[i - 1] 分裂的新情况。关于如何分裂,和上面类似,我们只需要移动其中 i 的指针为 i + 1 即可。这里的指针数组长度固定为 2,而不是上面题目中的 m。这里我将两个指针分别命名为 fr 和 to,分别代表 from 和 to。
代码
class Solution(object):
def smallestDistancePair(self, nums, k):
nums.sort()
# n 种候选答案
h = [(nums[i+1] - nums[i], i, i+1) for i in range(len(nums) - 1)]
heapq.heapify(h)
for _ in range(k):
diff, fr, to = heapq.heappop(h)
if to + 1 < len(nums):
heapq.heappush((nums[to + 1] - nums[fr], fr, to + 1))
return diff
由于时间复杂度和 k 有关,而 k 最多可能达到 N 2 N^2 N2 的量级,因此此方法实际上也会超时。不过这证明了这种思路的正确性,如果题目稍加改变说不定就能用上。
这道题可通过二分法来解决,由于和堆主题有偏差,因此这里简单讲一下。
求第 k 小的数比较容易想到的就是堆和二分法。二分的原因在于求第 k 小,本质就是求不大于其本身的有 k - 1 个的那个数。而这个问题很多时候满足单调性,因此就可使用二分来解决。
以这道题来说,最大的数对差就是数组的最大值 - 最小值,不妨记为 max_diff。我们可以这样发问:
而我们知道,发问的答案也是不严格递减的,因此使用二分就应该被想到。我们不断发问直到问到小于 x 的有 k - 1 个即可。然而这样的发问也有问题。原因有两个:
- 小于 x 的有 k - 1 个的数可能不止一个。
- 我们无法确定小于 x 的有 k - 1 个的数一定存在。 比如数对差分别为
[1,1,1,1,2],让你求第 3 大的,那么小于 x 有两个的数根本就不存在。
我们的思路可调整为求小于等于 x 有 k 个的,接下来我们使用二分法的最左模板即可解决。关于最左模板可参考我的二分查找专题
代码:
class Solution:
def smallestDistancePair(self, A: List[int], K: int) -> int:
A.sort()
l, r = 0, A[-1] - A[0]
def count_ngt(mid):
slow = 0
ans = 0
for fast in range(len(A)):
while A[fast] - A[slow] > mid:
slow += 1
ans += fast - slow
return ans
while l <= r:
mid = (l + r) // 2
if count_ngt(mid) >= K:
r = mid - 1
else:
l = mid + 1
return l
632 最小区间
技巧三 - 事后小诸葛
这个技巧指的是:当从左到右遍历的时候,我们是不知道右边是什么的,需要等到你到了右边之后才知道。
如果想知道右边是什么,一种简单的方式是遍历两次,第一次遍历将数据记录下来,当第二次遍历的时候,用上次遍历记录的数据。这是我们使用最多的方式。不过有时候,我们也可以在遍历到指定元素后,往前回溯,这样就可以边遍历边存储,使用一次遍历即可。具体来说就是将从左到右的数据全部收集起来,等到需要用的时候,从里面挑一个用。如果我们都要取最大值或者最小值且极值会发生变动, 就可使用堆加速。直观上就是使用了时光机回到之前,达到了事后诸葛亮的目的。
这样说你肯定不明白啥意思。没关系,我们通过几个例子来讲一下。当你看完这些例子之后,再回头看这句话。
871.最低加油次数
题目描述
汽车从起点出发驶向目的地,该目的地位于出发位置东面 target 英里处。
沿途有加油站,每个 station[i] 代表一个加油站,它位于出发位置东面 station[i][0] 英里处,
并且有 station[i][1] 升汽油。
假设汽车油箱的容量是无限的,其中最初有 startFuel 升燃料。它每行驶 1 英里就会用掉 1 升汽油。
当汽车到达加油站时,它可能停下来加油,将所有汽油从加油站转移到汽车中。
为了到达目的地,汽车所必要的最低加油次数是多少?如果无法到达目的地,则返回 -1 。
注意:如果汽车到达加油站时剩余燃料为 0,它仍然可以在那里加油。
如果汽车到达目的地时剩余燃料为 0,仍然认为它已经到达目的地。
示例 1:
输入:target = 1, startFuel = 1, stations = []
输出:0
解释:我们可以在不加油的情况下到达目的地。
示例 2:
输入:target = 100, startFuel = 1, stations = [[10,100]]
输出:-1
解释:我们无法抵达目的地,甚至无法到达第一个加油站。
示例 3:
输入:target = 100, startFuel = 10, stations = [[10,60],[20,30],[30,30],[60,40]]
输出:2
解释:
我们出发时有 10 升燃料。
我们开车来到距起点 10 英里处的加油站,消耗 10 升燃料。将汽油从 0 升加到 60 升。
然后,我们从 10 英里处的加油站开到 60 英里处的加油站(消耗 50 升燃料),
并将汽油从 10 升加到 50 升。然后我们开车抵达目的地。
我们沿途在1两个加油站停靠,所以返回 2 。
提示:
1 <= target, startFuel, stations[i][1] <= 10^9
0 <= stations.length <= 500
0 < stations[0][0] < stations[1][0] < ... < stations[stations.length-1][0] < target
思路
为了能够获得最低加油次数,我们肯定希望能不加油就不加油。那什么时候必须加油呢?答案应该是如果你不加油,就无法到达下一个目的地的时候。
伪代码描述就是:
cur = startFuel # 刚开始有 startFuel 升汽油
last = 0 # 上一次的位置
for i, fuel in stations:
cur -= i - last # 走过两个 staton 的耗油为两个 station 的距离,也就是 i - last
if cur < 0:
# 我们必须在前面就加油,否则到不了这里
# 但是在前面的哪个 station 加油呢?
# 直觉告诉我们应该贪心地选择可以加汽油最多的站 i,如果加上 i 的汽油还是 cur < 0,继续加次大的站 j,直到没有更多汽油可加或者 cur > 0
上面说了要选择可以加汽油最多的站 i,如果加了油还不行,继续选择第二多的站。这种动态求极值的场景非常适合使用 heap。
具体来说就是:
- 每经过一个站,就将其油量加到堆。
- 尽可能往前开,油只要不小于 0 就继续开。
- 如果油量小于 0,就从堆中取最大的加到油箱中去,如果油量还是小于 0 继续重复取堆中的最大油量。
- 如果加完油之后油量大于 0 ,继续开,重复上面的步骤。否则返回 -1,表示无法到达目的地。
那这个算法是如何体现事后小诸葛的呢?你可以把自己代入到题目中进行模拟。 把自己想象成正在开车,你的目标就是题目中的要求:最少加油次数。当你开到一个站的时候,你是不知道你的油量够不够支撑到下个站的,并且就算撑不到下个站,其实也许在上个站加油会更好。所以现实中你无论如何都无法知道在当前站,我是应该加油还是不加油的,因为信息太少了。
那我会怎么做呢?如果是我在开车的话,我只能每次都加油,这样都无法到达目的地,那肯定就无法到达目的地了。但如果这样可以到达目的地,我就可以说如果我们在那个站加油,这个站选择不加就可以最少加油次数到达目的地了。你怎么不早说呢? 这不就是事后诸葛亮么?
这个事后诸葛亮体现在我们是等到没油了才去想应该在之前的某个站加油。
所以这个事后诸葛亮本质上解决的是,基于当前信息无法获取最优解,我们必须掌握全部信息之后回溯。以这道题来说,我们可以先遍历一边 station,然后将每个 station 的油量记录到一个数组中,每次我们“预见“到无法到达下个站的时候,就从这个数组中取最大的。。。。 基于此,我们可以考虑使用堆优化取极值的过程,而不是使用数组的方式。
代码
class Solution:
def minRefuelStops(self, target: int, startFuel: int, stations: List[List[int]]) -> int:
stations += [(target, 0)]
cur = startFuel
ans = 0
h = []
last = 0
for i, fuel in stations:
cur -= i - last
while cur < 0 and h:
cur -= heapq.heappop(h)
ans += 1
if cur < 0:
return -1
heappush(h, -fuel)
last = i
return ans
四大应用
1. topK
接下来是本文的最后一个部分《四大应用》,目的是通过这几个例子来帮助大家巩固前面的知识。
求解 topK 是堆的一个很重要的功能。这个其实已经在前面的固定堆部分给大家介绍过了。
这里直接引用前面的话:
“其实求第 k 小的数最简单的思路是建立小顶堆,将所有的数先全部入堆,然后逐个出堆,一共出堆 k 次。最后一次出堆的就是第 k 小的数。然而,我们也可不先全部入堆,而是建立大顶堆(注意不是上面的小顶堆),并维持堆的大小为 k 个。如果新的数入堆之后堆的大小大于 k,则需要将堆顶的数和新的数进行比较,并将较大的移除。这样可以保证堆中的数是全体数字中最小的 k 个,而这最小的 k 个中最大的(即堆顶)不就是第 k 小的么?这也就是选择建立大顶堆,而不是小顶堆的原因。”
其实除了第 k 小的数,我们也可以将中间的数全部收集起来,这就可以求出最小的 k 个数。和上面第 k 小的数唯一不同的点在于需要收集 popp 出来的所有的数。
需要注意的是,有时候权重并不是原本数组值本身的大小,也可以是距离,出现频率等。
相关题目:
力扣中有关第 k 的题目很多都是堆。除了堆之外,第 k 的题目其实还会有一些找规律的题目,对于这种题目则可以通过分治+递归的方式来解决,具体就不再这里展开了,感兴趣的可以和我留言讨论。
2. 带权最短距离
关于这点,其实我在前面部分也提到过了,只不过当时只是一带而过。原话是“不过 BFS 真的就没人用优先队列实现么?当然不是!比如带权图的最短路径问题,如果用队列做 BFS 那就需要优先队列才可以,因为路径之间是有权重的差异的,这不就是优先队列的设计初衷么。使用优先队列的 BFS 实现典型的就是 dijkstra 算法。”
DIJKSTRA 算法主要解决的是图中任意两点的最短距离。
算法的基本思想是贪心,每次都遍历所有邻居,并从中找到距离最小的,本质上是一种广度优先遍历。这里我们借助堆这种数据结构,使得可以在 l o g N logN logN 的时间内找到 cost 最小的点,其中 N 为 堆的大小。
代码模板:
def dijkstra(graph, start, end):
# 堆里的数据都是 (cost, i) 的二元祖,其含义是“从 start 走到 i 的距离是 cost”。
heap = [(0, start)]
visited = set()
while heap:
(cost, u) = heapq.heappop(heap)
if u in visited:
continue
visited.add(u)
if u == end:
return cost
for v, c in graph[u]:
if v in visited:
continue
next = cost + c
heapq.heappush(heap, (next, v))
return -1
3. 因子分解
和上面两个应用一下,这个我在前面 《313. 超级丑数》部分也提到了。
回顾一下丑数的定义: 丑数就是质因数只包含 2, 3, 5 的正整数。 因此丑数本质就是一个数经过因子分解之后只剩下 2,3,5 的整数,而不携带别的因子了。
关于丑数的题目有很多,大多数也可以从堆的角度考虑来解。只不过有时候因子个数有限,不使用堆也容易解决。比如:264. 丑数 II 就可以使用三个指针来记录即可,这个技巧在前面也讲过了,不再赘述。
一些题目并不是丑数,但是却明确提到了类似因子的信息,并让你求第 k 大的 xx,这个时候优先考虑使用堆来解决。如果题目中夹杂一些其他信息,比如有序,则也可考虑二分法。具体使用哪种方法,要具体问题具体分析,不过在此之前大家要对这两种方法都足够熟悉才行。
4. 堆排序
前面的三种应用或多或少在前面都提到过。而堆排序却未曾在前面提到。
直接考察堆排序的题目几乎没有。但是面试却有可能会考察,另外学习堆排序对你理解分治等重要算法思维都有重要意义。个人感觉,堆排序,构造二叉树,构造线段树等算法都有很大的相似性,掌握一种,其他都可以触类旁通。
实际上,经过前面的堆的学习,我们可以封装一个堆排序,方法非常简单。
这里我放一个使用堆的 api 实现堆排序的简单的示例代码:
h = [9,5,2,7]
heapq.heapify(h)
ans = []
while h:
ans.append(heapq.heappop(h))
print(ans) # 2,5,7,9
明白了示例, 那封装成通用堆排序就不难了。
def heap_sort(h):
heapq.heapify(h)
ans = []
while h:
ans.append(heapq.heappop(h))
return ans
这个方法足够简单,如果你明白了前面堆的原理,让你手撸一个堆排序也不难。可是这种方法有个弊端,它不是原位算法,也就是说你必须使用额外的空间承接结果,空间复杂度为 O ( N ) O(N) O(N)。但是其实调用完堆排序的方法后,原有的数组内存可以被释放了,因此理论上来说空间也没浪费,只不过我们计算空间复杂度的时候取的是使用内存最多的时刻,因此使用原地算法毫无疑问更优秀。如果你实在觉得不爽这个实现,也可以采用原地的修改的方式。这倒也不难,只不过稍微改造一下前面的堆的实现即可,由于篇幅的限制,这里不多讲了。
总结
堆和队列有千丝万缕的联系。 很多题目我都是先思考使用堆来完成。然后发现每次入堆都是 + 1,而不会跳着更新,比如下一个是 + 2,+3 等等,因此使用队列来完成性能更好。 比如 649. Dota2 参议院 和 1654. 到家的最少跳跃次数 等。
堆的中心就一个,那就是动态求极值。
而求极值无非就是最大值或者最小值,这不难看出。如果求最大值,我们可以使用大顶堆,如果求最小值,可以用最小堆。而实际上,如果没有动态两个字,很多情况下没有必要使用堆。比如可以直接一次遍历找出最大的即可。而动态这个点不容易看出来,这正是题目的难点。这需要你先对问题进行分析, 分析出这道题其实就是动态求极值,那么使用堆来优化就应该被想到。
堆的实现有很多。比如基于链表的跳表,基于数组的二叉堆和基于红黑树的实现等。这里我们介绍了两种主要实现 并详细地讲述了二叉堆的实现,不仅是其实现简单,而且其在很多情况下表现都不错,推荐大家重点掌握二叉堆实现。
对于二叉堆的实现,核心点就一点,那就是始终维护堆的性质不变,具体是什么性质呢?那就是 父节点的权值不大于儿子的权值(小顶堆)。为了达到这个目的,我们需要在入堆和出堆的时候,使用上浮和下沉操作,并恰当地完成元素交换。具体来说就是上浮过程和比它大的父节点进行交换,下沉过程和两个子节点中较小的进行交换,当然前提是它有子节点且子节点比它小。