(三)算法与数据结构 | 单链表


简介

线性表是具有相同特性的元素的一个有限序列。线性表的存储结构由顺序存储结构和链式存储结构,前者称为顺序表(有时也成为数组),后者称为链表。顺序表和链表的比较:

顺序表 链表
随机访问特性 不支持随机访问
占用连续的存储空间 动态分配存储空间
每个结点只存储值 结点除了存储值外,还需要存储下一结点的索引

(1)查找:对于按值查找,序列无序时,二者的时间复杂度均为 O ( n ) {\rm O(n)} ;序列有序时,顺序表的时间复杂度可以优化为 O ( l o g 2 n ) {\rm O(log_2n)} 。对于按序号查找,顺序表的平均时间复杂度为 O ( 1 ) {\rm O(1)} ,链表的平均时间复杂度为 O ( n ) {\rm O(n)} 。——排序算法总结
(2)插入、删除:顺序表的插入和删除平均需要移动半个表的元素,而链表的插入和删除只需修改相关结点的指针即可。
本文介绍链表当中最基本的单链表,其形式如下:
在这里插入图片描述

图1:单链表

:除特殊说明,本文单链表均为不带头结点的单链表。


0. 结构体定义

struct ListNode {
	int val;
	ListNode* next;
	ListNode(int x) : val(x), next(NULL) {}
};

使用方法:ListNode* T = new ListNode(3)语句即可声明一个值( v a l {\rm val} )为3的单链表且T->next = NULL。


1. 建立链表

单链表的建立分为头插法和尾插法两种。头插法从一个空表开始,将读取到的数据放到新结点中,并将新结点插入到当前链表的表头,如图:
在这里插入图片描述

图2:头插法

每次插入结点时,在首结点前面依次插入元素。下面是单链表头插法的 C {\rm C} ++代码:

void createLinkList(ListNode* &head) {
	int ch;
	int flag = true;
	ListNode* temp;
	while (cin >> ch)
	{
		if (flag) {
			head = new ListNode(ch);
			head->next = NULL;
			flag = false;
		}
		else
		{
			if (ch != -1) {
				temp = new ListNode(ch);
				temp->next = head;
				head = temp;
			}
			else
			{
				break;
			}
		}
	}
}

头插法建立单链表时,初始结点需要单独处理,代码中定义了一个 f l a g {\rm flag} 用于标识是否为第一个结点。 t e m p {\rm temp} 待插入的新结点, h e a d {\rm head} 始终指向单链表的首结点。输入- 1 {\rm 1} 时,表示单链表创建完成。同时,头插法建立的单链表与输入元素成逆序关系。如输入为 1   2   3 {\rm 1\ 2\ 3} - 1 {\rm 1} ,则建立的单链表为 3 2 1 N U L L {\rm 3→2→1→NULL}

尾插法建立单链表每个将新结点插入到当前链表的表尾上。如图:

在这里插入图片描述

图3:尾插法

每次插入结点时,在尾结点后面依次插入元素。下面是单链表尾插法的 C {\rm C} ++代码:

void createLinkList(ListNode* &head) {
	int ch;
	int flag = true;
	ListNode* temp = NULL;
	while (cin >> ch)
	{
		if (flag) {
			head = new ListNode(ch);
			temp = head;
			flag = false;
		}
		else
		{
			if (ch != -1)
			{
				ListNode* listNode = new ListNode(ch);
				temp->next = listNode;
				temp = temp->next;
			}
			else
			{
				break;
			}
		}
	}
}

尾插法建立单链表时,初始结点需要单独处理,代码中定义了一个 f l a g {\rm flag} 用于标识是否为第一个结点。 t e m p {\rm temp} 待插入的新结点, t e m p {\rm temp} 始终指向单链表的尾结点。输入- 1 {\rm 1} 时,表示单链表创建完成。同时,尾插法建立的单链表与输入元素成顺序关系。如输入为 1   2   3 {\rm 1\ 2\ 3} - 1 {\rm 1} ,则建立的单链表为 1 2 3 N U L L {\rm 1→2→3→NULL}


2. 遍历链表

顺序遍历单链表采用迭代方法,逆序遍历单链表采用递归方法。

顺序遍历 C {\rm C} ++代码:

void visitLinkList(ListNode* &head) {
	if (head == NULL) {
		return;
	}
	else
	{
		while (head != NULL)
		{
			cout << head->val;
			head = head->next;
		}
	}
}

逆序遍历 C {\rm C} ++代码:

void visitLinkList(ListNode* &head) {
	if (head == NULL) {
		return;
	}
	else
	{
		visitLinkList(head->next);
		cout << head->val;
	}
}

3. 翻转链表

翻转链表即将链表的值逆序,这里采用迭代和递归的方法翻转链表。 C {\rm C} ++代码:

C {\rm C} ++代码一:

ListNode* reverseLinkList(ListNode* &head) {
	if (head == NULL) {
		return head;
	}
	ListNode *temp = head->next;
	head->next = NULL;
	ListNode *t;
	while (temp)
	{
		t = temp->next;
		temp->next = head;
		head = temp;
		temp = t;
	}
	return head;
}

迭代法翻转链表时,首先将首结点取下,然后将剩余结点依次按照头插法依次插入即可完成翻转链表。

C {\rm C} ++代码二:

ListNode* reverseLinkList(ListNode* &head) {
	if (head == NULL || head->next == NULL) {
		return head;
	}
	else
	{
		ListNode* temp = reverseLinkList(head->next);
		head->next->next = head;
		head->next = NULL;
		return temp;
	}
}

递归法翻转链表时,首先遍历到链表尾,然后根据递归的性质从后往前依次改变结点的 n e x t {\rm next} 指针,最后返回 t e m p {\rm temp} 为首结点的链表。


4. 插入、删除结点

插入

假如插入函数的功能是:在一个值有序的单链表中插入元素,使插入后的链表仍然有序。

C {\rm C} ++代码:

void insertLinkList(ListNode* &head, int val) {
	if (head == NULL) {
		ListNode* head = new ListNode(val);
		return;
	}
	else
	{
		ListNode* pre = head;
		ListNode* temp = pre->next;
		while (temp && val > temp->val) {
			pre = pre->next;
			temp = temp->next;
		}
		ListNode* L = new ListNode(val);
		L->next = pre->next;
		pre->next = L;
	}
}

在单链表中插入结点时,首先定义 t e m p {\rm temp} 指针遍历单链表,同时设置其前驱指针 p r e {\rm pre} w h i l e {\rm while} 循环用于寻找合适的插入位置,将结点插入到 p r e {\rm pre} t e m p {\rm temp} 之间。然后执行如下插入操作:
在这里插入图片描述

图3:插入结点

删除

假设删除函数功能为:删除值为 v a l {\rm val} 的结点(假设该值总是存在)。

C {\rm C} ++代码:

void deleteLinkList(ListNode* &head, int val) {
	if (head == NULL) {
		return;
	}
	else
	{
		ListNode* pre = head;
		ListNode* temp = pre->next;
		while (temp && val != temp->val)
		{
			pre = pre->next;
			temp = temp->next;
		}
		pre->next = temp->next;
		free(temp);
	}
}

在单链表中删除结点时,首先定义 t e m p {\rm temp} 指针遍历单链表,同时设置其前驱指针 p r e {\rm pre} w h i l e {\rm while} 循环用于寻找合适的删除位置,将结点 t e m p {\rm temp} 删除。然后执行如下删除操作:
在这里插入图片描述

图4:删除结点


5. 合并链表

输入两个有序链表 A {\rm A} B {\rm B} (假设两者均不为空),合并后的结果为以 C {\rm C} 为首结点的链表。

C {\rm C} ++代码:

void mergeLinkList(ListNode* &A, ListNode* &B, ListNode* &C) {
	ListNode* p = A;
	ListNode* q = B;
	if (p -> val >= q->val) {
		C = B;
		q = q->next;
	}
	else
	{
		C = A;
		p = p->next;
	}
	C->next = NULL;
	ListNode* r = C;
	while (p && q)
	{
		if (p->val <= q->val) {
			r->next = p;
			p = p->next;
			r = r->next;
		}
		else
		{
			r->next = q;
			q = q->next;
			r = r->next;
		}
	}
	if (q) {
		r->next = q;
	}
	if (p) {
		r->next = p;
	}
}

首先将 C {\rm C} 指向值较小的结点,定义一个指针 r {\rm r} 用于接收结点。然后遍历两个链表,依次将较小值插入 r {\rm r} 的末尾。最后,将非空的内容连接在 r {\rm r} 后面。如果要将结果保存为非递增的顺序,改动地方为:(1) i f {\rm if} 的判断条件;(2)最后非空的内容连接方式。


6. 拆分链表

