数据结构:迭代方法(Iteration)实现树的遍历

目录

为什么需要迭代?------从递归的"天花板"说起

准备我们的工具------手动实现一个栈

迭代遍历的逐一推导

迭代中序遍历 (L -> D -> R)

迭代前序遍历 (D -> L -> R)

迭代后序遍历 (L -> R -> D)

总结与完整代码


为什么需要迭代?------从递归的"天花板"说起

递归,本质上是函数自己调用自己。

在计算机底层,每一次函数调用,系统都需要在一种叫做"调用栈 (Call Stack)"的内存区域里保存一些信息(比如函数参数、返回地址、局部变量等)。这样,当内层函数返回时,外层函数才能知道从哪里继续执行。

这套机制很完美,但有一个致命的弱点:调用栈的容量是有限的

数据结构:二叉树的遍历 (Binary Tree Traversals)-CSDN博客

想象一棵极不平衡的"斜树",它基本上就是一条链表:

复制代码
A
 \
  B
   \
    C
     \
      ... (10万个节点)

当我们对这棵树进行递归遍历时,比如 preOrder(A) 会调用 preOrder(B)preOrder(B) 会调用 preOrder(C)......

在最深处的节点被访问到之前,调用栈上会同时存在10万个 preOrder 函数的"快照"。这几乎肯定会耗尽所有栈内存,导致程序崩溃,也就是我们常说的"栈溢出 (Stack Overflow)"。

第一性结论: 递归依赖于系统隐式提供的"调用栈",而这个栈是有容量限制的。

为了处理任意深度的树,避免栈溢出,我们需要一种不依赖于系统调用栈的方法。这就是迭代 (Iteration)

核心思想: 既然系统提供的隐式栈不可靠,那我们就用自己的数据结构在堆内存(Heap,容量大得多)中来模拟一个栈,手动控制遍历的"前进"和"回溯"。

所以,在开始之前,我们必须先拥有自己的"栈"。


准备我们的工具------手动实现一个栈

我们用数组实现一个简单的、存放 Node* 指针的栈。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

// --- 复用之前的Node定义 ---
typedef struct Node {
    char data;
    struct Node* left;
    struct Node* right;
} Node;

// --- 栈的定义和实现 ---
#define MAX_STACK_SIZE 100

typedef struct {
    Node* items[MAX_STACK_SIZE];
    int top; // 栈顶指针
} Stack;

// 创建栈
Stack* createStack() {
    Stack* s = (Stack*)malloc(sizeof(Stack));
    s->top = -1; // -1表示栈为空
    return s;
}

// 检查栈是否为空
int isStackEmpty(Stack* s) {
    return s->top == -1;
}

// 入栈
void push(Stack* s, Node* node) {
    if (s->top >= MAX_STACK_SIZE - 1) {
        printf("Stack Overflow\n"); // 我们自己的栈也可能溢出,但容量可控
        return;
    }
    s->items[++(s->top)] = node;
}

// 出栈
Node* pop(Stack* s) {
    if (isStackEmpty(s)) {
        return NULL;
    }
    return s->items[(s->top)--];
}

// 查看栈顶元素(不出栈)
Node* peek(Stack* s) {
    if (isStackEmpty(s)) {
        return NULL;
    }
    return s->items[s->top];
}

好了,工具准备完毕。现在,我们来逐一推导三种深度优先遍历的迭代写法。我们还是用之前熟悉的示例树:

cpp 复制代码
      A
     / \
    B   C
   / \   \
  D   E   F

迭代遍历的逐一推导

迭代中序遍历 (L -> D -> R)

中序遍历是最能体现"栈"是如何模拟"递归"思想的,我们从它开始。

递归是怎么做的?

inOrder(node) 会先一路 inOrder(node->left) 钻到底,走不下去了才回来处理 node,然后再去处理 node->right

