W kursie Javy (a dokładniej w lekcji 5) omówione były już podstawy kolekcji w języku Java. Dzisiaj poszerzamy tą wiedzę o informacje, które moga Ci się przydać w pracy oraz na rozmowach.Kolekcje to ta grupa obiektów z którą spotkasz się bardzo szybko w swojej karierze. O ile często wystarczy ich podstawowa znajomość, warto nieco się zgłębić i poznać ich wady/zalety, aby wiedzieć którą kolekcje zastosować kiedy. W tej lekcji zajmiemy się implementacjami, które rozszerzają interfejs Collection.
Słowem wstępu — notacja dużego O
Zanim przejdziemy do opisu poszczególnych kolekcji, zatrzymajmy się na moment, żeby omówić sobie notację dużego O, która będzie nam potrzebna do porównania różnych kolekcji. Szczegółowe informacje na ten temat zawrzemy w oddzielnym wpisie, dzisiaj jedynie wstęp.
Notacja dużego O służy w informatyce do porównywania klas algorytmów w sposób miarodajny — weźmy na przykład sortowanie. Najprostszym sposobem na porównanie różnych podejść jest zmierzenie czasu sortowania tych samych elementów, np. 100 liczb. Metoda A zrobi to w 10 jednostek czasu a metoda B zrobi to w 20 jednostek czasu. Czy znaczy to, że metoda A jest lepsza / wydajniejsza? Absolutnie nie! Wykonując próbę dla 1000 liczb okaże się, że metoda A potrzebuje już 100 jednostek czasu, a metoda B tylko 50. Aby rozwiązać ten problem wprowadzono notację dużego o, która mówi nam nie ile czasu zajmie pewna operacja, ale jak ten czas się zmienia w zależności od ilości danych, na których pracujemy.
Brzmi skomplikowanie, ale to naprawdę proste! Weźmy dla przykładu algorytm, który losuje liczbę z przedziału (a, b). Dla uproszczenia pomyślmy o tym jako o człowieku, który z worka wszystkich możliwych liczb z tego przedziału wybiera jedną. Czy czas potrzebny na wybranie tej liczby zmieni się w zależności od tego czy w worku będzie 5, 50 czy 500000 liczb? Nie! Oznacza to, że algorytm ten działa w czasie stałym, który oznaczamy jako O(1). A dlaczego O(1) a nie np. O(3) ? Ponieważ interesuje nas jedynie sposób, w jaki ten czas rośnie, a nie sam czas. Jeśli jest stały, używamy po prostu jednostki, aby pokazać, że się nie zmienia.
Jako drugi przykład weźmy algorytm, który dla każdej liczby od zera do x wypisuje ją na konsoli. W zależności od wejścia (x w tym wypadku) nasz algorytm będzie musiał wykonać różną ilość operacji — w tym wypadku dla każdej jednej liczby będzie to określona, stała liczba operacji (ponownie, nie ma znaczenia, czy będą to 3 operacje czy 50, ważne, że jest to stała ilość). Dlatego algorytm ten ma złożoność obliczeniową O(n).
A co, jeśli poprzedni algorytm zmodyfikujemy tak, aby wypisywał też kwadrat tej liczby? Nic się nie zmieni, ponieważ wypisanie oraz obliczenie kwadrata liczby są operacjami o stałym czasie wykonania, a więc złożoność nadal będzie wynosiła O(n).
Złożoność jest bardzo ważna ponieważ często nie wiemy, ile dokładnie danych będzie przetwarzanych w naszym systemie i przez to nie jesteśmy w stanie wykonać miarodajnych prób czasowych. Nawet jesli byśmy to zrobili, te założenia będą się zmieniać z czasem. Dlatego o ile nie jesteśmy absolutnie pewni, że będziemy przetwarzać określoną, niewielką ilość informacji (np. kilkanaście) w danym miejscu, zawsze powinniśmy wybierać algorytm o najmniejszej złożoności, a nie taki, który działa szybciej (czasowo) podczas testów. Oczywiście od tej reguły można robić wyjątki, ale tylko pod warunkiem, ze naprawdę wiemy co robimy, a decyzja ta została zweryfikowana i udokumentowana w specyfikacji projektu.
Porównanie złożoności operacji we wszystkich kolekcjach możesz znaleźć na stronie http://infotechgems.blogspot.ie/2011/11/java-collections-performance-time.html .
Zachęcamy także do zapoznania się z materiałami dostępnymi pod adresem https://codility.com/media/train/1‑TimeComplexity.pdf .
Tablica
Dla przekory pierwszą omawianą strukturą będzie tablica. Dla przekory ponieważ tablice nie są stricte kolekcjami (w tym sensie, że nie implementują interfejsu Collection), a elementem języka Java. Jako jedyne mogą przechowywać prymitywy (we wszystkich innych przypadkach ma miejsce autoboxing — automatyczna zamiana prymiwtywu na odpowiadającą mu reprezentacje obiektową — jeśli spróbujemy dodać prymityw).
Tablica to uporządkowany (mający kolejność, ale niekoniecznie posortowany!) zbiór elementów, które fizycznie w pamięci komputera są umieszczone obok siebie. Umożliwiają dostęp losowy (tzw. Random Access), czyli dostęp do elementu na dowolnie wybranej pozycji. Elementy tablicy mogą mieć wartość null! Aby utworzyć tablicę stosujemy poniższą konstrukcję:
String[] tablica = new String[6];
Dozwolone jest także umieszczenie nawiasów kwadratowych za nazwą zmiennej:
String tablica[] = new String[6];
Jak pewnie zauważyłaś, nie używamy tutaj nawiasów () jak w przypadku konstruktorów obiektów.
Inna dopuszczalna składnia, która od razu inicjuje tablicę wartościami jest poniższa:
String[] tablica = {"jeden", "dwa", "trzy", "cztery", "pięć", "sześć"};
Istnieje jeszcze jedna możliwość zadeklarowania tablicy — jako ostatni argument metody, dzięki czemu może one przyjmować nieokreśloną liczbę argumentów. Weźmy na przykład poniższą metodę:
public void wypiszStringiWNowychLiniach(String... tablica) { //argument ten jest tablicą
for (i = 0; i<tablica.length; i++) {
System.out.println(tablica[i]);
}
}
//przykładowe wywołania
wypiszStringiWNowychLiniach("jeden", "dwa", "trzy", "cztery", "pięć");
String[] tablica = {"jeden", "dwa", "trzy", "cztery", "pięć"};
wypiszStringiWNowychLiniach(tablica);
W powyższym przykładzie widzimy też jak odwoływać się do elementów tablicy — w przeciwieństwie do kolekcji nie używamy metod, a składni, pisząc nazwę zmiennej a następnie w nawiasie kwadratowym numer elementu.
Ostatnia bardzo ważna informacja na temat tablic jest taka, że rozmiaru tablicy nie można zmieniać! Jeśli utworzymy tablicę z 6 ‘miejscami’, aby dodać do niej 7 element, będziemy musieli utworzyć nową, większą, tablicę i przepisać wcześniejsze 6 elementów.
Lista (java.util.List)
Listy są jednymi z najczęściej używanych kolekcji — koncepcyjnie są podobne do tablic, można jednak (tak jak w przypadku wszystkich pozostałych kolekcji) dynamicznie zmieniać ich rozmiar (dodawać i usuwać elementy). Elementy w liście mogą się powtarzać (tzn. ten sam obiekt może być na liscie wielokrotnie). Istnieje kilka standardowych implementacji, z których najważniejsze zostały opisane poniżej:
Klasa | Cechy | Zastosowanie |
---|---|---|
ArrayList |
|
Najczęstszy wybór z racji najbardziej uniwersalnego zastosowania. Inne imeplementacje mają przewagę tylko w bardzo specyficznych przypadkach. Jeśli nie wiesz, jakiej listy potrzebujesz, wybierz tą. |
LinkedList |
|
LinkedList ma przewagę w przypadku dodawania elementów pojedynczo, w dużej ilości, w sposób trudny do przewidzenia wcześniej, kiedy przejmujemy się ilością zajmowanej pamięci.W praktyce nie spotkałem się z sytuacją, w której LinkedList byłoby wydajniejsze od ArrayList |
Vector |
|
API oficjalnie zaleca korzystanie z klasy ArrayList zamiast Vector |
Implementacją interfejsu List jest także klasa Stack — będziemy jednak omawiać stos jako osobną strukturę i tam omówimy tą klasę.
Aby utworzyć nową listę (do przykładów bedziemy korzystać z ArrayListy) wystarczy wywołać konstruktor:
List<String> lista = new ArrayList<String>();
Do dodawania elementu służy metoda add:
lista.add("jeden");
Pobieranie elementów odbywa się z użyciem metody get, podając pozycję elementu jako argument:
String pierwszy = lista.get(0);
Stan listy możemy sprawdzić korzystając z pomocniczych metod:
int iloscElementow = lista.size();
boolean listaJestPusta = lista.isEmpty();
Set / zbiór (java.util.Set)
Sety to zbiory elementów, w których nie możemy uzyskać dostępu do n’tego elementu, a jedynie możemy pobrac iterator (obiekt, który pozwoli nam pobrać kolejne elementy, w zależności od implementacji w porządku posortowanym lub losowym). Elementy w Setach nie mogą się powtarzać.
Klasa | Cechy | Zastosowanie |
---|---|---|
HashSet |
|
Sytuacje, kiedy nie potrzebujemy dostępu do konkretnego elementu, ale potrzebujemy często sprawdzać, czy dany element już istnieje w kolekcji (czyli chcemy zbudować zbiór unikalnych wartości).W praktyce często znajduje zastosowanie do raportowania/zliczeń oraz jako ‘przechowalnia’ obiektów do przetworzenia (jeśli ich kolejność nie ma znaczenia). |
LinkedHashSet |
|
Jesto to mniej znana i rzadziej stosowana implementacja, często może ona zastąpić HashSet poza specyficznymi przypadkami. W praktyce do iterowania w określonej kolejności często używane sa inne kolekcje (Listy, kolejki) |
TreeSet |
|
Jeśli potrzebujemy, aby nasz zbiór był posortowany bez dodatkowych operacji (sortowanie nastepuje już w momencie dodania elementu) oraz iterować po posortowanej kolekcji. |
Aby utworzyć nowy set (do przykładów bedziemy korzystać z HashSet) wystarczy wywołać konstruktor:
Set<String> set = new HashSet<String>();
Do dodawania elementu służy metoda add:
set.add("jeden");
Pobieranie elementów odbywa się z użyciem iteratora, np w pętli for:
for (String string : set) {
... }
Stan zbioru możemy sprawdzić korzystając z pomocniczych metod:
int iloscElementow = set.size();
boolean zbiorJestPusty = set.isEmpty();
Map (java.util.Map)
Mapy nie imlementują wprawdzie interfejsu Collection, ale są także częścią Java Collections API. Przechowują one mapowania pomiędzy kluczami, a wartościami. Zarówno dodanie elementu, jak i jego pobranie wymaga podania klucza. Kluczem może być dowolny obiekt. Elementy w Mapach mogą się powtarzać, pod warunkiem, że sa one wartościami. Klucze nie mogą się powtarzać. Mapy mają wiele wspólnego z omawianymi wyżej zbiorami (można o nich myśleć jak o zbiorach powiązań pomiędzy kluczem i wartością), przez co jak pewnie zauważysz dzielą wiele cech wspólnych.
Klasa | Cechy | Zastosowanie |
---|---|---|
HashMap |
|
Ogólny przypadek, tworzenie lokalnej pamięci podręcznej czy ‘słownika’ o ograniczonym rozmiarze, zliczanie wg klucza. |
LinkedHashMap |
|
Podobnie jak LinkedHashSet, rzadziej znana i stosowana. Przydatna, jeśli potrzebujemy iterować po kluczach w założonej kolejności. |
TreeMap |
|
Jeśli potrzebujemy, aby nasz zbiór był posortowany wg kluczy bez dodatkowych operacji (sortowanie nastepuje już w momencie dodania elementu) oraz iterować po posortowanej kolekcji. |
Hashtable |
|
Oficjalnie zaleca się korzystanie z HashSet w większości wypadków zamiast Hashtable |
Aby utworzyć nową mapę (do przykładów bedziemy korzystać z HashMap) wystarczy wywołać konstruktor:
Map<Integer, String> map= new HashMap<Integer,String>();
Do dodawania elementu służy metoda put:
map.put(1, "jeden");
Pobieranie elementów odbywa się z użyciem metody get, podając klucz jako argument:
String string = map.get(1)
Stan mapy możemy sprawdzić korzystając z pomocniczych metod:
int iloscElementow = map.size();
boolean mapaJestPusta = map.isEmpty();
boolean zawieraKlucz = map.containsKey(1);
boolean zawieraWartosc = map.containsValue("jeden");
Kolejki (java.util.Queue)
Kolejki dzielą się na dwa typy — LIFO (last-in, first-out) oraz FIFO (first-in, first-out). Przykładem kolejki LIFO jest stos, mówiąc o kolejce często mamy na myśli kolejkę FIFO. Co ciekawe, w języku Java klasa Stack implementuje interfejs listy, a nie kolejki. Drugą ciekawostką jest fakt, że klasa LinkedList także implementuje interfejs Queue (ponieważ była ona już omówiona wczesniej, nie będziemy powtarzać).
W kolejkach wyróżniamy głowę oraz ogon — stadardowo interfejs Queue pozwala na pracę tylko z głową, interfejs Dequeue (który dziedziczy po Queue) pozwala na pracę zarówno z głową jak i ogonem.
Ideą kolejek jest przechowywanie ‘kolejki’ obiektów do przetworzenia w określonej kolejności, stanowią pewnego rodzaju bufor obiektów.
Klasa | Cechy | Zastosowanie |
---|---|---|
ArrayDeque |
|
Uniwersalna implementacja pasująca do większości przypadków, działa na zasadzie FIFO, możliwość pracy także z ogonem powoduje że sprawdzi się także jako kolejka LIFO |
PriorityQueue |
|
Używana najczęściej jako kolejka zadań, które mają określony priorytet, tzn jedne są ważniejsze od innych. |
Stack |
|
Implementacja ta była od początku Javy, przed powstaniem Collections API. Obecnie raczej zalecane jest korzystanie np. z ArrayDequeue |
Poniższy opis obejmuje jedynie interfejs Queue, którego klasa Stack nie implementuje. Aby z niej skorzystać, zapoznaj się z dokumentacją API.
Aby utworzyć nową kolejkę (do przykładów bedziemy używać ArrayDequeue) wystarczy wywołać konstruktor:
Queue<String> kolejka = new ArrayDequeue<String>();
Do dodawania elementu służą metody add oraz offer:
kolejka.add("jeden"); //jeżeli kolejka jest pełna, rzuci wyjątek
kolejka.offer("jeden"); //nie rzuci wyjątku, a jedynie zwróci false
Pobieranie elementów odbywa się z użyciem metod remove i poll:
String string = kolejka.remove(); //jeżeli kolejka jest pusta, rzuci wyjątek
String string = kolejka.poll(); //jeżeli kolejka jest pusta, zwróci null
Kolejka pozwala nam także ‘podejrzeć’ kolejny element nie usuwając go z kolejki, służą do tego metody element oraz peek:
String string = kolejka.element(); //jeżeli kolejka jest pusta, rzuci wyjątek
String string = kolejka.peek(); //jeżeli kolejka jest pusta, zwróci null
Stan kolejki możemy sprawdzić korzystając z pomocniczych metod:
int iloscElementow = kolejka.size();
boolean kolejkaJestPusta = kolejka.isEmpty();
Podsumowanie
W ramach podsumowania zbierzemy informacje o wszystkich omówionych rodzajach kolekcji w formie tabeli. Warto też zapoznać się z oficjalną dokumentacją na temat collections API, dostępnńą pod adresem http://docs.oracle.com/javase/8/docs/technotes/guides/collections/ .
Kolekcja | Cechy | implementacje |
---|---|---|
Object[] | uporządkowana dostęp losowy (n’ty element) może zawierać duplikaty może zawierać wartości null struktura języka może przechowywać prymitywy niezmienna pojemność |
nie dotyczy |
List | uporządkowana dostęp losowy (n’ty element) może zawierać duplikaty |
ArrayList LinkedList Stack Vector |
Set | zbiór nieuporządkowany nie może zawierać duplikatów dostęp do elementów możliwy tylko poprzez iterator |
HashSet LinkedHashSet TreeSet |
Map | mapuje klucz na wartość możliwe duplikaty (tylko w przypadku wartości, klucze nie mogą się powtarzać) dostęp do obiektów poprzez klucz lub iteracyjnie |
HashMap LinkedHashMap TreeMap |
Queue | kolejka first-in, first-out możliwość pobierania pierwszego elementu poprzez jego ‘zdjęcie’ z kolejki lub podejrzenie |
ArrayDequeue PriorityQueue LinkedList |