设存在单链表 A = { a 1 , b 1 , a 2 , . . . , a n , b n } {\rm A}=\{{\rm a_1,b_1,a_2,...,a_n,b_n}\} ,拆分函数的功能是将 A {\rm A} 拆分为两个链表 B {\rm B} C {\rm C} ,其中满足 B = { a 1 , a 2 , . . . , a n } {\rm B}=\{{\rm a_1,a_2,...,a_n}\} C = { b n , . . . , b 2 , b 1 } {\rm C}=\{{\rm b_n,...,b_2,b_1}\}

依次将链表 A {\rm A} 上的结点接在 B {\rm B} C {\rm C} 上即可,其中需要注意 B {\rm B} C {\rm C} 中元素的顺序。则在生成 B {\rm B} 时采用尾插法,在生成 C {\rm C} 时采用头插法。这里假设 A {\rm A} 不为空。

C {\rm C} ++代码:

void splitLinkList(ListNode* &A, ListNode* &B, ListNode* &C) {
	ListNode* ra = A;
	ListNode* rb = NULL;
	ListNode* pc = NULL;
	ListNode* t;
	int flag = true;
	while (ra)
	{
		if (flag) {
			rb = ra;
			ra = ra->next;
			if (ra == NULL) {
				break;
			}
			pc = ra;
			ra = ra->next;
			pc->next = NULL;		
			flag = false;
			B = rb;
			continue;
		}
		rb->next = ra;
		rb = ra;
		ra = ra->next;
		if (ra == NULL) {
			break;
		}
		t = ra->next;
		ra->next = pc;
		pc = ra;
		ra = t;
	}
	rb->next = NULL;
	C = pc;
}

定义结点 r a {\rm ra} r b {\rm rb} p c {\rm pc} 分别处理链表 A {\rm A} B {\rm B} C {\rm C} ,其中结点 t {\rm t} 是头插法的辅助结点。定义标志 f l a g {\rm flag} 单独处理 A {\rm A} B {\rm B} 的首结点。


7. 链表的其他操作

(一)删除有序链表的重复元素
现在假设一个单链表存储的内容是一个有序且含有重复值的序列,该函数实现删除重复元素的功能。例如:原链表为 1 2 3 3 3 4 4 7 7 9 9 N U L L {\rm 1→2→3→3→3→4→4→7→7→9→9→NULL} ,删除后的链表为 1 2 3 4 7 9 N U L L {\rm 1→2→3→4→7→9→NULL}

C {\rm C} ++代码一:

void deleteRepeatedLinkList(ListNode* &head) {
	if (head) {
		ListNode* q;
		ListNode* p = head;
		while (p ->next != NULL)
		{
			if (p->val == p->next->val) {
				q = p->next;
				p->next = p->next->next;
				free(q);
			}
			else
			{
				p = p->next;
			}
		}
	}
}

代码一思想比较常规,直接遍历链表。如果当前结点的值等于下一个结点的值,则改变指针并释放重复结点。

针对序列 1 2 3 3 3 4 4 7 7 9 9 N U L L {\rm 1→2→3→3→3→4→4→7→7→9→9→NULL} ,可以将非重复的元素不断往前移动,得到结果 1 2 3 4 7 9 4 7 7 9 9 N U L L {\rm 1→2→3→4→7→9→4→7→7→9→9→NULL} ,然后将后续结点释放,得到 1 2 3 4 7 9 N U L L {\rm 1→2→3→4→7→9→NULL}

C {\rm C} ++代码二:

void deleteRepeatedLinkList_(ListNode* &head) {
	if (head) {
		ListNode* r;
		ListNode* p = head;
		ListNode* q = p->next;
		while (q)
		{
			while (q && p->val == q->val)
			{
				q = q->next;
			}
			if (q) {
				p = p->next;
				p->val = q->val;
			}
		}
		q = p->next;
		p->next = NULL;
		while (q)
		{
			r = q;
			q = q->next;
			free(r);
		}
	}
}

最外层 w h i l e {\rm while} 用于遍历链表,里层第一个 w h i l e {\rm while} 循环用于寻找使得 q {\rm q} 指向结点的值不等于 p {\rm p} 指向结点的值。算法过程中,指针 p {\rm p} 始终指向链表的尾。最后,使用辅助结点 r {\rm r} 释放掉重复的元素。

(二)链表的直接插入排序算法
单链表由于其数据结构的特点,不适合做大规模的移动类和交换类的排序算法。这里仅给出单链表的直接插入排序算法:给定一个序列 4 2 1 5 7 6 9 N U L L {\rm 4→2→1→5→7→6→9→NULL} ,排序后得到结果为 1 2 4 5 6 7 9 N U L L {\rm 1→2→4→5→6→7→9→NULL} 。为了便于操作,这里假设单链表是带头结点的。

