这四个主题单独拿出来都不算难,但放在一起看就有点意思了:菱形打印考的是控制流和数学归纳,数位运算考的是对整数底层的敏感度,递归多项式考的是问题分解能力,链表排序考的是指针操作和稳定性控制。它们不是同一类问题,却共享同一套基本功:先把规律看透,再写代码,最后用边界情况验证。我去年带过几个实习生刷题,发现掉坑最深的往往不是难题,而是这类基础题的细节。这篇就把四个方向完整拆一遍,每个都给出可复现的实现思路和避坑经验。
1. 菱形打印:从找规律到控制台输出的完整套路
1.1 题目拆解与常见误区
菱形打印是几乎所有入门课程都会布置的题目,要求很简单:输入一个奇数层数 n,输出一个由星号和空格组成的菱形。很多人的第一反应是“一行一行硬写”,先把上半部分打出来,再单独写下半部分。这种做法不是不行,但写出来的循环变量多、分支乱,改一个层数就要重新调半天。
更隐蔽的误区是“把菱形当成上下两个三角形分别处理,却忽略了中间最长行可能被输出两次”。比如 n=5 时,最长行在第3行出现一次,而如果你用两个不对称的循环去拼,很容易在第3行或第5行多打或少打一行。这属于典型的边界问题,刷题量不够的人经常在这里翻车。
正确做法是先建立数学模型。一个 n 层菱形,上下对称,中间行最长。只要算出每一行的空格数和星号数,剩下就是套循环的事。核心公式其实很简单:对于上半部分的第 i 行(从0开始),空格数是abs(n//2 - i),星号数是2 * (n//2 - abs(n//2 - i)) + 1。
1.2 关键规律:用绝对值函数统一上下半区
我自己第一次写这个题时也习惯分两段循环,后来看别人用绝对值函数一行算出空格数,才发现可以统一处理。原理是把菱形中央行设为坐标零点,每离中心远一行,空格数就多1,星号数就少2。这样上下半区共用同一套公式,不需要再分前后两个循环。
具体推导过程如下。设half = n // 2,当前行索引为i(0 <= i < n),则当前行到中心行的距离是distance = abs(half - i)。那么这一行的空格数等于distance,星号数等于2 * (half - distance) + 1。以 n=5 为例,half=2:
- i=0,distance=2,空格2,星号1
- i=1,distance=1,空格1,星号3
- i=2,distance=0,空格0,星号5
- i=3,distance=1,空格1,星号3
- i=4,distance=2,空格2,星号1
这个规律在任何奇数 n 下都成立。用统一的公式代替两段循环,代码长度短一半,还不会出现中间行错位的问题。有些教材要求 n 为偶数时也输出对称图形,那可以把 center 取为小数,但控制台输出没法处理半格,实际练习时通常要求奇数的前提。
1.3 参考实现与逐行讲解
下面给一个 C 语言实现,逻辑清晰,适合当作标准答案来理解。
#include <stdio.h> #include <stdlib.h> int abs(int x) { return x < 0 ? -x : x; } int main() { int n = 5; int half = n / 2; for (int i = 0; i < n; i++) { int distance = abs(half - i); for (int j = 0; j < distance; j++) { printf(" "); } for (int j = 0; j < 2 * (half - distance) + 1; j++) { printf("*"); } printf("\n"); } return 0; }这里自己写了一个 abs,避免引入额外头文件时在某些在线评测环境出现兼容问题。内层第一个循环负责输出空格,第二个循环负责输出星号,每行结束换行。整体时间复杂度 O(n^2),空间复杂度 O(1),对于这类输出题没有更优的复杂度可言,重点是代码可读性。
如果想要更紧凑,可以把两个内层循环合并成一个,用一个条件判断当前列是空格还是星号:
for (int j = 0; j < 2 * half + 1; j++) { printf(j >= distance && j < 2 * half + 1 - distance ? "*" : " "); }这种写法更短,但可读性差一些。我个人建议初学者用第一种,因为调试时更容易定位问题。
1.4 可变内容:空心菱形与字符菱形
学会了实心菱形,空心菱形就是小改:只有每行的首尾输出星号,其余位置输出空格。实现时只需要在输出星号的循环里加一个判断:j == distance || j == 2 * (half - distance)时输出星号,否则输出空格。但要注意最顶层和最底层只有一个星号,这时首尾是同一个位置,如果不加特殊处理,会输出两个星号。需要在循环里判断当前行是否只有一个星号位,或者先输出首星号、再判断是否需要尾星号。
字符菱形就是把星号换成任意字符,比如数字1到n循环、字母A到Z顺序往上排,本质上没变,只是把每行的符号换成规律数据。这类变形在面试里也经常出现,比如某公司就出过“画出每行数字为行号的奇数菱形”的笔试题,剥掉外壳后还是同一套公式。
1.5 常见问题排查小结
菱形打印常见问题有四个:
- 输入偶数层数导致图形不对称。解决方案是题目没明确说时统一处理为奇数,如果必须支持偶数,需要在中心行做特殊处理。
- 空格数量差1导致图形歪斜。通常是因为循环从0还是从1开始没算清楚,最简单的方法是先用小 n 逐行验证空格数。
- 输出行首有多余空格。在线评测系统一般不检查行尾空格,但行首空格会算错,所以第一个空格循环不能省略。
- 换行符缺失或重复。打印完一行后必须
printf("\n"),有些新手把换行放在内层循环里,导致每个符号后面都换行。
菱形打印虽然简单,但它考察的是“用变量公式代替硬编码”的思维转变。把规律提炼成distance = abs(half - i),比记住每一行的空格数更有价值。
2. 数位运算:别总把整数当成字符串处理
2.1 数位运算的典型场景
“数位运算”这个说法在不同人口中含义不太一样。有人指把一个多位整数的每一位拆出来做处理,比如数位和、数字逆序、回文数判断;有人指按二进制位进行的与、或、异或、移位操作。这两个方向我都用在刷题和面试准备中,这里一起讲,因为它们在原理上是相通的:都是绕过字符串 API,直接操作整数本身。
典型题目包括:
- 给定正整数 n,求出各位数字之和。
- 判断一个整数是否为回文数。
- 将一个整数按位逆序输出。
- 将十进制整数转为二进制字符串。
- 统计一个整数的二进制表示中有多少个1。
这些题的共同点是“从整数中提取信息”。提取信息有两个基本操作:模10取个位、整除10去掉个位。循环下去,就能从低位到高位遍历每一位数字。这是数位拆解的根。
2.2 数位拆解的原理与边界问题
数位拆解的原理不复杂,但有几个坑值得单独说。
第一个坑是处理 0。很多人的循环条件是while (n > 0),如果输入 n=0,循环一次都不执行,数位和直接返回0。这个结果恰好是对的,但如果你要做“数字逆序”,就会有问题:0的逆序应该是0,而循环没执行就返回默认值0,结果碰巧也对。可如果题目要求统计位数,0应该算1位,这时就要单独判断。
第二个坑是负数和溢出。负数取模在 C 和 Python 里行为不同。C 语言中 -7 % 10 结果是 -7 或 3 取决于编译器实现,容易出错。所以处理数位时,如果题目只涉及正整数,最好先特判负数;如果必须处理负数,可以先把符号分离,再对绝对值操作。逆序数还有一个溢出风险,比如反转 2147483647 会超过 32 位整型上限,在 LeetCode 风格的题目里需要判断溢出。
第三个坑是“逆序后丢弃前导0”。比如 1200 的逆序是 21 还是 0021?数学上逆序数通常是 21,因为 0021 和 21 相等。但如果你用纯整数操作,反转过程中自然会把前导0丢掉,这通常是符合预期的,除非题目明确要求输出字符串形式保留前导0。
2.3 典型实现:数位和、逆序与回文判断
先看数位和的实现:
int digitSum(int n) { int sum = 0; while (n > 0) { sum += n % 10; n /= 10; } return sum; }这个代码有几处值得琢磨。n % 10提取最低位,n /= 10去掉最低位,两个操作配合就能逐位推进。由于 n 是正数,循环终止条件是 n 变为0。sum 用 int 就够,因为即使 n 很大,数位和的增长远比 n 慢。
再看逆序数:
int reverseNumber(int n) { int result = 0; while (n > 0) { result = result * 10 + n % 10; n /= 10; } return result; }如果 n 是 1234,过程是:取4得4,再取3得43,再取2得432,再取1得4321。这个“乘以10再加低位”的动作,就是逐步构造逆序数的核心。鲁棒性方面,应该在使用前判断 result 是否会溢出,比如用 long 类型过渡,或者比较result > (INT_MAX - n % 10) / 10。
回文数判断可以直接利用逆序函数:如果 reverse(n) == n,那么 n 是回文数。但这样做在 n 很大时可能溢出,所以更稳妥的写法是“只反转后半部分”。原理是把后半段反转后和前半段比较,循环到后半段 >= 前半段时停止。这个方法比整体反转少一次溢出风险,适合进阶。
2.4 按位运算:统计二进制1的个数
数位运算的另一半是位运算。最经典的题目是“统计一个非负整数的二进制表示中有多少个1”,面试里通常叫“汉明重量”。
最朴素的做法是每次右移一位,与1做与运算:
int countBits(int n) { int count = 0; while (n) { count += n & 1; n >>= 1; } return count; }这个做法的复杂度是 O(log n)。更高效的是n & (n - 1)技巧:这个表达式会消除 n 二进制表示中最右边的1。原理是 n-1 会把最右边的1变成0,并把之后的所有0变成1,再与 n 相与,就只改变最右边那位。利用这个特性,循环的次数等于1的个数:
int countBitsFast(int n) { int count = 0; while (n) { n &= n - 1; count++; } return count; }这个代码在 n 很大的时候优势明显,比如 n=0x8000000000000001,朴素写法要循环64次,这个写法只需循环2次。很多刚接触位运算的人看到n & (n - 1)会蒙,我建议多画几组二进制数手动试一下,比如 n=12(1100),n-1=11(1011),相与得到8(1000),正好消掉了最低位的1。
2.5 数位运算的进击:进制转换与数学特征
进制转换也是数位运算的高频变种。十进制转二进制可以循环用除2取余法,把余数逆序输出。如果目标不是二进制而是任意进制 base,只需把n % 10换成n % base,把n /= 10换成n /= base。这里有个细节:base 大于10时,余数可能超过9,要用字母表示,比如16进制的10到15对应 A 到 F。
另一个常见题型是判断“快乐数”或者“数字黑洞”,它们的核心仍然是数位拆分。快乐数的规则是:把一个数的各位数字平方求和,重复操作直到得到1或进入循环。拆解每个数字的步骤和数位和完全一样,区别只是累加的是平方而不是数字本身。这说明数位分解是一个通用工具模块,在不同题目里复用即可。
3. 递归多项式:把数学表达式翻译成递归
3.1 多项式求值的两种思路
“递归多项式”这个名字在不同资料里有两种指向:一是用递归的方式求多项式的值,二是递归地生成多项式系数。先讲第一种,因为它是递归思想的基础训练。
求一个多项式比如 f(x) = 3x^4 + 2x^3 - x^2 + 5x + 1 在某个点 x 的值,最直接的做法是每一项分别计算幂再累加。这样做的问题是要反复调用 pow,计算量大且代码不如递归优雅。
另一种思路是化成一个递推结构。把多项式写成嵌套形式:
f(x) = (((3x + 2)x - 1)x + 5)x + 1
从最高次项的系数3开始,重复“乘以 x,加下一项系数”的操作,最后得到多项式的值。这就是多项式求值的经典算法,俗称“嵌套乘法”,一些资料里也叫霍纳方法。它比直接逐项求幂少很多乘法,而且很适合用递归实现。
3.2 递归实现嵌套乘法的细节
把嵌套乘法写成递归,关键在于定义递归函数:eval(coeffs, index, x)表示从第 index 个系数开始往后求值。设系数数组 coeffs 按降幂排列,长度为 n,则:
double evalPoly(int coeffs[], int n, double x, int idx) { if (idx == n - 1) { return coeffs[idx]; } return evalPoly(coeffs, n, x, idx + 1) * x + coeffs[idx]; }理解这个函数的递归过程:当 idx 指向最后一个系数时,直接返回常数项;否则先递归算出从下一项开始的多项式值,再乘以 x,加上当前系数。递归出口就是常数项。
调用时传入 idx=0,比如 coeffs 为 {3, 2, -1, 5, 1},n=5,x=2,输出 f(2)。
这个递归版本和迭代版本的逻辑完全等价,区别只在写法。递归版本更接近数学定义的“嵌套结构”,迭代版本更贴近计算机的执行方式。面试官让你用递归写,通常就是为了考察你是否能看出这种自相似结构。
3.3 复杂度分析与边界条件
嵌套乘法的乘法次数是 n-1,加法次数也是 n-1,复杂度 O(n),比逐项求幂的 O(n^2) 好得多。
写这个递归时容易犯的错有两个。
第一,递归出口写错。如果把idx == n - 1写成idx == n,数组会越界。原因在于最后一次递归返回的是系数本身,而不是再加一次 x 运算。
第二,系数数组顺序搞反。如果题目给的是升幂排列(常数项在前),递归逻辑就要反过来。从低位开始递归也可以,但写法是coeffs[idx] + x * evalPoly(...),而且出口在 idx == 0。很多人忽略这个问题,导致算出的答案完全不对。
我建议在代码里加上参数说明,明确“系数按最高次到最低次排列”,省得下次回头调试时还要重新推导。
3.4 递归多项式的另外一层含义:递推生成系数
除了求值,递归多项式还可以指“递推公式生成多项式”。比如切比雪夫多项式的递推关系:
- T0(x) = 1
- T1(x) = x
- Tn(x) = 2x * T(n-1)(x) - T(n-2)(x)
这种递归关系在数值计算中出现频次很高,比如逼近函数、滤波器设计。用代码实现这类递推很简单:
double chebyshev(int n, double x) { if (n == 0) return 1.0; if (n == 1) return x; return 2.0 * x * chebyshev(n - 1, x) - chebyshev(n - 2, x); }这里要注意的是,直接这么写会重复计算大量子问题,n 稍微大一点就会指数爆炸。优化方案有两个:一是加记忆化缓存,二是从底向上迭代。面试场景下,从底向上迭代更常见,因为不需要额外数组:
double T0 = 1.0; double T1 = x; for (int i = 2; i <= n; i++) { double T2 = 2 * x * T1 - T0; T0 = T1; T1 = T2; }对比这两段代码,会发现递归版本更短,但如果没有记忆化,效率极低。这本身就是递归主题的重要教训:递归清晰不等于高效,性能取决于子问题是否重复。
4. 链表排序:迭代和递归两种思路的完整落地
4.1 链表排序为什么比数组排序麻烦
链表排序是数据结构题里比较有区分度的一类。数组可以进行随机访问,容器可以直接使用标准库的排序算法;链表只有 next 指针,没有下标,所以很多数组排序的思路不能直接套用。
链表排序的常用方案有插入排序、归并排序。选择排序也可以,但需要频繁修改指针,代码容易出错。快速排序在单链表上也能实现,但划分时的指针操作复杂,实际面试题中较少让你写。归并排序因为天然适合链表的“顺序访问”特性,是最推荐的方案。
链表排序的核心难点有两个:一个是“如何切分链表”,另一个是“如何合并两个有序链表”。前者是归并排序的预处理,后者是归并排序的核心合并过程。下面分开讲。
4.2 切分链表的技巧:快慢指针
切分链表通常用快慢指针。一个指针每次走两步,另一个每次走一步,当快指针到达末尾时,慢指针正好停在中间位置。
struct ListNode { int val; struct ListNode *next; }; struct ListNode *getMid(struct ListNode *head) { struct ListNode *slow = head; struct ListNode *fast = head->next; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } return slow; }这里 fast 从 head->next 开始,而不是 head 开始,目的是在偶数长度链表上让 slow 停在前半段的末尾,这样切割后左右两边长度均衡或左少右多,归并过程更稳定。如果 fast 也从 head 开始,偶数长度时 slow 会偏向中间位置,切分时左边可能比右边多一个节点,虽然也能用,但处理不好容易丢节点。
切分方法找到 mid 后,保存right = mid->next,然后置mid->next = NULL,链表就从中间断成两半。这一步很关键,忘记断开会导致归并时出现环引用,栈溢出或死循环。
4.3 合并两个有序链表的递归与迭代写法
合并两个有序链表是链表题的基础。递归写法很简洁:
struct ListNode *mergeTwoLists(struct ListNode *a, struct ListNode *b) { if (!a) return b; if (!b) return a; if (a->val <= b->val) { a->next = mergeTwoLists(a->next, b); return a; } else { b->next = mergeTwoLists(a, b->next); return b; } }这个递归的巧妙之处在于:每次选择两个头节点中较小的一方,然后递归处理余下部分,最后返回当前头节点。递归出口是某一方为空时,直接返回另一方。递归深度和链表长度成正比,如果链表非常长,有栈溢出的风险,但面试中链表长度通常可控。
迭代写法用一个哑节点(dummy head)来简化逻辑:
struct ListNode *mergeTwoListsIter(struct ListNode *a, struct ListNode *b) { struct ListNode dummy; dummy.next = NULL; struct ListNode *cur = &dummy; while (a && b) { if (a->val <= b->val) { cur->next = a; a = a->next; } else { cur->next = b; b = b->next; } cur = cur->next; } cur->next = a ? a : b; return dummy.next; }哑节点的作用是不用单独处理头节点为空的情况,代码逻辑一下子清爽很多。最后cur->next = a ? a : b把剩余未合并的部分直接接上,省去循环。
对比两种写法,递归更短但依赖栈空间,迭代更稳但代码略长。建议两种都掌握,面试时根据题目要求选择。
4.4 单链表插入排序:基本操作但要小心断链
插入排序在链表上实现的时间复杂度是 O(n^2),适合小规模数据,也是验证链表指针操作基本功的好题。
思路是维护一个已排序部分,每次从未排序部分取出一个节点,在已排序部分找到合适位置插入。实现时可以使用一个 dummy head 指针,表示新链表的头部。
struct ListNode *insertionSortList(struct ListNode *head) { if (!head || !head->next) return head; struct ListNode *dummy = (struct ListNode *)malloc(sizeof(struct ListNode)); dummy->next = NULL; struct ListNode *cur = head; while (cur) { struct ListNode *pos = dummy; while (pos->next && pos->next->val < cur->val) { pos = pos->next; } struct ListNode *next = cur->next; cur->next = pos->next; pos->next = cur; cur = next; } return dummy->next; }这段代码的细节在插入部分。取当前节点 cur,把它的 next 先存到临时变量 next 中,因为接下来 cur->next 会被改写,不保存就会丢掉原链表后续节点。插入时把 cur->next 指向 pos->next,然后 pos->next = cur,完成链接。最后把 cur 更新为 next,继续处理下一个节点。
很多人第一次写链表插入排序时会在“保存 next”这一步犯错:如果没有next = cur->next,执行cur->next = pos->next之后,原链表在 cur 后面的部分就找不到了。这是链表操作最经典的一个坑。
4.5 链表归并排序的完整实现
链表归并排序的思路和数组归并排序一致,只是不用额外数组,而是重新连接指针。整体分三步:用快慢指针找中点,递归排好左右两段,合并两个有序链表。
struct ListNode *sortList(struct ListNode *head) { if (!head || !head->next) return head; struct ListNode *mid = getMid(head); struct ListNode *right = mid->next; mid->next = NULL; struct ListNode *leftSorted = sortList(head); struct ListNode *rightSorted = sortList(right); return mergeTwoLists(leftSorted, rightSorted); }看起来很短,但每一步都依赖前面讲过的基础函数。复杂度分析:每次切分 O(n),递归深度 O(log n),整体 O(n log n)。空间复杂度方面,递归栈消耗 O(log n),这比数组归并排序的 O(n) 额外空间要好,是链表归并的一大优势。
写归并排序时要重点检查三件事:切分是否正确断开、递归出口是否覆盖空链表和单节点、合并后返回的链表头是否正确。我建议在草稿纸上画一个小链表,比如 [4, 2, 1, 3],手动走一遍递归流程,能排查掉绝大多数指针错误。
4.6 常见链表排序问题速查
下面是我在调试链表排序时整理的高频问题,每条都对应一次真实的返工:
- 断链处没有置 NULL 导致循环链表。首尾相接后,归并或打印会死循环,排查方法是每次切分后检查 mid->next 是否为 NULL。
- 合并时没用哑节点,头节点处理分身乏术。养成“合并链表一律用 dummy head”的习惯,能省去大量空指针判断。
- 找中间节点时快慢指针初始化错。fast 和 slow 必须按需求初始化,如果 fast = head,偶数长度链表会找错中点位置。
- 递归归并时栈区告急。链表长度在几万以上时,递归深度也数万,考虑改迭代实现或调高栈大小。
- 插入排序保存后继节点的时机不对。保存 next 必须在修改 cur->next 之前,这个顺序一定不能反。
链表题目看似灵活,其实核心技能就两个:会用哑节点,并且永远记住“改动指针之前先备份后继”。
我的实操体会
四个主题写下来,我自己最大的感受是:菱形打印练的是“把规律公式化”的意识,数位运算练的是“不要依赖字符串 API”的自觉,递归多项式练的是“把数学定义翻译成代码”的对应能力,链表排序练的是“指针操作前先画图”的习惯。它们都不是什么高深算法,但面试时很多人的失败不是不会思路,而是小细节扣不到位。我建议把这四个题目当作一套基本功练习,抽出两三个下午逐行手写一遍,再自己扩展几个变种,比如把菱形换成空心、把数位和改成位数统计、把多项式求值改成多返回值、把链表归并改成按奇偶切分。基础吃透了,后面遇到复杂问题才不会慌。