当前位置: 首页 > news >正文

C语言:单链表与栈队列实现

前言:

本文基于指针与动态内存管理核心知识点,系统讲解C语言三大基础数据结构。内容涵盖结构定义、核心操作实现及高频面试真题解析,所有代码严格遵循笔试规范,注重边界条件处理和内存安全。作为嵌入式及C/C++岗位笔试第二大高频考点,本专题既适合零基础入门,也适用于知识点巩固与校招/社招面试冲刺复习。


一、单链表全解

链表是线性表的链式存储结构,通过指针将离散的内存节点串联起来,解决了数组插入删除效率低、大小固定的痛点,是指针与动态内存最经典的落地场景。

1. 链表 vs 数组核心对比

对比维度数组单链表
内存分布连续内存空间离散内存节点,通过指针连接
大小固定性大小固定,扩容麻烦动态增减,按需申请释放
随机访问支持下标 O (1) 随机访问不支持随机访问,必须从头遍历 O (n)
插入删除中间插入删除需移动元素 O (n)已知位置时仅需修改指针 O (1)
内存利用率有预分配空间浪费无浪费,但每个节点多一个指针开销

2. 节点定义

#include <stdio.h> #include <stdlib.h> #include <assert.h> // 单链表节点结构 typedef int SLTDataType; typedef struct SListNode { SLTDataType data; // 数据域 struct SListNode* next; // 指针域,指向下一个节点 } SListNode;

3. 核心操作手撕实现

① 创建新节点
// 创建一个值为x的新节点 SListNode* BuySListNode(SLTDataType x) { SListNode* newNode = (SListNode*)malloc(sizeof(SListNode)); if (newNode == NULL) { perror("malloc failed"); exit(-1); } newNode->data = x; newNode->next = NULL; return newNode; }
② 尾插法:末尾插入节点
// 在链表尾部插入值为x的节点 void SListPushBack(SListNode** pphead, SLTDataType x) { assert(pphead != NULL); SListNode* newNode = BuySListNode(x); // 空链表:直接让头指针指向新节点 if (*pphead == NULL) { *pphead = newNode; return; } // 非空:找到尾节点 SListNode* tail = *pphead; while (tail->next != NULL) { tail = tail->next; } tail->next = newNode; }

注意:修改头指针必须传二级指针,否则只是修改形参副本。

③ 头插法:头部插入节点
// 在链表头部插入值为x的节点 void SListPushFront(SListNode** pphead, SLTDataType x) { assert(pphead != NULL); SListNode* newNode = BuySListNode(x); // 新节点指向原头,头指针更新为新节点 newNode->next = *pphead; *pphead = newNode; }
④ 尾删法:删除尾节点
// 删除链表最后一个节点 void SListPopBack(SListNode** pphead) { assert(pphead != NULL); assert(*pphead != NULL); // 空链表不能删 // 只有一个节点:直接释放,头置空 if ((*pphead)->next == NULL) { free(*pphead); *pphead = NULL; return; } // 多个节点:找到倒数第二个节点 SListNode* prev = *pphead; while (prev->next->next != NULL) { prev = prev->next; } free(prev->next); prev->next = NULL; }
⑤ 头删法:删除头节点
// 删除链表第一个节点 void SListPopFront(SListNode** pphead) { assert(pphead != NULL); assert(*pphead != NULL); SListNode* del = *pphead; *pphead = del->next; // 头指针移到下一个 free(del); }
⑥ 查找节点
// 查找值为x的节点,找到返回节点地址,找不到返回NULL SListNode* SListFind(SListNode* phead, SLTDataType x) { SListNode* cur = phead; while (cur != NULL) { if (cur->data == x) { return cur; } cur = cur->next; } return NULL; }
⑦ 指定位置后插入
// 在pos节点之后插入值为x的节点 void SListInsertAfter(SListNode* pos, SLTDataType x) { assert(pos != NULL); SListNode* newNode = BuySListNode(x); newNode->next = pos->next; pos->next = newNode; }
⑧ 销毁整个链表
// 销毁链表,释放所有节点 void SListDestroy(SListNode** pphead) { assert(pphead != NULL); SListNode* cur = *pphead; while (cur != NULL) { SListNode* next = cur->next; free(cur); cur = next; } *pphead = NULL; }

二、栈的实现与应用

