0Pricing
DSA Interview Prep · 课时

最长连续序列与 LRU Cache

使用集合以 O(n) 的时间解决最长连续序列问题,然后使用 OrderedDict 设计 LRU Cache。

最长连续序列与 LRU Cache 是 CoddyKit 上的免费 DSA Interview Prep 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 DSA Interview Prep 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 DSA Interview Prep 课程共包含 4 节课。

最长连续序列问题

LeetCode 128“最长连续序列”:给定一个未排序的数组,找出最长连续整数序列的长度。例如,[100,4,200,1,3,2] 包含长度为 4 的连续序列 [1,2,3,4]。挑战在于要在O(n) 而不是 O(n log n) 时间内解决问题(后者是先排序再扫描的复杂度)。

关键思路是:使用集合进行 O(1) 成员检查,并且只从序列的最小元素开始计数(通过检查前驱元素不在集合中来识别最小元素)。

def longestConsecutive(nums):
    num_set = set(nums)
    best    = 0
    for n in num_set:
        if n - 1 not in num_set:   # n is the start of a sequence
            curr_n = n
            length = 1
            while curr_n + 1 in num_set:
                curr_n += 1
                length += 1
            best = max(best, length)
    return best

print(longestConsecutive([100,4,200,1,3,2]))   # 4
print(longestConsecutive([0,3,7,2,5,8,4,6,0,1]))  # 9

为什么 O(n) 的证明成立

在外层 for 循环的所有迭代中,每个数字最多只会在 while 循环中被访问一次。虽然 for 循环内部包含一个 while 循环,但所有外层迭代中的 while 循环迭代总数最多为 n(因为每个数字最多只能作为一个序列的“curr_n + 1”)。这一摊销分析使整体复杂度达到 O(n),与单调栈的分析类似。

# Demonstrate O(n) total inner iterations
nums    = list(range(1000))  # worst case: one long sequence
num_set = set(nums)
inner_iters = 0
for n in num_set:
    if n - 1 not in num_set:
        curr = n
        while curr + 1 in num_set:
            curr += 1
            inner_iters += 1
print('n =', len(nums), '  total inner iterations =', inner_iters)
# inner_iters = n-1 <= n => O(n)

替代方案:基于排序的方法

作为对比,排序后扫描的方法运行复杂度为 O(n log n):先对数组排序,去除连续重复项,然后统计连续区间。虽然速度较慢,但它使用 O(1) 额外空间(如果原地排序)。基于集合的方法使用 O(n) 额外空间。在面试中可以同时说明这两种方法,并根据空间限制阐明 O(n log n) 的方案是否可以接受。

def longestConsecutive_sort(nums):
    if not nums:
        return 0
    nums.sort()
    best = length = 1
    for i in range(1, len(nums)):
        if nums[i] == nums[i-1]:
            continue              # skip duplicates
        if nums[i] == nums[i-1] + 1:
            length += 1
            best = max(best, length)
        else:
            length = 1
    return best

print(longestConsecutive_sort([100,4,200,1,3,2]))  # 4

什么是 LRU 缓存

LRU(最近最少使用)缓存是一种固定容量的数据结构。当缓存已满且需要插入新项目时,它会逐出最近最少使用的项目。操作包括:如果键存在,get(key) 返回对应值(并将其标记为最近使用);如果键不存在,则返回 -1;put(key, value) 插入键值对(达到容量上限时逐出 LRU 项目)。

LRU 缓存用于操作系统(页面置换)、浏览器缓存和数据库查询缓存。LeetCode 146 要求您使用 O(1) 的 get 和 put 实现一个 LRU 缓存。

使用 OrderedDict 实现 LRU 缓存

Python 的 collections.OrderedDict 维护插入顺序,并支持使用 move_to_end(key)(O(1))将项目标记为最近使用。在执行 put 时,将键移动到末尾;溢出时,弹出第一个项目(LRU 项目)。这样便可使用一个内部由双向链表和哈希映射支持的内置结构,以 O(1) 复杂度完成 get 和 put。

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache    = OrderedDict()

    def get(self, key):
        if key not in self.cache:
            return -1
        self.cache.move_to_end(key)  # mark as recently used
        return self.cache[key]

    def put(self, key, value):
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            self.cache.popitem(last=False)  # evict LRU (first item)

