Keverjük a kártyát

Az algoritmusok tanítását, a számítógépek programozását nagymértékben motiválja a játékprogramok készítése. A pénzfeldobás, a kockadobás nagyon könnyen szimulálható számítógéppel. A dobás eredményének értékelésével már viszonylag egyszerű játékprogramokat készíthetünk (például kockapóker). Nehezebb a helyzet, ha kártyázni akarunk, mert a kártyacsomag keverése - amire szinte minden kártyajátékban szükség van - némi megfontolást igényel az algoritmus megírása előtt.

A keverés valójában a kártyák egy ismétlés nélküli, véletlen permutációjának kiválasztását jelenti a lehetséges permutációk közül. A keverési algoritmusok tehát lehetővé teszik n darab (vagy kevesebb) különböző véletlenszám sorozatának választását az [1; n] intervallumból. Véletlen permutációkat használnak az úgynevezett véletlenített algoritmusokban, melyek hatékonysága nem függ a bemenő adatoktól (ezért gyakran javul). A véletlen permutációknak a játékprogramokon kívül jelentős szerepük van a számítógépes szimulációban, a kriptográfiában és a különböző hibajavító kódok elméletében. Még a genetikában is találkozunk velük.

A továbbiakban néhány keverési és osztási algoritmust ismertetünk. Az algoritmusokban a kártyák helyett az [1; n] intervallumba eső természetes számok egy véletlen permutációját állítjuk elő. Az n lapból álló csomag kártyái megfeleltethetők ezen természetes számoknak. A lapokat a lap(1..n) tömbben tároljuk. Az algoritmusokat Pascal és Visual Basic Script nyelveken realizáljuk.

A Visual Basic Script programok kirajzolják a megkevert kártyákat is (lásd: A kártyalapok megjelenítése Visual Basic Scriptben). A hta fájlokat mentés után dupla kattintással futtathatjuk. A kártyalapokat tartalmazó mappát a hta fájl mappájába kell elhelyezni. A kirajzolás miatt csak 10 lapból álló kártyacsomaggal dolgozunk, de ez nem érinti az algoritmusok lényegét.

A hta és htm fájlok esetén a keverést az F5 (Frissítés) billentyű lenyomásával ismételhetjük meg.

Felhasznált eljárások és függvények

Az algoritmusokban a következő eljárásokat, illetve függvényeket használjuk fel.
 
Feltölt(tömb, n):
feltölti a tömb elemeit az 1-től n-ig terjedő természetes számokkal.
feltölt(tömb,n)
  ciklus i=1-től n-ig
    tömb(i)=i
  ciklus vége
eljárás vége
Csere(a, b):
felcseréli a két argumentumot.
csere(a,b)
  temp=a
  a=b
  b=temp
eljárás vége
Beszúr(tömb, j):
a tömb első elemét beszúrja a j-edik elem után.
beszúr(tömb,j)
  temp=tömb(1)
  ciklus i=2-től j-ig
    tömb(i-1)=tömb(i)
  ciklus vége
  tömb(j)=temp
eljárás vége
Vszám(i, j):
az [i, j] intervallumból kiválaszt egy véletlen egész számot.
Pascal: random(j-i+1)+i
Visual Basic Script: int(rnd()*(j-i+1)+i)

A program elején ne felejtsük el kiadni a randomize utasítást.

A Pascal programokban a globálisan deklarált tömböt nem adtuk meg az eljárások paraméterei között.

Keverés véletlen cserével

Első algoritmusunk leegyszerűsítve utánozza a kártyák kézzel történő keverését. Fogjuk a legelső kártyát, és betesszük egy véletlenszerűen kiválasztott kártya mögé. Megint fogjuk a legelső kártyát, és betesszük egy véletlenszerűen kiválasztott kártya mögé. Ezt a folyamatot ismételjük, amíg jól megkevert kártyacsomagot nem kapunk.

Véletlen cserés keverés
feltölt(lap,n)
ciklus i=1-től k-ig
  j=vszám(1,n)
  beszúr(lap,j)
ciklus vége

