بنى البيانات المفتوحة › 13. بنى بيانات الأعداد الصحيحة 13 / 14 السابق التالي

13. بنى بيانات الأعداد الصحيحة

في هذا الفصل، نعود إلى مسألة تنفيذ مجموعة مرتَّبة (SSet). والفرق الآن أننا نفترض أنّ العناصر المخزَّنة في SSet أعدادٌ صحيحة بطول w \mathtt{w} بت. أي أننا نريد تنفيذ add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} و find(x) \mathtt{find(x)} حيث x∈{0,…,2w−1} \ensuremath{\mathtt{x}}\in\{0,\ldots,2^{\ensuremath{\mathtt{w}}}-1\} . ومن السهل إلى حدٍّ ما أن نتخيّل تطبيقاتٍ كثيرة تكون فيها البيانات — أو على الأقلّ المفتاح الذي نستعمله في ترتيب البيانات — عددًا صحيحًا. وسنناقش ثلاث بنى بيانات، كلٌّ منها تبني على أفكار سابقتها. البنية الأولى، BinaryTrie، تنفّذ عمليات SSet الثلاث جميعها بزمن O(w) O(\ensuremath{\mathtt{w}}) . وهذا ليس لافتًا للنظر، إذ إنّ أي مجموعة جزئية من {0,…,2w−1} \{0,\ldots,2^{\ensuremath{\mathtt{w}}}-1\} لها حجم n≤2w \ensuremath{\mathtt{n}}\le 2^{\ensuremath{\mathtt{w}}} ، بحيث إنّ log⁡n≤w \log \ensuremath{\mathtt{n}} \le \ensuremath{\mathtt{w}} . وكل تنفيذات SSet الأخرى التي ناقشناها في هذا الكتاب تنفّذ جميع العمليات بزمن O(log⁡n) O(\log \ensuremath{\mathtt{n}}) ، فهي كلّها على الأقلّ بسرعة BinaryTrie. أمّا البنية الثانية، XFastTrie، فتُسرّع البحث في BinaryTrie باستعمال التجزئة (hashing). ومع هذا التسريع تعمل العملية find(x) \mathtt{find(x)} بزمن O(log⁡w) O(\log \ensuremath{\mathtt{w}}) . غير أنّ عمليتَي add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} في XFastTrie لا تزالان تستغرقان بزمن O(w) O(\ensuremath{\mathtt{w}}) ، والمساحة التي يستعملها XFastTrie هي O(n⋅w) O(\ensuremath{\mathtt{n}}\cdot\ensuremath{\mathtt{w}}) . أمّا البنية الثالثة، YFastTrie، فتستعمل XFastTrie لتخزين عيّنة فقط، تقارب عنصرًا واحدًا من كلّ w \ensuremath{\mathtt{w}} عناصر، وتخزّن بقية العناصر في بنية SSet معيارية. وتُنزل هذه الحيلة زمن تنفيذ add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} إلى O(log⁡w) O(\log \ensuremath{\mathtt{w}}) وتُنزل المساحة إلى O(n) O(\ensuremath{\mathtt{n}}) . أمّا التنفيذات المستعملة أمثلةً في هذا الفصل فيمكنها تخزين أيّ نوعٍ من البيانات، ما دام بإمكاننا إسناد عددٍ صحيحٍ إليها. وفي نماذج الشيفرة، يكون المتغيّر ix \mathtt{ix} دائمًا القيمة الصحيحة المسنَدة إلى x \mathtt{x} ، وتحوّل الدالة in. \mathtt{in.} intValue(x) \mathtt{intValue(x)} القيمة x \mathtt{x} إلى العدد الصحيح المسنَد إليها. أمّا في النصّ فسنكتفي بمعاملة x \mathtt{x} كما لو كانت عددًا صحيحًا.

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

opendatastructures.org

13.1 BinaryTrie: شجرة بحث رقمية

يشفّر BinaryTrie مجموعةً من الأعداد الصحيحة بطول w \mathtt{w} بت في شجرة ثنائية. فجميع الأوراق في الشجرة على العمق w \mathtt{w} ، ويُشفَّر كلّ عدد صحيح في صورة مسارٍ يمتدّ من الجذر إلى ورقة. ويتّجه مسار العدد الصحيح x \mathtt{x} إلى اليسار عند المستوى i \mathtt{i} إذا كان البت i \mathtt{i} th الأكثر أهميةً في x \mathtt{x} يساوي 0، وإلى اليمين إذا كان يساوي 1. ويعرض الشكل 13.1 مثالًا للحالة w=4 \ensuremath{\mathtt{w}}=4 ، حيث تخزّن شجرة البادئات (trie) الأعداد الصحيحة 3(0011) و9(1001) و12(1100) و13(1101).

لأنّ مسار البحث عن قيمةٍ ما، x \mathtt{x} ، يعتمد على بتات x \mathtt{x} نفسها، من المفيد أن نسمّي أبناء عقدةٍ ما، u \mathtt{u} ، بالاسمين u.child[0] \mathtt{u.child[0]} ( left \mathtt{left} ) و u.child[1] \mathtt{u.child[1]} ( right \mathtt{right} ). وفي الواقع، تخدم مؤشّرات الأبناء هذين غرضين معًا. ولأنّ أوراق شجرة البادئات الثنائية لا أبناء لها، تُستعمل هذه المؤشّرات لوصل الأوراق ببعضها في قائمة مترابطة ثنائية الوصل (doubly-linked list). أمّا الورقة في شجرة البادئات الثنائية، u.child[0] \mathtt{u.child[0]} ( prev \mathtt{prev} ) فهي العقدة التي تسبق u \mathtt{u} في القائمة، و u.child[1] \mathtt{u.child[1]} ( next \mathtt{next} ) هي العقدة التي تلي u \mathtt{u} في القائمة. وتُستعمل عقدةٌ خاصة، dummy \mathtt{dummy} ، قبل أوّل عقدةٍ في القائمة وبعد آخر عقدةٍ فيها معًا (انظر القسم 3.2). وتحتوي كلّ عقدة، u \mathtt{u} ، كذلك على مؤشّرٍ إضافي هو u.jump \mathtt{u.jump} . فإن كان الابن الأيسر لـ u \mathtt{u} مفقودًا، فإنّ u.jump \mathtt{u.jump} يشير إلى أصغر ورقةٍ في الشجرة الفرعية لـ u \mathtt{u} . وإن كان الابن الأيمن لـ u \mathtt{u} مفقودًا، فإنّ u.jump \mathtt{u.jump} يشير إلى أكبر ورقةٍ في الشجرة الفرعية لـ u \mathtt{u} . ويعرض الشكل 13.2 مثالًا على BinaryTrie يُظهر مؤشّرات jump \mathtt{jump} والقائمة المترابطة ثنائية الوصل عند الأوراق.

