生成变位词的算法

56

生成变位词的最佳策略是什么?

An anagram is a type of word play, the result of rearranging the letters
of a word or phrase to produce a new  word or phrase, using all the original
letters exactly once; 
ex.
  • Eleven plus two is anagram of Twelve plus one
  • A decimal point is anagram of I'm a dot in place
  • Astronomers is anagram of Moon starers
起初看起来很简单,只需混淆字母并生成所有可能的组合。但是如何高效地生成字典中的单词呢?
我发现了这个页面,在Ruby中解决字谜问题
但你有什么想法呢?

2
预期中放松下来..!如果你需要输出作为原始短语的线索,我不太清楚你怎么可能“生成”它。你肯定只能生成一系列短语/字谜配对,并从中选择吧?一个算法如何理解astronomers=moon starers呢? - robsoft
1
当然生成好的变位词是一个困难的问题,但生成糟糕的变位词则更容易 :) - Vinko Vrsalovic
14个回答

47

这些回答大多效率低下,或者只能给出单词解决方案(没有空格)。 我的解决方案可以处理任意数量的单词,并且非常高效。

你需要使用 trie 数据结构。这里有一个完整的 Python 实现。你只需要将单词列表保存在名为 words.txt 的文件中。你可以在这里尝试 Scrabble 字典单词列表:

http://www.isc.ro/lists/twl06.zip

MIN_WORD_SIZE = 4 # min size of a word in the output

class Node(object):
    def __init__(self, letter='', final=False, depth=0):
        self.letter = letter
        self.final = final
        self.depth = depth
        self.children = {}
    def add(self, letters):
        node = self
        for index, letter in enumerate(letters):
            if letter not in node.children:
                node.children[letter] = Node(letter, index==len(letters)-1, index+1)
            node = node.children[letter]
    def anagram(self, letters):
        tiles = {}
        for letter in letters:
            tiles[letter] = tiles.get(letter, 0) + 1
        min_length = len(letters)
        return self._anagram(tiles, [], self, min_length)
    def _anagram(self, tiles, path, root, min_length):
        if self.final and self.depth >= MIN_WORD_SIZE:
            word = ''.join(path)
            length = len(word.replace(' ', ''))
            if length >= min_length:
                yield word
            path.append(' ')
            for word in root._anagram(tiles, path, root, min_length):
                yield word
            path.pop()
        for letter, node in self.children.iteritems():
            count = tiles.get(letter, 0)
            if count == 0:
                continue
            tiles[letter] = count - 1
            path.append(letter)
            for word in node._anagram(tiles, path, root, min_length):
                yield word
            path.pop()
            tiles[letter] = count

def load_dictionary(path):
    result = Node()
    for line in open(path, 'r'):
        word = line.strip().lower()
        result.add(word)
    return result

def main():
    print 'Loading word list.'
    words = load_dictionary('words.txt')
    while True:
        letters = raw_input('Enter letters: ')
        letters = letters.lower()
        letters = letters.replace(' ', '')
        if not letters:
            break
        count = 0
        for word in words.anagram(letters):
            print word
            count += 1
        print '%d results.' % count

if __name__ == '__main__':
    main()
程序运行时,单词将被加载到内存中的Trie数据结构中。之后,只需输入您想要查找的字母,它就会打印结果。它只会显示使用所有输入字母的结果,不会显示长度较短的结果。
为了减少输出结果的数量,程序会过滤掉短单词。您可以自由调整“MIN_WORD_SIZE”的设置。请记住,如果“MIN_WORD_SIZE”为1,则仅使用“astronomers”作为输入就有233,549个结果。也许您可以找到一个更短的单词列表,其中只包含常见的英语单词。
另外,缩写"I'm"(来自您的示例之一)除非您将"im"添加到字典并将“MIN_WORD_SIZE”设置为2,否则不会出现在结果中。
获取多个单词的技巧是:每当在搜索中遇到完整的单词时,请跳回到Trie数据结构的根节点。然后,继续遍历Trie直到所有字母都被使用。

