2 Struktury danych - kolejka, struktury danych


Struktury danychC#

Część 2. Kolejka, stostablicahaszowaniem

Scott Mitchell
4GuysFromRolla.com

listopad 2003

Streszczenie
A
rtykuł ten to druga część sześcioczęściowej serii dotyczącej struktur danych na platformie .NET. Przedstawiononim trzy najczęściej używane struktury: kolejkę, stostablicęhaszowaniem.artykułu tego dowiemy się, że kolejkastos — wyspecjalizowane obiekty ArrayList pozwalają na przechowywanie zmiennej liczby elementów, lecz do tych elementów można uzyskać dostęp jedynie w z góry określonej kolejności. Tablicahaszowaniem działa podobnie jak zwykła tablica, jednak charakteryzuje się lepszym indeksowaniem.zwykłych tablicach wymagane jest, aby elementy były indeksowane liczbami porządkowymi, natomiasttablicach z haszowaniem elementy mogą być indeksowane dowolnymi obiektami.
(19 stron drukowanych)

Spis treści

Wprowadzenie
Szeregowanie zadań zgodnie z algorytmem „pierwszy przyszedł — pierwszy obsłużony”
Struktura stosu, czyli szeregowanie typu „pierwszy przyszedł — ostatni obsłużony”
Ograniczenia indeksowania bezpośredniego
Klasa System.Collections.Hashtable
Podsumowanie

Wprowadzenie

W części 1. pod nazwą Struktury danych — wprowadzenie rozważaliśmy, czym są struktury danych,jaki sposób można sprawdzić ich wydajność oraz jaką rolę odgrywa wydajnośćwyborze odpowiedniego algorytmu. Oprócz poznania podstawowych zagadnień dotyczących struktur danychich analizy, przyjrzeliśmy się także najczęściej stosowanej strukturze danych — tablicy.

W tablicy przechowywany jest zbiór jednorodnych elementów, które są indeksowane liczbami porządkowymi. Zawartość tablicy jest zaalokowana jako ciągły blok pamięci, dzięki czemu odczytzapis konkretnego elementu wykonywany jest bardzo szybko.

Tablice mają dwie wady jednorodność elementów oraz konieczność definiowania rozmiaru tablicy przy jej tworzeniu. Ograniczenia te można ominąć, stosując dostępnąbibliotece klas bazowych platformy.NET Framework strukturę danych ArrayList, która umożliwia przechowywanie niejednorodnych obiektównie wymaga jawnego definiowania rozmiaru tablicy. Jak opisałem to jużpoprzednim artykule, ArrayList wykorzystuje tablicę elementów typu object.każdym wywołaniu metody Add() klasy ArrayList następuje sprawdzenie, czywewnętrznej tablicy typu object jest jeszcze miejsce na nowe elementy. Jeśli nie, rozmiar tablicy jest automatycznie podwajany.

W tym artykule będziemy kontynuować naszą analizę tablicowych struktur danych. Zaczniemy od kolejkistosu.strukturach tych także przechowywane są ciągłe bloki niejednorodnych elementów (tak jakArrayList). Jednakprzypadku kolejkistosu dostęp do przechowywanych obiektów jestpewnym stopniu ograniczony.

Po omówieniu kolejkistosu przejdziemy do tablicyhaszowaniem. Tablicahaszowaniem (czasami nazywana tablicą asocjacyjną) służy do przechowywania kolekcji niejednorodnych elementów, ale jej elementy przeciwieństwie elementów zwykłych tablic, indeksowanych liczbami porządkowymi — indeksowane są obiektami dowolnego typu (np. string).

Szeregowanie zadań zgodniealgorytmem pierwszy przyszedł pierwszy obsłużony

Przy tworzeniu dowolnej usługi to jest programu, który otrzymujewielu źródeł wiele żądań wykonania jakiegoś zadania wyzwaniem staje się podjęcie decyzji o sposobie,jaki żądania te będą obsługiwane. Dwa najczęściej stosowane rozwiązania to:

Pierwszy przyszedł pierwszy obsłużony to sposób planowania realizacji zadań stosowany na przykładsklepie spożywczym, banku czyfabryce samochodów. Osoby oczekujące na obsługę ustawiają siękolejkę. Ludzie, którzy znajdują siękolejce przed tobą, zostaną obsłużeni przed tobą. Ludzie, którzy znajdują siękolejce za tobą, zostaną obsłużeni po tobie.przypadku przetwarzania priorytetowego najpierw realizowane są zadania o wysokim priorytecie,dopiero później zadania o niższym priorytecie. Na przykładszpitalnej izbie przyjęć pomoc zostanie udzielona najpierw osobomstanie krytycznym,dopiero później osobommniej poważnym stanie — niezależnie od tego, który pacjent przyjechał pierwszy.

Załóżmy, że chcemy utworzyć usługę komputerową, która będzie obsługiwać żądaniakolejności ich otrzymywania. Kolejne żądania mogą być nadsyłane szybciej, niż jesteśmystanie je obsłużyć, dlatego potrzebny jest bufor,którym moglibyśmy je umieszczać, zapamiętując jednocześnie porządek,jakim zostały nadesłane.

Jednymmożliwych rozwiązań jest zastosowanie tablicy ArrayList oraz zmiennej całkowitej o nazwie nextJobPos,której zapisywana będzie pozycja następnego zadania do wykonania. Gdy nadejdzie żądanie wykonania nowego zadania, po prostu wywołujemy metodę Add() tablicy ArrayList, aby ustawić to zadanie na koniec tablicy ArrayList. Gdy jesteśmy gotowi, by przetworzyć kolejne zadaniebufora, odczytujemy z tablicy ArrayList element o indeksie nextJobPoszwiększamy wartość nextJobPos. Rozwiązanie to zilustrowane jest przez następujący kod:

using System;

using System.Collections;

public class JobProcessing

{

private static ArrayList jobs = new ArrayList();

private static int nextJobPos = 0;

public static void AddJob(string jobName)

{

jobs.Add(jobName);

}

public static string GetNextJob()

{

if (nextJobPos > jobs.Count - 1)

return "BRAK ZADAŃ W BUFORZE ";

else

{

string jobName = (string) jobs[nextJobPos];

nextJobPos++;

return jobName;

}

}

public static void Main()

{

AddJob("1");

AddJob("2");

Console.WriteLine(GetNextJob());

AddJob("3");

Console.WriteLine(GetNextJob());

Console.WriteLine(GetNextJob());

Console.WriteLine(GetNextJob());

Console.WriteLine(GetNextJob());

AddJob("4");

AddJob("5");

Console.WriteLine(GetNextJob());

}

}

