web-dev-qa-db-ja.com

文字列コレクションを検索する最速の方法

問題:

120,000ユーザー(文字列)のテキストファイルがあり、これをコレクションに保存し、後でそのコレクションで検索を実行します。

ユーザーがTextBoxのテキストを変更するたびに検索方法が発生し、結果はcontainTextBoxのテキスト。

リストを変更する必要はありません。結果を取得してListBoxに入れるだけです。

これまでに試したこと:

私は2つの異なるコレクション/コンテナで試しましたが、外部テキストファイルから文字列エントリをダンプしています(もちろん一度):

  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秒)。

質問:

私のボトルネックはどこだと思いますか?私が使ったコレクションは?検索方法は?両方?

より良いパフォーマンスとより流functionalityな機能を得るにはどうすればよいですか?

79
etaiso

バックグラウンドスレッドでフィルタリングタスクを実行することを検討できます。バックグラウンドスレッドは、コールバックメソッドが終了するとコールバックメソッドを呼び出すか、入力が変更された場合にフィルタリングを再開します。

一般的な考え方は、次のように使用できるようにすることです。

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

また、親Formが破棄されるとき、実際に_filterインスタンスを破棄する必要があります。つまり、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);
}

私のマシンでは、非常に高速に動作するため、より複雑なソリューションに進む前に、これをテストしてプロファイルする必要があります。

そうは言っても、「より複雑な解決策」は、おそらく最後の2、3の結果を辞書に格納し、新しいエントリが最後の文字の最初の1つだけが異なることが判明した場合にのみフィルタリングすることです。

48
Groo

いくつかのテストを行ったところ、120,000のアイテムのリストを検索し、新しいリストにエントリを追加するのにかかる時間はごくわずかです(すべての文字列が一致したとしても、約1/50秒)。

したがって、発生している問題は、データソースの入力に起因している必要があります。

listBox_choices.DataSource = ...

リストボックスに入れるアイテムが多すぎると思われます。

おそらく、次のように最初の20エントリに制限する必要があります。

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

(他の人が指摘したように)allUsersの各アイテムのTextBox.Textプロパティにアクセスしていることにも注意してください。これは次のように簡単に修正できます。

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秒よりはるかに短い時間です。それでも、これは価値のある最適化です。

36
Matthew Watson

サフィックスツリー をインデックスとして使用します。または、すべての名前のすべての接尾辞を対応する名前のリストに関連付けるソート済み辞書を作成するだけです。

入力用:

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 ユーザー入力またはユーザー入力を見つけるためのユーザー入力の辞書。このようにして、「バーバラ」-「ブラ」よりも低い最後のキーを見つけます。 「bra」の下限と呼ばれます。検索には対数時間がかかります。
  2. ユーザーの入力が一致しなくなるまで、見つかったキーから繰り返します。これにより、「bram」-> Abramおよび「braham」-> Abrahamが得られます。
  3. 反復結果(Abram、Abraham)を連結して出力​​します。

このようなツリーは、部分文字列をすばやく検索するために設計されています。パフォーマンスはO(log n)に近いです。このアプローチは、GUIスレッドで直接使用するのに十分な速度で機能すると考えています。さらに、同期のオーバーヘッドがないため、スレッド化されたソリューションよりも高速に動作します。

28
Basilevs

テキスト検索エンジン( Lucene.Net など)またはデータベース( SQL CESQLite などの埋め込み検索エンジンを検討できます)等。)。つまり、インデックス検索が必要です。ハッシュベースの検索はサブストリングを検索するため、ここでは適用できませんが、ハッシュベースの検索は正確な値を検索するのに適しています。

それ以外の場合、コレクションをループする反復検索になります。

15
Dennis

また、イベントの「デバウンス」タイプを使用すると便利な場合があります。これは、イベントを起動する前に変更が完了するまで一定期間(たとえば、200ミリ秒)待機するという点で、調整とは異なります。

デバウンスの詳細については、デバウンスとスロットル:視覚的説明を参照してください。この記事はC#ではなくJavaScriptに焦点を当てていることを感謝していますが、原則は適用されます。

この利点は、まだクエリを入力しているときに検索しないことです。その後、一度に2つの検索を実行しようとするのを停止する必要があります。

12
paulslater19

