算法题种类与解题思路全面指南:基于LeetCode Hot 100与牛客Top 101
本文系统性地梳理了算法题的核心类型与解题思路,重点聚焦LeetCode Hot 100与牛客Top 101中的高频题目
一、算法学习的基础认知
1.1 复杂度分析基础
在深入学习具体算法之前,我们必须理解算法效率的衡量标准——时间复杂度和空间复杂度。这两个概念是评估算法优劣的核心指标。
时间复杂度的本质
时间复杂度并非指代码的实际运行时间,而是描述算法执行时间随数据规模增长的变化趋势,也称为渐进时间复杂度。用大O表示法记为T(n) = O(f(n)),其中n表示数据规模,f(n)表示代码执行次数与n的关系函数。
在计算时间复杂度时,我们遵循以下原则:
- 只保留最高阶项
- 忽略常数项和系数
- 从内向外分析,从最深层开始
常见时间复杂度从优到劣排序:
| 复杂度 | 名称 | 说明 | 典型算法 |
|---|---|---|---|
| O(1) | 常数阶 | 执行时间不随n变化 | 数组按索引访问、哈希表查找 |
| O(log n) | 对数阶 | 每次减半问题规模 | 二分查找、平衡二叉树操作 |
| O(n) | 线性阶 | 执行次数与n成正比 | 线性查找、单层循环 |
| O(n log n) | 线性对数阶 | 高效排序的标准 | 快速排序、归并排序、堆排序 |
| O(n²) | 平方阶 | 双层嵌套循环 | 冒泡排序、插入排序、选择排序 |
| O(n³) | 立方阶 | 三层嵌套循环 | 某些动态规划问题 |
| O(2ⁿ) | 指数阶 | 问题规模指数增长 | 递归求解斐波那契数列、回溯法 |
| O(n!) | 阶乘阶 | 全排列问题 | 旅行商问题的暴力解法 |
空间复杂度分析
空间复杂度是算法在运行过程中临时占用存储空间大小的量度,同样使用大O表示法。我们主要关注算法需要分配的额外空间,不包括输入数据本身占用的空间。
递归算法的空间复杂度需要特别注意递归调用栈的深度。例如,递归计算斐波那契数列的空间复杂度为O(n),因为最多有n层递归调用栈。
1.2 LeetCode Hot 100与牛客Top 101题目分布概览
为了更有针对性地学习,我们首先了解这两个高频题库的题型分布特点:
LeetCode Hot 100题型统计
牛客Top 101题型统计
| 题型 | 题目数量 | 占比 | 难度分布 |
|---|---|---|---|
| 链表 | 15 | 14.9% | 简单-中等 |
| 二叉树 | 17 | 16.8% | 简单-困难 |
| 动态规划 | 16 | 15.8% | 中等-困难 |
| 递归/回溯 | 10 | 9.9% | 中等-困难 |
| 排序 | 9 | 8.9% | 简单-中等 |
| 双指针 | 8 | 7.9% | 简单-中等 |
| 二分查找 | 7 | 6.9% | 中等 |
| 哈希 | 6 | 5.9% | 简单-中等 |
| 堆/栈/队列 | 7 | 6.9% | 中等 |
| 其他 | 6 | 5.9% | 不定 |
从分布可以看出,数据结构类题目(链表、树、栈/队列)和核心算法思想(动态规划、回溯、贪心)是高频考点的主体,这也将是本文重点讲解的内容。
二、基础数据结构类题目
2.1 数组与矩阵
核心特点
数组是最基础的数据结构,在内存中连续存储,支持O(1)时间的随机访问。
数组类题目通常考查对索引的灵活运用、边界条件处理、以及各种遍历技巧。
常见题型与解题思路
2.1.1 双指针技巧
双指针是数组题目中最常用的技巧之一,根据指针移动方式可分为:
对撞指针(两端向中间)
- 适用场景: 有序数组查找、回文判断、容器问题
- 经典题目:
- 两数之和II(LeetCode 167)
- 盛最多水的容器(LeetCode 11)
- 三数之和(LeetCode 15)
# 对撞指针模板
def two_pointers_converge(arr):left, right = 0, len(arr) - 1while left < right:# 根据条件移动指针if 满足某条件:left += 1else:right -= 1return result
快慢指针(同向移动)
- 适用场景: 数组去重、移动元素、滑动窗口
- 经典题目:
- 删除有序数组中的重复项(LeetCode 26)
- 移动零(LeetCode 283)
- 颜色分类(LeetCode 75)
# 快慢指针模板
def two_pointers_same_direction(arr):slow = 0for fast in range(len(arr)):if 满足保留条件:arr[slow] = arr[fast]slow += 1return slow # slow即为新数组长度
2.1.2 前缀和技巧
前缀和是一种预处理技巧,通过构建前缀和数组,可以在O(1)时间内计算任意区间的和。
原理
原数组: [a₀, a₁, a₂, ..., aₙ]
前缀和: prefix[i] = a₀ + a₁ + ... + aᵢ₋₁
区间和: sum(i, j) = prefix[j+1] - prefix[i]
经典题目
- 和为K的子数组(LeetCode 560): 前缀和 + 哈希表
- 矩阵区域和检索(LeetCode 304): 二维前缀和
# 一维前缀和模板
def prefix_sum(nums):n = len(nums)prefix = [0] * (n + 1)for i in range(n):prefix[i + 1] = prefix[i] + nums[i]return prefix# 查询区间[i, j]的和
def range_sum(prefix, i, j):return prefix[j + 1] - prefix[i]
2.1.3 滑动窗口
滑动窗口是处理连续子数组/子串问题的利器,本质上是双指针的一种特殊应用。
适用条件
- 问题涉及连续子数组/子串
- 需要找满足某条件的最长/最短子数组
- 具有单调性(扩大窗口使条件变差,缩小窗口使条件变好)
核心框架
def sliding_window(s):left = 0window = {} # 窗口内的数据result = 0for right in range(len(s)):# 扩大窗口c = s[right]window[c] = window.get(c, 0) + 1# 收缩窗口while 窗口需要收缩:d = s[left]window[d] -= 1left += 1# 更新结果result = max(result, right - left + 1)return result
经典题目
- 无重复字符的最长子串(LeetCode 3)
- 最小覆盖子串(LeetCode 76)
- 找到字符串中所有字母异位词(LeetCode 438)
- 长度最小的子数组(LeetCode 209)
时间复杂度分析
虽然有嵌套循环,但left和right都只会移动n次,因此时间复杂度是O(n)。
2.1.4 矩阵遍历
矩阵题目主要考查二维数组的遍历技巧和空间优化。
常见模式
- 螺旋遍历: 按顺时针螺旋顺序访问矩阵元素
- 螺旋矩阵(LeetCode 54)
- 螺旋矩阵II(LeetCode 59)
- 对角线遍历: 沿对角线方向访问
- 对角线遍历(LeetCode 498)
- 原地修改: 利用矩阵本身空间进行标记
- 矩阵置零(LeetCode 73)
- 旋转图像(LeetCode 48)
# 螺旋遍历模板
def spiral_order(matrix):if not matrix: return []result = []top, bottom = 0, len(matrix) - 1left, right = 0, len(matrix[0]) - 1while top <= bottom and left <= right:# 从左到右for j in range(left, right + 1):result.append(matrix[top][j])top += 1# 从上到下for i in range(top, bottom + 1):result.append(matrix[i][right])right -= 1if top <= bottom:# 从右到左for j in range(right, left - 1, -1):result.append(matrix[bottom][j])bottom -= 1if left <= right:# 从下到上for i in range(bottom, top - 1, -1):result.append(matrix[i][left])left += 1return result
2.2 链表
核心特点
链表是一种线性数据结构,元素在内存中不连续存储,通过指针连接。
与数组相比,链表的优势在于插入和删除操作的O(1)时间复杂度(给定节点位置),劣势是不支持随机访问,查找需要O(n)时间。
链表题目的通用技巧
2.2.1 虚拟头节点(哨兵节点)
虚拟头节点是链表题目中最重要的技巧之一,可以统一处理头节点和其他节点,避免大量边界判断。
# 标准模板
def process_linked_list(head):dummy = ListNode(0) # 虚拟头节点dummy.next = head# 进行链表操作# ...return dummy.next # 返回真正的头节点
应用场景
- 删除节点(包括可能删除头节点)
- 合并链表
- 反转链表的部分或全部
2.2.2 快慢指针
快慢指针是链表题目中的另一大核心技巧,通过两个移动速度不同的指针解决多种问题。
典型应用
- 找中点(快指针走2步,慢指针走1步)
def find_middle(head):slow = fast = headwhile fast and fast.next:slow = slow.nextfast = fast.next.nextreturn slow # slow指向中点
- 检测环(Floyd判圈算法)
def has_cycle(head):slow = fast = headwhile fast and fast.next:slow = slow.nextfast = fast.next.nextif slow == fast:return Truereturn False
- 找环的入口
def detect_cycle(head):slow = fast = head# 第一阶段:判断是否有环while fast and fast.next:slow = slow.nextfast = fast.next.nextif slow == fast:breakelse:return None# 第二阶段:找入口slow = headwhile slow != fast:slow = slow.nextfast = fast.nextreturn slow
数学原理:设链表头到环入口距离为a,环入口到相遇点距离为b,环长为c,则:
- 快指针走过: a + b + n*c
- 慢指针走过: a + b
- 由于快指针速度是慢指针2倍: a + b + n*c = 2(a + b)
- 化简得: a = (n-1)*c + (c-b)
- 含义: 从头节点到入口的距离 = 从相遇点继续走到入口的距离
- 删除倒数第N个节点
def remove_nth_from_end(head, n):dummy = ListNode(0)dummy.next = headfast = slow = dummy# fast先走n+1步for _ in range(n + 1):fast = fast.next# fast和slow一起走,直到fast到达末尾while fast:fast = fast.nextslow = slow.next# 删除slow.nextslow.next = slow.next.nextreturn dummy.next
2.2.3 链表反转
链表反转是面试中的经典题目,有多种变体。
1. 反转整个链表(迭代法)
def reverse_list(head):prev = Nonecurr = headwhile curr:next_temp = curr.next # 保存下一个节点curr.next = prev # 反转指针prev = curr # prev前进curr = next_temp # curr前进return prev
2. 反转整个链表(递归法)
def reverse_list_recursive(head):if not head or not head.next:return headnew_head = reverse_list_recursive(head.next)head.next.next = head # 关键步骤head.next = Nonereturn new_head
3. 反转链表的一部分(LeetCode 92)
def reverse_between(head, left, right):dummy = ListNode(0)dummy.next = headpre = dummy# 找到left前一个节点for _ in range(left - 1):pre = pre.next# 反转left到right部分curr = pre.nextfor _ in range(right - left):next_node = curr.nextcurr.next = next_node.nextnext_node.next = pre.nextpre.next = next_nodereturn dummy.next
4. K个一组翻转链表(LeetCode 25,困难题)
这是链表反转的最难变体,需要结合多种技巧:
- 计算链表长度,判断是否需要反转
- 分组反转
- 连接各个反转后的组
def reverse_k_group(head, k):dummy = ListNode(0)dummy.next = headpre = dummywhile True:# 检查剩余节点是否够k个tail = prefor _ in range(k):tail = tail.nextif not tail:return dummy.next# 记录下一组的前驱节点next_group = tail.next# 反转当前组head, tail = reverse_one_group(pre.next, tail)# 连接pre.next = headtail.next = next_grouppre = taildef reverse_one_group(head, tail):prev = tail.nextcurr = headwhile prev != tail:next_temp = curr.nextcurr.next = prevprev = currcurr = next_tempreturn tail, head
2.2.4 合并链表
1. 合并两个有序链表(LeetCode 21)
def merge_two_lists(l1, l2):dummy = ListNode(0)curr = dummywhile l1 and l2:if l1.val < l2.val:curr.next = l1l1 = l1.nextelse:curr.next = l2l2 = l2.nextcurr = curr.nextcurr.next = l1 if l1 else l2return dummy.next
2. 合并K个有序链表(LeetCode 23,困难题)
使用最小堆(优先队列)维护K个链表的当前最小节点。
import heapqdef merge_k_lists(lists):dummy = ListNode(0)curr = dummyheap = []# 初始化堆for i, l in enumerate(lists):if l:heapq.heappush(heap, (l.val, i, l))# 不断取出最小值while heap:val, idx, node = heapq.heappop(heap)curr.next = nodecurr = curr.nextif node.next:heapq.heappush(heap, (node.next.val, idx, node.next))return dummy.next
时间复杂度: O(N log K),其中N是所有节点总数,K是链表个数。每次堆操作的时间复杂度为O(log K),需要进行N次操作。
2.2.5 链表排序
归并排序(LeetCode 148)
链表排序的最佳选择是归并排序,因为:
- 不需要随机访问
- 空间复杂度可以做到O(1)
- 时间复杂度稳定O(n log n)
def sort_list(head):if not head or not head.next:return head# 快慢指针找中点slow, fast = head, head.nextwhile fast and fast.next:slow = slow.nextfast = fast.next.next# 分割链表mid = slow.nextslow.next = None# 递归排序left = sort_list(head)right = sort_list(mid)# 合并return merge(left, right)def merge(l1, l2):dummy = ListNode(0)curr = dummywhile l1 and l2:if l1.val < l2.val:curr.next = l1l1 = l1.nextelse:curr.next = l2l2 = l2.nextcurr = curr.nextcurr.next = l1 if l1 else l2return dummy.next
2.3 栈与队列
核心特点
- 栈: 后进先出(LIFO),主要操作为push和pop
- 队列: 先进先出(FIFO),主要操作为enqueue和dequeue
- 单调栈: 栈内元素保持单调性,用于解决下一个更大/更小元素问题
- 单调队列: 队列内元素保持单调性,常用于滑动窗口最值问题
2.3.1 栈的经典应用
1. 括号匹配(LeetCode 20)
这是栈最直接的应用,利用栈的LIFO特性匹配成对括号。
def is_valid(s):stack = []mapping = {')': '(', '}': '{', ']': '['}for char in s:if char in mapping:if not stack or stack[-1] != mapping[char]:return Falsestack.pop()else:stack.append(char)return len(stack) == 0
2. 最小栈(LeetCode 155)
要求实现一个栈,支持常数时间获取最小值。核心思路是用辅助栈记录每个状态的最小值。
class MinStack:def __init__(self):self.stack = []self.min_stack = []def push(self, val):self.stack.append(val)if not self.min_stack or val <= self.min_stack[-1]:self.min_stack.append(val)def pop(self):if self.stack.pop() == self.min_stack[-1]:self.min_stack.pop()def top(self):return self.stack[-1]def get_min(self):return self.min_stack[-1]
3. 逆波兰表达式求值(LeetCode 150)
栈天然适合处理后缀表达式。
def eval_rpn(tokens):stack = []operators = {'+', '-', '*', '/'}for token in tokens:if token in operators:b = stack.pop()a = stack.pop()if token == '+':stack.append(a + b)elif token == '-':stack.append(a - b)elif token == '*':stack.append(a * b)else:stack.append(int(a / b)) # 向零截断else:stack.append(int(token))return stack[0]
2.3.2 单调栈
单调栈是一种特殊的栈,栈内元素保持单调递增或递减。主要用于解决"下一个更大元素"或"下一个更小元素"类问题。
核心思想
- 单调递减栈: 从栈底到栈顶递减,用于找下一个更大元素
- 单调递增栈: 从栈底到栈顶递增,用于找下一个更小元素
1. 下一个更大元素(LeetCode 496, 503, 739)
# 每日温度(LeetCode 739)
def daily_temperatures(temperatures):n = len(temperatures)result = [0] * nstack = [] # 存储索引for i in range(n):while stack and temperatures[i] > temperatures[stack[-1]]:prev_index = stack.pop()result[prev_index] = i - prev_indexstack.append(i)return result
工作原理
- 遍历数组,维护一个单调递减栈(存储索引)
- 当前元素大于栈顶元素时,说明找到了栈顶元素的"下一个更大元素"
- 不断弹出栈顶并记录答案,直到当前元素不再大于栈顶
- 将当前元素索引入栈
2. 接雨水(LeetCode 42,困难题)
这是单调栈的经典应用,也可以用双指针解决。
def trap(height):n = len(height)if n < 3:return 0result = 0stack = [] # 单调递减栈,存储索引for i in range(n):while stack and height[i] > height[stack[-1]]:top = stack.pop()if not stack:breakleft = stack[-1]width = i - left - 1h = min(height[left], height[i]) - height[top]result += width * hstack.append(i)return result
原理解析
单调栈解法按层计算雨水:
- 栈维护可能形成积水的位置
- 当遇到更高的柱子时,计算之前凹陷处的积水
- 积水高度 = min(左柱子, 右柱子) - 凹陷处高度
- 积水宽度 = 右柱子位置 - 左柱子位置 - 1
3. 柱状图中最大的矩形(LeetCode 84,困难题)
def largest_rectangle_area(heights):stack = []max_area = 0heights = [0] + heights + [0] # 前后加0,简化边界处理for i in range(len(heights)):while stack and heights[i] < heights[stack[-1]]:h = heights[stack.pop()]w = i - stack[-1] - 1max_area = max(max_area, h * w)stack.append(i)return max_area
原理
- 维护单调递增栈
- 当前柱子矮于栈顶时,说明栈顶柱子的"右边界"找到了
- 栈顶下一个元素是"左边界"
- 矩形宽度 = 右边界 - 左边界 - 1
2.3.3 队列的应用
1. 用栈实现队列(LeetCode 232)
用两个栈实现队列的所有操作。
class MyQueue:def __init__(self):self.stack_in = [] # 负责入队self.stack_out = [] # 负责出队def push(self, x):self.stack_in.append(x)def pop(self):if not self.stack_out:while self.stack_in:self.stack_out.append(self.stack_in.pop())return self.stack_out.pop()def peek(self):if not self.stack_out:while self.stack_in:self.stack_out.append(self.stack_in.pop())return self.stack_out[-1]def empty(self):return not self.stack_in and not self.stack_out
均摊时间复杂度分析
- push: O(1)
- pop: 看似O(n),但每个元素最多被移动两次,因此均摊O(1)
2. 滑动窗口最大值(LeetCode 239,困难题)
使用单调队列(双端队列)维护窗口内的最大值。
from collections import dequedef max_sliding_window(nums, k):dq = deque() # 存储索引result = []for i in range(len(nums)):# 移除窗口外的元素if dq and dq[0] < i - k + 1:dq.popleft()# 维护单调递减队列while dq and nums[dq[-1]] < nums[i]:dq.pop()dq.append(i)# 窗口形成后记录最大值if i >= k - 1:result.append(nums[dq[0]])return result
核心思想
- 队列保持单调递减(队首最大)
- 队首元素就是窗口的最大值
- 新元素入队时,从队尾移除所有比它小的元素(因为它们不可能是答案)
- 时间复杂度: O(n),每个元素最多入队出队各一次
2.4 哈希表
核心特点
哈希表通过哈希函数将键映射到数组索引,实现O(1)平均时间的查找、插入和删除。
是用空间换时间的典型数据结构。
常见应用场景
- 快速查找: 判断元素是否存在
- 计数统计: 统计元素出现次数
- 去重: 利用哈希表key的唯一性
- 建立映射关系: 存储key-value对应关系
2.4.1 经典哈希表题目
1. 两数之和(LeetCode 1)
这是LeetCode的第一题,也是哈希表应用的经典例子。
def two_sum(nums, target):hash_map = {}for i, num in enumerate(nums):complement = target - numif complement in hash_map:return [hash_map[complement], i]hash_map[num] = ireturn []
优化思路
- 暴力解法: O(n²),两层循环
- 哈希表: O(n),用空间换时间,只需遍历一次
2. 字母异位词分组(LeetCode 49)
核心是找到一个标准化的key来表示异位词。
from collections import defaultdictdef group_anagrams(strs):hash_map = defaultdict(list)for s in strs:# 排序后的字符串作为keykey = ''.join(sorted(s))hash_map[key].append(s)return list(hash_map.values())
另一种key的构造方法(更高效)
def group_anagrams(strs):hash_map = defaultdict(list)for s in strs:# 用字符计数作为keycount = [0] * 26for c in s:count[ord(c) - ord('a')] += 1hash_map[tuple(count)].append(s)return list(hash_map.values())
3. 最长连续序列(LeetCode 128)
要求O(n)时间复杂度,因此不能排序。
def longest_consecutive(nums):num_set = set(nums)max_length = 0for num in num_set:# 只从序列的起点开始计数if num - 1 not in num_set:current_num = numcurrent_length = 1while current_num + 1 in num_set:current_num += 1current_length += 1max_length = max(max_length, current_length)return max_length
关键优化
- 只从序列起点开始计数(num-1不在集合中)
- 避免重复计算
- 每个数字最多被访问两次,时间复杂度O(n)
4. LRU缓存(LeetCode 146,中等偏难)
要求实现一个固定容量的缓存,支持O(1)时间的get和put操作,并在容量满时删除最久未使用的项。
class Node:def __init__(self, key=0, value=0):self.key = keyself.value = valueself.prev = Noneself.next = Noneclass LRUCache:def __init__(self, capacity):self.cache = {} # key -> Nodeself.capacity = capacity# 虚拟头尾节点self.head = Node()self.tail = Node()self.head.next = self.tailself.tail.prev = self.headdef get(self, key):if key not in self.cache:return -1node = self.cache[key]self._move_to_head(node)return node.valuedef put(self, key, value):if key in self.cache:node = self.cache[key]node.value = valueself._move_to_head(node)else:node = Node(key, value)self.cache[key] = nodeself._add_to_head(node)if len(self.cache) > self.capacity:removed = self._remove_tail()del self.cache[removed.key]def _add_to_head(self, node):node.prev = self.headnode.next = self.head.nextself.head.next.prev = nodeself.head.next = nodedef _remove_node(self, node):node.prev.next = node.nextnode.next.prev = node.prevdef _move_to_head(self, node):self._remove_node(node)self._add_to_head(node)def _remove_tail(self):node = self.tail.prevself._remove_node(node)return node
数据结构选择
- 哈希表: O(1)查找
- 双向链表: O(1)插入和删除
- 两者结合: 实现所有操作O(1)
三、树相关算法
3.1 二叉树基础
二叉树是最重要的非线性数据结构之一,每个节点最多有两个子节点。二叉树题目通常考查递归、遍历、路径问题等。
二叉树节点定义
class TreeNode:def __init__(self, val=0, left=None, right=None):self.val = valself.left = leftself.right = right
3.1.1 二叉树遍历
1. 前序遍历(根-左-右)
递归实现:
def preorder_traversal(root):result = []def dfs(node):if not node:returnresult.append(node.val) # 根dfs(node.left) # 左dfs(node.right) # 右dfs(root)return result
迭代实现(用栈):
def preorder_traversal_iterative(root):if not root:return []result = []stack = [root]while stack:node = stack.pop()result.append(node.val)# 先右后左,因为栈是LIFOif node.right:stack.append(node.right)if node.left:stack.append(node.left)return result
2. 中序遍历(左-根-右)
递归实现:
def inorder_traversal(root):result = []def dfs(node):if not node:returndfs(node.left) # 左result.append(node.val) # 根dfs(node.right) # 右dfs(root)return result
迭代实现:
def inorder_traversal_iterative(root):result = []stack = []curr = rootwhile curr or stack:# 一直向左走到底while curr:stack.append(curr)curr = curr.left# 处理栈顶节点curr = stack.pop()result.append(curr.val)# 转向右子树curr = curr.rightreturn result
重要性质
- 二叉搜索树的中序遍历结果是有序的
- 这个性质是BST相关题目的关键
3. 后序遍历(左-右-根)
递归实现:
def postorder_traversal(root):result = []def dfs(node):if not node:returndfs(node.left) # 左dfs(node.right) # 右result.append(node.val) # 根dfs(root)return result
迭代实现(技巧:前序遍历的变体):
def postorder_traversal_iterative(root):if not root:return []result = []stack = [root]while stack:node = stack.pop()result.append(node.val)# 先左后右(与前序相反)if node.left:stack.append(node.left)if node.right:stack.append(node.right)return result[::-1] # 反转得到后序
4. 层序遍历(BFS)
层序遍历是按层访问节点,需要用队列实现。
from collections import dequedef level_order(root):if not root:return []result = []queue = deque([root])while queue:level_size = len(queue)level_nodes = []for _ in range(level_size):node = queue.popleft()level_nodes.append(node.val)if node.left:queue.append(node.left)if node.right:queue.append(node.right)result.append(level_nodes)return result
变体题目
- 二叉树的右视图(LeetCode 199): 每层最右边的节点
- 二叉树的锯齿形层序遍历(LeetCode 103): 奇数层正序,偶数层逆序
- 二叉树的层平均值(LeetCode 637): 计算每层平均值
3.1.2 二叉树的深度和高度
1. 最大深度(LeetCode 104)
递归解法:
def max_depth(root):if not root:return 0return 1 + max(max_depth(root.left), max_depth(root.right))
迭代解法(BFS):
def max_depth_iterative(root):if not root:return 0depth = 0queue = deque([root])while queue:depth += 1for _ in range(len(queue)):node = queue.popleft()if node.left:queue.append(node.left)if node.right:queue.append(node.right)return depth
2. 最小深度(LeetCode 111)
注意最小深度的定义:从根节点到最近叶子节点的路径长度。
def min_depth(root):if not root:return 0# 如果只有一个子树,返回该子树的最小深度if not root.left:return 1 + min_depth(root.right)if not root.right:return 1 + min_depth(root.left)# 两个子树都存在return 1 + min(min_depth(root.left), min_depth(root.right))
3. 平衡二叉树(LeetCode 110)
判断一棵树是否是平衡二叉树(任意节点的左右子树高度差不超过1)。
def is_balanced(root):def height(node):if not node:return 0left_height = height(node.left)if left_height == -1:return -1right_height = height(node.right)if right_height == -1:return -1if abs(left_height - right_height) > 1:return -1return 1 + max(left_height, right_height)return height(root) != -1
优化技巧: 用-1表示不平衡,避免重复计算高度。
3.1.3 二叉树路径问题
1. 路径总和(LeetCode 112)
判断是否存在根到叶子的路径,使得路径上所有节点值之和等于目标值。
def has_path_sum(root, target_sum):if not root:return Falseif not root.left and not root.right:return root.val == target_sumreturn (has_path_sum(root.left, target_sum - root.val) orhas_path_sum(root.right, target_sum - root.val))
2. 路径总和II(LeetCode 113)
找出所有满足条件的路径。
def path_sum(root, target_sum):result = []def dfs(node, path, remain):if not node:returnpath.append(node.val)if not node.left and not node.right and remain == node.val:result.append(path[:]) # 复制路径dfs(node.left, path, remain - node.val)dfs(node.right, path, remain - node.val)path.pop() # 回溯dfs(root, [], target_sum)return result
3. 路径总和III(LeetCode 437,中等偏难)
路径不一定从根开始,也不一定到叶子结束。
def path_sum_iii(root, target_sum):def count_paths(node, current_sum):if not node:return 0current_sum += node.valcount = 0# 以当前节点结束的路径数if current_sum == target_sum:count += 1# 继续向下搜索count += count_paths(node.left, current_sum)count += count_paths(node.right, current_sum)return countif not root:return 0# 从当前节点开始的路径数paths_from_root = count_paths(root, 0)# 从左右子树开始的路径数paths_from_left = path_sum_iii(root.left, target_sum)paths_from_right = path_sum_iii(root.right, target_sum)return paths_from_root + paths_from_left + paths_from_right
优化: 使用前缀和+哈希表,时间复杂度从O(n²)降到O(n)。
def path_sum_iii_optimized(root, target_sum):prefix_sum_count = {0: 1} # 前缀和 -> 出现次数def dfs(node, current_sum):if not node:return 0current_sum += node.valcount = prefix_sum_count.get(current_sum - target_sum, 0)prefix_sum_count[current_sum] = prefix_sum_count.get(current_sum, 0) + 1count += dfs(node.left, current_sum)count += dfs(node.right, current_sum)prefix_sum_count[current_sum] -= 1 # 回溯return countreturn dfs(root, 0)
4. 二叉树的最大路径和(LeetCode 124,困难题)
路径可以从任意节点开始到任意节点结束。
def max_path_sum(root):max_sum = float('-inf')def max_gain(node):nonlocal max_sumif not node:return 0# 只有正收益才选择该子树left_gain = max(max_gain(node.left), 0)right_gain = max(max_gain(node.right), 0)# 当前节点作为路径顶点的最大路径和path_sum = node.val + left_gain + right_gainmax_sum = max(max_sum, path_sum)# 返回当前节点对父节点的贡献return node.val + max(left_gain, right_gain)max_gain(root)return max_sum
关键点
- 递归函数返回的是单侧路径的最大值
- 在递归过程中更新全局最大值(考虑经过当前节点的完整路径)
3.2 二叉搜索树(BST)
定义: 二叉搜索树是一种特殊的二叉树,满足:
- 左子树所有节点值 < 根节点值
- 右子树所有节点值 > 根节点值
- 左右子树也分别是二叉搜索树
重要性质
- BST的中序遍历结果是升序的
- 这个性质是解决BST问题的关键
3.2.1 BST的验证和搜索
1. 验证二叉搜索树(LeetCode 98)
错误的做法:
# 错误! 只比较了父子节点
def is_valid_bst_wrong(root):if not root:return Trueif root.left and root.left.val >= root.val:return Falseif root.right and root.right.val <= root.val:return Falsereturn is_valid_bst_wrong(root.left) and is_valid_bst_wrong(root.right)
正确的做法(维护取值范围):
def is_valid_bst(root):def validate(node, min_val, max_val):if not node:return Trueif node.val <= min_val or node.val >= max_val:return Falsereturn (validate(node.left, min_val, node.val) andvalidate(node.right, node.val, max_val))return validate(root, float('-inf'), float('inf'))
利用中序遍历:
def is_valid_bst_inorder(root):prev = float('-inf')stack = []curr = rootwhile curr or stack:while curr:stack.append(curr)curr = curr.leftcurr = stack.pop()if curr.val <= prev:return Falseprev = curr.valcurr = curr.rightreturn True
2. 二叉搜索树中的搜索(LeetCode 700)
def search_bst(root, val):if not root or root.val == val:return rootif val < root.val:return search_bst(root.left, val)else:return search_bst(root.right, val)
迭代版本(空间复杂度O(1)):
def search_bst_iterative(root, val):while root:if val == root.val:return rootelif val < root.val:root = root.leftelse:root = root.rightreturn None
3. 二叉搜索树中第K小的元素(LeetCode 230)
利用BST中序遍历的性质:
def kth_smallest(root, k):stack = []curr = rootcount = 0while curr or stack:while curr:stack.append(curr)curr = curr.leftcurr = stack.pop()count += 1if count == k:return curr.valcurr = curr.rightreturn -1
3.2.2 BST的修改操作
1. 插入节点(LeetCode 701)
def insert_into_bst(root, val):if not root:return TreeNode(val)if val < root.val:root.left = insert_into_bst(root.left, val)else:root.right = insert_into_bst(root.right, val)return root
2. 删除节点(LeetCode 450,中等偏难)
删除节点是BST操作中最复杂的,需要分三种情况:
- 删除叶子节点: 直接删除
- 删除只有一个子节点的节点: 用子节点替代
- 删除有两个子节点的节点: 用后继节点(右子树最小值)或前驱节点(左子树最大值)替代
def delete_node(root, key):if not root:return Noneif key < root.val:root.left = delete_node(root.left, key)elif key > root.val:root.right = delete_node(root.right, key)else:# 找到要删除的节点if not root.left:return root.rightif not root.right:return root.left# 有两个子节点:用后继节点替代successor = find_min(root.right)root.val = successor.valroot.right = delete_node(root.right, successor.val)return rootdef find_min(node):while node.left:node = node.leftreturn node
3.2.3 BST的构造
1. 有序数组转BST(LeetCode 108)
要构造高度平衡的BST,选择中间元素作为根。
def sorted_array_to_bst(nums):if not nums:return Nonemid = len(nums) // 2root = TreeNode(nums[mid])root.left = sorted_array_to_bst(nums[:mid])root.right = sorted_array_to_bst(nums[mid+1:])return root
2. 前序和中序遍历构造二叉树(LeetCode 105,中等偏难)
def build_tree(preorder, inorder):if not preorder:return Noneroot_val = preorder[0]root = TreeNode(root_val)# 在中序遍历中找到根节点位置root_idx = inorder.index(root_val)# 递归构造左右子树root.left = build_tree(preorder[1:root_idx+1], inorder[:root_idx])root.right = build_tree(preorder[root_idx+1:], inorder[root_idx+1:])return root
优化: 使用哈希表存储中序遍历的索引,避免重复查找。
def build_tree_optimized(preorder, inorder):inorder_map = {val: idx for idx, val in enumerate(inorder)}def helper(pre_start, pre_end, in_start, in_end):if pre_start > pre_end:return Noneroot_val = preorder[pre_start]root = TreeNode(root_val)root_idx = inorder_map[root_val]left_size = root_idx - in_startroot.left = helper(pre_start + 1, pre_start + left_size,in_start, root_idx - 1)root.right = helper(pre_start + left_size + 1, pre_end,root_idx + 1, in_end)return rootreturn helper(0, len(preorder) - 1, 0, len(inorder) - 1)
3.3 二叉树的高级操作
3.3.1 二叉树的序列化与反序列化
序列化(LeetCode 297,困难题)
将树结构转换为字符串,并能从字符串恢复树结构。
前序遍历方式:
class Codec:def serialize(self, root):"""将树序列化为字符串"""if not root:return "null"left = self.serialize(root.left)right = self.serialize(root.right)return f"{root.val},{left},{right}"def deserialize(self, data):"""从字符串反序列化树"""def helper(values):val = next(values)if val == "null":return Nonenode = TreeNode(int(val))node.left = helper(values)node.right = helper(values)return nodevalues = iter(data.split(','))return helper(values)
层序遍历方式:
class Codec:def serialize(self, root):if not root:return ""result = []queue = deque([root])while queue:node = queue.popleft()if node:result.append(str(node.val))queue.append(node.left)queue.append(node.right)else:result.append("null")return ",".join(result)def deserialize(self, data):if not data:return Nonevalues = data.split(',')root = TreeNode(int(values[0]))queue = deque([root])i = 1while queue:node = queue.popleft()if values[i] != "null":node.left = TreeNode(int(values[i]))queue.append(node.left)i += 1if values[i] != "null":node.right = TreeNode(int(values[i]))queue.append(node.right)i += 1return root
3.3.2 最近公共祖先
1. 二叉树的最近公共祖先(LeetCode 236,中等偏难)
def lowest_common_ancestor(root, p, q):if not root or root == p or root == q:return rootleft = lowest_common_ancestor(root.left, p, q)right = lowest_common_ancestor(root.right, p, q)# p和q分别在左右子树if left and right:return root# p和q都在左子树或都在右子树return left if left else right
核心思想
- 如果p和q分别在root的左右子树,root就是LCA
- 如果都在左子树,LCA也在左子树
- 如果都在右子树,LCA也在右子树
2. 二叉搜索树的最近公共祖先(LeetCode 235)
利用BST的性质可以简化算法。
def lowest_common_ancestor_bst(root, p, q):while root:# p和q都在左子树if p.val < root.val and q.val < root.val:root = root.left# p和q都在右子树elif p.val > root.val and q.val > root.val:root = root.right# p和q分别在两侧,或其中一个就是rootelse:return rootreturn None
四、搜索与图论
4.1 深度优先搜索(DFS)
核心思想
DFS采用"一条路走到黑"的策略,沿着一条路径尽可能深地搜索,直到无法继续,然后回溯到上一个节点,尝试其他路径。
实现方式
- 递归(隐式栈)
- 显式栈
经典应用场景
- 路径搜索
- 连通性问题
- 拓扑排序
- 岛屿问题
4.1.1 岛屿类问题
岛屿问题是DFS/BFS的经典应用,也是LeetCode和牛客的高频题型。
1. 岛屿数量(LeetCode 200)
def num_islands(grid):if not grid:return 0m, n = len(grid), len(grid[0])count = 0def 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'# 向四个方向DFSdfs(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 += 1dfs(i, j)return count
2. 岛屿的最大面积(LeetCode 695)
def max_area_of_island(grid):m, n = len(grid), len(grid[0])max_area = 0def dfs(i, j):if i < 0 or i >= m or j < 0 or j >= n or grid[i][j] != 1:return 0grid[i][j] = 0area = 1area += dfs(i + 1, j)area += dfs(i - 1, j)area += dfs(i, j + 1)area += dfs(i, j - 1)return areafor i in range(m):for j in range(n):if grid[i][j] == 1:max_area = max(max_area, dfs(i, j))return max_area
3. 被围绕的区域(LeetCode 130,中等偏难)
核心思路:从边界的’O’开始DFS,标记所有不会被包围的’O’。
def solve(board):if not board:returnm, n = len(board), len(board[0])def dfs(i, j):if i < 0 or i >= m or j < 0 or j >= n or board[i][j] != 'O':returnboard[i][j] = 'T' # 临时标记dfs(i + 1, j)dfs(i - 1, j)dfs(i, j + 1)dfs(i, j - 1)# 从边界DFSfor i in range(m):dfs(i, 0)dfs(i, n - 1)for j in range(n):dfs(0, j)dfs(m - 1, j)# 更新棋盘for i in range(m):for j in range(n):if board[i][j] == 'T':board[i][j] = 'O'elif board[i][j] == 'O':board[i][j] = 'X'
4.1.2 单词搜索
单词搜索(LeetCode 79)
在二维网格中搜索单词,可以上下左右移动,但不能重复使用同一个格子。
def exist(board, word):m, n = len(board), len(board[0])def dfs(i, j, k):# k是word中当前要匹配的字符索引if k == len(word):return Trueif 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 = (dfs(i + 1, j, k + 1) ordfs(i - 1, j, k + 1) ordfs(i, j + 1, k + 1) ordfs(i, j - 1, k + 1))# 回溯board[i][j] = tempreturn foundfor i in range(m):for j in range(n):if dfs(i, j, 0):return Truereturn False
关键点
- 原地修改board来标记访问状态,避免额外空间
- 回溯时恢复现场
- 剪枝:如果当前字符不匹配就立即返回
4.2 广度优先搜索(BFS)
核心思想
BFS采用"一层一层"的搜索策略,先访问距离起点近的节点,再访问远的节点。
实现方式
使用队列
适用场景
- 最短路径问题(无权图)
- 层次遍历
- 连通性判断
4.2.1 BFS求最短路径
1. 二进制矩阵中的最短路径(LeetCode 1091)
from collections import dequedef shortest_path_binary_matrix(grid):n = len(grid)if grid[0][0] == 1 or grid[n-1][n-1] == 1:return -1if n == 1:return 1queue = deque([(0, 0, 1)]) # (row, col, distance)grid[0][0] = 1 # 标记为已访问directions = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]while queue:row, col, dist = queue.popleft()for dr, dc in directions:r, c = row + dr, col + dcif r == n-1 and c == n-1:return dist + 1if 0 <= r < n and 0 <= c < n and grid[r][c] == 0:grid[r][c] = 1queue.append((r, c, dist + 1))return -1
2. 单词接龙(LeetCode 127,困难题)
从beginWord变换到endWord,每次只能改变一个字母,且中间词必须在字典中。
def ladder_length(begin_word, end_word, word_list):if end_word not in word_list:return 0word_set = set(word_list)queue = deque([(begin_word, 1)])while queue:word, length = queue.popleft()if word == end_word:return length# 尝试改变每个位置的字母for i in range(len(word)):for c in 'abcdefghijklmnopqrstuvwxyz':next_word = word[:i] + c + word[i+1:]if next_word in word_set:word_set.remove(next_word)queue.append((next_word, length + 1))return 0
优化:双向BFS
从起点和终点同时BFS,可以大幅减少搜索空间。
def ladder_length_bidirectional(begin_word, end_word, word_list):if end_word not in word_list:return 0word_set = set(word_list)begin_set = {begin_word}end_set = {end_word}length = 1while begin_set and end_set:# 总是从较小的集合开始扩展if len(begin_set) > len(end_set):begin_set, end_set = end_set, begin_setnext_set = set()for word in begin_set:for i in range(len(word)):for c in 'abcdefghijklmnopqrstuvwxyz':next_word = word[:i] + c + word[i+1:]if next_word in end_set:return length + 1if next_word in word_set:word_set.remove(next_word)next_set.add(next_word)begin_set = next_setlength += 1return 0
4.3 拓扑排序
定义: 对有向无环图(DAG)的所有顶点进行线性排序,使得对于任何有向边u->v,u在排序中都在v之前。
应用场景
- 课程安排
- 任务调度
- 编译顺序
算法思路(Kahn算法)
- 统计每个节点的入度
- 将入度为0的节点加入队列
- 从队列中取出节点,将其指向的节点入度-1
- 重复步骤2-3,直到队列为空
课程表(LeetCode 207)
from collections import deque, defaultdictdef can_finish(num_courses, prerequisites):# 构建图和入度表graph = defaultdict(list)in_degree = [0] * num_coursesfor course, prereq in prerequisites:graph[prereq].append(course)in_degree[course] += 1# BFSqueue = deque([i for i in range(num_courses) if in_degree[i] == 0])count = 0while queue:course = queue.popleft()count += 1for next_course in graph[course]:in_degree[next_course] -= 1if in_degree[next_course] == 0:queue.append(next_course)return count == num_courses
课程表II(LeetCode 210)
在判断能否完成的基础上,返回一个可行的课程顺序。
def find_order(num_courses, prerequisites):graph = defaultdict(list)in_degree = [0] * num_coursesfor course, prereq in prerequisites:graph[prereq].append(course)in_degree[course] += 1queue = deque([i for i in range(num_courses) if in_degree[i] == 0])result = []while queue:course = queue.popleft()result.append(course)for next_course in graph[course]:in_degree[next_course] -= 1if in_degree[next_course] == 0:queue.append(next_course)return result if len(result) == num_courses else []
五、回溯算法
5.1 回溯算法核心思想
定义: 回溯算法是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解不可行,则放弃该候选解,回溯到上一步,尝试其他可能的解。
本质: 暴力搜索 + 剪枝
适用场景
- 求所有解(组合、排列、子集)
- 求可行解(N皇后、数独)
- 求最优解(结合剪枝)
回溯算法模板
def backtrack(路径, 选择列表):if 满足结束条件:result.add(路径)returnfor 选择 in 选择列表:做选择backtrack(路径, 选择列表)撤销选择 # 回溯
5.2 组合问题
1. 组合(LeetCode 77)
从1到n中选k个数的所有组合。
def combine(n, k):result = []def backtrack(start, path):# 剪枝:剩余元素不足if len(path) + (n - start + 1) < k:returnif len(path) == k:result.append(path[:])returnfor i in range(start, n + 1):path.append(i)backtrack(i + 1, path)path.pop()backtrack(1, [])return result
关键点
- start参数避免重复组合
- 剪枝提高效率
2. 组合总和(LeetCode 39)
给定数组(无重复元素),找出和为target的所有组合,同一个数可以重复使用。
def combination_sum(candidates, target):result = []candidates.sort() # 排序便于剪枝def backtrack(start, path, remain):if remain == 0:result.append(path[:])returnfor i in range(start, len(candidates)):if candidates[i] > remain:break # 剪枝path.append(candidates[i])backtrack(i, path, remain - candidates[i]) # 注意是i不是i+1path.pop()backtrack(0, [], target)return result
3. 组合总和II(LeetCode 40)
数组有重复元素,每个元素只能使用一次,但结果不能有重复组合。
def combination_sum2(candidates, target):result = []candidates.sort()def backtrack(start, path, remain):if remain == 0:result.append(path[:])returnfor i in range(start, len(candidates)):if candidates[i] > remain:break# 去重:同一层不能使用相同的元素if i > start and candidates[i] == candidates[i-1]:continuepath.append(candidates[i])backtrack(i + 1, path, remain - candidates[i]) # i+1表示不重复使用path.pop()backtrack(0, [], target)return result
去重技巧
i > start and candidates[i] == candidates[i-1]: 同一层跳过重复元素- 需要先排序
5.3 排列问题
1. 全排列(LeetCode 46)
def permute(nums):result = []n = len(nums)used = [False] * ndef backtrack(path):if len(path) == n:result.append(path[:])returnfor i in range(n):if used[i]:continueused[i] = Truepath.append(nums[i])backtrack(path)path.pop()used[i] = Falsebacktrack([])return result
2. 全排列II(LeetCode 47)
数组包含重复元素,但结果不能有重复排列。
def permute_unique(nums):result = []nums.sort()n = len(nums)used = [False] * ndef backtrack(path):if len(path) == n:result.append(path[:])returnfor i in range(n):if used[i]:continue# 去重:如果当前元素与前一个元素相同,且前一个元素未被使用,则跳过if i > 0 and nums[i] == nums[i-1] and not used[i-1]:continueused[i] = Truepath.append(nums[i])backtrack(path)path.pop()used[i] = Falsebacktrack([])return result
去重原理
- 排序后,相同元素相邻
not used[i-1]保证相同元素按顺序使用- 避免[1a, 2, 1b]和[1b, 2, 1a]都出现
5.4 子集问题
1. 子集(LeetCode 78)
def subsets(nums):result = []def backtrack(start, path):result.append(path[:]) # 每个状态都是一个子集for i in range(start, len(nums)):path.append(nums[i])backtrack(i + 1, path)path.pop()backtrack(0, [])return result
2. 子集II(LeetCode 90)
数组包含重复元素。
def subsets_with_dup(nums):result = []nums.sort()def backtrack(start, path):result.append(path[:])for i in range(start, len(nums)):if i > start and nums[i] == nums[i-1]:continuepath.append(nums[i])backtrack(i + 1, path)path.pop()backtrack(0, [])return result
5.5 其他经典回溯问题
1. 括号生成(LeetCode 22)
生成n对括号的所有合法组合。
def generate_parenthesis(n):result = []def backtrack(path, left, right):if len(path) == 2 * n:result.append(''.join(path))return# 左括号:只要没用完就可以加if left < n:path.append('(')backtrack(path, left + 1, right)path.pop()# 右括号:只有当右括号少于左括号时才能加if right < left:path.append(')')backtrack(path, left, right + 1)path.pop()backtrack([], 0, 0)return result
关键点
- 不是盲目枚举,而是根据括号配对规则剪枝
right < left保证括号合法性
2. N皇后(LeetCode 51,困难题)
def solve_n_queens(n):result = []board = [['.'] * n for _ in range(n)]cols = set()diag1 = set() # 左上到右下对角线diag2 = set() # 右上到左下对角线def backtrack(row):if row == n:result.append([''.join(row) for row in board])returnfor col in range(n):# 检查是否可以放置皇后if col in cols or (row - col) in diag1 or (row + col) in diag2:continue# 放置皇后board[row][col] = 'Q'cols.add(col)diag1.add(row - col)diag2.add(row + col)backtrack(row + 1)# 回溯board[row][col] = '.'cols.remove(col)diag1.remove(row - col)diag2.remove(row + col)backtrack(0)return result
对角线判断技巧
- 左上到右下:
row - col相同 - 右上到左下:
row + col相同
3. 解数独(LeetCode 37,困难题)
def solve_sudoku(board):rows = [set() for _ in range(9)]cols = [set() for _ in range(9)]boxes = [set() for _ in range(9)]empty = []# 初始化for i in range(9):for j in range(9):if board[i][j] == '.':empty.append((i, j))else:digit = board[i][j]rows[i].add(digit)cols[j].add(digit)boxes[(i // 3) * 3 + j // 3].add(digit)def backtrack(idx):if idx == len(empty):return Truei, j = empty[idx]box_idx = (i // 3) * 3 + j // 3for digit in '123456789':if digit in rows[i] or digit in cols[j] or digit in boxes[box_idx]:continue# 放置数字board[i][j] = digitrows[i].add(digit)cols[j].add(digit)boxes[box_idx].add(digit)if backtrack(idx + 1):return True# 回溯board[i][j] = '.'rows[i].remove(digit)cols[j].remove(digit)boxes[box_idx].remove(digit)return Falsebacktrack(0)
六、动态规划
6.1 动态规划核心思想
定义: 动态规划是一种将复杂问题分解为子问题,通过求解子问题并存储其结果,避免重复计算,从而高效求解原问题的算法思想。
与递归、分治的区别
- 递归: 自顶向下,可能有大量重复计算
- 分治: 子问题相互独立
- 动态规划: 子问题重叠,通过记忆化避免重复计算
动态规划的三要素
- 最优子结构: 问题的最优解包含子问题的最优解
- 重叠子问题: 子问题会被多次求解
- 无后效性: 子问题的解一旦确定,就不会再改变
动态规划的解题步骤
- 定义状态: dp[i]或dp[i][j]表示什么
- 找状态转移方程: 如何从小问题推导到大问题
- 初始化: 确定边界条件
- 确定计算顺序: 保证计算dp[i]时,所需的子问题已经计算完成
- 优化空间复杂度(可选): 滚动数组等技巧
6.2 一维DP
6.2.1 爬楼梯类问题
1. 爬楼梯(LeetCode 70)
每次可以爬1或2个台阶,爬到第n阶有多少种方法?
状态定义: dp[i]表示爬到第i阶的方法数
转移方程: dp[i] = dp[i-1] + dp[i-2]
初始条件: dp[0] = 1, dp[1] = 1
def climb_stairs(n):if n <= 1:return 1dp = [0] * (n + 1)dp[0], dp[1] = 1, 1for i in range(2, n + 1):dp[i] = dp[i-1] + dp[i-2]return dp[n]
空间优化: 只需保存前两个状态
def climb_stairs_optimized(n):if n <= 1:return 1prev2, prev1 = 1, 1for i in range(2, n + 1):curr = prev1 + prev2prev2 = prev1prev1 = currreturn prev1
变体: 如果每次可以爬1、2或3个台阶呢?
# 转移方程变为: dp[i] = dp[i-1] + dp[i-2] + dp[i-3]
# 需要保存前三个状态
6.2.2 打家劫舍系列
1. 打家劫舍(LeetCode 198)
不能抢劫相邻的房子,求最大金额。
状态定义: dp[i]表示抢劫前i个房子能获得的最大金额
转移方程: dp[i] = max(dp[i-1], dp[i-2] + nums[i])
含义:
- dp[i-1]: 不抢第i个房子
- dp[i-2] + nums[i]: 抢第i个房子
def rob(nums):if not nums:return 0if len(nums) == 1:return nums[0]n = len(nums)dp = [0] * ndp[0] = nums[0]dp[1] = max(nums[0], nums[1])for i in range(2, n):dp[i] = max(dp[i-1], dp[i-2] + nums[i])return dp[n-1]
空间优化:
def rob_optimized(nums):prev2, prev1 = 0, 0for num in nums:curr = max(prev1, prev2 + num)prev2 = prev1prev1 = currreturn prev1
2. 打家劫舍II(LeetCode 213)
房子围成一圈,首尾房子相邻,不能同时抢。
核心思路: 分两种情况
- 抢第一个房子,不能抢最后一个: [0, n-2]
- 不抢第一个房子,可以抢最后一个: [1, n-1]
def rob_ii(nums):n = len(nums)if n == 1:return nums[0]if n == 2:return max(nums[0], nums[1])def rob_range(start, end):prev2, prev1 = 0, 0for i in range(start, end + 1):curr = max(prev1, prev2 + nums[i])prev2 = prev1prev1 = currreturn prev1return max(rob_range(0, n-2), rob_range(1, n-1))
3. 打家劫舍III(LeetCode 337)
房子分布在二叉树上,相邻节点不能同时抢。
状态定义: 返回(不抢当前节点的最大值, 抢当前节点的最大值)
def rob_iii(root):def dfs(node):if not node:return (0, 0)left = dfs(node.left)right = dfs(node.right)# 不抢当前节点:子节点可抢可不抢,取最大值not_rob = max(left) + max(right)# 抢当前节点:子节点一定不抢rob = node.val + left[0] + right[0]return (not_rob, rob)return max(dfs(root))
6.2.3 最大子数组和
最大子数组和(LeetCode 53)
状态定义: dp[i]表示以nums[i]结尾的最大子数组和
转移方程: dp[i] = max(nums[i], dp[i-1] + nums[i])
含义:
- nums[i]: 从当前元素重新开始
- dp[i-1] + nums[i]: 延续之前的子数组
def max_sub_array(nums):if not nums:return 0dp = [0] * len(nums)dp[0] = nums[0]max_sum = dp[0]for i in range(1, len(nums)):dp[i] = max(nums[i], dp[i-1] + nums[i])max_sum = max(max_sum, dp[i])return max_sum
空间优化(Kadane算法):
def max_sub_array_kadane(nums):max_sum = current_sum = nums[0]for num in nums[1:]:current_sum = max(num, current_sum + num)max_sum = max(max_sum, current_sum)return max_sum
变体: 最大子数组乘积(LeetCode 152)
由于有负数,需要同时维护最大值和最小值。
def max_product(nums):max_prod = min_prod = result = nums[0]for num in nums[1:]:if num < 0:max_prod, min_prod = min_prod, max_prodmax_prod = max(num, max_prod * num)min_prod = min(num, min_prod * num)result = max(result, max_prod)return result
6.3 二维DP
6.3.1 路径问题
1. 不同路径(LeetCode 62)
从左上角到右下角有多少条不同路径(只能向右或向下)。
状态定义: dp[i][j]表示到达(i,j)的路径数
转移方程: dp[i][j] = dp[i-1][j] + dp[i][j-1]
初始条件: 第一行和第一列都是1
def unique_paths(m, n):dp = [[1] * n for _ in range(m)]for i in range(1, m):for j in range(1, n):dp[i][j] = dp[i-1][j] + dp[i][j-1]return dp[m-1][n-1]
空间优化: 只需要一行
def unique_paths_optimized(m, n):dp = [1] * nfor i in range(1, m):for j in range(1, n):dp[j] += dp[j-1]return dp[n-1]
2. 不同路径II(LeetCode 63)
网格中有障碍物。
def unique_paths_with_obstacles(obstacle_grid):m, n = len(obstacle_grid), len(obstacle_grid[0])if obstacle_grid[0][0] == 1:return 0dp = [[0] * n for _ in range(m)]dp[0][0] = 1# 初始化第一列for i in range(1, m):dp[i][0] = 0 if obstacle_grid[i][0] == 1 else dp[i-1][0]# 初始化第一行for j in range(1, n):dp[0][j] = 0 if obstacle_grid[0][j] == 1 else dp[0][j-1]# 填表for i in range(1, m):for j in range(1, n):if obstacle_grid[i][j] == 1:dp[i][j] = 0else:dp[i][j] = dp[i-1][j] + dp[i][j-1]return dp[m-1][n-1]
3. 最小路径和(LeetCode 64)
找出路径和最小的路径。
def min_path_sum(grid):m, n = len(grid), len(grid[0])dp = [[0] * n for _ in range(m)]dp[0][0] = grid[0][0]# 初始化第一行for j in range(1, n):dp[0][j] = dp[0][j-1] + grid[0][j]# 初始化第一列for i in range(1, m):dp[i][0] = dp[i-1][0] + grid[i][0]# 填表for i in range(1, m):for j in range(1, n):dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]return dp[m-1][n-1]
6.3.2 编辑距离
编辑距离(LeetCode 72,困难题)
将word1转换为word2的最少操作数(插入、删除、替换)。
状态定义: dp[i][j]表示word1的前i个字符转换为word2的前j个字符的最少操作数
转移方程:
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], # 删除word1[i-1]dp[i][j-1], # 插入word2[j-1]dp[i-1][j-1] # 替换word1[i-1]为word2[j-1])
def min_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] = ifor 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]
6.3.3 最长公共子序列
最长公共子序列(LeetCode 1143)
状态定义: dp[i][j]表示text1的前i个字符和text2的前j个字符的LCS长度
转移方程:
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])
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] + 1else:dp[i][j] = max(dp[i-1][j], dp[i][j-1])return dp[m][n]
相关题目
- 最长公共子串: 要求连续,转移方程不同
- 最长递增子序列(LeetCode 300)
6.4 背包问题
背包问题是动态规划的经典应用,也是面试的高频考点。
6.4.1 0-1背包
问题描述: 有n个物品和容量为W的背包,每个物品有重量w[i]和价值v[i],每个物品只能选一次,求最大价值。
状态定义: dp[i][j]表示前i个物品,背包容量为j时的最大价值
转移方程:
dp[i][j] = max(dp[i-1][j], # 不选第i个物品dp[i-1][j-w[i]] + v[i] # 选第i个物品
)
def knapsack_01(weights, values, capacity):n = len(weights)dp = [[0] * (capacity + 1) for _ in range(n + 1)]for i in range(1, n + 1):for j in range(1, capacity + 1):# 不选第i个物品dp[i][j] = dp[i-1][j]# 选第i个物品(如果放得下)if j >= weights[i-1]:dp[i][j] = max(dp[i][j], dp[i-1][j-weights[i-1]] + values[i-1])return dp[n][capacity]
空间优化: 一维数组,逆序遍历
def knapsack_01_optimized(weights, values, capacity):dp = [0] * (capacity + 1)for i in range(len(weights)):# 逆序遍历,避免重复使用for j in range(capacity, weights[i] - 1, -1):dp[j] = max(dp[j], dp[j-weights[i]] + values[i])return dp[capacity]
相关题目
- 分割等和子集(LeetCode 416): 判断能否分割为两个和相等的子集
- 目标和(LeetCode 494): 给数组元素添加+/-使和为target
6.4.2 完全背包
问题描述: 每个物品可以选无限次。
转移方程:
dp[i][j] = max(dp[i-1][j],dp[i][j-w[i]] + v[i] # 注意是dp[i]而不是dp[i-1]
)
def knapsack_complete(weights, values, capacity):n = len(weights)dp = [[0] * (capacity + 1) for _ in range(n + 1)]for i in range(1, n + 1):for j in range(1, capacity + 1):dp[i][j] = dp[i-1][j]if j >= weights[i-1]:dp[i][j] = max(dp[i][j],dp[i][j-weights[i-1]] + values[i-1])return dp[n][capacity]
空间优化: 正序遍历(允许重复使用)
def knapsack_complete_optimized(weights, values, capacity):dp = [0] * (capacity + 1)for i in range(len(weights)):# 正序遍历,允许重复使用for j in range(weights[i], capacity + 1):dp[j] = max(dp[j], dp[j-weights[i]] + values[i])return dp[capacity]
相关题目
- 零钱兑换(LeetCode 322): 最少硬币数
- 零钱兑换II(LeetCode 518): 组合数
七、贪心算法
7.1 贪心算法核心思想
定义: 贪心算法在每一步选择中都采取当前状态下的最优选择,期望通过局部最优达到全局最优。
与动态规划的区别
- 贪心: 只看当前最优,不考虑后续影响
- 动态规划: 考虑所有可能,保存子问题的解
适用条件
- 贪心选择性质: 局部最优能导致全局最优
- 最优子结构: 问题的最优解包含子问题的最优解
- 无后效性: 当前选择不影响之前的选择
注意: 贪心算法不一定能得到全局最优解,需要通过数学证明或反例验证。
7.2 区间问题
区间问题是贪心算法的典型应用场景。
7.2.1 区间调度
无重叠区间(LeetCode 435)
给定一组区间,找出需要移除的最少区间数,使剩余区间不重叠。
贪心策略: 按结束时间排序,优先选择结束早的区间
def erase_overlap_intervals(intervals):if not intervals:return 0# 按结束时间排序intervals.sort(key=lambda x: x[1])count = 1 # 第一个区间一定选end = intervals[0][1]for i in range(1, len(intervals)):# 如果不重叠,选择该区间if intervals[i][0] >= end:count += 1end = intervals[i][1]# 需要移除的区间数return len(intervals) - count
正确性证明: 选择结束最早的区间,为后续区间留出更多空间。
相关题目
- 用最少数量的箭引爆气球(LeetCode 452)
- 会议室II(LeetCode 253): 需要的最少会议室数
7.2.2 区间合并
合并区间(LeetCode 56)
def merge(intervals):if not intervals:return []# 按起始时间排序intervals.sort(key=lambda x: x[0])merged = [intervals[0]]for i in range(1, len(intervals)):# 如果当前区间与上一个区间重叠if intervals[i][0] <= merged[-1][1]:# 合并区间merged[-1][1] = max(merged[-1][1], intervals[i][1])else:# 不重叠,添加新区间merged.append(intervals[i])return merged
7.3 贪心+排序
很多贪心问题需要先排序,然后根据某种策略选择。
分发饼干(LeetCode 455)
def find_content_children(g, s):g.sort() # 孩子的胃口s.sort() # 饼干尺寸child = cookie = 0while child < len(g) and cookie < len(s):if s[cookie] >= g[child]:child += 1cookie += 1return child
贪心策略: 用最小的饼干满足最小胃口的孩子
7.4 其他贪心问题
跳跃游戏(LeetCode 55)
判断能否跳到最后一个位置。
def can_jump(nums):max_reach = 0for i in range(len(nums)):# 如果当前位置不可达if i > max_reach:return False# 更新最远可达位置max_reach = max(max_reach, i + nums[i])# 如果已经可以到达最后if max_reach >= len(nums) - 1:return Truereturn True
跳跃游戏II(LeetCode 45,中等偏难)
求到达最后位置的最少跳跃次数。
def jump(nums):if len(nums) <= 1:return 0jumps = 0current_end = 0farthest = 0for i in range(len(nums) - 1):farthest = max(farthest, i + nums[i])# 到达当前跳跃的边界if i == current_end:jumps += 1current_end = farthest# 如果已经可以到达最后if current_end >= len(nums) - 1:breakreturn jumps
八、其他重要算法
8.1 二分查找
核心思想: 在有序数组中,每次比较中间元素,将搜索范围缩小一半,时间复杂度O(log n)。
标准模板
def binary_search(arr, target):left, right = 0, len(arr) - 1while left <= right:mid = left + (right - left) // 2 # 防止溢出if arr[mid] == target:return midelif arr[mid] < target:left = mid + 1else:right = mid - 1return -1
变体
1. 寻找左边界
def left_bound(arr, target):left, right = 0, len(arr)while left < right:mid = left + (right - left) // 2if arr[mid] < target:left = mid + 1else:right = midreturn left
2. 寻找右边界
def right_bound(arr, target):left, right = 0, len(arr)while left < right:mid = left + (right - left) // 2if arr[mid <= target:left = mid + 1else:right = midreturn left - 1
经典题目
- 搜索旋转排序数组(LeetCode 33)
- 寻找旋转排序数组中的最小值(LeetCode 153)
- 在排序数组中查找元素的第一个和最后一个位置(LeetCode 34)
8.2 排序算法
虽然实际编程中常用库函数,但理解排序算法原理对算法思维训练很有帮助。
常见排序算法性能对比
| 排序算法 | 平均时间 | 最坏时间 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 快速排序 | 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) | 不稳定 |
快速排序实现
def quick_sort(arr):if len(arr) <= 1:return arrpivot = arr[len(arr) // 2]left = [x for x in arr if x < pivot]middle = [x for x in arr if x == pivot]right = [x for x in arr if x > pivot]return quick_sort(left) + middle + quick_sort(right)
归并排序实现
def merge_sort(arr):if len(arr) <= 1:return arrmid = len(arr) // 2left = merge_sort(arr[:mid])right = merge_sort(arr[mid:])return merge(left, right)def merge(left, right):result = []i = j = 0while i < len(left) and j < len(right):if left[i] <= right[j]:result.append(left[i])i += 1else:result.append(right[j])j += 1result.extend(left[i:])result.extend(right[j:])return result
8.3 位运算
位运算在某些场景下可以大幅提升性能。
常用技巧
| 操作 | 实现 | 说明 |
|---|---|---|
| 判断奇偶 | n & 1 | 奇数为1,偶数为0 |
| 除以2 | n >> 1 | 右移一位 |
| 乘以2 | n << 1 | 左移一位 |
| 取反 | ~n | 按位取反 |
| 消除最后的1 | n & (n-1) | 常用于计数1的个数 |
| 获取最后的1 | n & (-n) | 提取最低位的1 |
经典题目
1. 只出现一次的数字(LeetCode 136)
def single_number(nums):result = 0for num in nums:result ^= num # 异或return result
原理: a ^ a = 0, a ^ 0 = a
2. 位1的个数(LeetCode 191)
def hamming_weight(n):count = 0while n:n &= n - 1 # 消除最后一个1count += 1return count
3. 颠倒二进制位(LeetCode 190)
def reverse_bits(n):result = 0for _ in range(32):result = (result << 1) | (n & 1)n >>= 1return result
九、算法学习路线与技巧总结
9.1 刷题顺序建议
基于LeetCode Hot 100和牛客Top 101的学习路线:
第一阶段:基础数据结构(2-3周)
- 数组与字符串(双指针、滑动窗口)
- 链表(虚拟头节点、快慢指针、反转)
- 栈和队列(单调栈、单调队列)
- 哈希表(两数之和系列)
第二阶段:树与图(2-3周)
- 二叉树遍历(递归与迭代)
- 二叉树路径问题
- 二叉搜索树
- DFS/BFS
- 岛屿问题
第三阶段:核心算法(3-4周)
- 回溯(组合、排列、子集、N皇后)
- 动态规划(一维DP → 二维DP → 背包问题)
- 贪心(区间问题、跳跃游戏)
- 二分查找
第四阶段:进阶与复习(2周)
- 困难题攻克
- 专题总结
- 模拟面试
9.2 解题技巧总结
通用思路
- 理解题意: 仔细读题,明确输入输出,考虑边界条件
- 暴力求解: 先想出暴力解法,分析时间复杂度
- 寻找优化:
- 能否用哈希表空间换时间?
- 能否先排序?
- 能否用双指针优化?
- 是否有单调性可以二分?
- 分类讨论: 复杂问题分情况讨论
- 画图辅助: 画出数据结构的图形表示
- 写伪代码: 先用伪代码理清思路
- 编码实现: 注意边界条件和特殊情况
- 测试用例:
- 正常用例
- 边界用例(空、单元素、极值)
- 特殊用例
Debug技巧
- 添加打印语句查看中间状态
- 使用调试器单步执行
- 用小数据手工模拟
- 检查数组越界
- 检查整数溢出
- 检查空指针
9.3 时间管理
刷题时间分配
- 简单题: 15-30分钟
- 中等题: 30-60分钟
- 困难题: 60-120分钟
如果卡住
- 15分钟无思路: 看提示
- 30分钟无进展: 看题解
- 看完题解:
- 理解思路
- 自己实现
- 第二天再做一遍
9.4 常见错误
思维错误
- 没有考虑边界条件
- 忘记回溯(修改后没有恢复)
- 混淆索引和长度
- 死循环(循环条件错误)
代码错误
- 数组越界
- 整数溢出
- 浮点数比较
- 深拷贝vs浅拷贝
- 引用传递问题
9.5 刷题心态
正确心态
- 刷题是练习思维,不是背答案
- 遇到困难很正常,不要气馁
- 重复刷题是必要的,第一遍不会很正常
- 重视理解原理,而非死记代码
刷题节奏
- 每天1-3题
- 周末复习一周的题目
- 每月总结专题
- 面试前集中复习
十、总结
算法学习是一个循序渐进的过程,需要理解原理、掌握模板、大量练习。
本文系统地梳理了LeetCode Hot 100和牛客Top 101中的核心题型:
数据结构类
- 数组: 双指针、滑动窗口、前缀和
- 链表: 快慢指针、反转、合并
- 栈队列: 单调栈、单调队列
- 哈希表: 快速查找、计数统计
- 树: 遍历、路径、BST、序列化
算法思想类
- 搜索: DFS(回溯)、BFS、拓扑排序
- 动态规划: 一维DP、二维DP、背包问题
- 贪心: 区间问题、跳跃游戏
- 分治: 归并排序、快速排序
- 二分: 标准二分、变体
核心建议
- 掌握基本数据结构的操作
- 理解算法思想的本质
- 熟记常用模板
- 大量练习形成肌肉记忆
- 定期复习巩固
参考文献
- LeetCode官方网站 - 提供了海量算法题目和社区讨论
https://leetcode.cn/ - 牛客网算法题库 - 国内优秀的算法练习平台
https://www.nowcoder.com/ - 《算法导论》(Introduction to Algorithms) - 算法领域的经典教材,全面系统地介绍了算法设计与分析
作者: Thomas H. Cormen等 - 《数据结构与算法之美》专栏 - 极客时间 - 通俗易懂的算法学习专栏
https://time.geekbang.org/column/intro/126 - LeetCode Hot 100题解汇总 - GitHub - 开源的题解集合
https://github.com/tonngw/LeetCode021 - OI Wiki - 算法竞赛知识整合站点 - 提供算法竞赛相关的知识和技巧
https://oi-wiki.org/ - Big O Cheat Sheet - 常用数据结构和算法的复杂度速查表
http://bigocheatsheet.com/ - 《算法》第4版 (Algorithms, 4th Edition) - 由Robert Sedgewick编写的经典算法教材
- LeetCode中国区题解精选 - CSDN博客 - 包含大量优质题解和学习心得
https://blog.csdn.net/ - 代码随想录 - 系统的算法学习路线和详细题解
https://programmercarl.com/ - 算法模板 - 知乎专栏 - 总结了各类算法的通用模板
https://www.zhihu.com/ - 《剑指Offer》 - 面试算法题的经典参考书
作者: 何海涛
