算法课堂——时空交错

202 阅读7分钟

算法课堂——时空交错

这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」。

通过上一节(算法课堂--初识算法)我们了解到,面对同一道算法题,不同解题方面的效率是截然不同的,但我们还不知道如何准确的量化出一个算法的执行时间与内存消耗,这节课我们带大家系统的了解一下什么是算法的时间复杂度与空间复杂度。


一、时间复杂度

我们先举一个生活中的小例子:

有一根长度N的绳子(单位:m),现在需要将绳子剪成一米一米的短绳,需要剪多少次?

同学一:我拿一把尺,每次量一米,一段一段的剪。

那么我们看下,这个方法我们要剪多少下。我们发现绳子有N米长,我们就剪N-1下。

我们看第二个例子:

有一根长度2^n的绳子(单位:m),现在需要将绳子剪成一米一米的短绳,需要剪多少次?

同学一:我拿一把尺,每次量一米,一段一段的剪。我们剪了2^n-1下。

同学二:这样太麻烦了,我每次对折以后剪一下。

我们看下同学二的方法我们需要剪多少次,我看先看当n=1,我们需要对折剪一下;当n=2,我们先对折一次剪一下,然后将剪好的两根绳子再对折剪一下;以此类推,我们发现当绳子长度是2^n时,我们需要剪n次。

这个时候我们做一个小小的代换,令2^n=N,两边取以2为底的对数可以得出n = log2N(以2为底的N的对数)。

我们得出结论,当有长度为N的绳子时,同学一的方法需要剪N-1下,同学二的方法需要log2N下。

可见我们剪的次数和绳子的长度时息息相关的,所以我们大可以用一个专门的符号O(大写的英文字母O)来表示这种关系

即:同学一的方法剪的次数与N的关系是O(N-1),同学二的方法剪的次数与N的关系是O(log2N)

很显然,这个O正是我们用来表示算法的时间复杂度的符号

那么既然是对应关系,那么关系里面的常量我们就可以忽略不计,最终得出:

我们方法一的时间复杂度为:O(n),方法二的时间复杂度为:O(logn)

知道了时间复杂度后我们看下们初识算法中的题目:

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

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。

你可以按任意顺序返回答案。

示例 1:

输入:nums = [2,7,11,15], target = 9 输出:[0,1] 解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。

算法一:双重循环法:

public int[] twoSum(int[] nums, int target) {
    int n = nums.length;
    for (int i = 0; i < n; ++i) {
        for (int j = i + 1; j < n; ++j) {
            if (nums[i] + nums[j] == target) {
                return new int[]{i, j};
            }
        }
    }
    return new int[0];
}

这个方法我们的时间复杂度是多少呢?通过我们上节课的分析我们很容易得出,在数据比较极端的情况下,我们需要完全循环两遍数组才能得到最终的答案, 所以我们很容易得出算法一的时间复杂的度是和数组的长度n的平方有对应关系,即时间复杂度为O(n^2)

这里我们需要注意的是,有的同学说,我看示例一不是两步就得出答案了,根本和4^2没关系啊?是不是搞错了?非也,那是因为我们大O的严格定义是取上界,即最坏的情况,也就是上文说的极端情况下,例如答案是最后两个数,需要全部循环两次数组才能找到答案,所以我们取算法一的时间复杂度为O(n^2)。

算法二:哈希表法:

public int[] twoSum(int[] nums, int target) {
    Map<Integer, Integer> hashtable = new HashMap<Integer, Integer>();
    for (int i = 0; i < nums.length; ++i) {
        if (hashtable.containsKey(target - nums[i])) {
            return new int[]{hashtable.get(target - nums[i]), i};
        }
        hashtable.put(nums[i], i);
    }
    return new int[0];
}

通过我们上节课的分析,哈希表法只需要最多做数组长度n次查找,就能找到问题的答案,所以很显然,我们算法二的时间复杂度为O(n)。

有的同学问了,不对呀,你每次循环里面都要做hashtable.containsKey(target - nums[i]) 这个操作呀,这个对于算法不影响吗?

问得好,首先我先预先透露一个知识点,hashtable.containsKey()这个方法的时间复杂度为O(1),所以,我们方法二的循环体里面的时间复杂度完全可以忽略不计。

通过上面的几个例子,我们遇到了4种不同时间复杂度的算法,即O(1),O(n),O(logn),O(n^2),我们也知道了他们的对应关系,从执行时间上来看:

                                O(1)<O(logn)<O(n)<O(n^2)

