卷一 · 算法与手撕 · 高频面试题

通用软件工程师 / FDE 高频面试题库 · 第一卷 题源:CodeTop / LeetCodeTop(LeetCode 题频次)+ 牛客网公开面经(notes/corpus.jsonl,85 份独立面试经历帖) 题目统计:普通问答 6 · 手撕 34 · N 备选 0 · 合计 40 个条目。

难度分布(全卷,含手撕):L1 11 · L2 22 · L3 7

频次口径(两套,互不混用):

  • Q01–Q30(LeetCode 30 题)的 🔥×N 来自 CodeTop / LeetCodeTop 聚合统计(头部互联网公司近一年 coding 面经),数值存于 notes/codetop.json,由 tools/fill_freq.py 查表回填,不是本仓库语料的计数。
  • 其余各题的 🔥×N 来自 牛客网独立面经帖命中数(同义题合并后计),每题正文带 <!-- src: ... --> 检索键,由 tools/fill_freq.py --check 在 CI 中机械校验。
  • 低频实录·1源 = 只有 1 份独立面经支撑;补充 = 非面经的工程题 / 整理题,不伪造频次。

使用方式:题目默认折叠,点开看答案。建议按 L1 → L2 → L3 顺序刷;同级内按频次从高到低。手机端原生支持。项目类 / 场景类问题的背景已用自然语言写进题干;「追问」列出常见后续考点。

与其他卷的关系:本卷只收语言无关的算法与数据结构能力——coding 一面不问岗位,行业不限。手撕题给"考察点 / 实现 / 易错"三段,代码按纯 Python 写(≤30 行),可本地跑。


01 手撕高频 30 题 · LeetCode(按聚合频次排序)

频次来源:CodeTop / LeetCodeTop 对头部互联网公司近一年面经 coding 环节的聚合统计。 每题给"考察点 / 实现 / 易错",附 ≤30 行纯 Python。

L2 🔥×30 Q01 · LC3 无重复字符的最长子串

考察点:滑动窗口;哈希记字符最后一次位置;左指针只能右移(用 max 防回退)。

实现:

def length_of_longest_substring(s):
    last = {}      # 字符 -> 最后出现下标
    l, ans = 0, 0
    for r, c in enumerate(s):
        # 关键:max(l, last[c]+1);last[c] 可能已在窗口外,l 不能回退
        if c in last and last[c] >= l:
            l = last[c] + 1
        last[c] = r
        ans = max(ans, r - l + 1)
    return ans

易错:忘 max 致 l 回退;用 set 删旧元素逻辑复杂;用 l = last[c]+1 不判区间漏过窗口外旧位置。

L1 🔥×25 Q02 · LC206 反转链表

考察点:三指针迭代 O(1) 空间;递归 O(n) 栈空间;断尾防成环。

实现:

def reverse_list(head):
    prev, cur = None, head
    while cur:
        nxt = cur.next      # 先存下一节点,否则反转后丢失
        cur.next = prev     # 反转当前节点指针
        prev, cur = cur, nxt
    return prev             # prev 为新头

def reverse_recursive(head):
    if not head or not head.next:  # 空或单节点直接返回(base case)
        return head
    new_head = reverse_recursive(head.next)
    head.next.next = head   # 让下一节点指回当前
    head.next = None        # 断尾防成环
    return new_head

易错:忘 head.next=None 致成环;指针顺序错(先改 next 再保存致丢失);递归 base case 漏判 head 空。

L2 🔥×20 Q03 · LC15 三数之和

考察点:排序 + 双指针;三处去重(i / l / r);命中后双指针必须同时移动。

实现:

def three_sum(nums):
    nums.sort()
    n, res = len(nums), []
    for i in range(n - 2):
        if nums[i] > 0:
            break                       # 已排序,最小值 > 0 则无解
        if i > 0 and nums[i] == nums[i - 1]:
            continue                    # 去重 i:同一起点只算一次
        l, r = i + 1, n - 1
        while l < r:
            s = nums[i] + nums[l] + nums[r]
            if s < 0:
                l += 1
            elif s > 0:
                r -= 1
            else:
                res.append([nums[i], nums[l], nums[r]])
                l += 1
                r -= 1
                while l < r and nums[l] == nums[l - 1]:
                    l += 1              # 去重 l
                while l < r and nums[r] == nums[r + 1]:
                    r -= 1              # 去重 r
    return res

