[c#] 문자열 컬렉션에서 검색하는 가장 빠른 방법

문제:

컬렉션에 저장하고 나중에 해당 컬렉션에 대해 검색을 수행하려는 약 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;
    }
}

결과 :

둘 다 저에게 응답 시간이 좋지 않았습니다 (각 키 누름 사이에 약 1-3 초).

질문:

내 병목이 어디에 있다고 생각하세요? 내가 사용한 컬렉션? 검색 방법? 양자 모두?

더 나은 성능과 더 유창한 기능을 얻으려면 어떻게해야합니까?



답변

완료되면 콜백 메서드를 호출하는 백그라운드 스레드에서 필터링 작업을 수행하거나 입력이 변경된 경우 필터링을 다시 시작할 수 있습니다.

일반적인 아이디어는 다음과 같이 사용할 수 있다는 것입니다.

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가 삭제 될 때 실제로 인스턴스 를 삭제해야합니다 . 즉, 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);
}

내 컴퓨터에서는 매우 빠르게 작동하므로 더 복잡한 솔루션을 찾기 전에이를 테스트하고 프로파일 링해야합니다.

즉, “더 복잡한 솔루션”은 마지막 두 개의 결과를 사전에 저장 한 다음 새 항목이 마지막 문자의 첫 번째 문자 만 다른 것으로 밝혀진 경우에만 필터링하는 것입니다.


답변

몇 가지 테스트를 수행했으며 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.Text50 만 번 액세스하는 데 걸리는 시간을 측정 했으며 OP에 언급 된 1-3 초보다 훨씬 적은 0.7 초 밖에 걸리지 않았습니다. 그래도 이것은 가치있는 최적화입니다.


답변

접미사 트리 사용 인덱스로. 또는 모든 이름의 모든 접미사를 해당 이름 목록과 연결하는 정렬 된 사전을 구축하십시오.

입력 :

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. 사용자 입력에 대해 사전을 양분 하여 사용자 입력 또는 이동할 수있는 위치를 찾습니다. 이렇게하면 “barbara”- “bra”보다 낮은 마지막 키를 찾습니다. “브라”의 하한이라고합니다. 검색에는 로그 시간이 걸립니다.
  2. 사용자 입력이 더 이상 일치하지 않을 때까지 찾은 키부터 계속 반복합니다. 이것은 “bram”-> Abram 및 “braham”-> Abraham을 줄 것입니다.
  3. 반복 결과 (Abram, Abraham)를 연결하고 출력합니다.

이러한 트리는 하위 문자열을 빠르게 검색하도록 설계되었습니다. 성능은 O (log n)에 가깝습니다. 이 접근 방식은 GUI 스레드에서 직접 사용할 수있을만큼 빠르게 작동 할 것이라고 생각합니다. 또한 동기화 오버 헤드가 없기 때문에 스레드 솔루션보다 빠르게 작동합니다.


답변

텍스트 검색 엔진 (예 : Lucene.Net ) 또는 데이터베이스 (예 : SQL CE , SQLite 등 의 임베디드 엔진을 고려할 수 있음 )가 필요합니다. 즉, 색인화 된 검색이 필요합니다. 해시 기반 검색은 하위 문자열을 검색하기 때문에 여기서 적용 할 수 없지만 해시 기반 검색은 정확한 값을 검색하는 데 적합합니다.

그렇지 않으면 컬렉션을 반복하는 반복 검색이됩니다.


답변

“디 바운스”유형의 이벤트를 갖는 것도 유용 할 수 있습니다. 이벤트를 시작하기 전에 변경이 완료 될 때까지 일정 시간 (예 : 200ms)을 기다린다는 점에서 스로틀 링과 다릅니다.

참조 디 바운스 및 스로틀 : 시각적 설명 디 바운싱에 대한 자세한 정보를 얻을 수 있습니다. 이 기사는 C # 대신 JavaScript에 초점을 맞추고 있지만 원칙이 적용됩니다.

이것의 장점은 여전히 ​​쿼리를 입력 할 때 검색하지 않는다는 것입니다. 그런 다음 한 번에 두 개의 검색을 수행하려는 시도를 중지해야합니다.


답변

다른 스레드에서 검색을 실행하고 해당 스레드가 실행되는 동안 로딩 애니메이션이나 진행률 표시 줄을 표시합니다.

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 64 비트, 8GB RAM
  • VS2012 + JetBrains dotTrace

2.500.000 레코드에 대한 초기 테스트 실행에는 20.000ms가 소요되었습니다.

가장 큰 범인은 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가 필요한 비교 횟수를 줄이고 키를 누른 후 검색에서 처리 시간을 파일에서 이름을로드 할 때까지 푸시하도록 제안했듯이 Suffix 트리를 생성하여 속도와 메모리 사용량 사이의 균형을 맞출 수 있습니다. 사용자에게 바람직 할 수 있습니다.

목록 상자에 요소를로드하는 성능을 높이려면 처음 몇 개의 요소 만로드하고 사용 가능한 추가 요소가 있음을 사용자에게 표시하는 것이 좋습니다. 이렇게하면 사용자에게 사용 가능한 결과가 있다는 피드백을 제공하여 더 많은 문자를 입력하여 검색을 구체화하거나 버튼을 눌러 전체 목록을로드 할 수 있습니다.

를 사용 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();
}

이게 도움이 되길 바란다.