链表的基本原理和实现(C++版本)

1. 链表的本质:为什么需要它?

数组的本质是连续内存块 。这个连续性带来了两个致命约束:(1)分配时必须预知大小(动态扩容需要realloc,代价是O(n)拷贝)。(2)插入/删除需要移动大量元素。但数组连续性的巨大优势是**缓存友好,**CPU预取机制可以一次加载一批相邻元素到L1/L2缓存,遍历速度极快。

链表用离散的节点换取了动态插入和删除的自由。每个节点独立分配,通过指针串联。这种设计的本质是用指针开销换取内存灵活性,任意位置插入/删除O(1),且操作绝不会导致其他元素的迭代器失效(被删除元素本身除外)。

cpp 复制代码
struct Node { int data; struct Node* next; };

链表和数组的基本操作效率对比

操作 数组 单向链表 双向链表
随机访问 O(1) O(n) O(n)
头部插入 O(n) O(1) O(1)
尾部插入(已知尾指针) O(1) amortized O(1) O(1)
任意位置插入(已知位置) O(n) O(1)* O(1)*
删除(已知节点) O(n) O(n) O(1)

*表示在持有前驱节点指针的插入,任意插入时,如果没有持有前驱指针,那么插入的效率也为O(n)。

2.单向链表:结构、实现与变体

2.1 基础结构定义

cpp 复制代码
// C++版本(模板化)智能指针版本
template<typename T>
class m_list {
private:
    struct Node
	{
		T data;
		unique_ptr<Node> next;
		Node(const T &val, unique_ptr<Node> nxt = nullptr)
			: data(val), next(nxt) {}
		Node(T &&val, Node *nxt = nullptr)
			: data(std::move(val)), next(nxt) {}
	};
	unique_ptr<Node> head_;
	Node *tail_;
	size_t size_;

public:
	m_list() : head_(), tail_(nullptr), size_(0) {}
    // ... 插入、删除、查找等方法
};

公开接口层面:提供了完整的操作方法集,包括:

插入操作:push_front(头插)、push_back(尾插)、insert_at(指定位置插入)

删除操作:pop_front(头删)、pop_back(尾删)、erase_at(指定位置删除)、clear(清空)

访问操作:operator\[\](随机访问)、size()(获取长度)

控制权管理:拷贝构造、移动构造、拷贝赋值运算符

遍历支持:Iterator迭代器类及begin()/end()方法

2.2 核心数据结构设计

Node节点结构

cpp 复制代码
struct Node
{
    T data;
    unique_ptr<Node> next;
    
    Node(const T &val)
        : data(val), next()
    {
        cout << "拷贝构造Node:" << val << "\n";
    }
    
    Node(T &&val)
        : data(std::move(val)), next()
    {
        cout << "移动构造Node:" << val << "\n";
    }
    
    ~Node()
    {
        cout << "析构Node:" << data << "\n";
    };
};

2.3 插入操作的实现

头插法push_front

cpp 复制代码
void push_front(const T &val)   // 左值版本
void push_front(T &&val)         // 右值版本

链表提供了两个版本的push_front函数,分别处理左值和右值。这种重载是C++11移动语义的核心应用。当传入左值时,调用拷贝构造版本;传入右值(临时对象或std::move的结果)时,调用移动构造版本,避免不必要的拷贝。

cpp 复制代码
void push_front(T &&val)
{
    if (!head_)
    {
        head_ = std::make_unique<Node>(std::forward<T>(val));
        tail_ = head_.get();
    }
    else
    {
        unique_ptr<Node> new_node = std::make_unique<Node>(std::forward<T>(val));
        new_node->next = std::move(head_);
        head_ = std::move(new_node);
    }
    ++size_;
}

尾插法push_back

cpp 复制代码
void push_back(const T &val)
void push_back(T &&val)

尾插操作充分利用了tail_指针的存在。如果没有tail_指针,尾插操作需要从头遍历到尾,时间复杂度为O(n)。有了尾指针,尾插操作可以直接在O(1)时间内完成:

cpp 复制代码
tail_->next = std::make_unique<Node>(val);
tail_ = tail_->next.get();
++size_;

