在C#中查找字符串匹配项的最佳比较算法

3

假设我有一个包含 100,000 个单词的列表。 我想找出给定字符串是否与该列表中的任何单词匹配,并且我希望以最快的方式完成。此外,我想知道是否有任何其他以该字符串的第一个字符开头的单词出现在列表中。

例如:

假设您有字符串“icedtgg”

"i" "ic" "ice" "iced" "icedt" "icedtg" "icedtgg"

我正在尝试设计一种最佳比较算法,告诉我以下单词是否在我的列表中。

到目前为止,我拥有的是存储在一个

Dicitonary<char, List<string>> WordList;

其中char是单词的第一个字符,List<string>是所有以该字符开头的单词列表。

因此,WordList ['a']有以'a'开头的所有单词的列表(如“猿”,“苹果”,“艺术”等),而'b'则有以'b'开头的所有单词的列表等。

由于我知道所有的单词都是以'i'开头的,所以我可以将我的解决方案从10万个单词缩小到仅以'i'开头的单词。

List<string> CurrentWordList = WordList['i'];

现在我进行检查。
if( CurrentWordList[0].Length == 1 )

那么,我知道我的第一个字符串与“i”匹配,因为“i”将是列表中的第一个单词。这些列表事先按字母顺序排序,以免减慢匹配速度。

有什么想法吗?

*不,这不是作业问题,我是一名专业软件架构师,正在寻找一种优化匹配算法,用于娱乐/爱好/游戏开发。


