创作不易,感谢三连!
一、算法
1.1 什么是算法
算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。
1.2 算法的效率
算法在编写成可执行程序的时候,运行的时候需要耗费时间资源和空间资源。因此衡量一个算法的效率,就是从时间和空间两个维度来衡量的,我们把他细分出了两个概念——时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。也就是说,现如今的我们判断算法的好坏重点是判断他的时间复杂度,在条件允许的情况下,我们也会非常乐意用空间去换时间。
二、时间复杂度
2.1 时间复杂度的概念
在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
// 请计算一下Func1中++count语句总共执行了多少次? 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--) { ++count; } printf("%d\n", count); }
Func1时间复杂度:F(N)=N^2+2*N+10
N = 10时,F(N) = 130
N = 100时,F(N) = 10210
N = 1000时,F(N) = 1002010
当N取越大时,2*N以及10对F(N)的影响越来越小,而影响最大的是N^2,所以引入了大O的渐进表示法,即计算一个大概的次数就行。
2.2 大O的渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。(函数中只有常数)
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
使用大O的渐进表示法以后
Func1的时间复杂度为:O(N)
N = 10时,F(N) = 100
N = 100时,F(N) = 10000
N = 1000时,F(N) = 1000000
大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
2.3 为什么要考虑最坏情况(卡瑞尔公式)
我是这样理解的:时间复杂度也是人为设计的,参考了心理学上的卡瑞尔公式,即"接收最坏的,往往才能有最好的"
卡瑞尔公式:强迫自己接受最坏的情况,首先在精神上接受它,然后集中精力从容解决问题,从根本上抹除忧虑,甚至有时候能给你带来惊喜。
最坏情况下的时间复杂度是算法在任何输入实例上运行时间的界限,这就保证了算法的运行时间不会比最坏情况更长 。
三、空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
四、常见的复杂度对比
五、时间复杂度和空间复杂度例题
特点:时间一去不复返,但是空间可以重复利用!!
// 计算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; } printf("%d\n", count); }
O(N)
// 计算Func4的时间复杂度? void Func4(int N) { int count = 0; for (int k = 0; k < 100; ++ k) { ++count; } printf("%d\n", count); }
O(1)
// 计算阶乘递归Fac的时间复杂度? long long Fac(size_t N) { if(0 == N) return 1; return Fac(N-1)*N; }
每次调用函数都是O(1)的复杂度,调用N次就是O(N)的复杂度
// 计算阶乘递归Fac的时间复杂度? long long Fac(size_t N) { if(0 == N) return 1; for(int i=0;i<N;++i) { …… } return Fac(N-1)*N; }
递归函数,第一次执行了N次循环,第二次执行N-1次循环,以此类推,最后执行N次时结束,所以调用总次数为等差数列,求和N(N+1)/2,时间复杂度是O(N^2)
// 计算斐波那契递归Fib的时间复杂度和空间复杂度 long long Fib(size_t N) { if(N < 3) return 1; return Fib(N-1) + Fib(N-2); }
最左侧会逐步减少到Fib(1),有N层,但是右侧未必能走到N层,所以呈现的三角形并不是等腰三角形。但是不影响大O阶表示时间复杂度O(N^2)
时间一去不复返,但是空间是可以重复利用的,新销毁的函数栈帧释放后可以马上被新的函数栈帧替代,重复利用的空间,所以空间复杂度是O(N)
// 计算BubbleSort的时间复杂度和空间复杂度 void BubbleSort(int* a, int n) { assert(a); for (size_t end = n; end > 0; --end) { int exchange = 0; for (size_t i = 1; i < end; ++i) { if (a[i-1] > a[i]) { Swap(&a[i-1], &a[i]); exchange = 1; } } if (exchange == 0) break; } }
嵌套for循环,所以时间复杂度是O(N^2),虽然每次循环都有存在创建i和end变量,但其实使用的都是一块空间,空间一直在被重复利用,所以空间复杂度O(1)
分析以下函数的空间复杂度( ) int** fun(int n) { int ** s = (int **)malloc(n * sizeof(int *)); while(n--) s[n] = (int *)malloc(n * sizeof(int)); return s; }
此处开辟的是一个二维数组,数组有n行,每行分别有1,2,3,...n列,所以是n(n + 1)/2个元素空间,空间复杂度为O(n^2)
六、二分查找法
二分查找法的使用前提:
1、数据存储在数组中
2、数组中的元素必须有序
一次折半的过程:
1、确定被查找的范围
2、确定被查找范围的左右下标
3、根据左右下标确定中间元素的下标
4、找到中间元素和要找的元素比较
情况1:找到了,就结束
情况2:找不到,根据大小关系,确定新的查找范围(折半)
5、如果不断折半到只剩最后一个元素都找不到,说明该元素在这个数组中不存在
6.1 时间复杂度
// 计算BinarySearch的时间复杂度? int BinarySearch(int* a, int n, int x) { assert(a); int begin = 0; int end = n-1; // [begin, end]:begin和end是左闭右闭区间,因此有=号 while (begin <= end) { int mid = begin + ((end-begin)>>1); if (a[mid] < x) begin = mid+1; else if (a[mid] > x) end = mid-1; else return mid; } return -1; }
如上图,空间复杂度是logN
6.2 效率以及实用性
7、内存、外存、CPU、缓存的一些相关知识
7.1 内存和外存的区别
内存:快、小、8G-16G左右、带电存储
外存:慢、大、500G左右、不带电存储
cpu只能在内存访问,要想访问外存就得先把数据拿到内存中去,运行速度会比较慢,所以我们平时处理数据都是在内存中处理的,处理之后要存储时才会拿到外存中保存起来,这其实和文件操作很类似,文件也是属于外存,可以永久化地保存数据。
举个例子:我们打开word写论文,在word还没保存的时候,该数据是存储在内存的缓存中的,如果这个时候突然断电,那么数据在缓存中没有及时保存到外存里,就会造成数据丢失,而如果我们保存在外存里,即使断电也不会出现数据丢失。
7.2 数据结构和数据库
我们学习数据结构的本质意义,是帮助我们在内存中管理数据,而因为不同的数据结构有不同的特点,对应着不同的需求,所以没有一种数据结构可以完美的解决所有的问题,因此需要学习大量的数据结构类型,根据场景和需要去使用
而我们在外存中管理数据就是通过数据库、文件。
7.3 CPU、寄存器、三级缓存
一般我们CPU在访问内存数据的时候,需要优先将数据放在寄存器或者三级缓存中。
寄存器是最快的,但是一般只有4-8字节的大小,对于大一点的数据,一般都是加载到缓存中再由cpu进行读取。
缓存命中率:在说明这两个问题之前。我们需要要解一个术语 Cache Line。缓存基本上来说就是把后面的数据加载到离自己近的地方,对于CPU来说,它是不会一个字节一个字节的加载的,因为这非常没有效率,一般来说都是要一块一块的加载(有利于提高缓存命中率)的,对于这样的一块一块的数据单位,术语叫“Cache Line”,一般来说,一个主流的CPU的Cache Line 是 64 Bytes(也有的CPU用32Bytes和128Bytes),64Bytes也就是16个32位的整型,这就是CPU从内存中捞数据上来的最小数据单位。
所以cpu在读取数据的时候,如果在缓存中找到该数据,就可以直接处理,这种情况就是缓存命中率高。而如果在缓存中找不到该数据,那么就需要先从内存中加载到缓存里再读取数据,这种情况就是缓存命中率低。
对于数组而言,由于其连续存放的特点,CPU在访问第一个数据的时候,会顺便把后面的数据加载进缓存,而CPU访问第二个数据的时恰好第二个数据就在缓存,甚至可能第三个、第四个数据都在缓存(取决于cpu的处理数据容量),所以数组(顺序表)的缓存命中率高!而对于链表来说,各个结点直接在物理结构上不存在连续,所以即使cpu加载了后续的空间,大概率也是无用的,所以链表的缓存命中率低。并且无用的数据还挤占了原先缓存区的位置,容易造成缓存污染。
与程序员相关的CPU缓存知识 | 酷 壳 - CoolShell
八、顺序表和链表的再总结
顺序表
优点:1、下标随机访问(排序、二分查找)
2、cpu高速缓存命中率高
缺点:1、指定位置插入和删除元素效率低下
2、扩容存在效率损失,还可能存在一定的空间浪费
应用场景:适用于高效存储以及频繁访问的场景
链表
优点:1、任意位置插入和删除效率都高
2、按需申请和释放,不存在空间的浪费
缺点:1、不支持下标的随机访问
2、cpu告诉缓存命中率低
应用场景:适用于频繁任意位置插入和删除的场景