بنى البيانات المفتوحة › 5. جداول التجزئة 5 / 14 السابق التالي

5. جداول التجزئة

جداول التجزئة (hash tables) طريقةٌ كفؤة لتخزين عددٍ صغير، n \mathtt{n}، من الأعداد الصحيحة المأخوذة من مدى واسع U={0,…,2w−1} U=\{0,\ldots,2^{\ensuremath{\mathtt{w}}}-1\}. ويشمل مصطلح «جدول التجزئة» مدىً واسعًا من بنى البيانات. يركّز الجزء الأول من هذا الفصل على أكثر تنفيذين شيوعًا لجداول التجزئة: التجزئة بالتسلسل (hashing with chaining) والاستكشاف الخطي (linear probing). وكثيرًا ما تخزّن جداول التجزئة أنواعًا من البيانات ليست أعدادًا صحيحة. وفي هذه الحالة يُسنَد إلى كل عنصر بيانات رمز تجزئة (hash code) صحيح يُستخدَم في جدول التجزئة. ويناقش الجزء الثاني من هذا الفصل كيفية توليد رموز التجزئة هذه. وتتطلب بعض الطرق المستخدَمة في هذا الفصل اختيار أعداد صحيحة عشوائية ضمن مدى محدّد. وفي نماذج الشيفرة تكون بعض هذه الأعداد «العشوائية» ثوابت مكتوبة على نحو صريح (hard-coded). وقد جُمِعت هذه الثوابت باستخدام بتّات عشوائية مولَّدة من ضجيج جوي.

الأقسام الفرعية

opendatastructures.org

5.1 ChainedHashTable: التجزئة بالتسلسل

الأقسام الفرعية

5.1 ChainedHashTable: التجزئة بالتسلسل

تستخدم بنية بيانات ChainedHashTable التجزئة بالتسلسل لتخزين البيانات في صورة مصفوفة، t \mathtt{t}، من القوائم (lists). ويحتفظ عدد صحيح، n \mathtt{n}، بمجموع عدد العناصر في جميع القوائم (انظر الشكل 5.1):

    List<T>[] t;
    int n;

قيمة تجزئة عنصر البيانات x \mathtt{x}، والمشار إليها بـ hash(x) \mathtt{hash(x)}، هي قيمة ضمن المدى {0,…,t.length−1} \{0,\ldots,\ensuremath{\mathtt{t.length}}-1\}. وتُخزَّن جميع العناصر ذات قيمة التجزئة i \mathtt{i} في القائمة عند t[i] \mathtt{t[i]}. ولمنع أن تصير القوائم طويلة أكثر من اللازم، نُبقي على الشرط الثابت (invariant)

n≤t.length \displaystyle \ensuremath{\mathtt{n}} \le \ensuremath{\mathtt{t.length}}

بحيث يكون متوسط عدد العناصر المخزَّنة في إحدى هذه القوائم هو n/t.length≤1 \ensuremath{\mathtt{n}}/\ensuremath{\mathtt{t.length}} \le 1. ولإضافة عنصر x \mathtt{x} إلى جدول التجزئة، نتحقّق أولًا مما إذا كان طول t \mathtt{t} يحتاج إلى زيادة، فإن كان ذلك فإننا نُنمّي t \mathtt{t}. وبعد أن نُنهي ذلك نجزّئ x \mathtt{x} لنحصل على عدد صحيح، i \mathtt{i}، ضمن المدى {0,…,t.length−1} \{0,\ldots,\ensuremath{\mathtt{t.length}}-1\}، ثم نُلحق x \mathtt{x} بالقائمة t[i] \mathtt{t[i]}:

    boolean add(T x) {
        if (find(x) != null) return false;
        if (n+1 > t.length) resize();
        t[hash(x)].add(x);
        n++;
        return true;
    }

ينطوي نموُّ الجدول، عند الضرورة، على مضاعفة طول t \mathtt{t} وإعادة إدراج جميع العناصر في الجدول الجديد. وهذه الاستراتيجية هي نفسها تمامًا المستخدَمة في تنفيذ ArrayStack، وينطبق عليها النتيجة نفسها: ف تكلفة النموّ ثابتة فقط عند الاحتساب بمعدل (amortized) على تسلسل من عمليات الإدراج (انظر المتراجمة 2.1 في الصفحة  ). وإلى جانب النموّ، فإن العمل الوحيد الآخر الذي يجري عند إضافة قيمة جديدة x \mathtt{x} إلى ChainedHashTable هو إلحاق x \mathtt{x} بالقائمة t[hash(x)] \mathtt{t[hash(x)]}. ولكلٍّ من تنفيذي القوائم الموصوفين في الفصلين 2 أو 3، لا يستغرق هذا سوى زمن ثابت. ولإزالة عنصر x \mathtt{x} من جدول التجزئة، نمرّ على القائمة t[hash(x)] \mathtt{t[hash(x)]} حتى نجد x \mathtt{x} لنتمكن من إزالته:

    T remove(T x) {
        Iterator<T> it = t[hash(x)].iterator();
        while (it.hasNext()) {
            T y = it.next();
            if (y.equals(x)) {
                it.remove();
                n--;
                return y;
            }
        }
        return null;
    }

يستغرق هذا زمنًا قدره O(nhash(x)) O(\ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{hash(x)}}})، حيث يشير ni \ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{i}}} إلى طول القائمة المخزَّنة عند t[i] \mathtt{t[i]}. والبحث عن العنصر x \mathtt{x} في جدول تجزئة مشابه. فنجري بحثًا خطيًا في القائمة t[hash(x)] \mathtt{t[hash(x)]}:

    T find(Object x) {
        for (T y : t[hash(x)])
            if (y.equals(x))
                return y;
        return null;
    }

ومرة أخرى، يستغرق هذا زمنًا متناسبًا مع طول القائمة t[hash(x)] \mathtt{t[hash(x)]}.

يعتمد أداء جدول التجزئة اعتمادًا حاسمًا على اختيار دالة التجزئة (hash function). فدالة التجزئة الجيدة توزّع العناصر بالتساوي على القوائم الـ t.length \mathtt{t.length}، بحيث يكون الحجم المتوقَّع للقائمة t[hash(x)] \mathtt{t[hash(x)]} هو O(n/t.length)=O(1) O(\ensuremath{\mathtt{n}}/\ensuremath{\mathtt{t.length)}} = O(1). وعلى النقيض، فإن دالة التجزئة السيئة تجعل جميع القيم (بما فيها x \mathtt{x}) تُجزَّأ إلى موضع الجدول نفسه، وعندها يكون حجم القائمة t[hash(x)] \mathtt{t[hash(x)]} هو n \mathtt{n}. وفي القسم التالي نصف دالة تجزئة جيدة. 5.1.1 التجزئة الضربية (multiplicative hashing) التجزئة الضربية طريقةٌ كفؤة لتوليد قيم التجزئة بالاعتماد على الحساب النمطي (modular arithmetic) (المُناقَش في القسم 2.3) والقسمة الصحيحة. وهي تستخدم المعامل div \ddiv الذي يحسب الجزء الصحيح من ناتج القسمة مع تجاهل الباقي. وصيغته: لأي عددين صحيحين a≥0 a\ge 0 و b≥1 b\ge 1، فإن adivb=⌊a/b⌋ a\ddiv b = \lfloor a/b\rfloor. وفي التجزئة الضربية نستخدم جدول تجزئة حجمه 2d 2^{\ensuremath{\mathtt{d}}} مهما كان العدد الصحيح d \mathtt{d} (ويسمى البُعد (dimension)). وصيغة تجزئة العدد الصحيح x∈{0,…,2w−1} \ensuremath{\mathtt{x}}\in\{0,\ldots,2^{\ensuremath{\mathtt{w}}}-1\} هي

هنا z \mathtt{z} عددٌ صحيح فردي يُختار عشوائيًا من {1,…,2w−1} \{1,\ldots,2^{\ensuremath{\mathtt{w}}}-1\}. ويمكن تنفيذ دالة التجزئة هذه بكفاءة بالغة بمجرد ملاحظة أن عمليات الأعداد الصحيحة تُنفَّذ افتراضيًا بمقياس 2w 2^{\ensuremath{\mathtt{w}}} حيث w \ensuremath{\mathtt{w}} عدد البتّات في العدد الصحيح.5.1 (انظر الشكل 5.2.) وإضافةً إلى ذلك، فإن القسمة الصحيحة على 2w−d 2^{\ensuremath{\mathtt{w}}-\ensuremath{\mathtt{d}}} تعادل إسقاط البتّات w−d \ensuremath{\mathtt{w}}-\ensuremath{\mathtt{d}} الأخيرة في التمثيل الثنائي (وهذا يُنفَّذ بإزاحة البتّات إلى اليمين بمقدار w−d \ensuremath{\mathtt{w}}-\ensuremath{\mathtt{d}} باستخدام المعامل >>> \mathtt{\text{\ttfamily >>>}}). وبهذه الطريقة تكون الشيفرة التي تنفّذ الصيغة أعلاه أبسط من الصيغة نفسها:

    int hash(Object x) {
        return (z * x.hashCode()) >>> (w-d);
    }

الشكل 5.2: تشغيل دالة التجزئة الضربية مع w=32 \ensuremath{\mathtt{w}}=32 و d=8 \ensuremath{\mathtt{d}}=8.  وتبيّن المتراجمة التالية (Lemma)، إن كان برهانها مؤجَّلًا إلى ما بعد في هذا القسم، أن التجزئة الضربية تحسن التخلّص من التصادمات (collisions): المتراجمة 5.1 ليكن x \mathtt{x} و y \mathtt{y} أي قيمتين في {0,…,2w−1} \{0,\ldots,2^{\ensuremath{\mathtt{w}}}-1\} بحيث x≠y \ensuremath{\mathtt{x}}\neq \ensuremath{\mathtt{y}}. وعندئذ Pr⁡{hash(x)=hash(y)}≤2/2d \Pr\{\ensuremath{\mathtt{hash(x)}}=\ensuremath{\mathtt{hash(y)}}\} \le 2/2^{\ensuremath{\mathtt{d}}}.

بالمتراجمة 5.1، يصبح تحليل أداء remove(x) \mathtt{remove(x)} و find(x) \mathtt{find(x)} سهلًا: المتراجمة 5.2 لكل قيمة بيانات x \mathtt{x}، يكون الطول المتوقَّع للقائمة t[hash(x)] \mathtt{t[hash(x)]} هو nx+2 \ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{x}}} + 2 على الأكثر، حيث nx \ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{x}}} هو عدد تكرارات x \mathtt{x} في جدول التجزئة.

إثبات. ليكن S S المجموعة (متعددة العناصر) للعناصر المخزَّنة في جدول التجزئة التي لا تساوي x \mathtt{x}. ولكل عنصر y∈S \ensuremath{\mathtt{y}}\in S، عرّف المتغيّر الإشاري (indicator variable)

