卷一 · 算法与手撕 · 高频面试题
通用软件工程师 / 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?
答:四要素:
- 状态定义:
dp[i][j]表示什么——最难也最关键,定义错了后面全错。 - 状态转移方程:
dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)。 - 初始值与边界:
dp[0][*]、dp[*][0]的取值。 - 计算顺序:保证用到的子状态已算出(按行/按层遍历)。
适用条件:最优子结构 + 重叠子问题。
不适合:贪心能解决的(区间调度)——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?
低频备选(未入主表)
- 手撕跳表(Redis ZSet 底层)——面试中作为 LRU 的对比追问出现,未见独立主表题。
- 手撕布隆过滤器——多见于缓存穿透追问,未见独立主表题。
- 手撕一致性哈希——多见于分布式分片追问,未见独立主表题。
- 手撕跳表 / 线段树 / 树状数组——偏后端与数据结构专题,卷二收录。