Ez a cikk a Soroló algoritmusok sorozat része: Végső útmutató” című sorozatban és…
- leírja a Selection Sort működését,
- megtaláljuk a Selection Sort Java forráskódját,
- megmutatja, hogyan lehet levezetni az időbonyolultságát (bonyolult matematika nélkül)
- és ellenőrzi, hogy a Java implementáció teljesítménye megfelel-e az elvárt futásidejű viselkedésnek.
A teljes cikksorozat forráskódja megtalálható a GitHub tárolómban.
- Példa: Playing Cards Sorting
- Különbség a beillesztéses rendezéshez
- Selection Sort algoritmus
- 1. lépés
- 2. lépés
- 3. lépés
- 4. lépés
- 5. lépés
- 6. lépés
- Algoritmus befejezve
- Selection Sort Java forráskód
- Selection Sort Time Complexity
- A Java Selection Sort példa futási ideje
- A legrosszabb eset futási idejének elemzése
- A legkisebb elem keresésének futási idejének elemzése
- A Selection Sort egyéb jellemzői
- A Selection Sort térbonyolultsága
- A kiválasztási rendezés stabilitása
- A Selection Sort stabil változata
- A kiválasztási rendezés párhuzamosíthatósága
- Selection Sort vs. Insertion Sort
- Összefoglaló
Példa: Playing Cards Sorting
A játékkártyák kézbe rendezése az Insertion Sort klasszikus példája.
A Selection Sort is szemléltethető játékkártyákkal. Nem ismerek senkit, aki így válogatja a kártyáit, de példaként elég jól működik 😉
Először is, az összes kártyát képpel felfelé az asztalra rakjuk magunk elé. Megkeresed a legkisebb lapot, és a kezed bal oldalára veszed. Aztán megkeresed a következő nagyobb lapot, és a legkisebbtől jobbra teszed, és így tovább, míg végül a legnagyobb lapot veszed fel a jobb szélsőjobbra.
Különbség a beillesztéses rendezéshez
A beillesztéses rendezésnél a következő, rendezetlen lapot vettük, és a rendezett lapok között a megfelelő helyre illesztettük.
A kiválasztásos rendezés valahogy fordítva működik: Kiválasztjuk a rendezetlen lapok közül a legkisebb lapot, majd – egymás után – beillesztjük a már rendezett lapok közé.
Selection Sort algoritmus
Az algoritmus legegyszerűbben egy példával magyarázható. A következő lépésekben bemutatom, hogyan rendezzük a tömböt Selection Sort algoritmussal:
1. lépés
A tömböt egy bal oldali, rendezett és egy jobb oldali, rendezetlen részre osztjuk. A rendezett rész kezdetben üres:
2. lépés
A jobb oldali, rendezetlen részben megkeressük a legkisebb elemet. Ehhez először megjegyezzük az első elemet, ami a 6. Átmegyünk a következő mezőbe, ahol találunk egy még kisebb elemet a 2. Végigmegyünk a tömb többi részén, még kisebb elemet keresve. Mivel ilyet nem találunk, maradunk a 2-nél, amit az első helyen lévő elemmel való felcseréléssel a megfelelő helyre teszünk. Ezután a tömbrészek határát egy mezővel jobbra toljuk:
3. lépés
Újra a jobb oldali, rendezetlen részben keressük a legkisebb elemet. Ezúttal ez a 3-as; kicseréljük a második helyen lévő elemmel:
4. lépés
Újra megkeressük a legkisebb elemet a jobb oldali részben. Ez a 4-es, ami már a megfelelő helyen van. Ebben a lépésben tehát nincs szükség cserélgetési műveletre, és csak a szakaszhatárt mozgatjuk:
5. lépés
A legkisebb elemként a 6-ost találjuk. Ezt felcseréljük a jobb oldali rész elején lévő elemmel, a 9-essel:
6. lépés
A fennmaradó két elem közül a 7-es a legkisebb. Ezt kicseréljük a 9-essel:
Algoritmus befejezve
Az utolsó elem automatikusan a legnagyobb, tehát a megfelelő helyen van. Az algoritmus befejeződött, és az elemek rendezve vannak:
Selection Sort Java forráskód
Ez a rész a Selection Sort egyszerű Java implementációját tartalmazza.
A külső ciklus végig iterálja a rendezni kívánt elemeket, és az utolsó előtti elem után ér véget. Ha ez az elem rendezve van, akkor az utolsó elem is automatikusan rendezésre kerül. A i
ciklusváltozó mindig a jobb oldali, rendezetlen rész első elemére mutat.
Minden ciklusban a jobb oldali rész első elemét kezdetben a legkisebb elemnek min
feltételezzük; pozícióját a minPos
-ben tároljuk.
A belső ciklus ezután a jobb oldali rész második elemétől annak végéig iterál, és újra hozzárendeli a min
és minPos
pozíciókat, amikor még kisebb elemet talál.
A belső ciklus befejezése után a i
(a jobb oldali rész eleje) és minPos
pozíciók elemeit felcseréljük (hacsak nem ugyanaz az elem).
public class SelectionSort { public static void sort(int elements) { int length = elements.length; for (int i = 0; i < length - 1; i++) { // Search the smallest element in the remaining array int minPos = i; int min = elements; for (int j = i + 1; j < length; j++) { if (elements < min) { minPos = j; min = elements; } } // Swap min with element at pos i if (minPos != i) { elements = elements; elements = min; } } }}
A bemutatott kód abban különbözik a GitHub adattárban található SelectionSort osztálytól, hogy a SortAlgorithm interfészt valósítja meg, hogy könnyen felcserélhető legyen a tesztelési keretrendszerben.
Selection Sort Time Complexity
Nel jelöljük az elemek számát, példánkban n = 6.
A két egymásba ágyazott ciklus jelzi, hogy O(n²) időbonyolultsággal* van dolgunk. Ez akkor lesz így, ha mindkét ciklus olyan értékig iterál, amely lineárisan nő n-nel.
A külső ciklus esetében nyilvánvalóan ez a helyzet: n-1-ig számol.
Mi a helyzet a belső hurokkal?
Nézzük a következő ábrát:
Minden lépésben az összehasonlítások száma eggyel kevesebb, mint a rendezetlen elemek száma. Összesen 15 összehasonlítás történik – függetlenül attól, hogy a tömb eredetileg rendezett-e vagy sem.
Ez a következőképpen is kiszámítható:
Hat elem szorozva öt lépéssel; osztva kettővel, mivel az összes lépés átlagában az elemek fele még rendezetlen:
6 × 5 × ½ = 30 × ½ = 15
Ha 6-ot n-nel helyettesítjük, akkor
n × (n – 1) × ½
Ha megszorozzuk, akkor ez:
½ n² – ½ n
Az n legnagyobb hatványa ebben a kifejezésben n². A legkisebb elem keresésének időbonyolultsága tehát O(n²) – más néven “kvadratikus idő”.
Nézzük most az elemek felcserélését. Minden lépésben (az utolsó kivételével) vagy egy elemet cserélünk, vagy egyet sem, attól függően, hogy a legkisebb elem már a megfelelő helyen van-e vagy sem. Így összességében maximum n-1 cserélgetési műveletünk van, azaz az időbonyolultság O(n) – más néven “lineáris idő”.
A teljes bonyolultság szempontjából csak a legnagyobb bonyolultsági osztály számít, ezért:
A Selection Sort átlagos, legjobb és legrosszabb esetre vonatkozó időbonyolultsága: O(n²)
* Az “időbonyolultság” és az “O-notáció” kifejezéseket ebben a cikkben példák és ábrák segítségével magyarázzuk.
A Java Selection Sort példa futási ideje
Elég az elméletből! Írtam egy tesztprogramot, amely a Selection Sort (és az ebben a sorozatban tárgyalt összes többi rendezési algoritmus) futási idejét a következőképpen méri:
- A rendezni kívánt elemek száma minden egyes iteráció után megduplázódik a kezdeti 1024 elemről 536 870 912 (= 229) elemre. Ekkora méretű tömböt nem lehet létrehozni Javában.
- Ha egy teszt 20 másodpercnél tovább tart, a tömböt nem bővítjük tovább.
- Minden tesztet rendezetlen, valamint emelkedő és csökkenő előre rendezett elemekkel futtatunk.
- A HotSpot fordítónak megengedjük, hogy két bemelegítő körrel optimalizálja a kódot. Ezt követően a teszteket a folyamat megszakításáig ismételjük.
Minden egyes iteráció után a program kiírja az összes korábbi mérési eredmény mediánját.
Íme a Selection Sort 50 iteráció után kapott eredménye (az áttekinthetőség kedvéért ez csak egy kivonat, a teljes eredmény itt található):
n | válogatás nélkül | felfelé | lefelé | lefelé |
---|---|---|---|---|
… | … | … | … | |
16.384 | 27,9 ms | 26,8 ms | 65,6 ms | |
32.768 | 108,0 ms | 105,4 ms | 265,4 ms | |
65.536 | 434,0 ms | 424,3 ms | 1.052,2 ms | |
131.072 | 1.729,8 ms | 1.714,1 ms | 4.209,9 ms | |
262.144 | 6.913,4 ms | 6.880,2 ms | 16.863,7 ms | |
524.288 | 27.649,8 ms | 27.568,7 ms | 67.537,8 ms |
Itt a mérések még egyszer diagramként (ahol a szinte azonos értékek miatt a “rendezetlen” és a “felmenő” értékeket egy görbeként ábrázoltam):
Jól látható, hogy
- ha az elemek számát megduplázzuk, a futási idő körülbelül megnégyszereződik – függetlenül attól, hogy az elemek előzetesen rendezettek-e vagy sem. Ez megfelel az O(n²) várható időbonyolultságnak.
- A futási idő emelkedő rendezett elemek esetén valamivel jobb, mint rendezetlen elemek esetén. Ennek az az oka, hogy a csereműveletekre, amelyeknek – mint fentebb elemeztük – nincs nagy jelentősége, itt nincs szükség.
- hogy a futási idő csökkenő rendezett elemek esetén lényegesen rosszabb, mint a rendezetlen elemek esetén.
Miért van ez?
A legrosszabb eset futási idejének elemzése
Egyáltalán, a legkisebb elem keresésének mindig ugyanannyi időt kellene igénybe vennie, függetlenül a kiindulási helyzettől. A csereműveleteknek pedig csak a csökkenő sorrendbe rendezett elemek esetében kellene valamivel többnek lennie (a csökkenő sorrendbe rendezett elemek esetében minden elemet cserélni kellene; a rendezetlen elemek esetében szinte minden elemet cserélni kellene).
A GitHub tárolómban található CountOperations programot használva láthatjuk a különböző műveletek számát. Itt vannak az eredmények a rendezetlen elemek és a csökkenő sorrendbe rendezett elemek esetében, egy táblázatban összefoglalva:
n | Összehasonlítások | Swaps rendezetlen |
Swaps csökkenő |
minPos/min válogatás nélkül |
minPos/min csökkenő |
---|---|---|---|---|---|
… | … | … | … | … | … |
512 | 130.816 | 504 | 256 | 2.866 | 66.047 |
1.024 | 523.776 | 1.017 | 512 | 6.439 | 263.167 |
2.048 | 2.096.128 | 2.042 | 1.024 | 14.727 | 1.050.623 |
4.096 | 8.386.560 | 4.084 | 2.048 | 30.758 | 4.198.399 |
8.192 | 33.550.336 | 8.181 | 4.096 | 69.378 | 16.785.407 |
A mért értékekből látható:
- A csökkenő sorrendbe rendezett elemekkel – a várakozásoknak megfelelően – ugyanannyi összehasonlítási műveletünk van, mint a rendezetlen elemekkel – azaz n × (n-1)/2 .
- Válogatás nélküli elemekkel – a feltételezésnek megfelelően – majdnem annyi csere műveletünk van, mint elemekkel: például 4096 válogatás nélküli elemmel 4084 csere műveletünk van. Ezek a számok tesztről tesztre véletlenszerűen változnak.
- Viszont csökkenő sorrendbe rendezett elemekkel csak feleannyi swap-műveletünk van, mint elemünk! Ennek az az oka, hogy a csere során nemcsak a legkisebb elemet tesszük a megfelelő helyre, hanem a megfelelő cserepartnert is.
Nyolc elem esetén például négy csereoperációnk van. Az első négy iterációban egy-egy, az ötödiktől nyolcadikig tartó iterációkban pedig egy sem (ennek ellenére az algoritmus a végéig fut):
A mérésekből kiolvashatjuk továbbá:
- Az, hogy a Selection Sort miért olyan sokkal lassabb a csökkenő sorrendbe rendezett elemekkel, a legkisebb elem keresésekor a helyi változó hozzárendelések számában (
minPos
ésmin
) keresendő. Míg 8192 rendezetlen elem esetén 69378 ilyen hozzárendelésünk van, csökkenő sorrendbe rendezett elemek esetén 16785407 ilyen hozzárendelés van – ez 242-szer annyi!
Miért ez a hatalmas különbség?
A legkisebb elem keresésének futási idejének elemzése
A csökkenő sorrendbe rendezett elemek esetén a nagyságrendet az iménti ábrából lehet levezetni. A legkisebb elem keresése a narancssárga és a narancssárga-kék dobozok háromszögére korlátozódik. A felső narancssárga részen az egyes dobozokban lévő számok egyre kisebbek, a jobb oldali narancssárga-kék részen a számok ismét növekednek.
A hozzárendelési műveletek minden narancssárga dobozban és a narancssárga-kék dobozok közül az elsőben történnek. A hozzárendelési műveletek száma minPos
és min
esetében tehát, képletesen szólva, körülbelül “a négyzet negyede” – matematikailag és pontosan ¼ n² + n – 1.
A rendezetlen elemek esetében sokkal mélyebbre kellene hatolnunk a dologban. Ez nemcsak ennek a cikknek, hanem az egész blognak a kereteit is meghaladná.
Ezért elemzésemet egy kis demóprogramra korlátozom, amely azt méri, hogy hány minPos/min hozzárendelés van, amikor a legkisebb elemet keressük egy rendezetlen tömbben. Íme az átlagos értékek 100 iteráció után (egy kis részlet; a teljes eredmény itt található):
n | átlagos minPos/min hozzárendelések száma |
---|---|
1.024 | 7.08 |
4.096 | 8.61 |
16.385 | 8.94 |
65.536 | 11.81 |
262.144 | 12.22 |
1.048.576 | 14.26 |
4.194.304 | 14.71 |
16.777.216 | 16.44 |
67.108.864 | 17.92 |
268.435.456 | 20.27 |
Itt mint egy diagram logaritmikus x-tengellyel:
A diagram nagyon szépen mutatja, hogy logaritmikus növekedésünk van, ill, az elemek számának minden megduplázódásával a hozzárendelések száma csak egy konstans értékkel nő. Mint mondtam, nem megyek bele mélyebben a matematikai háttérbe.
Ez az oka annak, hogy ezeknek a minPos
/min
hozzárendeléseknek a rendezetlen tömbökben nincs nagy jelentősége.
A Selection Sort egyéb jellemzői
A következő fejezetekben a Selection Sort térkomplexitását, stabilitását és párhuzamosíthatóságát fogom tárgyalni.
A Selection Sort térbonyolultsága
A Selection Sort térbonyolultsága állandó, mivel a i
és j
ciklusváltozókon, valamint a length
, minPos
és min
segédváltozókon kívül nincs szükségünk további memóriahelyre.
Ez azt jelenti, hogy mindegy, hány elemet rendezünk – tíz vagy tízmillió -, mindig csak erre az öt további változóra van szükségünk. Az állandó időt O(1)-ként jegyezzük meg.
A kiválasztási rendezés stabilitása
A kiválasztási rendezés első pillantásra stabilnak tűnik: Ha a rendezetlen rész több azonos kulcsú elemet tartalmaz, akkor először az elsőt kell a rendezett részhez csatolni.
A látszat azonban csalóka. Ugyanis az algoritmus második allépésében két elem felcserélésével előfordulhat, hogy a rendezetlen rész bizonyos elemei már nem az eredeti sorrendben vannak. Ez viszont azt eredményezi, hogy a rendezett részben már nem az eredeti sorrendben jelennek meg.
Egy példa nagyon egyszerűen felépíthető. Tegyük fel, hogy van két különböző, 2. kulcsú és egy 1. kulcsú elemünk, amelyek a következőképpen vannak elrendezve, majd a Selection Sort segítségével rendezzük őket:
Az első lépésben az első és az utolsó elemet felcseréljük. Így a “KETTŐ” elem a “kettő” elem mögé kerül – a két elem sorrendje felcserélődik.
A második lépésben az algoritmus összehasonlítja a két hátsó elemet. Mindkettőnek ugyanaz a kulcsa, 2. Tehát egyetlen elem sem cserélődik.
A harmadik lépésben már csak egy elem marad; ezt automatikusan rendezettnek tekintjük.
A két 2 kulcsú elem tehát a kezdeti sorrendbe cserélődött – az algoritmus instabil.
A Selection Sort stabil változata
A Selection Sort úgy tehető stabilvá, hogy a második lépésben nem cseréljük fel a legkisebb elemet az elsővel, hanem az első és a legkisebb elem közötti összes elemet egy pozícióval jobbra toljuk, és a legkisebb elemet az elejére illesztjük.
Az időbonyolultság ugyan változatlan marad a változtatás miatt, de a további eltolások jelentős teljesítményromláshoz vezetnek, legalábbis ha tömböt rendezünk.
Hivatkozott listával a rendezni kívánt elem kivágása és beillesztése jelentős teljesítményveszteség nélkül elvégezhető lenne.
A kiválasztási rendezés párhuzamosíthatósága
A külső hurkot nem tudjuk párhuzamosítani, mert az minden iterációban megváltoztatja a tömb tartalmát.
A belső ciklus (a legkisebb elem keresése) párhuzamosítható a tömb felosztásával, az egyes altömbökben a legkisebb elem párhuzamos keresésével és a közbenső eredmények összevonásával.
Selection Sort vs. Insertion Sort
Melyik algoritmus a gyorsabb, a Selection Sort vagy az Insertion Sort?
Vetjük össze a Java implementációim méréseit.
A legjobb esetet kihagyom. Insertion Sort esetén a legjobb esetben az időbonyolultság O(n), és 524 288 elemig kevesebb mint egy ezredmásodpercig tartott. Tehát a legjobb esetben az Insertion Sort bármilyen elemszám esetén nagyságrendekkel gyorsabb, mint a Selection Sort.
n | Selection Sort unsorted |
Insertion Sort unsorted |
Selection Sort descending |
Insertion Sort descending |
---|---|---|---|---|
… | … | … | … | … |
16.384 | 27,9 ms | 21,9 ms | 65,6 ms | 43,6 ms |
32.768 | 108,0 ms | 87,9 ms | 265,4 ms | 175,8 ms |
65.536 | 434,0 ms | 350,4 ms | 1.052,2 ms | 697,6 ms |
131.072 | 1.729,8 ms | 1.398,9 ms | 4.209,9 ms | 2.840,0 ms |
262.144 | 6.913,4 ms | 5.706,8 ms | 16.863,7 ms | 11.517,4 ms |
524.288 | 27.649,8 ms | 23.009,7 ms | 67.537,8 ms | 46.309,3 ms |
És még egyszer diagramként:
Az Insertion Sort tehát nemcsak a legjobb esetben, hanem az átlagos és a legrosszabb esetben is gyorsabb a Selection Sortnál.
Az ok az, hogy az Insertion Sort átlagosan feleannyi összehasonlítást igényel. Emlékeztetőül: Insertion Sort esetén átlagosan a rendezett elemek felénél vannak összehasonlításaink és eltolásaink; Selection Sort esetén minden lépésben a legkisebb elemet kell megkeresnünk az összes rendezetlen elem között.
A Selection Sortnak lényegesen kevesebb írási művelete van, ezért a Selection Sort gyorsabb lehet, ha az írási műveletek drágák. Nem ez a helyzet a tömbökbe történő szekvenciális írásoknál, mivel ezek többnyire a CPU gyorsítótárában történnek.
A gyakorlatban ezért a Selection Sortot szinte soha nem használják.
Összefoglaló
A Selection Sort egy könnyen implementálható, és tipikus megvalósításában instabil rendezési algoritmus, amelynek átlagos, legjobb és legrosszabb esetben is O(n²) az időbonyolultsága.
A Selection Sort lassabb, mint az Insertion Sort, ezért a gyakorlatban ritkán használják.
A cikksorozat első részében az összes rendezési algoritmusról és azok jellemzőiről szóló áttekintésben további rendezési algoritmusokat találsz.
Ha tetszett a cikk, oszd meg bátran a cikk végén található megosztás gombok valamelyikével. Szeretne e-mailben értesülni, ha új cikket teszek közzé? Akkor az alábbi űrlap segítségével feliratkozhatsz a hírlevelemre.