坚持到D32这天,我自己都有点意外。DHU上机打卡这个项目,最开始不过是实验班群里一句“要不每天固定上机写点代码”的提议,结果真的一天不落地走完了32次。第32次上机打卡,任务是用Python手写一版堆优化的Dijkstra最短路径算法,再做一个小规模的命令行查询程序。这篇文章就把D32这次的完整过程拆开讲一遍,包括为什么选这个任务、代码怎么写、运行现场什么样、踩了哪些坑,以及连续32天打卡到底带来了什么变化。如果你也在带实训、带打卡群,或者自己正在坚持每日编程训练,这份记录应该能给你一些可以直接抄作业的参考。
1. 上机打卡的机制与D32任务选型
1.1 打卡规则是怎么定出来的
先说清楚背景。DHU这个打卡项目是某高校实验班内部组织的,不算学分,也没有强制惩罚,规则只有三条:每天固定时间到机房或者自己的工位,连续上机至少90分钟;当天必须独立完成一个小任务,不能只是看视频、抄代码;结束前在共享表格里提交运行截图加三句话总结。
这个机制看起来简单,真正坚持下来才发现设计得很聪明。独立完成任务这一点,直接把“看懂了”和“写得出”之间的差距暴露出来。很多人刷算法题喜欢盯着题解看,看完觉得自己会了,一合上屏幕就卡在第一步。打卡要求的是当天提交可运行的程序,视频看再多都没用,必须亲手把代码敲出来、跑通。D32也就意味着第32次上机,前面的任务覆盖了数组、字符串、链表、栈与队列、二叉树、排序、搜索,到第32天正好进入图算法这个阶段。
1.2 为什么收官任务是Dijkstra
选Dijkstra作为D32的主题,不是随手定的。图算法是整个数据结构课程里承上启下的部分,而Dijkstra又是图算法里最贴近实际应用的一个,导航软件算路径、网络里的路由协议,核心思想都和它有关。作为32天打卡的收官任务,它比前面那些单独的数据结构更有综合性。
这个任务考察的能力刚好覆盖了前面31天练过的东西:图的存储要会选(邻接表还是邻接矩阵);优先队列要用明白(堆的实现和调整);贪心思想要能讲清楚(为什么每次选距离最小的点);递归和迭代的路径还原也得熟练。换句话说,第32天的任务不是新学一个算法,而是把前面积累的能力串起来用一次。
补一个选型对比,给以后想自己设计打卡任务的人参考:
| 候选任务 | 涉及数据结构 | 综合度 | 代码量 | 是否适合收官 |
|---|---|---|---|---|
| 反转链表 | 链表 | 低 | 少 | 不适合,太单一 |
| 二叉树的层序遍历 | 队列、树 | 中 | 中等 | 可以作为中期任务 |
| 快速排序优化 | 数组、递归 | 中 | 中等 | 缺少图结构 |
| Dijkstra最短路径 | 图、堆、贪心 | 高 | 中等偏多 | 很适合 |
综合度高的任务放到最后一天,既是对前面内容的总结,也让参与者有一种“我做了一个完整东西”的收束感,而不只是又刷了一道题。
2. 实验环境准备与测试数据设计
2.1 环境与工具链的选择
第32次上机我用的环境是这样的:操作系统是Windows 10,装的是Python 3.10解释器,IDE用VS Code,配合Python插件做调试,用CMD命令行做最终运行演示。选择Python而不是C++,主要是考虑到90分钟的限时,Python写起来更快,能节省处理指针和内存的时间,把注意力集中在算法逻辑本身。不过如果你打算把Dijkstra用到更大的数据规模,C++配合vector和priority_queue会自动快不少,这个根据自己目标来。
工具链上有一个容易被忽略的点:VS Code里跑Python,调试器会默认从launch.json的配置启动,如果你在代码里用了input()等待用户输入,调试终端不会自动弹出输入框,很多人第一次跑图算法时卡在这里。我的做法是不依赖调试器,直接Ctrl+F5运行,用系统的cmd窗口做输入输出,这样更接近真实命令行环境,也方便截图留档。
2.2 邻接表结构的选择
图有邻接矩阵和邻接表两种主流存储方式。这次我选的是邻接表,原因有两个。
第一是效率。Dijkstra用优先队列优化后,时间复杂度是O((V+E)logV),如果数据规模在几千个点、几万条边这个量级,邻接矩阵的O(V^2)存储空间就先撑不住了。打卡任务虽然只是演示,但选型应该面向真实场景,这才是练习的意义。
第二是Python的实现成本。Python里邻接表可以用字典套列表简洁表达:
graph = { 'A': [('B', 4), ('C', 2)], 'B': [('C', 1), ('D', 5)], 'C': [('D', 8), ('E', 10)], 'D': [('E', 2)], 'E': [] }每个键对应一个节点,值是一个列表,里面的元素是二元组,表示相邻节点和边的权重。这种结构写起来直观,打印出来也清晰,非常适合调试。
但要注意,Python字典的键只能是不可变类型,节点如果用自定义对象会麻烦一些。这次节点全部用单个大写字母表示,省去很多序列化的问题。
2.3 测试用例怎么构造
算法写出来之后,直接拿大图测试是不明智的,因为一旦出错你很难定位是哪个环节的问题。我的做法是分三层准备数据。
第一层是标准用例,一个10个节点左右的连通图,包含了多条可达路径,用来验证基本正确性。第二层是边界用例,挑出三类特殊情况:图中有不从起点可达的孤立节点;存在两个节点之间权重相同的多条路径;存在自环或者两条节点间权重不同的平行边。第三层是压力用例,用脚本随机生成5000个节点、两万条边的图,验证堆优化版本的性能能不能在可接受时间内跑完。
这三层数据分别对应正确性验证、边界鲁棒性、性能估计三个目标。实际打卡时我先把第一层数据手算了一遍最短路径结果,再与程序输出比对,确认无误后才继续后面的测试。这一步看起来笨,却是排查问题最有效的手段。
3. 核心算法实现与实操记录
3.1 从朴素Dijkstra到堆优化
Dijkstra算法的核心思想是贪心加动态规划:维护一个dist数组记录从起点到各节点的当前最短距离,每次从未确定最短距离的节点中取出距离最小的那个,然后用它去松弛相邻节点。
朴素版本每次要找距离最小的节点,遍历整个数组是O(V)的,外层循环又是V次,总体复杂度O(V^2)。当图的节点数到几千、几万的时候,这个复杂度很难接受。堆优化的思路是用优先队列来维护“候选节点”,每次从堆顶取距离最小的节点,取堆顶是O(logV),每条边最多入堆一次,总体降到O((V+E)logV)。
这里有一个很多人第一次写会踩的坑:visited数组要不要用。朴素版本里,节点一旦被确定最短距离就可以标记为visited,跳过后续重复处理。但在堆优化版本里,一个节点可能先被一个较大的距离压入堆,之后又被更小的距离更新,旧的记录还留在堆里。如果你在第一次出堆时就把节点标记为visited,后面那条更短的路径反而被跳过了。正确的做法是:出堆时比较当前取出的距离是否大于dist中记录的值,如果大于就说明这是一条过期的旧记录,直接continue跳过,不需要visited数组。
3.2 完整代码与关键细节说明
第32次打卡最终提交的代码是这个样子的,为了便于阅读,我把路径还原也一并实现了:
import heapq def dijkstra(graph, start): # 初始化距离表,所有节点距离设为无穷大 INF = float('inf') dist = {node: INF for node in graph} dist[start] = 0 # 优先队列中存放 (当前距离, 节点名称) pq = [(0, start)] # 记录前驱节点,用于还原路径 prev = {} while pq: cur_dist, u = heapq.heappop(pq) # 如果这个距离比记录中的距离还大,说明是旧记录,跳过 if cur_dist > dist[u]: continue for v, weight in graph.get(u, []): new_dist = cur_dist + weight if new_dist < dist[v]: dist[v] = new_dist prev[v] = u heapq.heappush(pq, (new_dist, v)) return dist, prev def reconstruct_path(prev, start, end): path = [] cur = end while cur != start: path.append(cur) if cur not in prev: return None, float('inf') cur = prev[cur] path.append(start) path.reverse() return path # 测试数据 graph = { 'A': [('B', 4), ('C', 2)], 'B': [('C', 1), ('D', 5)], 'C': [('D', 8), ('E', 10)], 'D': [('E', 2)], 'E': [] } dist, prev = dijkstra(graph, 'A') print(dist) path, cost = reconstruct_path(prev, 'A', 'E') print("A -> E 的最短路径:", path, "总距离:", cost)运行结果:
{'A': 0, 'B': 4, 'C': 2, 'D': 7, 'E': 9} A -> E 的最短路径: ['A', 'C', 'D', 'E'] 总距离: 9几个关键的代码细节:
INF的选择很有讲究。这里用了float('inf'),优点是任何整数加上它仍然是无穷大,不会出现溢出问题。但要注意,inf与整数做比较运算没问题,如果后面需要把距离打印成整数,需要做一次格式化处理或者用一个大整数如10**18代替。
路径还原写了一个单独的reconstruct_path函数,没有把路径组装混在主算法里。这样做的好处是主算法逻辑更清晰,也方便测试时对比多种终点路径。还原路径时用prev字典回溯,没有使用递归,避免图很深时递归栈溢出。
3.3 上机90分钟的时间分配参考
第32次打卡的实际时间线我给你列一下,不是流水账,是想说明一个90分钟的打卡任务应该怎么安排节奏才不会手忙脚乱:
| 时间段 | 内容 | 说明 |
|---|---|---|
| 14:00-14:15 | 复习图存储和Dijkstra伪代码 | 不直接看答案,先自己回忆 |
| 14:15-14:40 | 写邻接表结构和朴素版Dijkstra | 先用最简单版本跑通逻辑 |
| 14:40-15:05 | 改成堆优化,通过标准用例 | 用heapq替代线性查找 |
| 15:05-15:25 | 加上路径还原函数 | 单独实现,测试输出 |
| 15:25-15:45 | 边界用例测试和调试 | 重点测孤立节点和重复出队 |
| 15:45-16:00 | 性能测试与运行截图 | 5000节点压力用例跑一遍 |
| 16:00-16:20 | 写打卡总结提交 | 分析时间复杂度和问题 |
先写朴素版本再优化,看起来多花了一点时间,实际上是最稳的路线。直接上堆优化容易在逻辑混乱时更难排查。朴素版跑通能确认算法思想本身没问题,优化只是替换“取最小距离”这一步,两者可以分开测试。
3.4 压力测试的现场数据
压力测试我用脚本生成了一个5000个节点、两万条边的随机连通图,起点设为节点0,终点设为节点4999。堆优化版本的运行时间在命令行下测了三次,分别是0.42秒、0.45秒、0.43秒,基本稳定在0.4秒出头。这个结果说明堆优化版本在单机环境下处理中等规模的图完全没有问题。
对比之下,如果把代码改成朴素O(V^2)的版本去跑同一个图,5000个节点的数组每次遍历找最小值就是2500万次操作,加上松弛和更新,估计要30秒以上。这个差距直观解释了为什么要做堆优化。
4. 本次打卡踩坑与问题排查实录
4.1 坑一:优先队列里的旧记录没有被跳过
第一次写堆优化版本时,我忘了在出堆时比较cur_dist和dist[u],结果程序输出一直不对。排查的方式是在循环开头打印所有出堆的节点和距离,发现同一个节点被处理了两次,第一次距离大,第二次距离小。
原因就是我前面说的,一个节点可能被多条路径多次压入堆,旧记录距离大,不应该再被用来做松弛。修复方法就是加一行比较判断:
if cur_dist > dist[u]: continue这个问题在打卡群里有好几个人都遇到了,属于堆优化Dijkstra的经典易错点。排查思路很简单:打印出堆顺序,一旦发现一个节点出堆两次,就说明没有忽略过期记录。
4.2 坑二:自环和平行边导致的结果偏差
测试时我发现节点B和C之间同时存在两条边,一条权重是1,另一条权重是3。按照邻接表的写法,两条边都会被加入graph['B']列表里。Dijkstra算法会分别计算两条边,最终取较小值,所以结果没问题。但如果你用邻接矩阵存储,后写入的边会覆盖前面的边,权重3可能把权重1覆盖掉,导致结果错误。
这个问题暴露的是存储选型的细节:邻接表能天然处理平行边,邻接矩阵不能。如果是实际工程中的数据,建模前应该先确认边是否有重复,否则需要对输入做去重合并。
4.3 坑三:float('inf')与打印格式
测试时我输出dist字典,看到的是类似{'E': 9}这样的结果,但在处理不连通节点时,距离会是inf,打印出来是inf字符串。如果数据要写入文件或者做报表,这个格式并不友好。我的处理方式是写一个格式化函数,把inf替换为-1表示不可达,同时把浮点数转成整数再输出。
这类小问题看起来不起眼,但打卡任务的目的就是锻炼完整交付的能力,运行结果不是你盯着黑窗口看一眼就结束了,而是要考虑结果如何被别人理解和使用。
4.4 打卡总结怎么写才有复盘价值
打卡最后提交的三句话总结,最开始我写的是“今天完成了Dijkstra算法,实现了堆优化”,这种写法没有任何信息量。后来我改成有固定结构的格式:任务目标、关键实现、问题与收获。D32最终提交的总结是:
- 任务目标:用堆优化Dijkstra实现单源最短路径查询,完成路径还原。
- 关键实现:采用邻接表存储,优先队列维护候选节点,通过过期记录跳过保证每个节点仅以最小距离出堆。
- 问题与收获:处理平行边时意识到邻接表相比邻接矩阵的优势;压测5000节点图耗时约0.45秒,对算法复杂度有了直观感知。
三句话不多,但每一句都有信息量。复盘的价值不是记录“我做了什么”,而是记录“我做了之后有什么不一样的理解”。
5. 32天打卡的整体观察与实操建议
5.1 坚持带来的变化:从看懂到写出的拐点
说一句真实体会:第1天上机打卡时,群里大部分人的状态是拿到任务先沉默五分钟,然后开始翻课本、查资料,编码区半天敲不出几行。到了第10天左右,大部分人可以独立完成链表和栈的常规操作。第32天Dijkstra收官时,从环境准备到测试通过,全程耗时刚到90分钟,这说明编程思维已经形成了一种肌肉记忆。
变化的本质是“看懂”到“写出”之间的拐点被越过了。看别人代码时,很多边界条件和细节会被大脑自动忽略,以为自己理解了;独立实现时那些细节会一个不落地冒出来,逼着你逐个解决。我统计了一下32天的打卡数据,第1天任务是一个字符串反转加去重,代码量大约20行;第32天是图算法加路径还原,代码量120行左右,中间经历了6次完整重构。这种递进不是刻意设计的,而是每天的难度刚好保持在你“跳一跳能够到”的位置。
5.2 打卡难度曲线怎么设置不劝退
带打卡项目最容易犯的错误是一开始就把难度拉满。第一天讲动态规划,劝退率基本是80%。合理的曲线应该是:前5天保持简单重复性任务,目的是建立“每天写代码”的惯性;第6-15天进入数据结构核心区,难度适当提升,但保证有标准解可以参考;第16-30天进入综合应用,每三天出一个稍微复杂的任务;最后两天做总结性项目,把前面知识点串起来。
难度判断有一个简易标准:如果打卡者需要超过两小时才能完成,说明任务偏难,容易让人第二天不想来;如果不到三十分钟就能完成,说明偏简单,提升有限。D32的Dijkstra属于刚好压线,朴素版加堆优化版总计90分钟,适合作为收官难度。
5.3 如果中途断了一天怎么办
32天里其实有几个人断过卡。断卡之后的第一个心理障碍是“我断了一天,这个连续记录是不是就毁了”,然后就破罐子破摔。我的建议是连续记录断了就断了,但当天任务不能停,可以顺延,比如D13的任务变成D13补。打卡的本质是保持训练频率,不是追求一个数字上的完美。
实际操作中,我给群里的建议是把打卡表设计成“可补卡”的模式:每人每月有两次补卡机会,补卡需要在三天内完成对应任务。这个机制既不打击积极性,也保留了连续打卡的心理奖励。
5.4 打卡记录如何整理才有沉淀价值
32天的共享表格最终变成了一张很大的表,里面是每个人每天的提交截图和三句话总结。这种原始记录适合用来追踪进度,不适合用来复习。我的做法是每七天做一次整理,把本周的任务、代码链接、核心知识点汇总到一份Markdown笔记里,月末再合并成一份月报。
代码管理上,建议从第一天就建一个Git仓库,每天一个小目录,目录名包含日期和任务名,比如day32_dijkstra。这样到了第32天,你拥有了一个包含32个可运行小项目的仓库,无论是求职时作为作品集,还是日后回顾踩坑记录,都非常有价值。D32收官之后,我把整个仓库翻了一遍,那种看到第一天稚嫩代码和最后一天完整算法的对比感,比任何激励都有效。
最后再分享一个个人心得:上机打卡最难的其实不是第32天,而是第8天左右那种“好像学了不少但又不确定有什么变化”的倦怠期。撑过那个阶段,后面每一天的正反馈会越来越明显。坚持打卡这种事,一天看不出差别,三天也看不出,但30天之后,你会明显感觉到自己在处理陌生问题上更稳了。这个变化不限于编程,任何技能训练都一样。