【C】初阶数据结构1 -- 时间复杂度与空间复杂度

news/2025/1/13 15:29:09 标签: 数据结构, c语言

目录

数据结构 

2  算法 

3  复杂度 

1) 时间复杂度

2) 空间复杂度

4  提升算法能力的两点建议 

1) 画图

2) 多实践,多上手写代码


重点一  数据结构的定义

数据结构 

  数据结构是计算机存储、组织数据的方式,主要是指相互之间存在一种或多种特定关系的数据元素的集合。在计算机中,包含多种数据结构,如:顺序表、链表以及树、图、哈希表等多种数据结构,需要知道的一点是,没有任何一种数据结构能够适用于所有的情况,有时候需要综合多种数据结构才能解决一个实际问题,所以才会有多种数据结构


重点二  算法

2  算法 

  数据结构是与算法紧密相关的,所谓算法(Algorithm)就是定义良好的计算过程,取一个值或者一组值为输入,然后通过一系列的计算步骤,用来将输入的数据输出结果

  算法也是分好坏的,用有的算法写出来的程序可能运行时间只需要4毫秒,而有的算法写出来的程序可能就会需要8秒甚至9秒,如,堆排序和冒泡排序(以后会讲解),都是100000个数据进行排序,但是堆排序只需要4毫秒,而冒泡排序却需要8秒,所以衡量一个算法的好坏就显得尤为重要,那么该用什么来衡量算法的好坏或者执行效率呢?

  这里就不得不提到这篇文章的重点,复杂度了。


重点三  复杂度

3  复杂度 

  算法在编写成程序后,运行效率无非是从两个角度来分析,一个就是运行时间的长短,另一个就是运行所占空间的大小,所以复杂度也就区分为了时间复杂度与空间复杂度

重点四  时间复杂度

1) 时间复杂度

  时间复杂度主要是用来衡量一个算法的运行快慢的,如果一个算法的时间复杂度越小,那么这个算法也就越好。

  那么时间复杂度是否是直接计算程序运行时间的长短呢?答案是并不是,因为一个程序运行时间的长短不仅是跟算法的好坏相关,也与计算机的配置相关,一个计算机的配置越好,运行时间越快,也与编译器本身相关,而且最重要的一点是,用程序运行时间衡量的话,是不能事前估计的,只能在程序运行后才能衡量,综合来说,是不能用一个程序的具体运行时间来衡量一个算法的好坏的。

  在计算机科学中,时间复杂度是一个函数式T(n),这个函数式定量描述了一个算法的运行时间,体现了一个算法的运行时间的规模,规模越大,算法运行时间也就越长,就没有那么好,比如,算法A时间复杂度T(n)是n^2,算法B的时间复杂度T(n)是n,所以算法B是优于算法A的。

  计算一个算法的时间复杂度时,主要是看某个语句的运行次数,如以下这个代码:

//计算跟count相关语句的执行次数
void Func1()
{
  int count = 0;
  for (int i = 0;i < n;i++)
  {
    for (int j = 0;j < n;j++)
    {
      count++;
    }
  }
 
  for (int i = 0;i < n;i++)
  {
    count++;
  }
}

  运行次数如下面表格所示:

count语句运行次数
语句运行次数
int count = 01
第一个count++n^2
第二个count++n

 所以这个程序中的T(n) = n^2 + n + 1,但是只有当n很小的时候,T(n)中的n和1才会对T(n)起影响作用,但是当n特别大的时候,甚至是趋于无穷量的时候,n^2趋于无穷的速度远远快于n趋于无穷的速度的,所以当n特别大的时候,n和1也就对T(n)的影响很小了,由于T(n)只是衡量一个算法运行时间的规模,所以n和1也就可以舍弃了,这时候T(n) = n^2了

  所以在计算时间复杂度时,只需要计算运行次数中量级最大的那个语句的执行次数(一般是循环中的语句),也就是能够代表增长量的大概执行次数就可以了,这种表示时间复杂度的表示法叫做大O表示法。

  大O表示法规则:

1) 在计算时间复杂度时,只保留高阶项,去掉低阶项,因为当n特别大时,低阶项对于T(n)的影响可以忽略不计

2) 如果高阶项前面有系数且不为1,那么就把系数变为1,因为时间复杂度只是描述运行时间规模,常数项不影响

3) 如果程序的运行次数与n没有关系,只有常数次,那就统一用 1 来表示,表示这个算法的T(n)为常数阶

接下里我们来看几个例子:

例1:计算Func1函数的时间复杂度

