C++ "链链"不忘@必有回响之双向链表
C++ "链链"不忘@必有回响之双向链表
1. 前言
写过一篇与单链表
相关的博文(https://blog.51cto.com/gkcode/5681771)
,实际应用中,双向循环链表的功能更强大。
单链表中,查询一个已知结点的后驱结点的时间复杂度为O(1)
。因结点本身不存储与前驱结点相关的地址信息,查询前驱结点需要从头结点扫描一次,所以时间复杂度是O(n)
。
双向链表
在结点类型中增加了可以存储前驱结点地址的指针位,如下图所示:
如此,无论查询已知结点的后驱结点还是前驱结点的时间复杂度都是O(1)
,缺点是需要付出空间上的代价。
在权衡算法性能时,会优先考虑时间复杂度的优劣,往往会采用空间换时间的策略。
结点的类型定义:
typedef int dataType;
//结点
struct LinkNode {
//数据成员
dataType data;
//后驱结点的地址
LinkNode *next;
//前驱结点的地址
LinkNode *pre;
//构造函数
LinkNode(dataType data) {
this->data=data;
this->next=NULL;
this->pre=NULL;
}
};
2. 双向链表
双向链表
中除了有存储头结点的head
指针变量外,一般还会增加一个存储尾结点的tail
指针变量。这样,可以实现从头至尾或从尾至头对链表进行遍历。
为了操作的方便,初始化链表时都会提供一个空白的头结点作为标识结点。
在双向链表
中,如果尾结点的后驱指针位存储头结点地址,头结点的前驱指针位存储尾结点地址,如此形成一个首尾相连的闭环,则称此链表为双向循环链表
。
双向链表需要提供对结点上的数据进行常规维护的操作,如:
- 链表初始化。
- 创建链表。
- 查找。
- 后插入、前插入。
- 删除。
- ……
算法的整体思路和单链表相似,因结点中多了一个前驱结点信息,为各种操作带来便利的同时,也多了需要关注的细节。
下文将介绍双向链表中的几个重要函数。
2.1 初始化
如果是双向循环链表,初始化时:
head
和tail
指向空白头结点。- 且
head
的前驱结点和tail
的后驱结点也指向空白头结点。这个过程也可以放到创建链表时实现。
class LinkList {
private:
//头指针
LinkNode *head;
//尾指针
LinkNode *tail;
//链表的长度
int length;
public:
//构造函数
LinkList() {
this->initLinkList();
}
//初始化链表
void initLinkList() {
//头指针存储空白头结点地址
this->head=new LinkNode(0);
//尾指针和头指针位置相同
this->tail=this->head;
//尾结点的后驱结点是头结点
this->tail->next=this->head;
//头结点的前驱结点是尾结点
this->head->pre=this->tail;
}
//……其它函数
2.2 创建链表
可以使用头部插入或尾部插入算法创建链表,本文仅介绍尾部插入创建算法,头部创建算法可自行了解。如下演示使用尾部创建法构建以数列{7,3}
为数据的链表。
- 创建值为
7
的新结点n
。
- 设置新结点
n
的前驱结点为原尾结点。
n->pre=tail;
- 设置新结点
n
的后驱结点为原尾结点的后驱结点。
n->next=tail->next;
- 设置原尾结点的后驱结点为新结点
n
。
tail->next=n;
- 新结点
n
成为新的尾结点。
tail=n;
- 设置头结点的前驱结点为新的尾结点。
head->pre=tail;
重复上述流程,最终完成链表的创建过程。
是否可以无视上述创建流程中的顺序?
全局而言,顺序基本是要遵循上述的要求,原则是新结点->尾结点->头结点
。
- 新结点:先设置新结点的前驱和后驱结点的地址。新结点的相应存储位都是空白的,先设置前驱还是后驱不影响结果。
- 尾结点:修改原尾结点的后驱结点地址为新结点,原尾结点的使命完成后,再指定新结点为新的尾结点。
- 头结点:修改头结点的前驱地址为新尾结点。
编码实现:
//尾部插入方式创建链表
void createFromTail(int n) {
LinkNode *newNode,*p,*tail;
//头结点地址
p=this->head;
//尾结点地址
tail=this->tail;
for(int i=0; i<n; i++) {
//构建一个新结点
newNode=new LinkNode(0);
cout<<"请输入结点数据"<<endl;
cin>>newNode->data;
//原来的尾结点成为新结点的前驱结点
newNode->pre=tail;
//新结点的后驱结点为原来的尾结点的后驱结点
newNode->next=tail->next;
//原尾结点的后驱结点为新结点
tail->next=newNode;
//新结点成为新的尾结点
tail=newNode;
//头结点的前驱为新尾结点
head->pre=tail;
}
this->head=p;
this->tail=tail;
}
测试尾部创建:
int main(int argc, char** argv) {
LinkList list {};
list.createFromTail(2);
//没删除之前
cout<<"显示创建结果:"<<endl;
LinkNode *head= list.getHead();
cout<<"从头结点向尾结点方向搜索:"<<endl;
cout<<head->next->data<<endl;
cout<<head->next->next->data<<endl;
LinkNode *tail=list.getTail();
cout<<"从尾结点向头结点方向搜索 :"<<endl;
cout<<tail->data<<endl;
cout<<tail->pre->data<<endl;
head=tail->next;
cout<<"从尾结点的后驱信息位得到头结点信息 :"<<endl;
cout<<head->next->data<<endl;
cout<<head->next->next->data<<endl;
return 0;
}
执行结果:
2.3 查找
因双向循环链表
的头尾是相连的,其查询方案可以有多种变化:
- 按位置查找: 按位置查找建议从头结点开始。
- 按值查找: 按值查找可以从头结点或从尾结点开始。
- 查询所有: 查询所有可以从头结点也可以从尾结点开始。
2.3.1 按位置查找
设空白头结点编号为0
,从头结点向尾结点扫描过程中,给扫描到的结点依次编号,当查询到和指定参数相同的编号时停止。
//按位置查询结点(从头部扫描到尾部)
LinkNode * findNodeByIndex(int index) {
int j=0;
LinkNode *p=this->head;
if(index==j)
//如果 index 值为 0 ,返回空白头结点
return p;
//第一个数据结点
p=p->next;
//设置第一个数据结点的编号为 1 ,当然这不是绝对,可以根据自己的需要设置编号
j=1;
while(p!=this->head && j<index) {
p=p->next;
j++;
}
if(j==index)return p;
else return NULL;
}
2.3.2 按值查找
按值查找可以有 2
种方案:
- 头结点向尾结点方向查找。
//按值查询结点
LinkNode * findNodeByVal(dataType val) {
//从第一个数据结点开始查找
LinkNode *p=this->head->next;
//当 p 再次指向头结点结束查找,这也空白结点存在的意义
while(p!=this->head && p->data!=val ) {
p=p->next;
}
if(p!=this->head) {
return p;
} else {
return NULL;
}
}
- 尾结点向头结点方向查找。
LinkNode * findNodeByValFromTail(dataType val) {
//从尾结点开始查找
LinkNode *p=this->tail;
while(p!=this->head && p->data!=val ) {
//向头结点方向
p=p->pre;
}
if(p!=this->head) {
return p;
} else {
return NULL;
}
}
2.3.3 查询所有
- 从头结点向尾结点方向查询所有结点。
void showSelf() {
if(this->head==NULL)return;
//得到第一个数据结点
LinkNode *p=this->head->next;
while(p!=this->head) {
cout<<p->data<<"\t";
p=p->next;
}
}
- 从尾结点向头结点方向查询所有结点。
void showSelf_() {
if(this->tail==NULL)return;
LinkNode *p=this->tail;
while(p!=this->head) {
cout<<p->data<<"\t";
p=p->pre;
}
}
2.4 插入
插入有前插入和后插入 2
种方案,于双向链表而言,其时间复杂度都为O(1)
。
2.4.1 后插入
把新结点插入到已知结点的后面,称为后插入,其插入流程如下所示:
- 找到已知结点
p
,创建新结点n
。
- 设置
n
结点的前驱结点为已知结点p
,设置其后驱结点为已知结点p
的后驱结点。
n->pre=p;
n->next=p->next;
- 设置
p
结点的后驱结点为n
结点。
p->next=n;
- 设置结点
n
为其后驱结点的前驱结点。
n->next->pre=n;
编码实现:
//后插入
int instertAfter(dataType val,dataType data) {
//按值查找到结点
LinkNode *p=this->findNodeByVal(val);
if (p==NULL) {
//结点不存在,返回 false
return false;
}
//创建新结点
LinkNode *n=new LinkNode(0);
n->data=data;
//设置 p 结点为新结点的前驱结点
n->pre=p;
//新结点的后驱结点为已知结点 p 的后驱结点
n->next=p->next;
//已知结点的后驱结点为新结点
p->next=n;
//已知结点的原后驱结点的前驱结点为新结点
n->next->pre=n;
return true;
}
测试后插入:
int main(int argc, char** argv) {
LinkList list {};
//创建 7,3 两个结点
list.createFromTail(2);
//在结点 7 后面插入值为 9 的结点
list.instertAfter(7,9);
list.showSelf();
return 0;
}
执行结果:
2.4.2 前插入
把新结点插入到已知结点的前面,称为前插入,因结点有前驱结点的地址信息,双向链表的前或后插入都较方便。
- 找到已知结点
p
,创建新结点n
。
- 设置结点
n
的前驱结点为p
的前驱结点,设置其后驱结点为p
结点。
n->pre=p->pre;
n-next=p;
- 设置
p
结点的前驱结点的后驱结点为n
。
p->pre->next=n;
或
n->pre->next=n;
- 设置
p
结点的前驱结点为n
结点。
p->pre=n;
编码实现:
//前插入
int insertBefore(dataType val,dataType data) {
//按值查找到结点
LinkNode *p=this->findNodeByVal(val);
if (p==NULL)
return false;
//查找前驱结点
LinkNode *p1=this->head;
while(p1->next!=p) {
p1=p1->next;
}
//构建新结点
LinkNode *n=new LinkNode(0);
n->data=data;
//新结点的后驱为 p 结点
n->next=p;
//新结点的前驱为 p 的前驱
n->pre=p->pre;
//p 的前驱结点的后驱结点为 n
p->pre->next=n;
//p 的前驱结点为 n
p->pre=n;
return true;
}
测试前插入:
int main(int argc, char** argv) {
LinkList list {};
//创建 7,3 两个结点
list.createFromTail(2);
//在值为 7 的结点前面插入值为 9 的结点
list.insertBefore(7,9);
list.showSelf();
return 0;
}
执行结果:
2.5 删除
2.5.1 删除结点
删除已知结点的基本操作流程:
- 查找到要删除的结点
p
。
- 找到结点
p
的前驱结点,设置其后驱结点为p
的后驱结点。
p->pre->next=p->next;
- 找到
p
结点的后驱结点,设置其前驱结点为p
结点的前驱结点。删除p
结点。
p->next->pre=p->pre;
delete p;
编码实现:
int delNode(dataType data) {
//按值查找到要删除的结点
LinkNode *p= this->findNodeByVal(data);
if (p==NULL)return false;
//设置 p 的前驱结点的后驱结点
p->pre->next=p->next;
p->next->pre=p->pre;
delete p;
return true;
}
测试删除操作:
LinkList list {};
//创建 {7,3,9} 3个结点
list.createFromTail(3);
//LinkNode *res= list.findNodeByValFromTail(4);
list.delNode(3);
list.showSelf();
执行结果:
2.5.2 删除所有结点
编码实现:
void delAll() {
LinkNode *p=this->head->next;
//临时结点
LinkNode *p1;
while(p!=this->head) {
//保留删除结点的后驱结点
p1=p->next;
delete p;
p=p1;
}
this->head=NULL;
}
3. 算法案例
界定数列
的要求:对于一个无序数列,首先在数列中找出一个基数,然后以基数为分界点,把小于基数的数字放在基数前面,反之放在后面。
3.1 演示流程
使用双向循环链表
实现界定数列的流程。
- 已知的无序数列:
- 选择基数。这里选择第一个数字
7
作为基数。保存在临时变量tmp
中。声明2
个变量left
、right
,分别指向第一个数据和最后一个数据。
- 从
right
位置开始扫描整个数列,如果right
位置的数字大于tmp
中的值,则继续向左移动right
指针直到遇到比tmp
中值小的数字,然后保存到left
位置。
- 对
left
指针的工作要求:当所处位置的数字比tmp
值小时,则向右边移动直到遇到比tmp
值大的数字,然后保存至right
。
- 重复上述过程,直到
left
和right
指针重合。
- 最后把
tmp
中的值复制到left
和right
指针最后所指向的位置。最终实现以数字7
界定整个数列。
3.2 算法实现
使用双向链表实现上述需求:
- 初始化链表,并以尾部插入方式(保证数列的逻辑顺序和物理顺序一致)创建数列
{7,3,1,9,12,5,8}
。
int main(int argc, char** argv) {
LinkList list {};
list.createFromTail(7);
//没删除之前
cout<<"显示创建结果:"<<endl;
list.showSelf();
return 0;
}
执行后结果:
- 编写界定算法。
void baseNumBound() {
//第一个数据结点的数据作为界定数字
int tmp=this->head->next->data;
//左指针,指向第一个数据结点
LinkNode *left=this->head->next;
//右指针,指向尾结点
LinkNode *right=this->tail;
while(left!=right) {
while(left!=right && right->data>tmp) {
//右指针向左移动
right=right->pre;
}
left->data=right->data;
while(left!=right && left->data<tmp) {
//左指针向右移动
left=left->next;
}
right->data=left->data;
}
left->data=tmp;
}
测试代码:
int main(int argc, char** argv) {
LinkList list {};
list.createFromTail(7);
//没删除之前
cout<<"显示链表的创建结果:"<<endl;
list.showSelf();
list.baseNumBound();
cout<<"\n显示界定后的数列:"<<endl;
list.showSelf();
return 0;
}
执行结果:
使用双向循环链表,实现界定数列简单、明了。
4. 总结
双向链表的结点多了一个前驱指针位,对其内部数据的维护提供了大大的便利。对于程序而言,已知数据越多,算法也将会有更大灵活伸缩空间。
C++ "链链"不忘@必有回响之双向链表的更多相关文章
- C++ "链链"不忘@必有回响之单链表
1. 前言 数组和链表是数据结构的基石,是逻辑上可描述.物理结构真实存在的具体数据结构.其它的数据结构往往在此基础上赋予不同的数据操作语义,如栈先进后出,队列先进先出-- 数组中的所有数据存储在一片连 ...
- BZOJ2157: 旅游 树链剖分 线段树
http://www.lydsy.com/JudgeOnline/problem.php?id=2157 在对树中数据进行改动的时候需要很多pushdown(具体操作见代码),不然会wa,大概原因 ...
- NGK公链如何构建区块链数字经济商业帝国?
2020年对于区块链市场来说,重大的利好消息莫过于NGK公链的上线了.NGK公链其广泛的市场前景.顶尖的技术,一直备受众多大型机构以及投资者所看好.同时,NGK公链也不负众望,在上线以后,就开始落地到 ...
- atitit.设计模式(1)--—职责链模式(chain of responsibility)最佳实践O7 日期转换
atitit.设计模式(1)---职责链模式(chain of responsibility)最佳实践O7 日期转换 1. 需求:::日期转换 1 2. 可以选择的模式: 表格模式,责任链模式 1 3 ...
- Js作用域链及变量作用域
要理解变量的作用域范围就得先理解作用域链 用var关键字声明一个变量时,就是为该变量所在的对象添加了一个属性. 作用域链:由于js的变量都是对象的属性,而该对象可能又是其它对象的属性,而所有的对象都是 ...
- atitit.(设计模式1)--—职责链(chain of responsibility)最佳实践O7 转换日期
atitit.设计模式(1)---职责链模式(chain of responsibility)最佳实践O7 日期转换 1. 需求:::日期转换 1 2. 能够选择的模式: 表格模式,责任链模式 1 3 ...
- js学习--变量作用域和作用域链
作为一名菜鸟的我,每天学点的感觉还是不错的.今天学习闭包的过程中看到作用域与作用域链这两个概念,我觉得作为一名有追求的小白,有必要详细了解下. 变量的作用域 就js变量而言,有全局变量和局部变量.这里 ...
- [kuangbin]树链剖分 D - 染色
https://vjudge.net/contest/251031#problem/Dhttps://blog.csdn.net/kirito_acmer/article/details/512019 ...
- KVM虚拟机快照链创建,合并,删除及回滚研究
1 QEMU,KVM,libvirt关系 QEMU QEMU提供了一个开源的服务器全虚拟化解决方案,它可以使你在特定平台的物理机上模拟出其它平台的处理器,比如在X86 CPU上虚拟出Power的CPU ...
随机推荐
- Halcon图片标定,使得后续图片处理过后变成与模板图片一样
随便选择一张图片 对这张图片进行旋转矫正之后,图片就变成了一个模板图片.它的区域region位置如图所示: 当来了一张新的图片的时候,让它与region比较,与模板的位置有明显的偏差, 如图所示: ...
- Tapdata PDK 生态共建计划启动!Doris、OceanBase、PolarDB、SequoiaDB 等十余家厂商首批加入
2022年4月7日,Tapdata 正式启动 PDK 插件生态共建计划,致力于全面连接数据孤岛,加速构建更加开放的数据生态,以期让各行各业的使用者都能释放数据的价值,随时获取新鲜的数据.截至目前, ...
- 相约 DTCC 2021 | Tapdata 受邀分享:如何打造面向 TP 业务的数据平台架构
2021第十二届中国数据库技术大会(DTCC)将于2021年10月18-20日,在北京国际会议中心举行,Tapdata 创始人唐建法受邀分享:如何打造面向 TP 业务的数据平台架构. 演讲时间 ...
- Elasticsearch深度应用(下)
Query文档搜索机制剖析 1. query then fetch(默认搜索方式) 搜索步骤如下: 发送查询到每个shard 找到所有匹配的文档,并使用本地的Term/Document Frequer ...
- Qucs初步使用指南(不是multism)
众所周知,Multism是一款强大的电路仿真软件,学习电子电路的同学都会接触到. 但是,这软件不支持Linux.(这就很魂淡了啊) 我的主力机是Linux,不能进行电路仿真成了学习的最大障碍. 使用w ...
- Eolink 全局搜索介绍【翻译】
随着前后端分离成为互联网项目开发的标准模式, API 成为了前后端联通的桥梁.而面对越来越频繁和复杂的调用需求,项目里的 API 数量也越来越多,我们需要通过搜索功能来快速定位到对应的 API来进行使 ...
- python第三方模块与内置模块
目录 openpyxl模块 random随机模块 hashlib加密模块 subprocess模块 logging模块 openpyxl模块 1.读取:openpyxl不擅长读数据 所以有一些模块优化 ...
- kubernetes之DaemonSet以及滚动更新
1.什么是DaemonSet? 1.1DaemonSet是Pod控制器的又一种实现方式,用于在集群中的全部节点上同时运行一份指定的Pod资源副本,后续加入集群的节点也会自动创建一个相关的Pod对象,当 ...
- 四位一体水溶交融,Docker一拖三Tornado6.2 + Nginx + Supervisord非阻塞负载均衡容器式部署实践
原文转载自「刘悦的技术博客」https://v3u.cn/a_id_203 容器,又见容器.Docker容器的最主要优点就在于它们是可移植的.一套服务,其所有的依赖关系可以捆绑到一个独立于Linux内 ...
- SpringBoot定时任务 - 经典定时任务设计:时间轮(Timing Wheel)案例和原理
Timer和ScheduledExecutorService是JDK内置的定时任务方案,而业内还有一个经典的定时任务的设计叫时间轮(Timing Wheel), Netty内部基于时间轮实现了一个Ha ...