将一个单词转换为另一个单词的最短路径

23

在一个数据结构项目中,我必须找到两个单词之间的最短路径(例如"cat""dog"),每次只更改一个字母。我们被提供了Scrabble单词列表以用于查找路径。例如:

cat -> bat -> bet -> bot -> bog -> dog

我已经使用广度优先搜索解决了这个问题,但是希望找到更好的方法(我用字典树表示了字典)。

请给我一些更高效的方法(以速度和内存为基准)。 偏向于荒谬和/或具有挑战性。

我问过我的一个朋友(他是初学者),他说没有有效的解决方案。 他说当我上算法课时,我就会知道为什么。 对此有任何评论吗?

我们必须从一个单词移动到另一个单词。 我们不能像“cat->dat->dag->dog”那样走。 我们还必须打印出遍历路径。


6
在你的例子中,为什么要加上bet?你把相同的字母连续改变了两次,应该是这样写: cat -> bat -> bot -> bog -> dog - CaffGeek
重复的 https://dev59.com/JmbWa4cB1Zd3GeqPbuqD#11813399 - Rusty Rob
Dacman,您介意分享一下使用启发式算法与 BFS 相比,您的性能提高了多少吗? - V. Rubinetti
顺便说一句,从猫到狗的最短路径是猫 >> cot >> cog >> 狗 :) - dhughes
10个回答

16

新答案

鉴于最近的更新,您可以尝试使用汉明距离作为启发式方法的A *算法。它是一个可接受的启发式方法,因为它不会低估距离。

旧答案

您可以修改用于计算莱文斯坦距离的动态规划程序,以获取操作序列。

编辑:如果有恒定数量的字符串,则问题可以在多项式时间内解决。否则,这是NP难问题(这全部都在维基百科中)……假设您的朋友正在谈论NP难问题。

编辑:如果您的字符串长度相等,则可以使用汉明距离


3
请提供更多上下文信息以便我可以更准确地进行翻译。 - Zed
2
你不能修改Levenshtein函数来实现这个功能,因为你只有一个有限的有效单词字典 - 因此最短的有效路径可能比字符串中的字符数要长得多。 - Nick Johnson
1
厚颜无耻的自我推销 我一个月或两个月前问了一个关于这个问题的问题,只不过是在Python中,最终我使用了Hamming距离作为启发式的A *算法(尽管当时我不知道它叫做Hamming距离)。我得到了一些非常好的优化(一些是特定于Python的,一些则不是)。问题在这里https://dev59.com/HOo6XIcBkEYKwwoYTzNK,在问题更新中,我链接到了我写的博客文章(在页面的中间位置...问题有很多更新),其中我描述了代码和优化。 - Davy8
1
我做了这件事,效果非常好。我的教授也很惊讶! - dacman
Dacman,您介意分享一下使用启发式方法与 BFS 相比您的性能提高了多少吗? - V. Rubinetti
显示剩余6条评论

9

使用字典,BFS是最优的,但所需的运行时间与其大小(V+E)成正比。对于n个字母,字典可能有~a^n个条目,其中a是字母表大小。如果字典包含除应该在链末尾的单词之外的所有单词,则您将遍历所有可能的单词,但不会找到任何内容。这是图形遍历,但大小可能呈指数增长。

您可能想知道是否可能更快地浏览“智能”结构并在多项式时间内完成它。答案是,我认为不能。

问题:

您可以快速(线性地)检查单词是否在字典中,给定两个单词u,v并检查是否存在序列u->a1->a2->...->an->v。

是NP难题。

证明:取一个3SAT实例,例如

(p或q或not r)且(p或not q或r)

您将从0 000 00开始,并要检查是否可能转到2 222 22。