Hány ismétlést kell végeznünk ahhoz, hogy jól megkeverjük a csomagot? A kártyacsomagot akkor tekinthetjük jól megkevertnek, ha minden lap egy véletlenszerűen kiválasztott helyre kerül. Jelöljük a legutolsó lapot U-val. Ez addig marad a helyén, amíg ki nem választjuk a ciklusban, és utána nem teszünk egy másik lapot. 1/n annak a valószínűsége, hogy a ciklusban az utolsó lapot választjuk ki, így átlagosan n lépés után kerül az U lap mögé még egy lap. Ekkor már két hely van mögötte, tehát 2/n valószínűséggel, azaz átlagosan n/2 lépésben jut a következő lap mögéje stb. Végül az U lap jut a csomag tetejére, tehát egyetlen lépés kell még megtenni, hogy szintén véletlenszerű helyre kerüljön. Így a lépések átlagos száma összesen:

k = n + n/2 + n/3 + n/4 + ... + n/(n-1) + 1 = n(1 + 1/2 + 1/3 + ... + 1/(n-1) + 1/n)

A k értéke 32 lap esetén 130, az 52 lapos csomagnál pedig 236. A harmonikus sor összege csak lassan nő, de az eljárás a beszúrással járó sok léptetés miatt rossz hatékonyságú.

Forráskód: Pascal, Visual Basic Script (hta), Visual Basic Script (htm)

Keverés rendezéssel

A rendezési algoritmusok ötletet nyújthatnak hatékonyabb keverések végrehajtásához. A sok véletlen csere helyett válasszunk minden kártyalapnak egy véletlen sorszámot, és a sorszámnak megfelelő helyre tegyük a tömbben! Sajnos az egymástól különböző véletlenszámok sorozatának (permutációjának) kiválasztása az [1; n] intervallumból egyenértékű magának a keverésnek a végrehajtásával, így nem jutunk előbbre. Bizonyítható azonban, hogy ha n elemet választunk véletlenszerűen az [1; n3] intervallumból, akkor 1-1/n valószínűséggel már különböző értékeket kapunk.

A fentiek alapján az algoritmusban a tömbelemekhez választunk egy prioritás-értéket az [1; n3] intervallumból, és a prioritásnak megfelelően rendezzük a tömböt.

Rendezéses keverés
feltölt(lap,n)
ciklus i=1-től n-ig
  prioritás(i)=vszám(1,n3)
ciklus vége
a lap tömb rendezése a prioritások szerint

A keverés hatékonysága a rendezés hatékonyságától függ. A mozgatások számának csökkentése miatt a minimumkiválasztásos rendezést alkalmaztuk. A módszer hátránya, hogy a prioritások tárolása újabb tömböt igényel.

Forráskód: Pascal, Visual Basic Script (hta), Visual Basic Script (htm)

Keverés fordított rendezéssel

A rendezéses keverés hatékonyságát rontja, hogy a prioritások meghatározása után a két tömböt még rendezni kell. Használjuk fel magát a rendezést a keverésre úgy, hogy az elemeknek a "rendezés" során véletlenszerűen választunk helyet! A beillesztéses rendezésnél például kihagyjuk az algoritmusból az i-edik elem helyének keresését. Helyette 1 és i között véletlenszerűen kiválasztunk egy helyet, és oda tesszük be az elemet. Előtte a rendezéshez hasonlóan a többi elemet eggyel odébb léptetjük

Fordított rendezéses keverés
feltölt(lap,n)
ciklus i=2-től n-ig
  temp=lap(i)
  hely=vszám(1,i)
  ciklus j=i-1-től hely-ig visszafelé
    lap(j+1)=lap(j)
  ciklus vége
  lap(hely)=temp
ciklus vége

Sajnos a két egymásba ágyazott ciklus miatt ez az algoritmus a rendezéseknél megszokott n2-tel arányos lépésszámot igényel.

Forráskód: Pascal, Visual Basic Script (hta), Visual Basic Script (htm)

Knuth-féle keverés


4 szín permutációs fája

Véletlen permutáció kiválasztása
(ha nem látszik az animáció, frissíteni kell a weblapot)

A keverés gyorsaságának növeléséhez rajzoljuk fel az úgynevezett permutációs fát! Négy szín esetén például jelképezzük a kiválasztott színt a fa élének színével. Felülről lefelé haladva először 4 szín közül választhatunk, aztán csak 3-ból stb. Minden egyes út a gyökértől a levelekig megfelel a 4 szín egy permutációjának. Egy véletlen permutáció kiválasztása azt jelenti, hogy minden egyes elágazásnál véletlenszerűen döntjük el, merre induljunk tovább.

A keverési algoritmusban nem kell létrehoznunk a teljes fát. Elegendő mindig csak a következő lépést kiválasztani.

Kunth-keverés
feltölt(lap,n)
ciklus i=1-től n-ig
  j=vszám(i,n)
  csere(lap(i),lap(j))
ciklus vége

Az algoritmust A számítógép-programozás művészete című, nagy sikerű könyv szerzőjéről, Donald Knuthról nevezték el. Mint látjuk, a lépések száma n-nel arányos!

Vegyük észre, hogy az utolsó ismétlésénél valójában nem történik változás, így elegendő n-1-ig futtatni a ciklust:

ciklus i=1-től n-1-ig
  j=vszám(i,n)
  csere(lap(i),lap(j))
ciklus vége

Forráskód: Pascal, Visual Basic Script (hta), Visual Basic Script (htm)

A keverési algoritmusokat egymás után többször is végrehajthatjuk. Így az első végrehajtás után kapott véletlen permutációból újabb véletlen permutációt képezhetünk. Az ismétlésnél természetesen nincs szükség a feltölt eljárás meghívására.

A keverési algoritmusok vizsgálata

Egy keverési algoritmust, azaz a permutációk kiválasztását véletlenszerűnek tekinthetünk, ha

Mivel n elem esetén az ismétlés nélküli permutációk száma n!, a kívánt valószínűség 1/n!. Bizonyítható, hogy a Knuth-keverés esetén ez a feltétel teljesül.

Vigyáznunk kell azonban a részletekre. Ha például a Knuth-keverésnél az [i, n] intervallum helyett az [i+1, n] intervallumból választanánk ki a kártya új helyét (azaz a lap biztosan elmozdulna a helyéről), akkor például a legelső elem egyetlen előállított permutációban sem maradna a helyén. Nem kapnánk meg tehát bármely permutációt.

Ha a Knuth-keverésnél az [i, n] intervallum helyett az [1, n] intervallumból választanánk (ahogy természetesnek gondolnánk), akkor a lehetőségek száma nn lenne az n! helyett. Így egyes permutációk választásának a valószínűsége biztosan nem lehet 1/n!, hiszen az nn általában nem osztható n!-ral. (Például 33 = 27, de 3! = 6.)

Problémát jelenthet maga a véletlenszám-generátor. Ha nem biztosítja az egyenletes eloszlást, akkor nem lesz azonos az egyes permutációk valószínűsége. Lényeges továbbá, hogy a lehetséges véletlenszámok száma elérje a permutációk számát. Mivel log2 52! = 225,58, egy 52 lapos kártyacsomag esetén legalább 226 bites véletlenszám-generátorra van szükség!

Kártyalapok osztása a Knuth-algoritmus alapján

A játékokban a kártyák keverését osztás követi. Ha csak a kiosztott lapokra van szükségünk (a csomagban maradókra nem), akkor fölösleges az egész csomagot megkeverni. A keverés helyett elegendő véletlenszerűen kiválasztani a kívánt számú lapot. Ez az elemek (lapok) egy ismétlés nélküli, véletlen variációjának az előállítását igényli.

Megtehetjük ugyan, hogy egy új lap véletlenszerű választását addig-addig ismételgetjük, amíg különbözni fog az eddig kiválasztott összes laptól, de ez sok lap esetén sok fölösleges ismétléssel jár. A Knuth-féle keverési algoritmus módosítását sokkal hatékonyabban felhasználhatjuk a véletlen variáció előállítására. Egyszerűen a ciklust csak a kívánt számú (k darab) elem kiválasztásáig futtatjuk:

Knuth-osztás
ciklus i=1-től k-ig
  j=vszám(i,n)
  csere(lap(i),lap(j))
ciklus vége

Forráskód: Pascal, Visual Basic Script (hta), Visual Basic Script (htm)

Algoritmusunkat akkor is használhatjuk, ha a kiválasztás sorrendje nem számít (ismétlés nélküli kombináció), hiszen az ugyanazon elemekből álló sorozatok ugyanannyiszor fordulnak elő a variációk között (k!), tehát a valószínűségük megegyezik egymással.

