65 votes

Quelle est la méthode de recherche/récupération appropriée pour une liste TRÈS longue de chaînes de caractères ?

Ce n'est pas une question très rare, mais je n'ai pas réussi à trouver une réponse qui explique vraiment ce choix.

J'ai une très grande liste de chaînes de caractères (représentations ASCII de SHA-256 pour être exact), et je dois rechercher la présence d'une chaîne dans cette liste.

Il y aura probablement plus de 100 millions d'entrées dans cette liste, et je devrai répéter plusieurs fois la recherche d'une entrée.

Compte tenu de la taille, je doute de pouvoir tout faire entrer dans une HashSet<string> . Quel serait un système de récupération approprié pour maximiser les performances ?

Je PEUX trier la liste au préalable, je PEUX la placer dans une table SQL, je PEUX la placer dans un fichier texte, mais je ne suis pas sûr de ce qui est le plus logique compte tenu de mon application.

Y a-t-il un vainqueur incontestable en termes de performances parmi ces méthodes ou d'autres méthodes de recherche ?

62voto

insta Points 2628
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;

namespace HashsetTest
{
    abstract class HashLookupBase
    {
        protected const int BucketCount = 16;

        private readonly HashAlgorithm _hasher;

        protected HashLookupBase()
        {
            _hasher = SHA256.Create();
        }

        public abstract void AddHash(byte[] data);
        public abstract bool Contains(byte[] data);

        private byte[] ComputeHash(byte[] data)
        {
            return _hasher.ComputeHash(data);
        }

        protected Data256Bit GetHashObject(byte[] data)
        {
            var hash = ComputeHash(data);
            return Data256Bit.FromBytes(hash);
        }

        public virtual void CompleteAdding() { }
    }

    class HashsetHashLookup : HashLookupBase
    {
        private readonly HashSet<Data256Bit>[] _hashSets;

        public HashsetHashLookup()
        {
            _hashSets = new HashSet<Data256Bit>[BucketCount];

            for(int i = 0; i < _hashSets.Length; i++)
                _hashSets[i] = new HashSet<Data256Bit>();
        }

        public override void AddHash(byte[] data)
        {
            var item = GetHashObject(data);
            var offset = item.GetHashCode() & 0xF;
            _hashSets[offset].Add(item);
        }

        public override bool Contains(byte[] data)
        {
            var target = GetHashObject(data);
            var offset = target.GetHashCode() & 0xF;
            return _hashSets[offset].Contains(target);
        }
    }

    class ArrayHashLookup : HashLookupBase
    {
        private Data256Bit[][] _objects;
        private int[] _offsets;
        private int _bucketCounter;

        public ArrayHashLookup(int size)
        {
            size /= BucketCount;
            _objects = new Data256Bit[BucketCount][];
            _offsets = new int[BucketCount];

            for(var i = 0; i < BucketCount; i++) _objects[i] = new Data256Bit[size + 1];

            _bucketCounter = 0;
        }

        public override void CompleteAdding()
        {
            for(int i = 0; i < BucketCount; i++) Array.Sort(_objects[i]);
        }

        public override void AddHash(byte[] data)
        {
            var hashObject = GetHashObject(data);
            _objects[_bucketCounter][_offsets[_bucketCounter]++] = hashObject;
            _bucketCounter++;
            _bucketCounter %= BucketCount;
        }

        public override bool Contains(byte[] data)
        {
            var hashObject = GetHashObject(data);
            return _objects.Any(o => Array.BinarySearch(o, hashObject) >= 0);
        }
    }

    struct Data256Bit : IEquatable<Data256Bit>, IComparable<Data256Bit>
    {
        public bool Equals(Data256Bit other)
        {
            return _u1 == other._u1 && _u2 == other._u2 && _u3 == other._u3 && _u4 == other._u4;
        }

        public int CompareTo(Data256Bit other)
        {
            var rslt = _u1.CompareTo(other._u1);    if (rslt != 0) return rslt;
            rslt = _u2.CompareTo(other._u2);        if (rslt != 0) return rslt;
            rslt = _u3.CompareTo(other._u3);        if (rslt != 0) return rslt;

            return _u4.CompareTo(other._u4);
        }

        public override bool Equals(object obj)
        {
            if (ReferenceEquals(null, obj))
                return false;
            return obj is Data256Bit && Equals((Data256Bit) obj);
        }

