如果你刚开始系统刷算法题,很容易产生一种错觉:
两数之和是一道题,最长子串是另一道题,二分搜索又是另一道题。
Hot 100,就是要学会一百种解法。
其实恰恰相反。
真正熟练以后,你看到的往往不是一百道题,而是十几种不断换皮的问题结构。
有些题在问:
“扫描到这里时,过去的信息中,我真正需要留下什么?”
有些题在问:
“如果我已经知道这一端不可能产生更优答案,能不能永远把它扔掉?”
还有些题在问:
“答案虽然不知道是多少,但我能不能判断某一半肯定不可能有答案?”
算法能力的成长,本质上就是不断获得这种压缩问题的能力。
所以在正式进入 Hot 100 第 1~3 周之前,我不希望你先背二十多个模板。我更希望你先认识六道题。
它们分别是:
数组:121. 买卖股票的最佳时机
哈希:1. 两数之和
双指针:11. 盛最多水的容器
滑动窗口:3. 无重复字符的最长子串
二分:34. 在排序数组中查找元素的第一个和最后一个位置
矩阵:54. 螺旋矩阵
这六道题并不是各自专题里最难的。
但它们非常适合成为“母题”。
学完它们以后,你再去刷 283、49、128、15、438、35、33、73、48……会开始发现:
题目不同,骨架却似曾相识。
一、数组真正训练的不是下标,而是「扫描状态」
母题:121. 买卖股票的最佳时机
题目大意很简单。
给你每天的股票价格:
[7, 1, 5, 3, 6, 4]
你只能买一次、卖一次,而且必须先买后卖。
最大的利润是多少?
答案显然是:
1 买入
6 卖出
利润 = 5
刚开始做算法的人,脑子里很容易出现一种非常自然的方案:
“那我就枚举买入日期,再枚举卖出日期。”
于是:
for (let buy = 0; buy < prices.length; buy++) {
for (let sell = buy + 1; sell < prices.length; sell++) {
// 计算 prices[sell] - prices[buy]
}
}
这当然没错。
时间复杂度是 O(n²)。
但真正值得学习的问题不是:
有没有更厉害的代码?
而是:
这段暴力算法到底重复做了什么?
假设今天价格是 6。
为了知道“今天卖出最多赚多少”,你真的需要重新查看今天之前所有价格吗?
不需要。
你只需要知道一个数字:
今天之前出现过的最低价格。
如果历史最低价格是 1,那么今天价格是 6:
6 - 1 = 5
就是今天卖出的最好利润。
突然之间,一个看似需要保存整段历史的问题,只剩下两个状态:
minPrice 到目前为止最低的买入价格
maxProfit 到目前为止最大的利润
这就是数组题非常重要的一种思维:
从左往右扫描时,不要问“过去发生了什么”,而要问“过去的信息里,对未来真正有用的是什么”。
代码于是变得非常短:
function maxProfit(prices) {
let minPrice = Infinity;
let maxProfit = 0;
for (const price of prices) {
minPrice = Math.min(minPrice, price);
maxProfit = Math.max(maxProfit, price - minPrice);
}
return maxProfit;
}
短不是重点。
真正重要的是,你应该能够解释循环中的两个不变量。
扫描完第 i 天之后:
minPrice
=
prices[0...i] 中最小的价格
而:
maxProfit
=
到第 i 天为止能够得到的最大合法利润
为什么只保存最低价格就够?
因为如果你决定今天卖,那么所有过去日期的卖出价都已经不重要了。
今天的卖价固定以后:
利润 = 今天价格 - 买入价格
要让利润最大,只需要让买入价格最小。
这就是算法里极其重要的一件事:
扔掉无用历史。
很多初学者写算法时,总舍不得丢信息。
数组有 10000 个数,就觉得自己需要记住 10000 个数。
实际上高手经常在想:
到当前位置以后,前面的 9999 个信息能不能压缩成一个数字?
这也是以后动态规划、贪心、前缀统计乃至很多数据结构的思想源头。
当你之后遇到:
最大值
最小值
历史最好结果
截至当前位置……
先不要急着套模板。
问自己:
扫描到当前位置,为了处理未来,我最少需要保留什么?
这句话,比背 maxProfit 的代码重要得多。
二、哈希表的本质不是 Map,而是「用记忆换掉搜索」
母题:1. 两数之和
给你:
nums = [2, 7, 11, 15]
target = 9
寻找两个数,使它们相加等于 9。
最直接的思考方式仍然是枚举:
2 和谁?
2 + 7
2 + 11
2 + 15
7 和谁?
……
于是得到 O(n²)。
可真正重要的问题又来了:
哪里发生了重复工作?
当我们看到当前数字:
7
其实根本没必要问:
7 应该和数组中的哪个数字组合?
我们可以换一种问法:
我要凑成 9,现在有 7,所以我缺的是 2。
也就是说:
need = target - current
于是问题从:
帮我找两个数
变成:
我以前见过 need 吗?
这就是哈希表真正改变问题的地方。
它不是单纯让查询“变快”。
它把一个搜索问题改造成了一个记忆问题。
我们从左向右扫描,同时建立一本账:
见过什么数 -> 它出现在哪个下标
JavaScript 里就是:
const seen = new Map();
完整实现:
function twoSum(nums, target) {
const seen = new Map();
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i];
if (seen.has(need)) {
return [seen.get(need), i];
}
seen.set(nums[i], i);
}
return [];
}
这里有一个非常值得你认真理解的小细节。
为什么是:
if (seen.has(need)) {
...
}
seen.set(nums[i], i);
而不是先把当前数字存进去?
因为同一个元素不能使用两次。
我们希望 seen 始终表示:
当前位置左侧已经出现过的元素。
那么当我们处理 nums[i] 时,当前元素还不属于“历史”。
这又是一个不变量。
算法写得稳不稳,很大程度上取决于你能不能说清楚:
某个变量在循环的任意时刻,究竟代表什么。
还有一个 JavaScript 细节非常重要:
不要写:
if (seen.get(need)) {
因为某个数字对应的下标完全可能是 0。
而:
Boolean(0) === false
所以判断键是否存在,要使用:
seen.has(need)
两数之和真正应该留给你的,不是这一段代码,而是一种条件反射:
当暴力解法里出现:
我现在有 X
我还要去前面寻找 Y
就应该警觉:
能不能在经过 Y 的时候,提前把它记下来?
以后你做 49. 字母异位词分组,Map 存的就不再是“数字 -> 下标”,而会变成:
特征 -> 一组元素
做 128. 最长连续序列 时,你甚至只需要关心:
某个数字是否存在
于是 Map 又退化成 Set。
这就是哈希专题真正应该掌握的三种语言:
我要判断有没有 -> Set
我要保存对应关系 -> Map
我要统计出现次数 -> Map / 数组计数
不要背“这道题使用 Map”。
应该问:
我要记住什么?以后要拿什么信息来查它?
键和值的设计,才是哈希题真正的难点。
三、双指针最难的不是移动,而是证明「为什么敢移动」
母题:11. 盛最多水的容器
假设数组:
[1, 8, 6, 2, 5, 4, 8, 3, 7]
每个数字表示一根竖线的高度。
选择两根线形成一个容器。
面积:
宽度 × 两边较短的高度
如果左右指针分别是:
left
right
面积就是:
(right - left) * Math.min(height[left], height[right])
暴力方法显然是枚举所有两根线。
O(n²)。
双指针解法则从最宽的容器开始:
left = 0
right = n - 1
然后不断向中间移动。
但真正决定你有没有理解这道题的,不是你会不会写:
if (height[left] < height[right]) {
left++;
} else {
right--;
}
而是:
为什么移动矮的那根?
这是这道题最有价值的地方。
假设当前:
左边高度 = 4
右边高度 = 10
宽度 = 8
面积由较短的 4 决定:
8 × 4
现在宽度下一步必然会缩小。
如果我们保留左边这个高度 4,只把右边的 10 往里移动,那么新的右边无论是:
3
5
20
100
容器高度都不会超过 4。
因为左边仍然只有 4。
与此同时,宽度还变小了。
因此:
只移动高的一侧,不可能让“保留矮边的这个状态”获得更大面积。
所以高边可以暂时不动。
真正还有希望改变答案的是:
把当前限制水位的那根矮线扔掉,寻找一根更高的线。
于是:
function maxArea(height) {
let left = 0;
let right = height.length - 1;
let best = 0;
while (left < right) {
const width = right - left;
const h = Math.min(height[left], height[right]);
best = Math.max(best, width * h);
if (height[left] <= height[right]) {
left++;
} else {
right--;
}
}
return best;
}
双指针真正的灵魂就在这里:
每移动一次指针,你都应该能够解释:被跳过的那些状态为什么不可能产生更好的答案。
否则你只是在背指针移动规则。
这条原则非常重要。
以后做 15. 三数之和,你会遇到:
和太小 -> left++
和太大 -> right--
为什么?
因为排序之后存在单调性。
做 42. 接雨水,你也会遇到左右指针。
为什么可以先处理某一侧?
依然是因为某个边界已经足以确定这一侧的答案。
双指针从来不是:
有两个变量,一个叫 left,一个叫 right。
真正的双指针思想是:
根据问题结构,每一步安全地淘汰一批不可能成为答案的候选状态。
当你能够证明“为什么这一边可以扔”,你才真正学会了双指针。
四、滑动窗口,是把「所有子数组」变成一次连续运动
母题:3. 无重复字符的最长子串
给你:
"abcabcbb"
寻找最长的不含重复字符的连续子串。
答案:
"abc"
长度 = 3
注意这里有一个非常重要的词:
连续。
看到:
子数组
子串
连续区间
你应该立刻对“窗口”产生敏感。
暴力怎么做?
枚举每个起点,再枚举每个终点,然后检查里面有没有重复字符。
这意味着大量区间被反复检查。
滑动窗口换了一种看问题的方式。
我们始终维护:
[left, right]
并保证窗口内部:
没有重复字符。
现在让 right 向右移动。
如果新字符没有重复:
窗口仍然合法
继续扩张。
如果新字符造成重复:
窗口不合法
那我们怎么办?
不是重新开始。
而是从左边一点点删除字符,直到窗口重新合法。
代码:
function lengthOfLongestSubstring(s) {
const window = new Set();
let left = 0;
let best = 0;
for (let right = 0; right < s.length; right++) {
while (window.has(s[right])) {
window.delete(s[left]);
left++;
}
window.add(s[right]);
best = Math.max(best, right - left + 1);
}
return best;
}
第一次看到这里的人,经常会产生一个疑问:
这里明明有:
for (...)
里面又有:
while (...)
这不是 O(n²) 吗?
不是。
这是理解滑动窗口非常重要的一关。
观察每一个字符。
它最多:
被 right 加入窗口一次
被 left 移出窗口一次
right 从左到右走一次。
left 也从左到右走一次。
两个指针都不会回头。
所以所有 while 循环加起来,不是每次重新遍历整个数组,而是总共最多移动 n 次左右。
因此总复杂度仍然是:
O(n)
这叫做摊还分析的最基础直觉。
滑动窗口真正应该在你脑子里形成一个固定过程:
right 扩张
↓
加入新元素
↓
检查窗口是否合法
↓
不合法?
↓
left 收缩直到重新合法
↓
更新答案
但千万不要因此认为所有连续区间题都能滑窗。
你的计划里很快就会碰到一个极好的反例:
560. 和为 K 的子数组。
如果数组全部是正数,那么窗口和太大时,我们可以放心让 left 右移:
删除正数以后,总和只会变小
存在单调性。
可 560 中允许负数。
例如:
[10, -8, 3]
窗口当前和太大,并不意味着左边缩一下就一定朝正确方向前进。
因为删掉的数可能是负数。
所以那道题不能简单套普通滑窗,而要使用:
前缀和 + 哈希表
这个反例非常重要。
真正的滑动窗口不是:
看到子数组就套模板。
而是:
当右端扩张导致某个条件被破坏,而且左端移动能够以可预测的方向恢复条件时,窗口才真正成立。
这句话,你最好在刷完 560 后回来再读一次。
五、二分查找不是「找一个数」,而是「找到真假世界的分界线」
母题:34. 在排序数组中查找元素的第一个和最后一个位置
很多人第一次接触二分,是这种写法:
数组排好序
看中间
等于 target 就返回
于是他们以为二分查找就是:
更快地找到某个数。
这只理解了一半。
真正强大的二分思想其实是:
当搜索空间具有单调性时,找到某种状态发生变化的边界。
比如:
nums = [5, 7, 7, 8, 8, 10]
target = 8
我们不是只想知道有没有 8。
我们想知道:
第一个 8 在哪里?
最后一个 8 在哪里?
先只研究第一个 8。
我们其实是在寻找:
第一个
nums[i] >= 8的位置。
把数组想象成一个判断题:
nums[i] >= 8 ?
得到:
5 7 7 8 8 10
false false false true true true
突然你会发现:
我们根本不是在找 8。
我们是在找:
false false false | true true true
↑
分界线
这才是二分真正值得掌握的模型。
我们统一使用一个很好维护的区间:
[left, right)
注意右边不包含。
开始时:
left = 0;
right = nums.length;
寻找第一个 >= target 的位置:
function lowerBound(nums, target) {
let left = 0;
let right = nums.length;
while (left < right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
为什么:
nums[mid] < target
时可以写:
left = mid + 1;
因为:
mid 以及 mid 左边
都不可能是第一个 >= target 的位置。
为什么:
nums[mid] >= target
时却只能:
right = mid;
而不能 mid - 1?
因为 mid 自己完全可能就是我们要找的边界。
我们不能把它扔掉。
最终:
left === right
这个位置就是第一处:
nums[i] >= target
同理,寻找最后一个 8,可以转化为:
先找到第一个
> 8的位置,然后减一。
于是:
function upperBound(nums, target) {
let left = 0;
let right = nums.length;
while (left < right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] <= target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
完整答案:
function searchRange(nums, target) {
const first = lowerBound(nums, target);
if (first === nums.length || nums[first] !== target) {
return [-1, -1];
}
const last = upperBound(nums, target) - 1;
return [first, last];
}
function lowerBound(nums, target) {
let left = 0;
let right = nums.length;
while (left < right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
function upperBound(nums, target) {
let left = 0;
let right = nums.length;
while (left < right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] <= target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
如果你真正理解了这里,后面的二分题会发生一个巨大的变化。
做 35. 搜索插入位置:
你其实就在找:
第一个 >= target 的位置
也就是 lowerBound。
做 74. 搜索二维矩阵:
矩阵只是二维外衣,本质仍然能映射成一个有序的一维搜索空间。
做 33. 搜索旋转排序数组:
难点变成了:
当前哪一半仍然有序,因此哪一半可以被安全排除?
做 153. 寻找旋转排序数组中的最小值:
又变成:
如何根据
mid与右端的关系判断最小值在哪一半?
你会发现二分的核心从来不是:
mid = Math.floor((left + right) / 2);
这只是代码。
二分真正的灵魂是:
我有没有一个具有单调性的判断条件,使得答案左边是一种状态,答案右边是另一种状态?
一旦有:
FFFFFTTTTT
或者:
TTTTTFFFFF
你就应该闻到二分的味道。
六、矩阵题最怕的不是二维,而是「边界没有定义清楚」
母题:54. 螺旋矩阵
给你:
1 2 3
4 5 6
7 8 9
按照螺旋顺序输出:
1 2 3 6 9 8 7 4 5
很多人第一次写矩阵题,会下意识开始模拟:
往右
撞墙了往下
撞墙了往左
撞墙了往上
……
于是需要:
方向数组
visited
判断是否走过
判断边界
改变方向
当然可以。
但是螺旋矩阵有一种更值得学习的看法。
不要想:
一个人在矩阵里走。
想:
我正在一层一层剥掉矩阵的外壳。
于是我们只需要四个边界:
top
bottom
left
right
初始:
top = 0
bottom = m - 1
left = 0
right = n - 1
第一步,读取最上面一行:
left -> right
读完以后:
top++
为什么?
因为这一行已经处理完了。
它以后永远不会再属于剩余矩阵。
接着读取最右边一列:
top -> bottom
然后:
right--
接着底边:
right -> left
然后:
bottom--
最后左边:
bottom -> top
然后:
left++
你会发现,我们不是在控制一个点到处行走。
我们是在不断收缩一个矩形。
完整代码:
function spiralOrder(matrix) {
if (matrix.length === 0) {
return [];
}
const result = [];
let top = 0;
let bottom = matrix.length - 1;
let left = 0;
let right = matrix[0].length - 1;
while (top <= bottom && left <= right) {
// 上边
for (let col = left; col <= right; col++) {
result.push(matrix[top][col]);
}
top++;
// 右边
for (let row = top; row <= bottom; row++) {
result.push(matrix[row][right]);
}
right--;
// 下边仍然存在
if (top <= bottom) {
for (let col = right; col >= left; col--) {
result.push(matrix[bottom][col]);
}
bottom--;
}
// 左边仍然存在
if (left <= right) {
for (let row = bottom; row >= top; row--) {
result.push(matrix[row][left]);
}
left++;
}
}
return result;
}
这道题真正容易错的地方,是这两个判断:
if (top <= bottom)
和:
if (left <= right)
为什么需要?
考虑矩阵只有一行:
1 2 3 4
上边已经完整访问:
1 2 3 4
如果你还无脑执行“下边从右往左”,就会重复访问。
再考虑只有一列:
1
2
3
4
同样会重复。
所以矩阵题里有一个非常值得养成的习惯:
每做一次结构性修改之后,重新确认剩余区域是否仍然存在。
矩阵之所以让很多初学者害怕,通常不是因为二维本身多困难。
而是因为代码里同时出现:
行
列
上下左右
坐标变化
边界
视觉信息突然变多。
解决办法不是增强“脑内模拟能力”。
而是给每个变量一个清楚到不能再清楚的含义。
例如:
top 当前尚未处理区域的第一行
bottom 当前尚未处理区域的最后一行
left 当前尚未处理区域的第一列
right 当前尚未处理区域的最后一列
只要这四句话一直成立,代码就不容易乱。
以后做 73. 矩阵置零 时,你会学习:
怎样利用矩阵自身的第一行、第一列保存状态。
做 48. 旋转图像 时,你会学习:
一个二维坐标经过旋转以后如何映射。
做 240. 搜索二维矩阵 II 时,你会看到一个极其漂亮的性质:
从右上角开始,一个数字:
向左一定变小
向下一定变大
于是二维矩阵里竟然也能进行“单调淘汰”。
你会重新遇见双指针和二分里已经见过的思想。
这就是为什么算法专题并不是互相割裂的。
七、六道题背后,其实只有六句话
现在先不要看代码。
把六道题的代码全部忘掉。
如果几天以后,你还能记住下面六句话,这篇文章就已经达到目的。
对于数组扫描:
过去的信息很多,但未来真正需要的状态通常很少。
对于哈希表:
如果以后还要回来寻找某个东西,不如第一次经过它时就记下来。
对于双指针:
移动指针之前,要证明被舍弃的那批候选不可能更优。
对于滑动窗口:
维护一个连续合法区间;右边负责探索,左边负责修复。
对于二分:
不要只想着找某个值,要寻找单调状态发生变化的边界。
对于矩阵:
不要靠脑内乱走,先定义坐标、区域和边界的不变量。
这六句话比六份模板值钱得多。
八、接下来刷第 1~3 周,你应该观察什么
你接下来会刷很多看起来不像这六道母题的题。
这是好事。
因为训练真正开始的地方,就是“题目变了以后,你还能不能认出旧结构”。
做 283. 移动零 时,不要只记快慢指针。观察:
slow到底代表什么?
它其实代表:
下一个非零元素应该写入的位置
这依然是“不变量”。
做 49. 字母异位词分组 时,不要只记排序字符串。
想:
Map 的 key 究竟应该表达什么特征,才能保证属于同一组的字符串得到相同 key?
做 128. 最长连续序列 时,认真想:
为什么一个数存在
num - 1时,我们反而不从它开始向后数?
因为它不是一段连续序列的起点。
这仍然是在淘汰重复工作。
做 15. 三数之和 时,每次移动左右指针,都问:
为什么?
如果只能回答:
“因为题解这么写。”
说明还没有真正学会。
做 438. 找到字符串中所有字母异位词 时,把第 3 题的窗口思想搬过来,但观察一个变化:
第 3 题维护的是:
窗口中是否有重复字符
438 维护的是:
窗口里的字符频次是否与目标一致
于是 Set 不够了。
你需要频次表。
做 238. 除了自身以外数组的乘积 时,会遇到一个非常重要的新思想:
左边所有元素的信息
+
右边所有元素的信息
这就是前缀与后缀。
不要把它当作孤立技巧。
它仍然属于:
提前保存重复使用的信息。
做 560. 和为 K 的子数组 时,你应该主动问:
为什么第 3 题的滑动窗口不能直接过来?
如果你能回答“因为负数破坏了窗口和的单调性”,说明你真正开始理解适用条件,而不是模板匹配。
做 35、34、74、33、153 时,强迫自己每一道都先写一句中文:
我的搜索空间是什么?
我根据什么条件能够排除一半?
如果这两句话说不出来,先别写 while。
做 54、73、48、240 时,先在纸上写:
row 是什么
col 是什么
哪些区域已经处理
哪些区域还没有处理
你会发现矩阵代码突然会安静很多。
九、还有一个你很快会经历的阶段:知道算法,却写不出来
这是正常且必要的阶段。
比如你看完这篇文章以后,再做两数之和,很可能会想:
“我知道!用 Map!”
但真正开始写时又会卡:
Map 里到底存什么?
先判断还是先插入?
返回哪个下标?
重复元素怎么办?
这不是“没学会”。
这是你从:
认识算法
走向:
能够独立执行算法
中间必须经过的一段路。
不要因此立刻回来看完整代码。
先把你知道的东西写成中文。
例如:
遍历当前数字 nums[i]
需要的数字是 target - nums[i]
先看以前有没有出现 need
有就返回
没有就记录当前数字
如果这五句话是你自己推出来的,代码只是翻译。
真正危险的是另一种情况:
题目刚出现,你脑子里立刻浮现:
const map = new Map();
for (...)
但别人问一句:
为什么?
你突然不知道。
算法学习最容易出现的假象,就是:
手已经记住代码,大脑却没有建立模型。
所以未来这三周,每做完一道题,至少问自己三个问题:
我是根据题目的什么信号想到这个方法的?
这个算法每一步为什么不会漏掉正确答案?
如果题目改一个条件,这套方法什么时候会失效?
第三个问题尤其重要。
例如:
两数之和如果要求返回所有组合呢?
无重复最长子串如果不要求连续呢?
二分数组如果没有排序呢?
普通滑窗如果数组出现负数呢?
当你开始关心“方法为什么失效”,你才真正开始掌握“方法为什么有效”。
十、最后:算法高手真正积累的是什么
很多人刷完几百道题以后,会有一个奇怪的问题:
旧题会做。
稍微换皮,就不会。
原因往往不是刷得不够多,而是每一次刷题积累的是:
这题怎么写
而不是:
这类问题为什么可以这样处理
你接下来刷 Hot 100,我希望你的积累方式反过来。
121 买卖股票做完,不要只留下:
minPrice
maxProfit
留下的是:
扫描过程中压缩历史信息。
两数之和做完,不要只留下:
Map
留下的是:
把未来搜索转化成过去记忆。
盛最多水的容器做完,不要只留下:
移动矮边
留下的是:
利用单调结构安全淘汰候选。
最长无重复子串做完,不要只留下:
left / right / Set
留下的是:
维护一个动态合法区间。
34 题做完,不要只留下:
while (left < right)
留下的是:
寻找真假状态的边界。
螺旋矩阵做完,也不要只留下:
top bottom left right
留下的是:
用边界描述剩余问题,而不是靠混乱的过程模拟。
真正刷到后来,你脑子里不会装着一百段 LeetCode 代码。
你会装着一些非常稳定的问题模型。
当一道陌生题摆在你面前时,你会先看它的结构:
有没有重复计算?
有没有可以保存的信息?
有没有单调性?
有没有连续区间?
有没有边界?
有没有一批状态能够直接淘汰?
然后题目的外壳开始脱落。
剩下的,才是算法。
所以你现在不需要证明自己“能刷完 Hot 100”。
接下来只做一件事情:
每做一道题,都试着看见它背后的那一种结构。
第一遍也许看不见。
第二遍开始模糊。
第三遍某一天,你打开一道从没做过的题,读完题目的瞬间会突然产生一种感觉:
“等等……这个问题的骨架,我好像见过。”
那一刻,才是刷题真正开始产生复利的地方。