بنى البيانات المفتوحة › 12. الرسوم البيانية 12 / 14 السابق التالي

12. الرسوم البيانية

في هذا الفصل، ندرس تمثيلين للرسوم البيانية (graphs) وخوارزمياتٍ أساسية تستعمل هذه التمثيلات. رياضيًّا، الرسم البياني (الموجَّه) زوجٌ G=(V,E) G=(V,E) حيث V V مجموعة رؤوس (vertices) و E E مجموعة أزواجٍ مرتَّبة من الرؤوس تُسمَّى أضلاعًا (edges). الضلع (i,j) \mathtt{(i,j)} موجَّه من i \mathtt{i} إلى j \mathtt{j}؛ ويُسمَّى i \mathtt{i} مصدر الضلع (source) ويُسمَّى j \mathtt{j} هدفه (target). المسار (path) في G G هو تتابعٌ من الرؤوس v0,…,vk v_0,\ldots,v_k بحيث، لكلّ i∈{1,…,k} i\in\{1,\ldots,k\} ، يكون الضلع (vi−1,vi) (v_{i-1},v_{i}) في E E . ويكون المسار v0,…,vk v_0,\ldots,v_k دورةً (cycle) إذا، إضافةً إلى ذلك، كان الضلع (vk,v0) (v_k,v_0) في E E . ويكون المسار (أو الدورة) بسيطًا (simple) إذا كانت جميع رؤوسه فريدة. وإذا كان هناك مسار من رأسٍ ما vi v_i إلى رأسٍ ما vj v_j، فإننا نقول إنّ vj v_j يمكن الوصول إليه من vi v_i (reachable). ويُعرض مثالٌ على رسمٍ بياني في الشكل 12.1.

بفضل قدرتها على نمذجة عددٍ هائل من الظواهر، تمتلك الرسوم البيانية عددًا كبيرًا جدًّا من التطبيقات. وهناك أمثلةٌ واضحة كثيرة. ويمكن نمذجة شبكات الحاسوب (computer networks) في صورة رسوم بيانية، حيث تقابل الرؤوس الحواسيب وتقابل الأضلاع روابط الاتصال (الموجَّهة) بين تلك الحواسيب. ويمكن نمذجة شوارع المدينة في صورة رسوم بيانية، حيث تمثّل الرؤوس التقاطعات (intersections) وتمثّل الأضلاع الشوارع التي تصل بين تقاطعاتٍ متتالية. وتظهر أمثلةٌ أقلّ وضوحًا متى ندرك أنّ الرسوم البيانية تستطيع نمذجة أيّ علاقاتٍ ثنائية ضمن مجموعة. فمثلًا، في بيئةٍ جامعية قد لدينا رسمٌ بياني لتعارض الجداول الدراسية (timetable conflict graph) تمثّل رؤوسه المقرّرات المقدَّمة في الجامعة، ويكون فيه الضلع (i,j) \mathtt{(i,j)} حاضرًا إذا وفقط إذا كان هناك طالبٌ واحدٌ على الأقلّ يأخذ الصفَّ i \mathtt{i} والصفَّ j \mathtt{j} معًا. أي إنّ الضلع يشير إلى أنّ اختبار الصفَّ i \mathtt{i} لا ينبغي أن يُجدوَل في الوقت نفسه الذي يُجدوَل فيه اختبار الصفَّ j \mathtt{j} . وفي هذا القسم، سنستعمل n \mathtt{n} للإشارة إلى عدد رؤوس G G و m \mathtt{m} للإشارة إلى عدد أضلاع G G . أي إنّ n=∣V∣ \ensuremath{\mathtt{n}}=\vert V\vert و m=∣E∣ \ensuremath{\mathtt{m}}=\vert E\vert . وفوق ذلك، سنفترض أنّ V={0,…,n−1} V=\{0,\ldots,\ensuremath{\mathtt{n}}-1\} . ويمكن تخزين أيّ بياناتٍ أخرى نرغب في ربطها بعناصر V V في مصفوفة (array) طولها n \ensuremath{\mathtt{n}} . ومن العمليات النموذجية التي تُجرى على الرسوم البيانية:

  • addEdge(i,j) \mathtt{addEdge(i,j)} : إضافة الضلع (i,j) (\ensuremath{\mathtt{i}},\ensuremath{\mathtt{j}}) إلى E E .
  • removeEdge(i,j) \mathtt{removeEdge(i,j)} : إزالة الضلع (i,j) (\ensuremath{\mathtt{i}},\ensuremath{\mathtt{j}}) من E E .
  • hasEdge(i,j) \mathtt{hasEdge(i,j)} : التحقّق ممّا إذا كان الضلع (i,j)∈E (\ensuremath{\mathtt{i}},\ensuremath{\mathtt{j}})\in E
  • outEdges(i) \mathtt{outEdges(i)} : إعادة List تضمّ جميع الأعداد الصحيحة j \ensuremath{\mathtt{j}} بحيث (i,j)∈E (\ensuremath{\mathtt{i}},\ensuremath{\mathtt{j}})\in E
  • inEdges(i) \mathtt{inEdges(i)} : إعادة List تضمّ جميع الأعداد الصحيحة j \ensuremath{\mathtt{j}} بحيث (j,i)∈E (\ensuremath{\mathtt{j}},\ensuremath{\mathtt{i}})\in E

