Коллизии HashCode
Представьте, вы пишите мессенжер или почтовый клинт. Вам придется работать с кучей повторяющихся строковых значений. Например в чате — какой-то влюбленный Вася может спамить десятки аналогичных признаний в любви своей даме сердца Насте. Но мы то знаем, что все Насти шлю** Наверняка мы захотим как-то кэшировать эти сообщения. Для этого подойдет HashMap или HashSet, например. Все знают, что эти структуры данных, ссылаются на хэш значение объекта, которое не бесконечно, а ограничено 32 битами. Но проблемы могут начаться уже с пары строк.
Пример такой коллизии:
fun main() //generates 310265957 as hashcode print("I have a common prefixDB".hashCode()) //generates 310265957 as hashcode print("I have a common prefixCa".hashCode()) >
Вообще если длина строк одинаковая и выполняется:
31*(b[n-2] — a[n-2]) == (a[n-1] — b[n-1])
то, хэши всегда будут одинаковыми, не буду вдаваться в подробности почему так 😀
Окей, хэши могут быть одинаковыми на ровном месте, но по какому принципу они рассчитываются?
Стандартная реализация hashCode
Посмотри сгенерированный idea hashCode() для простого POJO класса.
class Lover( private val id: Long, private val name: String, private val mum: Mum? ) // Также генерится реализация equals, зачем она нужна узнаем ниже override fun equals(other: Any?): Boolean if (this === other) return true if (javaClass != other?.javaClass) return false other as Lover if (id != other.id) return false if (name != other.name) return false if (mum != other.mum) return false return true > override fun hashCode(): Int var result = id.hashCode() result = 31 * result + name.hashCode() result = 31 * result + (mum?.hashCode() ?: 0) // Если объект null, он не влияет return result > >
У String, кстати свой алгоритм: h(s)=∑(s[index] * 31 ^(n-1-index)) Смысл тот же, только проходим по всем char
31
Как вы заметили везде используется это непонятное число 31. Вопрос: А че не 41 или 97, например?
А по каким критериям оно отбирается?
- Число должно быть простым и нечетным, чтобы уменьшить вероятность коллизий.
- Число должно занимать мало места в памяти
- Умножение должно выполняться быстро
Оказывается 31 идеальный кандидат ведь:
- Оно простое и нечетное
- Занимает всего 1 байт в памяти
- Уго умножение можно заменить на быстрый побитовой сдвиг. 31 * i == (i
Побитовой сдвиг влево на x позиций. Работает точно также как умножить какое-то число на 2 x раз.
println(n.shl(1)) // 6 println(n.shl(2)) // 12 println(n.shl(3)) // 24
Вернемся к сути.
Окей, выяснили, по какому принципу считается HashCode и что в любой момент может произойти коллизия и данные перетрутся. Так че делать?
Да ничего, HashMap и HashSet самоcтоятельно обрабытывают такие ситуации. Важно только правильно переопределить метод equals(o) у класса.
Логика работы такая:
- Кладем в список объект по ключу “cat”
- Кладем в него другой по этому же ключу
- HashMap/Set проверяет равны ли эти объекты в методе equals
- Если равны — заменяет, если нет, создает в этой ячейке связный список, а например в седьмой джаве бакет в HashMap, при появлении в нём более семи объектов, меняет начинку со связного списка на дерево.
Важно помнить, что получение элемента из связного списка работает за время O(n), и если колиизий наберется много, скорость hash таблицы станет уже далеко не константной.
Поэтому если все же решили использовать String в качестве ключа, можно за основу брать 2 простых числа, скажем 17 и 37. Ребята из Project Lombok придумали здесь
fun main(args: ArrayString>) val first = BetterHashString("I have a common prefixDB") val second = BetterHashString("I have a common prefixCa") println(first.hashCode()) // 177503352 println(second.hashCode()) // 177503346 > class BetterHashString(val value: String) override fun hashCode(): Int val prime = 37 var result = 17 value.chars().forEach c -> result = prime * result + c > return result > >
Минуточку
Переопределил я hashCode() , но как-же теперь JVM узнает, на какой адрес в памяти ссылается этот объект?
На самом деле у каждого объекта есть идентификационный хеш (identity hash code). Если класс переопределил hashCode() , всегда можно вызвать System.identityHashCode(o) .
Да и вообще, hashCode() не возвращает какой-либо адрес объекта в памяти, что бы это не значило. В версиях java 6 и 7 это случайно генерируемое число. В версиях 8 и 9 это число, полученное на основании состояния потока. Подробнее об этом можно почитать здесь
Почему возникают коллизии?
Как известно, ситуация, когда у разных объектов одинаковые хеш-коды называется — коллизией. Вероятность возникновения коллизии зависит от используемого алгоритма генерации хеш-кода. Но вот вопрос, почему она возникает? Неужели тяжко придумать «защиту» от возникновения коллизии? Кто что думает?
Отслеживать
задан 30 окт 2017 в 22:06
432 2 2 золотых знака 6 6 серебряных знаков 14 14 бронзовых знаков
Результат хеш-функции может быть короче ее аргумента. Коллизий избежать невозможно.
– user239133
30 окт 2017 в 22:11
Вообще, есть perfect hash function (идеальная хэш-функция)
31 окт 2017 в 0:33
Вероятность возникновения коллизии зависит от используемого алгоритма генерации хеш-кода. Нет. Если алгоритмы хэширования не содержат статистических, математических и прочих погрешностей, вероятность коллизии зависит только от длины формируемого хэша.
31 окт 2017 в 5:01
Неужели тяжко придумать «защиту» от возникновения коллизии? Да элементарно. Просто длина хэша должна быть не меньше максимальной длины хэшируемых данных (с учётом пэддинга).
31 окт 2017 в 5:02
1 ответ 1
Сортировка: Сброс на вариант по умолчанию
Хеш код в java создается методом
public int hashCode()
У integer диапазон от -2147483648 до 2147483647, т.е. округлив получаем 4 миллиарда разных целых чисел.
А теперь представим ситуацию, у вас 8-10 миллиардов объектов. Вопрос: как каждому из них дать уникальный хеш код используя диапазон в 4 миллиарда?
При этом вы не знаете сколько объектов вашего класса могут создать пользователи.
Если ваш класс будет использоваться в Hash структурах как ключ, вы так же должны постараться обеспечить объекты такими хеш кодами, чтобы они попадали в разные корзины и получить равномерное заполнение структуры.
Атака на String.hashCode: прообразы и коллизии
Как-то раз мне понадобилось несколько наборов строк с коллизией по хеш-коду. То есть таких, чтобы значение String::hashCode() совпадало для всех строк в наборе.
Блуждание по интернету не дало результатов, примеров было мало и все они довольно однообразны. Поиск по словарям подарил забавную пару «javascript’s».hashCode() == «monocle».hashCode() , но практической пользы не принёс. Полный перебор не рассматривался в виду скорой тепловой смерти вселенной.
Тот самый случай, когда проще сделать всё самому. Стандартная хеш-функция строки в Java считается криптографически нестойкой, так что знаний из школьного курса математики должно быть достаточно.
Коллизии
Для начала взглянем на String::hashCode() из Java 8:
String::hashCode()
/** * Returns a hash code for this string. The hash code for a * object is computed as *
* s[0]*31^(n-1) + s[1]*31^(n-2) + . + s[n-1] ** using arithmetic, where is the * ith character of the string, is the length of * the string, and indicates exponentiation. * (The hash value of the empty string is zero.) * * @return a hash code value for this object. */ public int hashCode() < int h = hash; if (h == 0 && value.length >0) < char val[] = value; for (int i = 0; i < value.length; i++) < h = 31 * h + val[i]; >hash = h; > return h; >
Хеш-код непустой строки вычисляется по формуле:
Взятие остатка от деления случается при переполнении разрядной сетки типа int , остальная часть формулы очевидна из кода и описания.
Так как каждое слагаемое зависит только от кода символа и его удалённости от конца строки, то мы можем заменить произвольную подстроку на другую с таким же хеш-кодом и длиной, хеш-код модифицированной строки при этом будет совпадать с хеш-кодом оригинальной.
При замене начала строки требование на совпадение длин исчезает, так как больше нет символов, на которые такое несовпадение может повлиять.
- Берём произвольные два подряд идущих символа в строке, Ci и Ci + 1
- Код символа Ci увеличиваем на целое положительное число N
- Компенсируем увеличение первого символа уменьшением кода символа Ci + 1 на 31 * N.
- Повторяем это действие с произвольными парами символов в строке до тех пор, пока нам не надоест.
Главное ограничение: коды изменённых символов должны оставаться в диапазоне значений типа char , от 0 ( \u0000 ) до 65_535 ( \uFFFF ) включительно.
Именно этот приём чаще всего используется в примерах из интернета.
"0-42L" "0-43-"
"AaAa" "BBBB" "AaBB" "BBAa"
Если же пара строк с идентичными хеш-кодами нужна нам здесь и сейчас, а калькулятора под рукой нет, то добавив в начало любой строки префикс «Хабрахабр — торт! ЧЫМДШРС » (без кавычек) мы с лёгкостью получим такую пару в своё распоряжение.
Прообразы
Это было совершенно не трудно. Пойдём дальше и попробуем найти первый прообраз, то есть сгенерировать строку, имеющую нужный нам хеш-код.
Алгоритм расчёта хеш-кода строки очень похож на перевод 31-ричного числа из строкового представления в числовое, воспользуемся этим. Нашими «цифрами» будут 31 первых символа Unicode, от \u0000 до \u001E , а алгоритм — совершенно стандартным.
В рамках данной статьи будем рассматривать хеш-код как 32-битное беззнаковое целое, представление в дополнительном коде позволяет нам такую вольность.
private static String findFirstPreimage(int hash) < StringBuilder builder = new StringBuilder(); for(long current = Integer.toUnsignedLong(hash); current != 0; current /= 31L) < builder.insert(0, (char) (current % 31L)); >String result = builder.toString(); assert hash == result.hashCode(); return result; >
Как нетрудно убедиться, этот подход действительно работает и выдаёт строки с нужными нам параметрами. Но есть у него и серьёзный недостаток: все символы в сгенерированных строках — непечатные. IntelliJ IDEA, к примеру, показывает такие строки и в отладчике, и во встроенной консоли неотличимыми от состоящих из одних пробелов.
Отлаживать программу с такими значениями неудобно, а кое-где такие строки и вовсе могут посчитать некорректными входными данными.
Впрочем, ничто не мешает нам использовать любые другие идущие подряд символы. Диапазон Б … Я отлично подойдёт на эту роль. Хеш-коды генерируемых строк при такой подмене по-прежнему будут монотонно возрастать с шагом в единицу, но появится и несколько особенностей.
Хеш-код сгенерированной строки будет иметь смещение, напрямую зависящее от её длины и первого символа используемого диапазона, он выступает в роли нуля. К примеру, для строки длиной в три символа и буквы Б в качестве первого символа алфавита оно будет равно Integer.toUnsignedLong(«БББ».hashCode()) .
Это смещение необходимо учитывать и делать на него поправку. Причём, для каждой пары ( , ) эта поправка будет своей.
Все наши вычисления проходят в кольце вычетов по модулю 2 32 , а потому узнать величину поправки элементарно. Достаточно взять строку из N «нулей» и вычесть хеш-код этой строки из значения 2 32 . Результатом будет число, на которое нужно увеличить значение хеш-кода для генерации правильной последовательности символов.
Так мы приходим к улучшенной версии нашего алгоритма. Для удобства использования реализуем его в виде итератора.
class PreimageGenerator
import java.util.Arrays; import java.util.Iterator; import java.util.NoSuchElementException; /** * * String hash preimage generator. * * @author Maccimo * */ public class PreimageGenerator implements Iterator < private static final long MODULO = (1L . * * @param hash string hash code * @param alphabetStart first of 31 consequent symbols, used to generate strings * * @throws IllegalArgumentException thrown if value is too large */ public PreimageGenerator(int hash, char alphabetStart) < this(Integer.toUnsignedLong(hash), alphabetStart); >/** * Construct string hash preimage generator. * This constructor accept unsigned long hash code, can be calculated from ordinary hash code by invoking * . * * @param hash string hash code * @param alphabetStart first of 31 consequent symbols, used to generate strings * * @throws IllegalArgumentException thrown if value is too large or value is negative */ public PreimageGenerator(long hash, char alphabetStart) < if (alphabetStart >MAX_ALLOWED_CHAR) < throw new IllegalArgumentException( String.format( "Wrong alphabetStart value U+%04X. We need at least 31 symbol to work properly, only %d available.", (int) alphabetStart, Character.MAX_VALUE - alphabetStart + 1 ) ); >if (hash < 0) < throw new IllegalArgumentException( String.format( "Wrong hash value %,d. Must be non-negative.", hash ) ); >this.nextHash = hash; this.alphabetStart = alphabetStart; this.hashCorrectionTable = precomputeHashCorrection(alphabetStart); > /** * Returns if preimage generator has more elements. * (In other words, returns if would * return an element rather than throwing an exception.) * * @return if preimage generator has more elements */ @Override public boolean hasNext() < return nextHash >= 0; > /** * Returns the next colliding string for given hash code. * * @return the next colliding string * @throws NoSuchElementException if the iteration has no more elements */ @Override public String next() < if (nextHash < 0) < throw new NoSuchElementException(); >int length; int correctedLength = 0; long hashCorrection; do < length = correctedLength; hashCorrection = hashCorrectionTable[length]; correctedLength = calculateLength(nextHash + hashCorrection); >while (correctedLength > length); String result = generate(nextHash + hashCorrection, length); nextHash = nextHash + MODULO; return result; > private String generate(long hash, int length) < assert hash >= 0 : hash; char[] buffer = new char[length]; Arrays.fill(buffer, alphabetStart); int i = length - 1; for(long current = hash; current != 0; current /= 31L) < buffer[i--] = (char) (alphabetStart + current % 31L); >return String.valueOf(buffer); > private static long[] precomputeHashCorrection(char alphabetStart) < int maxLength = calculateLength(Long.MAX_VALUE); long[] result = new long[maxLength + 1]; int correction = 0; for (int i = 0; i < result.length; i++) < result[i] = (MODULO - Integer.toUnsignedLong(correction)) % MODULO; correction = correction * 31 + alphabetStart; >return result; > private static int calculateLength(long number) < return number == 0 ? 0 : 1 + (int) log31(number); >private static double log31(long number) < return (Math.log(number) / LOG_31); >>
Простейшим примером использования будет код, генерирующий 16 строк с таким же хеш-кодом, что и у строки «Java» .
public class PreimageGeneratorDemo < private static final int COUNT = 16; private static final char ALPHABET_START = 'Б'; private static final String ORIGINAL_STRING = "Java"; public static void main(String. args) < PreimageGenerator generator = new PreimageGenerator( ORIGINAL_STRING.hashCode(), ALPHABET_START ); boolean allHashesValid = true; for (int i = 0; i < COUNT; i++) < String preimage = generator.next(); allHashesValid &= (ORIGINAL_STRING.hashCode() == preimage.hashCode()); System.out.printf("\t\"%s\"%n", preimage); >System.out.println(); if (allHashesValid) < System.out.println("All generated strings are valid."); >else < System.out.println("Some of generated strings are invalid!"); >System.out.println(); System.out.println("Done."); > >
Хеш-код, для которого нам нужно найти первый прообраз и начальный символ используемого алфавита передаются параметрами в конструктор класса PreimageGenerator . Каждый вызов метода next() будет возвращать всё новые и новые значения первого прообраза для заданного хеш-кода.
Так будет длиться до тех пор, пока метод hasNext() не вернёт значение false . Внутреннее состояние генератора представлено типом long , что даёт нам примерно два миллиарда различных значений.
Two billion strings ought to be enough for anybody.
Десерт
На десерт нам досталась фраза «Хабрахабр — торт! » (без кавычек). Мы хотим научиться вставлять её в начало любой строки и в результате получать строку с тем же хеш-кодом, что был у оригинала. Как мы уже выяснили в первой главе, для такого фокуса хеш-код вставляемой строки должен быть равен нулю.
У нашей фразы он отличен от нуля и значит необходимо вычислить строку, добавление которой изменит его в нужную сторону.
Для этого решим уравнение
Вспомним формулу из первой главы и преобразуем уравнение в эквивалентное
Значение P.hashCode нам уже известно, это хеш-код нашей фразы, интерпретированный как 32-битное беззнаковое целое. Подставив его в формулу мы сможем найти нужный нам хеш-код суффикса. Генерировать подходящую строку по известному хеш-коду мы научились во второй главе.
Для фразы «Хабрахабр — торт! » он равен -1_287_461_747 или 3_007_505_549L , если рассматривать его как беззнаковое целое. Для упрощения расчётов зафиксируем длину суффикса 7 символами.
Полученную константу 3_196_431_661L используем для инициализации PreimageGenerator и получаем набор подходящих суффиксов.
Они же, в виде текста
"Хабрахабр -- торт! ИГУБЧПЮ" "Хабрахабр -- торт! МЭУХЦЙГ" "Хабрахабр -- торт! СШФКХВЗ" "Хабрахабр -- торт! ЦУФЮУЪЛ" "Хабрахабр -- торт! ЫОХУТУП"
Остался последний штрих: наша строка-префикс неэстетично сливается с остальным текстом, обособим её пробелом в конце.
Для этого в правой части уравнения вместо нуля должно быть значение хеш-кода строки до того, как финальный пробел обнулит его. Чтобы получить нужное значение вычтем из нуля код символа пробела, он равен 32, и разделим полученное значение на 31.
Так как нам нужно целое неотрицательное решение, то вычитание 32 из нуля мы заменим на сложение с его противоположным числом, равным 2 32 — 32, а деление полученной разницы на 31 — умножением на обратное ему число — 3_186_588_639L .
Константу 9_843_021L всё так же используем для инициализации PreimageGenerator и смотрим что получится.
Они же, в виде текста
"Хабрахабр -- торт! ДРЙСЭНБ " "Хабрахабр -- торт! ЙЛКЖЬЖЕ " "Хабрахабр -- торт! ОЖКЪЪЮЙ " "Хабрахабр -- торт! УБЛПЩЧН " "Хабрахабр -- торт! ЧЫМДШРС " "Хабрахабр -- торт! ЬЦМШЧЙХ "
Заключение
Задача выполнена полностью и теперь в нашем распоряжении бесконечное количество строк с совпадающими хеш-кодами.
Что нужно знать об устройстве коллекций, основанных на хешировании
Всем привет. На связи Владислав Родин. В настоящее время я являюсь руководителем курса «Архитектор высоких нагрузок» в OTUS, а также преподаю на курсах, посвященных архитектуре ПО.
Помимо преподавания, как вы могли заметить, я занимаюсь написанием авторского материала для блога OTUS на хабре и сегодняшнюю статью хочу посвятить запуску нового потока курса «Алгоритмы для разработчиков».
Введение
Хеш-таблицы (HashMap) наравне с динамическими массивами являются самыми популярными структурами данных, применяемыми в production’е. Очень часто можно услышать вопросы на собеседованиях касаемо их предназначения, особенностей их внутреннего устройства, а также связанных с ними алгоритмов. Данная структура данных является классической и встречается не только в Java, но и во многих других языках программирования.
Постановка задачи
Давайте зададимся целью создать структуру данных, которая позволяет:
- contains(Element element) проверять, находится в ней некоторый элемент или нет, за О(1)
- add(Element element) добавлять элемент за О(1)
- delete(Element element) удалять элемент за О(1)
Массив
Попробуем сделать эти операции поверх массива, который является самой простой структурой данных. Договоримся, что будем считать ячейку пустой, если в ней содержится null.
Проверка наличия
Необходимо сделать линейный поиск по массиву, ведь элемент потенциально может находиться в любой ячейке. Асимптотически это можно осуществить за O(n), где n — размер массива.
Добавление
Если нам надо добавить элемент абы куда, то вначале мы должны найти пустую ячейку, а затем записать в нее элемент. Таким образом, мы опять осуществим линейный поиск и получим асимптотику O(n).
Удаление
Чтобы удалить элемент, его нужно сначала найти, а затем в найденную ячейку записать null. Опять линейный поиск приводит нас к O(n).
Простейшее хеш-множество
Обратите внимание, что при каждой операции, мы сначала искали индекс нужной ячейки, а затем осуществляли операцию, и именно поиск портит нам асимптотику! Если бы мы научились получать данный индекс за O(1), то исходная задача была бы решена.
Давайте теперь заменим линейный поиск на следующий алгоритм: будем вычислять значение некоторой фунции — хеш-функции, ставящей в соответствие объекту класса некоторое целое число. После этого будем сопоставлять полученное целое число индексу ячейки массива (это достаточно легко сделать, например, взяв остаток от деления этого числа на размер массива). Если хеш-функция написана так, что она считается за O(1) (а она так обычно и написана), то мы получили самую простейшую реализацию хеш-множества. Ячейка массива в терминах хеш-множества может называться bucket‘ом.
Проблемы простейшей реализации хеш-множества
Как бы ни была написана хеш-функция, число ячеек массива всегда ограничено, тогда как множество элементов, которые мы хотим хранить в структуре данных, неограничено. Ведь мы бы не стали заморачиваться со структурой данных, если бы была потребность в сохранении всего лишь десяти заранее известных элементов, верно? Такое положение дел приводит к неизбежным коллизиям. Под коллизией понимается ситуация, когда при добавлении разных объектов мы попадаем в одну и ту же ячейку массива.
Для разрешения коллизий придумано 2 метода: метод цепочек и метод открытой адресации.
Метод цепочек
Метод цепочек является наиболее простым методом разрешения коллизий. В ячейке массива мы будем хранить не элементы, а связанный список данных элементов. Потому как добавление в начало списка (а нам все равно в какую часть списка добавлять элемент) обладает асимптотикой О(1), мы не испортим общую асимптотику, и она останется равной О(1).
У данной реализации есть проблема: если списки будут очень сильно вырастать (в качестве крайнего случая можно рассмотреть хеш-функцию, которая возвращает константу для любого объекта), то мы получим асимптотику O(m), где m — число элементов во множестве, если размер массива фиксирован. Для избежания таких неприятностей вводится понятие коэффициент заполнения (он может быть равен, например, 1.5). Если при добавлении элемента оказывается, что доля числа элементов, находящихся в структуре данных по отношению к размеру массива, превосходит коэффициент заполнения, то происходит следующее: выделяется новый массив, размер которого превосходит размер старого массива (например в 2 раза), и структура данных перестраивается на новом массиве.
Данный метод разрешения коллизий и применяется в Java, а структура данных называется HashSet.
Метод открытой адресации
В данном методе в ячейках хранятся сами элементы, а в случае коллизии происходит последовательность проб, то есть мы начинаем по некоторому алгоритму перебирать ячейки в надежде найти свободную. Это можно делать разными алгоритмами (линейная / квадратичная последовательности проб, двойное хеширование), каждый из которых обладает своими проблемами (например, возникновение первичных или вторичных кластеров).
От хеш-множества к хеш-таблице (HashMap)
Давайте создадим структуру данных, которая позволяет так же быстро, как и хеш-множество (то есть за O(1)), добавлять, удалять, искать элементы, но уже по некоторому ключу.
Воспользуемся той же структурой данных, что у хеш-множества, но хранить будем не элементы, а пары элементов.
Таким образом, вставка (put(Key key, Value value)) будет осуществляться так: мы посчитаем ячейку массива по объекту типа Key, получим номер bucket’а. Пройдемся по списку в bucket’е, сравнивая ключ с ключом в хранимых парах. Если нашли такой же ключ — просто вытесняем старое значение, если не нашли — добавляем пару.
Как осуществляется получение элемента по ключу? Да по похожему принципу: получаем bucket по ключу, проходимся по парам и возвращаем значение в паре, ключ в которой равен ключу в запросе, если такая пара есть, и null в противном случае.
Данная структура данных и называется хеш-таблицей.
Типичные вопросы на собеседовании
Q: Как устроены HashSet и HashMap? Как осуществляются основные операциии в данных коллекциях? Как применяются методы equals() и hashCode()?
A: Ответы на данные вопросы можно найти выше.
Q: Каков контракт на equals() и hashCode()? Чем он обусловлен?
A: Если объекты равны, то у них должны быть равны hashCode. Таким образом, hashCode должен определяться по подможноству полей, учавствующих в equals. Нарушение данного контракта может приводить к весьма интересным эффектам. Если объекты равны, но hashCode у них разный, то вы можете не достать значение, соответствующее ключу, по которому вы только что добавили объект в HashSet или в HashMap.
Вывод
На собеседованиях очень любят задавать различные кейсы, связанные с этими структурами данных. При этом решение любого из них может быть выведено из понимания принципов их работы без всякой «зубрежки».
На этом все. Если вы дочитали материал до конца, приглашаю на бесплатный урок по теме «Секреты динамического программирования» в рамках урока мой коллега — Евгений Волосатов покажет как решить олимпиадную задачу используя идеи динамического программирования.
- Блог компании OTUS
- Программирование
- Java
- Алгоритмы
- Промышленное программирование