文章目录
知识框架:
本文是我学习数据结构时的练习与思考,后期会继续补充,原理见注释。
以下案例都是带有头结点的链表。
在写本文代码部分时基本没有参考任何资料,所以List的部分成员函数可能和标准的实现方法有出入。自己从底层手动实现一遍,只要掌握了链表数据结构的基本思想,就可以分析任何变式(如前插入和后插入等)
Tip:在刷题时应注意效率和空间限制,若类中所含有的成员函数过多会导致在创建list/node时所需的空间越多,若针对node,在delete时会使得执行时间大大增加。
【0】链表的基本操作
链表初始化
构造链表时的操作都有:
- 创建头结点(尾结点),申请节点空间
- list大小初始化为0
- 双向链表需要链接头结点和尾结点
//在应用中,head和trailer节点都应该对外不可见(可以理解为只读).
刚创建时的双链表:
刚创建时的循环单链表:
添加节点(以双链表的后插入为例)
·前插入与此方法对偶相同
若已知循环迭代pos步骤后插入的位置但未知节点的物理位置,就需要用一个临时指针遍历至插入的位置节点(已知物理位置同理),下图中this节点为temp_idx经循环遍历到的位置(也可以视作已知节点时的物理位置):
- step1:获得需要插入节点具体的物理位置(图中的this节点)
- 新建一个节点new,并连接new的pred至this节点、new的succ至this节点的后继,此步没有任何安全隐患。
- ①先链接this的后继至节点new,②再通过new节点的后继,将succ node的前驱链接至node。①②可互换,但2、3步不能互换,即先链接好节点new的前驱后继,才能更安全地与原链表链接
删除节点
先来看普通链表的删除,以双链表为例
单链表删除时需要添加一个临时指针保存需要删除节点的前驱,其余与双链表同理
步骤:
- 首先找到需要删除节点的物理地址
- 将p的后继节点的前驱通过p指向p的前驱节点,将p的前驱节点通过p指向p的后继节点
- 删除节点p
循环链表节点的删除放在后文讨论。
【1】双向链表
代码(运行环境:VS2019):
#include <iostream>
using namespace std;
class Node {
public:
Node() { } //针对head和trailer的构造函数
Node(int n, Node* p = NULL, Node* s = NULL) :data(n), pred(p), succ(s) {} //默认构造函数
int data;
Node* pred; //前驱
Node* succ; //后继
};
class List { //双链表(DL double_lsit)
public:
List() { init(); }
~List();
int size() { return this->m_size; }
Node* first() { return head->succ; }
Node* last() { return trailer->pred; }
Node* insertAsFirst(int n); //插入至列表首位置
bool insertPos(int pos, int elem); //在pos位置插入元素
int remove(int p); //移除位置p处的元素并返回其数值
int remove(Node* p); //重载版:直接删除节点p
void output(); //输出当前链表(header->trailer)
private:
void init();
int m_size; //链表规模
Node* head, * trailer; //哨兵节点
};
void List::init() { //列表初始化,创建哨兵节点
Node* head = new Node;
Node* trailer = new Node; //注意初始化变量的申请 test: head->data = 0;
this->head = head; this->trailer = trailer;
head->succ = trailer; head->pred = NULL;
trailer->pred = head; trailer->succ = NULL;
this->m_size = 0;
}
Node* List::insertAsFirst(int n) {//首插节点
Node* p = new Node; //申请一个空间
p->data = n;
//step1.
p->pred = this->head;
p->succ = this->head->succ;
//setp2.
head->succ = p;
p->succ->pred = p;
this->m_size++;
return p;
}
bool List::insertPos(int pos, int elem) { //在pos位置后插入节点
if (pos >= this->m_size)
return false;
Node* p = new Node;
Node* temp_idx = head->succ;
p->data = elem;
int n = this->m_size - pos;
while (n--)
temp_idx = temp_idx->succ; //o(n)
p->pred = temp_idx;
p->succ = temp_idx->succ;
temp_idx->succ = p;
p->succ->pred = p;
this->m_size++;
return true;
}
int List::remove(int pos) { //删除特定位置的节点
if (pos<0 || pos>this->m_size) return false; //防止出现不合法位置id
Node* temp_idx = head->succ;
int n = pos;
while (n--)
temp_idx = temp_idx->succ; //从头节点开始遍历到需删除节点的位置
temp_idx->succ->pred = temp_idx->pred;
temp_idx->pred->succ = temp_idx->succ; //链接顺序可互换
n = temp_idx->data; //n发挥完作用后暂时储存信息
delete temp_idx;
this->m_size--;
return n;
}
int List::remove(Node* no) { //已知节点地址,删除链表节点
int n = no->data;
no->pred->succ = no->succ;
no->succ->pred = no->pred;
delete no;
return n;
}
List::~List() { //链表析构
while (m_size--) { //直接将头结点向尾节点缩进,直至head = trailer
head = head->succ;
delete head->pred;
}
delete head; //最后析构head节点(循环结束后head = trailer)
}
void List::output() { //链表的输出
Node* temp_idx = head->succ;
while (temp_idx->succ) {
cout << temp_idx->data << " ";
temp_idx = temp_idx->succ;
}
cout << endl;
}
int main() {
List lis;
for (int i = 0; i < 5; i++)
lis.insertAsFirst(i + 1);
cout << "长度:" << lis.size() << ",初始链表为:" << endl; lis.output();
cout << "在第3个元素后插入1000得:" << endl;
lis.insertPos(3, 1000); //在第3个元素后处插入1000
lis.output();
cout << "当前链表长度为:" << lis.size() << ",删除了节点lis[1]:";
cout << lis.remove(1) << endl;
lis.output();
cout << endl;
return 0;
}
运行结果:
总结一下本人在练习时容易犯的错误:
- 在初始化init时和插入节点时忘记申请辅助空间。
- 由于遍历过程的循环次数计算错误导致temp_idx访问至非法空间。
双向链表应用:
链式队列、
【2】单向链表
链表反转reverse()
比起双链表更为简单,但是有些操作不方便。其中的链表反转算法reverse()采用就地逆置法反转链表,时间复杂度O(n),空间复杂度O(1),相比其他操作略微麻烦。
若想使得区间反转,则修改代码中的pre、mid和next,并将while的循环条件改用计数方式,遍历至反转起始位置,之后同理。
本人在探究时使ppt摆弄了一段时间才摸索出结果,ppt截图如下(step1为初始状态):
至此一轮循环结束,step6-step12同理,略。所有循环结束后就是这样的:
此时再将head挪动到图上的node3(pre指针管理)即可。
tip:这个过程要对指针有足够深入的理解。
注意点2:链表析构和链表清空empty()
当然最无脑的方法是将头结点head的后继元素直接delete然后置空,但这样并不能将链表元素占用的空间归还给系统,会导致内存泄漏。
比较可靠的方法是从head节点遍历一遍链表。
- empty() 和析构都是使用一个临时指针通过head访问head所指向的node,这样可以保证head不会被改变。区别是析构最后将head也置空而empty()保留头结点。
代码块的具体功能见注释。
代码(运行环境VS2019):
#include <iostream>
using namespace std;
class Node {
public:
int data;
Node* succ;
Node() {}; //针对指针的构造
Node(int num, Node* p = NULL) :data(num), succ(p) {};
};
class List { //单链表
private:
int m_size;
void init();
public:
Node* head;
List() { init(); }
~List();
Node* push_back(int n); //尾插法
Node* insertPos(int n,int pos); //按指定位置插入,若不符合标准返回false
int remove(int pos); //删除具体位置的节点
int remove(Node* pos); //删除具体的某个节点
int size() { return this->m_size; } //返回list的大小
void showList(); //从头结点开始输出整个链表
void reverse(); //链表反转
Node* getNode(int pos); //返回具体位置的某个节点,若不合法返回NULL
bool isEmpty() { return this->m_size == 0 ? true : false; }
void empty(); //清空列表
};
void List::init() { //单链表初始化
Node* head = new Node; //创建头结点
this->head = head;
this->m_size = 0;
head->succ = NULL;
}
Node* List::insertPos(int n, int pos) { //在pos位置后插入节点
Node* node = new Node(n);
Node* temp_idx = head;
while (pos--)
temp_idx = temp_idx->succ; //o(n)
node->succ = temp_idx->succ;
temp_idx->succ = node;
m_size++;
return node;
}
Node* List::push_back(int n) { //后插入 O(n)
return insertPos(n, m_size);
}
void List::showList() {
Node* temp_idx = this->head->succ;
while (temp_idx) {
cout << temp_idx->data << " ";
temp_idx = temp_idx->succ;
}
cout << endl;
}
Node* List::getNode(int pos) { //若位置不合法,则返回边界上的节点
if (pos < 0) return head;
if (pos >= m_size)
pos = m_size;
Node* temp_idx = head;
while (pos--)
temp_idx = temp_idx->succ;
return temp_idx;
}
int List::remove(int pos) { //移除pos位置后的节点
if (pos < 0 || pos >= m_size) return false;
Node* temp_idx = head;
Node* delete_assist;
while (pos--)
temp_idx = temp_idx->succ; //到达需要删减的前一个元素
delete_assist = temp_idx->succ;
temp_idx->succ = delete_assist->succ;
pos = delete_assist->data; //此时pos为辅助存储数据作用
delete delete_assist;
m_size--;
return pos;
}
int List::remove(Node* node) { //直接删除具体节点
Node* temp_idx = head;
int data = node->data;
while (temp_idx->succ != node)
temp_idx = temp_idx->succ;
temp_idx->succ = node->succ;
delete node;
m_size--;
return data;
}
void List::reverse() { //链表反转
//区间反转只需要遍历到所需反转的位置,修改pre、mid和next,while循环用数值记录即可
Node* pre = NULL, * mid = head->succ, * next = head->succ;
//仅反转除head的其余节点,最后一步将head链接到最后一个节点上,可保证不修改head的值
while (mid) {
next = next->succ;
mid->succ = pre;
pre = mid;
mid = next;
}
head->succ = pre;
}
void List::empty() { //频繁delete会使得时间大大增加,刷oj可直接删除头结点
Node* pre;
while (head->succ) { //保留head节点,其余全部删除(pre通过head节点访问其他节点)
pre = head->succ;
head->succ = head->succ->succ;
delete pre;
pre = head;
//this->m_size--; //test
}
this->m_size = 0;
}
List::~List() { //单链表析构
Node* pre;
while (head->succ) {
pre = head->succ;
head->succ = head->succ->succ;
delete pre;
pre = head;
}
delete head;
}
int main() {
List lis;
cout << "插入了10个元素:" << endl;
for (int i = 1; i <= 10; i++)
lis.push_back(i);
lis.showList();
cout << "将最后一个元素删除后插到第3个位置后(尾插法)" << endl;
lis.insertPos(lis.remove(lis.getNode(lis.size())), 3);
cout << "此时的链表大小为:" << lis.size() << "。具体数值为:" << endl;
lis.showList();
cout << "\n第5个元素的值为:" << lis.getNode(5)->data << endl << endl;
cout << "删除了元素:" << endl;
cout << "lis[" << 4 << "] = " << lis.remove(4) << endl << endl;
cout << "再删除了最后一个元素:" << lis.remove(lis.getNode(lis.size()));
cout << "。此时结果为:" << endl;
lis.showList();
cout << endl;
cout << "现将链表反转得到:" << endl;
lis.reverse();
lis.showList(); cout << endl;
cout << "清空前头部哨兵元素值:" << lis.head->data << ",大小:" << lis.size() << endl;
lis.empty();
cout << "清空后头部哨兵元素值:" << lis.head->data << ",大小:" << lis.size() << endl;
return 0;
}
运行结果:
【3】循环链表
循环链表节点的删除
在写约瑟夫环问题时发现:
①如果删除循环链表的非首节点和非尾结点,操作和普通链表的remove操作相同。
②如果删除的节点恰好是头结点head所指向的前驱和后继,那么删除时就需要将head节点的succ和pred妥善链接好,否则就会出现无法寻址的情况。
约定:若删除头结点的pred节点(链表最后一个节点),则head->pred前移动(即指向倒数第二个节点)。若删除头结点的succ节点(链表的首节点),则head->succ向后移动(即指向第二个节点):
int List::remove(int pos) {
if (pos < 0) return false; //认定为位置不合法,直接返回
Node* temp_idx = head->succ;
while (pos--) {temp_idx = temp_idx->succ;}
//先删除,后断定,再操作
temp_idx->succ->pred = temp_idx->pred;
temp_idx->pred->succ = temp_idx->succ; //此时的temp_idx和原链表前后的节点没有断开
/******************需要分两种情况讨论:******************/
if (temp_idx == head->succ) //头结点指向的数据(两种情况),删除head->succ并后移
head->succ = temp_idx->succ;
if (temp_idx == head->pred) //删除head->pred并前移
head->pred = temp_idx->pred;
/*****************************************************/
int reData = temp_idx->data;
delete temp_idx;
this->m_size--;
return reData;
}
这里仅给出对双向循环链表代码,单链表同理。
代码(运行环境:VS2019):
#include <iostream>
using namespace std;
class Node {
public:
int data;
Node* succ;
Node* pred;
Node() {}; //针对指针的构造
Node(int num, Node* pr = NULL, Node* su = NULL) :data(num), succ(su), pred(pr) {};
};
class List { //双向循环链表
private:
int m_size;
void init();
Node* insertOperate(int num); //头插和尾插的相同操作
public:
Node* head;
List() { init(); }
~List();
Node* push_back(int n); //尾插法o(1)
Node* push_front(int n); //头插法o(1)
int remove(int pos); //删除迭代pos次后的节点
int size() { return this->m_size; } //返回list的大小
void showList(); //从头结点开始输出整个链表
Node* getNode(int pos); //返回具体位置的某个节点,若不合法返回NULL
bool isEmpty() { return this->m_size == 0 ? true : false; }
void empty(); //清空列表
void JosephRing(int suiside_interval, int remaining);
};
void List::init() {
this->m_size = 0;
Node* Head = new Node;
this->head = Head; //新建的节点指向NULL
head->succ = head;
head->pred = head; //自循环
}
Node* List::insertOperate(int num) { //头插和尾插只有最后head的链接不一样,故相同部分整合
Node* node = new Node(num); //申请新节点空间
//注意head节点不能参与运算,在第一次插入时需要单独考虑
if (!this->m_size) {
head->pred = node; head->succ = node;
node->pred = node; node->succ = node;
this->m_size++;
return node;
}
//m_size >= 2 的情况
node->pred = head->pred;
node->succ = head->succ; //先链接好node
head->pred->succ = node;
head->succ->pred = node; //再链接好原链表的头部和尾部节点
this->m_size++;
return node;
}
Node* List::push_back(int num) { //在链表最后(head->pred)插入节点,o(1),单链表需要o(n)
Node* node = insertOperate(num);
head->pred = node; //最后head后移(由于疏忽浪费了很多时间)
return node;
}
Node* List::push_front(int num) { //在链表头部插入节点
Node* node = insertOperate(num);
head->succ = node; //与尾插对偶
return node;
}
int List::remove(int pos) { //删除迭代pos后的节点,若数据小于1,则认定为不合法,直接返回
if (pos < 0) return false;
Node* temp_idx = head->succ;
while (pos--)
temp_idx = temp_idx->succ;
//先删除,后断定,再操作
temp_idx->succ->pred = temp_idx->pred;
temp_idx->pred->succ = temp_idx->succ;
if (temp_idx == head->succ) { head->succ = temp_idx->succ;}
if (temp_idx == head->pred) { head->pred = temp_idx->pred;}
int reData = temp_idx->data;
delete temp_idx;
this->m_size--;
return reData;
}
Node* List::getNode(int num) { //获取迭代n次后的链表数据
Node* temp_idx = head;
while(num--)
temp_idx = temp_idx->succ;
return temp_idx;
}
void List::showList() {
Node* temp_idx = head;
int count = this->m_size;
while (count--) {
temp_idx = temp_idx->succ;
cout << temp_idx->data << " ";
}
cout << endl;
}
void List::empty() {
Node* temp_idx = head;
m_size--; //借一
while (m_size--) {
temp_idx = temp_idx->succ; //使head移动到最后一个节点的位置
delete temp_idx->pred;
}
delete temp_idx;
m_size++; //还一
}
List::~List() { //思路同上两个例子,遍历一遍删除,最后再删除头结点
if (m_size) { //只有m_size不为空时才操作。原因:head未参与运算,需要考虑empty()的情况
m_size--;
while (m_size--) {
head = head->succ; //使head移动到最后一个节点的位置
delete head->pred;
}
}
delete head;
}
int main() {
List lis;
cout << "头插5个节点" << endl;
for (int i = 0; i < 5; i++)
lis.push_back(i + 1);
cout << "再尾插5个节点:" << endl;
for (int i = 0; i < 5; i++)
lis.push_front(i + 1);
cout << "此时lis大小为:" << lis.size() << endl;
lis.showList();
cout << "迭代18次后的节点数据为:" << lis.getNode(18)->data << endl << endl;
lis.remove(15);
cout << "移除迭代18次后的节点得到:" << endl;
lis.showList();
lis.empty();
cout << "最后清空了链表,此时链表大小:" << lis.size() << endl;
return 0;
}
运行结果:
注意点:
- 要考虑链表大小为0时的析构,此时需要添加一个判断条件,不然会报错。
- 循环链表与普通链表插入时的区别,此版本为head节点为独立的,不参与运算,而前两个例子(单链表和双链表)的两个列表head会参与运算(啊,反思了一天终于明白直接把网上的标准代码复制过来单链表输出就错位了。。。),在题目应用链表时建议使用head不参与运算的链表,这样会大大降低出错和debug的概率。
- 在写代码前应该了解每一个数据结构的细节,比如head是否参与运算,写代码逻辑遇到困难时建议先自己摆弄思考一下,最后再查资料。
典型案例:约瑟夫环
- 解题之前建议先了解一下约瑟夫环背后的故事。
Question:
41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止,求在所有人自杀以前,最后两个位置的id。
在List类体中加入以下成员函数:
void List::JosephCircle(int interval, int remain) { //分别对应:报数间隔/剩余位置
//假设输入所有数据都合法
Node* temp_idx = head->succ; //当前报数个体下一个节点的
Node* killed; //自杀个体指针
int time = 0; //自杀倒计时
while (m_size - remain) {
time++;
temp_idx = temp_idx->succ;
killed = temp_idx->pred;
if (!(time % interval)) { //自杀条件
//包含头结点指向前后节点的两种情况(同remove),操作头结点以免访问不到
if (killed->data == head->succ->data) { head->succ = temp_idx->succ; }
if (killed->data == head->pred->data) { head->pred = temp_idx->pred; }
m_size--;
//cout << killed->data << "自杀,剩余:" << m_size << "人" << endl;
killed->pred->succ = killed->succ;
killed->succ->pred = killed->pred;
delete killed;
time = 0;
}
}
temp_idx = head->succ; //辅助
Node* min = head->succ; //最小位置id
//使得最后结果从小到大输出,而不改变头节点
for (int i = 0; i < this->m_size; i++) {
if ((temp_idx->data) <= min->data) min = temp_idx;
temp_idx = temp_idx->succ;
}
cout << "安全位置id为:" << endl;
for (int i = 0; i < remain; i++) {
cout << min->data << " ";
min = min->succ;
}
}
再初始化一个链表的解决函数:
void slo() {
cout << "约瑟夫环问题:共41人,每报数到第3人此人就自杀,求最后剩余两个未自杀的人id。" << endl;
List l;
int total = 41; //总人数
for (int i = 0; i < total; i++) { //数据初始化
l.push_back(i + 1);
}
l.showList(); cout << endl;
l.JosephCircle(3, 2); //报数间隔:3,最后的安全位置数量:2
}
ok,完美解决,需要注意的是如果删除首尾节点,则需要将head的指针转移一下,以免访问不到,删除head->pred时应将head->pred指向原链表中的head->pred->pred,head->succ同理(该方法remove函数同理,具体见约瑟夫环的代码块)
运行结果:
反思:
通过以上实验可以看出链表可以非常灵活的使用,有时候甚至可以把多个链表组合到一起。如果是链表是单向的,类比火车在铁轨处交叉汇集的场景、从一个线性的链状进入一个循环结构、小时候课桌藏纸条等,可以隐身出更高级的数据结构:树(Tree)和图(Graph)。
也可以自行创新,比如在使用树时,需要再加上其他的成员属性(如在父节点+孩子节点表示法中的parent和child属性等)方便操作,这里不讨论。
【4】静态链表(咕咕咕…)
【5】*跳跃链表(咕咕咕…)
【6】*散链表(咕咕咕…)
参考资料:《数据结构第三版》邓俊辉
若文章中有不足之处,欢迎大家批评指正。