C++深入浅出探索数据结构的原理

前言

「数据结构」这四个字常被简化成一张张示意图:数组是一排格子,链表是箭头串起来的节点,树是倒过来的分叉。图看得懂,但一到写代码就出问题------不知道为什么 std::vector 中间插入慢,不知道为什么 std::list 明明插入是常数时间却常常更慢,不知道为什么哈希表「平均 O(1)」却可能退化成 O(n)。

原因在于,示意图只画了逻辑结构,没有画物理存储。数据结构的性能几乎全部由「数据在内存里怎么放、访问时要跳几次、扩容时要搬多少」决定。逻辑结构回答「元素之间是什么关系」,物理结构回答「这些关系用什么表示」,复杂度回答「操作代价随规模怎么长」,三者缺一不可。

本文按这条线索展开:先区分连续与链式两种基本映射,再从访问、插入、删除、查找四类操作推导复杂度,然后补上缓存局部性、摊还分析与哈希冲突这三个常被忽略的因素,最后手写一个动态数组,把扩容、拷贝、异常安全这些细节落到代码上。文中不给出任何未经验证的性能倍数,只讲可推导的复杂度与可观察的机制。

一、逻辑结构与物理存储

逻辑结构描述元素之间的关系,常见的有线性表、树、图、集合;物理结构描述这种关系在内存中的实现,基本只有两种:连续存储 与链式存储。

连续存储把所有元素放在一段连续地址里,第 i 个元素的地址等于首地址加 i 乘元素大小。这个公式带来两个直接后果:随机访问是常数时间;但在中间插入或删除时,必须搬动后面所有元素,代价与元素数量成正比。

链式存储把每个元素包进一个节点,节点里存数据和指向下一个节点的指针。节点之间不要求相邻,插入只需改几个指针,代价是常数;但要访问第 i 个元素,必须从头一个个跳,代价与 i 成正比。此外每个节点多出一个指针的开销,频繁分配小节点还会给分配器带来压力。

逻辑结构 常见物理实现 随机访问 中间插入/删除 额外空间

|-----|------|------|------|------|
| 线性表 | 动态数组 | O(1) | O(n) | 容量冗余 |

|-----|-------|------|------------|--------------|
| 线性表 | 单/双链表 | O(n) | 已知位置时 O(1) | 每节点 1~2 个指针 |

|--------|-------|-----|---------|-----|
| 栈 / 队列 | 数组或链表 | 仅端点 | 端点 O(1) | 视实现 |

|-----|---------|----------|----------|---------|
| 有序表 | 平衡二叉搜索树 | O(log n) | O(log n) | 每节点多个指针 |

|-----|-----|---------|---------|----------|
| 查找表 | 哈希表 | 平均 O(1) | 平均 O(1) | 桶数组 + 节点 |

这里有一个容易被忽视的事实:抽象数据类型不规定物理实现 。栈既可以用数组也可以用链表,队列既可以用循环数组也可以用链表,std::stack 和 std::queue 默认基于 std::deque,而标准只规定行为与复杂度,不规定底层一定是什么。因此「栈是数组」这种说法只在特定实现下成立。

二、四类操作的复杂度从哪里来

分析复杂度时,不要背结论,而要看「这个操作必须触碰多少内存」。

访问。连续存储可以直接算出地址,O(1);链式存储必须沿指针走,O(n)。这就是「链表不适合按下标读」的根本原因,与编译器优化无关。

插入与删除。若已经持有指向位置的迭代器或指针,链表改指针即可,O(1);数组要搬移后继元素,O(n)。但注意「已经持有位置」这个前提:如果还要先查找位置,总代价要把查找算进去。很多人比较链表与数组时只算插入不算定位,得出错误结论。

查找。无序结构只能线性扫描,O(n);有序数组可以二分,O(log n);平衡二叉搜索树维持有序性,O(log n);哈希表用哈希函数把键映射到桶,平均 O(1)。哈希表的「平均」有前提:哈希函数把键均匀分散,且负载因子受控。若所有键落到同一个桶,查找退化为 O(n),这就是哈希碰撞攻击的原理,也是标准库不保证最坏复杂度的原因。

有序操作 。需要按序遍历、找前驱后继、范围查询时,哈希表无能为力,必须用有序结构。std::map 与 std::set 通常用红黑树实现,标准只要求查找、插入、删除为对数复杂度,并不强制某种树。

一个操作往往由多个步骤组成,复杂度要按最坏或摊还口径说清楚。例如「在数组中插入」包含搬移与写入,搬移是 O(n);「在哈希表中插入」包含计算哈希、定位桶、可能的重哈希,重哈希单次是 O(n),但摊还到每次插入是 O(1)。

三、局部性、摊还与哈希冲突

