数据结构速查笔记:C语言实现核心指南

作为程序员,我们每天都在与数据打交道。你是否想过,为什么有些程序处理海量数据时依然飞快,而有些程序却随着数据量增加变得步履蹒跚?答案往往在于我们如何组织和存储这些数据。这就是我们今天要深入探讨的核心主题——数据结构。

在这篇技术笔记中,我们将以 C 语言为工具,一起重温并优化那些最基础、最重要的数据结构概念。无论你是正在准备技术面试,还是希望在日常编码中写出更高效的代码,这篇文章都将为你提供从理论到实践的全面视角。我们将探索数组的内存布局、栈的函数调用机制、链表的动态灵活性,以及更复杂的树与图结构。

数据结构本质上就是一套用来组织和存储数据的“容器”,目的是让我们能够高效地访问和修改这些数据。我们可以将它们大致分为两类:线性数据结构(如数组、栈、队列、链表)和非线性数据结构(如树、图)。让我们从最基础的开始,一点点揭开它们的神秘面纱。

Arrays (数组):内存中的连续布局

数组是最基本也是最重要的数据结构。想象一下,就像是一排紧挨着的储物柜,我们在内存中开辟了一块连续的区域来存储相同类型的数据。

为什么选择数组?

数组最大的优势在于随机访问的能力。因为内存是连续的,只要我们知道数组的起始地址(基地址)和每个元素的大小,我们就可以通过数学公式瞬间计算出第 N 个元素的地址,而不需要从头开始遍历。这就是数组访问速度快的原因,时间复杂度为 O(1)。

数组的声明与初始化实战

在 C 语言中,声明数组的方式非常灵活。让我们通过代码来看看几种常见的场景:

#include 

