大道至简-数据结构(时空复杂度)

程序是用来解决实际问题,它由多个步骤或者过程组成,这些步骤或者过程就是解决问题的一个算法。解决某个问题时,我们可以提出多种不同的算法,但花费的时间和内存就大不相同了,在实际应用中,我们当然希望找到一个最优的算法,能尽可能的节约时间和空间成本,那么我们如何评价一个算法的性能呢?这里就要提到今天的主角——时空复杂度了。
在这里插入图片描述
我们知道,一个由某种编程语言实现的算法运行在某台机器上的性能跟很多因素都会相关,比如:

  • 机器的配置
  • 程序使用的编程语言
  • 编译器或者解释器的优化能力

并且,因为有些算法是无法事后进行性能评估的,比如导弹发射系统,紧急报警系统等等,我们也希望能事前对算法的性能做个合理的评估,这就是时空复杂度分析法的提出的由来。

首先我们需要了解以下几个概念:

  • 语句的频度:一个语句的频度是指该语句在算法中被重复执行的次数,算法中所有语句的频度之和被记作 T ( n ) T(n)
  • 时间复杂度 T ( n ) T(n) :使用基本语句的频度 f ( n ) f(n) 的数量级作为度量,通常记为 T ( n ) = O ( f ( n ) ) T(n) = O(f(n))
  • 空间复杂度 S ( n ) S(n) :定义为该算法所耗费的存储空间,记为 S ( n ) = O ( g ( n ) ) S(n) = O(g(n))
  • 操作数:一条基本语句所要执行的次数,以f(1)的频度为基准。

时间复杂度:

实际上,我们讨论的时空复杂度都是基于输入规模 n n 趋于无穷的情况,此时,不同时间复杂度之间的比较相当于是数列极限的求解。

那么我们如何分析一个算法的时间复杂度呢?因为时间复杂度只关心数量级,那么,我们只要寻找算法中基本语句的频度即可估计出算法的时间复杂度,以下面几个算法为例:

    printf("hello, world")
    // .... 此处省略30000条
    printf("hello, world")

上面的一共有30002条printf语句,此时这个算法跟输入规模无关,基本语句为printf语句,我们只需要把这些语句的操作数加起来,即可得到时间复杂度:

T ( n ) = f ( 1 ) + . . . + f ( 1 ) = O ( f ( 30002 ) ) = O ( 1 ) T(n) = f(1) + ...+f(1) = O(f(30002)) = O(1)

    for(int i = 0; i < n; i++)
    	printf("hello, world")

上面的算法基本语句为printf语句,而执行的次数和输入规模n相关,所以我们只需要让输入规模与基本语句的操作数相乘即可得到时间复杂度:

T ( n ) = n f ( 1 ) = O ( f ( n ) ) = O ( n ) T(n) = n * f(1) = O(f(n)) = O(n)

    for(int i = 0; i < n; i++)
    	for(int j = 0; j < n; i++)
    			printf("hello, world")

上面的算法基本语句为printf语句,而执行的次数和输入规模n相关,但此时是嵌套循环,遇到嵌套循环,我们只需要寻找最内层的循环的基本语句和执行次数,将其相乘,即可得到时间复杂度:

扫描二维码关注公众号,回复: 10720612 查看本文章

T ( n ) = n n f ( 1 ) = O ( f ( n 2 ) ) = O ( n 2 ) T(n) = n * n * f(1) = O(f(n^2)) = O(n^2)

    int i = 1;
    while (i <= n) {
    	i *= 2;
      printf("hello, world");
    }

遇到指数型的算法,我们一般设其执行次数为k,然后根据循环条件来确定k的值,最后将其乘以循环内的基本语句即可得到时间复杂度,其中,对数的底数我们一般忽略。

由循环结束条件可知,当 2 k > n 2^k > n 时,循环结束,所以我们可以得到 k = l o g 2 ( n ) + 1 k=log_2(n) + 1 ,则:

T ( n ) = f ( l o g 2 ( n ) + 1 ) f ( 1 ) = O ( l o g ( n ) ) T(n) = f(log_2(n) + 1) *f(1) = O(log(n))

    int sum(int n) {
    	if (n == 1) return 1;
    	return n + sum(n - 1);
    }

遇到递归型的算法,我们一般使用2种方法,一种的递归树的分析方法,一种的主方法分析法,主方法分析法以后再进行补充,这里以第一种为例,我们可以将上述代码画出一颗递归树,然后计算树的节点个数,然后乘以每次递归中的基本语句即可得到时间复杂度:

T ( n ) = n f ( 1 ) = O ( f ( n ) ) = O ( n ) T(n) = n * f(1) = O(f(n)) = O(n)

下面我们引入4个新的概念:

  • 最坏时间复杂度:指在最坏的情况下,算法的时间复杂度
  • 平均时间复杂度:值所有可能输入的实例在等概率出现的情况下,算法的期望运行时间复杂度。
  • 最好时间复杂度:指在最好的情况下,算法的时间复杂度。
  • 均摊时间复杂度:针对一个操作序列,取平均值后的算法运行时间复杂度。