在之后的学习中,我们会遇到各种时间复杂度的算法,到时候我们具体问题具体分析,通过实际的问题来增强对时间复杂度的理解。

这边留给大家几个思考题,有兴趣的朋友可以将答案写在评论区,我会针对你的答案进行补充和讲解。

思考一:将一个字符串按照字典表顺序排序的时间复杂度是多少?

思考二:有一个字符串数组,将数组中每一个字符串进行字典表顺序排序后,再将整个字符串数组进行字典表顺序排序,这个算法的时间复杂度是多少?

二、空间复杂度

说完了执行时间,我们再来看看内存消耗,即我们的空间复杂度。

我们仍然用我们第一节课的例题举例子:

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

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。

你可以按任意顺序返回答案。

示例 1:

输入:nums = [2,7,11,15], target = 9 输出:[0,1] 解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。

算法一:双重循环法:

public int[] twoSum(int[] nums, int target) {
    int n = nums.length;
    for (int i = 0; i < n; ++i) {
        for (int j = i + 1; j < n; ++j) {
            if (nums[i] + nums[j] == target) {
                return new int[]{i, j};
            }
        }
    }
    return new int[0];
}

在算法一中,时间复杂度是O(n^2),虽然它没有用到额外的存储空间,但是显然它不是一个理想的算法,所以我们使用了我们的算法二(哈希表法):

public int[] twoSum(int[] nums, int target) {
    Map<Integer, Integer> hashtable = new HashMap<Integer, Integer>();
    for (int i = 0; i < nums.length; ++i) {
        if (hashtable.containsKey(target - nums[i])) {
            return new int[]{hashtable.get(target - nums[i]), i};
        }
        hashtable.put(nums[i], i);
    }
    return new int[0];
}

在算法二中,我们引出了一个新的数据结构--哈希表用来存储我们数组里面的数和位置。根据我们的分析,我们最终在哈希表中存放的数据个数在最坏的情况下就是数组的长度-1,也就是说哈希表的长度是和数组的长度线性相关的,我们仍然用大写的O来表示算法的空间复杂度,即算法二的空间复杂度是O(n)。

在日常算法中,我们会经常借助一些存储空间来帮助我们写出效率更高的算法,通常会有以下情形:

当算法的存储空间和给出的数据规模没有关系的时候,我们记算法的空间复杂度为O(1);

当算法的存储空间是一些线性空间数组、链表)且这些空间的大小和给出数据的规模也成线性关系时,我们记算法的空间复杂度为O(n)。

当算法的存储空间时一个二维数组,且数组的长和宽都和给出数据的规模成线性关系时,我们记算法的空间复杂度为O(n^2)。

另外我们还需要对递归算法中的空间复杂度进行具体分析,由于大部分同学对递归算法还比较陌生,我们暂且卖个关子,毕竟,时间复杂度和空间复杂度是贯穿我们整个算法课堂专栏中的知识点。也是我们优化算法的重中之重。

三、时空交错

介绍完时间复杂度与空间复杂度,我们不难发现,要想算法执行效率高,我们有时不得不开辟新的存储空间来满足我们的目的,在不开辟新的存储空间时,我们又很难让我们的算法达到一个很高的执行效率。那么我们究竟该如何取舍时间和空间呢?

这个就需要具体问题具体分析了,我总结三句话:

1、从实际上来讲:空间资源远大于时间资源,我们牺牲空间来换取时间,当时间资源远大于空间资源时,我们牺牲时间来换取空间。

2、从具体问题上来讲,题目要求你用什么时间复杂度,什么空间复杂度的算法你就用哪一种,或者说,面试官希望你用哪一种你就用哪一种。

3、大部分情况下,时间复杂度重要一点,我们宁愿多开辟一些空间,也要提升算法的执行速度。

四、总结

时间复杂度和空间复杂度,是被很多同学忽视的两个衡量算法优劣至关重要的指标,很多在leetcode撸了几百道算法题的“大神”也还是对这两个概念一知半解,所以我们花了一节课向大家介绍下这两个概念。希望同学们在日常刷题,解题时也能够多花一点时间思考一下,这样你在优化算法的时候会做到有的放矢,目标明确。

五、预告

在本文中,出现了很多大家没有见过的新词(当然,只是对于零基础的同学来讲),数组、链表、二维数组、等等。 下面几节课,我会向大家详细介绍各种我们会在算法中运用到的数据结构,并且根据所讲的数据结构开始真正的手撕算法题,不想错过的话大家多多点赞收藏哦。相信我,通过阅读我的算法课堂专栏,你会对算法有更加通俗的理解。