        public override int GetHashCode()
        {
            unchecked
            {
                var hashCode = _u1.GetHashCode();
                hashCode = (hashCode * 397) ^ _u2.GetHashCode();
                hashCode = (hashCode * 397) ^ _u3.GetHashCode();
                hashCode = (hashCode * 397) ^ _u4.GetHashCode();
                return hashCode;
            }
        }

        public static bool operator ==(Data256Bit left, Data256Bit right)
        {
            return left.Equals(right);
        }

        public static bool operator !=(Data256Bit left, Data256Bit right)
        {
            return !left.Equals(right);
        }

        private readonly long _u1;
        private readonly long _u2;
        private readonly long _u3;
        private readonly long _u4;

        private Data256Bit(long u1, long u2, long u3, long u4)
        {
            _u1 = u1;
            _u2 = u2;
            _u3 = u3;
            _u4 = u4;
        }

        public static Data256Bit FromBytes(byte[] data)
        {
            return new Data256Bit(
                BitConverter.ToInt64(data, 0),
                BitConverter.ToInt64(data, 8),
                BitConverter.ToInt64(data, 16),
                BitConverter.ToInt64(data, 24)
            );
        }
    }

    class Program
    {
        private const int TestSize = 150000000;

        static void Main(string[] args)
        {
            GC.Collect(3);
            GC.WaitForPendingFinalizers();

            {
                var arrayHashLookup = new ArrayHashLookup(TestSize);
                PerformBenchmark(arrayHashLookup, TestSize);
            }

            GC.Collect(3);
            GC.WaitForPendingFinalizers();

            {
                var hashsetHashLookup = new HashsetHashLookup();
                PerformBenchmark(hashsetHashLookup, TestSize);
            }

            Console.ReadLine();
        }

        private static void PerformBenchmark(HashLookupBase hashClass, int size)
        {
            var sw = Stopwatch.StartNew();

            for (int i = 0; i < size; i++)
                hashClass.AddHash(BitConverter.GetBytes(i * 2));

            Console.WriteLine("Hashing and addition took " + sw.ElapsedMilliseconds + "ms");

            sw.Restart();
            hashClass.CompleteAdding();
            Console.WriteLine("Hash cleanup (sorting, usually) took " + sw.ElapsedMilliseconds + "ms");

            sw.Restart();
            var found = 0;

            for (int i = 0; i < size * 2; i += 10)
            {
                found += hashClass.Contains(BitConverter.GetBytes(i)) ? 1 : 0;
            }

            Console.WriteLine("Found " + found + " elements (expected " + (size / 5) + ") in " + sw.ElapsedMilliseconds + "ms");
        }
    }
}

Les résultats sont plutôt prometteurs. Ils fonctionnent en mode monofilaire. La version hashset peut atteindre un peu plus de 1 million de recherches par seconde avec une utilisation de 7,9 Go de RAM. La version basée sur un tableau utilise moins de RAM (4,6 Go). Les temps de démarrage entre les deux sont presque identiques (388 vs 391 secondes). Le hashset échange la RAM contre les performances de recherche. Les deux ont dû être bucketisés en raison de contraintes d'allocation de mémoire.

Performances du réseau :

Le hachage et l'addition ont pris 307408ms

Le nettoyage du hachage (tri, généralement) a pris 81892ms

Trouvé 30000000 éléments (attendu 30000000) en 562585ms [53k recherches par seconde].

\======================================

Performances du Hashset :

Le hachage et l'addition ont pris 391105ms

Le nettoyage du hachage (tri, généralement) a pris 0ms

Trouvé 30000000 éléments (attendu 30000000) en 74864ms [400k recherches par seconde].

21voto

Juan Lopes Points 2194

Si la liste change au fil du temps, je la mettrais dans une base de données.

Si la liste ne change pas, je la mettrais dans un fichier trié et je ferais une recherche binaire pour chaque requête.

Dans les deux cas, j'utiliserais un Filtre Bloom pour minimiser les entrées/sorties. Et j'arrêterais d'utiliser les chaînes de caractères et utiliserais la représentation binaire avec quatre ulongs (pour éviter le coût de la référence objet).

Si vous avez plus de 16 GB (2*64*4/3*100M, en supposant que Base64 ), une option est de faire un Set&ltstring> et d'être heureux. Bien sûr, cela tiendrait dans moins de 7 Go si vous utilisez la représentation binaire.

La réponse de David Haney nous montre que le coût de la mémoire n'est pas si facile à calculer.

