ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

算法刷题第47天:一小时复习Dijkstra、背包DP与二分边界

算法刷题第47天:一小时复习Dijkstra、背包DP与二分边界

0x3f 第47天 复习 9:24-10:24——这个字符串是我每天都在刷题打卡的标题格式:代号 + 第几天 + 今天干什么 + 时间区间。今天是整轮计划的第47天,安排的内容是复习,从早上9:24到10:24,刚好六十分钟。标题看起来平平无奇,但对一个连续刷题四十多天的人来说,主动把黄金一小时从"刷新题"里抠出来做复习,其实是个挺反直觉的决定。

如果你也在坚持算法打卡,或者正处于"题刷了不少、但一合上笔记就写不出来"的状态,这篇记录应该能帮到你。我会把今天这一小时从头到尾拆开讲:为什么第47天非复习不可、一小时怎么分配到分钟级、复习时具体查了哪些知识点、又踩了哪些只有重写代码才会暴露的坑。里面还会附上我整理的易错清单和时间模板,都是真实试过且有效的做法。

1. 先解释"0x3f"这个代号:里面藏着一个算法常识

1.1 代号其实是十六进制的63

0x3f是我给自己定的学习代号,来历非常朴素。有一次组队练习抽签,我抽到63号,十六进制写出来就是0x3f,后来直接拿来当网名和打卡前缀。用久了反而觉得这名字挺妙:每次看到它,我第一反应不是数字63,而是算法竞赛里那个经典无穷大常量0x3f3f3f3f。

在很多最短路模板里,dist数组初始化写作memset(dist, 0x3f, sizeof(dist))。为什么不用INT_MAX或者十进制1061109567?原因很实际:memset按字节填充,0x3f的每个字节都是0x3f,四个字节拼起来正好是0x3f3f3f3f,约等于10^9。这个数比真实最短路长得多,又比int上限2147483647小,所以初始化和更新时不容易溢出。我就是从这个代号出发,把一堆图论初始化细节又顺了一遍。

1.2 第47天处在计划的什么阶段

我给自己定的计划是一百天左右的算法专项提升,第47天正好卡在中间偏后的位置。这个节点的典型状态是:新知识点堆了不少,最短路、背包DP、并查集、二分答案都过过一遍,但"过了一遍"和"真正会了"之间隔着一条巨大的鸿沟。前几天我试着不翻笔记手写带堆优化的Dijkstra,写到一半卡住了,转移思路倒是记得,但堆里pair的存取顺序搞反了。那一刻我意识到,不能再闷头推新题了,必须腾出整块时间做一次系统复习。

所以这篇打卡记录尤其适合两类人:一类是像我一样在长期刷题、但开始觉得知识往外漏的人;另一类是刷题数量上去了、却总在基础题上莫名翻车的人。复习不是退步,恰恰是把前面所有"表面学会"变成"真正掌握"的补票机会。

2. 为什么要专门安排一小时复习:刷题数量会骗人

2.1 过题记录带来的错觉

连续刷题很容易产生一种错觉:只要今天又过了几道题,就代表我在稳步进步。但过题记录反映的是"那一刻"做对了一道题,完全不代表一周后还能独立写出来。我观察过自己很久:任何一个算法,学完当天感觉通透,第三天就只剩大概印象,到了第七天基本只能说出"我好像学过"。这不是记忆力差,而是缺少提取练习。

打个比方,背单词只背不复习,背到第200个时前面50个基本剩不下多少。刷题也一样,而且算法知识比单词更复杂,遗忘之后靠重读笔记很难捡起来,最有效的恢复方式是直接动手重写。

2.2 复习的定义:不是看一遍,而是写出来

我对复习的要求很严格:关掉模板,关掉旧代码,只看题目描述或知识点名字,独立把代码写出来。写不出或写错了,才算真正发现薄弱点。整个过程本质是自我测验,目的不是自我感动,而是让"刚刚好像懂了"变成"不用想就能写"的肌肉记忆。

今天六十分钟,我给自己定的目标很简单:不贪多,集中查三个主题——堆优化Dijkstra、一道之前反复出错的背包DP、二分答案的整数边界。这三个都是最近两周碰过的,也是我判断自己最容易遗忘的点。复习内容不能随便抓,得讲究优先级,取舍逻辑后面细说。

3. 一小时复习怎么拆:我实际的时间切片

3.1 前5分钟:把复习清单写出来

开始复习前,我先花几分钟翻最近两周的做题记录,把所有值得复习的点列出来,再按优先级挑出今天要处理的三个。我的取舍规则是:优先复习下周马上要用的和最近新学的,其次是错得最惨的,最后才是纯想巩固的。一小时很有限,贪心想全覆盖的结果通常是一个都没复习透。

