Merge sort и „разделяй и владей“
Накратко: Merge sort дели списъка на половини до единични елементи и ги слива подредени: O(n log n) сравнения при всеки вход, срещу O(n) допълнителна памет.
Проблем
„Подреди по цена“ при 100 000 продукта
Онлайн магазинът е пораснал до 100 000 продукта. Бутонът „Подреди по цена“ ползва insertion sort от простите сортирания.
При 1000 продукта insertion sort прави 256 555 сравнения, и страницата се отваря веднага. При 100 000 сравненията са около 2,5 милиарда. Продуктите са 100 пъти повече, а работата е 10 000 пъти повече: insertion sort е O(n²), защото всяка нова цена може да се сравни с всички преди нея.
Трябва ти начин, при който два пъти повече продукти дават малко повече от два пъти повече работа.
Идеята
Разделяй и владей
Как двама души подреждат купчина от 1000 фактури по сума? Делят я на две, всеки подрежда своята половина, после сливат: гледат горната фактура на двете купчини и взимат по-малката. Сливането на две подредени купчини е бързо: всяко сравнение слага една фактура на мястото ѝ.
А как всеки подрежда своята половина? Същото: дели я на две. И така, докато остане по една фактура. Една фактура вече е подредена.
Това е разделяй и владей (divide and conquer): задачата се дели на по-малки задачи от същия вид, те се решават със същия метод, а отговорите им се сглобяват. Сортирането, построено така, е merge sort (сортиране чрез сливане).
Методът вика сам себе си, затова това е рекурсия, както в урока „Рекурсия“. Дъното е списък с една цена: него няма какво да делиш.
Виж го
Делене до една цена и сливане обратно
mergeSort дели и вика себе си за двете половини. merge слива две подредени части. Гледай как осемте цени слизат до единични и как сливанията ги подреждат на връщане:
function mergeSort(prices) { if (prices.length <= 1) { return prices; } const mid = Math.floor(prices.length / 2); const left = prices.slice(0, mid); const right = prices.slice(mid); return merge(mergeSort(left), mergeSort(right));}
function merge(left, right) { const merged = []; let i = 0; let j = 0; while (i < left.length && j < right.length) { if (left[i] <= right[j]) { merged.push(left[i]); i++; } else { merged.push(right[j]); j++; } } return merged.concat(left.slice(i), right.slice(j));}Стъпка по стъпка, с думи
- mergeSort получава 8 цени от каталога, неподредени.
- mid = 4. slice прави два нови масива: лявата половина [45, 12, 80, 33] и дясната [7, 61, 25, 50].
- Всяка половина вика mergeSort за себе си и се дели пак: 4 → 2 → 1.
- Част с една цена вече е подредена. Това е дъното на рекурсията: оттук извикванията започват да се връщат.
- merge слива две подредени части: сравнява първите им цени и взима по-малката. За двойка това е 1 сравнение, 4 за целия ред.
- Двойките стават четворки. Всяко сравнение пуска една цена надолу, затова на ред сравненията са по-малко от цените: тук 6.
- Последното сливане: [12, 33, 45, 80] с [7, 25, 50, 61]. i и j сочат първата невзета цена от всяка страна: 7 сравнения, 7 цени.
- Едната страна свърши. Остатъкът от другата (80) вече е подреден и отива накрая без сравнение.
- 3 нива деление (8 → 4 → 2 → 1), а на всяко ниво сливането минава всяка цена веднъж: 4 + 6 + 7 = 17 сравнения. При n цени нивата са log₂ n, затова работата е около n · log₂ n.
const prices = [45, 12, 80, 33, 7, 61, 25, 50];console.log(`Преди: [${prices.join(", ")}]`);console.log(`След: [${mergeSort(prices).join(", ")}]`);Преди: [45, 12, 80, 33, 7, 61, 25, 50]След: [7, 12, 25, 33, 45, 50, 61, 80]slice(0, mid) прави нов масив с елементите от 0 до mid (без него). / в JavaScript дава дроб (9 / 2 е 4.5), затова Math.floor закръгля надолу.Идеята
Защо е n · log n
Виж колко струва всяка част:
- Сливането е O(n). Всяко сравнение слага една цена в резултата, а щом едната част свърши, остатъкът от другата отива накрая без сравнения. Две части с общо n цени искат най-много n − 1 сравнения.
- Нивата са log₂ n. Всяко ниво дели на две: 8 цени дават 3 нива (8 → 4 → 2 → 1), 100 000 цени дават 17. log₂ n е „колко пъти n се дели на две до 1“.
- На всяко ниво сливанията минават всички n цени веднъж. Двойките, четворките, половините: на ниво са различни групи, но заедно винаги са n.
Общо: най-много n · log₂ n сравнения. За 100 000 цени това е под 1 700 000. Bench преброява 1 536 292, около 1600 пъти по-малко от 2,5-те милиарда на insertion sort.
Пробвай
Предвиди изхода
Тук merge брои сравненията си. Двете части са подредени, а всяка цена отляво е по-малка от всяка отдясно:
let comparisons = 0;
function merge(left, right) { const merged = []; let i = 0; let j = 0; while (i < left.length && j < right.length) { comparisons++; if (left[i] <= right[j]) { merged.push(left[i]); i++; } else { merged.push(right[j]); j++; } } return merged.concat(left.slice(i), right.slice(j));}
console.log(`[${merge([10, 20, 30], [40, 50, 60]).join(", ")}]`);console.log(`Сравнения: ${comparisons}`);Трите цени отляво излизат с по едно сравнение: 10 срещу 40, 20 срещу 40, 30 срещу 40. После лявата част свършва, и 40, 50 и 60 отиват накрая без сравнения. Сливането на n цени иска между по-късата част (тук 3) и n − 1 (тук 5) сравнения. Никога повече от n.
Виж го
Измери го: merge sort срещу insertion sort
Пусни двата алгоритъма върху един и същ каталог с цени. Броят е на сравненията. Insertion sort при 100 000 не се пуска наистина (страницата щеше да замръзне): Bench го оценява по кривата.
- Insertion sort
O(n²) - Merge sort
O(n log n)
| Алгоритъм | n = 10 | n = 1000 | n = 10 000 | n = 100 000 |
|---|---|---|---|---|
| Insertion sort | 32 | 256 555 | 24 738 716 | ≈ 2 473 871 600 |
| Merge sort | 24 | 8679 | 120 483 | 1 536 292 |
function mergeSort(prices) { if (prices.length <= 1) { return prices; } const mid = Math.floor(prices.length / 2); const left = prices.slice(0, mid); const right = prices.slice(mid); return merge(mergeSort(left), mergeSort(right));}
function merge(left, right) { const merged = []; let i = 0; let j = 0; while (i < left.length && j < right.length) { if (left[i] <= right[j]) { merged.push(left[i]); i++; } else { merged.push(right[j]); j++; } } return merged.concat(left.slice(i), right.slice(j));}При 10 цени двата са почти равни. При 1000 merge sort прави 8679 сравнения срещу 256 555. Оттам нататък разликата расте с всяка нула: кривата на O(n²) е два пъти по-стръмна.
По-точно, за любопитните
Същите числа на твоя език. Каталогът идва от малък генератор със семе, затова е еднакъв при всяко пускане, на всеки език и в Bench. Броячът расте при всяко сравнение на две цени:
let comparisons = 0;
function catalog(n) { const prices = []; let x = 2026; for (let k = 0; k < n; k++) { x = (x * 48271) % 2147483647; prices.push(1 + (x % 10000)); } return prices;}
function insertionSort(prices) { for (let i = 1; i < prices.length; i++) { const key = prices[i]; let j = i; while (j > 0) { comparisons++; if (prices[j - 1] <= key) { break; } prices[j] = prices[j - 1]; j--; } prices[j] = key; }}
function mergeSort(prices) { if (prices.length <= 1) { return prices; } const mid = Math.floor(prices.length / 2); const left = prices.slice(0, mid); const right = prices.slice(mid); return merge(mergeSort(left), mergeSort(right));}
function merge(left, right) { const merged = []; let i = 0; let j = 0; while (i < left.length && j < right.length) { comparisons++; if (left[i] <= right[j]) { merged.push(left[i]); i++; } else { merged.push(right[j]); j++; } } return merged.concat(left.slice(i), right.slice(j));}
for (const n of [10, 1000]) { comparisons = 0; insertionSort(catalog(n)); console.log(`insertion sort, n = ${n}: ${comparisons} сравнения`);}for (const n of [10, 1000, 100000]) { comparisons = 0; mergeSort(catalog(n)); console.log(`merge sort, n = ${n}: ${comparisons} сравнения`);}insertion sort, n = 10: 32 сравненияinsertion sort, n = 1000: 256555 сравненияmerge sort, n = 10: 24 сравненияmerge sort, n = 1000: 8679 сравненияmerge sort, n = 100000: 1536292 сравненияИдеята
Цената: памет
Merge sort не е безплатен. Insertion sort мести цените в самия списък и не иска друго място. Merge sort при всяко делене прави нови масиви, а merge пише резултата в още един. Допълнителната памет е O(n): за 100 000 цени още толкова място.
Стекът от извиквания е плитък: по една рамка на ниво, около log₂ n. За 100 000 цени са 18 рамки.
Еднаквите цени остават в реда, в който са дошли: при равенство merge взима лявата (<=). Това се казва стабилност и има свой урок, „Стабилно сортиране“.
slice винаги прави нов масив. Затова този merge sort е кратък, но копира много. Вграденият sort на V8 е TimSort, вариант на merge sort, и е много по-бърз.Пробвай
Намери грешката
Каталогът от осем продукта влиза, а излизат четири:
const prices = [45, 12, 80, 33, 7, 61, 25, 50];console.log(`Преди: [${prices.join(", ")}]`);console.log(`След: [${mergeSort(prices).join(", ")}]`);Преди: [45, 12, 80, 33, 7, 61, 25, 50]След: [33, 45, 50, 61]На кой ред е грешката?
Избери реда с грешката.
Дясната половина започва от mid + 1, а цената на място mid не отива в нито една половина. При всяко делене се губи по една цена: при 8 цени се губят 4. Компилаторът не казва нищо и програмата не спира. Лявата част е от 0 до mid без mid, затова дясната трябва да започне точно от mid.
Пробвай
Попълни
Цикълът в merge спира, щом едната част свърши. Какво пише на празното място, за да отиде остатъкът от дясната част накрая?
function merge(left, right) {
const merged = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
merged.push(left[i]);
i++;
} else {
merged.push(right[j]);
j++;
}
}
return merged.concat(left.slice(i), right.slice());
}j сочи първата цена отдясно, която още не е взета. Всичко от j нататък е подредено и по-голямо от взетото досега, затова отива накрая без сравнения. Ако вместо това започнеш отначало, част от цените влизат два пъти.
Упражнение
Два склада, един ценоразпис
Реши задачата в редактора си.
Два склада. Складът в София и складът във Варна пращат ценоразписите си, всеки вече подреден. Прочети двата реда и отпечатай един общ подреден ценоразпис. Не сортирай наново: ползвай merge от урока. Работата е O(n), не O(n log n).
12 33 45 807 25 50 61 99Общо: 7 12 25 33 45 50 61 80 99Още задачи
- Преброй. Добави брояч на сравненията в
mergeи подреди осемте цени от сцената. Трябва да излезе 17. - От най-скъпото. Смени един знак, така че списъкът да излиза от най-скъпото към най-евтиното.
- Без рекурсия. Слей съседните единични цени по двойки, после двойките по четворки, после четворките. Това е merge sort отдолу нагоре: същите сравнения, без стек от извиквания.
По-точно
Сливането извън сортирането
Сливането на подредени списъци е по-старо от merge sort: машините за перфокарти сливаха подредени тестета карти още през 30-те години. Днес се среща навсякъде, където данните вече са подредени: в git, в базите данни, при файлове, по-големи от паметта.
По-точно, за любопитните
Git. Git пази съдържанието на всяка папка (tree) подредено по име на файла. Когато сравнява две версии или слива клонове, той върви по двата подредени списъка едновременно, като merge: име, което го има само от едната страна, е добавен или изтрит файл, а еднаквите имена са файл и от двете страни, който се сравнява. Така проверката на папка е O(n), а не търсене на всеки файл в другия списък. Сливането на текста вътре във файла е друг алгоритъм (diff), до него ще стигнем в модула за динамично програмиране.
Външно сортиране. Логовете на сайта за година са 50 GB, а паметта е 8 GB. Сортирането чете по 1 GB, подрежда го в паметта и го записва като подредено парче на диска. После слива парчетата: от всяко чете само първия ред, взима най-малкия и продължава. Паметта стига за по един ред от всяко парче. Така подрежда големи файлове командата sort в Linux, а PostgreSQL пише в плана на заявката Sort Method: external merge, когато сортирането не се събира в паметта ѝ.
Мастър теоремата, като интуиция. Времето на merge sort се записва T(n) = 2 · T(n/2) + n: две задачи на половина плюс n за сливането. Нарисувай го като дърво: горе е n работа, на следващото ниво две по n/2, тоест пак n, и така log₂ n нива. Общо n · log₂ n. Мастър теоремата обобщава точно това сравнение: ако работата на ниво намалява надолу, горното ниво решава; ако расте, листата решават; ако е еднаква на всяко ниво, умножаваш по броя нива. Двоичното търсене е T(n) = T(n/2) + 1: по 1 на ниво, log₂ n нива, O(log n).
Вграденото сортиране. В JavaScript Array.prototype.sort на V8 (Chrome и Node) е TimSort, вариант на merge sort, който намира вече подредените парчета във входа. Как и защо, е в урока „Стабилно сортиране“.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Big-O: как расте работата · Най-добър, среден и най-лош случай · Сложност по памет · Insertion sort и selection sort
- Продължава в: Quick sort и лошият пивот · Heap и heap sort · Counting sort и radix sort · Стабилно сортиране и вграденият sort
- Задачи по темата: Сортиране