算法复杂度——算法与数据结构入门笔记(二)

简介: 算法复杂度——算法与数据结构入门笔记(二)


本文是算法与数据结构的学习笔记第二篇,将持续更新,欢迎小伙伴们阅读学习 。有不懂的或错误的地方,欢迎交流


什么是算法复杂度


算法复杂度旨在计算在输入数据量 N NN 的情况下,算法的「时间使用」和「空间使用」情况;体现算法运行使用的时间和空间随「数据大小 N NN」而增大的速度。

算法复杂度主要可从时间 、空间 两个角度评价:

  • 时间: 假设各操作的运行时间为固定常数,统计算法运行的「计算操作的数量」 ,以代表算法运行所需时间;
  • 空间: 统计在最差情况下,算法运行所需使用的「最大空间」;

「输入数据大小」 N NN 指算法处理的输入数据量;根据不同算法,具有不同定义,例如:

  • 排序算法N NN 代表需要排序的元素数量;
  • 搜索算法N NN 代表搜索范围的元素总数,例如数组大小、矩阵大小、二叉树节点数、图节点和边数等;

接下来,将分别从概念定义、符号表示、分析法则、常见种类、示例解析、时空权衡等角度入手,介绍「时间复杂度」和「空间复杂度」。


时间复杂度

概念定义


根据定义,时间复杂度指输入数据大小为 N NN 时,算法运行所需花费的时间。需要注意:

  • 统计的是算法的「计算操作数量」,而不是「运行的绝对时间」。计算操作数量和运行绝对时间呈正相关关系,但不相等。算法运行时间受到「编程语言 、计算机处理器速度、运行环境」等多种因素影响。例如,同样的算法使用 Python 或 C++ 实现、使用 CPU 或 GPU 、使用本地 IDE 或在线平台,运行时间都不同。
  • 体现的是计算操作数随数据大小 N NN 变化时的变化情况。假设算法运行总共需要「1 次操作」、「100 次操作」,此两情况的时间复杂度都为常数级 O ( 1 ) O(1)O(1) ;需要「N 次操作」、「100N 次操作」的时间复杂度都为线性级O ( N ) O(N)O(N)


符号表示


根据输入数据的特点,时间复杂度具有「最差」、「平均」、「最佳」三种情况,分别使用 O OO, Θ \ThetaΘ, Ω \OmegaΩ三种符号表示。以下借助一个查找算法的示例题目帮助理解。


题目: 输入长度为 nums ,判断此数组中是否有数字 7,若有则返回 true,否则返回 false。

解题思路: 线性查找,即遍历整个数组,遇到 7,则返回 true

C代码

#include <stdbool.h>
bool find_seven(int* nums, int length) {
   for (int i = 0; i < length; i++) {
      if (nums[i] == 7) {
          return true;
       }
   }
   return false;
}
  • 最佳情况Ω ( 1 ) \Omega(1)Ω(1): nums = [7, a, b, c, …] ,即当数组首个数字为 7 时,无论 nums 有多少元素,线性查找的循环次数都为 1 次;
  • 最差情况O ( N ) O(N)O(N) : nums = [a, b, c, …] 且 nums 中所有数字都不为 7 ,此时线性查找会遍历整个数组,循环 N NN 次;
  • 平均情况Θ \ThetaΘ : 需要考虑输入数据的分布情况,计算所有数据情况下的平均时间复杂度;例如本题目,需要考虑数组长度、数组元素的取值范围等;

大 O是最常使用的时间复杂度评价符号,也称渐进上界,表明了 N 逐步增大时间资源开销 T ( N ) 的增长趋势。实际上,分析的结果为程序在一定时间范围内能够终止运行停供了保障 。程序可能提前结束,但绝不可能拖后


时间复杂度分析法则


