科目一考试和算法学习的思考和体会(中篇)

摘要: 这篇文章主要是接着上篇中谈咱们的科目一算法考试中,我认为可能常考的一些知识点。结合上篇中有些同学的提问,我想再补充一点:我这里介绍的这些很多知识都是“我认为正确的”,如果从严谨的角度讲,有些内容可能“不完全正确”,比如下面我说O(n*2)可以认为就是双层for循环,这句话严格意义上说肯定是错的,但是从参加算法考试和我自己的感觉讲,“我认为也可以说是正确的”。因此这篇文章的初衷是给大家介绍一些我的思考和体会,如果提到的有错误,欢迎大家批评指正。

这是我写的针对咱们的科目一算法考试的思考和体会系列文章的第二篇,其他的链接:

科目一考试和算法学习的思考和体会(上篇)

科目一考试和算法学习的思考和体会(下篇)

从时间复杂度说起

上一篇中提到了当数据规模大于10^5时,很可能需要你使用时间复杂度为O(nlgn)的算法来解答这道题。那么到底什么是时间复杂度,它的定义很长,涵盖内容很多。这里我用我认为正确的、简单的说法解释下:

  1. 如果没有递归调用,你最复杂处的代码是关于数据规模n的N层嵌套for循环的话,时间复杂度就是O(n^N);

举个例子说,假如我做一道题,数据规模为0 <= n <= 10^5,如果我需要写如下这段代码

for (int i = 0; i < n; i++) {...}

时间复杂度就是O(n), 如果需要写

for (int i = 0; i < n; i++) {
 for (int j = 0; j < n; j++) {...}
}
for (int k = 0; k < n; k++) {...}

时间复杂度就是O(n^2),因为上面这段代码中最复杂的地方是两层for嵌套,相对来说,底下的一层for循环就不算进时间复杂度的表达式了,而在当前的数据规模下,这个O(n^2)的算法就很可能超时,需要额外优化。

当然,大家也要注意,假如把上面j < n改为j < 3,它就不算是O(n^2)了,也就不会超时了,所以说注意时间复杂度跟表示你数据规模的n紧密相关。

  1. 一般算法考试中如果用到递归,时间复杂度可以先不考虑,提交发现超时的话再想办法。在我们的考纲中的用到递归的算法(如分治算法)时间复杂度是O(nlgn);

以归并排序为例:

大家可以看到我们要对八个元素做归并排序,大家关心上上图中“治”的部分,它实际上是把每两个元素合成一个有序的新数组(8个元素需要3层的合并,3= log8,第一次归并每组1个元素,共8组,第二次归并每组2个元素,共4组,第三层归并每组4个元素,共2组,也就是说,需要处理logn层的数组,每层的数据总规模都是n),最终得到的时间复杂度就是O(nlogn)(因为一共要执行logn层,每层的数据规模总数都是n,其中logn表示log以2为底,n的对数)。

如果你听完我上面说的感觉有点懵的话。。。那就忘了上面的内容,记住下面的结论:

  1. 一般的算法应用、考试中能接受的最差的时间复杂度是O(n*2),也就是说,假如你想到的是一个要对你的数据规模写三层及以上嵌套的for循环的话,很可能说明你考虑偏了,或者说,你需要对这个算法做些优化;
  2. 看到数据规模为 10^6-10^7,说明这道题需要基本上就是要设计一个 O(n) 的算法。(当然 O(logn) 也可以,也就是更优复杂度也可以。下同。)
  3. 看到数据规模为 5 * 10^4-10^5,说明这道题很可能要用O(nlogn)的时间复杂度来做,也就是说基本肯定要写递归代码了,而且是要把数据规模不断砍半处理的方式解决
  4. 看到数据规模为10^3,说明这道题可以用O(n^2)时间复杂度的算法;
  5. 综上,我们在算法考试中会见到的时间复杂度可能有O(n*2), O(nlogn), O(n), O(logn), O(1), 下文中也会结合这些时间复杂度做简要介绍

如果大家平时学习(注意,不是考试)过程中还是不确定自己的某段代码时间复杂度是怎样的,有个简单的办法,你构造不同的规模的数据量测试一下你的算法,并记录时间,观察下它们的比例关系就知道了。后面我也会举个例子。

为什么说树和链表不容易被考?

