当前位置:   article > 正文

【数据结构和算法初阶(C语言)】时间复杂度(衡量算法快慢的高端玩家,搭配例题详细剖析)

【数据结构和算法初阶(C语言)】时间复杂度(衡量算法快慢的高端玩家,搭配例题详细剖析)

目录

1.算法效率

1.1如何衡量一个算法的好坏

1.2 算法的复杂度

2.主菜-时间复杂度

2.1 时间复杂度的概念

2.2 大O的渐进表示法

2.2.1算法的最好,最坏和平均的情况

3.经典时间复杂度计算举例

3.1计算冒泡排序的时间复杂度

3.2计算折半查找的时间复杂度 

3.3.1直观数据对比暴力查找(遍历查找)和折半查找的效率

3.3计算递归函数的时间复杂度

3.4计算递归斐波那契数列的时间复杂度 

4.时间复杂度的oJ练习及解析

4.1消失的数字:链接

4.1.1思路1:遍历+循环

4.1.2思路2 

4.1.3思路3使用异或(单身狗思路)

5.结语


1.算法效率

1.1如何衡量一个算法的好坏

考察算法的性能如何 

引入例子:对斐波那契数列递归实现和循环实现的讨论:

补充斐波那契数列知识:

省流:后一个数是前两个数的和的数列

  • 首先是递归实现:
  1. long long Fib(int N)
  2. {
  3. if(N < 3)
  4. return 1;
  5. return Fib(N-1) + Fib(N-2);
  6. }
  • 循环实现:
  1. long long fibonacci(int n) {
  2. if (n <= 1) {
  3. return n;
  4. }
  5. int a = 0, b = 1;
  6. for (int i = 2; i <= n; i++) {
  7. int temp = a + b;
  8. a = b;
  9. b = temp;
  10. }
  11. return b;
  12. }

看似我们的递归代码简单,当我们运行两段代码:

运行循环

在运行递归

发现递归和循环输入同样的数据,循环很快就能打印出结果,但是我们的递归却还在很长时间的计算,因为调用的函数很多。

所以代码简洁不一定好,衡量算法的好坏该如何衡量,接下来引入算法的复杂度衡量我们算法的好坏。

1.2 算法的复杂度

  • 算法在编写成可执行程序后,运行时需要耗费时间资源空间(内存)资源 。因此衡量一个算法的好坏,一般 是从时间空间两个维度来衡量的,即时间复杂度空间复杂度
  • 时间复杂度主要衡量一个算法的运行快慢
  • 而空间复杂度主要衡量一个算法运行所需要的额外空间。
  • 在计算 机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计 算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。

2.主菜-时间复杂度

2.1 时间复杂度的概念

  • 时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一 个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知 道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个 分析方式。而且有时候程序在好的cpu设备和坏的cpu设备上跑出来的时间也不一样,所以我们定义了以下方法:
  • 一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法 的时间复杂度
  • 即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。

举例来说:

我们来看一下这个函数的时间复杂度:

我们找到这个函数中的一条基本语句count,看一下它执行的次数

所以:

上述Func1 执行的基本操作次数 :

                        F(N)= N^2+2*N+10

  •         N = 10           F(N) = 130
  •         N = 100         F(N) = 10210 
  •         N = 1000        F(N) = 1002010

那么我们就可以将上述的F(N)的数学函数表达式,称为我们这个函数实现的一个算法的时间复杂度。但是实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这 里我们使用大O的渐进表示法。,即是抓大头,只要我们起决定作用的项,如果按照大O的渐进表示法,这个函数的时间复杂度就为O(N^2),因为在这个表达式中,当我们的N很大的时候,N^2后面表达式计算的结果甚至可以忽略不计。

2.2 大O的渐进表示法

大O符号(Big O notation):是用于描述函数渐进行为的数学符号。

本质是就是抓主要影响的项就行,当我们的表达式中未知数非常大,比如N=200万亿,那么他的常数倍或者再增加常数,就像对于大海来说,多一碗水少一碗水没有区别,我们只用抓主要因素来大概估算我们算法和程序的时间复杂度就可以。

所以:重要:大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。

推导大O阶方法:( 大O的渐进表示法的一些规则和使用方法) 

  • 用常数1取代运行时间中的所有加法常数。

比如这个函数的时间复杂度:

  1. // 计算Func4的时间复杂度?
  2. void Func4(int N)
  3. {
  4. int count = 0;
  5. for (int k = 0; k < 100; ++ k)
  6. {
  7. ++count;
  8. }
  9. printf("%d\n", count);
  10. }

这里我们的count的运行次数是100,那么我们的时间复杂度是O(100),但是据规则写为:

O(1)。当表达式中只有常数项的时候,表示执行常数次1,方便表示就表示为O(1),cpu每秒运算速度为上亿次。

这里大家完全不用担心,因为执行常数次速度很快,我们的K是整数,有符号的整型到无符号的整型就是21亿多到42亿多,cpu处理速度很快,所以对于cpu这个人类文明皇冠上的宝珠来说,执行这种常数次和一次没有很大的区别。

  • 在修改后的运行次数函数中,只保留最高阶项。
  • 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。(就是去除未知数前面的系数,因为其对表达式的结果相对于未知数的次方数影响较

我们来看一下这个函数的时间复杂度:

  1. // 计算Func2的时间复杂度?
  2. void Func2(int N)
  3. {
  4. int count = 0;
  5. for (int k = 0; k < 2 * N ; ++ k)
  6. {
  7. ++count;
  8. }
  9. int M = 10;
  10. while (M--)
  11. {
  12. ++count;
  13. }
  14. printf("%d\n", count);
  15. }

由代码我们可以看一下count的执行次数F(N) = 2*N+10.

按照规则:在修改后的运行次数函数中,只保留最高阶项。得到2N,10可以写为10*N^0;

如果最高阶项存在且不是1,则去除与这个项目相乘的常数得到N

所以这个函数的时间复杂度就为o(N);

那么同样的,对于我们引入的例子:

时间复杂度就为O(N^2)

2.2.1算法的最好,最坏和平均的情况

有些算法的时间复杂度存在最好、平均和最坏情况:

  • 最坏情况:任意输入规模的最大运行次数(上界)
  • 平均情况:任意输入规模的期望运行次数
  • 最好情况:任意输入规模的最小运行次数(下界)

结论:时间复杂度在计算时,是一个最稳健的保守预期即是我们只关注最坏的情况。

看例子:

  1. // 计算strchr的时间复杂度?
  2. const char * strchr ( const char * str, int character );

这个函数实现的是在字符串或者字符数组中去查找一个字符

那么他的执行情况就有三种大类:

最好情况:1次找到 最坏情况:N次找到 平均情况:N/2次找到

在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)

3.经典时间复杂度计算举例

3.1计算冒泡排序的时间复杂度

冒泡排序详解:冒泡排序详解

  1. // 计算BubbleSort的时间复杂度?
  2. void BubbleSort(int* a, int n)
  3. {
  4. assert(a);
  5. for (size_t end = n; end > 0; --end)
  6. {
  7. int exchange = 0;
  8. for (size_t i = 1; i < end; ++i)
  9. {
  10. if (a[i-1] > a[i])
  11. {
  12. Swap(&a[i-1], &a[i]);
  13. exchange = 1;
  14. }
  15. }
  16. if (exchange == 0)
  17. break;
  18. }
  19. }

我们知道冒泡排序的原理:就是元素两两进行比较然后交换位置,第一次我们需要比较n-1次,把最后一个数排好过后,第二次比较n-2次.......

  • 那么最好的情况就是:我们的这份数据是有序的,那么对于我们程序来说他还是要运行一次,看一下有没有数据之间有没有两两进行交换,这里执行n-1次,那么时间复杂度为O(N)
  • 平均情况,当我们的数据中有一个或者两个是乱序的,那么对于程序来说就要执行两次函数,数据对比n-1+n-2次就是2n-3也是O(N);
  • 最坏的情况,我们的数据完全乱序程序就要比较:n-1+n-2+n-3.......+1次,就是等差数列,(N*(N-1)/2次,通过推导大O阶方法+时间复杂度一般看最 坏,时间复杂度为 O(N^2)

3.2计算折半查找的时间复杂度 

(使用折半查找方法的前提是针对一组有序的数据)折半查找的思想为:将要查找的数据与这组数据的中间元素进行对比,如果要查找的数据大于或小于中间数据就舍弃另外一半数据,在新的数据段里面将要查找的数据和新数据段中间的数据进行查找,循环直到找到数据或者找不到退出)

  1. // 计算BinarySearch的时间复杂度?
  2. int BinarySearch(int* a, int n, int x)
  3. {
  4. assert(a);
  5. int begin = 0;
  6. int end = n-1;
  7. // [begin, end]:begin和end是左闭右闭区间,因此有=号
  8. while (begin <= end)
  9. {
  10. int mid = begin + ((end-begin)>>1);
  11. if (a[mid] < x)
  12. begin = mid+1;
  13. else if (a[mid] > x)
  14. end = mid-1;
  15. else
  16. return mid;
  17. }
  18. return -1;
  19. }
  • 最好的情况:要找的数据就是这份数据的中间值,那么只用执行一次,就是O(1)
  • 最坏的情况:要找的数据在尽头活着没有要查找的数据,我们要的结果无非就是折半的次数假设有吗有N个数据,执行一次还有N/2个数据。执行第二次还有N/2/2个数据执行到最后只有一个数据的时候是不是我们的式子为:N/2/2/2/2.....=1