缓存局部性 。现代 CPU 访问内存时会按缓存行批量加载,连续存储的顺序遍历能命中缓存,链式存储的每次跳转都可能是一次缓存缺失。这解释了一个反直觉现象:在元素数量不大、元素本身较小时,std::vector 的中间插入有时并不比 std::list 慢太多,因为链表的指针追逐把省下的搬移又赔了回去。这不是语言保证,而是硬件行为的后果;要下结论必须针对具体平台测量,不能凭复杂度表拍脑袋。

摊还分析 。动态数组扩容时要把所有元素搬到新缓冲区,单次代价 O(n)。若每次只增加固定容量,n 次插入的总代价是 O(n²),摊还每次 O(n);若容量按倍数增长(常见是翻倍),总代价是几何级数,摊还每次 O(1)。这就是「摊还 O(1)」的来源:它不否认某一次插入很慢,只说明长期平均下来是常数。标准库不规定 std::vector 的扩容因子,只要求 push_back 摊还常数,因此不要依赖「一定是两倍」。

哈希冲突 。主流方案有两类:链地址法把同桶元素串成链表或小树,开放寻址法在冲突时按探测序列找下一个空位。链地址法实现简单、删除方便;开放寻址法缓存更友好,但删除需要标记墓碑,负载因子过高时性能急剧下降。标准库的 std::unordered_map 通常用链地址法,但这是实现细节,标准只规定平均复杂度与迭代器失效规则。

迭代器失效 是数据结构原理的直接推论:数组扩容后旧指针指向被释放的缓冲区;链表删除节点后指向该节点的迭代器失效;哈希表重哈希后所有迭代器失效,但指向元素的引用与指针在标准容器中通常仍然有效(std::unordered_map 的重哈希不使引用失效,这一点常被记反)。

四、完整示例:手写一个动态数组

下面的 DynArray 把连续存储的关键机制显式写出来:容量与长度分离、按倍数扩容、拷贝交换实现赋值、移动构造接管缓冲区。它不追求替代 std::vector,而是让每个决策都可见。

cpp 复制代码
// 适用于 C++17

#include <algorithm>

#include <cstddef>

#include <iostream>

#include <stdexcept>

#include <utility>



template <typename T>

class DynArray {

public:

    DynArray() = default;



    explicit DynArray(std::size_t n)

        : data_(n ? new T[n] : nullptr), size_(n), cap_(n) {}



    ~DynArray() { delete[] data_; }



    DynArray(const DynArray& other)

        : data_(other.size_ ? new T[other.size_] : nullptr),

          size_(other.size_),

          cap_(other.size_) {

        std::copy(other.data_, other.data_ + other.size_, data_);

    }



    DynArray& operator=(const DynArray& other) {

        if (this != &other) {

            DynArray tmp(other);   // 先完成可能抛异常的拷贝

            swap(tmp);             // 再无异常地交换

        }

        return *this;

    }



    DynArray(DynArray&& other) noexcept

        : data_(other.data_), size_(other.size_), cap_(other.cap_) {

        other.data_ = nullptr;

        other.size_ = 0;

        other.cap_ = 0;

    }



    DynArray& operator=(DynArray&& other) noexcept {

        if (this != &other) {

            delete[] data_;

            data_ = other.data_;

            size_ = other.size_;

            cap_ = other.cap_;

            other.data_ = nullptr;

            other.size_ = 0;

            other.cap_ = 0;

        }

        return *this;

    }



    void swap(DynArray& other) noexcept {

        std::swap(data_, other.data_);

        std::swap(size_, other.size_);

        std::swap(cap_, other.cap_);

    }



    void reserve(std::size_t new_cap) {

        if (new_cap <= cap_) return;

        T* fresh = new T[new_cap];                 // 先分配,失败则原对象不变

        for (std::size_t i = 0; i < size_; ++i) {

            fresh[i] = data_[i];                   // 逐元素复制

        }

        delete[] data_;

        data_ = fresh;

        cap_ = new_cap;

    }



    void push_back(const T& value) {

        if (size_ == cap_) {

            reserve(cap_ ? cap_ * 2 : 1);          // 按倍数增长,摊还 O(1)

        }

        data_[size_] = value;                      // 先写成功再增加长度

        ++size_;

    }



    T& operator[](std::size_t i) { return data_[i]; }

    const T& operator[](std::size_t i) const { return data_[i]; }



    T& at(std::size_t i) {

        if (i >= size_) throw std::out_of_range("DynArray::at");

        return data_[i];

    }



    std::size_t size() const noexcept { return size_; }

    std::size_t capacity() const noexcept { return cap_; }



private:

    T* data_ = nullptr;

    std::size_t size_ = 0;

    std::size_t cap_ = 0;

};



