秋招手撕代码自测清单:边界值、空指针、大数越界的五项防御性编程检查
很多同学在秋招技术面中经常遇到这样的场景:面对一道中等难度的算法题,思路非常清晰,用了不到十分钟行云流水写完三十多行核心代码,随后信心满满地告诉面试官“我写完了”。面试官扫了一眼屏幕,微微一笑:“如果入参传入null呢?如果数组里全部是重复元素呢?如果数值累加触发了溢出怎么处理?”
这时候才手忙脚乱地回过头去补if分支,不仅打乱了原有的代码结构,还容易引入新的逻辑漏洞。在面试官眼里,手撕代码考察的从来不单单是“能否把题解背出来”,更是候选人在高压环境下编写生产级代码的工程素养与防御性编程(Defensive Programming)习惯。一段合格的代码,在核心业务逻辑运转之前,应当能够抵御各类恶意、边界以及异常输入。
结合真实面试与日常高频踩坑,我梳理出这份手撕代码交付前的“五项防御性自测清单”。写完代码后不要急着交卷,花两分钟按顺序走完这五项检查,能直接规避 90% 的低级失分。
检查一:入口级空值与空结构拦截(Null & Empty Check)
绝大多数线上 NullPointerException(NPE)或 Panic,本质上都是因为函数入口没有严格校验契约。在白板或 IDE 面试中,入口检查是展现编码成熟度的第一道门面。
1. 指针与容器的双重校验
对于数组、字符串、链表、树等复合数据结构,必须区分“指针未初始化(null)”与“结构体为空(empty)”。
- 数组与字符串:务必使用短路逻辑联合校验。
// 错误写法:如果 nums 为 null,nums.length 会直接抛出 NullPointerException if (nums.length == 0) return 0; // 正确写法:利用逻辑与/或的短路特性,优先拦截 null if (nums == null || nums.length == 0) { return 0; } - 链表与树节点:
单链表翻转、树的层序遍历等题目中,头节点为null属于完全合法的输入。代码第一行必须明确处理根节点为空时的返回值,避免后续解引用崩溃。
2. 规模不足以支撑算法启动的极端场景
除了null和size == 0,还要警惕容器长度小于算法窗口下限的场景。
例如滑动窗口求长度为k的最大子数组和:
public int maxSubArrayLen(int[] nums, int k) { if (nums == null || nums.length < k || k <= 0) { // 当数组元素少于 k 个,或者窗口大小 k 本身非法时,必须阻断 return 0; } // 核心滑窗逻辑... }如果没有nums.length < k的入口防御,后续滑动窗口在初始化前k个元素时,就会直接触发ArrayIndexOutOfBoundsException。
检查二:大数越界与隐式类型溢出(Overflow & Underflow)
计算机中 32 位有符号整数int的表示范围是 $[-2^{31}, 2^{31} - 1]$,即 $[-2147483648, 2147483647]$。在涉及累加、乘法、反转、哈希计算的题目中,溢出往往以极隐蔽的方式破坏计算结果。
1. 二分查找中的中点计算
这是最经典也最容易被面试官拿来当面设套的细节:
// 隐患写法:当 left 和 right 都很大(接近 2^31-1)时,相加直接上溢变为负数,导致 mid 变为负索引 int mid = (left + right) / 2; // 防御写法 1:减法防溢出 int mid = left + (right - left) / 2; // 防御写法 2:无符号右移(位运算优先级低于加法,务必加括号) int mid = (left + right) >>> 1;2. 累加/累乘过程中的隐式类型转换时机
在大数取模类题目中(如模 $10^9 + 7$),很多同学会写出如下代码:
int MOD = 1_000_000_007; int a = 1_000_000_000; int b = 1_000_000_000; // 致命错误:a * b 在赋值给 long 之前就已经在 int 寄存器中溢出截断了! long wrongAns = (a * b) % MOD; // 正确做法:运算操作数必须先提升至 64 位宽 long long correctAns = ((long) a * b) % MOD;3. 负数取模陷阱
在哈希表映射、环形循环数组移动时,负数取模是逻辑重灾区。例如在 Java 中,-1 % 5的运算结果是-1,如果直接拿着这个结果去访问数组arr[-1],会立即越界崩掉。
防御性数学处理应当保证模数落在 $[0, m-1]$ 内:
// 通用防负取模公式 int index = (val % k + k) % k;4. 逆向反转整数的提前截断
以经典的整数反转(LeetCode 7)或字符串转整数(LeetCode 8)为例,必须在将新数位推入结果之前进行边界探测,而不是等推入溢出后再来修补。
// 每次推入 digit 前探测溢出边界 if (rev > Integer.MAX_VALUE / 10 || (rev == Integer.MAX_VALUE / 10 && digit > 7)) { return 0; // 上溢 } if (rev < Integer.MIN_VALUE / 10 || (rev == Integer.MIN_VALUE / 10 && digit < -8)) { return 0; // 下溢 } rev = rev * 10 + digit;检查三:指针越界与短路求值顺序(Index Out of Bounds)
写双指针、滑动窗口、单调栈以及二分搜索时,指针越界是占比最高的逻辑 Bug。
1. 逻辑与(&&)表达式的依赖顺序
短路求值(Short-circuit Evaluation)是保障指针安全的核心武器。永远把边界合法性判断写在取值操作的最左侧。
// 致命错误:当 r == nums.length 时,先计算 nums[r] 会直接抛出数组越界异常! while (nums[r] > target && r < nums.length) { r++; } // 防御写法:必须先卡控索引范围,利用 && 的短路特性阻断越界寻址 while (r < nums.length && nums[r] > target) { r++; }2. 链表步长跨跳的级联防御
在快慢指针寻找链表中点或判定环时,快指针每次走两步fast = fast.next.next。
// 必须同时保证当前节点与其后继节点都不为空 while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; }如果只校验fast != null,那么当链表长度为奇数、快指针跳到尾节点时,fast.next为 null,下一次循环执行fast.next.next就会对 null 解引用导致空指针异常。
3. 虚拟头节点(Dummy Head / Sentinel)消除边界歧义
在链表插入、删除、合并操作中,头节点可能被变更或删除。为防止在头节点处单独写冗长且容易漏判的if (head == null) / if (cur == head)分支,引入哨兵节点是极其优雅的工程解法:
ListNode dummy = new ListNode(0); dummy.next = head; ListNode cur = dummy; // 所有的头节点增删操作统一退化为中间节点操作,彻底杜绝针对 head 的越界与空指针检查四:递归栈溢出与循环收敛性判定(Loop Termination)
死循环与无限递归(StackOverflowError)会直接耗尽线程执行资源。手撕代码时,必须能清晰证明自己的代码会在有限步内收敛。
1. 递归基(Base Case)的封闭性
写树或图的 DFS/递归时,务必检查两点:
- 是否存在没有覆盖到的叶子状态。
- 是否存在有向图/无向图成环导致递归深陷。
对于包含环路的图遍历,必须有状态记录表(如boolean[] visited或着色标记法)。如果缺少访问标记,递归深度直接击穿调用栈。
2. 循环内部指针步进的漏网之鱼
在双指针去重时(如三数之和):
while (left < right) { int sum = nums[i] + nums[left] + nums[right]; if (sum == target) { res.add(Arrays.asList(nums[i], nums[left], nums[right])); // 跳过重复元素 while (left < right && nums[left] == nums[left + 1]) left++; while (left < right && nums[right] == nums[right - 1]) right--; // 关键点:跳过重复之后,必须推进指针跳出当前值! // 如果这里漏写了 left++ 和 right--,循环将无限卡死在最后一个重复值上 left++; right--; } else if (sum < target) { left++; } else { right--; } }3. 二分搜索区间的收敛模型
二分搜索死循环的核心在于:区间没有严格缩小,导致mid重复计算。
牢记两种典型闭环心智模型:
left = mid + 1,right = mid - 1:匹配while (left <= right),每次都排除mid,绝对收敛。left = mid,right = mid - 1:必须配合向上取整mid = left + (right - left + 1) / 2。若采用默认向下取整,在right - left == 1时mid会永远等于left,导致left = mid陷入无限循环。
检查五:状态污染与回溯深浅拷贝(State Mutation)
很多同学算法写得很溜,但在处理组合、全排列、路径输出等题目时,最终输出的全是空列表,或者所有解都一模一样。根源在于对对象引用传递和状态回溯的理解偏差。
1. 结果收集时的浅拷贝幽灵
在 Java/Python 等语言中,对象传参均为引用语义。
// 错误写法:把 path 的引用直接加到结果集里 // 后续回溯清理 path 时,res 内部已收集的对象内容也会被同步清空! res.add(path); // 正确做法:必须构建当前状态的快照(深拷贝/创建新实例) res.add(new ArrayList<>(path));2. 回溯状态还原的对称性
回溯的核心逻辑在于“做出选择”与“撤销选择”的严格对称。
graph TD A["做出选择: path.add(val)"] --> B["深入下一层递归: dfs(...)"] B --> C["撤销选择: path.remove(path.size() - 1)"]如果在深入递归之前修改了外部状态(如visited[i] = true),必须确保在递归返回后、退出当前逻辑块前,将visited[i] = false还原。如果中间存在提前return语句,必须检查是否在return之前丢失了状态回滚。
3. 入参的原地修改约束(In-place Trap)
除非面试官明确要求“O(1) 额外空间复杂度下原地修改输入数组”,否则尽量不要在核心解题过程中直接污染调用方传入的入参。在工程体系中,上游传进来的对象可能同时被其他线程读取,随意修改入参内部结构容易诱发并发脏读与诡异的副作用。如果必须修改原数组,可以在动手前主动与面试官沟通确认。
面试现场自测推演心法(DRY RUN)
写完代码后,如何向面试官展示你的严谨度?不要立刻说“我写完了”,而是主动提出:“代码框架已经完成,我现在用几组典型测试用例做一下推演和防御性验证:”
| 测试用例类型 | 输入特征 | 重点检查的目标机制 |
|---|---|---|
| 空值与极端小规模 | nums = null或nums = [] | 入口条件拦截与异常防御 |
| 单一元素与边界规模 | nums = [1],单节点链表 | 指针跨跳与循环退出条件 |
| 全重复元素 | nums = [2, 2, 2, 2] | 避免单调栈死循环、双指针卡死 |
| 数学极值 | nums = [Integer.MAX_VALUE, -1] | 累加与乘法是否溢出、中点计算防溢出 |
| 逆序/无解场景 | 目标值不存在,或者严格降序输入 | 递归与循环退出的基准分支覆盖率 |
在白板上用手指或笔尖带着面试官过一遍变量指针的位移变化,顺便指明你的空值保护逻辑与防溢出设计。当你能够主动将这些防御措施讲出来时,面试官看到的就不仅是一个做对算法题的学生,而是一个在生产线上值得信赖的准后端工程师。