NOVINKA! E-learningové kurzy umělé inteligence. Nyní AI za nejlepší ceny. Zjisti více:
NOVINKA – Víkendový online kurz Software tester, který tě posune dál. Zjisti, jak na to!

Lekce 5 - Vyhledávání řetězce v textu

V minulé lekci, Interpolační vyhledávání, jsme si ukázali algoritmus na interpolační vyhledávání, který efektivně vyhledává prvky v rovnoměrně setříděném poli.

Tento článek je zároveň drobným úvodem do textových algoritmů, které za posledních několik desítek let mnohonásobně nabily na významu. Dnes se seznámíme s tzv. algoritmem KMP - Knuthův-Morrisův-Prattův algoritmus.

KMP algoritmus

Určitě jste se dostali do situace, kdy jste měli najít něco v neuspořádané hromadě. Nic není srovnané, hledaná věc může být v hromadě jednou, nebo třeba 50x... Podobný problém může být, pokud chceme najít v textu určité slovo či podřetězec. Budeme třeba chtít najít všechny výskyty slova kokos v mezinárodní dohodě dvou států o dovážení potravin. Úloha se podobá velmi známému přísloví o hledání jehly v kupce sena. Nejdříve si však formálně řekněme, jak úloha zní.

Formální zápis problému

Na vstupu dostaneme seno s a jehlu j. Seno i jehla jsou textové řetězce a chceme najít všechny indexy (neboli pozice) od kterých začíná jehla j v seně s, tedy všechny počáteční pozice hledaného slova v textu.

Brute-force řešení problému

Řekněme, že máme n znaků v řetězci. Velikost jehly je j. Můžeme vzít for-cyklus ve for-cyklu a zkoušet jednotlivé podobnosti:

List<int> mnozinaIndexu = new List<int>();

for (int i = 0; i < n - j; i++) {
    for (int k = 0; k < j; k++) {
        if (seno[i + k] != jehla[i]) {
          break;
        }
        if (k == j - 1) {
            mnozinaIndexu.add(i);
        }
    }
}

Můžeme se podívat na časovou složitost. Algoritmus pro každé i z n pojede j-krát. Časová složitost je tedy O(n*j). Dá se to dělat lépe? Kdy tento algoritmus provádí zbytečnou práci navíc?

Efektivnější přístup

Představme si, že hledáme v textu ten kokos. Mějme seno nejkokokokosovatější. Můžeme si uvědomit, že když přečteme ko, pak hledáme už jen výraz kos. Pokud přečteme koko, stačí nám už jen s. Co se ale stane v momentě, kdy přečteme další k? Nemusíme přeci zahazovat celý řetězec. Sice máme teď přečtené slovo kokok, ale můžeme vypustit první dva znaky a budeme mít přečtené opět kok. Dále přečteme o a pokud přečteme zase k, postup se opakuje. Pokud však přečteme s, máme hledaný kokos. Uvědomíme si, že se nyní nevracíme a ze vstupního sena čteme vždy další a další znak. Každý znak ze sena přečteme jen jednou. Jediné, co procházíme, je jehla, neboť procházíme jehlu od začátku a odsekáváme prefixy, které víme, že už nebudou užitečné. Nakonec můžeme odseknout celou jehlu, pokud po koko přečteme l:

seno: nejkokokokosovatější
jehla: kokos

n e j k o k o k o k o s o v a t ě j š í
k k k k o k o k
          k o k|o k
              k o k|o s
                        k k k k k k k k

Slovo kokos se tedy ve slově vyskytoval pouze jednou. Každý znak ze sena jsme prošli právě jednou. značka | znázorňuje část slova, kterou jsme převzali z doposud přečteného slova.

Algoritmus

Bohužel se zde nevyhneme pojmu automat. Berte to nyní pouze tak, že postavíme jakýsi parser, který sám bude rozhodovat, zda slovo odsekne nebo znak připojí k již nalezenému prefixu hledaného slova. Pokud máte za sebou teorii automatů, tak v KMP algoritmu využíváme konečný stavový automat.

Sestavení automatu

Prvním krokem bude postavit automat pro naši jehlu. Budeme chtít postavit nějakou krabičku, která se bude sama starat o to, kam se jehla posouvá:

static int[] BuildAutomat(string jehla)
       {
           ReturnFunction = new int[jehla.Length];
           ReturnFunction[0] = -1;
           if (jehla.Length > 1) ReturnFunction[1] = 0;
           int stav = 0;

           for (int i = 2; i < jehla.Length; i++)
           {
               stav = KMPStep(jehla, stav, jehla[i - 1]);
               ReturnFunction[i] = stav;
           }
           AfterBuildingAutomat = true;
           return ReturnFunction;
       }