易错:只去重 i 漏掉 l/r;命中后只移一个指针;忘记排序导致去重失效。

L2 🔥×18 Q04 · LC146 LRU 缓存机制

考察点:哈希表 + 双向链表,get/put 均摊 O(1);容量为 0 的边界。

实现:

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

class LRUCache:
    def __init__(self, capacity: int):
        self.cap = capacity
        self.map = {}
        self.head = Node()              # 哨兵头
        self.tail = Node()              # 哨兵尾
        self.head.next, self.tail.prev = self.tail, self.head

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

    def _add_front(self, node):
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node

    def get(self, key):
        if key not in self.map:
            return -1
        node = self.map[key]
        self._remove(node); self._add_front(node)   # 访问即刷新
        return node.val

    def put(self, key, value):
        if key in self.map:
            self._remove(self.map[key])
            del self.map[key]
        if self.cap <= 0:
            return
        node = Node(key, value)
        self.map[key] = node
        self._add_front(node)
        if len(self.map) > self.cap:
            lru = self.tail.prev        # 尾部即最久未用
            self._remove(lru)
            del self.map[lru.key]

易错:用普通 list 做 LRU 致 O(n) 查删;忘记维护 map 与链表一致;容量 0 时 put 仍插入。

L2 🔥×15 Q05 · LC215 数组中第 K 个最大元素

考察点:快速选择(Quickselect)平均 O(n)、最坏 O(n²);或大小为 K 的小顶堆 O(n log k)。

实现:

import random

def find_kth_largest(nums, k):
    def select(lo, hi, target):
        while lo < hi:
            p = random.randint(lo, hi)
            nums[p], nums[hi] = nums[hi], nums[p]
            pivot = nums[hi]
            i = lo
            for j in range(lo, hi):
                if nums[j] < pivot:
                    nums[i], nums[j] = nums[j], nums[i]
                    i += 1
            nums[i], nums[hi] = nums[hi], nums[i]
            if i == target:
                return nums[i]
            if i < target:
                lo = i + 1
            else:
                hi = i - 1
        return nums[lo]
    return select(0, len(nums) - 1, len(nums) - k)  # 第 K 大 = 升序下标 n-k

def find_kth_largest_heap(nums, k):
    import heapq
    h = nums[:k]
    heapq.heapify(h)                 # 小顶堆,堆顶是这 k 个里最小的
    for x in nums[k:]:
        if x > h[0]:
            heapq.heapreplace(h, x)
    return h[0]

易错:第 K 大误当第 K 小;下标写 k-1 而非 n-k;随机化 pivot 缺失致最坏 O(n²)。

L1 🔥×14 Q06 · LC53 最大子序和

考察点:Kadane 算法;cur = max(x, cur + x) 的取舍即"重新开始 vs 延续"。

实现:

def max_subarray(nums):
    cur = best = nums[0]
    for x in nums[1:]:
        cur = max(x, cur + x)   # 前面是负的就丢弃,从 x 重开
        best = max(best, cur)
    return best

易错:初始化 cur = 0 致全负数组返回 0(错,应为最大负数);漏掉 best 单独维护。

L3 🔥×12 Q07 · LC25 K 个一组翻转链表

考察点:dummy 哨兵;不足 K 个保持原序;接回时四个边界指针更新。

实现:

def reverse_k_group(head, k):
    dummy = type(head)(0) if head else None
    if not dummy:
        return None
    dummy.next = head
    pre = dummy                          # 上一组的末尾
    while True:
        # 先看后面是否还有 K 个节点;不足 K 直接 break(题目要求保留原序)
        tail = pre
        for _ in range(k):
            tail = tail.next
            if not tail:
                return dummy.next
        nxt_group = tail.next            # 下一组起点,反转前先存
        # 反转 [pre.next, tail] 这一段
        prev, cur = nxt_group, pre.next
        while cur != nxt_group:
            tmp = cur.next
            cur.next = prev
            prev, cur = cur, tmp
        # 接回:原 head 现在是这一组的尾
        new_tail = pre.next
        pre.next = prev                  # 新头接到 pre 后
        pre = new_tail                   # pre 推进到本组末尾

