Zaawansowane algorytmy i struktury danych/Wykład 2
Zaawansowane algorytmy tekstowe II
Poprzednie algorytmy dokonywały na tekstach wejściowych jedynie operacji sprawdzania symboli na równość. Załóżmy teraz, że alfabet jest liniowo uporządkowany. Pokażemy, że porównywanie symboli w sensie porządku liniowego można istotnie wykorzystać w algorytmach tekstowych. Porządek liniowy na symbolach implikuje {\em porządek leksykograficzny} na słowach, na przykład:
String-matching w pamięci stałej dla specjalnych wzorów
Oznaczmy przez maksymalny leksykograficznie sufiks słowa . Słowo nazwiemy specjalnym, gdy .
Przykład
Dlaczego słowa o tej własności są interesujące? Większość szybkich algorytmów szukania podsłów korzysta z okresów prefiksów słowa. Liczenie tych okresów w ogólnym przypadku jest wąskim gardłem w projekcie algorytmu. Natomiast dla słow specjalnych liczenie okresów jest trywialne.
Jeśli jest specjalny, to okres każdego prefiksu słowa można policzyć następującym naiwnym algorytmem:
Algorytm Funkcja Naiwne-Liczenie-Okresu (j)
;
for to do
if then ;
return ;
Przykład
są:
a | b | a | a | b | a | a | b | a | a | b | a | a | b | a | a | b | a | a |
1 | 2 | 2 | 4 | 5 | 5 | 7 | 8 | 8 | 10 | 11 | 11 | 13 | 14 | 14 | 16 | 17 | 17 | 19 |
Zatem Naiwne-Liczenie-Okresu, dla , wynik całkowicie niepoprawny. Poprawność algorytmu jest wyjaśniona na rysunku. Korzystamy z prostej własności, że prefiks specjalnego słowa jest też specjalny.

