跌倒了,就重新站起来,继续向前走;傻坐在地上是没用的。💓💓💓
目录
•✨说在前面
🍋知识点一:算法的效率
• 🌰1.斐波那契数列的第n项
• 🌰2.算法的复杂度
🍋知识点二:时间复杂度
• 🌰1.时间复杂度的概念
• 🌰2.大O的渐进表示法
🔥复杂度的一般分析法则
🔥总结求解复杂度的方法
• 🌰3.时间复杂度的量级
• 🌰4.时间复杂度增长趋势
• 🌰5.时间复杂度计算案例
🔥案例1:单个同量级for循环
🔥案例2:多个同量级for循环
🔥案例3:常数控制的for循环
🔥案例4:strchr函数的时间复杂度
🔥案例5:冒泡排序的时间复杂度
🔥案例6:调整语句为i=i*2的for循环
🔥案例7:二分查找的时间复杂度
🔥案例8:递归求阶乘
🔥案例9:递归求斐波那契数列的第n项
🍋知识点三:空间复杂度
• 🌰1.空间复杂度的概念
• 🌰2.空间复杂度计算案例
🔥案例1:冒泡排序的空间复杂度
🔥案例2:递归求阶乘
• ✨SumUp结语
•✨说在前面
亲爱的读者们大家好!💖💖💖,我们又见面了,在之前的阶段我们学习了顺序表、链表,包括单链表和双向链表,还刷了一些算法OJ练习。这些练习,我们有时可以有多种思路和方法解决,那我们如何对这些方法进行取舍呢?哪些方法是最优的呢?为此,我们必须进入学习时间复杂度和空间复杂度的相关知识,相信你学习完后就可以回答这个问题了。
👇👇👇
💘💘💘知识连线时刻(直接点击即可)🎉🎉🎉复习回顾🎉🎉🎉
【数据结构】顺序表专题详解(带图解析)
【数据结构】单链表专题详细分析
【数据结构】双向循环链表专题解析
博主主页传送门:愿天垂怜的博客
🍋知识点一:算法的效率
• 🌰1.斐波那契数列的第n项
在讲解时间复杂度与空间复杂度之前,我们先看一个简单的例子:
练习:写一个程序,求出斐波那契数列的第n项的值。
方法1:迭代法
long long Fibonacci(int n)
{
int x1 = 1;
int x2 = 1;
int x3 = 1;
while (n >= 3)
{
x3 = x1 + x2;
x1 = x2;
x2 = x3;
n--;
}
return x3;
}
方法2:递归法
long long Fibonacci(int n)
{
if (n == 1 || n == 2)
return 1;
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
由观察不难发现,递归的写法明显要比迭代的写法要短的多,那是不是就说明递归的写法就比迭代的写法更好呢?其实不然,实际上用递归来写的话它的运行效率将会大大降低。
比如求第50项,就要先得到49项和48项,要得到第49项,就要得到48项和47项……它会执行很多很多次,这是它效率不高的原因。
所以,项数较大时,我们还是用循环(迭代)的方式来实现。
所以说,我们不能只根据程序的长短就果断地判断程序的好坏。
• 🌰2.算法的复杂度
那究竟如何衡量算法的好坏呢?就是要看程序的时间复杂度和空间复杂度。
算法在编写成可执行的程序后,运行时需要耗费时间资源和空间(内存)资源,因此衡量一个算法好坏,一般从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小,所以对于空间复杂度很是在乎,但结果计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度,所以我们如今已经不需要再特别关注一个算法的空间复杂度,转而更加关注他的时间复杂度。
🍋知识点二:时间复杂度
• 🌰1.时间复杂度的概念
定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。
一个算法执行所消耗的时间,从理论上来说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?这显然非常麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
• 🌰2.大O的渐进表示法
实际问题中我们不需要精确的计算执行次数,而只需要大概的执行次数,即使用大O的渐进表示法。
公式如下:
🎉参数:
• T(n):代码执行所需要的时间,它是n的函数,T(n)代表了算法的时间复杂度。
• n:数据规模的大小,但有可能不止一个,如两个for循环分别循环m、n次,则该参数为m、n。
• f(n):每行代码执行的次数总和,它是n的函数,但我们只关注最大量级的那一项。
• O:表示T(n)与f(n)之间的关系为正比例,即一个算法所花费的时间与其中语句的执行次数成正比。
🔥复杂度的一般分析法则
1)单端代码看高频:比如for、while循环。
2)多段代码取最大:比如一段代码中有单循环和多重循环,那么取多重循环的复杂度。
3)嵌套代码求乘积:例如递归、多重循环的结构。
4)多个规模求加法:比如算法中有两个参数控制了两个for循环,那么此时复杂度取二者复杂度之和。
🎉举例:
//请计算一下Func1中++count语句总共执行了多少次?
void Func(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中,第一个循环是嵌套结构,for循环嵌套结构执行了n^2次,第二个for循环执行了2n次,最后一个while循环执行了10次。
由此,这个函数中基本语句的执行次数f(n)的表达式为:f(n)=n^2+2n+10,所以T(n)=O(n^2+2n+10)。但是,大O的渐进表示法并不具体代表代码真正的执行时间,而是代表代码执行时间随数据规模n增长的变化趋势。
当n很大时,比如1000、100000,此时公式中的低阶、常量、系数三部分不左右增长趋势,所以都可以忽略,只关注最高阶的那一项就可以了。
所以最终,Func的时间复杂度表示为:T(n)=O(n^2)。
🔥总结求解复杂度的方法
1)只关注循环执行次数最多的一段代码,总复杂度等于量级最大的那段代码的复杂度
2)加法法则:若控制两个for循环的数量级相同,则总复杂度取二者复杂度之和。
3)乘法法则:嵌套循环的复杂度等于嵌套内外代码复杂度的乘积。
• 🌰3.时间复杂度的量级
多项式阶:随着数据规模的增长,算法的执行时间和空间占用,按照多项式的比例增长,包括:O(1)(常数阶)、O(logn)(对数阶)、O(n)(线性阶)、O(nlogn)(线性对数阶)、O(n^2)(平方阶)、O(n^3)(立方阶)。
非多项式阶:随着数据规模的增长,算法的执行时间和空间占用暴增,这类算法性能极差。包括,
O(2^n)(指数阶)、O(n!)(阶乘阶)。
• 🌰4.时间复杂度增长趋势
常见不同数量级的复杂度增长趋势图如下:
另外有些算法的时间复杂度存在最好、平均和最快的情况:
🎉最坏情况:代码在最坏情况下执行的时间复杂度,即任意输入规模的最大运行次数(上界)。
🎉平均情况:代码在所有情况下执行的次数的加权平均值,即任意输入规模的期望运行次数。
🎉最好情况:代码在最理想情况下执行的时间复杂度,任意输入规模的最小运行次数(下界)。
比如:在一个长度为N的数组中搜索数据x
最坏情况:N次找到
平均情况:N/2次找到
最好情况:1次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据的时间复杂度为O(N)。
• 🌰5.时间复杂度计算案例
🔥案例1:单个同量级for循环
计算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循环执行了2N次,第二个while循环的执行次数是M,但是M为已知量10,量级最大的为2N,所以Func2的时间复杂度为O(N)。
🔥案例2:多个同量级for循环
计算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中,第一个for循环执行了M次,第二个for循环执行了N次,且M、N量级相同,所以Func3的时间复杂度为O(M+N),或O(max(M,N))。
注意:若M>>N,则时间复杂度为O(M),若M<<N,则时间复杂度为O(N)。
🔥案例3:常数控制的for循环
计算Func4的时间复杂度。
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
函数Func4中,控制for循环的次数为常数,代码不随n的增长而增长,常数阶的时间复杂度为O(1),即Func4的时间复杂度为O(1)。
🔥案例4:strchr函数的时间复杂度
strchr:strchr() 用于查找字符串中的一个字符,并返回该字符在字符串中第一次出现的位置。
strchr函数模拟实现代码如下:
const char* my_strchr(const char* str, int character)
{
assert(str);
while (*str)
{
if (*str == character)
return str;
else
str++;
}
}
在strchr查找的过程中,最好的情况是字符character就在下一个字符,一次就可以找到,也就是常数阶,此时时间复杂度为O(1);最坏的情况是字符character在离位置的无穷远处,也就是线性阶。此时时间复杂度为O(N);取最坏的情况,所以strchr的时间复杂度为O(N)。
🔥案例5:冒泡排序的时间复杂度
计算冒泡排序BubSort的时间复杂度。
void bubSort(int arr[], int length)
{
assert(arr);
int flag = 1;
while (flag && length--)
{
flag = 0;
for (int i = 0; i < length; i++)
{
if (arr[i] > arr[i + 1])
{
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
flag = 1;
}
}
}
}
冒泡排序中,由类似案例2的嵌套循环结构,但是像案例2这样的嵌套循环,它的内层for循环的控制参数是不变的,也就是说,内部循环一共执行了M次,每次执行内部循环都需要循环N次,所以总共的执行次数是M*N,这很好理解,但现在冒泡排序的内部循环一共执行了length次,而第一次for循环length次,第二次length-1次,第三次length-2次...,若考虑最坏的情况,最后到1次。这种情况怎么处理呢?
显然内部for循环的循环次数是一个等差数列,其中公差d=2,首项a1=1,尾项an=length(简写为n),项数为length。对于这样一个等差数列,我们可以对其求和,所得到的结果不就是所有的执行次数了吗?
根据高斯求和公式,很容易计算出等差数列的前n项和,即基本操作的执行次数,显然为平方阶,量级为N^2,所以冒泡排序Bubsort的时间复杂度为O(N^2)。
其实对于案例2这样每次都是循环固定次数的嵌套,或者说,任意的双层嵌套都可以转化为数列求和的问题,如案例2,显然就是一个公差d=0,首项为N,尾项为N,项数为M的等差数列
根据高斯求和公式,也能够分析出基本操作的执行次数,即时间复杂度为O(MN)。
🔥案例6:调整语句为i=i*2的for循环
计算Func5的时间复杂度。
void Func5(int n)
{
int x = 0;
for (int i = 1; i < n; i *= 2)
{
x++;
}
}
函数Func5中,这个for循环的调整语句为i*=2,即i每次都是前一次的两倍。这种循环是有规律的,这个我们后面再说。我们先直接分析这个函数,假设基本语句x++执行了N次,那么有循环语句可得:
所以我们得到,执行次数N为对数阶,所以时间复杂度为O(logN)。这里需要注意,为了方便起见,当底数为2时,我们直接将底数2省略不写,
🔥案例7:二分查找的时间复杂度
计算二分查找BinarySearch的时间复杂度。
int BinarySearch(int arr[], int length, int x)
{
assert(arr);
int left = 0;
int right = length - 1;
while (left <= right)
{
int mid = left + right - ((left - right) >> 1);
if (arr[mid] < x)
left = mid + 1;
else if (arr[mid] > x)
right = mid - 1;
else if (mid == x)
return x;
}
return -1;
}
在二分查找函数BinarySearch中,如果我们直接观察while循环的话,其实是不太好看出来的,因为left和right时不时都在改变,此时我们不要死盯代码,一定要理解它的实际意义,也可以看图进行观察
最好的情况我们很容易想到,就是要查找的x就在中间,我们一下就找到了,此时是时间复杂度为O(1)。那最坏的情况呢?其实就是当要查找的x在数组的两端的时候,比如x是第一个元素,此时我们设mid到x的距离为n,则mid会以每次靠近一半的速度逼近第一个元素x,也就是每次都除以2,直到这个值等于1,就找到了第一个元素。我们设循环执行了N次,mid第一次的位置为n,则
同样地,如果x是最后一个元素,也是同样的道理。显然为对数阶,所以二分查找BinarySearch的
的时间复杂度为O(logN)。
🔥案例8:递归求阶乘
计算Fact的时间复杂度。
long long Fact(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
这是一个递归求阶乘的函数。在函数Fact中,我们假设N=5的情况如下:
可以发现,每次递归的单个函数都是常数阶,即O(1),而递归总共调用了5+1=6次(Fact(5)、Fact(4)、Fact(3)、Fact(2)、Fact(1)、Fact(0))。
所以递归的函数我们先看单个函数内部的阶,再看递归了多少次。如果是N,那么每个函数为O(1),递归了N+1次,那么总共为(N+1)O(1),所以递归求阶乘函数的时间复杂度为O(N)。
🔥案例9:递归求斐波那契数列的第n项
计算Fibonacci函数的时间复杂度。
long long Fibonacci(int n)
{
if (n == 1 || n == 2)
return 1;
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
同样的道理,我们看单个函数内部的阶,显然为O(1),那递归了多少次呢?
我们以n=5为例,此时我们能画出面类似于树状图的结构,在数的每个节点都进而伸出两个节点,第一行为个数为1(2^0),第二行个数为2(2^1),第三行个数为4(2^2)...以此类推,第n行的个数为2^n。但其实上大家能看到,这颗树是歪的,它的底部缺失了一块三角形的部分,但是当n很大时,这些缺失的部分相对于整体的个数其实就很少了,由此我们可以将每一行的递归次数看做一项,那整体就可以看做首项a1=1,公比q=2的等比数列,求和得到的即是全体递归的总次数。
虽然真正的递归次数没有这么多,但是它的量级是不会被影响的,依然是指数阶,所以用递归求斐波那契数列的第n项,它的时间复杂度为O(2^N)。
稍许有些不严谨,在二叉树的部分我们还会提到。
🍋知识点三:空间复杂度
• 🌰1.空间复杂度的概念
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的亮度。
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。
空间复杂度计算规则基本和时间复杂度类似,也是大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式的额外空间来确定。
• 🌰2.空间复杂度计算案例
🔥案例1:冒泡排序的空间复杂度
计算冒泡排序BubSort的空间复杂度。
void bubSort(int arr[], int length)
{
assert(arr);
int flag = 1;
while (flag && length--)
{
flag = 0;
for (int i = 0; i < length; i++)
{
if (arr[i] > arr[i + 1])
{
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
flag = 1;
}
}
}
}
在冒泡排序BubSort中,创建了:int flag = 1,int i = 0两个变量,为常数个,所以冒泡排序的空间复杂度为O(1)。
注意:空间复杂度算的是算法中额外开辟的空间,所以数组arr和长度length并不算在内。
🔥案例2:递归求阶乘
计算Fact函数的空间复杂度。
long long Fact(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
对于这样的一个递归函数,每次递归都会在栈上创建栈帧空间(函数栈帧),每个栈帧使用了常数个空间,所以空间复杂度为O(N)。
• ✨SumUp结语
数据结构的学习一定要多画图,多理解,多思考,切忌直接抄写代码,就认为自己已经会了,一定到自己动手,才能明白自己哪个地方有问题。
如果大家觉得有帮助,麻烦大家点点赞,如果有错误的地方也欢迎大家指出~