如何优化这段Python代码以生成所有与给定单词距离为1的单词?

32

分析显示这是我编写的一个小文字游戏中最慢的代码段:

def distance(word1, word2):
    difference = 0
    for i in range(len(word1)):
        if word1[i] != word2[i]:
            difference += 1
    return difference

def getchildren(word, wordlist):
    return [ w for w in wordlist if distance(word, w) == 1 ]

备注:

  • distance() 被调用了超过500万次,其中大部分是从 getchildren 中调用的,它的作用是获取单词列表中与word只差一个字母的所有单词。
  • 单词列表已经预先过滤,只包含与word具有相同字母数的单词,因此可以保证word1word2拥有相同的字符数。
  • 我对Python还比较新(三天前开始学习),所以也欢迎对命名约定或其他代码风格方面的建议。
  • 对于单词列表,请使用“2+2lemma.txt”文件下载12dict单词列表

结果:

谢谢大家的建议,通过不同的建议组合,现在我的程序的运行速度提高了两倍(除了我在提问之前自己进行的优化之外,因此从最初实现以来大概提高了4倍的速度)

我测试了两组输入,分别称为 A 和 B

优化1:遍历 word1、word2 的索引 ...

for i in range(len(word1)):
        if word1[i] != word2[i]:
            difference += 1
    return difference

使用zip(word1, word2)迭代字母对

for x,y in zip (word1, word2):
        if x != y:
            difference += 1
    return difference

优化2: 除了距离方法(我仍然需要用于A*启发式算法),还添加了一个专门的differs-by-one方法。

执行时间从输入A的11.92缩短到9.18,从输入B的79.30缩短到74.59。

def is_neighbors(word1,word2):
    different = False
    for c1,c2 in zip(word1,word2):
        if c1 != c2:
            if different:
                return False
            different = True
    return different

对输入A,执行时间从9.18降至8.83;对输入B,执行时间从74.59降至70.14。

优化3:使用izip替代zip效果显著。

对输入A,执行时间从8.83降至5.02;对输入B,执行时间从70.14降至41.69。

虽然我可能可以用一种更底层的语言编写它,并取得更好的效果,但目前为止我对这个结果感到满意。谢谢大家!

再次编辑:更多结果,使用Mark的方法检查首字母不匹配的情况,将执行时间从5.02降至3.59,将执行时间从41.69降至29.82。

在此基础上,并加入了使用izip替代range,最终代码如下:

def is_neighbors(word1,word2):
    if word1[0] != word2[0]:
        return word1[1:] == word2[1:]
    different = False
    for x,y in izip(word1[1:],word2[1:]):
        if x != y:
            if different:
                return False
            different = True
    return different

将时间从3.59缩短到3.38,将29.82缩短到27.88。

更多结果!

尝试Sumudu的建议,我生成了一个由距离“word”只差1个字母的所有字符串组成的列表,然后检查哪些在单词列表中,而不是使用is_neighbor函数,最终得到了以下代码:

def one_letter_off_strings(word):
    import string
    dif_list = []
    for i in xrange(len(word)):
        dif_list.extend((word[:i] + l + word[i+1:] for l in string.ascii_lowercase if l != word[i]))
    return dif_list

def getchildren(word, wordlist):
    oneoff = one_letter_off_strings(word)
    return ( w for w in oneoff if w in wordlist )

结果最终变慢了(3.38 - > 3.74 和27.88 -> 34.40),但似乎很有前途。起初我以为需要优化的部分是“one_letter_off_strings”,但分析结果表明瓶颈实际上在

( w for w in oneoff if w in wordlist )

我曾想过,如果我交换“oneoff”和“wordlist”,然后以另一种方式进行比较,是否会有任何区别,但是当我开始寻找这两个列表的交集时,我意识到我需要使用字母的交集,于是我做出了替换。

return set(oneoff) & set(wordlist)

Bam!3.74 -> 0.23,34.40 -> 2.25