int main() {
    // 场景 1:指定大小。声明一个能装 10 个整数的数组
    // 此时内存已分配,但内容可能是未初始化的垃圾值
    int arr[10];

    // 场景 2:指定初始化列表。编译器会自动根据元素个数推断数组大小
    int counts[] = {10, 20, 30, 40};

    // 场景 3:指定大小并初始化(推荐做法,清晰明了)
    // 这里声明了一个大小为 6 的数组,但只初始化了前 4 个
    // 在 C 语言中,未初始化的部分会自动被设置为 0
    int primes[6] = {2, 3, 5, 7}; 

    // 访问和修改元素
    arr[0] = 100; // 将第一个元素赋值为 100
    printf("第一个元素是: %d
", counts[0]); // 输出 10
    printf("primes 的第 5 个元素 (未初始化): %d
", primes[4]); // 输出 0

    return 0;
}

内存地址计算公式(核心技术点)

理解数组在内存中是如何存放的,是理解指针和数组下标的关键。

假设我们有一个数组 INLINECODE8090b9b9,INLINECODE715cd077 是下限(通常为 0),UB 是上限。计算数组长度很简单:

> Length = UB – LB + 1

如果要计算某个元素的内存地址,公式如下:

> Address(Loc(arr[k])) = Base_Address + w * k

  • Base_Address: 数组的起始地址。
  • w: 数据类型的大小(例如 int 通常是 4 字节)。
  • k: 索引下标。

多维数组的寻址

当我们处理二维数组(矩阵)时,情况稍微复杂一点,取决于数据是按行还是按列存储(C 语言默认是按行主序)。

对于一个 INLINECODE1765be88 行 INLINECODE68fe4aa6 列的数组 INLINECODEfc284439,计算 INLINECODEa34a5706 的地址:

  • 按行主序: Address = Base + w * (n * i + j)

解释*:先跳过 INLINECODE6d1cafb1 个完整的行(每行有 INLINECODE47bb57b4 个元素),再移动 j 列。

  • 按列主序: Address = Base + w * (m * j + i)

解释*:这是 Fortran 等语言的方式,先跳过 j 个完整的列。

常见操作与性能陷阱

  • 遍历:最基本的操作,利用循环结构。
  •     for(int i = 0; i < 5; i++) {
            printf("%d ", arr[i]);
        }
        
  • 插入与删除:这是数组的弱项。

如果你想在数组中间插入一个元素,你必须先将其后的所有元素都向后移动一位,以腾出空间。这导致最坏情况下的时间复杂度为 O(n)。同样,删除元素也需要填补空缺。

> 实战建议:如果频繁需要在中间插入或删除数据,请考虑后续我们会讲到的链表结构。

  • 搜索

* 线性搜索: 逐个检查,O(n)。

* 二分查找: 如果数组是有序的,我们可以利用二分查找将效率提升至 O(log n)

Stacks (栈):LIFO 的艺术

栈是一种遵循后进先出 原则的线性数据结构。想象一摞盘子,你只能把新盘子放在最上面(入栈),也只能从最上面拿走盘子(出栈)。

栈的应用场景

在编程中,栈无处不在:

  • 函数调用管理:每当一个函数被调用,它的局部变量和返回地址都会被“压”入栈帧;函数结束时,栈帧“弹出”。这就是为什么递归过深会导致“栈溢出”。
  • 撤销操作:编辑器中的 Ctrl+Z 通常就是用栈来记录历史状态。
  • 表达式求值:编译器如何计算 3 + 4 * 5?栈起到了决定性作用。

栈的基本操作代码实现

让我们定义一个简单的栈结构,并实现其核心功能:

#include 
#include 
#include 

#define MAX 100

typedef struct {
    int items[MAX];
    int top;
} Stack;

// 初始化栈
void initialize(Stack *s) {
    s->top = -1;
}

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

// 检查是否已满
bool isFull(Stack *s) {
    return s->top == MAX - 1;
}

// Push (入栈) - 时间复杂度 O(1)
void push(Stack *s, int value) {
    if (isFull(s)) {
        printf("栈溢出!无法添加 %d
", value);
        return;
    }
    s->items[++(s->top)] = value; // 先移动指针,再存值
    printf("入栈: %d
", value);
}

// Pop (出栈) - 时间复杂度 O(1)
int pop(Stack *s) {
    if (isEmpty(s)) {
        printf("栈下溢!栈为空。
");
        return -1;
    }
    return s->items[(s->top)--]; // 先取值,再移动指针
}

// Peek (窥视栈顶) - 时间复杂度 O(1)
int peek(Stack *s) {
    if (isEmpty(s)) {
        printf("栈为空。
");
        return -1;
    }
    return s->items[s->top];
}

表达式表示法:中缀、前缀与后缀

在计算机科学中,理解这三种表示法对于编写编译器或计算器至关重要。

  • 中缀: 我们习惯的写法,操作符在中间。例如 A + B
  • 后缀 (Reverse Polish Notation): 操作符在后面。A B +

* 优点:不需要括号,计算机处理起来非常高效,因为不需要优先级判断,直接用栈处理即可。

* 例子: INLINECODE164735cf 转换为 INLINECODE00d9cf8b。

  • 前缀: 操作符在前面。+ A B

* 例子: INLINECODEafc6d348 转换为 INLINECODE15fd235c。

> 实战技巧:面试中常考“中缀转后缀”。你可以使用一个栈来暂存操作符:遇到数字直接输出,遇到操作符则与栈顶操作符比较优先级,决定是弹出还是压入。

Linked Lists (链表):动态内存的王者

如果说数组是静态的、刚性的,那么链表就是动态的、灵活的。链表中的元素在内存中不需要连续存放。每个元素(节点)包含两部分:数据本身和指向下一个节点的指针。

为什么要用链表?

  • 大小动态:不需要预先分配固定大小,内存按需分配。
  • 高效的插入/删除:在已知位置插入或删除节点只需要修改指针,不需要移动大量数据(时间复杂度 O(1),忽略查找时间)。

单向链表的基础实现

让我们用 C 语言构建一个链表节点,并演示如何插入数据:

#include 
#include 

// 定义链表节点结构
typedef struct Node {
    int data;
    struct Node* next;
} Node;

// 在链表头部插入节点 (时间复杂度 O(1))
Node* insertAtHead(Node* head, int value) {
    // 1. 创建新节点并分配内存
    Node* newNode = (Node*)malloc(sizeof(Node));
    if (newNode == NULL) {
        printf("内存分配失败
");
        return head;
    }
    newNode->data = value;
    
    // 2. 让新节点指向原来的头节点
    newNode->next = head;
    
    // 3. 更新头指针指向新节点
    head = newNode;
    
    return head;
}

// 遍历打印链表
void printList(Node* head) {
    Node* current = head;
    printf("链表内容: ");
    while (current != NULL) {
        printf("%d -> ", current->data);
        current = current->next;
    }
    printf("NULL
");
}

链表的常见陷阱与技巧

  • 内存泄漏:链表最大的敌人。当你删除一个节点时,一定要记得 free() 掉它,否则内存会越用越少。
  • 指针丢失:在插入或删除操作时,一定要注意操作的顺序。如果你先断开了 A -> B 的连接,还没把新节点连上,你就找不到后面的链表了!
  • 快慢指针法:这是解决链表面试题的利器。例如,要找到链表的中间节点,不需要遍历两遍。你可以设置两个指针,快指针每次走两步,慢指针每次走一步。当快指针到达终点时,慢指针正好在中间。

Tree (树):分层数据的艺术

当我们需要处理具有层级关系的数据(如文件系统、HTML DOM 结构、组织架构图)时,树结构是最佳选择。其中,二叉树 是最常用的形式,每个节点最多有两个子节点:左孩子和右孩子。

二叉树的遍历

理解树的遍历是处理树形结构的基础。我们可以按照根节点访问的顺序分为三种方式:

  • 前序遍历: 根 -> 左 -> 右。常用于复制树结构。
  • 中序遍历: 左 -> 根 -> 右。在二叉搜索树 (BST) 中,这会输出有序的序列。
  • 后序遍历: 左 -> 右 -> 根。常用于删除树(先删孩子,再删父节点)或计算表达式。
typedef struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
} TreeNode;

// 前序遍历示例
void preorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    printf("%d ", root->val);  // 访问根
    preorderTraversal(root->left);  // 遍历左子树
    preorderTraversal(root->right); // 遍历右子树
}

> 实战见解:面试中常考的“二叉搜索树 (BST)” 就是左子节点 < 根节点 < 右子节点的树。在 BST 中查找元素的时间复杂度类似于二分查找,平均为 O(log n),但如果树退化为链表(斜树),则退化为 O(n)。这就引出了平衡树(如 AVL 树)的重要性。

Graphs (图):复杂网络的建模

图是比树更一般化的数据结构。图由顶点(节点)和边组成。边可以是单向的(有向图)或双向的(无向图),甚至可以带有权重(权重图)。

图的表示方法

  • 邻接矩阵:使用二维数组 matrix[i][j] 表示节点 i 和 j 是否相连。

优点*:O(1) 时间判断两点是否相连。
缺点*:对于稀疏图(边很少),会浪费大量空间。

  • 邻接表:使用数组和链表的组合。数组索引代表节点,每个数组元素指向一个链表,里面存储着与该节点相连的所有邻居。

优点*:节省空间,表示稀疏图的标准方式。

图的搜索算法

  • 广度优先搜索 (BFS):像水波纹一样层层向外扩散。使用队列实现。常用于找无权图的最短路径。
  • 深度优先搜索 (DFS):一条路走到黑,碰壁再回头。使用(或递归调用栈)实现。常用于拓扑排序或检测环。

Hashing (哈希):极速查找

如果我们想在 O(1) 的时间内找到数据,哈希表是首选。

原理

哈希函数将键值映射到数组的某个索引。Hash(key) -> Index

哈希冲突

当两个不同的键被映射到同一个索引时,就发生了冲突。解决冲突的常用方法:

  • 链地址法:每个数组槽位存放一个链表。如果冲突,就挂在链表的末尾。这是 HashMap 的底层原理。
  • 开放寻址法:如果冲突,就寻找下一个空的槽位(线性探测等)。

实际应用

  • 数据库索引:虽然 B+ 树更常用,但哈希索引在某些场景下极快。
  • 缓存:Redis 等缓存系统大量依赖哈希表。
  • 去重:在大数据集中快速判断某个元素是否存在。

总结与下一步

我们已经一起快速浏览了计算机科学中最重要的几种数据结构。从连续紧凑的数组,到灵活多变的链表,再到规矩分明的和错综复杂的,每一种结构都有其独特的适用场景。

作为开发者,选择正确的数据结构往往比算法本身更重要。如果你需要快速访问,选数组;如果你需要频繁增删,选链表;如果你需要处理层级关系,选树;如果你需要处理网络关系,选图。

接下来的建议

  • 动手实现:不要只看代码,关掉浏览器,自己从头用 C 语言写一遍链表的增删改查,或者手写一个哈希表。你会发现很多细节问题只有在写代码时才能遇到。
  • 刷题巩固:找一些经典的 LeetCode 题目,比如“反转链表”、“二叉树的最大深度”或“岛屿数量(图的DFS)”来检验你的理解。

希望这篇笔记能帮助你建立起扎实的知识体系。数据结构的世界深奥而有趣,让我们一起保持好奇心,继续探索吧!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/29913.html
点赞
0.00 平均评分 (0% 分数) - 0