Keresés nagyméretű szövegfájlban és karaktersorozatban

Ha egy karakterláncban akarunk egy adott részkarakterláncot (mintát) keresni, akkor erre a str.find() vagy str.index() metódusokat lehet használni. Mindkettő a minta első előfordulásának indexével tér vissza, ha van találat. Ha nincs, akkor a str.find() visszatérési értéke -1, a str.index() pedig ValueError kivételt dob.

E két metódus azonban csak str típusú karakterláncokra hívható meg. Ezért, ezek nem használhatók, ha az a feladat, hogy

  • egy olyan nagyméretű szöveges fájlban kell keresni egy adott mintát, hogy a fájltartalom nem olvasható egyszerre be a memóriába, vagy
  • a vizsgálatot egy olyan karaktersorozaton kell végezni, amelynek karaktereit egy iterálható objektum szolgáltatja.

Mivel a szabványos könyvtárban nem áll rendelkezésre olyan függvény vagy metódus, amellyel ilyen esetekben a keresést el lehet végezni, ezért magunknak kell készíteni egyet, amely iterálható objektumot fogad, és egyet, amely egy fájlobjektumot.

A keresőfüggvényünkkel szembeni elvárások a következők:

  1. a keresés folyamán a memóriafelhasználást tudjuk kontrollálni.
  2. ne csak az első, hanem az összes előfordulás pozícióindexét kapjuk meg.
  3. a keresés az átlapolódó találatokat is vegye figyelembe.

Az utolsó két feltétel miatt célszerű, ha generátorfüggvényt készítünk, mert hosszú sorozat és sok átlapolódás esetén a kapott indexek sorozata is hosszú lehet. Generátorfüggvénnyel viszont egy olyan generátorobjektumot kapunk, amelyből egyesével lehet kikérni a pozícióindexeket. Ha csak az első előfordulásra van szükségünk, akkor csak egyszer kérjük ki a generátorból az értéket.

Ha a függvényünk az összes előfordulást megkeresi, akkor azért célszerű az átlapolódó minták pozícióját is meghatározni, mert lehet olyan használati eset, amikor ezek kellenek. Ha pedig csak a nem átlapolódókra van szükség, akkor a kapott indexeksorozatból ezek viszonylag egyszerűen kiszűrhetők.

A memóriafelhasználást úgy tudjuk kontrollálni, ha a beérkező karaktereket megadható hosszúságú blokkokban (chunk) dolgozzuk fel.

Ez azt jelenti, hogy a beérkező karaktereket a megadott hosszúságú karakterlánccá egyesítjük (fájlban keresésnél ekkora blokkokban olvasunk be), és a blokkon végezzük a tényleges keresést. Ezt a blokkra mint karakterláncra meghívott find() függvénnyel valósítjuk meg, mert ez C-ben futó, optimalizált függvény, ami nagyon gyorsan végzi a keresést (sokkal gyorsabban, mintha Python kódban valósítanánk meg). Azt azonban figyelembe kell venni, hogy a str.find() csak az első előfordulást keresi meg.

Amire a blokkonkénti feldolgozásnál ügyelni kell, hogy előfordulhat, hogy egy minta egyik blokkból átnyúlik a következőbe. Ezért a keresést valójában nem egy blokkon, hanem az előző blokk megfelelően hosszú vége és az új blokk egyesítésével létrejövő karakterláncon (keresőszakasz) kell végezni.

A keresőszakaszhoz az előző blokk megfelelő hosszú végének meghatározásakor a leghosszabb esetet kell figyelembe venni. Ez akkor van, amikor a minta csak egyetlen karaktere esik a következő blokk elejére, a többi a jelenlegi blokk végére. Tehát minden egyes kereső szakasz eleje az előző blokk utolsó mintahossz-1 darab karakterével egyezik meg. Mindezt vizuálisan a következő ábra szemlélteti.

A fenti elvek alapján megvalósított find_in_iterable() és find_in_file() függvények definícióit láthatjuk alább. A részletes kommentek segítik a működés megértését.

