☰
55道Python面试题及答案:执行时机、MRO与手写结构拆解
2026/11/4 13:08:20 网站建设 项目流程

简介:面向准备Python岗位笔试与面试的求职者及需要快速回顾语言核心考点的开发者,这份资源以55道高频面试题为主线,将语法细节与真实笔试场景结合,涵盖数组去重求和、变量作用域与global声明、多层循环枚举、装饰器执行顺序、try-except-finally流程、字典引用修改、os.walk目录遍历、正则match与search差异及贪婪匹配、文件与文件夹删除、logging应用场景、json序列化与datetime定制等知识点,每题配有代码与答案,便于对照自测和查漏补缺。资源包共1个docx文件,约80KB,内容以题目、代码片段与解析文字为主,结构按知识点连续排布,适合作为面试前的速查清单与刷题材料。目前已有1108人学习下载,可用于梳理答题思路、复现代码输出结果并检验对装饰器、异常处理、文件操作等常考模块的掌握程度。

1. 从面试前一晚的翻车说起

有位做后端的读者跟我说,他面试前把 Python 面试题及答案共 55 道从头刷了一遍,题目全都眼熟,真到白板上手写单例、解释装饰器打印顺序的时候还是卡住了。问题不在记性,在这份题库里的题看着简单,答案背后都藏着一个执行时机的细节,背结论没用。这份 docx 把散落在各处的常见问题收成一册,从数组去重、global 作用域、装饰器顺序,一路排到 MRO、手写栈队列、链表逆置和单例模式,每题配了能直接运行的代码和答案。它适合两类人:准备跳槽、需要一条不来回翻页的复习主线的人;写过一两年业务代码、想把「知道」变成「讲得清、写得对」的人。下面先看它怎么组织,再一段段拆代码。

2. 题库骨架:55 道题的知识点分布与语法陷阱

拿到一份 docx 题库,第一件事不是从第一题开始背,而是先把它摊开看清楚:哪些题是同一类知识点的不同问法,哪些题的答案必须亲手跑一遍才算数。这份题库的题目是围绕一张典型的 Python 知识地图展开的,覆盖面比较均衡,偏语法细节,也偏面试官爱追问的执行顺序。

2.1 一份 docx 的知识点分布表

把 55 道题按知识点归堆,大致是下面这个结构。做二次开发的时候,这张表可以直接变成题库的 tag 字段。

模块代表题目考察重点建议投入
基础语法与作用域数组去重、global 报错、字典传参变量查找规则、可变对象高,必手写
循环与流程控制字母数字谜题、判断一年第几天嵌套循环、边界条件中
装饰器与异常双层装饰器打印顺序、try/finally执行时机、返回覆盖高,面试高频
面向对象MRO、单例、魔术方法、with类模型与协议高,区分度大
文件与序列化os.walk 遍历、json 定制、logging工程实用度中
手写数据结构栈、队列、两队列实现栈、链表逆置指针操作与复杂度高,白板题常见

刷的顺序建议反过来:先挑「手写数据结构」和「面向对象」这两块动手写,写完再回头刷语法题。原因是语法题的答案你大概率见过,写得出来不代表讲得清;而手写结构一旦写错,运行结果会直接打脸,反馈最快。

2.2 数组去重的两种解法:都在 O(n²) 上栽跟头

题库第一题是求数组里没有重复的值的和,给了两个版本。第一个版本维护两个列表,一个存候选值,一个存已经判定为重复的值;第二个版本用了集合加count:

def func(lis): """双列表法:lis1 存候选,del_lis 存已判定重复的元素""" lis1 = [] del_lis = [] for i in lis: if i not in lis1: # 候选里没有它 if i not in del_lis: # 也没被判定过重复 lis1.append(i) # 收进候选 else: # 候选里有,说明至少第二次出现 del_lis.append(i) lis1.remove(i) # 从候选里剔除 return sum(lis1) def func2(lis): """集合法:先用 set 去重,再用 count 过滤出现次数为 1 的""" return sum([i for i in set(lis) if lis.count(i) == 1]) print(func([3, 4, 1, 2, 5, 6, 6, 5, 4, 3, 3])) # 3 print(func2([3, 4, 1, 2, 5, 6, 6, 5, 4, 3, 3])) # 3

