0Pricing
DSA Interview Prep · درس

DSU مع ضغط المسار

نفّذ find مع ضغط المسار بحيث تشير جميع العقد الواقعة على المسار مباشرةً إلى الجذر، محققًا زمنًا مُستهلكًا قريبًا من O(1) لعملية find.

DSU مع ضغط المسار درس مجاني في DSA Interview Prep على CoddyKit. هذا هو الدرس 1 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في DSA Interview Prep، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.

ما هو اتحاد المجموعات المنفصلة؟

اتحاد المجموعات المنفصلة (DSU)، ويُسمى أيضًا Union-Find، هو بنية بيانات تحافظ على مجموعة من المجموعات المنفصلة (غير المتداخلة). وهو يدعم عمليتين أساسيتين: find (إلى أي مجموعة ينتمي العنصر x؟) وunion (دمج المجموعتين اللتين تحتويان على x وy). يُعد DSU مثاليًا لمسائل الاتصال الديناميكي التي تندمج فيها المجموعات بمرور الوقت دون أن تنقسم.

يبدأ كل عنصر كمجموعة مستقلة. ومع معالجة الحواف أو العلاقات، ندمج المجموعات معًا. يكمن التحدي في تنفيذ ذلك بكفاءة؛ إذ إن التطبيقات الساذجة تستغرق O(n) لكل عملية، لكننا نقترب من O(1) موزعة مع استخدام التحسينات.

# Naive DSU without optimisations
class DSU:
    def __init__(self, n):
        self.parent = list(range(n))  # each node is its own parent

    def find(self, x):
        while self.parent[x] != x:
            x = self.parent[x]
        return x

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py

مشكلة عملية find الساذجة

في DSU الساذجة، تتتبع عملية find(x) سلسلة الآباء صعودًا حتى تصل إلى عقدة تشير إلى نفسها (أي الجذر). إذا كانت الشجرة متوازنة، فإن هذه العملية تستغرق O(log n). لكن إذا كنا ندمج دائمًا بربط الجذر الثاني تحت الجذر الأول، فقد ننشئ سلسلة (شجرة متدهورة) طولها n، مما يجعل كل عملية find تستغرق O(n).

لنفترض أننا نضمّ 0→1→2→3→4 بالتتابع. يجب أن يعبر استدعاء find للعقدة 0 السلسلة بأكملها. باستخدام ضغط المسار، نتخلص من هذه المشكلة بجعل كل عقدة تمت زيارتها تشير مباشرةً إلى الجذر أثناء عملية find نفسها.

# Worst case without compression: a chain
# parent = [1, 2, 3, 4, 4]  => find(0) takes 4 steps
# After path compression: parent = [4, 4, 4, 4, 4]  => find(0) takes 1 step

parent = [1, 2, 3, 4, 4]
print('Before:', parent)
# Simulate find(0) with naive approach
x = 0
steps = 0
while parent[x] != x:
    x = parent[x]
    steps += 1
print('Root:', x, 'Steps taken:', steps)

ضغط المسار: عودي بتمرير واحد

يعدّل ضغط المسار عملية find بحيث تُحدَّث، بعد العثور على الجذر، كل عقدة على المسار لتشير مباشرةً إلى الجذر. وتصبح استدعاءات find المستقبلية لهذه العقد O(1). تحقق النسخة العودية ذلك بأناقة في تمريرة واحدة.

الفكرة الأساسية هي أنه بعد أن يعيد الاستدعاء العودي الجذر، نعيّن self.parent[x] = root قبل الإرجاع. يؤدي ذلك إلى تسطيح الشجرة، فتصبح جميع العقد الموجودة على مسار البحث تشير مباشرةً إلى الجذر. ولا يغيّر هذا المجموعة التي تنتمي إليها العقدة؛ بل يختصر مسارات البحث المستقبلية فقط.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # path compression
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py

dsu = DSU(5)
dsu.union(0, 1)
dsu.union(1, 2)
dsu.union(2, 3)
print('Root of 0:', dsu.find(0))
print('Parent array after compression:', dsu.parent)

ضغط المسار: تكراري بتمريرتين