W wyniku wykonania powyższego programu uzyskamy następujący rezultat:

1

2

3

BRAK ZADAŃ W BUFORZE

BRAK ZADAŃ W BUFORZE

4

Jest to nieskomplikowane rozwiązanie — niestety jest także bardzo nieefektywne. Przede wszystkim tablica ArrayList stale rośnie wrazkażdym nowym zadaniem dodanym do bufora nawet jeśli zadania są wykonywane natychmiast po dodaniu ich do bufora. Zastanówmy się nad sytuacją, gdy co sekundę do bufora jest dodawane nowe zadanie, natychmiast przetwarzaneusuwanebufora. Oznacza to, że co sekundę wywoływana jest metoda AddJob(), którakolei wywołuje metodę Add() tablicy ArrayList. Metoda Add() jest regularnie wywoływanamiarę zapełniania się tablicy ArrayList podwajany jest rozmiar jej wewnętrznej tablicy. Po pięciu minutach (300 sekund) wewnętrzna tablica ma rozmiar 512 elementów — mimo żebuforze nigdy nie znajdował się więcej niż jeden element! Oczywiście ta tendencja ma miejsce przez cały czas działania programuzgłaszania nowych zadań.

Przyczyną rozrostu tablicy ArrayList do tak absurdalnych rozmiarów jest to, że nie następuje odzyskanie miejscabuforze, zwolnionego przez zrealizowane zadania. Po tym, jak pierwsze zadanie zostaje dodane do buforaprzetworzone, miejsce po nimArrayList jest gotowe do ponownego użycia. Rozważmy rozkład zadań przedstawionypoprzednim fragmencie kodu. Po pierwszych dwóch liniach kodu AddJob("1")i AddJob("2") tablica ArrayList będzie wyglądała tak, jak na rysunku poniżej.

0x01 graphic

Ilustracja 1. ArrayList po wykonaniu pierwszych dwóch linii kodu

Zauważmy, żetym momencie tablica ArrayList ma rozmiar 16, ponieważ domyślnietrakcie inicjalizacji klasa ArrayList tworzy wewnętrzną tablicę 16 elementów typu object. Następnie wywoływana jest metoda GetNextJob(), która realizuje (usuwa) pierwsze zadanie. Po jej wywołaniu tablica ArrayList wygląda tak, jak na ilustracji 2.

0x01 graphic

Ilustracja 2. Tablica po wywołaniu metody GetNextJob()

Wykonanie kolejnej linii kodu — AddJob("3") powoduje dodanie do bufora kolejnego zadania. Pierwszy element ArrayList (indeks 0) nie jest zajęty.początku może wydawać się, że wstawienie trzeciego zadania na miejsce o indeksie 0 jest dobrym rozwiązaniem. Jednak możemy szybko o tym zapomnieć, jeśli rozważymy przypadek,którym po wywołaniu AddJob("3") wywołalibyśmy AddJob("4"),następnie dwa razy wywołalibyśmy funkcję GetNextJob(). Jeśli wstawilibyśmy trzecie zadanie na miejsce o indeksie 0,czwarte zadanie na miejsce o indeksie 2, sytuacja wyglądałaby tak, jak na ilustracji 3.

0x01 graphic

Ilustracja 3. Sytuacja po wstawieniu zadania na miejsce o indeksie 0

Wywołanie funkcji GetNextJob() spowoduje usunięciebufora drugiego zadaniaustawienie zmiennej nextJobPos tak, by wskazywała indeks 2. Po kolejnym wywołaniu funkcji GetNextJob(),zostanie wykonane czwarte zadanieusuniętebufora przed trzecim zadaniem, co narusza szeregowanie pierwszy przyszedł pierwszy obsłużony, które chcieliśmy zastosować.

Sedno problemu tkwitym, że ArrayList reprezentuje listę zadańporządku liniowym. Dlatego aby zagwarantować odpowiednią kolejność wykonywania zadań nowe zadania należy dodawać na prawo od starych zadań. Gdy dojdziemy do końca tablicy ArrayList, jej rozmiar jest podwajany ze względu na kolejne wywołania funkcji GetNextJob(), nawet jeślitablicy znajdują się wolne miejsca.

Problem ten można rozwiązać, przekształcając tablicę ArrayListtablicę cykliczną. Tablica cykliczna charakteryzuje się tym, że nie ma ani określonego początku, ani określonego końca. Początekkoniec tablicy należy zapamiętaćosobnych zmiennych. Graficzną reprezentację tablicy cyklicznej przedstawiono na ilustracji 4.

0x01 graphic

Ilustracja 4. Przykład tablicy cyklicznej

W przypadku tablicy cyklicznej, metoda AddJob() dodaje nowe zadaniepozycji wskazywanej przez endPosnastępnie zwiększa wartość endPos. Metoda GetNextJob() pobiera do realizacji zadanie wskazywane przez startPos, na jego miejscetabeli wstawia wartość null ,następnie „zwiększa” wartość startPos. Wyraz zwiększa napisałemcudzysłowie, ponieważtym przypadku operacja jest nieco bardziej skomplikowana niż zwykłe dodawanie jedynki do wartości zmiennej. Aby przekonać się, dlaczego nie możemy po prostu dodać 1, rozważmy przypadek,którym wartość endPos wynosi 15. Jeśli zwiększlibyśmy endPos o 1, zmienna miałaby wartość 16. Przy następnym wywołaniu metody AddJob() program próbowałby odwołać się do elementu o indeksie 16, co spowodowałoby wyjątek IndexOutOfRangeException.

Dlatego gdy zmienna endPos ma wartość 15, zerujemy ją, zamiast zwiększać jej wartość o jeden. Takie zwiększenie możemy zaimplementować jako funkcję increment(zmienna), która sprawdza, czy przekazana zmienna ma wartość równą rozmiarowi tablicyjeśli tak jest ustawia jej wartość na 0. Innym rozwiązaniem jest zwiększanie wartości o jeden,następnie obliczenie resztydzielenia przez rozmiar tablicy.takim przypadku kod funkcji increment() wyglądałby następująco:

int increment(int variable)

{

return (variable + 1) % theArray.Length;

}

Uwaga   Operator modulo (%), stosowanywyrażeniu x % y, powoduje obliczenie resztydzielenia x przez y. Wynik zawsze ma wartośćprzedziału [0; y - 1].

