0Pricing
DSA Interview Prep · درس

الدمج حسب الرتبة وحدّ دالة Ackermann العكسية

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

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

لماذا تصبح الأشجار طويلة من دون الرتبة

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

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

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n   # initially all trees have rank 0

    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:
            return False
        # Attach lower-rank tree under higher-rank tree
        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   # only increases when ranks are equal
        return True

الحالات الثلاث للدمج حسب الرتبة

عند دمج مكوّنين جذراهما px وpy، تظهر ثلاث حالات استنادًا إلى رتبتيهما:

  • rank[px] > rank[py]: ألحِق py تحت px — لا تتغير رتبة px
  • rank[px] < rank[py]: ألحِق px تحت py — لا تتغير رتبة py
  • rank[px] == rank[py]: ألحِق py تحت px (أو العكس) — تزداد رتبة الجذر الجديد بمقدار 1

لا تزداد الرتبة إلا في حالة تساوي الرتبتين. وهذا يعني أن الرتبة n تتطلب ما لا يقل عن 2^n عقدة، ولذلك تكون الرتبة القصوى O(log n). ويحافظ ذلك على قِصر مسارات find حتى من دون ضغط المسار.

# Illustrating rank behaviour with 8 nodes
dsu_parent = list(range(8))
dsu_rank = [0] * 8

def find(x):
    while dsu_parent[x] != x:
        x = dsu_parent[x]
    return x

def union(x, y):
    px, py = find(x), find(y)
    if px == py: return
    if dsu_rank[px] < dsu_rank[py]:
        px, py = py, px
    dsu_parent[py] = px
    if dsu_rank[px] == dsu_rank[py]:
        dsu_rank[px] += 1

# Build balanced tree step by step
union(0,1); union(2,3); union(4,5); union(6,7)
union(0,2); union(4,6)
union(0,4)
print('Ranks:', dsu_rank)    # max rank <= log2(8) = 3
print('Root of all:', find(0))

الجمع بين ضغط المسار والدمج حسب الرتبة

عند استخدام كل من ضغط المسار والدمج حسب الرتبة معًا، ينخفض الزمن المُهَلْك لكل عملية إلى O(alpha(n))، أي دالة أكرمان العكسية. وبالنسبة إلى أي حجم إدخال عملي (حتى 2^65536)، لا تتجاوز alpha(n) القيمة 4. وهذا يعادل زمنًا ثابتًا فعليًا.

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

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

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

    def union(self, x, y):                    # union by rank
        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 = OptimalDSU(1000)
import random; random.seed(42)
for _ in range(5000):
    dsu.union(random.randint(0,999), random.randint(0,999))
print('Max rank reached:', max(dsu.rank))  # stays very small

فهم دالة أكرمان العكسية

تنمو دالة أكرمان A(m, n) بسرعة هائلة، أسرع من أي دالة عودية بدائية. أما عكسها، alpha(n)، فيُعرَّف بأنه أصغر m بحيث تحقق A(m, m) >= n. وبما أن دالة أكرمان تنمو بسرعة كبيرة جدًا، فإن alpha(n) تنمو ببطء لا يكاد يُتصوَّر.

حتى عندما تكون n = 10^80 (عدد الذرات في الكون المرئي)، لا تزال alpha(n) تساوي 4 فقط. ولهذا تُعامل DSU مع كلا التحسينين على أنها تحقق زمنًا ثابتًا فعليًا في كل سياق عملي. ولن تواجه أبدًا مسألة حقيقية كبيرة بما يكفي لتتجاوز فيها alpha(n) القيمة 5.

# Showing how slowly alpha(n) grows
# alpha(n) = smallest m such that A(m,m) >= n
# A(0,n) = n+1
# A(1,n) = n+2
# A(2,n) = 2n+3
# A(3,n) = 2^(n+3) - 3
# A(4,4) = 2^(2^(2^(2^2))) - 3 which is astronomically large

alpha_thresholds = {
    1: 'n=1',
    2: 'n up to 3',
    3: 'n up to about 2048',
    4: 'n up to 10^19728 (far beyond atoms in universe)',
    5: 'essentially unreachable in practice',
}
for k, v in alpha_thresholds.items():
    print(f'alpha(n)={k}: {v}')
print('\nConclusion: DSU operations are effectively O(1) for all real inputs.')

الرتبة أم الحجم: أيهما تستخدم؟

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

في المقابلات، يُعد أي من النهجين مقبولًا. ويتميز الدمج حسب الحجم بميزة إضافية، إذ يمنحك أحجام المكوّنات مجانًا، وهو ما تتطلبه مسائل كثيرة. أما الدمج حسب الرتبة فأكثر أناقة قليلًا من الناحية النظرية، ويتوافق مع برهان Tarjan الأصلي لحد دالة أكرمان العكسية.

class DSUBySize:
    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 False
        if self.size[px] < self.size[py]:
            px, py = py, px       # always attach smaller under larger
        self.parent[py] = px
        self.size[px] += self.size[py]
        return True

dsu = DSUBySize(8)
for u, v in [(0,1),(2,3),(0,2),(4,5),(6,7),(4,6),(0,4)]:
    dsu.union(u, v)
