刷算法题的时候,我最大的感受是:很多时候不是思路卡住,而是基础操作不够熟,手速跟不上脑子。同样是写一个循环,有人敲了半天还因为边界问题报错,有人一两行就搞定,差距基本都出在对Python基础操作的掌握程度上。这篇速查版博文不是教你算法思想,而是把Python刷题中最常用、最高频的基础操作整理成一份可快速查阅的清单,覆盖输入输出、数据结构初始化、排序二分、内置函数、标准库实战以及高频坑位排查。适合正在准备笔试面试、刷LeetCode或者刚学完Python语法需要实战加速的读者,也适合当作考前最后一小时的手边速查资料。
1. 输入输出的高频姿势:读写不卡壳,题就赢了一半
很多人刷题时只顾着研究算法,结果第一行input()就把自己卡住了。尤其在线笔试和力扣这种平台,输入经常是“多行、多空格、结尾不告诉你”的格式,处理不好轻则超时,重则样例都跑不过。输入输出这块,是真正体现“基础操作”四个字的地方。
1.1 读入方式怎么选:read、readline还是input
先说结论:通用场景优先用sys.stdin.buffer.read()一次性读完,需要逐行处理时用sys.stdin.buffer.readline(),最不推荐的是input()在循环里逐行调用。
为什么?input()内部做了很多额外处理,比如去换行、转字符串,在大数据量下会明显拖慢速度。我实测过,10万行整数的读入,input()比sys.stdin.buffer.readline()慢好几倍。比赛或笔试环境里,这种差距足以让你的O(n)算法白白吃一个超时。
一次性读入的典型写法是这样的:
import sys data = sys.stdin.buffer.read().split() # data 是字节串列表,比如 [b'3', b'4', b'5'] nums = list(map(int, data))这里有个新手容易困惑的点:sys.stdin.buffer.read()返回的是bytes,配合.split()后每个元素也是bytes。但没关系,int(b'123')是可以直接解析的,map(int, data)完全正常。如果实在不习惯,可以用sys.stdin.read()拿到字符串再处理,只不过速度略慢一点。
如果是典型的“第一行两个数n和m,接下来n行每行m个整数”这种矩阵输入,就用readline逐行读:
import sys def main(): input = sys.stdin.buffer.readline n, m = map(int, input().split()) matrix = [list(map(int, input().split())) for _ in range(n)] # 后续逻辑... if __name__ == "__main__": main()还有一种情况是题目没告诉你多少行,读到文件结尾为止。这时可以直接遍历sys.stdin.buffer:
import sys for line in sys.stdin.buffer: line = line.strip() if not line: continue nums = list(map(int, line.split())) # 处理每一行...这一招在处理“不确定行数”的输入时特别实用,比while True+try/except干净得多。
1.2 输出优化:join比循环print靠谱得多
很多人在输出阶段犯的错是:每次算出结果就print一次。这样在数据量大时也会拖慢程序,因为每次print都涉及系统调用和缓冲区刷新。正确的做法是:先收集字符串结果,最后一次性输出。
ans = [] for i in range(n): ans.append(str(compute(i))) print("\n".join(ans))如果要求同一行输出多个数,用空格连接:
print(" ".join(map(str, result_list)))为什么一定要用join而不是print(*result_list)?print(*list)的写法虽然简洁,但参数很多时性能一般,且不好控制换行和分隔符。join是C层面实现的字符串拼接,效率远高于循环里一次次的str + str。
另一个与输出相关的点是print的sep和end参数:
print(1, 2, 3, sep=",", end="\n") # 输出:1,2,3这道题如果要求“数字之间用逗号分隔”,就不用自己拼字符串了,直接sep解决。但要注意,这只适合结果量小的场景,大量结果还是收集后用join更稳。
1.3 输入输出的坑位速记
这块我踩过的坑不少,整理几个最常见的:
第一,split()到底按什么分割?默认按任意空白字符,包括空格、制表符、换行。所以输入里哪怕多个空格也不用担心,直接line.split()就对了。但如果题目指定用逗号分隔,那就需要用line.split(",")。
第二,strip()不要乱用。line.strip()是去掉首尾空白,通常在split之前不需要手动调用,因为split本身会忽略空白。但如果某一行完全为空,split()返回空列表,解包时容易ValueError,所以循环里加个if not line.strip(): continue更保险。
第三,sys.stdin.buffer读进来的是字节串(bytes),如果你在调试时打印出来看到b'123'别慌,int(b'123')完全合法。如果你处理的是含字母的字符串,最好统一用sys.stdin.read()而不是buffer,避免还要.decode()。
第四,输出别忘换行。如果题目要求每行一个结果,而你用print(ans)时 ans 是个列表,输出会是[1, 2, 3]这种带方括号的形式,直接判错。正确做法是把列表每个元素转成字符串后join。
2. 数据结构初始化和常用API:别在最基础的容器上翻车
算法题里,数据结构选型对了,题目就解决了一半。但很多人栽在“数据结构用对了,初始化写错了”这种憋屈问题上。Python的容器虽然好用,坑也不少。
2.1 列表和二维数组:初始化能藏住最大的坑
一维列表初始化很简单:arr = [0] * n。但二维数组就不一样了,最常见的一个错误是:
# 错误示范:千万不要这样初始化二维数组 matrix = [[0] * m] * n这行代码表面上创建了n行m列的零矩阵,但实际上每一行都是同一个列表对象的引用。你改matrix[0][0],会发现matrix[1][0]、matrix[2][0]全跟着变了,因为它们在内存里根本就是同一个列表。
正确写法是用列表推导式:
matrix = [[0] * m for _ in range(n)]这样每一行都是独立的新列表。原理也不难理解:*操作符复制的是引用,而不是对象本身;列表推导式则会重新执行一次[0] * m,创建新对象。
二维数组的遍历和修改也是高频操作。遍历时我习惯用两个索引:
for i in range(n): for j in range(m): matrix[i][j] += 1如果只是遍历而不需要索引,直接for row in matrix:就够了。需要同时拿值和索引时用enumerate:
for i, row in enumerate(matrix): for j, val in enumerate(row): print(i, j, val)2.2 字典、defaultdict、Counter:频率统计三件套
字典是算法题里出现频率最高的容器,没有之一。基础操作一定要烂熟于心:
d = {} d['key'] = 1 d.get('key', 0) # 取不到时返回默认值0 d.setdefault('key', 0) # 键不存在时设置默认值统计频率是常考场景,最原始的写法是:
d = {} for x in nums: d[x] = d.get(x, 0) + 1但更推荐用collections.defaultdict,省掉每次get的啰嗦:
from collections import defaultdict d = defaultdict(int) for x in nums: d[x] += 1defaultdict(int)的含义是:如果访问的键不存在,会自动创建该键,并初始化为int()的返回值,也就是0。同理,defaultdict(list)初始化为空列表,非常适合做“分组”场景,比如按某个属性分组:
groups = defaultdict(list) for person in persons: groups[person.city].append(person.name)如果统计的是可哈希元素的频次,直接用Counter更省事:
from collections import Counter c = Counter("aabbbcc") print(c.most_common(2)) # 输出频次最高的前两个元素及其次数 c.update("aa") # 累加计数 c.subtract("b") # 减去计数Counter最常见的用途是判断两个字符串/数组是否互为字符重排,直接Counter(s1) == Counter(s2)就完事了,不用自己排序。
2.3 栈、队列、堆:选对容器比写对逻辑更关键
栈在Python里直接用列表模拟,这是默认操作:
stack = [] stack.append(1) top = stack[-1] stack.pop()队列的话,建议直接用collections.deque,不要用list去pop(0)。因为list.pop(0)是O(n)的,会把所有元素前移一位,数据一多直接超时。deque的两端操作都是O(1):
from collections import deque dq = deque() dq.append(1) dq.appendleft(2) right_val = dq.pop() left_val = dq.popleft()刷题时deque最常见的场景是BFS层次遍历。还有一种“单调队列”的用法,用于滑动窗口最大值等题目。思路是维护一个从队头到队尾递减的队列,每个新元素入队前,先弹出队尾所有比它小的元素,再用队头取当前窗口最大值。
堆在Python里默认是小根堆,用的是heapq标准库:
import heapq heap = [] heapq.heappush(heap, 3) heapq.heappush(heap, 1) heapq.heappush(heap, 2) min_val = heapq.heappop(heap) # 弹出最小值1如果要大根堆,就存入负值-x,弹出时取-heapq.heappop(heap)。另外,heapq.nlargest(k, nums)和heapq.nsmallest(k, nums)在求TopK时特别实用,底层就是堆实现的。
2.4 去重、排序、翻转与枚举:几个高频小技巧
去重最直观的是set,但注意set是无序的。如果既要保留原顺序又要去重,可以用dict.fromkeys:
nums = [3, 1, 3, 2, 1] uniq = list(dict.fromkeys(nums)) # 得到 [3, 1, 2]dict.fromkeys会保留键的插入顺序(Python 3.7+保证),这比手动遍历判断要简洁。
翻转列表有两种常用方式:
nums.reverse() # 原地翻转 rev = nums[::-1] # 返回新列表,原列表不变需要根据是否要保留原列表来决定用哪一种。如果只是遍历时想从后往前走,用for x in reversed(nums)更省内存,因为nums[::-1]会创建一份完整拷贝。
enumerate是遍历时需要索引的首选:
for idx, val in enumerate(nums): print(idx, val)zip适合同时遍历多个序列:
a = [1, 2, 3] b = ["a", "b", "c"] for x, y in zip(a, b): print(x, y)还有一个容易被忽略的用法:zip(*matrix)可以完成矩阵转置,这个在二维数组题里经常能写出很飘逸的代码。
3. 排序、二分和常用搜索模板:刷题命中最高的三件套
排序和二分是笔试里最基础的考点,也是很多中等题的前置步骤。Python把这些工具封装得非常好用,但前提是你得知道每个API的特性。
3.1 排序的三种打开方式:sorted、sort、自定义key
sorted()返回一个新的排序列表,原列表不变;list.sort()原地排序,节省内存。这是最基本的区别,但很多人刷题时没意识到,有时候原数组顺序后面还要用,结果一个sort()下去把原数据改了,调试半天才发现。
更关键的是自定义排序规则。比如按元组排序,默认先按第一个元素排,再按第二个元素排:
points = [(1, 3), (2, 1), (1, 2)] points.sort() # 得到 [(1, 2), (1, 3), (2, 1)]如果要按第二个字段降序、第一个字段升序这种混合规则,就用key参数返回一个元组:
students.sort(key=lambda x: (x[0], -x[1]))注意-x[1]只对数字有效。如果字段是字符串且要降序,可以使用reverse=True,但那样整个排序都会反转。更灵活的做法是用functools.cmp_to_key自定义比较函数:
from functools import cmp_to_key def cmp(a, b): if a[1] != b[1]: return b[1] - a[1] # 按第二字段降序 return a[0] - b[0] # 第一字段升序 students.sort(key=cmp_to_key(cmp))cmp_to_key是Python 3迁移后的产物,在需要复杂排序规则时非常有用。不过能写key就尽量写key,cmp_to_key的性能要差一些,因为比较次数多且每次都要调用Python函数。
3.2 二分查找:能手写但更推荐bisect库
很多人在排序后要查找某个数,马上手写二分。手写二分当然可以,但边界条件很容易写错。其实Python标准库已经给你准备好了bisect:
from bisect import bisect_left, bisect_right nums = [1, 2, 2, 2, 3, 5] bisect_left(nums, 2) # 返回1,第一个 >= 2 的位置 bisect_right(nums, 2) # 返回4,第一个 > 2 的位置bisect_left和bisect_right的区别特别重要:
bisect_left:在有序数组中找第一个 >= target的位置。bisect_right:找第一个 > target的位置。
这两个函数配合使用,可以直接得到target在数组中的区间长度:bisect_right(nums, target) - bisect_left(nums, target),等价于统计target出现的次数。
bisect还常用于“在有序序列中找插入位置”以及“查找第一个满足条件的下标”。比如一道经典题:给定前缀和数组(严格递增),要找第一个和大于等于target的下标。就可以:
from bisect import bisect_left prefix = [0, 1, 3, 6, 10] # 前缀和 idx = bisect_left(prefix, 5) # 返回3,因为 prefix[3]=6如果你确实想手写二分,我推荐这个模板:
def lower_bound(nums, target): left, right = 0, len(nums) # 左闭右开区间 while left < right: mid = (left + right) // 2 if nums[mid] < target: left = mid + 1 else: right = mid return left左闭右开的好处是:循环结束后left == right,不用纠结返回left还是right。这个模板背熟就行。
3.3 DFS、BFS和记忆化搜索:模板比技巧重要
深度优先搜索和广度优先搜索是图论和动态规划的基础。Python写这两类搜索的套路很固定,我直接放模板。
DFS常见写法,用递归加集合标记访问:
def dfs(node, visited, graph): if node in visited: return visited.add(node) for nxt in graph[node]: dfs(nxt, visited, graph)BFS用deque:
from collections import deque def bfs(start, graph): q = deque([start]) visited = {start} while q: cur = q.popleft() for nxt in graph[cur]: if nxt not in visited: visited.add(nxt) q.append(nxt)如果要记BFS层数,最简单的办法是在队列里存入(节点, 步数):
q = deque([(start, 0)]) while q: cur, step = q.popleft() ... q.append((nxt, step + 1))记忆化搜索是DFS优化动态规划题目的利器,Python里最爽的写法是配合functools.lru_cache:
from functools import lru_cache @lru_cache(None) def dfs(i, j): if i == 0 or j == 0: return 1 return dfs(i - 1, j) + dfs(i, j - 1)lru_cache(None)会缓存函数的计算结果,下次遇到相同参数直接返回。这等于把递归搜索自动变成了带记忆的DP,省掉手写memo字典的功夫。
使用lru_cache有一个前提:函数参数必须是可哈希的,所以传列表进函数是行不通的,要改成传元组或者把列表转成不可变对象。
4. 内置函数与标准库的高频组合拳:写更少,跑更快
很多人刷了半年题,还在写for i in range(len(arr))这种三件套。我不是说这种写法错,而是Python内置函数和标准库提供了一堆“一句话解决问题”的工具,能让你代码更简洁、可读性更强,而且性能普遍更好。
4.1 map、filter、zip、reduce:函数式四件套
map用于把函数应用到序列的每个元素上:
nums = ["1", "2", "3"] nums_int = list(map(int, nums)) # [1, 2, 3]filter用于过滤元素:
nums = [1, 2, 3, 4, 5, 6] evens = list(filter(lambda x: x % 2 == 0, nums)) # [2, 4, 6]zip用于并行迭代和矩阵转置:
names = ["a", "b", "c"] scores = [90, 80, 70] for name, score in zip(names, scores): print(name, score) matrix = [[1, 2], [3, 4], [5, 6]] transposed = list(zip(*matrix)) # [(1, 3, 5), (2, 4, 6)]reduce用于累积计算,需要从functools导入:
from functools import reduce nums = [1, 2, 3, 4, 5] total = reduce(lambda x, y: x + y, nums) # 15不过说句实话,reduce在算法题里用得不算多,因为很多累计操作用循环或者内置的sum、max就解决了。真正有价值的是map+zip+ 列表推导式的组合使用,能省掉大量临时变量。
4.2 itertools的宝藏函数:排列组合和累加
itertools是算法题的隐藏神库。笔试里经常遇到的排列组合问题,手写回溯要写一堆代码,而标准库直接给你封装好了:
from itertools import permutations, combinations, product list(permutations([1, 2, 3], 2)) # [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)] list(combinations([1, 2, 3], 2)) # [(1, 2), (1, 3), (2, 3)] list(product([0, 1], repeat=3)) # [(0, 0, 0), (0, 0, 1), ..., (1, 1, 1)]product常用来生成所有可能的二进制组合,比如枚举子集的掩码,或者表示“每个位置选或不选”。
accumulate是前缀和最快的写法:
from itertools import accumulate nums = [1, 2, 3, 4] list(accumulate(nums)) # [1, 3, 6, 10]一个常见的坑:accumulate默认做加法,但也可以传函数作为第二个参数,比如求前缀最大值accumulate(nums, max)。
groupby用于按相邻规则分组,不过使用前通常要先排序,否则相同元素不挨在一起就没法分。
4.3 math和functools:数学题和递归DP的加速器
math库在算法题里出场率很高:
from math import gcd, lcm, comb, perm, inf gcd(12, 18) # 6 comb(5, 2) # 10,组合数 perm(5, 2) # 20,排列数 float('inf') # 正无穷,常用于DP初始化最小值时lcm在Python 3.9+才有,低版本需要自己用a * b // gcd(a, b)实现。如果面试环境不确定版本,建议用后者,兼容性更好。
functools除了刚才说的lru_cache和cmp_to_key,还有一个partial有时会用到,用于固定函数的部分参数:
from functools import partial def power(x, n): return x ** n square = partial(power, n=2) print(square(4)) # 16在算法题里partial用得不多,但看别人代码时遇到不熟悉会懵,知道一下就行。
4.4 字符串处理:split、正则和字符判断
字符串处理题基本占算法题的20%以上,尤其是大厂笔试题。最基础也最高频的操作:
s = " hello world, python " s.split() # ['hello', 'world,', 'python'] s.split(",") # [' hello world', ' python '] ",".join(["a", "b"]) # "a,b" s.strip() # 去掉首尾空白 s.replace("o", "0") # 替换全部 s.isdigit() # 是否全是数字 s.isalpha() # 是否全是字母 ord('a') # 97 chr(97) # 'a'当题目要提取字符串中的所有数字时,正则比手写循环优雅得多:
import re s = "ab12cd34ef56" nums = list(map(int, re.findall(r"\d+", s))) # [12, 34, 56]还有一个容易忽略的是startswith和endswith:
s = "hello.py" s.startswith("he") # True s.endswith(".py") # True字符串的切片也常用,特别适合反转字符串中间部分等题目。记住s[::-1]是翻转字符串,但它是生成新字符串,字符串本身不可变,所以没法原地翻转。
5. 高频坑位与刷题实测心得:细节是面试里的送命题
最后聊聊刷题时真正让我“意难平”的坑位。这些坑不解决,样例能过,提交就挂。
5.1 常见报错与翻车点速查
我整理了一个高频避坑速查表,先列出来,再逐个解释:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 二维数组修改互相影响 | [[0] * m] * n引用同一个内层列表 | 用列表推导式初始化 |
| 递归超时或栈溢出 | 未用缓存/深度超过默认限制 | 加lru_cache,必要时sys.setrecursionlimit |
list.pop(0)超时 | 底层是O(n)的删除操作 | 改用deque.popleft() |
IndexError边界出错 | 循环里没有考虑首尾边界 | 先跑空数组、单元素、全相等用例 |
| 字符串拼接超时 | 循环里反复s += c | 收集列表后用join |
input()读大数据超时 | input内部处理开销大 | 改用sys.stdin.buffer |
这里单独说一下递归深度。Python默认递归深度是1000,如果DFS的递归深度超过这个值,会直接抛RecursionError。我习惯在用到深递归的代码开头加上:
import sys sys.setrecursionlimit(10 ** 6)这个操作能让你少踩很多坑,但也要注意,设置过大的递归深度可能会导致本地环境栈溢出,所以10的6次方是个常用且相对安全的经验值。
另一个很容易被忽略的是“Python的int没有溢出”,但这不意味着你可以随便用大数运算。Python的大整数是任意精度,但超过一定大小后运算效率会急剧下降。刷题时如果数字范围很大,优先考虑取模运算,不要真去算那个天文数字。
5.2 性能认知:什么时候该用Python特性
很多人问Python刷算法题会不会太慢。我的经验是:你写的程序的常数因子,往往比语言本身的影响更大。
举几个例子:
优先用集合或字典做存在性判断,而不是列表。x in list是O(n),x in set是O(1)。数据量到10万级别,这个差距就是几百倍。
能用内置函数做的事情,不要用纯Python循环。比如sum(nums)比手工for累加快得多,max(nums)同理。
列表推导式通常比等价循环快,因为它在底层做了优化:
# 快 squares = [x * x for x in range(10000)] # 慢 squares = [] for x in range(10000): squares.append(x * x)但如果列表推导式里的逻辑太长,可读性会很差,这时候优先考虑可读性,不要为了那一点性能牺牲代码质量。
生成器和列表的选择也经常遇到。处理海量数据时,生成器按需生成、不占用全部内存,是更好的选择;在需要重复遍历或随机访问时,才应该用列表。算法题里如果只需要遍历一遍,用生成器表达式代替列表推导式可以省内存:
total = sum(x * x for x in range(1000000))这里传给sum的生成器不会一次性生成100万个平方数,而是边算边用。
5.3 正式刷题前的15分钟快检清单
根据我个人经验,拿到一道题先别急着写代码,花几十秒做这几件事,能省下大量调试时间:
第一,看输入规模。如果n到10的5次方,基本排除O(n^2)的暴力解法;如果n就几十,暴力搜索反而可能比复杂的优化更合适。这个判断决定了你采用什么算法。
第二,想清楚用哪种数据结构。需要频繁查找用哈希表;需要有序序列中快速定位用二分;需要维护最大最小值用堆;需要先进先出用队列,后进先出用栈。数据结构选对了,代码自然顺畅。
第三,先写一个能跑通小样例的版本,再考虑优化。我见过太多人一上来就写最优解,结果边界条件全漏了。正常流程是:暴力解验证思路,然后用哈希表、前缀和、滑动窗口等技巧逐步优化。
第四,一定要测边界条件。空输入、单元素输入、全部相等、最大规模,这四个用例一定要自己先跑一遍。尤其要注意数组长度为0时,很多代码会直接越界。
第五,调试用的print提交前必须删干净。我干过一个特别丢人的事:本地调试时加了print("debug: ", step),提交的时候忘删,结果输出里混了一堆调试信息,直接判错。后来我习惯了,认真写代码时不往代码里塞长期调试输出,临时调试用assert也比print好,因为assert不影响最终输出。
最后再分享一点个人体会:Python刷题最忌讳的是“看得多、敲得少”。这些基础操作看着都懂,但笔试现场时间紧张,大脑会短路,手也会生疏。我自己的习惯是每隔一段时间就把这份速查清单过一遍,尤其是bisect、heapq、deque、itertools、lru_cache这几个高频库,确保能不假思索地写出来。你会发现,当这些基础操作变成肌肉记忆之后,你刷题时真正需要思考的地方,就只剩下算法本身了。