这是我参与8月更文挑战的第9天,活动详情查看:8月更文挑战
在当今这个数据信息爆炸的时代,在某些环境下,一些程序会执行好几个小时,甚至好几天的情况,或者在执行过程中出现电脑几乎死机的情况。
出现的问题:
- 这样的系统使开发周期,测试周期变长。
- 在线的系统可能会出现时间爆炸或者内存爆炸的情况。
复杂度
复杂度是什么
复杂度是衡量代码运行效率的重要的度量因素,计算机通过一个个程序去执行计算任务,也就是对输入数据进行加工处理,并最终得到结果的过程。每个程序都是由代码构成的。可见,编写代码的核心就是要完成计算。但对于同一个计算任务,不同计算方法得到结果的过程复杂程度是不一样的,我们需要讲究合理的计算方法,去通过尽可能低复杂程度的代码完成计算任务。
降低复杂度,我们首先需要知道怎么衡量复杂度。而在实际衡量时,我们通常会围绕以下2 个维度进行。首先,这段代码消耗的资源是什么。 一般而言,代码执行过程中会消耗计算时间和计算空间,那需要衡量的就是时间复杂度和空间复杂度。其次,这段代码对于资源的消耗是多少。 我们不会关注这段代码对于资源消耗的绝对量,因为不管是时间还是空间,它们的消耗程度都与输入的数据量高度相关,输入数据少时消耗自然就少。为了更客观地衡量消耗程度,我们通常会关注时间或者空间消耗量与输入数据量之间的关系。
如何计算复杂度
复杂度是一个关于输入数据量 n 的函数。假设你的代码复杂度是 f(n),那么就用个大写字母 O 和括号,把 f(n) 括起来就可以了,即 O(f(n))。例如,O(n) 表示的是,复杂度与计算实例的个数 n 线性相关;O(logn) 表示的是,复杂度与计算实例的个数 n 对数相关。
通常,复杂度的计算方法遵循以下几个原则:
- 首先,复杂度与具体的常系数无关,例如 O(n) 和 O(2n) 表示的是同样的复杂度。我们详细分析下,O(2n) 等于 O(n+n),也等于 O(n) + O(n)。也就是说,一段 O(n) 复杂度的代码只是先后执行两遍 O(n),其复杂度是一致的。
- 其次,多项式级的复杂度相加的时候,选择高者作为结果,例如 O(n²)+O(n) 和 O(n²) 表示的是同样的复杂度。具体分析一下就是,O(n²)+O(n) = O(n²+n)。随着 n 越来越大,二阶多项式的变化率是要比一阶多项式更大的。因此,只需要通过更大变化率的二阶多项式来表征复杂度就可以了。
值得一提的是,O(1) 也是表示一个特殊复杂度,含义为某个任务通过有限可数的资源即可完成。此处有限可数的具体意义是,与输入数据量 n 无关。
不同算法对复杂度的影响
举一个简单的例子,判断一个数组中出现次数最多的数字,使用以下两种实现方式。
/**
* @desc 查询出现次数最多的数字
* @param
* @return void
*
* @date 2021/8/11 14:46
*/
@Test
public void s4() {
int a[] = {1, 2, 3, 4, 5, 3, 2, 1, 3, 4, 3};
int count = 0;
int max_value = 1;
int max_value_index = 0;
for (int i = 0; i < a.length; i++) {
count = 1;
for (int j = i + 1; j < a.length; j++) {
if (a[i] == a[j]) {
count++;
}
}
if (count > max_value) {
max_value = count;
max_value_index = i;
}
}
log.info(a[max_value_index] + "的个数为" + max_value);
}
/**
* @desc 查找出现次数最多的数字的次数
* @param
* @return void
*
* @date 2021/8/11 15:32
*/
@Test
public void s6() {
int a[] = {1, 2, 3, 4, 5, 6, 5};
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < a.length; i++) {
if (map.containsKey(a[i])) {
map.put(a[i], map.get(a[i]) + 1);
} else {
map.put(a[i], 1);
}
}
int val_max = -1;
int time_max = 0;
for(Integer key:map.keySet()){
if(map.get(key)>time_max){
time_max=map.get(key);
val_max=key;
}
}
log.info("得到出现最多的元素"+val_max+" 出现的次数"+time_max);
}
观察两种实现方法,第一种方式我们采用了双层循环的方式计算:第一层循环,我们对数组中的每个元素进行遍历;第二层循环,对于每个元素计算出现的次数,并且通过当前元素次数 count和全局最大次数变量 max_count的大小关系,持续保存出现次数最多的那个元素及其出现次数。由于是双层循环,这段代码在时间方面的消耗就是 n*n 的复杂度,也就是 O(n²)。第二种方式代码结构上,有两个 for 循环。不过,这两个循环不是嵌套关系,而是顺序执行关系。其中,第一个循环实现了数组转字典的过程,也就是 O(n) 的复杂度。第二个循环再次遍历字典找到出现次数最多的那个元素,也是一个 O(n) 的时间复杂度。因此,总体的时间复杂度为 O(n) + O(n),就是 O(2n),根据复杂度与具体的常系数无关的原则,也就是O(n) 的复杂度。空间方面,由于定义了 k-v 字典,其字典元素的个数取决于输入数组元素的个数。因此,空间复杂度增加为 O(n)。
将时间复杂度转换为空间复杂度
代码效率的瓶颈可能发生在时间或者空间两个方面。如果是缺少计算空间,花钱买服务器就可以了。这是个花钱就能解决的问题。相反,如果是缺少计算时间,只能投入宝贵的人生去跑程序。即使你有再多的钱、再多的服务器,也是毫无用处。相比于空间复杂度,时间复杂度的降低就显得更加重要了。因此,你会发现这样的结论:空间是廉价的,而时间是昂贵的。
我们需要从时间复杂度和空间复杂度两个维度来考虑。常用的降低时间复杂度的方法有递归、二分法、排序算法、动态规划等。而降低空间复杂度的方法,就需要使用数据结构了。
降低空间复杂度的核心思路就是,能用低复杂度的数据结构能解决问题,就千万不要用高复杂度的数据结构。在程序开发中,连接时间和空间的桥梁就是数据结构。对于一个开发任务,如果你能找到一种高效的数据组织方式,采用合理的数据结构的话,那就可以实现时间复杂度的再次降低。同样的,这通常会增加数据的存储量,也就是增加了空间复杂度。
代码优化的思路
-
第一步,暴力解法。在没有任何时间、空间约束下,完成代码任务的开发。
-
第二步,无效操作处理。将代码中的无效计算、无效存储剔除,降低时间或空间复杂度。
-
第三步,时空转换。设计合理数据结构,完成时间复杂度向空间复杂度的转移。