العملية find(x) \mathtt{find(x)} في BinaryTrie بسيطةٌ إلى حدٍّ ما. فنحن نحاول اتّباع مسار البحث عن x \mathtt{x} في شجرة البادئات. فإذا بلغنا ورقة، فقد وجدنا x \mathtt{x} . وإذا بلغنا عقدةً u \mathtt{u} لا يمكننا المتابعة عندها (لأنّ u \mathtt{u} تفتقر إلى ابن)، فإنّنا نتبع u.jump \mathtt{u.jump} ، الذي ينقلنا إمّا إلى أصغر ورقةٍ أكبر من x \mathtt{x} أو إلى أكبر ورقةٍ أصغر من x \mathtt{x} . وأيّ من هاتين الحالتين يقع يتوقّف على ما إذا كانت u \mathtt{u} تفتقر إلى ابنها الأيسر أم إلى ابنها الأيمن، على التوالي. وفي الحالة الأولى ( u \mathtt{u} تفتقر إلى ابنها الأيسر) فقد وجدنا العقدة التي نريدها. وفي الحالة الثانية ( u \mathtt{u} تفتقر إلى ابنها الأيمن) يمكننا استعمال القائمة المترابطة للوصول إلى العقدة التي نريدها. ويوضّح الشكل 13.3 كلَّ واحدةٍ من هاتين الحالتين.

    T find(T x) {
        int i, c = 0, ix = it.intValue(x);
        Node u = r;
        for (i = 0; i < w; i++) {
            c = (ix >>> w-i-1) & 1;
            if (u.child[c] == null) break;
            u = u.child[c];
        }
        if (i == w) return u.x;  // found it
        u = (c == 0) ? u.jump : u.jump.child[next]; 
        return u == dummy ? null : u.x;
    }

يغلب على زمن تنفيذ الدالة find(x) \mathtt{find(x)} الزمن اللازم لاتّباع مسارٍ من الجذر إلى ورقة، ولذلك تعمل بزمن O(w) O(\ensuremath{\mathtt{w}}) . أمّا العملية add(x) \mathtt{add(x)} في BinaryTrie فبسيطةٌ هي الأخرى، لكنّ عليها عملٌ كثير:

  1. يتّبع مسار البحث عن x \mathtt{x} حتى يبلغ عقدةً u \mathtt{u} لم يعد في وسعه المتابعة.
  2. ينشئ بقيّة مسار البحث من u \mathtt{u} إلى ورقةٍ تحوي x \mathtt{x} .
  3. يضيف العقدةَ u′ \mathtt{u'} ، التي تحوي x \mathtt{x} ، إلى القائمة المترابطة للأوراق (ويستطيع الوصول إلى السابقة pred \mathtt{pred} لـ u′ \mathtt{u'} في القائمة المترابطة من مؤشّر jump \mathtt{jump} لآخر عقدٍ، u \mathtt{u} ، صادفها في الخطوة 1.)
  4. يصعد عائدًا في مسار البحث عن x \mathtt{x} ، ويعدّل مؤشّرات jump \mathtt{jump} عند العقد التي ينبغي أن يشير مؤشّر jump \mathtt{jump} فيها الآن إلى x \mathtt{x} .

ويوضّح الشكل 13.4 مثالًا على إضافة عنصر.

    boolean add(T x) {
        int i, c = 0, ix = it.intValue(x);
        Node u = r;
        // 1 - search for ix until falling out of the trie
        for (i = 0; i < w; i++) {
            c = (ix >>> w-i-1) & 1;
            if (u.child[c] == null) break;
            u = u.child[c];
        }        
        if (i == w) return false; // already contains x - abort
        Node pred = (c == right) ? u.jump : u.jump.child[0];
        u.jump = null;  // u will have two children shortly
        // 2 - add path to ix
        for (; i < w; i++) {
            c = (ix >>> w-i-1) & 1;
            u.child[c] = newNode();
            u.child[c].parent = u;
            u = u.child[c];
        }
        u.x = x;
        // 3 - add u to linked list
        u.child[prev] = pred;
        u.child[next] = pred.child[next];
        u.child[prev].child[next] = u;
        u.child[next].child[prev] = u;
        // 4 - walk back up, updating jump pointers
        Node v = u.parent;
        while (v != null) {
            if ((v.child[left] == null 
                    && (v.jump == null || it.intValue(v.jump.x) > ix))
            || (v.child[right] == null 
                    && (v.jump == null || it.intValue(v.jump.x) < ix)))
                v.jump = u;
            v = v.parent;
        }
        n++;
        return true;
    }