1
如果你的字典是以{key: {word_list}的格式,并且每个键的值都是一个单词集合,那么每次查找时你将会拥有O(1)的查找时间,前提是没有太多的碰撞。 - kylieCatt
@Pseudonym,你能具体说明一下吗?我熟悉LINQ,但我正在寻找最佳解决方案。此外,我正在使用Xamarin进行开发,所以我希望这个sln文件可以轻松地被Xamarin编译器转换成Java(Android)和ObjectiveC(iPhone)。 - MattyMerrix
@IanAuld 请查看我的更新帖子,我已经将其添加到我的代码中,但正在尝试进一步优化,因为我知道每次只需将CurrentWordList限制为仅剩下的可能匹配项。 - MattyMerrix
4
你可能想考虑使用专门的数据结构和算法来完成这个任务:可以采用前缀树(特别是帕特里夏树)或Aho-Corasick字符串匹配算法。可以参考这个问题:https://dev59.com/OInda4cB1Zd3GeqPGPe6 - Alex
1
@MattyMerrix 很高兴我能稍微有所帮助! - Pseudonym
显示剩余15条评论
5个回答

6
我决定添加这个答案,不是因为它是您问题的最佳解决方案,而是为了说明两种可能的解决方案,它们相对简单,并且与您自己正在遵循的方法有些类似。
下面的示例(非优化)提供了一个极其简单的前缀树实现,它使用每个已消耗字符的节点。
public class SimplePrefixTrie
{
    private readonly Node _root = new Node(); // root represents empty string.

    private class Node
    {
        public Dictionary<char, Node> Children;
        public bool IsTerminal; // whether a full word ends here.

        public Node Find(string word, int index)
        {
            var child = default(Node);
            if (index < word.Length && Children != null)
                Children.TryGetValue(word[index], out child);
            return child;
        }

        public Node Add(string word, int toConsume)
        {
            var child = default(Node);
            if (toConsume == word.Length)
                this.IsTerminal = true;
            else if (Children == null || !Children.TryGetValue(word[toConsume], out child))
            {
                if (Children == null)
                    Children = new Dictionary<char, Node>();
                Children[word[toConsume]] = child = new Node();
            }
            return child;
        }
    }

    public void AddWord(string word)
    {
        var ndx = 0;
        var cur = _root;
        while (cur != null)
            cur = cur.Add(word, ndx++);
    }

    public IEnumerable<string> FindWordsMatchingPrefixesOf(string searchWord)
    {
        var ndx = 0;
        var cur = _root;
        while (cur != null)
        {
            if (cur.IsTerminal)
                yield return searchWord.Substring(0, ndx);
            cur = cur.Find(searchWord, ndx++);
        }
    }
}

下面还添加了一个简单的压缩前缀树实现。它与上面的示例几乎相同,但存储共享前缀部分而不是单个字符。当现有存储的前缀变为共享并需要拆分成两个部分时,它会分裂节点。
public class SimpleCompressedPrefixTrie
{
    private readonly Node _root = new Node();

    private class Node
    {
        private Dictionary<char, Node> _children;
        public string PrefixValue = string.Empty;
        public bool IsTerminal;

        public Node Add(string word, ref int startIndex)
        {
            var n = FindSharedPrefix(word, startIndex);
            startIndex += n;
            if (n == PrefixValue.Length) // full prefix match
            {
                if (startIndex == word.Length) // full match
                    IsTerminal = true;
                else
                    return AddToChild(word, ref startIndex);
            }
            else // partial match, need to split this node's prefix.
                SplittingAdd(word, n, ref startIndex);
            return null;
        }

        public Node Find(string word, ref int startIndex, out int matchLen)
        {
            var n = FindSharedPrefix(word, startIndex);
            startIndex += n;
            matchLen = -1;
            if (n == PrefixValue.Length)
            {
                if (IsTerminal)
                    matchLen = startIndex;
                var child = default(Node);
                if (_children != null && startIndex < word.Length && _children.TryGetValue(word[startIndex], out child))
                {
                    startIndex++; // consumed map key character.
                    return child;
                }
            }
            return null;
        }

        private Node AddToChild(string word, ref int startIndex)
        {
            var key = word[startIndex++]; // consume the mapping character
            var nextNode = default(Node);
            if (_children == null)
                _children = new Dictionary<char, Node>();
            else if (_children.TryGetValue(key, out nextNode))
                return nextNode;
            var remainder = word.Substring(startIndex);
            _children[key] = new Node() { PrefixValue = remainder, IsTerminal = true };
            return null; // consumed.
        }

        private void SplittingAdd(string word, int n, ref int startIndex)
        {
            var curChildren = _children;
            _children = new Dictionary<char, Node>();
            _children[PrefixValue[n]] = new Node()
            {
                PrefixValue = this.PrefixValue.Substring(n + 1),
                IsTerminal = this.IsTerminal,
                _children = curChildren
            };
            PrefixValue = PrefixValue.Substring(0, n);
            IsTerminal = startIndex == word.Length;
            if (!IsTerminal)
            {
                var prefix = word.Length > startIndex + 1 ? word.Substring(startIndex + 1) : string.Empty;
                _children[word[startIndex]] = new Node() { PrefixValue = prefix, IsTerminal = true };
                startIndex++;
            }
        }

        private int FindSharedPrefix(string word, int startIndex)
        {
            var n = Math.Min(PrefixValue.Length, word.Length - startIndex);
            var len = 0;
            while (len < n && PrefixValue[len] == word[len + startIndex])
                len++;
            return len;
        }
    }

    public void AddWord(string word)
    {
        var ndx = 0;
        var cur = _root;
        while (cur != null)
            cur = cur.Add(word, ref ndx);
    }

    public IEnumerable<string> FindWordsMatchingPrefixesOf(string searchWord)
    {
        var startNdx = 0;
        var cur = _root;
        while (cur != null)
        {
            var matchLen = 0;
            cur = cur.Find(searchWord, ref startNdx, out matchLen);
            if (matchLen > 0)
                yield return searchWord.Substring(0, matchLen);
        };
    }
}

使用示例:

var trie = new SimplePrefixTrie(); // or new SimpleCompressedPrefixTrie();
trie.AddWord("hello");
trie.AddWord("iced");
trie.AddWord("i");
trie.AddWord("ice");
trie.AddWord("icecone");
trie.AddWord("dtgg");
trie.AddWord("hicet");
foreach (var w in trie.FindWordsMatchingPrefixesOf("icedtgg"))
    Console.WriteLine(w);

带有输出:
i
ice
iced

更新:选择正确的数据结构很重要

我认为更新一下可以提供一些价值,以说明选择适合问题的数据结构是多么重要,以及涉及哪些折衷。因此,我创建了一个小型基准应用程序,测试到目前为止在这个问题中提供的策略与基线参考实现之间的差异。

  • Naive:是最简单、最朴素的解决方案。
  • JimMischel:基于这个答案的方法。
  • MattyMerrix:基于你自己的答案在这里
  • JimMattyDSL:结合了“JimMischel”和“MattyMerrix”的方法,并在排序列表中使用更优化的二进制字符串搜索。
  • SimpleTrieCompessedTrie基于这个答案中描述的两种实现。
完整的基准代码可以在this gist中找到。使用包含10,000、100,000和1,000,000(随机生成的字符序列)单词的字典运行该代码,并搜索所有前缀匹配项的结果如下:
将5000个单词与25个字符以内的10000个术语的字典进行匹配。
       Method              Memory (MB)         Build Time (s)        Lookup Time (s)
        Naive          0.64-0.64, 0.64     0.001-0.002, 0.001     6.136-6.312, 6.210
   JimMischel          0.84-0.84, 0.84     0.013-0.018, 0.016     0.083-0.113, 0.102
  JimMattyDSL          0.80-0.81, 0.80     0.013-0.018, 0.016     0.008-0.011, 0.010
   SimpleTrie       24.55-24.56, 24.56     0.042-0.056, 0.051     0.002-0.002, 0.002
CompessedTrie          1.84-1.84, 1.84     0.003-0.003, 0.003     0.002-0.002, 0.002
  MattyMerrix          0.83-0.83, 0.83     0.017-0.017, 0.017     0.034-0.034, 0.034

将5000个单词与最大长度为25的100000个术语的字典进行匹配

       Method              Memory (MB)         Build Time (s)        Lookup Time (s)
        Naive          6.01-6.01, 6.01     0.024-0.026, 0.025  65.651-65.758, 65.715
   JimMischel          6.32-6.32, 6.32     0.232-0.236, 0.233     1.208-1.254, 1.235
  JimMattyDSL          5.95-5.96, 5.96     0.264-0.269, 0.266     0.050-0.052, 0.051
   SimpleTrie    226.49-226.49, 226.49     0.932-0.962, 0.951     0.004-0.004, 0.004
CompessedTrie       16.10-16.10, 16.10     0.101-0.126, 0.111     0.003-0.003, 0.003
  MattyMerrix          6.15-6.15, 6.15     0.254-0.269, 0.259     0.414-0.418, 0.416

将5000个单词与最长长度为25的100万个字典条目匹配

       Method              Memory (MB)         Build Time (s)        Lookup Time (s)
   JimMischel       57.69-57.69, 57.69     3.027-3.086, 3.052  16.341-16.415, 16.373
  JimMattyDSL       60.88-60.88, 60.88     3.396-3.484, 3.453     0.399-0.400, 0.399
   SimpleTrie 2124.57-2124.57, 2124.57  11.622-11.989, 11.860     0.006-0.006, 0.006
CompessedTrie    166.59-166.59, 166.59     2.813-2.832, 2.823     0.005-0.005, 0.005
  MattyMerrix       62.71-62.73, 62.72     3.230-3.270, 3.251     6.996-7.015, 7.008

如您所见,(非空间优化的)Trie 字典树所需的内存要高得多。对于所有测试实现,它都会随着字典大小增加而增加,为 O(N)。
正如预期的那样,Trie 字典树的查找时间基本上是恒定的:O(k),仅取决于搜索词的长度。对于其他实现,时间将根据要搜索的字典大小而增加。
请注意,可以构建更优化的实现来解决此问题,这些实现将接近于 O(k) 的搜索时间,并允许更紧凑的存储和减少的内存占用。如果映射到缩小的字母表(例如仅限于 'A'-'Z'),也可以利用这一点。

Alex,谢谢你的回答。这个方法和我的不同,我看到你使用了节点和一种链表实现方式。我在下面添加了一个新的更短的答案,利用了Linq。我很想知道哪个答案在速度测试中更快。看看并告诉我你的想法,如果你想运行速度测试,我可以提供一个单词集合!:) - MattyMerrix
不错的总结。由于排序,我的构建时间很长。由于所有的Substring调用和因此产生的垃圾回收以及我进行完整的字符串比较而不仅仅是检查最后一个字符,查找时间变得昂贵。虽然无法改变排序,但我可以大大加快查找速度。 - Jim Mischel
我应该想到那个string.Compare重载。那是一个很大的改进。不过,那是在比较子字符串。我怀疑它可以被改成比较单个字符。到那时,它将非常类似于前缀树。 - Jim Mischel
@JimMischel在答案中添加了该选项,我认为排序搜索现在非常优化,并且它非常快,O(logN)而不是O(N),这是二进制搜索所期望的。而基准测试很困难,非trie解决方案的初始低内存使用率是因为它们仅存储对已分配字符串的引用,而不是字符串的副本。 - Alex
Alex和@JimMischel,我刚刚注意到Alex结合了JimM和MattyM的方法!非常棒的工作,先生!这真是太棒了,我认为这个问题肯定得到了解答,我们正在为人们提供多种实现方式,以满足他们对代码简洁性、排序时间、内存使用等因素的偏好。!!! - MattyMerrix
显示剩余4条评论