ولاحظ أن، بحسب المتراجمة 5.1، E[Iy]≤2/2d=2/t.length \mathrm{E}[I_{\ensuremath{\mathtt{y}}}] \le 2/2^{\ensuremath{\mathtt{d}}}=2/\ensuremath{\mathtt{t.length}}. ويعطى الطول المتوقَّع للقائمة t[hash(x)] \mathtt{t[hash(x)]} بـ

E[t[hash(x)].size()]\displaystyle \mathrm{E}\left[\ensuremath{\mathtt{t[hash(x)].size()}}\right] =\displaystyle = E[nx+∑y∈SIy]\displaystyle \mathrm{E}\left[\ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{x}}} + \sum_{\ensuremath{\mathtt{y}}\in S} I_{\ensuremath{\mathtt{y}}}\right]
=\displaystyle = nx+∑y∈SE[Iy]\displaystyle \ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{x}}} + \sum_{\ensuremath{\mathtt{y}}\in S} \mathrm{E}[I_{\ensuremath{\mathtt{y}}} ]
≤\displaystyle \le nx+∑y∈S2/t.length\displaystyle \ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{x}}} + \sum_{\ensuremath{\mathtt{y}}\in S} 2/\ensuremath{\mathtt{t.length}}
≤\displaystyle \le nx+∑y∈S2/n\displaystyle \ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{x}}} + \sum_{\ensuremath{\mathtt{y}}\in S} 2/\ensuremath{\mathtt{n}}
≤\displaystyle \le
≤\displaystyle \le nx+2,\displaystyle \ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{x}}} + 2 \enspace ,

كما هو مطلوب. ■\qedsymbol

الآن نريد إثبات المتراجمة 5.1، لكننا نحتاج أولًا إلى نتيجة من نظرية الأعداد (number theory). وفي البرهان التالي نستخدم الترميز (br,…,b0)2 (b_r,\ldots,b_0)_2 للدلالة على ∑i=0rbi2i \sum_{i=0}^r b_i2^i، حيث كل bi b_i بتّ قيمته 0 أو 1. بمعنى آخر، (br,…,b0)2 (b_r,\ldots,b_0)_2 هو العدد الصحيح الذي تمثيله الثنائي هو br,…,b0 b_r,\ldots,b_0. ونستخدم ⋆ \star للدلالة على بتّ مجهول القيمة. المتراجمة 5.3 ليكن S S مجموعة الأعداد الفردية في {1,…,2w−1} \{1,\ldots,2^{\ensuremath{\mathtt{w}}}-1\}؛ وليكن q q و i i أي عنصرين في S S. وعندئذ توجد قيمة واحدة بالضبط z∈S \ensuremath{\mathtt{z}}\in S تحقق zq mod 2w=i \ensuremath{\mathtt{z}}q\bmod 2^{\ensuremath{\mathtt{w}}} = i.

إثبات. بما أن عدد الاختيارات لـ z \ensuremath{\mathtt{z}} و i i هو نفسه، فإنّه يكفي إثبات وجود قيمة واحدة على الأكثر z∈S \ensuremath{\mathtt{z}}\in S تحقق zq mod 2w=i \ensuremath{\mathtt{z}}q\bmod 2^{\ensuremath{\mathtt{w}}} = i.

ولنفرض، طلبًا للتناقض، وجود قيمتين من هذا النوع z \mathtt{z} و z′ \mathtt{z'}، بحيث z>z′ \ensuremath{\mathtt{z}}>\ensuremath{\mathtt{z}}'. عندئذ

zq mod 2w=z′q mod 2w=i \displaystyle \ensuremath{\mathtt{z}}q\bmod 2^{\ensuremath{\mathtt{w}}} = \ensuremath{\mathtt{z}}'q \bmod 2^{\ensuremath{\mathtt{w}}} = i

إذن

(z−z′)q mod 2w=0 \displaystyle (\ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}')q\bmod 2^{\ensuremath{\mathtt{w}}} = 0

لكن هذا يعني أن

لبعض العدد الصحيح k k. وبالتفكير في مصطلحات الأعداد الثنائية، لدينا

(z−z′)q=k⋅(1,0,…,0⏟w)2, \displaystyle (\ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}')q = k\cdot(1,\underbrace{0,\ldots,0}_{\ensuremath{\mathtt{w}}})_2 \enspace ,

بحيث تكون البتّات w \mathtt{w} اللاحقة في التمثيل الثنائي لـ (z−z′)q (\ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}')q كلها أصفارًا.

وفوق ذلك k≠0 k\neq 0، لأن q≠0 q\neq 0 و z−z′≠0 \ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}'\neq 0. وبما أن q q فردي، فلا توجد عنده أصفار لاحقة في تمثيله الثنائي:

q=(⋆,…,⋆,1)2. \displaystyle q = (\star,\ldots,\star,1)_2 \enspace .

ولأن ∣z−z′∣<2w \vert\ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}'\vert < 2^{\ensuremath{\mathtt{w}}}، فإن z−z′ \ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}' يملك عددًا من الأصفار اللاحقة في تمثيله الثاني أقل من w \mathtt{w}:

z−z′=(⋆,…,⋆,1,0,…,0⏟<w)2. \displaystyle \ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}' = (\star,\ldots,\star,1,\underbrace{0,\ldots,0}_{<\ensuremath{\mathtt{w}}})_2 \enspace .

لذلك يملك حاصل الضرب (z−z′)q (\ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}')q عددًا من الأصفار اللاحقة في تمثيله الثاني أقل من w \mathtt{w}:

(z−z′)q=(⋆,⋯ ,⋆,1,0,…,0⏟<w)2. \displaystyle (\ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}')q = (\star,\cdots,\star,1,\underbrace{0,\ldots,0}_{<\ensuremath{\mathtt{w}}})_2 \enspace .

لذلك لا يمكن أن يحقّق (z−z′)q (\ensuremath{\mathtt{z}}-\ensuremath{\mathtt{z}}')q الشرط (5.1)، فينشأ تناقض ويكتمل البرهان. ■\qedsymbol

تنبع فائدة المتراجمة 5.3 من الملاحظة التالية: إذا اختير z \mathtt{z} عشوائيًا بانتظام من S S، فإن zt \mathtt{zt} يتوزّع بانتظام على S S. وفي البرهان التالي من المفيد التفكير في التمثيل الثنائي لـ z \mathtt{z}، وهو يتكوّن من w−1 \ensuremath{\mathtt{w}}-1 بتّ عشوائي يليه بتّ بقيمة 1.

إثبات. [إثبات المتراجمة 5.1] نلاحظ أولًا أن الشرط hash(x)=hash(y) \ensuremath{\mathtt{hash(x)}}=\ensuremath{\mathtt{hash(y)}} يعادل القول «أن البتّات d \mathtt{d} ذات الرتبة العليا في zx mod 2w \ensuremath{\mathtt{z}} \ensuremath{\mathtt{x}}\bmod2^{\ensuremath{\mathtt{w}}} والبتّات d \mathtt{d} ذات الرتبة العليا في zy mod 2w \ensuremath{\mathtt{z}} \ensuremath{\mathtt{y}}\bmod 2^{\ensuremath{\mathtt{w}}} متساوية.» والشرط الضروري لهذا القول هو أن تكون البتّات d \mathtt{d} ذات الرتبة العليا في التمثيل الثنائي لـ z(x−y) mod 2w \ensuremath{\mathtt{z}}(\ensuremath{\mathtt{x}}-\ensuremath{\mathtt{y}})\bmod 2^{\ensuremath{\mathtt{w}}} إمّا كلها أصفارًا أو كلها آحادًا. أي:

عندما zx mod 2w>zy mod 2w \ensuremath{\mathtt{zx}}\bmod 2^{\ensuremath{\mathtt{w}}} > \ensuremath{\mathtt{zy}}\bmod 2^{\ensuremath{\mathtt{w}}} أو

عندما zx mod 2w<zy mod 2w \ensuremath{\mathtt{zx}}\bmod 2^{\ensuremath{\mathtt{w}}} < \ensuremath{\mathtt{zy}}\bmod 2^{\ensuremath{\mathtt{w}}}. لذلك لا علىّ سوى تقييد احتمال أن يبدو z(x−y) mod 2w \ensuremath{\mathtt{z}}(\ensuremath{\mathtt{x}}-\ensuremath{\mathtt{y}})\bmod 2^{\ensuremath{\mathtt{w}}} على هيئة (5.2) أو (5.3).

ليكن q q العدد الفردية الفريد بحيث (x−y) mod 2w=q2r (\ensuremath{\mathtt{x}}-\ensuremath{\mathtt{y}})\bmod 2^{\ensuremath{\mathtt{w}}}=q2^r لبعض العدد الصحيح r≥0 r\ge 0. وبالمتراجمة 5.3، فإن التمثيل الثنائي لـ zq mod 2w \ensuremath{\mathtt{z}}q\bmod 2^{\ensuremath{\mathtt{w}}} يحتوي w−1 \ensuremath{\mathtt{w}}-1 بتّ عشوائي يليه بتّ بقيمة 1:

لذلك، فإن التمثيل الثنائي لـ المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 1 يحتوي w−r−1 \ensuremath{\mathtt{w}}-r-1 بتّ عشوائي، يليه بتّ بقيمة 1، يليه r r من الأصفار:

يمكننا الآن إكمال البرهان: إذا كان r>w−d r > \ensuremath{\mathtt{w}}-\ensuremath{\mathtt{d}}، فالبتّات d \mathtt{d} ذات الرتبة العليا في z(x−y) mod 2w \ensuremath{\mathtt{z}}(\ensuremath{\mathtt{x}}-\ensuremath{\mathtt{y}})\bmod 2^{\ensuremath{\mathtt{w}}} تحتوي أصفارًا وآحادًا معًا، ومن ثمّ فإن احتمال أن يبدو z(x−y) mod 2w \ensuremath{\mathtt{z}}(\ensuremath{\mathtt{x}}-\ensuremath{\mathtt{y}})\bmod 2^{\ensuremath{\mathtt{w}}} على هيئة (5.2) أو (5.3) هو 0. وإذا كان r=w−d \ensuremath{\mathtt{r}}=\ensuremath{\mathtt{w}}-\ensuremath{\mathtt{d}}، فإن احتمال الظهور على هيئة (5.2) هو 0، لكن احتمال الظهور على هيئة (5.3) هو 1/2d−1=2/2d 1/2^{\ensuremath{\mathtt{d}}-1}=2/2^{\ensuremath{\mathtt{d}}} (لأننا يجب أن يكون b1,…,bd−1=1,…,1 b_1,\ldots,b_{d-1}=1,\ldots,1). وإذا كان r<w−d r < \ensuremath{\mathtt{w}}-\ensuremath{\mathtt{d}}، فيجب أن يكون bw−r−1,…,bw−r−d=0,…,0 b_{\ensuremath{\mathtt{w}}-r-1},\ldots,b_{\ensuremath{\mathtt{w}}-r-\ensuremath{\mathtt{d}}}=0,\ldots,0 أو bw−r−1,…,bw−r−d=1,…,1 b_{\ensuremath{\mathtt{w}}-r-1},\ldots,b_{\ensuremath{\mathtt{w}}-r-\ensuremath{\mathtt{d}}}=1,\ldots,1. احتمال كلٍّ من هذين الحالتين هو 1/2d 1/2^{\ensuremath{\mathtt{d}}} وهما متعارضتان تبادليًا، ومن ثمّ فإن احتمال إحداهما هو 2/2d 2/2^{\ensuremath{\mathtt{d}}}. وبهذا يكتمل البرهان. ■\qedsymbol

5.1.2 خلاصة تلخّص النظرية التالية أداء بنية بيانات ChainedHashTable: نظرية 5.1 *تنفّذ ChainedHashTable واجهة USet. وتجاهلًا لتكلفة استدعاءات grow() \mathtt{grow()}، يدعم ChainedHashTable العمليات add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} و find(x) \mathtt{find(x)} في زمن متوقَّع قدره O(1) O(1) لكل عملية. * وفضًا عن ذلك، فإن البدء من ChainedHashTable فارغ وأي تسلسل عدده m m من عمليات add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} ينتج عنهما زمن كلي قدره O(m) O(m) مُنفَقًا خلال جميع استدعاءات grow() \mathtt{grow()}.

