مجموع المسار والسلف المشترك الأدنى
حل root-to-leaf path sum وall-paths-sum وlowest-common-ancestor لشجرة ثنائية عامة باستخدام النزول الذاتي
مجموع المسار والسلف المشترك الأدنى درس مجاني في DSA Interview Prep على CoddyKit. هذا هو الدرس 4 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في DSA Interview Prep، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.
مجموع المسار من الجذر إلى الورقة
يسأل مشكلة مجموع المسار عمّا إذا كان مجموع أي مسار من الجذر إلى الورقة يساوي قيمة مستهدفة. مرّروا القيمة المستهدفة المتبقية عبر الاستدعاءات العودية، مع طرح قيمة كل عقدة. عند الوصول إلى ورقة، تحقّقوا مما إذا كانت القيمة المتبقية تساوي قيمة الورقة. يجنّبكم هذا الاحتفاظ بقائمة مسار صريحة، كما أنه فعّال من حيث المساحة وواضح. حالة خاصة: الشجرة الفارغة لا تحتوي على مسارات، لذا أعيدوا False فورًا.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def has_path_sum(root, target):
if not root:
return False
if not root.left and not root.right: # leaf
return root.val == target
remain = target - root.val
return (has_path_sum(root.left, remain) or
has_path_sum(root.right, remain))
root = TreeNode(5)
root.left = TreeNode(4)
root.right = TreeNode(8)
root.left.left = TreeNode(11)
root.left.left.left = TreeNode(7)
root.left.left.right = TreeNode(2)
print(has_path_sum(root, 22)) # True: 5->4->11->2جميع المسارات من الجذر إلى الورقة
من أجل حصر جميع المسارات، احتفظوا بقائمة للمسار الجاري. عند كل استدعاء عودي، أضيفوا قيمة العقدة الحالية، ثم استدعوا العقدتين الابنتين عوديًا، وبعد العودة نفّذوا pop (تراجعًا). عند الوصول إلى ورقة، سجّلوا نسخة ثابتة (list(path)) من المسار الحالي. هذا النمط — اختيار، ثم استدعاء عودي، ثم إلغاء الاختيار — هو أساس التراجع في الأشجار.
def all_path_sums(root, target):
results = []
def dfs(node, path, remaining):
if not node:
return
path.append(node.val)
if not node.left and not node.right and remaining == node.val:
results.append(list(path)) # snapshot
else:
dfs(node.left, path, remaining - node.val)
dfs(node.right, path, remaining - node.val)
path.pop() # backtrack
dfs(root, [], target)
return results
root = TreeNode(5)
root.left = TreeNode(4)
root.right = TreeNode(8)
root.left.left = TreeNode(11)
root.left.left.right = TreeNode(2)
root.right.right = TreeNode(5)
print(all_path_sums(root, 22)) # [[5,4,11,2]]مجموع المسار III: أي مسار من أي عقدة
تحسب مشكلة مجموع المسار III (LeetCode #437) عدد المسارات التي يساوي مجموعها قيمة مستهدفة، حيث يمكن أن يبدأ المسار وينتهي في أي مكان (وليس بالضرورة من الجذر إلى الورقة). الحل بالقوة الغاشمة بزمن O(n²): تنفيذ DFS انطلاقًا من كل عقدة. أما الحل الأمثل بزمن O(n)، فيستخدم خريطة تجزئة للمجموع التراكمي: تتبّعوا المجموع الجاري، واحسبوا عدد مرات ظهور current_sum - target سابقًا، على غرار أسلوب مجموع المصفوفات الجزئية.
def path_sum_iii(root, target):
prefix_counts = {0: 1}
def dfs(node, running_sum):
if not node:
return 0
running_sum += node.val
count = prefix_counts.get(running_sum - target, 0)
prefix_counts[running_sum] = prefix_counts.get(running_sum, 0) + 1
count += dfs(node.left, running_sum)
count += dfs(node.right, running_sum)
prefix_counts[running_sum] -= 1 # backtrack
return count
return dfs(root, 0)
root = TreeNode(10)
root.left = TreeNode(5)
root.right = TreeNode(-3)
root.left.left = TreeNode(3)
root.left.right = TreeNode(2)
root.right.right = TreeNode(11)
root.left.left.left = TreeNode(3)
root.left.left.right = TreeNode(-2)
root.left.right.right = TreeNode(1)
print(path_sum_iii(root, 8)) # 3ما أدنى سلف مشترك؟
أدنى سلف مشترك (LCA) لعقدتين p وq في شجرة ثنائية هو أعمق عقدة يكون كل من p وq من سلالها (يمكن للعقدة أن تكون سلفًا لنفسها). يظهر LCA في مسائل مثل «المسافة بين عقدتين» و«المسار بين عقدتين» واستعلامات النطاق في BST. ويُعد فهم LCA ضروريًا لمسائل الأشجار المتوسطة المستوى.
# 3
# / \
# 5 1
# / \ / \
# 6 2 0 8
# / \
# 7 4
# LCA(5, 1) = 3 (root)
# LCA(5, 4) = 5 (p itself is ancestor of q)
# LCA(6, 4) = 5
# LCA(7, 4) = 2
# Key insight: the LCA is the node where p and q
# first 'split' into different subtrees.
print('LCA: deepest node that is ancestor of both p and q')الخوارزمية العودية لـ LCA
يعيد حل LCA العودي الأنيق أول عقدة تكون إما p أو q، أو تحتوي على كلتيهما في شجرتيها الفرعيتين. إذا كانت العقدة الحالية هي p أو q، فأعيدوها. وإلا، فاستدعوا العقدتين الفرعيتين اليسرى واليمنى عوديًا. إذا أعاد الجانبان قيمة غير فارغة، فالعقدة الحالية هي LCA. وإذا أعاد أحد الجانبين فقط قيمة غير فارغة، فمرّروا تلك النتيجة إلى الأعلى. يعمل هذا الحل بزمن O(n) ومساحة O(h).
def lowest_common_ancestor(root, p, q):
# Base case: empty or found one of the targets
if not root or root == p or root == q:
return root
# Search both subtrees
left = lowest_common_ancestor(root.left, p, q)
right = lowest_common_ancestor(root.right, p, q)
# If both sides found something, this node is the LCA
if left and right:
return root
# Otherwise, return whichever side found something
return left if left else right
root = TreeNode(3)
root.left = TreeNode(5)
root.right = TreeNode(1)
root.left.left = TreeNode(6)
root.left.right = TreeNode(2)
p, q = root.left, root.right # 5 and 1
lca = lowest_common_ancestor(root, p, q)
print(lca.val) # 3LCA عندما يمكن للعقدة أن تكون سلفًا لنفسها
حالة خاصة مهمة: إذا كانت p سلفًا لـ q (أو العكس)، فإن LCA هي p نفسها. تتعامل الخوارزمية العودية مع ذلك تلقائيًا — فعندما تصل إلى p، تعيد p فورًا من دون فحص الشجرتين الفرعيتين لـ p. سترى العقدة الأب أن أحد الجانبين أعاد p والآخر أعاد قيمة فارغة، فتمرّر p إلى الأعلى باعتبارها LCA. تحقّقوا دائمًا من هذه الحالة ضمن اختباراتكم عند كتابة LCA.
# Test case: p is ancestor of q
# Tree: 3 -> left=5 -> left=6
# LCA(5, 6) should be 5
root = TreeNode(3)
root.left = TreeNode(5)
root.left.left = TreeNode(6)
p = root.left # node 5
q = root.left.left # node 6
lca = lowest_common_ancestor(root, p, q)
print(lca.val) # 5 (p itself is the LCA)LCA باستخدام مؤشرات الأب
إذا كانت لكل عقدة مؤشر أب، تتحول مسألة LCA إلى مسألة «تقاطع قائمتين مرتبطتين». اجمعوا أسلاف p في مجموعة، ثم تحركوا صعودًا بدءًا من q حتى تجدوا عقدة موجودة في تلك المجموعة. يُستخدم هذا النهج، الذي يعمل بزمن O(h) ومساحة O(h)، كثيرًا في مقابلات تصميم الأنظمة عندما تتحكمون في بنية العقد ويمكنكم تخزين مراجع الآباء.
class NodeWithParent:
def __init__(self, val, parent=None):
self.val = val
self.parent = parent
self.left = None
self.right = None
def lca_with_parent(p, q):
ancestors = set()
# Collect all ancestors of p
node = p
while node:
ancestors.add(node)
node = node.parent
# Walk up from q until we hit a known ancestor
node = q
while node:
if node in ancestors:
return node
node = node.parent
return None
print('With parent pointers: O(h) time and space')LCA في شجرة بحث ثنائية
تكون LCA أبسط في BST، لأن خاصية الترتيب تخبركم بالشجرة الفرعية التي تحتوي على كل عقدة. إذا كانت p وq أصغر من العقدة الحالية، فإن LCA موجودة في الشجرة الفرعية اليسرى. وإذا كانتا أكبر منها، فهي موجودة في الشجرة الفرعية اليمنى. أما في غير ذلك، فالعقدة الحالية تفصل بينهما، ولذلك تكون هي LCA. ويخفض هذا المشكلة إلى O(log n) في أشجار BST المتوازنة.
def lca_bst(root, p, q):
if not root:
return None
if p.val < root.val and q.val < root.val:
return lca_bst(root.left, p, q) # both in left
if p.val > root.val and q.val > root.val:
return lca_bst(root.right, p, q) # both in right
return root # split point = LCA
# Iterative BST LCA (no recursion overhead):
def lca_bst_iter(root, p, q):
while root:
if p.val < root.val and q.val < root.val:
root = root.left
elif p.val > root.val and q.val > root.val:
root = root.right
else:
return root
return None
print('BST LCA: O(log n) for balanced trees')المسافة بين عقدتين
تساوي المسافة بين عقدتين في شجرة عدد الحواف في المسار الذي يربط بينهما. ويمكن حسابها مباشرةً باستخدام LCA: distance(p, q) = depth(p) + depth(q) - 2 * depth(LCA(p,q)). أوجدوا LCA أولًا، ثم احسبوا عمق كل عقدة. وباستخدام دالة مساعدة مناسبة، يعمل هذا الحل بزمن O(n) ومساحة O(h).
def find_depth(root, target, depth=0):
if not root:
return -1
if root == target:
return depth
left = find_depth(root.left, target, depth + 1)
if left != -1:
return left
return find_depth(root.right, target, depth + 1)
def node_distance(root, p, q):
lca = lowest_common_ancestor(root, p, q)
# depth from LCA to p and q
dp = find_depth(lca, p)
dq = find_depth(lca, q)
return dp + dq
root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)
print(node_distance(root, root.left.left, root.left.right)) # 2أقصى مجموع لمسار من الجذر إلى الورقة
يتتبّع المسار ذي أكبر مجموع من الجذر إلى الورقة المجموع الجاري من الجذر إلى العقدة الحالية. عند الأوراق، قارنوا هذا المجموع بأقصى قيمة عامة. هذا اجتياز DFS بترتيب ما قبل الترتيب، حيث يُمرّر مجموع المسار الحالي كمعامل. وبخلاف مجموع المسار الأقصى العام، يقتصر هذا الإصدار على المسارات من الجذر إلى الورقة، لذا فهو أبسط — فلا حاجة إلى مراعاة المسارات العشوائية بين العقد.
def max_root_to_leaf_sum(root):
if not root:
return float('-inf')
best = [float('-inf')]
def dfs(node, running):
running += node.val
if not node.left and not node.right: # leaf
best[0] = max(best[0], running)
return
if node.left:
dfs(node.left, running)
if node.right:
dfs(node.right, running)
dfs(root, 0)
return best[0]
root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)
print(max_root_to_leaf_sum(root)) # 1+2+5 = 8مجموع الأعداد من الجذر إلى الأوراق
تتعامل مشكلة مجموع الأعداد من الجذر إلى الأوراق (LeetCode #129) مع كل مسار من الجذر إلى الورقة باعتباره عددًا عشريًا (فمثلًا، يمثّل المسار 1→2→3 العدد 123)، وتطلب حساب مجموع هذه الأعداد. ابنوا العدد بتمرير current_number * 10 + node.val عبر الاستدعاءات العودية. عند كل ورقة، أضيفوا العدد المكتمل إلى المجموع الكلي. هذا مثال واضح على اجتياز DFS بترتيب ما قبل الترتيب مع تمرير الحالة المتراكمة إلى الأسفل.
def sum_numbers(root):
def dfs(node, num):
if not node:
return 0
num = num * 10 + node.val
if not node.left and not node.right: # leaf
return num
return dfs(node.left, num) + dfs(node.right, num)
return dfs(root, 0)
root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
print(sum_numbers(root)) # 12 + 13 = 25
root2 = TreeNode(4)
root2.left = TreeNode(9)
root2.right = TreeNode(0)
root2.left.left = TreeNode(5)
root2.left.right = TreeNode(1)
print(sum_numbers(root2)) # 495 + 491 + 40 = 1026تحقق سريع
اختبروا فهمكم لمفاهيم Data Structures & Algorithms — Coding Interview Prep الواردة في هذا الدرس.
مراجعة الدرس
تعلّمتم في هذا الدرس: تنويعات مجموع المسار (من الجذر إلى الورقة، وجميع المسارات، ومجموع المسار III باستخدام المجاميع التراكمية)، وأدنى سلف مشترك باستخدام التقسيم العودي الأنيق، وLCA في BST بزمن O(log n) باستخدام خاصية الترتيب. بعد ذلك سنبدأ أشجار البحث الثنائية بعمليتي الإدراج والبحث.
الأسئلة الشائعة
هل درس «مجموع المسار والسلف المشترك الأدنى» مجاني؟
نعم — نص درس «مجموع المسار والسلف المشترك الأدنى» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة DSA Interview Prep، انتقل إلى CoddyKit PRO. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.
ماذا ستتعلم في «مجموع المسار والسلف المشترك الأدنى»؟
حل root-to-leaf path sum وall-paths-sum وlowest-common-ancestor لشجرة ثنائية عامة باستخدام النزول الذاتي تتمرن على DSA Interview Prep مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.
هل أحتاج إلى خبرة سابقة لأبدأ DSA Interview Prep؟
لا تُشترط خبرة سابقة. DSA Interview Prep على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 4 من أصل 4.
كم من الوقت يستغرق درس «مجموع المسار والسلف المشترك الأدنى»؟
معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.
هل يمكنني كتابة وتشغيل أكواد في درس DSA Interview Prep هذا؟
نعم. كل درس في DSA Interview Prep يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.
جميع الدروس في هذه الدورة
- فئة TreeNode واجتياز BFS حسب المستويات
- DFS بالترتيب الوسطي والقبلي والبعدي
- قطر الأشجار وارتفاعها وتوازنها
- مجموع المسار والسلف المشترك الأدنى