一、复杂数据结构序列化:链表、树、图的文件存储与恢复

说实话,序列化这个话题,我当年刚入行时觉得很简单——不就是把数据写到文件里嘛。直到有一次,我负责一个网络设备的配置管理系统,需要把一棵复杂的路由树持久化到磁盘。嗯,那次差点翻车。今天我就把这些年踩过的坑和总结的经验,一次性说清楚。

1. 什么是序列化?为什么要序列化?

序列化,说白了就是把内存里的复杂数据结构,比如链表、树、图,转换成一种可以存储或传输的格式。反过来,从文件恢复成内存结构,就叫反序列化。

你想想看,程序一退出,内存里的数据就没了。如果你想让数据“活”下来,就得把它写到文件里。但链表、树这些结构,节点之间都是指针相连的,指针是内存地址,换个进程、换个时间,地址就变了。所以你不能直接存指针,得存“关系”。

核心问题:指针是地址,地址会变。序列化的本质是“地址无关化”。

2. 链表的序列化与反序列化

链表是最简单的。我个人的习惯是,用“前序遍历 + 特殊标记”的方式。比如单向链表,每个节点存一个值和一个next指针。序列化时,我按顺序把每个节点的值写进去,节点之间用逗号或换行分隔。反序列化时,按顺序读出来,一个个创建节点,再连起来。

但这里有个坑:如果链表有环呢?

我曾经在一个内存池管理模块里遇到过循环链表。如果按普通方式序列化,就会死循环。解决办法是:记录每个节点的“访问序号”,第一次遇到就写序号+值,第二次遇到只写序号。反序列化时,先创建所有节点,再根据序号重建连接。

// 链表节点
typedef struct Node {
    int data;
    struct Node* next;
} Node;

// 序列化:简单无环链表
void serialize_list(Node* head, FILE* fp) {
    Node* cur = head;
    while (cur) {
        fprintf(fp, "%d\n", cur->data);
        cur = cur->next;
    }
    fprintf(fp, "#\n");  // 结束标记
}

// 反序列化
Node* deserialize_list(FILE* fp) {
    Node dummy;
    Node* tail = &dummy;
    char line[32];
    
    while (fgets(line, sizeof(line), fp)) {
        if (line[0] == '#') break;
        Node* node = (Node*)malloc(sizeof(Node));
        sscanf(line, "%d", &node->data);
        node->next = NULL;
        tail->next = node;
        tail = node;
    }
    return dummy.next;
}

我的经验:对于有环链表,建议用“节点ID映射表”。序列化时给每个节点分配一个唯一ID,存成 (ID, 值, 下一个ID) 的格式。反序列化时,先建一个ID到节点指针的哈希表,再连next。

3. 树的序列化与反序列化

树比链表复杂一点,但思路类似。我常用的是“前序遍历 + 空节点标记”。比如二叉树,遍历时遇到空节点就写一个特殊符号(比如 '#'),这样反序列化时就能唯一重建。

你可能会问:为什么不用中序或后序?嗯,前序的好处是,第一个节点就是根节点,反序列化时可以直接创建根,然后递归构建左右子树。后序的话,最后一个才是根,处理起来稍微绕一点。

