搜索字符串集合的最快方法

时间:2014-01-27 11:07:44

标签: c# winforms linq collections string-search

问题:

我有一个大约 120,000 用户(字符串)的文本文件,我希望将其存储在一个集合中,然后再对该集合执行搜索。

每次用户更改TextBox的文字时都会发生搜索方法,结果应该是{strong> 包含 TextBox中文字的字符串1}}。

我不必更改列表,只需提取结果并将其放入ListBox

到目前为止我尝试了什么:

我尝试了两个不同的集合/容器,我正在从外部文本文件中转储字符串条目(当然是一次):

  1. List<string> allUsers;
  2. HashSet<string> allUsers;
  3. 使用以下LINQ查询:

    allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();

    我的搜索事件(用户更改搜索文本时触发):

    private void textBox_search_TextChanged(object sender, EventArgs e)
    {
        if (textBox_search.Text.Length > 2)
        {
            listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();
        }
        else
        {
            listBox_choices.DataSource = null;
        }
    }
    

    结果:

    两者都给我一个糟糕的响应时间(每次按键之间大约1-3秒)。

    问题:

    您认为我的瓶颈在哪里?我用过的系列?搜索方法?既?

    如何才能获得更好的性能和更流畅的功能?

17 个答案:

答案 0 :(得分:48)

您可以考虑在后台线程上执行过滤任务,该线程会在完成时调用回调方法,或者只是在输入更改时重新启动过滤。

一般的想法是能够像这样使用它:

public partial class YourForm : Form
{
    private readonly BackgroundWordFilter _filter;

    public YourForm()
    {
        InitializeComponent();

        // setup the background worker to return no more than 10 items,
        // and to set ListBox.DataSource when results are ready

        _filter = new BackgroundWordFilter
        (
            items: GetDictionaryItems(),
            maxItemsToMatch: 10,
            callback: results => 
              this.Invoke(new Action(() => listBox_choices.DataSource = results))
        );
    }

    private void textBox_search_TextChanged(object sender, EventArgs e)
    {
        // this will update the background worker's "current entry"
        _filter.SetCurrentEntry(textBox_search.Text);
    }
}

粗略的草图就像是:

public class BackgroundWordFilter : IDisposable
{
    private readonly List<string> _items;
    private readonly AutoResetEvent _signal = new AutoResetEvent(false);
    private readonly Thread _workerThread;
    private readonly int _maxItemsToMatch;
    private readonly Action<List<string>> _callback;

    private volatile bool _shouldRun = true;
    private volatile string _currentEntry = null;

    public BackgroundWordFilter(
        List<string> items,
        int maxItemsToMatch,
        Action<List<string>> callback)
    {
        _items = items;
        _callback = callback;
        _maxItemsToMatch = maxItemsToMatch;

        // start the long-lived backgroud thread
        _workerThread = new Thread(WorkerLoop)
        {
            IsBackground = true,
            Priority = ThreadPriority.BelowNormal
        };

        _workerThread.Start();
    }

    public void SetCurrentEntry(string currentEntry)
    {
        // set the current entry and signal the worker thread
        _currentEntry = currentEntry;
        _signal.Set();
    }

    void WorkerLoop()
    {
        while (_shouldRun)
        {
            // wait here until there is a new entry
            _signal.WaitOne();
            if (!_shouldRun)
                return;

            var entry = _currentEntry;
            var results = new List<string>();

            // if there is nothing to process,
            // return an empty list
            if (string.IsNullOrEmpty(entry))
            {
                _callback(results);
                continue;
            }

            // do the search in a for-loop to 
            // allow early termination when current entry
            // is changed on a different thread
            foreach (var i in _items)
            {
                // if matched, add to the list of results
                if (i.Contains(entry))
                    results.Add(i);

                // check if the current entry was updated in the meantime,
                // or we found enough items
                if (entry != _currentEntry || results.Count >= _maxItemsToMatch)
                    break;
            }

            if (entry == _currentEntry)
                _callback(results);
        }
    }

    public void Dispose()
    {
        // we are using AutoResetEvent and a background thread
        // and therefore must dispose it explicitly
        Dispose(true);
    }

    private void Dispose(bool disposing)
    {
        if (!disposing)
            return;

        // shutdown the thread
        if (_workerThread.IsAlive)
        {
            _shouldRun = false;
            _currentEntry = null;
            _signal.Set();
            _workerThread.Join();
        }

        // if targetting .NET 3.5 or older, we have to
        // use the explicit IDisposable implementation
        (_signal as IDisposable).Dispose();
    }
}

此外,您应该在处置父_filter时实际处置Form实例。这意味着您应该打开并修改Form的{​​{1}}方法(在Dispose文件中),看起来像这样:

YourForm.Designer.cs

在我的机器上,它运行得非常快,因此在进行更复杂的解决方案之前,您应该对其进行测试和分析。

话虽这么说,“更复杂的解决方案”可能是将最后几个结果存储在字典中,然后只有在新条目仅与最后一个字符的第一个不同时才过滤它们。 / p>

答案 1 :(得分:36)

我已经完成了一些测试,并且搜索了120,000个项目的列表并使用这些条目填充新列表所花费的时间可以忽略不计(即使所有字符串都匹配,大约是1/50秒)。

因此,您所看到的问题必须来自数据源的填充,例如:

listBox_choices.DataSource = ...

我怀疑你只是在列表框中放了太多项目。

也许您应该尝试将其限制为前20个条目,如下所示:

listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text))
    .Take(20).ToList();

另请注意(正如其他人指出的那样)您正在访问TextBox.Text中每个项目的allUsers属性。这可以很容易地解决如下:

string target = textBox_search.Text;
listBox_choices.DataSource = allUsers.Where(item => item.Contains(target))
    .Take(20).ToList();

但是,我计算了访问TextBox.Text 500,000次需要多长时间,它只用了0.7秒,远远少于OP中提到的1-3秒。不过,这是值得进行的优化。

答案 2 :(得分:28)

使用Suffix tree作为索引。或者更确切地说,只需构建一个排序字典,将每个名称的每个后缀与相应名称列表相关联。

输入:

Abraham
Barbara
Abram

结构如下:

a -> Barbara
ab -> Abram
abraham -> Abraham
abram -> Abram
am -> Abraham, Abram
aham -> Abraham
ara -> Barbara
arbara -> Barbara
bara -> Barbara
barbara -> Barbara
bram -> Abram
braham -> Abraham
ham -> Abraham
m -> Abraham, Abram
raham -> Abraham
ram -> Abram
rbara -> Barbara

搜索算法

假设用户输入“bra”。

  1. Bisect用户输入的字典,用于查找用户输入或可以进入的位置。这样我们发现“barbara” - 最后一个键低于“bra”。它被称为“胸罩”的下限。搜索将采用对数时间。
  2. 从找到的密钥开始迭代,直到用户输入不再匹配。这会给“bram” - &gt;亚伯兰和“braham” - &gt;亚伯拉罕。
  3. 连接迭代结果(Abram,Abraham)并输出它。
  4. 此类树设计用于快速搜索子串。它的性能接近于O(log n)。我相信这种方法可以快速工作,直接由GUI线程使用。此外,由于缺少同步开销,它将比线程解决方案更快地工作。

答案 3 :(得分:15)

您需要文本搜索引擎(如Lucene.Net)或数据库(您可以考虑嵌入式搜索引擎,如SQL CESQLite等)。换句话说,您需要一个索引搜索。基于散列的搜索在这里不适用,因为您搜索子字符串,而基于散列的搜索很适合搜索精确值。

否则,它将是循环遍历集合的迭代搜索。

答案 4 :(得分:12)

拥有“去抖动”类型的事件可能也很有用。这与限制不同之处在于它会在触发事件之前等待一段时间(例如,200 ms)以完成更改。

有关去抖动的详情,请参阅 Debounce and Throttle: a visual explanation 。我感谢这篇文章以JavaScript为重点,而不是C#,但原则适用。

这样做的好处是,当您仍在输入查询时,它不会进行搜索。然后它应该停止尝试一次执行两次搜索。

答案 5 :(得分:11)

更新

我做了一些分析。

(更新3)

  • 列出内容:从0到2.499.999
  • 生成的数字
  • 过滤文字:123(20.477结果)
  • Core i5-2500,Win7 64bit,8GB RAM
  • VS2012 + JetBrains dotTrace

2.500.000记录的初始测试运行时间为20.000毫秒。

头号罪魁祸首是调用textBox_search.Text内的Contains。这会调用每个元素到文本框的昂贵get_WindowText方法。只需将代码更改为:

    var text = textBox_search.Text;
    listBox_choices.DataSource = allUsers.Where(item => item.Contains(text)).ToList();

将执行时间缩短为 1.858ms

更新2:

另外两个重要的瓶颈现在是对string.Contains的调用(约占执行时间的45%)以及set_Datasource(30%)中列表框元素的更新。

我们可以通过创建一个后缀树来在速度和内存使用之间进行权衡,因为Basilevs建议减少必要的比较数量,并在按键后加载一些处理时间到加载名称来自文件,可能对用户更有利。

为了提高将元素加载到列表框中的性能,我建议只加载前几个元素,并向用户表明还有其他元素可用。通过这种方式,您可以向用户提供可用结果的反馈,以便他们可以通过输入更多字母或按一下按钮加载完整列表来优化搜索。

