0Pricing
DSA Interview Prep · درس

مجموع المسار والسلف المشترك الأدنى

حل 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)  # 3

LCA عندما يمكن للعقدة أن تكون سلفًا لنفسها

حالة خاصة مهمة: إذا كانت 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 يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.

جميع الدروس في هذه الدورة

  1. فئة TreeNode واجتياز BFS حسب المستويات
  2. DFS بالترتيب الوسطي والقبلي والبعدي
  3. قطر الأشجار وارتفاعها وتوازنها
  4. مجموع المسار والسلف المشترك الأدنى
← العودة إلى DSA Interview Prep