Rozwiązanie to świetnie się sprawdza, jeślinaszym buforze nigdy nie będzie więcej niż 16 zadań. Jednak co się stanie, jeśli do bufora będziemy chcieli dodać kolejne zadaniesytuacji, gdy wszystkie 16 miejsc będzie zapełnione? Tak samo jakprzypadku metody Add() obiektu ArrayList, będziemy musieli zwiększyć rozmiar tablicy na przykład podwajając go.

Klasa System.Collections.Queue

Opisana wyżej funkcjonalność — dodawanieusuwanie elementówbuforaporządku pierwszy przyszedł pierwszy obsłużony oraz dużo lepsze wykorzystanie pamięci — zapewniana jest przez standardową strukturę danych nazywaną kolejką. Biblioteka klas bazowych środowiska .NET Framework zawiera klasę kolejki — System.Collections.Queue.zamieszczonym powyżej kodziecelu dodania elementujego usunięcia wykorzystywane są funkcje AddJob()GetNextJob(), natomiastklasie Queue zadania te można wykonać za pomocą metod Enqueue()Dequeue().

Implementacja klasy Queue wykorzystuje wewnętrzną tablicę cykliczną elementów typu object oraz dwie zmienne, które są wskaźnikami na początekkoniec tablicy head (głowa kolejki)tail (ogon kolejki). Domyślny rozmiar kolejki wynosi 32 elementy, jednak można go samodzielnie zdefiniować, podając właściwy rozmiarwywołaniu konstruktora. Kolejka wykorzystuje tablicę elementów typu object, dlatego możnaniej przechowywać zmienne każdego typu.

Metoda Enqueue() sprawdza, czy tablica może pomieścić kolejny element. Jeśli tak dodaje nowy element na miejsce wskazywane przez indeks tailzwiększa” indeks tail metodą wykorzystującą operator modulo (dzięki temu indeks tail nie przekroczy rozmiaru tablicy wewnętrznej). Jeślitablicy brakuje miejsca, jej rozmiar jest mnożony przez określony współczynnik wzrostu. Jego standardowa wartość wynosi 2.0, można jednak przy wywoływaniu konstruktora klasy Queue podać inną wartość.

Metoda Dequeue() zwraca element określany jako głowa kolejki (element wskazywany przez indeks head). Następnie na miejscetablicy o indeksie head wstawia wartość nullzwiększa” indeks head. Pierwszy elementkolejce można także tylko sprawdzić, nie usuwając gokolejki — służy do tego metoda Peek().

Klasa Queueprzeciwieństwie do klasy ArrayList nie pozwala na dowolny dostęp do umieszczonychniej elementów. Oznacza to, że nie można uzyskać dostępu do trzeciego elementukolejce bez uprzedniego wyjęciakolejki dwóch pierwszych elementów. Klasa Queue zawiera jednak metodę Contains(), która umożliwia sprawdzenie, czy szukany obiekt znajduje siękolejce, czy nie. Jeśli wiadomo, że będzie potrzebny swobodny dostęp do elementów, kolejka nie jest dobrym rozwiązaniem.takim przypadku należy zastosować tablicę ArrayList. Kolejka jest idealnym rozwiązaniemsytuacjach,których ważne jest zachowanie kolejności otrzymywania elementów.

Uwaga   Kolejki czasem nazywane są także strukturami danych typu FIFO. FIFO to skrót od wyrażenia „First In, First Out” (pierwszy przyszedł pierwszy wyszedł).

Struktura stosu, czyli szeregowanie typu „pierwszy przyszedł — ostatni obsłużony”

Kolejka to struktura danych umożliwiająca dostęp do elementówkolejności pierwszy przyszedłpierwszy obsłużony.tym celu wykorzystywana jest wewnętrzna tablica cykliczna elementów typu object oraz metody Enqueue()Dequque(). Szeregowanie typu pierwszy przyszedł pierwszy obsłużony ma wiele zastosowań, głównieusługach takich jak serwer WWW, kolejkowanie wydruków orazinnych programach, obsługujących wiele żądań.

Innym popularnym sposobem szeregowania zadań jest pierwszy przyszedł ostatni obsłużony. Struktura danych, umożliwiająca taką formę dostępu do elementów, to stos. Biblioteka klas bazowych środowiska .NET Framework zawiera klasę System.Collections.Stack, która tak jak klasa Queue przechowuje elementycyklicznej tablicy elementów typu object. Stos obsługiwany jest za pomocą dwóch metod metody Push(item), umożliwiającej wstawienie elementu na stos oraz metody Pop() powodującej usunięcie oraz zwrócenie elementu ze szczytu stosu.

Graficznie stos można przedstawić jako pionowy zbiór elementów. Umieszczany na stosie element kładziony jest na szczycie stosu. Ściągnięcie elementu ze stosu polega na zdjęciu elementu znajdującego się na szczycie stosu. Zamieszczone poniżej dwa rysunki przedstawiają stos po dodaniu do niego elementów o numerach 1,23 oraz po ściągnięciu ich.

0x01 graphic

Ilustracja 5. Stostrzema elementami

0x01 graphic

Ilustracja 6. Stos po ściągnięciu trzech elementów

Domyślna pojemność stosu (klasa Stack) wynosi 10 elementów,przeciwieństwie do 32 elementówprzypadku kolejki (klasa Queue). Tak samo, jakprzypadku klas QueueArrayList, pojemność stosu można określić przy wywoływaniu konstruktora. Podobnie jakklasie ArrayList, gdy pojemność wewnętrznej tablicy musi zostać zwiększona, jej rozmiar jest podwajany (jednakprzypadku kolejkikonstruktorze można było również zdefiniować współczynnik wzrostu).

Uwaga   Stosy określa się również strukturami danych typu LIFO (Last In, First Out).

Stos częsta metaforajęzyku informatycznym

Mówiąc o kolejkach, można przytoczyć dużo przykładówrzeczywistego świata — kolejkiurzędzie, szeregowanie zadań wydruku itd. Niestety, trudniej jest wymyślić przykładyżycia odwzorowujące działanie stosu. Jest on jednak bardzo popularną strukturą danych, stosowanąwielu różnych aplikacjach komputerowych.