別のスレッドで検索を実行し、そのスレッドの実行中に読み込みアニメーションまたは進行状況バーを表示します。

[〜#〜] 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);
}
11
animaonline

更新:

プロファイリングを行いました。

(更新3)

  • リストの内容:0〜2.499.999で生成された数値
  • フィルターテキスト:123(20.477結果)
  • Core i5-2500、Win7 64ビット、8GB RAM
  • VS2012 + JetBrains dotTrace

2.500.000レコードの最初のテスト実行には20.000msかかりました。

一番の原因は、Contains内のtextBox_search.Textの呼び出しです。これにより、テキストボックスの高価なget_WindowTextメソッドへの各要素の呼び出しが行われます。コードを次のように変更するだけです。

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

実行時間を1.858msに短縮しました。

アップデート2:

他の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(); 
}

これがお役に立てば幸いです。

11
Andris

プレフィックスのみで一致すると仮定すると、探しているデータ構造は trie と呼ばれ、「プレフィックスツリー」とも呼ばれます。 IEnumerable.Where現在使用しているメソッドは、アクセスするたびに辞書内のすべてのアイテムを反復処理する必要があります。

このスレッド は、C#でトライを作成する方法を示しています。

9
Groo

ここでは、WinForms ListBoxコントロールが本当にあなたの敵です。レコードのロードが遅くなり、ScrollBarは120,000件のレコードをすべて表示するためにあなたと戦います。

単一の列[UserName]を持つDataTableにデータソースされた旧式のDataGridViewを使用して、データを保持してみてください。

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

最初に、ListControlがデータソースをどのように表示するかを変更します。結果を_IEnumerable<string>_から_List<string>_に変換しています。特に、いくつかの文字を入力しただけでは、これは非効率的(および不要)になります。 データの拡張コピーを作成しないでください

  • .Where()の結果を、IList(検索)から必要なものだけを実装するコレクションにラップします。これにより、入力する文字ごとに新しい大きなリストを作成する必要がなくなります。
  • 別の方法として、LINQを避け、より具体的な(そして最適化された)何かを書きます。リストをメモリに保持し、一致するインデックスの配列を構築し、配列を再利用して、検索ごとに再割り当てする必要がないようにします。

2番目のステップは、小さなリストで十分な場合に大きなリストを検索しないことです。ユーザーが「ab」と入力し始めて「c」を追加した場合、大きなリストで調査する必要はありません。フィルターされたリストで検索するだけで十分です(高速です)。 検索の絞り込みは毎回可能ですが、毎回完全検索を実行しないでください。

3番目のステップはより難しくなる可能性があります:データをすばやく検索できるように整理します次に、データの保存に使用する構造を変更する必要があります。このようなツリーを想像してください:

 A B C 
 Better Ceilを追加
骨の輪郭より上

