数据结构_线性表的链式表示和实现

线性表的链式表示和实现

链式存储的表示

  • 链式存储结构
    • 结点在存储器中的位置是任意的,即逻辑上相邻的数据元素在物理上不一定相邻
  • 线性表的链式表示又称为非顺序映像或链式映像。
    • 用一组物理位置任意的存储单元来存放线性表的数据元素。
    • 这组存储单元既可以是连续的,也可以是不连续的,甚至是零散分布在内存中的任意位置上的。
    • 链表中元素的逻辑次序和物理次序不一定相同。
  • 与链式存储有关的术语:
    • 结点:数据元素大的存储映像,由数据域和指针域两部分组成
      ∣数据域∣指针域∣‾‾\overline{\underline{|数据域 | 指针域|}}∣数据域∣指针域∣​​
    • 链表:n个结点由指针链组成一个链表。它是线性表的链式存储映像,称为线性表的链式存储结构
    • 单链表、双链表、循环链表:
      • 结点只有一个指针域的链表,称为单链表或线性链表
      • 结点有两个指针域的链表,称为双链表
      • 首尾相接的链表称为循环链表
    • 头指针、头结点和首元结点:
      ∣head∣‾‾\overline{\underline{|head|}}∣head∣​​–>∣info∣−∣‾‾\overline{\underline{|info|-|}}∣info∣−∣​​–>∣a1∣−∣‾‾\overline{\underline{|a_1|-|}}∣a1​∣−∣​​–>∣a2∣−∣‾‾\overline{\underline{|a_2| -|}}∣a2​∣−∣​​–>∣…∣−∣‾‾\overline{\underline{|\dots|-|}}∣…∣−∣​​–>∣an∣null∣‾‾\overline{\underline{|a_n|null|}}∣an​∣null∣​​
      头指针−−>-->−−>头结点−−>-->−−>首元结点
      • 头指针:是指向链表中第一个结点的指针:
      • 首元结点:是指链表中存储第一个数据元素a1a_1a1​的结点
      • 头结点:是在链表的首元结点之前附设的一个结点
  • 问题讨论
    1. 如何表示空表?
      • 无头结点时,头指针为空时表示空表
      • 有头结点时,当头结点的指针域为空时表示空表
    2. 在链表中设置头结点的好处?
      • 便于首元结点的处理
        首元结点的地址保存在头结点的指针域中,所以在链表的第一个位置上的操作和其它位置一致,无须进行特殊处理;
      • 便于空表和非空表的统一处理
        无论链表是否为空,头指针都是指向头结点的非空指针,因此空表和非空表的处理也就统一了。
    3. 头结点的数据域内装的是什么?
      头结点的数据域可以为空,也可存放线性表长度等附加信息,但此结点不能计入链表长度值。
  • 链表(链式存储结构)的特点
    1. 结点在存储器中的位置是任意的,即逻辑上相邻的数据元素在物理上不一定相邻。
    2. 访问时只能通过头指针进入链表,并通过每个结点的指针域依次向后顺序扫描其余结点,所以寻找第一个结点和最后一个结点所花费的时间不等
    3. 这种存取元素的方法被称为顺序存取法

单链表

定义

每个结点只有一个指针域

