数据结构:单链表

1.定义

单链表是一种离散存储的线性结构,和顺序表相比:本质区别在于存储方式 ------ 它不用整块连续内存,而是让每个数据单元(节点)通过指针 "链" 起来。简而言之,顺序表逻辑上相邻的元素必须在物理存储上也相邻,而链表逻辑上相邻的元素在物理存储上可以不相邻。 我们可以看到,链表的元素可以在内存中随意存放(前提是那块空内存至少可以容纳一个数据单元)。但是,之前的顺序表我们可以通过首元素的地址和序号找出每一个元素,链表中的物理顺序和逻辑顺序不同,我们不能直接根据逻辑顺序去找到元素。所以,我们在存放数据时必须加一个指针,这个指针指向该元素逻辑上的下一元素,数据加上指针就构成了单链表的最小数据单元------节点。 链表结构: 在链表中还有几个比较重要的概念:

  • 头指针:指向链表第一个节点
  • 头节点:为了方便处理数据额外增加的一个节点(数据域不存储数据,可以没有)
  • 首元节点:实际存储数据的第一个节点

现在展示有和没有头节点的两种情况: 添加头节点的优点:

  • 统一 "插入 / 删除" 的操作逻辑,不用特殊处理 "第一个位置"

没有头节点:如果链表是空的(head = NULL),插入第一个节点时,需要直接改head指针(head = 新节点);如果链表已有节点,插入到第 1 个位置时,又要改新节点的next指向原第一个节点,再改head------ 相当于要写两套逻辑,多一个 if 判断。 有头节点:不管链表是空(head->next = NULL)还是有数据,插入到第 1 个数据节点的位置时,都是 "让新节点的 next 指向头节点的 next,再让头节点的 next 指向新节点",不用动head指针,一套逻辑走到底。

  • 避免 "空链表" 时的空指针问题

没有头节点:head指针直接是NULL,如果不小心用head->next访问,就会触发空指针错误(程序崩溃)。 有头节点:head永远指向头节点(非 NULL),空链表时只是head->next = NULL,后续遍历或操作时,从head->next开始判断,天然避免了直接操作NULL指针的风险。


好了,现在我们了解了的单链表的结构,现在我们开始编写代码定义。 首先,链表是由一个一个节点构成的,我们先来定义节点:

c 复制代码
//节点定义
typedef struct LNode{
	ElemType data; 		//数据域,存储数据
	struct LNode *next;	//指针域,指向下一个节点
}LinkNode,*NodePtr;		//给结构体取别名,方便后续编写
						// LinkNode → 节点类型本身 ==struct LNode
						// NodePtr → 指向节点的指针类型 ==struct LNode*

节点定义完了,我们来定义单链表的管理结构:

c 复制代码
//单链表管理结构定义
typedef struct{
	NodePtr head; 	//头指针:指向头节点
	int length;		//链表长度:有效数据节点的数量(头节点不算)
}LinkList;

完整定义代码如下:

c 复制代码
#include <stdio.h>  //基本输入输出库
#include <stdlib.h> //内存操作相关库

//定义状态码,让后续函数返回更加直观
#define OK 1     	//操作成功
#define ERROR 0		//操作失败
#define OVERFLOW -1	//内存溢出
typedef int Status; //给int取个别名,int=Status,后续函数返回结果用Status,实际就是int

//定义元素类型
typedef int ElemType;//链表的元素类型,方便统一修改,int可以换成float,char等等

//节点定义
typedef struct LNode{
	ElemType data; 		//数据域,存储数据
	struct LNode *next;	//指针域,指向下一个节点
}LinkNode,*NodePtr;		//给结构体取别名,方便后续编写
						// LinkNode → 节点类型本身 ==struct LNode
						// NodePtr → 指向节点的指针类型 ==struct LNode*
						
//单链表管理结构定义
typedef struct{
	NodePtr head; 	//头指针:指向头节点
	int length;		//链表长度:有效数据节点的数量(头节点不算)
}LinkList;

2.初始化