Wystarczy przyjrzeć się jakiemuś językowi programowania wyższego poziomu — na przykład językowi C#. Po uruchomieniu programu napisanegoC#, środowisko CLR tworzy stos wywołań (call stack), na którym zapisywane są wszystkie wywołania funkcji. Za każdym razem, gdy wywoływana jest jakaś funkcja, jej dane odkładane są na stos. Po zakończeniu wykonywania funkcji, jej dane ściągane ze stosu wywołań. Informacje na samym szczycie stosu należą do aktualnie działającej funkcji. Aby zobaczyć wizualną prezentację stosu wywołań, należy utworzyć projektVisual Studio® .NET, ustawićkodzie punkt przerwania (breakpoint)menu Debug wybrać polecenie Start. Wykonywanie kodu zatrzyma się na ustawionym punkcie przerwania. Aby wyświetlić okno stosu wywołań, należy wtedy wybraćmenu Debug polecenie Windows,następnie Call Stack.

Stosy również powszechnie stosowane przy parsowaniu gramatyk (od prostych wyrażeń algebraicznych po komputerowe języki programowania), jako sposób symulowania rekursji,nawetmodelach wykonywania instrukcji.

Ograniczenia indeksowania bezpośredniego

W pierwszym artykule tej serii mówiliśmy o tym, że charakterystycznymi cechami zwykłych tablic jest homogenicznośćindeksowanie bezpośrednie. Oznacza to, że do i-tego elementu można uzyskać dostęp (w celu zapisu lub odczytu)czasie stałym. Czas stały zapisuje się jako O(1).

Rzadko znamy numer pozycjitablicy, na której znajdują się potrzebne nam dane. Rozważmy na przykład bazę danych pracowników. Pracownika można zidentyfikować na podstawie unikalnego numeru ubezpieczenia, mającego postać DDD-DD-DDDD, gdzie D jest cyfrą (0 - 9). Jeśli dane pracowników zapisanolosowej kolejnościjednej tablicy, to znalezienie danych pracownika o numerze ubezpieczenia 111-22-3333 wymagałoby przeszukania całej tablicy złożoność obliczeniowa tej operacji wynosi O(n). Lepszym rozwiązaniem byłoby wstępne posortowanie tablicy według numeru ubezpieczenia, co zredukowałoby złożoność obliczeniową operacji wyszukiwania do O(log n).

Najlepiej jednak byłoby dostać się do rekordu pracownikaczasie O(1). Taki czas dostępu można uzyskać tworząc ogromną tablicętaką liczbą elementów, by zmieściły sięniej wszystkie możliwe numery ubezpieczenia. Tablica taka zaczynałaby się wtedy od elementu o numerze 000-00-0000,kończyła na elemencie o numerze 999-99-9999, jak pokazano na rysunku poniżej:

0x01 graphic

Ilustracja 7. Tablica zawierająca wszystkie możliwe elementy dla 9-cyfrowego numeru ubezpieczenia

Jak pokazano na ilustracji, każdy rekord pracownika zawiera informacje takie jak nazwisko, numer telefonu, wysokość wynagrodzenia itp. Tablica jest indeksowana jest liczbami odpowiadającymi numerom ubezpieczenia pracowników. Dzięki takiemu rozwiązaniu do danych konkretnego pracownika można uzyskać dostępstałym czasie. Wadą takiego rozwiązania jest ogromne marnotrawstwo pamięci wszystkich numerów ubezpieczenia jest dokładnie 109, czyli tablica ma jeden miliard (1.000.000.000) elementów. Jeśli firma zatrudnia 1.000 osób, to wykorzystywane będzie tylko 0,0001% miejsctablicy. Dla porównania, firma musiałaby zatrudnić około jedną szóstą populacji świata, aby prawie zapełnić tę tablicę.

Kompresja indeksowania bezpośredniego za pomocą funkcji haszującej

Oczywiście niedopuszczalne jest tworzenie tablicy mogącej pomieścić milion elementów tylko po to, by przechowywaćniej informacje o 1.000 pracowników. Jednak możliwość dostępustałym czasie do danych konkretnego pracownika jest wysoce pożądana. Jednymmożliwych rozwiązań jest skrócenie numeru ubezpieczenia pracownika do czterech ostatnich cyfr. Dzięki temu indeksy tablicy nie należałyby do zakresu od 000-00-0000 do 999-99-9999, tylko do zakresu 0000 - 9999. Na ilustracji 8. przedstawiono zmniejszoną tablicę pracowników.

0x01 graphic

Ilustracja 8. Zmniejszona tablica

Takie podejście zapewnia stały czas dostępu do rekordów oraz dużo lepsze wykorzystanie pamięci. Nie ma znaczenia, czy wybierzemy cztery ostatnie cyfry numeru ubezpieczenia, cztery cyfry środkowe, czy cyfrę pierwszą, trzecią ósmądziewiątą.

Matematyczne przekształcenie dziewięciocyfrowego numeru ubezpieczenia na liczbę czterocyfrową nazywa się haszowaniem. Tablica, która wykorzystuje haszowanie do zmniejszenia zakresu indeksowania, nazywa się tablicąhaszowaniem.

Funkcja haszująca jest funkcją odpowiedzialną za haszowanie.przypadku naszego przykładu, funkcję haszującą można przedstawićnastępujący sposób:

H(x) = ostatnie cztery cyfry z x

Danymi wejściowymi funkcji H może być dowolna liczba dziewięciocyfrowa, reprezentująca numer ubezpieczenia,wynikiem jej działania będzie liczba czterocyfrowa, utworzonaczterech ostatnich cyfr liczby dziewięciocyfrowej. Stosując terminologię matematyczną można powiedzieć, że funkcja H odwzorowuje zbiór dziewięciocyfrowych numerów ubezpieczenia na zbiór numerów czterocyfrowych, co pokazano na ilustracji 9.

0x01 graphic

Ilustracja 9. Graficzne przedstawienie funkcji haszującej

Na ilustracji 9. zobrazowano także kolizje, które mogą wystąpićprzypadku każdej funkcji haszującej. Przy odwzorowywaniu większego zbioru na mniejszy nie ma takiej funkcji haszującej, która dwóm elementom większego zbioru nie przyporządkowałaby tej samej wartościzbiorze mniejszym.naszym przykładzie wszystkie numery ubezpieczenia kończące się cyframi 0000 zostaną przekształcone na liczbę 0000. Oznacza to, że wynik haszowania wartości takich jak 000-00-0000, 113-14-0000, 933-66-0000 oraz wielu innych będzie wynosił 0000.

