1. 第27天:算法与工程同时推进带来的双重压迫感
今天是连续刷题和跟课的第27天,也是我决定同时推进两条线的一天。上午把hot100链表的题单重新翻了一遍,下午继续跟黑马web课程168到182集的进度。不少人问我,为什么项目课都跟到一百多集了,还要回头刷链表这种“老知识”?我当时没细想,写完今天的总结后有了一个更清楚的答案:链表题训练的是对引用、状态转移和边界条件的控制力,而web课程这一阶段刚好开始讲工程结构、配置文件和部署,这两者表面上不搭,底层却共用同一套能力。
先说点背景。我在刷题记录前加的“0x3f”,没什么特殊意义,就是早年写C++时0x3f3f3f3f用习惯了,拿来当个人标记。第27天之所以值得单独写一篇,是因为它正好卡在两个节奏的中间:hot100链表这批题开始从“能做出来”转向“做得干净、不丢指针”,而web课程168到182集也在从“跑通项目”转向“把项目按企业级工程的方式组织”。一天之内同时面对这两种挑战,人是会被逼着成长的——前提是节奏安排对。
这篇记录打算写清楚四件事:链表的基本功到底该夯实到什么程度、hot100链表题的解题套路、链表代码里最容易翻车的几个点、以及web课程这个阶段和算法训练如何互相成就。目标读者是正在双线作战的同行:一边跟项目课、一边刷算法题,时间永远不够用,但两边都不想放。我说的都是自己实际操作中的做法和踩过的坑,没有“标准答案”,但你可以直接拿去参考。
2. 刷hot100链表前,先把这几种链表结构彻底分清
2.1 带头结点和不带头结点的区别
链表刷题翻车,十次里有八次出在头结点处理上。带头结点的链表,头结点本身不存有效数据,只是固定起点。它的最大好处是操作统一:无论插入还是删除,都不用格外考虑“我操作的是不是第一个结点”。不带头结点的链表,head指向真正的第一个数据结点,一旦要删除或插入到头部,就必须手动维护head的指向。
hot100里有不少题,给的输入是不带头结点的链表,但你在代码里可以自己造一个虚拟头结点(dummy node),把问题转换成带头结点的场景。这个技巧几乎贯穿链表题的所有中等题,不理解带头结点的价值,就很难理解为什么每道题解的代码里都多出来一个new ListNode(0)。
理解这件事,靠生活类比最快:带头结点的链表像火车多挂了一节不载客的守车,你不管从哪节车厢接新车厢,都不用担心整列车头要不要换;不带头结点的链表像单节小货车,你要是在车头前面再加一节,车头的标识就得换。
2.2 指定位置插入:建立单链表的基本功
热词里有一个很常见的说法叫“在指定位置插入建立单链表”,这里其实包含两层意思:一是会建立链表,二是能在指定位置插入。刷hot100不需要从零手写整个链表类,但你必须能把“插入到第i个位置”的每一步说清楚。
以Java为例,带头结点的单链表中,在位置pos插入值为val的新结点,核心代码是:
public void insert(ListNode head, int pos, int val) { ListNode cur = head; // 走到pos位置的前一个结点 for (int i = 0; i < pos && cur.next != null; i++) { cur = cur.next; } ListNode newNode = new ListNode(val); // 先连后继,再改前驱 newNode.next = cur.next; cur.next = newNode; }这段代码有两个细节值得反复强调。第一,先执行newNode.next = cur.next,再执行cur.next = newNode,顺序不能反。顺序反了之后,cur.next已经指向新结点,原本后面的那段链表就找不回来了。第二,循环条件是cur.next != null,这意味着如果pos超出了链表长度,这个插入会落在链表末尾,这在很多题目里其实是期望行为。
2.3 遍历、清空、逆置的三个细节
链表遍历是热词里另一个常被提到的基础操作。很多人觉得遍历有什么好说的,但就是这里最容易犯迷糊:判断条件是while (cur != null)还是while (cur.next != null)?前者能进到最后一个结点并读取它的值,适合“遍历所有结点”的场景;后者会停在倒数第二个结点,适合“修改前驱指针”的场景。这两种写法没有对错,用错位置才是问题。
清空链表比想象中讲究。如果只是把head.next置空,Java靠GC能回收,但在C/C++里必须逐个释放结点。热词里提到“单链表的清空”,在C语言写法中我会用一个临时指针遍历,逐个delete,不直接让头结点脱钩了事:
void clearList(LNode* head) { LNode* cur = head->next; while (cur != NULL) { LNode* tmp = cur; cur = cur->next; free(tmp); } head->next = NULL; }逆置链表是hot100的高频动作,也是后面许多中等题的共同子问题。迭代法需要同时维护pre、cur、next三个指针。核心逻辑:用next记录cur的下一个结点,把cur.next指向pre,然后pre和cur整体后移。这件事练不顺,后面做K个一组翻转链表基本做不下去。
至于循环单链表和双链表,hot100里出场率没那么高,但也不能完全不看。循环单链表的判断条件不是“某结点的next为null”,而是“next是否等于head”。双链表的删除要同时维护prev和next两个指针,和单链表相比只是多一条指针操作,思路完全一样。热词里把这些都列出来了,说明大家搜的时候确实容易混淆,建议按“带头/不带头、单向/双向、是否循环”三个维度,把六种形态在纸上画一遍。
3. hot100链表题拆解:虚拟头、快慢指针、双指针与哨兵
3.1 虚拟头结点:让删除头结点不再特殊
hot100里的链表题,我最推荐先攻克“虚拟头结点”这个套路,因为一半的题目都可以靠它简化边界判断。
以“删除链表的倒数第N个结点”为例。直接做要分两步:先遍历得到链表长度,再走length - n步找到目标结点的前驱。但用快慢指针加虚拟头,可以一次遍历完成:
public ListNode removeNthFromEnd(ListNode head, int n) { ListNode dummy = new ListNode(0); dummy.next = head; ListNode slow = dummy, fast = dummy; for (int i = 0; i < n; i++) { fast = fast.next; } while (fast.next != null) { slow = slow.next; fast = fast.next; } slow.next = slow.next.next; return dummy.next; }注意最后返回的是dummy.next,不是head。如果n刚好等于链表长度,删除的就是原头结点,此时head已经不再指向链表开头了,只有dummy.next才是新链表头。这个坑我踩过很多次,凡是用了dummy,返回时一律写dummy.next。
3.2 快慢指针:判环与找中点的通用算法
“环形链表”是hot100的必考题,快慢指针是标准解法。快指针每次走两步,慢指针每次走一步,如果链表有环,快指针一定会在环里追上慢指针。
public boolean hasCycle(ListNode head) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { return true; } } return false; }这段代码看起来很平常,但几乎每个新手都会问:为什么while条件要写成fast != null && fast.next != null?因为快指针一步要跨两个结点,如果fast已经到末尾了,fast.next是null,再访问fast.next.next就是空指针。记住这个判断条件,是快慢指针题不翻车的前提。
同样的套路也能用来找链表的中间结点:快指针到末尾时,慢指针正好停在中间。hot100里有一道“链表的中间结点”,本质就是快慢指针的应用。
3.3 双指针合并:合并两个有序链表
“合并两个有序链表”是链表双指针的入门题,它的逻辑很像两个有序队列的归并:谁小谁先出队。
public ListNode mergeTwoLists(ListNode list1, ListNode list2) { ListNode dummy = new ListNode(0); ListNode cur = dummy; while (list1 != null && list2 != null) { if (list1.val <= list2.val) { cur.next = list1; list1 = list1.next; } else { cur.next = list2; list2 = list2.next; } cur = cur.next; } cur.next = list1 == null ? list2 : list1; return dummy.next; }最后那行“cur.next = list1 == null ? list2 : list1”不是偷懒,而是利用了链表有序的性质:剩下的那段链表本身有序,直接挂上去一定不会错。看题解时不要跳过这一步,它的存在意味着归并排序中“合并”这一步可以做到O(n)的时间和O(1)的额外空间。
3.4 反转链表:迭代与递归的差异
反转链表是链表的“Hello World”,也是最容易在细节上翻车的题。迭代写法就是前面说的三指针法:
public ListNode reverseList(ListNode head) { ListNode pre = null, cur = head; while (cur != null) { ListNode next = cur.next; // 关键:先保存下一个结点 cur.next = pre; pre = cur; cur = next; } return pre; }递归写法代码更少,但空间复杂度更高:
public ListNode reverseList(ListNode head) { if (head == null || head.next == null) { return head; } ListNode newHead = reverseList(head.next); head.next.next = head; head.next = null; return newHead; }我的建议是两个版本都练。迭代版用来保证面试时能写出O(1)空间的最优解,递归版用来理解“递推”的本质。但初学阶段如果只想掌握一个,优先迭代。
3.5 哈希辅助:相交链表这类“找相同元素”的题
相交链表这道题,标准解法是双指针,A走完换到B,B走完换到A,最终会在交点相遇。但第一次接触时,我建议先用哈希表理解问题模型:
public ListNode getIntersectionNode(ListNode headA, ListNode headB) { Set<ListNode> seen = new HashSet<>(); while (headA != null) { seen.add(headA); headA = headA.next; } while (headB != null) { if (seen.contains(headB)) { return headB; } headB = headB.next; } return null; }注意哈希表存的是结点引用,不是结点值。两个结点值相同不代表是同一个结点,相交的判断标准是“同一个对象”。理解了这一点,再去看双指针解法,就会明白它优化的不过是空间复杂度。
把hot100链表的套路按场景归类,我整理了一个小表,刷题时直接对照:
| 套路 | 典型题目 | 关键条件 | 易错点 |
|---|---|---|---|
| 虚拟头结点 | 删除倒数第N个结点 | 返回dummy.next | 返回了旧的head |
| 快慢指针 | 环形链表、中间结点 | fast与fast.next判空 | 快指针越界 |
| 双指针合并 | 合并两个有序链表 | 剩余段直接挂上 | 忘记处理剩余段 |
| 反转三指针 | 反转链表 | 先保存后继 | 断链或死循环 |
| 哈希辅助 | 相交链表 | 比较引用而非值 | 用equals比较值 |
4. 链表题最常见的事故现场:断链、空指针与边界判定
4.1 先改next导致后半段链表丢失
第27天上午复盘“指定位置插入”时,我重新踩了一个特别基础的坑:新结点的next还没赋值,我就先把前一个结点的next指向了新结点,导致原链表后半截彻底找不回来。
排查方法不是看报错,因为这种逻辑错误根本不会报错。我用三个结点的链表在纸上推演了一遍,把每一步的指针变化画出来,才明白问题出在“引用的引用被覆盖”上。修复口诀我已经刻在脑子里:先连后继,再改前驱。所有涉及链表插入的题,这一条都适用。
4.2 while条件写错导致空指针或越界
链表遍历的while条件,是区分新手和老手的一个分水岭。while (cur != null)能进入最后一个结点;while (cur.next != null)会在倒数第二个结点停下。两种条件服务于不同目的,用混了很容易埋下空指针。
最常见的崩法是这样:先写了while (cur.next != null),又在循环体里执行cur = cur.next.next。假设当前cur是倒数第二个结点,cur.next不为null,但cur.next.next已经是null,下一轮循环访问cur.next时直接空指针。我在写快慢指针题时栽过好多次,后来养成一个习惯:只要一次跳两个结点,就先确认cur.next和cur.next.next都非空。
4.3 用了dummy却在结尾返回了head
这是虚拟头结点套路里最隐蔽的错误。删除头结点后,head仍然指向旧头结点,如果返回head,得到的是一个已经被删除、甚至已经和链表脱钩的结点。正确的返回永远是dummy.next。这里我的习惯是:看到dummy出现,就顺手把末尾的return改掉,不给自己留犹豫的机会。
4.4 完整排查链:反转链表出现死循环
下午写完反转链表,我用一个五结点链表测试,日志输出的是同一个结点的值,明显死循环。排查过程走了三步:
第一步,猜测问题出在指针更新,于是只在循环里打印pre、cur、next三个变量的引用地址。第二步,发现cur的地址始终没变,说明循环体里cur根本没有后移。第三步,回头查代码,果然是少了一行next = cur.next,导致cur.next被改成pre之后,cur自己再也没有办法前进。
这个问题看别人代码很难发现,因为代码结构长得跟正确版本几乎一样。但只要亲手踩过一次,就会明白“反转链表第一步永远是保存当前结点的后继”这句话为什么是铁律。
4.5 边界样例是过滤80%错误的过滤器
链表刷题有个经验:大半错误集中在空链表、单结点、双结点三种输入上。所以每道链表题写完,我不急着提交,先按三个规模自测,再跑四种边界操作:删除头结点、删除尾结点、插入最前面、插入最后面。这套动作听起来琐碎,实际能省下大量反复提交的冤枉时间。
5. web课程168到182集的阶段复盘:与链表刷题怎么互相成就
5.1 这一阶段课程在讲什么
黑马web课程到168到182集,基本已经进入工程化阶段。按我手头这个系列视频的节奏,这个阶段的核心是Spring Boot集成、yml配置和项目部署相关的内容。热词里出现的“spring boot 集成web socket yml配置”、“idea2024版本创建web项目”、“nginx高性能web服务器实战教程”,都落在这个阶段的范围里。
我的实操流程是:打开IDEA 2024新建Spring Boot项目,确认依赖版本能对上,否则yml里的配置很容易生效不了;接着把application.yml中的端口、数据源、上下文路径写清楚;然后演示WebSocket集成时,先跑通一个最简的广播消息,不写业务逻辑,只验证通道是否建立;最后用Maven打成可执行包,放到本地Nginx后面做反向代理。整个流程走完,才算把课程内容内化。
5.2 环境报错的真实复盘:dsh web authentication required
热词里有一句我印象深刻:“dsh web authentication required; reopen the url printed by dsh web.”第27天下午调WebSocket时,界面死活打不开,报的正是这种认证类问题。
解决方式很简单:去启动日志里找到它打印出来的那一行URL,在浏览器重新打开并完成认证,然后继续运行。这类报错的优先级永远排在改配置之前,因为它通常是令牌过期,不是配置错误。
另一个是“failed to load plugins web boot: 2 entries did not activate”,这类插件加载失败的问题,我按三步处理:先看哪个插件没激活,再到对应目录确认版本是否匹配,最后把无关插件先禁用,逐个排除。方法论很朴素:插件问题永远先看日志,不看日志的排查都是瞎猜。
5.3 web安全方向的顺带了解
热词里有“web安全”,还有“polar ctf web 签到题”、“ctf web解题 找flag夺旗赛”。我在168到182集这个阶段没有深入研究CTF,只是补了web安全的基础知识,重点看了身份认证、输入校验、会话管理这些正向防护的方向。这些内容改变了我的一个习惯:写后端接口时,之前只关心能不能跑通,现在会多想一层“这个参数进来,会不会让程序进入异常分支”。
有意思的是,这个习惯反过来帮助了链表刷题。写链表的边界条件时,我开始用同一套思维:空输入会怎样?只有一个结点会怎样?两个结点会怎样?两种状态下系统会不会崩溃?从web安全学到的“验证输入”思维,迁移到算法题里就是“验证边界”。
5.4 链表思想在Java工程里的影子
这个阶段让我确信,链表不是只在面试里出现的数据结构。JDK自带的LinkedList就是带头结点的双向链表;HashMap在哈希冲突时会用链表,链表过长又转红黑树;任务队列、缓冲区、中间件的责任链,全都有链式结构的影子。
所以hot100链表刷完之后,再回来看Spring、Nginx的源码,我对“当前结点”“下一个结点”“头尾维护”这些概念会特别敏感。刷算法不只是为了面试,更是在给读框架源码打底。这个体会,可能是我第27天最大的收获之一。
5.5 双线并行的一次交叉验证
课程讲yml配置时,我遇到一个参数在调用链上反复被覆盖的问题。排查到后面,发现这个问题可以抽象成一条链表:几个配置来源按优先级排成链,最终生效值取决于最后一个节点。我直接用链表遍历的顺序思维,把每个配置来源逐个判定,很快定位到问题出在最后一层覆盖。
这件事让我彻底认可了双线并行的价值。算法题训练出的抽象能力,在web项目里同样能用上,只是换了一层皮。
6. 从第27天回头看:双线作战稳住节奏的几个笨办法
6.1 固定时间块,把一天切成三段
我的安排很机械:上午算法复盘、下午课程、晚上新题。这样做的好处是到点就知道该干什么,不用反复纠结“现在干嘛”。双线作战最怕的不是累,是每次坐下都要花时间决定先做哪件事。固定时间块能消除这种决策成本。
6.2 新旧交替,隔天和隔三天复习
不管刷到第几天,我坚持每天重写一遍昨天那道链表题的代码,隔三天再重写一遍三天前那道。算法题的遗忘速度快得惊人,尤其是套路性很强、但细节很多的链表题。隔天回顾能保证短期记忆,隔三天回顾能把套路变成长期记忆。这个方法比刷题数量更关键,因为它针对的是“做过又忘了”这个核心痛点。
6.3 写不出来可以看题解,但必须闭卷重写
我有一段时间很抗拒看题解,总觉得看了就是作弊。后来想明白了:中等以上的算法题,能写出最优解的人大概率都看过同类型的题解,差别只是看得够不够多。所以现在的策略是:写不出来就正常看题解,看完必须合上题解闭卷重写一遍,再用边界样例自测。这和web课程里“先跑通再优化”是一个道理——先有正确实现,才谈得上理解和优化。
6.4 学习记录本身就是复习工具
“0x3f 第27天”这种标题,对我来说就是一个时间戳。写学习记录不是给别人看的,是给一周后的自己看的。记录里写下当天最想不通的那个点,一周后回看,往往会发现那个点已经成了常识。这种正反馈,比刷题数量的增长更能支撑人走下去。
第27天结束的时候,我手边的草稿纸上画满了反转链表的三指针状态,电脑上是刚跑通的Spring Boot项目。两个看起来毫无关联的进度,在同一个晚上都有了推进。这大概就是双线作战最舒服的状态——不追求一天做完多少事,只追求每一天都能看到自己在往前走。