حواشٍ

... عدد صحيح.5.1 ينطبق هذا على معظم لغات البرمجة ومنها C و C# و C++ و Java. ومن أبرز الاستثناءات Python و Ruby، إذ إن نتيجة عملية على عدد صحيح بطول ثابت قدره w \mathtt{w} بت يفيض عن هذا الطول تُرقَّى إلى تمثيل بطول متغيّر. opendatastructures.org

5.2 LinearHashTable: الاستكشاف الخطي

الأقسام الفرعية

5.2 LinearHashTable: الاستكشاف الخطي

تستخدم بنية بيانات ChainedHashTable مصفوفة من القوائم، حيث تخزّن القائمة رقم i \mathtt{i} جميع العناصر x \mathtt{x} التي تتحقق hash(x)=i \ensuremath{\mathtt{hash(x)}}=\ensuremath{\mathtt{i}}. وهناك بديل يُسمّى العنونة المفتوحة (open addressing) هو تخزين العناصر مباشرةً في مصفوفة، t \mathtt{t}، بحيث يخزّن كل موضع في t \mathtt{t} قيمة واحدة على الأكثر. وهذا هو المنهج الذي يتبعه LinearHashTable الموصوف في هذا القسم. وتُسمّى هذه البنية في بعض المواضع «عنونة مفتوحة مع استكشاف خطي». وتتمثّل الفكرة الأساسية خلف LinearHashTable في أننا نودّ، مثاليًا، تخزين العنصر x \mathtt{x} ذي قيمة التجزئة i=hash(x) \mathtt{i=hash(x)} في موضع الجدول t[i] \mathtt{t[i]}. وإذا تعذّر ذلك (لأن عنصرًا ما مخزَّن هناك بالفعل) نحاول تخزينه عند الموضع t[(i+1) mod t.length] \ensuremath{\mathtt{t}}[(\ensuremath{\mathtt{i}}+1)\bmod\ensuremath{\mathtt{t.length}}]؛ وإذا لم يكن ذلك ممكنًا، نحاول t[(i+2) mod t.length] \ensuremath{\mathtt{t}}[(\ensuremath{\mathtt{i}}+2)\bmod\ensuremath{\mathtt{t.length}}] وهكذا، حتى نجد مكانًا لـ x \mathtt{x}. وهناك ثلاثة أنواع من المداخل المخزَّنة في t \mathtt{t}:

  1. قيم البيانات: القيم الفعلية في USet التي نمثّلها؛
  2. قيم null \mathtt{null}: عند المواضع التي لم تُخزَّن فيها أي بيانات من قبل؛ و
  3. قيم del \mathtt{del}: عند المواضع التي خُزِّنت فيها بيانات في وقت ما ثم حُذفت منذ ذلك الحين.

إلى جانب العدّاد n \mathtt{n} الذي يتتبّع عدد العناصر في LinearHashTable، فإن عدّادًا آخر، q \mathtt{q}، يتتبّع عدد عناصر النوعين 1 و3. أي أن q \mathtt{q} يساوي n \mathtt{n} مضافًا إليه عدد قيم del \mathtt{del} في t \mathtt{t}. ولجعل هذا يعمل بكفاءة نحتاج إلى أن يكون t \mathtt{t} أكبر بكثير من q \mathtt{q}، بحيث تتوفّر في null \mathtt{null} قيَم t \mathtt{t} كثيرة. وتحافظ عمليات LinearHashTable بالتالي على الشرط الثابت t.length≥2q \ensuremath{\mathtt{t.length}}\ge 2\ensuremath{\mathtt{q}}. وباختصار، يحتوي LinearHashTable على مصفوفة، t \mathtt{t}، تخزّن عناصر البيانات، وعلى عددين صحيحين n \mathtt{n} و q \mathtt{q} يتتبّعان على التوالي عدد عناصر البيانات وعدد القيم غير null \mathtt{null} في t \mathtt{t}. ولأن كثيرًا من دوال التجزئة لا تعمل إلا مع أحجام جداول تكون قوة للعدد 2، فنحن نحتفظ أيضًا بعدد صحيح d \mathtt{d} ونحافظ على الشرط الثابت t.length=2d \ensuremath{\mathtt{t.length}}=2^\ensuremath{\mathtt{d}}.

    T[] t;   // the table
    int n;   // the size
    int d;   // t.length = 2^d
    int q;   // number of non-null entries in t

عملية find(x) \mathtt{find(x)} في LinearHashTable بسيطة. نبدأ عند المدخل t[i] \mathtt{t[i]} في المصفوفة حيث i=hash(x) \ensuremath{\mathtt{i}}=\ensuremath{\mathtt{hash(x)}}، ونفحص المداخل t[i] \mathtt{t[i]} و t[(i+1) mod t.length] \ensuremath{\mathtt{t}}[(\ensuremath{\mathtt{i}}+1)\bmod \ensuremath{\mathtt{t.length}}] و t[(i+2) mod t.length] \ensuremath{\mathtt{t}}[(\ensuremath{\mathtt{i}}+2)\bmod \ensuremath{\mathtt{t.length}}] وهكذا، حتى نجد فهرسًا i′ \mathtt{i'} بحيث إمّا t[i′]=x \mathtt{t[i']=x} أو t[i′]=null \mathtt{t[i']=null}. في الحالة الأولى نُرجع t[i′] \mathtt{t[i']}. وفي الحالة الثانية نستنتج أن x \mathtt{x} غير موجود في جدول التجزئة ونُرجع null \mathtt{null}.

    T find(T x) {
        int i = hash(x);
        while (t[i] != null) {
            if (t[i] != del && x.equals(t[i])) return t[i];
            i = (i == t.length-1) ? 0 : i + 1; // increment i
        }
        return null;
    }

عملية add(x) \mathtt{add(x)} سهلة التنفيذ أيضًا. وبعد التحقق من أن x \mathtt{x} غير مخزَّن بالفعل في الجدول (باستخدام find(x) \mathtt{find(x)})، نفحص t[i] \mathtt{t[i]} و t[(i+1) mod t.length] \ensuremath{\mathtt{t}}[(\ensuremath{\mathtt{i}}+1)\bmod \ensuremath{\mathtt{t.length}}] و t[(i+2) mod t.length] \ensuremath{\mathtt{t}}[(\ensuremath{\mathtt{i}}+2)\bmod \ensuremath{\mathtt{t.length}}] وهكذا، حتى نجد null \mathtt{null} أو del \mathtt{del} فنخزّن x \mathtt{x} عند ذلك الموضع، ونزيد n \mathtt{n} و q \mathtt{q} بحسب ما يناسب.

    boolean add(T x) {
        if (find(x) != null) return false;
        if (2*(q+1) > t.length) resize(); // max 50% occupancy
        int i = hash(x);
        while (t[i] != null && t[i] != del)
            i = (i == t.length-1) ? 0 : i + 1; // increment i
        if (t[i] == null) q++;
        n++;
        t[i] = x;
        return true;
    }

والآن أصبح تنفيذ عملية remove(x) \mathtt{remove(x)} واضحًا. نفحص t[i] \mathtt{t[i]} و t[(i+1) mod t.length] \ensuremath{\mathtt{t}}[(\ensuremath{\mathtt{i}}+1)\bmod \ensuremath{\mathtt{t.length}}] و t[(i+2) mod t.length] \ensuremath{\mathtt{t}}[(\ensuremath{\mathtt{i}}+2)\bmod \ensuremath{\mathtt{t.length}}] وهكذا حتى نجد فهرسًا i′ \mathtt{i'} بحيث t[i′]=x \mathtt{t[i']=x} أو t[i′]=null \mathtt{t[i']=null}. في الحالة الأولى نضبط t[i′]=del \mathtt{t[i']=del} ونُرجع true \mathtt{true}. وفي الحالة الثانية نستنتج أن x \mathtt{x} لم يكن مخزَّنًا في الجدول (وبالتالي لا يمكن حذفه) ونُرجع false \mathtt{false}.

    T remove(T x) {
        int i = hash(x);
        while (t[i] != null) {
            T y = t[i];
            if (y != del && x.equals(y)) { 
                t[i] = del;
                n--;
                if (8*n < t.length) resize(); // min 12.5% occupancy
                return y;
            }
            i = (i == t.length-1) ? 0 : i + 1;  // increment i
        }
        return null;
    }