我们如何手动模拟?

  1. 我们的目标是先找到最左边的节点 D。从根节点 A 出发,我们一路向左走。但是,走过的路径 A -> B -> D 需要被记住,因为处理完 D 之后,我们要回到 B,处理完 B 的整个左子树后,要回到 A

  2. 这种"记录回溯路径"的需求,正是栈的 LIFO (后进先出) 特性。当我们从 A 走到 B,就把 A 推入栈中;从 B 走到 D,就把 B 推入栈中。

  3. 好了,现在我们到了 D,再往左是 NULL,走到底了。这意味着 DL 部分处理完了。

  4. 下一步该干嘛?根据 LD R,该处理 D 了(打印 D)。

  5. 处理完 D,根据 L D R ,该处理 D 的右子树了。D 的右子树是 NULL

  6. D 的 L, D, R 全都搞定。我们该回到上一步,也就是 BB 在哪?它就在栈顶!

  7. 我们把 B 从栈中弹出。对于 B 来说,它的 L 部分 (即 D 子树) 刚刚已经全部处理完了。现在轮到 BD 部分了(打印 B)。然后,轮到 BR 部分,也就是 E 子树。

  8. 现在,我们对 E 这个新的子树,重复上面第1步的操作:一路向左...

cpp 复制代码
目标树:                      遍历规则: L → D → R

          A
         / \
        B   C
       / \    \
      D   E    F


步骤展开:
──────────────────────────────────────────────

① 一路向左: 入栈 A → B
栈: [A, B]
当前位置: D (左空)
输出: ---

② 处理 D
栈: [A, B]
输出: D

③ 回到 B (弹出)
栈: [A]
输出: D, B
下一步: 转向右子树 E

④ 处理 E
栈: [A]
输出: D, B, E

⑤ 回到 A (弹出)
栈: []
输出: D, B, E, A
下一步: 转向右子树 C

⑥ 一路向左: 入栈 C
栈: [C]
当前位置: C 的左子树为空
输出: D, B, E, A

⑦ 处理 C
栈: []
输出: D, B, E, A, C
下一步: 转向右子树 F

⑧ 处理 F
栈: []
输出: D, B, E, A, C, F

──────────────────────────────────────────────
✅ 最终中序遍历结果:  D, B, E, A, C, F

算法总结:

  1. 创建一个while循环,循环条件是"当前节点不为NULL"或"栈不为空"。

  2. 在循环内部,再用一个while循环,一路向左,将路径上的所有节点都推入栈中。

  3. 当左边走到底 (当前节点为 NULL) 时,从栈中弹出一个节点。这个节点就是当前需要"访问"的节点。

  4. 访问该节点后,将当前节点指针指向该节点的右孩子,然后回到第1步,对这个右子树重复整个过程。

代码实现 :

cpp 复制代码
void iterativeInOrder(Node* root) {
    // 1. 准备好栈和当前节点指针
    Stack* stack = createStack();
    Node* current = root;

    // 2. 只要"还有节点要处理"或者"栈里还存着要回溯的节点",循环就继续
    while (current != NULL || !isStackEmpty(stack)) {
        
        // 3. 第一阶段:一路向左,把路径上的节点都存起来
        // 这个循环对应递归的 inOrder(node->left) 部分
        while (current != NULL) {
            push(stack, current);
            current = current->left;
        }

        // 4. 第二阶段:左边走到底了,该处理一个节点了
        // 从栈顶弹出的节点,是当前最需要被处理的节点
        current = pop(stack);
        printf("%c ", current->data); // 对应 LDR 中的 D

        // 5. 第三阶段:处理完 D,轮到 R
        // 把 current 指向右子树,下一轮大循环就会处理这个右子树
        current = current->right; // 对应 LDR 中的 R
    }
    
    free(stack);
}

输出: D B E A C F,和递归版本完全一致。


迭代前序遍历 (D -> L -> R)

递归是怎么做的?

