双维度衡量算法好坏
前言
1 , 如何衡量一个算法的好坏
如何衡量一个算法的好坏? 是算法运行所耗的具体时间?还是
算法运行时所占用的系统内存大小?
的确这两种方式可以很好的衡量一个算法的好坏, 在我们的视角
中, 一个算法我们肯定要求其运行速度越快越好, 所占内存越少越好。
如果我们要通过算法运行所耗具体时间和其所占具体内存来衡量,
哪对于每一个不同的算法, 我们衡量它的好坏是,都需要让其具体运行一
次。
那这样的方式也太繁琐了。
所以, 针对这样的情况。 我们计算机界的前辈发挥自己的智慧,
说, 我们以后衡量一个算法, 要从时间和空间两个维度去衡量,
时间维度衡量的方式我们称其为 算法的时间复杂度
空间维度衡量的方式我们称其为 算法的空间复杂度
并且, 我们的前辈还统一了一套计算时间/空间复杂度的计算方式。
它就是 大O渐进表示法
2 , 大O的渐进表示法
- 大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
- 大O阶表示法基础规则:
- 第一步 , 用常数1取代函数中的所有加法常数。
- 第二步, 如果含有未知数, 去掉常数, 然后对于相同未知数只保留最大指数的那一项。
- 第三步 ,去掉未知数的系数, 原系数用1代替。
- 经过这三步, 所得结果就是 函数的大O阶
- 我们常用通过大O渐进表示法计算出的大O阶 去说明一个算法的时间复杂度和空间复杂度
一 , 时间复杂度
1 , 什么是时间复杂度
- 我们用时间复杂度去表示一个算法运行速度的快慢
- 一个算法的时间复杂度一般与这个算法的运行次数有关, 而计算一个算法的运行次数, 我们通常计算算法中基本操作的执行次数, 例如:
1. 循环 : 循环的执行次数
2. 递归函数 : 函数调用次数- 一个算法的时间复杂度是通过计算算法运行次数大O阶来表示
- 大O阶 表示的是算法运行速度的一个水平,或者说量级, 属于估计值
- 对于具体的算法,相同的大O阶, 只是代表它们的运行速度处于同一个水平, 同水平中有快有慢。
2 , 计算时间复杂度的几种示例
有些算法的时间复杂度存在最好和最坏两种情况:
最坏 : 任意输入规模的最大运行次数
最好 : 任意输入规模的最小运行次数
而在时间复杂度的计算中, 我们所取的全都是算法的最坏情况
例如:
最好 : 一次
最坏 : n次
那我们取n次, 所以最坏情况下该算法的大O阶为 O(n)
示例一:
void Func2(int N) // 该算法的基本操作可以看出是两个循环, 每个循环每运行一次就会对count进行++
{ // 因此 , 计算出count的值, 就可以得知该算法的运行次数
int count = 0;
for (int k = 0; k < 2 * N ; ++ k) // 循环 2n次
{
++count;
}
int M = 10;
while (M--) // 循环 10次
{
++count; // 共2n + 10次
} // 采取大O的渐进表示 对 函数 2n+10进行处理
printf("%d\n", count); // 结果为 n , 所以 大O阶 为 O(n)
}
因此 该算法的时间复杂度为 O(n)
示例二:
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++ k) //该示例函数的基础操作为 count++ 循环每运行一次, count++ 一次
{
++count; // 循环M次, 基本操作count++进行M次
}
for (int k = 0; k < N ; ++ k)
{
++count; // 循环N次, 基本操作count++进行N次
}
printf("%d\n", count); // 总次数 M+N
} // 大O渐进表示法, 其大O阶为 O(M+N)
因此该算法的时间复杂度为 O(M+N)
示例三:
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k) // 常数次循环
{
++count; // 共100次,
} // 其大O阶为 O(1)
printf("%d\n", count);
}
因此该算法的时间复杂度为 O(1)
示例四:
// C库函数 strchr
const char * strchr ( const char * str, int character );
// 函数strchr存在于cstring头文件中, 用于查找字符串中的字符
// 如果字符串str包含字符 character 那么该函数返回指向首次出现的character的指针。
// 最好的情况, 一次查找成功,
// 最坏的情况, 遍历整个字符串刚好找到,或者是未找到, 此时字符串str有几个字符,就查找了几次 n个就是n次
// 根据其最坏的情况, n次 那么它的大O阶表示为 O(n)
因此该算法的时间复杂度为O(n)
示例五:
// 这是一个排序算法
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; // 如果每次都是exchage = 1,
} // 那么外层循环一次, 内层循环 n-i次
// 这个问题就成为了 计算等差数列的前n项目和。
} // 最后结果为 n(n+1)/2 , 也就是 (n^2 +n)/2
if (exchange == 0) // 如果exchange = 0, 恰好标表明这是一个升序数组, 那么只需遍历这个数组,运行就会结束。 也就是运行了 n次
break;
} // 使用大O的渐进表示法为 n^2
} // 该算法的大O阶为 O(n^2)
** 该算法的时间复杂度为 O(n^2)**
示例六:
// 二分查找法
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n-1;
// [begin, end]:begin和end是左闭右闭区间,因此有=号 while (begin <= end)
while(begin<end)
{
int mid = begin + ((end-begin)>>1); // 让mid对应的索引等于 begin + end /2
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid-1;
else
return mid;
}
return -1;
} // 二分查找法, 最坏的运行情况: 当恰好拆半所得区间中只剩下一个元素,而这个元素就是要查找的元素, 或者不是
// 此时程序运行结束, 那么数组中元素总个数就是 最后一个区间中的3个元素 * 2 * 2*。。。 ,乘多少个2呢? 程序运行了几次, 就乘几个2
// 如果程序运行了 t次 ,那么就是 3*2^t = n
// 也就是 t 的值就是 log以2为底,三分之一n为对数
// 按大O的渐进表示法,最后所的结果就是 log2(底数) N(对数) 可以简写为 log n
// 所以该算法的大O阶为 0(log n)
该算法的时间复杂度为 O(log n)
- 注意: 只有 log以2为底,N的对数可以简化为 log N
示例七:
long long Fac(size_t N)
{
if(0 == N) // 从 Fac(N) 一直到Fac(0),
return 1; // 一共调用了N+1次 函数Fac
// 也就是程序一共运行了 n+1次
return Fac(N-1)*N;
}
// 利用大O的渐进表示法 结果为 n
// 因此其大O阶为 O(n)
** 该算法的时间复杂度为 O(n)
示例八:
long long Fib(size_t N)
{
if(N < 3) // 基本操作为函数调用次数
return 1; // 从Fib(N) 到 Fib(3)会调用 2^(n-3)
// Fib(N) 递归调用两次函数 Fib(N-1)和Fib(N-2)
// Fib(N-1)又会递归调用两次, Fib(N-2)也会递归调用两次
// 如此看来就相当于 Fib(N) 2 (2^0)
// Fib(N-1) 4 (2^2)
// Fib(N-2) 8 (2^3)
// 也就是说总的调用次数为 等比数列的前(n-3)项和
// 最后所得结果必然是 2^n 这个量级
// 因此其大O阶为 O(2^n)
return Fib(N-1) + Fib(N-2);
}
**该算法的时间复杂度为 O(2^n)
二 , 空间复杂度
1 , 什么是空间复杂度
- 我么用空间复杂度来表示一个算法在运行时所占用临时内存的大小
- 一个算法的空间复杂度一般与这个算法在运行时所显式申请的额外内存有关,算法每申请一次额外内存, 我们对其进行计数+1, 最终我们得出算法申请额外内存的总次数。
- 函数运行时所需要的栈空间是在编译期间已经确定好的,并非运行时申请的
- 上述所指的栈空间具体指 存储参数、局部变量、一些寄存器信息等
- 一个算法空间复杂度是通过计算其所申请额外内存次数的大O阶来表示
- 大O阶 表示的是算法所占的一个量级, 属于估计值
- 对于具体的算法,相同的大O阶, 只是代表它们的所占内存处于同一个量级, 同量级中有大有小。
2 , 计算空间复杂度的几种示例
示例一: (额外申请常数次空间)
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end) // size_t end 额外申请了一次空间
{
int exchange = 0; // 再次额外申请空间
for (size_t i = 1; i < end; ++i) // size_t i 再次额外申请空间
{ // 共额外申请了三次空间
if (a[i-1] > a[i]) // 大O的渐进表示法 结果为 1
{ // 所以其大O阶为 O(1)
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
该算法的空间复杂度为 O(1)
- O(1) 代表常数个
示例二: (额外申请函数栈帧)
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N; // 递归调用了N次函数 开辟了 N个函数栈帧
} // 大O的渐进表示法为 n
// 其大O阶为O(n)
该算法的空间复杂度为 O(n)
示例三: (数组空间的开辟)
long long* Fibonacci(size_t n)
{
if(n==0)
return NULL;
long long * fibArray = (long long *)malloc((n+1) * sizeof(long long)); // 额外申请为数组开辟了N+1个空间
fibArray[0] = 0; // 大O的渐进表示法 结果为 n
fibArray[1] = 1; // 其大O阶 为 O(n)
for (int i = 2; i <= n ; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray [i - 2];
}
return fibArray;
}
该算法的空间复杂度为 O(n)
示例四: (函数栈帧的重复利用)
long long Fib(size_t N)
{
if(N < 3) // 这个递归函数虽然调用了2^n次函数Fib, 但是在创建函数申请栈帧时, 会有空间的重复利用。
// 比如 Fib(n-2)就会被调用两次, 但是只会为函数申请一次栈帧
// 就是说, 函数的参数有多少种, 就会申请多少次栈帧
// 从 N 到 1 一共会申请 N-1次额外的栈帧
// 因此该函数的空间复杂度为 O(n)
return 1;
return Fib(N-1) + Fib(N-2);
}
该算法的空间复杂度为 O(n)