一、时间复杂度
(1)定义:
时间复杂度实际就是一个函数,该函数计算的是执行基本操作的次数。
注意:时间复杂度表示的是次数而不是时间。一个算法运算结果分三种情况:
最坏情况:任意输入规模的最大运行次数(上界)。
平均情况:任意输入规模的期望运行次数。
最好情况:任意输入规模的最小运行次数,通常最好情况不会出现(下界)。而时间复杂度采用的是最坏的情况,因为:
①一个算法的最坏情况的运行时间是在任意输入下的运行时间上界
②对于某些算法,最坏的情况出现的较为频繁
③大体上看,平均情况与最坏情况一样差
因此:一般情况下使用O渐进表示法来计算算法的时间复杂度。时间复杂度之大O渐进表示法:
一个算法语句总的执行次数是关于问题规模N的某个函数,记为f(N),N称为问题的规模。语句总的执行次数记为T(N),当N不断变化时,T(N)也在变化,算法执行次数的增长速率和f(N)的增长速率相同。则有T(N) =O(f(N)),称O(f(n))为时间复杂度的O渐进表法。(2)计算规则:
①用常数1取代运行时间中的所有加法常数
②在修改后的运行次数函数中,只保留最高阶项
③如果最高阶项系数存在且不是1,则去除与这个项相乘的常数
例如:O(2n^2 + 2*n + 1) = n^2
根据规则②只保留最高阶项2n^2,再根据规则③去掉系数2,最后结果是n^2。递归算法的时间复杂度:递归总次数 * 每次递归次数
常见的算法时间复杂度由小到大依次为:
Ο(1)<Ο(log2n)<Ο(n)<Ο(nlog2n)<Ο(n^2)<Ο(n^3)<…<Ο(2^n)<Ο(n!)
二、空间复杂度
(1)定义:
不是计算实际占用的空间,而是计算整个算法的辅助空间单元的个数,与问题的规模没有关系(简单理解就是算法执行时创建的变量(包括临时变量)个数)
(2)计算规则
①忽略常数,用O(1)表示
②递归算法的空间复杂度=递归深度N*每次递归所要的辅助空间
③对于单线程来说,递归有运行时堆栈,求的是递归最深的那一次压栈所耗费的空间的个数,因为递归最深的那一次所耗费的空间足以容纳它所有递归过程。递归是要返回上一层的,所以它所需要的空间不是一直累加起来的 。
栗子1:求下面代码的空间复杂度
int Sum(int N)
{
int count = 0;
for(int i = 1; i <= N; ++i)
count += i;
return count;
}
空间复杂度:O(n)=1 这里N是个常数。
栗子2:
void fun(int n)
{
int arr[10];
if(n<0)
{
return 1;
}
else
{
return fun(n-1);
}
}
空间复杂度:O(n*10)=O(n)
每次调用fun函数就会创建一个10个元素的整型数组,调用次数为n
三、实战分析
1.二分查找
//递归实现>
int binary_search(int *arr, int left, int right, int key)
{
if (left <= right)
{
int mid = left + ((right - left) >> 1);
if (key > arr[mid])
{
return binary_search(arr, mid+1, right, key);
}
else if (key < arr[mid])
{
return binary_search(arr, left, mid-1, key);
}
else
{
return mid;
}
}
else
{
return -1;
}
}
分析:
在这里查找我们按照最坏的情况来计算
①第一次在长度为 N/(2^0) 的数组中查找元素;
②第二次在长度为 N/(2^1) 的数组中查找元素;
③第三次在长度为 N/(2^2) 的数组中查找元素;
………
@第 n次在长度为 N/(2^n) 的数组中查找元素;
所以递归的次数 n = log2(N)
时间复杂度:O( log2(N) )
空间复杂度:O( log2(N) )
//迭代法>
int binary_search(int *arr, int lenth, int key)
{
assert(arr != NULL);
int left = 0;
int right = lenth - 1;
int mid = 0;
while (left <= right)
{
mid = left + ((right - left) >> 1);
if (key > arr[mid])
{
left = mid + 1;
}
else if (key < arr[mid])
{
right = mid - 1;
}
else
{
return mid;
}
}
return -1;
}
分析:最大查找次数是log2 n,所以:时间复杂度是O(log2 n);
由于辅助空间是常数级别的所以:空间复杂度是O(1);
2.斐波那契
int fib_recursion(int n)
{
if (n <= 2)
return 1;
else
{
return fib(n - 1) + fib(n - 2);
}
}
分析:
循环的基本操作次数是n-1,辅助空间是n+1,所以:
时间复杂度O(2^n)
空间复杂度O(n)
//迭代法
int fib_iteration(int n)
{
int a = 1;
int b = 1;
int c = 1;
if (n<2)
{
return n;
}
while (n>2)
{
c = a + b;
a = b;
b = c;
n--;
}
return c;
}
分析:
循环的基本次数是n-1,所用的辅助空间是常数级别的:
时间复杂度:O(n)
空间复杂度:O(1)
四、尾递归
1.定义:如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。
当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。
尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。
栗子3:
//这是尾递归
function f(x) {
if (1 == x)
return 1;
return f(x-1);
}
//这不是尾递归
function f(x) {
if (1 == x)
return 1;
return 1 + f(x-1);
}
后者不是尾递归,是因为该函数的最后一步操作是用1加上f(x-1)的返回结果,因此,最后一步操作不是调用自身。尾递归的判断标准是函数运行最后一步是否调用自身,而不是是否在函数的最后一行调用自身。
引用知乎大佬的解释:
function story(){
从前有座山,山上有座庙,庙里有个老和尚,一天老和尚对小和尚讲故事:story() // 尾递归,进入下一个函数不再需要上一个函数的环境了,得出结果以后直接返回。
}
function story(){
从前有座山,山上有座庙,庙里有个老和尚,一天老和尚对小和尚讲故事:story(),小和尚听了,找了块豆腐撞死了 // 非尾递归,下一个函数结束以后此函数还有后续,所以必须保存本身的环境以供处理返回值。
}
2.尾递归的优势
尾递归和一般的递归不同在对内存的占用,普通递归创建stack累积而后计算收缩,尾递归只会占用恒量的内存(和迭代一样)。我们知道递归调用是通过栈来实现的,每调用一次函数,系统都将函数当前的变量、返回地址等信息保存为一个栈帧压入到栈中,那么一旦要处理的运算很大或者数据很多,有可能会导致很多函数调用或者很大的栈帧,这样不断的压栈,很容易导致栈的溢出。
我们回过头看一下尾递归的特性,函数在递归调用之前已经把所有的计算任务已经完毕了,他只要把得到的结果全交给子函数就可以了,无需保存什么,子函数其实可以不需要再去创建一个栈帧,直接把就着当前栈帧,把原先的数据覆盖即可。相对的,如果是普通的递归,函数在递归调用之前并没有完成全部计算,还需要调用递归函数完成后才能完成运算任务。