资讯详情

55道Python面试题及答案:作用域、装饰器、MRO与手写数据结构

📅 2026/11/4 3:31:16 | 华诺云谱 👁 阅读
55道Python面试题及答案:作用域、装饰器、MRO与手写数据结构
简介面向 Python 求职者与初中级开发者的面试题整理文档围绕语法基础、数据结构、函数作用域、装饰器、异常处理、文件操作与正则表达式等高频考点展开适合面试前集中刷题、梳理知识盲区或在项目中查漏补缺。压缩包内仅 1 个 docx 文件约 80KB轻量易读内容以题目、代码片段和答案解析为主打开即可按主题逐题练习。文档收录 55 道常见题既有数组去重求和、全局变量修改报错、多层循环枚举字母数字等基础题也覆盖装饰器自下而上的执行顺序、try-except-finally 输出、字典引用传递、os.walk 遍历目录、match 与 search 差异、贪婪匹配、logging 应用场景以及 json 对 datetime 和中文的序列化处理并给出代码与结果解释。已有 1108 人学习便于读者对照输出复盘执行流程积累可复用的排错与答题思路。1. 先看一道“看起来稳”的题先看一道很多人第一眼觉得稳的题a 1函数体里写a 3调用之后打印a。不少人答 4正确答案是运行时报UnboundLocalError。Python 面试题就爱在这种地方下刀——语法看着简单真正拉开差距的是“赋值会不会改变作用域”“可变对象传进去会不会被改”这类细节。这套 Python 面试题及答案共 55 道覆盖的正是语法、数据结构、函数与装饰器、异常处理、面向对象、文件与正则、模块与工程实践几块内容输出题、手写题、概念题三种形态混着出。想拿分先别急着背结论能把每一步执行顺序讲出来才是面试官愿意给过的理由。这份笔记适合已经写过一些 Python、准备面试前集中过一遍考点的人也适合工作几年但基础题容易翻车的开发者。2. 语法与模块五类输出题加工程常识一起考这一章的内容比较杂因为这类面试的基础题本来就混着出一半是让你口算输出结果另一半是问你文件操作、正则、序列化怎么用。输出题考的是执行模型工程题考的是“有没有真的写过项目”两块的准备方式完全不同。2.1 全局变量与 UnboundLocalErrora 3为什么报错题干很常见a 1 def bar(): a 3 return a bar() print(a)答案不是 4而是直接抛异常UnboundLocalError: local variable a referenced before assignment。原因在编译阶段就定了只要函数体内部对某个名字出现赋值动作这个名字在整个函数作用域里就被判定为局部变量不管赋值语句写在第几行。a 3等价于a a 3先读后写读的时候局部变量a还没绑定任何值于是报错。面试官接着会问那如果函数里只写print(a)呢能正常输出 1因为只读不写时名字按 LEGB 顺序去外层找找到全局作用域里的a。这就是“读可以写不行”的分界线。正确改法有两种面试时最好两种都提一句a 1 def bar(): global a # 声明这里操作的是全局变量 a 3 bar() print(a) # 4另一种是把a作为参数传进来、用返回值接收避免函数偷偷改全局状态。如果是在嵌套函数里改外层函数的局部变量用的不是global而是nonlocal这个追问经常出现答不上来会被认为只背了题。提示回答这类题不要只说“报错”要把“编译期判定局部变量、运行期读不到值”这两步讲清楚面试官才知道你是真理解。2.2 可变对象传参字典那道题到底改了谁题目给一段代码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和外面的mydict指向同一个字典对象d[a] 0是原地修改改的是同一个堆上的对象出了函数依然生效。后面再加c键还是那个字典所以三个键都在。很多人会把它说成“Python 是传引用”这个说法在面试里容易被追着打。更准确的表述是Python 传的是对象引用的副本函数拿到的是同一个对象的引用能不能影响外部取决于你是原地修改还是重新绑定。对比下面这段mydict {a: 1, b: 2} def func(d): d {a: 0} # 重新绑定和外部字典断开 return d func(mydict) print(mydict) # {a: 1, b: 2}函数内部的d指向了新字典外部原对象没被动过。同类追问还有 listappend是原地改对 list 调的是__iadd__也是原地改但对元组做会新建对象。答题时把“可变对象原地改、不可变对象换新对象”这条主线讲出来基本就稳了。2.3 try-except-finally 与装饰器两道输出题的完整执行轨迹异常题长这样def test(): try: raise ValueError(something wrong) except ValueError as e: print(error occured) return finally: print(ok) test()输出两行先error occured再ok。finally块的执行时机在return真正返回之前哪怕try里抛了异常、except里已经returnfinally照样跑。面试官常追问如果finally里也写return呢答案是finally的返回值会覆盖前面的返回值而且异常会被吞掉——这一点在第 5 章会单独展开。装饰器题是这类面试的高频压轴def decorator_a(func): print(Get in decorator_a) 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) def inner_b(*args, **kwargs): print(Get in inner_b) return func(*args, **kwargs) return inner_b decorator_b 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最后返回值是 2。推演方式要分两个阶段讲。装饰阶段自下而上先执行decorator_a(f)打印第一行返回inner_a再执行decorator_b(inner_a)打印第二行返回inner_b此时名字f已经指向inner_b。调用阶段从上到下f(1)实际调inner_b先打印inner_b再调inner_a打印inner_a最后调原函数打印Get in f。返回值一路透传最终是1 * 2 2。答题时把“装饰发生在函数定义时、调用发生在调用时”这句话说出来再补一句“装饰顺序和调用顺序相反”比单纯背“从下往上”更能过。2.4 文件目录遍历与删除os.walk、listdir、rmtree 怎么选题目要求接收一个文件夹路径输出里面所有文件路径包括子文件夹里的。标准答法用os.walkimport os def walk_dir(root_dir): # root当前目录路径dirs子目录名列表files文件名列表 for root, dirs, files in os.walk(root_dir): for d in dirs: print(os.path.join(root, d)) for f in files: print(os.path.join(root, f)) walk_dir(D:/demo_dir)os.walk返回一个生成器每次产出一个三元组默认自顶向下遍历遇到子目录会继续递归进去。路径拼接用os.path.join不要手写斜杠跨平台时手写斜杠必翻车。第二种是手写递归import os def walk_dir_recursive(root_dir): for name in os.listdir(root_dir): path os.path.join(root_dir, name) print(path) if os.path.isdir(path): # 是目录就继续往下钻 walk_dir_recursive(path) walk_dir_recursive(D:/demo_dir)os.listdir只列当前一层子目录要自己判断、自己递归。面试官问两者差别答“walk 是生成器、自带递归、能拿到目录和文件分类listdir 只给名字适合自己控制遍历顺序和过滤逻辑”就够了。删除操作要分清三个函数import os import shutil os.remove(D:/demo_dir/a.txt) # 只能删文件删目录会报错 os.removedirs(D:/demo_dir/empty) # 删空目录且会逐级向上删空目录 shutil.rmtree(D:/demo_dir/data) # 删整棵目录树非空也能删答题时补一句异常处理文件不存在抛FileNotFoundError权限不足抛PermissionError删除前最好先os.path.exists判断。这句话能区分出“背过 API”和“真删过东西”的人。2.5 正则、json、logging、爬虫模块工程常识速答re.match 和 re.search。match只从字符串第 0 个位置开始匹配开头不中就返回 Nonesearch扫描整个字符串找第一个匹配。import re print(re.match(r\d, abc123)) # None开头不是数字 print(re.search(r\d, abc123)) # 匹配到 123邮箱正则。常见写法是[a-zA-Z0-9_-][0-9A-Za-z](\.[0-9a-zA-Z])。这里有个真题坑给的字符串前后带空格用re.match直接判会失败因为第 0 位是空格。正确做法是先strip()再用match或fullmatch校验。import re pattern r[a-zA-Z0-9_-][0-9A-Za-z](\.[0-9a-zA-Z]) text userdemo.test print(bool(re.match(pattern, text))) # False开头是空格 print(bool(re.match(pattern, text.strip()))) # True print(bool(re.fullmatch(pattern, text.strip())))# True要求整串匹配贪婪与非贪婪。默认量词是贪婪的尽可能多匹配量词后面加?变成非贪婪匹配到就停。import re html diva/divdivb/div print(re.findall(rdiv.*/div, html)) # 贪婪1 个结果整段 print(re.findall(rdiv.*?/div, html)) # 非贪婪2 个结果json 序列化。原生支持 str、int、float、bool、None、list、tuple、dictdatetime 不支持要自定义编码器。中文要加ensure_asciiFalse否则会被转成\uXXXX。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) # 其他类型交回父类处理 data {name: A同学, created: datetime(2024, 5, 18, 19, 52, 5)} print(json.dumps(data, clsComplexEncoder, ensure_asciiFalse))logging。作用是给应用和库提供统一、可分级、可输出到多处的日志系统。五个级别从低到高是 DEBUG、INFO、WARNING、ERROR、CRITICAL核心组件是 Logger、Handler、Formatter、Filter。应用场景说清楚一条就够线上排查问题要看带时间戳、模块名、级别的日志print做不到分级和落盘。爬虫模块。requests 发请求BeautifulSoup 和 pyquery 解析 HTMLaiohttp 做异步请求scrapy 是完整爬虫框架selenium 用于需要操作浏览器的场景。面试时按“请求—解析—调度—存储”四层各说一个模块比报菜名强。日期计算。判断某年某月某日是这一年的第几天核心是闰年规则加月份天数累加def day_of_year(date_str): days [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] year, month, day [int(i) for i in date_str.split(.)] if year % 400 0 or (year % 4 0 and year % 100 ! 0): days[1] 29 # 闰年二月 29 天 return sum(days[:month - 1]) day print(day_of_year(2024.03.01)) # 61注意边界1 月 1 日切片为空、返回day本身输入格式要校验否则split直接抛异常。另外sys.path.append(xxx)的作用是把路径临时加进模块搜索路径面试时补一句“生产环境更推荐用包安装或环境变量不建议硬编码路径”是个加分项。3. 数据结构与手写实现去重、穷举、栈、队列、链表这一章的题有个共同特征面试官不只看你能不能写出结果还要看你会不会分析复杂度和边界。写出来只是及格讲清楚为什么这么写才是加分。3.1 去重求和列表法、集合法、计数法差在哪题目是求数组里没有重复的值的和。常见两种写法def func(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): return sum([i for i in set(lis) if lis.count(i) 1]) print(func2([3, 4, 1, 2, 5, 6, 6, 5, 4, 3, 3])) # 7func2的结果是 7只有 1 和 2 各出现一次。但这两个写法都有问题面试官很可能会追着问。func的逻辑漏洞在于第三次出现的元素第一次进lis1第二次走 else 从lis1移除并记进del_lis第三次时i not in lis1成立但i not in del_lis不成立又走 else 执行lis1.remove(i)此时lis1里已经没有i直接抛ValueError。拿[3, 3, 3]一跑就崩。func2能出结果但复杂度不低set去重是 O(n)可对每个元素再调一次countcount本身是 O(n)整体还是 O(n²)只是常数比第一种小。更稳的写法是一次遍历计数from collections import Counter def sum_unique(lis): counter Counter(lis) # 一次遍历完成计数 return sum(num for num, cnt in counter.items() if cnt 1)复杂度 O(n)也不会因为元素出现三次而崩。面试时把三种写法的时间复杂度列出来对比比只写一个答案有说服力。写法时间复杂度风险点双列表法O(n²)元素出现三次时remove抛异常set countO(n²)结果对但每个元素都重新扫一遍列表Counter 计数O(n)需要额外 O(n) 空间3.2 循环穷举字母数字剪枝和 range 起点题目给一个竖式AB - CD EF且EF GH PPP每个字母代表不同数字求所有解。原始写法是九层循环加continue去重能跑但效率很低。关键约束先理清A、C、E、G、P 是首位不能为 0所以 range 从 1 开始B、D、F、H 可以取 0 到 9。等式的第二行PPP等于P * 111这是最大的剪枝点——不用等九层循环跑完才判断。from itertools import permutations for A, B, C, D, E, F, G, H, P in permutations(range(10), 9): if 0 in (A, C, E, G, P): # 首位不能是 0 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: print(A, B, C, D, E, F, G, H, P)面试时如果只写九层循环也能过但回答“怎么优化”时要能说出两点一是用permutations保证每个字母取值互不相同省掉手写去重二是把PPP直接算成P * 111提前缩小判断范围。这类题考的不是能不能跑出结果而是你有没有剪枝意识。3.3 手写栈链表实现和两个队列实现的取舍链表栈的核心是头插头删push 和 pop 都是 O(1)class Node: def __init__(self, val): self.val val self.next None class Stack: def __init__(self): self.top None def push(self, val): node Node(val) node.next self.top # 新节点指向原栈顶 self.top node # 栈顶前移 def pop(self): if self.top is None: return None val self.top.val self.top self.top.next # 栈顶后移一位 return val s Stack() s.push(1) s.push(2) print(s.pop()) # 2 print(s.pop()) # 1 print(s.pop()) # None两个队列实现栈思路是每次 pop 把前 n-1 个元素搬到另一个队列剩下那个就是栈顶from collections import deque class StackWithQueues: def __init__(self): self.q1 deque() self.q2 deque() def push(self, val): self.q1.append(val) def pop(self): if not self.q1: return None while len(self.q1) 1: self.q2.append(self.q1.popleft()) # 搬走前 n-1 个 val self.q1.popleft() # 剩下的就是栈顶 self.q1, self.q2 self.q2, self.q1 # 交换引用q2 清空 return val面试官一定会问“两种实现哪个好”。答案是链表栈更好push 和 pop 都是 O(1)队列版每次 pop 要搬 O(n) 个元素。如果追问队列版有什么优点可以答“只用现成容器、不用自己写节点代码短适合面试手写场景”。3.4 单链表逆置与队列指针题怎么写才不被追问倒单链表逆置是三指针的经典题循环里每一步只做三件事存后继、改指向、往前挪。class ListNode: def __init__(self, val): self.val val self.next None def reverse_list(head): prev None cur head while cur: nxt cur.next # 先存后继防止断链 cur.next prev # 反转当前节点的指向 prev cur # prev 前移 cur nxt # cur 前移 return prev # 新头是原链表最后一个节点最容易错的地方是返回值循环结束时cur是 None新头是prev。如果把return cur写上去返回空面试官一眼就看出来。空链表和单节点要单独想一下循环不进入直接返回prev也就是 None 或原头逻辑依然成立。队列题手写时原版常用insert(0, val)入队、pop()出队逻辑对但insert(0)是 O(n)每次入队都要搬整个列表。标准答法是用dequefrom collections import deque class Queue: def __init__(self, size): self.queue deque() self.size size def is_empty(self): return len(self.queue) 0 def is_full(self): return len(self.queue) self.size def enqueue(self, val): if self.is_full(): return False self.queue.append(val) # 尾部入队O(1) return True def dequeue(self): if self.is_empty(): return None return self.queue.popleft() # 头部出队O(1)答题时顺带提一句“列表头部插入是 O(n)deque 是双端队列两端操作都是 O(1)”这属于加分的工程细节。4. 面向对象三大特性、MRO、双下划线、单例面向对象这块的题有个特点定义人人会背追问一深就露馅。面试官真正想确认的是你写没写过带继承层次的代码遇没遇到过类变量被所有实例共享这种坑。4.1 三大特性与鸭子类型怎么讲到点上继承、封装、多态三个词背下来不难难的是每个都能落到代码。继承是子类直接拥有父类的属性和方法不用重写一遍被继承的叫父类或基类继承的叫子类或派生类。封装是把属性和方法收进类里用访问控制隔开外部直接操作。多态是不同对象调同一个方法名得到不同结果Python 里最直观的例子就是列表相加是拼接数字相加是求和字符串相加是连接。鸭子类型是 Python 多态的真正底色面试很爱考class Duck: def quack(self): print(quack) class Person: def quack(self): print(我会学鸭子叫) def make_it_quack(obj): obj.quack() # 不检查类型只检查有没有 quack 方法 make_it_quack(Duck()) make_it_quack(Person())make_it_quack完全不关心传进来的是什么类只要对象有quack方法就能跑。这就是“走起来像鸭子、叫起来像鸭子那就是鸭子”。答题时补一句“鸭子类型不依赖继承和接口声明靠的是运行时的属性和方法集合”比背定义更像写过代码的人。super()的作用也要能说清子类方法和父类方法重名时子类会覆盖父类想在子类里同时调用父类实现就用super().方法名()。它返回的是 MRO 里的下一个类不是简单地“返回父类”。4.2 MRO 与 C3super 到底调了谁MRO 是方法解析顺序多继承时按这个顺序查找属性和方法。Python 3 的新式类用 C3 算法生成 MRO核心规则是“子类永远排在父类前面多个父类按声明顺序保留且保持每个类的局部优先级”。class A: def hello(self): print(A) class B(A): def hello(self): print(B) class C(A): def hello(self): print(C) class D(B, C): def hello(self): super().hello() print([cls.__name__ for cls in D.__mro__]) D().hello()D.__mro__的顺序是 D、B、C、A、object。D().hello()里super()找的不是“D 的父类”这么模糊而是 MRO 里 D 的下一个类 B所以打印 B。如果 B 里也写super().hello()就会继续走到 C再走到 A形成一条链式调用。答题时能把“super 沿着 MRO 走不是只找直接父类”这句话说出来基本就过关了。4.3 单双下划线与实例/类变量名字改编和共享陷阱单下划线和双下划线的区别要答到名字改编这一层class Demo: def __init__(self): self._protected 1 # 约定俗成的保护变量 self.__private 2 # 触发名字改编 d Demo() print(d._protected) # 1外部能访问但约定上不该动 print(d._Demo__private) # 2名字被改写成 _类名__属性 print(d.__private) # AttributeError双下划线属性在类体内会被改写成_类名__属性名所以子类也没法直接用__private访问只能通过改写后的名字硬访问。这就是“私有”的实现方式——不是真的禁止而是改了名字。父类的私有属性和方法子类无法覆盖这点经常被追问。实例变量和类变量的区别最容易翻车的是可变类变量被共享class Team: members [] # 类变量所有实例共享 def add(self, name): self.members.append(name) t1 Team() t2 Team() t1.add(A同学) print(t2.members) # [A同学]t1 改的 t2 也能看到修复方式是在__init__里写self.members []让每个实例拥有自己的列表。面试时主动提这个坑比等面试官问出来强。4.4 单例模式与可调用对象__new__、元类、__call__单例模式要能写至少两种并且说清线程安全。第一种用__new__加锁控制实例创建import threading class Singleton: _instance None _lock threading.Lock() def __new__(cls, *args, **kwargs): if cls._instance is None: with cls._lock: # 双重检查加锁 if cls._instance is None: cls._instance super().__new__(cls) return cls._instance a Singleton() b Singleton() print(a is b) # True__new__负责创建实例__init__负责初始化实例。单例要在“创建”这一步卡住所以重写__new__。不加锁的话多线程同时进来会创建出两个实例这是面试官最爱追问的点。第二种用元类控制类的调用import threading class SingletonMeta(type): _instances {} _lock threading.Lock() def __call__(cls, *args, **kwargs): if cls not in cls._instances: with cls._lock: if cls not in cls._instances: cls._instances[cls] super().__call__(*args, **kwargs) return cls._instances[cls] class Config(metaclassSingletonMeta): pass元类的__call__在“类名加括号创建实例”时触发所以把单例逻辑放在这里所有用这个元类的类都自动变成单例。可调用对象这道题分两问。判断用callable()可调用对象包括用户自定义函数、内置函数、内置方法、类、定义了__call__的类实例、生成本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