需要注意的是,当链表为空时,push_back直接调用push_front来处理。这种复用逻辑的方式简化了代码。

指定位置插入insert_at

cpp 复制代码
void insert_at(size_t index, const T &val)
void insert_at(size_t index, T &&val)

insert_at函数实现在指定索引位置插入元素,其逻辑需要处理三种边界情况。当索引为0时调用push_front;当索引等于大小时调用push_back;其他情况则遍历到插入位置的前驱节点进行链接操作。核心插入逻辑展示了智能指针的所有权转移:

cpp 复制代码
unique_ptr<Node> pNext = std::move(pMove->next);
unique_ptr<Node> newNode = std::make_unique<Node>(val);
newNode->next = std::move(pNext);
pMove->next = std::move(newNode);
++size_;

首先将待插入位置后续节点的所有权保存到pNext,创建新节点后将其链接到后续节点,最后将新节点链接到前驱节点。整个操作保持了所有权链条的完整性和正确性。

2.4 删除操作的实现

头删操作pop_front

cpp 复制代码
void pop_front()
{
    if (!head_)
        return;
    
    unique_ptr<Node> pNext = std::move(head_->next);
    head_ = std::move(pNext);
    --size_;
}

pop_front函数删除链表头部的第一个元素。实现采用了智能指针移动的优雅方式:首先将原头节点的next指针(指向第二个节点)转移到临时智能指针pNext,然后将head_移动到pNext。由于head_被覆盖,原头节点失去所有权并自动被销毁。整个过程无需手动delete,体现了智能指针的自动化内存管理优势。

尾删操作pop_back

cpp 复制代码
void pop_back()
{
    if (!head_)
        return;
    
    Node *pMove = head_.get();
    while (pMove->next.get() != tail_)
    {
        pMove = pMove->next.get();
    }
    tail_ = pMove;
    unique_ptr<Node> pNext = std::move(tail_->next);
    tail_->next = nullptr;
    --size_;
}

pop_back函数删除链表尾部元素。实现比头删稍复杂,因为单向链表只能从头向尾遍历。这里需要找到倒数第二个节点(即尾节点的前驱),将其设置为新的尾节点。循环while (pMove->next.get() != tail_)的作用就是定位到倒数第二个节点。找到后,将tail_更新为该节点,然后将尾节点的智能指针转移并清空。

这段代码有一个需要关注的点:当链表中只有一个节点时,pMove就是head_,而head_->next为空,tail_也指向head_。此时循环不会执行,直接将尾指针设置为pMove(即head_),然后移动并清空next。这个逻辑是正确的,单节点链表尾删后变为空链表。

指定位置删除erase_at

cpp 复制代码
void erase_at(size_t index)
{
    if (index >= size_)
        return; // 越界
    
    Node *pMove = head_.get();
    for (size_t i = 0; i < index - 1; ++i)
    {
        pMove = pMove->next.get();
    }
    unique_ptr<Node> pNext = std::move(pMove->next);
    pMove->next = std::move(pNext->next);
    --size_;
}

erase_at函数实现在指定位置删除元素。边界检查index >= size_确保不会访问越界位置。删除逻辑需要定位到待删除节点的前驱,然后通过智能指针的移动操作将前驱节点直接链接到待删除节点的下一个节点。被移动的pNext(原待删除节点)在离开作用域时自动销毁,其析构函数会打印调试信息。

这段代码的巧妙之处在于:不需要显式处理待删除节点,只需将pMove->next重新指向 pNext->next,待删除节点的所有权转移到pNext,当pNext离开作用域时自动被清理。如果待删除节点是尾节点,pNext->next为空(nullptr),尾节点的智能指针被正确处理,tail_指针仍然指向正确的位置(虽然可能已经悬空,但在下一次push_back或pop_back时会重新定位)。

2.5 随机访问运算符的实现

operator\[\]的设计

cpp 复制代码
T &operator[](size_t index)
{
    Node *pMove = head_.get();
    for (size_t i = 0; i < index; ++i)
    {
        if (!pMove)
            throw std::out_of_range("Index out of range");
        pMove = pMove->next.get();
    }
    return pMove->data;
}

