☰
二叉树存储方式详解:顺序存储与链式存储的C语言实现
2026/10/22 10:30:08 网站建设 项目流程

二叉树这个东西,按理说应该是数据结构里很"中规中矩"的一章——节点、左子树、右子树,递归遍历,套路清清楚楚。但我发现真正让初学者卡住的,往往不是树本身,而是"树到底在内存里怎么放"。数组我会用、链表我也会用,可节点一旦分叉成两路,很多人就懵了。

这篇学习笔记不打算讲太多花哨的应用,就聚焦一件事:二叉树的存储方式。我会把顺序存储(数组)和链式存储(指针)两套方案的原理、代码、适用场景全部写透,并且给出完整可编译的C语言实现。适合正在学数据结构的学生、准备面试的开发者,以及"学过但一直没真正写明白树"的人。代码我全部自己跑过,可以直接照着敲。

1. 二叉树到底解决了什么问题:先搞清楚我们为什么需要它

1.1 数组和链表为什么"不够用了"

在接触树之前,我们手里的武器只有两个:数组和链表。数组就像一排编号固定的抽屉,你要找第K个抽屉里的东西,一步就能定位。链表则是一串只往下走的链子,每个节点只记得下一个节点的地址,遍历的时候只能一个接一个往下翻。

这两样东西有一个共同点:它们的结构都是"线性"的。所谓线性,就是每个元素只有一个直接后继,整个数据串起来是一条线。但在真实场景里,很多关系天生就是分叉的——文件目录系统、公司组织架构、表达式的运算优先级、比赛的对阵表,全都是一层套一层、一叉分两叉的结构。用一条线去表达一棵树,要么浪费大量空间,要么逻辑上扭捏得难受。

于是树结构出现了。二叉树是树里最克制的形态:每个节点最多两个分叉,左孩子、右孩子,整棵树像一张倒扣的族谱。它不是一种"高级技巧",而是一种更贴合真实世界的组织方式。怎么用线性内存把这张"谱"存下来,就是存储方式要回答的问题。

1.2 先统一几个术语:孩子、父亲、度、深度

后续所有代码都会用到这些词,我快速过一遍,避免读代码时卡壳。最顶端的节点叫根节点,往下连出来的节点叫孩子,反过来叫父亲,同一个父亲的几个孩子互称兄弟。一个节点下面直接连着几个孩子就叫"度",二叉树每个节点的度最多是2。树有几层就叫几层的深度,比如一个孤零零的根节点深度是1。

还有两个很关键的特殊形态:满二叉树和完全二叉树。满二叉树是每一层都塞满节点,整棵树呈现完美的等腰三角形。完全二叉树稍微宽松一点:除了最后一层,上面的层都必须是满的,最后一层的节点必须从左往右连续排列,中间不能有空洞。这两个概念在顺序存储那一章会反复用到,先有印象就行。

1.3 "存储方式"到底在讨论什么

我的理解是,存储方式这件事本质上是两套问题的交集:物理结构(数据在内存里怎么排)和逻辑结构(谁是谁的父亲、谁在谁的左边)。数组和链表都属于物理层面,而树的父子关系属于逻辑层面。顺序存储想让物理连续的内存直接体现逻辑关系,链式存储则是让物理上分散的节点通过指针串联成逻辑关系。

顺着这个思路往下学就清楚多了:数组存树靠的是"下标公式"——通过下标算出左右孩子在哪,逻辑关系藏在数字规律里;链式存树靠的是"指针字段"——每个节点明确记着两个孩子的地址,逻辑关系摆在明面上。两种方案各有代价,下面两章分别拆开讲。

2. 顺序存储:把一棵树"压平"进连续数组

2.1 顺序存储的核心:一组下标公式

