Skip to content

28. 陷阱大冒险(常见错误大全)

知识点:全书常见错误总结 —— 新手必踩的坑 + 老手也会翻的车

用法:这是你的"错题本"。平时遇到报错来这查;考试前通读一遍; 每道错题都标了【新手必踩】或【老手也翻车】——后者最阴险, 因为它们跑起来"看起来没问题",结果却是错的。


故事开场:陷阱区开园了

游乐园新开了一个区域,叫陷阱区。地上插着 20 面小旗, 每面旗下面都埋着一个"代码陷阱"。老师说:

"这些陷阱,都是我和很多程序员朋友亲身踩过的坑。 有的是新手必踩的(下标、等号),有的连写了十几年程序的老手 也会不小心翻车(浮点、边界、引用)。

考试之前,来这里逛一圈——把每个坑都认一遍,考试就不怕了。"

你深吸一口气,踏进了陷阱区。第一面旗上写着:


一、数数陷阱(下标与边界)

坑位 1:下标从 0 开始【老手也翻车】

ts
const names = ["小明", "小红", "小刚"]

console.log(names[1])     // "小红"!(不是"小明"!)

后果names[1] 是第二个元素——从 0 数起,第 1 个是 names[0]。 连写了 30 年程序的大神也会栽在这上面,这可能是计算机史上 被踩得最多的坑(它有个专门的名字,叫 off-by-one,差一位)。

正确:记住口诀——第 k 个元素,下标是 k−1

回扣第 2 章《点名器》:"计算机从 0 开始数"。

坑位 2:循环边界【新手必踩】

ts
const names = ["小明", "小红", "小刚"]

// 想遍历 3 个名字……
for (let i = 1; i <= names.length; i++) {
  console.log("第 " + i + " 个是:" + names[i])
}

输出:

第 1 个是:小红
第 2 个是:小刚
第 3 个是:undefined

后果:从 1 开始、数到 <= length——跳过了第 1 个,还多读了 一个不存在的(越界!读出来是 undefined)。三个名字,两个读错。

正确for (let i = 0; i < names.length; i++)——从 0 数到 "小于长度"为止,不多不少。

回扣第 2、4 章。写循环的三件套:从 0 起、小于 length、i++。

坑位 3:二分查找的边界【老手也翻车】

ts
// 一不小心把 low <= high 写成了 low < high
function binarySearchShort(list: number[], target: number): number {
  let low = 0
  let high = list.length - 1
  while (low < high) {            // ← 少了一个等号!
    const mid = Math.floor((low + high) / 2)
    if (list[mid] === target) { return mid }
    else if (list[mid] < target) { low = mid + 1 }
    else { high = mid - 1 }
  }
  return -1
}

console.log(binarySearchShort([1, 2, 3], 3))   // -1?!

后果low < high 会让查找漏掉最后一个元素——明明 3 就在 数组里,却返回 -1。这类边界 bug 极难发现:大部分数据能查对, 只有"边界数据"(第一个、最后一个、恰好相等的)才露馅。

正确while (low <= high)——lowhigh 重合时那个 元素也要查。

回扣第 11 章《猜数大师》。二分查找的边界是教科书级的 "老手翻车点"——写完之后,专门用"第一个、最后一个、不存在的" 三种数据测一遍。


二、等号与比较

坑位 4:====【新手必踩】

ts
let score = 99

if (score = 100) {       // ← 一个等号是"赋值"!不是"比较"!
  console.log("满分!")
}
console.log("score 变成了:" + score)   // 100!

后果= 是"把右边的值装进左边的变量";==/=== 才是"比较"。 if (score = 100) 先把 score 变成 100,条件恒为真——程序"永远满分", 还悄悄改掉了你的数据。

正确:比较用 ===(三个等号,TypeScript 的推荐写法)。

回扣第 3、12 章。记法:一个等号是"命令",三个等号是"提问"。

坑位 5:字符串比大小不是比数字【老手也翻车】

ts
console.log("10" < "9")    // true?!

后果:字符串比较逐字比编号(还记得第 11 章的"字的编号"吗): 先比第 1 个字符,"1" 的编号比 "9" 小 → 所以 "10" < "9" 成立! 给成绩排序时如果忘了转数字,90 分会排到 9 分前面——成绩单全乱。

正确:比数字前先转成数字:Number("10") < Number("9")

