G. Jagiella | ostatnia modyfikacja: 31.05.2020 |
W tym rozdziale omówimy najprostszą łączoną strukturę danych - listę łączoną, oraz zaprezentujemy na jej przykładzie hermetyzację - pewien koncept programowania obiektowego.
List łączona to struktura danych, w których obiekty przechowywane są w skończonej ilości węzłów (nodes). Pojedynczy węzeł przechowuje pojedynczy obiekt. Węzły ustawione są w łańcuch. Każdy węzeł zawiera dwie dane: przechowywany obiekt, oraz następnik węzła, czyli następny węzeł w łańcuchu (lub informację, że jest ostatnim węzłem). Pierwszy węzeł w łańcuchu nazywamy głową (head) listy, a ostatni jej ogonem (tail). Lista łączona pamięta, który węzeł jest głową, a który ogonem. Lista może być pusta (nie mieć głowy ani ogona).
Ilustracja listy łączonej, przechowującej liczby jako dane:
Rozważmy dwie operacje, polegające na dodaniu węzła node z danymi na początek lub na koniec listy łączonej. Operacje te muszą zachowywać strukturę listy łączonej.
Dodanie węzła na koniec listy. Mamy dwa przypadki:
node jako zarówno głowę, jak i ogon.node jako następnik, a node staje się nowym ogonem.Dodanie węzła na początek listy. Mamy dwa przypadki:
node jako zarówno głowę, jak i ogon.node zapamiętuje głowę jako następnik i staje się nową głową.Teraz rozważmy operacje usunięcia głowy (i tylko głowy) niepustej listy łączonej.
Usunięcie głowy. Mamy dwa przypadki:
Pokażemy teraz przykładową implementację listy łączonej. Pojedynczy węzeł będziemy reprezentować przez klasę Node z następującymi metodami:
__init__(data) - tworzy nowy węzeł, pamiętający obiekt data. Węzeł nie ma następnika.get_data() - zwraca obiekt, pamiętany przez węzeł.get_next() - zwraca następnik węzła (lub None, jeśli go nie ma).set_data(data) - ustawia obiekt, pamiętany przez węzeł.set_next(node) - ustawia następnik węzła na podany węzeł node. Argumentem node może być None.Wewnętrznie, węzeł, pamięta obiekt i następnik w atrybutach (odpowiednio) data i next.
Całą listę łączoną będziemy reprezentować klasą LinkedList, implementującą metody:
__init__() - tworzy pustą listę łączoną.push_front(data) - dokłada nowy węzeł pamiętający obiekt data na początek listy łączonej.push_back(data) - dokłada nowy węzeł pamiętający obiekt data na początek koniec łączonej.pop_front() - usuwa węzeł z początku listy łączonej i zwraca obiekt pamiętany przez ten węzeł.size() - zwraca ilość węzłów na liście łączonej.is_empty() - orzeka, czy lista łączona jest pusta.Wewnętrznie, lista łączona będzie używać atrybutów head i tail pamiętające odpowienio głowę i ogon.
Przechodzimy do implementacji. Najpierw węzeł:
class Node:
def __init__(self, data):
self.data = data
self.next = None
def get_data(self):
return self.data
def get_next(self):
return self.next
def set_data(self, new_data):
self.data = new_data
def set_next(self, new_next):
self.next = new_next
Następnie lista łączona. Metody implementujemy zgodnie z opisem operacji:
# from node import Node
class LinkedList:
def __init__(self):
self.head = None
self.tail = None
def push_front(self, data):
new_node = Node(data)
new_node.set_next(self.head)
if self.is_empty():
self.tail = new_node
self.head = new_node
def push_back(self, data):
new_node = Node(data)
if self.is_empty():
self.head = new_node
else:
self.tail.set_next(new_node)
self.tail = new_node
def pop_front(self):
obj = self.head.get_data()
if self.head is self.tail:
self.head = None
self.tail = None
else:
self.head = self.head.get_next()
return obj
def size(self):
n = 0
current = self.head
while current is not None:
current = current.get_next()
n += 1
return n
def is_empty(self):
return self.head is None
Przykład użycia:
lst = LinkedList()
for n in range(10):
lst.push_back(n) # -> lst: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
for _ in range(5):
print(lst.pop_front()) # -> 0, 1, 2, 3, 4, lst: [5, 6, 7, 8, 9]
for n in range(-1, -4, -1):
lst.push_front(n) # lst == [-3, -2, -1, 5, 6, 7, 8, 9]
while not lst.is_empty():
print(lst.pop_front()) # -3, -2, -1, 5, 6, 7, 8, 9, lst: []
Zwracamy uwagę na następujące fakty:
pop_front i push_front są stałe - nasza lista łączona nadaje się więc jako wydajny złożonościowo stos.push_back jest stały - więc struktura nadaje się jako wydajna kolejka.pop_back (usuwanie węzła z końca) w czasie stałym - może to wymagać modyfikacji listy łączonej.Zwróćmy uwagę, że w opisie klasy LinkedList (a także Node) prezentujemy metody służące do działania na strukturze danych. Ich znaczenie jest niezależne od szczegółów implementacji (np. atrybutów przechowujących głowę i ogon). Te ukryte szczegóły implementacji nie są przeznaczone do bezpośredniego dostępu: manipulacja np. atrybutem tail bez jednoczesnego uaktualnienia nastepników węzłów w łańcuchu niszczy strukturę listy łączonej.
Praktyką w programowaniu obiektowym jest tzw. hermetyzacja (encapsulation), polegająca na uniemożliwianiu dostępu do wewnętrznych danych obiektu (i sposobu reprezentacji tych danych) przez użytkownika (użytkownik to każdy, kto używa danego obiektu - w tym my sami). Służy ona do ochrony spójności przechowywanych danych przy jednoczesnym klarownym zaprezentowaniu operacji, które można wykonać na obiekcie (interfejs publiczny). Jednocześnie pozwala na abstrakcję: obiekt jest opisany przez (publiczne) działania, które można na nim wykonać, bez wnikania w szczegóły, jak te operacje są realizowane.
W naszym przykładzie w naszym przykładzie, szczegółami implementacji Node są jego atrybuty: data i next, a publicznym interfejsem metody get_data(), set_data(), get_next(), set_next(), pozwalające na manipulację nimi. Z kolei interfejsem LinkedList są wszystkie zaimplementowane metody, z kolei szczegółami implementacji LinkedList są na pewno atrybuty head oraz tail. Zauważmy jednak, że wewnętrznie LinkedList używa także klasy Node, która nie jest widoczna w publicznym interfejsie (w np. push_front() podajemy obiekt do zapamiętania, nie węzeł, który ma go pamiętać). Zatem można potraktować całą klasę Node jako niepubliczny szczegół implementacji LinkedList.
Różne języki dostarczają na różne sposoby mechanizmu rozdzielania interfejsu publicznego od szczegółów implementacji. Przykładowo W językach podobnych do C++ (C#, Java, ...), dla metody i atrybuty klas można określić poziom dostępu. Poziom może być publiczny lub niepubliczny (wyróżnia się różne poziomy niepubliczne, np. prywatny lub chroniony). Z publicznych metod i atrybutów klasy można korzystać w dowolnym miejscu kodu. Z niepublicznych jedynie w implementacji tej samej klasy (lub pewnych specjalnych klas stowarzyszonych z nią).
Python nie oferuje "twardego" mechanizmu określania poziomu dostępu, stosuje jednak pewne konwencje. Atrybuty, których nazwy rozpoczęte są znakiem _ traktowane są jako "chronione". Są cały czas dostępne poza implementacją klasy, ale użycie ich poza klasą jest traktowane jako zła praktyka, a edytory będą zgłaszać takie przypadki (np. podkreślając kod). Dodatkowym sposobem na oznaczenie atrybutu jako prywatnego jest nadanie mu nazwy rozpoczętej od __ (tzw. dunder, double underscore).
Klasa LinkedList, w której ukrywamy szczegóły zgodnie z konwencją mogłaby wyglądać tak (zwróćmy uwagę, że _Node - odpowiednik Node - jest klasą zdefiniowaną wewnątrz LinkedList:
class LinkedList:
class _Node:
def __init__(self, data):
self.data = data
self.next = None
def __init__(self):
self._head = None
self._tail = None
def push_front(self, data):
new_node = self._Node(data)
new_node.next = self._head
if self.is_empty():
self._tail = new_node
self._head = new_node
def push_back(self, data):
new_node = self._Node(data)
if self.is_empty():
self._head = new_node
else:
self._tail.next = new_node
self._tail = new_node
def pop_front(self):
obj = self._head.data
if self._head is self._tail:
self._head = None
self._tail = None
else:
self._head = self._head.next
return obj
def size(self):
n = 0
current = self._head
while current is not None:
current = current.next
n += 1
return n
def is_empty(self):
return self._head is None