لاحظ أنّ هذه العمليات ليست صعبة التنفيذ بكفاءة عالية. فمثلًا، يمكن تنفيذ العمليات الثلاث الأولى مباشرةً باستعمال USet، ولذلك يمكن تنفيذها بزمنٍ ثابتٍ متوقَّع (constant expected time) باستعمال جداول التجزئة (hash tables) التي ناقشناها في الفصل 5. ويمكن تنفيذ العمليتين الأخيرتين بزمنٍ ثابت بتخزين قائمةٍ برؤوس كلّ رأس المجاورة (adjacent) إليه. لكنّ تطبيقات الرسوم البيانية المختلفة لها متطلّبات أداءٍ مختلفة لهذه العمليات، ومن ثمّ فإنّنا نفضّل، مبدئيًّا، استعمال أبسط تنفيذٍ يحقّق جميع متطلّبات التطبيق. ولهذا السبب، سنناقش فئتين عريضتين من تمثيلات الرسوم البيانية.

الأقسام الفرعية

opendatastructures.org

12.1 AdjacencyMatrix: تمثيل الرسم البياني بمصفوفة

مصفوفة التجاور (adjacency matrix) طريقةٌ لتمثيل رسمٍ بياني من n \mathtt{n} رأس، G=(V,E) G=(V,E)، بمصفوفة أبعادها n×n \ensuremath{\mathtt{n}}\times\ensuremath{\mathtt{n}}، a \mathtt{a}، تكون مداخلها قيمًا منطقية (boolean).

    int n;
    boolean[][] a;
    AdjacencyMatrix(int n0) {
        n = n0;
        a = new boolean[n][n];
    }

يُعرَّف مدخل المصفوفة a[i][j] \mathtt{a[i][j]} على أنه

تُعرض مصفوفة التجاور للرسم البياني في الشكل 12.1 في الشكل 12.2. وفي هذا التمثيل، لا تتضمّن العمليات addEdge(i,j) \mathtt{addEdge(i,j)} و removeEdge(i,j) \mathtt{removeEdge(i,j)} و hasEdge(i,j) \mathtt{hasEdge(i,j)} سوى تعيين مدخل المصفوفة a[i][j] \mathtt{a[i][j]} أو قراءته:

    void addEdge(int i, int j) {
        a[i][j] = true;
    }
    void removeEdge(int i, int j) {
        a[i][j] = false;
    }
    boolean hasEdge(int i, int j) {
        return a[i][j];
    }

من الواضح أنّ هذه العمليات تستغرق زمنًا ثابتًا لكل عملية.

 0 1 2 3 4 5 6 7 8 9 10 11
0 0 1 0 0 1 0 0 0 0 0 0 0
1 1 0 1 0 0 1 1 0 0 0 0 0
2 1 0 0 1 0 0 1 0 0 0 0 0
3 0 0 1 0 0 0 0 1 0 0 0 0
4 1 0 0 0 0 1 0 0 1 0 0 0
5 0 1 1 0 1 0 1 0 0 1 0 0
6 0 0 1 0 0 1 0 1 0 0 1 0
7 0 0 0 1 0 0 1 0 0 0 0 1
8 0 0 0 0 1 0 0 0 0 1 0 0
9 0 0 0 0 0 1 0 0 1 0 1 0
10 0 0 0 0 0 0 1 0 0 1 0 1
11 0 0 0 0 0 0 0 1 0 0 1 0

أمّا الموضع الذي يظهر فيه أداءُ مصفوفة التجاور سيّئًا فهو العمليتان outEdges(i) \mathtt{outEdges(i)} و inEdges(i) \mathtt{inEdges(i)} . ولتنفيذهما، يتعيّن أن نمسح كلّ المداخل n \mathtt{n} في الصفّ أو العمود المقابل من a \mathtt{a}، وأن نجمع كلّ الفهارس j \mathtt{j} التي يكون فيها a[i][j] \mathtt{a[i][j]}، أو على التوالي a[j][i] \mathtt{a[j][i]}، قيمةً منطقية (true).

    List<Integer> outEdges(int i) {
        List<Integer> edges = new ArrayList<Integer>();
        for (int j = 0; j < n; j++) 
            if (a[i][j]) edges.add(j);
        return edges;
    }
    List<Integer> inEdges(int i) {
        List<Integer> edges = new ArrayList<Integer>();
        for (int j = 0; j < n; j++)
            if (a[j][i]) edges.add(j);
        return edges;
    }

من الواضح أنّ هذه العمليات تستغرق O(n) O(\ensuremath{\mathtt{n}}) زمنًا لكل عملية.

ومن عيوب تمثيل مصفوفة التجاور أنّها كبيرة الحجم. فهي تخزّن مصفوفةً منطقية أبعادها n×n \ensuremath{\mathtt{n}}\times \ensuremath{\mathtt{n}}، ولذلك تتطلّب n2 \ensuremath{\mathtt{n}}^2 بت على الأقلّ من الذاكرة. ويستخدم التنفيذ هنا مصفوفةً من قيم boolean \mathtt{boolean}، فهو يستهلك فعليًا من رتبة n2 \ensuremath{\mathtt{n}}^2 بايت من الذاكرة. أمّا تنفيذٌ أكثر عناية، يَحزِم w \mathtt{w} قيمةً منطقية في كلّ كلمة ذاكرة، فيمكنه خفض استهلاك هذه المساحة إلى O(n2/w) O(\ensuremath{\mathtt{n}}^2/\ensuremath{\mathtt{w}}) كلمة ذاكرة. المبرهنة 12.1 تنفّذ بنية بيانات AdjacencyMatrix واجهة Graph. ويدعم AdjacencyMatrix العمليات * addEdge(i,j) \mathtt{addEdge(i,j)} و removeEdge(i,j) \mathtt{removeEdge(i,j)} و hasEdge(i,j) \mathtt{hasEdge(i,j)} بزمنٍ ثابت لكل عملية؛ والعمليتين inEdges(i) \mathtt{inEdges(i)} و outEdges(i) \mathtt{outEdges(i)} في زمن O(n) O(\ensuremath{\mathtt{n}}) لكل عملية. * والمساحة التي يستعملها AdjacencyMatrix هي O(n2) O(\ensuremath{\mathtt{n}}^2) .