回扣第 11 章。字符串能比较,但不是你以为的那种比较。


三、变量与引用

坑位 6:交换忘了"第三只手"【新手必踩】

ts
let a = 5
let b = 10

a = b        // a 变成 10
b = a        // b 变成 10——a 原来的 5 已经丢了!
console.log("a=" + a + " b=" + b)   // a=10 b=10

后果:交换两个变量必须用临时变量(第三只手)端住一个, 否则第一个值被覆盖,两个变量都变成同一个值。

正确const temp = a; a = b; b = temp——第 12 章的 swap 函数就是这么干的。

回扣第 12 章《泡泡排队》。

坑位 7:覆盖 ≠ 交换【老手也翻车】

ts
const heights = [162, 150, 175]

heights[0] = heights[1]    // 想"把 150 放到最前面"?
console.log(heights)       // [150, 150, 175]——162 神秘失踪!

后果heights[0] = heights[1] 是"覆盖"——把 150 写进第 0 格, 原来的 162 被直接抹掉,谁都不记得它了。往一个位置放东西前, 先问自己:这个位置原来的东西还要不要?要,就交换;不要,才覆盖。

正确:交换(swap)。

回扣第 13 章《挑最小的》。

坑位 8:数组是"同一个柜子"【老手也翻车】

ts
const arr1 = [1, 2, 3]
const arr2 = arr1        // "复制"一份?

arr2[0] = 99
console.log(arr1)        // [99, 2, 3]??连 arr1 都变了!

后果const arr2 = arr1 不是复制!它只是给同一个柜子 起了第二个名字(这叫"引用")。改 arr2 = 改 arr1。 往函数里传数组也一样——函数里改数组,外面的数组也会变 (还记得第 12 章的排序为什么"改了原数组"吗)。

正确:真想复制,用 arr1.slice()(切一整段,就是整份副本) 或 arr1.concat([])

回扣第 2、13、16 章。判断口诀:变量装的是"柜子本身" 还是"柜子的门牌号"?数字和字符串是"本身"(复制安全), 数组和对象是"门牌号"(共享柜子)。

坑位 9:字符串改不动【新手必踩】

ts
const word = "放学后一起玩游戏"

word[0] = "上"        // 想改第一个字?
console.log(word)     // 还是"放学后一起玩游戏",什么都没发生

后果:字符串不可变——不能"拆开改其中一个字",只能 重新造一个。这一行"什么都没做",程序也不报错,特别迷惑人。

正确:想改,就拼接新字符串:"上" + word.slice(1)

回扣第 3 章《密信》。


四、循环与递归

坑位 10:无限循环【新手必踩】

ts
let i = 0
while (i < 5) {
  console.log("★")
  // 忘了写 i++!
}

后果i 永远小于 5,循环永远不结束——程序卡死,风扇狂转。 (while 里的条件永远成立 = 永远循环,只能按 Ctrl+C 强制停止。)

正确:写循环前先问自己:它真的会停下来吗?(i 在变小吗? 条件有一天会不成立吗?)

回扣第 4 章《会重复的小助手》。

坑位 11:递归忘了出口【新手必踩】

ts
function factorial(n: number): number {
  return n * factorial(n - 1)      // ← 出口(if n <= 1)被删了!
}

console.log(factorial(5))

后果:程序崩溃,报错 RangeError: Maximum call stack size exceeded (调用栈溢出)——还记得第 6 章的"栈"吗?函数调用也要"记账", 记到栈满了就崩。

正确先写出口,再写递归。出口是递归的刹车。

回扣第 15 章《套娃》。

坑位 12:回溯忘了撤销【老手也翻车】

ts
// 迷宫程序里,忘了写 maze[x][y] = 0(擦掉脚印)——
// 程序竟然还能找到出口!
// 可把"数一数有几条路"改成它,答案永远是 1 条。

后果:这是最阴险的 bug——程序照常运行、结果看着也对, 只有换一种问法(找所有路、数独、八皇后)才露馅:答案悄悄变少。

正确:回溯三步缺一不可:标记 → 尝试 → 撤销标记。 "撤销"才是回溯和普通递归的区别。

回扣第 23 章《迷宫探险》练习 2。


五、数字的诡计

坑位 13:0.1 + 0.2 不等于 0.3【老手也翻车】

