Самый быстрый способ поиска в коллекции строк

Проблема:

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

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

Мне не нужно менять список, просто вытащите результаты и поместите их в ListBox.

Что я пробовал до сих пор:

Я попробовал с двумя разными коллекциями/контейнерами, которые я сбрасываю строковые записи из внешнего текстового файла (раз, разумеется):

  • List<string> allUsers;
  • 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 секунд между каждым нажатием клавиши).

Вопрос:

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

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

Ответ 1

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

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

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

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

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

Ответ 2

Я провел некоторое тестирование и выполнил поиск в списке из 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. Тем не менее, это стоит оптимизировать.

Ответ 3

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

Для ввода:

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

Алгоритм поиска

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

  • Bisect словарь на пользовательском входе, чтобы найти вход пользователя или позицию, в которой он мог бы идти. Таким образом, мы находим "barbara" - последний ключ ниже, чем "бюстгальтер". Он называется нижней границей для "бюстгальтера". Поиск займет логарифмическое время.
  • Переходите от найденного ключа до тех пор, пока пользовательский ввод больше не будет соответствовать. Это даст "bram" → Abram и "braham" → Abraham.
  • Объединить результат итерации (Abram, Abraham) и вывести его.

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

Ответ 4

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

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

Ответ 5

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

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

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

Ответ 6

Обновление:

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

(Обновление 3)

  • Содержимое списка: цифры, созданные от 0 до 2.499.999
  • Текст фильтра: 123 (20.477 результатов)
  • Core i5-2500, Win7 64bit, оперативная память 8 ГБ.
  • 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 мс.

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

Другими двумя значительными бутылочными шейками теперь являются призыв string.Contains (около 45% времени выполнения) и обновление элементов списка в 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(); 
}

Надеюсь, это поможет.С >

Ответ 7

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

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

Ответ 8

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

Этот поток показывает, как создать trie в С#.

Ответ 9

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

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

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

  • Поиск на основе конечного автомата на основе: в этом подходе мы избегаем обратного отслеживания, создавая детерминированный конечный автомат (DFA), который распознает сохраненную строку поиска. Они дороги для построения - они обычно создаются с использованием конструкции poweret, но очень быстро используются.
  • Stubs: Knuth-Morris-Pratt вычисляет DFA, который распознает входы с строкой для поиска в качестве суффикса, Boyer-Moore начинает поиск с конца иглы, поэтому он может обычно прыгать впереди целую длину иглы на каждом шагу. Baeza-Yates отслеживает, были ли предыдущие j-символы префиксом строки поиска и, следовательно, адаптировались к поиску нечетких строк. Битовый алгоритм является применением подхода Баэза-Йейтс.
  • Методы индекса: более быстрые алгоритмы поиска основаны на предварительной обработке текста. После построения индекса подстроки, например дерева суффиксов или массива суффикса, вхождения шаблона можно найти быстро.
  • Другие варианты. Некоторые методы поиска, например поиск триграмм, предназначены для поиска "близости" между строкой поиска и текстом, а не "совпадением/несоответствием". Их иногда называют "нечеткими" поисками.

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

Ответ 10

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

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

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

Ответ 11

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

Ответ 12

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

a) Используйте метод расширения LINQ

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

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

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

Ответ 13

Вы также можете попробовать использовать функцию BindingSource.Filter. Я использовал его, и он работает как шарм для фильтрации из множества записей, каждый раз обновляя это свойство с помощью поиска текста. Другой вариант - использовать AutoCompleteSource для элемента управления TextBox.

Надеюсь, что это поможет!

Ответ 14

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

так что ininialization

allUsers.Sort();

и поиск

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

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

Ответ 15

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

Введение в PLINQ

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

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

Lucene.Net - это порт библиотеки поисковых систем Lucene, написанный на С# и предназначен для пользователей среды выполнения .NET. Библиотека поиска Lucene - это основанный на инвертированном индексе. Lucene.Net имеет три основные цели:

Ответ 16

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

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

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

Хешмап будет хешировать вашу строку и выполнить поиск с точным смещением. Это должно быть быстрее, я думаю.

Ответ 17

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

Содержит O (n) BinarySearch - это O (lg (n))

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