不需要数学基础,用大白话讲清楚:为什么有的代码跑得快,有的跑得慢?怎么一眼看出代码的"速度等级"?
一、什么是"时间复杂度"?一句话讲清楚
想象你要从 1 数到 100:
- 方式A :一个一个数:
1, 2, 3, 4...要数 100 下 - 方式B:先数到 50,再数到 75,再数到 88... 每次砍掉一半
时间复杂度 就是描述:当数字变得超级超级大时,你需要"操作"多少次。
💡 记住一句话:时间复杂度不看实际秒数,只看"操作次数随数据量变大的增长趋势"。
为什么要用大 O?
因为电脑有快有慢,但算法的"快慢基因"是不变的。就像:
- 乌龟和兔子比赛,兔子永远比乌龟快
- 但如果是
O(n²)的算法,数据量翻倍时,它永远比O(n)的算法慢得多(不管跑在超级计算机还是老爷机上)
常见复杂度排名(从快到慢):
scss
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ)
就像游戏里的段位:青铜 → 白银 → 黄金 → 铂金 → 钻石 → 王者(越往后越慢)
二、非递归算法:数循环,就像数糖果
非递归算法最常见的就是循环 。分析它,就像数你吃了多少颗糖果。
2.1 单层循环:O(n) ------ 一人发一颗糖
java
for (int i = 0; i < n; i++) {
吃一颗糖();
}
有 n 个人,每人发一颗糖 ,总共发了 n 颗。
→ 时间复杂度:O(n)(线性,像一条直线往上走)
2.2 双层循环:O(n²) ------ 每两个人握一次手
java
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
握手();
}
}
班里有 n 个同学,每个人都和所有人握一次手。
第1个人握 n 次,第2个人握 n 次... 总共 n × n = n² 次。
→ 时间复杂度:O(n²)(平方,增长很快!)
🌰 实际例子:冒泡排序,两两比较,就是
O(n²)
2.3 对数循环:O(log n) ------ 折纸游戏
java
for (int i = 1; i < n; i *= 2) {
操作();
}
想象你有一张纸,每次对折(乘以2),问:对折多少次能超过 n 张纸的厚度?
- 1 次 → 2 层
- 2 次 → 4 层
- 3 次 → 8 层
- k 次 → 2^k 层
当 2^k = n 时,k = log₂n。
→ 时间复杂度:O(log n)(超快!像折纸一样指数级减少操作)
🌰 实际例子:二分查找,每次砍掉一半数据
2.4 常见模式速查(小白版)
| 代码样子 | 像什么 | 复杂度 | 为什么 |
|---|---|---|---|
| 单层 for 循环 | 排队点名 | O(n) | 每人点一次 |
| 两层 for 循环 | 全班互相握手 | O(n²) | n×n 次 |
| 循环变量 ×2 | 折纸翻倍 | O(log n) | 每次翻倍,次数=log |
| 外层 n,内层 log n | 点名+每次折纸 | O(n log n) | n × log n |
| 没有循环,直接返回 | 一眼看到答案 | O(1) | 固定时间 |
三、递归算法:像俄罗斯套娃
递归就是函数自己调用自己,就像俄罗斯套娃:打开一个,里面还有一个更小的...
分析递归算法,核心就一句话:把问题拆成多小的子问题?每个子问题要花多少时间?
3.0 先搞懂递推式
写递推式就像写"账单":
scss
T(n) = 分成几个小问题 + 合并这些小问题要花的时间
比如归并排序:
- 把数组切成两半 → 2 个子问题
- 每个子问题规模是原来的一半 → n/2
- 合并两个有序数组 → 花 n 的时间
所以递推式是:T(n) = 2T(n/2) + n
3.1 展开法:像剥洋葱,一层层剥开
适合:简单的递推,比如每次只解决一个子问题。
例子:计算 n 的阶乘
java
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // 自己调用自己
}
递推式:T(n) = T(n-1) + 1(算完 n-1 的阶乘,再乘个 n,花 1 步)
展开过程就像剥洋葱:
scss
T(n) = T(n-1) + 1 ← 剥开第1层
= [T(n-2) + 1] + 1 ← 剥开第2层
= T(n-2) + 2
= [T(n-3) + 1] + 2 ← 剥开第3层
= T(n-3) + 3
= ...
= T(1) + (n-1) ← 剥到最后
= 1 + n - 1
= n
→ 时间复杂度:O(n)
💡 口诀:每次规模减 1,总共剥 n 层,就是 O(n)
3.2 递归树法:像画一棵"公司组织架构图"
适合:分成多个子问题的情况(比如分成2份、3份)
例子 :归并排序 T(n) = 2T(n/2) + n
想象一家公司:
- CEO 管 n 个人的大项目(花 n 时间做整合)
- CEO 把项目分给 2个经理,每人管 n/2 个人
- 经理再分给 4个组长,每人管 n/4 个人
- ...直到最底层的 员工,每人管 1 个人
css
CEO [n] ← 第0层,花 n 时间
/ \
经理A[n/2] 经理B[n/2] ← 第1层,花 n/2 + n/2 = n
/ \ / \
组长... 组长... ← 第2层,还是花 n
关键发现:
- 每一层花的总时间都是 n(不管分得多细,加起来都是 n)
- 一共有多少层?每次规模减半,从 n 减到 1,需要
log₂n次 - 总时间 = 每层时间 × 层数 =
n × log n
→ 时间复杂度:O(n log n)
💡 口诀:每层代价一样,数有几层,乘起来就是答案
3.3 代入法:先猜后证(面试装逼用)
适合:你已经大概知道答案,需要严谨证明。
步骤:
- 猜一个答案(凭直觉)
- 用数学归纳法验证
例子:证明归并排序是 O(n log n)
猜 :T(n) ≤ cn log n(c 是某个常数)
验证:
scss
假设对于比 n 小的问题都成立
T(n) = 2T(n/2) + n
≤ 2 × c(n/2)log(n/2) + n ← 把假设代进去
= cn(log n - 1) + n
= cn log n - cn + n
= cn log n - (c-1)n
只要 c ≥ 1,最后一项就是负数或零,T(n) ≤ cn log n 成立!
💡 小白可以跳过这一步,知道有这种方法就行。面试时如果面试官让你"严格证明",再用这招。
3.4 主定理:递归分析的"作弊码" ⭐
这是最实用的方法,记住三个规则,大部分递归题秒出答案!
递推式的标准形式
scss
T(n) = a × T(n/b) + O(n^d)
| 字母 | 什么意思 | 生活比喻 |
|---|---|---|
a |
每次分成几个子问题 | 一个经理管几个下属 |
b |
子问题规模是原来的几分之一 | 每个下属管多少比例的人 |
d |
合并子问题的"工作量等级" | 经理整合报告要花多大精力 |
三个规则(记住这个表!)
先算一个数:log_b(a)(即以 b 为底 a 的对数)
| 情况 | 条件 | 结果 | 直观理解 |
|---|---|---|---|
| 规则1 | d < log_b(a) |
O(n^(log_b a)) | 下属太多,"分"比"合"累 |
| 规则2 | d = log_b(a) |
O(n^d × log n) | 分和合一平衡,每层一样 |
| 规则3 | d > log_b(a) |
O(n^d) | 合并太费劲,"合"比"分"累 |
💡 超级口诀 :比较
d和log_b(a),谁大听谁的,一样大就乘个log n
实战:用主定理秒算常见算法
🌰 案例1:二分查找
java
int binarySearch(int[] arr, int target, int l, int r) {
if (l > r) return -1;
int mid = (l + r) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] > target)
return binarySearch(arr, target, l, mid-1); // 只走左边
else
return binarySearch(arr, target, mid+1, r); // 只走右边
}
分析:
- 每次只走 1 个子问题 →
a = 1 - 数据规模减半 →
b = 2 - 找中间点 + 比较 →
O(1)→d = 0
计算:log₂(1) = 0,而 d = 0 → 规则2
结果:O(n⁰ × log n) = O(log n) ✅
🌰 案例2:归并排序
- 分成 2 个子问题 →
a = 2 - 每个子问题规模减半 →
b = 2 - 合并两个数组 →
O(n)→d = 1
计算:log₂(2) = 1,而 d = 1 → 规则2
结果:O(n¹ × log n) = O(n log n) ✅
🌰 案例3:二叉树遍历
java
void traverse(TreeNode root) {
if (root == null) return;
traverse(root.left);
traverse(root.right);
// 访问当前节点 O(1)
}
- 每个节点产生 2 个子问题 (左右子树)→
a = 2 - 子树规模大约减半 →
b = 2 - 访问节点 →
O(1)→d = 0
计算:log₂(2) = 1,而 d = 0 → d < log_b(a) → 规则1
结果:O(n^(log₂2)) = O(n¹) = O(n) ✅
💡 虽然每个节点访问一次,但主定理算出来也是 O(n),吻合!
🌰 案例4:快速排序(理想情况)
- 分成 2 个子问题 →
a = 2 - 子问题规模大约各半 →
b = 2 - 分区操作 →
O(n)→d = 1
计算:log₂(2) = 1 = d → 规则2
结果:O(n log n) ✅
🌰 案例5:斐波那契(朴素递归)
java
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}
- 每次产生 2 个子问题 →
a = 2 - 但子问题规模是
n-1和n-2,不是除以 b!
⚠️ 主定理用不了! 因为子问题规模不是"按比例缩小"的。
用展开法/递归树法分析,会发现它像一棵"歪脖子树",节点数接近 2ⁿ。
→ 时间复杂度:O(2ⁿ)(超慢!)
💡 这就是为什么斐波那契要用动态规划优化到 O(n)
四、什么时候主定理"失灵"?
主定理很强,但不是万能钥匙。以下情况别用:
| 情况 | 例子 | 该用什么方法 |
|---|---|---|
| 子问题规模不按比例 | T(n)=T(n-1)+T(n-2) |
递归树/展开法 |
| 合并代价不是多项式 | T(n)=2T(n/2)+n log n |
递归树法 |
| 子问题规模不一样 | T(n)=T(n/2)+T(n/4)+n |
递归树法 |
五、一张图记住所有常见算法
scss
速度(快 → 慢)
O(1) ████ 数组按下标取值
O(log n) ████████ 二分查找
O(n) ████████████████ 遍历数组
O(n log n) ████████████████████ 归并排序、快排
O(n²) ████████████████████████████ 冒泡排序
O(2ⁿ) ████████████████████████████████████████████████ 斐波那契递归
六、给小白的学习路径
- 先学会数循环:单层 O(n),双层 O(n²),折半 O(log n)
- 理解递归的本质:自己调用自己,像套娃
- 掌握展开法:像剥洋葱,一层层展开
- 会用递归树:画树,算每层代价,数层数
- 背熟主定理三个规则:面试秒杀 90% 的递归复杂度题
- 知道主定理的边界:不是所有递归都能用
七、课后练习(测测你学会了没)
题目1:下面代码的时间复杂度是多少?
java
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
System.out.println("hi");
}
}
点击查看答案
内层循环从 i 开始到 n,总次数是 n + (n-1) + (n-2) + ... + 1 = n(n+1)/2
→ O(n²)(虽然有优化,但最高阶还是 n²)
题目2:用主定理分析下面递归:
java
void func(int n) {
if (n == 1) return;
func(n / 3);
func(n / 3);
func(n / 3);
// 合并操作 O(n²)
}
点击查看答案
- a = 3(3个子问题)
- b = 3(规模变为 1/3)
- d = 2(合并是 O(n²))
计算:log₃(3) = 1,而 d = 2
1 < 2 → 规则3
→ O(n²)
题目3(思考题):为什么下面代码主定理用不了?
java
void weird(int n) {
if (n == 1) return;
weird(n - 1);
weird(n - 1);
}
点击查看答案
子问题规模是 n-1,不是 n/b(不是按比例缩小,而是减1)。
需要用递归树法:每层节点数翻倍,深度为 n,总节点数 ≈ 2ⁿ
→ O(2ⁿ)
八、结语
时间复杂度分析不是数学竞赛,而是培养对代码"速度基因"的直觉。
记住几个关键点:
- 循环就数执行次数
- 递归就写递推式
- 分治型递归用主定理秒算
- 不是分治型就用展开法或递归树
多练几道真题,你会发现:原来时间复杂度分析,真的没那么难!😊
如果这篇文章帮到你,欢迎点赞收藏,有问题评论区见!