看到这个题单,我第一反应是:“这不就是数据结构期末考试/面试前很容易遇到的那组经典题吗?”方阵填数、二叉树编号、二叉排序树、链表实现二进制加一、哈希表平均查找长度,表面上是五道独立的题目,但它们把数组、树、链表、散列表这四大基础结构全过了一遍,几乎覆盖了数据结构课程最重要的考点。很多同学刷题时容易陷入“这道题我会写”的错觉,等真正上手才发现:要么边界写错,要么进位处理漏掉,要么哈希表算出来的平均查找长度和答案对不上。这篇文章我就把这五道题拆开揉碎,讲讲背后的原理、可以复现的写法和实操中容易踩的坑,适合正在准备课程设计、考研复习或者面试算法题的读者。
1. 五个题放在一起,到底在练什么
不要急着看每一道题的解法,先想清楚一个问题:为什么这五个题经常被放到同一节实验课或者同一份复习提纲里?
先说方阵填数。它考察的是二维数组的遍历和边界控制,看起来只是“绕着圈填数字”,但难点恰恰在“什么时候转向、什么时候收缩边界、什么时候停”。很多人一上来就写四个方向数组,然后试图用步长控制,结果绕来绕去把自己绕晕了。这个题真正的训练价值是:让你学会用上下左右四个边界变量来盯住问题,而不是靠脑子想象每一圈的走向。
二叉树编号这一题,对应的是“完全二叉树的顺序存储结构”。核心只有一句话:根节点编号为1,任意节点编号为i时,左孩子编号是2i,右孩子编号是2i+1。别小看这个规则,堆排序、优先队列、线段树的数组实现全建立在这套编号体系上。考这题的目的,是想让你从“链式树结构”过渡到“用数组存树”的思维方式。
二叉排序树(BST)就更有意思了。它考的不仅是一棵树的构建,更是“怎么把查找效率讲清楚”。同样一组数据,按不同顺序插入BST,树的高度可能不一样,平均查找长度(ASL)也随之不同。最坏情况下,递增序列插入会退化成一个链表,查找从O(log n)变成O(n)——这个坑面试里天天有人踩。
链表实现二进制数加一,很多人第一次看到会觉得奇怪:好好的二进制加法为什么要用链表?因为数组在头部插入是很贵的,而链表天然适合“从低位到高位逐位进位”这种过程。这个题的精髓在于“进位会不会一直传到最高位之外”,也就是“111 + 1 = 1000”这种情况。
哈希表构造和平均查找长度计算就更典型了,它就是数据结构笔试的常客:给你一组关键字、一个散列函数、一种冲突处理方法,让你画出哈希表并算ASL。这题看起来是算术题,实际上对“冲突探测过程”的理解不到位,算出来必然错。
把这五题串起来看,你会发现它们其实在训练同一套能力:把抽象规则转换成边界条件,再把边界条件转换成代码。数组有边界,树有父子关系的边界,链表有指针的边界,哈希表有探测路径的边界。程序崩溃或者答案不对,九成都是边界没控制住。
2. 方阵填数:想清楚方向,不如先管住边界
2.1 核心思路:上下左右四个边界,一次填一条边
方阵填数最常见的版本是:给定 n,生成一个 n×n 的矩阵,从1开始,按顺时针由外到内螺旋填入。比如 n=4 时,结果长这样:
1 2 3 4 12 13 14 5 11 16 15 6 10 9 8 7我见过很多人写这个题,第一反应是维护当前坐标 (x, y) 和方向向量,遇到越界就转向。这个方法不是不行,但代码写出来分支很多,尤其是每一圈的长度会变,容易在“走几步该转”上出错。
我更推荐另一种写法:不盯着“当前走的方向”,而是盯住“当前还没填的矩形区域”。维护四个变量:up表示最上面一行,down表示最下面一行,left表示最左列,right表示最右列。每一轮按顺序填上边、右边、下边、左边四条边,每填完一条边,就把对应的边界往里缩一格。当up > down或者left > right时,说明所有格子都填满了,结束。
用 Python 写出来非常直观:
def spiral_matrix(n): matrix = [[0] * n for _ in range(n)] up, down = 0, n - 1 left, right = 0, n - 1 num = 1 while up <= down and left <= right: # 上边:从左到右 for j in range(left, right + 1): matrix[up][j] = num num += 1 up += 1 # 右边:从上到下 for i in range(up, down + 1): matrix[i][right] = num num += 1 right -= 1 # 下边:从右到左(只剩一行时会被跳过) if up <= down: for j in range(right, left - 1, -1): matrix[down][j] = num num += 1 down -= 1 # 左边:从下到上(只剩一列时会被跳过) if left <= right: for i in range(down, up - 1, -1): matrix[i][left] = num num += 1 left += 1 return matrix这段代码里最关键的是两个if。为什么要加?因为当矩阵缩小到最内层时,如果只剩一行,那么“下边”就是这一行本身,如果照填会重复填已经填过的格子;只剩一列时,“左边”也会重复。所以这两个判断不是可有可无的优化,而是保证正确性的核心。
用 C 语言写也完全一样,只是把二维数组开成int a[n][n],控制循环时可以这样:外层while (up <= down && left <= right),内部分四条边走。判断条件建议用<=而不是<,因为当up == down或left == right时,那一行或一列仍然需要填。
2.2 为什么推荐边界收缩而不是方向转向
我见过有人把方向数组写得很漂亮,四个方向的增量分别是(0,1), (1,0), (0,-1), (-1,0),然后配合一个visited数组判断要不要转向。这种写法本身没问题,但新手容易忽略一个细节:转向的条件不只是“越界”,还包括“下一个位置已经填过”。填到第二圈时,数组并没有越界,但前方已经有人了,这时候也必须转。
也就是说,方向转向法本质上要同时判断两类边界:数组边界和已访问区域边界。而边界收缩法只需要判断一类边界——未填矩形区域的大小。后者显然更不容易出错。
如果你非要玩一下方向数组版本,也有一个小技巧:可以把“步长序列”预先算出来。对于 n 阶方阵,每圈的步长是递减的:n、n-1、n-1、n-2、n-2……直到0。这个规律单独看还蛮漂亮的,但实现时对奇偶 n 的收尾要额外处理,综合性价比不如边界收缩法。
实操时送你一个自检方法:斐波那契式的验证不靠谱,最好的验证是拿 n=1、n=2、n=3、n=4 都跑一遍,然后看一眼中心几个数是否连续。比如 n=3 的中心是9,n=4 的倒数第二个数字是15,最外圈最后填的数字是16。如果这些位置对不上,说明边界收缩的逻辑还有漏洞。
3. 二叉树编号:把树塞进数组的那套规则
3.1 层序编号与父子的下标关系
“二叉树编号”这个题看起来简单,但想真正讲明白,需要说清楚三件事:怎么编号、编号有什么用、编号背后的限制条件是什么。
最常见的是按“层序”编号:根节点是1号,然后从上到下、从左到右,依次给每个节点编号。这是个线性顺序,所以天然可以映射到数组下标。映射完会得到两个非常重要的性质:
- 编号为 i 的节点,它的左孩子编号是
2i,右孩子编号是2i+1 - 编号为 i 的节点,它的父节点编号是
floor(i/2)
这两个性质就是“完全二叉树用数组存储”的底层逻辑。堆为什么可以用数组实现?因为堆是一棵完全二叉树,所有节点恰好填满数组下标,不需要额外存左右指针,下标本身就隐含了父子关系。
那如果二叉树不是完全二叉树怎么办?编号就会出现“空洞”。比如一个只有3个节点的二叉树,根有右孩子,那么按照层序编号,根是1,右孩子是2,但数组下标2这个位置在完全二叉树里本应是根的左孩子。处理方式是:在对应数组位置留空,或者不追求连续编号,而是用哈希表记录“节点指针 -> 编号”的映射。
判断一棵树能不能完美编号,标准就是“看它从根开始逐层填过去,是不是没有空洞”。如果是完全二叉树,数组空间是连续的,父子关系完全由下标决定;如果不是,就只能用指针结构,或者接受数组里有很多空位。
3.2 一个可以跑起来的层序编号过程
动手实现时,最稳的思路是用队列做层序遍历,同时给每个节点分配编号。队列里存的不是单个节点,而是“节点 + 它当前的编号”这个组合。
用 Python 写大概是这样的:
from collections import deque def assign_indices(root): if root is None: return {} q = deque() q.append((root, 1)) index_map = {} while q: node, idx = q.popleft() index_map[idx] = node.val if node.left: q.append((node.left, idx * 2)) if node.right: q.append((node.right, idx * 2 + 1)) return index_map这个函数返回的是一个字典,键是编号,值是对应节点的值。可以看到,在队列里把编号传给左右孩子时,直接用了2i和2i+1的规则。这样写的好处是,即使树不是完全二叉树,号码会跳跃,但父子关系仍然能保持。
还有一个常见变体:根节点编号为0,那么左孩子是2i+1,右孩子是2i+2,父节点是(i-1)//2。两种体系没有本质区别,但千万不要混用。比如你在根节点是1的体系里算出了某个孩子的编号,又拿到根节点是0的体系里去访问数组,一找一个错。我建议代码里在注释第一行就写清楚“本代码采用根节点编号为1的体系”。
为什么“最后一个非叶子节点编号是 n/2”这个结论很常用?因为它经常被用在堆排序里做自底向上的调整。设想数组长度为 n,编号从1开始,那么编号大于 n/2 的节点都是叶子节点,它们没有孩子,不需要向下调整;编号小于等于 n/2 的节点才可能是父母节点。这个结论可以帮你在建堆时省一半操作。
这套编号体系还可以应用到线段树。线段树通常用一个四倍于数据量的数组存储,节点下标也是用类似规则分配的。虽然线段树的节点编号不一定严格等于2i、2i+1,但思路一脉相承:用下标隐含树形结构,省掉显式的指针。
4. 二叉排序树:查找效率靠中序遍历有序
4.1 构建规则、查找路径和退化陷阱
二叉排序树(BST,Binary Search Tree)的定义并不复杂:对于任意节点,它的左子树所有节点的值都小于它,右子树所有节点的值都大于它。按照这个规则把一组数据插入进去,就得到一棵BST。
插入过程也是一个查找过程。比如往空树里依次插入50, 30, 70, 20, 40, 60, 80,每一步都是拿新值和当前节点比较,小了往左走,大了往右走,走到空位就挂上去。这样构建出来的树,中序遍历恰好是递增序列——这是BST最核心的性质:中序有序。也就是说,一棵树是不是BST,不需要查定义,直接把中序遍历结果拉出来看看是否严格递增就行。
但这里藏着一个大坑:如果数据本身有序,比如依次插入1, 2, 3, 4, 5,BST会退化成一条单链表。根是1,2挂在右边,3挂在2的右边,一直往下延伸。这时候查找一个数字的时间复杂度从 O(log n) 退化成 O(n)。为什么会有这种退化?因为BST的构建完全依赖插入顺序,而普通BST没有任何自平衡机制。面试时只要聊到BST,几乎必问“极端输入会怎样”,答案就是这个“退化成链表”。
知道了这一点,再去看“二叉排序树平均查找长度”的计算题就会透彻很多。给定一个插入序列,按顺序建树后,根在第0层,根的孩子在第1层,依此类推。查找成功时,访问一个节点的比较次数等于它的层数加1。把所有节点的比较次数加起来除以节点总数,就是查找成功的ASL。
比如插入序列50, 30, 70, 20, 40, 60, 80得到的树,各节点层数从0到2都有:50在第0层,30和70在第1层,20、40、60、80在第2层。平均查找长度计算如下:
(1 + 2 + 2 + 3 + 3 + 3 + 3) / 7 = 17 / 7 ≈ 2.43你可以拿这个结果和后面哈希表的ASL对比。BST的查找长度和树的形态强相关,而哈希表的查找长度主要受“散列函数 + 冲突处理 + 装填因子”影响,这是两种截然不同的查找策略。
4.2 删除操作的三种情况,以及中序前驱/后继
BST的删除是很多学生的痛点。实际写的时候只需要分清楚三种情况:
- 删除叶子节点:直接把父节点的指针置空。
- 删除只有一个孩子的节点:用这个孩子顶替被删节点。
- 删除有两个孩子的节点:找到左子树中的最大节点,或者右子树中的最小节点,用它的值覆盖被删节点,然后递归删除刚才用来覆盖的那个节点。
为什么第三种情况要绕一下?因为被删节点有两个孩子,不能简单让孩子顶替,否则会破坏BST结构。把它换成左子树最大或右子树最小之后,问题就简化成“删除一个最多只有一个孩子的节点”,回到了情况1和情况2。
这个用来覆盖的节点,在中序遍历里恰好是被删节点的前驱或后继。所以“中序前驱/后继”这个概念不是纯理论,它是delete操作里实实在在要用的东西。
用C写BST插入和中序遍历,代码量不大,也是我推荐每个初学者完整敲一遍的:
#include <stdio.h> #include <stdlib.h> typedef struct Node { int key; struct Node *left; struct Node *right; } Node; Node* insert(Node* root, int key) { if (root == NULL) { Node* p = (Node*)malloc(sizeof(Node)); p->key = key; p->left = p->right = NULL; return p; } if (key < root->key) root->left = insert(root->left, key); else if (key > root->key) root->right = insert(root->right, key); // 相等时不处理,看题目是否允许重复 return root; } void inorder(Node* root) { if (root == NULL) return; inorder(root->left); printf("%d ", root->key); inorder(root->right); }这里有一个需要提前约定的点:遇到和根节点相等的值怎么办?不同教材有三种做法:直接忽略、插入到左子树、插入到右子树。如果你在做OJ题,一定要看题目描述有没有额外说明;如果没说明,最简单的是直接忽略。重点是这个约定必须全程序一致,否则插入和查找的行为会对不上。
BST一轮做下来,你会对“结构决定效率”有很直观的感受:同样的数据,不同插入顺序得到不同的树,ASL从2到5都可能。也正因为这个痛点,才催生了AVL树、红黑树这些自平衡结构。认识平衡树之前,先老老实实把普通BST的构建和删除搞清楚,后面学红黑树时会顺很多。
5. 二进制数加1(链表实现):别急着反转,先定存储方向
5.1 存储方式决定算法复杂度
“二进制数加1”这个题,普通数组实现其实很简单:从最低位开始,逢2进1,一直处理到没有进位为止。但一旦要求用链表,就出现一个关键选择:链表的头节点到底存最低位还是最高位?
- 头节点存最低位:直接从头部开始向后遍历,遇到0改成1就结束,遇到1改成0继续进位。如果所有位都是1,走到链表末尾再新建一个节点存1。
- 头节点存最高位:加1时不知道最低位在哪,得先遍历到链表尾部,处理完进位后可能还要处理新增节点。更常见的做法是先把链表反转,加1完成后再反转回来。
很多面试题默认头节点是最高位,因为这样打印出来符合人类阅读习惯,比如1->0->1表示5。如果你拿到题目先不思考就开写,很容易在最高位存储的方案里反复遍历和反转,把自己绕晕。我的建议是:不管题目怎么给的,先问清楚或者先定清楚存储方向,再动手。
如果你自己实现,我推荐一个讨巧的办法:用带哨兵头节点的单向链表,哨兵节点不存数据,dummy->next指向最低位。这样加1操作不需要额外判断链表是否为空的边界情况,代码会清爽很多。
用 C 写核心逻辑:
// 节点里存 0 或 1,dummy 是哨兵头节点,dummy->next 指向最低位 void addOne(Node* dummy) { Node* p = dummy->next; if (p == NULL) { // 空链表代表数字 0 的情况 Node* newNode = (Node*)malloc(sizeof(Node)); newNode->val = 1; newNode->next = NULL; dummy->next = newNode; return; } while (p != NULL) { if (p->val == 0) { p->val = 1; return; // 没有后续进位了 } else { p->val = 0; // 1 + 1 变 0,继续进位 if (p->next == NULL) { // 所有位都变成 0 了,需要在最高位追加一个 1 Node* newNode = (Node*)malloc(sizeof(Node)); newNode->val = 1; newNode->next = NULL; p->next = newNode; return; } p = p->next; } } }这个流程的终止条件要仔细想:只要当前位是0,说明这一位加1后变成1,不再产生进位,直接return;当前位是1,加1后变成0,必须要往下一位进1。循环到最后如果链表都用完还没有遇到0,说明整个数原本全是1,末尾要挂一个新节点。
如果你处理的是“头节点存最高位”的链表,操作是:先反转链表变成最低位在前,再调上面的逻辑,最后再反转回来。反转链表本身是个高频基础操作,很多人在反转时丢掉了尾指针,或者忘了把最后一个节点的 next 置空。要注意的是反转后原来的头节点变成了尾节点,它的 next 必须是 NULL。
5.2 这个题目的隐藏考点:循环链表、双链表和跨语言实现
别看题目要求“链表实现”,它背后能挖的东西其实不少。有的变种会把链表做成循环单链表,此时判断“是否到末尾”就不能用p == NULL,而是用p == dummy或者p == head。循环链表并不是这个题的最优解,但如果你在实验课上被要求用循环链表做,逻辑就要相应调整。
双链表在这个题上有一个额外的好处:如果头节点存最高位,你可以直接用指向前驱的指针从尾部向前走,不需要反转。不过实话实说,为一个加1操作引入双向指针有点杀鸡用牛刀,我更推荐把存储方向定义成最低位在头,单链表足够。
不同语言实现这个题的代码风格差别很大,但核心逻辑完全一致:
- C/C++:用结构体和
malloc手动管理节点 - Python:用
class Node表示节点,操作指针就是操作对象引用 - Java:同样用类定义节点,只是引用语义更严格
- PHP:可以直接用
SplDoublyLinkedList,甚至没必要手写节点
很多人私信问我:“我用Python写链表总觉得别扭,是不是该用C语言练?”我的回答是:链表本身就是和指针/引用强绑定的结构,语言会变,但“节点包含数据域和指针域”“插入要改前后节点的指针”这些规律不会变。用Python写一样能练到核心,只是少了内存管理的细节而已。
这个题的测试用例建议直接跑这几种:0 + 1、1 + 1(结果2)、111 + 1(结果1000)、101 + 1(结果110)。前三个最容易测出“新增最高位”的边界问题,最后一个能测出“中间有0但更高位也有值”的进位终止条件。跑了这四个用例,基本就放心了。
6. 哈希表构造与平均查找长度:算不对的根源是口径不清
6.1 构造过程:除留余数法 + 线性探测
哈希表(散列表)的题目通常给一堆关键字、一个散列函数、一个表长、一种冲突处理方法,然后让你干三件事:画出存储结果、计算查找成功时的平均查找长度、计算查找失败时的平均查找长度。
我拿一组经典数据来演示。假设表长m = 11,散列函数是H(key) = key % 11,冲突处理用线性探测,关键字序列是:
22, 41, 53, 46, 30, 13, 01, 67先算每个关键字的哈希地址:
22 % 11 = 0 41 % 11 = 8 53 % 11 = 9 46 % 11 = 2 30 % 11 = 8 13 % 11 = 2 01 % 11 = 1 67 % 11 = 1依次插入,遇到位置被占就往后探测,直到找到空位。完整的插入过程,包括探测路径和比较次数,我整理成了一张表:
| 关键字 | 初算地址 | 探测路径 | 最终位置 | 比较次数 |
|---|---|---|---|---|
| 22 | 0 | 0 | 0 | 1 |
| 41 | 8 | 8 | 8 | 1 |
| 53 | 9 | 9 | 9 | 1 |
| 46 | 2 | 2 | 2 | 1 |
| 30 | 8 | 8 -> 9 -> 10 | 10 | 3 |
| 13 | 2 | 2 -> 3 | 3 | 2 |
| 01 | 1 | 1 | 1 | 1 |
| 67 | 1 | 1 -> 2 -> 3 -> 4 | 4 | 4 |
插入完成后,哈希表从下标0到10的内容是:
[0]=22, [1]=01, [2]=46, [3]=13, [4]=67, [5]=空, [6]=空, [7]=空, [8]=41, [9]=53, [10]=30从这个过程就能看出线性探测的一个毛病:一旦发生冲突,后续元素会堆积在一起。比如“30”本来应该放在8,因为8和9都被占了,被挤到10;“67”本来应该放在1,结果一路探测到4才找到位置。这种“前方拥堵,后面的车全压到一起”的现象叫聚集(cluster),是线性探测不可避免的副作用。
6.2 平均查找长度:成功和失败的两种算法要分开记
查找成功时的ASL计算,看的是“查每个已存在元素需要比较多少次”,也就是插入时那个比较次数直接拿来用。所有关键字的比较次数加起来除以关键字个数:
ASL成功 = (1 + 1 + 1 + 1 + 3 + 2 + 1 + 4) / 8 = 14 / 8 = 1.75查找失败时的ASL计算,是很多人的重灾区。它的含义是:对每个可能的哈希地址(0 到 10),都去找一个不存在于表中的关键字,算它一共要比较几次才能确定“找不到”。线性探测的规则是:一直往后探测,直到遇到空位,说明这个关键字肯定不在表里。注意,空位本身也要算一次比较。
从每个地址出发的失败查找次数如下:
| 起始地址 | 探测序列 | 比较次数 |
|---|---|---|
| 0 | 22 -> 01 -> 46 -> 13 -> 67 -> 空 | 6 |
| 1 | 01 -> 46 -> 13 -> 67 -> 空 | 5 |
| 2 | 46 -> 13 -> 67 -> 空 | 4 |
| 3 | 13 -> 67 -> 空 | 3 |
| 4 | 67 -> 空 | 2 |
| 5 | 空 | 1 |
| 6 | 空 | 1 |
| 7 | 空 | 1 |
| 8 | 41 -> 53 -> 30 -> 22 -> 01 -> 46 -> 13 -> 67 -> 空 | 9 |
| 9 | 53 -> 30 -> 22 -> 01 -> 46 -> 13 -> 67 -> 空 | 8 |
| 10 | 30 -> 22 -> 01 -> 46 -> 13 -> 67 -> 空 | 7 |
所有比较次数求和:
6 + 5 + 4 + 3 + 2 + 1 + 1 + 1 + 9 + 8 + 7 = 47除以表长 11:
ASL失败 = 47 / 11 ≈ 4.27看到这里你可能会问:为什么失败ASL要从每个哈希地址都试一遍?因为查找失败时,我们只知道关键字的哈希地址,并不知道它实际落在哪里,所以必须把每个可能入口的查找成本都算进去,再平均。这和成功ASL只统计已有元素是两套口径。
还要注意一个教材差异:有些书在统计失败ASL时,遇到空位就直接停止,但不把空位那一次比较算进去。如果用这种口径,上面的结果会变成36/11 ≈ 3.27。哪个对?严格说,只要算法实现时每次真正比较了key == table[i]才发现位置为空,那么空位这次也算一次比较;但如果你用的是“先看是否为空,是空就停止查找并返回失败”的逻辑,有的教材也把它算作一次比较,有的不算。所以我建议做题时先看清楚教材约定,考场上看到“空位是否计入比较次数”这类细节,直接决定答案差多少。
6.3 不同冲突处理方法对ASL的影响
同样是这组数据,如果改用拉链法(链地址法),流程会变成:每个桶里挂一条链表,冲突的元素直接插到对应链表的头部或尾部。此时的ASL计算就变成每条链表长度的平均值。
拉链法的成功ASL是:
每个链表的长度之和 / 关键字个数失败ASL则是:
每条链表的长度之和 / 哈希地址个数这组数据在拉链法下,地址0那条链只有22,长度1;地址1链上是01和67,长度2;地址2链上是46和13,长度2;地址8链上是41和30,长度2;地址9链上只有53,长度1;其余地址链表为空,长度0。成功ASL:
(1 + 2 + 2 + 2 + 1 + 0 + 0 + 0 + 2 + 1 + 0) / 8 = 11 / 8 = 1.375失败ASL:
11 / 11 = 1.0对比线性探测的结果,你会发现拉链法的失败ASL明显低。原因很好理解:线性探测把冲突全堆在相邻位置,失败查找要跨过一大串元素;拉链法把冲突元素分开挂在各自桶里,查找失败时只需检查对应桶的链表。
那是不是拉链法永远更好?也不是。拉链法需要额外指针空间,在缓存友好性上不如连续数组存储的线性探测;表比较空的时候,线性探测的常数因子其实很小。工程上 Redis 的哈希表、Java 的 HashMap 都在不同时期用过开地址或拉链的不同思路,各有适用场景。做题归做题,工程里“哪种最好”永远要看具体数据规模和内存特征。
7. 实操中踩过的坑与一句话速记
这组题如果自己动手写一遍,大概率会遇到下面几个问题。我把它们集中列出来,方便你写完代码后逐条自查。
方阵填数内层多填或少填
最常见的现象是 n=4 时填出来的矩阵中间几个数字顺序不对。原因就是我在前面强调过的:最后一条边或者最后一列重复填了一次。修复方式是检查两个if判断,保证“只剩一行时不填下边”“只剩一列时不填左边”。
再补充一个细节:外层while为什么用up <= down && left <= right而不是<?因为 n 为奇数时,最后一圈中心只有一个格子,此时up == down && left == right,需要再执行一轮把中心填上,所以条件必须包含等号。
二叉树编号到底从0开始还是从1开始
两种体系都有人用,区别是左右孩子的下标公式不同。根从1开始:左孩子2i,右孩子2i+1。根从0开始:左孩子2i+1,右孩子2i+2。很多人在两个体系之间反复横跳,最后数组越界。解决方式很简单:写代码前先决定一种体系,并且连续用到底。我个人习惯用从1开始的体系,因为节点编号更符合直觉,而且堆排序里parent = i / 2这个计算特别干净。
BST插入顺序造成退化成链表
测试用例别只测乱序数据。如果题目允许,一定要试一下递增序列和递减序列。很多OJ题会故意用有序数据来卡人,让你不得不考虑平衡性。如果你只是写普通BST,能意识到“这种情况下效率退化”就够了;如果你要做优化,下一步就是学AVL旋转或者红黑树。
链表加1忘了新增最高位
这个坑的触发条件单一:整个二进制数全是1。比如111 + 1,如果代码里没处理“所有位都变0且链表遍历结束”的情况,结果会变成000,丢掉最高位的1。写出下面这行判断,才能补上:
if (p->next == NULL) { // 新建值为 1 的节点放到末尾 }哈希表ASL计算对不上答案
十有八九不是方法问题,而是“空位计不计数”或者“失败ASL要不要从所有地址都试一遍”这种口径问题。如果你发现和标准答案只差一个分子,先回头看看这两个口径是否一致。
最后分享一个小技巧:这组题目做完以后,别急着删代码。把螺旋填数的边界收缩法、BST的删除逻辑、链表加1的进位处理、哈希表的探测路径画在一张纸上,你会发现它们本质上都是在维护“某种结构在某个时刻的状态边界”。边界守住了,程序就稳了。这种能力很难通过看文章获得,只能靠亲手写、亲手调、亲手把bug改对。把这五道题多刷两遍,比看十遍理论有用得多。