实战案例:攻克 LeetCode 高频 50 题 - 系统化刷题指南
场景描述:本案例旨在帮助面试者系统化地攻克 LeetCode 高频算法题,通过精选的 50 道典型题目,覆盖数组、链表、树、图、动态规划等核心题型,建立高效的解题思维框架和代码模板,为技术面试中的算法环节做充分准备。
你将学到
- 题型分类与识别:掌握将不同问题归类到已知数据结构和算法模型(如双指针、滑动窗口、DFS/BFS、DP)的能力。
- 系统化解题框架:学会一套“审题 -> 分析复杂度 -> 选择策略 -> 编码实现 -> 测试验证”的标准解题流程。
- 常见优化技巧:理解并应用时间与空间复杂度的优化方法,如用哈希表降低循环嵌套、用动态规划避免重复计算。
- 通用代码模板:积累针对不同题型(如二叉树遍历、图搜索、背包问题)的标准化代码模板,提升编码速度和准确性。
- 面试应试策略:学习如何在面试中清晰沟通思路、处理边界条件、与面试官互动,以及如何管理解题时间。
前置知识
- 算法复杂度分析:理解 Big O 表示法,能够分析代码的时间与空间复杂度。
- 基础数据结构:熟悉数组、链表的基本操作和特性。
- 递归思想:理解递归的工作原理,能够编写递归函数。
- 常见排序算法:了解快速排序、归并排序等经典排序算法的思想。
架构设计
我们采用一种系统化的方法来处理和解决这 50 道题,整体架构如下:
[高频题库 50 题]
|
v
[题型分类与知识图谱]
|
+--------------------------------------------------+
| | | |
v v v v
[数组与字符串] [链表与递归] [树与图] [动态规划]
(双指针, 滑动 (快慢指针, (DFS/BFS, (背包, 最优
窗口, 二分 递归反转, 遍历, 拓扑 子结构, 状态
查找等) 合并等) 排序等) 转移方程)
| | | |
+------------------+-----------------+--------------+
|
v
[标准化解题流程]
|
v
[审题->分析->选择策略->编码->测试]
|
v
[代码模板库 & 最佳实践]
|
v
[面试模拟与实战演练]
核心思路:将题目归类,针对每一类题型总结通用的解题策略和代码模板,然后在标准化流程中应用这些模板来高效解题。
实现步骤
我们将以五道来自不同题型的 LeetCode 高频题为例,演示完整的解题过程。
步骤 1:数组与字符串 - 双指针技巧 (LC 15. 三数之和)
题目描述:给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j, i != k 且 j != k,同时还满足 nums[i] + nums[j] + nums[k] == 0。返回所有和为 0 且不重复的三元组。
关键思路:
- 排序:首先对数组排序,便于双指针移动和去重。
- 固定一个数,双指针找另外两个数:遍历数组,将当前数
nums[i]作为第一个数,在其后的区间[i+1, n-1]内使用双指针left和right寻找两个数,使得nums[left] + nums[right] == -nums[i]。 - 去重:在移动指针时,跳过重复元素,确保结果唯一。
def threeSum(nums):
"""
:type nums: List[int]
:rtype: List[List[int]]
"""
nums.sort() # 步骤1:排序
n = len(nums)
result = []
for i in range(n):
# 跳过重复的第一个数
if i > 0 and nums[i] == nums[i-1]:
continue
# 双指针查找另外两个数
left, right = i + 1, n - 1
target = -nums[i] # 我们要找 nums[left] + nums[right] == target
while left < right:
current_sum = nums[left] + nums[right]
if current_sum == target:
# 找到一个解,加入结果
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 current_sum < target:
# 和太小,左指针右移
left += 1
else:
# 和太大,右指针左移
right -= 1
return result
# 测试
print(threeSum([-1,0,1,2,-1,-4])) # [[-1,-1,2],[-1,0,1]]
复杂度分析:时间复杂度 O(n^2),排序 O(n log n),外层循环 O(n),内层双指针 O(n)。空间复杂度 O(1) (忽略结果存储)。
步骤 2:链表 - 递归与迭代 (LC 21. 合并两个有序链表)
题目描述:将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
关键思路:
- 迭代法:使用一个哨兵节点
dummy,用指针tail指向新链表的末尾。每次比较两个链表的头节点,将较小的节点接到tail后面,然后移动相应链表的指针和tail。 - 递归法:问题可分解为:
mergeTwoLists(l1, l2)= 如果l1.val < l2.val,则l1是头节点,l1.next是l1.next和l2的合并结果;反之亦然。
迭代法实现:
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def mergeTwoLists(l1, l2):
# 创建哨兵节点
dummy = ListNode(-1)
tail = dummy
while l1 and l2:
if l1.val <= l2.val:
tail.next = l1
l1 = l1.next
else:
tail.next = l2
l2 = l2.next
tail = tail.next
# 拼接剩余部分
tail.next = l1 if l1 else l2
return dummy.next
# 辅助函数:用于构建和打印链表
def build_list(vals):
if not vals:
return None
head = ListNode(vals[0])
current = head
for val in vals[1:]:
current.next = ListNode(val)
current = current.next
return head
def print_list(head):
vals = []
while head:
vals.append(str(head.val))
head = head.next
print(" -> ".join(vals) if vals else "Empty")
# 测试
l1 = build_list([1, 2, 4])
l2 = build_list([1, 3, 4)
merged_head = mergeTwoLists(l1, l2)
print_list(merged_head) # 1 -> 1 -> 2 -> 3 -> 4 -> 4
复杂度分析:时间复杂度 O(m+n),空间复杂度 O(1)。
步骤 3:二叉树 - 深度优先搜索 (LC 236. 二叉树的最近公共祖先)
题目描述:给定一个二叉树, 找到该树中两个指定节点 p 和 q 的最近公共祖先(LCA)。最近公共祖先是指对于树的两个节点,最近的同时是 p 和 q 祖先的节点(一个节点也可以是它自己的祖先)。
关键思路:使用后序遍历(DFS)。递归函数 lowestCommonAncestor(root, p, q):
- 基本情况:如果
root为空,或者root就是p或q,则返回root。 - 递归搜索:在左子树和右子树中分别查找
p和q。 - 判断:
- 如果
p和q分别在当前root的左右子树中,则root就是 LCA。 - 如果
p和q都在左子树中,则 LCA 在左子树。 - 如果
p和q都在右子树中,则 LCA 在右子树。
- 如果
class TreeNode:
def __init__(self, x):
self.val = x
self.left = None
self.right = None
def lowestCommonAncestor(root, p, q):
"""
:type root: TreeNode
:type p: TreeNode
:type q: TreeNode
:rtype: TreeNode
"""
# 基本情况:空节点或找到目标节点
if root is None or root == p or root == q:
return root
# 后序遍历:先在左右子树中找
left = lowestCommonAncestor(root.left, p, q)
right = lowestCommonAncestor(root.right, p, q)
# 判断逻辑
if left and right:
# p 和 q 分居两侧,当前 root 是 LCA
return root
elif left:
# p 和 q 都在左子树,返回左子树的结果
return left
else:
# p 和 q 都在右子树,或只在右侧找到一个
return right
# 构建测试树 (LeetCode 示例)
root = TreeNode(3)
root.left = TreeNode(5)
root.right = TreeNode(1)
root.left.left = TreeNode(6)
root.left.right = TreeNode(2)
root.right.left = TreeNode(0)
root.right.right = TreeNode(8)
p = root.left # 节点5
q = root.right # 节点1
lca_node = lowestCommonAncestor(root, p, q)
print(f"LCA of {p.val} and {q.val} is {lca_node.val}") # 输出 3
复杂度分析:时间复杂度 O(n),每个节点最多访问一次。空间复杂度 O(n),递归栈深度。
步骤 4:图论 - 拓扑排序 (LC 207. 课程表)
题目描述:你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1。在选修某些课程之前需要一些先修课程。先修关系用一个先决条件数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] 表示想要学习课程 ai 则必须先完成课程 bi。请判断是否可能完成所有课程的学习。
关键思路:这是一个有向图中检测是否有环的问题。我们可以使用拓扑排序(Kahn's algorithm)。
- 建图与入度表:构建邻接表
graph和每个节点的入度表indegree。 - 初始化队列:将所有入度为 0 的节点(没有先修课程)加入队列。
- BFS遍历:
- 从队列中取出一个节点,将其从图中“移除”(遍历计数+1)。
- 将其所有邻接节点的入度减 1,如果某个邻接节点入度变为 0,则加入队列。
- 判断:如果遍历过的节点数等于总课程数
numCourses,则说明无环,可以完成;否则,说明图中存在环(循环依赖)。
from collections import deque
def canFinish(numCourses, prerequisites):
"""
:type numCourses: int
:type prerequisites: List[List[int]]
:rtype: bool
"""
# 步骤1:构建邻接表和入度表
graph = [[] for _ in range(numCourses)]
indegree = [0] * numCourses
for a, b in prerequisites: # a 的先决条件是 b,即 b -> a
graph[b].append(a)
indegree[a] += 1
# 步骤2:初始化队列,将所有入度为0的节点入队
queue = deque()
for i in range(numCourses):
if indegree[i] == 0:
queue.append(i)
# 步骤3:BFS 拓扑排序
count = 0 # 记录已遍历的节点数
while queue:
node = queue.popleft()
count += 1
# 遍历当前节点的所有邻接节点
for neighbor in graph[node]:
indegree[neighbor] -= 1
if indegree[neighbor] == 0:
queue.append(neighbor)
# 步骤4:判断是否所有节点都被访问
return count == numCourses
# 测试
print(canFinish(2, [[1,0]])) # True
print(canFinish(2, [[1,0],[0,1]])) # False
复杂度分析:时间复杂度 O(V + E),V 是课程数,E 是先决条件数。空间复杂度 O(V + E),用于存储图和入度表。
步骤 5:动态规划 - 基础状态转移 (LC 300. 最长递增子序列)
题目描述:给你一个整数数组 nums,找到其中最长严格递增子序列的长度。
关键思路:
- 定义状态:
dp[i]表示以nums[i]结尾的最长递增子序列的长度。 - 状态转移方程:对于每个
i,我们查看所有j < i,如果nums[j] < nums[i],那么nums[i]可以接在nums[j]后面形成一个更长的递增子序列,即dp[i] = max(dp[i], dp[j] + 1)。 - 初始化:每个
dp[i]至少为 1,即子序列只有自己本身。 - 答案:
dp数组中的最大值。
def lengthOfLIS(nums):
"""
:type nums: List[int]
:rtype: int
"""
if not nums:
return 0
n = len(nums)
dp = [1] * n # 初始化:每个元素自身就是长度为1的子序列
# 遍历每个位置
for i in range(1, n):
# 查找在 i 之前,且比 nums[i] 小的元素
for j in range(i):
if nums[j] < nums[i]:
# 状态转移
dp[i] = max(dp[i], dp[j] + 1)
# 返回 dp 数组中的最大值
return max(dp)
# 测试
print(lengthOfLIS([10,9,2,5,3,7,101,18])) # 4 ([2,3,7,101] 或 [2,5,7,101] 等)
复杂度分析:时间复杂度 O(n^2),空间复杂度 O(n)。存在 O(n log n) 的优化解法(利用二分查找维护一个“耐心排序”的数组)。
完整项目结构
建议按照题型对代码文件进行组织,便于回顾和复习。
leetcode-high-frequency-50/
├── README.md # 项目总览和学习路线
├── solutions/
│ ├── array/ # 数组与字符串
│ │ ├── two_sum.py
│ │ ├── three_sum.py
│ │ ├── container_with_most_water.py
│ │ └── ...
│ ├── linked_list/ # 链表
│ │ ├── reverse_linked_list.py
│ │ ├── merge_two_sorted_lists.py
│ │ ├── linked_list_cycle.py
│ │ └── ...
│ ├── tree/ # 二叉树
│ │ ├── invert_binary_tree.py
│ │ ├── maximum_depth_of_binary_tree.py
│ │ ├── lowest_common_ancestor.py
│ │ └── ...
│ ├── graph/ # 图论
│ │ ├── number_of_islands.py
│ │ ├── course_schedule.py
│ │ └── ...
│ ├── dp/ # 动态规划
│ │ ├── climbing_stairs.py
│ │ ├── coin_change.py
│ │ ├── longest_increasing_subsequence.py
│ │ └── ...
│ └── templates/ # 通用代码模板
│ ├── binary_tree_dfs.py
│ ├── bfs_template.py
│ ├── sliding_window.py
│ └── dp_template.py
└── notes/
├── patterns.md # 解题模式总结
└── complexity.md # 复杂度分析笔记
最佳实践
- 先暴力,再优化:面试时,如果无法立刻想到最优解,先给出一个时间复杂度较高的暴力解法(例如 O(n^2) 或 O(2^n)),并向面试官说明。这能展示你的解题思路,并为优化奠定基础。
- 边界条件测试:在编写代码时,心里要始终装着边界条件(如空输入、单个元素、最大值/最小值等),并在提交或面试讲解前在心里或纸上进行测试。
- 善用代码模板:对于如 DFS、BFS、二分查找、滑动窗口等经典模式,牢记其标准模板。面试时在模板基础上进行修改,能极大减少编码错误和时间消耗。
- 注重沟通:在面试中,边写代码边解释你的思路。解释你为什么选择当前的数据结构或算法,时间/空间复杂度是多少,以及你是如何处理特殊情况的。
- 总结与复盘:每做完一道题,特别是难题,一定要总结。思考它属于哪种题型,核心思想是什么,有没有变种,与其他哪些题目类似。建立自己的“题目-模式”映射表。
常见问题 (FAQ)
Q1: 刷完这 50 道题就够了吗? A: 不够,但足够建立系统框架。这 50 道题覆盖了最高频的题型和模式,是建立算法思维框架的基石。掌握它们后,你应具备快速学习和解决新题型的能力。建议以此为基础,再向外扩展 20-30 道相关变种题进行巩固。
Q2: 遇到完全没思路的题目怎么办? A: 首先,不要慌张,这是正常的。可以按照以下步骤尝试:
- 举例子:手动模拟小规模输入,寻找规律。
- 想类似:回想这道题和你做过的哪道题类似(相同的数据结构?相同的算法?)。
- 降复杂度:先尝试暴力解法(哪怕时间复杂度很高),再考虑如何优化。
- 看提示:如果卡住太久,可以看官方题解或讨论区的提示(不是完整代码),获取关键思路后继续尝试。
Q3: 如何平衡刷题的数量和深度? A: 质量优于数量。对于一道典型题目,应该追求“一遍精,三遍通”。第一遍彻底理解并编码通过;第二遍限时练习,提升速度和稳定性;第三遍隔一段时间再做,检验是否真正掌握。盲目刷大量题而不总结,效果远不如把典型题目吃透。
扩展挑战
- 模拟面试:使用 LeetCode 的“模拟面试”功能或与同伴结对,随机抽取本案例中的题目,在 30-45 分钟内完成从审题到编码、测试、讲解的全过程。
- 一题多解:尝试用两种不同的方法解决同一道经典题目(例如,用动态规划和贪心算法解决“零钱兑换”),并对比分析它们的优劣和适用场景。
- 出题与讲解:选择一道你掌握得最好的题目,尝试向一位不懂编程的朋友或同事讲解清楚。这个过程会极大地深化你对题目本质的理解。
相关课程
| 课程 | 相关章节 |
|---|---|
| 算法基础 | 时间复杂度与空间复杂度 |
| 算法基础 | 数组基础 |
| 算法基础 | 链表 |
| 算法基础 | 递归基础 |
| 算法基础 | 快速排序 |