很多同学在面试算法题时,看到题目没思路;或者写出来了,代码边界条件一塌糊涂。高级算法题通常不是“凭空发明”出来的,而是由基础模板演化而来的。 学习地址:pan.baidu.com/s/1WwerIZ_elz_FyPKqXAiZCA?pwd=waug
我们将从学习方法和实战代码两个维度,通过一道经典的困难题(LRU缓存机制)来拆解。
一、 学习方法的“三板斧”
不要一上来就死磕难题,建立知识体系是关键:
-
由点及面,分类刷题:
- 不要随机刷题!比如本周只刷“二叉树”,下周只刷“动态规划”。将同一类题型的套路内化成肌肉记忆。
-
重视数据结构的选择:
- 遇到题目先问自己:什么数据结构最合适?链表适合增删,数组适合查找,Hash表适合O(1)查找,堆适合TopK问题。
-
代码的鲁棒性:
- Bug-free coding 是进阶的关键。写代码前先想好 corner case(空指针、边界值、负数)。
二、 实战演练:搞定经典困难题——LRU 缓存机制
这是 LeetCode 第 146 题(困难),也是字节跳动、腾讯等大厂的高频面试题。它考察的是哈希表与双向链表的完美结合。
题目描述
设计并实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作:获取数据 get 和写入数据 put。
get(key):如果密钥存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。put(key, value):如果密钥不存在,则写入数据。当缓存容量达到上限时,它应该在写入新数据之前删除最近最少使用的数据。
代码实现(Python 解析版)
为了满足 O(1) 的时间复杂度,我们需要:
- Hash Map:快速定位节点。
- Double Linked List:快速进行节点的插入和删除,并维护访问顺序。
python
复制
class DLinkedNode:
def __init__(self, key=0, value=0):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = {} # map key to node
# 使用伪头部和伪尾部节点,避免边界检查(这是一个重要的编程技巧)
self.head = DLinkedNode()
self.tail = DLinkedNode()
self.head.next = self.tail
self.tail.prev = self.head
def _add_node(self, node):
"""将节点添加到头部(表示最近被使用)"""
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def _remove_node(self, node):
"""移除指定节点"""
prev = node.prev
new_next = node.next
prev.next = new_next
new_next.prev = prev
def _move_to_head(self, node):
"""将节点移动到头部"""
self._remove_node(node)
self._add_node(node)
def _pop_tail(self):
"""移除并返回尾部节点(表示最久未使用)"""
res = self.tail.prev
self._remove_node(res)
return res
def get(self, key: int) -> int:
node = self.cache.get(key, None)
if not node:
return -1
# 关键一步:被访问了,移动到头部
self._move_to_head(node)
return node.value
def put(self, key: int, value: int) -> None:
node = self.cache.get(key)
if not node:
# 如果 key 不存在,创建新节点
new_node = DLinkedNode(key, value)
self.cache[key] = new_node
self._add_node(new_node)
# 如果超出容量,删除尾部节点
if len(self.cache) > self.capacity:
tail = self._pop_tail()
del self.cache[tail.key]
else:
# 如果 key 存在,更新 value 并移动到头部
node.value = value
self._move_to_head(node)
# 测试代码
if __name__ == "__main__":
cache = LRUCache(2) # 容量为 2
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1)) # 返回 1
cache.put(3, 3) # 该操作会使得密钥 2 作废
print(cache.get(2)) # 返回 -1 (未找到)
cache.put(4, 4) # 该操作会使得密钥 1 作废
print(cache.get(1)) # 返回 -1 (未找到)
print(cache.get(3)) # 返回 3
print(cache.get(4)) # 返回 4
三、 代码解析与进阶技巧
这道题之所以是困难题,不是因为你没见过,而是因为链表操作细节繁琐。以下是几个进阶编程技巧:
- 伪头节点:
在代码中,我定义了self.head和self.tail作为虚拟节点。这样在插入和删除时,永远不需要判断node.next是否为None,这极大地减少了if-else判断,降低了 Bug 率。 - 封装辅助函数:
不要把所有逻辑都塞在get和put里。将remove_node、add_node等操作封装起来,逻辑会像搭积木一样清晰,面试官也能一眼看懂你的思路。 - Hash Map 的价值:
如果没有 Hash Map,删除链表某个节点需要 O(N) 去找它。有了self.cache[key] = node的映射,查找就是 O(1),这决定了算法的档次。
总结
从中初级到高级的跨越,在于数据结构的组合应用能力。LRU 只是一个开始,后续还可以尝试 LFU(更复杂的频率统计)以及跳表、Tire 树等高级结构。
记住:先画图,理清指针关系;再动手,注意边界条件。 照着这个节奏,你会发现算法面试其实并不可怕。