Wracając do wcześniejszego przykładu rozważmy, co by się stało, gdyby zatrudniono nowego pracownika o numerze ubezpieczenia 123-00-0191. Dodanie tego pracownika do bazy stanowi problemem, ponieważtablicy na pozycji 0191 wpisany jest już jakiś pracownik (Jisun Lee).

Uwaga matematyczna   Za pomocą terminologii matematycznej funkcję haszującą można opisać jako funkcję f : A -> B. Dopóki |A| > |B|, to funkcja f nie jest funkcją różnowartościowąkolizje będą występować.

Występowanie kolizji może stanowić poważny problem.następnej sekcji przyjrzymy się powiązaniu funkcji haszującejwystępowaniem kolizji,następnie zajmiemy się sposobami radzenia sobiekolizjami. Później przejdziemy do tematu klasy System.Collections.Hashtable,której zaimplementowano tablicęhaszowaniem. Przyjrzymy się funkcji haszującej wykorzystywanejklasie Hashtable, sposobom rozwiązywania kolizji oraz paru przykładom zastosowania klasy Hashtablepraktyce.

Unikanie kolizji oraz ich rozwiązywanie

Podczas dodawania danych do tablicyhaszowaniem wystąpienie kolizji może popsuć całą operację. Jeśli nie wystąpi kolizja, możemy normalnie umieścić danetablicy na pozycji o indeksie wskazanym przez funkcję haszującą. Jeśli jednak wystąpi kolizja, musimy znaleźć jakieś inne rozwiązanie. Ze względu na zwiększeniem kosztów działaniaprzypadku wystąpienia kolizji, naszym celem powinno być unikanie ich.

Częstość występowania kolizji jest bezpośrednio związanazastosowaną funkcją haszującą oraz różnorodnością danych podawanych na wejściu funkcji haszującej. Zakładając, że numery ubezpieczenia nadawane są losowo, użycie ostatnich czterech cyfr numeru ubezpieczenia jest idealną funkcją haszującą. Jeśli jednak numery ubezpieczenia przyznawane taki sposób, że urodzenitym samym roku lubtym samym miejscu będą mieli podobne cztery ostatnie cyfry, to kolizje wystąpiąwiększym prawdopodobieństwem, szczególnieprzypadku, gdy datymiejsca urodzenia pracowników nie są zróżnicowane.

Uwaga   Gruntowna analiza wyników uzyskiwanychfunkcji haszujących wymaga znajomości statystyki, co wykracza poza tematykę tego artykułu. Najważniejsze jest zapewnienie, by prawdopodobieństwo odwzorowania dowolnej wartości na konkretny indekstablicyhaszowaniem,której jest miejsce na k elementów, wynosiło mniej niż 1/k (nie przejmuj się, jeśli tego nie rozumiesz ;-).

Wybór odpowiedniej funkcji haszującej ma ogromny wpływ na możliwość uniknięcia kolizji.związkutym zagadnieniem przeprowadzono wiele badań, ponieważ to, jaką zastosowano funkcję haszującą, ma bardzo duży wpływ na całkowitą wydajność tablicyhaszowaniem.następnej sekcji przyjrzymy się funkcji haszującej, zastosowanejklasie Hashtable platformy .NET Framework.

Istnieje kilka strategii postępowaniaprzypadku wystąpienia kolizji. Zadanie, które trzeba wykonać, określane mianem rozwiązywania kolizji, polega na znalezieniu innego miejsca dla elementu, gdy pierwsza znaleziona pozycja jest już zajęta. Jednymnajprostszych rozwiązań jest adresowanie liniowe, które działanastępujący sposób:

  1. Przy dodawaniu nowego elementu do tablicyhaszowaniem określ za pomocą funkcji haszującej pozycję docelową tego elementu.

  2. Sprawdź, czy miejsce to nie jest już zajęte przez inny element. Jeśli jest puste, umieść tam elementzakończ działanie funkcji. Jeśli miejsce było zajęte, przejdź do punktu trzeciego.

  3. Jeśli miejsce wskazywane przez funkcję haszującą to i, sprawdź, czy pozycja i + 1 jest wolna. Jeśli jest również zajęta, sprawdź pozycję i + 2tak dalej, dopóki nie znajdziesz wolnej pozycji.

Rozważmy przypadek,którym do tablicyhaszowaniem zostali wpisani następujący czterej pracownicy: Alice (333-33-1234), Bob (444-44-1234), Cal (555-55-1237), Danny (000-00-1235)Edward (111-00-1235). Po dodaniu ich rekordów tablica będzie wyglądałanastępujący sposób:

0x01 graphic

Ilustracja 10. Tablicahaszowaniem zawierająca czterech pracowników o podobnych numerach ubezpieczenia

Numer ubezpieczenia Alice po haszowaniu to 1234, tak więc jej rekord zostaje wpisany na pozycji 1234. Numer Boba po haszowaniu także wynosi 1234, lecz na tej pozycji znajduje się już rekord Alice, dlatego rekord Boba zostanie wpisany na następnej pozycji — czyli pod numerem 1235. Następny pracownik po Bobie to Cal. Jego numer ubezpieczenia po haszowaniu to 1237. Pozycja o tym indeksie jest wolna, więc umieszczamyniej rekord Cala. Następny jest Danny; jego numer po haszowaniu to 1235. Pozycja o indeksie 1235 jest już zajęta, więc sprawdzamy pozycję 1236. Ponieważ miejsce to jest wolne umieszczamynim dane Danny'ego. Jako ostatni do tablicy dodawany jest wpis Edwarda. Jego numer po haszowaniu również wynosi 1235. Pozycja o tym indeksie jest już zajęta; sprawdzamy pozycję 1236, ale ona również jest już zajęta. Następnie sprawdzamy pozycję 1237 jest zajęta przez Cala. Sprawdzamy kolejną pozycje —1238 — jest wolna, więc umieszczamyniej dane Edwarda.

Kolizje stanowią również problem podczas przeszukiwania tablicyhaszowaniem. Weźmy na przykład tablicę opisaną powyżejzałóżmy, że szukamy danych Edwarda. Bierzemy jego numer ubezpieczenia — 111-00-1235; funkcja haszująca zwraca wartość 1235. Rozpoczynamy poszukiwaniasprawdzamy pozycję o indeksie 1235, jednaktym miejscu znajdujemy rekord Boba,nie Edwarda. Musimy więc sprawdzić pozycję o numerze 1236, aletym miejscu niestety znajduje się rekord Danny'ego. Liniowe przeszukiwanie kontynuujemy tak długo, aż znajdziemy albo dane Edwarda, albo puste miejsce, co świadczy o tym, że nie istnieje pracownik o numerze ubezpieczenia 111-00-1235.

