算法效率分析一般分为两种,一种是时间效率,另外一种是空间效率。时间效率被称为时间复杂度,空间效率则被称为空间复杂度。时间复杂度是用来衡量一个算法的运行速度,而空间复杂度主要是用来衡量一个算法的所需要的额外空间,早期的计算机存储容量很小,所以比对空间复杂度很是在乎。但是随着计算机的叙述发展,计算机的存储已经到了一个很高的程度,比如现在的一台笔记本至少都是16G内存+512G磁盘,服务甚至是几百个G的内存,几百T的磁盘。所以现在并不那么关心空间复杂度,也经常出现空间换时间的做法。
时间复杂度的一个定义:在计算机科学中,算法的时间复杂度是一个函数(一个数学函数),它定量描述了一个算法执行所消耗的时间,从理论上来说,是不能计算出来的,只有当你把代码放到机器上跑才能知道运行时间,但是通过机器测试这种方式明显不现实,因为每台计算机的配置都不一定相同,所以才有了时间复杂度的分析方式。一个算法所花费的时间与其语句的执行次数成正比,算法中的基本操作的执行次数,为算法的时间复杂度。
来看一段代码
void func1(int N)
{
int count = 0;
int i = 0;
for (i = 0; i < N ; i++) //执行N次
{
for (int j = 0; j < N ; j++) //执行N次
{
count++;
}
}
int k = 0;
for (k = 0; k < 2 * N ; k++) //执行2*N次
{
count++;
}
int M = 10;
while (M--) //执行10次
{
count++;
}
printf("%d\n",count);
}
那么我们来计算一下这个代码的运行次数就是
f ( N ) = N 2 + 2 ∗ N + 10 f(N) = N^{2}+2*N+10 f(N)=N2+2∗N+10
但实际我们计算时间复杂度时,并不一定要计算的那么精确,而是只计算大概的执行次数.
我看到func1的执行次数,如果当我们的N非常大时,假设N = 1000,那么这里的+10是可以忽略了,因为 100 0 2 = 1000000 1000^{2}=1000000 10002=1000000,在一百万面前+10可以说是微乎其微了,所以+1和+10没什么区别。同理 2 ∗ N 2*N 2∗N也是一样的,当N足够大趋近于无穷时, 2 ∗ N 2*N 2∗N也时微乎其微了。
那么就可以使用大O的渐近表示法
大 O O O符号:是用于描述函数渐进行为的数学符号
推到大 O O O阶方法
通过上面的方法来推导一下
用常数1取代运行时间汇总的所有加法常数
f ( N ) = N 2 + 2 ∗ N + 1 f(N) = N^{2}+2*N+1 f(N)=N2+2∗N+1
在修改后的运行次数函数中,只保留最高阶项
f ( N ) = N 2 f(N) = N^{2} f(N)=N2
这里的最高阶项不是1,所以func1函数的时间复杂度就是** O ( N 2 ) O(N^{2}) O(N2)**
大 O O O渐进表示法去掉了那些对结果影响不大的项数,只保留了最影响结果的那一项。
另外有些算法存在着,最好、平均和最坏情况
举个例子:
假设在一个数组中查找一个数字。
我们平常嘴上所说的时间复杂度就是最坏情况的时间复杂度
实例1:
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);
}
粗略计算就是 f ( N ) = 2 ∗ N + 10 f(N) = 2*N+10 f(N)=2∗N+10
在修改后的运行次数函数中,只保留最高阶项,如果最高阶存在且不是1,则去除与这个项数相乘的常数
那么这个代码的时间复杂度就是** O ( N ) O(N) O(N)**
实例2:
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);
}
这种情况有一点特殊,因为不知到N和M谁大,所以对于这种不确定谁对结果的影响大,就都需要保留下来。所以这个代码的时间复杂度就是 O ( N + M ) O(N+M) O(N+M)
如果可以确定N远远大于M那么时间复杂度就是 O ( N ) O(N) O(N)
实例3:
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
对于这种场数次数的时间复杂度就是 O ( 1 ) O(1) O(1)
实例4:
int find(int* arr, int N int key)
{
assert(arr);
int i = 0;
for (i = 0; i < N; i++)
{
if (arr[i] == key)
{
return i;
}
}
return -1;
}
这个代码是在一个数组中查找一个数字,对于这种情况就是直接取它的最坏情况的时间复杂度,就是 O ( N ) O(N) O(N)
实例5:
void BubbleSort(int* arr, int n)
{
assert(arr);
int i = 0;
for (i = 0; i < n-1; i++)//排序趟数
{
int flag = 0;
int j = 0;
for (j = 0; j < n - 1 - i; j++)//比较次数
{
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
flag = 1;
}
}
if (flag == 0)
{
break;
}
}
}
这是冒泡排序的代码,冒泡排序的时间复杂度也是比较特殊的。
冒泡排序的外层循环是排序的趟数,每一趟冒泡排序都会确定一个数字的位置。所以每一趟排序后比较的次数都要减去1。
那么计算的次数就是 ( N − 1 ) + ( N − 2 ) + ( N − 3 ) . . . + 2 + 1 (N-1) + (N-2) + (N-3)... + 2 + 1 (N−1)+(N−2)+(N−3)...+2+1
这是要给等比数列,通过等比数列的前N项和公式得出$\frac{N*(N-1)}{2} $
通过大 O O O渐进法得出冒泡排序的时间负责度就是 O ( N 2 ) O(N^{2}) O(N2)
实例6:
void BinarySearch(int* arr, int size, int key)
{
assert(arr);
int left = 0;
int right = size - 1;
int mid = 0;
while (left < right)
{
mid = (right - left) / 2 + left;
if (arr[mid] < key)
{
left = mid + 1;
}
else if (arr[mid] > key)
{
right = mid - 1;
}
else
{
return mid;
}
}
return -1;
}
这是一个二分查找的代码,二分查找一次砍掉数组的一半元素。那么查找的次数就是 N / 2 / 2 / 2 / 2... / 2 = 1 N/2/2/2/2.../2 = 1 N/2/2/2/2.../2=1,找了 x x x次,则 / 2 /2 /2了 x x x次,那么长度为$N 的 数 组 最 坏 则 要 查 找 次 数 就 是 , 的数组最坏则要查找次数就是, 的数组最坏则要查找次数就是,N = 2^{x} , 转 为 对 数 的 形 式 就 是 ,转为对数的形式就是 ,转为对数的形式就是x = \log_{2}{N} $
所以二分查找的时间复杂度就是 O ( log 2 N ) O(\log_{2}{N}) O(log2N)
实例7:
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N-1)*N;
}
这是一个递归计算N的阶层的代码,这个代码的时间负责度又是多少呢?
递归算法的时间复杂度 = = = 递归次数 * 每次递归函数中的执行的次数
这里的递归次数是 N − 2 N-2 N−2,每次递归函数中的执行次数就是1,那么这个代码的时间复杂度就是 O ( N ) O(N) O(N)
实例8:
long long Fibonacci(size_t N)
{
return N < 2 ? N : Fibonacci(N-1)+Fibonacci(N-2);
}
这是递归计算斐波那契数列的函数
递归计算斐波那契数列,类似于一颗二叉树。假设这一棵二叉树是满二叉树。
那么第一层计算次数就是 2 0 2^{0} 20,第二层就是 2 1 2^{1} 21,第三层就是 2 2 2^{2} 22,以此类推…,那么函数每一次的执行次数就是 f ( N ) = 2 N − 1 f(N) = 2^{N-1} f(N)=2N−1,根据等比数列前N项和公式$S_{n}\frac{a_{1}(1-q^{n} ) }{1-q} $,它的准确的时间复杂度就是 2 N − 1 − 空 缺 2^{N}-1-空缺 2N−1−空缺
通过大 O O O渐进法推导后,这个代码的时间复杂度就是 O ( 2 N ) O(2^{N}) O(2N)
空间复杂度是衡量一个算法在运行过程中临时占用存储空间大小。空间复杂度不是很细致的计算一个代码所占用多少个字节的空间,而是计算变量的个数。空间复杂度基本和时间复杂度的计算方法类似,也是使用大 O O O渐进表示法。
实例1:
void BubbleSort(int* arr, int n)
{
assert(arr);//1
int i = 0;//1
for (i = 0; i < n-1; i++)//排序趟数
{
int flag = 0;//1
int j = 0;//1
for (j = 0; j < n - 1 - i; j++)//比较次数
{
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];//1
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
flag = 1;
}
}
if (flag == 0)
{
break;
}
}
}
这个冒泡排序的空间复杂度为 1 + 1 + 1 + 1 + 1 1+1+1+1+1 1+1+1+1+1
加起来都是常数,所以空间复杂度就是 O ( 1 ) O(1) O(1),因为arr数组是从外面传递过来的,不是我们创建的,所以不算入时间复杂度。那么每次循环创建的变量不需要记录进去吗?因为变量用完就会自动回收的,所以也是不算进去的。
时间复杂度考虑的是算法运行中需要额外创建的空间
实例2:
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 ;
}
这里是一个计算斐波那契数列的函数,这里很明显创建了额外的空间。创建了一个大小为n的数组。所以这个代码的空间复杂度为O(N)。
实例3:
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N-1)*N;
}
这是一个递归求阶乘的代码,每次递归都会在栈上开辟空间,也就是开辟栈帧。一共要开辟 N − 1 N-1 N−1层,所以通过大 O O O渐进法推导出这个代码空间复杂度就是 O ( N ) O(N) O(N)