数据结构:用链式队列实现层序遍历 (Level-order Traversal)

目录

打造我们的工具------从零推导链式队列

代码实现

回归任务------层序遍历的本质需求

连接!用我们的队列完成遍历

完整代码与回顾


我们放慢节奏,把这个问题拆解成最基本、最独立的部分,然后再把它们组合起来。

很多人感觉"队列结合树就不会写了",这很正常。根本原因是我们往往试图一次性理解两个概念的互动。

根据第一性原理,正确的做法是:先将每个概念彻底分解,独立理解,然后再看它们如何以最简单的方式连接。

数据结构:用链表实现队列(Implementing Queue Using List)-CSDN博客

数据结构:层序遍历 (Level-order Traversal)-CSDN博客

所以,我们的计划是:

  1. 第一步:彻底忘记树。 我们先从零开始,推导出什么是"链式队列",以及如何实现它。把它当作一个独立的工具来学习。

  2. 第二步:回归树的遍历问题。 我们重新审视"层序遍历"这个任务的本质需求,看看它到底需要一个什么样的"工具"。

  3. 第三步:连接。 把我们打造好的"工具"(链式队列)应用到我们的"任务"(层序遍历)上,你会发现一切都水到渠成。


打造我们的工具------从零推导链式队列

问题的起点:为什么要用"链式"?

我们之前用数组实现过队列,它很好用,但有个天生的缺陷:容量固定

#define MAX_QUEUE_SIZE 100 这行代码意味着队列最多只能装100个元素。如果树很大,有101个节点在同一层,程序可能就出错了。

我们希望能有一个"用多少,装多少",容量可以动态增长的队列。

怎么实现动态增长?在C/C++里,最基础的动态内存技术就是 malloc 和指针,也就是链表。

核心思想:如何用链表模拟"排队"?

一个队列,就是一个排队队伍。它有两个最重要的操作:

  • 入队 (Enqueue): 一个人(新元素)站到队尾。

  • 出队 (Dequeue): 队头的人(最早来的元素)办理业务,离开队伍。

为了高效地实现这两个操作,我们需要能瞬间定位到队头和队尾。

  • 如果只用一个 head 指针指向链表头部,要找到队尾就必须遍历整个链表,效率太低。

  • 所以, 一个高效的链式队列,必须有两个指针:一个 front 指针指向队头,一个 rear 指針指向队尾。

代码实现

定义队列的"节点"

队列里要存放的是树的节点指针 (Node*)。所以,队列自己的节点 QueueNode 结构就需要包含两部分:

a. 要存放的数据 (Node* data)

b. 指向下一个队列节点的指针 (struct QueueNode* next)

cpp 复制代码
// 首先,这是我们熟悉的树节点
typedef struct TreeNode {
    char data;
    struct TreeNode* left;
    struct TreeNode* right;
} Node; // 为了方便,我们还是叫它 Node

// 这是队列自己的节点结构
typedef struct QueueNode {
    Node* data; // 存放的数据是树节点的指针
    struct QueueNode* next; // 指向下一个队列节点
} QueueNode;

定义"队列"本身

队列本身的管理结构,只需要包含我们刚才推导出的两个核心指针:frontrear

cpp 复制代码
typedef struct Queue {
    QueueNode* front;
    QueueNode* rear;
} Queue;

实现队列的基本操作

  • 创建队列 (createQueue) 一个空的队列,就是队头和队尾指针都为 NULL
cpp 复制代码
Queue* createQueue() {
    Queue* q = (Queue*)malloc(sizeof(Queue));
    q->front = NULL;
    q->rear = NULL;
    return q;
}

入队 (enqueue) - 这是最关键的一步,我们放慢点

当一个新元素(比如树节点A)要入队时:

  1. 肯定要先为这个新元素创建一个队列节点 newNode

  2. 然后要考虑两种情况:

    • 情况一:队列当前是空的 (q->rear == NULL) 这时队伍里一个人都没有。新来的 newNode 既是队头,也是队尾。所以 frontrear 都应该指向 newNode

    • 情况二:队列里已经有人了 rear 指针正指向当前的队尾。我们要做的是:让当前的队尾的 next 指针指向 newNode,然后更新 rear 指针,让它指向新的队尾 newNode

cpp 复制代码
void enqueue(Queue* q, Node* treeNode) {
    // 1. 为新来的树节点创建一个队列节点
    QueueNode* newNode = (QueueNode*)malloc(sizeof(QueueNode));
    newNode->data = treeNode;
    newNode->next = NULL;

    // 2. 情况一:队列为空
    if (q->rear == NULL) {
        q->front = newNode;
        q->rear = newNode;
        return;
    }

    // 3. 情况二:队列不为空
    q->rear->next = newNode; // 让旧队尾指向新节点
    q->rear = newNode;       // 更新队尾指针
}