顺序存储的思路非常直接:给树上的每个节点编一个号,根节点是0,然后从上到下、从左到右依次往后排。这其实就是完全二叉树的编号方式。关键规律有三条:

  • 节点 i 的左孩子下标是 2*i + 1
  • 节点 i 的右孩子下标是 2*i + 2
  • 节点 i 的父亲下标是 (i - 1) / 2(整数除法,向下取整)

拿一个小例子验证:根节点在0,它的左孩子是1,右孩子是2;节点1的左孩子是3,右孩子是4。反过来,节点4的父亲是(4-1)/2 = 1,正确。这三个公式是整个顺序存储的命脉,建议直接背下来。

为什么是这个数字关系?因为完全二叉树每一层节点数都是上一层的两倍,根在第0层,第k层最多有2的k次方个节点。把节点按层序编号,恰好和数组下标一一对应,所以任意一个节点的孩子、父亲都能靠数学公式直接算出来。这比链式存储"顺着指针找"更省时间,因为不需要任何跳转,纯算数。

2.2 可编译运行的顺序存储实现

我用C语言写一个完整的顺序存储Demo。这个程序做的事情很简单:初始化一棵树,往里插入若干字符节点,然后以前序遍历的方式打印整棵树。实际工程里不会有人拿字符当数据,但用来演示结构再合适不过。

#include <stdio.h> #include <string.h> #define MAX_TREE_SIZE 20 // 用数组下标表达节点位置,每个格子标记是否真的放了数据 typedef struct { char data; int used; // 0表示该位置未使用 } SeqTreeNode; typedef struct { SeqTreeNode nodes[MAX_TREE_SIZE]; int size; // 当前已插入节点数 } SeqTree; void initSeqTree(SeqTree *tree) { tree->size = 0; for (int i = 0; i < MAX_TREE_SIZE; i++) { tree->nodes[i].used = 0; } } // 始终插到数组末尾,顺序天然保持"从上到下、从左到右" int insertSeqNode(SeqTree *tree, char value) { if (tree->size >= MAX_TREE_SIZE) { printf("树已满,插入失败\n"); return -1; } int index = tree->size; tree->nodes[index].data = value; tree->nodes[index].used = 1; tree->size++; return index; } // 前序遍历:访问根 -> 递归左子 -> 递归右子 void preorderSeq(SeqTree *tree, int index) { if (index >= MAX_TREE_SIZE || !tree->nodes[index].used) { return; } printf("%c ", tree->nodes[index].data); preorderSeq(tree, 2 * index + 1); preorderSeq(tree, 2 * index + 2); } int main() { SeqTree tree; initSeqTree(&tree); // 依次插入 A~J,共10个节点 for (int i = 0; i < 10; i++) { insertSeqNode(&tree, 'A' + i); } printf("顺序存储前序遍历结果:\n"); preorderSeq(&tree, 0); printf("\n"); return 0; }

程序输出是:A B D H I E J C F G。你可以自己画一下这棵完全二叉树,下标0到9对应的节点排满后,前序遍历就是这个顺序。这段代码最关键的地方在于2 * index + 1和2 * index + 2两个下标计算,只要下标公式没错,其他逻辑全是水到渠成。

2.3 顺序存储的"空间陷阱":稀疏树的灾难

上面代码跑得很顺,因为它用的是完全二叉树的形态,数组里几乎没空位。但二叉树不总是这么"饱满",如果你的树长得歪歪扭扭,顺序存储的浪费会非常恐怖。

举个最极端的例子:一棵右单支树——每个节点都只有右孩子,没有左孩子。它虽然有N个节点,但最右下角那个节点的下标却是2的N次方减1级别的。假设只有10个节点,数组最后一个位置的下标大约是1023,中间一大半格子全空着。要想存这10个节点,你得开一个1024长度的数组。