我的一个变位词程序只有16818个单词长度为1的astronomers的变位词,因为它不输出排列。在我的AMD Sempron计算机上运行时间约为2秒,生成结果。我将结果保存到文件中,这比将大量的单词输出到文本控制台更有用。我不使用树结构,而是使用纯文本和递归匹配从按字母顺序排序的键散列的字典键。 - Tony Veijalainen
我已经在DaniWeb上发布了我的先前代码,网址为http://www.daniweb.com/software-development/python/code/393153/multiword-anagrams-by-recursive-generator。 - Tony Veijalainen
2
错误报告:如果单词列表有两个条目:“foobar”和“foob”(按顺序),那么代码片段将无法找到“boof”的变位词。只有当您反转单词列表的顺序时,它才会正确返回“foob”。我认为可以通过在第一个“for”循环中放置另一个if子句来修复此问题,但我必须把这个任务留给懂Python的人。 - Martin J.H.
你能用几句话描述一下你的算法吗?我特别想知道的是,在你确定某个词典单词可以由输入的某些字母组成后,接下来会发生什么。我理解我们会检查剩余的字符是否可以用来组成其他单词。我们如何知道我们已经耗尽了所有可能性? - MadPhysicist
@MadPhysicist trie结构允许您特别利用英语中很多单词相同但以不同的结尾。因此,如果您的字母输入包含“q”,“u”但不包含“i”,那么仅需3个步骤,我们就可以消除“quick”,“quickly”,“quicker”,“quicken”等单词。因此,这是一种将单词实用地分组为彼此子集的结构。我怀疑还有另一种数据结构,它也允许您消除所有带有字母“i”的单词,并且不关心字母顺序,但不确定如何保持其可处理性大小。 - Adamantish
@MadPhysicist 它首先找到可能在完整的变位词句子中的所有单词。它通过测试顶部节点的所有子节点,然后使用递归来依次跟随那些通过的子节点的所有孙子节点等等来实现这一点... 这耗尽了第一个单词的所有可能性。对于每个可能的第一个单词,它然后重复整个过程以获得另一个带有剩余字母的单词,并为每个单词再次进行循环,直到所有字母都用完为止。正如您所想象的那样,即使是这样也无法很好地处理长输入字符串。 - Adamantish

20

对于字典中的每个单词,按字母顺序排序。因此,“foobar”变为“abfoor”。

然后输入乱序单词时,也要将其字母排序,然后查找字典。它就像哈希表一样快!

对于多个单词,可以通过组合排序后的字母来进行搜索,同时在排序过程中进行排序。仍然比生成所有组合要快得多

(有关更多优化和详细信息,请参见注释)


正如我在帖子中所说,对于多个单词,您可以检查所有的配对、三元组等,在每种情况下组合字母并排序(并使用归并排序使操作更快!),并针对该组合进行测试。您甚至可以更聪明一些,例如使用所有字符的位字段... - Jason Cohen
首先对字符进行排序并不起作用。这可能会给你一个或两个,但你需要测试所有的组合,然后拒绝它们。一种方法是生成所有可能的三元组,然后将它们与词典中所有单词的前三个字母进行比较。 - Mats Fredriksson
1
排序确实有帮助——除了使用.NET HashSet<T>或Python set()之外,这是将有序字母列表映射到无序列表的最简单方法。 - Jimmy
1
好的,说得也有道理,这样做可以加快速度,因为“foobar”和“barfoo”的变位词将解析为相同的结果集,但是如果您只从一个句子中获取所有变位词,则排序对您没有帮助,因为您需要考虑所有可用字符。 - Mats Fredriksson
@Jason,位运算不适用于此情况,因为一个字母可能在字符串中出现多次。如果使用OR,这些重复的字母将不会被计算,如果使用加法,则会发生冲突。 - Zach Langley
显示剩余5条评论

8
请参考华盛顿大学CSE系的这个任务。简单来说,您有一个数据结构,其中只包含单词中每个字母的计数(如果是ascii,则使用数组,如果需要支持unicode,请升级为map)。您可以减去两个这样的字母集;如果计数为负数,则知道一个单词不能是另一个单词的变位词。

处理计数使组合问题变得简单。您拥有搜索短语的映射,并将其与具有相同计数总和的单词映射的组合匹配。这是一种优雅的解决方案。 - Osama Al-Maadeed

5

预处理:

构建一棵字典树,每个叶子节点表示一个已知单词,按照字母顺序进行键入。

搜索时:

将输入字符串视为一个多重集合。通过深度优先搜索遍历索引字典树以查找第一个子单词。在每个分支处,您可以询问:我的输入剩余部分中是否包含字母x?如果您有一个良好的多重集合表示,这应该是一个常数时间查询(基本上)。

一旦您获得了第一个子单词,您可以保留剩余的多重集合,并将其视为查找该变位词的其余部分的新输入(如果存在)。

使用备忘录增强此过程,以便更快地查找常见的剩余多重集合。

