寻找给定集合中最长的单词

27

这是一个谷歌面试题,我发现大多数在线答案使用HashMap或类似的数据结构。如果可能的话,我想尝试使用Trie来解决问题。有人可以给我一些提示吗?

以下是问题描述: 给定一个字典,格式为每行包含一个单词的文件。例如,

abacus 
deltoid 
gaff 
giraffe 
microphone 
reef 
qar 

你还会得到一组字母,例如:

{a, e, f, f, g, i, r, q}. 

任务是在字典中找到能够用给定字母集合拼出的最长单词。例如,对于上述示例值,正确答案是“giraffe”。(请注意,“reef”不是可能的答案,因为字母集合仅包含一个“e”)。

首选Java实现。


我问了一个类似的问题,但是使用的是Python语言。http://stackoverflow.com/questions/1192881/python-to-find-longest-word - Niklas Rosencrantz
7
@NickRosencrantz,我不认为你理解了这个问题。 - i Code 4 Food
构建trie树难还是搜索trie树难?(有维基百科文章介绍如何构建trie树。对于搜索trie树,您必须递归地检查所有包含您仍然可以使用的字母的分支(例如,当您经过一个分支时,传递给自己一个字符列表的副本,减去您所取的字符),将trie树中的所有叶子节点(例如完全形成的单词)添加到列表中。当列表返回时,您可以在其中查找最长的单词。) - Patashu
你的输入示例已排序。我们可以假设它始终如此吗?这个文件有多大?您需要为同一词典回答许多查询还是只有一个? - meriton
1
@meriton - 我认为你会发现,文件是否排序并没有影响。此外,我认为我们假设1)文件“很大,但不太大,无法创建内存数据结构”,以及2)我们进行多个查询。比较解决方案的标准之一是内存数据结构需要多大。 - Stephen C
这是一个名为des chiffres et des lettres的游戏。它是法国最古老的电视游戏。 - MatthieuBizien
9个回答

13

没有Java代码,你可以自己想出。

假设我们需要多次执行此操作,以下是我会做的:

  • 首先为字典中每个单词创建“签名”,由26位组成,其中bit[letter]设置为当且仅当该单词包含一个或多个letter。这些签名可以编码为Java int

  • 然后创建一个映射,将签名映射到具有该签名的单词列表。

使用预计算映射进行搜索:

  • 创建要查找单词的字母集的签名。

  • 然后迭代映射的键,寻找其中(key & (~signature) == 0)的键。这给出了一个短列表,“可能性”不包含任何不在所需字母集中的字母。

  • 在短列表中遍历,寻找具有每个所需字母的正确数量的单词,并记录最长的匹配项。


注:

  1. 虽然主要搜索大致上是对字典中单词数的O(N)级别,但测试非常便宜。

  2. 这种方法的优点是需要一个相对较小的内存数据结构,并且(很可能)具有良好的局部性。这很有助于更快的搜索。


以下是加速上述O(N)搜索步骤的想法。

从上面的签名图开始,为所有包含特定字母对的单词创建(预计算)导出地图;即一个包含AB的单词,一个包含AC,BC ...和YZ。然后,如果您正在寻找包含(比方说)P和Q的单词,则只需扫描PQ导出地图。这将通过更多的内存成本来将O(N)步骤大约减少26^2…。
这可以扩展到3个或更多字母,但不利之处在于内存使用量会爆炸式增长。
另一个潜在的调整方法是(以某种方式)偏向选择初始字母对,使其倾向于不经常出现的字母/对。但这会增加前期开销,可能大于从较短列表中搜索获得的(平均)节省。