单链表的初始化很简单,大致可以分为三步:

  • 创建一个新节点作为头节点,让头指针指向它
  • 把头节点的指针域置空(头节点的数据域我们不需要关心)
  • 把链表长度设为0
c 复制代码
//单链表初始化
Status InitList(LinkList *L){
	//1.创建头节点,为其分配内存
	L->head = (NodePtr)malloc(sizeof(LinkNode));
	//判断内存是否分配成功
	if(L->head==NULL)
		return OVERFLOW;
	//2.把头结点的指针域置空
	L->head->next = NULL;
	//3.把链表长度设为0
	L->length = 0;
	
	return OK;
}

测试代码:

c 复制代码
int main(){
	LinkList list;
	if(InitList(&list)){
		printf("链表初始化成功,当前长度:%d",list.length);
	}
}

3.插入元素

在单链表中插入元素,核心是 "找到前驱节点,修改指针指向"。 步骤如下:

  • 判断插入位置合法性:i必须满足 1 ≤ i ≤ 链表长度+1(比如长度为 3 的链表,可插入位置是 1~4)。
  • 创建新节点:为新节点分配内存,存入数据e。
  • 找到第i-1个节点(前驱节点):因为单链表只能通过前驱节点修改指针,带头节点的链表中,即使i=1,前驱节点也是头节点。
  • 修改指针:让新节点的next指向前驱节点的next,再让前驱节点的next指向新节点(避免断链)。
  • 更新链表长度:length + 1。
c 复制代码
//插入元素
//参数:L:链表管理结构指针;i:插入位置;e:插入的元素值
Status ListInsert(LinkList *L,int i,ElemType e){
	//1.判断插入位置是否合法
	if (i < 1 || i > L->length + 1) {
	        return ERROR; // 位置越界,插入失败
	    }
	    
	//2.创建新节点并分配内存
	NodePtr newNode = (NodePtr)malloc(sizeof(LinkNode));
	if(newNode == NULL)
		return OVERFLOW;
	newNode->data = e; // 新节点存入数据e  
	  
	//3.找到第i-1个节点(前驱节点)
	NodePtr pre = L->head; // 从头部开始找,pre初始指向头节点(i=1时,pre就是头节点)
	for (int j = 1; j < i; j++) { // 循环i-1次,移动到前驱节点
	        pre = pre->next;
	}
	
	//4.修改指针,插入新节点(顺序不能反)
	    newNode->next = pre->next; // 新节点的next指向前驱节点原本的next
	    pre->next = newNode;       // 前驱节点的next指向新节点
	
	// 5. 更新链表长度
	    L->length++;
	    
	    return OK;
}

测试插入:

c 复制代码
int main(){
	LinkList list;
	if(InitList(&list)){
		printf("链表初始化成功,当前长度:%d\n",list.length);
	}
	// 测试插入:在第1个位置插入10
	    if (ListInsert(&list, 1, 10)) {
	        printf("插入10成功,当前长度:%d\n", list.length); // 输出:长度1
	    }
	    // 在第2个位置插入20(此时链表长度1,可插入到2)
	    if (ListInsert(&list, 2, 20)) {
	        printf("插入20成功,当前长度:%d\n", list.length); // 输出:长度2
	    }
	    // 插入位置不合法(比如插入到第4个位置,当前长度2,最大可插3)
	    if (ListInsert(&list, 4, 30) == ERROR) {
	        printf("插入位置非法!\n");
	    }
	    
	    return 0;
}

插入元素的时间复杂度我们同样考虑最坏情况,插入到末尾,此时需要遍历n个元素,时间复杂度为O(n)

4.取值

取值逻辑:找到第i个有效数据节点,将其数据域的值存入指定变量中。

  • 判断i值是否合法
  • 找到第i个节点,并取出值
c 复制代码
//单链表取值
//参数:L:链表管理结构(无需修改,传值即可),i:取值位置;e:存储结果的指针
Status GetElem(LinkList L,int i,ElemType *e){
	 //1.判断位置是否合法
	    if (i < 1 || i > L.length) {
	        return ERROR; // 位置越界,取值失败
	    }
	    
	 //2.找到第i个数据节点(从第一个数据节点开始遍历)
	    NodePtr p = L.head->next; // p初始指向第一个数据节点(i=1时直接用)
	    for (int j = 1; j < i; j++) { // 循环i-1次,移动到第i个节点
	        p = p->next;
	    }
	    
	    // 3. 取出数据存入e
	    *e = p->data;
	    
		return OK;
}

