Podręcznik

Strona: SEZAM - System Edukacyjnych Zasobów Akademickich i Multimedialnych
Kurs: 1. Wprowadzenie
Książka: Podręcznik
Wydrukowane przez użytkownika: Gość
Data: niedziela, 13 lipca 2025, 13:44

Opis

Wersja podręcznika: 1.0
Data publikacji: 01.01.2022 r.

1. Wskaźniki, tablice i listy dynamiczne

W trakcie nauki programowania na początku przedstawiane są typu zmiennych jakiego typu mogą być zmienne w języku C++. Teraz poruszymy zagadnienia związane z zapisem zmiennych w pamięci oraz przedstawimy typy ze względu na miejsce zapisu oraz ich zasięg w aplikacji. W języku C++ zmienne są przechowywane w różnych segmentach pamięci, w zależności od ich typu, czasu życia i sposobu alokacji. Ogólnie, pamięć procesu można podzielić na kilka segmentów:
  1. Stos (ang. stack) - przechowuje zmienne lokalne (automatyczne, bez static), wskaźniki oraz informacje o wywołaniach funkcji (stos wywołań).
  2. Sterta (ang. heap) - przechowuje zmienne dynamiczne alokowane przez operator new.
  3. Segment danych statycznych (ang. data segment) - podzielony jest na dwie części:
    • Zainicjalizowane zmienne statyczne (ang. Initialized Data Segment) - przechowuje zmienne globalne i statyczne, które są zainicjalizowane przed uruchomieniem aplikacji.
    • Niezainicjalizowane dane statyczne (ang. Block Started by Symbol - BSS) - przechowuje zmienne globalne i statyczne, które nie zostały jawnie zainicjalizowane.
  4. Segment kodu (ang. Text Segment) - przechowuje kod wykonywalny programu (instrukcje maszynowe).
Poniżej na rysunku przedstawiono rozmieszczenie poszczególnych segmentów w pamięci komputera.

Sterta rośnie w górę, a pamięć jest przydzielana i zwalniana dynamicznie. Jeżeli sterta zostanie w pełni zapełniona, próba alokacji pamięci może spowodować błąd (brak dostępnej pamięci). Stos natomiast rośnie w dół, zmniejszając adresy pamięci. Przy każdym wywołaniu na stosie zapisywane są lokalne zmienne i adres powrotu. Przepełnienie stosu może wystąpić w przypadku głębokiej rekurencji lub alokacji dużych zmiennych lokalnych.

Znajomość tego schematu pamięci jest kluczowa przy programowaniu w C++, zwłaszcza jeśli chodzi o zarządzanie dynamiczną pamięcią, optymalizację programu oraz unikanie błędów, takich jak wycieki pamięci lub przepełnienie stosu. 

W języku C++ istnieje możliwość podziału zmiennych uwzględniając różne kryteria klasyfikacji, takie jak np. zasięg, czas życia lub sposób alokacji pamięci.
Ze względu na zasięg możemy podzielić zmienne na:
  1. Zmienne globalne - są one deklarowane poza funkcjami lub klasami. Są widoczne w całym pliku źródłowym i mogą być widoczne w innych plikach, jeżeli nie są zadeklarowane jako static.
  2. Zmienne lokalne - są deklarowane wewnątrz funkcji, bloków kodu lub metod. Dostępne są jedynie w zakresie danego bloku lub funkcji w której zostały zadeklarowane.
  3. Zmienne statyczne globalne - są deklarowane poza funkcjami z użyciem słowa kluczowego static. Inicjalizowane są na początku programu i istnieją przez cały czas jego działania, ale mają ograniczony zasięg do pliku, w którym zostały zadeklarowane.
  4. Zmienne statyczne lokalne - są deklarowane wewnątrz funkcji lub metod z użyciem słowa kluczowego static. Widoczne są tylko w funkcji, w której były zadeklarowane ale ich czas życia jest taki sam jak czas trwania programu. Ich wartość nie jest resetowana przy kolejnych wywołaniach funkcji.
Ze względu na czas życia możemy podzielić zmienne na:
  1. Zmienne automatyczne - są deklarowane wewnątrz funkcji lub bloku (bez słowa kluczowego static). Alokowane są na stosie i zwalniane automatycznie po zakończeniu działania bloku/funkcji.
  2. Zmienne statyczne - pamięć na tego typu zmienne przydzielana jest na początku działania programu oraz zwalniana po jego zakończeniu. Mogą być zadeklarowane jako globalne (statyczna globalna) lub lokalnie w funkcji, gdzie zachowują wartość między wywołaniami funkcji.
  3. Zmienne dynamiczne - tworzone są w trakcie działania programu przez dynamiczną alokacje pamięci (na stercie) np. za pomocą operatora new. Ich czas życia zależy od tego, kiedy są jawnie zwlaniane przez programistę np. za pomocą operator delete.

Możemy także podzielić zmienne ze względu na miejsce alokacji pamięci:

  1. Zmienne na stosie (ang. stack) - dotyczy zmiennych automatycznych. Pamięć jest przydzielana  automatycznie gdy zmienna jest deklarowana i zwalniana gdy zmienna wychodzi z zakresu.
  2. Zmienne na stercie (ang. heap) - dotyczy zmiennych dynamicznych alokowanych za pomocą operatora new. Pamięć musi zostać zwolniona przez programistę.
  3. Zmienne w sekcji danych statycznych (ang. static data segment) - dotyczy zmiennych globalnych oraz statycznych. Pamięć jest przydzielana na poczatku programu i zwlaniana po jego zakończeniu.

W wiekszości zastosowań praktycznych istnieje potrzeba deklarowania zmiennych w trakcie działania aplikacji tzn. zmiennych dynamicznych

Po co więc stosujemy zmienne dynamiczne:

  • umożliwiają tworzenie bardzo dużych tablic,
  • umożliwiają tworzenie struktur dynamicznych  o nie znanej z góry wielkości (listy dynamiczne, drzewa),
  • można je w każdej chwili podczas działania programu usunąć z pamięci i na zwolnionym miejscu utworzyć nową zmienną dynamiczną - w tym samym lub innym programie. Ułatwiają więc  równoległą pracę wielu programów w systemie operacyjnym.

Do zmiennych dynamicznych mamy dostęp przez ich adres, nazwany wskaźnikiem. Adres w C++ to lokalizacja (numer) w pamięci, gdzie przechowywana jest określona wartość lub obiekt. Każda zmienna w programie ma swój adres, który wskazuje, gdzie w pamięci została przechowana.

1.1. Wskaźnik

Każda komórka pamięci ma swój unikalny adres, który jest liczbą całkowitą (zwykle w systemie szesnastkowym, np. 0x7FFEEB1A2C). Adres jest używany przez procesor do lokalizowania i manipulowania danymi. 

  • Jednostka pamięci: Najczęściej jest to jeden bajt, który jest najmniejszym adresowalnym blokiem pamięci.
  • Adres bazowy: Pierwszy bajt każdej zmiennej (lub bloku pamięci) ma swój adres bazowy.
  • Słowo pamięci: W zależności od architektury (np. 32-bit, 64-bit), procesor może przetwarzać dane w jednostkach większych niż bajt.

 Do danych w pamięci możemy odwoływać się nie tylko przez zwykłe nazwy, ale również przez adresy (wskaźniki) do nich. 

Wskaźnik wskazujący jakąś daną w pamięci ilustruje się zwykle następująco:


Zmienna wsk wskazuje na miejsce w pamięci gdzie znajduje się zadeklarowana zmienna. Nazwy wskaźników podobnie jak nazwy zmiennych mogą być dowolne (bez znaków specjalnych). 

Deklaracja wskaźnika ma postać:
typ_zmiennej wskazywanej *nazwa_wskaźnika;

Jeżeli chcemy utworzyć zmienną dynamiczna oraz zapamiętać adres do tej zmiennej w pamięci możemy to zrobić w następujący sposób:


int* wsk = new int;

Operacja new int tworzy zmienną dynamiczną w pamięci. Adres zmiennej jest zapisywany we wskaźniku wsk. Możliwe jest wyświetlenie adresu zmiennej. Po wykonaniu następujacego kodu:

cout << wsk;

Zostanie wyświetlony adres zmiennej na którą wskazuje zdefiniowany wskaźnik. Możemy także odwołać się do wartości wskazywanej przez wskaźnik za pomocą operatora wyłuskania (*).

*wsk = 20;
cout << *wsk; // Wyświetlona zostanie wartość 20

W celu usunięcia zmiennej dynamicznej z pamięci należy wykorzystać operator delete.

delete wsk;
Zostanie usunięta zmienna wskazywana przez danych wskaźnik (znajdująca się w pamięci pod danym adresem). Usunięcie zmiennej z pamięci nie powoduje "wyzerowania" wartości wskaźnika. Będzie on przechowywał adres zmiennej, która była w tym mijescu pamięci. Próba odwołania się do zmiennej usuniętej z pamieci sposoduje błąd (np. ang. Segmenattion Fault).

int* wsk = new int;
*wsk = 10;
delete wsk;
cout << *wsk; // Błąd

Aby programista mógł zaznaczyć, ze adres nie wskazuje w konkretne miejsce pamięci możliwe jest przypisanie do niego wartości NULL.

int* wsk = NULL; // Wskaźnik pusty

Dzięki temu możliwe jest sprawdzenie czy wskaźnik wskazuje na zmienną, czy może jest pusty. Oczywiście zależy to od programisty, który musi ustawić wartość wskaźnika na NULL np. po usunięciu zmiennej wskazywanej.
Po wywołaniu operatora delete nie jest automatycznie zerowany wskaźnik. Wskazuje dalej w to samo pamięci.
Zmienne dynamiczne mogą być różnych typów, tak jak wszystkie inne zmienne.
Umieszczając kilka wskaźników w jednej deklaracji trzeba uważać, by każdy z nich osobno był opatrzony gwiazdką po lewej stronie:
int *wsk1, *wsk2, *wsk3;    // tak deklarujemy kilka wskaźników
int *wsk1, wsk2, wsk3;    // a tu wskaźnikiem jest tylko wsk1, zaś wsk2 i wsk3  to zwykłe zmienne typu int 

