智能程序设计(C语言)


函数 (III) · 递归

计算机学院    杨已彪

yangyibiao@nju.edu.cn

提纲

  • 递归是什么 (两个要素、信任递归)

  • 递归执行过程 (层层递推与回归)

  • 经典案例: Fibonacci (弹性)、汉诺塔

  • 常见错误与课堂编码 (基础 3 题 + 案例 5 题)

  • 分治与快速排序 (弹性)

  • 课堂小测


递归是什么

递归: 函数直接或间接地自己调用自己——有些问题的定义本身就是递归的: 阶乘、汉诺塔、目录遍历……

代码
/* 阶乘: n! = n × (n-1)! */
long long f(long long n)
{
  if (n == 0) {            /* 基准: 0! = 1, 唯一的奠基 */
    return 1;
  }
  return n * f(n - 1);     /* 递归: 1! 由 0! 归纳出来 */
}
/* 求幂: x^n = x × x^(n-1), 可以精简成一行 */
int power(int x, int n) {
    return n == 0 ? 1 : x * power(x, n - 1);
}
要点
  • 两个要素: 基准情形 (最简单, 直接给答案) + 递归情形 (缩小后再调用自己)

  • 信任递归: 假设 n-1 层的答案是对的, 用它构造 n 的答案——不要在脑中展开所有层

  • 递归三问: 最小问题? 怎么缩小? 怎么构造当前答案?

  • 为什么能自己调用自己? 函数名只是代码的入口——每次调用都开一张新便签、配一套新参数, 互不干扰

递归 = 归纳法的计算版本: 基准 = 奠基, 递归 = 归纳, 信任 = 归纳假设


递归执行过程: 压栈与返回

fact(3)n = 3 → 3 × fact(2)fact(2)n = 2 → 2 × fact(1)fact(1)n = 1 → 1 × fact(0)fact(0)n = 0,命中基准 → return 1递推递推递推回归(弹栈)返回 3 × 2 = 6返回 2 × 1 = 2返回 1 × 1 = 1返回 1蓝色:递推(压栈) 绿色:回归(弹栈)

fact(3) 的跟踪——递推下去, 再回归上来:

  • 每一层调用压一张便签 (昨天的栈), 回归时逐层弹栈结算——递归就是便签一层层叠高、再一层层揭掉

  • 每层调用有自己的一套局部变量和参数, 各层互不干扰 (作用域与生命期见上一讲)

  • 递归深度 = 压栈层数; 递归过深 → 栈溢出

🎬 动画: 打开调用栈动画, 看阶乘递归的层层压栈与返回

🌐 Python Tutor: 把课程中的阶乘代码粘进去, 逐行看栈帧——每调用一次 fact, 栈上多一张便签; 返回时逐层揭掉

🎬 打开调用栈动画 (function-and-stack.html) 🌐 在 Python Tutor 中演示 (逐行看栈帧)

Fibonacci 数列

代码
#include <stdio.h>
long long fib(long long n)
{
  if (n == 0) {              /* 奠基之一: F(0) = 0 */
    return 0;
  }
  if (n == 1) {              /* 奠基之二: F(1) = 1 */
    return 1;
  }
  return fib(n - 1) + fib(n - 2);
}
int main(void)
{
  printf("fib(10) = %lld\n", fib(10));
  return 0;
}

样例输出: fib(10) = 55

要点
  • 两个奠基: 递推式需要前两项, 所以基准情形有两个、返回不同值 (对比阶乘: 只靠 0! 一个奠基就够)

  • F(n) = F(n-1) + F(n-2): 一次调用分成两个分支——递归展开成一棵二叉树, 深度 n、最多 2ⁿ 个结点

  • 重复计算: fib(5) 会把 fib(3) 算 2 遍、fib(2) 算 3 遍——朴素递归的代价; 优化办法 (记忆化/动态规划) 课下可以了解


Hanoi 塔 · 任务与规则

传说: 印度教寺庙里有 3 根柱子、64 个金盘, 僧侣昼夜不停地移动; 传说 64 个盘移完之日就是世界末日——放心, 一共要 2⁶⁴ − 1 步, 约 5849 亿年

初始 (n = 3)
A B C 3 2 1
目标
A B C 3 2 1

盘子编号: 最小的叫 盘 1 (叠在最上面), 往下依次是 盘 2、盘 3……, 最大的叫 盘 n (最下面; 后面代码输出的就是这些编号)

目标: 把 n 个盘子从 A 柱全部移到 C 柱, 结束时仍然上小下大 (和初始状态一样)

规则:

  • 每次只能移动一个盘子 (某根柱最上面的)

  • 大盘不能压在小盘上

  • 可以借 B 柱暂存

任务: 给定任意 n, 设计算法输出全部移动步骤 (每一步: 哪个盘、从哪柱到哪柱)

