本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:《C++数据结构第三版》是一本国外经典英文教材,系统讲解C++语言环境下各类核心数据结构的设计与实现。内容涵盖数组、链表、栈、队列、树(二叉树、AVL树、红黑树)、图、哈希表等基础结构,并结合C++特性如指针、引用、模板和STL容器进行高效实现。书中还深入探讨排序与查找算法,强化算法与数据结构的协同应用。本书适用于计算机专业学生、软件工程师及自学者,通过实践提升程序性能优化、内存管理与抽象思维能力,是掌握现代C++数据结构的优质学习资源。
数据结构

1. 数据结构基本概念与作用

数据结构的本质与分类

数据结构是组织和存储数据的逻辑框架,核心在于描述数据间的逻辑关系(如线性、树形、图状)以及其在内存中的物理实现方式(顺序或链式)。它不仅决定数据访问、插入、删除等操作的效率,还深刻影响算法设计的复杂度。典型结构包括线性表、栈、队列、树、图等,按逻辑可分为线性与非线性两大类。

抽象数据类型(ADT)与实现分离

ADT通过封装“是什么”而非“如何做”,定义了数据结构的行为接口(如栈的push/pop),屏蔽底层实现细节。这种抽象使得开发者可基于不同需求选择数组或链表实现同一ADT,提升代码模块化与可维护性。

时间与空间复杂度分析基础

采用大O表示法评估算法性能,关注最坏情况下的增长趋势。例如,数组支持O(1)随机访问,但插入需O(n)移动;链表插入为O(1),却牺牲了访问速度。合理选型需权衡时间、空间与应用场景。

2. C++数组与动态数组设计实现

在现代软件系统中,数据的组织方式直接决定了程序运行效率与资源利用率。作为最基础且使用最广泛的数据结构之一,数组提供了对元素进行连续存储和随机访问的能力。然而,静态数组的固定大小限制了其灵活性,无法适应运行时动态增长的需求。为此,C++引入了堆内存管理机制,使得开发者能够手动控制内存分配与释放,从而构建出可变长度的 动态数组 。本章将深入剖析数组在底层的内存布局原理,探讨从静态数组到动态数组的设计演进过程,并通过封装一个功能完整的自定义 MyVector 类来实践面向对象编程中的构造、拷贝、赋值与异常安全等关键问题。

2.1 数组的基本原理与内存布局

数组是一种线性数据结构,用于存储相同类型的多个元素,这些元素在内存中以连续的方式排列。这种物理上的连续性赋予了数组一项独特优势—— 常数时间的随机访问能力 。理解数组如何在内存中分布、编译器如何解析索引操作以及不同存储策略带来的性能差异,是掌握高效数据处理技术的基础。

2.1.1 静态数组的声明与访问机制

在 C++ 中,静态数组(也称栈数组)是在编译期确定大小并在栈上分配内存的数组。其声明语法如下:

int arr[5] = {1, 2, 3, 4, 5};

上述代码定义了一个包含 5 个整型元素的数组 arr ,初始化为 {1, 2, 3, 4, 5} 。该数组的空间由编译器自动在函数调用栈中分配,生命周期与其所在作用域绑定,退出作用域后自动回收。

数组名 arr 实际上是一个指向首元素地址的常量指针。例如, arr == &arr[0] 成立。当执行 arr[i] 操作时,编译器将其转换为指针算术运算: *(arr + i) 。这意味着每个元素的地址可以通过基地址加上偏移量计算得出。

内存访问流程图示
graph TD
    A["arr[i]"] --> B["计算偏移: i * sizeof(T)"]
    B --> C["基地址 + 偏移 => 目标地址"]
    C --> D["读取/写入内存"]
    D --> E["返回值或完成赋值"]

此流程展示了数组访问的本质过程: 基于偏移量的直接寻址 。由于不需要遍历或查找,无论 i 是多少,只要合法,访问时间均为 O(1)。

示例代码及分析
#include <iostream>
using namespace std;

void demonstrate_static_array() {
    int data[4] = {10, 20, 30, 40};

    cout << "Address of data[0]: " << &data[0] << endl;
    cout << "Address of data[1]: " << &data[1] << endl;
    cout << "Size of each element: " << sizeof(data[0]) << " bytes" << endl;
    // 手动计算地址验证连续性
    cout << "Expected address of data[1]: " 
         << (char*)&data[0] + sizeof(int) << endl;
}

逻辑逐行解读:
- 第 5 行:声明并初始化一个长度为 4 的静态整型数组。
- 第 7–8 行:输出第一个和第二个元素的地址,观察是否相差 sizeof(int) (通常为 4 字节)。
- 第 11–12 行:利用字符指针进行字节级偏移,验证内存连续性。若结果一致,则说明数组确实在内存中连续存放。

该机制的优势在于高速访问,但代价是缺乏弹性。一旦声明,无法扩展或收缩,超出范围的访问会导致未定义行为(如缓冲区溢出),这是许多安全漏洞的根源。

2.1.2 连续内存分配的特点与局限性

连续内存分配是数组的核心特征,也是其性能优越性的来源。所有元素被放置在一块相邻的内存区域中,这带来了以下显著特点:

特点 描述
空间局部性强 相邻元素物理位置接近,利于 CPU 缓存预取
支持随机访问 可通过公式 base + i * size 快速定位任意元素
存储开销低 无额外元数据(如指针)占用空间
分配速度快 栈上分配仅需移动栈指针

然而,这种模式也有明显局限:

  • 固定容量 :静态数组必须在编译时或函数入口处指定大小,不能动态调整。
  • 易造成浪费或不足 :预估过大导致内存浪费;过小则可能引发溢出。
  • 插入/删除效率低 :中间插入需整体后移,平均时间复杂度为 O(n)。
  • 栈空间有限 :大数组可能导致栈溢出(stack overflow)。
内存布局对比表
存储类型 分配位置 生命周期 扩展能力 典型用途
静态数组 作用域结束自动释放 不可扩展 小规模已知尺寸数据
动态数组 手动控制释放 可扩容 大数据集、运行时决定大小

因此,在需要灵活容量的应用场景中(如容器类库、大型数据处理),必须转向堆内存管理。

2.1.3 时间与空间复杂度分析:随机访问的优势

对于静态数组的操作,我们可以从时间和空间两个维度进行精确评估。

常见操作复杂度汇总
操作 时间复杂度 空间复杂度 说明
访问 arr[i] O(1) O(1) 地址计算一步到位
插入元素(末尾) O(1) O(1) 若有空位
插入元素(中间) O(n) O(1) 需移动后续元素
删除元素(中间) O(n) O(1) 同样需前移填补
查找(无序) O(n) O(1) 遍历比较
查找(有序+二分) O(log n) O(1) 前提已排序

可以看出, 随机访问的 O(1) 是数组最大的优势。相比之下,链表虽然支持高效的插入删除,但访问仍需 O(n) 时间。

空间利用率分析

假设存储 n 个类型为 T 的元素:

  • 静态数组总空间 = n * sizeof(T)
  • 无额外开销,空间利用率 100%

而像 std::vector 这样的动态数组,为了支持扩容,会预留冗余空间(capacity ≥ size),实际占用略高,但在大多数情况下仍保持较高效率。

性能测试片段
#include <chrono>
#include <vector>

const int N = 1e7;
int static_arr[N];

auto start = chrono::high_resolution_clock::now();
for (int i = 0; i < N; ++i) {
    static_arr[i] = i * 2;
}
auto end = chrono::high_resolution_clock::now();

cout << "Time to fill static array: "
     << chrono::duration_cast<chrono::milliseconds>(end - start).count()
     << " ms" << endl;

参数说明:
- N : 测试数据规模,设为一千万以体现缓存效应。
- 使用 <chrono> 获取高精度时间戳。
- 循环体模拟批量写入操作。

