内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将与15个工作日内将博客设置为仅粉丝可见。
链表的演示
略
双向链表的演示
略
链表的实现
实现链表,一般有两种方式:用指针实现的链表、用数组实现的链表。
指针实现版:
类型定义及结点构造
struct node {
struct node *next;
int d;
};
struct node *newnode(int d) {
struct node *p = (struct node *)malloc(sizeof(struct node));
p->d = d;
p->next = NULL;
return p;
}
struct node *head = newnode(1);
插入元素
// p is the location before insertion
struct node *x = newnode(data);
x->next = p->next;
p->next = x;
删除元素
// p is the location before deletion
p->next = p->next->next;
删除表头
head = head->next;
想一想
上面的操作是否有内存泄漏?如何避免?
数组实现版:
struct node {
int d, next;
} pool[1000];
int tot = 0;
int newnode(int d) {
pool[tot].d = d;
pool[tot].next = -1;
return tot++;
}
int head = newnode(1);
插入元素
// p is the location before insertion
int x = newnode(data);
pool[x].next = pool[p].next;
pool[p].next = x;
删除元素
// p is the location before deletion
pool[p].next = pool[pool[p].next].next;
删除表头
head = pool[head].next;
常见链表问题
基于链表的定义和插入、删除操作思想,有很多常见的链表问题,接下来会为你介绍其中的几个最经典的例子,及其解法和示例代码。
统计链表中的结点个数
对于给定的链表,统计其中一共有多少个结点。
思路:仿照前面插入和删除操作中的 while 循环,从表头head
开始遍历,直到NULL
结束。时间复杂度为 O(n)。
示例代码如下:
int get_length(ListNode *head) {
int count = 0;
while (head != NULL) {
count++;
head = head->next;
}
return count;
}
找出倒数第 K 个结点
对于给定的链表,找出倒数第 k 个结点的元素。
思路 1:先统计出链表中的结点个数 n,之后从前往后找到第 n−k+1 个结点。时间复杂度为 O(n)。
示例代码如下:
ListNode* get_kth(ListNode *head, int k) {
int n = get_length(head);
if (k <= 0 || k > n) {
return NULL;
}
int count = 0;
for (int i = 0; i < n - k; ++i) {
head = head->next;
}
return head;
}
思路 2:先让一个指针nextk
指向链表第 k 个元素,然后让head
和nextk
同时向后移动,直到nextk
为空。时间复杂度为 O(n)。
示例代码如下:
ListNode* get_kth(ListNode *head, int k) {
if (k <= 0 || k > n) {
return NULL;
}
ListNode *nextk = head;
for (int i = 0; i < k - 1; ++i) {
nextk = nextk->next;
}
while (nextk != NULL) {
head = head->next;
nextk = nextk->next;
}
return head;
}
链表翻转
将给定的链表进行翻转。例如,一个链表中的元素分别为 1,2,3,4,5,则翻转后的结果为 5,4,3,2,1。
思路 1:新建一个链表,每次把原链表中的第一个元素放到新链表的头部,并把这个元素从原链表中删掉。空间复杂度为 O(n),时间复杂度为 O(n)。
示例代码如下:
ListNode* reverse(ListNode *head, int k) {
ListNode *new_list = NULL;
while (head != NULL) {
ListNode *node = (ListNode*)malloc(ListNode);
node->val = head->val;
new_list = insert(new_list, 0, node);
head = head->next;
}
return new_list;
}
想一想,能不能把空间复杂度优化到 O(1) 呢?先思考一下再翻到下一页吧。
思路 2:我们可以不借助额外 O(n) 的空间就可以将这个链表翻转。我们以链表 a,b,c,d,e 举例,讲一下算法的过程:
a -> b -> c -> d -> e
第一步,让 a 的next
指向 c,再让 b 的next
指向表头 a,把表头修改为 b:
b -> a -> c -> d -> e
接下来,让 a 的next
指向 d,再让 c 的next
指向表头 b,把表头修改为 c:
c -> b -> a -> d -> e
第三步,让 a 的next
指向 e,再让 d 的next
指向表头 c,把表头修改为 d:
d -> c -> b -> a -> e
最后,让 a 的next
为NULL
,再让 e 的next
指向表头 d,把表头修改为 e:
e -> d -> c -> b -> a
算法的时间复杂度为 O(n),空间复杂度为 O(1)。
链表环的处理
当然,链表也不一定都是呈现“一条链”的状态的。对于如下的链表:
如果你使用如下代码遍历链表的话:
while (head != NULL) {
printf("%d\n", head->val);
head = head->next;
}
这个循环会一直循环下去,也就是说,这个链表和我们前面学到的不太一样,具有一个特性:有环。
对于有环的链表,有一些常见的问题,接下来会逐个为大家介绍思路。
判断链表是否有环
给定一个链表,判断其中是否有环。
思路 1:顺序遍历链表中的每个元素,判断链表中后面是否会再次出现该元素。时间复杂度为 O(n^2)。
示例代码如下:
int has_ring(ListNode *head) {
while (head != NULL) {
ListNode *now = head->next;
while (now != NULL && now != head) {
now = now->next;
}
if (now == head) {
return 1;
}
head = head->next;
}
return 0;
}
思路 2:可以设定两个指针,一个“速度”为 1,一个“速度”为 2。速度指的是指针每次向后移动的次数。一旦之后出现两个指针指向同一个位置的情况,则说明链表中包含环;而如果其中某个指针指向了NULL
,则说明链表无环。
对于上面这个链表,初始ptr1
和ptr2
都指向 4。
第一步,ptr1
指向 5,ptr2
指向 8;
第二步,ptr1
指向 8,ptr2
指向 5;
第三步,ptr1
和ptr2
都指向 1,说明链表有环,结束算法。
留一个思考题:为什么选择 1 和 2 呢?这个组合是不是最优的呢?
环和柄的长度
对于包含环的链表来说,有两个长度常常会需要计算:环的长度和柄的长度。环的长度是指环上的结点个数,而柄的长度指的是从链表的第一个结点开始,直到第一个进入环的结点为止的结点个数。
例如上面这个链表,环的长度为 3,柄的长度为 2。
环的长度
仿照判断链表是否有环的思路 2,我们在ptr1
和ptr2
相遇后让两个指针继续按原速度移动,直到再次相遇,两次相遇之间的移动次数就是环的长度。
第一次相遇时,ptr1
和ptr2
都指向 1。第一步,ptr1
指向 5,ptr2
指向 8。
第二步,ptr1
指向 8,ptr2
指向 5。
第三步,ptr1
和ptr2
都指向 1。一共经历了三步,所以环的长度是 3。
为什么是正确的呢?这是个很经典的“追及问题”:首先,速度为 1 和速度为 2 的指针同向移动,如果速度为 2 的指针从后面超过速度为 1 的指针,一定会在某个元素相遇,而不会“错过”。这样的话,两次相遇之间,ptr2
和ptr1
移动的位置差就是环的长度,也因此,ptr1
的移动次数就是环的长度。
双链表问题
在链表相关的算法问题中,同时在两个链表上进行操作是一类经常出现的题目。总的来说,一共有两类:一类是将在两个数组上的算法迁移到链表中的题目;一类是两个链表成环、交叉等基于链式结构的题目。
对于第一类题目,考察的往往是二分、分治等算法,结合单链表基本的插入、删除、遍历操作,对链表考察的部分难度不大,这里也就不再进行额外的讲解了,大家如果做这类题目遇到困难,应该先去完成“数组版”的题目,再将它改写成“链表版”的。
第二类题目最典型的例子就是一系列关于两个链表交叉的题目,接下来,会为你介绍其中几个较为经典的题目及其思路和解法。
判断无环链表是否交叉
两个链表交叉,意味着两个链表有公共点,这说明着什么呢?
我们看到,上图中的两个链表在 2 处相遇后,就完全重合了,这是由于链表的链式结构的性质决定的。因此,我们可以从两个链表的表头分别遍历到表尾,比较一下两个表尾是否相等,如果相等则说明两个链表交叉。
给出算法的 C++ 示例代码如下:
int intersect(ListNode *head1, ListNode *head2) {
while (head1 != NULL) {
head1 = head1->next;
}
while (head2 != NULL) {
head2 = head2->next;
}
return head1 == head2;
}
如果已知两个链表交叉,要找出其中的交叉点,可以先算出两个链表head1
、head2
的长度,不妨设为 l1,l2。如果 d=l1−l2≥0,则让head1
先向后移动 d 次,然后让两个指针同时向后移动,直到两个指针相等;如果 d=l2−l1>0,则让head2
先向后移动 d 次,然后让两个指针同时向后移动,直到两个指针相等。两个指针第一次相等时,就是两个链表的交叉点。
打印锯齿矩阵
- 时间限制:1000ms
- 空间限制:131072K
- 语言限制:C语言
锯齿矩阵是指每一行包含的元素个数不相同的矩阵,比如:
3 5 2 6 1
2 3 4
1 6 2 7
初始时矩阵为空,读入 m 对整数 (x,y),表示在第 x 行的末尾加上一个元素 y。
输出最终的锯齿矩阵。
输入格式
第一行输入两个整数 n,m (1≤n,m≤10000),其中 n 表示锯齿数组的行数,m 表示插入的元素总数。
接下来一共 m 行,每行两个整数 x,y (1≤x≤n,0≤y≤10000),表示在第 x 行的末尾插入一个元素 y。
输出格式
一共输出 n 行,每行若干个用空格分隔的整数。如果某行没有任何元素,则输出一个空行。
格式说明
输出时每行末尾的多余空格,不影响答案正确性
样例输入
3 12
1 3
2 2
2 3
2 4
3 1
3 6
1 5
1 2
1 6
3 2
3 7
1 1
样例输出
3 5 2 6 1
2 3 4
1 6 2 7
任务提示:请用链表完成本题,每行可以看成是一个链表,每次在尾部添加一个元素。
标程与题解
每行可以看成是一个链表,每次在尾部添加一个元素。
#include <stdio.h>
int main() {
int data[10000];
int next[10000];
int head[10000];
int n, m, x, y, tot = 0;
scanf("%d%d", &n, &m);
memset(next, -1, sizeof(next));
memset(head, -1, sizeof(head));
for (int i = 0; i < m; i++) {
scanf("%d%d", &x, &y);
x--;
if (head[x] == -1) {
head[x] = tot;
data[tot] = y;
tot++;
} else {
int now = head[x];
while (next[now] != -1) {
now = next[now];
}
next[now] = tot;
data[tot] = y;
tot++;
}
}
for (int i = 0; i < n; i++) {
int now = head[i];
while (now != -1) {
printf("%d ", data[now]);
now = next[now];
}
printf("\n");
}
return 0;
}
我的答案
#include <stdio.h>
#include <stdlib.h>
#define MAXN 10010
typedef struct Node {
int data;
struct Node *next;
} Node;
Node *create_node(int data) {
Node *node = (Node *)malloc(sizeof(Node));
node->data = data;
node->next = NULL;
return node;
}
void insert(Node *head, int data) {
Node *p = head;
while (p->next != NULL) {
p = p->next;
}
p->next = create_node(data);
}
void print_list(Node *head) {
Node *p = head->next;
while (p != NULL) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
int main() {
int n, m;
scanf("%d%d", &n, &m);
Node *heads[MAXN];
for (int i = 1; i <= n; i++) {
heads[i] = create_node(-1);
}
for (int i = 0; i < m; i++) {
int x, y;
scanf("%d%d", &x, &y);
insert(heads[x], y);
}
for (int i = 1; i <= n; i++) {
print_list(heads[i]);
}
return 0;
}
求链表环的长度
- 时间限制:1000ms
- 内存限制:32867K
- 语言限制:C语言
求出单链表中环的长度。我们已经为你定义好了链表结点。具体的可以参考注释部分。
/**
* 已经定义的 ListNode 结点
* typedef struct Node {
* int val;
* struct Node *next;
* } ListNode;
*/
int linkedListCycleLength(ListNode *head) {
}
传入的原始的链表的头指针。如果有环,返回环中结点的个数,否则返回 0。链表长度不大于 2000。
格式说明
输出时每行末尾的多余空格,不影响答案正确性
样例输入
6
8 13 11 20 2 8
2
样例输出
5
我的答案
int linkedListCycleLength(ListNode *head) {
if (head == NULL || head->next == NULL) return 0;
ListNode *slow = head;
ListNode *fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
int count = 1;
slow = slow->next;
while (slow != fast) {
count++;
slow = slow->next;
}
return count;
}
}
return 0;
}
找出两个链表的交点
- 时间限制:1000ms
- 内存限制:32867K
- 语言限制:C语言
给出两个单链表,两个链表可能会相交。如下图,两个单链表在结点 2 相交。保证两个链表都不存在环。
我们已经为你定义好了链表结点。具体的可以参考注释部分。
/**
* 已经定义的 ListNode 结点
* typedef struct Node {
* int val;
* struct Node *next;
* } ListNode;
*/
ListNode* findIntersectionListNode(ListNode *head1, ListNode *head2) {
}
传入两个原始链表的头指针,返回交点的指针,如果两个链表没有交点那么返回NULL
。链表总长度不大于 20000。
样例输入
5 3 2
样例输出
Accept
我的答案
ListNode* findIntersectionListNode(ListNode *headA, ListNode *headB) {
ListNode *pA = headA, *pB = headB;
while (pA != pB) {
pA = pA ? pA->next : headB;
pB = pB ? pB->next : headA;
}
return pA;
}