如何比较两个字符串数组并找出所有连续匹配项,并保存它们的索引?

7
例如,如果我有以下两个数组:

string[] userSelect = new string[] {"the", "quick", "brown", "dog", "jumps", "over"};
string[] original = new string[] {"the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"};

我正在尝试将用户选择数组与原始数组进行比较,并根据索引获取所有连续匹配项。用户选择的数组将总是由原始数组中的字符串组成。因此,输出将会像以下这样:

int[] match0 = new int[] {0, 1, 2}; // indices for "the quick brown"
int[] match2 = new int[] {4, 5}; // indices for "jumps over"
int[] match1 = new int[] {3}; // index for "dog"

用户选择的数组长度永远不会超过原始数组长度,但它可能会更短,并且单词可以以任何顺序排列。我该如何处理这个问题?

你自己试过吗?看起来并不太复杂。 - JeffRSon
我已经尝试了一下,发现并不像我想象的那么容易,因为某些单词可能会出现多次,而我正在寻找最长连续匹配。例如,在上面的例子中,“the”这个单词可能会在句子中出现两次,而且都需要进行检查。 - Howard
听起来有点像:http://en.wikipedia.org/wiki/Longest_common_substring_problem。您可以将数组转换为分隔符字符串,并使用解决该问题的任何算法来解决您的问题。我从您的评论中推断出,您实际上只是想找到最长的一个,而不是每个匹配组合,尽管乍一看这就是您的问题所描述的方式:顺便说一句,已经在堆栈上发布了C#的实现:https://dev59.com/sVPTa4cB1Zd3GeqPgRxM - deepee1
不仅仅是最长的。我确实想要所有组合,但基于第一个数组,只包括没有重叠单词的最长组合。 - Howard
第二个“the”是匹配的还是不匹配的?因为它们没有重叠。 - JeffRSon
你能提供更多细节吗?在上面的例子中,userSelect数组只有一个实例是“the”,所以这并不重要。 - Howard
5个回答

2

这是我想到的内容

var matches = 
    (from l in userSelect.Select((s, i) => new { s, i })
     join r in original.Select((s, i) => new { s, i }) 
     on l.s equals r.s 
     group l by r.i - l.i into g
     from m in g.Select((l, j) => new { l.i, j = l.i - j, k = g.Key })
     group m by new { m.j, m.k } into h
     select h.Select(t => t.i).ToArray())
    .ToArray();

这将输出:
matches[0] // { 0, 1, 2 } the quick brown
matches[1] // { 4, 5 } jumps over
matches[2] // { 0 } the 
matches[3] // { 3 } dog

使用输入{"the", "quick", "brown", "the", "lazy", "dog"}会产生以下结果:
matches[0] // { 0, 1, 2 } the quick brown
matches[1] // { 0 } the 
matches[2] // { 3 } the
matches[3] // { 3, 4, 5 } the lazy dog

请注意,对于ToArray的调用是可选的。如果您实际上不需要将结果存储在数组中,则可以省略该调用并节省一些处理时间。
要过滤掉任何完全包含在其他更大序列中的序列,您可以运行此代码(请注意修改后查询中的orderby):
var matches = 
    (from l in userSelect.Select((s, i) => new { s, i })
     join r in original.Select((s, i) => new { s, i }) 
     on l.s equals r.s 
     group l by r.i - l.i into g
     from m in g.Select((l, j) => new { l.i, j = l.i - j, k = g.Key })
     group m by new { m.j, m.k } into h
     orderby h.Count() descending
     select h.Select(t => t.i).ToArray());

int take = 0;
var filtered = matches.Where(m => !matches.Take(take++)
                                          .Any(n => m.All(i => n.Contains(i))))
    .ToArray();