为什么要使用(key & (~signature) == 0)?你确定你不是想用(key & signature != 0)吗?考虑这样一种情况,你的整个字典只有一个单词00000000000000000000000000001111,它代表着"ABCD",而你的搜索签名是['A','B']或者00000000000000000000000000000011。在这种情况下,你永远无法满足条件(key & (~signature) == 0),因此也永远无法找到答案,而事实上显然的答案就是"ABCD"(你的字典中唯一的单词)。 - The111
我认为这是一个不错的想法,但是OP明确要求使用Trie来解决问题,对吧? - Frerich Raabe
1
@The111 - 如果我正在寻找可以仅使用 {'A','B'} 拼写的单词,那么 "ABCD" 不是一个解决方案。你确定你理解了这个问题吗? - Stephen C
@StephenC:也许 Trie 并不是这个问题的最佳解决方案,但 OP 已经指出他在网上找到了各种解决方案(基于哈希映射等)。他明确要求使用 Trie 如何实现。我想你可以说我喜欢您的解决方案,只是这个问题不适合它。;-) - Frerich Raabe
哎呀,我一个小时前看的时候还理解了。回来后不知怎么把它搞混了(可能是因为昨晚我在研究另一种子序列问题时忘记了你上面提到的“只是”部分),这就解释了为什么我之前想出的解决方案似乎不再起作用了。但现在我发现我的解决方案仍然有效,这意味着我很快会发布它。 :-) - The111
显示剩余3条评论

4
首先,好问题。面试官想看到你如何解决问题。在这种类型的问题中,你需要分析问题并仔细选择一个数据结构。
在这种情况下,我想到了两个数据结构:HashMaps 和 Tries。HashMaps 不适合,因为你没有完整的键来查找(你可以使用基于映射的倒排索引,但是你说你已经找到了这些解决方案)。你只有部分-这就是 Trie 最适合的地方。
所以,Trie 的想法是,在遍历树时,你可以忽略不在字典中的字符分支。
在你的情况下,树看起来像这样(我省略了非分支路径的分支):
*
a
bacus
d
deltoid
g
a
gaff
i
giraffe m
microphone r reef
q
qar 因此,在这个 Trie 的每个级别上,我们查看当前节点的子节点,并检查子节点的字符是否在我们的字典中。
如果是:我们深入该树并从我们的字典中删除子元素的字符。
这将继续进行,直到你到达叶子节点(没有孩子了),在这里你知道这个单词包含这个字典中的所有字符。这是一个可能的候选者。现在我们想回到树中,直到我们找到另一个可以比较的匹配项为止。如果最新发现的匹配项更小,则放弃它,如果更长,则现在是我们可能的最佳匹配候选者。
有一天,递归将结束,你会得到所需的输出。
请注意,如果存在多个最长的单词,这只适用于单个最长的单词,否则你必须返回候选列表(这是面试中未知的部分,你需要问面试官想看到什么样的解决方案)。
因此,你需要 Java 代码,这里是带有简单 Trie 和单个最长单词版本的代码:
public class LongestWord {

  class TrieNode {
    char value;
    List<TrieNode> children = new ArrayList<>();
    String word;

    public TrieNode() {
    }

    public TrieNode(char val) {
      this.value = val;
    }

    public void add(char[] array) {
      add(array, 0);
    }

    public void add(char[] array, int offset) {
      for (TrieNode child : children) {
        if (child.value == array[offset]) {
          child.add(array, offset + 1);
          return;
        }
      }
      TrieNode trieNode = new TrieNode(array[offset]);
      children.add(trieNode);
      if (offset < array.length - 1) {
        trieNode.add(array, offset + 1);
      } else {
        trieNode.word = new String(array);
      }
    }    
  }

  private TrieNode root = new TrieNode();

  public LongestWord() {
    List<String> asList = Arrays.asList("abacus", "deltoid", "gaff", "giraffe",
        "microphone", "reef", "qar");
    for (String word : asList) {
      root.add(word.toCharArray());
    }
  }

  public String search(char[] cs) {
    return visit(root, cs);
  }