Rysunek 1: Załóżmy, że w algorytmie Naiwne-Liczenie-Okresu . Niech , . Ponieważ jest prefiksem słowa specjalnego , zatem . Gdyby to wtedy, ze względu na dwie okresowości, jest właściwym podsłowem słowa oraz . Zaprzecza to założeniu, że jest specjalne. Zatem .
Opiszemy teraz program szukania wzorca w slowie i, zakładając że jest specjalne. Program wczytuje dwa teksty, pierwszy z nich jest specjalny: pamiętamy w tablicy , w tablicy . Program wypisuje wszystkie wystapienia w , tzn. wszystkie takie pozycje , że . Zapisujemy program w języku C++.
Algorytm Specjalny-String-Matching
#include <iostream.h>
#include string.h
int , , ;
void przesun();
main() {
char[] x,y; cin>>x>>y; mstrlen(x); nstrlen(y);
while (i n-m-1)
{ if (jm) {cout<<i<<endl; przesun();};
else if (x[j]y[i+j]) {jj+1; if (j1) (x[j-1]x[j-1-p]) pj;};
else przesun(); } }
void przesun()
{ if (j-1<2p) {ii+p; j0;} else {jj-p; ii+p;}
}
Program jest wstępem do programu szukającego dowolne podsłowo, niekoniecznie o własności bycia specjalnym. Podstawowym niezmiennikiem w programie przed każdym wykonaniem i po każdym zakończeniu pętli while jest: (A)\ , (B)\ Program wypisał wszystkie wcześniejsze wystąpienia , (C)\ jest okresem slowa
Algorytm działa w czasie liniowym. Można to udowodnić obserwując zmiany wartości . Zauważmy, że wartość ta nie zmniejsza się, a w wypadku pozytywnego testu zwiększa się co najmniej o 1. Jednocześnie .
String-matching w pamięci stałej dla dowolnych wzorców
Algorytym Specjalny-String-Matching można łatwo zmodyfikować tak, aby znajdował on wystąpienia dowolnego słowa (niekoniecznie specjalnego) w czasie liniowym i stałej pamięci. Niech , gdzie jest leksykograficzne maksymalnym sufiksem . Oznaczmy . Technicznie informacja o rozkładzie sprowadza się do pamiętania .
Własność rozkładu. Niech będzie rozkładem jak wyżej opisany. Wtedy
słowo występuje tylko raz w słowie .
Jeśli są początkami wystąpień oraz , to
na pozycji nie kończy się wystąpienie .
Z powyższego faktu wynika stosunkowo prosty algorytm szukania w czasie liniowym i pamięci
stałej. Algorytm ten jest modyfikacją algorytmu Specjalny-String-Matching, w którym rolę pełni .
Algorytm String-matching w pamięci stałej
Niech będzie leksykograficznie maksymalnym sufiksem ;
Liczymy algorytmem Specjalny-String-Matching kolejne wystąpienia w ;
Dla każdego wystąpienia niech będzie wystąpieniem poprzednim;
jeśli , sprawdź czy występuje na lewo od pozycji ;
(sprawdzanie to wykonujemy w sposób naiwny)
jeśli występuje, wypisz kolejne wystąpienie całego wzorca .
Pozostawiamy bardziej precyzyjny zapis algorytmu jako ćwiczenie.
W ten sposób pokazaliśmy, że problem szukania słowa w słowie można rozwiązać w czasie liniowym i pamięci (dodatkowej) stałej, jeśli znamy początkową pozycję leksykograficznie maksymalnego sufiksu słowa .
Liczenie maksymalnego sufiksu w pamięci stałej
W algorytmie szukania wzorca w pamięci stałej potrzebna jest pozycja , od której zaczyna się maksymalny sufiks. Pokażemy teraz, jak ją znajdować w czasie liniowym i w pamięci stałej. Kluczem do tego jest liczenie czegoś więcej: dla każdego prefiksu liczymy maksymalny sufiks, jak również dodatkowo jego okres. To właśnie liczenie okresu daje efektywność, chociaż na końcu ten okres nie jest nam potrzebny. Przekształcimy najpierw algorytm Naiwne-Liczenie-Okresu na algorytm liczący długość najdłuższego specjalnego prefiksu włącznie z jego okresem.
{algorytm| funkcja Najdłuższy-Specjalny-Prefiks(x)|fun_najdl_spec_pref|
;
for to do
if \textbf{then}
'else if then
return ;
return ;
}}
Skorzystamy z algorytmu Najdłuższy-Specjalny-Prefiks. Funkcja Maksymalny-Sufiks liczy początkową pozycję i okres maksymalnego sufiksu.
Algorytm funkcja Maksymalny-Sufiks(x)
;
repeat
Najdłuższy-Specjalny-Prefiks;
if then return
else ;
forever
Możemy przepisać algorytm Maksymalny-Sufiks tak, aby nie wywoływał on funkcji Najdłuższy-Specjalny-Prefiks, wpisując tę funkcję do algorytmu. Arytmetyczna funkcja może być usunięta i zastąpiona przez operacje dodawania i odejmowania bez zmiany asymptotycznej złożoności.
Algorytm Maksymalny-Sufiks wykonuje co najwyżej porównań symboli. Uzasadnienie pozostawiamy jako ćwiczenie.
Algorytm funkcja} Maksymalny-Sufiks(x)
; ; ;
while () do
;
if ( then
else if ( then begin
; ;
else
; ; ;
return ;
Kodowanie prefiksowe: drzewa i kody Huffmana
Zbiór słów jest prefiksowy, gdy żadne słowo nie jest prefiksem drugiego. Taki zbiór słów odpowiada drzewu, którego ścieżki etykietowane są symbolami. W przypadku binarnym możemy przyjąć, że krawędź w lewo jest etykietowana zerem, a w prawo jedynką. Przez kodowanie rozumiemy funkcję , która każdemu symbolowi przyporządkowuje niepusty ciąg binarny . Całe słowo zostanie zakodowane na słowo (każda litera jest zakodowana niezależnie i kody są skonkatenowane). Kod jest prefiksowy gdy zbiór kodów symboli jest prefiksowy. Rozważamy następujący problem:
Optymalne kodowanie prefiksowe
Dla danego słowa znaleźć binarne kodowanie prefiksowe takie, że ma minimalną długość.
Przykład
Niech . Liczby wystąpień symboli w słowie są:
Optymalnym kodowaniem jest zostaje zakodowane na , ciąg binarny długości . Optymalne drzewo binarne odpowiadające optymalnemu kodowi prefiksowemu jest pokazane na rysunku.

Rysunek 2:Drzewo Huffmana kodujące optymalnie symbole z wagami odpowiednio . Liczby w wewnętrznych węzłach są sumą wag w liściach odpowiadającego poddrzewa. Koszt całkowity kodowania jest ważoną sumą długości ścieżek do liści, jest również sumą wartości w węzłach wewnętrznych:
Długość tekstu jest równa ważonej sumie długości ścieżek, ważonej w tym sensie, że długość ścieżki do danego liścia jest przemnożona przez wagę tego liścia. W przykładzie jest to suma: .
Niech będzie liczbą różnych symboli w , będzie liczbą wystąpień -tego symbolu. Problem możemy rozwiązać, stosując algorytm dla problemu Optymalne Sklejanie Par dla ciągu . Musimy algorytm zmodyfikować tak, aby nie tylko sklejał pary, ale również tworzył lokalnie drzewo. Inaczej mówiąc, algorytm w momencie sklejania elementów , w element tworzy również dowiązania, staje się lewym synem , natomiast staje się prawym synem.
Algorytm Huffmana (nieformalny opis)
Konfiguracje pośrednie algorytmu to zbiory drzew,
początkowo każdy pojedyńczy element z wagą jest pojedyńczym drzewem.
Korzeń każdego drzewa reprezentuje sklejenie jego wszystkich liści.
Za każdym razem sklejamy dwa korzenie drzew o minimalnej wadze.
Drzewo, które algorytm generuje, nazywamy drzewem Huffmana.
Pozostawiamy jako ćwiczenie przerobienie algorytmu Optymalne-Sklejanie-Par na algorytm liczenia kodów i drzew Huffmana.
Z analizy algorytmu Optymalne Sklejanie Par wynika, że problem optymalnych binarnych kodów prefiksowych można rozwiązać w czasie , a jeśli wagi są posortowane, to w czasie liniowym.
Kodowanie Huffmana słowami -arnymi.
Pozostawiamy jako ćwiczenie podobny problem, ale gdy kodujemy w alfabecie -arnym, mamy teraz symbole . W algorytmie jednorazowo możemy sklejać więcej niż dwa elementy.
Kodowanie prefiksowe z symbolami kodowymi nierównej długości
Problem robi się skomplikowany, gdy długość symbolu 0 jest 1, a długość symbolu 1 jest , gdzie jest pewną stałą (jest to problem tzw. lopsided trees). Inaczej mówiąc, szukamy takiego optymalnego drzewa, w którym ważona suma ścieżek jest minimalna, ale długość krawędzi na lewo wynosi 1, a długość krawędzi na prawo wynosi . Pozostawiamy jako ćwiczenie znalezienie efektywnego algorytmu dla małych ( lub ). Dla dowolnego (będącego częścią wejścia) i dowolnych wag jest to zbyt trudne, nie znamy algorytmu wielomianowego. Dla ustalonego istnieje algorytm wielomianowy, którego stopień zależy od . Natomiast pozostawiamy jako ćwiczenie przypadek, gdy jest dowolne, ale wszystkie wagi są równe.
Kodowanie prefiksowe z kodami o ograniczonej długości
Innym ciekawym problemem jest też skonstruowanie optymalnego kodu prefiksowego, w którym wszystkie słowa kodowe są ograniczone przez pewną zadaną liczbę . Inaczej mówiąc, ograniczamy z góry wysokość drzewa Huffmana. Istnieją wtedy algorytmy wielomianowe dla tego problemu, stopień wielomianu jest niezależny od .