目录
前言
数据结构的定义
数据结构中的复杂度
时间复杂度
时间复杂度的概念
空间复杂度
空间复杂度的概念
总结
前几期我们一起学习了C语言相关的知识点,今天开始,我们便进行数据结构的学习。
数据结构是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。简单点来说,数据结构就是数据在内存中的存储方式。
我们知道,干任何事情我们都讲究效率,编写程序也不例外,但是,我们用怎样去描述一个程序的效率呢?我们编译一个程序时,是需要消耗时间和内存资源的,这里便引入了数据结构中复杂度的概念,复杂度分为时间复杂度和空间复杂度,时间复杂度用于描述程序运行的快慢,空间复杂度用于描述程序运行时序要消耗的内存资源。
时间复杂度是一个函数,用来描述算法在执行时所需要的时间资源的大小。
有些人可能会发出疑惑?好家伙,时间复杂度怎么能是一个函数呢? 其实,此函数并不是以往我们数学中所遇到的函数,而是指带有未知数的函数表达式,定量描述了一个算法的执行时间。
大家再来想一个问题。比如说现在在你面前有两台机器,一台是20年前的,一台是现在的,我们把一串相同的代码在这两个机器下执行,毫无疑问,现在的机器肯定在很短的时间内就将代码运行完成,20年前的机器可能会相对慢一点,如果按照我们刚才的定义,如果用运行时间的长短来描述一串程序的时间复杂度的话,针对当前这种情况,难道同一串程序在两种不同的机器下,它的时间复杂度会不同吗?当然不是,为了避免这种情况的发生,我们规定时间复杂度为一串程序的执行次数,因为一个程序的执行次数也间接的表示了一串程序的运行时间。
综上,数据结构中,时间复杂度就是一串程序的执行次数。
注意:如果一个程序按照需求,可以执行N次,也可以执行常数次,我们用执行最多的次数来表示时间复杂度,即用最坏的预期表示程序的时间复杂度。比如二分查找一个数,最好的预期就是查找1次,最坏的预期就是找不到,我们用发现找不到时,程序已经执行的次数为二分查找的时间复杂度。
例1:
求Func1的执行次数?
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);
}
解析:Fun1函数由一个双层for循环和一个for循环以及一个while循环组成 ,对于第一个双层for循环而言,它的执行次数就是第一层的执行次数于第二层的执行次数的乘积加上第二个for循环的执行次数再加上最后一个while循环的执行次数。
所以总共的执行次数为:N*N+2*N+10
试想一下,计算出来的Func1实际执行的次数的函数表达式N*N+2*N+10而言,当N足够大时,后面两项对整个表达式的影响会变得很小很小,所以总共的执行次数就是N*N=N^2,此时我们引入了大O渐进表示法来表示程序大概的执行次数,所以例一中Func1大概的执行次数就是N^2,所以Func1的时间复杂度就是O(N^2)
大O渐进表示法的规定:
1>如果一个程序的执行次数是常数次,我们就称这个程序的时间复杂度就是O(1),所以以后看到O(1)并不意味着程序只执行了一次,而意味着程序执行了常数次数。
2>我们在大O渐进表达式中只保留阶数最高的项。
3>如果一个程序的时间复杂度为O(2N)类似于常数乘以变量的形式,那么此时常数也是可以省略的,因为当N足够大时,常数对整个函数表达式的影响极小。
例二:
求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);
}
解析:Func2函数由一个for循环和一个while循环组成,所以这个程序的实际执行次数就是:2*N+10,根据大O渐进表示法的规定,保留最高阶,舍去常数项,所以Func2大概的执行次数就是N次,即时间复杂度为O(N)
例三:
求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);
}
解析:Func3函数由2个for循环组合而成 ,第一个for循环执行M次,第二个for循环执行K次,总共执行M+N次,如果用大O渐进表示法表示Func3的时间复杂度,此时就要分情况讨论:
第一种:N和M的大小无法判断
Func3的时间复杂度为O(M+K)
第二种:N>>M或者N<
Func3的时间复杂度为O(N)或O(M)
第三种:N=M
Func3的时间复杂度为O(N)或O(M)
例四:
计算Fun4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
解析:Func4由一个for循环组成,for循环执行了100次,所以按照大O渐进表示法的规定,100为常数,所以Func4的时间复杂度为O(1)
上述四个例子我们根据代码就可以直观的看出来,接下来的例子就不能光通过代码计算,要根据算法的思想进行计算。
例五:
计算BubbleSort(冒泡排序)的时间复杂度?
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;
}
}
解析:Bubblesort函数由一个双层for循环组成,但是第二个for循环的执行次数是变化的,我们需要进行具体的算法执行过程分析,图解如下:
综上,总共执行(N^2-N)/2次,用大O渐进表示法表示,Bubblesort的时间复杂度就是 O(N^2)
例六:
计算BinarySearch的时间复杂度?
int 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;
else
return mid;
}
return -1;
}
分析:BinarySearch由一个while循环组成,所以整个程序的执行次数就完全取决于while循环的执行次数,但是如果直接看代码,我们也是无法直接分析出while循环的执行次数,所以此时我们就要分析这个算法的执行过程,图解如下:
综上,二分查找的时间复杂度为O(log2^N)
例七:
求阶乘Fac的时间复杂度?
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
规定:递归算法执行的次数=递归次数*每次递归执行的次数 ,图解如下:
综上,采用大O渐进表示法,(N的阶乘)Fac的时间复杂度是O(N)
例八:
计算斐波那契数列Fib的时间复杂度?
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
图解如下:
由图可以很清楚的看到,总共进行了2^0+2^1+...+2^(N-1)=2^N-1调用,每次调用执行两次,总共执行2*(2^N-1)次 ,所以斐波那契数列Fib的时间复杂度为O(2^N)
空间复杂度是描述程序所消耗内存资源的物理量,但是当代计算机的内存是很大的,一个程序消耗的内存再大对计算机内存而言其实影响都不是特别大,所以一般情况下,空间复杂度没有必要投入过度的时间去研究。
空间复杂度是指程序在执行时所需要的内存空间的大小,通常用函数表达式来表示,与时间复杂度一样我们也用大O渐进法来表示。
注意:如果真的用消耗内存空间的大小来表示时间复杂度并没有太大的意义,因为程序不管消耗多么大的空间,对于计算机本身来说,可以说几乎没有影响,所以一般情况下我们用程序在运行时调用函数的整个过程中所创建的栈帧的个数*栈帧内创建的变量的个数来表示空间复杂度,具体的函数要具体分析。(如果栈帧内没有创建变量,创建的栈帧的个数就是空间复杂度)
函数所创建的形参是不用算个数的,因为函数的形参不是函数本身的栈帧内部的,而是调用它的函数的栈帧内部创建的,这点不懂的伙伴可以去看上一期的内容。
例九:
求冒泡排序Bubblesort的空间复杂度?
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;
}
}
解析:根据空间复杂度的概念, 空间复杂度就是函数额外创建的变量的个数决定。总共创建了i,exchange,end三个即常数个变量,所以冒泡排序的空间复杂度就是O(1)
例十:
求Fib的空间复杂度?
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
对于斐波那契的空间复杂度的分析,我们要结合时间复杂度一起分析,我们在计算时间复杂度时,我们发现斐波那契的某一项会被多次调用,但是尽管相同一项会被多次调用,调用的每一次都会算到时间复杂度的执行次数里去, 而空间复杂度是不一样的,同一项虽然会被多次调用但是调用的每一次本质上都是同一块空间,空间是同一个,所以对于空间复杂度的变量个数而言只用算一次,所以总共就创建了第1项到到第N项总共N个栈帧空间,所以,Fib的空间复杂度是O(N)
例十一:
求阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
分析:与Fib空间复杂度求解过程一样,因为栈帧内没有创建变量,所以我们就用创建的栈帧的个数来表示空间复杂度,从Fac(N)到Fac(0),调用一次函数创建一次栈帧,所以总共创建了N+1个栈帧,所以采用大O渐进表示法,Fac的空间复杂度为O(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是一个开辟空间的函数,根据代码我们可以知道,总共开辟了大小为(long long)大小的n+1个空间,所以它的空间复杂度为O(n)
以上便是本期的所有内容,不知道大家有没有注意到一个细节,我们怎样从本质上区分时间复杂度和空间复杂度呢,时间是一去不复返的,而空间可以重复利用 ,这一句话变从本质上解释了时间复杂度和空间复杂度的区别,所以这也解释了,为什么同一个函数被多次调用,时间复杂度每次都要算上调用的次数,而空间复杂度只用算一次。
希望本期的内容能够帮助到大家!^_^