مقدمة في علوم الحاسوب والبرمجة باستخدام Python › المحاضرة 24: خوارزميات الترتيب (Sorting Algorithms) 26 / 30 السابق التالي

النص الكامل للمحاضرة 24: خوارزميات الترتيب

النص الكامل للمحاضرة 24: خوارزميات الترتيب

المصدر الأصلي: النص الرسمي الإنجليزي، PDF، صفحة المحاضرة الرسمية، فيديو المحاضرة الرسمي.

النسبة والترخيص: آنا بيل (Ana Bell)، مقرر 6.100L: مقدمة في علوم الحاسوب والبرمجة باستخدام Python، خريف 2022، معهد ماساتشوستس للتكنولوجيا، MIT OpenCourseWare. هذه ترجمة عربية تعليمية غير رسمية، وليست معتمدة من MIT ولا تعني تأييده لها. تخضع المواد الأصلية المشمولة والترجمة لرخصة المشاع الإبداعي: النسبة–غير تجاري–المشاركة بالمثل 4.0 (CC BY-NC-SA 4.0): يجب حفظ النسبة، والاستخدام غير التجاري، ومشاركة التكييفات بالرخصة نفسها. لا تشمل الرخصة الافتراضية مواد الأطراف الثالثة المستثناة؛ انظر شروط MIT OCW.

منهج الترجمة والتغطية: تُترجم هنا صفحات التفريغ الرسمي الاثنتا عشرة غير الفارغة، وفق فواصل الصفحات الفعلية في ملف PDF المستخرج، مع الحفاظ على ترتيب الفقرات، وكلام المتحدثة والحضور، والتكرار، والتردد، والتصحيحات الشفهية، والإشارات الصوتية. عناوين «صفحة المصدر» فواصل مادية، وليست تقسيمًا موضوعيًّا. تُحفظ أسماء Python والمعرّفات والتعبيرات الرياضية، وتُقرن المصطلحات المهمة بمقابلاتها الإنجليزية. المواضع التي أسقط فيها التفريغ كلمة أو تضمّن زلة تُوضح بملاحظات مترجم منفصلة، دون اختراع كلام للمتحدثة أو تصحيح صامت للمصدر.

صفحة المصدر 1

MITOCW | المحاضرة 24 من 6.100L، الإصدار 2.mp4

[صرير] [حفيف] [نقرات]

آنا بيل (ANA BELL):

حسنًا، محاضرة اليوم هي آخر محاضرة في موضوع خوارزميات الترتيب (Sorting Algorithms) — أو في موضوع التعقيد (Complexity). وسنتحدث تحديدًا عن خوارزميات الترتيب. فلنتذكر إذًا أين توقفنا في نهاية المحاضرة الماضية.

حاولنا البحث عن عناصر داخل قائمة (List). وهذه مسألة شائعة جدًّا في علوم الحاسوب، تكون فيها القائمة، في الأساس، مجموعة بيانات كبيرة ربما جمعتموها عن، لا أدري، معلومات بيولوجية أو بيانات تجارب فيزيائية. ملف كبير من البيانات. ومن أكثر الأشياء شيوعًا التي قد ترغبون في فعلها بملف كهذا البحث عن شيء داخل ذلك الملف. في الأساس، ستقرؤونه على هيئة قائمة، وستبحثون داخل هذه القائمة عن شيء يهمّكم.

رأينا إذًا خوارزميتين للبحث عن عنصر داخل قائمة. الأولى كانت ببساطة البحث الخطي (Linear Search) المباشر. أجرينا ذلك البحث الخطي على قائمة غير مرتّبة، وأجريناه أيضًا على قائمة مرتّبة. وما رأيناه هو أن التعقيد الزمني في أسوأ حالة (Worst-Case Time Complexity) للبحث عن عنصر داخل قائمة باستخدام البحث الخطي هو Θ(n). هذا أفضل ما استطعنا فعله.

ورأينا خوارزمية البحث الثنائي (Binary Search) طريقة بديلة للبحث عن عنصر في قائمة. لكن الشرط الذي ينبغي الانتباه إليه عند استخدام خوارزمية البحث الثنائي هو ضرورة امتلاك قائمة مرتّبة. لا يمكننا استخدام خوارزمية البحث الثنائي هذه على قائمة غير مرتّبة، لأنها ستعطينا إجابة غير صحيحة.

إذًا، بافتراض أن القائمة مرتّبة، يؤدي البحث الثنائي المهمة على نحو أفضل كثيرًا، وأسرع كثيرًا، في إيجاد العنصر داخل قائمة. يفعل ذلك في زمن Θ(log n)، وهو أسرع من Θ(n). قياسات الزمن باستخدام الكود الذي نفّذناه أظهرت ذلك، وعدّ العمليات أظهر ذلك، ثم النظرية أظهرت ذلك أيضًا.

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

فيصبح السؤال عندئذ أن الزمن الذي يستغرقه إجراء الترتيب، زائد الزمن الذي يستغرقه استخدام البحث الثنائي للبحث عن عنصر داخل تلك القائمة، ينبغي أن يكون أقل من الزمن الذي يستغرقه إجراء البحث الخطي. في هذه الحالة يكون من المنطقي أن نجري ترتيبًا ثم بحثًا ثنائيًّا.

وهذا يعني أن الزمن الذي يستغرقه أن نقوم بـ… أقل من ناتج الطرح. أي Θ(n) ناقص Θ(log n). وهذا يعني أننا نستطيع ترتيب قائمة في زمن أقل من Θ(n). وهذا يعني أننا نستطيع أن… قائمة من دون حتى النظر إلى كل عنصر فيها.

ملاحظة المترجم: الجملتان الأخيرتان ناقصتان في التفريغ الإنجليزي نفسه؛ السياق يناقش زمن ترتيب القائمة وإمكان ترتيبها دون فحص جميع عناصرها. أبقينا موضعي النقص ظاهرين بدل إضافة كلام منسوب إلى المتحدثة. والمقارنة بين رموز Θ هنا منقولة كما قيلت؛ ليست طرحًا جبريًّا دقيقًا لفئات التعقيد.

وهذا غير ممكن، أليس كذلك؟ علينا على الأقل أن نمرّ على كل عنصر في القائمة واحدًا واحدًا لنتأكد أصلًا من أن تلك القائمة مرتّبة، أليس كذلك؟ فحتى في أفضل سيناريو، سيستغرق ترتيب قائمة زمن Θ(n). لذا فمن الواضح أن هذا لن يكون صحيحًا أبدًا.

فيصبح السؤال إذًا: لماذا نتكلف إجراء البحث الثنائي من الأساس؟ حسنًا، لأنك في كثير من الأحيان، إذا نزّلت مجموعة بيانات أو أردت إجراء بحث على قائمة أو مجموعة بيانات حصلت عليها، فلن ترغب، في معظم الأحيان، في فعل ذلك مرة واحدة فقط. سترتّب تلك القائمة مرة واحدة، ثم تجري مجموعة كبيرة من عمليات البحث عن مجموعة كبيرة من الأشياء المختلفة داخل تلك القائمة.

صفحة المصدر 2

