写代码十年,面试和被面试加起来超过两百场。我发现一个有趣的现象:算法题、系统设计、八股文,大多数人准备得都不差。但一到写实际代码,总有一些看起来“不该错”的地方反复出错。
计数变更——也就是“数数这件事”——是其中最典型的代表。
一、计数的第一个坑:边界条件
面试题最常见的场景:写一个循环,遍历数组。
听起来简单。但几乎所有新手都在同一个地方栽过跟头:到底是i < n还是i <= n?
// 错误:数组长度是5,但索引只有0到4
for (int i = 0; i <= arr.length; i++) {
System.out.println(arr[i]); // 第6次访问时越界
}这个错误的本质是计数和索引的错位。长度是“数量”,索引是“位置”。长度是5,最后一个索引是4。i <= 5意味着要访问索引0、1、2、3、4、5——正好多了那一个不存在的。
在实际生产代码中,这种错误最常出现在手写二分查找、快排分区、滑动窗口边界更新时。每次出现,都意味着一次线上bug。
二、第二个坑:循环中的动态变更
比边界条件更隐蔽的,是“边遍历边修改”的问题。
# 错误:遍历时删除元素 for item in list: if condition(item): list.remove(item) # 遍历顺序被破坏
Python会抛出ConcurrentModificationException。Java的ArrayList也有同样的保护机制。但有些语言不会报错,只会悄无声息地跳过某些元素或重复处理某些元素。
这类问题的本质是:计数在遍历开始时就被固定了,但集合的结构在遍历过程中发生了变化。你数的是一件事,实际做的是另一件事。
正确做法:要么用迭代器(Iterator)的remove()方法,要么先收集要删除的索引,遍历结束后再批量删除,要么用倒序遍历。
三、第三个坑:递归计数——你以为你数对了,其实没有
递归的计数问题比循环更隐蔽。典型场景:二叉树深度、斐波那契数列、组合数计算。
// 看似正确,实际上有重复计数
int countNodes(TreeNode root) {
if (root == null) return 0;
return countNodes(root.left) + countNodes(root.right) + 1;
}这段代码本身是对的。但很多人会在更复杂的递归场景中犯一个错误:把递归调用的返回值既当作计数又当作状态。
// 错误示例:想同时计算深度和节点数
int countAndDepth(TreeNode root) {
if (root == null) return 0;
int left = countAndDepth(root.left);
int right = countAndDepth(root.right);
// 这里返回的究竟是深度还是节点数?
return Math.max(left, right) + 1;
}这个函数名义上返回的是深度,但调用者以为它返回的是节点数。一个函数只做一件事,计数就是计数,不要和状态计算混在一起。
四、数据库里的计数变更:乐观锁的经典陷阱
在分布式系统中,计数变更还有一个更经典的场景:库存扣减。
-- 错误写法:先查后改 SELECT stock FROM products WHERE id = 1; -- 查出来是10 -- 业务逻辑判断库存充足 UPDATE products SET stock = stock - 1 WHERE id = 1;
在高并发下,两个请求同时查到了10,都扣成了9——最终库存变成9,但实际应该变成8。卖超了。
正确做法:把判断和扣减放在同一个原子操作中。
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0; -- 条件直接放在UPDATE里
然后用返回值判断是否更新成功。更新失败说明库存不足或已被其他请求抢走。
五、计数的本质是什么?
边界条件、动态变更、递归混淆、并发扣减——这些看起来不同的场景,底层本质是同一个问题:把“正在数的东西”和“应该数的东西”搞混了。
- 边界条件是把“长度”当成了“索引范围”
- 动态变更是把“遍历开始时的集合状态”当成了“遍历过程中的集合状态”
- 递归混淆是把“深度”当成了“节点数”
- 并发扣减是把“查询时的库存”当成了“更新时的库存”
每次写代码涉及到“数数”的时候,停下来问自己三个问题:
- 我数的起点和终点到底是什么?(边界)
- 数的时候,被数的东西会变吗?(动态变更)
- 谁有权利改变这个数?(并发控制)
这三个问题想清楚了,百分之八十的计数相关bug都能规避。
六、一个附加价值:面试的时候怎么答计数题?
如果面试官问“如何设计一个线程安全的计数器”,不要只回答“用AtomicLong”。
加分回答的框架是:
- 单机场景:AtomicLong(CAS)、LongAdder(高并发下性能更好)
- 分布式场景:Redis的INCR命令,注意设置合理的过期时间
- 需要严格不丢的计数:先写日志(WAL),再异步刷入数据库
- 需要读-改-写原子性的:用乐观锁或分布式锁
面试官想看的不是你知道多少工具,而是你能根据不同的计数需求选择合适的方案。清楚计数的场景和边界,比背诵API重要得多。
代码里最难调试的bug,往往不是算法逻辑错误,而是边界条件错误。把“数数”这件事拆透了,至少能帮你省下三分之一的调试时间。而一个“数数清楚”的开发者,面试官几乎找不到理由拒绝。
