子字串查詢演算法

無風聽海發表於2022-01-04

一、什麼是子字串查詢
子字串查詢是一種基本的字串操作,是給定一段長度為N的文字和一個長度為M的模式(pattern)字串,在文字中找到一個和該模式相符的子字串的操作;

在實際的應用場景中,模式相對文字來說是很短的,即M遠小於N,我們一般也會對模式進行預處理來支援在文字中的快速查詢。

二、測試環境及基礎類

開發語言使用的是C#;

StringSearcher基礎的基類,負責載入檔案內容、字串查詢、效能測試等;

    public abstract class StringSearcher
    {
        public string[] Lines { get; set; }
        public StringSearcher(string textFile)
        {
            Lines = GetFileContent(textFile);
        }

        protected virtual string[] GetFileContent(string textFile)
        {
           var lines =  File.ReadAllLines(textFile);
            return lines;
        }

        public abstract IEnumerable<Match> IndexOf(string pattern);

        public virtual void Print(IEnumerable<Match> matches)
        {
            foreach (var m in matches)
            {
                m.Print();
            }
        }

        public void PerfTest(int testNumber, string pattern)
        {
            Console.WriteLine($"we will execute  {testNumber} times.");
            Stopwatch watch = new Stopwatch();
            watch.Start();
            for (int i = 0; i < testNumber; i++)
            {
                this.IndexOf(pattern);
            }

            watch.Stop();
            Console.WriteLine($"execute total time is {watch.ElapsedMilliseconds} ms");
        }
    }

Match類負責記錄字串搜尋的匹配資訊,以便輸出搜尋結果;

    public class Match
    {
        public int NO { get; set; }

        public int Start { get; set; }

        public int Lenght { get; set; }

        public string Line { get; set; }

        public void Print()
        {
            Console.WriteLine($"Line {NO}, start {Start}");
        }
    }

三、暴力子字串查詢演算法

所謂暴力查詢演算法就是使用文字中的字元逐個的跟模式進行比較,如果不匹配則直接從下一個文字字元重新開始比較;

    public class BruteForceStringSearcher : StringSearcher
    {
        public BruteForceStringSearcher(string filePath):base(filePath)
        { }

        public override IEnumerable<Match> IndexOf(string pattern)
        {           
            List<Match> result = new List<Match>();
            int pLen = pattern.Length;
            for (var lNO =0; lNO<Lines.Length; lNO ++)
            {
                string line = Lines[lNO];
                int lLen = line.Length;               
                for (int i = 0; i < lLen - pLen; i++)
                {
                    int j = 0;
                    for (; j < pLen; j++)
                    {
                        if (line[i + j] != pattern[j])
                        {
                            break;
                        }
                    }

                    if (j == pLen)
                    {
                        var m = new Match {
                            NO= lNO,
                            Line = line,
                            Start = i,
                            Lenght = pLen
                        };

                        result.Add(m);
                        break;
                    }
                }
            }

            return result;
        }
    }

從暴力子串查詢演算法的實現來看,文字串除了最後的N個字元之外,每個文字字元都會進行N的比較,所以時間複雜度是(M-N)*N;

從下圖我們可以看到暴力子字串查詢演算法最壞的情況,只有文字串和模式串存在大部分重複字元的特殊情況下才會出現;但是從自然語言的實際使用場景來看,基本上不會出現這種情況,更多的時候是模式串很短,絕大多數比較會在比較第一個字元的時候就會產生不匹配,所以時間複雜度可以粗略的算作跟文字串的長度成正比;

image

進行簡單的測試

            var searcher =  new BruteForceStringSearcher("txt");
            var result = searcher.IndexOf("async");
            searcher.Print(result);

            searcher.PerfTest(10000, "async");

            //Line 0, start 39
            //Line 2, start 59
            //Line 4, start 53
            //Line 5, start 94
            //Line 6, start 18
            //Line 7, start 44
            //we will execute  10000 times.
            //execute total time is 50 ms

四、KMP子字串查詢演算法

針對暴力演算法的最壞情況進行分析,我們已經知曉了文字串的一些資訊,而這些資訊其實跟模式串的當前字首是相同的,如果我們針對模式串進行一些預處理的話,其實是可以避免這種情況下的文字串指標的回退的;

這個演算法就是KMP演算法,是由Knuth、Morris、Pratt共同提出的模式匹配演算法;

