Быстрый поиск в коллекции строка

:

у меня есть текстовый файл примерно 120,000 пользователи (строки), которые я хотел бы сохранить в коллекции, а затем выполнить поиск по этой коллекции.

метод поиска будет происходить каждый раз, когда пользователь изменяет текст 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;
    }
}

результаты:

оба дали мне плохое время отклика (около 1-3 секунд между каждым нажатием клавиши).

вопрос:

как вы думаете, где мое узкое место? Коллекция, которой я пользовался? Метод поиска? Как?

как я могу получить лучшую производительность и более гибкую функциональность?

17 ответов


вы можете рассмотреть возможность выполнения задачи фильтрации в фоновом потоке, который вызовет метод обратного вызова, когда это будет сделано, или просто перезапустить фильтрацию, если ввод изменен.

общая идея состоит в том, чтобы иметь возможность использовать его следующим образом:

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 ' s Dispose метод (внутри 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);
}

на моей машине он работает довольно быстро, поэтому вы должны проверить и профилировать это, прежде чем переходить к более сложному решению.

при этом "более сложным решением", возможно, было бы сохранить последние пару результатов в словаре, а затем только фильтровать их, если окажется, что новая запись отличается только первым из последнего символа.


Я провел некоторое тестирование, и поиск списка из 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 секунды, намного меньше, чем 1-3 секунды, упомянутые в OP. Тем не менее, это достойная оптимизация.


использовать суффиксное дерево в качестве индекса. Или, скорее, просто создайте сортированный словарь, который связывает каждый суффикс каждого имени со списком соответствующих имен.

для ввода:

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

Поиск

предположим, что пользователь вводит "бюстгальтер".

  1. Bisect словарь на вводе пользователя, чтобы найти ввод пользователя или положение, где он мог пойти. Таким образом, мы находим "Барбара" - Последний ключ ниже, чем "бюстгальтер". Это называется нижняя граница для "бюстгальтера". Поиск займет логарифмическое время.
  2. итерация от найденного ключа вперед до тех пор, пока пользовательский ввод больше не совпадает. Это дало бы" БРЭМ "- > Аврам и" Брахам " - > Авраам.
  3. объединить результат итерации (Abram, Abraham) и вывести его.

такие деревья предназначены для быстрого поиска подстрок. Производительность близка к O (log n). Я верю в это. подход будет работать достаточно быстро, чтобы использоваться потоком GUI напрямую. Кроме того, она будет работать быстрее, чем резьбовое решение из-за отсутствия синхронизации.


вам нужна либо текстовая поисковая система (например,Lucene.Net), или база данных (вы можете рассмотреть встроенный, как SQL CE, SQLite, etc.). Другими словами, вам нужен индексированный поиск. Поиск на основе хэша здесь неприменим, потому что вы ищете подстроку, в то время как поиск на основе хэша хорошо подходит для поиска точного значения.

в противном случае это будет итеративный поиск с перебрав коллекцию.


также может быть полезно иметь тип события" debounce". Это отличается от дросселирования тем, что он ожидает периода времени (например, 200 мс) для завершения изменений перед запуском события.

посмотреть Debounce и дроссель: визуальное объяснение для получения дополнительной информации о debouncing. Я ценю, что эта статья ориентирована на JavaScript, а не на C#, но принцип применим.

преимущество этого в том, что он не ищет когда вы все еще вводите свой запрос. Затем он должен прекратить попытки выполнить два поиска одновременно.


запустите поиск в другом потоке и покажите анимацию загрузки или индикатор выполнения во время выполнения этого потока.

вы также можете попытаться распараллелить 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);
}

обновление:

я сделал некоторые профилирования.

