問題:
約120,000ユーザー(文字列)のテキストファイルがあり、これをコレクションに保存し、後でそのコレクションで検索を実行します。
ユーザーがTextBox
のテキストを変更するたびに検索方法が発生し、結果はcontainTextBox
のテキスト。
リストを変更する必要はありません。結果を取得してListBox
に入れるだけです。
これまでに試したこと:
私は2つの異なるコレクション/コンテナで試しましたが、外部テキストファイルから文字列エントリをダンプしています(もちろん一度):
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秒)。
質問:
私のボトルネックはどこだと思いますか?私が使ったコレクションは?検索方法は?両方?
より良いパフォーマンスとより流functionalityな機能を得るにはどうすればよいですか?
バックグラウンドスレッドでフィルタリングタスクを実行することを検討できます。バックグラウンドスレッドは、コールバックメソッドが終了するとコールバックメソッドを呼び出すか、入力が変更された場合にフィルタリングを再開します。
一般的な考え方は、次のように使用できるようにすることです。
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
インスタンスを破棄する必要があります。つまり、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、3の結果を辞書に格納し、新しいエントリが最後の文字の最初の1つだけが異なることが判明した場合にのみフィルタリングすることです。
いくつかのテストを行ったところ、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秒よりはるかに短い時間です。それでも、これは価値のある最適化です。
サフィックスツリー をインデックスとして使用します。または、すべての名前のすべての接尾辞を対応する名前のリストに関連付けるソート済み辞書を作成するだけです。
入力用:
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」を想定します。
このようなツリーは、部分文字列をすばやく検索するために設計されています。パフォーマンスはO(log n)に近いです。このアプローチは、GUIスレッドで直接使用するのに十分な速度で機能すると考えています。さらに、同期のオーバーヘッドがないため、スレッド化されたソリューションよりも高速に動作します。
テキスト検索エンジン( Lucene.Net など)またはデータベース( SQL CE 、 SQLite などの埋め込み検索エンジンを検討できます)等。)。つまり、インデックス検索が必要です。ハッシュベースの検索はサブストリングを検索するため、ここでは適用できませんが、ハッシュベースの検索は正確な値を検索するのに適しています。
それ以外の場合、コレクションをループする反復検索になります。
また、イベントの「デバウンス」タイプを使用すると便利な場合があります。これは、イベントを起動する前に変更が完了するまで一定期間(たとえば、200ミリ秒)待機するという点で、調整とは異なります。
デバウンスの詳細については、デバウンスとスロットル:視覚的説明を参照してください。この記事はC#ではなくJavaScriptに焦点を当てていることを感謝していますが、原則は適用されます。
この利点は、まだクエリを入力しているときに検索しないことです。その後、一度に2つの検索を実行しようとするのを停止する必要があります。
別のスレッドで検索を実行し、そのスレッドの実行中に読み込みアニメーションまたは進行状況バーを表示します。
[〜#〜] 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)
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つの重要なボトルネックは、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(); }
これがお役に立てば幸いです。
ここでは、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;
}
最初に、ListControl
がデータソースをどのように表示するかを変更します。結果を_IEnumerable<string>
_から_List<string>
_に変換しています。特に、いくつかの文字を入力しただけでは、これは非効率的(および不要)になります。 データの拡張コピーを作成しないでください。
.Where()
の結果を、IList
(検索)から必要なものだけを実装するコレクションにラップします。これにより、入力する文字ごとに新しい大きなリストを作成する必要がなくなります。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文字を辞書に使用しようとする場合があることに注意してください(より多くのリストとより短い)。これを拡張すると、ツリーができます(ただし、それほど多くのアイテムがあるとは思いません)。
多くの異なるアルゴリズム 文字列検索用(関連するデータ構造で)がありますが、ほんの数例です:
並列検索に関するいくつかの言葉。可能ですが、並列化するためのオーバーヘッドが検索自体よりもはるかに高くなる可能性があるため、めったにありません。検索自体を並行して実行することはありません(パーティション化と同期はすぐに広大で複雑になる可能性があります)が、検索を別のスレッドに移動します。メインスレッドがbusyでない場合、ユーザーは入力中に遅延を感じることはありません(200ミリ秒後にリストが表示されるかどうかはわかりませんが、入力後50ミリ秒待機する必要がある場合は不快です)。もちろん、検索自体は十分に高速である必要があります。この場合、検索を高速化するためにスレッドを使用せず、UIを応答性に保ちます。 別個のスレッドはqueryを高速化しないことに注意してください、UIはハングしませんが、クエリが遅い場合でも、別のスレッドでは遅くなります(さらに、複数のシーケンシャルリクエストも処理する必要があります)。
[〜#〜] plinq [〜#〜] (Parallel LINQ)を使用してみてください。これは速度向上を保証するものではありませんが、これは試行錯誤によって見つける必要があります。
あなたはそれをより速くすることができるとは思いませんが、確かにあなたはすべきです:
a)AsParallel [〜#〜] linq [〜#〜] 拡張メソッドを使用します
a)何らかの種類のタイマーを使用してフィルタリングを遅らせる
b)フィルタリングメソッドを別のスレッドに配置する
何らかの種類のstring previousTextBoxValue
をどこかに保管してください。 previousTextBoxValue
がtextbox.Text
の値と同じである場合、ティックで検索を開始する1000µmsの遅延でタイマーを作成します。そうでない場合-previousTextBoxValue
を現在の値に再割り当てし、タイマーをリセットします。テキストボックス変更イベントにタイマーの開始を設定すると、アプリケーションがよりスムーズになります。 1〜3秒で120,000件のレコードをフィルタリングしても問題ありませんが、UIは応答性を維持する必要があります。
BindingSource.Filter 関数を使用して試すこともできます。私はそれを使用しましたが、検索対象のテキストでこのプロパティを更新するたびに、一連のレコードからフィルタリングするのが魅力的です。別のオプションは、TextBoxコントロールに AutoCompleteSource を使用することです。
それが役に立てば幸い!
コレクションをソートし、開始部分のみに一致するように検索し、検索をある数で制限しようとします。
初期化など
allUsers.Sort();
そして検索
allUsers.Where(item => item.StartWith(textBox_search.Text))
キャッシュを追加することができます。
パラレルLINQ
を使用します。 PLINQ
は、LINQ to Objectsの並列実装です。 PLINQは、T:System.Linq名前空間の拡張メソッドとしてLINQ標準クエリ演算子の完全なセットを実装し、並列操作用の追加演算子を備えています。 PLINQは、LINQ構文のシンプルさと読みやすさを並列プログラミングの能力と組み合わせています。タスクパラレルライブラリを対象とするコードと同様に、PLINQクエリは、ホストコンピューターの機能に基づいて同時実行性の度合いを調整します。
また、 Lucene.Net を使用できます
Lucene.NetはLucene検索エンジンライブラリの移植版であり、C#で記述され、.NETランタイムユーザーを対象としています。 Lucene検索ライブラリは、逆索引に基づいています。 Lucene.Netには、3つの主要な目標があります。
BinarySearchメソッドを使用してみてください。Containsメソッドよりも速く動作するはずです。
含まれるのはO(n) BinarySearchはO(lg(n))です
ソートされたコレクションは、検索ではより速く、新しい要素の追加ではより遅くなるはずですが、理解したように、検索パフォーマンスの問題しかありません。
私が見たものによると、リストをソートするという事実に同意します。
ただし、リストが構成されているときに並べ替えるのは非常に遅く、構築するときに並べ替えると、実行時間が短縮されます。
それ以外の場合、リストを表示したり順序を維持する必要がない場合は、ハッシュマップを使用します。
ハッシュマップは文字列をハッシュし、正確なオフセットで検索します。速くなるはずです。