ورغم متطلّباتها المرتفعة للذاكرة وأدائها السيّئ في العمليتين inEdges(i) \mathtt{inEdges(i)} و outEdges(i) \mathtt{outEdges(i)}، لا يزال AdjacencyMatrix مفيدًا لبعض التطبيقات. وبخاصة، عندما يكون الرسم البياني G G كثيفًا (dense)، أي عندما يحتوي على ما يقارب n2 \ensuremath{\mathtt{n}}^2 ضلعًا، يكون استهلاك ذاكرةٍ مقداره n2 \ensuremath{\mathtt{n}}^2 مقبولًا. كما تُستعمل بنية بيانات AdjacencyMatrix على نطاقٍ واسع، لأنّ العمليات الجبرية على المصفوفة a \mathtt{a} يمكن استعمالها لحساب خصائص الرسم البياني G G بكفاءة. وهذا موضوع مقررٍ في الخوارزميات، لكنّنا نُشِر هنا إلى إحدى هذه الخصائص: إذا تعاملنا مع مداخل a \mathtt{a} بوصفها أعدادًا صحيحة (القيمة 1 مقابل true \mathtt{true} والقيمة 0 مقابل false \mathtt{false} ) وضربنا a \mathtt{a} في نفسها باستعمال ضرب المصفوفات، فإنّنا نحصل على المصفوفة a2 \ensuremath{\mathtt{a}}^2 . تذكّر، من تعريف ضرب المصفوفات، أنّ

إذا فسّرنا هذا المجموع في ضوء الرسم البياني G G، فإنّ هذه الصيغة تعدّ عدد الرؤوس، k \ensuremath{\mathtt{k}}، التي يحتوي G G على الضلعين (i,k) \mathtt{(i,k)} و (k,j) \mathtt{(k,j)} معًا. أي إنّها تعدّ عدد المسارات من i \ensuremath{\mathtt{i}} إلى j \ensuremath{\mathtt{j}} (عبر رؤوسٍ وسيطة، k \ensuremath{\mathtt{k}} ) التي طولها يساوي اثنين بالضبط. وهذه الملاحظة هي أساس خوارزميةٍ تحسب المسارات الأقصر (shortest paths) بين كلّ زوجٍ من الرؤوس في G G باستعمال ضربات مصفوفاتٍ لا تتجاوز O(log⁡n) O(\log \ensuremath{\mathtt{n}}) . opendatastructures.org

12.2 AdjacencyLists: رسمٌ بياني في صورة مجموعة قوائم

تتخذ تمثيلات قوائم التجاور (adjacency lists) للرسوم البيانية منهجًا أكثر تركيزًا على الرؤوس. وهناك إمكاناتٌ كثيرة لتنفيذ قوائم التجاور. وفي هذا القسم نقدّم واحدةً بسيطة منها. وفي نهاية القسم سنناقش إمكاناتٍ مختلفة. وفي تمثيل قوائم التجاور، يُمثَّل الرسم البياني G=(V,E) G=(V,E) في صورة مصفوفة، adj \mathtt{adj}، من القوائم. وتحتوي القائمة adj[i] \mathtt{adj[i]} على قائمةٍ بجميع الرؤوس المجاورة للرأس i \mathtt{i} . أي إنّها تحتوي كلّ فهرس j \mathtt{j} بحيث (i,j)∈E \ensuremath{\mathtt{(i,j)}}\in E .

    int n;
    List<Integer>[] adj;
    AdjacencyLists(int n0) {
        n = n0;
        adj = (List<Integer>[])new List[n];
        for (int i = 0; i < n; i++) 
            adj[i] = new ArrayStack<Integer>(Integer.class);
    }

(يُعرض مثالٌ في الشكل 12.3.) وفي هذا التنفيذ بعينه، نمثّل كلّ قائمة في adj \mathtt{adj} في صورة ArrayStack، لأنّنا نرغب في الوصول بحسب الموضع بزمنٍ ثابت. والخيارات الأخرى ممكنة أيضًا. وبخاصة، كان يمكن أن ننفّذ adj \mathtt{adj} بوصفه DLList.

 0 1 2 3 4 5 6 7 8 9 10 11
1 0 1 2 0 1 5 6 4 8 9 10
4 2 3 7 5 2 2 3 9 5 6 7
6 6 8 6 7 11 10 11
5 9 10
4

لا تفعل العملية addEdge(i,j) \mathtt{addEdge(i,j)} سوى إلحاق القيمة j \mathtt{j} بالقائمة adj[i] \mathtt{adj[i]} :

    void addEdge(int i, int j) {
        adj[i].add(j);
    }

يستغرق هذا زمنًا ثابتًا. وتبحث العملية removeEdge(i,j) \mathtt{removeEdge(i,j)} في القائمة adj[i] \mathtt{adj[i]} حتى تجد j \mathtt{j} ثمّ تحذفها:

    void removeEdge(int i, int j) {
        Iterator<Integer> it = adj[i].iterator();
        while (it.hasNext()) {
            if (it.next() == j) {
                it.remove();
                return;
            }
        }    
    }

