C/C++数据结构:【动图演示】 单链表的结构介绍与实现

链表

链表的概念及结构

概念理解

链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

知识点:

  1. 链表逻辑上是连续的,物理上是不一定连续的;
  2. 链表的链接方式就是通过数据之间的指针链接实现的链表次序;
  3. 因为是非顺序的,没有下标指引,所以无法使用诸如二分查找、冒泡排序等查找方法。

结构理解

以单链表为例

image-20220207190801482

知识点:

  1. 从上图可看出,链式结构在逻辑上是连续的,但是在物理上不一定连续;
  2. 每个结点在存储每个结点值(data)的同时,还必须存储指示其后继结点(next)的地址信息;
  3. 现实中的结点一般都是从堆上申请出来的;
  4. 因为是从堆上申请的空间,而堆是按照一定的策略来分配的,所以两次申请的空间可能连续,也可能不连续。

链表的分类

1、单向或者双向

image-20220209145756546

2、带头或者不带头

image-20220209145841249

3、循环与非循环

image-20220401180304343

虽然有这么多的链表的结构,但是我们实际中最常用是一下两种结构:

image-20220209150003808

  1. 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
  2. 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了

单链表

定义:结点结构

┌───┬───┐

│data │next │

└───┴───┘

data域 --> 存放结点值的数据域

next域 --> 存放结点的直接后继的地址(位置)的指针域;

链表通过每个结点的指针域将链表的n个结点按其逻辑顺序链接在一起的,每个结点只有一个指针域的链表称为单链表(Single Linked List)。

单链表顾名思义——单向链表,单链表的运行方向只有一个,链表结点存储这当前结点的数据以及下一结点的地址,链表末尾处为NULL空指针。

参考代码

C语言

#pragma once
#include <stdio.h>
#include <stdlib.h>

typedef int SLTDataType; 

typedef struct SListNode 		// 单链表结点结构
{
    
    
	SLTDateType data;  			// 单链表数据信息
	struct SListNode* next;		// 当前结点的后继结点信息
}SLTNode;

void IniSLTNode()
{
    
    
    SLTNode* plist = NULL;      // 初始化单链表
    
}

Java

class ListNode {
    
    
    public int val;
    public ListNode next;

    public ListNode(int val) {
    
    
        this.val = val;
    }
}

public class MyLinkedList {
    
    

    public ListNode head;							//链表的头引用

    public void IniSLTNode() 
    {
    
    
        ListNode listNode1 = new ListNode(12);		// 链表插入12
        ListNode listNode2 = new ListNode(23);		// 链表插入23
        ListNode listNode3 = new ListNode(34);		// 链表插入34
        ListNode listNode4 = new ListNode(45);		// 链表插入45
        ListNode listNode5 = new ListNode(56);		// 链表插入56
        listNode1.next = listNode2;
        listNode2.next = listNode3;
        listNode3.next = listNode4;
        listNode4.next = listNode5;
        this.head = listNode1;
    }
}

输出:遍历输出链表

设计思路:

因为单链表的尾部指针默认为空(NULL),那么当我们需要遍历输出整个链表时只需要判断是否到达最后一个尾结点位置输出即可;在遍历时,输出完成后需要将遍历指针调整为下一个结点的指针,以此类推。

**时间复杂度:**O(N)

参考代码:

C语言

void SListPrint(SLTNode* phead)
{
    
    
	SLTNode* cur = phead;
	while (cur != NULL)
	{
    
    
		printf("%d->", cur->data);
		cur = cur->next;
	}
}

逻辑实现:

rId44

插入:尾插法

设计思路:

  1. 首先判断头结点是否为空,因为NULL空指针不能作为头结点;
  2. 寻找链表的尾结点,只有寻找到尾结点才放入数据;

因为单链表是前向单向遍历的,一个结点存储着结点值和下一结点的地址,所以在实现单链表的尾插操作时逻辑操作应该是:先判断链表内是否有数据,如果没有就将数据插入在头部;在插入时,通过头部遍历到尾部指针为NULL处,申请插入一个结点放入data,将新申请的结点后继指针位置置为空指针NULL。最后再将新申请到的结点地址连接到原结点尾部的指针域处,实现链表尾插。

**时间复杂度:**O(N)

参考代码

C语言

