Przeskocz do treści

Delta mi!

Zliczamy podciągi

Tomasz Idziaszek

o artykule ...

  • Publikacja w Delcie: lipiec 2020
  • Publikacja elektroniczna: 1 lipca 2020
  • Autor: Tomasz Idziaszek
    Afiliacja: Informatyk, prowadzi stronę internetową algonotes.com
  • Wersja do druku [application/pdf]: (420 KB)

Zaczniemy od takiego zadania: dla danego |n -literowego słowa s chcemy znaleźć liczbę jego różnych podciągów. Innymi słowy, chcemy odpowiedzieć na pytanie, ile różnych słów możemy uzyskać poprzez wykreślanie niektórych liter ze słowa s: Dla przykładu rozważmy słowo ABAA. Ma ono dokładnie 10 różnych podciągów: słowo puste, A, B, AA, AB, BA, AAA, ABA, BAA oraz ABAA. Dla uproszczenia będziemy rozważać słowa złożone z liter A i B, ale nasze rozwiązania będą działać dla dowolnego A -literowego alfabetu. |ABA;BAA

obrazek

Rys. 1. Konstrukcja drzewa trie dla kolejnych prefiksów słowa ABAA. Krawędzie dodawane w kolejnych krokach zaznaczono kolorem; wynikowe drzewo ma |w węzłów

Rys. 1. Konstrukcja drzewa trie dla kolejnych prefiksów słowa ABAA. Krawędzie dodawane w kolejnych krokach zaznaczono kolorem; wynikowe drzewo ma |w węzłów

Zaczniemy od algorytmu, który będzie konstruował możliwe do uzyskania podciągi dla kolejnych prefiksów słowa |s (Rys. 1). Wygodnie jest trzymać te podciągi na drzewie, w którym krawędzie są etykietowane literami, a każda ścieżka od korzenia do dowolnego węzła odpowiada jednemu podciągowi (czyli na tzw. drzewie trie). Tak więc liczba węzłów |w tego drzewa będzie oznaczała liczbę różnych podciągów (włączając korzeń drzewa odpowiadający podciągowi pustemu).

Przypuśćmy, że skonstruowaliśmy drzewo dla prefiksu p | = s1s2...si−1 i chcemy dodać kolejną literę | c = si, aby uzyskać drzewo dla prefiksu |pc = s1s2 ...si−1si. Wszystkie podciągi z pc będą albo podciągami występującymi już w p, albo tymi samymi podciągami rozszerzonymi o literę c. Tak więc, gdy w drzewie dla |p do każdego węzła dodamy krawędź o etykiecie | c, uzyskamy drzewo dla | pc. Może się zdarzyć, że w niektórych węzłach taka krawędź już istniała - oznacza to, że odpowiadający podciąg już występował w p, zatem nie należy go dodawać ponownie.

Taki algorytm, choć poprawny, ma złożoność wykładniczą względem |n. Istotnie, słowo złożone z n różnych liter ma |2n podciągów. Ale dla dwuliterowego alfabetu nie jest dużo lepiej: słowo | n~2 (AB) (AB powtórzone  n |2 razy) ma więcej niż  n~2 2 podciągów (w szczególności zawiera wszystkie możliwe |n2 -literowe słowa jako podciągi).

Okazuje się, że aby znaleźć liczbę podciągów, nie musimy trzymać w pamięci całego drzewa. Niech |wc oznacza liczbę węzłów drzewa, z których wychodzi krawędź z literą c (jest to też po prostu liczba krawędzi z etykietą |c). Gdy dodajemy literę c, dodajemy nową krawędź do dokładnie |w węzłów, a zatem zwiększamy sumaryczną liczbę węzłów (tym samym liczbę podciągów) z |w na 2w | Ponadto zwiększamy liczbę krawędzi z etykietą c z |w do w

