算法与数据结构面试题 - 50道高频LeetCode精选¶
本文精选50道高频LeetCode面试题,按类型分类整理,每题包含思路分析、Python实现、复杂度分析和面试追问。
一、数组与字符串(15题)¶
1. 两数之和(Two Sum)¶
题目描述: 给定一个整数数组 nums 和一个整数目标值 target,请在数组中找出和为目标值的两个整数,返回它们的数组下标。假设每种输入只有一个答案,且同一个元素不能重复使用。
思路分析:
- 暴力法: 双层循环遍历所有组合,时间复杂度 O(n²)
- 优化 - 哈希表法: 遍历数组时,用哈希表记录已遍历的值和下标。对于当前元素
num,检查target - num是否已在哈希表中。一次遍历即可完成。
def twoSum(nums: list[int], target: int) -> list[int]:
# 哈希表:值 -> 下标
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
复杂度分析: - 时间复杂度:O(n),一次遍历 - 空间复杂度:O(n),哈希表存储
面试追问: - 如果数组已排序?→ 双指针法,O(1) 空间 - 如果要返回所有满足条件的组合?→ 需要去重处理 - 如果有多个答案怎么办?→ 收集所有结果对
2. 三数之和(3Sum)¶
题目描述: 给定整数数组 nums,找出所有和为 0 的三元组 [nums[i], nums[j], nums[k]],使得 i ≠ j ≠ k,且答案中不包含重复三元组。
思路分析:
- 暴力法: 三层循环 O(n³),再去重
- 优化 - 排序 + 双指针: 先排序,固定一个数,用双指针在剩余区间查找。排序后方便跳过重复元素。
def threeSum(nums: list[int]) -> list[list[int]]:
nums.sort()
result = []
n = len(nums)
for i in range(n - 2):
# 跳过重复的第一个数
if i > 0 and nums[i] == nums[i - 1]:
continue
# 剪枝:最小值已大于0,后面不可能有解
if nums[i] > 0:
break
left, right = i + 1, n - 1
while left < right:
total = nums[i] + nums[left] + nums[right]
if total == 0:
result.append([nums[i], nums[left], nums[right]])
# 跳过重复值
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
left += 1
right -= 1
elif total < 0:
left += 1
else:
right -= 1
return result
复杂度分析: - 时间复杂度:O(n²),排序 O(n log n) + 双指针 O(n²) - 空间复杂度:O(1),不计输出空间
面试追问: - 四数之和怎么做?→ 外层再加一层循环,或递归降维 - 最接近的三数之和?→ 维护一个最小差值 - 如果允许重复元素出现在不同位置?→ 不去重即可
3. 最长无重复子串(Longest Substring Without Repeating Characters)¶
题目描述: 给定一个字符串 s,找出其中不含重复字符的最长子串的长度。
思路分析:
- 暴力法: 枚举所有子串,检查是否无重复 O(n³)
- 优化 - 滑动窗口: 维护一个窗口
[left, right],用集合/哈希表记录窗口内字符。遇到重复字符时,移动左边界直到窗口内无重复。
def lengthOfLongestSubstring(s: str) -> int:
# 字符 -> 最近出现的下标
char_index = {}
max_len = 0
left = 0
for right, char in enumerate(s):
# 如果字符已在窗口内,移动左边界
if char in char_index and char_index[char] >= left:
left = char_index[char] + 1
char_index[char] = right
max_len = max(max_len, right - left + 1)
return max_len
复杂度分析: - 时间复杂度:O(n),每个字符最多被访问两次 - 空间复杂度:O(min(m, n)),m 为字符集大小
面试追问: - 最长无重复子串本身是什么(而非长度)?→ 记录起始位置 - 如果要求至少包含 k 个不同字符?→ 变形滑动窗口 - 如果字符串包含 Unicode?→ 哈希表天然支持
4. 滑动窗口最大值(Sliding Window Maximum)¶
题目描述: 给定整数数组 nums 和窗口大小 k,窗口从最左端滑到最右端,返回每个窗口位置的最大值。
思路分析:
- 暴力法: 每次取窗口内最大值 O(nk)
- 优化 - 单调队列(双端队列): 维护一个单调递减的双端队列,队首始终是当前窗口最大值的下标。新元素入队时,从队尾移除所有比它小的元素。
from collections import deque
def maxSlidingWindow(nums: list[int], k: int) -> list[int]:
dq = deque() # 存储下标,对应值单调递减
result = []
for i, num in enumerate(nums):
# 移除超出窗口范围的队首元素
while dq and dq[0] < i - k + 1:
dq.popleft()
# 维护单调递减:移除队尾所有比当前值小的元素
while dq and nums[dq[-1]] < num:
dq.pop()
dq.append(i)
# 窗口形成后,记录最大值
if i >= k - 1:
result.append(nums[dq[0]])
return result
复杂度分析: - 时间复杂度:O(n),每个元素最多入队出队各一次 - 空间复杂度:O(k),双端队列最多存 k 个元素
面试追问: - 如果需要滑动窗口最小值?→ 改为单调递增队列 - 用堆如何实现?→ 最大堆 + 懒删除,O(n log n) - 如何处理窗口大小动态变化的情况?
5. 合并区间(Merge Intervals)¶
题目描述: 给定若干区间 intervals,合并所有重叠的区间。
思路分析:
- 先按区间起始位置排序,然后依次比较当前区间与结果中最后一个区间是否重叠。
def merge(intervals: list[list[int]]) -> list[list[int]]:
# 按起始位置排序
intervals.sort(key=lambda x: x[0]) # lambda匿名函数:简洁的单行函数
merged = []
for interval in intervals:
# 如果结果为空,或当前区间与最后一个不重叠
if not merged or merged[-1][1] < interval[0]: # 负索引:从末尾倒数访问元素
merged.append(interval)
else:
# 合并:更新右端点
merged[-1][1] = max(merged[-1][1], interval[1])
return merged
复杂度分析: - 时间复杂度:O(n log n),排序主导 - 空间复杂度:O(n),存储结果
面试追问: - 插入一个新区间到已排序的区间列表?→ 二分查找 + 合并 - 区间交集如何计算?→ 双指针遍历两个已排序列表 - 如果区间是以流的方式到来?→ 在线算法 / 区间树
6. 接雨水(Trapping Rain Water)¶
题目描述: 给定 n 个非负整数表示柱子高度(宽度为1),计算下雨后能接多少雨水。
思路分析:
- 暴力法: 对每个位置,找左右最高柱子,取较小值减去当前高度 O(n²)
- 优化1 - 预处理数组: 预计算每个位置左边最大值和右边最大值 O(n)
- 优化2 - 双指针: 左右指针向中间移动,维护左右最大高度
def trap(height: list[int]) -> int:
if not height:
return 0
left, right = 0, len(height) - 1
left_max, right_max = 0, 0
water = 0
while left < right:
if height[left] < height[right]:
if height[left] >= left_max:
left_max = height[left]
else:
water += left_max - height[left]
left += 1
else:
if height[right] >= right_max:
right_max = height[right]
else:
water += right_max - height[right]
right -= 1
return water
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(1),双指针法
面试追问: - 用栈如何实现?→ 单调递减栈,横向计算 - 二维接雨水(3D版本)?→ BFS + 最小堆 - 柱子宽度不为1时如何处理?
7. 最长回文子串(Longest Palindromic Substring)¶
题目描述: 给定字符串 s,找到最长的回文子串。
思路分析:
- 暴力法: 枚举所有子串检查回文 O(n³)
- 优化 - 中心扩展法: 以每个字符(或两个字符间隙)为中心向两边扩展。回文中心有 2n-1 个。
- 最优 - Manacher算法: O(n) 但面试中中心扩展法足够。
def longestPalindrome(s: str) -> str:
if len(s) < 2:
return s
start, max_len = 0, 1
def expand_around_center(left: int, right: int):
"""从中心向两边扩展,返回回文长度"""
while left >= 0 and right < len(s) and s[left] == s[right]:
left -= 1
right += 1
return right - left - 1
for i in range(len(s)):
# 奇数长度回文
len1 = expand_around_center(i, i)
# 偶数长度回文
len2 = expand_around_center(i, i + 1)
cur_len = max(len1, len2)
if cur_len > max_len:
max_len = cur_len
start = i - (cur_len - 1) // 2
return s[start:start + max_len]
复杂度分析: - 时间复杂度:O(n²),中心扩展 - 空间复杂度:O(1)
面试追问: - Manacher 算法原理?→ 利用已知回文的对称性 - 最长回文子序列?→ DP,dp[i][j] 表示 s[i..j] 的最长回文子序列长度 - 回文划分最少次数?→ DP
8. 螺旋矩阵(Spiral Matrix)¶
题目描述: 给定一个 m × n 矩阵,按螺旋顺序返回所有元素。
思路分析:
按层模拟,定义上下左右四个边界,按"右→下→左→上"的顺序遍历,每遍历完一条边就收缩对应边界。
def spiralOrder(matrix: list[list[int]]) -> list[int]:
if not matrix:
return []
result = []
top, bottom = 0, len(matrix) - 1
left, right = 0, len(matrix[0]) - 1
while top <= bottom and left <= right:
# 向右遍历
for col in range(left, right + 1):
result.append(matrix[top][col])
top += 1
# 向下遍历
for row in range(top, bottom + 1):
result.append(matrix[row][right])
right -= 1
# 向左遍历(需检查边界)
if top <= bottom:
for col in range(right, left - 1, -1):
result.append(matrix[bottom][col])
bottom -= 1
# 向上遍历(需检查边界)
if left <= right:
for row in range(bottom, top - 1, -1):
result.append(matrix[row][left])
left += 1
return result
复杂度分析: - 时间复杂度:O(m × n) - 空间复杂度:O(1),不计输出
面试追问: - 螺旋矩阵II(生成螺旋矩阵)?→ 反向填充 - 逆时针螺旋?→ 调整遍历方向 - 对角线遍历?→ 按对角线分组
9. 旋转图像(Rotate Image)¶
题目描述: 给定 n × n 矩阵,将其顺时针旋转 90 度,要求原地旋转。
思路分析:
- 方法1: 先转置(行变列),再左右翻转每行
- 方法2: 四点轮换,每次处理一圈
def rotate(matrix: list[list[int]]) -> None:
n = len(matrix)
# 第一步:转置矩阵(沿主对角线翻转)
for i in range(n):
for j in range(i + 1, n):
matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
# 第二步:水平翻转每一行
for i in range(n):
matrix[i].reverse()
复杂度分析: - 时间复杂度:O(n²) - 空间复杂度:O(1),原地旋转
面试追问: - 逆时针旋转90度?→ 先转置,再上下翻转 - 旋转180度?→ 先上下翻转,再左右翻转 - 如果不是方阵?→ 无法原地,需要新矩阵
10. 缺失的第一个正数(First Missing Positive)¶
题目描述: 给定未排序整数数组 nums,找出其中没有出现的最小正整数。要求时间 O(n),空间 O(1)。
思路分析:
- 原地哈希: 利用数组自身作为哈希表。将每个值
v(1 ≤ v ≤ n)放到下标v-1的位置。遍历后第一个nums[i] ≠ i+1的位置就是答案。
def firstMissingPositive(nums: list[int]) -> int:
n = len(nums)
# 将每个数放到正确的位置:nums[i] = i + 1
for i in range(n):
while 1 <= nums[i] <= n and nums[nums[i] - 1] != nums[i]:
# 交换 nums[i] 到正确位置
correct_idx = nums[i] - 1
nums[i], nums[correct_idx] = nums[correct_idx], nums[i]
# 找第一个不在正确位置的数
for i in range(n):
if nums[i] != i + 1:
return i + 1
return n + 1
复杂度分析: - 时间复杂度:O(n),每个元素最多被交换一次 - 空间复杂度:O(1)
面试追问: - 为什么 while 循环不会超过 O(n)?→ 每次交换至少让一个元素归位 - 如果不允许修改原数组?→ 需要 O(n) 空间 - 缺失的前 k 个正数?→ 类似思路扩展
11. 最小覆盖子串(Minimum Window Substring)¶
题目描述: 给定字符串 s 和 t,在 s 中找出包含 t 所有字符的最小子串。
思路分析:
滑动窗口 + 计数器。右指针扩展窗口,当窗口满足条件时,左指针收缩寻找更小的窗口。
from collections import Counter
def minWindow(s: str, t: str) -> str:
if not s or not t:
return ""
need = Counter(t) # 需要的字符计数
missing = len(t) # 还缺少的字符数
left = 0
start, min_len = 0, float('inf')
for right, char in enumerate(s):
# 扩展窗口
if need[char] > 0:
missing -= 1
need[char] -= 1
# 收缩窗口
while missing == 0:
window_len = right - left + 1
if window_len < min_len:
min_len = window_len
start = left
need[s[left]] += 1
if need[s[left]] > 0:
missing += 1
left += 1
return s[start:start + min_len] if min_len != float('inf') else ""
复杂度分析: - 时间复杂度:O(|s| + |t|) - 空间复杂度:O(|字符集|)
面试追问: - 如果要求子串中字符顺序与 t 一致?→ 子序列匹配问题 - 最小窗口包含 t 的所有字符至少 k 次?→ 修改 need 计数 - 有多个最小窗口怎么返回?→ 收集所有长度等于 min_len 的窗口
12. 字符串转整数(atoi)¶
题目描述: 实现 myAtoi(string s) 函数,将字符串转换为 32 位有符号整数。
思路分析:
模拟题,注意边界条件:前导空格、正负号、非数字字符、溢出处理。
def myAtoi(s: str) -> int:
INT_MAX, INT_MIN = 2**31 - 1, -(2**31)
s = s.lstrip() # 去前导空格
if not s:
return 0
sign = 1
idx = 0
# 处理正负号
if s[0] in ('+', '-'):
sign = -1 if s[0] == '-' else 1
idx = 1
result = 0
while idx < len(s) and s[idx].isdigit():
digit = int(s[idx])
# 溢出检查
if result > (INT_MAX - digit) // 10:
return INT_MAX if sign == 1 else INT_MIN
result = result * 10 + digit
idx += 1
return sign * result
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(1)
面试追问: - 用有限状态自动机(DFA)如何实现?→ 定义状态转移表 - 如何支持十六进制转换?→ 识别 "0x" 前缀 - 浮点数转换如何处理?→ 增加小数点和指数处理
13. 最长公共前缀(Longest Common Prefix)¶
题目描述: 编写函数来查找字符串数组中的最长公共前缀。
思路分析:
- 纵向扫描: 逐个字符比较所有字符串的同一位置
- 分治法: 递归地找左半和右半的公共前缀,再合并
def longestCommonPrefix(strs: list[str]) -> str:
if not strs:
return ""
# 纵向扫描
for i in range(len(strs[0])):
char = strs[0][i]
for s in strs[1:]:
if i >= len(s) or s[i] != char:
return strs[0][:i]
return strs[0]
复杂度分析: - 时间复杂度:O(S),S 为所有字符串字符总数 - 空间复杂度:O(1)
面试追问: - 如果字符串非常多,用 Trie 如何优化?→ 沿唯一子节点路径找 - 二分查找法?→ 二分前缀长度,验证是否所有字符串都有该前缀 - 最长公共后缀?→ 反转所有字符串后求最长公共前缀
14. 移动零(Move Zeroes)¶
题目描述: 给定数组 nums,将所有 0 移到末尾,同时保持非零元素的相对顺序,要求原地操作。
思路分析:
双指针:slow 指向下一个非零元素应放的位置,fast 遍历数组。
def moveZeroes(nums: list[int]) -> None:
slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(1)
面试追问: - 最少交换次数?→ 统计非零元素前面的零的个数 - 移动特定值到末尾?→ 修改判断条件 - 移除所有零(不保留)?→ 只写不交换
15. 下一个排列(Next Permutation)¶
题目描述: 实现获取下一个排列的函数,将数字重新排列成字典序中下一个更大的排列。如果不存在,则排列为最小的排列(升序)。
思路分析:
- 从右往左找到第一个
nums[i] < nums[i+1]的位置i - 从右往左找到第一个
nums[j] > nums[i]的位置j - 交换
nums[i]和nums[j] - 翻转
i+1之后的部分
def nextPermutation(nums: list[int]) -> None:
n = len(nums)
# 第一步:从右往左找第一个递减位置
i = n - 2
while i >= 0 and nums[i] >= nums[i + 1]:
i -= 1
if i >= 0:
# 第二步:从右往左找第一个大于 nums[i] 的数
j = n - 1
while nums[j] <= nums[i]:
j -= 1
# 第三步:交换
nums[i], nums[j] = nums[j], nums[i]
# 第四步:翻转 i+1 之后的部分
left, right = i + 1, n - 1
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(1)
面试追问: - 上一个排列?→ 相反操作:找第一个递增位置 - 第 k 个排列?→ 数学方法,逐位确定 - 排列总数?→ n! / (重复元素阶乘)
二、链表(8题)¶
16. 反转链表(Reverse Linked List)¶
题目描述: 给定单链表的头节点 head,反转链表并返回反转后的链表头。
思路分析:
- 迭代法: 用三个指针
prev、curr、next逐个翻转 - 递归法: 递归到链表末尾,回溯时翻转指向
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def reverseList(head: ListNode) -> ListNode:
# 迭代法
prev, curr = None, head
while curr:
next_node = curr.next # 暂存下一个节点
curr.next = prev # 翻转指向
prev = curr # prev 前进
curr = next_node # curr 前进
return prev
def reverseListRecursive(head: ListNode) -> ListNode:
# 递归法
if not head or not head.next:
return head
new_head = reverseListRecursive(head.next)
head.next.next = head # 后继节点指回自己
head.next = None # 断开原来的指向
return new_head
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:迭代 O(1),递归 O(n)
面试追问: - 反转链表的第 m 到第 n 个节点?→ 定位后局部反转 - 每 k 个一组反转?→ 分组处理 + 连接 - 判断反转后是否与原链表相同(回文链表)?→ 反转后半部分比较
17. 合并K个排序链表(Merge k Sorted Lists)¶
题目描述: 给定 k 个升序排列的链表,合并为一个升序链表。
思路分析:
- 暴力法: 收集所有值排序后建链表 O(N log N)
- 优化1 - 最小堆: 将每个链表头节点入堆,每次弹出最小节点,将其后继入堆
- 优化2 - 分治合并: 两两合并,递归或迭代
import heapq
def mergeKLists(lists: list[ListNode]) -> ListNode:
# 最小堆方法
dummy = ListNode(0)
curr = dummy
heap = []
# 初始化:每个链表的头节点入堆
for i, node in enumerate(lists):
if node:
heapq.heappush(heap, (node.val, i, node))
while heap:
val, idx, node = heapq.heappop(heap)
curr.next = node
curr = curr.next
if node.next:
heapq.heappush(heap, (node.next.val, idx, node.next))
return dummy.next
复杂度分析: - 时间复杂度:O(N log k),N 为总节点数,k 为链表数 - 空间复杂度:O(k),堆大小
面试追问: - 分治法的时间复杂度推导?→ T(k) = 2T(k/2) + O(N) - 如果链表不是排序的?→ 先各自排序或全收集排序 - 外部排序的联系?→ 多路归并正是核心思想
18. 环形链表 II(Linked List Cycle II)¶
题目描述: 给定链表头节点 head,如果链表中有环,返回环的入口节点;否则返回 null。
思路分析:
Floyd 判圈算法: 1. 快慢指针相遇,确认有环 2. 将一个指针移回头部,两个指针同速前进,再次相遇处就是入口
数学推导: 设头到入口距离为 a,入口到相遇点为 b,环长为 c。快指针走了 a+b+nc,慢指针走了 a+b。因为快指针速度是慢指针2倍:2(a+b) = a+b+nc,所以 a = nc - b = (n-1)c + (c-b)。
def detectCycle(head: ListNode) -> ListNode:
slow = fast = head
# 第一步:快慢指针判断是否有环
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# 第二步:找入口
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
return None
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(1)
面试追问: - 如何求环的长度?→ 相遇后一个指针继续走,计数直到再次相遇 - 如何判断两个链表是否相交(都可能有环)?→ 分情况讨论 - 为什么快指针速度必须是2倍?→ 保证在环内必然相遇
19. LRU缓存(LRU Cache)¶
题目描述: 设计并实现一个 LRU(最近最少使用)缓存机制。支持 get(key) 和 put(key, value) 操作,均要求 O(1) 时间复杂度。
思路分析:
哈希表 + 双向链表: 哈希表提供 O(1) 查找,双向链表维护访问顺序。最近使用的放头部,淘汰时从尾部移除。
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.cache = {} # key -> DLinkedNode
self.capacity = capacity
self.size = 0
# 伪头部和伪尾部
self.head = DLinkedNode()
self.tail = DLinkedNode()
self.head.next = self.tail
self.tail.prev = self.head
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
self._move_to_head(node) # 标记为最近使用
return node.value
def put(self, key: int, value: int) -> None:
if key in self.cache:
node = self.cache[key]
node.value = value
self._move_to_head(node)
else:
node = DLinkedNode(key, value)
self.cache[key] = node
self._add_to_head(node)
self.size += 1
if self.size > self.capacity:
# 移除最久未使用的节点
removed = self._remove_tail()
del self.cache[removed.key]
self.size -= 1
def _add_to_head(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):
node.prev.next = node.next
node.next.prev = node.prev
def _move_to_head(self, node):
self._remove_node(node)
self._add_to_head(node)
def _remove_tail(self):
node = self.tail.prev
self._remove_node(node)
return node
复杂度分析: - 时间复杂度:O(1),get/put 均为常数时间 - 空间复杂度:O(capacity)
面试追问: - LFU 缓存如何实现?→ 多个频率桶 + 双向链表 - 线程安全的 LRU?→ 加锁或使用 ConcurrentHashMap - Python 中 OrderedDict 能否直接实现?→ 可以,move_to_end + popitem
20. 两数相加(Add Two Numbers)¶
题目描述: 两个非空链表表示两个非负整数(逆序存储),将两数相加返回结果链表。
思路分析:
模拟加法,逐位相加,处理进位。
def addTwoNumbers(l1: ListNode, l2: ListNode) -> ListNode:
dummy = ListNode(0)
curr = dummy
carry = 0
while l1 or l2 or carry:
val1 = l1.val if l1 else 0
val2 = l2.val if l2 else 0
total = val1 + val2 + carry
carry = total // 10
curr.next = ListNode(total % 10)
curr = curr.next
l1 = l1.next if l1 else None
l2 = l2.next if l2 else None
return dummy.next
复杂度分析: - 时间复杂度:O(max(m, n)) - 空间复杂度:O(max(m, n))
面试追问: - 如果数字是正序存储的?→ 先反转或用栈 - 如何处理非常大的数(超过 long 范围)?→ 链表天然支持大数 - 相减怎么做?→ 需要比较大小 + 借位
21. 删除链表的倒数第 N 个节点(Remove Nth Node From End)¶
题目描述: 给定链表头节点,删除链表的倒数第 n 个节点,返回链表头。
思路分析:
快慢指针: 快指针先走 n 步,然后快慢同时走,快指针到达末尾时慢指针指向倒数第 n+1 个节点。
def removeNthFromEnd(head: ListNode, n: int) -> ListNode:
dummy = ListNode(0, head)
fast = slow = dummy
# 快指针先走 n+1 步
for _ in range(n + 1):
fast = fast.next
# 同时移动
while fast:
fast = fast.next
slow = slow.next
# 删除目标节点
slow.next = slow.next.next
return dummy.next
复杂度分析: - 时间复杂度:O(L),L 为链表长度 - 空间复杂度:O(1)
面试追问: - 一趟扫描实现?→ 上述方法就是一趟 - 如果 n 大于链表长度?→ 需要先验证 - 删除倒数第 n 个到倒数第 m 个?→ 定位两个位置
22. 链表排序(Sort List)¶
题目描述: 给定链表头节点,将其按升序排序。要求 O(n log n) 时间和 O(1) 空间。
思路分析:
归并排序(自底向上): 避免递归带来的 O(log n) 栈空间。
def sortList(head: ListNode) -> ListNode:
# 递归归并排序(简洁版,空间 O(log n))
if not head or not head.next:
return head
# 快慢指针找中点
slow, fast = head, head.next
while fast and fast.next:
slow = slow.next
fast = fast.next.next
mid = slow.next
slow.next = None # 断开
# 递归排序两半
left = sortList(head)
right = sortList(mid)
# 合并
dummy = ListNode(0)
curr = dummy
while left and right:
if left.val <= right.val:
curr.next = left
left = left.next
else:
curr.next = right
right = right.next
curr = curr.next
curr.next = left or right
return dummy.next
复杂度分析: - 时间复杂度:O(n log n) - 空间复杂度:O(log n),递归栈;自底向上可做到 O(1)
面试追问: - 自底向上归并排序的实现?→ 循环 subLength = 1, 2, 4, ... - 为什么不用快排?→ 链表随机访问慢,归并更适合 - 稳定排序?→ 归并排序是稳定的
23. 相交链表(Intersection of Two Linked Lists)¶
题目描述: 找到两个单链表相交的起始节点。
思路分析:
双指针法: 指针 A 走完链表 A 后走链表 B,指针 B 走完链表 B 后走链表 A。如果相交,两指针会在交点相遇;如果不相交,同时到达 null。
def getIntersectionNode(headA: ListNode, headB: ListNode) -> ListNode:
pA, pB = headA, headB
while pA != pB:
pA = pA.next if pA else headB
pB = pB.next if pB else headA
return pA
复杂度分析: - 时间复杂度:O(m + n) - 空间复杂度:O(1)
面试追问: - 为什么一定会在交点相遇?→ 两指针都走了 m + n 的距离 - 如果要找所有交叉点?→ 链表只能有一个交叉起点 - 如果链表中有环怎么办?→ 先判环再处理
三、树(10题)¶
24. 二叉树的层序遍历(Binary Tree Level Order Traversal)¶
题目描述: 给定二叉树根节点,返回其节点值的层序遍历结果(逐层从左到右)。
思路分析:
BFS,使用队列逐层处理。
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def levelOrder(root: TreeNode) -> list[list[int]]:
if not root:
return []
result = []
queue = deque([root])
while queue:
level_size = len(queue)
level = []
for _ in range(level_size):
node = queue.popleft()
level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
result.append(level)
return result
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(n),队列最大宽度
面试追问: - 锯齿形层序遍历?→ 奇数层反转 - 层序遍历的倒序?→ 结果列表 reverse - N 叉树层序遍历?→ 遍历所有子节点
25. 验证二叉搜索树(Validate BST)¶
题目描述: 判断给定二叉树是否是有效的二叉搜索树。
思路分析:
- 递归 + 范围约束: 每个节点的值必须在
(min_val, max_val)范围内 - 中序遍历: BST 的中序遍历结果是严格递增的
def isValidBST(root: TreeNode) -> bool:
def validate(node, min_val, max_val):
if not node:
return True
if node.val <= min_val or node.val >= max_val:
return False
return (validate(node.left, min_val, node.val) and
validate(node.right, node.val, max_val))
return validate(root, float('-inf'), float('inf'))
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(h),h 为树高
面试追问: - 如果允许重复值怎么办?→ 明确重复值放左还是右 - 中序遍历法的优势?→ 可以用 Morris 遍历实现 O(1) 空间 - 恢复一棵被交换了两个节点的 BST?→ 中序遍历找两个逆序对
26. 二叉树的最近公共祖先(Lowest Common Ancestor)¶
题目描述: 给定二叉树和两个节点 p、q,找到它们的最近公共祖先。
思路分析:
递归:如果当前节点是 p 或 q,返回当前节点。递归左右子树,如果 p 和 q 分别在左右子树中,当前节点就是 LCA。
def lowestCommonAncestor(root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
if not root or root == p or root == q:
return root
left = lowestCommonAncestor(root.left, p, q)
right = lowestCommonAncestor(root.right, p, q)
if left and right:
return root # p 和 q 分别在左右子树
return left or right # 都在同一侧
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(h)
面试追问: - 如果是 BST 呢?→ 利用 BST 性质,O(h) 时间 - 如果节点有父指针?→ 转化为链表相交问题 - 多个节点的最近公共祖先?→ 两两求 LCA
27. 二叉树的序列化与反序列化(Serialize and Deserialize Binary Tree)¶
题目描述: 设计一个算法来序列化和反序列化二叉树。
思路分析:
使用前序遍历,用特殊标记(如 "#")表示 null 节点。
class Codec:
def serialize(self, root: TreeNode) -> str:
"""前序遍历序列化"""
result = []
def dfs(node):
if not node:
result.append("#")
return
result.append(str(node.val))
dfs(node.left)
dfs(node.right)
dfs(root)
return ",".join(result)
def deserialize(self, data: str) -> TreeNode:
"""反序列化"""
values = iter(data.split(","))
def dfs():
val = next(values)
if val == "#":
return None
node = TreeNode(int(val))
node.left = dfs()
node.right = dfs()
return node
return dfs()
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(n)
面试追问: - 层序遍历序列化?→ BFS + null 填充 - 如何压缩序列化结果?→ 二进制编码 / 位运算 - N 叉树如何序列化?→ 记录子节点数量
28. 二叉树中的最大路径和(Binary Tree Maximum Path Sum)¶
题目描述: 给定二叉树,找出任意节点到任意节点的路径的最大路径和。路径不一定经过根节点。
思路分析:
后序遍历,对于每个节点计算经过它的最大路径和。递归返回的是单侧最大贡献值(不能拐弯)。
def maxPathSum(root: TreeNode) -> int:
max_sum = float('-inf')
def max_gain(node):
nonlocal max_sum
if not node:
return 0
# 左右子树的最大贡献值(负数贡献不要)
left_gain = max(max_gain(node.left), 0)
right_gain = max(max_gain(node.right), 0)
# 经过当前节点的最大路径和
price_new_path = node.val + left_gain + right_gain
max_sum = max(max_sum, price_new_path)
# 返回单侧最大贡献值
return node.val + max(left_gain, right_gain)
max_gain(root)
return max_sum
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(h)
面试追问: - 如何输出最大路径本身?→ 记录路径节点 - 路径必须从叶到叶?→ 修改 gain 不能取 0 - 路径至少包含 k 个节点?→ 增加约束条件
29. 从前序与中序遍历构造二叉树¶
题目描述: 根据前序遍历和中序遍历的结果,构造二叉树。
思路分析:
前序的第一个元素是根。在中序中找到根的位置,左边是左子树,右边是右子树。递归构建。
def buildTree(preorder: list[int], inorder: list[int]) -> TreeNode:
# 中序值 -> 下标映射,加速查找
inorder_map = {val: idx for idx, val in enumerate(inorder)} # enumerate同时获取索引和值
pre_idx = 0
def helper(in_left, in_right):
nonlocal pre_idx
if in_left > in_right:
return None
root_val = preorder[pre_idx]
root = TreeNode(root_val)
pre_idx += 1
# 在中序中找到根的位置
in_root = inorder_map[root_val]
# 先构建左子树,再构建右子树(与前序遍历顺序一致)
root.left = helper(in_left, in_root - 1)
root.right = helper(in_root + 1, in_right)
return root
return helper(0, len(inorder) - 1)
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(n),哈希表
面试追问: - 从中序和后序构建?→ 后序最后一个是根,从右往左处理 - 如果有重复值?→ 无法唯一确定树结构 - 从前序和后序能否确定唯一二叉树?→ 不能,除非是满二叉树
30. 二叉树的直径(Diameter of Binary Tree)¶
题目描述: 给定二叉树,计算它的直径(任意两节点之间最长路径的长度)。
def diameterOfBinaryTree(root: TreeNode) -> int:
diameter = 0
def depth(node):
nonlocal diameter
if not node:
return 0
left = depth(node.left)
right = depth(node.right)
diameter = max(diameter, left + right)
return 1 + max(left, right)
depth(root)
return diameter
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(h)
面试追问: - 直径不一定经过根节点,如何理解?→ 每个节点都可能是"拐点" - N 叉树的直径?→ 取最大的两个子树深度之和 - 带权边的直径?→ 边权累加
31. 翻转二叉树(Invert Binary Tree)¶
题目描述: 翻转一棵二叉树(镜像)。
def invertTree(root: TreeNode) -> TreeNode:
if not root:
return None
root.left, root.right = root.right, root.left
invertTree(root.left)
invertTree(root.right)
return root
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(h)
面试追问: - 迭代实现?→ BFS 或 DFS + 栈 - 判断两棵树是否互为镜像?→ 对称树判断 - 原地翻转后如何恢复?→ 再翻转一次
32. 二叉树的右视图(Binary Tree Right Side View)¶
题目描述: 给定二叉树,返回从右侧看到的节点值(每层最后一个节点)。
def rightSideView(root: TreeNode) -> list[int]:
if not root:
return []
result = []
queue = deque([root])
while queue:
level_size = len(queue)
for i in range(level_size):
node = queue.popleft()
if i == level_size - 1: # 每层最后一个节点
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(n)
面试追问: - DFS 如何实现?→ 先访问右子树,记录每层第一次访问的节点 - 左视图?→ 取每层第一个节点 - 俯视图/仰视图?→ 列坐标的最上/最下节点
33. 完全二叉树的节点个数(Count Complete Tree Nodes)¶
题目描述: 给定完全二叉树,求其节点个数。要求优于 O(n)。
思路分析:
利用完全二叉树性质:如果左子树高度等于右子树高度,则左子树是满二叉树。
def countNodes(root: TreeNode) -> int:
if not root:
return 0
left_height = right_height = 0
left_node, right_node = root, root
while left_node:
left_height += 1
left_node = left_node.left
while right_node:
right_height += 1
right_node = right_node.right
# 如果高度相同,是满二叉树
if left_height == right_height:
return 2**left_height - 1
return 1 + countNodes(root.left) + countNodes(root.right)
复杂度分析: - 时间复杂度:O(log²n) - 空间复杂度:O(log n)
面试追问: - 二分搜索方法?→ 二分最后一层节点,O(log²n) - 如何判断第 k 个节点是否存在?→ 用路径的二进制表示 - 完全二叉树的最后一个节点?→ 二分找最后一层最右
四、动态规划(10题)¶
34. 0-1背包问题¶
题目描述: 有 n 件物品和容量为 W 的背包,第 i 件物品重量为 w[i],价值为 v[i]。每件物品只能用一次,求背包能装的最大价值。
思路分析:
dp[i][j]表示前i件物品、容量为j时的最大价值- 转移方程:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) - 空间优化:一维数组,倒序遍历容量
def knapsack(weights: list[int], values: list[int], capacity: int) -> int:
n = len(weights)
# 空间优化:一维 DP
dp = [0] * (capacity + 1)
for i in range(n):
# 倒序遍历容量,避免重复使用同一物品
for j in range(capacity, weights[i] - 1, -1):
dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
return dp[capacity]
复杂度分析: - 时间复杂度:O(nW) - 空间复杂度:O(W),一维优化后
面试追问: - 完全背包(物品可重复使用)?→ 正序遍历容量 - 多重背包?→ 二进制优化 - 如何输出选了哪些物品?→ 回溯 dp 数组
35. 最长递增子序列(Longest Increasing Subsequence)¶
题目描述: 给定整数数组 nums,找到最长严格递增子序列的长度。
思路分析:
- DP法:
dp[i]表示以nums[i]结尾的 LIS 长度,O(n²) - 贪心 + 二分查找: 维护一个有序数组
tails,用二分查找更新
import bisect
def lengthOfLIS(nums: list[int]) -> int:
# 贪心 + 二分查找 O(n log n)
tails = [] # tails[i] = 长度为 i+1 的 LIS 的最小末尾元素
for num in nums:
pos = bisect.bisect_left(tails, num)
if pos == len(tails):
tails.append(num) # 扩展 LIS
else:
tails[pos] = num # 替换,使末尾更小
return len(tails)
复杂度分析: - 时间复杂度:O(n log n) - 空间复杂度:O(n)
面试追问: - 如何输出最长递增子序列本身?→ 记录每个位置的前驱 - 最长不下降子序列?→ 用 bisect_right - 最长递增子序列的个数?→ DP + 计数
36. 编辑距离(Edit Distance)¶
题目描述: 给定两个单词 word1 和 word2,计算将 word1 转换成 word2 所需的最少操作数(插入、删除、替换)。
思路分析:
dp[i][j] 表示 word1[0..i-1] 转换到 word2[0..j-1] 的最少操作数。
def minDistance(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# dp[i][j]: word1[:i] -> word2[:j] 的最少操作
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]
复杂度分析: - 时间复杂度:O(mn) - 空间复杂度:O(mn),可优化到 O(min(m, n))
面试追问: - 只允许插入和删除?→ 转化为 LCS 问题 - 不同操作有不同代价?→ 修改转移方程中的权重 - 模糊匹配/拼写纠错?→ 编辑距离的实际应用
37. 买卖股票的最佳时机系列¶
题目描述: 给定股票每天价格数组,在不同约束下求最大利润。
思路分析:
- 只买卖一次: 维护最低价,计算每天卖出的利润
- 无限次买卖: 贪心,只要今天比昨天高就累加
- 最多k次买卖: DP,状态为
(天数, 交易次数, 持有状态)
# 买卖一次
def maxProfit_once(prices: list[int]) -> int:
min_price = float('inf')
max_profit = 0
for price in prices:
min_price = min(min_price, price)
max_profit = max(max_profit, price - min_price)
return max_profit
# 无限次买卖
def maxProfit_unlimited(prices: list[int]) -> int:
return sum(max(0, prices[i] - prices[i - 1]) for i in range(1, len(prices)))
# 最多 k 次买卖
def maxProfit_k(k: int, prices: list[int]) -> int:
n = len(prices)
if n < 2:
return 0
# 如果 k >= n/2,等同于无限次
if k >= n // 2:
return sum(max(0, prices[i] - prices[i - 1]) for i in range(1, n))
# dp[j][0/1]: 完成 j 次交易,不持有/持有 的最大利润
dp = [[0, float('-inf')] for _ in range(k + 1)]
for price in prices:
for j in range(1, k + 1):
dp[j][0] = max(dp[j][0], dp[j][1] + price) # 卖出
dp[j][1] = max(dp[j][1], dp[j - 1][0] - price) # 买入
return dp[k][0]
复杂度分析: - 一次:O(n) 时间,O(1) 空间 - 无限次:O(n) 时间,O(1) 空间 - k次:O(nk) 时间,O(k) 空间
面试追问: - 含冷冻期?→ 卖出后一天不能买入 - 含手续费?→ 卖出时减去手续费 - 最多持有 m 股?→ 扩展状态维度
38. 零钱兑换(Coin Change)¶
题目描述: 给定不同面额的硬币 coins 和总金额 amount,计算凑成总金额所需的最少硬币数。
def coinChange(coins: list[int], amount: int) -> int:
# dp[i] 表示凑成金额 i 的最少硬币数
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if coin <= i:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
复杂度分析: - 时间复杂度:O(amount × n),n 为硬币种类数 - 空间复杂度:O(amount)
面试追问: - 凑成 amount 的组合数?→ 完全背包变形 - 如果硬币数量有限?→ 多重背包 - BFS 方法?→ 最少步数 = 最短路径
39. 最长公共子序列(Longest Common Subsequence)¶
题目描述: 给定两个字符串 text1 和 text2,返回它们的最长公共子序列的长度。
def longestCommonSubsequence(text1: str, text2: str) -> int:
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]
复杂度分析: - 时间复杂度:O(mn) - 空间复杂度:O(mn),可优化到 O(min(m, n))
面试追问: - 如何输出 LCS 本身?→ 回溯 dp 数组 - 最长公共子串(连续)?→ 修改转移方程 - 三个字符串的 LCS?→ 三维 DP
40. 正则表达式匹配(Regular Expression Matching)¶
题目描述: 实现支持 . 和 * 的正则表达式匹配。. 匹配任意单字符,* 匹配零个或多个前面的元素。
def isMatch(s: str, p: str) -> bool:
m, n = len(s), len(p)
# dp[i][j]: s[:i] 是否匹配 p[:j]
dp = [[False] * (n + 1) for _ in range(m + 1)]
dp[0][0] = True
# 初始化:空字符串匹配 a*b*c* 形式
for j in range(2, n + 1):
if p[j - 1] == '*':
dp[0][j] = dp[0][j - 2]
for i in range(1, m + 1):
for j in range(1, n + 1):
if p[j - 1] == '*':
# * 匹配零次 or 匹配一次以上
dp[i][j] = dp[i][j - 2] # 匹配零次
if p[j - 2] == '.' or p[j - 2] == s[i - 1]:
dp[i][j] = dp[i][j] or dp[i - 1][j] # 匹配一次以上
elif p[j - 1] == '.' or p[j - 1] == s[i - 1]:
dp[i][j] = dp[i - 1][j - 1]
return dp[m][n]
复杂度分析: - 时间复杂度:O(mn) - 空间复杂度:O(mn)
面试追问: - 通配符匹配(? 和 *)?→ * 匹配任意字符串,DP 类似 - NFA/DFA 实现?→ 自动机理论 - 如何处理反向引用 \1?→ 需要更复杂的 NFA
41. 打家劫舍(House Robber)¶
题目描述: 不能偷相邻房屋,求最大偷窃金额。
def rob(nums: list[int]) -> int:
if not nums:
return 0
if len(nums) <= 2:
return max(nums)
# 空间优化:只需前两个状态
prev2, prev1 = nums[0], max(nums[0], nums[1])
for i in range(2, len(nums)):
curr = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, curr
return prev1
# 打家劫舍 II(环形)
def rob_circular(nums: list[int]) -> int:
if len(nums) == 1:
return nums[0]
# 分两种情况:不偷第一间 or 不偷最后一间
return max(rob(nums[1:]), rob(nums[:-1]))
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(1)
面试追问: - 环形排列(打家劫舍II)?→ 分两种情况讨论 - 树形排列(打家劫舍III)?→ 树形 DP - 如果可以偷隔一间以上?→ 相当于无约束
42. 不同路径(Unique Paths)¶
题目描述: m × n 网格,从左上角到右下角,每次只能向下或向右移动一步,共有多少条不同路径。
def uniquePaths(m: int, n: int) -> int:
# 空间优化:一维 DP
dp = [1] * n
for i in range(1, m):
for j in range(1, n):
dp[j] += dp[j - 1]
return dp[n - 1]
# 数学方法:组合数 C(m+n-2, m-1)
from math import comb
def uniquePaths_math(m: int, n: int) -> int:
return comb(m + n - 2, m - 1)
复杂度分析: - DP:时间 O(mn),空间 O(n) - 数学:时间 O(min(m, n)),空间 O(1)
面试追问: - 有障碍物?→ 障碍位置 dp[j] = 0 - 最小路径和?→ dp[i][j] = min(上, 左) + grid[i][j] - 可以向四个方向走(不重复)?→ 回溯搜索
43. 最大子数组和(Maximum Subarray / Kadane's Algorithm)¶
题目描述: 找到连续子数组的最大和。
def maxSubArray(nums: list[int]) -> int:
max_sum = nums[0]
current_sum = nums[0]
for i in range(1, len(nums)):
# 要么延续之前的子数组,要么从当前元素重新开始
current_sum = max(nums[i], current_sum + nums[i])
max_sum = max(max_sum, current_sum)
return max_sum
复杂度分析: - 时间复杂度:O(n) - 空间复杂度:O(1)
面试追问: - 分治法?→ O(n log n),最大子数组要么全在左半、全在右半、或跨越中点 - 输出子数组的起止位置?→ 记录 start 和 end - 环形子数组最大和?→ max(正常最大和, 总和 - 正常最小和)
五、其他高频(7题)¶
44. 前 K 个高频元素(Top K Frequent Elements)¶
题目描述: 给定整数数组 nums 和整数 k,返回出现频率前 k 高的元素。
思路分析:
- 排序法: O(n log n)
- 最小堆: 维护大小为 k 的最小堆 O(n log k)
- 桶排序: O(n)
import heapq
from collections import Counter # Counter计数器:统计元素出现次数
def topKFrequent(nums: list[int], k: int) -> list[int]:
count = Counter(nums)
# 最小堆,堆大小为 k
return heapq.nlargest(k, count.keys(), key=count.get)
# 桶排序方法 O(n)
def topKFrequent_bucket(nums: list[int], k: int) -> list[int]:
count = Counter(nums)
n = len(nums)
# 频率桶:bucket[i] 存储出现 i 次的元素
bucket = [[] for _ in range(n + 1)]
for num, freq in count.items():
bucket[freq].append(num)
result = []
for freq in range(n, 0, -1):
for num in bucket[freq]:
result.append(num)
if len(result) == k:
return result
return result
复杂度分析: - 堆方法:时间 O(n log k),空间 O(n) - 桶排序:时间 O(n),空间 O(n)
面试追问: - 快速选择算法?→ O(n) 平均时间 - 数据流场景?→ 维护堆 + 哈希表 - 前 K 个高频单词(有字母序要求)?→ 自定义排序
45. 数据流的中位数(Find Median from Data Stream)¶
题目描述: 设计一个支持 addNum 和 findMedian 的数据结构。
思路分析:
两个堆: 最大堆(存较小半部分) + 最小堆(存较大半部分),保持两堆大小平衡。
import heapq
class MedianFinder:
def __init__(self):
self.small = [] # 最大堆(取反实现)
self.large = [] # 最小堆
def addNum(self, num: int) -> None:
# 先加入最大堆
heapq.heappush(self.small, -num)
# 确保最大堆的最大值 ≤ 最小堆的最小值
heapq.heappush(self.large, -heapq.heappop(self.small))
# 平衡大小:最大堆可以多一个
if len(self.large) > len(self.small):
heapq.heappush(self.small, -heapq.heappop(self.large))
def findMedian(self) -> float:
if len(self.small) > len(self.large):
return -self.small[0]
return (-self.small[0] + self.large[0]) / 2
复杂度分析: - addNum:O(log n) - findMedian:O(1) - 空间复杂度:O(n)
面试追问: - 滑动窗口中位数?→ 需要支持删除操作,用有序集合 - 分布式场景?→ 分位数算法、t-digest - 如果数据在有限范围内?→ 桶计数
46. 岛屿数量(Number of Islands)¶
题目描述: 给定 m × n 二维网格('1' 为陆地,'0' 为水),计算岛屿数量。
def numIslands(grid: list[list[str]]) -> int:
if not grid:
return 0
m, n = len(grid), len(grid[0])
count = 0
def dfs(i, j):
if i < 0 or i >= m or j < 0 or j >= n or grid[i][j] != '1':
return
grid[i][j] = '0' # 标记已访问
dfs(i + 1, j)
dfs(i - 1, j)
dfs(i, j + 1)
dfs(i, j - 1)
for i in range(m):
for j in range(n):
if grid[i][j] == '1':
count += 1
dfs(i, j)
return count
复杂度分析: - 时间复杂度:O(mn) - 空间复杂度:O(mn),递归栈最坏情况
面试追问: - 用 BFS 实现?→ 队列遍历 - 用并查集实现?→ Union-Find - 岛屿最大面积?→ DFS 时计数 - 不修改原数组?→ 额外 visited 数组
47. 单词搜索(Word Search)¶
题目描述: 给定 m × n 字符网格和一个单词,判断是否存在于网格中(上下左右相邻,不能重复使用)。
def exist(board: list[list[str]], word: str) -> bool:
m, n = len(board), len(board[0])
def backtrack(i, j, k):
if k == len(word):
return True
if i < 0 or i >= m or j < 0 or j >= n or board[i][j] != word[k]:
return False
# 标记已访问
temp, board[i][j] = board[i][j], '#'
found = (backtrack(i + 1, j, k + 1) or
backtrack(i - 1, j, k + 1) or
backtrack(i, j + 1, k + 1) or
backtrack(i, j - 1, k + 1))
board[i][j] = temp # 回溯
return found
for i in range(m):
for j in range(n):
if backtrack(i, j, 0):
return True
return False
复杂度分析: - 时间复杂度:O(m × n × 3^L),L 为单词长度 - 空间复杂度:O(L),递归深度
面试追问: - 搜索多个单词?→ 用 Trie 优化(单词搜索 II) - 允许对角线移动?→ 增加 4 个方向 - 环形网格?→ 取模处理边界
48. 全排列(Permutations)¶
题目描述: 给定不包含重复数字的数组 nums,返回所有可能的全排列。
def permute(nums: list[int]) -> list[list[int]]:
result = []
def backtrack(path, remaining):
if not remaining:
result.append(path[:]) # 切片操作:[start:end:step]提取子序列
return
for i in range(len(remaining)):
path.append(remaining[i])
backtrack(path, remaining[:i] + remaining[i + 1:])
path.pop()
backtrack([], nums)
return result
# 有重复元素的全排列
def permuteUnique(nums: list[int]) -> list[list[int]]:
result = []
nums.sort()
used = [False] * len(nums)
def backtrack(path):
if len(path) == len(nums):
result.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
# 剪枝:相同元素跳过(前一个相同元素未使用时跳过)
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
used[i] = True
path.append(nums[i])
backtrack(path)
path.pop()
used[i] = False
backtrack([])
return result
复杂度分析: - 时间复杂度:O(n × n!) - 空间复杂度:O(n),递归深度
面试追问: - 第 k 个排列?→ 数学方法逐位确定 - 下一个排列?→ 见题目 15 - 组合/子集问题?→ 类似回溯框架
49. 实现 Trie(前缀树)¶
题目描述: 实现 Trie 数据结构,支持 insert、search、startsWith。
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for char in word:
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_end = True
def search(self, word: str) -> bool:
node = self._find(word)
return node is not None and node.is_end
def startsWith(self, prefix: str) -> bool:
return self._find(prefix) is not None
def _find(self, prefix: str) -> TrieNode:
node = self.root
for char in prefix:
if char not in node.children:
return None
node = node.children[char]
return node
复杂度分析: - insert/search/startsWith:O(m),m 为字符串长度 - 空间复杂度:O(字符总数 × 字符集大小)
面试追问: - 通配符搜索(. 匹配任意字符)?→ DFS 遍历所有子节点 - 自动补全?→ DFS 收集所有以前缀开头的单词 - 压缩 Trie(Radix Tree)?→ 合并只有一个子节点的路径
50. 最短路径 - Dijkstra 算法¶
题目描述: 给定加权有向图,求从源点到所有其他顶点的最短路径。
思路分析:
Dijkstra 算法: 贪心策略,每次选择距离最小的未访问节点,用它更新邻居的最短距离。用最小堆优化。
import heapq
from collections import defaultdict # defaultdict带默认值的字典,避免KeyError
def dijkstra(graph: dict, start: int, n: int) -> list[int]:
"""
graph: 邻接表 {u: [(v, weight), ...]}
start: 起始节点
n: 节点数
返回: 从 start 到每个节点的最短距离
"""
dist = [float('inf')] * n
dist[start] = 0
heap = [(0, start)] # (距离, 节点)
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue # 跳过过时的条目
for v, weight in graph[u]:
new_dist = dist[u] + weight
if new_dist < dist[v]:
dist[v] = new_dist
heapq.heappush(heap, (new_dist, v))
return dist
# 使用示例
# graph = defaultdict(list)
# graph[0] = [(1, 4), (2, 1)]
# graph[1] = [(3, 1)]
# graph[2] = [(1, 2), (3, 5)]
# distances = dijkstra(graph, 0, 4)
复杂度分析: - 时间复杂度:O((V + E) log V),优先队列实现 - 空间复杂度:O(V + E)
面试追问: - 有负权边怎么办?→ Bellman-Ford 算法 - 所有节点对的最短路径?→ Floyd-Warshall O(V³) - A* 搜索与 Dijkstra 的区别?→ A* 加入了启发式函数 - 无权图最短路径?→ BFS 就够了
六、面试总结与技巧¶
解题步骤(UMPIRE 法)¶
- U - Understand(理解题目): 复述题目,确认输入输出格式、边界条件
- M - Match(匹配模式): 将题目与已知的算法模式匹配(滑动窗口、双指针、DP 等)
- P - Plan(制定计划): 描述算法思路,讨论时间空间复杂度
- I - Implement(实现代码): 编写干净、有注释的代码
- R - Review(代码审查): 检查边界条件、特殊情况
- E - Evaluate(评估): 分析复杂度,讨论优化空间
高频算法模式速查¶
| 模式 | 适用场景 | 典型题目 |
|---|---|---|
| 双指针 | 有序数组、链表 | 两数之和、三数之和、接雨水 |
| 滑动窗口 | 连续子数组/子串 | 最长无重复子串、最小覆盖子串 |
| 快慢指针 | 链表环检测 | 环形链表、链表中点 |
| BFS/DFS | 图、树遍历 | 岛屿数量、层序遍历 |
| 回溯 | 排列组合、搜索 | 全排列、单词搜索、N皇后 |
| 动态规划 | 最优子结构 | 背包、LIS、编辑距离 |
| 贪心 | 局部最优→全局最优 | 买卖股票、合并区间 |
| 单调栈/队列 | 下一个更大元素 | 接雨水、滑动窗口最大值 |
| 二分查找 | 有序空间搜索 | LIS(二分优化)、搜索旋转数组 |
| 并查集 | 连通性问题 | 岛屿数量、冗余连接 |
| 堆 | Top-K 问题 | 前K高频元素、合并K个链表 |
| Trie | 前缀匹配 | 实现Trie、单词搜索II |
时间复杂度速查¶
- O(log n): 二分查找
- O(n): 线性扫描、哈希表查找
- O(n log n): 排序、堆操作
- O(n²): 双层循环、简单 DP
- O(2^n): 子集枚举
- O(n!): 全排列
学习建议: 按类型刷题,每类先掌握模板,再练变种。面试时先说思路再写代码,注重沟通。