إذًا، إذا استطعنا بطريقة ما توزيع كلفة (Amortize) إجراء ترتيب واحد على k عمليات بحث مختلفة، فإنه عندما تصبح k كبيرة جدًّا جدًّا، يكون من المنطقي إجراء البحث الثنائي على القائمة المرتّبة بدل البحث فيها باستخدام البحث الخطي k مرات مختلفة، أليس كذلك؟ عندئذ يصبح الزمن اللازم لإجراء الترتيب مرة واحدة فقط مستوعَبًا نوعًا ما، ويؤول إلى 0 عندما يصبح عدد — العدد k من عمليات البحث — عددًا كبيرًا جدًّا.

ملاحظة المترجم: المقصود في وصف توزيع الكلفة هو نصيب عملية البحث الواحدة من كلفة الترتيب، لا اختفاء الزمن الفعلي الذي استغرقه الترتيب.

حسنًا، لقد أظهرنا بوضوح أنه إذا أردت إجراء عمليات بحث كثيرة على مجموعة بيانات، فمن المنطقي أن تجري الترتيب مرة واحدة فقط. حسنًا، سننظر الآن في مجموعة من خوارزميات الترتيب المختلفة. سنبدأ ببعض الخوارزميات السيئة جدًّا. ثم سنتدرج وصولًا إلى ما يُعدّ إحدى أفضل خوارزميات الترتيب — أفضل ما يمكننا فعله.

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

تُسمى هذه خوارزمية ترتيب بوغو (Bogo Sort)، آتية من ترتيب بوغو، وتُسمى أيضًا الترتيب العشوائي (Random Sort) أو ترتيب القرد (Monkey Sort). الفكرة هنا — وسأستخدم هذه البطاقات ونحن ننظر في خوارزميات الترتيب المختلفة هذه. فكرة ترتيب بوغو هي أننا سنستخدم العشوائية (Randomness) لمساعدتنا على ترتيب القائمة.

إذًا، إذا أردنا مثلًا ترتيب قائمة أو رزمة من البطاقات، ففكرة ترتيب بوغو هي أن نأخذ كل بطاقاتنا، ونلقيها في الهواء، ونلتقطها كما تقع، ثم نتحقق لنرى هل هي مرتّبة. إن كانت كذلك، فقد انتهينا. وإن لم تكن، فسنكرر العملية. سنلقيها في الهواء، وندعها تقع حيثما تقع، ثم نتحقق هل هي مرتّبة، حسنًا؟

سيبدو الكود إذًا على نحو كهذا. يأخذ قائمة L. ويقول: ما دامت القائمة غير مرتّبة، فسنستدعي دالة shuffle هذه من مكتبة random. ودالة shuffle تعيد فقط خلط العناصر في القائمة، أو تعيد ترتيبها عشوائيًّا. فدعوني أريكم كيف يبدو ذلك. ها هي دالة الترتيب.

سأشغّلها. تبدأ إذًا بهذه القائمة التي من الواضح أن عناصرها ليست بالترتيب. واستغرق الأمر نحو 0.2 ثانية من الاستمرار في إعادة خلط عناصر تلك القائمة عشوائيًّا، ليعطيني — لتصبح في ترتيب صحيح. لقد أجريت نحو 30,000 عملية خلط. وإذا شغّلتها مرة أخرى، فستستغرق زمنًا مختلفًا تمامًا في كل مرة تُشغّل فيها.

الآن كانت سريعة جدًّا. لكن إذا واصلت تشغيلها — شغّلتها مرة الليلة الماضية، فاستغرقت نحو ثانيتين. فكما ترون، الأمر عشوائي تمامًا.

ما تعقيد هذه الدالة إذًا؟ من الواضح أنه لن يكون جيدًا جدًّا، حتى في أحسن الأحوال. في أفضل سيناريو، لنفترض أن قائمة الإدخال لدي مرتّبة بالفعل. إذًا في أفضل سيناريو، ستكون ثيتا ببساطة Θ(n)، حيث n طول القائمة. لأننا نحتاج إلى النظر إلى كل عنصر مرة واحدة للتأكد من أنه في مكانه الصحيح.

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

صفحة المصدر 3

سننظر بعد ذلك في خوارزمية ترتيب مختلفة تسمى الترتيب الفقاعي (Bubble Sort)، وهي من أشهر — إحدى خوارزميات الترتيب المشهورة، ليس لأنها جيدة، بل لأن الناس يحبون جدًّا السخرية منها. حسنًا، لذا فمن الأفضل فهمها.

فكرة الترتيب الفقاعي هي أن نبدأ بقائمة غير مرتّبة أصلًا. وكما قلت، سأستخدم هذه مثالًا. وسنحاول مقارنة العناصر المتتالية واحدًا واحدًا. وبينما نفعل ذلك، سنجعل عمليًّا أكبر عنصر يصعد كفقاعة نحو نهاية القائمة، حسنًا؟

سنبدأ إذًا المرور الأول (Pass) على هذه القائمة التي من الواضح أنها غير مرتّبة، وسنقارن أول عنصرين. إذا كان العنصر عند الفهرس (Index) i أصغر من العنصر عند الفهرس i-1، فسأجري تبديلًا (Swap). هنا كان الأمر كذلك، فأجريت تبديلًا.

ثم سأقارن مجموعة العناصر التالية. هذان مرتّبان بالفعل، أليس كذلك؟ وهذان ليسا كذلك، فسأبدلهما. وهذان ليسا كذلك، فسأبدلهما. وهذان ليسا كذلك، فسأبدلهما. ليسا كذلك، فسأبدلهما. وهذان ليسا كذلك، وسأبدلهما، حسنًا؟ سأحركها قليلًا فقط لأن تلك الطاولة اعترضت الطريق.

حسنًا، بعد أن أنهيت المرور الأول، صعد هذا العدد 11 عمليًّا كفقاعة من الموضع الذي كان فيه نحو نهاية القائمة، إلى المكان الذي ينتمي إليه، في الأساس، أليس كذلك؟ مكانه في نهاية القائمة لأنه أكبر عدد.

وبما أنني أجريت تبديلًا واحدًا على الأقل في المرور السابق، فسأمرّ مرة أخرى. لأنه أثناء إجراء التبديل ربما أفسدت ترتيب شيء كان مرتّبًا نوعًا ما بالفعل. سأبدأ إذًا من جديد تمامًا. سأقول: هل هذان في ترتيب صحيح؟ نعم. وهذان؟ لا، فأبدلهما. وهذان؟ لا، فأبدلهما. وهذان؟ لا، فأبدلهما، وأبدّل، وأبدّل.

والآن، بعد مرورين، جعلت عمليًّا العدد التالي في الكِبَر يصعد كفقاعة. يمكنكم أن تروا ذلك. حسنًا، في المرور التالي سأضطر إلى المرور مرة أخرى لأنني — أجريت تبديلًا واحدًا في المرة الماضية.

إذًا، سأقارن هذين مجددًا. أحتاج إلى تبديلهما. وهذان، أحتاج إلى تبديلهما. وهذان، أحتاج إلى تبديلهما. أبدلهما، أبدلهما، وهذان بالترتيب، وهذان بالترتيب، حسنًا؟ مجددًا، يحتاج 5 و4 إلى التبديل، ويحتاج 5 و1 إلى التبديل، ويحتاج 5 و0 إلى التبديل، ويحتاج 5 و2 إلى التبديل. هذان بالترتيب، وهذان بالترتيب، وهذان بالترتيب.

