我们现在要开始做题啦!

万里长征第一步,仍然是数组。 单纯针对数组来考察的题目,总体来说,都不算太难——数组题目要想往难了出,基本都要结合排序、二分和动态规划这些相对复杂的算法思想才行。

咱们本节要解决的正是这一类“不算太难”的数组题目——并不是只有难题才拥有成为真题的入场券,一道好题不一定会难,它只要能够反映问题就可以了。

本节所涉及的题目在面试中普遍具有较高的出镜率、同时兼具一定的综合性,对培养大家的通用解题能力大有裨益 。

相信这节你会学得很开心,在轻松中收获自己的第一份算法解题锦囊。

# Map 的妙用——两数求和问题

30 秒速记

  • 暴力两层循环 O(n²);用 Map 记「值 → 下标」,变成一次遍历 O(n)、空间 O(n)
  • 求和转求差:看到 x 就去查 target - x 出现过没有
  • 顺序必须是先查后存,Map 里只放当前下标之前的数,才不会自己配自己
  • 用 Map.has() 判存在,别用对象加 if (obj[k]),下标 0 是假值
  • [3, 3] 凑 6 是合法的,两个不同位置就行

两数之和的套路是拿空间换时间:一边遍历一边用 Map 记下见过的数和下标,每到一个数就去查它的「另一半」在不在。 比如 nums = [2, 7, 11, 15]、target = 9,到 7 时查 9 - 7 = 2,Map 里有,下标 0,直接返回 [0, 1]。顺序很关键,一定是先查再存,不然 [3, 3] 找 6 时第一个 3 会和自己配对。时间从 O(n²) 降到 O(n),代价是 O(n) 的额外空间。

哈希解法的不变量是:进入第 i 轮时,seen 只保存区间 [0, i) 的值及下标。因此命中补数时,它一定来自不同元素;未命中才写入当前值。原文使用对象并用 !== undefined 判断,会把“索引值是否存在”和“属性值是否为 undefined”混在一起,使用 Map.has() 更直接。

function twoSum(nums, target) {
  const seen = new Map()
  for (let i = 0; i < nums.length; i += 1) {
    const need = target - nums[i]
    if (seen.has(need)) return [seen.get(need), i]
    seen.set(nums[i], i)
  }
  return []
}
@前端进阶之旅: 代码已经复制到剪贴板

每个元素最多查询和写入一次,期望时间 O(n)、额外空间 O(n)。若要求全部不重复下标组合,Map<number, number> 不够,需要保存每个值的下标列表或采用另一套去重契约;若输入含浮点数,还要先确认能否直接用精确相等比较。

真题描述: 给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。

你可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素。

示例:

给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9 所以返回 [0, 1]
@前端进阶之旅: 代码已经复制到剪贴板

面试官追问

追问 1为什么用 Map 而不是普通对象 {}?
参考回答

对象的键会被转成字符串,还可能撞上 toString 这类原型属性。Map 键保留原始类型,has、get、set 语义也清楚,算法题我默认用它。

追问 2代码里写 if (seen[need]) 判断命中,哪里有坑?
参考回答

下标 0 是假值,补数正好在第一个位置时会被判成没找到。改成 seen.has(need),或者至少 seen[need] !== undefined。

追问 3题目改成要返回所有满足条件的下标对,还能一次遍历吗?
参考回答

能遍历一次,但 Map 要改成 Map<number, number[]>,每个值存所有出现过的下标,命中时和每一个都配一对。注意结果本身可能有 O(n²) 对,比如全是同一个数,输出成本就不是线性了。

追问 4金额是 0.1、0.2,目标 0.3,为什么永远找不到?
参考回答

0.3 - 0.1 在浮点里等于 0.19999999999999998,和 Map 里的 0.2 对不上,哈希只认精确相等。金额先换成分的整数再做,别临时加个 epsilon。

追问 5数组已经有序了,还用 Map 吗?
参考回答

有序就用左右指针:和小了左指针右移,大了右指针左移,O(n) 时间、O(1) 空间,比 Map 省内存。

# 思路分析:

30 秒速记

  • 看到两层循环先想:能不能用空间换时间压成一层
  • 求和变求差:Map 存「已见过的值 → 下标」,查 target - nums[i]
  • 先查补数再写当前值,命中返回两个下标
  • 平均 O(n) 时间、O(n) 空间
  • 对象模拟时用 !== undefined 判断,更推荐直接 Map.has()