出队 (dequeue) - 同样放慢

出队就是让队头元素离开。

  1. 首先检查队列是否为空。如果为空,无队可出。

  2. 如果不为空,front 正指向要离开的那个节点。我们要做的就是:

  • 先把这个要离开的节点存起来,因为我们待会要返回它里面的数据。

  • front 指针向后移动一位,指向它的下一个节点 front->next

  • 一个非常重要的边界情况: 如果出队的是队伍里最后一个人,那么出队后,队列就空了。这时 front 会变成 NULL。此时,我们必须同时把 rear 也更新为 NULL!否则 rear 就会成为一个指向无效内存的"野指针"。

  • 返回我们存起来的那个节点里的数据。

cpp 复制代码
Node* dequeue(Queue* q) {
    // 1. 检查队列是否为空
    if (q->front == NULL) {
        return NULL;
    }

    // 2. 保存队头节点和它里面的数据
    QueueNode* temp = q->front;
    Node* treeNodeData = temp->data;

    // 3. 移动 front 指针
    q->front = q->front->next;

    // 4. 处理边界情况:如果出队后队列变空
    if (q->front == NULL) {
        q->rear = NULL;
    }

    // 5. 释放出队的这个队列节点本身的内存,并返回树节点数据
    free(temp);
    return treeNodeData;
}

检查队列是否为空 (isQueueEmpty)

这个最简单,看 front 指针是不是 NULL 就行了。

cpp 复制代码
int isQueueEmpty(Queue* q) {
    return q->front == NULL;
}

到此为止,我们已经成功打造了一个功能完备、独立可靠的"链式队列"工具。

你现在可以彻底忘记它的内部实现,只需要记住它有三个接口:enqueue (在队尾放东西)、dequeue (从队头取东西)、isQueueEmpty (判断是不是空的)。


回归任务------层序遍历的本质需求

现在,我们把队列放一边,重新思考"层序遍历"这个任务。 它的口语化描述是: "先访问第0层,再访问第1层,再访问第2层..."

我们手动模拟一下:

  1. 我们站在树的根节点 A (第0层)。我们访问它 (打印 A)。

  2. 好了,A 访问完了。接下来该访问谁?是第1层的所有节点:BC

  3. 核心问题: 当我们在处理 A 的时候,我们怎么"记录"下 BC 是"待办事项"?并且,我们要保证 BC 之前被访问。

  4. 当我们访问 B 的时候,我们又发现了新的待办事项:DE。这些事项的办理顺序应该在 C 之后。

  5. 这个过程像什么?

  • 访问 A,产生待办 B, C。待办列表:[B, C]

  • 从列表头取出 B 访问,产生待办 D, E。把它们加到列表尾部。待办列表:[C, D, E]

  • 从列表头取出 C 访问,产生待办 F。加到列表尾部。待办列表:[D, E, F]

  • ...

序遍历的本质,就是一个"先进先出"的任务处理过程。它需要的工具,正是一个队列 (Queue)。


连接!用我们的队列完成遍历

现在,是时候把工具和任务连接起来了。这个过程非常简单,因为我们已经分别把两边都想清楚了。

算法逻辑:

  1. 创建一个队列。

  2. 如果树不是空的,把树的根节点入队 (enqueue)。

  3. 只要队列不为空 (isQueueEmpty),就循环执行以下操作:

a. 从队列中出队 (dequeue) 一个树节点,我们叫它 current

b. 访问 current 节点(比如打印它的数据)。

c. 检查 current 的孩子们: * 如果它有左孩子,把左孩子入队 (enqueue)。 * 如果它有右孩子,把右孩子入队 (enqueue)。

你会发现,下面的代码几乎就是上面算法逻辑的逐字翻译。我们完全不需要关心 enqueuedequeue 内部是怎么用链表实现的,我们只管用它们提供的功能。

cpp 复制代码
void levelOrder(Node* root) {
    // 边界情况:如果树是空的,直接返回
    if (root == NULL) {
        return;
    }

    // 1. 创建一个队列
    Queue* q = createQueue();

    // 2. 把根节点入队
    enqueue(q, root);

    // 3. 只要队列不为空,就循环
    while (!isQueueEmpty(q)) {
        // a. 出队一个节点
        Node* current = dequeue(q);

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

        // c. 把它的孩子们(如果存在)加入队列
        if (current->left != NULL) {
            enqueue(q, current->left);
        }
        if (current->right != NULL) {
            enqueue(q, current->right);
        }
    }
    
    // 别忘了释放队列本身的内存
    free(q);
}