struct Lnode { ElemType data;//结点的数据域 Lnode *next;//结点的指针域 }; typedef Lnode *LinkList; //LinkList为指向结构体Lnode的指针类型 

自己定义的时候使用了自己的类型 −−>-->−−> 嵌套定义

算法
【2.1】基础
    • 算法步骤:
      • 生成新结点作头结点,用头指针L指向头结点。
      • 将头结点的指针域置空。
    • 算法实现:
    • 空表:链表中无元素,称为空链表(头指针和头结点仍然在)
    • 算法思路:判断头结点指针域是否为空
    • 算法实现:
    • 算法思路:从头指针开始,依次释放所有结点
    • 算法实现:
    • 算法思路:依次释放所有结点,并将头结点指针域设置为空
    • 算法实现:
    • 算法思路:从首元结点开始,依次计数所有结点
    • 算法实现:

求链表表长 返回表中元素个数

int GetLength(const LinkList &L) { Lnode *p; p = L->next; int cnt = 0; while (p) { ++cnt; p = p->next; } return cnt; } //算法的时间复杂度为O(n) 

清空链表:链表变成空链表,还存在

bool ClearList(LinkList &L) { //判断链表是否为空 if(IsEmpty(L)) { cerr << "empty List!" << endl; return false; } Node *p = L -> next; //保留了头结点 while (p)//链表还未到达尾端 { Node *temp = p->next;//将头指针指向下一个结点 delete p; p = temp; } L -> next = nullptr; //头结点指针域为空 return true; } 

链表的销毁:销毁后不存在

bool DestroyList(LinkList &L) { //判断链表是否为空 if(IsEmpty(L)) { cerr << "empty List!" << endl; return false; } while (L)//链表还未到达尾端 { Node *temp = L->next;//将头指针指向下一个结点 delete L; L = temp; } return true; } 

判断是否为空

bool IsEmpty(const LinkList &L) { if(L->next)//非空 { return false; } else { return true; } } 

初始化

bool InitList(LinkList &L) { L = new Lnode; //堆区开辟一个头结点,结点的数据类型为Lnode L->next = nullptr; //空表,也就是说头结点的指针指向为空 return true; } //LinkList &L等价于 Lnode *&L,Lnode *&L是一个指向指针的引用 
【2.2】取值

取单链表中第i个元素内容

  • 算法思路:从链表的头指针出发,顺着链域next逐个结点往下搜索,直至搜索到第i个结点为止。因此,链表不是随机存取结构
  • 算法步骤:
    1. 从第1个结点(L->next)顺链扫描,用指针p指向当前扫描到的结点,p初值p=L->next。
    2. j做计数器,累计当前扫描过的结点数,j初值为1。
    3. 当p指向扫描到的下一结点时,计数器j加1。
    4. 当j==i时,p所指的结点就是要找的第i个结点。

算法实现:

bool GetElem(const LinkList &L, const int &i, ElemType &e) { if(i < 0) { cerr << "out of range" << endl; return false; } Lnode *p = L->next; for (int j = 1; j < i + 1; ++j) { p = p->next; if(!p) { cerr << "out of range" << endl; return false;//如果此时p为空,意味着已经到达链表尾端,停止循环 } } e = p->data; return true; } 
【2.3】查找

按值查找:

  1. 获取该数据所在位置(地址)
  2. 获取该数据所在的位置序号()
  • 算法思路:从第一个元素开始依次比对,直到找到相同元素
  • 算法步骤:
    1. .从第一个结点起,依次和e相比较。
    2. 如果找到一个其值与e相等的数据元素,则返回其在链表中的地址;
    3. 如果查遍整个链表都没有找到其值和e相等的元素,则返回0或"NULL"
    4. 设置一个从1开始的计数值,每比对一次就加一,
    5. 找到后最后返回即可,
    6. 没找到返回0即可。

算法实现:

size_t LocateElem(LinkList &L, ElemType &e) { Lnode *p = L->next; size_t cnt = 1; while (p) { if (p->data == e) { return cnt; } ++cnt; p = p->next; } cerr << "not found" << endl; return 0; } //算法的时间复杂度为O(n) 
【2.4】插入

在第i个结点前插入值为e的新结点

  • 算法步骤:
    1. 首先找到ai−1a_{i-1}ai−1​的存储位置p。
    2. 生成一个数据域为e的新结点s。
    3. 插入新结点:
      1. 新结点的指针域指向结点aia_iai​s->next = p-> next;
      2. 结点ai−1a_{i-1}ai−1​的指针域指向新结点 p->next = s;
    4. 思考:步骤一和步骤二能互换吗?
      思路可行,但直接换步骤会丢失aia_iai​的地址

算法实现:

bool InsertList(LinkList &L, const int &i, const ElemType &e) { Lnode *p = L; int j = 0; while(p && j < i-1) { p = p->next; ++j; } //异常判断 if(!p || i<0) { cerr << "out of range" << endl; return false; } LinkList insert = new Lnode; insert->data = e; insert->next = p->next; p->next = insert; return true; } //算法的时间复杂度为O(n) 
【2.5】删除

删除第i个结点

  • 算法步骤:
    1. 首先找到ai−1a_{i-1}ai−1​的存储位置p,保存要删除的a的值。
    2. 令p->next指向ai1。 p->next = p->next->next;
    3. 释放结点a的空间。

算法实现:

bool EraseList(LinkList &L, const int &i) { Lnode *p = L; int j = 0; while (p->next && j < i - 1) { p = p->next; ++j; } //寻找第i个结点,并令p指向其前驱 if (!(p->next) || i < 0) { cerr << "out of range" << endl; return false; } Lnode *q = p->next; p->next = p->next->next; delete q; return true; } //算法的时间复杂度为O(n) 
【2.6】单链表的建立
    1. 算法步骤
      1. 从一个空表开始,重复读入数据;
      2. 生成新结点,将读入数据存放到新结点的数据域中
      3. 从最后一个结点开始,依次将各结点插入到链表的前端
    2. 算法实现:
    3. 算法步骤
      1. 从一个空表L开始,将新结点逐个插入到链表的尾部,尾指针r指向链表的尾结点。
      2. 初始时,r同L均指向头结点。每读入一个数据元素则申请一个新结点,将新结点插入到尾结点后,r指向新结点。
      3. 从最后一个结点开始,依次将各结点插入到链表的后端
    4. 算法实现:

尾插法元素插入在链表尾部,也叫后插法

void CreatListTail(LinkList &L, const size_t n) { Lnode *r = L; for (int i = 0; i < n; ++i) { Lnode *p = new Lnode; cin >> p->data; p->next = r->next; r->next = p; r = r->next; } } 

头插法元素插入在链表头部,也叫前插法

void CreatListHead(LinkList &L, const size_t n) { for (int i = 0; i < n; ++i) { Lnode *p = new Lnode; cin >> p->data; p->next = L->next; L->next = p; } } 

双向链表

定义
  • 为什么要讨论双向链表:
    • 单链表的结点–>有指示后继的指针域–>找后继结点方便;
      即:查找某结点的后继结点的执行时间为O(1)。
    • 无指示前驱的指针域–>找前驱结点难:从表头出发查找。
      即:查找某结点的前驱结点的执行时间为O(n)。
    • 可用双向链表来克服单链表的这种缺点
  • 双向链表:在单链表的每个结点里再增加一个指向其直接前驱的指针域prior,这样链表中就形成了有两个方向不同的链,故称为双向链表
    ∣prior∣data∣next∣‾‾\overline{\underline{|prior|data|next|}}∣prior∣data∣next∣​​
  • 双向链表结构的对称性
    • 设指针p指向某一结点
    • p->prior->next = p = p->next->prior
    • 在双向链表中有些操作,因仅涉及一个方向的指针,故它们的算法与线性链表的相同。
    • 但在插入、删除时,则需同时修改两个方向上的指针,两者的操作的时间复杂度均为O(n)。

双向链表的结构可定义为:

typedef struct DuLnode { ElemType data; DuLnode *prior, *next; } * DuLinkList; 
算法
【3.1】插入

把x插入a和b中
a的后继指向x,b的前驱指向x,x的前驱是a,x的后继是b

在这里插入图片描述

算法实现:

bool ListInsert_DuL(DuLinkList &L, const int i, const ElemType &e) { //移动指针到i处 DuLnode *p = L->next; int j = 1; while (p->next && j < i) { ++j; p = p->next; } if (j < i || j < 1) //如果i在链表范围内,上面的while循环的终止条件就是j<i { cerr << "out of range" << endl; return false; } //在堆区创建要插入的结点 DuLnode *s = new DuLnode; s->data = e; //重新建立链接关系 s->prior = p->prior; //第一步:s的前趋等于p的前趋 p->prior->next = s; //第二步,用p的前趋结点的next指向插入元素s,更改了第一条链 s->next = p; //第三步:s的后继指向p p->prior = s; //第四步:p的前趋改为指向s,更改了第二条链 //return return true; } 
【3.2】删除
  • 操作:把b从a和c中间删除
    • p->prior->next=p->next;
    • p->next->prior=p->prior;
  • 实现:
    删掉第i个
bool ListErase_DuL(DuLinkList &L, const int i) { DuLnode *p = L->next; int j = 1; while (p->next && j < i) //移动到第i个元素的位置 { ++j; p = p->next; } if (j < i || j < 1) { cerr << "out of range" << endl; return false; } //改变链接关系 p->prior->next = p->next;//p的前趋结点的next等于p的后继 if ((p->next))//如果删除的不是最后一个元素 { p->next->prior = p->prior; } //释放p delete p; //结束 return true; } 

循环链表

定义
  • 是一种头尾相接的链表
  • 即:表中最后一个结点的指针域指向头结点,整个链表形成一个环
  • 优点:从表中任一结点出发均可找到表中其他结点

∣head∣‾‾\overline{\underline{|head|}}∣head∣​​–>∣info∣−∣‾‾\overline{\underline{|info|-|}}∣info∣−∣​​–>∣a1∣−∣‾‾\overline{\underline{|a_1|-|}}∣a1​∣−∣​​–>∣a2∣−∣‾‾\overline{\underline{|a_2| -|}}∣a2​∣−∣​​–>∣…∣−∣‾‾\overline{\underline{|\dots|-|}}∣…∣−∣​​–>∣an∣info∣‾‾\overline{\underline{|a_n|info|}}∣an​∣info∣​​

  • 注意:
    • 由于循环链表中没有NULL指针,故涉及遍历操作时,其终止条件就不再像非循环链表那样判断p或p->next是否为空,而是判断它们是否等于头指针。
    • 循环条件:
  • 表示方法:
    • 头指针表示:
      • 找a1a_1a1​的时间复杂度:O(1)O(1)O(1)
      • 找ana_nan​的时间复杂度:O(n)O(n)O(n)
      • 不方便
    • 尾指针表示:
      • a1a_1a1​的储存位置:R–>next–>next
      • ana_nan​的储存位置:R
      • 时间复杂度:O(1)O(1)O(1)
    • 注意:表的操作常常是在表的首尾位置上进行。
  • 双向循环链表:和单链的循环表类似,双向链表也可以有循环表
    • 让头结点的前驱指针指向链表的最后一个结点
    • 让最后一个结点的后继指针指向头结点。

单循环链表:

p != L; p->next != L; 

单链表:

p != nullptr; p->next != nullptr; 
实现

带尾指针循环链表的合并(将Tb合并在Ta之后)

  • 分析:
    • p存表头结点 p = Ta->next;
    • Tb表头接到Ta表尾 Ta->next=Tb->next->next;
    • 释放Tb表头结点 delete Tb->next;
    • 修改指针 Tb->next=p;

算法实现:

LinkList Connect(LinkList Ta,LinkList Tb) { p = Ta->next; Ta->next=Tb->next->next; delete Tb->next; Tb->next=p; return Tb; } 

单链表、循环链表、和和双向链表的时间效率比较

查找表头结点(首元结点)查找表尾结点查找结点*P的前驱结点
带头结点的单链表LL->next时间复杂度O(1)O(1)O(1)从L->next依次向后遍历时间复杂度O(n)O(n)O(n)通过p->next无法找到其前驱
带头结点仅设头指针L的循环单链表L->next时间复杂度O(1)O(1)O(1)从L->next依次向后遍历时间复杂度O(n)O(n)O(n)通过p->next可以找到其前驱时间复杂度O(n)O(n)O(n)
带头结点仅设尾指针R的循环单链表L->next时间复杂度O(1)O(1)O(1)R时间复杂度O(1)O(1)O(1)通过p->next可以找到其前驱时间复杂度O(n)O(n)O(n)
带头结点的双向循环链表LL->next时间复杂度O(1)O(1)O(1)L->prior时间复杂度O(1)O(1)O(1)p->prior时间复杂度O(1)O(1)O(1)

顺序表和链表的比较

  • 链式存储结构的优点:
    • 结点空间可以动态申请和释放;
    • 数据元素的逻辑次序靠结点的指针来指示,插入和删除时不需要移动数据元素。
  • 链式存储结构的缺点:
    • 存储密度小,每个结点的指针域需额外占用存储空间。当每个结点的数据域所占字节不多时,指针域所占存储空间的比重显得很大。
    • 链式存储结构是非随机存取结构。对任一结点的操作都要从头指针依指针链查找到该结点,这增加了算法的复杂度。

存储密度

存储密度是指结点数据本身所占的存储量和整个结点结构中所占的存储量之比,即:

存储密度 = 结点数据本身占用的空间结点占用的空间总量\frac{结点数据本身占用的空间}{结点占用的空间总量}结点占用的空间总量结点数据本身占用的空间​

例如:
∣a1∣∗p∣‾‾\overline{\underline{|a_1|*p|}}∣a1​∣∗p∣​​
数据域8个字节,指针域4个字节,存储密度=8/12=67%

  • 一般地,存储密度越大,存储空间的利用率就越高。
  • 显然,顺序表的存储密度为1(100%),而链表的存储密度小于1。

不同情况的比较

比较项目顺序表链表
存储空间预先分配,会导致空间闲置或溢出现象动态分配,不会出现存储空间闲置或溢出现象
存储密度存储密度等于1,逻辑关系等于存储关系,无额外的存储开销存储密度小于1,需要借助指针来体现元素间的逻辑关系
存取元素随机存取,按位置访问元素的时间复杂度为O(1)顺序存取,按位置访问元素的时间复杂度为O(n)
插入、删除平均移动约表中一半元素,时间复杂度为O(n)不需移动元素,确定插入、删除位置后,时间复杂度为O(n)
适用情况1. 表长变化不大,且能事先确定变化的范围
2. 很少进行插入或删除操作,经常按元素位置序号访问数据元素
1. 长度变化较大
2. 频繁进行插入或删除操作
类比c++模板vectorlist

Read more

【算法打卡day12(2026-02-28 周六)01背包基础理论】

【算法打卡day12(2026-02-28 周六)01背包基础理论】

- 第 174 篇 - Date: 2026 - 02- 28 | 周六 Author: 郑龙浩(仟墨) 算法:动态规划(DP) 01背包基础理论 文章目录 * 01背包基础理论 * 1 三种背包基本介绍 * 2 纯01背包问题 完全背包 和 多重背包 都是 以 【01背包】为基础得来的, 所以【01背包】是重中之重,一定要把01背包搞清楚,才能把完全背包理解透彻 1 三种背包基本介绍 1. 01 背包:有n种物品,每种物品只有1个 2. 完全背包:有n种物品,每种物品有无限个 3. 多重背包:有n种物品,

By Ne0inhk
数据结构——AVL树的实现

数据结构——AVL树的实现

Hello,大家好,这一篇博客我们来讲解一下数据结构中的AVL树这一部分的内容,AVL树属于是数据结构的一部分,顾名思义,AVL树是一棵特殊的搜索二叉树,我们接下来要讲的这篇博客是建立在了解搜索二叉树这个知识点的基础之上的,因此,我在这里建议大家可以先去看看我之前写过的那片有关搜索二叉树内容的博客,为了方便大家寻找,链接就放到下面了: 搜索二叉树,效率的进一步upgrate!-ZEEKLOG博客文章浏览阅读2k次,点赞171次,收藏116次。Hello,大家好,我们关于C++的大部分语法知识就可以宣告结束了,不知道聪明的你有没有掌握扎实呢?好了,不管各位掌握的情况如何,我们从这一篇博客开始就要进入下一个阶段了,这个阶段就是大家期待已久的高阶数据结构的部分,对于数据结构这个 " 东西 " 想必大家对它定是又爱又恨,哈哈哈,但是不要担心,相信大家在看过这一篇博客之后定然会明白的。https://blog.ZEEKLOG.net/2301_81390458/article/details/143648002?spm=1001.2014.3001.5502https://bl

By Ne0inhk

数组算法总结:搭配leetcode经典题型,教你完全掌握与数组相关的算法(包括java C++ go python版本)

一、二分查找(leetcode702 二分查找) 给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target  ,写一个函数搜索 nums 中的 target,如果 target 存在返回下标,否则返回 -1。 你必须编写一个具有 O(log n) 时间复杂度的算法。 二分查找是一个非常经典的操作数组的算法,其使用的条件是必须是一个有序的数组(升序或降序),其核心是根据数组的有序性来将target与nums[mid]比较,若target>nums[mid],则target在mid的右边区间,否则在左半区间,搞清楚这个问题后,那么代码就非常容易写出来了。 python def search(nums: list[int], target: int) ->

By Ne0inhk
优选算法《二分查找》

优选算法《二分查找》

在之前的学习当中我们已经初步了解过了二分查找的整体逻辑以及二分查找,接下来我们在本篇当中将系统的来学习二分查找的使用方式以及在什么情况下可以使用二分查找。在之前的学习当中我们了解到的二分查找是要求在有序的数组当中当数组元素有序时才能使用,但是其实这只是二分查找最朴素的使用场景,接下来我们将学习更多的二分查找的使用场景。相信通过被本章的学习之后你会有所收获,一起加油吧!!! 1.二分查找算法  在之前我们就已经初步了解过了二分查找算法,在此二分查找的本质就是将原来的数组来进行不断的折半直到找到要查找的元素为止,因此二分查找也叫作折半查找。那么接下来我们就先来通过以下的算法题来巩固之前学习过的最为朴素的二分查找 1.2 朴素二分查找 704. 二分查找 - 力扣(LeetCode) 通过以上的题目描述就可以看出该算法题要我们实现的是在给定的升序数组当中找出指定值target的数组下标,如果找不到就返回-1. 那么此时在升序数组当中要进行查找就可以使用到二分查找,这时一开始创建两个变量left和right分别指向数组当中首元素和最后一个元素的下标,之后创建变量mid指向l

By Ne0inhk