يحتاج 4 و1 إلى التبديل. هذان يحتاجان إلى تبديل. هذان يحتاجان إلى تبديل. مرتّبان، مرتّبان، مرتّبان، مرتّبان. بعد ذلك، يحتاج هذان إلى التبديل. هذان بخير، وهذان بخير، وهكذا.

والآن بما أنني لم — سأجري تحققًا أخيرًا واحدًا. هذه كلها بالترتيب، أليس كذلك؟ إذًا، الآن وقد لم أُجرِ أي تبديلات أخرى، يمكنني القول إن هذه القائمة أصبحت مرتّبة، أليس كذلك؟ فمع كل مرور، أجعل أكبر عنصر يصعد كفقاعة نحو نهاية القائمة.

إذًا، في نهاية n مرات مرور، ستكون العناصر العلوية — آخر n عناصر — مرتّبة، حسنًا؟ سيبدو الكود على نحو كهذا. لدي هنا علم منطقي (Boolean Flag). يتتبع هل أجريت تبديلًا أم لا. إذا أجريت تبديلًا، فأعرف أنني أحتاج إلى المرور والتحقق مرة ثانية من أن كل شيء ما زال بالترتيب، عن طريق مقارنة الفهرسين i وi-1.

صفحة المصدر 4

ولفعل ذلك، لدينا حلقة for تمرّ من 1 حتى نهاية القائمة، لأنني سأقارن العنصر عند الفهرس i بالعنصر عند i-1. لو بدأت عند 0، لحصلنا على خطأ فهرس خارج الحدود (Index Out of Bounds). لهذا أبدأ عند 1 هناك.

ملاحظة المترجم: عبارة الخطأ منقولة كما وردت. في Python، الفهرس -1 صالح للقائمة غير الفارغة ويشير إلى آخر عنصر؛ البدء عند 0 هنا سيقارن أول عنصر بآخر عنصر بدل مقارنة زوج متجاور بالاتجاه المقصود، ولا يسبب بحد ذاته الخطأ الموصوف.

ثم يتحقق ما داخل حلقة for فقط مما إذا كان العنصر عند، أظن، j — أستخدم بدلًا من j، بدلًا من i. ما إذا كان j وj-1 في الترتيب الصحيح. الآن هما كذلك، طبعًا. لكن عندما بدأت هذا العرض أول مرة، لم يكونا كذلك، أليس كذلك؟ إذًا، ما دام j-1 وj ليسا بالترتيب، فأجرِ تبديلًا. هنا أغيّر فقط — أستخدم حيلة الصفّ (Tuple) هذه لإجراء تبديل العنصرين j-1 وj. وأعيد أيضًا ضبط العلم المنطقي الذي يفيد بأنني أجريت التبديل إلى True. ويستمر ذلك حتى لا أُجري أي تبديلات أخرى. وعندئذ لن يمرّ الكود عبر حلقة while بعد ذلك.

فلنطبع كيف يبدو هذا فعلًا عندما نشغّله على قائمتنا. هنا لدي قائمتي الأصلية. كل مجموعة هنا تفصلها هذه السطر الفارغ تمثل دورة واحدة من حلقة while لدي. أي هذا الجزء هنا، أليس كذلك؟ تكرار واحد (Iteration) من حلقة while لدي. وكل سطر داخل هذه المجموعة يمثل تكرارًا واحدًا من حلقة for لدي، حسنًا؟

ما يمكننا رؤيته إذًا هو أننا، عندما نقارن 4 و8، يصعد 8 كفقاعة خطوة واحدة إلى الجانب. ثم نقارن 8 و6. ينتقل 8 كفقاعة إلى الجانب، وهكذا وهكذا، حتى يلتقي 11. وبعد ذلك يبدأ 11 بالصعود كفقاعة حتى النهاية.

إذًا، في نهاية المرور الأول لحلقة while، يصبح 11 في موضعه الصحيح في أعلى القائمة — في نهاية القائمة. في المرور التالي لحلقة while، أجعل عمليًّا 8 يصعد كفقاعة إلى النهاية، أي إلى هنا. وفي المرور التالي لحلقة while يصعد 6 كفقاعة إلى النهاية. وفي المرة التالية يصعد 5 إلى النهاية. ثم 4، ثم 2، ثم 1، ثم 0.

حسنًا، فما — نعم، سؤال.

أحد الحضور (AUDIENCE):

سؤال صغير فقط. هل هناك أي سبب يجعلك تحتاجين إلى الأقواس؟

آنا بيل (ANA BELL):

آه، لسنا بحاجة إلى الأقواس. أعني، يمكنك وضعها. لن تضرّ. لكن إذا لم تضعها، فلا بأس. تعرف Python أنها تجري إسنادًا (Assignment) واحدًا واحدًا. أي هذا إلى ذاك، وذاك إلى هذا. نعم.

حسنًا، فلننظر إلى تحليل التعقيد في أسوأ حالة. الحالة السهلة التي يمكننا معرفتها بالفعل هي حلقة for الداخلية هذه، أليس كذلك؟ تمرّ هذه من واحد إلى طول القائمة، فتلك ثيتا القائمة المترابطة.

ملاحظة المترجم: وردت هنا عبارة «linked list» في التفريغ الإنجليزي، وكذلك لاحقًا في وصف عدد المقارنات. المقصود في التحليل هو طول القائمة len(L)، لا بنية بيانات القائمة المترابطة (Linked List).

لكن لدينا تعقيد آخر. لأن قائمتنا، في أسوأ سيناريو، مقلوبة تمامًا. ولهذا ستتكرر حلقة while هذه هنا في الأعلى len(L) مرات، لأننا سنجعل كل واحد من العناصر يصعد كفقاعة عبر القائمة كلها حتى نهايتها.

إذًا، سيكون تعقيد حلقة while هذه Θ(len(L)) أيضًا. لأن التفكير في أسوأ حالة يعني أن يكون أكبر عنصر لدينا هنا، وثاني أكبر عنصر هنا، وهكذا.

حسنًا، التعقيد في أسوأ حالة لهذه الدالة هو Θ(len(L)²). أو Θ(n²)، حيث n طول القائمة، كي نقلل طول التعبير فحسب. حسنًا، من الواضح أنها ليست خوارزمية ترتيب رائعة. إنها غير كفؤة إلى حد كبير في بعض الأشياء التي تفعلها؛ أي إنها، بمجرد أن تصل — أن ترتّب بعض الأشياء هنا في الأعلى، تواصل مقارنتها حتى النهاية. فهي دائمًا تمرّ حتى طول القائمة.

صفحة المصدر 5

يمكننا النظر إلى خوارزمية ترتيب أخرى تسمى الترتيب بالاختيار (Selection Sort)، تشبه الترتيب الفقاعي نوعًا ما، لكنها تفعل الأشياء بطريقة أذكى قليلًا. دعوني أبدأ مجددًا بقائمة غير مرتّبة، حسنًا؟ ولنرَ كيف سيجري الترتيب بالاختيار ذلك. حسنًا، لنضع تلك هناك.

حسنًا، فكرة الترتيب بالاختيار هي أننا مع كل مرور سنقرر أيّ هذه العناصر مكانه عند فهرس ما. ففي المرور الأول سأقرر أيّ عنصر مكانه عند الفهرس 0. وفي المرور الثاني سأقرر أيّ عنصر مكانه عند الفهرس 1. وفي الثالث، أيّ عنصر مكانه عند الفهرس 2، وهكذا، حسنًا؟