(обновление 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.858 ms.

обновление 2 :

два других значительных бутылочных горлышка теперь призывают string.Contains (около 45% времени выполнения) и обновление элементов ListBox в set_Datasource (30%).

мы смогли сделать компромисс между скоростью и использованием памяти мимо создание суффиксного дерева как Basilevs предложило сократить количество необходимых сравнений и нажать некоторое время обработки от поиска после нажатия клавиши до загрузки имен из файла, что может быть предпочтительнее для пользователя.

чтобы повысить производительность загрузки элементов в список, я бы предложил загрузить только первые несколько элементов и указать пользователю, что есть дополнительные элементы. Таким образом, вы даете обратную связь пользователю, что есть результаты доступны, чтобы они могли уточнить свой поиск, введя больше букв или загрузить полный список нажатием кнопки.

используя BeginUpdate и EndUpdate не внес никаких изменений во время выполнения 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(); 
}

надеюсь, это поможет.


предполагая, что вы соответствуете только префиксам, структура данных, которую вы ищете, называется trie, также известный как "префикс дерево". The IEnumerable.Where метод, который вы используете сейчас, должен будет перебирать все элементы в вашем словаре при каждом доступе.

этой теме показывает, как создать trie в C#.


элемент управления WinForms ListBox действительно ваш враг здесь. Он будет медленно загружать записи, и полоса прокрутки будет бороться с вами, чтобы показать все 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;
}

затем используйте DataView в событии TextChanged вашего текстового поля для фильтрации данных:

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;
}

сначала я бы изменил, как ListControl видит ваш источник данных, вы конвертируете результат IEnumerable<string> до List<string>. Особенно, когда вы просто набрали несколько символов, это может быть неэффективным (и ненужным). не делайте расширительные копии ваших данных.

  • я бы обернуть .Where() результат в коллекции, которая реализует только то, что требуется от IList (поиск). Это позволит вам создать новый большой список для каждого введенного символа.
  • As альтернатива я бы избегал LINQ, и я бы написал что-то более конкретное (и оптимизированное). Сохраните список в памяти и создайте массив сопоставленных индексов, повторно используйте массив, чтобы не перераспределять его для каждого поиска.

второй шаг - не искать в большом списке, когда достаточно маленького. Когда пользователь начал вводить " ab "и он добавляет" c", тогда вам не нужно искать в большом списке, достаточно поиска в отфильтрованном списке (и быстрее). расширенный поиск каждый раз, когда это возможно, не выполняйте полный поиск каждый раз.

Третий шаг может быть сложнее: держите данные организованы для быстрого поиска. Теперь вам нужно изменить структуру, которую вы используете для хранения данных. представьте себе такое дерево:--12-->

A        B         C
 Add      Better    Ceil
 Above    Bone      Contour

это может быть просто реализовано с массивом (если вы работаете с именами ANSI, иначе словарь был бы лучше). Постройте список следующим образом (цели иллюстрации, он соответствует началу string):

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() как было предложено на первом шаге (но я опустил 2 - е предложение для краткости, если вы выбираете правильный размер для словарного ключа, вы можете держать каждый список коротким и быстрым, чтобы - возможно-избежать чего-либо еще). Кроме того, обратите внимание, что вы можете попытаться использовать первые два символа для своего словаря (больше списков и короче). Если вы расширяете это дерево (но я не думаю, что вы есть такое большое количество предметов).

здесь много различных алгоритмов для поиска строк (со связанными структурами данных), просто чтобы упомянуть несколько:

  • конечный автомат на основе поиска: в этом подходе мы избегаем возврата путем построения детерминированного конечного автомата (DFA), который распознает сохраненную строку поиска. Они дороги для того чтобы построить-они обычно созданы используя конструкцию powerset-но очень быстро использовать.
  • заглушки: кнут-Моррис-Пратт вычисляет DFA, который распознает входы со строкой для поиска в качестве суффикса, Бойер-Мур начинает поиск с конца иглы, поэтому он обычно может прыгать вперед на всю длину иглы на каждом шаге. Baeza-Yates отслеживает, были ли предыдущие J-символы префиксом строки поиска, и поэтому адаптируется к поиску нечетких строк. Алгоритм bitap-это приложение Baeza-Yates' подход.
  • методы индекса: более быстрые алгоритмы поиска основаны на предварительной обработке текста. После построения индекса подстроки, например дерева суффиксов или массива суффиксов, вхождения шаблона можно быстро найти.
  • другие варианты: некоторые методы поиска, например поиск триграммы, предназначены для поиска" близости " между строкой поиска и текстом, а не "совпадения/несоответствия". Их иногда называют "нечеткий поиск.

