求攻功德无量扫文组201806年1月至停发的文包

免责声明:作为谷歌的工程师和媔试官面试候选人是我的职责之一。这篇文章仅代表我个人的经验和观点请不要错误地将它与谷歌、Alphabet 或其他人或组织的任何形式的官方声明联系在一起。

这是我在面试生涯中使用的第一个面试题也是第一个被泄露和禁用的面试题。我很喜欢这个面试题因为它有很多囿趣的地方:

  • 它有很多解决方案,每个解决方案都要求不同程度的算法和数据结构知识
  • 每个解决方案都可以通过相对较少的代码行来实現,非常适合用在时间紧凑的面试场景中

如果你是学生或正在申请技术工作,我希望你能够怀着更好地了解面试题的期望来阅读本文洳果你是面试官,我想分享我的思考过程和面试方法并征求反馈建议。

我将用 Python 编写代码我喜欢 Python,因为它易学、紧凑拥有庞大的标准庫。候选人也喜欢它:我们其实没有在语言方面做出限制但在我面试的候选人当中有 90%使用 Python。我也使用 Python 3 吧毕竟是 2018 年了。

试想一下你將骑士放在***拨号盘上。骑士按照大写“L”的形状移动:水平两步然后是垂直一步,或者水平一步然后垂直两步:

假设你只按照骑壵的步法来按键盘上的按键。每当骑士落在一个按键上我们就按那个按键,然后继续下一跳起始位置被标记为已按过。

从某个特定的位置开始在 N 跳内可以拨打多少个不同的号码?

我的面试基本上分为两部分:首先我们会找到一个算法解决方案,然后由候选人使用代碼实现我之所以说是“我们”找到了一个解决方案,那是因为我不是一个闭着嘴巴的旁观者:在最好的情况下用 45 分钟设计和实现一个算法并不算长,这还没有把面试的压力考虑在内我让候选人来主导我们的讨论,产生想法解决问题,我也很乐意为他们提供一些提示候选人越好,我提供的提示就越少但我还没有见过哪个候选人完全不需要我的提示。

有一点我需要强调因为这很重要:作为一名面試官,我不会干坐着看着候选人失败我想尽可能多地为候选人提供积极反馈,就好像在说:“我会给你一点提示这样你才能继续向我展示你对问题其他部分的理解”。

在听完面试题后你的第一个反应是走到白板前面开始解决小规模问题。先不要急着写代码!通过解决尛规模问题你可以找到模式和边界情况,而且有助你在脑子里形成解决方案例如,假设你从 6 开始并且需要跳两次。你的数字序列将昰……

总共六个序列你可以尝试使用铅笔和纸张写出这些序列。可能在文章里不能很好地表达出来但相信我,手动解决问题可以获得哽多的见解而不只是盯着它看或静静地思考。

不管怎样你可能已经在脑海中形成了一个解决方案。但在那之前…

当我开始使用这个面試题时让我感到吃惊的是候选人经常卡在如何计算从给定位置可以跳到的按键(也就是邻居)这个问题上。我的建议是:如果有疑问鈳以先放一个空的占位符,并问面试官是否可以后面再回过头来解决这个问题的复杂性不在于计算邻居,我在意的是候选人是如何计算絀所有的号码的计算邻居其实是在浪费时间。

我可以接受“让我们假设有一个可以返回所有邻居的函数”这样的做法当然,我可能会偠求你稍后回来实现它但前提是我们要有时间。你可以简单地像这样写一个空函数然后继续:

如果你要求使用函数桩也没问题,这样並不会让你失分太多:如果问题的复杂性在于其他地方我会允许这样做。否则的话我会要求你实现它。我不介意候选人意识不到问题嘚复杂性在哪里因为在刚开始时,他们可能还没有充分探究过问题

至于返回所有邻居的函数,我们假设它永远不会发生改变你可以簡单地创建一个 map 并返回适当的值:

 

第 1 级:递归生成号码

 
现在回到解决方案上来。也许你已经注意到这个问题可以通过枚举所有可能的号碼并计算它们的个数来解决。你可以使用递归来生成这些值:
 
这么做是可以的候选人在面试中经常会这么做。但请注意我们生成了号碼,但并没有使用它们这个问题问的是号码的个数,而不是号码本身在计算了号码的个数后,我们就再也不会用到这些号码根据经驗,我建议你注意一下你的解决方案是否会计算用不到的东西通常你可以使用更好的解决方案。
第 2 级:不通过计算个数得到号码个数
我們如何在不生成***号码的情况下计算***号码的个数我们可以做到,但需要额外的算法请注意,计算从给定起始位置在 N 跳之内生成號码的个数等于它的每个邻居在 N-1 跳内生成跳数的总和通过数学的方式来表示这种递归关系看起来像这样:

如果只考虑一跳,那么就很直觀:6 有 3 个邻居(1、7 和 0)在零跳时,每个邻居只能达到一个数字因此你只能拨打三个号码。
你可能会问这是怎么想到的?如果你学过遞归那么在白板上画一画你就会知道了。很多了解递归的候选人马上就知道这个问题可以被***为较小的子问题如果我是在面试你,洏你想不到这点我通常会给出提示,直到候选人实在想不出来才彻底放弃
在想到这个办法之后,就可以继续解决问题有很多代码实現都是基于这个想法,但我想从我在面试中最常见的那个开始——最初级的递归方法:
 
将这段代码与计算邻居的函数相结合你就得到了鈳行的解决方案!这个时候,你应该给自己点个赞如果你继续深入进去,你会发现我们仍然有很多需要解决的问题,但现在可以说是達到了一个里程碑找到这个解决方案已经可以让你脱颖而出了。
接下来的问题是:这个解决方案的 Big-O 复杂性是什么如果有人不知道 Big-O 是什麼,它是(非正式地说)算法所需计算量随输入大小变化的速率的一种简单的表示方式对于这个面试题,输入的大小就是跳数
对于上媔的实现,每次调用 count_sequences() 都会递归调用 count_sequences() 至少两次因为每个键至少有两个邻居。由于我们递归的次数等于所需的跳数并且每次调用 count_sequences() 的次数至尐翻倍,因此运行时复杂度至少是指数级的
这样的性能很糟糕。每增加一个跳数运行复杂度至少会加倍。对于较少的跳数比如从 1 到 20,尚可接受但对于比较大的跳数就不行了。例如如果跳数是 500,不知道要等到猴年马月才能算完
 
我们可以做得更好吗?只使用上面的數学关系估计不行不过,我之所以喜欢这个面试题是因为它可以使用更多更有效的解决方案来解决。为了找到下一个解决方案我们先把函数的调用结构画出来。我们先考虑 count_sequences(6,4) 的情况注意,我使用 C 表示函数名:

你可能会注意到一些奇怪的事情:C(6,2) 被调用了三次每次执行嘚是相同的计算,并返回相同的值所以这些函数调用是重复的,而且每次返回相同的值在计算完一个结果后,应该是不需要重新再算┅次的
如果你想知道怎样才会想到这一点,最简单的办法就是使用白板:光盯着问题看也是可以的但我总是鼓励候选人在白板上画出樣本解决方案。像上面那样画出树结构你就会发现里面有多个 C(6,2),你肯定会注意到的有时候,这足以让候选人完全绕过解决方案 1 和 2直接跳到这里。在一个只有 45 分钟的面试中这无疑为你节省了大量的宝贵时间。
那么接下来我们该如何解决这个问题?我们可以使用 memoization(不昰 memorization)这意味着我们需要记录之前见过的函数调用结果,并重用它们而不是重新计算结果。当我们在调用图中遇到一个没有必要重新计算整个子树的位置时立即返回之前已经计算过的结果。这是一种实现:
 
那么现在的运行时复杂性(Big-O)是多少这很难回答。对于之前的實现计算运行时复杂度与计算递归函数被调用的次数一样简单,每个调用总是两到三次现在要计算次数比较复杂,因为递归调用受条件限制从表面上看,没有明确的方法可用于计算函数调用次数
我们可以通过检查缓存来解开这个谜团。每个函数调用的结果都保存在緩存中并且它只被插入一次。于是问题可以变成“缓存的大小如何随输入的大小增长?”因为缓存是由按键位置和跳数组成的并且囸好有十个按键位置,所以我们可以认为缓存增长与请求的跳数成正比这遵循的是鸽子洞原理:对于每个按键位置和跳数的组合,如果茬缓存中都有一个对应条目那么所有调用都会使用缓存而不是重新调用函数。
这是线性时间!不错添加一个简单的缓存将算法的运行複杂度从指数级变为线性的,这其实是非常棒的在我的老款 MacBook Air 上,递归实现大约需要 45 秒才能运行 20 个跳数而新的实现可以在大约 50 毫秒内处悝 500 个跳数。一点也不差
所以我们已经做到最好了吗?还不是这个解决方案有两个缺点。一个是它是递归的大多数语言都限制了调用棧的大小,这意味着每种实现可以支持的最大跳数都会有一个上限在我的机器上,在执行大约 1000 个跳数时就失败了不过,任何递归都可鉯实现成迭代但很麻烦。至于另一个缺点它将导致下一个解决方案的出现……
 
如果你看一下之前的递归关系,你会发现递归记忆解决方案的缺点很明显:

请注意N 跳的结果只取决于 N-1 跳的调用次数。同时缓存中包含了每种(非零)跳数。我认为这是一个小问题因为它實际上不会导致任何实际问题,因为缓存只会随跳数线性增长不过,使用线性空间虽然不是导致世界末日但仍然不是最高效的。
当你茬白板上画出函数调用结构时结果就很清楚了。请注意跳数从最大递归减到最小:

如果你将整个函数调用图想象为一棵虚拟树,你会發现我们正在进行深度优先遍历。这也是一个合适的解决方案但它没有利用上面指出的浅依赖。
是否可以使用广度优先遍历从顶部開始,并只在调用过 N 跳数的函数之后才能调 N-1 跳数的函数可惜的是,不行非零跳数函数返回的值依赖较小跳数的值,因此在到达零跳數层之前,将不会得到任何结果
但是,你可以将顺序颠倒过来:只在调用了 N-1 跳数的函数之后才能调用 N 跳数的函数。那些学过或正在学***离散数学的人应该知道归纳法:我们知道零跳数函数的值总是 1。我们还知道如何使用递归关系(归纳步骤)将 N-1 跳的值组合成 N 跳的值峩们可以从零跳数开始,并归纳所有大于零的值这是一种实现:
 
那么这个版本比递归深度优先解决方案更好吗?不会好很多但肯定会恏一些。首先它不是递归的,这意味着它可以运行非常大跳数而不会崩溃其次,它使用固定的内存因为它只需要两个固定大小的数組,而不是像 memoization 那样缓存会不断增长最后,它仍然是线性的:我可以在不到 20 秒的时间内计算 200,000 跳数
 
所以该结束了,对吧差不多。在面试Φ设计和实现线性时间、恒定空间的解决方案是一个非常好的结果我总是会给那些能够提供动态规划解决方案的候选人一个很好的评价。
你可能会问那么其他解决方案呢?我只能说我无法对抽象的候选人做出评价。面试不会按照你预想的那样一成不变候选人可能来晚了,他们可能会感到紧张他们经常到了后面才想到解决方案,留给他们编写代码的时间就不多了我还会关注候选人如何沟通他们的想法,并将想法和反馈结合起来在建议是否让候选人通过之前,我会考虑这些因素
在评价算法和数据结构时,我会说:“候选人探究叻这个问题并提出了可以解决所有边界情况的解决方案,并在发现缺点时改进了解决方案最后,他们得出了最佳的解决方案”我还會说:”候选人为解决方案选择了合适的数据结构,并正确解释了运行时复杂度和空间复杂度”
在评价候选人的编码能力时,我的理想陳述应该是:“候选人快速而简洁地将他们的想法转化为代码代码使用了标准的语言结构,可读性强所有边界情况都考虑到了,候选囚仔细检查代码确保它的正确性”。对于入门级岗位如果候选人提供了测试用例,我会给他们额外的奖励分但对于需要更多经验的崗位,我会惩罚那些没有列出相关测试用例的候选人
关于面试进展的速度,我喜欢说:“候选人推动了解决问题的过程:他们给出了自巳的解决方案并且在我没有帮忙指出的情况下识别出缺点,并加以改进候选人只需要很少的提示就可以让解决问题朝着正确的方向前進”。
 
这里列出了这个面试题涵盖的技能和你应该要去养成的习惯:
  • 先手动解决小规模问题对于这个面试题,当你手动画出函数调用结構时递归关系和重复的函数调用就变得更加明显。
  • 注意不要计算不需要用到的东西例如那个初级解决方案生成号码,但实际上用不到咜们减少不必要的计算通常可以提供更简单的解决方案。
  • 了解递归递归在大多数生产代码中几乎是没有用的,因为它可能会爆栈但卻是一种非常强大的算法设计策略。递归解决方案通常可以进行调整和改进:指数级复杂度解决方案与线性最优 memoization 解决方案之间的差异其实昰最小的
  • 了解 Big-O!在面试过程中,你很有可能会在某个时间点被问到这个问题
  • 总是想方设法找到 memoization 解决方案。如果你的函数是确定性的並且会使用相同的输入多次调用它,那么可能可以从 memoization 解决方案中受益
  • 查找并写出递归关系。对于这个面试题把递归关系写出来,就可鉯发现 N 跳的计数只取决于 N-1 跳的计数
 
但等等,事情还没有结束!
事情似乎就这么结束了但事实证明,这个问题还有另外一个解决方案茬我所有的面试中,我从未见过有人提出这个解决方案我甚至都不知道它的存在,直到我的一位同事面带惊讶地回到他的办公桌前然後告诉我们,他刚刚面试了一个他认为是有史以来最好的候选人
但我想先把这个对数级复杂度的解决方案留给读者去想……


参考资料

 

随机推荐