يستغرق هذا O(deg⁡(i)) O(\deg(\ensuremath{\mathtt{i}})) زمنًا، حيث إنّ deg⁡(i) \deg(\ensuremath{\mathtt{i}}) (درجة الرأس i \ensuremath{\mathtt{i}} ) تعدّ عدد الأضلاع في E E التي يكون i \ensuremath{\mathtt{i}} مصدرًا لها. والعملية hasEdge(i,j) \mathtt{hasEdge(i,j)} مشابهة؛ فهي تبحث في القائمة adj[i] \mathtt{adj[i]} حتى تجد j \mathtt{j} (وتُعيد true)، أو تبلغ نهاية القائمة (وتُعيد false):

    boolean hasEdge(int i, int j) {
        return adj[i].contains(j);
    }

يستغرق هذا أيضًا O(deg⁡(i)) O(\deg(\ensuremath{\mathtt{i}})) زمنًا. أمّا العملية outEdges(i) \mathtt{outEdges(i)} فبسيطةٌ جدًّا؛ فهي تُعيد القائمة adj[i] \mathtt{adj[i]} :

    List<Integer> outEdges(int i) {
        return adj[i];
    }

من الواضح أنّ هذا يستغرق زمنًا ثابتًا. أمّا العملية inEdges(i) \mathtt{inEdges(i)} فعملٌ أكبر بكثير. فهي تمسح كلّ رأس j j للتحقّق ممّا إذا كان الضلع (i,j) \mathtt{(i,j)} موجودًا، وعندئذٍ تضيف j \mathtt{j} إلى قائمة المخرجات:

    List<Integer> inEdges(int i) {
        List<Integer> edges = new ArrayStack<Integer>(Integer.class);
        for (int j = 0; j < n; j++)
            if (adj[j].contains(i))    edges.add(j);
        return edges;
    }

هذه العملية بطيئةٌ جدًّا. فهي تمسح قائمة التجاور لكلّ رأس، ولذلك تستغرق O(n+m) O(\ensuremath{\mathtt{n}} + \ensuremath{\mathtt{m}}) زمنًا.

تلخّص المبرهنة التالية أداء بنية البيانات أعلاه: المبرهنة 12.2 تنفّذ بنية بيانات AdjacencyLists واجهة Graph. ويدعم AdjacencyLists العمليات * addEdge(i,j) \mathtt{addEdge(i,j)} بزمنٍ ثابت لكل عملية؛ و removeEdge(i,j) \mathtt{removeEdge(i,j)} و hasEdge(i,j) \mathtt{hasEdge(i,j)} في زمن O(deg⁡(i)) O(\deg(\ensuremath{\mathtt{i}})) لكل عملية؛ و outEdges(i) \mathtt{outEdges(i)} بزمنٍ ثابت لكل عملية؛ و inEdges(i) \mathtt{inEdges(i)} في زمن O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) لكل عملية. * والمساحة التي يستعملها AdjacencyLists هي O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) .

كما أَلمَحْنا سابقًا، هناك خياراتٌ كثيرة مختلفة يجب اتخاذها عند تنفيذ رسمٍ بياني في صورة قائمة تجاور. ومن الأسئلة التي تُطرح:

  • ما نوع المجموعة (collection) التي ينبغي استعمالها لتخزين كلّ عنصر من adj \mathtt{adj}؟ يمكن استعمال قائمةٍ مبنيةٍ على مصفوفة، أو قائمة مترابطة (linked list)، أو حتى جدول تجزئة (hashtable).
  • هل ينبغي وجود قائمة تجاور ثانية، inadj \mathtt{inadj}، تخزّن لكلّ i \mathtt{i} قائمة الرؤوس، j \mathtt{j}، بحيث (j,i)∈E \ensuremath{\mathtt{(j,i)}}\in E؟ ويمكن أن يخفض هذا كثيرًا زمن تشغيل العملية inEdges(i) \mathtt{inEdges(i)}، لكنه يتطلّب عملًا إضافيًّا طفيفًا عند إضافة الأضلاع أو حذفها.
  • هل ينبغي أن يُربط مدخلُ الضلع (i,j) \mathtt{(i,j)} في adj[i] \mathtt{adj[i]} بمرجعٍ إلى المدخل المقابل في inadj[j] \mathtt{inadj[j]}؟
  • هل ينبغي أن تكون الأضلاع كائناتٍ من المرتبة الأولى (first-class objects) لها بياناتها المرتبطة بها؟ بهذه الطريقة، سيحتوي adj \mathtt{adj} على قوائم أضلاع بدلًا من قوائم رؤوس (أعداد صحيحة).

معظم هذه الأسئلة ينحصر في مفاضلة (tradeoff) بين تعقيد التنفيذ (والمساحة) وميزات أداء التنفيذ. opendatastructures.org

12.3 اجتياز الرسوم البيانية

الأقسام الفرعية

12.3 اجتياز الرسوم البيانية

في هذا القسم نقدّم خوارزميتين لاستكشاف رسمٍ بياني، تنطلقان من إحدى رؤوسه، i \mathtt{i}، وتجدان جميع الرؤوس التي يمكن الوصول إليها من i \mathtt{i} . وكلتا الخوارزميتين أنسب للرسوم البيانية الممثَّلة في صورة قائمة تجاور (adjacency list). ولذلك، عند تحليل هاتين الخوارزميتين سنفترض أنّ التمثيل الأساسي هو AdjacencyLists.

