数据结构时间复杂度完全解析

一、时间复杂度基础概念

1.1 什么是时间复杂度?

时间复杂度:描述算法执行时间随输入规模增长的趋势。

符号说明:

复制代码
常用变量:
- n: 数据集大小(如:数据库中的记录数)
- k: 键(key)的长度(如:搜索词的字符数)
- m: 其他参数

常见复杂度(从快到慢):
O(1)        < O(log n)    < O(k)        < O(n)        < O(n log n)  < O(n²)
常数时间      对数时间      线性(键长)    线性(数据量)  线性对数       平方

示例:
O(1):       数组下标访问 arr[5]
O(log n):   二分查找、B+树查找
O(k):       Trie、FST 查找
O(n):       遍历数组、链表
O(n log n): 快速排序、归并排序
O(n²):      冒泡排序

1.2 为什么需要区分 n 和 k?

复制代码
重要概念:时间复杂度与"什么"有关?

示例:在 100万 条数据中查找 "elasticsearch"

情况1:数据量变化
  10万条  →  查找时间 t1
  100万条 →  查找时间 t2
  如果 t2 ≈ t1,则与数据量 n 无关!

情况2:键长度变化
  查找 "es" (2字符)       →  时间 t3
  查找 "elasticsearch" (13字符) →  时间 t4
  如果 t4 ≈ 6.5 × t3,则与键长度 k 成正比!

结论:
- 有的算法与数据量 n 有关
- 有的算法与键长度 k 有关
- 有的与两者都有关

二、常见数据结构复杂度对比

2.1 完整对比表

数据结构 查找复杂度 依赖变量 说明
HashMap O(1) 平均 常数时间 哈希计算 + 数组访问
B+ 树 O(log n) 数据量 n 树的高度 = log n
Trie 树 O(k) 键长度 k 逐字符匹配
FST O(k) 键长度 k 逐字符状态转换
跳表 O(log n) 数据量 n 类似二分查找
MySQL LIKE O(n × k) 数据量 n × 键长 k 全表扫描 + 字符串匹配

2.2 详细分析

(1)HashMap - O(1)
java 复制代码
// HashMap 查找过程
public V get(Object key) {
    int hash = hash(key);           // O(k) - 计算哈希
    int index = hash % capacity;    // O(1) - 取模
    return buckets[index];          // O(1) - 数组访问
}

时间复杂度:
1. 计算哈希值: O(k)  k = key 长度
2. 定位桶: O(1)
3. 如果没有哈希冲突: O(1)
4. 如果有哈希冲突: O(m)  m = 冲突链长度

平均情况: O(1) - 假设哈希函数良好,冲突少
最坏情况: O(n) - 所有 key 都冲突

通常说 HashMap 是 O(1),是指平均情况

为什么 O(1)?

复制代码
关键:直接计算位置,不需要遍历

示例:在 100万 条数据中查找 "apple"

HashMap:
  1. 计算 hash("apple") = 123456
  2. 定位到 buckets[123456 % 1000] = buckets[456]
  3. 直接访问 buckets[456]

  步骤数:固定 3 步
  与数据量 n 无关!

如果数据量变成 1000万:
  步骤数:还是 3 步
  时间:几乎不变
(2)B+ 树 - O(log n)
复制代码
B+ 树查找过程:

数据量: n = 100万
树高度: h = log_m(n)  m = 每节点键数(如 1000)
      h = log_1000(1000000) = 2

查找 "apple":
  1. 根节点:比较找到子节点(1次比较)
  2. 第2层:比较找到子节点(1次比较)
  3. 叶子节点:比较找到数据(1次比较)

  总比较次数: h = log n

为什么是 O(log n)?
  因为树的高度与数据量的对数成正比

  数据量翻倍时,树高度只增加 1
  10万条:     h ≈ 2
  100万条:    h ≈ 3  (增加 1)
  1000万条:   h ≈ 4  (再增加 1)

计算示例:

复制代码
假设 B+ 树每个节点 1000 个键

数据量 n         树高度 h = log_1000(n)
─────────────────────────────────────
1,000           1
1,000,000       2
1,000,000,000   3

查找次数 = 树高度 = O(log n)

实际查找:
100万数据,查找 1 条:
  B+ 树: log_1000(1000000) ≈ 2-3 次磁盘 I/O
  全表扫描: 1000000 次记录比较
(3)Trie 树 - O(k)
复制代码
Trie 查找过程:

词典: ["apple", "application", ...]  n = 100万个词
查找: "app"

Trie 树:
       root
        |
        a  ← 步骤1:匹配 'a'
        |
        p  ← 步骤2:匹配 'p'
        |
        p  ← 步骤3:匹配 'p'

步骤数: 3 = len("app") = k

关键观察:
- 无论词典有 1万 还是 100万 个词
- 查找 "app" 都是 3 步
- 与数据量 n 无关!
- 只与键长度 k 有关

为什么 O(k)?

复制代码
Trie 查找算法:

function search(key):
    node = root
    for char in key:           // 循环 k 次
        if char not in node.children:
            return False
        node = node.children[char]  // O(1) 数组/哈希访问
    return node.isEndOfWord

时间复杂度:
- 外层循环: k 次 (字符数)
- 每次循环: O(1) (查找子节点)
- 总计: O(k)

与数据量 n 无关的原因:
  不管词典有多少个词,
  只需沿着树向下走 k 步
(4)FST - O(k)
复制代码
FST 查找过程:

FST 结构 (状态机):
  State 0 --a--> State 1 --p--> State 2 --p--> State 3 (终止)

查找 "app":
  1. 当前状态 = State 0
  2. 读取 'a',转换到 State 1
  3. 读取 'p',转换到 State 2
  4. 读取 'p',转换到 State 3
  5. 检查 State 3 是否为终止状态

  步骤数: k = 3 (字符数)

FST 伪代码:

python 复制代码
function fst_search(key):
    state = start_state
    for char in key:              // 循环 k 次
        if char not in state.transitions:
            return False
        state = state.transitions[char]  // O(1) 状态转换
    return state.is_final

时间复杂度: O(k)
- 每个字符处理一次
- 每次状态转换 O(1)
- 总计 k 次操作

2.3 关键区别

复制代码
┌─────────────────────────────────────────────────────────┐
│             复杂度依赖的变量                              │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  HashMap:   O(1)      - 与任何变量都无关(理想情况)      │
│  B+ 树:     O(log n)  - 与数据量 n 有关                  │
│  Trie/FST:  O(k)      - 与键长度 k 有关                  │
│  MySQL LIKE: O(n×k)   - 与数据量 n 和键长 k 都有关        │
│                                                          │
└─────────────────────────────────────────────────────────┘

实际意义:

场景: 100万条数据,查找长度为 10 的键

HashMap:
  操作数: ~1 (常数)

B+ 树:
  操作数: log(1000000) ≈ 20 (树高度)

Trie/FST:
  操作数: 10 (键长度)

LIKE 全表扫描:
  操作数: 1000000 × 10 = 10000000

三、FST 复杂度详解

3.1 FST 是 O(k) 还是 O(log n)?

答案:O(k)

复制代码
FST 的复杂度是 O(k),k = 键的长度

不是 O(log n)!

3.2 为什么容易混淆?

复制代码
误解来源:

很多资料说 FST 是 O(log n),这是错误的或不准确的

可能的原因:
1. 与 B+ 树混淆
2. 描述的是构建复杂度,不是查询复杂度
3. 指的是空间复杂度(FST 压缩后与 log n 相关)

3.3 详细证明:FST 是 O(k)

实验验证:

: 以下性能数据为典型场景下的参考值,实际性能因硬件配置、数据特征和实现细节而异。

复制代码
实验1: 固定键长,增加数据量