Wystarczy więc, że będziemy trzymali w pamięci wektor |(w, początkowo równy (1,0,0). Gdy dodajemy nową literę A, to zastępujemy ten wektor przez |(2w a dla litery B przez wektor |(2w Ponieważ zawsze zmieniamy tylko dwie współrzędne wektora, to dostajemy rozwiązanie o złożoności czasowej O(n

obrazek

W konkursach programistycznych panuje moda na utrudnianie zadań z ciągami przez rozważanie wielu zapytań o fragmenty słowa. Spróbujmy zmierzyć się z taką wersją zadania. Dostajemy zatem |q zapytań, każde postaci |(l,r), o liczbę różnych podciągów dla fragmentu slsl+1...sr. Przy czym q jest duże, więc nie możemy sobie pozwolić na uruchomienie algorytmu liniowego dla każdego zapytania oddzielnie.

W algorytmie dla jednego zapytania utrzymujemy wektor (w, Ponieważ współczynniki nowego wektora (po dodaniu litery) są kombinacjami liniowymi współczynników oryginalnego wektora, więc możemy zamianę wektora zastąpić mnożeniem go z prawej strony przez jedną z poniższych macierzy:

⎛210⎞⎛201⎞ A=⎜−100⎟,MB=⎜010⎟. M ⎝001⎠⎝−100⎠

Nadużywając nieco notacji, oznaczmy przez i M macierz odpowiadającą i-tej literze słowa s, czyli =M. M isi Zaczynając od wektora |(1,0, 0), mnożymy go przez kolejne wartości i, M uzyskując na końcu wektor (w, dla całego słowa. Pomnożywszy go skalarnie przez (1,0,0), dostajemy wartość |w, czyli szukaną liczbę podciągów. Ponieważ macierze są rozmiarów (A i umiemy pomnożyć dwie takie macierze w czasie O(A to cały algorytm, sprowadzający się do obliczenia wzoru

T 1M2⋯Mn−1Mn(1,0,0), (1,0,0) M

zadziała w czasie O(nA3). Jest to istotnie gorsza złożoność, niż mieliśmy wcześniej, ale przedstawienie obliczeń w postaci macierzowej daje nam większą elastyczność. Obliczenie odpowiedzi dla fragmentu s | s ...s ll+1 r wymaga bowiem przemnożenia macierzy T lMl+1⋯Mr−1Mr(1,0,0). (1,0,0)M Ponieważ mnożenie macierzy jest operacją łączną, więc w tym celu możemy użyć struktury danych zwanej drzewem przedziałowym. W liściach drzewa będziemy trzymać macierze ,M,...,M, |M 12n a w węzłach wewnętrznych przemnożone macierze z dzieci. Dzięki temu będziemy mogli odpytywać o iloczyn macierzy dla dowolnego fragmentu w czasie O(A3 gdyż dzielimy go na O(log przedziałów bazowych, których macierze mnożymy w czasie O(A3). Z kolei sama konstrukcja drzewa zajmie czas |O(nA więc algorytm będzie działał w sumarycznej złożoności czasowej |O(nA3

Można ją jeszcze trochę przyspieszyć, korzystając ze standardowej sztuczki dla zapytań o iloczyny macierzy. Tak naprawdę nie pytamy się o całą macierz, a o jeden z jej elementów (dlatego mnożymy obustronnie przez wektor). Ponieważ mnożenie macierzy przez wektor działa w czasie O(A2), jest więc szybsze niż mnożenie macierzy przez macierz (i daje w wyniku wektor), możemy zatem macierze dla przedziałów bazowych od razu domnażać do jednego z tych wektorów. Tym sposobem algorytm będzie działał w czasie |O(nA3

Własność mnożenia macierzy, którą wykorzystujemy w drzewie przedziałowym, to łączność. Gdyby dodatkowo nasze macierze AM i B |M były odwracalne, to zamiast drzewa przedziałowego moglibyśmy wykorzystać zwykłe sumy (a właściwie iloczyny) prefiksowe. Jeśli przyjmiemy oznaczenie 1M2⋯Mi−1Mi |Pi = M oraz Qi to wtedy −1M−1⋯M−1M−1Q ii−121 oraz

TT lMl+1⋯Mr−1Mr(1,0,0)=(1,0,0)Ql−1⋅Pr(1,0,0). (1,0,0) M

Niestety, nie wszystkie macierze są odwracalne. Ale popatrzmy na macierz A M jako przekształcającą wektor v = (w, na wektor |v′= (w gdzie w ,w i w . Gdybyśmy dostali wektor  ′ v , to czy umielibyśmy na jego podstawie odtworzyć wektor |v? Odpowiedź jest twierdząca - proste przekształcenia prowadzą do wzoru |w i |wB Są to również przekształcenia liniowe, więc możemy je zapisać w formie macierzy, która musi być zatem macierzą odwrotną do A M (tak samo odwracamy macierz B M ):

−1⎛0−10⎞−1⎛00−1⎞ A=⎜120⎟,MB=⎜010⎟. M ⎝001⎠⎝102⎠

Możemy więc w czasie O(nA3) wyznaczyć wszystkie Pi, | biorąc .P = P M i i i− 1 Macierz odwrotną Q możemy również obliczyć w czasie O(A ale jest to bardziej kłopotliwe niż mnożenie. Aby tego uniknąć, wyznaczymy |Qi, korzystając ze wzoru

i)−1=M−1iP−1i−1=M−1iQi−1. Qi

Teraz jedno zapytanie będzie działać w czasie O(A3) lub nawet w czasie |O(A2), bo dla wyniku potrzebujemy wykonać mnożenie (1, | 0,0)Q oraz mnożenie  T Pr(1,0,0) , a następnie pomnożyć skalarnie uzyskane wektory. Ale jeśli przyjrzymy się temu wzorowi bliżej, to tak naprawdę mnożymy w nim pierwszy wiersz macierzy Ql przez pierwszą kolumnę macierzy |P, r zatem możemy to bezpośrednio zrobić w czasie O(A). Dostajemy zatem algorytm o złożoności czasowej O(nA

Przypomnijmy, że zaczęliśmy od rekurencji liniowej, którą zapisaliśmy w postaci macierzy, aby skorzystać z ich własności (łączności dla drzewa przedziałowego i istnienia odwrotności dla iloczynów prefiksowych). Zauważmy, że o ile w drzewie przedziałowym potrzebowaliśmy umieć mnożyć dowolne macierze, to w algorytmie sum prefiksowych wystarczy nam domnażanie przez macierze i |M oraz −1 i, M więc znowu możemy skorzystać z faktu, są one szczególnej postaci. Domnażanie macierzy |P przez i M modyfikuje jedynie dwie kolumny macierzy P (przykładowo dla |M A na drugą kolumnę kopiujemy pierwszą, a pierwszą mnożymy przez 2 i odejmujemy drugą). Zatem możemy skopiować macierz Pi−1 do macierzy |Pi w czasie O(A2), a następnie zrobić uaktualnienie dwóch kolumn w czasie O(A). (Analogicznie dla macierzy Q. | ) Tak więc fazę obliczeń wstępnych możemy zrealizować w czasie O(nA2), | co da nam algorytm o złożoności |O(nA

Czas na ostatnią obserwację: wcale nie musimy pracowicie kopiować całych macierzy. Ponieważ z macierzy Qi potrzebujemy jedynie pierwszego wiersza, a z macierzy Pi jedynie pierwszej kolumny, zatem wystarczy te macierze modyfikować w miejscu (czyli nadpisując nieaktualne wartości nowymi w tym samym miejscu), a kopiować jedynie potrzebne wiersze i kolumny, co zajmie czas O(A). Zatem ostatecznie dostajemy rozwiązanie o złożoności czasowej O(nA