两个函数的结果都是 3,因为这一串数字里只出现一次的是 1 和 2。func的关键在del_lis:一个元素重复出现后,即使第三次出现时已经不在lis1里,也会被del_lis拦住,不会被重新收回去,这个判断漏掉就会写成把 3 加回去的错误版本。

但这两版都有性能问题,面试官往下一追问「10 万个元素呢」就得露怯。func里的in判断是线性扫描,func2里的count对 set 里每个元素都要把原列表扫一遍,都是 O(n²)。真正在生产代码里我会写:

from collections import Counter def func_fast(lis): counter = Counter(lis) # 一次遍历完成计数 return sum(v for v, c in counter.items() if c == 1) # O(n)

Counter把计数压到一次遍历,后面只是遍历字典。参数上没什么可调的,但要注意它对可哈希元素才有效,列表套列表这种嵌套结构得先转成元组再用。

2.3 global 与可变对象:两道作用域错题的真正报错

题库里有一道「下面代码执行结果是什么」的题:

a = 1 def bar(): a += 3 bar() print(a)

答案是运行出错,但错在哪、报什么,很多人说不上来。实际报的是UnboundLocalError: local variable 'a' referenced before assignment。原因在于函数体里出现a += 3这种赋值语句后,a在整个函数作用域内都被判定为局部变量,解释器执行到a += 3时要去读局部变量a的值,可它还没被赋值,于是报错。这不是语法错误,代码能编译通过,是运行时才炸。

修法有两种,看意图选:

a = 1 def bar_fixed(): global a # 显式声明操作的是模块级变量 a += 3 def bar_local(): temp = a + 3 # 只读全局变量不需要声明 return temp

我一般会建议面试时把两种都写出来,顺便补一句「如果a是可变对象比如列表,a.append(3)不需要 global,因为它没有重新绑定名字」。这一句能直接拉开和背答案的人的差距。

同一章里还有一道字典题,考的是可变对象传参:

mydict = {'a': 1, 'b': 2} def func(d): d['a'] = 0 # 原地修改,改的是调用方传进来的那个对象 return d func(mydict) # 返回值不接也行,原字典已经变了 mydict['c'] = 2 print(mydict) # {'a': 0, 'b': 2, 'c': 2}

想切断这种副作用,就得在函数开头做一次浅拷贝:d = dict(d),嵌套结构再深一层要用copy.deepcopy。这道题的答案之所以常被记错成原字典不变,是因为很多人把「函数内重新赋值d = {...}」和「原地修改d[key] = ...」混成一回事了。

2.4 多层循环的字母谜题:用 permutations 替掉八层 for

题库里最难读的是一道字母数字谜题:A 到 H 和 P 各代表一个互不相同的数字,满足 (AB) - (CD) = (EF) 且 (EF) + (GH) = (PPP)。原始答案写了八层嵌套的for,每层再用if X in [...]去重,光读就要几分钟。

逻辑上没问题,但这段代码有两个实际缺陷:一是可读性太差,二是每层都要手动维护「已用数字」列表,漏一层就出重复解。用标准库重写:

from itertools import permutations def solve(): results = [] # permutations 保证 9 个数字两两不同,省掉所有 in 判断 for A, B, C, D, E, F, G, H, P in permutations(range(10), 9): # 多位数首位不能是 0 if 0 in (A, C, E, G, P): continue ab, cd, ef, gh = A * 10 + B, C * 10 + D, E * 10 + F, G * 10 + H if ab - cd == ef and ef + gh == P * 111: results.append((A, B, C, D, E, F, G, H, P)) return results print(solve())