صحة الدوال find(x) \mathtt{find(x)} و add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} سهلة التحقّق، وإن كانت تعتمد على استخدام قيم del \mathtt{del}. ولاحظ أن أيًّا من هذه العمليات لا تضبط مدخلًا غير null \mathtt{null} ليصبح null \mathtt{null} أبدًا. لذلك، عندما نصل إلى فهرس i′ \mathtt{i'} بحيث t[i′]=null \mathtt{t[i']=null}، فإن هذا برهان على أن العنصر x \mathtt{x} الذي نبحث عنه غير مخزَّن في الجدول؛ فمدخل t[i′] \mathtt{t[i']} كان null \mathtt{null} دائمًا، فلا يوجد سبب يجعل عملية add(x) \mathtt{add(x)} سابقة أن تتجاوز الفهرس i′ \mathtt{i'}. وتُستدعى الدالة resize() \mathtt{resize()} من add(x) \mathtt{add(x)} عندما يتجاوز عدد المداخل غير null \mathtt{null} القيمة t.length/2 \ensuremath{\mathtt{t.length}}/2، أو من remove(x) \mathtt{remove(x)} عندما يصبح عدد مداخل البيانات أقل من t.length/8 \mathtt{t.length/8}. وتعمل الدالة resize() \mathtt{resize()} كما تعمل دوال resize() \mathtt{resize()} في بنى البيانات الأخرى المعتمدة على المصفوفات. فنجد أصغر عدد صحيح غير سالب d \mathtt{d} بحيث 2d≥3n 2^{\ensuremath{\mathtt{d}}} \ge 3\ensuremath{\mathtt{n}}. ثم نعيد تخصيص المصفوفة t \mathtt{t} لتصبح ذات حجم 2d 2^{\ensuremath{\mathtt{d}}}، وبعدها نُدرج جميع عناصر النسخة القديمة من t \mathtt{t} في النسخة المعاد تحجيمها من t \mathtt{t}. وأثناء ذلك نعيد ضبط q \mathtt{q} مساويةً لـ n \mathtt{n}، لأن t \mathtt{t} المعاد تخصيصه لا يحتوي على قيم del \mathtt{del}.

    void resize() {
        d = 1;
        while ((1<<d) < 3*n) d++;
        T[] told = t;
        t = newArray(1<<d);
        q = n;
        // insert everything from told
        for (int k = 0; k < told.length; k++) {
            if (told[k] != null && told[k] != del) {
                int i = hash(told[k]);
                while (t[i] != null) 
                    i = (i == t.length-1) ? 0 : i + 1;
                t[i] = told[k];
            }
        }
    }

5.2.1 تحليل الاستكشاف الخطي لاحظ أن كل عملية، add(x) \mathtt{add(x)} أو remove(x) \mathtt{remove(x)} أو find(x) \mathtt{find(x)}، تنتهي بمجرد اكتشافها (أو قبل ذلك) أول مدخل null \mathtt{null} في t \mathtt{t}. والبديهية الكامنة خلف تحليل الاستكشاف الخطي هي أن نصف عناصر t \mathtt{t} على الأقل تساوي null \mathtt{null}، وبالتالي فلا ينبغي أن تستغرق العملية وقتًا طويلًا لإتمامها لأنها ستصطدم سريعًا جدًا بمدخل null \mathtt{null}. ومع ذلك فلا ينبغي أن نعتمد على هذه البديهية كثيرًا، لأنها ستقودنا إلى استنتاج (خاطئ) بأن عدد المواضع المتوقَّع في t \mathtt{t} التي تفحصها عملية ما هو 2 على الأكثر. وفي بقية هذا القسم سنفترض أن جميع قيم التجزئة موزَّعة بشكل مستقل وموحَّد على {0,…,t.length−1} \{0,\ldots,\ensuremath{\mathtt{t.length}}-1\}. وهذا افتراض غير واقعي، لكنه سيمكّننا من تحليل الاستكشاف الخطي. وسنصف لاحقًا في هذا القسم طريقة تُسمّى التجزئة بالجداول (tabulation hashing) تُنتج دالة تجزئة «جيدة بما يكفي» للاستكشاف الخطي. وسنفترض كذلك أن جميع الفهارس المستخدَمة في مواضع t \mathtt{t} محسوبة بمقياس t.length \mathtt{t.length}، بحيث يكون t[i] \mathtt{t[i]} اختصارًا فعليًا لـ t[i mod t.length] \ensuremath{\mathtt{t}}[\ensuremath{\mathtt{i}}\bmod\ensuremath{\mathtt{t.length}}]. نقول إن مقطعًا (run) طوله k k يبدأ عند i \mathtt{i} يقع عندما تكون جميع مداخل الجدول t[i],t[i+1],…,t[i+k−1] \ensuremath{\mathtt{t[i]}}, \ensuremath{\mathtt{t[i+1]}},\ldots,\ensuremath{\mathtt{t}}[\ensuremath{\mathtt{i}}+k-1] غير null \mathtt{null} و t[i−1]=t[i+k]=null \ensuremath{\mathtt{t}}[\ensuremath{\mathtt{i}}-1]=\ensuremath{\mathtt{t}}[\ensuremath{\mathtt{i}}+k]=\ensuremath{\mathtt{null}}. وعدد العناصر غير null \mathtt{null} في t \mathtt{t} هو بالضبط q \mathtt{q}، وتضمن الدالة add(x) \mathtt{add(x)} في كل الأوقات أن q≤t.length/2 \ensuremath{\mathtt{q}}\le\ensuremath{\mathtt{t.length}}/2. وهناك q \mathtt{q} عنصرًا x1,…,xq \ensuremath{\mathtt{x}}_1,\ldots,\ensuremath{\mathtt{x}}_{\ensuremath{\mathtt{q}}} أُدرجت في t \mathtt{t} منذ آخر عملية rebuild() \mathtt{rebuild()}. وافتراضنا، فإن لكلٍّ منها قيمة تجزئة، hash(xj) \ensuremath{\mathtt{hash}}(\ensuremath{\mathtt{x}}_j)، موزَّعة بانتظام ومستقلة عن البقية. ومع هذا الإعداد يمكننا إثبات المتراجمة الرئيسية اللازمة لتحليل الاستكشاف الخطي. المتراجمة 5.4 ثبّت قيمة i∈{0,…,t.length−1} \ensuremath{\mathtt{i}}\in\{0,\ldots,\ensuremath{\mathtt{t.length}}-1\}. عندئذ يكون احتمال أن مقطعًا طوله k k يبدأ عند i \mathtt{i} هو O(ck) O(c^k) حيث 0<c<1 0<c<1 ثابت.

إثبات. إذا وقع مقطع طوله k k يبدأ عند i \mathtt{i}، فإن هناك بالضبط k k عنصرًا xj \ensuremath{\mathtt{x}}_j بحيث hash(xj)∈{i,…,i+k−1} \ensuremath{\mathtt{hash}}(\ensuremath{\mathtt{x}}_j)\in\{\ensuremath{\mathtt{i}},\ldots,\ensuremath{\mathtt{i}}+k-1\}. واحتمال حدوث ذلك هو بالضبط

إذ إن كل اختيار لـ k k عنصرًا يفرض أن تُجزَّأ هذه k k عناصر إلى أحد مواضع k k، وأن تُجزَّأ العناصر الـ q−k \ensuremath{\mathtt{q}}-k الباقية إلى بقية مواضع الجدول وعددها t.length−k \ensuremath{\mathtt{t.length}}-k.5.2

في الاشتقاق التالي سنحتال قليلًا ونستبدل r! r! بـ (r/e)r (r/e)^r. فتقريب ستيرلنغ (Stirling's Approximation) (القسم 1.3.2) يبيّن أن هذا لا يبتعد عن الحقيقة إلا بعامل قدره O(r) O(\sqrt{r}). وقد لجأنا إلى ذلك لمجرّد تبسيط الاشتقاق؛ إذ يطلب التمرين 5.4 من القارئ إعادة الحساب بمنتهى الدقة باستخدام تقريب ستيرلنغ كاملًا. وقيمة pk p_k تبلغ عظمىها عندما تكون t.length \mathtt{t.length} أصغر ما يمكن، وبنية البيانات تحافظ على الشرط الثابت t.length≥2q \ensuremath{\mathtt{t.length}} \ge 2\ensuremath{\mathtt{q}}، إذن

pk\displaystyle p_k المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 2
المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 3
المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 4
المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 5
المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 6
=(12)k((2q−k)2(q−k))q−k\displaystyle = \left(\frac{1}{2}\right)^k \left(\frac{(2\ensuremath{\mathtt{q}}-k)}{2(\ensuremath{\mathtt{q}}-k)}\right)^{\ensuremath{\mathtt{q}}-k}
=(12)k(1+k2(q−k))q−k\displaystyle = \left(\frac{1}{2}\right)^k \left(1+\frac{k}{2(\ensuremath{\mathtt{q}}-k)}\right)^{\ensuremath{\mathtt{q}}-k}
≤(e2)k.\displaystyle \le \left(\frac{\sqrt{e}}{2}\right)^k \enspace .

(في الخطوة الأخيرة نستخدم المتراجحة (1+1/x)x≤e (1+1/x)^x \le e، التي تتحقّق لكل x>0 x>0.) وبما أن e/2<0.824360636<1 \sqrt{e}/{2}< 0.824360636 < 1، فقد اكتمل البرهان. ■\qedsymbol

أصبح استخدام المتراجمة 5.4 لإثبات الحدود العليا لزمن التشغيل المتوقَّع للدوال find(x) \mathtt{find(x)} و add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} سهلًا إلى حدٍّ ما. لنفحص أبسط حالة: أن نُنفّذ find(x) \mathtt{find(x)} لقيمة x \mathtt{x} لم تُخزَّن قط في LinearHashTable. في هذه الحالة تكون i=hash(x) \ensuremath{\mathtt{i}}=\ensuremath{\mathtt{hash(x)}} قيمة عشوائية في {0,…,t.length−1} \{0,\ldots,\ensuremath{\mathtt{t.length}}-1\} مستقلة عن محتوى t \mathtt{t}. وإذا كان i \mathtt{i} جزءًا من مقطع طوله k k، فإن الزمن اللازم لتنفيذ عملية find(x) \mathtt{find(x)} هو O(1+k) O(1+k) على الأكثر. ومن ثمّ يمكن تحديد حدّ أعلى لزمن التشغيل المتوقَّع بـ

لاحظ أن كل مقطع طوله k k يساهم في المجموع الداخلي k k مرة، أي بمساهمة إجمالية قدرها k2 k^2، ومن ثمّ يمكن إعادة كتابة المجموع أعلاه على الصورة

المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 7
≤O(1+(1t.length)∑i=1t.length∑k=0∞k2pk)\displaystyle \le O\left(1 + \left(\frac{1}{\ensuremath{\mathtt{t.length}}}\right)\sum_{i=1}^{\ensuremath{\mathtt{t.length}}}\sum_{k=0}^{\infty} k^2p_k\right)
=O(1+∑k=0∞k2pk)\displaystyle = O\left(1 + \sum_{k=0}^{\infty} k^2p_k\right)
=O(1+∑k=0∞k2⋅O(ck))\displaystyle = O\left(1 + \sum_{k=0}^{\infty} k^2\cdot O(c^k)\right)
=O(1).\displaystyle = O(1) \enspace .

تأتي الخطوة الأخيرة في هذا الاشتقاق من أن ∑k=0∞k2⋅O(ck) \sum_{k=0}^{\infty} k^2\cdot O(c^k) متتالية متناقصة أُسّيًا.5.3لذلك نستنتج أن زمن التشغيل المتوقَّع لعملية find(x) \mathtt{find(x)} على قيمة x \mathtt{x} غير الموجودة في LinearHashTable هو O(1) O(1).

إذا تجاهلنا تكلفة عملية resize() \mathtt{resize()}، فإن التحليل أعلاه يمكّننا من كل ما نحتاجه لتحليل تكلفة العمليات على LinearHashTable. أولًا، فإن تحليل find(x) \mathtt{find(x)} أعلاه ينطبق على عملية add(x) \mathtt{add(x)} عندما لا يكون x \mathtt{x} موجودًا في الجدول. ولتحليل عملية find(x) \mathtt{find(x)} عندما يكون x \mathtt{x} موجودًا في الجدول، يكفي أن نلاحظ أن ذلك هو نفسه تكلفة عملية add(x) \mathtt{add(x)} التي كانت قد أضافت x \mathtt{x} إلى الجدول. وأخيرًا، تكلفة عملية remove(x) \mathtt{remove(x)} هي نفسها تكلفة عملية find(x) \mathtt{find(x)}. وباختصار، إذا تجاهلنا تكلفة استدعاءات resize() \mathtt{resize()}، فإن جميع العمليات على LinearHashTable تعمل بزمن متوقَّع قدره O(1) O(1). ويمكن احتساب تكلفة إعادة التحجيم (resize) باستخدام التحليل المُستهلَك (amortized analysis) نفسه الذي أُجري لبنية بيانات ArrayStack في القسم 2.1. 5.2.2 خلاصة تلخّص النظرية التالية أداء بنية بيانات LinearHashTable: نظرية 5.2 *تنفّذ LinearHashTable واجهة USet. وتجاهلًا لتكلفة استدعاءات resize() \mathtt{resize()}، يدعم LinearHashTable العمليات add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} و find(x) \mathtt{find(x)} في زمن متوقَّع قدره O(1) O(1) لكل عملية. * وفضًا عن ذلك، فإن البدء من LinearHashTable فارغ وأي تسلسل عدده m m من عمليات add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} ينتج عنهما زمن كلي قدره O(m) O(m) مُنفَقًا خلال جميع استدعاءات resize() \mathtt{resize()}.