这很快 - 每次字典树遍历都保证给出一个实际的子单词,并且每次遍历的时间复杂度与子单词长度成线性关系(通过编码标准,子单词通常非常小)。但是,如果您真的想要更快的速度,您可以在预处理中包含所有n-gram,其中n-gram是任何连续n个单词的字符串。当然,如果W =#单词,则索引大小将从O(W)跳至O(W ^ n)。根据您的字典大小,n = 2可能是现实的。


3

以下是 Jason Cohen 建议的 Java 工作解决方案,它的表现比使用 trie 的解决方案要好一些。以下是一些主要要点:

  • 只加载给定单词集合中的子集单词的字典
  • 字典将是以排序后的单词为键和实际单词集合为值的哈希表(如Jason所建议的)
  • 迭代每个来自字典键的单词,并进行递归向前查找,以查看是否找到任何有效的变位词
  • 仅进行向前查找,因为已经遍历过的所有单词的变位词应该已经被找到了
  • 合并与键相关联的所有单词,例如,如果“enlist”是要查找变位词的单词,并且要合并的一组键是[ins]和[elt],键[ins]的实际单词是[sin]和[ins],键[elt]的实际单词是[let],则最终的合并单词集将是[sin,let]和[ins,let],这将成为我们最终的变位词列表的一部分
  • 还要注意,此逻辑将仅列出唯一的单词集合,即“eleven plus two”和“two plus eleven”将相同,并且仅会在输出中列出一个

以下是找到变位词键集合的主递归代码:

// recursive function to find all the anagrams for charInventory characters
// starting with the word at dictionaryIndex in dictionary keyList
private Set<Set<String>> findAnagrams(int dictionaryIndex, char[] charInventory, List<String> keyList) {
    // terminating condition if no words are found
    if (dictionaryIndex >= keyList.size() || charInventory.length < minWordSize) {
        return null;
    }

    String searchWord = keyList.get(dictionaryIndex);
    char[] searchWordChars = searchWord.toCharArray();
    // this is where you find the anagrams for whole word
    if (AnagramSolverHelper.isEquivalent(searchWordChars, charInventory)) {
        Set<Set<String>> anagramsSet = new HashSet<Set<String>>();
        Set<String> anagramSet = new HashSet<String>();
        anagramSet.add(searchWord);
        anagramsSet.add(anagramSet);

        return anagramsSet;
    }

    // this is where you find the anagrams with multiple words
    if (AnagramSolverHelper.isSubset(searchWordChars, charInventory)) {
        // update charInventory by removing the characters of the search
        // word as it is subset of characters for the anagram search word
        char[] newCharInventory = AnagramSolverHelper.setDifference(charInventory, searchWordChars);
        if (newCharInventory.length >= minWordSize) {
            Set<Set<String>> anagramsSet = new HashSet<Set<String>>();
            for (int index = dictionaryIndex + 1; index < keyList.size(); index++) {
                Set<Set<String>> searchWordAnagramsKeysSet = findAnagrams(index, newCharInventory, keyList);
                if (searchWordAnagramsKeysSet != null) {
                    Set<Set<String>> mergedSets = mergeWordToSets(searchWord, searchWordAnagramsKeysSet);
                    anagramsSet.addAll(mergedSets);
                }
            }
            return anagramsSet.isEmpty() ? null : anagramsSet;
        }
    }

    // no anagrams found for current word
    return null;
}

你可以从这里分叉存储库并进行操作。可能有很多优化我没想到的,但是代码可以工作,并且确实找到了所有的变位词。

3

在程序化变位词方面的开创性工作之一是由迈克尔·莫顿(Mr. Machine Tool)使用一个名为Ars Magna的工具完成的。这里有一篇基于他的工作的轻松文章


3

这里是我新颖的解决方案,点击此处

Jon Bentley的《Programming Pearls》中有一道关于查找单词的变位词(anagrams)问题。

给定一个包含英文单词的字典,查找所有的变位词。例如,“pots”,“stop”和“tops”都是相互变位词,因为每个单词都可以通过重新排列字母来形成另一个单词。

我思考了一下,发现可以通过获得要搜索单词的签名,并将其与字典中的所有单词进行比较来解决该问题。所有单词的变位词应该具有相同的签名。但如何实现呢?我的想法是使用算术基本定理:

算术基本定理规定:

每个正整数(除了数字1)都可以表示为一个或多个质数的积,且唯一。