تُجري هذه الدالة نزولًا واحدًا في مسار البحث عن x \mathtt{x} وصعودًا واحدًا عائدًا. وتستغرق كلّ خطوةٍ من خطوتي الصعود والنزول زمنًا ثابتًا، ولذلك تعمل الدالة add(x) \mathtt{add(x)} بزمن O(w) O(\ensuremath{\mathtt{w}}) . أمّا العملية remove(x) \mathtt{remove(x)} فتلغي عمل add(x) \mathtt{add(x)} . ومثل add(x) \mathtt{add(x)} ، عليها عملٌ كثير:

  1. يتّبع مسار البحث عن x \mathtt{x} حتى يبلغ الورقةَ u \mathtt{u} ، التي تحوي x \mathtt{x} .
  2. يزيل u \mathtt{u} من القائمة المترابطة ثنائية الوصل.
  3. يحذف u \mathtt{u} ثمّ يصعد عائدًا في مسار البحث عن x \mathtt{x} ، محذفًا العقد، حتى يبلغ عقدةً v \mathtt{v} لها ابنٌ ليس على مسار البحث عن x \mathtt{x} .
  4. يصعد من v \mathtt{v} إلى الجذر، محدِّثًا أيّ مؤشّرات jump \mathtt{jump} التي تشير إلى u \mathtt{u} .

ويوضّح الشكل 13.5 مثالًا على إزالة عنصر.

    boolean remove(T x) {
        // 1 - find leaf, u, containing x
        int i, c, ix = it.intValue(x);
        Node u = r;
        for (i = 0; i < w; i++) {
            c = (ix >>> w-i-1) & 1;
            if (u.child[c] == null) return false;
            u = u.child[c];
        }
        // 2 - remove u from linked list
        u.child[prev].child[next] = u.child[next];
        u.child[next].child[prev] = u.child[prev];
        Node v = u;
        // 3 - delete nodes on path to u
        for (i = w-1; i >= 0; i--) {
            c = (ix >>> w-i-1) & 1;
            v = v.parent;
            v.child[c] = null;
            if (v.child[1-c] != null) break;
        }
        // 4 - update jump pointers
        c = (ix >>> w-i-1) & 1;
        v.jump = u.child[1-c];
        v = v.parent;
        i--;
        for (; i >= 0; i--) {
            c = (ix >>> w-i-1) & 1;
            if (v.jump == u) 
                v.jump = u.child[1-c];
            v = v.parent;
        }
        n--;
        return true;
    }

المبرهنة 13.1 يُنفِّذ BinaryTrie واجهة SSet للأعداد الصحيحة بطول w \mathtt{w} بت. ويدعم BinaryTrie العمليات add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} و find(x) \mathtt{find(x)} في زمن O(w) O(\ensuremath{\mathtt{w}}) لكل عملية. والمساحة التي يستعملها BinaryTrie يخزّن n \mathtt{n} قيمةً هي O(n⋅w) O(\ensuremath{\mathtt{n}}\cdot\ensuremath{\mathtt{w}}) .

opendatastructures.org

13.2 XFastTrie: البحث في زمن لوغاريتمي مزدوج

أداء بنية BinaryTrie ليس لافتًا للنظر. فعدد العناصر، n \mathtt{n} ، المخزَّنة في البنية لا يتجاوز 2w 2^{\ensuremath{\mathtt{w}}} ، ولذلك فإنّ log⁡n≤w \log \ensuremath{\mathtt{n}}\le \ensuremath{\mathtt{w}} . بمعنى آخر، أيّ بنيةٍ من بنى SSet المبنيّة على المقارنات الموصوفة في أجزاءٍ أخرى من هذا الكتاب لا تقلّ كفاءةً عن BinaryTrie، وهي غير مقيّدة بتخزين الأعداد الصحيحة وحدها. ثمّ نصف الآن XFastTrie، وهي مجرّد BinaryTrie مع w+1 \mathtt{w+1} من جداول التجزئة (hash tables) — جدولٌ لكلّ مستوىٍ من مستويات شجرة البادئات. وتُستعمل جداول التجزئة هذه لتسريع العملية find(x) \mathtt{find(x)} إلى زمن O(log⁡w) O(\log \ensuremath{\mathtt{w}}) . ولنتذكّر أنّ العملية find(x) \mathtt{find(x)} في BinaryTrie تكاد تكون منتهيةً بمجرد وصولنا إلى عقدة، u \mathtt{u} ، يكون فيها مسار البحث عن x \mathtt{x} راغبًا في الانتقال إلى u.right \mathtt{u.right} (أو u.left \mathtt{u.left} ) بينما u \mathtt{u} ليس له ابنٌ أيمن (على سبيل المقابل، ابنٌ أيسر). وعند هذه النقطة، يقفز البحث بواسطة u.jump \mathtt{u.jump} إلى ورقةٍ، v \mathtt{v} ، من أوراق BinaryTrie، ثمّ إمّا يعيد v \mathtt{v} أو خليفتها في القائمة المترابطة للأوراق. ويُسرّع XFastTrie عملية البحث باستعمال البحث الثنائي (binary search) على مستويات شجرة البادئات لتحديد موضع العقدة u \mathtt{u} . ولاستعمال البحث الثنائي، نحتاج طريقةً لمعرفة ما إذا كانت العقدة u \mathtt{u} التي نبحث عنها فوق مستوىٍ معيّن، i \mathtt{i} ، أم أنّ u \mathtt{u} عند المستوى i \mathtt{i} أو تحته. وتقدّم هذه المعلومة البتات i \mathtt{i} الأعلى رتبةً في التمثيل الثنائي لـ x \mathtt{x} ; فهذه البتات تحدّد مسار البحث الذي يسلكه x \mathtt{x} من الجذر إلى المستوى i \mathtt{i} . ولمثالٍ ما، راجع الشكل 13.6؛ ففي هذا الشكل، آخر عقدة، u \mathtt{u} ، على مسار البحث عن 14 (الذي تمثيله الثنائي هو 1110) هي العقدة الموسومة 11⋆⋆ 11{\star\star} عند المستوى 2، لأنّه لا توجد عقدةٌ موسومة 111⋆ 111{\star} عند المستوى 3. وبذلك، يمكننا وسم كلّ عقدةٍ عند المستوى i \mathtt{i} بعددٍ صحيحٍ من i \mathtt{i} بت. وعندئذٍ تكون العقدة u \mathtt{u} التي نبحث عنها عند المستوى i \mathtt{i} أو تحته إذا وفقط إذا وُجدت عقدةٌ عند المستوى i \mathtt{i} يطابق وسمها البتات i \mathtt{i} الأعلى رتبةً في x \mathtt{x} .

