Levenshtein算法用于子字符串匹配的C#实现

问题描述

我正在使用Levenshtein distance获取C#实现,该实现不仅可以判断两个字符串是否相似,而且还可以在更大的字符串中找到相似的字符串( needle )字符串(干草堆)。

为此,我尝试遵循this excellent post底部的建议,但是遇到了一些问题。

首先,我采用了this implementation,并对其进行了更改以满足我的其他要求。在this other post的启发下,我还添加了一些诊断转储支持,以使我更好地理解算法。

我的实现返回一个对象,该对象具有分数,(如果需要)索引和长度,以及对用于诊断目的的计算矩阵的引用:

public class LevenshteinMatch
{
    public int score { get; }
    public int Index { get; }
    public int Length { get; }
    public int[,] Matrix { get; set; }

    public LevenshteinMatch(int score,int index = 0,int length = 0)
    {
        score = score;
        Index = index;
        Length = length;
    }

    public override string ToString()
    {
        return $"{score} @{Index}x{Length}";
    }
}

这是我的实现:如果distance为false,则sub方法“正常”工作;否则,它将找到类似的子字符串。 DumpMatrix只是诊断助手方法

public static class Levenshtein
{
    public static string DumpMatrix(int[,] d,string a,string b)
    {
        if (d == null) throw new ArgumentNullException(nameof(d));
        if (a == null) throw new ArgumentNullException(nameof(a));
        if (b == null) throw new ArgumentNullException(nameof(b));

        //      #  k  i  t  t  e  n  
        //      00 01 02 03 04 05 06 
        // # 00 .. .. .. .. .. .. .. 
        // s 01 .. .. .. .. .. .. .. 
        // ...etc (sitting)

        StringBuilder sb = new StringBuilder();
        int n = a.Length;
        int m = b.Length;

        // b-legend
        sb.Append("     #  ");
        for (int j = 0; j < m; j++) sb.Append(b[j]).Append("  ");
        sb.AppendLine();
        sb.Append("     00 ");
        for (int j = 1; j < m; j++) sb.AppendFormat("{0:00}",j).Append(' ');
        sb.AppendFormat("{0:00} ",m).AppendLine();

        // matrix
        for (int i = 0; i <= n; i++)
        {
            // a-legend
            if (i == 0)
            {
                sb.Append("# 00 ");
            }
            else
            {
                sb.Append(a[i - 1])
                  .Append(' ')
                  .AppendFormat("{0:00}",i)
                  .Append(' ');
            }

            // row of values
            for (int j = 0; j <= m; j++)
                sb.AppendFormat("{0,2} ",d[i,j]);
            sb.AppendLine();
        }
        return sb.ToString();
    }

    private static LevenshteinMatch BuildMatch(string a,string b,int[,] d)
    {
        int n = a.Length;
        int m = b.Length;

        // take the min rightmost score instead of the bottom-right corner
        int min = 0,rightMinIndex = -1;
        for (int j = m; j > -1; j--)
        {
            if (rightMinIndex == -1 || d[n,j] < min)
            {
                min = d[n,j];
                rightMinIndex = j;
            }
        }

        // corner case: perfect match,just collect m chars from score=0
        if (min == 0)
        {
            return new LevenshteinMatch(min,rightMinIndex - n,n);
        }

        // collect all the lowest scores on the bottom row leftwards,// up to the length of the needle
        int count = n,leftMinIndex = rightMinIndex;
        while (leftMinIndex > -1)
        {
            if (d[n,leftMinIndex] == min && --count == 0) break;
            leftMinIndex--;
        }

        return new LevenshteinMatch(min,leftMinIndex - 1,rightMinIndex + 1 - leftMinIndex);
    }

