Listy powiązane pojedynczo

WPROWADZENIE

Listy powiązane składają się z szeregu elementów (węzłów) powiązanych ze sobą łączami. Python udostępnia listy jako swój typ wbudowany, a inne języki pozwalają samemu stworzyć ich odpowiednik. Węzły list powiązanych pojedynczo przechowują pewną wartość oraz zawierają referencję do następnego węzła na liście. Jedynym sposobem dostania się do konkretnego węzła jest przejście przez listę od jej samego początku. Ostatni węzeł oznacza się zgodnie z jedną przedstawionych poniżej konwencji:

IMPLEMENTACJA 1

Pierwszy etap ręcznej implementacji list w Pythonie to stworzenie klasy reprezentującej węzeł listy. Dostęp do listy zapewnia łącze do jej pierwszego węzła (head).


class Node:
    """Klasa reprezentująca węzeł listy jednokierunkowej."""

    def __init__(self, data=None, next=None):
        self.data = data
        self.next = next

    def __str__(self):
        return str(self.data)   # bardzo ogólnie

    def __eq__(self, other):
        return self.data == other.data

    def __ne__(self, other):
            return not self == other

head = None   # pusta lista

head = Node("front")   # lista jednoelementowa

# Ręczne budowanie dłuższej listy.
head = None                   # [], pusta lista
head = Node(3, head)          # [3]
head = Node(2, head)          # [2, 3]
head = Node(4, head)          # [4, 2, 3]

|  head                                |
|   |                                  |
|   o                                  |
| +---+-+   +---+-+   +---+-+    puste |
| | 4 | +--o| 2 | +--o| 3 | +--| łącze |
| +---+-+   +---+-+   +---+-+          |

PRZECHODZENIE PRZEZ LISTĘ JEDNOKIERUNKOWĄ

Posługiwanie się danymi zorganizowanymi w listy nazywa się przetwarzaniem list. Rozważmy funkcję traverse(), która pozwala przejść przez listę i wykonać operację visit() na każdym węźle.


def traverse(node, visit):
    """Iteracyjne przejście przez listę jednokierunkową."""
    while node:
        visit(node)
        node = node.next

def traverse(node, visit):
    """Rekurencyjne przejście przez listę jednokierunkową."""
    if node:
        visit(node)
        traverse(node.next, visit)

Powyższy schemat powtarza się w wielu funkcjach przetwarzających listy. Rozważmy przykładowo wypisanie zawartości całej listy.


def print_list(node):
    """Iteracyjne wypisanie listy jednokierunkowej."""
    while node:
        print(node)
        node = node.next

def print_forward(node):
    """Rekurencyjne wypisanie listy jednokierunkowej."""
    if node:
        print(node)
        print_forward(node.next)

def print_backward(node):
    """Rekurencyjne wypisanie listy wstecz."""
    if node:
        print_backward(node.next)
        print(node)

def count_list(node):
    """Obliczanie liczby węzłów na liście w czasie O(n)."""
    length = 0
    while node:
        length += 1
        node = node.next
    return length

def find_list(node, data):
    """Wyszukiwanie elementu na liście w czasie O(n)."""
    while node:
        if node.data == data:
            return node   # zwracamy węzeł z danymi
        node = node.next
    return None

def is_empty(node):
    """Sprawdzanie, czy lista jest pusta."""
    return node is None

PRZETWARZANIE LIST

Zbadamy operacje, które przebudowują listę, dlatego jako wynik muszą zwracać referencję do ewentualnie zmienionego początku listy. Napiszemy funkcje insert_head() i insert_tail(), które dodają element odpowiednio na początek i na koniec listy, a zwracają nowy początek listy.


def insert_head(node, new_node):   # algorytm klasy O(1)
    """Wstawienie nowego węzła na początek listy."""
    new_node.next = node
    return new_node

# Zastosowanie.
# head = insert_head(head, Node(33))

def insert_tail(node, new_node):   # algorytm klasy O(n)
    """Wstawienie nowego węzła na koniec listy."""
    head = node
    last = None
    while node:   # O(n)
        last = node
        node = node.next
    if last is None:   # lista była pusta
        return new_node
    else:              # last jest ostatni
        last.next = new_node
        return head   # head się nie zmienił

# Zastosowanie.
# head = insert_tail(head, Node(55))

Napiszemy funkcję reverse_list(), która odwraca kolejność elementów na liście, a zwraca nowy początek listy. Realizacja tego zadania wymaga trzech zmiennych przechowujących referencje do węzłów (before, after, node). Łącze before wskazuje na fragment listy już zmodyfikowany, a łącze after wskazuje na fragment, który jeszcze nie został zmodyfikowany. Dzięki łączu node nie zgubimy połączenia z resztą listy przy zmienianiu łącza after.next.


def reverse_list(node):   # zwraca nowy head
    """Odwrócenie kolejności elementów na liście."""
    before = None
    after = node
    while after:
        node = after.next
        after.next = before
        before = after
        after = node
    return before

# Zastosowanie.
# head = reverse_list(head)

Następna funkcja wykonuje sortowanie węzłów listy metodą sortowania przez wstawianie. Elementy będą ustawione od największego do najmniejszego. Algorytm nie jest zbyt wydajny, ponieważ jest klasy O(n^2), ale ma walory dydaktyczne. Charakterystyczne jest przesuwanie widełek (before, after) wzdłuż listy, aby znaleźć miejsce do wstawienia węzła pomiędzy nimi.


def insertsort(node):
    """Sortowanie listy przez wstawianie."""
    head1 = node          # początek listy pierwotnej
    head2 = None          # początek listy posortowanej
    while head1:
        node = head1            # odczepiam węzeł
        head1 = head1.next
        node.next = None        # czyszczę łącze
        before = None           # robię widełki w nowej liście
        after = head2
        while after:
            if after.data < node.data:
                break
            before = after
            after = after.next
        # sprawdzamy, gdzie jesteśmy
        if before is None:      # przed początkiem listy
            head2 = node
        else:         # jesteśmy w głębi listy, może na końcu
            before.next = node
        node.next = after
    return head2

# Zastosowanie.
# head = insertsort(head)

WYKRYWANIE CYKLI

https://en.wikipedia.org/wiki/Cycle_detection#Tortoise_and_hare

https://www.geeksforgeeks.org/detect-loop-in-a-linked-list/

Ciekawym problemem jest wykrywanie cykli na liście powiązanej. Chodzi o sytuację, kiedy ostatni węzeł listy zamiast łącza None posiada łącze do pewnego węzła ze środka listy. Problem rozwiązuje algorytm Floyda (1967), nazywany także algorytmem żółwia i zająca (ang. Floyd’s Cycle-Finding Algorithm, The Tortoise and the Hare Algorithm).

Algorytm utrzymuje dwa łącza, żółwia i zająca, przy czym łącze żółwia przesuwa się o jedną pozycję, a łącze zająca o dwie pozycje. Jeżeli na liście jest cykl, to łącza żółwia i zająca spotkają się w pewnym momencie wewnątrz cyklu. To wystarczy, aby wykryć cykl. W celu znalezienia pierwszego elementu należącego do cyklu i wyznaczenia długości cyklu, należy wykonać dodatkowe czynności. Złożoność czasowa O(n), pamięciowa O(1).


def floyd_detect_cycle(node):
    """Algorytm Floyda wykrywania cyklu."""
    tortoise = node
    hare = node
    while tortoise and hare and hare.next:
        tortoise = tortoise.next
        hare = hare.next.next
        if tortoise == hare:
            print("cycle detected")
            return True
    return False