1. 阶段复盘:第五次总结,我终于开始“写明白”了
照例先交代背景。这个系列走到第五篇,我的学习主线其实变过好几次:第一篇在消化变量、分支、循环这些最基础的东西,第二篇开始碰函数与模块化,第三篇和第四篇主要围着文件读写、异常处理、简单命令行交互打转。到了这篇“初学总结5”,我给自己设定的目标不是再背多少新语法,而是把前面学过的东西串起来,真正能独立拆解一个小需求,并且把它落成一段不太难看的代码。说白了,前面的阶段是“看得懂”,这个阶段我想追求“写得顺”。
如果你现在也处于类似的状态——语言基础基本过完一遍,但一到自己写东西就不知道从哪下手,看到稍微复杂一点的问题脑子就乱——那这篇总结应该能给你一些参考。我会把我这阶段学到的核心知识点、做过的两个小项目、踩过的几个坑,以及整理出来的排查思路,完整过一遍。没有花哨的内容,全是自己手动敲过、改过、又改回来过的真实记录。
2. 这一阶段的核心突破:从“会语法”到“会拆解”
2.1 复杂度意识:下手写代码之前,先算算会不会跑不动
以前写代码真的就是“能跑就行”,完全不考虑数据量大了会怎样。这阶段第一个让我开窍的概念是时间复杂度,特别是大O表示法。
我举个例子你就明白我在说什么了。假设你要在一个列表里找某个元素,最简单的是从头到尾遍历,这在Python里就是一句:
def find_index(items, target): for i, item in enumerate(items): if item == target: return i return -1这个写法没问题,但如果列表里有十万个元素,而目标恰好在最后,你就得老老实实比较十万次。写成大O表示法就是O(n),n代表数据规模。同样的问题,如果列表是有序的,用二分查找可以把复杂度降到O(log n),十万个元素的情况下大概只需要比较十七次左右。
可能你会说,现在电脑性能这么强,多点几次循环怕什么。但等你真正面对真实场景,比如处理一份几万行的数据文件,或者在一个查询结果集里反复筛选的时候,O(n^2)和O(n)的差距会从“几秒”变成“几分钟到几小时”。这阶段我把常见复杂度列了一个对照表,方便自己平时估算:
| 复杂度 | 名称 | 数据量从100翻到10000时的大致表现 | 常见场景 |
|---|---|---|---|
| O(1) | 常数时间 | 时间几乎不变 | 直接按下标访问列表、字典按键取值 |
| O(log n) | 对数时间 | 增长极慢 | 二分查找、平衡树操作 |
| O(n) | 线性时间 | 时间同比例增长 | 单次遍历、线性查找 |
| O(n log n) | 线性对数 | 增长稍快 | 快速排序、归并排序 |
| O(n^2) | 平方时间 | 时间爆炸式增长 | 双层嵌套循环、朴素排序 |
记住一套核心判断方法:看代码里循环的嵌套层数,一层循环通常是O(n),两层就是O(n^2),循环里每次又在循环内部做了列表切片之类的操作,就要特别注意实际开销可能比表面复杂度更高。这段学习让我养成了一个习惯:任何数据处理逻辑动手之前,先问自己一句“最坏情况下它要执行多少次”。
2.2 栈、队列、链表:这三个结构在解决同类问题
以前总觉得“数据结构”这四个字很吓人,实际学完才发现,栈、队列、链表做的事用生活场景类比一下就非常好懂。
栈,就像一摞盘子。后放上去的盘子总是最先拿走,专业说法叫“后进先出”,LIFO。典型应用就是我每次逛网页时的浏览器后退按钮:每访问一个新页面就压入栈顶,点后退就弹出栈顶的页面。Python里用列表就可以模拟,append入栈,pop出栈。
队列,就是排队打饭。先到的人先得到服务,专业说法叫“先进先出”,FIFO。典型应用包括打印机任务排队、消息队列。Python里可以用collections.deque来实现,两端的插入删除都是O(1),比直接用列表在头部插入要快得多。
链表,则是我这阶段觉得最需要转变思维的一个结构。它不像列表那样是一整块连续空间,而是一个节点指向下一个节点。好处是中间插入和删除节点不需要大规模移动其他元素,只需要改一下相邻节点的指针关系。我第一次在纸上画链表的插入过程时,画了快十遍才彻底搞懂“先把新节点.next指向旧节点.next,再把旧节点.next改成指向新节点”这两步顺序不能反。
这三个结构真正常见的困惑是“我到底什么时候该用哪一个”。我的简单判断标准是:如果数据只在一端进出,优先考虑栈;如果两端都需要进出且有序处理,考虑队列;如果数据需要频繁在中间位置增删,而且量又大,链表值得考虑。这些判断标准在真实项目里还会结合内存、缓存等因素变复杂,但作为初级阶段的决策参考已经很够用。
2.3 递归和二叉树:初次见面,互相折磨
二叉树这阶段只能算刚刚入门。为什么一定要学它?因为很多真实问题最后都能抽象成树形结构,最常见的例子就是文件夹目录、组织架构、表达式的解析。二叉树遍历有三种顺序:前序、中序、后序,区别只在于访问根节点的时机。配合递归写起来非常短。
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def inorder_traversal(root): result = [] if root is None: return result result.extend(inorder_traversal(root.left)) result.append(root.val) result.extend(inorder_traversal(root.right)) return result代码看着简单,理解递归过程才是关键。我自己的经验是把递归展开成“先处理左子树、再处理自己、最后处理右子树”的步骤,在纸上画一棵三层的小树,一步一步写下函数调用和返回的顺序,慢慢就能体会到“递进去、归回来”的感觉。
递归容易犯的问题也很经典:忘记终止条件,或者终止条件写错。比如求树的深度,如果写成下面这样,就永远等不到返回:
def max_depth(root): # 漏掉了 if root is None: return 0 return 1 + max(max_depth(root.left), max_depth(root.right))这个问题我在本地跑了好几次都卡住,后来才知道是因为root.left或者root.right为None时又在继续往里递归。这种错误去逻辑上想半天,不如直接打印每一步的节点值来得直观。这阶段处理递归问题,我给自己规定了几个必做动作:明确终止条件、明确每层递归干什么、明确返回值给谁用。三个问题都能回答,说明这个递归函数想清楚了。
3. 动手环节:两个小项目把知识点真正用起来
3.1 命令行记账小工具:第一次感受到状态管理
看书和写代码之间隔着一条叫“需求”的河。这阶段第一个完整项目是给别人做的一个记账命令行工具(我称之为模拟项目X)。需求并不复杂:支持添加收支记录、查看当前余额、列出所有记录、退出。就是这样一个看起来很“玩具”的东西,真写起来比我想象中更考验细节。
先说数据结构上的选择。记账记录天然可以用栈来组织,最新的记在最上面,查看历史时最先看到最近的一笔。我用列表模拟了栈的行为,每条记录用一个字典保存类型、金额、备注、时间四个字段。添加记录用append,删除最近一条用pop。
records = [] def add_record(record_type, amount, note): records.append({ "type": record_type, "amount": float(amount), "note": note, }) print("添加成功") def show_recent(limit=5): for r in records[-limit:][::-1]: print(f"[{r['type']}] {r['amount']:.2f}元 - {r['note']}") def total_balance(): return sum(r["amount"] if r["type"] == "收入" else -r["amount"] for r in records)别看代码简单,真正写的时候问题全出在“状态管理”上。比如用户输入负数金额要不要拦截?输入格式不对时是提示重新输入还是直接退出?“收入”和“支出”用字符串表示还是用布尔值?这些决策背后都是实际使用体验的问题。我最后选择了统一把金额按正数接收,类型字段单独控制,这样计算余额时逻辑清晰,可读性也更好。这个项目让我第一次意识到,写代码之前把规则想清楚,比多写几行代码重要得多。
还给这个工具加了一个简单的文本持久化,数据保存到本地文件里,启动时自动加载。这就把之前学的文件读写、异常处理全串起来了,每次运行完数据都能保留,终于有了一点“真软件”的感觉。
3.2 文件批量重命名脚本:在动手前先想好“怎么反悔”
第二个项目是一个批量处理文件夹内文件的脚本。起因很朴素:我电脑某个文件夹里全是截图,文件名带时间戳乱七八糟,想统一改成“项目A_序号.扩展名”的格式。
这个需求拆解下来主要是三步:遍历目标目录、按规则生成新文件名、批量重命名。但真操作的时候我并没有一上来就写完整代码,而是先在草稿纸上列了一个问题列表,这一步帮我避免了一次大事故:
- 文件名冲突怎么办?比如两个文件都要改成同一个名字。
- 重命名过程中途出错,已经改过的文件要不要恢复?
- 扩展名要不要保留?
- 是否要跳过那些已经符合目标格式的文件?
最终我选择了先预览再执行的两阶段方案。第一阶段只打印“旧名字 -> 新名字”的映射表,不真正改动;等人工确认无误后,再进入真正的重命名阶段。这个方案让我觉得特别踏实,因为批量操作最怕的不是不会写,而是写出来的程序在特定文件上翻车,然后一堆文件的状态变得不可控。
import pathlib folder = pathlib.Path("./screenshots") prefix = "项目A" counter = 1 plan = [] for path in sorted(folder.iterdir()): if path.suffix.lower() not in {".png", ".jpg", ".jpeg"}: continue new_name = f"{prefix}_{counter:03d}{path.suffix.lower()}" new_path = path.with_name(new_name) plan.append((path, new_path)) counter += 1 for old, new in plan: print(f"{old.name} -> {new.name}")这段代码先列出所有计划,我检查完不会有重名或者误改的问题之后,才把最后一行换成old.rename(new)真正执行。这个过程让我养成了一个重要的职业习惯:需要批量修改数据的操作,一定要提供“预演模式”和“恢复方案”,哪怕程序是自己一个人用也不例外。
3.3 两个项目做完之后的复盘心得
做完这两个项目我有几个很直接的体会:
第一,代码短不等于问题少。几十行的小程序,调试时间和代码行数往往不成正比。问题是隐藏在对细节规则的思考里的。
第二,学会用“最小可行步骤”推进。不要想着一次把所有功能全实现,而是先让一个核心功能跑通,再逐步加东西。做记账工具时我第一版只支持添加和查看余额,后面才补上类型筛选和记录删除。
第三,把测试变成习惯。这里说的测试不是最后写完再测,而是每写完一个小函数就立刻用几条典型数据跑一遍。写add_record函数的时候我当场就打印了几条不同的输入,确认异常输入能被正确处理,再继续下一部分。
4. 踩坑记录与排查思路:这些坑,我替你走过了
4.1 Python可变默认参数:看着没问题,运行起来全是问题
这个坑我是在一次数据处理练习里踩进去的。当时我写了一个函数,想把新记录追加到一个历史记录列表里,默认参数用了一个空列表,结果多次调用后数据越攒越多,明明每次都是“新会话”,历史记录里却残留着上一次的任务数据。
问题代码长这样:
def add_task(task, history=[]): history.append(task) return history第一次调用返回["写总结"],第二次调用返回["写总结", "调试代码"],看起来像是正常累积,但如果你希望每次不传history时都从头开始,那就完全错了。原因在于Python默认参数只在函数定义时计算一次,之后每次都复用同一个列表对象。
修复方式很简单,改为默认None,函数内部再初始化:
def add_task(task, history=None): if history is None: history = [] history.append(task) return history这个坑对我的警示作用非常大。它让我养成了一个习惯:所有可变类型(列表、字典、集合)一律不要直接作为默认参数。
4.2 金额计算的浮点陷阱:0.1加0.2不等于0.3
第二个坑是在记账工具里遇到的。当时我计算两笔金额相加,得到的结果是0.30000000000000004。这是浮点数在二进制表示下的固有误差,不是bug,是计算机表示实数的方式决定的。
解决方式有三种层次:展示层用格式化控制两位小数;计算层用decimal.Decimal;自己规定所有金额都用整数“分”为单位存储。我做账目时选择了第三种,全部按分存整数,比如12.34元就存成1234,只在展示时除以100。这种方法直观、可靠,也不用关心浮点误差问题。
from decimal import Decimal amount = Decimal("12.34") + Decimal("0.66") print(amount) # 13.00这个经历让我在以后写任何与钱、度量、时间相关的程序时都会格外小心,凡是涉及连续计算的场景,优先考虑整数或者十进制类型。
4.3 Git操作:分支切换前不提交,差点丢了半天工作量
这阶段我还遇到过一次工作区文件被覆盖的险情。当时我在一个分支上改了文件还没提交,临时要切到另一个分支去查一个旧版本,直接执行了git switch,结果文件内容瞬间变成另一套。好在后来用git stash恢复了修改,虚惊一场。
这件事的教训可以浓缩成一句话:切分支之前,要么提交、要么暂存,不要带着未处理的修改乱跑。我现在每次准备切换之前都会先看一下git status,确认工作区是干净的再操作。这不是什么高深技巧,但真的能省掉很多不必要的恐慌。
4.4 排查问题的方法论:别靠感觉,靠打印和分段确认
初学阶段最怕的是出了问题就胡乱改代码,改完还是错,心情爆炸。这阶段我总结出一套简单的排查流程,效果意外地好:
| 步骤 | 动作 | 目的 |
|---|---|---|
| 1 | 复现问题 | 明确触发条件,能不能稳定复现 |
| 2 | 缩小范围 | 把代码拆成几段,注释掉疑似有问题的部分 |
| 3 | 加打印语句 | 在关键节点输出变量值和执行顺序 |
| 4 | 对比预期 | 每个输出和应该看到的结果对比,找到第一个偏差 |
| 5 | 修改并回归 | 修好后再跑一遍完整流程,确认不会引入新问题 |
这个方法本质上是把一个大问题切成小问题,逐个击破。尤其是“找第一个偏差”这个思路,让我少走了很多弯路。以前出了错总是从头到尾通读代码试图凭眼睛找bug,效率极低;现在我会先定位到出错的行号,再看这条语句之前的输入到底符不符合预期,往往几分钟就能定位问题。
5. 下一阶段的目标和一点个人体会
这篇总结写到这里,该说说接下来的方向了。下一阶段我准备花时间把查找和排序的几种常见算法真正吃透,尤其是快速排序、归并排序的思想和实现,同时开始看一些基础的数据结构在实际工程中的应用案例,比如数据库索引和内存缓存,把抽象概念和具体场景搭上桥。另外一个重点是锻炼用伪代码或者流程图表达思路的能力,这个习惯在以后面对复杂需求时会非常有用。
按我自己的学习节奏,接下来的三到四周大概会保持每天一小时左右的练习量,重点不再是学新语法,而是刷少量有代表性的小题目,总结套路。以前总觉得“总结”没用,现在回头看,每次写总结时都会发现一些以为自己懂了、仔细一讲却说不清楚的东西。这正是最值得补的地方。
最后分享两个我亲身实践下来特别有用的经验:第一,每学一个新知识点,立刻用一个最简例子跑通,不要只停留在“看懂了”的层面;第二,保留一份“错误代码笔记”,把自己踩过的坑按症状、原因、解决方案记下来,以后遇到类似现象可以快速翻查。这份笔记现在已经成为我学习效率最高的材料之一。
初学阶段最大的好处是每天都能感受到明显的进步。希望下次写“初学总结6”的时候,能带着更多实际跑通的例子,继续把这条路走扎实。