const T &operator[](size_t index) const
{
    Node *pMove = head_.get();
    for (size_t i = 0; i < index; ++i)
    {
        if (!pMove)
            throw std::out_of_range("Index out of range");
        pMove = pMove->next.get();
    }
    return pMove->data;
}

operator\[\]提供了类似数组的下标访问能力,这是链表相对少见的特性。标准库中的std::list不提供operator\[\],因为链表的随机访问时间复杂度是O(n),不符合"数组式"访问的使用预期。但作为教学实现或特定场景,这个功能提供了便利。

代码实现了两个版本:非const版本返回可修改的引用,const版本返回const引用。两个版本都通过遍历链表到指定位置来访问数据。安全检查在每次移动前判断pMove是否为空,防止访问越界。

2.6 拷贝与移动语义

拷贝构造函数

cpp 复制代码
m_list(const m_list &other)
{
    if (!other.head_)
        return;
    
    Node *pMove = other.head_.get();
    while (pMove)
    {
        push_back(pMove->data);
        pMove = pMove->next.get();
    }
}

移动构造函数

cpp 复制代码
m_list(const m_list &&other) noexcept
{
    head_ = std::move(other.head_);
    tail_ = other.tail_;
    other.tail_ = nullptr;
    size_ = other.size_;
}

拷贝赋值运算符

cpp 复制代码
m_list &operator=(const m_list &other)
{
    if (this == &other)
        return *this;
    
    clear();
    for (Node *pMove = other.head_.get(); pMove; pMove = pMove->next.get())
    {
        push_back(pMove->data);
    }
    return *this;
}

2.7 迭代器的设计与实现

迭代器类遵循C++标准库的迭代器规范,定义了必要的类型别名。iterator_category = forward_iterator_tag表明这是一个前向迭代器,只支持单向遍历。这些类型别名使得迭代器能够与标准库算法(如std::for_each、std::find等)配合使用。

cpp 复制代码
class Iterator
{
private:
    Node *ptr_;
    
public:
    using iterator_category = std::forward_iterator_tag;
    using value_type = T;
    using difference_type = std::ptrdiff_t;
    using pointer = T *;
    using reference = T &;
    
    explicit Iterator(Node *ptr = nullptr) : ptr_(ptr) {}
    // ...
};

迭代器操作

cpp 复制代码
reference operator*() const
{
#ifdef _DEBUG
    if (!ptr_)
        throw std::runtime_error("Dereferencing null iterator");
#endif
    return ptr_->data;
}

Iterator &operator++()
{
#ifdef _DEBUG
    if (!ptr_)
        throw std::runtime_error("Incrementing past end");
#endif
    ptr_ = ptr_->next.get();
    return *this;
}

代码大量使用std::unique_ptr来管理节点内存,这一选择带来了多重优势。智能指针确保了节点的生命周期与链表一致,当节点被删除或链表被清空时,相关的内存会自动释放。同时,移动语义天然支持节点所有权的转移,使得push_front、insert_at、erase_at等操作可以简洁地实现,无需手动管理内存。

推荐一个零声教育学习教程,个人觉得老师讲得不错,分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习:链接

代码放在附件中

相关推荐
jufeng13072 小时前
【系列:MiniKV 原理剖析 · 第 5 篇】
linux·网络·c++·软件工程
码完就睡2 小时前
数据结构——树、二叉树基础概念
数据结构·算法
豆沙沙包?2 小时前
C++-程序的内存模型(P84-P88)
java·jvm·c++
jufeng13072 小时前
【系列:MiniKV 原理剖析 · 第 8 篇(完结篇)】
linux·c++·log4j·软件工程·makefile
VL——MOESR3 小时前
【LuoguP1967】货车运输【生成树】【倍增】
c++·算法·题解·倍增·生成树
草莓熊Lotso3 小时前
【Linux网络】从0手写Reactor反应堆(二):完善核心细节——ET非阻塞读写、分层架构与回调机制
linux·运维·服务器·网络·c++·tcp/ip·架构
cvby3 小时前
C++哈希
c++·哈希算法·散列表
世事如云有卷舒3 小时前
GoogTest测试框架
c++·测试
疯狂打码的少年3 小时前
【数据结构】栈:定义、顺序栈与链式栈
数据结构·笔记