数据结构与算法之时间复杂度与空间复杂度

642 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第10天,点击查看活动详情


⭐️前面的话⭐️

本篇文章带大家认识数据结构与算法基础,时间复杂度与空间复杂度。算法效率分析分为两种:第一种是时间效率,第二种是空间效率。时间效率被称为时间复杂度,而空间效率被称作空间复杂度。 时间复杂度主要衡量的是一个算法的运行速度,而空间复杂度主要衡量个算法所需要的额外空间,在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。描述代码:Java。

📒博客主页:未见花闻的博客主页
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
📌本文由未见花闻原创!
📆掘金首发时间:🌴2022年4月11日🌴
✉️坚持和努力一定能换来诗与远方!
💭参考书籍:📚《趣学数据结构》,📚《数据结构》
💬参考在线编程网站:🌐牛客网🌐力扣
博主的码云gitee,平常博主写的程序代码都在里面。
博主的github,平常博主写的程序代码都在里面。
🍭作者水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!


🍎1.问题导入

🍏1.1引入复杂度

🍊求下面序列之和∶ 1,1,1,1,...,(1)n-1,1,-1,1,...,(-1)^n

第一种做法:

    public static int sumAdd(int n) {
        int sum = 0;
        for (int i = 1; i <= n; i++) {
            sum += (int)Math.pow(-1, i);
        }
        return sum;
    }

该程序执行了n次。 第二种做法: 1

    public static int sumAddPlus(int n) {
        int sum = 0;

        if (n % 2 == 1) {
            sum = -1;
        }

        return sum;
    }

该程序执行了1次。 可见不同的算法,可能会有着不同的执行次数,执行的次数越少,则程序所费时间越少,说明算法越优。 所以我们使用程序的执行次数作为判断一个程序运行速度快慢的标准,这个执行次数就称为一种算法的时间复杂度。

🍏1.2棋盘麦子问题

我来带大家认识一种恐怖的爆炸增量,相信大家都听说过棋盘麦子的故事:

有一个古老的传说,有一位国王的女儿不幸落水,水中有很多鳄鱼,国王情急之下下令∶"谁能把公主救上来,就把女儿嫁给他。"很多人纷纷退让,一个勇敢的小伙子挺身而出,冒着生命危险把公主救了上来,国王一看是个穷小子,想要反悔,说∶"除了女儿,你要什么都可以。"小伙子说∶"好吧,我只要一棋盘的麦子。您在第1个格子里放1粒麦子,在第2 个格子里放2粒,在第3个格子里放4粒,在第4个格子里放8粒,以此类推,每一格子里的麦子粒数都是前一格的两倍。把这64个格子都放好了就行,我就要这么多。"国王听后哈哈大笑,觉得小伙子的要求很容易满足,满口答应。结果发现,把全国的麦子都拿来,也填不完这64格……国王无奈,只好把女儿嫁给了这个小伙子。

棋盘上的64个格子究竟要放多少粒麦子?把每一个放的麦子数加起来,总和为S,则∶

S=1+21+22+23+...+263S=1+2^1+2^2+2^3+...+2^{63}

等式两边同乘以2,得:

2S=21+22+23+24+...+2642S=2^1+2^2+2^3+2^4+...+2^{64}

两式做差,得:

S=2641=18446744073709551615S=2^{64}-1 = 18446744073709 551615

据专家统计,每个麦粒的平均重量约41.9毫克,那么这些麦粒的总重量是∶

18446744073709551615×41.9=772918576688430212668.5(毫克)7729(亿吨)18446744073709551615×41.9=772918576688430212668.5(毫克)≈7729(亿吨)

全世界人口按60亿计算,每人可以分得128吨!

我们称这样的函数为爆炸增量函数,想一想,如果算法时间复杂度是O(2n)O(2^n)会怎样?随着n的增长,这个算法会不会"爆掉"?经常见到有些算法调试没问题,运行一段也没问题,但关键的时候宕机(shutdown)。例如,在线考试系统,50 个人考试没问题,100 人考试也没问题,如果全校1万人考试就可能出现宕机。