    public static LevenshteinMatch distance(string a,bool sub = false,bool withmatrix = false)
    {
        if (a is null) throw new ArgumentNullException(nameof(a));
        if (b == null) throw new ArgumentNullException(nameof(b));

        int n = a.Length;
        int m = b.Length;
        int[,] d = new int[n + 1,m + 1];

        if (n == 0) return new LevenshteinMatch(m);
        if (m == 0) return new LevenshteinMatch(n);

        for (int i = 0; i <= n; i++) d[i,0] = i;
        // if matching substring,leave the top row to 0
        if (!sub)
        {
            for (int j = 0; j <= m; j++) d[0,j] = j;
        }

        for (int j = 1; j <= m; j++)
        {
            for (int i = 1; i <= n; i++)
            {
                if (a[i - 1] == b[j - 1])
                {
                    d[i,j] = d[i - 1,j - 1];  // no operation
                }
                else
                {
                    d[i,j] = Math.Min(Math.Min(
                        d[i - 1,j] + 1,// a deletion
                        d[i,j - 1] + 1),// an insertion
                        d[i - 1,j - 1] + 1 // a substitution
                        );
                }
            }
        }

        LevenshteinMatch match = sub
            ? BuildMatch(a,b,d)
            : new LevenshteinMatch(d[n,m]);
        if (withmatrix) match.Matrix = d;
        return match;
    }
}

为了更完整,这里是使用它的演示控制台程序。这只会提示用户输入匹配模式(是否为子字符串)和两个字符串,然后调用distance方法,转储结果矩阵,并在需要时显示子字符串。

internal static class Program
{
    private static string ReadLine(string defaultLine)
    {
        string s = Console.ReadLine();
        return string.IsNullOrEmpty(s) ? defaultLine ?? s : s;
    }

    private static void Main()
    {
        Console.WriteLine("Fuzzy Levenshtein Matcher");

        string a = "sitting",b = "kitten";
        bool sub = false;
        LevenshteinMatch match;

        while (true)
        {
            Console.Write("sub [y/n]? ");
            string yn = Console.ReadLine();
            if (!string.IsNullOrEmpty(yn)) sub = yn == "y" || yn == "Y";

            Console.Write(sub? $"needle ({a}): " : $"A ({a}): ");
            a = ReadLine(a);

            Console.Write(sub? $"haystack ({b}): " : $"B ({b}): ");
            b = ReadLine(b);

            match = Levenshtein.distance(a,sub,true);
            Console.WriteLine($"{a} - {b}: {match}");
            Console.WriteLine(Levenshtein.DumpMatrix(match.Matrix,a,b));
            if (sub) Console.WriteLine(b.Substring(match.Index,match.Length));
        }
    }
}

现在,对于子字符串匹配,这在“ c abba c”中的“ aba”情况下有效。这是矩阵:

aba - c abba c: 1 @3x3
     #  c     a  b  b  a     c
     00 01 02 03 04 05 06 07 08
# 00  0  0  0  0  0  0  0  0  0
a 01  1  1  1  0  1  1  0  1  1
b 02  2  2  2  1  0  1  1  1  2
a 03  3  3  3  2  1  1  1  2  2

在其他情况下,例如“ ego sum abbas Cucaniensis”中的“ abas”,我无法从最下面的行中收集最低分数:

abas - ego sum abbas Cucaniensis: 1 @-2x15
     #  e  g  o     s  u  m     a  b  b  a  s     C  u  c  a  n  i  e  n  s  i  s
     00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
# 00  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
a 01  1  1  1  1  1  1  1  1  1  0  1  1  0  1  1  1  1  1  0  1  1  1  1  1  1  1
b 02  2  2  2  2  2  2  2  2  2  1  0  1  1  1  2  2  2  2  1  1  2  2  2  2  2  2
a 03  3  3  3  3  3  3  3  3  3  2  1  1  1  2  2  3  3  3  2  2  2  3  3  3  3  3
s 04  4  4  4  4  4  3  4  4  4  3  2  2  2  1  2  3  4  4  3  3  3  3  4  3  4  3

此处底行只有一个分数= 1。在完美匹配(分数= 0)的情况下,我的代码仅从最右边的最低得分中获取了左N个字符(其中N是针的长度)。但是在这里我的分数大于0。可能是我误解了上面帖子中的提示,因为我是这种算法的新手。有人能建议在干草堆中找到针的索引和长度的正确方法吗?

解决方法

您从最下面一行的最高分开始:(13,4)的1分

然后您会发现可能使您到达那里的前任状态和过渡:

  • (12,4)-不可能,因为它具有更高的差异
  • (13,3)-不可能,因为它具有更高的差异
  • (12,3)-相同的区别和字符匹配,因此可行

从(12,3)开始,按照相同的步骤到达(11,2),然后到达(10,1)

在(10,1)处字母不匹配,因此您不可能来自(9,0)。您可以将(10,0)用于相似的字符串“ bas”,也可以将(9,1)然后将(8,0)用于相似的字符串“ abbas”,两者的距离均为1。