通過下圖我們可以看到KMP演算法的思想的原理;

在位置i處,我們的文字串的字元是a,模式串的字元是b,很顯然兩者不相等了;

由於已經匹配的字首模式串中存在兩個相等的子模式串A和B,所以可以直接移動整個模式串,直至A佔用B的位置,然後將A後邊的字元跟a進行比較,從而避免了i指標的回退;

這裡需要強調的幾個關鍵點是

模式串的前i-1個字元與文字串是匹配的;

A是這個i-1為的子模式串的最大字首公共子串;

B是這個i-1為的子模式串的最後字首公共子串;

A和B串是從左到右每個對應的字元相等;

模式串移動的長度是i-1-最大公共子串的長度;

image

還是針對暴力破解最壞的情況,由於BAAAA不存在最大公共子串,所以直接將模式串右移5-0=5個位置,則直接跳過文字串中4次比較計算;

image

通過KMP演算法的原理的瞭解,演算法的關鍵是對模式串的每個字元的最大公共子串的長度;我們使用next陣列來承載這個最大公共子串的長度;

next陣列的值是一個具體的數值,表示最大公共子串的長度;

對於next陣列的計算,當前字元的結果是依賴上一個字元的結果的;

我們需要比較兩個公共子串A和B右側的元素是否相等

如果相等則直接將上一個字元的結果=1儲存即可;

如果不相等,則需要找到A的次一級最長公共子串A',然後計算A'和B右側的元素是否相等,如果相等則將A'的結果=1儲存即可;如果不等則進行遞迴操作即可;

image

根據以上討論,我們實現了KMPStringSearcher

 public class KMPStringSearcher : StringSearcher
    {
        Dictionary<string, List<int>> nexts = new Dictionary<string, List<int>>();
        public KMPStringSearcher(string filePath):base(filePath)
        { }

        List<int> GetNext(string pattern)
        {
            if (!nexts.ContainsKey(pattern))
            {
                List<int> next = new List<int>();
                nexts.Add(pattern, next);
                InitNext(pattern, next);
            }

            return nexts[pattern];
        }

        void InitNext(string pattern, List<int> next)
        {
            next.Add( -1);
            for (int i = 1; i < pattern.Length; i++)
            {
                int j = next[i - 1];
                if (pattern[j + 1] != pattern[i] && j >= 0)
                {
                    j = next[j];
                }

                if (pattern[j + 1] == pattern[i])
                {
                    next.Add(j+1);
                }
                else
                {
                    next.Add(-1);
                }
            }
        }

        public override IEnumerable<Match> IndexOf(string pattern)
        {           
            List<Match> result = new List<Match>();
            var next = GetNext(pattern);
            int pLen = pattern.Length;
            for (var lNO =0; lNO<Lines.Length; lNO ++)
            {
                string line = Lines[lNO];
                int lLen = line.Length;
                int j = 0;
                for (int i = 0; i < lLen - pLen; )
                {
                    for (; j < pLen; j++, i++)
                    {
                        if (line[i] != pattern[j])
                        {
                            if (j != 0)
                            {
                                j = next[j - 1] + 1;
                            }
                            else
                            {
                                i++;
                            }
                            break;
                        }
                    }

                    if (j == pLen)
                    {
                        var m = new Match {
                            NO= lNO,
                            Line = line,
                            Start = i - pLen,
                            Lenght = pLen
                        };

                        result.Add(m);
                        break;
                    }
                }
            }

            return result;
        }
    }

對相同的資料進行測試,從測試結果可以看到,雖然KMP演算法通過一個O(m)的預處理,使匹配的複雜度降為O(n+m),但是KMP演算法的這個理論成果並不能為最壞情況的線性級別執行時間做保證。在實際應用中,它比暴力演算法的速度優勢並不十分明顯,因為極少有應用程式需要在重複性很高的文字中查詢重複性很高的模式。

            var searcher = new KMPStringSearcher("txt");
            var result = searcher.IndexOf("async");
            searcher.Print(result);

            searcher.PerfTest(10000, "async");

            //Line 0, start 39
            //Line 2, start 59
            //Line 4, start 53
            //Line 5, start 94
            //Line 6, start 18
            //Line 7, start 44
            //we will execute  10000 times.
            //execute total time is 64 ms

相關文章