الفرق بين من يتعلم بايثون ومن يتقنها ليس عدد الدورات التي شاهدها، بل عدد المشكلات التي حلّها بيده. التحديات البرمجية هي الطريقة الوحيدة لتحويل المعرفة السلبية إلى مهارة حقيقية. وليس أي تحدٍّ: التحدي المصمم جيدًا يعلّمك نمطًا قابلًا لإعادة الاستخدام، ويكشف لك الأخطاء التي ستكررها في الإنتاج إن لم ترها الآن.
هذا المقال ليس قائمة تمارين منفصلة، بل مسارًا متدرجًا من خمسة تحديات، كل واحد يبني على الذي قبله. ابدأ من الأول حتى لو بدا سهلًا: ليس الهدف أن تحله، بل أن تحله بطريقة تنجح مع الحالات الحدية. لكل تحدٍّ، نشرح الفكرة، ونكشف الفخاخ، ثم نقدم حلًا مع تحليل لما يجعله صحيحًا. حاول أن تحل التحدي بنفسك قبل قراءة الحل.
نظرة سريعة: خريطة التحديات الخمسة
الجدول التالي يوضح مستوى كل تحدٍّ، والمهارات التي يغطيها، والوقت المتوقع:
| التحدي | المستوى | المهارات الأساسية | الوقت |
|---|---|---|---|
| 1. حاسبة الدرجات | مبتدئ | الشروط، التحقق من الإدخال، المرشحات | 15 دقيقة |
| 2. كاشف التكرار | مبتدئ→متوسط | dict، set، الحفاظ على الترتيب | 20 دقيقة |
| 3. ضاغط RLE | متوسط | تكرار محكوم، بناء النصوص، الضغط العكسي | 30 دقيقة |
| 4. محلل الجمل | متوسط | split، comprehensions، Counter، max(key=) | 30 دقيقة |
| 5. مخزن LRU | متوسط→متقدم | تصميم الأصناف، dict المرتب، الحالات الحدية | 45 دقيقة |
كيف تستفيد من هذا المقال؟
قبل أن تبدأ، التزم بأربع قواعد:
- حاول أولًا. لا تقرأ الحل قبل أن تحاول 10–15 دقيقة. الفشل في المحاولة الأولى جزء من التعلم، وليس عيبًا.
- اختبر الحالات الحدية. إذا نجح حلك على المثال، فهذا لا يعني أنه صحيح. اختبره على القائمة الفارغة، العنصر الواحد، القيم السالبة، والنصوص الفارغة.
- لا تحفظ الحل، افهم النمط. كل تحدٍّ يعلمك نمطًا يعود في تحديات أخرى. إذا فهمت النمط، ستتعرف عليه في أي مسألة لاحقة.
- وسّع بعد الحل. كل تحدٍّ ينتهي بـ«تحدٍّ إضافي». لا تتجاهله؛ فهو ما يجعلك تتجاوز المستوى.
التحدي 1: حاسبة الدرجات الذكية (مبتدئ)
المشكلة
اكتب دالة analyze_grades(grades) تستقبل
قائمة أرقام (قد تحتوي على قيم غير صالحة)، وتعيد قاموسًا
يحتوي على:
average: متوسط الدرجات الصالحة (فقط بين 0 و100).letter: تقدير حرفي حسب المتوسط (A ≥ 90، B ≥ 80، C ≥ 70، D ≥ 60، F أقل من ذلك).count_valid: عدد الدرجات الصالحة.count_invalid: عدد الدرجات غير الصالحة.
إذا لم تكن هناك أي درجة صالحة، فالمتوسط والتقدير يجب أن
يكونا None.
أمثلة
analyze_grades([90, 85, 100])
# {'average': 91.67, 'letter': 'A', 'count_valid': 3, 'count_invalid': 0}
analyze_grades([50, -10, 200, 75])
# {'average': 62.5, 'letter': 'D', 'count_valid': 2, 'count_invalid': 2}
analyze_grades([])
# {'average': None, 'letter': None, 'count_valid': 0, 'count_invalid': 0} الفخاخ الشائعة
- نسيان التحقق من النوع. ماذا لو
كانت القائمة تحتوي على نص مثل
"85"؟ القاعدة: اعتبره غير صالح، ولا تُصدر استثناء. - القسمة على صفر. إذا كانت كل القيم غير صالحة، فالقائمة الصالحة فارغة، ولا يمكن حساب المتوسط.
- استخدام مقارنات متسلسلة بشكل خاطئ.
في بايثون،
0 <= g <= 100صحيح، لكنg >= 0 and g <= 100أوضح للمبتدئين.
الحل
def analyze_grades(grades):
valid = [g for g in grades if isinstance(g, (int, float)) and 0 <= g <= 100]
count_invalid = len(grades) - len(valid)
if not valid:
return {
"average": None,
"letter": None,
"count_valid": 0,
"count_invalid": count_invalid,
}
average = round(sum(valid) / len(valid), 2)
if average >= 90:
letter = "A"
elif average >= 80:
letter = "B"
elif average >= 70:
letter = "C"
elif average >= 60:
letter = "D"
else:
letter = "F"
return {
"average": average,
"letter": letter,
"count_valid": len(valid),
"count_invalid": count_invalid,
} لماذا هذا الحل صحيح؟
ثلاثة قرارات تجعله متينًا:
- التحقق من النوع قبل المقارنة.
isinstance(g, (int, float))يمنعTypeErrorعند مقارنة نص برقم. - فصل «التحقق» عن «الحساب». نبني القائمة الصالحة أولًا، ثم نعمل عليها. هذا يبسط المنطق ويمنع الأخطاء.
- معالجة القائمة الفارغة صراحةً. لا نعتمد على استثناءات؛ نرجع قيمة محددة.
التحدي الإضافي
عدّل الدالة لتُرجع median أيضًا، مع مراعاة
أن القائمة قد تحتوي على عدد فردي أو زوجي من القيم. تلميح:
استخدم sorted() ثم ابحث عن العنصر الأوسط.
التحدي 2: كاشف التكرار مع حفظ الترتيب (مبتدئ→متوسط)
المشكلة
اكتب دالة find_duplicates(items) تستقبل
قائمة، وتعيد قائمة تحتوي على العناصر المكررة
بترتيب ظهورها الأول. يجب أن تتعامل مع
النصوص دون حساسية لحالة الأحرف، مع تجاهل المسافات
الزائدة.
أمثلة
find_duplicates(["Ali", "Omar", "Ali", "Sara", "omar"])
# ['Ali', 'Omar']
find_duplicates(["a", "A", "b", "B", "c"])
# ['a', 'b']
find_duplicates([])
# [] الفخاخ الشائعة
- استخدام
setفقط. هذا يعطيك التكرارات بلا ترتيب، والترتيب مطلوب. - نسيان التطبيع.
"Ali"و"ali"و" ALI "كلها نفس الاسم. يجب أن تُطبَّع (normalize) قبل المقارنة. - إضافة التكرار أكثر من مرة. إذا ظهر العنصر ثلاث مرات، يجب أن يُضاف مرة واحدة إلى النتيجة.
الحل
def find_duplicates(items):
seen = set()
added = set()
result = []
for item in items:
if not isinstance(item, str):
continue
normalized = item.strip().lower()
if normalized in seen and normalized not in added:
result.append(normalized)
added.add(normalized)
else:
seen.add(normalized)
return result لماذا هذا الحل صحيح؟
نستخدم مجموعتين: seen لتتبع ما رأيناه،
وadded لتتبع ما أضفناه إلى النتيجة. هذا
يمنع إضافة العنصر نفسه مرتين، ويحافظ على الترتيب لأن
result قائمة تُبنى بالترتيب.
لاحظ أننا نستخدم dict ضمنيًا
عندما نحتاج حفظ الترتيب مع معرفة التكرار. هذا النمط
سيعود في تحديات كثيرة: مجموعة للبحث السريع، وقائمة أو
dict للترتيب.
التحدي الإضافي
عدّل الدالة لتُرجع dict يربط كل عنصر مكرر
بعدد مرات ظهوره، مع الحفاظ على ترتيب الظهور الأول.
التحدي 3: ضاغط النصوص RLE (متوسط)
المشكلة
اكتب دالتين: rle_compress(text)
وrle_decompress(encoded). الأولى تضغط
النص بخوارزمية Run-Length Encoding:
كل سلسلة أحرف متتالية متطابقة تُستبدل بالحرف ثم عدد
تكراره. والثانية تفك الضغط.
أمثلة
rle_compress("aaabbc")
# "a3b2c1"
rle_compress("abc")
# "a1b1c1"
rle_compress("")
# ""
rle_decompress("a3b2c1")
# "aaabbc"
rle_decompress("")
# "" الفخاخ الشائعة
- الحرف الأخير. عند التكرار على النص، الحرف الأخير لا يُغلق تلقائيًا. إذا لم تُعالجه صراحةً، ستُفقد آخر مجموعة.
- الأرقام متعددة الخانات.
"a12"تعني 12 نسخة، لا واحدة ثم اثنتين. فك الضغط يحتاج إلى قراءة كل الأرقام. - النص الفارغ. كثير من الحلول
تنهار على
""؛ عالجه كحالة خاصة.
الحل
def rle_compress(text):
if not text:
return ""
result = []
current = text[0]
count = 1
for char in text[1:]:
if char == current:
count += 1
else:
result.append(f"{current}{count}")
current = char
count = 1
result.append(f"{current}{count}")
return "".join(result)
def rle_decompress(encoded):
if not encoded:
return ""
result = []
i = 0
while i < len(encoded):
char = encoded[i]
i += 1
num_start = i
while i < len(encoded) and encoded[i].isdigit():
i += 1
count = int(encoded[num_start:i]) if i > num_start else 1
result.append(char * count)
return "".join(result) لماذا هذا الحل صحيح؟
في الضغط، نعالج الحرف الأول خارج الحلقة ثم نبدأ من
الثاني. هذا يجعل «آخر مجموعة» تُغلق بعد الحلقة، فتجنبنا
مشكلة نسيان الحرف الأخير. في فك الضغط، نقرأ الحرف ثم
نقرأ كل الأرقام المتتالية، وهذا يدعم "a12"
و"a100" دون انكسار.
التحدي الإضافي
ما فائدة هذه الخوارزمية؟ ومتى تكون أسوأ
من النص الأصلي؟ فكر في نص مثل
"abcdefg" وحجم الناتج مقابل الأصل.
التحدي 4: محلل الجمل (متوسط)
المشكلة
اكتب دالة analyze_sentence(sentence) تستقبل
جملة نصية، وتعيد قاموسًا يحتوي على:
word_count: عدد الكلمات (تجاهل الترقيم).unique_words: عدد الكلمات الفريدة (بعد التطبيع).longest_word: أطول كلمة، وطولها.palindromes: قائمة الكلمات التي تُقرأ من الجهتين بالتساوي.top_words: أكثر ثلاث كلمات تكرارًا.
أمثلة
analyze_sentence("Level is level, noon is noon!")
# {
# 'word_count': 6,
# 'unique_words': 3,
# 'longest_word': ('level', 5),
# 'palindromes': ['level', 'noon'],
# 'top_words': [('level', 2), ('noon', 2), ('is', 2)]
# } الفخاخ الشائعة
- الترقيم.
"level,"ليست"level"؛ يجب إزالة الترقيم قبل التحليل. - حالة الأحرف.
"Level"و"level"كلمة واحدة. - ترتيب top_words. عند تعادل التكرار، تحتاج قاعدة ترتيب محددة (مثلًا أبجديًا) لتجنب نتائج غير حتمية.
الحل
import re
from collections import Counter
def analyze_sentence(sentence):
if not sentence or not sentence.strip():
return {
"word_count": 0,
"unique_words": 0,
"longest_word": ("", 0),
"palindromes": [],
"top_words": [],
}
words = re.findall(r"\b\w+\b", sentence.lower())
counts = Counter(words)
longest = max(words, key=lambda w: (len(w), w))
palindromes = [w for w in words if w == w[::-1] and len(w) > 1]
palindromes = list(dict.fromkeys(palindromes))
top_words = sorted(counts.items(), key=lambda x: (-x[1], x[0]))[:3]
return {
"word_count": len(words),
"unique_words": len(counts),
"longest_word": (longest, len(longest)),
"palindromes": palindromes,
"top_words": top_words,
} لماذا هذا الحل صحيح؟
نستخدم re.findall لاستخراج الكلمات وحدها،
فتختفي الترقيم دون معالجة يدوية. ثم Counter
يمنحنا التكرار بسهولة. لترتيب top_words
بشكل حتمي، نرتب بمفتاح مركب:
(-count, word). هذا يعطينا الأكثر تكرارًا
أولًا، وعند التعادل، الأبجدي أولًا. وتستخدم
dict.fromkeys لإزالة تكرار قائمة
palindromes مع الحفاظ على الترتيب.
التحدي الإضافي
عدّل الدالة لتحسب متوسط طول الكلمة، وتُرجع أطول ثلاث كلمات، لا أطول كلمة واحدة. فكر في كيفية التعامل مع التعادل.
التحدي 5: مخزن LRU بسيط (متوسط→متقدم)
المشكلة
صمّم صنفًا LRUCache (Least Recently Used)
بسعة محددة، يدعم عمليتين:
-
get(key): يُرجع القيمة، أو-1إذا لم يكن المفتاح موجودًا. أي وصول ناجح يجعل المفتاح «الأحدث استخدامًا». -
put(key, value): يُضيف أو يُحدّث القيمة. إذا امتلأ المخزن، يُحذف المفتاح الأقل استخدامًا.
أمثلة
cache = LRUCache(2)
cache.put(1, 10)
cache.put(2, 20)
cache.get(1) # 10 (يُصبح 1 الأحدث)
cache.put(3, 30) # يحذف 2 (الأقل استخدامًا)
cache.get(2) # -1
cache.get(1) # 10
cache.get(3) # 30 الفخاخ الشائعة
- نسيان تحديث الترتيب في get. مجرد قراءة قيمة يجب أن يغير أولوية الحذف. هذا الفخ يسبب أخطاء منطقية صامتة.
- نسيان تحديث القيمة.
putعلى مفتاح موجود يجب أن يغير القيمة و الترتيب. - السعة صفر. يجب أن تتعامل مع
LRUCache(0)دون انكسار. - الاعتماد على سلوك غير موثق.
dictفي بايثون 3.7+ يحافظ على ترتيب الإدراج، لكن لا تعتمد على ذلك دون أن تفهمه.
الحل
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
if capacity < 0:
raise ValueError("Capacity must be non-negative")
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
if self.capacity == 0:
return
if key in self.data:
self.data.move_to_end(key)
self.data[key] = value
if len(self.data) > self.capacity:
self.data.popitem(last=False) لماذا هذا الحل صحيح؟
OrderedDict يوفر move_to_end
(لنقل المفتاح إلى نهاية الترتيب) وpopitem(last=False)
(لحذف الأقدم). هذا يحقق شرط LRU دون بناء قائمة مرتبطة
يدويًا. لاحظ أننا نعالج capacity == 0
صراحةً، لأن popitem على قاموس فارغ يسبب
استثناء.
بديل يستخدم dict العادي ممكن أيضًا، لأن
الإصدارات الحديثة تحافظ على الترتيب:
class LRUCache:
def __init__(self, capacity):
self.capacity = max(0, capacity)
self.data = {}
def get(self, key):
if key not in self.data:
return -1
value = self.data.pop(key)
self.data[key] = value # إعادة الإدراج في النهاية
return value
def put(self, key, value):
if self.capacity == 0:
return
if key in self.data:
self.data.pop(key)
self.data[key] = value
if len(self.data) > self.capacity:
oldest = next(iter(self.data))
del self.data[oldest] التحدي الإضافي
أضف طريقة peek(key) تُرجع القيمة دون تحديث
ترتيب الاستخدام. فكر في الفرق الدلالي بينها وبين
get، ومتى تحتاج كلًا منهما.
أنماط مشتركة بين التحديات الخمسة
التحديات ليست منفصلة. هي تستخدم أنماطًا متكررة ستعود في كل كود تكتبه:
| النمط | أين ظهر | القاعدة |
|---|---|---|
| التحقق قبل المعالجة | التحدي 1 | اعزل المدخلات الصالحة أولًا، ثم اعمل عليها |
| الترتيب مع السرعة | التحدي 2 | قائمة أو dict للترتيب + set للبحث |
| الحرف الأخير | التحدي 3 | عالج آخر عنصر خارج الحلقة دائمًا |
| الترتيب الحتمي | التحدي 4 | حدد قاعدة تعادل صريحة، لا تعتمد على الترتيب العشوائي |
| الحالة الصفرية | التحدي 5 | كل صنف أو دالة تحتاج اختبارًا للسعة/المدخل صفر |
كيف تطور حلولك بعد هذا المقال؟
الحل الصحيح هو نقطة البداية، لا النهاية. هذه أربع خطوات تجعل حلك “مقبولًا” يصبح “ممتازًا”:
- اختبارات. اكتب اختبارات
unittestأوpytestلكل حالة حدية: فارغ، عنصر واحد، تكرار، مدخل غير صالح. - قياس. استخدم
time.perf_counter()لقياس أداء دالتك على مدخل كبير. تعلم أن تلاحظ الفرق بينO(n)وO(n²). - التحسين. بعد أن يعمل، اسأل: هل يمكن أن يعمل أسرع؟ هل يمكن أن يكون أنظف؟ هل يمكن أن يكون أسهل في القراءة؟
- إعادة الاستخدام. حوّل الحل إلى دالة أو صنف قابل للاستيراد، مع docstrings وشروح نوعية (type hints). هذا يحوّلك من “متدرب” إلى “مهندس”.
قائمة تحقق نهائية
- هل حاولت كل تحدٍّ 10–15 دقيقة قبل قراءة الحل؟ ← الهدف التعلم، لا الحل
- هل اختبرت القائمة الفارغة في كل دالة؟ ← أشهر سبب لانهيار الكود
- هل عالجت القيم غير الصالحة (نصوص، سالب، أكبر من 100)؟ ← التحقق قبل المعالجة
- هل حافظت على الترتيب عندما كان مطلوبًا؟ ← set لا يحفظ الترتيب
- هل عالجت الحرف الأخير في RLE؟ ← الخطأ الصامت الأكثر شيوعًا
- هل استخدمت قاعدة تعادل صريحة في top_words؟ ← النتائج غير الحتمية خطر
- هل تعاملت مع السعة صفر في LRU؟ ← حالة حدية ينساها الكثيرون
- هل كتبت اختبارات لحلك؟ ← الاختبار جزء من الحل، لا إضافة
- هل جرّبت التحدي الإضافي في كل تحدٍّ؟ ← هو ما يجعلك تتقدم
- هل قست أداء حلك على مدخل كبير؟ ← الفرق بين O(n) وO(n²) حاسم
روابط ومصادر مفيدة
فيما يلي روابط لمصادر يمكنك استخدامها لتعميق التدريب:
- Python Tutorial — التوثيق الرسمي
- collections — Counter وOrderedDict
- unittest — إطار الاختبار الرسمي
- pytest — إطار اختبار عملي
- LeetCode — تحديات برمجية متدرجة
- Exercism — تمارين بايثون مع مراجعة
- HackerRank — مسار بايثون
- Real Python — دروس عملية
أسئلة شائعة
هل هذه التحديات كافية لمقابلة عمل؟
هي بداية جيدة، لكن المقابلات التقنية تغطي أيضًا هياكل البيانات (المكدس، الطابور، الشجرة)، والخوارزميات (البحث، الترتيب، البرمجة الديناميكية)، وتحليل التعقيد. هذه التحديات تؤسس مهارة “التفكير بالحالات الحدية”، وهي مهارة ستستخدمها في كل سؤال لاحق.
هل أستخدم مكتبات جاهزة أم أكتب كل شيء بنفسي؟
تعلم أولًا بكتابة الحل يدويًا، ثم استخدم المكتبة. هذا
يجعلك تفهم ما تفعله المكتبة، ويعطيك حكمًا لاختيار
الأداة المناسبة. مثلًا، Counter يختصر
سطورًا، لكن كتابة عدّاد يدويًا تعلمك dict
وsetdefault.
لماذا التحدي 5 أصعب من الباقي؟
لأنه يتطلب تصميم حالة (الحفاظ على الترتيب عبر العمليات) لا مجرد تحويل مدخلات. هذا النوع من التفكير هو ما يفصل المبتدئ عن المتوسط. ابدأ بحل بسيط، ثم حسّنه.
ما الوقت المناسب لحل كل تحدٍّ؟
لا يوجد معيار. المهم أن تحاول قبل قراءة الحل. إذا استغرق تحدٍّ أكثر من 30 دقيقة دون تقدم، اقرأ أول سطرين من الحل، ثم أكمل بنفسك. التعلم يحدث في المحاولة، لا في القراءة.
كيف أعرف أن حلي “جيد”؟
المعايير ثلاثة: الصحة (ينجح مع كل الحالات الحدية)، الوضوح (يستطيع مطور آخر فهمه في دقيقة)، والكفاءة (لا يستهلك وقتًا أو ذاكرة أكثر من اللازم). الحل الذي ينجح في المثال فقط، وليس في الحالات الحدية، ليس جيدًا.