النص الكامل للمحاضرة 23: أمثلة على فئات التعقيد
النص الكامل للمحاضرة 23: أمثلة على فئات التعقيد
المصدر الأصلي: النص الرسمي الإنجليزي بصيغة PDF، فيديو المحاضرة، صفحة المقرر.
النسبة والترخيص: آنا بيل (Ana Bell)، مقرر 6.100L: مقدمة في علوم الحاسوب والبرمجة باستخدام Python، خريف 2022 (MIT Fall 2022)، معهد ماساتشوستس للتكنولوجيا، MIT OpenCourseWare (MITOCW). هذه ترجمة عربية غير رسمية، ولا يعني نشرها تأييد MIT أو اعتمادها منه. المواد الأصلية المشمولة والترجمة تحت رخصة CC BY-NC-SA 4.0: النسبة، والاستخدام غير التجاري، ومشاركة الأعمال المشتقة بالمثل تحت الرخصة نفسها. مواد الأطراف الثالثة المستثناة لا تُعاد ترخيصها بالرخصة الافتراضية للمقرر؛ تسري عليها إشعاراتها الخاصة وشروط MIT OpenCourseWare.
ملاحظة المترجم عن التغطية: مصدر الترجمة هو
content-src/mit-6100l/extracted/1xR3e2ychp7hYL-4PwrUUucM6a_hYVwvo_transcript.txt، مع مطابقة فواصل الصفحات وحدود الفقرات فيcontent-src/mit-6100l/extracted/1xR3e2ychp7hYL-4PwrUUucM6a_hYVwvo_transcript.layout.txt. تتبع الترجمة الصفحات العشرين غير الفارغة بالترتيب، وتنقل الفقرات والمداخلات والتكرار والتردد والتصحيح الشفهي والإشارات الصوتية. عناوين صفحات المصدر فواصل مادية بحسب PDF، وليست تلخيصات موضوعية. تُحفظ أسماء Python والتعبيرات الرياضية، وتُنقل زلات الشرح كما وردت دون تصحيح صامت. هذا التنبيه من المترجم وليس جزءًا من كلام المحاضرة.
صفحة المصدر 1
MITOCW | المحاضرة 23 من 6.100L، الإصدار 2.mp4
[صرير]
[حفيف]
[نقرات]
آنا بيل (ANA BELL):
حسنًا، لنبدأ محاضرة اليوم. سننظر في شيفرات أكثر بكثير، نحدد فيها أساسًا فئة التعقيد (Complexity Class) للشيفرة المعطاة. لكن أولًا، لنتذكر ما تعلمناه في نهاية المحاضرة الماضية. قدمنا ترميز ثيتا (Theta Notation) بوصفه ترميزًا للدلالة على رتبة نمو دالة معينة أو قطعة معينة من الشيفرة. وفضلنا ثيتا على ترميز أو الكبيرة (Big O Notation)، لأن ثيتا أتاح لنا الحصول على هذا الحد العلوي التقاربي لزمن تشغيل دالتنا في أسوأ حالة.
إذًا أردنا حدًّا تقاربيًّا (Asymptotic Bound)، بدلًا من حد علوي، لأن ذلك الحد العلوي قد يكون أي شيء ينمو أسرع من دالتنا. ولذلك نفضل ثيتا بوصفه الحد التقاربي. وفي نهاية المحاضرة الماضية قلنا أساسًا إنه، إذا أُعطينا دالة، فستكون ثيتا لتلك الدالة هي الحد المهيمن (Dominant Term) فيها. فإذا كانت لدينا مجموعة كبيرة من الحدود، نركز على الحد الذي ينمو أكثر من غيره. نحذف أي ثوابت جمع، وأي ثوابت ضرب، وكل الحدود الأخرى التي لا تنمو بسرعة ذلك الحد الأكبر.
وهكذا توصلنا إلى بعض فئات الخوارزميات التي سنراجعها اليوم. سنرى مجموعة من الشيفرات التي تقع ضمن تلك الفئات من الخوارزميات. لكن قبل أن ندخل في ذلك، أردت أن أراجع سريعًا نهاية المحاضرة الماضية. رأينا مثالًا يشبه هذا المثال كثيرًا، إن لم يكن هو نفسه.
نعرف إذًا أنه، إذا أُعطينا دالة، يمكننا استخراج ثيتا لتلك الدالة بالتركيز على حدها المهيمن. لكن كيف نصل إلى تلك الدالة؟ إذا أُعطينا قطعة من الشيفرة، فكانت الفكرة للوصول إلى تلك الدالة أن نبدأ أولًا بالنظر إلى مدخلات الدالة. لدينا ثلاثة مدخلات في هذه الحالة بالتحديد: L وL1 وL2.
بعد أن نحدد مدخلات هذه الدالة، نتابع وننظر إلى كل شيء داخل الشيفرة يعتمد على معاملات الإدخال هذه. وقد يكون الاعتماد مباشرًا، مثل حلقة تمر على شيء متعلق بالمدخل، أو قد يكون غير مباشر، كما سنرى في بعض الأمثلة لاحقًا اليوم. لكننا ننظر أساسًا فقط إلى أجزاء الدالة التي تتعامل مع هذا المدخل.
إذا أردنا الدقة، نبدأ بإيجاد العدد الدقيق للعمليات التي ننفذها داخل هذه الشيفرة. وهذا شيء فعلناه حين عددنا العمليات في دالة معطاة. سنعدّ إذًا عدد العمليات في هذه الشيفرة بالنسبة إلى L1 وL2 وL. وبذلك لدينا هذه العلاقة التي يمكننا التوصل إليها، والتي تربط عدد العمليات المنفذة بوصفه دالة في L وL1 وL2.
العدد 1 هنا ثابت، لأن لدينا هنا مجرد إسناد (Assignment) إلى متغير ما. الحد التالي هنا ليس ثابتًا. توجد خمسة أشياء ثابتة ننفذها: إسناد قيمة من range إلى i، ثم الفهرسة (Indexing) في L عند i، وهذه عمليتان. والفهرسة في L1 عند i، وهذه الثالثة. والتحقق من التساوي، وهذه الرابعة. ثم تعيين inL1 إلى True، وهذه الخامسة.
إذًا هناك خمس عمليات، لكن كم مرة تتكرر هذه العمليات؟ إنها تتكرر بعدد مرات طول L1، لأن هذه الحلقة تمر بعدد مرات طول L1. لذا فهذا الحد هنا، وهذه الحلقة for هنا، هما طول L1 مضروبًا في 5 عمليات. ثم العدد 1 هنا هو هذا الإسناد هنا. وبعد ذلك، هذه الحلقة في الأسفل هي نفسها تمامًا الحلقة في الأعلى، إلا أن هذه الحلقة السفلية تتكرر الآن بعدد مرات طول L2.
صفحة المصدر 2
وهكذا كلما كبرت L2، استغرقت هذه الحلقة وقتًا أطول في التشغيل. هذه هي طريقة تفكيرنا في ذلك. وأخيرًا، العدد 2 المضاف في نهاية تلك العلاقة يمثل إيجاد and لهذين المتغيرين، ثم تنفيذ return. وهذا يقودنا إلى تبسيطها إلى 5 * len(L1) + 5 * len(L2) + 3. وتصبح هذه هي الدالة التي يمكننا بعد ذلك استخراج ثيتا لها.
نستخدم الآن فقط القواعد المعتادة لثيتا، قانون الجمع (Law of Addition) وقانون الضرب (Law of Multiplication)، إن كان هناك شيء يُجمع أو يُضرب. وفي هذه الحالة بالتحديد، لنفترض أن L وL1 وL2 كلها بالطول نفسه. عندئذ يمكننا تبسيط الدالة أعلاه إلى 10 * len(L) + 3. وتصبح ثيتا لها مجرد Θ(len(L))، لأننا نحذف 3، ونحذف 10 التي تضرب — العدد 10 الذي يضرب L. ثم نحتفظ فقط بطول L.
هذه إذًا طريقة الوصول إلى ثيتا لدالة معينة. وهذه دالة نظرنا إليها في المرة الماضية. لكن بينما ننظر في مزيد من الدوال اليوم، سنصبح أفضل في تحديد أجزاء الشيفرة التي تتعامل فقط مع مدخلاتنا. هذا inL1 = False، وهذا inL2 = False، وهذه return، وهذه and، كلها أمور ثابتة تحدث. لذلك لا نحتاج إلى التركيز عليها. ربما نلقي عليها نظرة سريعة جدًّا فقط للتأكد من عدم حدوث شيء غريب يعتمد على طول قائمتنا.
لكن يمكننا أن نقول أساسًا: حسنًا، هذه مدخلاتنا. لدينا حلقة for واحدة تمر على الطول، وحلقة for أخرى تمر على الطول. وهما متتاليتان، لذلك نستخدم قانون الجمع لنقول إن هذه الدالة هي Θ(len(L1) + len(L2)). وعندئذ يمكننا معرفة ثيتا لهذه الدالة سريعًا بمجرد النظر إلى الأجزاء التي تعتمد على المدخل.
وهذا يقودنا — في نهاية المحاضرة الماضية انتهينا إلى النظر في هذه — عفوًا، إلى تقرير أن هذه هي فئات التعقيد التي يمكننا تصنيف كثير من دوالنا فيها. Θ(1) ثابتة (Constant). وΘ(log n) لوغاريتمية (Logarithmic). هنا n هي — بافتراض أن n هي مدخل دالتي. وΘ(n) خطية (Linear). وΘ(n log n) خطية لوغاريتمية (Log Linear). وثيتا لـn، إذا كانت n هي مدخلي، مرفوعة إلى ثابت ما، مثل n² وn³، تعمل في زمن متعدد الحدود (Polynomial Time).
ثم ثيتا لثابت ما مرفوع إلى n، حيث n هي مدخلي، تكون أسية (Exponential)، مثل 2ⁿ و3ⁿ. كل هذه تُعد خوارزميات ذات زمن أسي. وعندما نكتب خوارزمياتنا، نريد أن نكون هنا في الأعلى، ربما ضمن الفئات الأربع الأولى، وربما الخمس الأولى، مع أن متعدد الحدود سينمو بسرعة كبيرة نسبيًّا حين يكبر مدخلنا.
لذلك إذا أمكننا أخذ شيفرتنا وإلقاء نظرة سريعة عليها، وتصنيفها ضمن إحدى هذه الخوارزميات، فقد يرشدنا ذلك نحو كتابة — نحو تقرير ما إذا كانت الخوارزمية التي كتبناها جيدة أم سيئة. إذا نظرنا إليها وقلنا: هذه الخوارزمية أسية، أو هذه الدالة التي كتبتها أسية، فربما نرغب في إعادة التفكير في نهجنا لحل المسألة، ومحاولة جعلها تقع ضمن إحدى فئات التعقيد العليا.
ما سنفعله في بقية هذه الحصة هو المرور على مجموعة من فئات التعقيد هذه، وسنرى بعض الشيفرات التي تنتمي إليها، ونأمل أن يعطيكم ذلك فكرة عما تبدو عليه الشيفرة التي تقع ضمن إحدى هذه الفئات.
أول فئة سننظر إليها هي فئة التعقيد الثابت. إنها بسيطة جدًّا. وليست مثيرة للاهتمام حقًّا. إذا كانت شيفرتكم تنتمي إلى فئة التعقيد الثابت هذه، فهذا يعني أنها لا تعتمد على المدخل إطلاقًا. إنها تعمل دائمًا في زمن ثابت. قد تحتوي شيفرتكم على حلقات، أو قد تحتوي على نوع من البنية العودية (Recursive Structure). لكن تلك الحلقة أو تلك البنية العودية لا تعتمد على المدخل إطلاقًا.
صفحة المصدر 3
إذًا لا بأس بوجود حلقات. ما دامت لا تعتمد على المدخل، فهي تُعد ثابتة. هناك بعض العمليات المضمنة (Built-in Operations) التي تستغرق زمنًا ثابتًا. فإذا رأيتم أيًّا من هذه العمليات، مثل الفهرسة في قائمة (List)، أو الإلحاق بقائمة، أو أخذ القيمة المرتبطة بمفتاح قاموس (Dictionary Key)، فكل هذه ذات زمن ثابت. وإذا رأيتموها في شيفرتكم، فلا تحتاجون إلى احتسابها إطلاقًا. لكن سنرى بعد بضع شرائح أن بعض العمليات على القوائم والقواميس تضيف تعقيدًا غير ثابت. لذلك لا يمكنكم تجاهلها ببساطة.
حسنًا، لننظر في مثالين من الشيفرة. هذه دالة بسيطة جدًّا، add(x, y). إذًا x وy هما مدخلاي. ليست هناك حلقة، ولا شيء عودي، ولا شيء يستغرق وقتًا هنا. لا يوجد شيء يتكرر في هذه الشيفرة. لذلك تعقيد هذه الشيفرة هو Θ(1). وهذا كل شيء.
هذا مثال آخر. إنه مثال الكيلومترات لدينا، الذي يأخذ أميالًا. كل ما يفعله هو عملية ضرب. مرة أخرى، Θ(1). لا يحدث هنا شيء مثير للاهتمام — لا حلقة ولا عودية. وهذه دالة تحتوي بالفعل على حلقة داخلها. لكن أول شيء ننظر إليه هو مدخلي. أي متغير هو مدخلي هنا؟ إنه x، صحيح؟ إذًا أي جزء من شيفرتي هنا يعتمد على x؟
حسنًا، هناك شيء أجمعه هنا. أنا أضيف x إلى عدد ما. ولدي حلقة بالفعل، لكن هل تعتمد الحلقة على x؟ لا. إنها تعتمد على عدد لا يساوي إلا 100 داخل دالتي. لو كانت y = x هنا، لما كانت هذه الشيفرة ثابتة، لأن هذه الحلقة ستمر x مرة. لكن y هنا لا تساوي إلا 100، ولذلك تعقيد هذه الشيفرة هو Θ(1). لا يوجد شيء هنا يعتمد على x مع نمو x.
حسنًا، هذه أمثلة ليست مثيرة للاهتمام كثيرًا. لننتقل إذًا إلى أبسط فئة تالية من الدوال، فئة التعقيد الخطي. وعادةً تتميز هذه الدوال بحلقة واحدة، أو ربما بحلقات كثيرة متتالية، أو شيء من هذا النوع. لكن كل هذه الحلقات تعتمد على n اعتمادًا خطيًّا فقط. ويمكن أن تكون لديكم أيضًا دالة عودية تتكرر على نحو خطي في n.
سنرى مثالًا لدالة عودية بعد قليل. لكن سنبدأ أولًا ببعض الدوال التي تتكرر حلقاتها خطيًّا مع n. مع ذلك، هناك بعض العمليات المضمنة التي يكون زمنها خطيًّا. فإذا رأينا هذه العمليات داخل شيفرتنا، فلا يمكننا تجاهلها، لأنها ستسهم بتعقيد Θ(n) في شيفرتنا. لذا علينا احتسابها؛ مثلًا إذا كان لدينا تعبير e in n داخل حلقة أخرى، فلا يمكننا أن نقول ببساطة إن هذا e in n ثابت. علينا استخدام قانون الضرب أو شيء من هذا القبيل لاحتسابه.
التحقق مما إذا كان عنصر موجودًا في قائمة هو خطي بوضوح، لأن عليكم النظر إلى كل عنصر في القائمة لتحديد ما إذا كانت e موجودة فيها أم لا. وإنشاء نسخة من قائمتكم خطي في الزمن أيضًا. وحتى إذا أنشأنا نسخة من نصف قائمتنا، أي النصف الأول من قائمتنا، فذلك يظل خطيًّا، لأن نسخ 0.5 * len(L) يظل Θ(len(L)). ثابت الضرب الذي يسبق طول L هو 0.5. فإذا حذفناه، فهذا يظل Θ(len(L)).
التحقق من التساوي بين قائمتين ثابت أيضًا، لأن عليكم النظر إلى كل عنصر في القائمة ومقارنته للتأكد مما إذا كانت العناصر هي نفسها أم لا. وحذف عنصر من قائمة خطي في الزمن أيضًا. عفوًا، هذه كانت ثابتة في الزمن — عفوًا، هذه كانت خطية في الزمن. والحذف خطي في الزمن أيضًا، لمجرد الطريقة التي تُخزن بها القوائم في الذاكرة. لذلك إذا حذفتم عنصرًا في النهاية — من قائمتكم، فستحسب Python ذلك بوصفه تعقيدًا زمنيًّا خطيًّا.
صفحة المصدر 4
لننظر إذًا في بعض الأمثلة. سنبدأ أولًا ببعض الدوال العادية التي تحتوي على حلقات، ثم ننظر في دالة عودية واحدة. هنا لدي ضرب x في y. تمر الحلقة عبر range(y)، وتجمع فقط x + x + x + x عدد y من المرات. لدي إذًا معاملان هنا. لذلك أحتاج إلى التفكير في تعقيد هذه الدالة بالنسبة إلى كليهما.
التعقيد بالنسبة إلى y هو Θ(y)، لأن لدي حلقة واحدة هي دالة في y. ستتكرر هذه الحلقة بحسب مقدار y. فإذا زادت y، زاد أيضًا الوقت الذي تستغرقه هذه الحلقة. لذلك تعقيد ثيتا لهذه الدالة هو Θ(y) بالنسبة إلى y.
لكن ما تعقيد هذه الدالة بالنسبة إلى x؟ ليست لدي هنا بنية تكرار مرتبطة بـx. كل ما أفعله بـx هو إضافتها إلى عدد ما. لذا التعقيد بالنسبة إلى x هو مجرد Θ(1). وعليه فإن التعقيد الكلي لهذه الدالة سيكون فقط Θ(y). لا تسهم x بأي شيء في هذا، في زمن تشغيل هذه الدالة.
حسنًا، هذا المثال، وكذلك الدالة السابقة ذات الحلقة من الفئة الثابتة، يخبراننا نوعًا ما بأن علينا الانتباه إلى ماهية المدخلات. عندما نذكر التعقيد، علينا ذكره بالنسبة إلى مدخلات دالتنا. لا نقول دائمًا فقط Θ(n) أو Θ(n²) أو ثيتا لطول n، أيًّا كان التعبير. علينا ربطه بمدخلات دالتنا. وإذا كان لدينا أكثر من مدخل واحد، فعلينا الحرص على احتساب كل المدخلات التي تسهم في التعقيد.
حسنًا، لننظر في مثال آخر. هنا مثال يأخذ سلسلة نصية (String) اسمها s. نمر على كل محرف في s. نحول كل محرف إلى عدد صحيح (Integer). ثم نضيفه إلى قيمة ما. وهكذا نحن نجمع أساسًا كل المحارف في s، في السلسلة s. يحتوي هذا على حلقة واحدة تمر على كل العناصر في s.
الآن، إذا كانت s سلسلة نصية، فما الذي سيجعل هذا البرنامج أبطأ؟ هل هو أن السلسلة — أي القيمة العددية للسلسلة — أكبر؟ لا، لأنني إذا مررت على السلسلة 1، 0، 0، 0، فسيستغرق ذلك المقدار نفسه من الوقت الذي يستغرقه المرور على السلسلة 9، 9، 9، 9. طول السلسلة هو ما يهم. وهذا ما تفعله هذه الحلقة. إنها تأخذ طول السلسلة في الحسبان. فإذا كانت سلسلتي أطول، فسيستغرق التشغيل وقتًا أطول.
إذًا تعقيد هذه الدالة هو فقط Θ(len(s))، لأن طول السلسلة هو ما يسهم في إبطاء دالتي. كل شيء آخر نفعله ثابت. ولذلك التعقيد الكلي هو Θ(len(s)). أو، إن كان ذلك أبسط، يمكنكم فقط قول Θ(n)، لكن عليكم حينئذ أن تقولوا إن n تساوي شيئًا، مثل طول s.
حسنًا، هذا مثال آخر. هذا برنامج لحساب المضروب (Factorial) بطريقة تكرارية (Iterative). سيستخدم حلقة لمواصلة إضافة — عفوًا، لمواصلة الضرب في i، لحساب مضروب عدد n ما. في هذه الحالة مدخلي هو n. لذا سأنظر الآن عبر دالتي لأرى أي جزء منها يعتمد على n. هنا n مجرد عدد، وأنا أمر بحلقة من 2 وصولًا إلى n + 1. لذلك سأكرر الحلقة n - 1 مرة في المجموع.
بما أنني أكرر الحلقة n - 1 مرة، فلا يوجد حقًّا شيء آخر يسهم في التعقيد. لذا Θ(n - 1) هي فقط Θ(n). وعليه فتعقيد هذه الدالة هو فقط Θ(n). هل الجميع بخير حتى الآن؟ إذًا هذه برامج بسيطة جدًّا لا تحتوي إلا على حلقة واحدة تعتمد على المدخل خطيًّا.
صفحة المصدر 5
حسنًا، سأذكر ملاحظة صغيرة عن المضروب، لأن هذا شيء مهم نوعًا ما. سيخبرنا بالفرق بين النظرية، وهي ما يدور حوله معظم هذا المقرر أو هذه المجموعة من المحاضرات، والعالم الحقيقي. لقد شغلت فعلًا النسخة التكرارية من المضروب على الحاسوب. ويمكنكم أن تروا هنا أنني ضربت المدخل في 2، فكانت القيم 40 و80 و160 و320، وهكذا.
بينما أضرب المدخل في 2، إذا كنت أتوقع أن تكون هذه الدالة مرتبطة بالمدخل خطيًّا، فإنني أتوقع أن يصبح الوقت الذي تستغرقه هذه الدالة في التشغيل نحو الضعف. إذا زاد المدخل بمعامل 2، فينبغي أن يزداد وقت تشغيل هذا البرنامج بمعامل 2 أيضًا. وهذا يحدث. يحدث طوال الطريق إلى موضع ما بين 640 و1,280.
إذا أجرينا الحساب، فهذا تقريبًا ضرب في 2 في كل مرة ناقص — لأننا ننفذ الضرب هنا فقط. لكن بعد موضع ما ضمن 640 و1,280، لا يعود الوقت الذي يستغرقه تشغيل برنامجي يتبع هذا النمط الخطي. بل يبدأ في النمو أسرع من الخطي. وللوهلة الأولى يبدو أنه ينمو تربيعيًّا، على نحو متعدد الحدود. إذًا بدلًا من — إذا زدتم المدخل بمعامل 2، يبدو أن الوقت الذي يستغرقه تشغيل هذا البرنامج يزداد بمعامل 4 بعد نقطة معينة.
وذلك لأن لدي، في العالم الحقيقي، Python تعمل على الآلة. لا يوجد إلا عدد محدد من البتات (Bits) يستطيع حاسوبي الاحتفاظ به عندما يخزن الأعداد. ومضروب عدد ما بين 640 و1,280 يصبح كبيرًا جدًّا، بحيث عندما تحاول Python والآلة التعامل مع ضرب هذه الأعداد الكبيرة في هذه الأعداد الكبيرة كلها، يستغرق التشغيل وقتًا طويلًا جدًّا، لأنها لا تستطيع تخزين هذه الأعداد الكبيرة بالكفاءة نفسها التي كانت تخزن بها هذه الأعداد الأصغر.
وهكذا، ما يحدث في العالم الحقيقي هو أنه بعد شيء ما — بعد أن أحاول تخزين قيمة كبيرة جدًّا وإجراء العمليات على قيم كبيرة جدًّا، ينخفض التعقيد الزمني انخفاضًا هائلًا، صحيح؟ الانتقال من n إلى n² قفزة كبيرة نسبيًّا. وهذا يوضح نوعًا ما الفرق بين النظرية والعالم الحقيقي. في العالم الحقيقي لا نستطيع تخزين هذه القيم بالكفاءة نفسها عندما تكبر.
أحد الحضور (AUDIENCE):
فهل يعني ذلك أن، أظن، [غير مسموع]؟
آنا بيل (ANA BELL):
نعم، إذا استخدمنا آلة تحتوي على بتات أكثر لتخزين القيم، فسنستطيع أن نكون أكثر كفاءة إلى مدى أبعد. نعم، بالضبط. ويمكننا — أظن أنه يمكننا — لو كانت لدينا لغة تنفذ ربما بعض الأشياء الأذكى، وتخزن هذه القيم الكبيرة بطريقة أذكى بكثير، فقد يؤثر ذلك أيضًا في الزمن.
لكن لأغراض هذا المقرر، نحن مهتمون فقط بما يحدث نظريًّا هنا. مع زيادة المدخل بمعامل x، نتوقع أن يصبح وقت تشغيل البرنامج أطول بمقدار x مرات، لأننا ننظر نظريًّا إلى قيم كبيرة جدًّا، جدًّا.
حسنًا، لننظر في مثال آخر. هذه دالة لحساب المضروب بطريقة عودية (Recursive). رأينا هذه الدالة من قبل. نظرنا للتو في النسخة التكرارية من المضروب. وننظر الآن في النسخة العودية من المضروب. ماذا لدينا إذًا؟ لدينا حالة أساس (Base Case) واحدة ستصل إليها شيفرتنا في النهاية، وخطوة عودية (Recursive Step)، وهي فقط n * factorial(n - 1).
كيف نجري تحليل خوارزمية عودية؟ في هذه الخوارزمية العودية ليست لدينا حلقة. في الأمثلة السابقة كانت لدينا حلقة نستطيع أن نقول عنها بصورة قاطعة: هذه الحلقة ستتكرر هذا العدد من المرات. فمن الواضح أن زيادة n ستزيد الوقت الذي يستغرقه تشغيل هذه الحلقة.
صفحة المصدر 6
عندما نتعامل مع دوال عودية، نفكر في عدد المرات التي ستُستدعى فيها الدالة العودية. فعندما نستدعي factorial، لدينا مضروب 5 مثلًا أو أي عدد آخر، وهذا يستدعي مضروب 4، وهذا يستدعي مضروب 3. وهكذا تكون لدينا سلسلة من استدعاءات الدوال نصل عبرها إلى حالة الأساس. وبمجرد الوصول إلى حالة الأساس، نبدأ بإطلاق الخطوة التي تعيد النتيجة، واحدة في كل مرة.
لذلك عندما نتحدث عن الدوال العودية، فما نهتم به حقًّا هو عدد المرات التي نستدعي فيها الدالة. هذه هي «الحلقة»، بين علامتي اقتباس، في الدوال العودية. إنها مجرد استدعاء الدالة لنفسها لتطلب من نفسها تنفيذ العمل. وهي تنفذ العمل بمعامل تغير قليلًا. لذا ما نحتاج إليه هو التفكير: كم مرة تستدعي هذه الدالة نفسها؟ وفوق ذلك، هل توجد كلفة إضافية (Overhead) من نوع ما غير ثابتة؟
في هذه الحالة بالتحديد، عندما نستدعي المضروب العودي، سنمر أساسًا عدد Θ(n) من المرات، لأننا نبدأ بـn ثم ننفذ n - 1 وn - 2 وn - 3، حتى نصل إلى 0. وبالفعل نكون قد استدعينا أنفسنا نحو n مرة، أي Θ(n). والكلفة الإضافية لكل واحد من هذه الاستدعاءات ثابتة، لأن كل ما أفعله بـn هو طرح 1 منها. وهذا أمر ثابت. n - 1 هي Θ(1). إنها ثابتة فقط.
إذًا التعقيد الكلي لهذا هو فقط Θ(n)، حيث n مجرد مدخلي. وما نلاحظه هو أن النسختين التكرارية والعودية من المضروب كلتاهما Θ(n)، وهذا يعني، عمومًا، أنه لو كنا نحاول تقرير ما إذا كنا سننفذ المضروب عوديًّا أو تكراريًّا، فلن يهم ذلك حقًّا على المدى البعيد، لأن تعقيد أسوأ حالة هو Θ(n). إنه نفسه في الحالتين. لذا سيكون اختيار النسخة التي تستخدمونها فعليًّا متروكًا لكم. وربما يتعلق الأمر عندئذ بسهولة القراءة أو بعوامل أخرى.
حسنًا، مثال آخر. هذه compound. رأيناها في المحاضرة الماضية. قسنا زمنها فعلًا، وعددنا كم — هل عددنا فعلًا؟ لا أتذكر. لا أظن أننا عددنا العمليات، أو ربما فعلنا. لكننا بالتأكيد قسنا زمنها. كانت هذه الدالة تأخذ ثلاثة معاملات. لذا علينا أن ننتبه إلى أي واحد من هذه المعاملات — أو أي معاملات منها تسهم فعلًا في تعقيدي.
تحسب هذه الدالة مقدار المال الذي أملكه إذا استثمرت مبلغًا شهريًّا ما، بفائدة شهرية ما، على مدى عدد معين من الأشهر. الحلقة هنا تتكرر عبر عدد من الأشهر. وكل شيء آخر يبدو ثابتًا. لدي حلقة واحدة. لذا ما داخل الحلقة ثابت. عليّ بالفعل أن أتحقق من ذلك مرة أخرى. لكن الأمور جيدة حتى الآن. إنها لا تكرر أي شيء آخر. وليست دالة في أي شيء آخر.
لكن الحلقة نفسها هي Θ(n_months). لذا التعقيد الكلي لهذه الدالة هو Θ(n_months). أو يمكننا قول Θ(n) حيث n = n_months. لا يسهم أي من المعاملات الأخرى في تعقيدي. وهذا بالضبط ما رأيناه عندما شغلنا الشيفرة. شغلناها مع تغيير كل واحد من المعاملات، ورأينا أن n_months وحده يسهم في إبطاء البرنامج.
لو أردنا ذلك حقًّا، لأمكننا إجراء هذا التحليل بالتفصيل، كما فعلنا في المحاضرة الماضية، لعدّ العدد الكامل للعمليات فعلًا، أو كما فعلنا في بداية هذه المحاضرة. إذًا total = 0 هي Θ(1). والحلقة هي Θ(n_months) مضروبة في أربع عمليات. أخذ i قيمة من range، ثم إجراء الضرب والجمع، ثم حفظ ذلك في total، هذه أربع عمليات، مضروبة في Θ(n) حيث n هي n_months، زائد Θ(1) لتنفيذ الإرجاع. وهكذا ينتهي الأمر إلى ثيتا لـ1 + 4 * n + 1، التي تُبسط فقط إلى Θ(n) حيث n هي n_months. نعم.
أحد الحضور (AUDIENCE):
لماذا صار ذلك [غير مسموع] إلى 1 — حسنًا، ثيتا 1 [غير مسموع]؟
صفحة المصدر 7
آنا بيل (ANA BELL):
نعم، نحن ننظر فقط إلى العمليات. نجري حسابات باستخدام interest وinvest، ونضرب ذلك في total، صحيح؟ لكن كون interest أكبر — مثلًا، إذا كانت الفائدة دولارًا واحدًا، أو كانت الفائدة 1,000 دولار، فهل سيجعل ذلك سطر الشيفرة هذا أبطأ بكثير؟ لا، لأن كل ما نفعله هو ضرب عددين. ولهذا يكون الداخل Θ(1). لكن وجود حلقة نكرر فيها هذا مرة تلو الأخرى سيبطئ البرنامج. نعم.
حسنًا، ماذا عن دالة فيبوناتشي (Fibonacci) هذه؟ هذه نسخة تكرارية من فيبوناتشي. لا أعرف إن كنا قد رأيناها من قبل. مرة أخرى، يمكننا إجراء تحليل تقريبي سريع نوعًا ما، ننظر فيه باقتضاب إلى كل سطر، ونسأل أنفسنا ما إذا كان يسهم بـΘ(1) أو بشيء أسوأ في تحليلنا الكلي — تحليل زمن التشغيل الكلي.
لدينا هذا الجزء الأول هنا، وهو ثابت فقط، Θ(1). لا يوجد فيه شيء حلقي. ولا تحدث فيه عودية، ولا شيء يعتمد على المدخل بطريقة غير ثابتة. وفي else لدينا هذا الثابت، مرة أخرى، مجرد إسناد معاملين. ولدينا حلقة. إذًا ستكون هذه الحلقة الآن غير ثابتة. لكن الأشياء داخل الحلقة ثابتة. والحلقة نفسها تعتمد على n، مدخلي. ولذلك ستكون Θ(n).
لكن تلك Θ(n) تُضرب في Θ(1). الأشياء داخل الحلقة ثابتة فقط. لذا هذا Θ(n) * Θ(1)، وهو مجرد Θ(n). ثم الإرجاع، بالطبع، هو Θ(1). يمكننا إجراء حساب مثل هذا، أو يمكنكم المرور سريعًا والقول: لدي فقط حلقة تبدو — تعتمد على n. وهذا Θ(n). إذًا التعقيد الكلي لهذا، إذا أردنا التفصيل، هو هذا: Θ(1) + Θ(1) + Θ(n) * Θ(1) + Θ(1). لكن إجمالًا لا يعطينا ذلك إلا Θ(n)، لأن تلك الحلقة هي الشيء الوحيد الذي يعتمد على مدخلي.
هل الجميع بخير حتى الآن؟ حسنًا. ممتاز. لننتقل الآن إلى ثاني أسهل تعقيد يمكن تحديده. وهو التعقيد متعدد الحدود (Polynomial Complexity). يتعامل التعقيد متعدد الحدود عمومًا مع دوال تحتوي على حلقات متداخلة (Nested Loops). فإذا كانت لدينا حلقتان متداخلتان تعتمدان خطيًّا على مدخلي، فستكون تلك دالة من رتبة n². وإذا كانت لدي ثلاث حلقات متداخلة، كلها تعتمد خطيًّا على مدخلي، فستكون من رتبة n³.
لنر بعض الأمثلة. هنا لدي حالة حلقات متداخلة بسيطة جدًّا. لدي دالة اسمها g، وستأخذ مدخلًا n. سأبحث إذًا عن كل شيء يعتمد على n. لدي حلقة for هنا ستتكرر n مرة. لذلك هذه Θ(n). ولدي حلقة for داخلية. وهكذا لكل شيء في حلقتي الخارجية، سأنفذ الشيء الداخلي n مرة أيضًا.
ثم الأشياء داخل حلقتي for الداخلية ثابتة. إذًا هذه Θ(n). والأشياء خارج حلقاتي هي — عفوًا، الأشياء داخل حلقتي for الداخلية هي Θ(1). والأشياء خارج أي من حلقات for هي Θ(1) أيضًا. لذا لا تسهم بشيء في هذا التعقيد. الشيء الوحيد الذي أحتاج إلى احتسابه هو حلقتي الخارجية، وهي Θ(n). وقانون الضرب يقول إن حلقتي الداخلية ستُضرب — يُضرب تعقيدها في تعقيد حلقتي الخارجية.
حسنًا، التعقيد الكلي لهذه الدالة هو Θ(n²)، لأن عدد المرات التي سأنفذ فيها هذه العملية سيكون n² مرة. ممتاز. لننظر الآن في بعض الأمثلة على القوائم. لم نر تلك الأمثلة بعد. علينا الآن التفكير في المدخل. وفي هذه الحالة سيكون قائمتين. وعندما نتعامل مع القوائم، فأحد الأشياء التي — أو الشيء الأكثر شيوعًا الذي نهتم به هو: ماذا يحدث لسلوك الدالة مع ازدياد حجم القوائم؟
صفحة المصدر 8
كما رأينا في المحاضرة الماضية، فإن حجم العناصر داخل القائمة لا يهم عادةً. لكن حقيقة أن لدي عناصر أكثر لأفعل أشياء بها مهمة بالفعل. فإذا صار عدد العناصر في قائمتي الآن ضعف ما كان، فسيكون هذا البرنامج، أو معظم البرامج، على الأرجح أبطأ بمقدار الضعف.
هذه دالة اسمها is_subset. تأخذ قائمتين، L1 وL2. أضفت مثالين صغيرين هنا في الأعلى لمساعدتنا على معرفة ما تفعله هذه الدالة. إنها ستخبرنا ما إذا كانت عناصر L1 موجودة في L2. في المثال الأول هنا، العناصر في L1 هي 3 و5 و2. وL2 تحتوي بالفعل على 3 و5 و2، لكنها تحتوي على أشياء أخرى أيضًا. وهذا مقبول تمامًا. كل العناصر في L1 موجودة في L2.
لذا ستعيد هذه الدالة True لهذين المثالين، لهاتين القائمتين L1 وL2. ثم هذا مثال ستعيد فيه False. عناصر L1 هي 3 و5 و2. والعدد 3 غير موجود في L2. لذا سيعيد ذلك المثال False. عناصر L1 ليست كلها في L2. ولذلك فهي ليست مجموعة جزئية (Subset).
حسنًا، ماذا تفعل هذه الدالة؟ إنها تمر على كل العناصر في L1. ستنظر أولًا إلى 3، ثم 5، ثم 2. وستمر على كل عنصر في L2 من أجل كل واحد من عناصر L1 هذه. ستنظر إذًا إلى 3 و2، ثم 3 و3، ثم 3 و5، ثم 3 و9. بعد ذلك ستنظر إلى 5 و2، ثم 5 و3، ثم 5 و5، ثم 5 و9. وستواصل فعل ذلك.
وستتابع هذه القيمة المنطقية (Boolean) المسماة matched. وستقوم — ما دامت تجد هذا العنصر e1 داخل L2 لدي، فستحفظ matched على أنها True. وستواصل فعل ذلك إلى أن لا تجد — عفوًا، إلى أن تواصل العثور على تطابقات — ما دام — عفوًا، إلى أن تجد تطابقًا. وبمجرد أن تجد تطابقًا، تخرج باستخدام break، لأنه لا حاجة إلى مواصلة النظر في بقية عناصر L2. لقد وجدت بالفعل عنصرًا مطابقًا.
لذا يمكن في الحقيقة إعادة كتابة هذه الشيفرة بقول العكس. إذا كانت e1 لا تساوي L2، فيمكننا فورًا إعادة False، لأننا وجدنا بالفعل عنصرًا من L1 ليس في L2. كان يمكننا إعادة كتابة هذه الشيفرة بطرق مختلفة كثيرة، لكن التحليل النهائي سيكون نفسه. لننظر إذًا إلى تحليل هذه الدالة. لدينا مدخلان. لذا علينا الانتباه إلى هذين المدخلين كليهما.
أي أجزاء من هذه الدالة تعتمد على L1 وL2؟ لدينا حلقة for خارجية. ماذا يحدث للتعقيد بالنسبة إلى هذه الحلقة؟ إذا كانت لدي عناصر أكثر في L1، فستمر هذه الشيفرة — هذه الحلقة — مرات أكثر. لذا ستُنفذ هذه الحلقة بعدد مرات طول L1. وثيتا لهذه الحلقة الخارجية ستكون Θ(len(L1)).
لكن توجد حلقة داخلية. لذلك، لكل عنصر في حلقتي الخارجية، سأنفذ أيضًا كل شيء في هذه الحلقة الداخلية. وفي أسوأ حالة أحتاج إلى النظر في كل عنصر في L2 لإيجاد تطابق. لذا ستُنفذ الحلقة الداخلية في أقصى الحالات بعدد مرات طول L2، مرة أخرى في أسوأ حالة. وستكون الحلقة الداخلية Θ(len(L2)).
وبما أن لدي هذه الحالة من الحلقات المتداخلة، فإن قانون الضرب يقول إن التعقيد الكلي سيكون ثيتا لحلقتي الخارجية مضروبة في ثيتا لحلقتي الداخلية. أي Θ(len(L1) * len(L2)). حسنًا، الجميع — نعم، سؤال.
أحد الحضور (AUDIENCE):
هل يعني ذلك أنه إذا كانت لديك عبارة if خطية، فستكون e؟
آنا بيل (ANA BELL):
نعم. مثل هنا، في هذه if؟ نعم، إذا كانت if تحتوي على شيء مثل استخدام in، حيث in خطية، فعندئذ نعم، سيكون هناك شيء آخر — سيكون الأمر كأن هناك حلقة أخرى في المستوى الثالث. نعم، وعندها سيكون n³. سيظل متعدد الحدود، لكن n³.
صفحة المصدر 9
إذا كانت L1 وL2 بالطول نفسه، وهو شرط نضعه أحيانًا لتبسيط التعقيد — سنضع هذا الشرط لتبسيط التعقيد — عندئذ نقول إنه Θ(len(L1)²). ما زال تعقيدًا متعدد الحدود.
حسنًا، لننظر — عفوًا، سؤال.
أحد الحضور (AUDIENCE):
إذا لم تكونا بالطول نفسه، [غير مسموع]؟
آنا بيل (ANA BELL):
نعم، إذا لم تكونا بالطول نفسه، فعليكم التعبير عنه بدلالة الطولين كليهما، نعم. حسنًا، لننظر في مثال آخر. هذه دالة تأخذ تقاطع (Intersection) قائمتين. مرة أخرى، لدي مثال صغير هنا في الأعلى، مثال لـL1 وL2. سيكون التقاطع هو العناصر المشتركة داخل L1 وL2. لكنني سأقوم فقط — لن أتعامل مع تكرارات. سأحتفظ فقط بالأعداد المتميزة.
هنا تحتوي L1 وL2 على 3 و5 و2، وعلى 2 و3 و5 و9. لاحظوا أن 2 و3 و5 تحدث كلها — كلها موجودة في القائمتين. إذًا تقاطع هاتين القائمتين هو 2 و3 و5. هذا المثال هنا في الجانب الأيمن سيكون أصعب قليلًا. إنه نوع من الحالات الحدية (Edge Case) الفريدة، لكن الشيفرة تظل تعمل لتلك الحالة الحدية. وهو أن لدي L1 تحتوي على تكرارات لعدد ما، وL2 تحتوي على تكرارات للعدد نفسه؛ ينبغي أن تكون قائمة التقاطع المعادة هي فقط 7، هذا العدد الواحد مرة واحدة.
كيف تحقق الشيفرة هذا إذًا؟ تلاحظون هنا بنية صغيرة لطيفة. لدي كتلتان من الشيفرة، صحيح؟ لدي شيء هنا سيساعدنا فعلًا على بناء هذه القائمة من كل العناصر المشتركة داخل القائمتين، ثم شيء هنا في الأسفل سأُنقح به تلك القائمة للاحتفاظ بالقيم المتميزة فقط. لذا هنا في الأعلى لدينا حالة حلقات متداخلة، مثل المثال السابق تمامًا.
عليّ النظر إلى كل الأزواج من L1 وL2 لمعرفة أيها مشترك. حلقة for هذه على L1 ستمر على 3 و5 و2. ثم حلقة for الداخلية على L2 ستطابق أساسًا — ستلقي نظرة: هل يطابق 3 العدد 2؟ هل يطابق 3 العدد 3؟ هل يطابق 3 العدد 5؟ هل يطابق 3 العدد 9؟ ثم هل يطابق 5 العدد 2، وهل يطابق 5 العدد 3، وهكذا.
هذا ما تفعله تلك الحلقات. وبمجرد العثور على تطابق، سنلحقه بهذه القائمة المؤقتة. ولا بأس بوجود تكرارات في هذه القائمة. فإذا نظرتم إلى المثال في الجانب الأيمن هناك، حيث يتكرر 7 مرات كثيرة، فسيُنشئ هذا فعلًا قائمة مؤقتة تحتوي على ذلك العدد 7 تسع مرات.
ستنظر إذًا إلى 7 مع 7، وتقول: هذا تطابق. دعيني أضيفه. ثم ستنظر إلى 7 مع العدد 7 الأوسط في L2، وتقول: دعيني أضيف ذلك. ثم ستنظر إلى العدد 7 الأول في L1 مع العدد 7 الأخير في L2، وتقول: دعيني أضيف ذلك. ثم ستفعل الشيء نفسه من جديد عندما تنظر إلى العدد 7 الأوسط في L1 مع كل عنصر في L2. ستضيف 7 ثلاث مرات أخرى. ثم مرة أخرى عندما تنظر إلى العدد 7 الأخير في L1 مع كل عدد 7 في L2.
هذا مقبول تمامًا. وهذا فقط ما تفعله هذه الشيفرة. ثم الجزء السفلي هنا سيأخذ هذه القائمة المؤقتة التي أنشأناها، وسيحتفظ فقط بالقيم المتميزة داخلها. سيحتفظ بتلك — سينشئ تلك القائمة المتميزة. وسيقول: إذا لم أكن قد رأيت هذه القيمة في unique، فأضيفيها. وإذا كنت قد رأيتها، فلا تفعلي شيئًا. وهكذا في النهاية ستأخذ هذه الشيفرة هنا هذه القائمة الكبيرة هنا، وتحتفظ فقط بالقيم المتميزة.
صفحة المصدر 10
حسنًا، لنجر تحليل هذا. لدي حلقة for الخارجية وحلقة for الداخلية في النصف العلوي من شيفرتي هنا. وهذا يُنتج قائمتي المؤقتة — القائمة المؤقتة الطويلة، التي قد تكون طويلة. نعرف ذلك بالفعل من المثال السابق: إنه Θ(len(L1)) * Θ(len(L2)). بسيط جدًّا. لكن ماذا عن هذا النصف السفلي هنا؟ علينا الانتباه إلى هذا النصف السفلي. فقد يسهم أيضًا في التعقيد.
إنه يمر بحلقة على متغير مؤقت، متغير قائمة أنشأناه. لكن هذه القائمة تُنشأ بفعل شيء بـL1 وL2، بالنظر إلى عناصر في L1 وL2. لذا فهي مرتبطة في الحقيقة بصورة غير مباشرة بـL1 وL2. ولذلك لا يمكننا تجاهلها ببساطة، لأنها قد تسهم في تعقيد برنامجي.
وفي أسوأ حالة أُنشئ هذا المتغير المؤقت الذي يبدو هكذا. في أسوأ حالة ممكنة، سيكون طول متغيري المؤقت هو len(L1) * len(L2). لقد أضفت أساسًا ذلك المحرف في كل مرة قارنت فيها قيمة. لذا طول هذه القائمة في أسوأ حالة هو len(L1) * len(L2).
إذا كنت أمر على تلك القائمة، فسيكون تعقيد ذلك النصف الثاني أيضًا Θ(len(L1) * len(L2)) في أسوأ حالة، صحيح؟ لذا التعقيد الكلي للدالة هو Θ(len(L1) * len(L2)) هنا في الأعلى، زائد Θ(len(L1) * len(L2)) هنا في الأسفل.
في هذه الحالة بالتحديد، لم تزد حقيقة أنني أمر على temp تعقيدي فعلًا. لكن يمكنكم تخيل شيفرة قد يؤثر فيها فعل شيء غير معتاد كهذا، بحيث تكون لديكم بصورة غير مباشرة حلقة على شيء متعلق بالمدخل، في التعقيد. إذًا في هذه الحالة يظل التعقيد الكلي Θ(len(L1) * len(L2)). هل توجد أسئلة عن هذا المثال؟ حسنًا. نعم؟
أحد الحضور (AUDIENCE):
لماذا ليس — مرة أخرى، لأن [غير مسموع] أنت تلحق عددًا معينًا منها، فكيف تعرف أن ذلك — مثل [غير مسموع] يختلف في كل مسألة.
آنا بيل (ANA BELL):
إنه يختلف في كل مسألة، صحيح؟ لكننا في التحليل مهتمون بسيناريو أسوأ حالة، مثل السلوك التقاربي لأسوأ حالة. وفي أسوأ حالة أضفنا هذا العدد بعدد مرات طول L1 مضروبًا في L2. معظم الوقت، بالطبع، لن يكون الأمر بهذا السوء. إنه بهذا السوء في هذه الحالة المعينة الواحدة فقط.
أحد الحضور (AUDIENCE):
آه، أرى ما تضيفينه، حسنًا.
آنا بيل (ANA BELL):
حسنًا، لننظر في دالة أخرى متعددة الحدود. هذه diameter. رأيناها في المحاضرة الماضية. أساسًا، إذا كانت لدينا مجموعة من النقاط في مستوى ثنائي الأبعاد (2D Plane)، فستخبرنا هذه الدالة بالمسافة — عفوًا، بالمسافة القصوى بين أي نقطتين. لذلك رسمت تلك الصورة في المستوى ثنائي الأبعاد. وستحتوي هذه الدالة مرة أخرى على حلقات متداخلة. الحلقة الخارجية تتكرر بعدد مرات طول L.
تذكروا أن L لدينا هي مجرد قائمة من صفوف (Tuples) تمثل إحداثيات x وy هذه. لذا الحلقة الخارجية تمر بسهولة بعدد مرات طول L. لكن على ماذا تمر الحلقة الداخلية؟ تبدأ الحلقة الداخلية في الحقيقة من i، لا من 0. لو بدأت من 0، لكانت الحلقة الداخلية بوضوح Θ(len(L)). لكنها لا تفعل ذلك. إنها تبدأ من i. ومع ذلك، كم مرة تمر تلك الحلقة الداخلية في المتوسط؟
صفحة المصدر 11
في المرة الأولى التي تمر فيها تلك الحلقة الداخلية، ستنظر إلى len(L) - 1 عنصرًا. في المرة التالية ستنظر إلى len(L) - 2 عنصرًا. في المرة التالية ستنظر إلى len(L) - 3 عنصرًا، إلى أن نصل إلى النهاية، حيث ستنظر إلى عنصر واحد ثم إلى 0 عناصر.
إذا فكرنا في عدد المرات التي تتكرر فيها تلك الحلقة الداخلية فعلًا، فسيكون، ما هو، شيء مثل ((len(L) - 1) * len(L)) / 2. أهذه هي الدالة، على ما أظن، لجمع كل هذه معًا؟ شيء من هذا القبيل، وهو أساسًا يظل شيئًا يمثل دالة في طول L. مثلًا يمكننا تبسيطه ليكون 0.5 * len(L). لذا فهو ما زال دالة في طول L، لأن المعامل الذي يسبق طول L هو 0.5.
إذًا التعقيد الكلي للحلقة الداخلية يظل Θ(len(L)). حسنًا، كل شيء آخر داخل هذه الشيفرة ثابت. لذلك التعقيد الكلي هو فقط Θ(len(L)²). نعم. عفوًا؟ من أين جاء 1/2 — آه، إنها صيغة الجمع — مثلًا إذا جمعتم 1 زائد 2 زائد 3 زائد 4، وصولًا إلى n، فما صيغة فعل ذلك؟ أظن أنها شيء مثل n * (n + 1) / 2، شيء من هذا القبيل.
هذا ليس نصفًا بالضبط، لكنه شيء من الرتبة زائد، لا أعرف، شيء ما، أيًّا كانت نتيجة هذا الحساب. لكنه في الواقع شيء أصغر من طول L، ومع ذلك يظل دالة في طول L. ولذلك فإن ذلك المعامل الأمامي الذي يسبق طول L مباشرة يُحذف. حتى لو كان 10، فسنحذفه أيضًا. في هذه الحالة هو 0.5 أو أيًّا كان. إذًا هو ما زال أقل من 1، لكننا نحذفه أيضًا لأننا مهتمون بثيتا لهذا. نعم.
أحد الحضور (AUDIENCE):
[غير مسموع] حلقات for متداخلة — مع حلقتي for، ذلك الطول [غير مسموع] وn² [غير مسموع]؟
آنا بيل (ANA BELL):
أعني، يمكن أن تكون الحلقة الداخلية غير معتمدة على المدخل إطلاقًا. هنا مثلًا هو n²، لأن كلتا الحلقتين تعتمدان خطيًّا على المدخل. لكن إذا كانت الحلقة الداخلية — مثلًا لو مرت الحلقة الخارجية على نطاق مقداره طول L تربيع، لكان التعقيد الكلي في هذه الحالة طول L تكعيب، لأنه طول L تربيع مضروبًا في طول L.
أو إذا كانت إحدى الحلقتين لا تعتمد على المدخل إطلاقًا، فهي لا تسهم بشيء ثابت — بشيء خطي، لذا فهي ثابتة. حسنًا، لنجعلكم تفكرون قليلًا في هذا السؤال.
فكروا إذًا في المدخل. فكروا في أجزاء الدالة التي تعتمد على المدخل. ثم ما التعقيد؟ حسنًا، الحلقة الخارجية هي ثيتا لأي شيء؟ نعم.
أحد الحضور (AUDIENCE):
[غير مسموع]
آنا بيل (ANA BELL):
نعم، nums قائمة، ولذلك الحلقة الخارجية هي Θ(len(nums)). صحيح. جيد. والحلقة الداخلية هي ثيتا لأي شيء؟ نعم. أهذا ما كنت ستقوله، Θ(1)؟ بالضبط. إنه طول digits، لكن digits ليست مدخلي. nums هي مدخلي. لذا الحلقة الداخلية ستتكرر دائمًا 10 مرات فقط. وبالنسبة إلى مدخلات الدالة، فهذا ثابت فقط.
إذًا المدخل هو nums. الحلقة الخارجية هي ثيتا لـnums. والحلقة الداخلية هي Θ(1). ولذلك التعقيد الكلي هو Θ(len(nums)). ممتاز. ماذا عن هذا المثال؟ ما مدخلاتي؟ هل تعتمد أي حلقات على هذه المدخلات؟ ما تعقيد الحلقة الخارجية؟ نعم.
أحد الحضور (AUDIENCE):
طول L1.
آنا بيل (ANA BELL):
نعم، Θ(len(L1))، بالضبط. ما تعقيد الحلقة الداخلية؟
أحد الحضور (AUDIENCE):
ثيتا لطول L2.
صفحة المصدر 12
آنا بيل (ANA BELL):
Θ(len(L2))، ممتاز. وهل هناك أي شيء آخر يسهم في التعقيد هنا؟ ما ذلك؟
أحد الحضور (AUDIENCE):
عبارة if.
آنا بيل (ANA BELL):
عبارة if. نعم، ما الذي فيها يجعلك تتساءل إن كان التعقيد غير ثابت؟
أحد الحضور (AUDIENCE):
إذًا in تمر عبر طول L3.
آنا بيل (ANA BELL):
بالضبط، نعم، جميل جدًّا. in تمر عبر طول L3. البحث عن عنصر في L3، التعبير e1 in L3، ليس ثابتًا. عليكم النظر عبر طول L3 كله لمعرفة ما إذا كان موجودًا أم لا. لذا هذا الجزء الداخلي هنا ليس ثابتًا. إنه Θ(len(L3)). في الحقيقة، إنه ضعف طول L3. إذًا التعقيد الكلي لهذه الدالة هو Θ(len(L1)) * Θ(len(L2)) * Θ(len(L3)).
حسنًا. رائع. لننظر في التعقيد الأسي (Exponential Complexity). هذا تعقيد ينمو بسرعة كبيرة جدًّا، جدًّا. لا نريد أبدًا أن تقع الخوارزميات التي نكتبها ضمن هذه الفئة. للأسف، هناك بعض المسائل في الحياة الحقيقية التي علينا حسابها، وهي بطبيعتها جزء من فئة التعقيد هذه. توجد بعض التقنيات للتعامل مع جعل هذه الخوارزميات أسرع قليلًا.
لكن توجد بطبيعتها خوارزميات أسية لا نستطيع أن نفعل أفضل من الأسي في حل هذه المسائل. حسنًا، لننظر في فيبوناتشي مرة أخرى. نظرنا إلى فيبوناتشي قبل بضع شرائح، النسخة التكرارية. وكانت النسخة التكرارية Θ(n). لكن إذا نظرنا إلى النسخة العودية من فيبوناتشي، فهي ليست Θ(n) إطلاقًا. بل كما ترون، إنها في هذه المجموعة من الشرائح الخاصة بالأسي؛ فالنسخة العودية من فيبوناتشي أسية في الحقيقة.
لنتذكر ما تفعله هذه الشيفرة. هناك حالتا أساس، فيبوناتشي لـ0 ولـ1. ثم الخطوة العودية هي فيبوناتشي لـn - 1 زائد فيبوناتشي لـn - 2. لذا عند كل مستوى ننزل إليه، سيكون هناك ضعف عدد المسارات التي نحتاج إلى استكشافها للحصول على القيم منها.
لأول n، لدينا قيمة واحدة فقط نحصل عليها. ولـn التالية لدينا ضعف ذلك العدد من القيم لنحصل عليها. المستوى التالي — لـn التالية — لدينا قيم أكثر بمقدار الضعف. وهكذا. لذا حقيقة أن هناك استدعاءين عوديين في هذه الخطوة العودية تقودنا إلى هذه البنية الصغيرة التي تشبه شجرة مقلوبة. وقد رسمنا هذا أيضًا عندما نظرنا في عدد استدعاءات الدوال التي تُنفذ.
تذكروا، عندما نحدد التعقيد لدالة عودية، نحتاج إلى معرفة كم واحدة من هذه الدوال — كم استدعاءً عوديًّا ننفذ فعلًا. وبسبب بنية الشجرة هذه، فكلما أضفنا مستوى جديدًا، تكون لدينا أساسًا مساران منفصلان تمامًا لاستكشافهما أكثر. ولهذين المسارين مساران خاصان بهما، وهكذا. وهذا يقودنا إلى بنية الشجرة هذه، التي ستقود في الحقيقة إلى كون العدد الكلي للاستدعاءات العودية أسيًّا، أي Θ(2ⁿ).
الآن، إذا نظرنا إلى شجرة الاستدعاءات العودية الفعلية — صحيح، نظرنا إلى هذا، وكان يبدو شيئًا مثل هذا قبل مجموعة من المحاضرات — فقد تلاحظون أن الشجرة تتناقص كثافتها قليلًا باتجاه اليمين. إنها ليست شجرة كاملة، بأوراق مصطفة بشكل جميل وصولًا إلى الأسفل. وذلك لأن المسار الأيسر يحسب Fib(5)، لكن المسار الأيمن يحسب Fib(4)، أي n - 1 مقارنة بالمسار الأيسر. لكن لا بأس بذلك.
صفحة المصدر 13
ليس الأمر أننا سنسرّع فعلًا أي شيء بنوع من رتبة المقدار. مجرد تناقص كثافة الشجرة قليلًا في الجانب الأيمن لن يسرّع التعقيد الكلي لهذه الدالة. سيكون Θ(2ⁿ) ناقص ثيتا لشيء أقل من 2ⁿ. لذا هذا الطرح لن يقلل حقًّا التعقيد الكلي لدالتنا. ولذلك تظل رتبة هذا أسية.
حسنًا، هذا مثال آخر لشيفرة أسية. هذه دالة ستُولّد كل المجموعات الجزئية لقائمة. مرة أخرى، أضفت مثالًا صغيرًا هنا لمساعدتنا على فهم ما تفعله. هنا لدي ثلاثة أعداد، قائمة تحتوي على ثلاثة أعداد: 1 و2 و3. وما يعنيه توليد المجموعات الجزئية هو أنني سأُنشئ قائمة جديدة بكل التركيبات الممكنة للأعداد داخل قائمتي الأصلية، بكل الأطوال الممكنة.
أولًا، يمكن أن تكون إحدى المجموعات الجزئية لهذه القائمة هي القائمة الفارغة فقط. أي عدم أخذ أيٍّ من أعدادي الأصلية إطلاقًا. التالية هي قائمة تحتوي على عدد واحد فقط من هذه الأعداد. إما 1 أو 2 أو 3. ويمكن أن تكون مجموعة جزئية تالية من قائمتي هي أخذ عنصرين فقط. أي 1 و2، و1 و3، و2 و3، ثم أخيرًا يمكنني أخذ كل العناصر. أي 1 و2 و3. لا أهتم بالترتيب. أهتم فقط بأن يكون لدي كل هذه التركيبات المختلفة، بكل الأطوال المختلفة، في قائمتي النهائية.
هل يفهم الجميع هدف هذه الدالة؟ حسنًا، كيف نحقق هذا؟ ربما لن تتفاجؤوا. سنفعله بطريقة عودية. هذه حقًّا الطريقة المعقولة الوحيدة لكتابة هذه الشيفرة. سأمر على هذه الشريحة، وأشرح فقط ما يفعله كل سطر. لكن في الشريحة التالية سيكون لدي رسم متحرك صغير يوضح خطوة بخطوة كيف تنشئ الدالة قائمة المجموعات الجزئية هذه.
أول شيء، إنها عودية، لذا لدي حالة الأساس هنا في الأعلى. إذا كانت لدي قائمة طولها 0، فستكون المجموعة الجزئية لقائمة فارغة مجرد هذه القائمة التي تحتوي على الشيء الفارغ داخلها. إذا لم تكن لدي عناصر، فهناك مجموعة جزئية واحدة فقط، هي القائمة الفارغة. ثم إذا كان لدي أكثر من عنصر واحد داخلها، فسأستخدم الفكرة نفسها التي رأيناها عندما عملنا على القوائم في محاضرات العودية.
سأستخرج واحدًا من عناصري. سأعمل على القائمة المتبقية. ثم سأفعل شيئًا بأخذ ذلك العنصر وإلحاقه مجددًا بالنتيجة. في هذه الحالة بالتحديد، الشيء الذي أستخرجه هو آخر عنصر في قائمتي. إذا كانت قائمتي 1 و2 و3، ففي خطوة هنا سأستخرج 3 وأجعله في قائمته الخاصة. هذا ما تفعله تلك الخطوة. إنها تستخرج آخر عنصر في القائمة.
ثم أنفذ استدعاء دالة لتوليد المجموعات الجزئية لكل شيء عدا ذلك العنصر الأخير. إذا كنت — إذًا أقول: أيتها الدالة التي أكتبها حاليًّا الآن، إذا استطعت أن تولدي لي المجموعة الجزئية لكل العناصر، المجموعة الجزئية لهذه القائمة، فسوف تتوصلين إلى شيء يبدو هكذا. ستكون القائمة الفارغة، و1، و2، و1 و2 معًا.
إذًا ستكون المجموعة الجزئية لهذه القائمة هذه المجموعة من العناصر هنا. وهذا ما سيفعله هذا. وهذا، مرة أخرى، ثقتنا بأن الدالة التي نكتبها ستولد شيئًا يبدو هكذا. إذا وصلنا إلى هذه النقطة، فستكون smaller قائمة تبدو هكذا. الجزء التالي من الشيفرة سيأخذ ذلك الشيء الصغير الإضافي الذي حفظته سابقًا، وسيلحق ذلك العدد 3 بكل عنصر داخل هذه القائمة.
عندئذ سأقول أساسًا: سأأخذ هذا العدد 3، وأُنشئ قائمة تحتوي على 3، وقائمة تحتوي على 1 و3، وقائمة تحتوي على 2 و3، وقائمة تحتوي على 1 و2 و3. لقد أخذت ذلك العدد 3 وأضفته إلى كل شيء نتج من سطر الشيفرة هذا هنا، من استدعاء دالتي لنفسها.
صفحة المصدر 14
ثم كل ما تفعله هو إعادة smaller + new. إذا جمعت هاتين معًا، فسيولد لي هذا المجموعة الجزئية النهائية التي كنت مهتمة بها. لدي الشيء الفارغ. ولدي 1 و2 و3 كلٌّ بمفرده. ولدي 1 و2؛ و1 و3؛ و2 و3 بمفردها؛ ثم 1 و2 و3 كلها معًا. هذه هي الفكرة الكبرى هنا.
لنمر عليها خطوة بخطوة، مع استدعاء أنفسنا عوديًّا. هذا أنا أجد — أبدأ استدعاء دالتي، وأقول: ولدي المجموعات الجزئية للقائمة 1 و2 و3. سأحتفظ بـextra جانبًا. أحتاج إلى تنفيذ استدعاء دالة آخر، لأنني لست عند حالة الأساس. لذا سأستدعي gen_subsets على 1 و2.
هذه أيضًا ليست حالة الأساس. لذا سأأخذ آخر عنصر لدي، وأضعه جانبًا، وسأستدعي gen_subsets على 1 فقط. ما زلنا لسنا عند حالة الأساس. سأأخذ هذه extra، وأضعها جانبًا، وسأستدعي gen_subsets على القائمة الفارغة. وهنا أصل إلى حالة الأساس. حتى الآن، لم يُرجع أي شيء إطلاقًا. ولم يُنفذ أي عمل.
عند حالة الأساس، ستقول Python: أعرف ما هذا. سيكون القائمة التي لا تحتوي إلا على الشيء الفارغ داخلها. حسنًا، رائع؟ بعد ذلك تُعاد تلك النتيجة، فيختفي استدعاء الدالة هذا. ماذا ستفعل الآن؟ ستأخذ تلك extra التي وضعتها جانبًا، وتأخذ قائمة smaller التي أعدتها للتو، وتضاعف أساسًا تلك القائمة الأصغر. هذه قائمة smaller لدي. ثم سأضاعفها بأن أقول: سأضع هذا العدد 1 في نهاية كل شيء في قائمة smaller لدي.
ربما لا يكون هذا ظاهرًا جدًّا عند هذه الخطوة، لكن لنذهب خطوة أخرى ونرَ ما يحدث. الآن تنتهي هذه الدالة أيضًا. إنها تعيد هذه القائمة الفارغة ومعها 1، وتقول: حسنًا، هنا، في استدعاء الدالة هذا، كنت قد حفظت 2 بصورة منفصلة وقلت إنني سألحق الآن هذا العدد 2 بنهاية كل شيء أعدته للتو. هذه إذًا smaller. وهذه smaller هنا.
كل ما سأفعله هو أخذ هذا الشيء extra وإلحاقه بنهاية كل شيء كان في smaller. سألحقه بنهاية هذه القائمة الفارغة، فيعطيني ذلك فقط هذا العدد 2، وألحقه بنهاية هذا العدد 1، فيعطيني 1 و2.
لقد ضاعفت قائمتي أساسًا في هذه المرحلة. خطوة أخرى، تُعاد هذه النتيجة. وهذا الآن هو استدعاء دالتي الأصلي. الشيء الذي كنت قد استخرجته هو 3. نحن الآن أساسًا عند هذه الخطوة هنا. استخرجت 3. وأعادت الدالة التي تحتها مباشرة هذه smaller، صحيح؟ وهذا يعني أن هذا العدد 3 سيُلحق بنهاية كل شيء كان في smaller.
سيُلحق بنهاية هذه القائمة الفارغة ليعطيني 3 فقط، وبنهاية 1 ليعطيني 1 و3، وبنهاية 2 ليعطيني 2 و3، وبنهاية 1 و2 ليعطيني 1 و2 و3. هذه الآن هي الإجابة النهائية. أحتفظ أساسًا بما أعدته من استدعاء الدالة السابق، وأصله (Concatenate) بالشيء الذي أنشأته للتو، حيث ألحقت العدد 3 لدي. وهذه إجابتي النهائية. إنها فقط بترتيب مختلف عما كنا سنكتبه حدسيًّا باليد.
لكنها تشمل كل العناصر التي كنت أرغب في وجودها على أي حال. لدي القائمة الفارغة، وكل شيء يحتوي على عنصر واحد فقط، وكل شيء يحتوي على عنصرين، وكل شيء يحتوي على العناصر الثلاثة كلها. لننظر إذًا في تحليل التعقيد لهذا. يحدث هنا شيئان. أحدهما هو عدد استدعاءات الدوال هذه التي تُنفذ فعلًا، مثل بنية الشجرة المقلوبة. كم واحدًا من استدعاءات الدوال هذه نحتاج إلى تنفيذه للوصول إلى نهاية — إلى حالة الأساس لدينا؟
صفحة المصدر 15
وفوق ذلك — عفوًا، سيخبرنا ذلك بعدد العناصر الفعلية التي ستكون لدينا في القائمة. وفوق ذلك، لدينا في الحقيقة تعقيد زمني غير ثابت. وهو نسخ قائمتنا. نسخ قائمة ليس ثابتًا، لأنه يستغرق بعض الوقت لأخذ كل العناصر في قائمة وإنشاء نسخة منها.
إذا فكرنا في الوقت الذي يستغرقه إنشاء قائمتنا عند كل خطوة، وكم واحدًا من هذه العناصر الفرعية نُنشئ، ففي حالة الأساس نفسها لدينا عنصر واحد؛ وفي الحالة التي فوقها مباشرة كان لدينا عنصران؛ وفي الحالة التي فوق تلك مباشرة كان لدينا أربعة عناصر؛ وفي الحالة التي فوق تلك مباشرة كان لدينا ثمانية عناصر. إذًا عند كل خطوة كان عدد القوائم الفرعية التي نولدها أساسًا ضعف عدد الخطوة السابقة.
كان العدد الكلي للمجموعات الجزئية إذًا من رتبة 2ⁿ. لكن كانت هناك أيضًا كلفة زمنية لإنشاء نسخة من القائمة داخل كل واحدة من تلك المجموعات الجزئية. لذا نضرب التعقيد اللازم لتنفيذ كل استدعاءات الدوال تلك وتوليد كل تلك المجموعات الجزئية في الزمن اللازم لإنشاء نسخة من القائمة. وعليه فالتعقيد الكلي سيكون في الحقيقة Θ(n * 2ⁿ)، لأنه أصعب قليلًا — أسوأ قليلًا من الأسي، فقط لمجرد أننا ننسخ القائمة عند كل خطوة.
حسنًا، لننتقل إلى التعقيد اللوغاريتمي (Logarithmic Complexity). سيكون هذا أصعب قليلًا، لأننا لن نستطيع منذ البداية رؤية علاقة مباشرة بين المدخل والحلقة الموجودة لدينا فعلًا. هنا لدي دالة اسمها digit_add. ستأخذ عددًا، مثل 1234، شيء من هذا القبيل، العدد 1,234.
تحول الشيفرة ذلك إلى سلسلة نصية. إنها تأخذ قيمة عددية خالصة، وتجعل منها سلسلة، ثم تمر على السلسلة. إذًا الدالة هنا، من حيث التعقيد الزمني، هي ثيتا لطول s. هنا نمر على السلسلة بالعكس أساسًا: 4، ثم 3، ثم 2، ثم 1. لكن ما مدخلي؟ إنه n. وليس s.
التعقيد الزمني لهذه الدالة، مع أنه خطي في s، فإن s ليست خطية في n، لأن حلقتي تتكرر مرتين فقط عندما يكون عددي 83. وإذا كان عددي يحتوي على أربع خانات، 4,271، فإن حلقتي تتكرر أربع مرات. إذًا هذه العلاقة ليست خطية. فما هي بالضبط؟ لنفكر فيما تفعله تلك الحلقة فعلًا.
إذا كان لدي عدد يحتوي على أربع خانات، شيء في الآلاف، فعندما أمر على العدد بالعكس نوعًا ما، هذا العدد بوصفه سلسلة نصية، فأنا أساسًا آخذ ذلك العدد 1 وأحتفظ به في مجموعي الجاري. ثم يبدو الأمر كأنني قسمت ذلك العدد على 10. أخذت باقي القسمة عندما قسمت ذلك العدد على 10. وهذا هو الشيء الذي أضفته للتو. والعدد الصحيح المتبقي عندما قسمت على 10 هو هذا الجزء هنا.
فكروا الآن في الأمر كأخذ هذا — لأخذ هذا العنصر الأخير هنا، يبدو الأمر كأنني آخذ هذا العدد وأقسمه على 10 مرة أخرى. آخذ باقي القسمة عندما أقسم على 10، وأضيفه إلى مجموعي الجاري. والعدد الصحيح الذي يبقى لي من القسمة على 10 هو هذا فقط. مرة أخرى، آخذ 2. الباقي عندما قسمت ذلك العدد 42 هو 2، والعدد الصحيح الذي بقي لي هو 4. وأخيرًا يمكنني فعل ذلك الشيء الأخير مرة أخرى.
ما العلاقة بين مقدار n، هذا العدد الذي يساوي أربعة آلاف وشيئًا، أو هذا العدد الذي يساوي ثمانين وشيئًا، وبين عدد المرات التي عليّ أن أمر فيها بالحلقة للحصول على كل خانة في عددي؟ الحيلة هنا هي التفكير في أخذ مقداري، n لدي — مقدار n — وقسمته على 10 مجموعة من المرات. كم مرة أقسم على 10 للحصول أساسًا على كل عنصر، كل خانة في n لدي؟
صفحة المصدر 16
بعدد مرات طول s، صحيح؟ هذا يشبه أخذ كل محرف على حدة. لأخذ كل محرف على حدة، يبدو الأمر كالقسمة على 10 للحصول على الباقي. ثم أكون قد فعلت ذلك بعدد مرات طول s. هذا ما تفعله هذه الحلقة. إذًا العلاقة بين مقدار n وعدد المرات التي أمر فيها بالحلقة هي هذه: n مقسومة على 10 عددًا من المرات، بعدد مرات طول s، تساوي 1. وهذا يعني أنني انتهيت من المرور عبر هذا العنصر كله — هذا العدد كله، كل الخانات داخل العدد.
إذًا العلاقة بين n وطول s هي أن طول s يساوي log n. والآن، بعد أن أصبحت لدي هذه العلاقة الجميلة، فقد قلت إن هذه الدالة خطية في طول s. فإذا كانت Θ(len(s))، فستكون Θ(log n). لقد ربطت هذين الأمرين معًا فقط.
هل توجد أسئلة عن هذا؟ يمكن أن تنجح هذه الحيلة بطرق مختلفة كثيرة. المهم أن ندرك أن هناك هنا نوعًا من العلاقة غير المباشرة بين ما يحدث فعلًا في الشيفرة ومدخلي. إنها ليست واضحة تمامًا بهذه البساطة. لكن هناك علاقة ما، وهي ليست ثابتة وليست خطية.
حسنًا. التعقيد الكلي لهذه الدالة هو إذًا Θ(log n)، حيث إنني لا أهتم فعلًا بالأساس عندما أذكر التعقيد بدلالة اللوغاريتم. في هذه الحالة الأساس هو 10. لكن لو كان الأساس 2، لكان الأمر نفسه، log n.
حسنًا، رأينا مجموعة من الأمثلة، مثالًا واحدًا فقط للتعقيد اللوغاريتمي. لكن سنرى تاليًا أن البحث عن عنصر في القائمة سيكون ذا تعقيد لوغاريتمي أيضًا. قبل أن نصل إلى ذلك، أود فقط أن أصنع — أن أعرض هذه الشريحة فقط، لأذكركم بأن هناك عدة دوال، دوال مضمنة، تتعامل مع القوائم والقواميس، ليست ثابتة.
كما في المثال الذي حللتموه، حيث استخدمنا العامل in، كان علينا الانتباه — إذا رأيتم هذه العمليات تُنفذ في الشيفرة، فلا تتجاهلوها فقط. عليكم احتسابها ضمن تحليل التعقيد.
حسنًا، سننظر تاليًا في بعض خوارزميات البحث (Searching Algorithms). هذه الخوارزميات، سنرى مجموعة من الشيفرات المختلفة التي تنفذ البحث. ستكون هذه — مرة أخرى، ستكون شبيهة جدًّا بتلك التي قسنا زمنها فعلًا في المحاضرة الماضية. سننظر إلى البحث عن عنصر في قائمة. وسننظر في مجموعة من التطبيقات المختلفة للبحث البسيط بالقوة الغاشمة (Brute Force) عن عنصر في قائمة.
سواء كانت مرتبة أو غير مرتبة، فما دمتم تشقون طريقكم بالقوة الغاشمة من بداية القائمة إلى نهايتها، فستتمكنون من العثور على العنصر الذي تبحثون عنه، أو القول إنه غير موجود. سننظر إذًا في بعض دوال البحث الخطي (Linear Search). ثم سننظر في البحث بالتنصيف (Bisection Search) — تطبيقين للبحث بالتنصيف. وفيه نقسم القائمة إلى نصفين، ونتخلص من أحد النصفين. لكن هذه التطبيقات ستحتاج إلى أن تكون قائمتنا مرتبة.
إذًا في طريقة القوة الغاشمة لا يهم حقًّا ما إذا كانت القائمة مرتبة أم لا، لكن البحث بالتنصيف لا يعطي الإجابة الصحيحة إلا إذا كانت القائمة مرتبة منذ البداية. حسنًا، لننظر أولًا إلى البحث الخطي في قائمة غير مرتبة. هذه شيفرة ستبحث عن العنصر e في القائمة L. إنها تمر بحلقة عبر طول القائمة، وتحتفظ بهذا العلم المنطقي (Boolean Flag). إذا وجدت العنصر الذي نبحث عنه، فإنها تضبط العلم فقط. وفي نهاية المرور على القائمة كلها تخبرنا ما إذا كانت قد وجدته أم لا.
صفحة المصدر 17
تحليل سيناريو أسوأ حالة يقول إذًا إن علينا النظر في القائمة كلها لتحديد ما إذا كان العنصر موجودًا أم لا. لذا ثيتا لهذه الدالة بالتحديد هي Θ(len(L)). هناك حلقة واحدة فقط. وهي تعتمد على طول L، ولا شيء مميزًا حقًّا في هذه الدالة. الآن، ربما تلاحظون أن هناك شيئًا غير كفؤ في هذه الدالة، وهو أنها بمجرد العثور على عنصر، لنقل في بداية القائمة، فإن هذه الدالة تضبط العلم فقط، ثم تواصل المرور إلى نهاية القائمة.
يمكننا في الحقيقة إجراء تسريع بسيط بهذا الجزء هنا، ونقول: إذا وجدناه، فأعيدي True فورًا. لا حاجة إلى مواصلة المرور إلى نهاية القائمة. فما تحليل هذه الشيفرة؟ مرة أخرى، نجري تحليل أسوأ حالة. وفي أسوأ حالة، العنصر غير موجود. لذا ما زال علينا البحث عبر كل عنصر في القائمة، من البداية إلى النهاية، لتحديد أنه غير موجود.
إذًا تحليل ثيتا لأسوأ حالة لهذه الدالة يقول إننا ما زلنا بحاجة إلى المرور إلى نهاية القائمة لتحديد أنه غير موجود. لذا ستظل — عفوًا، ستظل من الزمن Θ(n) — عفوًا، Θ(len(L)). هذا في قائمة غير مرتبة. لكن ماذا لو نظرنا في قائمة مرتبة؟
حسنًا، يمكننا فعل شيء ذكي قليلًا في شيفرتنا. إذا كانت القائمة مرتبة، يمكننا القول إننا سنبدأ — لنفترض أنها مرتبة تصاعديًّا. يمكننا البدء من بداية القائمة، والنظر إلى كل عنصر. إذا وجدناه، نعيد True. وإذا وصلنا إلى عنصر أكبر من الذي نبحث عنه، فإذا كانت القائمة مرتبة، فكل العناصر المتبقية في القائمة أكبر أيضًا من الذي نبحث عنه، صحيح؟
وعندئذ يمكننا إعادة False فورًا. نعتقد أننا أذكياء إلى حد ما، لكن تحليل أسوأ حالة يقول إن العنصر ليس في القائمة أصلًا. لذلك ما زال علينا المرور والنظر وصولًا إلى نهاية القائمة لمعرفة أن ذلك العنصر غير موجود. ما زال علينا لمس كل عنصر في القائمة لتحديد أنه غير موجود. لذا تحليل تعقيد ثيتا لأسوأ حالة ما زال يقول إن هذا Θ(len(L))، لأن كل شيء آخر ثابت.
لننظر الآن إلى التنصيف. بقدر ما يمكننا أن نرى، فإن مجرد إجراء بحث خطي بطريقة القوة الغاشمة لن يعطينا أي شيء أفضل من Θ(n). لكن عندما نظرنا في الأزمنة في المحاضرة الماضية، رأينا أن هذا البحث الثنائي (Binary Search)، أو البحث بالتنصيف عن عنصر في قائمة، كان أسرع بكثير جدًّا. كان ينمو بمعدل أسرع من الخطي، لكنه ليس ثابتًا تمامًا. لنتذكر إذًا كيف كانت تبدو تلك الشيفرة.
كان لدينا أساسًا قائمة تحتوي على مجموعة من العناصر. نظرنا إلى العنصر في منتصف القائمة. وقلنا: أأنت العنصر الذي نبحث عنه؟ في أسوأ حالة، ليس هو. إذًا علينا أن نسأل: أأنت أكبر أم أصغر من العنصر الذي نبحث عنه؟ إذا كان أكبر، فنعرف أن علينا النظر في النصف الأدنى من القائمة. وإذا كان أصغر، ننظر في النصف الأعلى من القائمة.
والآن، بعد أن ننظر إما في النصف الأدنى أو الأعلى، نلاحظ أن لدينا المسألة نفسها تمامًا لنحلها. ينبغي أن يقرع هذا جرسًا صغيرًا يقول إن علينا استخدام العودية (Recursion). لدينا الآن المسألة نفسها لنحلها: عنصر e في قائمة أصغر قليلًا. هل هو موجود في تلك القائمة؟ وهذا بالضبط ما سننفذه.
من الناحية البصرية، هذا ما سنفعله. ستكون لدينا قائمة أصلية فيها n عنصرًا. سننظر إلى نقطة المنتصف. في أسوأ حالة، ليست العنصر الذي نبحث عنه. لذا سنقرر أحد الجانبين لنبحث فيه بعد ذلك. أصبح لدينا الآن n / 2 عنصرًا لننظر خلالها. مرة أخرى، العنصر ليس هناك، في أسوأ حالة. لذا علينا تقرير أي نصف ننظر فيه. أصبح لدينا الآن n / 4 عنصرًا لننظر خلالها. نواصل فعل ذلك. نواصل التنصيف عبر استدعاءات عودية أكثر فأكثر، حتى نصل إلى حالة أساس.
صفحة المصدر 18
وحالة الأساس هي أن لدينا الآن قائمة تحتوي على عنصر واحد. إما أن ذلك العنصر هو ما نبحث عنه، أو، في أسوأ حالة، ليس هو، ونكون قد حددنا أن العنصر الذي نبحث عنه ليس في هذه العناصر الـn إطلاقًا. إذًا حالة الأساس لدينا هنا في الأسفل. وقد بدأنا بـn عنصرًا هنا. لذلك ستكرر خوارزمية البحث بالتنصيف مهمة تقسيم القائمة إلى نصفين هذه، لنقل i مرة.
هذا إذًا هو، بين علامتي اقتباس، «عدد التكرارات التي كنا سننفذها». لكن بما أن هذا عودية، فلا توجد تكرارات. هذا هو عدد استدعاءات الدالة لدينا حتى نصل إلى حالة الأساس، i استدعاءً للدالة. فإذا أخذنا عناصرنا الأصلية الـn، وقسمناها على 2 عددًا من المرات حتى لا يبقى لدينا إلا عنصر واحد نبحث فيه، فعندها نكون قد وجدنا إجابتنا.
لدينا الآن علاقة بين عدد العناصر التي كانت لدينا أصلًا، n عنصرًا، وعدد المرات التي اضطررنا فيها إلى تقسيم حلقتنا للوصول إلى إجابتنا، وعدد هذه المستويات لدينا. n / 2ⁱ = 1، هذه علاقتنا. إذًا في خوارزمية البحث بالتنصيف، كم مرة نستدعي هذه الدالة العودية للوصول إلى حالة الأساس؟ عدد i من المرات.
فما i بدلالة n؟ العلاقة بين i وn مشابهة لتلك التي كانت لدينا هنا، حيث قسمنا هذا العدد على 10 في كل مرة، إلا أننا الآن نقسم قائمة من n عنصرًا على 2 في كل مرة. لذا العلاقة تظل لوغاريتمية. إنها تربط عدد العناصر التي كانت لدي أصلًا، n، بعدد المرات التي اضطررت فيها إلى تقسيم قائمتي للوصول إلى عنصر واحد، سواء كان هو العنصر الذي أبحث عنه أم لا.
إذًا تعقيد خوارزمية البحث بالتنصيف الخالصة فقط هو Θ(log n)، حيث n طول القائمة. هذا هو عدد التقسيمات الفرعية التي أحتاج إلى تنفيذها للوصول إلى عنصر واحد كي أقرر أنه ليس العنصر الذي أبحث عنه.
سننظر الآن في تطبيقين مختلفين للشيفرة لتنفيذ البحث بالتنصيف. أحدهما سيكون أكثر كفاءة من الآخر. لنبدأ بالتطبيق الأبسط كتابةً، لكنه أقل كفاءة. كما ترون هنا، تبحث هذه الشيفرة عن العنصر e في القائمة L. تحتوي على حالتي أساس في الأعلى، كلتاهما ثابتة، وعلى خطوة عودية واحدة هنا. إذًا ننفذ إما هذه أو هذه.
هذه في حال قررنا أن علينا النظر في النصف الأدنى، وهذه في حال قررنا أن علينا النظر في النصف الأعلى بحثًا عن العنصر. هذا مجرد بحث بالتنصيف خالص، وقد قررنا في الشريحة السابقة أنه ثيتا للوغاريتم طول القائمة، Θ(log n). وهذا جيد، لكن ماذا لدينا بوصفه معاملًا هنا؟ إنه نصف قائمتي.
إضافة إلى تنفيذ البحث بالتنصيف، وتنفيذ الخوارزمية فقط، ووجود مجموعة من استدعاءات البحث بالتنصيف التي توصلني إلى تلك القائمة التي تحتوي على عنصر واحد، فوق ذلك، في كل مرة أنفذ فيها استدعاء البحث بالتنصيف هذا، أنسخ قائمتي. لذا هذا ليس ثابتًا. إنه Θ(len(L) / 2)، صحيح؟ آخذ نصف قائمتي.
إذًا تعقيد تلك الشيفرة هو Θ(n * log n). هناك Θ(log n) لجزء البحث بالتنصيف، لكن Θ(n) تلحق بكل واحد من تلك الاستدعاءات، لأن عليّ أخذ نسخة من قائمتي مع كل استدعاء للدالة. لذا فهي ليست بهذه الكفاءة تمامًا.
لننظر الآن في تطبيق مختلف قليلًا. سيستخدم هذا التطبيق بالتحديد أعدادًا صحيحة لتتبع نقاط النهاية (Endpoints). بدلًا من نسخ قائمتي، دعوني أتابع فقط عددًا لنقطة نهايتي الدنيا، وعددًا لنقطة نهايتي العليا. سيكون تحليل التعقيد للبحث بالتنصيف نفسه تمامًا، لأنه حتى مع أنني أتابع فقط نقاط النهاية العليا والدنيا هذه، فما زلت أقسم القائمة إلى نصفين مع كل استدعاء.
صفحة المصدر 19
لكنني أفعل ذلك بتتبع فهارس صحيحة (Integer Indices). لذا حجم المسألة ما زال يُخفض بمعامل اثنين في كل خطوة. أنا أتابع هذه الفهارس الصحيحة. لا أنسخ القائمة عند هذه النقطة. إنما أغير فقط قيمة عدد صحيح من 10 إلى 5، أو أيًّا كانت القيمة. إذًا تحليل التعقيد للبحث بالتنصيف هو Θ(log n).
تبدو الشيفرة أقل ترتيبًا قليلًا، لكنها إجمالًا ما زالت تفعل النوع نفسه من الأشياء. إنها أقل ترتيبًا لأنني أريد الآن أن يبحث البحث بالتنصيف عن عنصر e في القائمة L، لكنني أود أن يتتبع استدعائي العودي نقطتي نهاية، هذين العددين الصحيحين low والعدد الصحيح high، وهما الشيء الذي أريد البحث في قائمتي بين حدّيه.
سأنشئ إذًا دالة أخرى أبدأها هنا في الأسفل، تبحث عن عنصر في القائمة L. لكنني سأتابع أيضًا نقطتي النهاية low وhigh بوصفهما معاملات لدالة البحث بالتنصيف لدي. لذا bisection_search_helper هنا ستأخذ الآن هذه المعاملات الأربعة. بقية الشيفرة مجرد تفاصيل. لكن المهم هو أن كل شيء ثابت عدا استدعائي البحث بالتنصيف لدي.
هنا أغير low لدي — عفوًا، أغير high لدي إذا أردت النظر في النصف الأدنى من القائمة. وهنا أغير low لدي إذا أردت النظر في النصف الأعلى من القائمة. لذا استدعاءات البحث بالتنصيف ستظل Θ(log n)، لكن ما الكلفة الإضافية الآن؟ الكلفة الإضافية لا شيء، صحيح؟ إنها ثابتة. هذه L هي نفسها. لا أنشئ نسخة منها. إنما أمررها فقط. e مجرد عدد. وlow مجرد عدد. وmid - 1 مجرد عملية ثابتة. لا يُنسخ هنا أي شيء.
إذًا التعقيد الكلي لهذه الشيفرة، مع أنها تبدو أقل ترتيبًا قليلًا، هو فقط Θ(log n)، لأن الكلفة الإضافية ثابتة في كل واحد من استدعاءات الدوال هذه.
وهذا يقودنا إلى هذا السؤال الأخير. من الواضح أن البحث بالتنصيف في قائمة مرتبة أسرع، فهو Θ(log n)، من البحث بالقوة الغاشمة الخالصة في قائمة يمكن أن تكون مرتبة أو غير مرتبة. لذا السؤال هو: متى يكون من المنطقي ترتيب القائمة أولًا؟ إذا أُعطينا قائمة غير مرتبة، فمتى ترتبون القائمة وتستخدمون هذا البحث الثنائي السريع، بدلًا من استخدام بحث خطي مباشر فقط؟
يكون ذلك عندما يكون الوقت الذي يستغرقه الترتيب (Sort)، الترتيب الأولي، زائد التعقيد اللازم لإجراء البحث الثنائي، أقل من إجراء البحث الخطي المباشر، لأن القائمة لا بد أن تكون مرتبة لكي ينجح هذا. متى يكون ذلك صحيحًا؟ هذا يعني أن الوقت الذي يستغرقه الترتيب أقل من Θ(n).
فماذا يعني ذلك؟ هل يمكنكم ترتيب قائمة دون حتى النظر إلى كل العناصر مرة واحدة؟ لا، صحيح؟ عليكم النظر إلى كل العناصر مرة واحدة حتى لتقولوا: هذه القائمة مرتبة بالفعل. لذلك هذا في الحقيقة ليس صحيحًا أبدًا. فما معنى ذلك؟ هل يعني أننا لا نريد أبدًا إجراء بحث ثنائي في قائمة إلا إذا كانت مرتبة بالفعل؟ نوعًا ما.
لكن في الحقيقة هناك مواقف مختلفة يكون فيها من المنطقي إجراء الترتيب أولًا، ثم استخدام البحث الثنائي. وهي الحالة التي تُعطون فيها مجموعة بيانات (Data Set)، وتريدون إجراء مجموعة كبيرة من عمليات البحث في مجموعة البيانات تلك.
فإذا أمكنكم أخذ ذلك الترتيب، وتنفيذه مرة واحدة، ثم توزيع كلفة (Amortize the Cost) ذلك الترتيب على K عمليات بحث مختلفة، فسيكون من المنطقي دفع ثمن إجراء الترتيب مرة واحدة، ثم تنفيذه عبر — ثم إجراء البحث الثنائي عبر كل عمليات البحث هذه — نعم، كل عمليات البحث هذه. وهكذا مع ازدياد K كثيرًا، يصبح الوقت الذي يستغرقه إجراء الترتيب غير ذي أهمية.
صفحة المصدر 20
تصبح ثيتا لتنفيذ هذا الشيء في اليسار مجرد ثيتا لإجراء البحث لوغاريتميًّا، مقارنة بإجراء البحث خطيًّا. حسنًا، إذا كنتم تجرون البحث مرة واحدة فقط، فأرجو ألا ترتبوا قائمتكم ثم تجروا بحثًا ثنائيًّا. سيستغرق ذلك وقتًا أطول من مجرد النظر إلى العناصر في قائمتكم مباشرة، باستخدام القوة الغاشمة. لكن إذا كنتم ستجرون مجموعة كبيرة من عمليات البحث، فمن المنطقي إجراء الترتيب ثم إجراء البحث.
حسنًا. حسنًا، هذا كل ما لدي. في المحاضرة القادمة سننظر في مجموعة من خوارزميات الترتيب المختلفة، وسيكون لدينا اختبار قصير.