1. Podstawowe pojęcia dotyczące przetwarzania tekstów 2. Podstawowe operacje na łańcuchach znakowych 3. Naiwne wyszukiwanie wzorca w tekście 4. Zliczanie słów w łańcuchu 5. Dzielenie łańcucha na słowa
W algorytmach tekstowych będziemy stosowali następujące ustalenia i oznaczenia: Alfabetem (ang. alphabet) będziemy nazywali skończony zbiór symboli (ang. the set of symbols) zwykle alfabet jest zbiorem liter, znaków, cyfr, jednakże w przypadku uogólnionym może to być dowolny zbiór obiektów, które można w jakiś sposób sklasyfikować. Skończone ciągi symboli alfabetu nazwiemy łańcuchami (ang. strings). Czasami w tym znaczeniu używa się terminów słowa (ang. words) lub teksty (ang. texts). Przez pusty łańcuch (ang. empty string) rozumiemy łańcuch nie zawierający ani jednego znaku. s[i] oznacza i-ty znak łańcucha s. Umówmy się, iż indeksy w łańcuchach rozpoczynają się od 0. W języku Pascal oraz Basic indeksy startują od wartości 1. Należy to uwzględniać w programach. My wybieramy wartość 0, robiąc ukłon w stronę języka C++, który jest o wiele bardziej popularny niż Pascal i Basic. s oznacza długość łańcucha (ang. string length), czyli liczbę przechowywanych w nim aktualnie znaków. Łańcuch pusty ma długość 0.
s[i : j] oznacza fragment łańcucha (ang substring) zawierający kolejne znaki s[i] s[i + 1] s[i + 2]... s[j - 1]. Znak s[j] nie należy do tej sekwencji. Na przykład, jeśli s = "ALA MA BOCIANA", to s[4 : 9] = "MA BO". Taki fragment łańcucha s będziemy nazywali oknem (ang. string window). Długość podłańcucha wyliczamy ze wzoru: s[i : j] = j - i Podłańcuch s[i : i] jest łańcuchem pustym posiada długość 0, co wynika bezpośrednio z podanego powyżej wzoru. s = t równość dwóch łańcuchów oznacza, iż są one tej samej długości oraz posiadają identyczne znaki na tych samych pozycjach. s[i : i + t ] = t oznacza to, iż fragment łańcucha s od pozycji i-tej do i + t - 1 zawiera dokładnie te same znaki, co łańcuch t. Na przykład, jeśli s = "ALA MA BOCIANA" i t = "MA", to zachodzi s[4 : 6] = t (zwróć uwagę, iż znak s[6] nie jest częścią podłańcucha s[4...6]).
Prefiks (ang. prefix) łańcucha s jest łańcuchem zbudowanym z k początkowych znaków: Pref(s) = s[0 : k] Sufiks (ang. suffix) łańcucha s jest łańcuchem zbudowanym z k końcowych znaków: Suff(s) = s[n - k : n] Prefiks i sufiks mogą być puste, tzn. mogą nie zawierać żadnego znaku. Mówimy, że prefiks lub sufiks jest właściwy (ang. proper), jeśli nie obejmuje całego łańcucha s.
Maksymalny prefiks właściwy (ang. maximal proper prefix) obejmuje wszystkie znaki łańcucha s za wyjątkiem ostatniego. Podobnie maksymalny sufiks właściwy (ang. maximal proper sufix)obejmuje wszystkie znaki łańcucha za wyjątkiem pierwszego: max Pref(s) = s[0 : s - 1] max Suff(s) = s[1 : s ] Jeśli istnieje prefiks s, który jest równy sufiksowi s, to mówimy, iż tworzą one tzw. prefikso-sufiks (ang. border):
Uwaga: prefiks i sufiks w powyższym układzie mogą się wzajemnie częściowo pokrywać (a nie sugeruje tego rysunek). Na przykład maksymalny prefikso-sufiks dla tekstu: Dwie środkowe litery ab pokrywają się w prefiksie i sufiksie. Przez maksymalny prefikso-sufiks łańcucha s rozumiemy najdłuższy, właściwy prefiks i sufiks s, które są sobie równe. Jeśli dany łańcuch s posiada prefikso-sufiks o długości k, to okresem (ang. period) nazywamy taką liczbę całkowitą d, że zachodzi warunek: s[0 : k] = s[d : n]
Graficznie wygląda to tak: Okres można bardzo prosto obliczyć wg wzoru: d = s - Border(s) Maksymalny prefikso-sufiks łańcucha s oznacza najdłuższy prefiks i sufiks tego łańcucha, które są sobie równe.
Deklaracja zmiennych znakowych i dostęp do przechowywanych znaków We współczesnych językach programowania znaki są podstawowym typem danych. W pamięci komputera znak jest przechowywany w postaci liczby, którą nazywamy kodem znaku (ang. character code). Każdy znak posiada swój własny kod. Aby różne urządzenia systemu komputerowego mogły w ten sam sposób interpretować kody znaków, opracowano kilka standardów kodowania liter. Poniżej przedstawiamy wybrane dwa: ASCII American Standard Code for Information Interchange Amerykański Standardowy Kod do Wymiany Informacji.
Znaki są zapamiętywane w postaci 8 bitowych kodów (pierwotnie było to 7 bitów, lecz później standard ASCII został poszerzony na 8 bitów, w których znalazły się różne znaki narodowe). Taki sposób reprezentacji znaków jest dzisiaj bardzo wygodny, ponieważ podstawowa komórka pamięci komputera IBM przechowuje właśnie 8 bitów. Dzięki temu znaki dobrze mieszczą się w pamięci. 8-bitowy kod pozwala przedstawić 256 różnych wartości i tylko tyle może być zdefiniowane znaków w kodzie ASCII. Pierwsza połówka zbioru kodów od 0 do 127 jest zdefiniowana na stałe i raczej nigdy nie jest modyfikowana. Jest to tzw. podstawowy zestaw znaków ASCII. Druga połówka od 128 do 255 zawiera znaki narodowe, które w różny sposób mogą być przydzielane rozszerzonym kodom ASCII. Z tego właśnie powodu powstały różne strony kodowe. Na przykład konsola znakowa stosuje kodowanie LATIN II. Natomiast system Windows stosuje Windows 1250. Niestety, w obu systemach polskie literki posiadają różne kody. Dlatego wyświetlenie przygotowanego w Windows polskiego tekstu w konsoli znakowej powoduje, iż polskie znaki Windows 1250 zostają źle zinterpretowane w konsoli LATIN II.
Poniższy program demonstruje niekompatybilność kodowania znaków w Windows ze znakami wyświetlanymi w oknie konsoli znakowej.
Unicode Znaki są zapamiętywane w postaci kodów 16-bitowych. Dzięki temu rozwiązaniu liczba możliwych do przedstawienia znaków rośnie do 65536. Pierwsze 256 kodów jest zwykle kompatybilne z kodami ASCII. Kody powyżej 256 tworzą banki znaków, w których znajdują się wszystkie znaki narodowe, arabskie, hebrajskie, matematyczne itp. Poniższa tabelka prezentuje nazwy typów znakowych w wybranych przez nas językach programowania:
Zmienne znakowe deklarujemy w identyczny sposób jak zmienne innych typów: Tak zadeklarowana zmienna c może przechowywać jeden znak ASCII, a zmienna wc jeden znak Unicode.Zmienne znakowe mogą również być zadeklarowane jako tablice znaków. W przypadku tablicy znakowej mamy dostęp do poszczególnych znaków za pomocą indeksu w klamerkach kwadratowych. Wyjątkiem jest język Basic, gdzie zmienna znakowa jest traktowana jako spójna całość i dostęp do poszczególnych znaków uzyskujemy poprzez polecenie Mid (przy zapisie do zmiennej) oraz funkcję Mid (przy odczycie ze zmiennej). Pierwszy znak posiada zawsze indeks równy 1.
Program tworzy trzyznakową tablicę i wpisuje do niej wyraz ALO. Następnie literki są wypisywane w kierunku odwrotnym:
Oprócz zwykłych tablic znakowych (ang. character tables), języki Pascal, C++ oraz Basic udostępniają tzw. łańcuchy znakowe (ang. character strings). Są to tablice dynamiczne, które mogą przechowywać ciągi znaków o różnych długościach (łańcuchy automatycznie dopasowują się do rozmiaru przechowywanego tekstu tablice znakowe natomiast nie posiadają takich cech, programista musi o to zadbać sam): Koniec łańcucha znakowego znaczony jest kodem 0. Znak o tym kodzie nie jest wliczany do łańcucha. Również nie powinieneś tego znaku umieszczać wewnątrz łańcucha, gdyż może to spowodować nieprawidłowe działanie wielu funkcji i procedur tekstowych.
W języku Pascal i Basic indeksy znaków w łańcuchu rozpoczynają się od 1, a w języku C++ od 0. W algorytmach tekstowych musimy wziąć na to poprawkę. W języku Basic łańcuchy Wstring są wskaźnikami do obszaru pamięci przechowującego właściwe znaki. Dlatego do wskaźnika musi być przypisywany adres zarezerwowanego obszaru, w którym będą umieszczane znaki Unicode. Dostęp do danych następuje poprzez operator *, podobnie jak w języku C++. Liczbę znaków przechowywanych w łańcuchu tekstowym otrzymamy przy pomocy następujących funkcji:
Programy demonstrują sposoby deklarowania zmiennej łańcuchowej oraz dostępu do znaków zawartych w łańcuchu.
Kod znaku Przy przetwarzaniu tekstu często musimy odczytywać kody znaków zawartych w zmiennej znakowej lub zamieniać kody na odpowiadające im znaki na przykład w celu umieszczenia ich w tekście. W każdym z wybranych przez nas języków programowania istnieją odpowiednie do tego zadania narzędzia. Język C++ traktuje znaki jak liczby całkowite (unsigned char bez znaku, char ze znakiem). Nie ma zatem zwykle potrzeby dokonywać konwersji znakowych. Wyjątek stanowi przesyłanie znaków do strumieni musimy dokonać konwersji kodu, aby w strumieniu został zapisany znak, a nie jego kod jako liczba całkowita.
Program odczytuje z klawiatury łańcuch znaków do zmiennej łańcuchowej, a następnie wypisuje kolejne literki wraz z ich kodami ASCII.
Konkatencja łączenie łańcuchów Często zdarza się, iż chcemy połączyć dwa lub więcej tekstów w jeden tekst. Operacja łączenia tekstu nosi nazwę konkatencji (ang. concatenation). W przypadku łańcuchów jest to bardzo proste: Wstawianie znaku/ciągu znaków do łańcucha Podmiana znaku w łańcuchu jest operacją prostą. Po prostu odwołujemy się do wybranego elementu w zmiennej łańcuchowej może nią być również tablica znaków i zapisujemy go nową zawartością:
Wstawienie znaku wymaga przesunięcia części znaków w zmiennej łańcuchowej, aby udostępnić miejsce na wstawiany znak. Operacja wstawiania znaku lub łańcucha znaków jest obsługiwana przez funkcje biblioteczne:
Program umieszcza w łańcuchu tekstowym zdanie "Rudy lisek", a następnie wstawia łańcuch ", szybki" po słowie "Rudy".
Usuwanie znaku z łańcucha Usunięcie znaku z łańcucha/tablicy polega na przesunięciu wszystkich znaków następujących za znakiem usuwanym o jedną pozycję w lewo. W ten sposób znak zostaje nadpisany znakiem sąsiadującym z prawej strony w efekcie zniknie on z łańcucha. Dla łańcuchów znakowych mamy w każdym z wybranych języków programowania gotowe funkcje usuwania znaku lub fragmentu łańcucha.
Program umieszcza w łańcuchu tekstowym zdanie "Rakieta kosmiczna", a następnie usuwa z wyrazu "kosmiczna" literkę 's'.
Zastępowanie fragmentu łańcucha innym łańcuchem tekstowym Zastępując fragment łańcucha innym łańcuchem możemy wykorzystać funkcje usuwania i wstawiania tekstu najpierw usuwamy zastępowany fragment z łańcucha, a następnie wstawiamy na jego miejsce nowy fragment. W języku C++ możemy wykorzystać funkcję składową replace() klasy string, która wykonuje dokładnie to samo zadanie. W języku FreeBasic wykorzystujemy funkcje Left() i Right().
Program umieszcza w łańcuchu tekstowym zdanie "Zielone, stare drzewko", a następnie wymienia wyraz "stare" na "wysokie".
Porównywanie łańcuchów Łańcuchy tekstowe możemy porównywać przy pomocy typowych operatorów porównań. Jednakże obowiązuje tutaj kilka zasad. Dwa łańcuchy są równe, jeśli składają się z takiej samej liczby znaków oraz zgadzają się ze sobą na każdej pozycji znakowej. Jeśli dwa łańcuchy mają różną długość, lecz krótszy łańcuch zawiera te same początkowe znaki c łańcuch dłuższy, to krótszy jest mniejszy, a dłuższy jest większy. W dwóch łańcuchach porównywane są znaki na odpowiadających sobie pozycjach znakowych aż do napotkania niezgodności kodów. Wtedy mniejszy łańcuch jest tym, który posiada na porównywanej pozycji znak o mniejszym kodzie. Na przykład: "ALA" > "AKACJA" kod literki L jest większy od kodu literki K. Taki sposób porównywania nosi nazwę leksykograficznego. Zwróć uwagę, iż w ten sposób nie można porównywać łańcuchów zawierających polskie litery poprawny będzie jedynie test na równość łańcuchów.
Pozostałe operacje tekstowe W poniższej tabelce zebraliśmy często wykonywane, typowe operacje na tekstach. Z operacji tych będziemy intensywnie korzystać w przykładach programowych do omawianych algorytmów tekstowych.
W łańcuchu znakowym s znaleźć wszystkie wystąpienia wzorca p. Problem Wyszukiwania Wzorca WW (ang. pattern matching) to jeden z podstawowych problemów tekstowych, który intensywnie badali wybitni informatycy. Rozwiązaniem jest wskazanie w ciągu swszystkich pozycji i takich, że zachodzi równość: s[i : i + p ] = p Oznacza to, iż wzorzec p jest fragmentem łańcucha s występującym na pozycji i-tej. Algorytm N naiwny ustawia okno o długości wzorca p na pierwszej pozycji w łańcuchu s. Następnie sprawdza, czy zawartość tego okna jest równa wzorcowi p. Jeśli tak, pozycja okna jest zwracana jako wynik, po czym okno przesuwa się o jedną pozycję w prawo i cała procedura powtarza się. Algorytm kończymy, gdy okno wyjdzie poza koniec łańcucha. Klasa pesymistycznej złożoności obliczeniowej algorytmu N jest równa O(n m), gdzie n oznacza liczbę znaków tekstu, a m liczbę znaków wzorca. Jednakże w typowych warunkach algorytm pracuje w czasie O(n), ponieważ zwykle wystarczy porównanie kilku początkowych znaków okna z wzorcem, aby stwierdzić, iż są one niezgodne.
Algorytm naiwny wyszukiwania wzorca w łańcuchu tekstowym
Lista kroków: K01: n s K02: m p ; obliczamy długość łańcucha s ; obliczamy długość wzorca p K03: Dla i = 0,1,... n - m wykonuj K04 K04: Jeśli p = s[i : i + m], to pisz i ; okno zawiera wzorzec? K05: Zakończ
Program generuje 80 znakowy łańcuch zbudowany z pseudolosowych kombinacji liter A, B i C. Następnie losuje 3 literowy wzorzec z tych samych liter i algorytmem naiwnym wyszukuje wszystkie wystąpienia wzorca w łańcuchu. Pozycję wzorca zaznacza znakiem ^.
Algorytm N możemy nieco usprawnić wykorzystując ideę wartowników. Na końcu łańcucha oraz wzorca umieszczamy dwa różne znaki. Zapobiegną one wyjściu poza obszar łańcucha przy testowaniu znaków w oknie i we wzorcu. Dzięki nim odpadnie sprawdzanie zakresu indeksów w oknie algorytm wykona mniej operacji. Algorytm naiwny wyszukiwania wzorca z wartownikami
K01: n s K02: m p K03: s s + 'X' K04: p p + 'Y' K05: K06: j 0 K07: Dla i = 0,1,...,n - m wykonuj K06...K08 Dopóki s[i + j] = p[j], wykonuj j j + 1 K08: Jeśli j = m, to pisz i K09: Usuń ostatni znak z s K10: Usuń ostatni znak z p K11: Zakończ Lista kroków: ; obliczamy długość łańcucha s ; obliczamy długość wzorca p ; na końcu łańcucha umieszczamy wartownika ; na końcu wzorca umieszczamy innego wartownika ; pozycje okna ; pozycja w oknie i we wzorcu ; szukamy pierwszego różnego znaku okna i wzorca ; sprawdzamy, czy cały wzorzec wystąpił w oknie ; pozbywamy się wartownika z łańcucha ; pozbywamy się wartownika ze wzorca
Program generuje 80 znakowy łańcuch zbudowany z pseudolosowych kombinacji liter A, B i C. Następnie losuje 3 literowy wzorzec z tych samych liter i algorytmem naiwnym wyszukuje wszystkie wystąpienia wzorca w łańcuchu. Pozycję wzorca zaznacza znakiem ^.
W łańcuchu s wyznaczyć liczbę wszystkich słów. Zadanie zliczenia słów (ang. words counting) sprowadza się do wyszukiwania liniowego znaków. Na początku pracy algorytmu ustawiamy znacznik słów t na false. Wartość true tego znacznika oznacza przetwarzanie znaków słowa. Licznik słów ls zerujemy. Teraz w pętli przeglądamy kolejne znaki łańcucha s. Jeśli napotkanym znakiem jest znak litery lub cyfry, to sprawdzamy stan znacznika t. Jeśli jest on ustawiony na false, to znaczy, iż napotkaliśmy w tekście początek słowa. W takim przypadku ustawiamy t na true i zwiększamy o 1 licznik ls. Jeśli znacznik t jest już ustawiony na true, to napotkaliśmy kolejną literę już zliczonego słowa nic nie robimy. Jeśli napotkamy inny znak, to traktujemy go jako separator i znacznik t zawsze zerujemy. Po przeglądnięciu wszystkich znaków łańcucha s w zmiennej ls mamy liczbę słów.
Algorytm zliczania wyrazów
Lista kroków: K01: ls 0 ; zerujemy licznik słów K02: t false ; zerujemy znacznik słowa K03: Dla i = 0,1,..., s - 1 wykonuj K04...K09 ; przeglądamy znaki łańcucha s K04: Jeśli s[i] = cyfra_lub_litera, idź do K07 K05: t false ; zerujemy znacznik słowa K06: Następny obieg pętli K03 K07: Jeśli t = true, następny obieg pętli K03 ; słowo już zliczone K08: t true ; ustawiamy znacznik słowa K09: ls ls + 1 ; zliczamy słowo K10: Zakończ z wynikiem ls
Program odczytuje wiersz znaków, a następnie zlicza występujące w nim wyrazy i wypisuje ich ilość.
Podzielić dany łańcuch tekstowy s na zawarte w nim słowa. Operacja podziału łańcucha znaków na zawarte w nim słowa (ang. splitting into words) jest często wykonywana jako wstęp do różnych algorytmów tekstowych. W naszym prostym algorytmie w pętli będą wydobywane kolejne słowa z łańcucha s. Słowa te mogą być następnie odpowiednio przetworzone przez inny algorytm. Zasada pracy jest następująca: Tworzymy pusty łańcuch ss, w którym będą gromadzone znaki wydobywanego słowa. Na końcu łańcucha s umieszczamy wartownika dowolny znak nie będący literą ani cyfrą może to być np. spacja. Wartownik zagwarantuje nam przetworzenie wszystkich słów łańcucha s. Następnie przeglądamy kolejne znaki łańcucha s. Jeśli przeglądany znak jest literą lub cyfrą, to dołączamy go do łańcucha ss. W przeciwnym razie, jeśli łańcuch ss zawiera znaki, przetwarzamy je jako słowo, po czym łańcuch ss zerujemy będzie on gotowy na przyjęcie nowych znaków dla kolejnego słowa.
Algorytm podziału łańcucha na słowa
Lista kroków: K01: ss "" ; zerujemy łańcuch słowa K02: s s + wartownik ; na końcu łańcucha s umieszczamy wartownika K03: Dla i = 0,1,..., s - 1 wykonuj K04...K09 K04: Jeśli s[i] = cyfra_lub_litera, to idź do K09 K05: Jeśli ss = "", to następny obieg pętli K03 ; przeglądamy kolejne znaki łańcucha s ; litery i cyfry dołączamy do łańcucha ss ; sprawdzamy, czy ss zawiera jakieś słowo K06: Przetwarzaj ss ; przetwarzamy wydobyte słowo K07: ss "" ; zerujemy ss dla następnego słowa K08: Następny obieg pętli K03 K09: ss ss + s[i] ; dołączamy znak s[i] do łańcucha ss K10: Zakończ
Program odczytuje wiersz znaków i wypisuje zawarte w nim słowa w klamerkach.