1.2. Tablice dynamiczne

Na kursie Programowania dowiedzieliście się w jaki sposób zadeklarować tablicę. Były to jednak tablice statyczne, których rozmiar musiał być określony na etapie kompilacji aplikacji. Możliwa więc była deklaracja tablicy w następujący sposób:

const int n=10;
int tab[n];
Rozmiar tablicy n jest w naszym przypadku stały. Jeśli jednak nie wiemy z góry, ile elementów będziemy umieszczać w tablicy, musimy ją rezerwować niepotrzebnie dużą, na zapas (podając dużą wartość stałej n) - niekiedy nam się to przyda, ale często elementy tablicy zajmą w niej mały fragment i cały duży obszar pamięci pozostanie niepotrzebnie zarezerwowany. Aby temu zaradzić, aż się prosi, by najpierw dowiedzieć się od użytkownika, ile elementów ma mieć tablica, a potem dopiero przydzielić jej pamięć. To oznacza jednak, że taką rezerwację należy przeprowadzić w trakcie działania programu - będzie to więc dynamiczna rezerwacja pamięci. Nie możemy jej jednak wykonać w taki sposób:

int n;
cin >> n;
int tab[n];  // TAK NIE WOLNO !!!  taki zapis oznacza tablicę statyczną
Kod jest nieprawidłowy w standardowym C++ i prowadzi do błędu kompilacji. Rozmiar tablicy musi być znany na etapie kompilacji, a w tym przypadku n jest ustalane dopiero podczas działania programu (ang. runtime). Tablice statyczne w C++ są przechowywane w strefie stosu (stack) i ich rozmiar musi być stały, ponieważ kompilator musi wiedzieć z góry, ile miejsca zarezerwować. Rozmiar zmiennej n jest ustalany dopiero w trakcie działania programu (np. na podstawie wejścia użytkownika), dlatego kompilator nie może tego obsłużyć.

Dlaczego ten zapis działa w GCC lub Clang jako rozszerzenie?

W niektórych kompilatorach, takich jak GCC i Clang, podany zapis może działać dzięki obsłudze Variable Length Arrays (VLA), które są nieformalnym rozszerzeniem języka. Jednak:
  • Nie jest to zgodne ze standardem C++,
  • Kod nie jest przenośny – kod może nie działać na innych kompilatorach (np. MSVC),
  • Należy tego unikać, ponieważ korzystanie z VLA może prowadzić do nieprzewidywalnego zachowania.
W celu zadeklarowania tablicy o określonym w trakcie działania aplikacji rozmiarze należy dokonać dynamicznej alokacji pamięci oraz deklaracji tablicy dynamicznej. Poniżej przedstawiono kod aplikacji, który umożliwia deklarację tablicy dynamicznej jednowymiarowej o rozmiarze n.

int n;
cout << "Podaj rozmiar tablicy" << endl; 
cin >> n;

int* tab = new int[n];

for (int i=0;i<n;i++){
     cin >> tab[i]; // Wczytywanie danych do tablicy dynamicznej o rozmiarze n
}

delete []tab;

Jak widzimy na podanym przykładzie do tablicy dynamicznej możemy odwoływać się jak do tablicy statycznej tzn. za pośrednictwem indeksów. Na końcu musimy także zadbać o usunięcie tablicy alokowanej dynamicznie za pomocą operatora [].
Aby zaalokować tablicę jednowymiarową, wykorzystujemy operator new w następującej postaci: 
zmienna_wskaźnikowa = new nazwa_typu_elementów[rozmiar];
Tablicę jednowymiarową usuwamy zaś z pamięci za pomocą operatora delete wywoływanego następująco:
delete [] zmienna_wskaźnikowa;
W przypadku usuwania tablicy nalezy pamietać o podaniu nawiasów [] po słowie kluczowym delete. Dzięki temu usunięte zostaną wszystkie elementy tablicy dynamicznej. Wywołanie operatora bez nawiasów spowoduje usunięcie jedynie pierwszego elementu tablicy. We wskaźniku w przypadku tablicy przechowywany jest adres pierwszego elementu tablicy.

Tablica jest przechowywana w sposób ciągły. Każdy kolejny element znajduje się zaraz za poprzednim. Tablica int tab[5] zawiera 5 elementów. Typ int zajmuje 4 bajty (zależy od architektury, ale na większości współczesnych systemów jest to prawdziwe). Adres początkowy tablicy to 0x1000tab[0] ma adres 0x1000, a wartość 10tab[1] ma adres 0x1004 (0x1000 + 1 * 4), a wartość 20.

Jeśli int* ptr = tab;, to:
  • ptr wskazuje na 0x1000 (adres tab[0]),
  • ptr + 1 wskazuje na 0x1004 (adres tab[1]).
Możliwe jest więc odwołanie do poszczególnych wartości w tablicy za pośrednictwem zmiany adresu wskazującego na początek tablicy.

Dodanie wartości 1 do adresu T oznacza powiększenie adresu o rozmiar elementu pamiętanego w tablicy, czyli wyznaczenie adresu elementu następnego w tablicy - wiemy przecież, że są one poukładane za sobą w spójnym obszarze pamięci.

Mozliwe jest także deklarowanie tablic dynamicznych z większą liczbą wymiarów. Poniżej przedsatwiono kod aplikacji, który umożliwia deklarację tablicy dwuwymiarowej dynamicznej.

int w,k;
cout << "Podaj rozmiar tablicy w i k" << endl; 
cin >> w >> k;

// Deklaracja tablicy dwuwymiarowej w x k

int** tab = new int*[w];

for (int i=0;i<w;i++){
     tab[i]=new int[k]
}

// Odwołanie do elementów tablicy - wczytywanie danych

for (int i=0;i<w;i++){
     for (int i=0;i<k;i++){
          cin >> tab[i][j];
     }
}

// Usuwanie tablicy w x k
for (int i=0;i<w;i++){
     delete []tab[i];
}
delete []tab;
Przy deklarowaniu tablicy dynamicznej dwuwymiarowej Najpierw alokujemy pamięć na wskaźnik do wskaźników (int** tab). Następnie, dla każdego wiersza (pierwszy wymiar), alokujemy pamięć na poszczególne elementy (drugi wymiar). Odwołanie do poszczególnych elementów tablicy możemy zademonstrować na rysunku.

Oczywiście możliwe jest także odwołanie do poszczególnych elementów za pomoca indeksów.  Aby zwolnić pamięć, musimy wykonać delete[] dla każdego wiersza (alokowanego za pomocą new int[k]), a potem usunąć samą tablicę wskaźników (delete[] tab).  Dotychczasowa wiedza to jedynie podstawy operowania wskaźnikami. Wskaźniki oraz bezpośredni dostęp do pamięci w języku C++ oferują ogromne możliwości i znajdują szerokie zastosowanie. Przedstawiliśmy ich użycie na przykładzie tablic, lecz ponieważ nasz materiał nie obejmuje pełnego kursu C++, temat ten pozostawiamy na tym etapie. W kolejnym rozdziale poruszymy zagadnienia związane z jedną z podstawowych struktur danych jakim jest lista jednokierunkowa.


1.3. Listy dynamiczne

W poprzednich częściach podręcznika wyjaśniliśmy pojęcie wskaźnika oraz w jaki sposób wykorzystać zmienne dynamiczne do deklaracji tablic dynamicznych, czyli podstawowych struktur danych dynamicznych. Stosowanie dynamicznych tablic umożliwia dostosowanie ich rozmiaru do potrzeb w trakcie działania aplikacji. Możemy deklarować tablice o rozmiarze podanym przez użytkownika. Co zrobić jednak w przypadku, gdy w trakcie działania aplikacji istnieje potrzeba zwiększenia rozmiaru zadeklarowanej już tablicy dynamicznej? W języku C++ tablica dynamiczna, której rozmiar został ustalony podczas alokacji pamięci, nie może być bezpośrednio powiększona. Jednak możliwe jest osiągnięcie efektu zwiększenia jej rozmiaru poprzez utworzenie nowej tablicy o większym rozmiarze, skopiowanie istniejących elementów, a następnie zwolnienie pamięci zajmowanej przez starą tablicę. Możemy przedstawić opisaną sytuację na prostym przykładzie. 



Możemy zaobserwować, że nie mamy możliwości zwiększenia rozmiaru zadeklarowanej tablicy dynamicznej. Należy zadeklarować tablice o jeden większą niż poprzednio zadeklarowana oraz przepisać do niej wszystkie elementy. Oczywiście im więcej jest elementów w tablicy, tym więcej czasu będzie potrzebne na wykonanie takiej operacji. Istnieje więc potrzeba deklarowania struktur danych, które umożliwią swobodne zwiększanie rozmiaru przechowywanych elementów i jednocześnie nie powodując zwiększenia narzutu czasowego na wykonanie takiej operacji. W tym celu powstały listy dynamiczne. W porównaniu do tablic dynamicznych, listy dynamiczne pozwalają na łatwe dodawanie i usuwanie elementów w dowolnym miejscu, bez konieczności kopiowania pozostałych danych. Istnieje wiele różnych implementacji list dynamicznych m.in.:
  1. Lista dynamiczna jednokierunkowa (ang. Single Linked List).
  2. Lista dynamiczna dwukierunkowa (ang. Double Linked List).
  3. Lista dynamiczna cykliczna (ang. Circular Linked List).
W kolejnym rozdziale omówimy bardziej szczegółowo listy dynamiczne jednokierunkowe. Będzie to stanowiło idealny wstęp do bardziej złożonych struktur danych.