// 二叉树节点
typedef struct TreeNode {
    int val;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;

// 前序序列化
void serialize_tree(TreeNode* root, FILE* fp) {
    if (!root) {
        fprintf(fp, "# ");
        return;
    }
    fprintf(fp, "%d ", root->val);
    serialize_tree(root->left, fp);
    serialize_tree(root->right, fp);
}

// 前序反序列化
TreeNode* deserialize_tree(FILE* fp) {
    char token[16];
    if (fscanf(fp, "%s", token) != 1) return NULL;
    if (token[0] == '#') return NULL;
    
    TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
    node->val = atoi(token);
    node->left = deserialize_tree(fp);
    node->right = deserialize_tree(fp);
    return node;
}

注意:如果树很深(比如几万层),递归反序列化可能导致栈溢出。我建议改用迭代方式,或者用栈模拟递归。我在一个嵌入式项目里就吃过这个亏,递归深度超过系统栈限制,程序直接崩了。

4. 图的序列化与反序列化

图是最复杂的。因为图可能有环,而且节点之间的连接关系是任意的。我个人的做法是:

  1. 节点列表:先存所有节点的信息(比如ID、值)。
  2. 边列表:再存所有边的信息(起点ID、终点ID、权重等)。

举个例子,一个社交关系图,每个人是一个节点,好友关系是边。序列化时,我先写节点数,再写每个节点的ID和名字,然后写边数,再写每条边的两个ID。

// 图节点
typedef struct GraphNode {
    int id;
    char name[32];
    struct GraphNode** neighbors;
    int neighbor_count;
} GraphNode;

// 序列化图(简化版)
void serialize_graph(GraphNode** nodes, int node_count, FILE* fp) {
    // 先写节点数
    fprintf(fp, "%d\n", node_count);
    
    // 写每个节点
    for (int i = 0; i < node_count; i++) {
        fprintf(fp, "%d %s\n", nodes[i]->id, nodes[i]->name);
    }
    
    // 写边数(先统计)
    int edge_count = 0;
    for (int i = 0; i < node_count; i++) {
        edge_count += nodes[i]->neighbor_count;
    }
    fprintf(fp, "%d\n", edge_count / 2);  // 无向图每条边算了两次
    
    // 写每条边(避免重复)
    for (int i = 0; i < node_count; i++) {
        for (int j = 0; j < nodes[i]->neighbor_count; j++) {
            if (nodes[i]->id < nodes[i]->neighbors[j]->id) {
                fprintf(fp, "%d %d\n", nodes[i]->id, 
                        nodes[i]->neighbors[j]->id);
            }
        }
    }
}

避坑指南:我曾经在序列化有向图时,忘了处理“孤立节点”(没有边的节点)。结果反序列化时,那些节点虽然存在,但邻居数组是NULL,导致后续遍历时崩溃。所以,序列化时一定要考虑边界情况:空图、单节点、全连通图等。

5. 知识体系总览

下面这张图,是我总结的序列化核心逻辑。你看一眼,基本就明白整体脉络了。

复杂数据结构序列化核心逻辑 序列化/反序列化 链表 顺序存储 / 节点ID映射 处理环:访问序号标记 前序/层序 + 空节点标记 递归/迭代反序列化 节点列表 + 边列表 处理孤立节点/环 核心原则:地址无关化 + 结构可重建

6. 通用序列化格式建议

在实际项目中,我建议你考虑以下几种格式:

格式 优点 缺点 适用场景
纯文本(自定义) 简单、可读性强 解析慢、易出错 小数据量、调试用
JSON 标准、跨语言 体积大、无类型 配置文件、网络传输
二进制(自定义) 体积小、速度快 不可读、跨平台需注意字节序 高性能场景、嵌入式
Protocol Buffers 高效、强类型、可扩展 需要预定义schema 大型系统、微服务

我的建议:如果你只是自己用,或者数据量不大,纯文本格式就够了。但如果是生产环境,尤其是跨语言、跨平台,我强烈推荐用 Protocol Buffers 或者类似的成熟方案。别自己造轮子,除非你想像我一样,在凌晨三点debug到怀疑人生。

7. 总结

序列化这件事,说白了就是“把内存里的关系,变成文件里的数据”。链表、树、图,复杂度依次递增,但核心思路是一样的:

  • 存什么:节点的值 + 节点之间的关系(用ID代替指针)。
  • 怎么存:顺序、前序、层序,选一种能唯一重建的顺序。
  • 注意什么:环、空节点、孤立节点、递归深度、字节序。

嗯,差不多就这些。如果你在实际项目中遇到什么奇葩的序列化问题,欢迎来交流。毕竟,踩坑是成长的必经之路嘛。


专注资料整理