ts
console.log(0.1 + 0.2)                // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3)        // false!

后果:小数在电脑里是"近似值"(浮点数),算出来差一点点。 用 === 比小数,经常"明明相等却说不相等"。

正确:比小数差用"差一点点就认":Math.abs(a - b) < 0.000001; 算钱用"分"为单位(整数!),别用"元"(小数)。

回扣第 1 章《谁算得快》的"小发现"。

坑位 14:大数累加,误差越攒越多【老手也翻车】

ts
let total = 0
for (let i = 1; i <= 1000000000; i++) {
  total = total + i
}
console.log(total)    // 500000000067109000?正确答案是 500000000500000000!

后果:电脑存大数是近似值,累加 10 亿次,误差攒了 4 亿多。 "每次错一点点"不可怕,"错了很多次"才可怕。

正确:能用公式一步算,就别循环 10 亿次;大数用专门工具(BigInt)。

回扣第 1 章《谁算得快》——这本书的第一个彩蛋!

坑位 15:随机数忘了取整【新手必踩】

ts
const index = Math.random() * 3        // 0~3 之间的小数,比如 2.57
console.log(names[index])              // undefined!

后果Math.random() 返回 0~1 的小数,乘 3 还是小数。 数组下标必须整数——names[2.57] 不存在,取到 undefined

正确Math.floor(Math.random() * names.length)——先乘, 再向下取整。这个"取整"忘了写,点名器会随机点到"空气"。

回扣第 2 章《点名器》。


六、算法专坑

坑位 16:二分查找的前提【老手也翻车】

ts
const shuffled = ["小明", "小红", "小刚", "小雨", "小丽"]   // 乱序!

console.log(binarySearch(shuffled, "小丽"))   // -1?她明明在名单里!

后果:二分查找的铁规矩是数据必须排好序——乱序数据上 用二分,程序"自信地排除一半",把正确的半边扔了,结果错得 悄无声息(不报错,就是找不到)。

正确:二分前先确认排好序;没排好,先排序(第 12~14 章三兄弟 或第 16 章快排)。

回扣第 11 章。算法的前提条件,和算法本身一样重要。

坑位 17:贪心不找反例【新手必踩】

ts
// 硬币 1/3/4,凑 6 元——贪心:"每次拿最大的"→ 4+1+1 = 3 枚
// 最优:3+3 = 2 枚。贪心输了!

后果:"每次都拿最大的"听起来天经地义,可眼前最优 ≠ 全局最优。 直接宣布"贪心就是最优"而不找反例,是新手最常犯的算法错误。

正确:用贪心前,先试着找反例(第 24 章的"反例探测器"); 找不到反例再用,找到就换动态规划。

回扣第 24 章《先拿再说》。

坑位 18:排序方向【新手必踩】

ts
if (list[i] < list[i + 1]) {    // 想从大到小?还是从小到大?
  swap(list, i, i + 1)
}

后果:一个 <> 之差,排序方向完全反过来——成绩单 从"学霸在前"变成"学渣在前"。而且程序不报错,看起来还很正常。

正确:写完排序,立刻用一组已知数据验证[3, 1, 2] 排完 必须是 [1, 2, 3](或反方向)。永远不要相信"没报错 = 写对了"。

回扣第 12~14 章。

坑位 19:BFS 忘了"记过的人"【老手也翻车】

ts
// 地铁图 BFS 里忘了 visited:
// 家 → 公园 → 家 → 公园 → 家……

后果:图里有环(A 能到 B,B 能到 A),不记"去过哪", 程序会在两个站之间原地打转,队列越来越长,直到卡死。 (这个 bug 的翻车率和二分边界并列前茅。)

正确:入队一个站,马上把它记进 visited(不是出队时记! 出队时记还是会重复入队)。

回扣第 26 章《地铁线路图》。搜图三件套:队列、visited、入队即记。

坑位 20:Map 取不到东西【老手也翻车】

ts
const counts = new Map<string, number>()
counts.set("好", 3)

const old = counts.get("啊")       // 没有"啊"这个 key
console.log(old + 1)               // NaN(不是一个数)!

后果Map.get 找不到 key 时返回 undefinedundefined + 1NaN——统计字数时一个没见过的字,直接让整个统计变 NaN, 后面全错。而且程序不报错!