Adresowanie liniowe jest łatwerealizacji, ale nie jest dobrą metodą rozwiązywania problemu kolizji, ponieważ prowadzi do grupowania zajętych pozycji. Wyobraźmy sobie przypadek,którym pierwszych dziesięciu wpisywanych do tablicy pracowników ma numer ubezpieczenia dający po haszowaniu taką samą wartość — na przykład 3344. Oznacza to, żetablicy będzie zajęte 10 kolejnych pozycji (3344 - 3353). Grupa tych rekordów będzie wymagała adresowania liniowego za każdym razem, gdy będzie poszukiwany jedentych 10 rekordów. Co więcej, do tej grupy zostanie dołączony każdy pracownik, którego numer ubezpieczenia po haszowaniu da wartośćzakresu od 3345 do 3353. Aby umożliwić szybkie wyszukiwanie danychtablicy, dane muszą być rozłożonetablicy równomiernie,nie grupować się wokół pewnych punktów.

Bardziej skomplikowana technika adresowania to adresowanie kwadratowe, które polega na sprawdzaniu pozycji odległych od pozycji znalezionejpierwszej próbie o wartość, będącą podniesionym do kwadratu numerem próby. Czyli jeśli jakaś pozycja jest zajęta, to zamiast sprawdzać miejsca o indeksach s + 1, s + 2 itd. (jak miało to miejsceprzypadku adresowania liniowego),adresowaniu kwadratowym sprawdzane są pozycje o indeksach s + 12, s - 12,następnie s + 22, s - 22, s + 32 itd. Niestety adresowanie kwadratowe także może prowadzić do grupowania.

W następnej sekcji zajmiemy się trzecią metodą rozwiązywania kolizji haszowaniem dwukrotnym, wykorzystywanym równieżklasie Hashtable środowiska .NET Framework.

Klasa System.Collections.Hashtable

Klasa Hashtable biblioteki klas bazowych środowiska .NET Framework jest implementacją tablicyhaszowaniem. Przy dodawaniu elementu do tablicyhaszowaniem należy podać nie tylko dany element, ale także unikalny klucz, poprzez który będziemy uzyskiwali dostęp do tego elementu. Zarówno klucz jakelement mogą być obiektami dowolnego typu.naszym poprzednim przykładzie kluczem jest numer ubezpieczenia pracownika. Do tablicyhaszowaniem elementy dodawane są za pośrednictwem metody Add().

Aby odczytać elementtablicyhaszowaniem, należy podać przyporządkowany elementowi klucz, tak jakprzypadku zwykłej tablicy podaje się liczbę porządkową, będącą indeksem tablicy. Podany niżej kod, napisanyjęzyku C#, służy zaprezentowaniu tej koncepcji.kodzie do tablicyhaszowaniem dodawane są kolejne elementy,każdym elementem związany jest klucz będący ciągiem znaków. Do każdegododanych elementów można uzyskać dostęp, podając odpowiedni łańcuch znaków.

using System;

using System.Collections;

public class HashtableDemo

{

private static Hashtable ages = new Hashtable();

public static void Main()

{

// Dodanie elementów do tablicy z haszowaniem,

// każdemu elementowi przypisywany jest łańcuch znaków

ages.Add("Scott", 25);

ages.Add("Sam", 6);

ages.Add("Jisun", 25);

// Uzyskanie dostępu do elementu o podanym kluczu

if (ages.ContainsKey("Scott"))

{

int scottsAge = (int) ages["Scott"];

Console.WriteLine("Scott, lat " + scottsAge.ToString());

}

else

Console.WriteLine("Danych Scotta nie ma w tablicy...");

}

}

W programie zastosowano również metodę ContainsKey(), która zwraca wartość boolowską wskazującą, czy wybrany klucz znajduje siętablicyhaszowaniem. Klasa Hashtable posiada właściwość Keys, która zwraca zbiór wszystkich kluczy, wykorzystanychdanej tablicyhaszowaniem. Właściwością tą można się posłużyć, jeśli chcemy wypisać wszystkie elementy znajdujące siętablicyhaszowaniem:

// Przejście przez wszystkie elementy znajdujące się w tablicy z haszowaniem

foreach(string key in ages.Keys)

Console.WriteLine("Wartość ages[\"" + key + "\"] = " + ages[key].ToString());

Kolejność,jakiej klucze zostały dodane do tablicy, oraz kolejność kluczytablicy nie musi być taka sama. Porządek zbioru kluczy zależy od tego, na jakiej pozycji przechowywany jesttablicy element związanykluczem. Na przykład wykonanie powyższych fragmentów kodu daje następujący wynik:

Wartość ages["Jisun"] = 25

Wartość ages["Scott"] = 25

Wartość ages["Sam"] = 6

Wynik ten nie zależy od kolejności dodania elementów do tablicy ("Scott", "Sam", "Jisun").

Funkcja haszująca klasy Hashtable

Funkcja haszująca klasy Hashtable jest bardziej skomplikowana niż funkcja używana we wcześniejszym przykładzienumerem ubezpieczenia. Po pierwsze, pamiętajmy o tym, że funkcja musi zwracać wartość porządkową.przypadku numeru ubezpieczenia nie stanowiło to żadnego problemu, ponieważ numer ubezpieczenia był liczbą. Funkcja haszująca musiała tylko wyciąć cztery ostatnie cyfry numeru ubezpieczenia.klasie Hashtable klucz mogą stanowić dane dowolnego typu.ostatnim przykładzie kluczami były łańcuchy znaków, takie jak Scott” lub Sam”. Nictym niezwykłego, żetakim przypadku zastanawiamy się,jaki sposób funkcja haszująca przekształca łańcuch znakówliczbę.

Ta magiczna transformacja możliwa jest dzięki funkcji GetHashCode(), zdefiniowanejklasie System.Object. Podstawowa implementacja metody GetHashCode()klasie Object zwraca unikalną liczbę całkowitą, która nie zmienia się tak długo, jak długo istnieje egzemplarz obiektu. Każdy typ danych dziedziczy bezpośrednio lub pośrednio po typie Object, tak więc każdy element ma dostęp do tej metody. Dlatego każdy łańcuch znaków lub obiekt dowolnego innego typu może być reprezentowany przez unikalny numer identyfikacyjny.

Funkcja haszująca klasy Hashtable zdefiniowana jestnastępujący sposób:

H(key) = [GetHash(key) + 1 + (((GetHash(key) >> 5) + 1) % (hashsize - 1))] % hashsize

Domyślnie GetHash(key) zwraca wynik wywołania funkcji GetHashCode(key) (ale przy stosowaniu klasy Hashtable można podać własną funkcję haszującą, którą będzie wskazywać GetHash()). GetHash(key) >> 5 to wynik funkcji haszującej dla zadanego klucza key, przesunięty o 5 bitówprawo, co jest równoważne podzieleniu wyniku przez 32. Wartość hashsize jest równa liczbie miejsctablicyhaszowaniem. Jak pisałem wcześniej, operator % wykorzystywany jestarytmetyce modularnej (dla przypomnienia x % y jest równe reszciedziałania x / y i wartość ta zawsze należy do zakresu od 0 do y - 1). Dzięki tej końcowej operacji modulo, wartość H(key) zawsze będzie należeć do przedziału od 0 do hashsize - 1. Ponieważ wartość hashsize jest równa rozmiarowi tablicy z haszowaniem, wartość zwracana przez funkcję haszującą H zawsze będzie się znajdowaładozwolonym przedziale wartości.

Rozwiązywanie kolizjiklasie Hashtable

Podczas umieszczania lub odczytywania elementutablicyhaszowaniem mogą wystąpić kolizje. Gdy dodajemy element, musimy znaleźć wolne miejsce. Gdy odczytujemy elementtablicy, musimy go odszukać, jeśli nie znajduje się na pierwszej wyszukanej pozycji. Wcześnie analizowaliśmy dwa sposoby rozwiązywania kolizji adresowanie liniowe oraz adresowanie kwadratowe.klasie Hashtable wykorzystano inną technikę, zwaną haszowaniem dwukrotnym (w niektórych źródłach metoda ta nazywana jest rehaszowaniem).

Haszowanie dwukrotne opiera się na istnieniu zbioru różnych funkcji haszujących H1 - Hn. Podczas dodawania lub odczytywania elementutablicyhaszowaniem, początkowo wykorzystuje się funkcję haszującą H1. Jeśli wynik funkcji H1 prowadzi do kolizji, sprawdzamy wynik funkcji H2miarę potrzeby wyniki kolejnych funkcji haszujących aż do Hn.poprzedniej sekcji przedstawiłem jedną funkcję haszującą jest to właśnie początkowa funkcja haszująca (H1). Inne funkcje haszujące są do niej bardzo podobne, różnią się tylko współczynnikiem, przez który mnożona jest część wyrażenia. Mówiąc ogólnie, funkcja haszująca Hk jest zdefiniowananastępujący sposób:

Hk(key) = [GetHash(key) + k * (1 + (((GetHash(key) >> 5) + 1) % (hashsize - 1)))] % hashsize