والطريقة التي سنفعل بها ذلك هي أن نقول: حسنًا، سآخذ هذا العنصر، إنه الأول في القائمة، وهو الموجود حاليًّا عند الفهرس 0. وسأقارنه بكل عنصر من بقية القائمة. وبينما أجد عنصرًا أصغر من الموجود حاليًّا هناك، سأبدلهما، لأنني أعرف أن مكان ذلك الأصغر، بوضوح، هو الفهرس 0.

سأقارن إذًا 5 بـ8. سأقول: حسنًا، 5 أصغر من 8، لذلك مكانه حاليًّا عند الفهرس 0. أقارن 5 بـ1. الواحد أصغر، فسأجري تبديلًا وأقول إن مكان 1 هنا. 5 مع 11، مكان 1 — عذرًا، 1 مع 11، مكان 1 هنا. 1 مع 6، مكان 1 هنا. 1 مع 2، ما زال 1 هناك. 1 مع 0، حسنًا، 0 أصغر من 1، فدعوني أبدله. 0 مع 4، انتهينا.

الآن، في نهاية المرور الأول، قررت أن 0 هو الأصغر بين كل العناصر هنا، لذا مكانه عند الفهرس 0. وفي المرة التالية، في المرور الثاني، لن أقلق بشأن هذا العنصر. أعرف أنه الأصغر بالفعل، فسأحدد أيّ عنصر مكانه عند الفهرس 1، أليس كذلك؟

إذًا 8 هو الأول هناك، وهو الموجود حاليًّا عند الفهرس 1. سأبدأ إذًا بافتراض أنه الذي ينتمي إلى هذا الموضع. وسأقارنه بكل الآخرين على التوالي. أي 8 مع 5. من الواضح أن 5 أصغر من 8. 5 مع 11، 5 أصغر. 5 مع 6، 5 أصغر. 5 مع 2 يحتاجان إلى تبديل لأن 2 أصغر. 2 مع 1، نبدلهما مجددًا. 1 أصغر. ثم 1 مع 4، انتهينا.

إذًا، في نهاية المرور الثاني، قررت أن مكان 1 عند الفهرس التالي. أصبح هذان العنصران الآن في مكانيهما الصحيحين. إنهما بالترتيب. حسنًا، في المرور الثالث سنقرر أيّ عنصر مكانه عند الفهرس التالي، الفهرس 2. أي 8 مع 11، لا مشكلة. 8 مع 6، نحتاج إلى تبديل. 6 مع 5، نحتاج إلى تبديل. 5 مع 2، نحتاج إلى تبديل. 2 مع 4، كل شيء بخير.

ثلاث مرات مرور، وأول ثلاثة عناصر بالترتيب. الآن نحتاج فقط إلى معرفة أيّ واحد من هذه العناصر المتبقية مكانه في المستوى التالي. أي 8 مع 11، نجري تبديلًا. 8 مع 6، نجري التبديل. 6 مع 5، نجلب 5 إلى هنا. 5 مع 4، نجلبه إلى هنا.

حسنًا، مجددًا، 11 مع 8، بدّلنا هذين. 8 مع 6، بدّلنا هذين. 6 مع 5، بدّلناهما، حسنًا؟ فكما ترون، بينما أشقّ طريقي لأعرف أيّ عنصر مكانه عند الفهرس التالي، يكون لدي عدد أقل من العناصر لأقرر بينها أيّها مكانه عند الفهرس التالي، أليس كذلك؟ هنا يحتاج 8 و11 إلى التبديل. ويحتاج 8 و6 إلى التبديل. ثم أخيرًا، هكذا.

حسنًا، هذا أكثر كفاءة قليلًا من حيث إننا لا نقارن مجموعة من الأزواج طوال الوقت حتى طول القائمة كله. يبدو الكود إذًا هكذا. لدي حلقة for تمرّ عبر طول القائمة. وحلقة for داخلية لا تبدأ إلا عند i وتمرّ حتى نهاية القائمة، أليس كذلك؟

صفحة المصدر 6

إذًا، على خلاف الترتيب الفقاعي، الذي كان يبدأ عند 1 ويمرّ حتى نهاية القائمة طوال الوقت، أبدأ هنا عند i وأمرّ حتى نهاية القائمة. لأنني في الترتيب بالاختيار، مع كل مرور، قررت أيّ عنصر مكانه عند فهرس محدد. لذلك لم أعد بحاجة إلى القلق بشأن مقارنة ذلك العنصر بكل الآخرين.

إذًا، عندما كنا في هذا الوضع، كنا قد قررنا أن هذه العناصر بالترتيب، ولم أكن بحاجة إلا إلى مقارنة هذه الثلاثة فيما بينها لأقرر أيّها يناسب الموضع التالي. كان كل ما عداها مرتّبًا بالفعل.

ما تحليل التعقيد لهذا إذًا؟ سيبدو هذا مشابهًا جدًّا لدالة diameter من المحاضرة الماضية. لأن diameter أيضًا كانت فيها هذه الحركة الغريبة، حيث بدأنا عند i ومررنا حتى طول القائمة. حسنًا، سيكون Θ(len(L)²) مجددًا.

إذًا، توجد طريقتان للتفكير في هذا، حسنًا؟ الأولى هي النظر إلى كل حلقة على حدة. من الواضح أن الحلقة الخارجية تمرّ Θ(len(L))، أليس كذلك؟ لا شك في ذلك. فهي تمرّ ببساطة عبر نطاق طوله len(L).

الحلقة الداخلية أصعب قليلًا، أليس كذلك؟ لأنها لا تمرّ دائمًا من عدد ثابت ما حتى طول القائمة.

لكن ما يمكننا التفكير فيه هو المتوسط، أليس كذلك؟ في المرة الأولى، عندما نحاول معرفة العنصر الذي مكانه عند الفهرس الأول، أو الفهرس 0، مررنا حتى طول القائمة. اضطررنا إلى المقارنة بكل الآخرين. وفي المرة التالية نحتاج إلى المقارنة بالقائمة المترابطة ناقص 1، ثم القائمة المترابطة ناقص 2. ثم في النهاية لم يكن لدينا إلا عنصر واحد نقارنه.

إذًا، في المتوسط تمرّ تلك الحلقة الداخلية len(L)/2 مرات، أليس كذلك؟ في المتوسط، نحتاج إلى فحص نحو نصف العناصر في القائمة لإجراء المقارنة.

إذًا، إذا كانت الحلقة الداخلية هنا، في المتوسط، هي Θ(len(L)) — هي len(L)/2، فإن Θ(len(L)/2) هي Θ(len(L)). يوجد فقط عامل 0.5 أمام ذلك.

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

حسنًا، في المرة الأولى تمامًا، من المرور الخارجي الأول حتى نهاية القائمة، ستجري نحو len(L) مقارنات. وفي المرة التالية ستجري len(L)-1 مقارنات، ثم len(L)-2 مقارنات، وهكذا وهكذا، وصولًا إلى مقارنة واحدة فقط.

إذًا، إذا أجرينا ذلك الجمع: 1 زائد 2 زائد 3 زائد كل الأعداد حتى len(L)، فإن المجموع — تصبح تلك الصيغة len(L) * (len(L)+1) / 2، فيصبح ذلك len(L)²/2 + len(L)/2، ويصبح ذلك Θ(len(L)²).