列清单这个动作看起来简单,其实决定了复习成败。没有清单的复习很容易变成翻开笔记随便看看,看着看着就去研究新东西,最后什么都没沉淀。今天的清单我写在白纸左侧:Dijkstra堆优化、背包DP初始化、二分边界。写完定了个闹钟,开始默写。

3.2 20分钟:默写核心模板,而不是读模板

第一个复习堆优化Dijkstra。关掉所有代码,从建图开始在白纸上写。第一次写出来就卡在初始化:memset第二个参数到底传0x3f还是0x3f3f3f3f?答案是0x3f,因为这个函数只认单个字节。继续往下写,堆里存什么?我习惯存pair<long long, int>,第一位距离,第二位节点编号。priority_queue默认是大根堆,所以必须声明成小根堆,或者存距离时取负。这一步我犹豫了半分钟。

完整写出来后对照正确模板检查,发现一个隐蔽问题:邻接表存稀疏图时,如果两个节点之间有重边,插入时不能直接覆盖,必须保留最短的那条。很多入门模板不会特意讲这个,但题故意给重边时代码就会挂。我把这个点记在了清单旁边,整个过程大概20分钟,重点不是写多快,而是每写一步都清楚为什么会这么写。

3.3 25分钟:不看旧代码,重做一道错题

接着我选了一道最近反复出错的背包DP题,当时第一次提交把转移方程写反了,后来看了别人思路才勉强通过。今天的复习要求是彻底重新写一遍,不允许看旧代码。结果一上手问题就暴露了:我居然在初始化上犹豫了很久。

01背包倒序遍历容量、完全背包正序遍历容量,这个我倒是记得,但我忘了"恰好装满"和"不超过容量"两种问法在初始化上的差别。恰好装满要求dp[0] = 0,其他状态初始化为负无穷;不超过容量则全部初始化为0。这次复习让我出了一身冷汗,如果明天竞赛遇到这类题,我大概率会栽在初始化上。

后来我在白纸上重新推导一遍转移方程,把dp[j]用文字描述出来:前i个物品中选若干个,使容量恰好为j时的最大价值。定义一清晰,转移方程就顺理成章了。这25分钟让我确认:复习错题的价值远大于再做三道没见过的简单题。

3.4 10分钟:把暴露的问题整理进遗忘清单

最后十分钟我没有再开新题,而是把所有卡壳点整理进一个固定格式的复习文档:知识点、暴露的问题、是否已解决、下次复查日期。今天的记录大概是:Dijkstra堆优化,pair排序方式犹豫,已解决,3天后复查;背包DP恰好装满初始化不熟,已解决,3天后复查;二分边界还没开始验证,明天上午重点看。整理完,一小时刚好结束,9:24开始的复习在10:24准时收尾。

4. 复习现场:高频考点到底容易错在哪

既然今天复习的主要是基础算法,我顺便把这次复盘时重新梳理的高频易错点写出来。这些不算竞赛大佬才懂的神仙优化,而是普通刷题人最容易在模板题上翻车的细节。

4.1 最短路与INF的溢出陷阱

堆优化Dijkstra核心代码不长,但dist初始化的写法很讲究。很多人用memset(dist, 0x3f, sizeof(dist)),没问题,但要注意后续更新判断。如果写成if(dist[v] > dist[u] + w),而dist[u]和w都接近0x3f3f3f3f,相加后可能超过int上限,得到一个负数,然后整条最短路就崩了。

我复习时给自己定了个习惯:所有涉及距离和边权的地方都开long long,判断不可达时直接比较if(dist[v] == INF),而不是先相加再比较。即使数组是long long,memset依然可用,因为按字节填充的原理一致。这条记进笔记,以后写最短路直接沿用。

4.2 背包DP初始化取决于题目问法

背包DP里最坑的就是初始化。同样是01背包,问"容量不超过V时最大价值",dp数组全部初始化为0就行;问"恰好装满V时最大价值",则dp[0]=0,其他初始化为负无穷,因为那些状态初始不可达。很多题解只贴代码不解释,导致大家背板子背熟了,一换问法就错。

我复习时会把"为什么这么初始化"写一遍,再拿最小数据手动演算一遍。比如V=5,物品只有一个体积3价值4。恰好装满时答案是不可行,不超过容量时答案是4。两者结果完全不同,代码却只差一行初始化。

4.3 并查集和线段树的隐藏坑

并查集的路径压缩大家都会写,但数据范围一大,递归路径压缩可能因为递归层数过深导致栈溢出。我建议直接用迭代版本,代码几乎一样,却更稳。另外,如果只压缩路径、不按秩合并,极端数据下树还是会很深,查询退化成接近O(N)。所以merge时建议根据size或rank合并小树到大树。

