时间复杂度和空间复杂度是数据结构中最为基础的概念,是对算法效率的衡量指标,贯穿数据结构始终。或者说,在数据结构领域,程序员追求的就是数据结构的优化,以此来降低算法时间复杂度和空间复杂度,最后达到优化程序性能的目的。
在给出时间复杂度和空间复杂度具体定义之前,不妨根据已有的编程经验猜想什么指标可以衡量算法的效率。
以时间复杂度为例子,运行时间想必是可以的,类比生活中各类效率问题,不难理解。
但是这样猜想的话就陷入了误区。任何编程最后的产物,无论是一段算法还是一个完整的程序,一定是会迁移到其他计算机上的,而运行时间是受到计算机硬件影响的。
那自然而然想到补充限制条件,比如控制变量,将计算机硬件性能用一个指标统一衡量。
不探讨实际可行性,这样执行,计算的准确度一定很高,但对于实际生产来说,我们显然可以预见这样操作的复杂程度,而且一位程序员想要计算算法效率,往往是不需要如此准确的。
所以,我们需要一种衡量方式,精度符合实际生产要求,且计算简单。
时间复杂度
有了以上理解,我们直接给出时间复杂度的定义。一般的,时间复杂度是指基本操作的执行次数。下面以 C 语言中 for 循环为例子说明。
//打印1-N数字
int i = 0;
for(i=0;i<N;i++)
{
printf("%d\n",i+1);
}
这里需要假定 N 在循环前面已经被赋值了。可以预见这个循环会执行 N 次打印,我们就可以说它的时间复杂度是 N。
但这样的定义实际上还有些瑕疵。如果这是被封装到一个函数体里面,在循环后面又执行了一句呢?比如下面的代码。
void NumsPrint(int N)
{
//打印1-N数字
int i = 0;
for(i=0;i<N;i++)
{
printf("%d\n",i+1);
}
//执行结束,输出
printf("打印结束");
}
这个函数中,基本操作(实际上就是打印)的执行次数会是 N+1 次。
用一点高数的知识,当 N 远大于 1 时,N+1 近似于 N,也就是说上面两段代码的时间复杂度是非常接近的。
大 O 渐进表示法
为此,我们引出了数学上的表示方法,大 O 渐进表示法。给出大 O 阶的推导方法:
- 用常数 1 取代运行时间中的所有加法常数,也就是说,固定次数的基本操作的时间复杂度都是 O(1)。
给出代码示例:
#include <stdio.h>
int main()
{
printf("helloworld");
printf("helloworld");
printf("helloworld");
printf("helloworld");
printf("helloworld");
printf("helloworld");
return 0;
}
这里打印操作执行了六次,但时间复杂度不是 O(6),而是 O(1)。
这个计算特点也是根据实际生产情况得到的。当前计算机技术发展迅速,对于固定次数的基本操作而言,执行特定的次数和执行一次的差距不是很大。
- 只保留最高阶项。
这里所说的最高阶项可以参考文章上面写的 NumsPrint 代码,基本操作执行次数 N+1 和基本操作次数 N 区别不大(这里应该认为 N 是较大的数,具体的原因也是实际生产需求)。
- 如果最高阶系数不是 1,最后应该看作 1。
这是为了解决函数多次调用的问题。还是文章上面写的 NumsPrint 代码,如果在 C 的 main 函数里面多次调用该函数,它对时间复杂度的影响也是 O(N),而不是调用几次就几个 N。
如果高等数学有一定基础,根据这三点很容易理解到,时间复杂度的计算方法实际上就是等价无穷小的求取。(根据同济第八版《高等数学》,等价无穷小的严格定义是:设当 x→x₀(或 x→∞)时,f(x) 与 g(x) 都是无穷小,若极限 lim f(x)/g(x) = 1,则称 f(x) 与 g(x) 是等价无穷小,记作 f(x) ~ g(x)。)
常见示例
下面给出常见情况,分别分析时间复杂度,加深理解。
void exp1(int N)
{
int i =0;
int j = 0;
int k =0;
for(i =0 ;i < N;i++)
{
for(j =0 ;j<N;j++)
{
printf("qiaqia");
}
}
for(k =0;k<N;k++)
{
printf("qiaqia");
}
int count = 10;
while(count)
{
count--;
}
}
这段代码的准确的实际复杂度是 N^2+N+10,写出大 O 阶就是 O(N^2)。
再给出一个例子:
void exp2(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);
}
这一道题目比较复杂,基本执行次数是 M+N。
如果能理解到等价无穷小的概念的话,很容易看出 M 和 N 两个数字的相对大小影响时间复杂度。如果 M 和 N 差不多大,可以看作 O(N) 或者看作 O(M);如果 N 远大于 M,就必须要写成 O(N+M)。
再看冒泡排序:
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;
}
}
}
对于这样的代码,往往情况不同,执行的基本操作次数也不同。一般都是考虑最坏情况,或者称为最不利原则。
如果从比较次数的角度来看,时间复杂度应该是 (N-1)+(N-2)+……+1,一个等差数列求和,显然最后得到 O(N^2)。
如果从未排序区间元素个数的角度来看,那就是 N+N-1+……+2,最后时间复杂度也是 O(N^2)。
从不同角度得到的时间复杂度计算方式稍显差异,也就不难理解时间复杂度计算原则这样设定的原因。
再看一个二分查找的例子:
int BinarySearch(int* a, int N, int x)
{
assert(a);
int begin = 0;
int end = N;
while (begin < end)
{
int mid = begin + ((end - begin) >> 1);
if (a[mid] < x)
{
begin = mid + 1;
}
else if (a[mid] > x)
{
end = mid;
}
else
{
return mid;
}
}
return -1;
}
二分查找的前提是有序排列。同样采用最不利原则。
查找区间最开始是 N,后来是二分之一 N,折半减少,当查找区间大小为 1 的时候找到目标。
不妨列一个方程 N/2^x=1,其中 N 是区间长度,x 是执行次数。则 x=log2N,为了简写,我们改成 x=logN,但是不能简写成 lgN。也就是时间复杂度为 O(logN)。
有了这些例子,理解时间复杂度就不难了。接下来分析空间复杂度。
空间复杂度
空间复杂度和时间复杂度一样,用大 O 渐进表示法。受不同操作系统影响,同一变量类型占用的字节大小略有差异。因此空间复杂度计算的是变量的个数,而不是字节的多少。
计算方式是类似的。来看一个例子:
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;
}
}
}
变量的个数是常数个,哪怕包含形参,因此空间复杂度是 O(1)。这里要和时间复杂度区分开,虽然循环了 N 次,但是空间不会像时间复杂度那样累计,而是利用同一块空间。
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;
}
malloc 动态开辟了一个大小为 N+1 的数组,相当于额外开了 N+1 个空间;所以空间复杂度为 O(N)。
很多 OJ 题涉及到了时间复杂度和空间复杂度的问题,掌握这些基础概念,就能更好地优化算法、提高程序性能。