C++ STL list 容器详解:使用与模拟实现

· C++编程教程

在C++ STL的序列式容器当中,list是一款特点很突出的容器,它底层靠着带头双向循环链表实现,和平时经常用到的vector相比,不管是底层结构还是运行性能都有很大区别,很多刚接触STL的新手大多只会熟练使用vector,不光对list的基础用法、底层原理了解不透彻,还常常在迭代器失效、插入删除数据的场景里频繁出错。

这篇博客会从基础内容讲起,全面梳理list容器的核心特点、常用接口用法,再带着大家手写代码模拟实现list的底层逻辑,彻底吃透它的底层运行规则,方便大家日后在项目开发里精准挑选容器、躲开各类常见问题。

一、list容器核心认知

1.1 底层结构

list的底层是带哨兵位头节点的双向循环链表,每一个单独的节点都包含数据存储区、前驱节点指针、后继节点指针这三个部分。

这种链表结构主要有这几个特点:物理内存空间不是连续的,节点分散在堆区开辟空间,靠着指针完成各个节点之间的连接,既能顺着链表从前往后遍历,也能倒着从后往前遍历,头节点的存在还简化了边界情况的处理,空链表时头节点的前驱和后继指针都会指向自己,而且在任意位置插入、删除数据的效率都很高,对应的时间复杂度为O(1)。

1.2 list与vector核心对比

不少新手都分不清vector和list到底该用在什么场景,接下来就简单说说两者的主要差异,vector底层是动态数组,内存空间连续排布,支持随机访问也就是通过下标[]、at()方法查找数据,但在链表头部、中间位置插入或者删除数据时,需要挪动大量元素导致整体效率偏低,而list不支持随机访问,没办法直接通过下标定位元素,不过在任意位置增删数据都不用挪动内容,只需要修改指针指向,运行效率要比vector高出不少。

简单概括两者的适用场景:如果需要频繁随机访问数据、只在尾部做插入删除操作,优先选用vector;如果需要经常在任意位置增删元素、对随机访问没有需求,优先选用list。

1.3 迭代器特性

list的迭代器属于双向迭代器,只支持++、--这类单步移动操作,不支持+、-、+=、-=这类随机跳转操作,这也是list无法实现随机访问的直接原因。

除此之外list迭代器还有一个关键优势,那就是插入操作不会让任何迭代器失效,只有删除操作会让指向被删节点的迭代器失效,其余迭代器都不会受到任何影响,这一点和vector区别很大,vector插入数据扩容后所有迭代器都会直接失效,这一点大家一定要牢牢记住。

二、list常用接口使用

使用list容器之前需要先包含头文件,该容器定义在std命名空间下,接下来就逐一讲解各类常用接口的使用方法。

2.1 构造函数

list自带多种构造方式,能够适配不同场景下的初始化需求,包括无参构造创建空链表、传入n和val创建含n个val值元素的list、借助其他容器或数组的迭代器区间完成初始化、通过已有list对象拷贝构造新对象这几种常用方式。

2.2 迭代器相关接口

迭代器是遍历list容器的唯一方式,常用的迭代器接口有获取首个有效元素迭代器的begin()、获取头节点迭代器的end()、专供const对象使用的cbegin()/cend(),以及用来反向遍历list的rbegin()/rend()。

遍历代码示例:

void test_list() {
    list<int> lt = {1,2,3,4,5};
    // 正向迭代器遍历
    list<int>::iterator it = lt.begin();
    while (it != lt.end()) {
        cout << *it << " ";
        ++it;
    }
    cout << endl;

    // 范围for遍历(底层也是迭代器)
    for (auto& e : lt) {
        cout << e << " ";
    }
    cout << endl;
}

2.3 容量操作

list的容量操作接口很简单,empty()用来判断容器是否为空,为空返回true反之返回false,size()则用来获取容器内部有效元素的总数量,同时要注意list没有capacity接口,因为它不需要提前预留内存空间,每次插入数据都会动态新建节点。

2.4 元素访问

list不支持随机访问,只能获取链表开头和结尾的元素,对应front()返回首个元素的引用,back()返回末尾元素的引用。

2.5 插入删除操作

插入删除操作是list最核心的优势,这类操作的效率远高于其他容器,主要分为头尾增删和指定位置增删两类,头尾操作包含尾部插入的push_back()、尾部删除的pop_back()、头部插入的push_front()、头部删除的pop_front(),指定位置操作则有insert()插入和erase()删除两种方法。

这里要重点提醒大家,删除元素时一定要接收erase的返回值,不然很容易出现迭代器失效的问题,甚至导致程序直接崩溃,大家可以对照错误写法和正确写法,理清迭代器的正确使用逻辑。

// 错误写法:迭代器失效后++,程序崩溃
auto it = lt.begin();
while (it != lt.end()) {
    if (*it % 2 == 0) {
        lt.erase(it);
    }
    ++it;
}

// 正确写法
auto it = lt.begin();
while (it != lt.end()) {
    if (*it % 2 == 0) {
        it = lt.erase(it);
    } else {
        ++it;
    }
}

其余常用接口

list还有两个常用辅助接口,clear()可以清空容器内所有有效元素但保留头节点,swap()则能直接交换两个list对象的内部数据。

三、list容器模拟实现

想要彻底吃透list的底层逻辑,手写模拟实现是必不可少的步骤,我们按照STL的标准规范,将整体代码拆分为节点类、迭代器类、list容器类三部分编写,整套代码完整无缺失,可直接编译运行。

3.1 节点类实现