这题我会把「找两个数加起来等于 target」换成「对每个数,查它差的那个数之前出现过没有」。 遍历时 Map 里只放当前位置之前的数,所以要先查 target - nums[i],没查到再把自己放进去,这样同一个元素不会被用两次。双层循环是 O(n²),数据上万就开始明显慢;Map 版一趟走完,O(n),多花的就是一张表的内存。教程里用对象写也能过,但判断要写成 !== undefined,我更习惯直接用 Map.has(),省得被下标 0 坑。

一个“淳朴”的解法

这道题相信很多同学看一眼就很快能得出一个最基本的思路:两层循环来遍历同一个数组;第一层循环遍历的值记为 a,第二层循环时遍历的值记为 b;若 a+b = 目标值,那么 a 和 b 对应的数组下标就是我们想要的答案。

对“淳朴”解法的反思

大家以后做算法题的时候,要有这样的一种本能:当发现自己的代码里有两层循环时,先反思一下,能不能用空间换时间,把它优化成一层循环。

因为两层循环很多情况下都意味着 O(n^2) 的复杂度,这个复杂度非常容易导致你的算法超时。即便没有超时,在明明有一层遍历解法的情况下,你写了两层遍历,面试官对你的印象分会大打折扣。

空间换时间,Map 来帮忙

拿我们这道题来说,其实二层遍历是完全不必要的。

大家记住一个结论:几乎所有的求和问题,都可以转化为求差问题。 这道题就是一个典型的例子,通过把求和问题转化为求差问题,事情会变得更加简单。

我们可以在遍历数组的过程中,增加一个 Map 来记录已经遍历过的数字及其对应的索引值。然后每遍历到一个新数字的时候,都回到 Map 里去查询 targetNum 与该数的差值是否已经在前面的数字中出现过了。若出现过,那么答案已然显现,我们就不必再往下走了。

我们以 nums = [2, 7, 11, 15] 这个数组为例,来模拟一下这个思路:

第一次遍历到 2,此时 Map 为空:

以 2 为 key,索引 0 为 value 作存储,继续往下走;遇到了 7:

计算 targetNum 和 7 的差值为2,去 Map 中检索 2 这个 key,发现是之前出现过的值:

那么 2 和 7 的索引组合就是这道题的答案啦。

键值对存储我们可以用 ES6 里的 Map 来做,如果图省事,直接用对象字面量来定义也没什么问题

编码实现

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
const twoSum = function(nums, target) {
    // 这里我用对象来模拟 map 的能力
    const diffs = {}
    // 缓存数组长度
    const len = nums.length
    // 遍历数组
    for(let i=0;i<len;i++) {
        // 判断当前值对应的 target 差值是否存在(是否已遍历过)
        if(diffs[target-nums[i]]!==undefined) {
            // 若有对应差值,那么答案get!
            return [diffs[target - nums[i]], i]
        }
        // 若没有对应差值,则记录当前值
        diffs[nums[i]]=i
    }
};
@前端进阶之旅: 代码已经复制到剪贴板

tips:这道题也可以用 ES6 中的 Map 来做,你试试呢?

面试官追问

追问 1候选人写了双层循环,说数据量不大不用优化,你会怎么接?
参考回答

我会问他数据涨到 10 万条会怎样:n² 是 100 亿级的比较,直接超时。能一层解决的写两层,面试里印象分会掉,至少要说出 Map 的思路。

追问 2[3, 3] 找 6,代码先 set 当前值再查补数,会返回什么?
参考回答

第一轮存了 3 → 0,马上查 3 就命中自己,返回 [0, 0]。顺序换成先查后存,第二轮才会返回正确的 [0, 1]。

追问 3要返回所有组合,但现在是 Map<number, number>、命中就 return,改哪两处?
参考回答

一是值改成下标数组,不然同值的旧下标会被覆盖;二是命中后别 return,把组合收集起来继续走。

追问 4用对象模拟 Map,日志里补数明明存在却没命中,查什么?
参考回答

先看判断是不是写成了真假值判断,下标 0 会被吞掉。再看键是不是被字符串化后和别的东西撞了,比如输入里混进了字符串 '2' 和数字 2,在对象里是同一个键。

追问 5数组已经按价格排好序,只问有没有一对等于预算,选 Map 还是双指针?
参考回答

双指针,O(1)