تستخدم النسخة التكرارية من ضغط المسار تمريرتين: يصعد التمرير الأول حتى يعثر على الجذر، ثم يعيد التمرير الثاني زيارة كل عقدة في المسار ويحدّث والدها ليشير مباشرةً إلى الجذر. يتجنب ذلك كلفة مكدس الاستدعاءات العودية، ويكون آمنًا للشجرات شديدة العمق والقريبة من حد الاستدعاء العودي في Python.

في النهجين العودي والتكراري، لا يتغير الصواب؛ فما زالت find تعيد الجذر نفسه. والاختلاف الوحيد هو تحديث مؤشرات الآباء كأثر جانبي، مما يجعل جميع عمليات find المستقبلية على تلك العقد O(1).

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        root = x
        while self.parent[root] != root:
            root = self.parent[root]          # first pass: find root
        while self.parent[x] != root:
            nxt = self.parent[x]
            self.parent[x] = root             # second pass: compress
            x = nxt
        return root

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py
            return True
        return False  # already connected

dsu = DSU(6)
for a, b in [(0,1),(1,2),(2,3),(3,4)]:
    dsu.union(a, b)
print('Parent before find(0):', dsu.parent[:])
dsu.find(0)
print('Parent after  find(0):', dsu.parent[:])

التعقيد المُهَلْك لضغط المسار

يحقق ضغط المسار وحده زمنًا مُهَلْكًا قدره O(log n) لكل عملية على مدى سلسلة من m عمليات. قد تكون عملية find مكلفة في المرة الأولى التي تُجتاز فيها سلسلة، لكنها تسطّح تلك السلسلة، بحيث تصبح كل عملية find لاحقة على تلك العقد O(1). وتتوزع الكلفة الإجمالية على عدد كبير من العمليات.

يستخدم التحليل الرسمي طريقة دالة الجهد: ينخفض جهد DSU كلما أصبح والد عقدة أقرب، ويدفع هذا الانخفاض كلفة الاجتياز. ومن دون الدمج حسب الرتبة، يحقق ضغط المسار وحده زمنًا مُهَلْكًا قدره O(log n)، وهو تحسن هائل بالفعل مقارنةً بـ O(n) في الحالة الساذجة.

# Demonstrating amortised benefit
import time

def build_chain(n):
    parent = list(range(n))
    for i in range(n - 1):
        parent[i] = i + 1  # chain: 0->1->2->...->n-1
    return parent

n = 1000
parent = build_chain(n)

# First find on a chain: visits n nodes
x = 0
root = x
while parent[root] != root:
    root = parent[root]
# Compress
while parent[x] != root:
    nxt = parent[x]; parent[x] = root; x = nxt
print('After first find, parent[0]:', parent[0])  # should be n-1
print('Second find cost: O(1) since parent[0] is now the root')

حساب المكوّنات المتصلة

من التطبيقات الشائعة لـ DSU حساب المكوّنات المتصلة في رسم بياني. نهيّئ عدّادًا باسم components مساويًا لـ n، أي مكوّن واحد لكل عقدة. وكل عملية دمج ناجحة (تدمج مجموعتين مختلفتين) تنقص العدّاد بمقدار 1. وفي النهاية، يحتوي العدّاد على عدد المكوّنات المتميزة.

هذا أكثر كفاءة من تشغيل BFS أو DFS لاستعلامات الاتصال، ولا سيما عندما تصل الحواف تدريجيًا (online). تعالج DSU كل حافة في زمن مُهَلْك يقارب O(1)، بغض النظر عن وقت وصولها.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.components = n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False
        self.parent[px] = py
        self.components -= 1
        return True

dsu = DSU(7)
edges = [(0,1),(1,2),(3,4),(5,6)]
for u, v in edges:
    dsu.union(u, v)
print('Components:', dsu.components)  # 4: {0,1,2}, {3,4}, {5,6}, {6 alone was merged}
# Node 6 is alone => 4 total: {0,1,2},{3,4},{5,6},{6} wait
# Let me recalculate: 7 nodes, 4 edges merged 4 pairs => 7-4=3... no
# {0,1,2} one union, {3,4} one, {5,6} one => 7-3=4 components
print('Expected: 4')

DSU لمسائل الرسوم البيانية: عدد المقاطعات