测试:

c 复制代码
	ElemType e;
	// 测试取第1个元素
	if (GetElem(list, 1, &e)) {
		printf("第1个元素:%d\n", e); // 输出:10
	}
		    
	// 测试取第2个元素
	if (GetElem(list, 2, &e)) {
		printf("第2个元素:%d\n", e); // 输出:20
	}
		    
	// 测试取非法位置(第3个,当前长度2)
	if (GetElem(list, 3, &e) == ERROR) {
		printf("取值位置非法!\n");
	}

时间复杂度:最坏情况取最后一个元素的值,遍历n次,时间复杂度O(n)

5.查找元素

查找逻辑:从第一个数据节点(首元节点)开始遍历,逐个比较节点的data与目标值e,找到第一个匹配的节点后返回其位置索引;若遍历完所有节点都没找到,返回 0。

c 复制代码
//查找元素
//返回值为查找元素的位置索引,没找到返回0
int LocateElem(LinkList L,ElemType e){
	//1.空链表返回0
	if(L.length==0)
		return 0;
	
	//2.从第一个节点开始遍历
	NodePtr p = L.head->next;//首元结点
	int pos = 1; //记录索引
	
	//3.遍历所有节点比较元素值
	while(p != NULL){
		if(p->data == e)
			return pos;
	//未找到,继续遍历下一个节点
	p = p->next;
	pos++;
	}
	return 0;
}

查找元素的时间复杂度也为O(n)

6.删除节点

要删除单链表指定位置的元素,同插入元素一样,首先应该找到该位置的前驱节点,让前驱节点的指针域指向该节点的指针域,同时需要一个临时变量保存该节点的地址以便释放内存。

c 复制代码
//删除元素
Status ListDelete(LinkList *L,int i,ElemType *e){
	//1.判断i位置的合法性
	if(i<1 || i>L->length)
		return ERROR;
	
	//2.找到删除位置的前驱节点
	NodePtr pre = L->head;
	for(int j=1;j<i;j++){
		pre = pre->next;
	}
	
	//3.记录删除节点的地址
	NodePtr delNode = pre->next;
	
	//4.修改指针,跳过删除的节点
	pre->next = delNode->next;
	
	//5.获取被删除的元素值
	*e = delNode->data;
	
	//6.释放删除节点内存
	free(delNode);
	delNode = NULL; //避免野指针
	
	//7.更新链表长度
	L->length--;
	
	return OK;
}

时间复杂度O(n)

7.创建单链表

之前我们在初始化操作里就已经创建过单链表,但是这种方式创建的单链表是一个只有头节点的空表。 如果我们想要添加初始元素就必须使用ListInsert函数,但是我们希望可以在创建单链表时可以高效初始化多个节点。就是我们想在创建链表的同时就往里面添加一些初始节点。


为了实现这种高效创建单链表的函数,我们先来分析下单链表的结构。现在我们需求如下:

  • 初始化一个单链表
  • 在创建链表时同时往里面初始化一些节点

链表和顺序表不同,它是一种动态结构,它不会预先分配内存,所以要初始化这些节点需要:依次建立各元素节点并插入链表中(说到底就是初始化后一并把插入做了)。

在我们看来这些节点是创建链表时被同时加进了链表中,实际上这些节点是一个一个被加进去的。

我们按照每次插入位置的不同可以分为前插法和后插法。

(1)前插法

顾名思义,前插法每次都会把新创建的节点插入到最前面(头节点之后)。 我们可以看到,最先插入的节点被放在了最后面。所以,如果我们要想创建链表的元素和我们原本保持一致,就需要逆序输入。

