4. gyakorlat
A backtracking regex motorok illesztési mechanizmusa¶
Láttuk már korábban, hogy a Java és a grep időnként egész más találatokat hoz fel, ez a motorjuk közti
különbség miatt van:
- a
grepha tud, véges automatát épít és azt használja az illesztéshez; - a Java pedig egy visszalépéses kereső, azaz backtracking motor.
A backtracking motorok mechanizmusa kb a következő: egy kifejezést egy pozícióra mikor illesztenek, akkor valamilyen sorrendben adnak fel találatokat, előbb az elsőt, újrakérés esetén a másodikat, stb, végül egy "nincs több lehetőség ezen a pozíción" jelzést.
karakterek, vagyolás, szorzat¶
- ha egy
akaraktert illesztünk valahol, akkor legfeljebb egyetlen találatot ad: haakövetkezik, akkor azt, ha nemakövetkezik, akkor failel, ugyanúgy, mintha azafeladása után kérnénk tőle a következőt. - ha egy
R|Tkifejezést illesztünk valahol, akkor a sorrend: előszörRösszes találatát adja fel sorrendben, majd haRfailel, akkorTösszes találatát sorrendben, majd ha márTis failel, akkor failel ez a kifejezés is. - ha egy
RTkifejezést illesztünk valahol, akkor - először lekéri
Relső találatát, ez tart egy pozícióig, - ettől a pozíciótól kezdve elkéri
Telső találatát, - ha kérjük a következő illesztést, akkor továbbra is
Relső találatát tartja meg és kériTkövetkező találatát mellé, - ha
Tfailel, akkor kéri elRkövetkező találatát, és az annak a záró pozíciójától kezdőTelső találatát, - majd ha már
Ris failel, akkor fogRTis failelni az adott pozíción. - lényegében tehát mint egy egymásba ágyazott ciklus, a külső ciklus
Rillesztése, a belső pedigTillesztése.
Nézzük meg eddig pl. az egyik első példánkat kicsit módosítva,
az (alm|alma|al)(malom|l) regex illesztését az almalom
szóra:
- ez egy szorzat, előbb az első tagját illeszti a legelső pozícióra
- az
(alm|alma|al)illesztésekor először azalmrészt próbálja illeszteni azalmalomelején - ez egy háromtényezős szorzat, de most ennek a részletezését kihagyva: az
almilleszkedik, az első tag ezt dobja fel első találatnak - most az
alomszó elejére próbálja illeszteni hozzá amalom|lregexet - ez egy vagyolás, először a
malommal próbálkozik, ez failel ezen a pozíción - majd az
l-lel próbálkozik, ez is failel ezen a pozíción, ezért az egész vagyolás, a(malom|l)failel - mivel a
(alm|alma|al)(malom|l)illesztésekor a második tag failelt, ezért most az elsővel nézzük a következő találatot, azalmalomszó elejétől - az
almalternatíva egyetlen féleképp illeszkedik, ez a második kérdezéskor failel - kipróbáljuk a második alternatívát: az
almailleszkedik, ezt dobja fel találatnak - így most a
(malom|l)regexet alomszó elejétől próbáljuk illeszteni - az első alternatíva nem illik ide, failel, a második viszont igen, az feldobja az
l-t mint első találatot (ha újra kérnénk tőle, akkor failelne, ez az egy találata van) - így az
(alm|alma|al)(malom|l)szorzatunk feldobja első találatnak azalmal-t azalmalomszó elejéről - ha esetleg ez egy hosszabb regex része lenne, és további illesztési failek miatt kérnénk tőle a következő
találatot ugyanitt, akkor pedig az
almalom-ot dobná fel, mint azalésmalomillesztését a két tagból, ha újra kérnénk, akkor már failelne.
Látszik tehát, hogy az alternatívák sorrendje számít backtrack motoroknál (és nem számít a grepnél).
Általában ha csak egy találatot szeretnénk megkapni, érdemes úgy rendezni az alternatívák sorrendjét,
hogy előre tegyük azt, ami várakozásaink szerint leggyakrabban fogja feladni a nekünk jó találatot, ezzel
gyorsítva az illesztési folyamatot.
kérdőjel¶
Ha egy R? regexet illesztünk egy pozíción, akkor a találatok sorrendje: először feladja találatnak sorban
az R regex illeszkedéseit a megadott inputra, majd ha végül failel az R, akkor még feladja az üres stringet
is, és ezután failel.
Ezt úgy is mondjuk, hogy a kérdőjel mohó: preferálja az "illeszkedik valami" opciót a "nincs itt semmi"vel szemben.
Mi történik például, ha az a?a?a?aaa regexet illesztjük az aaa stringre?
- először az első
a?feladja az elsőa-t mint találatot, most aza?a?aaa-t kell illesszük a megmaradtaa-ra - most a második
a?is lenyel egya-t, marada?aaavs.a - a harmadik is:
aaavs üres string, és ez failel. Visszalépünk az előző állapotba és kérünk egy másik opciót. - most a harmadik
a?feladja az üres stringet mint illeszkedést, maradaaavs.a - az első
amég illeszkedik, de azaavs. üres string failel. Visszalépünk, azais failel azzal, hogy nincs több opció, majd a harmadika?is failel azzal, hogy már feladta az üres stringet is - a második
a?-hez visszajut a vezérlés, aki egya-t adott fel, most feladja az üres stringet. Most aza?aaastringet a megmaradtaastringre próbáljuk illeszteni. - a harmadik
a?már egy új menetben van, hiszen korábban már végigfailelte az illesztést, most ezt előlről kezdjük, hiszen nem jobbról, azafelől, hanem balról, a másodika?felől érkezik a motor, feladja aza-t mint első illeszkedést - az
aaavsaillesztés failel megint, mert azaavs üres string illesztés failel - visszaérünk a harmadik
a?-ig, ezúttal feladja az üres stringet mint következő találatot, marad azaastring - az
aaavsaaillesztés is failel, visszamegyünk a harmadika?-ig, ő már feladta az utolsó opcióját, visszamegyünk a másodika?-ig, mostanra ő is, visszamegyünk az elsőa?-ig - aki feldobja második opcióként az üres stringet, most az
a?a?aaaregexet illesztjük azaaastringre - a második
a?és a harmadik is először megint aza-t dobják fel mint találatot, de azaaavsaillesztés failel - a harmadik
a?elengedi aza-t, feladja az üres stringet,aavsamegint failel - eztán a második
a?elengedi aza-t, feladja az üres stringet, de a harmadik megint restartolva lenyeli aza-t - fail után engedi el és így jutunk el oda, hogy mindhárom kérdőjeles kifejezés az üres stringet dobja fel és a maradék
három
aillik az inputaaa-ra.
Ez elég sok lépés: összesen 8 lehetőség van arra, hogy a kérdőjeles a-k közül melyik legyen a és melyik üres string
és a mohóság miatt az utolsó lesz, ami végül találatot ad. Általában ha n hosszú a?-lel indítunk, majd n darab
a-val és ezt illesztjük n darab a-ra, a backtrack motorok ezen eltöltenek 2n lépést...
Próbáljuk is ki: mérve a futásidőt, egyesével növeljük az a-k számát és figyeljük meg, hogyan növekszik az illesztés
ideje Javában vs grepben!
csillag, plusz¶
Az R* kifejezés illesztési sorrendje:
- először illeszti a pozíción
R-t, feljön az első találat, ha ez üres string, akkor kéri a következőt, - ezután a találat végétől újra illeszti
R*-ot (majd annak az első találatának a végétől újra és újra) - amikor ebben az
RRRR..Rtalálatsorozatban egyRmár failel, akkor ezt az eddigi illeszkedést dobja fel első találatként, jegyezve az összes belsőR-ben, hogy éppen melyik találatot dobták fel - ha újat kérnek, akkor az utolsó
R-től mintR*-tól kéri a következő, majd a következő találatot - ha az utolsó
Rmár failel, akkor a következő találatát úgy készíti, hogy eltávolítja az utolsóR-t (így eggyel kevesebb "tagból rakja ki" a találatot, mint eddig) - az ezutáni találatán a (most már) utolsó
R-től mintR*-tól kéri a következő találatot... - ... és így tovább, egészen addig, míg oda nem jut, hogy csak egy
Rilleszkedik és az is mintR*már feladta az utolsó találatát, ekkor utolsó lépésben feldobja az üres stringet mint találatot - és ezután failel.
Az R+ annyiban tér el ettől, hogy utolsó találatként nem dobja fel az üres stringet.
Nézzünk erre is egy példát: illesszük az ^(a|aa)*b$ regext az aaaa stringre (melyre nem illeszkedik)!
A találatok sorrendje, ahol a b-t kezdi majd illeszteni (és nem találja sehol) a következő lesz,
szóközzel választom el a belső R-ek határait:
a a a a(mindenből az első találatot választjuk, amíg el nem érünk az input végéig)a a a(feladjuk az utolsóa-t, helyette a másodikaanem illeszkedik, az utolsó komponenst elengedjük)a a aa(az utolsó komponenst mint(a|aa)*-ot léptetjük tovább, a következő találataaa, utána már nincs illeszkedés, ezt adjuk fela a(az utolsó komponenst léptetnénk tovább, failel, elengedjük)a aa a(a másodika-t léptetjük tovább mint(a|aa)*-ot: egyrészt azautáni következő találat azaalesz, de illeszt tovább és még a következő(a|aa)blokk első találatát is hozzáveszi)a aa(léptetnénk az utolsó komponenst, failel, elengedjük)a(léptetnénk az utolsó komponenst, failel, elengedjük)aa a a(léptetjük az utolsó komponenst, azaalesz az első tényezőjében, majd megint illeszkedik, első találata, megint, annak is első találataa, most már nem, ezt adjuk fel következőnek)aa a(léptetnénk tovább az utolsó komponenst, nem illeszkedik a másik alternatíva, azaa, a maradék inputra, amia, failel, elengedjük)aa aa(léptetjük az utolsó komponenst,aa, tovább nem tudjuk ismételni, ezt adja fel)aa(léptetnénk tovább az utolsó komponenst, failel, elengedjük)- üres string (elengedtük az utolsó komponenst, utoljára feladjuk az üres stringet)
- fail (vége)
Ez így szintén az, aminek látszik: exponenciális futásidejű illesztés, azaz catastrophic backtracking.
Kézbe osztott párok¶
Pókerben a lapok értékét a [02-9TJQKA] karakterosztállyal kódolhatjuk, ahol 0 a joker lap.
Ha kapok két karaktert ebből az osztályból, az egy pár akkor, ha megegyeznek, vagy ha legalább egyikük joker.
Ha egy pár felismerésére kell regext írjunk, az ember előállhat pl. a (?:0.|.0|(.)\1) regexszel: vagy az első
lap joker, vagy a második, vagy az elsőt captureöljük és ugyanaz követi. Ezzel magában még nincs is semmi gond első ránézésre.
Ha viszont pár-sorozatok felismerésére kell regext írnunk, azaz egy olyat, mely pl. illeszkedik az 5502JJ30QQ stringre,
de nem illeszkedik pl. az 5502JT30QQ stringre, akkor előállhatunk a következő ötlettel:
^(?:0.|.0|(.)\1)+$
Abból a szempontból ez nem hibás, hogy valóban a kért stringekre illeszkedik: iterált csoporton belül a backreference mindig az aktuális csoport"példány"on belül tárolja le az illeszkedést.
Mi történik, ha ezt a regext az 0000000 (hét darab nulla) stringre illesztjük?
- iterálás: először a
0.illeszkedik az első két nullára, majd a0.a következő kettőre, majd a0.a következő kettőre, utána a három alternatíva közül egyik sem az egyetlen maradék nullánkra, ezt dobja fel a+-os rész első találatnak, de a$nem matchel, fail. - látjuk, hogy a
00stringre mind a három alternatívánk illeszkedik! az illesztési sorrendben azt írom, hogy az egyes00substringekre a motor sorban melyik alternatívák illesztését jelenti le, szóközzel elválasztva, tehát az első feldobott találat a0. 0. 0.volt, és a$-nál failelt - az utolsó iterált rész léptetésével kapjuk a
0. 0. .0illeszkedést, majd 0. 0. (.)\10. 0.0. .0 0.0. .0 .00. .0 (.)\10. .00. (.)\1 0.0. (.)\1 .00. (.)\1 (.)\10. (.)\10..0 0. 0.- ...
ez 39 lehetőség végigpróbálását jelenti, általában ha 2n+1 hosszú az input, csupa 0, akkor kb. 3n lépést
igényel az illesztés. Próbáljuk ki ezt is Javában és greppel is!
A catastrophic backtracking problémájával ebben a formában tudunk találkozni, amikor van egy (R|T)* vagy (R|T)+* alakú
olyan rész a regexünkben, ahol is létezik olyan x string, ami illik R-re is és T-re is (mint most volt a 00): ilyenkor
ha nincs találat, de az inputban sok x követi egymást ezen a ponton, akkor az kivált egy exponenciális idejű illesztést,
míg a motor rájön, hogy nincs illeszkedés.
Ezt ebben a feladatban könnyen ki tudjuk védeni, pl. az eredeti helyett a 0[^0]|[^0]0|(.)\1 regex iterálásával, ebben a
három alternatíva diszjunkt lehetőségeket fed le, így az első találat után a következő kérésekor failelni fognak és lineáris
időben kiderül, hogy illeszkedik-e a stringünk vagy sem.
Possessive iterálás¶
A Java motor támogatja a possessive quantifiereket: a ?, {..}, + és * helyett a ?+, {..}+,
++, *+ operátorokkal az alap greedy viselkedés helyett
- az első találatot pontosan ugyanúgy adják fel, mint az eredeti
?,{..},+és*operátorok, - viszont ha kérnek tőlük következőt, akkor nem adnak, hanem failelnek.
Ez azt is jelenti, hogy egészen megváltozik, mikre fog illeszkedni a regex! Használjuk körültekintően, kizárólag akkor, ha biztosak vagyunk benne, hogy az iterált rész legelső találata ha nem megfelelő, akkor nincs is jó lehetőség.
A fenti pókeres példára visszatérve, a ^(?:0.|.0|(.)\1)++$ regex sem okoz catastrophic backtrackinget: a 0. 0. 0. .. 0.
első találat feladása után mivel a még egy nulla nem az input vége, a motor kéri tőle a következő találatot, erre pedig egyből failelni fog.
(Lépésszámot tekintve gyorsabban, mint ha a ^(?:0[^0]|[^0]0|(.)\1)+$ regexet illesztenénk, hiszen az kipróbálja a belső alternatívákat is,
mind failel, visszalépked és egyesével adja fel az egyre rövidebb prefixeket, és csak mikor feladja az utolsót és egyik se lett az input
vége, akkor failel el. Persze azért puding próbája az evés, mérjük le az idejét Javában a két regex illesztésének!)
Lazy iterálás¶
Greedy helyett egy másik opció a lazy quantifier: a ?, {..}, + és * helyett a ??, {..}?,
+?, *? operátorokkal az alap greedy viselkedés helyett
- az
R?előbb adja fel az üres stringet találatnak, és csak ezután halad végig azRtalálatain - az
R*előbb adja fel az üres stringet, majd (jelöljeRiazRkifejezési-edik találatát, hogy jobban látsszon) a sorrend: R1R1 R1R1 R1 R1(amíg van mégR-illeszkedés, csak egyesével veszi fel őket)R1 R1 R2R1 R1 R3(mondjuk most ez volt az utolsó alternatíva)R1 R2(itt is először csak egyet illeszt, nem iterál tovább akkor se ha lehet)R1 R2 R1(csak a következő lépésben)R1 R2 R1 R1(ha mondjuk ennyiszer lehet így iterálni)R1 R2 R1 R2(mondjuk itt ez az utolsó)R1 R2 R2(mondjuk itt ez az utolsó)R1 R3(stb)- az
R+ettől annyiban tér el, hogy nem dobja fel az elején az üres stringet - az intervallum-ismétlések pedig minél kevesebbet próbálnak iterálni és csak akkor rátenni még egy
R-t ha kérik tőlük a következőt
Mint láthatjuk, a lazy iterálás viszont feldobja ugyanazokat a lehetőségeket, mint a greedy, tehát
- az illeszkedő stringek köre ugyanaz marad
- a konkrét visszaadott illeszkedés lehet más
- ha az input string nem illeszkedik a reguláris kifejezésre, ez is ugyanúgy végigpróbál minden lehetőséget, tehát sebességre nem lesz jobb ilyenkor
- viszont illeszkedő string esetén lehet, hogy más konkrét csoportokat ad vissza, mint greedy változatban, és lehet, hogy nem ugyanannyi lépésben találja meg, mint greedy változatban.
Például ha egy xml-ben meg akarjuk keresni az <a> tagek belsejében lévő tartalmakat, korábban egy lehetőség,
ami eszünkbe juthatott, az <a>(.*)</a> regex lehetett, azonban ez az <a>egy</a>kettő<a>három</a> input esetén
a . greedy iterálása miatt találatában az első csoportba az egy</a>kettő<a>három stringet teszi, ami nem az, amit
szeretnénk! (Ellenőrizzük.) Ehelyett a lehető legkevesebb karaktert akarjuk ismételni és rögtön az első záró
tagnál kiszállni: <a>(.*?)</a> már előbb az egy, majd újra findolva a három stringet találja meg, ahogy szeretnénk.
Atomi csoportok¶
Javában támogatottak a (possessive quantifierekhez nagyon hasonló) atomi csoportok is, melynek jele az (?>..) zárójelpáros;
egy (?>R) reguláris kifejezés illesztésekor egy pozíciótól
- a motor elkéri
Relső illeszkedését ezen a pozíción, - ha nincs, akkor failel,
- ha van, feladja ezt az első illeszkedést, és ha kérnek tőle még egyet, akkor failel.
Tehát ha nézzük az R*+ possessive iterálást, ez ugyanaz, mint az (?>R*) atomi csoportban greedy iterálás: mindkettő
a greedy R* legelső találatát adja fel és mást nem. Ha véletlenül olyan környezetben vagyunk, mely atomi csoportot támogat,
de possessive iterálást nem, így oldhatjuk meg. A párok felismerésére pl. egy szintén jó reguláris kifejezés lett volna
az (?>0.|.0|(.)\1) atomi csoport, hiszen mivel minden találat kétbetűs, így biztos, hogy ha még egyszer megkérdezik tőlünk
ugyanazon a pozíción, hogy van-e még illeszkedés, ha adunk is, úgyis ugyanazt adnánk, mint adtunk korábban, ezt pedig nem
akarjuk, hiszen az egész hátralévő illesztési mechanizmus megismétlődne még egyszer fölöslegesen.