上面写了很多内容来介绍时间复杂度,相信大家很容易感觉到,提了很多次递归,递归是一种编程技巧,相信很多同学都已经很会了,不过我在这里还是想简单介绍下,递归是二叉树、链表等数据解构天然就具备的性质。等等,上一篇中我不是说树和链表不容易被考吗?为什么这么说?我以Java为例来解释,随便打开一道LeetCode上的二叉树或链表的题目,你会发现,它给你的输入是个数组,但是需要你完成的方法那里,参数往往是个树对象(TreeNode),或者链表对象(ListNode),如下图所示

你会发现你其实是没法做题的,除非有人给你提供一个数组转TreeNode的方法,当然,如果你平时在LeetCode上用Java考试的话,点击右上方的playground,你会发现这道题目的完整代码,如下图所示

你会发现,原来有这么个方法帮我去把它传入的数组转成一棵树,然后你才可以接下来的编码,否则你再本地是没法调试代码的。然而我们参加LeetCode的科目一算法考试的时候,这个Playground按钮是不存在的。。。就是这种感觉:

调试能力本来也是算法考试的重要一环,也就是说如果考了二叉树或链表之类的问题,你没有提前记住这个转换数组成二叉树/链表的方法的话,就没法使用测试数据本地调试你的代码了,所以我认为考这样的题目,对不常刷题的同学,是不公平的,反过来说,既然出这类题可能引起不平公的呼声,出的概率也就应该低一些(至少在我参加考试和看软件能力提升团队空间的帖子中感觉确实很少看到这类题目),建议大家把复习它们的优先级放最低。

说说递归

相信递归的代码怎么写,大家都是了解的,我在这里补充说明三点:

  1. 递归本质上跟子函数调用是一样的,类似我们可能常写的代码,a方法中调了b方法,b方法中又调了c方法(这个过程就叫递),c方法返回了一个结果,交给b,b再交给a(这个过程就叫归),结束。如果你在调试递归代码时产生了疑惑,其实可以把多次进入同一个递归方法看成不同的方法;
  2. 子函数调用它总会有个终点,递归调用也需要有个终点,或者看作结束条件,否则便成了无限循环(以斐波那契数列为例,大家都知道f(n)= f(n-1) + f(n-2),可是如果你不知道f(1) = f(2) = 1这个结束条件的话,你是永远没法算出f(n)的);
  3. 我们之前常说推荐大家学习递归时通过调试等方式看到代码是如何运行的,进而理解递归,但实际上这样调试的过程很多同学调着调着就晕了,更简练的方法是梳理清楚相邻两次递归的关系,把这个“公式”写出来,作为代码再去验证。

综上,一段递归代码往往是这样的,你也可以把它当作一个模板

function xx(T t) {
 // 结束条件
 if (...) {
  return ...
 }
 
 // 递归逻辑
 return xx(t1)
}

虽然按我上面说的,考二叉树、链表有些“不公平”,但如果就是考了,这里也说说思路,关于链表的问题很容易考的两类解题思路是

  1. 需要添加虚拟头节点(或者叫哑节点)来处理(因为我觉得这不是考试重点,当然,这是个值得大家了解的处理链表问题的思想。所以给大家列出来一道题,大家可以去看下这道题的题解,就知道这类题目的思路):可以看下LeetCode 82题这个题解,简单来说,这个思路就是:因为链表很多时候处理时对首节点需要额外做判断,因此它在首节点前面又加了个节点,这样真正的链表数据就可以用统一逻辑来处理了
  2. 递归。参考LeetCode 203题,这个题解介绍了三种方法,其中方法1就是最常规的方法,建议大家看下它的缺点,因而引入方法2,就是添加虚拟头节点法,当然,也可以用方法三,递归来解决,如下图。

二叉树的问题,容易考的两类解题思路是:

  1. BFS,也即广度优先(同样,应该不是考试的重点,但是值得学习,参考题解),而且BFS题目的考法,往往是题目中提到了对树的每一层都要怎么操作下(大家可以通过LeetCode 102103199这些问题感受下它的题目特点),这时候就大家可以考虑通过BFS解题,否则,考虑递归。
  2. 递归,同样,我认为这个考的概率低,大家可以参考这个题解感受下这种思路,如下图,顺便可以看下,它跟上面说的模板是很类似的。

写出递归代码也有可能超时吗?

算法面试中也有一道很容易被考的题目:斐波那契数列,或者爬楼梯(这两道题写出来的代码是一模一样的)。如果你很快的写出类似如下思路的这段递归代码:

public int fib(int n) {
 // 结束条件
 if (n == 0 || n == 1) { return 1;}
 // 递归逻辑
 return fib(n - 1) + fib(n - 2);
}

面试官一定会问你这段代码还有什么问题/优化的办法吗?大家可以自己试试把这段代码在自己的机器上执行下,当n等于42的时候,一时半会是算不出来结果的。