print('Size of giant component:', dsu.size[dsu.find(0)])

مخطط برهان: لماذا تبقى الرتبة ضمن O(log n)

يمكننا أن نثبت بالاستقراء أن شجرة DSU ذات الرتبة r تحتوي على ما لا يقل عن 2^r عقدة. الحالة الأساسية: الرتبة 0 تعني عقدة واحدة (2^0 = 1). وخطوة الاستقراء: لا تزداد الرتبة r إلا عند دمج شجرتين متساويتي الرتبة r-1. ووفقًا لفرضية الاستقراء، تحتوي كل شجرة فرعية على الأقل على 2^(r-1) عقدة، ولذلك تحتوي الشجرة المدمجة على 2 × 2^(r-1) = 2^r عقدة على الأقل.

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

# Verify the 2^rank lower bound empirically
class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * 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.rank[px] < self.rank[py]: px, py = py, px
        self.parent[py] = px
        self.size[px] += self.size[py]
        if self.rank[px] == self.rank[py]: self.rank[px] += 1

n = 32
dsu = DSU(n)
for i in range(n - 1): dsu.union(i, i + 1)
for root in range(n):
    if dsu.find(root) == root:
        r = dsu.rank[root]
        print(f'Root {root}: rank={r}, size={dsu.size[root]}, 2^rank={2**r}')

قالب DSU للبرمجة التنافسية

في البرمجة التنافسية والمقابلات، تحتاج إلى قالب DSU مختبرًا وموثوقًا، قصيرًا وصحيحًا ويتعامل مع جميع الحالات الطرفية. يستخدم القالب أدناه تنصيف المسار (ضغطًا بتمرير واحد) مع الدمج حسب الحجم، وهو تركيب سهل الكتابة بسرعة ويتجنب العودية تمامًا.

احرص دائمًا على تهيئة parent[i] = i وsize[i] = 1. وتذكر أنه بعد find يعكس size الخاص بالجذر حجم المكوّن بأكمله. لا تستخدم size[x] مباشرةً أبدًا، بل استدعِ دائمًا size[find(x)].

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]   # path halving
            x = self.p[x]
        return x

    def union(self, x, y):
        x, y = self.find(x), self.find(y)
        if x == y: return False
        if self.sz[x] < self.sz[y]: x, y = y, x
        self.p[y] = x
        self.sz[x] += self.sz[y]
        return True

    def same(self, x, y): return self.find(x) == self.find(y)
    def size(self, x): return self.sz[self.find(x)]

# Usage
dsu = DSU(10)
dsu.union(0, 5)
dsu.union(5, 9)
print(dsu.same(0, 9))   # True
print(dsu.size(0))       # 3

متى لا تكفي DSU

تدعم DSU دمج المجموعات، لكنها لا تدعم تقسيم مجموعة إلى مجموعتين من جديد. فإذا كانت المسألة تتطلب ضم المجموعات وفصلها، فستحتاج إلى بنية مختلفة، مثل link-cut tree. كما أن DSU لا تخزّن عناصر كل مجموعة أصلًا، ولذلك تحتاج إلى قائمة تجاور أو قاموس إضافي لذلك.

إضافةً إلى ذلك، لا تدعم DSU القياسية الحواف الموزونة من دون تعديل (فـ DSU الموزونة متغير أكثر تقدمًا). وفي مسائل مثل إيجاد أرخص مسار بين عقد متصلة، يكون استخدام Dijkstra أو BFS أنسب. ويساعد إدراك نطاق DSU على تجنب استخدامها في غير موضعها.

# DSU is perfect for: connected-components, cycle detection,
# Kruskal's MST, accounts-merge, number-of-provinces

# DSU is NOT suitable for:
# - Splitting/removing edges from a group
# - Finding the actual path between two nodes
# - Storing all members of a group efficiently
# - Directed graphs (without modification)

# Example of storing group members alongside DSU
from collections import defaultdict

class DSUWithMembers:
    def __init__(self, n):
        self.p = list(range(n))
        self.members = defaultdict(set)
        for i in range(n): self.members[i].add(i)

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

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py: return
        self.members[px] |= self.members[py]
        del self.members[py]
        self.p[py] = px

مقارنة DSU بـ BFS/DFS للاتصال

يحل كلٌّ من BFS/DFS وDSU استعلامات الاتصال الساكن، لكن لكلٍّ منهما نقاط قوة مختلفة. يعمل BFS/DFS في O(V + E) ويمكنه العثور على المسار الفعلي بين العقد. يجيب DSU عن العديد من استعلامات الاتصال على مجموعات حواف تنمو تدريجيًا بزمن يقارب O(1) لكل استعلام — وهو مثالي للخوارزميات online التي تصل فيها الحواف واحدة تلو الأخرى.

إذا تلقيتم جميع الحواف مسبقًا ولم تحتاجوا إلا إلى معرفة الاتصال، فكلتا الطريقتين مناسبتان. أما إذا وصلت الحواف ديناميكيًا واحتجتم إلى الإجابة عن استعلامات الاتصال بين كل حافة جديدة، فإن DSU هو الخيار الأفضل بوضوح. وبالنسبة إلى المسائل التي تحتاج أيضًا إلى أقصر مسار، فالتزموا بـ BFS.