int main() {

    DynArray<int> a;

    for (int i = 0; i < 10; ++i) {

        a.push_back(i);

        std::cout << "size=" << a.size()

                  << " capacity=" << a.capacity() << '\n';

    }

    std::cout << "a[3]=" << a[3] << '\n';

    return 0;

}

这段代码里有几处刻意为之的设计:

  • reserve 先分配新缓冲区再复制,若分配失败抛出 std::bad_alloc,原对象保持完整;若复制过程中抛出,新缓冲区会泄漏,因为它是裸指针------这正是 std::vector 要用分配器与 RAII 的原因。示例为了短小没有处理这条路径,生产代码应把缓冲区包进 std::unique_ptr<T[]>。
  • push_back 先赋值再自增长度。如果赋值抛异常,长度不变,容器仍处于有效状态;写成 data_[size_++] = value 则可能留下一个长度已增、元素未构造的洞。
  • 拷贝赋值用「拷贝并交换」,天然处理自赋值并提供强异常保证。
  • 移动构造与移动赋值都标 noexcept,这是容器在扩容时愿意用移动而非拷贝的前提;若移动可能抛异常,标准容器通常退回拷贝以维持强保证。
  • operator[] 不检查边界,越界是未定义行为;at 检查并抛出 std::out_of_range。这个分工与标准库一致。
  • 扩容因子取 2,只是示例选择;标准不规定 std::vector 的因子,实际实现可能取 1.5 或 2。

常见坑点

❌ 认为链表插入一定是 O(1),忽略定位成本。

✅ 只有已持有插入位置的迭代器时才是 O(1);按值查找再插入的总体代价仍是 O(n)。

❌ 认为 std::vector 的 push_back 每次都是 O(1)。

✅ 它是摊还 O(1):个别插入触发扩容时为 O(n),长期平均为常数。

❌ 用下标遍历 std::list,写成 for (int i = 0; i < l.size(); ++i) use(l[i]);。

✅ std::list 没有下标操作;用迭代器或范围 for,否则根本无法通过编译。

❌ 扩容后继续使用指向旧缓冲区的指针或迭代器。

✅ 扩容会使所有指针、引用、迭代器失效;扩容后重新获取。

❌ 认为 std::unordered_map 重哈希后指向元素的引用也失效。

✅ 重哈希使迭代器失效,但指向元素的引用和指针保持有效;删除元素才会让被删元素的引用失效。

❌ 用 data_[size_++] = value 实现 push_back。

✅ 先赋值再自增,异常时容器状态仍然一致。

❌ 自己实现容器时把析构、拷贝、移动只写一半。

✅ 遵循规则五,或直接用 std::vector 等现成容器,把精力放在业务上。

总结

维度 连续存储 链式存储

|------|------|------|
| 随机访问 | O(1) | O(n) |

|-----------|------|------|
| 已知位置插入/删除 | O(n) | O(1) |

|------|------|-------|
| 额外空间 | 容量冗余 | 每节点指针 |

|-------|---|---|
| 缓存友好度 | 高 | 低 |

|-------|---------|---------|
| 迭代器失效 | 扩容时全部失效 | 仅被删节点失效 |

数据结构的原理可以压缩成一句话:逻辑关系决定能做什么,物理布局决定要付出什么。复杂度给出趋势,缓存与常数因子决定实际感受,摊还分析解释「偶尔很慢但长期很稳」。写业务代码时优先用标准容器,让实现去处理扩容与异常安全;需要自己实现时,先把长度与容量、拷贝与移动、检查与不检查这三组边界想清楚,再动手写循环。

相关推荐
eggwyw1 小时前
C++深入浅出讲解希尔排序算法的实现
开发语言·c++
starzy19901 小时前
Scala Array操作:从定长数组到ArrayBuffer的完整指南
开发语言·后端·scala
读书札记20221 小时前
Qt UTF-8和UTF-8(BOM)
开发语言·qt
ShineWinsu1 小时前
对于Qt:QProgressBar常见属性的解析
开发语言·c++·qt·进度条·gui·定时器·qprogressbar
fanruitian1 小时前
python 机器学习 交叉验证train_test_split 网格搜索GridSearchCV
开发语言·python·机器学习
AlanAshley1 小时前
无 SIM 卡也能打 110?手机紧急呼叫能力的软硬件技术探究
开发语言·智能手机·php
ShineWinsu1 小时前
对于Qt:QLCDNumber常见属性以及QTimer的解析
服务器·数据库·c++·qt·gui·qtimer·qlcdnumber
modelmd1 小时前
Go 切片、映射常见使用场景
开发语言·后端·golang
xiaoye-duck2 小时前
《C++ 工具库实战》C++ 解析利器 JsonCpp:从基础使用到封装简易 JSON 工具
c++·json·第三方库