高级10+ 小时·面试准备

LeetCode 高频题解

精选 50 道 LeetCode 高频题,涵盖数组、链表、树、图、动态规划等核心题型

algorithmsleetcodeinterviewdynamic-programminggraph

实战案例:攻克 LeetCode 高频 50 题 - 系统化刷题指南

场景描述:本案例旨在帮助面试者系统化地攻克 LeetCode 高频算法题,通过精选的 50 道典型题目,覆盖数组、链表、树、图、动态规划等核心题型,建立高效的解题思维框架和代码模板,为技术面试中的算法环节做充分准备。

你将学到

  1. 题型分类与识别:掌握将不同问题归类到已知数据结构和算法模型(如双指针、滑动窗口、DFS/BFS、DP)的能力。
  2. 系统化解题框架:学会一套“审题 -> 分析复杂度 -> 选择策略 -> 编码实现 -> 测试验证”的标准解题流程。
  3. 常见优化技巧:理解并应用时间与空间复杂度的优化方法,如用哈希表降低循环嵌套、用动态规划避免重复计算。
  4. 通用代码模板:积累针对不同题型(如二叉树遍历、图搜索、背包问题)的标准化代码模板,提升编码速度和准确性。
  5. 面试应试策略:学习如何在面试中清晰沟通思路、处理边界条件、与面试官互动,以及如何管理解题时间。

前置知识

架构设计

我们采用一种系统化的方法来处理和解决这 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 != kj != k,同时还满足 nums[i] + nums[j] + nums[k] == 0。返回所有和为 0 且不重复的三元组。

关键思路

  1. 排序:首先对数组排序,便于双指针移动和去重。
  2. 固定一个数,双指针找另外两个数:遍历数组,将当前数 nums[i] 作为第一个数,在其后的区间 [i+1, n-1] 内使用双指针 leftright 寻找两个数,使得 nums[left] + nums[right] == -nums[i]
  3. 去重:在移动指针时,跳过重复元素,确保结果唯一。
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.nextl1.nextl2 的合并结果;反之亦然。

迭代法实现

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. 二叉树的最近公共祖先)

题目描述:给定一个二叉树, 找到该树中两个指定节点 pq 的最近公共祖先(LCA)。最近公共祖先是指对于树的两个节点,最近的同时是 pq 祖先的节点(一个节点也可以是它自己的祖先)。

关键思路:使用后序遍历(DFS)。递归函数 lowestCommonAncestor(root, p, q)

  1. 基本情况:如果 root 为空,或者 root 就是 pq,则返回 root
  2. 递归搜索:在左子树和右子树中分别查找 pq
  3. 判断
    • 如果 pq 分别在当前 root 的左右子树中,则 root 就是 LCA。
    • 如果 pq 都在左子树中,则 LCA 在左子树。
    • 如果 pq 都在右子树中,则 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 门课程,记为 0numCourses - 1。在选修某些课程之前需要一些先修课程。先修关系用一个先决条件数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] 表示想要学习课程 ai必须先完成课程 bi。请判断是否可能完成所有课程的学习。

关键思路:这是一个有向图中检测是否有环的问题。我们可以使用拓扑排序(Kahn's algorithm)。

  1. 建图与入度表:构建邻接表 graph 和每个节点的入度表 indegree
  2. 初始化队列:将所有入度为 0 的节点(没有先修课程)加入队列。
  3. BFS遍历
    • 从队列中取出一个节点,将其从图中“移除”(遍历计数+1)。
    • 将其所有邻接节点的入度减 1,如果某个邻接节点入度变为 0,则加入队列。
  4. 判断:如果遍历过的节点数等于总课程数 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,找到其中最长严格递增子序列的长度。

关键思路

  1. 定义状态dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。
  2. 状态转移方程:对于每个 i,我们查看所有 j < i,如果 nums[j] < nums[i],那么 nums[i] 可以接在 nums[j] 后面形成一个更长的递增子序列,即 dp[i] = max(dp[i], dp[j] + 1)
  3. 初始化:每个 dp[i] 至少为 1,即子序列只有自己本身。
  4. 答案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         # 复杂度分析笔记

最佳实践

  1. 先暴力,再优化:面试时,如果无法立刻想到最优解,先给出一个时间复杂度较高的暴力解法(例如 O(n^2) 或 O(2^n)),并向面试官说明。这能展示你的解题思路,并为优化奠定基础。
  2. 边界条件测试:在编写代码时,心里要始终装着边界条件(如空输入、单个元素、最大值/最小值等),并在提交或面试讲解前在心里或纸上进行测试。
  3. 善用代码模板:对于如 DFS、BFS、二分查找、滑动窗口等经典模式,牢记其标准模板。面试时在模板基础上进行修改,能极大减少编码错误和时间消耗。
  4. 注重沟通:在面试中,边写代码边解释你的思路。解释你为什么选择当前的数据结构或算法,时间/空间复杂度是多少,以及你是如何处理特殊情况的。
  5. 总结与复盘:每做完一道题,特别是难题,一定要总结。思考它属于哪种题型,核心思想是什么,有没有变种,与其他哪些题目类似。建立自己的“题目-模式”映射表。

常见问题 (FAQ)

Q1: 刷完这 50 道题就够了吗? A: 不够,但足够建立系统框架。这 50 道题覆盖了最高频的题型和模式,是建立算法思维框架的基石。掌握它们后,你应具备快速学习和解决新题型的能力。建议以此为基础,再向外扩展 20-30 道相关变种题进行巩固。

Q2: 遇到完全没思路的题目怎么办? A: 首先,不要慌张,这是正常的。可以按照以下步骤尝试:

  1. 举例子:手动模拟小规模输入,寻找规律。
  2. 想类似:回想这道题和你做过的哪道题类似(相同的数据结构?相同的算法?)。
  3. 降复杂度:先尝试暴力解法(哪怕时间复杂度很高),再考虑如何优化。
  4. 看提示:如果卡住太久,可以看官方题解或讨论区的提示(不是完整代码),获取关键思路后继续尝试。

Q3: 如何平衡刷题的数量和深度? A: 质量优于数量。对于一道典型题目,应该追求“一遍精,三遍通”。第一遍彻底理解并编码通过;第二遍限时练习,提升速度和稳定性;第三遍隔一段时间再做,检验是否真正掌握。盲目刷大量题而不总结,效果远不如把典型题目吃透。

扩展挑战

  1. 模拟面试:使用 LeetCode 的“模拟面试”功能或与同伴结对,随机抽取本案例中的题目,在 30-45 分钟内完成从审题到编码、测试、讲解的全过程。
  2. 一题多解:尝试用两种不同的方法解决同一道经典题目(例如,用动态规划和贪心算法解决“零钱兑换”),并对比分析它们的优劣和适用场景。
  3. 出题与讲解:选择一道你掌握得最好的题目,尝试向一位不懂编程的朋友或同事讲解清楚。这个过程会极大地深化你对题目本质的理解。

相关课程

课程相关章节
算法基础时间复杂度与空间复杂度
算法基础数组基础
算法基础链表
算法基础递归基础
算法基础快速排序