这就是顺序存储的先天缺陷:它假设数据是"完整排列"的,一旦树稀疏,内存就变成了沙漠。所以顺序存储最适合的是完全二叉树、堆这种形态规整的结构。存普通二叉树,除非节点数量和位置很确定,否则直接用链式存储更稳妥。实际面试和工程里,遇到"用数组建堆"的方法很多,遇到"用数组存一棵普通二叉树"则很少,就是因为这个空间效率问题。

3. 链式存储:让每个节点自己记住两个孩子

3.1 链式存储的节点设计:data + left + right

链式存储的思路更贴近人的直觉:不再依赖下标推断父子关系,而是每个节点直接存两个指针,一个指向左孩子,一个指向右孩子。你要是会单链表,这个结构基本就是它的数学升级版——单链表节点的第二个字段是next,二叉树节点的第二、第三个字段分别是left和right。

对比一下结构体设计:

// 单链表节点 typedef struct ListNode { int data; struct ListNode *next; } ListNode; // 二叉树节点 typedef struct TreeNode { char data; struct TreeNode *left; struct TreeNode *right; } TreeNode;

多了一个指针字段,就把"一条线"变成了"一个分叉"。这里的核心逻辑是:物理上每个节点可以零散分布在内存任意角落,但通过指针,节点之间依然能精确地构成父子关系。这就是逻辑结构与物理结构松耦合的典型例子:内存碎片没关系,指针会替你记住路。

3.2 完整的链式存储实现:建树、遍历、释放

我给出一个覆盖面比较全的Demo:手动构造一棵二叉树,然后分别用前序、中序、后序、层序四种方式遍历打印,最后彻底释放整棵树的内存。这版代码比学校实验课要求的通常更完整一些,尤其是释放函数很多人会漏写。

#include <stdio.h> #include <stdlib.h> #define QUEUE_CAPACITY 64 typedef struct TreeNode { char data; struct TreeNode *left; struct TreeNode *right; } TreeNode; TreeNode *createNode(char value) { TreeNode *node = (TreeNode *)malloc(sizeof(TreeNode)); node->data = value; node->left = NULL; node->right = NULL; return node; } // 手动构造一棵树: // A // / \ // B C // / \ \ // D E F TreeNode *buildDemoTree() { TreeNode *a = createNode('A'); TreeNode *b = createNode('B'); TreeNode *c = createNode('C'); TreeNode *d = createNode('D'); TreeNode *e = createNode('E'); TreeNode *f = createNode('F'); a->left = b; a->right = c; b->left = d; b->right = e; c->right = f; return a; } void preorder(TreeNode *root) { if (root == NULL) return; printf("%c ", root->data); preorder(root->left); preorder(root->right); } void inorder(TreeNode *root) { if (root == NULL) return; inorder(root->left); printf("%c ", root->data); inorder(root->right); } void postorder(TreeNode *root) { if (root == NULL) return; postorder(root->left); postorder(root->right); printf("%c ", root->data); } // 层序遍历:用一个简易数组当队列,front/rear 指针移动 void levelOrder(TreeNode *root) { if (root == NULL) return; TreeNode *queue[QUEUE_CAPACITY]; int front = 0; int rear = 0; queue[rear++] = root; while (front < rear) { TreeNode *current = queue[front++]; printf("%c ", current->data); if (current->left != NULL) { queue[rear++] = current->left; } if (current->right != NULL) { queue[rear++] = current->right; } } } // 释放整棵树:必须用后序,先释放孩子再释放自己 void destroyTree(TreeNode *root) { if (root == NULL) return; destroyTree(root->left); destroyTree(root->right); free(root); } int main() { TreeNode *root = buildDemoTree(); printf("前序遍历:"); preorder(root); printf("\n"); printf("中序遍历:"); inorder(root); printf("\n"); printf("后序遍历:"); postorder(root); printf("\n"); printf("层序遍历:"); levelOrder(root); printf("\n"); destroyTree(root); return 0; }

输出结果是:

  • 前序:A B D E C F
  • 中序:D B E A C F
  • 后序:D E B F C A
  • 层序:A B C D E F