تبدأ خوارزمية البحث بالعرض أوّلًا (breadth-first search) من رأس i \mathtt{i} وتزور، أوّلًا، جيران i \mathtt{i}، ثمّ جيران جيران i \mathtt{i}، ثمّ جيران جيران جيران i \mathtt{i}، وهكذا. وهذه الخوارزمية تعميمٌ لخوارزمية اجتياز الأشجار الثنائية بالعرض أوّلًا (القسم 6.1.2)، وهي مشابهة لها إلى حدٍّ كبير؛ فهي تستعمل طابورًا (queue)، q \mathtt{q}، يحتوي في البدء على i \mathtt{i} وحده. ثمّ تستخرج عنصرًا من q \mathtt{q} على التوالي وتضيف جيرانه إلى q \mathtt{q}، ما دام أنّ هؤلاء الجيران لم يكونوا في q \mathtt{q} من قبل. والفرق الجوهري الوحيد بين خوارزمية البحث بالعرض أوّلًا للرسوم البيانية وبين نظيرتها للأشجار هو أنّ خوارزمية الرسوم البيانية عليها أن تتأكّد من أنّها لا تضيف الرأس نفسه إلى q \mathtt{q} أكثر من مرّة. وهي تفعل ذلك باستعمال مصفوفةٍ منطقية مساعدة (auxiliary)، seen \mathtt{seen}، تتتبّع الرؤوس التي اكتُشِفت بالفعل.

    void bfs(Graph g, int r) {
        boolean[] seen = new boolean[g.nVertices()];
        Queue<Integer> q = new SLList<Integer>();
        q.add(r);
        seen[r] = true;
        while (!q.isEmpty()) {
            int i = q.remove();
            for (Integer j : g.outEdges(i)) {
                if (!seen[j]) {
                    q.add(j);
                    seen[j] = true;
                }
            }
        }
    }

يُعرض في الشكل 12.4 مثالٌ على تشغيل bfs(g,0) \mathtt{bfs(g,0)} على الرسم البياني في الشكل 12.1. وتختلف عمليات التنفيذ بحسب ترتيب قوائم التجاور؛ إذ يستعمل الشكل 12.4 قوائم التجاور الواردة في الشكل 12.3.

الشكل 12.4: مثالٌ على البحث بالعرض أوّلًا انطلاقًا من العقدة 0. العقدُ موسومةٌ بترتيب إضافتها إلى q \mathtt{q} . والأضلاع التي أدّت إلى إضافة العُقد إلى q \mathtt{q} مرسومةٌ بالأسود، أمّا بقية الأضلاع فمرسومةٌ بالرمادي.  وتحليل زمن تشغيل الإجراء bfs(g,i) \mathtt{bfs(g,i)} مباشر إلى حدٍّ كبير. ويضمن استعمال مصفوفة seen \mathtt{seen} ألّا يُضاف أيّ رأس إلى q \mathtt{q} أكثر من مرّة. وتستغرق إضافة كلّ رأس إلى q \mathtt{q} (وحذفه لاحقًا) زمنًا ثابتًا لكلّ رأس، أي O(n) O(\ensuremath{\mathtt{n}}) زمنًا في المجموع. وبما أنّ الحلقة الداخلية تعالج كلّ رأسٍ مرّةً واحدة على الأكثر، تُعالَج كلّ قائمة تجاورٍ مرّةً واحدة على الأكثر، ومن ثمّ تُعالَج كلّ ضلعٍ من أضلاع G G مرّةً واحدة على الأكثر. وهذه المعالجة، التي تجري في الحلقة الداخلية، تستغرق زمنًا ثابتًا في كلّ دورة، أي O(m) O(\ensuremath{\mathtt{m}}) زمنًا في المجموع. ولذلك تعمل الخوارزميةُ كاملةً في O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) زمن. وتلخّص المبرهنة التالية أداء خوارزمية bfs(g,r) \mathtt{bfs(g,r)} . المبرهنة 12.3 إذا قُدِّم لها كمدخل رسمٌ بياني (Graph)، g \mathtt{g}، منفَّذ ببنية بيانات AdjacencyLists، فإنّ خوارزمية bfs(g,r) \mathtt{bfs(g,r)} تعمل في O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) زمن.