C {\rm C} ++代码:

inline void selectSortLinkList(ListNode* head) {
	ListNode* pre;
	ListNode* p = head->next;
	ListNode* q = p->next;
	p->next = NULL;
	p = q;
	while (p)
	{
		q = p->next;
		pre = head;
		while (pre->next != NULL && pre->next->val < p->val) {
			pre = pre->next;
		}
		p->next = pre->next;
		pre->next = p;
		p = q;
	}
}

这里结点 p r e {\rm pre} 用于寻找合适的插入位置,最终结点的插入位置为 p r e n e x t {\rm pre→next} 。结点 q {\rm q} 为了保持链表不断链。最外层循环用于遍历单链表,里层的 w h i l e {\rm while} 循环用于寻找合适的插入为位置。接着执行插入操作。

(三)两个链表的公共结点/共同后缀
在这里插入图片描述

图5:公共结点

两个链表的公共结点,也就是两个链表从某一个结点开始,它们的 n e x t {\rm next} 指针指向的内容都相同(由于每个结点只有一个 n e x t {\rm next} 域,如果当前结点的相同的后面结点也相同。即公共结点中相同元素存放在同一位置)。两个结点有公共结点在拓扑形状上为 Y {\rm “Y”} 。首先,很容易想到的方法是暴力求解。遍历第一个链表,如果然后从第二个链表上面寻找是否有相同的结点,这种方法的时间复杂度为 O ( m n ) {\rm O(mn)}

C {\rm C} ++代码一:

ListNode* commonLinkList(ListNode* &A, ListNode* &B) {
	if (A == NULL || B == NULL) {
		return NULL;
	}
	ListNode* b = B;
	while (A) {
		while (b)
		{
			if (b != A) {
				b = b->next;
			}
		}
		A = A->next;
	}
	return A;
}

由上述代码可知,暴力法会产生很多重复的操作。我们知道,公共结点开始的位置后面链表的长度是一致的。我们可以先让两个遍历链表的指针同步,然后同时向后移动用以寻找公共结点。

C {\rm C} ++代码二:

inline ListNode* commonLinkList(ListNode* &A, ListNode* &B) {
	if (A == NULL || B == NULL) {
		return NULL;
	}
	int len1 = Length(A);
	int len2 = Length(B);
	ListNode* longList;
	ListNode* shortList;
	int dist;
	if (len1 > len2) {
		longList = A;
		shortList = B;
		dist = len1 - len2;
	}
	else
	{
		longList = B;
		shortList = A;
		dist = len2 - len1;
	}
	while (dist--)
	{
		longList = longList->next;
	}
	while (longList)
	{
		if (longList == shortList) {
			return longList;
		}
		else
		{
			longList = longList->next;
			shortList = shortList->next;
		}
	}
	return longList;
}

L e n g t h {\rm Length} 函数用于求链表的长度,定义两个指针 l o n g L i s t {\rm longList} s h o r t L i s t {\rm shortList} 分别用于遍历较短和较长的链表,变量 d i s t {\rm dist} 用于计算两链表的长度差。第一个 w h i l e {\rm while} 循环使两个指针同步,第二个 w h i l e {\rm while} 循环用于寻找公共结点。此时,算法的时间复杂度为 O ( l e n 1 + l e n 2 ) {\rm O(len1+len2)}

此外,还可以借助额外辅助空间的方法。公共结点的值一定是从后往前依次相等的。我们可以定义两个栈分别用于存放两个链表的元素值,然后依次出栈,最后一个元素值相等处即为公共结点的位置。

C {\rm C} ++代码三:

ListNode* commonLinkList(ListNode* &A, ListNode* &B) {
	if (A == NULL || B == NULL) {
		return NULL;
	}
	stack<ListNode*> s1;
	stack<ListNode*> s2;
	while (A)
	{
		s1.push(A);
		A = A->next;
	}while (B)
	{
		s2.push(B);
		B = B->next;
	}
	ListNode* common = NULL;
	while (!s1.empty() && !s2.empty())
	{
		ListNode* temp1 = s1.top();
		ListNode* temp2 = s2.top();
		if (temp1->val == temp2->val) {
			common = temp1;
			s1.pop();
			s2.pop();
		}
		else
		{
			break;
		}
	}
	return common;
}

(四)两个链表元素的交集
假设两个链表的元素按非递减顺序存放,该函数的功能是求两个链表元素的交集。为了便于操作,这里假设单链表是带头结点的。

C {\rm C} ++代码:

