刷 Hot 100 前,先学会这六种「看问题的方式」

4 阅读23分钟

如果你刚开始系统刷算法题,很容易产生一种错觉:

两数之和是一道题,最长子串是另一道题,二分搜索又是另一道题。
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”。

接下来只做一件事情:

每做一道题,都试着看见它背后的那一种结构。

第一遍也许看不见。

第二遍开始模糊。

第三遍某一天,你打开一道从没做过的题,读完题目的瞬间会突然产生一种感觉:

“等等……这个问题的骨架,我好像见过。”

那一刻,才是刷题真正开始产生复利的地方。