一、算法的复杂度
在理解复杂度之前,我们简单讲讲什么是数据结构和算法。
计算机中,程序等于数据结构加算法,即程序=数据结构+算法
那么什么是数据结构,什么又是算法呢?
数据结构即数据的逻辑结构,是计算机存储、组织数据的方式,是相互之间存在的一种或多种特定关系的数据元素的集合。
算法就是定义良好的计算过程,取一个值输入,并产生一个值作为输出。简而言之算法就是指计算机编程过程中所采用的步骤。
那么在编程过程中,如何衡量一个算法的好坏呢?
算法在编写成可执行程序后,运行是需要耗费时间和空间,因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即本文接下来要介绍的时间复杂度和空间复杂度
二、时间复杂度
2.1、时间复杂度的定义
时间复杂度是算法中的基本操作的执行次数,即找到某条基本语句与问题规模N之间的数学表达式,就是该算法的时间复杂度的计算方式。
下面我们举个例子:
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中count++语句共执行了多少次?
Func1执行的基本操作次数:F(N) = N^2+2*N+10
实际上我们计算时间复杂度的时候,我们不需要计算精确的执行次数,只需要计算大概执行次数,即只保留对结果影响很大的值,和数学中取极限一个道理。
2.2、如何计算时间复杂度
计算时间复杂度,我们不需要计算精确的执行次数,那么我们就使用大O的渐进表示法。
下面我们来介绍大O的渐进表示法:
大O符号:是用于描述函数渐进行为的数学符号。
大O阶的推导方法:
一、用常数1取代运行时间中的所以加法常数。
二、只保留最高项,其他项对结果的影响不大。
三、如果最高阶不是1,则去掉该项的系数
我们使用大O的渐进表示法来计算Func1的时间复杂度:O(N^2)
由此可知大O的渐进表示法去掉了那些对结果影响不大的项。
有些算法的时间复杂度存在最好、平均和最坏的情况:
最坏情况:最大运行次数(上界)
平均情况:期望运行次数
最好情况:最小运行次数(下界)
例如:输入一个N数组,查找其中一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2找到
在实际情况中,一般都是关注的是算法最坏情况,所以该例子中的时间复杂度为O(N)
下面我们举几个例子:
实例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);
}
实例1中,Func2执行的基本次数是F(N) = 2*N+10,通过大O阶推导时间复杂度为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);
}
实例2中,Func3执行的基本次数为M+N,有两个未知数,所以时间复杂度为O(M+N)。
实例3:
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; k++)
{
count++;
}
printf("%d\n", count);
}
实例3中,Func4执行的基本次数是100,是一个常数,所以时间复杂度为O(1),代表的是常数次,而不是1次
实例4:
const char* strchr(const char* str, int character);
实例4中函数strchr的意思是在字符串里查找一个字符character,其时间复杂度分三种情况:
最好情况:O(1)
最坏情况:O(N)
平均情况:O(N/2)
实例5:
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;
}
}
实例5中的代码是一个冒泡排序,执行的基本次数是N-1+N-2+N-3+…+2+1,即N*(N-1)/2,系数是1/2,最高项是N^2,所以时间复杂度为O(N ^ 2)。
实例6:
void 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 - 1;
else
return mid;
}
return -1;
}
实例6中的代码是一个二分查找,假设查找X次,即N/2/2/2/2…/2=1,
N=122*2…*2,
N=2 ^ X,
x=logN
时间复杂度为O(logN)。
时间复杂度以底数为2的对数一般都可以忽略不写,但是其他底数要写,一般很少出现其他底数。
实例7:
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
实例7中,递归了n次,所以时间复杂度为O(N)。
递归函数的时间复杂度计算方法和技巧:每次递归调用的执行次数累加。
若把实例7改为:
long long Fac(size_t N)
{
if (0 == N)
return 1;
for (size_t i = 0; i < N; i++)
{
;
}
return Fac(N - 1) * N;
}
它的时间复杂度又是多少呢?
执行的基本次数为N+N*(N-1)/2,时间复杂度为O(N^2)。
三、空间复杂度
3.1、空间复杂度的定义
空间复杂度是一个对算法在运行过程中临时占用存储空间大小的量度,算的是变量的个数。
3.2、如何计算时间复杂度
空间复杂度的计算基本和时间复杂度的计算类似,也使用大O渐进表示法
注意:
函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显示申请的额外空间来确定。
下面我们举几个例子:
实例1:
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;
}
}
实例1中是一个冒泡排序,使用了常数个额外空间,所以空间复杂度为O(1)。
实例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;
}
实例2是返回斐波那契数列前n项,该函数动态开辟了n个空间,所以空间复杂度为O(N)。
s实例3:
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
实例3,计算阶乘递归Fac的空间复杂度,该函数递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间,所以空间复杂度为O(N)。
四、常见几种复杂度对比
一般算法常见的复杂度如下:
123456 | O(1) | 常数阶 |
3n+4 | O(n) | 线性阶 |
3n^2+4n+5 | O(n^2) | 平方阶 |
3logN+4 | O(logN) | 对数阶 |
2n+3nlogN+14 | O(nlogN) | nlogN阶 |
n ^ 3+2n ^ 2+4n+6 | O(n^3) | 立方阶 |
2^n | O(2^n) | 指数阶 |