البحث في الموقع
المحتوى عن 'الخوارزميات للمحترفين'.
-
البرمجة الديناميكية هي مفهوم يُستخدم على نطاق واسع، وغالبًا ما تستخدم لأجل التحسين optimization، ومبدأ عملها هو تبسيط المشكلة المعقدة عبر تقسيمها إلى مشاكل تكرارية recursive فرعية وأبسط من المشكلة الأصلية. هناك صفتان رئيسيتان لا بد من وجودهما في المشكلة حتى يمكن تطبيق البرمجة الديناميكية عليها، وهما: البنية المثلى Optimal substructure، والتداخل بين المشاكل الفرعية Overlapping sub-problems. تستخدم البرمجة الديناميكية مفهومًا يسمى التذكير memoization من أجل العثور على الحل المثالي. مسافة التحرير إذا أُعطِيت سلسلتين نصيتين str1 وstr2، فما هو العدد الأدنى للعمليات التي يمكن إجراؤها على str1 لتحويلها إلى str2؟ في تلك الحالة يكون إجراء هاتين الوظيفتين معًا؛ أما خلاف ذلك فسنتحقّق مما إذا كان؟ انظر تنفيذ الحل أدناه في لغة جافا لهذه المشكلة: public class EditDistance { public static void main(String[] args) { // TODO Auto-generated method stub String str1 = "march"; String str2 = "cart"; EditDistance ed = new EditDistance(); System.out.println(ed.getMinConversions(str1, str2)); } public int getMinConversions(String str1, String str2){ int dp[][] = new int[str1.length()+1][str2.length()+1]; for(int i=0;i<=str1.length();i++){ for(int j=0;j<=str2.length();j++){ if(i==0) dp[i][j] = j; else if(j==0) dp[i][j] = i; else if(str1.charAt(i-1) == str2.charAt(j-1)) dp[i][j] = dp[i-1][j-1]; else{ dp[i][j] = 1 + Math.min(dp[i-1][j], Math.min(dp[i][j-1], dp[i-1][j-1])); } } } return dp[str1.length()][str2.length()]; } } سيكون الخرج الناتج: 3 خوارزمية جدولة المهام الموزونة Weighted Job Scheduling Algorithm يُطلق أحيانًا على خوارزمية جدولة المهام الموزونة اسم خوارزمية اختيار الأنشطة الموزونة Weighted Activity Selection Algorithm، حيث تحاول هذه الخوارزمية حل المشكلة التالية: إذا كانت لديك قائمة من الوظائف وكان لكل وظيفة وقت بدء ووقت انتهاء والربح الذي تحقّقه عند الانتهاء منها، فما هو أقصى ربح يمكن أن تحقّقه؟ علمًا بأنّه لا يمكنك تنفيذ وظيفتين في الوقت نفسه؟ تشبه هذه المشكلة مشكلة "اختيار النشاط باستخدام الخوارزمية الجشعة Greedy Algorithm"، لكن هناك جانبًا آخر ينبغي أن يُؤخذ بالحسبان، إذ يجب نركّز على تحقيق أقصى قدر من الأرباح بدلًا من زيادة عدد الوظائف المطلوبة إلى أقصى حد، ولا يهم هنا عدد الوظائف المنجزة. لنأخذ مثالا عمليًا: +---------------------------------+---------+---------+---------+---------+---------+--------+ | Name | A | B | C | D | E | F | +---------------------------------+---------+---------+---------+---------+---------+--------+ |(Start Time, Finish Time) | (2,5) | (6,7) | (7,9) | (1,3) | (5,8) | (4,6) | +---------------------------------+---------+---------+---------+---------+---------+--------+ | Profit | 6 | 4 | 2 | 5 | 11 | 5 | +---------------------------------+---------+---------+---------+---------+---------+--------+ يحتوي الجدول أعلاه على أسماء الوظائف ووقت بدايتها ونهايتها وأجرها. وتستطيع ملاحظة أنك إذا قمت بالعملين A وE، فستحصل على أقصى ربح ممكن (وهو 17)، لكن السؤال الآن هو: كيف يمكن العثور على ذلك باستخدام خوارزمية؟ أول شيء سنفعله هو ترتيب الوظائف حسب أوقات انتهائها ترتيبًا غير متناقص non-decreasing order، وذلك لأنّه عند اختيار وظيفة تستغرق وقتًا قليلًا لتنفيذها، سيتبّقى وقت أطول ينقضي في اختيار الوظائف الأخرى. انظر أدناه حيث الوظائف مرتبةً: +--------------------------------+---------+---------+---------+---------+---------+-------+ | Name | D | A | F | B | E | C | +--------------------------------+---------+---------+---------+---------+---------+-------+ |(Start Time, Finish Time) | (1,3) | (2,5) | (4,6) | (6,7) | (5,8) | (7,9) | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Profit | 5 | 6 | 5 | 4 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ ستصبح لدينا المصفوفة المؤقّتة الإضافية Acc_Prof التي يساوي حجمها n (إجمالي عدد الوظائف)، وتحتوي أقصى قدر من الأرباح المتراكمة جرّاء أداء الوظائف. لتوضيح الفكرة، سنهيّئ initializing قيم المصفوفة بأرباح كل وظيفة، وهذا يعني أنّ العنصر Acc_Prof سيخزّن في البداية أرباح إنجاز الوظيفة رقم i. +-------------------------+---------+---------+---------+---------+---------+---------+ | Acc_Prof | 5 | 6 | 5 | 4 | 11 | 2 | +-------------------------+---------+---------+---------+---------+---------+---------+ سنشير الآن إلى الموضع 2 بالرمز i، وإلى الموضع 1 بالرمز j. تُبنى استراتيجيتنا هنا على تكرار j من 1 إلى i-1، ونزيد i بمقدار 1 عقب كلّ تكرار إلى أن تساوي i قيمة n +1: j i +----------------------------------+---------+---------+---------+---------+---------+-------+ | Name | D | A | F | B | E | C | +----------------------------------+---------+---------+---------+---------+---------+-------+ |(Start Time, Finish Time) | (1,3) | (2,5) | (4,6) | (6,7) | (5,8) | (7,9) | +----------------------------------+---------+---------+---------+---------+---------+-------+ | Profit | 5 | 6 | 5 | 4 | 11 | 2 | +----------------------------------+---------+---------+---------+---------+---------+-------+ | Acc_Prof | 5 | 6 | 5 | 4 | 11 | 2 | +----------------------------------+---------+---------+---------+---------+---------+-------+ سنتحقق الآن ممّا إذا كان هناك تداخل بين الوظيفتين و[j]، أي إذا كان وقت إنتهاء الوظيفة [j] أكبر من وقت بدء الوظيفة ، إذ لا يمكن في تلك الحالة إجراء هاتين الوظيفتين معًا؛ أما خلاف ذلك فسنتحقّق مما إذا كان Acc_Prof[j] + Profit > Acc_Prof، وإذا كان كذلك، فسنجري التحديث الآتي: Acc_Prof = Acc_Prof[j] + Profit if Job[j].finish_time <= Job[i].start_time if Acc_Prof[j] + Profit[i] > Acc_Prof[i] Acc_Prof[i] = Acc_Prof[j] + Profit[i] endif endif تمثّل Acc_Prof[j] + Profit هنا الربح المتراكم الناتج عن القيام بهاتين الوظيفتين، وتتداخل الوظيفة [j] مع ، لذا لا يمكن القيام بهما معًا. وبما أن j يساوي i-1، فسنزيد قيمة i إلى i +1، أي 3، ثمّ نجعل j = 1. j i +---------------------------------+---------+---------+---------+---------+---------+-------+ | Name | D | A | F | B | E | C | +---------------------------------+---------+---------+---------+---------+---------+-------+ |(Start Time, Finish Time) | (1,3) | (2,5) | (4,6) | (6,7) | (5,8) | (7,9) | +---------------------------------+---------+---------+---------+---------+---------+-------+ | Profit | 5 | 6 | 5 | 4 | 11 | 2 | +---------------------------------+---------+---------+---------+---------+---------+-------+ | Acc_Prof | 5 | 6 | 5 | 4 | 11 | 2 | +---------------------------------+---------+---------+---------+---------+---------+-------+ لا تتداخل الوظيفتان [j] و الآن، أمّا الربح الإجمالي الذي يمكن تحصيله بتنفيذهما، فهو Acc_Prof[j] + Profit = 5 + 5 = 10، وهو أكبر من Acc_Prof ، لذا نجري التحديث Acc_Prof = 10، كما نزيد أيضًا قيمة j بـ 1 لنحصل على ما يلي: j i +--------------------------------+---------+---------+---------+---------+---------+-------+ | Name | D | A | F | B | E | C | +--------------------------------+---------+---------+---------+---------+---------+-------+ |(Start Time, Finish Time) | (1,3) | (2,5) | (4,6) | (6,7) | (5,8) | (7,9) | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Profit | 5 | 6 | 5 | 4 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Acc_Prof | 5 | 6 | 10 | 4 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ تتداخل هنا [j] مع ، بينما تساوي j قيمة i-1، لذا نزيد i بمقدار 1، ونجعل j = 1 لنحصل على التالي: j i +--------------------------------+---------+---------+---------+---------+---------+-------+ | Name | D | A | F | B | E | C | +--------------------------------+---------+---------+---------+---------+---------+-------+ |(Start Time, Finish Time) | (1,3) | (2,5) | (4,6) | (6,7) | (5,8) | (7,9) | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Profit | 5 | 6 | 5 | 4 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Acc_Prof | 5 | 6 | 10 | 4 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ الآن، لا تتداخل الوظيفتان [j] و، بينما يساوي الربح المتراكم القيمة 5 + 4 = 9، وهو أكبر من Acc_Prof، لذا نجري التحديث Acc_Prof = 9، ونزيد j بمقدار 1. j i +--------------------------------+---------+---------+---------+---------+---------+-------+ | Name | D | A | F | B | E | C | +--------------------------------+---------+---------+---------+---------+---------+-------+ |(Start Time, Finish Time) | (1,3) | (2,5) | (4,6) | (6,7) | (5,8) | (7,9) | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Profit | 5 | 6 | 5 | 4 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Acc_Prof | 5 | 6 | 10 | 9 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ كذلك، لا تتداخل [j] و الآن، أمّا الربح المتراكم فيساوي: 6 + 4 = 10، وهو أكبر من Acc_Prof . سنجري التحديث Acc_Prof = 10 مرّةً أخرى ، ونزيد j بـ 1 لنحصل على: j i +--------------------------------+---------+---------+---------+---------+---------+-------+ | Name | D | A | F | B | E | C | +--------------------------------+---------+---------+---------+---------+---------+-------+ |(Start Time, Finish Time) | (1,3) | (2,5) | (4,6) | (6,7) | (5,8) | (7,9) | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Profit | 5 | 6 | 5 | 4 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Acc_Prof | 5 | 6 | 10 | 10 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ إذا واصلنا هذه العملية وكررنا الجدول بأكمله، فسيبدو جدولنا كما يلي: +--------------------------------+---------+---------+---------+---------+---------+-------+ | Name | D | A | F | B | E | C | +--------------------------------+---------+---------+---------+---------+---------+-------+ |(Start Time, Finish Time) | (1,3) | (2,5) | (4,6) | (6,7) | (5,8) | (7,9) | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Profit | 5 | 6 | 5 | 4 | 11 | 2 | +--------------------------------+---------+---------+---------+---------+---------+-------+ | Acc_Prof | 5 | 6 | 10 | 14 | 17 | 8 | +--------------------------------+---------+---------+---------+---------+---------+-------+ إذا كررنا عبر المصفوفة Acc_Prof، فسنجد أنّ الربح الأقصى الممكن هو 17. انظر إلى الشيفرة التوضيحية التالية: Procedure WeightedJobScheduling(Job) sort Job according to finish time in non-decreasing order for i -> 2 to n for j -> 1 to i-1 if Job[j].finish_time <= Job[i].start_time if Acc_Prof[j] + Profit[i] > Acc_Prof[i] Acc_Prof[i] = Acc_Prof[j] + Profit[i] endif endif endfor endfor maxProfit = 0 for i -> 1 to n if maxProfit < Acc_Prof[i] maxProfit = Acc_Prof[i] return maxProfit تعقيد تعبئة المصفوفة Acc_Prof هو O(n2)، بينما يستغرق اجتياز المصفوفة O(n)، لذا فإنّ التعقيد الكلي لهذه الخوارزمية هو O (n2). والآن، إذا أردنا العثور على الوظائف التي تحقّق أقصى قدر من الربح، فسنحتاج إلى عبور المصفوفة أو اجتيازها بترتيب عكسي، وإذا تطابق Acc_Prof مع الربح الأقصى maxPro، فسندفع push اسم الوظيفة إلى مكدّس stack، ونطرح أرباح تلك الوظيفة من قيمة maxPro. سنستمرّ في فعل هذا حتى يصير maxProT t> 0، أو نصل إلى نقطة البداية في المصفوفة Acc_Prof. وستبدو الشيفرة التوضيحية كالتالي: Procedure FindingPerformedJobs(Job, Acc_Prof, maxProfit): S = stack() for i -> n down to 0 and maxProfit > 0 if maxProfit is equal to Acc_Prof[i] S.push(Job[i].name maxProfit = maxProfit - Job[i].profit endif endfor تعقيد هذا الإجراء هو O (n). أطول تسلسل مشترك Longest Common Subsequence لتكن str1 وstr2 سلسلتان نصيّتان، حيث سنحاول العثور على أطول تتابع sub-sequence مشترك بينهما من الحروف. انظر إلى الأمثلة التوضيحية التالية: أكبر تتابع مشترك للسلسلتين النصيتين “ABCDGH” و“AEDFHR” هو “ADH”، وطوله 3. أطول تتابع مشترك بين “AGGTAB” و“GXTXAYB” هو “GTAB”، وطوله 4. فيما يلي تطبيق للحلّ في لغة جافا: public class LCS { public static void main(String[] args) { // TODO Auto-generated method stub String str1 = "AGGTAB"; String str2 = "GXTXAYB"; LCS obj = new LCS(); System.out.println(obj.lcs(str1, str2, str1.length(), str2.length())); System.out.println(obj.lcs2(str1, str2)); } //Recursive function public int lcs(String str1, String str2, int m, int n){ if(m==0 || n==0) return 0; if(str1.charAt(m-1) == str2.charAt(n-1)) return 1 + lcs(str1, str2, m-1, n-1); else return Math.max(lcs(str1, str2, m-1, n), lcs(str1, str2, m, n-1)); } //دالة تكرارية public int lcs2(String str1, String str2){ int lcs[][] = new int[str1.length()+1][str2.length()+1]; for(int i=0;i<=str1.length();i++){ for(int j=0;j<=str2.length();j++){ if(i==0 || j== 0){ lcs[i][j] = 0; } else if(str1.charAt(i-1) == str2.charAt(j-1)){ lcs[i][j] = 1 + lcs[i-1][j-1]; }else{ lcs[i][j] = Math.max(lcs[i-1][j], lcs[i][j-1]); } } } return lcs[str1.length()][str2.length()]; } } سيكون الخرج الناتج كالآتي: 4 أعداد فيبوناتشي هذا منظور تصاعدي لطباعة عدد فيبوناتشي التاسع باستخدام البرمجة الديناميكية: انظر إلى الشجرة التكرارية التالية: fib(5) / \ fib(4) fib(3) / \ / \ fib(3) fib(2) fib(2) fib(1) / \ / \ / \ fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) / \ fib(1) fib(0) في هذا المثال، تكون كل من fib(0) وfib(1) وfib(3) هي المشاكل المتداخلة الفرعية overlapping sub-problems، إذ أنّ b(0) تكرّرت 3 مرات، في حين تكرّرت fib(1) خمس مرات، وتكرّرت fib(3) مرّتين. انظر الشيفرة التالية: public int fib(int n){ int f[] = new int[n+1]; f[0]=0;f[1]=1; for(int i=2;i<=n;i++){ f[i]=f[i-1]+f[i-2]; } return f[n]; } تعقيد الشيفرة أعلاه يساويO(n) . أطول سلسلة نصية فرعية مشتركة Longest Common Substring إذا كان لدينا السلسلتان النصيتان str1 وstr2، فيجب أن نبحث عن أطول سلسلة نصية فرعية مشتركة longest common substring بينهما. انظر الأمثلة التوضيحية التالية: الدخل: x = abcdxyz وy = xyzabcd والخرج: 4 -، لأنّ أطول سلسلة نصية فرعية مشتركة هي abcd، ويبلغ طولها 4. الدخل: x = zxabcdezy وy = yzabcdezx والخرج: 6 -، لأنّ أطول سلسلة نصية فرعية مشتركة هي abcdez، وطولها هو 6. هذا تطبيق في لغة جافا لخوارزمية تحلّ هذه المشكلة: public int getLongestCommonSubstring(String str1,String str2){ int arr[][] = new int[str2.length()+1][str1.length()+1]; int max = Integer.MIN_VALUE; for(int i=1;i<=str2.length();i++){ for(int j=1;j<=str1.length();j++){ if(str1.charAt(j-1) == str2.charAt(i-1)){ arr[i][j] = arr[i-1][j-1]+1; if(arr[i][j]>max) max = arr[i][j]; } else arr[i][j] = 0; } } return max; } التعقيد الزمني للخوارزمية أعلاه يساوي O(m*n). تطبيقات البرمجة الديناميكية تقوم البرمجة الديناميكية على فكرة تقسيم مشكلة معقدة إلى مشاكل أصغر وأبسط متكرّرة كما ذكرنا أول حديثنا، وسنركّز في هذه الفقرة على التطبيقات، وليس على إنشاء خوارزميات البرمجة الديناميكية. تعرضنا لأعداد فيبوناتشي كذلك قبل قليل، وتعَد هذه الأعداد من المواضيع الرئيسية للبرمجة الديناميكية، إذ أنّ المنظور التكراري التقليدي يتطلّب الكثير من الحسابات المكرّرة. سنستخدم في هذه الأمثلة الحالة الأساسية f( 0) = f (1) =1. فيما يلي مثال على شجرة تكرارية لـ fibonacci(4)، لاحظ أنّ هناك حسابات مكرّرة: في البرمجة غير الديناميكية، يساوي التعقيد الزمني O(2^n)، فيما يساوي تعقيد المكدّس Stack complexity القيمة O(n). def fibonacci(n): if n < 2: return 1 return fibonacci(n-1) + fibonacci(n-2) هذه هي الطريقة البديهية لحلّ المشكلة، حيث لن تتجاوز مساحة المكدّس stack space القيمة O(n) عندما تنزل إلى الفرع الأول التكراري recursive branch مجريًا استدعاءات لـ fibonacci(n-1) تباعًا إلى أن تصل إلى الحالة الأساسية (أي n < 2). النقطة الرئيسية هنا هي أنّ وقت التشغيل أسّي exponential، ما يعني أنّه سيتضاعف في كل خطوة، حيث سيستغرق fibonacci(15) مثلًا ضعف المدة التي يستغرقها fibonacci(14) الخوارزمية التذكيرية Memoized: التعقيد الزمني يساوي O(n)، ويساوي تعقيد المساحة Space complexity القيمة O(n)، فيما يساوي تعقيد المكدّس O(n). memo = [] memo.append(1) # f(1) = 1 memo.append(1) # f(2) = 1 def fibonacci(n): if len(memo) > n: return memo[n] result = fibonacci(n-1) + fibonacci(n-2) memo.append(result) # f(n) = f(n-1) + f(n-2) return result نقدّم باستخدام المنظور التذكيري memoized approach مصفوفةً تمثّل جميع استدعاءات الدوال السابقة ، ويكون الموقع memo[n] نتيجةً لاستدعاء الدالة fibonacci(n). يتيح لنا هذا مقايضة تعقيد مساحة O(n) بوقت تشغيل O(n)، إذ ستنتفي الحاجة إلى إعادة حساب استدعاءات الدوالّ المكررة. البرمجة الديناميكية التكرارية وقت التشغيل يساوي O(n)، وتعقيد المساحة هو O(n) بدون مكدّس تكراري recursive stack. def fibonacci(n): memo = [1,1] # f(0) = 1, f(1) = 1 for i in range(2, n+1): memo.append(memo[i-1] + memo[i-2]) return memo[n] إذا قسّمت المشكلة إلى عناصرها الأساسية فستلاحظ أنّه لحساب fibonacci(n). تحتاج إلى حساب fibonacci(n-1) وfibonacci(n-2)، كما يمكن ملاحظة أنّ الحالة الأساسية ستظهر في نهاية تلك الشجرة التكرارية كما هو موضّح أعلاه. صار من المنطقي الآن باستخدام هذه المعلومات أن نحسب الحل من الخلف، أي بدءًا من الحالات الأساسية، ثمّ الصعود إلى الأعلى. ولكي نحسب الآن fibonacci(n)، sنحتاج إلى حساب جميع أعداد فيبوناتشي الخاصة بكل الأعداد الأصغر من n. الفائدة الرئيسية لهذا المنظور هي أنّنا لم نَعُد نحتاج إلى مكدّس تكراري، مع الحفاظ على وقت التشغيل O(n) في نفس الوقت. لا يزال تعقيد المساحة هو O(n)، لكن يمكن تغيير ذلك أيضًا. البرمجة الديناميكية التكرارية المتقدمة Advanced Iterative Dynamic Programming: يساوي وقت التشغيل O(n)، أما تعقيد المساحة فهو O(1)، وبدون مكدّس تكراري recursive stack. def fibonacci(n): memo = [1,1] # f(1) = 1, f(2) = 1 for i in range (2, n): memo[i%2] = memo[0] + memo[1] return memo[n%2] يبدأ منظور البرمجة الديناميكية التكرارية من الحالات الأساسية كما لوحظ أعلاه، وتصعد انطلاقًا منها إلى النتيجة النهائية. تنطبق نفس الملاحظة التي أشرنا لها سابقًا بخصوص المكدّس التكراري هنا، فلأجل تحويل تعقيد المساحة إلى O(1) (أي تعقيد ثابت)، لن نحتاج إلّا إلى fibonacci(n-1) وfibonacci(n-2) لبناء قيمة فيبوناتشي fibonacci(n)، وهذا يعني أننا سنكتفي بحفظ نتيجتي (n-1) و(n-2) وحسب في كلّ مرة. لتخزين آخر نتيجتين، نستخدم مصفوفة ثنائية مع تغيير قيمة الفهرس الذي سنعيّنه باستخدام الصيغة i % 2، والتي تتراوح قيمتها بين 0 و1 كما يلي: 0، 1، 0، 1، 0، 1، ...، i % 2. وقد أضفنا فهرسي المصفوفة معًا لعلمنا أنّ الإضافة تبادلية (5 + 6 = 11 و 6 + 5 =11)، ثم عيّنّا النتيجة إلى أقدم النقطتين (المُشار إليها بواسطة i % 2)، وخزّنّا النتيجة النهائية بعد ذلك في الموضع n%2. ترجمة -بتصرّف- للفصلين 14 و 15 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقالة السابقة: خوارزمية تحديد المسار النجمية A* Pathfinding كيف تتعلم البرمجة تعلم البرمجة
-
يُستخدم تحويل فورييه المتقطع Discrete Fourier Transforms أو DFT بشكليه الحقيقي والمركّب لتحليل ترددات الإشارات المتقطعة والدورية. تحويل فورييه السريع Fast Fourier Transform أو FFT هو تطبيق خاص لتحويل فورييه المتقطع DFT، يتميّز بالسرعة ويناسب وحدات المعالجة المركزية الحديثة. تحويل فورييه السريع من الأساس 2 لعلّ أبسط طرق حساب تحويل فورييه السريع FFT وأشهرها هي خوارزمية التعشير الزمني من الأساس 2 أي Radix-2 Decimation in Time، وينبني عمل هذه الخوارزمية على تفكيك إشارة معيّنة ذات نطاق زمني مؤلف من N نقطة إلى N إشارة أحادية (أي ذات نطاق زمني مؤلف من نقطة واحدة). يمكن تفكيك الإشارة -أو التعشير الزمني decimation in time- عن طريق عكس بتّات المؤشّرات في مصفوفة بيانات النطاق الزمني، فإن كانت الإشارة مؤلفة من ستة عشر نقطة مثلًا، فستُبدّل العينة 1 (ترميزها الثنائي 0001) بالعينة 8 (ترميزها الثنائي 1000)، وتبديل العينة 2 (0010) بـ 4 (0100) وهكذا دواليك. يمكن إجراء مبادلة العينات Sample swapping باستخدام تقنية عكس البتات bit reverse، بيْد أنّ ذلك سيحدّ إمكانية استخدام خوارزمية Radix 2 FFT على الإشارات التي يمكن كتابة أطوالها وفق الصيغة N = 2 ^ M. تتطابق قيم الإشارات الأحادية داخل النطاق الزمني time domain مع قيمها في نطاق التردد frequency domain، لهذا لا تتطلب هذه المصفوفة المؤلّفة من نقاط النطاق الزمني المفكّكة أيّ تحويل لتصبح مصفوفة لترددات النطاق. بيْد أنّه يجب إعادة بناء النقاط الفردية، وعددها N كما تعلم ، إلى طيف ترددات مؤلف من N نقطة N-point frequency spectra. تُعدّ طريقة الفراشة أفضل طريقة لإعادة بناء طيف الترددات. تتجنّب خوارزمية تحويل فورييه السريع الحسابات الزائدة التي يُجريها تحويل فورييه المتقطع DFT، وذلك عبر استغلال التواتر الدوري periodicity لـ Wn ^ R. تستغرق عملية إعادة البناء الطيفي عدد log2 (N) مرحلة من مراحل حسابات الفراشة لتُعيد قيمة X [K]؛ أي بيانات نطاق التردد وفق الشكل الجبري. ولكي نحول الإحداثيات الجبرية إلى إحداثيات قطبية فإننا نحسب القيمة المطلقة التي تساوي: √(Re^2 + Im^2)، حيث يمثّل Re الجزء الحقيقي من العدد المركب، ويمثّل Im الجزء التخيلي. سيكون علينا إيضًا حساب وسيط العدد المركّب عبر الصيغة: tan-1 (Im / Re). حيث: N: عدد النقاط في تحويل فورييه السريع R: قيمة WN الحالية، وتعتمد على قيمة N و المرحلة الحالية من تحويل فورييه السريع وقيمة حسابات الفراشة في هذه المرحلة. يمثل الرسم البياني التالي مخطّط الفراشة لخوارزمية فورييه السريع الثُمانِي من الأساس 2 - أي eight point Radix 2 FFT- ولاحظ أن الإشارات المُدخلة رُتَِبت وفقًا لقيم التعشير الزمني المشار إليه سابقًا. يأخذ تحويل فورييه السريع أعدادًا مركّبة ويعيد أعدادًا مركّبة، فإن أردت تمرير إشارات حقيقية (أي تنتمي لمجموعة الأعداد الحقيقية R)، فيجب أن تعيّن الجزء التخيلي على القيمة 0، وتعطي للجزء الحقيقي قيمة الإشارة المُمرَّرة، أي x [n]. يمكن تحديد قيم Wn ^ R المستخدمة خلال أطوار إعادة البناء باستخدام معادلة أسّية موزونة exponential weighting equation. تُحدَِّد قيمة R (القوة الأسية الموزونة) المرحلة الحالية في عملية إعادة البناء الطيفية ومرحلة حسابات الفراشة. هذه شيفرة مكتوبة بلغة C / C++ لحساب تحويل فورييه السريع من الأساس 2. تعمل هذه الشيفرة مهما كان الحجم N، شرط أن يكون N قوة للعدد 2. هذه الطريقة أبطأ 3 مرات تقريبًا من أسرع تقديم لتحويل فورييه السريع، ولكنّها تقنية واعدة ويمكن تحسينها مستقبلًا، كما أنّها مناسبة لتعليم أساسيات خوارزمية تحويل فورييه السريع. #include <math.h> #define PI 3.1415926535897932384626433832795 // PI قيمة #define TWOPI 6.283185307179586476925286766559 // 2*PI قيمة // معامل التحويل من Degrees إلى Radians #define Deg2Rad 0.017453292519943295769236907684886 // معامل التحويل من Radians إلى Degrees #define Rad2Deg 57.295779513082320876798154814105 / #define log10_2 0.30102999566398119521373889472449 // Log10 of 2 #define log10_2_INV 3.3219280948873623478703194294948 // 1/Log10(2) // بنية لتمثيل الأعداد المركبة struct complex { public: double Re, Im; }; // تعيد true إن كان N قوة للعدد 2 bool isPwrTwo(int N, int *M) { // تمثل M عدد المراحل التي يجب إجراؤها، 2^M = N *M = (int)ceil(log10((double)N) * log10_2_INV); int NN = (int)pow(2.0, *M); // تحقق من أنّ N قوة للعدد 2 if ((NN != N) || (NN == 0)) return false; return true; } void rad2FFT(int N, complex *x, complex *DFT) { int M = 0; // تحقق من أنّه قوة للعدد 2، وإن كان خلاف ذلك فأنهِ البرنامج if (!isPwrTwo(N, &M)) throw "Rad2FFT(): N must be a power of 2 for Radix FFT"; // متغيرات صحيحة // لتخزين المسافة في الذاكرة بين حسابات الفراشة int BSep; // لتخزين المسافة بين الطرفين المتقابلين في حسابات الفراشة int BWidth; // المتماثلة التي ستُستخدم في هذه المرحلة Wn عدد قيم P يمثل المتغير int P; // في حلقة التكرار لإجراء كل الحسابات في هذه المرحلة j يُستخدم int j; // رقم المرحلة الحالية في تحليل فورييه السريع stage يمثل المتغير // في المجموعة M هناك int stage = 1; // الخاصة بالقيمة العليا DFT يمثَّل فهرس مصفوفة // في كل عملية من حسابات الفراشة int HiIndex; // يُستخدم في قلب القناع البتي unsigned int iaddr; // يُستخدم في التعشير الزمني int ii; int MM1 = M - 1; unsigned int i; int l; unsigned int nMax = (unsigned int)N; // يمثل TwoPi_N ثابتة لتخزين مدّة الحسابات = 2*PI / N double TwoPi_N = TWOPI / (double)N; double TwoPi_NP; // أعداد مركبة complex WN; // يمثل دالة الوزن الأسية وفق الشكل a + jb complex TEMP; // يُستخدم لتخزين نتائج حسابات الفراشة complex *pDFT = DFT; // مؤشر يشير إلى العنصر الأول في مصفوفة DFT complex *pLo; // مؤشّر يشير إلى lo / hi في حسابات الفراشة complex *pHi; complex *pX; // x[n] مؤشر إلى for (i = 0; i < nMax; i++, DFT++) { // حساب قيمة x[n] انطلاقا من العنوان *x والفهرس i pX = x + i; // DFT[n] إعادة تعيين عنوان جديد لـ ii = 0; iaddr = i; for (l = 0; l < M; l++) // ii وتخزين الناتج في i قلب بتات { if (iaddr & 0x01) // تحديد البتة الأقل أهمية ii += (1 << (MM1 - l)); iaddr >>= 1; operations for speed increase if (!iaddr) break; } DFT = pDFT + ii; DFT->Re = pX->Re; DFT->Im = pX->Im; // العدد التخيلي يساوي 0 دائما } for (stage = 1; stage <= M; stage++) { //تقسيم حسابات الفراشة إلى 2^stage BSep = (int)(pow(2, stage)); // قيم Wn المتماثلة في هذه المرحلة = N/Bsep P = N / BSep; // عرض المرحلة الحالية في حسابات الفراشة // (المسافة بين النقاط المتقابلة) BWidth = BSep / 2; TwoPi_NP = TwoPi_N*P; for (j = 0; j < BWidth; j++) { //حفظ الحسابات إن كان R = 0، حيث WN^0 = (1 + j0) if (j != 0) { //WN.Re = cos(TwoPi_NP*j) // (حساب Wn (الجزء الحقيقي والتخيلي WN.Re = cos(TwoPi_N*P*j); WN.Im = -sin(TwoPi_N*P*j); } for (HiIndex = j; HiIndex < N; HiIndex += BSep) { pHi = pDFT + HiIndex; // التأشير إلى القيمة العليا pLo = pHi + BWidth; // التأشير إلى القيمة السفلى if (j != 0) { //CMult(pLo, &WN, &TEMP); TEMP.Re = (pLo->Re * WN.Re) - (pLo->Im * WN.Im); TEMP.Im = (pLo->Re * WN.Im) + (pLo->Im * WN.Re); //CSub (pHi, &TEMP, pLo); pLo->Re = pHi->Re - TEMP.Re; pLo->Im = pHi->Im - TEMP.Im; //CAdd (pHi, &TEMP, pHi); pHi->Re = (pHi->Re + TEMP.Re); pHi->Im = (pHi->Im + TEMP.Im); } else { TEMP.Re = pLo->Re; TEMP.Im = pLo->Im; //CSub (pHi, &TEMP, pLo); pLo->Re = pHi->Re - TEMP.Re; pLo->Im = pHi->Im - TEMP.Im; //CAdd (pHi, &TEMP, pHi); pHi->Re = (pHi->Re + TEMP.Re); pHi->Im = (pHi->Im + TEMP.Im); } } } } pLo = 0; pHi = 0; pDFT = 0; DFT = 0; pX = 0; } تحويل فورييه السريع المعكوس من الأساس 2 يمكن الحصول على تحويل فورييه المعكوس عبر تعديل ناتج تحويل فورييه السريع العادي، ويمكن تحويل البيانات في نطاق الترددات frequency domain إلى النطاق الزمني time domain بالطريقة التالية: اعثر على مرافق العدد المركب لبيانات نطاق الترددات. طبّق تحويل فورييه السريع العادي على مرافق بيانات نطاق التردّد conjugated frequency domain data. اقسم كل مخرجات نتيجة تحويل فورييه السريع على N للحصول على القيمة الصحيحة للنطاق الزمني. احسب مرافق العدد المركب للعنصر الناتج عبر عكس المكوّن التخيلي لبيانات النطاق الزمني لجميع قيم n. ملاحظة: تُعدّ كل من بيانات نطاق التردد وبيانات النطاق الزمني متغيّراتٍ مركبة، وعادة ما يساوي المكونّ التخيلي للنطاق الزمني للإشارة التي تعقُب تحويل فورييه السريع المعكوس القيمة 0، وإلا فستُعد خطأ تقريبيًا وتُتجاهل. وإنّ زيادة دِقة المتغيرات من 32 بتّة عشرية 32-bit float إلى 64 بتّة مزدوجة 64-bit double أو إلى 128 بتّة مزدوجة سيقلّص أخطاء التقريب الناتجة عن تعاقب عدّة تحويلات فورييه سريعة. هذا تطبيق مكتوب بلغة C / C++: #include <math.h> #define PI 3.1415926535897932384626433832795 #define TWOPI 6.283185307179586476925286766559 #define Deg2Rad 0.017453292519943295769236907684886 #define Rad2Deg 57.295779513082320876798154814105 #define log10_2 0.30102999566398119521373889472449 // Log10 of 2 #define log10_2_INV 3.3219280948873623478703194294948 // 1/Log10(2) // بنية لتمثل الأعداد المركبة struct complex { public: double Re, Im; }; void rad2InverseFFT(int N, complex *x, complex *DFT) { // تمثل M عدد المراحل التي يجب إجراؤها، 2^M = N double Mx = (log10((double)N) / log10((double)2)); int a = (int)(ceil(pow(2.0, Mx))); int status = 0; if (a != N) { x = 0; DFT = 0; throw "rad2InverseFFT(): N must be a power of 2 for Radix 2 Inverse FFT"; } complex *pDFT = DFT; complex *pX = x; double NN = 1 / (double)N; // تعديل معامل تحويل فورييه السريع المعكوس for (int i = 0; i < N; i++, DFT++) DFT->Im *= -1; // حساب مرافق التردد الطيفي DFT = pDFT; rad2FFT(N, DFT, x); // حساب تحويل فورييه السريع العادي int i; complex* x; for ( i = 0, x = pX; i < N; i++, x++){ x->Re *= NN; // تقسيم النطاق الزمني على N لتصحيح السعة x->Im *= -1; // ImX تغيير إشارة } } ترجمة -بتصرّف- للفصل 55 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: خوارزميات للتعامل مع المصفوفات matrix algorithms المرجع الشامل إلى: تعلم الخوارزميات للمبتدئين خوارزميات البحث في النصوص أمثلة عن أنواع الخوارزميات خوارزميات الترتيب وأشهرها
-
المصفوفات matrices هي إحدى مفاهيم الجبر الخطي، ولها تطبيقات في الكثير من المجالات، وسوف نستعرض في هذه المقالة خوَارزميتان من خوارزميات المصفوفات. طباعة المصفوفات سنبدأ بخوارزمية بسيطة لطباعة المصفوفات. تأخذ الخوارزمية مصفوفة ثنائية الأبعاد وتطبع عناصرها الواحد تلو الآخر وفق ترتيب خاص، وإليك مثال على ذلك: Input: الدخل 14 15 16 17 18 21 19 10 20 11 54 36 64 55 44 23 80 39 91 92 93 94 95 42 Output: الخرج print value in index // اطبع القيم 14 15 16 17 18 21 36 39 42 95 94 93 92 91 64 19 10 20 11 54 80 23 44 55 or print index // أو اطبع الفهارس 00 01 02 03 04 05 15 25 35 34 33 32 31 30 20 10 11 12 13 14 24 23 22 21 هذه شيفرة عامة للخوارزمية: function noOfLooping(m,n) { if(m > n) { smallestValue = n; } else { smallestValue = m; } if(smallestValue % 2 == 0) { return smallestValue/2; } else { return (smallestValue+1)/2; } } function squarePrint(m,n) { var looping = noOfLooping(m,n); for(var i = 0; i < looping; i++) { for(var j = i; j < m - 1 - i; j++) { console.log(i+''+j); } for(var k = i; k < n - 1 - i; k++) { console.log(k+''+j); } for(var l = j; l > i; l--) { console.log(k+''+l); } for(var x = k; x > i; x--) { console.log(x+''+l); } } } squarePrint(6,4); ضرب المصفوفات ضرب المصفوفات هي إحدى العمليات الأساسية على المصفوفات، ولها تطبيقات عديدة، مثل حلّ نِظمات المعادلات التفاضلية الخطية وغيرها، سوف نستعرض في هذه الفقرة أحد تطبيقات ضرب المصفوفات، وهو حساب أعداد فيبوناتشي. نعلم أنّ العثور على العنصر رقم n في متتالية فيبوناتشي سهل للغاية عندما يكون n صغيرًا نسبيًا، إذ يكفي أن نستخدم منظورًا عوديًا بسيطًا، حيث أنّ f(n)=f(n-1)+f(n-2)، أو يمكننا استخدام منظور البرمجة الديناميكية لتجنب تكرار الحسابات. لكنّ هذه الطرق لا تنفع لحل المشاكل المعقّدة، فماذا ستفعل مثلًا إن طُلِب منك حلّ هذه المشكلة: حيث تمثّل mod عملية حساب الباقي. لن تنجح البرمجة الديناميكية هنا، لذا علينا أن نبحث عن حل أفضل لهذه المشكلة، وإحدى الحلول الممكنة لهذه المشكلة هي استخدام ضرب المصفوفات لتمثيل العلاقة العَودية. قبل أن نواصل، يُستحبّ أن تكون لك معرفة أولية بالمفاهيم الرياضية الآتية. لتكن A و B مصفوفتين، يجب أن تكون قادرًا على حساب ضرب هاتين المصفوفتين، أي BxA. لتكن A و B مصفوفتين، يجب أن تكون قادرًا على إيجاد المصفوفة T التي تحقق B = TxA. لتكن A مصفوفة أبعادها d X d، يجب أن تكون قادرًا على إيجاد قوّتها إلى n (في مدّة O(d3log(n))). سنحتاج في البداية إلى إنشاء مصفوفة M تمثّل العلاقة العَودية، بحيث يمكننا انطلاقًا منها حساب الحالة / القيمة المطلوبة انطلاقًا من الحالات / القيم المعروفة سلفًا من المتتالية. لنفترض أنّنا نعرف قيمة k حالة سابقة من المتتالية العودية، نريد أن نحسب قيمة الحالة رقم (k +1) من المتتالية انطلاقًا من الحالات المعروفة. لتكن M مصفوفة حجمها k X k، سنبني مصفوفة A حجمها k X 1 تحتوى الحالات المعروفة لعلاقة العوديّة، وسننشئ أيضًا مصفوفة B حجمها k X 1 تحتوي قيم الحالات الموالية، أي تحقق MXA = B: | f(n) | | f(n+1) | | f(n-1) | | f(n) | M X | f(n-2) | = | f(n-1) | | ...... | | ...... | | f(n-k) | | f(n-k+1) | إن استطعنا تصميم المصفوفة M وفقًا لهذه الخاصيات، فسنكون قادرين على حل المشكلة بيُسر. سنجرّب هذه الطريقة على عدةّ أنواع من علاقات العوديّة. النوع 1 لنبدأ بالحالة الأبسط: f(n) = f(n-1) + f(n-2)، سنحصل على: f(n+1) = f(n) + f(n-1). لنفترض أنّنا نعرف قيمتي f(n) و f(n-1)، ونريد أن نحسب قيمة f(n+1) انطلاقًا من القيمتين السابقتين. نبدأ بإنشاء المصفوفتين A و B كما وضّحنا سابقًا: Matrix A Matrix B | f(n) | | f(n+1) | | f(n-1) | | f(n) | لاحظ أن المصفوفة A تُصمّم بحيث تحتوي كل الحالات التي تعتمد عليها f(n+1)، وعلينا الآن تصميم مصفوفة M حجمها 2X2 تحقّق MxA = B. العنصر الأول من B هو f(n+1)، ويساوي f(n) + f(n-1) بحسب علاقة العوديّة. نحن نعلم أنّ العنصر الأول من B يساوي الصف الأول من M مضروبًا في A حسبَ قواعد ضرب المصفوفات، أي أنّ f(n)*a + f(n-1)*b = f(n+1)، حيث [a b] يمثّل الصف الأول من المصفوفة M، من جهة أخرى نعلم من علاقة التعود أنّ f(n+1)=f(n)+f(n-1)، لهذا نستنتج أنّ الصفّ الأول من M سيكون [1 1]. | 1 1 | X | f(n) | = | f(n+1) | | ----- | | f(n-1) | | ------ | ملاحظة: يشير الرمز ----- إلى عدم اهتمامنا بهذه القيمة حاليًا. يساوي العنصر الثاني من B القيمة f(n)، والتي يمكن الحصول عليها عن طريق العملية f(n) x 1، ومن ثم فإنّ الصف الثاني من M هو [1 0]. | ----- | X | f(n) | = | ------ | | 1 0 | | f(n-1) | | f(n) | لقد حصلنا الآن على المصفوفة M المطلوبة. | 1 1 | X | f(n) | = | f(n+1) | | 1 0 | | f(n-1) | | f(n) | النوع 2 سنحاول الآن حساب: f(n) = a X f(n-1) + b X f(n-2)، حيث a و b ثابتتان. تكافئ الصيغة أعلاه المعادلة: f(n+1) = a X f(n) + b X f(n-1). قد تعلم الآن أنّه ينبغي أن تتساوي أبعاد المصفوفات مع عدد التبعيات، والتي تساوي 2 في هذا المثال، لذا سنبني المصفوفتين A و B، وكلاهما من الحجم 2x1: Matrix A Matrix B | f(n) | | f(n+1) | | f(n-1) | | f(n) | نحتاج أن تكون [a, b] في الصفّ الأول من المصفوفة M بالنسبة لـ f(n+1) = a X f(n) + b X f(n-1)، وبالنسبة للعنصر الثاني فيB، أي f(n)، فهو موجود في المصفوفة A، لذلك سنأخذه، ولهذا نضع [1 0] في الصف الثاني من M لنحصل على: | a b | X | f(n) | = | f(n+1) | | 1 0 | | f(n-1) | | f(n) | النوع 3 نأخذ الآن الحالة التالية: احسب قيمة f(n) = a x f (n-1) + c x f (n-3). لقد كانت الحالات -القيم السابقة التي تُحسب العودية انطلاقًا منها- في المثالين السابقين متجاورات، أمّا هنا فهي غير متجاورة، فالحالة f (n-2) مفقودة، فكيف نتعامل مع هذا؟ يمكننا تحويل علاقة العودية إلى العلاقة التالية: f(n) = a X f(n-1) + 0 X f (n-2) + c X f (n-3) // أو f(n + 1) = a X f(n) + 0 X f (n-1) + c X f (n-2) شبه هذه الصيغةُ صيغةَ النوع 2. هذه هي المصفوفة M، والتي ستكون من الحجم 3x3. لقد حسبنا المصفوفة M بطريقة مشابهة للطريقة التي استخدمناها في النوع 2، ويمكنك تجربتها بالورقة والقلم إن وجدت صعوبة في فهمها. | a 0 c | | f(n) | | f(n+1) | | 1 0 0 | X | f(n-1) | = | f(n) | | 0 1 0 | | f(n-2) | | f(n-1) | النوع 4 نأخذ الآن مثالًا أكثر تعقيدًا: f(n) = f(n-1) + f(n-2) + c، حيث تكون c ثابتًا ما. يختلف هذا المثال عن سابقيه، فقد كنا نحوّل في السابق كل حالة في A إلى الحالة التالية في B عبر عملية الضرب. f(n) = f(n-1) + f(n-2) + c f(n+1) = f(n) + f(n-1) + c f(n+2) = f(n+1) + f(n) + c ................................ أما هنا فلا تصلح الطريقة التي اعتمدنَاها سابقًا، وعلينا أن نبحث في طريقة أخرى. ماذا لو جرّبنا إضَافة c كحالة على النحو التالي: | f(n) | | f(n+1) | M X | f(n-1) | = | f(n) | | c | | c | يمكننا الآن إنشاء M بسهولة: | 1 1 1 | | f(n) | | f(n+1) | | 1 0 0 | X | f(n-1) | = | f(n) | | 0 0 1 | | c | | c | النوع 5 لنأخذ الآن مثالًا آخر: f(n) = a X f(n-1) + c X f(n-3) + d X f(n-4) + e هذا تمرين لك، حاول أولاً تحديد حالات العوديّة، وأنشئ المصفوفتين A و B، ثمّ احسب المصفوفة M: هذه قيمة M: | a 0 c d 1 | | 1 0 0 0 0 | | 0 1 0 0 0 | | 0 0 1 0 0 | | 0 0 0 0 1 | النوع 6 هناك أشكال كثيرة من العودية، منها الشكل التالي: f(n) = f(n-1) -> if n is odd // في الحالات الفردية f(n) = f(n-2) -> if n is even // في الحالات الزوجية أو باختصار: f(n) = (n&1) X f(n-1) + (!(n&1)) X f(n-2) يمكننا تقسيم الدوال على أساس الفردية أو الزوجية، وإنشاء مصفوفتين تمثّل إحداهما الفردية، وتمثّل الأخرى الزوجية، ثمّ نحُسب كل واحدة منهما على حدة. النوع 7 قد تحتاج أحيانًا إلى الحفاظ على أكثر من علاقة عودية واحدة كما في المثال التالي: g(n) = 2g(n-1) + 2g(n-2) + f(n) لاحظ أنّ قيمة g (n) تعتمد على f(n). يمكننا استخدام ضرب المصفوفات كما في الأمثلة السابقة ولكن بأبعاد أكبر. ننشئ أولا المصفوفتين A و B. Matrix A Matrix B | g(n) | | g(n+1) | | g(n-1) | | g(n) | | f(n+1) | | f(n+2) | | f(n) | | f(n+1) | لدينا: g(n+1) = 2g(n-1) + f(n+1) و f(n+2) = 2f(n+1) + 2f(n). لننشئ المصفوفة M بالطريقة نفسها التي استخدمناها آنفًا: | 2 2 1 0 | | 1 0 0 0 | | 0 0 2 2 | | 0 0 1 0 | ترجمة -بتصرّف- للفصلين 51 و 52 من الكتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: خوارزميات المتتاليات subsequences algorithms مدخل إلى تحليل الخوارزميات أمثلة عن أنواع الخوارزميات
-
تستعرض هذه المقالة بعض الخوارزميات المتعلقة بالمتتاليات الرياضية، مثل خوارزمية أطول متتالية جزئية مشتركة، وخوارزمية أطول متتالية متزايدة، وخوارزمية الحيد الزمني الديناميكي. أطول متتالية جزئية مشتركة Longest Common Subsequence سوف نعرّف بعض المصطلحات الأساسية أولًا: المتتالية الجزئية Subsequence: في الرياضيات، المتتالية الجزئية هي متتالية مشتقة من متتالية أخرى، بحيث يمكن الحصول عليها بحذف بعض عناصر المتتالية الضامّة دون المساس بترتيب عناصرها. لتكن ABC سلسلة نصية، سنحصل عند حذف حرف واحد أو أكثر من هذه السلسلة النصية على متتالية جزئية من هذه السلسلة النصية، لذا فإنّ السلاسل النصية { A - B - C - AB - AC - BC - ABC …إلخ} كلها متتاليات جزئية من ABC، بل حتى السلسلة الفارغة (الناتجة عن إزالة جميع الأحرف) تُعدّ أيضًا متتالية جزئية. وهناك طريقة سهلة لاستخراج المتتاليات الجزئية من سلسلة نصية، بالمرور على كل حرف من حروف السلسلة النصية، وأمامك خياران، إمّا أن تأخذه أو تتركه، ومهما كانت خياراتك ستكون المتتالية المؤلّفة من الحروف التي أخذتها متتالية جزئية، كذلك إن كان طول السلسلة النصية يساوي n، فيمكن أن نستخرج منها 2n متتالية جزئية. أطول متتالية جزئية مشتركة longest Common Subsequence: لتكن س و ش سلسلتين نصيتين، ولتكن ج المجموعة المؤلفة من كل المتتاليات الجزئية في س، ولتكن ح المجموعة المؤلفة من جميع المتتاليات الجزئية في ش. هناك على الأقل متتالية جزئية واحدة مشتركة بين هاتين المجموعتين، وهي المتتالية الفارغة، لأنّ المتتالية الفارغة تُعدّ دائما متتالية جزئية من كل المتتاليات. أطول متتالية جزئية مشتركة LCS بين السلسلتين النصيتين س و ش هي أطول عنصر مشترك بين المجموعتين ج و ح، فمثلًا: المتتاليات الجزئية المشتركة بين السلسلتين النصيتين HELLOM وHMLD هي H و HL و HM، والسلسلة النصية HLL هي الأطول من بين كل هذه المتتاليات، لذا فهي أطول متتالية جزئية مشتركة بين HMLD و HELLOM. هناك عدّة طرق للعثور على أطول متتالية جزئية مشتركة، منها الطريقة العنيفة وطريقة البرمجة الديناميكية. الطريقة العنيفة: يمكننا إنشاء جميع المتتاليات الجزئية للسلسلتين النصيتين ثم نوازن بينها لنخرج بالمتتاليات الجزئية المشتركة بينهما، ثم سنحاول العثور على أطول عنصر / متتالية مشتركة. بما أننا قد رأينا أنّ هناك 2n متتالية جزئية لكل سلسلة نصية طولها n، فنحن نعلم أننا سنستغرق أعوامًا من أجل حل المشكلة إن كانت n أكبر من 20، وبناءً على ذلك فهي غير عملية، وهناك طريقة أفضل لإيجاد أطول متتالية جزئية مشتركة، وهي البرمجة الديناميكية. طريقة البرمجة الديناميكية: لنفترض أنّ لدينا سلسلتين نصيتين abcdaf و acbcf نرمز لهما بالرمزين s1 و s2، وأطول متتالية جزئية مشتركة بين هاتين السلسلتين النصيتين هي abcf، وطولها يساوي 4. بما أن المتتاليات الجزئية لا يشترط أن تكون متواصلة، فيمكن إنشاء المتتالية abcf عبر تجاهل da في s1 و c في s2. والآن، كيف نعرف هذا باستخدام البرمجة الديناميكية؟ سنبدأ بإنشاء جدول (مصفوفة ثنائية الأبعاد) تحتوي جميع أحرف s1 في أحد صفوفها، وتحتوي جميع أحرف s2 في أحد أعمدتها، ونفهرس الجدول انطلاقًا من 0، ونضع الأحرف في المصفوفة ابتداءً من الفهرس 1، وسيبدو الجدول كما يلي: 0 1 2 3 4 5 6 +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | | chʳ | | a | b | c | d | a | f | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | | | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 1 | a | | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 2 | c | | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 3 | b | | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 4 | c | | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 5 | f | | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ تمثل كل خانة Table[i][j] من خانات الجدول طول أطول متتالية جزئية مشتركة بين السلسلتين النصيتين t1 و t2، حيث تساوي t1 سابقة السلسلة النصية s1 التي تبدأ من بدايتها وحتى الحرف رقم j، وتساوي t2 سابقة السلسلة النصية s2 التي تبدأ من بدايتها وحتى الحرف رقم i. على سبيل المثال: تمثّل الخانة Table[2][3] طول أطول متتالية جزئية مشتركة بين ac و abc. يمثّل العمود رقم 0 المتتالية الجزئية الفارغة من s1، وبالمثل فإن الصفّ رقم 0 يمثّل المتتالية الجزئية الفارغة من s2. إذا أخذنا متتالية جزئية فارغة من سلسلة نصية وحاولنا مطابقتها بسلسلة نصية أخرى، فسيساوي طول المتتالية الجزئية المشتركة 0 مهما كان طول السلسلة النصية الفرعية الثانية، لهذا نملأ خانات الصف الأوّل والعمود الأوّل بالقيمة 0، ونحصل على: 0 1 2 3 4 5 6 +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | | chʳ | | a | b | c | d | a | f | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 1 | a | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 2 | c | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 3 | b | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 4 | c | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 5 | f | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ نريد الآن ملء الخانة Table[1][1]، إن كان لدينا سلسلتين نصيتين a و a أخرى، ما هي أطول متتالية جزئية مشتركة يمكننا الحصول عليها هنا؟ نحن نعلم أنّ طول أطول متتالية جزئية مشتركة بين السلسلة النصية a و a هو 1، فنذهب الآن إلى Table[1][2] التي تمثّل طول أطول متتالية جزئية مشتركة بين ab و a، والتي تساوي 1، وكما ترى فإن جميع قيم الصف رقم 1 تساوي القيمة 1، وذلك لأنه يحتوي على طول أطول متتالية جزئية مشتركة بين a والسلاسل abcd و abcda و abcdaf. سيبدو الجدول كما يلي: 0 1 2 3 4 5 6 +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | | chʳ | | a | b | c | d | a | f | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 1 | a | 0 | 1 | 1 | 1 | 1 | 1 | 1 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 2 | c | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 3 | b | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 4 | c | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 5 | f | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ نذهب الآن إلى الصف رقم 2 الذي يتضمن الحرف c. تحتوي الخانة Table[2][1] طول أكبر متتالية جزئية مشتركة بين السلسلتين النصيتين ac و a والذي يساوي 1، وقد حصلنا على هذه القيمة من القيمة الموجودة في الصف الأعلى، ذلك أنّه إذا كان الحرفان s1 [2] و s2 [1] غير متساويين فلا بدّ أن يساوي طول أكبر متتالية جزئية مشتركة الحد الأقصى لقيمة LCS (أطول متتالية جزئية مشتركة) الموجودة في الأعلى أو على اليسار. إن أخذنا قيمة LCS الموجودة في الأعلى فذلك يعني أنّنا تجاهلنا الحرف الحالي في السلسلة النصية s2، وبالمثل إن أخذنا قيمة LCS الموجودة على اليسار فذلك يعني أنّنا تجاهلنا الحرف الحالي في s1. نحصل على هذا الجدول: 0 1 2 3 4 5 6 +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | | chʳ | | a | b | c | d | a | f | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 1 | a | 0 | 1 | 1 | 1 | 1 | 1 | 1 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 2 | c | 0 | 1 | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 3 | b | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 4 | c | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 5 | f | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ هذه صيغة الحالة الأولى: if s2[i] is not equal to s1[j] Table[i][j] = max(Table[i-1][j], Table[i][j-1] endif ننتقل الآن إلى Table[2][2] التي تحوي طول أطول متتالية جزئية مشتركة بين ab و ac. بما أن الحرفين الأخيرين من هاتين السلسلتين مختلفان (c و b)، فسنَأخذ الحدّ الأقصى الموجود في الأعلى أو على اليسار، وهو 1 في هذه الحالة. ثم بعد ذلك ننتقل إلى Table[2][3] والتي تحتوي طول أكبر متتالية جزئية مشتركة بين abc و ac، ستتساوى القيمتان الحاليتان في كل من الصف والعمود هذه المرة، لذا فإن طول المتتالية الجزئية المشتركة الأطول يساوي قيمة LCS القصوى الحالية + 1. ولكي تحصل على قيمة LCS القصوى الحالية عليك أن تتحقق من القيمة القطرية diagonal value، والتي تمثل أفضل تطابق بين ab و a، ومن هذه الحالة نضيف محرفًا من محارف s1 وآخر من s2، وقد وجدنا أنّهما متساويين -أي s1 و s2-، لذا سيزيد طول أكبر متتالية جزئية مشتركة حتمًا. نضع القيمة 1 + 1 = 2 في الخانة Table[2][3] لنحصل على: 0 1 2 3 4 5 6 +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | | chʳ | | a | b | c | d | a | f | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 1 | a | 0 | 1 | 1 | 1 | 1 | 1 | 1 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 2 | c | 0 | 1 | 1 | 2 | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 3 | b | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 4 | c | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 5 | f | 0 | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ هذه صيغتنا الثانية: if s2[i] equals to s1[j] Table[i][j] = Table[i-1][j-1] + 1 endif لقد عرّفنا كلا الحالتين، سنملأ الآن الجدول كله باستخدام هاتين الصيغتين، وسيبدو بعد أن نتمّ ملأه كما يلي: 0 1 2 3 4 5 6 +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | | chʳ | | a | b | c | d | a | f | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 1 | a | 0 | 1 | 1 | 1 | 1 | 1 | 1 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 2 | c | 0 | 1 | 1 | 2 | 2 | 2 | 2 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 3 | b | 0 | 1 | 2 | 2 | 2 | 2 | 2 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 4 | c | 0 | 1 | 2 | 3 | 3 | 3 | 3 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ | 5 | f | 0 | 1 | 2 | 3 | 3 | 3 | 4 | +-----+-----+-----+-----+-----+-----+-----+-----+-----+ نستنتج من الجدول أنّ طول أكبر متتالية جزئية مشتركة بين s1 و s2 هو Table[5][6] = 4. لاحظ أنّ 5 و 6 هما طولا s2 و s1 على التوالي. انظر المثال التوضيحي التالي: Procedure LCSlength(s1, s2): Table[0][0] = 0 for i from 1 to s1.length Table[0][i] = 0 endfor for i from 1 to s2.length Table[i][0] = 0 endfor for i from 1 to s2.length for j from 1 to s1.length if s2[i] equals to s1[j] Table[i][j] = Table[i-1][j-1] + 1 else Table[i][j] = max(Table[i-1][j], Table[i][j-1]) endif endfor endfor Return Table[s2.length][s1.length] التعقيد الزمني لهذه الخوارزمية يساوي O(mn)، حيث يمثّل m و n طولي السلسلتين النصيتين. ها قد عرفنا طول أكبر متتالية جزئية مشتركة، فكيف نستخرج هذه المتتالية الجزئية؟ من أجل معرفة أطول متتالية جزئية مشتركة، سننشئ مكدسًا لتخزين محارفها، وسنبدأ من الزاوية اليمنى السفلى باحثين عن المصدر الذي أتت منه القيمة، فإذا كانت القيمة قادمة من القطر diagonal -أي إذا كانت Table[i][j] - 1 = Table[i-1][j-1]- فسندفع إمّا [i]s2 أو s1 [j] (كلاهما متساويان) إلى المكدّس ثمّ نتحرك قُطريًا. أمّا إذا كانت القيمة آتية من الأعلى -أي إن كانت Table[i][j] = Table[i-1][j]- فإننا ننتقل إلى الأعلى، وإذا كانت القيمة آتية من اليسار، أي إن كانت Table[i][j] = Table[i][j-1]، فإننا ننتقل إلى اليسار. ينتهي بحثنا عندما نصل إلى العمود الموجود في الأعلى أو في أقصى اليسار، وننزع القيم المخزّنة في المكدّس ثمّ نطبعها. انظر المثال التوضيحي: Procedure PrintLCS(LCSlength, s1, s2) temp := LCSlength S = stack() i := s2.length j := s1.length while i is not equal to 0 and j is not equal to 0 if Table[i-1][j-1] == Table[i][j] - 1 and s1[j]==s2[i] S.push(s1[j]) //or S.push(s2[i]) i := i - 1 j := j - 1 else if Table[i-1][j] == Table[i][j] i := i-1 else j := j-1 endif endwhile while S is not empty print(S.pop) endwhile ملاحظة: إذا كان كلّ من Table[i-1][j] و Table[i][j-1] يساويان Table[i][j]، ولم يكن Table[i-1][j-1] مساويًا للقيمة Table[i][j] - 1، فذلك يعني أنّه قد تكون هناك أكثر من متتالية جزئية مشتركة طولى (أي كل واحدة منها تعدّ أطول متتالية جزئية مشتركة)، ولا يأخذ المثال التوضيحي المذكور آنفًا هذا الأمر في الحسبان. إن أردت العثور على جميع المتتاليات الجزئية المشتركة القصوية، فسيكون عليك استخدام العوديّة. التعقيد الزمني لهذه الخوارزمية هو: O (max (m, n)). أطول متتالية جزئية متزايدة Longest Increasing Subsequence أطول متتالية جزئية متزايدة، هي أطول متتالية جزئية عناصرها مرتبة تصاعديًا أو تنازليًا. تُستخدم خوارزميات حساب أطول متتالية جزئية متزايدة في العديد من التطبيقات مثل أنظمة إدارة الإصدارات Git وغيرها، وفيما يلي وصفٌ مبسّط للخوارزمية التي تعتمدها أنظمة إدارة الإصدارات للموازنة بين ملفّين يمثّلان إصدارين مختلفين من الملف نفسه: ابحث عن الأسطر المشتركة في كلا الملفين. خذ كل هذه الأسطر من الملف الأوّل ثم رتّبها بحسب ظهورها في الملف الثاني. اعثر على أطول متتالية جزئية متزايدة LIS للمتتالية الناتجة (باستخدام خوارزمية ترتيب الصبر patience sorting). ستحصل على أطول متتالية متطابقة من الأسطر، والتي تمثّل التقابلات بين السطور المشتركة في الملفّين. كرّر الخوارزمية عوديًا على كل مجال من الأسطر بين السطور المُتطابقة. لننظر الآن في مثال بسيط على مشكلة أطول متتالية جزئية متزايدة: مدخلات المشكلة هي متتالية من الأعداد الصحيحة المختلفة a1,a2,...,an.، ونريد أن نجد أطول متتالية جزئية متزايدة. إذا كانت المدخلات تُساوي 7,3,8,4,2,6 مثلًا، فإنّ أطول متتالية جزئية متزايدة فيها هي 3,4,6. أسهل طريقة للحصول على أطول متتالية جزئية متزايدة هي ترتيب عناصر المدخلات ترتيبًا تزايديًا ثمّ تطبيق خوارزمية LCS على التسلسلات الأصلية والمرتّبة، غير أنّه إذا ألقيت نظرة سريعة على المصفوفة الناتجة، فستلاحظ أنّ العديد من قيمها متساوية إذ تحتوي المصفوفة الكثير من العناصر المكرّرة، هذا يعني أنّه يمكن حل مشكلة LIS بالبرمجة الديناميكية باستخدام مصفوفة من بعد واحد. المثال التوضيحي: صِف مصفوفة القيم التي نريد حسابها: لكل 1 <= i <= n، ليكن A (i) طول أكبر متتالية جزئية متزايدة في المتتالية المؤلفة من أوّل i عنصر في المدخلات. لاحظ أنّ طول أكبر متتالية جزئية متزايدة في المدخلات (بالكامل) يحقق العبارة max{A(i)|1 ≤ i ≤ n}، أي أنّه: إن كان طول أكبر متتالية جزئية متزايدة في المدخلات هو L، فإنّه يوجد عدد k يحقّق A(k) = L، و لكل 1 =< n >= j لدينا: L > A(j). نحسب قيم A (i) عوديًا على النحو التالي: For 1 <= i <= n, A(i) = 1 + max { A(j)|1 ≤ j < i and input(j) < input(i) } احسب قيم A باستخدام الصيغة السابقة. ابحث عن الحل الأمثل. يستخدم البرنامج التالي A لحساب الحل الأمثل، ويحسب الجزء الأول منه قيمة m حيث تساوي A (m) طول متتالية جزئية متزايدة مثلى في المدخلات، فيما يحاول الجزء الثاني العثور على متتالية جزئية متزايدة مثلى. سنطبع المتتالية بترتيب عكسي لجعلها أوضَح. يستغرق هذا البرنامج O (n)، وتستغرق الخوارزمية إجمالًا O (n ^ 2). الجزء الأول: m ← 1 for i : 2..n if A(i) > A(m) then m ← i end if end for الجزء الثاني: put a while A(m) > 1 do i ← m−1 while not(ai < am and A(i) = A(m)−1) do i ← i−1 end while m ← i put a end while الحل العودي، المنظور الأول: LIS(A[1..n]): if (n = 0) then return 0 m = LIS(A[1..(n − 1)]) B is subsequence of A[1..(n − 1)] with only elements less than a[n] (* let h be size of B, h ≤ n-1 *) m = max(m, 1 + LIS(B[1..h])) Output m التعقيد الزمني للمنظور الأول: O(n*2^n). المنظور الثاني: LIS(A[1..n], x): if (n = 0) then return 0 m = LIS(A[1..(n − 1)], x) if (A[n] < x) then m = max(m, 1 + LIS(A[1..(n − 1)], A[n])) Output m MAIN(A[1..n]): return LIS(A[1..n], ∞) التعقيد الزمني للمنظور الثاني: O(n^2). المنظور الثالث: LIS(A[1..n]): if (n = 0) return 0 m = 1 for i = 1 to n − 1 do if (A[i] < A[n]) then m = max(m, 1 + LIS(A[1..i])) return m MAIN(A[1..n]): return LIS(A[1..i]) التعقيد الزمني للمنظور الثالث: O(n^2). الخوارزمية التكرارية Iterative Algorithm: تحسب هذه الخوارزمية القيم بطريقة متكررة من أسفل إلى أعلى. LIS(A[1..n]): Array L[1..n] (* L[i] = value of LIS ending(A[1..i]) *) for i = 1 to n do L[i] = 1 for j = 1 to i − 1 do if (A[j] < A[i]) do L[i] = max(L[i], 1 + L[j]) return L MAIN(A[1..n]): L = LIS(A[1..n]) return the maximum value in L التعقيد الزمني للمنظور التكراري: O(n^2). المساحة الإضافية: O(n). على سبيل المثال، لو كانت المدخلات تساوي: {0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15} فستكون أطول متتالية جزئية متزايدة في المدخلات هي: {0, 2, 6, 9, 11, 15}. الحيد الزمني الديناميكي Dynamic Time Warping خوارزمية الحيد الزمني الديناميكي Dynamic Time Warping -أو DTW اختصارًا- هي خوارزمية لقياس مدى التشابه بين متتاليتين زمنيتين قد تختلفان في السرعة، فمثلًا يمكن رصد أوجه التشابه في مشية شخصين باستخدام خوارزمية DTW حتى لو كان أحدهما يمشي أسرع من الآخر، أو كان هناك تسارع وتباطؤ أثناء المراقبة. وتُستخدم هذه الخوارزمية لمطابقة مقطع صوتي قياسي مع مقطع صوتي لشخص آخر، حتى لو كان ذلك الشخص يتحدث أسرع من صوت العينة القياسية المسجّلة أو أبطأ منها. تُطبَّق خوارزمية DTW على التسلسلات الزمنية لبيانات الفيديو والصوت والرسومات، أو أيّ نوع من البيانات ما دام يمكن تحويلها إلى متتالية خطية. تحاول هذه الخوارزمية إيجاد التطابق الأمثل بين متتاليتين زمنيتين مع الأخذ بالحسبان قيودًا معيّنة، فمثلًا لنفترض أنّ لدينا متتاليتين صوتيتين Sample و Test، ونريد أن نتحقق من تطابق هاتين المتتاليتين الزمنيتين. يشير مصطلح متتالية صوتية هنا إلى الإشارة الرقمية للصوت، وقد تُمثَّل بسعة الصوت amplitude أو تردّده frequency. لنفترض أنّ: Sample = {1, 2, 3, 5, 5, 5, 6} Test = {1, 1, 2, 2, 3, 5} نريد العثور على أمثل تطابق بين هاتين المتتاليتين، نبدأ بتعريف دالة المسافة d (x، y)، حيث تمثل x و y النقطتين اللتان نودّ حِساب المسافة الفاصلة بينهما. تكون صيغة حساب المسافة كما يلي: d(x, y) = |x - y| // القيمة المطلقة للفرق ننشئ الآن مصفوفة ثنائية الأبعاد Table باستخدام قيم المتتاليتين Sample و Test، وسنحسب المسافات بين كل نقطة في Sample وكل نقطة في Test ونبحث عن أفضل تطابق بينهما. +------+------+------+------+------+------+------+------+ | | 0 | 1 | 1 | 2 | 2 | 3 | 5 | +------+------+------+------+------+------+------+------+ | 0 | | | | | | | | +------+------+------+------+------+------+------+------+ | 1 | | | | | | | | +------+------+------+------+------+------+------+------+ | 2 | | | | | | | | +------+------+------+------+------+------+------+------+ | 3 | | | | | | | | +------+------+------+------+------+------+------+------+ | 5 | | | | | | | | +------+------+------+------+------+------+------+------+ | 5 | | | | | | | | +------+------+------+------+------+------+------+------+ | 5 | | | | | | | | +------+------+------+------+------+------+------+------+ | 6 | | | | | | | | +------+------+------+------+------+------+------+------+ تحتوي كل خانة Table[i][j] من خانات الجدول المسافة المثلى بين المتتاليتين الزمنيتين الجزئيتين t1 و t2، حيث تساوي t1 المتتالية الجزئية من Test التي تبدأ من بدايتها وحتى العنصر رقم j، وتساوي t2 المتتالية الجزئية من Sample التي تبدأ من بدايتها وحتى العنصر رقم i. إذا لم نأخذ أيّ قيمة من Sample في الصف الأول فستكون المسافة بينها وبين Test لا نهائية، لذا نضع ما لا نهاية في الصف الأول، وكذلك نفعل مع العمود الأول لأنّنا إن لم نأخذ أيّ قيمة من Test فستكون المسافة بينها وبين Sample لا نهائية كذلك، والمسافة بين 0 و 0 تساوي 0. نحصل على ما يلي: +------+------+------+------+------+------+------+------+ | | 0 | 1 | 1 | 2 | 2 | 3 | 5 | +------+------+------+------+------+------+------+------+ | 0 | 0 | in | inf | inf | inf | inf | inf | +------+------+------+------+------+------+------+------+ | 1 | inf | | | | | | | +------+------+------+------+------+------+------+------+ | 2 | inf | | | | | | | +------+------+------+------+------+------+------+------+ | 3 | inf | | | | | | | +------+------+------+------+------+------+------+------+ | 5 | inf | | | | | | | +------+------+------+------+------+------+------+------+ | 5 | inf | | | | | | | +------+------+------+------+------+------+------+------+ | 5 | inf | | | | | | | +------+------+------+------+------+------+------+------+ | 6 | inf | | | | | | | +------+------+------+------+------+------+------+------+ سنحسب في كل خطوة المسافة بين النقاط الحالية ثمّ نضيفها إلى الحد الأدنى للمسافة التي وجدناها حتى الآن، وسينتج عن هذا المسافة المثلى بين المتتاليتين الجزئيتين t1 و t2 (انظر تعريفيهمَا في الأعلى). هذه هي الصيغة التي سنعمل بها: Table[i][j] := d(i, j) + min(Table[i-1][j], Table[i-1][j-1], Table[i][j-1]) يكون لدينا في البداية d(1, 1) = 0، كما تحتوي الخانة Table[0][0] القيمةَ الصغرى، لذا نضع Table[1][1] = 0 + 0 = 0. لدينا في الحالة الثانية d(1, 2) = 0، وتحتوي الخانة Table[1][1] القيمةَ الصغرى، لذا نضع Table[1][2] = 0 + 0 = 0. نستمر على هذا المنوال إلى أن نملأ الجدول، الذي سيبدو بعد الانتهاء كما يلي: +------+------+------+------+------+------+------+------+ | | 0 | 1 | 1 | 2 | 2 | 3 | 5 | +------+------+------+------+------+------+------+------+ | 0 | 0 | inf | inf | inf | inf | inf | inf | +------+------+------+------+------+------+------+------+ | 1 | inf | 0 | 0 | 1 | 2 | 4 | 8 | +------+------+------+------+------+------+------+------+ | 2 | inf | 1 | 1 | 0 | 0 | 1 | 4 | +------+------+------+------+------+------+------+------+ | 3 | inf | 3 | 3 | 1 | 1 | 0 | 2 | +------+------+------+------+------+------+------+------+ | 5 | inf | 7 | 7 | 4 | 4 | 2 | 0 | +------+------+------+------+------+------+------+------+ | 5 | inf | 11 | 11 | 7 | 7 | 4 | 0 | +------+------+------+------+------+------+------+------+ | 5 | inf | 15 | 15 | 10 | 10 | 6 | 0 | +------+------+------+------+------+------+------+------+ | 6 | inf | 20 | 20 | 14 | 14 | 9 | 1 | +------+------+------+------+------+------+------+------+ تمثل قيمة الخانة Table[7][6] المسافة القصوى بين المتتاليتين Sample و Test، وتشير القيمة 1 هنا إلى أنّ المسافة القصوى بين Sample و Sample تساوي 1. الآن إذا ارتددنا من النقطة الأخيرة وحتى نقطة البداية (0 , 0) فسنحصل على خط طويل يتحرك أفقيًا وعموديًا وقطريًا. هذا مثال توضيحي لعملية الارتداد: if Table[i-1][j-1] <= Table[i-1][j] and Table[i-1][j-1] <= Table[i][j-1] i := i - 1 j := j - 1 else if Table[i-1][j] <= Table[i-1][j-1] and Table[i-1][j] <= Table[i][j-1] i := i - 1 else j := j - 1 end if نواصل على هذا المنوال حتى نصل إلى النقطة (0 , 0)، واعلم أن كل حركة لها معنى خاص: تمثل الحركة الأفقية حذفًا، وهذا يعني أنّ المتتالية Test قد تسرّعت أثناء هذه المدة. تمثل الحركة الرأسية إدراجًا، وهذا يعني تباطؤ المتتالية Test أثناء هذه المدة. تمثل الخطوة القطرية تطابقًا، أي أنّ Test تتطابق مع Sample أثناء هذه المدة. انظر المثال التوضيحي التالي: Procedure DTW(Sample, Test): n := Sample.length m := Test.length Create Table[n + 1][m + 1] for i from 1 to n Table[i][0] := infinity end for for i from 1 to m Table[0][i] := infinity end for Table[0][0] := 0 for i from 1 to n for j from 1 to m Table[i][j] := d(Sample[i], Test[j]) + minimum(Table[i-1][j-1], // مطابقة Table[i][j-1], // إدراج Table[i-1][j]) // حذف end for end for Return Table[n + 1][m + 1] يمكن أيضًا أن نضيف قيودًا محلية، كأن نشترط أنّه إذا تطابقت Sample[i] مع Test[j]، فيجب أن لا تكون قيمة |i - j| أكبر من w (معامل نافذة - window parameter). التعقيد: التعقيد الزمني لخوارزمية DTW هو O (m * n)، حيث يمثل كلّ من m و n طولي المتتاليتين. ويجدر التنويه إلى أنّ هناك تقنيات أخرى أسرع لحساب DTW مثل: PrunedDTW و SparseDTW و FastDTW. ترجمة -بتصرّف- للفصول 47 و 48 و 54 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: أمثلة على خوارزميات لحل مشكلات بسيطة تطوير الخوارزميات في جافا أمثلة عن أنواع الخوارزميات خوارزميات الترتيب وأشهرها
-
نستعرض في هذه المقالة ثلاث خوارزميات بسيطة، وهي خوارزمية الحقيبة، وخوارزمية أخرى للتحقق من الألفاظ المقلوبة وخوارزمية لطباعة مثلث باسكال. مشكلة الحقيبة Knapsack Problem في مشكلة الحقيبة، نفترض أنّ لدينا مجموعة من العناصر، لكل منها قيمة ووزن، وعليك اختيار مجموعة من تلك العناصر بحيث يكون وزنها الإجمالي أقلّ من حدّ معيّن أو يساويه، وتكون قيمتها الإجمالية أكبر ما يمكن. هذا مثال توضيحي لحل مشكلة الحقيبة: v: مصفوفة تمثّل قيم العناصر. w: مصفوفة تمثّل أوزان العناصر. n: مصفوفة تحتوي العناصر غير المكررة. W: تمثّل سعة Capacity الحقيبة. for j from 0 to W do: m[0, j] := 0 for i from 1 to n do: for j from 0 to W do: if w[i] > j then: m[i, j] := m[i-1, j] else: m[i, j] := max(m[i-1, j], m[i-1, j-w[i]] + v[i]) هذا تطبيق implementation للشيفرة السالفة بلغة بايثون: def knapSack(W, wt, val, n): K = [[0 for x in range(W+1)] for x in range(n+1)] for i in range(n+1): for w in range(W+1): if i==0 or w==0: K[i][w] = 0 elif wt[i-1] <= w: K[i][w] = max(val[i-1] + K[i-1][w-wt[i-1]], K[i-1][w]) else: K[i][w] = K[i-1][w] return K[n][W] val = [60, 100, 120] wt = [10, 20, 30] W = 50 n = len(val) print(knapSack(W, wt, val, n)) احفظ هذه الشيفرة في ملفّ يُسمّى knapSack.py، ثمّ نفّذه على النحو التالي: $ python knapSack.py 220 التعقيد الزمني: يساوي تعقيد الشيفرة السابقة O(nW)، حيث يمثّل n عدد العناصر، وتمثّل W سعة الحقيبة. هذا تطبيق آخر بلغة C#: public class KnapsackProblem { private static int Knapsack(int w, int[] weight, int[] value, int n) { int i; int[,] k = new int[n + 1, w + 1]; for (i = 0; i <= n; i++) { int b; for (b = 0; b <= w; b++) { if (i==0 || b==0) { k[i, b] = 0; } else if (weight[i - 1] <= b) { k[i, b] = Math.Max(value[i - 1] + k[i - 1, b - weight[i - 1]], k[i - 1, b]); } else { k[i, b] = k[i - 1, b]; } } } return k[n, w]; } public static int Main(int nItems, int[] weights, int[] values) { int n = values.Length; return Knapsack(nItems, weights, values, n); } } التحقق من العبارات المقلوبة لتكن س سلسلة نصية، ولتكن ع سلسلة نصية أخرى نحصل عليها بتغيير مواضع بعض حروف س، فتُسمّى السلسلة النصية ع لفظًا مقلوبًا anagram للسلسلة النصية س. مثلًا، تعدّ الكلمتان لكم و كلم لفظان مقلوبان للكلمة ملك. بتعبير آخر، تكون سلسلة نصية لفظًا مقلوبًا من سلسلة نصية أخرى إن كانت السلسِلتان مؤلّفتان من مجموعة الحروف نفسها، وبالتردّدات نفسها. لتكن str1 و str2 سلسلتين نصيتين، نريد أن نتحقّق ممّا إذا كانت str2 لفضًا مقلوبًا للسلسلة str1. هذه خطوات الخوارزمية التي سنعتمدها: أنشئ قاموسًا hashMap مفاتيحه تساوي حروف str1 (غير مكرّرة)، وقيمة كل مفتاح (أو حرف) في القاموس تساوي عدد مرّات تكرار ذلك الحرف. امرر على كل حرف من حروف str2 وتحقّق من أنّه موجود في القاموس hashMap. إن وجدت الحرف في القاموس فانقص قيمته في القاموس بواحد. إن لم تجد الحرف في القاموس فذلك يدل على أنّه غير موجود في السلسلة النصية str1، وإذن لا يمكن أن تكون str2 لفضًا مقلوبًا للسلسلة str1. تنتهي الخوارزمية هنا وتعيد false. إن كانت جميع قيم مفاتيح القاموس hashMap تساوي الصفر بعد المرور على كل حروف str2، فذلك يدل على أنّ str2 لفظًا مقلوبًا للسلسلة str1، نعيد إذن true. خلاف ذلك نعيد false. انظر المثال التالي: let str1 = 'stackoverflow'; let str2 = 'flowerovstack'; هاتان السلسِلتان النصيتَان مُتقالبتان. وهذا هو قاموس الترددات خاصّتها: hashMap = { s : 1, t : 1, a : 1, c : 1, k : 1, o : 2, v : 1, e : 1, r : 1, f : 1, l : 1, w : 1 } لاحظ أنّ القيمة المرتبطة بالمفتاح o تساوي 2، ذلك أنّ o تكرّرت مرّتين في السلسلة النصية. سنمرّ على كل حرف من حروف str2 لنتحقق من أنّه موجود في hashMap، فإن كان كذلك نقصنا قيمة الحرف في hashMap واحدًا، وإلا أعدنا false دلالة على أنّ str2 ليست لفظًا مقلوبًا عن str1. hashMap = { s : 0, t : 0, a : 0, c : 0, k : 0, o : 0, v : 0, e : 0, r : 0, f : 0, l : 0, w : 0 } نمرّ الآن على كل قيم hashMap ونتحقق من أنّها تساوي جميعًا 0. وبما أن جميع قيم hashMap تساوي 0 في المثال الذي ضربناه آنفًا، فإنّ str2 لفظٌ مقلوب عن str1. إليك مثال توضيحي لخوارزمية التحقق من الألفاظ المقلوبة: (function(){ var hashMap = {}; function isAnagram (str1, str2) { if(str1.length !== str2.length){ return false; } createStr1HashMap(str1); var valueExist = createStr2HashMap(str2); return isStringsAnagram(valueExist); } function createStr1HashMap (str1) { [].map.call(str1, function(value, index, array){ hashMap[value] = value in hashMap ? (hashMap[value] + 1) : 1; return value; }); } function createStr2HashMap (str2) { var valueExist = [].every.call(str2, function(value, index, array){ if(value in hashMap) { hashMap[value] = hashMap[value] - 1; } return value in hashMap; }); return valueExist; } function isStringsAnagram (valueExist) { if(!valueExist) { return valueExist; } else { var isAnagram; for(var i in hashMap) { if(hashMap[i] !== 0) { isAnagram = false; break; } else { isAnagram = true; } } return isAnagram; } } isAnagram('stackoverflow', 'flowerovstack'); // true isAnagram('stackoverflow', 'flowervvstack'); // false })(); التعقيد الزمني: 3n أو O (n). مثلث باسكال مثلث باسكال هو منظومة عددية تُرتّب على هيئة مثلث، ولها استخدامات كثيرة في الرياضيات. هذه شيفرة مكتوبة بلغة C لطباعة مثلث باسكال: int i, space, rows, k=0, count = 0, count1 = 0; row=5; for(i=1; i<=rows; ++i) { for(space=1; space <= rows-i; ++space) { printf(" "); ++count; } while(k != 2*i-1) { if (count <= rows-1) { printf("%d ", i+k); ++count; } else { ++count1; printf("%d ", (i+k-2*count1)); } ++k; } count1 = count = k = 0; printf("\n"); } الخرج الناتج: 1 2 3 2 3 4 5 4 3 4 5 6 7 6 5 4 5 6 7 8 9 8 7 6 5 ترجمة -بتصرّف- للفصول 45 و 49 و 50 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: خوارزميات حل المعادلات الرياضية المرجع الشامل إلى: تعلم الخوارزميات للمبتدئين أمثلة على أشهر خوارزميات المخططات والأشجار خوارزميات البحث وآلية عملها حل المشكلات وأهميتها في احتراف البرمجة
-
حل المعادلات هي من المسائل الشائعة في الرياضيات، وهناك بحث مستمر عن طرق جديدة وسريعة لحل المعادلات عبر الحاسوب، وسنستعرض في هذه المقالة بعض خوارزميات حل المعادلات الخطية وغير الخطية. المعادلات الخطية Linear Equations هناك نوعان من الطرق لحل المعادلات الخطية: الطرق المباشرة: يسعى هذا النوع من الطرق إلى تحويل المعادلة الأصلية إلى معادلة مكافئة أيسر حلًّا، أي أنّنا نسعى في هذا النوع إلى إيجاد الحل مباشرة من معادلة. الطرق التكرارية Iterative Method: تبدأ هذه الطرق بتخمين قيمة أولية للحل، ثم تُجري عمليات تكرارية تقرِّب من الحل، وتستمر إلى حين الاقتراب من الحل بمقدار محدّد سلفًا. تعدّ الطرق التكرارية أقل فعالية على العموم من نظيراتها المباشرة لأنّها تجري الكثير من العمليات الإضافية، ولدينا بعض الأمثلة على الطرق التكرارية مثل طريقة جاكوبي التكرارية Jacobi's Iteration Method، وطريقة جاوس - سيدل Gauss-Seidal. إليك تطبيق لطريقة جاكوبي بلغة C: // تطبيق لطريقة جاكوبي void JacobisMethod(int n, double x[n], double b[n], double a[n][n]){ double Nx[n]; // شكل مُعدَّل من المتغيرات int rootFound=0; // راية int i, j; while(!rootFound){ for(i=0; i<n; i++){ Nx[i]=b[i]; for(j=0; j<n; j++){ if(i!=j) Nx[i] = Nx[i]-a[i][j]*x[j]; } Nx[i] = Nx[i] / a[i][i]; } rootFound=1; // التحقق من قيمة الراية for(i=0; i<n; i++){ if(!( (Nx[i]-x[i])/x[i] > -0.000001 && (Nx[i]-x[i])/x[i] < 0.000001 )){ rootFound=0; break; } } for(i=0; i<n; i++){ // تقييم x[i]=Nx[i]; } } return ; } وإليك تطبيق لطريقة جاوس-سيدل بلغة C أيضًا: // تطبيق لطريقة جاوس سيدل void GaussSeidalMethod(int n, double x[n], double b[n], double a[n][n]){ double Nx[n]; // شكل معدّل من المتغيرات int rootFound=0; // راية int i, j; for(i=0; i<n; i++){ //تهيئة Nx[i]=x[i]; } while(!rootFound){ for(i=0; i<n; i++){ Nx[i]=b[i]; for(j=0; j<n; j++){ if(i!=j) Nx[i] = Nx[i]-a[i][j]*Nx[j]; } Nx[i] = Nx[i] / a[i][i]; } rootFound=1; // التحقق من قيمة الراية for(i=0; i<n; i++){ if(!( (Nx[i]-x[i])/x[i] > -0.000001 && (Nx[i]-x[i])/x[i] < 0.000001 )){ rootFound=0; break; } } for(i=0; i<n; i++){ // تقييم x[i]=Nx[i]; } } return ; } // طباعة المصفوفة void print(int n, double x[n]){ int i; for(i=0; i<n; i++){ printf("%lf, ", x[i]); } printf("\n\n"); return ; } int main(){ // تهيئة المعادلة int n=3; // عدد المتغيرات double x[n]; // المتغيرات double b[n], // الثوابت a[n][n]; // المعاملات //تعيين القيم a[0][0]=8; a[0][1]=2; a[0][2]=-2; b[0]=8; //8x₁+2x₂-2x₃+8=0 a[1][0]=1; a[1][1]=-8; a[1][2]=3; b[1]=-4; //x₁-8x₂+3x₃-4=0 a[2][0]=2; a[2][1]=1; a[2][2]=9; b[2]=12; //2x₁+x₂+9x₃+12=0 int i; for(i=0; i<n; i++){ // تهيئة x[i]=0; } JacobisMethod(n, x, b, a); print(n, x); for(i=0; i<n; i++){ //تهيئة x[i]=0; } GaussSeidalMethod(n, x, b, a); print(n, x); return 0; } المعادلات غير الخطية Non-Linear Equations تُصنَّف المعادلات من النوع f(x)= 0 إلى صنفين: إما جبرية أو غير جبرية -أو متسامية transcendental- ويمكن حل المعادلات غير الخطية باستخدام أحد أسلوبين: الأسلوب المباشر: يعطي هذا الأسلوب القيمة الدقيقة لكل جذور f (حلول المعادلة) مباشرة في عدد محدود من الخطوات. الأسلوب غير المباشر أو التكراري: هذا النوع أصلح من النوع الأول لحل المعادلات عبر الحاسوب، ويُبنى على مبدأ التقريب المتتالي، ولدينا طريقتين لحل المعادلة في الأسلوب التكراري: طريقة الحصار Bracketing Method: نأخذ نقطتين أوليّتين نعلم أنّ الجذر يقع بينهما، ثم نستمر في تضييق طول المجال الذي يحاصر الجذر إلى أن نصل إلى طول تقريبي معيّن. تُعد خوارزمية التنصيف من أشهر الخوارزميات التي تستخدم طريقة الحصار. طريقة النهاية المفتوحة Open End Method: نأخذ قيمة أولية أو قيمتين، ولا يُشترط أن تحاصر هاتان القيمتان جذر المعادلة، ثم نكرّر إجراء عمليات حسابية على هاتين القيمتين. وعادة ما يحدث هنا أحد أمرين، إمّا أن تتباعد القيمتان مع تكرار العمليات، أو تتقاربان -أي تؤُولان إلى نقطة واحدة، فإن كانتا متقاربتين فإنّ نقطة التقارب ستكون هي الحل. هذه الطريقة أسرع عمومًا من طريقة الحصار، ويُعد أسلوب نيوتن-رافسون Newton-Raphson، وأسلوب التقريب المتتالي Successive Approximation Method، وأسلوب القاطع Secant Method من الأمثلة على هذه الطريقة. هذا تطبيق بلغة C للحلول السابقة كلها على معادلات وضعناها في بداية الشيفرة: // دوال مساعدة #define f(x) ( ((x)*(x)*(x)) - (x) - 2 ) #define f2(x) ( (3*(x)*(x)) - 1 ) #define g(x) ( cbrt( (x) + 2 ) ) /** * نأخذ قيمتية أوليتين ونقصّر المسافة من كلا الجانبين **/ double BisectionMethod(){ double root=0; double a=1, b=2; double c=0; int loopCounter=0; if(f(a)*f(b) < 0){ while(1){ loopCounter++; c=(a+b)/2; if(f(c)<0.00001 && f(c)>-0.00001){ root=c; break; } if((f(a))*(f(c)) < 0){ b=c; }else{ a=c; } } } printf("It took %d loops.\n", loopCounter); return root; } /** * نأخذ قيمتية أوليتين ونقصّر المسافة من جانب واحد **/ double FalsePosition(){ double root=0; double a=1, b=2; double c=0; int loopCounter=0; if(f(a)*f(b) < 0){ while(1){ loopCounter++; c=(a*f(b) - b*f(a)) / (f(b) - f(a)); /*/printf("%lf\t %lf \n", c, f(c));/**////test if(f(c)<0.00001 && f(c)>-0.00001){ root=c; break; } if((f(a))*(f(c)) < 0){ b=c; }else{ a=c; } } } printf("It took %d loops.\n", loopCounter); return root; } /** * نستخدم قيمة أولية واحدة، ثمّ نتدرّج نحو القيمة الصحيحة **/ double NewtonRaphson(){ double root=0; double x1=1; double x2=0; int loopCounter=0; while(1){ loopCounter++; x2 = x1 - (f(x1)/f2(x1)); /*/printf("%lf \t %lf \n", x2, f(x2));/**////test if(f(x2)<0.00001 && f(x2)>-0.00001){ root=x2; break; } x1=x2; } printf("It took %d loops.\n", loopCounter); return root; } /** * نستخدم قيمة أولية واحدة، ثمّ نتدرّج نحو القيمة الصحيحة **/ double FixedPoint(){ double root=0; double x=1; int loopCounter=0; while(1){ loopCounter++; if( (x-g(x)) <0.00001 && (x-g(x)) >-0.00001){ root = x; break; } /*/printf("%lf \t %lf \n", g(x), x-(g(x)));/**////test x=g(x); } printf("It took %d loops.\n", loopCounter); return root; } /** *استخدام قيمتين أوليتين، بحيث تقترب كلاهما من الحل الصحيح **/ double Secant(){ double root=0; double x0=1; double x1=2; double x2=0; int loopCounter=0; while(1){ loopCounter++; /*/printf("%lf \t %lf \t %lf \n", x0, x1, f(x1));/**////test if(f(x1)<0.00001 && f(x1)>-0.00001){ root=x1; break; } x2 = ((x0*f(x1))-(x1*f(x0))) / (f(x1)-f(x0)); x0=x1; x1=x2; } printf("It took %d loops.\n", loopCounter); return root; } int main(){ double root; root = BisectionMethod(); printf("Using Bisection Method the root is: %lf \n\n", root); root = FalsePosition(); printf("Using False Position Method the root is: %lf \n\n", root); root = NewtonRaphson(); printf("Using Newton-Raphson Method the root is: %lf \n\n", root); root = FixedPoint(); printf("Using Fixed Point Method the root is: %lf \n\n", root); root = Secant(); printf("Using Secant Method the root is: %lf \n\n", root); return 0; } ترجمة -بتصرّف- للفصل 46 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال التالي: أمثلة على خوارزميات لحل مشكلات بسيطة المقال السابق: مفهوم دوال التقطيع Hash Functions في الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية
-
تكون دالةُ h() دالةَ تقطيع Hash Functions إذا كانت تأخذ عنصرًا x ∈ X من أيّ حجم، وتعيد قيمة y ∈ Y من حجمٍ ثابت y = h (x). تتميز دوّال التقطيع النموذجية بالخصائص التالية: تتصرف مثل توزِيعات منتظمة uniform distribution دوال التقطيع حتمية deterministic، حيث ينبغي أن تعيد الدالة h(x) القيمة نفسها دائمًا لعنصر x محدد. ينبغي أن تكون سريعة الحساب (ذات تعقيد زمني O (1)). حجم قيمة التقطيع عمومًا أصغر من حجم البيانات المُدخلة: |y| < |x|، علاوة على أنّ دوال التقطيع غير قابلة للعكس، لأنه يجوز أن تكون لقيمتين مختلفتين قيمة التقطيع نفسها، أو بتعبير رياضي: ∃ x1, x2 ∈ X, x1 ≠ x2: h(x1) = h(x2) قد تكون المجموعة X منتهية أو غير منتهية، أما Y فهي دائمًا مجموعة منتهية. وتُستخدم دوال التقطيع في الكثير من مجالات الحوسبة مثل هندسة البرمجيات والتشفير وقواعد البيانات والشبكات وتعلُُّم الآلة ونحو ذك، وهناك العديد من أنواع دوال التقطيع، ولكل منها خصائص مميزة. تكون قيمة التقطيع عددًا صحيحًا في الغالب، ومعظم لغات البرمجة لديها توابع ودوال خاصة لحساب قيم التقطيع، مثل الدالة GetHashCode() في لغة C# التي تعيد قيمة من النوع Int32 (عدد صحيح من 32 بتة) لكل الأنواع. وكذلك في لغة جافا هناك تابع hashCode() يحسب قيمة التقطيع -من النوع int- لكل صنف من أصناف جافا. طرق تعريف دوال التقطيع Hash methods هناك عدة طرق لتعريف دوال التقطيع، يمكننا أن نفترض دون الإخلال بالعمومية، أنّ المجموعة X تساوي مجموعة الأعداد الصحيحة الموجبة، وx عنصر منها: أي x ∈ X = {z ∈ ℤ: z ≥ 0}. وليكن m عددًا، ويُفضّل أن يكون أوليًا (شرط أن لا تكون قيمته قريبة جدًا من أي قوّة للعدد 2). فيما يلي طريقتان لحساب قيم التقطيع الخاصة بعناصر X: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } الطريقة دالة التقطيع طريقة القسمة h(x) = x mod m طريقة الضرب h(x) = ⌊m (xA mod 1)⌋, A ∈ {z ∈ ℝ: 0 < z < 1} جداول التقطيع تُستخدم دوال التقطيع مع جداول التقطيع hash tables لحساب الفهارس في مصفوفة من الحجرات array of slots، وجدول التقطيع هي هيكلية بيانات لتنفيذ القواميس dictionaries وهي بيانات من نوع مفتاح-قيمة. التعقيد الزمني لتطبيقات لجداول التقطيع يساوي في العادة O (1) للعمليات التالية: إدراج البيانات بحسب قيمة المفتاح. حذف البيانات بحسب قيمة المفتاح. يمكن أن يكون لعدة مفاتيح نفس التقطيع (الحجرة)، وعندئذ فهناك طريقتان لحل هذه المشكلة: السلسلة Chaining: تُستخدم القوائم المترابطة لتخزين العناصر التي لها نفس قيمة التقطيع في الحجرة. العنونة المفتوحة Open addressing: يُخزّن عنصر واحد على الأكثر في كل حجرة. تُستخدم الطرق التالية لحساب تسلسلات المسبار probe sequences المطلوبة في العنونة المفتوحة: الطريقة الصيغة السبر الخطي h(x, i) = (h”(x) + i) mod m السبر التربيعي h(x, i) = (h”(x) + c1*i + c2*i^2) mod m السبر المضاعف h(x, i) = (h1(x) + i*h2(x)) mod m الدوال h"(x) و h1(x) و h2(x) هي دوال مساعدة، وi ينتمي إلى المجموعة {0, 1, ..., m-1}، وc1 و c2 ثابتتان موجبتان. يمكنك معرفة المزيد من التفاصيل عن هذه الطرق الثلاث وغيرها من المفاهيم المتعلقة بدوال التقطيع من موسوعة حسوب. لنأخذ المثال التالي، ليكن x ∈ U{1, 1000} و h = x mod m. يوضح الجدول التالي قيم التقطيع في حالة كان العدد m أوليا أو غير أولي، تشير النصوص الغليظة إلى قيم التقطيع المتساوية. x m = 100 - غير أولي m = 101 - أولي 723 23 16 103 3 2 738 38 31 292 92 90 61 61 61 87 87 87 995 95 86 549 49 44 991 91 82 757 57 50 920 20 11 626 26 20 557 57 52 931 31 23 619 19 13 قيم التقطيع للأنواع الشائعة في C# سنستعرض في هذه الفقرة قيم التقطيع hash codes التي ينتجها التابع GetHashCode() لأنواع C#المضمّنة في فضاء الاسم System. يمكنك الاطلاع على توثيقات هذه الأنواع من github. القيم المنطقية Boolean 1 إذا كانت القيمة صحيحة true، أو 0 خلاف ذلك. الأنواع Byte و UInt16 و Int32 و UInt32 و Single قيمة العنصر (تُحوّل عند الضرورة إلى النوع Int32). النوع SByte ((int)m_value ^ (int)m_value << 8); النوع Char (int)m_value ^ ((int)m_value << 16); النوع Int16 ((int)((ushort)m_value) ^ (((int)m_value) << 16)); النوعان Int64 و Double تطبيق المعامل Xor بين أدنى 32 بتّة وأعلى 32 بتة في العدد المؤلف من 64 بتة. (unchecked((int)((long)m_value)) ^ (int)(m_value >> 32)); الأنواع UInt64 و DateTime و TimeSpan ((int)m_value) ^ (int)(m_value >> 32); النوع Decimal ((((int *)&dbl)[0]) & 0xFFFFFFF0) ^ ((int *)&dbl)[1]; Object النوع Object هو الصنف الجذري الذي تنحدر منه جميع الكائنات. RuntimeHelpers.GetHashCode(this); استخدمنا التطبيق الافتراضي فهرس كتلة المزامنة sync block index. السلاسل النصية String يعتمد حساب قيمة التقطيع على نوع نظام التشغيل (Win32 أو Win64)، وإمكانية استخدام تقطيع السلاسل النصية العشوائية randomized string hashing، إضافة إلى الإصدار ووضعية المنقّح. في حالة المنصة Win64: int hash1 = 5381; int hash2 = hash1; int c; char *s = src; while ((c = s[0]) != 0) { hash1 = ((hash1 << 5) + hash1) ^ c; c = s[1]; if (c == 0) break; hash2 = ((hash2 << 5) + hash2) ^ c; s += 2; } return hash1 + (hash2 * 1566083941); ValueType يتم البحث عن أول حقل غير ساكن non-static field، وتُحسب قيمة التقطيع خاصته، إذا لم يكن للنوع أيّ حقل غير ساكن، تُعاد قيمة التقطيع الخاصة بذلك النوع. لا يمكن الاعتماد على قيمة التقطيع الخاصة بمتغير عضو ساكن static member، لأنه قد تنتجُ حلقة أبدية إذا كان نوع ذلك العضو يساوي النوع الأصلي. النوع Nullable return hasValue ? value.GetHashCode() : 0; المصفوفات Array int ret = 0; for (int i = (Length >= 8 ? Length - 8 : 0); i < Length; i++) { ret = ((ret << 5) + ret) ^ comparer.GetHashCode(GetValue(i)); } ترجمة -بتصرّف- للفصل 43 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: أمثلة على أشهر خوارزميات المخططات والأشجار مدخل إلى الخوارزميات أمثلة عن أنواع الخوارزميات تعقيد الخوارزميات Algorithms Complexity
-
تستعرض هذه المقالة أربعةً من خوارزميات الأشجار والمخططات، وهي خوارزمية البحث بالعرض أولا BFS، وخوارزمية البحث بالعمق أولا DFS، والبائع المتجول، وخوارزمية كثيرة الحدود للعثور على أصغر غطاء رأسي في مخطط ما. البحث بالعرض أولا Breadth-First Search سنحاول استعمال هذه الخوارزمية في عدة تطبيقات عمليات بحث. البحث عن أقصر مسار من المصدر إلى العقد الأخرى خوارزمية البحث بالعرض أولا BFS هي خوارزمية لتسلق مخطط أو البحث فيها. تبدأ الخوارزمية من جذر الشجرة (أو أيّ عقدة من المخطط، ويُشار إليها أحيانًا باسم "مفتاح البحث" - search key)، ثمّ تستكشف العقد المجاورة أولاً قبل الانتقال إلى المستوى التالي من العقد. اكتُشفت خوارزمية BFS في أواخر الخمسينيات من قبل إدوارد فورست مور Edward Forrest Moore، الذي استخدمها للعثور على أقصر مسار للخروج من متاهة، وقد اكتُشفت أيضًا بشكل مستقل من قبل CY Lee الذي استخدمها في مجال التوجيه السلكي في عام 1961. تفترض خوارزمية BFS الأمور التالية: لن نجتاز أي عقدة أكثر من مرة. تقع العقدة المصدرية (أي العقدة التي نبدأ منها) في المستوى 0. العقد التي يمكننا الوصول إليها مباشرة من العقدة المصدرية ستكون في المستوى 1، والعقد التي يمكننا الوصول إليها مباشرة من عقد المستوى 1 ستكون في المستوى 2 وهكذا دواليك. يشير مستوى عقدة ما إلى أقصر مسار يصل إلى تلك العقدة انطلاقًا من المصدر. انظر المثال التالي: لنفترض أنّ هذا المخطط يمثل طرقا بين عدة مدن حيث تمثل كل عقدة مدينة واحدة، فيما يشير كل ضلع بين عقدتين إلى طريق يربط بينهما، ونحن نريد الانتقال من العقدة 1 إلى العقدة 10. ستكون العقدة 1 هي المصدر، وهذا يجعلها في المستوى 0. نحدد (نلوّن) العقدة 1 لنشير إلى أننا زرناها. يمكننا الذهاب إلى العقدة 2 أو العقدة 3 أو العقدة 4 من العقدة المصدر، أي العقدة 1، لذلك ستكون هناك 3 عقد من المستوى 1. نحدد هذه العقد لنبيّن أنّها مُزارة، ثمّ نعمل عليها. نلوّن العقد المُزارة، حيث نلوّن العقد التي نعمل عليها حاليًا باللون الوردي، تذكّر أنّنا لن نزور العقدة نفسها مرتين. يمكننا الذهاب إلى العقد 6 و 7 و 8 عبر كل من العقدة 2 والعقدة 3 والعقدة 4، وسنحددهذه العقد لنبيّن أنّها مُزارة. مستوى هذه العقد سيساوي (1 +1) = 2. يشير مستوى العقد إلى أقصر مسافة بينها وبين المصدر. على سبيل المثال: بما أن العقدة 8 موجودة في المستوى 2، فهذا يعني أنّ المسافة من المصدر إلى العقدة 8 هي 2. لم نصل بعدُ إلى العقدة المستهدفة وهي العقدة 10، لذا ننتقل إلى العقد التالية. نستطيع الانطلاق من أيّ من عقد المستوى 2، وهي العقدة 6 والعقدة 7 والعقدة 8. ها قد وجدنا العقدة 10 عند المستوى 3، هذا يعني أنّ أقصر مسار من المصدر إلى العقدة 10 هو 3، وقد بحثنا في كل مستوى من مستويات المخطط إلى أن وجدنا أقصر مسار، وإذ ذاك سنمسح الأضلاع التي لم نستخدمها: بعد إزالة تلك الأضلاع التي لم نستخدمها نحصل على شجرة تُسمى شجرة البحث بالعرض أولًا BFS. تُظهر هذه الشجرة أقصر مسار من المصدر إلى جميع العقد الأخرى. مهمتنا الآن هي الانتقال من العقدة المصدر إلى العقد الموجودة في المستوى 1، ثم من عقد المستوى 1 إلى عقد المستوى 2، وهكذا حتى نصل إلى وجهتنا. يمكننا استخدام الطوابير Queues لتخزين العقد التي سنعالجها. لكل عقدة نعمل عليها فإننا ندفع (نضيف) إلى الطابور جميع العُقد الأخرى التي من الممكن اجتيَازها مباشرة لكنّنا لم نفعل بعد. هذه محاكاة للمثال: أولاً، ندفع العقدة المصدر إلى الطابور، فيبدو هكذا: front +-----+ | 1 | +-----+ مستوى العقدة 1 يساوي 0، أي level[1] = 0، نبدأ الآن البحث بالعرض أولا BFS. في البداية، ننزع عقدة من الطابور فنحصل على العقدة 1، يمكننا -انطلاقًا من هذه العقدة- أن نذهب إلى العقدة 4 أو 3 أو 2، لهذا فهذه العقد الثلاث من المستوى الأول، أي level[4] = level[3] = level[2] = level[1] + 1 = 1. سنحددالآن هذه العقد لنبيّن أنّها مُزارَة، ثمّ ندفعها إلى الطابور. front +-----+ +-----+ +-----+ | 2 | | 3 | | 4 | +-----+ +-----+ +-----+ ننزع الآن العقدة 4 من الطابور ونعالجها، نستطيع الذهاب منها إلى العقدة 7، لهذا يكون لدينا: level[7] = level[4] + 1 = 2. والآن نحددالعقدة 7، ثمّ ندفعها إلى الطابور. front +-----+ +-----+ +-----+ | 7 | | 2 | | 3 | +-----+ +-----+ +-----+ من العقدة 3، يمكننا الذهاب إلى العقدة 7 أو العقدة 8، وبما أن العقدة 7 محددة من قبل (أي مُزارة سابقًا)، فإننا نحدد العقدة 8، ونضع level[8] = level[3] + 1 = 2. والآن ندفع العقدة 8 إلى الطابور. front +-----+ +-----+ +-----+ | 6 | | 7 | | 2 | +-----+ +-----+ +-----+ ستستمر هذه العملية حتى نصل إلى وجهتنا أو نُفرِغ الطابور، وتزوّدنا مصفوفة المستويات level بطول أقصر مسار من المصدر. نستطيع تهيئة عناصر مصفوفة المستويات بقيمة اللانهاية، كناية إلى أنّ العقد لم تُزر بعد. انظر المثال التوضيحي لهذا: Procedure BFS(Graph, source): Q = queue(); level[] = infinity level[source] := 0 Q.push(source) while Q is not empty u -> Q.pop() for all edges from u to v in Adjacency list if level[v] == infinity level[v] := level[u] + 1 Q.push(v) end if end for end while Return level يمكننا معرفة المسافة التي تفصل كل عقدة عن المصدر من خلال التكرار عبر مصفوفة المستويات، على سبيل المثال: المسافة إلى العقدة 10 من المصدر مخزّنة في level[10]. قد نريد أحيانًا أن نعرف المسارات الأخرى التي يمكن أن تُوصلنا إلى العقدة انطلاقًا من المصدر، وذلك إلى جانب المسار الأقصر. لفعل هذا نحتاج إلى مصفوفة جديدة نسميها parent، قيمة parent[source] (source هنا تعني العقدة المصدر) ستكون معدومة NULL. نضيف التعليمة parent[v] := u إلى المثال الوهمي عند كل تحديث لمصفوفة المستويات level في حلقة for. لكي تعثر على المسار بعد انتهاء خوارزمية البحث العرضي أولًا، يكفي أن تجتاز المصفوفة parent خلفيًا إلى أن تصل إلى المصدر، والذي يحمل القيمة NULL في هذه المصفوفة. هذا مثال توضيحي يستخدم العودية recursion: Procedure PrintPath(u): if parent[u] is not equal to null PrintPath(parent[u]) end if print -> u وهذا مثال لا يستخدمها وإنما يستخدم التكرارية فقط iteration: Procedure PrintPath(u): S = Stack() while parent[u] is not equal to null S.push(u) u := parent[u] end while while S is not empty print -> S.pop end while تعقيد الخوارزمية: سوف نزور كل عقدة وكل ضلع مرّة واحدة بالضبط، لذا سيكون تعقيد الخوارزمية الزمني O (V + E)، حيث يمثل V عدد العقد، ويمثّل E عدد الأضلاع. البحث عن أقصر مسار ينطلق من المصدر في مخطط ثنائية الأبعاد قد نحتاج أحيانًا إلى العثور على أقصر مسار من مصدر معيّن إلى جميع العقد الأخرى، أو إلى عقدة محدّدة في مخطط ثنائية الأبعاد. على سبيل المثال: نريد أن نعرف عدد التحركات المطلوبة للفارس للوصول إلى مربع معين في رقعة الشطرنج، وهذه حالة خاصة من مشكلة أكبر، حيث تكون لدينا رقعة بعض مربعاتها محظورة (الفارس لا يمكنه الانتقال لبعض المربعات على الرقعة)، وعلينا العثور على أقصر مسار من مربع إلى آخر لا يمر عبر المربعات المحظورة. في مثل هذه الحالات، يمكننا تحويل المربعات أو الخلايا إلى عقد، ونحل هذه المشكلة بسهولة باستخدام خوارزمية البحث بالعرض أولًا BFS. ستكون المصفوفات visited و parent و level جميعها مصفوفات ثنائية الأبعاد. نأخذ جميع التحركات الممكنة لكل عقدة، ونحسب المسافة إلى العقد الأخرى عبر تحليل مسارات التحرك، ثم نضيف مصفوفة أخرى نسميها direction، والتي ستخزّن جميع التركيبات الممكنة للاتجاهات التي يمكننا الذهاب إليها. مثلا، هذه مصفوفة الاتجاهات الخاصة بالتحركات الأفقية والعمودية: +----+-----+-----+-----+-----+ | dx | 1 | -1 | 0 | 0 | +----+-----+-----+-----+-----+ | dy | 0 | 0 | 1 | -1 | +----+-----+-----+-----+-----+ تمثل dx الحركة على المحور الأفقي x بينما تمثل dy الحركة على المحور العمودي y، ولا شك أن هناك طرق أخرى لتمثيل اتجاهات الحركة لكن يُفضل استخدام مصفوفة الاتجاهات لأنها أسهل وأبسط. كذلك هناك الكثير من التركيبات الممكنة للحركة، إذ يمكن أن تكون التحركات قُطرية أو قد تكون أكثر تعقيدًا مثل تحركات الحصان على رقعة الشطرنج. وينبغي أن نضع في حسباننا الأمور التالية: إذا كانت أيّ من الخلايا محظورة فسيكون علينا أن نتحقق في كل حركة محتملة مما إذا كانت الخلية الحالية محظورة أم لا. علينا أن نتحقق أيضًا ممّا إذا كنا قد تجاوزنا الحدودَ، أي حدودَ المصفوفة. سنُعطى عدد الصفوف والأعمدة. انظر المثال التوضيحي التالي: Procedure BFS2D(Graph, blocksign, row, column): for i from 1 to row for j from 1 to column visited[i][j] := false end for end for visited[source.x][source.y] := true level[source.x][source.y] := 0 Q = queue() Q.push(source) m := dx.size while Q is not empty top := Q.pop for i from 1 to m temp.x := top.x + dx[i] temp.y := top.y + dy[i] if temp is inside the row and column and top doesn't equal to blocksign visited[temp.x][temp.y] := true level[temp.x][temp.y] := level[top.x][top.y] + 1 Q.push(temp) end if end for end while Return level ناقشنا سابقًا أن خوارزمية البحث بالعرض أولا BFS لا تصلح إلا للمخططات غير الموزونة unweighted graphs، أما بالنسبة للمخططات الموزونة فسنحتاج إلى خوارزمية ديكسترا. وبالنسبة لدورات الأضلاع السلبية negative edge cycles فسنحتاج إلى خوارزمية بِِلمان - فورد. هناك مسألة أخرى ينبغي الانتباه إليها، وهي أنّ هذه الخوارزمية مخصّصة لإيجاد أقصر مسار من مصدر واحد، فإذا أردت العثور على المسافة من كل عقدة إلى جميع العقد الأخرى ستحتاج إلى استخدام خوارزمية بِلمان - فورد كذلك. البحث عن المكونات المتصلة لمخطط غير موجهة باستخدام خوارزمية BFS يمكن استخدام خوارزمية BFS للعثور على المكونات المتصلة connected components في مخطط غير موجه، ويمكن استخدامها أيضًا للتحقق مما إذا كان المخطط متصلًا أم لا، وسنفترض فيما يلي أنّنا نتعامل مع مخططات غير موجهة. تعريف: يكون المخطط متصلًا connected إذا كان هناك مسار بين كل زوج من الحروف في المخطط. هذا مثال على مخطط متصل: المخطط التالي غير متصل، ولكن يحتوي على مكوّنتين متصلتين: المكونة المتصل الأولى: {a، b، c، d، e} . المكون المتصلة الثانية: {f} خوارزمية BFS هي خوارزمية لاجتياز المخططات، لذا إن بدأت الخوارزمية من عقدة مصدرية ما وانتقلت من عقدة إلى أخرى، ولم تترك عقدة إلا زارتها قبل انتهائها، فيكون المخطط في هذه الحالة متصلًا، أما إن بقيت بعض بعض العقد غير مُزارة، فسيكون المخطط غير متصل. انظر المثال التوضيحي التالي للخوارزمية: boolean isConnected(Graph g) { BFS(v) // هي عقدة مصدرية v if(allVisited(g)) { return true; } else return false; } وهذا تطبيق بلغة C للتحقق مما إذا كان مخططًا متصلًأ غير موجّه أم لا: #include<stdio.h> #include<stdlib.h> #define MAXVERTICES 100 void enqueue(int); int deque(); int isConnected(char **graph,int noOfVertices); void BFS(char **graph,int vertex,int noOfVertices); int count = 0; struct node { int v; struct node *next; }; typedef struct node Node; typedef struct node *Nodeptr; Nodeptr Qfront = NULL; Nodeptr Qrear = NULL; char *visited;// مصفوفة لتتبع العقد المُزارة int main() { int n,e;// يمثل n عدد الحروف، ويمثل e عدد الأضلاع int i,j; char **graph;//adjacency matrix printf("Enter number of vertices:"); scanf("%d",&n); if(n < 0 || n > MAXVERTICES) { fprintf(stderr, "Please enter a valid positive integer from 1 to %d",MAXVERTICES); return -1; } graph = malloc(n * sizeof(char *)); visited = malloc(n*sizeof(char)); for(i = 0;i < n;++i) { graph[i] = malloc(n*sizeof(int)); visited[i] = 'N';// في البداية، تكون جميع العقد غير مزارة for(j = 0;j < n;++j) graph[i][j] = 0; } printf("enter number of edges and then enter them in pairs:"); scanf("%d",&e); for(i = 0;i < e;++i) { int u,v; scanf("%d%d",&u,&v); graph[u-1][v-1] = 1; graph[v-1][u-1] = 1; } if(isConnected(graph,n)) printf("The graph is connected"); else printf("The graph is NOT connected\n"); } void enqueue(int vertex) { if(Qfront == NULL) { Qfront = malloc(sizeof(Node)); Qfront->v = vertex; Qfront->next = NULL; Qrear = Qfront; } else { Nodeptr newNode = malloc(sizeof(Node)); newNode->v = vertex; newNode->next = NULL; Qrear->next = newNode; Qrear = newNode; } } int deque() { if(Qfront == NULL) { printf("Q is empty , returning -1\n"); return -1; } else { int v = Qfront->v; Nodeptr temp= Qfront; if(Qfront == Qrear) { Qfront = Qfront->next; Qrear = NULL; } else Qfront = Qfront->next; free(temp); return v; } } int isConnected(char **graph,int noOfVertices) { int i; // نختار العقدة 0 لتكون عقدة مصدرية BFS(graph,0,noOfVertices); for(i = 0;i < noOfVertices;++i) if(visited[i] == 'N') return 0;//0 implies false; return 1;//1 implies true; } void BFS(char **graph,int v,int noOfVertices) { int i,vertex; visited[v] = 'Y'; enqueue(v); while((vertex = deque()) != -1) { for(i = 0;i < noOfVertices;++i) if(graph[vertex][i] == 1 && visited[i] == 'N') { enqueue(i); visited[i] = 'Y'; } } } للعثور على جميع المكونات المتصلة لمخطط غير موجه، يكفي أن نضيف سطرين إلى شيفرة الدالة BFS. الفكرة هي أن نستدعي الدالة BFS إلى أن تُزار جميع الحروف. هذا هو السطر الأول، المتغير count هو متغير عام مهيء على القيمة 0، وهذا السطر يوضع في بداية دالة BFS: printf("\nConnected component %d\n",++count); وهذا هو السطر الثاني، اجعله في بداية حلقة while في دالة BFS: printf("%d ",vertex+1); نعرّف الآن الدالة التالية: void listConnectedComponents(char **graph,int noOfVertices) { int i; for(i = 0;i < noOfVertices;++i) { if(visited[i] == 'N') BFS(graph,i,noOfVertices); } } خوارزمية البحث بالعمق أولا Depth First Search خوارزمية البحث بالعمق أولًا DFS هي خوارزمية لتسلق مخطط أو البحث فيها، وتبدأ من الجذر وتستكشف العقد على طول كل فرع وتتعمق فيه إلى آخر حدّ قبل أن ترتد backtrack، وقد استُخدِمت نسخة أولية من هذه الخوارزمية من قبل عالم الرياضيات الفرنسي تشارلز بيير Trémaux في القرن التاسع عشر لإيجاد حلول للمتاهات. البحث بالعمق أولًا هي طريقة تحاول العثور على جميع العقد التي يمكن الوصول إليها من العقدة المصدر، وتجتاز خوارزمية DFS عقد مُكوّنة متصلة connected component ما من مخطط خوارزمية البحث بالعرض أولًا، ثم تعرّف شجرة ممتدة spanning tree. كذلك تستكشف خوارزمية البحث بالعمق أولا كل الأضلاع بطريقة منهجية. إذ تبدأ من العقدة المصدرية، وبمجرد الوصول إلى عقدة من المخطط تبدأ خوارزمية DFS في الاستكشاف انطلاقًا منها (على عكس خوارزمية BFS، التي تضعها في طابور لأجل استكشافها لاحقًا). انظر المثال التالي: نجتاز المخطط باستخدام القواعد التالية: نبدأ من المصدر. لن نزور أيّ عقدة مرتين. نلوّن العقد التي لم نزرها بعد باللون الأبيض. نلوّن بالرمادي العقد التي زرناها ولكن لم نزر بعد جميع العقد المتفرّعة منها. نلوّن بالأسود العقد التي اجتزناها بالكامل هي والعقد المتفرعة منها. تبيّن الرسوم التالية هذه الخطوات: الكلمة المفتاحية التي تهمنا والتي نراها فيما سبق هي الضلع الخلفي backedge، فالضلع 5-1 هو ضلع خلفي backedge، لأننا لم ننته بعد من العقدة 1، فالعودة إلى العقدة المصدرية 1 يعني وجود دورة في المخطط. إذا كنا نستطيع الانتقال من عقدة رمادية إلى أخرى في خوارزمية البحث بالعمق أولا، فذلك دليل على أنّ المخطط يحتوي على دورة، وهذه إحدى طرق رصد الدورات في المخططات. يمكننا أن نجعل أيّ ضلع في الدورة كضلع خلفي اعتمادًا على العقدة المصدرية، وترتيب زيارة العقد. على سبيل المثال: لو انتقلنا إلى العقد 5 من العقدة 1 أولا، لوجدنا أنّ الضلع 2-1 أصبح ضلعًا خلفيًا. تسمى الأضلاع التي تنتقل من عقدة رمادية إلى عقدة بيضاء أضلاع الشجرة أو أضلاعًا شجرية tree edge، وإذا أبقينا على أضلاع الشجرة وحذفنا الأضلاع الأخرى، فسوف نحصل على شجرة البحث بالعمق أولا DFS. إذا استطعنا زيارة عقدة مزارة سابقًا في مخطط غير موجه فذلك يعني وجود ضلع خلفي، أمّا بالنسبة للمخططات الموجّهة فسيكون علينا أن نتحقق من الألوان، حيث يكون الضلع خلفيًا فقط إذا كان بمقدورنا الانتقال من عقدة رمادية إلى عقدة رمادية أخرى. أيضًا، في خوارزمية البحث بالعمق أولا، نستطيع تخزين علامات زمنية timestamps لكل عقدة، تخزن هذه العلامات الزمنية معلومات عما حدث للعقد أثناء المعالجة، ونخزّنها في مصفوفتين، مصفوفة f لتخزين أوقات الانتهاء، ومصفوفة d لتخزين أوقات استكشاف العقد: عندما يتغيّر لون العقدة v من الأبيض إلى الرمادي نسجّل الوقت في الموضع d[v]. عند يتغيّر لون عقدة v من الرمادي إلى الأسود، نسجّل الوقت في f [v]. سيبدو المثال التوضيحي لتخزين العلامات الزمنية كما يلي: Procedure DFS(G): for each node u in V[G] color[u] := white parent[u] := NULL end for time := 0 for each node u in V[G] if color[u] == white DFS-Visit(u) end if end for Procedure DFS-Visit(u): color[u] := gray time := time + 1 d[u] := time for each node v adjacent to u if color[v] == white parent[v] := u DFS-Visit(v) end if end for color[u] := black time := time + 1 f[u] := time التعقيد: تُزار كل عقدة وكل ضلع مرة واحدة، لذا فإن تعقيد خوارزمية DFS هو O (V + E)، حيث يمثّل V عدد العقد في المخطط، ويمثل E عدد الأضلاع. التطبيقات على خوارزمية البحث بالعمق أولًا كثيرة، منها على سبيل المثال لا الحصر: العثور على أقصر مسار بين كل زوج من العقد في مخطط غير موجه. رصد الدورات في المخططات. العثور على المسارات. الترتيب التخطيطي (الطوبولوجي). التحقق ممّا إذا كان المخطط ثنائي التجزئة. العثور على المكونات شديدة الاتصال Strongly Connected Component. حل الألغاز التي لها حل واحد. خوارزمية البائع المتجول إن إنشاء مسار يمر عبر كل رأس من رؤوس المخطط مرة واحدة يكافئ ترتيبًا يرتّب حروف ذلك المخطط، ويمكننا استغلال هذه الخاصية لحساب التكلفة الدنيا لعبور كل رأس مرة واحدة بالضبط عبر تجريب كل التبديلات لمجموعة الأعداد من 1 إلى N، وعددها N!. انظر الشيفرة العامة لهذا: minimum = INF for all permutations P // كل التبديلات current = 0 for i from 0 to N-2 // أضف تكلفة الانتقال من عقدة إلى إلى أخرى current = current + cost[P[i]][P[i+1]] // أضف تكلفة الانتقال من العقدة الأخيرة إلى إلى العقدة الأولى current = current + cost[P[N-1]][P[0]] if current < minimum // إن كان ذلك ضروريا minimum حدِّث minimum = current output minimum التعقيد الزمني: هناك N! تبديلة ينبغي معالجتها، وحساب تكلفة كل مسار تستغرق O(N)، من ثم فإنّ هذه الخوارزمية تستغرق مدة O (N * N!). استخدام البرمجة الديناميكية انظر المسار: (1,2,3,4,6,0,5,7) والمسار (1,2,3,5,0,6,7,4) تبقى تكلفة الانتقال من العقدة 1 إلى العقدة 2 ثمّ إلى العقدة 3 كما هي، لذا ليس علينا إعادة حسابها، إذ يمكن أن نحفظ هذه النتيجة لاستخدامها لاحقًا. لنفترض أنّ dp[bitmask][vertex] تمثل الحد الأدنى لتكلفة المسارات التي تعبر جميع الحروف التي قيمة البتّات المقابلة لها فيها bitmask تساوي 1، والتي تنتهي عند الحرف vertex. على سبيل المثال: dp[12][2] 12 = 1 1 0 0 ^ ^ vertices: 3 2 1 0 نظرًا لأنّ 1100 هي التمثيل الثنائي لـ 12، فإنّ dp[12][2] تمثل الانتقال عبر الحرفين 2 و 3 في المخطط عبر مسار ينتهي عند الحرف 2. هذا تطبيق على الخوارزمية بلغة C++: int cost[N][N]; // إن كان ذلك ضروريا N تعديل قيمة int memo[1 << N][N]; // -1 تهيئة كل العناصر بالقيمة int TSP(int bitmask, int pos){ int cost = INF; if (bitmask == ((1 << N) - 1)){ // استكشاف كل العقد return cost[pos][0]; // تكلفة الرجوع } if (memo[bitmask][pos] != -1){ // إن كنا قد حسبنا هذه القيمة سابقا return memo[bitmask][pos]; // فسنعيد القيمة السابقة، فلا داعي لإعادة حسابها } for (int i = 0; i < N; ++i){ if ((bitmask & (1 << i)) == 0){ //إن لم تُكن العقدة مُزارة بعد cost = min(cost,TSP(bitmask | (1 << i) , i) + cost[pos][i]); // زيارة العقدة } } memo[bitmask][pos] = cost; // حفظ النتيجة return cost; } //Call TSP(1,0) قد يكون هذا السطر مبهمًا، لذا سنفصِّله من أجل التوضيح: cost = min(cost,TSP(bitmask | (1 << i) , i) + cost[pos][i]); هنا، تعيّن العبارة bitmask | (1 << i) البتة رقم i في bitmask وتعطيها القيمة 1 دلالة على أنّ الحرف رقم i قد تمت زيارته. تمثّل i الموضوعة بعد الفاصلة قيمة الموضع pos الجديد في هذا الاستدعاء، والذي يمثل الحرف الأخير الجديد. وتضيف العبارةcost[pos][i] تكلفة الانتقال من الحرف الموجود في الموضع pos إلى الحرف i. يُحدّث هذا السطر قيمة cost، ويعطيها أدنى قيمة ممكنة للمرور عبر كل الحروف الأخرى التي لم تُزر بعد. التعقيد الزمني: هناك 2^N قيمة ممكنة لـ bitmask و N قيمة ممكنة لـ pos في الدالة TSP(bitmask,pos). ويستغرق تنفيذ كل دالة O(N) (الحلقة for). وبالتالي يستغرق هذا التطبيق إجمالا مدة O(N^2 * 2^N) لإيجاد الحل النهائي. خوارزمية كثيرة الحدود ومقيدة زمنيًا للعثور على أصغر غطاء رأسي تعريف: ليكن G مخطط، و C مجموعة من حروفها، فنقول أنّ C غطاء رأسي للمخطط G إن كانت تحتوي طرفًا واحدًا على الأقل من كل ضلع من أضلاع المخطط. وفي هذه الفقرة نستعرض خوارزمية كثيرة الحدود للحصول على أصغر غطاء رأسي vertex cover لمخطط متصل غير موجّه. التعقيد الزمني لهذه الخوارزمية يساوي O (n2). هذا مثال توضيحي لخوارزمية أصغر غطاء رأسي، إذ نُدخل إليها مخططًا متصلًا G لتعيد لنا مجموعة C تمثّل أصغر غطاء رأسي لهذا المخطط. Set C <- new Set<Vertex>() Set X <- new Set<Vertex>() X <- G.getAllVerticiesArrangedDescendinglyByDegree() for v in X do List<Vertex> adjacentVertices1 <- G.getAdjacent(v) if !C contains any of adjacentVertices1 then C.add(v) for vertex in C do List<vertex> adjacentVertices2 <- G.adjacentVertecies(vertex) if C contains any of adjacentVertices2 then C.remove(vertex) return C يمكنك استخدام ترتيب الدلو لترتيب الحروف بحسب درجاتها، وبما أن القيمة القصوى للدرجات تساوي (n-1)، حيث يمثّل n عدد الحروف، فستستغرق عمليات الترتيب O(n). ترجمة -بتصرّف- للفصول 41 و 42 و 44 و 53 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: خوارزميات البحث في النصوص تطبيقات الخوارزميات الشرهة خوارزمية ديكسترا Dijkstra’s Algorithm
-
نستعرض في هذه المقالة أمثلةً على مجموعة من الخوارزميات، مثل خوارزمية الأعداد الكاتالانية وخوارزمية بريزنهام لرسم المستقيمات، وخوارزميات إدارة ذاكرة التخزين المؤقت، إضافة إلى بعض الخوارزميات متعددة الخيوط. خوارزمية رسم المستقيمات تُرسم المستقيمات على شاشة الحاسوب بِمدِّ نقاط متتابعة ومتقاربة على طول المسار الذي يمرّ منه المستقيم بين النقطتين اللتين تحدّدان طرفي المستقيم، وسنستعرض في هذه الفقرة إحدى أفضل الخوارزميات التي يستخدمها المبرمجون لحساب إحداثيات هذه النقاط وهي خوارزمية بريزنهام لرسم المستقيمات، التي هي عبارة عن خوارزمية لرسم الخطوط بفعالية ودقّة، طورها جاك إيلتون بريزنهام Jack E. Bresenham، وهي دقيقة وسريعة لأنها تتم بإجراء عمليات على الأعداد الصحيحة فقطـ، وهي تسع أيضًا رسم الدوائر والمنحنيات الأخرى. وفي خوارزمية بريزنهام، لنقل أنّ ميل المستقيم الذي نريد رسمه هو m. إن كان |m|<1: إما تُزاد قيمة الإحداثية x. أو تُزاد قيمتا الإحداثيتان x و y معًا باستخدام معامل قرار decision parameter خاصّ. إن كان |m|<1: إما أن تُزاد قيمة y. أو تُزاد قيمتا x و y معا باستخدام معامل قرار خاصّ. والآن، نستعرض خطوات الخوارزمية اعتمادًا على ميل المستقيم المراد رسمه. الحالة الأولى: | m | <1 أدخِل طرفي المستقيم (x1، y1) و (x2، y2). ارسم النقطة الأولى (x1، y1). احسب القيمتين: Delx = | x2 – x1 | . Dely = | y2 – y1 | . احسب معامل القرار الأولي عبر الصيغة التالية: P = 2 * dely – delx لكل I من 0 إلى delx: إذا كان p <0، فسيكون: X1 = x1 + 1. ارسم (x1، y1). P = p + 2dely. وإلا: X1 = x1 + 1. Y1 = y1 + 1. ارسم (x1، y1). P = p + 2dely – 2 * delx. نهاية عبارة إن. نهاية عبارة لكل. النهاية. فيما يلي شيفرة الخوارزمية، وهي برنامج مكتوب بلغة C لتطبيق خوارزمية بريزنهام في حالة | m | <1: #include<stdio.h> #include<conio.h> #include<graphics.h> #include<math.h> int main() { int gdriver=DETECT,gmode; int x1,y1,x2,y2,delx,dely,p,i; initgraph(&gdriver,&gmode,"c:\\TC\\BGI"); printf("Enter the intial points: "); scanf("%d",&x1); scanf("%d",&y1); printf("Enter the end points: "); scanf("%d",&x2); scanf("%d",&y2); putpixel(x1,y1,RED); delx=fabs(x2-x1); dely=fabs(y2-y1); p=(2*dely)-delx; for(i=0;i<delx;i++){ if(p<0) { x1=x1+1; putpixel(x1,y1,RED); p=p+(2*dely); } else { x1=x1+1; y1=y1+1; putpixel(x1,y1,RED); p=p+(2*dely)-(2*delx); } } getch(); closegraph(); return 0; } الحالة الثانية: | m | > 1: أدخل طرفي المستقيم (x1، y1) و (x2، y2). ارسم النقطة الأولى (x1، y1). احسب القيمتين: Delx = | x2 – x1 | . Dely = | y2 – y1 | . احسب معامل القرار الأول عبر الصيغة التالية: P = 2 * dely – delx. لكل I من 0 إلى delx: إذا كان p <0 فسيكون: Y1 = y1 + 1. ارسم (x1، y1). P = p + delx. وإلا: X1 = x1 + 1. Y1 = y1 + 1. ارسم (x1، y1) P = p + 2delx – 2 * dely. نهاية عبارة إن. نهاية عبارة لكل. النهاية. انظر إلى الشيفرة أدناه، وهي برنامج مكتوب بلغة C لتطبيق خوارزمية بريزنهام في حالة | m | > 1: #include<stdio.h> #include<conio.h> #include<graphics.h> #include<math.h> int main() { int gdriver=DETECT,gmode; int x1,y1,x2,y2,delx,dely,p,i; initgraph(&gdriver,&gmode,"c:\\TC\\BGI"); printf("Enter the intial points: "); scanf("%d",&x1); scanf("%d",&y1); printf("Enter the end points: "); scanf("%d",&x2); scanf("%d",&y2); putpixel(x1,y1,RED); delx=fabs(x2-x1); dely=fabs(y2-y1); p=(2*delx)-dely; for(i=0;i<delx;i++){ if(p<0) { y1=y1+1; putpixel(x1,y1,RED); p=p+(2*delx); } else { x1=x1+1; y1=y1+1; putpixel(x1,y1,RED); p=p+(2*delx)-(2*dely); } } getch(); closegraph(); return 0; } خوارزمية الأعداد الكاتالانية خوارزمية الأعداد الكاتالانية Catalan Number Algorithm هي إحدى خوارزميات البرمجة الديناميكية، وتشكل الأعداد الكاتلانية في الرياضيات التوافقية combinatorial mathematics سلسلةً من الأعداد الطبيعية التي تظهر في العديد من المسائل العددية المختلفة، وخاصةً المقادير التي تُعرّف بطريقة تكرارية، كما تظهر في مشاكل عدّ الأشجار. ويمكن استخدام خوارزمية الأعداد الكاتالانية لحساب مقادير مثل: عدد الطرق التي يمكن تكديس القطع النقدية بها في صف سفلي يتكون من n قطعة نقدية متتالية في المستوى، بحيث لا يُسمح بوضع أي قطعة نقدية على جانبي القطع النقدية السفلية، كما يجب أن تكون كل قطعة نقدية فوق قطعنين نقديتين أُخريين، الجواب هو العدد الكتالاني رقم n. عدد التعابير التي تحتوي على n زوج من الأقواس الهلالية، والتي تطابق بعضها البعض تطابقًا صحيحًا هو العدد الكتالاني رقم n. عدد الطرق الممكنة لقطع مضلّع محدّب ذو n+2 وجهًا n+2-sided convex polygon في مستوى ما إلى مثلثات عبر ربط الحروف ذات الخطوط المستقيمة غير المتقاطعة هو العدد الكاتالاني رقم n. يُحصَل على العدد الكاتالاني رقم n مباشرةً عبر المعادلة التالية التي تستخدم المعاملات الثنائية. إليك مثال على عدد كاتالاني: n = 4 المساحة الإضافية = O(n). تعقيد الوقت = O(n^2). الخوارزميات متعددة الخيوط Multithreaded Algorithms سنستعرض في هذه الفقرة أمثلة على بعض الخوارزميات متعددة الخيوط. مثال عن ضرب المصفوفة المربعة Square matrix multiplication: multiply-square-matrix-parallel(A, B) n = A.lines C = Matrix(n,n) //n*n إنشاء مصفوفة من الحجم parallel for i = 1 to n parallel for j = 1 to n C[i][j] = 0 pour k = 1 to n C[i][j] = C[i][j] + A[i][k]*B[k][j] return C مثال عن خوارزمية ضرب مصفوفة ومتجه متعددة الخيوط: matrix-vector(A,x) n = A.lines y = Vector(n) //create a new vector of length n parallel for i = 1 to n y[i] = 0 parallel for i = 1 to n for j = 1 to n y[i] = y[i] + A[i][j]*x[j] return y خوارزمية دمج وترتيب متعددة الخيوط لتكن A وB مصفوفتان، وليكن p و r فهرسان للمصفوفة A، سنحاول ترتيب المصفوفة الجزئية A [p..r]. وسنملأ المصفوفة B عند ترتيب المصفوفة الجزئية. في الشيفرة التالية، ترتب الدالة p-merge-sort (A، p، r، B، s) عناصر A [p..r] وتضعها في B [s..s + rp]. p-merge-sort(A,p,r,B,s) n = r-p+1 if n==1 B[s] = A[p] else T = new Array(n) //create a new array T of size n q = floor((p+r)/2)) q_prime = q-p+1 spawn p-merge-sort(A,p,q,T,1) p-merge-sort(A,q+1,r,T,q_prime+1) sync p-merge(T,1,q_prime,q_prime+1,n,B,s) في الشيفرة أدناه، تجري الدالة المساعدة p-merge عملية الدمج بالتوازي، وتفترض هذه الدالة أنّ المصفوفتين الجزئيتين المراد دمجهما موجودتان في المصفوفة نفسها، ولكنّها لا تفترض أنهما متجاورتان في المصفوفة، لذا علينا تزويدها بالفهارس p1 وr1 p2 وr2. p-merge(T,p1,r1,p2,r2,A,p3) n1 = r1-p1+1 n2 = r2-p2+1 if n1<n2 // n1>=n2 التحقق من أنّ permute p1 and p2 permute r1 and r2 permute n1 and n2 if n1==0 // كلاهما فارغ؟ return else q1 = floor((p1+r1)/2) q2 = dichotomic-search(T[q1],T,p2,r2) q3 = p3 + (q1-p1) + (q2-p2) A[q3] = T[q1] spawn p-merge(T,p1,q1-1,p2,q2-1,A,p3) p-merge(T,q1+1,r1,q2,r2,A,q3+1) sync الشيفرة أدناه تمثل دالة البحث الانقسامي المساعِدة auxillary function dichotomic-search، ويكون x فيها هو المفتاح المراد البحث عنه في المصفوفة الفرعية T [p..r]. dichotomic-search(x,T,p,r) inf = p sup = max(p,r+1) while inf<sup half = floor((inf+sup)/2) if x<=T[half] sup = half else inf = half+1 return sup خوارزمية KMP خوارزمية KMP هي خوارزمية لمطابقة الأنماط، إذ تبحث عن مواضع ظهور كلمةٍ ما W في سلسلة نصية S. ومبدأ هذه الخوارزمية أنّه في كلّ مرة نرصد فيها عدم تطابق، فإنّنا سنكون على علم ببعض الحروف الموجودة في النص والتي ستكون مطابقة في الخطوة القادمة. سنستغل هذه المعلومة لتجنب مطابَقة الحروف التي نعلم بأنّها ستكون متطابقة بأي حال. ولا يتجاوز تعقيد هذه الخوارزمية O (n) في أسوأ الحالات. سنأخذ مثالًا على هذا النوع من الخوارزميات؛ تتألف هذه الخوارزمية من خطوتين: الخطوة الأولى: التجهيز نعالج النمط معالجةً مسبقة، وننشئ مصفوفة مساعدة lps [] تُستخدم لتخطي المحارف أثناء المطابقة. تشير lps[] هنا إلى أطول سابقة ملائمة proper prefix تكون لاحقة suffix أيضًا. نقصد بالسابقة المناسبة أيّ سابقة تبدأ بها السلسلة النصية المبحوث عنها، شرط ألا تحتوي كامل السلسلة النصية. مثلاً، سوابق "ABC" الملائمة هي "" و "A" و "AB"، أما اللواحق الخاصة بهذه السلسلة النصية فهي: "" و "C" و "BC" و "ABC". الخطوة الثانية: البحث نستمر في مطابقة المحارف txt و pat [j]، ونستمر في زيادة i و j طالما تطابق pat [j] و txt. عندما نرصد عدم تطابق فإننا نعلم أنّ الأحرف pat[0..j-1] تتطابق مع txt [i-j +1… i-1]. ونعلم أيضًا أنّ lps[j-1] يساوي تعداد محارف pat[0 … j-1] التي هي سوابق ملائمة ولواحق على حدّ سواء، لهذا لا نحتاج إلى مطابقة أحرفlps [j-1] مع txt [ij… i-1]، لأنّنا نعلم سلفًا أنّها مُتطابقة. فيما يلي تطبيق على ذلك بلغة جافا: public class KMP { public static void main(String[] args) { // TODO Auto-generated method stub String str = "abcabdabc"; String pattern = "abc"; KMP obj = new KMP(); System.out.println(obj.patternExistKMP(str.toCharArray(), pattern.toCharArray())); } public int[] computeLPS(char[] str){ int lps[] = new int[str.length]; lps[0] = 0; int j = 0; for(int i =1;i<str.length;i++){ if(str[j] == str[i]){ lps[i] = j+1; j++; i++; }else{ if(j!=0){ j = lps[j-1]; }else{ lps[i] = j+1; i++; } } } return lps; } public boolean patternExistKMP(char[] text,char[] pat){ int[] lps = computeLPS(pat); int i=0,j=0; while(i<text.length && j<pat.length){ if(text[i] == pat[j]){ i++; j++; }else{ if(j!=0){ j = lps[j-1]; }else{ i++; } } } if(j==pat.length) return true; return false; } } خوارزمية مسافة التعديل الديناميكية Edit Distance Dynamic Algorithm بيان المشكلة: إذا أُعطيت سلسلتين نصيتين str1 و str2، فما هو العدد الأدنى للعمليات التي يمكن إجراؤها على str1 لتحويلها إلى str2. علمًا بأنّ العمليات الممكنة هي: الإدراج Insert. الحذف Remove. الاستبدال Replace. إليك مثال: إن كانت str1 = "geek" و str2 = "gesek" فالجواب هو 1، لأنّنا نحتاج فقط إلى إدراج الحرف s في السلسلة النصية str1. إن كانت str1 = "march" و str2 = "cart" فالجواب هو 3، لأنّنا نحتاج إلى 3 عمليات هي وضع c مكان m، ثمّ حذف c، ثمّ وضع t مكان h. ولحل هذه المشكلة، سنستخدم المصفوفة dp[n+1] [m+1] ثنائية الأبعاد، حيث يمثّل n طول السلسلة النصية الأولى، و m طول السلسلة النصية الثانية. على سبيل المثال، إذا كانت str1 تساوي "azcef" و str2 تساوي "abcdef"، فستكون المصفوفة من البعدين dp [6] [7]، وستُخزّن الإجابة النهائية في الموضع dp [5] [6]. (a) (b) (c) (d) (e) (f) +---+---+---+---+---+---+---+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | +---+---+---+---+---+---+---+ (a) | 1 | | | | | | | +---+---+---+---+---+---+---+ (z) | 2 | | | | | | | +---+---+---+---+---+---+---+ (c) | 3 | | | | | | | +---+---+---+---+---+---+---+ (e) | 4 | | | | | | | +---+---+---+---+---+---+---+ (f) | 5 | | | | | | | +---+---+---+---+---+---+---+ بالنسبة للفهرس dp [1] [1]، ليس علينا فعل شيء لتحويل a إلى a لأنّهما متساويان، لذا نضع 0 هناك. وبالنسبة للفهرس dp [1] [2]، ما الذي علينا أن نفعله لتحويل a إلى ab؟ سنحتاج إلى عملية واحدة، وهي إدراج الحرف b. ستبدو المصفوفة كما يلي بعد التكرار الأول: (a) (b) (c) (d) (e) (f) +---+---+---+---+---+---+---+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | +---+---+---+---+---+---+---+ (a) | 1 | 0 | 1 | 2 | 3 | 4 | 5 | +---+---+---+---+---+---+---+ (z) | 2 | | | | | | | +---+---+---+---+---+---+---+ (c) | 3 | | | | | | | +---+---+---+---+---+---+---+ (e) | 4 | | | | | | | +---+---+---+---+---+---+---+ (f) | 5 | | | | | | | +---+---+---+---+---+---+---+ بالنسبة للفهرس dp [2] [1]، لأجل تحويل az إلى a، نحتاج إلى إزالة z، ومن ثم نضع 1 في dp [2] [1]. أما بالنسبة لـ dp [2] [2]، نحتاج إلى استبدال z بـ b، ومن ثم نضع 1 في dp [2] [2]. هكذا ستبدو المصفوفة الآن: (a) (b) (c) (d) (e) (f) +---+---+---+---+---+---+---+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | +---+---+---+---+---+---+---+ (a) | 1 | 0 | 1 | 2 | 3 | 4 | 5 | +---+---+---+---+---+---+---+ (z) | 2 | 1 | 1 | 2 | 3 | 4 | 5 | +---+---+---+---+---+---+---+ (c) | 3 | | | | | | | +---+---+---+---+---+---+---+ (e) | 4 | | | | | | | +---+---+---+---+---+---+---+ (f) | 5 | | | | | | | +---+---+---+---+---+---+---+ نستمر في إعادة العملية إلى أن نحوّل السلسلة النصية الأولى إلى الثانية ("azcef" ← "abcdef")، وستبدو المصفوفة النهائية هكذا: (a) (b) (c) (d) (e) (f) +---+---+---+---+---+---+---+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | +---+---+---+---+---+---+---+ (a) | 1 | 0 | 1 | 2 | 3 | 4 | 5 | +---+---+---+---+---+---+---+ (z) | 2 | 1 | 1 | 2 | 3 | 4 | 5 | +---+---+---+---+---+---+---+ (c) | 3 | 2 | 2 | 1 | 2 | 3 | 4 | +---+---+---+---+---+---+---+ (e) | 4 | 3 | 3 | 2 | 2 | 2 | 3 | +---+---+---+---+---+---+---+ (f) | 5 | 4 | 4 | 2 | 3 | 3 | 3 | +---+---+---+---+---+---+---+ هذه هي الصيغة العامة لتحديث المصفوفة: if characters are same dp[i][j] = dp[i-1][j-1]; // الحرفان متطابقان else dp[i][j] = 1 + Min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) هذا تطبيق مكتوب بلغة جافا: public int getMinConversions(String str1, String str2){ int dp[][] = new int[str1.length()+1][str2.length()+1]; for(int i=0;i<=str1.length();i++){ for(int j=0;j<=str2.length();j++){ if(i==0) dp[i][j] = j; else if(j==0) dp[i][j] = i; else if(str1.charAt(i-1) == str2.charAt(j-1)) dp[i][j] = dp[i-1][j-1]; else{ dp[i][j] = 1 + Math.min(dp[i-1][j], Math.min(dp[i][j-1], dp[i-1][j-1])); } } } return dp[str1.length()][str2.length()]; } التعقيد الزمني لهذه الخوارزمية هو O(n^2). الخوارزميات المتصلة Online algorithms تنشأ مشكلة التخزين المؤقت caching من محدودية مساحة التخزين، وسنفترض أنّ ذاكرة التخزين المؤقت C تحتوي على k صفحة، ونحن نريد معالجة سلسلة من طلبات الصفحات عددها m طلب، ويجب تخزينها في ذاكرة التخزين المؤقت قبل معالجتها. إذا كانت m<=k فلن تكون هناك مشكلة إذ سنضع جميع العناصر في ذاكرة التخزين المؤقت، ولكن في العادة تكون m>>k. تكون طلبيّة ما مقبولة في ذاكرة التخزين المؤقت (نقول أنها cache hit) إذا كان العنصر موجودًا في ذاكرة التخزين المؤقت بالفعل، وإلا نقول أنّها مُفوَّتة من ذاكرة التخزين المؤقت cache miss، وفي مثل هذه الحالة سيكون علينا إحضار العنصر المطلوب إلى ذاكرة التخزين المؤقت وإخراج عنصر آخر وذلك على افتراض أنّ ذاكرة التخزين المؤقت ممتلئة. إن الهدف هنا هو تصميم جدول إخلاء eviction schedule يقلّل عدد عمليات الإخلاء الضرورية قدر الإمكان. هناك العديد من الاستراتيجيات الممكنة لحلّ هذه المشكلة، منها: من دخل أولًا، يخرج أولًا First in, first out أو FIFO: تُخلى أقدم صفحة. من دخل آخرًا، يخرج أولًا Last in, first out أو LIFO: تُخلى أحدث صفحة. الأقل استخدامًا مؤخرًا LRU: تخلى الصفحة التي كان آخر وصول لها هو الأبكر. الأقل طلبًا LFU: تخلى الصفحة التي طُلِبت أقل عدد من المرّات. أطول مسافة أمامية LFD: تخلى الصفحة التي لن تُطلَب إلا بعد أطول مدة. التفريغ عند الامتلاء FWF: مسح ذاكرة التخزين المؤقت كاملة بمجرد حدوث فوات في ذاكرة التخزين المؤقت. هناك طريقتان للتعامل مع هذه المشكلة: الطريقة غير المتصلة offline: يكون فيها تسلسل الطلبات معروف منذ البدء. الطريقة المتصلة online: يكون تسلسل الطلبات غير معروف مسبقًا. وقبل أن نواصل، سنقدم بعض التعاريف الرياضية المساعدة. التعريف 1: نقول أنّ المشكلة Π هي مشكلة تحسين optimization problem إن كانت تتألف من مجموعة من الحالات ΣΠ، بحيث يكون لكل حالة σ∈ΣΠ مجموعة من الحلول ودالة تقييمf σ: Ζσ → ℜ≥ 0 تعطي قيمة حقيقية موجبة لكلّ حل من تلك الحلول، ونرمز لقيمة الحل الأمثل (optimal solution) من بين كل حلول الحالة σ بالرمز OPT (σ). ولكل خوارزمية A، يشير الرمز A (σ) إلى حل الخوارزمية A للمشكلة Π في الحالة σ، بينما يساوي التعبير wA (σ) = fσ (A (σ)) قيمة الخوارزمية. التعريف 2: نقول أنّ خوارزمية متصلة A لمشكلة تصغير Π minimization problem لديها معدل تنافسي competetive ratio قيمته r ≥ 1 إذا كان هناك ثابت τ∈ℜ يحقّق: wA(σ) = fσ(A(σ)) ≤ r ⋅ OPT(σ) + τ لكل الحالات σ∈ΣΠ، نقول أنّ الخَوارزمية A خوارزمية متصلة ذات r تنافسية r-competitive online algorithm إن كان لدينا: wA(σ) ≤ r ⋅ OPT(σ) لكل عنصر σ∈ΣΠ، نقول أنّ A خوارزمية متصلة ذات r تنافسية قطعًا strictly r-competitive online algorithm. تعريف 3: نقول أنّ خوارزمية A خوارزمية واسمة Marking Algorithm فقط إذا لم تكن تُخلي أيّ صفحة موسومة من ذاكرة التخزين المؤقت، مما يعني أنّ الصفحات التي تُستخدم أثناء أيّ مرحلة لن تُخلى. خاصية 1.3: الخوارِزميتان LRU -الأقل استخدامًا مؤخرًا Least recently used، أي إخلاء الصفحة التي لم يُدخل إليها منذ أطول مدة-، و FWF -التفريغ عند الملء Flush when full، وتعني مسح ذاكرة التخزين المؤقت بمجرد وقوع فوات في ذاكرة التخزين المؤقت cache miss- هما خوارِزميتان واسمَتان. دعنا نفترض أن LRU ليست خوارزميةًواسمة، إذًا ستكون هناك حالة σ، بحيث تخلي الخوازرمية LRU صفحة x موسومة marked page في المرحلة i. وإن كان σt هو الطلب في المرحلة i حيث أُخلِيت الصفحة x، وبما أن صفحة x موسومة، فلا بدّ أن يكون هناك طلب سابق σt* للصفحة x في المرحلة نفسها، لذا تكون t * <t. بعد t* ، ستكون x هي الصفحة الأحدث في ذاكرة التخزين المؤقت، لذا ينبغي أن يطلب التسلسل σt*+1,...,σt على الأقل k صفحة مختلفة عن x، وذلك لأجل إخلائها عند الوقت t. هذا يعني أنّ المرحلة i قد طلبت على الأقل k +1 صفحة مختلفة، وهذا يتعارض مع تعريف المرحلة. وعليه نستنتج أنّ LRU خوارزمية واسمة. خاصية 1.4: كل الخوارزميات الواسمة هي خوارزميات ذات k تنافسية قطعًا. إذا افترضنا أنّ l ≥ 2، فستكون تكلفة جميع الخوارزميات الواسمة للحالة σ أصغر من l ⋅ k، لأنه لا يمكن للخوارزمية الواسمة أن تخلي في كل مرحلة أكثر من k صفحة دون إخلاء صفحة واحدة واسمة على الأقل. سنحاول الآن أن نبيّن أنّ الخوارزمية غير المتصلة المثلى optimal offline algorithm تخلي على الأقل k + l-2 صفحةً في الحالة σ، حيث تُخلي k في المرحلة الأولى، إضافةً إلى صفحة واحدة على الأقل في كل مرحلة تالية خلال المرحلة الأخيرة: نعرّف الآن l - 2 تسلسلًا فرعيًا منفصلا للحالة σ، يبدأ التسلسلi ∈ {1,…,l-2} في الموضع الثاني من المرحلة i + 1 وينتهي عند الموضع الأول للمرحلة i + 2. لتكن x الصفحة الأولى من المرحلة i +1، في بداية التسلسل i توجد صفحة x، إضافةً إلى k-1 صفحة مختلفة على الأكثر في ذاكرة التخزين المؤقت الخاصة بالخوارزميات غير المتصلة المثلى. هناك k صفحة مطلوبة في التسلسل i مختلفة عن x، لذا يتوجّب على الخوارزمية غير المتصلة المثلى إخلاء صفحة واحدة على الأقل في كل تسلسل. وبما أن ذاكرة التخزين المؤقت في المرحلة الأولى فارغة، فإنّ الخوارزمية غير المتصلة المثلى تجري k عملية إخلاء خلال المرحلة الأولى. وهذا يبيّن أنّ: wA(σ) ≤ l⋅k ≤ (k+l-2)k ≤ OPT(σ) ⋅ k خاصية 1.5: الخوارزمِيتان LRU و FWF خوارِزميتان ذواتا k تنافسية قطعا. لتكن A خوارزمية متصلة، إذا لم تكن هناك أيّ ثابتة r بحيث تكون هذه الخوارزمية ذات r تنافسية، نقول أنّ A غير تنافسية not competitive. خاصية 1.6: الخوارزميتان LFU و LIFO غير تنافُسيتان. الصفحة الأولى مطلوبة عدد l مرّة، وكذلك الصفحة 2، وهكذا دواليك، وفي النهاية سيكون لدينا (l-1) طلبًا للصفحة k و k +1. تملأ الخوارِزميتان LFU و LIFO صفحات المخزن المؤقت بـالصفحات من 1 إلى k، وعند طلب الصفحة k +1 تُخلى الصفحة k، والعكس صحيح. هذا يعني أنّ كل طلب من التسلسل (k,k+1)l-1 يخلي صفحة واحدة. أيضًا، هناك مرات فوات قدرها k-1 فواتًا في ذاكرة التخزين المؤقت cache misses في أول استخدام للصفحات من 1 إلى (k-1)، وهذا يعني أنّ الخوارزميتَين LFU و LIFO تخليان k-1 +2 (l-1) صفحة بالضبط. سنحاول الآن أن نثبت أنّه لكل ثابتة τ∈ℜ، ولكل ثابتة r ≤ 1، يوجد l يحقق: والذي يساوي: لتحقيق هذه المتراجحة، يكفي أن تختار عدد I كبيرًا بما يكفي. نستنتج من كل هذا أنّ الخوارزميتَين LFU و LIFO غير تنافسيتَين. خاصية 1.7: لكل r <k، لا توجد أيّ خوارزمية حتمية متصلة ذات r تنافسية لحل مشكلة التصفيح r-competetive deterministic online algorithm for paging. المنظور غير المتصل Offline Approach قبل المواصلة، راجع مقالة تطبيقات خوارزميات الشَّرِهة، وراجع فقرة التخزين المؤقت غير المتصل والاستراتيجيات الخمس المستخدمة فيها. لقد وسّعنا برنامج المثال بإضافة استراتيجية FWF: class FWF : public Strategy { public: FWF() : Strategy("FWF") { } int apply(int requestIndex) override { for(int i=0; i<cacheSize; ++i) { if(cache[i] == request[requestIndex]) return i; // بعد أول صفحة فارغة، يجب أن تكون كل الصفحات الأخرى فارغة else if(cache[i] == emptyPage) return i; } // لا صفحات حرة return 0; } void update(int cachePos, int requestIndex, bool cacheMiss) override { // لا صفحات حرة -> فوات -> مسح المخزن المؤقت if(cacheMiss && cachePos == 0) { for(int i = 1; i < cacheSize; ++i) cache[i] = emptyPage; } } }; يمكنك تحميل الشيفرة المصدرية كاملة من pastebin. إذا أعدنا استخدام المثال، فسنحصل على النتيجة التالية: Strategy: FWF Cache initial: (a,b,c) Request cache 0 cache 1 cache 2 cache miss a a b c a a b c d d X X x e d e X b d e b b d e b a a X X x c a c X f a c f d d X X x e d e X a d e a f f X X x b f b X e f b e c c X X x Total cache misses: 5 على الرغم من أنّ خوارزمية LFD مثلى، إلا أنّ خوارزمية FWF تتميّز بأنّ عدد الفوات فيها أقل، غير أن الهدف الرئيسي هو تقليل عدد عمليات الإخلاء إلى الحد الأدنى، وفي خوارزمية FWF تحدث 5 حالات فوات، مما يعني أنّه ستكون هناك 15 عملية إخلاء، ما يجعلها الخيار الأسوء. المنظور المتصل Online Approach نريد الآن أن نبحث عن حل لمشكلة التصفيح المتصل online problem of paging، نحن نعلم أنّه لا يمكن لأيّ خوارزمية متصلة أن تكون أفضل من الخوارزمية غير المتصلة المثلى، لأنّه في الخوارزمية غير المتصلة تكون سلسلة الطلبات معروفة منذ البداية، وذلك على خلاف الخوارزميات المتصلة التي لا تعرف مسبقًا إلا الطلبات السابقة وليس اللاحقة، لهذا فإنّ الخوارزميات غير المتصلة لديها دائما معلومات أكثر موازنةً بالخوارزميات المتصلة، ما يجعلها أكثر كفاءة. سنحتاج إلى بعض التعريفات الدقيقة قبل أن نواصل: التعريف 1: نقول أنّ المشكلة Π هي مشكلة تحسين optimization problem إن كانت تتألف من مجموعة من الحالات ΣΠ. بحيث يكون لكل حالة σ∈ΣΠ مجموعة من الحلول، ودالة تقييمf σ: Ζσ → ℜ≥ 0 تعطي قيمة حقيقية موجبة لكلّ حل من تلك الحلول. نرمز للحل الأمثل optimal solution من بين كل حلول الحالة σ بالرمز OPT (σ). لكل خوارزمية A، يشير الرمز A (σ) إلى حل الخوارزمية A للمشكلة Π في الحالة σ، فيما يساوي التعبير wA (σ) = fσ (A (σ)) قيمة الخوارزمية. التعريف 2: نقول أنّ خوارزمية متصلة A لمشكلة تصغير Πminimization problem لديها معدل تنافسي competetive ratio قيمته r ≥ 1 إذا كان هناك عدد τ∈ℜ ثابت يحقّق: wA(σ) = fσ(A(σ)) ≤ r ⋅ OPT(σ) + τ لكل الحالات σ∈ΣΠ، نقول أنّ الخَوارزمية A خوارزمية r-تكرارية متصلة r-competitive online algorithm إن كان لدينا: wA(σ) ≤ r ⋅ OPT(σ) لكل عنصر σ∈ΣΠ، نقول أنّ A خوارزمية متصلة تنافسية قطعا strictly r-competitive online algorithm. لذا فإن السؤال هو مدى تنافسية خوارزميتنا المتصلة موازنةً بالخوارزمية غير المتصلة المثلى. وصف كل من آلان بورودين Allan Borodin وران الينيف Ran El-Yaniv في كتابهما الشهير خوارزميةَ التصفيح المتصل بالتعبير الطريف التالي: إذا أردت أن تكون مكان الخصم، فيمكنك تجربة لعبة الخصم (حاول التغلب على استراتيجيات التصفيح). الخوارزميات الواسمة Marking Algorithms بدلاً من تحليل كل الخوارزميات على حدة، سنحلّل عائلةً خاصةً من الخوارزميات المتصلة الخاصة بمشكلة التصفيح تسمّى خوارزميات الوسم أو الخوارزميات الواسمة marking algorithms. تعريف: نقول أنّ خوارزمية A خوارزمية واسمةً، فقط إذا لم تكن تُخلي أيّ صفحة موسومة من ذاكرة التخزين المؤقت، مما يعني أنّ الصفحات التي تُستخدم أثناء أيّ مرحلة لن تُخلى. لتكن σ=(σ1,...,σp) حالة من المشكلة، و k حجم ذاكرة التخزين المؤقت، فعندئذ يمكن تقسيم σ إلى مراحل: المرحلة 1 هي أقصى تتابع لـ σ من البداية حتى أقصى k صفحة مختلفة مطلوبة. المرحلة i ≥ 2 هي أقصى تتابع لـ σ من نهاية المرحلة i-1 حتى أقصى k صفحة مختلفة مطلوبة على سبيل المثال: في الحالة k = 3، نحصل على: تتذكّر الخوارزميات الواسمة (ضمنيًا أو صراحة) ما إذا كانت الصفحة موسومةً أم لا، وتكون كل الصفحات غير موسومة في بداية كل مرحلة، وتوسم الصفحة بمجرد أن تُطلب أثناء مرحلة ما. خاصية 1.3: الخوارِزميتان LRU (وتعني الأقل استخدامًا مؤخرًا - Least recently used، أي إخلاء الصفحة التي لم يُدخل إليها منذ أطول مدة)، و FWF (أي التفريغ عند الملء - Flush when full، وتعني مسح ذاكرة التخزين المؤقت بمجرد وقوع فوات في ذاكرة التخزين المؤقت cache miss) هما خوارِزميتان واسمَتان marking algorithm. لنفترض أنّ LRU ليست خوارزميةً واسمة، إذًا ستكون هناك حالة σ بحيث تخلي الخوازرمية LRU صفحة x موسومة marked page في المرحلة i. ليكن *σt هو الطلب في المرحلة i، حيث أُخلِيت الصفحة x، عندئذ وبما أن الصفحة x موسومة، فلا بدّ أن يكون هناك طلب سابق *σt للصفحة x في المرحلة نفسها، لذلك فإنّ t * <t. بعد * t، ستكون x هي الصفحة الأحدث في ذاكرة التخزين المؤقت، لذلك ينبغي أن يطلب التسلسل σt*+1,...,σt على الأقل k صفحة مختلفة عن x لأجل إخلائها عند الوقت t، وهذا يعني أنّ المرحلة i قد طلبت على الأقل k +1 صفحة مختلفة، مما يتعارض مع تعريف المرحلة، وعليه نستنتج أنّ LRU خوارزمية واسمة. خاصية 1.4: كل الخوارزميات الواسمة هي خوارزميات ذات k تنافسية قطعًا. إذا افترضنا أنّ l ≥ 2، فستكون تكلفة جميع الخوارزميات الواسمة للحالة σ أصغر من l ⋅ k، لأنه لا يمكن للخوارزمية الواسمة أن تخلي في كل مرحلة أكثر من k صفحة دون إخلاء صفحة واحدة واسمة على الأقل. سنحاول الآن أن نبيّن أنّ الخوارزمية غير المتصلة المثلى optimal offline algorithm تخلي على الأقل k + l-2 صفحة في الحالة σ، حيث تُخلي k في المرحلة الأولى، إضافةً إلى صفحة واحدة على الأقل في كل مرحلة تالية عدا المرحلة الأخيرة. نعرّف الآن l - 2 تسلسلًا فرعيًا منفصلا للحالة σ، يبدأ التسلسل i ∈ {1,…,l-2} في الموضع الثاني من المرحلة i + 1 وينتهي عند الموضع الأول للمرحلة i + 2. لتكن x الصفحة الأولى من المرحلة i +1. في بداية التسلسل i توجد صفحة x، إضافةً إلى k-1 صفحة مختلفة على الأكثر في ذاكرة التخزين المؤقت الخاصة بالخوارزميات غير المتصلة المثلى. في التسلسل i، هناك عدد k صفحة مطلوبة مختلفة عن x، لذا يتوجّب على الخوارزمية غير المتصلة المثلى إخلاء صفحة واحدة على الأقل في كل تسلسل. وبما أن ذاكرة التخزين المؤقت في المرحلة الأولى فارغة، فإنّ الخوارزمية غير المتصلة المثلى تجري k عملية إخلاء خلال المرحلة الأولى، وهذا يبيّن أنّ: wA(σ) ≤ l⋅k ≤ (k+l-2)k ≤ OPT(σ) ⋅ k خاصية 1.5: الخوارزميتان LRU و FWF خوارزميتان ذواتا k تنافسية قطعًا. تمرين: أثبت أنّ خوارزمية FIFO ليست خوارزميةً واسمة، وإنّما خوارزمية ذات k تنافسية قطعًا. لتكن A خوارزمية متصلة، إذا لم تكن هناك أيّ ثابتة r بحيث تكون هذه الخوارزمية ذات r تنافسية، نقول أنّ A غير تنافسية not competitive. خاصية 1.6: الخوارزميتان LFU و LIFO غير تنافُسيتان. الصفحة الأولى مطلوبة عدد l مرّة، وكذلك الصفحة 2، وهكذا دواليك. في النهاية، سيكون هناك (l-1) طلبًا للصفحة k و k +1. تملأ الخوارِزميتان LFU وLIFO صفحات المخزن المؤقت بـالصفحات من 1 إلى k، وتُخلى الصفحة k عند طلب الصفحة k +1، والعكس صحيح. هذا يعني أنّ كل طلب من التسلسل (k,k+1)l-1 يخلي صفحة واحدة. أيضًا، هناك عدد k-1 فواتًا في ذاكرة التخزين المؤقت cache misses في أول استخدام للصفحات من 1 إلى (k-1)، وهذا يعني أنّ الخوارزميتَين LFU و LIFO تخليان k-1 +2 (l-1) صفحة بالضبط. الآن، سنحاول أن نثبت أنّه لكل ثابتة τ∈ℜ، ولكل ثابتة r ≤ 1، يوجد l يحقق: والذي يساوي: لتحقيق هذه المتراجحة، يكفي أن تختار عدد I كبيرًا بما يكفي، من ذلك نستنتج أنّ الخوارزميتَين LFU و LIFO غير تنافسيتَين. خاصية 1.7: لكل r <k، لا توجد أيّ خوارزمية حتمية متصلة ذات r تنافسية لحل مشكلة التصفيح r-competetive deterministic online algorithm for paging. برهان هذه الخاصية طويل نوعًا ما ويستند إلى فكرة أنّ LFD هي خوارزمية مثلى. السؤال الآن هو: هل هناك منظور أفضل؟ للإجابة على هذا السؤال سيكون علينا أن نتجاوز المنظور الحتمي deterministic approach ونستخدم المقاربات العشوائية في خوَارزميتنا، وهو أمر سنعود إليه لاحقًا في هذه السلسلة. ترجمة -بتصرّف- للفصول 21 و 23 و 24 و25 و26 و27 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال التالي: خوارزميات الترتيب وأشهرها المقال السابق: خوارزميات تحليل المسارات في الأشجار مدخل إلى الخوارزميات تعلم بايثون تعلم PHP
-
سنتحدث في هذه المقالة عن بعض المفاهيم العامة المتعلقة بخوارزميات الترتيب، ثم نستعرض 10 من أشهر خوارزميات ترتيب المصفوفات. قبل أن نواصل، سنعطي بعض التعاريف العامة. خوارزميات الترتيب المستقرة: تكون خوارزمية ترتيب ما مستقرةً stable إذا كانت تحافظ على الترتيب النسبي للعناصر التي لها نفس قيمة المفتاح الذي يُجرى الترتيب وفقًا له. خوارزميات الترتيب الموضعية In place algorithms: نقول أنّ خوارزمية ترتيب ما هي خوارزمية موضعية In place إذا كانت الذاكرة الإضافية التي تستخدمها أثناء الترتيب تساوي O(1) (دون احتساب المصفوفة المراد ترتيبها). أسوء حالة تعقيدية Worst case complexity: نقول أنّ أسوء حالة تعقيدية Worst case complexity لخوارزمية تساوي O(T(n)) إذا كان وقت تشغيلها لا يزيد عن T(n) مهما كانت المدخلات. أفضل حالة تعقيدية Best case complexity: نقول أنّ أفضل حالة تعقيدية Best case complexity لخوارزمية ما تساوي O(T(n)) إذا كان وقت تشغيلها لا يقل عن T(n) مهما كانت المدخلات. أوسط حالة تعقيدية Average case complexity: نقول أنّ أوسط حالة تعقيدية Average case complexity لخوارزمية ترتيب ما تساوي O(T(n)) إذا كان متوسط أوقات تشغيلها بالنسبة لجميع المدخلات الممكنة يساوي T(n). استقرار الترتيب Stability in Sorting تكون خوارزمية الترتيب مستقرةً إذا كانت تحافظ على الترتيب النسبي للقيم ذات المفاتيح المتساوية في المصفوفة المُدخلة الأصلية بعد ترتيبها. أي إن كانت خوارزمية الترتيب مستقرةً وكان هناك كائنَان لهما مفاتيح متساوية، فسيكون ترتيبُهما في الخرج المُرتّب مماثلًا لتَرتيبهما في المصفوفة المدخلة غير المرتبة. انظر مصفوفة الأزواج التالية: (1, 2) (9, 7) (3, 4) (8, 6) (9, 3) سنحاول ترتيب المصفوفة حسب العنصر الأول في كل زوج. سيخرج الترتيب المستقر لهذه القائمة ما يلي: (1, 2) (3, 4) (8, 6) (9, 7) (9, 3) وهو ترتيبٌ مستقر لأنّ (9, 3) تظهر بعد (9, 7) في المصفوفة الأصلية أيضًا. أما الترتيب غير مستقر فيكون كما يلي: (1, 2) (3, 4) (8, 6) (9, 3) (9, 7) قد ينتج عن الترتيب غير المستقر الخرج نفسه الذي يٌنتجه الترتيب المستقر أحيانًا، لكن ليس دائمًا. هذه بعض أشهر أنواع التراتيب المستقرة: الترتيب بالدمج Merge sort. الترتيب بالإدراج Insertion sort. ترتيب بالجذر Radix sort . ترتيب Tim. الترتيب بالفقاعات Bubble Sort. هذه بعض أنواع التراتيب غير المستقرة: الترتيب بالكومة Heap sort. الترتيب السريع Quick sort. سوف نستعرض في بقية هذه المقالة 10 خوارزميات ترتيب شهيرة. الترتيب بالفقاعات Bubble Sort table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } المعامِل الوصف ترتيب مستقر نعم ترتيب موضعي نعم أفضل حالة تعقيد (O(n أسوء حالة تعقيد (O(n^2 أوسط حالة تعقيد (O(n^2 تعقيد المساحة (O(1 توازن خوارزمية الفقاعات BubbleSort كل زوج متتالي من العناصر في المصفوفة غير المرتبة، ثمّ تبدل مواضعهما إن كان تَرتيبهما خاطئًا. ويوضّح المثال التالي كيفية عمل خوارزمية الفقاعات على المصفوفة {6,5,3,1,8,7,2,4} (تُحاط الأزواج قيد المقارنة في كل خطوة بالرمز "**"): {6,5,3,1,8,7,2,4} {**5,6**,3,1,8,7,2,4} -- 5 < 6 -> تبديل {5,**3,6**,1,8,7,2,4} -- 3 < 6 -> تبديل {5,3,**1,6**,8,7,2,4} -- 1 < 6 -> تبديل {5,3,1,**6,8**,7,2,4} -- 8 > 6 -> لا تبديل {5,3,1,6,**7,8**,2,4} -- 7 < 8 -> تبديل {5,3,1,6,7,**2,8**,4} -- 2 < 8 -> تبديل {5,3,1,6,7,2,**4,8**} -- 4 < 8 -> تبديل نحصل بعد الدورة الأولى على المصفوفة {5,3,1,6,7,2,4,8}، لاحظ أنّ أكبر قيمة غير مرتبة في المصفوفة (8 في هذه الحالة) ستصل دائمًا إلى موضعها النهائي. للتأكد من أنّ المصفوفة مرتّبة ترتيبًا صحيحًا وأنّ كل عناصرها في المواضع الصحيحة، سيكون علينا تكرار هذه العملية n-1 مرّة، حيث يمثّل n طول المصفوفة المراد ترتيبها. خوارزمية الترتيب بالفقاعات التي تُعرف أيضًا باسم الترتيب المتدرّج Sinking Sort، هي خوارزمية ترتيب بسيطة تمرّ مرارًا وتكرارًا على المصفوفة حتى ترتّبها، وتوازن كل زوج متتال من العناصر، ثمّ تبدّلهما إن كان ترتيبهما خاطئًا. الرسم البياني التالي يمثّل آلية عمل خوارزمية الترتيب بالفقاعات: هذه تطبيقات لخوارزمية الترتيب بالفقاعات بعدة لغات برمجة. لغة C++: void bubbleSort(vector<int>numbers) { for(int i = numbers.size() - 1; i >= 0; i--) { for(int j = 1; j <= i; j++) { if(numbers[j-1] > numbers[j]) { swap(numbers[j-1],numbers(j)); } } } } لغة C: void bubble_sort(long list[], long n) { long c, d, t; for (c = 0 ; c < ( n - 1 ); c++) { for (d = 0 ; d < n - c - 1; d++) { if (list[d] > list[d+1]) { /* تبديل */ t = list[d]; list[d] = list[d+1]; list[d+1] = t; } } } } هذا تطبيق يستخدم المؤشرات: void pointer_bubble_sort(long * list, long n) { long c, d, t; for (c = 0 ; c < ( n - 1 ); c++) { for (d = 0 ; d < n - c - 1; d++) { if ( * (list + d ) > *(list+d+1)) { /* تبديل */ t = * (list + d ); * (list + d ) = * (list + d + 1 ); * (list + d + 1) = t; } } } } لغة C#: public class BubbleSort { public static void SortBubble(int[] input) { for (var i = input.Length - 1; i >= 0; i--) { for (var j = input.Length - 1 - 1; j >= 0; j--) { if (input[j] <= input[j + 1]) continue; var temp = input[j + 1]; input[j + 1] = input[j]; input[j] = temp; } } } public static int[] Main(int[] input) { SortBubble(input); return input; } } بايثون: #!/usr/bin/python input_list = [10,1,2,11] for i in range(len(input_list)): for j in range(i): if int(input_list[j]) > int(input_list[j+1]): input_list[j],input_list[j+1] = input_list[j+1],input_list[j] print input_list جافا: public class MyBubbleSort { public static void bubble_srt(int array[]) {//main logic int n = array.length; int k; for (int m = n; m >= 0; m--) { for (int i = 0; i < n - 1; i++) { k = i + 1; if (array[i] > array[k]) { swapNumbers(i, k, array); } } printNumbers(array); } } private static void swapNumbers(int i, int j, int[] array) { int temp; temp = array[i]; array[i] = array[j]; array[j] = temp; } private static void printNumbers(int[] input) { for (int i = 0; i < input.length; i++) { System.out.print(input[i] + ", "); } System.out.println("\n"); } public static void main(String[] args) { int[] input = { 4, 2, 9, 6, 23, 12, 34, 0, 1 }; bubble_srt(input); } } جافاسكربت: function bubbleSort(a) { var swapped; do { swapped = false; for (var i=0; i < a.length-1; i++) { if (a[i] > a[i+1]) { var temp = a[i]; a[i] = a[i+1]; a[i+1] = temp; swapped = true; } } } while (swapped); } var a = [3, 203, 34, 746, 200, 984, 198, 764, 9]; bubbleSort(a); console.log(a); // [ 3, 9, 34, 198, 200, 203, 746, 764, 984 ] الترتيب بالدمج Merge Sort الترتيب بالدمج هي خوارزمية تعتمد مبدأ فرّق تسد، إذ تقسم المصفوفةَ المدخلةَ إلى نصفين بشكل متتابع إلى أن تصبح لدينا n مصفوفة أحادية، حيث n يمثل حجم المصفوفة المُدخلة. بعد ذلك، تُدمج المصفوفات الجزئية المرتبة مثنى مثنى، إذ يُضاف أصغر العنصرين المتقابلين في المصفوفتين المراد دمجهما في كل خطوة. تستمر هذه العملية إلى حين الانتهاء من بناء المصفوفة المُرتّبة. يوضح المثال التالي آلية عمل خوارزمية الترتيب بالدمج: التعقيد الزمني للخوارزمية: T(n) = 2T(n/2) + Θ(n). يمكن تطبيق التكرارية أعلاه إما باستخدام طريقة الشجرة التكرارية Recurrence Tree method أو الطريقة الرئيسية (Master method)، يمكنك معرفة تفاصيل هاتين الطريقتين من هنا. تعقيد الطريقة الرئيسية يساوي Θ(nLogn) في جميع الحالات الثلاث (الأسوأ والمتوسطة والفضلى)، ذلك أنّ الترتيب بالدمج يقسّم المصفوفة تعاوديًا إلى أنصاف، ويستغرق وقتًا خطيًا لدمج نصفين، حيث لدينا: المساحة الإضافية: O(n). نموذج الخوارزمية: فرّق تسد. الموضعيّة: على العموم، التطبيقات الشائعة للخوارزمية لا تكون موضعية. مستقرة: نعم. هذه بعض تطبيقات خوارزمية الدمج في بعض لغات البرمجة: لغة Go: package main import "fmt" func mergeSort(a []int) []int { if len(a) < 2 { return a } m := (len(a)) / 2 f := mergeSort(a[:m]) s := mergeSort(a[m:]) return merge(f, s) } func merge(f []int, s []int) []int { var i, j int size := len(f) + len(s) a := make([]int, size, size) for z := 0; z < size; z++ { lenF := len(f) lenS := len(s) if i > lenF-1 && j <= lenS-1 { a[z] = s[j] j++ } else if j > lenS-1 && i <= lenF-1 { a[z] = f[i] i++ } else if f[i] < s[j] { a[z] = f[i] i++ } else { a[z] = s[j] j++ } } return a } func main() { a := []int{75, 12, 34, 45, 0, 123, 32, 56, 32, 99, 123, 11, 86, 33} fmt.Println(a) fmt.Println(mergeSort(a)) } لغة C int merge(int arr[],int l,int m,int h) { int arr1[10],arr2[10]; // مصفوفتان مؤقتتان hold the two arrays to be merged int n1,n2,i,j,k; n1=m-l+1; n2=h-m; for(i=0; i<n1; i++) arr1[i]=arr[l+i]; for(j=0; j<n2; j++) arr2[j]=arr[m+j+1]; arr1[i]=9999; // لتحديد نهاية كل مصفوفة مؤقتة arr2[j]=9999; i=0; j=0; for(k=l; k<=h; k++) { // دمج مصفوفتين مرتبتين if(arr1[i]<=arr2[j]) arr[k]=arr1[i++]; else arr[k]=arr2[j++]; } return 0; } int merge_sort(int arr[],int low,int high) { int mid; if(low<high) { mid=(low+high)/2; // فرق تسد merge_sort(arr,low,mid); merge_sort(arr,mid+1,high); // Combine merge(arr,low,mid,high); } return 0; } لغةC# public class MergeSort { static void Merge(int[] input, int l, int m, int r) { int i, j; var n1 = m - l + 1; var n2 = r - m; var left = new int[n1]; var right = new int[n2]; for (i = 0; i < n1; i++) { left[i] = input[l + i]; } for (j = 0; j < n2; j++) { right[j] = input[m + j + 1]; } i = 0; j = 0; var k = l; while (i < n1 && j < n2) { if (left[i] <= right[j]) { input[k] = left[i]; i++; } else { input[k] = right[j]; j++; } k++; } while (i < n1) { input[k] = left[i]; i++; k++; } while (j < n2) { input[k] = right[j]; j++; k++; } } static void SortMerge(int[] input, int l, int r) { if (l < r) { int m = l + (r - l) / 2; SortMerge(input, l, m); SortMerge(input, m + 1, r); Merge(input, l, m, r); } } public static int[] Main(int[] input) { SortMerge(input, 0, input.Length - 1); return input; } } جافا: هذا تطبيق بلغة جافا يستخدم المقاربة العامة. public interface InPlaceSort<T extends Comparable<T>> { void sort(final T[] elements); } public class MergeSort < T extends Comparable < T >> implements InPlaceSort < T > { @Override public void sort(T[] elements) { T[] arr = (T[]) new Comparable[elements.length]; sort(elements, arr, 0, elements.length - 1); } // نتحقق من كلا الجانبين، ثم ندمجهما private void sort(T[] elements, T[] arr, int low, int high) { if (low >= high) return; int mid = low + (high - low) / 2; sort(elements, arr, low, mid); sort(elements, arr, mid + 1, high); merge(elements, arr, low, high, mid); } private void merge(T[] a, T[] b, int low, int high, int mid) { int i = low; int j = mid + 1; // b نختار أصغر عنصر منهما، ثم نضعه في for (int k = low; k <= high; k++) { if (i <= mid && j <= high) { if (a[i].compareTo(a[j]) >= 0) { b[k] = a[j++]; } else { b[k] = a[i++]; } } else if (j > high && i <= mid) { b[k] = a[i++]; } else if (i > mid && j <= high) { b[k] = a[j++]; } } for (int n = low; n <= high; n++) { a[n] = b[n]; }}} Java: هذا تطبيق آخر للخوارزمية بلغة Java، ولكن وفق منظور تصاعدي (من الأسفل إلى الأعلى) public class MergeSortBU { private static Integer[] array = { 4, 3, 1, 8, 9, 15, 20, 2, 5, 6, 30, 70, 60,80,0,9,67,54,51,52,24,54,7 }; public MergeSortBU() { } private static void merge(Comparable[] arrayToSort, Comparable[] aux, int lo,int mid, int hi) { for (int index = 0; index < arrayToSort.length; index++) { aux[index] = arrayToSort[index]; } int i = lo; int j = mid + 1; for (int k = lo; k <= hi; k++) { if (i > mid) arrayToSort[k] = aux[j++]; else if (j > hi) arrayToSort[k] = aux[i++]; else if (isLess(aux[i], aux[j])) { arrayToSort[k] = aux[i++]; } else { arrayToSort[k] = aux[j++]; } } } public static void sort(Comparable[] arrayToSort, Comparable[] aux, int lo, int hi) { int N = arrayToSort.length; for (int sz = 1; sz < N; sz = sz + sz) { for (int low = 0; low < N; low = low + sz + sz) { System.out.println("Size:"+ sz); merge(arrayToSort, aux, low, low + sz -1 ,Math.min(low + sz + sz - 1, N - 1)); print(arrayToSort); } } } public static boolean isLess(Comparable a, Comparable b) { return a.compareTo(b) <= 0; } private static void print(Comparable[] array) {http://stackoverflow.com/documentation/algorithm/5732/merge-sort# StringBuffer buffer = new StringBuffer();http://stackoverflow.com/documentation/algorithm/5732/merge-sort# for (Comparable value : array) { buffer.append(value); buffer.append(' '); } System.out.println(buffer); } public static void main(String[] args) { Comparable[] aux = new Comparable[array.length]; print(array); MergeSortBU.sort(array, aux, 0, array.length - 1); } } بايثون def merge(X, Y): " دمج مصفوفتين مرتبتين " p1 = p2 = 0 out = [] while p1 < len(X) and p2 < len(Y): if X[p1] < Y[p2]: out.append(X[p1]) p1 += 1 else: out.append(Y[p2]) p2 += 1 out += X[p1:] + Y[p2:] return out def mergeSort(A): if len(A) <= 1: return A if len(A) == 2: return sorted(A) mid = len(A) / 2 return merge(mergeSort(A[:mid]), mergeSort(A[mid:])) if __name__ == "__main__": # Generate 20 random numbers and sort them A = [randint(1, 100) for i in xrange(20)] print mergeSort(A) الترتيب بالإدراج الترتيب بالإدراج هي إحدى خوارزميات الترتيب البسيطة، إذ ترتّب العناصر واحدًا تلو الآخر بنفس الطريقة التي ترتّب فيها أوراق اللعب يدويًّا. الرسم البياني التالي يوضّح آلية عمل خوارزمية الترتيب بالإدراج: المصدر: ويكيبديا يمكنك معرفة المزيد من التفاصيل حول آلية عمل الخوارزمية من ويكي حسوب. هذا تطبيق لخوارزمية الترتيب بالإدراج بلغة هاسكل Haskell: insertSort :: Ord a => [a] -> [a] insertSort [] = [] insertSort (x:xs) = insert x (insertSort xs) insert :: Ord a => a-> [a] -> [a] insert n [] = [n] insert n (x:xs) | n <= x = (n:x:xs) | otherwise = x:insert n xs الترتيب بالدلو Bucket Sort الترتيب بالدلو هي خوارزمية ترتيب توزّع عناصر المصفوفة المراد ترتيبها على عدد من الدلاء (مصفوفات)، ثمّ تُرتّب عناصر كل دلو على حدة باستخدام خوارزمية ترتيب مختلفة، أو بتطبيق خوارزمية الترتيب بالدلو تكراريًا، يمكنك معرفة المزيد من التفاصيل عن هذه الخوارزمية من موسوعة حسوب. هذا تطبيق لخوارزمية الترتيب بالدلو بلغة C# public class BucketSort { public static void SortBucket(ref int[] input) { int minValue = input[0]; int maxValue = input[0]; int k = 0; for (int i = input.Length - 1; i >= 1; i--) { if (input[i] > maxValue) maxValue = input[i]; if (input[i] < minValue) minValue = input[i]; } List<int>[] bucket = new List<int>[maxValue - minValue + 1]; for (int i = bucket.Length - 1; i >= 0; i--) { bucket[i] = new List<int>(); } foreach (int i in input) { bucket[i - minValue].Add(i); } foreach (List<int> b in bucket) { if (b.Count > 0) { foreach (int t in b) { input[k] = t; k++; } } } } public static int[] Main(int[] input) { SortBucket(ref input); return input; } } الترتيب السريع Quicksort خوارزمية الترتيب السريع هي خوارزمية ترتيب تنتقي عنصرًا من عناصر المصفوفة وتجعله محورًا، ثمّ تقسّم المصفوفة المعطاة حول ذلك العنصر، بحيث تأتي جميع العناصر الأصغر من المحور قبله، وجميع العناصر الأكبر منه تأتي بعده. تُطبّق الخوارزمية تكراريًا على الأقسام حتى تُرتّب المصفوفة بالكامل، وهناك طريقتان لتقديم هذه الخوارزمية. طريقة لوموتو Lomuto في هذه الطريقة يُنتقى أحد العناصر ليكون محور الترتيب، وغالبًا ما يكون العنصر الأخير من المصفوفة، ويُحفظ فهرس المحور ثم تُتعقّب المواقع التي تحتوي على العناصر التي تساوي قيمة المحور أو تصغُره، ويُجرى التبديل بين موقعي العنصرين إن عُثِر على عنصر أصغر من المحور، ويُزاد فهرس المحور بواحد. فيما يلي شيفرة عامة للخوارزمية: partition(A, low, high) is pivot := A[high] i := low for j := low to high – 1 do if A[j] ≤ pivot then swap A[i] with A[j] i := i + 1 swap A[i] with A[high] return i آلية الترتيب السريع: quicksort(A, low, high) is if low < high then p := partition(A, low, high) quicksort(A, low, p – 1) quicksort(A, p + 1, high) يوضّح المثال التالي آلية عمل خوارزمية الترتيب السريع: طريقة هور Hoare تستخدم طريقة هور مؤشرين يبدآن بالإشارة إلى طرفي المصفوفة المُقسّمة، ثم يتحركان نحو بعضهمًا إلى أن يحصل انقلاب، أي تصبح القيمة الصغرى في الجانب الأيسر من المحور، والكبرى في الجانب الأيمن منه؛ وعند حصول الانقلاب تبدّل الخوارزمية بين موقعي القيمتين وتعاد العملية مرةً أخرى. عندما تلتقي الفهارس، تتوقف الخوارزمية ويُعاد الفهرس النهائي. تُعد طريقة هور أكثر فعاليةً من طريقة Lomuto، لأنّ عدد التبديلات التي تجريها أقل بثلاث مرات في المتوسط من طريقة Lomuto، كما أنّها أكثر فعاليةً في إنشاء الأقسام حتى عندما تكون جميع القيم متساوية. quicksort(A, lo, hi) is if lo < hi then p := partition(A, lo, hi) quicksort(A, lo, p) quicksort(A, p + 1, hi) شيفرة عامة لتقسيم المصفوفة: partition(A, lo, hi) is pivot := A[lo] i := lo - 1 j := hi + 1 loop forever do: i := i + 1 while A[i] < pivot do do: j := j - 1 while A[j] > pivot do if i >= j then return j swap A[i] with A[j] هذا تطبيق لخوارزمية الترتيب السريع بلغة بايثون: def quicksort(arr): if len(arr) <= 1: return arr pivot = arr[len(arr) / 2] left = [x for x in arr if x < pivot] middle = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot] return quicksort(left) + middle + quicksort(right) print quicksort([3,6,8,10,1,2,1]) الخرج الناتج: [1 ، 1 ، 2 ، 3 ، 6 ، 8 ، 10] وهذا تطبيق لطريقة Lomuto بلغة جافا: public class Solution { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); int[] ar = new int[n]; for(int i=0; i<n; i++) ar[i] = sc.nextInt(); quickSort(ar, 0, ar.length-1); } public static void quickSort(int[] ar, int low, int high) { if(low<high) { int p = partition(ar, low, high); quickSort(ar, 0 , p-1); quickSort(ar, p+1, high); } } public static int partition(int[] ar, int l, int r) { int pivot = ar[r]; int i =l; for(int j=l; j<r; j++) { if(ar[j] <= pivot) { int t = ar[j]; ar[j] = ar[i]; ar[i] = t; i++; } } int t = ar[i]; ar[i] = ar[r]; ar[r] = t; return i; } الترتيب بالعد Counting Sort المساحة الإضافية: O(n+k) التعقيد الزمني: الحالة الأسوأ: O(n+k). الحالة الأفضل: O(n). الحالة المتوسطة O(n+k). الترتيب بالعد Counting sort هي خوارزمية لترتيب الكائنات وفقًا لمفاتيحها. خطوات الخوارزمية: أنشئ مصفوفة C حجمها يساوي عدد العناصر الفريدة في المصفوفة المُدخلة A. املأ المصفوفة 😄 لكل x عنصر فريد من المصفوفة المُدخلة، تحتوي C[x] تردّد ذلك العنصر في المصفوفة المُدخلة A. حوّل C إلى مصفوفة بحيث يشير C[x] إلى عدد القيم التي تصغُر x عبر التكرار في المصفوفة، وعيّن مجموع القيمة السابقة لكل C [x]، وكذلك جميع القيم في C التي تظهر قبله. كرِّر خلفيًا على المصفوفة A مع وضع كل قيمة في مصفوفة B جديدة مرتبة في الفهرس المسجّل في C. ولكل A [x]، نعيّن قيمة B [C [A [x]]] إلى A [x]، مع إنقاص قيمة C [A [x]] بمقدار واحد في حال وجود قيم مكرّرة في المصفوفة الأصلية غير المرتبة. يوضّح المثال التالي آلية عمل خوارزمية الترتيب بالعد: وفي ما يلي مثال توضيحي للخوارزمية: for x in input: count[key(x)] += 1 total = 0 for i in range(k): oldCount = count[i] count[i] = total total += oldCount for x in input: output[count[key(x)]] = x count[key(x)] += 1 return output يمكنك الاطلاع على المزيد من التفاصيل والأمثلة التوضيحية عن خوارزمية الترتيب بالعدّ من موسوعة حسوب الترتيب بالكومة Heap Sort المساحة الإضافية: O(1). التعقيد الزمني:O(nlogn). الترتيب بالكومة هي خوارزمية تستند على الكومة الثنائية Binary Heap، وهي مشابهة لخوارزمية الترتيب بالتحديد Selection Sort التي تعتمد على اختيار العنصر الأكبر في المصفوفة في البداية، ثمّ تضعه في نهاية المصفوفة، ثمّ تعيد العملية على بقية العناصر. هذا مثال توضيحي لخوارزمية الترتيب بالكومة: function heapsort(input, count) heapify(a,count) end <- count - 1 while end -> 0 do swap(a[end],a[0]) end<-end-1 restore(a, 0, end) function heapify(a, count) start <- parent(count - 1) while start >= 0 do restore(a, start, count - 1) start <- start - 1 وهذا مثال على آلية عمل خوارزمية الترتيب بالكومة على المصفوفة [2,3,7,1,8,5,6]: هذا تطبيق لخوارزمية الترتيب بالكومة بلغة C#: public class HeapSort { public static void Heapify(int[] input, int n, int i) { int largest = i; int l = i + 1; int r = i + 2; if (l < n && input[l] > input[largest]) largest = l; if (r < n && input[r] > input[largest]) largest = r; if (largest != i) { var temp = input[i]; input[i] = input[largest]; input[largest] = temp; Heapify(input, n, largest); } } public static void SortHeap(int[] input, int n) { for (var i = n - 1; i >= 0; i--) { Heapify(input, n, i); } for (int j = n - 1; j >= 0; j--) { var temp = input[0]; input[0] = input[j]; input[j] = temp; Heapify(input, j, 0); } } public static int[] Main(int[] input) { SortHeap(input, input.Length); return input; } } الترتيب بالتدوير Cycle Sort الترتيب بالتدوير هي خوارزمية موضعية غير مستقرة، وتُعدّ مثاليةً من حيث عدد مرات الكتابة في المصفوفة الأصلية وعدد مرات الكتابة في الذاكرة، فكل عنصر يُكتب إمّا مرةً واحدةً إن لم يكن في موضعه الصحيح، أو لا يُكتب على الإطلاق. يمكنك معرفة المزيد من التفاصيل والأمثلة التوضيحية عن خوارزمية الترتيب بالتدوير من موسوعة حسوب هذا مثال توضيحي عن تطبيق على الخوارزمية: (input) output = 0 for cycleStart from 0 to length(array) - 2 item = array[cycleStart] pos = cycleStart for i from cycleStart + 1 to length(array) - 1 if array[i] < item: pos += 1 if pos == cycleStart: continue while item == array[pos]: pos += 1 array[pos], item = item, array[pos] writes += 1 while pos != cycleStart: pos = cycleStart for i from cycleStart + 1 to length(array) - 1 if array[i] < item: pos += 1 while item == array[pos]: pos += 1 array[pos], item = item, array[pos] writes += 1 return outout ترتيب الفردي-الزوجي Odd-Even Sort المساحة الإضافية: O(n) التعقيد الزمني: O(n) خوارزمية ترتيب الفردي-الزوجي Odd-Even Sort (أو الترتيب بالطوب Brick sort هي خوارزمية ترتيب بسيطة طُوِّرت لتُستخدم مع المعالجات المتوازية ذات التقاطعات المحلية parallel processors with local interconnection. توازن هذه الخوارزمية بين جميع أزواج العناصر المتجاورة ذات الفهارس الفردية / الزوجية في المصفوفة، وتبدل العناصر إذا وجدت أنّ الزوج مرتّب ترتيبًا خاطئًا. تكرر الخوارزمية الخطوة نفسها، لكن هذه المرة على الأزواج ذات الفهارس الزوجية / الفردية، وتستمر في المناوبة بين النمط زوجي / فردي وفردي / زوجي إلى أن تُرتّب المصفوفة. هذا مثال توضيحي لخوارزمية الطوب Brick: if n>2 then 1. apply odd-even merge(n/2) recursively to the even subsequence a0, a2, ..., an-2 and to the odd subsequence a1, a3, , ..., an-1 2. comparison [i : i+1] for all i element {1, 3, 5, 7, ..., n-3} else comparison [0 : 1] هذا رسم توضيحي لآلية عمل خوارزمية الترتيب فردي/زوجي على مجموعة عشوائية: المصدر: ويكيبيديا وهذا مثال توضيحي على الخوارزمية: وفيما يلي تطبيق بلغة C# لخوارزمية الترتيب بقوالب الطوب: public class OddEvenSort { private static void SortOddEven(int[] input, int n) { var sort = false; while (!sort) { sort = true; for (var i = 1; i < n - 1; i += 2) { if (input[i] <= input[i + 1]) continue; var temp = input[i]; input[i] = input[i + 1]; input[i + 1] = temp; sort = false; } for (var i = 0; i < n - 1; i += 2) { if (input[i] <= input[i + 1]) continue; var temp = input[i]; input[i] = input[i + 1]; input[i + 1] = temp; sort = false; } } } public static int[] Main(int[] input) { SortOddEven(input, input.Length); return input; } } الترتيب بالتحديد Selection Sort المساحة الإضافية: O(n) التعقيد الزمني: O(n^2) الترتيب بالتحديد هي خوارزمية موضعية لترتيب المصفوفات، تعقيدها الزمني يساوي O (n2)، ما يجعلها غير فعالة مع المصفوفات الكبيرة، وعادةً ما يكون أداؤها أسوأ من خوارزميات الإدراج المماثلة. بالمقابل، تتميّز خوارزمية الترتيب بالتحديد بالبساطة وقد تتفوّق أداءً على بعض الخوارزميات الأعقد في بعض الحالات، خاصّةً عندما تكون الذاكرة الإضافية محدودة. تقسّم الخوارزمية المصفوفة إلى قسمين هما مصفوفة تضم العناصر المرتبة فعليًا، والتي تُبنى من اليسار إلى اليمين من مقدمة المصفوفة (اليسرى)؛ فيما تضم المصفوفة الأخرى العناصر المتبقية التي تنتظر أن تُرتّب، والتي تشغل بقية المصفوفة. وتكون المصفوفة المرتّبة فارغةً في البداية، بينما تحتوى المصفوفة غير المرتّبة كل عناصر المصفوفة المُدخلة. تبحث الخوارزمية عن أصغر عنصر (أو أكبر عنصر، بحسب غرض الترتيب) في المصفوفة غير المرتّبة وتبدّله بالعنصر غير المرتّب الموجود في أقصى اليسار (أي تضعه في المكان الصحيح)، ثمّ تنقل حدود المصفوفة الفرعية عنصرًا واحدًا إلى اليمين . هذا مثال توضيحي للخوارزمية: function select(list[1..n], k) for i from 1 to k minIndex = i minValue = list[i] for j from i+1 to n if list[j] < minValue minIndex = j minValue = list[j] swap list[i] and list[minIndex] return list[k] وهذا تمثيل بصري للخوارزمية: وهذا مثال على خوارزمية الترتيب بالتحديد: فيما يلي تطبيق على لخوارزمية بلغة C #: public class SelectionSort { private static void SortSelection(int[] input, int n) { for (int i = 0; i < n - 1; i++) { var minId = i; int j; for (j = i + 1; j < n; j++) { if (input[j] < input[minId]) minId = j; } var temp = input[minId]; input[minId] = input[i]; input[i] = temp; } } public static int[] Main(int[] input) { SortSelection(input, input.Length); return input; } } وهذا تطبيق على الخوارزمية بلغة إكسير Elixir: defmodule Selection do def sort(list) when is_list(list) do do_selection(list, []) end def do_selection([head|[]], acc) do acc ++ [head] end def do_selection(list, acc) do min = min(list) do_selection(:lists.delete(min, list), acc ++ [min]) end defp min([first|[second|[]]]) do smaller(first, second) end defp min([first|[second|tail]]) do min([smaller(first, second)|tail]) end defp smaller(e1, e2) do if e1 <= e2 do e1 else e2 end end end Selection.sort([100,4,10,6,9,3]) |> IO.inspect ترجمة -بتصرّف- للفصول من 28 إلى 38 من الكتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: أمثلة عن أنواع الخوارزميات خوارزميات تحليل المسارات في الأشجار خوارزمية ديكسترا Dijkstra’s Algorithm الخوارزميات الشرهة Greedy Algorithms
-
هذه المقالة تتمة للمقالة السابقة خوارزميات البحث وآلية عملها، إذ نستعرض فيها بعض خوارزميات البحث، لكننا سنركز هذه المرة على البحث داخل النصوص، وسنستعرض اثنتين من أشهر خوارزميات البحث في النصوص، وهما خوارزمية رابين-كارب وخوارزمية KMP. خوارزمية KMP لنفترض أن لدينا نصًّا ونمطًا (سلسلة نصية)، ونريد أن نعرف ما إذا كان النمط موجودًا في النص أم لا. انظر المثال التالي: +-------+----+----+----+----+----+----+----+----+ | Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +-------+----+----+----+----+----+----+----+----+ | Text | a | b | c | b | c | g | l | x | +-------+----+----+----+----+----+----+----+----+ +----------+----+----+----+----+ | Index | 0 | 1 | 2 | 3 | +----------+----+----+----+----+ | Pattern | b | c | g | l | +----------+----+----+----+----+ هذا النمط Pattern موجود فعلًا في النص Text، لذا يجب أن تعيد خوارزمية البحث العدد 3، وهو فهرس الموضع الذي يبدأ منه هذا النمط داخل النص. الطريقة البدهية هي أن نبدأ من الفهرس 0 في النص والفهرس 0 في النمط، ونوازن Text[0] وPattern[0]. وبما أنهما غير متساويين، سننتقل إلى الفهرس التالي في النص، ونوازن Text[1] وPattern[0]. وبما أنهما متطابقين، فإنّنا نزيد قيمة الفهرس في النمط والنص معًا. بعد ذلك نوازن Text[2] وPattern[1] فنجد أنّهما متطابقين، ثم نتابع ونوازن الآن Text[3] وPattern[2]، واللذين نجد أنّهما غير متطابقين، فنبدأ الآن من الموضع حيث بدأ التطابق بين النص والنمط، أي الفهرس 2 في النص، ونوازن بين كل من Text[2] وPattern[0]، وبما أنهما لا يتطابقان، فسنزيد الفهرس النصي، ونوازن بين كل من Text[3] وPattern[0]. واللذان نجد أنهما متطابقان، كما يتطابق كل من: Text[4]. Pattern[1]. Text[5]. Pattern[2]. Text[6]. Pattern[3]. لقد استنفدنا حروف النمط، وهذا يعني أنه موجود في النص. نعيد الآن الفهرس الذي بدأ التطابق عنده، وهو 3. إن لم يكن النمط موجودًا في النص (مثلا إن كان يساوي bcgll)، فنتوقّع أن يُرفع اعتراض exception أو يُعاد -1 أو أيّ قيمة أخرى محدّدة مسبقًا. تستغرق الخوارزمية في أسوأ الحالات O(mn)، حيث يمثّل m طول النص، و n طول النمط. السؤال الآن هو: هل هناك خوارزمية أسرع؟ نعم، وهي خوارزمية KMP. تبحث خوارزمية نوث-موريس-برات Knuth-Morris-Pratt أو KMP اختصارًا، عن ظهور نمط معيّن داخل سلسلة نصّية، وتستغل هذه الخوارزمية حقيقة أنّه عند حدوث عدم التطابق فإنّ الكلمة نفسها تقدّم معلومات كافية لتقدير المكان التالي الذي يمكن أن يبدأ التطابق عنده، وبالتالي تجاوز بعض الحروف التي تحقّقنا منها سابقًا. صُمِّمت هذه الخوارزمية سنة 1970 على يد دونالد نوث Donuld Knuth وفوهان برات Vaughan Pratt، وبشكل مستقل على يد جيمس مورِس James H. Morris، وقد نَشر هذا الثلاثي بحثًا مشتركًا عنها سنة 1977. لنوسّع مثالنا السابق كي نفهمه أكثر: +-------+---+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | Index |0 |1 |2 |3 |4 |5 |6 |7 |8 |9 |10|11|12|13|14|15|16|17|18|19|20|21|22| +--------+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | Text |a |b |c |x |a |b |c |d |a |b | x | a | b | c |d | a | b | c | d | a | b | c | y | +--------+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +----------+---+---+---+---+---+---+---+---+ | Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +----------+---+---+---+---+---+---+---+---+ | Pattern | a | b | c | d | a | b | c | y | +---------+---+---+---+---+---+---+---+---+ في البداية، يتطابق النص والنمط حتى الفهرسَ 2، ثمّ ينهار التطابق عند الفهرس 3، إذ لا تتطابق Text[3] وPattern[3]. ونحن نهدف إلى عدم الرجوع للخلف في النص، إذ لا نريد أن نبدأ المطابقة مرةً أخرى من الموضع الذي بدأنا المطابقة عنده سابقًا في حالة عدم التطابق. ولتحقيق ذلك، نبحث عن لاحقةٍ suffix في الجزء الحالي من النمط قبل انهيار التطابق والتي تكون أيضًا سابقةً في النمط (السلسلة النصية الجزئية abc). على سبيل المثال، بما أنّ جميع الحروف فريدة غير مكرّرة، فلن يكون هناك أيّ سلسلة نصية تكون لاحقة وسابقة في الوقت نفسه في السلسلة النصية الجزئية التي طَابقناها للتو، هذا يعني أنّ الموازنة التالية ستبدأ من الفهرس 0. سنوازن الآن بين كل من Text[3] وPattern[0]، إلا أنّهما لا يتطابقان. بعد ذلك نجد تطابقًا ابتداءً من الفهرس 4 وحتى الفهرس 9 في النص، ومن الفهرس 0 إلى 5 في النمط. غير أنّ التطابق ينهار بعد ذلك في Text[10] وPattern[6]. نأخذ الآن السلسلة النصية الجزئية المُطابِقة من النمط قبل نقطة الانهيار (أي abcdabc)، ونبحث عن لاحقة فيها تكون أيضًا سابقة للنمط. تستطيع ملاحظة أنّ ab هي لاحقة لهذه السلسلة النصية الجزئية وسابقة لها في الوقت نفسه. وبما أنّ المطابقة السابقة استمرت حتى Text[10]، فإنّ الحرفين اللذين يسبقان موضع عدم التطابق هما ab. هنا نستنتج أنّه لما كانت ab أيضًا سابقة للسلسلة النصية الجزئية التي أخذناها، فلن يكون علينا التحقق من ab مرة أخرى، ويمكن أن يبدأ الفحص التالي من Text[10] وPattern[2]، كذلك ليس علينا أن نعود حيث كنا إذ يمكننا أن نبدأ مباشرة من موضع انهيار التطابق. علينا الآن التحقق من Text[10] وPattern[2]، بما أنّهما غير متطابقتين وليس للجزء المطابَق من النمط أي لاحقة هي نفسها سابقة للنمط، فسيكون علينا أن نتحقق من Text[10] وPattern[0]، ولكنّها لا يتطابقان أيضًا. بعد ذلك نجد تطابقًا ابتداءً من الفهرس 11 وحتى الفهرس 17 في النص، ومن الفهرس 0 إلى 6. لكن ينهار التطابق مجددًا في Text[18] وPattern[7]. مرّة أخرى علينا التحقق من السلسلة النصية الجزئية قبل انهيار التطابق (أي abcdabc) لنجد أنّ abc هي لاحقة وسابقة في الوقت نفسه، لذا ينبغي أن تكون abc قبل Text[18] بما أنّ التطابق السابق استمر حتى الموضع Pattern[7]، وهذا يعني أننا لسنا بحاجة إلى الموازنة حتى الموضع Text[18]، إذ ستبدأ الموازنة من Text[18] وPattern[3]. سنجد الآن تطابقًا، ونعيد 15، وهو الفهرس الذي يبدأ عنده التطابق. هذه هي طريقة عمل خوارزمية KMP. والآن، كيف نتحقّق مما إذا كانت اللاحقة تساوي السابقة، وعند أي نقطة نبدأ التحقق مما إذا كان هناك عدم تطابق للحروف بين النص والنمط؟ لنلق نظرة على المثال التالي: +-------+----+----+----+----+----+----+----+----+ | Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +-------+----+----+----+----+----+----+----+----+ | Text | a | b | c | d | a | b | c | a | +-------+----+----+----+----+----+----+----+----+ سننشئ مصفوفةً تحتوي المعلومات الضرورية وسنسميها المصفوفة S، حجم هذه المصفوفة يساوي طول النمط، ولا يمكن أن يكون الحرف الأول من النمط لاحقة لأيّ سابقة، لذا نضع S[0] = 0. نأخذ i = 1 و j = 0 في البداية، نوازن في كل خطوة بين Pattern[i] وPattern[j] ونزيد قيمة i بواحد، فإذا كان هناك تطابق نضع S[i] = j + 1 ونزيد قيمة j، وإن لم يكن فنتحقق من قيمة الموضع السابق لـ j -إن وُجد- ونضع j = S [j-1] -إذا كانت j تخالف 0-، ونستمر في هذا إلى أن يحدث عدم تطابق بين S[i] [j] و S ، أو تخالف j القيمة 0. في الحالة الأخيرة نضع S[i] = 0. نعود إلى المثال السابق: j i +-------+----+----+----+----+----+----+----+----+ | Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +-------+----+----+----+----+----+----+----+----+ | Text | a | b | c | d | a | b | c | a | +-------+----+----+----+----+----+----+----+----+ لا يتطابق Pattern[i] وPattern[j]، لذا نزيد قيمة i بواحد، وبما أنّ j تساوي 0، فلن نتحقق من القيمة السابقة، وسنضع Pattern[i] = 0. سنحصل على تطابق عند الموضع i = 4 إذا واصلنا زيادة i، لذلك نضع [i] = S[4] = j + 1 = 0 + 1 = 1 ثمّ نزيد قيمتي j و i. ستبدو المصفوفة هكذا: j i +-------+----+----+----+----+----+----+----+----+ | Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +-------+----+----+----+----+----+----+----+----+ | Text | a | b | c | d | a | b | c | a | +-------+----+----+----+----+----+----+----+----+ | S | 0 | 0 | 0 | 0 | 1 | | | | +-------+----+----+----+----+----+----+----+----+ بما أن Pattern[5] وPattern[1] متطابقتين، فإننا نضع S[i] = S [5] = j + 1 = 1 + 1 = 2. إذا تابعنا سنجد عدم تطابق في الموضعين j = 3 و i = 7. وبما أنّ j لا تساوي 0، فإننا نضع j = S [j-1] ونوازن الحرفين الموجودين عند الموضعين i و j، ولأنها متماثلان فإننا نضع S[i] = j + 1. ستبدو المصفوفة النهائية كما يلي: +---------+---+---+---+---+---+---+---+---+ | S | 0 | 0 | 0 | 0 | 1 | 2 | 3 | 1 | +---------+---+---+---+---+---+---+---+---+ هذه هي المصفوفة المطلوبة. إن كانت S[i] تخالف الصفر فذلك يعني أنّ هناك سلسلة نصية طولها S[i] هي لاحقة وسابقة في الوقت نفسه للسلسلة النصية الجزئية من النمط التي تبدأ من 0 وتنتهي عند i. نبدأ الموازنة التالية من الموضع S[i] + 1 في النمط. هذه شيفرة عامة لخوارزمية توليد المصفوفة: Procedure GenerateSuffixArray(Pattern): i := 1 j := 0 n := Pattern.length while i is less than n if Pattern[i] is equal to Pattern[j] S[i] := j + 1 j := j + 1 i := i + 1 else if j is not equal to 0 j := S[j-1] else S[i] := 0 i := i + 1 end if end if end while التعقيد الزمني لعملية بناء المصفوفة هو O(n)، والتعقيد المساحي يساوي أيضًا O(n). للتأكد من أنك قد فهمت الخوارزمية، حاول إنشاء مصفوفة للنمط aabaabaa، وتحقق مما إذا كانت النتيجة التي حصلت عليها تتطابق مع هذه النتيجة: +---------+---+---+---+---+---+---+---+---+---+ | S | 0 | 1 | 0 | 1 | 2 | 3 | 4 | 5 | 2 | +---------+---+---+---+---+---+---+---+---+---+ انظر إلى المثال التالي: +---------+---+---+---+---+---+---+---+---+---+---+---+---+ | Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |10 |11 | +---------+---+---+---+---+---+---+---+---+---+---+---+---+ | Text | a | b | x | a | b | c | a | b | c | a | b | y | +---------+---+---+---+---+---+---+---+---+---+---+---+---+ +---------+---+---+---+---+---+----+ | Index | 0 | 1 | 2 | 3 | 4 | 5 | +---------+---+---+---+---+---+----+ | Pattern | a | b | c | a | b | y | +---------+---+---+---+---+---+----+ | S | 0 | 0 | 0 | 1 | 2 | 0 | +---------+---+---+---+---+---+----+ لدينا سلسلة نصية ونمط ومصفوفة S محسوبة قبليًا باستخدام الطريقة التي شرحناها سابقًا. سوف نوازن Text[0] وPattern[0] وهما متطابقان، كما أنّ Text[1] وPattern[1] متطابقان كذلك؛ أمّا Text[2] وPattern[2]، فهما غير متطابقين. نتحقق من القيمة الموجودة عند الموضع الذي يسبق موضع عدم التطابق. ولمّا كانت S [1] تساوي 0، فذلك يعني أنّه لا توجد لاحقة تطابق سابقة في السلسلة النصية الجزئية الحالية، لذا تبدأ الموازنة عند الموضع S [1]، وهو 0. وهكذا نجد أنّ Text[2] وPattern[0] غير متطابقتين، لذا نستمر لنجد أنّ Text[3] وPattern[0] متطابقتان، ويستمر التطابق حتى Text[8] وPattern[5]. نعود الآن خطوةً واحدةً إلى الوراء في المصفوفة S لنجد القيمة 2، هذا يعني أنّ هناك سابقة طولها 2 وهي أيضًا لاحقة في السلسلة النصية الجزئية الحالية abcab، وهي ab. هذا يعني أنّ السلسلة النصية ab موجودة قبل Text[8]. وعلى ذلك نستطيع تجاهلPattern[1] وPattern[0] وبدء الموازنة التالية من الموضعين Text[8] وPattern[2]. إذا تابعنا بذلك النسق فسنجد النمط داخل النص، وستبدو الشيفرة لتلك الإجراءات كما يلي: Procedure KMP(Text, Pattern) GenerateSuffixArray(Pattern) m := Text.Length n := Pattern.Length i := 0 j := 0 while i is less than m if Pattern[j] is equal to Text[i] j := j + 1 i := i + 1 if j is equal to n Return (j-i) else if i < m and Pattern[j] is not equal t Text[i] if j is not equal to 0 j = S[j-1] else i := i + 1 end if end if end while Return -1 التعقيد الزمني لهذه الخوارزمية -بصرف النظر عن العمليات الحسابية المتعلقة بحساب مصفوفة اللاحقات- هو O(m)، وبما أنّ دالة GenerateSuffixArray تستغرق وقتًا قدره O (n)، فإن التعقيد الزمني الإجمالي لخوارزمية KMP هو: O (m+n). خوارزمية رابين كارب Rabin-Karp خوارزمية رابين-كارب Rabin-Karp هي خوارزمية بحث في السلاسل النصية أُنشِئت على يد ريتشارد كارب Richard M. Karp ومايكل رابِن Michael Rabin، وتستخدم هذه الخوارزمية دالة التعمية hashing للتحقق من وجود مجموعة من الأنماط في نص ما. ونقول أنّ s سلسلة نصية جزئية من S إذا كانت s مُتضمّنة في S. على سبيل المثال، ver هي سلسلة نصية جزئية من stackoverflow. لكن لا ينبغي الخلط بين هذا المفهوم، وبين مفهوم التتابع الجزئئ subsequence الذي لا يشترط أن تكون حروفه متلاصقةً في النص، فمثلًا، تُعَد cover تتابعًا جزئيًا في stackoverflow، لكنها ليست سلسلةً نصيةً جزئيةً منها. في خوارزمية Rabin-Karp، سنحسب تجزئة النمط الذي نبحث عنه، ثمّ نتحقق مما إذا كانت هناك تعمية تداولية rolling hash في النص تتوافق مع النمط أم لا، وإذا لم تتطابق فذلك يضمن لنا أنّ النمط غير موجود في النص، أما في حال كان هناك تطابق فربّما يكون النمط موجودًا في النص. دعنا نلقي نظرةً على المثال التالي: لنفترض أنّ النص يساوي yeminsajid، وأننا نريد أن نعرف إن كان هذا النص يحتوي النمط nsa. لكي نحسب التجزئة والتجزئة التداولية سنحتاج إلى استخدام عدد أولي، ويمكنك هنا اختيار أيّ عدد أولي وليكن العدد Prime = 11. نحسب قيمة التجزئة باستخدام هذه الصيغة: (1st letter) X (prime) + (2nd letter) X (prime)¹ + (3rd letter) X (prime)² X + ...... لكل حرف رقم يميزه في قائمة الحروف الهجائية: a -> 1 g -> 7 m -> 13 s -> 19 y -> 25 b -> 2 h -> 8 n -> 14 t -> 20 z -> 26 c -> 3 i -> 9 o -> 15 u -> 21 d -> 4 j -> 10 p -> 16 v -> 22 e -> 5 k -> 11 q -> 17 w -> 23 f -> 6 l -> 12 r -> 18 x -> 24 قيمة تجزئة nsa هي: 14 X 11⁰ + 19 X 11¹ + 1 X 11² = 344 الآن نحسب التجزئة التداولية للنص، أي نحسب تجزئة كل سلسلة نصية جزئية مؤلفة من 3 حروف في النص، فإذا تطابقت التجزئة التداولية مع قيمة تجزئة النمط، نتحقق مما إذا كانت السلسلتان النصيّتان متطابقتان أم لا، وبما أنّ النمط يحتوي على 3 أحرف، فإننا نأخذ أوّل ثلاثة أحرف من النص yem ونحسب قيمة تجزئتها، فنحصل على: 25 X 11⁰ + 5 X 11¹ + 13 X 11² = 1653 لا تتطابق هذه القيمة مع قيمة تجزئة النمط، لذا فإنّ السلسلة النصية غير موجودة هنا، وسننتقل الآن إلى الخطوة التالية ونحسب قيمة التجزئة الخاصة بالسلسلة النصية emi التالية باستخدام الصيغة أعلاه. مشكلة هذا المنظور هي أنّ حساب التجزئة في كل مرة مكلف جدًا، لكن هناك تقنية يمكن أن تسرّع العملية برمّتها، وخطواتها كالآتي: نطرح قيمة الحرف الأول من السلسلة النصية السابقة (التي حسبنا تجزئتها قبيل قليل) من قيمة التجزئة الحالية، والتي هي فـي هـذه الحالـة y. وسنحصل على 1653 - 25 = 1628. نقسّم الناتج على العدد الأولي 11 الذي اخترناه سابقًا، وسنحصل على 1628 / 11 = 148. نضيف ناتج العملية (ترتيب الحرف الجديد) X 11m-y، حيث يمثّل m طول النمط، أما ترتيب الحرف الجديد فيساوي ترتيب i، وهو 9. نحصل على 112 = 1237 قيمة التجزئة الجديدة لا تساوي قيمة التجزئة الخاصة بالنمط، لذا نستمر بالبحث على المنوال نفسه، وبالنسبة للحرف n نحصل على: Previous String: emi // السلسلة النصية السابقة First Letter of Previous String: e(5) // الحرف الأول من السلسلة النصية السابقة New Letter: n(14) // الحرف الجديد New String: "min" // السلسلة النصية الجديدة 1237 - 5 = 1232 1232 / 11 = 112 112 + 14 X 11² = 1806 ليس هناك تطابق أيضًا، لذا سنأخذ الآن الحرف s: Previous String: min // السلسلة النصية السابقة First Letter of Previous String: m(13) // الحرف الأول من السلسلة النصية السابقة New Letter: s(19) // الحرف الجديد New String: "ins" // السلسلة النصية الجديدة 1806 - 13 = 1793 1793 / 11 = 163 163 + 19 X 11² = 2462 ليس هناك تطابق أيضًا. بعد ذلك، نأخذ a فنحصل على: Previous String: ins // السلسلة النصية السابقة First Letter of Previous String: i(9) // الحرف الأول من السلسلة النصية السابقة New Letter: a(1) // الحرف الجديد New String: "nsa" // السلسلة النصية الجديدة 2462 - 9 = 2453 2453 / 11 = 223 223 + 1 X 11² = 344 هنا حصل تطابق، لذا سنوازن الآن النمط بالسلسلة النصية الحالية، وبما أنّ كلا السلسلتين متطابقتين نستنج أنّ السلسلة النصية الجزئية موجودة في النص، ونعيد موضع بداية السلسلة النصية الجزئية. انظر المثال التوضيحي التالي: حساب التجزئة: Procedure Calculate-Hash(String, Prime, x): hash := 0 // هنا، تمثل x الطول for m from 1 to x // البحث عن قيمة التجزئة hash := hash + (Value of String[m])⁻¹ end for Return hash إعادة حساب التجزئة، ستمثل Curr هنا الحرف الأول من السلسلة النصية السابقة: Procedure Recalculate-Hash(String, Curr, Prime, Hash): Hash := Hash - Value of String[Curr] Hash := Hash / Prime m := String.length New := Curr + m - 1 Hash := Hash + (Value of String[New])⁻¹ Return Hash مطابقة السلسلة النصية: Procedure String-Match(Text, Pattern, m): for i from m to Pattern-length + m - 1 if Text[i] is not equal to Pattern[i] Return false end if end for Return true منظور رابين كارب: Procedure Rabin-Karp(Text, Pattern, Prime): m := Pattern.Length HashValue := Calculate-Hash(Pattern, Prime, m) CurrValue := Calculate-Hash(Pattern, Prime, m) for i from 1 to Text.length - m if HashValue == CurrValue and String-Match(Text, Pattern, i) is true Return i end if CurrValue := Recalculate-Hash(String, i+1, Prime, CurrValue) end for Return -1 ستعيد الخوارزمية -1 إذا لم تعثر على أي تطابق. تُستخدم هذه الخوارزمية للكشف عن الانتحال، إذ نعطيها مادةً نصيةً مصدريةً، فتبحث بسرعة في مقالة عما إذا كانت المقالة تحتوي جملًا أو عبارات من المادة المصدرية متجاهلةً تفاصيلًا معينةً من قبيل علامات الترقيم وحالة الحروف (أو تشكيلها). عادةً ما تكون هناك الكثير من السلاسل النصية (الجمل) التي ينبغي أن نبحث عنها، لذا فإنّ خوارزميات البحث التقليدية التي تبحث عن سلسلة نصية واحدة مثل خوارزمية Knuth- Morris-Pratt أو خوارزمية Boyer-Moore String، لن تكون عمليّةً هنا، لهذا يُفضّل استخدام خوارزمية Rabin-Karp. لكن من المهم أن تنتبه إلى أنّ خوارزمية Knuth- Morris-Pratt وخوارزمية Boyer-Moore String أسرع من خوارزمية Rabin-Karp. هناك تطبيقات أخرى كثيرة لخوارزمية Rabin-Karp، حيث يمكنك استخدامها مثلًا للعثور على السلاسل النصية الرقمية من طول معين في النص وموجودة بعدد ما وليكن k، وذلك عبر إجراء بعض التعديلات البسيطة على الخوارزمية. بالنسبة لنص طوله n، و p نمط مجموع أطوالها يساوي m، فإنّ حالتي التعقيد المتوسطة والفضلى تساويانO (n + m) ، كما تحتاج الخوازمية إلى مساحة O (p)؛ أما حالة التعقيد الأسوأ فتُساوي O (nm). هذا تطبيق بلغة بايثون على خوارزمية KMP: Haystack: السلسلة النصية التي سنبحث فيها. Needle: النمط المراد البحث عنه. التعقيد الزمني: التعقيد الزمني للجزء الذي يُجري عمليات البحث (التابع strstr) هو O (n)، حيث يمثّل n طول الوسيط haystack، ولكن لمّا كان الوسيط needle يُحلَّل قبليا لأجل بناء جدول السوابق prefix table، فسنحتاج مدة O (m)، وهي المدة المطلوب لبناء الجدول، حيث يمثل m طول needle، وهكذا فإنّ التعقيد الزمني الكلي لخوارزمية KMP يساوي O (n + m). تعقيد المساحة: تحتاج الخوارزمية إلى مساحة بحجم O (m) لأجل بناء جدول السوابق. def get_prefix_table(needle): prefix_set = set() n = len(needle) prefix_table = [0]*n delimeter = 1 while(delimeter<n): prefix_set.add(needle[:delimeter]) j = 1 while(j<delimeter+1): if needle[j:delimeter+1] in prefix_set: prefix_table[delimeter] = delimeter - j + 1 break j += 1 delimeter += 1 return prefix_table def strstr(haystack, needle): # يمثل m الموضع في S حيث يبدأ التطابق القادم لـ W # يمثل i فهرس المحرف الحالي W haystack_len = len(haystack) needle_len = len(needle) if (needle_len > haystack_len) or (not haystack_len) or (not needle_len): return -1 prefix_table = get_prefix_table(needle) m = i = 0 while((i<needle_len) and (m<haystack_len)): if haystack[m] == needle[i]: i += 1 m += 1 else: if i != 0: i = prefix_table[i-1] else: m += 1 if i==needle_len and haystack[m-1] == needle[i-1]: return m - needle_len else: return -1 if __name__ == '__main__': needle = 'abcaby' haystack = 'abxabcabcaby' print strstr(haystack, needle) هذا تطبيق على خوارزمية KMP في لغة C، الوسيط txt يمثل النص، فيما يمثل الوسيط pat النمط، يطبع هذا البرنامج كل ظهور للنمط pat في النص txt. مثلا، إن كان الدخل يساوي: txt[] = "THIS IS A TEST TEXT" pat[] = "TEST" فسيطبع البرنامج الخرج التالي: Pattern found at index 10 بالنسبة للدخل: txt[] = "AABAACAADAABAAABAA" pat[] = "AABA" يطبع البرنامج الخرج التالي: Pattern found at index 0 Pattern found at index 9 Pattern found at index 13 هذا هو المثال التطبيقي على استخدام خورازمية KMP في لغة C: #include<stdio.h> #include<string.h> #include<stdlib.h> void computeLPSArray(char *pat, int M, int *lps); void KMPSearch(char *pat, char *txt) { int M = strlen(pat); int N = strlen(txt); هنا ستنشئ []lps لتخزين أطول قيم سابقة لاحقة للنمط، نتابع … int *lps = (int *)malloc(sizeof(int)*M); int j = 0; // pat[] فهرس لـ الحساب القبلي للنمط، أي حساب مصفوفة []lps، نتابع … computeLPSArray(pat, M, lps); int i = 0; // txt[] فهرس while (i < N) { if (pat[j] == txt[i]) { j++; i++; } if (j == M) { printf("Found pattern at index %d \n", i-j); j = lps[j-1]; } // تطابق j العثور على عدم تطابق بعد else if (i < N && pat[j] != txt[i]) { // lps[0..lps[j-1]] لا تحاول التحقق من مطابقة الحروف // لأننا نعلم أنها مطابقة if (j != 0) j = lps[j-1]; else i = i+1; } } free(lps); // لتجنب تسرب الذاكرة } void computeLPSArray(char *pat, int M, int *lps) { int len = 0; // حجم أطول سابقة-لاحقة حالية int i; lps[0] = 0; // يساوي 0 دائما lps[0] i = 1; while (i < M) { if (pat[i] == pat[len]) { len++; lps[i] = len; i++; } else // (pat[i] != pat[len]) { if (len != 0) { // هنا فخ، انظر المثال التالي // AAACAAAA و i = 7. len = lps[len-1]; // هنا i لاحظ أيضا أننا لم نزد قيمة } else // if (len == 0) { lps[i] = 0; i++; } } } } الخرج الناتج هو الآتي: Found pattern at index 10 ترجمة -بتصرّف- للفصل 40 من الكتاب Algorithms Notes for Professionals. اقرأ أيضًا خوارزميات الترتيب وأشهرها أمثلة عن أنواع الخوارزميات خوارزميات تحليل المسارات في الأشجار خوارزمية ديكسترا Dijkstra’s Algorithm
-
سوف نستعرض في هذه المقالة خوارِزميتان من خوارزميات البحث الشهيرة، وهما خوارزمية البحث الثنائي، وخوارزمية البحث الخطي، مع تحليلهما وشرح آلية عملهما. البحث الثنائي Binary Search خوارزمية البحث الثنائي هي خوارزمية بحث في المصفوفات المرتبة تعتمد منظور فرق تسد، وتحتاج هذه الخوارزمية مدة قدرها O(log n) للعثور على موقع العنصر المبحوث عنه في المصفوفة المرتبة، حيث يمثّل n مساحة المَبحث، أي المصفوفة التي نبحث فيها. تبدأ خوارزمية البحث الثنائي في كل خطوة بالتحقق من القيمة الموجودة في منتصف المصفوفة mid، فإن كانت تساوي العنصر المبحوث عنه فإنها تتوقف وتعيد فهرسه؛ وإلا تقسّم المصفوفة إلى اثنتين هما مصفوفة [mid,b] ومصفوفة [a,mid]، حيث يمثل a و b طرفي المصفوفة. وإن كان العنصر المبحوث عنه موجودا في المصفوفة وكان أكبر من mid، فهذا يعني أنّه سيكون لا محالة في الجزء [mid,b] لأنّ المصفوفة مرتبة؛ أما إذا كان العنصر المبحوث عنه أصغر من mid فهذا يعني أنّه سيكون في الجزء [a,mid]. نختار الآن الجزء المناسب، ونعيد تطبيق الخوارزمية عليه، ونستمر في هذه العملية إلى أن نجد العنصر المبحوث عنه أو تتساوى قيمة mid مع قيمة a، فإن لم تكن قيمة mid تساوي العنصر المبحوث، فهذا يعني أنّه غير موجود في المصفوفة. إليك مثالًا توضيحيًا؛ لنفترض أنّك خبير اقتصادي وقد تمّ تكليفك بمهمة تحديد سعر التوازن equilibrium price (أي السعر الذي يحقق المعادلة العرض = الطلب) لسلعة الأرز. تذكّر أنه كلما زاد السعر، زاد العرض وقلّ الطلب. لنفترضّ أنّ الشركة التي تعمل فيها توفر لك جميع المعطيات المتعلقة بالعرض والطلب المتعلقين بالأرز عندما يستقر سعر الأرز عند قيمة معيّنة p، فعندئذ تستطيع الحصول على العرض والطلب بوحدات الأرز. ويريد رئيسك أن تحسب سعر التوازن في أسرع وقت ممكن، لكن سينبّهك إلى أنّ سعر التوازن يجب أن يكون عددًا صحيحًا موجبًا لا يتجاوز 10^17، كما انّه يضمن لك أنّه سيكون هناك حل عددي صحيح موجب واحد بالضبط في هذا النطاق. يُسمح لك باستدعاء الدالتين getSupply(k) و getDemand(k)، وَاللتان تعيدان كمية العرض وحجم الطلب بالنسبة للسعر K على التوالي. المبحث أو مساحة البحث هنا هي مصفوفة الأعداد الصحيحة من 1 إلى 10^17. ويكون البحث الخطي في هذه الحالة غير مناسب لأنّ المصفوفة مرتّبة. لاحظ أنه كلما ارتفعت قيمة k، ستزيد قيمة getSupply(k)، وتنخفض قيمة getDemand(k)، ومن ثم تكون لدينا المتراجحة التالية: أي أن زيادة السعر يصاحبها زيادة في الفرق بين العرض والطلب، وهذا منطقي ومتوقع. أيضًا، بما أنّ مساحة المبحث (المصفوفة) مرتّبة، فيمكننا استخدام خوارزمية البحث الثنائي. المثال التوضيحي التالي يبين كيفية استخدام خوارزمية البحث الثنائي: high = 100000000000000000 <- الحد الأقصى لمساحة المبحث low = 1 <- الحد الأدنى لمساحة المبحث while high - low > 1 mid = (high + low) / 2 <- خذ القيمة الوسطى supply = getSupply(mid) demand = getDemand(mid) if supply > demand high = mid <- الحل موجود في النصف السفلي من مساحة المبحث else if demand > supply low = mid <- الحل موجود في النصف العلوي من مساحة المبحث else <- الشرط: العرض == الطلب return mid <- العثور على الحل تستغرق هذه الخوارزمية وقتًا مقداره ~O(log 10^17). وعمومًا، يساوي تعقيد خوارزمية الترتيب الثنائي ~O(log S)، حيث يمثّل S حجم مصفوفة البحث، لأنّنا نقلّص مساحة البحث إلى النصف في كل تكرار للحلقة while، (من [low: high] إلى [low: mid] أو [mid: high] ). فيما يلي تطبيق على خوارزمية البحث الثنائي يستخدم التكرارية، في لغة 😄 int binsearch(int a[], int x, int low, int high) { int mid; if (low > high) return -1; mid = (low + high) / 2; if (x == a[mid]) { return (mid); } else if (x < a[mid]) { binsearch(a, x, low, mid - 1); } else { binsearch(a, x, mid + 1, high); } } خوارزمية رابِن كارب Rabin Karp خوارزمية رابِن كارب هي خوارزمية بحث نصية تستخدم دالة تعمية hashing للعثور على نمط معيّن من مجموعة من الأنماط النصية في سلسلة نصية، ومتوسط أوقات تشغيلها وأفضلها يساوي المدة O(n + m) في المساحة (O(p، حيث يمثّل n طول السلسلة النصية، فيما يمثّل m طول النمط. بالمقابل، فأسوأ أوقات تشغيلها يساوي O(nm). هذا تطبيق لخوارزمية مطابقة السلاسل النصية في لغة جافا، حيث يمثل q عددًا أوليًا، و p يمثل قيمة التجزئة الخاصة بالنمط، و t قيمة التجزئة الخاصة بالنص، و d يمثل عدد الحروف الأبجدية. void RabinfindPattern(String text,String pattern){ int d=128; int q=100; int n=text.length(); int m=pattern.length(); int t=0,p=0; int h=1; int i,j; //دالة حساب قيمة التجزئة for (i=0;i<m-1;i++) h = (h*d)%q; for (i=0;i<m;i++){ p = (d*p + pattern.charAt(i))%q; t = (d*t + text.charAt(i))%q; } // البحث عن النمط for(i=0;i<end-m;i++){ if(p==t){ // إن تطابقت قيمة التجزئة، فقارن النمط حرفا حرفا for(j=0;j<m;j++) if(text.charAt(j+i)!=pattern.charAt(j)) break; if(j==m && i>=start) System.out.println("Pattern match found at index "+i); } if(i<end-m){ t =(d*(t - text.charAt(i)*h) + text.charAt(i+m))%q; if(t<0) t=t+q; } } } نقسّم قيمة التجزئة على عدد أولي لتجنب وقوع أيّ تداخل، إذ أنّ القسمة على الأعداد الأولية تقلّل احتمال حدوث تداخل، ولكنّها لا تعدمه، فلا يزال احتمال أن يكون لسلسلتين نصيتين مختلفتين قيمة التجزئة نفسها قائمًا. ولأجل هذا، يجب أن تتحقق من النمط حرفًا حرفًا عند العثور على تطابق، من أجل التأكد أنك حصلت على تطابق تام. تعيد المعادلة التالية حساب قيمة التجزئة الخاصة بالنمط، أولًا عن طريق إزالة الحرف الموجود في أقصى اليسار، ثم إضافة الحرف الجديد من النص. t =(d*(t - text.charAt(i)*h) + text.charAt(i+m))%q; تحليل الحالات التعقيدية لخوارزمية البحث الخطي كل خوارزمية لها ثلاث حالات تعقيد: أسوأ حالة. الحالة المتوسطة. أفضل حالة. #include <stdio.h> // البحث عن x في arr[]، في حال العثور على x، تعيد الدالة الفهرس // وإلا أعِد -1 int search(int arr[], int n, int x) { int i; for (i=0; i<n; i++) { if (arr[i] == x) return i; } return -1; } برنامج مشغِّل لاختبار الدوال أعلاه: int main() { int arr[] = {1, 10, 30, 15}; int x = 30; int n = sizeof(arr)/sizeof(arr[0]); printf("%d is present at index %d", x, search(arr, n, x)); getchar(); return 0; تحليل أسوأ حالة عند تحليل الحالة الأسوأ نحسب الحد الأعلى لوقت تشغيل الخوارزمية، ويجب أن نعرف الحالة التي تجعل الخوارزمية تنفّذ أقصى عدد من العمليات. وبالنسبة لخوارزمية البحث الخطي، تحدث أسوأ حالة عندما لا يكون العنصر المبحوث عنه (x) موجودًا في المصفوفة، فحينئذ سوف تقارنه دالة البحث search() مع جميع عناصر المصفوفة المبحوث فيها واحدًا تلو الآخر. وعليه، فإنّ أسوأ حالة تعقيدية في خوارزمية البحث الخطي تساوي Θ(n). تحليل الحالة التعقيدية المتوسطة عند تحليل الحالة التعقيدية المتوسطة، نأخذ جميع المدخلات الممكنة ونحسب أوقات تنفيذها بالنسبة لجميع المدخلات، ثمّ نجمع كل القيم المحسوبة ونقسّم المجموع على إجمالي عدد المدخلات. سيكون علينا أن نقدّر توزيع الحالات الممكنة. لنفترض أنّ جميع الحالات مُوزّعة توزيعًا منتظمًا uniformly distributed -بما في ذلك الحالات التي لا يكون فيها x ضمن المصفوفة-، ثم نجمع جميع تلك الحالات ونقسّم المجموع على (n +1). هذه صيغة الحالة التعقيدية المتوسطة: تحليل أفضل حالة عند تحليل أفضل حالة تعقيدية، نحسب الحد الأدنى لأوقات تشغيل الخوارزمية، ويجب أن نعرف الحالة التي تجعل الخوارزمية تنفّذ أقل عدد من العمليات. تحدث أفضل حالة في مشكلة البحث الخطي عندما يكون x في الموضع الأول، ففي هذه الحالة، سيكون عدد العمليات المُنفّذة ثابتًا (مهما كانت قيمة n)، لذا فإنّ التعقيد الزمني لأفضل حالة يساوي Θ(1). عادةً ما نحلل أداء الخوارزمية بناءً على الحالة الأسوأ، إذ أنّ الحالة الأسوأ تعطينا فكرةً عن أقصى وقت يمكن أن تستغرقه الخوارزمية، وهذه معلومة حيوية؛ بالمقابل، لا يكون حساب الحالة المتوسطة سهلًا في معظم الحالات، وعليه فنادرًا ما نلجأ إليه، وذلك لأنه من أجل إجراء تحليل الحالة المتوسطة سيكون علينا أن نعرف التوزيع الرياضي لجميع المدخلات الممكنة، وذلك من الصعوبة بمكان. بالمقابل، لا يُعَد تحليل أفضل حالة مجديًا، إذ لن نستفيد شيئًا من معرفة الحد الأدنى الذي تستغرقه الخوارزمية، لأنه في الحالة الأسوأ، قد تستغرق الخوارزمية سنوات. في بعض الخوارزميات، تكون جميع الحالات متماثلة تقريبًا، إذ لا توجد حالة أسوأ وحالة فضلى، نجد هذا على سبيل المثال في خوارزمية الترتيب بالدمج، إذ ينفّذ الترتيب بالدمج عدد Θ(nLogn) عملية في جميع الحالات. تتمايز الحالة الأسوأ عن الفضلى في معظم خوارزميات الترتيب الأخرى، ففي التطبيق التقليدي مثلًا لخوارزمية الترتيب السريع -حيث يُختار المحور في الركن-، تحدث أسوأ حالة عندما تكون المصفوفة المراد ترتيبها مرتّبة سلفا، فيما تحدث أفضل حالة عندما يقسّم المحور المصفوفة إلى نصفين دائمًا؛ أما بالنسبة لخوارِزمية الترتيب بالإدراج، تحدث الحالة الأسوأ عندما تكون المصفوفة المراد ترتيبها مرتبةً ترتيبًا معكوسًا، فيما تحدث أفضل حالة عند تكون المصفوفة مُرتبةً في الاتجاه المراد. تطبيق خوارزمية البحث الثنائي على أعداد مرتبة هذا مثال توضيحي لتطبيق خوارزمية البحث الثنائي على الأعداد: int array[1000] = { sorted list of numbers }; int N = 100; // عدد المدخلات في مساحة المبحث int high, low, mid; // قيم مؤقتة int x; // القيمة المبحوث عنها low = 0; high = N -1; while(low < high) { mid = (low + high)/2; if(array[mid] < x) low = mid + 1; else high = mid; } if(array[low] == x) // low العثور على العنصر عند الفهرس else // لم نعثر على العنصر لا تحاول العودة مبكرًا بموازنة العنصر الأوسط array[mid] بـ x، فذلك سيضيف عملية موازنة زائدة تبطئ الخوارزمية. لاحظ أن عليك إضافة 1 إلى قيمة low لتجنب أن يُقرّب ناتج القسمة الصحيحة إلى أسفل rounding down. يتيح الإصدار أعلاه من البحث الثنائي إمكانية إيجاد أصغر فهرس لظهور x في المصفوفة في حال كانت تحتوي عدة نسخ من x، ويمكن تعديل الخوارزمية لجعلها تعيد أكبر فهرس كما تبيّن الشيفرة التالية: while(low < high) { mid = low + ((high - low) / 2); if(array[mid] < x || (array[mid] == x && array[mid + 1] == x)) low = mid + 1; else high = mid; } لاحظ أنه بدلًا من إجراء العملية التالية: mid = (low + high) / 2 فقد يكون الأفضل إجراء العملية الآتية: mid = low + ((high - low) / 2) في التطبيقات التي تستهدف لغات مثل Java لتقليل خطر حصول طفح overflow في حال كانت المدخلات كبيرة الحجم. البحث الخطي linear search البحث الخطي هي خوارزمية بسيطة، فهي تمرّ على جميع عناصر المصفوفة حتى تعثر على العنصر المبحوث عنه. تعقيد هذه الخوارزمية يساوي O(n)، حيث يمثّل n عدد العناصر. قد تسأل لماذا O(n)؟ لأنّه في أسوأ حالة، سيكون عليك المرور على جميع عناصر المصفوفة، أي على n عنصر. يمكن موازنة منظور خوارزمية البحث الخطي بعملية البحث عن كتاب في رفّ من الكتب، إذ سيكون عليك الاطلاع عليها جميعًا حتى تجد الكتاب الذي تريده. هذا تطبيق لخوارزمية البحث الخطي بلغة بايثون: def linear_search(searchable_list, query): for x in searchable_list: if query == x: return True return False linear_search(['apple', 'banana', 'carrot', 'fig', 'garlic'], 'fig') #returns True ترجمة -بتصرّف- للفصل 39 من الكتاب Algorithms Notes for Professionals. اقرأ أيضًا أمثلة عن أنواع الخوارزميات خوارزمية ديكسترا Dijkstra’s Algorithm خوارزميات تحليل المسارات في الأشجار الخوارزميات الشرهة Greedy Algorithms
-
نستعرض في هذا المقال بعض أشهر الخوارزميات المستخدمة لتحليل المسارات في الأشجار، مثل خوارزمية بْرِم Prim وخوارزمية فلويد-وورشال Floyd-Warshall وخوارِزمية بلمان-فورد Bellman-Ford. خوارزمية برم Prim's Algorithm لنفترض أنّ لدينا 8 منازل، ونريد إعداد خطوط هاتفية بينها بأقل تكلفة ممكنة. لأجل ذلك سننشئ شجرةً حروفها تمثل المنازل، وأضلَاعها تمثّل تكلفة توصيل الخط الهاتفي بين منزلين. سنحاول وصل الخطوط بين جميع المنازل بأقل كلفة ممكنة، ولتحقيق ذلك سنستخدم خوارزمية برم Prim، وهي خوارزمية شرهة تبحث عن أصغر شجرة ممتدة spanning tree في مخطط موزون غير موجّه undirected weighted graph. وهذا يعني أنها تبحث عن مجموعة من الأضلاع التي تشكل شجرة تتضمّن كل العقد، بحيث يكون الوزن الإجمالي لجميع أضلاع الشجرة أقل ما يمكن. طُوِّرت هذه الخوارزمية عام 1930 من قبل عالم الرياضيات التشيكي Vojtěch Jarník، ثم أعاد عالم الحوسبة روبرت كلاي بْرِم اكتشافها ونشرها في عام 1957، وكذلك إيدجر ديكسترا في عام 1959. تُعرف أيضًا باسم خوارزمية DJP وخوارزمية Jarnik وخوارزمية Prim-Jarnik وخوارزمية Prim-Dijsktra. إذا كانت G مخططًا غير موجّه، فنقول أنّ المخطط S هو مخطط فرعي subgraph من G إذا كانت جميع حروفه وأضلَاعه تنتمي إلى G. ونقول أنّ S شجرة ممتدة فقط إذا كانت: تحتوي جميع عقد G. وكانت شجرة، أي أنّها لا تحتوي أيّ دورة cycle، وجميع عقدها متصلة. تحتوى (n-1) ضلعًا، حيث n يمثّل عدد العقد في G. يمكن أن يكون لمخطط ما أكثر من شجرة ممتدة واحدة، والشجرة الممتدة الصغرى لمخطط موزون غير موجّه هي شجرة مجموع أوزان أضلاعها أقل ما يمكن. سنستخدم خوارزمية Prim لإيجاد الشجرة الممتدة الصغرى، وسيساعدنا هذا على حل مشكلة الخطوط الهاتفية في المثال أعلاه. أولاً، سنختار عقدةً ما لتكون العقدة المصدرية source node، ولتكن العقدة 1 مثلًا. سنضيف الآن الضلع الذي ينطلق من 1 وله أقل كلفة إلى المخطط الفرعي، ونلوّن الأضلاع التي أضفناها إلى المخطط الفرعي باللون الأزرق. وسيكون الضلع 1-5 في مثالنا هو الضلع الذي له أقل كلفة. الآن، سنأخذ الضلع الأقل كلفة من بين جميع الأضلاع المُنطلِقة من العقدة 1 أو العقدة 5. وبما أنّنا لوّنّا 1-5 سلفًا، فالضلع المرشّح الثاني هو الضلع 1-2. نأخذ هذه المرة الضلع الأقل كلفةً من بين جميع الأضلاع (غير المُلوّنة) المُنطلقة من العقدة 1 أو العقدة 2 أو العقدة 5، والذي هو في حالتنا 5-4. تحتاج الخطوة التالية إلى تركيز، وفيها سنحاول أن نفعل ما فعلناه سابقا، ونختار من بين جميع الأضلاع (غير المُلوّنة) التي تنطلق من العقدة 1 أو 2 أو 5 أو 4 الضلع الذي له أقل كلفة، وهو 2-4. ولكن انتبه إلى أنّه في حال اختيار هذا الضلع، فسنخلُق دورةً cycle في المخطط الفرعي، ذلك أنّ العقدتين 2 و4 موجودتان سلفًا فيه، لذا فإنّ أخذ الضلع 2-4 ليس الخيار الصحيح. وبدلًا من ذلك سنختار الضلع 4-8. إذا واصلنا على هذا النحو فسنختَار الأضلاع 8-6 و6-7 و4-3، وسيبدو المخطط الفرعي النهائي هكذا: إذا أزلنا الأضلاع التي لم نَخترها (غير المُلوّنة)، فسنحصل على: هذه هي الشجرة الممتدة الصغرى MST التي نبحث عنها، ونستنتج منها أنّ تكلفة إعداد خطوط الهاتف هي: 4 + 2 + 5 + 11 + 9 + 2 + 1 = 34. يمكن أن تكون هناك عدة أشجار ممتدة صغرى للمخطط نفسه بحسب العقدة المصدرية التي اخترناها. انظر شيفرة توضيحية لهذه الخوارزمية، حيث تمثل Graph مخططًا فارغًا متصلًا غير موزون، وتمثل Vnew مخططًا فرعيًا جديدًا عقدته المصدرية هي x: Procedure PrimsMST(Graph): Vnew[] = {x} Enew[] = {} while Vnew is not equal to V u -> a node from Vnew v -> a node that is not in Vnew such that edge u-v has the minimum cost // إذا كان لعقدتين الوزن نفسه، فاختر أيًّا منهما add v to Vnew add edge (u, v) to Enew end while Return Vnew and Enew التعقيد التعقيد الزمني للمنظور البسيط أعلاه هو ( O (V 2، يمكننا تقليل التعقيد باستخدام طابور أولويات priority queue، فإذا أضفنا عقدةً جديدةً إلى المخطط الفرعي الجديد Vnew، فيمكننا إضافة أضلاعها المجاورة إلى رتل الأولويات، ثم إخراج الضلع الموزون ذو الكلفة الأدنى منه. وحينئذ يصبح التعقيد مساويًا للقيمة O (ElogE)، حيث يمثّل E عدد الأضلاع. يمكنك أيضًا إنشاء كَومة ثنائية Binary Heap لتخفيض التعقيد إلى O (ElogV). هذه شيفرة توضيحية تستخدم طابور الأولويات: Procedure MSTPrim(Graph, source): for each u in V key[u] := inf parent[u] := NULL end for key[source] := 0 Q = Priority_Queue() Q = V while Q is not empty u -> Q.pop for each v adjacent to i if v belongs to Q and Edge(u,v) < key[v] // edge(u, v) كلفة Edge(u, v) تمثل parent[v] := u key[v] := Edge(u, v) end if end for end while تخزّن key[] الكلفة الأقل لعبور العقدة v، فيما تُستخدم parent[] لتخزين العقدة الأصلية parent node، وهذا مفيد لتسلّق الشجرة وطباعتها. فيما يلي برنامج بسيط بلغة Java: import java.util.*; public class Graph { private static int infinite = 9999999; int[][] LinkCost; int NNodes; Graph(int[][] mat) { int i, j; NNodes = mat.length; LinkCost = new int[NNodes][NNodes]; for ( i=0; i < NNodes; i++) { for ( j=0; j < NNodes; j++) { LinkCost[i][j] = mat[i][j]; if ( LinkCost[i][j] == 0 ) LinkCost[i][j] = infinite; } } for ( i=0; i < NNodes; i++) { for ( j=0; j < NNodes; j++) if ( LinkCost[i][j] < infinite ) System.out.print( " " + LinkCost[i][j] + " " ); else System.out.print(" * " ); System.out.println(); } } public int unReached(boolean[] r) { boolean done = true; for ( int i = 0; i < r.length; i++ ) if ( r[i] == false ) return i; return -1; } public void Prim( ) { int i, j, k, x, y; boolean[] Reached = new boolean[NNodes]; int[] predNode = new int[NNodes]; Reached[0] = true; for ( k = 1; k < NNodes; k++ ) { Reached[k] = false; } predNode[0] = 0; printReachSet( Reached ); for (k = 1; k < NNodes; k++) { x = y = 0; for ( i = 0; i < NNodes; i++ ) for ( j = 0; j < NNodes; j++ ) { if ( Reached[i] && !Reached[j] && LinkCost[i][j] < LinkCost[x][y] ) { x = i; y = j; } } System.out.println("Min cost edge: (" + + x + "," + + y + ")" + "cost = " + LinkCost[x][y]); predNode[y] = x; Reached[y] = true; printReachSet( Reached ); System.out.println(); } int[] a= predNode; for ( i = 0; i < NNodes; i++ ) System.out.println( a[i] + " --> " + i ); } void printReachSet(boolean[] Reached ) { System.out.print("ReachSet = "); for (int i = 0; i < Reached.length; i++ ) if ( Reached[i] ) System.out.print( i + " "); //System.out.println(); } public static void main(String[] args) { int[][] conn = {{0,3,0,2,0,0,0,0,4}, // 0 {3,0,0,0,0,0,0,4,0}, // 1 {0,0,0,6,0,1,0,2,0}, // 2 {2,0,6,0,1,0,0,0,0}, // 3 {0,0,0,1,0,0,0,0,8}, // 4 {0,0,1,0,0,0,8,0,0}, // 5 {0,0,0,0,0,8,0,0,0}, // 6 {0,4,2,0,0,0,0,0,0}, // 7 {4,0,0,0,8,0,0,0,0} // 8 }; Graph G = new Graph(conn); G.Prim(); } } صرّف الشيفرة أعلاه باستخدام التعليمة javac Graph.java، وسيكون الخرج الناتج كما يلي: $ java Graph * 3 * 2 * * * * 4 3 * * * * * * 4 * * * * 6 * 1 * 2 * 2 * 6 * 1 * * * * * * * 1 * * * * 8 * * 1 * * * 8 * * * * * * * 8 * * * * 4 2 * * * * * * 4 * * * 8 * * * * ReachSet = 0 Min cost edge: (0,3)cost = 2 ReachSet = 0 3 Min cost edge: (3,4)cost = 1 ReachSet = 0 3 4 Min cost edge: (0,1)cost = 3 ReachSet = 0 1 3 4 Min cost edge: (0,8)cost = 4 ReachSet = 0 1 3 4 8 Min cost edge: (1,7)cost = 4 ReachSet = 0 1 3 4 7 8 Min cost edge: (7,2)cost = 2 ReachSet = 0 1 2 3 4 7 8 Min cost edge: (2,5)cost = 1 ReachSet = 0 1 2 3 4 5 7 8 Min cost edge: (5,6)cost = 8 ReachSet = 0 1 2 3 4 5 6 7 8 0 --> 0 0 --> 1 7 --> 2 0 --> 3 3 --> 4 2 --> 5 5 --> 6 1 --> 7 0 --> 8 خوارزمية بلمان- فورد Bellman–Ford خوارزمية بِلمان - فورد Bellman–Ford هي خوارزمية تحاول حساب أقصر المسارات من رأس مصدري source vertex إلى جميع الحروف الأخرى في مخطط موجّه موزون، ورغم أنّ هذه الخوارزمية أبطأ من خوارزمية Dijkstra، إلا أنها تعمل في الحالات التي تكون فيها أوزان الأضلاع سالبة، كما أنّها قادرة على العثور على الدورات ذات الوزن السالب في المخطط، على خلاف خوارزمية Dijkstra التي لا تعمل في حال كانت هناك دورة سالبة، إذ ستستمر في المرور عبر الدورة مرارًا وتكرارًا، وتستمر أيضًا في تقليل المسافة بين الرأسيْن. تتمثل فكرة هذه الخوارزمية في المرور عبر جميع أضلاع المخطط واحدًا تلو الآخر بترتيب عشوائي، شرط أن يحقق الترتيب المعادلة التالية: بعد تحديد الترتيب، سنخفّف الضلع -تخفيف الضلع هو تخفيض تكلفة الوصول إلى رأس معيّن عبر استخدام رأس آخر وسيط- وفقًا لصيغة التخفيف التالية. لكل ضلع u-v من u إلى v: if distance[u] + cost[u][v] < d[v] d[v] = d[u] + cost[u][v] بمعنى أنّه إذا كانت المسافة من المصدر إلى أيّ رأس u + وزن الضلع u-v < المسافة من المصدر إلى رأس ٍآخر v، فسنحدّث المسافة من المصدر إلى v. نحتاج إلى تخفيف الأضلاع (V-1) مرّة على الأكثر، حيث V هو عدد الأضلاع في المخطط. سنشرح لاحقًا لماذا اخترنا العدد (V-1)، ونخزّن أيضًا الرأس الأب parent vertex الخاص بكل الرأس، ونكتب ما يلي في كل مرّة نخفّف ضلعًا: parent[v] = u هذا يعني أننا وجدنا مسارًا آخر أقصر للوصول إلى v عبر u. سنحتاج أيضًا هذه القيمة المُخزّنة لاحقًا لطباعة أقصر مسار من المصدر إلى الرأس المنشود. انظر المثال التالي: لقد اخترنا 1 ليكون الرأس المصدري، والآن نريد العثور على أقصر مسار من هذا المصدر إلى جميع الحروف الأخرى. نكتب في البداية d[1] = 0، لأنّ 1 هو المصدر؛ أما البقيّة فستساوي اللانهاية لأنّنا لا نعرف مسافاتها بعد. سنخفّف الأضلاع في هذا التسلسل: +--------+--------+--------+--------+--------+--------+--------+ | التسلسل | 1 | 2 | 3 | 4 | 5 | 6 | +--------+--------+--------+--------+--------+--------+--------+ | الضلع | 4->5 | 3->4 | 1->3 | 1->4 | 4->6 | 2->3 | +--------+--------+--------+--------+--------+--------+--------+ يمكنك أن تختار أيّ تسلسل تريد، إذا خفّفنا الأضلاع مرّةً واحدة، فسنحصل على المسافات من المصدر إلى جميع الرؤوس الأخرى للمسار الذي يستخدم ضلعًا واحدًا على الأكثر. لنخفّف الآن الأضلاع ونحدث قيم d[]: d[4] + cost[4][5] = infinity + 7 = infinity. لا يمكننا تحديث هذا. d[2] + cost[3][4] = infinity. لا يمكننا تحديث هذا. d[1] + cost[1][3] = 0 + 2 = 2 < d[2] إذًا d[3] = 2 و parent[1] = 1. d[1] + cost[1][4] = 4. إذن d[4] = 4 < d[4]. parent[4] = 1. d[4] + cost[4][6] = 9. d[6] = 9 < d[6]. parent[6] = 4. d[2] + cost[2][3] = infinity لا يمكننا تحديث هذا. تعذّر تحديث بعض الرؤوس نتيجة عدم تحقّق الشرط d[u] + cost[u][v] < d[v]. وكما قلنا سابقًا، فقد حصلنا على المسارات من المصدر إلى العقد الأخرى باستخدام ضلع واحد على الأكثر. سيزوّدنا التكرار الثاني بمسار يستخدم عقدتين: d[4] + cost[4][5] = 12 < d[5]. d[5] = 12. parent[5] = 4. d[3] + cost[3][4] = 1 < d[4]. d[4] = 1. parent[4] = 3 d[3] تبقى بلا تغيير. d[4] تبقى بلا تغيير d[4] + cost[4][6] = 6 < d[6]. d[6] = 6. parent[6] = 4. d[3] تبقى بلا تغيير. هكذا سيبدو المخطط: التكرار الثالث سيحدّث الرأس 5 فقط، حيث سيضع القيمة 8 في d[5]. وسيبدو المخطط هكذا: بعد هذا، ستبقى المسافة كما هي مهما زِدنا من التكرارات، لذلك سنخزّن راية flag للتحقق من وقوع أيّ تحديث أم لا، فإذا لم يقع أيّ تحديث، أوقفنا حلقة التكرار. هذه شيفرة توضيحية: Procedure Bellman-Ford(Graph, source): n := number of vertices in Graph for i from 1 to n d[i] := infinity parent[i] := NULL end for d[source] := 0 for i from 1 to n-1 flag := false for all edges from (u,v) in Graph if d[u] + cost[u][v] < d[v] d[v] := d[u] + cost[u][v] parent[v] := u flag := true end if end for if flag == false break end for Return d لتتبع الدورات السالبة، سنعدّل الشيفرة كما يلي: Procedure Bellman-Ford-With-Negative-Cycle-Detection(Graph, source): n := number of vertices in Graph for i from 1 to n d[i] := infinity parent[i] := NULL end for d[source] := 0 for i from 1 to n-1 flag := false for all edges from (u,v) in Graph if d[u] + cost[u][v] < d[v] d[v] := d[u] + cost[u][v] parent[v] := u flag := true end if end for if flag == false break end for for all edges from (u,v) in Graph if d[u] + cost[u][v] < d[v] Return "Negative Cycle Detected" end if end for Return d لأجل طباعة أقصر مسار إلى رأس معين، سنكّرر خلفيًا إلى الأب parent إلى أن نعثر على القيمة المعدومة NULL، ثمّ نطبع الحروف. انظر الشيفرة التوضيحية التالية: Procedure PathPrinting(u) v := parent[u] if v == NULL return PathPrinting(v) print -> u سيساوي التعقيد الزمني لهذه الخوارزمية O (V * E) إن استخدمنا قائمة تجاور adjacency list بما أننا سنحتاج إلى تخفيف الأضلاع بحد أقصى (V-1) مرة، حيث تشير E إلى عدد الأضلاع؛ أما إن استخدمنا مصفوفة تجاور لتمثيل المخطط، فسيكون التعقيد الزمني O (V ^ 3)، ذلك أننا سنستطيع التكرار على جميع الأضلاع عند استخدام قائمة التجاور خلال زمن قدره O(E)، بينما نستغرق زمنًا قدره O (V ^ 2) إن استخدمنا مصفوفة التجاور. رصد الدورات السالبة في المخططات نستطيع رصد أي دورة سالبة في المخطط باستخدام خوارزمية بِلمَن-فورد Bellman-Ford. ونحن نعرف أنه يجب تخفيف جميع أضلاع المخطط عدد (V-1) مرة من أجل العثور على أقصر مسار، حيث تمثل V عدد الرؤوس في المخطط، وقد رأينا أنه لا يمكن تحديث d[] مهما كان عدد التكرارات التي أجريناها. إذا كانت هناك دورة سالبة في المخطط، فسيمكننا تحديث d[] حتى بعد التكرار (V-1)، ذلك أن كل تكرار، سيقلل العبور خلال دورة سالبة تكلفةَ المسار الأقصر، وهذا سبب أن خوارزمية بِلمَن فورد تحد عدد التكرارات بـ (V-1) مرة، لأننا سنعلق داخل حلقة أبدية لا تنتهي إن استخدمنا خوارزمية ديكسترا. سنركز الآن على كيفية إيجاد دورة سالبة، انظر المخطط التالي: لنختر الرأس1 ليكون المصدر، بعد تطبيق خوارزمية بلمان-فورد لأقصر مسار ذي مصدر وحيد على المخطط، سنجد المسافات التي تفصل المصدر عن جميع الرؤوس الأخرى. انظر: هكذا سيبدو المخطط بعد (V-1) = 3 تكرار، وينبغي أن تكون هذه هي النتيجة الصحيحة، لأنّنا سنحتاج 3 تكرارات على الأكثر للعثور على أقصر مسار ما دامت هناك 4 أضلاع في المخطط، لذا إمّا أنّ هذه هي الإجابة الصحيحة، وإمّا أنّ هناك دورة ذات وزن سالب في المخطط. ولنعرف ذلك، سنضيف تكرارًا جديدًا بعد التكرار (V-1)، فإذا استمرت المسافة في الانخفاض، فذلك يعني أنّ هناك دورة سالبة في المخطط. ولهذا المثال فإنه بالنسبة للضلع 2-3، تكون نتيجة d[2] + cost [2] [3] مساوية لـ 1، وهو أقل من d[3]، لذا يمكننا أن نستنتج أنّ هناك دورةً سالبةً في المخطط. والسؤال الآن هو كيف نجد هذه الدورة السالبة؟ سنجري تعديلًا بسيطًا على خوارزمية Bellman-Ford للعثور على الدورة السالبة: Procedure NegativeCycleDetector(Graph, source): n := number of vertices in Graph for i from 1 to n d[i] := infinity end for d[source] := 0 for i from 1 to n-1 flag := false for all edges from (u,v) in Graph if d[u] + cost[u][v] < d[v] d[v] := d[u] + cost[u][v] flag := true end if end for if flag == false break end for for all edges from (u,v) in Graph if d[u] + cost[u][v] < d[v] Return "Negative Cycle Detected" end if end for Return "No Negative Cycle" بهذه الطريقة نستطيع التحقق مما إذا كانت هناك أيّ دورة سالبة في المخطط، كذلك نستطيع تعديل خوارزمية Bellman-Ford لتخزين الدورات السالبة وحفظ سجل لها. لماذا نحتاج إلى تخفيف جميع الأضلاع بحد أقصى (V-1) مرة نحتاج إلى تخفيف جميع أضلاع المخطط في خوارزمية Bellman-Ford للعثور على أقصر مسار، وتُكرّر هذه العملية (V-1) مرّةً بحد أقصى، حيث يمثل V عدد الرؤوس في المخطط. ويعتمد عدد التكرارات اللازمة للعثور على أقصر مسار من المصدر إلى جميع الرؤوس الأخرى على الترتيب الذي اخترناه لتخفيف الأضلاع. انظر المثال التالي: لقد اخترنا الرأس 1 ليكون المصدر، سنبحث عن أقصر مسافة تفصل بين المصدر وجميع الحروف الأخرى. ونستطيع رؤية أننا سنحتاج في أسوأ الأحوال إلى (V-1) ضلع للوصول إلى الرأس 4، ووفقًا للترتيب الذي اكتُشفت من خلاله الأضلاع، فقد نحتاج إلى (V-1) مرة. للتوضيح، سنستخدم خوارزمية Bellman-Ford في مثال توضيحي للعثور على أقصر مسار. انظر التسلسل التالي: +--------+--------+--------+------+ | التسلسل | 1 | 2 | 3 | +--------+--------+--------+------+ | الضلع | 3->4 | 2->3 | 1->2 | +--------+--------+--------+------+ في التكرار الأول: d[3] + cost[3][4] = infinity لن يحدث أيّ تغيير. d[2] + cost[2][3] = infinity لن يحدث أيّ تغيير. d[1] + cost[1][2] = 2 < d[2]. d[2] = 2. parent[2] = 1. لاحظ أنّ عملية التخفيف لم تغيّر إلا قيمة d[2] فقط. سيبدو المخطط خاصتنا هكذا: التكرار الثاني: d[3] + cost[3][4] = infinity لن يحدث أيّ تغيير. d[2] + cost[2][3] = 5 < d[3]. d[3] = 5. parent[3] = 2. لن يحدث أيّ تغيير. غيّرت عمليةُ التخفيف قيمةَ d[3] في هذه المرّة. وسيبدو المخطط هكذا: التكرار الثالث: d[3] + cost[3][4] = 7 < d[4] . d[4] = 7 . parent[4] = 3 . لن يحدث أيّ تغيير. لن يحدث أيّ تغيير. وجدنا في التكرار الثالث أقصر مسار إلى 4 من المصدر 1، حيث سيبدو المخطط هكذا: احتجنا إلى 3 تكرارات للعثور على أقصر مسار. ستظل قيمة d[] كما هي بعد ذلك مهما حاولنا تخفيف الأضلاع. إليك تسلسلًا آخر: +--------+--------+--------+------+ | التسلسل | 1 | 2 | 3 | +--------+--------+--------+------+ | الضلع | 1->2 | 2->3 | 3->4 | +--------+--------+--------+------+ سنحصل على: d[1] + cost[1][2] = 2 < d[2] . d[2] = 2 . d[2] + cost[2][3] = 5 < d[3]. d[3] = 5. d[3] + cost[3][4] = 7 < d[4]. d[4] = 5. وجدنا أقصر مسار من المصدر إلى جميع العقد الأخرى منذ التكرار الأول، يمكننا إجراء التسلسلات الإضافية 1-> 2 و3-> 4 و 2-> 3، والتي ستعطينا أقصر مسار بعد تكرارين. يقودنا هذا إلى استنتاج أنه مهما كان ترتيب التسلسل، فلن نحتاج أكثر من 3 تكرارات للعثور على أقصر مسار من المصدر. نستنتج أيضًا أننا قد لا نحتاج في بعض الحالات إلّا إلى تكرار واحد فقط للعثور على أقصر مسار من المصدر. وسنحتاج في أسوأ الحالات إلى (V-1) تكرار. أرجو أن تكون قد فهمت الآن لماذا ينبغي أن نكرّر عملية التخفيف (V-1) مرّة. خوارزمية فلويد وورشال Floyd-Warshall تُستخدم خوارزمية فلويد وورشال Floyd-Warsha ll لإيجاد أقصر المسارات في مخطط موزون قد تكون أوزان أضلاعه موجبة أو سالبة. عند تنفيذ الخوارزمية مرّةً واحدة، سنحصل على أطوال -مجاميع أوزان- أقصر المسارات بين كل أزواج الرؤوس، ويمكنها -بقليل من التعديل- طباعة أقصر مسار، كما يمكنها رصد الدورات السالبة في المخطط. وخوارزمية Floyd- Warshall هي من خوارزميات البرمجة الديناميكية. لنطبق هذه الخوارزمية على المخطط التالي: أول شيء نفعله هو أخذ مصفوفتين ثنائيتَي الأبعاد لتكونا مصفُوفتي تجاور adjacency matrices يساوي حجماهما العدد الإجمالي للرؤوس. وفي مثالنا، سنأخذ مصفوفتين من الحجم 4*4، الأولى هي مصفوفة المسافات Distance Matrix، وسنخزّن فيها أقصر مسافة عثرنا عليها حتى الآن بين رأسين. بدايةً، إذا كان هناك ضلع بين u-v وكانت المسافة / الوزن = w، فإننا نضع distance[u][v] = w، وسنضع قيمة ما لا نهاية للأضلاغ غير الموجودة. المصفوفة الثانية هي مصفوفة المسارات Path Matrix، ونستخدمها لتوليد أقصر مسار بين رأسين، لذا إذا كان هناك مسار بين u وv، فسنضع path[u][v] = u، وهذا يعني أنّ أفضل طريقة للوصول إلى الرأس v انطلاقًا من u هو باستخدام الضلع الذي يربط v بـ u، وإذا لم يكن هناك مسار بين الرأسين، فسنعطيه القيمة N كناية على عدم وجود مسار متاح حاليًا. سيبدو جدولا المخطط كما يلي: +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | | 1 | 2 | 3 | 4 | | | 1 | 2 | 3 | 4 | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | 1 | 0 | 3 | 6 | 15 | | 1 | N | 1 | 1 | 1 | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | 2 | inf | 0 | -2 | inf | | 2 | N | N | 2 | N | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | 3 | inf | inf | 0 | 2 | | 3 | N | N | N | 3 | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | 4 | 1 | inf | inf | 0 | | 4 | 4 | N | N | N | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ المسار المسافة وقد وضعنا الرأس N في المواضع القطرية diagonals في مصفوفة المسارات نظرًا لعدم وجود حلقة loop، ولما كانت المسافة من كل رأس إلى نفسه تساوي 0، فقد وضعنا القيمة 0 في المواضع القطرية في مصفوفة المسافات. سنختار رأسًا وسطيًا k لأجل تطبيق خوارزمية Floyd-Warshall، وسنتحق بعد ذلك لكلّ رأس i مما إن كنا نستطيع الانتقال من i إلى k ثم من k إلى j، حيث j يمثّل رأسًا آخر، لتقليل تكلفة الانتقال من i إلى j. إذا كانت المسافة distance [j] أكبر من المسافة distance [k] + distance[k] [j]، فسنحدّث قيمة distance [j]، ونضع فيها مجموع هاتين المسافتين، كما سنحدّث path [j] ونعطيها القيمة path[k] [j]، لأنّ الانتقال من i إلى k، ثم من k إلى j أفضل. كما ستٌختار جميع الرؤوس بنفس طريقة اختيار k. وهكذا نحصل على 3 حلقات متداخلة: k من 1 إلى 4. i من 1 إلى 4. j من 1 إلى 4. هذه شيفرة توضيحية لذلك: if distance[i][j] > distance[i][k] + distance[k][j] distance[i][j] := distance[i][k] + distance[k][j] path[i][j] := path[k][j] end if السؤال الآن هو: لكل زوج u,v من الرؤوس، هل يوجد رأس يمكن أن نمرّ عبره لتقصير المسافة بين u وv؟ سيكون العدد الإجمالي لعمليات المخطط هو 4 * 4 * 4 = 64. وهذا يعني أنّنا سنجري هذا الاختبار 64 مرة، لنلقي نظرةً على بعض هذه الحالات: إذا كانت k = 1 وi = 2 وj = 3، فإن [distance [j ستساوي -2، وهي ليست أكبر من distance [k] + distance[k] [j] = -2 + 0 = -2، لذلك ستبقى دون تغيير. ومرةً أخرى، إذا كانت k = 1 وi = 4 وj = 2، فستكون distance [j] = infinity، وهي أكبر من distance [k] + distance[k] [j] = 1 + 3 = 4، لذا نضع distance [j] = 4، وpath [j] = path[k] [j] = 1. وهذا يعني أنّه للانتقال من الرأس 4 إلى 2، فإنّ المسار 4-> 1-> 2 يكون أقصر من المسار الحالي. انظر هذا الرابط الأجنبي للاطلاع على حسابات كل خطوة، وستبدو مصفوفتنا كما يلي بعد إجراء التعديلات اللازمة. بعد إجراء التغييرات اللازمة، ستبدو المصفوفتان كما يلي: +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | | 1 | 2 | 3 | 4 | | | 1 | 2 | 3 | 4 | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | 1 | 0 | 3 | 1 | 3 | | 1 | N | 1 | 2 | 3 | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | 2 | 1 | 0 | -2 | 0 | | 2 | 4 | N | 2 | 3 | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | 3 | 3 | 6 | 0 | 2 | | 3 | 4 | 1 | N | 3 | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ | 4 | 1 | 4 | 2 | 0 | | 4 | 4 | 1 | 2 | N | +-----+-----+-----+-----+-----+ +-----+-----+-----+-----+-----+ المسار المسافة وهكذا نكون قد حصلنا على مصفوفةِ أقصر المسافات، فمثلًا، تكون أقصر مسافة من 1 إلى 4 هي 3، وأقصر مسافة من 4 إلى 3 هي 2. انظر إلى الشيفرة التوضيحية التالية، حيث تمثل V عدد الرؤوس: Procedure Floyd-Warshall(Graph): for k from 1 to V for i from 1 to V for j from 1 to V if distance[i][j] > distance[i][k] + distance[k][j] distance[i][j] := distance[i][k] + distance[k][j] path[i][j] := path[k][j] end if end for end for end for سنتحقق من مصفوفة المسارات Path من أجل طباعة المسار، ولطباعة المسار من u إلى v، سنبدأ من path[u] [v]. ثمّ نستمر في تغيير v = path[u] [v] إلى أن نحصل على path[u] [v] = u، وندفع كل قيم path[u] [v] إلى مكدّس. بعد العثور على الرأس u، سنطبع u ونبدأ بإخراج العناصر من المكدّس وطباعتها. هذا الأمر ممكن لأنّ مصفوفة المسارات تخزّن قيمة الرأس الذي يشارك في أقصر مسار إلى v انطلاقًا من أيّ عقدة أخرى. انظر إلى الشيفرة التوضيحية لذلك: Procedure PrintPath(source, destination): s = Stack() S.push(destination) while Path[source][destination] is not equal to source S.push(Path[source][destination]) destination := Path[source][destination] end while print -> source while S is not empty print -> S.pop end while من أجل التحقق من وجود دورة سالبة، سيكون علينا التحقق من القطر diagonal الرئيسي لمصفوفة المسافات، وإذا كانت أيٌّ من القيم القطرية diagonal سالبة، فهذا يعني وجود دورة سالبة في المخطط. إن تعقيد خوارزمية فلويد-وورشال Floyd-Warshall هو O (V3)، وتعقيد المساحة هو: O (V2). ترجمة -بتصرّف- للفصول 19 و20 و22 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: تطبيقات الخوارزميات الشرهة المرجع الشامل إلى تعلم الخوارزميات للمبتدئين دليل شامل عن تحليل تعقيد الخوارزمية مدخل إلى الخوارزميات خوارزمية تحديد المسار النجمية A* Pathfinding خوارزمية ديكسترا Dijkstra’s Algorithm
-
الرسم التخطيطي أو المخطط هو مجموعة من النقاط والخطوط التي ترتبط ببعضها (يمكن أن تكون فارغة)، وتسمى نقاط المخطط رؤوسًا vertices أو عقدًا nodes، بينما تسمى الخطوط التي تربط رؤوس المخطط أضلاعًا edges أو أقواسًا أو خطوطًا. يعرَّف مخطط G مثلًا كزوْج (V، E)، حيث تمثّل V مجموعة من الحروف، وتمثّل E مجموعة الأضلاع التي تربط تلك الحروف، انظر: E ⊆ {(u,v) | u, v ∈ V} تخزين المخططات هناك طريقتان شائعتان لتخزين المخططات، وهما: مصفوفة التجاور Adjacency Matrix. قائمة التجاور. مصفوفة التجاور مصفوفة التجاور هي مصفوفة تُستخدم لتمثيل مخطط محدود finite graph، وتشير عناصر المصفوفة إلى ما إذا كانت أزواج الرؤوس متجاورة (مترابطة) في المخطط أم لا. وفي نظرية المخططات، نقول أنّ العقدة B مجاورة للعقدة A إذا كنا نستطيع الذهاب من العقدة A إلى العقدة B، وسنتعلم الآن كيفية تخزين العُقد المتجاورة عبر مصفوفة التجاور Adjacency Matrix ثنائية الأبعاد، هذا يعني أننا سنمثل العقد التي تتشارك الأضلاع فيما بينها. نرى في الشكل الموضح أعلاه جدولًا إلى جانب المخطط، ويمثّل هذا الجدول مصفوفة التجاور الخاصة بالمخطط المجاور له، وتمثل Matrix[i][j] = 1 هنا وجود ضلع بين i و j. بالمقابل، سنكتب Matrix[i][j] = 0 إذا لم يكن هناك أيّ ضلع يربطهما. نستطيع وزن تلك الأضلاع، أي إلحاق رقم بكل ضلع -قد يمثل هذا الرقم المسافة بين مدينتين مثلًا-، وهنا نضع الوزن في الموضع Matrix[i][j] بدلًا من 1. والمخطط الموضح أعلاه ثنائي الاتجاه Bidirectional، أو غير موجّه Undirected، أي أنّه إذا كان بإمكاننا الانتقال من العقدة 2 إلى العقدة 1 فيمكننا أيضًا الانتقال من العقدة 1 إلى العقدة 2. إن لم تتحقّق هذه الخاصية نقول أنّ المخطط موجّه Directed. وتوضع أسهم بدل الخطوط إذا كان المخطط موجَّهًا، كما يمكن استخدام مصفوفات التجاور لتمثيل هذا النوع من المخططات. لكن على خلاف المخططات غير الموجّهة، فإن العقد التي لا تشترك في أيّ ضلع تُمثَّل باللانهاية inf في المخططات الموجّهة كما يبيّن الرسم أعلاه. هناك أمر آخر ينبغي الانتباه له، وهو أنّ مصفوفة التجاور الخاصة بمخطط غير موجّه تكون دائما غير متماثلة. انظر الشيفرة التوضيحية pseudo-code التالية لإنشاء مصفوفة التجاور، حيث يمثل N عدد العُقد: Procedure AdjacencyMatrix(N): Matrix[N][N] for i from 1 to N for j from 1 to N Take input -> Matrix[i][j] endfor endfor فيما يلي طريقة أخرى لتعبئة المصفوفة، يمثل N فيها عدد العُقَد بينما يمثل E عدد الأضلاع: Procedure AdjacencyMatrix(N, E): Matrix[N][E] for i from 1 to E input -> n1, n2, cost Matrix[n1][n2] = cost Matrix[n2][n1] = cost endfor يمكنك إزالة السطر Matrix[n2][n1] = cost من الشيفرة في المخططات الموجّهة. عيوب استخدام مصفوفة التجاور إحدى المشاكل التي تنجم عن استخدام مصفوفات التجاور هو أنّها تستهلك مقدارا كبيرًا من الذاكرة، فمهما كان عدد أضلاع المخطط، سنحتاج دائمًا إلى مصفوفة بحجم N*N، حيث يمثّل N عدد العُقد. أما إذا كانت هناك 10000 عقدة في المخطط فسنحتاج مصفوفة بحجم 4 * 10000 * 10000، أي حوالي 381 ميغابايت، وهذا مضيعة للذاكرة، علمًا أنّ الكثير من المخططات لا تحتوي إلا القليل من الأضلاع. وبفرض أنّنا نريد أن نعرف العقد التي يمكننا الوصول إليها انطلاقًا من العقدة u، فسنحتاج إلى التحقق من الصفّ الخاص بـ u في المصفوفة بالكامل، وهذا سيكلفنا الكثير من الوقت. والفائدة الوحيدة لمصفوفة التجاور هي أنها تمكّننا من العثور بسهولة على مسار بين عقدتين مثل u-v، وكذلك تكلفة cost ذلك المسار، أي مجموع أوزان الأضلاع التي تؤلف المسار. شيفرة جافا التالية تطبق الشيفرة العامّة أعلاه: import java.util.Scanner; public class Represent_Graph_Adjacency_Matrix { private final int vertices; private int[][] adjacency_matrix; public Represent_Graph_Adjacency_Matrix(int v) { vertices = v; adjacency_matrix = new int[vertices + 1][vertices + 1]; } public void makeEdge(int to, int from, int edge) { try { adjacency_matrix[to][from] = edge; } catch (ArrayIndexOutOfBoundsException index) { System.out.println("The vertices does not exists"); } } public int getEdge(int to, int from) { try { return adjacency_matrix[to][from]; } catch (ArrayIndexOutOfBoundsException index) { System.out.println("The vertices does not exists"); } return -1; } public static void main(String args[]) { int v, e, count = 1, to = 0, from = 0; Scanner sc = new Scanner(System.in); Represent_Graph_Adjacency_Matrix graph; try { System.out.println("Enter the number of vertices: "); v = sc.nextInt(); System.out.println("Enter the number of edges: "); e = sc.nextInt(); graph = new Represent_Graph_Adjacency_Matrix(v); System.out.println("Enter the edges: <to> <from>"); while (count <= e) { to = sc.nextInt(); from = sc.nextInt(); graph.makeEdge(to, from, 1); count++; } System.out.println("The adjacency matrix for the given graph is: "); System.out.print(" "); for (int i = 1; i <= v; i++) System.out.print(i + " "); System.out.println(); for (int i = 1; i <= v; i++) { System.out.print(i + " "); for (int j = 1; j <= v; j++) System.out.print(graph.getEdge(i, j) + " "); System.out.println(); } } catch (Exception E) { System.out.println("Something went wrong"); } sc.close(); } } لتشغيل الشيفرة أعلاه، احفظ الملف، ثم صرّف compile الشيفرة باستخدام التعليمة الآتية: javac Represent_Graph_Adjacency_Matrix.java انظر المثال التالي الذي يوضح هذا: $ java Represent_Graph_Adjacency_Matrix Enter the number of vertices: 4 Enter the number of edges: 6 Enter the edges: 1 1 3 4 2 3 1 4 2 4 1 2 The adjacency matrix for the given graph is: 1 2 3 4 1 1 1 0 1 2 0 0 1 1 3 0 0 0 1 4 0 0 0 0 تخزين المخططات (قوائم التجاور) قائمة التجاور هي مجموعة من القوائم غير المرتبة تُستخدم لتمثيل المخططات المحدودة finite graphs، وتصف كل قائمة في المجموعة جيرانَ كلّ حرف من حروف المخطط. وميزة قوائم التجاور أنّها تحتاج مساحة ذاكرة أقل لتخزين المخططات. انظر المثال التالي عن مخططٍ ومصفوفة التجاور الخاصة به: وهذه قائمة التجاور الخاصة بالمخطط أعلاه: تسمى هذه القائمة قائمةَ التجاور adjacency list، وتوضّح الروابط بين العقد. نستطيع إن شئنا تخزين هذه المعلومات باستخدام مصفوفة ثنائية الأبعاد، لكن هذا سيكلفنا نفس مقدار الذاكرة الذي يتطلّبه تخزين مصفوفة التجاور. بدلاً من ذلك، سنستخدم الذاكرة المخصّصة ديناميكيًا لتخزين هذه القيم. تدعم العديد من لغات البرمجة نوعي البيانات المتجهات Vector والقوائم List ، والتي يمكننا استخدامها لتخزين قائمة التجاور، وهكذا لن يكون علينا تحديد حجم القائمة إذ يكفي أن نحدّد الحد الأقصى لعدد العقد. انظر الشيفرة العامة لذلك، حيث يمثل maxN الحد الأقصى للعُقد، بينما يمثل E عدد الأضلاع، ويشير التعبير x, y إلى وجود ضلع يربط بين x وy: Procedure Adjacency-List(maxN, E): edge[maxN] = Vector() for i from 1 to E input -> x, y edge[x].push(y) edge[y].push(x) end for Return edge وبما أنّ هذا المخطط غير موجّه فإنّ وجود ضلع من x إلى y يستلزم وجود ضلع معاكس، أي ضلعٍ من y إلى x، ولن تتحقق هذه الخاصية في المخططات غير الموجّهة. أما بالنسبة للمخططات الموزونة فسنحتاج إلى تخزين التكلفة (الوزن) أيضًا، من خلال إنشاء متجه أو قائمة أخرى باسم cost[] لتخزينها، انظر الشيفرة العامة لذلك: Procedure Adjacency-List(maxN, E): edge[maxN] = Vector() cost[maxN] = Vector() for i from 1 to E input -> x, y, w edge[x].push(y) cost[x].push(w) end for Return edge, cost يمكننا الآن أن نعثر بسهولة على العقد المتصلة بعقدة ما وعددها أيضًا، وسنحتاج وقتًا أقل مقارنة بمصفوفة التجاور. بالمقابل، ستكون مصفوفة التجاور أكثر كفاءة إن احتجنا إلى معرفة ما إذا كان هناك ضلع بين u وv. مقدمة إلى نظرية الرسوم التخطيطية نظرية المخططات أو الرسوم التخطيطية Graph Theory هي فرع من فروع الرياضيات يهتم بدراسة الرسوم التخطيطية، وهي كائنات رياضية تُستخدم لنمذجة العلاقات الزوجية بين الكائنات. طُوِّرت نظرية المخططات من قبل اختراع الحاسوب، إذ كتب ليونهارت أويلر Leonhard Euler ورقة حول جسور كونيجسبرج السبعة Seven Bridges of Königsberg والتي تُعدّ أوّل ورقة علمية عن نظرية المخططات، وأدرك الناس منذ ذلك الحين أنه إذا أمكننا تحويل المشاكل إلى مسائل من نوع مدينة-طريق City-Road، فيمكننا حلها بسهولة باستخدام نظرية المخططات. وهناك تطبيقات عديدة لهذه النظرية، لعل أشهرها هو العثور على أقصر مسافة بين مدينتين. فمثلا، ينبغي أن تمرّ عبر العديد من المُوجّهات routers عندما تدخل موقعًا إلكترونيا، انطلاقًا من الخادم، لكي تصل محتويات الموقع إلى حاسوبك. تساهم نظرية المخططات هنا في العثور على الموجّهات التي ينبغي المرور عبرها للوصول إلى حاسوبك في أسرع وقت. كما تُستخدم خلال الحروب لتحديد الطريق الذي يجب قصفه لقطع العاصمة عن المدن الأخرى. وسنتعلم فيما يلي بعض الأساسيات لهذه النظرية. إليك تعاريف من المهم الاطلاع عليها ومعرفتها: المخططات: لنقل أنّ لدينا 6 مدن، نرقّم هذه المدن من 1 إلى 6. سننشئ الآن مخططًا يمثّل هذه المدن، حيث تمثل الرؤوسُ المدن، مع ربط المدن التي تربطها طرق فيما بينها بأضلاع. هذا مخطط بسيط لتمثيل المدن والطرق الرابطة بينها، ونسمي هذه المدن في نظرية المخططات عقدًا Nodes أو حروفًا Vertex فيما نسمّي الطرق أضلاعًا Edge. يمكن أن تمثل العقدة أشياءً كثيرة، إذ قد تمثّل مدنًا أو مطارات أو مربّعات على رقعة الشطرنج. بالمقابل، تمثل الأضلاع العلاقات بين تلك العقد. مثلًا، يمكن أن تمثّل هذه العلاقات الوقت اللازم للانتقال من مطار إلى آخر، أو نقلة الفارس من مربّع إلى المربعات الأخرى على رقعة الشطرنج، وغير ذلك. تمثيل لمسار الفارس على رقعة الشطرنج وببساطة، تمثّل العقدة أيّ نوع من الكائنات، وتمثّل الأضلاع العلاقات بين تلك الكائنات. العقدة المتجاورة Adjacent Node: تكون B مجاورة لـ A إذا اشتركت عقدة A مع عقدة أخرى B في ضلع واحد، أي نقول أنّ العقدتين متجاورتان إذا اتصلت عقدتان اتصالًا مباشرًا، ويمكن لكل عقدة أن يكون لها عدة عقد مجاورة. المخططات الموجّهة وغير الموجّهة Directed and Undirected Graph: توضع علامات توجيهية (مثل الأسهم) على الأضلاع في المخططات الموجّهة للدلالة على أنّ الضلع أحادي الاتجاه. من ناحية أخرى، تحتوي أضلاع المخططات غير الموجّهة على علامات اتجاه على كلا الجانبين، للدلالة على أنها ثنائية الاتجاه. لكن تُحذف علامات التوجيه تلك في الغالب من المخططات غير الموجّهة، وتمثّل حينها الأضلاع كخطوط وحسب. وإذا افترضنا وجود حفلٍ في مكان ما، فسنمثّل الأشخاص الحاضرين بالعُقد، وسنرسم خطًا بين شخصين إذا تصافحا. لا شك أن هذه المخططات غير موجّهة هنا، لأنّه إذا صافح عمرو زيدًا فهذا يعني أنّ زيدًا صافح عَمرًا كذلك، فهي عملية ثنائية. بالمقابل، إذا رسمنا ضلعًا من عمرو إلى زيد إن كان زيد يقدّر عَمرًا ويحترمه فإنّ هذه المخططات ستكون موجّهة، ذلك أن الإعجاب لا يشترط أن يكون متبادلًا. يُطلق على النوع الأول مخططات غير موجّهة undirected graphs، وتسمّى الأضلاع أضلاعًا غير موجّهة undirected edges، بالمقابل، يسمّى النوع الثاني مخططات موجّهة directed graph وتسمّى الأضلاع أضلاعًا موجّهة directed edges. المخططات الموزونة وغير الموزونة Weighted and Unweighted Graph: المخطط الموزون هو مخطط يكون لكلّ ضلع من أضلاعه رقم (وزن)، يمكن أن تمثّل هذه الأوزان التكاليف أو الأطوال أو السعات وغير ذلك، وذلك اعتمادًا على المشكلة المطروحة. بالمقابل، المخططات غير الموزونة هي مخططات نفترض أنّ أوزان جميع أضلاعها متساوية (تساوي1 افتراضيًا ). المسارات: يمثّل المسار طريقًا للانتقال من عقدة إلى أخرى ويتألّف من سلسلة من الأضلاع، ولا شيء يمنع وجود عدة مسارات بين عقدتين. في المثال أعلاه، هناك مساران من A إلى D، الأول هو A-> B ،B-> C ،C-> D ، وكلفته (مجموع أوزان الأضلاع التي تؤلّفه) هي 3 + 4 + 2 = 9، أما المسار الآخر فهو A-> D، وكلفته 10. يقال أن المسار الذي يكلّف أدنى قدر هو المسار الأقصر. الدرجة degree: درجة الحرف degree of a vertex هي عدد الأضلاع المرتبطة به، فإذا كان هناك ضلع يرتبط بالحرف في كلا الطرفين (حلقة loop)، فسيُحسب مرتين. يكون للعُقد في المخططات الموجّهة نوعان مختلفان من الدرجات: الدرجة الداخلية In-degree: عدد الأضلاع التي تشير إلى العقدة. الدرجة الخارجية Out-degree: عدد الأضلاع التي تنطلق من العقدة الحالية وتشير إلى العقد الأخرى. بالنسبة للمخططات غير الموجّهة، يكون هناك نوع واحد طبعا، ويُسمّى درجة الحرف. بعض الخوارزميات المتعلقة بنظرية المخططات: خوارزمية بلمان فورد Bellman–Ford خوارزمية ديكسترا Dijkstra خوارزمية فورد فولكرسون Ford–Fulkerson خوارزمية كروسكال Kruskal. خوارزمية الجار الأقرب Nearest neighbour algorithm. خوارزمية بْرِم Prim. خوارزمية البحث العميق أولا Depth-first search. خوارزمية البحث العريض أولًا Breadth-first search. سوف نستعرضُ بعض هذه الخوارزميات لاحقًا. الترتيب الطوبولوجي Topological Sort يرتّب الترتيب الطوبولوجي حروف مخطط موجّه ترتيبًا خطيًا، إذ يضعها في قائمة مُرتّبة حسب الأضلاع الموجّهة التي تربط تلك الحروف. وليكون هذا الترتيب ممكنا، يجب ألّا يحتوي المخطط على دورة موجّهة directed cycle، فإن كان لدينا مخطط G = (V, E)، فالترتيب الخطي رياضيًا هو ترتيب متوافق مع المخطط، أي يحقّق ما يلي: إن كانت G تحتوي الضلع (u, v) ∈ E الذي ينتمي إلى E وينطلق من الحرف u إلى v، فستكون u أصغر من v وفق هذا الترتيب. وهنا من المهم ملاحظة أنّ كلّ مخطط موجّه غير دوري directed acyclic graph، أو DAG اختصارًا له ترتيب طوبولوجي واحد على الأقل، وهناك عدد من الخوارزميات التي تمكّننا من إنشاء ترتيب طوبولوجي لمخطط موجّه غير دوري في وقتٍ خطي، هذا مثال عام على إحداها: استدع دالة depth_first_search(G) لحساب أوقات الإنتهاء finishing times بـ v.f لكل حرف v عقب الانتهاء من حرف ما، أدرِجه في مقدّمة قائمة مرتبطة linked list. يُحدَّد الترتيب الطوبولوجي بقائمة الحروف المرتبطة التي نتجت من الخطوتين السابقتين. يمكن إجراء ترتيب طوبولوجي في مدة V + E، لأنّ "خوارزمية البحث العميق أولًا depth-first search" تستغرق مدّة (V + E) ـ كما ستستغرق Ω(1) (وقت ثابت) لإدراج كل الحروف |V| في مقدمة القائمة المرتبطة. تستخدم العديدُ من التطبيقات المخططاتَ الموجّهة الدورية directed acyclic graphs لتمثيل الأسبقية بين الأحداث، إذ يُستخدم الترتيب الطوبولوجي للحصول على الترتيب الصحيح لمعالجة كل حرف من حروف المخطط. وقد تمثّل حروف المخططُ المهامَ التي يتعيّن إنجازها، فيما تمثل الأضلاع أسبقية تنفيذ تلك المهام، وهكذا يمثّل الترتيب الطوبولوجي التسلسل المناسب لأداء مجموعة المهام الموضّحة في V. مثال ليكن v حرفًا يمثّل مهمّة Task(hours_to_complete: int)، بحيث يمثّل الوسيط hours_to_complete الوقت المُستغرَق لتنفيذ المهمة. فمثلًا، تمثّل Task(4) مهمّة تستغرق 4 ساعات لإكمالها. من جهة أخرى، يمثّل ضلع e قيمة Cooldown(hours: int)، والتي تمثّل المدة الزمنية التي تنقضي قبل استئناف المهمة التالية (أي التي يشير إليها الضلع) بعد الانتهاء من المهمة الحالية (التي ينطلق منها الضلع). فإن كان هناك ضلع Cooldown(3) يربط بين مهمّتين أ و ب، فذلك يعني أنه بعد الانتهاء من المهمة أ، ستحتاج أن تنتظر 3 ساعات حتى تستطيع تنفيذ المهمة ب (مثلا ليبرد المحرّك). فيما يلي، المخطط غير الدوري والموجّه dag يحتوي 5 رؤوس: A <- dag.add_vertex(Task(4)); B <- dag.add_vertex(Task(5)); C <- dag.add_vertex(Task(3)); D <- dag.add_vertex(Task(2)); E <- dag.add_vertex(Task(7)); نربط الحروف عبر أضلاع موجّهة بحيث يكون المخططات غير دوري، انظر: // A ---> C -----+ // | | | // v v v // B ---> D ---> E dag.add_edge(A, B, Cooldown(2)); dag.add_edge(A, C, Cooldown(2)); dag.add_edge(B, D, Cooldown(1)); dag.add_edge(C, D, Cooldown(1)); dag.add_edge(C, E, Cooldown(1)); dag.add_edge(D, E, Cooldown(3)); ستكون هناك ثلاثة تراتيب طوبولوجية ممكنة بين A وE: A -> B -> D -> E A -> C -> D -> E A -> C -> E رصد الدورات في المخططات الموجهة باستخدام الاجتياز العميق أولا Depth First Traversal إذا نتج عن الاجتياز العميق أولًا ضلعٌ خلفي back edge، فذلك يعني أنّ المخطط الموجّه يحتوي دورة cycle. والضلع الخلفي هو ضلع ينطلق من عقدة ويعود إليها أو إلى إحدى أسلافها في شجرة بحث عميق أولًا Depth-first search اختصارًا DFS. بالنسبة لمخطط غير متصل disconnected graph، سنحصل على غابة بحث عميق أولا أو غابة DFS وهي اختصار لـ DFS forest، لذلك سيكون عليك التكرار على جميع الحروف في المخطط لإيجاد أشجار البحث العميق أولًا والمنفصلة disjoint DFS trees. فيما يلي تنفيذ بلغة C++: #include <iostream> #include <list> using namespace std; #define NUM_V 4 bool helper(list<int> *graph, int u, bool* visited, bool* recStack) { visited[u]=true; recStack[u]=true; list<int>::iterator i; for(i = graph[u].begin();i!=graph[u].end();++i) { if(recStack[*i]) شرح السطر السابق في الشيفرة: عند إيجاد حرف v في مكدس التكرارية الخاص باجتياز DFS، أعِد true، تابع المثال الآتي: return true; else if(*i==u) // في حال كان هناك ضلع من الحرف إلى نفسه return true; else if(!visited[*i]) { if(helper(graph, *i, visited, recStack)) return true; } } recStack[u]=false; return false; } هنا تستدعي دالة التغليف الدالةَ helper على كل حرف لم يُزَر بعد، وتعيد دالة helper القيمة true عند رصد ضلع خلفي في الشجيرة، وإلا فإنها تعيد false، تابع المثال الآتي: bool isCyclic(list<int> *graph, int V) { bool visited[V]; // مصفوفة لتتبع الأحرف المُزارة سلفا bool recStack[V]; // مصفوفة لتتبع الأحرف في المكدس التكراري للاجتياز for(int i = 0;i<V;i++) visited[i]=false, recStack[i]=false; // تهيئة جميع الأحرف على أنها غير مُزارة تكراريًا for(int u = 0; u < V; u++) // التحقق اليدوي مما إذا كانت كل الأحرف مُزارة { if(visited[u]==false) { if(helper(graph, u, visited, recStack)) // التحقق مما إذا كانت شجرةُ “بحثٍ عميقٍ أولًا” تحتوي دورة. return true; } } return false; } /* Driver function */ int main() { list<int>* graph = new list<int>[NUM_V]; graph[0].push_back(1); graph[0].push_back(2); graph[1].push_back(2); graph[2].push_back(0); graph[2].push_back(3); graph[3].push_back(3); bool res = isCyclic(graph, NUM_V); cout<<res<<endl; } تكون النتيجة كما هو موضّح أدناه، أن هناك ثلاثة أضلاع خلفية في المخططات، واحد بين الحرفين 0 و 2؛ وآخر بين الحروف 0 و1 و2؛ والحرف 3. والتعقيد الزمني للبحث يساوي O (V + E)، حيث يمثّل V عدد الحروف، وE يمثّل عدد الأضلاع. خوارزمية Thorup كيف يمكن العثور على أقصر مسار من حرف (مصدر) معيّن إلى أيّ حرف آخر في مخطط غير موجّهة؟ قدّم "ميكيل توغوب Mikkel Thorup" -نُطْقُ اسمه من الدانماركية- أول خوارزمية تحل هذه المشكلة. يساوي التعقيد الزمني لهذه الخوارزمية O (m). وفيما يلي الأفكارُ الأساسية التي تعتمد عليها الخوارزمية: هناك عدّة طرق للعثور على الشجرة المتفرّعة spanning tree في مدة O (m) (لن نذكر هذه الطرق هنا)، سيكون عليك إنشاء الشجرة المتفرّعة من الضلع الأقصر إلى الأطول، وسينتج عن ذلك غابة (مجموعة من الأشجار غير المتصلة بالضرورة) تحتوي العديد من المكوّنات المتصلة قبل أن تنمو كاملةً. اختر عددًا صحيحًا b (b> = 2)، ولا تأخذ بالحسبان إلّا الغابات المتفرّعة ذات الطول الأقصى b ^ k، ثم ادمج المكونات المتشابهة في كل شيء ولكن تختلف في قيمة k. سنسمّى أصغر قيم k مستوى المكوّن level of the component. ثم ضع المكوّنات بعد هذا في الشجرة في المكان المناسب بحيث يكون الحرف u أبًا للحرف v إذ كان u هي أصغر مكوّن مختلف عن v ويحتوي v بشكل كامل. الجذر سيكون المخطط بأكمله، أمّا الأوراق فهي الحروف المفردة single vertices في المخطط الأصلي (مستواها يساوي سالب ما لا نهاية). ستحتوي الشجرة على O (n) عقدة. حافظ على المسافة بين كل مكوّن وبين المصدر كما هو الحال في خوارزمية Dijkstra، تساوي مسافة مكوّن يحتوي أكثر من حرفٍ المسافةَ الأقل بين أبنائها غير الموسَّعين unexpanded children. اجعل مسافة الحرف الأصلي source vertex على 0، ثمّ حدّث الأسلاف وفقًا لذلك. احسب المسافات بنظام العدّ من الأساس b أو base b، وعند زيارة عقدة في المستوى k للمرة الأولى، ضع أبناءها في مجموعات أو سلَّات buckets مشتركة بين جميع العقد من المستوى k. وخذ بالحسبان أوّل b سلّة وحسب في كل مرة تزور فيها عقدة، وَزُر كلّ واحدة منها ثمّ أزلها، ثمّ حدّث مسافة العقدة الحالية، وَأعِد ربط العقدة الحالية بأصلها باستخدام المسافة الجديدة وانتظر الزيارة القادمة للسلات التالية. عند زيارة ورقة leaf، تكون المسافة الحالية هي المسافة النهائية للحرف. وسِّع جميع الأضلاع المنطلقة منه في المخطط الأصلي، ثمّ حدّث المسافات وفقًا لذلك. زر العقدة الجذرية (المخطط كاملًا) بشكل متكرر إلى أن تصل إلى الوجهة المقصودة. تعتمد هذه الخوارزمية على حقيقة أنّه لا يمكن أن يوجد ضلع ذا طول أقل من l بين مكوّنيْن متصليْن في غابة متفرّعة ذات حدّ طولي يساوي length limitation، لذلك، يمكنك حصر تركيزك على مكوّن واحد متصل بدءًا من مسافة x إلى أن تصل إلى المسافة x + l. ستزور بعض الحروف في الطريق قبل زيارة جميع الحروف ذات المسافة الأقصر، لكن ذلك لا يهم بما أنّنا نعلم أنّه لن يكون هناك مسار أقصر إلى هنا من تلك الحروف. اجتياز المخططات Graph Traversals هناك العديد من الخوارزميات للبحث في المخططات، سنستعرض إحداها فيما يلي، وهي خوارزمية البحث العميق أولا. تنفذ الشيفرة التالية هذه الخوارزمية، إذ تنشئ دالة تأخذ فهرس العقدة الحالي كوسيط، وقائمة التجاور (مخزّنة في متجهة من المتجهات)، ومتجهة منطقية vector of boolean لتعقّب العقدة التي تمت زيارتها، انظر: void dfs(int node, vector<vector<int>>* graph, vector<bool>* visited) { // التحقّق مما إذا كانت العقدة مُزارة سلفا if((*visited)[node]) return; // set as visited to avoid visiting the same node twice (*visited)[node] = true; // نفّذ بعض الإجراءات هنا cout << node; // اجتياز العقد المتجاورة عبر البحث العميق أولا for(int i = 0; i < (*graph)[node].size(); ++i) dfs((*graph)[node][i], graph, visited); } ترجمة -بتصرّف- للفصلين 9 و10 من كتاب Algorithms Notes for Professionals اقرأ أيضًا المقالة السابقة: الأشجار Trees في الخوازرميات مدخل إلى الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية النسخة الكاملة من كتاب مدخل إلى الذكاء الاصطناعي وتعلم الآلة
-
تمثل الأشجار نوعًا فرعيًا لهيكل أعم من هياكل البياناتٍ التخطيطية التفرعية Node-Edge Graph Data Structure كالآتي: والشجرة هي مخطط يحقق الشرطين التاليين: غير حلقي acyclic: أي لا يحتوي أيّ دورات cycles أو حلقات loops. متصل: أي يمكن الوصول إلى كل عقدة من عقد المخطط عبر مسار معيّن. وهيكل الشجرة شائع جدًا في علوم الحاسوب، إذ يُستخدم لنمذجة العديد من هياكل البيانات الخوارزمية المختلفة، مثل الأشجار الثنائية العادية والأشجار الحمراء-السوداء red-black trees، وأشجار B وأشجار AB وأشجار 23 وأشجار الكومة Heap وكذلك أشجار Trie. وتكون الشجرة جِذريةً Rooted Tree في حال: اختيار خلية واحدة لتكون جذرًا للشجرة. صباغة Painting الجذر في أعلى الشجرة. إنشاء طبقة سفلية lower layer لكل خلية في المخطط تبعًا للمسافة بينها وبين الجذر، فكلما كانت المسافة أكبر، كانت الخلية أسفل كما في الصورة التوضيحية أعلاه. ويرمز عادةً للأشجار بالرمز T. الأشجار اللانهائية anary tree نُمثّل الأشجار اللانهائية -أي الأشجار التي يمكن أن يتفرّع عدد لانهائي من الفروع من عقدها- على هيئة أشجار ثنائية binary tree، وهي أشجار يتفرع عن كل عقدة منها فرعان فقط، ويُعَد الفرع الثاني منهما شقيقًا Sibling، لاحظ أنه إذا كانت الشجرة ثنائيةً، فسينشئ التمثيل عُقدًا إضافية. ثم نكرر بعد ذلك على الأشقاء، ومن ثم على الفروع، وبما أن أغلب الأشجار ضحلة (أي لها مستويات تفرع هرمي قليلة، رغم إمكانية كثرة الفروع للمستوى الواحد)، فإن هذا ينتج عنه شيفرة ذات كفاءة عالية، لاحظ أن هذا عكس هرمية البشر، إذ يكون لنا مستويات كثيرة جدًا في هرمية الآباء وأولاد قليلون لكل مستوى موازنةً بأولئك الآباء. من الممكن حفظ المؤشرات الخلفية للسماح للشجرة أن ترتقي، لكن تلك المؤشرات صعبة في معالجتها وصيانتها. لاحظ أنه عادةً ما يكون لدينا دالة واحدة للاستدعاء على الجذر، ودالة تكرارية recursive مع معامِلات إضافية، وفي هذا المثال فهي عمق الشجرة، انظر: struct node { struct node *next; struct node *child; std::string data; } void printtree_r(struct node *node, int depth) { int i; while(node) { if(node->child) { for(i=0;i<depth*3;i++) printf(" "); printf("{\n"): printtree_r(node->child, depth +1); for(i=0;i<depth*3;i++) printf(" "); printf("{\n"): for(i=0;i<depth*3;i++) printf(" "); printf("%s\n", node->data.c_str()); node = node->next; } } } void printtree(node *root) { printree_r(root, 0); } التحقق من تساوي شجرتين ثنائيتين انظر الشجرتين التاليتين: هاتان الشجرتين متساويتان، على خلاف الشجرتين التاليتين: وفيما يلي شيفرة عامة زائفة pseudo code للتحقق من تساوي شجرتين: boolean sameTree(node root1, node root2){ if(root1 == NULL && root2 == NULL) return true; if(root1 == NULL || root2 == NULL) return false; if(root1->data == root2->data && sameTree(root1->left,root2->left) && sameTree(root1->right, root2->right)) return true; } أشجار البحث الثنائية Binary Search Trees الأشجار الثنائية هي الأشجار التي يتفرّع عن كلّ عقدة منها ابنان على الأكثر، وشجرة البحث الثنائية Binary search tree أو BST اختصارًا هي شجرة ثنائية عناصرها مُرتّبة ترتيبًا خاصًا، إذ تكون جميع القيم الموجودة في الشجيرة أو الفرع sub tree الأيسر أصغر من القيم في الشجَيرة اليمنى. إدراج عنصر في شجرة بحث ثنائية Python رسم يوضح كيفية إدراج عنصر في الشجرة هذا تنفيذ بسيط لكيفية إدراج عنصر في شجرة بحث ثنائية مكتوب بلغة Python. class Node: def __init__(self, val): self.l_child = None self.r_child = None self.data = val def insert(root, node): if root is None: root = node else: if root.data > node.data: if root.l_child is None: root.l_child = node else: insert(root.l_child, node) else: if root.r_child is None: root.r_child = node else: insert(root.r_child, node) def in_order_print(root): if not root: return in_order_print(root.l_child) print root.data in_order_print(root.r_child) def pre_order_print(root): if not root: return print root.data pre_order_print(root.l_child) pre_order_print(root.r_child) حذف عنصر من شجرة بحث ثنائية C++ قبل أن نبدأ، نريد أن التذكير بمفهوم شجرة البحث الثنائية BST، وهي التي يتفرّع عن كل عقدة منها عقدتان كحد أقصى (ابن أيمن وأيسر)، حيث تحتوي الشجيرة اليسرى المتفرّعة عن عقدة ما على مفتاح قيمته أصغر من أو تساوي مفتاحَ العقدة الأصلية. وتحتوي الشجيرة اليمنى للعقدة على مفتاح أكبر من مفتاح العقدة الأصلية. سنناقش في هذه الفقرة كيفية حذف عقدة من شجرة بحث ثنائية مع الحفاظ على الخاصية أعلاه. هناك ثلاث حالات يجب مراعاتها عند حذف العقدة، هي الآتية: الحالة 1: العقدة المراد حذفها هي ورقة أو عقدة طرفية leaf node، مثل العقدة ذات القيمة 22. الحالة 2: العقدة المراد حذفها لها ابن واحد، مثل العقدة ذات القيمة 26. الحالة 3: العقدة المراد حذفها لها ابنان، مثل العقدة ذات القيمة 49. شرح الحالات عندما تكون العقدة المراد حذفها ورقةً، فما عليك سوى حذف العقدة وتعيين المؤشر الفارغ nullptr إلى العقدة الأصلية. عندما تحتوي العقدة المراد حذفها على ابن واحد فقط، انسخ قيمة الابن إلى قيمة العقدة ثمّ احذف الابن (ستُحوّل إلى الحالة 1). عندما يكون للعقدة المراد حذفها ابنان، فيمكن نسخ القيمة الأصغر من شجيرتها اليمنى إلى العقدة، بعدها يمكن حذف القيمة الدنيا من الشجيرة اليمنى للعقدة، حيث ستُحوَّل إلى الحالة 2. انظر المثال التالي على حذف عنصر من شجرة بحث ثنائية: struct node { int data; node *left, *right; }; node* delete_node(node *root, int data) { if(root == nullptr) return root; else if(data < root->data) root->left = delete_node(root->left, data); else if(data > root->data) root->right = delete_node(root->right, data); else { if(root->left == nullptr && root->right == nullptr) // الحالة 1 { free(root); root = nullptr; } else if(root->left == nullptr) // الحالة 2 { node* temp = root; root= root->right; free(temp); } else if(root->right == nullptr) // الحالة 2 { node* temp = root; root = root->left; free(temp); } else // الحالة 3 { node* temp = root->right; while(temp->left != nullptr) temp = temp->left; root->data = temp->data; root->right = delete_node(root->right, temp->data); } } return root; } التعقيد الزمني للشيفرة أعلاه هو O(h)، حيث تمثّل h ارتفاع الشجرة. أدنى سلف مشترك في شجرة بحث ثنائية انظر شجرة البحث الثنائية التالية: أدنى سلف مشترك Lowest common ancestor لـ 22 و26 هو 24. أدنى سلف مشترك لـ 26 و49 هو 46. أدنى سلف مشترك لـ 22 و24 هو 24. تُستغل الخاصية المميّزة لأشجار البحث الثنائية للعثور على السلف الأدنى للعقد، فيما يلي شيفرة عامة للعثور على السلف المشترك الأدنى: lowestCommonAncestor(root,node1, node2){ if(root == NULL) return NULL; else if(node1->data == root->data || node2->data== root->data) return root; else if((node1->data <= root->data && node2->data > root->data) || (node2->data <= root->data && node1->data > root->data)){ return root; } else if(root->data > max(node1->data,node2->data)){ return lowestCommonAncestor(root->left, node1, node2); } else { return lowestCommonAncestor(root->right, node1, node2); } } شجرة البحث الثنائية Python انظر إلى شيفرة البايثون التالية: class Node(object): def __init__(self, val): self.l_child = None self.r_child = None self.val = val class BinarySearchTree(object): def insert(self, root, node): if root is None: return node if root.val < node.val: root.r_child = self.insert(root.r_child, node) else: root.l_child = self.insert(root.l_child, node) return root def in_order_place(self, root): if not root: return None else: self.in_order_place(root.l_child) print root.val self.in_order_place(root.r_child) def pre_order_place(self, root): if not root: return None else: print root.val self.pre_order_place(root.l_child) self.pre_order_place(root.r_child) def post_order_place(self, root): if not root: return None else: self.post_order_place(root.l_child) self.post_order_place(root.r_child) print root.val هذه شيفرة لإنشاء عقدة جديدة وإدراج البيانات فيها: r = Node(3) node = BinarySearchTree() nodeList = [1, 8, 5, 12, 14, 6, 15, 7, 16, 8] for nd in nodeList: node.insert(r, Node(nd)) print "------In order ---------" print (node.in_order_place(r)) print "------Pre order ---------" print (node.pre_order_place(r)) print "------Post order ---------" print (node.post_order_place(r)) التحقق مما إذا كانت الشجرة شجرة بحث ثنائية أم لا تكون شجرةٌ ثنائيةٌ ما "شجرةَ بحث ثنائية" إذا كانت تستوفي أيًّا من الشروط التالية: إن كانت فارغة. لا تتفرع منها أيّ شجيرات. لكلّ عقدة x في الشجرة، يجب أن تكون جميع المفاتيح (إن وجدت) في الشجيرة اليسرى أصغر من مفتاح x، أي key(x)، ويتعيّن أن تكون جميع المفاتيح (إذا وجدت) في الشجيرة اليمنى أكبر من key(x). الخوارزمية التكرارية التالية تتحقق من الشروط أعلاه: is_BST(root): if root == NULL: return true تحقق من القيم في الشجيرة اليسرى: if root->left != NULL: max_key_in_left = find_max_key(root->left) if max_key_in_left > root->key: return false تحقق من القيم في الشجيرة اليمنى: if root->right != NULL: min_key_in_right = find_min_key(root->right) if min_key_in_right < root->key: return false return is_BST(root->left) && is_BST(root->right) رغم صحة الخوارزمية أعلاه إلا أنها تفتقر إلى الكفاءة، لأنّها تمر على كلّ عقدة عدّة مرات، ولتقليل عدد مرّات زيارة كل عقدة يجب أن نتذكر القيم الدنيا والقصوى الممكنة للمفاتيح في الشجيرة التي نزورها. سنستخدم هذه الفكرة لتطوير خوازمية أكثر فعالية. ولفعل هذا، نرمز للقيمة الدنيا الممكنة لأيّ مفتاح K_MIN، والقيمة القصوى بالرمز K_MAX. فإن بدأتَ من جذر الشجرة يكون نطاق قيم الشجرة هو [ K_MIN ،K_MAX]. وإذا كان x هو مفتاح عقدة الجذر فسيكون نطاق القيم في الشجيرة اليسرى هو [K_MIN,x)، ونطاق القيم في الشجيرة اليمنى هو (x,K_MAX]. s_BST(root, min, max): if root == NULL: return true // هل مفتاح العقدة الحالية خارج النطاق؟ if root->key < min || root->key > max: return false // التحقق مما إذا كانت الشجيرتان اليسرى واليمنى أشجارَ بحث ثنائية return is_BST(root->left,min,root->key-1) && is_BST(root->right,root->key+1,max) وستُستَدعى في البداية على النحو التالي: is_BST(my_tree_root,KEY_MIN,KEY_MAX) هناك منظور آخر لحل الأمر، وهو الاجتياز المُرتّب inorder traversal للشجرة الثنائية -انظر أدناه-، فإذا نتج عن ذلك الاجتياز المُرتب تسلسلٌ مرتّب من المفاتيح، فستكون الشجرة شجرة بحث ثنائية. وللتحقق ممّا إذا كان التسلسل الناتج مُرتّبًا أم لا، فعليك تخزين قيمة العقدة المُزارة سابقًا، ثمّ موازنتها بالعقدة الحالية. النظر في ما إن كانت شجرة ما تحقق شرط أشجار البحث الثنائية انظر المثال التالي: إن كانت المدخلات كما يلي: فإن الخرج سيكون خاطئًا false، وعليه لا تكون هذه شجرة بحث ثنائية، ذلك أنّ 4 في الشجيرة اليسرى أكبر من قيمة الجذر 3. انظر الآن مثالًا على شجرة أخرى: النتيجة ستكون صحيحة وتكون شجرة بحث ثنائية. اجتيازات الأشجار الثنائية Binary Tree traversals زيارة عقدة لشجرة ثنائية بترتيب معيّن يُسمّى اجتيازَا أو عبورًا traversal، وهناك عدة أنواع من الاجتياز سنستعرض في هذه الفقرة بعضها. الاجتياز بالمستويات: التطبيق انظر الشجرة التالية: يكون الاجتياز بالمستويات على الترتيب التالي: 1 2 3 4 5 6 7 مع طبع بيانات العُقَد مستوىً بمستوى، انظر: include<iostream> #include<queue> #include<malloc.h> using namespace std; struct node{ int data; node *left; node *right; }; void levelOrder(struct node *root){ if(root == NULL) return; queue<node *> Q; Q.push(root); while(!Q.empty()){ struct node* curr = Q.front(); cout<< curr->data <<" "; if(curr->left != NULL) Q.push(curr-> left); if(curr->right != NULL) Q.push(curr-> right); Q.pop(); } } struct node* newNode(int data) { struct node* node = (struct node*) malloc(sizeof(struct node)); node->data = data; node->left = NULL; node->right = NULL; return(node); } int main(){ struct node *root = newNode(1); root->left = newNode(2); root->right = newNode(3); root->left->left = newNode(4); root->left->right = newNode(5); root->right->left = newNode(6); root->right->right = newNode(7); printf("Level Order traversal of binary tree is \n"); levelOrder(root); return 0; } تُستخدم الصفوف Queues -وهو نوع من البيانات- لتحقيق الهدف أعلاه. الاجتياز التنازلي والتصاعدي والمرتب انظر الشجرة الثنائية التالية: الاجتياز التنازلي Pre-order traversal: يبدأ هذا النوع باجتياز العقدة، ثم الشجيرة اليسرى للعقدة، ثمّ الشجيرة اليمنى لها. ويكون الاجتياز التنازلي بالترتيب التالي: 1 2 4 5 3 6 7 الاجتياز المُرتّب In-order traversal: هو اجتياز الشجيرة اليسرى للعقدة، ثمّ العقدة نفسها، ثم الشجيرة اليمنى للعقدة. يكون الاجتياز المُرتّب بالترتيب التالي: 4 2 5 1 6 3 7 الاجتياز التصاعدي Post-order traversal: هو اجتياز الشجيرة اليسرى للعقدة، ثم الشجيرة اليمنى، ثمّ العقدة، ويكون الاجتياز التصاعدي بالترتيب التالي: 4 5 2 6 7 3 1 العثور على أدنَى سلف مشترك لشجرة ثنائية السلف المشترك الأدنى للعقدتين n1 وn2 هو أدنى عقدة في الشجرة يكون كلّ من n1 وn2 أحفادًا لها. انظر الشجرة التالية: أدنى سلف مشترك للعقدتين ذواتي القيمتين 1 و4 هو 2. أدنى سلف مشترك للعقدتين ذواتي القيمتين 1 و5 هو 3. أدنى سلف مشترك للعقدتين ذواتي القيمتين 2 و4 هو 4. أدنى سلف مشترك للعقدتين ذواتي القيمتين 1 و2 هو 2. ترجمة -بتصرّف- للفصول من 4 إلى 8 من كتاب Algorithms Notes for Professionals اقرأ أيضًا المقالة السابقة: ترميز Big-O المرجع الشامل إلى: تعلم الخوارزميات للمبتدئين تعقيد الخوارزميات Algorithms Complexity مدخل إلى الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية النسخة الكاملة من كتاب مدخل إلى الذكاء الاصطناعي وتعلم الآلة
-
- 1
-
- الخوارزميات
- الأشجار
-
(و 1 أكثر)
موسوم في:
-
ترميز Big-O هو ترميز رياضي في الأساس يُستخدم لموازنة معدّلات تقارب الدوال، وسنستعرض في هذا المقال استخدامات هذا الترميز في تحليل الخوارزميات وتصنيفها كما يلي. إذا كانت n -> f(n) وn -> g(n) دالتين مُعرّفتين على الأعداد الطبيعية، فسنقول أنّ f = O(g) فقط إذا كانت f(n)/g(n) محصورة bounded عندما يؤول n إلى اللا نهاية. أي أن f = O(g) فقط إذا كان هناك ثابت A، بحيث يكون f(n)/g(n) <= A لكل n. وفي الواقع فإن نطاق استخدام ترميز Big-O أوسع قليلاً في الرياضيات، لكن سنضيّقه في النطاق المُستخدَم في تحليل الخوارزميات للتبسيط، أي الدوال المُعرَّفة على الأعداد الطبيعية والتي ليست لها قيم صفرية أو جذور zero values عندما يؤول n إلى اللانهاية. شرح لنأخذ حالة هاتين الدالتين: f(n) = 100n^2 + 10n + 1 7 والدالة g(n) = n^2 من الواضح تمامًا أنهما تؤولان إلى اللانهاية عندما يؤول n إلى اللانهاية. لكن قد لا يكفي أحيانًا أن نعرف النهاية limit، فقد نرغب أيضًا في معرفة السرعة التي تقترب بها الدوال من نهايتها، وهنا يأتي دور ترميز Big-O إذ يساعد على تصنيف الدوال بحسَب سرعة تقاربها. فعندئذ نطبّق التعريف للتحقق ممّا إذا كانت f = O(g) حيث لدينا: f(n)/g(n) = 100 + 10/n + 1/n^2 وبما أن 10/n يساوي القيمة 10 عندما يكون n=1 ويتناقص مع تزايد قيمة n، وبما أنّ 1/n^2 تساوي 1 عندما يساوي n القيمة 1 وهو أيضًا يتناقص مع تزايد قيمة n، فنحصل على المتراجحة f(n)/g(n) <= 100 +10 + 1 = 111 وقد تحقّق شرط التعريف هنا لأنّنا وجدنا حدًّا bound للتعبير f(n)/g(n) - وهو 111-، ونكون بهذا قد أثبتنا أنّf = O(g)، ونقول أنّ f هي Big-O لـ n^2. هذا يعني أنّ f تؤول إلى اللانهاية بنفس سرعة g تقريبًا. قد يبدو هذا غريبًا في البداية لأنّنا وجدنا أنّ f أكبر بـ 111 مرة من g، أو بعبارة أخرى، عندما تنمو g بمقدار 1، فإن f تنمو بمقدار 111 على أقصى حد. والحقيقة أنّ ترميز Big-O ليس دقيقًا في تصنيف سرعات تقارب الدوال، لهذا نَستخدم علاقة التكافؤ equivalence relationship في الرياضيات عندما نريد تقديرًا دقيقًا للسرعة. لكن إن أردت تصنيف الخوارزميات إلى أصناف عامّة بحسب السرعات، فإنّ Big-O كافية، إذ لن نحتاج إلى التمييز بين دالتين تنمو إحداهما أسرع من الأخرى بعدد محدد من المرات، بل المهم هو التمييز بين الدوال التي تنمو لانهائيًا أسرع من بعضها البعض. على سبيل المثال، في حالة h(n) = n^2*log(n) نلاحظ أنّ h(n)/g(n) = log(n) تؤول إلى ما لانهاية عندما يؤول n إلى ما لا نهاية، لذا فإنّ h ليست من الصنف O (n ^ 2)، لأنّ h تنمو لانهائيًا أسرع من n ^ 2. وفي مجال تحليل تعقيد الخوارزميات، نكتب f = O(g) للدلالة على أنّ: f = O(g) و g = O(f) والتي يمكن تأويلها على أنّ g هي أصغر دالة من الصنف Big-O لــ f؛ أما في الرياضيات فنقول أنّ هاتين الدالتين Big-Theta لبعضها البعض. كيفية الاستخدام أول شيء عليك حسابه عند موازنة أداء الخوارزميات هو عدد العمليات التي تجريها الخوارزمية، وهو ما يُسمّى وقت التعقيد time complexity. ونفترض في هذا النموذج أنّ كل عملية أساسية (الجمع والضرب والمقارنة والتعيين وما إلى ذلك) تستغرق مقدارًا ثابتًا من الوقت، ونحسب عدد هذه العمليات. ونعبر في الغالب عن هذا العدد كدالة لحجم الدخل -الذي نصطلح على تسميته n-، ويؤول هذا العدد (الدالة) في الغالب إلى ما لا نهاية عندما يؤول n إلى ما لا نهاية، أما خلاف ذلك، فنقول أنّ الخوارزمية من النوع O (1). ونحن نصنّف الخوارزميات إلى أصناف بحسب سرعات الدوال ونمثّلها بالترميز Big-O: فمثلًا إن قلنا أنّ خوارزميةً ما من النوع الآتي: O (n ^ 2) فإنّنا نقصد أنّ عدد العمليات التي تنفّذها الخوارزمية -معبَّرًا عنها كدالة لـ n- هو O (n ^ 2). وهو ما يعني أنّ سرعة الخوارزمية تقارب سرعة خوارزمية تجري عددًا من العمليات يساوي مرّبع حجم الدخل أو أسرع. لاحظ لفظة أو أسرع، لقد وضعناها لأنّنا استخدمنا Big-O بدلاً عن Big-Theta، ذلك أنّه من الشائع أن ترى الناس تكتب Big-O، رغم أنّهم يعنون Big-Theta. ونحن نأخذ الحالة الأسوأ في حسابنا عادة عند عدّ العمليات، فإن كانت حلقة تكرارية تُنفَّذ n مرّةً على الأكثر وكانت تحتوي على 5 عمليات، فإنّ عدد العمليات المُقدَّر سيكون 5n. كذلك من الممكن مراعاة متوسط تعقيد الحالة. يمكن أن نأخذ مساحة التخزين بالحسبان كذلك، وهو ما يُسمّى تعقيد المساحة space complexity للخوارزمية، ذلك أن الوقت ليس المورد الوحيد المهم، وفي هذه الحالة نحسُب عدد البايتات التي تشغلها الخوارزمية في الذاكرة كدالة لحجم الدخل، ونستخدم Big-O كما في حالة تعقيد الوقت. مثال عن حلقة بسيطة Simple Loop الدالة التالية تبحث عن أكبر عنصر في المصفوفة: int find_max(const int *array, size_t len) { int max = INT_MIN; for (size_t i = 0; i < len; i++) { if (max < array[i]) { max = array[i]; } } return max; } حجم الدخل هو حجم المصفوفة، والذي سمّيناه len في الشيفرة أعلاه. دعنا الآن نَعُد العمليات. int max = INT_MIN; size_t i = 0; تُنفَّذ هاتان العمليتان مرةً واحدة، لذا فلدينا عمليتان هنا. والآن نعدّ عمليات الحلقة: if (max < array[i]) i++; max = array[i] لمّا كانت هناك 3 عمليات في الحلقة، وكانت الحلقة تُنفَّذ n مرة، فسنضيف 3n إلى 2 (العمليتان اللتان حسبناهما من قبل)، ليبلغ الإجمالي 3n + 2. تجري دالتنا إذن عددًا من العمليات مقداره 3n + 2 عملية للعثور على أكبر عنصر في المصفوفة (تعقيدها يساوي 3n + 2). وهذا يعرف بتعددية الحدود polynomial، وأسرع عواملها نموّا هو العامل n، لذا يساوي تعقيدها O (n). لعلّك لاحظت أنّ طريقة عدّ العمليات ليست دقيقة، فقد قلنا مثلًا أنّ (max < array) هي عملية واحدة، ولكن اعتمادًا على هندسة الحاسوب فقد تنطوي هذه العبارة على تعليمتين مثلاً، الأولى لقراءة الذاكرة والثانية للمقارنة. وقد اعتبرنا كذلك أنّ جميع العمليات متشابهة رغم أنّ العمليات التي تخصّ الذاكرة على سبيل المثال تكون أبطأ من العمليات الأخرى، كما يختلف أداؤها اختلافًا كبيرًا بسبب تأثيرات التخزين المؤقت cache. كذلك تجاهلنا أيضًا تعليمة الإعادة return وحقيقةَ إنشاء إطار frame خاص بالدالة، وما إلى ذلك من الأمور الجانبية. لكنّ هذا لن يؤثّر في النهاية على تحليل التعقيد، ذلك أنّه مهما كانت طريقة عدّ العمليات، فلن يؤثّر إلا على معامل العامل n وكذلك لن يؤثر على الثابت، لذا ستظل النتيجة O (n). ويُظهر التعقيد كيف تتطوّر الخوارزمية مع تطوّر حجم الدخل، بيْد أنّها ليست الجانب الوحيد الذي يمثّل الأداء. مثال عن الحلقات المتشعبة Nested Loops تتحقق الدالة التالية ممّا إذا كانت المصفوفة تحتوي أيّ تكرارات، وذلك عبر المرور على كل عنصر على حدة، ثم المرور مجدّدا على المصفوفة للتحقّق ممّا إذا كان هناك عنصر آخر يساويه. _Bool contains_duplicates(const int *array, size_t len) { for (int i = 0; i < len - 1; i++) { for (int j = 0; j < len; j++) { if (i != j && array[i] == array[j]) { return 1; } } } return 0; } تنفّذ الحلقة الداخلية عند كل تكرار عددًا ثابتًا من العمليات (بغضّ النظر عن قيمة n)، كما تنفّذ الحلقة الخارجية أيضًا بعض العمليات الثابتة علاوة على تنفيذ الحلقة الداخلية عدد n مرّة. أيضًا، تنفَّذ الحلقةُ الخارجية نفسها n مرّة، وعليه تُنفّذ العمليات داخل الحلقة الداخلية n^2 مرّة بينما تُنفّذ عمليات الحلقة الخارجية n مرّة، أمّا عملية التعيين إلى i فتُتفّذ مرّة واحدة. وهكذا يمكن التعبير عن التعقيد بمعادلة مثل an^2 + bn + c، ولمّا كان المعامل الأعلى هو n^2، فإنّ ترميز O سيساوي O(n^2). لعلك لاحظت أن هناك فرصة لتحسين الخوارزمية أكثر، إذ يمكن مثلًا تجنّب إجراء نفس المقارنات أكثرة من مرّة. يمكننا أن نبدأ من i + 1 في الحلقة الداخلية نظرًا لأنّ جميع العناصر التي تسبقه قد تم التحقق منها سلفًا وقورنت مع جميع عناصر المصفوفة، بما في ذلك العنصر الموجود عند الفهرس i + 1. هذا يسمح لنا بحذف الاختبار i == j. _Bool faster_contains_duplicates(const int *array, size_t len) { for (int i = 0; i < len - 1; i++) { for (int j = i + 1; j < len; j++) { if (array[i] == array[j]) { return 1; } } } return 0; } كما ترى فإنّ هذا الإصدار أفضل لأنّه يُجري عمليات أقل، لكن كيف نترجم هذا في ترميز Big-O؟ حسنًا، في الإصدار الجديد، يُنفّذ مَتن الحلقة الداخلية المرات 1 + 2 + ... + n - 1 = n(n-1)/2 لا يزال هذا التعبير كثير الحدود من الدرجة الثانية، لذا سيظلّ التعقيد مساويا لـ O(n^2). وقد قلّلنا التعقيد إذ خفّضنا عدد العمليات إلى النصف تقريبًا، بيْد أنّنا ما زلنا في نفس صنف التعقيد من Big-O. ونحتاج إلى تقسيم عدد العمليات على مقدار يؤول إلى اللا نهاية مع n من أجل تقليل التعقيد إلى صنف أدنى. الخوارزميات اللوغاريتمية لنفترض أنّ لدينا مشكلة ذات حجم n، ولنفترض أنّ حجم مشكلتنا الأصلية ينخفض إلى النصف (n / 2) بعد كل خطوة من خطوات الخوارزمية. أي أنّه في كل خطوة جديدة يصير حجم المشكلة نصف ما كانت عليه في الخطوة التي قبلها: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } الخطوة المشكلة 1 n/2 2 n/4 3 n/8 4 n/16 تكون المشكلة محلولة حين يتعذر تقليل حجمها أكثر بعد الخروج من شرط التحقق، أي عندما تكون n مساوية للقيمة 1. لنفترض الآن أنّ حجم المشكلة يساوي n، نريد أن نحسب عدد الخطوات اللازمة لحل الخوارزمية، سنرمز لهذا العدد بالحرف k: عند الخطوة k، سيساوي حجم المشكلة 1 (أي أنّ المشكلة ستكون قد حُلَّت). من جهة أخرى، نعلم أنّه عند الخطوة k، ينبغي أن يساوي حجم المشكلة العدد (n/2^k). من 1، 2، إذًا n = 2k. طبّق دالة اللوغاريتم على كلا الجانبين: log n = k * loge_2 → k = loge n / loge 2 باستخدام المعادلة التالية: logx (m) / logx (n) = logn (m) حيث يرمز التعبير logt إلى اللوغاريتم ذي الأساس t، وتصبح النتيجة k = log2 (n) أو k = log n ببساطة. انظر المثال التوضيحي التالي: for(int i=1; i<=n; i=i*2) { // إجراء بعض العمليات } إن كان n يساوي 256، فكم تتوقّع أن يكون عدد الخطوات التي ستنفّذها الحلقة أو أي خوارزمية أخرى ينخفض حجم مشكلتها إلى النصف بعد كل خطوة؟ سنرمز للعدد الذي نبحث عنه بالرمز k، ونحسبه على النحو الآتي: k = log2 (256). k = log2 (2^8) ( نعلم أنّ log(a^a) = 1 لكل عدد a). k = 8. هذا مثال آخر لتوضيح هذا النوع من الخوارزميات. وهي خوارزمية البحث الثنائي Binary Search Algorithm. int bSearch(int arr[],int size,int item){ int low=0; int high=size-1; while(low<=high){ mid=low+(high-low)/2; if(arr[mid]==item) return mid; else if(arr[mid]<item) low=mid+1; else high=mid-1; } return –1;// لا يوجد } مثال على خوارزمية من الصنف O (log n) انظر المشكلة التالية: L قائمة مرتّبة تحتوي n عددًا صحيحًا نسبيًا (n كبيرة جدا)، على سبيل المثال [-5, -2, -1, 0، 1، 2، 4] (هنا، n تساوي 7). إذا كانت L تحتوي العدد الصحيح 0، فكيف يمكنك العثور على فهرس 0؟ المقاربة البسيطة أول ما يتبادر إلى الذهن هو قراءة كل فهرس إلى حين العثور على 0، وفي أسوأ الحالات سيكون علينا إجراء n عملية، وعليه فإنّ التعقيد سيساوي O (n). لا مشكلة في هذا مع القيم الصغيرة لـ n، ولكن هل هناك طريقة أفضل؟ مبدأ الحصار Dichotomy انظر الخوارزمية التالية Python3: a = 0 b = n-1 while True: h = (a+b) // (*) if L[h] == 0: return h elif L[h] > 0: b = h elif L[h] < 0: a = h وفي أسوأ الحالات، علينا الانتظار حتى يتساوى a وb، لكن كم عدد العمليات التي يتطلبها ذلك؟ قطعًا ليس n لأننا نقسم المسافة بين a وb على 2 في كل مرة ندخل إلى الحلقة، لذا فالتعقيد يساوي O (log n). إذا صادفت خوارزمية يُقسم حجمها على عدد معيّن (2 أو 3 أو أيّ عدد) بعد كل خطوة، فإنّ تعقيدها سيكون لوغاريتميًا. ترجمة -بتصرّف- للفصل الثالث من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقالة السابقة: تعقيد الخوارزميات مدخل إلى الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية مدخل إلى الذكاء الاصطناعي وتعلم الآلة
-
تستعرض هذه المقالة مفهوم التعقيد Complexity، وهو أحد المفاهيم الأساسية في علم الخوارزميات، مع ذكر بعض أهم أصناف التعقيد وبعض الترميزات المستخدمة في ذلك. ترميز Big-Omega تُستخدم ترميز Ω -notation لتمثيل المقارِب الأدنى asymptotic lower bound. التعريف الرسمي لتكن fn) و g(n) دالتين مُعرّفتين على مجموعة الأعداد الحقيقية الموجبة، فنقول أنّ f(n) = Ω(g(n)) في حال وُجِدت ثابتان موجبان c و n0 يحققان الآتي لكل n أكبر من أو تساوي n0: 0 ≤ c g(n) ≤ f(n) نظرية بالنسبة لدالتين f(n) و g(n)، نقول أنّ f(n) = Ө(g(n)) فقط إذا كان الآتي محققًا: f(n) = O(g(n)). f(n) = Ω(g(n)). يمكن تمثيل ترميز Ω بيانيًا على النحو التالي: على سبيل المثال، إذا كانت f(n) = 3n^2 + 5n - 4 فستكون f(n) = Ω(n^2)، كما ستكون f(n) = Ω(n) أو حتى f(n) = Ω(1) صحيحة. لدينا مثال آخر يحل خوارزمية التطابق التام matching algorithm، حيث إذا كان عدد الرؤوس vertices فرديًا، فسنحصل على الخرج "No Matching Matching"، وإلا فينبغي تجربة جميع التطابقات الممكنة. وكنا نودّ القول أنّ الخوارزمية تتطلب وقتًا أسيًا exponential time، لكن الواقع أنه لا يمكنك إثبات وجود حدّ أدنى Ω(n^2) باستخدام التعريف المعتاد لـ Ω نظرًا لأنّ الخوارزمية تُجرى في وقت خطي linear time لقيم n الفردية. وبدلًا من هذا فيجب علينا تعريف f(n)=Ω(g(n)) على نحو أنه بالنسبة لثابت c قيمته أكبر من الصفر، فهناك عدد لا نهائي من قيم n التي تحقّق الآتي: f(n)≥ c g(n) يوفّق هذا التعريف بين الحدّين الأعلى والأدنى إذ يحقّق التكافؤ الآتي: f(n)=Ω(g(n)) فقط إن كان f(n) != o(g(n)) ترميز Big-Theta على خلاف ترميز Big-O الذي يمثل الحد الأعلى upper bound فقط من وقت تشغيل الخوارزميات، فإنّ ترميز Big-Theta هو رابط محصور Tight bound يشمل الحدّ العلوي والسفلي معًا، وهو أكثر دقة لكنه صعب الحساب. وترميز Big-Theta متماثلة، أي تحقق المعادلة: f(x) = Ө(g(x)) <=> g(x) = Ө(f(x)) ولتفهم ذلك بسهولة، اعلم أن المعادلة الآتية f(x) = Ө(g(x)) <=> g(x) = Ө(f(x))f(x) = Ө(g(x)) تعني أنّ الرسمين البيانيين للدالتين f و g ينمُوان بنفس المعدّل، أو أنّ الرسمين البيانيين "يتصرّفان" بشكل متماثل عند قيم x الكبيرة. والتعبير الرياضي الكامل لترميز Big-Theta هو كما يلي: Ө(f(x)) = {g: N0 -> R and c1, c2, n0 > 0} حيث تكون c1 < abs(g(n) / f(n)) لكل n أكبر من n0، وتمثل abs القيمة المطلقة. ﻣﺜــــﺎل إذا كانت الخوارزمية تأخذ العدد الآتي عمليةً للانتهاء مقابل مُدخل n، فنقول أنّ تعقيدها يساوي O(n^2) ، كما يساوي أيضًا O(n^3) وO(n^100). 42n^2 + 25n + 4 أمّا في حال ترميز Big-Theta، فنقول أنّ التعقيد يساوي Ө(n^2)، لكن لا يساوي Ө(n^3) أو Ө(n^4)، إلخ… . كذلك، فإن كانت الخوارزمية من نوع Ө(f(n))، فستكون أيضًا من النوع O(f(n))، ولكن ليس العكس. التعريف الرياضي تُعرّف Ө(g(x)) على أنها مجموعة من الدوال، أما تعريفها الرياضي الرسمي Formal mathematical definition فهو: Ө(g(x)) = f(x)} وإذا كانت هناك ثوابت موجبة N وc1 و c2، بحيث يكون 0 <= c1*g(x) <= f(x) <= c2g*(x) لكل x أكبر من N}. وهذا يعني أنّ Ө(g(x)) هي مجموعةٌ تحتوي كلّ دالة f مُحاصَرة من قبل الدالة g، بمعنى أنّه توجد 3 ثوابت موجبة هي c1 و c2 وN، بحيث يكون لدينا: 0 <= c1*g(x) <= f(x) <= c2*g(x) لكل عدد x أكبر من N. وبما أن Ө(g(x)) مجموعةً فيمكننا كتابة الآتي للإشارة إلى أنّ f(x) تنتمي إلى Ө(g(x)). f(x) ∈ Ө(g(x)) غير أن الشائع هو كتابة الآتي للتعبير عن نفس الترميز: f(x) = Ө(g(x)) ويمكن تفسير Ө(g(x)) عندما تظهر في ترميز ما على أنّها كناية عن دالة مجهولة لا تهمّنا تسميتها، فمثلًا، المعادلة الآتية: T(n) = T(n/2) + Ө(n) تكافئ T(n) = T(n/2) + f(n) حيث تمثّل f(n) دالةً ما من المجموعة Ө(n)ا وإن كانت f و g دالتين مُعرَّفتين على نفس النطاق من مجموعة الأعداد الحقيقية real numbers، فإننا نكتب الآتي: f(x) = Ө(g(x)) عندما تؤول x إلى ما لا نهاية x->infinity، وذلك فقط في حالة وجود ثابتْين موجبيْن K و L وعدد حقيقي x0 يحققون K|g(x)| <= f(x) <= L|g(x)| لكل x أكبر من أو تساوي x0. يكون التعريف مكافئًا للعبارة التالية: f(x) = O(g(x)) والعبارة: f(x) = Ω(g(x)) استخدام مفهوم النهايات limits إذا كان لدينا limit(x->infinity) f(x)/g(x) = c ∈ ( 0,∞) أي أنّ النهاية موجودة وموجبة، أي: f(x) = Ө(g(x)) وفيما يلي بعض أصناف التعقيد الشائعة: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } الاسم الترميز n = 10 n = 100 Constant - ثابت Ө(1) 1 1 Logarithmic - لوغارتمي Ө(log(n)) 3 7 Linear - خطي Ө(n) 10 100 Linearithmic - لوغارتمي-خطي Ө(n*log(n)) 30 700 Quadratic - تربيعي Ө(n^2) 100 10000 Exponential - أسّي Ө(2^n) 1024 1.267650e+ 30 Factorial - مُعاملي Ө(n!) 3 628 800 9.332622e+157 موازنة الصيغ المقاربة asymptotic notations لتكن f(n) و g(n) دالتين مُعرّفتين على مجموعة الأعداد الحقيقية الموجبة، ولتكن c, c1, c2, n0 ثوابت حقيقية موجبة. يوضّح الجدول التالي الفروق بين مختلف الصيغ: الترميز f(n) = O(g(n)) f(n) = Ω(g(n)) f(n) = Θ(g(n)) f(n) = o(g(n)) f(n) = ω(g(n)) التعريف الرسمي ∃ c > 0, ∃ n0 > 0 : ∀ n ≥ n0, 0 ≤ f(n) ≤ c g(n) ∃ c > 0, ∃ n0 > 0 : ∀ n ≥ n0, 0 ≤ c g(n) ≤ f(n) ∃ c1, c2 > 0, ∃ n0 > 0 : ∀ n ≥ n0, 0 ≤ c1 g(n) ≤ f(n) ≤ c2 g(n) ∀ c > 0, ∃ n0 > 0 : ∀ n ≥ n0, 0 ≤ f(n) < c g(n) ∀ c > 0, ∃ n0 > 0 : ∀ n ≥ n0, 0 ≤ c g(n) < f(n) التشابه مع الموازنة بين عددين a وb a ≤ b a ≥ b a = b a < b a > b أمثلة 7n + 10 = O(n^2 + n - 9) n^3 - 34 = Ω(10n^2 - 7n + 1) 1/2 n^2 - 7n = Θ(n^2) 5n^2 = o(n^3) 7n^2 = ω(n) الرسوم البيانية يمكن تمثيل الصيغ المُقارِبة بواسطة مخطّط فِن كما يلي: ترجمة -بتصرّف- للفصل الثاني من كتاب Algorithms Notes for Professionals اقرأ أيضًا المقالة السابقة: مدخل إلى الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية بناء مصنف بالاعتماد على طرق تعلم الآلة بلغة بايثون باستخدام مكتبة Scikit-Learn النسخة الكامة من كتاب مدخل إلى الذكاء الاصطناعي وتعلم الآلة
-
نتعرف في مقال اليوم على مفهوم الخوارزميات Algorithms التي تعد واحدة من المفاهيم الأساسية المستخدمة في مجال الرياضيات والبرمجة وعلوم الحاسوب والعديد من المجالات المختلفة الأخرى كما يمكننا تطبيقها في حل مشكلات حياتنا اليومية، ونكتشف أهم خصائصها وطرق كتابتها والتعبير عنها وطريقة تحويلها إلى كود برمجي. كما نوفر في ختام المقال أهم النصائح التي تساعدك في تعلم الخوارزميات بالطريقة الصحيحة. ما هي الخوارزميات Algorithms الخوارزمية هي مصطلح يشير إلى مجموعة من الخطوات المرتبة المتبعة لحل مشكلة ما أو إنجاز مهمة معينة، ويعود أصل هذا المفهوم إلى العالم المسلم أبي عبد الله محمد بن موسى الخوارزمي مؤلف كتاب “المختصر في حساب الجبر والمقابلة” والذي قدم فيه طرقًا منهجية جديدة لإجراء العمليات الحسابية وحل المعادلات الخطية والتربيعية بشكل خطوات متسلسلة ومن هنا نشأت كلمة خوارزمية نسبة إلى طريقة التفكير التي أوجدها لحل المسائل والتي كان لها أثرًا كبيرًا في تطوير الخوارزميات المستخدمة في شتى المجالات. فهناك خوارزميات تستخدم في مجال الرياضيات مثل خوارزمية خوارزمية إقليدس Euclidean algorithm وخوارزمية غربال إراتوستينس Sieve of Eratosthenes و خوارزميات تستخدم في مجال البرمجة وعلوم الحاسوب مثل خوارزميات الترتيب Sorting algorithms التي تساعدك على ترتيب مجموعة من العناصر وفق ترتيب تصاعدي، ويفيدنا الترتيب في حل الكثير من المشكلات الحاسوبية بسهولة أكبر على سبيل المثال البحث عن اسم ما في سلسلة عناصر مرتبة وفق التسلسل الأبجدي أسرع وأسهل من البحث في سلسلة عناصر غير مرتبة لذا من الأفضل أن ترتب العناصر قبل البحث فيها. كما تستخدم الخوارزميات كذلك في حياتنا اليومية في العديد من المواقف، على سبيل المثال لكل منا طريقة أو خوارزمية يعد من خلالها كوب القهوة الصباحي يوميًا بنفس الطريقة، بالنسبة لي أتبع الخطوات التالية: املأ الركوة أو دلة القهوة بالماء. سخّن الماء. انتظر حتى يغلي الماء. أضف القهوة وحركها. اسكب القهوة في الكوب. أعتقد أن فكرة الخوارزمية قد أصبحت واضحة بالنسبة لك، فهي ببساطة ليست سوى مجوعة من الخطوات المرتبة لإنجاز مهمة محددة وقد تختلف خطوات الخوارزمية المتبعة لإنجاز المهمة من شخص لآخر لكن المهم أن تكون النتيجة النهائية صحيحة. سنركز في فقراتنا التالية على مفهوم الخوارزميات في علوم الحاسوب والتي تعني بشكل محدد مجموعة من الخطوات المفصلة والمرتبة التي تخبر الحاسوب بطريقة أداء مهمة ما والتي تحوّل بسهولة إلى برامج حاسوبية يمكن للحاسوب تنفيذها. خصائص الخوارزميات يجب أن تتسم الخوارزمية بمجموعة من المواصفات أو الخصائص الأساسية، وتساعدك معرفة هذه الخصائص على تصميم الخوارزمية بشكل صحيح، ومن أهم هذه الخصائص: يجب أن تتكون الخوارزمية من تعليمات محددة وواضحة ومفهومة وقابلة للتنفيذ. يجب أن تكون الخوارزمية فعالة وقادرة على حل المشكلة المطلوبة بطريقة صحيحة وبسيطة قدر المستطاع. يمكن أن لا تحتوي الخوارزمية على مدخلات أو قد تتضمن عدة مدخلات. يجب أن تحتوي الخوارزمية على خرج واحد أو أكثر وينبغي أن تؤدي نفس المدخلات دائمًا إلى نفس النتائج أو المخرجات. يجب أن تجد الخوارزمية حلاً للمشكلة المطلوبة بعد عدد منتهي من الخطوات وتنجز خلال فترة زمنية محددة لتكون فعالة وذات جدوى. طرق تمثيل الخوارزمية يمكن تمثيل الخوارزمية بطرق مختلفة تصف من خلالها كيفية حل المشكلة، وفيما يلي نوضح عدة طرق ممكنة لتمثيل الخوارزمية: يمكنك تمثيل الخوارزمية بلغتك المحكية أو اللغة العامية بأي طريقة تفهمها وتستوعبها دون الحاجة للالتزام بأي قواعد محددة، وهذا الأسلوب يسهل عليك مشاركة الخوارزمية مع أشخاص آخرين مهما كانت خلفياتهم التقنية. كما يكمن أن تتبع طريقة أكثر رسمية في التعبير اللفظي باستخدام ما يعرف بالكود الزائف Pseudo-code الذي يشبه أسلوب الكتابة في لغة البرمجة ويعمل كحل وسط بين اللغة المحكية ولغة البرمجة. كما يمكن تمثيل الخوارزمية بطريقة رسومية باستخدام المخططات الهيكلية أو المخططات الانسيابية Flowcharts التي تتكون من رموز وأشكال أساسية مرتبطة بأسهم تظهر الترتيب المنطقي للخطوات، ولكل شكل هدف محدد. حيث يمثل الشكل البيضوي بداية ونهاية المخطط، ويمثل المستطيل عملية ما، ويشير متوازي الأضلاع إلى إدخال وإخراج البيانات، ويدل المعين على عملية اتخاذ قرار. على سبيل المثال إذا جربنا كتابة خوارزمية صنع كوب قهوة التي وضحناها سابقًا بشكل لفظي على شكل كود زائف يمكن أن نعبر عنها بالأسلوب التالي: ابدأ . املأ دلة القهوة بالماء. سخن الماء. تحقق مما إذا كان الماء يغلي. إذا كان الجواب صحيح انتقل إلى الخطوة التالية. إذا لم يكن الجواب صحيح عد إلى الخطوة السابقة. ضع القهوة فوق الماء المغلي وحركها. اسكب القوة في الكوب. النهاية. ويمكن كذلك تمثيل خوارزمية عمل كوب القهوة بشكل مخطط تدفقي رسومي كما يلي: خطوات كتابة الخوارزمية لتحديد خطوات الخوارزمية وكتابتها بشكل صحيح علينا بدايةً فهم المشكلة بشكل جيد وبعدها البدء بتحديد مشكلة الخوارزمية وتحديد مدخلات الخوارزمية من خلال تحديد كافة الأمثلة أو الحالات التطبيقية instances التي تعمل عليها الخوارزمية، ثم تحديد الخرج التي سنحصل عليه بعد تشغيل الخوارزمية على إحدى تلك الحالات، واعلم أن التمييز بين المشكلة نفسها والحالة التطبيقية عليها instance مهم جدًا. على سبيل المثال تُعرَّف مشكلة خوارزمية الفرز أو الترتيب sorting على النحو التالي: المشكلة: الترتيب. المدخلات: تسلسل من عدد n من المفاتيح a_1, a_2, ..., a_n الخرج: إعادة ترتيب تسلسل المدخلات بحيث يكون لدينا: b_1, b_2,<= ... <= b_{n-1}, b_n يمكن أن تعمل خوارزمية الفرز على عدة حالات instances، على سبيل المثال يمكن أن يكون دخل هذه الخوارزمية عبارة عن مصفوفة من السلاسل النصية التي نحتاج لترتيبها أبجديًا مثل {Haskell, Emacs} أو يكون عبارة عن تسلسل من الأرقام التي نريد ترتيبها تصاعديًا مثل {154، 245، 1337}. الخطوة التالية هي معرفة التعليمات التي ستعمل بها الخوارزمية وتعالج المدخلات للحصول على المخرجات وكتابتها بالترتيب الصحيح والتعبير عنها بالطريقة التي تفضلها وبعدها ستكون جاهزًا لتحويل الخوارزمية إلى كود برمجي قابل للتنفيذ على الحاسوب. مثال على كتابة خوارزمية Fizz Buzz سنوضح في هذه الفقرة كيفية حل مسألة Fizz Buzz وهي خوارزمية معروفة تحل مشكلة بسيطة ويطلب منك حلها في معظم المقابلات البرمجية والهدف منها طباعة مجموعة من الأعداد الصحيحة من واحد إلى N ولكنك ستطبع كلمة Fizz إذا كان العدد قابلاً للقسمة على 3، و كلمة Buzz إذا كان العدد قابلاً للقسمة على 5، وكلمة Fizzbuzz إذا كان العدد قابلاً للقسمة على العددين 3 و 5 بذات الوقت. يشير المعنى الحرفي لهاتين الكلمتين إلى الصوت التي تحدثه كل كلمة منهما، ويرجع أصل استخدامها إلى لعبة أطفال شهيرة تُسمّى fizz buzz تقوم على نفس المبدأ وتستخدم لتعلم الأطفال عملية القسمة. في الفقرات التالية سنكتب هذه الخوارزمية بلغة البرمجة سويفت Swift وفي حال لم تكن تملك خبرة في هذه اللغة اكتبها بأي لغة برمجة تعرفها مثل لغة بايثون أو جافا او اكتبها بالكود الزائف. بفرض أن دخل الخوارزمية هو سلسلة الأرقام التالية: 1 2 3 4 5 6 7 8 9 10 كما وضحنا سابقًا، يشير المصطلحَان Fizz و Buzz إلى أيّ عدد مضاعف للعدد 3 أو 5 على الترتيب، أي إذا كان العدد يقبل القسمة على 3 فيمكن استبداله بالكلمة Fizz، وإذا كان قابلاً للقسمة على 5 فيمكن استبداله بكلمة Buzz. أما إذا كان من مضاعفات 3 و 5 بذات الوقت فيُستبدَل بكلمة FzzBuzz أي سيكون الخرج كما هو مبين في الصورة التالية: يمكن التعبير عن الخوارزمية بشكل كود زائف كما يلي: ابدأ. قم بتكرار الأعداد من 1 إلى 10. تحقق مما إذا كان العدد الحالي قابلاً للقسمة على 3. إذا كان الجواب صحيح اطبع الكلمة fizz. إذا لم يكن الجواب صحيح، انتقل إلى الخطوة التالية. تحقق فيما إذا كان العدد الحالي قابلاً للقسمة على 5. إذا كان الجواب صحيح اطبع الكلمة buzz. إذا لم يكن الجواب صحيح، اعرض العدد الحالي. كرر الحلقة حتى تصل إلى الرقم 10. النهاية. لنعبر عن هذه الخطوات من خلال رسم مخطط تدفقي يوضح بشكل رسومي خطوات عمل الخوارزمية. تنفيذ خوارزمية Fizz Buzz من خلال لغة البرمجة Swift بعد أن فهمت الخوارزمية ستتمكن من تنفيذ هذه الخوارزمية بسهولة وتحويلها لبرنامج حاسوبي، افتح محرر الأكواد البرمجية Xcode أو VS code أو أي محرر آخر تفضله لكتابة برنامج جديد، يبدأ البرنامج بتعلمية تهيئة مصفوفة من 1 إلى 10 let number = [1,2,3,4,5,6,7,8,9,10] نريد أن نعالج مصفوفة الدخل والحصول على الخرج المطلوب، أي نريد أن نستبدل 3 بالكلمة fizz هنا و 5 بالكلمة buzz كما شرحنا سابقًا. ولتحقيق ذلك نمرّ على جميع عناصر المصفوفة والتحقق من كل عنصر من عناصرها ولذا سننشئ حلقة for تمرّ عبر هذه العناصر كما يلي: for num in number { // الحسابات هنا } بعد هذا، سنستخدم العبارة الشرطية if else، وعامل باقي القسمة module operator في لغة swift أي % لتحديد مواقع fizz و buzz كما يلي: for num in number { if num % 3 == 0 { print("\(num) fizz") } else { print(num) } } يكون الخرج الناتج من تنفيذ البرنامج هو كالتالي: 1 2 3 fizz 4 5 6 fizz 7 8 9 fizz 10 سنضيف الآن الجزء المتعلق بالكلمة Buzz مستخدمين نفس الآلية التي اتبعناها في كتابة الكود السابق: for num in number { if num % 3 == 0 { print("\(num) fizz") } else if num % 5 == 0 { print("\(num) buzz") } else { print(num) } } بعد إضافة الكود السابق سيكون الخرج الناتج من تنفيذ البرنامج بالشكل التالي: 1 2 3 fizz 4 5 buzz 6 fizz 7 8 9 fizz 10 سنزيد عناصر المصفوفة إلى 1-15. لاحظ أنّه بما أن 15 مضاعف لكلّ من 3 و 5، فينبغي استبدالها بـ fizz buzz: for num in number { if num % 3 == 0 && num % 5 == 0 { print("\(num) fizz buzz") } else if num % 3 == 0 { print("\(num) fizz") } else if num % 5 == 0 { print("\(num) buzz") } else { print(num) } } لا تزال لدينا مشكلة قائمة في الكود أعلاه، فالغرض الأساسي من الخوارزمية هو ترشيد وقت التنفيذ. تخيّل لو زدنا نطاق المصفوفة من 1-15 إلى 1-100، سيكون على مُصرِّف اللغة فحص كل عدد على حدة لتحديد ما إذا كان قابلاً للقسمة على 3 أو 5، ثمّ سيمرّ على الأعداد ثانيةً للتحقق ممّا إذا كانت قابلة للقسمة على 3 و 5 (معًا)، كما سيتعيّن على الشيفرة أن تمرّ على كلّ عدد في المصفوفة مرّتين لأنّها ستتحقق من قابلية قسمة العدد على 3 أولًا، ثمّ على 5. ولتسريع هذه العملية، يمكن أن نأمر المصرّف بقسمة الأعداد على 15 مباشرة. انظر الشيفرة النهائية: for num in number { if num % 15 == 0 { print("\(num) fizz buzz") } else if num % 3 == 0 { print("\(num) fizz") } else if num % 5 == 0 { print("\(num) buzz") } else { print(num) } } هنيئًا لك، بهذا تكون قد كتبت أوّل خوارزمية لك، يمكنك استخدامها في أيّ لغة برمجة، وستعمل كما هو مُتوقّع. تعلم الخوارزميات إن تعلم الخوارزميات خطوة مهمة لأي شخص مهتم بالبرمجة والتطوير فهي تساعده على فهم البرمجة بشكل أفضل وتكسبه مهارة فهم المشكلات البرمجية المعقدة واختيار الطرق الأنسب والأكثر كفاءة لحلها. كما يرتبط تعلم الخوارزميات مع تعلم هياكل البيانات التي تساعدك على تنظيم وإدارة وتخزين ومعالجة البيانات بكفاءة وتتضمن معظم مقابلات العمل للوظائف البرمجية أسئلة متعلقة بفهم أساسيات الخوارزميات وهياكل البيانات لذا من الضروري لأي مهتم بمجالات العمل البرمجي تعلمها بشكل جيد. وإليك أهم النصائح والخطوات التي تساعدك على تعلم الخوارزميات بكفاءة: عزز مهارة التفكير المنطقي فهي مهارة أساسية يحتاجها أي مبرمج. درب نفسك على التفكير الخوارزمي أي التفكير في أي مشكلة تواجهك بطريقة تشبه طريقة تفكير الحاسوب من خلال تحديد الخطوات المفصلة للحل أو تقسيم المشكلة إلى مشكلات أصغر وحل كل جزء على حدا. تعلم أهم أنواع الخوارزميات المعروفة التي يحتاجها معظم المطورين والطرق المختلفة لتنفيذها وكفاءة كل طريقة منها. تعلم كيف تحلل أي مشكلة برمجية وتفهمها جيدًا وتخطط لها على الورق وتمثلها بالطرق الخوارزمية وتأكد من أنك فهمتها بشكل كامل قبل كتابة كودها البرمجي. تعرف على أهم هياكل البيانات في لغة البرمجة التي تستخدمها لكتابة برامجك وتطبيقاتك وفكر بالبنية الأفضل لحل مشكلتك. طبق الخوارزميات لحل مشكلات بسيطة وتدرب على حل التحديات البرمجية Problem Solving وهي كثيرة عبر الإنترنت وتضم الكثير من المهتمين بالبرمجة والتطوير وقارن إجاباتك مع إجابات الآخرين وتناقش معهم حول حلولهم لتكسب المزيد من الخبرات. فكر في تنفيذ عدة طرق لحل المشكلات البرمجية التي تواجهك وقارن أيّ هذه الطرق أسرع وأكثر كفاءة وفعالية. تعلم مفهوم تعقيد الخوارزميات الذي يعبر عن مقدار الوقت أو مساحة الذاكرة التي تستغرقها الخوارزمية للتنفيذ واستفذ منه في كتابة شيفرات برمجية فعالة. اعتمد مصادر تعلم موثوقة ومنهجية، ستجد في أكاديمية حسوب الكثير من المقالات والدروس المفيدة لتعلم البرمجة والخوارزميات، كما توفر موسوعة حسوب توثيقًا باللغة العربية لأشهر الخوارزميات التي يحتاج أي مطور لمعرفتها، كما يمكنك الانضمام إلى دورة علوم الحاسوب التي تضم العديد من المسارات المفيدة لتعلم أساسيات البرمجة وتتضمن عدة مسارات تشرح الخوارزميات وطريقة التفكير المنهجي في حل أي مشكلة وكتابة خوارزمياتها قبل البدء ببرمجتها مما يصقل مهاراتك التقنية لتفكر كمهندس برمجيات بدلاً من أن تكون مجرد مبرمج يكتب الأكواد وينفذها فحسب. الخلاصة تعرفنا في مقال اليوم على ما هي الخوارزميات وما الخطوات التي يجب اتباعها لكتابة الخوارزمية وتمثيلها بطرق مختلفة واستعرضنا في الختام أهمية تعلم الخوارزميات واكتساب مهارة التفكير المنهجي لحل المشكلات بطريقة تشبه طريقة عمل الحاسوب وأهم النصائح التي تساعدك على تعلمها وإتقانها كخطوة أساسية قبل تطوير مهارات البرمجة والتطوير. ترجمة -بتصرّف- للفصل الأول من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المرجع الشامل إلى تعلم الخوارزميات للمبتدئين دليل شامل عن تحليل تعقيد الخوارزمية بناء مصنف بالاعتماد على طرق تعلم الآلة بلغة بايثون باستخدام مكتبة Scikit-Learn - النسخة الكاملة لكتاب مدخل إلى الذكاء الاصطناعي وتعلم الآلة
-
تُعرف خوارزمية ديكسترا بخوارزمية أقصر مسار أحادي المصدر single-source shortest path algorithm، وتُستخدم للعثور على أقصر المسارات بين العقد في مخطط، وقد ابتُكرت هذه الخوارزمية على يد إدجستر ديكسترا Edsger W. Dijkstra -النطق من الهولندية- عام 1956، ونُشِرت بعدها بثلاث سنوات. يمكننا العثور على أقصر مسار باستخدام خوارزمية البحث بالوُسع أو العرض أولًا Breadth First Search اختصارًا BFS. وتعمل هذه الخوارزمية جيدًا لكنّها محدودة، إذ تفترض أنّ تكلفة cost اجتياز كل مسار لا تتغيّر، أي أنّ أوزان جميع الأضلاع متساوية. تساعدنا خوارزمية ديكسترا على العثور على أقصر مسار حتى لو كانت تكاليف المسارات مختلفة. سنتعلّم في البداية كيفية تعديل خوارزمية BFS لكتابة خوارزمية Dijkstra، ثم سنضيف طابور الأولويات priority queue لجعلها خوارزمية Dijkstra كاملة. لنفترض أنّ المسافة بين كل عقدة وبين المصدر مُخزّنة في مصفوفة d[]، حيث تمثّل d[3] مثلًا الوقت اللازم للوصول إلى العقدة 3 من المصدر، وإذا لم نعرف المسافة فسنضع قيمة ما لا نهاية infinity في d[3]. لنفترض أن cost مصفوفة تخزّن التكاليف، بحيث تحتوي cost[u][v] على كلفة مجموع أوزان الأضلاع التي تؤلف المسار u-v، أي أنّ الانتقال من العقدة u إلى العقدة v يتطلب تكلفة cost[u][v]. قبل أن نواصل، سنوضّح مفهوم تخفيف الضلع Edge Relaxation. لنفترض أنّك تحتاج 10 دقائق للانتقال من منزلك (المصدر) إلى المكان A، بينما يستغرق الانتقال إلى المكان B حوالي 25 دقيقة. سيكون لدينا إذًا الآتي: d[A] = 10 d[B] = 25 لنفترض الآن أنّك تحتاج 7 دقائق للانتقال من A إلى B، هذا يعني أنّ: cost[A][B] = 7 يمكننا الذهاب إلى B عبر الانتقال من المصدر (منزلك) إلى A، ثمّ من A إلى B، وسيستغرق هذا 10 + 7 = 17 دقيقة بدلًا من 25 دقيقة: d[A] + cost[A][B] < d[B] نحدّث الجدول على النحو التالي: d[B] = d[A] + cost[A][B] يسمّى هذا تخفيفًا relaxation، إذ نذهب من u إلى v، فإذا وجدنا أنّ < d[u] + cost[u][v] < d[v]، فسنحدّث المصفوفة بالقيمة d[v] = d[u] + cost[u][v]. لم نكن بحاجة إلى زيارة أيّ عقدة مرتين في خوارزمية البحث بالوُسع أولًا BFS، فقد اكتفينا بالتحقّق ممّا إذا تمّت زيارة العقدة أم لا، فإن لم تُزَر، فسنضع العقدة في الطابور ثمّ نحددها على أنّها "تمت زيارتها"، بعدها نزيد المسافة بمقدار 1؛ أما في خوارزمية Dijkstra، فإننا نضع العقدة في الطابور، لكن بدلًا من تحديثها وتحديدها على أنّها مُزارة، فسنخفّف الضلع الجديد relax أو نحدّثه. انظر المثال التالي: لنفترض أنّ العقدة 1 هي المصدر، إذًا: d[1] = 0 d[2] = d[3] = d[4] = infinity (or a large value) لقد عيّنا قيم d [2] وd [3] وd [4] إلى ما لا نهاية لأنّنا لا نعرف المسافة بعد، أمّا مسافة المصدر فتُساوي 0 بالطبع. سننتقل الآن إلى العُقد الأخرى انطلاقًا من المصدر، وإذا كانت هناك حاجة لتحديثها فسنضيفها إلى الطابور. لنقل على سبيل المثال أنّنا سنجتاز الضلع 1-2، وبما أن d[1] + 2 < d[2]، فسنضع d[2] = 2، وبالمثل نجتاز الضلع 1-3، وتبعًا لذلك نكتب d [3] = 5. يمكن أن ترى بوضوح أنّ 5 ليست أقصر مسافة يمكننا عبورها للذهاب إلى العقدة 3، لذا فإنّ اجتياز العقدة مرّةً واحدةً فقط كما في خوارزمية BFS، ليس صالحًا هنا. ويمكننا إضافة التحديث d [3] = d [2] + 1 = 3 إذا انتقلنا من العقدة 2 إلى العقدة 3 باستخدام الضلع 2-3. وكما ترى، فمن الممكن تحديث العقدة الواحدة عدّة مرات. قد تسأل عن عدد المرات التي يمكن أن نفعل فيها ذلك، والإجابة هنا هي أن الحد الأقصى لعدد مرات تحديث العقدة هو الدرجة الداخلية in-degree لتلك العقدة. فيما يلي شيفرة عامّة لزيارة أيّ عقدة عدّة مرات، هذه الشيفرة هي تعديل لخوارزمية BFS: procedure BFSmodified(G, source): Q = queue() distance[] = infinity Q.enqueue(source) distance[source]=0 while Q is not empty u <- Q.pop() for all edges from u to v in G.adjacentEdges(v) do if distance[u] + cost[u][v] < distance[v] distance[v] = distance[u] + cost[u][v] end if end for end while Return distance يمكن استخدام هذا للعثور على أقصر مسار إلى جميع العقدة انطلاقًا من المصدر، ولا يُعد تعقيد هذه الشيفرة جيدًا، ذلك أننا في خوارزمية BFS نتبّع طريقة أوّل من يأتي هو أوّل من يُخدم "first come, first serve" عندما تنتقل من العقدة 1 إلى أي عقدة أخرى. فمثلًا، نحن ذهبنا إلى العقدة 3 من المصدر قبل معالجة العقدة 2، وعليه سنحدّث العقدة 4 إذا ذهبنا إلى العقدة 3 من المصدر، وذلك لأنّ 5 + 3 = 8. وعندما نحدّث العقدة 3 مرّةً أخرى من العقدة 2، فسيكون علينا تحديث العقدة 4 بالقيمة 3 + 3 = 6 مرّةً أخرى، وعليه تكون العقدة 4 قد حُدِّثَت مرتين. وقد اقترح Dijkstra طريقة أخرى بدلًا من طريقة أوّل من يأتي أوّل من يُخدَم، فإذا حدّثنا العُقَد الأقرب أولًا سنستطيع إجراء عدد أقل من التحديثات، وإذا كنا قد عالجنا العقدة 2 من قبل، فإنّ العقدة 3 ستكون قد حُدِّثت كذلك من قبل، وسنحصل على أقصر مسافة بسهولة بعد تحديث العقدة 4 وفقًا لذلك. الفكرة هنا هي أن نختار أقرب عقدة إلى المصدر من الطابور، لذلك سنستخدم طابور أولويات Priority Queue، وعندما ننزع عنصرًا من الطابور، سنحصل على أقرب عقدة u من المصدر عبر التحقق من قيمة d[u]. انظر الشيفرة التوضيحية التالية: procedure dijkstra(G, source): Q = priority_queue() distance[] = infinity Q.enqueue(source) distance[source] = 0 while Q is not empty u <- nodes in Q with minimum distance[] remove u from the Q for all edges from u to v in G.adjacentEdges(v) do if distance[u] + cost[u][v] < distance[v] distance[v] = distance[u] + cost[u][v] Q.enqueue(v) end if end for end while Return distance تعيد الشيفرة التوضيحية مسافةَ جميعِ العقد الأخرى من المصدر، فإذا أردنا معرفة مسافة عقدة v، فيمكننا ببساطة إعادة القيمة الناتجة عن أخذ v من الطابور. السؤال الآن هو: هل تعمل خوارزمية ديكسترا حال وجود ضلع سالب negative edge؟ إذا كانت هناك دورة سالبة فستحدث حلقة لا نهائية، وذلك لأنها ستستمر في تخفيض التكلفة إلى الأبد، ولن تعمل خوارزمية ديكسترا حتى لو كان لدينا ضلع سالب إلا إذا عدنا مباشرةً بعد أخذ الهدف من الطابور، لكن لن تكون الخوارزمية حينئذ خوارزميةَ Dijkstra، وسنحتاج إلى خوارزمية بيلمن فورد Bellman-Ford لمعالجة الأضلاع أو الدورات السالبة. يتساوى تعقيد خوارزمية BFS مع O(log(V+E))، حيث يمثّل V عدد العقد، ويمثّل E عدد الأضلاع، والتعقيد مقارب لهذه القيمة فيما يخصّ خوارزمية Dijkstra، لكنّ ترتيب طابور الأولويات يأخذ O (logV)، لذا فإنّ التعقيد الكلي يساوي: O (V log (V) + E). المثال أدناه هو تطبيق بلغة جافا لخوارزمية Dijkstra باستخدام مصفوفة التجاور: import java.util.*; import java.lang.*; import java.io.*; class ShortestPath { static final int V=9; int minDistance(int dist[], Boolean sptSet[]) { int min = Integer.MAX_VALUE, min_index=-1; for (int v = 0; v < V; v++) if (sptSet[v] == false && dist[v] <= min) { min = dist[v]; min_index = v; } return min_index; } void printSolution(int dist[], int n) { System.out.println("Vertex Distance from Source"); for (int i = 0; i < V; i++) System.out.println(i+" \t\t "+dist[i]); } void dijkstra(int graph[][], int src) { Boolean sptSet[] = new Boolean[V]; for (int i = 0; i < V; i++) { dist[i] = Integer.MAX_VALUE; sptSet[i] = false; } dist[src] = 0; for (int count = 0; count < V-1; count++) { int u = minDistance(dist, sptSet); sptSet[u] = true; for (int v = 0; v < V; v++) if (!sptSet[v] && graph[u][v]!=0 && dist[u] != Integer.MAX_VALUE && dist[u]+graph[u][v] < dist[v]) dist[v] = dist[u] + graph[u][v]; } printSolution(dist, V); } public static void main (String[] args) { int graph[][] = new int[][]{{0, 4, 0, 0, 0, 0, 0, 8, 0}, {4, 0, 8, 0, 0, 0, 0, 11, 0}, {0, 8, 0, 7, 0, 4, 0, 0, 2}, {0, 0, 7, 0, 9, 14, 0, 0, 0}, {0, 0, 0, 9, 0, 10, 0, 0, 0}, {0, 0, 4, 14, 10, 0, 2, 0, 0}, {0, 0, 0, 0, 0, 2, 0, 1, 6}, {8, 11, 0, 0, 0, 0, 1, 0, 7}, {0, 0, 2, 0, 0, 0, 6, 7, 0} }; ShortestPath t = new ShortestPath(); t.dijkstra(graph, 0); } } هذا هو الخرج المتوقّع: Vertex Distance from Source 0 0 1 4 2 12 3 19 4 21 5 11 6 9 7 8 8 14 ترجمة -بتصرّف- للفصل 11 من كتاب Algorithms Notes for Professionals اقرأ أيضًا المقالة السابقة: الرسوم التخطيطية Graphs في الخوارزميات المرجع الشامل إلى تعلم الخوارزميات للمبتدئين مدخل إلى الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية مدخل إلى الذكاء الاصطناعي وتعلم الآلة
-
خوارزمية البحث النجمي (*A) هي خوارزمية تهدف إلى تحديد أفضل مسار من عقدة إلى أخرى، لذا تشبه إلى حد ما خوارزميات البحث بالوسع أولًا Breadth First Search أو Dijkstra أو "البحث بالعمق أولًا Depth First Search" أو "خوارزمية البحث بالتخمين Best First Search". تتميّز خوارزمية البحث النجمي بكفاءة ودقة عالية خاصّة في الحالات التي يتعذّر فيها معالجة المخطط معالجة مُسبقة، وهي حالة خاصّة من خوارزمية البحث بالتخمين Best First Search، مع تعريف دالة التقييم f بطريقة معيّنة، إذ تساوي f(n) = g(n) + h(n) الكلفة الدنيا، حيث تمثّلg (n) التكلفة الدنيا من العقدة الأولية إلى n، فيما تمثّل h (n) الكلفة الدنيا من n إلى أقرب هدف من n. وتقوم خوارزمية *A على التخمين، وتضمن دائمًا العثور على أقصر مسار -المسار الذي له أقل تكلفة- في أقل وقت ممكن، لذلك فهي مثالية. يوضح الرسم البياني التالي آلية العمل لها: خوارزمية البحث عن المسارات *A عبر متاهة لا تحتوي على عقبات انظر الشبكة التالية: لنفترض أنّ هذه متاهة، لاحظ أنّه لا توجد فيها أيّ جدران أو عوائق، وإنما لدينا نقطة انطلاقٍ -المربع الأخضر- ونقطة وصول ممثلة في المربع الأحمر. لنفترض أيضًا أنّه لا يمكننا التحرك قطريًا diagonally من أجل الانتقال من المربّع الأخضر إلى المربع الأحمر. يبيّن الرسم التالي المربعات التي يمكننا الانتقال إليها بدءًا من المربع الأخضر، والتي سنصبغُها باللون الأزرق. من أجل اختيار المربع التالي، نحتاج إلى مراعاة بعض المقاييس، وهي الآتية: قيمة g - تمثّل بُعد هذه العقدة عن المربع الأخضر. قيمة h - تمثّل بُعد هذه العقدة عن المربع الأحمر. قيمة f - تساوي مجموع قيمتي g وh. هذا هو العدد النهائي الذي سنستعين به لتحديد العقدة التي ينبغي أن ننتقل إليها. لحساب هذه القيم، سنستخدم هذه الصيغة: distance = abs(from.x - to.x) + abs(from.y - to.y) تُعرَف هذه الصيغة بصيغة مسافة مانهاتن Manhattan Distance formula، حيث نستخدم الصيغة أعلاه لحساب قيمة g الخاصّة بالمربع الأزرق الموجود على يسار المربع الأخضر: abs(3 - 2) + abs(2 - 2 ) = 1، ونحصل على القيمة 1. سنحاول الآن حساب قيمة h بنفس الصيغة: abs(2 - 0) + abs (2 - 0) = 4. قيمة f تساوي 1 + 4 = 5، لذا فإنّ القيمة النهائية لهذه العقدة تساوي 5. علينا فعل نفس الأمر مع جميع المربعات الزرقاء الأخرى. يمثّل الرقم الكبير في وسط المربعات في الرسم أدناه قيمة f، بينما يمثّل العدد الموجود أعلى اليسار قيمة g، ويمثّل العدد الموجود أعلى اليمين قيمة h. لقد حسبنا قيم g وh وf الخاصّة بجميع العُقد الزرقاء. الآن، أيّها نختار؟ سنختار العقدة التي لها أصغر قيمة لـ f. لدينا في هذه الحالة عقدتان لهما نفس قيمة f (هذه القيمة تساوي 5)، كيف نختار من بينهما؟ إمّا أن تختار إحداهما عشوائيًا، أو تضع قواعد للأولويات. فمثلا، يمكن أن تحدّد قاعدة للأولويات من قبيل: أيمن > أسفل > أيسر > أعلى. لو طبّقنا هذه القاعدة على مثالنا، فإنّ إحدى العقدتين التي تساوي قيمة f الخاصة بها 5 تذهب إلى أسفل، وتذهب الأخرى إلى اليسار. وبما أن أولوية الاتجاه الأسفل أكبر من أولوية اليسار (أسفل>أيسر)، فسنختار المربع الذي يأخذنا إلى أسفل. سنلوّن المربعات التي حسبنا قيمها دون أن نتحرّك إليها باللون البرتقالي، ونلوّن العقدة التي اخترناها باللون الأزرق السماوي: سنحسب الآن نفس المقاييس التي حسبناها من قبل للعقد المحيطة بالعقدة ذات اللون الأزرق السماوي (التي اخترناها سابقًا): سنختار العقدة الموجودة أسفل العقدة السماوية، ذلك أنّ جميع الخيارات لها نفس قيمة f: لنحسب الآن مقاييس الجار الوحيد للعقدة السماوية: نتّبع نفس النمط الذي اتبعناه من قبل: سنحسب مرّةً أخرى مقاييس جار العقدة: دعنا ننتقل إلى هناك: أخيرًا وصلنا إلى المربع المنشود، وأنهينا المهمّة. حل لغز باستخدام خوارزمية البحث النجمي *A لغز الثمانية 8 puzzle هي لعبة بسيطة تُلعب على شبكة تحتوي 9 مربعات (3 × 3). إحدى هذه المربعات فارغة، والهدف هو نقل المربعات إلى أن تتصافّ الأرقام وفق الترتيب المُراد. تريد هذه اللعبة البحث عن المسار الأقل كلفة، والذي ينطلق من حالة أولية للعبة ألغاز الثمانية، ويصل إلى الحالة المنشودة. _ 1 3 4 2 5 7 8 6 وهذه هي الحالة النهائية التي نريد الوصول إليها: 1 2 3 4 5 6 7 8 _ لنحسب مسافة مانهاتن بين الحالة الحالية والحالة النهائية. h(n) = | x - p | + | y - q | x وy يمثّلان إحداثيات co-ordinates الحالة الراهنة، فيما تمثّل p وq إحداثيات الحالة النهائية. تحقّق دالة التكلفة الإجمالية f(n) الشرط التالي: f(n) = g(n) + h(n) حيث تمثّل g كلفة الوصول إلى الحالة الراهنة انطلاقًا من حالة أولية معيّنة، ولحل المشكلة سنحسب أولًا مسافة مانهاتن المطلوبة للوصول إلى الحالة النهائية انطلاقًا من الحالة الأولية. ستساوي دالة التكلفة g (n) = 0، لأنّنا ما نزال عند الحالة الأولية: h(n) = 8 لو نظرت إلى الحالة الأولية ستلاحظ أنّ 1 موجود على بعد مربّع واحد على يمين المكان الذي يُفترض أن يكون فيه عند الوصول إلى الحالة المنشودة، وينطبق الأمر ذاته على 2 و5 و6. توجد _ على مسافة مربعين أُفقيين ومربّعين عموديين، لذا فإن القيمة الإجمالية لـ h(n) هي 1 + 1 + 1 + 1 + 2 + 2 = 8، بينما تساوي دالة التكلفة الإجمالية f(n) القيمة 8 + 0 = 8. والآن نكون قد عثرنا على الحالات المحتملة التي يمكن الوصول إليها انطلاقًا من الحالة الأولية، كما أنّه لا يمكننا تحريك _ إلّا إلى اليمين أو إلى الأسفل، لذا فإنّ الحالات التي يمكن الوصول إليها بعد التحريك هي: 1 _ 3 4 1 3 4 2 5 _ 2 5 7 8 6 7 8 6 (1) (2) مرّةً أخرى، سنحسب دالة التكلفة الإجمالية لهذه الحالات كما فعلنا أعلاه، وسنجد أنها تساوي 6 و7 على التوالي. لقد اخترنا الحالة ذات التكلفة الأقل، وهي الحالة (1). النقلة المحتملة التالية يمكن أن تكون إمّا يسارًا أو يمينًا أو لأسفل. لن ننتقل إلى اليسار لأنّنا كنّا هناك سابقًا، لذا بقي لنا التحرك يمينًا أو إلى أسفل. ومرّةً أخرى، وجدنا الحالات التي يمكن الوصول إليها من (1). 1 3 _ 1 2 3 4 2 5 4 _ 5 7 8 6 7 8 6 (3) (4) دالة تكلفة (3) تساوي 6، فيما تساوي دالة تكلفة (4) القيمة 4. خذ بالحسبان أيضًا (2) التي حصلنا عليها من قبل، والتي تساوي دالة تكلفتها 7، وهنا سنختار الحالة (4) لأن لها أقل تكلفة. يمكن أن تكون النقلات المحتملة التالية إمّا يسارًا أو يمينًا أو لأسفل. سنحصل على الحالات التالية: 1 2 3 1 2 3 1 2 3 _ 4 5 4 5 _ 4 8 5 7 8 6 7 8 6 7 _ 6 (5) (6) (7) تكاليف الحالات (5) و(6) و(7) تساوي 5 و2 و4 على الترتيب، كذلك لدينا حالتان سابقتان (3) و(2) ذواتا تكلفتين 6 و7 على الترتيب، لكننا سنختار الحالة (6) ذات التكلفة الأقل. يمكن أن تكون التحركات المحتملة التالية إما إلى أعلى أو أسفل، ولا يحتاج الأمر إلى تفكير طويل، إذ سيقودنا النزول إلى أسفل إلى الحالة النهائية، وهذا سيجعل مسافة مانهاتن تساوي 0. ترجمة -بتصرّف- للفصلين 13 و 12 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقالة السابقة: خوارزمية ديكسترا Dijkstra’s Algorithm مدخل إلى الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية
-
هذه المقالة هي تتمة للمقالة السابقة، وفيها تجد عددًا من التطبيقات الحقيقية للخوارزميات الشَرِهَة لحل بعض المشاكل، مثل تقليل التأخير وجدولة الوظائف وإدارة ذاكرة التخزين المؤقت. التخزين المؤقت غير المتصل Offline Caching تنشأ مشكلة التخزين المؤقت caching بسبب محدودية مساحة التخزين، فمثلًا لنفرض أن ذاكرةَ تخزينٍ مؤقتة ولتكن C، تحتوي على عدد k من الصفحات، ونريد معالجة سلسلة من العناصر عددها m عنصر، هنا يجب تخزين هذه العناصر في الذاكرة المؤقتة قبل معالجتها. لا شك أنه لن توجد مشكلة إن كانت m<=k، إذ سنضع جميع العناصر في ذاكرة التخزين المؤقت، ولكن في العادة تكون m>>k. نقول أنّ طلبيّةً ما مقبولة في ذاكرة التخزين المؤقت cache hit إذا كان العنصر موجودًا في ذاكرة التخزين المؤقت فعليًا، وإلا سنقول أنّها مُفوَّتة عن ذاكرة التخزين المؤقت cache miss. سيكون علينا في مثل هذه الحالة إحضار العنصر المطلوب إلى ذاكرة التخزين المؤقت وإخراج عنصر آخر، وذلك بفرض أنّ ذاكرة التخزين المؤقت ممتلئة، وهدفنا هو تصميم جدول إخلاء eviction schedule يقلّل قدر الإمكان من عدد عمليات الإخلاء الضرورية. هناك عدة استراتيجيات شَرِهَة لحلّ هذه المشكلة، من بينها: أول داخل، أول خارج First in, first out أو FIFO: تُخلى أقدم صفحة. آخر داخل، أوّل خارج Last in, first out أو LIFO: تُخلى أحدث صفحة. خروج الأبكر وصولًا Last recent out أو اختصارا LRU: إخلاء الصفحة التي تمّ الدخول إليها في أبكر وقت. الأقل طلبًا Least frequently requested أو LFU: إخلاء الصفحة التي طُلِبت أقل عدد من المرات. أطول مسافة أمامية Longest forward distance أو LFD: إخلاء الصفحة التي ستُطلَب في أبعد مسافة في المستقبل. مثال FIFO لنفترض أنّ حجم ذاكرة التخزين المؤقت هو k=3، وأنّ ذاكرة التخزين المؤقت الأولية تحتوي a,b,c، وأنّ هناك قائمةً من الطلبيات هي على النحو الآتي: a,a,d,e,b,b,a,c,f,d,e,a,f,b,e,c table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } الطلب a a d e b b a c f d e a f b e c المخزن المؤقت 1 a a d d d d a a a d d d f f f c المخزن المؤقت 2 b b b e e e e c c c e e e b b b المخزن المؤقت 3 c c c c b b b b f f f a a a e e الحالات المفوَّتة x x x x x x x x x x x x x فوّتنا ثلاثة عشرة عنصرًا من ذاكرة التخزين المؤقت من بين ستة عشر طلبية، وهو أمر لا يبدو جيدًا، لذلك من الأحسن أن نجرّب استراتيجيةً أخرى. مثال LFD لنفترض أنّ حجم ذاكرة التخزين المؤقت هو k=3، وأنّ ذاكرة التخزين المؤقت الأولية تحتوي a,b,c؛ أما الطلبيات فهي: الطلب a a d e b b a c f d e a f b e c المخزن المؤقت 1 a a d e e e e e e e e e e e e c المخزن المؤقت 2 b b b b b b a a a a a a f f f f المخزن المؤقت 3 c c c c c c c c f d d d d b b b الحالات المفوَّتة x x x x x x x x فوّتنا ثمانيةً هذه المرة، وهذا أفضل بكثير. تمرين جرّب استراتيجيات LIFO وLFU وRFU على المثال، وانظر إلى ما يحدث. يتكون البرنامج التوضيحي التالي (مكتوب بلغة C++) من جزأين، حيث أن الأول هو هيكل البرنامج، ويحلّ المشكلة اعتمادًا على استراتيجية الشَرِههَ المختارة: #include <iostream> #include <memory> using namespace std; const int cacheSize = 3; const int requestLength = 16; const char request[] = {'a','a','d','e','b','b','a','c','f','d','e','a','f','b','e','c'}; char cache[] = {'a','b','c'}; // for reset char originalCache[] = {'a','b','c'}; class Strategy { public: Strategy(std::string name) : strategyName(name) {} virtual ~Strategy() = default; // احسب موضع التخزين المؤقت الذي ينبغي أن يُستخدم virtual int apply(int requestIndex) = 0; // حدّث المعلومات التي تحتاجها الاستراتيجية virtual void update(int cachePlace, int requestIndex, bool cacheMiss) = 0; const std::string strategyName; }; bool updateCache(int requestIndex, Strategy* strategy) { // حدد مكان وضع الطلب int cachePlace = strategy->apply(requestIndex); // تحقق مما إذا قُبِل التخزين المؤقت أم لا bool isMiss = request[requestIndex] != cache[cachePlace]; // تحديث الاستراتيجية - مثلا: إعادة حساب المسافات strategy->update(cachePlace, requestIndex, isMiss); //الكتابة في ذاكرة التخزين المؤقت cache[cachePlace] = request[requestIndex]; return isMiss; } int main() { Strategy* selectedStrategy[] = { new FIFO, new LIFO, new LRU, new LFU, new LFD }; for (int strat=0; strat < 5; ++strat) { //إعادة تعيين المخزن المؤقت for (int i=0; i < cacheSize; ++i) cache[i] = originalCache[i]; cout <<"\nStrategy: " << selectedStrategy[strat]->strategyName << endl; cout << "\nCache initial: ("; for (int i=0; i < cacheSize-1; ++i) cout << cache[i] << ","; cout << cache[cacheSize-1] << ")\n\n"; cout << "Request\t"; for (int i=0; i < cacheSize; ++i) cout << "cache " << i << "\t"; cout << "cache miss" << endl; int cntMisses = 0; for(int i=0; i<requestLength; ++i) { bool isMiss = updateCache(i, selectedStrategy[strat]); if (isMiss) ++cntMisses; cout << " " << request[i] << "\t"; for (int l=0; l < cacheSize; ++l) cout << " " << cache[l] << "\t"; cout << (isMiss ? "x" : "") << endl; } cout<< "\nTotal cache misses: " << cntMisses << endl; } for(int i=0; i<5; ++i) delete selectedStrategy[i]; } الفكرة الأساسية بسيطة، حيث نستدعي الاستراتيجية مرتين لكل طلب: التطبيق apply: يجب أن تخبر الاستراتيجية المستدعي بالصفحة التي يجب استخدامها. التحديث update: بعد أن يستخدم المستدعي المساحة، يخبر الإستراتيجية ما إذا تمّ تفويته أم لا. ثمّ تستطيع الاستراتيجية حينها تحديث بياناتها الداخلية. فمثلًا، على الاستراتيجية LFU تحديث تردّد القبول hit frequency لصفحات ذاكرة التخزين المؤقت، بينما تعيد استراتيجية LFD حساب مسافات صفحات ذاكرة التخزين المؤقت. نقدّم الآن تطبيقات للاستراتيجيات الخمس: FIFO class FIFO : public Strategy { public: FIFO() : Strategy("FIFO") { for (int i=0; i<cacheSize; ++i) age[i] = 0; } int apply(int requestIndex) override { int oldest = 0; for(int i=0; i<cacheSize; ++i) { if(cache[i] == request[requestIndex]) return i; else if(age[i] > age[oldest]) oldest = i; } return oldest; } void update(int cachePos, int requestIndex, bool cacheMiss) override { // لم يتغير أي شيء، لسنا بحاجة إلى تحديث الصفحات if(!cacheMiss) return; // كل الصفحات القديمة تُصبح أقدم، والجديدة تحصل على القيمة 0 for(int i=0; i<cacheSize; ++i) { if(i != cachePos) age[i]++; else age[i] = 0; } } private: int age[cacheSize]; }; لا تحتاج FIFO إلا إلى المعلومات المتعلقة بطول الصفحة في ذاكرة التخزين المؤقت (نسبة إلى الصفحات الأخرى)، لذا فهي تكتفي بانتظار حدوث فوات miss، ثم تجعل الصفحات التي لم تُخلى أقدم. بالنسبة للمثال أعلاه، فسيكون الحل كالتالي: Strategy: FIFO Cache initial: (a,b,c) Request cache 0 cache 1 cache 2 cache miss a a b c a a b c d d b c x e d e c x b d e b x b d e b a a e b x c a c b x f a c f x d d c f x e d e f x a d e a x f f e a x b f b a x e f b e x c c b e x Total cache misses: 13 العدد الإجمالي للفوات وهذا يكافئ الحل أعلاه. LIFO class LIFO : public Strategy { public: LIFO() : Strategy("LIFO") { for (int i=0; i<cacheSize; ++i) age[i] = 0; } int apply(int requestIndex) override { int newest = 0; for(int i=0; i<cacheSize; ++i) { if(cache[i] == request[requestIndex]) return i; else if(age[i] < age[newest]) newest = i; } return newest; } void update(int cachePos, int requestIndex, bool cacheMiss) override { // لم يتغير شيء، لا نحتاج إلى تحديث الصفحات if(!cacheMiss) return; // كل الصفحات القديمة تُصبح أقدم، والصفحة الجديدة تحصل على القيمة 0 for(int i=0; i<cacheSize; ++i) { if(i != cachePos) age[i]++; else age[i] = 0; } } private: int age[cacheSize]; }; تقديم LIFO مشابه إلى حد ما لتقديم FIFO، بيْد أنّنا نخلي الصفحة الأحدث وليس الأقدم. ستكون نتائج البرنامج كالتالي: Strategy: LIFO Cache initial: (a,b,c) Request cache 0 cache 1 cache 2 cache miss a a b c a a b c d d b c x e e b c x b e b c b e b c a a b c x c a b c f f b c x d d b c x e e b c x a a b c x f f b c x b f b c e e b c x c e b c Total cache misses: 9 العدد الإجمالي لحالات الفوات LRU، سنستخدم كلمة oldest في المثال أدناه للإشارة إلى طول فترة "عدم" الاستخدام. class LRU : public Strategy { public: LRU() : Strategy("LRU") { for (int i=0; i<cacheSize; ++i) age[i] = 0; } int apply(int requestIndex) override { int oldest = 0; for(int i=0; i<cacheSize; ++i) { if(cache[i] == request[requestIndex]) return i; else if(age[i] > age[oldest]) oldest = i; } return oldest; } void update(int cachePos, int requestIndex, bool cacheMiss) override { // الصفحات القديمة تصبح أقدم، والصفحة الجديدة تأخذ القيمة 0 for(int i=0; i<cacheSize; ++i) { if(i != cachePos) age[i]++; else age[i] = 0; } } private: int age[cacheSize]; }; في حالة LRU، فإن الاستراتيجية مستقلة عن صفحة ذاكرة التخزين المؤقت، إذ أنّ تركيزها ينصبّ على الصفحة الأخيرة فقط. نتائج البرنامج هي: Strategy: LRU Cache initial: (a,b,c) Request cache 0 cache 1 cache 2 cache miss a a b c a a b c d a d c x e a d e x b b d e x b b d e a b a e x c b a c x f f a c x d f d c x e f d e x a a d e x f a f e x b a f b x e e f b x c e c b x Total cache misses: 13 العدد الإجمالي لحالات الفوات LFU: class LFU : public Strategy { public: LFU() : Strategy("LFU") { for (int i=0; i<cacheSize; ++i) requestFrequency[i] = 0; } int apply(int requestIndex) override { int least = 0; for(int i=0; i<cacheSize; ++i) { if(cache[i] == request[requestIndex]) return i; else if(requestFrequency[i] < requestFrequency[least]) least = i; } return least; } void update(int cachePos, int requestIndex, bool cacheMiss) override { if(cacheMiss) requestFrequency[cachePos] = 1; else ++requestFrequency[cachePos]; } private: // ما هو تردد استخدام الصفحة int requestFrequency[cacheSize]; }; تُخلي LFU الصفحة الأقل استخدامًا، لذا تعتمد استراتيجية التحديث على عدد مرّات الوصول إلى الصفحات. وبالطبع، يُصفَّر العدّاد بعد الإخلاء، وهذه نتائج البرنامج: Strategy: LFU Cache initial: (a,b,c) Request cache 0 cache 1 cache 2 cache miss a a b c a a b c d a d c x e a d e x b a b e x b a b e a a b e c a b c x f a b f x d a b d x e a b e x a a b e f a b f x b a b f e a b e x c a b c x Total cache misses: 10 LFD: class LFD : public Strategy { public: LFD() : Strategy("LFD") { // الحساب المُسبق للاستخدام اللاحق قبل تلبية الطلبات for (int i=0; i<cacheSize; ++i) nextUse[i] = calcNextUse(-1, cache[i]); } int apply(int requestIndex) override { int latest = 0; for(int i=0; i<cacheSize; ++i) { if(cache[i] == request[requestIndex]) return i; else if(nextUse[i] > nextUse[latest]) latest = i; } return latest; } void update(int cachePos, int requestIndex, bool cacheMiss) override { nextUse[cachePos] = calcNextUse(requestIndex, cache[cachePos]); } private: int calcNextUse(int requestPosition, char pageItem) { for(int i = requestPosition+1; i < requestLength; ++i) { if (request[i] == pageItem) return i; } return requestLength + 1; } // الاستخدام اللاحق للصفحة int nextUse[cacheSize]; }; تختلف استراتيجية LFD عن جميع ما قبلها، فهي الاستراتيجية الوحيدة التي تستخدم الطلبات المستقبلية لاتخاذ القرار بخصوص الصفحة التي ينبغي أن تُخلى. يَستخدم التطبيق دالة calcNextUse للحصول على الصفحة التي يكون استخدَامها التالي هو الأبعد في المستقبل. الحل الذي يعطيه البرنامج يساوي الحل اليدوي الذي وجدناه أعلاه: Strategy: LFD Cache initial: (a,b,c) Request cache 0 cache 1 cache 2 cache miss a a b c a a b c d a b d x e a b e x b a b e b a b e a a b e c a c e x f a f e x d a d e x e a d e a a d e f f d e x b b d e x e b d e c c d e x Total cache misses: 8 استراتيجية LFD الشَرِهَة هي الاستراتيجية المثلى من بين الاستراتيجيات الخمس المقدمة، لكن هناك مشكلة كبيرة، فهي حل مثالي غير متصل optimal offline solution، أي أنّها تتطلب قراءة جميع عناصر الطابور مرّةً واحدة؛ لكن عند التطبيق، سيكون التخزين المؤقت غالبًا مشكلة متصلة online، أي لا يمكنك أن تعرف من العناصر غير السابقة وحدها، وهذا يعني أنّ هذه الاستراتيجية غير مجدية لأننا لا نستطيع معرفة الموعد القادم الذي سنحتاج فيه إلى عنصر معيّن؛ أما الاستراتيجيات الأربع الأخرى فهي متصلة أيضًا. وبالنسبة للمشاكل المتصلة online، سنحتاج إلى منظور مختلف. جهاز التذاكر لنفترض أنّ لدينا جهاز تذاكر يصرّف المبالغ بقطع نقدية تحمل القيم 1 و2 و5 و10 و20. يمكن النظر إلى عملية الصرف مثل سلسلة من القطع النقدية التي تتساقط حتى يُستوفى المبلغ الصحيح، ونقول أنّ الصرف مثالي حين يكون عدد القطع النقدية أقل ما يمكن بالنسبة إلى قيمته. وإذا كانت M توجد بين [1,50] وتمثّل سعر التذكرة T، و العدد P الموجود بين [1,50] يمثل المبلغ المالي المدفوع مقابل التذكرة T، حيث P >= M؛ فإننا نكتب D=P-M، ونعرّف الفائدة benefit لخطوة معيّنة على أنّها الفرق بين D وD-c، حيث تمثّلc القطعة النقدية التي أضافها الجهاز في هذه الخطوة. انظر إلى الخوارزمية الشَرِهَة التوضيحية لإجراء عملية الصرف: الخطوة 1: إذا كانت D > 20، أضف القطعة 20، وعيّن D = D - 20. الخطوة 2: إذا كانت D > 10، أضف القطعة 10 وعيّن D = D - 10. الخطوة 3: إذا كانت D > 5، أضف القطعة 5 وعيّن D = D - 5. الخطوة 4: إذا كانت D > 2، أضف القطعة 2 وعيّن D = D - 2. الخطوة 5: إذا كانت D > 1، أضف القطعة 1 وعيّن D = D - 1. بعد ذلك سيكون مجموع جميع العملات مساويًا القيمة D. هذه الخوارزمية شَرِهَة لأنّها تبحث بعد كل خطوة وبعد كل تكرار عن تعظيم الفائدة. والآن، لنكتب برنامج التذكرة بلغة C++: #include <iostream> #include <vector> #include <string> #include <algorithm> using namespace std; // اقرأ قيم بعض القطع النقدية، ورتّبها تنازليا // احذف النسخ مع ضمان أن تكون القطعة 1 موجودة بينها std::vector<unsigned int> readInCoinValues(); int main() { std::vector<unsigned int> coinValues; // مصفوفة القطع النقدية تنازليا int ticketPrice; // في المثال M int paidMoney; // في المثال P // ولِّد قيم القطع النقدية coinValues = readInCoinValues(); cout << "ticket price: "; cin >> ticketPrice; cout << "money paid: "; cin >> paidMoney; if(paidMoney <= ticketPrice) { cout << "No exchange money" << endl; return 1; } int diffValue = paidMoney - ticketPrice; // هنا تبدأ الخوارزمية الشَرِهَة. // نحفظ عدد القطع النقدية التي علينا إعطاؤها std::vector<unsigned int> coinCount; for(auto coinValue = coinValues.begin(); coinValue != coinValues.end(); ++coinValue) { int countCoins = 0; while (diffValue >= *coinValue) { diffValue -= *coinValue; countCoins++; } coinCount.push_back(countCoins); } // اطبع النتائج cout << "the difference " << paidMoney - ticketPrice << " is paid with: " << endl; for(unsigned int i=0; i < coinValues.size(); ++i) { if(coinCount[i] > 0) cout << coinCount[i] << " coins with value " << coinValues[i] << endl; } return 0; } std::vector<unsigned int> readInCoinValues() { // قيم القطع النقدية std::vector<unsigned int> coinValues; // تحقق من أنّ 1 موجود في المتجهة coinValues.push_back(1); // اقرأ قيم القطع النقدية، لاحظ أن خطأ المعالجة يهمَل. while(true) { int coinValue; cout << "Coin value (<1 to stop): "; cin >> coinValue; if(coinValue > 0) coinValues.push_back(coinValue); else break; } // رتب القيم sort(coinValues.begin(), coinValues.end(), std::greater<int>()); // امح النسخ التي لها القيمة نفسها auto last = std::unique(coinValues.begin(), coinValues.end()); coinValues.erase(last, coinValues.end()); // اطبع المصفوفة cout << "Coin values: "; for(auto i : coinValues) cout << i << " "; cout << endl; return coinValues; } لاحظ أننا لم نضع أيّ فحص للمدخلات من أجل إبقاء المثال بسيطًا، وهنا سيكون خرج المثال كما يلي: Coin value (<1 to stop): 2 Coin value (<1 to stop): 4 Coin value (<1 to stop): 7 Coin value (<1 to stop): 9 Coin value (<1 to stop): 14 Coin value (<1 to stop): 4 Coin value (<1 to stop): 0 Coin values: 14 9 7 4 2 1 ticket price: 34 money paid: 67 the difference 33 is paid with: 2 coins with value 14 1 coins with value 4 1 coins with value 1 إن كانت قيمة القطعة النقدية تساوي 1، فستنتهي الخوارزمية، لأن: D تنخفض مع كل خطوة. لا يمكن أن تكون D موجبةً وأصغر من أصغر عملة (1) في نفس الوقت. هذه الخوارزمية فيها ثغرتان: إذا كانت C هي أكبر قطعة نقدية، فسيكون وقت التشغيل كثير الحدود polynomial طالما أنّ D/C كثيرة الحدود أيضًا، ذلك أن تمثيل D يستخدم بتَّات log D وحسب؛ أمّا وقت التشغيل، فهو على الأقل خطيٌ في D/C تختار خوَارزميتنا الخيار المحلي الأمثل في كل خطوة، لكنّ هذا لا يعني أنّ الحل الذي تقدمه الخوارزمية هو الحل الأمثل العام. انظر إلى المثال التوضيحي التالي: لنفترض أنّ القطع النقدية هي 1,3,4، وأنّ D=6. من الواضح أنّ الحل الأمثل هو قطعتان نقديتان من فئة 3، ولكنّ الخوارزمية الشَرِهَة ستختار 4 في الخطوة الأولى، لذا سيكون عليها أن تختار 1 في الخطوتين الثانية والثالثة، وعليه فهي لا تعطينا الحل الأمثل. قد يكون استخدام البرمجة الديناميكية لإيجاد الحل الأمثل هو الأفضل في هذه الحالة. جدولة الوظائف لنفترض أنّ لدينا مجموعةً من الوظائف J={a,b,c,d,e,f,g}. إذا كانت j التي تنتمي إلى J هي وظيفة تبدأ عند sj وتنتهي عند fj، فسنقول عندئذ أنّ وظيفتين متوافقتان إذا لم تتدَاخلا overlap. انظر الصورة التالية: والهدف هنا هو العثور على أكبر مجموعة من الوظائف المتوافقة مع بعضها بعضًا، وهناك عدة أوجه شَرِهَة لحل هذه المشكلة: أبكر وقت بداية: خذ الوظائف وفق ترتيب تصاعدي لقيم sj. أبكر وقت انتهاء: خذ الوظائف وفق ترتيب تصاعدي لـ fj. أقصر مدة: خذ الوظائف وفق ترتيب تصاعدي لـ fj-sj. أقل تعارض: لكل وظيفة j، احسب عدد الوظائف المتعارضة معها (cj). السؤال الآن هو: أيّ وجه أفضل؟ من الواضح أنّ منظور أبكر وقت بداية غير صالح كما يوضّح المثال المضاد التالي: كذلك فإن منظور أقصر مدة ليس مثاليًا: وقد يبدو أنّ منظور أقل تعارض هو الأمثل، غير أن المثال المضاد التالي ينفي ذلك: إذًا الحل المتبقي هو أبكر وقت انتهاء. انظر شيفرته التوضيحية: رتّب الوظائف بحسب وقت الانتهاء f1<=f2<=...<=fn. لتكن A مجموعةً فارغة. من j=1 إلى n، إذا كان j متوافقًا مع جميع الوظائف في A، ضع A=A+{j}. ستكون A هي أكبر مجموعة تضمّ وظائفًا متوافقة. أو نفس الأمر مكتوبًا في برنامج بلغة C++: #include <iostream> #include <utility> #include <tuple> #include <vector> #include <algorithm> const int jobCnt = 10; // أوقات بداية الوظائف const int startTimes[] = { 2, 3, 1, 4, 3, 2, 6, 7, 8, 9}; // أوقات انتهاء الوظائف const int endTimes[] = { 4, 4, 3, 5, 5, 5, 8, 9, 9, 10}; using namespace std; int main() { vector<pair<int,int>> jobs; for(int i=0; i<jobCnt; ++i) jobs.push_back(make_pair(startTimes[i], endTimes[i])); // المرحلة الأولى: الترتيب sort(jobs.begin(), jobs.end(),[](pair<int,int> p1, pair<int,int> p2) { return p1.second < p2.second; }); // A المرحلة الثانية: المجموعة الفارغة vector<int> A; // المرحلة الثالثة for(int i=0; i<jobCnt; ++i) { auto job = jobs[i]; bool isCompatible = true; for(auto jobIndex : A) { // A تحقق مما إذا كانت الوظيفة الحالية غير متوافقة مع الوظيفة من if(job.second >= jobs[jobIndex].first && job.first <= jobs[jobIndex].second) { isCompatible = false; break; } } if(isCompatible) A.push_back(i); } // A المرحلة الرابعة: طباعة cout << "Compatible: "; for(auto i : A) cout << "(" << jobs[i].first << "," << jobs[i].second << ") "; cout << endl; return 0; } خرج هذا المثال سيكون: Compatible: (1,3) (4,5) (6,8) (9,10) تعقيد هذا التطبيق يساوي Θ (n ^ 2)، لكن هناك تطبيق آخر من التعقيد Θ (n log n) (انظر مثال جافا أدناه). أصبحت لدينا الآن خوارزمية شَرِهَة لحل مشكلة الجدولة الزمنية، ولكن أهي مُثلى؟ لنفترض أنّ هذه الخوارزمية الشَرِهَة ليست مثلى، وأن i1,i2,...,ik تشير إلى مجموعة من الوظائف التي اختارتها الخوارزمية الشَرِهَة، ولتكن j1,j2,...,jm تشير إلى مجموعة الوظائف في الحل الأمثل، وليكن r هو العدد الأكبر الذي يحقق i1=j1,i2=j2,...,ir=jr. الوظيفة i(r+1) موجودة وتنتهي قبل الوظيفة j(r+1) (أبكر وقت انتهاء)، لكن هذا يعني أنّ الوظيفة الآتية ستكون أيضًا حلًّا أمثلَ، ولكلّ k من المجال [1,(r+1)]، سيكون لدينا jk=ik، مما يناقض فرضية أنّ r هو العدد الأكبر. وهذا يُتمّ البرهان. j1,j2,...,jr,i(r+1),j(r+2),...,jm يوضّح المثال التالي أنّ هناك العديد من الاستراتيجيات الشَرِهَة الممكنة في الغالب، ولكن ليست كلها تجد الحل الأمثل العام، بل قد لا تجده أي منها. فيما يلي برنامج Java تعقيده Θ (n log n) . import java.util.Arrays; import java.util.Comparator; class Job { int start, finish, profit; Job(int start, int finish, int profit) { this.start = start; this.finish = finish; this.profit = profit; } } class JobComparator implements Comparator<Job> { public int compare(Job a, Job b) { return a.finish < b.finish ? -1 : a.finish == b.finish ? 0 : 1; } } public class WeightedIntervalScheduling { static public int binarySearch(Job jobs[], int index) { int lo = 0, hi = index - 1; while (lo <= hi) { int mid = (lo + hi) / 2; if (jobs[mid].finish <= jobs[index].start) { if (jobs[mid + 1].finish <= jobs[index].start) lo = mid + 1; else return mid; } else hi = mid - 1; } return -1; } static public int schedule(Job jobs[]) { Arrays.sort(jobs, new JobComparator()); int n = jobs.length; int table[] = new int[n]; table[0] = jobs[0].profit; for (int i=1; i<n; i++) { int inclProf = jobs[i].profit; int l = binarySearch(jobs, i); if (l != -1) inclProf += table[l]; table[i] = Math.max(inclProf, table[i-1]); } return table[n-1]; } public static void main(String[] args) { Job jobs[] = {new Job(1, 2, 50), new Job(3, 5, 20), new Job(6, 19, 100), new Job(2, 100, 200)}; System.out.println("Optimal profit is " + schedule(jobs)); } } الخرج المتوقع هو: Optimal profit is 250 تقليل التأخير Minimizing Lateness هناك العديد من المشاكل التي تقلل التأخير، ولنفترض هنا أنّ لدينا مصدرًا وحيدًا يمكنه معالجة مهمّة واحدة فقط في كل مرّة، والوظيفة j تتطلب tj وحدة وقت من زمن المعالجة، وتبدأ عند التوقيت dj، فإن بدأت j في الوقت sj، فستنتهي عند الوقت fj = sj + tj. نعرّف التأخير lateness بالصيغة التالية: L=max{ 0 ,fj-dh}، لكل وظيفة j، والهدف هو تقليل أقصى تأخير لـ L. 1 2 3 4 5 6 tj 3 2 1 4 3 2 dj 6 8 9 9 10 11 الوظيفة 3 2 2 5 5 5 4 4 4 4 1 1 1 6 6 الوقت 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Lj 8- 5- 4- 1 7 4 من الواضح أنّ الحل L=7 ليس الأمثل. لهذا من الأفضل أن نلقي نظرةً على بعض الاستراتيجيات الشَرِهَة: أقصر وقت معالجة أولاً: نُجدول المهام وفق ترتيب تصاعدي لوقت المعالجة j`. أبكر موعد نهائي أولًا: جدولة المهام وفق ترتيب تصاعدي للمواعيد النهائية dj. أقصر مُهلة Smallest slack: جدولة المهام وفق ترتيب تصاعدي للمهلة dj-tj. من الواضح أنّ منظور أقصر وقت معالجة أولًا ليس الأمثل، هذا المثال المضاد يبيّن ذلك: 1 2 tj 1 5 dj 10 5 كذلك فإن منظور أقصر مهلة لديه مشاكل مشابهة كما يبيّن المثال التالي: 1 2 tj 1 5 dj 3 5 والإستراتيجية الأخيرة تبدو صالحة، انظر هذه الشيفرة التوضيحية لها: رتّب n وظيفةً بحسب المواعيد النهائية، بحيث تكون d1<=d2<=...<=dn. عيّن t=0. من j=1 إلى n: عيّن المهمة j للفاصل الزمني [t,t+tj]. عيّن sj=t وfj=t+tj. عيّن t=t+tj. أعِد المجالات [s1,f1],[s2,f2],...,[sn,fn]. هذا تطبيق بلغة C++: #include <iostream> #include <utility> #include <tuple> #include <vector> #include <algorithm> const int jobCnt = 10; // أوقات بداية الوظائف const int processTimes[] = { 2, 3, 1, 4, 3, 2, 3, 5, 2, 1}; // أوقات انتهاء الوظائف const int dueTimes[] = { 4, 7, 9, 13, 8, 17, 9, 11, 22, 25}; using namespace std; int main() { vector<pair<int,int>> jobs; for(int i=0; i<jobCnt; ++i) jobs.push_back(make_pair(processTimes[i], dueTimes[i])); // المرحلة الأولى: الترتيب sort(jobs.begin(), jobs.end(),[](pair<int,int> p1, pair<int,int> p2) { return p1.second < p2.second; }); // t=0 المرحلة الثانية: تعيين int t = 0; // المرحلة الثالثة vector<pair<int,int>> jobIntervals; for(int i=0; i<jobCnt; ++i) { jobIntervals.push_back(make_pair(t,t+jobs[i].first)); t += jobs[i].first; } // المرحلة الرابعة: طباعة المجالات cout << "Intervals:\n" << endl; int lateness = 0; for(int i=0; i<jobCnt; ++i) { auto pair = jobIntervals[i]; lateness = max(lateness, pair.second-jobs[i].second); cout << "(" << pair.first << "," << pair.second << ") " << "Lateness: " << pair.second-jobs[i].second << std::endl; } cout << "\nmaximal lateness is " << lateness << endl; return 0; } والخرج الناتج لهذا البرنامج هو: Intervals: (0,2) Lateness:-2 (2,5) Lateness:-2 (5,8) Lateness: 0 (8,9) Lateness: 0 (9,12) Lateness: 3 (12,17) Lateness: 6 (17,21) Lateness: 8 (21,23) Lateness: 6 (23,25) Lateness: 3 (25,26) Lateness: 1 maximal lateness is 8 الظاهر هنا أنّ وقت تشغيل هذه الخوارزمية هو Θ(n log n)، لأنّ الترتيب هو العملية الغالبة في هذه الخوارزمية. سنحاول الآن إثبات أنّ هذه الخوارزمية مُثلى، وهذا يستلزم ألا يكون في الجدول الزمني وقت خامل idle time أو غير مُستخدم. وهو أمر يتحقق في منظور أبكر موعد نهائي أولًا. لنفترض أنّ الوظائف مُرقّمة بحيث تكون d1<=d2<=...<=dn. نقول أن تقليبات جدول ما inversion of a schedule هي أزواج من الوظائف i وj، بحيث تكون i<j، وتكون j مُجدولةً قبل i. من الواضح أنّ منظور أبكر وقت نهائي أولًا ليس لها تقليبات، وإن كان الجدول يحتوي تقليبة، فهذا يعني أنّه يحتوي زوجًا من الوظائف المقلوبة والمُجدولة بالتتابع. إذا كانت L هي قيمة التأخير قبل التبديل، وM هو التأخير بعد التبديل، فستكون Lk=Mk لكل k != i,j.، نظرًا لأنّ تبديل وظيفتين متجاورتين لا ينقل الوظائف الأخرى من مواقعها. من الواضح أنّ Mi<=Li لأنّ الوظيفة i جُدوِلَت في وقت أبكر، أما إذا تأخرت الوظيفة j فيمكن أن نستنتج من التعريف أنّ: Mj = fi-dj من التعريف <= fi-di ( j و i لأنّه يمكن مبادلة) <= Li هذا يعني أنّ التأخير بعد المبادلة سيكون أقل أو يساوي التأخير قبل المبادلة، وهذا يُتمّ البرهان. لنفترض أنّ S* هو الجدول الأمثل وأنّ له أقل عدد ممكن من التقليبات. يمكن أن نفترض أنّ S* ليس فيه وقت شاغر، إذ لو كانت لـ S* أي تقليبات، فسيكون لدينا S=S*، وهذا يتمّ البرهان؛ أمّا إن احتوى S* على تقلِيبة فستكون له تقلِيبة مجاورة adjacent inversion. الخاصية الأخيرة تنصّ على أنّنا نستطيع تبديل التقليبات المتجاورة وتقليل عدد التقليبات دون زيادة التأخير، وهذا يتناقض مع تعريف S*. هناك الكثير من التطبيقات لمشكلة تقليل التأخير ومشكلة الحد الأدنى المشابهة لها minimum makespan problem، وهي المشكلة التي تحاول تقليل وقت انتهاء الوظائف. والمعتاد أنه لا تكون لديك آلة واحدة فقط، بل العديد من الآلات التي يمكنها القيام بالمهام نفسها بمعدّلات مختلفة. يمكن أن تتحول هذه المشكلة بسرعة إلى مشكلة من الصنف NP-complete. يظهر هنا سؤال مثير للاهتمام إن نظرنا إلى المشكلة المتصلة online، حيث تكون لدينا كل المهام والبيانات، لكن في صورة متصلة online، حيث تظهر المهام أثناء التنفيذ. ترجمة -بتصرّف- للفصل 18 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا دليل شامل عن تحليل تعقيد الخوارزمية المرجع الشامل إلى تعلم الخوارزميات للمبتدئين مدخل إلى الخوارزميات خوارزمية تحديد المسار النجمية A* Pathfinding خوارزمية ديكسترا Dijkstra’s Algorithm
-
الخوارزمية الشرهة هي كل خوارزمية تسعى إلى حل مشكلةٍ عبر البحث عن أفضل خيار في كل مرحلة جزئية من أجل إيجاد الحل الشامل والمثالي، ولهذا تُسمّى شرهة، إذ تحاول أن تبحث عن أفضل الخيارات في كل مرحلة ممكنة، ولا تأخذ بالضرورة كل المراحل بالحسبان، ولذلك لا تعطي الحل المثالي دائمًا لكامل المشكلة، وإنّما تعطي حلًا مؤلفًا من حلول جزئية مثالية، والتي تكون عادةً قريبةً إلى حدّ ما من الحل الشامل المثالي في مدة زمنية معقولة. ترميز هوفمان Human Coding ترميز هوفمان هو نوع خاص من الترميز المُحسّن optimal prefix code الذي يُستخدم في الضغط المحافظ على البيانات lossless data compression، إذ تضغط هذه الخوارزمية البيانات بفعالية كبيرة، بحيث يمكن أن تختزل من 20٪ إلى 90٪ من مساحة الذاكرة تبعًا لخصائص البيانات المضغوطة. ونحن ننظر إلى البيانات التي سنعمل عليها على أنها سلاسل من الحروف، تبدأ خوارزمية هوفمان الشرهة Huffman's greedy algorithm بإنشاء جدول يحدّد عدد مرات ظهور كل حرف (أي تردّده)، وبناءً على ذلك تنشئ الترميز المثالي لتمثيل كل حرف على هيئة سلسلة من القيم الثنائية (0 و 1). وقد اقتُرِحت هذه الخوارزمية على يد ديفيد هوفمان في عام 1951. لنفترض أنّ لدينا ملفًّا يحتوي بيانات مؤلفة من 100000 محرف، ونود ضغطها في أقل مساحة ممكنة. سنفترض أنّ هناك 6 أحرف مختلفة فقط في الملف. وأنّ تردّد الأحرف هو كالتالي: +---------------------------------+-----+-----+-----+-----+-----+-----+ | Character | a | b | c | d | e | f | +---------------------------------+-----+-----+-----+-----+-----+-----+ |Frequency (in thousands) | 45 | 13 | 12 | 16 | 9 | 5 | +---------------------------------+-----+-----+-----+-----+-----+-----+ لدينا عدة خيارات لتمثيل هذه البيانات، وسنحاول فيما يلي تصميم ترميز ثنائي للمحارف Binary Character Code، بحيث نمثّل كلّ حرف بسلسلة ثنائية فريدة سنسميها codeword أو الكلمة الرمزية. هذان الترميزان مشتقّان من الشجرة أعلاه: +----------------------------------+-----+-----+-----+-----+------+------+ | Character | a | b | c | d | e | f | +----------------------------------+-----+-----+-----+-----+------+------+ | Fixed-length Codeword | 000 | 001 | 010 | 011 | 100 | 101 | +----------------------------------+-----+-----+-----+-----+------+------+ |Variable-length Codeword | 0 | 101 | 100 | 111 | 1101 | 1100 | +----------------------------------+-----+-----+-----+-----+------+------+ إذا أردنا استخدام ترميز ثابت الطول فسنحتاج إلى ثلاث بتّات bit لتمثيل الأحرف الستة. تتطلّب هذه الطريقة 300000 بتّة لتخزين الملف بأكمله. والسؤال الآن، هل هذا أفضل ترميز ممكن؟ هناك نوع آخر من الترميز، وهو ترميز متغيّر الطول، أي أنّ الشيفرات التي تمثّل الحروف قد تكون من أطوال مختلفة، قد يكون أفضل بكثير من الترميز ثابت الطول، إذ أنّه يُرشِّد المساحة المُستخدمة لتخزين البيانات عبر إعطاء الأحرف الكثيرة التكرار/التردد شيفرات قصيرة، فيما يترك الشيفرات الطويلة للأحرف قليلة التردّد. ويتطلب هذا الترميز الآتي: (45 X 1 + 13 X 3 + 12 X 3 + 16 X 3 + 9 X 4 + 5 X 4) X 1000 = 224000 بتّةً لتمثيل الملف، أي أنّه يقتص على حوالي 25% من مساحة الذاكرة موازنة بالتمثيل ثابت الطول. تبسّط ترميزات السوابق Prefix codes عمليّة فك الترميز decoding، فما دام من غير الممكن أن يكون أيّ ترميز كلميّ سابقة لترميز كلميّ آخر، فإنّ الترميز الكلميّ الذي يبدأ ترميز الملف لن يكون فيه أيّ لبس، لهذا يمكننا بسهولة تحديد الترميز الكلميّ الأولي، ثمّ ترجمته إلى الحرف الأصلي الذي يرمز له، ثمّ تكرار عملية فك الترميز على بقية الملف المُرمّز. على سبيل المثال، هناك طريقة واحدة فقط لفك ترميز 001011101، وهي 0.0.101.1101، لتي تُترجم إلى aabe. باختصار، ستكون جميع توليف التمثيلات الثنائية مختلفةً عن بعضها، فإذا رمزنا لحرف ما بالترميز الكلمي 110 مثلًا، فلا يمكن ترميز أيّ حرف آخر بترميز كلمي يبدأ بالترميز السابق، مثل 1101 أو 1100. وهذا لمنع اللبس أثناء فك الترميز، وتجنّب أيّ ارتباك حول ما إذا كان علينا اختيار 110 أو الاستمرار في تحليل التسلسل البتّي. تقنيات الضغط تعمل تقنية الضغط عبر إنشاء شجرة ثنائية من العقد، يمكن تخزينها في مصفوفة عادية حجمها (n) يساوي عدد الرموز، وكل عقدة يمكن أن تكون إما ورقة leaf أو عقدة داخلية internal node. وتكون جميع العقد في البداية عبارة عن أوراق، تحتوي كل ورقة الرمز المراد تمثيله إلى جانب تردّده، ورابطًا اختياريًا يشير إلى ابنيها child nodes. ونصطلح في العادة على تمثيل الابن الأيسر بالبتّة '0'، فيما تمثّل البتة '1' الابن الأيمن، وتُخزّن العقد في رتل أو طابور، وعند استخراج قيمة منه، يعيد العقدة ذات التردد الأقل. وهذه هي خطوات العملية: أنشئ ورقةً لكلّ رمز، ثمّ أضفه إلى رتل الأولويات. طالما يحتوي الطابور أكثر من عقدة واحدة: انزع العقدتين ذواتي أكبر أولوية من الطابور. أنشئ عقدةً داخليةً جديدة، مع جعل العُقدتين اللتان استخرجتَهما من الطابور أبناءً لها، بحيث يساوي التردّد مجموع تردّدي العقدتين. أضف العقدة الجديدة إلى الطابور. العقدة المتبقية هي العقدة الجذرية، وبهذا نكون قد أكملنا شجرة هوفمان. انظر إلى الأمثلة التالية: ستبدو الشيفرة التوضيحية pseudo-code كالتالي، حيث أن C هي مجموعة المحارف والمعلومات ذات الصلة: Procedure Huffman(C): n = C.size Q = priority_queue() for i = 1 to n n = node(C[i]) Q.push(n) end for while Q.size() is not equal to 1 Z = new node() Z.left = x = Q.pop Z.right = y = Q.pop Z.frequency = x.frequency + y.frequency Q.push(Z) end while Return Q يتطلب استخدام الخوارزمية في الحالات العامة إجراء عملية ترتيب مسبق للمصفوفة المُدخلة. ويمثّل n عدد الرموز في الأبجدية، ويكون عادةً صغيرًا جدًّا (موازنةً بطول الرسالة المراد ترميزها)، لذا فإنّ تعقيد الوقت ليس مهمًا جدًا في اختيار هذه الخوارزمية. تقنيات فك الضغط Decompression فك الضغط هو عملية ترجمة تدفق من ترميزات السوابق prefix codes إلى بايتات فردية، وعادةً عن طريق تسلّق الشجرة عقدة بعقدة مع قراءة كل بتّة من المدخلات. يؤدّي الوصول إلى ورقة إلى إنهاء البحث عن قيمة البايت المضغوط، حيث تمثل قيمة الورقة الحرفَ المطلوب. وعادةً ما تُنشأ شجرة هوفمان باستخدام بيانات معدّلة إحصائيًا في كل دورة ضغط، وعليه فإنّ إعادة البناء بسيطة إلى حدّ ما؛ أما خلاف ذلك، فيجب إرسال المعلومات اللازمة لإعادة بناء الشجرة بشكل منفصل. انظر الشيفرة التوضيحية التالية، تمثل root جذر شجرة هوفمان، بينما تمثل S تدفق البتات المراد ضغطه: Procedure HuffmanDecompression(root, S): n := S.length for i := 1 to n current = root while current.left != NULL and current.right != NULL if S[i] is equal to '0' current := current.left else current := current.right endif i := i+1 endwhile print current.symbol endfor يحسب ترميز هوفمان تردّد كل محرف ويخزّنه على هيئة سلسلة ثنائية، وتتمثل فكرة الخوارزمية هنا في تعيين ترميزات متغيرة الأطوال للمحارف المُدخلة، بحيث يستند طول الترميزات على تردّدات الأحرف المقابلة، وذلك عبر إنشاء شجرة ثنائية والعمل عليها تصاعديًا حتى يكون الحرفان الأقل ترددًا بعيدين قدر الإمكان عن الجذر. وبهذه الطريقة سيحصل الحرف الأكثر تردّدًا على أقصر ترميز، بينما يحصل الحرف الأقل تردّدًا على أطول ترميز. مشكلة اختيار الأنشطة Activity Selection Problem لنفترض أنّ لديك مجموعةً من المهام التي عليك إنجازها (أنشطة)، بحيث لكل نشاط وقت بداية ووقت نهاية. ولا يُسمح لك بأداء أكثر من نشاط واحد في كلّ مرّة. السؤال الآن هو كيف تعثر على طريقة لأداء أقصى عدد ممكن من الأنشطة. على سبيل المثال، لنفترض أنّ لديك مجموعةً من الأقسام الدراسية للاختيار من بينها. table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } النشاط رقم وقت البداية وقت النهاية 1 10.20 صباحا 11.00 صباحا 2 10.30 صباحا 11.30 صباحا 3 11.00 صباحا 12.00 صباحا 4 10.00 صباحا 11.30 صباحا 5 9.00 صباحا 11.00 صباحا تذكّر أنه لا يمكنك أخذ فصلين دراسيين متداخلين، وهذا يعني أنه لا يمكنك أخذ الفصل 1 و2، لأنهما يتشاركان في الفترة من 10:30 صباحًا إلى 11.00 صباحًا. بالمقابل، يجوز لك أخذ الفصلين 1 و3، لأنهما لا يتشاركان أيّ وقت. عليك الآن أن تختار الفصول بطريقة تتيح لك أخذ أكبر عدد ممكن من الفصول دون أي تداخل، لكن كيف تفعل هذا؟ تحليل الجدول الزمني للفصول هذه بعض الطرق الممكنة لحل المشكلة: ترتيب الأنشطة بحسب وقت البداية: هذا يعني أنّنا سنأخذ النشاطات التي تبدأ أولًا، ثم نأخذ النشاطات من القائمة المُرتّبة من النشاط الأوّل إلى الأخير، ونتحقق ممّا إذا كان كل نشاط يتداخل مع النشاط الذي اخترناه سابقًا أم لا. إذا لم يكن هناك تداخل بين النشاطين، فسننفّذ النشاط، وإلا لن ننفّذه. تُعَد هذه الطريقة صالحةً لبعض الحالات، كما في المثال التالي: النشاط رقم وقت البداية وقت النهاية 1 11.00 صباحا 1.30 مساء 2 11.30 صباحا 12.00 ظهرًا 3 1.30 مساء 2.00 مساء 4 10.00 صباحا 11.00 صباحا سنحصل على هذا الترتيب 4 -> 1 -> 2 -> 3، وسننفّذ الأنشطة 4 -> 1 -> 3، ونتخطّى النشاط 2. وهذا يعني أننا سننفّذ 3 نشاطات، وهو الحد الأقصى الممكن. بهذا تكون طريقتنا قد نجحت إذًا في هذه الحالة، لكن هذا لا يعني أنّها ستنجح دائمًا، إذ هناك حالات يمكن أن تفشل فيها. كما يبيّن المثال التالي: النشاط رقم وقت البداية وقت النهاية 1 11.00 صباحا 1.30 مساء 2 11.30 صباحا 12.00 ظهرًا 3 1.30 مساء 2.00 مساء 4 10.00 صباحا 3.00 صباحا سنحصل على الترتيب التالي: 4 -> 1 -> 2 -> 3، ولن يُنفّذ إلا النشاط 4، أي نشاط واحد وحسب، لكنّنا نعلم أنّ الإجابة الأمثل هي 1 -> 3 أو 2- -> 3، وذلك لأنه في كلا الحالتين سنحضر فصلين دراسيين، وهذا يبيّن أنّ طريقتنا لم تنجح في الحالة المذكورة أعلاه، وذلك لنجرّب طريقةً أخرى. ترتيب الأنشطة بحسب مددها الزمنية: هذا يعني تنفيذ أقصر الأنشطة أولًا. ستحل هذه الطريقة المشكلة السابقة، لكنها ليست كاملةً أيضًا، فلا تزال هناك بعض الحالات التي لا يمكن أن تحلها. ولنطبق الطريقة على الحالة التالية: النشاط رقم وقت البداية وقت النهاية 1 6.00 صباحا 11.40 صباحا 2 11.30 صباحا 12.00 ظهرًا 3 11.40 صباحا 2.00 مساء إذا رتّبنا الأنشطة بحسب مددها الزمنية فسنحصل على الترتيب 2 -> 3 -> 1، لكن إن نفّذنا النشاط رقم 2 أولاً، فلن نستطيع تنفيذ أيّ نشاط آخر. الجواب المثالي هو تنفيذ النشاط 1 ثم 3، لذا لا يمكن أن يكون هذا حلًا لهذه المشكلة. لنجرّب طريقةً أخرى: ترتيب الأنشطة بحسب وقت الإنتهاء: هذا يعني أنّ الأنشطة التي تنتهي أولا توضع أولًا. انظر الخوارزمية: ترتيب الأنشطة بحسب أوقات نهايتها. إذا لم يتقاسم النشاط المراد تنفيذه وقتًا مشتركًا مع الأنشطة المُنفّذة سابقًا، فسننفّذه. لنحلّل المثال الأول: النشاط رقم وقت البداية وقت النهاية 1 10.20 صباحا 11.00 صباحا 2 10.30 صباحا 11.30 صباحا 3 11.00 صباحا 12.00 صباحا 4 10.00 صباحا 11.30 صباحا 5 9.00 صباحا 11.00 صباحا نرتب الأنشطة بحسب أوقات نهاياتها لنحصل على الترتيب 1 -> 5 -> 2 -> 4 -> 3، والجواب الصحيح هو 1 -> 3، أي أنّنا سننفّذ النشاطين 1 و3. وهو الجواب الصحيح: ترتيب الأنشطة. نفّذ أول نشاط في قائمة الأنشطة المرتبة. عيّن قيمة النشاط الأول إلى النشاط الحالي Current_activity := first activity. عين end_time := t، حيث t يمثل وقت إنهاء النشاط الحالي اذهب إلى النشاط الموالي إن كان موجودًا، خلاف ذلك أنهِ الخوارزمية. إن كان وقت البداية الخاص بالنشاط الحالي أكبر من end_time، نفّذ النشاط وعد إلى المرحلة 4. خلاف ذلك اذهب إلى المرحلة 5. مشكلة الصرف Change-making problem لنفترض أنّ لديك مبلغًا معينًا في نظام نقدي ما، هل يمكن إيجاد الحد الأدنى من القطع والأوراق النقدية المقابلة لذلك المبلغ. حسب الأنظمة المالية الأساسية، إذا افترضنا في بعض النظم المالية -مثل التي نستخدمها الآن- أنّ العملات النقدية الممكنة هي 1 و2 و5 و10، فالحل البديهي هو أن نبدأ بأعلى قطعة أو ورقة نقدية، ثمّ نكرّر هذا الإجراء إلى أن نستكمل المبلغ. فمثلا، إن كان المبلغ هو 28 درهمًا، فيمكننا تصريفه إلى 10 + 10 + 5 + 2 + 1 = 28، وهكذا سيكون الحدّ الأدنى هو 5، وهو الحل الصحيح. نستطيع فعل هذا بشكل تكراري في لغة OCaml كما يلي: (* نفترض أنّ النظام النقدي مُرتب ترتيبًا تنازليًا *) let change_make money_system amount = let rec loop given amount = if amount = 0 then given else (* القيمة الأولى أصغر أو تساوي باقي المبلغ *) let coin = List.find ((>=) amount) money_system in loop (coin::given) (amount - coin) in loop [] amount هذه الخوارزمية ليست صالحةً دائمًا، فلو كان المبلغ يساوي 99 وكانت القطع والأوراق النقدية الممكنة هي 10 و7 و5، فإنّ الحل السابق لن يعمل، إذ لو أخذنا أكبر القطع النقدية 10، وجمعناه إلى أن نصل إلى 90، فستبقى لنا 9، وهو مبلغ لا يمكن تكوينه من القطعتين 5 و7. ثم إنّه لا توجد ضمانة لوجود حلّ أصلًا، وهذه المشكلة في الواقع صعبة للغاية، لكن توجد بعض الحلول الصالحة التي تجمع بين الشره greediness والتذكر memoization، وتقوم على استكشاف جميع الإمكانات، واختيار تلك التي تتألف من أقل عدد من القطع النقدية. لنفترض أنّ لدينا مبلغًا X> 0، وقد اخترنا قطعة نقدية P من النظام المالي، سيكون المبلغ المتبقي إذن هو X-P. نحلّ الآن مشكلة تصريف المبلغ X-P إلى أقل عدد ممكن من القطع النقدية، ونجرّب هذا مع جميع القطع النقدية في النظام. لاحظ أنّه في كل مرة نختار قطعة نقدية P فإنّنا نجعل المشكلة أصغر (أي X-P). ويكون الحل النهائي -إذا كان موجودًا- هو أصغر مسار من المسارات التي اتبعناها وأدّت إلى المبلغ 0. انظر فيما يلي دالة OCaml تكرارية لحل هذه المشكلة، تعيد هذه الدالة None في حال لم يكن هناك حل: (* option utilities *) let optmin x y = match x,y with | None,a | a,None -> a | Some x, Some y-> Some (min x y) let optsucc = function | Some x -> Some (x+1) | None -> None (* مشكلة الصرف*) let change_make money_system amount = let rec loop n = let onepiece acc piece = match n - piece with | 0 -> (*problem solved with one coin*) Some 1 | x -> if x < 0 then (* نتجاهل هذا الحل إن لم نصل إلى 0*) None else (*من القطع النقدية الباقية None نبحث عن أقصر مسار يخالف*) optmin (optsucc (loop x)) acc in (*على جميع القطع النقدية onepiece نستدعي*) List.fold_left onepiece None money_system in loop amount خوارزمية كروسكال Kruskal's Algorithm خوارزمية كروسكال هي خوارزمية تهدف إلى إيجاد المسار الأقصر (الأقل كلفة)، وهي من الخوارزميات الشرهة Greedy Algorithm التي تُستخدَم بكثرة في نظرية المخططات. استخدام المجموعات التوزيعية هناك شيئان يمكننا القيام بهما لتحسين مجموعة الخوارزميات الفرعية للمجموعات التوزيعية المُحسّنة sub-optimal disjoint-set subalgorithms، وهما: مقاييس بحثية لضغط المسار Path compression heuristic: لن تحتاج الدالة findSet (انظر الشيفرة أدناه) إلى التكرار على شجرة ارتفاعها أطول من 2، وإلا فيمكنها ربط العقد السفلية مباشرة بالجذر، مما يحسّن عمليات العبور traversals المستقبلية: subalgo findSet(v: a node): if v.parent != v v.parent = findSet(v.parent) return v.parent المقاييس البحثية القائمة على الارتفاع Height-based merging heuristic: خزّن ارتفاع الشجيرة subtree الخاصة بكل عقدة، واجعل الشجرة الأطول أبًا parent للشجرة الأصغر عند الدمج، وذلك دون زيادة ارتفاع أيّ شجرة: subalgo unionSet(u, v: nodes): vRoot = findSet(v) uRoot = findSet(u) if vRoot == uRoot: return if vRoot.height < uRoot.height: vRoot.parent = uRoot else if vRoot.height > uRoot.height: uRoot.parent = vRoot else: uRoot.parent = vRoot uRoot.height = uRoot.height + 1 يستغرق هذا مدة O(alpha(n)) لكلّ عملية، حيث تمثّل alpha مقلوب inverse دالة أكرمان Ackermann المعروفة بسرعة نموها، وهذا يعني أنّ alpha ستكون بطيئة جدًا، ويمكن عدّ تعقيدها عمليًا ثابتًا (O(1))، ما يعني أنّ تعقيد خوارزمية Kruskal سيساوي O(m log m + m) = O(m log m) أخذًا بالحسبان الترتيبَ الأولي. ولتجنّب تعقيد تخزين وحساب ارتفاعات الأشجار، يمكن اختيار الشجرة الأب parent عشوائيًا: subalgo unionSet(u, v: nodes): vRoot = findSet(v) uRoot = findSet(u) if vRoot == uRoot: return if random() % 2 == 0: vRoot.parent = uRoot else: uRoot.parent = vRoot عمليًا، ينتج عن هذه الخوارزمية العشوائية مرفوقة بعملية ضغط المسار في الدالة findSet، تحسّن كبير في الأداء رغم أنّها أكثر بساطة. تطبيق مفصل implementation من أجل رصد الدورات cycle detection بفعالية، سنعُدّ كل عقدة جزءًا من شجرة، ونتحقّق عند إضافة ضلع ممّا إذا كانت عقدَتاه جزءًا من شجرتين منفصلتين. في البداية، تشكّل كلّ عقدة شجرةً مؤلفةً من عقدة واحدة وحسب: algorithm kruskalMST(G: a graph) sort Gs edges by their value // بحسب القيم G ترتيب MST = a forest of trees, initially each tree is a node in the graph // غابة من الأشجار، حيث تمثل كل شجرة عقدة من الشعبة for each edge e in G: if the root of the tree that e.first belongs to is not the same as the root of the tree that e.second belongs to: connect one of the roots to the other, thus merging two trees إذا كان جذر الشجرة التي ينتمي إليها e.first يساوي جذر الشجرة التي ينتمي إليها e.second، فاربط أحد الجذرين بالآخر، وهكذا تُدمَج الشجرتان. return MST, which now a single-tree forest // أصبحت الآن غابة من شجرة واحدة MST يقوم منظور الغابات forest -مجموعة من الأشجار غير المتصلة بالضرورة- المذكور أعلاه على استخدام المجموعات التوزيعية disjoint-set data structure، وينطوي على ثلاث عمليات رئيسية: subalgo makeSet(v: a node): v.parent = v <- make a new tree rooted at v subalgo findSet(v: a node): if v.parent == v: return v return findSet(v.parent) subalgo unionSet(v, u: nodes): vRoot = findSet(v) uRoot = findSet(u) uRoot.parent = vRoot algorithm kruskalMST(G: a graph): sort Gs edges by their value for each node n in G: makeSet(n) for each edge e in G: if findSet(e.first) != findSet(e.second): unionSet(e.first, e.second) يستغرق هذا التطبيق حوالي O(n log n) لإدارة المجموعات التوزيعية، وهكذا يصبح التعقيد الزمني الإجمالي لخوارزمية كروسكال O(m*n log n ). في الأخير، نستعرض تطبيقًا آخر عالي المستوى للخوارزمية، حيث نرتّب الأضلاع بحسب القيم، ثمّ نضيف كلّ واحد منها إلى شجرة الامتداد الأدنى MST بالترتيب ما لم ينجم عن ذلك دورة. algorithm kruskalMST(G: a graph) sort Gs edges by their value MST = an empty graph for each edge e in G: if adding e to MST does not create a cycle: add e to MST return MST ترجمة -بتصرّف- للفصلين 16 و17 من كتاب Algorithms Notes for Professionals. اقرأ أيضًا المقال السابق: البرمجة الديناميكية مدخل إلى الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية