بداية دخولك في عالم الجراف | Graph Theory
السلام عليكم ورحمة الله وبركاته
الفهرس
المقدمة
سنبدأ اليوم في شرح واحد من أهم المواضيع في عالم البرمجة وهو الجراف Graph
، وهو يعد أهم وأكبر هياكل البيانات Data Structure
في عالم البرمجة، وهو يستخدم في العديد من المجالات مثل الشبكات والألعاب والذكاء الصناعي والتعلم الآلي والعديد من المجالات الأخرى
هذه المقالة ستكون أكبر من مجرد مقدمة بل ستكون مفصلة قليلًا وستشمل عدة مواضيع منها مقدمة عن الجراف ثم تعريف لشكله وأهم المصطلحات التي تحوم حوله
ثم سنتعرف على كيفية بناء الجراف والبحث فيه وأهم الخوارزميات المتعلقة به ومنها الـ BFS
والـ DFS
وسنعطي بعض الأمثلة العملية على كيفية استخدام الجراف في البرمجة
فلنبدأ ؟ ... أنصحك بأخذ فنجان قهوة والجلوس بشكل مريح لأن الرحلة ستكون طويلة قليلًا هنا
وأنصحك بأخذ ورقة وقلم لأننا سنرسم كثيرًا هنا
ما هو الجراف ؟
الجراف هو مجموعة من الـ Nodes
والـ Edges
، والمقصود بالـ Node
هو العنصر التي يتم تخزينه وتمثيله في الجراف
أما الـ Edge
الرابطة بين كل Node
والآخر
الـ Node
يمكن أن يكون أي شيء يمكنك تخيله مثل رقم أو حرف أو object
أو أي شيء آخر
user_1 ---------> user_2
| |
| |
v |
user_3 user_4
هذا الشكل يمثل جراف بسيط يحتوي على 4
أشخاص وكل شخص يمثل Node
ويوجد 3
روابط بينهم كما تلاحظ كل رابطة تمثل Edge
وستلاحظ أن كل Node
يمكنه أن يكون له أكثر من Edge
وباتجاهات مختلفة
بمعنى هنا لدينا user_1
يمكنه التواصل مع user_2
و user_3
ولكن العكس لا
وهذا يعني أن الـ Edge
يكون له اتجاه معين
وستلاحظ أن user_2
و user_4
يمكنهم التواصل مع بعضهما البعض لأن الـ Edge
التي تربطهما تكون بدون اتجاه معين أي كأنها ذهاب وإياب
الجراف بشكل عام يمثل شبكة من الروابط بين العناصر المختلفة
وهو يعد من أقوى الهياكل البيانات المسؤولة عن تحزين البيانات بشكل مترابط ومتصل بينها كما سترى لاحقًا
العناصر التي تخزن في الجراف تسمى Node
والروابط بينها تسمى Edge
والـ Edge
يمكن أن يكون له اتجاه معين ويسمى Directed Edge
ويمكن أن يكون بدون اتجاه معين ويسمى Undirected Edge
كيفية بناء الجراف في البرمجة ؟
في البرمجة يمكننا تمثيل الجراف بطريقتين رئيسيتين وهما:
Adjacency Matrix
Adjacency List
الـ Adjacency Matrix
يمثل الجراف بشكل مصفوفة ثنائية الأبعاد 2D Array
والـ Adjacency List
يمثل الجراف بشكل Linked List
وسنتعرف على كل منهما بشكل مفصل
Adjacency Matrix
الـ Adjacency Matrix
ما هي إلا أراي ثنائية الأبعاد تستخدم كمحاولة لتمثيل الجراف
user_1 ---------> user_2
| |
| |
v |
user_3 user_4
فهنا يمكنا تخيل أن كل شخص يمثل رقمًا معينًا مثل user_1
يمثل 0
و user_2
يمثل 1
وهكذا
user_1 --> 0
user_2 --> 1
user_3 --> 2
user_4 --> 3
بالتالي يمكننا أن نقول أن هناك Edge
بين 0
و 1
وهي user_1
و user_2
و Edge
بين 0
و 2
وهي user_1
و user_3
وهكذا
حسنًا كيف نمثل هذه الروابط في الـ Adjacency Matrix
؟
أولًا لننشيء أراي ثنائية الأبعاد بحجم 4x4
ورقم 4
يمثل عدد الـ Node
وهذه الأراي ستكون قيمها من نوع boolean
بالتالي كل عنصر فيها سيكون true
أو false
وهذا سنستفيد منه لتحديد وجود رابطة بين Node
والآخر أم لا
const graph: boolean[][] = [
[false, false, false, false],
[false, false, false, false],
[false, false, false, false],
[false, false, false, false],
];
الآن أنشأنا الـ Adjacency Matrix
وحاليًا كل قيمها false
وهذا يعني أنه لا يوجد رابطة بين أي Node
الآن كيف سنقوم بإضافة الروابط ؟ كيف سنقول أن هناك رابطة بين user_1
و user_2
؟
ببساطة تتذكر حينما قلنا لنفترض أن كل شخص يمثل رقمًا معينًا ؟
بالتالي user_1
يمثل الرقم 0
و user_2
يمثل الرقم 1
هكذا عندما نقوم أن هناك رابطة من user_1
إلى user_2
فهذا أن هناك Edge
من 0
إلى 1
بالتالي كل ما علينا فعله هو تغيير قيمة الـ graph[0][1]
إلى true
graph[0][1] = true;
هكذا قلنا أن هناك رابطة من 0
إلى 1
وهذه الرابطة اتجاه واحد فقط من user_1
إلى user_2
وليس العكس
وهذا يعني أن user_1
يمكنه التواصل مع user_2
ولكن العكس لا
لنعد للجراف السابق ونقوم بتمثيله في الـ Adjacency Matrix
user_1 ---------> user_2
| |
| |
v |
user_3 user_4
هذا الجراف يستخدم object
لتمثيل الـ Node
ولكن نستطيع تمثيله بأرقام كما قلنا سابقًا لتبسيط الأمور
0 --------------> 1
| |
| |
v |
2 3
والآن لاحظ وركز على الـ Adjacency Matrix
الخاص بنا وكيف لنملئه بالبيانات ونمثل الجراف فيه
graph[0][1] = true;
graph[0][2] = true;
graph[1][3] = true;
graph[3][1] = true;
هكذا قمنا بتمثيل الجراف في الـ Adjacency Matrix
نحن لم نقم بشيء سوى تغيير قيمة الـ index
المحدد في الـ Adjacency Matrix
إلى true
لتمثيل الـ Edge
بين الـ Node
فمثلًا graph[0][1] = true
يعني أن هناك Edge
بين 0
و 1
وهذا يعني أن user_1
يمكنه التواصل مع user_2
و graph[0][2] = true
يعني أن هناك Edge
بين 0
و 2
وهذا يعني أن user_1
يمكنه التواصل مع user_3
و graph[1][3] = true
يعني أن هناك Edge
بين 1
و 3
وهذا يعني أن user_2
يمكنه التواصل مع user_4
و graph[3][1] = true
يعني أن هناك Edge
بين 3
و 1
وهذا يعني أن user_4
يمكنه التواصل مع user_2
وهكذا يمكنك بسهولة أن تعرف إذا كان هناك Edge
بين Node
والآخر أم لا
الآن شكل الـ Adjacency Matrix
الخاص بنا سيكون كالتالي
i \ j | 0 | 1 | 2 | 3 |
---|---|---|---|---|
0 | false | true | true | false |
1 | false | false | false | true |
2 | false | false | false | false |
3 | false | true | false | false |
هكذا يمكنك ببساطة أن تتفقد أي عنصر في الـ Adjacency Matrix
وتعرف إذا كان هناك Edge
بين Node
والآخر أم لا بكل سهولة
شكل الـ Adjacency Matrix
في النهاية سيكون كالتالي
هناك تطبيقات مفيدة للـ Adjacency Matrix
سنتطرق لها لاحقًا
لكن لاحظ أن هذه الطريقة رغم أنها سهلة وبسيطة إلا أنها تستهلك الكثير من الذاكرة
فتخيل لو لدينا 1000
شخص، كم سيكون حجم الـ Adjacency Matrix
؟
سيكون 1000x1000
وهذا يعني أنه سيكون لدينا 1000000
خانة مليون خانة تحتوي على قيمة true
أو false
وبشكل مبدئي سيكون لديك كل القيم بـ false
قبل أن يكون لديك حتى أي روابط
بمعنى أننا نستهلك الكثير من الذاكرة بدون فائدة
وهذا يعتبر عيب كبير في الـ Adjacency Matrix
لذا لا يستخدم كثيرًا في البرمجة
تطبيق عملي صغير لتسجيل القيم في الـ Adjacency Matrix
غالبًا عندما تتعامل مع الجراف سيكون دائمًا مع قائمة من الـ Node
والـ Edge
التي تريد تسجيلها في الـ Adjacency Matrix
والـ Node
يمكن أن يكون رقم أو حرف أو object
أو أي شيء آخر
والـ Edge
هي الروابط بين الـ Node
والآخر
فغالبا ما ستكون لديك بيانات مسبقة عن البيانات التي تريد تسجيلها بهذا الشكل
const users = [
{ id: 0, name: 'user_1' },
{ id: 1, name: 'user_2' },
{ id: 2, name: 'user_3' },
{ id: 3, name: 'user_4' },
{ id: 4, name: 'user_5' },
];
const relations = [
{ from: 0, to: 2 },
{ from: 0, to: 3 },
{ from: 1, to: 0 },
{ from: 2, to: 1 },
{ from: 3, to: 4 },
{ from: 4, to: 0 },
];
لاحظ مثلًا هنا لدينا 5
أشخاص و 6
روابط بينهم
هنا يمكنك اعتماد أن الأشخاص يمثلون الـ Node
وسنستخدم الـ id
لتمثيلهم
والروابط بينهم تمثل الـ Edge
وعن طريق from
و to
نستطيع معرفة من يستطيع التواصل مع من
الآن سنقوم بتسجيل هذه البيانات في الـ Adjacency Matrix
طبعًا نحتاج أولًا لإنشاء الـ Adjacency Matrix
وهنا سنقوم بتسجيل القيم بـ false
لكل العناصر
وهنا أنا فقط استخدم لغة الـ TypeScript
لكن يمكنك استخدام أي لغة برمجية تفضلها
// Create the graph (matrix 2D) with false values
const graph: boolean[][] = Array.from({ length: users.length }, () =>
Array(users.length).fill(false)
);
شكل الـ Adjacency Matrix
حاليًا سيكون فارغًا وكل قيمة فيه false
i \ j | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
0 | false | false | false | false | false |
1 | false | false | false | false | false |
2 | false | false | false | false | false |
3 | false | false | false | false | false |
4 | false | false | false | false | false |
الآن بعد أن أنشأنا الـ Adjacency Matrix
سنقوم بتسجيل الروابط فيه
for (let i = 0; i < relations.length; i++) {
let from = relations[i].from;
let to = relations[i].to;
graph[from][to] = true;
}
لاحظ أننا فقط نقوم بعمل for loop
على الـ relations
التي تمثل كل رابطة فيها Edge
ثم نستخدم from
و to
لتحديد أي Node
يمكنه التواصل مع الآخر
وفي النهاية نقوم بتسجيل القيمة true
في الـ Adjacency Matrix
لتحديد وجود الـ Edge
هكذا graph[from][to] = true
هكذا قمنا بتسجيل الروابط في الـ Adjacency Matrix
بكل سهولة
Adjacency List
طريقة أخرى لتمثيل الجراف هي الـ Adjacency List
وهي تستخدم Linked List
لتمثيل الجراف
وتعد الطريقة الأكثر استخدامًا في البرمجة لتمثيل الجراف لأنه يحجز فقط الذاكرة اللازمة للروابط فقط
بمعنى لو لدينا 1000
شخص فسيتم حجز فقط 1000
خانة في الذاكرة
ولوكل شخص يملك رابطة أي Edge
مع شخص آخر فسيتم حجز هذه Edge
فقط
يمكنك تخيل الـ Adjacency List
كأنها أراي من الـ Linked List
بمعنى أن كل عنصر أو index
في الأراي يمثل Linked List
خاص به
لنتخيل الأمر بالمثال الخاص بنا الذي يحتوي على 4
أشخاص والذي نمثلهم بالأرقام من 0
إلى 3
0 --------------> 1
| |
| |
v |
2 3
الآن الـ 0
ما هو إلا Node
أليس كذلك ؟
وهذه الـ Node
يمكنها التواصل مع 1
و 2
فهنا كأن الرقم 0
يمثل Linked List
يحتوي على 1
و 2
والـ 1
يمثل Linked List
يحتوي على 3
والـ 2
يمثل Linked List
فارغة لأنه لا يوجد Edge
بين 2
وأي Node
أو العكس
والرقم 3
يمثل Linked List
يحتوي على 1
0 : 1 -> 2
1 : 3
2 :
3 : 1
لنقم بتمثيل الجراف السابق في الـ Adjacency List
وهنا سنقوم بإنشاء أراي من الـ Linked List
وبالطبع إن كنت قد قرأت مقالة الـ LinkedList بديل الـ Array ؟ فيمكنك استخدامها لتعريف الجراف بطريقة الـ Adjacency List
هكذا
const graph: LinkedList[] = Array.from({ length: 4 }, () => new LinkedList());
الآن لدينا أراي فارغة وكل عنصر فيها يمثل Linked List
فارغة حاليًا
بالطبع أنت تستخدم اللغة البرمجية التي تدعم الـ Linked List
في الـ C++
يمكنك استخدام vector
وفي الـ Java
يمكنك استخدام ArrayList
وفي الـ TypeScript
يمكنك استخدام الـ الأراي العادية وهكذا
بالرفم من أنني أظن أنها أقرب إلى كونها Dynamic Array
أكثر من الـ Linked List
في بعض اللغات البرمجية مثل الـ C++
الآن بعد ما أنشأنا الـ Adjacency List
سواء بالـ Array
أو الـ LinkedList
سنقوم بتسجيل الروابط فيه
لاحظ شيء هنا أن الجراف هو أراي من الـ Linked List
وكل index
في الأراي يمثل Linked List
خاص به
بمعنى أن graph[0]
يمثل Linked List
خاص بـ 0
و graph[1]
يمثل Linked List
خاص بـ 1
و graph[2]
يمثل Linked List
خاص بـ 2
وهكذا ...
تذكر الشكل السابق للجراف الخاص بنا
0 --------------> 1
| |
| |
v |
2 3
أولًا سنقوم بإضافة الـ Edge
بين 0
و 1
وسنضيف أيضا واحد بين 0
و 2
graph[0].push(1);
graph[0].push(2);
لاحظ أننا استخدمنا الدالة push
لإضافة عنصر جديد إلى الـ Linked List
الخاص بـ 0
و 0
هنا هو الـ index
في الأراي الرئيسية لكننا نستخدمه كـ Node
لذا نقوم بإضافة الـ Edge
إليه
لآن أصبح الـ Adjacency List
الخاص بنا يبدو كالتالي
0 : 1 -> 2
1 :
2 :
3 :
الآن سنقوم بإضافة الـ Edge
بين 1
و 3
وسنضيف أيضا واحد بين 3
و 1
graph[1].push(3);
graph[3].push(1);
بما أن الـ Edge
ليس له اتجاه معين فهذا يعني أنه يمكن لـ 1
التواصل مع 3
والعكس
لذا اضفنا الـ Edge
بينهما في الـ Adjacency List
والآن الـ Adjacency List
الخاص بنا يبدو كالتالي
0 : 1 -> 2
1 : 3
2 :
3 : 1
هكذا قمنا بتمثيل الجراف في الـ Adjacency List
بنجاح
وهذه الطريقة تعتبر الأكثر استخدامًا في البرمجة لتمثيل الجراف لأنها تستهلك القليل من الذاكرة
لكن الاختلاف هنا أنك في الـ Adjacency Matrix
إذا أردت أن تعرف إذا كان هناك Edge
بين Node
والآخر فكل ما عليك فعله هو البحث في الـ index
المحدد هكذا
let from = 0;
let to = 1;
if (graph[from][to]) {
// There is an edge between 0 and 1
}
لكن في الـ Adjacency List
يجب عليك البحث في الـ Linked List
الخاص بالـ Node
وهذا يستهلك القليل من الوقت
let from = 0;
let to = 1;
if(graph[from].isExist(to)) {
// There is an edge between 0 and 1
}
لاحظ أننا هنا قمنا بالبحث في الـ Linked List
الخاص بـ 0
ونقول إذا كان يحتوي على 1
أم لا
باستخدام دالة isExist
التي تقوم بالبحث عن القيمة المحددة في الـ Linked List
لأنه تذكر أن graph[0]
يمثل Linked List
لذا لا يدعم مفهوم الـ index
مباشرة كما في الـ Adjacency Matrix
لذا عليك انشاء دالة تقوم بالبحث في الـ Linked List
الخاص بـ Node
لتعرف إذا كان هناك Edge
بينهم أم لا
لذا افترضنا أن هناك دالة isExist
تقوم بالبحث في الـ Linked List
وتعيد true
أو false
حسب النتيجة
ويمكنك تخيل الدالة بهذا الشكل
class LinkedList {
// ..
public isExist(value: number): boolean {
let current = this.head;
while (current != null) {
if (current.value === value) {
return true;
}
current = current.next;
}
return false;
}
}
لذا ستلاحظ بالطبع أن الـ Adjacency List
قد تستهلك بعض الوقت في البحث عن الـ Edge
بين الـ Node
مقارنة بالـ Adjacency Matrix
تطبيق عملي صغير لتسجيل القيم في الـ Adjacency List
لنستمر على على نفس النهج ونقوم بتسجل البيانات التالية في الـ Adjacency List
كما في المثال السابق والذي كان يقول أنه لدينا بضعة أشخاص وعدد من الروابط بينهم
const users = [
{ id: 0, name: 'user_1' },
{ id: 1, name: 'user_2' },
{ id: 2, name: 'user_3' },
{ id: 3, name: 'user_4' },
{ id: 4, name: 'user_5' },
];
const relations = [
{ from: 0, to: 2 },
{ from: 0, to: 3 },
{ from: 1, to: 0 },
{ from: 2, to: 1 },
{ from: 3, to: 4 },
{ from: 4, to: 0 },
];
أولًا سنقوم بإنشاء الـ Adjacency List
الخاص بنا
بحسب اللغة التي تستخدمها ستكون الطريقة مختلفة وكما قلت سابقًا هنا أنا استخدم الـ TypeScript
// Create the graph (array of Linked List) with empty values
const graph: LinkedList[] = Array.from({ length: users.length }, () => new LinkedList());
الآن بعد أن أنشأنا الـ Adjacency List
سنقوم بتسجيل الروابط فيه
for (let i = 0; i < relations.length; i++) {
let from = relations[i].from;
let to = relations[i].to;
graph[from].push(to);
}
نفس الطريقة السابقة نقوم بعمل for loop
على الـ relations
ونقوم بتسجيل الروابط في الـ Adjacency List
وكما عرفنا أن كل index
في الأراي يمثل Linked List
ونحن فقط نقوم بإضافة الـ Edge
إليه
عن طريق push
كما في الشرح السابق
هكذا قمنا بتسجيل الروابط في الـ Adjacency List
بكل سهولة
شكل الجراف سيكون كالتالي:
0 : 2 -> 3
1 : 0
2 : 1
3 : 4
4 : 0
هكذا قمنا بتمثيل الجراف في الـ Adjacency List
بنجاح
وزيادة تأكيد بأنك سجلت وبنيت الـ Adjacency List
بشكل صحيح يمكنك أن تقوم بطباعة الـ Linked List
الخاصة بكل Node
for (let i = 0; i < graph.length; i++) {
console.log('Node ' + i);
for (let node = graph[i].head; node != null; node = node.next) {
console.log('Connected to ' + node.value);
}
}
هنا نحن نلف على الـ graph
بحكم أنها أراي من الـ Linked List
ونقوم بطباعة الـ Node
الحالية ثم نلف على الـ Linked List
الخاصة بهذه الـ Node
لنطبع الـ Node
التي يمكنه التواصل معها
البحث في الجراف
البحث في الجراف يعتبر من أهم العمليات التي يمكن أن تقوم بها في الجراف ويوجد الكثير والكثير من الطرق للبحث في الجراف ومنها:
BFS
(Breadth First Search
) أيضًا معروف بـShortest Path First
وهو يبحث عن أقصر مسار بين نقطتين ويعتمد على مفهوم الـQueue
في التخزين والبحثDFS
(Depth First Search
) وهو أيضًا يبحث عن مسار معين بين نقطتين لكنه مختلف عن الـBFS
وميزته أنه يعتمد على مفهوم الـBacktracking
ويعتمد على مفهوم الـStack
في التخزين والبحثDijkstra
وهو يبحث عن أقصر مسار بين نقطتين ويعتبر من أهم الخوارزميات في الجراف ويعتمد على مفهوم الـGreedy Algorithm
لكنه يعمل على الجراف الذي يحتوي على أوزانWeighted Graph
وسنتعرف عليه لاحقًا في مقالة مستقلةA*
وهو يعتبر من أهم الخوارزميات في الذكاء الصناعي والتعلم الآلي وهو نسخة مطورة من الـDijkstra
ويعتمد على مفهوم الـHeuristic
يجب أن تكون المكان الذي تريد الوصول إليه معلومة مسبقًا وسنتعرف عليه لاحقًا في مقالة مستقلة أيضًا- ... والعديد والعديد من الخوارزميات الأخرى
هنا في هذه المقالة سنتعرف على الـ BFS
والـ DFS
فقط
BFS (Breadth First Search)
الـ BFS
هو خوارزمية بحث تعتمد على مفهوم الـ Queue
والذي شرحناه في مقالة بناء الـ Queue عن طريق الـ LinkedList
الـ BFS
هو أسلوب بحث يركز على أقصر مسافة بين الـ Node
والأخرى
بمعنى أنه يبدأ من الـ Node
الحالية التي تقف عليها ثم ينتقل إلى كل الـ Node
المجاورة لها
وعندما يمر على كل الـ Node
المجاورة لها ينتقل إلى الـ Node
المجاورة لها وهكذا حتى يصل إلى الـ Node
المطلوب
لنأخذ مثالًا بسيطًا لفهم الـ BFS
user_5 user_2 user_6
| | |
| | |
user_9 ---- user_1 ---- user_0 ---- user_3 ---- user_10
| | |
| | |
user_8 user_4 user_7
هذا الجراف يحتوي على 11
شخص وهم متصلين ببعضهم البعض
ولاحظ أن كل شخص لديه تواصل أو معارف مع بعض الأشخاص الآخرين
فالشخص الذي يمثل user_0
يمكنه التواصل مع user_1
و user_2
و user_3
و user_4
والشخص الذي يمثل user_1
يمكنه التواصل مع user_0
و user_5
و user_8
و user_9
وهكذا ...
لنتخيل أننا نقف عند الـ user_0
ونريد الوصول إلى الـ user_8
لكننا لا نعرف أين يقع الـ user_8
بالضبط
وقررنا استخدام الـ BFS
للوصول إلى الـ user_8
ما يفعله الـ BFS
كما قلنا أنه يبدأ من الـ Node
الحالية ثم ينتقل إلى كل الـ Node
المجاورة لها
لذا نحن بدأنا من الـ user_0
ثم ما سنفعله هو أننا سننتقل إلى كل الأشخاص المجاورين لـ user_0
ونرى هل user_8
موجود بينهم أم لا
كأنك تبحث عن شخص معين في الحي الذي تعيش فيه وتسأل كل جار لديك هل رأيت هذا الشخص أم لا
فالـ BFS
يعمل بنفس الطريقة فهو يبدأ بالجيران الذي يحيطون بك أولًا
بالتالي الخطواط التي سيتبعها الـ BFS
للوصول إلى الـ user_8
ستكون كالتالي
يبدأ من الـ user_0
ويرى هل الـ user_0
هو من نبحث عنه ؟
الاجابة لا لذا سنبحث ونرى جيرانه وهما user_1
و user_2
و user_3
و user_4
الـ BFS
سيضيف هؤلاء الأشخاص إلى Queue
ويبدأ بالبحث فيهم بالترتيب
لأن الـ Queue
يعمل بنظام FIFO
أي أن الأول الذي يدخل هو الأول الذي يخرج
وهذا ما نريده لأننا نريد البحث بالترتيب
بحيث أننا نريد أن نبحث في الجيران الأقرب إلينا أولًا ثم ننتقل إلى الجيران الأبعد عنا
هنا نحن نبحث في الجيران الأقرب إلى الـ user_0
أولًا وهم user_1
و user_2
و user_3
و user_4
Queue: [user_1, user_2, user_3, user_4]
ونبدأ بالبحث في الـ user_1
ونرى هل هو الـ user_8
الذي نبحث عنه ؟
الاجابة لا لذا سنخرجه من الـ Queue
ونرى الاشخاص الأخرين وهم user_2
و user_3
و user_4
لكن قبل أن نخرج الـ user_1
من الـ Queue
سنقوم بإضافة جيرانه إلى الـ Queue
لنبحث فيهم لاحقًا
جيران الـ user_1
هم user_0
و user_5
و user_8
و user_9
Queue: [user_2, user_3, user_4, user_0, user_5, user_8, user_9]
لكن انتظر لحظة ستلاحظ أننا أضفنا الـ user_0
إلى الـ Queue
وهذا خطأ لأننا قد بحثنا فيه من قبل
لذا لا نريد أن بحث فيه مرة أخرى لذا لكي نتجنب تكرار البحث في نفس الـ Node
مرتين
يمكننا إنشاء أراي بسيطة تدعى visited
على سبيل المثال ونقوم بتسجيل الـ Node
التي قمنا بالبحث فيها
بالتالي عندما ننتهي من التحقق من أي الـ Node
نقوم بتسجيله في الـ visited
وأيضًا قبل أن نضع أي Node
جديدة في الـ Queue
نقوم بالتحقق من الـ visited
أولًا هل تم البحث فيها من قبل أم لا
الآن شكل الـ Queue
و visited
سيكون كالتالي
Queue: [user_2, user_3, user_4, user_5, user_8, user_9]
Visited: [user_0, user_1]
لنراجع ما حصل
أولًا بدأنا من الـ user_0
وبحثنا فيه ووجدنا أنه ليس الـ user_8
الذي نبحث عنه
ثم وضعناه في الـ visited
لأننا قد بحثنا فيه
ثم بدأنا بالبحث في جيرانه وهم user_1
و user_2
و user_3
و user_4
ووضعناهم في الـ Queue
وبدأنا بالبحث فيهم
بدأنا بالبحث في الـ user_1
ووجدنا أنه ليس الـ user_8
الذي نبحث عنه
لذا بوضعه في الـ visited
ثم وضعنا جيرانه في الـ Queue
وهم user_0
و user_5
و user_8
و user_9
لكننا استثنينا الـ user_0
لأننا قد بحثنا فيه من قبل وهو موجود في الـ visited
حتى حصلنا على الشكل التالي
Queue: [user_2, user_3, user_4, user_5, user_8, user_9]
Visited: [user_0, user_1]
هل لاحظ شيء أخر ؟
جيران الـ user_1
هم user_0
و user_5
و user_8
و user_9
تم وضعهم في الـ Queue
ولكننا لن نبحث فيهم الآن
بل نبحث بالترتيب لذا سنبدأ بالبحث في الـ user_2
وهكذا
ونسأل هل الـ user_2
هو الـ user_8
الذي نبحث عنه ؟
الاجابة لا لذا سنخرجه من الـ Queue
ونضع جيرانه في الـ Queue
لكن ستجد أن user_0
هو جار الـ user_2
الوحيد
لكن بالطبع لقد بحثنا في الـ user_0
من قبل لذالن نضعه في الـ Queue
وبالطبع نضع الـ user_2
في الـ visited
لأننا قد بحثنا فيه
ونكمل البحث في الـ user_3
ونسأل هل هو الـ user_8
الذي نبحث عنه ؟
الاجابة لا لذا سنخرجه من الـ Queue
ونضع جيرانه في الـ Queue
وهم user_6
و user_10
و user_7
ما عدا الـ user_0
ونضع الـ user_3
في الـ visited
لأننا قد بحثنا فيه
ونكمل البحث في الـ user_4
ونسأل هل هو الـ user_8
الذي نبحث عنه ؟
الاجابة لا لذا سنخرجه من الـ Queue
ونضع جيرانه في الـ Queue
لكن سنجد أيضًا أن الـ user_1
هو جار الـ user_4
الوحيد لذا لن نضعه في الـ Queue
ثم نضع الـ user_4
في الـ visited
لأننا قد بحثنا فيه
الآن شكل الـ Queue
و visited
سيكون كالتالي
Queue: [user_5, user_8, user_9, user_6, user_10, user_7]
Visited: [user_0, user_1, user_2, user_3, user_4]
لاحظ أننا انتهينا من البحث في الـ user_0
وجيرانه وهم user_1
و user_2
و user_3
و user_4
ثم الآن سنتقل إلى الـ user_5
وهو أحد جيران الـ user_1
لاحظ كيف أن اسلوب البحث في الـ BFS
يعمل بالترتيب ويبدأ بالجيران الأقرب إليك أولًا
ثم عندما ينتهي من البحث في الجيران الأقرب إليك ينتقل إلى الجيران الأبعد عنك وهكذا
الآن سنكمل البحث في الـ user_5
ونسأل هل هو الـ user_8
الذي نبحث عنه ؟
الاجابة لا لذا سنخرجه من الـ Queue
ونضع جيرانه في الـ Queue
والذي يكون user_1
فقط
لكننا لن نضعه في الـ Queue
لأننا قد بحثنا فيه من قبل
ونضع الـ user_5
في الـ visited
لأننا قد بحثنا فيه
ونكمل البحث في الـ user_8
ونسأل هل هو الـ user_8
الذي نبحث عنه ؟
الاجابة نعم لذا سننهي البحث
هكذا وصلنا إلى الـ user_8
بنجاح عن طريق user_0
باستخدام الـ BFS
هذا هو الـ BFS
بشكل بسيط وسهل
بالطبع هذا مثال بسيط ولكن الـ BFS
يعتبر من أهم الخوارزميات في الجراف
حيث أنك يمكنك عن طريقه إيجاد أقصر مسار بين نقطتين
بالتالي يستخدم في تطبيقات الخرائط والألعاب والذكاء الصناعي والعديد من التطبيقات الأخرى
فعلى سبيل المثال في الألعاب يمكنك استخدام الـ BFS
لتحديد الطريق الأقصر بين اللاعب والهدف
أو تجعل الوحش يلاحق اللاعب بأقصر مسار ممكن
تطبيق عملي للـ BFS
لنقم بتطبيق الـ BFS
على الجراف السابق
أولًا سنقوم بإنشاء الجراف وتسجيل الروابط فيه كما في الشرح السابق
const users = [
{ id: 0, name: 'user_0' },
{ id: 1, name: 'user_1' },
{ id: 2, name: 'user_2' },
{ id: 3, name: 'user_3' },
{ id: 4, name: 'user_4' },
{ id: 5, name: 'user_5' },
{ id: 6, name: 'user_6' },
{ id: 7, name: 'user_7' },
{ id: 8, name: 'user_8' },
{ id: 9, name: 'user_9' },
{ id: 10, name: 'user_10' },
];
const relations = [
{ from: 0, to: 1 },
{ from: 0, to: 2 },
{ from: 0, to: 3 },
{ from: 0, to: 4 },
{ from: 1, to: 0 },
{ from: 1, to: 5 },
{ from: 1, to: 8 },
{ from: 1, to: 9 },
{ from: 2, to: 0 },
{ from: 3, to: 0 },
{ from: 3, to: 6 },
{ from: 3, to: 7 },
{ from: 3, to: 10 },
{ from: 4, to: 0 },
{ from: 5, to: 1 },
{ from: 6, to: 3 },
{ from: 7, to: 3 },
{ from: 8, to: 1 },
{ from: 9, to: 1 },
{ from: 10, to: 3 },
];
ثم سنقوم بإنشاء الـ Adjacency List
وتسجيل الروابط فيه
const graph: LinkedList[] = Array.from({ length: users.length }, () => new LinkedList());
for (let i = 0; i < relations.length; i++) {
let from = relations[i].from;
let to = relations[i].to;
graph[from].push(to);
}
الآن سنقوم بكتابة دالة الـ BFS
function bfs(graph: LinkedList[], start: number, target: number): boolean {
let queue: Queue = new Queue();
let visited: boolean[] = Array.from({ length: graph.length }, () => false);
queue.push(start);
visited[start] = true;
while (queue.length > 0) {
let current = queue.pop();
if (current === target) {
return true;
}
for (let node = graph[current].head; node != null; node = node.next) {
if (!visited[node.value]) {
queue.push(node.value);
visited[node.value] = true;
}
}
}
return false;
}
هنا قمنا بكتابة دالة الـ BFS
وسنشرحها بالتفصيل قليلًا وستلاحظ أنها مثل ما شرحناها سابقًا
يمكنك قراءتها وفهمها بنفسك إن أردت
قبل أن نشرحها سنقوم بتجربتها
bfs(graph, 0, 8); // true
هنا قمنا بتجربة الـ BFS
ونرى هل يمكننا الوصول من الـ user_0
إلى الـ user_8
أم لا
والنتيجة كانت true
لذا يمكننا الوصول من الـ user_0
إلى الـ user_8
يمكنك تعديل الدالة لتجعلها ترجع لك الـ Node
التي يمكنك الوصول إليها
أو المسار الذي يمكنك الوصول إليها
الآن سنشرح الدالة
أولًا قمنا بإنشاء الـ Queue
والـ visited
وبدأنا بوضع الـ start
في الـ Queue
وجعلناه visited
let queue: Queue = new Queue();
let visited: boolean[] = Array.from({ length: graph.length }, () => false);
queue.push(start);
visited[start] = true;
وفي حالتنا هنا الـ start
هو الـ user_0
ثم بدأنا بالـ while loop
ونقوم بالبحث في الـ Queue
ونقوم بإخراج أخر Node
دخلت الـ Queue
ونرى هل هو الـ target
الذي نبحث عنه ؟
while (queue.length > 0) {
let current = queue.pop();
if (current === target) {
return true;
}
// ...
إذا كان الـ current
هو الـ target
الذي نبحث عنه نرجع true
لكن بالطبع حاليًا الـ current
هو الـ user_0
وليس الـ target
الذي نبحث عنه والذي هو الـ user_8
في حالة أن الـ current
هو الـ target
نرجع true
وننهي البحث
وفي حالة أن الـ current
ليس الـ target
نقوم بالبحث في جيرانه
ونضعهم في الـ Queue
ونجعلهم visited
for (let node = graph[current].head; node != null; node = node.next) {
if (!visited[node.value]) {
queue.push(node.value);
visited[node.value] = true;
}
}
حاليًا الـ current
هو الـ user_0
ونبحث في جيرانه وهم user_1
و user_2
و user_3
و user_4
وكلهم ليسو visited
لذا نضعهم في الـ Queue
ونجعلهم visited
كما ترى في الشرط if (!visited[node.value])
والذي يتحقق من أن الـ Node
هل تم البحث فيه من قبل أم لا
لكي لا نكرر البحث في نفس الـ Node
مرتين والذي سيأدي إلى حدوث Infinite Loop
هكذا نكمل البحث حتى نصل إلى الـ target
أو حتى ننتهي من الـ Queue
إذا وجدنا الـ target
نرجع true
وإذا أنهينا الـ Queue
ولم نجد الـ target
نرجع false
هذا هو الـ BFS
بشكل بسيط وسهل
DFS (Depth First Search)
الـ DFS
هو خوارزمية بحث تعتمد على مفهوم الـ Stack
(يمكننا أيضًا استخدام الـ Recursion
لتطبيقه)
لكننا في هذا المثال سنستخدم الـ Stack
البسيط ويمكنك قراءة مقالة بناء الـ Stack عن طريق الـ LinkedList لفهم كيفية بناء الـ Stack
وأسلوب عمله
على عكس الـ BFS
الذي كان يبحث أولًا في الجيران الأقرب إليك ثم ينتقل إلى الجيران الأبعد عنك
يقوم الـ DFS
بالبحث في العمق أولًا بمعنى أنه يبدأ من الـ Node
الحالية ثم ينتقل إلى إحدى جاراتها
ثم ينتقل إلى أحدى جارات الـ Node
الذي اختاره ثم ينتقل إلى أحدى جاراتها وهكذا حتى يصل إلى الـ Node
المطلوب
فهو يبحث في العمق أولًا على عكس الـ BFS
الذي كان ببحث بشكل عرضي أي أنه يبحث في الذي يحيط به أولًا
هنا الـ DFS
يبحث في العمق أولًا ويظل يتعمق حتى يصل إلى الـ Node
المطلوبة
أول يصل إلى طريق مسدود وحينها يبدأ في الرجوع للخلف ويبحث في الـ Node
أخرى ويظل يبحث ويتعمق فيها وهكذا
فكرة الرجوع للخلف واتخاذ طريق آخر تسمى Backtracking
وهي فكرة مهمة في الـ DFS
ويمكنك قراءة مقالة كودك بـ Recursion نفسه ؟ لفهم هذه الفكرة بشكل أفضل
على أي حالة فكرة الـ Recursion
تعتبر من أهم الأفكار في الـ DFS
والتي يمكنك استخدامها لتطبيق الـ DFS
لكننا لن نستخدم الـ Recursion
في الشرح بالسنستخدم Data Structure
الـ Stack
في هذه المقالة
لنأخذ مثالًا لفهم الـ DFS
وهذا المثال سيكون نفس الجراف الذي استخدمناه في الـ BFS
user_5 user_2 user_6
| | |
| | |
user_9 ---- user_1 ---- user_0 ---- user_3 ---- user_10
| | |
| | |
user_8 user_4 user_7
لنفترض أننا نقف عند الـ user_0
ونريد الوصول إلى الـ user_8
باستخدام الـ DFS
نبدأ من الـ user_0
وننظر إلى جيرانه user_1
, user_2
, user_3
, user_4
هنا في الـ DFS
سنختار أحد الجيران ونبدأ بالبحث فيه فورًا لذا سنختار أول جارة (حسب ترتيب معين مثل ترتيب الإدخال) وفي هذه الحالة نختار user_1
الآن نكرر العملية مع user_1
ننظر إلى جاراته user_0
, user_5
, user_8
, user_9
نستثني user_0
لأنه تم زيارته سابقًا
هنا سنختار أحد الجيران وسيكون user_5
ونكرر العملية معه وهكذا حتى نصل إلى الـ user_8
هل تلاحظ الفرق بين الـ DFS
والـ BFS
؟
الـ DFS
يركز على أول Node
يجده ويبدأ بالبحث فيه فورًا ويتعمق فيه دون الاهتمام أو معرفة بالـ Node
الأخرى الذي تحيط به
أما الـ BFS
يركز على الـ Node
الذي تحيط به أولًا ويبدأ بالبحث فيهم
حسنًا لنبدأ بتحليل الخطوات الذي سيتبعها الـ DFS
للوصول إلى الـ user_8
نبدأ من الـ user_0
ونسأل السؤال المعتاد هل هو الـ user_8
الذي نبحث عنه ؟
الاجابة لا لذا سنبدأ بالبحث في جيرانه وهم user_1
, user_2
, user_3
, user_4
الـ DFS
سيضع كل هؤلاء الأشخاص في الـ Stack
ويبدأ بالبحث في فيهم
وبما أن الـ Stack
يعمل بنظام LIFO
أي أن آخر الذي يدخل هو الأول الذي يخرج
فسنفترض أن الـ DFS
وضعهم في الـ Stack
بالترتيب التالي user_4
, user_3
, user_2
, user_1
Stack: [user_4, user_3, user_2, user_1]
Visted: [user_0]
هنا أخر Node
دخلت الـ Stack
هو الـ user_1
لذا سيبدأ البحث فيه
ويرى جيرانه وهم user_0
, user_5
, user_8
, user_9
ويضعهم في الـ Stack
ويبدأ بالبحث فيهم
لكنه سيستثني الـ user_0
لأنه تم زيارته سابقًا وموضوع في الـ visited
كما في الـ BFS
Stack: [user_4, user_3, user_2, user_5, user_8, user_9]
Visted: [user_0, user_1]
هنا ما هي الـ Node
التي عليها الدور ؟
ستجدها هي الـ user_9
لأنها آخر من دخل الـ Stack
لذا سيرى جيرانها وهم user_1
ولكنه تم زيارته سابقًا وموجود في الـ visited
لذا لن يضعه في الـ Stack
هكذا وصلنا إلى طريق مسدود هنا سينظر إلى الـ Stack
ويبدأ بالبحث في الـ Node
التي تم وضعها قبل الـ user_9
وهي الـ user_8
وهنا سيجد الـ user_8
وينهي البحث
هذا هو الـ DFS
بشكل بسيط وسهل
وهو يعتبر من الخوارزميات الهامة في الجراف والتي يمكنك استخدامها في العديد من التطبيقات
ويمكنك تطبيقها بسهولة على الجراف الذي تريده والبحث فيه
تطبيق عملي للـ DFS
لنقم بكتابة دالة الـ DFS
function dfs(graph: LinkedList[], start: number, target: number): boolean {
let stack: Stack = new Stack();
let visited: boolean[] = Array.from({ length: graph.length }, () => false);
stack.push(start);
visited[start] = true;
while (stack.length > 0) {
let current = stack.pop();
if (current === target) {
return true;
}
for (let node = graph[current].head; node != null; node = node.next) {
if (!visited[node.value]) {
stack.push(node.value);
visited[node.value] = true;
}
}
}
return false;
}
هل تلاحظ أن الدالة تشبه الـ BFS
؟
الدالة هي نفس الدالة تماما دون فرق، والاختلاف الوحيد أننا استخدمنا الـ Stack
بدلا من الـ Queue
فقط
وهذا يعتبر الفرق الوحيد بين الـ DFS
والـ BFS
من ناحية كتابة الكود
لكن طريقة البحث تختلف تمامًا بين الـ DFS
والـ BFS
باختلاف الـ Data Structure
المستخدمة
لنقم بتجربة الـ DFS
dfs(graph, 0, 8); // true
بالطبع يوجد شكل آخر للـ DFS
وهو استخدام الـ Recursion
وهو الشكل الأكثر استخدامًا للـ DFS
لأنه يعطي سلاسة ويطبق مفهوم الـ Backtracking
بشكل أفضل
على أي حال لم اقوم بشرح الدالة بل أعتقد أنك تستطيع فهمها بنفسك
لذا قم برسم جراف عشوائي واستخدم ورقة وقلم وحاول تطبيق كلا الـ BFS
والـ DFS
عليه وحاول تتبعه وفهمه
الختام
هذه المقالة طويلة بعض الشيء ولكنها تحتوي على معلومات هامة ومفيدة
ولكن عليك أن تعرف أن هذه مجرد مقدمة للجراف والخوارزميات المتعلقة به
ويوجد خوارزمات وأمور وتفاصيل يصعب شرحها في مقالة واحدة
لذا اكتفيت بمقدمة بسيطة ومثالين عمليين للـ BFS
والـ DFS
حتى مثالين بسيطين ليس إلا
لأنك فيما بعد ستجد تطبيقات معقدمة لكن إن كان لديك فهم جيد لأساسيات الجراف وتستطيع تخيل كيفية البحث فيه
باستخدام الـ BFS
والـ DFS
فكل شيء سيكون سهلًا مع الوقت والتطبيق
قد أقوم بعمل مقالة عن الـ Maze
بمثال واقعي وتطبيق للـ BFS
والـ DFS
عليه
لكن هذا قد يأخذ وقتصا لأنني افكر بشيء ممتع قد يستغرق وقتًا طويلًا للتحضير
على أي حال أتمنى أن تكون قد استفدت من هذه المقالة وأن تكون قد فهمت الفكرة الأساسية للجراف والـ BFS
والـ DFS
وأن تكون قد استمتعت بالقراءة والتطبيق
وأهم نصيحة لك أن يكون لديك ورقة وقلم وترسم الجراف وتطبق عليه الخوارزميات وتتبعها وتفهمها