栈是一种 ** 后进先出(LIFO)** 的线性表,只能在栈顶进行插入和删除操作,是算法题中最常用的数据结构之一。

1. 顺序栈(数组实现)

数组实现的栈缓存友好、访问效率高,是工业界主流实现方式。

结构定义
typedef int STDataType; typedef struct Stack { STDataType* a; // 动态数组 int top; // 栈顶位置,指向栈顶元素的下一个位置 int capacity; // 栈容量 } Stack;
初始化与销毁
// 初始化栈 void StackInit(Stack* ps) { assert(ps != NULL); ps->a = (STDataType*)malloc(sizeof(STDataType) * 4); if (ps->a == NULL) { perror("malloc failed"); exit(-1); } ps->top = 0; ps->capacity = 4; } // 销毁栈 void StackDestroy(Stack* ps) { assert(ps != NULL); free(ps->a); ps->a = NULL; ps->top = ps->capacity = 0; }
入栈与扩容
// 元素入栈 void StackPush(Stack* ps, STDataType x) { assert(ps != NULL); // 满了则扩容 if (ps->top == ps->capacity) { int newCapacity = ps->capacity * 2; STDataType* tmp = (STDataType*)realloc(ps->a, sizeof(STDataType) * newCapacity); if (tmp == NULL) { perror("realloc failed"); exit(-1); } ps->a = tmp; ps->capacity = newCapacity; } ps->a[ps->top] = x; ps->top++; }
出栈与取栈顶
// 栈顶元素出栈 void StackPop(Stack* ps) { assert(ps != NULL); assert(ps->top > 0); // 空栈不能删 ps->top--; } // 获取栈顶元素 STDataType StackTop(Stack* ps) { assert(ps != NULL); assert(ps->top > 0); return ps->a[ps->top - 1]; } // 判断栈是否为空 int StackEmpty(Stack* ps) { assert(ps != NULL); return ps->top == 0; }

2. 链栈(链表实现)

用链表头作为栈顶,头插头删模拟入栈出栈,优点是无容量限制,缺点是每个节点有指针开销,缓存性差。

面试中优先写数组实现,更简洁高效,符合常规考察方向。


三、队列的实现与应用

队列是一种先进先出(FIFO)的线性表,只能在队尾插入、队头删除,常用于任务调度、消息缓冲等场景。

1. 循环队列(数组实现)

数组实现队列如果直接用头尾指针,出队后前面的空间会浪费,因此采用循环队列复用空间,是面试核心考点。

核心难点:判空与判满

循环队列头尾重合时,无法区分是空还是满,主流两种解决方案:

  1. 牺牲一个存储单元:约定尾指针下一个是头指针时为满(最常用,面试首选)
  2. 增加 size 计数变量:额外维护元素个数,空时 size=0,满时 size = 容量
结构定义(牺牲一个单元方案)
typedef int QDataType; typedef struct CircularQueue { QDataType* a; int front; // 队头下标 int rear; // 队尾下标,指向队尾元素的下一个位置 int capacity; // 总容量(实际有效元素数为capacity-1) } CQueue;
初始化
// 初始化循环队列,k为有效元素最大个数 void CQueueInit(CQueue* q, int k) { assert(q != NULL); // 多开一个空间用于区分空满 q->a = (QDataType*)malloc(sizeof(QDataType) * (k + 1)); q->front = q->rear = 0; q->capacity = k + 1; }
入队与判满
// 入队,成功返回1,满了返回0 int CQueueEnQueue(CQueue* q, QDataType x) { assert(q != NULL); if ((q->rear + 1) % q->capacity == q->front) { return 0; // 队列已满 } q->a[q->rear] = x; q->rear = (q->rear + 1) % q->capacity; return 1; }
出队与判空
// 出队,成功返回1,空队列返回0 int CQueueDeQueue(CQueue* q) { assert(q != NULL); if (q->front == q->rear) { return 0; // 队列为空 } q->front = (q->front + 1) % q->capacity; return 1; } // 获取队头元素 QDataType CQueueFront(CQueue* q) { assert(q != NULL); assert(q->front != q->rear); return q->a[q->front]; } // 判断队列是否为空 int CQueueIsEmpty(CQueue* q) { assert(q != NULL); return q->front == q->rear; }

2. 链队列(链表实现)

用链表头尾分别作为队头队尾,尾插头删,适合元素数量不确定的场景,实现简单但缓存效率低。


四、高频面试手撕真题

1. 反转单链表(笔试 100% 高频)

题目:给你单链表的头节点,反转该链表并返回反转后的头节点。

思路:三指针迭代法,逐个改变节点指向。

struct ListNode* reverseList(struct ListNode* head) { struct ListNode* prev = NULL; struct ListNode* cur = head; while (cur != NULL) { struct ListNode* next = cur->next; // 保存下一个节点 cur->next = prev; // 反转当前节点指向 prev = cur; // prev后移 cur = next; // cur后移 } return prev; // prev最终成为新头 }

2. 链表判环(快慢指针经典题)

题目:判断链表中是否有环。

思路:快慢指针,快指针每次走两步,慢指针每次走一步,有环则一定会相遇。

bool hasCycle(struct ListNode *head) { struct ListNode* slow = head; struct ListNode* fast = head; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; if (slow == fast) { return true; } } return false; }

3. 有效的括号(栈经典应用)

题目:给定一个只包含括号的字符串,判断括号是否有效闭合。

思路:左括号入栈,遇到右括号则匹配栈顶,不匹配或栈空则无效,最后栈空则有效。

bool isValid(char * s) { Stack st; StackInit(&st); for (int i = 0; s[i] != '\0'; i++) { // 左括号入栈 if (s[i] == '(' || s[i] == '[' || s[i] == '{') { StackPush(&st, s[i]); } else { // 右括号时栈空,无效 if (StackEmpty(&st)) { StackDestroy(&st); return false; } char top = StackTop(&st); StackPop(&st); // 匹配校验 if ((s[i] == ')' && top != '(') || (s[i] == ']' && top != '[') || (s[i] == '}' && top != '{')) { StackDestroy(&st); return false; } } } // 最后栈必须为空 bool ret = StackEmpty(&st); StackDestroy(&st); return ret; }

4. 用栈实现队列

题目:用两个栈实现一个队列,支持入队、出队、取队头、判空。

思路:一个栈负责入队,一个栈负责出队;出队栈空时,把入队栈全部倒入出队栈,顺序就反转成了队列顺序。

typedef struct { Stack pushSt; // 入队栈 Stack popSt; // 出队栈 } MyQueue; void myQueuePush(MyQueue* obj, int x) { StackPush(&obj->pushSt, x); } int myQueuePop(MyQueue* obj) { // 出队栈空了,把入队栈全部倒过来 if (StackEmpty(&obj->popSt)) { while (!StackEmpty(&obj->pushSt)) { StackPush(&obj->popSt, StackTop(&obj->pushSt)); StackPop(&obj->pushSt); } } int ret = StackTop(&obj->popSt); StackPop(&obj->popSt); return ret; }

五、面试高频考点与易错坑点

1. 经典面试问答

Q1:数组和链表有什么区别?各自的适用场景?

答:

  1. 内存分布:数组是连续内存,链表是离散节点通过指针连接
  2. 访问效率:数组支持 O (1) 随机访问,链表必须遍历 O (n)
  3. 增删效率:数组中间增删需移动元素 O (n),链表已知位置时增删 O (1)
  4. 空间扩容:数组大小固定,扩容有开销;链表动态增减,按需分配 适用场景:数据量固定、频繁随机访问选数组;频繁增删、数据量不确定选链表。

Q2:栈和队列有什么区别?各有什么典型应用?

答: 栈是后进先出,队列是先进先出。 栈的典型应用:括号匹配、表达式求值、函数调用栈、递归转迭代; 队列的典型应用:任务调度、消息缓冲、广度优先搜索、生产者消费者模型。

Q3:循环队列为什么要牺牲一个单元?还有其他方案吗?

答: 因为循环队列中头尾指针重合时,无法区分队列是空还是满。 牺牲一个存储单元是最常用的方案:约定 rear 下一个位置是 front 时为满,空时则是 rear==front,以此区分两种状态。 其他方案:额外增加一个 size 变量记录元素个数,空时 size=0,满时 size 等于容量,逻辑更直观但多一个变量开销。

Q4:单链表为什么常用二级指针传参?什么时候需要二级指针?

答: 当我们需要修改链表的头指针本身时(比如头插、头删、空链表插入第一个节点),就必须传头指针的地址也就是二级指针。 因为 C 语言传参是传值,直接传头指针只是传了一份副本,函数内修改副本不会影响外面的头指针。如果只是遍历、修改节点数据,不修改头指针,传一级指针即可。

Q5:反转链表有哪些方法?迭代法的核心思路是什么?

答: 主要有迭代法和递归法两种,面试优先写迭代法,更直观无栈溢出风险。 迭代法核心是三指针:prev 记录前一个节点,cur 记录当前节点,next 保存下一个节点;遍历过程中逐个将 cur 的 next 指向 prev,同时三个指针同步后移,最终 prev 成为新的头节点。

2. 常见易错坑点

  1. 空链表不判空:删除、查找操作前不判断链表是否为空,直接解引用空指针崩溃
  2. 尾删漏处理单节点:只处理多节点情况,只剩一个节点时尾删会出现野指针
  3. 修改头指针不传二级指针:头插头删只传一级指针,导致外面头指针没变化
  4. 循环队列取模遗漏:头尾指针移动忘记取模,导致数组越界
  5. 链表销毁只释放头节点:只 free 头节点,其余节点全部泄漏,必须遍历逐个释放
  6. 插入节点顺序错误:先断开原链接再赋值新节点 next,导致后续节点地址丢失

以上就是单链表、栈、队列的全部核心内容,是数据结构的入门基础,也是笔试手撕代码的高频必考题,建议结合代码手动实现,重点掌握边界条件处理与底层逻辑。


制作不易,如果对你有用,希望能点赞收藏支持一下。

http://www.jsqmd.com/news/1082647/

相关文章:

  • 简单指南:如何用Visual C++ Redistributable AIO一键修复Windows程序运行库
  • 模拟电子技术教程-三极管
  • 3个实战技巧:用Retrieval-based-Voice-Conversion-WebUI快速构建AI语音转换系统
  • 计算机毕业设计之基于微信小程序的校园二手交易平台
  • 网站收录优化是什么?
  • 网络安全靶场 | 网络安全教程:4 个合法练手靶场,网安新人入门实战系统化训练方案
  • 【计算机毕业设计案例】基于 SpringBoot+Vue 的电影评分与推荐网站系统的设计与实现 基于 SpringBoot+Vue 的影视评论互动管理系统(程序+文档+讲解+定制)
  • 安科士 AndXe QSFP112-FR4-400G 光模块:智算 Spine-Leaf 架构 2km 高速互联标准化方案
  • 车载集成最大的好处是不用吊装
  • 《HarmonyOS技术精讲-窗口管理》第二篇:创建与控制主窗口
  • 3步实战指南:如何用qmc-decoder快速解锁加密音乐文件
  • 【MES】如何通俗简单地理解MES系统
  • 3秒图片格式转换终极指南:Chrome右键菜单一键保存JPG/PNG/WebP
  • okbiye 数据分析模块:告别 SPSS 操作难题,一键自动生成论文可用 DOCX 统计报告
  • JBoss高危漏洞复现与安全加固实战指南
  • 如何选择合适的嵌入式核心板产品?
  • IPXWrapper终极指南:5分钟让Windows 10/11完美运行经典IPX游戏
  • 计算机毕业设计之基于微信小程序的校园拼车系统的设计与实现
  • 终极宝可梦随机化器:Universal Pokemon Randomizer ZX完全使用指南
  • SkyJM-Gen 重磅开源:让文生图裁判模型“自己写打分细则“,效果登顶专用裁判模型
  • 17.Excel报表自动化(下):一键生成生产报表
  • Java毕业设计-基于 SpringBoot 的高校学生评教系统的设计与实现 基于 SpringBoot 的校园评教管理系统的设计与实现(源码+LW+部署文档+全bao+远程调试+代码讲解等)
  • 4346346
  • iOS智能背景移除终极指南:3行代码实现专业级抠图效果
  • 树莓派摄像头应用编译指南:从源码构建到二次开发
  • Git 常用指令精炼速查
  • 实战指南:掌握番茄小说下载器的本地化部署与高效使用
  • 如何高效解决Windows快捷键冲突:专业级键盘映射优化指南
  • 区间邻域中2项预倾斜复形的面结构:代数、组合与几何的交叉研究
  • 引力波数据分析:基线规范与残差增益计算的核心技术与实践