2

所以你只是想在字典中找到输入字符串的前缀吗?您可以比提出的任何方法更有效地执行此操作。这实际上只是一个修改后的合并。

如果您的单词列表由以第一个字母为键的字典组成,每个条目包含以该字母开头的单词的排序列表,则可以完成此操作。最坏情况下的时间复杂度为O(n + m),其中n是以该字母开头的单词数,m是输入字符串的长度。

var inputString = "icegdt";
// get list of words that start with the first character
var wordsList = MyDictionary[input_string[0]];

// find all words that are prefixes of the input string
var iInput = 0;
var iWords = 0;
var prefix = inputString.Substring(0, iInput+1);
while (iInput < inputString.Length && iWords < wordsList.Count)
{
    if (wordsList[iWords] == prefix)
    {
        // wordsList[iWords] is found!
        ++iWords;
    }
    else if (wordsList[iWords] > prefix)
    {
        // The current word is alphabetically after the prefix.
        // So we need the next character.
        ++iInput;
        if (iInput < inputString.Length)
        {
            prefix = inputString.Substring(0, iInput+1);
        }
    }
    else
    {
        // The prefix is alphabetically after the current word.
        // Advance the current word.
        ++iWord;
    }
}

如果您只想做的就是找到输入字符串的前缀词典单词,那么没有特别的理由使用以第一个字符为索引的字典。给定一个已排序的单词列表,您可以在第一个字母上进行二分查找以找到起始点。相对于字典查找,这将需要 略微 更多的时间,但与搜索匹配项所需的时间相比,时间差异非常小。此外,排序后的单词列表所需的内存比字典方法少。
如果您想进行大小写不敏感的比较,请更改比较代码为:
    var result = String.Compare(wordsList[iWords], prefix, true);
    if (result == 0)
    {
        // wordsList[iWords] is found!
        ++iWords;
    }
    else if (result > 0)
    {

这也将每次迭代的字符串比较次数减少到恰好一次。


Jim,乍一看这似乎很不错!你提出了很多好的观点,只需检查一个单词是否等于当前单词或在其后面,因为列表按字母顺序排序。我认为我有点害怕使用>运算符来比较字符串,因为我不确定这在汇编语言中编译成了多少步骤。我想在有空的时候(可能是明天)用速度测试比较这两种方法。感谢您的意见!您真是太聪明了。 - MattyMerrix
调用一个优化的字符串比较函数不会比编写一堆逐个字符比较字符串的代码慢,这本质上就是你的LINQ代码所做的。 - Jim Mischel

0
while (x < str.Length-1)
{
    if (ChrW(10) == GetChar(str, x) && ChrW(13) == GetChar(str, x+1))
     {
       // x+2 - This new line
     }
   x++;
}

0

这是我第一次尝试,想在这里发布以防我今天无法完成它。

 public class CompareHelper
 {
    //Should always be sorted in alphabetical order.
    public static Dictionary<char, List<string>> MyDictionary;
    public static List<string> CurrentWordList;
    public static List<string> MatchedWordList;

    //The word we are trying to find matches for.
    public static char InitChar;
    public static StringBuilder ThisWord;

    /// <summary>
    /// Initialize the Compare.  Set the first character.  See if there are any 1 letter words
    /// for that character.
    /// </summary>
    /// <param name="firstChar">The first character in the word string.</param>
    /// <returns>True if a word was found.</returns>
    public static bool InitCompare(char firstChar)
    {
        InitChar = firstChar;
        //Get all words that start with the firstChar.
        CurrentWordList = MyDictionary[InitChar];
        ThisWord = new StringBuilder();
        ThisWord.Append(firstChar);

        if (CurrentWordList[0].Length == 1)
        {
            //Match.
            return true;
        }
        //No matches.
        return false;
    }

    /// <summary>
    /// Append this letter to our ThisWord.  See if there are any matching words.
    /// </summary>
    /// <param name="nextChar">The next character in the word string.</param>
    /// <returns>True if a word was found.</returns>
    public static bool NextCompare(char nextChar)
    {
        ThisWord.Append(nextChar);
        int currentIndex = ThisWord.Length - 1;
        if (FindRemainingWords(nextChar, currentIndex))
        {
            if (CurrentWordList[0].Length == currentIndex)
            {
                //Match.
                return true;
            }
        }
        //No matches.
        return false;
    }

    /// <summary>
    /// Trim down our CurrentWordList until it only contains words
    /// that at currIndex start with the currChar.
    /// </summary>
    /// <param name="currChar">The next letter in our ThisWord.</param>
    /// <param name="currIndex">The index of the letter.</param>
    /// <returns>True if there are words remaining in CurrentWordList.</returns>
    private static bool FindRemainingWords(char currChar, int currIndex)
    {
        //Null check.
        if (CurrentWordList == null || CurrentWordList.Count < 1)
        {
            return false;
        }

        bool doneSearching = false;
        while(!doneSearching)
        {
            int middleIndex = CurrentWordList.Count / 2;

            //TODO: test for CurrentWordList.count 2 or 1 ...

            //TODO: test for wordToCheck.length < curr index

            char middleLetter = CurrentWordList[middleIndex][currIndex];


            LetterPositionEnum returnEnum = GetLetterPosition(currChar, middleLetter);
            switch(returnEnum)
            {
                case LetterPositionEnum.Before:
                    CurrentWordList = CurrentWordList.GetRange(middleIndex, (CurrentWordList.Count - middleIndex));
                    break;
                case LetterPositionEnum.PREV:
                    CurrentWordList = CurrentWordList.GetRange(middleIndex, (CurrentWordList.Count - middleIndex));

                    break;
                case LetterPositionEnum.MATCH:
                    CurrentWordList = CurrentWordList.GetRange(middleIndex, (CurrentWordList.Count - middleIndex));

                    break;
                case LetterPositionEnum.NEXT:
                    CurrentWordList = CurrentWordList.GetRange(0, middleIndex);

                    break;
                case LetterPositionEnum.After:
                    CurrentWordList = CurrentWordList.GetRange(0, middleIndex);

                    break;
                default:
                    break;
            }
        }

        TrimWords(currChar, currIndex);

        //Null check.
        if (CurrentWordList == null || CurrentWordList.Count < 1)
        {
            return false;
        }

        //There are still words left in CurrentWordList.
        return true;
    }

    //Trim all words in CurrentWordList 
    //that are LetterPositionEnum.PREV and LetterPositionEnum.NEXT
    private static void TrimWords(char currChar, int currIndex)
    {
        int startIndex = 0;
        int endIndex = CurrentWordList.Count;
        bool startIndexFound = false;

        //Loop through all of the words.
        for ( int i = startIndex; i < endIndex; i++)
        {
            //If we havent found the start index then the first match of currChar
            //will be the start index.
             if( !startIndexFound &&  currChar == CurrentWordList[i][currIndex] )
            {
                startIndex = i;
                startIndexFound = true;
            }

             //If we have found the start index then the next letter that isnt 
             //currChar will be the end index.
             if( startIndexFound && currChar != CurrentWordList[i][currIndex])
            {
                endIndex = i;
                break;
            }
        }

        //Trim the words that dont start with currChar.
        CurrentWordList = CurrentWordList.GetRange(startIndex, endIndex);
    }


    //In order to find all words that begin with a given character, we should search
    //for the last word that begins with the previous character (PREV) and the 
    //first word that begins with the next character (NEXT).
    //Anything else Before or After that is trash and we will throw out.
    public enum LetterPositionEnum
    {
        Before,
        PREV,
        MATCH,
        NEXT,
        After
    };

    //We want to ignore all letters that come before this one.
    public static LetterPositionEnum GetLetterPosition(char currChar, char compareLetter)
    {
        switch (currChar)
        {
            case 'A':
                switch (compareLetter)
                {
                    case 'A': return LetterPositionEnum.MATCH;
                    case 'B': return LetterPositionEnum.NEXT;
                    case 'C': return LetterPositionEnum.After;
                    case 'D': return LetterPositionEnum.After;
                    case 'E': return LetterPositionEnum.After;
                    case 'F': return LetterPositionEnum.After;
                    case 'G': return LetterPositionEnum.After;
                    case 'H': return LetterPositionEnum.After;
                    case 'I': return LetterPositionEnum.After;
                    case 'J': return LetterPositionEnum.After;
                    case 'K': return LetterPositionEnum.After;
                    case 'L': return LetterPositionEnum.After;
                    case 'M': return LetterPositionEnum.After;
                    case 'N': return LetterPositionEnum.After;
                    case 'O': return LetterPositionEnum.After;
                    case 'P': return LetterPositionEnum.After;
                    case 'Q': return LetterPositionEnum.After;
                    case 'R': return LetterPositionEnum.After;
                    case 'S': return LetterPositionEnum.After;
                    case 'T': return LetterPositionEnum.After;
                    case 'U': return LetterPositionEnum.After;
                    case 'V': return LetterPositionEnum.After;
                    case 'W': return LetterPositionEnum.After;
                    case 'X': return LetterPositionEnum.After;
                    case 'Y': return LetterPositionEnum.After;
                    case 'Z': return LetterPositionEnum.After;
                    default: return LetterPositionEnum.After;
                }
            case 'B':
                switch (compareLetter)
                {
                    case 'A': return LetterPositionEnum.PREV;
                    case 'B': return LetterPositionEnum.MATCH;
                    case 'C': return LetterPositionEnum.NEXT;
                    case 'D': return LetterPositionEnum.After;
                    case 'E': return LetterPositionEnum.After;
                    case 'F': return LetterPositionEnum.After;
                    case 'G': return LetterPositionEnum.After;
                    case 'H': return LetterPositionEnum.After;
                    case 'I': return LetterPositionEnum.After;
                    case 'J': return LetterPositionEnum.After;
                    case 'K': return LetterPositionEnum.After;
                    case 'L': return LetterPositionEnum.After;
                    case 'M': return LetterPositionEnum.After;
                    case 'N': return LetterPositionEnum.After;
                    case 'O': return LetterPositionEnum.After;
                    case 'P': return LetterPositionEnum.After;
                    case 'Q': return LetterPositionEnum.After;
                    case 'R': return LetterPositionEnum.After;
                    case 'S': return LetterPositionEnum.After;
                    case 'T': return LetterPositionEnum.After;
                    case 'U': return LetterPositionEnum.After;
                    case 'V': return LetterPositionEnum.After;
                    case 'W': return LetterPositionEnum.After;
                    case 'X': return LetterPositionEnum.After;
                    case 'Y': return LetterPositionEnum.After;
                    case 'Z': return LetterPositionEnum.After;
                    default: return LetterPositionEnum.After;
                }
            case 'C':
                switch (compareLetter)
                {
                    case 'A': return LetterPositionEnum.Before;
                    case 'B': return LetterPositionEnum.PREV;
                    case 'C': return LetterPositionEnum.MATCH;
                    case 'D': return LetterPositionEnum.NEXT;
                    case 'E': return LetterPositionEnum.After;
                    case 'F': return LetterPositionEnum.After;
                    case 'G': return LetterPositionEnum.After;
                    case 'H': return LetterPositionEnum.After;
                    case 'I': return LetterPositionEnum.After;
                    case 'J': return LetterPositionEnum.After;
                    case 'K': return LetterPositionEnum.After;
                    case 'L': return LetterPositionEnum.After;
                    case 'M': return LetterPositionEnum.After;
                    case 'N': return LetterPositionEnum.After;
                    case 'O': return LetterPositionEnum.After;
                    case 'P': return LetterPositionEnum.After;
                    case 'Q': return LetterPositionEnum.After;
                    case 'R': return LetterPositionEnum.After;
                    case 'S': return LetterPositionEnum.After;
                    case 'T': return LetterPositionEnum.After;
                    case 'U': return LetterPositionEnum.After;
                    case 'V': return LetterPositionEnum.After;
                    case 'W': return LetterPositionEnum.After;
                    case 'X': return LetterPositionEnum.After;
                    case 'Y': return LetterPositionEnum.After;
                    case 'Z': return LetterPositionEnum.After;
                    default: return LetterPositionEnum.After;
                }
//etc.  Stack Overflow limits characters to 30,000 contact me for full switch case.

   default: return LetterPositionEnum.After;
        }
    }
}

为什么要发明一个不如现有解决方案好的东西呢?你已经得到了多种已知可行的方法。我不确定你的代码是否能够正常工作。此外,你庞大的嵌套switch语句可以被简化成一些简单的代码:int diff = currChar - compareLetter; 然后是 if (diff == 1) return LetterPositionEnum.PREV; if (diff == -1) return LetterPositionEnum.NEXT; return LetterPositionEnum.After; - Jim Mischel
Matty,我没有完全检查你答案中的代码,但正如@JimMischel已经提到的那样,一些标准算法和数据结构可能更适合此任务。为了向您提供更多具体信息,我刚刚添加了一个答案,它演示了使用两种不同的前缀树实现解决您的问题,这两种方法有点类似于您似乎正在遵循的方法,因此可以更容易地探索和入门。 - Alex

0

好的,这是我想出的最终解决方案,我不确定它是否是最优的,但似乎非常快,并且我喜欢逻辑和简洁的代码。

基本上,在应用程序启动时,您将传入任意长度的单词列表到InitWords中。这将对单词进行排序并将它们放入一个字典中,该字典有26个键,每个键代表字母表中的一个字母。

然后在游戏过程中,您将遍历字符集,始终从第一个字母开始,然后是第一个和第二个字母,以此类推。在整个过程中,您将缩小CurrentWordList中单词的数量。

因此,如果您有字符串“icedgt”。您将使用“i”调用InitCompare,这将从MyDictionary中获取Key为“I”的KeyValuePair,然后您将查看第一个单词是否为长度为1,因为它们已按字母顺序排列,单词“I”将是第一个单词。然后在下一次迭代中,您将传入“c”到NextCompare,这再次使用Linq来仅返回具有第二个字符“c”的单词,从而减少了列表大小。接下来,您将执行另一个NextCompare并传入“e”,再次使用Linq减少CurrentWordList中单词的数量。

所以在第一次迭代后,您的CurrentWordList中有每个以'i'开头的单词,在NextCompare中,您将拥有每个以'ic'开头的单词,并且在NextCompare中,您将拥有其中每个单词都以'ice'开头的子集,依此类推。

我不确定Linq是否能够在速度上击败我的手动巨大Switch Case,但它简单而优雅。对此我感到高兴。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Xuzzle.Code
{
public class CompareHelper
{
    //Should always be sorted in alphabetical order.
    public static Dictionary<char, List<string>> MyDictionary;
    public static List<string> CurrentWordList;

    //The word we are trying to find matches for.
    public static char InitChar;
    public static StringBuilder ThisWord;

    /// <summary>
    /// Init MyDictionary with the list of words passed in.  Make a new
    /// key value pair with each Letter.
    /// </summary>
    /// <param name="listOfWords"></param>
    public static void InitWords(List<string> listOfWords)
    {
        MyDictionary = new Dictionary<char, List<string>>();
        foreach (char currChar in LetterHelper.Alphabet)
        {
            var wordsParsed = listOfWords.Where(currWord => char.ToUpper(currWord[0]) == currChar).ToArray();
            Array.Sort(wordsParsed);
            MyDictionary.Add(currChar, wordsParsed.ToList());
        }
    }

    /// <summary>
    /// Initialize the Compare.  Set the first character.  See if there are any 1 letter words
    /// for that character.
    /// </summary>
    /// <param name="firstChar">The first character in the word string.</param>
    /// <returns>True if a word was found.</returns>
    public static bool InitCompare(char firstChar)
    {
        InitChar = firstChar;
        //Get all words that start with the firstChar.
        CurrentWordList = MyDictionary[InitChar];
        ThisWord = new StringBuilder();
        ThisWord.Append(firstChar);

        if (CurrentWordList[0].Length == 1)
        {
            //Match.
            return true;
        }
        //No matches.
        return false;
    }

    /// <summary>
    /// Append this letter to our ThisWord.  See if there are any matching words.
    /// </summary>
    /// <param name="nextChar">The next character in the word string.</param>
    /// <returns>True if a word was found.</returns>
    public static bool NextCompare(char nextChar)
    {
        ThisWord.Append(nextChar);
        int currentIndex = ThisWord.Length - 1;
        if (CurrentWordList != null && CurrentWordList.Count > 0)
        {
            CurrentWordList = CurrentWordList.Where(word => (word.Length > currentIndex && word[currentIndex] == nextChar)).ToList();
            if (CurrentWordList != null && CurrentWordList.Count > 0)
            {
                if (CurrentWordList[0].Length == ThisWord.Length)
                {
                    //Match.
                    return true;
                }
            }
        }
        //No matches.
        return false;
    }
}
}

但是,如果你得到的字符串是" dice ",那么会发生什么呢?你的算法会找到单词" dice "、" i "和" ice "吗?我想问题是你是否在寻找给定字符串中的所有单词,还是只寻找位于给定字符串前面的单词? - Jim Mischel
根据所写的内容,似乎您的算法将针对给定字符串“icedgt”迭代“i”列表,以查找可能匹配的单词(在此情况下为6次)。这就是您的“CurrentWordList”的选择。这使得算法的时间复杂度为O(n * m),其中n是给定字符串的长度,m是以“i”开头的单词数。另一方面,Aho-Corasick算法的时间复杂度为O(n + z),其中n是给定字符串的长度,z是找到的匹配数。 - Jim Mischel
@JimMischel 我不确定你的第二条评论是否正确。每次迭代列表时,列表的大小都会缩小。例如,我有一个包含10,000个单词的列表,其中1,000个以字母“i”开头。因此,对于第一个单词“i”,我几乎没有使用任何CPU周期,因为我只是从我的字典中获取了“i”键。接下来,我从那些以i开头的1,000个单词中取出以ic开头的单词,这样我的要检查的单词列表就减少到了100个。 - MattyMerrix
@JimMischel 接下来我会检查有多少个单词以 ice 开头,因为如果一个单词不是以 ic 开头,那么它也不能以 ice 开头,所以我只需要检查剩下的 100 个单词是否以 ice 开头。所以在这 10,000 个单词中,有 1,000 个以 i 开头,其中有 100 个以 ic 开头,然后我发现只有 10 个以 ice 开头。我的下一次迭代非常快,因为我只需要检查 10 个单词。这些单词中有 1 个单词 "iced" 以 iced 开头。现在所有接下来的迭代都会几乎瞬间完成,因为我没有剩余的单词需要检查了。 - MattyMerrix
从您的评论中,似乎您正在寻找输入字符串的前缀在字典中的单词。这可以通过简单的合并非常快速地完成。请参阅我的答案。 - Jim Mischel
显示剩余2条评论

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