一、时间复杂度基础概念
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)