易错:探测不足 k 个时仍翻转;组尾接回时把新旧头弄反;虚拟头未参与最终返回。

L1 🔥×12 Q08 · LC121 买卖股票的最佳时机

考察点:一次遍历维护历史最低价;收益 = 当前价 − 最低价。

实现:

def max_profit(prices):
    if not prices:
        return 0
    low, best = prices[0], 0
    for p in prices[1:]:
        best = max(best, p - low)   # 先算收益,再用当前价更新最低
        low = min(low, p)
    return best

易错:先更新 low 再算收益(同日买卖);允许多次交易当成单次。

L3 🔥×11 Q09 · LC42 接雨水

考察点:双指针;max(left_max, right_max) 与 min(left_max, right_max) - h 的推导。

实现:

def trap(height):
    if not height:
        return 0
    l, r = 0, len(height) - 1
    lmax = rmax = 0
    water = 0
    while l < r:
        if height[l] < height[r]:
            lmax = max(lmax, height[l])
            water += lmax - height[l]   # 左侧水位由 lmax 决定
            l += 1
        else:
            rmax = max(rmax, height[r])
            water += rmax - height[r]
            r -= 1
    return water

易错:写成 max(lmax, rmax) - h(应为 min);循环条件用 l <= r 致重复计数。

L2 🔥×11 Q10 · LC102 二叉树层序遍历

考察点:队列按层;用 len(queue) 固定本层节点数。

实现:

from collections import deque

def level_order(root):
    if not root:
        return []
    res, q = [], deque([root])
    while q:
        n = len(q)              # 固定本层节点数
        level = []
        for _ in range(n):
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        res.append(level)
    return res

易错:不固定 len(q) 致分层错乱;忘判 root 为空。

L1 🔥×10 Q11 · LC1 两数之和

考察点:哈希表一趟解;边遍历边查"补数是否已出现"。

实现:

def two_sum(nums, target):
    seen = {}
    for i, x in enumerate(nums):
        if target - x in seen:      # 先查后存,避免自己和自己配
            return [seen[target - x], i]
        seen[x] = i
    return []

易错:先存后查致 x + x == target 时返回同一元素两次;返回的是值而非下标。

L2 🔥×10 Q12 · LC200 岛屿数量

考察点:DFS/BFS 洪水填充;访问标记原地改写避免额外 set。

实现:

def num_islands(grid):
    if not grid or not grid[0]:
        return 0
    R, C = len(grid), len(grid[0])
    count = 0
    for i in range(R):
        for j in range(C):
            if grid[i][j] != '1':
                continue
            count += 1
            stack = [(i, j)]
            grid[i][j] = '0'          # 入栈即标,防重复入栈
            while stack:
                x, y = stack.pop()
                for dx, dy in ((1,0),(-1,0),(0,1),(0,-1)):
                    nx, ny = x+dx, y+dy
                    if 0 <= nx < R and 0 <= ny < C and grid[nx][ny] == '1':
                        grid[nx][ny] = '0'
                        stack.append((nx, ny))
    return count

易错:出栈才标记致重复入栈栈溢出;四邻居漏写上下左右。

L1 🔥×9 Q13 · LC141 环形链表

考察点:快慢指针;fast 每次走 2 步、slow 走 1 步,相遇即有环。

实现:

def has_cycle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow is fast:      # 身份比较,不是数值比较
            return True
    return False

易错:用 == 比值(链表节点应比身份);fast.next.next 未判 fast.next 空致异常。

L2 🔥×8 Q14 · LC560 和为 K 的子数组

考察点:前缀和 + 哈希;need = prefix - k 的出现次数即以当前位结尾的答案数。

实现:

def subarray_sum(nums, k):
    from collections import defaultdict
    cnt = defaultdict(int)
    cnt[0] = 1        # 前缀和为 0 的空前缀,出现 1 次
    prefix = ans = 0
    for x in nums:
        prefix += x
        ans += cnt[prefix - k]
        cnt[prefix] += 1     # 先查后加,避免把空子数组算进去
    return ans

易错:忘记 cnt[0]=1 漏掉从 0 开始的子数组;先加后查致 k=0 时计入空数组。

L2 🔥×8 Q15 · LC236 二叉树最近公共祖先

考察点:后序遍历;左右子树都有返回值则当前即 LCA,否则上抛非空的那侧。