في XFastTrie، نخزّن، لكلّ i∈{0,…,w} \ensuremath{\mathtt{i}}\in\{0,\ldots,\ensuremath{\mathtt{w}}\} ، جميع العقد عند المستوى i \mathtt{i} في مجموعة غير مرتَّبة (USet)، هي t[i] \mathtt{t[i]} ، منفَّذةً في صورة جدول تجزئة (hash table) (الفصل 5). ويتيح لنا استعمال هذا USet أن نتحقّق بزمنٍ ثابتٍ متوقَّع ممّا إذا كانت هناك عقدةٌ عند المستوى i \mathtt{i} يطابق وسمها البتات i \mathtt{i} الأعلى رتبةً في x \mathtt{x} . بل إنّنا نستطيع أصلًا أن نجد هذه العقدة باستعمال t[i].find(x>>>(w−i)) \mathtt{t[i].find(x\text{\ttfamily >>>}(w-i))} تتيح لنا جداول التجزئة t[0],…,t[w] \ensuremath{\mathtt{t[0]}},\ldots,\ensuremath{\mathtt{t[w]}} استعمال البحث الثنائي للعثور على u \mathtt{u} . وفي البداية نعرف أنّ u \mathtt{u} عند مستوىٍ ما، i \mathtt{i} ، مع 0≤i<w+1 0\le \ensuremath{\mathtt{i}}< \ensuremath{\mathtt{w}}+1 . لذلك نُهيّئ l=0 \ensuremath{\mathtt{l}}=0 و h=w+1 \ensuremath{\mathtt{h}}=\ensuremath{\mathtt{w}}+1 ، وننظر تكرارًا في جدول التجزئة t[i] \mathtt{t[i]} ، حيث i=⌊(l+h)/2⌋ \ensuremath{\mathtt{i}}=\lfloor (\ensuremath{\mathtt{l+h}})/2\rfloor . فإذا كان t[i] \ensuremath{\mathtt{t[i]}} يحوي عقدةً يطابق وسمها البتات x \mathtt{x} الأعلى رتبةً، أي البتات i \mathtt{i} ، فإنّنا نضع l=i \mathtt{l=i} ( u \mathtt{u} عند المستوى i \mathtt{i} أو تحته )؛ وإلّا فإنّنا نضع h=i \mathtt{h=i} ( u \mathtt{u} فوق المستوى i \mathtt{i} ). وتنتهي هذه العملية حين يصبح h−l≤1 \ensuremath{\mathtt{h-l}}\le 1 ، وعندئذٍ نحدّد أنّ u \mathtt{u} عند المستوى l \mathtt{l} . ثمّ نُكمل العملية find(x) \mathtt{find(x)} باستعمال u.jump \mathtt{u.jump} والقائمة المترابطة ثنائية الوصل للأوراق.

    T find(T x) {
        int l = 0, h = w+1, ix = it.intValue(x);
        Node v, u = r, q = newNode();
        while (h-l > 1) {
            int i = (l+h)/2;
            q.prefix = ix >>> w-i;
            if ((v = t[i].find(q)) == null) {
                h = i;
            } else {
                u = v;
                l = i;
            }
        }
        if (l == w) return u.x;
        Node pred = (((ix >>> w-l-1) & 1) == 1) 
                 ? u.jump : u.jump.child[0];
        return (pred.child[next] == dummy) 
                     ? null : pred.child[next].x;
    }

تُنقص كلّ دورةٍ من دورات حلقة while \mathtt{while} في الطريقة أعلاه h−l \mathtt{h-l} بنحو النصف، ولذلك تجد هذه الحلقة u \mathtt{u} بعد O(log⁡w) O(\log \ensuremath{\mathtt{w}}) دورة. وتُجري كلّ دورة عملًا ثابت المقدار وعملية find(x) \mathtt{find(x)} واحدة في USet، وهي عملية تستغرق زمنًا متوقَّعًا ثابتًا. ولا يستغرق العمل المتبقّي سوى زمنٍ ثابت، ولذلك لا تستغرق الطريقة find(x) \mathtt{find(x)} في XFastTrie سوى O(log⁡w) O(\log\ensuremath{\mathtt{w}}) زمنًا متوقَّعًا.

الدالتان add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} في XFastTrie متطابقتان تقريبًا مع الدالتين ذواتهما في BinaryTrie. والتعديلات الوحيدة هي الخاصة بإدارة جداول التجزئة t[0] \mathtt{t[0]} و... و t[w] \mathtt{t[w]} . وخلال العملية add(x) \mathtt{add(x)} ، حين تُنشأ عقدةٌ جديدة عند المستوى i \mathtt{i} ، تُضاف هذه العقدة إلى t[i] \mathtt{t[i]} . وخلال العملية remove(x) \mathtt{remove(x)} ، حين تُزال عقدةٌ من المستوى i \mathtt{i} ، تُزال هذه العقدة من t[i] \mathtt{t[i]} . ولأنّ الإضافة والإزالة في جدول التجزئة تستغرقان زمنًا متوقَّعًا ثابتًا، فإنّ هذا لا يزيد زمنَي تنفيذ add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} إلّا بعاملٍ ثابت. ونُهمل عرض شيفرة add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} لأنّ الشيفرة تكاد تكون مطابقةً لشيفرة العرض (الطويلة) التي سبق أن قدّمناها للدالتين ذواتهما في BinaryTrie. وتُلخّص المبرهنة التالية أداء XFastTrie: المبرهنة 13.2 يُنفِّذ XFastTrie واجهة SSet للأعداد الصحيحة بطول w \mathtt{w} بت. ويدعم XFastTrie العمليات * add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} في زمن O(w) O(\ensuremath{\mathtt{w}}) متوقَّع لكل عملية، والعملية find(x) \mathtt{find(x)} في زمن O(log⁡w) O(\log \ensuremath{\mathtt{w}}) متوقَّع لكل عملية. * والمساحة التي يستعملها XFastTrie يخزّن n \mathtt{n} قيمةً هي O(n⋅w) O(\ensuremath{\mathtt{n}}\cdot\ensuremath{\mathtt{w}}) .

