数据结构与算法|第十四章:通用解题的方法论

数据结构与算法|第十四章:通用解题的方法论

前言

本章我们主要讨论当遇到一个实际的算法问题,我们应该如何解题,如何定位问题和技术选型。相关内容参考公瑾博士的《重学数据结构与算法》,公瑾老师的讲解通俗易懂,文中的方法也是多年工作的总结,希望本章能给大家提供一个通用解题的方法论。

项目环境

1.问题定位&技术选型

假设我们现在面对一个实际的算法问题,则需要从以下两个方面进行思考:

  • 首先,我们要明确目标。

即用尽可能低的时间复杂度和空间复杂度,解决问题并写出代码;

  • 接着,我们要定位问题。

目的是更高效地解决问题。这里定位问题包含很多内容。

例如:

  • 这个问题是什么类型(排序、查找、最优化)的问题?

  • 这个问题的复杂度下限是多少,即最低的时间复杂度可能是多少?

  • 采用哪些数据结构或算法思维,能把这个问题解决?

1.1 热身示例

举一个具体例子(这个例子比较简单,但是能说明问题),题目如下:

在一个包含 n 个元素的无序数组 a 中,输出其最大值 max_val。

分析问题:

  • 这个问题的类型是,在数据中基于某个条件的查找问题。

  • 关于查找问题,我们学习过二分查找,其复杂度是 O(logn)。但可惜的是,二分查找的条件是输入数据有序,这里并不满足。这就意味着,我们很难在 O(logn) 的复杂度下解决问题。

  • 继续分析我们会发现,某一个数字元素的值会直接影响最终结果。这是因为,假设前 n-1 个数字的最大值是 5,但最后一个数字的值是否大于 5,会直接影响最后的结果。这就意味着,这个问题不把所有的输入数据全都过一遍,是无法得到正确答案的。要把所有数据全都过一遍,这就是 O(n) 的复杂度。

所以我们可以得到初步结论:

该问题属于查找问题,所以考虑用 O(logn) 的二分查找。但因为数组无序,导致它并不适用。又因为必须把全部数据过一遍,因此考虑用 O(n) 的检索方法。这就是复杂度的下限。

当明确了复杂度的下限是 O(n) 后,你就能知道此时需要一层 for 循环去寻找最大值。那么循环的过程中,就可以实现动态维护一个最大值变量。空间复杂度是 O(1),并不需要采用某些复杂的数据结构。代码如下:

public class FindMaxValueForArrDemo {
    public static void main(String[] args) {
        int[] arr = new int[]{4, 5, 1, 2, 3};
        int maxValue = findMaxValue(arr);
        System.out.println("最大值:" + maxValue);
    }

    private static int findMaxValue(int[] arr) {
        int maxValue = -1;
        for (int i = 0; i < arr.length; i++) {
            if (maxValue <= arr[i]) {
                maxValue = arr[i];
            }
        }
        return maxValue;
    }
}

2.通用解题的方法论

上面的例子只是简单的热身,在面试中或者实际工作中,我们遇到的问题远比这复杂的多,所以我们需要一个通用的解题方法,下面我们来进行讨论。

面对一个未知问题时:

  • 从复杂度入手,尝试去分析
  1. 这个问题的时间复杂度上限是多少,也就是复杂度再高能高到哪里。这就是不计任何时间、空间损耗,采用暴力求解的方法去解题。

  2. 这个问题的时间复杂度下限是多少,也就是时间复杂度再低能低到哪里。这就是你写代码的目标。

  • 尝试去定位问题
  1. 在分析出这两个问题之后,根据问题类型设计合理的数据结构和运用合适的算法思维,从暴力求解的方法去逼近写代码的目标。

  2. 定位问题类型,问题的类型就决定了采用哪种算法思维。

  • 对数据操作进行分析
  1. 在这个问题中,需要对数据进行哪些操作(增删查),数据之间是否需要保证顺序或逆序?
  2. 当分析出这些操作的步骤、频次之后,就可以根据不同数据结构的特性,去合理选择你所应该使用的那几种数据结构了。

经过以上分析,我们对方法论进行凝练,宏观上的步骤总结为以下 4 步:

  • 复杂度分析。估算问题中复杂度的上限和下限。

  • 定位问题。根据问题类型,确定采用何种算法思维。

  • 数据操作分析。根据增、删、查和数据顺序关系去选择合适的数据结构,利用空间换取时间。

  • 编码实现。

3.小试身手

题目1:

在一个数组 a = [1, 3, 4, 3, 4, 1, 3],找到出现次数最多的那个数字。如果并列存在多个,随机输出一个。

按照上面的 通用解题方法论,我们来一步一步解题。

3.1 复杂度分析

首先我们来分析一下复杂度。假设我们采用暴力解法。利用双层循环的方式计算:

  • 第一层循环,遍历数组中的每个元素
  • 第二层循环,对于每个元素计算出现的次数,并且通过当前元素次数 timeTmp 和全局最大次数变量 timeMax 的大小关系,持续保存出现次数最多的那个元素及其出现次数。

由于是双层循环,这段代码在时间方面的消耗就是 n*n 的复杂度,也就是 O(n²)。

