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

eta*_*iso 79 c# linq collections string-search winforms

问题:

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

每次用户更改a的文本时都会发生搜索方法TextBox,结果应该是包含文本的字符串TextBox.

我不必更改列表,只需将结果拉出来并将其放入ListBox.

到目前为止我尝试过的:

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

  1. List<string> allUsers;
  2. HashSet<string> allUsers;

使用以下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;
    }
}
Run Code Online (Sandbox Code Playgroud)

结果:

两者都给了我一个很差的响应时间(每次按键之间大约1-3秒).

题:

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

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

Gro*_*roo 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);
    }
}
Run Code Online (Sandbox Code Playgroud)

粗略的草图将是这样的:

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();
    }
}
Run Code Online (Sandbox Code Playgroud)

此外,您应该在处置_filter父级时实际处置实例Form.这意味着您应该打开并编辑您FormDispose方法(在YourForm.Designer.cs文件中),如下所示:

// inside "xxxxxx.Designer.cs"
protected override void Dispose(bool disposing)
{
    if (disposing)
    {
        if (_filter != null)
            _filter.Dispose();

        // this part is added by Visual Studio designer
        if (components != null)
            components.Dispose();
    }

    base.Dispose(disposing);
}
Run Code Online (Sandbox Code Playgroud)

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

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


Mat*_*son 36

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

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

listBox_choices.DataSource = ...
Run Code Online (Sandbox Code Playgroud)

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

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

listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text))
    .Take(20).ToList();
Run Code Online (Sandbox Code Playgroud)

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

string target = textBox_search.Text;
listBox_choices.DataSource = allUsers.Where(item => item.Contains(target))
    .Take(20).ToList();
Run Code Online (Sandbox Code Playgroud)

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

  • Downvoter,关注评论?我也想学习! (13认同)
  • @etaiso嗯,搜索时间可以忽略不计,就像我说的那样.我尝试了120,000个字符串,并搜索了一个非常长的字符串,该字符串没有给出匹配,而且一个非常短的字符串给出了许多匹配都在1/50秒之内. (5认同)
  • `textBox_search.Text`是否会为时间贡献可衡量的金额?对于每个120k字符串,在文本框上获取一次`Text`属性可能会向编辑控制窗口发送120k消息. (3认同)

Bas*_*evs 28

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

输入:

Abraham
Barbara
Abram
Run Code Online (Sandbox Code Playgroud)

结构看起来像:

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
Run Code Online (Sandbox Code Playgroud)

搜索算法

假设用户输入"bra".

  1. 在用户输入上对字典进行对比以查找用户输入或其可以进入的位置.这样我们发现"barbara" - 最后一个键低于"bra".它被称为"胸罩"的下限.搜索将采用对数时间.
  2. 从找到的键开始迭代,直到用户输入不再匹配.这会给"bram" - > Abram和"braham" - >亚伯拉罕.
  3. 连接迭代结果(Abram,Abraham)并输出它.

这些树设计用于快速搜索子串.它的性能接近于O(log n).我相信这种方法的工作速度足以让GUI线程直接使用.此外,由于没有同步开销,它将比线程解决方案更快地工作.


Den*_*nis 15

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

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

  • @OrangeDog:不同意.索引搜索*可以*通过索引键实现为基于散列的搜索,但它不是必需的,并且它不是字符串值本身的基于散列的搜索. (3认同)

pau*_*r19 12

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

请参阅去抖动和节流:有关去抖动的更多信息的可视化解释.我感谢这篇文章是以JavaScript为重点,而不是C#,但原则适用.

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


ani*_*ine 11

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

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

var queryResults = strings.AsParallel().Where(item => item.Contains("1")).ToList();
Run Code Online (Sandbox Code Playgroud)

这是一个演示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);
}
Run Code Online (Sandbox Code Playgroud)

  • @TimSchmelter我不知道你要证明什么,使用我提供的代码很可能会提高OP的性能,这里有一个基准来演示它是如何工作的:http://pastebin.com/ATYa2BGt - - 期间--- (4认同)

And*_*ris 11

更新:

我做了一些分析.

(更新3)

  • 列表内容:从0到2.499.999生成的数字
  • 筛选条文字:123(20.477结果)
  • 酷睿i5-2500,Win7 64bit,8GB内存
  • 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();
Run Code Online (Sandbox Code Playgroud)

将执行时间缩短为1.858ms.

更新2:

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

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

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

使用BeginUpdateEndUpdate没有改变执行时间set_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(); 
}
Run Code Online (Sandbox Code Playgroud)

我希望这有帮助.


Gro*_*roo 9

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

该主题展示了如何在C#中创建一个trie.


Lar*_*ech 8

WinForms ListBox控件确实是你的敌人.加载记录的速度很慢,ScrollBar会跟你一起显示所有120,000条记录.

尝试使用一个老式的DataGridView数据源,使用单个列[UserName]来保存数据:DataTable:

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;
}
Run Code Online (Sandbox Code Playgroud)

然后在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;
}
Run Code Online (Sandbox Code Playgroud)

  • +1而其他人都试图优化搜索只需要30毫秒,你是唯一一个认识到问题实际上在填充列表框的人. (2认同)

Adr*_*tti 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);
    }
}
Run Code Online (Sandbox Code Playgroud)

然后使用第一个字符完成搜索:

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

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

字符串搜索有许多不同的算法(具有相关的数据结构),仅举几例:

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

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

  • @Groo你是对的,正如我所说,它仅用于说明目的.该代码的重点不是一个有效的解决方案,而是一个提示:如果您尝试了其他所有内容 - 避免复制,优化搜索,将其移动到另一个线程 - 这还不够,那么您必须更改您正在使用的数据结构.示例是为了保持简单而开始字符串. (2认同)