preOrder(node) 一上来就先处理 node 自己,然后才去处理左、右。

我们如何手动模拟? 这个比中序简单。

  1. 从根节点 A 开始。规则是 D LR,所以立刻访问 A

  2. 接下来是 LR。我们得先处理 L (B子树),并且得记住之后还要回来处理 R (C子树)。

  3. 用栈来"记住"这个待办事项。

  4. 但是栈是 LIFO 的。如果我们希望先处理 L 再处理 R,那么入栈的顺序必须是先 RL。这样 L 就在栈顶,下一个被弹出来处理。

算法总结:

  1. 创建一个栈,把根节点推入。

  2. 当栈不为空时,循环执行: a. 弹出一个节点。 b. 访问这个节点。 c. 如果它有右孩子,把右孩子推入栈中。 d. 如果它有左孩子,把左孩子推入栈中。(保证左孩子在栈顶)

代码实现:

cpp 复制代码
void iterativePreOrder(Node* root) {
    if (root == NULL) return; // 处理空树的边界情况

    // 1. 准备好栈
    Stack* stack = createStack();
    
    // 2. 从根节点开始
    push(stack, root);

    // 3. 只要栈里还有待办节点,就循环
    while (!isStackEmpty(stack)) {
        // a. 取出一个待办节点
        Node* current = pop(stack);

        // b. 立刻访问它 (D)
        printf("%c ", current->data);

        // c. 把右孩子加到待办事项 (R)
        // 注意:先推右孩子
        if (current->right != NULL) {
            push(stack, current->right);
        }

        // d. 把左孩子加到待办事项 (L)
        // 后推左孩子,保证它在栈顶,能被优先处理
        if (current->left != NULL) {
            push(stack, current->left);
        }
    }
    
    free(stack);
}

输出: A B D E C F,和递归版本完全一致。


迭代后序遍历 (L -> R -> D)

后序遍历是最棘手的。

对于一个节点,我们必须保证它的左、右子树都已经被完全访问后,才能访问它自己。当我们从栈中弹出一个节点时,我们无法确定它的右子树是否已经被访问过了。

思路一:暴力破解?

像中序那样,一路向左推入栈。到达最左边 D 时,我们不急着弹出,而是看它有没有右孩子。

没有,好,那可以访问 D 了。回到 BB 的左边(D)处理完了,我们去看 B 的右边(E)。处理完 E 才能处理 B

这个逻辑需要记录每个节点的状态(是从左边回来的还是从右边回来的),非常复杂。

思路二:寻找"逆向"关系(第一性推导的奇妙捷径)

  1. 我们来看后序遍历:L -> R -> D

  2. 我们把它完全逆转过来,是什么? D -> R -> L

  3. 这个 D -> R -> L 看起来非常眼熟!它和前序遍历 D -> L -> R 极其相似,只是 LR 的顺序反了。

  4. 那么,我们能不能先实现一个 D -> R -> L 的遍历,然后把得到的结果序列再整个逆转,不就得到 L -> R -> D 了吗?

  5. 如何实现 D -> R -> L?太简单了,我们直接修改前序遍历的代码:在推入子节点时,先推左,再推右就行了。

  6. 如何"保存结果并逆转"?我们可以用另一个栈!第一个栈用来做 D->R->L 遍历,每当一个节点被访问时,不打印,而是把它推入第二个"结果栈"。当遍历结束后,再把结果栈里的所有元素依次弹出并打印,就自然完成了逆转。

cpp 复制代码
树结构:
          A
         / \
        B   C
       / \    \
      D   E    F

───────────────────────────────────────────────
Step 1: 初始
S1: [A]          S2: []
输出: ---

───────────────────────────────────────────────
Step 2: 处理 A → 推入 S2
S1: [B, C]       S2: [A]
输出: ---

───────────────────────────────────────────────
Step 3: 弹出 C → 推入 S2
S1: [B, F]       S2: [A, C]
输出: ---