هذه إذًا طريقتان للتفكير في التحليل، وهذا شيء شائع إلى حد كبير سترونه. لكن مجرد أننا نبدأ عند i لا يعني أن ذلك يقلل تعقيد هذه الدالة تقليلًا كبيرًا. لا يقلله برتبة كاملة؛ بل يقلله إلى النصف فقط، أليس كذلك؟ لذلك فهو ما زال Θ(len(L)).

ملاحظة المترجم: العبارة الأخيرة Θ(len(L)) منقولة كما وردت في الكلام. تعقيد الدالة كاملة، وفق التحليل الذي سبقها، هو Θ(len(L)²)؛ أما عامل النصف فلا يغيّر رتبة النمو (Order of Growth).

صفحة المصدر 7

حسنًا، يمكننا فعلًا إجراء تعديل صغير على هذا. لأنكم ربما لاحظتم أنه كان من غير الكفؤ قليلًا إجراء التبديل كل مرة ألاحظ فيها عنصرًا آخر أصغر، أليس كذلك؟ لم يكن عليّ إجراء التبديل. كل ما كان عليّ فعله هو أن أتتبع، نوعًا ما، من خلال متغير، أصغر عدد رأيته حتى الآن، وأجري التبديل في النهاية فقط عندما أحدد أن ذلك هو أصغر عدد.

فالتعديل، في الأساس، إذا كانت هذه قائمتي، يقول: مهلًا، سأنظر إلى هذا العنصر الذي مكانه في الخانة الأولى تمامًا. 8 هو الأول. ثم سأنظر عبر العناصر حتى نهاية القائمة وأتتبع الأصغر. 4، و1 هو الأصغر حاليًّا، و6 ليس كذلك، و5 ليس كذلك، و9 ليس كذلك، و2 ليس كذلك، و0 أصغر من 1. فإذا رأيت أن 0 هو الأصغر، أبدله عندئذ.

إذًا، لا أجري إلا تبديلًا واحدًا في النهاية. في المرور التالي سأقرر أيّ عنصر مكانه عند هذا الفهرس. 1 هو أصغر ما أراه، فأجري التبديل في النهاية فقط. ثم أقرر أيّ عنصر مكانه هنا. 2 هو الأصغر بين كل ما تبقّى، فيذهب 2 هناك.

أجري إذًا كل هذه المقارنات، لكنني لا أجري التبديل إلا في النهاية عندما أقرر: مهلًا، هذا هو أصغر عنصر. دعوني أبدله فقط مع الموجود حاليًّا هناك، أليس كذلك؟ إذًا سيمرّ ذلك فقط حتى نهاية هذا الجزء.

حسنًا، كتبت ذلك التعديل هنا. هذا هو الترتيب بالاختيار، تمامًا كما رأيناه. يمكننا أن نرى هنا أن المرور الأول للحلقة الخارجية لدينا فيه len(L) مقارنات ينبغي إجراؤها، لأننا دائمًا نقارن هذين، ثم الموجود حاليًّا عند هذا الفهرس و— العنصر عند الفهرس التالي، والموجود حاليًّا عند هذا الفهرس والعنصر عند الفهرس التالي، وهكذا.

في المرور الأول، أجريت len(L) تبديلات — أو عذرًا، len(L) مقارنات. وفي المرور التالي أجريت len(L)-1 مقارنات، لأنني لا أحتاج إلى النظر إلى 0 بعد الآن. أعرف بالفعل أنه في المكان الصحيح. ثم بعد ذلك أجري len(L)-2 مقارنات، ثم len(L)-3 مقارنات.

فكما ترون، ونحن نتقدم عبر حلقتنا الخارجية، يصبح لدينا عدد أقل فأقل من المقارنات التي ينبغي إجراؤها. لذا قد تظنون أن هذا أفضل كثيرًا، لكن تحليل تعقيد ثيتا يقول إنه ليس كذلك. هذا إذًا هو الترتيب بالاختيار الأصلي.

أما تعديل الترتيب بالاختيار فيبدو أكثر تعقيدًا قليلًا، لكنه لا يجري تبديلًا. فهو لا يجري التبديل إلا هنا في الأسفل، كما ترون. يجريه بعد أن ينتهي من حلقة for الداخلية هذه. وكل ما تفعله حلقة for الداخلية هذه هو التحقق — إجراء المقارنات وتتبع أصغر عدد تراه في هذا المتغير الذي اسمه smallest. والفهرس المرتبط بذلك المتغير smallest في smallest_j.

إذا نظرنا الآن إلى تحليل هذا، حسنًا، فما زالت لدينا حلقة for خارجية تمرّ عبر len(L). وما زالت لدينا حلقة for داخلية تمرّ من i إلى len(L). كل ما يفعله ذلك هو حذف هذا السطر هنا. ينفّذه مرة واحدة فقط في النهاية، لكنه ما زال يجري كل هذه المقارنات. ما زال مضطرًّا إلى النظر عبر كل هذه العناصر، زوجًا بعد زوج، لإجراء المقارنة. لذلك، في الواقع، ليس لهذا التسريع الطفيف أثر كبير في تعقيد ثيتا لدي. سيظل Θ(len(L)²).

هل هناك أي أسئلة حتى الآن عن خوارزميات الترتيب هذه؟ من الواضح إذًا أننا لا نؤدي عملًا جيدًا جدًّا في التفكير في طريقة مميزة لإجراء الترتيب، أليس كذلك؟ لأن كل هذه التعديلات المختلفة، التي نجري فيها تسريعات طفيفة هنا وهناك، لا تُحدث تغييرًا كبيرًا بما يكفي لتنقلنا إلى فئة تعقيد (Complexity Class) أقل برتبة كاملة.

صفحة المصدر 8

لذلك علينا أن نفكر في المسألة بطريقة مختلفة تمامًا. فالنهج التكراري (Iterative Approach) لا ينجح معنا، حيث تكون لدينا في الأساس حلقة تفعل شيئًا، ثم حلقة أخرى تجري نوعًا من المقارنة، أليس كذلك؟ لن يمنحنا هذا تسريعًا ينقلنا إلى فئة تعقيد أقل برتبة كاملة.

بدلًا من ذلك، ما سنفعله هو تناول المسألة بطريقة مستوحاة نوعًا ما من البحث الثنائي — البحث بالتنصيف (Bisection Search)، أو البحث الثنائي. في البحث بالتنصيف، لم نكن ننظر إلى كل عنصر واحدًا واحدًا. كنا نأخذ قائمتنا ونقسمها إلى نصفين، أليس كذلك؟

يمكننا إذًا محاولة اتباع نهج مشابه هنا، وهذا ما تفعله خوارزمية الترتيب بالدمج (Merge Sort) هذه. ستأخذ قائمة أصلية وتقسم هذه القائمة إلى نصفين مع كل خطوة. وستفعل ذلك عوديًّا (Recursively). ستكون خوارزمية فرّق تسد (Divide and Conquer). ستقسم هذه القائمة إذًا عوديًّا إلى نصفين في كل خطوة.

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

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