تعطي مسألة عدد المقاطعات مصفوفة تجاور n×n، وتسأل عن عدد مجموعات المدن المتصلة مباشرةً أو بصورة غير مباشرة. وهذه مسألة مكوّنات متصلة تمامًا، وتحلها DSU ببساطة. نكرّر على جميع الأزواج (i, j) التي تحقق isConnected[i][j] == 1، ونستدعي union(i, j).

بعد معالجة جميع الاتصالات، تكون قيمة dsu.components هي الإجابة. وهذا أبسط وأسرع من تشغيل BFS انطلاقًا من كل عقدة لم تتم زيارتها، كما أنه يتعامل مباشرةً مع تمثيل المصفوفة من دون إنشاء قائمة تجاور أولًا.

def find_provinces(isConnected):
    n = len(isConnected)
    parent = list(range(n))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(x, y):
        px, py = find(x), find(y)
        if px != py:
            parent[px] = py
            return True
        return False

    count = n
    for i in range(n):
        for j in range(i + 1, n):
            if isConnected[i][j] == 1:
                if union(i, j):
                    count -= 1
    return count

matrix = [[1,1,0],[1,1,0],[0,0,1]]
print(find_provinces(matrix))  # 2: cities {0,1} and {2}

متغيرات ضغط المسار: تنصيف المسار

إلى جانب الضغط ذي التمريرتين، يوجد متغير أبسط بتمرير واحد يسمى تنصيف المسار: أثناء صعودنا السلسلة، نجعل كل عقدة تشير إلى جدّها بدلًا من والدها. ويؤدي ذلك إلى تنصيف طول المسار في كل اجتياز من دون تمرير ثانٍ، كما يحقق التعقيد المُهَلْك نفسه O(alpha(n)) عند دمجه مع الدمج حسب الرتبة.

غالبًا ما يُفضَّل تنصيف المسار في البرمجة التنافسية، لأنه حلقة واحدة واضحة لا تتطلب عودية أو اجتيازًا ثانيًا. وتنفذ كل خطوة self.parent[x] = self.parent[self.parent[x]]; x = self.parent[x].

class DSUHalving:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]  # point to grandparent
            x = self.parent[x]
        return x

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False
        if self.rank[px] < self.rank[py]:
            px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
        return True

dsu = DSUHalving(8)
for u, v in [(0,1),(2,3),(4,5),(6,7),(0,2),(4,6),(0,4)]:
    dsu.union(u, v)
print('All in one component:', dsu.find(0) == dsu.find(7))

التحقق من الاتصال بعد عمليات الدمج

للتحقق مما إذا كانت عقدتان متصلتين (أي في المكوّن نفسه)، نستدعي find(x) == find(y). فإذا أعاد الاستدعاءان الجذر نفسه، كانت العقدتان في المكوّن نفسه. وهذا هو استعلام الاتصال، ومع ضغط المسار يُنفَّذ في زمن مُهَلْك يقترب من O(1).

في مسائل المقابلات، غالبًا ما تتداخل استعلامات الاتصال مع عمليات الدمج. تتعامل DSU مع كليهما online، إذ يمكن التناوب بين عمليات الدمج والاستعلامات بأي ترتيب. وهذا ما يميز DSU عن خوارزميات الرسوم البيانية الساكنة مثل BFS وDFS، التي يجب إعادة تشغيلها بعد كل تغيير بنيوي.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py

    def connected(self, x, y):
        return self.find(x) == self.find(y)

dsu = DSU(10)
dsu.union(0, 3)
dsu.union(3, 7)
dsu.union(1, 5)
print(dsu.connected(0, 7))   # True: 0-3-7
print(dsu.connected(0, 5))   # False: different components
print(dsu.connected(1, 5))   # True: 1-5

الأخطاء الشائعة في تنفيذ DSU

من الأخطاء المتكررة استدعاء find ثم تعديل parent بطريقة غير صحيحة. احرص دائمًا على استدعاء find على العنصرين قبل التحقق من تساويهما، وإلا فقد تقارن عقدة بجذرها هي بطريقة غير صحيحة. ومن الأخطاء الأخرى نسيان أن تكون union عمليةً بلا تأثير عندما يشترك العنصران أصلًا في الجذر نفسه.

