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
grep
ha 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
a
karaktert illesztünk valahol, akkor legfeljebb egyetlen találatot ad: haa
következik, akkor azt, ha nema
következik, akkor failel, ugyanúgy, mintha aza
feladása után kérnénk tőle a következőt. - ha egy
R|T
kifejezést illesztünk valahol, akkor a sorrend: előszörR
összes találatát adja fel sorrendben, majd haR
failel, akkorT
összes találatát sorrendben, majd ha márT
is failel, akkor failel ez a kifejezés is. - ha egy
RT
kifejezést illesztünk valahol, akkor - először lekéri
R
első találatát, ez tart egy pozícióig, - ettől a pozíciótól kezdve elkéri
T
első találatát, - ha kérjük a következő illesztést, akkor továbbra is
R
első találatát tartja meg és kériT
következő találatát mellé, - ha
T
failel, akkor kéri elR
következő találatát, és az annak a záró pozíciójától kezdőT
első találatát, - majd ha már
R
is failel, akkor fogRT
is failelni az adott pozíción. - lényegében tehát mint egy egymásba ágyazott ciklus, a külső ciklus
R
illesztése, a belső pedigT
illeszté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 azalm
részt próbálja illeszteni azalmalom
elején - ez egy háromtényezős szorzat, de most ennek a részletezését kihagyva: az
alm
illeszkedik, az első tag ezt dobja fel első találatnak - most az
alom
szó elejére próbálja illeszteni hozzá amalom|l
regexet - ez egy vagyolás, először a
malom
mal 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, azalmalom
szó elejétől - az
alm
alternatíva egyetlen féleképp illeszkedik, ez a második kérdezéskor failel - kipróbáljuk a második alternatívát: az
alma
illeszkedik, ezt dobja fel találatnak - így most a
(malom|l)
regexet alom
szó 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 azalmalom
szó 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
ésmalom
illeszté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 grep
né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?aaa
vs.a
- a harmadik is:
aaa
vs ü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, maradaaa
vs.a
- az első
a
még illeszkedik, de azaa
vs. üres string failel. Visszalépünk, aza
is 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?aaa
stringet a megmaradtaa
stringre 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, aza
felől, hanem balról, a másodika?
felől érkezik a motor, feladja aza
-t mint első illeszkedést - az
aaa
vsa
illesztés failel megint, mert azaa
vs üres string illesztés failel - visszaérünk a harmadik
a?
-ig, ezúttal feladja az üres stringet mint következő találatot, marad azaa
string - az
aaa
vsaa
illeszté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?aaa
regexet illesztjük azaaa
stringre - a második
a?
és a harmadik is először megint aza
-t dobják fel mint találatot, de azaaa
vsa
illesztés failel - a harmadik
a?
elengedi aza
-t, feladja az üres stringet,aa
vsa
megint 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
a
illik 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 2
n
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 grep
ben!
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..R
találatsorozatban egyR
má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ó
R
má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
R
illeszkedik é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ásodikaa
nem 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 aza
utáni következő találat azaa
lesz, 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, azaa
lesz 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
00
stringre mind a három alternatívánk illeszkedik! az illesztési sorrendben azt írom, hogy az egyes00
substringekre 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. .0
illeszkedést, majd 0. 0. (.)\1
0. 0.
0. .0 0.
0. .0 .0
0. .0 (.)\1
0. .0
0. (.)\1 0.
0. (.)\1 .0
0. (.)\1 (.)\1
0. (.)\1
0.
.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. 3
n
lépést
igényel az illesztés. Próbáljuk ki ezt is Javában és grep
pel 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 azR
találatain - az
R*
előbb adja fel az üres stringet, majd (jelöljeRi
azR
kifejezési
-edik találatát, hogy jobban látsszon) a sorrend: R1
R1 R1
R1 R1 R1
(amíg van mégR
-illeszkedés, csak egyesével veszi fel őket)R1 R1 R2
R1 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
R
első 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.