正确:取之前先问:if (old === undefined) { 第一次见到 }—— 这正是第 8 章统计字数的写法。

回扣第 8 章《闪电查找》。


错题本使用守则

  1. 考试前:通读一遍,把每道坑的"后果"读出声——踩过坑的人 考试时一眼就能认出陷阱
  2. 报错时:来查"现象"——崩溃了查 11、卡死了查 10/19、 答案不对查 2/3/5/14/16/18、看不出问题查 12/17/20
  3. 写完程序:对照守则自查——边界数据测了吗?前提满足吗? 撤销写了吗?visited 记了吗?

最后一句忠告:"没报错"不等于"写对了"——这本书里一半的坑, 跑起来都"看着很正常"。真正的老手,不是不犯错, 而是专门去测试"会翻车的地方"


小挑战

  1. 找 bug(第 1 题):下面的代码想找最高分,错在哪?怎么修?
ts
const scores = [92, 78, 65]      // 最高分 92 在第一个
let best = 0
for (let i = 1; i < scores.length; i++) {   // ← 从 1 开始?
  if (scores[i] > best) {
    best = scores[i]
  }
}
console.log("最高分:" + best)    // 78?!92 被跳过了!
  1. 预测输出:不看电脑,猜猜下面的程序各输出什么, 再运行验证:① "5" + 3console.log([10, 9, 100].sort())0.3 - 0.1 === 0.2。(提示:② 有惊喜——数组的 sort() 默认按什么排?回想坑位 5!)
  2. 给老手出题:把坑位 2、3、14 的"错误代码"各抄一遍, 给同桌做"找 bug"测试——看他能不能都找出来。

课后练习

第 1 题(动手题):修点名器

下面这个点名器有三个 bug(对应坑位 2、15、1),全部修好:

ts
const names = ["小明", "小红", "小刚", "小雨"]

for (let i = 1; i <= names.length; i++) {
  console.log("第 " + i + " 位同学:" + names[i])
}

const index = Math.random() * names.length
console.log("随机点到:" + names[index])
参考答案(点开查看)

三个 bug:

ts
// 坑位 1+2:从 0 开始、小于 length(这样不会跳过第一个,也不会越界)
for (let i = 0; i < names.length; i++) {
  console.log("第 " + (i + 1) + " 位同学:" + names[i])
}

// 坑位 15:先乘,再向下取整
const index = Math.floor(Math.random() * names.length)
console.log("随机点到:" + names[index])

验证:遍历应输出 4 个名字(最后一个不是 undefined); 随机点名跑 10 次,永远点到"真实存在"的同学。

第 2 题(思考题):哪里错了?

运行下面的代码,说出结果哪里不对劲,并解释为什么:

ts
const a = [1, 2, 3]
const b = a
b.push(4)
console.log("a 现在是:" + a)
参考答案(点开查看)

输出 a 现在是:1,2,3,4——a 也被加了 4

因为 const b = a 不是复制,而是给同一个数组起了第二个名字 (坑位 8:数组是"门牌号")。b.push 改的是那个柜子本身。

想复制:const b = a.slice()。想确认:console.log(b === a)—— true 说明它俩是同一个柜子。

第 3 题(思考题):沉默的错误

坑位 12、17、20 有一个共同点:程序不报错,结果却是错的。 为什么这类错误比"崩溃"更可怕?你打算怎么防住它们?

参考答案(点开查看)

"崩溃"至少会喊救命(报错信息告诉你位置);"沉默的错误" 悄悄给出错误答案,你可能一直不知道——比如排行榜少统计一个人、 背包方案不是最优、找路少算一条——等你发现时,数据已经用了很久。

防线(三招):

  1. 边界数据测试:空数组、第一个、最后一个、恰好相等—— 坑位 3/2 都藏在边界里
  2. 反例思维:新算法先找反例(坑位 17),别信直觉
  3. 交叉验证:两种方法算同一件事(贪心 vs 回溯、循环 vs 递归), 答案一致才放心——第 24、25 章的"反例探测器"就是这么干的

"没报错"是及格线,"验证过"才是优秀。


全书完!

27 章 + 这本错题本,《小探险家学算法》到这里就全部结束了。 你已经拥有了完整的算法工具箱,也认清了路上所有的陷阱。

下一站,是你自己的作品——去写点什么吧! 后会有期,小探险家!