本篇只讲解前三种,至于最后的均摊时间复杂度,这里后面的动态扩容算法再讲。

    int arr[n] = { ....};  // 包含n个正整数的数组
    int target = k; // 要查找的值
    
    for(int i = 0; i < n; i++)
    	if(arr[i] == target)
    		break

上述这个算法的查找次数,会因为要查找的值和查找数组arr的值不同而不同,假设:

  • 要查找的值在第一位:则查找次数为 f ( 1 ) f(1)
  • 要查找的值在最后一位:则查找次数为 f ( n ) f(n)
  • 要查找的值在中间:我们使用数学期望来对其进行估算:

f ( n ) = ( 1 + 2 + 3 + . . . ) 1 n = n + 1 2 f(n) = (1 + 2 + 3 + ...) * \frac{1}{n} = \frac{n+1}{2}

上式的意思是,要查找的值在每一个位置都是等可能的,概率为 1 n \frac{1}{n} ,而在第 k k 位的查找次数为 k k 次,将其加起来乘以对应概率就能得到数学期望。

这里的三种情况,分别对应着最好时间复杂度,最坏时间复杂度和平均时间复杂度。

那么我们怎么比较不同算法时间复杂度的优劣呢,我们需要记住以下几个法则:

(1) 加法规则:以执行次数最大的项为主,其他项可省去:

T ( n ) = T 1 ( n ) + T 2 ( n ) = O ( f ( n ) ) + O ( g ( n ) ) = O ( m a x ( f ( n ) , g ( n ) ) ) T(n) = T_1(n) + T_2(n) = O(f(n))+O(g(n))=O(max(f(n),g(n)))

(2) 乘法规则:如果是嵌套执行的项,则要将两项相乘:

T ( n ) = T 1 ( n ) T 2 ( n ) = O ( f ( n ) ) O ( g ( n ) ) = O ( f ( n ) g ( n ) ) T(n) = T_1(n)*T_2(n) = O(f(n)) *O(g(n))=O(f(n)*g(n))

(3) 复杂度的比较:(注:下面的 k > 1 k>1

O ( 1 ) < O ( l o g n ) < O ( n ) < O ( n l o g n ) < O ( n k ) < O ( 2 n ) < O ( n ! ) < O ( n n ) O(1)<O(logn)<O(n)<O(nlogn)<O(n^k)<O(2^n)<O(n!)<O(n^n)

至于证明,我们可以使用高阶无穷小的证明方法,使用数列的极限,这里以一个证明为例:

lim n > l o g 2 n n = lim n > 1 n l o g 2 = 0 \lim_{n->\infty }\frac{log_2n}{n} = \lim_{n->\infty } \frac{1}{nlog_2} = 0

空间复杂度:

大部分内容与时间复杂度类似,它度量的是除了在存储程序时所需要的存储空间外(如存放程序本身的指令、常数、变量和输入数据等),还需额外占用的辅助空间。

    int i = 1;
    while (i <= n) {
    	i *= 2;
      printf("hello, world");
    }

我们只需要找到除输入变量意外程序所使用的额外空间即可,如上述代码,程序需要存储变量i和变量n,所以算法的空间复杂度为:

S ( n ) = g ( 1 ) + g ( 1 ) = O ( g ( 2 ) ) = O ( 1 ) S(n) = g(1) + g(1) = O(g(2)) = O(1)

    for(int i = 0; i < n; i++)  {
        int arr[n];
    		arr[i] = 1;
    }

上述代码每次循环都会定义一个arr数组,占用n个int类型的存储空间,所以这里的空间复杂度为:

S ( n ) = g ( n ) = O ( g ( n ) ) = O ( n ) S(n) = g(n) = O(g(n)) = O(n)

注意,这里不能将其相乘,因为每次占用完毕后,程序就会释放那段内存,而不是一直占用。

    int sum(int n) {
    	if (n == 1) return 1;
    	return n + sum(n - 1);
    }

除了变量占用内存之外,我们还需要注意的是,函数在递归的过程中,也需要辅助空间,一般我们需要找到递归执行的次数,然后将其中每次执行需要占用的辅助空间与其相乘:

S ( n ) = n g ( 1 ) = O ( g ( n ) ) = O ( n ) S(n) = n * g(1) = O(g(n)) = O(n)

注意,因为递归是自顶向下,然后再回溯,所以每一层递归系统都要为其保存执行现场,因为是一直占用,所以需要进行相乘。

总结:

  1. 时间复杂度和空间复杂度是一种有效度量算法性能的事前分析方法
  2. 分析时间复杂度要抓住基本语句和执行频度
  3. 分析空间复杂度要抓住辅助空间,尤其要注意递归
  4. 要学会时空复杂度之间的运算规则和比较方法
发布了129 篇原创文章 · 获赞 20 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/u011544909/article/details/105467612