permutations(range(10), 9)直接给出所有 9 元不重复排列,A * 10 + B把两个数字拼成两位数,P * 111就是 PPP。写这种题的时候,先想清楚「互不相同」这个约束能不能交给标准库,能交出去就别自己维护列表。注意这个写法是暴力枚举,9 个数字的排列量级约 36 万,跑起来是秒级;如果题目扩展到 10 个以上字母,就得换剪枝思路了。

3. 装饰器与异常链路:打印顺序背后的执行时机

装饰器和异常是面试里最容易「背得出答案、讲不清原因」的两块。题库给了好几道看输出结果的题,答案都对,但注释语焉不详。这一章把打印顺序拆到每一步,方便你在白板上边说边写。

3.1 装饰器执行顺序:装饰阶段从下往上,调用阶段从上往下

题库里这段双层装饰器的代码,加上了functools.wraps和正确的缩进:

import functools def decorator_a(func): print('Get in decorator_a') # 装饰阶段就打印,不是调用时 @functools.wraps(func) def inner_a(*args, **kwargs): print('Get in inner_a') return func(*args, **kwargs) return inner_a def decorator_b(func): print('Get in decorator_b') @functools.wraps(func) def inner_b(*args, **kwargs): print('Get in inner_b') return func(*args, **kwargs) return inner_b @decorator_b # 等价于 f = decorator_b(decorator_a(f)) @decorator_a def f(x): print('Get in f') return x * 2 f(1)

完整输出是:Get in decorator_a、Get in decorator_b、Get in inner_b、Get in inner_a、Get in f。

理解的关键是把@看成函数调用。@decorator_a离函数最近,先执行f = decorator_a(f),所以先打印 a;然后@decorator_b包住刚才的结果,打印 b。此时f已经被换成inner_b,而inner_b闭包里持有的是inner_a,inner_a闭包里持有的才是原始函数。调用f(1)时是从外层往里剥洋葱,顺序自然就是 b、a、原始函数。

这里functools.wraps别省。它把原函数的__name__、__doc__复制到包装函数上,否则排查日志时你会看到一堆叫inner_b的调用记录,属于那种查一次就想砸键盘的问题。

再补一个面试常追的点:装饰器带参数怎么写。带参数的装饰器是三层,最外层收参数,中间层收函数,最内层收调用参数,写法如下:

def repeat(times): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): result = None for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) def hello(): print('hi')

3.2 try/except/finally 与 return 的交互

题库里那道异常题,答案是先打印error occured再打印ok:

def test(): try: raise ValueError('something wrong') except ValueError as e: print('error occured') return 'from except' # 有 return,但 finally 仍然执行 finally: print('ok') print(test())

输出顺序是error occured、ok、from except。最后那个返回值是except块里给的,finally只是执行、没有改写返回值。这是正常用法。

危险的是另一种写法:

def bad(): try: raise ValueError('boom') finally: return 'swallowed' # 异常被静默吞掉,调用方毫无感知 print(bad()) # swallowed,没有任何报错

finally里写return会把正在抛出的异常直接吞掉,还会覆盖try里的返回值。这类代码在线上最难查,因为日志里干干净净,业务却莫名其妙不走异常分支。我自己的规矩是finally里只做资源释放,绝不写return和raise。

异常捕获的粒度也值得说一句。题库只给了except ValueError,但实际项目里我应该补一个兜底:

try: do_something() except ValueError as e: logger.warning('参数非法: %s', e) raise except Exception: logger.exception('未预期异常') # exception 会带上完整 traceback raise

raise不带参数表示原样抛出,保留原始堆栈,这比raise e更准确,后者会把抛出点改到当前行。

3.3 logging 不只是 print 的替代品

题库里问到 logging 的作用和应用场景,标准答案是「记录日志」。真到项目里,要讲清楚的是为什么不能用 print 顶替。

import logging logging.basicConfig( level=logging.INFO, format='%(asctime)s %(levelname)s %(name)s %(message)s' ) logger = logging.getLogger(__name__) logger.info('service start, pid=%s', 1234) logger.warning('cache miss, key=%s', 'user:42')