opendatastructures.org

13.3 YFastTrie: مجموعة مرتَّبة بزمن لوغاريتمي مزدوج

يُعدّ XFastTrie تحسينًا هائلًا — بل أُسّيّ — على BinaryTrie من حيث زمن الاستعلام، لكنّ عمليتَي add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} لا تزالان بطيئتين نوعًا ما. وإضافةً إلى ذلك، فإنّ استهلاك المساحة، O(n⋅w) O(\ensuremath{\mathtt{n}}\cdot\ensuremath{\mathtt{w}}) ، أعلى من تنفيذات SSet الأخرى الموصوفة في هذا الكتاب، التي تشغل جميعها O(n) O(\ensuremath{\mathtt{n}}) من المساحة. وهذان المشكلتان مترابطتان؛ فإذا كانت n \mathtt{n} عملية add(x) \mathtt{add(x)} تبني بنيةً بحجم n⋅w \ensuremath{\mathtt{n}}\cdot\ensuremath{\mathtt{w}} ، فإنّ عملية add(x) \mathtt{add(x)} تتطلّب على الأقلّ زمنًا (ومساحةً) من رتبة w \mathtt{w} لكل عملية. أمّا YFastTrie، الذي سنناقشه تاليًا، فيحسّن المساحة والسرعة معًا. ويستعمل YFastTrie بنية XFastTrie، xft \mathtt{xft} ، لكنّه لا يخزّن سوى O(n/w) O(\ensuremath{\mathtt{n}}/\ensuremath{\mathtt{w}}) قيمةً في xft \mathtt{xft} . وبهذه الطريقة، لا تتجاوز المساحة الإجمالية المستعملة في xft \mathtt{xft} حدود O(n) O(\ensuremath{\mathtt{n}}) . وإضافةً إلى ذلك، فإنّ واحدةً فقط من كلّ w \mathtt{w} عمليات add(x) \mathtt{add(x)} أو remove(x) \mathtt{remove(x)} في YFastTrie تؤدّي إلى عملية add(x) \mathtt{add(x)} أو remove(x) \mathtt{remove(x)} في xft \mathtt{xft} . وبهذا، لا يتجاوز متوسّط الكلفة التي يتحمّلها استدعاءُ xft \mathtt{xft} — أي عمليتَي add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} — قيمةً ثابتة. والسؤال الواضح يصبح: إذا كان xft \mathtt{xft} يخزّن n \mathtt{n} / w \mathtt{w} عنصرًا فقط، فإلى أين تذهب بقية العناصر n(1−1/w) \ensuremath{\mathtt{n}}(1-1/\ensuremath{\mathtt{w}})؟ تنتقل هذه العناصر إلى بنى ثانوية، وهي في هذه الحالة نسخةٌ موسَّعة من Treap (القسم 7.2). وهناك نحو n \mathtt{n} / w \mathtt{w} من هذه البنى الثانوية، لذا يخزّن كلٌّ منها في المتوسّط O(w) O(\ensuremath{\mathtt{w}}) عنصرًا. وتدعم Treap عمليات SSet بزمنٍ لوغاريتمي، ولذلك ستعمل العمليات على هذه الـTreap بزمن O(log⁡w) O(\log \ensuremath{\mathtt{w}})، كما هو مطلوب. وبنحوٍ أكثر تحديدًا، يحتوي YFastTrie على XFastTrie، xft \mathtt{xft} ، يحوي عيّنةً عشوائية من البيانات، حيث يظهر كلّ عنصر في العيّنة باستقلالٍ واحتمال 1/w 1/\ensuremath{\mathtt{w}} . وللراحة، القيمة 2w−1 2^{\ensuremath{\mathtt{w}}}-1 محتواةٌ دائمًا في xft \mathtt{xft} . ولنفرض أنّ x0<x1<⋯<xk−1 \ensuremath{\mathtt{x}}_0<\ensuremath{\mathtt{x}}_1<\cdots<\ensuremath{\mathtt{x}}_{k-1} تصف العناصر المخزَّنة في xft \mathtt{xft} . ومرتبطةً بكلّ عنصر، xi \ensuremath{\mathtt{x}}_i ، Treap، ti \ensuremath{\mathtt{t}}_i ، تخزّن جميع القيم في المدى xi−1+1,…,xi \ensuremath{\mathtt{x}}_{i-1}+1,\ldots,\ensuremath{\mathtt{x}}_i . ويوضّح هذا الشكل 13.7.

العملية find(x) \mathtt{find(x)} في YFastTrie سهلةٌ إلى حدٍّ ما. فنحن نبحث عن x \mathtt{x} في xft \mathtt{xft} ، ونجد قيمةً ما xi \ensuremath{\mathtt{x}}_i مرتبطةً بالـTreap ti \ensuremath{\mathtt{t}}_i . ثمّ نستعمل الدالة find(x) \mathtt{find(x)} الخاصة بالـTreap على ti \ensuremath{\mathtt{t}}_i للردّ على الاستعلام. والدالة بأكملها سطرٌ واحد:

    T find(T x) {
        return xft.find(new Pair<T>(it.intValue(x))).t.find(x);
    }