5.2.3 التجزئة بالجداول

أثناء تحليل بنية LinearHashTable، أتينا بافتراض قوي جدًا: أن قيم التجزئة لأي مجموعة عناصر {x1,…,xn} \{\ensuremath{\mathtt{x}}_1,\ldots,\ensuremath{\mathtt{x}}_\ensuremath{\mathtt{n}}\}، أي hash( \ensuremath{\mathtt{hash}}(1),…,hash(xn) _1),\ldots,\ensuremath{\mathtt{hash}}(\ensuremath{\mathtt{x}}_\ensuremath{\mathtt{n}})، موزَّعة بشكل مستقل وموحَّد على المجموعة {0,…,t.length−1} \{0,\ldots,\ensuremath{\mathtt{t.length}}-1\}. وإحدى طرق تحقيق ذلك هي تخزين مصفوفة ضخمة، tab \mathtt{tab}، طولها 2w 2^{\ensuremath{\mathtt{w}}}، يكون فيها كل مدخل عددًا صحيحًا عشوائيًا بطول w \mathtt{w} بت مستقلًا عن جميع المداخل الأخرى. وبهذه الطريقة يمكننا تنفيذ hash(x) \mathtt{hash(x)} باستخراج عدد صحيح بطول d \mathtt{d} بت من tab[x.hashCode()] \mathtt{tab[x.hashCode()]}:

    int idealHash(T x) {
        return tab[x.hashCode() >>> w-d];
    }

للأسف، فإن تخزين مصفوفة بحجم 2w 2^{\ensuremath{\mathtt{w}}} مكلف جدًا من حيث استهلاك الذاكرة. والأسلوب الذي تعتمده التجزئة بالجداول هو، بدلًا من ذلك، معاملة الأعداد الصحيحة بطول w \mathtt{w} بت كأنها مكوَّنة من w/r \ensuremath{\mathtt{w}}/\ensuremath{\mathtt{r}} عددًا صحيحًا، كل واحد منها بطول r \ensuremath{\mathtt{r}} بت فقط. وبهذه الطريقة، لا تحتاج التجزئة بالجداول إلا إلى w/r \ensuremath{\mathtt{w}}/\ensuremath{\mathtt{r}} مصفوفة، كلٌّ منها بطول 2r 2^{\ensuremath{\mathtt{r}}}. وكل المداخل في هذه المصفوفات أعداد صحيحة عشوائية مستقلة بطول w \mathtt{w} بت. وللحصول على قيمة hash(x) \mathtt{hash(x)} نقسّم x.hashCode() \mathtt{x.hashCode()} إلى w/r \ensuremath{\mathtt{w}}/\ensuremath{\mathtt{r}} عددًا صحيحًا بطول r \mathtt{r} بت ونستخدمها كفهارس في هذه المصفوفات. ثم ندمج كل هذه القيم بمعامل XOR على مستوى البتات للحصول على hash(x) \mathtt{hash(x)}. ويبيّن الشيفرة التالية كيف يعمل هذا عندما w=32 \ensuremath{\mathtt{w}}=32 و r=4 \ensuremath{\mathtt{r}}=4:

    int hash(T x) {
        int h = x.hashCode();
        return (tab[0][h&0xff] 
                 ^ tab[1][(h>>>8)&0xff]
                 ^ tab[2][(h>>>16)&0xff] 
                 ^ tab[3][(h>>>24)&0xff])
                  >>> (w-d);
    }

في هذه الحالة يكون tab \mathtt{tab} مصفوفة ثنائية الأبعاد بأربعة أعمدة و 232/4=256 2^{32/4}=256 صفًا. ويمكن التحقّق بسهولة من أن كل قيمة x \mathtt{x} لها رمز تجزئة hash(x) \mathtt{hash(x)} موزَّع بانتظام على {0,…,2d−1} \{0,\ldots,2^{\ensuremath{\mathtt{d}}}-1\}. وجهدٍ قليل يمكن حتى التحقّق من أن قيم التجزئة لأي زوج من القيم مستقلة. وهذا يعني أن في وسعنا استخدام التجزئة بالجداول بدلًا من التجزئة الضربية في تنفيذ ChainedHashTable. غير أنه ليس صحيحًا أن أي مجموعة من n \mathtt{n} قيم متمايزة تعطي مجموعة من n \mathtt{n} قيم تجزئة مستقلة. ومع ذلك، فعند استخدام التجزئة بالجداول يظل حدّ النظرية 5.2 ساريًا. أما المراجع اللازمة فتوجد في نهاية هذا الفصل.

حواشٍ

... المواضع.5.2 لاحظ أن pk p_k أكبر من احتمال أن مقطعًا طوله k k يبدأ عند i \mathtt{i}، لأن تعريف pk p_k لا يتضمّن اشتراط t[i−1]=t[i+k]=null \ensuremath{\mathtt{t}}[\ensuremath{\mathtt{i}}-1]=\ensuremath{\mathtt{t}}[\ensuremath{\mathtt{i}}+k]=\ensuremath{\mathtt{null}}. ... المتتالية.5.3 ولغة كثير من كتب التفاضل والتكامل، تُجتاز هذه المتتالية اختبار النسبة: يوجد عدد صحيح موجب k0 k_0 بحيث إن كان k≥k0 k\ge k_0 فإن (k+1)2ck+1k2ck<1 \frac{(k+1)^2c^{k+1}}{k^2c^k} < 1. opendatastructures.org

5.3 رموز التجزئة

الأقسام الفرعية

5.3 رموز التجزئة

تُستخدم جداول التجزئة التي ناقشناها في القسم السابق لربط البيانات بمفاتيح صحيحة مكوَّنة من w \mathtt{w} بت. وفي كثير من الحالات تكون لدينا مفاتيح ليست أعدادًا صحيحة؛ قد تكون نصوصًا (strings) أو كائنات (objects) أو مصفوفات أو بنى مركّبة أخرى. ولاستخدام جداول التجزئة مع أنواع البيانات هذه، علينا ربطها برموز تجزئة (hash codes) بطول w \mathtt{w} بت. وينبغي أن توفّر ربطات رموز التجزئة الخصائص التالية: إذا كان x \mathtt{x} و y \mathtt{y} متساويين، فإن x.hashCode() \mathtt{x.hashCode()} و y.hashCode() \mathtt{y.hashCode()} متساويان. وإذا كان x \mathtt{x} و y \mathtt{y} غير متساويين، فيجب أن يكون احتمال x.hashCode()=y.hashCode() \ensuremath{\mathtt{x.hashCode()}}=\ensuremath{\mathtt{y.hashCode()}} صغيرًا (قريبًا من 1/2w 1/2^{\ensuremath{\mathtt{w}}}). والخاصية الأولى تضمن أنه إذا خزّنّا x \mathtt{x} في جدول تجزئة ثم بحثنا لاحقًا عن قيمة y \mathtt{y} مساوية لـ x \mathtt{x}، فسنجد x \mathtt{x} -- كما ينبغي. أما الخاصية الثانية فتُقلّل الخسارة الناتجة عن تحويل كائناتنا إلى أعداد صحيحة. فهي تضمن أن الكائنات غير المتساوية تملك عادةً رموز تجزئة مختلفة، ومن ثمّ يُرجَّح أن تُخزَّن في مواضع مختلفة في جدول التجزئة لدينا. 5.3.1 رموز التجزئة لأنواع البيانات البدائية عادةً ما يكون إيجاد رموز تجزئة للأنواع البدائية الصغيرة مثل char \mathtt{char} و byte \mathtt{byte} و int \mathtt{int} و float \mathtt{float} سهلًا. فلهذه الأنواع تمثيل ثنائي دائمًا، وهذا التمثيل الثنائي يتكوّن عادةً من w \mathtt{w} بت أو أقل. (على سبيل المثال، في Java، byte \mathtt{byte} نوع بطول 8 بت و float \mathtt{float} نوع بطول 32 بت.) وفي هذه الحالات، نتعامل مع هذه البتّات بوصفها تمثيلًا لعدد صحيح ضمن المدى {0,…,2w−1} \{0,\ldots,2^\ensuremath{\mathtt{w}}-1\}. فإذا اختلفت قيمتان فستحصلان على رمزي تجزئة مختلفين، وإذا تساوتا فستحصلان على رمز التجزئة نفسه. وهناك القليل من أنواع البيانات البدائية المكوَّنة من أكثر من w \mathtt{w} بت، عادةً cw c\ensuremath{\mathtt{w}} بت حيث c c عدد صحيح ثابت. (ومن أمثلتها نوعا long \mathtt{long} و double \mathtt{double} في Java مع c=2 c=2.) ويمكن التعامل مع أنواع البيانات هذه بوصفها كائنات مركّبة من c c أجزاء، كما سيأتي في القسم التالي. 5.3.2 رموز التجزئة للكائنات المركّبة بالنسبة إلى كائن مركّب، نريد إنشاء رمز تجزئة بدمج رموز التجزئة الفردية لأجزاء الكائن المكوّنة له. وهذا ليس بالأمر السهل كما يبدو. فعلى الرغم من وجود طرق كثيرة — بل متناهية — للقيام بذلك (مثلًا دمج رموز التجزئة بعمليات XOR على مستوى البتات)، إلا أن كثيرًا من هذه الحيل يسهل إبطالها (انظر التمارين 5.7-5.9). لكن إذا كان المرء مستعدًا لإجراء عمليات حسابية بدقة 2w 2\ensuremath{\mathtt{w}} بت، فإن هناك طرقًا بسيطة ومتينة متاحة. لنفترض لدينا كائن مكوَّن من عدة أجزاء P0,…,Pr−1 P_0,\ldots,P_{r-1} رموز تجزئتها x0,…,xr−1 \ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1}. عندئذ يمكننا اختيار أعداد صحيحة عشوائية مستقلة متبادلة بطول w \mathtt{w} بت هي z0,…,zr−1 \ensuremath{\mathtt{z}}_0,\ldots,\ensuremath{\mathtt{z}}_{r-1}، وعددًا صحيحًا فرديًا عشوائيًا بطول 2w 2\ensuremath{\mathtt{w}} بت هو z \mathtt{z}، ثم نحسب رمز تجزئة لكائننا بمعادلة