c 复制代码
//前插法创建单链表
//n为初始化元素个数
Status CreateList_Head(LinkList *L,int n){ 
	//1.初始化链表
	InitList(L); 
	
	//2.依次插入元素
	for(int i=0;i<n;i++){
		//3.获取元素值
		ElemType e;
		printf("请输入第%d个插入的元素(前插法)",i+1);
		scanf("%d",&e);
		//4.创建节点
		NodePtr p = (NodePtr)malloc(sizeof(LinkNode));
		if(p==NULL)
			return OVERFLOW;
		//5.设置节点的值
		p->data = e;
		//6.插入到头节点之后
		p->next = L->head->next;
		L->head->next = p;
		//7.长度增加
		L->length++;
	} 
	return OK;
} 

(2)后插法

后插法每次会把元素插入到链表最后,链表中的顺序和输入一致。之前我们写过插入函数ListInsert(),如果要把元素插入到链表末尾,需要遍历所有元素。在这里我们可以创建一个尾指针,让它一直指向最后一个节点。

c 复制代码
//后插法创建单链表
Status CreateList_Tail(LinkList *L,int n){
	//1.初始化链表
	InitList(L);
	
	//2.创建尾指针
	NodePtr rear = L->head; //最开始头节点就是最后一个节点
	
	//3.依次插入元素
	for(int i=0;i<n;i++){
		//4.获取元素值
		ElemType e;
		printf("请输入第%d个插入的元素(后插法)",i+1);
		scanf("%d",&e);
		//5.创建并设置节点
		NodePtr p = (NodePtr)malloc(sizeof(NodePtr));
		if(p==NULL)
			return OVERFLOW;
		p->data = e;
		p->next = NULL; //每次插入都是最后一个节点
		//6.插入到最后
		rear->next = p; //原尾部的next指向新节点
		rear = p; 		//更新尾指针
	}
	return OK;
}

(3)前插法与后插法对比

后插法既保证了 "正序输入、正序存储" 的直观性,并且插入效率与前插法相当,是最常用的链表创建方式之一

8.销毁链表

由于我们使用malloc()函数,所以我们需要手动释放内存。

c 复制代码
// 销毁单链表:释放所有节点(包括头节点),使链表彻底失效
Status DestroyList(LinkList *L) {
    NodePtr p = L->head; // p初始指向头节点
    NodePtr q;           // 用于保存下一个节点的地址
    
    // 遍历所有节点,逐个释放
    while (p != NULL) {
        q = p->next; // 先记录下一个节点的地址(避免释放p后丢失)
        free(p);     // 释放当前节点
        p = q;       // p移动到下一个节点
    }
    
    // 链表状态置空
    L->head = NULL;  // 头指针置空,标记链表无效
    L->length = 0;   // 长度归零
}

9.附录

完整代码如下:

c 复制代码
#include <stdio.h>  //基本输入输出库
#include <stdlib.h> //内存操作相关库

//定义状态码,让后续函数返回更加直观
#define OK 1     	//操作成功
#define ERROR 0		//操作失败
#define OVERFLOW -1	//内存溢出
typedef int Status; //给int取个别名,int=Status,后续函数返回结果用Status,实际就是int

//定义元素类型
typedef int ElemType;//链表的元素类型,方便统一修改,int可以换成float,char等等

//节点定义
typedef struct LNode{
	ElemType data; 		//数据域,存储数据
	struct LNode *next;	//指针域,指向下一个节点
}LinkNode,*NodePtr;		//给结构体取别名,方便后续编写
						// LinkNode → 节点类型本身 ==struct LNode
						// NodePtr → 指向节点的指针类型 ==struct LNode*
						
//单链表管理结构定义
typedef struct{
	NodePtr head; 	//头指针:指向头节点
	int length;		//链表长度:有效数据节点的数量(头节点不算)
}LinkList;

//单链表初始化
Status InitList(LinkList *L){
	//1.创建头节点,为其分配内存
	L->head = (NodePtr)malloc(sizeof(LinkNode));
	//判断内存是否分配成功
	if(L->head==NULL)
		return OVERFLOW;
	//2.把头结点的指针域置空
	L->head->next = NULL;
	//3.把链表长度设为0
	L->length = 0;
	
	return OK;
}

