【问题标题】:Optimizing array iteration with nested Where (LINQ) clauses使用嵌套 Where (LINQ) 子句优化数组迭代
【发布时间】:2014-12-14 21:49:40
【问题描述】:

我正在创建一个具有搜索功能的 (C#) 工具。该搜索有点类似于“去任何地方”搜索(如 ReSharper 或 VS2013)。

搜索上下文是一个字符串数组,包含前面的所有项目:

private string[] context; // contains thousands of elements

搜索是增量的,并且随着用户提供的每个新输入(字符)发生。

我已经使用 LINQ Where 扩展方法实现了搜索:

// User searched for "c"
var input = "c";
var results = context.Where(s => s.Contains(input));

当用户搜索“ca”时,我尝试使用以前的结果作为搜索上下文,但这会导致(我认为?)嵌套的 Where 迭代,并且运行得不是很好。想想类似这样的代码:

// Cache these results.
var results = var results = context.Where(s => s.Contains(input));

// Next search uses the previous search results
var newResults = results.Where(s => s.Contains(input));

有没有办法优化这个场景?

每次搜索都将 IEnumerable 转换为数组会导致高内存分配并且运行不佳。

【问题讨论】:

  • 您可以使用ToList 实现中间结果。但这真的比每次都使用context 更有效吗?
  • ToList 比 ToArray 好吗?
  • 您没有使用ToArray 吗?如果列表很大,ToList 会更有效,因为加倍算法只需要找到大于或等于项目计数的大小,而数组必须具有正确的大小。但这不是我的意思。
  • 这听起来像是Rx 的完美用例,我已经在多个示例和教程中看到过这种情况,如果我找到一个我会发布它或围绕它写一个答案。
  • 考虑使用智能字符串搜索算法,随着输入字符串的增长和模式数量的增加,性能可能会显着下降。例如,Aho-CorasickRabin-Karp

标签: c# .net performance linq linq-to-objects


【解决方案1】:

向用户展示数以千计的搜索结果是毫无用处的。在将结果呈现给用户之前,您应该在查询中添加一个“top”(linq 中的Take)语句。

var results = context.Where(s => s.Contains(input)).Take(100);

如果你想向用户展示接下来的 100 个结果:

var results = context.Where(s => s.Contains(input)).Skip(100).Take(100);

还只需使用原始数组进行所有搜索,不要嵌套Where,因为除非您具体化查询,否则它没有任何好处。

【讨论】:

  • 谢谢。我忘了补充一点,我显然没有显示数千个结果。我只服用少量(例如:10)。
  • 问题是,经过几次搜索后,查询类似于: context.Where(s => s.Contains(input1)).Where(s => s.Contains(input2))等等……
  • 我不明白为什么任何后续搜索都会变慢。
  • 如果他按照他所说的去做(使用第一个 where 的结果作为第二个的上下文),它可能会更慢
  • 只需使用原始数组进行所有搜索。在内存中搜索具有几千个文件路径大小的元素的字符串数组应该非常快。
【解决方案2】:

我有几个有用的点要补充,太多了,无法评论。

首先,我同意您应该以.take(100) 开头的其他cmets,减少加载时间。更好的是,一次添加一个结果:

var results = context.Where(s => s.Contains(input));
var resultEnumerator = result.GetEnumerator()

循环遍历 resultEnumerator 以一次显示一个结果,当屏幕已满或启动新搜索时停止。

其次,限制您的输入。如果用户写Hello,您不想搜索HHeHelHellHello,只搜索Hello。当用户稍后添加world 时,可能值得采用您的旧结果并将Hello world 添加到where 子句。

results = results.Where(s => s.Contains(input));
resultEnumerator = result.GetEnumerator()

当然,当用户添加新文本时,取消当前正在进行的结果。

使用Rx,油门部分很简单,你会得到这样的:

var result = context.AsEnumerable();
var oldStr = "";
var resultEnumerator = result.GetEnumerator();
Observable.FromEventPattern(h => txt.TextChanged += h, h => txt.TextChanged -= h)
         .Select(s => txt.Text)
         .DistinctUntilChanged().Throttle(TimeSpan.FromMilliseconds(300))
         .Subscribe(s =>
         {
             if (s.Contains(oldStr))
                 result = result.Where(t => t.Contains(s));
             else
                 result = context.Where(t => t.Contains(s));
             resultEnumerator = result.GetEnumerator();
             oldStr = s;
             // and probably start iterating resultEnumerator again,
             // but perhaps not on this thread.
         });

【讨论】:

    【解决方案3】:

    如果你关心 allocs 并且你不想编写一个 trie 实现或使用第三方代码,你应该避免连续分区你的上下文数组以将匹配的条目聚集在前面。不是非常类似于 LINQ,但速度快且内存成本为零。

    分区扩展方法,基于C++的std::partition

    /// <summary>
    /// All elements for which predicate is true are moved to the front of the array.
    /// </summary>
    /// <param name="start">Index to start with</param>
    /// <param name="end">Index to end with</param>
    /// <param name="predicate"></param>
    /// <returns>Index of the first element for which predicate returns false</returns>
    static int Partition<T>(this T[] array, int start, int end, Predicate<T> predicate)
    {
        while (start != end)
        {
            // move start to the first not-matching element
            while ( predicate(array[start]) )
            {
                if ( ++start == end )
                {
                    return start;
                }
            }
    
            // move end to the last matching element
            do
            {
                if (--end == start)
                {
                    return start;
                }
            }
            while (!predicate(array[end]));
    
            // swap the two
            var temp = array[start];
            array[start] = array[end];
            array[end] = temp;
    
            ++start;
        }
        return start;
    }
    

    所以现在你需要存储最后一个分区索引,它应该用context长度初始化:

    private int resultsCount = context.Length;
    

    然后,对于输入中的每个增量更改,您都可以运行:

    resultsCount = context.Partition(0, resultsCount, s => s.Contains(input));
    

    每次这只会检查之前没有被过滤掉的元素,这正是你所追求的。

    对于每个非增量更改,您需要将 resultsCount 重置为原始值。

    您可以以方便、调试器和 LINQ 友好的方式公开结果:

    public IEnumerable<string> Matches
    {
        get { return context.Take(resultsCount); }
    }
    

    【讨论】:

      猜你喜欢
      • 2021-07-05
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2021-11-23
      • 2021-05-16
      • 1970-01-01
      相关资源
      最近更新 更多