في Python، قد يؤدي حد عمق الاستدعاء العودي (الافتراضي 1000) إلى حدوث RecursionError عند وجود سلاسل كبيرة مع find العودية. ويمكنك استخدام النسخة التكرارية ذات التمريرتين، أو زيادة الحد باستخدام sys.setrecursionlimit، أو استخدام تنصيف المسار تكراريًا لتجنب العودية العميقة تمامًا.

import sys
sys.setrecursionlimit(10000)  # needed for large recursive DSU

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        # Safe iterative path compression
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        while self.parent[x] != root:
            nxt = self.parent[x]
            self.parent[x] = root
            x = nxt
        return root

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False  # already same component — do nothing
        self.parent[px] = py
        return True

dsu = DSU(5)
print(dsu.union(0, 1))  # True: merged
print(dsu.union(0, 1))  # False: already merged — no double-counting

تتبّع أحجام مكوّنات DSU

في بعض المسائل، تحتاج إلى حجم كل مكوّن، وليس جذره فقط. أضف مصفوفة size مهيّأة بحيث تكون جميع قيمها 1. عند دمج مكوّنين، أضف حجم الجذر الأصغر إلى الجذر الأكبر. يتيح ذلك إجراء استعلامات حجم المكوّن في O(1) بعد أي عملية دمج.

يمثل تتبّع الأحجام أيضًا أساس الدمج حسب الحجم (وهو بديل للدمج حسب الرتبة): ألحِق دائمًا الشجرة الأصغر بجذر الشجرة الأكبر. وهذا يضمن بقاء ارتفاع الشجرة ضمن O(log n)، مما يوفر الضمان التقاربي نفسه الذي يوفّره الدمج حسب الرتبة.

class DSUWithSize:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return
        if self.size[px] < self.size[py]:
            px, py = py, px           # attach smaller under larger
        self.parent[py] = px
        self.size[px] += self.size[py]

    def get_size(self, x):
        return self.size[self.find(x)]

dsu = DSUWithSize(6)
for u, v in [(0,1),(1,2),(3,4)]:
    dsu.union(u, v)
print('Size of component containing 0:', dsu.get_size(0))  # 3
print('Size of component containing 3:', dsu.get_size(3))  # 2
print('Size of component containing 5:', dsu.get_size(5))  # 1

تحقق سريع

اختبر مدى فهمك لمفاهيم Data Structures & Algorithms — Coding Interview Prep الواردة في هذا الدرس.

مراجعة الدرس

في هذا الدرس تعلمت أن: DSU تدير مجموعات منفصلة باستخدام عمليتي find وunion، وضغط المسار يسطّح الشجرة بجعل جميع العقد التي عُبِرت تشير مباشرةً إلى الجذر، وهذا يوفر أداءً مُهَلْكًا لعملية find يقترب من O(1). بعد ذلك سنستكشف الدمج حسب الرتبة، الذي يحافظ على ضحالة الأشجار من الأعلى إلى الأسفل ليحقق حد دالة أكرمان العكسية.

الأسئلة الشائعة

هل درس «DSU مع ضغط المسار» مجاني؟

نعم — نص درس «DSU مع ضغط المسار» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة DSA Interview Prep، انتقل إلى CoddyKit PRO. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.

ماذا ستتعلم في «DSU مع ضغط المسار»؟

نفّذ find مع ضغط المسار بحيث تشير جميع العقد الواقعة على المسار مباشرةً إلى الجذر، محققًا زمنًا مُستهلكًا قريبًا من O(1) لعملية find. تتمرن على DSA Interview Prep مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.

هل أحتاج إلى خبرة سابقة لأبدأ DSA Interview Prep؟

لا تُشترط خبرة سابقة. DSA Interview Prep على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 1 من أصل 4.

كم من الوقت يستغرق درس «DSU مع ضغط المسار»؟

معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.

هل يمكنني كتابة وتشغيل أكواد في درس DSA Interview Prep هذا؟

نعم. كل درس في DSA Interview Prep يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.

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

  1. DSU مع ضغط المسار
  2. الدمج حسب الرتبة وحدّ دالة Ackermann العكسية
  3. الحافة الزائدة واكتشاف الدورات
  4. دمج الحسابات والمكوّنات المتصلة
← العودة إلى DSA Interview Prep