void Func1(int n)
{
  int count = 0;
  for (int i = 0; i < 3 * n; i++)
  {
    count++;
  }

  int m = 100;
  
  for (int i = 0; i < m;i--)
  {
    count++;
  }

}

运行次数如下面表格所示:

Func1函数运行次数
语句执行次数
第一个count++2 * n
第二个count++100

总执行次数:2 * n + 1

时间复杂度:根据以上大O表示法规则,舍弃低阶项以及系数,可得T(n) = n,所以时间复杂度为:O(n)

例2:计算Func2函数的时间复杂度

void Func2(int n, int m)
{
  int count = 0;
  for (int i = 0;i < n; i++)
  {
    count++;
  }

  for (int j = 0;j < m; j++)
  {
    count++;
  }

}

运行次数如表格所示:

Func2函数运行次数
语句运行次数
第一个count++语句n
第二个count++语句m

总执行次数:m + n

这个函数的运行次数与两个变量n,m都相关,当m < n时,T(n) = n,时间复杂度:O(n);当m == n时,T(n) = n 或者 m,时间复杂度:O(n) 或者 O(m);当m > n时,T(n) = m,时间复杂度:O(m)

例3:计算Func3函数的时间复杂度

int Func3(int n)
{
  int count = 0;
  for (int i = 0;i < 1000;i++)
  {
    count++;
  }
  
  return count;
}

运行次数:

Func3函数语句的运行次数
语句运行次数
count++100

这个函数里count++语句的运行次数为100次,跟n是没有关系的,T(n) = 100,根据上面的大O推导规则,运行次数为常数次,时间复杂度为:O(1)

  需要注意的是,这里的O(1)并不是代表运行次数为1次,而是代表该算法的消耗时间的规模是常数阶,跟输入的数据n是没有关系的

例3:计算Func4函数的时间复杂度

const char* Func4(const char* str, char character)//查找character字符在str字符串中的位置
{
  const char* p = s;
  while (*p != character)
  {
    if (*p == '\0')
    {
      return NULL;
    }
    
    p++;
  }

  return p;
}

执行次数(这里假设字符串长度为n):

Func4函数语句的运行次数
查找情况执行次数
若查找字符在第一个位置1次
若查找字符在中间位置n/2次
若查找字符在最后位置或者找不到n次

所以可以看到上述程序的运行次数是与查找字符的位置相关的,当查找字符位于前面位置时,T(n)是常数次,所以时间复杂度是:O(1)当查找字符位于偏中间位置时,T(n) = n/2,时间复杂度为:O(n)当查找字符位于末尾位置时,T(n) = n,时间复杂度为:O(n)

  这种分不同情况时,复杂度也随之不同的算法,是有最好时间复杂度、平均时间复杂度和最坏时间复杂度的:

最坏时间复杂度:任意输入规模的最大运行次数(上界)。

平均时间复杂度:任意输入规模的期望(均值)运行次数。

最好时间复杂度:任意输入规模的最小运行次数(下界)。

  显然,这个算法的最坏和平均时间复杂度为O(n),最好时间复杂度为O(1)。

例4:计算Func5函数的时间复杂度

void Func5(int n)
{
  int count = 1;
  while (count < n)
  {
    count *= 2;
  }
  
}

这个函数的运行次数跟之前函数不太一样,之间count都是++,这里是每次乘2,要分析运行次数就得根据运行次数和count值之间的关系来推导出运行次数,count *= 2 语句运行次数(也就是循环次数)与count值变化如下表格:

Func5函数语句的运行次数
count的值运行次数
10
1*21
1*2*22
1*2*2*23
1*2*2*2*24
............
2^i

通过推导运行次数和count值之间的关系,不难得出规律,就是当运行次数为 i 次时,count的值是2^i ,由于循环停止条件是count >= n,所以运行次数和n之间的关系就是2^i >= n,这里取等号就是2^i = n,也就是 i = \log_{2}n所以T(n) = \log_{2}n,时间复杂度为:O(\log_{2}n),这里写O(logn)也是可以的,这里还是因为时间复杂度表示的运行时间的规模,而\log_{2}n就代表其时间复杂度为对数阶,所以写logn也是可以的。

例5:计算Func6函数的时间复杂度

//计算阶乘的递归函数
int Func6(int n)
{
  if (n == 0)
  {
    return 1;
  }
  
  return Func6(n - 1) * n;
}

Func6函数是用来计算一个数n的阶乘的递归(递归是指直接或者间接调用自身函数的一种算法思想)定义的函数,而计算一个跟递归相关算法的时间复杂度公式为:
 

