static int indexFor(int h, int length) {
return h & (length-1);
}
كمدخل، تم أخذ رمز التجزئة الذي تم الحصول عليه نتيجة للعمل hashCode()
وطول المصفوفة الداخلية (عدد الخلايا). وأعاد النتيجة "رمز التجزئة" -> "AND" -> (طول المصفوفة - 1). يرث الفصل HashMap
من الفصل AbstractMap
وينفذ الواجهات التالية: Map
, Cloneable
, Serializable
. الطريقة . هي المسؤولة عن وظيفة التجزئة في Java hashCode()
. يُرجع التنفيذ الافتراضي hashCode()
قيمة تسمى رمز تجزئة الهوية . حتى لو تم تجاوز فئة ما hashCode()
، يمكنك دائمًا الحصول على تجزئة معرف الكائن عن طريق استدعاء System.identityHashCode(Object o)
. التنفيذ الافتراضي hashCode()
في OpenJDK لا علاقة له بعنوان الذاكرة، كما يُعتقد أحيانًا. مزيد من التفاصيل هنا: habr في HashMap ، يتم تنفيذ جدول التجزئة بناءً على مصفوفة (أو بشكل أكثر دقة، ديناميكي، حيث يمكن توسيع الجدول) من القوائم المرتبطة بشكل فردي. في الأساس، نحصل على رمز التجزئة للمفتاح نتيجة للطريقة hashCode()
، والتي يتم تعديلها بعد ذلك (كما سنرى لاحقًا)، وداخليًا، باستخدام طريقة إضافية، يتم توزيع القيم الناتجة على الخلايا المطلوبة. تسمى عناصر المصفوفة (الخلايا) أيضًا بالدلاء ، والتي تُستخدم لتخزين العقد الفردية. كل دلو عبارة عن مجموعة (قائمة أو شجرة). العقدة هي كائن من فئة متداخلة Node
(أو TreeNode
في بنية شجرة). في الواقع، يوجد داخل خلية المصفوفة LinkedList
قائمة مرتبطة بشكل فردي فقط، أو شجرة ذات لون أحمر وأسود، والتي تكمن وراء تنفيذ فئة أخرى - TreeMap
.
HashMap
تحتوي على الحقول التالية:
final int hash
— تجزئة العنصر الحالي، والتي نحصل عليها نتيجة تجزئة المفتاح؛final K key
— مفتاح العنصر الحالي. هذا هو المكان الذي تتم فيه كتابة ما تحدده ككائن أول في الطريقةput()
؛V value
— قيمة العنصر الحالي. وما تحدده ككائن ثانٍ في الطريقة مكتوب هناput()
؛Node < K, V> next
- الارتباط بالعقدة التالية داخل نفس السلة. القائمة متصلة، لذا فهي تحتاج إلى رابط ليس للعقدة التالية، إذا كان هناك واحد.
transient Node < K, V> [] table
- جدول التجزئة نفسه، الذي يتم تنفيذه على أساس مصفوفة، لتخزين أزواج القيمة الرئيسية في شكل عقد. هذا هو المكان الذي يتم فيه تخزين عقدنا؛transient int size
- عدد أزواج القيمة الرئيسية؛int threshold
— الحد الأقصى لعدد العناصر، والذي عند الوصول إليه يتضاعف حجم جدول التجزئة. يتم حسابها باستخدام الصيغة (السعة * عامل التحميل)؛final float loadFactor
— تحدد هذه المعلمة درجة التحميل التي يحتاجها جدول التجزئة الحالي لإنشاء جدول تجزئة جديد، على سبيل المثال. بمجرد امتلاء جدول التجزئة بنسبة 75%، سيتم إنشاء جدول تجزئة جديد وسيتم نقل العناصر الحالية إليه (عملية مكلفة، حيث يجب إعادة صياغة جميع العناصر)؛transient Set< Map.Entry< K,V>> entrySet
- يحتوي على ذاكرة تخزين مؤقتentrySet()
يمكننا من خلالها التكرارHashMap
.
static final int DEFAULT_INITIAL_CAPACITY= 1 << 4
- سعة جدول التجزئة الافتراضية (16)؛static final int MAXIMUM_CAPACITY = 1 << 30
- أقصى سعة ممكنة لجدول التجزئة (حوالي 1 مليار)؛static final float DEFAULT_LOAD_FACTOR = 0.75f
— عامل الحمولة الافتراضي؛static final int TREEIFY_THRESHOLD = 8
هي "عتبة" عدد العناصر في مجموعة واحدة، وعند الوصول إليها سيتم تحويل القائمة المرتبطة الداخلية إلى بنية شجرة (شجرة حمراء-سوداء).static final int UNTREEIFY_THRESHOLD = 6
— إذا انخفض عدد العناصر في سلة واحدة إلى 6، فسيحدث انتقال عكسي من الشجرة إلى القائمة المرتبطة؛static final int MIN_TREEIFY_CAPACITY = 64
- الحد الأدنى للسعة (عدد الدلاء) لجدول التجزئة الذي يمكن من خلاله الانتقال إلى بنية الشجرة. أولئك. إذا كان هناك ما لا يقل عن 64 مجموعة في جدول التجزئة وكان هناك 8 عناصر أو أكثر في مجموعة واحدة، فسيتم الانتقال إلى بنية الشجرة.
public HashMap()
— إنشاء عرض تجزئة بشكل افتراضي: من حيث الحجم(capacity) = 16
ومع عامل التحميل(load factor) = 0.75
؛public HashMap(Map< ? extends K, ? extends V> m)
- إنشاء تعيين تجزئة تمت تهيئته بواسطة عناصر تعيين آخر محدد بسعة أولية كافية لاستيعاب عناصر تعيين آخر؛public HashMap(int initialCapacity)
- ينشئ خريطة تجزئة بسعة أولية معينة. لكي يعمل HashMap بشكل صحيح وصحيح، يجب أن يكون حجم المصفوفة الداخلية أسًا اثنين (أي 16، 64، 128، وما إلى ذلك)؛public HashMap(int initialCapacity, float loadFactor)
- إنشاء خريطة تجزئة بالمعلمات المحددة: السعة الأولية وعامل الحمولة.
Map
ويوسع الفصل AbstractMap
دون إضافة أساليبه الخاصة. لا تضمن خريطة التجزئة ترتيب عناصرها. لذلك، فإن الترتيب الذي يتم به إدخال العناصر في خريطة التجزئة ليس بالضرورة هو الترتيب الذي يتم استردادها به بواسطة المكرر. إضافة كائنات تتم إضافة زوج من القيمة الرئيسية باستخدام put()
. دعونا نلقي نظرة على الخطوات المتبعة عند إضافة كائن:
-
يتم حساب قيمة التجزئة لمفتاح الكائن الذي تم إدخاله. يتم حساب تجزئة المفتاح بطريقة
static final int hash(Object key)
تصل بالفعل إلىhashCode()
الطريقة الرئيسية التي نعرفها. للقيام بذلك، يتم استخدام أسلوب تجاوزhashCode()
أو تطبيقه الافتراضي. تحويلات إضافية في الطريقةhash()
:static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
Почему бы просто не вычислить с помощью
hashCode()
? Это сделано из-за того, чтоhashCode()
можно реализовать так, что только нижние битыint
'a будут заполнены. Например, дляInteger
,Float
– если мы в HashMap кладем маленькие значения, то у них и биты хеш-codeов будут заполнены только нижние. В таком случае ключи в HashMap будут иметь тенденцию скапливаться в нижних ячейках, а верхние будут оставаться пустыми, что не очень эффективно. На то, в Howой бакет попадёт новая запись, влияют только младшие биты хеша. Поэтому и придумали различными манипуляциями подмешивать старшие биты хеша в младшие, чтобы улучшить распределение по бакетам (чтобы старшие биты родного хеша an object начали вносить коррективы в то, в Howой бакет попадёт an object) и, How следствие, производительность. Потому и придумана дополнительная функцияhash
внутри HashMap. -
Вычисляем индекс бакета (ячейки массива), в который будет добавлен наш элемент:
i = (n - 1) & hash
где n – длина массива.
-
Создается an object Node.
-
Теперь, зная индекс в массиве, мы получаем список (цепочку) элементов, привязанных к этой ячейке. Если в бакете пусто, тогда просто размещаем в нем элемент. Иначе хэш и ключ нового element поочередно сравниваются с хешами и ключами элементов из списка и, при совпадении этих параметров, meaning element перезаписывается. Если совпадений не найдено, элемент добавляется в конец списка.
Теперь к очень подробному примеру.
-
Создадим an object класса HashMap:
HashMap < String, Integer> map = new HashMap<>();
-
С помощью метода
put()
добавим в хеш-отображение первую пару «ключ-meaning»:map.put("KING", 100);
В этот момент внутри вызывается метод
putVal()
. -
С помощью хеш-функции, роль которой играет метод hash, вычисляется хеш-code ключа, внутри которого предварительно вычисляется хеш-code с помощью метода hashCode() (в данном случае класса String), в результате чего мы получаем его «предварительное meaning» – 2306967. Может проверить в IDEA с помощью
System.out.println("KING".hashCode());
Полученный хеш-code модифицируется по формуле:
(хеш-code) ^ (хеш-code>>> 16)
, и в результате получаем окончательный хеш-code – 2306996. -
Проверяем таблицу на «пустоту»:
if ((tab = table) == null || (n = tab.length) == 0)
где
[] tab
— сама хеш-table: ссылаем tab и table (напомню, что это поле класса HashMap, которое хранит массив для наших элементов) на один и тот же массив, а int n – дополнительная переменная-счетчик.Так How проверка вернёт true (потому что массив для таблицы не был создан с помощью оператора new в конструкторе), будет вызван метод
resize()
, который собственно и создаст таблицу размером на 16 элементов. Да-да, конструкторы класса ниHowой таблицы не создают. Вместо этого всегда происходит вызов методаresize()
при первом добавлении element. Длина созданной таблицы (считай длина массива) будет записана в переменнуюn – n = (tab = resize()).length
, которая в дальнейшем используется для вычисления бакета. -
في الوقت نفسه، نحسب مؤشر الدلو حيث سيتم وضع كائننا والتحقق مما إذا كانت هناك عناصر بالفعل فيه. نحسب:
i = (n - 1) & hash i = (16 - 1) & 2306996 i = 4
يفحص:
if ((p = tab[i = (n - 1) & hash]) == null)
-
نظرًا لأنه نتيجة للتحقق، أصبحنا صحيحين (لا توجد عناصر في المجموعة)، سيتم إنشاء كائن عقدة يحتوي على الحقول التالية:
{ int hash = 2306996 — сгенерированный хеш-code String key = {"KING"} — ключ Integer value = 100 — meaning Node next = null — link на следующий узел }
ستتم إضافة كائن العقدة الذي تم إنشاؤه إلى المجموعة الموجودة ضمن الفهرس [4]:
tab[i] = newNode(hash, key, value, null); tab[4] = newNode(2306996, “KING”, 100, null);
newNode()
هي طريقة تقوم ببساطة بإرجاع كائن من فئة Node. -
بعد الإضافة، سيتم إجراء فحص لمعرفة ما إذا كان العدد الحالي للعناصر يتجاوز المعلمة
threshold
:if (++size > threshold) resize();
إذا تم تجاوزه، فسيتم استدعاء طريقة
resize()
لزيادة حجم جدول التجزئة.عند هذه النقطة، الطريقة
putVal()
(و، على التواليput()
) ستكمل عملها.ويمكن تمثيل النتيجة بيانيا على النحو التالي:
بشكل عام، هذا هو ما تبدو عليه إضافة عقدة (زوج قيمة المفتاح) إلى جدول التجزئة إذا كانت المجموعة المطلوبة فارغة . الآن دعونا نحاول إضافة عنصر يؤدي إلى الاصطدام (عندما يكون هناك أكثر من عنصر في مجموعة واحدة).
-
- طريقة التسلسل أو التسلسل الخارجي (يتم تنفيذها في HashMap) - أي. تحتوي الخلية بالفعل على قائمة (سلسلة). وقد تحتوي القائمة بالفعل على عدة قيم (ليس بالضرورة بنفس رمز التجزئة).
- طريقة الفحص الخطي أو طريقة العنونة المفتوحة (المطبقة في IdentityHashMap) - تتكون من البحث عن الخلية الفارغة الأولى بعد الخلية المشار إليها بواسطة وظيفة التجزئة؛
-
باستخدام هذه الطريقة،
put()
سنضيف زوجًا آخر من المفاتيح والقيمة إلى تعيين التجزئة:map.put("BLAKE", 10);
-
نقوم بحساب "التجزئة الأولية" – 63281361. نقوم بتعديلها ونتيجة لذلك نحصل على رمز التجزئة النهائي – 63281940.
-
نظرًا لأن التحقق الأول من "الفراغ" سيعيد الآن خطأ (ليست هناك حاجة لإنشاء جدول)، فإننا نحسب على الفور فهرس الدلو الذي سيتم وضع الكائن فيه:
i = (n - 1) & hash i = (16 - 1) & 63281940 i = 4
-
يتم فحص الدلو الموجود في الفهرس المحدد للتأكد من وجود عناصر فيه، وبما أن الشرط
if ((p = tab[i = (n - 1) & hash]) == null)
لم يتم استيفاءه في هذه الحالة (يوجد بالفعل عنصر في الدلو)، فإننا ننتقل إلى الكتلةelse
. -
أولًا، نقارن العنصر الذي تتم إضافته بالعنصر الأول من القائمة المرتبطة داخل المجموعة:
(p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
При проверке сначала сравниваются хеши ключей. Если этот «участок»
(p.hash == hash)
возвращает false, тогда остальная часть условия игнорируется (&&
), так How an objectы гарантированно разные. Иначе затем сравниваются ключи по ссылке (==) и в случае неequalsства, ключи сравниваются уже посредством методаequals()
. Сравнение осуществляется в таком порядке во благо производительности. Если все три выражения возвращают true, это означает, что ключи равны и новый элемент будет записан в дополнительную переменную, чтобы в дальнейшем с её помощью перезаписать meaning у ключа:if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; }
В результате сравнения ключей мы получаем false уже на первом этапе (разные хеши).
-
Игнорируем condition (
p instanceof TreeNode
), так How структура в бакете не является древовидной на данном этапе. -
Далее переходим в цикл
for
, где в пределах одного бакета проверяем у элементов указатель на следующий элементnext
, и если он equals null (значит элемент в списке последний и единственный), добавляем новый элемент Node в конец списка:if ((e = p.next) == null){ p.next = newNode(hash, key, value, null) ... };
Вы можете спросить, а где же проверка на equalsство ключей? Мы же не можем просто взять и добавить новый элемент. Так вот она уже была заранее осуществлена в пункте (5). Благодаря этому, теперь мы можем просто проверить указатель этого element, и так How он сейчас equals null, можно сделать вывод о том, что в списке только один элемент. И так How мы уже проверяли их ключи, мы можем безболезненно добавлять новый элемент.
Если же при первой итерации указатель не equals null, это говорит о том, что в списке How минимум два element, поэтому в таком случае мы переходим к следующему условия и сравниваем ключ добавляемого element со всеми ключами элементов в списке (способом, описанным в пятом пункте).
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
Если при сравнении ключей будет найдено совпадение, новый элемент будет записан в дополнительную переменную, чтобы в дальнейшем с её помощью перезаписать meaning у ключа.
После добавления второго element наш an object HashMap графически выглядит так:
В итоге, добавление элементов при коллизии (в пределах одного бакета) выглядит следующим образом:
- проверяем с помощью методов
hashCode()
иequals()
, что оба ключа одинаковы. - если ключи одинаковы, заменить текущее meaning новым.
- иначе связать новый и старый an objectы с помощью структуры данных "связный список", указав ссылку на следующий an object в текущем и сохранить оба под нужным индексом; либо осуществить переход к древовидной структуре
- проверяем с помощью методов
-
После каждой итерации (добавления нового element) в цикле for увеличивается счетчик, который отвечает за количество элементов в бакете:
for (int binCount = 0; ; ++binCount)
До тех пор, пока их количество не станет равно or больше 7:
binCount >= TREEIFY_THRESHOLD – 1
В таком случае произойдет вызов метода
treeifyBin()treeify()
для непосредственного построения древовидной структуры. Однако, если количество бакетов в текущей хеш-таблице меньше 64:if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
بدلاً من الانتقال إلى بنية الشجرة، سيتم استدعاء طريقة
resize()
لزيادة حجم جدول التجزئة وإعادة توزيع العناصر.treeify()
بدورها، تتحول قائمةTreeNode
المتحولين المرتبطة إلى شجرة حمراء وسوداء. تتكرر الطريقةresize()
عبر جميع عناصر التخزين الحالية، وتعيد حساب مؤشراتها (مع الأخذ في الاعتبار الحجم الجديد) وتعيد توزيع العناصر في مصفوفة جديدة.باختصار، ودون الخوض في تفاصيل هيكل الشجرة ذات اللون الأحمر والأسود يحدث ما يلي:
-
العنصر الأول من القائمة مكتوب كجذر الشجرة بأكملها (أسود):
if (root == null) { x.parent = null; x.red = false; root = x; }
-
بالنسبة للعناصر الأخرى:
قم بتوزيعها على اليسار أو اليمين حسب قيمة التجزئة:
if ((ph = p.hash) > h) dir = -1; else if (ph < h) dir = 1;
يجب أن يكون جميع الأطفال الأيسر أقل من (أو يساوي) العقدة الجذرية الخاصة بهم، بينما يجب أن يكون جميع الأطفال الأيمن أكبر.
-
إذا كان هناك عنصران لهما نفس التجزئات ولا يمكن مقارنتهما بأي طريقة أخرى (لا يطبقان الواجهة
Comparable
)، فإننا نقوم بمقاطعة بناء الشجرة واستدعاء الطريقةtieBreakOrder()
، والتي بدورها تستخدم الطريقة الأصليةSystem.identityHashCode()
لحساب رمز تجزئة فريد عالميًا .مزيد من التفاصيل هنا: رابط المقال
-
نقوم بفحص عناصر الشجرة (الكائنات
TreeNode
) حتى يتم العثور على عنصر صفر فرعي (يسار أو يمين).if ((p = (dir <= 0) ? p.left : p.right) == null)
-
أضف عقدة فرعية (يسارًا أو يمينًا اعتمادًا على dir):
x.parent = xp; if (dir <= 0) xp.left = x; else xp.right = x;
-
وبما أن إضافة عنصر جديد قد يخل بتوازن الشجرة، فإننا نسمي طريقة إعادة التوازن:
root = balanceInsertion(root, x);
يمكنك أن تقرأ عن موازنة CCH هنا: habr
-
بعد الموازنة، قد يتغير العنصر الجذري. نقوم بإصلاح ذلك عن طريق استدعاء طريقة تضمن أن الجذر الذي تم تمريره إليها سيكون العقدة الأولى:
moveRootToFront(tab, root)
يمكنك أن ترى بوضوح كيف يتم بناء الشجرة ذات اللون الأحمر والأسود والتوازن الذاتي هنا: التصور
-
- حساب رمز التجزئة للمفتاح.
- احسب مؤشر الدلو.
- انتقل إلى الفهرس وقارن مفتاح العنصر الأول بالقيمة الموجودة. إذا كانا متساويين، قم بإرجاع القيمة، وإلا تحقق من العنصر التالي، إذا كان موجودًا.
- إذا كان كائن العقدة التالي فارغًا، فسيتم إرجاعه فارغًا.
- إذا لم يكن كائن العقدة التالي فارغًا، فانتقل إليه وكرر الخطوات الثلاث الأولى حتى يتم العثور على العنصر أو يصبح كائن العقدة التالي خاليًا.
get()
نحصل على قيمة المفتاح "KING":
map.get("KING");
في الداخل، تسمى الطريقة getNode(int hash, Object key)
، والتي يتم تمرير المفتاح نفسه ("KING") والتجزئة الخاصة به (2306996)، والتي يتم حسابها مسبقًا بنفس الطريقة التي يتم بها أثناء العملية put()
.
-
نحن نفحص:
-
هل يوجد جدول تجزئة حتى:
(tab = table) != null
اسمحوا لي أن أذكرك أنه عند إنشاء HashMap، لا يتم إنشاء مصفوفة للجدول في المُنشئ؛ يحدث هذا لاحقًا في الطريقة
resize()
، والتي يتم استدعاؤها دائمًا عند إضافة العنصر الأول إلى جدول التجزئة. لذلك، إذا لم تتم إضافة أي عناصر إلى HashMap، فلن يكون هناك ببساطة مصفوفة داخلية لتخزين العناصر. -
إذا كان التعبير السابق صحيحًا، فستحتاج إلى التأكد من أن طول المصفوفة الداخلية أكبر من 0:
(n = tab.length) > 0;
-
إذا كان التعبير السابق أيضًا صحيحًا، فانتقل إلى المجموعة الموجودة في الفهرس (في حالتنا 4)، والتي تم حسابها مسبقًا، وتحقق من وجود العناصر:
(first = tab[(n - 1) & hash]) != null)
-
نقوم بمقارنة المفتاح الذي نبحث عنه مع مفتاح العنصر الأول في القائمة داخل المجموعة، لأنه في معظم المجموعات سيكون هناك (وليس في كل مكان لدينا تصادمات) عنصر واحد فقط (حالتنا). كما في حالة الطريقة
put()
، تتم مقارنة التجزئات، وإذا تطابقت، تتم مقارنة المفاتيح حسب المرجع، وعندها فقط بواسطةequals()
.if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k))))
بما أنه في حالتنا، المفتاح "KING" سيسبق المفتاح "BLAKE" (داخل قائمة مرتبطة، تتم إضافة عناصر جديدة إلى النهاية، وتم إضافة KING أولاً)، فسوف نتوقف عند هذه النقطة ونعيد الكائن الأول من اكتب Node إلى طريقة get() التي "تنتزع" منه حقلاً بالقيمة (100):
return (e = getNode(hash(key), key)) == null ? null : e.value;
-
-
إذا كان هناك أكثر من عنصر داخل الدلو، فعندئذٍ:
-
إذا كانت المجموعة عبارة عن قائمة مرتبطة، فإننا نتصفح القائمة عبر كل عنصر من العناصر في حلقة
do – while
حتى يتم العثور على التطابق:do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null);
-
إذا كان الدلو عبارة عن بنية شجرة، فسيتم استدعاء الطريقة أيضًا
getTreeNode()
، والتي بدورها تستخدم الطريقة للعثور على المفتاح المطلوبfind()
. نقوم بإجراء بحث شجرة - تتم مقارنة التجزئات وتحديد عقدة الجذر اليسرى أو اليمنى للبحث. إذا كانت المفاتيح متساوية (بالإشارة أو حسبequals
)، قم بإرجاع هذه العقدة. إذا كانت العقد الفرعية اليسرى أو اليمنى فارغة، فإننا نقارن المفاتيح أيضًا باستخدام CompareTo (إذا كانت المفاتيح تنفذ الواجهةComparable
)، وإلا فإننا نقوم بإجراء بحث متكرر عبر الشجرة (الشجرة الفرعية اليمنى أو اليسرى) حتى يتم العثور على تطابق.
-
-
انتقل إلى الجرافة المطلوبة (مرة أخرى، يتم حسابها مسبقًا)؛
-
إذا كان هناك كائن واحد فقط في المجموعة (نتحقق من مؤشره الفارغ) فإننا نقارن التجزئات والروابط
equals
(إذا كانت التجزئات غير متساوية فجأة). العثور على المباراة؟ عظيم، هذا هو مفتاحنا - احذفه (=خالي) وأعد قيمة هذا المفتاح. -
إذا كان هناك أكثر من عنصر واحد في المجموعة، فإننا نتحقق من كل عنصر في حلقة حتى نجد العنصر أو نصل إلى نهاية القائمة.
-
إذا لم يتم العثور على العنصر، نعود فارغة.
GO TO FULL VERSION