实验表明,静态数组因良好的缓存局部性,往往比同等规模的链式结构快数倍甚至数十倍。

综上所述,静态数组以其简洁性和高效性成为许多算法实现的首选结构,尤其适用于已知大小、频繁随机访问的场合。但在面对不确定数据量时,我们必须借助更高级的机制——动态数组。

2.2 动态数组的设计思想与实现细节

动态数组是对静态数组的功能扩展,它允许在程序运行过程中根据需求动态地增加或减少存储容量。其实现依赖于堆内存管理、智能扩容策略以及深拷贝语义的正确处理。理解这些机制不仅有助于我们编写高效的容器类,还能加深对 C++ 资源管理模型的理解。

2.2.1 使用 new/delete 进行堆内存管理

在 C++ 中,堆内存通过 new delete 操作符进行管理。与栈不同,堆空间更大,适合存储大型对象或运行时才能确定大小的数据结构。

创建动态数组的基本语法如下:

int* ptr = new int[capacity];
// ... 使用 ...
delete[] ptr; // 注意使用 delete[]

其中:
- new int[capacity] 在堆上分配 capacity 个整型空间,返回首地址。
- delete[] 必须配对使用,否则会导致未定义行为或内存泄漏。

自定义动态数组雏形
class SimpleDynamicArray {
private:
    int* data;
    int capacity;
    int size;

public:
    SimpleDynamicArray(int cap = 10) : capacity(cap), size(0) {
        data = new int[capacity];
    }

    ~SimpleDynamicArray() {
        delete[] data;
    }

    void push_back(int value) {
        if (size >= capacity) {
            resize();
        }
        data[size++] = value;
    }

    int get(int index) const {
        if (index < 0 || index >= size)
            throw out_of_range("Index out of bounds");
        return data[index];
    }

private:
    void resize() {
        capacity *= 2;
        int* new_data = new int[capacity];
        for (int i = 0; i < size; ++i) {
            new_data[i] = data[i];
        }
        delete[] data;
        data = new_data;
    }
};

逻辑分析:
- 构造函数初始化容量并分配堆内存。
- push_back 检查容量,必要时调用 resize 扩容。
- resize 创建新数组,复制旧内容,释放原内存,更新指针。
- 析构函数确保资源释放,防止泄漏。

该实现虽简单,但已具备动态数组核心功能。

2.2.2 容量扩展策略:倍增扩容与摊销时间分析

动态数组的关键挑战是如何平衡内存使用与性能。频繁扩容成本高昂,而过度预留又浪费空间。常见的解决方案是采用 倍增扩容 (doubling strategy):每当空间不足时,将容量翻倍。

扩容策略对比表
策略 每次增量 平均插入时间 空间利用率
固定增长(+k) k O(n)
倍增增长(×2) ×2 O(1) 摊销 中等(~50%)

尽管倍增扩容看似浪费空间(最多闲置一半),但它带来了 摊销 O(1) 的插入时间。

摊销分析(Amortized Analysis)

考虑向初始容量为 1 的数组插入 n 个元素:

  • 第 1 次插入:无需扩容
  • 第 2 次:扩容至 2,复制 1 项
  • 第 4 次:扩容至 4,复制 2 项
  • 第 8 次:扩容至 8,复制 4 项
  • 总复制次数 ≈ 1 + 2 + 4 + … + n/2 < n

故总操作数为 O(n),平均每插入一次耗时 O(1)。

graph LR
    subgraph 扩容事件
        A[插入第1个] --> B[容量=1]
        B --> C[插入第2个触发扩容]
        C --> D[复制1个, 容量=2]
        D --> E[插入第3~4个]
        E --> F[插入第5个触发扩容]
        F --> G[复制2个, 容量=4]
    end

可视化显示扩容呈指数间隔出现,因此长期来看开销被“摊销”。

2.2.3 拷贝构造函数与赋值操作的安全实现

默认拷贝会使两个对象共享同一块堆内存,导致双重释放错误(double free)。必须显式实现深拷贝。

// 拷贝构造函数
SimpleDynamicArray(const SimpleDynamicArray& other)
    : capacity(other.capacity), size(other.size) {
    data = new int[capacity];
    for (int i = 0; i < size; ++i) {
        data[i] = other.data[i];
    }
}

// 赋值操作符
SimpleDynamicArray& operator=(const SimpleDynamicArray& other) {
    if (this != &other) {
        delete[] data;                    // 释放原有资源
        capacity = other.capacity;
        size = other.size;
        data = new int[capacity];
        for (int i = 0; i < size; ++i) {
            data[i] = other.data[i];
        }
    }
    return *this;
}

参数说明:
- this != &other :防止自赋值。
- 先释放再分配,避免内存泄漏。
- 深拷贝确保独立性。

此外,应遵循 Rule of Three :若需自定义析构函数、拷贝构造或赋值操作其中之一,通常三者都需要重写。

2.3 C++类封装动态数组的实践案例

2.3.1 自定义 MyVector 类接口设计

template<typename T>
class MyVector {
private:
    T* data;
    size_t cap, sz;

    void resize() {
        cap = cap == 0 ? 1 : cap * 2;
        T* new_data = new T[cap];
        for (size_t i = 0; i < sz; ++i)
            new_data[i] = data[i];
        delete[] data;
        data = new_data;
    }

public:
    MyVector() : data(nullptr), cap(0), sz(0) {}
    ~MyVector() { delete[] data; }

    void push_back(const T& val) {
        if (sz == cap) resize();
        data[sz++] = val;
    }

    void pop_back() {
        if (sz > 0) --sz;
    }

    T& operator[](size_t idx) { return data[idx]; }
    const T& operator[](size_t idx) const { return data[idx]; }

    size_t size() const { return sz; }
    bool empty() const { return sz == 0; }

    void resize(size_t new_size, const T& value = T{}) {
        if (new_size > cap) {
            while (cap < new_size) cap = cap == 0 ? 1 : cap * 2;
            T* new_data = new T[cap];
            for (size_t i = 0; i < sz; ++i) new_data[i] = data[i];
            delete[] data;
            data = new_data;
        }
        if (new_size > sz) {
            for (size_t i = sz; i < new_size; ++i)
                data[i] = value;
        }
        sz = new_size;
    }
};

扩展说明:
- 支持泛型模板,适用于任意类型。
- 提供 push_back , pop_back , resize , [] 等标准接口。
- 异常安全性尚待完善(见下节)。

2.3.2 异常安全与资源泄漏防范

当前实现存在异常风险:若 new T[cap] 抛出异常(如内存不足), delete[] data 已执行,但 data 被置空前发生中断,将导致资源泄漏。

改进方案:使用临时变量和 RAII 思想。

void resize() {
    size_t new_cap = cap == 0 ? 1 : cap * 2;
    T* new_data = new T[new_cap];  // 可能抛出 bad_alloc

    try {
        for (size_t i = 0; i < sz; ++i)
            new_data[i] = data[i];
    } catch (...) {
        delete[] new_data;
        throw;
    }

    delete[] data;
    data = new_data;
    cap = new_cap;
}

或者更优地,使用 std::unique_ptr<T[]> temp(new T[new_cap]); 自动管理中间状态。

2.3.3 迭代器初步引入:支持范围 for 循环

添加迭代器类型以便兼容现代 C++ 风格:

using iterator = T*;
using const_iterator = const T*;

iterator begin() { return data; }
iterator end() { return data + sz; }
const_iterator begin() const { return data; }
const_iterator end() const { return data + sz; }

现在可以使用:

MyVector<int> vec;
vec.push_back(1); vec.push_back(2);

for (int x : vec) {
    cout << x << " ";
}

输出: 1 2

这极大提升了接口友好性。

2.4 性能测试与优化建议

2.4.1 对比静态数组与动态数组的实际开销

编写基准测试比较两者性能:

操作 静态数组 动态数组(MyVector)
构造 几乎零开销 new/delete 开销
访问 相同(O(1)) 相同
扩容 不支持 存在复制成本

结论:小规模已知数据优先静态数组;大规模动态数据选动态数组。

2.4.2 缓存局部性对数组遍历效率的影响

连续内存使数组遍历时命中率高。实验证明,顺序访问比跳跃访问快 5–10 倍。

优化建议:
- 尽量顺序访问
- 避免跨步过大(如 strided access)
- 数据结构尽量紧凑

最终,合理选择数组形式并结合良好设计模式,可在性能与灵活性之间取得最佳平衡。

3. 链表(单向、双向、循环)设计与操作

链表作为最基础的动态数据结构之一,是理解指针、内存管理和复杂数据组织方式的关键桥梁。相比于数组依赖连续内存空间的特性,链表通过节点间的指针链接实现灵活的数据存储,能够在不移动大量元素的前提下完成高效的插入与删除操作。这种“以空间换灵活性”、“以间接访问换取动态扩展”的设计理念,在现代软件系统中广泛存在——从操作系统内核中的进程队列管理,到数据库引擎中的索引页链式连接,再到网络协议栈中的缓冲区队列处理,无不体现着链表的核心价值。

本章将深入剖析链式存储的本质机制,从底层节点结构出发,逐步构建出完整的单向链表、双向链表和循环链表,并结合C++语言特性进行类封装与接口实现。重点在于揭示不同链表类型在逻辑结构、遍历策略、边界控制以及性能权衡上的差异。同时,针对实际开发中常见的陷阱如空指针解引用、内存泄漏、循环判断错误等问题,提供可落地的编码规范与调试思路。最终通过典型算法实例展示链表的应用能力,为后续章节中更高级的数据结构(如栈、队列、图等)打下坚实基础。

3.1 链式存储结构的理论基础

链式存储是一种非连续的内存组织形式,其核心思想是将数据分散存储于多个独立的内存块中,每个块包含数据本身以及一个或多个指向其他块的指针,从而形成逻辑上的线性序列。与数组依赖下标和地址偏移的直接访问不同,链表必须通过指针逐个跳转来访问目标节点,这带来了时间开销,但也赋予了它无须预分配固定大小、支持动态增删的优势。

3.1.1 节点结构与指针链接机制解析

链表的基本组成单位是 节点(Node) ,通常由两部分构成:
- 数据域(data) :用于存储实际的数据内容,可以是整数、字符串、对象或其他自定义类型;
- 指针域(next 或 prev) :保存下一个(或上一个)节点的地址,用于建立节点之间的逻辑联系。

在C++中,最简单的节点可以用 struct class 定义:

struct ListNode {
    int data;
    ListNode* next;

    // 构造函数便于初始化
    ListNode(int val) : data(val), next(nullptr) {}
};

上述代码定义了一个单向链表节点,其中 next 指针初始设为 nullptr ,表示该节点当前未连接任何后续节点。当多个这样的节点通过 next 指针依次连接时,就形成了链式结构。

指针链接过程示例

假设我们创建三个节点A、B、C,分别存储值10、20、30:

ListNode* A = new ListNode(10);
ListNode* B = new ListNode(20);
ListNode* C = new ListNode(30);

A->next = B;  // A 指向 B
B->next = C;  // B 指向 C
C->next = nullptr; // C 是尾节点

此时形成的链表结构如下图所示:

graph LR
    A[Node: 10 | next → B] --> B[Node: 20 | next → C]
    B --> C[Node: 30 | next → null]

流程图说明 :该mermaid图展示了单向链表的物理连接关系。箭头方向代表 next 指针的指向,最后一个节点指向 nullptr 作为终止标志。

这种结构允许我们在任意位置插入新节点。例如要在B和C之间插入D(值25),只需调整指针:

ListNode* D = new ListNode(25);
D->next = B->next;  // D 指向 C
B->next = D;        // B 指向 D

无需像数组那样整体后移元素,仅需两次指针赋值即可完成插入,时间复杂度为O(1),前提是已定位到前驱节点。

内存分布特点

链表节点在堆上动态分配,彼此可能分布在不同的内存区域,如下表所示:

节点 数据值 内存地址(示例) next指针值
A 10 0x1000 0x2000
B 20 0x2000 0x3000
C 30 0x3000 nullptr

表格说明 :链表节点物理地址不连续,但通过 next 指针维持逻辑顺序。这种方式牺牲了缓存局部性,但提升了插入/删除效率。

3.1.2 与数组相比的插入删除优势分析

为了全面理解链表的价值,必须将其与数组进行对比,尤其是在插入和删除操作上的表现。

操作 数组(静态/动态) 单向链表
头部插入 O(n) — 所有元素后移 O(1) — 修改头指针
尾部插入 O(1)(若容量充足) O(1)(若有尾指针)
中间插入 O(n) — 查找+移动 O(n)查找 + O(1)修改指针
头部删除 O(n) — 元素前移 O(1) — 更新头指针
尾部删除 O(1) O(n) — 需找到前驱节点
随机访问 O(1) — 直接索引 O(n) — 必须遍历
内存使用 紧凑,无额外指针开销 每节点多一个指针空间
缓存友好性 高 — 连续内存访问快 低 — 跳跃式访问易缓存失效

表格说明 :链表在频繁插入删除场景中具有显著优势,尤其适用于头部操作频繁的队列、LRU缓存等应用。

举个例子,若在一个长度为10^6的数组前端插入一个元素,需要移动近百万个元素;而在链表中只需新建节点并修改头指针即可。然而,链表无法支持随机访问,查找第k个元素必须从头开始遍历,平均时间复杂度为O(n)。

因此,选择链表还是数组应基于具体应用场景:
- 若频繁执行中间插入/删除 → 优先考虑链表;
- 若主要进行随机读取或遍历 → 数组更优;
- 若数据规模不确定且增长迅速 → 动态数组或链表均可,但链表扩容无“倍增拷贝”开销。

3.1.3 内存碎片问题与节点分配代价

尽管链表提供了动态伸缩的能力,但它也引入了新的挑战: 内存碎片 分配开销

每次调用 new ListNode(...) 都会请求操作系统分配一小块内存(通常约8~16字节),这些小块可能散布在整个堆空间中。随着程序运行,频繁的申请与释放会导致堆内存出现大量无法利用的小空隙——即 外部碎片 。虽然现代内存管理器(如glibc的ptmalloc)采用合并机制缓解此问题,但在高并发或多线程环境下仍可能导致性能下降。

此外,每次节点创建都涉及系统调用开销。相比之下,数组一次性分配大片内存,减少了频繁调用 malloc/new 的次数。为此,一些高性能库采用 内存池技术 优化链表节点分配。

自定义内存池示例
class NodePool {
private:
    std::vector<ListNode*> free_list;
    const int CHUNK_SIZE = 1024;

public:
    ListNode* allocate() {
        if (free_list.empty()) {
            // 批量预分配一批节点
            ListNode* chunk = new ListNode[CHUNK_SIZE];
            for (int i = 0; i < CHUNK_SIZE; ++i)
                free_list.push_back(&chunk[i]);
        }
        ListNode* node = free_list.back();
        free_list.pop_back();
        return node;
    }

    void deallocate(ListNode* node) {
        free_list.push_back(node);
    }
};

代码逻辑解读
- allocate() :优先从空闲列表取节点,若为空则批量分配一个区块(减少系统调用频率);
- deallocate() :回收节点时不真正释放内存,而是加入自由链表供复用;
- 整体实现了 对象池模式 ,有效降低动态分配开销。

该方案特别适合生命周期短、数量大的链表操作场景,如事件队列、消息缓冲等。

性能对比示意
分配方式 10万次节点创建耗时(ms) 内存碎片程度
原生 new/delete ~85
内存池 ~12 极低

可见,合理的设计能极大提升链表的实际运行效率。

3.2 单向链表的完整C++实现

