[转] 一个经典编程面试题的“隐退”
本文由 伯乐在线 – 王伯 翻译自 The Noisy Channel。
面试程序员很困难。Jeff Atwood 抱怨找一个会写代码的候选人是如此艰难。在技术媒体发布的那些“最佳”面试题中,很少有能让我提起兴趣的——尽管我很喜欢IKEA的这个面试题。Codility和 Interview Street这样的创业公司从这个具有挑战性的课题中看到了机会。与此同时,Diego Basch 呼吁我们停止逼迫求职者进行白板编程。
对此我没有什么更好的建议。我同意IQ测试和刁难人的问题非常糟糕。在最好的情况下,它仅仅能测试候选人的一项素质;在最坏的情况下,它完全不能说明候选人是因为曾经遇到过相同的问题,还是靠着自己的能力找到了解决方法。编程题对于一个一整天的工作内容就是写代码的人来说是更好的面试方法,但是传统的方法,不论是电话还是面对面交流,都不是最优的测试编程能力的方法。同样,人们也不是很清楚编程题应该是以怎样的形式呈现——直接解决问题,还是仅仅把一个算法翻译成可执行的代码?
面对着如此多的挑战,我想到了一个为我以及其他在Endeca、 Google 和LinkedIn工作的同僚们服务了多年的面试题。我带着沉重的心情解这个题,原因我会在结尾中解释。但是首先让我描述下这个问题,并且解释为什么它如此有效。
问题
我把它叫做“分词”问题并解释如下:
给定一个输入的字符串和一个包含各种单词的字典,用空格将字符串分割成一系列字典中存在的单词。举个例子,如果输入字符串是“applepie”而字典中包含了所有的英文单词,那么我们应该得到返回值“apple pie”。
注意,我故意没有解释或者漏掉了一些细节,从而给候选人一个弄清楚问题的机会。 这里我举一些候选人可能会问的问题,以及我会如何回答。
问:如果输入字符串本身是一个单词怎么办?
答:可以把它看作一个特殊情况。
问:我只用考虑分割成两个单词的情况吗?
答:不,但是可以从这种情况开始。
问:如果输入字符串无法被分割成单词怎么办?
答:返回null或者类似的东西。
问:有变位或者拼写错误怎么办?
答:只需要严格分割成字典中有的单词。
问:如果有多种分割可能性怎么办?
答:只需要返回任何一个正确的答案。
问:我在想将字典用前缀树,后缀树,Fibonacci堆实现…
答:你不用实现字典。只需要假设它已经被合理地实现了。
问:字典支持哪些操作?
答:字符串查询——这就是你所需要的全部
问:字典有多大?
答:假设它远远大于输入字符串,但足够装入内存。
观察求职者如何讨论这些问题,能帮助你了解求职者的沟通技巧和对细节的关注,以及求职者对数据结构和算法的基本理解。
简单解法
题目介绍的足够多了,我们接着看看解法。一些求职者从问题的简易版入手,即只考虑把字符串拆分成两个单词。我把它看作一个“傻瓜”解法,并且期望任何有竞争力的软件工程师可以给出任何等价于下面解法的代码。在我的解答中将使用Java实现。
String SegmentString(String input, Set<String> dict) {
int len = input.length();
for (int i = 1; i < len; i++) {
String prefix = input.substring(0, i);
if (dict.contains(prefix)) {
String suffix = input.substring(i, len);
if (dict.contains(suffix)) {
return prefix + " " + suffix;
}
}
}
return null;
}
我面试过无法给出上面解法的求职者——其中一些甚至通过了Google的技术面。如同Jeff Atwood所说,傻瓜问题是避免面试官在那些根本不会编程的求职者身上浪费时间的好办法。
通用解法
当然,这个问题的精华在于它的一般情况,即输入字符串可以被分割成任意数量的单词。有很多方法可以解决这个问题,但是最直接的是递归回溯。这是一个典型的建立在上个解法上的版本:
String SegmentString(String input, Set<String> dict) {
if (dict.contains(input)) return input;
int len = input.length();
for (int i = 1; i < len; i++) {
String prefix = input.substring(0, i);
if (dict.contains(prefix)) {
String suffix = input.substring(i, len);
String segSuffix = SegmentString(suffix, dict);
if (segSuffix != null) {
return prefix + " " + segSuffix;
}
}
}
return null;
}
许多申请软件工程师职位的候选人无法在半小时内得到相当于上面解法的方法(比如一个用显式栈实现的算法)。我可以肯定他们很有竞争力并且很会写代码,但是我不会把他们安排到有关信息检索或者机器学习的职位上,尤其像那些开发大规模搜索功能的公司。
运行时间研究
但是等一下,问题不仅于此!当一个求职者走到了上面这一步,我会让他研究这个算法的最差运行时间,用字符串的长度n表示。我听过从O(n)到O(n!)的各种各样的回答。
我通常会提供以下提示:
考虑一个想象中的字典,它只包含”a”,”aa”,”aaa”,…,这样仅由字母”a”组成的单词。如果输入字符串是由一长串”a”和最末尾一个”b”组成会发生什么?
求职者最好能发现这样的话递归回溯将会寻找每一个可能的分割,于是问题就变成了举出单纯分割字符串的所有可能性。我把这个问题作为练习留给读者自己思考,答案是时间复杂度O(2^n)。
高效解法
如果求职者能走到这一步,我会问他是否可以做得更好。许多求职者意识到这是一个负载问题,更厉害的那些意识到可以用动态规划来完成。这是一个用了存储的解法:
Map<String, String> memoized;
String SegmentString(String input, Set<String> dict) {
if (dict.contains(input)) return input;
if (memoized.containsKey(input) {
return memoized.get(input);
}
int len = input.length();
for (int i = 1; i < len; i++) {
String prefix = input.substring(0, i);
if (dict.contains(prefix)) {
String suffix = input.substring(i, len);
String segSuffix = SegmentString(suffix, dict);
if (segSuffix != null) {
memoized.put(input, prefix + " " + segSuffix);
return prefix + " " + segSuffix;
}
}
memoized.put(input, null);
return null;
}
同样的求职者需要进行时空分析。关键点是SegmentString方法只需要在输入字符串的后缀中执行,并且只有O(n)数量的后缀。我把这个作为练习留给读者,这个解法的时间复杂度为O(n^2)。
为什么我喜欢这个问题
有非常多的理由让我喜欢它。我说几点:
- 这是一个在现实的软件开发过程中会遇到的问题。我曾经为Endeca开发搜索词重写,这个问题会在拼写检查和同义词扩充时出现。
- 它不要求任何特殊的知识——仅仅是字符串、集合、表、递归和动态规划的简单应用。这些都是本科一二年级的基础内容。
- 写出这些代码并不容易,45分钟的时间会很紧凑,不论面试是电话进行或者用Collabedit这样的工具。
- 这个问题很有挑战性,但不是个故意难倒你的问题。它需要一些有条理的分析以及对基本工具的使用。
- 候选人在这个问题上的表现不是非对即错的。最糟糕的候选人甚至不能在45分钟内想出第一个解法。最好的候选人能在10分钟内实现带存储的解法,这使得你有机会问一些更有趣的问题,比如他们如何处理一个大到难以放在内存中的字典。多数候选人的表现在这两种之间。
“退休”愉快
不幸的是,所有好的东西都有尽头。我最近发现有人把这个题目放到了Glassdoor上。那里的解法没有我这篇讲的这么深入,而我认为像这样好的题目应该体面“善终”。
想出一个好的面试题很难,保守秘密同样很难。秘诀是保守更少的秘诀。一个理想的面试题应该尽量不涉及更高端的知识。我和我的同事们正在像这个方向努力。一旦我们有所进展,我自然会分享更多。
同时,我希望每一个经历过分词问题的人能够感激它带给我们的价值。没有完美的题目,同样一个人在一个面试题上的表现无法说明他在工作中的表现会怎样。尽管如此,这个题目仍然很棒,我知道很多人会怀念它。
原文链接: The Noisy Channel 翻译: 伯乐在线 – 王伯
译文链接: http://blog.jobbole.com/60798/