递归函数的时间复杂度 = 单次递归的时间复杂度 * 递归的深度(次数)

Func6函数单次执行的时间复杂度为:O(1)

Func6函数递归的次数为:n次

所以Func6函数的时间复杂度为:O(n)

  要注意的是这里的相乘并不是n * O(1),而是直接将O(1)里面的1乘以n,其实递归函数计算时间复杂度的内在原理为:C语言在每次调用一个函数的时候,都会为其开辟一块函数栈帧(可以理解为在调用函数时,会为每一次多用的函数额外开辟一块空间,每个函数之间的函数栈帧是独立的),也叫运行时堆栈,而每递归一次,就相当于对该函数调用了一次,虽然每次运行次数为常数次,但是当递归次数达到n次时,也就相当于常数次的运行次数变为了n次,所以时间复杂度会变为O(n)

重点五  空间复杂度

2) 空间复杂度

  空间复杂度类似于时间复杂度,也是一个表达式,用来描述程序在运行过程中临时额外开辟空间的大小,同样的,也是描述一个开辟空间的规模大小。

  在时间复杂度中,时间复杂度并不是程序运行时间的大小;同样的,空间复杂度也不是实际开辟了多少个字节的空间,而是算的是额外使用变量的个数,把额外使用的一个变量抽象为一块空间。

  空间复杂度的计算与时间复杂度一样,也采用大O渐进表示法

  需要注意的一点是:函数在运行时所需要的空间(形参,局部变量等)已经确定好了,所以这些变量不被计算到空间复杂度中,计算的是在运行过程中额外开辟的空间

例6:计算Func7函数的空间复杂度

//数组打印函数
void Print(int* arr, int n)
{
  for (int i = 0;i < n;i++)
  {
    printf("%d ", arr[i]);
  }

  printf("\n");
}

  在这个函数中,形参arr,n,以及局部变量 i 在运行时他们的空间就已经确定,所以是不会算到额外开辟空间中的,而这个函数有没有其他额外的变量,所以额外开辟空间为0,根据大O表示法,空间复杂度为:O(1),这里的 1 仍表示额外开辟的空间为常数阶的。 

例7:计算Func6函数的空间复杂度

//计算阶乘的递归函数
int Func6(int n)
{
  if (n == 0)
  {
    return 1;
  }
  
  return Func6(n - 1) * n;
}

这个函数还是上面那个求阶乘的递归函数,该函数的递归次数与额外开辟的空间如下:

Func6函数的空间复杂度
每次递归额外开辟空间大小递归次数总额外开辟空间大小
1nn

由此可以得出递归函数的额外开辟空间的大小的公式:

总的额外开辟空间大小 = 递归次数 * 单次函数额外开辟空间大小

根据大O渐进表示法,所以求阶乘的递归函数的空间复杂度为:O(n)

  其实其内在原理和递归函数求空间复杂度是一样的:每次递归调用函数时,都会为每次函数调用开辟一块空间,这个空间叫做函数栈帧,所以每递归调用一次函数,就相当于额外开辟了一块空间,所以当递归调用n次时,也就额外开辟了n块空间

例8:计算Func8函数的时间复杂度与空间复杂度

//求递归函数的时间复杂度与空间复杂度
void Func8(int n, int count, int stop)
{
  if (count >= stop)
  {
    printf("%d ", count);
    return;
  }

  for (int i = 0;i < n;i++)
  {
    count++;
  }

  Func8(n, count, stop);
}

先看时间复杂度:

count 的值与n和stop同时相关,count++单次递归运行次数和递归次数如表格所示:

Func8函数的执行次数(这里假设n是10,stop是100,刚开始count=0)
语句单次递归执行次数count的值是否进行下一轮递归
count++1010
count++1020
count++1030
........................
count++1090
count++10100
count++10--------

  当count的值达到100时,由于 if 条件在递归之前,所以还是会再调用一次函数,在最后一次函数栈帧里面(也就count自增到 100 的函数栈帧的下一次递归调用函数的函数栈帧),才会进行count值是否等于100,然后停止递归调用,所以Func8函数是会递归调用10次函数的。

  由以上分析不难得出,Func8函数的count++语句单次运行次数是n次,而递归调用的次数是(stop - count) / n,所以总的执行次数就是[(stop - count) / n] * n,即(stop - count),所以时间复杂度为O(stop - count)

  而空间复杂度是取决于递归调用的次数(深度)的,而递归调用的次数为(stop - count) / n,由于递归调用的次数是跟3个变脸相关的,所以是分最好情况和最坏情况的:

 1) 当count == stop时,刚进入函数就会直接return,一次递归也不会进行,所以空间复杂度为O(1)

2) 当n == stop时,只会进行一次递归,所以空间复杂度也为O(1)

3) 当count != stop,n != stop时,空间复杂度就是递归调用的深度,所以空间复杂度为O((stop - count) / n)

  所以这个算法的最好空间复杂度为O(1),最坏时间复杂度为O((stop - count) / n)


4  提升算法能力的两点建议 

1) 画图

  在刚开始学习数据结构的时候,由于各种数据结构比较抽象,不好理解,所以画图来了解算法的执行过程是十分有必要的,可以使得思路更加清晰,更容易理解执行过程。

2) 多实践,多上手写代码

  在学习数据结构与算法的时候,最忌讳的就是只学不实现代码,认识的过程是一个认识,实践,再认识,再实践的过程,只有不断通过写代码理解算法执行过程以及各种数据结构,才能熟练的写出各种算法以及数据结构

  刚开始学习数据结构与算法是比较困难的,但是相信只要跨过这个坎,就会越来越轻松的,加油!


http://www.niftyadmin.cn/n/5821995.html

相关文章

JavaScript 数组及其常用方法

1. JavaScript 数组概述 数组是 JavaScript 中用于存储多个值的数据结构。它可以存储不同类型的元素&#xff0c;并提供强大的方法来操作和管理数据。数组的元素按索引&#xff08;从 0 开始&#xff09;进行访问。 2. 数组的创建方式 1) 使用数组字面量 let fruits [&quo…

linux 内核OOM问题定位-SLAB_DEBUG

1&#xff0c;配置menuconfig Kernel hacking > Memory Debugging 配置 configy [*] SLUB debugging on by default [*] Enable SLUB performance statistics 配置之前 larkubuntu:~/Public/rk356x…

C语言之旅5--分支与循环【2】

本章概述 while循环for循环do-while循环break和continue语句循环嵌套goto语句彩蛋时刻&#xff01;&#xff01;&#xff01; while循环 概述&#xff1a;C语言提供了3种循环语句&#xff0c;while就是其中一种。另外2种分别是for和do-while。接下来介绍while语句。while语句和…

Web基础之什么是HTTP协议

Q&#xff1a;什么是HTTP协议&#xff1f; 概念&#xff1a;Hyper Text Transfer Protocol&#xff0c;超文本传输协议&#xff0c;规定了浏览器和服务器之间数据传输的规则。 特点&#xff1a; 1&#xff0e;基于TCP协议&#xff1a;面向连接&#xff0c;安全 2&#xff0e;基…

ip属地什么条件会改变?多角度深入探讨

IP属地&#xff0c;即IP地址的归属地&#xff0c;是互联网上设备连接时的一个关键信息&#xff0c;它通常反映了设备连接互联网时的地理位置。随着社交软件及各大平台推出IP归属地显示功能&#xff0c;IP属地的变化问题逐渐受到广大用户的关注。那么&#xff0c;IP属地在什么条…

书说 MySQL 的悲观锁和乐观锁

什么是乐观锁&#xff1f;什么是悲观锁&#xff1f; 悲观锁&#xff1a; 悲观锁是一种基于悲观态度的控制机制&#xff08;最坏的程度想&#xff0c;每次并发一定会造成阻塞&#xff09;&#xff0c;用于防止数据冲突。它采取预防性措施&#xff0c;在修改数据之前将其锁定&a…

Python----Python基础(元组 tuple,元组的创建,基本操作:访问,连接,索引,计数,长度,最大值,最小值,求和,判断,排序)

一、元组tuple 列表属于可变序列&#xff0c;可以任意修改列表中的元素。 元组属于不可变序列&#xff0c;不能修改元组中的元素。 因此&#xff0c;元组没有增加元素、修改元素、删除元素相关的方法。 二、元组的创建 2.1、使用()方式创建元组 使用圆括号 () 可以创建一个…

深度学习的加速器:Horovod,让分布式训练更简单高效!

什么是 Horovod&#xff1f; Horovod 是 Uber 开发的一个专注于深度学习分布式训练的开源框架&#xff0c;旨在简化和加速多 GPU、多节点环境下的训练过程。它以轻量级、易用、高性能著称&#xff0c;特别适合需要快速部署分布式训练的场景。Horovod 的名字来源于俄罗斯传统舞…