العملية find(x) \mathtt{find(x)} الأولى (على xft \mathtt{xft} ) تستغرق زمن O(log⁡w) O(\log\ensuremath{\mathtt{w}}) . أمّا العملية find(x) \mathtt{find(x)} الثانية (على Treap) فتستغرق زمن O(log⁡r) O(\log r) ، حيث r r هو حجم الـTreap. وفيما بعد من هذا القسم سنبيّن أنّ الحجم المتوقَّع للـTreap هو O(w) O(\ensuremath{\mathtt{w}}) ، بحيث تستغرق هذه العملية زمن O(log⁡w) O(\log \ensuremath{\mathtt{w}}) .13.1 وإضافة عنصرٍ إلى YFastTrie بسيطةٌ هي الأخرى في معظم الأحيان. تستدعي الدالة add(x) \mathtt{add(x)} الدالة xft.find(x) \mathtt{xft.find(x)} لتحديد الـTreap، t \mathtt{t} ، التي ينبغي أن يُدرج x \mathtt{x} فيها. ثمّ تستدعي t.add(x) \mathtt{t.add(x)} لإضافة x \mathtt{x} إلى t \mathtt{t} . وعند هذه النقطة، ترمي عملةً بميل، تأتي وجوهًا باحتمال 1/w 1/\ensuremath{\mathtt{w}} وظهرًا باحتمال 1−1/w 1-1/\ensuremath{\mathtt{w}} . فإذا جاءت هذه العملة وجوهًا، فسيُضاف x \mathtt{x} إلى xft \mathtt{xft} . وهنا تبدأ الأمورقليلاً في التعقيد. وحين يُضاف x \mathtt{x} إلى xft \mathtt{xft} ، لا بدّ من تقسيم الـTreap t \mathtt{t} إلى Treapَين، t1 \mathtt{t1} و t′ \mathtt{t'} . ويحتوي الـTreap t1 \mathtt{t1} على جميع القيم الأصغر من x \mathtt{x} أو المساوية لها؛ أمّا t′ \mathtt{t'} فهو الـTreap الأصلي، t \mathtt{t} ، بعد إزالة عناصر t1 \mathtt{t1} منه. وبعد إتمام ذلك، نضيف الزوج (x,t1) \mathtt{(x,t1)} إلى xft \mathtt{xft} . ويعرض الشكل 13.8 مثالًا على ذلك.

    boolean add(T x) {
        int ix = it.intValue(x);
        STreap<T> t = xft.find(new Pair<T>(ix)).t;
        if (t.add(x)) {
            n++;
            if (rand.nextInt(w) == 0) {
                STreap<T> t1 = t.split(x);
                xft.add(new Pair<T>(ix, t1));
            }
            return true;
        } 
        return false;
    }

