Counting sort и radix sort
Накратко: Counting sort подрежда цели числа от малък диапазон без нито едно сравнение, като брои колко пъти се среща всяка стойност: O(n + k); radix sort го прилага цифра по цифра.
Проблем
Милион поръчки, сто зони
Куриерската фирма получава за деня един милион поръчки. Всяка има зона за доставка: число от 0 до 99. Преди колите да тръгнат, поръчките се подреждат по зона, за да може всяка кола да вземе своя куп.
Merge sort прави около 18,7 милиона сравнения за милион поръчки, и heap sort не е по-добър (виж heap sort). Те сравняват поръчка с поръчка, защото не знаят нищо за ключа.
А тук знаеш много: различните зони са само сто. Същото е с оценките на продукт: от 1 до 5. Сравняването на милион поръчки, за да разбереш, че 37 е преди 38, е работа, която може да не се прави изобщо.
Идеята
Преброй, вместо да сравняваш
Ключът е малко цяло число. Значи може да е индекс в масив. Подреждането става на три минавания:
- Броене. По един брояч за всяка зона. Всяка поръчка добавя едно в брояча на своята зона.
- Начало на всяка зона. Зона 0 почва от място 0. Зона 1 почва след всички поръчки на зона 0. Всяко начало е предишното плюс броя на предишната зона. Това се казва префиксна сума.
- Поставяне. Минаваш по поръчките отляво надясно. Всяка отива на началото на своята зона, а началото се мести с едно.
Това е counting sort. Нито една поръчка не се сравнява с друга.
Минаването отляво надясно има важна последица: поръчките от една зона остават в реда, в който са дошли. Ако бяха подредени по час, в своята зона пак са по час. Такова подреждане се казва стабилно. В урока за стабилността ще видиш защо това е толкова важно.
Виж го
Осем поръчки, четири зони
Броят на зоните е параметър. За милиона поръчки е 100, тук е 4, за да се вижда всичко:
function byZone(orders, zones) { const count = new Array(zones).fill(0); for (const o of orders) { count[o.zone]++; } const start = new Array(zones).fill(0); for (let z = 1; z < zones; z++) { start[z] = start[z - 1] + count[z - 1]; } const out = new Array(orders.length); for (const o of orders) { out[start[o.zone]++] = o; } return out;}const orders = [ { id: 101, zone: 2 }, { id: 102, zone: 0 }, { id: 103, zone: 3 }, { id: 104, zone: 2 }, { id: 105, zone: 1 }, { id: 106, zone: 0 }, { id: 107, zone: 2 }, { id: 108, zone: 3 },];for (const o of byZone(orders, 4)) { console.log(`зона ${o.zone}, поръчка ${o.id}`);}зона 0, поръчка 102зона 0, поръчка 106зона 1, поръчка 105зона 2, поръчка 101зона 2, поръчка 104зона 2, поръчка 107зона 3, поръчка 103зона 3, поръчка 108function byZone(orders, zones) { const count = new Array(zones).fill(0); for (const o of orders) { count[o.zone]++; } const start = new Array(zones).fill(0); for (let z = 1; z < zones; z++) { start[z] = start[z - 1] + count[z - 1]; } const out = new Array(orders.length); for (const o of orders) { out[start[o.zone]++] = o; } return out;}Стъпка по стъпка, с думи
- Осем поръчки, всяка със зона за доставка от 0 до 3. По един брояч за зона, всички на 0.
- Броене: всяка поръчка добавя едно в брояча на своята зона. Поръчка с поръчка не се сравнява.
- Откъде почва всяка зона: зона 0 от място 0, зона 1 след двете поръчки на зона 0, от място 2; зона 2 от 3, зона 3 от 6. Всяко начало е предишното плюс броя на предишната зона.
- Поставяне отляво надясно: 101 отива на началото на зона 2, място 3, а началото на зона 2 се мести с едно.
- Подредено по зона за 2n + k стъпки. В зона 2 поръчките са 101, 104, 107: в реда, в който са дошли. Това е стабилно подреждане.
Поръчки 101, 104 и 107 са в зона 2. Дойдоха в този ред и излязоха в този ред.
start[...]++ поставя на сегашното начало и после го мести с едно: ++ след израза връща старата стойност.Пробвай
Попълни
Началото на зона z е началото на предишната зона плюс:
start[z] = start[z - 1] + ;Зона z почва там, където свършва зона z - 1: нейното начало плюс колко поръчки има в нея. Затова е count[z - 1], а не count[z].
Идеята
Колко струва: O(n + k)
Всяка поръчка се преброява веднъж и се поставя веднъж: 2n стъпки. Началата са по едно на зона: k стъпки. Общо O(n + k), където k е броят на възможните ключове. Памет: масивът с броячите (k) и масивът с резултата (n).
Тук сравнения няма, затова Bench брои всяка стъпка на counting sort: всяко преброяване, всеки брояч и всяко поставяне. Merge sort брои сравненията си:
- Counting sort (зони 0–99)
O(n) - Подреждане (merge sort)
O(n log n)
| Алгоритъм | n = 10 | n = 1000 | n = 100 000 | n = 1 000 000 |
|---|---|---|---|---|
| Counting sort (зони 0–99) | 120 | 2100 | 200 100 | 2 000 100 |
| Подреждане (merge sort) | 21 | 8709 | 1 536 371 | 18 674 993 |
За милион поръчки counting sort прави 2 000 100 стъпки, а merge sort 18 674 993 сравнения. Правата линия на counting sort е O(n): двойно повече поръчки, двойно повече работа.
По-точно, за любопитните
Може ли подреждане със сравнения да е по-бързо от n log n? Не. При n елемента има n! възможни подреждания, а всяко сравнение има два отговора. За да различиш n! случая, трябват поне log₂(n!) сравнения, а това е около n log₂ n. Counting sort заобикаля тази граница, защото не сравнява: той ползва ключа като адрес.
Виж го
Radix sort: цифра по цифра
А ако ключът е по-голям? Пощенските кодове са четирицифрени, от 1000 до 9999: 9000 брояча за всеки ден. Номерата на поръчки са шестцифрени: 900 000 брояча.
Radix sort подрежда цифра по цифра със същото броене, но само с 10 брояча. Първо по последната цифра, после по предпоследната и така до първата (LSD, от най-малката цифра). Всяко минаване е counting sort по една цифра:
function byDigit(codes, exp) { const count = new Array(10).fill(0); for (const c of codes) { count[Math.floor(c / exp) % 10]++; } const start = new Array(10).fill(0); for (let d = 1; d < 10; d++) { start[d] = start[d - 1] + count[d - 1]; } const out = new Array(codes.length); for (const c of codes) { out[start[Math.floor(c / exp) % 10]++] = c; } return out;}let codes = [4003, 1504, 9000, 1000, 8001, 4000, 1404];for (let exp = 1; exp <= 1000; exp *= 10) { codes = byDigit(codes, exp); console.log(`[${codes.join(", ")}]`);}[9000, 1000, 4000, 8001, 4003, 1504, 1404][9000, 1000, 4000, 8001, 4003, 1504, 1404][9000, 1000, 4000, 8001, 4003, 1404, 1504][1000, 1404, 1504, 4000, 4003, 8001, 9000]След първото минаване кодовете са подредени по последната цифра. След второто по последните две. След четвъртото по всичките четири.
Това работи само защото всяко минаване е стабилно. Минаването по стотиците не разбърква кодовете с еднакви стотици, а те вече са подредени по последните две цифри. 1000 и 1404 и 1504 имат еднаква първа цифра: последното минаване ги оставя в реда, който са получили от предишните.
Math.floor(c / exp) % 10 е цифрата: / в JavaScript дава дробно число, затова Math.floor.Цена: d минавания по n + 10 стъпки, където d е броят на цифрите. За шестцифрени номера това е 6 · (2n + 10):
- Radix sort (6 цифри)
O(n) - Counting sort (зони 0–99)
O(n) - Подреждане (merge sort)
O(n log n)
| Алгоритъм | n = 10 | n = 1000 | n = 100 000 | n = 1 000 000 |
|---|---|---|---|---|
| Radix sort (6 цифри) | 180 | 12 060 | 1 200 060 | 12 000 060 |
| Counting sort (зони 0–99) | 120 | 2100 | 200 100 | 2 000 100 |
| Подреждане (merge sort) | 21 | 8709 | 1 536 371 | 18 674 993 |
Пробвай
Предвиди изхода
Едно минаване по последната цифра (exp е 1). Какво печата програмата?
const codes = [31, 12, 22, 11];console.log(`[${byDigit(codes, 1).join(", ")}]`);Пробвай
Намери грешката
Някой поставя поръчките отзад напред. Зоните излизат подредени, но вътре в зона 2 нещо не е както трябва:
function byZone(orders, zones) { const count = new Array(zones).fill(0); for (const o of orders) { count[o.zone]++; } const start = new Array(zones).fill(0); for (let z = 1; z < zones; z++) { start[z] = start[z - 1] + count[z - 1]; } const out = new Array(orders.length); for (let i = orders.length - 1; i >= 0; i--) { out[start[orders[i].zone]++] = orders[i]; } return out;}const orders = [ { id: 101, zone: 2 }, { id: 102, zone: 0 }, { id: 103, zone: 3 }, { id: 104, zone: 2 }, { id: 105, zone: 1 }, { id: 106, zone: 0 }, { id: 107, zone: 2 }, { id: 108, zone: 3 },];for (const o of byZone(orders, 4)) { console.log(`зона ${o.zone}, поръчка ${o.id}`);}зона 0, поръчка 106зона 0, поръчка 102зона 1, поръчка 105зона 2, поръчка 107зона 2, поръчка 104зона 2, поръчка 101зона 3, поръчка 108зона 3, поръчка 103На кой ред е грешката?
Избери реда с грешката.
Поставянето тръгва от края, а началата растат напред. Последната поръчка от зона 2 заема първото място на зоната, и редът в зоната се обръща: 107, 104, 101. Подреждането вече не е стабилно, а radix sort с такова минаване дава грешен резултат. Минавай отляво надясно. (Има и вариант отзад напред, но тогава се ползват краищата на зоните и те намаляват.)
Идеята
Кога не работи
- k е огромно. Цена в евроцентове до 100 000 €: 10 милиона брояча, за да подредиш хиляда поръчки. Merge sort ще свърши с около 10 000 сравнения. Counting sort си заслужава, когато k е колкото n или по-малко.
- Ключът не е цяло число. Имена, адреси, дробни цени: те не стават индекс в масив. Radix sort може да подрежда низове знак по знак, но само ако всички са с еднаква дължина или се допълнят.
- Отрицателни ключове. Стават, ако извадиш най-малкия: ключ − min е от 0 нагоре.
Затова вграденият sort на никой от четирите езика не е counting sort. Той трябва да подрежда каквото и да е, а единственото, което знае за елементите, е как да ги сравни.
По-точно, за любопитните
Как е в другите езици: в Java Arrays.sort за масив от int е dual-pivot quick sort, в C# Array.Sort е introsort, в Python sort и в JavaScript sort са варианти на merge sort (TimSort). Всички сравняват. Counting sort и radix sort се пишат на ръка, когато знаеш, че ключът е малък: зони, оценки, години, кодове. Как подреждат вградените, е в урока за стабилността.
Упражнение
Отзивите по оценка
Реши задачата в редактора си.
Оценките от 1 до 5. Дванайсет отзива за продукт са с оценки 5, 3, 4, 5, 1, 2, 5, 4, 4, 3, 5, 2. Преброй колко има от всяка оценка с масив от броячи, отпечатай броевете и после оценките подредени, без нито едно сравнение между две оценки.
1: 12: 23: 24: 35: 4Подредени: [1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 5, 5]Още задачи
- Отзивите, не само оценките. Всеки отзив има автор и оценка. Подреди отзивите по оценка с
byZoneи провери, че отзивите с еднаква оценка остават в реда, в който са писани. - Шестцифрени номера. Подреди номерата на поръчки
482113, 105990, 482100, 300001, 105911с radix sort. Колко минавания трябват? - Отрицателни температури. Подреди температурите
-3, 5, 0, -8, 12, 5с counting sort. Какво вадиш от всеки ключ?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Времева сложност: броене на стъпки · Big-O: как расте работата · Merge sort и „разделяй и владей“ · Heap и heap sort
- Продължава в: Стабилно сортиране и вграденият sort
- Задачи по темата: Сортиране