> For the complete documentation index, see [llms.txt](https://datastructure-1.gitbook.io/datastructure-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://datastructure-1.gitbook.io/datastructure-docs/di-yi-bu-fen-xu-lun/01-complexity-adt.md).

# 第 1 章：复杂度分析与 ADT

[<- 已是第一章](/datastructure-docs/di-yi-bu-fen-xu-lun/01-complexity-adt.md) | [课程目录](/datastructure-docs/readme.md) | [第 2 章　线性表 ->](/datastructure-docs/di-er-bu-fen-xian-xing-jie-gou/02-linear-list.md)

> 所属模块：第一部分：绪论\
> 对应实现：complexity\_adt.cpp

### 本章导航

* [1.1 算法评价的本质：为什么需要复杂度分析？](#c01-1-1)
* [1.2 三大渐进记号的严格定义与几何直观](#c01-1-2)
* [1.3 常见复杂度阶级与物理时空感受](#c01-1-3)
* [1.4 代码复杂度分析实战法则](#c01-1-4)
* [1.5 分治递归的利器：主定理（Master Theorem）与递归树直观](#c01-1-5)
* [1.6 均摊分析（Amortized Analysis）三大法宝](#c01-1-6)
* [1.7 抽象数据类型（ADT）与现代 C++ 契约设计](#c01-1-7)
* [本章小结](#c01-summary)
* [思考题与课后自测](#c01-exercises)

***

## 第 1 章　复杂度分析与 ADT

> 难度：★☆☆☆☆ | 代码：[complexity\_adt.cpp](https://github.com/Huo0077/DataStructure/blob/main/complexity_adt.cpp)

**前置知识**：基本的 C++ 语法、函数调用与循环结构。本章为全书的理论基石，定义了全书用于度量算法优劣的统一语言。

**学习目标**：

1. 建立对“增长率”而非“绝对运行秒数”的直观敏感度；
2. 掌握 (O, \Omega, \Theta) 三大渐进记号的数学定义、极限判别法与适用语境；
3. 熟练运用主定理（Master Theorem）和递归树求解分治递归式；
4. 理解均摊分析（聚合分析、记账法、势能法）与“平均情况”的本质区别；
5. 掌握抽象数据类型（ADT）的核心理念：以“接口与实现分离”构建高内聚低耦合的代码。

***

### 1.1 算法评价的本质：为什么需要复杂度分析？

许多初学者在刚接触编程时，往往习惯使用 `std::chrono` 记录代码运行前后的时间戳，用“耗费了多少毫秒”来衡量一个算法的快慢。然而，**绝对运行时间是极不可靠的**：

* 它受制于硬件配置（酷睿 i9 与树莓派的执行速度判若云泥）；
* 它受制于编译器优化等级（`-O0` 与 `-O3` 差异可达数倍至数十倍）；
* 它受制于输入规模与测试数据的偶然性（对 10 个数字排序和对 1000 万个数字排序无法直接比较）。

为了摆脱硬件与实现细节的纠缠，计算机科学家图灵奖得主 Donald Knuth 等人确立了\*\*渐进分析（Asymptotic Analysis）\*\*框架：

* **核心关注点**：当输入规模 (n \to \infty) 时，算法运行时间或空间占用的**增长率（Growth Rate）**。
* **基本假设（RAM 模型）**：每条基本指令（加减乘除、指针解引用、数组寻址、赋值）均在常数时间 (O(1)) 内完成。

```
算法 A: T(n) = 100n
算法 B: T(n) = 0.01n²

当 n = 10 时:    T_A = 1000,    T_B = 1       (算法 B 胜出)
当 n = 1000 时:  T_A = 100,000, T_B = 10,000  (算法 B 依然较快)
当 n = 100,000 时: T_A = 10⁷,   T_B = 10⁸     (算法 A 彻底反超!)
当 n = 10⁷ 时:   T_A = 10⁹,     T_B = 10¹²    (算法 B 已经崩溃，相差千倍)
```

**结论**：高阶项永远主导低阶项。随着数据规模扩大，高阶算法的劣势无法通过升级硬件来弥补。这就是我们为什么追求更低复杂度算法的根本原因。

***

### 1.2 三大渐进记号的严格定义与几何直观

在经典教材（如 CLRS《算法导论》、邓俊辉《数据结构》）中，渐进记号有着严格的集合与极限定义。

```
       f(n) 增长速度与上/下界关系
  函数值 ^
        │             c₂·g(n)  [上界 O]
        │            /
        │    f(n)   /
        │     ~~\  /
        │        \/     c₁·g(n)  [下界 Ω]
        │        /\    /
        │       /  \  /
        │      /    \/
        │     /     /\
        └────┴─────┴──┴────────────> 输入规模 n
             0     n₀
```

#### 1.2.1 渐进上界：(O)（Big-Oh）

* **数学定义**：\
  (O(g(n)) = { f(n) \mid \exists c > 0, n\_0 > 0, \text{使得 } \forall n \ge n\_0, 0 \le f(n) \le c \cdot g(n) })
* **含义**：存在一个常数 (c) 和阈值 (n\_0)，当 (n) 超过 (n\_0) 后，(f(n)) 永远不会超过 (c \cdot g(n))。它刻画了算法性能的**最坏情况上界**。
* **极限法判定**：若 (\lim\_{n \to \infty} \frac{f(n)}{g(n)} = L < \infty)，则 (f(n) = O(g(n)))。

#### 1.2.2 渐进下界：(\Omega)（Big-Omega）

* **数学定义**：\
  (\Omega(g(n)) = { f(n) \mid \exists c > 0, n\_0 > 0, \text{使得 } \forall n \ge n\_0, 0 \le c \cdot g(n) \le f(n) })
* **含义**：算法无论在何种情况下，其耗时增长率**至少**和 (g(n)) 一样快。它常用于证明某个问题的**理论计算下界**（例如：任何基于比较的排序问题其时间下界为 (\Omega(n \log n))）。

#### 1.2.3 渐进紧确界：(\Theta)（Big-Theta）

* **数学定义**：\
  (\Theta(g(n)) = { f(n) \mid \exists c\_1, c\_2 > 0, n\_0 > 0, \text{使得 } \forall n \ge n\_0, c\_1 \cdot g(n) \le f(n) \le c\_2 \cdot g(n) })
* **充要条件**：(f(n) = \Theta(g(n)) \iff f(n) = O(g(n)) \text{ 且 } f(n) = \Omega(g(n)))。
* **含义**：(f(n)) 与 (g(n)) 同阶增长，上下界被严格夹逼。

> **工程提示与误区澄清**：\
> 口语中工程师常说的“这个算法复杂度是 (O(n \log n))”，如果它在最好、最坏和平均情况下都是这个量级（如归并排序），学术上更严谨的表述其实是 (\Theta(n \log n))。但根据业界惯例，一般用 (O) 来泛指最坏情况运行时间。

***

### 1.3 常见复杂度阶级与物理时空感受

下表以现代主流计算机（假设每秒执行 (10^8) 次基本操作，约 100 MIPS）为基准，直观感受不同量级在不同规模下的表现：

| 记号            | 名称    | (n=100) 计算步数           | (n=10^6) 耗时预估             | 典型场景 / 算法                     |
| ------------- | ----- | ---------------------- | ------------------------- | ----------------------------- |
| (O(1))        | 常数阶   | 1                      | (\approx 10 \text{ ns})   | 数组寻址、哈希表查找、栈 push/pop         |
| (O(\log n))   | 对数阶   | (\approx 7)            | (\approx 0.2 \mu\text{s}) | 二分查找、平衡树（AVL/红黑树）查找           |
| (O(n))        | 线性阶   | 100                    | (\approx 10 \text{ ms})   | 线性遍历扫描、计数排序、单链表遍历             |
| (O(n \log n)) | 线性对数阶 | (\approx 700)          | (\approx 0.2 \text{ s})   | 快速排序、归并排序、堆排序、倍增算法            |
| (O(n^2))      | 平方阶   | (10^4)                 | (\approx 2.7 \text{ 小时})  | 冒泡/选择/插入排序、双重循环枚举             |
| (O(n^3))      | 立方阶   | (10^6)                 | (\approx 31.7 \text{ 年})  | Floyd-Warshall 全源最短路、矩阵乘法朴素算法 |
| (O(2^n))      | 指数阶   | (1.27 \times 10^{30})  | 宇宙毁灭亦无法算完                 | 旅行商问题（TSP）暴力回溯、子集枚举           |
| (O(n!))       | 阶乘阶   | (9.33 \times 10^{157}) | 不可承受之重                    | 全排列暴力生成                       |

> **关键经验法则（竞赛与系统设计通用）**：
>
> * 若 (n \le 10^8)：可设计 (O(n)) 算法；
> * 若 (n \le 10^5 \sim 10^6)：必须控制在 (O(n \log n))；
> * 若 (n \le 1000 \sim 5000)：允许 (O(n^2))；
> * 若 (n \le 20 \sim 30)：通常预示着要使用状压 DP 或回溯搜索（(O(2^n)) 或 (O(n \cdot 2^n))）。

***

### 1.4 代码复杂度分析实战法则

分析一段代码的时间复杂度并不需要复杂的微积分，掌握以下组合规则即可化繁为简：

1. **加法法则（顺序执行取大者）**：\
   (T(n) = T\_1(n) + T\_2(n) = O(f(n)) + O(g(n)) = O(\max(f(n), g(n))))\
   *例*：先对数组排序（(O(n \log n))），再遍历打印一遍（(O(n))），总体耗时依然是 (O(n \log n))。
2. **乘法法则（嵌套循环取乘积）**：\
   (T(n) = T\_1(n) \times T\_2(n) = O(f(n)) \times O(g(n)) = O(f(n) \cdot g(n)))\
   *例*：外层循环遍历 (n) 次，内层调用二分查找（(O(\log n))），总体为 (O(n \log n))。
3. **对数步长循环**：

   ```cpp
   for (int i = 1; i < n; i *= 2) {
       // 每次 i 翻倍，循环次数满足 2^k >= n => k = ceil(log2 n)
   } // 复杂度 O(log n)
   ```
4. **双指针 / 滑动窗口技巧（均摊分析的雏形）**：

   ```cpp
   int left = 0;
   for (int right = 0; right < n; ++right) {
       while (condition && left <= right) {
           left++; // 尽管有两层循环，但 left 在整个算法过程中最多递增 n 次
       }
   } // 总体依然是 O(n) 而非 O(n²)
   ```

***

### 1.5 分治递归的利器：主定理（Master Theorem）与递归树直观

分治算法将规模为 (n) 的大问题拆分为 (a) 个规模为 (n/b) 的子问题，拆分与合并的耗时为 (f(n))。其通用的递归关系式为： \[ T(n) = a \cdot T\left(\frac{n}{b}\right) + f(n) ] 其中 (a \ge 1, b > 1)，(f(n)) 为渐进正函数。

#### 1.5.1 递归树的直观博弈

在递归树模型中：

* **树的深度**为 (\log\_b n)；
* **第 (i) 层节点数**为 (a^i)；
* **叶子节点总数**为 (a^{\log\_b n} = n^{\log\_b a})；
* **博弈的核心**：叶子节点的总工作量 (\Theta(n^{\log\_b a})) 与 根节点拆分合并代价 (f(n)) 谁占主导地位？

```
                  f(n)                  ── 第 0 层: f(n)
              /    │    \
          f(n/b) f(n/b) f(n/b) ...      ── 第 1 层: a · f(n/b)
          /  \    /  \    /  \
        ...  ... ... ... ... ...        ── 第 2 层: a² · f(n/b²)
        /      \   /      \
      Θ(1)    Θ(1) Θ(1)  Θ(1) ...      ── 叶子层: n^(log_b a) 个节点，总计 Θ(n^(log_b a))
```

#### 1.5.2 三种情况判定表

| 情况               | 条件（本质）                                                                          | 结论                                    | 典型算法实例                                                                                                                                                            |
| ---------------- | ------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Case 1: 树叶主导** | (f(n) = O(n^{\log\_b a - \epsilon})), (\epsilon > 0)（底层叶子开销远大于顶层）               | (T(n) = \Theta(n^{\log\_b a}))        | Strassen 矩阵乘法：(T(n) = 7T(n/2) + O(n^2) \Rightarrow \Theta(n^{\log\_2 7}) \approx \Theta(n^{2.81}))                                                                |
| **Case 2: 各层均等** | (f(n) = \Theta(n^{\log\_b a}))（每层工作量同阶）                                         | (T(n) = \Theta(n^{\log\_b a} \log n)) | <p>归并排序：(T(n) = 2T(n/2) + O(n) \Rightarrow a=2,b=2, n^{\log\_2 2}=n^1 \Rightarrow \Theta(n \log n))<br>二分查找：(T(n) = T(n/2) + O(1) \Rightarrow \Theta(\log n))</p> |
| **Case 3: 树根主导** | (f(n) = \Omega(n^{\log\_b a + \epsilon})) 且满足正则条件 (a f(n/b) \le c f(n) (c < 1)) | (T(n) = \Theta(f(n)))                 | 某些特殊树型查找：(T(n) = 2T(n/2) + O(n^2) \Rightarrow \Theta(n^2))                                                                                                        |

***

### 1.6 均摊分析（Amortized Analysis）三大法宝

在工程中，常常遇到这样一种数据结构：**大多数操作极其便宜（如 (O(1))），但偶尔某次操作会非常昂贵（如 (O(n))）**。如果仅仅用最坏情况 (O(n)) 来概括每一次操作，会极其悲观且失真。

> **核心原则**：均摊分析保证的是**任意连续 (m) 次操作序列的最坏总代价不超过 (m \cdot T\_{\text{amortized}})**。它不是统计学上的“平均情况”（不需要假设概率分布），而是拥有数学确定性的最坏界限！

#### 方法一：聚合分析（Aggregate Analysis）

直接求出包含 (n) 次操作的整个序列的总开销 (T\_{\text{total}}(n))，然后均摊为 (T\_{\text{total}}(n) / n)。

* **动态数组扩容案例**：\
  设初始容量为 1，每次满时容量翻倍（1 -> 2 -> 4 -> 8 -> ... -> (2^k)）。\
  连续插入 (n) 个元素，只有当 (i = 1, 2, 4, 8, \dots) 时才触发复制。\
  所有搬移操作的总次数为：(1 + 2 + 4 + \dots + 2^{\lfloor \log\_2 n \rfloor} < 2n)。\
  加上每个元素本身写入的 (n) 次开销，总成本 (< 3n)。\
  故每次 `push_back` 的均摊时间为 (3n / n = O(1))。

#### 方法二：记账法（Accounting Method）

为每种操作制定一个“虚拟收费标准（均摊代价）”。当实际成本低于收费时，将盈余作为“信用（Credit）”存入数据结构的节点中；当发生昂贵操作时，提取已存入的信用支付开销。**关键是任何时刻信用总额不能为负**。

* **动态数组记账模型**：\
  对每次普通的插入，我们收取 **3 元**：
  1. 第 1 元支付本次将元素放入数组的即时成本；
  2. 第 2 元存为自身未来的信用（当它将来被搬移到新数组时作为搬移费）；
  3. 第 3 元存为对应前序某个已经在数组中但尚未有搬移费的元素的信用。\
     当数组满需要搬移时，所有元素的搬移费早已全额付清，无须额外开销。均摊代价稳稳维持在 (O(1))。

#### 方法三：势能法（Potential Method）

将数据结构当前状态映射为一个非负的“势能”数值 (\Phi(D\_i))，初始状态 (\Phi(D\_0) = 0)。\
定义第 (i) 次操作的均摊代价为实际代价加势能增量： \[ \hat{c}*i = c\_i + \Phi(D\_i) - \Phi(D*{i-1}) ] 只要保证 (\Phi(D\_n) \ge \Phi(D\_0))，则总实际代价一定被总均摊代价所限定。势能法在高级数据结构（如斐波那契堆、Splay 树的均摊复杂度证明）中是无可替代的利器。

***

### 1.7 抽象数据类型（ADT）与现代 C++ 契约设计

#### 1.7.1 什么是 ADT？

**抽象数据类型（Abstract Data Type, ADT）** 是纯粹从逻辑层面对数据模型以及施加于其上的操作的数学描述。它遵循 **“做什么（What）”与“怎么做（How）”彻底解耦** 的原则。

```
                  ┌────────────────────────────────────────┐
                  │              栈 ADT 接口               │
                  │   push(x), pop(), top(), empty()       │
                  └───────────────────┬────────────────────┘
                                      │
              ┌───────────────────────┴───────────────────────┐
              ▼                                               ▼
   【实现 A：顺序栈 (ArrayStack)】                 【实现 B：链式栈 (LinkedStack)】
   - 内存连续，缓存友好 (Cache-friendly)            - 离散节点，无容量上限，无内存搬移
   - 扩容时存在瞬时开销                            - 每个节点多付出指针开销
```

#### 1.7.2 现代 C++17 中的 ADT 工程实践

在现代 C++ 开发中，ADT 理念贯彻于：

1. **面向接口编程**：通过纯虚基类（抽象接口）或 C++20 Concepts 约束类型；
2. **RAII 资源管理**：在构造函数中申请资源，在析构函数中释放资源，保证异常安全；
3. **值语义与移动语义**：善用移动构造 `T(T&&)` 与移动赋值 `operator=(T&&)`，让复杂的内部数据结构具备高效转移的能力。

***

### 本章小结

1. **渐进性是第一原则**：算法的好坏由输入规模扩大时的增长趋势决定，而非机器上的瞬时读秒。
2. **大 O 并不等同于最坏情况**：(O) 在数学上仅代表上界；但在实际工程沟通中，我们约定俗成用最坏情况的大 (O) 作为最可靠的性能底线。
3. **主定理是一面照妖镜**：掌握递归树中“树根拆分”与“树叶合并”的博弈，就能瞬间看穿绝大部分分治算法的渐进复杂度。
4. **均摊不等于平均**：均摊分析是确定性的最坏界限保证，为工业界动态扩容容器（如 `std::vector`）的常数级尾插提供了坚不可摧的数学证明。
5. **ADT 是软件架构之魂**：坚持逻辑契约与物理实现分离，是用 C++ 编写高可靠、可测试、易扩展工程代码的核心思想。

***

### 思考题与课后自测

1. **对数底数问题**：为什么在算法分析中，我们通常直接写 (O(\log n))，而不写底数（如 (O(\log\_2 n)) 或 (O(\log\_{10} n))）？请从换底公式角度给出严格证明。
2. **主定理实战应用**：
   * 求 (T(n) = 3T(n/4) + n \log n) 的渐进复杂度；
   * 求 (T(n) = 2T(n/2) + n / \log n) 能否直接应用主定理？若不能，请用递归树方法估计其界。
3. **缩容策略的均摊陷阱**：\
   某工程师设计了一个动态数组：当 `size == capacity` 时扩容为 2 倍；当 `size == capacity / 2` 时立即缩容为一半。请构造一个最坏操作序列，证明该策略下 `push_back` 和 `pop_back` 交替执行时的均摊复杂度会退化为 (O(n))，并提出正确的缩容防抖设计。