线段树最值得复习的是懒标记。区间加、区间赋值、区间乘混在一起时,下传顺序非常容易乱。我一般会给每个节点存待下传的累加值和覆盖值,下传时先处理覆盖再处理累加,因为如果先加后覆盖,覆盖会把加法结果清掉。这个顺序问题当初困扰了我一整天,后来整理成固定模板才消停。

4.4 整数二分边界为什么容易死循环

二分答案边界问题,刷了几十题的人也一样会翻车。整数二分最容易出现死循环,比如求满足条件的最大值时写成mid = (l + r) / 2,再执行l = mid,当区间长度为2时就永远收缩不了。正确写法是向上取整:mid = (l + r + 1) / 2,这样才能保证l = mid时区间真正缩小。

我的复习口诀是:找最小值用右边界收缩,mid=(l+r)/2,l=mid+1;找最大值用左边界扩张,mid=(l+r+1)/2,r=mid-1。写的时候对着口诀核对,基本不出错。

4.5 数组越界的隐蔽性

最后一个吐槽所有算法题通用的坑:数组越界。越界不一定崩溃,常常只是读到相邻数组的垃圾值,导致极端数据下突然出错。我复习时养成了习惯:凡是开数组的算法,开完立刻检查下标范围,n最大多少,数组长度是不是留了足够冗余,比如n+5或n*4。这个习惯看着土,真能省下大量排查时间。

5. 复习过程中最常踩的五个坑与排查方法

今天一个小时里,我自己也有几次烦躁到想放弃的时刻。这里把常见的几个坑整理成速查表,下次遇到症状可以直接对照。

症状可能原因排查与处理
代码跑了但结果偏大或偏小初始化错误、循环边界取值不对先检查初始化和边界,再用最小样例手算一遍
样例能过但提交超时复杂度分析失误或死循环检查二分区间是否收缩,检查循环是否写成最坏情况
偶尔报错本地测不出来数组越界或变量未初始化打开编译器警告,数组开大一个量级,用最大n测试
重写比第一次写还慢算法只有模糊印象,没有形成稳定模板先回到原理层面画状态图,再闭卷重写
复习完感觉什么都没记住复习太散,没有明确输出强制自己默写代码并写下易错点,有输出才算复习完成

每次复习完,我至少挑两三个情况复盘。尤其是"重写比第一次还慢",这个症状在长时间不碰某个算法后特别常见。我的体会是,这时候不能急着继续写,先回到算法核心思想,想清楚状态怎么转移、边界怎么收缩,再闭卷写。写得慢不一定是不努力,但一定说明原理理解不够深。

6. 把"一小时复习"变成长期机制,而不只是一次打卡

6.1 打卡日志要记四件事

今天这次复习让我改掉了打卡日志的格式。以前我只记"今天做了几题",现在固定记录四部分:今日新题、今日复习、暴露问题、明日计划。新题和复习体现广度和深度,暴露问题才是复盘最重要的产出,明日计划保证问题不会被拖着拖着就没了。

举个今天的例子,日志标题是"0x3f 第47天 复习 9:24-10:24",内容我写的是:新题0道;复习Dijkstra堆优化、背包DP初始化、二分边界;暴露问题包括恰好装满初始化不熟、pair排序方式犹豫;明日计划是做一道完全背包,重点验证二分最大值模板。过一个月回来看,我依然能知道自己当时卡在哪。

6.2 日复习、周复习、月复习怎么排

我给自己定的复习节奏分三层。日常复习约5分钟,主要是在打卡日志上标注昨天学的知识点有没有忘;每周专门安排一小时,像今天这样深挖三四个主题;每月拿出半天,把所有遗忘清单过一遍,至少选两道错题独立重写。这种节奏的目的不是增加负担,而是让复习嵌入学习,而不是临时抱佛脚。

6.3 第47天的心态调整

47天正好是容易疲惫的阶段。新内容学了不少,离"大成"还远,中途没有太多里程碑式反馈。我发现复习其实是很好的正反馈来源:当你默写出一个大体正确的模板时,你会真切地感到自己"真会了",这种踏实感比闷头刷新题强很多。如果你也正进行到计划一半左右,感到疲劳,不妨把节奏从开新题切换成复习旧题。

今天最大的收获反而不是某个具体细节,而是重新确认了一件事:持续学习的动力,很大程度来自对已有知识的掌控感。第47天能在9:24到10:24之间完成一次有效复习,让我觉得前面四十多天刷过的题都没有白费。明天进入第48天,我继续保持这个节奏,也建议你给自己安排一个固定复习时段,哪怕一周只有一小时,效果也会比你想象中明显。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进