第一个字符将是“我们完成了”,接下来的三个位将控制p,q,r,接下来的两个位将控制子句。
允许的单词包括:
- 以0开头且只包含0和1的任何内容。 - 以2开头且合法的任何内容。这意味着它由0和1组成(除了第一个字符为2外,所有子句位根据变量位正确设置,并且它们设置为1(因此显示公式是可满足的)。 - 以至少两个2开头,然后由0和1组成(正则表达式:222*(0+1)*,例如22221101但不是2212001)。
要从0 000 00生成2 222 22,必须按照以下方式进行:
(1)翻转适当的位 - 例如,在四个步骤中翻转为0 100 111。这需要找到3SAT解决方案。
(2)将第一位更改为2:2 100 111。在这里,您将验证这确实是3SAT解决方案。

(3) 将 2 100 111 改为 2 200 111,然后改为 2 220 111,再改为 2 222 111,然后改为 2 222 211,最后改为 2 222 221,最终改为 2 222 222。

这些规则强制执行了你不能作弊(检查)。只有当公式可满足时,才能到达2 222 22,并且检查它是NP难题。我觉得这可能会更难(可能是#P或FNP),但是我认为NP难度已经足够了。

编辑:您可能会对不相交集数据结构感兴趣。这将获取您的字典并分组可以从彼此到达的单词。您还可以存储从每个顶点到根或其他顶点的路径。这将为您提供一条路径,不一定是最短的路径。


很好的总结。如果原作者正在寻找真正有创意的东西,可以将编辑距离与单词到达图结合使用作为遗传算法的适应度函数。输出是从一个起始单词到结束单词的路径,因此最佳答案将是最短的。(虽然很酷,但这将找到最快的答案,但不会得出明确的答案。非常TS。)我会坚持现实世界。消除循环,枚举路径并使用上述建议找到“最佳”路径。这被标记为“Java”,因此请尝试JGraphT。 - Joe Liversedge
很酷,不经常在Stackoverflow答案中看到NP难度证明。 :-) 我也怀疑如果字典只是作为成员预言机给出,那么这个问题比NP更难(PSPACE完全?)...但是如果字典实际上是在输入中给出的,那么问题可以轻松地在多项式时间内完成,因为字典大小是输入的一部分(这是你的NP难度证明的缺陷)。 - ShreevatsaR

3

寻找链接有不同效率的方法 - 您可以为每个单词长度构建完整的图,也可以构建BK-Tree等数据结构,但您的朋友是对的 - BFS算法是最有效的。

然而,有一种方法可以显着提高运行时间:不要从源节点进行单个BFS,而是从图的两端开始进行两个广度优先搜索,并在它们的前沿集中找到共同节点时终止。您需要完成的工作量大约是仅从一端搜索时所需工作量的一半。


请注意,我认为此方法仅适用于无权图。在加权图中(其中某些边的“成本”或长度比其他边更高),以相同方式使用双向搜索不能保证已找到最短路径。请查看此链接和此主题。但在这种情况下,一个字母不同的单词之间的步骤都是相同的。 - V. Rubinetti

2
您可以先删除不符合长度要求的单词,这样可以更快一些。这样可以让有限的词典全部适应CPU缓存,甚至能够更好地适应。
另外,如果您将所有单词转换为小写字母,那么所有的strncmp比较都可以改为memcmp比较,或者是展开比较,这样可以提高速度。
您可以使用一些预处理技巧,并为该单词长度编译任务,或为常见单词长度滚动几个优化版本的任务。这样所有额外的比较都可以被消除,从而实现完全展开的乐趣。

0
bool isadjacent(string& a, string& b)
{
  int count = 0;  // to store count of differences
  int n = a.length();

  // Iterate through all characters and return false
  // if there are more than one mismatching characters
  for (int i = 0; i < n; i++)
  {
    if (a[i] != b[i]) count++;
    if (count > 1) return false;
  }
  return count == 1 ? true : false;
}

// 一个队列项,用于存储单词和到达该单词所需的最小链长度。

struct QItem
{
  string word;
  int len;
};

// 返回从“start”到“target”的最短链长度,使用最少的相邻移动。D是字典

int shortestChainLen(string& start, string& target, set<string> &D)
{
  // Create a queue for BFS and insert 'start' as source vertex
  queue<QItem> Q;
  QItem item = {start, 1};  // Chain length for start word is 1
  Q.push(item);

  // While queue is not empty
  while (!Q.empty())
  {
    // Take the front word
    QItem curr = Q.front();
    Q.pop();

    // Go through all words of dictionary
    for (set<string>::iterator it = D.begin(); it != D.end(); it++)
    {
        // Process a dictionary word if it is adjacent to current
        // word (or vertex) of BFS
        string temp = *it;
        if (isadjacent(curr.word, temp))
        {
            // Add the dictionary word to Q
            item.word = temp;
            item.len = curr.len + 1;
            Q.push(item);

            // Remove from dictionary so that this word is not
            // processed again.  This is like marking visited
            D.erase(temp);

            // If we reached target
            if (temp == target)
              return item.len;
        }
    }
  }
  return 0;
}

// Driver program
int main()
{
  // make dictionary
  set<string> D;
  D.insert("poon");
  D.insert("plee");
  D.insert("same");
  D.insert("poie");
  D.insert("plie");
  D.insert("poin");
  D.insert("plea");
  string start = "toon";
  string target = "plea";
  cout << "Length of shortest chain is: "
       << shortestChainLen(start, target, D); 
  return 0; 
}

从这里复制:https://www.geeksforgeeks.org/word-ladder-length-of-shortest-chain-to-reach-a-target-word/


0
你可以找到最长公共子序列,从而找到必须更改的字母。

0

这是一个典型的动态规划问题。请查看编辑距离问题。


3
不是这样的,请仔细阅读问题。有一个固定的给定字典,因此编辑距离几乎没有任何关联性。 - ShreevatsaR

0

我的直觉是你的朋友是正确的,因为假设你每次都重新加载字典,那么就没有更有效的解决方案了。如果你能保持一个常见转换的运行数据库,那么肯定会有更有效的方法来找到解决方案,但你需要事先生成转换,并发现哪些转换是有用的(因为你不能生成所有转换!),这可能是一门艺术。


0

0
这是LeetCode 127. Word Ladder。从源到目的地的最短路径可以使用BFS找到,但挑战在于以高效的方式找到要访问的下一个节点。 一种选择是遍历字典中的每个单词,并逐对比较字符,以找出它们是否相差1,但这需要O(mn2)的时间,其中m是单词的长度,n是字典中的单词数。 相反,我们观察到一个单词"cat"可以转换为"_at"、"c_t"和"ca_",其中"_"表示任意字符。因此,我们创建了每个通配符变体与实际单词之间的映射。在BFS过程中,要访问的下一个节点由从队列中弹出的单词的所有通配符变体对应的单词给出。
时间复杂度:一个长度为m的单词有m个通配符变体,因此每个字符串的通配符需要O(m^2)的时间(从技术上讲,由于它是一个生成器,在被消耗时只需要O(m^2))。如果字典中有n个单词,则创建图表需要O(nm^2)的时间。 BFS很难估计,但LeetCode限制了m为10,因此BFS的上限为O(n)。
Python实现:
def word_ladder(begin: str, end: str, dictionary: list[str]) -> list[str]:
    def wildcards(i: int) -> Iterator[str]:
        word = dictionary[i]
        for j in range(len(word)):
            yield f"{word[:j]}_{word[j + 1:]}"

    if end not in dictionary:
        return []
    graph: dict[str, list[int]] = defaultdict(list)
    for i in range(len(dictionary)):
        for w in wildcards(i):
            graph[w].append(i)

    dictionary.append(begin)

    to_visit = deque([(len(dictionary) - 1, -1)])
    visited: set[int] = set()
    path: dict[int, int] = {}
    end_idx = -1
    while to_visit:
        word, parent = to_visit.popleft()
        if word in visited:
            continue
        visited.add(word)
        path[word] = parent
        if dictionary[word] == end:
            end_idx = word
            break
        for w in wildcards(word):
            for neighbor in graph[w]:
                if neighbor not in visited:
                    to_visit.append((neighbor, word))

    result: deque[int] = deque()
    while 0 <= end_idx < len(dictionary):
        result.appendleft(end_idx)
        end_idx = path[end_idx]
    return [dictionary[i] for i in result]

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