注意∶宕机就是死机,指电脑不能正常工作了,包括一切原因导致的死机。计算机主机出现意外故障而死机,一些服务器(如数据库)死锁,服务器的某些服务停止运行都可以称为宕机。

7

常见的算法时间复杂度有以下几类:

  1. 常数阶,如O(1)O(1)
  2. 多项式阶,如O(n2),O(n3),O(n4)O(n^2),O(n^3),O(n^4)等。
  3. 指数阶,如棋盘麦子,递归实现斐波拉契数列O(2n)O(2^n)
  4. 对数阶,如二分查找O(log2n)O(log_2n)

根据对应函数的趋势图: O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n)

一般我们设计算法时,时间复杂度最好小于O(n2)O(n^2)

🍎2.时间复杂度

🍏2.1概念

时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。

🍏2.2时间复杂度的计算

// 请计算一下func1基本操作执行了多少次?
void func1(int N){
   int count = 0;
   for (int i = 0; i < N ; i++) {
       for (int j = 0; j < N ; j++) {
           count++;
       }
   }
   for (int k = 0; k < 2 * N ; k++) {
       count++;
   }
   int M = 10;
   while ((M--) > 0) {
       count++;
   }
   System.out.println(count);
}

对于这个程序,首先两层嵌套的for循环每层都执行了n次,所以它执行的次数为n*n ,即n^2^。然后执行单层的for循环,执行次数为2n,最后执行了单层的while循环,执行次数为m = 10。所以该程序一共执行的次数为T(n)T(n)

T(n)=n2+2n+10T(n) = n^2 +2n + 10

n=10,T(n)=130n = 10, T(n) = 130

n=100,T(n)=10210n = 100, T(n) = 10210

n=1000,T(n)=1002010n = 1000, T(n) = 1002010

...

nn趋近于无穷大时,我们发现除最高阶外其他的低阶项或常数项可以忽略。 我们实际要计算时间复杂度时,只计算它的大概的执行次数,所以对于复杂度我们取对程序执行次数起主要因素的一项,也就是最高阶项,并且最高阶的系数一律置为1,因为当nn趋近于无穷大时多乘一个系数少乘一个系数都对复杂度没有很大的影响。这种方法也称“大O的渐进表示法”。

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

推导大O阶方法:

  1. 用常数1取代运行时间中的所有加法常数。
  2. 在修改后的运行次数函数中,只保留最高阶项。
  3. 如果最高阶项存在且不是1,则去除与这个最高阶项相乘的常数,得到的结果就是大O阶。

另外,算法的时间复杂度存在最好,平均,最差情况。我们所计算的时间复杂度一般为最坏情况下的复杂度,因为计算最好情况与平均情况的复杂度意义不大,最坏情况下的时间复杂度才能体现一个程序的性能好坏。

最坏情况:任意输入规模的最大运行次数(上界)

平均情况:任意输入规模的期望运行次数

最好情况:任意输入规模的最小运行次数(下界)

所以该程序的时间复杂度为O(n2)O(n^2)

下面我们就开始来小试牛刀一下:

🍊题1

// 计算func2的时间复杂度?
void func2(int N) {
int count = 0;
for (int k = 0; k < 2 * N ; k++) {
   count++; 
}
int M = 10;
while ((M--) > 0) {
   count++; 
}
System.out.println(count);
}

该程序执行次数为T(n)T(n)

T(n)=2n+10T(n) = 2n + 10

时间复杂度为O(n)O(n)

🍊题2

// 计算func3的时间复杂度?
void func3(int N, int M) {
int count = 0;
for (int k = 0; k < M; k++) {
   count++; 
}
for (int k = 0; k < N ; k++) {
   count++; 
}
System.out.println(count);
}

该程序执行次数为T(n,m)T(n,m)

T(n,m)=m+nT(n,m) = m + n

时间复杂度为O(m+n)O(m + n)

🍊题3

// 计算func4的时间复杂度?
void func4(int N) {
int count = 0;
for (int k = 0; k < 100; k++) {
   count++; 
}
System.out.println(count);
}

该程序执行次数为T(n)T(n)

T(n)=100T(n) = 100

T(n)T(n)为一个常数,所以时间复杂度为O(1)O(1)