我常说这棵树是"教科书树"——它每一个节点的左右形态都不太一样,既有B这种双子齐全的节点,也有C这种只有右子没有左子的节点,很适合验证遍历逻辑。你把它画在纸上,再看输出序列,递归的走向就一目了然。

3.3 递归遍历为什么"无脑":把问题切成相同的小块

很多人学递归遍历的时候,总是试图用脑子一层层展开调用栈,结果越展开越乱。其实递归遍历不用展开,你就记住一条规则:树遍历等于"处理根节点"加"递归左子树"加"递归右子树",区别只在于处理根节点的时机。

  • 前序:先处理自己,再去左,再去右
  • 中序:先左,再处理自己,再右
  • 后序:先左,再右,最后处理自己

这样看,中序和前序的代码差异只是printf那一行的位置不同。理解了这一点,三种遍历再也不会记混。递归的终止条件就是root == NULL,一个空子树什么都不用做,自然返回。你不需要知道整棵树有多深,递归会一层层自己收住。

3.4 层序遍历的"为什么用队列":先来后到的宽度扩散

层序遍历是唯一不靠递归也能写清楚的方式。它要按层次从上往下、从左往右地扫描,这意味着你访问完第k层的所有节点之前,不能跳到第k+1层去访问。这种"同一层先被处理"的特性,只有先进先出的队列能天然满足。

我把队列实现简化成数组了:一个指针在后面接收新节点,一个指针在前面取走待访问节点。每取出一个节点,就把它的左右孩子放进队尾。这个过程很直观——你想象一群人排成一排,第一个人出队买东西,而他身后又来了两个孩子排队,如此循环,直到队伍清空。栈就不行,因为栈是后进先出,用栈做层序会把顺序完全颠倒,处理逻辑反而别扭。

4. 完全二叉树的独门玩法:数组存储在这里大放异彩

4.1 为什么完全二叉树天生适合数组

我前面说过顺序存储最怕稀疏,但有一类树不算稀疏,那就是完全二叉树。在完全二叉树里,节点严格按照"层序连续"排列,中间不会有空洞,所以数组里每个格子都能被用上,空间效率很高。更重要的是,下标公式在完全二叉树里得到了最完美的体现——父亲、左孩子、右孩子,全都可以靠加减乘除直接算出来,不用任何指针跳转。

堆就是一个最典型的完全二叉树。优先队列、堆排序这些经典数据结构,底层实现几乎都是数组。正因为堆的存储极度规整,所以它能保证:找到最大/最小元素和插入新元素的操作复杂度都在对数级别。如果你想看"数组存树"在真实场景里的价值,看堆就够了。

4.2 用数组实现最大堆的插入与删除(含代码)

我用数组实现一个简单的最大堆,演示插入和删除的核心操作。插入的思路是:先把新元素放在数组末尾,然后一路"上浮",和父亲比较大小,如果比父亲大就交换位置。删除的思路正好相反:先把堆顶元素拿走,把末尾元素放到堆顶,然后一路"下沉",和孩子里较大的那个交换位置。