تستغرق إضافة x \mathtt{x} إلى t \mathtt{t} زمن O(log⁡w) O(\log \ensuremath{\mathtt{w}}) . ويبيّن التمرين 7.12 أنّ تقسيم t \mathtt{t} إلى t1 \mathtt{t1} و t′ \mathtt{t'} يمكن أن يتمّ أيضًا في زمن O(log⁡w) O(\log \ensuremath{\mathtt{w}}) متوقَّع. أمّا إضافة الزوج ( x \mathtt{x} ، t1 \mathtt{t1} ) إلى xft \mathtt{xft} فتستغرق زمن O(w) O(\ensuremath{\mathtt{w}}) ، لكنها لا تحدث إلّا باحتمال 1/w 1/\ensuremath{\mathtt{w}} . لذلك فإنّ زمن التشغيل المتوقَّع للعملية add(x) \mathtt{add(x)} هو

O(log⁡w)+1wO(w)=O(log⁡w). \displaystyle O(\log\ensuremath{\mathtt{w}}) + \frac{1}{\ensuremath{\mathtt{w}}}O(\ensuremath{\mathtt{w}}) = O(\log \ensuremath{\mathtt{w}}) \enspace .

تلغي الدالة remove(x) \mathtt{remove(x)} العمل الذي أدّته add(x) \mathtt{add(x)} . فنحن نستعمل xft \mathtt{xft} للعثور على الورقة، u \mathtt{u} ، في xft \mathtt{xft} التي تحوي جواب xft.find(x) \mathtt{xft.find(x)} . ومن u \mathtt{u} ، نحصل على الـTreap، t \mathtt{t} ، التي تحوي x \mathtt{x} ونزيل x \mathtt{x} من t \mathtt{t} . فإذا كان x \mathtt{x} مخزَّنًا أيضًا في xft \mathtt{xft} (و x \mathtt{x} لا يساوي 2w−1 2^{\ensuremath{\mathtt{w}}}-1 )، فإنّنا نزيل x \mathtt{x} من xft \mathtt{xft} ونضيف عناصر الـTreap الخاصة بـ x \mathtt{x} إلى الـTreap، t2 \mathtt{t2} ، التي يخزّنها خَلِفُ u \mathtt{u} في القائمة المترابطة. ويوضّح هذا الشكل 13.9.

    boolean remove(T x) {
        int ix = it.intValue(x);
        Node<T> u = xft.findNode(ix);
        boolean ret = u.x.t.remove(x);
        if (ret) n--;
        if (u.x.x == ix && ix != 0xffffffff) {
            STreap<T> t2 = u.child[1].x.t;
            t2.absorb(u.x.t);
            xft.remove(u.x);
        }
        return ret;
    }

يستغرق العثور على العقدة u \mathtt{u} في xft \mathtt{xft} زمنًا متوقَّعًا قدره O(log⁡w) O(\log\ensuremath{\mathtt{w}}) . ويستغرق إزالة x \mathtt{x} من t \mathtt{t} زمنًا متوقَّعًا قدره O(log⁡w) O(\log\ensuremath{\mathtt{w}}) . ومرةً أخرى، يبيّن التمرين 7.12 أنّ دمج جميع عناصر t \mathtt{t} في t2 \mathtt{t2} يمكن أن يتمّ في زمن O(log⁡w) O(\log\ensuremath{\mathtt{w}}) . وإن كان ذلك ضروريًا، فإنّ إزالة x \mathtt{x} من xft \mathtt{xft} تستغرق زمن O(w) O(\ensuremath{\mathtt{w}}) ، لكنّ x \mathtt{x} لا يكون محتوًى في xft \mathtt{xft} إلّا باحتمال 1/w 1/\ensuremath{\mathtt{w}} . لذلك فإنّ الزمن المتوقَّع لإزالة عنصرٍ من YFastTrie هو O(log⁡w) O(\log \ensuremath{\mathtt{w}}) .

في النقاش السابق، أجّلنا البحت في أحجام الـTreap في هذه البنية إلى وقتٍ لاحق. وقبل أن نختم هذا الفصل، نبرهن النتيجة التي نحتاجها. الملاحظة 13.1 ليكن x \mathtt{x} عددًا صحيحًا مخزَّنًا في YFastTrie، وليكن nx \ensuremath{\mathtt{n}}_\ensuremath{\mathtt{x}} يدلّ على عدد العناصر في الـTreap، t \mathtt{t} ، التي تحوي x \mathtt{x} . عندئذٍ E[nx]≤2w−1 \mathrm{E}[\ensuremath{\mathtt{n}}_\ensuremath{\mathtt{x}}] \le 2\ensuremath{\mathtt{w}}-1 .

إثبات. راجع الشكل 13.10. ولنفرض أنّ المعادلة الأصلية: ترتيب العناصر المخزنة في بنية واي فاست تراي، الصيغة 1 تصف العناصر المخزَّنة في YFastTrie. ويحتوي الـTreap t \mathtt{t} على بعض العناصر الأكبر من x \mathtt{x} أو المساوية لها. وهذه هي xi,xi+1,…,xi+j−1 \ensuremath{\mathtt{x}}_i,\ensuremath{\mathtt{x}}_{i+1},\ldots,\ensuremath{\mathtt{x}}_{i+j-1} ، حيث xi+j−1 \ensuremath{\mathtt{x}}_{i+j-1} هو العنصر الوحيد من هذه العناصر الذي جاءت رمية العملة بميل في الدالة add(x) \mathtt{add(x)} فيه وجوهًا. بمعنى آخر، E[j] \mathrm{E}[j] يساوي العدد المتوقَّع لرمي العملة بميل اللازم للحصول على أول وجوه.13.2 وكلّ رمية العملة مستقلّة وتأتي وجوهًا باحتمال 1/w 1/\ensuremath{\mathtt{w}} ، ولذلك فإنّ E[j]≤w \mathrm{E}[j]\le\ensuremath{\mathtt{w}} . (راجع الملاحظة 4.2 لتحليل هذا في الحالة w=2 \ensuremath{\mathtt{w}}=2 .)

وبالمثل، فإنّ عناصر t \mathtt{t} الأصغر من x \mathtt{x} هي xi−1,…,xi−k \ensuremath{\mathtt{x}}_{i-1},\ldots,\ensuremath{\mathtt{x}}_{i-k} حيث تأتي كلّ رميات العملة الـ k k هذه ظهرًا، وتأتي رمية العملة الخاصة بـ xi−k−1 \ensuremath{\mathtt{x}}_{i-k-1} وجوهًا. لذلك فإنّ E[k]≤w−1 \mathrm{E}[k]\le\ensuremath{\mathtt{w}}-1 ، لأنّها تجربة رمي العملة عينها التي نظرنا إليها في الفقرة السابقة، غير أنّ الرمية الأخيرة لا تُحتسب فيها. وباختصار،

nx=j+k \ensuremath{\mathtt{n}}_\ensuremath{\mathtt{x}}=j+k ، إذًا

■ \qedsymbol

كانت الملاحظة 13.1 هي القطعة الأخيرة في برهان المبرهنة التالية، التي تلخّص أداء YFastTrie: المبرهنة 13.3 يُنفِّذ YFastTrie واجهة SSet للأعداد الصحيحة بطول w \mathtt{w} بت. ويدعم YFastTrie العمليات add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} و find(x) \mathtt{find(x)} في زمن O(log⁡w) O(\log \ensuremath{\mathtt{w}}) متوقَّع لكل عملية. والمساحة التي يستعملها YFastTrie يخزّن n \mathtt{n} قيمةً هي O(n+w) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{w}}) .

حدّ w \mathtt{w} في متطلّب المساحة يأتي من أنّ xft \mathtt{xft} يخزّن دائمًا القيمة 2w−1 2^\ensuremath{\mathtt{w}}-1 . ويمكن تعديل التنفيذ (على حساب إضافة بعض الحالات الإضافية إلى الشيفرة) بحيث يصبح تخزين هذه القيمة غير لازم. وعندئذٍ يصبح متطلّب المساحة في المبرهنة O(n) O(\ensuremath{\mathtt{n}}) .

حواشٍ

