Załóżmy, że mamy rekursywną strukturę danych, taką jak drzewo binarne. Istnieje wiele sposobów na przemierzanie go i mają różne profile wykorzystania pamięci. Na przykład, gdybyśmy po prostu wydrukować wartości każdego węzła, używając pseudo-kod podobny do poniższego przechodzenie na zamówienie ...Czy można leniwie przemierzać rekursywną strukturę danych przy użyciu pamięci O (1), zoptymalizowanym ogonem?
visitNode(node) {
if (node == null) return;
visitNode(node.leftChild);
print(node.value);
visitNode(node.rightChild);
}
... nasz wykorzystanie pamięci byłaby stała, ale ze względu na wywołania rekurencyjne, zwiększyłybyśmy rozmiar stosu wywołań. Na bardzo dużych drzewach może to potencjalnie spowodować przepełnienie.
Załóżmy, że zdecydowaliśmy się zoptymalizować pod kątem rozmiaru stosu wywołań; przy założeniu, że język ten jest zdolny do prawidłowego tailcalls, możemy przepisać to jako następnego pre-order przechodzenie ...
visitNode(node, nodes = []) {
if (node != null) {
print(node.value);
visitNode(nodes.head, nodes.tail + [node.left, node.right]);
} else if (node == null && nodes.length != 0) {
visitNode(nodes.head, nodes.tail);
} else return;
}
Choć nigdy nie dmuchać stos, chcielibyśmy teraz zobaczyć sterty wzrost wykorzystania liniowo w stosunku do wielkość drzewa.
Powiedzmy, że wtedy próbowaliśmy leniwie przemierzać drzewo - tu moje rozumowanie staje się niewyraźne. Myślę, że nawet stosując podstawową leniwą strategię ewaluacyjną, zwiększylibyśmy pamięć w tym samym tempie, co wersja zoptymalizowana pod kątem tailcall. Oto konkretny przykład przy użyciu Scala klasę Stream, który zapewnia leniwe ocena:
sealed abstract class Node[A] {
def toStream: Stream[Node[A]]
def value: A
}
case class Fork[A](value: A, left: Node[A], right: Node[A]) extends Node[A] {
def toStream: Stream[Node[A]] = this #:: left.toStream.append(right.toStream)
}
case class Leaf[A](value: A) extends Node[A] {
def toStream: Stream[Node[A]] = this #:: Stream.empty
}
Chociaż tylko szef strumienia jest surowo oceniane każdym razem, gdy left.toStream.append(right.toStream)
jest oceniany, myślę to faktycznie ocenić głowę zarówno lewy, jak i prawy strumień. Nawet jeśli nie (z powodu dodania sprytu), , myślę, że, rekursywnie budując ten thunk (pożyczyć termin od Haskella) zasadniczo zwiększyłby pamięć w tym samym tempie. Zamiast mówić: "umieść ten węzeł na liście węzłów do przemierzania", w zasadzie mówimy: "tutaj jest inna wartość do oceny, która powie ci, co masz przechodzić dalej", ale wynik jest taki sam; przyrost pamięci liniowej.
Jedyna strategia, o której można pomyśleć, pozwoli uniknąć tego, że ma zmienny stan w każdym węźle, deklarując, które ścieżki zostały przecięte. To pozwoliłoby nam na posiadanie referencyjnej przezroczystej funkcji, która mówi: "Biorąc pod uwagę węzeł, powiem ci, który pojedynczy węzeł powinieneś przejść dalej", i moglibyśmy użyć tego do zbudowania iteratora O (1).
Czy istnieje inny sposób osiągnięcia O (1), zoptymalizowanego przejścia poprzecznego drzewa binarnego, być może bez stanu zmiennego?
Robiąc to ze zmiennym stanem, to nie jest łatwe, ale wykonalne - to większość (nie "wszystko", ponieważ uważam, że niektórzy nie przejmują się) robiąc to podczas zbierania śmieci. – delnan
Jeśli drzewo nie musi przetrwać przejścia, możliwe jest iterowanie z O (1) przestrzenią pomocniczą przez linearyzację drzewa. W przeciwnym razie, myślę, że będziesz potrzebować niestałej ilości miejsca. –
Rzeczywiście, z odwróceniem wskaźnika możesz zrobić to z O (1) dodatkową przestrzenią. Ale nie zawsze jest to odpowiednie, ponieważ zakłóca drzewo podczas przemierzania. – augustss