【数据结构取经之路】单链表

何为单链表及单链表的意义

单链表是一种链式存取的数据结构,用一组地址任意的存储单元存放线性表中的数据元素。链表中的数据是以结点来表示的,每个结点的构成:数据域 + 指针域,数据域就是存储数据的存储单元,指针域就是连接每个结点的地址数据。

 接着再来说说单链表的意义,也就是它的优势。单链表的优势是相对于和它同属于线性表的顺序表而言的。顺序表的特点是逻辑关系上相邻的两个元素在物理位置上(内存中的真实存储情况)也相邻 ,因此,可以通过下标的随机访问来取出任意元素。但也正因为顺序表物理空间上的连续,在作插入或删除操作时,需要移动大量元素。可以试想,在含有100个元素的顺序表中,要删除第一个元素,后面的99个元素都得往前挪,这将导致了效率降低。单链表的出现,弥补了这一缺点,因为单链表不要求逻辑上相邻的元素物理位置上也相邻,在删除指定元素时,直接free掉就好,但单链表也失去了顺序表可随机存取的优点。明白了单链表节点中包含的内容后,将其转成代码,如下:

typedef int STDataType;//类型重命名

typedef struct STNode
{
	STDataType data;     //数据域
	struct STNode* next; //指针域
}STNode;

单链表的头插

单链表的头插,这里将提供两种方法。第一种方法是:不带哨兵位的头;第二种是:带哨兵位的头。哨兵位的头就是一块不存放有效数据的空间,它的指针指向真正的头结点。这里很抽象,结合下面讲解的第二种方法更有助于理解。相较于带哨兵位的头结点,不带哨兵位的头结点更难理解,不过,在理解了不带哨兵位的头结点后,在学习带哨兵位的头结点时就是降维打击了。

不带哨兵位的头

直接用head指针指向头结点,并没有malloc一块空间出来当哨兵,这中做法就是不带哨兵位的头结点。下面就来进入真正的主题——单链表的头插。首先,最重要的一点就是抓住问题的本质,在插入数据时,要想方设法把链表给链接起来,这是关键。

> 第一步,malloc一个结点并存入数据

考虑到单链表的尾插模块也需要malloc出结点,因此,封装一个专门产生结点的函数是有必要的,这里将该函数命名为STBuyNode。该函数的内部细节如下:

STNode* STBuyNode(STDataType x)
{
	STNode* newnode = (STNode*)malloc(sizeof(STNode));
	if (newnode == NULL)
	{
		perror("malloc fail\n");
		return NULL;
	}

	newnode->data = x;    //插入数据
	newnode->next = NULL;

	return newnode;
}

> 第二步,也就是最关键的一步,链接。

链表为空时的头插很简单,让head指向newnode即可。如果不为空,结合下图,假设在B前插入A 。

第一步,newnode->next 指向 head; 第二步,把head移动到newnode的位置,指向新的头。 

代码如下:

//给出这段代码是为了能够看清传给STPushFront的参数
/*void TestList()
{
	STNode* plist = NULL;//初始化为空,plist为头指针

	STPushFront(&plist, 1);
	STPushFront(&plist, 2);
	STPushFront(&plist, 3);
	STPushFront(&plist, 4);
}

int main()
{
	TestList();

	return 0;
}*\

//STPushFront内部
void STPushFront(STNode** pphead, STDataType x)
{
	assert(pphead);

	STNode* newnode = STBuyNode(x);
	if (newnode == NULL)
		return;

	//链表为空
	if (*pphead == NULL)//初始化为空
	{
		*pphead = newnode;
	}
	//不为空
	else
	{
		newnode->next = *pphead;
		*pphead = newnode;
	}
}


可以看到,这里用到了二级指针。之所以用二级指针是因为除了链表为空时的插入,其余的头插都需要改变头指针plist, 使plist指向新的头,plist 的类型为 STNode*,所以要用二级指针STNode**才能改变plist(即*pphead)。下面还会继续解释这一点。

看了上面头插的代码,你可能会有很多疑问。下面将列出常见的几个疑问并逐个解答。

为什么写成STNode* plist,而不是STNode plist?也就是说,为什么创建结构体指针而不是结构体?

这一问题其实在上文已经提到了答案,STNode* plist 中,plist是结构体指针,只要将其指向单链表的头结点就可以唯一确定一个单链表,通过plist指针,可以访问到单链表的任意一个结点。而STNode plist中的plist,存不了头结点的地址,因此无法通过它找到单链表,写成STNode* plist也就顺理成章了。

为什么使用二级指针而不是一级指针? 

要回答清楚这一问题,需要一步步引入,篇幅比较长,还请读者耐心阅读。

首先,以交换int类型的a、b的值为引子。在调用交换函数Swap时,我们会这样写:Swap(&a, &b) ,传的是a和b的地址,a、b的地址类型为int*。如果这样写:Swap(a, b),显然是无法达到交换的目的的。因为形参是实参的一份临时拷贝,传值时,对形参的修改不会影响实参。拿不到a和b的地址就无法改变a、b的值。举这一例子是为了说明并达成第一个共识:在主函数内调用其他函数时,要想在被调用函数内改变主函数中变量的值,就要传变量的地址。int类型的变量,需要int*才能改变。char类型的变量,需要char*才能改变。要想改变STNode* plist中的plist,就要用STNode**类型的指针才能实现。我们需要达成的第二个共识是:在每一次头插时,都需要改变plist指针,使其指向新的头结点。所以,在函数STPushFront的参数中使用二级指针也就理所当然了。再来看看,如果使用的是一级指针,也就是说头插函数写成这样子:void STPushFront(STNode* phead, STDataType x)。下面来分析为什么写成这样不行。这里用到了函数栈帧的创建与销毁的知识。如果此处用一级指针,则代码如下:

//给出这段代码是为了能够看清传给STPushFront的参数
/*void TestList()
{
	STNode* plist = NULL;//初始化为空,plist为头指针

	STPushFront(&plist, 1);
	STPushFront(&plist, 2);
	STPushFront(&plist, 3);
	STPushFront(&plist, 4);
}

int main()
{
	TestList();

	return 0;
}*\

//STPushFront内部
void STPushFront(STNode* phead, STDataType x)
{
	STNode* newnode = STBuyNode(x);
	if (newnode == NULL)
		return;

	//链表为空
	if (phead == NULL)//初始化为空
	{
		phead = newnode;
	}
	//不为空
	else
	{
		newnode->next = phead;
		phead = newnode;
	}
}

 

将一级指针plist传给phead,本质上就是把plist中的内容拷贝一份给phead,假设plist中存储了头结点的地址0x12FFA0,那么phead中也将会存储头结点的地址0x12FFA0,这时候,这两个指针同时指向单链表的头。接着头插新的结点newnode,然后链接并将phead里的值改为新的头结点newnode的地址0x12FFB0,这时,phead将指向newnode,但是并不会使plist也指向newnode,所以此函数用了二级指针。如果非要用一级指针不可,那也不是没有办法。其中一个办法是返回phead,然后将返回值手动赋值给plist,确保plist始终指向链表的头,请看代码:

//给出这段代码是为了能够看清传给STPushFront的参数
/*void TestList()
{
	STNode* plist = NULL;//初始化为空,plist为头指针

	STNode* ret = STPushFront(plist, 1);
    plist = ret;                            //手动赋值

	ret = STPushFront(plist, 2);
    plist = ret;

	ret = STPushFront(plist, 3);
    plist = ret;
 
	ret = STPushFront(plist, 4);
    plist = ret; 
}

int main()
{
	TestList();

	return 0;
}*\

//STPushFront内部
STNode* STPushFront(STNode* phead, STDataType x)  //修改了返回类型
{
	STNode* newnode = STBuyNode(x);
	if (newnode == NULL)
		return;

	//链表为空
	if (phead == NULL)//初始化为空
	{
		phead = newnode;
	}
	//不为空
	else
	{
		newnode->next = phead;
		phead = newnode;
	}
    
    return phead;         //返回phead
}

不用二级指针的第二种方法就是下面要讲的带哨兵位的头。

带哨兵位的头

 这就是带哨兵位的头的单链表。哨兵位的头结点中不存放有效数据,它指向的下一个结点才是真正的头结点。哨兵位的头结点的存在,可以在头插时很好理解。同时呢,在每一次头插时,需要改变的是哨兵位头结点里的成员next,也就是要改变结构体,用结构体指针,也就是一级指针即可。头插的过程如下: 

代码如下:

//给出这段代码是为了能够看清传给STPushFront的参数
/*void TestList()
{
	STNode* plist = STBuyNode(-1);  //plist为哨兵位的头

	STPushFront(plist, 1);
	STPushFront(plist, 2);
	STPushFront(plist, 3);
	STPushFront(plist, 4);
}

int main()
{
	TestList();

	return 0;
}*\

//STPushFront内部
void STPushFront(STNode* phead, STDataType x)
{
	STNode* newnode = STBuyNode(x);
	if (newnode == NULL)
		return;

	newnode->next = phead->next;
    phead->next = newnode;
}

 通过对比这两种写法,显然带哨兵位的头写起来更简洁也更容易让人理解,但在一些刷题网站,比如力扣上,默认是不带哨兵位的头结点的,还有一点,就是理解了不带哨兵位的头结点后,带哨兵位的头结点的方法将会很好理解。

声明:以下功能的实现都是基于不带哨兵位的头结点的单链表

单链表的尾插

单链表的尾插很关键的一点是找到单链表的尾结点,然后在插入。可以结合上面单链表的图示想想,单链表的尾结点的特点是什么呢?通过观察可以发现:单链表的尾结点的next为NULL。找到这一特点以后,问题就很好解决了。下面请看代码:

//给出这段代码是为了看清传的参数
/*void Testlist()
{
	STNode* plist = NULL;

	STPushBack(&plist, 1);
	STPushBack(&plist, 2);
	STPushBack(&plist, 3);
	STPushBack(&plist, 4);

	STPrint(plist);

}*\

void STPushBack(STNode** pphead, STDataType x)
{
	assert(pphead);

	STNode* newnode = STBuyNode(x);
	if (newnode == NULL)
		return;

	if (*pphead == NULL)
	{
		*pphead = newnode;
	}
	else
	{
		STNode* tail = *pphead;
		while (tail->next)
		{
			tail = tail->next;
		}

		tail->next = newnode; 
	}
}

在插入的时候,要分两种情况:第一,单链表为空;第二,单链表不为空。当单链表为空时,直接将头指针指向newnode即可。当单链表不为空时,才需要找尾。

单链表的头删

可以想像到,每次头删时都要改变头指针的指向,所以用二级指针是无疑的。头删的步骤如下:

> 保存当前头结点

> 将当前头结点更新为它的下一个结点next,更新完头结点后,free掉原来的头结点

请看代码:

//给出这一段代码是为了看清函数的参数
/*void Testlist()
{
	STNode* plist = NULL;

	STPushBack(&plist, 1);
	STPushBack(&plist, 2);
	STPushBack(&plist, 3);
	STPushBack(&plist, 4);

	STPrint(plist);

	STPopFront(&plist);
	STPopFront(&plist);
	STPopFront(&plist);
	STPopFront(&plist);

	STPrint(plist);
}*\

void STPopFront(STNode** pphead)
{
	assert(pphead);
	assert(!STEmpty(pphead));

	STNode* del = *pphead;
	*pphead = del->next;
	free(del);
}

单链表的尾删

要删除尾结点,找到尾结点是必需的。尾结点的特点是next结点为空,根据这一特点,我们很容易找到尾结点。请看代码吧!

//给出这一段代码是为了看清函数的参数
/*void Testlist()
{
	STNode* plist = NULL;


	STPushBack(&plist, 3);
	STPushBack(&plist, 4);

	STPrint(plist);

    STPopBack(&plist);

	STPrint(plist);
}*\
void STPopBack(STNode** pphead)
{
	assert(pphead);
	assert(!STEmpty(pphead));

	STNode* tail = *pphead;

	while (tail->next)
	{
		tail = tail->next;
	}

	free(tail);
}

如果这么写,一跑起来就会出问题!为什么呢?请看图:

尾结点即为上图的C结点,注意看,当free掉C结点后,B结点中的 next 还指向C结点,但是这块空间已经不属于该程序了,也就是说B结点中的next指针为野指针,此时已经导致了非法访问,编译器自然会报错。解决办法就是:将尾指针的上一个结点中的next置空。因为这是单链表,所以无法直接通过尾结点找到它的上一个节点。我们可以这样:当tail->next->next == NULL,tail此时就是尾结点的上一个结点,删掉尾结点后,tail就是新的尾结点。但是这么做的话,必须要保证至少有两个结点。所以,分为两种情况,第一,只有一个结点;第二,有多个节点。如果没有结点,也就是说链表为空,那么过不了断言,不用担心。

正确的尾删代码:

//给出这一段代码是为了看清函数的参数
/*void Testlist()
{
	STNode* plist = NULL;


	STPushBack(&plist, 3);
	STPushBack(&plist, 4);

	STPrint(plist);

    STPopBack(&plist);

	STPrint(plist);
}*\

void STPopBack(STNode** pphead)
{
	assert(pphead);
	assert(!STEmpty(pphead));

	STNode* tail = *pphead;

	if (tail->next == NULL)
	{
		free(tail);
		tail = NULL;
	}
	else
	{
		while (tail->next->next)
		{
			tail = tail->next;
		}

		free(tail->next);
		tail->next = NULL;
	}
}

单链表的查找

单链表的查找就是要遍历单链表,查找常常伴随着修改,请看代码:

//给出这一段代码是为了看清函数的参数
/*void Testlist()
{
	STNode* plist = NULL;


	STPushBack(&plist, 3);
	STPushBack(&plist, 4);

	STPrint(plist);

    STNode* pos = STFind(&plist, 3);
	if (pos != NULL)
	{
		pos->data = 999;
		STPrint(plist);
	}
}*\

STNode* STFind(STNode** pphead, STDataType x)
{
	assert(pphead);
	
	if (*pphead == NULL)
		return NULL;
	else
	{
		STNode* cur = *pphead;
		while (cur)
		{
			if (cur->data == x)
				return cur;

			cur = cur->next;
		}

		return NULL;
	}
}

 

单链表在pos结点之前插入

要实现在pos位置前插入新的结点,就需要找到pos前的结点prev,从而可以使prev结点和新结点链接起来。但是。这时单链表,不能往回找,所以只能遍历链表找到prev结点,prev结点的特点是prev->next == pos.请看代码:

//给出这段代码是为了看清函数的参数
/*void Testlist()
{
	STNode* plist = NULL;

	STPushFront(&plist, 1);
	STPushFront(&plist, 2);
	STPrint(plist);

	STNode* pos = STFind(&plist, 1);
	if (pos != NULL) STInsertBefor(&plist, pos, 999);
	STPrint(plist);
}*\

void STInsertBefor(STNode** pphead, STNode* pos, STDataType x)
{
	assert(pphead);
	assert(pos);

	STNode* newnode = STBuyNode(x);
	STNode* prev = *pphead;

	while (prev->next != pos)
	{
		prev = prev->next;
	}

	newnode->next = pos;
	prev->next = newnode;
}

请仔细想想,当pos == *pphead时,这段代码会不会出问题?

 答案是肯定的。请看下面的分析:

 正确的代码如下:

void STInsertBefor(STNode** pphead, STNode* pos, STDataType x)
{
	assert(pphead);
	assert(pos);

	STNode* newnode = STBuyNode(x);

	if (pos == *pphead)
	{
		STPushFront(pphead, x);//调用头插函数
	}
	else
	{
		STNode* prev = *pphead;

		while (prev->next != pos)
		{
			prev = prev->next;
		}

		newnode->next = pos;
		prev->next = newnode;
	}
}

单链表删除pos结点

 例如,要删掉结点B。首先得找到B的前一个结点A,然后将结点A和结点C链接起来,最后释放掉B结点。还有一种情形:要删除的是头结点,遇到这种情况,可以调用头删函数,也可以参考下面的写法:

/*void Testlist()
{
	STNode* plist = NULL;

	STPushFront(&plist, 4);
	STPrint(plist);

	STNode* pos = STFind(&plist, 4);
	if (pos != NULL) STErase(&plist, pos);
	STPrint(plist);

}*\

void STErase(STNode** pphead, STNode* pos)
{
	assert(pphead);
	assert(!STEmpty(pphead));

	STNode* prev = *pphead;

	if (*pphead == pos)
	{
		*pphead = prev->next;
	}
	else
	{
		while (prev->next != pos) prev = prev->next;
		prev->next = pos->next;
	}

	free(pos);
}

单链表的销毁 

单链表的销毁直接释放掉malloc出来的结点就结束了。请看代码:

//给出这段代码是为了看清函数的参数
/*void Testlist()
{
	STNode* plist = NULL;

	STPushFront(&plist, 1);
	
	STPrint(plist);

	STDestroy(&plist);
}*\

void STDestroy(STNode** pphead)
{
	assert(pphead);
	assert(!STEmpty(pphead));

	STNode* cur = *pphead;
	while (cur)
	{
		STNode* next = cur->next;
		free(cur);
		cur = next;
	}

	*pphead = NULL;
}

  完!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值