回炉重造12时辰-代码效率优化方法论(一)
衡量程序运行的效率:复杂度
之前听我一个朋友说过,他曾写过这么一段代码,就是将80万条数据从一个库中拿出来,插入另一个库的两个表中,同时这些数据要在solr中建索引,每插入1000条提交一次事务,结果就是,这段代码执行了3天多,才将80万条数据插入数据库完毕···
暂且不提可以通过多线程提高插入速度,单纯想一下3天多执行完一段代码,它的效率显然是低下的。(狠狠的嘲讽我那哥们)
如果这个效率低下的系统是离线的,那么它会让我们的开发周期、测试周期变得很长。
如果这个效率低下的系统是在线的,那么它随时具有时间爆炸或者内存爆炸的可能性。
此刻,我们就明白了衡量一段代码的执行效率对于我们开发工程师是至关重要的,而复杂度便是衡量代码运行效率的重要度量因素。
在展开讲复杂度之前,我们先看一下复杂度和计算机实际任务处理效率的关系。
计算机通过一个个程序去执行计算任务,也就是对输入数据进行加工处理,并最终得到结果的过程。每个程序都是由代码构成的。可见,编写代码的核心就是要完成计算。但对于同一个计算任务,不同计算方法得到结果的过程复杂程度是不一样的,这对你实际的任务处理效率就有了非常大的影响。
举个例子,你要在一个在线系统中实时处理数据。假设这个系统平均每分钟会新增 300M 的数据量。如果你的代码不能在 1 分钟内完成对这 300M 数据的处理,那么这个系统就会发生时间爆炸和空间爆炸。表现就是,电脑执行越来越慢,直到死机。因此,我们需要讲究合理的计算方法,去通过尽可能低复杂程度的代码完成计算任务。
我们可以想一下,一段代码在执行的过程中消耗的资源无非就是计算时间和计算空间,消耗时间过长或者消耗空间过大都会导致复杂度的过高,因而导致代码效率低下,所以衡量复杂度的维度便是时间复杂度和空间复杂度。
在这里列举一个生活的小例子,某居民居住聚集区A到工作聚集区B仅仅有一个小路可以通行,每天早高峰的时候,这条小路就会交通阻塞,这样就大大消耗了大家的时间,但是后来在A,B之间又新建了两条道路通行,交通堵塞就再也没有发生,因为新建道路的存在,等于消耗了空间资源,来换取了时间资源。
不管是空间还是时间,它们的消耗量都与输入的数据量高度相关,输入数据少消耗自然就少,但是数据量却是我们没法控制的,所以为了更客观的衡量消耗程度,我们通常会关注时间或者空间消耗量与输入数据量之间的关系。那么问题来了,我们应该如何去计算复杂度呢?
复杂度是一个关于输入数据量 n 的函数。假设你的代码复杂度是 f(n),那么就用个大写字母 O 和括号,把 f(n) 括起来就可以了,即 O(f(n))。例如,O(n) 表示的是,复杂度与计算实例的个数 n 线性相关;O(logn) 表示的是,复杂度与计算实例的个数 n 对数相关。
通常,复杂度的计算方法遵循以下几个原则:
(1)首先,复杂度与具体的常系数无关,例如 O(n) 和 O(2n) 表示的是同样的复杂度。我们详细分析下,O(2n) 等于 O(n+n),也等于 O(n) + O(n)。也就是说,一段 O(n) 复杂度的代码只是先后执行两遍 O(n),其复杂度是一致的。
(2)其次,多项式级的复杂度相加的时候,选择高阶者作为结果,例如 O(n²)+O(n) 和 O(n²) 表示的是同样的复杂度。具体分析一下就是,O(n²)+O(n) = O(n²+n)。随着 n 越来越大,二阶多项式的变化率是要比一阶多项式更大的。因此,只需要通过更大变化率的二阶多项式来表征复杂度就可以了。
(3)O(1) 也是表示一个特殊复杂度,含义为某个任务通过有限可数的资源即可完成。此处有限可数的具体意义是,与输入数据量 n 无关。例如,你的代码处理 10 条数据需要消耗 5 个单位的时间资源,3 个单位的空间资源。处理 1000 条数据,还是只需要消耗 5 个单位的时间资源,3 个单位的空间资源。那么就能发现资源消耗与输入数据量无关,就是 O(1) 的复杂度。
接下来我们通过一个小例子来阐述不同计算方法对复杂度的影响。
问题:输入一个数组,输出它的逆序数组,比如输入a[] = {1,2,3,4,5},则输出b[]={5,4,3,2,1}。
方法一:
public void testOne() { int a[] = { 1, 2, 3, 4, 5 }; int b[] = new int[a.length]; for (int i = 0; i < a.length; i++) { b[i] = a[i]; } for (int i = 0; i < a.length; i++) { b[a.length - 1 - i] = a[i]; } System.out.println(Arrays.toString(b)); }
这段代码的输入数据是 a,数据量就等于数组 a 的长度。代码中有两个 for 循环,作用分别是给b 数组初始化和赋值,其执行次数都与输入数据量相等。因此,代码的时间复杂度就是 O(n)+O(n),也就是 O(n)。
空间方面主要体现在计算过程中,对于存储资源的消耗情况。上面这段代码中,我们定义了一个新的数组 b,它与输入数组 a 的长度相等。因此,空间复杂度就是 O(n)。
方法二:
public void testTwo() { int a[] = { 1, 2, 3, 4, 5 }; int tmp = 0; for (int i = 0; i < (a.length / 2); i++) { tmp = a[i]; a[i] = a[a.length - 1 - i]; a[a.length - 1 - i] = tmp; } System.out.println(Arrays.toString(a)); }
这段代码包含了一个 for 循环,执行的次数是数组长度的一半,时间复杂度变成了 O(n/2)。根据复杂度与具体的常系数无关的性质,这段代码的时间复杂度也就是 O(n)。
空间方面,我们定义了一个 tmp 变量,它与数组长度无关。也就是说,输入是 5 个元素的数组,需要一个 tmp 变量;输入是 50 个元素的数组,依然只需要一个 tmp 变量。因此,空间复杂度与输入数组长度无关,即 O(1)。
可见,对于同一个问题,采用不同的编码方法,对时间和空间的消耗是有可能不一样的。因此,我们在写代码的时候,一方面要完成任务目标;另一方面,也需要考虑时间复杂度和空间复杂度,以求用尽可能少的时间损耗和尽可能少的空间损耗去完成任务。
时间复杂度与代码结构的关系
从本质来看,时间复杂度与代码的结构有着非常紧密的关系;而空间复杂度与数据结构的设计有关。接下来我先来系统地讲一下时间复杂度和代码结构的关系。
代码的时间复杂度,与代码的结构有非常强的关系,我们一起来看一些具体的例子。
例 1,定义了一个数组 a = [1, 4, 3],查找数组 a 中的最大值,代码如下:
public void s1_3() { int a[] = { 1, 4, 3 }; int max_val = -1; for (int i = 0; i < a.length; i++) { if (a[i] > max_val) { max_val = a[i]; } } System.out.println(max_val); }
这个例子比较简单,实现方法就是,暂存当前最大值并把所有元素遍历一遍即可。因为代码的结构上需要使用一个 for 循环,对数组所有元素处理一遍,所以时间复杂度为 O(n)。
例2,下面的代码定义了一个数组 a = [1, 3, 4, 3, 4, 1, 3],并会在这个数组中查找出现次数最多的那个数字:
public void s1_4() { int a[] = { 1, 3, 4, 3, 4, 1, 3 }; int val_max = -1; int time_max = 0; int time_tmp = 0; for (int i = 0; i < a.length; i++) { time_tmp = 0; for (int j = 0; j < a.length; j++) { if (a[i] == a[j]) { time_tmp += 1; } if (time_tmp > time_max) { time_max = time_tmp; val_max = a[i]; } } } System.out.println(val_max); }
这段代码中,我们采用了双层循环的方式计算:第一层循环,我们对数组中的每个元素进行遍历;第二层循环,对于每个元素计算出现的次数,并且通过当前元素次数 time_tmp 和全局最大次数变量 time_max 的大小关系,持续保存出现次数最多的那个元素及其出现次数。由于是双层循环,这段代码在时间方面的消耗就是 n*n 的复杂度,也就是 O(n²)。
综上,我们总结出一些经验性的结论:
1.一个顺序结构的代码,时间复杂度是 O(1)。
2.二分查找,或者更通用地说是采用分而治之的二分策略,时间复杂度都是 O(logn)。
3.一个简单的 for 循环,时间复杂度是 O(n)。
4.两个顺序执行的 for 循环,时间复杂度是 O(n)+O(n)=O(2n),其实也是 O(n)。
5.两个嵌套的 for 循环,时间复杂度是 O(n²)。
降低时间复杂度的必要性
很多新手的程序员,对降低时间复杂度并没有那么强的意识。这主要是在学校或者实验室中,参加的课程作业或者科研项目,普遍都不是实时的、在线的工程环境。
实际的在线环境中,用户的访问请求可以看作一个流式数据。假设这个数据流中,每个访问的平均时间间隔是 t。如果你的代码无法在 t 时间内处理完单次的访问请求,那么这个系统就会一波未平一波又起,最终被大量积压的任务给压垮。这就要求程序员必须通过优化代码、优化数据结构,来降低时间复杂度。
为了更好理解,我们来看一些客观数据。
假设某个计算任务需要处理 10 万 条数据,你编写的代码复杂度不同计算次数也会大相径庭:
如果是 O(n²) 的时间复杂度,那么计算的次数就大概是 100 亿次左右。
如果是 O(n),那么计算的次数就是 10 万 次左右。
如果你再厉害一些,能在 O(log n) 的复杂度下完成任务,那么计算的次数就是 17 次左右(log 100000 = 16.61,计算机通常是二分法,这里的对数可以以 2 为底去估计)。
数字是不是一下子变得很悬殊?通常在小数据集上,时间复杂度的降低在绝对处理时间上没有太多体现。但在当今的大数据环境下,时间复杂度的优化将会带来巨大的系统收益。而这是优秀程序员必须具备的工程开发基本意识。
文章知识点来源于自购课程《重学数据结构与算法》,在这里做一个简单的整理总结分析分享给大家。
工作中由于几乎没遇到过大数据的场景,因此几乎没考虑过代码效率,所以准备把算法和数据结构一点一点的拾起,不要让自己变成一个盲目开发的程序猿。
敬请期待接下来会更新的博客--《回炉重造12时辰--代码效率优化方法论(二)》,关于将时间复杂度转化为空间复杂度的介绍。
我是帝莘,希望能在博客园和你进行技术交流和思想交流!