实现:

def lowest_common_ancestor(root, p, q):
    if not root or root is p or root is q:
        return root
    left = lowest_common_ancestor(root.left, p, q)
    right = lowest_common_ancestor(root.right, p, q)
    if left and right:
        return root        # p 和 q 分居两侧 → 当前是 LCA
    return left or right

易错:前序遍历时无法判断"两侧都有";未考虑 p 或 q 就是根节点。

L1 🔥×8 Q16 · LC20 有效的括号

考察点:栈;遇右括号必须与栈顶匹配。

实现:

def is_valid(s):
    pairs = {')': '(', ']': '[', '}': '{'}
    stack = []
    for ch in s:
        if ch in '([{':
            stack.append(ch)
        else:
            if not stack or stack.pop() != pairs[ch]:
                return False   # 空栈或类型不匹配
    return not stack           # 栈必须为空

易错:只判断长度奇偶;栈空时 pop 抛异常;忘记最后判栈空。

L3 🔥×8 Q17 · LC239 滑动窗口最大值

考察点:单调递减队列;队首即窗口最大值;新元素入队前清掉队尾 ≤ 它的。

实现:

from collections import deque

def max_sliding_window(nums, k):
    dq = deque()      # 存下标,值单调递减
    res = []
    for i, x in enumerate(nums):
        while dq and dq[0] <= i - k:      # 队首过期
            dq.popleft()
        while dq and nums[dq[-1]] <= x:    # 队尾被 x 支配
            dq.pop()
        dq.append(i)
        if i >= k - 1:
            res.append(nums[dq[0]])
    return res

易错:存值而非下标致无法判断过期;清理过期用 < 而非 <=。

L1 🔥×7 Q18 · LC155 最小栈

考察点:辅助栈同步记录当前最小值;或单栈存 (value, min) 对。

实现:

class MinStack:
    def __init__(self):
        self.data = []
        self.mins = []      # 与 data 等长,mins[-1] 是当前最小值

    def push(self, x):
        self.data.append(x)
        self.mins.append(min(x, self.mins[-1]) if self.mins else x)

    def pop(self):
        self.mins.pop()
        return self.data.pop()

    def top(self):
        return self.data[-1]

    def get_min(self):
        return self.mins[-1]

易错:用 min(self.data) 每次 O(n);pop 时只弹 data 忘了弹 mins 导致长度错位。

L2 🔥×7 Q19 · LC56 合并区间

考察点:按左端点排序;与上一个区间重叠时把右端点取 max。

实现:

def merge(intervals):
    if not intervals:
        return []
    intervals.sort(key=lambda x: x[0])
    res = [list(intervals[0])]
    for start, end in intervals[1:]:
        if start <= res[-1][1]:            # 重叠(含相邻端点相等)
            res[-1][1] = max(res[-1][1], end)
        else:
            res.append([start, end])
    return res

易错:合并后右端点没取 max(被短区间覆盖);忘记先排序。

L3 🔥×7 Q20 · LC23 合并 K 个升序链表

考察点:两两归并(分治)避免逐条插入的 O(NK);或小顶堆。

实现:

import heapq

def merge_k_lists(lists):
    heap = [(l.val, i, l) for i, l in enumerate(lists) if l]
    heapq.heapify(heap)
    dummy = ListNode(0)
    tail = dummy
    while heap:
        _, i, node = heapq.heappop(heap)
        tail.next = node
        tail = node
        if node.next:
            heapq.heappush(heap, (node.next.val, i, node.next))
    return dummy.next

易错:堆元素只放值致无法区分同值来自哪条链表(需带序号打破平局);漏推 node.next。

L2 🔥×7 Q21 · LC300 最长递增子序列

考察点:贪心 + 二分;tails[k] 存长度 k+1 的递增子序列最小结尾。

实现:

import bisect

def length_of_lis(nums):
    tails = []
    for x in nums:
        i = bisect.bisect_left(tails, x)   # 第一个 >= x 的位置
        if i == len(tails):
            tails.append(x)
        else:
            tails[i] = x      # 替换为更小的结尾,给后面留空间
    return len(tails)

易错:用 bisect_right 把相等元素也算进递增;返回 tails 本身而非其长度。

L2 🔥×6 Q22 · LC994 腐烂的橘子

