النص الكامل للمحاضرة 16: العودية على القيم غير العددية
النص الكامل للمحاضرة 16: العودية على القيم غير العددية
المصدر الأصلي: النص الرسمي الإنجليزي، PDF، صفحة المحاضرة، صفحة الفيديو الرسمي.
النسبة والترخيص: آنا بيل (Ana Bell)، مقرر 6.100L، خريف 2022، معهد ماساتشوستس للتكنولوجيا، MIT OpenCourseWare. هذه ترجمة وتكييف عربي غير رسمي، لا تعتمده MIT ولا يعني نشره تأييدها له. الاستخدام غير تجاري، وتُنشر الترجمة والمواد الأصلية المشمولة وفق رخصة CC BY-NC-SA 4.0، مع وجوب المشاركة بالمثل. لا تشمل الرخصة الافتراضية مواد الأطراف الثالثة المستثناة.
منهج الترجمة: تُترجم فقرات النص المستخرج من PDF الرسمي بالترتيب، لا ملخصات الشرائح، مع حفظ التكرار والتردد والتصحيحات الشفهية ومداخلات الحضور والإشارات الصوتية. تتبع عناوين «صفحة المصدر» الصفحات غير الفارغة المفصولة بمحرف فصل الصفحات في الاستخراج. تُحفظ أسماء الكود والتعبيرات، وتُفصل توضيحات المترجم عن كلام المصدر، بما في ذلك التنبيه إلى زلات التفريغ دون تصحيحها صامتًا.
صفحة المصدر 1
MITOCW | المحاضرة 16 من 6.100L، النسخة المتعددة، الإصدار 2.mp4
[صرير]
[حفيف]
[نقرات]
آنا بيل (ANA BELL):
لنبدأ جميعًا. في المحاضرة الماضية بدأنا الحديث عن موضوع العودية (Recursion). وآمل أنها رسّخت بضعة أفكار أساسية حقًّا عن العودية، سنستخدمها في محاضرة اليوم. في محاضرة اليوم، في نصفها الأول تقريبًا، سنتحدث عن العودية لمراجعتها على بعض الأمثلة العددية الفعلية. لكن النصف الثاني، وهو الحدث الرئيسي اليوم، سيكون العودية على القيم غير العددية (Non-Numerics)، وتحديدًا العودية على القوائم (Lists). لكن التقنيات التي سنراها على القوائم يمكن تطبيقها أيضًا على أشياء أخرى غير عددية، مثل الصفوف (Tuples)، أو السلاسل النصية (Strings)، أو ما شابه.
لنبدأ المراجعة، قليلًا من مراجعة ما تحدثنا عنه المرة الماضية وبعض الأفكار الكبرى، بالنظر إلى هذا المثال. سنكتب دالة عودية لمتتالية فيبوناتشي (Fibonacci Sequence). ومتتالية فيبوناتشي موجودة في الطبيعة في مواضع كثيرة. من المواضع المحددة أنه يمكنك نمذجة تزاوج الأرانب باستخدام متتالية فيبوناتشي. لكننا لن ندرس ذلك بعمق اليوم. سننظر إلى المتتالية نفسها فقط.
وللتذكير فقط، الفكرة وراء فيبوناتشي هي أننا نبدأ بقيمتين أساسيتين نوعًا ما: فيبوناتشي لـ1 هو 1، وفيبوناتشي لـ2 هو 1. في الجدول عندي هنا، لدي هاتان القيمتان الابتدائيتان. ويمكننا ملء بقية الجدول بأن نقول أساسًا إن فيبوناتشي لـn هو فيبوناتشي لـn ناقص 1 زائد فيبوناتشي لـn ناقص 2. سيكون فيبوناتشي لـ3 هو 1 زائد 1. وفيبوناتشي لـ4 سيكون 2 زائد 1. وفيبوناتشي لـ5 هو 3 زائد 2. وفيبوناتشي لـ6 هو 5 زائد 3. وفيبوناتشي لـ7 هو 8 زائد 5. صحيح. هذه هي المتتالية التي نعرفها جميعًا ونحبّها.
حسنًا. حالتا الأساس (Base Cases) عندنا، إذا وضعنا هذا بصيغة رياضية، هما: فيبوناتشي لـ1 هو 1، وفيبوناتشي لـ2 هو 1. وخطوتنا العودية (Recursive Step)، بلغة الرياضيات و/أو البرمجة، ستكون أن فيبوناتشي لـn يساوي فيبوناتشي لـn ناقص 1 زائد فيبوناتشي لـn ناقص 2. نضع ذلك في دالتنا. نحيط ذلك الكود بتعريف، ونحوّله إلى دالة لطيفة يمكننا تشغيلها.
إذا كان x هو 1 أو كان x هو 2 — هاتان حالتا الأساس عندنا — نُرجع واحدًا مباشرة. صحيح. لا شيء يُستدعى. لا دوال تُستدعى. إنهما حالتا الأساس عندنا. لكن فيما عدا ذلك، سنُرجع قيمة. والشيء الذي سنُرجعه هو استدعاء فيبوناتشي لـn ناقص 1 زائد فيبوناتشي لـn ناقص 2، تمامًا كما قال التعريف الرياضي أن نفعل.
حسنًا. هذا مختلف عما رأيناه في المحاضرة الماضية. في المحاضرة الماضية، في الخطوة العودية، كان لدينا أساسًا استدعاء دالة واحد لأنفسنا. فأيًّا كانت الدالة التي عرّفناها هنا في الأعلى، لم يكن لدينا إلا إرجاع نسخة ما من تلك الدالة هنا في الأسفل، مع شيء آخر ملحق بها، مثل إضافة قيمة ما أو شيء آخر. في هذه الحالة، لدينا في الواقع استدعاء للدالة مرتين. حسنًا. سنرى تبعات ذلك ونحن نتتبّع الكود. وأثناء تتبّعي الكود، سأذكّركم ببعض الأفكار الكبرى التي تعلّمناها في المحاضرة الماضية.
صفحة المصدر 2
لنفترض أننا أردنا حساب فيبوناتشي لـ6. سأوضّح استدعاء الدالة (Function Call) بمجرد اسم الدالة مع المعامل (Parameter) الذي أستدعيها به. إحدى الأفكار الكبرى من المحاضرة الماضية هي أنك عندما تستدعي دالة عودية، ستتتبّع استدعاء الدالة وبيئتها (Environment) كما تفعل عادة تمامًا.
لكن بمجرد أن ترى استدعاء دالة آخر، ففي هذه الحالة فيبوناتشي لـ6 لا يدخل حالتي الأساس. إنه يدخل الخطوة العودية. ويقول: سأحسب فيبوناتشي لـx ناقص 1 زائد فيبوناتشي لـx ناقص 2. بالنسبة إلى استدعاء فيبوناتشي لـ6 هذا، لنتابع ونقول: حسنًا، فيبوناتشي لـ6، سنقول إنني أريد حساب فيبوناتشي لـ5.
وهذا سؤالي لكم: هل سيحسب الآن فيبوناتشي لـ4؟ لا. جيد جدًّا، لأن فيبوناتشي لـ5 استدعاء دالة. صحيح. نحتاج إلى استكشاف ما ستُرجعه هذه الدالة قبل أن يستطيع فيبوناتشي لـ6 إضافة نتيجة هذه، قيمة إرجاع هذه، إلى فيبوناتشي لـ4. وهذا يعني أن Fib(5) الجديد هذا بيئة جديدة تمامًا، تستدعي فيبوناتشي مع n يساوي 5، منفصلة تمامًا عن استدعاء فيبوناتشي لـ6 الأصلي عندنا.
لنستكشف ما الذي سيفعله فيبوناتشي لـ5. حسنًا، في استدعائه، سيدخل الخطوة العودية مرة أخرى. سيعرف فيبوناتشي لـ4. ثم سيتوقف عند ذلك، لأنه يحتاج إلى معرفة فيبوناتشي لـ4 قبل أن يُكمل نصفه الآخر، ليحسب فيبوناتشي لـ3. سينشئ فيبوناتشي لـ4 الآن بيئة جديدة. وعليه الآن استكشاف قيمة إرجاعه.
فيجد أن فيبوناتشي لـ4 يدخل، مرة أخرى، الخطوة العودية لحساب فيبوناتشي لـ3 زائد شيء. لكننا لا نعرف ما ذلك الشيء بعد، لأن علينا استكشاف فيبوناتشي لـ3. وصلنا بالفعل إلى عمق أربعة استدعاءات دوال، ولم نقم فعلًا بأي عمل يمكننا رؤية نتيجته. لا توجد قيم تعود إلى أعلى. كل ما نفعله هو استكشاف هذا المسار نزولًا، حتى نصل إلى نوع من حالات الأساس التي ستطلق خطوة الحل (Conquer Step)، حيث نمرّر القيم إلى أعلى السلسلة. فيبوناتشي لـ3، مرة أخرى، سينظر إلى فيبوناتشي لـ2.
وأخيرًا، وصلنا إلى حالة أساس. سيُرجع فيبوناتشي لـ2 فورًا. لا يجري استدعاء دالة آخر. سيُرجع فيبوناتشي لـ2 قيمة، ثم يكون فيبوناتشي لـ3، في استدعائه، قد حصل على نتيجة فيبوناتشي لـ2، ثم سيجري الجمع: تلك القيمة زائد فيبوناتشي لـ1، أي 3 ناقص 2. هذا هو هذا هنا. يستطيع إجراء ذلك الجمع بسهولة وإرجاع القيمة إلى أعلى، إلى فيبوناتشي لـ3. والآن أصبح النصف الأول من فيبوناتشي لـ3 جاهزًا.
إذًا فيبوناتشي لـ4 — آسفة. فيبوناتشي لـ4 أصبح نصفه الأول جاهزًا، فيبوناتشي لـ3. كان فيبوناتشي لـ4 يحاول معرفة Fib(3)، وقد عرفه. كان Fib(2) زائد Fib(1)، أي 2. لديه الآن قيمة لنصفه الأول هنا. ويحتاج إلى إضافة تلك القيمة إلى فيبوناتشي لـ2، أي 4 ناقص 2. لذلك سيستكشف ذلك المسار. هذه حالة أساس. فكل ما تفعله هو إرجاع القيمة فورًا، والآن أصبح لدى فيبوناتشي لـ4 قيمته: أيًّا كانت قيمة Fib(3) التي عرفناها، زائد Fib(2).
الآن Fib(4)، لدينا قيمة له عندما استدعينا Fib(5). أصبح Fib(5) الآن سعيدًا نصف سعادة، لأنه يعرف Fib(4). لكنه يحتاج إلى إضافته إلى Fib(3). لذلك يظل Fib(5) متوقفًا. لا يستطيع إرجاع شيء، لأنه يحتاج الآن إلى استكشاف Fib(3). حسنًا، سيجري Fib(3) استدعاء دالة آخر. صحيح؟ سيستدعي Fib(2) وFib(1)، وهما حالتا أساس تُرجعان القيمة بسهولة إلى أعلى، إلى Fib(3).
صفحة المصدر 3
والآن أصبح Fib(5) سعيدًا، لأنه يعرف هذه القيمة، والآن يعرف هذه القيمة. يستطيع جمعهما. وأصبح لدى Fib(5) الآن قيمة يستطيع تتبّعها. وأخيرًا، الآن، Fib(6) — لسنا حتى قريبين من الانتهاء، يا جماعة. لدى Fib(6) قيمة Fib(5)، لذلك لديه نصف الأشياء التي يحتاج إليها لمعرفة Fib(6)، لأنه يحتاج الآن إلى معرفة Fib(4).
وبالفعل يمكنكم معرفة ما سنفعله تاليًا. سنبدأ الاستكشاف بالطريقة نفسها تمامًا كما فعلنا من قبل. يحتاج Fib(4) إلى حساب Fib(3). لا يستطيع حساب Fib(2) بعد، لأن Fib(3) يحتاج إلى حساب Fib(2) وFib(1)، ثم تمرير القيمة صعودًا. يستطيع Fib(4) الآن إنهاء مهمته بحساب Fib(3) وFib(2)، ويمرّر القيمة صعودًا، والآن، أخيرًا، لدى Fib(6) نصفاه هنا، Fib(5) وFib(4). ويمكنه جمعهما وإرجاع القيمة.
حسنًا. هذه خوارزمية غير كفؤة إطلاقًا، لأن أشياء كثيرة تحدث، لكن لا يُنجَز كثير من العمل حتى النهاية. لدينا مجموعة من حالات الأساس نصل إليها. وبعد ذلك يمكننا البدء في إعادة بناء النتيجة. والسبب الذي يجعلني أقول إنها غير كفؤة هو أننا، حسنًا، نستكشف هذه المسارات. وبينما نمضي، نعرف ما قيمة Fib(3) وما قيمة Fib(4). صحيح؟
لكن عندما نستكشف النصف الأيمن من Fib(6) هنا، نعيد بالفعل حساب هذه القيم كلها من جديد. لهذا قلت إننا لم ننجز حتى نصف العمل، لأننا عندما حصلنا على Fib(5)، كان علينا استكشاف Fib(4). وFib(4)، هذا الفرع هنا في الأسفل، هو أساسًا نسخة من هذا هنا في الأسفل. حسنًا. هناك كثير من العمل يُنجَز هنا حيث تفعل الشيء نفسه مرارًا وتكرارًا.
وهذا يقودني إلى القول: حسنًا، ماذا لو لم نضطر إلى إعادة كل هذا العمل من جديد؟ لو كانت هناك بنية بيانات (Data Structure) من نوع ما يمكننا استخدامها لتتبّع الأشياء أثناء حسابها. صحيح؟ صحيح؟ لربط شيء بآخر أساسًا. فإذا كنّا قد حسبنا بالفعل أن Fib(4) يساوي قيمة ما، لماذا لا نبحث عنها ببساطة؟ في أي وقت نستخدم أشياء مثل التتبّع والبحث عن القيم (Lookup)، ينبغي أن يدق ذلك جرسًا صغيرًا يقول إن القواميس (Dictionaries) تستطيع مساعدتنا على ذلك. وما يمكننا فعله هو كتابة دالة فيبوناتشي عودية أكثر كفاءة، تظل عودية لكنها تستخدم القواميس لتتبّع القيم أثناء حسابها.
حسنًا. هذه هي دالة فيبوناتشي الكفؤة. اسمها عندي fib_efficient. لاحظوا أننا ما زلنا نحسب فيبوناتشي لعدد ما n، لكننا سنمرّر معاملًا آخر، قاموسًا. سيتتبّع هذا القاموس قيم فيبوناتشي أثناء حسابها. سيكون المفتاح (Key) هو n، والقيمة (Value) هي فيبوناتشي لذلك الـn. وهنا في الأسفل ترون أننا سنهيّئ قاموسًا فيه فيبوناتشي لـ1 مرتبط بـ1، وفيبوناتشي لـ2 مرتبط بـ1. هاتان حالتا الأساس عندنا.
لننظر إلى دالة فيبوناتشي العودية الآن التي تستخدم القواميس. لم نعد نحتاج إلى التفكير في حالتي الأساس على أن فيبوناتشي لـ1 هو هذا، وفيبوناتشي لـ2 هو هذا. الآن كل ما نحتاج إلى فعله أن نقول: حسنًا، لنبحث عن القيمة في قاموسنا. هذه ستكون حالة الأساس عندنا.
ولا نحتاج إلى استدعاء أنفسنا إذا كان العنصر موجودًا بالفعل في القاموس. لذلك يمكننا إرجاع القيمة المرتبطة بـn في القاموس d إذا كان ذلك الـn موجودًا بالفعل في القاموس. حالتا الأساس عندنا هنا في الأسفل ستكونان في البداية داخل قاموسنا. وبينما نعرف قيم فيبوناتشي، سنضيفها إلى قاموسنا.
وهذا بالضبط ما ستفعله الخطوة العودية. إذًا else: القيمة ليست في قاموسنا. ولذلك، للأسف، علينا حسابها، وهذا لا بأس به. سنفعل ذلك أساسًا في المرة الأولى، خلال ذلك الاستكشاف للنصف الأيسر من مسارنا نوعًا ما. لكن تلك تقريبًا هي المرات الوحيدة التي سنحسبها فيها. في كل المرات الأخرى سنبحث عنها فقط.
صفحة المصدر 4
سيكون هذا مختلفًا قليلًا عما رأيناه من قبل، لأنني لا أُرجع مباشرة فيبوناتشي لـn ناقص 1 زائد فيبوناتشي لـn ناقص 2. ما زلت في الواقع أنفّذ الخطوة العودية نفسها، فيبوناتشي لـn ناقص 1 زائد فيبوناتشي لـn ناقص 2، لكنني أحفظها في متغير. وفعل ذلك مقبول تمامًا.
ثم قبل أن أُرجع هذه القيمة فعلًا، دعوني أُضفها إلى قاموسي. هذا ببساطة يقول إن هذا القاموس عند هذا الـn المحدد، لهذه الدالة المحددة، يساوي هذا الشيء الذي حسبته للتو؛ مجرد إضافة مباشرة إلى قاموس، إضافة هذا العنصر إلى القاموس. وبعد إضافته إلى قاموسي، أستطيع إرجاع الإجابة — أو إرجاع تلك القيمة. ما زلنا نمرّرها إلى أعلى سلسلة استدعاءات الدوال، لكننا سنحفظها أولًا. هل هذا الكود واضح للجميع؟ حسنًا.
ثم هذا هو القاموس الذي ذكرته حيث نهيّئ حالتي الأساس عندنا. وبعد ذلك يمكننا طباعة الدالة. فلنتتبّع الكود لنرى ما يحدث بالضبط مع استدعاءات الدوال هذه الآن.
نهيّئ قاموسنا، حيث لدينا n1، فيبوناتشي لـ1 هو 1، وn2، فيبوناتشي لـ2 هو 1، حالتا الأساس عندنا. فيبوناتشي لـ6 مرة أخرى. نجري استدعاءات الدوال نفسها، وهذا يعني أنه لا يوجد شيء مخزّن لفيبوناتشي لـ5، لذلك لا يزال علينا استكشاف قيمته. لا شيء مخزّن لفيبوناتشي لـ4. ما زلنا نستكشف. لا شيء مخزّن لفيبوناتشي لـ3. ما زلنا نستكشف. وصلنا إلى حالة أساس.
أول شيء نفعله الآن هو التحقق مما إذا كان في القاموس. إنه فيه، فنُرجع 1 مباشرة. نتحقق مما إذا كان النصف الآخر في القاموس. نُرجع 1 مباشرة. والآن لدينا قيمة لفيبوناتشي لـ3. قبل إرجاعها، لنخزّنها في قاموسنا. حسبت للتو ما فيبوناتشي لـ3. فلنضعه فيه. المفتاح هو 3، وفيبوناتشي لـ3 هو 2. حتى الآن كل شيء جيد. الأمر يشبه كثيرًا ما فعلناه من قبل، إلا أننا نخزّن هذه القيمة في القاموس.
الآن نستكشف النصف الأيمن من فيبوناتشي لـ4 هذا، فيبوناتشي لـ3 زائد فيبوناتشي لـ2. إنه موجود بالفعل في القاموس، لذلك يُرجع هذا الجمع فورًا. نعرف الآن ما فيبوناتشي لـ4، فنُضيفه إلى قاموسنا. فيبوناتشي لـ4 هو 3. نستكشف الجزء الأيمن من فيبوناتشي لـ5، أي فيبوناتشي لـ4 زائد فيبوناتشي لـ3. هل ننزل أبعد الآن؟ في الحالة السابقة استكشفنا 2 و1. في هذه الحالة، هل نواصل الاستكشاف؟ لا، بالضبط، لأن حالة الأساس عندنا تقول: إذا كان 3 في القاموس بالفعل، فأرجِع ببساطة القيمة المرتبطة به. نعم، ها هو هناك مباشرة. أضفناه منذ قليل. نُرجع 2 فورًا فقط. لا حاجة إلى النزول في هذا المسار.
فيُنجَز فيبوناتشي لـ5 بسرعة كبيرة الآن. أي النصف الأيمن. وهذا يعني أن لدينا قيمة فيبوناتشي لـ5 ونُضيفها إلى قاموس. نستكشف النصف الأيمن من فيبوناتشي لـ6. تذكّروا أنني قلت سابقًا إننا لم ننتهِ. لم نعد بحاجة إلى استكشاف فيبوناتشي لـ4 هذا، لأننا أضفناه إلى قاموسنا منذ وقت طويل. الآن كل ما نحتاج إلى فعله هو البحث عن القيمة المرتبطة بـ4 في قاموسنا.
فها هي. وبعد ذلك يمكننا جمع فيبوناتشي لـ5 وفيبوناتشي لـ4، والحصول على قيمة فيبوناتشي لـ6، وتخزينها في القاموس، وفي هذه الحالة تكون هذه النهاية. لا نحتاج إلى فعل أي شيء آخر بهذه القيمة، كتمريرها عائدة أو شيء من هذا القبيل.
إذًا نحن لا نعيد حساب أي شيء آخر. نتحقق من القاموس فقط، وإذا لزم الأمر نحسبها. هذا تحسّن. لكن ما مقدار هذا التحسّن فعلًا؟ إذا شغّلنا هذه الدالة — وهي موجودة في كود Python؛ يمكنكم تجربتها بأنفسكم. إذا شغّلتم الدالة التي كتبناها أصلًا، التي لا نخزّن فيها أي شيء في قاموس، وإذا حاولنا حساب فيبوناتشي لـ34، فسينتج عن ذلك 11.5 مليون استدعاء دالة. هذا عدد كبير من استدعاءات الدوال، لأن حتى فيبوناتشي لـ6 كان فيه استدعاء فيبوناتشي لـ3 مرتين، وفيبوناتشي لـ4 يُستدعى — فيبوناتشي لـ3 يُستدعى ثلاث مرات، وفيبوناتشي لـ4 يُستدعى مرتين، وما شابه. فهل تتخيلون كم مرة سيُستدعى فيبوناتشي لـ3 عندما نحاول حساب فيبوناتشي لـ34؟ على الأرجح آلاف المرات، إن لم يكن أكثر.
صفحة المصدر 5
إجمالًا، عدد استدعاءات الدوال التي نجريها هو 11.5 مليون بالكود الأصلي. لكن النسخة الكفؤة لا تجري إلا 65. ليس الأمر أننا انتقلنا من 11.5 مليون إلى مليونين. انتقلنا من رتبة الملايين إلى العشرات، وهذا مدهش حقًّا، حقًّا، من حيث السرعة. إذا حاولتم تشغيل هذا البرنامج، فسيستغرق بضع ثوانٍ لفيبوناتشي لـ34، لكن النسخة الكفؤة ستكون فورية.
ولكل استدعاءات الدوال هذه كلفة إضافية (Overhead). تحتاج إلى إنشاء بيئة في Python. ويحتاج إلى تمرير هذه المعاملات. لذلك تستغرق كل هذه الاستدعاءات وقتًا طويلًا، في حين يكون البحث في القاموس فوريًّا أساسًا. في هذه الحالة بالتحديد، تخلّينا عن جزء من ذاكرتنا لتخزين القيم.
القاموس يخزّن 34 مدخلًا (Entries)، وهذا ليس كثيرًا، لكن هناك تطبيقات لا يمكنك فيها الاستغناء عن مساحة 34 مدخلًا في ذاكرتك، وفي تلك الحالة قد تخصّص بعض الوقت لمواصلة الحساب دون شغل جزء من الذاكرة. فهناك شيء من المفاضلة (Trade-off) بين هذين البرنامجين. أحدهما لا يخزّن شيئًا، لكنه بطيء. والآخر يخزّن أشياء، لكنه سريع.
لننظر إلى مثال آخر نجري فيه فيبوناتشي على القيم العددية (Numerics). ولا أعرف متى ستستخدمون فيبوناتشي في عالمكم الحقيقي — في حياتكم الواقعية، لكن معرفة كل الطرق الممكنة لتسجيل مجموع x من النقاط في كرة السلة مفيدة أكثر قليلًا. لنفكّر في هذه المسألة عوديًّا. بالتأكيد يمكننا فعلها تكراريًّا، واستخدام القوة الغاشمة (Brute Force) لاستعراض جميع التركيبات الممكنة للنقاط.
في كرة السلة، يمكنك تسجيل سلة تساوي نقطة واحدة، أو نقطتين، أو ثلاث نقاط. لذلك تستطيع التفكير في جميع التركيبات الممكنة التي يمكنك تسجيلها لتعطيك مجموع نقاط x. سنفكّر في هذه المسألة عوديًّا. فلنبدأ بحالات الأساس.
حالات الأساس — لدينا ثلاث منها. إذا فكّرنا في مجموع نقاط 1، أي إذا كان x مساويًا لـ1، فهذا يعني أنه إذا كان لدينا مجموع نقطة واحدة في كرة السلة، فما كل الطرق الممكنة التي حققنا بها 1؟ حسنًا، يمكنك تسجيل نقطة واحدة فقط، وهذا كل شيء. كتبت 1 زائد 0 فقط للتأكيد على أننا نسجّل 1 ولا شيء آخر.
إذا سجّلنا سلة تساوي نقطتين — أو إذا كانت لدينا نقطتان في كرة السلة، فما جميع الطرق الممكنة لتحقيق 2؟ حسنًا، قد نكون سجّلنا 1 و1، أو سجّلنا 2 مباشرة. هاتان إذًا طريقتان ممكنتان لتحقيق مجموع نقاط 2. وبالمثل، لتحقيق مجموع 3، ما كل الطرق الممكنة؟ حسنًا، قد نكون سجّلنا 1 ثم 1 ثم 1، أو سجّلنا 2 و1، أو قد نكون سجّلنا الجذر التربيعي لـ3 مباشرة. هذه إذًا ثلاث طرق مختلفة يمكنكم بها تحقيق مجموع 3 في كرة السلة.
ملاحظة المترجم: يورد التفريغ الرسمي هنا عبارة «square root of 3»؛ نُقلت بوصفها «الجذر التربيعي لـ3» دون تصحيح صامت، رغم أن السياق يتحدث عن تسجيل ثلاث نقاط مباشرة. كذلك لا تذكر قائمة الطرق المنطوقة الترتيب
1 + 2؛ نُقلت القائمة كما هي، لا بوصفها تعدادًا رياضيًّا مصححًا لكل ترتيبات التسجيل.
هل الجميع معي حتى الآن؟ هذه حالات الأساس عندنا. لأن الخطوة العودية ستكون — ستذهلكم. إنها بسيطة جدًّا. تبدو الخطوة العودية هكذا. الآن، ليعطني أحدكم — ما مجموع نقاط معقول لفريق في كرة السلة؟ 87؟ حسنًا. مرّ على الأرجح 25 عامًا منذ أن لعبت كرة السلة الاحترافية في الصف الخامس، يا جماعة، ولذلك نسيت ما المجموع المعقول.
إذًا 87. لنقل إننا الآن لا نتعامل مع حالة الأساس. بل نتعامل مع عدد أكبر من إحدى حالات الأساس هذه. كيف نفكّر في هذه المسألة عوديًّا؟ حسنًا، هناك ثلاثة احتمالات. إذا كان مجموع نقاطي النهائي 87، فلنفترض أنني أفكّر في مجموع 86. إذا كنت أعرف جميع الطرق الممكنة لتحقيق مجموع 86، فكل ما أحتاج إلى فعله هو إضافة 1 إلى ذلك المجموع. سيعطيني 87.
صفحة المصدر 6
هذا احتمال واحد هنا. لكنه ليس الاحتمال الوحيد، لأنني قد أحصل على مجموع 85، وإذا أضفت 2 إلى ذلك الـ85 — ليس عدّتين، بل النقاط فقط. إذا كان مجموع نقاطي الأصلي 85، وأضفت 2 إلى ذلك المجموع فقط، فسيعطيني مجموعي المطلوب، 87. فإذا عرفت التركيبات الممكنة لتحقيق 85، فأنا أعرف أن كل ما أحتاج إلى فعله هو إضافة 2 إلى مجموعي، وسيعطيني ذلك 87.
ثم الاحتمال الأخير هو معرفة جميع الطرق الممكنة لتحقيق 84 — مجموع نقاط 84 — لأنني عندئذ سأخذ ذلك المجموع وأضيف إليه 3 ليعطيني 87. أستخدم إذًا حالات الأساس لتوجيه خطوتي العودية. عدد الطرق التي أستطيع بها تحقيق مجموع 87 هو مجموع جميع الطرق الممكنة لتحقيق 86 أو 85 أو 84، لأنني إذا حققت 86 فسأضيف 1 إليه فقط، وإذا حققت 85 أضيف 2 إليه، وإذا حققت 84 أضيف 3 إلى المجموع.
وهذا أساسًا ما تفعله هذه الخطوة العودية. لدي، هذه هي كل الطرق الممكنة لتحقيق مجموع 80 — x ناقص 1. أي 87، 86. وهذا مجرد استدعاء دالتي. أي score_count مع x ناقص 1، وscore_count مع x. زائد جميع الطرق الممكنة لتحقيق مجموع x ناقص 2، زائد جميع الطرق الممكنة لتحقيق مجموع x ناقص 3. فإذا جمعت هذه الطرق الثلاث كلها، أحصل على جميع الطرق الممكنة لتحقيق مجموع x. هل هذا واضح؟ حسنًا.
هذا كل شيء. إنه كود نظيف إلى حدٍّ ما. يبدو لطيفًا حقًّا. ولو كتبناه تكراريًّا، فسيكون فوضويًّا، لأن لدينا على الأرجح مجموعة كاملة من الحلقات المتداخلة (Nested Loops)، لنحاول بالقوة الغاشمة استعراض كل التركيبات الممكنة للنقاط التي يمكننا تسجيلها، ولن يبدو جميلًا أو موافقًا لأسلوب Python (Pythonic).
فلنتتبّع هذا الكود لنجمع الفكرة كلها. سيكون التتبّع (Trace) مشابهًا جدًّا لتتبّع فيبوناتشي، إلا أن لدينا الآن ثلاثة مسارات نستكشفها قبل الحصول على قيمة إرجاع (Return Value). بالنسبة إلى مجموع 6، سأستكشف: كيف يمكنني تحقيق مجموع 5؟ وبالطبع سأستكشف كيف يمكنني تحقيق مجموعي 4 و3، لكنني لم أصل إلى ذلك بعد.
أحتاج أولًا إلى استكشاف كيفية تحقيق مجموع 5، وهو استدعاء دالة. سيستكشف هذا كيفية تحقيق مجموع 4، وبالطبع 3 و2، لكن ليس الآن تمامًا. مجموع 4 سيقودنا إلى حالات الأساس. إنه فقط كيفية تحقيق مجموع 3 و2 و1. هذه حالات أساس. تُرجع فورًا، ونعرف كيف نحقق مجموع 4. مجموع 3 أيضًا حالة أساس، ومجموع 2 أيضًا حالة أساس. لذلك ستُرجع هذه فورًا لتعطينا مجموع 5.
الآن نعرف كيف نحقق مجموع 5، ونحتاج إلى متابعة كيفية تحقيق مجموع 4، وهو 3 و2 و1 فقط. عفوًا، ينبغي أن أغيّر ذلك ليصبح 1. ثم كيفية تحقيق مجموع 3، وهو حالة أساس فقط. فهناك خصائص تتبّع مشابهة جدًّا لكود فيبوناتشي. هل هناك أسئلة عن هذه الأمثلة؟ هل هي واضحة؟ هل تبدو منطقية؟ حسنًا.
هناك تمرين واحد في ملف Python، للعمل في المنزل. أود أن تحاولوا تطبيق حفظ النتائج (Memoization) على هذا الكود. حفظ النتائج يعني أساسًا محاولة استخدام مذكرة (Memo)، مثل قاموس، لتخزين القيم أثناء حسابها، لأنكم ترون أنه سيكون غير كفؤ تمامًا مثل كود فيبوناتشي. هنا نحسب مجموع 4 مرة أخرى، بعدما حسبناه هنا منذ وقت سابق. فجرّبوا إضافة قاموس إلى هذا الكود لمحاولة تسريعه.
في النصف الثاني من هذه المحاضرة، سنبتعد الآن عن العودية على الأعداد وعن هذه العمليات الرياضية اللطيفة التي يمكننا ترجمتها بسهولة إلى كود، ونبدأ النظر إلى العودية على أشياء غير عددية. سننظر إلى القوائم فقط، لكن مرة أخرى، كما قلت، يمكنكم تطبيق هذه الأكواد المشابهة جدًّا على أي تسلسلات من القيم (Sequences)، صفوف أو سلاسل نصية أو ما شابه.
صفحة المصدر 7
السبب الذي يجعلنا ننظر إلى القوائم هو أنها عودية بطبيعتها. أحد الدوافع التي قدمتها في نهاية المحاضرة الماضية هو أن لدينا قوائم يمكن أن تكون عناصرها قوائم أخرى، يمكن أن تكون عناصرها قوائم أخرى، يمكن أن تكون عناصرها قوائم أخرى. ومن دون معرفة مدى عمق هذه القوائم داخل القوائم داخل القوائم، ستكون كتابة كود تكراري صعبة حقًّا. إنها ممكنة، لكنها ستكون صعبة حقًّا.
وبدلًا من ذلك، سنرى أن النسخة العودية من هذا الكود ستكون أكثر بداهة بكثير على المدى الطويل — ربما ليس فورًا، لكنها بالتأكيد أسهل كثيرًا في الكتابة والقراءة. فلنفكّر في القوائم بطريقة عودية.
لو كنّا نفعل ذلك تكراريًّا، لقلنا إننا سنمرّ في حلقة على كل عنصر ونفعل شيئًا. المسألة التي سنحلّها في البداية هي معرفة مجموع جميع العناصر في قائمة. تكراريًّا، قلنا فقط إننا نمرّ على كل عنصر في القائمة ونحتفظ به في نتيجتنا. لدي متغيرات الحالة (State Variables) التي تحدثت عنها المرة الماضية، result وe، التي تتتبّع أي عنصر وصلنا إليه وما القيمة.
أما عوديًّا، فتذكّروا أننا سنجري جميع استدعاءات الدوال هذه حتى نصل إلى حالة أساس، وعندها سنبدأ بناء النتيجة. كيف يمكننا التفكير في هذه القائمة عوديًّا؟ لنفترض أن لدينا قائمة ونريد إيجاد مجموع جميع عناصرها. تلك مسألتنا الأصلية.
لنقل الآن إننا نأخذ العنصر الأول ونخرجه فقط. لدينا هذه القائمة مع مجموعة عناصر. لنأخذ الأول. نعرف أنه 10. ثم لننظر إلى العناصر المتبقية، أي من 20 فصاعدًا. إذا أخذت الـ10 عندي وكنت أعرف إجابة مجموع جميع العناصر من 20 فصاعدًا، فكل ما أحتاج إلى فعله لمعرفة مجموع قائمتي الأصلية، هذه هنا، هو القول إنه 10 زائد مجموع، أيًّا كان مجموع العناصر من 20 فصاعدًا.
الآن، المجموع للعناصر من 20 فصاعدًا هو المسألة نفسها مرة أخرى. إنها مسألة إيجاد مجموع جميع العناصر في قائمة. وقد صادف أن قائمتنا الآن هي قائمتنا الأصلية دون ذلك العنصر الأول فيها. هل يفهم الجميع ذلك؟ لدينا مسألتنا الأصلية، وصنعنا المسألة نفسها مرة أخرى، نسخة مختلفة قليلًا فقط: القائمة كلها باستثناء العنصر الأول.
ننفّذ الشيء نفسه الآن. لنقل إن هذه قائمتنا الجديدة. نُخرج العنصر الأول منها، ونعتبر العناصر باستثناء ذلك الأول قائمة جديدة. ومرة أخرى، لو عرفت ما مجموع 30 وصولًا إلى 60، فكل ما أحتاج إلى فعله هو إضافته إلى الـ20 التي أخرجتها، وسأعرف مجموع هذه القائمة. نواصل إذًا فعل ذلك. نأخذ قائمتنا، ونُخرج 30، ونعتبر العناصر المتبقية قائمة.
الأمر نفسه. لو عرفت ما 40 زائد 50 زائد 60، أي مجموع جميع العناصر في هذه القائمة، فأضيفه إلى 30 فقط، وتكون لدي إجابة تلك المسألة. ونواصل فعل هذا، إخراج عنصر والنظر إلى القوائم المتبقية، وصولًا إلى قائمة فيها عنصر واحد فقط.
حسنًا، هذه مسألة بسيطة إلى حدٍّ ما. إذا كانت لدي قائمة فيها عنصر واحد، فإن مجموع العناصر داخل تلك القائمة ليس إلا قيمة ذلك العنصر. إنه 60 فقط. مسألة بسيطة جدًّا. لا حاجة إلى مواصلة التقدّم، وتقسيم هذه المسألة إلى أجزاء أصغر. أعرف بالفعل إجابة هذه. إنها بسيطة جدًّا. هذه إذًا حالة الأساس عندنا. ونعرف أن مجموع عناصر قائمة طولها 1 هو ذلك العنصر.
صفحة المصدر 8
بمجرد أن نصل إلى حالة الأساس، نعيد بناء النتيجة. نأخذ 60، وكان قد أُخرج 50 أصلًا، لذلك سنمرّر المجموع صعودًا إلى من استدعاه، وهو الدالة التي أخرجت 50. الآن 50 زائد 60 هو 110. وهذا الـ110 يُمرَّر إلى أعلى السلسلة. عندما أخرجنا 40، قلنا: حسنًا، سأضيف 40 إلى مجموع 50 و60، أي 110، وهو 150.
نمرّر تلك الإجابة إلى أعلى السلسلة. عندما أخرجت 30، قلت إنني سأضيف 30 إلى مجموع الأشياء المتبقية فقط، الذي اكتشفت أنه 150. والـ20 — كنت قد أخرجتها — تصبح 20 زائد مجموع كل الآخرين، وهو 180. فيكون المجموع 200. وأخيرًا، كان سؤالي الأصلي إخراج 10 وإضافتها إلى كل ما عداها، وهو الـ200 الذي عرفناه. فالمجموع الكامل 210.
هل هذا واضح، هذا الرسم المتحرك؟ حسنًا. لدينا التقسيم نزولًا حتى حالة الأساس، ثم إعادة بناء النتيجة. فلنحاول كتابته. سنكتبه في أجزاء. اسم الدالة total_recur. تأخذ قائمة L. سنعرف عوديًّا مجموع كل العناصر في هذه القائمة.
يمكن أن تكون لدينا حالة أساس عندما تكون القائمة فارغة. نستطيع إرجاع 0. الأمر متروك لكم. حالة أساس أخرى، وهي التي وضّحتها في الشريحة السابقة، هي عندما يكون طول القائمة 1. عندما يكون طول القائمة 1، فما المجموع؟ لا حاجة إلى العودية. إنه ذلك العنصر فقط.
وفي هذه الشرائح، ما أدرجته أيضًا بالإضافة إلى الكود هو مثال صغير. يساعدكم على التفكير فيما تُرجعه الدالة. في حالة الأساس هذه، عندما يكون طول القائمة 1، ستبدو القائمة شيئًا كهذا. وكل ما عليّ فعله هو إرجاع L عند الفهرس (Index) 0، أي 50. وهذا هو مجموعي. وهذا ما أفعله هنا، إرجاع L عند الفهرس 0. جيد.
والآن الخطوة العودية. تذكّروا أنني في الخطوة العودية أخرجت العنصر الأول وقلت: دعوني أحفظ هذا العنصر الأول. ها هو محفوظ بوصفه L عند الفهرس 0. وسأضيفه إلى شيء. في هذا المثال هنا لدي هذه القائمة الأطول من 1. أُخرج 30، أي L عند الفهرس 0، وسأضيفه إلى شيء.
حسنًا، ذلك الشيء، بناءً على الشرائح، الشريحة السابقة حيث قدّمت الرسم المتحرك، سيكون وضع ثقتنا في أننا نكتب هذه الدالة كتابة صحيحة. ذلك الشيء سيكون معرفتنا ما مجموع 40 و50. إنها المسألة نفسها التي نحاول حلّها الآن — مجموع 30 و40 و50 — إلا أنني الآن سأخذ مجموع 40 و50 فقط.
فيصبح ذلك الشيء الدالة نفسها التي نكتبها الآن، total_recur، إلا أنني لا أستدعيها على L، ليس الكل من جديد. سيكون ذلك سيئًا. لكنني سأستدعيها على L من الفهرس 1 فصاعدًا، أي إزالة ذلك العنصر الأول أساسًا. هل هذا واضح للجميع؟
هذا كل شيء. هذه الدالة. لا شيء آخر نكتبه. لا حلقة. لقد كتبنا أساسًا دالة بافتراض أننا كتبنا الدالة كتابة صحيحة، وهذه طريقة غريبة جدًّا للتفكير في العودية، لكن هذا أساسًا ما هي عليه. تثق في نفسك في كتابة هذه الدالة كتابة صحيحة، بحيث تقودك الخطوة العودية إلى حالة الأساس، حتى تستطيع إعادة بناء النتيجة على نحو صحيح. فهناك كثير من الثقة في كتابة هذه الدوال عوديًّا.
لن أمرّ عبر Python Tutor، لكن ينبغي بالتأكيد أن تمرّوا به بأنفسكم للتدرب على الاختبار القصير وما شابه. فلنجعلكم تكتبون هذا إذًا. سيكون تعديلًا طفيفًا على الكود الذي كتبناه للتو. سيأخذ قائمة بوصفها معامله، وبدلًا من جمع عناصر القائمة كما فعلنا — 10 زائد 20 زائد 30، أو أيًّا كان — أود أن تجمعوا أطوال العناصر في القائمة.
صفحة المصدر 9
إذا مرّرت ذلك إلى هذه الدالة، فستجمع الطول: هذا 2، زائد طول هذا 1، زائد طول هذا 5، أي 2 زائد 1 زائد 5. سيكون تعديلًا طفيفًا جدًّا على الكود الذي نظرنا إليه للتو. وها هو عند السطر 70 تقريبًا. فكّروا في حالة الأساس. إذا كانت لديكم قائمة فيها عنصر واحد، فماذا تُرجعون؟ وإذا كانت لديكم قائمة فيها عناصر كثيرة، فكيف تضعون ثقتكم في شيء كتبتموه للتو ليساعدكم على الوصول إلى الإجابة؟
ماذا لديكم لي؟ فلنبدأ بحالة الأساس. وإذا كنتم تجدون صعوبة، أشجّعكم على كتابة، في تعليق صغير فقط، الشكل الذي تبدو عليه حالة الأساس، كما فعلت في الشرائح. تبدو هكذا. ماذا سأُرجع إذًا إذا كانت لدي قائمة فيها عنصر واحد؟ نعم.
أحد الحضور (AUDIENCE):
[غير مسموع]
آنا بيل:
نعم، بالضبط. سنُرجع طول ذلك العنصر. أي طول هذا، أيًّا كان، ab أو غيره. رائع. كيف ننفّذ الخطوة العودية؟ نعم.
أحد الحضور:
[غير مسموع]
آنا بيل:
نعم، بالضبط. total_len_recur مع أي قائمة؟
أحد الحضور:
[غير مسموع]
آنا بيل:
نعم. سنُخرج الأول. وسيعطينا هذا مجموع أطوال كل الآخرين.
أحد الحضور:
[غير مسموع]
آنا بيل:
بالضبط. نحتاج أيضًا إلى إضافته إلى — نعم، سأضيف 0. لا بأس بفعل ذلك قبل أو بعد، لأننا نجمع هاتين القيمتين فقط. فلا يهم الترتيب الذي نجمعهما به. هذا ممتاز إذًا. هل هناك أسئلة عن هذا الكود؟ نعم.
أحد الحضور:
[غير مسموع] هل هو أكثر كفاءة أم أقل من تنفيذ الطريقة المعتادة أولًا؟
آنا بيل:
من تنفيذ ماذا؟
أحد الحضور:
الطريقة المعتادة [غير مسموع].
آنا بيل:
من حيث الكفاءة (Efficiency)، ستكون هذه الدالة أقل كفاءة قليلًا، كما أرى، لأن هناك كلفة إضافية صغيرة عند إجراء استدعاء دالة بالفعل، بينما إذا استخدمت معاملًا مدمجًا (Built-in Operator)، فقد جرى تحسينه ليعمل بسرعة كبيرة. نعم.
أحد الحضور:
[غير مسموع] عند تنفيذ زائد يساوي، ألا يفعل هذا في الخلفية؟
آنا بيل:
لا. عندما ينفّذ زائد يساوي، فهو بالتأكيد لا يفعل هذا في الخلفية. بالضبط. نعم. لكن هذا مجرد — أعني، أحاول أن أُريكم العودية على شيء لا تستخدمون عادة العودية عليه، لمجرد المساعدة على توضيح فكرة العودية. بالتأكيد يمكنكم استخدام خوارزمية تكرارية (Iterative Algorithm)، بالطبع، لحساب مجموع هذه العناصر. وهي أكثر بداهة وأقرب إلى ما تعلّمناه حتى الآن. ممتاز.
الآن لننظر إلى مسألة مختلفة قليلًا. بدلًا من إيجاد مجموع جميع العناصر في قائمة، لنعالج مسألة البحث عن عنصر في قائمة. مختلفة تمامًا، لكننا ما زلنا ننفّذ نوعًا من عمليات القوائم (List Operations).
صفحة المصدر 10
سنبدأ بتنفيذ (Implementation) ليس صحيحًا تمامًا. وسنرى السبب بعد قليل. فلنتبع النمط نفسه تقريبًا الذي رأيناه في المثال السابق. لننظر إلى قائمة طولها 1. في هذه الحالة بالتحديد، إذا كانت لدي قائمة فيها عنصر واحد فقط، فكيف أعرف إن كان ذلك العنصر هو الذي أبحث عنه؟ حسنًا، سأُرجع هذه القيمة المنطقية (Boolean) فقط، أي هل L عند الفهرس 0 — ذلك العنصر — هو الذي أبحث عنه، e. لاحظوا أن in_list هذه تمرّر القائمة نفسها والعنصر الذي أبحث عنه. أظن — حسنًا.
ثم لننظر إلى الخطوة العودية. الخطوة العودية في هذه الحالة بالتحديد، لنقل إنها تقول: حسنًا، else. قد نفكّر أن نقول: إذا لم يكن هو الذي أبحث عنه، فلنبحث في بقية القائمة. فكما فعلنا في الحالة السابقة، لنطبّق الدالة نفسها التي نكتبها الآن على كل العناصر باستثناء الأول. وما زلنا نبحث عن العنصر e في تلك العناصر المتبقية.
يمكننا اختبارها. وإذا شغّلناها بالفعل — مرة أخرى، من فضلكم، أشجّعكم على استخدام Python Tutor بأنفسكم. لكن يمكننا اختبارها والقول: في هذه الحالة بالتحديد، 2 و5 و8 و1، إذا شغّلت هذا الكود فعلًا، فسيعطيني True. إذًا وجد 1 داخل القائمة 2 و5 و8 و1، وهذا جيد. إنه ما أردناه بالضبط.
لكن إذا غيّرت قائمة المدخلات قليلًا، وأصبح لدي 2 و1 و5 و8، فالعنصر الذي أبحث عنه هنا. الكود سيعطيني بالفعل False، ذلك الذي كتبته للتو، وهذا غير مقبول. أرى 1 هناك مباشرة. فما الذي يحدث بالضبط؟ يمكننا تشغيل الكود هنا. هذا هو الكود هنا. إذا رأيتم أنه يعطيكم قيمة غير صحيحة، فمن الأشياء التي يمكنكم فعلها عند التعامل مع العودية وضع تعليمة طباعة (Print Statement) داخل الدالة نفسها.
يمكننا طباعة، ربما، القائمة التي ننظر إليها حاليًّا والعنصر الذي نبحث عنه، ورؤية ما يحدث بالضبط. إذا شغّلته فسيقول: حسنًا، في المرة الأولى خلال استدعاء الدالة، أبحث عن العدد 1 في هذه القائمة. في المرة التالية أبحث عن 1 في هذه القائمة. وفي المرة التالية أبحث عن 1 في هذه القائمة. وفي المرة الأخيرة، بالنسبة إلى استدعاء دالتي، أبحث عن 1 في هذه القائمة.
وبالفعل نرى أن شيئًا سار على نحو خاطئ، لأنني أثناء البحث عبر هذه القوائم أتجاوز أساسًا عناصر مهمة. ما يفعله هذا الكود فعلًا هو التحقق فقط مما إذا كان العنصر الأخير هو الذي تبحث عنه، لأنه يتجاهل أساسًا ذلك العنصر الأول في الكود. الكود هنا، نعم، يُخرج ذلك العنصر الأول، لكنه لا يفعل شيئًا به. هذه إذًا مشكلتنا.
ما نريده هو الاستمرار في النظر إلى عناصر أبعد في القائمة. فهذا الجزء من الكود صحيح. لكننا لا نريد فعل ذلك إلا في وضع معين. وهذا الوضع هو عندما لا يكون العنصر الذي أخرجناه للتو، L عند الفهرس 0، هو الذي نبحث عنه، أي حالة else الصغيرة.
ما زلنا نريد إخراج العنصر الأول إذا كانت لدينا قائمة فيها أكثر من عنصر واحد. لكن بعد إخراجه، تحقّق مما إذا كان هو الذي نبحث عنه. إذا كان كذلك، فأرجِع True. لا حاجة إلى مواصلة البحث في بقية عناصر القائمة. وإذا لم يكن هو الذي نبحث عنه، هذه الـelse هنا، عندئذ يمكننا النظر إلى العناصر المتبقية في القائمة وتشغيل الدالة نفسها تمامًا التي نكتبها، للتحقق مما إذا كان العنصر في القائمة المتبقية.
هل هذا الكود واضح؟ هل هو جيد؟ حسنًا. الطريقة التي كتبت بها هذا الكود هي كيف أفكّر شخصيًّا في المسألة. وإذا شغّلنا الكود مرة أخرى، فسيعطيني الإجابات الصحيحة في كل مرة. لكن أردت أن أذكر أننا نستطيع في الواقع تنظيف الكود قليلًا وكتابته بصورة أكثر موافقة لأسلوب Python، ليصبح ألطف في القراءة وأكثر ترتيبًا.
صفحة المصدر 11
لكن من الأشياء التي أربكتني عندما بدأت تعلّم العودية أنني كنت دائمًا أرى هذه النسخ الجميلة والمنظّفة من الكود التي تنفّذ العودية، وهذه ليست الطريقة التي نتبعها في التفكير في المسألة. لا أستطيع التوصل إلى هذه الصيغة الجميلة مباشرة. وهذا مثال واحد، لكن هناك بالتأكيد أمثلة أخرى لكود أكثر تعقيدًا تراه، وهو فقط — يبدو جميلًا.
ونعم، إذا نظرت إليه، أستطيع فهمه. وأقول: حسنًا، نعم، هذا منطقي. لكنني شخصيًّا لم أستطع أبدًا التوصل إليه بنفسي. عندما كنت أكتب هذه المحاضرات، فكّرت: حسنًا، كيف أفكّر فعلًا في المسألة؟ عدت للتو شريحة واحدة. وطريقتي في التفكير في المسألة هي فصلها إلى هذه الأجزاء الأصغر — مجموعة من حالات الأساس المختلفة، أو مجموعة من الحالات المختلفة.
وهذا ما كنت أحاول فعله في هذه المحاضرة بالتحديد لمساعدتكم على فهم العودية. وهو أن تفكّروا في الحالة التي تكون لدينا فيها قائمة فيها عنصر واحد. كيف تحلّون هذه المسألة؟ ثم فكّروا في الحالة التي تكون لديكم فيها قائمة فيها عناصر كثيرة. كيف تحلّون تلك المسألة؟
نعم، هذا صحيح. هناك أجزاء هنا تتكرر. لدينا L عند 0 يساوي e في موضعين. لكن يمكنكم إجراء ذلك التنظيف لاحقًا. هنا لدي حالتا اختبار تُرجعان — حالتان تُرجعان L عند 0، فيمكننا دمجهما في حالة الاختبار نفسها هنا. ثم يمكننا التحقق مما إذا كان طول القائمة 0. يمكننا إضافة حالة الاختبار تلك. وفي else نتحقق من بقية القائمة.
هذا مقبول تمامًا. وإذا كان التفكير في المسألة بهذه الطريقة يساعدكم، فهذا مقبول أيضًا. لكن بالنسبة إليّ شخصيًّا، كان أسهل كثيرًا التفكير في المسألة من حيث قائمة فيها عنصر واحد، ثم قائمة فيها عناصر كثيرة. ولا بأس تمامًا بأن تضطر إلى كتابة كود «يبدو غير كفؤ»، بين علامتي اقتباس، قليلًا في البداية.
وبالتأكيد، لا تثبّتوا جميع حالات الأساس في الكود (Hardcode): إذا كان الطول 0، افعل هذا. إذا كان الطول 1، افعل هذا. إذا كان الطول 2، افعل هذا. لكن لا بأس ببعض حالات الأساس المعقولة. فهذا يعرض الكود المبسّط فقط.
شيء أردت ذكره — وآمل أنكم لاحظتموه بالفعل — هو أن الدالة التي تكتبونها، يجب أن يكون لجميع قيم الإرجاع منها النوع نفسه (Type). عندما كتبنا — سأعود بضع شرائح. عندما كتبنا الدالة التي تحسب مجموع جميع العناصر في قائمة — هذه هنا — ماذا كنّا نُرجع؟ هنا كنّا نُرجع عددًا فعليًّا، ثم هنا كنّا نفترض أن هذه الدالة تُرجع عددًا فعليًّا يمكننا إضافته إلى هذا العدد الفعلي.
كل تعليمة إرجاع (Return Statement) يجب أن تُرجع نوع الكائن (Object) نفسه، لأنك إن لم تفعل ذلك، إذا افترضت أن حالة الأساس تُرجع قائمة، لكنك عند نقطة ما في الكود ستعمل مع عدد أو قيمة منطقية، فإن Python، بمجرد وصوله إلى حالة الأساس تلك، سيقول: مهلًا، أنت تحاول إضافة قيمة منطقية إلى قائمة. ما الأمر؟
في جمع عناصر القائمة، كانت جميع حالات الاختبار تُرجع عددًا، وفي هذه الحالة التي نحاول فيها إرجاع ما إذا كان العنصر في القائمة أم لا، لاحظوا أن كل إرجاع عندي سيُرجع قيمة منطقية. هنا قيمة منطقية، وهنا قيمة منطقية، وهنا في الخطوة العودية أفترض أنني أمرّر هذه القيمة المنطقية إلى أعلى سلسلة التحكم فقط.
هذا شيء مهم جدًّا، جدًّا. مرة أخرى، هو شيء لم يكن واضحًا لي عندما بدأت العودية. لكن بمجرد أن عرفته، أصبح الأمر أوضح كثيرًا وساعدني على كتابة الكود أفضل، وبصورة أكثر إتقانًا، من البداية.
صفحة المصدر 12
لننظر الآن إلى مثال مختلف قليلًا. نظرنا إلى أخذ مجموع جميع العناصر في قائمة. ونظرنا إلى معرفة ما إذا كان عنصر في قائمة. لنفعل شيئًا مختلفًا تمامًا. ما زلنا نعمل مع القوائم، فلنفترض أن لدينا الآن قائمة مدخلات تبدو هكذا. لدينا قائمة. هذه قائمتي، بدايتها ونهايتها.
وهذه القائمة ليس داخلها إلا عناصر من نوع قائمة. لا أعداد صحيحة (Integers). لكن عناصرها قوائم. هنا عنصر قائمة. وهنا عنصر قائمة آخر. وهنا عنصر قائمة آخر. في هذا المثال لدي قائمة فيها ثلاثة عناصر من نوع قائمة. ما أود فعله هو تسطيح (Flatten) هذه القائمة، أي أنني أريد إزالة أي أثر للقوائم الفرعية (Sublists)، وأخذ جميع عناصر هذه القوائم الفرعية فقط ووضعها في المستوى الأعلى (Top Level).
هل هذه المهمة واضحة؟ حسنًا. أنا لا أفترض أن لدي قوائم داخل قوائم داخل قوائم. أفترض فقط أن لدي قوائم بعناصر من نوع قائمة، فيها أعداد صحيحة أو أي شيء. مرة أخرى، لنفكّر في حالة الأساس، لنفكّر في الحالة التي تكون لدينا فيها قائمة فيها عنصر واحد، ثم نستطيع معرفة الخطوة العودية.
إذا كانت لدي قائمة فيها عنصر واحد — مرة أخرى، لدي مثال هنا على الجانب الأيمن. إنها قائمة فيها عنصر قائمة واحد. لذلك لدي الأقواس المربعة المزدوجة. لو أردت تسطيح هذه، فماذا أستطيع فعله؟ يمكنني أخذ العنصر عند الفهرس 0 فقط، لأن العنصر عند الفهرس 0 هو هذه القائمة الداخلية، وهي نسخة مسطّحة من قائمتي.
أما else، فماذا سأفعل؟ فلنتبع النمط نفسه. يبدو أنه نجح حتى الآن معنا. لنتبع نمط إخراج ذلك العنصر الأول. أي أخذ العنصر عند الفهرس 0. هنا سنأخذ شيئًا مثل أقواس مربعة، 1، فاصلة، 2، ونصله (Concatenate) بشيء. حسنًا، تذكّروا أننا عندما نصل قائمة بقائمة أخرى، نحصل على قائمة كبيرة فيها جميع العناصر، وهو بالضبط ما نبحث عنه عندما نريد تسطيح قائمة.
الشيء الذي سنُضيف إليه L عند الفهرس 0 ليس إلا تسطيح بقية قائمتنا. مرة أخرى، النمط نفسه الذي رأيناه بالفعل. إذا أخرجت، في هذا المثال هنا، 1، فاصلة، 2، بوصفها قائمة، فسأصلها مع افتراض أن الدالة التي أكتبها ستعمل على نحو صحيح لتسطيح 3، فاصلة، 4، و9، فاصلة، 8، فاصلة، 7. فإذا سطّحت ذلك، فسيعطيني قائمة فيها 3 و4 و9 و8 و7 فقط. وإذا وصلت 1، فاصلة، 2 مع 3 و4 و9 و8 و7، فهذا يعطيني 3 و4 و9 و8 و7 فقط.
ملاحظة المترجم: الجملة الأخيرة في التفريغ تسقط
1, 2من النتيجة المنطوقة. نتيجة الوصل الموصوف رياضيًّا هي[1, 2, 3, 4, 9, 8, 7]؛ حُفظت الجملة المنطوقة أعلاه، وهذا التوضيح منفصل عنها.
هل الجميع معي؟ هل هذا واضح؟ حسنًا، جيد. أرى بعض الإيماءات، وهذا في الواقع مؤشر جيد إلى حدٍّ ما. حسنًا، أنتم معي، صحيح؟ لأن الدور دوركم الآن. سنكتب صيغة مختلفة من التحقق مما إذا كان عنصر في قائمة. سأعطيكم سيناريو مشابهًا جدًّا لهذا المثال المسطّح. سأعطيكم قائمة تحتوي عناصر من نوع قائمة. ها هي قائمتي التي تحتوي عناصر من نوع قائمة.
وما أود منكم فعله هو كتابة دالة عودية تتحقق مما إذا كان هذا العنصر، أيًّا كان المعامل الثاني في استدعاء دالتي، موجودًا في عناصر القوائم هذه. ليس في المستوى الأعلى، كما كتبنا في الكود الأخير للتحقق مما إذا كان عنصر في قائمة، بل في هذه القوائم الفرعية.
ولأريكم الفرق فقط، إذا تحققت مما إذا كان 3 في 1، فاصلة، 2، فاصلة، 3، فستكون النتيجة True. لكن إذا تحققت مما إذا كان 3 في القائمة التي تحتوي القائمة 1، فاصلة، 2، فاصلة، 3، فذلك False، لأنه يتحقق مما إذا كان 3 مساويًا لهذه القائمة. إنه يجري اختبار مساواة في المستوى الأعلى فقط هنا.
صفحة المصدر 13
فلنجعلكم تكتبون هذا الكود عند السطر 166. يمكنكم استخدام المعامل in للتحقق مما إذا كان عنصر في قائمة نفسها، لكن بالطبع لن تستطيعوا استخدام المعامل in — ولا ينبغي لكم ذلك، لأننا عندئذ لا نكتب دالة عودية — للتحقق مما إذا كان العنصر داخل عنصر من نوع قائمة. سأدعكم تعملون عليه بضع دقائق، ثم يمكننا كتابته معًا.
هل لدى أحد بداية؟ لننظر إلى الحالة التي يكون فيها عنصر واحد. كيف تتحقق مما إذا كان ذلك العنصر داخل القائمة التي بالداخل؟ إذًا if — هذه حالتنا التي فيها عنصر واحد. طول L يساوي 1. نعم؟
أحد الحضور:
أمم، لست متأكدًا، لكن [غير مسموع] e وL [غير مسموع].
آنا بيل:
نعم، بالضبط. e وL هو الشيء الصحيح الذي نفعله. L عند الفهرس 0. إذا كانت هذه هي L عندنا — لهذا أضفت هذا المثال الصغير هنا، حتى يستطيع مساعدتنا. L عند الفهرس 0 هو هذا هنا، وكل ما عليّ فعله التحقق مما إذا كان e في L عند الفهرس 0. ويمكنني إرجاع ذلك مباشرة.
يمكنني أن أفعل: إذا كان e في L0، فأرجِع True، وإلا فأرجِع False. لكن e in L0 قيمة منطقية بالفعل. لذلك يمكنني إرجاعها مباشرة فقط. أما else، فلدينا قائمة فيها أكثر من عنصر واحد. ماذا نفعل هنا؟ تذكّروا: أخرجوا العنصر الأول، ثم افعلوا الباقي.
لنقل هذا. لنقل إن العنصر الأول هو L عند الفهرس 0. سيساعدنا ذلك على التفكير فيه قليلًا. قبل النظر إلى بقية القائمة واستدعاء دالتنا العودية، ماذا فعلنا عندما تحقّقنا مما إذا كان عنصر في قائمة، عندما كانت لدينا قائمة عادية فقط؟ قلنا ببساطة: إذا كان e في first، فأرجِع True. وإلا فأرجِع False.
لكننا لا نريد تنفيذ else وإرجاع False، لأن ذلك ليس صحيحًا تمامًا. في else نريد النظر إلى بقية القائمة. نريد معرفة ما إذا كان — بالطبع، إذا لم يكن العنصر في أول شيء أخرجته للتو، هذه القائمة هنا، فأود أن أقول: هل هو في بقية هذه القائمة؟ وهذا يعني استدعاء الدالة التي نكتبها للتو من جديد.
يمكننا إذًا إرجاع اسم هذه الدالة، in_lists_of — ماذا سمّيتها؟ قوائم من قوائم. ثم L من 1 فصاعدًا، مع العنصر نفسه الذي نحاول إيجاده. وبالطبع يمكننا تبسيط هذا تمامًا كما استطعنا تبسيط المثال السابق. لكن يساعدنا التفكير فيه بهاتين الحالتين: قائمة فيها عنصر واحد، وقائمة فيها عناصر كثيرة. هل هناك أسئلة عن هذا؟ نعم.
أحد الحضور:
[غير مسموع]
آنا بيل:
هذه؟ في هذه ننظر إلى قائمة فيها قائمة واحدة. نعم. يمكننا تضمين حالة أساس أخرى، على ما أظن: إذا كان طول L هو 0 فأرجِع False. ستعمل تلك أيضًا، لأنه بالطبع، إذا كانت القائمة فارغة، فلن يكون فيها.
إذًا، متى نستخدم العودية؟ بالطبع كثير من الأمثلة التي رأيناها هنا بدهيّ جدًّا أن نكتبها تكراريًّا. لكنني ذكرت بضعة أمثلة المرة الماضية حيث يكون استخدام العودية أكثر بداهة. وتحديدًا أردت رسم تشابه قليل مع ذلك الشيء عندما تعلّمنا حلقات while. قلنا: حسنًا، ماذا لو حاولنا برمجة لعبة صغيرة لا تستخدم إلا if وelse؟
قلت إن لدينا مجموعة من تعليمات if/else المتداخلة دون حلقة while، لأننا لا نعرف إلى أي عمق نجعل تعليمات if/else/if هذه تمتد. وتوجد فكرة مشابهة جدًّا في العودية ومتى نستخدمها. فإذا كانت لدي قائمة فيها مجموعة كاملة من القوائم، وقد تحتوي تلك القوائم قوائم داخلها، وهكذا وهكذا، فأنا لا أعرف كم أحتاج إلى — إلى أي عمق أحتاج إلى جعل كودي يذهب.
صفحة المصدر 14
مثال باستخدام حلقة for سيكون أن أقول: لكل عنصر في L، سأقول، سأنظر إلى كل عنصر. سأقول: حسنًا، إذا لم تكن قائمة، أستطيع التعامل معك مباشرة. لكن إذا كنت قائمة، فأحتاج إلى المرور عليك تكراريًّا. لذلك لدي هذا التكرار الآخر هنا، لكل j في i، لإحدى تلك القوائم. ومرة أخرى سأقول: هل أنت قائمة؟ إذا لم تكن، سأتعامل معك مباشرة. أما else فأنت قائمة، ولذلك أحتاج إلى المرور عليك تكراريًّا.
وترون أن هذه الفكرة المتداخلة تبدأ الآن في الظهور هنا. وبالطبع يمكننا محاولة استخدام حلقة while لتحسين الكود قليلًا، فنقول: طالما نوع هذا العنصر ليس قائمة، افعل هذا، وما شابه. لكن ذلك يؤدي إلى كود مطوّل حقًّا. والعودية طريقة للتعامل مع هذه القوائم داخل القوائم داخل القوائم، وبالطبع عندما تكون لديكم بُنى بيانات لا تعرفون طولها — أو مدى عمقها.
ذكرت أنظمة الملفات ومجموعة عمليات في المحاضرة الماضية بوصفها مواضع لطيفة جدًّا لاستخدام العودية. وعصابة سكوبي دو (Scooby-Doo) التي تبحث عن الجاني: غرف فيها أبواب تقود إلى غرف أخرى فيها أبواب تقود إلى غرف أخرى؛ إنهم لا يعرفون كم بابًا يحتاجون إلى المرور به حتى يصلوا إلى غرفة لا أبواب فيها. بالطبع ينبغي لهم استخدام العودية. ثم هناك مجموعة من الأمثلة الممتعة الأخرى لمواضع استخدام العودية.
في الجزء الأخير من المحاضرة، أود العمل على هذا المثال حيث سنرى الكود لحل القوائم داخل القوائم داخل القوائم داخل القوائم. لكن قبل أن نفعل ذلك، سنتحدث عن — سنقدّم ذلك المثال في سياق عكس قائمة (Reversing a List). لكن قبل النظر إلى قائمة فيها كل هذه القوائم الفرعية المختلفة، لننظر إلى قائمة فيها أعداد صحيحة فقط.
كيف نفكّر في هذه المسألة عوديًّا لعكس جميع العناصر في هذه القائمة؟ مرة أخرى، سنستخدم النمط نفسه تمامًا الذي استخدمناه طوال اليوم عند التعامل مع القوائم. سنأخذ العنصر الأول ونخرجه، وسنتعامل مع بقية القائمة أساسًا بتشغيل الدالة نفسها التي نكتبها على بقية القائمة.
لنقل إن لدي قائمتي الأصلية، وأنظر إلى العنصر الأول، كما من قبل. سأُخرجه. إذا أخذت هذا العنصر الأول ووضعته في النهاية، ثم نظرت إلى القائمة المتبقية، كل شيء باستثناء ذلك العنصر الأول الذي وضعته في النهاية، يمكنني استدعاء الدالة نفسها التي أكتبها الآن لعكس القائمة المتبقية. وهذا يعني أنني سأخذ هذه القائمة المتبقية، وأمسك العنصر الأول، وأضعه في النهاية، وأتعامل مع القائمة المتبقية.
مرة أخرى، خذ العنصر الأول، وضعه في النهاية، وتعامل مع القائمة المتبقية، حتى تكون لدي قائمة طولها 1. كيف أعكس قائمة ليس فيها إلا عنصر واحد؟ إنها تلك القائمة فقط. عكس قائمة L هو L فقط. هذه هي الفكرة. ولاحظوا أننا عندما نعيد بناء النتيجة، أخذنا ذلك العنصر الأول وألحقناه بالنهاية. لذلك سننفّذ شيئًا آخر من نوع وصل القوائم، إلا أن الشيء الذي أصله الآن، العنصر الأول، سيكون في النهاية. سيكون الجزء الثاني من عملية الجمع عندي. أعطيكم تنبيهًا مسبقًا فقط. هكذا سيبدو الأمر.
فلنكتب الكود. إذا كان طول القائمة 1، وإذا كنت أعكس قائمة فيها عنصر واحد، فأرجِع تلك القائمة فقط. سهل جدًّا. إنها القائمة نفسها فقط. أما else — وهنا يبدأ المرح. لدي شيء. سأفعل شيئًا موصولًا بشيء آخر. أُخرج العنصر الأول. ها هو، L عند الفهرس 0. لكنه موجود في موضع غريب لم نره فيه من قبل. إنه في الجزء الثاني، على يمين عملية الوصل. وهذا لا بأس به، لأن ما نريد فعله هو أخذ العنصر من بداية القائمة وإلحاقه بالنهاية.
صفحة المصدر 15
وهناك شيء آخر غريب فيه. وضعتُه داخل أقواس مربعة. مرة أخرى، أُدرج هذا المثال لمساعدتنا على التفكير فيه. لماذا توجد تلك الأقواس المربعة؟ فكّروا فيما نريد لهذه الدالة أن تُرجعه. هل تُرجع عددًا؟ لا. هل تُرجع قيمة منطقية؟ لا. إنها تُرجع قائمة. أريد لهذه الدالة أن تأخذ قائمة وتعيد إليّ قائمة، لكن تكون عناصرها بترتيب معكوس.
فما أريد فعله — ترون بالفعل أن هذا الإرجاع هنا يُرجع قائمة. فستكون أقواسًا مربعة فيها 10 أو أي شيء. في خطوتي العودية، إذا كنت أصل، أريد وصل هذا الشيء هنا، الذي سأخبركم عنه في الشريحة القادمة. لكنني سأصله مع — ستكون قائمة مع قائمة أخرى. إذا وصلت قائمة مع عدد، فإن L عند 0 هي — L عند 0 هي 10.
إذا وصلت قائمة مع عدد، فستصرخ Python في وجهي. ما أحتاج إلى فعله إذًا هو جعل ذلك العدد الذي أخرجته للتو، L عند 0، قائمة. سأضع حوله قوسًا مربعًا فقط وأقول: يا Python، هذه قائمة فيها عنصر واحد. هل هذا واضح؟ جيد.
معنى ذلك إذًا أن لدي هذه الـ10 التي أخرجتها. سأصل شيئًا مع تلك الـ10. وذلك الشيء هو أن أضع ثقتي في الدالة التي أكتبها، وأقول إن ذلك الشيء سيكون 20 و30 و40 معكوسة بنجاح، أي 40 و30 و20. إذا استطعت فعل ذلك، 40 و30 و20، ووصلته مع 10، فقد أنجزت مهمتي. عكست بنجاح 10 و20 و30 و40 لتصبح 40 و30 و20 و10.
فلنفعل ذلك فقط. هذه أنا أضع ثقتي في هذه الدالة التي أكتبها. أستدعي الدالة نفسها مرة أخرى، وأقول: مرحبًا، أود عكس بقية القائمة، تمامًا كما فعلنا في السابق. ما زال التفكير في ذلك غريبًا جدًّا، لأننا نثق في شيء نكتبه. جيد.
فلنختبرها إذًا. لنشغّلها. إذا شغّلتها مع القائمة 1 و2 وabc، فستعكس Python قائمتي. وستطبع abc ثم 2 ثم 1. لنقل إنني أشغّلها الآن بشيء مختلف قليلًا. أشغّلها مع هذه القائمة هنا. كم عنصرًا في هذه القائمة؟ test. أخبروني أنتم. ثلاثة. بالضبط. الأول عدد صحيح، والثاني قائمة، والأخير قائمة فيها مجموعة من الأشياء غير المهمة، لكن بصفتها test لا يهمني ذلك، لأن كل ما يهمني هو أن لدي ثلاثة عناصر بالداخل.
عندما أشغّل هذه الدالة على test، ستعكس المستوى الأعلى فقط، لأن هذا ما تفعله هذه. لم أقل في أي مكان هنا إنني أريد عكس القوائم داخل القوائم. لم أقل: إذا كنت قائمة، فاعكس نفسك أيضًا. قلت فقط: في المستوى الأعلى، خذ هذا العنصر وضعه في النهاية. عندما أعكس test، هذه الـtest غريبة المظهر هنا، ستأخذ ذلك العنصر الأول وتضعه في النهاية. يبقى العنصر الأوسط حيث هو، ويصبح العنصر الأخير أولًا. هل هذا واضح للجميع حتى الآن؟ أنا قلقة من عدم وجود أسئلة أكثر. حسنًا.
هذا جيد. لكنه الآن ليس حقًّا ما أريده. ما أريده هو أنه إذا كانت لدي قوائم داخل قوائم داخل قوائم داخل قوائم، وكانت لتلك القوائم عناصر من نوع ما بداخلها — في المستوى الأدنى لدي قائمة فيها عدد صحيح أو سلسلة نصية أو أي شيء — فأود عكس تلك العناصر أيضًا. في الواقع، ما كنت أريده لو مرّرت هذه الدالة هنا — هذه القائمة هنا — هو أن أقول: حسنًا، لماذا لا تعكس كل شيء؟
كنت أود أن أحصل على g ثم f بوصفهما قائمة، ثم e، ثم d، ثم 1. وهنا سنفعل ذلك. لنقل إن لدي الآن قائمة. كل واحد من هذه المربعات الزرقاء قائمتي — أو عناصر قائمتي ومستواي الأعلى. وصادف أن داخلها نوعًا من القوائم.
صفحة المصدر 16
كيف أفعل هذا؟ حسنًا، بما أن لدي الآن عناصر قد تكون قوائم، فأحتاج إلى أن تختبر دالتي العودية ما إذا كان العنصر الذي أنظر إليه حاليًّا قائمة أم لا. إذا لم يكن، مثل 3 و4، أستطيع التعامل معهما بالطريقة نفسها تمامًا التي تعاملنا بها معهما في هذه الحالة. لكن إذا كان قائمة، كما في هذا — هذا عنصر قائمة، وهذا أيضًا عنصر قائمة فيه عناصر قوائم داخله. فذلك أغرب حتى. عندئذ نحتاج إلى النظر إليها بصورة منفصلة.
لنأخذ الكود الذي كتبناه في الشريحة السابقة، لأنه بداية جيدة: أخرج العنصر الأول، وضعه في النهاية. هذا ما فعلناه من قبل. لكن قبل المغادرة، لنقل: إذا كنت قائمة، إذا كنت قائمة، فاعكس نفسك أيضًا. فلا أريد فقط، في المستوى الأعلى، أن تذهب تلك القائمة — ذلك العنصر — إلى النهاية؛ أريد أيضًا النظر في ماهيتك. لا أريد أن يكون هذا العنصر الأخير 1، فاصلة، 2. أريد عكس عناصره أيضًا لتكون 2، فاصلة، 1. ففي النهاية، ما أريد أن يعطيني هذا هو 87654321.
هذا يتعامل مع وضع ذلك العنصر الأول في النهاية هناك. الآن أنظر إلى قائمتي الجديدة. ومرة أخرى، ستكون هذه خطوة عودية. العنصر في المقدمة، أُخرجه مرة أخرى. إنه عدد فقط. لا شيء خاص هنا. فتذهب إلى النهاية فقط، كما من قبل. لا شيء ننظر فيه. لا شيء نعكسه بالنسبة إلى ذلك الـ3. ومرة أخرى، 4، كما من قبل، يذهب إلى النهاية. والآن، ماذا عن هذه القائمة التي فيها قوائم، وهكذا؟
حسنًا، وصلنا إلى نوع من «حالة الأساس»، بين علامتي اقتباس. لا يوجد شيء نضعه في النهاية. لكن يمكنكم تخيّل وضعه في النهاية لو كانت هناك عناصر أخرى داخله. لذلك سيبقى هذا كما هو. آسفة على ذلك. سيبقى هذا كما هو. لكن ما سنفعله هو القول: حسنًا، أنت قائمة، تمامًا كما كانت هذه قائمة. كانت قائمة فيها عددان. أنت أيضًا قائمة فيها عنصران. الخطوة الأولى التي أود منك فعلها هي عكس نفسك. فيأتي 7 و8 إلى المقدمة، ويذهب 5 و6 بعده.
لكن عناصرها أيضًا قوائم. لذلك لا أريد عكسك فقط، بل أريد منك أن تطلب من كل عناصرِك عكس نفسها. ينبغي أن ينعكس 5 و6 ليصبح 6 و5، وينبغي أن يعكس 7 و8 نفسه ليصبح 8 و7. هل هذا واضح؟ مفهوميًّا، أعتقد أننا فهمناه. نريد العكس إلى أبعد عمق ممكن حتى نصل إلى بعض الأعداد.
فلنكتب الكود. سنفعل شيئًا مشابهًا جدًّا لما فعلناه في السابق: كتابة كل هذه الأمثلة وفق النمط نفسه تمامًا. انظر إلى قائمة فيها عنصر واحد، ثم انظر إلى قائمة فيها عناصر كثيرة. إذا كانت لدي قائمة فيها عنصر واحد — هنا، هذه قائمة. لن يكون فيها إلا عنصر واحد. إذا كانت القائمة — إذا كان ذلك العنصر داخل تلك القائمة عددًا، فسأفعل شيئًا مختلفًا عما أفعله إذا كان العنصر داخل هذه القائمة قائمة.
ما أريد فعله فعلًا داخل هذا الشرط if len(L) يساوي 1 هو وجود جزأين فرعيين بحسب ما إذا كان قائمة أم لا، لأنه إذا كان عددًا فقط، يسعدني تركه كما هو. هذا العدد في موضعه بالفعل. إنه معكوس بالفعل. لكن إذا كان العنصر داخلها قائمة، فهذا العنصر الواحد داخل قائمتي هو أيضًا قائمة، وأريده أن يعكس نفسه. إذا كان طول القائمة 1، أتحقق الآن من النوع. إذا لم يكن قائمة، أفعل الشيء نفسه تمامًا كما فعلت من قبل. إذا لم يكن قائمة، فأنت معكوس بالفعل. لا حاجة إلى عكس أي شيء آخر. نعم، سؤال.
أحد الحضور:
أظن أنني مرتبك قليلًا بشأن العنصر الواحد [غير مسموع] الجزء الأول.
آنا بيل:
نعم، نفصلها فقط إلى عنصر واحد أو اثنين — أو أكثر من واحد. في الحالة التي يكون فيها عنصر واحد، هذه قائمتي، وهذا هو العنصر الواحد. وإذا كان لدي عنصر هو نفسه قائمة، فما زال هذا عنصرًا واحدًا. نعم.
أحد الحضور:
لنقل إنك تزيلين الأقواس على خارج [غير مسموع].
صفحة المصدر 17
آنا بيل:
نعم. هذه الآن قائمة فيها عنصران. نعم. نعم. بالضبط. لكنني أنظر إلى الحالة التي تكون لدي فيها قائمة فيها عنصر واحد. وصادف أنه قائمة أخرى. وما الذي بداخلها لا يهمني حاليًّا، لأن — نعم. إذا لم يكن قائمة، فهو معكوس بالفعل. وإلا، ماذا نفعل؟ نريد أن نطلب منه عكس نفسه. وتلك هي الدالة التي نكتبها حاليًّا. هل هذا جيد، على ما أظن؟ حسنًا. مرة أخرى، هناك كثير من الثقة هنا يا جماعة.
نستدعي إذًا العكس العميق (Deep Reverse)، هذه الدالة التي نكتبها حاليًّا، على عنصر القائمة هذا، L عند الفهرس 0. إنه عنصرنا الوحيد. ولاحظوا مرة أخرى أن لدي هذه الأقواس المربعة حوله، لأن هذه الدالة يفترض أن تُرجع قائمة. فتمامًا كما في الحالة السابقة، عندما وضعت بعض الأقواس المربعة حول العدد، عليّ فعل ذلك هنا أيضًا. هل هذه الحالة واضحة للجميع؟ لأن الخطوة العودية ستكون أكثر جنونًا.
حسنًا، في else، لدينا قائمة فيها أكثر من عنصر واحد. لدينا قائمة فيها بعض الأشياء هنا، ثم لدي ربما قائمة أخرى ومجموعة أشياء أخرى هنا، مثل هذا، أيًّا كان. ما أود فعله إذًا، مرة أخرى وفق النمط نوعًا ما الذي كنّا ننظر إليه، هو أن أقول: سأُخرج العنصر الأول في القائمة. إذا كانت لدي قائمة فيها عناصر كثيرة، فلنُخرج الأول ونتعامل معه.
لكن مرة أخرى، أحتاج إلى الحذر، لأن ذلك العنصر الأول قد يكون عددًا أو سلسلة نصية أو أي شيء، أو قد يكون قائمة. وأتعامل مع هاتين الحالتين على نحو منفصل. إذا كان عددًا فقط — هذه هي حالة if هنا. إذا كان نوع L عند 0، الشيء الذي أخرجته، قائمة، فإن ما عليّ فعله هو ما كان لدي في المثال السابق.
أخذ ذلك العنصر الأول، وأضع أقواسًا مربعة حوله، وأصله مع العكس العميق للباقي. بالضبط مثل الحالة السابقة، لأنه عدد فقط. أفعل ما فعلته من قبل، أضعه في النهاية، وننتهي. ومرة أخرى، أجري استدعاء دالة هنا لنفسي.
أما else، فهذا الشيء هنا، هذا L عند الفهرس 0 الذي أخرجته، قائمة. لذلك لا يكفي أن أستدعي العكس العميق على هؤلاء هنا، بل — الجميع معًا — علينا استدعاء العكس العميق على العنصر الأول أيضًا، لأنه قائمة. لا أستطيع وضعه في النهاية فقط. أريده أن يعكس جميع عناصره. هذا هو الكود الذي يفعل ذلك.
هذا الجزء هنا، العكس العميق لـL[1:]، يطلب من المتبقي من القائمة — بقية القائمة — أن يعكس نفسه، تمامًا كما فعلنا في حالة العدد الصحيح. كل شيء كما هو. لكننا نصل ذلك، مرة أخرى بوضع أقواس مربعة حوله، لأننا نريد الوصل بقائمة. نصل ذلك مع عكس عنصرنا عند الفهرس 0 عكسًا عميقًا. فلا نضع هذا في النهاية فقط لعكسه، بل نحتاج إلى أن يعكس جميع عناصره أيضًا. لا توجد أسطر أخرى لهذا الكود، لكن ما أفكاركم؟
ملاحظة المترجم: في شرح فرع العدد أعلاه يقول التفريغ «إذا كان النوع قائمة»، ثم يشرح التعامل مع عدد، ويخصّص
elseللقائمة. حُفظت الزلة المنطوقة؛ الشرط المقصود في فرع العدد هو أن العنصر ليس قائمة.
أحد الحضور:
[غير مسموع]
آنا بيل:
أعرف. نعم.
أحد الحضور:
لماذا تواصلين وضع الأقواس المربعة؟
آنا بيل:
نعم. نضع الأقواس المربعة لأننا نريد الحفاظ على البنية نفسها التي كانت للقائمة الأصلية. إذا كان عددًا صحيحًا، فهذه، على ما أظن، أبسط حالة لشرح ذلك. إذا كان عددًا صحيحًا، فلا يمكنك وصل القائمة بالعدد الصحيح. ستكون هناك مشكلة. لذلك تريد وصل القائمة بالعدد الصحيح داخل قائمة بوصفه عنصرًا واحدًا.
صفحة المصدر 18
ما يمكننا فعله هو تبسيط الكود. مرة أخرى، أرى شخصيًّا أن التفكير في هذا أسهل قليلًا، لأنني أفصل الحالة التي تكون فيها لدي قائمة فيها شيء واحد والحالة التي تكون فيها قائمة فيها أشياء كثيرة. لكن يمكنكم بالتأكيد التفكير فيه هكذا. إذا كانت لدي قائمة فارغة، فأرجِع قائمة فارغة فقط. أما else، فأُخرج العنصر عند الفهرس 0 مباشرة، وأعكس ذلك عكسًا عميقًا — بقية القائمة — موصولًا بذلك العنصر في النهاية. ومرة أخرى — عفوًا — مع ملاحظة أننا نضع هذا العنصر بوصفه قائمة.
وفي else نستطيع عكس بقية القائمة عكسًا عميقًا، موصولًا بالعكس العميق لهذا هنا. فلا نضعه في النهاية فقط، بل نعكس جميع عناصره أيضًا. هذه النسخة المبسّطة — الكود المبسّط. هذه العودية التي رأيناها، كل هذه الأمثلة هنا التي طبّقناها على القوائم، يمكن في الواقع تطبيقها على أي تسلسلات مرتّبة قابلة للفهرسة (Indexable Ordered Sequences).
سيعمل الكود نفسه على الصفوف. وسيعمل الكود نفسه على السلاسل النصية، باستثناء المثال الذي — لأنه لا يمكنك أن تكون لديك سلاسل نصية داخل سلاسل نصية داخل سلاسل نصية. لكن بالتأكيد سيعمل جمع العناصر في قائمة والتحقق مما إذا كان عنصر في قائمة على الصفوف أيضًا، وسيعمل بعض هذه الأمثلة على السلاسل النصية، ما دمت تستطيع إجراء تلك العملية على السلاسل النصية، لأن كل هذه تسلسلات قابلة للفهرسة، فلا ينبغي أن تكون هناك مشكلة.
هناك إذًا دروس كثيرة نستخلصها هنا عن العودية. هذا المثال الأخير تحديدًا يبدو جميلًا حقًّا في الصيغة المنظّفة. وهو، كم؟ خمسة أسطر من الكود لحل هذه المسألة الصعبة نوعًا ما، التي كان عليك خلاف ذلك حلّها باستخدام حلقات while وحلقات for وما شابه. لذلك أشجّعكم بالتأكيد على إلقاء نظرة عبر روابط Python Tutor التي أدرجتها.
نصيحتاي، أي الخلاصتان الكبيرتان عن العودية، هما هذا الأمر المتعلق بحالات الأساس — أو الحالات. في أي وقت تكون لديك تعليمة إرجاع وتكتب دالة عودية، تأكّد من أن كل تعليمة إرجاع تُرجع شيئًا من النوع نفسه. وإلا فستكون لديك حالات عدم توافق أنواع (Type Mismatches) في كل مكان. ثم تستفيد الخطوة العودية من حقيقة أنك تُرجع هذه الأنواع. وعندئذ ستعمل تلك العمليات في الخطوة العودية مع تلك الأنواع.
والثانية هي أن الدالة لا يجب أن تكون كفؤة من المحاولة الأولى. الطريقة التي فكّرنا بها في المسألة، بفصلها إلى قائمة فيها عنصر واحد وأخرى فيها عناصر كثيرة، أسهل لي في التفكير، لأنني أستطيع استيعاب المسألة. ولا يلزمك كتابة أكثر كود كفاءة مباشرة في العودية. بالتأكيد لا حاجة إلى فعل ذلك. لكن يمكنك بالتأكيد تنظيفه بعد أن تكون لديك نسخة تعمل.
هناك مسائل تدريبية كثيرة في ملف Python لليوم. مسائل تدريبية كثيرة، كثيرة. تطبيق حفظ النتائج على كرة السلة. بالطبع ذكرت ذلك. مثال — قليل من التدريب دون قوائم داخل قوائم، وتدريب مع قوائم داخل قوائم داخل قوائم، ثم أدرجت ثلاثة تنفيذات عودية فيها أخطاء (Buggy Implementations) لتحاولوا إصلاحها. قليل من التدريب على تنقيح الأخطاء (Debugging) إلى جانب التدريب على العودية. شكرًا لكم جميعًا.