→ 下一页: 先看 3 个盘实际怎么挪


Hanoi 塔 · 盘子动画

A B C 3 2 1
初始状态: 3 个盘都在 A
0 / 7
  • 按 "下一步" 逐盘观察: 大盘永远不压小盘 (规则见上一页), 3 个盘恰好要 7 步

  • 第 4 步恰好是最大盘就位——后面讲递归分解时对照着看


Hanoi 塔

/* hanoi.c: A = 起始柱, B = 辅助柱, C = 目标柱;
   n = 本次要移的盘数 (最大盘编号) */
#include <stdio.h>

void Hanoi(int n, char A, char B, char C)
{
  if (n == 1) {
    /* 基准: 只剩一个盘 */
    printf("盘 %d: %c -> %c\n", n, A, C);
  } else {
    /* 上面 n-1 个: A -> B (借 C) */
    Hanoi(n - 1, A, C, B);
    /* 最大盘: A -> C */
    printf("盘 %d: %c -> %c\n", n, A, C);
    /* 上面 n-1 个: B -> C (借 A) */
    Hanoi(n - 1, B, A, C);
  }
}

int main(void)
{
  /* 3 个盘的完整移法 */
  Hanoi(3, 'A', 'B', 'C');
  return 0;
}
n n n-1 个 ① ② ③ A B C
① n-1 个: A → B (虚线)    ② 盘 n: A → C (实线)    ③ n-1 个: B → C (虚线)

刚才的动画 (3 个盘、7 步) 正是来自下面的分解 (A 起始、B 辅助、C 目标):

  • ① 先把上面 n-1 个 A → B (借 C)——同类问题小 1

  • ② 把最大盘 n 直接 A → C——一步完成

  • ③ 再把那 n-1 个 B → C (借 A)

①③ 交给递归; n = 1 直接 A → C (基准情形)

⚡ 打开 hanoi.c

Hanoi 塔 · 调用过程与运行结果

动画里的 7 步, 正是下面这棵调用树逐层产生的:

Hanoi(3, A, B, C)
 ├─ Hanoi(2, A, C, B)        ← 先把上面 2 个 A→B
 │    ├─ Hanoi(1, A, B, C) → 盘1: A→C
 │    ├─ 盘2: A→B
 │    └─ Hanoi(1, C, A, B) → 盘1: C→B
 ├─ 盘3: A→C                 ← 最大盘就位
 └─ Hanoi(2, B, A, C)        ← 再把上面 2 个 B→C
      ├─ Hanoi(1, B, C, A) → 盘1: B→A
      ├─ 盘2: B→C
      └─ Hanoi(1, A, B, C) → 盘1: A→C

运行输出 (共 7 步): 盘1: A→C   盘2: A→B   盘1: C→B   盘3: A→C   盘1: B→A   盘2: B→C   盘1: A→C

🌐 在 Python Tutor 中演示 (粘代码, 逐层看调用栈)
常见错误

  • 缺少基准情形: 递归永不终止 (如忘了 n==0 的判断)

  • 问题没有缩小: 每次调用与上次规模相同 (如 f(n) 里调用 f(n))

写递归的两步检查: ① 基准情形对吗? ② 每次调用规模在变小吗?

→ 下一页: 现场演示"没有基准情形"的下场——调用栈爆掉


演示: 没有基准情形的栈溢出

代码
/* 不规范: 少了基准情形 */
long long fact(int n)
{
  return n * fact(n - 1);
}

int main(void)
{
  printf("%lld\n", fact(5));
  return 0;
}

运行结果: 一阵呼啸之后——

Segmentation fault (core dumped)

⚡ 在 VS Code 中打开 stack-overflow.c (直接编译运行, 看它崩溃)
要点
  • 每一层调用压一张便签; 没有基准情形, 便签就无限叠高——谁也不返回, 谁也腾不出空间

  • 栈的空间是有限的 (默认几 MB): 用尽即 栈溢出 (stack overflow), 程序直接崩溃

  • 即使有基准情形, 极大的递归也会爆: 递归深度以"层"计, 大输入要留意 (如对 10 万个元素的数组递归求最大值)

  • 应对: 递归三问缺一不可; 有些递归能改写成循环——循环不占栈, 深了也不怕 (这是"尾递归优化"的朴素版理解)


课堂编码: 基础三题

课堂编码 1: 递归求幂

任务: 实现 int power(int x, int n)——计算 x 的 n 次方 (开篇"递归是什么"一页提过的例子, 这里把它写完整)

样例: power(5, 3) → 125; power(2, 10) → 1024

先想参数: 哪些量随递归变化、哪些全程不变?

⚡ 在 VS Code 中打开 power-rec.c (只写函数体)
课堂编码 2: 递归求和

任务: 实现 int sum(int n)——计算 1 + 2 + … + n