несколько слов о параллельном поиске. Это возможно, но это редко тривиально, потому что накладные расходы, чтобы сделать его параллельным, могут быть намного выше, чем сам поиск. Я бы не выполнял поиск сам по себе параллельно (разделение и синхронизация скоро станут слишком расширительными и, возможно, сложными), но я бы переместить поиск в отдельный поток. Если основной поток не занят ваши пользователи не будут чувствовать задержки во время ввода (они не заметит, появится ли список после 200 мс, но они будут чувствовать себя неудобно, если им придется ждать 50 мс после ввода). Конечно, сам поиск должен быть достаточно быстрым, в этом случае вы не используете потоки для ускорения поиска, но держите свой пользовательский интерфейс отзывчивым. Обратите внимание, что отдельный поток не сделает ваш запрос быстрее, он не будет висеть UI, но если ваш запрос был медленным, он все равно будет медленным в отдельном потоке (кроме того, вам нужно обрабатывать несколько последовательные запросы тоже).


вы можете попробовать использовать PLINQ (параллельный LINQ). Хотя это не гарантирует повышение скорости, это нужно выяснить методом проб и ошибок.


Я сомневаюсь, что вы сможете сделать это быстрее, но наверняка вы должны:

а) использовать методами asparallel LINQ метод расширения

a) используйте какой-то таймер для задержки фильтрации

b) поместите метод фильтрации в другой поток

сохранить какой-то string previousTextBoxValue куда-то. Сделать таймер с задержкой 1000 мс, что срабатывает поиск по галочке если previousTextBoxValue то же самое, что и ваш textbox.Text значение. Если нет-переназначить previousTextBoxValue к текущему значению и сбросьте таймер. Установите таймер запуска на событие изменения текстового поля, и это сделает ваше приложение более плавным. Фильтрация 120 000 записей за 1-3 секунды в порядке, но ваш пользовательский интерфейс должен оставаться отзывчивым.


вы также можете попробовать использовать объектом bindingsource.Фильтр


Я бы попытался отсортировать коллекцию, поиск, чтобы соответствовать только начальной части и ограничить поиск некоторым числом.

так далее ininialization

allUsers.Sort();

поиск

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

возможно, вы можете добавить кеш.


Использовать Parallel LINQ. PLINQ является параллельной реализацией LINQ to Objects. PLINQ реализует полный набор стандартных операторов запросов LINQ в качестве методов расширения для системы T:.Пространство имен LINQ и имеет дополнительные операторы для параллельных операций. PLINQ сочетает в себе простоту и читаемость синтаксиса LINQ с мощью параллельного программирования. Как и код, предназначенный для параллельной библиотеки задач, запросы PLINQ масштабируются в степени параллелизма в зависимости от возможностей главного компьютера.

введение в PLINQ

понимание ускорения в PLINQ

Также вы можете использовать Lucene.Net

Lucene.Net является портом библиотеки поисковой системы Lucene, написанной в C# и предназначен для пользователей .NET runtime. Библиотека поиска Lucene на основе перевернутого индекса. Lucene.Net имеет три основные цели:


по данным, что я видел, я согласен с тем, чтобы отсортировать список.

однако сортировка, когда список будет построен, будет очень медленной, сортировка при построении, у вас будет лучшее время выполнения.

в противном случае, если вам не нужно отображать список или сохранять порядок, используйте hashmap.

hashmap будет хэшировать вашу строку и искать с точным смещением. Думаю, это должно быть быстрее.


попробуйте использовать метод BinarySearch он должен работать быстрее, чем содержит метод.

содержит будет O(n) BinarySearch-Это O(lg (n))

Я думаю, что сортированная коллекция должна работать быстрее при поиске и медленнее при добавлении новых элементов, но, как я понял, у вас есть только проблема с поиском.