写下这篇文章是因为看到了一篇很有趣的技术文章: 一个经典编程面试题的“隐退”
为不愿意去读原文的读者考虑,我先对原文做一个简单的总结:
这是一个在Endeca、 Google 和LinkedIn等多家公司服务过的编程面试题,题目内容如下
给定一个输入的字符串和一个包含各种单词的字典,用空格将字符串分割成一系列字典中存在的单词。举个例子,如果输入字符串是“applepie”而字典中包含了所有的英文单词,那么我们应该得到返回值“apple pie”。
对原文中提到的简单解法在此略过,原文提到了两种有趣的解法。
一种是通用解法,时间复杂度为O(2^n)
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;
}
一种是高效解法,时间复杂度为O(n^2)
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;
}
memoized.put(input, prefix + " " + segSuffix);
根据此解法的逻辑,当任何一次方法调用执行到了这行代码,即某个后缀被成功地分词匹配,则必然导致着全体输入串的成功分词匹配。在这个时候保存成功匹配的值是没有意义的,因为此后它不会有机会被从Map中查询了。
但是让我觉得到此为止这个题目还不能真正“隐退”的真正原因并不在此,而是在于这个高效解法仍然有优化的空间,可以将时间复杂度从O(n^2)提升到O(n)。
我的解法1
Map<String, String> memoized = new HashMap<String, String>();
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);
if (!memoized.containsKey(suffix)) {
String segSuffix = SegmentString(suffix, dict);
if (segSuffix != null)
return prefix + " " + segSuffix;
}
}
}
memoized.put(input, null);
return null;
}
与原文中的高效解法相比,最主要的改进在于,Map表中仅保存无法匹配的字符串后缀,对于Map表中已保存的字符串后缀,不再递归调用SegmentString方法。
这绝不仅仅是一个将这几行代码移到那里的小把戏,基于以下三个原因,这个改动是非常重要的。
1) 我们将一些中间结果(如无法匹配的字符串后缀)缓存在Map中的动机是什么?在原文解法中,缓存是为了避免重复的计算。在我的解法中,缓存是为了同时避免重复计算以及降低算法复杂度。
2) 我相信,原文中的两个解法的时间复杂度的计算过程已经难住了不少人(有兴趣的人可以自行验证,本文不打算就此进行详细说明)。但这一点没什么可自豪的。恰恰因为它们的解法不够简明,所以才会造成估算时间复杂度的困难。在我的解法中,保证了对任何一个字符串后缀只会调用一次SegmentString方法,因此给定一个长度为n的字符串,对此方法的调用不会超过n次。它的时间复杂度是非常易于估算的。
3) 最重要的一点是,代码执行的时间和空间效率确实会有明显提高。因为递归调用的次数降低了整整一个数量级。
以上的所有解法都是基于原文提出的解题要求:找到任意一个成功的分词匹配。在实际应用中,找到所有的成功分词匹配经常是有必要的。这是一个更难的要求,在这个时候,缓存成功的匹配结果就是必要的了。我也针对对这个需求做了小小尝试,解法如下:
我的解法2
Map<String, List<String>> cached = new HashMap<String, List<String>>();
List<String> AdvancedSegmentString(String input, Set<String> dict) {
List<String> list=null;
if (dict.contains(input)) {
list = new ArrayList<String>();
list.add(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);
List<String> segSuffix = cached.containsKey(suffix)? cached.get(suffix) : AdvancedSegmentString(suffix, dict);
if (segSuffix != null) {
if (list == null)
list = new ArrayList<String>();
for (int j=0; j<segSuffix.size(); j++) {
list.add(prefix + " " + segSuffix.get(j));
}
}
}
}
cached.put(input, list);
return list;
}
注: 这个解法是以不考虑可能的解法个数过多导致内存溢出为前提的。
我觉得综合我的解法1和我的解法2,才是这个编程题的真正“终极隐退”。当然也许我仍然没有达到这一目标,我很欢迎有人能证明这一点。递进式地挖掘编程之美,正是编程的魅力所在。