如果你已经跟着刷完了前面几讲,现在应该处于一个“能写但写不快”的阶段:循环能套,数组会遍历,字符串也能处理,但一遇上稍微绕一点的题,第一反应还是暴力解法,甚至暴力解都写得磕磕绊绊。这一期“力扣【新】动计划·编程入门4”要解决的问题,就是把你从“会写代码”推向“会设计代码”的拐点,重点补上三种最常见的解题思维:哈希表、双指针、滑动窗口。
这一阶段非常适合两类人:一类是已经把入门三件套(变量、循环、数组/字符串)过完、想系统接触算法思维的零基础学习者;另一类是准备实习或校招、需要在短期内把简单题刷出面试感的新手。这一讲的题目难度仍然控制在简单到中等偏易,但每道题都代表一类非常典型的套路,学会之后可以直接迁移到很多实际业务场景里,比如统计去重、区间合并、敏感词匹配、日志窗口截取等等。
1. 第4阶段到底在训练什么:从“做出来”到“做得聪明”
1.1 为什么到了第4讲才开始讲算法思维
前3期的内容,本质上是编程语言的“肌肉记忆”:变量类型、分支、循环、数组、字符串、函数。这些是任何语言都绕不开的地基。但刷力扣的人通常会在一个时刻突然卡住:题目看懂了,暴力解也写出来了,提交后却显示“超出时间限制”,或者更尴尬的是——写出来之后自己都觉得代码又臭又长,但不知道怎么优化。
这就是第4讲存在的意义。你需要的不是再刷几十道简单题,而是开始理解“数据结构”和“算法设计”这两个东西到底在解决什么问题。数据结构说白了就是数据怎么组织更方便;算法设计就是一套通用的思考框架。哈希表解决“查找”的问题,双指针解决“扫描”的问题,滑动窗口解决“连续子区间”的问题。这三板斧覆盖了力扣简单到中等题里至少一半的常用场景。
很多同学在这一阶段最容易犯的错误是死记代码模板。今天看到两数之和用哈希表,就把哈希表背下来;明天看到无重复子串用滑动窗口,又把滑动窗口背下来。结果换一道题题就不会了。我建议大家换个心态:第4阶段的重点不是记住某个题的解法,而是理解“为什么这个场景适合用这个结构”。
1.2 本期核心考点地图:三道题带你覆盖三套思维
按照“新动计划”入门第4部分的典型题单,我挑了三条最具有代表性的主线:
| 核心考点 | 代表题型 | 核心思维 | 复杂度改善 |
|---|---|---|---|
| 哈希表 | 两数之和 | 用空间换时间,把查找从O(n)降到O(1) | 暴力O(n²)降到O(n) |
| 双指针 | 有序数组去重 / 移除元素 | 一个指针负责遍历,一个指针负责写结果 | 原地操作,省掉额外数组 |
| 滑动窗口 | 无重复字符的最长子串 | 用可变窗口维护连续区间,避免重复扫描 | 暴力O(n²)降到O(n) |
这三个考点表面上不是同一类题,但底层逻辑是相通的:都是想办法让程序少做无用功。暴力解的问题不在于它做错了,而在于它反复做了一样的事情。哈希表把“找历史数据”变成“查记录”;双指针让“两边同时比较”替代“两层循环嵌套”;滑动窗口用一个变化的区间替代“每次从头重新算”。等你把这三道题彻底吃透,再看那些“简单偏中”的题,多半都能找到熟悉的感觉。
2. 核心考点的原理拆解:为什么这样做更快
2.1 哈希表:空间换时间的经典策略
先讲两数之和,力扣第1题。这道题几乎所有刷题的人都做过,但真正理解哈希表好处的其实不多。
题目要求很简单:给定一个整数数组和一个目标值,返回两个数的下标,使得这两个数之和等于目标值。暴力解法谁都会:两层循环,外层定一个数,内层找另一个数,找到就返回。这个方案直观,但提交之后在数据量大一点的例子上会跑得很慢,因为时间复杂度是O(n²)——每增加一个元素,要比较的次数按平方增长。
优化思路的核心是回答一个问题:内层循环到底在做什么?它做的是在数组里“查找”一个目标值。如果查找这个动作本身是O(1)的,总复杂度不就成了O(n)?哈希表(在Python里就是字典)的查找平均复杂度刚好是O(1)。于是我们就有了新的做法:一次遍历数组,边遍历边把“数值 -> 下标”存进字典,同时对当前元素检查“目标值 - 当前值”是否已经在字典里出现了。如果出现,说明之前已经遇到过你需要的另一半。
这里值得多讲一句:为什么不是先建好整个哈希表再遍历?因为会遇到“同一个元素被用了两次”的情况。比如数组[3, 3],目标值是6,如果先全部存进去,遍历第一个3的时候会发现另一个3也在表里,但它自己也在表里,看起来好像是自己加自己,实际上是复用了同一个元素。边遍历边存的好处就在于,每个元素只跟它之前的元素配对,天然规避了这个坑。
哈希表这种思路可以迁移到很多其他题目,比如判断两字符串是否为异位词、统计出现次数、找第一个重复出现的元素。说白了,这些题的共同点是:都需要在遍历过程中快速回顾“之前我见过什么”,哈希表就是最佳拍档。
2.2 双指针:把两件事合并到一趟扫描
第二个考点是双指针。力扣的第26题“删除有序数组中的重复项”是这一类的经典入门题。
题目要求在一个排好序的数组里原地去重,不能额外建一个新数组,最后返回去重后的长度。排序数组有一个天然性质:重复元素是连续排列的,所以去重这件事本质上就是“把每个第一次出现的元素往前挪”。
暴力做法可能是这样:每遇到一个新元素,就把它跟前面所有已经保留下来的元素对比一遍,看有没有重复。这样做的复杂度又是O(n²),而且在原地操作时还会涉及大量的元素搬移。双指针的做法则完全换了个思路。
准备两个指针:一个指针负责往前扫描,挨个看每一个元素,叫它“读指针”;另一个指针负责记录下一个不重复元素应该放到哪个位置,叫它“写指针”。因为数组有序,读指针遇到的“新元素”只需要跟“写指针前一个位置的元素”比较一次——如果不一样,说明这是个新出现的数,把它写到写指针位置,两个指针一起往前走;如果一样,说明是重复项,只让读指针往前走,写指针原地等待。
这个思路的妙处在于,一趟扫描就完成了遍历和写入两件事,时间复杂度O(n),空间复杂度O(1)。很多同学第一次看到这个解法时会觉得有点抽象,但实际手动模拟几个例子之后就明白了。双指针的应用面非常广:两数之和(数组有序版本)、移除元素、合并两个有序数组、链表找中间节点,全都是一家人。
2.3 滑动窗口:让区间像一个窗口一样移动
滑动窗口大概是这一阶段最需要“悟”的一个技巧。以“无重复字符的最长子串”为例,这道题要求你从一个字符串里找出最长的一个连续子串,这个子串里所有字符不能重复。
想都不用想,暴力解法就是穷举所有子串,每个子串再检查有没有重复字符,复杂度直接O(n³)甚至更高。滑动窗口的思路则特别符合人的直觉:想象一个可以伸缩的窗口在字符串上滑动,窗口始终满足“没有重复字符”。窗口右边每扩展一个字符就试着让窗口变大;一旦新字符跟窗口里的字符重复了,左边就收缩到“把那个重复字符请出去”为止。
这里面最关键的问题:怎么快速判断“字符是否在窗口里出现过”?这又要借用哈希表了。拿Python举例,你可以用一个字典记录每个字符最后出现的下标,遇到一个字符时,先看它之前有没有出现过、以及出现的位置是否还在当前窗口内。如果在窗口内,就把窗口左边界移动到那个位置加一;不管怎么样,更新当前字符的最新位置,然后计算窗口长度,记录最大值。
滑动窗口这个名字听着唬人,拆开看就三个动作:右指针往前走、左指针按条件收缩、窗口长度更新。整个流程走下来是O(n),因为每个字符最多被左右指针各碰一次。滑动窗口能解的题目也很多:长度最小的子数组、字符串排列、替换后的最长重复字符,套路几乎一模一样。
3. 实操过程与代码实现:从零写出一份能过的解
3.1 环境准备与刷题节奏建议
不需要用什么重型的开发环境,力扣自带的网页编辑器就够用了。我个人的建议是:用你平时最熟悉的语言刷这一阶段的题,JavaScript、Python、Java、C++都可以,但最好固定一门,不要今天用Python、明天换Java,否则精力会浪费在语言切换上。
刷题节奏上,我建议每道题给自己20到30分钟的思考时间。如果到了时间还是没有思路,直接看题解,看懂之后关掉题解自己默写一遍,第二天再独立重做一遍。这个“重做”的环节一定不能省,它是把短期记忆变成长期能力的唯一有效途径。
下面三份代码都以Python为例,兼顾可读性和规范性,你甚至可以稍微改改直接背下来作为模板。
3.2 两数之和的完整实现
def two_sum(nums, target): # 记录每个数字最后出现的位置 seen = {} for i, num in enumerate(nums): complement = target - num # 另一半已经出现过,直接返回 if complement in seen: return [seen[complement], i] # 把当前数字放进字典,方便后续配对 seen[num] = i return [] # 题目保证必有解,这里兜底需要注意,这个写法利用的是“边遍历边存储”,每个元素只和它之前的元素匹配。如果你想不依赖“题目保证有解”这个条件,可以在开头加一条判断,或者在循环结束后抛异常。实际笔试中我一般会保留return [],确保函数在边界情况下的行为是可预测的。
3.3 有序数组去重的原地写法
def remove_duplicates(nums): if not nums: return 0 # 慢指针指向下一个不重复元素的写入位置 slow = 1 for fast in range(1, len(nums)): # 发现新元素,写入到slow位置 if nums[fast] != nums[fast - 1]: nums[slow] = nums[fast] slow += 1 return slowfast从1开始,每次跟它前一个位置的元素比较。因为数组是有序的,只要有变化就意味着出现了一个新值。slow记录的不只是新元素的数量,同时也是下一个可以写入的位置。整个数组在处理完毕后,前slow个元素就是不重复的序列,顺序也不会乱。
这里有个容易忽略的小细节:为什么fast不是从0开始?因为nums[0]一定是第一个不重复的元素,它天然站好了位置。slow从1开始,正好留出坑位给后面可能出现的新元素。边界条件“空数组”也必须先处理掉,否则nums[fast-1]会越界。
3.4 无重复字符最长子串的滑动窗口
def length_of_longest_substring(s): last_pos = {} # 字符 -> 最后一次出现的下标+1 left = 0 max_len = 0 for right, ch in enumerate(s): if ch in last_pos and last_pos[ch] >= left: # 窗口内已经出现过这个字符,收缩左边界 left = last_pos[ch] + 1 # 更新这个字符的最新位置 last_pos[ch] = right # 当前窗口长度是 right - left + 1 max_len = max(max_len, right - left + 1) return max_len这里字典存的是“字符 -> 该字符当前出现下标的下一个位置”,为什么不是直接存下标?因为当窗口收缩时,我们关心的是“如果字符重复,左边界至少应该移动到重复位置的后一格去”。存下一个位置,赋值和比较会更直观。这也是很多题解里left更新写成last_pos[ch] + 1的原因——这个+1初学者最容易漏,漏掉之后会发现结果总是比正确答案大一点。
4. 常见问题与排查技巧实录
4.1 高频踩坑点速查
| 问题表现 | 可能原因 | 解决办法 |
|---|---|---|
| 两数之和返回了两个相同的下标 | 预先建表导致元素和自己匹配 | 改成边遍历边存入字典 |
| 去重后返回的长度对,但数组不对 | slow指针的语义没理清 | 手动走一遍[1,1,2]流程 |
| 滑动窗口结果偏大 | left更新漏了+1,或没检查索引是否在窗口内 | 在python里打印left/right逐个核对 |
| 字符串为空时代码报错 | 没有处理空输入边界 | 第一步就判断if not s: return 0 |
| 超时,但本地测试正常 | 暴力解法在极端用例下复杂度爆炸 | 重新检查是不是两层或三层循环嵌套 |
这些坑我基本都踩过,尤其是滑动窗口的left更新,当时调试了快一个小时,打印每个字符的最后位置才反应过来:当一个字符在窗口外也出现过时,不能把left移过去,只有位置>=left才算在窗口内。
4.2 实战排错思路:三连问
如果你提交后测试没过,别急着东改一行西改一行。按下面的顺序排查,绝大多数问题都能快速定位。
第一问:输入为空、长度为1时程序是否正常?这是最便宜、最快的边界检查。第二问:我的“窗口/指针/哈希表”里维护的状态变量是否正确?比如双指针中slow到底代表写入位置还是有效长度,这俩在这种题里经常混。第三问:手动在纸上跑一遍中小规模的样例,把每个变量在每个循环开始和结束时的值写出来,跟预期对比,通常一眼就能看出问题在哪。
调试时我喜欢在里面加上简单打印,比如:
print(f"left={left}, right={right}, ch={ch}, max_len={max_len}")不要嫌烦,入门阶段花在调试上的时间都是赚的,它在帮你建立“程序是如何一步步执行”的直觉。
4.3 关于面试表达:代码之外的注意事项
如果刷题的目标是面试,那这阶段就要开始刻意练习“讲思路”。我见过很多人题能解出来,但讲的时候只会说“嗯这题用哈希表就行了”,面试官追问一句“为什么用哈希表?复杂度怎么分析?”就卡住了。建议每道题都按这个模板练习:先描述暴力解,再说暴力解的瓶颈在哪,接着说优化思路,最后分析时间空间复杂度。哪怕只有自己一个人,也出声讲一遍,会比闷头写效果好非常多。
另外,力扣的编辑器默认没有代码补全,建议平时练习时也关掉自动提示,尽量手写常用函数,避免面试时依赖IDE导致节奏全乱。
5. 这一阶段的题目还能怎么扩展:举一反三的思路
5.1 把同一套路套到新题目上
学会三道题不算本事,能把套路迁移到五道、八道题才算真正理解。两数之和的哈希表思路可以改成“三数之和”(配合排序和双指针),也可以改成“两个字符串是否互为字母异位词”(用字符计数哈希表)。有序数组去重的双指针思路可以改成“移除给定值的元素”,做法基本一致,只是把“相邻重复”的判断条件换成“元素是否等于目标值”。
滑动窗口的扩展就更常见了:长度最小的子数组、字符串中的所有字母异位词、最大连续1的个数III,核心都是维护窗口的“左右指针”和“有效状态”。做扩展题的时候,不用一上来就硬做,先回想一下母题是怎么解的,哪里变了、哪里没变,比盲目刷十道同类题更有用。
5.2 从刷题到工程:这些思维能用在哪儿
有人可能觉得力扣题跟实际开发关系不大,其实这三套思维在工作里随处都是。哈希表自不必说,几乎所有统计类需求都会用到,缓存设计、去重计数、字典映射,本质都是一回事。双指针思想在做有序数组/链表的合并、日志区间合并、两个有序列表求交集时很常见,代码写出来比嵌套循环干净很多。滑动窗口则特别适合那些“连续区间统计”的需求,比如限流统计最近一分钟的请求数、监控最近N条日志里的异常比例、分析一段时间窗口内的用户活跃度。
理解这一点之后,再刷题的感觉会很不一样。你不是在为面试刷题,而是在练习结构化拆解问题的能力,只不过练习场景恰好是算法题罢了。
5.3 下一步怎么规划
“新动计划”第4讲如果刷完了,下一步的路线图通常有两个方向:一个是继续深入哈希表和双指针的中等难度题,比如三数之和、乘积小于K的子数组、最长重复字符替换;另一个方向是开始接触二叉树和递归,这是很多人的下一个分水岭,因为递归的思考方式和前面所有循环思维完全不一样。
我个人更建议先把哈希表、双指针、滑动窗口这三类简单题刷到“看到题目条件就能想到大概该用什么方法”的程度,再进入树和递归,地基打牢了,上楼才不会晃。走得太快的结果往往是前面看着都懂,一到中等题就卡壳,再回头看基础才发现原来根本没吃透。
最后分享一个自己的体会:我在刷这些入门题的时候,一个特别大的转折点是接受了一个事实——解不出来很正常,看题解也不丢人。真正拉开差距的不是谁一次性写对,而是谁能在看会之后真正脱离答案自己写出来。所以错题重刷、讲题给别人听、隔几天再做一遍,这三件事的价值可能比刷十个新题都大。编程入门阶段,慢就是快。