learn-tech/专栏/重学数据结构与算法-完/15定位问题才能更好地解决问题:开发前的复杂度分析与技术选型.md
2024-10-16 11:12:24 +08:00

199 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
15 定位问题才能更好地解决问题:开发前的复杂度分析与技术选型
在前面课时中,我们学习了数据结构和算法思维,这些知识和技巧,是解决问题、代码优化的基础。从本课时开始,我们将进入实战模块,从真正解决问题的角度来看看,如何将我们此前学到的知识灵活运用到实际工作中。
问题定位和技术选型
假设你现在面对一个实际的算法问题,则需要从以下两个方面进行思考。
首先,我们要明确目标。即用尽可能低的时间复杂度和空间复杂度,解决问题并写出代码;
接着,我们要定位问题。目的是更高效地解决问题。这里定位问题包含很多内容。
例如:
这个问题是什么类型(排序、查找、最优化)的问题;
这个问题的复杂度下限是多少,即最低的时间复杂度可能是多少;
采用哪些数据结构或算法思维,能把这个问题解决。
为了方便你理解,下面我们来举一个例子,在一个包含 n 个元素的无序数组 a 中,输出其最大值 max_val。
这个问题比较简单。显然,要输出的最大值 max_val也是原数组的元素之一。因此这个问题的类型是在数据中基于某个条件的查找问题。
关于查找问题,我们学习过二分查找,其复杂度是 O(logn)。但可惜的是,二分查找的条件是输入数据有序,这里并不满足。这就意味着,我们很难在 O(logn) 的复杂度下解决问题。
但是,继续分析你会发现,某一个数字元素的值会直接影响最终结果。这是因为,假设前 n-1 个数字的最大值是 5但最后一个数字的值是否大于 5会直接影响最后的结果。这就意味着这个问题不把所有的输入数据全都过一遍是无法得到正确答案的。要把所有数据全都过一遍这就是 O(n) 的复杂度。
小结一下就是,因为该问题属于查找问题,所以考虑用 O(logn) 的二分查找。但因为数组无序,导致它并不适用。又因为必须把全部数据过一遍,因此考虑用 O(n) 的检索方法。这就是复杂度的下限。
当明确了复杂度的下限是 O(n) 后,你就能知道此时需要一层 for 循环去寻找最大值。那么循环的过程中,就可以实现动态维护一个最大值变量。空间复杂度是 O(1),并不需要采用某些复杂的数据结构。这个问题我们在前面的课时 1 中写过的代码如下:
public void s1_3() {
int a[] = { 1, 4, 3 };
int max_val = -1;
int max_inx = -1;
for (int i = 0; i < a.length; i++) {
if (a[i] > max_val) {
max_val = a[i];
max_inx = i;
}
}
System.out.println(max_val);
}
通用解题的方法论
前面的例子只是一个简单的热身。在实际工作中,我们遇到的问题通常会更复杂多变。那么。面对这些问题是否有一些通用的解决方法呢?答案是有的。
面对一个未知问题时,你可以从复杂度入手。尝试去分析这个问题的时间复杂度上限是多少,也就是复杂度再高能高到哪里。这就是不计任何时间、空间损耗,采用暴力求解的方法去解题。然后分析这个问题的时间复杂度下限是多少,也就是时间复杂度再低能低到哪里。这就是你写代码的目标。
接着,尝试去定位问题。在分析出这两个问题之后,就需要去设计合理的数据结构和运用合适的算法思维,从暴力求解的方法去逼近写代码的目标了。
在这里需要先定位问题,这个问题的类型就决定了采用哪种算法思维。
最后,需要对数据操作进行分析。例如:在这个问题中,需要对数据进行哪些操作(增删查),数据之间是否需要保证顺序或逆序?当分析出这些操作的步骤、频次之后,就可以根据不同数据结构的特性,去合理选择你所应该使用的那几种数据结构了。
经过以上分析,我们对方法论进行提练,宏观上的步骤总结为以下 4 步:
复杂度分析。估算问题中复杂度的上限和下限。
定位问题。根据问题类型,确定采用何种算法思维。
数据操作分析。根据增、删、查和数据顺序关系去选择合适的数据结构,利用空间换取时间。
编码实现。
这套方法适用于绝大多数的问题,在实战中需要你灵活运用。
案例
梳理完方法论之后,我们回过头来再看一下以前的例子,看看采用方法论是如何分析题目并找到答案的。
例 1在一个数组 a = [1, 3, 4, 3, 4, 1, 3] 中,找到出现次数最多的那个数字。如果并列存在多个,随机输出一个。
我们先来分析一下复杂度。假设我们采用最暴力的方法。利用双层循环的方式计算:
第一层循环,我们对数组中的每个元素进行遍历;
第二层循环,对于每个元素计算出现的次数,并且通过当前元素次数 time_tmp 和全局最大次数变量 time_max 的大小关系,持续保存出现次数最多的那个元素及其出现次数。
由于是双层循环,这段代码在时间方面的消耗就是 n*n 的复杂度,也就是 O(n²)。这段代码我们在第 1 课时中的例子里讲过,这里就不再赘述了。
接着,我们思考一下这段代码最低的复杂度可能是多少?
不难发现,这个问题的复杂度最低低不过 O(n)。这是因为某个数字的数值是完全有可能影响最终结果。例如a = [1, 3, 4, 3, 4, 1],随机输出 1、3、4 都可以。如果 a 中增加一个元素变成a = [1, 3, 4, 3, 4, 1, 3, 1],则结果为 1。
由此可见,这个问题必须至少要对全部数据遍历一次,所以复杂度再低低不过 O(n)。
显然,这个问题属于在一个数组中,根据某个条件进行查找的问题。既然复杂度低不过 O(n),我们也不用考虑采用二分查找了。此处是用不到任何算法思维。那么如何让 O(n²) 的复杂度降低为 O(n) 呢?
只有通过巧妙利用数据结构了。分析这个问题就可以发现,此时不需要关注数据顺序。因此,栈、队列等数据结构用到的可能性会很低。如果采用新的数据结构,增删操作肯定是少不了的。而原问题就是查找类型的问题,所以查找的动作一定是非常高频的。在我们学过的数据结构中,查找有优势,同时不需要考虑数据顺序的只有哈希表,因此可以很自然地想到用哈希表解决问题。
哈希表的结构是“key-value”的键值对如何设计键和值呢哈希表查找的 key所以 key 一定存放的是被查找的内容,也就是原数组中的元素。数组元素有重复,但哈希表中 key 不能重复,因此只能用 value 来保存频次。
分析到这里,所有解决方案需要用到的关键因素就出来了,我们总结为以下 2 点:
预期的时间复杂度是 O(n),这就意味着编码采用一层的 for 循环,对原数组进行遍历。
数据结构需要额外设计哈希表,其中 key 是数组的元素value 是频次。这样可以支持 O(1) 时间复杂度的查找动作。
因此,这个问题的代码就是:
public void s2_4() {
int a[] = { 1, 3, 4, 3, 4, 1, 3, 1 };
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);
}
}
int val_max = -1;
int time_max = 0;
for (Integer key : d.keySet()) {
if (d.get(key) > time_max) {
time_max = d.get(key);
val_max = key;
}
}
System.out.println(val_max);
}
这个问题,我们在前面的课时中曾给出了答案。答案并不是最重要的,重要的是它背后的解题思路。这个思路可以运用在很多我们没有遇到过的复杂问题中。例如下面的问题。
例 2这个问题是力扣的经典问题two sums。给定一个整数数组 arr 和一个目标值 target请你在该数组中找出加和等于目标值的两个整数并返回它们在原数组中的下标。
你可以假设原数组中没有重复元素而且有且只有一组答案。但是数组中的元素只能使用一次。例如arr = [1, 2, 3, 4, 5, 6]target = 4。因为arr[0] + arr[2] = 1 + 3 = 4 = target则输出 02。
首先,我们来分析一下复杂度。假设我们采用最暴力的方法,利用双层循环的方式计算,步骤如下:
第一层循环,我们对数组中的每个元素进行遍历;
第二层循环,对于第一层的元素与 target 的差值进行查找。
例如,第一层循环遍历到了 1第二层循环就需要查找 target - arr[0] = 4 - 1 = 3 是否在数组中。由于是双层循环,这段代码在时间方面的消耗就是 n*n 的复杂度,也就是 O(n²)。
接下来,我们看看下限。很显然,某个数字是否存在于原数组对结果是有影响的。因此,复杂度再低低不过 O(n)。
这里的问题是在数组中基于某个条件去查找数据的问题。然而可惜的是原数组并非有序,因此采用二分查找的可能性也会很低。那么如何把 O(n²) 的复杂度降低到 O(n) 呢?路径只剩下了数据结构。
在暴力的方法中,第二层循环的目的是查找 target - arr[i] 是否出现在数组中。很自然地就会联想到可能要使用哈希表。同时,这个例子中对于数据处理的顺序并不关心,栈或者队列使用的可能性也会很低。因此,不妨试试如何用哈希表去降低复杂度。
既然是要查找 target - arr[i] 是否出现过,因此哈希表的 key 自然就是 target - arr[i]。而 value 如何设计呢?这就要看一下结果了,最终要输出的是查找到的 arr[i] 和 target - arr[i] 在数组中的索引,因此 value 存放的必然是 index 的索引值。
基于上面的分析,我们就能找到解决方案,分析如下:
预期的时间复杂度是 O(n),这就意味着编码采用一层的 for 循环,对原数组进行遍历。
数据结构需要额外设计哈希表,其中 key 是 target - arr[i]value 是 index。这样可以支持 O(1) 时间复杂度的查找动作。
因此,代码如下:
private static int[] twoSum(int[] arr, int target) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < arr.length; i++) {
map.put(arr[i], i);
}
for (int i = 0; i < arr.length; i++) {
int complement = target - arr[i];
if (map.containsKey(complement) && map.get(complement) != i) {
return new int[] { map.get(complement), i };
}
}
return null;
}
在这段代码中我们采用了两个 for 循环时间复杂度就是 O(n) + O(n) = O(n)额外使用了 map空间复杂度也是 O(n)第一个 for 循环把数组转为字典存放的是数值 -index的键值对第二个 for 循环在字典中依次判断target - arr[i] 是否出现过如果它出现过且不是它自己则打印 target - arr[i] arr[i] 的索引
总结
在开发前一定要对问题的复杂度进行分析做好技术选型这就是定位问题的过程只有把这个过程做好才能更好地解决问题
通过本课时的学习常用的分析问题的方法有以下 4
复杂度分析估算问题中复杂度的上限和下限
定位问题根据问题类型确定采用何种算法思维
数据操作分析根据增查和数据顺序关系去选择合适的数据结构利用空间换取时间
编码实现
其中前 3 个步骤分别对应于这个课程的模块 1 到模块 3这也是算法开发的基础知识有了这些知识才能在实际问题中分析并拼装出解决方案
练习题
最后我们给出一个练习题在这个课时案例 2 two sums 我们采用了两个 for 循环去实现那么能否只使用一个 for 循环完成结果的查找呢