使用BeginUpdateEndUpdateset_Datasource的执行时间没有任何影响。

正如其他人在此处所述,LINQ查询本身运行得非常快。我相信你的瓶颈是列表框本身的更新。您可以尝试类似:

if (textBox_search.Text.Length > 2)
{
    listBox_choices.BeginUpdate(); 
    listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();
    listBox_choices.EndUpdate(); 
}

我希望这会有所帮助。

答案 6 :(得分:11)

在另一个线程上运行搜索,并在该线程运行时显示一些加载动画或进度条。

您也可以尝试并行化LINQ查询。

var queryResults = strings.AsParallel().Where(item => item.Contains("1")).ToList();

这是一个演示AsParallel()的性能优势的基准:

{
    IEnumerable<string> queryResults;
    bool useParallel = true;

    var strings = new List<string>();

    for (int i = 0; i < 2500000; i++)
        strings.Add(i.ToString());

    var stp = new Stopwatch();

    stp.Start();

    if (useParallel)
        queryResults = strings.AsParallel().Where(item => item.Contains("1")).ToList();
    else
        queryResults = strings.Where(item => item.Contains("1")).ToList();

    stp.Stop();

    Console.WriteLine("useParallel: {0}\r\nTime Elapsed: {1}", useParallel, stp.ElapsedMilliseconds);
}

答案 7 :(得分:9)

假设您只是通过前缀匹配,您要查找的数据结构称为trie,也称为“前缀树”。您现在使用的IEnumerable.Where方法必须在每次访问时遍历字典中的所有项目。

This thread显示了如何在C#中创建一个trie。

答案 8 :(得分:8)

WinForms ListBox控件确实是你的敌人。加载记录的速度很慢,ScrollBar会对你展示所有120,000条记录。

尝试使用传统的DataGridView数据来源DataTable,使用单个列[UserName]来保存您的数据:

private DataTable dt;

public Form1() {
  InitializeComponent();

  dt = new DataTable();
  dt.Columns.Add("UserName");
  for (int i = 0; i < 120000; ++i){
    DataRow dr = dt.NewRow();
    dr[0] = "user" + i.ToString();
    dt.Rows.Add(dr);
  }
  dgv.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
  dgv.AllowUserToAddRows = false;
  dgv.AllowUserToDeleteRows = false;
  dgv.RowHeadersVisible = false;
  dgv.DataSource = dt;
}

然后在TextBox的TextChanged事件中使用DataView来过滤数据:

private void textBox1_TextChanged(object sender, EventArgs e) {
  DataView dv = new DataView(dt);
  dv.RowFilter = string.Format("[UserName] LIKE '%{0}%'", textBox1.Text);
  dgv.DataSource = dv;
}

答案 9 :(得分:7)

首先,我会更改ListControl查看数据来源的方式,您将结果IEnumerable<string>转换为List<string>。特别是当你输入几个字符时,这可能是低效的(并且不需要)。 不要制作数据的大量副本

  • 我会将.Where()结果包装到一个只实现IList(搜索)所需内容的集合中。这将节省您为键入的每个字符创建一个新的大列表。
  • 作为替代方案,我会避免使用LINQ,并且我会编写更具体(并优化)的内容。将列表保留在内存中并构建一组匹配的索引,重用数组,这样您就不必为每次搜索重新分配它。

第二步是当小的足够时不要在大列表中搜索。当用户开始键入“ab”并添加“c”时,您不需要在大列表中进行研究,在过滤列表中搜索就足够了(并且更快)。每次都可以优化搜索,每次都不要执行完整搜索。

第三步可能更难:保持数据的快速搜索。现在,您必须更改用于存储数据的结构。想象一下这样的树:

A        B         C
 Add      Better    Ceil
 Above    Bone      Contour

这可以简单地用数组实现(如果你使用ANSI名称,否则字典会更好)。像这样构建列表(插图目的,它匹配字符串的开头):

var dictionary = new Dictionary<char, List<string>>();
foreach (var user in users)
{
    char letter = user[0];
    if (dictionary.Contains(letter))
        dictionary[letter].Add(user);
    else
    {
        var newList = new List<string>();
        newList.Add(user);
        dictionary.Add(letter, newList);
    }
}

然后使用第一个字符进行搜索:

char letter = textBox_search.Text[0];
if (dictionary.Contains(letter))
{
    listBox_choices.DataSource =
        new MyListWrapper(dictionary[letter].Where(x => x.Contains(textBox_search.Text)));
}

请注意我在第一步中使用了MyListWrapper()(但为了简洁,我省略了第二条建议,如果你选择正确的字典大小,你可以保持每个列表的简短和快速 - 也许 - 避免其他任何)。此外请注意,您可以尝试使用前两个字符作为字典(更多列表和更短)。如果你扩展这个你将有一棵树(但我不认为你有这么大的项目)。

