数组基础与常用操作

二维数组的花式遍历

这类题的共同点

旋转、螺旋、对角线看起来是三件事,但解法都归结为同一句话: 找到那个坐标关系,剩下的是两层循环。

难的从来不是写循环,是想清楚「哪些格子该被同等对待」。

顺时针旋转 90°:先转置,再左右翻转

直接推每个元素的新位置容易绕晕。拆成两步就不用想了:

function rotate(matrix) {
  const n = matrix.length;

  // 第一步:沿主对角线转置(行列互换)
  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {           // 🚨 j 从 i+1 开始
      [matrix[i][j], matrix[j][i]] = [matrix[j][i], matrix[i][j]];
    }
  }

  // 第二步:每一行左右翻转
  for (const row of matrix) row.reverse();
}

🚨 j 必须从 i + 1 开始,不能从 0。从 0 开始的话每一对都会被交换两次, 转置被自己抵消了。

⚠️ 但结果不是「矩阵原封不动」 —— 第二步的行反转照常执行, 所以你拿到的是一个水平镜像:

[[1,2,3],          正确(顺时针 90°)   [[7,4,1],      j 从 0 开始   [[3,2,1],
 [4,5,6],     →                         [8,5,2],   →                 [6,5,4],
 [7,8,9]]                               [9,6,3]]                     [9,8,7]]
                                                                  ↑ 只做了行反转

🚨 这个区别很重要:结果看起来确实变了,所以不会有「代码没执行」的警觉, 只会觉得「转出来的方向不太对」,容易往「是不是该逆时针」上面去查。

⚠️ n = 1 时两种写法结果相同,n = 2 才看得出来。各规模的暴露率:

n         1      2      3      4      5
暴露率   0.0%  66.5%  96.2%  99.9%  100%

📌 逆时针 90° 只要把第二步换成上下翻转(matrix.reverse())。 两个方向的差别就这一处,不用记两套。

螺旋遍历:四个边界向内收

function spiralOrder(matrix) {
  if (matrix.length === 0) return [];
  let top = 0, bottom = matrix.length - 1;
  let left = 0, right = matrix[0].length - 1;
  const res = [];

  while (top <= bottom && left <= right) {
    for (let j = left; j <= right; j++) res.push(matrix[top][j]);      // →
    top++;

    for (let i = top; i <= bottom; i++) res.push(matrix[i][right]);    // ↓
    right--;

    // 🚨 这两个判断不能省
    if (top <= bottom) {
      for (let j = right; j >= left; j--) res.push(matrix[bottom][j]); // ←
      bottom--;
    }
    if (left <= right) {
      for (let i = bottom; i >= top; i--) res.push(matrix[i][left]);   // ↑
      left++;                                                          // 🚨 是 left++
    }
  }
  return res;
}

这题有两个独立的坑,而且它们要用不同形状的矩阵才测得出来。

坑一:中间那两个 if 不能省

走完「上边」和「右边」之后,边界可能已经收缩到重叠了; 不判断就直接走「下边」,会把刚走过的那一行再走一遍:

[[1,2,3]]        单行   不判断 → [1,2,3,2,1]   多了两个
[[1],[2],[3]]    单列   不判断 → [1,2,3,2]     多了一个
[[1,2],[3,4]]    2×2    不判断 → [1,2,4,3]     ✅ 恰好正确

📌 只用方阵测,这个坑暴露不出来。 单行和单列是必测用例。

🚨 坑二:最后一步是 left++,不是 top++

四条边分别收缩四个边界:上边收 top、右边收 right、 下边收 bottom、左边收 left。

写成 top++ 的话,top 在一轮里被加了两次, 于是奇数边长矩阵的最中心那个元素会被跳过:

[[1,2,3],[4,5,6],[7,8,9]]
  正确    → [1,2,3,6,9,8,7,4,5]
  top++   → [1,2,3,6,9,8,7,4]     ← 少了中心的 5

⚠️ 这个坑恰好躲过了上面那三个测试用例 —— 单行、单列、2×2 全都正确, 因为它们没有「中心」。要 3×3 才暴露。

⭐ 两个坑加起来的教训:这道题至少要测 单行 + 单列 + 3×3。 把两种错法逐个喂给这四组用例,抓到的情况是:

                        单行   单列   2×2   3×3
坑一(省掉 if)           ✅     ✅     ❌    ❌
坑二(写成 top++)        ❌     ❌     ❌    ✅

⚠️ 注意 2×2 一个坑都抓不到。它在这里的作用不是「抓错」, 而是当反面教材 —— 说明为什么「我拿方阵测过了」不能让人放心。 👉 真正互补的是「非方阵」(单行、单列)和「奇数边长」(3×3)这两个维度。

对角线遍历:i + j 和 i - j 是常数

这是二维题里最好用的一个观察:

  • 副对角线方向(↙↗)上,i + j 是常数
  • 主对角线方向(↘↖)上,i - j 是常数

于是「按对角线分组」只要拿 i + j 当 key:

function diagonalGroups(matrix) {
  const groups = new Map();
  for (let i = 0; i < matrix.length; i++) {
    for (let j = 0; j < matrix[0].length; j++) {
      const k = i + j;
      if (!groups.has(k)) groups.set(k, []);
      groups.get(k).push(matrix[i][j]);
    }
  }
  return [...groups.keys()].sort((a, b) => a - b).map((k) => groups.get(k));
}

⭐ 判断棋盘上两个皇后是否互相攻击,用的也是这一条: 同一条对角线 ⇔ i+j 相等或 i-j 相等。 N 皇后的剪枝就是这么写的。

矩阵置零:原地标记的经典陷阱

题意:某个格子是 0,就把它所在的整行整列都置零。

🚨 最直觉的写法是错的 —— 边遍历边置零,产生的新 0 会被后面的遍历读到、 当成原本就存在的 0,于是清零像传染病一样扩散:

[[1,1,1],          正确      [[1,0,1],        边扫边置零   [[1,0,0],
 [1,0,1],    →                [0,0,0],    →                [0,0,0],
 [1,1,1]]                     [1,0,1]]                     [0,0,0]]
                              4 个 1                        只剩 1 个

⚠️ 注意左上角那个 1 活下来了 —— 因为遍历是从左上往右下走的, 污染只会往「还没扫到」的方向扩散。所以症状不是「全变 0」而是 「左上角残留一小块」,看起来更像是某个边界条件写错了。

正确做法是先记录、后统一处理:

function setZeroes(matrix) {
  const m = matrix.length, n = matrix[0].length;
  const rows = new Set(), cols = new Set();

  for (let i = 0; i < m; i++)
    for (let j = 0; j < n; j++)
      if (matrix[i][j] === 0) { rows.add(i); cols.add(j); }

  for (let i = 0; i < m; i++)
    for (let j = 0; j < n; j++)
      if (rows.has(i) || cols.has(j)) matrix[i][j] = 0;
}

⭐ 这个「先扫描收集,再统一修改」的两遍模式,是所有 「原地修改、但新值会干扰后续判断」问题的通解。 同一个套路在封闭岛屿那题里也出现过 —— 先淹掉边界上的岛,再统计。

📌 空间可以优化到 O(1)(借第一行第一列当标记位),但那一版的边界处理很啰嗦。 面试里先写 O(m+n) 这版、再口头说明能优化到 O(1),是更稳的打法。

练习

勾选记录做过哪些,0 / 5 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。