【数据结构基础】程序运行效率优化的核心方法论

复杂度是什么?

计算机通过一个个程序去执行计算任务,也就是对输入数据进行加工处理,并最终得到结果的过程。每个程序都是由代码构成的。可见,编写代码的核心就是要完成计算。但对于同一个计算任务,不同计算方法得到结果的过程复杂程度是不一样的,这对实际的任务处理效率就有了非常大的影响。

复杂度是衡量代码运行效率的重要的度量因素。在实际衡量时,我们通常会围绕以下2 个维度进行。

  • 首先,这段代码消耗的资源是什么。一般而言,代码执行过程中会消耗计算时间和计算空间,那需要衡量的就是时间复杂度和空间复杂度。
  • 其次,这段代码对于资源的消耗是多少。我们不会关注这段代码对于资源消耗的绝对量,因为不管是时间还是空间,它们的消耗程度都与输入的数据量高度相关,输入数据少时,消耗自然就少。因此,为了更客观地衡量消耗程度,我们通常会关注时间或者空间消耗量与输入数据量之间的关系。

从本质来看,时间复杂度与代码的结构有着非常紧密的关系;而空间复杂度与数据结构的设计有关

如何计算复杂度?

复杂度是一个关于输入数据量 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 无关

时间复杂度与代码结构的关系

一些经验性的结论:

  • 一个顺序结构(即没有循环,一句句顺序执行一遍)的代码,时间复杂度是 O(1)。
  • 二分查找,或者更通用地说,采用分而治之的二分策略,时间复杂度都是 O(logn)。
  • 一个简单的 for 循环,时间复杂度是 O(n)。
  • 两个顺序执行的 for 循环,时间复杂度是 O(n)+O(n)=O(2n),其实也是 O(n)。
  • 两个嵌套的 for 循环,时间复杂度是 O(n²)。

降低复杂度的核心方法论

代码效率的瓶颈可能发生在时间或者空间两个方面。如果是缺少计算空间,花钱买服务器就可以了。这是个花钱就能解决的问题。相反,如果是缺少计算时间,只能投入宝贵的人生时间去跑程序。即使你有再多的钱、再多的服务器,也是毫无用处。因此,相比于空间复杂度,时间复杂度的降低就显得更加重要了。可以发现这样的结论:空间是廉价的,最不济也是可以通过购买更高性能的计算机进行解决的。而时间是昂贵的,如果无法降低时间复杂度,那系统的效率就永远无法得到提高。

无论什么难题,降低复杂度的方法都可以总结为以下三个步骤:

  1. 暴力解法。在没有任何时间、空间约束下,完成代码任务的开发。
  2. 无效操作处理。将代码中的无效计算、无效存储剔除,降低时间或空间复杂度。
  3. 时空转换。设计合理数据结构,完成时间复杂度向空间复杂度的转移。
暴力解法

假定在不限制时间、也不限制空间的情况下,完成某个任务的代码开发。这就是通常我们所说的暴力解法,也是程序优化的起点。

如果当前暴力解法的复杂度比较低或者可以接受,那自然万事大吉。可如果暴力解法复杂度比较高的话,那就要考虑采用程序优化的方法去降低复杂度了。

无效操作处理

一个直观的思路是:梳理程序,看其流程中是否有无效的计算或者无效的存储。需要从时间复杂度和空间复杂度两个维度来考虑:

  • 常用的降低时间复杂度的方法有递归、二分法、排序算法、动态规划等,
  • 而降低空间复杂度的方法,就要围绕数据结构做文章了,核心思路就是能用低复杂度的数据结构能解决问题,就千万不要用高复杂度的数据结构

举个例子,假设有任意多张面额为 2 元、3 元、7 元的货币,现要用它们凑出 100 元,求总共有多少种可能性。

暴力解法如下:

public void case1_1() {
	int count = 0;
	for (int i = 0; i < (100 / 7); i++) {
		for (int j = 0; j < (100 / 3); j++) {
			for (int k = 0; k < (100 / 2); k++) {
				if (i * 7 + j * 3 + k * 2 == 100) {
					count += 1;
				}
			}
		}
	}
	System.out.println(count);
}