那么,为什么会有这个问题?这里说下前面遗留的一个问题,对一下看不出来时间复杂度的算法怎么估算:比如这个题目,数据规模是n,你可以让n=10,算下求出结果的时间,再让n=20,算下时间,是怎么样的,如果这两次时间消耗的倍数大于4,那就说明这个算法时间复杂度大于O(n^2),我们需要想想办法怎么优化,也就是我上篇中提到的一个考点,带优化的递归。

带优化的递归,常体现为两种形式:

记忆化搜索

以这个问题为例,大家可以定义一个变量num,记录这个方法执行的次数,也就是没调用一次这个fib方法,这个num就加一,我试了,n=20的时候,这个方法被调用了2189次,在n =40的时候,则是33116028次,所以,问题就是,这个算法被调用的次数太多了。那么优化的办法很自然的就可以想到了,如果有一个数组,把之前算过的fib(n)记录下来,你会发现,在n= 40时,这个方法只被调用了79次,性能提升了超过100万倍;大家可以看下这个题解来看下代码并理解记忆化搜索。

我相信大家也有疑问,就是我怎么知道这道题是不是在考递归还是带优化的递归?办法就是,当你发现一道题可以不停地调用同一个方法来解决的时候,你先把这个递归代码写出来,调试无误,提交,如果超时,就考虑是不是要记忆化搜索或者动态规划,当你写出这个递归代码的时候,其实你就离记忆化搜索或动态规划距离不远了。

动态规划

动态规划原则上不考,但是很多题目可以一题多解,所以动态规划的思想还是很建议大家学习的,当然,你可以用自己习惯的方式去学习动态规划,我这里也只说说我学习的方法:

很多动态规划都是从递归的代码转换过来的,如我上面所说递归代码的写法是梳理清楚相邻两次递归的关系,把这个“公式”写出来, 动态规划的写法则类似高中数学中的数学归纳法,数学归纳法往往用于证明一些等式,步骤有两步:

  1. 证明n=1的时候,这个结论成立(这个往往很好证明);
  2. 假设n=N的时候这个结论成立,用这个前提来证明n=N+1的时候这个结论同样成立。

而动态规划则是:

  1. 把数据规模最简单的时候的值,先初始化好(以上面的斐波那契数列为例,就是初始化记录fib(0),fib(1)的值);
  2. 找到fib(n)和fib(n-1)的关系,这个关系找好后,这些值就像竹筒倒豆子,从上一步初始化的值一步步推出来了。

大家同样可以参考这个题解来再理解下动态规划的解题思路。

理解了之后大家就会发现,原来动态规划的问题,就是在分析一道题中f(n)和f(n-1)的关系,只不过,说是f(n)和f(n-1)还不恰当,更恰当的说法是,一个大问题和它子问题的关系。当然,有时候这个关系是不太好找的,这也是很多动态规划的问题难做的原因,这里有一篇文章,通过它介绍的题目,你就知道动态规划的问题怎么找大问题和子问题的关系了(当然,你会经常看到这些动态规划问题中都会创建一个叫dp的数组,dp就是动态规划的意思,也就是我上面说的动态规划问题的第一步,初始化,这个dp的设置也是很有学问的,大家同样可以参考这些题解学习,比如什么情况下需要使用二维dp数组)。另外动态规划还有很多经典的问题,推荐大家可以看下这个电子书,里面记录了很多动态规划的问题解法,以及模板。

考试的时候,注意下“最”字

我相信大家可能还有这个疑问,怎么快速发现这道题是不是考它呢?按动态规划的定义,它需要符合一些条件的时候才能用。这里我说一个我发现的特点,很多动态规划的题目中都有个“最”字,其实原因是,正是因为我们经常要对一个大的问题考虑它最佳或最优的的一个条件,把它跟小规模问题关联的时候就用到了动态规划,比如什么最短路径,最长递增子序列,等等。所以大家考试的时候,可以注意下有没有这个"最"字,如果有,可以想想跟你之前看过的哪道动态规划的问题是不是类似,如果是的话,可以用上面介绍的方法去解决,或者如果能想到用递归的解法,可以先把代码写出来试试能否通过,如果超时了在加记忆化搜索去解决。在一般的算法考试中,大家可以认为动态规划和记忆化搜索的时间复杂度是类似的。

那么,如果你看到“最”字后,感觉又不是动态规划,怎么办呢?

下一篇中,给大家介绍。

posted @ 2023-03-17 16:45  易先讯  阅读(20)  评论(0编辑  收藏  举报