Сортування — одне із базових видів активності чи дій, виконуваних над предметами. Ще в дитинстві дітей вчать сортувати, розвиваючи мислення. Комп'ютери та програми теж не виняток. Існує безліч алгоритмів. Пропоную подивитися, які є та як вони працюють. Крім того, раптом вас запитають про одного з них на співбесіді?
Матеріали:
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, у 2 рази. Коли в нашій програмі є один цикл, час виконання зростає лінійно: що більше елементів, то довше виконання. Виходить, що алгоритм коду працює за лінійний час (n). У разі говорять, що " складність алгоритму " дорівнює O(n). Це позначення ще називають "велике" або "асимптотичне поведінка". Але можна запам'ятати просто "складність алгоритму".
Найпростіше сортування (Bubble Sort)
Отже, ми маємо масив і можемо ним ітеруватися. Чудово. Давайте спробуємо його відсортувати за зростанням. Що це означає для нас? Це означає, що маючи два елементи (наприклад, a=6, b=5), ми повинні переставити місцями a та b, якщо a більше ніж b (якщо a > b). Що для нас це означає при роботі з колекцією за індексом (як у випадку з масивом)? Це означає, якщо елемент з індексом а більше, ніж елемент з індексом b, (array[a] > array[b]), такі елементи треба поміняти місцями. Зміну місць часто називають swap. Існують різні способи зміни місць. Але ми використовуємо простий, зрозумілий код, що легко запам'ятовується: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 < 1 не поверне true і тим самим ми убезпечимо себе від випадків масиву в один елемент або зовсім без них, а код буде виглядати краще. Але наш підсумковий масив не відсортовано однаково, т.к. за один прохід не вдається відсортувати всіх. Прийде додати ще цикл, в якому ми будемо виконувати проходи один за одним до тих пір, поки не отримаємо відсортований масив:
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
) До тих пір, поки не вирішимо, що ітерацій більше не потрібно. За умовчанням перед кожною новою ітерацією ми припускаємо, що наш масив відсортований, і більше не хочемо ітеруватися. Тому ми проходимо елементи послідовно і перевіряємо це припущення. Але якщо елементи не по порядку, ми виконуємо swap елементів та розуміємо, що немає впевненості, що тепер елементи у правильному порядку. Отже хочемо виконати ще одну ітерацію. Наприклад, [3, 5, 2]. 5 більше трьох, все гаразд. Але 2 менше 5. Проте [3, 2, 5] потребує ще одного проходу, т.к. 3 > 2 та його треба поміняти місцями. Оскільки ми використовуємо цикл у циклі, виходить, що складність нашого алгоритму збільшується. За n елементів вона стає n * n, тобто O(n^2). Така складність називається квадратичною. Як ми розуміємо, ми можемо точно знати, скільки знадобиться ітерацій. Показник складності алгоритму має на меті показати тенденцію зростання складності, найгірший випадок. Наскільки сильно збільшуватиметься час роботи при зміні кількості елементів n. Сортування бульбашкою - одне з найпростіших і неефективних сортувань. Її ще іноді називають "дурним сортуванням". Матеріал на тему:
Сортування вибором (Selection Sort)
Інше сортування – сортування вибором. Вона також має квадратичну складність, але про це трохи згодом. Отже, ідея проста. Кожен прохід вибиратиме найменший елемент і зміщувати його на початок. При цьому кожен новий прохід починати зрушуючи праворуч, тобто перший прохід - з першого елемента, другий прохід - з другого. Виглядати це буде так: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));
Ця сортування нестійка, т.к. однакові елементи (з погляду тієї характеристики, якою ми сортуємо елементи) можуть змінити своє становище. Хороший приклад наведено у статті на Вікіпедії: Сортування_вибором . Матеріал на тему:
Сортування вставками (Insertion Sort)
Сортування вставками також має квадратичну складність, тому що у нас знову цикл у циклі. У чому на відміну від сортування вибором? Дане сортування є "стійким". Це означає, що однакові елементи змінять свій порядок. Поодинокі з погляду характеристики, за якою ми сортуємо.int[] array = {10, 2, 10, 3, 1, 2, 5};
System.out.println(Arrays.toString(array));
for (int left = 0; left < array.length; left++) {
// Витягуємо значення елемента
int value = array[left];
// Переміщаємося елементами, які перед витягнутим елементом
int i = left - 1;
for (; i >= 0; i--) {
// Якщо витягли менше значення — пересуваємо більший елемент далі
if (value < array[i]) {
array[i + 1] = array[i];
} else {
// Якщо витягнутий елемент більше - зупиняємось
break;
}
}
// У місце, що звільнилося, вставляємо витягнуте значення
array[i + 1] = value;
}
System.out.println(Arrays.toString(array));
Матеріал на тему:
Човникове сортування (Shuttle Sort)
Серед простих сортувань є ще одне – човникове сортування. Але мені більше подобається шатл сорт. Мені здається, ми рідко говоримо про космічні човники, а човниковий у нас скоріше біг. Тому простіше уявити, як у космос запускаються шатли. Ось вам асоціація із цим алгоритмом. У чому полягає суть алгоритму? Суть алгоритму в тому, що ми ітеруємося ліворуч, при цьому при виконанні swap елементів ми виконуємо перевірку всіх інших елементів, які залишабося позаду, чи не потрібно повторити swap.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));
// Вираховуємо проміжок між елементами, що перевіряються.
int gap = array.length / 2;
// Поки що різниця між елементами є
while (gap >= 1) {
for (int right = 0; right < array.length; right++) {
// Зміщуємо правий покажчик, доки зможемо знайти такий, що
// між ним та елементом до нього не буде потрібного проміжку
for (int c = right - gap; c >= 0; c -= gap) {
if (array[c] > array[c + gap]) {
swap(array, c, c + gap);
}
}
}
// Перераховуємо розрив
gap = gap / 2;
}
System.out.println(Arrays.toString(array));
Матеріал на тему:
Сортування злиттям (merge sort)
Крім зазначених простих сортувань є сортування і складніше. Наприклад, сортування злиттям. По-перше, нам допоможе прийде рекурсія. По-друге, складність у нас буде вже не квадратична, як ми з вами звикли. Складність даного алгоритму – логарифмічна. Записується як O(n log n). Отже, зробимо це. Спочатку напишемо рекурсивний виклик методу сортування:public static void mergeSort(int[] source, int left, int right) {
// Виберемо роздільник, тобто. розділимо навпіл вхідний масив
int delimiter = left + ((right - left) / 2) + 1;
// Виконаємо рекурсивно цю функцію для двох половинок (якщо зможемо розбити (
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) {
// Виберемо роздільник, тобто. розділимо навпіл вхідний масив
int delimiter = left + ((right - left) / 2) + 1;
// Виконаємо рекурсивно цю функцію для двох половинок (якщо зможемо розбити (
if (delimiter > 0 && right > (left + 1)) {
mergeSort(source, left, delimiter - 1);
mergeSort(source, delimiter, right);
}
// Створюємо тимчасовий масив із потрібним розміром
int[] buffer = new int[right - left + 1];
// Починаючи від зазначеної лівої межі йдемо по кожному елементу
int cursor = left;
for (int i = 0; i < buffer.length; i++) {
// Ми використовуємо delimeter щоб вказувати на елемент з правої частини
// Якщо delimeter > right, отже, у правій частині не залишилося недоданих елементів
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)
. Як видно, суть зводиться до того, що ми приймаємо на вхід масив із зазначенням початку та кінця ділянки для сортування. На початку сортування - це початок і кінець масиву. Далі ми обчислюємо delimiter - становище дільника. Якщо дільник може розділити на 2 частини, значить рекурсивно викликаємо сортування для ділянок, на які дільник розбив масив. Готуємо додатковий буферний масив, у якому виділяємо відсортовану ділянку. Далі встановлюємо курсор на початок ділянки, що сортується, і починаємо йти по кожному елементу порожнього масиву, який ми підготували, і заповнюємо його найменшими елементами. Якщо елемент, який вказує курсор менше, ніж елемент, який вказує дільник — поміщаємо в буферний масив цей елемент і зсуваємо курсор. В іншому випадку поміщаємо в буферний масив елемент, на який вказує роздільник та зрушуємо роздільник. Як тільки роздільник піде за межі ділянки, що сортується, або ми заповнимо весь масив, зазначений діапазон вважається відсортованим. Матеріал на тему:
Сортування підрахунком (Counting Sort) та Порозрядне сортування (Radix Sort)
Іншим цікавим алгоритмом сортування є сортування підрахунком (Counting Sort). Алгоритмічна складність у разі буде O(n+k), де n — кількість елементів, а k — максимальне значення елемента. Є з алгоритмом одна невдача: нам потрібно знати мінімальне та максимальне значення у масиві. Ось приклад реалізації сортування підрахунком:public static int[] countingSort(int[] theArray, int maxValue) {
// Масив із "лічильниками" розміром від 0 до максимального значення
int numCounts[] = new int[maxValue + 1];
// У відповідному осередку (індекс = значення) збільшуємо лічильник
for (int num : theArray) {
numCounts[num]++;
}
// Підготовляємо масив для відсортованого результату
int[] sortedArray = new int[theArray.length];
int currentSortedIndex = 0;
// йдемо масивом з "лічильниками"
for (int n = 0; n < numCounts.length; n++) {
int count = numCounts[n];
// йдемо за кількістю значень
for (int k = 0; k < count; k++) {
sortedArray[currentSortedIndex] = n;
currentSortedIndex++;
}
}
return sortedArray;
}
Як ми розуміємо, дуже незручно, коли ми повинні знати наперед мінімальне та максимальне значення. І тут є інший алгоритм – Radix Sort. Я тут наведу алгоритм лише візуально. Реалізацію див. у матеріалах:
Швидке сортування Java (Quick Sort)
Ну і на солодке — один із найвідоміших алгоритмів: швидке сортування. Вона має алгоритмічну складність, тобто маємо O(n log n). Це сортування ще називають сортуванням Хоара. Цікаво, що алгоритм придумали Хоар під час його перебування в Радянському Союзі, де він навчався в Московському університеті комп'ютерного перекладу і займався розробкою російсько-англійського розмовника. А ще даний алгоритм у більш складній реалізації використовується в Arrays.sort Java. А що із 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 {
// Рухаємо лівий маркер зліва направо поки що елемент менше, ніж pivot
while (source[leftMarker] < pivot) {
leftMarker++;
}
// Рухаємо правий маркер, поки елемент більше ніж pivot
while (source[rightMarker] > pivot) {
rightMarker--;
}
// Перевіримо, чи не потрібно обміняти місцями елементи, на які вказують маркери
if (leftMarker <= rightMarker) {
// Лівий маркер буде менше правого, тільки якщо ми повинні виконати swap
if (leftMarker < rightMarker) {
int tmp = source[leftMarker];
source[leftMarker] = source[rightMarker];
source[rightMarker] = tmp;
}
// Зрушуємо маркери, щоб отримати нові кордони
leftMarker++;
rightMarker--;
}
} while (leftMarker <= rightMarker);
// Виконуємо рекурсивно для елементів
if (leftMarker < rightBorder) {
quickSort(source, leftMarker, rightBorder);
}
if (leftBorder < rightMarker) {
quickSort(source, leftBorder, rightMarker);
}
}
Тут все дуже страшно, тож розбиратимемося. Для вхідного масиву int[]
source виставляємо два маркери, лівий (L) та правий (R). При першому виклику вони відповідають початку та кінцю масиву. Далі визначається опорний елемент, він pivot
. Після цього наше завдання — перемістити значення, менші ніж pivot
, на ліву pivot
частину, а більші — на праву. Для цього спочатку рухаємо покажчик L
, доки не знайдемо значення, більше ніж pivot
. Якщо менше значення не знайшли, тоpivot
. Потім рухаємо покажчик доки
R
знайдемо менше, ніж
pivot
значення. Якщо меншого значення не знайшли, то
R
збігається з
pivot
. Далі, якщо вказівник
L
знаходиться до покажчика
R
або збігається з ним, то намагаємося виконати обмін елементів, якщо елемент
L
менше, ніж
R
. Далі
L
зрушуємо праворуч на 1 позицію,
R
зрушуємо ліворуч на одну позицію. Коли лівий маркер
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 менше, ніж
pivot 6
виконуємо
swap
, тобто. обмін. Запишемо результат: 4 2 3 7 6 обводимо 6 кругом, т.к. він як і раніше
pivot
. Покажчик
L
на цифрі 3, покажчик
R
на цифрі 6. Ми пам'ятаємо, що рухаємо покажчики доти, доки
L
не зайдемо за
R
.
L
рухаємо на наступну цифру. Тут хочеться розібрати два варіанти: якби передостання цифра була 7 і якби вона була не 7, а 1.
Передостання цифра 1: Зрушабо покажчик
L
цифру 1, т.к. ми можемо рухати
L
доти, доки покажчик
L
вказує на цифру, меншу ніж
pivot
. А от
R
ми можемо зрушити з 6, т.к. R ми не можемо рухати тільки якщо вказівник
R
вказує на цифру, яка більше ніж
pivot
.
swap
не робимо, т.к. 1 менше 6. Записуємо положення: 4 2 3 1 6, обводимо
pivot 6
.
L
зрушується
pivot
і більше не рухається.
R
також не рухається. Обмін не робимо. Зсуваємо
L
і
R
одну позицію і підписуємо цифру 1 маркером
R
, а
L
виходить поза числом. Т.к.
L
поза числом — нічого не робимо, а ось частину 4 2 3 1 виписуємо знову, т.к. це наша ліва частина, менша, ніж
pivot 6
. Виділяємо новий
pivot
і починаємо знову )
Передостання цифра 7: Зрушабо вказати
L
на цифру 7, правий покажчик можемо рухати, т.к. він уже вказує на pivot. т.к. 7 більше, ніж
pivot
, то робимо
swap
. Запишемо результат: 4 2 3 6 7, обводимо 6 кружком, т.к. він
pivot
. Покажчик
L
тепер зсувається на цифру 7, а покажчик
R
зсувається на цифру 3. Частина від
L
кінця немає сенсу сортувати, т.к. там лише 1 елемент, а ось частину від 4 до покажчика
R
відправляємо на сортування. Вибираємо
pivot
і починаємо все знову ) Може на перший погляд здатися, що якщо розставити багато однакових з
pivot
значень це зламає алгоритм, але це не так. Можна вигадувати каверзних варіантів і на папірці переконатися, що все правильно і здивуватися, як такі прості дії надають такий надійний механізм. Єдиний мінус — таке сортування не є стабільним. Т.к. при виконанні обміну однакові елементи можуть змінити свій порядок, якщо один з них зустрівся до того
pivot
, як інший елемент потрапив у частину
pivot
за допомогою обміну. Матеріал:
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