cache = LRUCache(2)
cache.put(1, 1); cache.put(2, 2)
print(cache.get(1))  # 1 (and 1 becomes most recently used)
cache.put(3, 3)      # evict key 2 (LRU)
print(cache.get(2))  # -1
cache.put(4, 4)      # evict key 1 (LRU)
print(cache.get(1))  # -1
print(cache.get(3))  # 3
print(cache.get(4))  # 4

从零实现 LRU 缓存:双向链表 + HashMap

从零实现的方案使用双向链表(支持 O(1) 节点删除)和哈希映射(通过键以 O(1) 查找节点)。链表按照从 LRU(head.next)到 MRU(tail.prev)的顺序维护项目。虚拟头节点和尾节点充当哨兵,可以消除在边界处插入和删除时的特殊情况。

class DNode:
    def __init__(self, key=0, val=0):
        self.key  = key
        self.val  = val
        self.prev = None
        self.next = None

class LRUCacheDLL:
    def __init__(self, capacity):
        self.cap  = capacity
        self.map  = {}   # key -> DNode
        self.head = DNode()   # dummy LRU end
        self.tail = DNode()   # dummy MRU end
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def _add_to_tail(self, node):
        node.prev = self.tail.prev
        node.next = self.tail
        self.tail.prev.next = node
        self.tail.prev = node

    def get(self, key):
        if key not in self.map:
            return -1
        node = self.map[key]
        self._remove(node)
        self._add_to_tail(node)
        return node.val

    def put(self, key, val):
        if key in self.map:
            self._remove(self.map[key])
        node = DNode(key, val)
        self._add_to_tail(node)
        self.map[key] = node
        if len(self.map) > self.cap:
            lru = self.head.next
            self._remove(lru)
            del self.map[lru.key]

cache = LRUCacheDLL(2)
cache.put(1,1); cache.put(2,2)
print(cache.get(1))  # 1
cache.put(3,3)
print(cache.get(2))  # -1 (evicted)

为什么 LRU 使用双向链表

如果不知道前驱节点,单向链表就无法在 O(1) 时间内删除任意节点。双向链表同时存储 prev 和 next 指针,因此只要给定节点引用,就能在 O(1) 时间内完成删除。哈希映射则提供通过键以 O(1) 访问节点的能力。两者结合后:get(key) 需要 O(1) 查找节点,并需要 O(1) 将其移动到尾部;put(key) 需要 O(1) 添加节点,并需要 O(1) 从头部删除 LRU 节点。

# Why not a singly linked list?
# To remove a node you need its predecessor
# With SLL: must traverse from head to find predecessor => O(n)
# With DLL: node.prev IS the predecessor => O(1) removal

print('SLL removal: O(n) — must find predecessor by traversal')
print('DLL removal: O(1) — node.prev is immediately available')
print('Hash map lookup: O(1) — get DNode reference by key')
print('Combined LRU get/put: O(1) average')

LFU 缓存(最不经常使用)

更复杂的一种变体是 LFU 缓存(LeetCode 460),其中访问次数最少的项目会被逐出。如果访问次数相同,则按最近使用情况打破平局(在访问频率最低的项目中,逐出最近最少使用的项目)。实现需要三种数据结构:键到值的映射、键到频率的映射,以及频率到 OrderedDict 的映射(用于维护每个频率分组内的插入顺序)。LFU 的 get 和 put 的摊销复杂度为 O(1)。

from collections import defaultdict, OrderedDict

class LFUCache:
    def __init__(self, capacity):
        self.cap   = capacity
        self.min_f = 0
        self.kv    = {}   # key -> val
        self.kf    = {}   # key -> freq
        self.fk    = defaultdict(OrderedDict)  # freq -> {key: None}

    def _touch(self, key):
        f = self.kf[key]
        self.kf[key] = f + 1
        del self.fk[f][key]
        if not self.fk[f] and f == self.min_f:
            self.min_f += 1
        self.fk[f+1][key] = None

    def get(self, key):
        if key not in self.kv:
            return -1
        self._touch(key)
        return self.kv[key]

    def put(self, key, val):
        if self.cap == 0: return
        if key in self.kv:
            self.kv[key] = val
            self._touch(key)
        else:
            if len(self.kv) == self.cap:
                lfu_key, _ = self.fk[self.min_f].popitem(last=False)
                del self.kv[lfu_key]; del self.kf[lfu_key]
            self.kv[key] = val; self.kf[key] = 1
            self.fk[1][key] = None; self.min_f = 1