  public String visit(TrieNode n, char[] allowedCharacters) {
    String bestMatch = null;
    if (n.children.isEmpty()) {
      // base case, leaf of the trie, use as a candidate
      bestMatch = n.word;
    }

    for (TrieNode child : n.children) {
      if (contains(allowedCharacters, child.value)) {
        // remove this child's value and descent into the trie
        String result = visit(child, remove(allowedCharacters, child.value));
        // if the result wasn't null, check length and set
        if (bestMatch == null || result != null
            && bestMatch.length() < result.length()) {
          bestMatch = result;
        }
      }
    }
    // always return the best known match thus far
    return bestMatch;
  }

  private char[] remove(char[] allowedCharacters, char value) {
    char[] newDict = new char[allowedCharacters.length - 1];
    int index = 0;
    for (char x : allowedCharacters) {
      if (x != value) {
        newDict[index++] = x;
      } else {
        // we removed the first hit, now copy the rest
        break;
      }
    }
    System.arraycopy(allowedCharacters, index + 1, newDict, index,
        allowedCharacters.length - (index + 1));

    return newDict;
  }

  private boolean contains(char[] allowedCharacters, char value) {
    for (char x : allowedCharacters) {
      if (value == x) {
        return true;
      }
    }
    return false;
  }

  public static void main(String[] args) {
    LongestWord lw = new LongestWord();
    String longestWord = lw.search(new char[] { 'a', 'e', 'f', 'f', 'g', 'i',
        'r', 'q' });
    // yields giraffe
    System.out.println(longestWord);
  }

}

我建议阅读这本书《Cracking the Coding Interview: 150 Programming Questions and Solutions》,它会引导你决策和构建那些专门用于面试问题的算法。


我觉得这个问题的所有答案都被踩了,可能是有人今天心情不好。;-) - Frerich Raabe
@FrerichRaabe 或许这是那种情况之一,即有人不喜欢这个问题,因此决定将答案投票为负。 - Bernhard Barker
@ThomasJungblut 算法不正确,请尝试 Arrays.asList("abacus", "deltoid", "gaff", "gira", "giraffe", "microphone", "reef", "qar");lw.search(new char[] { 'a', 'g', 'i', 'r', 'q' });,应该返回 gira 而不是 qar - CSnerd

3
我怀疑一个基于 Trie 的实现不会非常节省空间,但它将非常适合并行化,因为您可以并行地进入树的所有分支,并收集可以使用给定字母集从每个顶部分支到达的最深节点。最终,您只需收集所有最深的节点并选择最长的一个。
我会从这个算法开始(抱歉,只有伪代码),它不尝试并行化,只是使用普通的递归(和回溯)来查找最长匹配:
TrieNode visitNode( TrieNode n, LetterCollection c )
{
    TreeNode deepestNode = n;
    for each Letter l in c:
        TrieNode childNode = n.getChildFor( l );

        if childNode:
            TreeNode deepestSubNode = visitNode( childNode, c.without( l ) );
            if deepestSubNode.stringLength > deepestNode.stringLength:
                deepestNode = deepestSubNode;
   return deepestNode;
}

即:此函数应从trie的根节点开始,使用整个给定字母集合。对于集合中的每个字母,您尝试查找子节点。如果有一个子节点,则递归并从集合中删除该字母。在某个时刻,您的字母集合将为空(最好情况下,所有字母都被消耗 - 您实际上可以立即退出而不继续遍历trie),或者没有更多的子节点包含任何剩余字母 - 在这种情况下,您删除节点本身,因为那是您的“最长匹配项”。
如果更改递归步骤以便并行访问所有子项,并收集结果 - 并选择最长的结果并返回该结果,则可以很好地并行化此过程。

1
也许在将单词添加到 Trie 树之前对每个单词进行排序会更有效率?例如,插入“dorw”而不是“word”。这可能会使查询给定单词的速度更快,因为不需要回溯。 - Peter de Rivaz
@PeterdeRivaz:我可能漏掉了什么(我今天刚喝了第一杯咖啡),但如果我在“香蕉”之前插入“苹果”,我仍然需要回溯到树的“苹果”分支,才能进入“香蕉”,不是吗?我不明白按任何特定顺序插入这些单词如何避免回溯。 - Frerich Raabe
你正在对单词中的字母进行排序,而不是单词的顺序。例如,你会插入“aelpp”和“aaabnn”,而不是“apple”和“banana”。但是,如果你不必使用集合中的每个字母,仍然需要回溯。 - Peter de Rivaz