代码如下:

    private static int findMaxFreqNumber(int[] arr) {
        int timeMax = 0;
        int value = -1;
        for (int i = 0; i < arr.length; i++) {
            int timeTmp = 0;
            for (int j = 0; j < arr.length; j++) {
                if (arr[i] == arr[j]) {
                    timeTmp++;
                }
            }
            if (timeMax <= timeTmp) {
                timeMax = timeTmp;
                value = arr[i];
            }
        }
        return value;
    }

接着,我们思考一下这段代码最低的复杂度可能是多少?

不难发现,这个问题的复杂度最低低不过 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)。

3.2 定位问题&选择算法思维

显然,这个问题属于在一个数组中,根据某个条件进行查找的问题。既然复杂度低不过 O(n),我们也不用考虑采用二分查找了。此处是用不到任何算法思维。那么如何让 O(n²) 的复杂度降低为 O(n) 呢?

3.3 数据操作分析

答案是通过合适的数据结构,分析这个问题就可以发现,此时不需要关注数据顺序。因此,栈、队列等数据结构用到的可能性会很低。如果采用新的数据结构,增删操作肯定是少不了的。而原问题就是查找类型的问题,所以查找的动作一定是非常高频的。

在我们学过的数据结构中,查找有优势,同时不需要考虑数据顺序的只有哈希表。因此可以很自然地想到用哈希表解决问题。

哈希表(Java 中可以类比 HashMap)的结构是“key-value”的键值对,如何设计键和值呢?哈希表查找的 key,所以 key 一定存放的是被查找的内容,也就是原数组中的元素。数组元素有重复,但哈希表中 key 不能重复,因此只能用 value 来保存频次。

3.4 代码实现

我们对上面的暴力解法进行修改,代码如下:

    private static int findMaxFreqNumberBetter(int[] arr) {
        Map<Integer, Integer> map = new HashMap<>();// 存放元素和出现的次数
        int resValue = -1;
        int timeMax = 0;
        for (int i = 0; i < arr.length; i++) {
            Integer key = arr[i];
            if (map.containsKey(key)) {
                map.put(key, map.get(key) + 1);
            } else {
                map.put(key, 1);
            }
        }

        for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
            int value = map.get(entry.getKey());// 获取次数
            if (timeMax < value) {
                timeMax = value;
                resValue = entry.getKey();
            }
        }
        return resValue;
    }

代码调试,可以看到 HashMap 中存放的数据结构如下
在这里插入图片描述
传入的数组为 [1, 4, 4, 4, 1, 2, 3]

第一步 HashMap 中的数据结构如下:

  • 元素1-> 2 次
  • 元素2-> 1 次
  • 元素3-> 1 次
  • 元素4-> 3 次

第二步我们再遍历 map 中的元素,找到最大次数的元素即可。

4.LeetCode 经典题两数之和

题目:

给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。

示例:

给定 nums = [2, 7, 11, 15], target = 9

因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/two-sum
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

4.1 第一步:复杂度分析

假设我们采用暴力解法,还是利用双层循环方法,步骤如下:

  • 第一层循环,我们对数组的每个元素进行遍历
  • 第二层循环,与第一层元素与 target 的差值进行比较

例如:第一层遍历找到 2 ,第二层只需要找打 9-2=7 的元素是否在数组中就可以了。由于是双层循环所以时间复杂度为 O(n²)。

我们再看看最低的时间复杂度是多少?

很显然,某个数字是否存在于原数组对结果是有影响的。因此,复杂度再低低不过 O(n)。

4.2 第二步:定位问题以及算法思维

这里的问题是在数组中基于某个条件去查找数据的问题。然而可惜的是原数组并非有序,因此采用二分查找的可能性也会很低。那么如何把 O(n²) 的复杂度降低到 O(n) 呢?路径只剩下了数据结构。

4.3 第三步:数据操作分析

在暴力的方法中,第二层循环的目的是查找 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) 时间复杂度的查找动作。

4.4 第四步:代码实现

  • 在 LeetCode 提交题解的时候,存在 [3,3] -> 6 这种重复元素情况,所以增加了重复判断
    private static int[] twoSum(int[] arr, int target) {
        int[] res = new int[2];
        Map<Integer, Integer> map = new HashMap<>();// 用来存储差值和原值下标
        for (int i = 0; i < arr.length; i++) {
            map.put(target - arr[i], i);
        }

        for (int i = 0; i < arr.length; i++) {
            if (map.containsKey(arr[i])) {// 存在差值元素
                int index = map.get(arr[i]);
                if (i == index) {// [3,3]->6 元素重复问题
                    continue;
                }
                res[0] = index;// 原值位置
                res[1] = i;// 差值位置
            }
        }
        return res;
    }

其实还可以优化几个地方,这里就不演示了,比如:

  • int[] res = new int[2]; 其实可以不需要,因为个人习惯喜欢定义返回值变量 res
  • 在第二个循环中,可以直接 return 返回结果,减少循环的次数

5.总结

磨刀不误砍柴功

所以在碰到算法问题时,一定要对问题的复杂度进行分析,做好技术选型。这就是定位问题的过程。只有把这个过程做好,才能更好地解决问题。

常用的分析问题的方法有以下 4 种:

  • 复杂度分析。估算问题中复杂度的上限和下限。

  • 定位问题。根据问题类型,确定采用何种算法思维。

  • 数据操作分析。根据增、删、查和数据顺序关系去选择合适的数据结构,利用空间换取时间。

  • 编码实现。

6.参考

  • 《重学数据结构与算法》- 公瑾
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值