设计模式:哈希映射 + 链表

LRU 缓存展示了一种强大的设计模式:将用于 O(1) 键查找的哈希映射,与用于 O(1) 有序操作的链表结合起来。这种模式会出现在多个面试设计问题中:LRU 缓存、LFU 缓存、跳表以及某些队列变体。只要问题同时要求 O(1) 查找和 O(1) 基于顺序的操作,就可以考虑这种组合。

在面试中明确说明这一模式,可以体现您进行系统级思考的能力,以及对经典数据结构组合的熟悉程度。

矩阵中的连续序列

这是将连续序列思想扩展到二维的情况:给定一个整数矩阵,找出能够沿路径追踪的最长连续序列的长度(每一步移动到相邻单元格)。这会结合 BFS/DFS 与连续序列的集合方法。存储每个值的位置,然后对于每个起始值,检查值加 1 的元素是否存在于相邻位置。

# Simpler: find longest consecutive values in a 2D matrix (no adjacency)
def longestConsecutiveMatrix(matrix):
    all_vals = set()
    for row in matrix:
        for v in row:
            all_vals.add(v)
    best = 0
    for v in all_vals:
        if v - 1 not in all_vals:  # start of sequence
            length = 0
            while v in all_vals:
                v += 1
                length += 1
            best = max(best, length)
    return best

m = [[1, 5, 3], [4, 6, 2], [8, 7, 9]]
print(longestConsecutiveMatrix(m))  # 9 (1..9 all present)

面试总结:集合 + HashMap 的力量

这两个问题体现了一个共同主题:使用正确的哈希结构,将 O(n log n) 或 O(n²) 问题转化为 O(n) 问题。最长连续序列使用集合以 O(1) 回答“前驱是否存在?”;LRU 缓存使用哈希映射即时找到节点,再使用双向链表以 O(1) 更新顺序。两者都用 O(1) 的成员检查或查找替代了缓慢的遍历。

当面试官问“您能做到优于 O(n log n) 吗?”时,答案几乎总是“使用哈希映射或哈希集合来避免排序”。

快速检查

请测试您对本课数据结构与算法 — 编程面试准备相关概念的理解。

课程回顾

本课您学到了:最长连续序列通过使用集合进行 O(1) 成员检查,并且只从序列起点开始计数,从而以 O(n) 运行,LRU 缓存通过 OrderedDict(或从零实现的哈希映射加双向链表)以 O(1) 完成 get 和 put,以及哈希映射加链表模式是构建对顺序敏感的 O(1) 数据结构时可复用的基础模块。接下来我们将使用基本情况、信任和构建框架重新学习递归。

常见问题解答

「最长连续序列与 LRU Cache」课时是免费的吗?

是的 — 「最长连续序列与 LRU Cache」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 DSA Interview Prep 课程的其余内容,请升级到 CoddyKit PRO。 DSA Interview Prep 课程共包含 4 节课。

「最长连续序列与 LRU Cache」这节课中我会学到什么?

使用集合以 O(n) 的时间解决最长连续序列问题,然后使用 OrderedDict 设计 LRU Cache。 你通过在浏览器中直接运行的动手代码来练习 DSA Interview Prep,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 DSA Interview Prep 需要有经验吗?

无需任何先前经验。CoddyKit 上的 DSA Interview Prep 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。

「最长连续序列与 LRU Cache」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 DSA Interview Prep 课中编写并运行代码吗?

能。每节 DSA Interview Prep 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 哈希函数原理与冲突处理
  2. 两数之和及其多种变体
  3. 频率统计与分组
  4. 最长连续序列与 LRU Cache
← 返回 DSA Interview Prep