三个参数值得记:level控制输出阈值,format里的%(name)s是 logger 名字,getLogger(__name__)拿到的就是当前模块名,日志里能直接定位到文件。占位符用%s而不是 f-string,因为 logging 会延迟格式化,日志级别不够时字符串根本不会被拼出来,在高频日志场景下这个差别不小。

basicConfig有个坑:它只在 root logger 没有 handler 时生效,第二次调用直接忽略。所以线上项目通常是显式配 handler:

from logging.handlers import RotatingFileHandler handler = RotatingFileHandler('app.log', maxBytes=10 * 1024 * 1024, backupCount=5) handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s')) logger.addHandler(handler)

maxBytes单文件上限,backupCount保留几个历史文件,两个一起用才能防止日志把磁盘写满。

3.4 json 序列化的两个定制点:datetime 与中文

题库里 json 那道题问了三件事:能序列化哪些类型、怎么支持 datetime、中文怎么不转义。前两个的答案是str、int、float、bool、None、list、tuple、dict,datetime 得自定义编码器:

import json from datetime import datetime from json import JSONEncoder class ComplexEncoder(JSONEncoder): def default(self, obj): if isinstance(obj, datetime): return obj.strftime('%Y-%m-%d %H:%M:%S') return super().default(obj) # 其它类型回退给父类,别直接 return str(obj) data = {'name': 'alexa', 'created': datetime.now()} print(json.dumps(data, cls=ComplexEncoder, ensure_ascii=False, indent=2))

三个点要说明白:default只在遇到无法序列化的类型时被调用,所以正常类型不会走到这里;遇到不认识的对象一定要super().default(obj),直接str(obj)会把本该报错的地方变成一串没用的字符串;ensure_ascii=False才能让中文按原样输出,默认是True,中文会变成\u4e2d\u6587这种形式,接口联调时看到这一坨很容易以为是编码错了。

indent参数顺手记一下,调试期加indent=2输出好读,上线前要去掉,它会让体积涨一截。

4. 面向对象与手写结构:MRO、单例和栈队列的实现细节

这一块是题库里分量最重的部分,也是最考验「能不能白板写出来」的地方。类的加载、MRO、单例、上下文管理器、手写栈队列链表,几乎每道都是面试官会追问三层的那种。

4.1 MRO 与 C3:多继承到底按什么顺序找方法

题库里问了 C3 算法和 MRO 的关系,标准说法是「新式类用来生成方法解析顺序的一套算法」。落到代码上,能讲清楚下面这段就够用了:

class A: def hello(self): return 'A' class B(A): pass class C(A): def hello(self): return 'C' class D(B, C): pass print([cls.__name__ for cls in D.__mro__]) # ['D', 'B', 'C', 'A', 'object'] print(D().hello()) # C

C3 的合并规则可以记成一句话:一个类的 MRO 等于它自己,加上所有父类 MRO 的合并,再加父类列表本身。合并时从每个列表头部取第一个「不在其它列表尾部出现过」的类。D的两个父类是B和C,B的 MRO 是[B, A, object],C的 MRO 是[C, A, object],合并后就是D, B, C, A, object。B里没重写hello,沿着 MRO 往下找,第一个有实现的是C。

面试里常被追问的是「为什么 Python 3 所有类都是新式类」和「菱形继承下 super 到底调到谁」。后者的答案是:super()不是调父类,是调 MRO 里的下一个类。在D里写super().hello()走的是B,在B里写就走的是C,这个顺序在协作式多继承里很关键。

4.2 单例的两种写法:new加锁与元类

题库给了两种单例,第一种用__new__加线程锁:

import threading class Singleton: _instance_lock = threading.Lock() def __new__(cls, *args, **kwargs): if not hasattr(cls, '_instance'): with cls._instance_lock: # 锁内再判一次,双检锁 if not hasattr(cls, '_instance'): cls._instance = super().__new__(cls) return cls._instance obj1, obj2 = Singleton(), Singleton() print(obj1 is obj2) # True

