
今天晚上参加了摩根士丹利的宣讲会,结束以后有一个快速的现场笔试,3 道编程题。我觉得还是有点难度的,如果想要优化的话。当然 WillowTY 说都是傻*题。
这张图是笔试结束的时候我偷拍的,最后一问没有想明白,所以想要再想一想。

第 1 题没什么好说的,就是看两个单词用的字母的个数是不是一样的。
由于只有 26 个字母,所以开一个数组,统计 word1
和 word2
中每个单词出现的次数,看次数是不是相等。
对于 word1
,做 count++
,对于 word2
,做 count--
,最后只要所有的 count
都为 0,那就是 Anagram
,任意一个 count
不是 0,就不是 Anagram
。
boolean isAnagram(String word1, String word2) { int[] count = new int[26]; for (int i = 0; i < word1.length(); i++) { int index = word1.charAt(i) - 'a'; count[i]++; } for (int i = 0; i < word2.length(); i++) { int index = word2.charAt(i) - 'a'; count[i]--; } for (int i = 0; i < count.length; i++) { if (count[i] != 0) { return false; } } return true; }
第 2 题就是遍历 dictionary
,对于某一个 item
,如果和 word
互为 Anagram
,那么就返回这个单词。
由于可以使用第 1 问的函数,那事情就很简单了。
String findAnagram(String word, List<String> dictionary) { for (String s : dictionary) { if (isAnagram(s, word)) { return s; } } return null; }
最关键的是第 3 题,题目也明确说了,简单的做法是,分别遍历 words
和 dictionary
,每一次都调用第 2 问的函数来计算,只是这样效率太低了。
由于我前 2 问陷入了思维盲区,所以这一问没有一下子想到最优的方案。
在继续之前,先说一下前两问还有什么做法。
有一种是 WillowTY 跟我说的。
他的做法是,将 word1
和 word2
的每一位按字典序排序,例如 bbdas
排序后就变成 abbds
。那么,显然,如果 word1
和 word2
互为 Anagram
,显然他们每一位字符重新排序后形成的单词应该是一样的。
由此,可以得到第 1 问的伪代码是:
boolean isAnagram(String word1, String word2) { char[] c1 = word1.toCharArray(); char[] c2 = word2.toCharArray(); Arrays.sort(c1); Arrays.sort(c2); String s1 = new String(c1); String s2 = new String(c2); return s1.equals(s2); }
我们假设每个单词的平均长度为 $a$,那么,这样的复杂度就是 $O(a \log a)$。
而我之前的做法的复杂度是 $O(a)$。
第 2 问,如果在这种排序的思路下,还是对 dictionary
的每一个单词做内部排序,包括 word
。
List<String> newDictionary = new List<>(); for (String s : dictionary) { char[] c = s.toCharArray(); Arrays.sort(c); newDictionary.add(new String(c)); } // word 排序类似
之后对 newDictionary
排序,还是按照字典序。
Collections.sort(newDictionary);
这样一来,问题就变成了,找 newWord
是不是在 newDictionary
里面。
for (String s : newDictionary) if (s.euqals(newWord)) // return
当然,由于这里的 s
已经是排序后的了,所以势必还要加一个 Map,完成排序前后的映射。
for (String s : dictionary) { // ... String t = new String(c); map.put(t, s); newDictionary.add(t); }
for (String t : newDictionary) if (t.euqals(newWord)) return map.get(t);
运用到的数据结构复杂了,但是可以降低复杂度。
假设 dictionary
长度为 $m$,则对字典排序 $O(m \log m)$,之前说每个单词排序的时间是 $O(a \log a)$,所以,所有的排序时间是 $O(m a \log a + m \log m)$,最后查找的时间是线性的, $O(m)$。
比较两个字符串的复杂度不是 O(1) ,所以排序复杂度不是 O(mlogm)
2019-03-16 经 zerol 指出后修订
甚至,由于字典已经有序,甚至可以用二分查找进一步加速。不过由于排序花的时间太长了,所以只能优化常数了。
回过来看我之前的方法,$O(ma)$。
这时候再考虑第 3 问,我当时笔试的时候能想到的只有 2 个优化。
第一,不是取出一个 s in words
去看是不是有 t in dictionary
满足 t.equals(s)
,而是取出一个 t in dictionary
去看是不是有 s in words
满足 t.equals(s)
,在这个过程中,维护一个可以被删除的 words
列表,一旦某一个位置填充了,就立刻将这个 word
从 words
移除,这样就相当于没有被找到的单词会越来越少,仅仅优化了常数,复杂度不变。
第二,对字典和原始列表排序,有一个显然的事实是如果两个单词互为 Anagram
,他们的长度一定相等,所以只需要比较长度相等的部分就可以了。
这里需要自定义一个 Comparator,在调用 Collections.sort()
的时候传入这个 new Comparator() {}
,比较的标准是,谁更短,谁排前面。
遍历字典的时候维护一个 leftPointer
和 rightPointer
,分别表示长度为 $x$ 的单词是在字典的哪个区域。
也是常数优化。
按照 Willow 的方法,一劳永逸地对 words
和 dictionary
按照第 2 问的方法排序。
排序之前用 HashMap 记录原始单词和调换过顺序的单词之间的一一映射。
对字典排序 $O(m \log m)$,之前说每个单词排序的时间是 $O(a \log a)$,所以,所有的排序时间是 $O(m a \log a + m \log m)$,类似地,假设 words
的长度是 $n$,那么,对单词表的排序时间是 $O(n a \log a + n \log n)$。
然后遍历 words
,去找是不是在字典中出现,如果出现,则返回其 Map 的值。
除去排序用的时间,查找的时间只是遍历 words
的时间,如果查找字典用的也是遍历,那么查找部分就是平方的复杂度,如果用的是有序列表的二分查找或者树形结构的查找,那么总的查找复杂度是线性的。
加上排序用的时间,其实能接受。
后来我在写这篇文章的时候,想到,其实 WillowTY 的这个方法有一定的小瑕疵。
假设字典中有 dog
和 god
,由于他们排序以后都是 dgo
,所以,当 dgo
去反向查 Map 的时候,输出的就是只有一个,而不会有另一个。当然题目本来就只要求输出一个,所以这个不算是 BUG。
但是当 WillowTY 说出哈希的时候,我突然有了一个大胆想法。
假设存在一个哈希函数 $f$,计算字符串的哈希值,$f$ 使得两个字符串的哈希值相等,当且仅当这两个字符串中出现的每个字母的数量相同的时候,$f$ 的值相等。例如,$f(dog)=f(god)=f(ogd)$。
显然这样一个函数存在,因为有一个 a[26],对这个数组哈希
2019-03-16 经 zerol 指出后修订
假设存在这样一个函数,那么,只需要重写 equals()
方法为 return hashcode(this) == hashcode(that)
即可,即,只要两个单词的每个字母出现次数一样,那么他们的 hashcode()
和 euqals()
是一样的。
于是,只需要:
for (String word : words) { if (dictionary.contains(word)) // do something }
并且,假设我们把字典做成了一个 K-V 键值对,键是字典中的每一个元素,值是键本身,那么,其实上面的代码就被简化成:
for (String word : words) { return dictionaryMap.get(word); }
这里的含义就是,如果 dictionaryMap
有 word
,由于重写了 hashcode()
和 equals()
,所以说明字典中有一个单词和给定单词有着相同的字母数量,即互为 Anagram
,所以直接返回 dictionaryMap.get(word)
,而如果没有匹配的,那么 get()
本来返回的就是 null
。
当然,这样一个哈希函数可能很难找,这是硬伤,决定了这个方法能不能用。
假设存在的话,就是这么简单。
况且,即便找到,也不建议重写 String 类的 hashcode()
和 euqals()
,风险有点大。
仓促之间,感觉这次笔试非常不尽如人意,匆匆忙忙想好,然后写下来,很多细节都没有考虑到,所以有预感要凉凉。
这 3 道题肯定还有更好的方法,欢迎你留言与我探讨。
放心,有我给你垫底