ListNode* intersectionLinkList(ListNode* &A, ListNode* &B) {
	ListNode* p = A->next;
	ListNode* q = B->next;
	ListNode* c = A;
	ListNode* u;
	while (p && q) {
		if (p->val == q->val) {
			c->next = p;
			c = p;
			p = p->next;
			u = q;
			q = q->next;
			free(u);
		}
		else if(p->val < q->val)
		{
			u = p;
			p = p->next;
			free(u);
		}
		else
		{
			u = q;
			q = q->next;
			free(u);
		}
	}
	while (p)
	{
		u = p;
		p = p->next;
		free(u);
	}
	while (q)
	{
		u = q;
		q = q->next;
		free(u);
	}
	c->next = NULL;
	return A;
}

A {\rm A} 的头结点作为最后的返回结果。指针 p {\rm p} 和指针 q {\rm q} 分别用于遍历链表 A {\rm A} 和指针 B {\rm B} 。然后,将链表 A {\rm A} 和指针 B {\rm B} 中剩余元素释放掉。最后将结果链表的 n e x t {\rm next} 指针置为空,返回结果。

(五)从小到大删除链表中的元素
该函数功能为从小到大删除链表中的元素,直至链表为空。为了便于操作,这里假设单链表是带头结点的。

C {\rm C} ++代码:

void deleteSmallToLargeLinkList(ListNode* &head) {
	ListNode* premin, *min;
	ListNode* pre, *p;
	premin = pre = head;
	min = premin->next;
	p = pre->next;
	while (p)
	{
		while (p)
		{
			if (min->val > p->val) {
				premin = pre;
				min = p;
			}
			pre = p;
			p = p->next;
		}
		Visit(min->val);
		premin->next = min->next;
		free(min);
		premin = pre = head;
		min = premin->next;
		p = pre->next;
	}
}

采用指针 p r e m i n {\rm premin} m i n {\rm min} 共同指向最小值的位置,指针 p r e {\rm pre} p {\rm p} 用于遍历链表。找到最小值后删除,并重置上述 4 {\rm 4} 个指针的位置。上述程序的时间复杂度为 O ( n 2 ) {\rm O(n^2)} 。另外,我们可以先将链表中元素依次赋值到数组中,然后在数据中以时间复杂度为 O ( l o g 2 n ) {\rm O(log_2n)} 的算法排序,最后再放回原数组。这是一种典型的用空间换时间的思想。

(六)查找链表倒数第k个位置的元素
该函数的功能是找到链表中倒数第 k {\rm k} 个元素,如果该元素存在,访问该元素并返回1;否则返回0。该函数的思想是:定义两个指针 p {\rm p} q {\rm q} ,初始状态下都指向首结点。然后先让 q {\rm q} 移动   k {\ k} 步后,两个指针同步移动。当 q {\rm q} 为空结点时则结点 p {\rm p} 指向的为倒数第 k {\rm k} 个结点。

C {\rm C} ++代码:

int lastedKLinkList(ListNode* head, int k) {
	ListNode*p, *q;
	p = q = head;
	int count = 0;
	while (q)
	{
		if (count < k) {
			count++;
		}
		else
		{
			p = p->next;
		}
		q = q->next;
	}
	if (count < k) {
		return 0;
	}
	else {
		cout << p->val << endl;
		return 1;
	}
}

首先定义快指针 q {\rm q} 和慢指针 p {\rm p} ,在循环体内部,如果 c o u n t < k {\rm count<k} 则只移动快指针,否则当 c o u n t = k {\rm count=k} 时两个指针均移动。最后判断,链表的长度是否长于等于所给 k {\rm k} 值,并返回结果。


8. 单链表操作的总结

单链表操作算法的形式类似,但设计方法多变。在拿到一个题后,往往现在纸上手动模拟程序的运行过程,然后书写代码。特别注意的是,合理地保存下一个结点防止断链。此外,单链表不适合做排序操作。要对其中元素进行排序时,可以考虑借助数组以空间换时间的思想。最后,在单链表中经常出现的一种思想是:快慢指针,定义两个移动速度不同或移动位置不同的指针有时可以大大简化算法流程。如第7节中的(三)和(六)等。


参考

  1. 率辉. 2019版数据结构高分笔记[M]. 北京:机械工业出版社. 2018.1.
  2. 王道论坛. 2019年数据结构考研复习指导[M]. 北京:电子工业出版社, 2018.4.
  3. https://www.cnblogs.com/edisonchou/p/4822675.html.


发布了12 篇原创文章 · 获赞 0 · 访问量 624

猜你喜欢

转载自blog.csdn.net/Skies_/article/details/104608272