c++编程(15)——list的模拟实现

news2024/11/8 17:07:47

欢迎来到博主的专栏——c++编程
博主ID:代码小豪


文章目录

    • 前言
    • list的数据结构
    • list的默认构造
    • 尾插与尾删
    • iterator
    • 插入和删除
    • 构造、析构、赋值
      • copy构造
      • initializer_list构造
      • operator =
      • 析构函数

前言

受限于博主当前的技术水平,暂时还不能模拟实现出STL当中用到的allocator,所以在这片博客当中,博主更想表达的是list的数据结构,以及list的各个操作涉及的算法,还有list的迭代器是怎样的工作原理。

博主参考的是SGI版本的STL源码,如果你对源码非常感兴趣,可以私聊博主获取源码。

在《vectoe的模拟实现》这一博客当中,我是按照c++在线文档的接口顺序来模拟实现的,但是list的结构要比vector复杂,所以博主选择跳跃式的方法来模拟实现list。

list的数据结构

list是一个带头双向循环链表的数据结构,其结点具有以下结构

  1. 存储数据的val
  2. 指向上一个节点的指针prev
  3. 指向下一个节点的指针next
    在这里插入图片描述

而链表的结构则需要将表头节点和尾结点链接起来,头结点不存储有效数据,只记录前驱节点和后继节点。
在这里插入图片描述

那么我们需要定义两个类,一个类是节点,另外一个类是管理头结点的链表。链表中的成员只需要一个,那就是指向头结点的指针。

	template<class T>
	struct ListNode//结点
	{
		T _data;//数据
		ListNode* _next;//指向下一个节点的指针
		ListNode* _prev;//指向上一个节点的指针
	};

	template<class T>
	class list
	{
		typedef ListNode<T> Node;
		Node* _head;//指向链表头结点
	};

list的默认构造

我们先来为链表和节点设计一个默认构造吧、还有其他的构造等我们实现了list的插入和删除再来实现。

先来想想怎么默认构造链表,根据c++标准,list的默认构造是实例化出一个空表,由于list是一个带头双向循环链表,因此即使list是一个空表,我们也需要生成这个链表的头结点(带头链表的头结点是不计入有效节点的)。

由于头结点不设置有效数据,那么在构造头结点的时候,我们只需要调用节点的默认构造就行。

list()
{
	_head = new Node;//_head指向头结点,头结点是默认构造的Node
	_head->next=_head;
	_head->prev=_head;
}

在这里插入图片描述

那么问题就来了,list的默认构造会调用Node的默认构造,可是Node的默认构造还没有生成呢!

节点的默认构造则是这样,我们允许节点传入一个T类型的参数val,这个val是传递给data的值。但是有参数的构造不属于默认构造,那么我们就为这个val设置一个缺省值吧,由于ListNode是一个模板类,那么data的值可能是各种各样的类,我们应该为这个val设置什么缺省值呢?

用匿名对象T()作为val的缺省值,如果T实例化成了内置类型,那么val的缺省值就是0,如果T实例化成了类类型,比如string。那么val的缺省值就会调用T类型的默认构造。

ListNode(T val = T())
{
	date = val;
	next = nullptr;
	prev = nullptr;
}

尾插与尾删

实际上push_back和pop_back应该放在插入和删除那一部分的,但如果你查看一下c++手册,你会发现insert和erase都需要用到迭代器,但是如果我们要实现迭代器,又需要插入和删除数据才能验证这些迭代器是无误的,那么这就进入了死循环。

好在push_back和push_pop不需要迭代器,那么我们先来实现这两个操作吧。

void push_back(const T& val);
void push_back();

我们先找到当前链表的尾结点,双向链表的尾结点是非常好找的,_head->prev就是尾结点。尾插的操作如下:

(1)new一个新节点newnode
(2)newnode的next指向头结点
(3)newnode的prev指向尾结点
(4)让尾结点的next指向newnode
(5)头结点的prev指向newnode

在这里插入图片描述

void push_back(const T& val)
{
	Node* tail = _head->prev;//找到尾结点
	Node* newnode = new Node(val);
	newnode->next = _head;
	newnode->prev = tail;
	tail->next = newnode;
	_head->prev = newnode;
}

尾删法的操作如下:
(1)找到尾结点tail的前驱节点Prev。
(2)让Prev的next指向_head
(3)让_head的prev指向Prev
(4)delete掉tail节点
在这里插入图片描述
不过有一点要注意,那就是避免对空表进行尾删操作,这是会导致程序报错的,因此我们可以设置一个异常,也可以直接断言。这里博主为了方便就选择断言了

void push_back()
{
	Node* tail = _head->prev;//找到tail节点
	assert(tail != _head);//当尾结点就是头结点时,list为空表
	Node* prev = tail -> prev;//找到prev节点

	prev->next = _head;
	_head->prev = prev;
	delete tail;
}

这个断言其实不太好,等我们实现了list的迭代器后,我们可以让迭代器完成这项工作,即

assert(this->begin()!=this->!end());

iterator

好吧,最终还是来到了最让我头疼的iterator部分,我第一次尝试实现迭代器的时候是非常痛苦的。不过还是从SGI版本的list的迭代器中学到了精妙的编程方式,也深深的感叹c++封装特性的奇妙之处。

在string和vector的模拟实现当中,博主都是粗暴的将容器中的指针当成迭代器来使用,即

typedef T* iterator;

这是因为string和vector的元素的存储方式都是连续存储。这就导致指针的行为逻辑(++,–,判断)都和迭代器一致。因此我们可以直接把指针当做迭代器。

但是list可不是顺序存储的数据结构,如果我们还是让迭代器具有指针的行为逻辑,那么迭代的结果肯定是飞到姥姥家去了。原因也很简单,迭代器需要执行++操作的吧,而指针++是指向顺序存储的下一个元素空间,但是list不是顺序存储,那么迭代器指向的空间就是未使用的空间,那不成野指针了吗。

那么我们该怎么实现list的迭代器呢?SGI板的STL是这么解决的,list的迭代器的主要问题是由于指针的行为不符合list的迭代要求,那么我们将指针封装起来,成为一个类,那么我们不就可以定义operator++,operator–,使得这个指针符合list迭代的需求。简直是一个奇妙的想法。

博主这里直接将这个类模拟实现出来,源码之下没有秘密,千言万语不如让大家亲身感受。

template<class T,class ref,class ptr>
struct listiterator
{
	typedef ListNode<T> Node;
	typedef listiterator self;
	listiterator(Node* listnode=nullptr)
	{
		linkptr = listnode;
	}
	self operator++()//前置++
	{
		linkptr = linkptr->next;
		return linkptr;
	}

	self operator++(int)//后置++
	{
		listiterator tmp = linkptr;
		linkptr = linkptr->next;
		return tmp;
	}

	self operator--()//前置--
	{
		linkptr = linkptr->prev;
		return linkptr;
	}

	self operator--(int)//后置--
	{
		listiterator tmp = linkptr;
		linkptr = linkptr->next;
		return tmp;
	}

	ref operator*()//解引用
	{
		return linkptr->data;
	}

	ptr operator->()//成员访问
	{
		return &(linkptr->data);
	}

	bool operator!=(listiterator it)//判等
	{
		return linkptr != it.linkptr;
	}

	bool operator==(listiterator it)//判等
	{
		return linkptr == it.linkptr;
	}

	Node* linkptr;
};

list的迭代器的设计思路是这样的:由于Node*的指针的行为不符合list对迭代器的需求。那么就把指针封装起来,设计成一个类,这样我们就能在类中重载各种操作符的函数,使这个类符合迭代器的行为。

比如,listiterator的迭代器进行++操作,linkptr并不会指向顺序结构的下一个元素的位置,而是指向节点的后继节点,这就使listiertaor符合list的迭代器行为,然后在list当中将listiterator实例化成iterator。这样就能用迭代器完成迭代操作了。

