第 2 讲 · 程序设计、递归与调试
程序设计的难点不在于把语法写对,而在于把含糊的问题变成一组边界明确、能够验证的步骤。课件把“获得程序设计能力”的途径概括得很直接:读程序只能帮助理解,真正的能力来自自己分析、实现和调试。
先分清程序设计与程序设计语言
程序设计回答“怎样解决问题”;C 语言只是表达这个方案的工具。一个好的方案应先写清:
- 输入是什么,范围和格式是什么;
- 输出是什么,是否有唯一结果;
- 核心数据需要怎样组织;
- 处理可以拆成哪些彼此独立的步骤;
- 哪些边界情况最容易出错。
直接把整道题塞进 main 往往会同时混合输入、数据组织、算法和输出。一旦出错,很难确定是哪一层出了问题。
模块化:每个函数只负责一件事
函数的接口由参数、返回值和副作用组成。设计函数时,应让调用者不必知道内部细节。例如链表问题可以拆成:创建结点、查找位置、插入、删除、打印和释放。
好的函数还应明确所有权:谁申请内存、谁最终释放;传入的是值、地址,还是可能被修改的结构。
递归为什么有效
递归把一个问题写成“同类但规模更小的问题”。完整递归必须有:
- 基本情况:直接得到答案并停止继续调用;
- 递归情况:把当前问题缩小;
- 组合方式:利用子问题答案得到当前答案。
阶乘可以写成
long factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
每次调用都把 n 减一,因此一定走向基本情况。
调用栈里发生了什么
每次函数调用都要保存参数、局部变量和返回位置。递归深入时,这些现场按后进先出的顺序压入调用栈;到达基本情况后,再逐层返回。
因此递归写法短,不表示执行代价小。递归深度为 时,调用栈通常至少需要 空间。没有终止条件或规模没有缩小,会不断压栈并最终溢出。
汉诺塔体现的分解方式
把 个盘从 A 移到 C:
- 先把上面 个从 A 移到 B;
- 把最大的盘从 A 移到 C;
- 再把 个从 B 移到 C。
移动次数满足 。递归结构很自然,但复杂度也说明规模稍大就不可行。
调试不是反复改到“能跑”
源课件以 Dev-C++ 演示断点、单步执行和变量观察。工具可能变化,方法不变:
- 用最小输入稳定复现问题;
- 先写出这个输入下的预期状态序列;
- 在关键状态变化前设断点;
- 单步执行,观察变量、数组、指针和调用栈;
- 找到“第一次偏离预期”的语句。
最终输出不对只是结果,第一次错误状态才接近原因。
三类常见运行错误
SIGSEGV
通常来自非法内存访问:野指针、空指针解引用、数组越界、已经释放的内存再次使用。先检查指针是否初始化,再检查下标范围和生命周期。
SIGFPE
常见原因是整数除零或取模时除数为零。不要只检查字面量,也要检查经过计算后可能变成零的变量。
SIGABRT
常见于重复释放、释放错误地址、堆越界破坏了分配器维护的信息。问题往往发生在报错之前,因此要向前追踪最近的内存写操作。
一套可复用的自测方式
至少准备:
- 最小合法输入;
- 空输入或零元素;
- 单元素;
- 恰好触及容量边界;
- 重复元素;
- 已有序和完全逆序;
- 不存在目标的查找;
- 会改变头、尾或根结点的操作。
能解释这些输入下每一步的结构状态,才算真正掌握了程序。