本篇的基本算法原理简单,代码易写,但是效率很高,是常用的编码技术,广泛应用于编程和竞赛中,做题时不妨先想想能否结合这个算法。
在阅读本系列后续文章算法知识点之前,读者需要通过这一篇了解算法复杂度的概念,学会用算法复杂度分析算法的效率,分析自己的解题思路。
计算的资源是有限的,竞赛题会限制代码所使用的计算资源。计算资源有两种:计算时间和存储空间。与此对应的有时间复杂度和空间复杂度,时间复杂度衡量计算的次数,空间复杂度衡量需要的存储空间。
编程竞赛的题目在逻辑、数学、算法上有不同的难度:简单的题目,可以一眼看懂;复杂的题目,往往需要很多步骤才能找到解决方案。它们对代码性能考核的要求是代码必须在限定的时间和空间内运行结束。这是因为问题的“有效”解决,不仅在于能否得到正确答案,更重要的是能在合理的时间和空间内给出答案。算法竞赛的题目,都会对代码的运行时间和空间做出要求。一般情况下,C++代码的运行时间是1s,即代码必须在1s内计算结束并输出结果;空间一般不超过100MB。如果是Java和Python代码,运行时间应该是C++代码的3~5倍。
需要说明的是,时间复杂度不完全等于运行时间。由于代码的运行时间依赖于计算机的性能,同样的代码在不同的机器上运行,需要的时间不同。所以,直接把运行时间作为判断标准并不准确。用代码执行的“次数”衡量更加合理,如一个for循环了n次,把它的时间复杂度记为O(n)。
对于同一个问题,常常存在不同的解决方案,有高效的,也有低效的。算法编程竞赛主要的考核点就是在限定的时间和空间内解决问题。虽然在大部分情况下,只有高效的算法才能满足判题系统的要求,但并不是只有高效的算法才是合理的,低效的算法有时也是有用的。对于程序设计竞赛,由于竞赛时间极为紧张,解题速度极为关键,只有尽快完成更多的题目,才能获得胜利。在满足限定条件的前提下,用最短的时间完成编码任务才是最重要的。而低效算法的编码时间往往大大低于高效算法。例如,题目限定时间是1s,现在有两个方案:
①高效算法0.01s运行结束,代码有50行,编程40min;
②低效算法1s运行结束,代码只有20行,编程10min。此时显然应该选择低效算法。
01
算法的概念
一般认为: “程序=算法+数据结构”。算法是解决问题的逻辑、方法、步骤,数据结构是数据在计算机中的存储和访问的方式。算法和数据结构是紧密结合的,而且有些数据结构有特定的处理方法,此时很难把数据结构和算法区分开来。
算法(Algorithm)是对特定问题求解步骤的一种描述,是指令的有限序列。算法有以下5个特征。
(1) 输入: 一个算法有零个或多个输入。算法可以没有输入,如一个定时闹钟程序,它不需要输入,但是能够每隔一段时间就输出一个报警。
(2) 输出: 一个算法有一个或多个输出。程序可以没有输入,但是一定要有输出。
(3) 有穷性: 一个算法必须在执行有穷步之后结束,且每步都在有穷时间内完成。
(4) 确定性: 算法中的每条指令必须有确切的含义,对于相同的输入只能得到相同的输出。
(5) 可行性: 算法描述的操作可以通过已经实现的基本操作执行有限次来实现。
以冒泡排序算法为例,它满足上述5个特征,具体如下。
(1) 输入: 由n个数构成的序列{a1,a2,a3,…,an}。
(2) 输出: 对输入的排序结果{a′1,a′2,a′3,…,a′n},a′1≤a′2≤a′3≤…≤a′n。
(3) 有穷性: 算法在执行O(n2)次后结束,这也是对算法性能的评估,即算法复杂度。
(4) 确定性: 算法的每个步骤都是确定的。
(5) 可行性: 算法的步骤能编程实现。
02
复杂度和大O记号
衡量算法性能的主要对象是时间复杂度,一般不讨论空间复杂度。因为一个算法的空间复杂度是容易分析的,而时间复杂度往往关系到算法的根本逻辑,不容易分析,更能说明一个程序的优劣。
衡量算法运行时间最常见的一种方法是大O记号,它表示一个算法或函数的渐近上界。对于一个函数g(n),用O(g(n))表示一个函数集合,读作“g(n)的大O”。
O(g(n))={f(n): 存在正常数c和n0,使对所有的n≥n0,有0≤f(n)≤cg(n)}。
用大O做算法分析,得到的复杂度只是一个上界估计。例如,在一个有n个数的无序数列中,查找某个数x,可能第1个数就是x,也可能最后一个数才是x,平均查找次数为n/2次,但是把查找的时间复杂度记为O(n),而不是O(n/2)。再如,冒泡排序算法的计算次数约为n2/2次,但是时间复杂度仍记为O(n2),而不是O(n2/2)。在大O分析中,规模n前面的常数系数被省略了。不过在做算法题时,由于打分时还是按照实际的运行时间衡量代码的性能,所以规模n前面的常数也需要考虑。
一个代码或算法的时间复杂度有以下情况。
1. O(1)
计算时间是一个常数,与问题的规模n无关。例如,用公式计算时,一次计算的复杂度就是O(1);哈希算法用哈希函数在常数时间内计算出存储位置;在矩阵A[M][N]中查找i行j列的元素,只需要对A[i][j]做一次访问就够了;贪心法的时间复杂度也常常是O(1)。
2. O(log2n)
计算时间是对数,通常是以2为底的对数,每步计算后,问题的规模缩小一半。本章的二分法、倍增法、分治法的复杂度为O(log2n)。例如二分法,在一个长度为n的有序数列中查找某个数,用折半查找的方法,只需要log2n次就能找到。再如分治法,每次分治能把规模减小一半,所以一共只有O(log2n)个步骤。
O(log2n)和O(1)没有太大差别,它们的计算量都极小。
3. O(n)
计算时间随规模n线性增长。在很多情况下,这是算法可能达到的最优复杂度,因为对输入的n个数,程序一般需要处理所有数,即计算n次。例如,查找一个无序数列中的某个数,可能需要检查所有的数。再如图问题,一个图中有V个点和E条边,大多数图问题都需要搜索到所有的点和边,复杂度至少为O(V+E)。
4. O(nlog2n)
O(nlog2n)常常是算法能达到的最优复杂度,n为问题规模,log2n为操作步骤数。例如分治法,一共有O(log2n)个步骤,每个步骤对n个数操作一次,所以总复杂度为O(nlog2n)。用分治法思想实现的快速排序算法和归并排序算法,复杂度就是O(nlog2n)。
5. O(n2)
一个二重循环的算法的复杂度为O(n2),如冒泡排序是典型的二重循环。类似的复杂度有O(n3)、O(n4)等,如矩阵乘法、Floyd算法,都是3个for循环。
6. O(2n)
一般对应集合问题,如一个集合中有n个数,要求输出它的所有子集,共有2n个子集。
7. O(n!)
在排列问题中,如果要求输出n个数的全排列,共有n!个,复杂度为O(n!)。
把上述复杂度分为两类:①多项式复杂度,包括O(1)、O(n)、O(nlog2n)、O(nk)等,其中k为一个常数;②指数复杂度,包括O(2n)、O(n!)等。
如果一个算法是多项式复杂度,称它为“高效”算法; 如果是指数复杂度,则称它为一个“低效”算法。可以这样通俗地解释“高效”和“低效”算法的区别:多项式复杂度的算法,随着规模n的增加,可以通过堆叠硬件来实现,“砸钱”是行得通的;而指数复杂度的算法,增加硬件也无济于事,其计算量的增长速度远远超过了硬件的增长。换个角度说,高效算法可以弥补低档硬件的不足。
竞赛题的限制时间一般是1s,对应普通计算机计算速度是每秒千万次级,现在有些判题系统是每秒上亿次,不过为了保险,一般还是按千万次估算复杂度。通过上述时间复杂度可以换算出能解决问题的数据规模。例如,一个算法的复杂度为O(n!),当n=11时,11!=39916800,这个算法只能解决n≤11的问题。
表2.1详细总结了不同算法复杂度与n的关系,需要牢记。
■ 表2.1问题规模和可用算法
在学习算法和解算法题时,请自觉地分析时间复杂度和空间复杂度。
《算法竞赛》系列推文正在连载中,欢迎持续关注!
扫码优惠购书