考察点:多源 BFS;层数即经过的分钟数。

实现:

from collections import deque

def oranges_rotting(grid):
    if not grid:
        return 0
    R, C = len(grid), len(grid[0])
    q = deque()
    fresh = 0
    for i in range(R):
        for j in range(C):
            if grid[i][j] == 2:
                q.append((i, j))
            elif grid[i][j] == 1:
                fresh += 1
    mins = 0
    while q:
        n = len(q)
        for _ in range(n):
            x, y = q.popleft()
            for dx, dy in ((1,0),(-1,0),(0,1),(0,-1)):
                nx, ny = x+dx, y+dy
                if 0 <= nx < R and 0 <= ny < C and grid[nx][ny] == 1:
                    grid[nx][ny] = 2
                    fresh -= 1
                    q.append((nx, ny))
        if q:
            mins += 1
    return -1 if fresh else mins

易错:有新鲜橘子但队列耗尽时应返回 -1;层数在空队列轮次仍自增。

L3 🔥×6 Q23 · LC32 最长有效括号

考察点:栈存"未匹配左括号下标",栈底哨兵 −1;遇左括号入栈,右括号匹配后算长度。

实现:

def longest_valid_parentheses(s):
    stack = [-1]        # 栈底哨兵:最后一个未匹配右括号的位置
    best = 0
    for i, ch in enumerate(s):
        if ch == '(':
            stack.append(i)
        else:
            stack.pop()
            if not stack:
                stack.append(i)   # 当前右括号无匹配,作为新哨兵
            else:
                best = max(best, i - stack[-1])
    return best

易错:栈空时直接取 stack[-1] 抛异常;哨兵初值写成 0 而非 -1。

L3 🔥×6 Q24 · LC72 编辑距离

考察点:二维 DP;dp[i][j] = 前 i 个字符变前 j 个字符的最少操作数。

实现:

def edit_distance(word1, word2):
    m, n = len(word1), len(word2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1):
        dp[i][0] = i      # 全删
    for j in range(n + 1):
        dp[0][j] = j      # 全插
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if word1[i-1] == word2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = 1 + min(dp[i-1][j],      # 删
                                   dp[i][j-1],      # 插
                                   dp[i-1][j-1])   # 替换
    return dp[m][n]

易错:边界初始化写成 0(应 i / j);相等时仍取 min 三项(等价但多余)。

L2 🔥×6 Q25 · LC5 最长回文子串

考察点:中心扩展法;奇偶两种中心都要试。

实现:

def longest_palindrome(s):
    best = ""
    for i in range(len(s)):
        for l, r in ((i, i), (i, i + 1)):   # 奇数中心 & 偶数中心
            while l >= 0 and r < len(s) and s[l] == s[r]:
                l -= 1
                r += 1
            cand = s[l+1:r]
            if len(cand) > len(best):
                best = cand
    return best

易错:只处理奇数长度;边界判断写错致越界。

L2 🔥×6 Q26 · LC46 全排列

考察点:回溯;used 数组标记已选;撤销选择回到上层状态。

实现:

def permute(nums):
    res, path, used = [], [], [False] * len(nums)
    def backtrack():
        if len(path) == len(nums):
            res.append(path[:])     # 拷贝,不能直接 append(path)
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            used[i] = True
            path.append(nums[i])
            backtrack()
            path.pop()
            used[i] = False         # 撤销
    backtrack()
    return res

易错:res.append(path) 未拷贝,后续回溯会把已存结果改掉;忘撤销 used。

L2 🔥×6 Q27 · LC19 删除链表倒数第 N 个节点

考察点:快慢指针相差 N+1 步;虚拟头节点处理删头。

实现:

def remove_nth_from_end(head, n):
    dummy = ListNode(0, head)
    fast = slow = dummy
    for _ in range(n + 1):      # fast 先走 n+1 步
        if fast is None:
            break
        fast = fast.next
    while fast:
        fast = fast.next
        slow = slow.next
    slow.next = slow.next.next  # 删除 slow 的下一个
    return dummy.next

易错:fast 先走 N 步(应 N+1);不用虚拟头致删头指针悬挂。

L2 🔥×5 Q28 · LC22 括号生成

考察点:回溯 + 剩余左括号/右括号计数剪枝。

实现:

def generate_parenthesis(n):
    res, path = [], []
    def backtrack(open_c, close_c):
        if len(path) == 2 * n:
            res.append("".join(path))
            return
        if open_c < n:                    # 还能加左括号
            path.append("(")
            backtrack(open_c + 1, close_c)
            path.pop()
        if close_c < open_c:              # 右括号不能多于左括号
            path.append(")")
            backtrack(open_c, close_c + 1)
            path.pop()
    backtrack(0, 0)
    return res

易错:漏掉 close_c < open_c 剪枝产生非法串;未用 path.pop() 回溯。

L2 🔥×5 Q29 · LC1143 最长公共子序列

考察点:二维 DP;相等时取左上+1,否则取上/左较大者。

实现:

def longest_common_subsequence(text1, text2):
    m, n = len(text1), len(text2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i-1] == text2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

易错:相等时写成 max(dp[i-1][j-1], ...)(应直接取左上);边界全 0 未处理。

L1 🔥×4 Q30 · LC169 多数元素

考察点:Boyer-Moore 投票;候选人与计数成对维护。

实现:

def majority_element(nums):
    cand = None
    count = 0
    for x in nums:
        if count == 0:
            cand = x
        count += 1 if x == cand else -1
    return cand

易错:题目只保证多数元素存在才可不二次校验;把它写成"求众数"(可多个)致逻辑不符。


02 数据结构选型与复杂度(4 题)

L1 🔥×15 Q31 · 常见排序算法的时间复杂度?各适合什么场景?

答:

算法平均最坏空间稳定典型场景
快排O(n log n)O(n²)O(log n)否通用、内存友好(原地)
归并O(n log n)O(n log n)O(n)是需要稳定 / 外部排序 / 链表排序
堆排O(n log n)O(n log n)O(1)否只要前 K 大、流式数据
计数O(n + k)O(n + k)O(k)是值域小的整数
插入O(n²)O(n²)O(1)是近乎有序 / 小数组

选型:默认快排(加随机 pivot + 三数取中防退化);要稳定用归并;内存紧用堆排;数据小直接插排(常数小)。

易错:说快排"平均 O(n log n)"就完事——最坏 O(n²) 是面试官追问点;把"稳定"和"原地"混为一谈(归并稳定但不原地,堆排序原地但不)。

追问:① 快排怎么避免最坏情况?② Java Arrays.sort 为什么用 TimSort(归并 + 插入)?③ 外部排序(数据大于内存)怎么做?

L1 🔥×7 Q32 · LRU 底层是什么?链表查询是 O(n),怎么优化到 O(1)?

答:LRU = 哈希表 + 双向链表。

  • 哈希表 key → 节点,负责 O(1) 按 key 定位。
  • 双向链表按访问顺序排列,头 = 最近使用,尾 = 最久未用;get / put 时把节点摘下移到头部,满容量时淘汰尾部。

为什么不用普通链表:get 要按 key 找节点是 O(n),整体退化。哈希表把"定位"降到 O(1),链表把"调整顺序 / 淘汰"降到 O(1)。

替代结构:用 OrderedDict(Python)/ LinkedHashMap(Java,accessOrder=true)可省掉手写链表,但面试要能手写。

易错:只用链表不用哈希表;用单向链表(摘除节点需前驱,退化 O(n));忘了 put 已有 key 时要先摘除再插入,否则出现同一 key 两个节点。

追问:① 为什么 LRU-2 / LFU 抗扫描污染?② 换成 LFU 怎么设计(计数桶 + 最小堆)?③ 缓存容量动态变化怎么做?

L2 🔥×3 Q33 · 单链表怎么排查是否有环?

答:判环本身是 Q13 的 has_cycle(快慢指针),这里答面试官常追加的三问。

① 求环的入口节点:相遇后把一个指针放回头结点,两指针每次各走 1 步,再次相遇处即入口。

② 求环长:相遇后固定 slow,让 fast 继续走直到再次相遇,走过的步数即 L。

③ 求环外长度:从 head 走到入口 e 的步数即环外长度。

为什么快慢指针必然相遇:设头到入环距离 a、环长 L。相遇时 slow 走 s 步、fast 走 2s 步,差 s 步是环长整数倍,故 s ≥ a,slow 已进环。

