List <T> .Contains () дуже повільний?


93

Хто-небудь може пояснити мені, чому загальна List.Contains()функція працює так повільно?

У мене є List<long>близько мільйона номерів, і код, який постійно перевіряє, чи є в цих номерах певний номер.

Я спробував зробити те саме, використовуючи Dictionary<long, byte>та Dictionary.ContainsKey()функцію, і це було приблизно в 10-20 разів швидше, ніж у списку.

Звичайно, я не дуже хочу використовувати Словник для цієї мети, оскільки він не був призначений для використання таким чином.

Отже, справжнє питання тут полягає в тому, чи існує якась альтернатива List<T>.Contains(), але не така хитра, як Dictionary<K,V>.ContainsKey()?


2
Яка проблема зі словником? Він призначений для використання у випадку, подібному до вашого.
Камарей

4
@Kamarey: HashSet може бути кращим варіантом.
Брайан Расмуссен,

HashSet - це те, що я шукав.
DSent

Відповіді:


159

Якщо ви просто перевіряєте наявність, HashSet<T>у .NET 3.5 є найкращим варіантом - схожість на словник, але відсутність пари ключ / значення - лише значення:

    HashSet<int> data = new HashSet<int>();
    for (int i = 0; i < 1000000; i++)
    {
        data.Add(rand.Next(50000000));
    }
    bool contains = data.Contains(1234567); // etc

30

List.Constens - це операція O (n).

Dictionary.ContainsKey - це операція O (1), оскільки вона використовує хеш-код об’єктів як ключ, що дає вам можливість швидшого пошуку.

Я не думаю, що це гарна ідея мати список, який містить мільйон записів. Я не думаю, що клас List був розроблений саме з цією метою. :)

Чи не можливо зберегти ці сутності-мільйони в RDBMS, наприклад, і виконати запити в цій базі даних?

Якщо це неможливо, я б все-таки скористався Словником.


13
Я не думаю, що в списку з мільйоном елементів є щось недоречне, просто ви, мабуть, не хочете продовжувати проводити лінійний пошук по ньому.
Уілл Дін

Погоджено, нічого поганого немає ні в списку, ні в масиві з такою кількістю записів. Тільки не скануйте значень.
Michael Krauklis

8

Думаю, у мене є відповідь! Так, це правда, що Contains () у списку (масиві) має значення O (n), але якщо масив є коротким, і ви використовуєте типи значень, він все одно повинен бути досить швидким. Але, використовуючи CLR Profiler [завантажити безкоштовно від Microsoft], я виявив, що Contains () - це боксерські значення для їх порівняння, що вимагає розподілу купи, що ДУЖЕ дорого (повільно). [Примітка: Це .Net 2.0; інші версії .Net не перевірені.]

Ось повна історія та рішення. У нас є перелік, що називається "VI", і ми створили клас "ValueIdList", який є абстрактним типом для списку (масиву) об'єктів VI. Оригінальна реалізація була в старовинному .Net 1.1 днів і використовувала інкапсульований ArrayList. Нещодавно ми виявили в http://blogs.msdn.com/b/joshwil/archive/2004/04/13/112598.aspx, що загальний список (List <VI>) працює набагато краще, ніж ArrayList, для типів значень (наприклад, наш enum VI), оскільки значення не потрібно вставляти в рамки. Це правда, і це спрацювало ... майже.

CLR Profiler відкрив сюрприз. Ось частина Графіку розподілу:

  • ValueIdList :: Містить bool (VI) 5,5 МБ (34,81%)
  • Generic.List :: Містить bool (<UNKNOWN>) 5,5 МБ (34,81%)
  • Generic.ObjectEqualityComparer <T> :: Дорівнює bool (<UNKNOWN> <UNKNOWN>) 5,5 МБ (34,88%)
  • Цінності. VІ 7,7 МБ (49,03%)

Як бачите, Contains () напрочуд викликає Generic.ObjectEqualityComparer.Equals (), що, мабуть, вимагає боксу значення VI, що вимагає дорогого розподілу купи. Дивно, що Microsoft виключить бокс у списку, лише вимагаючи його знову для такої простої операції, як ця.

Нашим рішенням було переписати реалізацію Contains (), що в нашому випадку було легко зробити, оскільки ми вже інкапсулювали загальний об’єкт списку (_items). Ось простий код:

public bool Contains(VI id) 
{
  return IndexOf(id) >= 0;
}

public int IndexOf(VI id) 
{ 
  int i, count;

  count = _items.Count;
  for (i = 0; i < count; i++)
    if (_items[i] == id)
      return i;
  return -1;
}

public bool Remove(VI id) 
{
  int i;

  i = IndexOf(id);
  if (i < 0)
    return false;
  _items.RemoveAt(i);

  return true;
}

Зараз порівняння значень VI робиться у нашій власній версії IndexOf (), яка не вимагає боксу, і це дуже швидко. Наша програма пришвидшилась на 20% після цього простого перезапису. O (n) ... немає проблем! Просто уникайте марного використання пам’яті!


Дякую за підказку, мене самого впіймали погані результати боксу. Спеціальна Containsреалізація набагато швидша для мого випадку використання.
Lea Hayes,

5

Словник не так вже й поганий, оскільки ключі в словнику розроблені для швидкого пошуку. Щоб знайти номер у списку, його потрібно переглядати по всьому списку.

Звичайно, словник працює, лише якщо ваші номери унікальні та не упорядковані.

Я думаю, що HashSet<T>в .NET 3.5 також є клас, він також допускає лише унікальні елементи.


Словник <Type, integer> може ефективно зберігати і не унікальні об’єкти - використовуйте ціле число для підрахунку кількості дублікатів. Наприклад, ви б зберігали список {a, b, a} як {a = 2, b = 1}. Звичайно, це втрачає замовлення.
MSalters


2

Це не зовсім відповідь на ваше запитання, але у мене є клас, який підвищує продуктивність Contains () у колекції. Я підкласував Чергу та додав Словник, який відображає хеш-коди до списків об’єктів. Dictionary.Contains()Функція О (1) , тоді як List.Contains(), Queue.Contains()і Stack.Contains()є O (N).

Тип значення словника - це черга, що містить об’єкти з однаковим хеш-кодом. Абонент може надати об'єкт власного класу, який реалізує IEqualityComparer. Ви можете використовувати цей шаблон для стеків або списків. Код потребував би лише кількох змін.

/// <summary>
/// This is a class that mimics a queue, except the Contains() operation is O(1) rather     than O(n) thanks to an internal dictionary.
/// The dictionary remembers the hashcodes of the items that have been enqueued and dequeued.
/// Hashcode collisions are stored in a queue to maintain FIFO order.
/// </summary>
/// <typeparam name="T"></typeparam>
private class HashQueue<T> : Queue<T>
{
    private readonly IEqualityComparer<T> _comp;
    public readonly Dictionary<int, Queue<T>> _hashes; //_hashes.Count doesn't always equal base.Count (due to collisions)

    public HashQueue(IEqualityComparer<T> comp = null) : base()
    {
        this._comp = comp;
        this._hashes = new Dictionary<int, Queue<T>>();
    }

    public HashQueue(int capacity, IEqualityComparer<T> comp = null) : base(capacity)
    {
        this._comp = comp;
        this._hashes = new Dictionary<int, Queue<T>>(capacity);
    }

    public HashQueue(IEnumerable<T> collection, IEqualityComparer<T> comp = null) :     base(collection)
    {
        this._comp = comp;

        this._hashes = new Dictionary<int, Queue<T>>(base.Count);
        foreach (var item in collection)
        {
            this.EnqueueDictionary(item);
        }
    }

    public new void Enqueue(T item)
    {
        base.Enqueue(item); //add to queue
        this.EnqueueDictionary(item);
    }

    private void EnqueueDictionary(T item)
    {
        int hash = this._comp == null ? item.GetHashCode() :     this._comp.GetHashCode(item);
        Queue<T> temp;
        if (!this._hashes.TryGetValue(hash, out temp))
        {
            temp = new Queue<T>();
            this._hashes.Add(hash, temp);
        }
        temp.Enqueue(item);
    }

    public new T Dequeue()
    {
        T result = base.Dequeue(); //remove from queue

        int hash = this._comp == null ? result.GetHashCode() : this._comp.GetHashCode(result);
        Queue<T> temp;
        if (this._hashes.TryGetValue(hash, out temp))
        {
            temp.Dequeue();
            if (temp.Count == 0)
                this._hashes.Remove(hash);
        }

        return result;
    }

    public new bool Contains(T item)
    { //This is O(1), whereas Queue.Contains is (n)
        int hash = this._comp == null ? item.GetHashCode() : this._comp.GetHashCode(item);
        return this._hashes.ContainsKey(hash);
    }

    public new void Clear()
    {
        foreach (var item in this._hashes.Values)
            item.Clear(); //clear collision lists

        this._hashes.Clear(); //clear dictionary

        base.Clear(); //clear queue
    }
}

Моє просте тестування показує, що я HashQueue.Contains()працюю набагато швидше, ніж Queue.Contains(). Запуск тестового коду з рахунком, встановленим на 10000, займає 0,00045 секунди для версії HashQueue та 0,37 секунди для версії Queue. При підрахунку 100 000 версія HashQueue займає 0,0031 секунди, тоді як Черга займає 36,38 секунди!

Ось мій код тестування:

static void Main(string[] args)
{
    int count = 10000;

    { //HashQueue
        var q = new HashQueue<int>(count);

        for (int i = 0; i < count; i++) //load queue (not timed)
            q.Enqueue(i);

        System.Diagnostics.Stopwatch sw = System.Diagnostics.Stopwatch.StartNew();
        for (int i = 0; i < count; i++)
        {
            bool contains = q.Contains(i);
        }
        sw.Stop();
        Console.WriteLine(string.Format("HashQueue, {0}", sw.Elapsed));
    }

    { //Queue
        var q = new Queue<int>(count);

        for (int i = 0; i < count; i++) //load queue (not timed)
            q.Enqueue(i);

        System.Diagnostics.Stopwatch sw = System.Diagnostics.Stopwatch.StartNew();
        for (int i = 0; i < count; i++)
        {
            bool contains = q.Contains(i);
        }
        sw.Stop();
        Console.WriteLine(string.Format("Queue,     {0}", sw.Elapsed));
    }

    Console.ReadLine();
}

Я щойно додав 3-й тест для HashSet <T>, який, здається, приносить навіть кращі результати, ніж ваше рішення: HashQueue, 00:00:00.0004029 Queue, 00:00:00.3901439 HashSet, 00:00:00.0001716
psulek

1

Чому словник недоречний?

Щоб побачити, чи є певне значення у списку, вам потрібно пройти весь список. За допомогою словника (або іншого контейнера на основі хешу) значно швидше звузити кількість об’єктів, з якими потрібно порівнювати. Ключ (у вашому випадку число) хешований, і це дає словнику дробову підмножину об’єктів для порівняння.


0

Я використовую це в Compact Framework, де немає підтримки HashSet, я вибрав словник, де обидва рядки є значенням, яке я шукаю.

Це означає, що я отримую функціональність списку <> з продуктивністю словника. Це трохи хакі, але це працює.


1
Якщо ви використовуєте словник замість HashSet, ви можете встановити значення "", а не той самий рядок, що і ключ. Таким чином ви будете використовувати менше пам'яті. Як варіант, ви навіть можете використовувати Dictionary <string, bool> і встановити для них усі значення true (або false). Я не знаю, який би зайняв менше пам'яті, порожній рядок або bool. Моє припущення було б bool.
TTT

У словнику stringпосилання та boolзначення мають різницю в 3 або 7 байтів для 32 або 64 бітних систем відповідно. Однак зверніть увагу, що розмір кожного запису округлюється до кратних 4 або 8 відповідно. Вибір між stringі, boolтаким чином, може взагалі не змінити розмір. Порожній рядок ""завжди існує в пам'яті вже як статична властивість string.Empty, тому не має значення, використовувати ви його у словнику чи ні. (І його в будь-якому випадку використовують деінде.)
Вормбо,
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.