... زمن.13.1 وهذا تطبيقٌ لمتراجحة جِنسن (Jensen's Inequality): إذا كان E[r]=w \mathrm{E}[r]=\ensuremath{\mathtt{w}} ، فإنّ E[log⁡r]≤log⁡w \mathrm{E}[\log r] \le \log w . ... وجوهًا.13.2 وهذا التحليل يتجاهل أنّ j j لا يتجاوز أبدًا n−i+1 \ensuremath{\mathtt{n}}-i+1 . غير أنّ هذا يقلّل E[j] \mathrm{E}[j] فقط، ولذلك يبقى الحدّ الأعلى قائمًا. opendatastructures.org

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

أوّل بنية بيانات توفّر عمليات بزمن O(log⁡w) O(\log\ensuremath{\mathtt{w}}) هي عمليات add(x) \mathtt{add(x)} و remove(x) \mathtt{remove(x)} و find(x) \mathtt{find(x)} التي اقترحها van Emde Boas وصارت معروفة منذ ذلك الحين باسم شجرة van Emde Boas (أو الشجرة المتدرّجة (stratified)) [74]. وكانت بنية van Emde Boas الأصلية ذات حجم 2w 2^{\ensuremath{\mathtt{w}}} ، ممّا يجعلها غير عمليةٍ للأعداد الصحيحة الكبيرة. واكتشف Willard [77] بنيتَي البيانات XFastTrie وYFastTrie. وترتبط بنية XFastTrie ارتباطًا وثيقًا بأشجار van Emde Boas؛ فمثلًا، تحلّ جداول التجزئة في XFastTrie محلَّ المصفوفات في شجرة van Emde Boas. أي إنّ شجرة van Emde Boas تخزّن، بدلًا من جدول التجزئة t[i] \mathtt{t[i]} ، مصفوفةً طولها 2i 2^{\ensuremath{\mathtt{i}}} . وبنية أخرى لتخزين الأعداد الصحيحة هي أشجار الدمج (fusion trees) لفريدمان وويلارد [32]. ويمكن لهذه البنية أن تخزّن n \mathtt{n} عددًا صحيحًا بطول w \mathtt{w} بت في مساحة O(n) O(\ensuremath{\mathtt{n}}) بحيث تعمل العملية find(x) \mathtt{find(x)} بزمن O((log⁡n)/(log⁡w)) O((\log \ensuremath{\mathtt{n}})/(\log \ensuremath{\mathtt{w}})) . وباستعمال شجرة دمج حين log⁡w>log⁡n \log \ensuremath{\mathtt{w}} > \sqrt{\log \ensuremath{\mathtt{n}}} و YFastTrie حين log⁡w≤log⁡n \log \ensuremath{\mathtt{w}} \le \sqrt{\log \ensuremath{\mathtt{n}}} ، نحصل على بنية بيانات تشغل O(n) O(\ensuremath{\mathtt{n}}) من المساحة وتستطيع تنفيذ العملية find(x) \mathtt{find(x)} بزمن O(log⁡n) O(\sqrt{\log \ensuremath{\mathtt{n}}}) . وتبيّن نتائج الحدود الدنيا الحديثة لـ P tra cu و Thorup [59] أنّ هذه النتائج مثلى إلى حدٍّ كبير، على الأقلّ بالنسبة إلى البنى التي لا تشغل إلّا O(n) O(\ensuremath{\mathtt{n}}) من المساحة. التمرين 13.1 صمّم ونفّذ نسخةً مبسَّطة من BinaryTrie لا تملك قائمة مترابطة ولا مؤشّرات قفز (jump pointers)، لكنّ العملية find(x) \mathtt{find(x)} تظلّ تعمل فيها بزمن O(w) O(\ensuremath{\mathtt{w}}) .

التمرين 13.2 صمّم ونفّذ تنفيذًا مبسَّطًا لـ XFastTrie لا يستعمل شجرة بادئات ثنائية إطلاقًا. وبدلًا من ذلك، ينبغي أن يخزّن تنفيذك كلّ شيء في قائمة مترابطة ثنائية الوصل وفي w+1 \ensuremath{\mathtt{w}}+1 من جداول التجزئة.

التمرين 13.3 يمكننا التفكير في BinaryTrie على أنها بنية تخزّن سلاسلَ بتاتٍ بطول w \mathtt{w} بحيث يُمثَّل كلُّ سلسلة بتاتٍ بمسارٍ من الجذر إلى ورقة. ووسِّع هذه الفكرة إلى تنفيذ SSet يخزّن سلاسل متغيّرة الطول وينفّذ add(s) \mathtt{add(s)} و remove(s) \mathtt{remove(s)} و find(s) \mathtt{find(s)} بزمن متناسب مع طول s \mathtt{s} . تلميح: ينبغي أن تخزّن كلُّ عقدةٍ في بنية بياناتك جدول تجزئة مفهرسًا بقيم المحارف.

التمرين 13.4 بالنسبة إلى العدد الصحيح x∈{0,…2w−1} \ensuremath{\mathtt{x}}\in\{0,\ldots2^{\ensuremath{\mathtt{w}}}-1\} ، ليكن d(x) d(\ensuremath{\mathtt{x}}) يدلّ على الفرق بين x \mathtt{x} والقيمة التي تعيدها find(x) \mathtt{find(x)} [وإذا أعادت find(x) \mathtt{find(x)} القيمة null \mathtt{null} ، فإننا نعرّف d(x) d(\ensuremath{\mathtt{x}}) بأنّه 2w 2^\ensuremath{\mathtt{w}} ]. فمثلًا، إذا أعادت find(23) \mathtt{find(23)} القيمة 43، فإنّ d(23)=20 d(23)=20 . صمّم ونفّذ نسخةً معدَّلة من العملية find(x) \mathtt{find(x)} في XFastTrie تعمل بزمن O(1+log⁡d(x)) O(1+\log d(\ensuremath{\mathtt{x}})) متوقَّع. تلميح: يحتوي جدول التجزئة t[w] t[\ensuremath{\mathtt{w}}] على جميع القيم، x \mathtt{x} ، التي تحقّق d(x)=0 d(\ensuremath{\mathtt{x}})=0 ، ولذلك سيكون ذلك نقطة بدايةٍ جيدة. صمّم ونفّذ نسخةً معدَّلة من العملية find(x) \mathtt{find(x)} في XFastTrie تعمل بزمن O(1+log⁡log⁡d(x)) O(1+\log\log d(\ensuremath{\mathtt{x}})) متوقَّع.

opendatastructures.org