void SListPushBack(SLTNode** phead, SLTDataType x)
{
    
    
    // 新申请一个结点
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	newnode->data = x;
	newnode->next = NULL;

    // 如果头节点是空的,就直接将申请到的节点插入
	if (*phead == NULL)
	{
    
    
		*phead = newnode;
	}
	else  // 如果头节点非空,那就去寻找尾结点
	{
    
    
		SLTNode* tail = *phead;  
		while (tail->next != NULL)
		{
    
    
			tail = tail->next;
		}// 找到尾结点后
		tail->next = newnode; // 赋值新的尾结点; 
	}	
}

逻辑实现:

rId48

插入:头插法

设计思路:

  1. 申请新的结点信息,并将新申请的结点下一节点地址设置为当前链表的头结点;
  2. 将当前链表的头结点设置为申请到的结点信息。

*在尾插时,我们要考虑头结点是否为空指针,因为空指针不能作为头结点。而在头插时,即使头结点是NULL空指针,同样相当于把头结点的NULL转为尾部指针,所以在头插时可以不考虑头结点是否为空指针

**时间复杂度:**O(1)

参考代码:

C语言

void SListPushFront(SLTNode** phead, SLTDataType x)
{
    
    
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	newnode->data = x;
	newnode->next = *phead;
    
	*phead = newnode;
}

逻辑实现:

rId52

删除:尾删法

设计思路:

  1. 先判断链表的头结点是否为空,如果为空则不能尾删。
  2. 因为是单链表尾部的最后一个结点的下一指针地址为空指针,那么我们只需要遍历找到下一指针地址为空的结点。
  3. 找到尾部数据以后,将其地址置为空指针,并将其上一结点的链接指针置为空。

因为要在删除掉最后一个数据以后要将上一个数据的链接指针置为空,理想方案是:设置一个prev指针记录当前遍历指针的地址,当遍历指针temp走到尾并删除结点后,prev指针所在的位置就是被删除结点的上一结点的位置,此时只要将prev的链接指针指向NULL,即可完整实现链表删除操作。同时,这里需要注意的是:如果链表当前只有一个结点,就不需要将上一结点链接指针置空的操作

**时间复杂度:**O(N)

参考代码:

C语言

void SListPopBack(SLTNode** phead)
{
    
    
	if (*phead == NULL) 			// 判断单链表是否为空,如果为空无法进行尾插操作
		return;

	if ((*phead)->next == NULL)		// 如果单链表只有一个数据,只需要释放当前结点即可
	{
    
    
		free(*phead);
		*phead = NULL;
	} 								// 如果单链表长度 > 1
    else 
    {
    
    
        SLTNode* prev = NULL; 		// 记录指针
        SLTNode* temp = *phead;	// 遍历指针
        while (temp->next != NULL)	// 当遍历指针没有到达链表末尾
        {
    
    
            prev = temp;			// 在进入下一次遍历之前,遍历指针记录着遍历指针当前的位置
            temp = temp->next;		// 遍历指针进入下一步
        }

        free(temp);					
        temp = NULL;				// 找到尾结点后,释放尾结点并置为空指针
        prev->next = NULL;			// 记录指针存储着被删除结点的上一结点信息,此时将其结点的链接指针置为空完成尾删。
        
		/* 第二种写法,不需要prev记录指针,连续解引用。
		SLTNode* temp = *phead;
        while (temp->next->next != NULL)
        {
            temp = temp->next;
        }

        free(temp->next);
        temp->next = NULL;
        */
	}
}

逻辑实现:

rId56

删除:头删法

设计思路:

  1. 头删时依旧要考虑链表为空的情况。
  2. 在删除头结点的时候,需要把第二个结点定义成新的头结点,否则链表会崩溃。

**时间复杂度:**O(1)

参考代码:

C语言版本

void SListPopFront(SLTNode** phead)
{
    
    
	if (*phead == NULL)					// 考虑链表是否为空
		return;

	SLTNode* next = (*phead)->next;		// 将新的头结点改为第二个结点地址
	free(*phead);						
	*phead = next;						// 释放头结点
}

逻辑实现:

img

查找:按值查找链表

设计思路:

  1. 查找同打印链表思路一致,在没有找到首元素时只需要一直遍历下去就行。
  2. 找到首元素返回当前结点,走到尾部后仍没找到元素返回NULL

**时间复杂度:**O(N)

参考代码:

C语言

SLTNode* SListFindElem(SLTNode* phead, SLTDataType x)
{
    
    
	SLTNode* cur = phead;		// 遍历指针cur
	while (cur)					// 只要指针非空
	{
    
    
		if (cur->data == x)		// 找到数据data
			return cur;			// 找到返回结点信息
		else
			cur = cur->next;	// 没找到继续遍历
	}
	return NULL;				// 没找到返回空NULL
}