单向链表是最基本的链式结构,仅支持向前遍历。其实现虽简单,但包含了链表编程的所有关键细节:内存管理、边界处理、异常安全等。

3.2.1 结构体与类的封装选择

在C++中,我们可以选择使用 struct class 来封装链表。虽然 struct 默认成员公有,适合快速原型开发,但生产级代码推荐使用 class 进行信息隐藏。

class SinglyLinkedList {
private:
    struct Node {
        int data;
        Node* next;
        Node(int val) : data(val), next(nullptr) {}
    };

    Node* head;     // 头指针
    size_t size;    // 当前节点数

public:
    SinglyLinkedList() : head(nullptr), size(0) {}
    ~SinglyLinkedList();
    void push_front(int val);     // 头插
    void push_back(int val);      // 尾插
    bool remove(int val);         // 删除第一个匹配值
    void display() const;         // 打印链表
    size_t length() const { return size; }
    bool empty() const { return head == nullptr; }
};

参数说明
- head :指向首节点的指针,空链表时为 nullptr
- size :维护节点总数,避免遍历计数;
- 私有嵌套 Node 结构增强封装性,防止外部误操作。

使用 class 而非全局 struct 的好处包括:
- 支持构造函数/析构函数自动资源管理;
- 可添加迭代器、异常处理等高级功能;
- 更易于扩展为模板类支持泛型。

3.2.2 增删改查操作的边界条件处理

链表操作中最容易出错的是 边界情况 ,常见有:
- 空链表插入/删除;
- 单节点链表操作;
- 删除头节点;
- 查找不存在的值。

头插法实现
void SinglyLinkedList::push_front(int val) {
    Node* newNode = new Node(val);
    newNode->next = head;  // 新节点指向原头
    head = newNode;        // 更新头指针
    ++size;
}

逐行分析
1. 创建新节点;
2. 将其 next 指向当前 head (即使为 nullptr 也合法);
3. 将 head 更新为新节点;
4. 计数加一。

✅ 自动兼容空链表情形。

尾插法实现
void SinglyLinkedList::push_back(int val) {
    Node* newNode = new Node(val);
    if (!head) {
        head = newNode;  // 空链表直接赋给head
    } else {
        Node* cur = head;
        while (cur->next) cur = cur->next;  // 找到最后一个节点
        cur->next = newNode;
    }
    ++size;
}

关键点 while(cur->next) 确保停在最后一个有效节点,而不是越过末尾造成空指针访问。

删除操作(含边界处理)
bool SinglyLinkedList::remove(int val) {
    if (!head) return false;

    if (head->data == val) {
        Node* temp = head;
        head = head->next;
        delete temp;
        --size;
        return true;
    }

    Node* cur = head;
    while (cur->next && cur->next->data != val)
        cur = cur->next;

    if (cur->next) {
        Node* toDelete = cur->next;
        cur->next = toDelete->next;
        delete toDelete;
        --size;
        return true;
    }
    return false;
}

逻辑分析
- 特判头节点匹配的情况,单独处理;
- 否则用 cur 遍历至待删节点的前驱;
- 使用双指针技巧避免丢失后续链;
- 成功删除返回 true ,否则 false

3.2.3 头插法、尾插法与中间插入的统一逻辑

虽然三种插入方式位置不同,但都可以抽象为“在某个节点后插入”。

插入类型 实现方式 时间复杂度
头插 head 前插入 O(1)
尾插 在最后一个节点后插入 O(n)
中间插 在指定值或索引后插入 O(n)

若要实现按索引插入:

bool insertAt(size_t index, int val) {
    if (index > size) return false;

    if (index == 0) {
        push_front(val);
        return true;
    }

    Node* cur = head;
    for (size_t i = 0; i < index - 1; ++i)
        cur = cur->next;

    Node* newNode = new Node(val);
    newNode->next = cur->next;
    cur->next = newNode;
    ++size;
    return true;
}

参数说明
- index :插入位置(0-based);
- 循环走到第 index-1 个节点,然后在其后插入;
- 若 index == size ,等价于尾插。

3.3 双向链表与循环链表的进阶实现

3.3.1 prev指针的作用与内存开销权衡

双向链表每个节点除了 next 外还包含 prev 指针,使其能够双向遍历。

struct DoubleNode {
    int data;
    DoubleNode* prev;
    DoubleNode* next;
    DoubleNode(int val) : data(val), prev(nullptr), next(nullptr) {}
};

优点:
- 支持反向遍历;
- 删除节点时无需寻找前驱(可通过 node->prev 直接获取);
- 实现双端队列更自然。

缺点:
- 每节点多一个指针(通常8字节),空间开销增加约50%~100%;
- 指针维护更复杂,易出错。

删除操作对比
链表类型 删除节点所需信息
单向 必须知道前驱节点
双向 只需当前节点即可完成
void erase(DoubleNode* node) {
    if (node->prev) node->prev->next = node->next;
    else head = node->next;  // 是头节点

    if (node->next) node->next->prev = node->prev;
    else tail = node->prev;  // 是尾节点

    delete node;
}

优势凸显 :无需遍历查找前驱,O(1)完成删除。

3.3.2 循环结构下的遍历终止判断技巧

循环链表中尾节点的 next 指向头节点(单向循环)或头节点的 prev 指向尾节点(双向循环),形成闭环。

graph TD
    A[Head] --> B
    B --> C
    C --> A

流程图说明 :循环链表无明确终点,遍历需设置起点标记。

遍历示例:

void traverseCircular(DoubleNode* head) {
    if (!head) return;
    DoubleNode* cur = head;
    do {
        std::cout << cur->data << " ";
        cur = cur->next;
    } while (cur != head);
}

关键技巧 :使用 do-while 确保至少执行一次,并以 cur == head 为终止条件。

3.3.3 实现高效的反向遍历与删除操作

双向循环链表支持四种遍历方式:
- 正向遍历
- 反向遍历
- 从任意节点出发正向
- 从任意节点出发反向

反向遍历代码:

void reverseTraverse(DoubleNode* tail) {
    DoubleNode* cur = tail;
    do {
        std::cout << cur->data << " ";
        cur = cur->prev;
    } while (cur != tail);
}

应用场景 :浏览器历史记录“后退/前进”、音乐播放器“上一首/下一首”。

3.4 链表应用实例与常见陷阱

3.4.1 链表反转、合并两个有序链表

反转链表(迭代法)
ListNode* reverseList(ListNode* head) {
    ListNode* prev = nullptr;
    ListNode* curr = head;
    while (curr) {
        ListNode* nextTemp = curr->next;
        curr->next = prev;
        prev = curr;
        curr = nextTemp;
    }
    return prev;
}

逻辑解析
三指针技巧: prev 记录翻转后的头, curr 当前处理节点, nextTemp 暂存后继。

合并两个有序链表
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
    ListNode dummy(0);
    ListNode* tail = &dummy;

    while (l1 && l2) {
        if (l1->data <= l2->data) {
            tail->next = l1;
            l1 = l1->next;
        } else {
            tail->next = l2;
            l2 = l2->next;
        }
        tail = tail->next;
    }

    tail->next = l1 ? l1 : l2;
    return dummy.next;
}

技巧 :使用虚拟头节点简化边界处理。

3.4.2 内存泄漏检测与智能指针初步探索

原始指针易导致泄漏。C++11起可用 std::unique_ptr std::shared_ptr 管理:

#include <memory>

using NodePtr = std::unique_ptr<ListNode>;

struct SmartNode {
    int data;
    NodePtr next;
    SmartNode(int val) : data(val), next(nullptr) {}
};

优势 :离开作用域自动释放资源,无需手动 delete

但仍需注意循环引用问题( shared_ptr 在双向链表中需配合 weak_ptr 使用)。

4. 栈与队列的C++实现及应用场景

