Problème:
J'ai un fichier texte d'environ 120 000 utilisateurs (chaînes) que je voudrais stocker dans une collection et plus tard pour effectuer une recherche sur cette collection.
La méthode de recherche se produit chaque fois que l'utilisateur modifie le texte d'un TextBox
et le résultat doit être les chaînes qui contiennent le texte dans TextBox
.
Je n'ai pas à modifier la liste, il suffit de tirer les résultats et de les mettre dans un ListBox
.
Ce que j'ai essayé jusqu'à présent:
J'ai essayé avec deux collections/conteneurs différents, que je vide les entrées de chaîne à partir d'un fichier texte externe (une fois, bien sûr):
List<string> allUsers;
HashSet<string> allUsers;
Avec la requête LINQ suivante:
allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();
Mon événement de recherche (se déclenche lorsque l'utilisateur modifie le texte de recherche):
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;
}
}
Résultats:
Les deux m'ont donné un mauvais temps de réponse (environ 1 à 3 secondes entre chaque pression de touche).
Question:
Où pensez-vous que mon goulot d'étranglement est? La collection que j'ai utilisée? La méthode de recherche? Tous les deux?
Comment obtenir de meilleures performances et des fonctionnalités plus fluides?
Vous pouvez envisager de faire la tâche de filtrage sur un thread d'arrière-plan qui invoquerait une méthode de rappel lorsqu'elle est terminée, ou simplement redémarrer le filtrage si l'entrée est modifiée.
L'idée générale est de pouvoir l'utiliser comme ceci:
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);
}
}
Une ébauche serait quelque chose comme:
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();
}
}
En outre, vous devez en fait disposer le _filter
instance lorsque le parent Form
est supprimé. Cela signifie que vous devez ouvrir et modifier la méthode Form
de votre Dispose
(à l'intérieur de la YourForm.Designer.cs
fichier) pour ressembler à:
// 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);
}
Sur ma machine, cela fonctionne assez rapidement, vous devez donc tester et profiler cela avant d'aller vers une solution plus complexe.
Cela étant dit, une "solution plus complexe" consisterait peut-être à stocker les deux derniers résultats dans un dictionnaire, puis à les filtrer uniquement s'il s'avère que la nouvelle entrée ne diffère que par le premier du dernier caractère.
J'ai fait quelques tests, et la recherche dans une liste de 120 000 éléments et le remplissage d'une nouvelle liste avec les entrées prennent un temps négligeable (environ un 1/50e de seconde même si toutes les chaînes sont appariées).
Le problème que vous voyez doit donc provenir du remplissage de la source de données, ici:
listBox_choices.DataSource = ...
Je soupçonne que vous mettez simplement trop d'articles dans la liste.
Vous devriez peut-être essayer de le limiter aux 20 premières entrées, comme ceci:
listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text))
.Take(20).ToList();
Notez également (comme d'autres l'ont souligné) que vous accédez au TextBox.Text
propriété pour chaque élément dans allUsers
. Cela peut facilement être résolu comme suit:
string target = textBox_search.Text;
listBox_choices.DataSource = allUsers.Where(item => item.Contains(target))
.Take(20).ToList();
Cependant, j'ai chronométré le temps qu'il faut pour accéder à TextBox.Text
500 000 fois et cela n'a pris que 0,7 seconde, bien moins que les 1 à 3 secondes mentionnées dans le PO. Pourtant, c'est une optimisation intéressante.
Utilisez Arborescence des suffixes comme index. Ou plutôt créez simplement un dictionnaire trié qui associe chaque suffixe de chaque nom à la liste des noms correspondants.
Pour l'entrée:
Abraham
Barbara
Abram
La structure ressemblerait à:
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
Algorithme de recherche
Supposons que l'utilisateur saisit "soutien-gorge".
Ces arbres sont conçus pour rechercher rapidement des sous-chaînes. Ses performances sont proches de O (log n). Je crois que cette approche fonctionnera assez rapidement pour être utilisée directement par le thread GUI. De plus, il fonctionnera plus rapidement que la solution filetée en raison de l'absence de surcharge de synchronisation.
Vous avez besoin d'un moteur de recherche de texte (comme Lucene.Net ), ou d'une base de données (vous pouvez envisager un moteur embarqué comme SQL CE , SQLite , etc.). En d'autres termes, vous avez besoin d'une recherche indexée. La recherche basée sur le hachage n'est pas applicable ici, car vous recherchez une sous-chaîne, tandis que la recherche basée sur le hachage est idéale pour rechercher la valeur exacte.
Sinon, ce sera une recherche itérative avec boucle dans la collection.
Il pourrait également être utile d'avoir un type d'événement "anti-rebond". Cela diffère de la limitation dans la mesure où il attend un certain temps (par exemple, 200 ms) pour que les modifications se terminent avant de déclencher l'événement.
Voir Debounce and Throttle: a visual Explication pour plus d'informations sur le rebouncing. J'apprécie que cet article soit axé sur JavaScript, au lieu de C #, mais le principe s'applique.
L'avantage de cela est qu'il ne recherche pas lorsque vous entrez toujours votre requête. Il devrait alors cesser d'essayer d'effectuer deux recherches à la fois.
Exécutez la recherche sur un autre thread et affichez une animation de chargement ou une barre de progression pendant l'exécution de ce thread.
Vous pouvez également essayer de paralléliser la requête LINQ .
var queryResults = strings.AsParallel().Where(item => item.Contains("1")).ToList();
Voici une référence qui démontre les avantages de performance d'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);
}
J'ai fait du profilage.
(mise à jour 3)
Le test initial de 2 500 000 enregistrements m'a pris 20 000 ms.
Le coupable numéro un est l'appel à textBox_search.Text
à l'intérieur Contains
. Cela fait un appel pour chaque élément au coûteux get_WindowText
méthode de la zone de texte. Changer simplement le code en:
var text = textBox_search.Text;
listBox_choices.DataSource = allUsers.Where(item => item.Contains(text)).ToList();
réduit le temps d'exécution à 1.858ms.
Les deux autres goulots d'étranglement importants sont maintenant l'appel à string.Contains
(environ 45% du temps d'exécution) et la mise à jour des éléments de listbox dans set_Datasource
(30%).
Nous pourrions faire un compromis entre la vitesse et l'utilisation de la mémoire en créant un arbre de suffixes comme Basilevs a suggéré de réduire le nombre de comparaisons nécessaires et de pousser un certain temps de traitement de la recherche après une pression de touche au chargement des noms à partir du fichier qui peut être préférable pour l'utilisateur.
Pour augmenter les performances de chargement des éléments dans la zone de liste, je suggère de ne charger que les premiers éléments et d'indiquer à l'utilisateur que d'autres éléments sont disponibles. De cette façon, vous signalez à l'utilisateur que des résultats sont disponibles afin qu'il puisse affiner sa recherche en entrant plus de lettres ou charger la liste complète en appuyant sur un bouton.
L'utilisation de BeginUpdate
et EndUpdate
n'a pas modifié le temps d'exécution de set_Datasource
.
Comme d'autres l'ont noté ici, la requête LINQ elle-même s'exécute assez rapidement. Je crois que votre goulot d'étranglement est la mise à jour de la listbox elle-même. Vous pouvez essayer quelque chose comme:
if (textBox_search.Text.Length > 2) { listBox_choices.BeginUpdate(); listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text)).ToList(); listBox_choices.EndUpdate(); }
J'espère que ça aide.
En supposant que vous ne correspondiez que par des préfixes, la structure de données que vous recherchez est appelée trie , également connue sous le nom d '"arbre de préfixes". Le IEnumerable.Where
la méthode que vous utilisez maintenant devra parcourir tous les éléments de votre dictionnaire à chaque accès.
Ce fil montre comment créer un trie en C #.
Le contrôle WinForms ListBox est vraiment votre ennemi ici. Le chargement des enregistrements sera lent et la barre de défilement vous combattra pour afficher les 120 000 enregistrements.
Essayez d'utiliser un DataGridView à l'ancienne à partir d'un DataTable avec une seule colonne [UserName] pour contenir vos données:
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;
}
Utilisez ensuite un DataView dans l'événement TextChanged de votre TextBox pour filtrer les données:
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;
}
Je voudrais d'abord changer la façon dont ListControl
voit votre source de données, vous convertissez le résultat IEnumerable<string>
En List<string>
. Surtout lorsque vous venez de taper quelques caractères, cela peut être inefficace (et inutile). Ne faites pas de copies volumineuses de vos données .
.Where()
dans une collection qui implémente uniquement ce qui est requis de IList
(recherche). Cela vous évitera de créer une nouvelle grande liste pour chaque caractère tapé.La deuxième étape consiste à ne pas chercher dans la grande liste quand une petite suffit. Lorsque l'utilisateur a commencé à taper "ab" et qu'il ajoute "c", vous n'avez pas besoin de rechercher dans la grande liste, la recherche dans la liste filtrée est suffisante (et plus rapide). Affiner la recherche chaque fois que cela est possible, ne pas effectuer une recherche complète à chaque fois.
La troisième étape peut être plus difficile: garder les données organisées pour une recherche rapide . Vous devez maintenant modifier la structure que vous utilisez pour stocker vos données. imaginez un arbre comme celui-ci:
A B C Ajouter un meilleur plafond Au-dessus du contour osseux
Cela peut simplement être implémenté avec un tableau (si vous travaillez avec des noms ANSI sinon un dictionnaire serait mieux). Construisez la liste comme ceci (à des fins d'illustration, elle correspond au début de la chaîne):
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);
}
}
La recherche se fera ensuite en utilisant le premier caractère:
char letter = textBox_search.Text[0];
if (dictionary.Contains(letter))
{
listBox_choices.DataSource =
new MyListWrapper(dictionary[letter].Where(x => x.Contains(textBox_search.Text)));
}
Veuillez noter que j'ai utilisé MyListWrapper()
comme suggéré à la première étape (mais j'ai omis par la deuxième suggestion pour plus de brièveté, si vous choisissez la bonne taille pour la clé du dictionnaire, vous pouvez garder chaque liste courte et rapide pour - peut-être - éviter toute autre chose) . Notez en outre que vous pouvez essayer d'utiliser les deux premiers caractères pour votre dictionnaire (plus de listes et plus court). Si vous étendez cela, vous aurez un arbre (mais je ne pense pas que vous ayez un si grand nombre d'articles).
Il existe de nombreux algorithmes différents pour la recherche de chaînes (avec des structures de données connexes), pour n'en citer que quelques-uns:
Quelques mots sur la recherche parallèle. C'est possible mais c'est rarement trivial car les frais généraux pour le rendre parallèle peuvent être facilement beaucoup plus élevés que la recherche elle-même. Je n'effectuerais pas la recherche elle-même en parallèle (le partitionnement et la synchronisation deviendront bientôt trop étendus et peut-être complexes) mais je voudrais déplacer la recherche vers un thread séparé . Si le thread principal n'est pas occupé vos utilisateurs ne ressentiront aucun retard pendant la frappe (ils ne noteront pas si la liste apparaîtra après 200 ms mais ils se sentiront mal à l'aise s'ils attendre 50 ms après avoir tapé). Bien sûr, la recherche elle-même doit être assez rapide, dans ce cas, vous n'utilisez pas de threads pour accélérer la recherche mais pour garder votre interface utilisateur réactive . Veuillez noter qu'un thread séparé ne rendra pas votre requête plus rapide , il ne suspendra pas l'interface utilisateur mais si votre requête était lent, il sera toujours lent dans un thread séparé (de plus, vous devez également gérer plusieurs demandes séquentielles).
Vous pouvez essayer d'utiliser PLINQ (Parallel LINQ). Bien que cela ne garantisse pas une augmentation de la vitesse, vous devez le savoir par essais et erreurs.
Je doute que vous puissiez l'accélérer, mais vous devez certainement:
a) Utilisez la méthode d'extension AsParallel LINQ
a) Utilisez une sorte de temporisateur pour retarder le filtrage
b) Mettre une méthode de filtrage sur un autre thread
Gardez une sorte de string previousTextBoxValue
quelque part. Créez une minuterie avec un délai de 1000 ms, qui déclenche la recherche sur tick si previousTextBoxValue
est identique à votre textbox.Text
valeur. Sinon - réaffectez previousTextBoxValue
à la valeur actuelle et réinitialisez la minuterie. Réglez le démarrage du minuteur sur l'événement modifié de la zone de texte, et cela rendra votre application plus fluide. Filtrer 120 000 enregistrements en 1 à 3 secondes est OK, mais votre interface utilisateur doit rester réactive.
Vous pouvez également essayer d'utiliser la fonction BindingSource.Filter . Je l'ai utilisé et cela fonctionne comme un charme pour filtrer des tas d'enregistrements, à chaque fois mettre à jour cette propriété avec le texte recherché. Une autre option serait d'utiliser AutoCompleteSource pour le contrôle TextBox.
J'espère que ça aide!
J'essaierais de trier la collection, de rechercher pour faire correspondre uniquement la partie de départ et de limiter la recherche par un certain nombre.
ainsi de suite l'ininialisation
allUsers.Sort();
et chercher
allUsers.Where(item => item.StartWith(textBox_search.Text))
Vous pouvez peut-être ajouter du cache.
Utilisez Parallel LINQ
. PLINQ
est une implémentation parallèle de LINQ to Objects. PLINQ implémente l'ensemble complet des opérateurs de requête standard LINQ en tant que méthodes d'extension pour l'espace de noms T: System.Linq et dispose d'opérateurs supplémentaires pour les opérations parallèles. PLINQ combine la simplicité et la lisibilité de la syntaxe LINQ avec la puissance de la programmation parallèle. Tout comme le code qui cible la bibliothèque parallèle de tâches, les requêtes PLINQ évoluent selon le degré de concurrence en fonction des capacités de l'ordinateur hôte.
Comprendre l'accélération dans PLINQ
Vous pouvez également utiliser Lucene.Net
Lucene.Net est un port de la bibliothèque du moteur de recherche Lucene, écrit en C # et destiné aux utilisateurs d'exécution .NET. La bibliothèque de recherche Lucene est basée sur un index inversé. Lucene.Net a trois objectifs principaux:
Essayez d'utiliser la méthode BinarySearch, elle devrait fonctionner plus rapidement que la méthode Contains.
Contient sera un O(n) BinarySearch est un O (lg (n))
Je pense que la collection triée devrait fonctionner plus rapidement sur la recherche et plus lentement sur l'ajout de nouveaux éléments, mais si j'ai bien compris, vous n'avez qu'un problème de performance de recherche.
D'après ce que j'ai vu, je suis d'accord avec le fait de trier la liste.
Cependant, pour trier lorsque la liste est construite sera très lent, trier lors de la construction, vous aurez un meilleur temps d'exécution.
Sinon, si vous n'avez pas besoin d'afficher la liste ou de conserver l'ordre, utilisez une table de hachage.
La table de hachage hachera votre chaîne et recherchera le décalage exact. Ça devrait être plus rapide je pense.