يعد الفرز إحدى العمليات الأساسية التي نقوم بها على الكائنات. حتى في مرحلة الطفولة، يتم تعليم الأطفال الفرز أثناء تطوير مهارات التفكير لديهم. أجهزة الكمبيوتر والبرمجيات ليست استثناء. هناك مجموعة كبيرة ومتنوعة من خوارزميات الفرز في Java . أقترح عليك التحقق من ما هي وكيف تعمل. ماذا لو سُئلت عن أحدهم في مقابلة يومًا ما؟
L يصبح مساوياً لـ
مقدمة
يعد فرز العناصر إحدى فئات الخوارزميات التي يجب على المطور معرفتها. إذا لم يتم أخذ علوم الكمبيوتر على محمل الجد عندما كنت في المدرسة، فيجب أن يكون طلاب اليوم قادرين على تنفيذ وفهم خوارزميات الفرز. يتم تنفيذ الخوارزميات الأساسية، وهي الأبسط، باستخدام حلقة for . بطبيعة الحال، لفرز مجموعة من العناصر، مثل المصفوفة، تحتاج إلى المرور عبر المجموعة بطريقة أو بأخرى. على سبيل المثال:int[] array = {10, 2, 10, 3, 1, 2, 5};
for (int i = 0; i < array.length; i++) {
System.out.println(array[i]);
}
ماذا يمكن أن يقال عن هذا الجزء من التعليمات البرمجية؟ لدينا حلقة نقوم فيها بتغيير الفهرس ( int i
) من 0 إلى العنصر الأخير في المصفوفة. في الواقع، نحن ببساطة نأخذ كل عنصر في المصفوفة ونطبع محتوياته. كلما زاد عدد العناصر في المصفوفة، كلما استغرقت التعليمات البرمجية وقتًا أطول حتى تنتهي. وهذا يعني أنه إذا n
كان عدد العناصر، n = 10
فسيستغرق تشغيل البرنامج ضعف الوقت الذي يستغرقه تشغيله n = 5
. إذا كان برنامجنا يحتوي على حلقة واحدة، فإن وقت التنفيذ ينمو خطيًا: كلما زاد عدد العناصر، زاد وقت التنفيذ. اتضح أن الخوارزمية المذكورة أعلاه تعمل في الزمن الخطي (دالة خطية لـ n). في مثل هذه الحالات، نقول أن تعقيد الخوارزمية هو "O(n)". يُطلق على هذا الترميز أيضًا اسم "big O" أو "السلوك المقارب". ولكن يمكنك فقط أن تتذكر "تعقيد الخوارزمية".
أبسط خوارزمية الفرز (الفرز الفقاعي)
لنفترض أن لدينا مصفوفة ويمكننا التكرار من خلالها. عظيم. الآن دعونا نحاول فرزها بترتيب تصاعدي. ماذا يعني هذا؟ وهذا يعني أنه بالنظر إلى عنصرين (على سبيل المثالa = 6
، b = 5
)، يجب علينا إعادة ترتيب a
إذا b
كان a
أكبر من b
(إذا a > b
). ماذا يعني هذا عندما نستخدم فهرسًا للعمل مع مجموعة (كما هو الحال مع المصفوفة)؟ وهذا يعني أنه إذا كان العنصر ذو الفهرس a أكبر من العنصر ذو الفهرس b
( array[a] > array[b]
)، فيجب تبديل العناصر. هناك طرق مختلفة لتغيير الأماكن. لكننا سنستخدم أسلوبًا بسيطًا ومفهومًا وسهل التذكر:
private void swap(int[] array, int ind1, int ind2) {
int tmp = array[ind1];
array[ind1] = array[ind2];
array[ind2] = tmp;
}
والآن يمكننا أن نكتب ما يلي:
int[] array = {10, 2, 10, 3, 1, 2, 5};
System.out.println(Arrays.toString(array));
for (int i = 1; i < array.length; i++) {
if (array[i] < array[i - 1]) {
swap(array, i, i-1);
}
}
System.out.println(Arrays.toString(array));
كما ترون، تم بالفعل تبديل العناصر. لقد بدأنا بالفهرس 1، لأنه إذا كان المصفوفة تحتوي على عنصر واحد فقط، array[i] < array[i-1]
فسيكون التعبير غير صالح للفهرس 0
. القيام بذلك يحمينا أيضًا من الحالات التي لا تحتوي فيها المصفوفة على عناصر أو عنصر واحد فقط، ويجعل الكود يبدو أفضل. لكن المصفوفة الناتجة لم يتم فرزها بعد، لأن تمريرة واحدة لا تكفي لإجراء الفرز. سيتعين علينا إضافة حلقة أخرى سنقوم من خلالها بإجراء التمريرات مرارًا وتكرارًا حتى نحصل على مصفوفة مرتبة:
int[] array = {10, 2, 10, 3, 1, 2, 5};
System.out.println(Arrays.toString(array));
boolean needIteration = true;
while (needIteration) {
needIteration = false;
for (int i = 1; i < array.length; i++) {
if (array[i] < array[i - 1]) {
swap(array, i, i-1);
needIteration = true;
}
}
}
System.out.println(Arrays.toString(array));
وبذلك انتهينا من خوارزمية الفرز الأولى. نكرر الحلقة الخارجية ( while
) حتى نقرر عدم الحاجة إلى المزيد من التكرار. افتراضيًا، قبل كل تكرار جديد، نفترض أن المصفوفة الخاصة بنا مرتبة ولم نعد بحاجة إلى التكرار. وبناء على ذلك، نتحرك بالتسلسل عبر العناصر ونتحقق من هذا الافتراض. لكن إذا لم تكن العناصر مرتبة، فإننا نقوم بتبديل العناصر ونفهم أنه ليس لدينا الآن أي ضمان بأن العناصر مرتبة بشكل صحيح. هذا يعني أننا بحاجة إلى إجراء تكرار آخر. على سبيل المثال، لنفترض أن لدينا [3, 5, 2]
. 5
أكثر من 3
— كل شيء على ما يرام. ولكنها 2
أقل من 5
. ومع ذلك، [3, 2, 5]
يتطلب تمريرة أخرى، حيث 3 > 2
يجب تبديلهما. نظرًا لأننا نستخدم حلقة داخل حلقة، يزداد تعقيد الخوارزمية لدينا. بالنظر إلى n
العناصر، يصبح n * n
، أي، O(n^2)
. وهذا ما يسمى التعقيد التربيعي. بشكل عام، لا يمكننا أن نعرف بالضبط عدد التكرارات المطلوبة. يوضح التعبير عن تعقيد الخوارزمية كيف يزداد التعقيد في أسوأ الحالات. أي أنه يشير إلى مقدار زيادة وقت التنفيذ مع تغير عدد العناصر n
. يعد فرز الفقاعات أحد أبسط خوارزميات الفرز وأكثرها كفاءة. ويطلق عليه أيضًا أحيانًا "النوع الغبي". المواد حول هذا الموضوع:
اختيار نوع
خوارزمية فرز أخرى هي الفرز بالاختيار. كما أن لديها تعقيدًا من الدرجة الثانية، ولكن المزيد عن ذلك لاحقًا. لذا فإن الفكرة بسيطة. في كل تمريرة، نختار أصغر عنصر وننقله إلى البداية. بالإضافة إلى ذلك، تبدأ كل تمريرة بخطوة واحدة إلى اليمين. بمعنى آخر، التمريرة الأولى تبدأ من العنصر الصفري، والتمريرة الثانية من العنصر الأول، وهكذا. سيبدو الأمر كما يلي:int[] array = {10, 2, 10, 3, 1, 2, 5};
System.out.println(Arrays.toString(array));
for (int left = 0; left < array.length; left++) {
int minInd = left;
for (int i = left; i < array.length; i++) {
if (array[i] < array[minInd]) {
minInd = i;
}
}
swap(array, left, minInd);
}
System.out.println(Arrays.toString(array));
هذا النوع غير مستقر، لأن العناصر المتطابقة (من حيث أي خاصية نستخدمها لفرز العناصر) يمكن أن تغير مواقعها النسبية. يوجد مثال جيد في مقالة ويكيبيديا حول نوع التحديد
. المواد حول هذا الموضوع:
ترتيب بالإدراج
يتميز فرز الإدراج أيضًا بتعقيد تربيعي، نظرًا لأنه لدينا مرة أخرى حلقة داخل حلقة. ما الذي يجعل فرز الإدراج مختلفًا؟ خوارزمية الفرز هذه "مستقرة". وهذا يعني أن العناصر المتطابقة لن تغير ترتيبها النسبي. مرة أخرى، نعني متطابقًا من حيث الخصائص التي نقوم بالفرز وفقًا لها.int[] array = {10, 2, 10, 3, 1, 2, 5};
System.out.println(Arrays.toString(array));
for (int left = 0; left < array.length; left++) {
// Get an element
int value = array[left];
// Iterate through the elements that are in front of this element
int i = left - 1;
for (; i >= 0; i--) {
// If the current element is smaller, then move the larger element to the right.
if (value < array[i]) {
array[i + 1] = array[i];
} else {
// If the current element is larger, we stop
break;
}
}
// Insert the current value in the empty space
array[i + 1] = value;
}
System.out.println(Arrays.toString(array));
نوع المكوك
هناك خوارزمية فرز بسيطة أخرى: الفرز المكوكي. يُعرف هذا أيضًا بنوع الفقاعة ثنائي الاتجاه أو نوع شاكر الكوكتيل. تخبرنا هذه الأسماء البديلة أن نوع المكوك لا يتعلق بالمكوك الفضائي. يتعلق الأمر بشيء يتحرك ذهابًا وإيابًا. الآن يمكنك التفكير في ذلك عندما تفكر في هذه الخوارزمية. ما هو جوهر الخوارزمية؟ جوهر الخوارزمية هو أننا نكرر من اليسار إلى اليمين، ونتبادل العناصر ونتحقق مما إذا كان أي من العناصر المتبقية في الاتجاه الآخر بحاجة إلى التبديل.int[] array = {10, 2, 10, 3, 1, 2, 5};
System.out.println(Arrays.toString(array));
for (int i = 1; i < array.length; i++) {
if (array[i] < array[i - 1]) {
swap(array, i, i - 1);
for (int z = i - 1; (z - 1) >= 0; z--) {
if (array[z] < array[z - 1]) {
swap(array, z, z - 1);
} else {
break;
}
}
}
}
System.out.println(Arrays.toString(array));
المواد حول هذا الموضوع:
نوع شل
خوارزمية فرز بسيطة أخرى هي فرز الصدفة. جوهرها مشابه لفرز الفقاعات، ولكن في كل تكرار لدينا فجوة مختلفة بين العناصر المقارنة. يتم قطعه إلى النصف مع كل تكرار. وهنا التنفيذ:int[] array = {10, 2, 10, 3, 1, 2, 5};
System.out.println(Arrays.toString(array));
// Calculate the gap between the checked elements
int gap = array.length / 2;
// As long as there is a gap between the elements
while (gap >= 1) {
for (int right = 0; right < array.length; right++) {
// Shift the right index until we find one for which
// there is the necessary gap between it and the element before it
for (int c = right - gap; c >= 0; c -= gap) {
if (array[c] > array[c + gap]) {
swap(array, c, c + gap);
}
}
}
// Recalculate the gap
gap = gap / 2;
}
System.out.println(Arrays.toString(array));
المواد حول هذا الموضوع:
دمج الفرز
بالإضافة إلى خوارزميات الفرز البسيطة هذه، هناك أيضًا خوارزميات فرز أكثر تعقيدًا. على سبيل المثال، دمج الفرز. هناك شيئان يجب ملاحظتهما. أولا، العودية تأتي لإنقاذنا هنا. ثانيًا، لم يعد تعقيد الخوارزمية تربيعيًا، كما اعتدنا عليه. تعقيد هذه الخوارزمية لوغاريتمي. هذا مكتوب كO(n log n)
. لذلك دعونا ننفذها. أولاً، سنكتب استدعاءً متكررًا لطريقة الفرز:
public static void mergeSort(int[] source, int left, int right) {
// Select the delimiter, i.e. split the input array in half
int delimiter = left + ((right - left) / 2) + 1;
// Recursively execute this function on the two halves (if we can split the input array)
if (delimiter > 0 && right > (left + 1)) {
mergeSort(source, left, delimiter - 1);
mergeSort(source, delimiter, right);
}
}
الآن، دعونا نضيف الإجراء الرئيسي إلى تنفيذنا. إليك طريقتنا الفائقة:
public static void mergeSort(int[] source, int left, int right) {
// Select the delimiter, i.e. split the input array in half
int delimiter = left + ((right - left) / 2) + 1;
// Recursively execute this function on the two halves (if we can split the input array)
if (delimiter > 0 && right > (left + 1)) {
mergeSort(source, left, delimiter - 1);
mergeSort(source, delimiter, right);
}
// Create a temporary array with the required size
int[] buffer = new int[right - left + 1];
// Starting from the specified left index, go through each element.
int cursor = left;
for (int i = 0; i < buffer.length; i++) {
// We use delimeter to point to the element on the right half
// If delimeter> right, then the right half has no elements that haven't been added
if (delimiter > right || source[cursor] > source[delimiter]) {
buffer[i] = source[cursor];
cursor++;
} else {
buffer[i] = source[delimiter];
delimiter++;
}
}
System.arraycopy(buffer, 0, source, left, buffer.length);
}
يمكننا تنفيذ مثالنا عن طريق الاتصال mergeSort(array, 0, array.length-1)
. كما ترون، تتلخص العملية في قبول مصفوفة إدخال مع مؤشرات لبداية ونهاية القسم الذي يحتاج إلى الفرز. عندما يبدأ الفرز، فهذه هي بداية المصفوفة ونهايتها. ثم نحسب المحدد، وهو الفهرس الذي سنقسم فيه المصفوفة. إذا كان من الممكن تقسيم المصفوفة إلى جزأين، فإننا نسمي طريقة الفرز بشكل متكرر لنصفي المصفوفة. نقوم بإعداد مخزن مؤقت مساعد حيث سنقوم بإدراج القسم الذي تم فرزه. بعد ذلك، قم بتعيين الفهرس في بداية القسم المراد فرزه وابدأ في التنقل عبر كل عنصر من عناصر المخزن المؤقت الفارغ، واملأه بأصغر العناصر. إذا كان العنصر الذي يشير إليه الفهرس أقل من العنصر الذي يشير إليه المحدد، فإننا نضع العنصر في المخزن المؤقت ونغير الفهرس. بخلاف ذلك، فإننا نضع العنصر الذي يشير إليه المحدد في المخزن المؤقت ونغير المحدد. بمجرد أن يتجاوز المحدد حدود القسم الذي يتم فرزه أو نملأ المصفوفة بأكملها، يعتبر النطاق المحدد مصنفا. المواد حول هذا الموضوع:
عد الفرز وفرز الجذر
خوارزمية فرز أخرى مثيرة للاهتمام هي فرز الفرز. التعقيد الخوارزمي هنا هوO(n+k)
عدد n
العناصر والحد k
الأقصى لقيمة العنصر. هذه الخوارزمية لديها عيب واحد: نحن بحاجة إلى معرفة الحد الأدنى والحد الأقصى للقيم في المصفوفة. فيما يلي مثال على فرز العد:
public static int[] countingSort(int[] theArray, int maxValue) {
// An array of "counters", ranging from 0 to the maximum value
int numCounts[] = new int[maxValue + 1];
// We increase the counter in the corresponding cell (index = value)
for (int num : theArray) {
numCounts[num]++;
}
// Create an array to hold the sorted result
int[] sortedArray = new int[theArray.length];
int currentSortedIndex = 0;
// Run through the array of "counters"
for (int n = 0; n < numCounts.length; n++) {
int count = numCounts[n];
// Run through the number of values
for (int k = 0; k < count; k++) {
sortedArray[currentSortedIndex] = n;
currentSortedIndex++;
}
}
return sortedArray;
}
يمكنك أن تفهم أنه من غير الملائم للغاية أن نحتاج إلى معرفة الحد الأدنى والحد الأقصى للقيم مسبقًا. ولدينا خوارزمية أخرى هنا: فرز الجذر. سأقدم الخوارزمية هنا بشكل مرئي فقط. انظر المواد التكميلية للتنفيذ: المواد:
فرز سريع
حسنًا، حان وقت الحلوى - الفرز السريع، إحدى خوارزميات الفرز الأكثر شهرة. لديها تعقيد لوغاريتمي:O(n log n)
. تم تطوير خوارزمية الفرز هذه بواسطة توني هور. ومن المثير للاهتمام أنه اخترعها أثناء إقامته في الاتحاد السوفيتي، حيث درس الترجمة الآلية في جامعة موسكو وقام بتطوير كتاب العبارات باللغتين الروسية والإنجليزية. علاوة على ذلك، تستخدم Java إصدارًا أكثر تعقيدًا من هذه الخوارزمية في Arrays.sort
. ماذا عن Collections.sort
؟ لماذا لا تلقي نظرة على كيفية فرز الأشياء "تحت الغطاء"؟ إليك الكود:
public static void quickSort(int[] source, int leftBorder, int rightBorder) {
int leftMarker = leftBorder;
int rightMarker = rightBorder;
int pivot = source[(leftMarker + rightMarker) / 2];
do {
// Move the left marker from left to right as long as the element is less than pivot
while (source[leftMarker] < pivot) {
leftMarker++;
}
// Move the right marker as long as the element is greater than pivot
while (source[rightMarker] > pivot) {
rightMarker--;
}
// Check whether we need to swap the elements pointed to by the markers
if (leftMarker <= rightMarker) {
// The left marker will be less than the right one only if we need to do a swap
if (leftMarker < rightMarker) {
int tmp = source[leftMarker];
source[leftMarker] = source[rightMarker];
source[rightMarker] = tmp;
}
// Shift the markers to get new borders
leftMarker++;
rightMarker--;
}
} while (leftMarker <= rightMarker);
// Execute recursively on the parts
if (leftMarker < rightBorder) {
quickSort(source, leftMarker, rightBorder);
}
if (leftBorder < rightMarker) {
quickSort(source, leftBorder, rightMarker);
}
}
هذه كلها أشياء مخيفة للغاية، لذلك دعونا نتعمق فيها. بالنسبة لمصفوفة الإدخال ( int[]
المصدر)، نقوم بإنشاء علامتين: اليسار ( L
) واليمين ( R
). أثناء استدعاء الأسلوب الأول، فإنها تتوافق مع بداية المصفوفة ونهايتها. ثم نحدد العنصر المحوري، والذي يدعى على نحو مناسب pivot
. بعد ذلك، مهمتنا هي نقل القيم الأصغر من pivot
إلى اليسار pivot
، والقيم الأكبر إلى اليمين. للقيام بذلك، نقوم أولاً بتحريك L
العلامة حتى نجد قيمة أكبر من pivot
. إذا لم يتم العثور على قيمة أصغر، ثمpivot
. ثم نحرك
R
العلامة حتى نجد قيمة أصغر من
pivot
. إذا لم يتم العثور على قيمة أكبر،
R
تصبح مساوية لـ
pivot
. بعد ذلك، إذا
L
كانت العلامة قبل
R
العلامة أو تتزامن معها، فإننا نحاول تبديل العناصر إذا
L
كان العنصر أقل من
R
العنصر. ثم ننتقل
L
إلى اليمين بمقدار 1، وننتقل
R
إلى اليسار بمقدار 1. عندما
L
تتحرك العلامة خارج
R
العلامة، فهذا يعني أن المبادلة قد اكتملت: القيم الأصغر على يسار
pivot
، والقيم الأكبر على يمين
pivot
. بعد ذلك، نطلق نفس طريقة الفرز بشكل متكرر على المصفوفات الفرعية - من بداية القسم المراد فرزه إلى العلامة اليمنى، ومن العلامة اليسرى إلى نهاية القسم المراد فرزه. لماذا من البداية إلى العلامة الصحيحة؟ لأنه في نهاية التكرار، اتضح أن العلامة اليمنى تتحرك بما يكفي لتصبح حدود الجزء الأيسر. هذه الخوارزمية أكثر تعقيدًا من الفرز البسيط، لذا من الأفضل رسمها. خذ ورقة بيضاء واكتب فيها: 4 2 6 7 3. ثم اكتب
pivot
في المنتصف، أي تحت الرقم 6. ضع دائرة حولها. أقل من 4 اكتب
L
، وأقل من 3 اكتب
R
. 4 أقل من 6، 2 أقل من 6. انتهى بنا الأمر بالانتقال
L
إلى
pivot
الموضع، لأنه
L
لا يمكننا تجاوزه
pivot
، وفقًا للشرط. اكتب مرة أخرى 4 2 6 7 3. ضع دائرة حول 6 (
pivot
) ثم ضع
L
تحتها. الآن حرك
R
العلامة. 3 أقل من 6، لذا ضع
R
العلامة على 3. بما أن 3 أقل من 6 (
pivot
)، فإننا نقوم بإجراء
swap
. اكتب النتيجة: 4 2 3 7 6. ضع دائرة حول 6، لأنه لا يزال
pivot
. العلامة
L
على 3.
R
العلامة على 6. تذكر أننا نحرك العلامات حتى
L
تتجاوز
R
. انتقل
L
إلى الرقم التالي. أود هنا استكشاف احتمالين: 1) الحالة التي يكون فيها الرقم قبل الأخير 7 و2) الحالة التي يكون فيها الرقم 1 وليس 7.
إذا كان الرقم قبل الأخير هو 1: حرك
L
العلامة إلى 1، لأننا نستطيع التحرك
L
طالما أن
L
العلامة تشير إلى رقم أصغر من
pivot
. لكن لا يمكننا التحرك
R
من 6، لأننا لا نستطيع تحريك R إلا إذا كانت
R
العلامة تشير إلى رقم أكبر من
pivot
. نحن لا نقوم بإجراء
swap
، لأن 1 أقل من 6. اكتب الوضع الحالي: 4 2 3 1 6. ضع دائرة حول 6 (
pivot
).
L
يتحول
pivot
ويتوقف عن الحركة.
R
لا يتحرك. نحن لا نقوم بإجراء مبادلة. التحول
L
وبموضع
R
واحد. اكتب
R
العلامة تحت الرقم 1.
L
العلامة خارج الحدود. لأن
L
هو خارج الحدود، ونحن لا نفعل شيئا. لكننا نكتب 4 2 3 1 مرة أخرى، لأن هذا هو الجانب الأيسر، وهو أقل من
pivot
(6). حدد الجديد
pivot
وابدأ كل شيء مرة أخرى :)
إذا كان الرقم قبل الأخير هو 7: انقل
L
العلامة إلى 7. لا يمكننا تحريك العلامة الصحيحة، لأنها تشير بالفعل إلى المحور. 7 أكبر من
pivot
، لذلك نقوم بإجراء
swap
. اكتب النتيجة: 4 2 3 6 7. ضع دائرة حول 6، لأنه
pivot
. تم الآن إزاحة العلامة
L
إلى 7، وتم
R
نقل العلامة إلى 3. ليس من المنطقي فرز الجزء من
L
النهاية، لأنه يوجد عنصر واحد فقط. ومع ذلك، نرسل الجزء من 4 إلى
R
العلامة للفرز. نختار a
pivot
ونبدأ من جديد :) للوهلة الأولى، قد يبدو أنه إذا أضفت الكثير من القيم المساوية لـ
pivot
، فسوف تكسر الخوارزمية. ولكن هذا ليس هو الحال. يمكنك ابتكار مجموعات صعبة وإقناع نفسك على الورق بأن كل شيء صحيح، وتتعجب من أن مثل هذه الإجراءات البسيطة تنفذ آلية الفرز الموثوقة هذه. الجانب السلبي الوحيد هو أن خوارزمية الفرز هذه غير مستقرة. لأن المبادلة قد تغير الترتيب النسبي للعناصر المتطابقة إذا تمت مواجهة أحدها قبل
pivot
أن يتم تبديل العنصر الآخر إلى الجزء السابق
pivot
.
GO TO FULL VERSION