Список в STL представляет собой двунаправленный связанный список с заголовком. После изучения строк и векторов стоимость обучения их интерфейсным функциям становится намного ниже, поэтому я сосредоточиваюсь не на изучении использования списков, а на базовом обучении.
Вопрос о том, является ли итератор недействительным:
Станет ли позиция позиции недействительной после вставки?
Здесь оно не является недействительным. Причина в том, что предпосылка признания недействительной заключается в том, что пространство перемещается или расширяется, чтобы изменить старое пространство и создать новое пространство. Здесь пространство, занимаемое каждыми данными в списке, независимо, и нет перемещения или расширения пространства для создания нового пространства.Поэтому, одним словом, использование вставки в список не имеет проблемы сбоя итератора .
Станет ли позиция позиции недействительной после вставки?
Очевидно, что это недопустимо, поскольку после стирания удаляется весь узел, поэтому позиция после стирания должна быть недействительной .
Может ли связанный список использовать сортировку в библиотеке алгоритмов?
Для начала давайте посмотрим, как используется сортировка в библиотеке алгоритмов:
В сортировке мы можем передать два диапазона итераторов. В чем причина ошибки при передаче двух итераторов списка?
Поскольку в нижней части алгоритма он будет использовать итераторы для выполнения вычитания, что по сути является быстрой сортировкой , поэтому, когда строка или вектор используют сортировку в алгоритме, их итераторы эквивалентны необработанным указателям, и их можно вычесть, чтобы получить количество данных. Но итератор списка инкапсулирован и не может быть вычтен.
Решение состоит в том, чтобы вызвать собственный сорт:
Функция дедупликации единственная в списке:
Суть использования функции дедупликации заключается в сортировке связанного списка. Если он не отсортирован, возникнут проблемы :
void list_test2()
{
list<int> a;
a.push_back(50);
a.push_back(30);
a.push_back(30);
a.push_back(50);
a.push_back(10);
a.push_back(70);
a.unique();
for (auto e : a)
{
cout << e << " ";
}
cout << endl;
}
результат операции:
Результат выполнения неправильный. Когда мы сначала сортируем связанный список и удаляем дубликаты, проблема решена:
Сравнение эффективности сортировки:
vector和list相比,当数据访问量比较大的时候,list的sort效率比vector要低很多,我们宁愿现在vector里面先排序,然后再将数据拷贝到list中的效率都比直接在list排序的效率高。因此list中的sort我们很少用,原因就是效率不太高。
list底层的模拟实现:
首先我们将大框架写出来:
namespace my_list
{
template<class T>
struct Listnode
{
Listnode* _next;
Listnode* _prev;
T _data;
};
template<class T>
class list
{
typedef Listnode<T> Node;
public:
list()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
private:
Node* _head;
};
}
push_back:
尾插的逻辑很简单,用_head->prev找到最后一个节点,再创建一个新节点并把要插入的数据放在这个新节点里面。先将最后一个节点和新结点互相连接起来,最后再将新节点和_head互相连接起来。
void push_back(const T& x)
{
Node* newnode = new Node(x);
newnode->_data = x;
Node* tail = _head->_prev;
tail->_next = newnode;
newnode->prev = tail;
newnode->_next = _head;
_head->_prev = newnode;
}
迭代器:
首先我们需要理解一个迭代器需要哪些特性:
能支持解引用。
能支持++或--。
string和vector的迭代器可以是原生指针就是因为它们原本的结构(数组)能够支持这两个特性。但是list就不行了,list的每个节点的地址是不固定的的,如果用原生指针Node*就不能支持迭代器的两大特性。因此我们可以用类对list的迭代器进行分装,再用运算符重载实现这两大特性:
template<class T>
struct _list_iterator
{
typedef Listnode<T> Node;
_list_iterator(Node* p)
:_pnode(p)
{}
_list_iterator<T>& operator ++()
{
_pnode=_pnode->_next;
return *this;
}
_list_iterator<T>& operator --()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator !=(const _list_iterator<T>& p)
{
return _pnode != p._pnode;
}
T& operator *()
{
return _pnode->_data;
}
Node* _pnode;
};
当我们用封装是结点指针具备了迭代器的特性以后,接下来就可以在list完善迭代器的一些接口函数:
class list
{
typedef Listnode<T> Node;
public:
typedef _list_iterator<T> iterator;
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
}
拷贝构造(传统写法):
如果我们不写拷贝构造,编译器默认形成的是浅拷贝,在使用析构函数的时候同一空间会析构两次。所以我们需要写一个深拷贝的拷贝构造:
list(const list<T> lt)
{
//创建出头节点
_head = new Node(T());
_head->_next = _head;
_head->_prev = _head;
//用范围for遍历尾插
for (auto e : lt)
{
push_back(e);
}
}
首先让我们看上面的代码,看看有什么缺陷?
当我们使用范围for的时候会把*lt的数据传给e,(不是把结点给它),因为数据类型是泛型,可能是sting类型,可能是vector类型,数据可大可小,如果我们不加引用拷贝的成本会大很多。
因为lt是const类型,使用迭代器的时候必须要用const迭代器。push_back中也会用到end(),一个const对象不可以用非const,因为传this指针权限放大。
3.参数如果不加&时,当调用拷贝构造时传入参数是会无限进行拷贝构造变成死循环,程序崩溃。
赋值重载(传统写法)
和拷贝构造一样不写就是浅拷贝,问题多多:
list<T>& operator =(list<T>& lt)
{
if (this != <)
{
//先清除原本数据
clear();
//范围for迭代尾插
for (auto& e : lt)
{
push_back(e);
}
}
return *this;
}
参数不能加const,因为我们还没有实现const迭代器,话不多说我们现在就去搞搞const迭代器:
const迭代器:
首先我们得理解什么是const迭代器中const修饰的是什么?
很明显答案是第一个,const修饰的是迭代器中的数据不能被修改而不是迭代器本身,所以const迭代器不是简单的认为只是在普通迭代器的前面加上const,一下是错误示范:
当我们明白了这个道理,接下来我们制定三种方案进行实现:
方案一:
因为const迭代器不能修改迭代器所指向的值,所以我们可以可以在返回值上做出修改:
这样做很显然还是不可以,原因和上面一样,你想调用下面的const函数你必须使用用const修饰的迭代器,而迭代器前面加const就不能进行++等操作了,所以这种方案不可行。
方案二:
我们可以重新创建一个 _list_const_iterator类,其他的运算符重载和 _list_iterator类中的一样。
template<class T>
struct _list_const_iterator
{
typedef Listnode<T> Node;
_list_const_iterator(Node* p)
:_pnode(p)
{}
_list_const_iterator<T>& operator ++()
{
_pnode = _pnode->_next;
return *this;
}
_list_const_iterator& operator --()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator !=(const _list_const_iterator<T>& p)
{
return _pnode != p._pnode;
}
const T& operator *() //此处修改了返回值,满足了const迭代器的特性
{
return _pnode->_data;
}
Node* _pnode;
};
然后我们在list类中声明一下,并把函数重载搞搞好:
const_iterator begin() const
{
return const_iterator(_head->_next);
}
const_iterator end()const
{
return const_iterator(_head);
}
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
注:在成员函数后面加const,const修饰this指针所指向的对象,也就是保证调用这个const成员函数的对象在函数内不会被改变。构成了函数重载又加强了保护。
方案三:
方案二是普通人写的代码,方案三是大佬写的,大佬认为如果只是改变一个返回值却要大费周章的创建出一个类,实在是显得代码非常的冗余,所以大佬用了模板参数解决了这个问题:
template<class T,class Ref>
struct _list_iterator
{
typedef Listnode<T> Node;
typedef _list_iterator<T, Ref> Self;
_list_iterator(Node* p)
:_pnode(p)
{}
Self& operator ++()
{
_pnode=_pnode->_next;
return *this;
}
Self& operator --()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator !=(const Self& p)
{
return _pnode != p._pnode;
}
Ref& operator *()
{
return _pnode->_data;
}
Node* _pnode;
};
然后在list中用不同的模板参数实例出不同的类:
class list
{
typedef Listnode<T> Node;
public:
typedef _list_iterator<T,T&> iterator;
typedef _list_iterator<T,const T&> const_iterator;
}
拷贝构造(现代写法)
拷贝构造的现代写法的精髓就在于赋用构造函数,首先我们得写出一个合适的构造函数(迭代器区间):
template <class InputIterator>
list(InputIterator first, InputIterator last)
{
empty_initialize();
while (first != last)
{
push_back(*first);
++first;
}
}
然后再写出我们的拷贝构造:
但是以上代码是存在问题的,原因就是没有被初始化(连头节点都没有),当它和tmp交换了以后,局部变了出了作用域会被销毁,tmp会调用它的析构函数,析构函数会清除所有数据并销毁头结点,而tmp交换过以后没有头节点,程序崩溃。以下是正确写法:
void swap(list<T>& lt)
{
std::swap(_head, lt._head);
}
list(const list<T>& lt)
{
empty_initialize();
list<T> tmp(lt.begin(), lt.end());
swap(tmp);
}
赋值重载(现代写法)
list<T>& operator =(const list<T> lt)
{
swap(lt);
return *this;
}
参数没有加引用是为了调用拷贝构造,使之成为一个深拷贝。然后直接swap,使原本的数据丢弃交给lt,lt为局部变量出了作用域就销毁,简称完美。
类名与类型的小结:(不是重点但要了解)
为什么在上面的代码中,参数要加<T>,构造函数不用加?这涉及到类名与类型的区别:
在普通的类中,类名等价于类型。如:vector s1,s1的类型是vector
在类模板中,类名不等价于类型。只有当类名实例化以后才能等价于类型。
如:template<class T>
class vector
{
........
};
vector<T>是类型,vector是类名,它们俩之间不等。
在类模板中,可以用类名代替类型(c++官网上有的代码就是这么写的),但是我们不建议这么做,我们就应该类名是类名,类型是类型区分开来。
重载->运算符:
我们先创建一个结构体Pos,在我们刚刚完成的链表中一个个尾插Pos:
但发现结果并没有很好的输出出来,原因就是我们并没有重载运算符。那如何不通过重载运算符<<让数据很好的输出呢?
方法一:
it是迭代器,*it是迭代器中的数据,也就是Pos这个结构体,然后通过点进行结构体中数据的访问。
方法二:
我们可以重载一个运算符->
有人会困惑,运算符重载返回的是数据的地址,如果想要访问Pos中的数据不应该是
it->->_row吗?这时编译器在这个方面做了一个优化,本来是两个箭头优化成了一个箭头,提高了代码的可读性。接下来我们来验证const变量用上面的运算符是否可行:
我们发现const变量中的数据竟然可以被修改,所以我们重载的运行符不合格。不合格的原因依旧是返回值的问题,可以继续沿用模板参数来解决:
总结:方案二比方案一的代码更加清晰明了,到后面的智能指针等会经常用到->运算符,它这次算是跟大家初次见面了。
类和对象的总结:
没有写析构函数的类,它的拷贝构造和赋值重载都不需要写。原因:不写拷贝构造和赋值重载,编译器默认是浅拷贝,浅拷贝对于没有析构函数的类已经够用了。而有了析构函数,如果不写拷贝和赋值,两个对象就会指向同一块空间,等出了作用域调用各自的析构函数时程序会崩溃。
string、vector、list的相关总结:
vector和list性能的对比:
有人会有疑问删除数据要先找数据啊,那为什么list删除数据是O(1)呢?因为这里的删除数据不包括找数据,默认数据的位置已经有了只需要删除即可。
迭代器失效的问题:
string内部存储结构:
首先我们得需要知道所有的字符并不是储存在string对象中,是存储在堆空间里,对象只是相当于一个指针能够找到开辟的空间。
string内部是由以下成员变量组成的:
在vs2013下,字符小于16就存储在一个buff数组中,大于等于16个就存储在开辟的对空间中。
buffer里面存储数据
堆空间中存储数据
在g++下面,string的存储更是离谱:
在vs下string拷贝构造走的是深拷贝:
在g++下如果我们不对数据进行修改等写入操作,它的拷贝构造走的是浅拷贝:
那么它是如何解决多次析构同一块空间产生的问题呢?
g++下面string的结构中有一个引用计数,如果有多个对象指向同一块空间,引用计数的值会增加,等到只有一个对象指向一个空间时,即引用计数的值为一,才会调用它的析构函数。
在g++下当我们对数据进行写入的时,拷贝构造走的就是深拷贝了:
总结的来说,g++下的string是用写时拷贝完成的。写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。但是g++下的string在特定的场景也会产生很多的问题,有兴趣的话可以去看看大佬的博客:写时拷贝 写时拷贝在读取时的缺陷
到这里list相关知识就全部结束,感谢阅读和支持!