إذًا، إذا حدث بطريقة ما أن خوارزميتي، التي كان لدي فيها قائمة كاملة واحدة تضم كل هذه العناصر الثمانية هنا، قسمت نفسها، وعندما عادت إلى التجمع أعطتني قائمتين فرعيتين (Sublists) مرتّبتين كلٌّ منهما بذاتها، فهذه قائمة مرتّبة وهذه قائمة مرتّبة بذاتها. عندئذ توجد خطوة دمج ذكية جدًّا يمكننا إجراؤها.

يمكننا أن ندرك أنه، إذا كانت هذه القائمة مرتّبة بذاتها وهذه القائمة مرتّبة بذاتها، فلكي نحدد أصغر عنصر بين القائمتين، لا نحتاج إلا إلى النظر إلى أول عنصر من كل قائمة — كل قائمة فرعية، أليس كذلك؟ هذا هو الأصغر بين هؤلاء. وهذا هو الأصغر بين هؤلاء.

إذًا، إذا قارنت 0 و4 فقط، فأعرف أن 0 سيكون الأصغر بين الجميع. ثم تتبقى لدي هذه القائمة، وهي ما زالت مرتّبة. وهذه القائمة، إنها. ما زالت مرتّبة. أنظر إلى أول عنصر من كل واحدة من هاتين القائمتين. أيّهما الأصغر؟ حسنًا، 1 أصغر من 4. فسآخذ هذا وأقول إن هذا يأتي بعده، أليس كذلك؟

نستخدم إذًا خاصية أن هاتين القائمتين مرتّبتان كلٌّ بذاتها. فلا أحتاج إلا إلى مقارنة أول عنصر من كل قائمة. ثم أقارن 2 و4. أقول إن 2 أصغر من 4. 6 و4، يأتي 4 بعد ذلك. 6 و5، يذهب 5 هنا. 6 و8، يذهب 6 هنا. 8 و11، حسنًا، هما بالترتيب بالفعل، فقد انتهينا.

إذًا، لمست خطوة الدمج الذكية هذه كل عنصر مرة واحدة فقط لإدخاله إلى قائمتي المرتّبة الرئيسية. لم أحتج إلى إجراء مرات مرور متعددة. احتجت فقط إلى النظر إلى أول عنصر من كل قائمة.

إذًا، إذا استطعنا بطريقة ما الوصول إلى هذه النقطة، حيث لدينا هاتان القائمتان الفرعيتان المرتّبتان، فيمكنني إجراء دمج صغير بمجرد النظر إلى أول عنصر في كل واحدة من هاتين القائمتين المرتّبتين. وهذا يعطيني في الأساس تعقيدًا Θ(n) لإجراء الدمج من قائمتين مرتّبتين أصغر إلى قائمة مرتّبة كبيرة واحدة، أليس كذلك؟

إليكم إذًا فكرة خوارزمية الترتيب بالدمج هذه. سنأخذ قائمة أصلية كبيرة غير مرتّبة تحتوي على n عناصر. إنها غير مرتّبة. سنقسمها إلى نصفين. وطبعًا، لا يوجد ترتيب لهذين النصفين، لذا من المحتمل أن يكونا غير مرتّبين إلى حد كبير.

صفحة المصدر 9

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

ثم أقسمها إلى نصفين مرة أخرى، ليصبح لدي قائمة ذات عنصر واحد في كل — قائمة ذات عنصر واحد. ربما ستكون بعض هذه القوائم فارغة. إذًا، إذا استطعت الوصول إلى هذه النقطة، حيث لا يكون لدي إلا قوائم تحتوي كل قائمة فيها على عنصر واحد، فإن تلك القوائم مرتّبة بذاتها، أليس كذلك؟ عنصر فيه 1 فقط — قائمة فيها 1 فقط هي قائمة مرتّبة.

عندئذ يمكنني بدء خطوة دمج تقول: مهلًا، هاتان هنا، اللتان لم تكونا مرتّبتين أصلًا، فلندمج الأزواج مجددًا فقط. وسنفعل ذلك بطريقة الدمج الذكية تلك، أليس كذلك؟ ستندمج هاتان مجددًا لتعطياني كل هذه القوائم الثماني المرتّبة ذات الطول اثنين.

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

ملاحظة المترجم: عدد «ثماني قوائم ذات طول اثنين» منقول كما ورد في التفريغ. في المثال العملي التالي، الذي يبدأ بثمانية عناصر، تنتج عن أول دمج أربع قوائم ذات طول اثنين؛ لا نغيّر زلة العدد في الكلام الأصلي بصمت.

سنأخذ إذًا قائمتنا الأصلية هكذا. سأحاول فعلًا تنفيذ هذا. سأحتاج إلى بعض المساحة لتحريكها إلى الأسفل. هذه إذًا قائمتي الأصلية غير المرتّبة. لنحرك هذه إلى هنا. شيء من هذا القبيل.

كيف ستكون العملية إذًا؟ الخطوة الأولى هي تقسيمها إلى نصفين. الخطوة الثانية، تقسيم كل واحدة من هاتين إلى نصفين. الخطوة الثالثة، تقسيم كل واحدة منها إلى نصفين. أصبح لدي الآن مجموعة من القوائم لا تحتوي كل واحدة منها إلا على عنصر واحد.

الآن أحتاج إلى دمجها مجددًا. دمج هاتين معًا لإعطائي قائمة ذات عنصرين يقول: سأقارنهما فقط. الأصغر يأتي أولًا. والأكبر يأتي ثانيًا. مجددًا، هاتان، قارنهما. الأصغر يأتي أولًا. والأكبر يذهب ثانيًا. مجددًا، قارنهما. مجددًا، قارنهما.

إذًا، أجريت الآن دمجًا واحدًا، أصبح لدي بعده أربع قوائم مرتّبة كلٌّ بذاتها، أليس كذلك؟ سأدمج الآن هاتين معًا، وهاتين معًا. فلا أنظر إلا إلى أول عنصر من كل واحدة. أقارن إذًا 0 و2، وأعرف أن 0 أصغر من 2.

ثم 2 و8، 2 أصغر. ثم 8 و11، ثم 11. أصبحت هذه القائمة الآن مرتّبة بذاتها. العملية نفسها هنا، قارن أول عنصر فقط من كل قائمة. يأتي 1 أولًا، ثم يأتي 4 بعده، ثم يأتي 5 بعده، ثم 6، أليس كذلك؟

الآن وصلت إلى الموضع نفسه تمامًا الذي كنت فيه عندما كنت أتحدث عن خطوة الدمج، أليس كذلك؟ عندما أريتكم أننا نستطيع الوصول إلى ذلك الموضع. لدي إذًا هاتان القائمتان المرتّبتان كلٌّ بذاتها لأدمجهما. فلا أحتاج إلا إلى النظر إلى أول عنصر في كل قائمة. إذًا ها هو 0 لدي يأتي أولًا. 1 يُقارَن بـ2، يأتي 1 بعده. 2 يُقارَن بـ4، يأتي 2 بعده. 4 يُقارَن بـ8، يأتي 4 بعده. 5 يُقارَن بـ8، يأتي 5 بعده.

6 يُقارَن بـ8، يأتي 6 بعده. وقد أزلت كل العناصر في هذه القائمة، فأعرف أنني لا أحتاج إلا إلى أخذ كل ما تبقّى هنا، بأي ترتيب هو فيه، لأن كل شيء مرتّب بالفعل، حسنًا؟

صفحة المصدر 10