2. Listy jednokierunkowe

Po lekturze poprzedniego rozdziału powinniście mieć już solidną wiedzę na temat wskaźników oraz zrozumienie przyczyn, dla których w niektórych sytuacjach bardziej efektywne i elastyczne okazuje się stosowanie list zamiast tradycyjnych tablic dynamicznych. Tablice, choć proste w użyciu, mają swoje ograniczenia przede wszystkim wymagają wcześniejszego określenia rozmiaru lub kosztownej operacji realokacji pamięci przy zmianie ich wielkości. Wskaźniki natomiast dają możliwość dynamicznego zarządzania pamięcią oraz tworzenia bardziej złożonych i elastycznych struktur danych.

W kolejnych rozdziałach przyjrzymy się dokładnie, jak można wykorzystać te możliwości poprzez implementację list jednokierunkowych. Listy jednokierunkowe są jednymi z podstawowych i jednocześnie bardzo użytecznych struktur danych, które stanowią fundament dla wielu bardziej zaawansowanych rozwiązań w informatyce. Ich budowa opiera się na sekwencji elementów (zwanych węzłami), z których każdy przechowuje dane oraz wskaźnik do kolejnego elementu w liście.

Poznanie zasad działania i implementacji list jednokierunkowych pozwoli Wam lepiej zrozumieć sposób zarządzania dynamiczną pamięcią, mechanizmy iteracji po strukturach o zmiennym rozmiarze oraz przygotuje grunt pod dalsze eksplorowanie innych typów list, takich jak listy dwukierunkowe czy listy cykliczne. Co więcej, umiejętność samodzielnego tworzenia i modyfikowania takich struktur jest niezwykle cenna w praktyce programistycznej — niezależnie od wybranego języka czy dziedziny zastosowania.

2.1. Podstawowe informacje o listach jednokierunkowych

Lista jednokierunkowa to wzajemnie połączone ze sobą liniowo elementy. Każdy element posiada jakieś dane oraz adres kolejnego elementu na liście.Dzięki temu lista może dynamicznie zmieniać swój rozmiar tzn. elementy mogą być dowolnie dodawane lub usuwane bez konieczności przesuwania pozostałych elementów, jak ma to miejsce w tablicy.

Przechowywanie listy jednokierunkowej w pamięci odbywa się poprzez dynamiczną alokację. Węzły są tworzone w obszarze pamięci zwanym stertą (ang. heap), który służy do przechowywania obiektów o dynamicznie określonym czasie życia. W przeciwieństwie do tablicy, w której elementy są przechowywane w kolejnych komórkach pamięci, węzły listy mogą być rozmieszczone w dowolnych, niepowiązanych ze sobą adresach pamięci. To właśnie wskaźniki przechowywane w każdym węźle zapewniają powiązanie między nimi i pozwalają na odtworzenie logicznego porządku listy.

Każdy węzeł składa się z dwóch części: pola przechowującego dane użytkownika (na przykład liczby lub tekst) oraz pola przechowującego wskaźnik do kolejnego węzła. Wskaźnik ten jest adresem pamięci, pod którym znajduje się następny element listy. Ostatni węzeł w liście ma ustawiony wskaźnik na wartość pustą (NULL), co sygnalizuje koniec listy.

Na rysunku poniżej pokazano prezentację graficzną jednego elementu listy.


W jaki sposób utworzyć tego typu element? W pierwszej kolejności należy zadeklarować strukturę:

struct Element{
  int data;
  Element* next;
};
Następnie aby utworzyć taki element dynamicznie (np. na liście jednokierunkowej), można użyć operatora new.

Element* elem = new Element; 
Mając adres elementu wiemy gdzie w pamięci znajduje się dany element. Możemy za pomocą operatora wyłuskania odczytać wartości w polach struktury:

(*elem).data = 42;      // przykładowa wartość
(*elem).next = NULL; // na razie brak następnego elementu

Do poszczególnych pól możemy się także odwoływać w następujący sposób:

elem->data = 42;      // przykładowa wartość
elem->next = NULL; // na razie brak następnego elementu
Poniżej przedstawiono kompletny kod do utworzenia jednego elementu:

#include <iostream>

struct Element {
    int data;
    Element* next;
};

int main() {
    // Utworzenie pierwszego elementu
    Element* elem = new Element;
    elem->data = 42;
    elem->next = nullptr;

    // Wyświetlenie wartości
    std::cout << "Element data: " << elem->data << std::endl;

    // Zwolnienie pamięci
    delete elem;

    return 0;
}
Należy pamiętać, że usunięcie elementu za pomocą operatora delete nie powoduje przypisania do wskaźnika adresu NULL.

Adres ma taką samą wartość jak przed usunięciem obiektu.

Na razie utworzyliśmy tylko jeden element. Co prawda jest to już lista jednokierunkowa, która zawiera tylko jeden element jednak zwykle list używa się do przechowywaniu wielu elementów. Korzystając ze struktury możemy utworzyć kilka elementów tego typu:


Element* e1 = new Element;
Element* e2 = new Element;
Element* e3 = new Element;

Elementy te nie są na razie w żaden sposób połączone. Następnie możemy połączyć elementy za pomocą następującychy operacji:

e1->next = e2;
e2->next = e3
e3->next = NULL;
Na rysunku poniżej przedstawiono graficzną reprezentację połączonej listy.

Widzimy na rysunku, że pojawił się dodatkowy wskaźnik head. Jest to adres pierwszego elementu na liście. 
Posiadając adres pierwszego elementu na liście możemy uzyskać dostęp do dowolnego elementu na liście.

cout << head->data;	// Wyświetla wartość w pierwszym elemencie listy
cout << head->next->data;	// Wyświetla wartość w drugim elemencie listy
cout << head->next->next->data;	// Wyświetla wartość w trzecim elemencie listy
Przy odwoływaniu do pól poszczególnych elementów listy musimy mieć pewność, że elementy istnieją w pamięci. Próba odwołania do elementu, który nie istnieje spowoduje wystąpienie w aplikacji błędu dostępu do pamięci.

head = NULL;
cout << head->data;	// Jeżeli head=NULL to otrzymamy błąd dostępu do pamięci
Oczywiście nie tworzymy list wiedząc ile elementów powinna mieć lista tylko dynamicznie w przypadku potrzeby tworzymy nowy element oraz dodajemy go do listy.

Symulacja listy jednokierunkowej

struct Element {
    int data;
    Element* next;
};

Element* current = head;
while (current != nullptr) {
    std::cout << current << " " << current->data << " " << current->next << std::endl;
    current = current->next;
}
Poniżej przedstawiono kod aplikacji w którym tworzona jest lista jednokierunkowa zawierająca liczby. Lista tworzona jest z liczb podawanych przez użytkownika. Użytkownik podaje liczby, aż do momentu gdy poda wartość 0. Następuje wtedy koniec wczytywania oraz utworzona lista jest drukowana na ekranie.

#include <iostream>

using namespace std;

// Definicja elementu listy
struct Element {
    int data;
    Element* next;
};

// Funkcja dodająca element na koniec listy
void append(Element*& head, int value) {
    Element* newElement = new Element;
    newElement->data = value;
    newElement->next = nullptr;

    if (head == nullptr) {
        head = newElement;
    } else {
        Element* current = head;
        while (current->next != nullptr) {
            current = current->next;
        }
        current->next = newElement;
    }
}

// Funkcja drukująca listę
void printList(Element* head) {
    Element* current = head;
    while (current != nullptr) {
        cout << "Adres: " << current << "wartość: " << current->data << " Adres następnego: " << current->next << endl;
        current = current->next;
    }
}

int main() {
    Element* head = nullptr;

    cout << "Podaj liczby do listy (0 konczy wprowadzanie):" << endl;

    int value;
    do {
        cout << "Podaj liczbe: ";
        cin >> value;

        if (value != 0) {
            append(head, value);
        }
    } while (value != 0);

    cout << "\nUtworzona lista:" << endl;
    printList(head);

    // Zwolnienie pamięci
    Element* current = head;
    while (current != nullptr) {
        Element* toDelete = current;
        current = current->next;
        delete toDelete;
    }

    return 0;
}
W kodzie oprócz funkcji do tworzenia listy w kolejności zgodne z wczytywaniem wartości istanieje jeszcze kod do drukowania elementów listy. Jest on podstawą większości operacji na liście. W przypadku gdy chcemy przeanalizować każdy element z listy lub odszukać konkretny element możemy skorzystać z pętli while.

    Element* current = head;
    while (current != nullptr) {
				\\ Tutaj mamy dostęp do kolejnych elementów znajdujących się na liście
        current = current->next; \\ Przejście do kolejnego elementu na liście
    }
Pętla while, w której wykorzystujemy wskaźnik current, jest podstawowym wzorcem stosowanym podczas pracy z listą jednokierunkową. W każdej iteracji tej pętli uzyskujemy dostęp do kolejnego elementu listy, co pozwala nam przeprowadzać różnego rodzaju operacje. Dzięki temu mechanizmowi możemy łatwo przeanalizować każdy element, wypisać jego wartość, a także wykonywać bardziej złożone działania, takie jak wyszukiwanie konkretnego elementu czy obliczanie sumy wartości przechowywanych w liście. Iteracja po liście za pomocą takiej pętli sprawdza się również wtedy, gdy chcemy zliczyć liczbę elementów lub zmodyfikować wartości zapisane w kolejnych węzłach. Przechodząc krok po kroku od początku listy (czyli od wskaźnika head) aż do jej końca (oznaczonego wskaźnikiem NULL), mamy pewność, że żadnego elementu nie pominiemy. Jednocześnie zachowujemy pełną kontrolę nad tym, co dzieje się z danymi w liście podczas przechodzenia przez kolejne węzły. Bardzo ważnym zastosowaniem tej konstrukcji jest również usuwanie całej listy i zwalnianie pamięci zajmowanej przez jej elementy. W takim przypadku w każdej iteracji pętli while usuwamy bieżący element, a następnie przechodzimy do kolejnego. Po zakończeniu pętli wskaźnik head ustawiamy na NULL, co oznacza, że lista została poprawnie usunięta.