样例: sum(10) → 55; sum(100) → 5050

提示: 先想清楚递推关系和递归出口, 再动手写

⚡ 在 VS Code 中打开 sum-rec.c (只写函数体)
课堂编码 3: 递归打印整数

任务: 实现 void print(int n)——输入 1234, 按位逐个打印

样例: 1234 → 1 2 3 4

提示: 先递归打印前面几位, 回来再打印末位——顺序是本题关键

⚡ 在 VS Code 中打开 print-rec.c (只写函数体)

课堂编码 · 参考实现

参考实现 1: 递归求幂
int power(int x, int n)
{
  /* 基准 x^0=1; 递归 x × x^(n-1) */
  return n == 0 ? 1 : x * power(x, n - 1);
}
  • 参数即状态: 底数 x 全程不变, 指数 n 逐层减 1——两个量都要进参数, 缺一不可

  • 基准情形: n == 0 → 1; 递归情形把问题缩小成 x^(n-1)

参考实现 2: 递归求和
int sum(int n)
{
  /* 基准 0; 递归 n + sum(n-1) */
  return n == 0 ? 0 : n + sum(n - 1);
}
  • 参数即状态: 只带 n——"加到几为止"就是子问题的全部状态

  • 基准情形: n == 0 → 0; 递归情形: sum(n) = n + sum(n-1)

参考实现 3: 递归打印整数
void print(int n)
{
  if (n / 10 == 0) {
    /* 基准: 一位数直接打印 */
    printf("%d", n);
  } else {
    /* 先递归打印前面几位 */
    print(n / 10);
    /* 回来再打印末位 —— 顺序是本题关键 */
    printf(" %d", n % 10);
  }
}
  • 参数即状态: 只带 n; 顺序是本题关键——先递归打印 n/10, 回来再打印末位 n%10, 得到正序

  • 基准情形: 一位数 (n / 10 == 0) 直接打印


课堂案例: 递归求最大值与判断回文

课堂案例 4: 递归求最大值

任务: 实现 int max_rec(int a[], int n)——最大值 = a[n-1] 与 "前 n-1 个的最大值" 里较大的那个 (与之前"先假设再修正"同源)

样例: a = {3, 9, 1, 7, 5} → 输出 9

⚡ 在 VS Code 中打开 max-rec.c (只写函数体)
课堂案例 5: 判断回文

任务: 判断字符串是否回文——两端字符相等, 且中间那一段也是回文 (回文用循环双指针更自然; 这题的价值是练习"把递归定义直接翻译成代码")

先想参数: int is_pal( ??? )——子问题是"首尾去掉后剩下的中间一段", 要几个参数才能刻画它? 需要复制一个新字符串吗? 想清楚再打开 VS Code 对照

样例: "level" → 1, "hello" → 0

⚡ 在 VS Code 中打开 pal-rec.c (自己设计签名 + 函数体 + main 调用)

课堂案例: 递归求最大值与判断回文 · 参考实现

案例 4: 递归求最大值
int max_rec(int a[], int n)
{
  int rest;
  if (n == 1) {                    /* 基准情形 */
    return a[0];
  }
  rest = max_rec(a, n - 1);        /* 递归: 前 n-1 个的最大值 */
  return a[n - 1] > rest ? a[n - 1] : rest;
}
  • 递归情形里先信任 max_rec(a, n-1) 是对的, 再拿 a[n-1] 和它比——这就是"信任递归"

案例 5: 判断回文
int is_pal(char s[], int l, int r)
{
  if (l >= r) {                    /* 基准: 0 或 1 个字符 */
    return 1;
  }
  return s[l] == s[r] && is_pal(s, l + 1, r - 1);
}
  • 参数即状态: l、r 刻画当前子串——不复制字符串, 只移动两个下标

  • 定义即代码: 把"首尾相等且中间是回文"逐字翻译出来, 就是函数体


课堂案例: 二分查找

课堂案例 6: 二分查找

任务: 在升序数组 a 的某个区间里查找 key——每次砍一半, 与分治同构; 找到返回下标, 找不到返回 -1

先想参数: int bin_search( ??? )——子问题是什么? 哪几个量随递归变化、哪几个全程不变? 想清楚再打开 VS Code 对照骨架里的签名

样例: 在 {2, 5, 8, 12, 19} 中找 8 → 下标 2; 找 7 → -1

⚡ 在 VS Code 中打开 bin-rec.c (自己设计签名 + 函数体 + main 调用)
课堂案例: 二分查找 · 参考实现

int bin_search(int a[], int low, int high, int key)
{
  int mid;
  if (low > high) {                /* 基准: 没找到 */
    return -1;
  }
  mid = (low + high) / 2;
  if (a[mid] == key) {
    return mid;
  }
  if (a[mid] > key) {
    return bin_search(a, low, mid - 1, key);
  }
  return bin_search(a, mid + 1, high, key);
}
  • 参数即状态: 递归函数的参数要能完整描述"当前子问题"——low、high 刻画查找区间, key 与 a 全程不变

  • 每次调用区间必然缩小——这保证递归一定终止; 下一页分治法正是这个思想的放大版


