二.时间和空间复杂度
1.时间复杂度
在计算机科学中,时间复杂性,又称时间复杂度,算法的时间复杂度是一个函数,它定性描述该算法的运行时间。这是一个代表算法输入值的字符串的长度的函数。时间复杂度常用大O符号表述,不包括这个函数的低阶项和首项系数。使用这种方式时,时间复杂度可被称为是渐近的,亦即考察输入值大小趋近无穷时的情况。
通常把算法中的基本操作重复执行的频度称为算法的时间复杂度。算法中的基本操作一般是指算法中最深层循环内的语句(赋值、判断、四则运算等基础操作)。我们可以把时间频度记为T(n),它与算法中语句的执行次数成正比。其中的n被称为问题的规模,大多数情况下为输入的数据量。
对于每一段代码,都可以转化为常数或与n相关的函数表达式,记做f(n) 。如果我们把每一段代码的花费的时间加起来就能够得到一个刻画时间复杂度的表达式,在合并后保留量级最大的部分即可确定时间复杂度,记做O(f(n)) ,其中的O就是代表数量级。
常见的时间复杂度有(由低到高):O(1)、O( log 2 n \log _{2} n log2n)、O(n)、O( n log
2 n n\log _{2} n nlog2n)、O( n 2 n^{2} n2)、O( n 3 n^{3} n3)、O( 2 n
2^{n} 2n)、O(n!)。
四个时间复杂度
同一段代码在不同输入的情况下,可能存在时间复杂度量级不一样的情况,所以有以下四种不同的时间复杂度。
最好情况时间复杂度(best case time complexity);
最坏情况时间复杂度(worst case time complexity);
平均情况时间复杂度(average case time complexity);
均摊时间复杂度(amortized time complexity)。
1、最好、最坏、平均情况时间复杂度
// n表示数组array的长度 int find(int *array, int n, int x) { int i = 0; int pos = -1; for ( ; i < n; ++i) { if (array[i] == x) { pos = i; break; } } return pos; }
这是一个find()函数,这段代码的作用是查找参数x在数组array中的位置,如果没有就返回-1。
最好情况时间复杂度:
在最理想的情况下,代码的时间复杂度。本例中,如果数组中的第一个元素就是要查找的变量,则时间复杂度为O(1)。
最坏情况时间复杂度:
在最糟糕的情况下,代码的时间复杂度。本例中,如果数组中没有变量x,则需要遍历数组中的每一个元素,则时间复杂度为O(n)。
平均情况时间复杂度:
最好、最坏情况时间复杂度表示的都是代码在极端情况下的时间复杂度,发生的概率并不大,所以平均情况时间复杂度用于表示平均情况下的时间复杂度。
本例中,首先,变量x分为在数组中和不在数组中两种情况,假设两种情况的概率相同为;其次,要查找的变量出现在数组0 ~ n-1共n个位置的概率是一样的,都为;最后,根据概率论的知识,变量x出现在0 ~ n-1这n个位置的概率都为,变量不在数组中的概率为。
根据概率论中的加权平均值,也叫期望值的计算放法(每一种情况下的时间复杂度乘以其发生的概率)得出平均时间复杂度的值:
用大O表示法表示,则平均时间复杂度为O(n),所以平均时间复杂度又叫加权平均时间复杂度,或者期望时间复杂度。
一般情况下,我们并不会区分这三种时间复杂度,只使用其中一种即可。当同一代码块在不同情况下的时间复杂度有量级上的差距,才会区分这三种复杂度。
2、均摊时间复杂度
由上述可知,一般情况下,并不区分最好、最坏、平均情况时间复杂度,平均情况时间复杂度也只有在某些特殊的情况下才会使用,而均摊时间复杂度的应用场景比平均复杂度更特殊,更有限。
// array表示一个长度为n的数组 // 代码中的array.length就等于n int *array = new int[n]; int count = 0; void insert(int val) { if (count == array.length) { int sum = 0; for (int i = 0; i < array.length; ++i) { sum = sum + array[i]; } array[0] = sum; count = 1; } array[count] = val; ++count; }
这是一个insert()函数,实现往数组中插入一个数据的功能,如果数组满了的话,即当count == array.length时,遍历数组求和,并把数据放到数组第一个位置,然后再把新的数据插入。
本段代码的时间复杂度分析:
最好情况时间复杂度:数组未满,有空闲的位置时为O(1);
最坏情况时间复杂度:数组已满,需要遍历求和时为O(n);
平均情况时间复杂度:分为数组未满和已满两种情况,未满时的插入有n种情况,每种情况的时间复杂度为O(1),已满时的时间复杂度度为O(n),所以共n+1种可能,这n+1种可能的概率相同都是,所以平均情况时间复杂度为:
2)、均摊时间复杂度
本例中的insert()函数区别于之前的find()函数:find()函数在极端情况下,时间复杂度为O(1),而insert()函数在大多数情况下时间复杂度都为O(1);find()函数时间复杂度的多种情况并没有任何规律。而insert()函数O(n)之后,必有n-1个O(1),循环往复。
针对这种特殊的场景,可以采用一种特殊的时间复杂度分析方法:摊还分析法,得出的是均摊时间复杂度。
分析方法:
因为时间复杂度有规律的在O(n) -> n-1个O(1)之间循环,所以把耗时最多的那次操作(O(n)),均摊到耗时最少的n-1次操作(O(1)),这样,每一组操作的时间复杂度都是O(1),即均摊时间复杂度为O(1)。
应用场景:
均摊时间复杂度就是一种特殊的平均情况时间复杂度,没有必要过度区分。当大部分情况下的时间复杂度较低,而只有极少数情况下的时间复杂度较高,且这些情况的出现有固定的时序性规律时,使用均摊时间复杂度。这时,尝试将较高复杂度操作的耗时均摊到较低复杂度的操作上,这就叫摊还分析法。
一般能应用摊还分析法的场景,均摊时间复杂度就等于最好情况时间复杂度
2. 折半查找
输入
n个数的有序序列,以数组为例,默认升序。
待查找元素key。
输出
查找成功:返回元素所在位置的编号。
查找失败:返回-1或自定义失败标识。
算法说明
算法的核心思想是不断的缩小搜索的范围,每次取区间的中心来进行比较,会有三种情况发生:
与key相等:直接返回对应的位置(对于有重复元素的情况,会在其他子专栏中说明)。
比key大:由于元素有序,要查找的元素一定在左侧(如有),于是搜索区间变为左一半。
比key小:由于元素有序,要查找的元素一定在右侧(如有),于是搜索区间变为右一半。
于是,只要不断的重复取中间比较和指定新的搜索区间这两个步骤,直到区间的两个端点已经重合(代表搜索完毕)或者找到元素时为止。
算法流程
以下图片来自《数据结构简明教程》,查找关键字为7的元素:
第一次比较:mid坐标为4,对应元素为10,大于7,则区间变为左一半:[0,3]。
第二次比较:mid坐标为1,对应元素为1,小于7,则区间变为右一半:[2,3]。
第三次比较:mid坐标为2,对应元素为7,等于7,返回逻辑序号:mid + 1 = 3。
3. 伪代码
折半查找需要不断的改变区间和取中间元素来进行判断,只要明确key与比较元素的关系就可以确定新的比较区间,然后循环这个过程。理解了核心步骤后,伪代码表示如下:
left = 1 right = A.length while left <= right mid = (left + right) / 2 if A[mid] == key return mid else if A[mid] > key right = mid - 1 else left = mid + 1 return -1
算法的输入为升序数组A(其中包含n个元素,无重复)以及待查找元素key。
初始搜索区间为整个数组:从 A[1] 到 A[n]。
最后一次循环为左右区间已经重合,如果还没有找到元素,说明集合中没有元素。
如果在查找过程中,出现中间点与key相等的情况,则代表已经找到,直接返回。
如果中间点的值与key不相等,则需要改变其中一个端点,实现搜索区间的减半。