必须加锁,因为if not hasattr和赋值之间有窗口期,多线程下可能创建出两个实例。super().__new__(cls)不能写成object.__new__(cls),后者在多继承混入别的类时会跳过父类逻辑。

第二种用元类,把判断提到类创建层面:

class SingletonMeta(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls] class Config(metaclass=SingletonMeta): pass

元类这版的好处是用字典按类分别存实例,一个元类可以同时服务多个单例类,互不干扰。__new__那版用hasattr(cls, '_instance')有个隐患:子类会继承父类的_instance属性,导致子类拿到的还是父类的实例。要修就得改成检查cls.__dict__:

if '_instance' not in cls.__dict__: ...

另外别忘了__init__每次实例化都会再执行一遍,单例只保证对象唯一,不保证初始化只跑一次。要控制初始化次数,得在__init__里加个标记位。

4.3 with 与上下文管理器:exit的返回值会吞异常

题库里让写一个支持 with 的类,答案用__enter__和__exit__。这份实现我改写成了一个计时器,更接近真实用途:

import time class Timer: def __enter__(self): self._start = time.perf_counter() return self # 返回值会绑定到 as 后面的变量 def __exit__(self, exc_type, exc_val, exc_tb): self.cost = time.perf_counter() - self._start print(f'cost {self.cost:.4f}s') return False # False 表示不吞异常,让它继续往外抛 with Timer() as t: time.sleep(0.1)

__exit__的三个参数在没异常时都是None,有异常时分别是异常类型、异常实例和 traceback。返回值是布尔语义:True表示异常已处理、不再向外抛,False或None表示继续传播。

这个返回值是最容易出错的地方。返回True的上下文管理器会把with块里所有异常静默吃掉,with Timer(): 1/0这种代码不会报错,问题被藏起来。除非你写的就是「忽略异常」语义,否则一律返回False。

__enter__返回self也不是必须的。返回别的对象、甚至什么都不返回(此时as拿到None)都合法,取决于你想让调用方拿到什么。

4.4 手写栈、队列与链表逆置

栈的写法题库用了链表节点实现,这个版本适合展示指针操作:

class Node: def __init__(self, val): self.val = val self.next = None class LinkedStack: def __init__(self): self.top = None def push(self, val): node = Node(val) node.next = self.top # 新节点指向原栈顶 self.top = node # 栈顶前移 return val def pop(self): if self.top is None: return None val = self.top.val self.top = self.top.next # 栈顶下移一位 return val

push和pop都是 O(1),不涉及搬移元素。如果只是做题,用list的append和pop更省事;但面试官问「用链表实现」就是想看指针怎么挪,node.next = self.top这行是整个结构的核心。

队列那题,题库的写法用insert(0, val)入队、pop()出队,逻辑没错但复杂度是 O(n),每次入队都要把整个列表往后挪。同样的语义用collections.deque是 O(1):

from collections import deque class Queue: def __init__(self, size): self._data = deque() self._size = size def is_empty(self): return not self._data def is_full(self): return len(self._data) == self._size def enqueue(self, val): if self.is_full(): return False self._data.append(val) return True def dequeue(self): return self._data.popleft() if self._data else None

链表逆置是三行指针操作的经典题,务必练到闭眼能写:

def reverse_list(head): last = None while head: tmp = head.next # 先存下一个,否则改完 next 就断了 head.next = last # 当前节点反向指 last = head # last 前移 head = tmp # head 前移 return last # 循环结束时 last 是新头

顺序不能乱:tmp = head.next必须在改head.next之前,这是整道题唯一的坑,写反了链表直接从中间断掉,返回的只剩半截。

5. 常见问题排查:照抄题库代码最容易翻车的 5 个点

docx 题库有个通病:排版一复制就散架,答案看着对,跑起来不是报错就是结果不一样。下面这几条是我实际带人时反复遇到的。