هذه إذًا خوارزمية الترتيب بالدمج كاملة، أليس كذلك؟ الآن، إذا أجريت هذا العرض، فسيُريكم فعلًا الخطوات الدقيقة التي تنفّذها الخوارزمية العودية (Recursive Algorithm). ولن يكون ذلك بالترتيب نفسه الذي أريتكم إياه. لن يكون تقسيم هذه إلى نصفين، ثم التقسيم إلى نصفين، وهكذا.

لأننا عندما ننفّذ العودية (Recursion)، سنعرف أولًا كيف نقوم بـ… قائمة فرعية يسرى، أليس كذلك؟ إذا كانت لدي قائمتي الأصلية غير المرتّبة هنا، فسنعرف كيف نقوم بـ… قائمة فرعية يسرى أولًا. هذه خطوة عودية لم نصل إلى الحالة الأساسية (Base Case) لها بعد. ما زال علينا ترتيب هذه القائمة. لذا سنحاول أن… القائمة الفرعية اليسرى لهذه. ثم سنحاول أن… القائمة الفرعية اليسرى لهذه.

ملاحظة المترجم: يسقط التفريغ الرسمي فعلًا في أربعة مواضع من الفقرة السابقة؛ تشير الجمل المحيطة إلى ترتيب القائمة الفرعية اليسرى. تُظهر نقاط الحذف مواضع النقص في المصدر، ولا تحذف كلامًا موجودًا فيه.

إذًا سنفعل شيئًا يبدو مشابهًا جدًّا لمتتالية فيبوناتشي (Fibonacci Sequence) — نعم، فيبوناتشي، أليس كذلك؟ فيبوناتشي لـn تساوي فيبوناتشي لـn-1 زائد فيبوناتشي لـn-2، أليس كذلك؟ في تلك الحالة بعينها، عندما كنا نحاول إيجاد فيبوناتشي لـ6 أو شيء كهذا، كنا نذهب ونستكشف الجانب الأيسر حتى نصل إلى حالة أساسية، أليس كذلك؟

ولم نكن نستطيع الصعود وإجراء النصف الآخر إلا بعد أن نصل إلى الحالة الأساسية. لذا ستبدو هذه الخوارزمية مشابهة جدًّا لذلك. ها هي إذًا قائمتي الأصلية. أقسم الجانب الأيسر لأحاول معرفة كيف أدمج تلك، وصولًا إلى تلك القوائم اليسرى. ستجري إذًا مقارنة 8 و4، ويأتي 4 قبل 8.

ثم سأدمج — ثم سأدمج 1 و6 وحدهما. وهما مرتّبان بالفعل، كما نعرف. ثم سندمج 4 و8 مجددًا مع 1 و6 باستخدام خطوة الدمج تلك. ثم سنفعل الشيء نفسه للجانب الأيمن ذاك، واحدًا واحدًا. سنجري مثالًا آخر نمرّ فيه عبر هذا خطوة خطوة.

والآن أصبح لدينا قائمتانا ذواتا العناصر الأربعة معًا. فنحن الآن نجري فقط خطوة الدمج النهائية، حيث نقرر أيّ عنصر يأتي بعد ذلك، حسنًا؟ فلننظر إلى كود الدمج. وهذا — ليس بعد، عذرًا. فلننظر إلى خطوة الدمج مرة أخرى.

إذًا، إذا كانت لدي قائمتان أحاول دمجهما، فالفكرة هي أن تنظر إلى أول عنصر من كل واحدة، أليس كذلك؟ أولًا، مقارنة 1 و2 تعني أن 1 أصغر، فيدخل إلى نتيجتي. تجري مقارنة 5 و2، و2 أصغر، فيدخل 2 إلى النتيجة.

تجري مقارنة 5 و3، و3 أصغر، فيدخل 3 إلى النتيجة، وهكذا وهكذا. نواصل إذًا هذه العملية، حيث نستمر فقط في النظر إلى أول عنصر حتى تصبح إحدى القائمتين فارغة، أليس كذلك؟ هذه قائمتي الفرعية اليسرى، وهذه قائمتي الفرعية اليمنى.

عندما تصبح إحدى هاتين القائمتين فارغة، لا أعود بحاجة إلى مقارنة 18 بلا شيء، أليس كذلك؟ كل ما أحتاج إلى فعله هو أخذ كل هذه العناصر وإلحاقها في النهاية. فلننظر إلى الكود الخاص بخطوة الدمج وحدها. لا نحتاج إلى النظر إلى كود الخوارزمية كاملة بعد. لكن كود خطوة الدمج هو فقط الجزء الذي ينقلنا من قائمتين مرتّبتين إلى قائمة مرتّبة أكبر واحدة.

إذًا، ينفّذ تلك الخطوة دفعة واحدة. هنا يحدث الجزء الرئيسي. سيستخدم هذا فقط الفهارس لمقارنة أيّ عنصر نحتاج إلى أخذه تاليًا. فإذا كان لدي شيء كهذا — هكذا، أليس كذلك؟ فلن أنشئ فعلًا نسخة من قائمة، أو أفعل أيّ أشياء غريبة تتعلق بنسخ القوائم (List Copying)، لأن ذلك سيزيد التعقيد.

لكننا سنستخدم تلك الحيلة التي نأخذ فيها — التي نستخدم فيها فهرسًا صحيحًا (Integer Index) لنقرر إلى أين سنـ — أيّ عنصر سنأخذ بعد ذلك. فهذا هو الغرض من i وj هذين. لدينا i سيكون الفهرس لقائمتي الفرعية اليسرى، وj سيكون الفهرس لقائمتي الفرعية اليمنى.

صفحة المصدر 11

وكل ما يفعله هو أنه يقول: ما دامت لدي عناصر في كلتا هاتين القائمتين، فخذ المؤشر (Pointer) فقط، وقل أيّ العنصرين عند هذين المؤشرين i وj أصغر. فإذا كان 0 أصغر، فسأنشئ هنا قائمة جديدة سيكون فيها 0.

أنا لا آخذ هذا العنصر فعلًا وأنقله إلى هنا. كل ما سأفعله بعد ذلك هو القول إن المؤشر الذي يخبرني أيّ عنصر ينبغي أن أنظر إليه تاليًا يتحرك موضعًا واحدًا. لذا تبقى هذه القائمة دون تغيير. ثم سأقارن 2 بـ1. يأتي ذلك 1 بعده. فسآخذ 1 وأضعه في قائمتي هنا. ويتحرك هذا المؤشر هنا إلى العنصر التالي.

إذًا، بينما تبقى هذه القائمة كما هي، أنظر إلى العنصر عند هذا المؤشر وأقارنه بالعنصر عند هذا المؤشر. ثم يأتي 2 بعد ذلك، ويزداد هذا المؤشر واحدًا، حسنًا؟

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

إذًا، تعقيد هذا الترتيب بالدمج — هذا فقط ما يفعله. فهو يجري مرورًا واحدًا فقط. لا يجري مرات مرور متعددة. ننظر فقط إلى كل عنصر مرة واحدة. لذا فإن تعقيد هذا الترتيب بالدمج — ليس الترتيب، بل خطوة الدمج فقط — هو Θ لطول القائمة، أليس كذلك؟ لأننا ننظر فقط إلى كل هذه العناصر مرة واحدة.