既然写好了迭代器的类,现在就将迭代器放进list类中吧,顺便再把begin和end实现出来

template<class T>
	class list
	{
	public:
		typedef ListNode<T> Node;
		typedef listiterator<T, T&, T*> iterator;
		typedef listiterator<T, const T&, const T*> const_iterator;
		iterator begin()
		{
			return _head->next;//begin返回第一个有效节点
			//即_head的下一个节点
		}

		const_iterator begin()const
		{
			return _head->next;
		}

		iterator end() 
		{
			return _head;
			//头节点是无效节点,当链表遍历到这里时,即为遍历成功
		}

		const_iterator end() const
		{
			return _head;
		}

	private:
		Node* _head;//指向链表头结点
	};
}

begin()指向list中的第一个有效数据,因此我们返回_head->next.

而end()指向list的无效数据(因为STL规定begin到end的区间是左闭右开[begin,end),这意味着end()返回无效的位置),而头结点保存的是无效数据,因此end()返回头结点_head

我们用范围for测试一下,如果成功,就代表迭代器实现完成了。

void TestMylist1()
{
	list<int> list1;
	list1.push_back(1);
	list1.push_back(2);
	list1.push_back(3);
	list1.push_back(4);

	for (auto& e : list1)
	{
		cout << e << ' ';
	}
}

插入和删除

我们先来写写单元素的insert和erase。来看看c标准定义的函数原型吧。

iterator insert (iterator position, const value_type& val);
iterator erase (iterator position);

insert需要传入迭代器position,在position位置处插入新的节点。具体操作如下:

(1)构造一个值为val的新节点newnode
(2)令position位置的节点为cur,cur的前驱节点为prev
(3)让newnode的next指向cur
(4)让newnode的prev指向prev。
(5)让prev的next指向newnode
(6)让cur的prev指向newnode

在这里插入图片描述
这个position可以是任意位置,包括头结点和尾结点。我们注意insert是存在返回值的,而且返回值是一个迭代器。我们查看c++文档的叙述。这个返回值是指向新插入的节点的迭代器。

iterator insert(iterator position, const T& val)
{
	Node* newnode = new Node(val);
	Node* cur = position.linkptr;
	Node* prev = cur->prev;

	newnode->next = cur;
	newnode -> prev = prev;
	prev->next = newnode;
	cur->prev = newnode;
	return iterator(newnode);
}

erase也是通过迭代器position确定删除的节点位置,具体操作如下:

(1)让position位置的节点为cur
(2)让cur的前驱节点为prev,让position的后继节点为next
(3)让prev的next指向next
(4)让next的prev指向prev
(5)delete掉cur

要注意erase操作不能删除头结点(STL中的list也是这么做的),即pos位置不能是list.end()因此我们在函数的前面加上一句断言。

c++标准当中规定,erase返回被删除节点的下一个节点。因此erase应该实现成这样。

iterator erase(iterator position)
{
	assert(position != end());
	Node* cur = position.linkptr;
	Node* prev = cur->prev;
	Node* next = cur->next;

	next->prev = prev;
	prev->next = next;

	return iterator(next);
}

insert和erase其实是很万能的,如果我们想要头插,可以调用erase(list.begin(),val),如果想要头删,可以调用erase(list.begin())。包括尾插和尾删,我们都可以通过insert和erase实现操作。因此push_back,push_fornt,pop_back,pop_front,都可以复用insert和erase.

		void push_back(const T& val)
		{
			insert(end(), val);
		}

		void pop_back()
		{
			erase(--end());
		}

		void push_front(const T&val)
		{
			insert(begin(), val);
		}

		void pop_front()
		{
			erase(end());
		}

构造、析构、赋值

通常我们在写一个类时,都会先写类的构造、析构、赋值重载函数。但是list有点不同,因为list的大部分构造、析构都依赖迭代器的实现。因此在list的末尾,博主才对这些函数进行实现。

