Детальный анализ списка в STL

Список в 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;
        }

迭代器:

首先我们需要理解一个迭代器需要哪些特性:

  1. 能支持解引用。

  1. 能支持++或--

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);
            }
        }

首先让我们看上面的代码,看看有什么缺陷?

  1. 当我们使用范围for的时候会把*lt的数据传给e,(不是把结点给它),因为数据类型是泛型,可能是sting类型,可能是vector类型,数据可大可小,如果我们不加引用拷贝的成本会大很多。

  1. 因为lt是const类型,使用迭代器的时候必须要用const迭代器。push_back中也会用到end(),一个const对象不可以用非const,因为传this指针权限放大

3.参数如果不加&时,当调用拷贝构造时传入参数是会无限进行拷贝构造变成死循环,程序崩溃。

赋值重载(传统写法)

和拷贝构造一样不写就是浅拷贝,问题多多:

list<T>& operator =(list<T>& lt)
        {
            if (this != &lt)
            {
                //先清除原本数据
                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相关知识就全部结束,感谢阅读和支持!

Supongo que te gusta

Origin blog.csdn.net/m0_69005269/article/details/128758378
Recomendado
Clasificación