والآن، ماذا عن الخوارزمية الفعلية؟ هنا لدي دالة merge في الأسفل، حسنًا؟ ستأخذ قائمة يسرى وقائمة يمنى، وستنفّذ تلك الخطوة التي نفّذناها للتو، حيث تنظر إلى أصغر عنصر في كل واحدة.

ماذا عن بقيتها؟ حسنًا، البقية ليست إلا عودية. حالتي الأساسية هي عندما تكون لدي قائمة فارغة، أو قائمة فيها عنصر واحد. عندئذ آخذ تلك القائمة فحسب. ستكون هي ناتج الدمج لدي.

وإلا، فما سنفعله هو تنفيذ الخطوة التي نقسم فيها القائمة إلى نصفين. نجري إذًا قسمة صحيحة (Integer Division) لطول القائمة، لأننا لا نريد أن يكون المنتصف 7.5، مثلًا. فسنأخذ فهرسًا صحيحًا ما.

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

ثم إن قائمتي اليمنى هنا، right تساوي هذا الشيء هنا، ستكون أيضًا بطريقة ما قائمة مرتّبة. هذا إذًا يعني أنني أضع ثقتي في خوارزميتي، في أنني أستطيع الحصول على قائمة مرتّبة من الفهرس 0 حتى نقطة المنتصف، ومن نقطة المنتصف حتى نهاية القائمة.

إذًا، إذا استطعت بطريقة ما الحصول على قائمة فرعية يسرى مرتّبة بذاتها، وقائمة فرعية يمنى مرتّبة بذاتها، فإن كل ما أحتاج إليه للحصول على القائمة المرتّبة النهائية هو دمجهما. هذا إذًا ما تفعله دالة merge.

حسنًا، فلنمرّ عبر الخطوات. لدي قائمتي الأصلية هنا. وهنا سنفكر في الطريقة التي مررنا بها عبر فيبوناتشي. ها هي قائمتي الأصلية. الخطوة الأولى هي أن نقوم بـ — أن نعرف الجزء الأيسر. سنقسمها إذًا إلى نصفين، وتقول: أحتاج إلى معرفة النسخة المرتّبة من 8، 4، 1، 6.

صفحة المصدر 12

لكنها ليست حالتي الأساسية، لذلك أحتاج إلى معرفة النسخة المرتّبة من الجزء الأيسر من ذلك، 8، 4. مجددًا، ليست حالتي الأساسية، لذلك أحتاج إلى معرفة النسخة المرتّبة من اليسار، 8 فقط. إنه منفرد بذاته. لذا سيكون ذلك 8 فحسب.

ثم يمكننا معرفة نصفها الأيمن، وهو 4 وحده، وندمجهما. ثم يمكننا معرفة النصف الأيمن من هذه هنا، 8، 4، 1، 6. فنحتاج إلى معرفة ما النسخة المرتّبة من 1، 6. حسنًا، نحن البشر نعرف أنها مرتّبة بالفعل، لكن الخوارزمية تمرّ، وتنظر إلى الجانب الأيسر، وتنظر إلى الجانب الأيمن، وتدمجهما.

الآن ندمج 4، 8، 1، 6 وفق خطوة الدمج لتعطينا 1، 4، 6، 8. وعند هذه النقطة، لم ننتهِ إلا من النصف الأيسر من 8، 4، 1، 6، 5، 9، 2، 0. والآن نحتاج إلى معالجة النصف الأيمن. فنعيد العملية كاملة من جديد، بأخذ 5، 9، 2، 0، والنظر إلى الجزء الأيسر فقط، ثم الجزء الأيسر من ذلك، ثم الجزء الأيمن من تلك الحالة الأساسية، ودمجهما مجددًا.

الخطوة اليمنى، والجزء الأيسر من تلك الخطوة اليمنى، والجزء الأيمن من تلك الخطوة اليمنى، ودمجهما مجددًا. ثم نجري خطوة الدمج لـ5، 9 و0، 2، ثم خطوة الدمج لهاتين القائمتين، 1، 4، 6، 8 و0، 2، 5، 9. يمكنكم إذًا أن تروا أنها تشبه استكشاف جانب واحد من الفرع أولًا، تمامًا كما في فيبوناتشي، وللسبب نفسه تمامًا. لأن لدينا استدعاء دالة عوديًّا (Recursive Function Call)، لا يمكننا إكماله حتى نكون قد استكشفنا حتى الأسفل تمامًا.

إذًا، التعقيد الكلي لهذا سيكون: خطوة الدمج نفسها هي Θ(n)، كما تحدثنا للتو، لكن كم مستوى لدينا؟ أي كم مرة نأخذ قائمتنا الأصلية ونقسمها إلى أقسام أصغر حتى نصل إلى حالتنا الأساسية؟ وعدد المرات، وفق هذه الدالة، يشبه جدًّا ما حدث عندما أجرينا البحث بالتنصيف. سنأخذ n عناصر أصلية في قائمتي، وسأواصل قسمة هذه العناصر الـn على 2، وتكوين مجموعة من القوائم الفرعية، i مرات.

إذًا، i مرات هو عدد المرات التي سنقسم فيها هذه القائمة إلى أقسام أصغر حتى نصل إلى حالة أساسية. فما i بدلالة n؟ حسنًا، i تساوي log n. إذًا، في كل خطوة دمج — عذرًا، في كل مستوى فرعي لدي خطوة دمج. فلدي Θ(log n) مستويات، مضروبة في Θ(n) لخطوة الدمج لدي. لذا فإن التعقيد الكلي لهذه الدالة هو Θ(n log n)، حيث n طول القائمة.

حسنًا، يتبين أن Θ(n log n) هو فعلًا أسرع ما يمكن أن يكون عليه الترتيب. لا يمكنك تنفيذ خوارزمية ترتيب أسرع من ذلك. يمكنك إجراء حيل صغيرة هنا وهناك اعتمادًا على بياناتك. ربما لا تقسم القائمة إلى نصفين متساويين تمامًا. ربما تقسمها وتجد نقطة ارتكاز (Pivot Point) أذكى قليلًا فيما يتعلق بالبيانات. لكن عمومًا، فإن تعقيد هذه الدالة — خوارزمية ترتيب — سيكون دائمًا — أسرع ما سيكون عليه هو Θ(n log n).

ملاحظة المترجم: هذا الحد الأدنى ينطبق على الترتيب العام القائم على المقارنات (Comparison-Based Sorting). لا يصح تعميمه على كل خوارزميات الترتيب تحت جميع الفرضيات؛ فخوارزميات تستغل قيودًا إضافية على مفاتيح البيانات، مثل الترتيب بالعدّ (Counting Sort)، قد تتجاوز هذا الحد. أُبقي كلام المتحدثة كما ورد، وهذا التقييد إضافة توضيحية من المترجم.

رأينا هنا مجموعة من الخوارزميات المختلفة لمساعدتنا على تصميم البرامج. فالسبب الذي يجعلنا نجري تحليل التعقيد هذا هو توجيه تصميم البرنامج. إذا كان لديك بالفعل مجموعة من حلقات for المتداخلة (Nested Loops) في البرنامج الذي تفكر في كتابته، فستعرف بالفعل أنه سيكون غير كفؤ وبطيئًا إلى حد كبير. لذلك قد ترغب في إعادة التفكير في التصميم من البداية، حسنًا؟ حسنًا.