لاحظ أن رمز التجزئة هذا يتضمّن خطوة أخيرة (الضرب في z \mathtt{z} والقسمة على 2w 2^{\ensuremath{\mathtt{w}}}) تستخدم دالة التجزئة الضربية من القسم 5.1.1 لتحويل النتيجة الوسيطة ذات 2w 2\ensuremath{\mathtt{w}} بت واختصارها إلى نتيجة نهائية ذات w \mathtt{w} بت. وفيما يلي مثال على تطبيق هذه الطريقة على كائن مركّب بسيط من ثلاثة أجزاء x0 \mathtt{x0} و x1 \mathtt{x1} و x2 \mathtt{x2}:

    int hashCode() {
        // random numbers from rand.org
        long[] z = {0x2058cc50L, 0xcb19137eL, 0x2cb6b6fdL}; 
        long zz = 0xbea0107e5067d19dL;

        // convert (unsigned) hashcodes to long
        long h0 = x0.hashCode() & ((1L<<32)-1);
        long h1 = x1.hashCode() & ((1L<<32)-1);
        long h2 = x2.hashCode() & ((1L<<32)-1);
        
        return (int)(((z[0]*h0 + z[1]*h1 + z[2]*h2)*zz)
                     >>> 32);
    }

تبيّن النظرية التالية أن هذه الطريقة، إلى جانب بساطة تنفيذها، جيدة ويمكن إثبات ذلك:

نظرية 5.3 ليكن x0,…,xr−1 \ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1} و y0,…,yr−1 \ensuremath{\mathtt{y}}_0,\ldots,\ensuremath{\mathtt{y}}_{r-1} كلٌّ منهما تسلسلًا من الأعداد الصحيحة بطول w \mathtt{w} بت ضمن {0,…,2w−1} \{0,\ldots,2^{\ensuremath{\mathtt{w}}}-1\}، ولنفترض أن xi≠yi \ensuremath{\mathtt{x}}_i \neq \ensuremath{\mathtt{y}}_i لفهرس واحد على الأقل i∈{0,…,r−1} i\in\{0,\ldots,r-1\}. عندئذ

Pr⁡{h(x0,…,x......suremathyr−1)}≤3/2w. \displaystyle \Pr\{ h(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_... ...suremath{\mathtt{y}}_{r-1}) \} \le 3/2^{\ensuremath{\mathtt{w}}} \enspace .

إثبات. سنتجاهل أولًا خطوة التجزئة الضربية الأخيرة، ونرى لاحقًا كيف تساهم تلك الخطوة. نعرّف:

لنفترض أن h′(x0,…,xr−1)=h′(y0,…,yr−1) h'(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1}) = h'(\ensuremath{\mathtt{y}}_0,\ldots,\ensuremath{\mathtt{y}}_{r-1}). ويمكن إعادة كتابة ذلك على الصورة:

حيث

t=(∑j=0i−1zj(......ttyj−xj)) mod 22w \displaystyle t = \left(\sum_{j=0}^{i-1} \ensuremath{\mathtt{z}}_j(\ensuremath{... ...tt{y}}_j-\ensuremath{\mathtt{x}}_j)\right) \bmod 2^{2\ensuremath{\mathtt{w}}}

إذا افترضنا، دون حدّ من العمومية، أن xi>yi \ensuremath{\mathtt{x}}_i> \ensuremath{\mathtt{y}}_i، فإن (5.4) يصير

إذ إن كلًا من zi \ensuremath{\mathtt{z}}_i و (xi−yi) (\ensuremath{\mathtt{x}}_i-\ensuremath{\mathtt{y}}_i) هو 2w−1 2^{\ensuremath{\mathtt{w}}}-1 على الأكثر، فإن حاصل ضربهما هو 22w−2w+1+1<22w−1 2^{2\ensuremath{\mathtt{w}}}-2^{\ensuremath{\mathtt{w}}+1}+1 < 2^{2\ensuremath{\mathtt{w}}}-1 على الأكثر. ويفترض أن xi−yi≠0 \ensuremath{\mathtt{x}}_i-\ensuremath{\mathtt{y}}_i\neq 0، ومن ثمّ يكون للمعادلة (5.5) حل واحد على الأكثر في zi \ensuremath{\mathtt{z}}_i. لذلك، وبما أن zi \ensuremath{\mathtt{z}}_i و t t مستقلان ( z0,…,zr−1 \ensuremath{\mathtt{z}}_0,\ldots,\ensuremath{\mathtt{z}}_{r-1} مستقلة متبادلة)، فإن احتمال اختيار zi \ensuremath{\mathtt{z}}_i بحيث h′(x0,…,xr−1)=h′(y0,…,yr−1) h'(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1})=h'(\ensuremath{\mathtt{y}}_0,\ldots,\ensuremath{\mathtt{y}}_{r-1}) هو 1/2w 1/2^{\ensuremath{\mathtt{w}}} على الأكثر.

الخطوة الأخيرة في دالة التجزئة هي تطبيق التجزئة الضربية لاختصار نتيجتنا الوسيطة ذات 2w 2\ensuremath{\mathtt{w}} بت، أي h′(x0,…,xr−1) h'(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1})، إلى نتيجة نهائية ذات w \mathtt{w} بت، أي h(x0,…,xr−1) h(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1}). وبالنظرية 5.3، إذا كان h′(x0,…,xr−1)≠h′(y0,…,yr−1) h'(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1})\neq h'(\ensuremath{\mathtt{y}}_0,\ldots,\ensuremath{\mathtt{y}}_{r-1})، فإن المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 8. وباختصار،

المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 9
المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 10
≤1/2w+2/2w=3/2w.□\displaystyle \le 1/2^{\ensuremath{\mathtt{w}}} + 2/2^{\ensuremath{\mathtt{w}}} = 3/2^{\ensuremath{\mathtt{w}}} \enspace . \qedhere
■ \qedsymbol

5.3.3 رموز التجزئة للمصفوفات والنصوص تنجح طريقة القسم السابق جيدًا مع الكائنات التي لها عدد ثابت من المكوّنات. غير أنها تنهار عندما نريد استخدامها مع كائنات ذات عدد متغيّر من المكوّنات، لأنها تتطلّب عددًا صحيحًا عشوائيًا بطول w \mathtt{w} بت هو zi \ensuremath{\mathtt{z}}_i لكل مكوّن. يمكننا استخدام تسلسل شبه عشوائي لتوليد أكبر عدد نحتاجه من zi \ensuremath{\mathtt{z}}_i، لكن عندئذٍ لا تكون zi \ensuremath{\mathtt{z}}_i مستقلة متبادلة، ويصعب عندئذٍ إثبات أن الأعداد شبه العشوائية لا تتفاعل بصورة ضارة مع دالة التجزئة التي نستخدمها. وعلى وجه التحديد، لا تعود قيمتا t t و zi \ensuremath{\mathtt{z}}_i في برهان النظرية 5.3 مستقلتين. وهناك نهج أدقّ هو بناء رموز التجزئة على متعددات الحدود (polynomials) فوق الحقول الأولية (prime fields)؛ وهذه ليست سوى متعددات حدود عادية تُقَيَّم بمقياس عدد أولي ما، p \mathtt{p}. ويقوم هذا النهج على النظرية التالية التي تقول إن متعددات الحدود فوق الحقول الأولية تتصرف إلى حدٍّ كبير كالمتعددات الحدود المعتادة: نظرية 5.4 ليكن p \ensuremath{\mathtt{p}} عددًا أوليًا، وليكن المعادلة الأصلية: تحليل الاحتمالات ودوال التجزئة في جداول التجزئة، الصيغة 11 متعدد حدود غير تافه معاملات xi∈{0,…,p−1} \ensuremath{\mathtt{x}}_i\in\{0,\ldots,\ensuremath{\mathtt{p}}-1\}. عندئذ تكون للمعادلة f(z) mod p=0 f(\ensuremath{\mathtt{z}})\bmod \ensuremath{\mathtt{p}} = 0 حلول عددها r−1 r-1 على الأكثر من أجل z∈{0,…,p−1} \ensuremath{\mathtt{z}}\in\{0,\ldots,p-1\}.

لاستخدام النظرية 5.4، نجزّئ تسلسلًا من الأعداد الصحيحة x0,…,xr−1 \ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1}، حيث كل xi∈{0,…,p−2} \ensuremath{\mathtt{x}}_i\in \{0,\ldots,\ensuremath{\mathtt{p}}-2\}، باستخدام عدد صحيح عشوائي z∈{0,…,p−1} \ensuremath{\mathtt{z}}\in\{0,\ldots,\ensuremath{\mathtt{p}}-1\} عبر الصيغة

