web-dev-qa-db-fra.com

Le moyen le plus rapide de rechercher dans une collection de chaînes

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):

  1. List<string> allUsers;
  2. 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?

79
etaiso

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.

48
Groo

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.

36
Matthew Watson

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".

  1. Bisect le dictionnaire sur l'entrée utilisateur pour trouver l'entrée utilisateur ou la position où elle pourrait aller. De cette façon, nous trouvons "barbara" - dernière touche inférieure à "soutien-gorge". Il est appelé borne inférieure pour "soutien-gorge". La recherche prendra du temps logarithmique.
  2. Itérer à partir de la clé trouvée jusqu'à ce que l'entrée utilisateur ne corresponde plus. Cela donnerait "bram" -> Abram et "braham" -> Abraham.
  3. Concatène le résultat de l'itération (Abram, Abraham) et le produit.

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.

28
Basilevs

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.

15
Dennis

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.

12
paulslater19

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);
}
11
animaonline

Mise à jour:

J'ai fait du profilage.

(mise à jour 3)

  • Contenu de la liste: nombres générés de 0 à 2.499.999
  • Filtrer le texte: 123 (20.477 résultats)
  • Core i5-2500, Win7 64 bits, 8 Go de RAM
  • VS2012 + JetBrains dotTrace

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.

Mise à jour 2:

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.

11
Andris

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 #.

9
Groo

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

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 .

  • J'envelopperais le résultat .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é.
  • Comme alternative, j'éviterais LINQ et j'écrirais quelque chose de plus spécifique (et optimisé). Conservez votre liste en mémoire et créez un tableau d'index correspondants, réutilisez le tableau afin de ne pas avoir à le réallouer pour chaque recherche.

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:

  • Recherche basée sur un automate à états finis : dans cette approche, nous évitons le retour en arrière en construisant un automate fini déterministe (DFA) qui reconnaît la chaîne de recherche stockée. Ceux-ci sont coûteux à construire - ils sont généralement créés à l'aide de la construction du jeu de puissance - mais sont très rapides à utiliser.
  • Stubs : Knuth – Morris – Pratt calcule un DFA qui reconnaît les entrées avec la chaîne à rechercher comme suffixe, Boyer – Moore commence la recherche à partir de la fin de l'aiguille, de sorte qu'il peut généralement avancer d'une longueur d'aiguille entière à chaque étape. Baeza – Yates garde une trace de si les caractères j précédents étaient un préfixe de la chaîne de recherche, et est donc adaptable à la recherche de chaîne floue. L'algorithme bitap est une application de l'approche de Baeza – Yates.
  • Méthodes d'indexation : des algorithmes de recherche plus rapides sont basés sur le prétraitement du texte. Après avoir construit un index de sous-chaîne, par exemple un arbre de suffixes ou un tableau de suffixes, les occurrences d'un modèle peuvent être trouvées rapidement.
  • Autres variantes : certaines méthodes de recherche, par exemple la recherche de trigrammes, sont destinées à trouver un score de "proximité" entre la chaîne de recherche et le texte plutôt qu'un " match/non-match ". Ces recherches sont parfois appelées recherches "floues".

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).

7
Adriano Repetti

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.

4
D. Gierveld

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.

4
Tarec

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!

3
NeverHopeless

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.

2
hardsky

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.

Introduction à PLINQ

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:

1
user1968030

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.

1
user2917540

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.

1
dada