これは単に配列を使用して実装できます(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()を使用したことに注意してください(ただし、簡潔にするために2番目の提案では省略しました。辞書キーに適切なサイズを選択すると、 。さらに、最初の2文字を辞書に使用しようとする場合があることに注意してください(より多くのリストとより短い)。これを拡張すると、ツリーができます(ただし、それほど多くのアイテムがあるとは思いません)。

多くの異なるアルゴリズム 文字列検索用(関連するデータ構造で)がありますが、ほんの数例です:

  • 有限状態オートマトンベースの検索:このアプローチでは、保存された検索文字列を認識する決定論的有限オートマトン(DFA)を構築することにより、バックトラックを回避します。これらは構築するのに費用がかかります(通常はパワーセット構築を使用して作成されます)が、非常に使いやすいです。
  • スタブ:Knuth–Morris–Prattは、検索する文字列をサフィックスとして入力を認識するDFAを計算し、Boyer–Mooreは最後から検索を開始します針なので、通常は各ステップで針の長さ全体にジャンプできます。 Baeza–Yatesは、前のj文字が検索文字列のプレフィックスであったかどうかを追跡するため、ファジー文字列検索に適応できます。 bitapアルゴリズムは、Baeza–Yatesのアプローチのアプリケーションです。
  • インデックス方式:より高速な検索アルゴリズムは、テキストの前処理に基づいています。接尾辞ツリーや接尾辞配列などの部分文字列インデックスを構築した後、パターンの出現をすばやく見つけることができます。
  • その他のバリアント:一部の検索方法、たとえばトライグラム検索は、検索文字列とテキストの間の「近さ」スコアではなく、「一致/不一致」。これらは「ファジー」検索とも呼ばれます。

並列検索に関するいくつかの言葉。可能ですが、並列化するためのオーバーヘッドが検索自体よりもはるかに高くなる可能性があるため、めったにありません。検索自体を並行して実行することはありません(パーティション化と同期はすぐに広大で複雑になる可能性があります)が、検索を別のスレッドに移動します。メインスレッドがbusyでない場合、ユーザーは入力中に遅延を感じることはありません(200ミリ秒後にリストが表示されるかどうかはわかりませんが、入力後50ミリ秒待機する必要がある場合は不快です)。もちろん、検索自体は十分に高速である必要があります。この場合、検索を高速化するためにスレッドを使用せず、UIを応答性に保ちます別個のスレッドはqueryを高速化しないことに注意してください、UIはハングしませんが、クエリが遅い場合でも、別のスレッドでは遅くなります(さらに、複数のシーケンシャルリクエストも処理する必要があります)。

7
Adriano Repetti

[〜#〜] plinq [〜#〜] (Parallel LINQ)を使用してみてください。これは速度向上を保証するものではありませんが、これは試行錯誤によって見つける必要があります。

4
D. Gierveld

あなたはそれをより速くすることができるとは思いませんが、確かにあなたはすべきです:

a)AsParallel [〜#〜] linq [〜#〜] 拡張メソッドを使用します

a)何らかの種類のタイマーを使用してフィルタリングを遅らせる

b)フィルタリングメソッドを別のスレッドに配置する

何らかの種類のstring previousTextBoxValueをどこかに保管してください。 previousTextBoxValuetextbox.Textの値と同じである場合、ティックで検索を開始する1000µmsの遅延でタイマーを作成します。そうでない場合-previousTextBoxValueを現在の値に再割り当てし、タイマーをリセットします。テキストボックス変更イベントにタイマーの開始を設定すると、アプリケーションがよりスムーズになります。 1〜3秒で120,000件のレコードをフィルタリングしても問題ありませんが、UIは応答性を維持する必要があります。

4
Tarec

BindingSource.Filter 関数を使用して試すこともできます。私はそれを使用しましたが、検索対象のテキストでこのプロパティを更新するたびに、一連のレコードからフィルタリングするのが魅力的です。別のオプションは、TextBoxコントロールに AutoCompleteSource を使用することです。

それが役に立てば幸い!

3
NeverHopeless

コレクションをソートし、開始部分のみに一致するように検索し、検索をある数で制限しようとします。

初期化など

allUsers.Sort();

そして検索

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

キャッシュを追加することができます。

2
hardsky

パラレルLINQを使用します。 PLINQは、LINQ to Objectsの並列実装です。 PLINQは、T:System.Linq名前空間の拡張メソッドとしてLINQ標準クエリ演算子の完全なセットを実装し、並列操作用の追加演算子を備えています。 PLINQは、LINQ構文のシンプルさと読みやすさを並列プログラミングの能力と組み合わせています。タスクパラレルライブラリを対象とするコードと同様に、PLINQクエリは、ホストコンピューターの機能に基づいて同時実行性の度合いを調整します。

PLINQの概要

PLINQの高速化について

また、 Lucene.Net を使用できます

Lucene.NetはLucene検索エンジンライブラリの移植版であり、C#で記述され、.NETランタイムユーザーを対象としています。 Lucene検索ライブラリは、逆索引に基づいています。 Lucene.Netには、3つの主要な目標があります。

1
user1968030

BinarySearchメソッドを使用してみてください。Containsメソッドよりも速く動作するはずです。

含まれるのはO(n) BinarySearchはO(lg(n))です

ソートされたコレクションは、検索ではより速く、新しい要素の追加ではより遅くなるはずですが、理解したように、検索パフォーマンスの問題しかありません。

1
user2917540

私が見たものによると、リストをソートするという事実に同意します。

ただし、リストが構成されているときに並べ替えるのは非常に遅く、構築するときに並べ替えると、実行時間が短縮されます。

それ以外の場合、リストを表示したり順序を維持する必要がない場合は、ハッシュマップを使用します。

ハッシュマップは文字列をハッシュし、正確なオフセットで検索します。速くなるはずです。

1
dada