copy构造

copy (4)	
list (const list& x);

list的拷贝构造是深拷贝,浅拷贝会导致内存错误,list深拷贝的方法如下:

我们遍历x,将x的元素按照顺序尾插到list容器中。但是光拷贝可不行啊,容器还没有生成头结点(遍历x的时候可不会遍历头结点,因为头结点被视为容器的终点end)。所以我们先创建一个头结点。

void emptynode()//创建头结点
{
	_head = new Node;//_head指向头结点,头结点是默认构造的Node
	_head->next = _head;
	_head->prev = _head;
}

emptynode函数只用于对象构造时创建空节点,所以我们最好把它隐藏起来(protected或者private)。

		list(const list& l1)
		{
			emptynode();
			for (const auto& e : l1)
			{
				push_back(e);
			}
		}

initializer_list构造

initializer_list是c++11新增的模板类,关于initializer_list大家可以查看c++手册,或者查看博主的另一篇博客:好用的c++11语言特性

initializer list (6)	
list (initializer_list<value_type> il,
       const allocator_type& alloc = allocator_type());

initializer_list可以简单的认为是一个存储数据数组,那么构造的方法也很简单,遍历initializer_list的元素,将元素挨个尾插到容器当中。

list<int> list2(list1);
for (const auto& e : list2)
{
	cout << e << ' ';
}

operator =

赋值操作和拷贝操作的逻辑一致,我直接套用就行。不过这种套用可是单纯的将程序代码复制粘贴过来,我们会利用到一种取巧的方法

const list& operator=(list x)
{
	std::swap(_head, x._head);
	return *this;
}

ok,就是这么一个简短的代码,但是其思想可一点都不简短。

首先是函数的形参,没有继续采取常用的pass by const reference(const T&)。而是选择了pass by value的形式, 这就导致调用operator=函数时,形参x会调用拷贝构造构造拷贝实参。
在这里插入图片描述

然后我们将容器的头结点和x的头结点进行交换,使得容器获得实参的内容。

在这里插入图片描述
ok,我们发现了一个有趣的现象,容器现在结构、数据都和实参一致了,这难道不是完成了复制操作吗?

而且这种方法还有一个特别有意思的地方,x是一个临时变量,这就说明x中的空间是没有实际作用的,我们需要将其空间释放掉,但是x是栈帧中的参数,一旦离开了栈帧,x会调用析构函数将资源依次释放。是不是很神奇,简短的三行代码,竟然进行了这么多的操作。而且效率还不低。

析构函数

好吧,讲到最后才完成析构函数,这其实不是很好的习惯,如果你将析构函数放到最后一步,一旦程序通过了测试,你可能就认为已经完成了所有操作,便遗忘掉了析构函数,那么等到内存泄漏的时候可就麻烦了。

list的析构函数应该完成以下操作,释放所有的有效结点,最后释放头结点。额,实际上c++标准定义了list的成员函数clear,clear会释放除头结点外的所有节点。那么为什么博主没有提到呢?因为博主忘了哈哈哈哈。

我们从第一个有效节点开始遍历,每遍历一个节点就释放该节点的空间,直到我们回到头结点为止。

void clear()
{
	iterator start = begin();
	while (start != end())
	{
		start=erase(start);
	}
}

erase的返回值是被删除节点的下一个节点,正好拿来用了哈哈哈。

诶,我们的析构函数是做什么来着,先释放所有的有效节点,再释放头结点,OK,clear正好是释放所有的有效节点,那我们就直接拿来复用就行了啊。

~list()
		{
			clear();
			delete _head;
			_head = nullptr;
		}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1714757.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

HTTP报文

HTTP报文 报文流 HTTP报文是在HTTP引用程序之间发送的数据块&#xff0c;这些数据块以一种文本形式的元信息开头&#xff0c;这些信息描述了报文的内容和含义&#xff0c;后面跟着可选的数据部分&#xff0c;这些报文在客户端&#xff0c;服务器和代理之间流动。 报文流入源…

