同一个程序在不同配置的电脑运行结束时间是不同的,所以统计一个算法运行时间的长短是没有意义的。算法的时间复杂度并不是计算这个算法运行了多长时间,而是一个程序执行的大概次数。
时间复杂度的定义
在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。
一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
大O的渐进表示法
题目:请计算一下Func1基本操作执行了多少次?
void Func1(in 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
)
=
n
2
+
2
∗
n
+
10
F(n)=n^2+2*n+10
F(n)=n2+2∗n+10
现在计算机硬件性能越来越强,所以当N等于太小的数字是测不出来这个算法究竟是臃肿还是迅捷。
假如说我现在写了能实现与Func1一样功能的程序,但基本操作次数公式是
F
(
N
)
=
100
n
∗
100
F(N)=100n*100
F(N)=100n∗100
F(n)=100n*100 | F(N)=n^2+2*n+10 | |
---|---|---|
n=1 | 10000 | 13 |
n=5 | 50000 | 45 |
n=10 | 100000 | 130 |
n=100 | 1000000 | 10030 |
n=1000 | 10000000 | 1002010 |
n=10000 | 100000000 | 100020010 |
n=100000 | 1000000000 | 10000200010 |
n=1000000 | 10000000000 | 1000002000010 |
从表格中可以看出,当n的值很小的时候,算法A的运行用时要远大于算法B;当n的值达到1000左右,算法A和算法B的运行时间已经接近;当n的值越来越大,达到十万、百万时,算法A的优势开始显现,算法B则越来越慢,差距越来越明显。
在代入数据时,我们发现实际中计算时间复杂度时带入的数值越大越能表现出算法的优略,最好是趋于无穷,所以并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。
大O(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
有三种情况
常数
如果运行时间是常数量级,用常数1表示
void Func(int N)
{
int count = 0;
for(int k = 0;k <100; ++k)
{
++count;
}
printf("%d\n",count);
}
上面一段代码时间复杂度不是O(100)而是O(1)。
加法
如果有很多项,只保留时间函数中的最高阶项,且系数变为1。
T
(
n
)
=
T
1
(
n
)
+
T
2
(
n
)
=
O
(
f
(
n
)
)
+
O
(
g
(
n
)
)
=
O
(
m
a
x
(
f
(
n
)
,
g
(
n
)
)
T(n)=T_1(n)+T_2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n))
T(n)=T1(n)+T2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n))
void Func(int N)
{
int count = 0;
for(int k = 0;k <2*N; ++k)
{
++count;
}
int M = 10;
while(M--)
{
++count;
}
printf("%d\n",count);
}
上面一段代码时间复杂度不是O(2n+10)而是O(n)。
乘法
如果是嵌套循环,每一项项都保留,多项相乘。
T
(
n
)
=
T
1
(
n
)
×
T
2
(
n
)
=
O
(
f
(
n
)
)
×
O
(
g
(
n
)
)
=
O
(
f
(
n
)
×
g
(
n
)
)
T(n)=T_1(n)×T_2(n)=O(f(n))×O(g(n))=O(f(n)×g(n))
T(n)=T1(n)×T2(n)=O(f(n))×O(g(n))=O(f(n)×g(n))
void Func
{
count = 0;
for(k=1; k<=n;k*=2)
for(j=1;j<=n;j++)
count++
}
使用大O的渐进表示法以后,for(k=1; k<=n;k*=2) 的时间复杂度是O(f(log2n)),for(j=1;j<=n;j++)的时间复杂度是O(g(n)),相乘得出Func的时间复杂度为O(nlog2n)
现在我们可以推导出Func1时间复杂度为O(n2)。
常见的渐进时间复杂度为
O
(
1
)
<
O
(
l
o
g
2
n
)
<
O
(
n
)
<
O
(
n
l
o
g
2
n
)
<
O
(
n
2
)
<
O
(
n
3
)
<
O
(
2
n
)
<
O
(
n
!
)
<
O
(
n
n
)
O(1)<O(log_2^n)<O(n)<O(nlog_2^n)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n)
O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)
时间复杂度存在最好、平均和最坏情况
现在不妨思考一下,在一个数组里面查找一个元素,如果最后一个才找到那这个算法时间复杂度为O(n),如果第一个就找到那这个算法时间复杂度为O(1)。
所以有些算法时间复杂度是不会变的有些算法是会根据数据的存储顺序不同而改变,存在着最好、平均和最坏三种情况:
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
- 最好情况:1次找到
- 最坏情况:N次找到
- 平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
练习
计算冒泡排序的时间复杂度
void bubble_sort(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] > arr[1])
{
Swap(&a[i-1],&a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
准确的时间复杂度是多少?
准确的时间复杂度也就是每一项都要比,第一趟要比n-1次,第二趟要比n-2次,同时这也是最坏的情况。
答案是F(n)=1+2+3…+(n-1),用大O表示法表示O(n2)
那冒泡排序最好的情况呢?
观察算法发现如果第一趟发生交换exchange赋值1,如果没有交换exchange还等于0,会break,算法结束。最好的情况F(n)=n-1,用大O表示法表示O(n)。
经过推导我们得知冒泡排序的时间复杂度是O(n2),最好的情况是O(n)
计算二分查找的时间复杂度
int binary_search(int arr[], int k,int n)
{
//算法实现
int left = 0;
int right = n - 1;
while (left <= right)
{
int mid = (left + right) / 2; //中间元素的下标
if (arr[mid]<k)
left = mid + 1;
else if(arr[mid]>k)
right = mid - 1;
else
return mid;
}
return -1;
}
二分查找每查找一次长度就会变成一半,这个过程用数学公式表示就是:N/2/2/2/2…/2,直到查找为止。设x是查找的次数,得到N=2x,指数变换得x=log2N。
所以二分查找的时间复杂度为O(log2N)。
二分查找是比较优秀的查找方式,因为它的时间复杂度是个对数函数,对数函数随着n增大增长幅度小。
计算递归的时间复杂度
看下面阶乘递归Factorial,思考它的时间复杂度
long long Factorial(size_t N)
{
return N < 2 ? N : Factorical(N - 1)*N;
}
递归Factorial的时间复杂度为O(n)
递归算法时间复杂度如何计算:
递
归
算
法
时
间
复
杂
度
=
递
归
次
数
×
单
次
递
归
函
数
的
时
间
复
杂
度
递归算法时间复杂度=递归次数×单次递归函数的时间复杂度
递归算法时间复杂度=递归次数×单次递归函数的时间复杂度
假如递归函数里面是个二分查找,那就是O(N*logn)。
其实递归最好不要用太频繁,递归里面的内容算法长度最好是O(1)。
常用的算法的时间复杂度
排序法 | 平均时间 | 最差情况 | 稳定度 | 备注 |
---|---|---|---|---|
冒泡 | O(n2) | O(n2) | 稳定 | n小时较好 |
交换 | O(n2) | O(n2) | 不稳定 | n小时较好 |
选择 | O(n2) | O(n2) | 不稳定 | n小时较好 |
插入 | O(n2) | O(n2) | 稳定 | 大部分已排序时较好 |
基数 | O(logRB) | O(logRB) | 稳定 | B是真数(0-9) R是基数(个十百) |
Shell | O(nlogn) | O(n2) 1<S<2 | 不稳定 | s是所选分组 |
快速 | O(nlogn) | O(n2) | 不稳定 | n大时较好 |
归并 | O(nlogn) | O(nlogn) | 稳定 | n大时较好 |
堆 | O(nlogn) | O(nlogn) | 不稳定 | n大时较好 |