栈(Stack)与队列(Queue)作为两种基础但极具代表性的线性数据结构,广泛应用于程序设计、系统架构和算法建模中。它们虽在操作规则上极为简单——栈遵循后进先出(LIFO, Last In First Out),而队列遵循先进先出(FIFO, First In First Out)——但其抽象模型却支撑了大量复杂系统的底层机制。从函数调用管理到任务调度,从表达式求值到图遍历算法,栈与队列扮演着不可或缺的角色。本章将深入剖析这两种结构的逻辑本质,结合 C++ 语言特性完成自定义实现,并通过典型应用场景展示其工程价值。

4.1 栈的抽象模型与LIFO特性

栈是一种受限访问的线性结构,仅允许在一端进行插入(push)和删除(pop)操作,这一端称为“栈顶”(top)。由于这种严格的单向操作限制,数据的存取顺序天然形成 LIFO 模式。理解栈的关键在于掌握其抽象行为而非具体实现方式。无论基于数组还是链表构建,只要满足 push、pop、top 等核心接口的行为一致性,即可视为有效的栈结构。

4.1.1 基于数组与链表的两种实现方案

实现栈有两种主流方式: 静态数组实现 动态链表实现 ,二者在性能特征和适用场景上有显著差异。

实现方式 时间复杂度(均摊) 空间使用 扩展能力 缓存友好性
数组实现 O(1) for all ops 连续内存 固定容量或倍增扩容 高(局部性好)
链表实现 O(1) for all ops 分散内存 动态增长 低(指针跳转)
数组实现代码示例:
#include <iostream>
#include <stdexcept>

class ArrayStack {
private:
    int* data;
    int capacity;
    int topIndex;

public:
    ArrayStack(int cap = 10) : capacity(cap), topIndex(-1) {
        data = new int[capacity];
    }

    ~ArrayStack() {
        delete[] data;
    }

    void push(int value) {
        if (isFull()) {
            throw std::overflow_error("Stack overflow");
        }
        data[++topIndex] = value;
    }

    void pop() {
        if (isEmpty()) {
            throw std::underflow_error("Stack underflow");
        }
        --topIndex;
    }

    int& top() {
        if (isEmpty()) {
            throw std::underflow_error("Stack is empty");
        }
        return data[topIndex];
    }

    bool isEmpty() const { return topIndex == -1; }
    bool isFull() const { return topIndex == capacity - 1; }
    int size() const { return topIndex + 1; }
};

代码逻辑逐行分析:

  • data[++topIndex] = value; :先递增索引再赋值,确保新元素位于当前栈顶位置。
  • 构造函数初始化 topIndex 为 -1,表示空栈状态;当第一个元素入栈时变为 0。
  • 异常处理采用标准库异常类型 std::overflow_error std::underflow_error ,增强鲁棒性。
  • 析构函数显式释放堆内存,防止资源泄漏。

该实现具备良好的缓存局部性,适合频繁访问的小规模数据集。若需支持自动扩容,可在 push 中加入判断并执行 resize() 方法,类似 std::vector 的倍增策略。

链表实现代码示例:
struct ListNode {
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

class LinkedStack {
private:
    ListNode* head;
    int count;

public:
    LinkedStack() : head(nullptr), count(0) {}

    ~LinkedStack() {
        while (head) {
            ListNode* temp = head;
            head = head->next;
            delete temp;
        }
    }

    void push(int value) {
        ListNode* newNode = new ListNode(value);
        newNode->next = head;
        head = newNode;
        ++count;
    }

    void pop() {
        if (isEmpty()) {
            throw std::underflow_error("Stack underflow");
        }
        ListNode* temp = head;
        head = head->next;
        delete temp;
        --count;
    }

    int& top() {
        if (isEmpty()) {
            throw std::underflow_error("Stack is empty");
        }
        return head->val;
    }

    bool isEmpty() const { return head == nullptr; }
    int size() const { return count; }
};

参数说明与扩展讨论:

  • newNode->next = head; head = newNode; 实现头插法,使最新节点始终处于链表前端,符合栈顶语义。
  • 使用 count 成员变量避免遍历计数,保证 size() 操作为 O(1)。
  • 内存分配由 new 完成,每个节点独立存在于堆中,无连续性要求,灵活性更高。
  • 相较于数组版本,链表实现不存在溢出问题(除非内存耗尽),但存在指针开销(每节点额外 8 字节指针)和缓存不命中风险。

4.1.2 push、pop、top操作的异常处理机制

健壮的栈实现必须包含完善的错误检测与异常响应机制。常见异常包括:
- 栈溢出(Overflow) :数组实现中空间不足;
- 栈下溢(Underflow) :对空栈执行 pop top
- 空指针解引用 :链表实现中未检查 head 是否为空。

C++ 提供多种应对策略:

  1. 抛出异常(推荐) :如上述代码所示,使用 <stdexcept> 中的标准异常类;
  2. 返回布尔状态码 :例如 bool pop(int& outValue) ,成功则填充输出参数并返回 true;
  3. 断言(assert) :仅用于调试阶段,生产环境应避免崩溃式中断。

以安全模式为例,可提供带返回值的 try_pop 接口:

bool try_pop(int& result) {
    if (isEmpty()) return false;
    result = head->val;
    ListNode* temp = head;
    head = head->next;
    delete temp;
    --count;
    return true;
}

此设计适用于事件驱动系统或嵌入式环境,避免异常开销的同时提升可控性。

4.1.3 函数调用栈与表达式求值的实际映射

栈不仅是数据结构,更是程序运行时的核心支撑机制之一。 函数调用栈(Call Stack) 是操作系统为每个线程维护的一块内存区域,用于保存活动函数的上下文信息(参数、局部变量、返回地址等)。

函数调用过程模拟流程图(Mermaid):
graph TD
    A[main()] --> B[f1()]
    B --> C[f2()]
    C --> D[f3()]
    D -->|return| C
    C -->|return| B
    B -->|return| A

每次函数调用相当于一次 push 操作,将新的栈帧压入调用栈;函数返回则是 pop ,释放对应帧。递归函数正是利用这一机制实现层层嵌套。

表达式求值中的栈应用

考虑中缀表达式 (3 + 4) * 5 转换为后缀表达式 3 4 + 5 * 后的计算过程:

输入符号 操作 栈内容(底→顶)
3 入栈 [3]
4 入栈 [3, 4]
+ 弹出两数相加,结果入栈 [7]
5 入栈 [7, 5]
* 弹出两数相乘,结果入栈 [35]

最终栈中唯一元素即为表达式结果。

此算法依赖一个操作数栈,在扫描后缀表达式时按如下规则处理:

  • 遇数字 → 入栈;
  • 遇运算符 → 弹出两个操作数,计算后结果入栈;
  • 结束后栈大小应为 1。

代码实现片段如下:

#include <stack>
#include <sstream>
#include <cctype>

int evaluatePostfix(const std::string& expr) {
    std::stack<int> stk;
    std::istringstream iss(expr);
    std::string token;

    while (iss >> token) {
        if (isdigit(token[0])) {
            stk.push(std::stoi(token));
        } else {
            int b = stk.top(); stk.pop();
            int a = stk.top(); stk.pop();
            switch (token[0]) {
                case '+': stk.push(a + b); break;
                case '-': stk.push(a - b); break;
                case '*': stk.push(a * b); break;
                case '/': stk.push(a / b); break;
            }
        }
    }
    return stk.top();
}

逻辑分析:

  • 使用 std::istringstream 按空格分割字符串,便于逐个读取标记;
  • 判断首字符是否为数字来区分操作数与操作符;
  • 注意减法和除法顺序:先弹出的是右操作数;
  • 最终栈顶即为结果,无需额外检查(假设输入合法)。

此案例体现了栈在语法解析与数学建模中的强大能力。

5. 递归原理及其在数据结构中的应用

递归作为计算机科学中一种深刻而优雅的编程范式,不仅是数学归纳思想在程序设计中的自然延伸,更是在处理具有自相似结构的数据时不可或缺的核心工具。从函数自身调用自身的简洁语法背后,隐藏着调用栈管理、状态保存与恢复、问题分解与组合等复杂机制。在数据结构领域,树形结构、链表乃至图的许多操作本质上都具备天然的递归特性。理解递归的工作模型,掌握其执行流程和优化策略,对于构建高效、可读性强且逻辑清晰的算法系统至关重要。

本章将深入剖析递归的底层运行机制,揭示其与分治法之间的内在联系,并通过典型数据结构中的经典案例——如链表反转、二叉树遍历、排序算法实现等——展示递归如何以极简代码表达复杂逻辑。同时,针对递归常见的性能瓶颈,如重复计算和栈溢出风险,引入记忆化技术和尾递归优化方法,探讨编译器支持与手动转换为迭代的可能性,从而实现理论深度与工程实践的统一。

5.1 递归的数学基础与执行模型

递归的本质来源于数学中的递推关系和归纳定义。它允许一个函数在其定义体内直接或间接地调用自身,以此来解决规模逐渐缩小的子问题,直到达到一个无需进一步递归的基本情况(base case)。这种“大问题依赖小问题解”的思维方式,在处理具有嵌套或层级结构的问题时表现出极大的优势。

现代程序语言通过运行时的 调用栈 (Call Stack)机制支持递归。每当函数被调用时,系统会为其分配一个新的栈帧(stack frame),用于存储局部变量、参数、返回地址等信息。当递归发生时,每层调用都会压入新的栈帧,形成一条由外向内的调用链条。只有当最深层的 base case 返回后,这些栈帧才开始逐层弹出并完成计算。因此,递归的正确性不仅取决于逻辑是否严密,还受限于运行环境对栈空间的限制。

为了更清晰地理解这一过程,下面从数学建模出发,逐步过渡到程序执行细节。

5.1.1 分治思想与递推关系建立

分治法(Divide and Conquer)是递归算法设计的重要指导原则,其核心步骤包括:

  1. 分解 (Divide):将原问题划分为若干个规模较小但结构相同的子问题;
  2. 解决 (Conquer):递归求解各个子问题;
  3. 合并 (Combine):将子问题的解组合成原问题的解。

以经典的阶乘函数为例,$ n! = n \times (n-1)! $ 是一个典型的递推关系。其递归定义如下:

\text{factorial}(n) =
\begin{cases}
1 & \text{if } n = 0 \
n \times \text{factorial}(n - 1) & \text{if } n > 0
\end{cases}

该公式明确表达了两个要素: 递归关系式 终止条件 。缺少任一都将导致无限递归或逻辑错误。

在实际编码中,我们可以通过 C++ 实现如下:

int factorial(int n) {
    if (n == 0) return 1;           // Base case
    return n * factorial(n - 1);    // Recursive call
}
代码逻辑逐行分析:
  • 第1行:函数声明,接收整型参数 n
  • 第2行:判断是否到达基本情况( n == 0 ),若是则返回1,防止无限递归。
  • 第3行:否则,返回 n 乘以 factorial(n - 1) 的结果,即进入下一层递归调用。
参数说明:
  • n :非负整数,表示要求阶乘的数值。
  • 时间复杂度:O(n),因为需要进行 n 次函数调用。
  • 空间复杂度:O(n),由于每次调用都在调用栈上保留一个帧,最大深度为 n。

尽管此实现简洁直观,但在较大输入下可能引发栈溢出。例如,当 n > 10000 时,多数系统的默认栈大小不足以容纳如此多的嵌套调用。

输入值 函数调用次数 栈帧数量 是否安全
5 6 6 安全
100 101 101 安全
10000 10001 10001 危险
100000 超过栈限制 崩溃 不可用

⚠️ 注意:递归深度受操作系统和编译器设置影响,可通过 -Wstack-usage= 编译选项检测单个函数的栈使用情况。

此外,递推关系还可用于描述更复杂的结构。例如斐波那契数列:

F(n) = F(n-1) + F(n-2), \quad F(0)=0, F(1)=1

虽然形式简单,但朴素递归实现会导致指数级时间复杂度,将在后续章节详细讨论优化方案。

5.1.2 调用栈的工作机制与递归深度限制

调用栈是递归得以实现的关键基础设施。它是内存中一段连续区域,遵循 LIFO(后进先出)原则,用于管理函数调用上下文。每一次函数调用都会创建一个新的 活动记录 (activation record),包含以下内容:

  • 函数参数
  • 局部变量
  • 返回地址
  • 控制链指针(指向父调用者的栈帧)

当函数返回时,该栈帧被销毁,控制权交还给上层调用者。

考虑如下递归函数追踪示例:

void countdown(int n) {
    if (n <= 0) {
        std::cout << "Done!\n";
        return;
    }
    std::cout << n << "...\n";
    countdown(n - 1);
    std::cout << "Back from " << n << "\n";
}

假设调用 countdown(3) ,其执行过程可表示为以下 mermaid 流程图

graph TD
    A["main → countdown(3)"] --> B["countdown(3): 打印 '3...'<br>调用 countdown(2)"]
    B --> C["countdown(2): 打印 '2...'<br>调用 countdown(1)"]
    C --> D["countdown(1): 打印 '1...'<br>调用 countdown(0)"]
    D --> E["countdown(0): 打印 'Done!'"]
    E --> F["返回至 countdown(1)"]
    F --> G["打印 'Back from 1'"]
    G --> H["返回至 countdown(2)"]
    H --> I["打印 'Back from 2'"]
    I --> J["返回至 countdown(3)"]
    J --> K["打印 'Back from 3'"]
    K --> L["最终返回 main"]

该流程图清晰展示了递归调用的“下降”与“回升”两个阶段:

  • 下降阶段 :从 countdown(3) countdown(0) ,不断压栈;
  • 回升阶段 :从 countdown(0) 返回,依次执行未完成的语句,逐层出栈。

这也解释了为何某些递归函数在 base case 触发后仍会继续执行后续代码——它们正处于“回溯”过程中。

不同平台对递归深度的限制差异显著:

平台/编译器 默认栈大小 允许的最大递归深度(估算)
Linux (g++) 8 MB ~8000–10000
Windows (MSVC) 1 MB ~1000–2000
WebAssembly 更严格限制 < 1000
嵌入式系统 可能仅 KB < 100

因此,在高可靠性系统中必须谨慎评估递归深度,必要时采用迭代改写或尾递归优化。

5.1.3 递归与迭代的等价转换分析

理论上,任何递归算法都可以转化为等价的迭代版本,反之亦然。这是因为两者共享相同的状态转移逻辑,只是控制流的表现形式不同。

以阶乘为例,其迭代实现如下:

int factorial_iterative(int n) {
    int result = 1;
    for (int i = 1; i <= n; ++i) {
        result *= i;
    }
    return result;
}
代码逻辑解析:
  • 初始化 result = 1
  • 使用循环变量 i 从 1 到 n,逐步累积乘积;
  • 循环结束即得结果。

相比递归版本,该实现的空间复杂度降为 O(1),避免了栈溢出风险,且运行效率更高(无函数调用开销)。

然而,并非所有递归都能轻易转为迭代。尤其是涉及多分支递归(如树遍历)的情况,需显式维护一个栈结构来模拟调用过程。例如,前序遍历二叉树的递归写法极为简洁:

void preorder(TreeNode* root) {
    if (!root) return;
    std::cout << root->val << " ";
    preorder(root->left);
    preorder(root->right);
}

若改为迭代,则需借助 std::stack<TreeNode*> 显式管理访问顺序:

void preorder_iterative(TreeNode* root) {
    if (!root) return;
    std::stack<TreeNode*> stk;
    stk.push(root);

    while (!stk.empty()) {
        TreeNode* node = stk.top(); stk.pop();
        std::cout << node->val << " ";

        // 先右后左,保证左子树先处理
        if (node->right) stk.push(node->right);
        if (node->left)  stk.push(node->left);
    }
}
特性 递归版本 迭代版本
代码可读性
空间复杂度 O(h),h为树高 O(h),但使用堆栈而非调用栈
是否易出错 较少(逻辑清晰) 较多(需手动管理栈顺序)
可调试性 强(调用栈可见) 弱(需打印中间状态)
编译器优化潜力 尾递归可优化 已是最优

由此可见,递归更适合表达结构性问题,而迭代更适合资源敏感场景。开发者应根据具体需求权衡选择。

5.2 数据结构中的经典递归算法

递归与数据结构之间存在天然契合。链表、树等结构本身就具有递归定义特征:一个链表节点指向另一个链表;一棵树由根节点和若干子树构成。利用递归可以极大简化对这类结构的操作实现。

本节聚焦三大经典应用场景:链表逆序、二叉树遍历、以及基于分治思想的排序算法,展示递归如何将复杂操作转化为直观的逻辑表达。

5.2.1 链表逆序的递归实现路径

链表逆序是一个典型的递归问题。传统迭代方法需维护三个指针(prev, curr, next),逻辑较繁琐。而递归方式则能更自然地体现“先处理后续部分,再调整当前连接”的思维。

设有一个单向链表节点定义如下:

struct ListNode {
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

目标是反转整个链表。递归思路如下:

  1. 若当前节点为空或仅剩一个节点,直接返回该节点(作为新头结点);
  2. 递归处理 head->next 子链,得到新的头结点 newHead
  3. 调整当前节点与下一个节点的链接方向: head->next->next = head
  4. 断开当前节点的后向连接: head->next = nullptr
  5. 返回 newHead

完整实现如下:

ListNode* reverseList(ListNode* head) {
    if (!head || !head->next) return head;        // Base case

    ListNode* newHead = reverseList(head->next);  // Recursively reverse rest
    head->next->next = head;                      // Reverse link
    head->next = nullptr;                         // Avoid cycle

    return newHead;
}
代码逐行解读:
  • 第2行:检查是否为空或最后一个节点,是则直接返回,作为反转后的尾部(即新头部);
  • 第5行:递归调用获得已反转的子链的新头结点;
  • 第6行:将原来 head->next 指向 head ,实现指针翻转;
  • 第7行:切断原向后指针,防止形成环;
  • 第9行:始终返回同一新头结点。
示例跟踪(反转 1→2→3):
  1. reverseList(1) → 调用 reverseList(2)
  2. reverseList(2) → 调用 reverseList(3)
  3. reverseList(3) → 返回 3 (base case)
  4. 回溯: 2->next->next = 2 3->next = 2
  5. 2->next = nullptr
  6. 继续回溯: 1->next->next = 1 2->next = 1
  7. 1->next = nullptr
  8. 最终返回 3 ,链表变为 3→2→1
步骤 当前节点 子链返回头 操作
1 3 3 直接返回
2 2 3 3→2, 2→null
3 1 3 2→1, 1→null

该算法时间复杂度为 O(n),空间复杂度为 O(n)(调用栈深度)。

5.2.2 二叉树遍历(前序、中序、后序)的自然递归表达

二叉树的三种深度优先遍历方式——前序、中序、后序——天然适合递归实现。其区别在于访问根节点的时机:

  • 前序 :根 → 左 → 右
  • 中序 :左 → 根 → 右
  • 后序 :左 → 右 → 根

节点结构定义如下:

struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

以前序遍历为例:

void preorder(TreeNode* root) {
    if (!root) return;
    std::cout << root->val << " ";   // 访问根
    preorder(root->left);            // 遍历左子树
    preorder(root->right);           // 遍历右子树
}

中序与后序只需调整语句顺序:

void inorder(TreeNode* root) {
    if (!root) return;
    inorder(root->left);
    std::cout << root->val << " ";
    inorder(root->right);
}

void postorder(TreeNode* root) {
    if (!root) return;
    postorder(root->left);
    postorder(root->right);
    std::cout << root->val << " ";
}
递归优势分析:
  • 逻辑清晰 :完全对应定义,无需额外数据结构;
  • 易于扩展 :可在任意位置插入业务逻辑(如收集节点值);
  • 便于调试 :调用栈反映访问路径。

例如,收集所有节点值的前序遍历:

void preorder_collect(TreeNode* root, std::vector<int>& res) {
    if (!root) return;
    res.push_back(root->val);
    preorder_collect(root->left, res);
    preorder_collect(root->right, res);
}

5.2.3 快速排序与归并排序的分治递归结构

快速排序与归并排序是分治递归的经典代表。

快速排序(QuickSort)

核心思想:选取基准(pivot),将数组分为小于和大于两部分,递归排序各部分。

void quickSort(std::vector<int>& arr, int low, int high) {
    if (low < high) {
        int pi = partition(arr, low, high);   // 分区操作
        quickSort(arr, low, pi - 1);          // 排序左半
        quickSort(arr, pi + 1, high);         // 排序右半
    }
}

其中 partition 函数负责重排元素,使 pivot 左侧均小于它,右侧均大于它。

归并排序(Merge Sort)

思想:将数组平分,递归排序两部分,然后合并有序子数组。

void mergeSort(std::vector<int>& arr, int left, int right) {
    if (left >= right) return;

    int mid = left + (right - left) / 2;
    mergeSort(arr, left, mid);         // 排序左半
    mergeSort(arr, mid + 1, right);    // 排序右半
    merge(arr, left, mid, right);      // 合并结果
}

merge 函数需额外 O(n) 空间进行合并操作。

算法 时间复杂度(平均) 最坏情况 空间复杂度 是否稳定
QuickSort O(n log n) O(n²) O(log n)
MergeSort O(n log n) O(n log n) O(n)

二者均体现了递归在大规模数据处理中的强大表达能力。

5.3 递归优化技术:记忆化与尾递归

尽管递归代码优美,但原始实现常伴随严重性能问题,尤其是存在大量重复子问题或深层调用时。为此,发展出两类关键优化技术: 记忆化 (Memoization)与 尾递归消除 (Tail Call Optimization)。

5.3.1 斐波那契数列的指数级到线性级优化

朴素递归版斐波那契:

int fib_naive(int n) {
    if (n <= 1) return n;
    return fib_naive(n - 1) + fib_naive(n - 2);
}

时间复杂度高达 O(2ⁿ),因存在大量重复计算。例如 fib(5) 会多次计算 fib(3) fib(2)

引入记忆化缓存已计算结果:

#include <unordered_map>
std::unordered_map<int, int> memo;

int fib_memo(int n) {
    if (n <= 1) return n;
    if (memo.find(n) != memo.end()) return memo[n];
    memo[n] = fib_memo(n - 1) + fib_memo(n - 2);
    return memo[n];
}

此时时间复杂度降至 O(n),空间复杂度 O(n)。

也可使用动态规划自底向上实现:

int fib_dp(int n) {
    if (n <= 1) return n;
    std::vector<int> dp(n + 1);
    dp[0] = 0; dp[1] = 1;
    for (int i = 2; i <= n; ++i)
        dp[i] = dp[i-1] + dp[i-2];
    return dp[n];
}

进一步可优化空间至 O(1):

int fib_optimized(int n) {
    if (n <= 1) return n;
    int a = 0, b = 1, c;
    for (int i = 2; i <= n; ++i) {
        c = a + b;
        a = b;
        b = c;
    }
    return b;
}
方法 时间复杂度 空间复杂度 是否推荐
朴素递归 O(2ⁿ) O(n)
记忆化递归 O(n) O(n) 是(教学)
动态规划 O(n) O(n)
空间优化版 O(n) O(1) 最佳

5.3.2 尾递归消除的可能性与编译器支持

尾递归指递归调用出现在函数末尾,且其返回值直接作为当前函数的返回值。这类递归可被编译器优化为循环,避免栈增长。

例如,阶乘的尾递归版本:

int factorial_tail(int n, int acc = 1) {
    if (n == 0) return acc;
    return factorial_tail(n - 1, acc * n);
}

此处 acc 为累积器,保存中间结果。由于最后一次操作是递归调用,无后续计算,编译器可在支持 TCO(Tail Call Optimization)时将其转换为跳转指令。

GCC 在 -O2 或更高优化级别下通常支持尾递归优化。可通过汇编输出验证:

g++ -S -O2 code.cpp

查看生成的 .s 文件中是否存在 call 指令重复调用自身。

然而,C++ 标准并未强制要求 TCO,因此跨平台兼容性不能保证。在关键系统中,建议手动改写为迭代形式以确保稳定性。

编译器 支持 TCO 条件
GCC -O2 及以上
Clang -O1 及以上
MSVC 有限 某些简单情况
Embedded 通常不支持 栈资源紧张

综上所述,尾递归是一种重要的优化手段,但应结合实际部署环境审慎使用。

6. 二叉树结构与遍历算法实现

6.1 二叉树的逻辑结构与性质

二叉树是一种典型的非线性数据结构,其每个节点最多有两个子节点:左子节点和右子节点。这种结构天然适合表达具有层次关系或分支决策路径的数据。

满二叉树、完全二叉树与一般二叉树的区别