Java事务入门:从基础概念到初步实践

Java事务入门&#xff1a;从基础概念到初步实践 引言1. Java事务基础概念1.1 什么是事务&#xff1f;1.2 为什么需要事务&#xff1f; 2. Java事务管理2.1 JDBC 的事务管理2.2 Spring 事务管理2.2.1 Spring JDBC2.2.1.1 添加 Spring 配置2.2.1.2 添加业务代码并测试验证 2.2.2…

在做题中学习(62):矩阵区域和

1314. 矩阵区域和 - 力扣&#xff08;LeetCode&#xff09; 解法&#xff1a;二维前缀和 思路&#xff1a;读题画图才能理解意思&#xff1a;dun点点的是mat中的一个数&#xff0c;而要求的answer同位置的数 以点为中心上下左右延长 k 个单位所围成长方形的和。 因为最后answ…

拷贝构造、移动构造、拷贝赋值、移动赋值

最近在学习C的拷贝构造函数时发现一个问题&#xff1a;在函数中返回局部的类对象时&#xff0c;并没有调用拷贝构造函数。针对这个问题&#xff0c;查阅了一些资料&#xff0c;这里记录整理一下。 调用拷贝构造函数的三种情况&#xff1a; ① 用一个类去初始化另一个对象时&a…

PLC自动化行业的发展前景好吗?

第一先说plc的薪资&#xff1a; 整体的平均薪资还是非常可观的&#xff0c;在1.3w/月左右。 当然PLC是需要经验积累的&#xff0c;尤其需要拥有大型的系统设计经验&#xff0c;那将会在PLC以至于自动化行业都会吃的开。所以待遇是与自身的经验&#xff0c;能力&#xff0c;所在…

Java设计模式:享元模式实现高效对象共享与内存优化(十一)

码到三十五 &#xff1a; 个人主页 目录 一、引言二、享元设计模式的概念1. 对象状态的划分2. 共享机制 三、享元设计模式的组成四、享元设计模式的工作原理五、享元模式的使用六、享元设计模式的优点和适用场景结语 [参见]&#xff1a; Java设计模式&#xff1a;核心概述&…

GDPU Java 天码行空13

&#xff08;一&#xff09;实验目的 1、掌握JAVA中与网络程序开发相关的知识点&#xff1b; 2、理解并掌握网络编程开发思想及方法&#xff1b; 3、熟悉项目开发的分包方法和依据&#xff1b; 4、实现聊天室中客服端和服务器端的实现方法&#xff1b; 5、熟悉多线程程序开发方…

第五届武汉纺织大学ACM程序设计竞赛 个人题解(待补完)

前言&#xff1a; 上周周日教练要求打的一场重现赛&#xff0c;时长五个小时&#xff0c;题目难度还行&#xff0c;除了部分题目前我还写不出来之外&#xff0c;大部分题都写完或补完了&#xff0c;这边给出比赛链接和我的代码&#xff08;有些是队友的&#xff09;和题解。 正…

工作中有哪些超级好用的C/C++程序库?

视频和讲义发布在这里&#xff1a; B站链接

Spring框架温习

Spring Spring是一个全面的、企业应用开发一站式的解决方案&#xff0c;贯穿表现层、业务层、持久层。但是 Spring仍然可以和其他的框架无缝整合。 Spring 特点&#xff1a; 轻量级、控制反转、面向切面、容器、框架集合 Spring 核心组件&#xff1a; Spring 常用模块&…

Leecode热题100---二分查找--4:寻找两个正序数组的中位数

题目&#xff1a; 给定两个大小分别为 m 和 n 的正序&#xff08;从小到大&#xff09;数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。 解法1、暴力解法&#xff08;归并&#xff09; 思路&#xff1a; 合并 nums1&#xff0c;nums2 为第三个数组 排序第三个数…