思考:

能不能改写成,返回结点的信息同时返回序列位置呢?或是找出链表内所有元素呢?

逻辑实现:

rId64

查找:按序列查找链表

设计思路:

  1. 单链表只需要从第一个结点开始出发,遍历指针逐个往下搜索;
  2. 遍历指针遍历直到第n个结点处结束;

**时间复杂度:**O(N)

参考代码:

C语言

SLTNode* SListGetIndexData(SLTNode* phead, int index)
{
    
    
    if (phead == NULL)
        return NULL;
    if (index == 0)
        return phead;

    int count = 1;
    SLTNode* cur = phead;
    // while(cur != NULL)
    while (cur && count < index)
    {
    
    
        cur = cur->next;
        count++;
        // if (count == index) break; 
    }
    return cur;
}

插入:在结点后插入元素(结点法)

结点法的意思是:传入的参数是链表的一个结点,只需要在这个结点后插入即可

设计思路:

  1. 先确定传进来的插入结点是否为空,为空退出,否则继续;
  2. 将新结点newnode的链接指针指向插入结点pos的链接指针地址,先连好后面;
  3. 再连新结点newnode的前面:将插入结点的链接指针指向新结点的地址。

**时间复杂度:**O(1)

参考代码:

void SListInsertAfterUsingElem(SLTNode* pos, SLTDataType x)
{
    
    
	if (pos == NULL)
        return;

	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    newnode->data = x;
    
	newnode->next = pos->next;
	pos->next = newnode;
}

插入:在结点后插入元素(序列法)

设计思路:

  1. 首先确定头结点是否为空,为空退出,否则继续;
  2. 使用遍历指针prev走到插入位置;
  3. 与结点法一致,将新结点链接指针指向遍历指针的链接指针地址;
  4. 再把遍历指针的链接指针指向新结点的地址;

**时间复杂度:**O(N)

参考代码:

C语言

void SListInsertAfterUsingIndex(SLTNode** phead, int index, SLTDateType x)
{
    
    
    if (index == 0 && phead == NULL)
        return;
    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    newnode->data = x;

    SLTNode* prev = *phead;
    for (int count = 0; count < index; count++)
    {
    
    
        prev = prev->next;
        if (prev == NULL)
        {
    
    
            printf("超出链表长度导致了插入失败\n");
            return;
        }
    }
    newnode->next = prev->next;
    prev->next = newnode;
}

插入:在结点前插入元素(结点法)

(向前插入要比向后插入复杂一些,但尽量要熟悉前插操作的思路,这对于后续的C++ STL库剖析非常有帮助)

如果需要在结点之前插入元素,需要改变上一结点的链接指为新节点,以及设置新结点链接指针为当前结点的地址。这句话是不是听着很熟悉?没错,与链表尾删法思路相似(现场考察尾删法!!)!但这里我们不需要删除元素,我们最主要做的就是替换上一结点的链接指针。

设计思路:

  1. 可以使用上一节的根据结点值查找元素,先查找出结点的信息;
  2. 使用遍历指针遍历到目标结点前的上一个位置,在此之后插入新节点并连接到原结点即可;
  3. 如果插入地址在头元素中,那只需要将新结点的链接指针设置为原有头元素的地址。

**时间复杂度:**O(N)

参考代码:

void SListInsertBeforeUseElem(SLTNode** phead, SLTNode* pos, SLTDateType x)
{
    
    
    if (phead == NULL)
        return;
    if (pos == NULL)			   		// 确定信息在链表中存在
        return;
    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    newnode->data = x;
	newnode->next = NULL;
    
	if (*pphead == pos)					// 如果插入元素的地址是头元素
	{
    
    
		newnode->next = *phead;
		*phead = newnode;
	}
	else
	{
    
    
		SLTNode* posPrev = *phead;		// 用来找到pos的前一个位置的遍历指针
		while (posPrev->next != pos)	// 找到pos的前一个位置
		{
    
    
			posPrev = posPrev->next;
		}

		posPrev->next = newnode;		// 将前一个位置的链接指针为新结点
		newnode->next = pos;			// 将新结点的链接指针为插入位置
	}
}


插入:在结点前插入元素(序列法)

设计思路:

  1. 与根据序列查找链表元素方法相似。当我们只需要走到前一个位置即可;
  2. 来到当前序列前一个位置后,与结点法插入操作一致;

