Насправді я вважаю контейнери стандартного набору для себе здебільшого марними і вважаю за краще використовувати масиви, але це роблю по-іншому.
Для обчислення встановлених перетинів я перебираю через перший масив і розмічую елементи одним бітом. Потім я перебираю через другий масив і шукаю позначені елементи. Voila, встановіть перетин в лінійному часі з набагато меншою роботою та пам'яттю, ніж хеш-таблиця, наприклад, Unions та відмінності однаково просто застосувати за допомогою цього методу. Це допомагає моєму кодовій базі обертатися навколо елементів індексації, а не дублювати їх (я дублюю індекси до елементів, а не даних самих елементів) і рідко потрібно щось сортувати, але я не використовував набір структур даних протягом років результат.
У мене також є якийсь злий кодовий код C, який я використовую, навіть коли елементи не пропонують поля даних для таких цілей. Він передбачає використання пам'яті самих елементів, встановивши найзначніший біт (який я ніколи не використовую) для позначення пройдених елементів. Це досить грубо, не робіть цього, якщо ви дійсно не працюєте на рівні майже збірки, а просто хотіли б зазначити, як це може бути застосовано навіть у випадках, коли елементи не забезпечують певного поля, специфічного для обходу, якщо ви можете гарантувати, що певні біти ніколи не будуть використані. Він може обчислити набір перетину між 200 мільйонами елементів (що стосується 2,4 гіга даних) менше ніж за секунду на моєму динкому i7. Спробуйте зробити перетин між двома std::set
екземплярами, що містять по сто мільйонів елементів за один і той же час; навіть не наближається.
Це вбік ...
Однак я також міг би це зробити, додавши кожен елемент до іншого вектора та перевіривши, чи елемент вже існує.
Ця перевірка, щоб переконатися, що елемент вже існує в новому векторі, як правило, буде лінійною операцією в часі, що зробить заданий перетин самим квадратичною операцією (вибухонебезпечний обсяг роботи тим більший розмір вводу). Я рекомендую описану вище техніку, якщо ви просто хочете використовувати звичайні старі вектори або масиви і робити це таким чином, що чудово масштабує.
В основному: для яких типів алгоритмів потрібен набір, і це не слід робити з будь-яким іншим типом контейнера?
Ні, якщо ви запитаєте мою необ’єктивну думку, якщо ви говорите про це на рівні контейнера (як у структурі даних, спеціально реалізованій для ефективного забезпечення заданих операцій), але є багато, що вимагає заданої логіки на концептуальному рівні. Наприклад, скажімо, ви хочете знайти істот у ігровому світі, які здатні як літати, так і плавати, і у вас є літаючі істоти в одному наборі (незалежно від того, чи ви фактично використовуєте набір контейнерів) та ті, які можуть плавати в іншому . У цьому випадку потрібно встановити перетин. Якщо ви хочете істот, які можуть або літати, або бути магічними, тоді ви використовуєте набір союзів. Звичайно, вам реально не потрібен контейнер з набором для його здійснення, і найбільш оптимальна реалізація, як правило, не потребує контейнера, спеціально розробленого для набору.
Відходить від дотичної
Гаразд, у мене з’явилися приємні запитання від JimmyJames щодо цього підходу до перехрестя. Це якось відхиляється від теми, але добре, мені цікаво бачити, як більше людей використовують цей основний нав'язливий підхід для встановлення перехрестя, щоб вони не будували цілих допоміжних структур, як врівноважених бінарних дерев та хеш-таблиць лише з метою заданих операцій. Як зазначалося, основна вимога полягає в тому, щоб списки були неглибокими копіюючими елементами, щоб вони індексували або вказували на спільний елемент, який може бути "позначений" як пройдений переходом через перший несортований список або масив, або все, що потім вибирати на другий пройти через другий список.
Однак це може бути здійснено практично навіть у багатопотоковому контексті, не торкаючись елементів, за умови, що:
- Два агрегати містять індекси до елементів.
- Діапазон індексів не надто великий (скажімо [0, 2 ^ 26), не мільярди і більше) і досить густо зайнятий.
Це дозволяє використовувати паралельний масив (лише один біт на елемент) для встановлення операцій. Діаграма:
Синхронізація потоків повинна бути тільки тоді, коли ви отримуєте паралельний бітовий масив із пулу та випускаєте його назад у пул (робиться неявно, коли виходить із сфери застосування). Дійсні дві петлі для виконання заданої операції не повинні включати жодних синхронізацій потоків. Нам навіть не потрібно використовувати паралельний бітовий пул, якщо потік може просто розподіляти та звільняти біти локально, але бітовий пул може бути зручним для узагальнення шаблону в кодових базах, які підходять до такого роду подання даних, де центральні елементи часто посилаються за індексом, щоб кожен потік не мав заважати ефективному керуванню пам’яттю. Основними прикладами для моєї області є сутнісно-компонентні системи та індексовані представлення сітки. Обидва часто потребують встановлених перетинів і, як правило, посилаються на все, що зберігається централізовано (компоненти та об'єкти в ECS та вершинах, ребрах,
Якщо індекси не є густо зайнятими і малорозсіяними, то це все ще застосовно при розумній рідкої реалізації паралельного бітового / булевого масиву, такого як той, який зберігає пам'ять лише в 512-бітових фрагментах (64 байти на нерозгорнутий вузол, що представляє 512 суміжних індексів ) і пропускає виділення абсолютно порожніх суміжних блоків. Цілком ймовірно, ви вже використовуєте щось подібне, якщо ваші центральні структури даних вкрай зайняті самими елементами.
... подібна ідея для розрідженого набору бітів, який служить паралельним бітовим масивом. Ці структури також піддаються незмінності, оскільки легко копіювати кучеряві блоки, які не потрібно копіювати, щоб створити нову незмінну копію.
Знову ж встановити перетини між сотнями мільйонів елементів можна за секунду за допомогою цього підходу на дуже середній машині, і це в межах однієї нитки.
Це також можна зробити за менше половини часу, якщо клієнту не потрібен перелік елементів для результуючого перетину, наприклад, якщо вони хочуть лише застосувати певну логіку до елементів, знайдених в обох списках, і тоді вони можуть просто пройти функціональний вказівник або функтор або делегат або все, що потрібно викликати назад, для обробки діапазонів елементів, які перетинаються. Щось до цього ефекту:
// 'func' receives a range of indices to
// process.
set_intersection(func):
{
parallel_bits = bit_pool.acquire()
// Mark the indices found in the first list.
for each index in list1:
parallel_bits[index] = 1
// Look for the first element in the second list
// that intersects.
first = -1
for each index in list2:
{
if parallel_bits[index] == 1:
{
first = index
break
}
}
// Look for elements that don't intersect in the second
// list to call func for each range of elements that do
// intersect.
for each index in list2 starting from first:
{
if parallel_bits[index] != 1:
{
func(first, index)
first = index
}
}
If first != list2.num-1:
func(first, list2.num)
}
... або щось для цього. Найдорожча частина псевдокоду в першій діаграмі знаходиться intersection.append(index)
у другому циклі, і це стосується навіть std::vector
заздалегідь зарезервованого розміру меншого списку.
Що робити, якщо все глибоко копіювати?
Ну, припиніть це! Якщо вам потрібно встановити перехрестя, це означає, що ви дублюєте дані для перетину. Цілком ймовірно, що навіть найдрібніші об'єкти не менше 32-бітного індексу. Можна дуже скоротити діапазон адресації ваших елементів до 2 ^ 32 (2 ^ 32 елемента, а не 2 ^ 32 байта), якщо вам насправді не потрібно більше ~ 4,3 мільярда елементів, ініціативи, і тоді потрібне зовсім інше рішення ( і це точно не використовує встановлені контейнери в пам'яті).
Ключові відповідники
Як щодо випадків, коли нам потрібно робити операції з встановленням, коли елементи не однакові, але можуть мати відповідні ключі? У такому випадку та сама ідея, що і вище. Нам просто потрібно зіставити кожен унікальний ключ до індексу. Якщо, наприклад, ключем є рядок, то інтерновані рядки можуть робити саме це. У цих випадках потрібна така приємна структура даних, як трие або хеш-таблиця, для відображення рядкових клавіш на 32-бітні індекси, але такі структури нам не потрібні для того, щоб робити встановлені операції на отриманих 32-бітних індексах.
Ціла низка дуже дешевих і простих алгоритмічних рішень та структур даних відкривається приблизно так, коли ми можемо працювати з індексами до елементів у дуже розумному діапазоні, а не повний діапазон адресації машини, і тому часто це більше, ніж варто того бути можливість отримати унікальний індекс для кожного унікального ключа.
Я люблю індекси!
Я люблю показники так само, як піцу та пиво. Коли мені було в 20-ті роки, я по-справжньому перейшов на C ++ і почав розробляти всі види цілком стандартних структур даних (включаючи хитрощі, пов'язані з розмежуванням заповнення ctor з ctor діапазону під час компіляції). Заднім часом це було великою марною тратою часу.
Якщо ви обертаєте свою базу даних навколо централізованого зберігання елементів у масивах та їх індексації, а не зберігання їх у фрагментованому вигляді та, можливо, у всьому адресному діапазоні машини, тоді ви можете вивчити світ можливостей алгоритміки та структури даних просто проектування контейнерів та алгоритмів, що обертаються навколо звичайного старого int
або int32_t
. І я виявив, що кінцевий результат є набагато ефективнішим і простішим у підтримці, де я не передавав постійно елементи з однієї структури даних в іншу до іншої.
Деякі приклади використовують випадки, коли ви можете просто припустити, що будь-яке унікальне значення T
має унікальний індекс і матиме екземпляри, що знаходяться в центральному масиві:
Багатопотокові радіоскопічні сорти, які добре працюють із непідписаними цілими числами для індексів . Насправді у мене є багатопотокова радіоізоляція, яка займає приблизно одну десяту частину часу, щоб сортувати сто мільйонів елементів як власне паралельне сортування Intel, а Intel вже в 4 рази швидше, ніж std::sort
для таких великих входів. Звичайно, Intel набагато гнучкіше, оскільки це сортування на основі порівняння і може сортувати речі лексикографічно, тому воно порівнює яблука з апельсинами. Але тут мені часто потрібні лише апельсини, як, наприклад, я можу зробити прохідний сортуючий радикс лише для досягнення кешованих шаблонів доступу до пам'яті або швидко фільтрувати дублікати.
Можливість побудови пов'язаних структур, таких як пов’язані списки, дерева, графіки, окремі ланцюгові хеш-таблиці тощо без розподілу купи на вузол . Ми можемо просто виділити вузли масово, паралельно елементам, і зв’язати їх разом з індексами. Самі вузли просто стають 32-розрядним індексом до наступного вузла і зберігаються у великому масиві, як-от так:
Дружній для паралельної обробки. Часто пов'язані структури не так зручні для паралельної обробки, оскільки принаймні незручно намагатися домогтися паралелізму в обході дерева або пов'язаного списку на відміну від, скажімо, просто проведення паралелі для циклу через масив. За допомогою представлення індексу / центрального масиву ми завжди можемо перейти до цього центрального масиву і обробити все в чіткі паралельні петлі. У нас завжди є той центральний масив усіх елементів, який ми можемо обробити таким чином, навіть якщо ми хочемо обробити лише деякі (в цей момент ви можете обробити елементи, індексовані списком відсортованого за радіацією списку для кеш-доступу через центральний масив).
Може пов'язувати дані з кожним елементом на льоту в постійний час . Як і у випадку з паралельним масивом бітів вище, ми можемо легко і надзвичайно дешево пов’язати паралельні дані з елементами для, скажімо, тимчасової обробки. Це має випадки використання, окрім тимчасових даних. Наприклад, сітчаста система може захотіти дозволити користувачам приєднувати до мережі стільки УФ-карт, скільки вони хочуть. У такому випадку ми не можемо просто зафіксувати, скільки ультрафіолетових карт буде у кожній вершині та обличчі, використовуючи підхід AoS. Нам потрібно вміти пов'язувати такі дані під час руху, і паралельні масиви зручні там і так набагато дешевше будь-яких складних асоціативних контейнерів, навіть хеш-таблиць.
Звичайно, паралельні масиви піддаються насуванню через їх схильний до помилок характер синхронізації паралельних масивів один з одним. Кожного разу, коли ми видаляємо елемент з індексу 7 з масиву "root", нам також потрібно робити те ж саме для "дітей". Однак у більшості мов досить просто узагальнити цю концепцію до контейнера загального призначення, щоб хитра логіка тримати паралельні масиви синхронізованими один з одним лише в одному місці на всій базі коду, і такий контейнер паралельного масиву може використовуйте розріджену реалізацію масиву вище, щоб уникнути втрати великої кількості пам’яті для суміжних вільних просторів у масиві, які потрібно повернути після наступних вставок.
Детальніше розробка: розріджене дерево біт
Гаразд, я отримав прохання розробити ще декілька, які я вважаю саркастичними, але я все одно зроблю це, бо це так весело! Якщо люди хочуть винести цю ідею на цілком нові рівні, то можна виконати задані перехрестя, навіть не лінійно перебираючи елементи N + M. Це моя кінцева структура даних, яку я використовую протягом віків і в основному моделей set<int>
:
Причина, по якій він може виконувати набір перетинів, навіть не перевіряючи кожен елемент в обох списках, полягає в тому, що один набір бітів у корені ієрархії може вказувати на те, що, скажімо, мільйон суміжних елементів зайнятий у наборі. Просто перевіривши один біт, ми можемо знати, що N індексів у діапазоні [first,first+N)
знаходяться у безлічі, де N може бути дуже великою кількістю.
Я фактично використовую це як оптимізатор циклу при обході зайнятих індексів, тому що, скажімо, у наборі є 8 мільйонів індексів. Ну, зазвичай нам доведеться отримати доступ до 8 мільйонів цілих чисел в пам'яті. За допомогою цього, він може просто перевірити кілька біт і придумати діапазони індексів зайнятих індексів, щоб пройти цикл. Крім того, діапазони індексів, які він придумує, відсортовані в порядку, що забезпечує дуже зручний кеш послідовний доступ на відміну від, скажімо, ітерації через несортований масив індексів, використовуваних для доступу до вихідних даних елементів. Звичайно, ця техніка гіршає для надзвичайно рідких випадків, причому найгірший сценарій є таким, як кожен індекс є парним числом (або кожен непарний), і в цьому випадку взагалі немає сусідніх регіонів. Але в моїх випадках, як мінімум,