A függvények teszteléséhez olyan nagyméretű fájlokra van szükségünk, amelyek méretét előre meg tudjuk határozni, és el tudjuk bennük helyezni egy megadható pozícióba a keresendő karakterláncot, vagyis a mintát. A mintán felüli karaktereket véletlenszerűen állítjuk elő. Ahhoz, hogy a keresést ne csak ASCII karakterekre teszteljük, ékezetes betűket is szerepeltetünk mind a mintában, mind a véletlenszerűen előállított karakterek között.

A keresési idő mérését minden fájlméret esetében a legrosszabb esetre végezzük el. Ehhez a mintát a fájlok végére helyezzük. Hosszú sorozatok esetén azonban a minta előfordulása a véletlenszerűen előállított szakaszon is előfordulhat. Érdemes megbecsülni, hogy egy adott sorozat- és mintahossz estén hány előfordulás várható. Ezzel azt is tervezni tudjuk, hogy milyen hosszú sorozatoknál lesz a minta szinte biztosan csak a fájl végén, is mi az a határ, ahol már több előfordulás is várható. Az utóbbi esetben a függvényeinknek az összes előfordulás pozícióját ki kell adniuk. Ezzel egyúttal a véletlen eloszlásra tett feltételezéseket is tesztelni tudjuk.

A hosszú karaktersorozatokat tartalmazó nagyméretű fájlok létrehozásához szükséges tervezési képletek levezetése a következő ábrákon követhető.

A fenti képleteket felhasználó fájl- és karaktersorozatelőállító függvények definíciói, valamint a fájlokat létrehozó kódsor az alábbi.

A find_in_iterable() és find_in_file() függvények keresési idejének mérését végző programkód a következő.

Az eredményeket az alábbi táblázat foglalja össze.

Amint az eredményekből láthatjuk, a keresés ideje a karakterek számával lineárisan nő. Ezt is vártuk, mert a str.find() metódus optimalizált keresési algoritmusa – adott keresendő minta esetén –  legrosszabb esetben is, amikor a minta sorozat legvégén van, a bemeneti sorozat hosszával lineárisan növő végrehajtási időt biztosít.

Fájlban keresés esetén a linearitás csak az 1–1000 MiB tartományban igaz, 10000 MiB-os fájlméretnél az addigi trendből várthoz képest érzékelhetően magasabb a keresési idő. Ennek oka, hogy a kisebb fájlok a tesztelés során teljes terjedelmükben elfértek az operációs rendszer memóriabeli gyorsítótárában (Page Cache), így a keresés valójában a gyors RAM-ban zajlott. A 10000 MiB-os fájl viszont már túllépte a szabad lemez-cache keretét, ezért a feldolgozás sebességét itt már nem a CPU és a memória, hanem a fizikai háttértár lassabb olvasási sebessége határozta meg.

Az iterálható objektumra épülő változatnál a keresési idő egy nagyságrenddel (kb. 10-szeresére) nőtt, viszont a futási idő még a legnagyobb karakterszámnál is megőrizte a lineáris jelleget.

A lassulást a Python-szintű karakterenkénti feldolgozás okozza: a karakterláncszakaszok előállításánál az islice() és a ''.join() kombinációja nagyon sok belső műveletre (függvényhívások és ideiglenes memóriafoglalás) kényszeríti a Python interpretert. Ugyanakkor a linearitás éppen emiatt marad meg a teljes tartományban: a kód működése tisztán processzor-korlátossá (CPU-bound) vált. Mivel a C-alapú fájlolvasáshoz képest a Python-szintű ciklus sokkal lassabb, a feldolgozás sebességét itt már nem a lemez fizikai I/O korlátai vagy a Page Cache mérete, hanem kizárólag a CPU állandó tempójú számítási kapacitása határozza meg.

A bemutatott függvények megírásához számos Python nyelvi ismeret szükséges, amelyek megtalálhatók a Python tudásépítés lépésről lépésre az alapoktól az első asztali alkalmazásig című e-könyvben.

Érdekel a Python tudásépítés lépésről lépésre az alapoktól az első asztali alkalmazásig című e-könyv.