//插入元素
//参数:L:链表管理结构指针;i:插入位置;e:插入的元素值
Status ListInsert(LinkList *L,int i,ElemType e){
	//1.判断插入位置是否合法
	if (i < 1 || i > L->length + 1) {
	        return ERROR; // 位置越界,插入失败
	    }
	    
	//2.创建新节点并分配内存
	NodePtr newNode = (NodePtr)malloc(sizeof(LinkNode));
	if(newNode == NULL)
		return OVERFLOW;
	newNode->data = e; // 新节点存入数据e  
	  
	//3.找到第i-1个节点(前驱节点)
	NodePtr pre = L->head; // 从头部开始找,pre初始指向头节点(i=1时,pre就是头节点)
	for (int j = 1; j < i; j++) { // 循环i-1次,移动到前驱节点
	        pre = pre->next;
	}
	
	//4.修改指针,插入新节点(顺序不能反)
	    newNode->next = pre->next; // 新节点的next指向前驱节点原本的next
	    pre->next = newNode;       // 前驱节点的next指向新节点
	
	// 5. 更新链表长度
	    L->length++;
	    
	    return OK;
}

//单链表取值
//参数:L:链表管理结构(无需修改,传值即可),i:取值位置;e:存储结果的指针
Status GetElem(LinkList L,int i,ElemType *e){
	 //1.判断位置是否合法
	    if (i < 1 || i > L.length) {
	        return ERROR; // 位置越界,取值失败
	    }
	    
	 //2.找到第i个数据节点(从第一个数据节点开始遍历)
	    NodePtr p = L.head->next; // p初始指向第一个数据节点(i=1时直接用)
	    for (int j = 1; j < i; j++) { // 循环i-1次,移动到第i个节点
	        p = p->next;
	    }
	    
	    // 3. 取出数据存入e
	    *e = p->data;
	    
		return OK;
}

//查找元素
//返回值为查找元素的位置索引,没找到返回0
int LocateElem(LinkList L,ElemType e){
	//1.空链表返回0
	if(L.length==0)
		return 0;
	
	//2.从第一个节点开始遍历
	NodePtr p = L.head->next;//首元结点
	int pos = 1; //记录索引
	
	//3.遍历所有节点比较元素值
	while(p != NULL){
		if(p->data == e)
			return pos;
	//未找到,继续遍历下一个节点
	p = p->next;
	pos++;
	}
	return 0;
	
}

//删除元素
Status ListDelete(LinkList *L,int i,ElemType *e){
	//1.判断i位置的合法性
	if(i<1 || i>L->length)
		return ERROR;
	
	//2.找到删除位置的前驱节点
	NodePtr pre = L->head;
	for(int j=1;j<i;j++){
		pre = pre->next;
	}
	
	//3.记录删除节点的地址
	NodePtr delNode = pre->next;
	
	//4.修改指针,跳过删除的节点
	pre->next = delNode->next;
	
	//5.获取被删除的元素值
	*e = delNode->data;
	
	//6.释放删除节点内存
	free(delNode);
	delNode = NULL; //避免野指针
	
	//7.更新链表长度
	L->length--;
	
	return OK;
}

//前插法创建单链表
//n为初始化元素个数
Status CreateList_Head(LinkList *L,int n){ 
	//1.初始化链表
	InitList(L); 
	
	//2.依次插入元素
	for(int i=0;i<n;i++){
		//3.获取元素值
		ElemType e;
		printf("请输入第%d个插入的元素(前插法)",i+1);
		scanf("%d",&e);
		//4.创建节点
		NodePtr p = (NodePtr)malloc(sizeof(LinkNode));
		if(p==NULL)
			return OVERFLOW;
		//5.设置节点的值
		p->data = e;
		//6.插入到头节点之后
		p->next = L->head->next;
		L->head->next = p;
		//7.长度增加
		L->length++;
	} 
	return OK;
} 

