第38章:递归进阶——汉诺塔问题与快速排序的递归实现

递归这东西,说实话,很多初学者学到这儿就开始犯怵了。我当年也一样,看到递归调用自己,总觉得脑子要绕成一个死循环。但后来我发现,递归其实就两个核心:递出去,再归回来。你只要把这两个动作想清楚,什么汉诺塔、快速排序,统统不在话下。

这一章,咱们就啃两块硬骨头:汉诺塔问题快速排序的递归实现。这两个例子,一个是经典的递归思维训练,一个是实际工程中高频使用的算法。搞懂了它们,你对递归的理解会上一个台阶。

38.1 汉诺塔问题:递归思维的经典案例

先说说汉诺塔。这个问题的背景很简单:有三根柱子,A、B、C。A柱上有n个大小不等的圆盘,大的在下,小的在上。目标是把所有圆盘从A移到C,过程中可以借助B。规则就两条:一次只能移动一个盘大盘不能压在小盘上

你想想看,如果只有1个盘,直接挪过去就完事了。但如果有3个、5个、甚至64个盘呢?手动模拟会疯掉。这时候递归就派上用场了。

我个人习惯把汉诺塔的递归思路总结成三步:

  1. 把上面n-1个盘从A移到B(借助C)
  2. 把最底下的第n个盘从A移到C
  3. 再把B上的n-1个盘从B移到C(借助A)

你看,问题规模从n变成了n-1,这就是递归的典型特征——问题规模逐步缩小,直到边界条件

核心要点:汉诺塔的递归本质是——把大问题拆成两个子问题加一个基本操作。子问题和原问题形式相同,只是规模更小。

代码实现其实很简洁:

#include <stdio.h>

void hanoi(int n, char from, char to, char aux) {
    if (n == 1) {
        printf("移动盘 1 从 %c 到 %c\n", from, to);
        return;
    }
    hanoi(n - 1, from, aux, to);
    printf("移动盘 %d 从 %c 到 %c\n", n, from, to);
    hanoi(n - 1, aux, to, from);
}

int main() {
    int n = 3;
    hanoi(n, 'A', 'C', 'B');
    return 0;
}

运行结果会打印出每一步的移动过程。你试试n=3,看看输出,再对照我上面说的三步,就能理解递归是怎么一层层展开又一层层收回的。

小提示:我在项目中遇到过类似的问题——需要把一批任务按依赖关系重新排序。当时我就想到了汉诺塔的递归思想,把依赖链拆解成子任务,逐层处理。递归思维在复杂逻辑拆解中非常实用。

38.2 快速排序:递归在排序中的经典应用

快速排序,简称快排,是实际工程中最常用的排序算法之一。C标准库里的qsort就是基于快排实现的。它的核心思想也是递归——分而治之

快排的基本思路是这样的:

  1. 从数组中选一个元素作为基准(pivot)
  2. 把比基准小的元素放左边,比基准大的放右边——这一步叫分区(partition)
  3. 对左右两个子数组递归地执行同样的操作

说白了,就是每次选一个基准,把数组分成两半,然后分别处理。直到子数组只剩一个元素,递归结束。

注意:快排的性能高度依赖基准的选择。如果每次选的基准都恰好是中位数,时间复杂度是O(n log n)。但如果选的基准总是最小或最大,就会退化成O(n²)。我曾经在项目中遇到过线上数据分布不均匀导致快排变慢的情况,后来改用三数取中法选基准,问题就解决了。

来看代码实现:

#include <stdio.h>

// 分区函数
int partition(int arr[], int low, int high) {
    int pivot = arr[high];  // 选最后一个元素作为基准
    int i = low - 1;

    for (int j = low; j < high; j++) {
        if (arr[j] < pivot) {
            i++;
            int temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
        }
    }
    // 把基准放到正确位置
    int temp = arr[i + 1];
    arr[i + 1] = arr[high];
    arr[high] = temp;
    return i + 1;
}

// 快速排序的递归实现
void quickSort(int arr[], int low, int high) {
    if (low < high) {
        int pi = partition(arr, low, high);
        quickSort(arr, low, pi - 1);   // 排序左半部分
        quickSort(arr, pi + 1, high);  // 排序右半部分
    }
}

int main() {
    int arr[] = {10, 7, 8, 9, 1, 5};
    int n = sizeof(arr) / sizeof(arr[0]);
    quickSort(arr, 0, n - 1);
    for (int i = 0; i < n; i++)
        printf("%d ", arr[i]);
    return 0;
}

你看,quickSort函数里调用了自己两次——一次处理左边,一次处理右边。这就是递归的典型用法:先分治,再递归

38.3 递归的避坑指南

说到递归,我得提醒你几个容易踩的坑。这些都是我当年亲手踩过的:

  • 忘记写终止条件——递归会无限调用下去,最终栈溢出。我曾经调试一个bug调了一下午,最后发现就是少了个if (n == 0) return;
  • 递归层数太深——每层递归都会占用栈空间。如果递归深度达到几千甚至上万,程序很容易崩溃。我建议递归深度超过1000的场景,考虑改用循环或迭代。
  • 重复计算——比如斐波那契数列的朴素递归实现,会有大量重复计算。这时候可以用记忆化递归动态规划来优化。

经验之谈:递归不是万能的。有些问题用循环更直观,有些问题用递归更优雅。我的原则是——如果递归能让代码更清晰,就用;如果递归让代码更难懂,就别用

38.4 本章知识体系总览

下面这张图帮你梳理了本章的核心知识点和它们之间的关系:

递归进阶 汉诺塔问题 三步递归法 规模缩小到n-1 快速排序 分区(partition) 分治递归 避坑指南 终止条件 | 栈溢出 | 重复计算 递归 = 递出去 + 归回来 汉诺塔:三步递归法,问题规模逐步缩小 快速排序:分区 + 分治,递归处理左右子数组 避坑:终止条件、栈深度、重复计算

38.5 写在最后

递归这东西,你光看是看不明白的。我建议你打开编译器,把上面的代码敲一遍,单步调试走一走。看看函数调用栈是怎么一层层压进去,又一层层弹出来的。嗯,等你亲眼看到那个过程,你就真正理解了。

记住一句话:递归不是魔法,是分治思想的自然表达。你只要抓住「递出去」和「归回来」这两个动作,递归就不再神秘。

个人建议:学完这一章,你可以试着用递归实现一个二分查找归并排序。这两个也是递归的经典应用,练熟了,你的递归功力就真正到家了。


公众号:蓝海资料掘金营,微信 deep3321