#include <stdio.h> #define MAX_HEAP_SIZE 100 typedef struct { int data[MAX_HEAP_SIZE]; int size; } MaxHeap; void initHeap(MaxHeap *heap) { heap->size = 0; } // 从数组末尾插入,然后向上调整 void insertToHeap(MaxHeap *heap, int value) { if (heap->size >= MAX_HEAP_SIZE) { printf("堆已满\n"); return; } int i = heap->size; heap->data[i] = value; heap->size++; while (i > 0) { int parent = (i - 1) / 2; if (heap->data[i] > heap->data[parent]) { int temp = heap->data[i]; heap->data[i] = heap->data[parent]; heap->data[parent] = temp; i = parent; } else { break; } } } // 移除堆顶,把最后一个元素挪上来,然后向下调整 int popHeap(MaxHeap *heap) { if (heap->size == 0) { printf("堆为空\n"); return -1; } int top = heap->data[0]; heap->data[0] = heap->data[heap->size - 1]; heap->size--; int i = 0; while (1) { int left = 2 * i + 1; int right = 2 * i + 2; int largest = i; if (left < heap->size && heap->data[left] > heap->data[largest]) { largest = left; } if (right < heap->size && heap->data[right] > heap->data[largest]) { largest = right; } if (largest == i) { break; } int temp = heap->data[i]; heap->data[i] = heap->data[largest]; heap->data[largest] = temp; i = largest; } return top; } int main() { MaxHeap heap; initHeap(&heap); int values[] = {10, 25, 5, 17, 8, 42, 33}; for (int i = 0; i < 7; i++) { insertToHeap(&heap, values[i]); } printf("依次弹出堆顶:\n"); while (heap.size > 0) { printf("%d ", popHeap(&heap)); } printf("\n"); return 0; }

运行结果就是元素从大到小依次弹出:42 33 25 17 10 8 5。这段代码里的(i - 1) / 2和2 * i + 1其实就是前面顺序存储下标公式的直接使用。你把堆的画法(完全二叉树形态)和数组下标对照,会发现每一个上浮、下沉操作都精确对应着节点在树里的上下移动,这就是顺序存储的真正优势:不需要指针,数学位置就是逻辑位置。

4.3 用下标反推父子关系:一个快速自检技巧

如果你想验证自己对完全二叉树下标规律有没有吃透,我给你一个自检方法:随便给一个下标,比如17,立刻说出它的父节点和左右孩子的下标。父节点在8,左孩子在35,右孩子在36。熟练之后你会发现,这类问题计算量极小,无非是"减一除二"和"乘二加一"。画图和口算搭配着来,比死记公式管用得多。

这个能力在面试里也会突然派上用场。有些题给出一棵树的数组存储,要求判断某个节点是不是叶子——你只需检查它的左孩子下标是否越界(比如数组长度不够),越界就是叶子。不需要建树、不需要递归,一个下标判断就够了。

5. 两种存储方式怎么选:别等踩坑了才开始权衡

5.1 一张表看穿时间、空间、操作的差异

为了让你在选型时有个清晰抓手,我把两种存储方式的差异整理成一张对照表:

对比维度顺序存储(数组)链式存储(指针)
存储密度完全二叉树接近100%,稀疏树极低每个节点额外存两个指针,但无空洞浪费
随机访问第K个节点下标直达,O(1)要从根一路找,O(n)
插入/删除节点需要处理下标空洞,复杂度高改指针即可,灵活
获取父节点公式 (i-1)/2,O(1)默认节点无parent字段,需要额外记录
对缓存友好度高,连续内存遍历快低,节点分散,缓存命中差
实现复杂度简单,但边界易错稍复杂,但逻辑直观

这张表最有价值的启示是:顺序存储并不只是"省内存",它在缓存局部性和随机访问上都有天然优势,所以在堆、优先队列这种场景几乎不可替代。而链式存储在二叉树普通形态下更接近人的思维模型,增删改查都自由,代价就是节点多了两个指针字段和一些malloc/system调用开销。

5.2 内存碎片和"释放不完全":链式存储的两个隐藏烦恼

链式存储用起来灵活,但别忘了每次创建节点都得malloc,每次销毁都得free。频繁的申请和释放会让内存碎片增多,在嵌入式环境或长期运行的服务里尤其明显。更重要的是释放顺序:必须先释放左子树、再释放右子树、最后释放自己,也就是后序遍历的顺序。如果先free(root)再递归释放孩子,孩子指针已经读不到了,直接造成内存泄漏。

我之前见过一个调试场景:A同学写了一个递归释放函数,把free(root)写在递归调用之前,程序跑起来内存占用只增不减。这就是典型的"释放顺序错误"。我的建议是释放函数闭着眼按后序写,同时用Valgrind一类的工具跑一遍,看到"no leaks are possible"再继续下一步。