many different algorithms用于字符串搜索(带有相关数据结构),仅举几例:

  • 基于有限状态自动机的搜索:在这种方法中,我们通过构造识别存储的搜索字符串的确定性有限自动机(DFA)来避免回溯。这些构造成本很高 - 它们通常使用powerset构造创建 - 但使用起来非常快。
  • 存根:Knuth-Morris-Pratt计算一个DFA,用于识别输入字符串以作为后缀进行搜索,Boyer-Moore开始从指针末尾搜索,因此它通常可以跳转在每一步都超过整个针长。 Baeza-Yates跟踪前面的j个字符是否是搜索字符串的前缀,因此适用于模糊字符串搜索。 bitap算法是Baeza-Yates方法的应用。
  • 索引方法:更快的搜索算法基于文本的预处理。在构建子字符串索引(例如后缀树或后缀数组)之后,可以快速找到模式的出现。
  • 其他变体:某些搜索方法(例如trigram搜索)旨在查找搜索字符串与文本之间的“接近度”分数,而不是“匹配/不匹配”。这些有时被称为“模糊”搜索。

关于并行搜索的几句话。这是可能的,但它很少是微不足道的,因为使其并行的开销可以比搜索本身高得多。我不会并行执行搜索(分区和同步将很快变得过于庞大而且可能很复杂)但我会将搜索移动到单独的线程。如果主线程不是,您的用户在打字时不会感到任何延迟(他们不会注意列表是否会在200毫秒后出现但是如果他们不得不这样做会感到不舒服他们打字后等待50毫秒)。当然,搜索本身必须足够快,在这种情况下,您不使用线程来加速搜索,而是保持您的UI响应。请注意,单独的线程不会使您的查询更快,它不会挂起UI但如果您的查询速度很慢,它在单独的线程中仍然会很慢(此外,您还必须处理多个顺序请求。)

答案 10 :(得分:4)

我怀疑你能否加快速度,但肯定你应该:

a)使用AsParallel LINQ扩展方法

a)使用某种计时器来延迟过滤

b)在另一个线程上放置一个过滤方法

在某处保留某种string previousTextBoxValue。做一个延迟的计时器 如果previousTextBoxValue与您的textbox.Text值相同,则会触发查询。如果不是 - 将previousTextBoxValue重新分配给当前值并重置计时器。将计时器开始设置为文本框已更改事件,它将使您的应用程序更流畅。在1-3秒内过滤120,000条记录是可以的,但您的用户界面必须保持响应。

答案 11 :(得分:4)

您可以尝试使用PLINQ(并行LINQ)。 虽然这并不能保证提速,但您需要通过反复试验找出答案。

答案 12 :(得分:3)

您也可以尝试使用BindingSource.Filter功能。我已经使用它,它就像一个魅力来过滤一堆记录,每次都用搜索文本更新这个属性。另一种选择是使用AutoCompleteSource进行TextBox控制。

希望它有所帮助!

答案 13 :(得分:2)

我会尝试对集合进行排序,搜索仅匹配起始部分并限制搜索某个数字。

关于ininialization

allUsers.Sort();

并搜索

allUsers.Where(item => item.StartWith(textBox_search.Text))

也许你可以添加一些缓存。

答案 14 :(得分:1)

使用并行 LINQ PLINQ 是LINQ to Objects的并行实现。 PLINQ实现了一整套LINQ标准查询运算符作为T:System.Linq命名空间的扩展方法,并具有用于并行操作的附加运算符。 PLINQ将LINQ语法的简单性和可读性与并行编程的强大功能相结合。就像针对任务并行库的代码一样,PLINQ查询根据主机的功能在并发度上进行扩展。

Introduction to PLINQ

Understanding Speedup in PLINQ

您也可以使用Lucene.Net

  

Lucene.Net是Lucene搜索引擎库的一个端口,用于编写   C#并针对.NET运行时用户。 Lucene搜索库是   基于倒排索引。 Lucene.Net有三个主要目标:

答案 15 :(得分:1)

根据我所看到的情况,我同意对列表进行排序的事实。

但是,对列表构造时的排序将非常缓慢,在构建时排序,您将有更好的执行时间。

否则,如果您不需要显示列表或保留订单,请使用散列图。

hashmap将散列您的字符串并搜索确切的偏移量。我想它应该更快。

答案 16 :(得分:1)

尝试使用BinarySearch方法,它应该比Contains方法更快。

包含将是O(n) BinarySearch是O(lg(n))

我认为排序集合在搜索时应该更快,在添加新元素时会更慢,但据我所知,你只有搜索性能问题。