للاجتياز بالعرض أوّلًا بعض الخصائص شديدة التخصّص. فاستدعاء bfs(g,r) \mathtt{bfs(g,r)} سيؤدي في نهاية المطاف إلى إدخال (وفي نهاية المطاف إخراج) كلّ رأس j \mathtt{j} يوجد مسارٌ موجَّه من r \mathtt{r} إلى j \mathtt{j} . وفوق ذلك، تدخل الرؤوس الواقعة على مسافة 0 من r \mathtt{r} (أي r \mathtt{r} نفسه) في q \mathtt{q} قبل الرؤوس الواقعة على مسافة 1، وهذه تدخل في q \mathtt{q} قبل الرؤوس الواقعة على مسافة 2، وهكذا. ولذلك تزور الدالة bfs(g,r) \mathtt{bfs(g,r)} الرؤوس بحسب تصاعد المسافة عن r \mathtt{r}، أمّا الرؤوس التي لا يمكن الوصول إليها من r \mathtt{r} فلا تُزار البتّة. ومن هنا فإنّ أحد أكثر تطبيقات خوارزمية البحث بالعرض أوّلًا نفعًا هو حساب المسارات الأقصر. ولحساب المسار الأقصر من r \mathtt{r} إلى كلّ رأسٍ آخر، نستعمل صيغةً معدَّلة من bfs(g,r) \mathtt{bfs(g,r)} تستعمل مصفوفةً مساعدة، p \mathtt{p}، طولها n \mathtt{n} . وحين يُضاف رأسٌ جديد، j \mathtt{j}، إلى q \mathtt{q}، نضبط p[j]=i \mathtt{p[j]=i} . وبهذه الطريقة، تصبح p[j] \mathtt{p[j]} هي العقدة ما قبل الأخيرة في مسارٍ أقصر من r \mathtt{r} إلى j \mathtt{j} . وبتكرار ذلك بأخذ p[p[j] \mathtt{p[p[j]} و p[p[p[j]]] \mathtt{p[p[p[j]]]} وهكذا، يمكننا إعادة بناء (انعكاس) مسارٍ أقصر من r \mathtt{r} إلى j \mathtt{j} .

خوارزمية البحث بالعمق أوّلًا (depth-first search) مشابهة للخوارزمية القياسية لاجتياز الأشجار الثنائية؛ فهي تستكشف أوّلًا شجرةً فرعية (subtree) بالكامل قبل العودة إلى العقدة الحالية ثمّ استكشاف الشجرة الفرعية الأخرى. وهناك طريقةٌ أخرى للفهم هي القول إنّ البحث بالعمق أوّلًا يشبه البحث بالعرض أوّلًا ما لمّ أنّه يستعمل مكدسًا (stack) بدلًا من طابور. وأثناء تنفيذ خوارزمية البحث بالعمق أوّلًا يُسنَد إلى كلّ رأس، i \mathtt{i}، لون (colour)، c[i] \mathtt{c[i]} : القيمة white \mathtt{white} إذا كنّا لم نرَ الرأس من قبل، والقيمة grey \mathtt{grey} إذا كنّا نزور ذلك الرأس حاليًّا، والقيمة black \mathtt{black} إذا كنا قد فرغنا من زيارته. وأيسر طريقةٍ لفهم البحث بالعمق أوّلًا هي اعتباره خوارزميةً تعاودية (recursive). فهي تبدأ بزيارة r \mathtt{r} . وعند زيارة رأسٍ i \mathtt{i}، نضع أوّلًا i \mathtt{i} على أنّها grey \mathtt{grey} . بعد ذلك، نمسح قائمة تجاور i \mathtt{i} ونسري بزيارة كلّ رأسٍ أبيض (white) نجده في هذه القائمة. وأخيرًا، نكتمل معالجة i \mathtt{i}، فنلوّن i \mathtt{i} باللون الأسود ونعود.

    void dfs(Graph g, int r) {
        byte[] c = new byte[g.nVertices()];
        dfs(g, r, c);
    }
    void dfs(Graph g, int i, byte[] c) {
        c[i] = grey;  // currently visiting i
        for (Integer j : g.outEdges(i)) {
            if (c[j] == white) {
                c[j] = grey;
                dfs(g, j, c);
            } 
        }
        c[i] = black; // done visiting i
    }

يُعرض في الشكل 12.5 مثالٌ على تنفيذ هذه الخوارزمية.

ولعلّ الأفضل تصوّر البحث بالعمق أوّلًا بوصفه خوارزميةً تعاودية، فإنّ التعاود ليس أفضل طريقةٍ لتنفيذه. فبالحقّ، ستُعاني الشيفرة أعلاه في كثيرٍ من الرسوم البيانية الكبيرة بسبب حدوث فيض المكدس (stack overflow). ويكمن تنفيذٌ بديل في استبدال مكدس التعاود بمكدسٍ صريح (explicit stack)، s \mathtt{s} . والتنفيذ التالي يفعل ذلك بالضبط:

    void dfs2(Graph g, int r) {
        byte[] c = new byte[g.nVertices()];
        Stack<Integer> s = new Stack<Integer>();
        s.push(r);
        while (!s.isEmpty()) {
            int i = s.pop();
            if (c[i] == white) {
                c[i] = grey;
                for (int j : g.outEdges(i))
                    s.push(j);
            }
        }
    }

في الشيفرة السابقة، عند معالجة الرأس التالي، i \mathtt{i}، يُلوَّن i \mathtt{i} باللون grey \mathtt{grey} ثمّ يُستبدل على المكدس برؤوسه المجاورة. وخلال التكرار التالي، ستُزار إحدى هذه الرؤوس.

وليس مفاجئًا أنّ زمنَي تشغيل dfs(g,r) \mathtt{dfs(g,r)} و dfs2(g,r) \mathtt{dfs2(g,r)} هما نفسه زمن تشغيل bfs(g,r) \mathtt{bfs(g,r)} : المبرهنة 12.4 إذا قُدِّم لهما كمدخل رسمٌ بياني (Graph)، g \mathtt{g}، منفَّذ ببنية بيانات AdjacencyLists، فإنّ خوارزميتَي dfs(g,r) \mathtt{dfs(g,r)} و dfs2(g,r) \mathtt{dfs2(g,r)} تعمل كلٌّ منهما في O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) زمن.

وكما في خوارزمية البحث بالعرض أوّلًا، ترتبط بكلّ تنفيذٍ للبحث بالعمق أوّلًا شجرةٌ ضمنية. فحين تنتقل عقدة، i≠r \ensuremath{\mathtt{i}}\neq \ensuremath{\mathtt{r}}، من white \mathtt{white} إلى grey \mathtt{grey}، فإنّ ذلك لأنّ dfs(g,i,c) \mathtt{dfs(g,i,c)} استُدعيت تعاوديًّا أثناء معالجة عقدةٍ ما i′ \mathtt{i'} . (وفي حالة خوارزمية dfs2(g,r) \mathtt{dfs2(g,r)}، تكون i \mathtt{i} إحدى العقد التي استبدلت i′ \mathtt{i'} على المكدس.) فإذا نظرنا إلى i′ \mathtt{i'} بوصفها الأمّ (parent) لـ i \mathtt{i}، نحصل على شجرةٍ مجذَّرة عند r \mathtt{r} . وفي الشكل 12.5، تكون هذه الشجرة مسارًا من الرأس 0 إلى الرأس 11. وإليك خاصيةً مهمّة لخوارزمية البحث بالعمق أوّلًا: نفترض أنّه حين تُلوَّن عقدة، i \mathtt{i}، باللون grey \mathtt{grey}، يوجد مسارٌ من i \mathtt{i} إلى عقدةٍ أخرى ما، j \mathtt{j}، يستعمل رؤوسًا بيضاءً فقط. عندئذٍ ستُلوَّن j \mathtt{j} أوّلًا باللون grey \mathtt{grey} ثمّ black \mathtt{black} قبل أن تُلوَّن i \mathtt{i} باللون black \mathtt{black} . (ويمكن إثبات ذلك بالتناقض، بالنظر في أيّ مسار، P P، من i \mathtt{i} إلى j \mathtt{j} .) ومن تطبيقات هذه الخاصية كشف الدورات (cycles). راجع الشكل 12.6. خُذ دورةً ما، C C، يمكن الوصول إليها من r \mathtt{r} . ولنفرض أنّ i \mathtt{i} هي أوّل عقدةٍ في C C تُلوَّن باللون grey \mathtt{grey}، وأنّ j \mathtt{j} هي العقدة التي تسبق i \mathtt{i} في الدورة C C . عندئذٍ، وبموجب الخاصية أعلاه، ستُلوَّن j \mathtt{j} باللون grey \mathtt{grey}، وستنظر الخوارزمية في الضلع (j,i) \mathtt{(j,i)} ما دام i \mathtt{i} ما تزال بلون grey \mathtt{grey} . إذًا تستطيع الخوارزمية أن تستنتج أنّه يوجد مسار، P P، من i \mathtt{i} إلى j \mathtt{j} في شجرة البحث بالعمق أوّلًا، وأنّ الضلع (j,i) \mathtt{(j,i)} موجود. ولذلك فإنّ P P هي أيضًا دورة.

opendatastructures.org

12.4 نقاشٌ وتمارين

تضخّم المبرهنتان 12.3 و12.4 نوعًا ما تقديرَ زمن تشغيل خوارزميتَي البحث بالعمق أوّلًا والبحث بالعرض أوّلًا. عرِّف nr \ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{r}}} بأنّه عدد الرؤوس، i \mathtt{i}، في G G التي يوجد مسارٌ من r \mathtt{r} إلى i \mathtt{i} . وعرِّف mr \ensuremath{\mathtt{m}}_\ensuremath{\mathtt{r}} بأنّه عدد الأضلاع التي تكون هذه الرؤوس مصادرَ لها. عندئذٍ تكون المبرهنة التالية بيانًا أدقّ لزمن تشغيل خوارزميتَي البحث بالعرض أوّلًا والبحث بالعمق أوّلًا. (وهذه الصياغةُ الأدقّ لزمن التشغيل مفيدةٌ في بعض التطبيقات لهاتين الخوارزميتين المبيَّنة في التمارين.) المبرهنة 12.5 إذا قُدِّم لها كمدخل رسمٌ بياني (Graph)، g \mathtt{g}، منفَّذ ببنية بيانات AdjacencyLists، فإنّ خوارزميات bfs(g,r) \mathtt{bfs(g,r)} و dfs(g,r) \mathtt{dfs(g,r)} و dfs2(g,r) \mathtt{dfs2(g,r)} تعمل كلٌّ منها في O(nr+mr) O(\ensuremath{\mathtt{n}}_{\ensuremath{\mathtt{r}}}+\ensuremath{\mathtt{m}}_{\ensuremath{\mathtt{r}}}) زمن.