Automat si reprezentujeme jako pole. Vstupem pro funkci bude jehla. Chceme postavit funkci ReturnFunction, neboli návratovou funkci, která se rozhodne pro to, kam se posunout pro přečtený znak. Pro první znak nemá návratová funkce smysl. Pokud je jehla větší než 1, pak pro pole v indexu 1 se vracíme do nuly. Toto odpovídá situaci, kdy jsme chtěli přečíst slovo kokos a přečetli jsme znak m. Vracíme se tedy na začátek. Pro další stavy pošleme stav do KMPStep(), kterému "řekneme", co hledáme, ve kterém jsme stavu a jaký symbol jsme přečetli. Poté co sestavíme automat, neboli budeme mít řečeno pro jaká písmena se kam vracíme, označíme si, že jsme automat sestavili. Díky tomu můžeme využívat funkci KMPStep() bez toho, aby se znova stavěl už postavený automat.

Pattern je naše jehla. Máme již nadefinovanou návratovou funkci. Návratová funkce pro znak určí, o kolik se má v přečtené jehle vrátit.

[k o k o s]  - jehla
[k o k ]     - přečteno
pokud nyní přečtu 'o', je to v pořádku a návratová funkce není třeba.
[k o k o]
pokud nyní přečtu 'k', tak jehla[4] != k, musím použít zpětnou funkci. Ta dostane 'k' a vrátí mě na pozici 3.
[k o k]
pokud nyní přečtu 'o', je to v pořádku a návratová funkce není třeba.
[k o k o]
pokud nyní přečtu 's' nebo 'a', tak mě návratová funkce pošle na začátek. Samozřejmě si pro 's' zaznamenám index, neboť jsem našel jehlu v kupce sena.

Jeden krok automatu

Jeden krok reprezentujeme funkcí KMPStep:

static int KMPStep(string jehla, int stav, char x)
{
    while ( (jehla[stav] != x) && (stav!= 0) )
    {
        stav = ReturnFunction[stav];
        if (AfterBuildingAutomat)
        {
            break;
        }
    }
    if (jehla[stav] == x)

    {
        stav++;
    }

    return stav;
}

Tento kód odpovídá tomu, že pokud písmeno x není v jehle na pozici [stav], neboli na 4. pozici není kupříkladu písmeno s, musíme se tedy vrátit zpět. Odpovídá to našemu znaku k, když jsme přečetli slovo koko. Pokud bychom přečetli s, automat by se nevracel a cyklus while by neproběhl ani jednou. Pokud jsme našli shodu, jehla se posune o pozici doprava zvednutím stavu. AfterBuildingAutomat je proměnná, která nám řekne, jestli jsme už automat sestavili, nebo ne. Tu samou funkci potom budeme používat při hledání řetězce, zatím si toho nevšímejme.

Použití algoritmu

Nyní máme sestavený automat a máme definováno, co se stane, když KMP dostane znak ve funkci KMPStep(). Nyní už nám stačí mít na vstupu seno a jehlu. Všechny potřebné nástroje již máme:

public static List<int> SearchKMP(string seno, string jehla)
{
    BuildAutomat(jehla);
    int stav = 0;
    List<int> indexes = new List<int>();

    for (int i = 0; i < seno.Length; i++)
    {
        stav = KMPStep(jehla, stav, seno[i]);
        if (stav == jehla.Length)
        {
            state--;
            indexes.Add(i - jehla.Length + 1);
            stav = ReturnFunction[stav];
        }

    }
    return indexes;
}

Nejdříve si postavíme automat. Zavedeme si dynamické pole indexů. Je-li pole na konci prázdné, jehla v seně nebyla. Potom máme jeden cyklus for, kterým projdeme seno. Jestliže nám funkce KMPStep() vrátí stav, který se rovná délce jehly, víme, že jsme našli vhodné slovo. Snížíme stav (kvůli indexování pole od 0) a přidáme index slova, kde jehla začíná. Poté ještě zavoláme návratovou funkci, protože přečteme-li slovo kuchtík, tak jsme zároveň přečetli i znak k pro slovo kuchtíkuchtíkuchtík a algoritmus bezpečně odhalí všechny výskyty tohoto slova.

Zhodnocení algoritmu

Proč to vlastně všechno děláme? Pomohli jsme si oproti původnímu algoritmu brute-force? Rozhodně. Časová složitost nového algoritmu je O(j + s), kde j je délka jehly a s je délka sena. Hledáme-li nějaké jméno v několikagigovém textovém souboru, rozdíl rychlostí je očividný.

Co ale dělat pokud bychom v textu chtěli najednou najít více slov? Hlavě taková jako potok, popel, potopení, popelník... Pokaždé na celý text pouštět KMP by bylo neefektivní. V dalším článku se tedy podíváme, jak se vyhledá více jehel v jednom seně mnohem efektivněji.

V další lekci, Binární vyhledávací strom (BST), si popíšeme algoritmus vyhledávací struktury binárního vyhledávacího stromu (BST) s obrázky a teorií.


 

Předchozí článek
Interpolační vyhledávání
Všechny články v sekci
Vyhledávací algoritmy
Přeskočit článek
(nedoporučujeme)
Binární vyhledávací strom (BST)
Článek pro vás napsal Ondřej Michálek
Avatar
Uživatelské hodnocení:
13 hlasů
Autor se věnuje teoretické informatice. Ve svých volných chvílích nepohrdne šálkem dobrého čaje, kaligrafickým brkem a foukací harmonice.
Aktivity