**时间复杂度:**O(N)

参考代码:

void SListInsertBeforeUseIndex(SLTNode** phead, int index, SLTDateType x)
{
    
    

    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    newnode->data = x;
    newnode->next = NULL;

    if (index == 0)								// 如果插入元素的地址是头元素, 直接插入
    {
    
    
        newnode->next = *phead;
        *phead = newnode;
    }
    else
    {
    
    
        int count = 0;
        SLTNode* posPrev = *phead;				// 遍历指针

        SLTNode* prev = NULL; 					// 记录指针
        while (posPrev->next != NULL)
        {
    
    
            prev = posPrev;			  			// 在进入下一次遍历之前,记录指针会记录着遍历指针当前的位置
            posPrev = posPrev->next;			// 遍历指针进入下一步
            count++;
            if (count == index)
            {
    
    
                prev->next = newnode;			// 将前一个位置的链接指针的指向为新结点
            	newnode->next = posPrev;		// 将新结点的链接指针为插入位置
                return;
            }
        }
        printf("超出链表长度导致了插入失败\n");		// 如果未达到指定位置而已经遍历完成
        return;
    }
}

删除:删除链表指定结点(结点法)

设计思路:

  1. 如果删除的结点是头结点需要单独处理,因为是头删,可以直接调用之前的头删函数;
  2. 如果是非头节点,那删除逻辑是如何的呢?删除掉某个结点,需要找到其上一个结点并将连接指针指向删除结点的下一指针地址,再把需要删除结点的指针给free释放掉即可。

**时间复杂度:**O(N)

参考代码

void SListEraseUsingElem(SLTNode** phead, SLTNode* pos)
{
    
    
	if(phead == NULL || pos == NULL)	// 确定传入合法性
        return;

	if (*phead == pos)					// 头删
	{
    
    
		SListPopFront(phead);
	}
	else
	{
    
    
		SLTNode* prev = *phead;			// 遍历指针prev
		while (prev->next != pos)		
		{
    
    
			prev = prev->next;			// 找到删除结点的上一个结点位置
		}

		prev->next = pos->next;			// 将上一结点的链接指针 指向 删除结点的下一结点地址
		free(pos);						// 释放删除结点
		pos = NULL;						// 这里的pos可以置空也可以不置空,因为pos是一个形参,出了函数就会被栈帧销毁
	}
}

删除:删除链表指定结点(序列法)

设计思路:

  1. 同结点法的思路相似,只不过序列法需要使用循环去找到删除结点的前一个位置;
  2. 来到删除序列的前一个位置后,开始进行删除操作:将连接指针指向删除结点的下一指针地址,再把需要删除结点的指针给free释放掉即可。

**时间复杂度:**O(N)

参考代码:

void SListEraseUsingIndex(SLTNode** phead, int index)
{
    
    
    if (phead == NULL)								// 确定传入合法性
        return;

    if (index == 0)
    {
    
    
        SListPopFront(phead);
    }
    else
    {
    
    
        int count = 0;
        SLTNode* posPrev = *phead;				    // 遍历指针,等会它会指向删除结点

        SLTNode* prev = NULL; 					    // 记录指针,之后会指向删除结点的前一个位置
        while (posPrev->next != NULL)
        {
    
    
            prev = posPrev;			  			    // 在进入下一次遍历之前,记录指针会记录着遍历指针当前的位置
            posPrev = posPrev->next;			    // 遍历指针进入下一步
            count++;
            if (count == index)
            {
    
    
                prev->next = posPrev->next;			// 将前一个位置的链接指针指向删除结点的下一个位置
                free(posPrev);
                posPrev = NULL;						// 思考一下,这里的posPrev要不要置空呢?
                return;
            }
        }
        printf("超出链表长度导致了删除失败\n");      	 // 如果未达到指定位置而已经遍历完成
        return;
    }
}

删除:在结点后删除元素(结点法)

设计思路:

删除结点后的元素,需要获取到其往后数2个序列位置的结点的地址并将其链接上,随后释放到后面的结点,所以我们有以下方案:

  1. 得到传入结点后,先临时拷贝一份它的下一结点信息next(也就是被删除结点);
  2. 将原结点的下一指针地址指向next的下一结点地址;
  3. 释放被删除结点next。

**时间复杂度:**O(1)

参考代码

