数据结构初阶——时间复杂度与空间复杂度
目录
一、数据结构前言
1.数据结构与算法的概念
在了解时间复杂度与空间复杂度之前。我们有必要前简单了解一下数据结构和算法的概念。那什么是数据结构呢?有关定义是这么说的:数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。通俗的说:数据结构主要指的是在内存当中以某种组织的形式(c语言中是用结构体定义的)对数据进行管理(这里的管理主要是指增删查改)。那么,什么是算法呢?算法(Algorithm)是定义良好的计算过程,它取一个或一组的值作为输入,并产生出一个或一组值作为输出。通俗来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。在简单了解数据结构与算法的概念后,我们就可以展开下面对时间复杂度与空间复杂度的探讨了。
二、算法的效率
前面我们简单讲诉了算法的概念,既然算法是定义良好的计算过程,那么它就存在好坏之分。我们看下面一个关于斐波那契数列的函数
long long Fib(int N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
可以看出,这样使用递归方式写的求斐波那契数列的函数看上去非常简洁。但简洁真的就一定好吗?怎样才算是一个好的算法,从而引出一个概念,算法的复杂度
1.算法的复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
三、算法的时间复杂度
1.时间复杂度的概念
定义如下:在计算机科学中,算法的时间复杂度是一个函数(这里的函数指的是数学中的函数,并不是c语言当中的函数),它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是如果我们每个程序都上机测试的话,会非常的麻烦,于是就有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
看下面的一个例子:
// 请计算一下Func1中++count语句总共执行了多少次?
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);
}
我们可以以函数的形式将其表示出来,其中函数的未知数是N,简单的计算得知:F(N)=N*N+2*N+10
现在针对这个函数关于N的取值进行一个简单的分析
N=10 | F(N)=130 |
N=100 | F(N)=10210 |
N=1000 | F(N)=1002010 |
2.大O的渐进表示
大O符号(Big O notation):是用于描述函数渐进行为的数学符号
推导大O阶方法:
-
用常数1取代运行时间中的所有加法常数。
-
在修改后的运行次数中,只保留最高阶项( 因为其他项对结果的影响不大)
-
如果最高阶存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
N=10 | F(N)=100 |
N=100 | F(N)=10000 |
N=1000 | F(N)=1000000 |
通过上面两个表格的对比,我们可以发现当N的值逐渐增大时,两者的F(N)越来越接近。时间复杂度并不是需要精确的计算时间,而是计算时间所在的量级。大O的渐进表示法去掉了那些对结果影响不大的项,更加简洁的表示出了执行次数。
而对于有些算法的时间复杂度,存在着最好,平均和最坏的情况:
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最坏情况:任意输入规模的最小运行次数(下界)
这里以在一个长度为N的数组中搜索一个数据x为例:
- 最好情况:1次找到
- 最坏情况:N次找到
- 平均情况:N/2次找到
在实际情况中,我们关注的算法的最坏运行情况,所以这个例子的时间复杂度为O(N)
3.常见的时间复杂度举例
实例1:
// 计算Func2的时间复杂度?
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);
}
我们来看这串代码,计算时间复杂度首先需要找到基本语句,可以很容易看出是,在for循环中,基本语句被执行了2N次,在while循环中,基本语句被执行了10次,根据大O阶的渐进表示规则,只保留最高阶项,并且将系数设置为1。得出时间复杂度为O(N)。(这里再次强调,O(N)计算的是量级,而不是一个具体的式子)
实例2:
// 计算Func3的时间复杂度?
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);
}
这里可以看出来,基本语句是,时间复杂度是O(N+M)。(这是需要说明的是,时间复杂度非常喜欢用N这个未知数,但是可以有其他未知数)这两项并没有说谁的阶数更高,所以N和M都不可以去除。
实例3:
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
这里可以看出来,基本语句跑了100次,但是根据大O阶的第一条渐进表示规则,常数统一写做O(1)。换言之,对于大O阶,O(1)代表的不是1次,而是常数次。(其实,现在的计算机运算速度足够快,循环1次,100次,甚至1万次所花费的时间基本没有区别,这也为大O阶的表示规则提供了技术支持)
实例4:
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n - 1;
// [begin, end]:begin和end是左闭右闭区间,因此有=号
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;
}
这是二分查找的代码。使用二分查找,数组必须有序,最开始查找范围是N,每一次查找都会使查找范围缩小一半,最坏的情况是当这个空间只有一个值才查找到。假设查找了x次,N=1*2*2*2……*2(乘了x次2),那么N=2^x,x就是,那么时间复杂度就是O(logN)。(这里有人会好奇,log下标的2我怎么没有写出来,其实是这样的,在时间复杂度涉及对数时,大部分情况下是以2为底的对数,所以对于O阶表示,如果log没有下标就默认下标为2,仅限于O阶表示,数学考试的时候课千万别忘写呀)
一些关于二分查找的小补充:二分查找是一种效率非常高的算法,比如对于一个长度为100万的数组,倘若使用一般的查找一个一个遍历过去,则最坏需要100万次。但是如果采用二分查找,最坏仅大约需要20次(我们把2的10次方近似为1000),如果长度为10亿,普通查找最坏需要10亿次,而二分查找最坏仅需要约30次。这样对比下来,二分查找的效率之高令人震惊。不过二分查找也有其局限性,它所操作的数组必须是升序或者降序的数组,而给数组排序可不比查找来的简单。所以对于查找这种操作,二分查找用的并不算多,后面我会专门更新排序和查找的博客,请大家点赞收藏关注,敬请期待……
实例5:
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if (1 == N)
return 1;
return Fac(N - 1) * N;
}
这里是关于阶乘的代码,对于一个参数N,从N到1,一共是调用了N次(见下面的图片),每次调用是执行了常数次,那么这个阶乘的算法时间复杂度就是O(N)。
递归时间复杂度的计算方法和技巧:每次递归调用的执行次数累加。
四、算法的空间复杂度
1.空间复杂度的概念
有关空间复杂度的定义如下:
- 空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度。
- 空间复杂度不是程序占用了多少字节的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。
- 空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
2.常见的空间复杂度举例
实例1:
// 计算BubbleSort的空间复杂度?
void BubbleSort(int* a, int n)
{
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;
}
}
我们计算空间复杂度需要找到算法额外消耗的空间,函数原本所带的空间是不算的。那么在这个算法中,有哪些是额外开辟的呢,比如说end,exchange,i,总共是开辟了三个额外空间,也就是常数个,所以空间复杂度就是O(1)。
这里需要简单的提醒一下,重复创建同一个变量算作同一个,就比如exchange这个变量,在循环中被创建了很多次,但只算做一个额外开辟的空间。
实例2:
// 计算Fibonacci的空间复杂度?
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;
}
还是和刚才的一样的思路,找寻额外开辟的空间,我们可以看到第7行关于数组的动态开辟,开辟的是n+1个额外空间,既然有了N这个量级,那么后面的常数量级就可以忽略不记了。也就是O(N)。
实例3:
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if (N == 1)
return 1;
return Fac(N - 1) * N;
}
这是递归求n的阶乘的函数,递归调用了N次,开辟了N个栈帧(关于函数栈帧,后面我也会专门写一期博客,敬请期待),每个栈帧使用了常数个空间。空间复杂度为O(N)。
对于递归函数求空间复杂度,其实和求时间复杂度的方法类似,是对每次递归调用的变量个数的累加。
五、常见的时间复杂度对比
一般算法常见的时间复杂度对比如下:
O(1) | 常数阶 |
O(n) | 线性阶 |
O(n^2) | 平方阶 |
O(logn) | 对数阶 |
O(nlogn) | nlogn阶 |
O(n^3) | 立方阶 |
O(2^n) | 指数阶 |
六、结语
在文章的一开始,先是简单介绍了一下数据结构与算法的概念,然后从定义开始,结合例子,介绍时间复杂度与空间复杂度(这里简单的提醒一下,时间复杂度要比空间复杂度来的重要,要重点理解)。最后是简单的罗列了常见的时间复杂度,让大家加深印象。同时,笔者认为文章的重点之处,均以用加粗或者黄色标记,日后的文章也大概率会采用这种风格。
这篇文章不足之处,还请读者多多批评指正,日后会继续更新数据结构的相关知识(下一篇是顺序表),敬请期待。