对于非常长的字符串列表,什么是合适的搜索/检索方法?

时间:2014-03-13 20:16:05

标签: c# performance data-structures lookup

这不是一个非常罕见的问题,但我似乎无法找到真正解释选择的答案。

我有一个非常大的字符串列表(确切地说是SHA-256哈希的ASCII表示),我需要查询该列表中是否存在字符串。

此列表中可能会有超过1亿条目,我需要多次重复查询条目的存在。

考虑到尺寸,我怀疑我可以将它全部填入HashSet<string>。什么是最佳性能的适当检索系统?

我可以预先对列表进行排序,我可以把它放到一个SQL表中,我可以把它放到一个文本文件中,但是我不确定在我的应用程序中真正最有意义的是什么。

这些或其他检索方法在性能方面是否有明显优势?

16 个答案:

答案 0 :(得分:62)

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

结果很有希望。他们运行单线程。 hashset版本可以在7.9GB RAM使用率下每秒查看超过100万次查找。基于阵列的版本使用较少的RAM(4.6GB)。两者之间的启动时间几乎相同(388对391秒)。 hashset交换RAM以获得查找性能。由于内存分配限制,两者都必须被bucketized。

  

数组性能:

     

哈希和加法花了307408ms

     

哈希清理(通常是排序)花了81892ms

     

在562585ms [每秒53k次搜索]中找到30000000个元素(预期为30000000)

     

======================================

     

Hashset性能:

     

哈希和加法花了391105毫秒

     

哈希清理(通常是排序)花了0毫秒

     

在74864ms [每秒400k次搜索]中找到30000000个元素(预期为30000000)

答案 1 :(得分:21)

答案 2 :(得分:17)

使用<gcAllowVeryLargeObjects>,您可以拥有更大的数组。为什么不将256位哈希码的ASCII表示转换为实现IComparable<T>的自定义结构?它看起来像这样:

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

然后,您可以创建这些数组,这将占用大约3.2 GB。您可以使用Array.BinarySearch轻松搜索。

当然,您需要将用户的输入从ASCII转换为其中一个哈希代码结构,但这很容易。

至于性能,这不会像哈希表那么快,但它肯定会比数据库查找或文件操作更快。

想想看,你可以创建一个HashSet<MyHashCode>。您必须覆盖Equals上的MyHashCode方法,但这非常简单。我记得,HashSet每个条目的成本大约为24个字节,并且您需要增加更大结构的成本。如果您使用HashSet,则总计为五或六千兆字节。更多的内存,但仍然可行,你得到O(1)查找。

答案 3 :(得分:15)

这些答案不会将字符串内存计入应用程序。 字符串在.NET中不是1个字符== 1个字节。每个字符串对象都需要一个20字节的常量对象数据。缓冲区每个字符需要2个字节。因此:字符串实例的内存使用估计值为20 +(2 * Length)字节。

我们来做一些数学。

  • 100,000,000个UNIQUE字符串
  • SHA256 = 32字节(256位)
  • 每个字符串的大小= 20 +(2 * 32字节)= 84字节
  • 所需内存总量:8,400,000,000字节= 8.01千兆字节

可以这样做,但这不会很好地存储在.NET内存中。您的目标应该是将所有这些数据加载到一个可以访问/分页的表单中,而不必将其全部保存在内存中。为此,我使用Lucene.net将数据存储在磁盘上并智能地搜索它。将每个字符串写为可搜索的索引,然后在索引中搜索字符串。现在你有一个可扩展的应用程序,可以解决这个问题;你唯一的限制是磁盘空间(并且需要很多字符串才能填满一个TB的驱动器)。或者,将这些记录放在数据库中并对其进行查询。这就是数据库存在的原因:将事物保存在RAM之外。 :)

答案 4 :(得分:8)

散列集将数据拆分为存储桶(数组)。在64位系统上,the size limit for an array is 2 GB大致 2,000,000,000字节。

由于字符串是引用类型,并且由于引用占用8个字节(假设为64位系统),因此每个存储桶可以容纳大约250,000,000(2.5亿)个字符串引用。它似乎比你需要的更多。

话虽如此,正如Tim S.所指出的那样,即使引用符合hashset,你也不太可能拥有必要的内存来保存字符串。数据库对我来说更适合这个。

答案 5 :(得分:8)

要获得最大速度,请将它们保存在RAM中。它只有大约3GB的数据,加上你的数据结构需要的任何开销。 HashSet<byte[]>应该可以正常工作。如果您想降低开销和GC压力,请启用<gcAllowVeryLargeObjects>,使用单个byte[]和带有自定义比较器的HashSet<int>进行索引。

对于速度和内存使用率较低的情况,请将它们存储在基于磁盘的哈希表中。 为简单起见,将它们存储在数据库中。

无论你做什么,都应该将它们存储为纯二进制数据,而不是字符串。

答案 6 :(得分:7)

答案 7 :(得分:7)

在这种情况下你需要小心,因为大多数语言中的大多数集合并没有真正为这种规模设计或优化。正如您已经确定的,内存使用也是一个问题。

这里明显的赢家是使用某种形式的数据库。 SQL数据库或者有许多适合的NoSQL数据库。

SQL服务器已经过设计和优化,可以跟踪大量数据,对其进行索引以及跨这些索引进行搜索和查询。它的设计目的正是为了做到你想做的事情,所以真的是最好的方式。

对于性能,您可以考虑使用将在您的进程中运行的嵌入式数据库,并节省由此产生的通信开销。对于Java,我可以为此目的推荐一个Derby数据库,我不知道C#的等价物足以在那里提出建议,但我认为存在合适的数据库。

答案 8 :(得分:6)

如果集合是常量,那么只需创建一个大的排序哈希列表(原始格式,每个32字节)。存储所有哈希值以使它们适合磁盘扇区(4KB),并且每个扇区的开头也是哈希的开头。将每个第N个扇区中的第一个哈希保存在特殊索引列表中,该列表很容易适合内存。在此索引列表上使用二进制搜索来确定散列应该在的扇区群集的起始扇区,然后在此扇区群集中使用另一个二进制搜索来查找散列。应根据测试数据进行测量,确定值N.

编辑:替代方案是在磁盘上实现自己的哈希表。该表应使用open addressing策略,探测序列应尽可能限制在同一磁盘扇区。空槽必须用特殊值标记(例如全部为零),因此在查询存在时应特别处理此特殊值。为避免冲突,表的值不应小于80%,因此在您的情况下,有1亿个大小为32字节的条目,这意味着该表应至少有100M / 80%= 125百万个插槽,并且具有大小125M * 32 = 4 GB。您只需要创建将2 ^ 256域转换为125M的散列函数,以及一些不错的探测序列。

答案 9 :(得分:5)

您可以尝试使用Suffix Treequestion了解如何在C#中执行此操作

或者您可以尝试这样的搜索

var matches = list.AsParallel().Where(s => s.Contains(searchTerm)).ToList();

AsParallel将帮助加快速度,因为它可以创建查询的并行化。

答案 10 :(得分:2)

  1. 将您的哈希值存储为UInt32 [8]
  2. 2a上。使用排序列表。要比较两个哈希值,首先要比较它们的第一个元素;如果他们是平等的,那么比较第二个,依此类推。

    2B。使用前缀树

答案 11 :(得分:1)

首先,我建议您使用数据压缩以最大限度地减少资源消耗。缓存和内存带宽通常是现代计算机中最有限的资源。无论你如何实现这一点,最大的瓶颈就是等待数据。

我还建议使用现有的数据库引擎。其中许多都具有内置压缩功能,任何数据库都可以使用您可用的RAM。如果你有一个不错的操作系统,系统缓存将尽可能多地存储文件。但是大多数数据库都有自己的缓存子系统。

我真的不知道什么数据库引擎最适合你,你必须尝试一下。我个人经常使用具有良好性能的H2,可以用作内存和基于文件的数据库,并且具有透明压缩功能。

我看到有些人声称将数据导入数据库并构建搜索索引可能需要比某些自定义解决方案更长的时间。这可能是真的,但进口通常是非常罕见的。我将假设您对快速搜索更感兴趣,因为它们很可能是最常见的操作。

为什么SQL数据库既可靠又快速,您可能需要考虑NoSQL数据库。尝试一些替代方案。了解哪种解决方案可以为您提供最佳性能的唯一方法是对它们进行基准测试。

另外,您应该考虑将列表存储为文本是否合理。也许你应该将列表转换为数值。这将占用更少的空间,从而为您提供更快的查询。数据库导入可能会明显变慢,但查询可能会变得更快。

答案 12 :(得分:1)

如果你想要非常快,并且元素或多或少是不可变的并且需要完全匹配,你可以构建像病毒扫描程序一样运行的东西:设置范围以使用与之相关的任何算法来收集最少数量的潜在元素您的条目和搜索条件,然后遍历这些项目,使用RtlCompareMemory对搜索项进行测试。如果项目相当连续,您可以从磁盘中提取项目并使用以下内容进行比较:

    private Boolean CompareRegions(IntPtr hFile, long nPosition, IntPtr pCompare, UInt32 pSize)
    {
        IntPtr pBuffer = IntPtr.Zero;
        UInt32 iRead = 0;

        try
        {
            pBuffer = VirtualAlloc(IntPtr.Zero, pSize, MEM_COMMIT, PAGE_READWRITE);

            SetFilePointerEx(hFile, nPosition, IntPtr.Zero, FILE_BEGIN);
            if (ReadFile(hFile, pBuffer, pSize, ref iRead, IntPtr.Zero) == 0)
                return false;

            if (RtlCompareMemory(pCompare, pBuffer, pSize) == pSize)
                return true; // equal

            return false;
        }
        finally
        {
            if (pBuffer != IntPtr.Zero)
                VirtualFree(pBuffer, pSize, MEM_RELEASE);
        }
    }

我会修改这个例子来获取一个充满条目的大缓冲区,然后循环遍历这些条目。但是托管代码可能不是最好的方法..最快的是总是更接近实际工作的调用,所以内置模式访问的驱动程序建立在直接C上会更快..

答案 13 :(得分:1)

首先,你说这些字符串实际上是SHA256哈希值。注意100 million * 256 bits = 3.2 gigabytes,因此可以将整个列表放在内存中,假设您使用的是内存有效的数据结构。

如果你原谅偶尔的误报,你实际上可以使用更少的内存。请参阅bloom过滤器http://billmill.org/bloomfilter-tutorial/

否则,使用排序数据结构来实现快速查询(时间复杂度O(log n))。


如果您确实希望将数据存储在内存中(因为您经常查询并需要快速结果),请尝试使用Redis。 http://redis.io/

  

Redis是一个开源的,BSD许可的高级键值存储。它通常被称为数据结构服务器,因为键可以包含字符串,散列,列表,集和排序集。

它有一个set数据类型http://redis.io/topics/data-types#sets

  

Redis集是一个无序的字符串集合。可以在O(1)中添加,删除和测试成员的存在(恒定时间,无论集合中包含的元素数量如何)。


否则,请使用将数据保存在磁盘上的数据库。

答案 14 :(得分:0)

普通的vanilla二叉搜索树将在大型列表上提供出色的查找性能。但是,如果您不需要存储字符串并且简单的成员资格是您想要了解的,那么Bloom Filter可能是一个特定的解决方案。布隆过滤器是一种紧凑的数据结构,您可以使用所有字符串进行训练。经过训练,它可以快速告诉您之前是否看过字符串。它很少报告。误报,但从不报告假阴性。根据应用程序的不同,它们可以快速生成令人惊叹的结果并且内存相对较少。

答案 15 :(得分:0)

我开发了类似于Insta's方法的解决方案,但有一些差异。实际上,它看起来很像他的分块阵列解决方案。但是,我的方法不仅仅是简单地拆分数据,而是构建一个块索引,并将搜索引导到适当的块。

构建索引的方式与散列表非常相似,每个存储桶都是一个可以使用二进制搜索进行搜索的排序数组。但是,我认为计算SHA256哈希的哈希值没什么意义,所以我只需要取一个值的前缀。

这项技术的有趣之处在于您可以通过扩展索引键的长度来调整它。较长的键意味着较大的索引和较小的桶。我的8位测试用例可能很小; 10-12位可能会更有效。

我试图对这种方法进行基准测试,但它很快就会耗尽内存,因此无法在性能方面看到任何有趣的内容。

我还写了一个C实现。 C实现也无法处理指定大小的数据集(测试机器只有4GB的RAM),但确实管理得更多。 (在这种情况下,目标数据集实际上并不是一个问题,它是填满RAM的测试数据。)我无法找到一种很好的方法来快速地将数据投入其中看它的性能测试。

虽然我喜欢写这篇文章,但总的来说,它主要提供的证据支持你不应该在C#内存中尝试这样做的论点。

public interface IKeyed
{
    int ExtractKey();
}

struct Sha256_Long : IComparable<Sha256_Long>, IKeyed
{
    private UInt64 _piece1;
    private UInt64 _piece2;
    private UInt64 _piece3;
    private UInt64 _piece4;

    public Sha256_Long(string hex)
    {
        if (hex.Length != 64)
        {
            throw new ArgumentException("Hex string must contain exactly 64 digits.");
        }
        UInt64[] pieces = new UInt64[4];
        for (int i = 0; i < 4; i++)
        {
            pieces[i] = UInt64.Parse(hex.Substring(i * 8, 1), NumberStyles.HexNumber);
        }
        _piece1 = pieces[0];
        _piece2 = pieces[1];
        _piece3 = pieces[2];
        _piece4 = pieces[3];
    }

    public Sha256_Long(byte[] bytes)
    {
        if (bytes.Length != 32)
        {
            throw new ArgumentException("Sha256 values must be exactly 32 bytes.");
        }
        _piece1 = BitConverter.ToUInt64(bytes, 0);
        _piece2 = BitConverter.ToUInt64(bytes, 8);
        _piece3 = BitConverter.ToUInt64(bytes, 16);
        _piece4 = BitConverter.ToUInt64(bytes, 24);
    }

    public override string ToString()
    {
        return String.Format("{0:X}{0:X}{0:X}{0:X}", _piece1, _piece2, _piece3, _piece4);
    }

    public int CompareTo(Sha256_Long other)
    {
        if (this._piece1 < other._piece1) return -1;
        if (this._piece1 > other._piece1) return 1;
        if (this._piece2 < other._piece2) return -1;
        if (this._piece2 > other._piece2) return 1;
        if (this._piece3 < other._piece3) return -1;
        if (this._piece3 > other._piece3) return 1;
        if (this._piece4 < other._piece4) return -1;
        if (this._piece4 > other._piece4) return 1;
        return 0;
    }

    //-------------------------------------------------------------------
    // Implementation of key extraction

    public const int KeyBits = 8;
    private static UInt64 _keyMask;
    private static int _shiftBits;

    static Sha256_Long()
    {
        _keyMask = 0;
        for (int i = 0; i < KeyBits; i++)
        {
            _keyMask |= (UInt64)1 << i;
        }
        _shiftBits = 64 - KeyBits;
    }

    public int ExtractKey()
    {
        UInt64 keyRaw = _piece1 & _keyMask;
        return (int)(keyRaw >> _shiftBits);
    }
}

class IndexedSet<T> where T : IComparable<T>, IKeyed
{
    private T[][] _keyedSets;

    public IndexedSet(IEnumerable<T> source, int keyBits)
    {
        // Arrange elements into groups by key
        var keyedSetsInit = new Dictionary<int, List<T>>();
        foreach (T item in source)
        {
            int key = item.ExtractKey();
            List<T> vals;
            if (!keyedSetsInit.TryGetValue(key, out vals))
            {
                vals = new List<T>();
                keyedSetsInit.Add(key, vals);
            }
            vals.Add(item);
        }

        // Transform the above structure into a more efficient array-based structure
        int nKeys = 1 << keyBits;
        _keyedSets = new T[nKeys][];
        for (int key = 0; key < nKeys; key++)
        {
            List<T> vals;
            if (keyedSetsInit.TryGetValue(key, out vals))
            {
                _keyedSets[key] = vals.OrderBy(x => x).ToArray();
            }
        }
    }

    public bool Contains(T item)
    {
        int key = item.ExtractKey();
        if (_keyedSets[key] == null)
        {
            return false;
        }
        else
        {
            return Search(item, _keyedSets[key]);
        }
    }

    private bool Search(T item, T[] set)
    {
        int first = 0;
        int last = set.Length - 1;

        while (first <= last)
        {
            int midpoint = (first + last) / 2;
            int cmp = item.CompareTo(set[midpoint]);
            if (cmp == 0)
            {
                return true;
            }
            else if (cmp < 0)
            {
                last = midpoint - 1;
            }
            else
            {
                first = midpoint + 1;
            }
        }
        return false;
    }
}

class Program
{
    //private const int NTestItems = 100 * 1000 * 1000;
    private const int NTestItems = 1 * 1000 * 1000;

    private static Sha256_Long RandomHash(Random rand)
    {
        var bytes = new byte[32];
        rand.NextBytes(bytes);
        return new Sha256_Long(bytes);
    }

    static IEnumerable<Sha256_Long> GenerateRandomHashes(
        Random rand, int nToGenerate)
    {
        for (int i = 0; i < nToGenerate; i++)
        {
            yield return RandomHash(rand);
        }
    }

    static void Main(string[] args)
    {
        Console.WriteLine("Generating test set.");

        var rand = new Random();

        IndexedSet<Sha256_Long> set =
            new IndexedSet<Sha256_Long>(
                GenerateRandomHashes(rand, NTestItems),
                Sha256_Long.KeyBits);

        Console.WriteLine("Testing with random input.");

        int nFound = 0;
        int nItems = NTestItems;
        int waypointDistance = 100000;
        int waypoint = 0;
        for (int i = 0; i < nItems; i++)
        {
            if (++waypoint == waypointDistance)
            {
                Console.WriteLine("Test lookups complete: " + (i + 1));
                waypoint = 0;
            }
            var item = RandomHash(rand);
            nFound += set.Contains(item) ? 1 : 0;
        }

        Console.WriteLine("Testing complete.");
        Console.WriteLine(String.Format("Found: {0} / {0}", nFound, nItems));
        Console.ReadKey();
    }
}