Как работи HashMap
Проблем
Мястото е продадено два пъти
Касата на театъра пази продадените места в речник: място → име на зрителя. Мястото е клас с ред и номер:
class Seat { int row; int number;
Seat(int row, int number) { this.row = row; this.number = number; }}Map<Seat, String> bookings = new HashMap<>();bookings.put(new Seat(5, 12), "Мария");
System.out.println(bookings.get(new Seat(5, 12)));System.out.println(bookings.size());bookings.put(new Seat(5, 12), "Иван");System.out.println(bookings.size());null12Мария е купила място 5/12, но get със същото място връща null. А Иван купува същото място и речникът вече има два ключа 5/12. Защо?
Идеята
Хеш код: число от ключа
HashMap не сравнява ключа с всички останали. Той смята от ключа едно цяло число и по него разбира къде да търси. Това число се казва хеш код и го връща методът hashCode(). Всеки обект го има, защото е в Object.
System.out.println("Мария".hashCode());System.out.println("Мария".hashCode());System.out.println("Мариа".hashCode());System.out.println("мария".hashCode());1004560195100456019510045601641034112867- Един и същ текст дава едно и също число, всеки път и на всеки компютър.
- Малка промяна дава друго число: „Мария“, „Мариа“ и „мария“ са три различни хеш кода.
- Хеш кодът се смята бързо, без значение колко ключа има в речника.
Методът, който прави числото, се казва хеш функция.
По-точно, за любопитните
- За
Stringформулата е определена в документацията на Java:s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1], катоs[i]е кодът на всеки символ. Резултатът еint, затова при препълване става отрицателен.
- Криптографските хеш функции като SHA-256 са друго нещо. Те дават дълъг резултат и е практически невъзможно да намериш два текста с един и същ хеш. Затова с тях се проверява дали файл не е подменен и се пазят пароли.
hashCode()е за бързо търсене и не дава такава защита.
Виж го
Кофите
Отвътре HashMap е масив от кофи (buckets). Номерът на кофата за ключа е остатъкът от хеша при деление на броя на кофите:
String[] keys = { "Иван", "Мария", "Петър", "Ана" };int buckets = 8;for (String key : keys) { int hash = key.hashCode(); int index = Math.floorMod(hash, buckets); System.out.println(key + " -> " + hash + " -> кофа " + index);}Стъпка по стъпка, с думи
- Четири ключа и осем празни кофи с номера от 0 до 7.
- „Иван“.hashCode() е 32287399. Остатъкът при деление на 8 е 7: това е кофата.
- „Мария“ дава 1004560195, кофа 3.
- „Петър“ дава 1007482178, кофа 2.
- „Ана“ също е в кофа 3: колизия. Двата ключа са в една кофа: „Ана“ се закача след „Мария“ като верига.
- Търсене на „Ана“: същото число, същата кофа 3. equals с „Мария“: не. С „Ана“: да.
putсмята кофата и слага двойката там.getсмята същата кофа и гледа само в нея. Затова е бърз и при милион ключа.Math.floorModе остатъкът от деление, който никога не е отрицателен. Хеш кодът може да е отрицателен, а номер на кофа не може.- „Мария“ и „Ана“ попаднаха в кофа 3. Това се казва колизия. За нея е следващата карта.
По-точно, за любопитните
- Истинският
HashMapсмята индекса малко по-иначе:(h ^ (h >>> 16)) & (n - 1). Броят на кофитеnвинаги е степен на двойката, а& (n - 1)е бърз остатък при деление наn. Затова номерата там са други, но принципът е същият.
Пробвай
В коя кофа?
Хеш кодът на Integer е самото число. Номерата на поръчките отиват в речник с 4 кофи.
Какво ще отпечата програмата?
int[] orderIds = { 6, 10, 13, 21 };for (int id : orderIds) { Integer key = id; System.out.println(id + " -> кофа " + Math.floorMod(key.hashCode(), 4));}Остатъците при деление на 4: 6 и 10 дават 2, а 13 и 21 дават 1. Четири ключа, две колизии, две празни кофи. При повече кофи ключовете се разпределят по-равномерно.
Идеята
Колизия: hashCode намира кофата, equals намира ключа
Различните ключове може да имат един и същ хеш код. Двата най-известни примера:
System.out.println("Aa".hashCode() + " " + "BB".hashCode());
Map<String, Integer> codes = new HashMap<>();codes.put("Aa", 1);codes.put("BB", 2);System.out.println(codes.get("Aa") + " " + codes.get("BB"));2112 21121 2Двата ключа са в една кофа, но речникът не ги бърка. В кофата има малък свързан списък от двойки. get минава по него и сравнява с equals, докато не намери ключа.
Така работят и двете стъпки:
hashCode()казва в коя кофа да търси.equals()казва кой от ключовете в кофата е търсеният.
Сега е ясно какво стана с мястото в театъра. Seat не презаписва нито едното, затова ползва версиите от Object. Там equals е ==, а hashCode обикновено е различен за всеки обект. Двата new Seat(5, 12) са два различни обекта, затова са и два различни ключа.
Идеята
Договорът: equals и hashCode заедно
Seat трябва да каже кога две места са едно и също място и да даде еднакъв хеш код на еднаквите:
class Seat { int row; int number;
Seat(int row, int number) { this.row = row; this.number = number; }
@Override public boolean equals(Object other) { if (!(other instanceof Seat)) { return false; } Seat s = (Seat) other; return row == s.row && number == s.number; }
@Override public int hashCode() { return 31 * row + number; }}Със същия код в main като в първата карта:
Map<Seat, String> bookings = new HashMap<>();bookings.put(new Seat(5, 12), "Мария");
System.out.println(bookings.get(new Seat(5, 12)));System.out.println(bookings.size());bookings.put(new Seat(5, 12), "Иван");System.out.println(bookings.size());Мария11Правилата, които Java очаква:
- Ако
a.equals(b), тогаваa.hashCode() == b.hashCode(). Винаги. - Ако хеш кодовете са равни, обектите може да са различни. Това е колизия.
- Презапишеш ли
equals, презапиши иhashCode, по същите полета.
По-точно, за любопитните
- Защо 31? То е нечетно просто число и разбърква добре. Умножаването по 31 прави хеша да зависи от реда: мястото 5/12 и мястото 12/5 имат различни хеш кодове. При
row + numberдвете щяха да попаднат в една кофа. - Вместо формулата можеш да пишеш
Objects.hashотjava.util.Objects. Тя смята същото по полетата, които ѝ дадеш:
@Overridepublic int hashCode() { return Objects.hash(row, number);}equalsполучаваObject, затова първо проверява сinstanceofдали другият изобщо еSeatи после го превръща. Това е превръщането надолу от модул „Наследяване“.
Пробвай
Намери грешката
HashSet. Два продукта с един и същ код са един и същ продукт, дори цената да се е сменила. Но contains с новата цена връща false:Set<Product> catalog = new HashSet<>();catalog.add(new Product("A-100", 25.0));// Цената е намалена, но продуктът е същият.System.out.println(catalog.contains(new Product("A-100", 19.9)));falseНа кой ред в класа е грешката?
Избери реда с грешката.
equals сравнява само кода, а hashCode смята и цената. Двата продукта са равни според equals, но имат различни хеш кодове. Това нарушава договора, затова HashSet търси в друга кофа и не ги сравнява изобщо. hashCode трябва да ползва същите полета като equals: return code.hashCode();. Тогава contains връща true.Идеята
Кофите се пълнят
Когато двойките станат много, кофите стават дълги и търсенето се забавя. Затова HashMap расте:
- В началото кофите са 16.
- Когато двойките станат повече от 75% от броя на кофите, той прави двойно повече кофи и преразпределя всички ключове. Номерът на кофата зависи от броя на кофите, затова всеки ключ се смята наново.
- Затова средно
putиgetса бързи, а понякога единputотнема повече време.
Има и капан. Ключът не бива да се променя, докато е в речника или в множеството:
Seat seat = new Seat(5, 12);Set<Seat> sold = new HashSet<>();sold.add(seat);
seat.number = 13;System.out.println(sold.contains(seat));System.out.println(sold.contains(new Seat(5, 12)));System.out.println(sold.size());falsefalse1contains(seat) търси в кофата на 5/13 и не го намира. contains(new Seat(5, 12)) търси в правилната кофа, но там обектът вече не е равен на 5/12. Елементът е вътре (размерът е 1), но не може да бъде намерен. Затова полетата на ключа е добре да са final.По-точно, за любопитните
- Тези 75% се казват load factor и могат да се зададат в конструктора:
new HashMap<>(64, 0.75f). Ако знаеш колко ключа ще има, дай началния брой кофи и речникът няма да расте по пътя. - От Java 8 нататък кофа с повече от 8 двойки (
TREEIFY_THRESHOLD) се превръща от списък в балансирано дърво, ако кофите са поне 64. Ако при преразпределяне в такава кофа останат 6 или по-малко двойки (UNTREEIFY_THRESHOLD), дървото отново става списък. Така при много колизии търсенето в кофа с n двойки отнема около log n сравнения вместо n.
Упражнение
Свои хеш кодове
Реши задачите в редактора си.
1. Хеш код на човек. Клас Person с име, фамилия и възраст. Презапиши equals и hashCode. За hashCode ползвай рецептата: започни от 17 и за всяко поле result = 31 * result + хешът на полето. За int поле хешът е самото число. Създай Иван Петров на 30 два пъти и веднъж на 31. Отпечатай хеш кодовете и колко различни хора остават в HashSet.
-1508576532-1508576532-1508576531Различни хора: 22. Роботът в склада. Роботът тръгва от клетка (0, 0) и изпълнява ходове NNEESWWN: N е нагоре (y + 1), S надолу, E надясно (x + 1), W наляво. Колко различни клетки е посетил, заедно с началната? Пази ги в HashSet<Point>. С този клас програмата казва 9, а трябва да каже 7. Поправи го:
class Point { int x; int y;
Point(int x, int y) { this.x = x; this.y = y; }}Ходове: 8Различни клетки: 73. Хеш на файл. Метод hashFileContent(String filePath) чете файла и връща хеш кода на съдържанието му като шестнайсетичен текст: Integer.toHexString(content.hashCode()). Запиши три отчета: a.txt и b.txt с „Отчет за октомври: 1250 €“, c.txt с „Отчет за октомври: 1260 €“. Сравни хешовете им. Различни хешове значат със сигурност различни файлове. Еднакви хешове значат „почти сигурно еднакви“: при int колизията е възможна, затова за истинска проверка се ползва SHA-256.
a.txt: ed426abfb.txt: ed426abfc.txt: ed508240a и b: еднаквиa и c: различниОще задачи
- Своя хеш таблица. Продължение на таблицата с потребители от миналия урок, този път без
HashMap. Направи масив от 8 кофи, всяка еArrayListот двойки потребител–имейл. Хеш функцията е сумата от кодовете на символите в името. Напишиadd,getи метод, който печата колко двойки има във всяка кофа. - Идентификатор от имейл.
generateUserId(String email)връща"U" + Integer.toHexString(email.toLowerCase().hashCode()). Еднакъв ли е идентификаторът заIvan@Example.comиivan@example.com? Може ли два различни имейла да получат един и същ идентификатор? Как ще го откриеш? - Броене на колизии. Сметни кофата на ключовете
user1…user1000при 16, 64 и 1024 кофи и преброй колко ключа попадат в кофа, в която вече има ключ. - Кутията за обувки. Клас
Boxс ширина, височина и дълбочина. Две кутии са еднакви, ако размерите им са еднакви в същия ред. НапишиequalsиhashCodeсObjects.hashи провери кутиите 30×20×10 и 10×20×30 вHashSet.
Тест