1. 算法效率
1.1 如何衡量一个算法的好坏
衡量一个算法的好坏,主要从以下几个维度考虑:
- 正确性:算法必须能够正确解决问题,这是最基本的要求。
- 可读性:代码应该易于理解和维护。
- 健壮性:算法对非法输入的处理能力。
- 效率:算法执行所需的时间和空间资源。
其中,效率是算法分析的核心,因为在实际应用中,我们经常需要处理大规模数据,效率直接决定了算法的实用性。
1.2 算法的复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源 和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度 和空间复杂度。
- 时间复杂度:主要衡量一个算法的运行快慢
- 空间复杂度:主要衡量一个算法运行所需要的额外空间
2. 时间复杂度
2.1 时间复杂度的概念
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。
一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有把你的程序放在机器上跑起来,才能知道。但是,一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
关键理解:
- 时间复杂度不是实际的运行时间,而是执行次数的数学表达式
- 我们关注的是随着输入规模n的增长,执行次数的增长趋势
示例分析:Func1
c
// 请计算一下Func1中++count语句总共执行了多少次?
void Func1(int N)
{
int count = 0;
// 第一部分:双重循环
for (int i = 0; i < N ; ++ i)
{
for (int j = 0; j < N ; ++ j)
{
++count; // 执行 N × N = N² 次
}
}
// 第二部分:单层循环
for (int k = 0; k < 2 * N ; ++ k)
{
++count; // 执行 2N 次
}
// 第三部分:固定次数循环
int M = 10;
while (M--)
{
++count; // 执行 10 次
}
printf("%d\n", count);
}
精确执行次数:F(N) = N² + 2N + 10
2.2 大O的渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
- 用常数1取代运行时间中的所有加法常数
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数
Func1的大O表示:
- 精确次数:F(N) = N² + 2N + 10
- 步骤1:去掉常数项 → N² + 2N
- 步骤2:保留最高阶项 → N²
- 步骤3:系数已经是1,不需要处理
- 时间复杂度:O(N²)
为什么使用大O表示法?
- 关注增长趋势而非精确值
- 忽略常数和低阶项对大规模数据的影响
- 提供算法性能的上界估计
三种情况分析:
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
在实际分析中,我们通常关注最坏情况,因为它代表了算法的性能底线。
2.3 常见时间复杂度计算举例
示例1:Func2
c
// 计算Func2的时间复杂度?
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N ; ++ k) // 执行 2N 次
{
++count;
}
int M = 10;
while (M--) // 执行 10 次
{
++count;
}
printf("%d\n", count);
}
分析:
- 总执行次数:F(N) = 2N + 10
- 大O表示:O(N)
- 解释:随着N增大,2N占主导,常数10可忽略
示例2:Func3
c
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++ k) // 执行 M 次
{
++count;
}
for (int k = 0; k < N ; ++ k) // 执行 N 次
{
++count;
}
printf("%d\n", count);
}
分析:
- 总执行次数:F(N, M) = M + N
- 大O表示:O(M + N)
- 注意:当M和N规模相当时,不能简化为O(N)
示例3:Func4
c
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k) // 执行 100 次
{
++count;
}
printf("%d\n", count);
}
分析:
- 总执行次数:F(N) = 100
- 大O表示:O(1)
- 解释:执行次数与输入规模N无关,是常数时间复杂度
示例4:冒泡排序(BubbleSort)
c
// 计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end) // 外层循环n次
{
int exchange = 0;
for (size_t i = 1; i < end; ++i) // 内层循环end-1次
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break; // 优化:如果一趟没有交换,说明已有序
}
}
分析:
-
最坏情况(完全逆序):
- 第一趟比较n-1次
- 第二趟比较n-2次
- ...
- 第n-1趟比较1次
- 总比较次数:(n-1) + (n-2) + ... + 1 = n(n-1)/2
- 时间复杂度:O(n²)
-
最好情况(已经有序):
- 第一趟比较n-1次,发现exchange=0,直接退出
- 时间复杂度:O(n)
-
平均时间复杂度:O(n²)
示例5:二分查找(BinarySearch)
c
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n-1;
// [begin, end]:begin和end是左闭右闭区间,因此有=号
while (begin <= end)
{
int mid = begin + ((end-begin)>>1); // 防止溢出
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid-1;
else
return mid;
}
return -1;
}
分析:
- 每次查找范围减半:n → n/2 → n/4 → ... → 1
- 设查找次数为k,则 n/(2^k) = 1
- 解得:k = log₂n
- 时间复杂度:O(log n)
- 注意:这是对数时间复杂度,效率非常高
示例6:阶乘递归(Fac)
c
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
递归树分析:
Fac(N)
↓
Fac(N-1) * N
↓
Fac(N-2) * (N-1)
↓
...
Fac(0) * 1
分析:
- 递归深度:N层
- 每层执行常数次操作(乘法和返回)
- 时间复杂度:O(N)
示例7:斐波那契递归(Fib)
c
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
递归树分析:
Fib(N)
/ \
Fib(N-1) Fib(N-2)
/ \ / \
Fib(N-2) Fib(N-3) ...
分析:
- 这是一个二叉树结构
- 树的高度:N
- 节点总数:约2^N(实际略少,因为底部有重复)
- 时间复杂度:O(2^N)
- 问题:存在大量重复计算,效率极低
优化方案:
- 记忆化搜索:保存已计算的结果
- 动态规划:自底向上计算
- 矩阵快速幂:O(log N)时间复杂度
3. 空间复杂度
3.1 空间复杂度的概念
空间复杂度:也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度。
重要理解:
- 空间复杂度不是程序占用了多少bytes的空间,因为这个意义不大
- 空间复杂度算的是变量的个数
- 空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法
注意 :函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
3.2 空间复杂度计算示例
示例1:冒泡排序(BubbleSort)
c
// 计算BubbleSort的空间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0; // 1个变量
for (size_t i = 1; i < end; ++i) // 1个变量
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]); // 使用临时变量,但属于Swap函数的空间
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
分析:
- 函数参数:a(指针,4/8字节),n(整型,4字节)
- 局部变量:end(4/8字节),exchange(4字节),i(4/8字节)
- 关键点:这些变量在循环中重复使用,不随n增大而增加
- 空间复杂度:O(1)(常数空间)
示例2:阶乘递归(Fac)
c
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
递归调用栈分析:
Fac(5) → Fac(4) → Fac(3) → Fac(2) → Fac(1) → Fac(0)
分析:
- 每次递归调用都会在栈上分配空间
- 递归深度:N层
- 每层需要存储:参数N、返回地址、局部变量(如果有)
- 空间复杂度:O(N)
重要对比:
- 时间复杂度:O(N) - 执行N次乘法
- 空间复杂度:O(N) - 递归深度N层
3.3 递归算法的空间复杂度深入分析
情况1:尾递归
c
// 尾递归版本
long long FacTail(size_t N, long long result = 1)
{
if(N == 0)
return result;
return FacTail(N-1, result * N);
}
- 现代编译器可能优化为迭代,空间复杂度O(1)
- 但C/C++标准不保证尾递归优化
情况2:斐波那契递归(Fib)
c
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
- 时间复杂度:O(2^N) - 指数级,效率极低
- 空间复杂度:O(N) - 递归深度最大为N
为什么空间复杂度不是O(2^N)?
因为递归调用是深度优先的,同一时间只有一条路径在栈中:
Fib(5)
→ Fib(4)
→ Fib(3)
→ Fib(2)
→ Fib(1)
→ Fib(2)
→ Fib(3)
→ Fib(2)
→ Fib(1)
最大栈深度为5,而不是2^5=32。
4. 常见时间复杂度对比
| 复杂度 | 名称 | 示例 | N=10时的操作次数 | N=1000时的操作次数 |
|---|---|---|---|---|
| O(1) | 常数阶 | 数组索引 | 1 | 1 |
| O(log n) | 对数阶 | 二分查找 | ~3 | ~10 |
| O(n) | 线性阶 | 遍历数组 | 10 | 1000 |
| O(n log n) | 线性对数阶 | 快速排序 | ~30 | ~10000 |
| O(n²) | 平方阶 | 冒泡排序 | 100 | 1,000,000 |
| O(2^n) | 指数阶 | 斐波那契递归 | 1024 | 天文数字 |
| O(n!) | 阶乘阶 | 旅行商问题 | 3,628,800 | 无法计算 |
5. 如何让小白学会计算复杂度(重点总结)
5.1 计算时间复杂度的四步法
第一步:找出基本操作
- 找到执行次数最多的那条语句
- 通常是循环最内层的操作
第二步:建立执行次数函数F(n)
- 用数学表达式表示执行次数
- 考虑循环的嵌套和条件
第三步:用大O表示法简化
- 去掉所有加法常数
- 只保留最高阶项
- 去掉最高阶项的系数
第四步:考虑最坏情况
- 分析算法在最坏输入下的性能
- 这是评价算法性能的标准
5.2 常见模式识别
模式1:单层循环
c
for(int i = 0; i < n; i++) {
// 基本操作
}
- 时间复杂度:O(n)
模式2:双层嵌套循环
c
for(int i = 0; i < n; i++) {
for(int j = 0; j < n; j++) {
// 基本操作
}
}
- 时间复杂度:O(n²)
模式3:循环变量翻倍
c
for(int i = 1; i < n; i *= 2) {
// 基本操作
}
- 时间复杂度:O(log n)
模式4:循环变量减半
c
while(n > 0) {
// 基本操作
n /= 2;
}
- 时间复杂度:O(log n)
5.3 递归复杂度分析技巧
技巧1:递归树法
- 画出递归调用树
- 计算树的高度(空间复杂度)
- 计算树的节点总数(时间复杂度)
技巧2:主定理(Master Theorem)
适用于形式为 T(n) = aT(n/b) + f(n) 的递归:
- 比较 f(n) 与 n^(log_b a)
- 根据比较结果确定复杂度
技巧3:递推公式法
- 建立递推关系:T(n) = T(n-1) + O(1) → O(n)
- 求解递推公式得到复杂度
5.4 空间复杂度计算要点
- 只算额外空间:不考虑输入数据本身占用的空间
- 递归看深度:递归算法的空间复杂度等于递归深度
- 变量复用不计:循环中重复使用的变量只算一次
- 动态分配要算:malloc/new分配的空间要计入
5.5 实战练习建议
初级阶段(掌握基础):
- 分析简单的循环结构
- 计算非递归函数的复杂度
- 理解大O表示法的含义
中级阶段(应对面试):
- 分析常见排序算法复杂度
- 计算递归函数复杂度
- 区分时间复杂度和空间复杂度
高级阶段(优化设计):
- 根据复杂度选择合适算法
- 设计满足复杂度要求的算法
- 进行复杂度优化(时间换空间或空间换时间)
5.6 常见误区与纠正
误区1:认为O(100n)比O(n²)好
- 纠正:大O表示法忽略常数系数,两者都是O(n)
误区2:认为递归一定比循环慢
- 纠正:复杂度相同的情况下,递归可能更简洁,但可能有栈溢出风险
误区3:忽略空间复杂度
- 纠正:内存有限时,空间复杂度同样重要,需要权衡时空