───────────────────────────────────────────────
Step 4: 弹出 F → 推入 S2
S1: [B]          S2: [A, C, F]
输出: ---

───────────────────────────────────────────────
Step 5: 弹出 B → 推入 S2
S1: [D, E]       S2: [A, C, F, B]
输出: ---

───────────────────────────────────────────────
Step 6: 弹出 E → 推入 S2
S1: [D]          S2: [A, C, F, B, E]
输出: ---

───────────────────────────────────────────────
Step 7: 弹出 D → 推入 S2
S1: []           S2: [A, C, F, B, E, D]
输出: ---

───────────────────────────────────────────────
Step 8: 弹出 S2(逆转序列)
S2 从顶到底: D, E, B, F, C, A
输出: D, E, B, F, C, A

───────────────────────────────────────────────
✅ 最终后序遍历 (L → R → D):  D, E, B, F, C, A

算法总结 (双栈法):

a. 从 stack1 弹出一个节点 current

b. 将 current 推入 stack2

c. 如果 current左孩子 ,将其推入 stack1

d. 如果 current右孩子 ,将其推入 stack1。 (保证右孩子先被处理)

  1. 创建两个栈:stack1 用于遍历,stack2 用于存储逆序结果。

  2. 将根节点推入 stack1

  3. stack1 不为空时,循环:

  4. 当循环结束后,stack2 中就存储了正确的后序遍历序列(的逆序的逆序)。

  5. 依次弹出 stack2 的所有元素并打印。

代码实现:

cpp 复制代码
void iterativePostOrder(Node* root) {
    if (root == NULL) return;

    // 1. 准备两个栈
    Stack* stack1 = createStack();
    Stack* stack2 = createStack();

    // 2. 从根节点开始
    push(stack1, root);

    // 3. 执行 D -> R -> L 遍历
    while (!isStackEmpty(stack1)) {
        Node* current = pop(stack1);
        push(stack2, current); // 访问操作变成存入stack2

        // 和前序遍历相反,先推左,再推右
        if (current->left != NULL) {
            push(stack1, current->left);
        }
        if (current->right != NULL) {
            push(stack1, current->right);
        }
    }

    // 4. 依次弹出结果栈中的元素,得到最终后序序列
    while (!isStackEmpty(stack2)) {
        Node* current = pop(stack2);
        printf("%c ", current->data);
    }
    
    free(stack1);
    free(stack2);
}

输出: D E B F C A,和递归版本完全一致。

(注:后序遍历也存在更优化的单栈解法,但逻辑更复杂,需要一个prev指针来判断是从左子树还是右子树返回。双栈法是从第一性原理推导"逆向关系"得出的最直观解法。)


总结与完整代码

我们从递归的"栈溢出"风险出发,确立了使用手动管理的栈进行迭代遍历的必要性。

  • 迭代中序 (LDR): 通过"一路向左入栈,到底再弹出处理,然后转向右子树"的循环,完美模拟了递归的回溯过程。

  • 迭代前序 (DLR): 逻辑最简单,弹出即访问,然后按"先右后左"的顺序把孩子推入栈中。

  • 迭代后序 (LRD): 通过 LRD 逆序为 DRL 的巧妙思路,把问题转化为一个"改版的先序遍历",并借助第二个栈来逆转结果, elegantly 解决了难题。

这三种方法的核心,都是用一个显式的栈 ,去模拟了递归函数调用时隐式的调用栈 所做的工作:保存待处理的现场,以便后续可以回溯

以下是包含所有迭代方法的完整可运行代码:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

// --- 节点定义 ---
typedef struct Node {
    char data;
    struct Node* left;
    struct Node* right;
} Node;

// --- 栈的定义和实现 ---
#define MAX_STACK_SIZE 100

typedef struct {
    Node* items[MAX_STACK_SIZE];
    int top;
} Stack;

