Appearance
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)——low 和 high 重合时那个 元素也要查。
回扣第 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 时返回 undefined,undefined + 1 是 NaN——统计字数时一个没见过的字,直接让整个统计变 NaN, 后面全错。而且程序不报错!
正确:取之前先问:if (old === undefined) { 第一次见到 }—— 这正是第 8 章统计字数的写法。
回扣第 8 章《闪电查找》。
错题本使用守则
- 考试前:通读一遍,把每道坑的"后果"读出声——踩过坑的人 考试时一眼就能认出陷阱
- 报错时:来查"现象"——崩溃了查 11、卡死了查 10/19、 答案不对查 2/3/5/14/16/18、看不出问题查 12/17/20
- 写完程序:对照守则自查——边界数据测了吗?前提满足吗? 撤销写了吗?visited 记了吗?
最后一句忠告:"没报错"不等于"写对了"——这本书里一半的坑, 跑起来都"看着很正常"。真正的老手,不是不犯错, 而是专门去测试"会翻车的地方"。
小挑战
- 找 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 被跳过了!- 预测输出:不看电脑,猜猜下面的程序各输出什么, 再运行验证:①
"5" + 3②console.log([10, 9, 100].sort())③0.3 - 0.1 === 0.2。(提示:② 有惊喜——数组的sort()默认按什么排?回想坑位 5!) - 给老手出题:把坑位 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 有一个共同点:程序不报错,结果却是错的。 为什么这类错误比"崩溃"更可怕?你打算怎么防住它们?
参考答案(点开查看)
"崩溃"至少会喊救命(报错信息告诉你位置);"沉默的错误" 悄悄给出错误答案,你可能一直不知道——比如排行榜少统计一个人、 背包方案不是最优、找路少算一条——等你发现时,数据已经用了很久。
防线(三招):
- 边界数据测试:空数组、第一个、最后一个、恰好相等—— 坑位 3/2 都藏在边界里
- 反例思维:新算法先找反例(坑位 17),别信直觉
- 交叉验证:两种方法算同一件事(贪心 vs 回溯、循环 vs 递归), 答案一致才放心——第 24、25 章的"反例探测器"就是这么干的
"没报错"是及格线,"验证过"才是优秀。
全书完!
27 章 + 这本错题本,《小探险家学算法》到这里就全部结束了。 你已经拥有了完整的算法工具箱,也认清了路上所有的陷阱。
下一站,是你自己的作品——去写点什么吧! 后会有期,小探险家!