实验介绍
我们这门课是算法,这里所讲的数据结构倾向于实战,大家不要拘泥于具体的写法,而重在学习原理,和使用方式,我们所需要的是简洁、实用和快速。我们这节课主要目标学会三种链表的原理与实现,学会灵活地运用,能够不依赖于模板根据题目独立写出各类链表。
我们不是数据结构教程,经典的数据结构采用 C 或 C++ 采用模板类进行编写,但是非常不适合竞赛使用,几行代码硬是能写成十几行,提高了复用性但是浪费了书写时间。所以并不适合竞赛,竞赛追求效率、accept 和简洁。
知识点
- 单链表实现原理与应用
- 双向链表实现原理与应用
- 循环链表实现原理与应用
为什么使用链表
相信大家在这之前已经学过数组,无论是 C++,Java,Python 还是其它语言大都会有数组这一概念,好用吗?很好用,所谓数组其实就是线性表的顺序存储形式的原理,我们来看一下链表的定义并对比一下链式存储与顺序存储的存储方式。
什么是链表
链表是线性表的链式存取的数据结构,是一种链式存取的数据结构,是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:数据域(数据元素的映象)+ 指针域(指示后继元素存储位置),数据域就是存储数据的存储单元,指针域就是连接每个结点的地址数据。 相比于线性表顺序结构,操作复杂。
似乎定义是有些晦涩难懂,我们用两张图来对比一下数组也就是线性表的顺序存储结构和链表在内存中存储 1-9 号元素的形式:
- 顺序存储
- 链式存储
思考一下:
线性表的数据存储方式的内存地址是顺序的,链式存储的数据的内存地址的有什么规律呢?
事实上链式存储的内存地址的是随机分配的,他们每个节点地址之间是没有任何关联的。而且在每个新的节点在产生之前,我们都是不知道他的地址的。
链表初体验
通过上面的介绍,大家可能还是不太能理解为什么要使用链表或者还不懂什么是链表,我们用一个题目来引入。
小王子有一天迷上了排队的游戏,桌子上有标号为 1-10 按顺序摆放的 10 个玩具,现在小王子想将它们按自己的喜好进行摆放。小王子每次从中挑选一个好看的玩具放到所有玩具的最前面。已知他总共挑选了 M 次,每次选取标号为 X 的玩具放到最前面,求摆放完成后的玩具标号。
给出一组输入,M=8 共计排了 8 次,这 8 次的序列为 9,3,2,5,6,8,9,8。 求最终玩具的编号序列。
我们首先梳理一下基本的模拟方法的思路,这个题该怎么去解答:
- 首先我们要开一个长度为 11 的数组,因为下标要从 1 开始所以 0 — 10 共计 11 个元素。
a[11]={0,1,2,3,4,5,6,7,8,9,10}
- 然后根据题意我们要写一个查找函数:
//伪代码形式
int Funciton 查找(X)
{
range i in(1, 10) //循环从x位置到2号位置
if data[i] == x : //找到X返回
return i
end if end range
}
我们简单描述一下这个过程:
首先是步进查找比如查找值为 5 元素的下标:
找到元素后返回下标,值为 5。
- 最后我们找到元素后要进行插入操作。
void Function 移动(L)
{ //移动函数
//拿走了X,X在L位置,所以将L-1向后移动到L,依次向后移动空处最前面的位置
temp = data[L]
range i in(L, 2) //循环从L位置到2号位置
data[i] = data[i - 1] //向后移动
end range
data[i] = temp
}
我们还是以 5 为例,要把 5 移到到首位,肯定不是把 5 放到第一位就行。
讲到这里,大部分同学肯定会写出如下代码:
//伪代码形式
int Funciton 查找(X){
range i in (1,10) //循环从x位置到2号位置
if data[i]==x : //找到X返回
return i
end if
end range
}
void Function 移动(L){ //移动函数
//拿走了X,X在L位置,所以将L-1向后移动到L,依次向后移动空处最前面的位置
temp=data[L]
range i in (L,2) //循环从L位置到2号位置
data[i]=data[i-1] //向后移动
end range
data[i]=temp
}
void Main()
{
输入 M
range in (1,M) //循环M次
输入 X
L=查找(X)
移动(L)
end range
}
这样每次调用移动函数即可,M=8 调用 8 次函数,每次传入 X 找到位置后,即可得到正确答案。
如果我们规定每次循环的时间复杂度为 1 的话,这次花费了我们多少时间呢?
- X=9 查找 9 循环了 9 次 移动花费了 9 次 此时序列为 9,1,2,3,4,5,6,7,8,10
- X=3 查找 3 循环了 4 次 移动花费了 4 次 此时序列为 3,9,1,2,4,5,6,7,8,10
我们看到每次都花费了大量时间去移动。如果我们采用链表去存储呢,会是什么样子呢。
我们来模拟一下过程:
- 这是初始序列
第一次输入 X=9:
- 执行查询操作:
- 执行删除操作
给 9 前面结点的指针赋值为 9 的指针,再将 9 删除。
- 执行插入操作:
新建一个结点, data 部分为 9 ,将结点插入链表的首部
相比之下后者执行的操作更少,速度更快,那我们给出该题目的一个标准的答案及详细解析。
题目解析
我们学了前面那么多的知识点,我们来动手解答一下小王子的问题了。
- 首先,我们使用链表的话,要先给出结点的定义,上面讲到链表的形式。
结点定义:
struct Node
{
int data;
Node *next;
}
- 第二步,我们要先构成一个这样的链表:
head-> 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
Node * head=new Node; //先生成头结点
void init()
{
head->next = nullptr; //形成空链,由上文已知单链表最后一个结点的指针为空。
for (int i = 10; i >= 1; i--)
{
Node *temp = new Node;
temp->data = i;
temp->next = head->next;
head->next = temp;
}
}
//由于我们后边会用到插入函数,其实我们可以写成插入函数的形式
void insert(int x)
{
Node* temp=new Node;
temp->data=x;
temp->next=head->next;
head->next=temp;
}
void init(){//为了美观,我们写个初始化函数
head->next=nullptr; //无论用什么方式,都不能省略该语句,不然无法正常使用。
for(int i=10;i>=1;i--) insert(i);//从10开始插入
}
- 第三步,我们要写一个插入函数
void insert(int x)
{
Node *temp = new Node; //新建一个结点
temp->data = x; //把数据域赋值为x
temp->next = head->next;
head->next = temp; //将节点加入到链表中
}
- 第四步我们要写一个删除函数,通过遍历链表删掉想要的数字
void del(int x)
{
Node *Befor = head; //用于存放当前节点的前驱,因为单链表单向遍历,我们不能从下一个找到上一个
for (Node *T = head->next; T != nullptr; T = T->next) //链表的遍历常用写法
{
if (T->data == x) //找到要的那个数了
{
Node *temp = T; //先临时保存结点
Befor->next = T->next; //将节点从链表上摘除
delete temp; //从内存中删除结点。
return; //删除结束后,结束函数。
}
Befor = T; //前驱改变
}
}
- 第五步我们写一个遍历输出函数,形式接近于删除函数
void show(int i)
{
cout << "这是第" << i << "次操作";
for (Node *T = head->next; T != nullptr; T = T->next) //链表的遍历常用写法
{
cout << T->data << " ";
}
cout << endl;
}
- 最后一步我们编写主函数
int main()
{
init();
show(0);
int N;
cin >> N;
for (int i = 1; i <= N; i++)
{
int x;
cin >> x;
del(x);
insert(x);
show(i);
}
}
我们带入之前的样例进行测试:N=8 X= 9 3 2 5 6 8 9 8。
完整代码如下:
C++写法:
#include <iostream>
using namespace std;
struct Node
{
int data;
Node *next;
};
Node *head = new Node; //先生成头结点
void init()
{
head->next = nullptr; //形成空链,由上文已知单链表最后一个结点的指针为空。
for (int i = 10; i >= 1; i--)
{
Node *temp = new Node;
temp->data = i;
temp->next = head->next;
head->next = temp;
}
}
void del(int x)
{
Node *Befor = head; //用于存放当前节点的前驱,因为单链表单向遍历,我们不能从下一个找到上一个
for (Node *T = head->next; T != nullptr; T = T->next) //链表的遍历常用写法
{
if (T->data == x) //找到要的那个数了
{
Node *temp = T; //先临时保存结点
Befor->next = T->next; //将节点从链表上摘除
delete temp; //从内存中删除结点。
return; //删除结束后,结束函数。
}
Befor = T; //前驱改变
}
}
void insert(int x)
{
Node *temp = new Node;
temp->data = x;
temp->next = head->next;
head->next = temp;
}
void show(int i)
{
cout << "这是第" << i << "次操作"; //提交代码时删掉这一行
for (Node *T = head->next; T != nullptr; T = T->next) //链表的遍历常用写法
{
cout << T->data << " ";
}
cout << endl;
}
int main()
{
init();
show(0);//提交代码时删掉这一行
int N;
cin >> N;
for (int i = 1; i <= N; i++)
{
int x;
cin >> x;
del(x);
insert(x);
show(i);
}
}
Python 写法
Java 写法
以上为带有头结点的单链表,相应的还有没有头结点的单链表,有兴趣的同学可以自行查阅资料。在比赛中我们常用 STL 中 link 容器而很少会去定义和使用,这一章节我们主要是学会原理和使用,原理学会之后无论是什么样的链表都是可以设计出来的。
头结点: 如果链表有头节点,则链式结构中的第一个节点称为头结点,其数据域可以存储一些附加信息,如链表长度;其指针域指向链表中的第一个节点。 经过上面对小王子这个题的讲解,相信大家都对这个链表的有点有了一个初步的认识,我们看到链表的使用确实很方便,在我们不需要随机访线性表里面的元素时,使用链表确实要方便很多,无论是时间复杂度还是空间复杂度都十分优秀。下面我们对线性表的两种存储方式的对比。
链表与顺序表优缺点对比
经过上面的讲解,大家应该都已经对线性表存储数据有了一定的了解,线性表是编写程序中的最常见数据结构,对于顺序和链式两种存储结构我们到底该在何时选择哪一种存储方式? 我们对两种存储方式做一个对比。
顺序表
优点:
- 无需为表示结点间的逻辑关系而增加额外的存储空间(因为逻辑上相邻的元素其存储的物理位置也是相邻的);
- 可方便地随机存取表中的任一元素:
由于顺序表每个元素的大小相等,且知道第几个元素就可以通过计算得到任意元素的地址,既可以随机存取任一元素。
缺点:
- 插入或删除运算不方便:
除表尾的位置外,在表的其它位置上进行插入或删除操作都必须移动大量的结点,其效率较低;
如在 8、9 之 间插入 X 元素,那我们为了保证其顺序性需要把 8 和 9 向后移动一位,再将 X 放到 8 的位置。
这样的存储方式增加了处理器和 IO 资源的消耗代价,这是我们不愿意看到的,至于删除其原理相同,我们不再进行赘述。
- 难以匹配存储规模:
由于顺序表要求占用连续的存储空间,存储分配只能预先进行静态分配,因此当表长变化较大时,难以确定合适的存储规模。
时间复杂度
查找操作为 O(1),插入和删除操作为 O(n)。
时间复杂度的计算:
时间复杂度不是一个具体的数字,而是一个量级。
常见的时间复杂度量级如下:
常数阶 O(1) < 对数阶 O(log2n) < 线性阶 O(n) < 线性对数阶 O(n log_{2}n)O(nlog2n) < 平方阶 O(n^{2})O(n2) < 方阶 O(n^{3})O(n3) < k 次方阶 O(n^{K})O(nK) < 指数阶 O(2^{n})O(2n) < 阶乘阶O(n!)O(n!) < O(n^{n})O(nn)
具体的计算方法其他章节会进行讲述,这里大家简单知道时间复杂度量级的大小即可。
链表
优点:
- 插入和删除速度快,保留原有的物理顺序,在插入或者删除一个元素的时候,只需要改变指针指向即可;
- 没有空间限制, 存储元素无上限, 只与内存空间大小有关;
- 动态分配内存空间,不用事先开辟内存;
- 使内存的利用率变高。
缺点:
- 占用额外的空间以存储指针,比较浪费空间,不连续存储,Malloc 函数开辟空间碎片比较多;
- 查找速度比较慢,因为在查找时,需要循环遍历链表。
时间复杂度:
查找操作为 O(n), 插入和删除操作为 O(1)。
使用循环链表解决约瑟夫环问题
将单链表或者双链表的头尾结点链接起来,就是一个循环链表。不增加额外存储花销,却给不少操作带来了方便从循环表中任一结点出发,都能访问到表中其他结点。
循环链表的组成
特点:
- 首尾相接的链表。
- 可以从任一节点出发,访问链表中的所有节点。
- 判断循环链表中尾结点的特点:
q->next==first
通过观察不难发现,循环链表与单链表的差别就是最后的指针一个为空一个与 First 相等,其他的都没有什么变化,也就是多了一个循环遍历的过程。
约瑟夫环问题
设有 n 个人围坐在圆桌周围,现从某个位置 k(1≤k≤n) 上的人开始报数,报数到 m 的人就站出来。下一个人,即原来的第 m+1 个位置上的人,又从 1 开始报数,再报数到 m 的人站出来。依次重复下去,直到全部的人都站出来为止。试设计一个程序求出这 n 个人的出列顺序。
- 要求一:采用循环链表解决
- 要求二:可以使用模拟法,模拟循环链表
- 要求三:可以不使用循环链表类的定义使用方式
大家可以先思考一下如何实验,在 OJ 或者右侧的环境中动手完成。
具体实验步骤
思路分析
首先要通过循环链表模拟一整个过程,然后再寻找删除位置。
删除位置的计算:
从线性表中起始位置 index 出发开始计数,当计数到 m 时(间隔 m-1 个数据),删除该位置上的元素;同时该位置又是下一次计数的起始位置:index=(index+k-1)
代码编写
- C++解法:
第一步:定义循环链表的结点结构体
//头文件与命名空间
#include <iostream>
using namespace std;
struct Node
{
int data;
Node *pNext;
};
第二步:定义主函数直接进行解题:
- 信息输入,和所需变量的声明
int n, k, m, i; //n个人从k位置开始报数,数到m出列
struct Node *p, *q, *head;
cin >> n >> k >> m;
- 依据题目构造循环链表,可以看出我们直接把插入的函数,拿到了这里来使用。
first = (Node *)new Node;
p = first;
first->data = 1;
for (i = 2; i <= n; i++)
{
q = new Node;
q->data = i;
p->pNext = q;
p = p->pNext;
}
p->pNext = first;
- 寻找报数的起点
p = first;
for (i = 1; i <= k - 1; i++)
p = p->pNext;
- 按照顺序依次出链表
while (p != p->pNext) //只剩下一个结点的时候停止
{
for (i = 1; i < m - 1; i++)
{
p = p->pNext;
}
q = p->pNext; //q为要出队的元素
cout << q->data << endl;
p->pNext = q->pNext;
delete q;
p = p->pNext;
}
cout << p->data << endl; //输出最后一个元素
}
完整代码:
//头文件与命名空间
#include <iostream>
using namespace std;
struct Node
{
int data;
Node *pNext;
};
int main()
{
int n, k, m, i; //n个人从k位置开始报数,数到m出列
Node *p, *q, *head;
cin >> n >> k >> m;
Node * first = (Node *)new Node;
p = first;
first->data = 1;
for (i = 2; i <= n; i++)
{
q = new Node;
q->data = i;
p->pNext = q;
p = p->pNext;
}
p->pNext = first;
p = first;
for (i = 1; i <= k - 1; i++) //
p = p->pNext;
while (p != p->pNext) //只剩下一个结点的时候停止
{
for (i = 1; i < m - 1; i++)
{
p = p->pNext;
}
q = p->pNext; //q为要出队的元素
cout << q->data << endl;
p->pNext = q->pNext;
delete q;
p = p->pNext;
}
cout << p->data << endl; //输出最后一个元素
return 0;
}
Java 解法
import java.util.Scanner;
public class Main
{
static class Node
{
int val;
Node next;
Node(int v)
{
val = v;
}
} //成员类,代表节点,类似于C++语言中的结构体
public static void main(String[] args)
{
int N, M, K; //n个人从k位置开始报数,数到m出列
Scanner input = new Scanner(System.in);
N = input.nextInt();
K = input.nextInt();
M = input.nextInt();
Node t = new Node(1); //头节点单列出来,方便形成循环链表
Node x = t;
for (int i = 2; i <= N; i++)
x = (x.next = new Node(i)); //建立单向链表
x.next = t; //最后一个节点的next指向第一个节点,形成循环链表
for (int i = 1; i <= K - 1; i++) //寻找报数的起点
x = x.next;
while (x != x.next)
{ //只剩下一个结点的时候停止
for (int i = 1; i < M; i++) {
x = x.next;
// System.out.print(x.val+" ");
}
//此时x是将出列的节点的前一个节点
System.out.println(x.next.val + " ");
x.next = x.next.next;
}
System.out.println(x.val);
}
}
Python 解法
思路相同,具体解析参见 C++ 和 Java 解法:
class Node():
def __init__(self, value, next=None):
self.value = value
self.next = next
def createLink(n):
if n <= 0:
return False
if n == 1:
return Node(1)
else:
root = Node(1)
tmp = root
for i in range(2, n + 1):
tmp.next = Node(i)
tmp = tmp.next
tmp.next = root
return root
if __name__ == '__main__':
n, k, m = map(int, input().split())
root = createLink(n)
tmp = root
for i in range(0, k - 1):
tmp = tmp.next
while tmp.next != tmp:
for i in range(m - 2):
tmp = tmp.next
print(tmp.next.value, " ")
tmp.next = tmp.next.next
tmp = tmp.next
print(tmp.value)
双向链表再求解小王子问题
单链表的主要不足之处是 link 字段仅仅指向后继结点,不能有效地找到前驱。双链表弥补了上述不足之处,增加一个指向前驱的指针 。
由于在双向链表中既有前向链又有后向链,寻找任一个结点的直接前驱结点与直接后继结点变得非常方便。设指针 p 指向双链表中某一结点,则有下式成立:
p-> llink->rlink = p = p->rlink->llink
双向链表的实现
还记得我们在小王子那一题目中所定义前驱变量吗?
因为单链表只能单向遍历所以我们要定义临时变量,如果我们改成双向链表这个题目这里就可以进行优化。
- 首先,我们使用链表的话,要先给出结点的定义,上面讲到链表的形式。
struct Node
{
int data;
Node *next;
Node *before;
}
- 第二步,我们要先构成一个这样的链表:
head <-> 1 <-> 2 <-> 3 <-> 4 <-> 5 <-> 6 <-> 7 <-> 8 <-> 9 <-> 10
void insert(int x)
{
Node *temp = new Node;
temp->data = x;
temp->next = head->next;
head->next = temp;
temp->before = head;
if (temp->next)
temp->next->before = temp;
}
Node *head = new Node; //先生成头结点
void init()
{ //为了美观,我们写个初始化函数
head->next = nullptr; //无论用什么方式,都不能省略该语句,不然无法正常使用。
head->before = nullptr;
for (int i = 10; i >= 1; i--)
insert(i); //从10开始插入
}
- 第三步我们要写一个删除函数,通过遍历链表删掉想要的数字
void del(int x)
{
for (Node *T = head->next; T != nullptr; T = T->next) //链表的遍历常用写法
{
if (T->data == x)
{ //找到要的那个数了
T->before->next = T->next; //双向链表,就是如此简单方便。
T->next->before=T->before;
return; //删除结束后,结束函数。
}
}
}
- 第四步我们写一个遍历输出函数,形式接近于删除函数
void show(int i)
{
cout << "这是第" << i << "次操作";
for (Node *T = head->next; T != nullptr; T = T->next) //链表的遍历常用写法
{
cout << T->data << " ";
}
cout << endl;
}
- 最后一步我们编写主函数
int main()
{
init();
show(0);
int N;
cin >> N;
for (int i = 1; i <= N; i++)
{
int x;
cin >> x;
del(x);
insert(x);
show(i);
}
}
我们带入之前的样例进行测试:N=8 X= 9 3 2 5 6 8 9 8。
我们可以看到这里的删除非常简单,这么写的话大大简化删除了过程,所以在不同题目下灵活地选取链表能够使得解题变得简单。
完整代码如下:
#include <iostream>
using namespace std;
struct Node
{
int data;
Node *next;
Node *before;
};
Node* head = new Node; //先生成头结点
void insert(int x)
{
Node* temp=new Node;
temp->data=x;
temp->next=head->next;
head->next=temp;
temp->before=head;
if(temp->next) temp->next->before=temp;
}
void init() //为了美观,我们写个初始化函数
{
head->next=nullptr; //无论用什么方式,都不能省略该语句,不然无法正常使用。
head->before=nullptr;
for(int i=10; i>=1; i--) insert(i); //从10开始插入
}
void del(int x)
{
for(Node*T=head->next; T!=nullptr; T=T->next) //链表的遍历常用写法
{
if(T->data==x) //找到要的那个数了
{
T->before->next=T->next;//双向链表,就是如此简单方便。
T->next->before=T->before;
return; //删除结束后,结束函数。
}
}
}
void show(int i)
{
// cout << "这是第" << i << "次操作";
for (Node *T = head->next; T != nullptr; T = T->next) //链表的遍历常用写法
{
cout << T->data << " ";
}
cout << endl;
}
int main()
{
init();
// show(0);
int N;
cin >> N;
for (int i = 1; i <= N; i++)
{
int x;
cin >> x;
del(x);
insert(x);
show(i);
}
}
Java 解法:
Python 解法
实验总结
关于链表的定义方式,在各种教科书上和网站都有着各个不同版本的定义方式,我们应该学习的实现原理,具体实现都是大同小异,通常在算法中我们只定义结点,在 Main 函数中直接使用结点组成新的链表而不去写链表的结构体,这样可以减少代码量的使用,提高编程的速度,在程序竞赛中的使用的比较多,但是相应的也降低了代码的复用性,不适合用于项目开发中,还需大家理解差异。不要拘泥于写法,注重的是应用和原理,我们学习这门课的目的是为了 accept 题目,而不是写一堆无用的代码浪费时间。
本次实验,我们学习了三种最常见的链表的原理与使用方式,诚然链表的种类是千变万化的,像是循环链表与双向链表的结合形成的双向循环链表,存储图的十字链表等,我们学好这基础的三种链表,以不变应万变才是正确的面对方式。作者在早期学习链表的时侯只会单链表,在比赛的时候临场写出了双向链表,成功 AC 题目,其实链表的类的定义方式也是我在上文写的是最复杂的一种方式,将每种功能封装,诚然这样的代码复用性会很高,当作模板可以,但是在赛场上的时候,我们没有那么多时间去写代码,都是用什么功能再去写什么功能直接在主函数中完成,追求简洁高效。