先记住这个答案
数组长度为 n 时,合法下标是 0 到 n - 1,写成区间就是左闭右开的 [0, n)。所以 for (let i = 0; i < arr.length; i++) 从 0 开始、到 n 之前停下,正好 n 次。写成 i <= arr.length 会多访问一次 arr[n],拿到 undefined;写成 i < arr.length - 1 会少一次,漏掉最后一项。i <= arr.length - 1 和 i < arr.length 是等价的,不算错,只是多绕了一步。真正要记住的是:起点、终点和循环体里访问的下标要对得上。
- 下标范围是
[0, length),左闭右开 i <= length多一次,读到undefined- 比较相邻两项时,
i < length - 1才是对的 - 边遍历边删除,要倒着遍历或者改用
filter
多一次和少一次各是什么结果
偏一错误(off-by-one)指循环次数刚好多了一次或少了一次,是最常见的边界 bug。用求和看最直观。
const arr = [10, 20, 30]
let a = 0
for (let i = 0; i <= arr.length; i++) a += arr[i] // 多读一次 arr[3]
console.log('i <= length', a)
let b = 0
for (let i = 0; i < arr.length - 1; i++) b += arr[i] // 少了最后一项
console.log('i < length - 1', b)
let c = 0
for (let i = 0; i < arr.length; i++) c += arr[i]
console.log('i < length', c)查看输出与解释
i <= length NaN
i < length - 1 30
i < length 60多一次的情况,arr[3] 是 undefined,于是 60 + undefined 得到 NaN,整个结果都废了。如果循环体里是 arr[i].name,就会直接抛 TypeError。少一次的情况更隐蔽,不报错,只是结果不对,要靠测试数据里最后一项有特殊值才能发现。
i < length 这种写法之所以成为习惯,是因为 length 本身就是个数,「从 0 数 n 个」写出来就是 i < n。像 slice(start, end)、substring 也都是左闭右开,风格统一,记一种就够了。
什么时候 length - 1 反而是对的
终点条件要看循环体访问了什么。循环体里用到 arr[i + 1] 时,最后一个 i 必须是 length - 2,否则 arr[i + 1] 就越界了。
const nums = [1, 3, 2, 5]
for (let i = 0; i < nums.length - 1; i++) {
// 最后一项没有下一项,所以终点往前收一格
if (nums[i] > nums[i + 1]) console.log('逆序', nums[i], nums[i + 1])
}查看输出与解释
逆序 3 2这里如果写 i < nums.length,最后一轮会比较 5 > undefined,结果是 false,碰巧不报错,但换成别的逻辑就会出问题。空数组时 length - 1 是 -1,而 0 < -1 不成立,循环一次都不跑,也是安全的。
- 倒序遍历的边界
- 倒序时起点是
length - 1,条件是i >= 0。常见错误是写成i > 0,漏掉下标 0 的第一项。
边遍历边删除为什么会漏项
条件 i < arr.length 每轮都会重新读 length。如果循环里删了元素,后面的元素整体前移,下标就和元素对不上了。
splice 跳过了相邻的重复项JavaScriptconst list = [1, 2, 2, 3]
for (let i = 0; i < list.length; i++) {
if (list[i] === 2) list.splice(i, 1) // 删掉后,下一个 2 移到了当前位置
}
console.log(JSON.stringify(list))查看输出与解释
[1,2,3]i 为 1 时删掉第一个 2,第二个 2 移到了下标 1,但下一轮 i 已经是 2,直接跳过了它。修法是倒着遍历,从 length - 1 往 0 走,删掉的元素只影响已经处理过的位置;或者干脆用 list.filter((x) => x !== 2) 生成新数组。
容易答错的地方
- 说
i <= length - 1是错的 - 它和
i < length完全等价,只要长度不变、下标是整数就没问题。面试官想听的不是哪种写法「标准」,而是你能说清边界为什么对。 - 说把
length缓存到变量里一定更快 - 现代引擎对
arr.length的读取已经优化得很好,缓存的性能收益基本可以忽略。反而如果循环里会增删元素,缓存的长度就过期了,可能导致越界或漏项。
面试官还会怎么问?
二分查找里 left <= right 和 left < right 怎么选?
取决于你把搜索范围定义成闭区间还是左闭右开。right = length - 1 表示闭区间 [left, right],条件用 left <= right;如果 right = length,表示 [left, right),条件用 left < right,缩小时写 right = mid。区间定义和条件一致就不会错。
有没有不用管下标边界的写法?
for...of 直接遍历值,forEach、map 由数组自己控制次数。需要下标时可以写 for (const [i, v] of arr.entries())。只有需要跳着走、倒着走或者提前修改下标时,才需要手写 for 循环。
循环里 push 新元素会怎样?
条件每轮都重新读 length,新 push 的元素也会被遍历到。如果每轮都 push,循环就永远不会结束。forEach 不一样,它在开始时就定好了遍历范围,中途 push 的元素不会被访问。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。