PS : SHA-256 utilise 256 bits, et non des octets.

17voto

Jim Mischel Points 68586

Avec <gcAllowVeryLargeObjects> vous pouvez avoir des tableaux beaucoup plus grands. Pourquoi ne pas convertir ces représentations ASCII de codes de hachage de 256 bits en une structure personnalisée qui met en œuvre les fonctions suivantes IComparable<T> ? Cela ressemblerait à ceci :

struct MyHashCode: IComparable<MyHashCode>
{
    // make these readonly and provide a constructor
    ulong h1, h2, h3, h4;

    public int CompareTo(MyHashCode other)
    {
        var rslt = h1.CompareTo(other.h1);
        if (rslt != 0) return rslt;
        rslt = h2.CompareTo(other.h2);
        if (rslt != 0) return rslt;
        rslt = h3.CompareTo(other.h3);
        if (rslt != 0) return rslt;
        return h4.CompareTo(other.h4);
    }
}

Vous pouvez ensuite créer un tableau de ceux-ci, qui occuperait environ 3,2 Go. Vous pouvez le rechercher assez facilement avec Recherche binaire (Array) .

Bien sûr, vous devrez convertir l'entrée de l'utilisateur de l'ASCII à l'une de ces structures de code de hachage, mais c'est assez facile.

Quant aux performances, elles ne seront pas aussi rapides que celles d'une table de hachage, mais elles seront certainement plus rapides que celles d'une consultation de base de données ou d'opérations sur des fichiers.

Maintenant que j'y pense, vous pourriez créer un HashSet<MyHashCode> . Vous devrez remplacer le Equals méthode sur MyHashCode mais c'est très facile. Si je me souviens bien, le HashSet coûte quelque chose comme 24 octets par entrée, et vous auriez le coût supplémentaire de la plus grande structure. Comptez cinq ou six gigaoctets, au total, si vous deviez utiliser une structure de type HashSet . Plus de mémoire, mais toujours faisable, et vous obtenez une recherche O(1).

15voto

Haney Points 8373

Ces réponses ne tiennent pas compte de la mémoire des chaînes dans l'application. Les chaînes de caractères ne sont pas 1 char == 1 byte en .NET. Chaque objet de type chaîne de caractères requiert une quantité constante de 20 octets pour les données de l'objet. Et le tampon nécessite 2 octets par caractère. Par conséquent : l'estimation de l'utilisation de la mémoire pour une instance de chaîne est de 20 + (2 * Longueur) octets.

Faisons un peu de maths.

  • 100.000.000 chaînes UNIQUES
  • SHA256 = 32 octets (256 bits)
  • taille de chaque chaîne = 20 + (2 * 32 octets) = 84 octets
  • Mémoire totale requise : 8 400 000 000 octets = 8,01 gigaoctets

Il est possible de le faire, mais cela ne sera pas bien stocké dans la mémoire de .NET. Votre objectif devrait être de charger toutes ces données dans un formulaire auquel on peut accéder ou que l'on peut paginer sans avoir à les garder toutes en mémoire en même temps. Pour cela, j'utiliserais Lucene.net qui stocke vos données sur le disque et les recherche intelligemment. Écrivez chaque chaîne de caractères comme étant recherchable dans un index, puis recherchez la chaîne de caractères dans l'index. Vous avez maintenant une application évolutive qui peut gérer ce problème ; votre seule limite sera l'espace disque (et il faudrait beaucoup de chaînes pour remplir un disque d'un téraoctet). Une autre solution consiste à placer ces enregistrements dans une base de données et à effectuer des requêtes sur celle-ci. C'est pour cela que les bases de données existent : pour faire persister les choses en dehors de la mémoire vive :)

8voto

dcastro Points 17978

Un hashset divise vos données en buckets (tableaux). Sur un système 64 bits, la taille limite d'un tableau est de 2 Go qui est à peu près 2.000.000.000 d'octets.

Étant donné qu'une chaîne de caractères est un type de référence et qu'une référence prend huit octets (en supposant un système 64 bits), chaque seau peut contenir environ 250 000 000 (250 millions) de références à des chaînes de caractères. Cela semble être bien plus que ce dont vous avez besoin.

Cela dit, comme l'a souligné Tim S., il est très peu probable que vous disposiez de la mémoire nécessaire pour contenir les chaînes de caractères elles-mêmes, même si les références peuvent être placées dans le hashset. Une base de données serait bien plus appropriée pour cela.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X