# Comparing DSU vs BFS for 1000 nodes, 2000 edges
# After all edges given => BFS works fine
# But with online edge arrival + interleaved queries => DSU shines

from collections import deque

def bfs_connected(graph, src, dst, n):
    visited = set([src])
    q = deque([src])
    while q:
        node = q.popleft()
        if node == dst: return True
        for nb in graph.get(node, []):
            if nb not in visited:
                visited.add(nb); q.append(nb)
    return False

# DSU for same query:
# dsu.same(src, dst) -- O(alpha(n)) amortised
# BFS for same query:
# O(V + E) every time -- not suitable for repeated queries
print('DSU is preferred for repeated connectivity queries.')
print('BFS/DFS is preferred when you also need the actual path.')

تدريب: الشجرة الممتدة الدنيا باستخدام DSU

تستخدم خوارزمية Kruskal للشجرة الممتدة الدنيا DSU مباشرةً. رتّبوا جميع الحواف حسب أوزانها، ثم أضيفوا كل حافة بشكل جشع إذا كان طرفاها في مكوّنين مختلفين، أي من دون إنشاء دورة. يوفّر DSU فحص الدورة في زمن يقارب O(1). والنتيجة هي شجرة ممتدة دنيا تتكون من n-1 حافة.

هذا مثال كلاسيكي على قوة DSU: فهو يحوّل فحصًا ساذجًا للدورات بزمن O(E × V) إلى عملية بزمن O(E × alpha(n)). ومع فرز يستغرق E log E، يكون الزمن الإجمالي لخوارزمية Kruskal هو O(E log E)، وتكون عمليات DSU سريعة إلى حد يجعل تكلفتها ضئيلة مقارنةً بالفرز.

def kruskal(n, edges):
    edges.sort(key=lambda e: e[2])  # sort by weight
    parent = list(range(n))
    rank = [0] * 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: return False
        if rank[px] < rank[py]: px, py = py, px
        parent[py] = px
        if rank[px] == rank[py]: rank[px] += 1
        return True

    mst_weight = 0
    mst_edges = []
    for u, v, w in edges:
        if union(u, v):
            mst_weight += w
            mst_edges.append((u, v, w))
    return mst_weight, mst_edges

edges = [(0,1,4),(0,2,3),(1,2,1),(1,3,2),(2,3,5)]
w, e = kruskal(4, edges)
print('MST weight:', w)   # 6: edges (1,2,1)+(1,3,2)+(0,2,3)
print('MST edges:', e)

DSU مع التراجع: الاتصال خارج الخط

لا يدعم DSU القياسي عمليات التراجع. ومع ذلك، يدعم DSU مع التراجع (ويُسمّى أيضًا DSU مع السجل) ذلك: فبدلًا من ضغط المسار، الذي يصعب التراجع عنه، استخدموا الاتحاد بحسب الرتبة فقط، وسجّلوا كل عملية اتحاد في مكدس. وللتراجع، أزيلوا العناصر من المكدس واستعيدوا الأب والرتبة. يتيح ذلك حل مسائل الاتصال الديناميكي خارج الخط، حيث يمكن إضافة الحواف وإزالتها.

مع أن هذا الإصدار المتقدم نادرًا ما يظهر في المقابلات التقليدية، فإنه يوضح أن الاتحاد بحسب الرتبة هو الثابت الأساسي — وليس ضغط المسار. ومن دون ضغط المسار، تستغرق كل عملية find زمن O(log n)، ومع التراجع تستغرق عمليات المكدس O(1)، ما يعطي O(log n) لكل عملية إجمالًا بدلًا من O(alpha(n)).

class DSUWithRollback:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.history = []   # stack of (node, old_parent, node2, old_rank)

    def find(self, x):    # NO path compression (cannot undo)
        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: return False
        if self.rank[px] < self.rank[py]: px, py = py, px
        # Record state before modifying
        self.history.append((py, self.parent[py], px, self.rank[px]))
        self.parent[py] = px
        if self.rank[px] == self.rank[py]: self.rank[px] += 1
        return True

    def rollback(self):
        py, old_par_py, px, old_rank_px = self.history.pop()
        self.parent[py] = old_par_py
        self.rank[px] = old_rank_px

dsu = DSUWithRollback(5)
dsu.union(0, 1); dsu.union(1, 2)
print('0 and 2 connected:', dsu.find(0) == dsu.find(2))  # True
dsu.rollback()
print('After rollback:', dsu.find(0) == dsu.find(2))     # False

تحقق سريع

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

مراجعة الدرس

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

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

هل درس «الدمج حسب الرتبة وحدّ دالة Ackermann العكسية» مجاني؟

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

ماذا ستتعلم في «الدمج حسب الرتبة وحدّ دالة Ackermann العكسية»؟

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

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

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

كم من الوقت يستغرق درس «الدمج حسب الرتبة وحدّ دالة Ackermann العكسية»؟

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

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

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

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

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