数据结构:迭代方法(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*
指针的栈。
#include <stdio.h>
#include <stdlib.h>// --- 复用之前的Node定义 ---
typedef struct Node {char data;struct Node* left;struct Node* right;
} Node;// --- 栈的定义和实现 ---
#define MAX_STACK_SIZE 100typedef 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];
}
好了,工具准备完毕。现在,我们来逐一推导三种深度优先遍历的迭代写法。我们还是用之前熟悉的示例树:
A/ \B C/ \ \D E F
迭代遍历的逐一推导
迭代中序遍历 (L -> D -> R)
中序遍历是最能体现“栈”是如何模拟“递归”思想的,我们从它开始。
递归是怎么做的?
inOrder(node)
会先一路 inOrder(node->left)
钻到底,走不下去了才回来处理 node
,然后再去处理 node->right
。
我们如何手动模拟?
-
我们的目标是先找到最左边的节点
D
。从根节点A
出发,我们一路向左走。但是,走过的路径A
->B
->D
需要被记住,因为处理完D
之后,我们要回到B
,处理完B
的整个左子树后,要回到A
。 -
这种“记录回溯路径”的需求,正是栈的 LIFO (后进先出) 特性。当我们从
A
走到B
,就把A
推入栈中;从B
走到D
,就把B
推入栈中。 -
好了,现在我们到了
D
,再往左是NULL
,走到底了。这意味着D
的L
部分处理完了。 -
下一步该干嘛?根据 LDR,该处理
D
了(打印D
)。 -
处理完
D
,根据 L D R,该处理D
的右子树了。D
的右子树是NULL
。 -
D
的 L, D, R 全都搞定。我们该回到上一步,也就是B
。B
在哪?它就在栈顶! -
我们把
B
从栈中弹出。对于B
来说,它的L
部分 (即D
子树) 刚刚已经全部处理完了。现在轮到B
的D
部分了(打印B
)。然后,轮到B
的R
部分,也就是E
子树。 -
现在,我们对
E
这个新的子树,重复上面第1步的操作:一路向左...
目标树: 遍历规则: L → D → RA/ \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
算法总结:
-
创建一个
while
循环,循环条件是“当前节点不为NULL”或“栈不为空”。 -
在循环内部,再用一个
while
循环,一路向左,将路径上的所有节点都推入栈中。 -
当左边走到底 (当前节点为
NULL
) 时,从栈中弹出一个节点。这个节点就是当前需要“访问”的节点。 -
访问该节点后,将当前节点指针指向该节点的右孩子,然后回到第1步,对这个右子树重复整个过程。
代码实现 :
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
自己,然后才去处理左、右。
我们如何手动模拟? 这个比中序简单。
-
从根节点
A
开始。规则是 DLR,所以立刻访问A
。 -
接下来是
L
和R
。我们得先处理L
(B子树),并且得记住之后还要回来处理R
(C子树)。 -
用栈来“记住”这个待办事项。
-
但是栈是 LIFO 的。如果我们希望先处理
L
再处理R
,那么入栈的顺序必须是先R
后L
。这样L
就在栈顶,下一个被弹出来处理。
算法总结:
-
创建一个栈,把根节点推入。
-
当栈不为空时,循环执行: a. 弹出一个节点。 b. 访问这个节点。 c. 如果它有右孩子,把右孩子推入栈中。 d. 如果它有左孩子,把左孩子推入栈中。(保证左孩子在栈顶)
代码实现:
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
了。回到 B
,B
的左边(D
)处理完了,我们去看 B
的右边(E
)。处理完 E
才能处理 B
。
这个逻辑需要记录每个节点的状态(是从左边回来的还是从右边回来的),非常复杂。
思路二:寻找“逆向”关系(第一性推导的奇妙捷径)
-
我们来看后序遍历:
L -> R -> D
-
我们把它完全逆转过来,是什么?
D -> R -> L
-
这个
D -> R -> L
看起来非常眼熟!它和前序遍历D -> L -> R
极其相似,只是L
和R
的顺序反了。 -
那么,我们能不能先实现一个
D -> R -> L
的遍历,然后把得到的结果序列再整个逆转,不就得到L -> R -> D
了吗? -
如何实现
D -> R -> L
?太简单了,我们直接修改前序遍历的代码:在推入子节点时,先推左,再推右就行了。 -
如何“保存结果并逆转”?我们可以用另一个栈!第一个栈用来做
D->R->L
遍历,每当一个节点被访问时,不打印,而是把它推入第二个“结果栈”。当遍历结束后,再把结果栈里的所有元素依次弹出并打印,就自然完成了逆转。
树结构: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
。 (保证右孩子先被处理)
-
创建两个栈:
stack1
用于遍历,stack2
用于存储逆序结果。 -
将根节点推入
stack1
。 -
当
stack1
不为空时,循环: -
当循环结束后,
stack2
中就存储了正确的后序遍历序列(的逆序的逆序)。 -
依次弹出
stack2
的所有元素并打印。
代码实现:
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 解决了难题。
这三种方法的核心,都是用一个显式的栈,去模拟了递归函数调用时隐式的调用栈所做的工作:保存待处理的现场,以便后续可以回溯。
以下是包含所有迭代方法的完整可运行代码:
#include <stdio.h>
#include <stdlib.h>// --- 节点定义 ---
typedef struct Node {char data;struct Node* left;struct Node* right;
} Node;// --- 栈的定义和实现 ---
#define MAX_STACK_SIZE 100typedef 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;
}