易错:比较节点值而非身份(== vs is);fast.next.next 未判空;求入口时忘了把指针放回头结点。

追问:① 哈希集合判环的代价?② 有环时反转链表结果是什么?③ 双向链表判环有什么不同?

L2 🔥×11 Q34 · 哈希表冲突怎么解决?Java HashMap 为什么线程不安全?

答:

冲突解决:

  • 链地址法(Java HashMap):同桶挂链表,Java 8 后链表长度 ≥8 且表长 ≥64 时转红黑树。
  • 开放地址法(线性探测 / 二次探测):找下一个空槽,删除需"墓碑"标记,否则探测链断裂。

负载因子:容量用满性能骤降。HashMap 默认 0.75,超过即扩容为 2 倍并 rehash。

线程不安全:并发 put 可能形成环形链表(Java 7 头插法)或数据覆盖(Java 8 尾插 + CAS 失败直接覆盖);size() 不是原子的。并发场景用 ConcurrentHashMap(Java 8 起 CAS + synchronized 锁单个桶)。

易错:以为"Hashtable 是线程安全的所以用 Hashtable"——它是全表锁 + 老旧实现;Collections.synchronizedMap 只是包装同步。

追问:① 为什么链表转红黑树要同时要求表长 ≥64?② ConcurrentHashMap 的 size 是怎么统计的?③ 布隆过滤器怎么配合缓存防穿透?


03 图与搜索(2 题 + 2 手撕)

L2 低频实录·1源 Q35 · BFS、DFS、Dijkstra、A* 的适用场景和区别是什么?

答:

算法数据结构保证适用
BFS队列无权图最短步数最短路径(等权)、层序遍历
DFS栈/递归不保证最短环检测、连通分量、回溯枚举
Dijkstra优先队列非负权最短路最短路(正权)
A*优先队列h 可采纳时最优有目标先验的路径规划

核心差别:BFS 按"层"扩展 → 无权图最优;DFS 沿一条路走到底 → 适合找"有没有解";Dijkstra 按累计代价出队 → 加权图最优;A* 的排序键是 f = g + h,其中 g 是已走代价、h 是到目标的估价,h 越小扩展越偏向目标。

易错:Dijkstra 用在有负权图上(算法失效,需 Bellman-Ford);A* 的 h 高估会给出次优解(h 必须 admissible:h ≤ 真实剩余代价);BFS 用 visited 太早会导致漏解。

追问:① 0-1 BFS 是什么?② 带权但只有 0/1 边时怎么最短路?③ A* 在网格上 h 怎么选才 admissible?

L2 🔥×2 ✍ H01 · 手撕 BFS / DFS 在 grid 上找最短路径

考察点:BFS 必给最短(无权图);visited 入队即标(非出队);DFS 求所有路径不能求最短。

实现:

from collections import deque

def bfs_shortest(grid, start, goal):
    # grid: 2D list;0 通行、1 障碍;返回最短步数或 -1
    R, C = len(grid), len(grid[0])
    if grid[start[0]][start[1]] or grid[goal[0]][goal[1]]:
        return -1
    visited = [[False] * C for _ in range(R)]
    q = deque([(start[0], start[1], 0)])
    visited[start[0]][start[1]] = True  # 入队即标,防重复入队 TLE
    dx, dy = (-1, 1, 0, 0), (0, 0, -1, 1)  # 四邻居
    while q:
        x, y, d = q.popleft()
        if (x, y) == goal:
            return d
        for k in range(4):
            nx, ny = x + dx[k], y + dy[k]
            # 边界 + 障碍 + visited 三道闸
            if 0 <= nx < R and 0 <= ny < C and not visited[nx][ny] and not grid[nx][ny]:
                visited[nx][ny] = True
                q.append((nx, ny, d + 1))
    return -1

易错:visited 出队才标 → 重复入队 TLE;用 DFS 求最短(应 BFS);八邻居斜走代价应 √2 而非 1。

L2 🔥×2 ✍ H02 · 手撕 A* / Dijkstra 路径规划

考察点:Dijkstra h=0、A* f=g+h;h 必须 admissible(≤真实代价)保证最优;用 g_best 替代 closed set。

实现:

import heapq

def heuristic(p, goal):
    # 4 邻居用 Manhattan;8 邻居/自由方向用 Euclidean;都 admissible
    return abs(p[0] - goal[0]) + abs(p[1] - goal[1])

