تقسيم الكلمات وتقسيم السلسلة
استخدم جدول DP أحادي البعد لتحديد إمكانية تقسيم سلسلة إلى كلمات قاموس، وحلّل الزمن O(n²) وسبب تسريع trie له
تقسيم الكلمات وتقسيم السلسلة درس مجاني في DSA Interview Prep على CoddyKit. هذا هو الدرس 3 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في DSA Interview Prep، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.
مسألة Word Break
تطلب مسألة Word Break (LeetCode 139) ما يلي: بالنظر إلى سلسلة نصية s وقاموس من الكلمات، حدّدوا ما إذا كان يمكن تقسيم s إلى تسلسل من كلمة واحدة أو أكثر من كلمات القاموس، تفصل بينها مسافات. فمثلًا، مع s = 'leetcode' وwordDict = ['leet', 'code']، تكون الإجابة True لأن 'leet' + 'code' = 'leetcode'. هذه مسألة كلاسيكية في DP أحادي البعد.
s = 'leetcode'
word_set = {'leet', 'code'}
# Can we split 'leetcode' into words from word_set?
# 'leet' in set → yes, 'code' in set → yes
# So: 'leetcode' = 'leet' + 'code' → True
s2 = 'catsandog'
word_set2 = {'cats', 'dog', 'sand', 'and', 'cat'}
# No matter how we split, last part 'og' not in dict
print('Expected: True, False')صياغة DP والحالة
عرّفوا dp[i] على أنها True إذا أمكن تقسيم السلسلة الفرعية s[:i] باستخدام القاموس. حالة الأساس هي dp[0] = True، لأن السلسلة الفارغة يمكن تقسيمها دائمًا. لكل موضع i، تحقّقوا من جميع المواضع j < i: إذا كانت dp[j] تساوي True وكانت s[j:i] موجودة في القاموس، فاجعلوا dp[i] = True. الإجابة النهائية هي dp[len(s)].
def word_break(s, word_dict):
word_set = set(word_dict)
n = len(s)
dp = [False] * (n + 1)
dp[0] = True # empty string
for i in range(1, n + 1):
for j in range(i):
# If s[:j] is segmentable AND s[j:i] is a word
if dp[j] and s[j:i] in word_set:
dp[i] = True
break # no need to check other j values
return dp[n]
print(word_break('leetcode', ['leet', 'code'])) # True
print(word_break('catsandog', ['cats','dog','sand','and','cat'])) # Falseتتبّع جدول DP
بالنسبة إلى s = 'leetcode' وdict {'leet', 'code'}: dp[0]=T. عند i=4: j=0، وdp[0]=T، وs[0:4]='leet' موجودة في dict → dp[4]=T. عند i=8: j=4، وdp[4]=T، وs[4:8]='code' موجودة في dict → dp[8]=T. تظل جميع المواضع الأخرى التي لا تنتهي عندها أي كلمة مساوية لـ False. تؤكد الإجابة dp[8]=True إمكانية تقسيم السلسلة.
def word_break_trace(s, word_dict):
word_set = set(word_dict)
n = len(s)
dp = [False] * (n + 1)
dp[0] = True
for i in range(1, n + 1):
for j in range(i):
if dp[j] and s[j:i] in word_set:
dp[i] = True
print(f'dp[{i}]=True via s[{j}:{i}]={repr(s[j:i])}')
break
print('dp table:', dp)
return dp[n]
word_break_trace('leetcode', ['leet', 'code'])تحليل التعقيد الزمني
يعمل DP الساذج في زمن O(n²): تكرارات خارجية عددها n، يرافق كلًا منها ما يصل إلى n تكرارًا داخليًا. لكن تقطيع s[j:i] يكلّف أيضًا O(n)، مما يجعل التعقيد الفعلي في Python هو O(n³). يتمثل أحد التحسينات في التكرار على كلمات القاموس والتحقق مما إذا كانت كل كلمة تنتهي عند الموضع i، مما يعطي O(n × W × L)، حيث W هو حجم القاموس وL هو متوسط طول الكلمة. بالنسبة إلى معظم مدخلات المقابلات، يكون O(n²) أو O(n³) مقبولًا.
# Slightly faster: iterate over words rather than all j positions
def word_break_v2(s, word_dict):
word_set = set(word_dict)
n = len(s)
dp = [False] * (n + 1)
dp[0] = True
for i in range(1, n + 1):
for word in word_set:
wl = len(word)
# Does 'word' end exactly at position i?
if i >= wl and dp[i - wl] and s[i - wl:i] == word:
dp[i] = True
break
return dp[n]
print(word_break_v2('applepenapple', ['apple', 'pen'])) # Trueبديل الاستدعاء التعاودي مع التخزين المؤقت
يمكن حل المسألة نفسها من الأعلى إلى الأسفل باستخدام التخزين المؤقت. عرّفوا دالة تعاودية هي can_break(start)، وتعيد True إذا أمكن تقسيم s[start:]. جرّبوا كل كلمة كبادئة لـ s[start:]، ثم نفّذوا الاستدعاء التعاودي على الجزء المتبقي. خزّنوا النتائج لتجنب استكشاف فهرس البداية نفسه عدة مرات. هذا مكافئ لنهج DP من الأسفل إلى الأعلى، لكنه قد يكون أسرع عمليًا إذا جرى استبعاد مواضع كثيرة مبكرًا.
from functools import lru_cache
def word_break_memo(s, word_dict):
word_set = set(word_dict)
@lru_cache(maxsize=None)
def can_break(start):
if start == len(s): return True
for end in range(start + 1, len(s) + 1):
if s[start:end] in word_set and can_break(end):
return True
return False
return can_break(0)
print(word_break_memo('leetcode', ['leet', 'code'])) # True
print(word_break_memo('catsandog', ['cats','dog','sand','and','cat'])) # Falseإرجاع جميع التقسيمات الصالحة
تطلب مسألة Word Break II (LeetCode 140) جميع التقسيمات الممكنة. ويتمثل النهج في التراجع مع التخزين المؤقت: نفّذوا الاستدعاء التعاودي بدءًا من كل موضع، وعندما تتطابق كلمة ما، نفّذوا الاستدعاء التعاودي على الجزء المتبقي. خزّنوا جميع النتائج الجزئية على شكل قوائم من السلاسل النصية. لتجنب TLE، احفظوا مؤقتًا قائمة الجمل الممكنة من كل فهرس بداية. قد يكون عدد الجمل أُسّيًا في أسوأ الحالات، لكن التخزين المؤقت يلغي الحسابات المتكررة.
from functools import lru_cache
def word_break_ii(s, word_dict):
word_set = set(word_dict)
@lru_cache(maxsize=None)
def break_from(start):
if start == len(s): return ['']
results = []
for end in range(start + 1, len(s) + 1):
word = s[start:end]
if word in word_set:
for rest in break_from(end):
results.append(word if not rest else word + ' ' + rest)
return results
return break_from(0)
print(word_break_ii('catsanddog', ['cat','cats','and','sand','dog']))
# ['cat sand dog', 'cats and dog']تحسين باستخدام Trie
عندما يكون القاموس كبيرًا أو تكون الكلمات طويلة، يصبح التحقق من s[j:i] in word_set لكل j بطيئًا بسبب تجزئة السلاسل النصية في Python. يتيح Trie السير عبر المحارف واحدًا تلو الآخر، مع استبعاد المسارات المستحيلة مبكرًا. وبدلًا من التحقق من جميع مواضع البداية وعددها O(n)، لا تتبعون سوى المسارات الموجودة في Trie. ويقلل ذلك زمن التنفيذ العملي بدرجة ملحوظة عندما لا تؤدي سوى بادئات قليلة إلى كلمات صالحة.
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
def build_trie(words):
root = TrieNode()
for word in words:
node = root
for ch in word:
node = node.children.setdefault(ch, TrieNode())
node.is_end = True
return root
def word_break_trie(s, word_dict):
root = build_trie(word_dict)
n = len(s)
dp = [False] * (n + 1)
dp[0] = True
for i in range(n):
if not dp[i]: continue
node = root
for j in range(i, n):
ch = s[j]
if ch not in node.children: break
node = node.children[ch]
if node.is_end:
dp[j + 1] = True
return dp[n]
print(word_break_trie('leetcode', ['leet', 'code'])) # Trueالحالات الطرفية والقيود
حالات طرفية مهمة: (1) السلسلة الفارغة: أعيدوا True، إذ يمكن تقسيم السلسلة الفارغة بصورة بديهية. (2) كلمة غير موجودة في القاموس: لا يعيّن DP الموضع المطابق إلى True، ويعيد False بشكل صحيح. (3) الكلمات المتداخلة: مثل 'a' و'aa' في dict مع s='aaa' — يتعامل DP مع ذلك طبيعيًا من خلال التحقق من جميع قيم j. (4) المحارف المتكررة: s='aaaaab' مع dict=['a','aa','aaa'] — توجد مسارات أُسّية، لكن التخزين المؤقت يحصرها في O(n²).
def word_break(s, word_dict):
word_set = set(word_dict)
dp = [False] * (len(s) + 1)
dp[0] = True
for i in range(1, len(s) + 1):
for j in range(i):
if dp[j] and s[j:i] in word_set:
dp[i] = True
break
return dp[len(s)]
# Edge cases
print(word_break('', ['hello'])) # True (empty string)
print(word_break('a', ['b'])) # False
print(word_break('aaa', ['a', 'aa'])) # True (many ways)تعميم تقسيم السلسلة
يمكن تعميم Word Break على أي مسألة من مسائل تقسيم السلاسل النصية: هل يمكن تقسيم السلسلة s وفق قاعدة ما؟ استبدلوا البحث في القاموس بأي تحقق بتعقيد O(1) أو O(L). فمثلًا: هل يمكن تقسيم s إلى سلاسل متناظرة؟ استخدموا جدولًا محسوبًا مسبقًا للسلاسل المتناظرة بدلًا من مجموعة الكلمات. تظل بنية DP متطابقة — ولا يتغير سوى التحقق من الصلاحية.
def palindrome_partition_possible(s):
'''Can s be partitioned into palindromes? (Always yes — single chars are palindromes)'''
n = len(s)
# Precompute palindrome table
is_pal = [[False]*n for _ in range(n)]
for i in range(n): is_pal[i][i] = True
for i in range(n-1): is_pal[i][i+1] = (s[i]==s[i+1])
for length in range(3, n+1):
for i in range(n-length+1):
j = i + length - 1
is_pal[i][j] = s[i]==s[j] and is_pal[i+1][j-1]
# DP similar to word break
dp = [False] * (n + 1)
dp[0] = True
for i in range(1, n + 1):
for j in range(i):
if dp[j] and is_pal[j][i-1]:
dp[i] = True
break
return dp[n]
print(palindrome_partition_possible('aab')) # True (a,a,b or aa,b)نهج DP مقابل نهج BFS
يمكن أيضًا صياغة Word Break بوصفها مسألة إيجاد أقصر مسار باستخدام BFS: كل موضع في السلسلة هو عقدة، وتوجد حافة من j إلى i إذا كانت s[j:i] موجودة في القاموس. ويسأل BFS بدءًا من العقدة 0 عما إذا كانت العقدة n قابلة للوصول. يعطي BFS التعقيد نفسه O(n² × L)، لكنه قد يكون أكثر وضوحًا إذا صغتم المسألة على شكل مسألة بيانية أثناء المقابلة.
from collections import deque
def word_break_bfs(s, word_dict):
word_set = set(word_dict)
n = len(s)
visited = set()
queue = deque([0])
while queue:
start = queue.popleft()
if start == n: return True
for end in range(start + 1, n + 1):
if end not in visited and s[start:end] in word_set:
visited.add(end)
queue.append(end)
return False
print(word_break_bfs('leetcode', ['leet', 'code'])) # True
print(word_break_bfs('catsandog', ['cats','dog','and','sand','cat'])) # Falseاستراتيجية التواصل في المقابلات
في المقابلة، اشرحوا مسار التفكير الآتي: (1) لاحظوا أن الخيارات عند كل موضع تعتمد على ما كان قابلًا للوصول سابقًا — وهذا يشير إلى DP. (2) حدّدوا الحالة: dp[i] = هل يمكن تقسيم s[:i]؟ (3) اذكروا العلاقة العودية وحالة الأساس قبل كتابة الكود. (4) اكتبوا حل O(n²) أولًا، ثم اذكروا تحسين Trie كخطوة لاحقة. (5) ناقشوا الحالات الطرفية: السلسلة الفارغة، والمحرف الواحد، والكلمة غير الموجودة في القاموس.
# Clean final solution to present in interview
def word_break(s, word_dict):
'''O(n^2 * L) time, O(n + W) space where W = total word length in dict'''
word_set = set(word_dict) # O(W) space
n = len(s)
dp = [False] * (n + 1) # O(n) space
dp[0] = True
for i in range(1, n + 1):
for j in range(i): # try all split points
if dp[j] and s[j:i] in word_set:
dp[i] = True
break
return dp[n]
# Time: O(n^2 * L) - n^2 pairs, each dict lookup is O(L)
# Space: O(n) for dp array, O(W) for word_set
print(word_break('applepenapple', ['apple', 'pen'])) # Trueاختبار سريع
اختبروا فهمكم للمفاهيم الواردة في هذا الدرس من Data Structures & Algorithms — Coding Interview Prep.
مراجعة الدرس
في هذا الدرس تعلمتم: أن dp[i] تمثل ما إذا كان يمكن تقسيم s[:i] إلى كلمات من القاموس، وأن العلاقة العودية O(n²) تتحقق من جميع نقاط التقسيم j حيث dp[j]=True وتكون s[j:i] ضمن مجموعة الكلمات، وأن Trie يمكنه تسريع الحلقة الداخلية من خلال استبعاد البادئات غير الموجودة مبكرًا. بعد ذلك سنستكشف مسألة Decode Ways وعدّ المسارات، وهي نمط آخر من أنماط DP أحادي البعد الشبيهة بمتتالية فيبوناتشي.
الأسئلة الشائعة
هل درس «تقسيم الكلمات وتقسيم السلسلة» مجاني؟
نعم — نص درس «تقسيم الكلمات وتقسيم السلسلة» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة DSA Interview Prep، انتقل إلى CoddyKit PRO. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.
ماذا ستتعلم في «تقسيم الكلمات وتقسيم السلسلة»؟
استخدم جدول DP أحادي البعد لتحديد إمكانية تقسيم سلسلة إلى كلمات قاموس، وحلّل الزمن O(n²) وسبب تسريع trie له تتمرن على DSA Interview Prep مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.
هل أحتاج إلى خبرة سابقة لأبدأ DSA Interview Prep؟
لا تُشترط خبرة سابقة. DSA Interview Prep على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 3 من أصل 4.
كم من الوقت يستغرق درس «تقسيم الكلمات وتقسيم السلسلة»؟
معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.
هل يمكنني كتابة وتشغيل أكواد في درس DSA Interview Prep هذا؟
نعم. كل درس في DSA Interview Prep يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.
جميع الدروس في هذه الدورة
- لص المنازل: علاقة تكرار الأخذ أو التخطي
- المصفوفة الجزئية العظمى والمصفوفة الجزئية ذات حاصل الضرب الأقصى
- تقسيم الكلمات وتقسيم السلسلة
- فك الترميز واحتساب المسارات