5.3 我的选型经验:看树的"工作负载",而不是看哪种更高级

你可以用一条很朴素的规则来选:如果数据基本固定、树的形态接近完全二叉树、核心操作是排序/取极值/层序遍历,优先用数组存储;如果你要频繁插入删除节点、树的形状不规则、需要做各种递归遍历的复杂操作,优先用链式存储。

还有一个很容易忽略的点:面试手写题里,二叉搜索树的增删查普遍用链式实现,因为树会不断长高、变形,数组动态扩容反而麻烦。但一旦题目提到"堆"或者"完全二叉树",几乎默认就是数组实现。所以这两种存储不是互相替代的关系,它们是不同场景下的最佳工具。能把"遇到什么样的数据形态选什么样的存储"说清楚,比背一堆概念更能体现你真的学懂了。

6. 学习路上的几个常见误区和我自己的调试方法

6.1 三个高频错误的真实形态

第一个错误是把"用数组存树的节点值"当成了顺序存储。这不是一回事。顺序存储的精髓是下标公式体现父子关系,如果你的程序里只是把节点按层序塞进数组,查找父亲、孩子仍然靠遍历,那本质上还是"自带索引的线性表",并没有利用树的规律。

第二个错误是混淆满二叉树和完全二叉树。满二叉树每一层都满,完全二叉树允许最后一层不满但必须从左到右连续。顺序存储下标公式可以完美描述完全二叉树,但对普通二叉树只描述位置,无法描述"空洞"——因为空洞也是空间。做题时这两者一旦混淆,公式和判断逻辑全都会走偏。

第三个错误是把2 * i + 1写成2 * i。看起来只差一个1,实际访问的不是左孩子,而是自己的"副本"位置,经常引发数组越界。我一开始也犯过,建议先写三个节点的最小树,把下标0、1、2的实际数字跑明白,再套到大规模数据上。

6.2 调试树结构的实用技巧

调试树结构比调试数组麻烦,因为树没有"打印出来看"的天然方式。我的做法分三步:第一步,把树画在纸上,每个节点标上下标或指针地址,画完再写代码,不要边画边写;第二步,写一个简单的层序打印函数,把树按行输出成类似二叉树ASCII图的形式,肉眼确认结构对不对;第三步,在递归函数入口加一个缩进参数,用printf("%*c", depth * 2, ' ')这种格式输出递归进入顺序,这样你能看到递归调用到底先去了哪个分支。

这些方法说出来很简单,但真的能把调试时间砍掉一多半。我见过很多人在递归遍历出问题时反复阅读代码,看十遍也找不到逻辑错误——因为逻辑可能没错,是树结构本身搭错了。先验证结构,再验证遍历,是最高效的排查顺序。

6.3 用边界用例检验你的实现是否稳

最后一个习惯:每写完一套存储代码,至少用四类用例测试一遍——空树、只有一个节点的树、完全二叉树、右单支/左单支的退化树。空树可以验证递归出口有没有写对;单节点树可以验证能不能正确输出根;完全二叉树能验证数组下标是否越界;退化树则能让链式存储的指针管理是否健壮暴露出来。

比如空树调preorder(NULL),如果忘记if (root == NULL) return;这行,程序会直接崩溃;而右单支树用顺序存储去建的话,数组容量必须开得极大,否则瞬间越界。这两个问题在正常形状的树上可能根本不会出现,但恰恰是它们帮你理解"存储方式"本身的边界条件在哪。

我自己写代码的习惯是把这些边界用例直接写成一个辅助测试函数,每次改完代码就跑一遍,而不是靠手动造数。这一个习惯帮我少踩了大量隐性bug。学二叉树这种递归味重的数据结构,测试驱动比反复背诵定义有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询