节点类主要用来封装链表单个节点,内部包含数据域、前驱指针、后继指针,通过构造函数就能直接完成节点的初始化工作。

template<class T>
struct list_node {
    T _data;
    list_node<T>* _prev;
    list_node<T>* _next;

    // 节点构造
    list_node(const T& data = T())
        :_data(data)
        ,_prev(nullptr)
        ,_next(nullptr)
    {}
};

3.2 迭代器类实现

list的迭代器并不是普通的原生指针,而是对节点指针的封装,需要重载*、++、--、->这些常用运算符,同时借助模板参数实现普通迭代器与const迭代器的代码复用,减少冗余代码。

template<class T, class Ref, class Ptr>
struct list_iterator {
    typedef list_node<T> Node;
    typedef list_iterator<T, Ref, Ptr> Self;

    Node* _node;

    // 迭代器构造
    list_iterator(Node* node)
        :_node(node)
    {}

    // 解引用
    Ref operator*() {
        return _node->_data;
    }

    // 箭头运算符
    Ptr operator->() {
        return &_node->_data;
    }

    // 前置++
    Self& operator++() {
        _node = _node->_next;
        return *this;
    }

    // 后置++
    Self operator++(int) {
        Self tmp = *this;
        _node = _node->_next;
        return tmp;
    }

    // 前置--
    Self& operator--() {
        _node = _node->_prev;
        return *this;
    }

    // 后置--
    Self operator--(int) {
        Self tmp = *this;
        _node = _node->_prev;
        return tmp;
    }

    // 迭代器比较
    bool operator!=(const Self& it) const {
        return _node != it._node;
    }

    bool operator==(const Self& it) const {
        return _node == it._node;
    }
};

3.3 list容器类实现

list容器类主要封装链表头节点、有效元素个数,同时实现构造、析构、增删、迭代器获取等各类核心接口,完整还原STL list的基础功能。

template<class T>
class list {
    typedef list_node<T> Node;
public:
    // 迭代器重命名
    typedef list_iterator<T, T&, T*> iterator;
    typedef list_iterator<T, const T&, const T*> const_iterator;

    // 空链表初始化
    void empty_init() {
        _head = new Node;
        _head->_next = _head;
        _head->_prev = _head;
        _size = 0;
    }

    // 构造函数
    list() {
        empty_init();
    }

    // 迭代器区间构造
    template<class InputIterator>
    list(InputIterator first, InputIterator last) {
        empty_init();
        while (first != last) {
            push_back(*first);
            ++first;
        }
    }

    // 拷贝构造
    list(const list<T>& lt) {
        empty_init();
        for (auto& e : lt) {
            push_back(e);
        }
    }

    // 析构函数
    ~list() {
        clear();
        delete _head;
        _head = nullptr;
    }

    // 清空有效元素
    void clear() {
        iterator it = begin();
        while (it != end()) {
            it = erase(it);
        }
        _size = 0;
    }

    // 迭代器获取
    iterator begin() {
        return iterator(_head->_next);
    }

    iterator end() {
        return iterator(_head);
    }

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

    const_iterator end() const {
        return const_iterator(_head);
    }

    // 容量
    size_t size() const {
        return _size;
    }

    bool empty() const {
        return _size == 0;
    }

    // 元素访问
    T& front() {
        return *begin();
    }

    T& back() {
        return *(--end());
    }

    // 插入
    iterator insert(iterator pos, const T& x) {
        Node* cur = pos._node;
        Node* prev = cur->_prev;
        Node* newnode = new Node(x);

        // 指针链接
        prev->_next = newnode;
        newnode->_prev = prev;
        newnode->_next = cur;
        cur->_prev = newnode;

        ++_size;
        return iterator(newnode);
    }

    // 删除
    iterator erase(iterator pos) {
        // 不能删除头节点
        assert(pos != end());

        Node* cur = pos._node;
        Node* prev = cur->_prev;
        Node* next = cur->_next;

        // 指针解绑
        prev->_next = next;
        next->_prev = prev;
        delete cur;

        --_size;
        return iterator(next);
    }

    // 尾插
    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());
    }

private:
    Node* _head;
    size_t _size;
};

四、模拟实现代码测试

完成模拟实现代码后,我们可以编写对应的测试代码,检验各个接口能否正常调用、功能是否正常。

void test_my_list() {
    list<int> lt;
    lt.push_back(1);
    lt.push_back(2);
    lt.push_back(3);
    lt.push_front(0);

    // 遍历
    for (auto e : lt) {
        cout << e << " ";
    }
    cout << endl;

    // 删除偶数
    auto it = lt.begin();
    while (it != lt.end()) {
        if (*it % 2 == 0) {
            it = lt.erase(it);
        } else {
            ++it;
        }
    }

    for (auto e : lt) {
        cout << e << " ";
    }
    cout << endl;
    cout << "size:" << lt.size() << endl;
}

int main() {
    test_my_list();
    return 0;
}

五、总结与避坑

最后给大家总结list核心知识点与避坑要点:list底层是带头双向循环链表,增删元素时间复杂度为O(1)且不支持随机访问,它的迭代器属于双向迭代器,仅支持++、--单步操作,插入操作不会让迭代器失效,删除操作只会让当前被删节点的迭代器失效,调用erase函数时必须接收返回值,否则会因迭代器失效引发程序崩溃,模拟实现的核心是迭代器封装,通过运算符重载让迭代器用起来和原生指针一致,日常开发中按需选容器,需要随机访问就选vector,需要频繁任意位置增删就选list。