  • 满二叉树 (Full Binary Tree):除叶子节点外,每个节点都有两个子节点。
  • 完全二叉树 (Complete Binary Tree):除了最后一层外,其余层全满;最后一层从左到右连续填充。
  • 一般二叉树 :无任何形状限制,是最通用的形式。
类型 特点 示例
满二叉树 所有非叶节点度为2 高度h时,总节点数=2^h - 1
完全二叉树 层序紧凑,适合数组存储 堆结构的基础
一般二叉树 形状任意,灵活性高 表达式树、决策树等

层次编号与数组表示法对应关系

对于完全二叉树,可以用一维数组高效存储:
- 若根节点索引为0,则:
- 左孩子: 2*i + 1
- 右孩子: 2*i + 2
- 父节点: (i - 1) / 2

例如,以下树结构:

       A
     /   \
    B     C
   / \   /
  D   E F

在数组中可表示为: [A, B, C, D, E, F] ,下标0~5。

深度、高度与节点数量的关系公式

设二叉树高度为 h (根高度为0),则:

性质 公式
最大节点数(满树) $2^{h+1} - 1$
最小高度(n个节点) $\lceil \log_2(n+1) \rceil - 1$
叶子节点数(满树) $2^h$
边数 $n - 1$

这些数学关系是分析树效率的重要基础。

6.2 二叉树的C++节点类设计与构建

TreeNode类的设计与内存管理

struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;

    // 构造函数
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}

    // 支持带左右孩子的构造
    TreeNode(int x, TreeNode* l, TreeNode* r) : val(x), left(l), right(r) {}
};

该结构体使用原始指针管理子树,需手动释放资源。

手动构建测试用例树

TreeNode* buildSampleTree() {
    /*
           1
         /   \
        2     3
       / \     \
      4   5     6
    */
    TreeNode* root = new TreeNode(1);
    root->left = new TreeNode(2);
    root->right = new TreeNode(3);
    root->left->left = new TreeNode(4);
    root->left->right = new TreeNode(5);
    root->right->right = new TreeNode(6);
    return root;
}

此函数返回一个用于遍历测试的固定结构树。

析构函数与资源释放策略

由于涉及动态内存分配,应在封装类中实现析构逻辑:

class BinaryTree {
private:
    TreeNode* root;

    void destroy(TreeNode* node) {
        if (node) {
            destroy(node->left);  // 后序释放
            destroy(node->right);
            delete node;
        }
    }

public:
    ~BinaryTree() {
        destroy(root);
    }
};

采用后序遍历方式确保先释放子节点再释放父节点,避免悬空指针。

6.3 四种主要遍历方式的编码实现

递归版前/中/后序遍历模板

void preorder(TreeNode* root, vector<int>& res) {
    if (!root) return;
    res.push_back(root->val);     // 访问根
    preorder(root->left, res);    // 遍历左
    preorder(root->right, res);   // 遍历右
}

void inorder(TreeNode* root, vector<int>& res) {
    if (!root) return;
    inorder(root->left, res);     // 左
    res.push_back(root->val);     // 根
    inorder(root->right, res);    // 右
}

void postorder(TreeNode* root, vector<int>& res) {
    if (!root) return;
    postorder(root->left, res);   // 左
    postorder(root->right, res);  // 右
    res.push_back(root->val);     // 根
}

时间复杂度均为 O(n),空间复杂度平均 O(log n),最坏 O(n)。

非递归遍历:利用栈模拟调用过程

以中序遍历为例,手动维护栈:

vector<int> inorderIterative(TreeNode* root) {
    vector<int> res;
    stack<TreeNode*> stk;
    TreeNode* curr = root;

    while (curr || !stk.empty()) {
        while (curr) {
            stk.push(curr);
            curr = curr->left;  // 一直向左压栈
        }
        curr = stk.top(); stk.pop();
        res.push_back(curr->val);  // 输出当前节点
        curr = curr->right;        // 转向右子树
    }
    return res;
}

该方法显式模拟系统调用栈行为,适用于栈溢出风险高的深递归场景。

层序遍历:基于队列逐层展开

vector<vector<int>> levelOrder(TreeNode* root) {
    vector<vector<int>> result;
    if (!root) return result;

    queue<TreeNode*> q;
    q.push(root);

    while (!q.empty()) {
        int levelSize = q.size();
        vector<int> currentLevel;

        for (int i = 0; i < levelSize; ++i) {
            TreeNode* node = q.front(); q.pop();
            currentLevel.push_back(node->val);

            if (node->left) q.push(node->left);
            if (node->right) q.push(node->right);
        }
        result.push_back(currentLevel);
    }
    return result;
}

输出格式为每层一个子数组,便于可视化树形结构。

mermaid 流程图展示层序遍历执行流程:

graph TD
    A[根节点入队] --> B{队列非空?}
    B -->|是| C[出队一个节点]
    C --> D[访问该节点值]
    D --> E[左孩子入队?]
    E --> F[右孩子入队?]
    F --> B
    B -->|否| G[结束遍历]

6.4 二叉树应用拓展:表达式树与决策树雏形

构建算术表达式的语法树

表达式 (3 + 5) * 2 可建模为:

       *
     /   \
    +     2
   / \
  3   5

构建代码:

TreeNode* buildExpressionTree() {
    TreeNode* plus = new TreeNode('+', new TreeNode(3), new TreeNode(5));
    return new TreeNode('*', plus, new TreeNode(2));
}

操作符作为内部节点,操作数作为叶子节点。

遍历生成不同形式的表达式

  • 前序遍历 → 前缀表达式(波兰表示法): * + 3 5 2
  • 中序遍历 → 中缀表达式(需加括号): ((3 + 5) * 2)
  • 后序遍历 → 后缀表达式(逆波兰): 3 5 + 2 *

这三种表达式广泛应用于编译器解析与计算器实现中。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:《C++数据结构第三版》是一本国外经典英文教材,系统讲解C++语言环境下各类核心数据结构的设计与实现。内容涵盖数组、链表、栈、队列、树(二叉树、AVL树、红黑树)、图、哈希表等基础结构,并结合C++特性如指针、引用、模板和STL容器进行高效实现。书中还深入探讨排序与查找算法,强化算法与数据结构的协同应用。本书适用于计算机专业学生、软件工程师及自学者,通过实践提升程序性能优化、内存管理与抽象思维能力,是掌握现代C++数据结构的优质学习资源。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