يبدو أنّ البحث بالعرض أوّلًا قد اُكتشف بصورةٍ مستقلّة على يد Moore [52] وLee [49] في سياقَي استكشاف المتاهات وتوجيه الدوائر الإلكترونية على التوالي. وقد قدّم Hopcroft وTarjan [40] تمثيلات قوائم التجاور للرسوم البيانية بوصفها بديلًا عن تمثيل مصفوفة التجاور (الذي كان أكثر شيوعًا آنذاك). وقد لعب هذا التمثيل، وكذلك البحث بالعمق أوّلًا، دورًا رئيسيًّا في خوارزمية Hopcroft-Tarjan الشهيرة لاختبار المستوية (planarity)، التي تستطيع تحديد، في O(n) O(\ensuremath{\mathtt{n}}) زمن، ما إذا كان رسمٌ بياني يمكن رسمه في المستوَى، بحيث لا يتقاطع أيّ زوجٍ من الأضلاع [41]. وفي التمارين التالية، يكون الرسم البياني غير الموجَّه (undirected) رسمًا يكون فيه، لكلّ i \mathtt{i} و j \mathtt{j}، الضلع (i,j) (\ensuremath{\mathtt{i}},\ensuremath{\mathtt{j}}) حاضرًا إذا وفقط إذا كان الضلع (j,i) (\ensuremath{\mathtt{j}},\ensuremath{\mathtt{i}}) حاضرًا. التمرين 12.1 ارسم تمثيل قائمة التجاور وتمثيل مصفوفة التجاور للرسم البياني في الشكل 12.7.

