目录
为什么malloc()前的是(LinkList)而不是(LinkList*)
单链表
链式结构中,除了存储数据元素信息(数据域),还要存储后继元素的存储地址(指针域)。这两部分组成数据元素 aᵢ 的存储映像,也叫结点(Node),n个结点组成一个链表
单链表:链表的每个结点中只包含一个指针域
头指针和头结点
头指针:链表中第一个结点的存储位置
线性链表的最后一个结点指针为“空”(通常用 NULL 或 ^ 符号表示)
头结点:头结点的指针域,存储指向第一个结点的指针
头指针和头结点的异同:
头结点的好处
便于首元结点的处理。首元结点的地址保存在头结点的指针域中,所以在链表的第一个位置上的操作和其他位置一致,无须进行特殊处理
便于空表和发空表的同意处理。无论链表是否为空,头指针都是指向头结点的非空指针,因此空表和发空表的处理也统一了
头结点的数据域内装的是什么
头结点的数据域可以为空,也可存放线性表长度等附加信息,但此结点不能计入链表长度值
链表(链式存储结构)的特点(顺序存取法)
- 结点在存储器中的位置是任意的,即逻辑上相邻的数据元素在物理上不一定相信
- 访问时只能通过头指针进入链表,并通过每个结点的指针域依次向后顺序扫描其余结点,所以寻找第一个结点和最后一个结点所花费的时间不等(找最后一个结点时要一个一个结点找过去,最后才能找到需要的结点)
注:
- 顺序表是随机存取
- 链表是顺序存取
结构代码分析
typedef struct Lnode { ElemType data; // 结点的数据域 struct Lnode *next; // 结点的指针域 }Lnode, *LinkList; /* 声明结点的类型和指向结点的指针类型 LinkList为指向结够体Lnode的指针类型 */
代码中的 *next
在这里,*next是指向 Lnode 这种结构体类型的指针
Lnode 和 *LinkList
typedef 将图中红色的结构体类型重新定义成 Lnode 和 *LinkList
Lnode 表示链表当中的结点,
*LinkList 是指向这种结构结点的指针,为了方便之后的定义不加*
LinkList L 和 Lnode *L的区别
比如,头指针L 是指向这种有数据域,有指针域的结点的一个指针
如果要定义这个L,有两种:
- Lnode *L; // 代表这向这种结点的指针
- LinkList L; // 因为LinkList本身就是指向这种结点的指针,所以定义时不用加*
这两种定义是一样的,但通常定义指向头结点指针就代表了整个链表,所以我们通常用 LinkList 来定义,而不是 Lnode *L(指向这种结点的指针)
而指向某一个结点的指针,我们才用 Lnode *L 这种方式定义
单链表的定义
例如,存储学生学号、姓名、成绩的单链表结点类型定义:
但是,为了统一链表的操作,通常这样定义:
单链表基本操作的实现
单链表的初始化
算法思路
- 生成新结点作头结点,用头指针 L 指向头结点
- 将头结点的指针域置空
/* 定义链表的结构 */ typedef struct Lnode { ElemType data; struct Lnode* next; }Lnode, * LinkList; /* 单链表的初始化 */ Status InitList(LinkList L) { L = (LinkList)malloc(sizeof(Lnode)); /* LinkList是指向结点的指针 malloc()的运算结果本来是一块空间 经过(LinkList)后变成了这块空间的地址 然后将地址赋值给L */ /* 将头结点的指针域置空 */ L->next = NULL; return OK; }
判断单链表是否为空
算法思路
- 判断头结点指针域是否为空
/* 若L为空表,则返回OK,否则返回ERROR */ Status ListEmpty(LinkList L) { if (L->next) return ERROR; // 非空返回ERROR else return OK; }
求单链表的表长
算法思路:
- 从首元结点开始,依次计数所有结点
Status ListLength(LinkList L) { Lnode* p; /* p指向第一个结点 */ p = L->next; int cnt = 0; /* 遍历单链表,统计结点数 */ while (p) { /* 如果p不为空,计数cnt就+1 */ cnt++; /* 把指针移到下一个 */ p = p->next; } return cnt; }
单链表的取值,取链表中第 i 个元素的内容
算法思路:
- 声明一个指针 p 指向链表的第一个结点,初始化 j 从 1 开始
- 当 j < i 时,就遍历链表,让 p 的指针向后移动,不断指向下一个结点,j + 1
- 若到链表末尾 p 为空,则说明第 i 个结点不存在
- 否则查找成功,返回结点 p 的数据
Status GetElem(LinkList L, int i, ElemType* e) { int j = 0; Lnode* p; /* 声明一结点p */ p = L->next; /* 让p指向链表L的第一个结点 */ j = 1; /* j为计数器 */ while (p && j < i) /* p不为空或j指向第i个元素,循环继续 */ { p = p->next; /* 让p指向下一个结点 */ ++j; } if (!p || j > i) return ERROR; /* 第i个元素不存在 */ *e = p->data; /* 取第i个元素 */ return OK; }
单链表的按值查找O(n)
因线性表只能顺序存取,即在查找时要从头指针找起,查找的时间复杂度为O(n)
算法思路
- 从第一个结点开始,依次和 e 比较
- 如果找到一个其值与 e 相等的数据元素,则返回其在链表中的“位置”或地址
- 如果查遍整个链表都没有找到其值和 e 相等的元素,则返回 0 或 NULL
返回找到值的指针
/* 按值查找,返回的是地址,返回类型有*号 */ Lnode* LocateElem(LinkList L, ElemType e) { Lnode* p; /* p指向首元结点 */ p = L->next; /* p为空且p指的数据域的值和e不一样(就是还没找到) */ while (p && p->data != e) { /* p指向下一个结点 */ p = p->next; } return p; }
返回找到值的位序
/* 按值查找,返回该数据的位序 */ Status LocateElem(LinkList L, ElemType e) { Lnode* p; int j = 1; // 代表初始位序是第一个结点 p = L->next; while (p && p->data != e) { p = p->next; j++; } /* 找到了,p指向的是找到的结点,没找到指向空结点 */ if (p) return j; // p不为空,返回j else return ERROR; }
单链表的插入O(n)
线性链表不需要移动元素,只要修改指针,一般情况下时间复杂度为O(1)
但如果要在单链表中进行前插或删除操作,由于要从头查找前驱结点,所耗时间复杂度为O(n)
算法思路:
如果要在 i=3 之前插入,我们要给新结点的指针域,赋值原来的第 3(i) 个结点的地址
要找到第 2(i-1) 个结点,修改第 2(i-1) 个结点的指针域,指向插入值为 e 的新结点
所以我们要先找到第 i-1 个结点
// 在L中第i个元素之前插入数据元素e Status ListInsert(LinkList* L, int i, ElemType e) { Lnode* p; p = *L; int j = 0; // 从0开始,因为一开始指向头结点,头结点没有元素 /* 寻找第i-1个结点,当j = i-1时,找到了,退出循环,p指向第i-1个结点 */ while (p && j < i - 1) { p = p->next; ++j; } /* i大于表长+1或者小于1,插入位置非法 */ if (!p || j > i - 1) return ERROR; /* 生成新结点s,将结点s的数据域置为e */ Lnode* s; s = (Lnode*)malloc(sizeof(Lnode)); s->data = e; /* 将结点s插入L中 */ s->next = p->next; p->next = s; return OK; }
LinkList* L中的 * 有什么用
LinkList* L
: 允许函数内部修改链表的头指针。这里的LinkList* L
实际上是一个指向链表头指针的指针,也就是说L是一个指针的地址,函数可以通过解引用(*L
)来访问和修改这个指针变量的值LinkList L
: 只能传递链表的头指针给函数,L持有头指针这个指针变量的值(即地址),但它本身并不是一个地址的地址,不能在函数内部修改头指针。在这个函数中,使用
LinkList L
也是可以的,因为函数的目的是插入结点而不是修改头指针
j 为什么等于 0
因为
j
用于追踪当前遍历到的结点位置,以确保能够正确地找到插入点的前一个结点。这里的计数逻辑与链表结点的索引有关
- 头节点:头结点不存储数据,它的索引为0
- 数据节点:第一个数据结点的索引为1,第二个数据结点的索引为2,依此类推
从0开始计数确保了头结点(索引0)和数据结点(索引1及以后)的一致性
while (p && j < i - 1)的含义
这是一个循环的条件判断语句,用于遍历链表直到找到插入位置的前一个节点
p
:
p
是一个指向链表结点的指针。- 这个条件检查
p
是否为NULL
。如果p
不为NULL
,表示当前还结点可以遍历。
j < i - 1
:
j
是当前遍历到的结点的位置索引,从0开始计数。i
是要插入新结点的目标位置,从1开始计数。j < i - 1
表示当前结点的位置索引还没有达到目标插入位置的前一个位置
具体分析:
p
的情况:
- 如果
p
不为NULL
,循环继续执行- 如果
p
为NULL
,表示已经到达链表的末尾,循环停止
j < i - 1
的情况:
- 如果
j
的值小于i - 1
,表示还没有到达目标插入位置的前一个结点,需要继续遍历- 当
j
的值等于i - 1
,表示已经到达了目标插入位置的前一个结点,循环停止
举例说明
假设我们有一个单链表,当前包含三个数据元素,元素值分别为10、20和30。链表的结构如下:
头结点(索引0) -> 结点1(索引1, 数据10) -> 结点2(索引2, 数据20) -> 结点3(索引3, 数据30)
现在,我们想要在索引2(即数据20)之前插入一个新的数据元素15。
遍历过程
- 第一次循环:
p
指向头结点,j
为0。- 检查条件
p && j < i - 1
:p
不为NULL
且 0 < 1(因为我们要插入到索引2的位置,所以i = 2
),条件为真。- 移动到下一个结点:
p
指向结点1。- 增加索引:
j
增加到1。- 第二次循环:
p
指向结点1,j
为1。- 检查条件
p && j < i - 1
:p
不为NULL
且 1 < 1,条件为真。- 移动到下一个节点:
p
指向结点2。- 增加索引:
j
增加到2。- 第三次循环:
p
指向结点2,j
为2。- 检查条件
p && j < i - 1
:p
不为NULL
但 2 不小于 1,条件为假。- 循环结束
插入操作
在循环结束后,
p
指向结点2(索引1的数据结点),这是我们想要插入新元素15的位置的前一个结点。我们可以安全地插入新结点
if (!p || j > i - 1)
i
大于链表长度 + 1: 如果i
大于链表长度 + 1,那么在遍历链表的过程中,p
会先变成NULL
,所以!p
为真,触发return ERROR;
i
小于1: 如果i
小于1,(假设 i=0,-1,-2……),因为j
从0开始计数,而i - 1
一定是负数,所以这时j > i - 1
为真,同样触发return ERROR;
单链表的删除,删除第 i 个结点O(n)
线性链表不需要移动元素,只要修改指针,一般情况下时间复杂度为O(1)
但如果要在单链表中进行前插或删除操作,由于要从头查找前驱结点,所耗时间复杂度为O(n)
算法思路
- 首先找到 aᵢ₋₁ 的存储位置 p,用 q 结点指向要删除的 aᵢ(如果还有需要就保存到其他位置)
- 令 p->next 指向 aᵢ₊₁(aᵢ₊₁的指针在 aᵢ 的指针域存着,aᵢ 的指针在 aᵢ₋₁ 的指针域存着。所以p->next = p ->next ->next)
- 释放结点 aᵢ 的空间
/* 将线性表L中第i个数据元素删除 */ Status ListDelete(LinkList* L, int i, ElemType* e) { Lnode* p,*q; p = *L; // 或p = L; int j = 0; /* 寻找删除结点i的前驱(i-1),并令p指向其前驱 */ while (p->next && j < i - 1) { p = p->next; ++j; } /* p->next,确保 p 指向的结点不是链表的最后一个结点。 如果 p->next(判断条件中) 为 NULL 那么 p 就是链表的最后一个结点的前一个结点(没有进入循环体,p没变) */ /* 删除位置不合理 */ if (!(p->next) || j > i - 1) return ERROR; /* 临时保存被删结点的地址以备释放 */ q = p->next; /* 将要删除结点的前驱结点的指针域,修改为指向删除结点的后继结点 */ p->next = q->next; // 或p->next = p->next->next; /* 将删除结点数据域中的值给e */ *e = q->data; /* 释放删除结点的空间 */ free(q); return OK; }
p = *L 和 p = L的区别
两者等价(在LinkList *L的情况下)
由于
L
是一个指向链表头指针的指针,*L
和L
在这个上下文中实际上指向同一个地址(即链表的头结点的地址)。因此,p = *L;
和p = L;
都会使p
指向链表的头结点
两者不同(在LinkList L的情况下)
p = L;
p = L;
直接将L
的值赋给p
。因为L
是一个指针,所以p
现在指向链表的头结点。p = *L;
*L
试图解引用L
,这是非法的,因为在这个上下文中L
不是一个指向指针的指针,而是一个普通的指针。所以,*L
会导致编译错误
单链表的清空
算法思路
- 从首元结点开始,依次释放所有结点,并将头结点指针域设置为空、
让 p 从首元结点开始,p = L->next
在删除 p 指向的结点之前,需要另一变量指向我们要释放掉的结点的下一个结点
最后:
Status ClearList(LinkList L) { Lnode* p, * q; // 或LinkList p, q; p = L->next; /* 循环条件是p非空,即没到表尾 */ while (p) { q = p->next; free(p); p = q; } /* 将头结点指针域设置为空 */ L->next = NULL; return OK; }
单链表的销毁
算法思路
- 从头结点开始,依次释放所有结点
- 有这样一个单链表,我们还需要另外一个指针变量 p,来操作我们当前想要操作的结点让它一开始指向头结点,然后释放它指向的结点
- 一个变量要想指向某一个空间,我们就把这个空间的地址赋给它。现在头结点的地址在头指针里存着,所以直接 p=L 将 L 的值赋值给 p
- 不能直接删掉 p 指向的结点,不然 L 的地址也没了,我们就找不到下一个结点在哪里了
- 所以我们先将L往后移一下,让L存储下一结点的地址
结束条件和循环条件:
Status DestroyList(LinkList L) { Lnode* p; // 或LinkList p; /* 循环条件是L非空 */ while (L) { /* 让指针p指向当前L指向的结点 */ p = L; /* 然后L指针指向下一结点 */ L = L->next; /* 释放内存 */ free(p); } /* 头结点指针域为空 */ L->next = NULL; return OK; }
单链表的建立
头插法O(n)
头插法(前插法):始终让新结点在第一个位置
从一个空表开始,重复读入数据
生成新结点,将读入数据存放到新结点的数据域中(让原来的头指针指向新结点,让新结点的指针域指向刚才在表头的结点)
从最后一个结点开始(最先插入的是第一个元素),依次将各结点插入到链表的前端
示意图:
- 在内存中找到一块空间,作为头结点/* *L = (LinkLIst) malloc (sizeof (Lnode)) */
- 将头结点的指针域置空/* (*L)->next = NULL; */
- 在内存中再次找到一块空间 p,作为存储新结点的空间/* p = (Lnode*) malloc (sizeof (Lnode)); */
- 然后把要插入的数据放在新结点的数据域/* scanf(&p->next); */
- 将新结点的指针域置空/* p->next = (*L)->next; */
- 将头结点的指针域指向新结点/* (*L)->next = p; */
- 循环:
- 生成一新结点 p,把插入的值放在新结点的数据域
p = (Lnode*)malloc(sizeof(Lnode));
scanf(&p->data);
p->next = (*L)->next;- 将新结点插入到链表中
- (*L)->next = p;
/* 单链表的建立-头插法 */
void CreatListHead(LinkList* L, int n)
{
/* 建立一个带头结点的空链表 */
*L = (LinkList)malloc(sizeof(Lnode));
(*L)->next = NULL;
Lnode* p;
int i = 0;
for (i = n; i > 0; --i)
{
p = (Lnode*)malloc(sizeof(Lnode));
scanf(&p->data);
/* 将新结点的指针域置空 */
p->next = (*L)->next;
/* 让头结点指向新结点(将新结点的地址赋值给头指针) */
(*L)->next = p;
}
}
为什么是 *L 而不是 L
L
是一个指向LinkList
类型的指针,也就是指向指针的指针。在这个函数中,我们想要分配内存给链表的头结点,并且让头指针
L
指向这个新分配的内存地址。因此,我们需要使用*L
来修改L
指针指向的地址
*L
解引用L
指针,允许你修改它指向的地址L
直接指向一个指针,没有解引用,所以你不能通过L
来修改指针指向的地址
如果函数声明是
void CreatList_H(LinkList L, int n)
,那么L
是一个普通的指针,而不是指向指针的指针在这种情况下,无法修改原始头指针的地址,因为
L
只是一个局部副本,对它的任何修改都不会影响函数外部的头指针所以不行
为什么malloc()前的是(LinkList)而不是(LinkList*)
(LinkList)
将malloc
返回的void*
类型转换为LinkList
类型,也就是Lnode*
类型。LinkList
是Lnode*
的别名,所以这是一个指针类型(LinkList*)
将会错误地将返回值转换为指向指针的指针类型,这并不符合我们的需求在这个上下文中,我们想要分配内存给一个新的
Lnode
结构体,并且让*L
指向这个新的结构体。因此,我们需要将malloc
返回的void*
类型转换为LinkList
(也就是Lnode*
)类型
*L = (LinkList)malloc(sizeof(Lnode))
*L = (Lnode*)malloc(sizeof(Lnode))
两种写法在技术上是等效的
使用类型别名(第一种写法)可能在语义上更清晰
为什么是 (*L)->next,而 L->next 不行
在函数
void CreatList_H(LinkList* L, int n)
中,参数L
是一个指向LinkList
类型的指针,即L
是一个指向指针的指针
(*L)
是对L
指针解引用,得到L
指向的LinkList
类型的值,这是一个Lnode
类型的对象(或指针),可以访问它的next
成员
- 为什么
(*L)->next = NULL;
可以
*L
:首先对L
进行解引用,得到L
指向的LinkList
类型的值,这是一个Lnode
类型的对象(或指针)(*L)->next
:然后访问这个Lnode
对象的next
成员因为
*L
是一个Lnode
类型(或指针),它具有next
成员,所以(*L)->next = NULL;
是合法的这行代码的作用是将新分配的头结点的
next
指针设置为NULL
,表示链表的开始
- 为什么
L->next = NULL;
不可以
L
是一个指向LinkList
类型的指针,即指向指针的指针L->next
:尝试访问指针的next
成员,这是不合法的,因为指针本身没有next
成员,next
成员是Lnode
类型的成员如果你尝试写
L->next = NULL;
,编译器会报错,因为L
是一个指针的指针,不是Lnode
类型,所以没有next
成员。
关于scanf("%d", & p->data);
- 格式字符串
"%d"
告诉scanf
函数p->data
是一个整数类型的数据。&p->data
是p->data
的地址,scanf
会将读取的整数值存储在p->data
的内存地址中。
尾插法O(n)
- 尾插法:始终让新结点在最后一个位置
- 从一个空表 L 开始,将新结点逐个插入到链表的尾部,尾指针 r 指向链表的尾结点
- 初始时,r 同 L 均指向头结点。每读入一个数据元素则申请一个新结点,将新结点插入到尾结点后,r 指向新结点
- 在内存中找到一块空间,作为头结点/* *L = (LinkLIst) malloc (sizeof (Lnode)) */
- 将头结点的指针域置空/* (*L)->next = NULL; */
- 头指针 L 和尾指针 r 一开始都指向头结点/* r = *L */
- 生成新结点 p /* p = (Lnode*)malloc(sizeof(Lnode)); */
- 给新结点插入数据/* scanf("%d", &p->data); */
- 将新结点的指针域置空/* p->next = NULL; */
- 把新结点接在尾结点的后面/* r->next = p; */
- 移动指针 r,让它指向这个新的尾结点,让新结点变成新的尾结点 /* r = p; */
- 循环:
- 生成一新结点 p,把插入的值放在新结点的数据域
- p = (Lnode*)malloc(sizeof(Lnode));
scanf(&p->data);
p->next = NULL;- 将新结点插入到链表中
r->next = p;
r = p;
void CreateListTail(LinkList* L, int n) { Lnode* p, * r; *L = (LinkList)malloc(sizeof(Lnode)); (*L)->next = NULL; /* 只有头结点的时候,尾指针也指向头结点 */ r = *L; // 将头指针赋值给尾指针 int i = 0; for (i = 0; i < n; ++i) { /* 生成新结点,为新结点分配内存,输入元素值 */ p = (Lnode*)malloc(sizeof(Lnode)); scanf("%d", &p->data); p->next = NULL; /* 将新结点接入到链表后面 */ r->next = p; // 插入到表尾 /* 将尾指针指向新结点,让新结点变成新的尾结点 */ r = p; } }
单链表结构与顺序存储结构的优缺点
顺序存储结构 | 顺序存储结构 | |
---|---|---|
存储方式 | 用一段连续的存储单元依次存储线性表的数据元素 | 用一组任意的存储单元存放线性表的元素 |
时间性能 | 查找:O(1) 插入和删除:O(n) | 查找:O(n) 插入和删除:在找出某位置的指针后,O(1) |
空间性能 | 需要预分配存储空间,分大了,浪费,分小了易发生上溢 | 不需要预分配存储空间,只要有就可以动态分配,元素个数不受限制 |
选择 | 频繁查找,很少插入和删除 | 频繁插入和删除 元素个数较大或者不确定 |