🍊题4

// 计算bubbleSort的时间复杂度?
void bubbleSort(int[] array) {
   for (int end = array.length; end > 0; end--) {
       boolean sorted = true;
       for (int i = 1; i < end; i++) {
           if (array[i - 1] > array[i]) {
               Swap(array, i - 1, i);
               sorted = false;
           }
       }
       if (sorted == true) {
           break;
       }
   }
}

该程序最坏情况下执行次数为T(n)T(n)

T(n)=n1+n2+...+1+0=n(n1)/2T(n) = n - 1 + n - 2 + ... + 1 +0 =n*(n-1)/2

时间复杂度为O(n2)O(n^2)

🍊题5

// 计算binarySearch的时间复杂度?
int binarySearch(int[] array, int value) {
   int begin = 0;
   int end = array.length - 1;
   while (begin <= end) {
       int mid = begin + ((end-begin) / 2);
       if (array[mid] < value)
           begin = mid + 1;
       else if (array[mid] > value)
           end = mid - 1;
       else
           return mid;
   }
   return -1; 
}

这是一个二分查找的程序,每循环一次,排除的元素就少一半,我们设该程序的执行次数为T(n)T(n),元素个数为nn,当查找剩余元素个数为1个时,程序还需要查找一次,所以当执行T(n)1T(n)-1次时,剩余的元素个数为1,则有下面等式:

n/2(T(n)1)=1n/2^{(T(n)-1)} = 1

即:2(T(n)1)=n 2^{(T(n)-1)} = n

该程序执行次数为T(n)T(n)

T(n)=log2n+1T(n) = log_2 n + 1

时间复杂度为O(log2n) 或 O(logn)O(log_2n)\ 或\ O(logn)

🍊题6

// 计算阶乘递归factorial的时间复杂度?
long factorial(int N) {
    return N < 2 ? N : factorial(N-1) * N; 
}

该程序需要递归的次数为nn次,所以它的时间复杂度为O(n)O(n)

🍊题7

// 计算斐波那契递归fibonacci的时间复杂度?
int fibonacci(int N) {
    return N < 2 ? N : fibonacci(N-1)+fibonacci(N-2);
}

2 不考虑右边最后缺失的几次递归,大概的递归次数为T(n)T(n)

T(n)=1+21+22+...+2(n1)T(n)=1 + 2^1 + 2^2 + ...+ 2^{(n-1)}

由等比数列求和公式:

T(n)=2n1T(n) = 2^n - 1

所以斐波拉契数列递归实现的时间复杂度为O(2n)O(2^n),在前面的麦子棋盘已经可知该时间复杂度的恐怖性。

🍎3.空间复杂度

🍏2.1概念

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度 。空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。

🍏2.2空间复杂度的计算

// 计算bubbleSort的空间复杂度?
void bubbleSort(int[] array) {
	for (int end = array.length; end > 0; end--) {
     	boolean sorted = true;
    	 for (int i = 1; i < end; i++) {
        	 if (array[i - 1] > array[i]) {
         	    Swap(array, i - 1, i);
        	    sorted = false;
        	 }
    	 }
    	 if (sorted == true) {
    	     break;
    	 }
   }
}

该程序只开辟了常数级的内存空间,空间复杂度为O(1)O(1)

// 计算fibonacci的空间复杂度?
int[] fibonacci(int n) {
	long[] fibArray = new long[n + 1];
	fibArray[0] = 0;
	fibArray[1] = 1;
	for (int i = 2; i <= n ; i++) {
  		fibArray[i] = fibArray[i - 1] + fibArray [i - 2];
 	}
	return fibArray; 
}

该程序申请了长度为n+1n+1的长整型数组内存空间,空间复杂度为O(n)O(n)

// 计算阶乘递归Factorial的空间复杂度?
long factorial(int N) {
 	return N < 2 ? N : factorial(N-1)*N; 
}

递归求阶乘一共递归了nn次,每次开辟的内存是常数级的,使用空间复杂度为O(n)O(n)

留给读者:递归实现斐波拉契数列空间复杂度为多少?

答案是O(2n)O(2^n),知道为什么吗?思考一下吧!