最近因为要找工作了,要开始刷算法题了,但是工作几年好多都忘记了,所以给自己写了一个算法 刷题从基础到进阶总结,让自己把之前丢失的算法知识找回来。
这一套笔记,我建议按照 Python 语法回顾 → 常用数据结构 → 核心算法思想 → 经典题型 → 综合面试训练 的顺序展开。
适合人群: 有几年开发经验、曾经学过算法,但因为长期从事业务开发,对算法和数据结构逐渐生疏,准备重新刷 LeetCode、参加技术面试的程序员。也适合有代码基础,准备学习整套算法体系的大学生
每个章节完成后,我会在每个章节末尾放几个题目来给大家复习巩固
第一章:Python 算法基础
1.1 Python 算法高频语法
这一节只讲刷算法题真正需要的 Python 语法,不展开 Web 开发、异步编程、装饰器等内容。
1.1.1 变量、运算符与整除
a = 10
b = 3
print(a + b) # 13 加法
print(a - b) # 7 减法
print(a * b) # 30 乘法
print(a / b) # 3.333... 真除法
print(a // b) # 3 向下取整除法
print(a % b) # 1 取模
print(a ** b) # 1000 幂运算
笔试重点 :/ 和 // 的区别。
在二分查找中,我们经常这样计算中间位置:
mid = (left + right) // 2
注意:Python 的 // 是向负无穷取整,而不是简单去掉小数部分。
print(-7 // 2) # -4
print(int(-7 / 2)) # -3
Python 的整数没有固定的 32 位或 64 位范围限制,但大整数运算也并非完全没有成本。
1.1.2 条件判断与循环
# 条件判断
if a > b:
print("a 更大")
elif a == b:
print("相等")
else:
print("b 更大")
# 遍历 0 到 4
for i in range(5):
print(i)
# 遍历 2 到 7(不包含 8)
for i in range(2, 8):
print(i)
# 从 5 递减到 1
for i in range(5, 0, -1):
print(i)
# while 循环
left, right = 0, 10
while left < right:
left += 1
刷题中,range()、for、while 是最常见的循环形式。
1.1.3 列表 List
Python 的 list 是刷算法题最常使用的容器之一,可以理解为动态数组。
nums = [10, 20, 30, 40, 50]
# 访问
print(nums[0]) # 10
print(nums[-1]) # 50
# 切片,左闭右开
print(nums[1:4]) # [20, 30, 40]
print(nums[::-1]) # 反转后的新列表
# 添加元素
nums.append(60)
# 删除末尾元素
nums.pop()
# 获取长度
n = len(nums)
# 遍历元素
for num in nums:
print(num)
# 遍历索引和值
for i, num in enumerate(nums):
print(i, num)
算法题中的高频操作:
| 操作 | 写法 | 时间复杂度 |
|---|---|---|
| 索引访问 | nums[i] |
O(1) |
| 末尾添加 | nums.append(x) |
均摊 O(1) |
| 末尾删除 | nums.pop() |
O(1) |
| 头部删除 | nums.pop(0) |
O(n) |
| 查找元素 | x in nums |
O(n) |
| 排序 | nums.sort() |
O(n log n) |
| 切片 | nums[a:b] |
O(k) |
其中 k 为切片长度。
一个常见的二维数组陷阱:
# 错误:每一行引用的是同一个列表
grid = [[0] * 3] * 3
# 正确:每一行都是独立列表
grid = [[0] * 3 for _ in range(3)]
这是动态规划、矩阵题中非常容易出现的 Bug。
1.1.4 字典 Dict:哈希表
哈希表是算法面试中出现频率非常高的数据结构。
它最重要的作用是:使用额外空间,换取更快的查找速度。
count = {}
nums = [1, 2, 2, 3, 3, 3]
for num in nums:
count[num] = count.get(num, 0) + 1
print(count) # {1: 1, 2: 2, 3: 3}
常见操作:
d = {"a": 1, "b": 2}
d["a"] # 获取
d.get("c", 0) # 不存在时返回 0
d["c"] = 3 # 插入或更新
del d["a"] # 删除
for key, value in d.items():
print(key, value)
一般情况下,字典的查询、插入、删除平均为 O(1),最坏情况可能退化。
1.1.5 集合 Set:快速判断是否存在
seen = set()
seen.add(3)
seen.add(5)
print(3 in seen) # True
print(4 in seen) # False
seen.remove(3) # 不存在会抛异常
seen.discard(10) # 不存在也不会报错
典型用途是数组去重:
nums = [1, 2, 2, 3, 3, 4]
unique = set(nums)
注意:集合不保证按照原列表顺序输出。如果题目要求保留首次出现的顺序,可以考虑:
unique = list(dict.fromkeys(nums))
1.1.6 字符串 String
Python 字符串不可变。
s = "hello"
print(s[0]) # h
print(s[::-1]) # olleh
print(s[:3]) # hel
print(s.lower())
print(s.upper())
print("".join(["a", "b", "c"])) # abc
如果需要频繁构建字符串:
chars = []
for c in "hello":
chars.append(c)
result = "".join(chars)
比在循环里不断拼接字符串更容易获得稳定的线性性能。
1.1.7 刷题必备的 Python 标准库
| 工具 | 作用 | 典型使用场景 |
|---|---|---|
collections.Counter |
统计元素频率 | 字母异位词 |
collections.defaultdict |
自动提供默认值 | 分组、邻接表 |
collections.deque |
双端队列 | BFS、单调队列 |
heapq |
最小堆 | Top K、优先队列 |
bisect |
二分插入位置 | 有序数组 |
functools.cache |
缓存函数结果 | 记忆化搜索 |
例如:
from collections import Counter, defaultdict, deque
from heapq import heappush, heappop
from bisect import bisect_left
# 计数
print(Counter("aabbc"))
# 默认字典
graph = defaultdict(list)
graph[1].append(2)
# 双端队列
q = deque([1, 2, 3])
q.append(4)
print(q.popleft()) # 1
# 最小堆
heap = []
heappush(heap, 5)
heappush(heap, 2)
print(heappop(heap)) # 2
# 二分查找插入点
nums = [1, 3, 5, 7]
print(bisect_left(nums, 4)) # 2
1.2 刷题必备的数据结构
这一节先建立数据结构的直觉,后续章节再分别深入讲解。

1.3 时间复杂度与空间复杂度
1.3.1 什么是时间复杂度?
时间复杂度衡量的是:随着输入规模 n 增大,算法执行的操作次数如何增长。

1.3.2 O(1):常数时间
def get_first(nums): return nums[0]
1.3.3 O(n):线性时间
def find_max(nums):
ans = nums[0]
for num in nums:
ans = max(ans, num)
return ans
1.3.4 O(n²):平方时间
def print_pairs(nums):
n = len(nums)
for i in range(n):
for j in range(n):
print(nums[i], nums[j])
1.3.5 O(log n):对数时间
def binary_search(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
1.3.6 空间复杂度
空间复杂度主要关注算法使用的额外内存如何随输入规模增长。
# 额外空间 O(1)
def sum_nums(nums):
total = 0
for num in nums:
total += num
return total
# 额外空间 O(n)
def copy_nums(nums):
result = []
for num in nums:
result.append(num)
return result
注意递归函数也需要考虑调用栈空间,例如深度为 n 的递归,调用栈可能占用 O(n) 空间。
1.4 算法思维:从暴力解到优化解
这是整套笔记最需要反复训练的部分。
不要一开始就背最优解,而应该训练自己形成如下流程:

举一个最经典的例子。
题目:两数之和(LeetCode 1)
给定整数数组 nums 和整数 target,返回两个元素的下标,使它们的和等于 target。
假设恰好存在一个答案,不能重复使用同一个元素。
输入:
nums = [2, 7, 11, 15]
target = 9
输出:
[0, 1]
因为 nums[0] + nums[1] == 9。
解法一:暴力枚举
最直接的想法是检查每一对元素。
def two_sum(nums, target):
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] + nums[j] == target:
return [i, j]
return []
print(two_sum([2, 7, 11, 15], 9))
分析:
-
外层循环枚举第一个元素。
-
内层循环枚举第二个元素。
-
判断两个元素之和是否等于目标值。
时间复杂度 O(n²),额外空间复杂度 O(1)。
解法二:哈希表优化
核心思想:对于每个 num,我们不需要遍历之前所有的元素,只需要检查 target - num 有没有出现过。
def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
need = target - num
if need in seen:
return [seen[need], i]
seen[num] = i
return []
print(two_sum([2, 7, 11, 15], 9))
哈希表解法执行过程


优化后的复杂度:
| 解法 | 时间复杂度 | 额外空间 |
|---|---|---|
| 双重循环 | O(n²) | O(1) |
| 哈希表 | 平均 O(n) | O(n) |
1.5 算法中的常见边界问题
很多错误不在算法思路,而在边界条件。
| 问题 | 常见错误 | 需要注意 |
|---|---|---|
| 空数组 | 直接访问 nums[0] |
先判断是否为空 |
| 单元素数组 | 假设至少有两个元素 | 考虑 n = 1 |
| 重复元素 | 错误地去重 | 判断题目是否允许重复 |
| 负数 | 误用 // 的行为 |
Python 向下取整 |
| 二分查找 | 左右边界更新错误 | 统一使用明确的区间规则 |
| 递归 | 缺少终止条件 | 注意递归深度 |
| 浮点数 | 直接比较相等 | 适当使用误差容忍 |
| 数组遍历 | 遍历时删除元素 | 防止跳过元素 |
第二章:数组与字符串
从这一章开始,正式进入算法刷题。
数组与字符串是整个算法体系的基础。 后面的双指针、滑动窗口、二分查找、前缀和、动态规划,都与本章的知识密切相关。
不过,这一章不急着学习复杂的算法技巧,而是重点恢复三项能力:
-
遍历能力: 能正确地扫描、查找、统计和更新数组。
-
索引能力: 熟悉下标、区间、原地修改,避免边界错误。
-
优化思维: 从直接模拟开始,逐渐学会利用前缀和、额外空间和数组自身的性质优化代码。

2.1.1 数组简单介绍
数组是将一组元素按照顺序组织起来的数据结构,可以通过索引访问元素。
需要注意,Python 的 list 与 C/C++ 中的固定类型数组并不完全相同。
Python list 实际上是一个支持动态扩容的引用数组,存储的是对象引用,而不是直接连续存放所有对象的值。
2.1.2 数组操作与复杂度
| 操作 | Python 写法 | 时间复杂度 |
|---|---|---|
| 读取元素 | nums[i] |
O(1) |
| 修改元素 | nums[i] = x |
O(1) |
| 尾部添加 | nums.append(x) |
均摊 O(1) |
| 尾部删除 | nums.pop() |
O(1) |
| 指定位置插入 | nums.insert(i, x) |
O(n) |
| 删除首元素 | nums.pop(0) |
O(n) |
| 查找元素 | x in nums |
O(n) |
| 排序 | nums.sort() |
O(n log n) |
| 复制数组 | nums.copy() |
O(n) |
为什么 nums.pop(0) 是 O(n)?
因为删除第一个元素后,后面的引用都需要向前移动。
这意味着:
while nums:
nums.pop(0)
如果原数组长度为 n,整体复杂度会达到 O(n²)。
如果需要频繁从两端插入或删除,应该考虑 collections.deque。
2.2 数组遍历:所有数组算法的起点
2.2.1 三种最常见的遍历方式
nums = [10, 20, 30, 40]
# 方式一:只需要元素
for num in nums:
print(num)
# 方式二:需要下标
for i in range(len(nums)):
print(i, nums[i])
# 方式三:同时需要下标和值
for i, num in enumerate(nums):
print(i, num)
什么时候使用哪一种?
-
只进行求和、统计、判断时,优先使用
for num in nums。 -
需要修改原数组、比较相邻元素时,使用索引遍历。
-
需要同时访问位置和值时,使用
enumerate()。
2.2.2 示例:寻找第二大元素
题目:给定整数数组,返回第二大的不同数值,如果不存在则返回 None。
例如:
nums = [5, 8, 3, 8, 6]# 返回 6,而不是 8
最容易想到的是排序:
def second_max(nums):
unique = sorted(set(nums), reverse=True)
return unique[1] if len(unique) >= 2 else None
但排序不是必须的。
我们可以在一次遍历中维护两个变量:
-
first:当前最大的元素。 -
second:当前第二大的不同元素。def second_max(nums):
first = None
second = Nonefor num in nums: if first is None or num > first: second = first first = num elif num != first and ( second is None or num > second ): second = num return secondprint(second_max([5, 8, 3, 8, 6])) # 6
print(second_max([-5, -2, -9])) # -5
print(second_max([3, 3, 3])) # None
时间复杂度 O(n),额外空间 O(1)。
这里出现了一个非常重要的算法思想:
在遍历过程中维护某种状态,就有机会避免重复遍历或排序。
这种思维会在后面的贪心算法、动态规划中反复出现。
2.3 原地修改数组:学会区分读写位置
原地修改是数组题的高频考点。
所谓原地修改,就是尽量直接利用原数组存放结果,不再额外创建一个等长数组。
先从一道经典题开始。
例题 1:移除元素(LeetCode 27)
给定数组 nums 和整数 val,原地移除所有值等于 val 的元素,返回剩余元素数组。这里频繁的删除会导致时间复杂度为O(n^2)
解法一:创建新数组
def remove_element(nums, val):
result = []
for num in nums:
if num != val:
result.append(num)
nums[:len(result)] = result
return len(result)
这个方法容易理解,但需要 O(n) 额外空间。
解法二:原地覆盖
我们可以只使用一个额外变量 slow。
-
fast:负责遍历原数组。 -
slow:负责指向下一个应该写入的位置。def remove_element(nums, val):
slow = 0for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slownums = [3, 2, 2, 3]
k = remove_element(nums, 3)print(k) # 2
print(nums[:k]) # [2, 2]





F 表示 fast(读取位置),S 表示 slow(写入位置)。绿色背景表示已确认的有效前缀。
时间复杂度 O(n),额外空间复杂度 O(1)。
关键理解:循环不变量
在每次循环开始前,
nums[0:slow]始终保存了已经处理过的、需要保留的元素。
这叫作循环不变量。
有了这个不变量,我们就能解释代码为什么正确,而不只是知道它能通过样例。
严格来说,本例属于同向双指针技巧。这里先用于理解原地覆盖,第四章会系统讲解双指针。
2.4 字符串的本质与常见处理方式
2.4.1 Python 字符串不可变
字符串对象创建之后,其内容不能被修改。
s = "hello"
s[0] = "H" # TypeError
字符串不可变,指的是字符串对象创建之后,其内容不能被修改。
但这并不意味着变量 s 不能重新赋值。
s = "hello"
s = "H" + s[1:]
print(s) # Hello

频繁的拼接操作并不推荐,看这段代码:
s = ""for i in range(10000): s += "a"
第 1 次:a
第 2 次:aa
第 3 次:aaa
第 4 次:aaaa
第 5 次:aaaaa
......
如果每次拼接都需要创建新字符串,并复制已有内容,那么总共需要处理的字符数量近似为:

chars = []
for i in range(10000):
chars.append("a")
s = "".join(chars)
print(len(s))
这种方式通常具有 O(n) 的总时间复杂度,其中 n 是最终字符串长度。
2.4.2 字符串常用操作
| 操作 | 代码 | 说明 |
|---|---|---|
| 获取长度 | len(s) |
字符数量 |
| 获取字符 | s[i] |
索引访问 |
| 字符串反转 | s[::-1] |
创建新字符串 |
| 转小写 | s.lower() |
返回新字符串 |
| 分割 | s.split() |
返回列表 |
| 连接 | "".join(arr) |
拼接字符串 |
| 查找 | s.find("abc") |
返回首次位置或 -1 |
| 判断字母数字 | c.isalnum() |
返回布尔值 |
| 判断数字 | c.isdigit() |
返回布尔值 |
2.4.3 示例:反转字符串
如果只是输出反转后的字符串:
def reverse_string(s): return s[::-1]
但如果题目要求原地修改字符数组:
def reverse_chars(chars): left = 0 right = len(chars) - 1 while left < right: chars[left], chars[right] = ( chars[right], chars[left] ) left += 1 right -= 1
这里使用了左右双指针。
它体现了一个非常重要的区别:
返回反转后的新字符串,与原地修改数组,是两个不同的要求。
2.5 前缀和:将重复计算变成快速查询
前缀和是数组算法中的核心优化技巧之一。
我要求数组每三个的前缀和,例如:
nums = [2, 4, 1, 7, 3]
2.5.1 暴力解法
def range_sum(nums, left, right):
total = 0
for i in range(left, right + 1):
total += nums[i]
return total
单次查询最坏 O(n)。
如果有 q 次查询,总时间复杂度最坏可达到 O(nq)。
2.5.2 前缀和的核心思想
我们提前计算:

也就是说,prefix[i] 表示原数组前 i 个元素的和。
为了方便计算,我们特意增加一个初始元素 prefix[0] = 0。


因为:
-
prefix[right+1]包含了从 0 到 right 的所有元素。 -
prefix[left]包含了从 0 到 left - 1 的所有元素。 -
两者相减,就只剩
[left, right]区间。
2.5.4 代码实现
class NumArray:
def __init__(self, nums):
n = len(nums)
self.prefix = [0] * (n + 1)
for i in range(n):
self.prefix[i + 1] = (
self.prefix[i] + nums[i]
)
def sumRange(self, left, right):
return (
self.prefix[right + 1]
- self.prefix[left]
)
obj = NumArray([2, 4, 1, 7, 3])
print(obj.sumRange(1, 3)) # 12
print(obj.sumRange(0, 4)) # 17
这就是 LeetCode 303. 区域和检索 - 数组不可变 的核心解法。
复杂度分析:
| 操作 | 时间复杂度 |
|---|---|
| 构建前缀和 | O(n) |
| 单次区间求和 | O(1) |
| q 次查询总成本 | O(n + q) |
| 额外空间 | O(n) |
2.5.5 前缀和适用的场景
看到这些题目描述时,可以优先思考前缀和:
-
多次查询某一段连续数组的和。
-
统计某一区间内满足条件的元素数量。
-
计算连续子数组的和是否满足条件。
-
需要快速获取二维矩阵某个矩形区域的和。
一个容易混淆的点是:
前缀和特别适合数组内容不变、查询很多的场景。
如果元素经常更新,普通前缀和需要重新维护,后面会学习树状数组、线段树等更适合动态更新的结构。
2.6 差分数组:高效处理区间修改
前缀和解决的是区间查询。
那么,如果我们要频繁修改某个区间呢?
例如:
nums = [0, 0, 0, 0, 0]
我们需要执行:
-
区间
[1, 3]全部加 2。 -
区间
[2, 4]全部加 3。
直接循环当然可以,但如果有成千上万次区间更新,效率会很差。
2.6.1 暴力解法
def add_range(nums, left, right, val):
for i in range(left, right + 1):
nums[i] += val
单次区间更新最坏 O(n)。
2.6.2 差分思想
我们不直接修改整个区间,而是记录变化发生的边界。
对于闭区间 [left, right] 加上 val:
diff[left] += val
diff[right + 1] -= val
最后对 diff 求前缀和,就能恢复出修改结果。
def range_add(n, updates):
diff = [0] * (n + 1)
for left, right, val in updates:
diff[left] += val
diff[right + 1] -= val
nums = [0] * n
current = 0
for i in range(n):
current += diff[i]
nums[i] = current
return nums
updates = [
(1, 3, 2),
(2, 4, 3)
]
print(range_add(5, updates))
# [0, 2, 5, 5, 3]
为什么这样能实现区间加法?
因为 diff[left] += val 意味着从 left 开始增加 val,而 diff[right + 1] -= val 意味着从 right 的下一位开始取消这次增量。
复杂度
假设 n 为数组长度,q 为区间修改次数:
-
创建差分数组 O(n)。
-
执行 q 次区间更新 O(q)。
-
恢复最终数组 O(n)。
总时间复杂度为 O(n + q),额外空间复杂度为 O(n)。
注意:普通差分数组适用于先批量更新,最后统一得到结果的场景,不是说它能同时支持任意在线区间查询都为 O(1)。
2.7 二维数组与矩阵
数组题并不总是一维的。
图论、动态规划、岛屿问题、矩阵遍历都会涉及二维数组。
2.7.1 创建二维数组
rows = 3
cols = 4
grid = [[0] * cols for _ in range(rows)]
二维数组的访问方式:
grid[0][0] = 1
grid[1][2] = 5
2.7.2 二维数组遍历
grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
rows = len(grid)
cols = len(grid[0])
for i in range(rows):
for j in range(cols):
print(grid[i][j])
如果有 m 行、n 列,需要访问每个元素一次,则时间复杂度为 O(mn)。
2.7.3 四方向移动
矩阵搜索中,经常需要访问当前位置的上下左右四个方向。
directions = [
(-1, 0), # 上
(1, 0), # 下
(0, -1), # 左
(0, 1) # 右
]
二维网格与四方向移动
当前
当前位置为 (r, c),四个相邻位置分别为 (r-1,c)、(r+1,c)、(r,c-1)、(r,c+1)。
遍历代码:
def get_neighbors(grid, r, c):
rows = len(grid)
cols = len(grid[0])
directions = [
(-1, 0),
(1, 0),
(0, -1),
(0, 1)
]
result = []
for dr, dc in directions:
nr = r + dr
nc = c + dc
if 0 <= nr < rows and 0 <= nc < cols:
result.append((nr, nc))
return result
这个模板会在后续的 DFS、BFS、岛屿数量、最短路径问题中反复使用。
题目练习