课堂案例: 组合数与爬楼梯

课堂案例 7: 组合数

任务: C(m,n) = 从 m 个元素中选出 n 个的方案数。实现 long comb(int m, int n)——按"选/不选第 m 个元素"分成两类: C(m,n) = C(m-1,n-1) + C(m-1,n); 对比 8-2 用 factorial 的公式算法: 递归定义有时比公式更直接

样例: comb(5, 2) → 10

⚡ 在 VS Code 中打开 comb-rec.c (只写函数体)
课堂案例 8: 爬楼梯

任务: 有 n 级台阶, 每次可以走 1 或 2 级——从第 0 级走到第 n 级共有多少种走法? 实现 int climb(int n)

样例: climb(4) → 5 (走法: 1111、112、121、211、22)

⚡ 在 VS Code 中打开 climb-rec.c (只写函数体)

课堂案例: 组合数与爬楼梯 · 参考实现

案例 7: 组合数
long comb(int m, int n)
{
  if (n == 0 || n == m) {          /* 基准情形 */
    return 1;
  }
  return comb(m - 1, n - 1) + comb(m - 1, n);
}
  • 同一行里两个递归调用——先展开一层看: "选中第 m 个"与"不选第 m 个"两类, 加起来

案例 8: 爬楼梯
int climb(int n)
{
  if (n <= 1) {                    /* 基准情形 */
    return 1;
  }
  return climb(n - 1) + climb(n - 2);
}
  • 生活问题→递归定义: 走到第 n 级的方法数 = 走到 n-1 的方法数 + 走到 n-2 的方法数


分治法

分治法 (divide and conquer): 把大问题拆成几个同类的小问题, 分别解决, 再合并结果

  • 三步: 分 (拆) → 治 (解决小问题) → 合 (拼起来)

  • 生活例子: 整理一摞试卷——先按分数段分堆, 每堆再各自整理

  • 排序中的分治 (快速排序): 选一个基准, 把数组划分成"小的在左、大的在右"两段, 再对两段各自递归——不需要合并 (划分完位置就对了); 过程见下一页

  • ("分成两半各自排好再合并"是另一种排序——归并排序, 课下可以了解)

递归常常是分治法的自然结果——快速排序正是如此


分治图示: 快速排序

原数组划分后最终529183① 划分213598左段(<5)右段(>5)② 各自递归123589基准 = 55 已就位左右各自递归无需合并基准位置一次到位 → 左右两段递归即可,不需要合并
  • ① 划分: 选基准 5, 小的放左、大的放右——5 的位置就此一次到位

  • ② 治: 左段 [2, 1, 3] 和右段 [9, 8] 各自递归——同样先划分、再递归

  • ③ 合: 什么都不用做——每次划分都把基准放到最终位置, 递归结束时整个数组就有序了

⚡ 自己动手: 打开 qsort-rec.c (框架只有 main)——对照图示写 quicksort 和 split
递归排序: 快速排序

用递归做排序: 选一个基准, 小的放左、大的放右, 再对左右递归

void quicksort(int a[], int low, int high)
{
  int middle;

  if (low >= high) {          /* 基准情形: 只剩 0 或 1 个元素 */
    return;
  }
  middle = split(a, low, high);       /* 划分: 小的在左, 大的在右 */
  quicksort(a, low, middle - 1);      /* 递归排左半 */
  quicksort(a, middle + 1, high);     /* 递归排右半 */
}
  • 平均 O(n log n), 比之前三种排序的 O(n^2) 快得多

  • split 是划分函数, 下一页展开


快速排序: 划分函数 split

int split(int a[], int low, int high)
{
  int part_element = a[low];

  for (;;) {
    while (low < high && part_element <= a[high]) {
      high--;
    }
    if (low >= high) {
      break;
    }
    a[low++] = a[high];

    while (low < high && a[low] <= part_element) {
      low++;
    }
    if (low >= high) {
      break;
    }
    a[high--] = a[low];
  }

  a[high] = part_element;
  return high;
}

split: 以 a[low] 为基准, 把小元素换到左边、大的换到右边, 返回基准的最终下标

完整可运行程序: code/qsort-split.c (无指针版, 全部用数组下标)


课堂小测

  1. 概念: 递归函数必须具备哪两个要素?
  2. 概念: f(5) 计算 5! 的过程中,栈上最多同时压了几层调用?
  3. 代码阅读: fib(5) 中 fib(2) 被计算了几次?这说明了什么?
  4. 纠错: 递归函数忘了写基准情形会怎样?



未完待续