这真是太神奇了,与我最初的天真实现相比,总速度差异为:23.79 -> 0.23和180.07 -> 2.25,大约比原始实现快80到100倍。

如果有人感兴趣,我写了一篇博客文章描述了这个程序,以及描述了所做的优化,其中包括没有在此处提到的一个(因为它在代码的另一个部分)。

伟大的辩论:

好吧,我和Unknown正在进行一场激烈的辩论,你可以在他的回答的评论中阅读。他声称,如果将其移植到C语言,则使用原始方法(使用is_neighbor而不是使用集合)将更快。我尝试了两个小时编写了一个C模块,并尝试遵循这个这个示例,但并没有取得太大成功,而且在Windows中的过程似乎有点不同?我不知道,但我放弃了。无论如何,这是程序的完整代码,文本文件来自12dict单词表,使用“2+2lemma.txt”文件。对不起,如果代码有点混乱,这只是我随便搞出来的东西。另外,我忘了从单词列表中去除逗号,所以这实际上是一个错误,你可以为了同样的比较而保留它,或者通过将逗号添加到cleanentries字符列表中来修复它。

from itertools import izip
def unique(seq):  
    seen = {} 
    result = [] 
    for item in seq: 
        if item in seen:
            continue 
        seen[item] = 1 
        result.append(item) 
    return result
def cleanentries(li):
    pass
    return unique( [w.strip('[]') for w in li if w != "->"] )
def distance(word1, word2):
    difference = 0
    for x,y in izip (word1, word2):
        if x != y:
            difference += 1
    return difference
def is_neighbors(word1,word2):
    if word1[0] != word2[0]:
        return word1[1:] == word2[1:]
    different = False
    for x,y in izip(word1[1:],word2[1:]):
        if x != y:
            if different:
                return False
            different = True
    return different
def one_letter_off_strings(word):
    import string
    dif_list = []
    for i in xrange(len(word)):
        dif_list.extend((word[:i] + l + word[i+1:] for l in string.ascii_lowercase if l != word[i]))
    return dif_list

def getchildren(word, wordlist):
    oneoff = one_letter_off_strings(word)
    return set(oneoff) & set(wordlist)
def AStar(start, goal, wordlist):
    import Queue
    closedset = []
    openset = [start]
    pqueue = Queue.PriorityQueue(0)
    g_score = {start:0}         #Distance from start along optimal path.
    h_score = {start:distance(start, goal)}
    f_score = {start:h_score[start]}
    pqueue.put((f_score[start], start))
    parent_dict = {}
    while len(openset) > 0:
        x = pqueue.get(False)[1]
        if x == goal:
            return reconstruct_path(parent_dict,goal)
        openset.remove(x)
        closedset.append(x)
        sortedOpen = [(f_score[w], w, g_score[w], h_score[w]) for w in openset]
        sortedOpen.sort()
        for y in getchildren(x, wordlist):
            if y in closedset:
                continue
            temp_g_score = g_score[x] + 1
            temp_is_better = False
            appended = False
            if (not y in openset): 
                openset.append(y)
                appended = True
                h_score[y] = distance(y, goal)
                temp_is_better = True
            elif temp_g_score < g_score[y] :
                temp_is_better = True
            else :
                pass
            if temp_is_better:
                parent_dict[y] = x
                g_score[y] = temp_g_score
                f_score[y] = g_score[y] + h_score[y]
                if appended :
                    pqueue.put((f_score[y], y))
    return None


def reconstruct_path(parent_dict,node):
     if node in parent_dict.keys():
         p = reconstruct_path(parent_dict,parent_dict[node])
         p.append(node)
         return p
     else:
         return []        

wordfile = open("2+2lemma.txt")
wordlist = cleanentries(wordfile.read().split())
wordfile.close()
words = []
while True:
    userentry = raw_input("Hello, enter the 2 words to play with separated by a space:\n ")
    words = [w.lower() for w in userentry.split()]
    if(len(words) == 2 and len(words[0]) == len(words[1])):
        break