Stack* createStack() { Stack* s = (Stack*)malloc(sizeof(Stack)); s->top = -1; return s; }
int isStackEmpty(Stack* s) { return s->top == -1; }
void push(Stack* s, Node* node) { if (s->top < MAX_STACK_SIZE - 1) s->items[++(s->top)] = node; }
Node* pop(Stack* s) { if (isStackEmpty(s)) return NULL; return s->items[(s->top)--]; }

// --- 树的创建 (复用) ---
Node* createNode(char data) { /* ... same as before ... */ 
    Node* newNode = (Node*)malloc(sizeof(Node));
    newNode->data = data;
    newNode->left = NULL;
    newNode->right = NULL;
    return newNode;
}
Node* build_example_tree() { /* ... same as before ... */ 
    Node* root = createNode('A');
    root->left = createNode('B');
    root->right = createNode('C');
    root->left->left = createNode('D');
    root->left->right = createNode('E');
    root->right->right = createNode('F');
    return root;
}


// --- 迭代遍历的实现 ---

void iterativePreOrder(Node* root) {
    if (root == NULL) return;
    Stack* stack = createStack();
    push(stack, root);
    while (!isStackEmpty(stack)) {
        Node* current = pop(stack);
        printf("%c ", current->data);
        if (current->right != NULL) push(stack, current->right);
        if (current->left != NULL) push(stack, current->left);
    }
    free(stack);
}

void iterativeInOrder(Node* root) {
    Stack* stack = createStack();
    Node* current = root;
    while (current != NULL || !isStackEmpty(stack)) {
        while (current != NULL) {
            push(stack, current);
            current = current->left;
        }
        current = pop(stack);
        printf("%c ", current->data);
        current = current->right;
    }
    free(stack);
}

void iterativePostOrder(Node* root) {
    if (root == NULL) return;
    Stack* stack1 = createStack();
    Stack* stack2 = createStack();
    push(stack1, root);
    while (!isStackEmpty(stack1)) {
        Node* current = pop(stack1);
        push(stack2, current);
        if (current->left != NULL) push(stack1, current->left);
        if (current->right != NULL) push(stack1, current->right);
    }
    while (!isStackEmpty(stack2)) {
        printf("%c ", pop(stack2)->data);
    }
    free(stack1);
    free(stack2);
}

// --- Main 函数 ---
int main() {
    Node* root = build_example_tree();

    printf("Iterative Pre-order: ");
    iterativePreOrder(root);
    printf("\n");

    printf("Iterative In-order:  ");
    iterativeInOrder(root);
    printf("\n");

    printf("Iterative Post-order:");
    iterativePostOrder(root);
    printf("\n");
    
    return 0;
}
相关推荐
艾莉丝努力练剑35 分钟前
【洛谷刷题】用C语言和C++做一些入门题,练习洛谷IDE模式:分支机构(一)
c语言·开发语言·数据结构·c++·学习·算法
Cx330❀2 小时前
【数据结构初阶】--排序(五):计数排序,排序算法复杂度对比和稳定性分析
c语言·数据结构·经验分享·笔记·算法·排序算法
散1122 小时前
01数据结构-Prim算法
数据结构·算法·图论
..过云雨3 小时前
01.【数据结构-C语言】数据结构概念&算法效率(时间复杂度和空间复杂度)
c语言·数据结构·笔记·学习
拂晓银砾3 小时前
Java数据结构-栈
java·数据结构
旺小仔.4 小时前
双指针和codetop复习
数据结构·c++·算法
楽码6 小时前
底层技术SwissTable的实现对比
数据结构·后端·算法
瓦特what?7 小时前
关于C++的#include的超超超详细讲解
java·开发语言·数据结构·c++·算法·信息可视化·数据挖掘
重生之我是Java开发战士7 小时前
【数据结构】深入理解单链表与通讯录项目实现
数据结构·链表