void SListEraseAfterUsingElem(SLTNode* pos)
{
    
    
    if (pos == NULL || pos -> next == NULL)		// 检查传入合法性
        return;

	SLTNode* next = pos->next;					// next为结点后的元素,这里先拷贝一份出来
	pos->next = next->next;						// 将结点指向删除元素的下一个结点
	free(next);									// 释放被删除的结点
    next = NULL;								// 同样,这里的next是否置空都可以,但出于职业素养我还是置空吧OWO~
}

删除:在结点后删除元素(序列法)

设计思路:

思路与结点法一致,而序列法只有序列位置信息,所以我们需要循环遍历到该位置上即可;

  1. 需要额外考虑如果删除序列位置超出链表长度时的处理。

时间复杂度:O(N)

参考代码

void SListEraseAfterUsingIndex(SLTNode** phead, int index)
{
    
    
    printf("%d: ", index);
    SLTNode* posPrev = *phead;
    for (int count = 0; count < index; count++)
    {
    
    
        posPrev = posPrev->next;
        if (posPrev == NULL)
        {
    
    
            printf("超出链表长度导致了删除失败\n");   // 如果已经到达尾部仍为到达序列位置时直接退出
            return;
        }
    }
    SListEraseAfterUsingElem(posPrev);			 // 因为遍历指针已经走到尾删的结点处,直接调用结点法偷个小懒OWO吧~
}

销毁:销毁链表

设计思路:

销毁链表同输出链表思路一致,一路遍历,一路释放空间;

  1. 设计一个记录指针,释放的同时也要记录下一个结点的地址;
  2. 释放某个结点后,循环继续进入下一结点并释放直至末尾处;

**时间复杂度:**O(N)

参考代码:

C语言

void SListDestory(SLTNode** pphead)
{
    
    
	if (phead == NULL)
        return;

	SLTNode* cur = *phead;
	while (cur)
	{
    
    
		SLTNode* next = cur->next;
		free(cur);
		cur = next;
	}
	*phead = NULL;
}

顺序表与链表的区别

顺序表:一白遮百丑
白:

  1. 空间连续;
  2. 支持随机访问。

丑:

  1. 中间或前面部分的插入删除时间复杂度O(N) ;
  2. 增容的代价比较大。

image-20220213012910545

链表:一(胖黑)毁所有
胖黑:

  1. 以节点为单位存储;
  2. 不支持随机访问。

所有:

  1. 任意位置插入删除时间复杂度为O(1)
  2. 没有增容问题,插入一个开辟一个空间。

image-20220213013018296

顺序表

优点

  1. 支持随机访问。需要随机访问结构支持算法可以很好的适用。
  2. cpu高速缓存利用率更高。(缓存利用率详情查看下面的扩展阅读)

缺点:

  1. 头部中部插入删除时间效率低;O(N)
  2. 连续的物理空间,空间不够了以后需要增容;
  3. 增容有一定程度程度消耗;
  4. 为了避免频繁增容,一般我们都按倍数去增,用不完可能存在一定的空间浪费。

链表

优点:

  1. 任意位置插入删除效率高。O(1);
  2. 按需申请释放空间。

缺点:

  1. 不支持随机访问。(用下标访问)意味着:一些排序,二分查找等在这种结构上不适用;
  2. 链表存储一个值,同时要存储链接指针,一定程度上也有一定的消耗;
  3. cpu高速缓存利用率更低。
不同点 顺序表 链表
存储空间上 物理上一定连续 逻辑上连续,但物理上不一定连 续
随机访问 支持O(1) 不支持:O(N)
任意位置插入或者删除元 素 可能需要搬移元素,效率低O(N) 只需修改指针指向
插入 动态顺序表,空间不够时需要扩 容 没有容量的概念
应用场景 元素高效存储+频繁访问 任意位置插入和删除频繁
缓存利用率

扩展阅读:什么是CPU高速缓存

可以浏览我往期CPU高速缓存的介绍:什么是CPU告诉缓存?

单链表完~散会!

总结

一个 单链表实现与介绍 笔记 结束啦!~因为相关的知识点不仅重要,还非常非常的多,所以篇幅较长!相信你一定能收获满满的。如果有疑问或者纰漏,欢迎在评论区内交流学习!

如果你觉得本系列文章对你用帮助,别忘了点赞关注作者哦!你的鼓励是我继续创作分享加更的动力!愿我们都能一起在顶峰相见。欢迎来到作者的公众号:“01编程小屋” 做客哦!关注小屋,学习编程不迷路!

猜你喜欢

转载自blog.csdn.net/weixin_43654363/article/details/124567659