print "You selected %s and %s as your words" % (words[0], words[1])
wordlist = [ w for w in wordlist if len(words[0]) == len(w)]
answer = AStar(words[0], words[1], wordlist)
if answer != None:
    print "Minimum number of steps is %s" % (len(answer))
    reply = raw_input("Would you like the answer(y/n)? ")
    if(reply.lower() == "y"):
        answer.insert(0, words[0])
        print "\n".join(answer)
    else:
        print "Good luck!"
else:
    print "Sorry, there's no answer to yours"
reply = raw_input("Press enter to exit")

尽管is_neighbors方法没有被使用,但我还是保留了它。这个方法被建议移植到C语言中。要使用它,只需将getchildren替换为该方法:

def getchildren(word, wordlist):
    return ( w for w in wordlist if is_neighbors(word, w))

至于将其作为C模块运行,我没有深入研究,但这是我得出的结果:

#include "Python.h"

static PyObject *
py_is_neighbor(PyObject *self, Pyobject *args)
{
    int length;
    const char *word1, *word2;
    if (!PyArg_ParseTuple(args, "ss", &word1, &word2, &length))
        return NULL;

    int i;
    int different = 0;
    for (i =0; i < length; i++)
    {
        if (*(word1 + i) != *(word2 + i))
        {
            if (different)
            {
                return Py_BuildValue("i", different);
            }
            different = 1;
        }
    }
    return Py_BuildValue("i", different);
}

PyMethodDef methods[] = {
    {"isneighbor", py_is_neighbor, METH_VARARGS, "Returns whether words are neighbors"},
    {NULL, NULL, 0, NULL}
};

PyMODINIT_FUNC
initIsNeighbor(void)
{
    Py_InitModule("isneighbor", methods);
}

我使用以下命令进行了性能分析:

python -m cProfile "Wordgame.py"

记录的时间是 AStar 方法调用的总时间。快速输入集为 "verse poets",长输入集为 "poets verse"。不同机器的计时结果会有所不同,因此如果有人尝试这样做,请提供程序的结果比较,包括使用 C 模块的情况。


如果word2是word1的前缀,应该如何计算距离? - Markus Jarderot
能否请您再次运行我的最新函数并计时?我非常想知道它在您的实际数据上与之前相比如何。 - Mark Ransom
请看 http://norvig.com/spell-correct.html 上的 edits1(word) 函数。 - jfs
必须使用Python 2.x吗(3.x中不存在izip)?在3.x中更具可重用性;此外,优化和trie库的实现将有所不同。 - smci
12个回答

0

我首先想到的是:

from operator import ne

def distance(word1, word2):
    return sum(map(ne, word1, word2))

这个函数有很大的概率比其他人发布的函数更快,因为它没有解释循环,只是调用Python原语。而且它很短,你可以合理地将它内联到调用程序中。

对于您的更高级别问题,我建议您查阅为度量空间相似性搜索开发的数据结构,例如this paperthis book,我都没有看过(它们是在搜索我已经阅读但无法记起的论文时出现的)。


0

其他人只关注显式距离计算,而没有做任何关于构建距离为1的候选词的事情。你可以通过使用一个名为Trie的众所周知的数据结构,将隐式距离计算生成所有距离为1的邻居单词的任务合并起来。Trie是一个链表,每个节点代表一个字母,'next'字段是一个最多有26个条目的字典,指向下一个节点。

以下是伪代码:迭代地遍历给定单词的Trie;在每个节点上将所有距离为0和距离为1的邻居添加到结果中;保持距离的计数器并递减它。你不需要递归,只需要一个查找函数,它带有一个额外的distance_so_far整数参数。

通过为长度为3、长度为4、长度为5等单词构建单独的Trie,可以获得额外速度的微小折衷,以增加O(N)的空间。


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