完整代码与回顾

现在,让我们把所有部分(树的创建、链式队列的完整实现、层序遍历函数)放在一起,形成一个可以运行的完整程序。

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

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

// --- 链式队列的节点定义 ---
typedef struct QueueNode {
    Node* data;
    struct QueueNode* next;
} QueueNode;

// --- 链式队列本身的定义 ---
typedef struct Queue {
    QueueNode* front;
    QueueNode* rear;
} Queue;

// --- 队列操作函数的实现 ---
Queue* createQueue() {
    Queue* q = (Queue*)malloc(sizeof(Queue));
    q->front = q->rear = NULL;
    return q;
}

int isQueueEmpty(Queue* q) {
    return q->front == NULL;
}

void enqueue(Queue* q, Node* treeNode) {
    QueueNode* newNode = (QueueNode*)malloc(sizeof(QueueNode));
    newNode->data = treeNode;
    newNode->next = NULL;
    if (q->rear == NULL) {
        q->front = q->rear = newNode;
        return;
    }
    q->rear->next = newNode;
    q->rear = newNode;
}

Node* dequeue(Queue* q) {
    if (isQueueEmpty(q)) return NULL;
    QueueNode* temp = q->front;
    Node* treeNodeData = temp->data;
    q->front = q->front->next;
    if (q->front == NULL) {
        q->rear = NULL;
    }
    free(temp);
    return treeNodeData;
}

// --- 层序遍历的实现 ---
void levelOrder(Node* root) {
    if (root == NULL) return;
    Queue* q = createQueue();
    enqueue(q, root);
    while (!isQueueEmpty(q)) {
        Node* current = dequeue(q);
        printf("%c ", current->data);
        if (current->left != NULL) {
            enqueue(q, current->left);
        }
        if (current->right != NULL) {
            enqueue(q, current->right);
        }
    }
    free(q);
}

// --- 用于测试的辅助函数 ---
Node* createTreeNode(char data) {
    Node* newNode = (Node*)malloc(sizeof(Node));
    newNode->data = data;
    newNode->left = newNode->right = NULL;
    return newNode;
}

// --- Main 函数 ---
int main() {
    // 构建示例树
    Node* root = createTreeNode('A');
    root->left = createTreeNode('B');
    root->right = createTreeNode('C');
    root->left->left = createTreeNode('D');
    root->left->right = createTreeNode('E');
    root->right->right = createTreeNode('F');

    printf("Level-order Traversal (with Linked Queue):\n");
    levelOrder(root);
    printf("\n");

    return 0;
}

回顾一下我们的思路: 我们并没有直接去"写队列和树结合的代码"。我们是先独立地把"链式队列"这个工具造好、想透彻。然后,我们再独立地分析"层序遍历"这个任务,发现它天生就需要一个FIFO的工具。最后一步,我们只是简单地把工具的接口和任务的需求对接起来。

通过这种"隔离-分析-连接"的思路,复杂问题就被分解成了几个简单问题,你就可以更有信心地写出正确的代码了。

相关推荐
John.Lewis1 小时前
数据结构初阶(13)排序算法-选择排序(选择排序、堆排序)(动图演示)
c语言·数据结构·排序算法
AI小白的Python之路1 小时前
数据结构与算法-排序
数据结构·算法·排序算法
一只鱼^_2 小时前
牛客周赛 Round 105
数据结构·c++·算法·均值算法·逻辑回归·动态规划·启发式算法
指针满天飞4 小时前
Collections.synchronizedList是如何将List变为线程安全的
java·数据结构·list
洋曼巴-young4 小时前
240. 搜索二维矩阵 II
数据结构·算法·矩阵
楼田莉子5 小时前
C++算法题目分享:二叉搜索树相关的习题
数据结构·c++·学习·算法·leetcode·面试
小明的小名叫小明6 小时前
区块链技术原理(14)-以太坊数据结构
数据结构·区块链
pusue_the_sun6 小时前
数据结构——栈和队列oj练习
c语言·数据结构·算法··队列
奶黄小甜包7 小时前
C语言零基础第18讲:自定义类型—结构体
c语言·数据结构·笔记·学习
想不明白的过度思考者7 小时前
数据结构(排序篇)——七大排序算法奇幻之旅:从扑克牌到百亿数据的魔法整理术
数据结构·算法·排序算法