Python小屋编程题161-170:字符串、递归与字典的进阶实战 我刷到这套Python小屋编程题的第161题时正好是晚上十一点本来想着“再写两道就睡”结果一口气做到了170。倒不是题目有多难而是它那种把前期知识点全部开始交叉使用的编排方式让你写一道就忍不住想看下一道。这里说的“Python小屋编程题161-170”是某位深耕Python教学多年的老师整理的系列练习题中的一组前面160题已经把变量、循环、分支、字符串基础方法都铺垫完了到了这个区间题目开始主动给你挖坑考察的不再是“你记没记住某个API”而是“你能不能把学过的东西组合起来解决一个完整的小问题”。这篇文章想把这一组题里最有代表性的题型和思路梳理出来给正在刷题的人一个参考尤其是那些刷到中段、开始觉得“语法都会但题目做不动”的朋友。1. 这套题在考什么从“会语法”到“会写代码”的分水岭1.1 为什么偏偏是161-170这个区间如果把这套题从1到170拉一条难度曲线前60题几乎是平缓上升的每道题只增加一个新知识点60到100开始出现两个知识点叠加而161-170这个区间曲线的斜率明显变陡。原因很简单教育者在这个阶段默认你已经掌握了Python的核心语法开始把题目设计成“需要自己拆解需求”的形式。题面不再是“请调用某个方法完成某个操作”而是给你一段生活化或工程化的场景描述让你自己决定用什么数据结构、写几个函数、返回什么格式。我做了几遍之后总结出这组题的几个共性。第一题干变长信息密度变大读题本身就是考察点漏掉一个修饰词结果就完全不同。第二解法不唯一用循环能做用列表推导式也能做但不同解法的性能差异开始显现有的解法在数据量变大之后会明显变慢。第三边界条件不再是可有可无的加分项而是直接决定对错空输入、单元素输入、极端值都需要主动覆盖。第四从这一组开始很多题都可以用递归来解递归思维是往后接触树、图和更多算法的基础这一组正好是练递归手感的好时机。1.2 出题思路背后的教学意图这组题之所以值得反复做核心在于它逼着你完成一次“思维跃迁”。举个例子前面你学for循环时题目多半是“计算1到100的和”你只需要记住sum函数或者循环累加但到161-170你面对的问题可能是“给出一段文本统计每个字符出现的次数并按频率排序”。这已经不是记住某个API能解决的了你需要自己设计一个容器来存数据——是字典还是列表排序的key怎么写相同频率的字符按什么顺序排这些都是工程里真实会遇到的决策。换句话说这套题是给“学校知识”和“实际写代码”之间搭桥用的。它不追求偏题怪题所有考察点都在Python标准库和语言基础范围内但组合方式贴近真实开发——处理用户输入要过滤无效字符统计结果要按规则排序函数要能处理空值。这也是我推荐把这一组题作为复习材料的原因一道题能同时练到字符串方法、字典操作、sorted的key参数、lambda表达式而这些恰恰是日常写脚本时最高频的能力。1.3 适合谁刷刷之前需要什么基础如果你还在前面一百多题的阶段建议先把基础循环、字符串、列表的常规操作练熟再上来不然直接冲刺这一组容易打击信心。刷161-170之前我建议你先确保自己能独立写出字符串的切片和常用方法split、join、strip、replace、count、find、列表的增删改查和推导式、字典的get/default赋值技巧、以及自定义函数和默认参数。这些是这一组里反复出现的地基。有了这些基础你就可以把这十道题当作“组合练习”来打而不是一招一式的单点训练。我自己刷题的习惯是先不看任何参考实现把每道题当作一个小项目来对待先写思路注释再填代码最后补测试用例。这种做法在161-170这个区间特别合适因为题目复杂度正好到了“不用设计模式也能写、不写注释就容易乱”的临界点。2. 核心题型拆解字符串、递归、字典2.1 字符串处理看起来简单坑全在细节字符串类是这一组题的重头戏几乎每道题都绕不开。常见的考法有这么几种判断回文、统计词频、过滤特定字符、大小写转换后比较、按某种规则拼接输出。表面上看都是字符串方法的应用但真正拉开差距的是细节处理。拿回文判断举例最直观的思路是把字符串反转和原串比较。Python里s s[::-1]一行就能完成但题目往往不会给你一个干干净净的字符串而是带着空格、标点、大小写。这时候问题的重点就变了你要先定义“回文”在这个题里的判定标准是忽略标点还是忽略大小写还是都忽略这个决策过程就是真实开发里的“需求澄清”比那行比较代码重要得多。还有一个我刷题时反复看到的细节中文字符和英文字符的处理差异。很多初学者统计字符频次时直接用ch.isalpha()过滤但这个方法是按Unicode判断的中文也会返回True如果你的题目本意是只统计英文字母那结果就全错了。这种细节只有亲手踩过坑才会有印象文档里是不会专门写一句“注意中文”的。# 错误示范想过滤出英文字母但中文也通过isalpha text Hello 你好 world 世界 letters [ch for ch in text if ch.isalpha()] print(letters) # 输出 [H, e, l, l, o, 你, 好, w, o, r, l, d, 世, 界] # 正确做法限定英文字母范围 letters [ch for ch in text if a ch.lower() z] print(letters) # 输出 [H, e, l, l, o, w, o, r, l, d]2.2 递归与递推理解“函数调用自己”的真正含义161-170这组里递归题的比例明显上升。很多学习者对递归的态度是看得懂但自己写不出来。我见过太多人把递归理解成“函数在函数里调用自己”这种字面意思结果一写就爆栈。真正的核心是两件事递归出口base case和状态传递。没有出口的递归是死循环状态传递错误则结果全错。我自己的体会是递归题有一个万能的思考顺序。先把问题缩小问自己“如果输入规模小到不能再小答案是什么”这就是出口然后再想“我手里有一个中等规模的问题怎么把它拆成一个更小的子问题并且用子问题的结果拼出当前结果”这就是递归体。只要这两件事都想清楚了递归函数基本不会写错。另一个值得强调的点是递归不一定比循环“高级”。有些题目用递归写很自然比如树形结构和分治问题但有些题比如单纯的累加用递归就是负重前行。我在刷题时见过不止一次有人把简单的循环题硬写成递归结果效率差了几个数量级。选择工具的标准是问题结构是否天然递归而不是为了用递归而用递归这一点在往后的开发里尤其重要。2.3 字典聚合从统计到合并数据组织的基本功字典操作的题目在161-170里也占据了好几席考察的方式通常是两类一类是“聚合”把散落的数据按某个键归类并做统计另一类是“合并”把多个字典按规则合并在一起。这类题的核心就一个函数dict.get(key, default)配合赋值可以省掉一大半if判断。初学者最常见的写法是freq {} for ch in text: if ch in freq: freq[ch] 1 else: freq[ch] 1这个写法没错就是啰嗦。用get可以压缩成两行freq {} for ch in text: freq[ch] freq.get(ch, 0) 1别看这个小技巧它是后面做Counter、做数据清洗、做分组统计时最常用的基本功。字典的get方法在这类题里几乎是最优解——不需要判断键是否存在不需要写else分支一次调用同时完成“读取”和“兜底”。如果你还停留在if key in dict的写法上这组题就是逼你改掉这个习惯的最佳时机。3. 完整解法与逐步推演3.1 字符频次统计一道把字典、排序、lambda全串起来的题我挑的第一道代表题是这类“给定一段英文文本统计每个字母出现的次数忽略大小写只统计英文字母按出现次数从高到低输出次数相同的按字母顺序输出。”这道题几乎覆盖了前面提到的所有知识点做一遍顶得上刷十道简单题。第一步是预处理。要把文本里非英文字母的东西清理掉同时统一大小写。我的做法是def count_letters(text: str) - list[tuple[str, int]]: # 统一小写只保留英文字母 filtered [ch.lower() for ch in text if a ch.lower() z] if not filtered: return [] freq {} for ch in filtered: freq[ch] freq.get(ch, 0) 1 # 按次数降序同次数按字母升序 items sorted(freq.items(), keylambda item: (-item[1], item[0])) return items这里的关键在排序那一行。sorted的 key 是一个元组(-item[1], item[0])元组做排序时先比第一个元素再比第二个元素。次数取负数是因为我们要降序而字母自然升序。用lambda构造这个key一行就完成了“多条件排序”不用写复杂的比较函数。这个写法在实际开发里的出现频率非常高值得完全吃透。测试用例是最重要的环节。我个人会准备这么几组空字符串、纯标点没有字母、大小写混合、全是同一字母、以及一个包含26个字母的完整句子。用这些用例跑一遍边界条件覆盖到才能确定地提交或者写进自己的笔记。很多人做完题不测试就直接看下一道这个习惯在161-170会很快让你吃苦头。3.2 回文判断递归出口和预处理缺一不可另一道典型题是回文判断但加了限制条件“忽略所有非字母数字字符忽略大小写判断一个字符串是否为回文。”这个限制条件就是考预处理能力也是字符串类题最常见的变体。初级写法会很啰嗦先循环过滤再递归判断。其实可以一步到位def is_palindrome(s: str) - bool: # 保留字母和数字统一小写 cleaned [ch.lower() for ch in s if ch.isalnum()] def check(chars: list[str]) - bool: if len(chars) 1: return True if chars[0] ! chars[-1]: return False return check(chars[1:-1]) return check(cleaned)注意几点。第一isalnum判断的是字母或数字比isalpha涵盖更广符合题目“非字母数字”的要求但中文也会被isalnum判定为True如果你的测试用例里恰好有中文要确认它是否符合题目预期这又是一个中英文差异的坑。第二内部递归函数check的操作是头尾不相等就直接False相等就递归检查去掉头尾的剩余部分长度小于等于1时返回True。这就是标准的“缩小窗口”递归结构。如果想更极致这个题还有一行解法def is_palindrome_oneliner(s: str) - bool: cleaned .join(ch.lower() for ch in s if ch.isalnum()) return cleaned cleaned[::-1]递归版的意义在于训练思维一行版的优势是性能更好。在刷题阶段我建议你两种都写一遍——先写递归理解过程再写一行版记住最优解。这两份代码的差别就是“能跑”和“跑得漂亮”的差别。3.3 矩阵顺时针旋转用zip省掉三层循环第三道代表题是矩阵旋转“给定一个n阶矩阵返回它顺时针旋转90度之后的新矩阵。”这类二维列表操作题在161-170里算是比较有区分度的。笨办法是写三层循环先外层行、再内层列开一个新矩阵往里填。但Python有一个内置技巧可以两行搞定def rotate_matrix(matrix: list[list[int]]) - list[list[int]]: return [list(row) for row in zip(*matrix[::-1])]拆开来看matrix[::-1]先做行逆序把最后一行搬到最上面zip(*...)再把每一列的对应元素打包成新的元组相当于做了转置最后用list(row)把元组转成列表。两步分别对应“行反转”和“转置”合在一起正好是顺时针旋转90度。反过来如果要逆时针旋转先转置再反转行就行list(zip(*matrix))[::-1]。很多人第一次看到zip(*matrix)会懵。*在这里是解包操作符把一个二维列表的行逐个拿出来作为多个参数传给zip。zip再把第一列的元素放在一起、第二列的元素放在一起……这不就是转置吗理解了这一层矩阵旋转这类题就再也不用硬背步骤了。不过我也得提醒一句基础不牢固的阶段先老老实实写三层循环跑对结果之后再学这个技巧你会记得更牢。上来就背一行解下次换个方向旋转还是会卡住。4. 刷题过程中的常见问题与排查实录4.1 错误类型速查表把161-170刷完我整理了一张错误对照表基本覆盖了这一阶段最常见的翻车现场现象错误原因解决方式运行报错unhashable type: list把列表当成字典的键改成元组或字符串作为键递归函数报RecursionError没有出口或出口条件写错先写if len(数据) 1之类的兜底结果重复或漏项循环中删除元素导致索引错位用列表推导式或遍历副本lst[:]默认参数污染数据把可变对象写在函数默认参数里默认参数写None函数内再初始化排序结果和预期相反没分清升序降序key里对数值取负或加reverseTrue中文也被过滤/统计进去用isalpha想过滤英文改用范围判断a ch.lower() z这里我想特别展开说说默认可变参数这个坑因为它太隐蔽了。很多人会写def add_item(item, container[]): container.append(item) return container第一次调用返回[1]第二次调用返回[1, 2]看起来很正常。但你换一个函数、换一个场景上一轮的数据残留就会让你百思不得其解。因为默认参数在函数定义时就创建了之后每次调用都用的是同一个列表。我在刷题时遇到过类似问题排错排了半天最后发现问题出在自己随手写的默认参数上。正确写法是def add_item(item, containerNone): if container is None: container [] container.append(item) return container4.2 边界用例是刷题最好的老师刷这套题给我最深的感受是真正让代码出错的不是主流程而是边界。空字符串、只含一个字符、全是相同字符、字典为空、矩阵是1x1、输入None……这些极端情况如果不在写代码时主动考虑提交的时候一定会挂。我给自己定了一个规矩每道题写完先不急着跑功能用例而是先跑一组边界用例至少包含完全空输入、最小合法输入比如空字符串、1x1矩阵、最大规模输入大量数据时验证一下性能、以及“看起来合法但其实是陷阱”的输入比如整串都是无效字符。把这几类用例跑过一遍再跑常规用例正确率会明显提升。另外还要养成分步打印的习惯。字符串过滤完、字典统计完、排序完分别打个print看看中间结果。很多初学者不打印中间过程只看最终输出一旦错了就一脸蒙。调试技能其实比写代码技能更影响刷题效率而调试的第一步就是把中间态亮出来。这组题的复杂度刚好适合练这个习惯再往后题目更复杂时你会感谢自己在这个阶段养成的调试节奏。4.3 刷题节奏与自查方法最后聊聊这组题的刷法。我自己的经验是不要一次从头刷到尾而是按题型分组刷。161-170里字符串类、递归类、字典类各有几道把同类题放在一起做能明显感受到套路的存在。第一次做不出来不要急着看答案给自己十五分钟把题目里提到的所有条件一句一句翻译成代码写下来哪怕写出来是错的也比直接看答案强——看答案记住的是别人的逻辑自己折腾出来的才是你的。每次刷完一组我还习惯做一次“复盘三问”这道题考了哪几个知识点哪个知识点我第一反应没想起来如果把这道题改一改条件比如把字符串改成列表把英文字母改成中文我还能写出来吗这三个问题看着简单但能逼着你把“做对了”升级成“真正会了”。尤其是第三个问题它直接对应真实开发里需求变更的场景比多刷十道新题还值。我把这十道题刷完一遍之后又隔了两周重刷了一遍第二遍的速度比第一遍快了差不多一半而且很多第一遍需要靠提示才能完成的细节第二遍已经能很自然地写出来。我个人觉得161-170真正的价值不在于这十道题本身而在于它把前面一百六十道题攒下的零散知识点第一次系统地拧成了一股绳。如果你也刷到了这个区间别急着赶进度把每道题的多种解法都写一遍把边界用例都跑一遍收获会比想象中大得多。刷完这一组你再往后的题目读题和解法的感觉会完全不一样。