活动地址:CSDN21天学习挑战赛
算法及其复杂度
1.算法简介
1.1算法是什么
如今对于算法普遍认可的一个定义是:
算法是解决特定问题求解步骤的描述,在计算机中变现为指令的有限序列,并且每条指令表示一个或多个操作。
现实世界中的问题千奇百怪,算法是为了解决问题而生的,所以算法也是多种多样的,不存在通用的算法可以解决所有问题。
1.2算法的特性
算法有五个基本特性。
输入输出:算法具有零个或多个输入,至少有一个或多个输出。
有穷性:算法在执行有限步骤后,自动结束而不会出现无限循环,并且每一个步骤都在可接受的时间内完成。
确定性:算法的每一步骤都具有确定的含义,不会出现二义性。
可行性:算法的每一步都必须是可行的,也就是说每一步能够通过执行有限次数完成。
1.3算法设计的要求
算法设计上有四个基本要求。
正确性:是指算法至少应该具有输入输出和加工处理无歧义性,能够正确反映问题的需求,得到正确答案。
可读性:算法设计的另一目的是为了便于阅读、理解和交流。
健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或者莫名其妙的结果。
时间效率高和存储量低:用有限的资源,尽可能提高时间和空间上的效率。
2.算法效率
2.1度量方法
2.1.1事后统计方法
这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
实际上,这种方法有很大的缺陷:
- 必须依据算法事先编制好程序,这通常需要花费大量时间和精力,而且要是编制出来后发现这个算法很糟糕岂不是竹篮打水一场空吗?
- 时间的比较在较大程度上依赖于计算机硬件和软件等环境因素,有时会掩盖算法本身的优劣。现在的一台四核处理器计算机,拿来跟以前的386、486等爷爷辈的计算机相比,在处理算法的运算速度上,完全是不能相提并论的。就算是同一台计算机,CPU的使用率和内存占用情况不同,也会造成细微的差异。所以说,单纯拿时间来比较是没有意义的。
- 算法的测试数据设计困难。我们到底用多少数据来测试?测试多少次才可以?这些都是很难判断的问题。
2.1.2事前分析估算方法
在计算机程序编制之前,依据统计方法对算法效率进行估算。
用高级程序语言编写的程序在计算机上运行时所消耗时间主要取决于以下因素:
第(1)条当然是算法好坏的根本,第(2)条需要由软件来支持,第(4)条要看硬件性能。也就是说,抛开环境因素,一个程序的运行时间,依赖于算法的好坏和问题的输入规模。
程序执行过程中,执行每一条代码指令操作是不是要消耗一定量的时间,那执行操作越多,是不是消耗时间越长,也就是说,程序运行时间是和有消耗时间的基本操作的执行次数成正比的。
我们不关心编写程序所用的语言是什么,也不关心这些程序要跑在怎样的计算机上,我们只关心它所实现的算法。这样,不计那些循环索引的改变和循环条件、变量声明以及打印结果等操作,最终,在分析程序的运行时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤,根据基本操作的执行次数来估量比较程序运行时间,以此来衡量算法时间效率的高低。
例如:
int sum(int n)
{
int i = 0;
int sum = 0;
for(i = 1; i < n; i++)
{
sum += i; //执行n次
}
return sum;
}
int print(int n)
{
int i = 0;
int j = 0;
int m = 0;
for(i = 0; i < n; i++)
{
for(j = 0; j < n; j++)
{
printf("%d ", m); //nxn次
m++; //nxn次
}
printf("\n"); //n次
}
}
我们在分析一个算法的运行时间时,重要的是把基本操作的数量与输入规模关联起来,即基本操作的数量必须表示成输入规模的函数,这里我们设为f(n),n就是基本操作数量。
2.2函数的渐近增长
下面算法哪个更快呢?其实不一定。
当n=1时,算法A效率不如B,而当n=2时,两者效率相同,但随着n的增加,算法A比B越来越好了,由此我们可以说算法A总体上好过B。
函数的渐近增长:给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们就说f(n)的增长渐近快于g(n)。
我们发现,随着n的增大,后面的+3或+1实际上不影响最终的算法效率的,所以我们实际上可以忽略这些常数。
看看第二个例子
当n<=3时,算法C要差于D,但当n>3后,算法C就越来越优于D了,到后来甚至远远胜过。无论是去掉后面的常数,还是去掉与n相乘的常数,算法C与D的时间效率差距几乎没有变化。也就是说,与最高次项相乘的常数并不那么重要,可以忽略。
看看第三个例子。
当n=1时,算法E和F结果相同,但当n>1后,算法E的优势就凸现出来了,而且随着n的增大越来越明显。也就是说,最高次项的指数大的,操作执行次数也会增长更快,算法时间效率会更低。
看看第四个例子。
你看,随着n逐渐增大,算法H完全没法和其他两个比,都不是一个量级的。而算法G随n增大逐渐趋于算法I,它们之间的那点差距相对于现在的量级而言完全可以忽略不计。也就是说,判断一个算法效率时,其中的常数和其他次要项可以忽略,要关注的是主项(最高次项)的阶数。
判断一个算法好不好,仅通过少量的数据是不能做出准确判断的。**某个算法,随着n的增大,它会越来越优于另一算法,或者越来越差于另一算法。**这就是我们用来估量算法时间效率的理论依据。
2.3时间复杂度
2.3.1定义
在进行算法分析时,语句总的执行次数T(n)是关于问题输入规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。
算法的时间复杂度,也就是算法的时间量度,和T(n)成正比,记作T(n) = O(f(n))。这表示随n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题输入规模n的函数。
这样用大写O( )来体现时间复杂度的记法称为大O记法。
一般情况下,随着n增大,T(n)增长最慢的算法为最优算法。
2.3.2如何推导大O阶
实际上,我们上一节总结的内容就是方法。
推导方法
1.用常数1取代所有加法常数
2.只保留最高阶项,其他的去掉
3.如果最高阶项存在且其系数不为1,则去除其系数
**要注意,这里的阶不是只看指数的,而是看f(n)的增长率的,增长率高的就为高阶,低的为低阶。**所以一些数学函数的增长率关系必须得清楚。
实际上,低阶项并不是说毫无影响,只是说相对于高阶项而言影响微乎其微到可以忽略,因为我们要看的是量级蜕变,就比如n和n2 ,不是一个量级的,差距显著,而n和2n仍处在一个量级。
2.3.3常见大O阶的说明
2.3.3.1常数阶
int main()
{
int sum = 0;//执行1次
int i = 0;//执行1次
for(i = 0; i < 10; i++)//执行11次
{
sum += i;//执行10次
}
printf("%d", sum);//执行1次
return 0;
}
上面代码的执行次数是固定的常数,与n无关,我们称之为具有O(1)的时间复杂度,又叫常数阶。
无论常数是多少,我们都记作O(1)。
2.3.3.2线性阶
分析算法的复杂度,其中的一个关键就是要分析循环结构的运行情况。
int add(int n)
{
int i = 0;
int sum = 0;
for(i = 0; i < n; i++)
{
sum += i;
}
return sum;
}
循环体中代码要执行n次,最高阶项就是n,所以易知时间复杂度为O(n)。
2.3.3.3对数阶
void mul(int n)
{
int cnt = 1;
while(cnt < n)
{
cnt *= 2;
}
}
cnt只要小于n就会一直进入循环乘以2,直到比n要大为止,在这个过程中,我们假设乘了x个2,那么就有2x =n,也就是x=log2n。所以时间复杂度为O(logn)。要注意的是,logn是对log2 n的简写。
2.3.3.4平方阶
下面是一个很简单的例子,对于时间复杂度为O(n2 )。
for(i = 0; i < n; i++)
{
for(j = 0; j < n; j++)
{
//执行O(1)的操作
}
}
一般而言,循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。
for(i = 0; i < n; i++)
{
for(j = 0; j < m; j++)
{
//执行O(1)的操作
}
}
这里时间复杂度是不是就是O(m*n)呢?不一定,得看m和n的相对大小:
若是m>>n,则时间复杂度为O(m2 )
若是n>>m,则时间复杂度为O(n2)
若是m和n差不多大,则时间复杂度就为O(m*n)
再看看这个例子:
for(i = 0; i < n; i++)
{
for(j = 0; j <= i; j++)
{
//执行O(1)的操作
}
}
i=0时,内循环执行1次,i=1时,内循环执行2次…i=n-1时,内循环执行n次,也就是说,总的执行次数为1+2+3+…+n,求和得(n+1)n/2也就是1/2n2 +1/2n,时间复杂度为O(n2 )。
来个调用函数相关的例子:
void fun(int cnt)
{
int j = 0;
for(j = cnt; j < n; j++)
{
//执行O(1)的操作
}
}
int main()
{
int i = 0;
int j = 0;
int n = 0;
scanf("%d", &n);
fun(n);//执行次数为n
for(i = 0; i < n; i++)
{
fun(i);//执行次数为n*n
}
for(i = 0; i < n; i++)//执行次数为n(n+1)/2
{
for(j = i; j < n; j++)
{
//执行O(1)的操作
}
}
return 0;
}
乍一看是不是还有点复杂的说,其实还是平方阶的,我们分析一波。
常数次的就不看了,第一个fun(n)的,调用一次fun函数,里面执行了n次,第一个for循环,循环里面调用了fun函数,调用一次就是n次,总共调用了n次,所以是n*n次。
而对于第二个for循环,是个嵌套循环,注意内循环的初始化条件为j=i,当i=0时,内循环执行n次,i=1时,内循环执行n-1次…当i=n-2时,内循环执行2次,i=n-1时,内循环执行1次,总的执行次数为n+n-1+n-2+…+2+1,求和得到n(n+1)/2。
所以程序的执行次数大概为3/2n2 +3/2n,时间复杂度就为O(n2)。
2.3.3.5常见的时间复杂度
从低阶到高阶排序:
O(1) < O(logn) < O(n) < O(nlongn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)
我们最常讨论的其实也就以下几个时间复杂度
2.3.4最坏情况和平均情况
2.3.4.1三种情况
我们前面讲的例子其实执行次数是可以预估出来的,因为较为固定,但是有一些算法它的执行次数时不确定的,这时候就有几种可能情况了。
- 最好情况:任意输入规模的最小运行次数(下界)
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中,一般情况下关注的是算法的最坏运行情况,也就是所谓的时间复杂度指的是最坏情况下的时间复杂度,比如上面例子中数组中搜索数据时间复杂度为O(N)。
2.3.4.2平均时间复杂度(特殊情况才会用到)
平均时间复杂度,又称之为“加权平均时间复杂度”,“期望时间复杂度”,为什么叫加权呢?因为,通常计算平均时间复杂度,需要将概率考虑进去,也就是,在计算平均时间复杂度的时候,需要一个“加权值”,来真正的计算平均时间复杂度。
举个例子来分析
// n 表示数组 arr 的长度
int find(int[] arr, int n, int x)
{
int i = 0;
int pos = -1;
for (i = 0; i < n; i++)
{
if (arr[i] == x)
{
pos = i;
break;
}
}
return pos;
}
代码很简单,表示在一个数组中,查找 x 这个数,如果找到了就返回下标,没找到就返回-1,最好情况下时间复杂度是 O(1), 最坏情况下时间复杂度是 O(n)。
那平均复杂度是怎么计算呢?
先说简单的平均值计算公式:
上面的代码,查找x是不是有多种可能,比如说执行次数为1、2、3…n,每种可能下执行次数都不同, 将所有可能下的执行次数相加:1 + 2 + 3 +······ + n + n (第二个 n 表示当 x 不存在的情况下遍历 arr 需要的执行次数
) , 另外,可能的情况就有 n + 1 次(多出的1是找不到的情况),那么结果就是:
用大O阶表示法的话就是O(n)了。
这个公式表达的是:计算所有可能出现的情况下执行次数之和,然后除以可能出现的情况的次数。说白了,这是一个绝对平均的结果。表示每个结果可能出现的概率为1/(n+1)。
如何用加权平均公式计算?
这里有 2 个概率:
- x 变量是否在数组中的概率,有 2 种情况—— 在与不在,所以概率是 1/2
- x 变量出现在数组某位置的概率,有n种情况,但只会出现一次,所以概率是 1/n
x要存在于数组中并且出现在某个位置,所以我们把两个概率进行相乘,结果是 1/(2n),在平均情况下,每一种可能出现概率都相同,所以这个数就是所有执行次数的“加权值”。
如何使用加权值对上面代码的“复杂度”进行计算?
(1+2+3+...+n+n)/2n
,也就是把分母换成2n,计算得到 (3n+1)/4
。
用大O阶表示法的话,时间复杂度为O(n)。
要计算准确的平均时间复杂度,就需要准确的计算这个“权重值”,而权重值会受到数据范围,数据种类影响。因此需要在实际操作中,进行调参。
2.3.5实例练习
2.3.5.1二分查找
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)/2);
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid-1;
else
return mid;
}
return -1
}
二分查找查找次数其实是不确定的,最好情况下1次就能找到,最坏情况下要一直二分直到left>right,有n个数字,每次二分都会截断一半的数字,最后截断到只剩下一个数字,其中假设截断了x次,每次截断n都要除以2,所以最后有n/2x =1,也就是执行次数x=log2 n,时间复杂度就为O(logn)。
2.3.5.2阶乘递归
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return 1;
else
return Fac(N-1)*N;
}
前面好像没有讲过递归欸,那你觉得时间复杂度会是多少呢?
每次进入函数都会调用一次N-1代入后的函数,直到N的值变为0,所以实际上执行了f(N-1)、f(N-2)…f(3)、f(2)、f(1)、f(0)这么多函数,执行次数为N,时间复杂度为O(n)。
2.3.5.3斐波那契递归
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
else
return Fib(N-1) + Fib(N-2);
}
这个就比较复杂了,最好知道什么是二叉树(后面才学的),简单来说二叉树就是每次分支最多只有两个分支的树形结构,如图
二叉树和斐波那契递归又有什么关系呢?斐波那契递归的函数调用过程其实可以用二叉树来表示,如图
这样一看就很直观了,明显发现这种算法有很多重复计算,所以随着n的增大,算法效率应该很低,那到底怎样计算时间复杂度呢?
注意看图,每一层的执行次数可以数这一层有多少函数调用来计算,而且还有一定规律:都是2的指数倍,比如第一层是20 ,第二层是21 ,第三层是22 ,以此类推,第n层就是2n-1 ,这下时间复杂度不就出来了吗——O(2n)。
2.4空间复杂度
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度 。
算法的空间复杂度通过计算算法所需的辅助空间的大小而实现,计算的是变量的个数。空间复杂度计算规则基本跟时间复杂度的类似,也使用大O渐进表示法 ,记作:S(n)=O(f(n)),n为问题输入规模,f(n)为n的函数。
一般情况下,一个程序在机器上执行时,除了需要存储程序本身的指令、常数、变量和输入数据外,还需要存储对数据操作的存储单元,只需要分析该算法在实现时所需的辅助单元即可。
函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定 。
2.4.1示例1
// 计算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;
}
}
是不是O(n)呢?a不是有n个元素吗?注意啦,a数组不是算法设计时根据需要额外开辟的辅助空间!它是必不可少的必须空间,因为我们设计的这个冒泡排序算法就是要对这个数组进行操作的,而不是冒泡排序算法需要再开辟数组。这样一看,我们额外开辟的空间最多也只是常数个,所以空间复杂度为O(1)。
2.4.2示例2
// 计算Fibonacci的空间复杂度?
// 返回斐波那契数列的前n项
long long* Fibonacci(size_t n)
{
if(n==0)
return NULL;
long long * fibArray = (long long *)malloc((n+1) * sizeof(long long));
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n ; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray [i - 2];
}
return fibArray;
}
这是用的迭代方法求斐波那契数列的前n项,在堆上申请了一个fibArray数组,所以空间复杂度为O(n)。
2.4.3示例3
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
这个递归前面讲过时间复杂度,实际上在递的过程中创建了n+1个栈帧,每次函数调用开辟一个,只有一个函数参数N被创建(栈的细节不关心),一共要创建n+1个N,所以空间复杂度为O(n)。
2.4.4示例4
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
else
return Fib(N-1) + Fib(N-2);
}
还是前面用的那张图,我们来看看,首先,调用f(n)会调用f(n-1)和f(n-2),是不是先调用的f(n-1)?调用完f(n-1)后才会去调用f(n-2),调用f(n-1)会创建栈帧是吧,那调用完f(n-1)后该栈帧销毁,空间返还给系统,紧接着又要调用f(n-2)呢,不还得创建栈帧么,那用的是哪块空间啊?用的就是先前销毁的f(n-1)用过的那块呗,这也就是说f(n-1)和f(n-2)的函数栈帧创建用的是同一块空间。同理,如图所示:
所以来来回回用的就只有n个函数栈帧,每个函数栈帧中创建的都是常数个变量,所以空间复杂度为O(n)。
若算法执行时所需辅助空间对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为(1)。