20、指针与递归:递归函数中的指针传递、递归遍历链表、递归遍历二叉树

指针和递归,这两个东西单独拎出来都够喝一壶的。把它们放一起?很多人第一反应是「头大」。但说实话,在嵌入式开发里,这俩是黄金搭档。尤其是处理链表、二叉树这类动态数据结构时,递归配合指针,代码能写得又短又清晰。

我个人习惯是:能用递归解决的问题,绝不硬写循环。当然,前提是递归深度可控。嗯,这里要注意——嵌入式环境里栈空间有限,递归太深容易爆栈。这个我们后面会细说。

递归函数中的指针传递

先聊个基础问题:递归函数里,指针怎么传?

其实很简单——传指针就是传地址。递归调用时,每一层函数都有自己的栈帧,但指针指向的是同一块内存。这意味着你在递归深处修改了数据,外层也能看到。

举个例子,写一个递归函数,把链表里所有节点的值加1:

void increment_all(Node *head) {
    if (head == NULL) return;   // 递归终止条件
    head->value += 1;           // 修改当前节点
    increment_all(head->next);  // 递归处理下一个
}

你看,这里传的是 Node *head,每一层递归都拿着同一个链表的节点指针。修改是「原地」的,不需要返回值。这就是指针传递的威力——递归调用链上的所有函数,共享同一份数据。

我的经验: 递归函数里传指针,一定要先检查 NULL。我曾经在一个项目里忘了判空,结果递归到链表尾部时直接访问了非法地址,系统硬复位。从那以后,我写递归的第一行永远是终止条件判断。

递归遍历链表

链表遍历,用循环写很直观。但用递归写,代码更简洁,而且能帮你理解「递归思维」。

先看一个正向遍历的例子:

void print_list(Node *head) {
    if (head == NULL) {
        printf("NULL\n");
        return;
    }
    printf("%d -> ", head->value);
    print_list(head->next);
}

输出:10 -> 20 -> 30 -> NULL

那反向遍历呢?只需要调换一下顺序:

void print_list_reverse(Node *head) {
    if (head == NULL) return;
    print_list_reverse(head->next);
    printf("%d -> ", head->value);
}

输出:30 -> 20 -> 10 -> NULL

为什么会这样?因为递归调用是「先递后归」。正向遍历是在「递」的过程中打印,反向遍历是在「归」的过程中打印。你想想看,这比用栈或者反转链表简单多了吧?

避坑指南: 我曾经在一条几百个节点的链表上用递归遍历,结果栈溢出了。嵌入式环境里,递归深度超过几百层就要小心。建议:链表长度可控(比如几十个节点)再用递归,否则老老实实用循环。

递归遍历二叉树

二叉树才是递归的「主场」。二叉树的天然递归结构——每个节点又是一棵子树的根——让递归遍历变得极其自然。

三种经典遍历方式,用递归写出来几乎就是「教科书」级别的简洁:

// 前序遍历:根 -> 左 -> 右
void preorder(TreeNode *root) {
    if (root == NULL) return;
    printf("%d ", root->value);
    preorder(root->left);
    preorder(root->right);
}

// 中序遍历:左 -> 根 -> 右
void inorder(TreeNode *root) {
    if (root == NULL) return;
    inorder(root->left);
    printf("%d ", root->value);
    inorder(root->right);
}

// 后序遍历:左 -> 右 -> 根
void postorder(TreeNode *root) {
    if (root == NULL) return;
    postorder(root->left);
    postorder(root->right);
    printf("%d ", root->value);
}

代码几乎一模一样,只是 printf 的位置不同。这就是递归的美妙之处——逻辑清晰,不易出错。

我记得有一次做嵌入式文件系统,需要遍历目录树来统计文件大小。目录结构本身就是一棵多叉树,我用递归后序遍历,每个子目录的大小由子节点累加得到。代码不到20行,搞定。

递归遍历的核心逻辑

下面这张图,帮你理清递归遍历二叉树的核心逻辑:

递归遍历二叉树核心逻辑 左左 左右 右左 右右 遍历顺序 前序:根 → 左 → 右 中序:左 → 根 → 右 后序:左 → 右 → 根

说白了,递归遍历就是「遇到节点,决定什么时候处理它」。前序、中序、后序的区别,仅仅是 printf 的位置不同。这个规律记牢了,二叉树遍历你就掌握了八成。

递归遍历的实际应用

光说不练假把式。我举两个实际场景:

场景一:计算二叉树深度

int tree_depth(TreeNode *root) {
    if (root == NULL) return 0;
    int left_depth = tree_depth(root->left);
    int right_depth = tree_depth(root->right);
    return (left_depth > right_depth ? left_depth : right_depth) + 1;
}

这个函数用后序遍历的思想:先算左子树深度,再算右子树深度,取较大值加1。递归的每一层都返回当前子树的深度,最终得到整棵树的高度。

场景二:释放二叉树所有节点

void free_tree(TreeNode *root) {
    if (root == NULL) return;
    free_tree(root->left);
    free_tree(root->right);
    free(root);
}

这里必须用后序遍历。为什么?因为你要先释放子节点,再释放父节点。如果先释放父节点,子节点就找不到了——内存泄漏的坑就是这么来的。

核心要点: 递归遍历二叉树时,指针传递保证了所有递归层共享同一棵树。修改节点内容、释放节点内存,都是「原地」操作。但一定要确保递归终止条件正确,否则就是无限递归 + 栈溢出。

递归 vs 循环:怎么选?

我列个对比表,帮你快速决策:

对比维度 递归 循环
代码简洁度 极高,尤其适合树形结构 一般,需要手动维护栈
可读性 逻辑清晰,接近数学定义 需要理解循环控制流
栈空间 每层递归消耗栈帧 只消耗一层栈帧
适用场景 深度可控的树/链表遍历 深度大、性能敏感的场景
嵌入式建议 深度 < 100 层可用 深度不限,推荐使用

我个人习惯是:二叉树遍历无脑用递归。链表遍历看情况——链表短(几十个节点)用递归,链表长(几百上千)用循环。毕竟嵌入式环境里,栈空间是稀缺资源,不能乱来。

曾经踩过的坑: 有一次我在一个中断服务函数里递归遍历二叉树,结果递归深度才几十层,但中断栈本来就小,直接溢出了。从那以后,我定了个规矩:中断里绝对不用递归,循环才是王道。

小结

指针和递归的组合,说白了就是「用指针共享数据,用递归简化逻辑」。链表和二叉树的递归遍历,是理解这个组合的最佳练习。你只要记住三点:

  • 递归终止条件必须写,而且必须写在最前面
  • 指针传递让所有递归层共享同一份数据
  • 前序、中序、后序的区别,就是处理节点的时机不同

把这些练熟了,指针和递归这块你就拿下了。后面遇到更复杂的数据结构,比如红黑树、AVL树,递归遍历的思路完全一样,只是处理逻辑更复杂一些。


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