那么我们的2的个数就是程序执行的此时:N=2^n,这个n就等于log2N,(这个2是角标)

那么我们的时间复杂度就有了,由于对数键值我们不好书写,所以优化为logN,有些资料会写为lgN.O(logN)

3.3.1直观数据对比暴力查找(遍历查找)和折半查找的效率

虽然折半查找的效率高,但是实际应用不这么好。因为他要求一个大前提,针对有序的数据,使用前还需要排序。

3.3计算递归函数的时间复杂度

  1. // 计算阶乘递归Fac的时间复杂度?
  2. long long Fac(size_t N)
  3. {
  4. if(0 == N)
  5. return 1;
  6. return Fac(N-1)*N;
  7. }

递归的时间复杂度这里我们可以理解为FAc函数的调用次数。

对比这段代码:

  1. long long Fac(size_t N)
  2. {
  3. if(N==0)
  4. {
  5. return 1;
  6. }
  7. for(size_t i = 0;i<N;++i)
  8. {
  9. ;
  10. }
  11. return Fac(N-1)*N;
  12. }

这段代码不仅调用了N次Fac函数,每次调用后函数里面还执行了N次,所以这个递归的调用的时间复杂度为O(N^2).

3.4计算递归斐波那契数列的时间复杂度 

  1. / 计算斐波那契递归Fib的时间复杂度?
  2. long long Fib(size_t N)
  3. {
  4. if(N < 3)
  5. return 1;
  6. return Fib(N-1) + Fib(N-2);
  7. }

由上图,我们计算时间复杂度就是取大概,那么如果我们将N=4,N=5的最下面想象为有值也就是调用了,因为末尾多几次少几次调用在本次代码中并不是影响很大:

那么我们的时间复杂度就可以计算为:2^0+2^1.........2^(n-1)

使用大O表示法就是O(2^N)

所以这就是我们为什么最开始的时候使用递归方法计算我们的cpu还在计算,因为这个算法效率是在太慢了。

4.时间复杂度的oJ练习及解析

分析思路实现比较好的思路

4.1消失的数字:链接

4.1.1思路1:遍历+循环

思路一:遍历+循环

我们循环遍历这个数组中的所有数,直到找到一个数,他不是他的上一个数+1,这里还要排序。我们使用最快的快速排序:时间复杂度为O(logN*N),在加上我们的循环遍历的N,可以记为:O(logN*N)

4.1.2思路2 

思路二:既然知道这个数组里面的元素是0~n的元素缺一个,那么我们就可以先计算出0~n的所有数的和(可以循环也可以使用等差公式)再减去我们数组里面所有元素是不是就能够得到那个不见的数字。如果使用公式,时间复杂度为我们循环减去数组元素的循环次数就是O(N),如果使用循环来计算和,那么第一个执行次数为N+1,然后减法循环次数为N,时间复杂度为O(N),我们来实现一下:

4.1.3思路3使用异或(单身狗思路)

^  异或

  • 相同为0,相异为1
  • 0与任何数异或都是那个数本身
  • 两个相同的数异或为0,像2^3^2^3=0,那我我们上述的缺了一个数的数据和我们没有缺的数据异或起来,首先我们得先把缺的那个数赋值为0,0和任何数异或都是那个数本身。
  • 比如我们是0~3缺2

完整的数据:0,1,2,3

缺的数据:0,1,3

那么我们首先先让未知数和我们的这个缺的数据循环异或起来:x=0^0^1^3

这里时间复杂度为O(N+1)

接着我们在让x和完整数据异或起来:

x =0^0^1^3^0^1^3^2

就得到我们的2,这一步的时间复杂度为O(N)

那么这个算法的时间复杂度就为O(N),我们来实现一下:

5.结语

以上就是本期的所有内容,知识含量蛮多,大家可以配合解释和原码运行理解。创作不易,大家如果觉得还可以的话,欢迎大家三连,有问题的地方欢迎大家指正,一起交流学习,一起成长,我是Nicn,正在c++方向前行的奋斗者,数据结构内容持续更新中,感谢大家的关注与喜欢。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/知新_RL/article/detail/169045
推荐阅读
相关标签
  

闽ICP备14008679号