前言:
在上期,我们学习了STL库中的第一个容器--vector ,今天我将给大家介绍的是 库中的另外一个容器--List。其实,有了之前学习 vector 的知识,对于List 的学习成本就很低了。
目录
(一)基本介绍
1、基本概念
2、list 与 forward_list 的比较
3、特点
(二)list的使用
1、list的构造
2、 list iterator的使用
3、list capacity
4、list element access
5、list modifiers
6、list的迭代器失效
1、失效时机
2、list与vector迭代器失效对比:
1️⃣vector
2️⃣list
(三)list的模拟实现
1、代码展示
2、注意事项
1️⃣【】不能实现访问操作
2️⃣sort()
(四)list与vector的对比
(五)总结
(一)基本介绍
1、基本概念
- list 是可以在常数范围内在任意位置进行插入和删除的序列式容器,并且该容器可以前后双向迭代。
- list 的底层是双向链表结构,双向链表中每个元素存储在互不相关的独立节点中,在节点中通过指针指向 其前一个元素和后一个元素。
2、list 与 forward_list 的比较
list与forward_list非常相似,最主要的区别如下:
- 最主要的不同在于forward_list是单链表,只能朝前迭代,已让其更简单高效。
3、特点
与其他的序列式容器相比(array,vector,deque),list通常在任意位置进行插入、移除元素的执行效率更好。
与其他序列式容器相比,list 和 forward_list 最大的缺陷是不支持任意位置的随机访问:
- 比如:要访问list 的第6个元素,必须从已知的位置(比如头部或者尾部)迭代到该位置,在这段位置上迭代需要线性的时间开销;
- list还需要一些额外的空间,以保存每个节点的相关联信息(对于存储类型较小元素的大list来说这 可能是一个重要的因素)
(二)list的使用
list的文档介绍:文档介绍
list中的接口比较多,此处类似,只需要掌握如何正确的使用,然后再去深入研究背后的原理,已达到可扩展 的能力。以下为list中一些常见的重要接口。
1、list的构造
2、 list iterator的使用
此处,大家可暂时将迭代器理解成一个指针,该指针指向list中的某个节点。
- 1. begin与end为正向迭代器,对迭代器执行++操作,迭代器向后移动
- 2. rbegin(end)与rend(begin)为反向迭代器,对迭代器执行++操作,迭代器向前移动
3、list capacity
4、list element access
5、list modifiers
- list中还有一些操作,需要用到时大家可参阅list的文档说明。
6、list的迭代器失效
前面已经跟大家说过,此处大家可将迭代器暂时理解成类似于指针,迭代器失效即迭代器所指向的节点的无效,即该节点被删除了。
1、失效时机
因为list的底层结构为带头结点的双向循环链表,因此在list中进行插入时是不会导致list的迭代器失效的,只有在删除时才会失效,并且失效的只是指向被删除节点的迭代器,其他迭代器不会受到影响。
⚔️ 注意 ⚔️
迭代器有两种实现方式,具体应根据容器底层数据结构实现:
- 1️⃣原生指针:比如:vector的迭代器就是原生指针;
- 2️⃣将原生指针进行封装,因迭代器使用形式与指针完全相同,迭代器就是自定义类型对原生指针的封装,模拟指针的行为。
2、list与vector迭代器失效对比:
在C++中,当使用STL容器中的元素并且对容器进行修改时,可能会导致迭代器失效。在这种情况下,如果试图继续使用已经失效的迭代器,则程序可能会产生未定义的行为。
1️⃣vector
在使用vector容器时,添加或删除元素可能会导致迭代器失效。
- 这是因为vector容器内部使用动态数组实现,当容器中的元素数量超过了其内存分配的大小时,就需要重新分配一块更大的内存,并将原有元素拷贝到新的内存中;
- 此时,原有的迭代器就无法正确指向其对应的元素,因为元素的位置已经改变了;
- 同样的情况也发生在删除元素时,因为删除元素后,后面的元素会向前移动,导致原有的迭代器失效。
2️⃣list
插入元素不会导致迭代器失效, 删除元素时,只会导致当前迭代 器失效,其他迭代器不受影响。
void Test()
{
int array[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
list<int> ll(array, array+sizeof(array)/sizeof(array[0]));
auto it = ll.begin();
while (it != ll.end())
{
// erase()函数执行后,it所指向的节点已被删除,因此it无效
//在下一次使用it时,必须先给其赋值
ll.erase(it);
++it;
}
}
// 改正
void Test()
{
int array[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
list<int> ll(array, array+sizeof(array)/sizeof(array[0]));
auto it = ll.begin();
while (it != ll.end())
{
ll.erase(it++); // it = ll.erase(it);
}
}
(三)list的模拟实现
要模拟实现list,必须要熟悉list的底层结构以及其接口的含义,通过上面的学习以及之前对vector和string的学习,我相信大家对于这些内容已基本掌握,现在我们来模拟实现list。
1、代码展示
⚔️ 如果大家还有不懂的,可以参考我上一篇文章:vector的基本实现 ⚔️
- 接下来我直接给出代码(这里实现的风格跟上篇实现vector稍微不一样):
#pragma once
namespace zp
{
template<typename T>
class List {
private:
struct Node
{
T _data;
Node* _prev;
Node* _next;
Node(const T& x, Node* p = nullptr, Node* n = nullptr)
:_data(x)
, _prev(p)
, _next(n)
{}
};
public:
class Iterator
{
public:
Iterator(Node* n = nullptr)
:node(n)
{}
T& operator*()
{
return node->_data;
}
Iterator& operator++()
{
node = node->_next;
return *this;
}
Iterator operator++(int)
{
Iterator tmp = *this;
node = node->_next;
//++(*this);
return tmp;
}
Iterator& operator--()
{
node = node->_prev;
return *this;
}
Iterator operator--(int)
{
Iterator tmp = *this;
node = node->_prev;
return tmp;
}
bool operator==(const Iterator& x) const
{
return node == x.node;
}
bool operator!=(const Iterator& x) const
{
return !(*this == x);
}
private:
Node* node;
friend class List<T>;
};
public:
List()
:head(new Node(T()))
, tail(head)
{}
~List()
{
clear();
delete head;
head = nullptr;
}
void push_back(const T& x)
{
insert(end(), x);
}
void push_front(const T& x)
{
insert(begin(), x);
}
void pop_back()
{
erase(--end());
}
void pop_front()
{
erase(begin());
}
Iterator begin() const
{
return head->_next;
}
Iterator end() const
{
return tail;
}
bool empty() const
{
return head->_next == tail;
}
void clear()
{
while (!empty())
{
erase(begin());
}
}
Iterator insert(Iterator it, const T& x)
{
Node* tmp = it.node;
Node* newNode = new Node(x, tmp->_prev, tmp);
tmp->_prev->_next = newNode;
tmp->_prev = newNode;
return newNode;
}
Iterator erase(Iterator pos)
{
Node* tmp = pos.node;
Iterator res(tmp->_next);
tmp->_prev->_next = tmp->_next;
tmp->_next->_prev = tmp->_prev;
delete tmp;
return res;
}
private:
Node* head;
Node* tail;
};
void print_list(const list<int>& lt)
{
list<int>::const_iterator it = lt.begin();
while (it != lt.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
void test_1()
{
list<int> ll;
ll.push_back(1);
ll.push_back(2);
ll.push_back(3);
ll.push_back(4);
ll.push_back(5);
list<int>::iterator it = ll.begin();
while (it != ll.end())
{
(*it) *= 2;
cout << *it << " ";
++it;
}
cout << endl;
list<int>::iterator pos = find(ll.begin(), ll.end(), 4);
if (pos != ll.end())
{
ll.insert(pos, 10);
}
for (auto& e : ll)
{
cout << e << " ";
}
cout << endl;
it = ll.begin();
++it;
ll.erase(it);
}
void test_2()
{
list<int> ll;
ll.push_back(1);
ll.push_back(2);
ll.push_back(3);
ll.push_back(4);
ll.push_back(5);
for (auto e : ll)
{
cout << e << " ";
}
cout << endl;
auto pos = ll.begin();
++pos;
ll.insert(pos, 20);
for (auto e : ll)
{
cout << e << " ";
}
cout << endl;
ll.push_back(100);
ll.push_front(1000);
for (auto e : ll)
{
cout << e << " ";
}
cout << endl;
ll.pop_back();
ll.pop_front();
for (auto e : ll)
{
cout << e << " ";
}
cout << endl;
}
void test_3()
{
list<int> ll;
ll.push_back(1);
ll.push_back(2);
ll.push_back(3);
ll.push_back(4);
ll.push_back(5);
for (auto e : ll)
{
cout << e << " ";
}
cout << endl;
ll.clear();
for (auto e : ll)
{
cout << e << " ";
}
cout << endl;
ll.push_back(0);
ll.push_back(5);
ll.push_back(10);
ll.push_back(15);
for (auto e : ll)
{
cout << e << " ";
}
cout << endl;
}
}
- 上述代码我定义了一个List类,其中包含了一个嵌套的Node结构体用于表示双向链表的节点。使用模板实现泛型数据类型,可以存储任意类型的数据。List类还包含了一个嵌套的【Iterator】类,用于遍历双向链表。
2、注意事项
1️⃣【】不能实现访问操作
前面已经说过list是链表的结果,因此大家不难理解为什么在list下【】不能访问元素。由于list采用的是链接存储而不是连续存储,所以本质上它不支持下标方式(也就是按偏移量)访问。
2️⃣sort()
其次就是list的排序操作,我们还是对比之前学习的vector
在 C++ 中,【list
】和 【vector
】是两种不同的数据结构,它们都提供了 sort()
函数来对其中的元素进行排序。但是,它们的底层实现不同,因此其 sort()
函数的效率也不同
①vector
- 对于
vector
来说,它是一个基于连续内存空间的动态数组,其元素存储在连续的内存块中,因此可以通过指针算术运算等方式快速访问其中的元素。 - 由于其元素是连续存储的,因此对其进行排序时可以使用标准库提供的
std::sort()
算法,该算法时间复杂度为 O(NlogN),其中 N 为元素个数。因此,vector
的sort()
函数效率较高。
②list
- 而对于
list
来说,它是一个基于双向链表的容器,其元素并不是连续存储的,因此不能像vector
那样直接进行指针算术运算访问其中的元素。 - 由于其底层实现的原因,
list
并没有提供sort()
函数,但是可以通过调用标准库提供的std::list::sort()
成员函数来对其中的元素进行排序。 - 该函数采用的是归并排序(Merge Sort)算法,时间复杂度为 O(NlogN)。
- 由于归并排序需要频繁的进行链表节点的指针操作,因此相较于
vector
的sort()
函数而言,list
的sort()
函数效率较低。
接下来,我们通过代码来简单的看看到底怎么样
- 代码一:
void test_op()
{
srand(time(0));
const int N = 1000000;
vector<int> v;
v.reserve(N);
list<int> lt2;
for (int i = 0; i < N; ++i)
{
auto e = rand();
v.push_back(e);
lt2.push_back(e);
}
int begin1 = clock();
sort(v.begin(), v.end()); //对vector进行sort,调用的是算法里的快排
int end1 = clock();
int begin2 = clock();
lt2.sort(); //对list也进行sort
int end2 = clock();
printf("vector sort:%d\n", end1 - begin1);
printf("list sort:%d\n", end2 - begin2);
}
解释说明:
- 首先我们给出一个测试用例 N(我这里赋值的为100w),产生了100w 个随机值;
- 其中一个放到vector里面,一个放到list里面;
- 分别对list和vector进行sort,对比它们的性能看差别有多大
- 注意:是在release模式下进行查看
结果如下:
结果:
- 经过几次反复的验证,我们可以发现,vector的效率比list 的效率打上三四倍的样子,如果在工程里这是一个巨大的差距
- 代码二:
void test_op()
{
srand(time(0));
const int N = 1000000;
vector<int> v;
v.reserve(N);
list<int> lt1;
list<int> lt2;
for (int i = 0; i < N; ++i)
{
auto e = rand();
lt1.push_back(e);
lt2.push_back(e);
}
// 拷贝到vector排序,排完以后再拷贝回来
int begin1 = clock();
for (auto e : lt1)
{
v.push_back(e);
}
sort(v.begin(), v.end());
size_t i = 0;
for (auto& e : lt1)
{
e = v[i++];
}
int end1 = clock();
int begin2 = clock();
lt2.sort();
int end2 = clock();
printf("vector sort:%d\n", end1 - begin1);
printf("list sort:%d\n", end2 - begin2);
}
解释说明:
- 我这里给出两个list;
- 对于给出的list1,我们先把它拷贝到vector中,排序完成之后在拷贝回来;
- 而对于list2,我们直接进行sort排序;
- 对比上述的两个操作,看效率如何
结果如下:
结果:
- 不难发现,list1拷贝到vector里面在排,排序完成之后在拷贝回来都比list2要快,因此不难发现list下sort() 的效率较差。因此很少用,在上述实现中也没有写。
小结
- 对于需要频繁进行元素访问操作的情况,建议使用
vector;
- 而对于需要频繁进行元素插入、删除和排序等操作的情况,建议使用
list
。
(四)list与vector的对比
(五)总结
到此,便是本期的全部知识内容了,感谢大家的阅读与支持!!!