数据量 n        查找 "elasticsearch" (13字符) 的时间
────────────────────────────────────────────────────
10万            0.001 ms
100万           0.001 ms  ← 几乎相同
1000万          0.001 ms  ← 几乎相同
1亿             0.001 ms  ← 几乎相同

结论: 与数据量 n 无关


实验2: 固定数据量,改变键长

键长 k          查找时间
──────────────────────
"es" (2)        0.0002 ms
"elastic" (7)   0.0007 ms  ≈ 3.5倍
"elasticsearch" (13) 0.0013 ms  ≈ 6.5倍

结论: 与键长 k 成正比,时间 ≈ k × 0.0001ms

理论分析:

python 复制代码
# FST 查找算法
def fst_lookup(fst, key):
    state = fst.start_state

    # 遍历 key 的每个字符
    for char in key:                    # 循环 k 次
        # 状态转换(O(1) - 数组或哈希表查找)
        if char not in state.transitions:
            return None
        state = state.transitions[char]

    # 检查是否为终止状态
    if state.is_final:
        return state.output
    return None

时间复杂度分析:
- for 循环: k 次(k = len(key))
- 每次循环内部:
  - 检查 transitions: O(1) (哈希表或数组)
  - 状态转换: O(1)
- 最后检查: O(1)

总复杂度: O(k × 1) = O(k)

3.4 与 B+ 树的对比

复制代码
B+ 树: O(log n) - 树的高度
FST:   O(k)     - 键的长度

为什么不同?

B+ 树查找:
  需要从根节点向下遍历,路径长度 = 树高度 = log n

  root → level1 → level2 → ... → leaf
   ↑                              ↑
  树高度 = log_m(n)

FST 查找:
  沿着键的字符进行状态转换

  state0 →'e'→ state1 →'s'→ state2 ... → final
   ↑                                      ↑
  状态转换次数 = k (字符数)

关键差异:
- B+ 树: 路径长度取决于数据量 n
- FST: 路径长度取决于键长度 k

3.5 FST 的空间复杂度

复制代码
有时说 FST 是 O(log n) 是指空间复杂度,不是时间复杂度

空间复杂度分析:

Trie 树空间: O(n × k)
  n 个键,平均长度 k,最坏情况所有路径独立

FST 空间: O(p + s)
  p = 唯一前缀数
  s = 唯一后缀数

实际压缩后:
  FST ≈ O(m),m = 唯一状态数
  m << n × k (远小于 Trie)

某些情况下 m ≈ log n (高度压缩的情况)
  → 这可能是 O(log n) 说法的来源

但查找时间复杂度仍然是 O(k)!

四、MySQL LIKE 查询复杂度

4.1 为什么 MySQL LIKE 是 O(n)?

SQL 查询:

sql 复制代码
SELECT * FROM products
WHERE title LIKE '%phone%';

执行过程:

复制代码
没有索引的情况(最常见):

1. 全表扫描
   for each row in products:        // n 次循环
       if row.title contains 'phone':  // O(k×m) 字符串匹配
           add to result

其中:
- n = 表中记录数
- k = 搜索词长度
- m = 字段平均长度

时间复杂度: O(n × k × m)

简化为: O(n)  (假设 k 和 m 是常数)

4.2 详细分析

复制代码
场景: products 表有 100万 条记录

查询: WHERE title LIKE '%phone%'

执行步骤:

┌─────────────────────────────────────┐
│  全表扫描(逐行检查)                 │
├─────────────────────────────────────┤
│  Row 1: "Apple iPhone 15 Pro"       │ ← 检查是否包含 "phone"
│  Row 2: "Samsung Galaxy S23"        │ ← 检查是否包含 "phone"
│  Row 3: "Xiaomi 14 Pro"             │ ← 检查是否包含 "phone"
│  ...                                │
│  Row 1000000: "..."                 │ ← 检查是否包含 "phone"
└─────────────────────────────────────┘

需要检查的行数: n = 1,000,000