//后插法创建单链表
Status CreateList_Tail(LinkList *L,int n){
	//1.初始化链表
	InitList(L);
	
	//2.创建尾指针
	NodePtr rear = L->head; //最开始头节点就是最后一个节点
	
	//3.依次插入元素
	for(int i=0;i<n;i++){
		//4.获取元素值
		ElemType e;
		printf("请输入第%d个插入的元素(后插法)",i+1);
		scanf("%d",&e);
		//5.创建并设置节点
		NodePtr p = (NodePtr)malloc(sizeof(NodePtr));
		if(p==NULL)
			return OVERFLOW;
		p->data = e;
		p->next = NULL; //每次插入都是最后一个节点
		//6.插入到最后
		rear->next = p; //原尾部的next指向新节点
		rear = p; 		//更新尾指针
	}
	return OK;
}

// 销毁单链表:释放所有节点(包括头节点),使链表彻底失效
Status DestroyList(LinkList *L) {
    NodePtr p = L->head; // p初始指向头节点
    NodePtr q;           // 用于保存下一个节点的地址
    
    // 遍历所有节点,逐个释放
    while (p != NULL) {
        q = p->next; // 先记录下一个节点的地址(避免释放p后丢失)
        free(p);     // 释放当前节点
        p = q;       // p移动到下一个节点
    }
    
    // 链表状态置空
    L->head = NULL;  // 头指针置空,标记链表无效
    L->length = 0;   // 长度归零
}
int main(){
	LinkList list;
	if(InitList(&list)){
		printf("链表初始化成功,当前长度:%d\n",list.length);
	}
	// 测试插入:在第1个位置插入10
	if (ListInsert(&list, 1, 10)) {
	    printf("插入10成功,当前长度:%d\n", list.length); // 输出:长度1
	}
	// 在第2个位置插入20(此时链表长度1,可插入到2)
	if (ListInsert(&list, 2, 20)) {
	    printf("插入20成功,当前长度:%d\n", list.length); // 输出:长度2
	}
	// 插入位置不合法(比如插入到第4个位置,当前长度2,最大可插3)
	if (ListInsert(&list, 4, 30) == ERROR) {
	    printf("插入位置非法!\n");
	}    
	ElemType e;
	// 测试取第1个元素
	if (GetElem(list, 1, &e)) {
		printf("第1个元素:%d\n", e); // 输出:10
	}
		    
	// 测试取第2个元素
	if (GetElem(list, 2, &e)) {
		printf("第2个元素:%d\n", e); // 输出:20
	}
		    
	// 测试取非法位置(第3个,当前长度2)
	if (GetElem(list, 3, &e) == ERROR) {
		printf("取值位置非法!\n");
	}
		  
	 // 查找存在的元素(10)
	int pos = LocateElem(list, 10);
	if (pos != 0) {
	    printf("元素10第一次出现的位置:%d\n", pos); // 输出:1(第一个10在位置1)
	} 
	//测试删除第2个节点(值为20)
	if (ListDelete(&list, 2, &e) == OK) {
	    printf("删除成功,被删除元素:%d,当前长度:%d\n", e, list.length); // 输出:20,长度1
	}
	    
	DestroyList(&list);
	
	return 0;
}
相关推荐
perseveranceX7 小时前
插入排序:扑克牌式的排序算法!
c语言·数据结构·插入排序·时间复杂度·排序稳定性
CS创新实验室7 小时前
典型算法题解:长度最小的子数组
数据结构·c++·算法·考研408
Ialand~10 小时前
深度解析 Rust 的数据结构:标准库与社区生态
开发语言·数据结构·rust
Yupureki12 小时前
从零开始的C++学习生活 18:C语言复习课(期末速通)
c语言·数据结构·c++·学习·visual studio
小兔崽子去哪了12 小时前
数据结构和算法(Python)
数据结构·python
FmZero15 小时前
基于比特位图映射对List<Object>多维度排序
数据结构·list
Mr.H012715 小时前
克鲁斯卡尔(Kruskal)算法
数据结构·算法·图论
熬了夜的程序员15 小时前
【LeetCode】94. 二叉树的中序遍历
数据结构·算法·leetcode·职场和发展·深度优先
熬了夜的程序员15 小时前
【LeetCode】92. 反转链表 II
数据结构·算法·leetcode·链表·职场和发展·排序算法