Как сделать неэффективное заполнение хэш-таблицы за O(n^2)?
Добавление в хэш-таблицу в среднем занимает O(1), но в худшем случае O(n).
Можно ли сформировать такой массив, что добавление всех его элементов в хэш-таблицу будет работать за O(n2)?
Ответы (3 шт):
Как уже сказали в комментариях, нужно просто сделать так, чтобы у объектов было разное значение, но одинаковый хэш-код, тогда хэш-таблица должна будет как-то решать коллизии, а для этого обычно нужно проходить по всем уже имеющимся в таблице элементам в поисках элемента совпадающего по значению (или чтобы убедиться в отсутствии такого элемента).
Проверим эту мысль кодом:
- Берём класс с нормальным хэшем
- Берём класс с вырожденным хэшем
- Создаём по 1000 экземпляров каждого класса
- Делаем из этих экземпляров множество (а это как-раз хэш-таблица)
- Меряем время создания множества (для стабильности - усредняем за 100 прогонов)
- Сравниваем
import timeit
class Test:
def __init__(self, x):
self.x = x
def __hash__(self):
return hash(self.x)
def __eq__(self, other):
if not isinstance(other, Test):
return NotImplemented # Or raise TypeError, depending on desired behavior
return self.x == other.x
class TestDegenerate(Test):
def __hash__(self):
return 1
t1 = timeit.timeit('set(map(Test, range(1_000)))', number=100, globals=globals())
t2 = timeit.timeit('set(map(TestDegenerate, range(1_000)))', number=100, globals=globals())
print(t1, t2, t2/t1, sep='\n')
Вывод:
0.025643200031481683
6.845358700025827
266.9463519226113
Деградация произошла не совсем в n
раз (n = 1000
в данном случае), но по порядку близко к тому. И это видимо правильно, потому что n2 - это "оценка сверху". Ведь в хэш-таблице не сразу уже n
элементов, они туда постепенно добавляются.
P.S. Добавил во второй класс вызов подсчёта хэша без его использования _ = hash(self.x)
, чтобы скорость подсчёта хэша обоих классов была схожая - результат чуть поменялся, но не сильно.
P.S. Fun fact: в Питоне hash
натурального числа совпадает с самим числом вплоть до числа 2_305_843_009_213_693_950
(0x1ffffffffffffffe), после которого хэш опять начинается с 0.
Пример деградации хэш-таблицы в Питоне. Само по себе падение производительности не означает, что сложность стала квадратичной, но намекает.
$ time -p python -c 'set(i * 2 ** 61 for i in range(40000))' real 0.01 user 0.01 sys 0.00 $ time -p python -c 'set(i * (2 ** 61 - 1) for i in range(40000))' real 5.07 user 5.06 sys 0.00
Подробности
Хэш небольшого целого числа в Питоне совпадает с ним самим. Более того, можно установить что для неотрицательных чисел хэши вычисляются как величина числа по некоторому модулю. Функция hash_loop
отыскивает этот модуль. На моей машине он равен 261 - 1.
Если построить любую прогрессию неотрицательных целых чисел с шагом 261 - 1, все числа в ней получат одинаковое значение хэша.
Функция main
строит прогрессии увеличивающейся длины и вызывает построение множества от них. Печатается длина прогрессии и время, которое было потрачено на построение множества.
import time
def bsearch(low, high, pred):
if low >= high or pred(low):
return low
while low < high - 1:
mid = (low + high) // 2
if pred(mid):
high = mid
else:
low = mid
return high
def hash_loop():
n = 1
while hash(n) == n:
n *= 2
return bsearch(n // 2, n, lambda n: hash(n) != n)
def elapsed(f):
start = time.perf_counter()
r = f()
finish = time.perf_counter()
return finish - start, r
def main():
n = hash_loop()
p = 1
while True:
for k in range(10 ** p, 10 ** (p + 1), 10 ** p):
r = range(0, k * n, n)
t, _ = elapsed(lambda: set(r))
print(len(r), f'{t:.2f}')
p += 1
main()
Запустите и дождитесь пока времена работы будут сравнимы с секундами. У меня получается такое:
$ python benchmark.py ... 10000 0.30 20000 1.42 30000 3.10 40000 5.69 50000 9.11 60000 13.73 70000 19.04 80000 27.44 90000 35.25 100000 42.29 ....
Отношение времён для разных длин прогрессий:
отношение длин | отношение времён |
---|---|
100000 / 50000 = 2 | 42.29 / 9.11 = 4.64 > 4 |
80000 / 40000 = 2 | 27.44 / 5.69 = 4.82 > 4 |
90000 / 30000 = 3 | 35.25 / 3.10 = 11.37 > 9 |
Для всех отношений длин, отношения времён превышают квадрат.
Что и требовалось доказать.
График времён и парабола:
В современной Java сделать n2 не получится. Но можно сделать n log n.
Если в одной корзине таблицы накапливается более восьми элементов, корзина из списка превращается в сортированное дерево (TREEIFY_THRESHOLD).
Для сортировки используется порядок на ключах, если он доступен (comparableClassFor).
А если порядка нет, ключи сортируются по адресам (tieBreakOrder).
Операции с сортированным деревом занимают log n. Если поместить все элементы в одну корзину, получим дополнительный логарифмический множитель в сложность.
По исходному коду Long.hashCode
можно предсказать, что все числа типа long
кратные 232 + 1 буду иметь нулевой хэш.
Benchmark.java
читает шаг из командной строки, печатает хэши для первых десяти шагов (нужно для проверки) и начинает измерять времена построения HashSet
:
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.HashSet;
class Benchmark {
public static void main (String[] args) {
long step = Long.valueOf(args[0]);
for (int i = 0; i < 10; ++i) {
System.out.print(" " + Long.valueOf(i * step).hashCode());
}
System.out.println();
for (int t = 1; ; t *= 10) {
for (int s = t; s < 10 * t; s += t) {
System.out.format("%d %.3f\n", s, elapsed(step, s));
}
}
}
private static double elapsed(long step, int n) {
Long[] a = new Long[n];
for (int i = 0; i < n; ++i) {
a[i] = i * step;
}
List<Long> aa = Arrays.asList(a);
System.gc();
long start = System.nanoTime();
Set<Long> s = new HashSet<>(aa);
long finish = System.nanoTime();
return (finish - start) / 1e9;
}
}
Я запускал её два раза, для шагов 4294967297 (все хэши нулевые) и 4294967296 (хэши – последовательные числа). Результат в таблице:
n | время для шага 232 + 1 | время для шага 232 |
---|---|---|
1000000 | 0.164 | 0.008 |
2000000 | 0.363 | 0.017 |
3000000 | 0.541 | 0.051 |
4000000 | 0.762 | 0.066 |
5000000 | 0.918 | 0.068 |
6000000 | 1.175 | 0.074 |
7000000 | 1.375 | 0.100 |
8000000 | 1.520 | 0.093 |
9000000 | 1.908 | 0.108 |
10000000 | 2.003 | 0.116 |
20000000 | 4.206 | 0.300 |
30000000 | 6.255 | 0.434 |
40000000 | 8.452 | 0.543 |
50000000 | 10.632 | 0.640 |
60000000 | 13.031 | 0.792 |
70000000 | 15.014 | 0.884 |
80000000 | 17.437 | 1.016 |
90000000 | 19.635 | 1.143 |
100000000 | 21.854 | 1.325 |
На картинке синие точки (нулевой хэш) наложены на график n log n, а зелёные на линейный график. К сожалению, на таких масштабах прогиб, вызванный логарифмическим множителем почти не заметен.