0%

数据结构与算法自学笔记(8)- 堆结构&哈希表&堆结构相关习题

前言

参照的是左程云的课程:https://space.bilibili.com/8888480/lists/3509640?type=series

本笔记涵盖了堆的基本概念与堆排序、哈希表的实现以及堆结构的一些相关习题,包括了class025→027的内容。

025【必备】堆结构和堆排序

堆的基本概念

什么是堆

堆是一种特殊的完全二叉树结构,通常用数组来实现存储。堆有以下特性:

  1. 结构性质:堆是完全二叉树,即除了最后一层,其他层都是满的,最后一层从左到右连续填充
  2. 堆序性质
    • 大根堆:任何一个子树内部的最大值一定在顶部(父节点 ≥ 子节点)
    • 小根堆:任何一个子树内部的最小值一定在顶部(父节点 ≤ 子节点)

堆的数组表示

堆使用数组实现,通过下标关系来表示父子关系:

1
2
3
4
5
6
7
8
# 对于下标为 i 的节点:
parent = (i - 1) // 2 # 父节点下标
left_child = i * 2 + 1 # 左孩子下标
right_child = i * 2 + 2 # 右孩子下标

# 判断是否有孩子节点:
# 如果 left_child >= size,则没有左孩子
# 如果 right_child >= size,则没有右孩子

堆结构→完全二叉树

堆的核心操作

堆有两个核心调整操作,时间复杂度都是 O(log n)

  1. heapInsert(向上调整):新元素插入后向上调整维持堆性质
  2. heapify(向下调整):删除堆顶后向下调整维持堆性质

堆的核心算法实现

heapInsert - 向上调整

当在堆的末尾插入新元素时,需要向上调整以维持堆的性质:

1
2
3
4
5
6
7
8
def heapInsert(i):
"""
i位置的数,向上调整大根堆
时间复杂度:O(logn)
"""
while arr[i] > arr[(i - 1) // 2]: # 当前节点大于父节点时
swap(i, (i - 1) // 2) # 与父节点交换
i = (i - 1) // 2 # 更新当前位置为父节点位置

算法流程

  1. 比较当前节点与其父节点的值
  2. 如果当前节点更大(大根堆),则交换
  3. 向上移动到父节点位置,重复过程
  4. 直到满足堆性质或到达根节点

测试链接https://www.luogu.com.cn/problem/P1177

大根堆的堆插入

heapify - 向下调整

当删除堆顶元素后,需要向下调整以维持堆的性质:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def heapify(i, size):
"""
i位置的数,向下调整大根堆
当前堆的大小为size
时间复杂度:O(logn)
"""
l = i * 2 + 1 # 左孩子
while l < size: # 如果左孩子存在
# 选出左右孩子中更大的那个
best = l + 1 if (l + 1 < size and arr[l + 1] > arr[l]) else l
# 当前节点和最强孩子之间,选出最强的
best = best if arr[best] > arr[i] else i
if best == i: # 如果当前节点最大,调整完成
break
swap(best, i) # 交换
i = best # 移动到交换后的位置
l = i * 2 + 1 # 更新左孩子
# 1.找到更大的子节点:比较左右子节点,选出值更大的那个
# 2.与父节点比较:将较大的子节点与当前父节点比较
# 3.决定是否交换:如果子节点更大就交换,否则停止调整
# 4.继续向下:交换后继续对新位置进行同样的操作

算法流程

  1. 找到更大的子节点:比较左右子节点,选出值更大的那个
  2. 与父节点比较:将较大的子节点与当前父节点比较
  3. 决定是否交换:如果子节点更大就交换,否则停止调整
  4. 继续向下:交换后继续对新位置进行同样的操作

堆排序的两种实现

方法一:从顶到底建堆(经典版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def heapSort1():
"""
完全二叉树的节点为N的话,高度是log_2(N)的水平
从顶到底建立大根堆,从顶到底建立大根堆,O(n * logn),每次排好一个数,排n个数要n次,每排一个数时间复杂度是logn,排n个数当然是nlogn
依次弹出堆内最大值并排好序,O(n * logn)
整体时间复杂度O(n * logn)
"""
# 建堆阶段:从第一个元素开始,依次插入
for i in range(n):
heapInsert(i) # 每个元素向上调整

# 排序阶段:不断取出最大值
size = n
while size > 1:
swap(0, size - 1) # 将最大值(堆顶)放到数组末尾
size -= 1 # 减少堆的大小
heapify(0, size) # 对新的堆顶进行向下调整

时间复杂度分析

  • 建堆阶段:log₁ + log₂ + log₃ + ... + logₙ =(收敛于) O(n logn)
  • 排序阶段:每次调整 O(log n),共 n-1 次,总计 O(n logn)
  • 总时间复杂度:O(n logn)

从底部到顶部的堆排序
从底部到顶部的堆排序-复杂度分析

方法二:从底到顶建堆(优化版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def heapSort2():
"""
从底到顶建立大根堆,O(n)
依次弹出堆内最大值并排好序,O(n * logn)
整体时间复杂度O(n * logn)
"""
# 建堆阶段:从最后一个非叶子节点开始,向前调整
for i in range(n - 1, -1, -1):
heapify(i, n)

# 排序阶段:与方法一相同
size = n
while size > 1:
swap(0, size - 1)
size -= 1
heapify(0, size)
# 这种方法更像修理一栋破房子,从最下面的楼层开始修。建堆阶段只需要O(n)时间,具体是从最后一个非叶子节点开始逐个向前调整,每个节点只需要"向下看"把不合格的子节点换上来,由于采用从底向上的策略效率更高。
# 排序阶段的做法与方法1相同,先取堆顶元素放到合适位置,然后重新调整剩余元素成堆并重复进行。整体而言方法2更优,因为它的建堆过程比方法1快了一个数量级,从O(n logn)优化到了O(n)

两种排序方法对比
为什么从底到顶建堆更快?

从底到顶建堆的时间复杂度是 O(n),这是因为:

  1. 叶子节点无需调整:完全二叉树中约有 n/2 个叶子节点,它们天然满足堆性质
  2. 调整距离递减:越靠近叶子的节点,需要向下调整的最大距离越小
  3. 数学分析:可以证明总的调整代价是一个等比数列,收敛到 O(n)

两种方法的对比

建堆方法 建堆复杂度 排序复杂度 总复杂度 特点
从顶到底 O(n logn) O(n logn) O(n logn) 逐个插入元素”爬楼梯”
从底到顶 O(n) O(n logn) O(n logn) 从下往上”修房子”

完整代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import sys
import random

MAXN = 100001
arr = [0] * MAXN
n = 0

def main():
global n
data = sys.stdin.read().split()
n = int(data[0])
for i in range(n):
arr[i] = int(data[i + 1])

heapSort2() # 使用优化版本

# 高效输出
sys.stdout.write(' '.join(str(arr[i]) for i in range(n - 1)))
sys.stdout.write(' ' + str(arr[n - 1]) + '\n')

def heapInsert(i):
while arr[i] > arr[(i - 1) // 2]:
swap(i, (i - 1) // 2)
i = (i - 1) // 2

def heapify(i, size):
l = i * 2 + 1
while l < size:
best = l + 1 if (l + 1 < size and arr[l + 1] > arr[l]) else l
best = best if arr[best] > arr[i] else i
if best == i:
break
swap(best, i)
i = best
l = i * 2 + 1

def swap(i, j):
arr[i], arr[j] = arr[j], arr[i]

def heapSort1():
for i in range(n):
heapInsert(i)
size = n
while size > 1:
swap(0, size - 1)
size -= 1
heapify(0, size)

def heapSort2():
for i in range(n - 1, -1, -1):
heapify(i, n)
size = n
while size > 1:
swap(0, size - 1)
size -= 1
heapify(0, size)

if __name__ == "__main__":
main()

堆排序的特点总结

优点

  1. 时间复杂度稳定:无论什么数据,时间复杂度都是 O(n logn)
  2. 原地排序:额外空间复杂度 O(1),直接在原数组上建堆
  3. 不稳定但可预测:虽然不是稳定排序,但性能可预测

缺点

  1. 不稳定:相同元素的相对位置可能改变
  2. 常数因子较大:虽然渐进复杂度优秀,但实际运行时常数因子比快排大
  3. 缓存友好性差:堆调整过程中的访问模式对CPU缓存不太友好

应用场景

  1. 优先队列:堆是实现优先队列的最佳数据结构
  2. Top-K 问题:找出最大或最小的K个元素
  3. 实时数据流:需要实时维护最值的场景
  4. 任务调度:按优先级处理任务

重要提示

堆结构比堆排序有用得多,尤其是和比较器结合之后。堆排序只是堆数据结构的一个应用,堆在实际开发中更多用于实现优先队列、解决Top-K问题等场景。

026【必备】哈希表、有序表和比较器的用法

哈希表(Hash Table)的基本概念

什么是哈希表?

哈希表是一种数据结构,它通过键(key)来直接访问存储在值(value)中的数据,实现快速查找、插入和删除操作。

大概约等于python中的字典啦

核心思想

  1. 将数据存储在数组中
  2. 通过哈希函数将key转换为数组索引
  3. 理想情况下,查找、插入、删除的时间复杂度都是O(1)

工作原理

1
Key → 哈希函数 → 数组索引 → 存储位置

哈希表的特点

  • 时间复杂度:增、删、改、查时间为O(1),但是大常数
  • 空间换时间:用额外的空间来换取时间效率
  • 两种形式
    • HashSet:只存储键,用于判断元素是否存在
    • HashMap:存储键值对,根据键快速找到对应的值

Python中的哈希表实现

字符串的比较机制

Python中字符串有特殊的驻留机制,需要注意is==的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 字符串比较
str1 = str("Hello")
str2 = str("Hello") # 不同内存地址,但是内容都是Hello

# 比较对象身份(内存地址)
print(str1 is str2) # 可能是True(驻留机制)

# 比较值(内容)
print(str1 == str2) # True,内容一样

# 动态生成的字符串
s3 = ''.join(['a', 'b', 'c'])
print(s1 == s3) # True
print(s1 is s3) # False,复杂或动态生成的字符串,is一般是False

重要概念

  • 字符串驻留:Python对于短小、常用的字符串可能会优化,放到同一个内存地址
  • 内容比较 vs 身份比较==比较内容,is比较内存地址

set操作(HashSet)

1
2
3
4
5
6
7
8
9
10
11
12
13
# set判断元素是否相等靠内容,不看对象id
str1 = str("Hello")
str2 = str("Hello") # 不同内存地址,但是内容都是Hello

s = set()
s.add(str1)
print("Hello" in s) # True
print(str2 in s) # True
s.add(str2)
print(len(s)) # 1,因为set里不会存储重复元素
s.discard(str1) # 删除等价元素(内容相等的字符串都会被删掉)
s.clear() # 清空所有元素
print(len(s) == 0) # True

dict操作(HashMap)

1
2
3
4
5
6
7
8
9
10
11
12
# dict操作
map1 = dict()
map1[str1] = "World" # str1是key,"World"是value
print("Hello" in map1) # True
print(str2 in map1) # True
print(map1.get(str2)) # World
print(map1.get("你好") is None) # True
if "Hello" in map1:
del map1["Hello"]
print(len(map1)) # 0
map1.clear()
print(len(map1) == 0) # True

哈希表的优化策略

数组替代哈希表

当key的范围是固定的、可控的情况下,可以用数组结构替代哈希表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 哈希表实现
map2 = dict()
map2[56] = 7285
map2[34] = 3671263
map2[17] = 716311
map2[24] = 1263161

# 数组替代实现(速度更快)
arr = [0] * 100
arr[56] = 7285
arr[34] = 3671263
arr[17] = 716311
arr[24] = 1263161

优势

  • 速度更快:直接数组访问,无需哈希计算
  • 空间可控:预知范围,精确分配空间
  • 无哈希冲突:避免了哈希表的冲突处理

自定义对象作为Key

1
2
3
4
5
6
7
8
9
10
11
12
13
class Student:
def __init__(self, age, name):
self.age = age
self.name = name

s1 = Student(17, "张三")
s2 = Student(17, "张三")
map3 = dict()
map3[s1] = "这是张三"
print(s1 in map3) # True
print(s2 in map3) # False,Python默认不同对象hash不同
map3[s2] = "这是另一个张三"
print(len(map3)) # 2,s1和s2是不同的对象,字典中有两个键值对

重要提示:Python自定义对象没有实现__eq____hash__时,默认按对象id判定。

有序表(TreeMap/TreeSet)

Python中的有序表模拟

Python没有内置的TreeMap,需要用其他方式模拟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import heapq

# 用dict+排序模拟TreeMap
tree_map = dict()
tree_map[5] = "这是5"
tree_map[7] = "这是7"
tree_map[1] = "这是1"
tree_map[2] = "这是2"
tree_map[3] = "这是3"
tree_map[4] = "这是4"
tree_map[8] = "这是8"

print(1 in tree_map) # True
print(10 in tree_map) # False
print(tree_map.get(4)) # 这是4
tree_map[4] = "张三是4"
print(tree_map.get(4)) # 张三是4

tree_map.pop(4, None) # 删除key为4的键值对
print(tree_map.get(4) is None) # True

有序表的特殊操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# firstKey 和 lastKey 需要排序
keys = sorted(tree_map.keys())
print(keys[0]) # firstKey: 1
print(keys[-1]) # lastKey: 8

# floorKey: 所有的key,<= 4且最近的key是什么
def floor_key(k):
return max([x for x in tree_map if x <= k], default=None)

# ceilingKey: 所有的key,>= 4且最近的key是什么
def ceiling_key(k):
return min([x for x in tree_map if x >= k], default=None)

print(floor_key(4)) # 3
print(ceiling_key(4)) # 5

操作解释

  • 删除前:1, 2, 3, [4], 5, 7, 8
  • 删除后:1, 2, 3, 5, 7, 8
  • floor_key(4):找到 ≤ 4 的最大值,可选值:[1, 2, 3],最大值:3
  • ceiling_key(4):找到 ≥ 4 的最小值,可选值:[5, 7, 8],最小值:5

TreeSet模拟

1
2
3
4
5
6
7
8
9
10
11
# TreeSet模拟:用set+排序(去重且有序)
s = set()
s.add(3)
s.add(3)
s.add(4)
s.add(4)
print("有序表大小:", len(s)) # 2,因为set里不会存储重复元素

# 以有序弹出
for item in sorted(s):
print(item) # 3 4

优先队列(堆)

小根堆

1
2
3
4
5
6
7
8
9
# 小根堆
heap1 = []
heapq.heappush(heap1, 3)
heapq.heappush(heap1, 3)
heapq.heappush(heap1, 4)
heapq.heappush(heap1, 4)
print("堆大小:", len(heap1)) # 4
while heap1:
print(heapq.heappop(heap1)) # 3 3 4 4

大根堆实现技巧

1
2
3
4
5
6
7
# 大根堆trick:使用负数
heap2 = []
for x in [3, 3, 4, 4]:
heapq.heappush(heap2, -x)
print("堆大小:", len(heap2)) # 4
while heap2:
print(-heapq.heappop(heap2)) # 4 4 3 3

核心技巧:Python的heapq只提供小根堆,要实现大根堆可以将所有元素取负数。

比较器的使用

基本比较器概念

任何比较器都有统一的规则:

  • 返回负数:认为第一个对象优先级更高
  • 返回正数:认为第二个对象优先级更高
  • 返回0:两个对象优先级相等

员工类的排序示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from functools import cmp_to_key

class Employee:
def __init__(self, company, age):
self.company = company
self.age = age

def __repr__(self):
return f"Employee({self.company}, {self.age})"

# 员工比较器,按年龄升序
def employee_comparator(o1, o2):
return o1.age - o2.age

# 创建员工对象
s1 = Employee(2, 27)
s2 = Employee(1, 60)
s3 = Employee(4, 19)
s4 = Employee(3, 23)
s5 = Employee(1, 35)
s6 = Employee(3, 55)
arr = [s1, s2, s3, s4, s5, s6]

# 按年龄升序排序
arr.sort(key=cmp_to_key(employee_comparator))
for e in arr:
print(f"{e.company}, {e.age}")

多种排序方式

1
2
3
4
5
6
7
8
9
# 按年龄降序排序(lambda表达式)
arr.sort(key=lambda x: -x.age)
for e in arr:
print(f"{e.company}, {e.age}")

# 多级排序:先按公司编号,再按年龄
arr.sort(key=lambda x: (x.company, x.age))
for e in arr:
print(f"{e.company}, {e.age}")

自定义对象的去重策略

按年龄去重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class EmployeeByAge(Employee):
def __eq__(self, other):
return self.age == other.age # 只看年龄

def __hash__(self):
return hash(self.age)

treeSet1 = set()
for e in arr:
treeSet1.add(EmployeeByAge(e.company, e.age))
print(len(treeSet1)) # 6

# 会去重,因为age一样的员工被认为是同一个
treeSet1.add(EmployeeByAge(2, 27))
print(len(treeSet1)) # 6

按多个属性去重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class EmployeeByAll(Employee):
def __eq__(self, other):
if not isinstance(other, EmployeeByAll):
return False
return (self.company == other.company and
self.age == other.age and
repr(self) == repr(other))

def __hash__(self):
return hash((self.company, self.age, repr(self)))

treeSet2 = set()
for e in arr:
treeSet2.add(EmployeeByAll(e.company, e.age))
print(len(treeSet2)) # 6

# 不会去重,因为repr不同(内存地址不同)
treeSet2.add(EmployeeByAll(2, 27))
print(len(treeSet2)) # 7

重要概念

  • __eq__方法:定义对象间的相等性比较,当使用==操作符时调用
  • __hash__方法:定义对象的哈希值,用于在集合和字典中作为键

字符串的字典序比较

1
2
3
4
5
6
7
8
9
# 字典序比较
str1 = "abcde"
str2 = "ks"
print(str1.__lt__(str2) - str1.__gt__(str2)) # -1
print(str2.__lt__(str1) - str2.__gt__(str1)) # 1

# 或者直接用比较运算符
print((str1 > str2) - (str1 < str2)) # -1
print((str2 > str1) - (str2 < str1)) # 1

字典序规则:按字符的ASCII码逐位比较,先遇到不同字符的位置决定大小关系。

数据结构选择指南

性能对比表

数据结构 查找 插入 删除 有序性 去重 适用场景
set O(1) O(1) O(1) 快速查重、集合运算
dict O(1) O(1) O(1) 键值映射、缓存
数组 O(1) O(1) O(1) key范围可控时替代哈希表
排序列表 O(log n) O(n) O(n) 需要有序且查找频繁
heapq O(1) O(log n) O(log n) 部分 优先队列、Top-K问题

使用建议

  1. 快速查找、去重:使用set
  2. 键值映射:使用dict
  3. key范围固定:考虑数组替代哈希表
  4. 需要有序:使用排序+二分查找或第三方库
  5. 优先队列:使用heapq
  6. 自定义排序:实现比较器或使用key参数

实际应用场景

场景1:统计词频

1
2
3
4
5
6
# 使用dict统计
text = "hello world hello python"
word_count = {}
for word in text.split():
word_count[word] = word_count.get(word, 0) + 1
print(word_count) # {'hello': 2, 'world': 1, 'python': 1}

场景2:去重并保持顺序

1
2
3
4
5
6
7
8
9
10
11
12
# 使用dict保持插入顺序的去重
def dedupe_keep_order(items):
seen = {}
result = []
for item in items:
if item not in seen:
seen[item] = True
result.append(item)
return result

items = [1, 2, 3, 2, 4, 1, 5]
print(dedupe_keep_order(items)) # [1, 2, 3, 4, 5]

场景3:Top-K问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import heapq

def find_top_k(nums, k):
# 使用小根堆,维护k个最大元素
heap = []
for num in nums:
if len(heap) < k:
heapq.heappush(heap, num)
elif num > heap[0]:
heapq.heapreplace(heap, num)
return sorted(heap, reverse=True)

nums = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
print(find_top_k(nums, 3)) # [9, 6, 5]

总结

  1. 哈希表:提供O(1)的查找、插入、删除,但有较大常数因子
  2. 有序表:在Python中需要模拟实现,适合需要有序性的场景
  3. 比较器:统一的优先级比较规则,负数表示第一个对象优先级更高
  4. 优化策略:key范围可控时用数组替代哈希表,性能更好
  5. 实际应用:根据具体需求选择合适的数据结构,考虑时间复杂度和空间复杂度的权衡

027【必备】堆结构常见题

Python heapq 模块详解

heapq 基本概念

heapq 是 Python 标准库提供的”二叉堆”实现工具,基于列表实现的最小堆(min-heap)。它能在 O(log n) 时间内插入与弹出最小元素,heap[0] 永远是当前最小值。

常用 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import heapq

# 基本操作
heapq.heappush(heap, item) # 压入元素,O(log n)
heapq.heappop(heap) # 弹出最小元素,O(log n)
heapq.heapify(lst) # 原地把列表转成堆,O(n)

# 高效组合操作
heapq.heappushpop(heap, item) # 先推入再弹出(更高效的一步)
heapq.heapreplace(heap, item) # 先弹出最小再推入

# 实用函数
heapq.nsmallest(n, iterable) # 返回n个最小元素
heapq.nlargest(n, iterable) # 返回n个最大元素
heapq.merge(*iterables) # 合并多个已排序可迭代对象(惰性生成)

大根堆实现技巧

由于 Python 的 heapq 只提供小根堆,实现大根堆需要使用负数技巧:

1
2
3
4
5
6
# 大根堆实现
heap = []
for x in [3, 1, 4, 1, 5]:
heapq.heappush(heap, -x) # 插入负数

max_val = -heapq.heappop(heap) # 弹出并转回正数

问题一:合并K个有序链表

测试链接https://www.nowcoder.com/practice/65cfde9e5b9b4cf2b6bafa5f3ef33fa6

问题描述

给定K个有序链表,将它们合并成一个有序链表。

算法思路

使用小根堆维护所有链表的当前头节点,每次取出值最小的节点:

  1. 初始化:将所有链表的头节点放入堆中
  2. 合并过程
    • 弹出堆顶最小节点,连接到结果链表
    • 如果该节点有下一个节点,将下一个节点入堆
    • 重复直到堆为空

合并k个有序链表-小根堆版

核心实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import heapq
from typing import List, Optional

class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next

def mergeKLists(arr: List[Optional[ListNode]]) -> Optional[ListNode]:
# 小根堆,存储节点及其值,用堆结构的话时间复杂度会比O(NlogN)更小
# Optional[T] 等价于 Union[T, None]
# 表示这个值可能是 T 类型,也可能是 None
heap = []

# 将所有链表头节点入堆
for h in arr:
if h is not None:
# Python的heapq不能直接比较对象,需包装
heapq.heappush(heap, (h.val, id(h), h))
# heapq 比较规则:它按元组字典序比较。你放入 (val, something, node) 时,先比 val,相等就比第二个元素;如果第二个也相等,就会去比第三个元素(即 ListNode 实例)。
# 问题:ListNode 没有定义大小比较,直接比较会抛出 TypeError: '<' not supported...
# 加入 id(node):id 是对象在该进程生命周期内的唯一“身份值”(可比较),作为“决胜键”保证元组能比较出大小,从而避免直接比较 ListNode。
if not heap:
return None

# 弹出一个节点作为总头部
_, _, h = heapq.heappop(heap)
pre = h

# 将该节点的下一个节点入堆
if pre.next is not None:
heapq.heappush(heap, (pre.next.val, id(pre.next), pre.next))

# 继续合并过程
while heap:
_, _, cur = heapq.heappop(heap)
pre.next = cur # 连接链表
pre = cur # pre后移
if cur.next is not None:
heapq.heappush(heap, (cur.next.val, id(cur.next), cur.next))

return h
# 对 heapq 来说,一个 item 就是一个可比较的对象。这里把一个三元组 (pre.next.val, id(pre.next), pre.next) 当作 item 放入堆。
# 比较顺序(字典序):
# 先比第 1 位 pre.next.val(值越小,优先级越高)
# 若相等,再比第 2 位 id(pre.next)(保证能比较出大小,避免去比较 ListNode 本身)
# 只在前两位都相等时,才会看第 3 位 pre.next(但通常不会用到,因为 id 已经唯一)

关键技术点

元组比较机制

  • heapq 按元组字典序比较:(val, something, node)
  • 先比较 val,相等时比较第二个元素
  • 加入 id(node) 作为”决胜键”,避免直接比较 ListNode 对象

为什么需要 id(node)

  • ListNode 没有定义大小比较,直接比较会抛出 TypeError
  • id() 返回对象的唯一标识符,可以进行比较
  • 保证元组能比较出大小,避免直接比较 ListNode

复杂度分析

  • 时间复杂度:O(N log K),其中 N 是所有节点总数,K 是链表个数
  • 空间复杂度:O(K),堆中最多存储 K 个节点
    合并k个有序链表-复杂度分析

问题二:最多线段重合问题

测试链接

问题描述

给定n条线段,每条线段有起点和终点,求最多同时重合的线段数量。

算法思路

使用扫描线算法 + 小根堆

  1. 排序:将所有线段按起点排序
  2. 扫描:从左到右处理每条线段
  3. 维护堆:堆中存储当前重合线段的结束时间
  4. 清理:移除已结束的线段(结束点 ≤ 当前起点)
  5. 更新:添加当前线段,更新最大重合数

最大重合线段数

核心实现

  • 想象每条线段根据开始和结束位置,放在x轴上,然后有一根竖线,从左到右划过x轴,竖线压中的线段上的点,就需要把当前点放入到某个组里。

  • 请问,从左到右划的过程,你最多需要准备几个组?最多重合几条线段,就需要几个组。这些组不断复用空间,放入不同的点。但是最大重合了多少,你就需要准备几个组。

  • 重要:我之前一直在纠结为什么不要考虑后面重复合并, 但是我们这个代码已经考虑了后面重复合并,因为我们是把结束点加入到堆里,而不是把线段加入到堆里。因此即便这条线段的结束点和后面的线段的开始点重合,我们也会把这条线段的结束点加入到堆里,则以这个为标准对数轴进行扫描,又因为小根堆是根据开始点进行从小大排序的,所以能够保证后面的线段初始点严格小于当前线段的初始点,不会忽略到后面的线段重合,这个是个离散的逐点扫描的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import heapq

def compute():
"""
# 时间复杂度:O(NlogN) ,n条线段,平均比较次数是logN,所以是O(NlogN)
# 空间复杂度:O(N),因为需要存储每条线段的结束点"""
global size
size = 0 # 堆的清空

# 所有线段根据开始位置排序
sorted_lines = sorted(line[:n], key=lambda x: x[0])

ans = 0 # 最大重合数
for i in range(n):
# 步骤1:清理已结束的线段
while size > 0 and heap[0] <= sorted_lines[i][0]:
# 堆顶是最早结束的线段
# 如果它的结束点 ≤ 当前线段的起点,说明已经不重合了
pop() # 移除这个已结束的线段

# 步骤2:加入当前线段
add(sorted_lines[i][1]) # 把当前线段的结束点加入堆

# 步骤3:更新答案
ans = max(ans, size) # 当前堆的大小 = 当前重合的线段数

return ans

算法可视化

想象一根竖线从左到右扫过数轴:

1
2
3
4
5
6
7
8
9
10
线段1: |-------|
线段2: |-----|
线段3: |---|
线段4: |-----|

扫描过程:
时刻1: | 重合数 = 1
时刻2: | 重合数 = 2
时刻3: | 重合数 = 3 (最大值)
时刻4: | 重合数 = 2

手写堆实现

为了提高效率,使用数组实现小根堆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
MAXN = 10001
heap = [0] * MAXN # 存储结束点
size = 0

def add(x):
global size
heap[size] = x
i = size
size += 1
# 上浮调整
while i > 0 and heap[i] < heap[(i - 1) // 2]:
swap(i, (i - 1) // 2)
i = (i - 1) // 2

def pop():
global size
swap(0, size - 1)
size -= 1
i = 0
l = 1
# 下沉调整
while l < size:
best = l + 1 if l + 1 < size and heap[l + 1] < heap[l] else l
best = best if heap[best] < heap[i] else i
if best == i:
break
swap(i, best)
i = best
l = i * 2 + 1

def swap(i, j):
heap[i], heap[j] = heap[j], heap[i]

变种问题

会议室问题

1
2
3
4
5
6
7
8
9
10
11
12
13
def minMeetingRooms(meeting):
n = len(meeting)
meeting.sort(key=lambda x: x[0]) # 按起始时间排序
heap = [] # 小根堆,会议的结束时间
ans = 0

for i in range(n):
while heap and heap[0] <= meeting[i][0]: # 堆顶会议已结束
heapq.heappop(heap)
heapq.heappush(heap, meeting[i][1]) # 当前会议结束时间入堆
ans = max(ans, len(heap)) # 记录最大会议室数量

return ans

分组问题

1
2
3
4
5
6
7
8
9
10
11
12
13
def minGroups(meeting):
n = len(meeting)
meeting.sort(key=lambda x: x[0])
heap = []
ans = 0

for i in range(n):
while heap and heap[0] < meeting[i][0]: # 注意此处是 <,题意不同
heapq.heappop(heap)
heapq.heappush(heap, meeting[i][1])
ans = max(ans, len(heap))

return ans

关键区别:会议室问题用 <=(会议可以无缝衔接),分组问题用 <(需要间隔)。

复杂度分析

  • 时间复杂度:O(N log N),排序 + 每个元素平均 log N 次堆操作
  • 空间复杂度:O(N),存储线段结束点的堆

问题三:将数组和减半的最少操作次数

测试链接https://leetcode.cn/problems/minimum-operations-to-halve-array-sum/

问题描述

给定一个数组,每次操作可以将任意一个元素减半,求使数组总和减少到原来一半所需的最少操作次数。

算法思路

使用贪心策略 + 大根堆

  1. 贪心原理:每次选择当前最大的元素进行减半,减少量最大
  2. 堆维护:用大根堆维护当前所有元素
  3. 操作过程:不断取出最大元素减半,直到总减少量达到目标

方法一:基于 heapq

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import heapq

def halveArray1(nums):
# 大根堆(用负数模拟)
heap = []
sum_val = 0

for num in nums:
heapq.heappush(heap, -float(num)) # 负数模拟大根堆
sum_val += num

sum_val /= 2 # 要减少到总和一半
ans = 0 # 操作次数
minus = 0 # 已减少的和

while minus < sum_val:
cur = -heapq.heappop(heap) / 2 # 取出最大数、减半
heapq.heappush(heap, -cur) # 新值放回堆
minus += cur # 记录减少量
ans += 1 # 操作次数加1

return ans

方法二:手写堆 + 精度优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
MAXN = 100001
heap_arr = [0] * MAXN
size = 0

def halveArray2(nums):
global size
size = len(nums)
sum_val = 0

# 初始化大根堆,左移20位保证精度
for i in range(size - 1, -1, -1):
heap_arr[i] = int(nums[i]) << 20 # 左移20位放大
sum_val += heap_arr[i]
heapify(i) # 调整堆

sum_val //= 2 # 目标减少量
ans = 0
minus = 0

while minus < sum_val:
heap_arr[0] //= 2 # 堆顶减半
minus += heap_arr[0]
heapify(0)
ans += 1

return ans

def heapify(i):
global size
l = i * 2 + 1
while l < size:
best = l + 1 if l + 1 < size and heap_arr[l + 1] > heap_arr[l] else l
best = best if heap_arr[best] > heap_arr[i] else i
if best == i:
break
swap(best, i)
i = best
l = i * 2 + 1

def swap(i, j):
heap_arr[i], heap_arr[j] = heap_arr[j], heap_arr[i]

综合减少一半最少操作-优化方法

关键优化技术

精度处理

问题:浮点数运算有精度损失,可能导致结果错误

解决方案

  • 将所有数左移20位(乘以 2²⁰)
  • 用整数运算模拟浮点数运算
  • int(32位) × 2²⁰ < long(64位),不会溢出

性能优化

手写堆 vs heapq

  • heapq 基于 Python 列表,有额外开销
  • 手写数组堆效率更高,常数因子更小

算法正确性证明

贪心策略的正确性

  • 每次选择最大元素减半,获得的减少量最大
  • 假设最优解不是每次选最大元素,可以证明交换操作不会使结果变差
  • 因此贪心策略能得到最优解

复杂度分析

  • 时间复杂度:O(N log N),每个元素最多被操作 log(max_value) 次。若傻傻地每个元素都缩1/2,一定能达成目标;显然操作的元素个数一定小于n
  • 空间复杂度:O(N),存储堆的空间

堆结构应用总结

适用场景

问题类型 堆类型 核心思想 典型例题
合并有序序列 小根堆 维护多个序列的当前最小值 合并K个有序链表
区间重合问题 小根堆 扫描线 + 维护结束时间 最多线段重合
贪心选择 大根堆 每次选择当前最值进行操作 数组和减半
Top-K问题 小根堆 维护K个最大值 第K大元素
任务调度 小根堆 按优先级处理任务 CPU任务调度

实现选择

情况 推荐实现 原因
原型开发 heapq 简单易用,标准库
性能要求高 手写堆 效率更高,常数因子小
需要大根堆 负数技巧 Python只有小根堆
精度要求高 整数模拟 避免浮点数误差

常见陷阱

  1. 对象比较问题:自定义对象作为堆元素时,需要实现比较方法或使用包装
  2. 精度问题:浮点数运算可能有误差,关键场合使用整数
  3. 堆类型混淆:Python默认小根堆,大根堆需要取负数
  4. 边界条件:空堆操作、单元素情况需要特殊处理

性能优化技巧

  1. 预分配空间:手写堆时预分配数组空间
  2. 避免频繁内存操作:使用数组而非动态列表
  3. 批量操作heapify 比逐个插入效率高
  4. 精度与性能平衡:根据需求选择合适的数据类型

堆结构是解决很多算法问题的重要工具,在实际使用中,要根据具体问题选择合适的实现方式和优化策略。