Uwaga matematyczna   Przy dwukrotnym haszowaniu ważne jest, by przy wykonywaniu hashsize prób każda pozycjatablicyhaszowaniem była sprawdzona tylko jeden raz. Oznacza to, że dla danego klucza funkcje HiHj nie powinny wskazaćtablicy tej samej pozycji.formule dwukrotnego haszowania, wykorzystanejklasie Hashtable, założenie to będzie spełnione, gdy wynik wyrażenia (1 + (((GetHash(key) >> 5) + 1) % (hashsize - 1)) oraz wartość hashsize są względnie pierwsze. Dwie liczby są względnie pierwsze, jeśli nie mają żadnych wspólnych dzielników poza jedynką. Jeśli hashsize jest liczbą pierwszą, to te dwie liczby na pewno będą względnie pierwsze.

Haszowanie dwukrotne zapewnia skuteczniejsze unikanie kolizji niż adresowanie liniowe oraz kwadratowe.

Współczynnik zapełnienia oraz powiększanie tablicyhaszowaniem

Klasa Hashtable zawiera prywatną zmienną składowa o nazwie loadFactor, która określa stosunek maksymalnej liczby elementów do całkowitej liczby miejsctablicyhaszowaniem. Współczynnik zapełnienia loadFactor o wartości 0,5 mówi nam, żetablicyhaszowaniem można zapełnić co najwyżej połowę miejsc. Oznacza to, że połowa tablicy musi pozostać pusta.

Przeciążony konstruktor klasy Hashtable umożliwia określenie wartości współczynnika zapełnienia loadFactor jako dowolną wartość pomiędzy 0,11,0. Jednak bez względu na to, jaką wartośćtego zakresu podamy, zastosowany współczynnik zapełnienia będzie wynosił 72% podanej wartości. Jeśli więc podamy wartość 1, to wartość loadFactor będzie wynosiła 0,72. Liczba 0,72 została uznana przez firmę Microsoft za najbardziej optymalny współczynnik zapełnienia, dlatego najlepiej jest podawać domyślną wartość 1,0 (wartość ta zostanie automatycznie przeskalowana na 0,72). Można więc używać nieprzeciążonej wersji konstruktora, która stosuje domyślną wartość 1 (czyli prawdziwy współczynnik zapełnienia będzie wynosił 0,72).

Uwaga   Przez parę dni sondowałem uczestników różnych list dyskusyjnych oraz pracowników Microsoft, dlaczego zastosowano to automatyczne skalowanie. Zastanawiałem się, dlaczego nie zdefiniowano dozwolonego przedziału jako przedziału od 0,072 do 0,72, jeśli wyjściowe wartościtak miały należeć do tego przedziału.końcu dotarłem do zespołu, który pracował nad klasą Hashtablektóry podzielił się ze mną swoimi przemyśleniami na ten temat.badańtestów wynikało, że wszelkie wartości współczynnika zapełnienia większe niż 0,72 znacznie obniża wydajność tablicy. Zdaniem tego zespołu programiści łatwiej zapamiętają, że optymalną wartością współczynnika jest 1,nie 0,72. Rozwiązanie to trochę zmniejsza funkcjonalność struktury, ale jednocześnie znacznie ułatwia jej stosowanie, wpływając tym samym na komfort pracy programistów.

Za każdym razem, gdy do obiektu klasy Hashtable dodawany jest nowy element, następuje sprawdzanie, czy operacja ta nie spowoduje przekroczenia dopuszczalnego współczynnika zapełnienia tablicy. Jeśli współczynnik miałby zostać przekroczony, tablicahaszowaniem jest automatycznie powiększana. Powiększanie tablicy odbywa siędwóch etapach:

  1. Liczba pozycjitablicyhaszowaniem jestprzybliżeniu podwajana. Określając to ściślej, liczba miejscwewnętrznej tablicy jest zwiększanaliczby pierwszej, będącej aktualnym rozmiarem tablicy, do kolejnej większej liczby pierwszej (dla przypomnienia — aby haszowanie dwukrotne działało poprawnie, liczba miejsctablicyhaszowaniem musi być liczbą pierwszą).

  2. Ponieważ wynik dwukrotnego haszowania zależy od liczby wszystkich miejsctablicy, należy ponownie wyliczyć za pomocą funkcji haszującej nowe pozycje dla wszystkich wartości wpisanychtablicy (ze względu na powiększenie rozmiaru tablicypunkcie pierwszym).

Na szczęście wszystkie te skomplikowane operacje są wykonywane przez klasę Hashtableramach metody Add(), dlatego nie musimy przejmować się szczegółami.

Współczynnik zapełnienia wpływa na rozmiar tablicyhaszowaniem oraz spodziewaną liczbę próbprzypadku kolizji.wysokim współczynnikiem zapełnienia, zezwalającym na względne zagęszczenie tablicy, związane jest mniejsze zapotrzebowanie na pamięć, aledrugiej strony związana jestnim większa liczba próbprzypadku kolizji. Spodziewana liczba próbprzypadku kolizji wynosi 1 / (1 lf), gdzie lf jest współczynnikiem zapełnienia.

Jak wspomniałem wcześniej, Microsoft ustalił standardową wartość współczynnika zapełnienia na 0,72. Dlategoprzypadku kolizji można spodziewać się 3,5 kolejnych prób. Ponieważ szacowania te nie zależą od liczby elementów zapisanychtablicy, asymptotyczna złożoność obliczeniowa związana ze znalezieniem konkretnego elementutablicyhaszowaniem wynosi O(1) — wynik ten znacznie przebija złożoność przeszukiwania zwykłej tablicy, wynoszącą O(n).

Na koniec warto sobie uświadomić, że powiększenie tablicyhaszowaniem jest kosztowną operacją. Dlatego jeśliprzybliżeniu wiemy, ile elementów zostanie dodanych do tablicyhaszowaniem powinniśmy za pomocą konstruktora odpowiednio ustawić początkowy rozmiar tablicy, aby uniknąć niepotrzebnego zwiększania tablicy.

Podsumowanie

W tym artykule poznaliśmy trzy rodzaje struktur danych zaimplementowanychbibliotece klas bazowych środowiska .NET:

Klasy QueueStack pozwalają na przechowywanie dowolnej liczby obiektów niejednorodnych. Różnią się one od klasy ArrayList tym, że klasa ArrayList pozwala na bezpośredni dostęp do dowolnego przechowywanego elementu.przypadku klasy QueueStack do elementów można uzyskać dostęp jedynie w z góry określonej kolejności.

W kolejkach stosowane jest szeregowanie FIFO (pierwszy przyszedł — pierwszy obsłużony). Oznacza to, że elementy mogą być usuwanekolejki jedynietakiej samej kolejności,jakiej zostałyniej umieszczone. Aby zapewnić taką kolejność, klasa Queue udostępnia metody Enqueue()Dequeue(). Kolejki to struktury danych przydatneszeregowaniu zadań orazinnych sytuacjach, gdy ważna jest kolejność pojawiania się elementów.

Z koleistosie występuje szeregowanie LIFO (ostatni przyszedł — pierwszy obsłużony). Operacje na stosie można przeprowadzać za pomocą metod Push() oraz Pop(). Stos ma wiele zastosowańinformatyceod wykonywania kodu po parsery.

Ostatnią omawianą strukturą danych była tablicahaszowaniem. Klasa Hashtable poszerza funkcjonalność klasy ArrayList o możliwość indeksowania tablicy kluczami dowolnego typu (nie tylko liczbami całkowitymi). Gdy potrzebne jest przeszukiwanie tablicy za pomocą unikalnego klucza, najbardziej wydajnym rozwiązaniem jest zastosowanie tablicyhaszowaniem, gdyż przeszukiwanie tablicy według klucza przeprowadzane jestczasie stałym,przeciwieństwie do liniowego czasu przeszukiwania zwykłej tablicy.

Na tym kończę drugi artykuł serii poświęconej strukturom danych.następnym artykule przyjrzymy się drzewom wyszukiwań binarnych strukturom danych,których przeszukiwanie ma złożoność obliczeniową na poziomie O(log n). Drzewa wyszukiwań binarnych — tak samo jak tablicehaszowaniem — są najlepszym rozwiązaniem, gdy planujemy częste przeszukiwanie danych.

Życzę miłego programowania!

Bibliografia

0x01 graphic

Scott Mitchell — autor 5 książekzałożyciel witryny 4GuysFromRolla.com. Od 5 lat zajmuje sięMicrosoft technologiami internetowymi. Scott pracuje jako niezależny konsultant, szkoleniowiecautor artykułów,niedawno zdobył dyplominformatyki na Uniwersytecie KalifornijskimSan Diego. Jego adres e-mail to mitchell@4guysfromrolla.com.



Wyszukiwarka

Podobne podstrony:
27 Struktury danych stos, kolejka, lista, drzewo id (2)
Algorytmy i struktury danych 06 Sterty i kolejki priorytetowe
Algorytmy i struktury danych Wykład 1 Reprezentacja informacji w komputerze
Algorytmy i struktury danych Wykład 3 i 4 Tablice, rekordy i zbiory
14 Struktury i inne formy danych
Algorytmy i struktury danych, AiSD C Lista04
4 TurboPascal Struktury i typy danych
Algorytmy i struktury danych 08 Algorytmy geometryczne
Instrukcja IEF Algorytmy i struktury danych lab2
Algorytmy, struktury danych i techniki programowania wydanie 3
Algorytmy i struktury danych 1
Cw 5 Struktury Danych Materiały dodatkowe
Ściaga sortowania, algorytmy i struktury danych
ukl 74xx, Informatyka PWr, Algorytmy i Struktury Danych, Architektura Systemów Komputerowych, Archit
cw 0 1, pwr, informatyka i zarządzanie, Informatyka, algorytmy i struktury danych
AIDS w7listy, studia, Semestr 2, Algorytmy i struktury danych AISD, AIDS
lab3 struktury danych
k balinska projektowanie algorytmow i struktur danych

więcej podobnych podstron