如果 userSelect 是 {"the", "quick", "brown", "the", "lazy", "dog"},这个程序会正常工作吗? - Jim Mischel
@rotaercz 看看我的更新答案。它仍然没有过滤掉重叠的序列,但已经接近了。如果输入是{"the", "quick", "brown", "quick", "brown", "fox" },预期结果应该是什么? - p.s.w.g
是的,那确实非常接近。 - Howard
我得到的结果是{ { 0, 1, 2, 3 }, { 4, 5, 6 }, { 7, 8 },对应着"司机感到惊讶","; 雪橇"和"移动了。"。这看起来对我来说是正确的。结果应该是什么? - p.s.w.g
你是对的,它正常工作了!我向控制台打印的 for 循环有误。 - Howard
显示剩余14条评论

2

如果单词不能重复,这将更容易……

总体思路是从原始单词列表创建一个Dictionary<string, List<int>>。这将告诉您哪些单词在哪个位置使用。对于您的示例,字典如下:

key="the", value={0, 6}
key="quick", value={1}
key="brown", value={2}
... etc

现在,当你获取用户输入时,需要按顺序逐个查找单词,并查询字典以获取位置列表。
所以你查找一个单词,它在字典中。你保存从字典返回的位置(s)。查找下一个单词。需要处理三种情况:
1. 单词不在字典中。保存前一个连续分组并进入下一个单词,在那里你有可能开始一个新的分组。 2. 单词在字典中,但没有任何位置匹配预期的位置(预期的位置是上一个单词保存位置的的下一个位置)。保存前一个连续分组并进入下一个单词,在那里你有可能开始一个新的分组。 3. 单词在字典中并且返回的位置之一与预期位置匹配。保存这些位置并进入下一个单词。
希望你能理解这个概念。

这非常接近我所思考的方向。只是我很难将其转换为代码。 - Howard

1
这并不完全符合您的要求,但是这是一种非常简洁和简单的方法,可以获得一个包含所有共同字符串的新数组(即获取两个数组的交集)。
var results = array1.Intersect(array2, StringComparer.OrdinalIgnoreCase);

执行后,results数组将包含在array1array2中都出现(忽略大小写)的每个字符串。
如果您想了解一些理论,intersect方法基于lambda演算中集合的交集操作。C#中的集合实现了所有常见的集合操作,因此熟悉它们是值得的。这是一个维基百科文章链接:http://en.wikipedia.org/wiki/Intersection_(set_theory)

这并没有帮助,因为根据你的代码,在我所有的示例中,userSelect数组的所有值每次都会被返回。 - Howard
@rotaercz 我不太明白你的意思。这不是交集的工作方式。如果我对以下数组取交集; { 1, 2, 4, 7 }{ 2, 7, 9, 10, 11 } 结果应该是 { 2, 7 }。你的第二个代码块没有意义。它在硬编码源数组和注释中字符串的交集索引。为什么要取索引而不是元素本身呢? - evanmcdonnal
第二个代码块是我想要的结果,它们是一个数组列表,其中包含保存了userSelect数组索引值的数组。我正在寻找最长连续匹配项。例如:{A,B,D,G}和{B,D,G,Y,Z}将导致:{1,2,3}因为B,D,G在第一个数组的1、2、3索引中。 - Howard

1

这并不是非常优雅但却很有效。当涉及到索引时,Linq 通常会比简单的循环更加复杂和低效。

string[] userSelect = new string[] { "the", "quick", "brown", "dog", "jumps", "over" };
string[] original = new string[] { "the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog" };
var consecutiveGroups = new Dictionary<int, IList<string>>();
IList<Tuple<int, string>> uniques = new List<Tuple<int, string>>();

int maxIndex = Math.Min(userSelect.Length, original.Length);
if (maxIndex > 0)
{
    int minIndex = 0;
    int lastMatch = int.MinValue;
    for (int i = 0; i < maxIndex; i++)
    {
        var us = userSelect[i];
        var o = original[i];
        if (us == o)
        {
            if (lastMatch == i - 1)
                consecutiveGroups[minIndex].Add(us);
            else
            {
                minIndex = i;
                consecutiveGroups.Add(minIndex, new List<string>() { us });
            }
            lastMatch = i;
        }
        else
            uniques.Add(Tuple.Create(i, us));
    }
} 

输出连续组和唯一组的索引:
var consecutiveGroupsIndices = consecutiveGroups
    .OrderByDescending(kv => kv.Value.Count)
    .Select(kv => Enumerable.Range(kv.Key, kv.Value.Count).ToArray()
    .ToArray());
foreach(var consIndexGroup in consecutiveGroupsIndices)
    Console.WriteLine(string.Join(",", consIndexGroup));
Console.WriteLine(string.Join(",", uniques.Select(u => u.Item1)));

我试图测试你的答案,但是我的Android项目使用的是.NET 3.5版本,所以我无法使用元组。 - Howard
@rotaercz:那就使用List<int>并添加唯一项的索引,或者如果您想查看字符串,则使用List<string>。我使用了第二个集合,因为唯一项根本不属于_连续组_,但是您仍然希望像您期望的输出一样看到它们。 - Tim Schmelter
我只需要用List<string>替换Tuple<int, string>吗?我不熟悉Tuple对象。 - Howard
@rotaercz:不,我认为您只想看到唯一值的索引。那么您需要使用List<int>。请将IList<Tuple<int, string>> uniques = new List<Tuple<int, string>>();替换为var uniques = new List<int>()。然后将单行代码uniques.Add(Tuple.Create(i, us));替换为uniques.Add(i)。请注意,.NET 4中新增了一个带有IEnumerable<string>重载的String.Join方法。因此,您需要使用ToArray将它们转换为数组。例如(对于最后的唯一值):Console.WriteLine(string.Join(",", uniques.Select(i => i.ToString()).ToArray())); - Tim Schmelter
它提供了与您的问题相同的结果。主要结果在字典中,其中键是每个组的最小索引,值是所有连续字符串的列表。 - Tim Schmelter

0

使用LINQ增加乐趣

经过几次尝试,我想出了一个纯LINQ解决方案,理论上可以成为一行代码。我尝试让它高效,但是功能性的解决方案会导致重复计算,因为你无法保持状态。

我们从一些预处理开始,以便稍后节省重复计算。是的,我知道使用索引可能是一个有问题的做法,但是如果你小心谨慎,它可以快速实现:

var index = 0;
var lookup = original.ToLookup(s => s, s => index++);

庞然大物

var occurrences = userSelect
  .Where(lookup.Contains)
  .SelectMany((s, i) => lookup[s]
    .Select(j => new {
      User = userSelect.Skip(i),
      Original = original.Skip(j),
      Skipped = i
    })
    .Select(t => t.User.Zip(t.Original, (u, v) => Tuple.Create(u, v, t.Skipped))
                       .TakeWhile(tuple => tuple.Item1 == tuple.Item2)
    )
    .Select(u => new { 
      Word = s, 
      Start = u.Select(v => v.Item3).Min(), 
      Length = u.Count()
    })
  )
  .GroupBy(v => v.Start + v.Length)
  .Select(g => g.OrderBy(u => u.Start).First())
  .GroupBy(v => v.Word)
  .Select(g => g.OrderByDescending(u => u.Length).First())
  .Select(w => Enumerable.Range(w.Start, w.Length).ToArray())
  .ToList();

使用打印

foreach (var occurrence in occurrences) {
  Console.WriteLine(
    "Maximal match starting with '{0}': [{1}]",
    userSelect[occurrence[0]],
    string.Join(", ", occurrence)
  );
}

提供

Maximal match starting with 'the': [0, 1, 2]
Maximal match starting with 'dog': [3]
Maximal match starting with 'jumps': [4, 5]

很明显,你不会想在生产中使用这段代码,另一种(过程式)解决方案要好得多。然而,这个解决方案的独特之处在于除了lookup之外完全是纯函数式的。当然,这也可以用函数式的方式编写:

var lookup = original.Select((s, i) => Tuple.Create)
                     .ToLookup(t => t.Item1, t => t.Item2);

它是如何工作的

在热身阶段,它创建了一个类似于字典的结构,将original中的每个单词与其在同一集合中出现的索引相关联。稍后将使用此结构从userSelect中的每个单词创建尽可能多的匹配序列(例如,“the”将导致两个匹配序列,因为它在original中出现了两次)。

然后:

.Where(lookup.Contains)

这很简单,它会从userSelect中删除所有不在original中出现的单词。

 // For each place where the word s appears in original...
.SelectMany((s, i) => lookup[s]
  // Define the two subsequences of userSelect and original to work on.
  // We are trying to find the number of identical elements until first mismatch.
  .Select(j => new { User = userSelect.Skip(i), Original = original.Skip(j), Skipped = j })

  // Use .Zip to find this subsequence
  .Select(t => t.User.Zip(t.Original, (u, v) => Tuple.Create(u, v, t.Skipped)).TakeWhile(tuple => tuple.Item1 == tuple.Item2))

  // Note the index in original where the subsequence started and its length
  .Select(u => new { Word = s, Start = u.Select(v => v.Item3).Min(), Length = u.Count() })
)

此时,我们已经将userSelect中的每个匹配单词投影到一个带有StartLength属性的匿名对象中。然而,在匹配长度为N的序列中,还会导致长度为N-1、N-2等的更小的匹配序列。

关键在于意识到对于这些集合中的所有子序列,Start + Length将是相同的;此外,来自不同集合的子序列将具有不同的Start + Length之和。因此,让我们利用这一点来简化结果:

// Obvious from the above
.GroupBy(v => v.Start + v.Length)

// We want to keep the longest subsequence. Since Start + Length is constant for
// all, it follows the one with the largest Length has the smallest Start:
.Select(g => g.OrderBy(u => u.Start).First())

这仍将为我们留下与userSelect中每个单词匹配次数相同的匹配项,就像在original中出现的次数一样。因此,让我们将其减少到最长匹配:
.GroupBy(v => v.Word)
.Select(g => g.OrderByDescending(u => u.Length).First())

现在我们有一个对象,如{ Word = "the", Start = 0, Length = 3 }。让我们将其转换为userSelect中的索引数组:
.Select(w => Enumerable.Range(w.Start, w.Length).ToArray())

最后,将所有这些数组放入同一集合中,任务完成!

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