在这段代码中,使用了 3 层的 for 循环。从结构上来看,是很显然的 O( n³ ) 的时间复杂度。然而,仔细观察就会发现,代码中最内层的 for 循环是多余的。因为,当你确定了要用 i 张 7 元和 j 张 3 元时,只需要判断用有限个 2 元能否凑出 100 - 7* i - 3* j 元就可以了。

代码改写如下:

public void case1_2() {
	int count = 0;
	for (int i = 0; i < (100 / 7); i++) {
		for (int j = 0; j < (100 / 3); j++) {
			if ((100 - i * 7 - j * 3) % 2 == 0) {
				count += 1;
			}
		}
	}
	System.out.println(count);
}

经过改造后,代码的结构由 3 层 for 循环,变成了 2 层 for 循环,时间复杂度就变成了O(n²) 。

空间换时间的优化思路

经过了前面剔除无效计算和存储的处理之后,如果程序在时间和空间等方面的性能依然还有瓶颈,又该怎么办呢?如果可以通过某种方式,把时间复杂度转移到空间复杂度的话,就可以把无价的东西变成有价了,实现了用空间换取时间。

在程序开发中,连接时间和空间的桥梁就是数据结构。对于一个开发任务,如果你能找到一种高效的数据组织方式,采用合理的数据结构的话,那就可以实现时间复杂度的再次降低。同样的,这通常会增加数据的存储量,也就是增加了空间复杂度。

举个例子,要查找出一个数组中,出现次数最多的那个元素的数值。

暴力解法如下:

public void case2_1() {
	int a[] = { 1, 2, 3, 4, 5, 5, 5, 6, 6 };
	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);
}

采用两层的 for 循环完成计算。第一层循环,对数组每个元素遍历。第二层循环,则是对第一层遍历的数字,去遍历计算其出现的次数。同时,全局再缓存一个出现次数最多的元素及其次数就可以了。

很显然,这份代码的时间复杂度就是 O(n²)。而且代码中,几乎没有冗余的无效计算。如果还需要再去优化,就要考虑采用一些数据结构方面的手段,来把时间复杂度转移到空间复杂度了。

不妨思考一下,这个问题能否通过一次 for 循环就找到答案呢?一个直观的想法是,一次循环的过程中,我们同步记录下每个元素出现的次数。最后,再通过查找次数最大的元素,就得到了结果。

针对这个思路,具体可以定义一个 k-v 结构的字典,用来存放元素和其出现次数。那么首先通过一次循环,将数组转变为元素-出现次数的一个字典。接下来,再去遍历一遍这个字典,找到出现次数最多的那个元素,就能找到最后的结果了。具体代码如下:

public void case2_2() {
	int a[] = { 1, 2, 3, 4, 5, 5, 5, 6, 6 };
	Map<Integer, Integer> d = new HashMap<>();
	for (int i = 0; i < a.length; i++) {
		if (d.containsKey(a[i])) {
			d.put(a[i], d.get(a[i]) + 1);
		} else {
			d.put(a[i], 1);
		}
	}
	Collection<Integer> count = d.values();
    // 找出d中value中最大的数字,也就是数组中数字出现最多的次数
    int maxCount = Collections.max(count);
    int maxNumber = 0;
    for (Map.Entry<Integer, Integer> entry : d.entrySet()) {
        // 找到d中value为maxCount对应的key,也就是数组中出现次数最多的数字
        if (maxCount == entry.getValue()) {
            maxNumber = entry.getKey();
        }
    }
    System.out.println(maxNumber);
}

代码结构上,有两个 for 循环。不过,这两个循环不是嵌套关系,而是顺序执行关系。其中,第一个循环实现了数组转字典的过程,是 O(n) 的复杂度。第二个循环再次遍历字典找到出现次数最多的那个元素,也是一个 O(n) 的时间复杂度。因此,总体的时间复杂度为 O(n) + O(n),即 O(2n),根据复杂度与具体的常系数无关的原则,也就是O(n) 的复杂度。空间方面,由于定义了 k-v 字典,其字典元素的个数取决于输入数组元素的个数,空间复杂度增加为 O(n)。因此,这段代码通过采用更复杂、高效的数据结构,完成了时空转移,提高了空间复杂度,让时间复杂度再次降低。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值