因此,我们可以使用前26个质数的数组。对于单词中的每个字母,我们获取相应的质数,例如A = 2,B = 3,C = 5,D = 7...然后计算输入单词的乘积。接下来,我们对字典中的每个单词都进行这样的计算,如果一个单词与输入单词匹配,则将其添加到结果列表中。

性能相当可观。对于一个包含479828个单词的字典,获取所有变位词需要160毫秒。这大约是每个单词0.0003毫秒,或者每个单词0.3微秒。该算法的复杂度似乎为O(mn)或~O(m),其中m为字典的大小,n为输入单词的长度。

以下是代码:

package com.vvirlan;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Scanner;

public class Words {
    private int[] PRIMES = new int[] { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73,
            79, 83, 89, 97, 101, 103, 107, 109, 113 };

    public static void main(String[] args) {
        Scanner s = new Scanner(System.in);
        String word = "hello";
        System.out.println("Please type a word:");
        if (s.hasNext()) {
            word = s.next();
        }
        Words w = new Words();
        w.start(word);
    }

    private void start(String word) {
        measureTime();
        char[] letters = word.toUpperCase().toCharArray();
        long searchProduct = calculateProduct(letters);
        System.out.println(searchProduct);
        try {
            findByProduct(searchProduct);
        } catch (Exception e) {
            e.printStackTrace();
        }
        measureTime();
        System.out.println(matchingWords);
        System.out.println("Total time: " + time);
    }

    private List<String> matchingWords = new ArrayList<>();

    private void findByProduct(long searchProduct) throws IOException {
        File f = new File("/usr/share/dict/words");
        FileReader fr = new FileReader(f);
        BufferedReader br = new BufferedReader(fr);
        String line = null;
        while ((line = br.readLine()) != null) {
            char[] letters = line.toUpperCase().toCharArray();
            long p = calculateProduct(letters);
            if (p == -1) {
                continue;
            }
            if (p == searchProduct) {
                matchingWords.add(line);
            }
        }
        br.close();
    }

    private long calculateProduct(char[] letters) {
        long result = 1L;
        for (char c : letters) {
            if (c < 65) {
                return -1;
            }
            int pos = c - 65;
            result *= PRIMES[pos];
        }
        return result;
    }

    private long time = 0L;

    private void measureTime() {
        long t = new Date().getTime();
        if (time == 0L) {
            time = t;
        } else {
            time = t - time;
        }
    }
}

2

几个月前,我使用以下的方式计算字谜:

  • 为字典中的每一个单词计算一个“代码”:创建从字母到质数的查找表,例如以 ['a',2] 开始以 ['z',101] 结束。作为预处理步骤,通过在查找表中查找它所组成的每个字母的质数来计算字典中每个单词的代码,并将它们相乘。为了进行后续查询,创建代码映射到单词的多重映射。

  • 按照上述方式计算输入单词的代码。

  • 对于多重映射中的每个代码,计算 codeInDictionary % inputCode。如果结果为 0,则已经找到一个字谜,可以查找相应的单词。这也适用于由两个或多个单词组成的字谜。

希望这有所帮助。


1
为什么要用这么复杂的字典...质数、预处理、多重映射?只需要将字典键设置为排序字符串即可。 - IgorGanapolsky
1
请参见:https://www.scribd.com/document/284697348/A-Fast-Data-Structure-for-Anagrams - Brian Clapper
@IgorGanapolsky 因为仅凭这个功能只能给你单词的变位词。例如“Eleven plus two”的输出是不可能的。 - Adamantish

2

Programming Pearls这本书是Jon Bentley写的,非常好地涵盖了这种东西。必读。


不知道为什么你被评分降低了,但《编程珠玑》的第二章详细介绍了一个程序的实现,该程序可以在给定单词字典的情况下找到所有的变位词集合。绝对值得一看。按照以下方式编译和运行代码: ./sign <somedictionary| sort | ./squash - vinc456

1

我的看法是:

你需要构建一个将无序字母集映射到单词列表的表格,即遍历字典,这样你就会得到,比如说:

lettermap[set(a,e,d,f)] = { "deaf", "fade" }

然后从您的起始单词开始,找到字母集:

 astronomers => (a,e,m,n,o,o,r,r,s,s,t)

然后循环遍历该集合的所有分区(这可能是最技术性的部分,只需生成所有可能的分区),并查找该字母集的单词。

编辑:嗯,这基本上就是Jason Cohen发布的内容。

编辑:此外,问题的评论提到生成“好”的变位词,例如示例 :). 在构建了所有可能的变位词列表之后,运行它们通过WordNet并找到与原始短语在语义上接近的词汇 :)


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