2.2. Przykładowe operacje na listach jednokierunkowych

Dopisywanie elementu na koniec listy


Korzystając z zasad pokazanych w poprzednim rozdziale napiszemy funkcję, która do  listy o strukturze Tos dopisuje  na jej końcu dodatkowy element. 
void dopiszNaKoncu (Tos *&glowa, string ktos) {
//  funkcja dopisuje osobę o imieniu ktos na końcu listy
//  zaczynającej się pod adresem glowa
//  glowa może się zmienić w trakcie działania funkcji, dlatego jest przy niej &
Tos *nowy, *akt;   // wskażniki pomocnicze
//  tworzymy nowy, dodatkowy rekord,
//  wpisujemy do niego imie ktos i adres NULL,
//  bo to ma być rekord końcowy:
    nowy=new Tos;
    nowy->imie=ktos;
    nowy->nast=NULL;
//  jeśli lista była pusta, to  teraz będzie składała się
//  tylko z tego nowego rekordu:
  if (glowa==NULL)
    glowa=nowy;
  else {  // a jeśli nie była pusta, to:
    akt=glowa;   // wracamy na początek listy
    while (akt->nast!=NULL) // wolno napisać taki warunek, bo lista nie jest pusta
        akt = akt->nast;   // dojeżdżamy do elementu, który w polu nast ma NULL
    // teraz akt jest adresem ostatniego elementu listy
    // możemy do niego dołączyć nowy, końcowy element:
    akt->nast=nowy;
   }
}
Za pomocą tej funkcji łatwo można utworzyć całą listę od początku w kolejności zgodnej z wczytywaniem danych - pobierając w pętli kolejne imiona i wywołując dopiszNaKoncu. Zwróćcie tylko uwagę, że za każdym razem, gdy chcemy dopisać coś na końcu listy, musimy przebiec całą listę od początku. Gdybyście uznali, że to bardzo nieoptymalne działanie, można do funkcji dodać dodatkowy parametr, tak by zwracała ona adres ostatniego elementu, aby nie trzeba było tego "ogona" poszukiwać za każdym razem od początku. Oba sposoby tworzenia listy tą metodą pozostawiamy Wam jako ćwiczenie.

Wstawianie i usuwanie elementów


Przyszła teraz pora na pokazanie, jak do istniejącej listy wstawiamy dodatkowe elementy i jak je z niej usuwamy. W zasadzie czynność wstawienia elementu sprowadza się do dwu kroków: po pierwsze musimy wstawiany element utworzyć w pamięci (za pomocą operatora new), a następnie zmodyfikować istniejące powiązania w liście. Podobnie z usuwaniem elementów. Tym razem musimy jedynie odwrócić kolejność działań, czyli najpierw zmienić powiązania, a potem element usunąć przy pomocy operatora delete.  Poniżej znajduje się prosta symulacja umożliwiająca wstawianie 4 elementu do listy oraz usuwanie wskazanego elementu z listy.

Symulacja listy jednokierunkowej: Wstawianie oraz Usuwanie

Wstawianie

Element* current = head;
int i = 1;
while (i < 4 - 1) {
    current = current->next;
    i++;
}
Element* newElement = new Element;
newElement->next = current->next;
current->next = newElement;

Usuwanie

Element* current = head;
int i = 1;
while (i < pos - 1) {
    current = current->next;
    i++;
}
Element* temp = current->next;
current->next = temp->next;
delete temp;
Przedstawiona symulacja umożliwia jedynie usuwanie elementów znajdujących się wewnątrz listy. Nie można usuwać elementów znajdujących się na początku listy. W przypadku usuwania pierwszego elementu listy odbywa się ono w inny sposób niż usuwanie elementu znajdującego się wewnątrz listy lub na końcu.  Poniżej przedstawiono kod umożliwiający usunięcie pierwszego elementu z listy:

void dopiszNaKoncu (Tos *&glowa, string ktos) {
Tos* do_usunięcia = head;
head = head->next;
delete do_usuniecia;
W przedstawionym kodzie na początku tworzę pomocniczy wskaźnik na pierwszy element listy oraz następnie zmieniam adres pierwszego elementu na następny. Na końcu usuwam tymczasowy element - wskazujący na adres starej head listy. Analogicznie jest w przypadku dodawania listy na poczatek listy:
void dopiszNaKoncu (Tos *&glowa, string ktos) {
Tos* nowy_element = new Tos;
nowy_element->next; = head;
head = nowy_element;

3. Stosy i kolejki jako elementarne struktury danych

Kolejka (ang. queue)jest bardzo prostą i intuicyjną strukturą, która odpowiada wielu sytuacjom z życia codziennego. Wyobraźcie sobie np. kolejkę do okienka w urzędzie, kolejkę do kasy w sklepie czy też kolejkę pasażerów wchodzących do autobusu. Zasada jest zawsze taka sama: osoby (elementy) dołączają na końcu kolejki, a obsługa odbywa się od początku. 

Pod względem technicznym kolejka to struktura dynamiczna lub statyczna, która udostępnia dwa podstawowe operatory:
  •  enqueue (dodanie elementu do kolejki, zwykle na końcu),
  • dequeue (usunięcie elementu z kolejki, czyli wyjęcie pierwszego elementu).

Czasami udostępnia się też operator peek (podgląd pierwszego elementu bez usuwania go), dzięki czemu można sprawdzić, jaki element będzie obsłużony jako następny.

Struktura FIFO znajduje zastosowanie w bardzo wielu algorytmach i systemach informatycznych. Przykładowo:

  • kolejki zadań w systemach operacyjnych,
  • buforowanie danych w transmisji sieciowej,
  • implementacja algorytmu przeszukiwania grafu (ang. BFS — Breadth-First Search),
  • obsługa zdarzeń w grach komputerowych lub w interfejsach użytkownika.
Na rysunku przedstawiono sposób umieszczenia elementów w klasycznej kolejce oraz ich pobierania. Pobieranie elementów możliwe jest tylko z początku kolejki, natomiast nie ma możliwości pobierania z innych indeksów struktury danych.


Kolejkę można zaimplementować na wiele sposobów:
  •  jako tablicę z dwoma indeksami (początku i końca), 
  •  jako listę jednokierunkową, gdzie nowe elementy dodawane są na końcu, a usuwane z początku, 
  •  z wykorzystaniem gotowych struktur dostępnych w językach wysokiego poziomu (np. queue w C++, Queue w Javie czy collections.deque w Pythonie).
W podręczniku przedstawimy implementację w postaci tablicy z dwoma indeksami (początku i końca). Dodatkowo będzie to implementacja bufora cyklicznego. Jest to bardzo popularne podejście w tego typu rozwiązaniach. Ideę bufora cyklicznego wyjaśnia szczegółowo poniższy rysunek. Rysunek pokazuje operację wstawiania do bufora cyklicznego czterech wartości. Numerami pokazana jest kolejność zamieszczania poszczególnych elementów w tablicy. W tablicy o rozmiarze n można zaimplementować kolejkę o rozmiarze n-1. Kolejka jest pusta jeżeli początek=wolny oraz pełna gdy początek=wolny+1. 

Kolejny przykład rysunkowy demonstruje w jaki sposób pobrać elementy z kolejki. W przykładzie pobierane są dwie wartości z kolejki.

Zauważcie tylko przyglądając się rysunkowi, że w pełniącej rolę bufora cyklicznego tablicy, która ma miejsce na n elementów, zawsze jedno miejsce pozostaje nie wykorzystane, dzięki czemu możemy odróżnić stan, kiedy kolejka jest pusta (indeksy poczatek i wolny są sobie równe) od stanu jej zapełnienia (poczatek=wolny+1).

 Struktura kolejki jest wykorzystywana w wielu algorytmach komputerowych, a jednym z najważniejszych zastosowań jest kolejkowanie procesów oczekujących na wykonanie na procesorze komputera.

Stos (ang. stack) jest jedną z najważniejszych struktur danych używanych w algorytmach komputerowych. Podstawowe cechy można zawrzeć w stwierdzeniu "ostatni wchodzi - pierwszy wychodzi", czyli w skrócie LIFO (Last In – First Out)

Obsługa stosu polega na tym, że dostęp do danych możliwy jest wyłącznie z jednej strony – od góry stosu. Można to porównać do układania kart do gry jedna na drugiej. Nowe karty zawsze kładziemy na wierzchu, a jeśli chcemy sięgnąć po kartę znajdującą się niżej, musimy najpierw zdjąć wszystkie leżące nad nią. Aby dotrzeć do pierwszej z położonych kart, konieczne jest usunięcie kolejnych – aż odsłonimy tę, której szukamy. Oznacza to, że elementy są zdejmowane w odwrotnej kolejności niż zostały dodane.


 Przy implementacji stosu zazwyczaj definiuje się dwie podstawowe operacje, które umożliwiają jego obsługę. Najczęściej nazywa się je:
  •  push(x) – dodaje element x na szczyt stosu. To tak, jakby położyć nową kartę na samą górę stosu. Operacja ta zwiększa rozmiar stosu o jeden i sprawia, że nowo dodany element staje się pierwszym do usunięcia. 
  •  pop() – usuwa element znajdujący się aktualnie na szczycie stosu i zwykle go zwraca. Jeśli stos jest pusty, próba wykonania tej operacji może zakończyć się błędem (w zależności od implementacji), ponieważ nie ma elementu do usunięcia. 
Te dwie operacje w pełni definiują zachowanie stosu zgodnie z zasadą LIFO (Last In, First Out), co oznacza, że ostatni dodany element jest usuwany jako pierwszy. Na rysunku poniżej przedstawiono podstawowe operacje na stosie.
 


W praktycznych implementacjach stosu (np. w językach programowania takich jak Python, Java, C++) często dodaje się również dodatkowe metody pomocnicze, takie jak:
  • peek() lub top() – pozwala podejrzeć element znajdujący się na szczycie stosu bez jego usuwania,
  • isEmpty() – sprawdza, czy stos jest pusty, 
  • size() – zwraca liczbę elementów znajdujących się aktualnie na stosie. 
Stos jest jedną z podstawowych struktur danych i znajduje zastosowanie m.in. w analizie wyrażeń arytmetycznych, wywoływaniu funkcji (stos wywołań), przetwarzaniu nawiasów, czy przy realizacji algorytmów rekurencyjnych. 

Stos jest taką strukturą danych, która skutecznie pomaga zwiększyć efektywność niektórych algorytmów. Pokażemy to na przykładzie obliczeń związanych z Odwrotną Notacją Polską (
ONP). Odwrotna Notacja Polska (ONP), nazywana również notacją postfiksową, to sposób zapisu wyrażeń arytmetycznych, w którym operatory występują po operandach, a nie pomiędzy nimi – jak ma to miejsce w tradycyjnym zapisie infiksowym. Przykładowo, wyrażenie „2 + 3” w ONP przyjmuje postać „2 3 +”. Choć taki zapis może początkowo wydawać się mniej intuicyjny, niesie ze sobą liczne korzyści, szczególnie w kontekście przetwarzania wyrażeń przez komputer. Jedną z najważniejszych cech ONP jest to, że nie wymaga ona stosowania nawiasów. Kolejność wykonywania operacji wynika wprost z kolejności, w jakiej występują operatory i operandy. Dzięki temu unika się niejednoznaczności i konieczności stosowania dodatkowych zasad pierwszeństwa działań. W wyrażeniach ONP zawsze operujemy na dwóch ostatnich liczbach umieszczonych wcześniej w ciągu.

Wyrażenia zapisane w ONP są wyjątkowo łatwe do przetwarzania algorytmicznego, zwłaszcza przy użyciu stosu. Podczas obliczeń elementy (liczby) są kolejno umieszczane na stosie. Kiedy napotkamy operator, zdejmujemy ze stosu dwa ostatnie elementy, wykonujemy odpowiednią operację i wynik odkładamy z powrotem na stos. Proces ten powtarza się aż do momentu przetworzenia całego wyrażenia, a końcowy wynik znajduje się na szczycie stosu jako jedyny pozostały element. 

Słowny opis algorytmu przetwarzania jednego znaku pobranego z wejścia można w skrócie przedstawić tak:

  1. jeżeli znak odczytany z wejścia jest cyfrą, dodaj go do łańcucha wyjściowego
  2. jeżeli odczytany znak jest operatorem, to:
    • jeśli na szczycie stosu znajduje się operator o wyższym lub równym priorytecie względem  priorytetu operatora odczytanego z wejścia, zdejmij ten "silniejszy" operator ze stosu i dodaj do kolejki wyjściowej - powtarzaj to aż do momentu, gdy na (nowym) szczycie stosu znajdzie się operator o niższym priorytecie, wtedy:
    • odłóż odczytany z wejścia operator na stos – tak samo postąp, gdy  stos będzie pusty (od razu bądź też po zdjęciu ze stosu innych operatorów)
  3. jeżeli odczytany znak jest lewym nawiasem, to odłóż go na stos, natomiast
  4. jeżeli oczytany znak jest prawym nawiasem, to zdejmuj po kolei operatory ze stosu i przekazuj na wyjście aż do momentu, gdy na szczycie stosu znajdzie się lewy nawias – wtedy zdejmij ten nawias ze stosu.
Algorytm przetwarzania zapisu infiksowego na ONP kończy się, jeżeli wszystkie znaki z wejścia zostały odczytane i przetworzone. W sytuacji, gdy stos jest niepusty, należy przekazać pozostałe znaki operatorów na wyjście. 

Poniżej przedstawiono tabelę priorytetów dla czterech podstawowych operandów arytmetycznych.


Najlepiej zademonstrować działanie algorytmu na prostym przykładzie. Jako ciąg wejściowy wprowadzamy wyrażenie: (4+6)*(8-3)/2. 

Symulacja konwersji na ONP

Wyrażenie: (4 + 6) * (8 - 3) / 2

Dla każdego tokena w wyrażeniu:
  jeśli to liczba → dodaj do wyjścia
  jeśli to '(' → połóż na stos
  jeśli to ')' → zdejmuj ze stosu do '('
  jeśli to operator → zdejmuj silniejsze operatory ze stosu, potem połóż
Po zakończeniu → zdejmij wszystko ze stosu
 
 
        

Stos operatorów:

Wyjście (ONP):

Przekład można przedsatwić także w formie tabelarycznej, która pokazuje wszystkie kroki działania algorytmu.


Ciąg znaków wyjściowych: 4 6 + 8 3 - * 2 /

Podczas implementacji należy pamiętać o tym, by po dopisaniu do łańcucha wyjściowego operatora bądź też ostatniej cyfry liczby umieścić znak biały ' ', dzięki któremu unikniemy „złączenia” się dwóch liczb w jedną (w naszym przykładzie musimy zapisać „4 6 + 8 3 - * 2 /”, a nie „46+83-*2/”).

Wiemy już, jak przetworzyć zapis w notacji konwencjonalnej do postaci ONP. Za chwilę dowiemy się, jak za pomocą stosu - tym razem liczbowego - w szybki sposób obliczać wartość wyrażenia przedstawionego właśnie w zapisie postfiksowym.

Okazuje się, że taki algorytm może być naprawdę prosty w zapisie, a przy tym efektywny. Tym razem jest nam potrzebny ciąg wejściowy będący zapisem wyrażenia w notacji ONP oraz stos pozwalający na przechowywanie liczb. Wynik wyrażenia jest zdejmowany ze stosu na samym końcu.

Kroki algorytmu są następujące:

  1. jeżeli znak odczytany z wejścia jest cyfrą, to pobieraj kolejne znaki budując z nich liczbę, aż natrafisz na znak nie będący cyfrą - wtedy otrzymaną liczbę odłóż na stos
  2. jeżeli znak jest operatorem, to
    • zdejmij ze stosu dwie liczby (pierwszą zdjętą niech będzie x, drugą y)
    • oblicz wartość wyrażenia y operator x
    • odłóż obliczoną wartość na stos
  3. gdy już skończą się znaki w ciągu wejściowym, zdejmij ze stosu końcowy wynik.

Symulacja obliczania wartości ONP

Wyrażenie ONP: 4 6 + 8 3 - * 2 /

Dla każdego tokena w wyrażeniu:
  jeśli to liczba → odłóż na stos
  jeśli to operator:
    zdejmij x i y ze stosu
    oblicz y operator x i odłóż wynik na stos
Na końcu zdejmij wynik ze stosu

Stos liczbowy:

Aktualny token:

Analogicznie jak poprzednio poszczególne kroki algorytmu można także przedstawić w formie tabelarycznej:

4. Rekurencja

Rekurencja to technika programistyczna i koncepcja matematyczna, w której funkcja wywołuje samą siebie w celu rozwiązania mniejszej części tego samego problemu. Jest to bardzo potężne narzędzie, które pozwala w elegancki sposób rozwiązywać złożone zadania poprzez ich sprowadzanie do prostszych przypadków. W kodzie możemy zaprezentować rekurencję w następujący sposób:



void funkcja_rekrencyjna(){
	...
  funkcja_rekrencyjna();
  ...
}
W implementacji funkcja wywołuje samą siebie. Najważniejszą rzeczą do zapamiętania w przypadku rekurencji jest konieczność odpowiedniego zdefiniowania warunku końca. W przypadku gdy warunek zostanie w nieprawidłowy sposób zdefiniowany może to prowadzić do nieskończonego wywoływania funkcji. Jednym z najprostszych przykładów wykorzystania rekurencji jest wyznaczenie wartości silni.

int silnia(int n) {
    if (n == 0 || n == 1) {
        return 1; // silnia z 0 i 1 to 1
    } else {
        return n * silnia(n - 1); // rekurencyjne wywołanie
    }
}
Możliwe jest także iteracyjne wyznaczenie wartości silni.

int silnia(int n) {
    int wynik = 1;

    for (int i = 2; i <= n; ++i) {
        wynik *= i;
    }

    return wynik;
}
Porównując różne sposoby obliczania silni w języku C++, można zauważyć istotne różnice między podejściem rekurencyjnym a iteracyjnym. Rekurencja odwołuje się do eleganckiego, matematycznego sposobu myślenia o problemie – funkcja wywołuje samą siebie, aż do osiągnięcia warunku brzegowego. Jest to sposób bardziej naturalny z punktu widzenia teorii algorytmów i często łatwiejszy do napisania w kilku linijkach kodu.

Z kolei iteracja opiera się na konstrukcjach pętli, takich jak for czy while, i działa w sposób bardziej techniczny. Zajmuje mniej pamięci, ponieważ nie wymaga dodatkowego miejsca na stosie wywołań funkcji, co ma znaczenie przy dużych wartościach n. Dzięki temu iteracyjne podejście jest zwykle wydajniejsze i bezpieczniejsze w środowiskach o ograniczonych zasobach.

Główne zalety rekurencji wynikają z jej elegancji, przejrzystości i bezpośredniego odwzorowania logicznej struktury wielu problemów. Poniżej przedstawiam kluczowe korzyści płynące z jej stosowania.

Przede wszystkim rekurencja pozwala na bardzo zwięzłe i czytelne wyrażenie algorytmów, które mają charakter powtarzalny lub dzielą problem na mniejsze podproblemy. Problemy takie jak silnia, ciąg Fibonacciego, przeszukiwanie struktur drzewiastych (np. drzewa binarnego), rozwiązywanie zadań typu "dziel i zwyciężaj" (np. sortowanie szybkiego podziału – quicksort) dają się naturalnie wyrazić właśnie rekurencyjnie.

Drugą zaletą rekurencji jest to, że wiele złożonych struktur danych (takich jak drzewa, grafy, listy zagnieżdżone) można przetwarzać w sposób bardzo intuicyjny – odwiedzając je w głąb (DFS – depth-first search) bez konieczności ręcznego stosowania struktur pomocniczych, takich jak stos czy kolejka.

Rekurencja bywa też niezastąpiona w algorytmach, które wymagają eksploracji wielu wariantów rozwiązania, np. w problemach kombinatorycznych, algorytmach typu backtracking (jak rozwiązywanie sudoku, znajdowanie ścieżek w labiryncie) czy generowaniu permutacji i podzbiorów.

5. Złożoność obliczeniowa

Zagadnienia złożoności obliczeniowej algorytmów stanowią fundament matematycznej analizy efektywności aplikacji komputerowych. Stanowią one istotny punkt wyjścia w nauce algorytmiki. Umożliwiają ocenę jakości danego rozwiązania niezależnie od konkretnego sprzętu, języka programowania czy implementacji. Głównym celem wyznaczania złożoności obliczeniowej jest efektywność działania algorytmu rozumiana jako ilość zasobów niezbędnych do jego wykonania, w szczególności czasu oraz pamięci. Wprowadzenie tych pojęć pozwala sformułować precyzyjne miary porównawcze dla różnych metod rozwiązania tego samego problemu.

Z matematycznego punktu widzenia, złożoność czasowa i pamięciowa algorytmu jest wyrażana jako funkcja zmiennej naturalnej n n, która opisuje rozmiar danych wejściowych. Interesuje nas przy tym przede wszystkim asymptotyczne zachowanie tej funkcji, czyli jak rośnie liczba operacji lub potrzebna pamięć, gdy n . Do zapisu tego wzrostu stosuje się tzw. notację dużego O – oznaczaną jako O ( f ( n ) ) , gdzie f ( n ) jest funkcją ograniczającą wzrost złożoności od góry. Przykładowo, jeśli algorytm wykonuje maksymalnie 3 n 2 + 2 n + 1 3 operacji, to jego złożoność asymptotyczna to O ( n 2 ) , ponieważ składnik kwadratowy dominuje dla dużych wartości n .

W praktyce często porównujemy algorytmy poprzez analizę ich złożoności w różnych przypadkach: najgorszym, średnim i najlepszym. Taka analiza pozwala przewidywać zachowanie algorytmu nie tylko w idealnych, ale i w rzeczywistych warunkach. Na przykład wyszukiwanie liniowe ma złożoność O ( n )  w najgorszym przypadku, ale wyszukiwanie binarne – już tylko O ( log n ) , co wprowadza jakościową różnicę przy dużych zbiorach danych.

W algorytmice nie chodzi jednak wyłącznie o zminimalizowanie złożoności. Równie istotna jest czytelność, poprawność i łatwość implementacji algorytmu. Czasem prostszy, choć teoretycznie wolniejszy algorytm, może okazać się lepszym wyborem, szczególnie w sytuacjach, gdy dane wejściowe mają niewielki rozmiar lub gdy istotna jest szybkość implementacji rozwiązania. Ważne jest także, by algorytm spełniał warunek stopu, czyli kończył działanie dla danych poprawnych w skończonym czasie – co formalnie oznacza, że musi on być funkcją totalną.

Wprowadzenie pojęcia złożoności pozwala zidentyfikować i poprawić nieefektywne fragmenty algorytmów. Na przykład, odczytanie głównej przekątnej macierzy n × n  można wykonać w czasie O ( n ) , używając jednej pętli. Jednak początkujący mogą nieświadomie zastosować algorytm złożony z dwóch pętli z warunkiem i = = j , co prowadzi do złożoności O ( n 2 )  – mimo że problem da się rozwiązać znacznie szybciej. W tym miejscu ujawnia się praktyczna wartość analizy złożoności: pozwala ona uniknąć niewidocznych gołym okiem nieefektywności.

Ostatecznie, złożoność obliczeniowa to narzędzie matematyczne, które służy nie tylko do porównywania algorytmów, lecz również do ich projektowania i optymalizacji. Dzięki niej jesteśmy w stanie zrozumieć granice obliczalności problemów, klasyfikować zadania według trudności (np. klasy P, NP, NP-zupełne) oraz podejmować świadome decyzje projektowe. Choć złożoność pamięciowa jest dziś mniej krytyczna z racji dużej dostępności RAM, nadal pozostaje istotna np. w systemach wbudowanych lub przy pracy na wielkich zbiorach danych. Jednak to złożoność czasowa pozostaje najczęściej analizowanym i najważniejszym aspektem efektywności algorytmicznej.

Notacja dużego O (czyli tzw. big-O notation) to kluczowe narzędzie matematyczne służące do opisu zachowania algorytmu przy dużych danych wejściowych. Zamiast analizować dokładną liczbę operacji, jakie wykonuje program, interesuje nas ogólny trend wzrostu tej liczby w zależności od rozmiaru wejścia. Umożliwia to ocenę i porównanie efektywności algorytmów niezależnie od konkretnej maszyny czy języka programowania.

Formalnie, mówimy, że funkcja f ( n )  ma złożoność O ( g ( n ) ) , jeśli istnieją stałe c > 0  oraz N 0 , takie że dla wszystkich n N  zachodzi nierówność:

f ( n ) c g ( n )

Oznacza to, że dla dużych n n, funkcja f ( n )  jest ograniczona z góry przez pewną wielokrotność funkcji g ( n ) . Intuicyjnie: nie rośnie szybciej niż g ( n ) , pomnożone przez jakąś stałą. Taka definicja opisuje asymptotyczny kres górny, czyli zachowanie algorytmu „na dłuższą metę” – ignorując drobne różnice dla małych wartości n , a skupiając się na tym, jak zachowuje się on, gdy rozmiar danych rośnie do nieskończoności.

Na przykład, jeśli algorytm wykonuje f ( n ) = 3 n 2 + 5 n + 7  operacji, to mówi się, że ma on złożoność O ( n 2 ) . Pomijamy tutaj współczynniki stałe (takie jak 3 czy 5), ponieważ interesuje nas jedynie kształt wzrostu funkcji. Z tego względu funkcje takie jak 10 n 2 + 100 n  i n 2  również uznaje się za rzędu O ( n 2 ) O( n2 ), mimo że ich wartości dla konkretnych n n mogą być różne.

Często spotykane klasy funkcji używanych w notacji dużego O to:

  • O ( 1 )  – stała złożoność (niezależna od rozmiaru wejścia),

  • O ( log n )  – logarytmiczna (np. przeszukiwanie binarne),

  • O ( n )  – liniowa (np. przeglądanie tablicy),

  • O ( n log n )  – liniowo-logarytmiczna (np. szybkie algorytmy sortowania),

  • O ( n 2 ) , O ( n 3 ) – złożoności wielomianowe (np. sortowanie bąbelkowe, algorytmy dynamiczne),

  • O ( 2 n ) , O ( n ! )  – złożoności wykładnicze i silniowe (dla problemów trudnych obliczeniowo, np. w teorii grafów, NP-trudne zadania).

Z wykresu funkcji takich jak log n , n , n log n n, n 2 , 2 n  widać, jak drastycznie różni się ich tempo wzrostu. Funkcja O ( n log n )  – charakterystyczna dla szybkich algorytmów sortowania – leży pomiędzy funkcją liniową a kwadratową, ale rośnie znacznie wolniej niż ta druga. Tymczasem O ( 2 n )  wznosi się tak stromo, że dla nawet umiarkowanie dużych danych staje się praktycznie nieużyteczna.

W praktyce, kiedy szukamy najlepszego algorytmu, dążymy do tego, by jego złożoność była jak najmniejsza – np. zamiast algorytmu O ( n 2 ) , warto znaleźć taki, który działa w O ( n log n ) , a idealnie – w O ( n ) , jeśli to możliwe. Złożoność w notacji dużego O pozwala więc nie tylko na efektywne porównywanie algorytmów, ale także stanowi fundament teorii obliczalności i klasyfikacji problemów pod kątem ich trudności obliczeniowej.


Na wykresie pokazano złożoność obliczeniową w zależności od liczby danych.

6. Przegląd metod konstruowania algorytmów

Obecnie informatyka opiera się na efektywnym rozwiązywaniu problemów. Sercem tego procesu są oczywiście algorytmy. W celu utworzenia skutecznego algorytmu, projektanci sięgają po zestaw sprawdzonych metod konstrukcyjnych. Do najważniejszych z nich należą: 

  • metody typu „dziel i zwyciężaj”,
  • algorytmy bazujące na programowaniu dynamicznym, 
  • algorytmy zachłanne, 
  • algorytmy z powrotami, 
  • metody heurystyczne,
  • algorytmy probabilistyczne,
  • algorytmy iteracyjne (np. gradientowe),
  • algorytmy przeszukiwania.

Metody typu „dziel i zwyciężaj"

Metoda „dziel i zwyciężaj” polega na podziale problemu na mniejsze, prostsze podproblemy, które są następnie rozwiązywane niezależnie, a ich wyniki łączone w rozwiązanie problemu głównego. To podejście często wykorzystuje się w algorytmach sortowania, takich jak sortowanie szybkie czy sortowanie przez scalanie. Technika ta opiera się na rekurencji i umożliwia uzyskanie znacznej poprawy wydajności w porównaniu do metod naiwnych.

Oto przykłady zastosowania podejścia dziel i zwyciężaj:

  • Sortowanie przez scalanie (ang. Merge Sort) – dzieli tablicę na dwie połowy, sortuje je rekurencyjnie, a następnie scala w jedną posortowaną tablicę.

  • Sortowanie szybkie (ang. Quick Sort) – wybiera element główny (pivot), dzieli tablicę na elementy mniejsze i większe od pivota, a następnie sortuje każdą część osobno.

  • Mnożenie dużych liczb (algorytm Karatsuby) – rozkłada liczby na mniejsze części, mnoży je i łączy wynik przy mniejszej liczbie mnożeń niż klasyczny algorytm.

  • Przeszukiwanie binarne – dzieli uporządkowaną listę na pół i przeszukuje tylko tę część, w której może znajdować się szukany element.

  • Algorytm znajdowania największego wspólnego dzielnika (NWD) – rekurencyjnie dzieli problem na mniejsze, korzystając z własności dzielników.

  • Znajdowanie maksimum i minimum w tablicy – dzieli tablicę na części, znajduje lokalne maksimum i minimum, a następnie wybiera globalne.

  • Algorytmy do przetwarzania obrazów (np. kompresja JPEG) – dzielą obraz na mniejsze bloki i przetwarzają każdy blok niezależnie.

  • Szybkie przekształcenie Fouriera (FFT) – dzieli problem przekształcenia sygnału na mniejsze podproblemy, przekształca je i łączy wynik.

  • Zliczanie inwersji w tablicy – łączy sortowanie przez scalanie z liczeniem par elementów w złej kolejności.

  • Algorytmy do znajdowania najbliższej pary punktów na płaszczyźnie – dzielą zbiór punktów i łączą wyniki przy pomocy sprytnej analizy geometrycznej.

Algorytmy bazujące na programowaniu dynamicznym

Programowanie dynamiczne to technika, która sprawdza się w problemach z nakładającymi się podproblemami i optymalną strukturą. W odróżnieniu od „dziel i zwyciężaj”, gdzie podproblemy są niezależne, tutaj ich rozwiązania są przechowywane i wykorzystywane wielokrotnie, co zapobiega redundantnym obliczeniom. Programowanie dynamiczne stosuje się na przykład w problemie plecakowym, w obliczaniu wartości ciągów czy w analizie sekwencji biologicznych. Metoda ta pozwala znacząco skrócić czas działania algorytmu, choć często kosztem zwiększonego zużycia pamięci.

Do tej pory nic nie mówiliśmy o typowych zastosowaniach programowania dynamicznego. Otóż wzorcowymi przykładami wykorzystania PD są zadania polegające na:

  • Rozwiązaniu problemu „plecakowego”,
  • Znalezieniu najkrótszych ścieżek między parami węzłów grafu – algorytm Floyda-Warshalla,
  • Obliczeniu wyrazów ciągu Fibonacciego lub kolejnych wartości symbolu Newtona.

Algorytmy zachłanne

Algorytmy zachłanne działają według prostej zasady: wybieraj na każdym kroku najlepszą lokalnie opcję, mając nadzieję, że doprowadzi to do najlepszego globalnego rozwiązania. Choć nie zawsze dają one wynik optymalny, to w wielu przypadkach, takich jak znajdowanie minimalnego drzewa rozpinającego czy wyznaczanie najkrótszej ścieżki w grafie, okazują się skuteczne i bardzo szybkie. Kluczem do ich stosowania jest możliwość wykazania, że lokalne decyzje prowadzą do optymalnego globalnego wyniku, co nie zawsze jest możliwe.

Przykładami zastosowania podejścia zachłannego są takie algorytmy, jak m.in.:

  • algorytm Dijkstry – służący do znajdowania najkrótszych ścieżek w grafach,
  • metoda Kruskala – używana do wyznaczania minimalnego drzewa rozpinającego,
  • konstrukcja drzewa kodowego Huffmana służącego do kompresji danych,
  • algorytmy szeregowania zadań.

Algorytmy z powrotami

Technika z powrotami, znana także jako backtracking, wykorzystywana jest w problemach, gdzie konieczne jest przeszukiwanie dużych przestrzeni rozwiązań. Polega ona na budowaniu rozwiązania krok po kroku i wycofywaniu się z błędnych ścieżek, gdy okaże się, że nie prowadzą do celu. Tę metodę stosuje się często w zagadkach logicznych, takich jak sudoku, układanie hetmanów na szachownicy czy generowanie kombinacji i permutacji. Choć jej czas działania może być wykładniczy, jest niezwykle uniwersalna i stosunkowo prosta do zaimplementowania.

Istnieje wiele różnych zagadnień, do rozwiązania których stosuje się algorytmy z powrotami, m.in.:

  • problem „plecakowy” (optymalnego wyboru),
  • grupa problemów dotyczących gier (w szachach, warcabach), np.:
    • problem rozmieszczenia n-królowych na szachownicy (znany najczęściej w wersji z 8 hetmanami (królowymi) ),
    • problem znalezienia drogi konika szachowego (odwiedzenie każdego pola szachownicy dokładnie raz) – Knight’s Tour.
  • problemy „grafowe”, np.:
    • problem kolorowania grafu (należy w taki sposób użyć m kolorów, by każde dwa sąsiednie węzły były różnego koloru.

Metody heurystyczne

Metody heurystyczne to podejście stosowane w sytuacjach, gdzie rozwiązanie optymalne jest trudne lub niemożliwe do znalezienia w rozsądnym czasie. Heurystyki nie gwarantują najlepszego możliwego wyniku, ale dostarczają rozwiązań wystarczająco dobrych w praktyce. Stosuje się je w problemach NP-trudnych, takich jak komiwojażer czy optymalizacja tras. Do metod heurystycznych należą między innymi algorytmy genetyczne, symulowane wyżarzanie czy lokalne przeszukiwanie. Ich zaletą jest elastyczność i możliwość adaptacji do wielu różnych dziedzin.

Metodami heurystycznymi posługujemy się najczęściej w problemach, gdy brakuje nam pewnych danych, dzięki którym bylibyśmy w stanie znaleźć rozwiązanie przy użyciu klasycznych algorytmów.

Przykładami takich problemów są chociażby:

  • prognozowanie pogody,
  • wykrywanie wirusów i robaków internetowych.

Algorytmy probabilistyczne

Algorytmy probabilistyczne to klasa algorytmów, które wykorzystują element losowości w swoim działaniu. Mogą podejmować decyzje w sposób losowy lub korzystać z losowych danych pomocniczych. Ich wynik lub czas działania nie zawsze jest taki sam przy każdym uruchomieniu, nawet dla tych samych danych wejściowych.

Przykłady zastosowań:
  • Testy pierwszości liczb (Miller–Rabin, Fermata) ,
  • Algorytmy w kryptografii (np. generowanie kluczy, testy losowości),
  • Symulacje fizyczne i finansowe (metody Monte Carlo),
  • Grafika komputerowa (np. rozpraszanie światła, wygładzanie krawędzi),
  • Algorytmy na grafach (np. randomizowane wyszukiwanie cykli), 
  • Optymalizacja (np. symulowane wyżarzanie – wykorzystuje probabilistyczne przejścia).

Algorytmy iteracyjne

Algorytmy iteracyjne (np. gradientowe) stanowią klasę metod, które rozwiązują problemy poprzez sukcesywne przybliżanie rozwiązania. W przeciwieństwie do innej klasy algorytmów nie dzielą problemu na mniejsze podproblemy, tylko rozpoczynają od pewnego stanu początkowego, a następnie modyfikują go krok po kroku, aż osiągną wynik spełniający określone kryterium zbieżności lub dokładności. Charakterystyczne dla tego podejścia jest użycie pętli, w których każda iteracja przybliża rozwiązanie coraz bardziej do wartości optymalnej. Algorytmy tego typu są powszechnie stosowane w optymalizacji matematycznej, analizie numerycznej i uczeniu maszynowym, gdzie często nie da się wyznaczyć dokładnego wyniku analitycznie. Dobrym przykładem jest metoda spadku gradientowego (ang. gradient descent), która znajduje minimum funkcji poprzez poruszanie się w kierunku przeciwnym do gradientu. Algorytmy iteracyjne mogą działać zarówno na liczbach rzeczywistych, jak i dyskretnych strukturach danych, a ich główną zaletą jest możliwość szybkiego uzyskania przybliżonego rozwiązania nawet dla bardzo dużych i złożonych problemów. W praktyce często łączy się je z innymi technikami, takimi jak heurystyki czy metody probabilistyczne, w celu zwiększenia skuteczności i stabilności.

Algorytmy przeszukiwania

Algorytmy przeszukiwania (ang. search-based algorithms) to grupa algorytmów, które rozwiązują problemy poprzez systematyczne eksplorowanie przestrzeni możliwych rozwiązań. Zamiast budować wynik wprost, analizują możliwe stany lub konfiguracje problemu i wybierają ścieżki prowadzące do rozwiązania na podstawie określonej strategii przeszukiwania. Przestrzeń rozwiązań może być reprezentowana jako drzewo lub graf, w którym węzły odpowiadają stanom, a krawędzie – możliwym przejściom między nimi. Algorytmy te są szczególnie istotne w sztucznej inteligencji, teorii gier, grafach oraz planowaniu działań i tras. Podstawowe techniki przeszukiwania to przeszukiwanie wszerz (ang. BFS – Breadth-First Search) i przeszukiwanie w głąb (ang. DFS – Depth-First Search). Algorytmy te różnią się kolejnością eksplorowania węzłów i mają odmienne właściwości dotyczące kompletności, optymalności i zużycia pamięci. W bardziej zaawansowanych zastosowaniach wykorzystuje się przeszukiwanie z heurystyką, takie jak algorytm A*, który łączy rzeczywisty koszt przebytej drogi z oszacowaniem odległości do celu, co pozwala znacząco przyspieszyć proces rozwiązywania. Inne znane metody to przeszukiwanie iteracyjno-głębokie, przeszukiwanie z kosztem jednolitym czy przeszukiwanie bidirectionalne. Algorytmy przeszukiwania mogą być zarówno deterministyczne, jak i probabilistyczne, zależnie od rodzaju problemu i użytych technik pomocniczych. W przypadku dużych lub nieskończonych przestrzeni stanów, stosuje się ograniczenia głębokości, przeszukiwanie heurystyczne lub losowe eksplorowanie węzłów. Choć nie zawsze prowadzą do rozwiązania optymalnego, często pozwalają znaleźć zadowalające odpowiedzi w akceptowalnym czasie. Ich siłą jest uniwersalność – mogą być stosowane do szerokiego zakresu problemów: od rozwiązywania łamigłówek, przez sterowanie robotami, aż po analizę struktur danych i sieci komputerowych.

7. Problem skoczka szachowego

Problem skoczka szachowego (ang. Knight's Tour Problem) to klasyczny problem kombinatoryczny z teorii grafów i algorytmiki, który polega na znalezieniu takiej sekwencji ruchów skoczka szachowego, aby odwiedził on każde pole szachownicy dokładnie raz. Skoczek porusza się zgodnie z regułami gry w szachy – wykonuje ruch w kształcie litery „L”, czyli dwa pola w jednym kierunku (poziomo lub pionowo), a następnie jedno pole prostopadle. Celem jest zaplanowanie takiej trasy, która obejmuje wszystkie pola na szachownicy bez powtórzeń, czyli tzw. pełnej trasy.


Problemem poruszania się konika po szachownicy zajmowało się w przeciągu ostatnich dwustu lat bardzo wielu wybitnych matematyków i naukowców. Dzisiaj wiemy na przykład, że w przypadku szachownicy o rozmiarze m x n, gdzie min(m,n) >= 5, zawsze istnieje ścieżka skoczka przechodząca przez wszystkie pola dokładnie raz. Dla naszego przypadku 8 istnieje pewność, że możliwe jest znalezienie rozwiązania. 

Poniżej przedstawiono kod umożliwiający rozwiązanie problemu skoczka przy pomocy algorytmu z powrotami (ang. backtracking)

#include <iostream>
#include <iomanip>

const int N = 8;  // rozmiar szachownicy

// Możliwe ruchy skoczka (x, y)
int dx[8] = { 2, 1, -1, -2, -2, -1, 1, 2 };
int dy[8] = { 1, 2, 2, 1, -1, -2, -2, -1 };

// Funkcja do weryfikacji czy ruch jest możliwy
bool isSafe(int x, int y, int board[N][N]) {
    return (x >= 0 && x < N &&
            y >= 0 && y < N &&
            board[x][y] == -1);
}

// Funkcja do rekurencyjnego rozwiązania problemu skoczka
bool solveKnightTour(int x, int y, int moveCount, int board[N][N]) {
    if (moveCount == N * N) return true;

    for (int i = 0; i < 8; i++) {
        int nextX = x + dx[i];
        int nextY = y + dy[i];

        if (isSafe(nextX, nextY, board)) {
            board[nextX][nextY] = moveCount;
            if (solveKnightTour(nextX, nextY, moveCount + 1, board))
                return true;
            else
                board[nextX][nextY] = -1;
        }
    }
    return false;
}

// Funkcja uruchamiająca algorytm
void startKnightTour() {
    int board[N][N];

    // Inicjalizacja planszy
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++)
            board[i][j] = -1;

    int startX = 0, startY = 0; // punkt startowy
    board[startX][startY] = 0;  // pierwszy ruch

    if (solveKnightTour(startX, startY, 1, board)) {
        // Wyświetlenie rozwiązania
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++)
                std::cout << std::setw(2) << board[i][j] << " ";
            std::cout << std::endl;
        }
    } else {
        std::cout << "Brak rozwiązania." << std::endl;
    }
}

int main() {
    startKnightTour();
    return 0;
}
Za pomocą kodu można samodzielnie sprawdzić działanie algorytmu dla dowolnej wielkości planszy oraz dowolnego punktu startowego. Poniżej pokazano kolejne kroki algorytmu dla przykładowego punktu startowego znajdującego się w górnym lewym rogu szachownicy.

Można zaobserwować analizując poszczególne ruchy, że każde pole zostało odwiedzone tylko raz oraz że zostały odwiedzone wszystkie pola na szachownicy.

Poniżej macie symulator za pomocą którego możecie symulować działanie algorytmu z powrotami dla dowolnego punktu startowego oraz wielkości planszy <5;8>. Po każdym uruchomieniu za pomocą Start należy użyć Stop, aby było możliwe ponowne uruchomienie symulatora.

Symulacja problemu skoczka szachowego

W symulatorze zaimplementowano także heurystykę Warnsdorffa w celu przyspieszenia obliczeń. Możecie sprawdzić jak szybko działa algorytm z powrtotami, a jak działa algorytm w oparciu o heurystykę. Heurystyka Warnsdorffa to efektywna metoda przyspieszająca rozwiązywanie problemu skoczka szachowego. Została opracowana w XIX wieku przez niemieckiego matematyka H. C. Warnsdorffa i do dziś stanowi jedną z najskuteczniejszych strategii dla tego typu zadań. Jej podstawową ideą jest wybieranie w każdym kroku takiego ruchu skoczka, który prowadzi do pola z najmniejszą liczbą dalszych dostępnych ruchów. Dzięki temu unika się sytuacji, w których skoczek mógłby zablokować sobie drogę i nie być w stanie odwiedzić wszystkich pól planszy. Zamiast przeszukiwać wiele potencjalnych ścieżek, jak robi to klasyczny algorytm z powrotami, heurystyka Warnsdorffa kieruje się prostą obserwacją: pola, z których można wykonać mało ruchów, są najbardziej „ryzykowne” i powinny zostać odwiedzone jak najwcześniej. Strategia ta pozwala więc skoczkowi poruszać się w sposób bardziej rozważny i minimalizuje ryzyko utknięcia w martwym punkcie planszy. W praktyce oznacza to, że z dostępnych w danym momencie opcji ruchu wybierana jest ta, która daje najmniejszą swobodę w kolejnych turach. W przypadku remisu – gdy kilka pól ma tę samą liczbę kontynuacji – można wybierać losowo lub według ustalonego porządku. Zaletą heurystyki Warnsdorffa jest jej bardzo wysoka wydajność. Pozwala rozwiązać problem skoczka niemal natychmiastowo nawet na planszach dużych rozmiarów, takich jak klasyczna szachownica 8×8. W wielu przypadkach prowadzi do poprawnego rozwiązania bez potrzeby cofania się i analizowania alternatywnych ścieżek. Nie gwarantuje jednak znalezienia rozwiązania w każdym możliwym układzie – jako heurystyka, nie zapewnia pełnej niezawodności. Może się zdarzyć, że dla nietypowego punktu startowego lub specyficznego rozmiaru planszy nie znajdzie trasy, choć taka istnieje.

8. Problem plecakowy

Problem plecakowy (ang. knapsack problem) jest przykładem zagadnienia optymalizacyjnego, które może być rozwiązywanie za pomocą wielu technik algorytmicznych (oczywiście z różną efektywnością). Niemniej jednak jest to doskonałe „modelowe” zadanie, dzięki któremu jesteśmy w stanie w przybliżony sposób zaprezentować sposoby działania kilku grup algorytmów. O trudności tego problemu niech świadczy fakt, że nie istnieje (przynajmniej nie został jeszcze odkryty) algorytm rozwiązujący dyskretny problem plecakowy, którego pesymistyczna złożoność obliczeniowa byłaby lepsza niż wykładnicza O(2n). Nie powinno nas to dziwić, gdyż problem plecakowy jest głównym przykładem algorytmu tzw. NP-trudnego – szczególnie zainteresowanych tematyką klasyfikacji problemów odsyłamy do naukowej literatury.

Główna zasada tego problemu brzmi: mamy pewien zbiór mniej lub bardziej cennych przedmiotów scharakteryzowanych za pomocą dwóch parametrów:

1. Wartość (cena) przedmiotu i-tego c_i
2. Masa przedmiotu i-tego w_i

Oprócz tych obiektów mamy pewien zbiorczy obiekt, zwyczajowo nazywany plecakiem, który z kolei posiada jeden parametr – całkowitą pojemność W (a ściślej rzecz biorąc powinna być to wytrzymałość na obciążenie). Zadanie jakie stoi przed osobą, która dokonuje załadunku przedmiotów do plecaka (zamiast  turysty wyruszającego na długą wyprawę w opisach tego zadania często spotykany jest  np.  złodziej w muzeum sztuki), jest następujące:

  • Należy dokonać takiego wyboru przedmiotów do plecaka, by ich sumaryczna wartość była możliwie maksymalna, zachowując przy tym ograniczenia dotyczące pojemności W plecaka.

Poniżej przedsatwiono kod aplikacji, która przy pomocy programowania dynamicznego rozwiązuje problem plecakowy:

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// Funkcja rozwiązująca problem plecakowy 0-1
int knapsack(const vector<int>& val, const vector<int>& wt, int W) {
    int n = val.size();
    vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));

    // Budowanie tabeli dp
    for (int i = 1; i <= n; i++) {
        for (int w = 0; w <= W; w++) {
            if (wt[i - 1] <= w) {
                dp[i][w] = max(
                    dp[i - 1][w],                         // nie bierzemy przedmiotu
                    dp[i - 1][w - wt[i - 1]] + val[i - 1] // bierzemy przedmiot
                );
            } else {
                dp[i][w] = dp[i - 1][w]; // nie zmieści się więc nie bierzemy
            }
        }
    }

    return dp[n][W]; // maksymalna wartość możliwa do uzyskania
}

int main() {
    vector<int> val = {60, 100, 120};
    vector<int> wt = {10, 20, 30};
    int W = 50;

    cout << "Maksymalna wartość, którą można zmieścić w plecaku: "
         << knapsack(val, wt, W) << endl;

    return 0;
}