5.1 复制出来就 TabError,__init__变成init

现象:把题目代码粘进编辑器,一运行报TabError: inconsistent use of tabs and spaces in indentation,或者装饰器、魔术方法全部失效,__init__显示成init、*args变成args。

原因:docx 的富文本会把下划线渲染成格式标记,复制时被丢掉;缩进里 tab 和空格混排,Python 3 对此零容忍。

解决:粘完先全选,把缩进统一替换成 4 个空格;再全局搜一遍init、new、call、enter、exit,凡是该带双下划线的地方手动补上。魔术方法名字不对时不会报错,只会静默不生效,比报错更难查。

5.2 以为a += 3是语法错误,实际是运行时 UnboundLocalError

现象:题目问「执行结果」,很多答案写「语法错误,需要加 global」。实际跑起来是UnboundLocalError: local variable 'a' referenced before assignment。

原因:函数体内出现对a的赋值,编译期就把a判定为局部变量,运行时读取未赋值的局部变量才报错。语法检查阶段完全通过。

解决:区分「读全局」和「写全局」。只读不用声明,写(重新绑定)才需要global。面试时把这个区别说出来,比背「加 global」强得多。

5.3 单例的hasattr(cls, '_instance')被子类继承污染

现象:父类做了单例,子类实例化出来拿到的却是父类的对象,type(obj)一看不对。

原因:hasattr(cls, '_instance')会沿着继承链往上找,父类已经设过_instance,子类判断直接为真,于是返回父类实例。

解决:判断改成'_instance' not in cls.__dict__,只检查当前类自己的属性字典。元类版本用cls做字典 key 天然没这个问题。

5.4insert(0, val)和pop(0)在万级数据上卡住

现象:队列代码本地跑几十条数据没问题,面试官让改成 10 万条压测,直接卡到超时。

原因:Python 的list是连续内存,头部插入和头部删除都要搬移后面所有元素,单次 O(n),累计 O(n²)。

解决:队列一律用collections.deque,append和popleft都是 O(1)。栈用list的尾部操作就没问题,因为尾部不涉及搬移。

5.5logging.basicConfig写了不生效

现象:配了basicConfig(level=logging.DEBUG),日志还是一堆 INFO 以上的内容不输出 DEBUG。

原因:basicConfig只在 root logger 没有任何 handler 时才生效。项目里某个依赖先调过一次,或者你自己在别处addHandler过,后面这次调用就被忽略了。

解决:要么确保只在程序入口调用一次,要么绕开basicConfig,显式建 handler 加到自己的 logger 上,并设置logger.setLevel(logging.DEBUG)和logger.propagate = False防止日志重复输出。

6. 二次开发:把 docx 题库改成能自测的答题脚本

刷题库最没效率的方式就是眼睛看答案。这份 docx 真正值钱的用法是把它拆成结构化数据,让机器替你判对错。我一般会先手工整理成 jsonl,每行一道题,字段固定成id、topic、code、expected、note,expected存运行后的标准输出,note存踩坑点。

有了这份数据,判题脚本的核心就是隔离执行,因为题库里有死循环、有sys.exit、有会抛异常的代码,绝不能直接在主进程里exec:

import json import subprocess import sys import tempfile from pathlib import Path def run_snippet(code: str, timeout: float = 5.0) -> str: """把代码写进临时文件,用子进程跑,超时直接杀掉""" with tempfile.NamedTemporaryFile('w', suffix='.py', delete=False, encoding='utf-8') as f: f.write(code) path = f.name try: r = subprocess.run([sys.executable, path], capture_output=True, text=True, timeout=timeout) return (r.stdout + r.stderr).strip() except subprocess.TimeoutExpired: return '__TIMEOUT__' finally: Path(path).unlink(missing_ok=True) # 临时文件必须清掉 def grade(jsonl_path: str): passed = failed = 0 with open(jsonl_path, encoding='utf <p> <a href="https://download.csdn.net/download/qq_33037637/88093734" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询