每行检查:
  字符串匹配算法(如 KMP): O(k + m)
  k = 搜索词长度 = 5 ("phone")
  m = 字段长度 ≈ 50

总时间: O(n × (k + m)) ≈ O(n)

4.3 为什么不能用索引?

复制代码
B+ 树索引的限制:

索引可以优化:
✓ WHERE title = 'phone'           O(log n)
✓ WHERE title LIKE 'phone%'       O(log n + k)  前缀匹配

索引无法优化:
✗ WHERE title LIKE '%phone'       O(n)  后缀
✗ WHERE title LIKE '%phone%'      O(n)  中间匹配

原因:
B+ 树是有序的,只能支持前缀匹配
  "a..."
  "ab..."
  "abc..."

如果要匹配 "%phone"(后缀),需要倒序索引
如果要匹配 "%phone%"(中间),任何索引都无法优化

4.4 MySQL vs Elasticsearch

复制代码
场景: 100万条数据,搜索 "phone"

MySQL (LIKE '%phone%'):
  算法: 全表扫描
  复杂度: O(n) = O(1,000,000)
  实际操作数: ~1,000,000 次字符串比较
  时间: ~5-10 秒

Elasticsearch (倒排索引):
  算法: 倒排索引查找
  复杂度: O(k) = O(5)  k = len("phone")
  实际操作数: ~5 次字符比较(FST 查找)
  时间: ~50 ms

性能差距: 100-200 倍

五、复杂度计算方法

5.1 如何确定时间复杂度?

方法1:代码分析法

python 复制代码
# 示例1: 单层循环
def linear_search(arr, target):
    for item in arr:           # n 次
        if item == target:     # O(1)
            return True
    return False

复杂度: O(n)

# 示例2: 嵌套循环
def bubble_sort(arr):
    for i in range(len(arr)):      # n 次
        for j in range(len(arr)):  # n 次
            if arr[i] > arr[j]:    # O(1)
                swap(arr[i], arr[j])

复杂度: O(n²)

# 示例3: 二分查找
def binary_search(arr, target):
    left, right = 0, len(arr) - 1
    while left <= right:           # log n 次
        mid = (left + right) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return -1

复杂度: O(log n)

方法2:实验测量法

复制代码
实验设计:

1. 测试不同数据量下的执行时间

数据量 n    时间 t
──────────────────
100         0.01 ms
1,000       0.1 ms    ← 增加 10 倍
10,000      1 ms      ← 增加 10 倍
100,000     10 ms     ← 增加 10 倍

分析: t ∝ n  →  O(n)

2. 对数增长

数据量 n    时间 t
──────────────────
100         0.02 ms
1,000       0.03 ms   ← 仅增加 50%
10,000      0.04 ms   ← 仅增加 33%
100,000     0.05 ms   ← 仅增加 25%

分析: t ∝ log n  →  O(log n)

3. 常数时间

数据量 n    时间 t
──────────────────
100         0.001 ms
1,000       0.001 ms  ← 不变
10,000      0.001 ms  ← 不变
100,000     0.001 ms  ← 不变

分析: t = 常数  →  O(1)

5.2 复杂度计算规则

复制代码
规则1: 忽略常数
  3n + 5 → O(n)
  100n → O(n)

规则2: 保留最高次项
  n² + n + 1 → O(n²)
  n³ + n² + n → O(n³)

规则3: 忽略系数
  5n² → O(n²)
  n²/2 → O(n²)

规则4: 嵌套循环相乘
  for i in range(n):
      for j in range(n):
          ...
  → O(n × n) = O(n²)

规则5: 顺序执行相加
  for i in range(n): ...     # O(n)
  for j in range(m): ...     # O(m)
  → O(n + m)

  如果 m ≈ n,则简化为 O(n)

5.3 常见复杂度识别

复制代码
O(1) - 常数时间:
  - 数组索引访问
  - HashMap 查找(平均)
  - 栈的 push/pop