Osztás hasítótáblával

Ha az n jóval nagyobb mint a k, akkor az osztás fenti algoritmusa nem hatékony, hiszen végrehajtásához n elemű tömbre van szükségünk, melyet fel is kell töltenünk kezdőértékekkel. A memóriaigényt ilyenkor hasítótáblázattal csökkenthetjük. A cseréket a teljes tömb tárolása helyett elegendő kulcs-érték párokkal nyilvántartani.

Az

1 2 3 4 7 6 5 8 9

permutáció tárolása helyett például az

(5, 7)
(7, 5)

kulcs-érték párokat tároljuk. Az (5, 7) kulcs-érték pár azt jelenti, hogy az 5. helyen a 7-es szám áll.

Az i-edik tömbelem lekérdezéséhez végignézzük a hasítótábla kulcsértékeit. Ha nem szerepel bennük az i, akkor a tömbelem értéke i lesz, ha szerepel, akkor pedig a kulcsnak megfelelő érték. A fenti esetben például tömb(8)=8, de tömb(7)=5.

Ezek után az osztás (a k-ad rendű ismétlés nélküli véletlen variáció kiválasztásának) algoritmusa pontosan megegyezik a Knuth-algoritmuson alapuló osztáséval, csak az elemek cseréjét a hasítótábla segítségével kell végrehajtani:

Hasítótáblás osztás
hasítótábla inicializálása
ciklus i=1-től k-ig
  j=vszám(i,n)
  h-csere(i,j)
ciklus vége

A csere előtt meg kell vizsgálni, hogy az i, illetve a j szerepel-e kulcsként a hasítótáblában. Ha nem, akkor fel kell venni. Ezek után tudjuk végrehajtani a cserét:

h-csere(i,j)
ha i nem szerepel kulcsként akkor
  az (i,i) pár felvétele a hasítótáblába
elágazás vége
ha j nem szerepel kulcsként akkor
 a (j,j) pár felvétele a hasítótáblába
elágazás vége
az (i,x) és a (j,y) párok értékének cseréje (i,y), (j,x)-re

Az algoritmus lefutása után a véletlen variáció elemeit a kulcsok kiolvasásával kapjuk meg a hasítótáblából.

Forráskód: Pascal, Visual Basic Script (hta), Visual Basic Script (htm)

A Knuth-féle osztási algoritmussal ellentétben ezen algoritmus ismételt alkalmazása előtt újra inicializálni kell a hasítótáblát.

Az algoritmust felhasználhatjuk például lottószámok választására, és minden egyéb, ismétlés nélküli véletlen variáció vagy kombináció előállítására.


A kártyalapok megjelenítése Visual Basic Scriptben

Visual Basic Scriptben nagyon könnyen és látványosan megjeleníthetjük a kártyalapokat. Kép elhelyezéséhez az <IMG> elemet kell beírni a weblap forráskódjába (image: kép). Az elem src tulajdonságában megadjuk a képfájl elérési útját (source: forrás). Mivel idézőjelen belül nem nyithatunk újabb idézőjelet, helyette aposztrófot használunk:

document.write "<img src='elérési út'> "

A kép bárhol lehet az elérhető háttértárakon vagy az Interneten. Megadhatunk relatív és abszolút elérési utat is.

Helyezzük el a kártyalapokat kirajzoló képfájlokat a program mappájának lapok nevű almappájába. Egyszerűen tudunk hivatkozni rájuk, ha a lap sorszámát választjuk fájlnévnek: 1.gif, 2.gif, 3.gif stb. Az elérési utat az almappa nevéből, a fájlnevet jelölő indexből és a kiterjesztésből állíthatjuk össze:

"<img src='lapok\" & lap(i) & ".gif'> "

Ha a lap(i) értéke például 5, akkor a document.write utasítás a következő sztringet írja a weblap forráskódjába:

<img src='lapok\5.gif'>

A hta fájlok futtatása előtt töltsük le a kártyalapokat, és kicsomagolva helyezzük el a mappát a hta fájlokkal megegyező mappába.

A kártyalapok letöltése

Összeállította: Juhász Tibor

A képek forrása:
Card shuffling tutorial
Magic for All
TechUser.Net
Wikipedia