以下借助一段程序帮助理解时间复杂度分析中,这里是计算 ∑ i = 1 N i 3 \sum\limits_{i =的一个简单的程序片段

int sum(int N){
  int i, PartialSum;
  PartialSum = 0;
  for( i=1; i<=N; i++){
    PartialSum += i * i * i;
  }
  return PartialSum;
}

对这个程序的分析很简单。声明不计入时间。第 4 行和第 8 行各占一个时间单元。第 6 行每执行一次占用四个时间单元(两次乘法,一次加法和一次赋值),而执行 N NN 次共占用 4 N 4N4N 个时间单元。第 5 行在初始化 i ii,测试 i ≤ N i\le Ni≤N 和对 i ii 的自增运算中隐含着开销。所有这些的总开销是初始化 1 个时间单元,所有的测试 N + 1 N+1N+1 个时间单元,以及所有的自增运算 N NN 个时间单元,共 2 N + 2 2N+22N+2。我们忽略调用函数和返回值的开销,得到总量是6 N + 4 6N+46N+4,因此我们说该程序是 O ( N ) O(N)O(N)。分析如下图所示:


如果我们每次分析一个程序都要演示所有这些工作,那么这项任务很快就会变成不可行的工作。幸运的是,由于我们有了大 O OO 的结果,因此就存在许多可以采取的捷径并且不影响最后的结果。例如,第 6 行(每次执行时)显然是 O ( 1 ) O(1)O(1) 语句,因此精确计算它究竟是二、三还是四个时间单元是愚蠢;这无关紧要。第 4 行与 for 循环相比显然是不重要的,所以在这里花费时间也是不明智的。这使得我们得到若干一般法则。

  • 法则1一for 循环
    一次 for 循环的运行时间至多是该 for 循环内语句(包括测试)的运行时间乘以迭代的次数。
  • 法则2一嵌套的 for 循环
    从里向外分析这些循环。在一组嵌套循环内部的一条语句总的运行时间为该语句的运行时间乘以该组所有的 for 循环的大小的乘积。
    作为一个例子,下列程序片段为 O ( N 2 )
for( i=0; i<N; i++){
  for( j=0; j<N; j++){
    k++;
  }
}

•法则3——顺序语句:

多段语句取最大:总复杂度等于量级最大的那段代码的复杂度。

作为一个例子,下面的程序片段先用去 O ( N ),再花费 O ( N 2 )

,总的开销也是 O ( N 2 )

for( i=0; i<N; i++){
  A[i] = 0:
}
for( i =0; i<N; i++){
  for( j=0; j<N; j++){
    A[i] += A[j] + i + j; 
  }
}

•法则4——IF/ELSE语句

对于程序片段

if(Condition){
  S1;
}
else{
  S2;
}

一个 if/ise 语句的运行时间从不超过判断再加上  S1S2 中运行时间长者的总的运行时间。

常见种类

算法的时间复杂度最后表示出来一定是一个自变量为输入规模 N NN 的一元函数,根据从小到大排列,常见的算法时间复杂度主要有:

1690462152196.png

指数级、阶乘级是灾难性的,其他级是能接受的范围。

示例解析


下面是几个不同复杂度的C代码示例:

常数级 O ( 1 )

运行次数与 N 大小呈常数关系,即不随输入数据大小 N  的变化而变化。

对于以下代码,无论 a取多大,都与输入数据大小 N 无关,因此时间复杂度仍为 O ( 1 )  。

int algorithm(int N) {
    int count = 0;
    int a = 10000;
    for (int i = 0; i < a; i++) {
        count += 1;
    }
    return count;
}

线性级 O ( N ):

运行次数与 N 小呈线性关系,时间复杂度为 O ( N )

以下代码是单层循环,运行了 N 次,所以时间负责度是 O ( N )

int algorithm(int N) {
    int count = 0;
    for (int i = 0; i < N; i++) {
        count += 1;
    }
    return count;
}


平方级 O ( N )

以两层循环为例,若两层循环相互独立,都与 N 呈线性关系,因此总体与 N  呈平方关系,时间复杂度为 O ( N 2 )

多项式级 O ( N c ) :其中,c 为常数,聪明的你一定能猜到 O ( N 3 ) 时间复杂度的程序该怎么写。

指数级 O ( 2 N ) :

生物学科中的 “细胞分裂” 即是指数级增长。初始状态为 1 个细胞,分裂一轮后为 2 个,分裂两轮后为 4 个,……,分裂 N 轮后有 2 N 个细胞。

算法中,指数级常出现于递归,算法代码与原理图如下所示。

int algorithm(int N) {
    if (N <= 0) {
        return 1;
    }
    int count_1 = algorithm(N - 1);
    int count_2 = algorithm(N - 1);
    return count_1 + count_2;
}

对数级 O ( log ⁡ N  ):

对数阶与指数阶相反,指数阶为 “每轮分裂出两倍的情况” ,而对数阶是 “每轮排除一半的情况” 。对数阶常出现于「二分法」、「分治」等算法中,体现着 “一分为二” 或 “一分为多” 的算法思想。

int algorithm(int N) {
  int count = 1;
  while(count<N){
      count *= 2;
}

count初始值为1,不断自乘 2 逼近 N,设循环次数为 m,则输入数据大小 N  与 2 m 呈线性关系,两边同时取 log ⁡ 2对数,则得到循环次数 m 与 log ⁡ 2 N  呈线性关系,即时间复杂度为 O ( log ⁡ N )。

线性对数级 O ( N log ⁡ N )、:

两层循环相互独立,第一层和第二层时间复杂度分别为O ( log ⁡ N ) 和 O ( N ) ,则总体时间复杂度为 O ( N log ⁡ N )

int algorithm(int N) {
    int count = 0;
    int i = N;
    while (i > 1) {
        i = i / 2;
        for (int j = 0; j < N; j++) {
            count += 1;
        }
    }
    return count;
}

线性对数阶常出现于排序算法,例如「快速排序」、「归并排序」、「堆排序」等,其时间复杂度原理如下图所示。

阶乘级 O ( N ! )

阶乘级对应数学上常见的 “全排列” 。即给定 N  个互不重复的元素,求其所有可能的排列方案,则方案数量为:

1690462418732.png

如下图与代码所示,阶乘常使用递归实现,算法原理:第一层分裂出 N个,第二层分裂出 N − 1 个,…… ,直至到第 N层时终止并回溯。

int algorithm(int N) {
    if (N <= 0) {
        return 1;
    }
    int count = 0;
    for (int i = 0; i < N; i++) {
        count += algorithm(N - 1);
    }
    return count;
}


空间复杂度


概念定义


空间复杂度涉及的空间类型有:

  • 输入空间: 存储输入数据所需的空间大小;
  • 暂存空间: 算法运行过程中,存储所有中间变量和对象等数据所需的空间大小;
  • 输出空间: 算法运行返回时,存储输出数据所需的空间大小;

通常情况下,空间复杂度指在输入数据大小为 N NN 时,算法运行所使用的「暂存空间」+「输出空间」的总体大小。

而根据不同来源,算法使用的内存空间分为三类:

指令空间:

编译后,程序指令所使用的内存空间。

数据空间:

算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间。

栈帧空间:

程序调用函数是基于栈实现的,函数在调用期间,占用常量大小的栈帧空间,直至返回后释放。如以下代码所示,在循环中调用函数,每轮调用 test() 返回后,栈帧空间已被释放,因此空间复杂度仍为 O ( 1 ) 。

int test() {
    return 0;
}
void algorithm(int N) {
    for (int i = 0; i < N; i++) {
        test();
    }
}

算法中,栈帧空间的累计常出现于递归调用。如以下代码所示,通过递归调用,会同时存在 N  个未返回的函数algorithm(),此时累计使用 O ( N ) 大小的栈帧空间。

int algorithm(int N) {
    if (N <= 1) {
        return 1;
    }
    return algorithm(N - 1) + 1;
}


符号表示


通常情况下,空间复杂度统计算法在 “最差情况” 下使用的空间大小,以体现算法运行所需预留的空间量,使用符号 O OO 表示。

最差情况有两层含义,分别为「最差输入数据」、算法运行中的「最差运行点」。例如以下代码:

输入整数 N NN ,取值范围 N ≥ 1 N≥1N≥1 ;

  • 最差输入数据: 当 N ≤ 10 N\le10N10 时,数组 nums 的长度恒定为 10 ,空间复杂度为 O ( 10 ) = O ( 1 ) O(10)=O(1)O(10)=O(1) ;当 N > 10 N>10N>10 时,数组 nums 长度为 N NN ,空间复杂度为 O ( N ) O(N)O(N) ;因此,空间复杂度应为最差输入数据情况下的 O ( N ) O(N)O(N)
  • 最差运行点: 在执行 int* nums = (int*)malloc(10 * sizeof(int)); 时,算法仅使用 O ( 1 ) O(1)O(1) 大小的空间;而当执行 nums = (int*)malloc(N * sizeof(int)); 时,算法使用 O ( N ) O(N)O(N) 的空间;因此,空间复杂度应为最差运行点的 O ( N ) O(N)O(N)
void algorithm(int N) {
    int num = 5;              // O(1)
    int* nums = (int*)malloc(10 * sizeof(int));  // O(1)
    if (N > 10) {
        free(nums);  // 释放原来分配的内存
        nums = (int*)malloc(N * sizeof(int));  // O(N)
    }
}


常见种类


根据从小到大排列,常见的算法空间复杂度有:

1690462602955.png


示例解析


对于以下所有示例,设输入数据大小为正整数 N ,节点类 Node 、函数 test() 如以下代码所示。

// 节点结构体
struct Node {
    int val;
    struct Node* next;
};
// 创建节点函数
struct Node* createNode(int val) {
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    newNode->val = val;
    newNode->next = NULL;
    return newNode;
}
// 函数 test()
int test() {
    return 0;
}

常数级 O ( 1 )

普通常量、变量、对象、元素数量与输入数据大小 N 无关的集合,皆使用常数大小的空间。

int N = 0;                        // 变量
int num = 0;
int nums[10000] = {0};            // 数组
struct Node* node = createNode(0); // 动态对象

如以下代码所示,虽然函数 test()调用了 N次,但每轮调用后test() 已返回,无累计栈帧空间使用,因此空间复杂度仍为O ( 1 )。

void algorithm(int N) {
    for (int i = 0; i < N; i++) {
        test();
    }
}

线性级 O ( N ) :

元素数量与 N 呈线性关系的任意类型集合(常见于一维数组、链表、哈希表等),皆使用线性大小的空间。

int* nums_1 = (int*)malloc(N * sizeof(int));
int* nums_2 = (int*)malloc((N / 2) * sizeof(int));
struct Node** nodes = (struct Node**)malloc(N * sizeof(struct Node*));

如下图与代码所示,此递归调用期间,会同时存在 N 个未返回的 algorithm() 函数,因此使用 O ( N )  大小的栈帧空间。

int algorithm(int N) {
    if (N <= 1) return 1;
    return algorithm(N - 1) + 1;
}

平方级 O ( N 2 ):元素数量与 N 呈平方关系的任意类型集合(常见于矩阵),皆使用平方大小的空间。

int** num_matrix = (int**)malloc(N * sizeof(int*));
struct Node*** node_matrix = (struct Node***)malloc(N * sizeof(struct Node**));
// 初始化 num_matrix 二维数组
for (int i = 0; i < N; i++) {
    num_matrix[i] = (int*)malloc(N * sizeof(int));
    for (int j = 0; j < N; j++) {
        num_matrix[i][j] = 0;
    }
}
// 创建节点对象并初始化
for (int i = 0; i < N; i++) {
    node_matrix[i] = (struct Node**)malloc(N * sizeof(struct Node*));
    for (int j = 0; j < N; j++) {
        node_matrix[i][j] = createNode(j);
    }
}

如下图与代码所示,递归调用时同时存在 N 个未返回的 algorithm()函数,使用 O ( N ) 栈帧空间;每层递归函数中声明了数组,平均长度为 N 2 ,使用 O ( N ) 空间;因此总体空间复杂度为 O ( N 2 ) 。

int algorithm(int N) {
    if (N <= 0) return 0;
    int* nums = (int*)malloc(N * sizeof(int));
    return algorithm(N - 1);
}

指数级 O ( 2 N ):指数阶常见于二叉树、多叉树。例如,高度为 N 的「满二叉树」的节点数量为 2 N  ,占用 O ( 2 N ) 大小的空间;同理,高度为 N 的「满 m  叉树」的节点数量为 m  ,占用 O ( m N ) = O ( 2 N ) O(m^N)=O(2^N)O(m N )=O(2 N ) 大小的空间。

1690639217408.png


时空权衡


对于算法的性能,需要从时间和空间的使用情况来综合评价。优良的算法应具备两个特性,即时间和空间复杂度皆较低。而实际上,对于某个算法问题,同时优化时间复杂度和空间复杂度是非常困难的。降低时间复杂度,往往是以提升空间复杂度为代价的,反之亦然。

由于当代计算机的内存充足,通常情况下,算法设计中一般会采取「空间换时间」的做法,即牺牲部分计算机存储空间,来提升算法的运行速度。

以 LeetCode 全站第一题 两数之和 为例,「暴力枚举」和「辅助哈希表」分别为「空间最优」和「时间最优」的两种算法。

  • 方法一:暴力枚举
    时间复杂度 O ( N 2 ) O(N^2)O(N2),空间复杂度 O ( 1 ) O(1)O(1);属于「时间换空间」,虽然仅使用常数大小的额外空间,但运行速度过慢。
  • 方法二:辅助哈希表
    时间复杂度 O ( N ) O(N)O(N),空间复杂度 O ( N ) O(N)O(N);属于「空间换时间」,借助辅助哈希表,通过保存数组元素值与索引的映射来提升算法运行效率,是该题的最佳解法。


小结


以上就是算法复杂度的相关内容

下一篇文章将在详细介绍常用的九大数据结构,持续更新中…


相关文章
|
29天前
|
机器学习/深度学习 算法 数据挖掘
K-means聚类算法是机器学习中常用的一种聚类方法,通过将数据集划分为K个簇来简化数据结构
K-means聚类算法是机器学习中常用的一种聚类方法,通过将数据集划分为K个簇来简化数据结构。本文介绍了K-means算法的基本原理,包括初始化、数据点分配与簇中心更新等步骤,以及如何在Python中实现该算法,最后讨论了其优缺点及应用场景。
96 4
|
2月前
|
存储 人工智能 算法
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
这篇文章详细介绍了Dijkstra和Floyd算法,这两种算法分别用于解决单源和多源最短路径问题,并且提供了Java语言的实现代码。
94 3
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
|
3天前
|
存储 运维 监控
探索局域网电脑监控软件:Python算法与数据结构的巧妙结合
在数字化时代,局域网电脑监控软件成为企业管理和IT运维的重要工具,确保数据安全和网络稳定。本文探讨其背后的关键技术——Python中的算法与数据结构,如字典用于高效存储设备信息,以及数据收集、异常检测和聚合算法提升监控效率。通过Python代码示例,展示了如何实现基本监控功能,帮助读者理解其工作原理并激发技术兴趣。
41 20
|
26天前
|
数据采集 存储 算法
Python 中的数据结构和算法优化策略
Python中的数据结构和算法如何进行优化?
|
1月前
|
算法
数据结构之路由表查找算法(深度优先搜索和宽度优先搜索)
在网络通信中,路由表用于指导数据包的传输路径。本文介绍了两种常用的路由表查找算法——深度优先算法(DFS)和宽度优先算法(BFS)。DFS使用栈实现,适合路径问题;BFS使用队列,保证找到最短路径。两者均能有效查找路由信息,但适用场景不同,需根据具体需求选择。文中还提供了这两种算法的核心代码及测试结果,验证了算法的有效性。
102 23
|
26天前
|
并行计算 算法 测试技术
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面,旨在通过综合策略提升程序性能,满足实际需求。
57 1
|
1月前
|
算法 vr&ar 计算机视觉
数据结构之洪水填充算法(DFS)
洪水填充算法是一种基于深度优先搜索(DFS)的图像处理技术,主要用于区域填充和图像分割。通过递归或栈的方式探索图像中的连通区域并进行颜色替换。本文介绍了算法的基本原理、数据结构设计(如链表和栈)、核心代码实现及应用实例,展示了算法在图像编辑等领域的高效性和灵活性。同时,文中也讨论了算法的优缺点,如实现简单但可能存在堆栈溢出的风险等。
43 0
|
1月前
|
机器学习/深度学习 算法 Python
机器学习入门:理解并实现K-近邻算法
机器学习入门:理解并实现K-近邻算法
36 0
|
2月前
|
存储 算法 Java
Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性
Java Set因其“无重复”特性在集合框架中独树一帜。本文解析了Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性,并提供了最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的hashCode()与equals()方法。
45 4
|
2月前
|
算法 API 计算机视觉
人脸识别笔记(一):通过yuface调包(参数量54K更快更小更准的算法) 来实现人脸识别
本文介绍了YuNet系列人脸检测算法的优化和使用,包括YuNet-s和YuNet-n,以及通过yuface库和onnx在不同场景下实现人脸检测的方法。
81 1