الشكل 12.7: مثالٌ على رسمٍ بياني.  التمرين 12.2 تمثيل مصفوفة الورودية (incidence matrix) لرسمٍ بياني، G G، هو مصفوفة أبعادها n×m \ensuremath{\mathtt{n}}\times\ensuremath{\mathtt{m}}، A A، حيث

Ai,j={−1if vertex i the source of ... ...if vertex i the target of edge j0otherwise. \displaystyle A_{i,j} = \begin{cases} -1 & \text{if vertex $i$\ the source of ... ...if vertex $i$\ the target of edge $j$} \\ 0 & \text{otherwise.} \end{cases}
  1. ارسم تمثيل مصفوفة الورودية للرسم البياني في الشكل 12.7.
  2. صمّم وحلّل ونفّذ تمثيلًا بمصفوفة الورودية لرسمٍ بياني. واحرص على تحليل المساحة، وتكلفة addEdge(i,j) \mathtt{addEdge(i,j)} و removeEdge(i,j) \mathtt{removeEdge(i,j)} و hasEdge(i,j) \mathtt{hasEdge(i,j)} و inEdges(i) \mathtt{inEdges(i)} و outEdges(i) \mathtt{outEdges(i)} .

التمرين 12.3 بيّن تنفيذ bfs(G,0) \mathtt{bfs(G,0)} و dfs(G,0) \mathtt{dfs(G,0)} على الرسم البياني، G \ensuremath{\mathtt{G}}، في الشكل 12.7.

التمرين 12.4 ليكن G G رسمًا بيانيًا غير موجَّه. نقول إنّ G G متّصل (connected) إذا، لكلّ زوجٍ من الرؤوس i \mathtt{i} و j \mathtt{j} في G G، كان هناك مسارٌ من i \ensuremath{\mathtt{i}} إلى j \ensuremath{\mathtt{j}} (ولأنّ G G غير موجَّه، فهناك أيضًا مسارٌ من j \mathtt{j} إلى i \mathtt{i} ). بيّن كيف تختبر ما إذا كان G G متّصلًا في O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) زمن.

التمرين 12.5 ليكن G G رسمًا بيانيًا غير موجَّه. تسمية المكوّنات المتّصلة (connected-component labelling) لـ G G تقسّم رؤوس G G إلى مجموعات قصوى، كلٌّ منها يشكّل رسمًا بيانيًا فرعيًّا متّصلًا. بيّن كيف تحسب تسمية المكوّنات المتّصلة لـ G G في O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) زمن.

التمرين 12.6 ليكن G G رسمًا بيانيًا غير موجَّه. الغابة الممتدّة (spanning forest) لـ G G هي مجموعة أشجار، واحدةٌ لكلّ مكوّن، أضلاعُها أضلاعٌ من G G ورؤوسُها تضمّ كلّ رؤوس G G . بيّن كيف تحسب غابةً ممتدّة لـ G G في O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) زمن.

التمرين 12.7 نقول إنّ الرسم البياني G G متّصلٌ بشدّة (strongly-connected) إذا، لكلّ زوجٍ من الرؤوس i \mathtt{i} و j \mathtt{j} في G G، كان هناك مسارٌ من i \ensuremath{\mathtt{i}} إلى j \ensuremath{\mathtt{j}} . بيّن كيف تختبر ما إذا كان G G متّصلًا بشدّة في O(n+m) O(\ensuremath{\mathtt{n}}+\ensuremath{\mathtt{m}}) زمن.

التمرين 12.8 معطًى رسمٌ بياني، G=(V,E) G=(V,E)، ورأسٌ خاصّ، r∈V \ensuremath{\mathtt{r}}\in V، بيّن كيف تحسب طول المسار الأقصر من r \ensuremath{\mathtt{r}} إلى i \mathtt{i} لكلّ رأس، i∈V \ensuremath{\mathtt{i}}\in V .

التمرين 12.9 أعطِ مثالًا (بسيطًا) تزور فيه شيفرة dfs(g,r) \mathtt{dfs(g,r)} عقدَ رسمٍ بياني بترتيبٍ يخالف ترتيب شيفرة dfs2(g,r) \mathtt{dfs2(g,r)} . واكتب صيغةً من dfs2(g,r) \mathtt{dfs2(g,r)} تزور العقدَ دائمًا بالترتيب نفسه تمامًا الذي تزور به dfs(g,r) \mathtt{dfs(g,r)} . (تلميح: يكفي أن تبدأ بتتبّع تنفيذ كلّ خوارزمية على رسمٍ بيانيٍّ ما يكون فيه r \mathtt{r} مصدرَ أكثر من ضلعٍ واحد.)

التمرين 12.10 البالعة الشاملة (universal sink) في رسمٍ بياني، G G، هي رأسٌ يكون هدفًا لـ n−1 \ensuremath{\mathtt{n}}-1 ضلعًا ومصدرًا لأيّ ضلع.12.1 صمّم ونفّذ خوارزمية تختبر ما إذا كان رسمٌ بياني، G G، ممثَّلًا في صورة AdjacencyMatrix، يملك بالعةً شاملة. وينبغي أن تعمل خوارزميتك في O(n) O(\ensuremath{\mathtt{n}}) زمن.

الحواشي

... الأضلاع.12.1 تُسمّى البالعة الشاملة، v \mathtt{v}، أحيانًا «شخصيةً مشهورة» (celebrity): كلُّ من في الغرفة يعرف v \mathtt{v}، لكنّ v \mathtt{v} لا يعرف أيّ شخصٍ آخر في الغرفة. opendatastructures.org