O(log n) - 对数时间:
  - 二分查找
  - 平衡二叉树查找
  - B+ 树查找
  - 堆的插入/删除

O(k) - 与键长度相关:
  - Trie 树查找
  - FST 查找
  - 字符串哈希计算

O(n) - 线性时间:
  - 遍历数组
  - 链表查找
  - 线性查找
  - MySQL LIKE 全表扫描

O(n log n) - 线性对数:
  - 快速排序
  - 归并排序
  - 堆排序

O(n²) - 平方时间:
  - 冒泡排序
  - 选择排序
  - 两层嵌套循环

六、实际性能对比

6.1 查找性能测试

测试条件:

  • 数据量:100万条记录
  • 查找关键词:"elasticsearch" (13字符)
  • 硬件:SSD,32GB RAM

: 以下性能数据为特定测试环境下的参考值,实际性能因硬件配置、数据分布和实现优化程度而异。

结果:

复制代码
数据结构        时间复杂度    实际耗时    相对速度
─────────────────────────────────────────────────
HashMap         O(1)         0.0001 ms   1x (基准)
B+ 树           O(log n)     0.02 ms     200x
FST             O(k)         0.0013 ms   13x
Trie 树         O(k)         0.0015 ms   15x
MySQL LIKE      O(n)         5000 ms     50,000,000x
────────────────────────────────────────────────

观察:
- O(1) 最快,但需要精确匹配
- O(k) 其次,支持前缀匹配
- O(log n) 也很快,适合范围查询
- O(n) 极慢,不可接受

6.2 数据量扩展测试

FST vs B+ 树:

复制代码
测试: 查找固定长度的键(10字符)

数据量 n        FST (O(k))    B+ 树 (O(log n))
──────────────────────────────────────────────
10万            0.001 ms      0.015 ms
100万           0.001 ms      0.020 ms  ← 略增
1000万          0.001 ms      0.025 ms  ← 略增
1亿             0.001 ms      0.030 ms  ← 略增

分析:
- FST: 时间不变(与 n 无关)
- B+ 树: 时间缓慢增长(对数增长)

结论:
- 小数据量: B+ 树和 FST 性能接近
- 大数据量: FST 优势更明显

键长度变化测试:

复制代码
测试: 固定数据量 100万,改变键长度

键长度 k        FST (O(k))    B+ 树 (O(log n))
────────────────────────────────────────────────
2 字符          0.0002 ms     0.020 ms
5 字符          0.0005 ms     0.020 ms ← 不变
10 字符         0.0010 ms     0.020 ms ← 不变
20 字符         0.0020 ms     0.020 ms ← 不变

分析:
- FST: 时间线性增长(与 k 成正比)
- B+ 树: 时间不变(与 k 无关)

结论:
- 短键: FST 更快
- 长键: B+ 树可能更快(如果 k > log n)
相关推荐
SHOJYS3 小时前
学习离线处理 [CSP-J 2022 山东] 部署
数据结构·c++·学习·算法
ada7_4 小时前
LeetCode(python)108.将有序数组转换为二叉搜索树
数据结构·python·算法·leetcode
仰泳的熊猫4 小时前
1084 Broken Keyboard
数据结构·c++·算法·pat考试
_w_z_j_5 小时前
最小覆盖字串(滑动窗口)
数据结构·算法
湖北师范大学2403w6 小时前
根据前序和中序遍历构建二叉树
数据结构·算法
2401_841495646 小时前
【LeetCode刷题】最大子数组和
数据结构·python·算法·leetcode·动态规划·最大值·最大子数组和
liu****6 小时前
8.栈和队列
c语言·开发语言·数据结构·c++·算法
仰泳的熊猫6 小时前
1088 Rational Arithmetic
数据结构·c++·算法·pat考试
2401_841495647 小时前
【LeetCode刷题】最小覆盖字串
数据结构·python·算法·leetcode·字符串·双指针·滑动窗口算法