亚马逊VC账号产品热销,在美国哪些智能小家电产品最好卖?

亚马逊VC账号产品在美国市场的热销&#xff0c;反映了消费者对于特定智能小家电产品的强烈需求。智能小家电产品因其实用性、便捷性和科技感&#xff0c;近年来在美国市场备受追捧。 以下是一些在亚马逊VC账号上热销的智能小家电产品&#xff1a; 智能扫地机器人 这类产品在美…

【传知代码】自监督高效图像去噪(论文复现)

前言&#xff1a;在数字化时代&#xff0c;图像已成为我们生活、工作和学习的重要组成部分。然而&#xff0c;随着图像获取方式的多样化&#xff0c;图像质量问题也逐渐凸显出来。噪声&#xff0c;作为影响图像质量的关键因素之一&#xff0c;不仅会降低图像的视觉效果&#xf…

STM32读写内部FLASH读取芯片id

文章目录 读写内部Flash接线程序编写测试效果补充 读取芯片id代码编写 读写内部Flash 接线 程序编写 首先使用ThisFlash.c来写入flash的基本操作&#xff0c;写入、读取、擦除&#xff0c;然后使用Store.c配合数组来进行主存与flash的交互 ThisFlash.c #include "stm32…

Spring Cloud Gateway 集成 Nacos、Knife4j

目录 1、gateway网关配置1.1 pom 配置2.2 配置文件1.3 yaml 配置 2、其他服务配置2.1 pom 配置2.2 配置文件2.3 yaml 配置 3、界面访问4、其他 官方文档地址&#xff1a;Spring Cloud Gateway集成Knife4j 官方完整源码&#xff1a;https://gitee.com/xiaoym/swagger-bootstrap-…

Redis篇 哈希表在redis中的命令

哈希命令 一.哈希表的基本认识二. 哈希表在redis中的命令1.hset,hget2.hdel3.hkeys,hvals4.hexists5.hgetall6.hmget7.hlen8.hincrby和hincrbyfloat 一.哈希表的基本认识 在JAVA数据结构中&#xff0c;我们就已经接触到了哈希表&#xff0c; 在当时&#xff0c;我们主要用到的哈…

第16章-超声波跟随功能 基于STM32的三路超声波自动跟随小车 毕业设计 课程设计

第16章-超声波跟随功能 无PID跟随功能 //超声波跟随if(HC_SR04_Read() > 25){motorForward();//前进HAL_Delay(100);}if(HC_SR04_Read() < 20){motorBackward();//后退HAL_Delay(100);}PID跟随功能 在pid.c中定义一组PID参数 tPid pidFollow; //定距离跟随PIDpidFol…

CISP难度将加大?还考不考啊...

最新消息&#xff1a;CISP即将调整知识体系大纲&#xff0c;更新题库&#xff0c;后续考试难度加大。 最近几年&#xff0c;CISP改版地比较频繁&#xff0c;难度也在不断上升&#xff0c;因此各位小伙伴有考CISP想法的尽早考。 随着《网络安全法》、《网络空间安全战略》、《…

牛客NC324 下一个更大的数(三)【中等 双指针 Java/Go/PHP/C++】参考lintcode 52 · 下一个排列

题目 题目链接&#xff1a; https://www.nowcoder.com/practice/475da0d4e37a481bacf9a09b5a059199 思路 第一步&#xff1a;获取数字上每一个数&#xff0c;组成数组arr 第二步&#xff1a;利用“下一个排列” 问题解题方法来继续作答&#xff0c;步骤&#xff1a;利用lintc…

oracle12.1 rac 静默dbca教程(百分之百创建成功)

修改原响应文件 [oracleprimary1 database]$ cd response/ [oracleprimary1 response]$ ls dbca.rsp db_install.rsp netca.rsp [oracleprimary1 response]$ vi dbca.rsp 默认即可 数据库类型内存占比按需选择 运行静默创建实例脚本 [oracleprimary1 response]$ dbca -silen…