Day3,打卡继续。今天这第7到第9题,我特意放在同一天做,不是随手翻到哪页刷哪页,而是按“字符串处理—链表操作—栈结构应用”这条线排的。三道题恰好覆盖了三类面试高频考点,题目本身不算难,但想在三十分钟内一次写对、还能把思路讲清楚,其实很考验基本功。这篇文章记录一下我当天的完整过程:为什么这么安排、每道题怎么拆解、踩了哪些坑,以及最后怎么把代码从“能AC”练到“能讲给面试官听”。
1. 先聊聊这三道题为什么要组队刷
1.1 题型之间的隐藏关联
很多初学者喜欢按题库序号顺序往下刷,今天1到5,明天6到10,看起来很有节奏感,但实际效率不高。按序号刷的最大问题,是题目考察点比较跳跃,还没形成肌肉记忆就切换去了另一个方向,第二天再看到同类题照样蒙。
我这次把7、8、9三道题归为一组,原因是它们的内部有一条清晰的逻辑链:哈希表解决“查找唯一性”,指针操作解决“结构反转”,栈解决“顺序匹配”。这三类操作正好覆盖了笔试里最常出现的三种数据结构基础能力容器类数据结构、链式结构、受限线性表。一次打卡把这三样都过一遍,比单纯刷三道风马牛不相及的题要划算得多。
1.2 从“会做”到“会讲”才是有效打卡
单纯把代码提交通过,只是第一步。我给自己定的打卡标准是多做一步:每道题写完,对着空白编辑器口述一遍解题思路,模拟被面试官追问的场景。三道题口述下来,大概多花十五分钟,但收获完全不同。第9题“有效的括号”,我一开始只是会用栈,被追问“为什么非要用栈,数组不行吗”时,我愣了几秒,才发现自己并没有真正理解栈在此处的不可替代性。打卡刷题如果只是刷数量,那第七天你就会发现前面的全忘了。组队刷、讲出来、记笔记,这三件事缺一不可。
1.3 时间分配的节奏感
三道题我给自己定的总预算是九十分钟,单题不超过半小时。实际执行下来,第7题用了二十分钟,第8题用了二十八分钟,第9题只用了十二分钟。前两道题因为涉及细节边界,多花点时间值得;第9题因为思路太清晰,反而要提醒自己别大意。这种节奏感很重要,刷题不是为了感动自己,而是为了在有限时间里覆盖最多的考点。
2. 题7:字符串中的第一个唯一字符
2.1 题目与暴力解法的直觉
第7题的内容很简单:给定一个字符串,找到第一个不重复的字符,返回它的下标;如果不存在,返回-1。例如"aabb"应返回 -1,"leetcode"应返回 0。
拿到这题的第一反应,大概率是两层循环:外层枚举每个字符,内层扫描整个字符串统计它出现了多少次,遇到第一个计数为1的直接返回。这种暴力解法思路自然,但时间复杂度是O(n²),在字符串长度达到十万级时基本不可接受。我第一遍做的时候也写了这个版本,原因很简单:它最不容易写错。但写完提交后,我盯着复杂度想了半分钟,决定无论如何都要换成哈希表方案,因为刷题不能只满足于“能过”,还要考虑数据规模变大后的表现。
2.2 哈希计数解法与代码实现
正确的思路是用哈希表(Python里叫Counter)先遍历一次字符串,记录每个字符出现的次数;再遍历第二次,从左到右检查第一个计数为1的字符。这个方案的时间复杂度是O(n),空间复杂度是O(1),因为字符集大小有限(通常就26个小写字母)。
from collections import Counter def firstUniqChar(s: str) -> int: count = Counter(s) for i, ch in enumerate(s): if count[ch] == 1: return i return -1代码只有五行,但有几个细节值得注意。第一,Counter(s)是对整个字符串做一次完整遍历,所以后面count[ch]的查询都是O(1)。第二,第二次遍历用的是enumerate,同时拿到下标和字符,比先index()再赋值高效得多。第三,这里返回的是第一个满足条件的字符下标,不是字符本身,所以循环里直接return i。
注意:如果题目改成“返回第一个不重复的字符本身”,那只需要去掉下标逻辑,返回
ch即可。面试时一定要听清楚问的是下标还是字符。
2.3 边界条件与追问场景
这道题的边界条件其实不多,但要全部覆盖:空字符串返回 -1;字符串全是重复字符返回 -1;只有一个字符返回 0;大小写敏感问题,比如"aA"中'a'和'A'算不算同一个字符,题目没说明就默认算不同,面试时要主动确认。
我把这段代码在本地跑了五组用例:"leetcode"返回 0、"loveleetcode"返回 2、"aabb"返回 -1、""返回 -1、"a"返回 0。全部通过后,我追问了自己一个扩展场景:如果是流式数据,也就是字符不是一次性给出,而是源源不断到来,怎么处理?答案是维护一个哈希表记录每个字符的状态(未出现、出现一次、出现多次),同时用一个队列保存候选字符。这个扩展点让一道简单题的含金量直接翻倍。
3. 题8:反转链表
3.1 迭代法的核心过程
第8题是链表题里最经典的一道:反转一个单链表。输入1 -> 2 -> 3 -> 4 -> 5,输出5 -> 4 -> 3 -> 2 -> 1。
我推荐先从迭代法入手,因为它不涉及递归栈溢出的风险,且每一步的状态变化都看得见。核心操作是准备三个指针:prev(前驱节点)、cur(当前节点)、nxt(后继节点)。每次循环做四件事:先保存cur.next到nxt防止丢失,再把cur.next指向prev完成反转,接着把prev移到cur,最后把cur移到nxt。
def reverseList(head): prev = None cur = head while cur: nxt = cur.next # 先保存下一个节点 cur.next = prev # 反转指针方向 prev = cur # prev 前进到 cur cur = nxt # cur 前进到 nxt return prev这里容易出错的地方是:用完cur.next之后,一定要先存nxt再改指针。我第一天练习时在这行栽过跟头——先改了cur.next = prev,然后想用cur.next去拿后面的节点,结果拿回来的是prev,链表直接断成两截。这个“先保存后修改”的顺序,是链表题里最高频的翻车点。
3.2 递归解法与脉络对比
迭代会写之后,我建议再花十分钟看递归写法。递归的出口是链表为空或只剩一个节点,直接返回该节点;递归的核心在于假设后面的链表已经反转好,只需要把当前节点接到末尾。
def reverseList_recursive(head): if not head or not head.next: return head new_head = reverseList_recursive(head.next) head.next.next = head head.next = None return new_head这段代码初看很绕,但拆开就清晰了:head.next.next = head是让当前节点的下一个节点反过来指向自己,head.next = None是切断原来的正向连接,防止成环。递归写法代码短,但额外占用O(n)的调用栈空间,如果链表特别长(比如十万节点以上),可能会栈溢出。面试时最好先说迭代法,再补充“我也可以用递归实现”,展示自己两种思路都掌握。
3.3 边界检查与常见追问方向
边界条件我列了个小清单:输入None返回None;只有一个节点返回它本身;两个节点反转;正常多节点反转。每次写完代码先空想一遍这三条,再切换到编译器验证。
面试官很喜欢在这题后面追加进阶问题,比如“每K个节点一组反转”“反转链表的一部分”。第8题练稳之后,这些变体其实就是加了分组和定位逻辑的迭代法。我的建议是打卡当天先把基础反转写三遍,第一遍照抄理解,第二遍关掉代码自己写,第三遍在白纸上画指针变化图。三遍下来,链表指针的流转基本不会再用混。
4. 题9:有效的括号
4.1 为什么栈是唯一合理的结构
第9题给定一个只包含()[]{}的字符串,判断括号是否有效。有效条件是左右括号必须成对出现且顺序正确。
我最初尝试过用一个普通数组模拟,每次遇到左括号就记下来,遇到右括号就往前找有没有对应的左括号。这样在嵌套层级很深时会出现问题:比如"([{}])",最右边的右括号')'需要匹配的是最左边的'(',如果靠遍历往前找,复杂度会很高,而且逻辑混乱。栈的出现正是为了解决这个问题,因为栈天然就是“后进先出”,这正好对应了括号匹配的规则:最近出现的左括号,一定优先匹配当前的右括号。
用生活化的比喻,这就像一摞盘子:你总是先拿最上面那个,而最先放进柜子的盘子反而最后才能被碰到。括号嵌套时左括号是“放进去的盘子”,右括号就是要“拿出来的盘子”,顺序完全吻合。这就是为什么我说,遇到嵌套匹配类问题,首先要想到栈。
4.2 实现细节与容易踩的坑
def isValid(s: str) -> bool: stack = [] mapping = {')': '(', ']': '[', '}': '{'} for ch in s: if ch in mapping: # 遇到右括号,栈顶必须是对应的左括号 if not stack or stack[-1] != mapping[ch]: return False stack.pop() else: # 遇到左括号,直接入栈 stack.append(ch) return not stack这段代码里最妙的细节是使用一个反向映射mapping,让右括号能O(1)地找到它对应的左括号。判断条件if not stack很关键,它处理了“第一个字符就是右括号”的情况,比如 字符串")(",此时栈是空的,stack[-1]会直接抛异常,所以必须先检查栈是否为空。最后return not stack,既能处理合法情况,也能处理“有多余左括号”的情况。
我在本地测试时专门准备了一组刁钻用例:"()"返回 True、"()[]{}"返回 True、"(]"返回 False、"([)]"返回 False、"{[]}"返回 True、""返回 True。其中"([)]"这个用例很多人会弄错,因为它左右括号数量是配对的,但交叉排列不合法,只有逐层匹配的栈能做到正确判断。
4.3 变体与应用价值
这道题看起来简单,但应用面远不止字符串。编辑器里代码缩进的配对检测,编译器语法解析中的作用域判定,甚至可以扩展到HTML标签的嵌套闭合校验,底层思路都是同一个栈模型。刷完第9题后,我又顺手做了个变体:字符串里同时包含小括号、中括号、大括号和尖括号,还允许中间混入普通字符,判断嵌套是否合法。只需要在判断前先过滤掉非括号字符,再走同样的栈逻辑。这个变体其实就是很多系统里“括号语法高亮”的简化版本,实战价值很高。
5. 打卡当天的实操流程复盘
5.1 一个多小时的时间怎么分的
当天我给自己列了排程:前十五分钟快速浏览三道题,不急着写代码,先在白纸上画出每道题的数据流;中间四十五分钟分别实现三道题并跑测试;最后二十分钟做复盘,把三道题的思路用语音口述一遍,同时记录踩坑点。
第7题是最顺利的,哈希表的直觉一上来就有,写完就跑通。第8题反而是三道题里最耗时间的,因为我在迭代法和递归法之间切换着写了两遍,还额外画了三张指针图,用来确认反转过程中每个节点的next指向。第9题代码最短,但我在测试用例"([)]"上多盯了两分钟,确认栈出栈顺序完全正确才放心。整个流程下来大约八十分钟,比预算的九十分钟还省了一点。
5.2 用测试用例把边界条件逼出来
我习惯在写业务代码之前先写测试用例,刷题时同样如此。三道题我分别整理了一份最小测试集:第7题四个用例,第8题四个用例,第9题六个用例。运行完一轮后,我把每个用例的结果和预期逐一比对,再把报错的用例单独记到错误日志里。
比如第7题,如果不加enumerate而是直接s.index(ch),代码逻辑也能过,但在反复调用index时会引入额外的O(n)开销,让整体复杂度退化成O(n²)。我特意对比了两种实现的耗时,在十万字符的随机串上差距非常明显。这就是“测试用例倒逼优化”的价值:不是先想着优化,而是先用用例跑出问题,再让你有动力去改。
5.3 复盘笔记怎么记才有用
我的复盘笔记不是简单抄代码,而是记“一类题的钥匙”。第7题我写的是:唯一性问题,优先想哈希表;第8题写的是:链表改指针,先保存再修改;第9题写的是:嵌套匹配,栈加反向映射。每把“钥匙”都配合一个错误案例,方便下次复习时快速唤醒记忆。这套笔记方法在打卡到第15天左右时,会明显比单纯累积提交记录更有价值,因为你不需要从头回忆每道题,只看钥匙卡片就能迅速捡起关联知识点。
6. 常见问题与排查技巧实录
6.1 运行报错案例:空栈引发的异常
第9题最容易出现的运行时错误就是if not stack漏写。字符串")"进来时,栈为空,直接执行stack[-1]会抛出IndexError。这种问题在本地调试时一眼就能看到,但在笔试平台上传代码时很难定位,因为报错行号往往指向的是调用函数那一层被包装过的代码。我的排查经验是:所有涉及stack[-1]的地方,先问自己“栈可能为空吗”,然后在纸上模拟一遍最极端的输入。
6.2 复杂度误判:自以为O(n)其实不是
第8题有个典型误判:有人用“把链表所有值放进数组,反转数组,再重建链表”的办法,最后得出结论说这是O(n)。从时间复杂度角度确实也是O(n),但空间复杂度从O(1)退化成了O(n),而且多了一次遍历和数组构造。面试官如果问“你能否用常数空间完成”,这种解法直接出局。我吃过一次亏,所以现在每写完一道题都会额外问自己:空间上是否存在更优解。
6.3 面试现场“演”代码的细节
刷题最终要落到面试场景。我总结了一套三分钟展示模板:先复述题目确认理解,再说暴力解法,然后说优化后的解法,最后手动跑一个用例。第8题在口述时,重点强调“三个指针的节奏感”和“先保存nxt再改next”;第9题在口述时,强调“反向映射让代码更简洁”和“栈为空时要提前拦截”。这些都是可以提前排练的表达细节,练到脱口而出比临时组织要自然得多。
我个人在打卡中最深的体会是:代码题写对只是及格,稳定写对、还能边写边解释才是真正的高手状态。第7到第9题这三道,让我把哈希计数、链表反转、栈匹配这三类基础动作练成了肌肉记忆。如果你也刚开始这个系列,建议别贪多,一天三道题,走完做题、测试、复盘、口述这四步,坚持一周,你会明显感觉自己写代码时脑子里有了一张“武器清单”,看到题目就知道该抽哪把武器出来。