数据结构(一):算法的时间复杂度和空间复杂度
🚁 算法效率不止看表面复杂度!探秘均摊分析、递归深度与工程权衡,解锁解锁性能优化新思路。关注小编不迷路~
一、算法效率
1.1 如何衡量一个算法的好坏
- 如何衡量一个算法的好坏呢?比如对于以下斐波那契数列:
long long Fib(int N)
{if(N < 3)return 1;return Fib(N-1) + Fib(N-2);
}
- 斐波那契数列的递归实现方式非常简洁,但简洁一定好吗?那该如何衡量其好与坏呢?
1.2 算法的复杂度
-
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度
- 时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间
- 在计算机发展的早期,计算机的存储容量很小,所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度,所以我们如今已经不需要再特别关注一个算法的空间复杂度
二、时间复杂度
2.1 时间复杂度的概念
-
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道,但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度
-
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度
看下面代码,计算Func1
中++count
语句总共执行了多少次?
// 请计算一下Func1中++count语句总共执行了多少次?
void Func1(int N)
{int count = 0;for (int i = 0; i < N; ++i){for (int j = 0; j < N; ++j){++count;}}for (int k = 0; k < 2 * N; ++k){++count;}int M = 10;while (M--){++count;}
}
printf("%d\n", count);
}
-
Func1执行的基本操作次数:
F(N)=N2+2∗N+10F(N) = N^2 + 2*N + 10F(N)=N2+2∗N+10- N=10N = 10N=10,F(N)=130F(N) = 130F(N)=130
- N=100N = 100N=100,F(N)=10210F(N) = 10210F(N)=10210
- N=1000N = 1000N=1000,F(N)=1002010F(N) = 1002010F(N)=1002010
-
实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法
2.2 大O的渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
📌 推导大O阶方法:
- 用常数1取代运行时间中的所有加法常数
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数,得到的结果就是大O阶
📌 使用大O的渐进表示法以后,Func1
的时间复杂度为:
O(N2)O(N^2)O(N2)
- N=10N = 10N=10,F(N)=100F(N) = 100F(N)=100
- N=100N = 100N=100,F(N)=10000F(N) = 10000F(N)=10000
- N=1000N = 1000N=1000,F(N)=1000000F(N) = 1000000F(N)=1000000
通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数
另外有些算法的时间复杂度存在最好、平均和最坏情况:
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为NNN数组中搜索一个数据 xxx
- 最好情况:1次找到
- 最坏情况:NNN次找到
- 平均情况:N/2N/2N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)O(N)O(N)
2.3 常见时间复杂度计算举例
实例1:
// 计算Func2的时间复杂度?
void Func2(int N)
{int count = 0;for (int k = 0; k < 2 * N; ++k){++count;}int M = 10;while (M--){++count;}printf("%d\n", count);
}
实例2:
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{int count = 0;for (int k = 0; k < M; ++k){++count;}for (int k = 0; k < N; ++k){++count;}printf("%d\n", count);
}
实例3:
// 计算Func4的时间复杂度?
void Func4(int N)
{int count = 0;for (int k = 0; k < 100; ++k){++count;}printf("%d\n", count);
}
实例4:
// 计算strchr的时间复杂度?
const char * strchr ( const char * str, int character );
实例5:
// 计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{assert(a);for (size_t end = n; end > 0; --end){int exchange = 0;for (size_t i = 1; i < end; ++i){if (a[i-1] > a[i]){Swap(&a[i-1], &a[i]);exchange = 1;}}if (exchange == 0)break;}
}
实例6:
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{assert(a);int begin = 0;int end = n-1;while (begin < end){int mid = begin + ((end-begin)>>1);if (a[mid] < x)begin = mid+1;else if (a[mid] > x)end = mid;elsereturn mid;}return -1;
}
实例7:
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{if (0 == N)return 1;return Fac(N-1)*N;
}
实例8:
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{if(N < 3)return 1;return Fib(N-1) + Fib(N-2);
}
📌 实例答案及分析:
- 实例1基本操作执行了2∗N+102*N + 102∗N+10次,通过推导大OOO阶的方法,时间复杂度为O(N)O(N)O(N)
- 实例2基本操作执行了M+NM + NM+N次,有两个未知数MMM和NNN,时间复杂度为O(N+M)O(N + M)O(N+M)
- 实例3基本操作执行了100100100次,通过推导大OOO阶的方法,时间复杂度为O(1)O(1)O(1)
- 实例4基本操作执行最好111次,最坏NNN次,时间复杂度一般看最坏,时间复杂度为O(N)O(N)O(N)
- 实例5基本操作执行最好NNN次,最坏执行了N∗(N+1)/2N*(N+1)/2N∗(N+1)/2次,通过推导大OOO阶方法+时间复杂度一般看最坏,时间复杂度为O(N2)O(N^2)O(N2)
- 实例6基本操作执行最好111次,最坏O(logN)O(\log N)O(logN)次,时间复杂度为O(logN)O(\log N)O(logN)(ps:logN\log NlogN在算法分析中表示是底数为222,对数为NNN。有些地方会写成O(lgN)O(\lg N)O(lgN),(建议通过折纸查找的方式讲解logN\log NlogN是怎么计算出来的)
- 实例7通过计算分析发现基本操作递归了NNN次,时间复杂度为O(N)O(N)O(N)
- 实例8通过计算分析发现基本操作递归了2N2^N2N次,时间复杂度为O(2N)O(2^N)O(2N)(建议画图递归栈的二叉树讲解)
三、空间复杂度
-
空间复杂度的定义
空间复杂度是用于衡量算法在运行过程中临时占用存储空间规模的数学表达式,聚焦于算法执行时动态占用的额外空间,而非具体字节数(因字节换算意义有限 ),核心统计关键变量、数据结构等占用的空间数量,辅助分析算法对内存资源的需求 -
计算规则与核心逻辑
和时间复杂度类似,空间复杂度也采用 大OOO渐进表示法 。需先梳理算法运行中显式申请的额外空间(如动态分配数组、递归栈帧等 ),再通过简化规则(保留最高阶项、去除常数系数等 ),提炼出空间增长的趋势,例如数组扩容、递归深度带来的空间变化,最终用 O()O( )O() 形式简洁描述 -
空间分析的特殊关注
函数运行依赖的栈空间(存储参数、局部变量、寄存器信息等 )由编译期静态确定,不纳入动态空间复杂度分析。因此,空间复杂度主要考察算法运行时主动申请的额外空间,像动态内存分配、递归调用栈深度(递归场景 )等,以此精准衡量算法的内存使用效率
实例1:
// 计算BubbleSort的空间复杂度?
void BubbleSort(int* a, int n)
{assert(a);for (size_t end = n; end > 0; --end){int exchange = 0;for (size_t i = 1; i < end; ++i){if (a[i-1] > a[i]){Swap(&a[i-1], &a[i]);exchange = 1;}}if (exchange == 0)break;}
}
实例2:
// 计算Fibonacci的空间复杂度?
// 递归实现斐波那契数列的第n项
long long* Fibonacci(size_t n)
{if(n == 0)return NULL;long long * fibArray = (long long *)malloc((n+1) * sizeof(long long));fibArray[0] = 0;fibArray[1] = 1;for (int i = 2; i <= n; ++i){fibArray[i] = fibArray[i - 1] + fibArray[i - 2];}return fibArray;
}
实例3:
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{if (N == 0)return 1;return Fac(N-1)*N;
}
📌实例答案及分析:
- 实例1使用了常数个额外空间,所以空间复杂度为 O(1)O(1)O(1)
- 实例2动态开辟了 NNN 个空间,空间复杂度为 O(N)O(N)O(N)
- 实例3递归调用了 NNN 次,开辟了 NNN 个栈帧,每个栈帧使用了常数个空间,空间复杂度为 O(N)O(N)O(N)
四、常见复杂度对比
📌一般算法常见的复杂度如下:
表达式 | 大O表示 | 复杂度阶 |
---|---|---|
100100100 | O(1)O(1)O(1) | 常数阶 |
3n+43n + 43n+4 | O(n)O(n)O(n) | 线性阶 |
3n2+2n+53n^2 + 2n + 53n2+2n+5 | O(n2)O(n^2)O(n2) | 平方阶 |
3log2(n)+43\log_2(n) + 43log2(n)+4 | O(logn)O(\log n)O(logn) | 对数阶 |
2n+3nlog2(n)+142n + 3n\log_2(n) + 142n+3nlog2(n)+14 | O(nlogn)O(n\log n)O(nlogn) | nlognn\log nnlogn 阶 |
n3+3n2+2n+4n+6n^3 + 3n^2 + 2n + 4n + 6n3+3n2+2n+4n+6 | O(n3)O(n^3)O(n3) | 立方阶 |
2n2^n2n | O(2n)O(2^n)O(2n) | 指数阶 |
五、复杂度分析进阶
5.1 amortized complexity(均摊复杂度)
- 在某些算法中,操作的时间复杂度会因场景波动(如动态数组扩容 )。以动态数组
push_back
为例:大部分插入是 O(1)O(1)O(1),但扩容时需拷贝数据,单次为 O(n)O(n)O(n)。此时用均摊复杂度分析,将扩容成本均摊到多次插入,可更准确反映长期效率,均摊后为 O(1)O(1)O(1)
5.2 递归复杂度的深度关联
- 递归算法的时间、空间复杂度与递归深度强相关。如二叉树递归遍历,时间复杂度由节点数决定(O(n)O(n)O(n) );而递归栈空间复杂度由树高决定(平衡树 O(logn)O(\log n)O(logn) 、退化成链则 O(n)O(n)O(n) ),分析时需结合递归结构特性
5.3 复杂度的实际工程权衡
- 理论复杂度为算法效率提供参考,但工程中还需结合常数因子与实际数据规模。例如,O(n)O(n)O(n) 的朴素算法,若常数因子极小,在小数据量下可能比 O(nlogn)O(n\log n)O(nlogn) 但常数大的算法更快,需灵活取舍
📌理解复杂度本质,平衡理论与实践,方能在算法设计中做出最优抉择,提升工程效能,关注小编不迷路~
- C 语言入门知识大全(一)
- C 语言入门知识大全(二)
- C 语言进阶 分支语句和循环语句