时间复杂度和空间复杂度
时间复杂度和空间复杂度
评价一个算法的优劣,往往会从时间复杂度和空间复杂度两个维度来评价。执行时间越短,占用空间越小的算法,被视为最优算法。
——《数据结构和算法之美》
概念
● 时间复杂度全称渐进式时间复杂度
● 空间复杂度全称渐进式空间复杂度
为什么需要对算法进行评价呢?一个算法直接执行一遍,它的消耗时间和占用空间就一目了然了。这个策略也可以,它有个专业名称——事后统计法,但是它有着很大局限性:
● 依赖运行环境和机器性能
● 依赖测试数据的规模
因此,需要在算法设计之初,就要对它进行一个大概的评估。当算法开发完成后,可以执行算法,来验证评估。
大 O 复杂度表示法
算法的执行效率,可以简单的描述为执行时间。算法没有运行,如何估计它的运行时间呢?来分析下面一段代码:
var dic = new Dictionary<int, int>(); // 1行
var n = nums.Length; // 2行
for (int i = 0; i < n; i++) // 3行
{ // 4行
res = nums[i]; // 5行
dic.Add(nums[i], i); // 6行
} // 7行
这段代码一看,消耗时间的地方肯定是这个for循环。当 n 很大时,第1,2行代码消耗的时间可以忽略不计了,因此在算法分析过程里,只考虑消耗最大的那段代码。假设第4、5行代码执行所需时间都是一个单位时间(unit_time),那4、5行代码共需 \(2*unitTime\),整个for循环执行完的时间 \(T(n)\) 大概是 \(n2unitTime\),可以看出,T(n)与n成正比,用一个公式记作 \(T(n)=O(f(n))\)。这个公式中 T(n) 表示代码执行时间总和,n表示数据规模,$$f(n)$$ 表示每行代码执行时间,\(O\) 表示 \(T(n)\) 是 \(f(n)\) 的一个正比函数。
时间复杂度
分析方法
1. 只关注执行次数最多的代码段
例如上面示例中的代码,代码执行次数最多的是for循环这一段。第4、5行代码共执行n次,执行时间$$T(n)=O(f(n))$$,因此时间复杂度记作$$O(n)$$。
2. 加法法则
有下面一段代码
//第一段
for (int i = 0; i < n; i++)
{
res = nums[i];
dic.Add(nums[i], i);
}
//第二段
for (int k = 0; i < m; i++)
{
res = nums[k];
dic.Add(nums[k], k);
}
这段代码有2小段,先看第一段,假设单行代码执行所需时间为 unit_time,那么第一段代码所需时间就是 \(n*2*unitTime\),记作$$T1(n)=O(f(n))$$。同理第二段代码消耗时间为$$T2(n)=O(g(n))$$,整段代码所需全部时间为$$T(n)=T1(n)+T2(n)$$。
根据第一条规则,取时间数量级最大的作为总的时间复杂度,那么$$T(n)=Max((O(f(n))),(O(g(n))))=O(n)$$。
3. 乘法规则
看下面代码:
for (int i = 0; i < n; i++)
{
for (int k = 0; i < n; i++)
{
res = nums[k];
dic.Add(nums[k], k);
}
}
外层循环的消耗时间为\(T1(n)\),内层循环消耗时间为\(T2(n)\),总消耗时间为 \(T(n) = T1(n)*T2(n)= O(n * n)=O(n^2)\)
常见时间复杂度
● 常量阶 \(O(1)\)
● 线性阶 \(O(n)\)
● 对数阶 \(O(logn)\)
● 线性对数阶 \(O(nlogn)\)
● 指数阶 \(O(2^n)\)
● 阶乘阶 \(O(n!)\)
● 平方阶 \(O(n^2)\) 立方阶 \(O(n^3)\) ……m次方阶 \(O(n^m)\)
\(O(1)\)
是一种时间复杂度表示方式,并非指执行了一行代码,例如下面的代码,及时它执行了100次,1千次,时间复杂度仍是\(O(1)\)
int i=0;
int j=i+10;
\(O(n)\)
这种时间复杂度非常常见,例如for循环
for(int i=0; i<n;i++){
int k=i;
}
\(O(logn)\)
int i=1;
while(i<n){
i*=2;
}
这段代码的终止条件是 \(2^i >= n\) ,那么 \(i = log_2 n = log n\) ,因此时间复杂度为\(O(logn)\)。
最好、最坏、均摊、平均时间复杂度
● 最好情况时间复杂度
● 最坏情况时间复杂度
● 均摊时间复杂度
● 平均情况时间复杂度
最好、最好情况时间复杂度,很好理解,也很常见。例如使用循环在一个数组中找到某个元素,最好的情况是,这个元素是第一个元素,那么就是最好情况复杂度 \(O(1)\),如果这个元素是最后一个,那么就需要遍历整个数组,才能找到,其时间复杂度就是 \(O(n)\)。
空间复杂度
空间复杂度分析比时间复杂度分析要简单很多,例如以下代码:
int i=0;
int[] arr=new int[n];
for (int i = 0; i < n; i++)
{
res = nums[k];
}
这段代码中,第1行声明一个变量,它是常量阶 \(O(1)\) ,和数据规模无关。第2行声明一个长度为 n 的数组,它占用空间取决于数据规模n,因此它的空间复杂度为 \(O(n)\)。for循环中只是进行副职,并没有开辟新的空间。因此这段代码空间复杂度为 \(O(n)\)
在看一段代码:
for (int i = 0; i < n; i++)
{
for (int k = 0; i < n; i++)
{
int a=i;
}
}
这段代码 int a=i 是常量阶,但是它处于2层循环内,相当于声明了\(n*n=n^2\) 次变量,因此它的空间复杂度为 \(O(n^2)\).
最好、最坏时间复杂度是个极端情况,出现的概率不大。为了更好的表示大多数情况下的时间复杂度,引入平均情况时间复杂度概念。
还是假设在一个数组中查找某个元素,这个元素可能在数组中或不在数组中、在与不在概率都是\(\frac{1}{2}\) ,这个元素可能出现在索引0~n-1任意位置的概率为 \(\frac{1}{n}\) ,根据概率乘法得到综合概率为 \(\frac{1}{2n}\) 。那么目标元素出现在索引0位置的时间复杂度为 \(1*\frac{1}{2n}\) ,出现在索引1位置的时间复杂度为 \(2*\frac{1}{2n}\),以此类推得到如下公式:
\(1*\frac{1}{2n} + 2*\frac{1}{2n} + 3*\frac{1}{2n}+……+n*\frac{1}{2n} = \frac{3n+1}{4}\) .
这个值就是概率论中的平均加权值,也称作期望值。在去掉系数、常量、低阶量之后,得到的平均情况时间复杂度为 \(O(n)\)
均摊时间复杂度。这个概念跟平均时间复杂度容易混淆。有这样一个场景,集合(数组)中的 insert() 方法,待插入的数据可能会插入到任意一个索引位置,且每个索引位置概率都是 \(\frac{1}{n+1}\) 。根据平均时间复杂度可得:
\(1*\frac{1}{n+1} + 2*\frac{1}{n+1} + 3*\frac{1}{n+1}+……+n*\frac{1}{n+1} = O(1)\) .
由此可看出数组的 insert() 操作时间复杂度为 \(O(1)\),而 find() 操作时间复杂度为 \(O(n)\)。这也是这两个函数的一个不同点。当然也存在特殊情况,当数组满了之后,此时插入数据,数组需要进行扩容,此时时间复杂度为 \(O(n)\) (注意,这里的数组是指可变长数组,如果是定长数组,满了之后无法插入) 。当数组扩容后,再次插入数据时,时间复杂度又是\(O(1)\),每到数组满了之后插入数据,时间复杂度又变成\(O(n)\),如此循环往复。有n-1个\(O(1)\),1个\(O(n)\),然后接着是\(O(1)\),\(O(n)\)……那么这一个\(O(n)\)就可以均摊到n-1个\(O(1)\)上去,最终时间复杂度还是\(O(1)\)。这块概念比较难理解,可以将均摊时间复杂度理解为特殊的平均情况时间复杂度。不用过度纠结。
本文来自博客园,作者:宣君{https://www.nhit.icu/},转载请注明原文链接:https://www.cnblogs.com/ycit/p/16496113.html