لاحظ الحدّ الإضافي (p−1)zr (\ensuremath{\mathtt{p}}-1)\ensuremath{\mathtt{z}}^r في نهاية الصيغة. ومن المفيد التفكير في (p−1) (\ensuremath{\mathtt{p}}-1) بوصفه العنصر الأخير، xr \ensuremath{\mathtt{x}}_r، في التسلسل x0,…,xr \ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r}. ولاحظ أن هذا العنصر يختلف عن كل عنصر آخر في التسلسل (وكلٌّ منها يقع في المجموعة {0,…,p−2} \{0,\ldots,\ensuremath{\mathtt{p}}-2\}). ويمكننا أن نرى في p−1 \ensuremath{\mathtt{p}}-1 علامة نهاية تسلسل. وتبيّن النظرية التالية، التي تتناول حالة تسلسلين بالطول نفسه، أن دالة التجزئة هذه تحقّق عائدًا جيدًا بالمقابل مع مقدار التوزيع العشوائي المحدود اللازم لاختيار z \mathtt{z}: نظرية 5.5 ليكن p>2w+1 \ensuremath{\mathtt{p}}>2^{\ensuremath{\mathtt{w}}}+1 عددًا أوليًا، وليكن x0,…,xr−1 \ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1} و y0,…,yr−1 \ensuremath{\mathtt{y}}_0,\ldots,\ensuremath{\mathtt{y}}_{r-1} كلٌّ منهما تسلسلًا من الأعداد الصحيحة بطول w \mathtt{w} بت ضمن {0,…,2w−1} \{0,\ldots,2^{\ensuremath{\mathtt{w}}}-1\}، ولنفترض أن xi≠yi \ensuremath{\mathtt{x}}_i \neq \ensuremath{\mathtt{y}}_i لفهرس واحد على الأقل i∈{0,…,r−1} i\in\{0,\ldots,r-1\}. عندئذ

Pr⁡{h(x0,…,x......mathyr−1)}≤(r−1)/p}. \displaystyle \Pr\{ h(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_... ...math{\mathtt{y}}_{r-1}) \} \le (r-1)/\ensuremath{\mathtt{p}} \} \enspace .

إثبات. يمكن إعادة كتابة المعادلة h(x0,…,xr−1)=h(y0,…,yr−1) h(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1}) = h(\ensuremath{\mathtt{y}}_0,\ldots,\ensuremath{\mathtt{y}}_{r-1}) على الصورة

بما أن xi≠yi \ensuremath{\mathtt{x}}_\ensuremath{\mathtt{i}}\neq \ensuremath{\mathtt{y}}_\ensuremath{\mathtt{i}}، فإن هذا المتعدد غير تافه. لذلك، وبالنظرية 5.4، فإن له حلولًا عددها r−1 r-1 على الأكثر في z \mathtt{z}. ومن ثمّ فإن احتمال أن نختار z \mathtt{z} ليكون أحد هذه الحلول هو (r−1)/p (r-1)/\ensuremath{\mathtt{p}} على الأكثر. ■\qedsymbol

لاحظ أن دالة التجزئة هذه تتعامل أيضًا مع الحالة التي يكون فيها التسلسلان مختلفي الطول، حتى عندما يكون أحد التسلسلين بادئةً (prefix) للآخر. وذلك لأن هذه الدالة تُجزّئ فعليًا التسلسل اللانهائي

x0,…,xr−1,p−1,0,0,…. \displaystyle \ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1}, \ensuremath{\mathtt{p}}-1,0,0,\ldots \enspace .

وهذا يضمن أنه إذا كان لدينا تسلسلان بطول r r و r′ r' مع r>r′ r > r'، فإن هذين التسلسلين يختلفان عند الفهرس i=r i=r. وفي هذه الحالة، تصير (5.6)

(∑i=0i=r′−1(xi−......nsuremathzr) mod p=0, \displaystyle \left( \sum_{i=0}^{i=r'-1}(\ensuremath{\mathtt{x}}_i-\ensuremath... ...nsuremath{\mathtt{z}}^{r} \right)\bmod \ensuremath{\mathtt{p}} = 0 \enspace ,

والتي، بحسب النظرية 5.4، لها حلول عددها r r على الأكثر في z \ensuremath{\mathtt{z}}. وهذا مقترنًا بالنظرية 5.5 يكفي لإثبات النظرية الأكثر عمومية التالية:

نظرية 5.6 ليكن p>2w+1 \ensuremath{\mathtt{p}}>2^{\ensuremath{\mathtt{w}}}+1 عددًا أوليًا، وليكن x0,…,xr−1 \ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_{r-1} و y0,…,yr′−1 \ensuremath{\mathtt{y}}_0,\ldots,\ensuremath{\mathtt{y}}_{r'-1} تسلسلين متمايزين من الأعداد الصحيحة بطول w \mathtt{w} بت ضمن {0,…,2w−1} \{0,\ldots,2^{\ensuremath{\mathtt{w}}}-1\}. عندئذ

Pr⁡{h(x0,…,x......yr−1)}≤max⁡{r,r′}/p. \displaystyle \Pr\{ h(\ensuremath{\mathtt{x}}_0,\ldots,\ensuremath{\mathtt{x}}_... ...{\mathtt{y}}_{r-1}) \} \le \max\{r,r'\}/\ensuremath{\mathtt{p}} \enspace .

تبيّن شيفرة المثال التالية كيف تُطبَّق دالة التجزئة هذه على كائن يحتوي على مصفوفة، x \mathtt{x}، من القيم:

    int hashCode() {
        long p = (1L<<32)-5;   // prime: 2^32 - 5
        long z = 0x64b6055aL;  // 32 bits from random.org
        int z2 = 0x5067d19d;   // random odd 32 bit number
        long s = 0;
        long zi = 1;
        for (int i = 0; i < x.length; i++) {
            // reduce to 31 bits
            long xi = (x[i].hashCode() * z2) >>> 1; 
            s = (s + zi * xi) % p;
            zi = (zi * z) % p;    
        }
        s = (s + zi * (p-1)) % p;
        return (int)s;
    }

تقدّم الشيفرة السابقة بعضًا من احتمال التصادم مقابل سهولة التنفيذ. وعلى وجه التحديد، فهي تطبّق دالة التجزئة الضربية من القسم 5.1.1، مع d=31 \ensuremath{\mathtt{d}}=31، لاختصار x[i].hashCode() \mathtt{x[i].hashCode()} إلى قيمة بطول 31 بت. وذلك حتى يمكن تنفيذ الجمعيات والضربيات التي تُجرى بمقياس العدد الأولي p=232−5 \ensuremath{\mathtt{p}}=2^{32}-5 باستخدام حساب غير موقَّع بدقة 63 بت. ومن ثمّ فإن احتمال تسلسلين مختلفين، أحدهما أطول وطوله r r، أن يكون لهما رمز التجزئة نفسه هو

2/231+r/(232−5) \displaystyle 2/2^{31} + r/(2^{32}-5)

بدلًا من r/(232−5) r/(2^{32}-5) المحدَّد في النظرية 5.6. opendatastructures.org

5.4 مناقشة وتمارين

تمثّل جداول التجزئة ورموز التجزئة حقلًا بحثيًا هائلًا ونشطًا لم يقتصر هذا الفصل على إلماح سريع به. تحتوي «ببليوغرافيا التجزئة» (Bibliography on Hashing) على الإنترنت [10] على نحو 2000 مدخل. وتوجد تنوّعات كثيرة من تنفيذي جداول التجزئة. والتنفيذ الموصوف في القسم 5.1 معروف بالتجزئة بالتسلسل (hashing with chaining) (يحتوي كل مدخل في المصفوفة على سلسلة (List) من العناصر). وتعود التجزئة بالتسلسل إلى مذكرة داخلية من IBM كتبها H. P. Luhn وتاريخها يناير 1953. ويبدو أن هذه المذكرة أيضًا إحدى أقدم الإشارات إلى القوائم المترابطة (linked lists). والبديل عن التجزئة بالتسلسل هو ما تعتمده مخططات العنونة المفتوحة، حيث تُخزَّن جميع البيانات مباشرةً في مصفوفة. ومن هذه المخططات بنية LinearHashTable في القسم 5.2. وقد اقتُرح هذه الفكرة أيضًا، بشكل مستقل، من مجموعة في IBM في خمسينيات القرن الماضي. ويجب أن تتعامل مخططات العنونة المفتوحة مع مسألة معالجة التصادم (collision resolution): الحالة التي تُجزَّأ فيها قيمتان إلى موضع المصفوفة نفسه. وتوجد استراتيجيات مختلفة لمعالجة التصادم؛ وهي تقدّم ضمانات أداء مختلفة، وغالبًا ما تتطلّب دوال تجزئة أكثر تطوّرًا من تلك الموصوفة هنا. وهناك فئة أخرى من تنفيذي جداول التجزئة هي ما تُسمّى طرائق التجزئة التامّة (perfect hashing). وهذه طرائق تستغرق فيها عمليات find(x) \mathtt{find(x)} زمنًا قدره O(1) O(1) في أسوأ الحالات. وبالنسبة لمجموعات البيانات الساكنة، يمكن تحقيق ذلك بإيجاد دوال تجزئة تامّة (perfect hash functions) للبيانات؛ وهذه دوال تربط كل قطعة بيانات بموضع فريد في المصفوفة. أما البيانات التي تتغيّر مع الزمن، فتشمل طرائق التجزئة التامّة جداول التجزئة ثنائية المستوى من نوع FKS [31,24] وتجزئة cuckoo [57]. ومن المرجّح أن دوال التجزئة المقدَّمة في هذا الفصل من بين أكثر الأساليب العملية المعروفة اليوم التي يمكن إثبات أنها تعمل جيدًا مع أي مجموعة بيانات. وهناك أساليب أخرى جيدة يمكن إثباتها تعود إلى العمل الرائد لـ Carter و Wegman اللذين أدخلا مفهوم التجزئة الشاملة (universal hashing) ووصفا عدة دوال تجزئة لسيناريوهات مختلفة [14]. أمّا التجزئة بالجداول الموصوفة في القسم 5.2.3 فينسبها Carter و Wegman [14]، لكن تحليلها عند تطبيقها على الاستكشاف الخطي (وعدة مخططات أخرى لجداول التجزئة) فينسبه إلى P tra cu و Thorup [60]. وفكرة التجزئة الضربية قديمة جدًا ويبدو أنها جزء من التراث المتعلق بالتجزئة [48, Section 6.4]. لكن فكرة اختيار المُضاعِف z \mathtt{z} عددًا فرديًا عشوائيًا، وكذلك التحليل في القسم 5.1.1، فينسبها إلى Dietzfelbinger وآخرون [23]. وهذه النسخة من التجزئة الضربية من أبسط النسخ، لكن احتمال التصادم فيها البالغ 2/2d 2/2^{\ensuremath{\mathtt{d}}} أكبر بمعامل اثنين مما يمكن توقّعه مع دالة عشوائية من 2w→2d 2^{\ensuremath{\mathtt{w}}}\to 2^{\ensuremath{\mathtt{d}}}. وتستخدم طريقة التجزئة بالضرب-والجمع (multiply-add hashing) الدالة

حيث z \mathtt{z} و b \mathtt{b} يُختار كلٌّ منهما عشوائيًا من {0,…,22w−1} \{0,\ldots,2^{\ensuremath{\mathtt{2w}}}-1\}. وتجزئة الضرب-والجمع (multiply-add hashing) ذات احتمال تصادم يبلغ 1/2d 1/2^{\ensuremath{\mathtt{d}}} فقط [21]، لكنها تتطلّب حسابًا بدقة 2w 2\ensuremath{\mathtt{w}} بت.

توجد عدة طرق للحصول على رموز تجزئة من تسلسلات ثابتة الطول من الأعداد الصحيحة بطول w \mathtt{w} بت. ومن أسرعها على الإطلاق [11] هي الدالة

حيث r r زوجي و a0,…,ar−1 \ensuremath{\mathtt{a}}_0,\ldots,\ensuremath{\mathtt{a}}_{r-1} تُختار عشوائيًا من {0,…,2w} \{0,\ldots,2^{\ensuremath{\mathtt{w}}}\}. وينتج عن ذلك رمز تجزئة بطول 2w 2\ensuremath{\mathtt{w}} بت احتمال تصادمه 1/2w 1/2^{\ensuremath{\mathtt{w}}}. ويمكن اختصاره إلى رمز تجزئة بطول w \mathtt{w} بت باستخدام التجزئة الضربية (أو التجزئة بالضرب-والجمع). وهذه الطريقة سريعة لأنها تتطلّب r/2 r/2 فقط من الضربيات ذات 2w 2\ensuremath{\mathtt{w}} بت، في حين تتطلّب الطريقة الموصوفة في القسم 5.3.2 ما مقداره r r ضربة. (وتحدث عمليات  mod  \bmod ضمنيًا باستخدام حساب بطول w \mathtt{w} للجمعيات وبحساب بطول 2w 2\ensuremath{\mathtt{w}} للضربيات على التوالي.)

طريقة القسم 5.3.3، أي استخدام متعددات الحدود فوق الحقول الأولية لتجزئة المصفوفات والنصوص متغيّرة الطول، ترجع إلى Dietzfelbinger وآخرون [22]. ولأنها تستخدم المعامل  mod  \bmod الذي يعتمد على تعليمة آليّة مكلفة، فإنها للأسف ليست سريعة جدًا. وتختار بعض صيغ هذه الطريقة العدد الأولي p \mathtt{p} من الشكل 2w−1 2^{\ensuremath{\mathtt{w}}}-1، وعندها يمكن استبدال المعامل  mod  \bmod بعمليتي الجمع ( + \mathtt{+} ) و AND على مستوى البتات ( & \mathtt{\text{\ttfamily\&}} ) [47, Section 3.6]. ومن الخيارات الأخرى تطبيق إحدى الطرق السريعة للنصوص ثابتة الطول على كتل بطول c c حيث c>1 c>1 ثابت، ثم تطبيق طريقة الحقل الأولي على التسلسل الناتج من رموز التجزئة وعددها ⌈r/c⌉ \lceil r/c\rceil. تمرين 5.1 تمنح جامعةٌ معيّنة كلًا من طلابها رقمًا جامعيًا عند أول تسجيل له في أي دورة. وهذه الأعداد أعداد صحيحة متتابعة بدأت من 0 قبل سنوات طويلة واليوم تقع في الملايين. لنفترض لدينا صفٌّ من مئة طالب في السنة الأولى ونريد إسناد رموز تجزئة إليهم بالاستناد إلى أرقامهم الجامعية. فهل استخدام الرقم الأولين من الرقم الجامعي أم آخر رقمين فيه أكثر منطقية؟ برّر إجابتك.

تمرين 5.2 تأمّل مخطّط التجزئة في القسم 5.1.1، ولنفرض n=2d \ensuremath{\mathtt{n}}=2^{\ensuremath{\mathtt{d}}} و d≤w/2 \ensuremath{\mathtt{d}}\le \ensuremath{\mathtt{w}}/2. بيّن أن لكل اختيار للمُضاعِف، z \mathtt{z}، توجد n \mathtt{n} قيمة كلها تملك رمز التجزئة نفسه. (تلميح: هذا سهل ولا يحتاج إلى نظرية الأعداد.) وبمعطى المُضاعِف، z \mathtt{z}، صِف n \mathtt{n} قيمة كلها تملك رمز التجزئة نفسه. (تلميح: هذا أصعب ويحتاج إلى بعض أساسيات نظرية الأعداد.)

تمرين 5.3 أثبت أن الحدّ 2/2d 2/2^{\ensuremath{\mathtt{d}}} في المتراجمة 5.1 هو أفضل حدّ ممكن، ببيّن أنه إذا كان x=2w−d−2 x=2^{\ensuremath{\mathtt{w}}-\ensuremath{\mathtt{d}}-2} و y=3x \ensuremath{\mathtt{y}}=3\ensuremath{\mathtt{x}}، فإن Pr⁡{hash(x)=hash(y)}=2/2d \Pr\{\ensuremath{\mathtt{hash(x)}}=\ensuremath{\mathtt{hash(y)}}\}=2/2^{\ensuremath{\mathtt{d}}}. (تلميح: انظر إلى التمثيلين الثنائيين لـ zx \ensuremath{\mathtt{zx}} و z3x \ensuremath{\mathtt{z}}3\ensuremath{\mathtt{x}} واستخدم الحقيقة z3x=zx+2zx \ensuremath{\mathtt{z}}3\ensuremath{\mathtt{x}} = \ensuremath{\mathtt{z}}x\ensuremath{\mathtt{+2}}z\ensuremath{\mathtt{x}}.)

تمرين 5.4 أعد إثبات المتراجمة 5.4 باستخدام الصيغة الكاملة لتقريب ستيرلنغ المعطاة في القسم 1.3.2.

تمرين 5.5 تأمّل الصيغة المبسّطة التالية لشيفرة إضافة عنصر x \mathtt{x} إلى LinearHashTable، وهي ببساطة تخزّن x \mathtt{x} في أول مدخل null \mathtt{null} في المصفوفة تجده. واشرح لماذا قد تكون بطيئة جدًا، مع إعطاء مثالٍ لتسلسل من O(n) O(\ensuremath{\mathtt{n}}) من عمليات add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} و find(x) \mathtt{find(x)} يستغرق زمنًا من المرتبة n2 \ensuremath{\mathtt{n}}^2 في التنفيذ.

    boolean addSlow(T x) {
        if (2*(q+1) > t.length) resize(); // max 50% occupancy
        int i = hash(x);
        while (t[i] != null) {
            if (t[i] != del && x.equals(t[i])) return false;
            i = (i == t.length-1) ? 0 : i + 1; // increment i
        }
        t[i] = x;
        n++; q++;
        return true;
    }

تمرين 5.6 عملت الإصدارات المبكِّرة من دالة Java hashCode() \mathtt{hashCode()} لصنف String على عدم استخدام كل المحارف الموجودة في النصوص الطويلة. فمثلًا، كان رمز التجزئة لنص من ستة عشر محرفًا يُحسب باستخدام المحارف ذات الفهارس الزوجية الثمانية فقط. واشرح لماذا كانت فكرة سيئة جدًا، مع إعطاء مثال لمجموعة كبيرة من النصوص كلها تملك رمز التجزئة نفسه.

تمرين 5.7 لنفترض لديك كائن مكوَّن من عددين صحيحين بطول w \mathtt{w} بت، x \mathtt{x} و y \mathtt{y}. بيّن لماذا لا يصلح x⊕y \ensuremath{\mathtt{x}}\oplus\ensuremath{\mathtt{y}} رمز تجزئة لكائنك. وأعطِ مثالًا لمجموعة كبيرة من الكائنات جميعها تملك رمز التجزئة 0.

تمرين 5.8 لنفترض لديك كائن مكوَّن من عددين صحيحين بطول w \mathtt{w} بت، x \mathtt{x} و y \mathtt{y}. بيّن لماذا لا يصلح x+y \ensuremath{\mathtt{x}}+\ensuremath{\mathtt{y}} رمز تجزئة لكائنك. وأعطِ مثالًا لمجموعة كبيرة من الكائنات جميعها تملك رمز التجزئة نفسه.

تمرين 5.9 لنفترض لديك كائن مكوَّن من عددين صحيحين بطول w \mathtt{w} بت، x \mathtt{x} و y \mathtt{y}. ولنفترض أن رمز التجزئة لكائنك معرَّف بدالة حتمية h(x,y) h(\ensuremath{\mathtt{x}},\ensuremath{\mathtt{y}}) تُنتج عددًا صحيحًا واحدًا بطول w \mathtt{w} بت. أثبت وجود مجموعة كبيرة من الكائنات تملك رمز التجزئة نفسه.

تمرين 5.10 ليكن p=2w−1 p=2^{\ensuremath{\mathtt{w}}}-1 حيث w \mathtt{w} عدد صحيح موجب. اشرح لماذا، بالنسبة إلى عدد صحيح موجب x x

(x mod 2w)+(xdiv2w)≡x mod (2w−1). \displaystyle (x\bmod 2^{\ensuremath{\mathtt{w}}}) + (x\ddiv 2^{\ensuremath{\mathtt{w}}}) \equiv x \bmod (2^{\ensuremath{\mathtt{w}}}-1) \enspace .

(وهذا يعطي خوارزمية لحساب x mod (2w−1) x \bmod (2^{\ensuremath{\mathtt{w}}}-1) عبر إعادة الضبط المتكرر لـ

x=x&((1<<w)−1)+x>>>w \displaystyle \ensuremath{\mathtt{x = x\text{\ttfamily\&}((1\text{\ttfamily <<}w)-1) + x\text{\ttfamily >>>}w}}

حتى x≤2w−1 \ensuremath{\mathtt{x}} \le 2^{\ensuremath{\mathtt{w}}}-1.)

تمرين 5.11 اعثر على أحد تنفيذي جداول التجزئة الشائعة الاستخدام، مثل HashMap في Java Collection Framework أو تنفيذَي HashTable و LinearHashTable في هذا الكتاب، وصمّم برنامجًا يخزّن أعدادًا صحيحة في بنية البيانات هذه بحيث توجد أعداد صحيحة، x \mathtt{x}، يكون find(x) \mathtt{find(x)} فيها زمنيًا خطيًا. أي بعبارة أخرى، ابحث عن مجموعة من n \mathtt{n} عدد صحيح يوجد فيها cn c\ensuremath{\mathtt{n}} عنصر تُجزَّأ إلى موضع الجدول نفسه. وبحسب جودة التنفيذ، قد تتمكّن من ذلك بمجرد فحص شيفرة التنفيذ، أو قد تضطر إلى كتابة شيفرة تجرّب عمليات الإدراج والبحث، وتقيس الزمن اللازم لإضافة قيم بعينها وإيجادها. (وقد استُخدم هذا، وما زال يُستخدم، في إطلاق هجمات حجب الخدمة على خوادم الويب [17].)

opendatastructures.org