def astar(grid, start, goal):
    R, C = len(grid), len(grid[0])
    g_best = {start: 0}
    pq = [(heuristic(start, goal), 0, start)]  # (f, g, pos)
    dx, dy = (-1, 1, 0, 0), (0, 0, -1, 1)
    while pq:
        f, g, p = heapq.heappop(pq)
        if p == goal:
            return g
        if g > g_best.get(p, float('inf')):  # 懒删除:过期条目跳过
            continue
        for k in range(4):
            nx, ny = p[0] + dx[k], p[1] + dy[k]
            if not (0 <= nx < R and 0 <= ny < C) or grid[nx][ny]:
                continue
            ng = g + 1
            np_ = (nx, ny)
            # 仅当新 g 更小才入队(替代 closed set,更简洁)
            if ng < g_best.get(np_, float('inf')):
                g_best[np_] = ng
                heapq.heappush(pq, (ng + heuristic(np_, goal), ng, np_))
    return -1

易错:h 不 admissible(高估)→ 解不最优;按 g 入堆而非 f;同节点重复入队忘懒删除致路径错。

L3 🔥×2 Q36 · 动态规划的四要素是什么?什么情况下不适合用 DP?

答:四要素:

  1. 状态定义:dp[i][j] 表示什么——最难也最关键,定义错了后面全错。
  2. 状态转移方程:dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)。
  3. 初始值与边界:dp[0][*]、dp[*][0] 的取值。
  4. 计算顺序:保证用到的子状态已算出(按行/按层遍历)。

适用条件:最优子结构 + 重叠子问题。

不适合:贪心能解决的(区间调度)——DP 正确但浪费;无重叠子问题(归并排序的递归划分)——普通递归即可,记忆化无收益;状态维度太高。

空间优化:只依赖上一行/上一列时,滚动数组可降到 O(1) 空间(如编辑距离)。

易错:把"状态定义"当成"递推公式";边界初始化错(漏掉空区间);状态里塞多余信息导致无法降维。

追问:① 0-1 背包和完全背包的循环顺序区别?② 区间 DP 的遍历顺序为什么是"小区间在外"?③ 状压 DP 的 n 一般不超过多少?


04 手撕补充:常被追问但不在 LeetCode Top 30 的题(2 题)

L1 🔥×10 ✍ H03 · 手撕线程安全的单例(双检锁 + volatile)

考察点:双检锁的内存可见性问题——没有 volatile 时指令重排会返回未完全初始化的对象。

实现:

import threading

class Singleton:
    _instance = None
    _lock = threading.Lock()

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:          # 第一重:无锁快速路径
            with cls._lock:
                if cls._instance is None:  # 第二重:持锁复查
                    cls._instance = super().__new__(cls)
                    cls._instance._init_done = False
        return cls._instance

    def __init__(self, value=0):
        if getattr(self, "_init_done", False):
            return                          # 避免重复初始化
        self.value = value
        self._init_done = True

易错:漏 volatile(Java)致其他线程读到半初始化对象;__init__ 每次都重跑致状态被重置;用模块级判断在多进程下失效(每进程一份)。

追问:① 进程内单例 vs 跨进程单例怎么实现?② Python 里为什么不需要 volatile(GIL + CPython 内存模型)?③ 单例的测试怎么写?

L2 🔥×6 ✍ H04 · 手撕限流器(令牌桶 / 滑动窗口)

考察点:令牌桶允许突发;滑动窗口限流更平滑;两者都是"时间维度上的计数"。

实现:

import time
from collections import deque

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate              # 每秒生成令牌数
        self.capacity = capacity      # 桶容量 = 最大突发量
        self.tokens = capacity
        self.last = time.monotonic()

    def allow(self, n=1):
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
        self.last = now
        if self.tokens >= n:
            self.tokens -= n
            return True
        return False

易错:用 time.time()(会被 NTP 调整回退,应用 time.monotonic());漏了容量上限致长时间空闲后一次放行无限流量;并发下未加锁。

追问:① 令牌桶和漏桶区别?② 分布式限流怎么做(Redis + Lua 保证原子)?③ 限流维度:全局 / 用户 / 接口 / IP?


低频备选(未入主表)