-1
免责声明:这不是一种trie解决方案,但我仍然认为这是一个值得探索的想法。
创建一种只考虑单词中的字母而不考虑其顺序的哈希函数(除了排列的情况外,不应该存在碰撞)。例如,ABCD和DCBA会生成相同的哈希值(但ABCDD不会)。使用链式法解决碰撞问题,生成包含字典中每个单词的哈希表(另一方面,除非您有严格要求找到“所有”最长的单词而不仅仅是一个,否则可以忽略碰撞,也就是排列,并放弃整个链接过程)。
现在,如果您的搜索集长度为4个字符,例如A、B、C、D,那么作为一种简单的搜索方法,您可以检查以下哈希值是否已经包含在字典中:
hash(A), hash(B), hash(C), hash(D) // 1-combinations
hash(AB), hash(AC), hash(AD), hash(BC), hash(BD), hash(CD) // 2-combinations
hash(ABC), hash(ABD), hash(ACD), hash(BCD) // 3-combinations
hash(ABCD) // 4-combinations

如果按照这个顺序搜索哈希值,你找到的最后一个匹配项将是最长的。

这最终会导致运行时间取决于搜索集的长度而不是字典的长度。如果M是搜索集中字符的数量,则哈希查找的数量是总和M choose 1 + M choose 2 + M choose 3 + ... + M choose M,这也是搜索集的幂集的大小,因此它是O(2^M)。乍一看,这听起来真的很糟糕,因为它是指数级的,但是为了让事情更清楚,如果您的搜索集大小为10,则只会有大约1000次查找,这可能比实际现实场景中的字典大小要小得多。当M = 15时,我们获得32000个查找,而实际上,有多少英语单词的长度超过15个字符呢?

然而,我可以想到两种(备选)优化方法:

1)首先搜索更长的匹配项,例如M组合,然后是(M-1)组合等。一旦找到匹配项,您就可以停止!很可能您只覆盖了搜索空间的一小部分,最坏的情况可能只有一半。

2) 首先搜索较短的匹配项(1个组合,2个组合等)。假设在第2级出现了一个错误(例如,字典中没有仅由AB组成的字符串)。使用一个辅助数据结构(比如位图),可以检查字典中的任何单词是否部分地AB组成(与主哈希表相反,主哈希表检查的是完全的组合)。如果在辅助位图上也出现了错误,那么就知道可以跳过包含AB的所有更高级别的组合(即可以跳过hash(ABC)hash(ABD)hash(ABCD),因为没有单词同时包含AB)。这利用了Apriori原则,并且随着M的增长和错误变得更频繁,可以大大减少搜索空间。编辑:我意识到我所抽象掉的与“辅助数据结构”相关的细节是重要的。当我对这个想法思考得更多时,我意识到它倾向于作为一个子过程进行完整的字典扫描,这违背了整个方法的初衷。不过,似乎还应该有一种方法在这里使用Apriori原则。

-1

我认为以上的答案都忽略了关键点。我们有一个27维的空间,第一维是长度,其余的是每个字母的坐标。在这个空间中,我们有点,这些点就是单词。一个单词的第一个坐标是它的长度。其他坐标是每个字母出现的次数。例如单词abacus, deltoid, gaff, giraffe, microphone, reef, qar, abcdefghijklmnopqrstuvwxyz的坐标如下:

[3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[6, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0]
[7, 0, 0, 0, 2, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
[4, 1, 0, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[7, 1, 0, 0, 0, 1, 2, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[10, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 2, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[4, 0, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[3, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[26, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

一个带有坐标的集合的良好结构是R树R*-树。给定你的集合[x0, x1, ..., x26],你需要查询包含最多xi个字母的所有单词,对于每个字母。你的搜索时间复杂度为O(log N),其中N是字典中单词的数量。然而,你不想查看与查询匹配的所有单词中最长的单词。这就是为什么第一维很重要。

你知道最长单词的长度在0到X之间,其中X=sum(x_i, i=1..26)。你可以从X到1进行迭代搜索,但也可以使用二分查找算法来查询长度。你将数组的第一维用作查询。你从a=X开始到b=X/2。如果至少有一个匹配项,你就从a到(a+b)/2进行搜索,否则你就从b到b-(a-b)/2=(3b-a)/2进行搜索。你重复这个过程直到b-a=1。现在你已经得到了最大长度和所有与该长度匹配的结果。

这个算法的时间复杂度比上面的算法要高效得多。时间复杂度为O(ln(N)×ln(X))。具体实现取决于你使用的R-tree库。


-2

Groovy(几乎就是Java):

def letters = ['a', 'e', 'f', 'f', 'g', 'i', 'r', 'q']
def dictionary = ['abacus', 'deltoid', 'gaff', 'giraffe', 'microphone', 'reef', 'qar']
println dictionary
    .findAll{ it.toList().intersect(letters).size() == it.size() }
    .sort{ -it.size() }.head()

选择用什么类型的集合来保存字典对算法来说并不重要。如果你需要实现一个 trie,那就另当别论了。否则,只需从适当的库中创建一个来保存数据即可。据我所知,Java 和 Groovy 的标准库中都没有这样的库。


这样的findAll的复杂度是多少?更不用说对一个列表进行排序,而你只需要最大元素,这并不是在Google面试中取得成功的最佳方式。 - i Code 4 Food
@Arthur:在问题陈述中没有提到这些限制条件,因此我采取了我能想到的最简单和最直接的方法。 - Ryan Stewart
字典容器的选择并不是无关紧要的。Trie树可能会浪费空间,但可以实现高度并行化的解决方案。 - Frerich Raabe
@FrerichRaabe:我说的是与算法无关。它对整个问题来说是完全相关的,我同意你所说的原因是解决问题的好选择,我的算法也是如此。我的观点是,除非你被要求写它,否则已经有 trie 实现可以使用,并且可以完成上述操作。 - Ryan Stewart

-2

我尝试用C++编写这个问题的代码,其中我创建了自己的哈希键,并使用给定字符的所有组合。

从最大长度到1遍历这些输入字符的所有组合

这是我的解决方案

#include "iostream"
#include <string>

using namespace std;

int hash_f(string s){
        int key=0;
        for(unsigned int i=0;i<s.size();i++){
           key += s[i];
        }
        return key;
}

class collection{

int key[100];
string str[10000];

public: 
collection(){
    str[hash_f( "abacus")] = "abacus"; 
    str[hash_f( "deltoid")] = "deltoid"; 
    str[hash_f( "gaff")] = "gaff"; 
    str[hash_f( "giraffe")] = "giraffe"; 
    str[hash_f( "microphone")] = "microphone"; 
    str[hash_f( "reef")] = "reef"; 
    str[hash_f( "qar")] = "qar"; 
}

string  find(int _key){
    return str[_key];
}
};

string sub_str(string s,int* indexes,int n ){
    char c[20];
    int i=0;
    for(;i<n;i++){
        c[i] = s[indexes[i]];
    }
    c[i] = 0;
    return string(c);
}

string* combination_m_n(string str , int m,int n , int& num){

    string* result = new string[100];
    int index = 0;

    int * indexes = (int*)malloc(sizeof(int)*n);

    for(int i=0;i<n;i++){
        indexes[i] = i; 
    }

    while(1){
            result[index++] = sub_str(str , indexes,n);
            bool reset = true;
            for(int i=n-1;i>0;i--)
            {
                if( ((i==n-1)&&indexes[i]<m-1) ||  (indexes[i]<indexes[i+1]-1))
                {
                    indexes[i]++;
                    for(int j=i+1;j<n;j++) 
                        indexes[j] = indexes[j-1] + 1;
                    reset = false;
                    break;
                }
            }
            if(reset){
                indexes[0]++;
                if(indexes[0] + n > m) 
                    break;
                for(int i=1;i<n;i++)
                    indexes[i] = indexes[0]+i;
            }
    }
    num = index;
    return result;
}


int main(int argc, char* argv[])
{
    string str = "aeffgirq";
    string* r;
    int num;

    collection c;
    for(int i=8;i>0;i--){
        r = combination_m_n(str, str.size(),i ,num);
        for(int i=0;i<num;i++){
            int key = hash_f(r[i]);
             string temp = c.find(key);
            if(  temp != "" ){
                  cout << temp ;
            }
        }
    }
}

-2

首先要注意的是,您可以完全忽略字母顺序。

有一个类似于 trie 的结构如下:

  • 从根节点开始,最多有 26 个子节点,每个子节点代表一个字母。
  • 对于每个非根节点,其子节点数等于大于或等于该节点字母的字母数。
  • 每个节点都存储可以使用路径中的字母(恰好)制作的所有单词。

按照以下方式构建 trie:

对于每个单词,对其字母进行排序,并将排序后的字母插入到 trie 中(通过从根创建这些字母的路径),在此过程中创建所有所需的节点。并将单词存储在最终节点处。

如何进行查找:

对于给定的一组字母,查找所有字母的子集(其中大多数希望不存在),并输出遇到的每个节点的单词。

复杂度:

O(k!),其中k是提供的字母数量。哎呀!但幸运的是,在trie中单词越少,路径就越少,这将花费更少的时间。而k提供的字母数量(应该相对较小),而不是trie中单词的数量。

实际上,它可能更接近于O(min(k!,n)),看起来好多了。请注意,如果您提供了足够的字母,您将不得不查找所有单词,因此在最坏情况下,您必须进行O(n)的工作,因此在最坏情况下的复杂度方面,您无法做得更好。

示例:

输入:

aba
b
ad
da
la
ma

已排序:

aab
b
ad
ad
al
am

字典树:(仅显示非空子节点)

     root
     /  \
    a    b
 /-/|\-\
a b d l m
|
b

adb的查找:

  • 从根节点开始...
  • 转到子节点a
    • 转到子节点b
      • 没有子节点,返回
    • 转到子节点d
      • 输出节点上的单词- adda
      • 没有子节点,返回
    • 所有字母已处理,返回
  • 转到子节点b
    • 输出节点上的单词- b
    • 不寻找a子节点,因为只有 >= b 的子节点存在
    • 没有d子节点,返回
  • 没有d子节点,停止

2
有人可以告诉我为什么这个被踩了两次吗?这很可能是这里介绍的最快算法之一,而且它不会浪费空间。 - Bernhard Barker

-2

假设有一个大型字典和一个不到10或11个成员的字母集(例如给定的示例),最快的方法是构建一个包含可能由这些字母组成的单词的树,然后将单词列表与该树进行匹配。换句话说,您的字母树的根节点有七个子节点:{a,e,f,g,i,r,q}。 "a" 的分支有六个子节点 {e,f,g,i,r,q},等等。因此,该树包含了可以用这些字母组成的每个可能的单词。

遍历列表中的每个单词并将其与树进行匹配。如果匹配的长度最大(使用所有字母),则完成。如果单词长度小于最大值,但比以前匹配的任何单词都长,请记住它,这是“到目前为止最长的单词”(LWSF)。忽略长度小于或等于 LWSF 的任何单词。同时,忽略长度超过字母列表长度的任何单词。

一旦构建了字母树,这就是一个线性时间算法,因此只要单词列表显着大于字母树,它就是最快的方法。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接