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

目录

[1 数据结构](#1 数据结构)

[2 算法](#2 算法)

[3 复杂度](#3 复杂度)

[1) 时间复杂度](#1) 时间复杂度)

[2) 空间复杂度](#2) 空间复杂度)

[4 提升算法能力的两点建议](#4 提升算法能力的两点建议)

[1) 画图](#1) 画图)

[2) 多实践,多上手写代码](#2) 多实践,多上手写代码)


cpp 复制代码
重点一  数据结构的定义

1 数据结构

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


cpp 复制代码
重点二  算法

2 算法

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

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

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


cpp 复制代码
重点三  复杂度

3 复杂度

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

cpp 复制代码
重点四  时间复杂度

1) 时间复杂度

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

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

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

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

cpp 复制代码
//计算跟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++;
  }
}

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

语句 运行次数
int count = 0 1
第一个count++ n^2
第二个count++ n
[count语句运行次数]

所以这个程序中的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表示法规则:

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

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

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

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

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

cpp 复制代码
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++;
  }

}

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

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

总执行次数:2 * n + 1

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

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

cpp 复制代码
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++;
  }

}

运行次数如表格所示:

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

总执行次数: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函数的时间复杂度

cpp 复制代码
int Func3(int n)
{
  int count = 0;
  for (int i = 0;i < 1000;i++)
  {
    count++;
  }
  
  return count;
}

运行次数:

|---------|------|
| 语句 | 运行次数 |
| count++ | 100 |
[Func3函数语句的运行次数]

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

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

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

cpp 复制代码
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):

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

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

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

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

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

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

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

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

cpp 复制代码
void Func5(int n)
{
  int count = 1;
  while (count < n)
  {
    count *= 2;
  }
  
}

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

|---------------|--------|
| count的值 | 运行次数 |
| 1 | 0 |
| 1*2 | 1 |
| 1*2*2 | 2 |
| 1*2*2*2 | 3 |
| 1*2*2*2*2 | 4 |
| ...... | ...... |
| 2^i | i |
[Func5函数语句的运行次数]

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

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

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

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

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

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

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

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

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

cpp 复制代码
重点五  空间复杂度

2) 空间复杂度

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

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

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

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

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

cpp 复制代码
//数组打印函数
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函数的空间复杂度

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

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

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

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

cpp 复制代码
总的额外开辟空间大小 = 递归次数 * 单次函数额外开辟空间大小

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

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

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

cpp 复制代码
//求递归函数的时间复杂度与空间复杂度
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++单次递归运行次数和递归次数如表格所示:

语句 单次递归执行次数 count的值 是否进行下一轮递归
count++ 10 10
count++ 10 20
count++ 10 30
...... ...... ...... ......
count++ 10 90
count++ 10 100
count++ 10 --------
[Func8函数的执行次数(这里假设n是10,stop是100,刚开始count=0)]

当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) 多实践,多上手写代码

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

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

相关推荐
菜鸟学编程o13 分钟前
数据结构之双向链表
数据结构·链表
余识-20 分钟前
16.C语言预处理指令详解:#define、#include、#ifdef 等高效用法
c语言·数据库
skywalk816322 分钟前
C语言基本知识复习浓缩版:数组
c语言·数据结构·c++·算法
国服卤蛋儿26 分钟前
C语言将点分十进制的IP字符串转成4个整数
c语言·tcp/ip
Ning_.1 小时前
LeetCode 283题:移动零
数据结构·算法·leetcode
Xiao Xiangζั͡ޓއއ1 小时前
于交错的路径间:分支结构与逻辑判断的思维协奏
c语言·开发语言·程序人生·学习方法·改行学it
请叫我大虾1 小时前
数据结构与算--堆实现线段树
java·数据结构·算法
工一木子2 小时前
【数据结构】第1天之Java中的数据结构
java·数据结构
Victoria.a2 小时前
数据在内存的存储
c语言·c++
labuladuo5203 小时前
洛谷 P3435 [POI2006] OKR-Periods of Words(扩展KMP+线段树做法)
数据结构·c++·算法