线性表的链式存储结构-单链表

目录

单链表

 头指针和头结点

头结点的好处

头结点的数据域内装的是什么

链表(链式存储结构)的特点(顺序存取法)

结构代码分析

 代码中的 *next

LinkList L 和 Lnode *L的区别

单链表的定义

单链表基本操作的实现

 单链表的初始化

判断单链表是否为空

求单链表的表长

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

单链表的按值查找O(n)

单链表的插入O(n)

LinkList* L中的 * 有什么用

j 为什么等于 0

while (p && j < i - 1)的含义

if (!p || j > i - 1)

单链表的删除,删除第 i 个结点O(n)

p = *L 和 p = L的区别

单链表的清空

单链表的销毁

单链表的建立

头插法O(n)

为什么是 *L 而不是 L

为什么malloc()前的是(LinkList)而不是(LinkList*)

为什么是 (*L)->next,而 L->next 不行

关于scanf("%d", & p->data);

尾插法O(n)

单链表结构与顺序存储结构的优缺点


单链表

链式结构中,除了存储数据元素信息(数据域),还要存储后继元素的存储地址(指针域)。这两部分组成数据元素 aᵢ 的存储映像,也叫结点(Node),n个结点组成一个链表

单链表:链表的每个结点中只包含一个指针域


 头指针和头结点


头指针:链表中第一个结点的存储位置

线性链表的最后一个结点指针为“空”(通常用 NULL 或 ^ 符号表示)


头结点:头结点的指针域,存储指向第一个结点的指针


头指针和头结点的异同


头结点的好处

  1.  便于首元结点的处理。首元结点的地址保存在头结点的指针域中,所以在链表的第一个位置上的操作和其他位置一致,无须进行特殊处理

  2. 便于空表和发空表的同意处理。无论链表是否为空,头指针都是指向头结点的非空指针,因此空表和发空表的处理也统一了


头结点的数据域内装的是什么

头结点的数据域可以为空,也可存放线性表长度等附加信息,但此结点不能计入链表长度值



链表(链式存储结构)的特点顺序存取法)

  1.  结点在存储器中的位置是任意的,即逻辑上相邻的数据元素在物理上不一定相信
  2. 访问时只能通过头指针进入链表,并通过每个结点的指针域依次向后顺序扫描其余结点,所以寻找第一个结点和最后一个结点所花费的时间不等(找最后一个结点时要一个一个结点找过去,最后才能找到需要的结点)

注:

  • 顺序表是随机存取
  • 链表是顺序存取

结构代码分析

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

 代码中的 *next

 

在这里,*next是指向 Lnode 这种结构体类型的指针



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)的含义

这是一个循环的条件判断语句,用于遍历链表直到找到插入位置的前一个节点


  1. p

    • p 是一个指向链表结点的指针。
    • 这个条件检查 p 是否为 NULL。如果 p 不为 NULL,表示当前还结点可以遍历。
  2. 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。

遍历过程

  1. 第一次循环
    • p 指向头结点,j 为0。
    • 检查条件 p && j < i - 1p 不为 NULL 且 0 < 1(因为我们要插入到索引2的位置,所以 i = 2),条件为真。
    • 移动到下一个结点:p 指向结点1。
    • 增加索引:j 增加到1。
  2. 第二次循环
    • p 指向结点1,j 为1。
    • 检查条件 p && j < i - 1p 不为 NULL 且 1 < 1,条件为真。
    • 移动到下一个节点:p 指向结点2。
    • 增加索引:j 增加到2。
  3. 第三次循环
    • p 指向结点2,j 为2。
    • 检查条件 p && j < i - 1p 不为 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 是一个指向链表头指针的指针,*LL 在这个上下文中实际上指向同一个地址(即链表的头结点的地址)。因此,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)

  • 头插法(前插法):始终让新结点在第一个位置

    1. 从一个空表开始,重复读入数据

    2. 生成新结点,将读入数据存放到新结点的数据域中(让原来的头指针指向新结点,让新结点的指针域指向刚才在表头的结点)

    3. 从最后一个结点开始(最先插入的是第一个元素),依次将各结点插入到链表的前端


示意图:


  • 在内存中找到一块空间,作为头结点/* *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*)类型


  1. *L = (LinkList)malloc(sizeof(Lnode))

  2. *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->datap->data 的地址,scanf 会将读取的整数值存储在 p->data 的内存地址中。

尾插法O(n)

  • 尾插法:始终让新结点在最后一个位置
    1. 从一个空表 L 开始,将新结点逐个插入到链表的尾部,尾指针 r 指向链表的尾结点
    2. 初始时,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)
空间性能需要预分配存储空间,分大了,浪费,分小了易发生上溢不需要预分配存储空间,只要有就可以动态分配,元素个数不受限制
选择频繁查找,很少插入和删除频繁插入和删除
元素个数较大或者不确定

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值