CodeGym /Kursy /C++ SELF /Indeksowanie danych: id → obiekt, tag → lista

Indeksowanie danych: id → obiekt, tag → lista

C++ SELF
Poziom 25, Lekcja 4
Dostępny

1. Indeksy danych

Wyobraźcie sobie, że przechowujecie zadania w std::vector<Task> i za każdym razem, gdy trzeba znaleźć zadanie po id, uczciwie przebiegacie cały wektor pętlą. To działa… dopóki zadań nie zrobi się dużo, a zapytań — jeszcze więcej. Indeks to struktura danych, która przyspiesza typowe zapytanie: „znajdź obiekt po kluczu”. W praktycznym rozwoju to właśnie indeksy sprawiają, że aplikacje są „zwinne”, ale równocześnie dodają nowy ból głowy: trzeba je utrzymywać w zgodzie.

Zróbmy prostą, codzienną analogię. Biblioteka ma książki na półkach (to „prawdziwe obiekty”), a także katalog według autorów i katalog według tytułów (to „indeksy”). Jeśli bibliotekarz dodał książkę na półkę, ale zapomniał wpisać ją do katalogu — czytelnik uzna, że książki nie ma. Jeśli odwrotnie, w katalogu jest wpis, a książki na półce nie ma — czytelnik będzie szukać „widma”. To właśnie jest rozbieżność indeksów.

Mini-model danych: Task i „źródło prawdy”

Żeby mówić konkretnie, wprowadzimy mały model danych. Niech mamy mini-aplikację TaskBook: przechowujemy zadania, każde ma numeryczne id, tekstowy title i zestaw tagów (np. "cpp", "study", "home"). Nie stosujemy skomplikowanej architektury: żadnych klas i „magii”, tylko struct i kontenery STL, tak jak już umiemy.

Ważne, by wcześniej ustalić jedną zasadę: jedno źródło prawdy. Oznacza to, że prawdziwy obiekt Task przechowujemy w jednym miejscu, a w indeksie trzymamy tylko informacje referencyjne (np. id). Dzięki temu przy zmianie zadania nie trzeba „synchronizować dwóch kopii obiektu”, a jedynie zaktualizować indeksy, które do niego wskazują.

Zacznijmy od modelu:

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
    std::vector<std::string> tags;
};

Tutaj tags to właśnie vector, bo to wygodne do wyświetlania i iteracji. Unikalność tagów zapewnimy osobno (później), żeby nie przechowywać "cpp", "cpp", "cpp" — tak, to się zdarza, zwłaszcza jeśli użytkownik wpisuje tagi ręcznie.

2. Indeksy: podstawowy i wtórny

Indeks podstawowy id → Task: szybki dostęp do obiektu

Indeks podstawowy to miejsce, gdzie leżą „prawdziwe” obiekty. Najbardziej oczywista opcja: std::unordered_map<int, Task> tasksById; Wtedy po id otrzymujemy zadanie szybko i bez przeszukiwania liniowego. Nie będziemy rozważać „idealnych” złożoności i wnętrza hashtable — ważne jest, że sens operacji zmienia się z „przebiec wszystko” na „znaleźć po kluczu”.

Wprowadźmy typy (kod czyta się lepiej, oczy mniej się męczą):

#include <unordered_map>

using TaskId = int;
using TaskById = std::unordered_map<TaskId, Task>;

Pokażemy teraz podstawowy wzorzec odczytu: find → sprawdzić → użyć. Zwróćcie uwagę: to odczyt bez efektów ubocznych (nie tworzymy niczego przypadkowo).

#include <iostream>
#include <unordered_map>

int main() {
    TaskById tasks;
    tasks.emplace(1, Task{1, "Learn C++", {"cpp", "study"}});

    if (auto it = tasks.find(1); it != tasks.end()) {
        std::cout << it->second.title << '\n'; // Learn C++
    }
}

I osobno warto podkreślić ważną myśl (to realnie oszczędza godziny debugowania): jeśli chcesz tylko przeczytać — używaj find/contains. operator[] używaj, gdy naprawdę chcesz „utworzyć lub zaktualizować”.

Indeks wtórny tag → lista id: szybkie wyszukiwanie po kategorii

Teraz zrobimy drugi rodzaj zapytań: „pokaż wszystkie zadania z tagiem cpp”. Jeśli mamy tylko tasksById, trzeba by przeszukać wszystkie zadania i sprawdzić, czy dany tag jest w Task::tags. To znowu liniowe przejście po wszystkich zadaniach, i na dużych danych będzie smutno.

Dlatego wprowadzamy indeks wtórny: tag → lista id. Dlaczego nie tag → lista Task? Bo ustaliliśmy jedno źródło prawdy: Task żyje w tasksById, a indeks przechowuje tylko referencje do niego w postaci id.

Wybór kontenera dla „listy id” zależy od wymagań. W wersji edukacyjnej wygodnie użyć std::unordered_set<int>: automatycznie usuwa duplikaty, a sprawdzenie „czy id jest w zbiorze” jest szybkie. Gdyby zależał nam na kolejności lub potrzebne były duplikaty — można by użyć std::vector<int>, ale wtedy trzeba by ręcznie czyścić duplikaty. Na początek wybieramy „solidny” wariant.

#include <string>
#include <unordered_map>
#include <unordered_set>

using Tag = std::string;
using IdSet = std::unordered_set<TaskId>;
using TagIndex = std::unordered_map<Tag, IdSet>;

Dane aplikacji wyglądają teraz tak (konceptualnie):

flowchart LR
    subgraph Primary["Indeks podstawowy (źródło prawdy)"]
        A["tasksById: id → Task"]
    end

    subgraph Secondary["Indeks wtórny (referencje)"]
        B["tagToIds: tag → {id, id, id}"]
    end

    B -->|"id"| A

Psychologicznie warto pamiętać: w indeksie wtórnym nie przechowujemy zadania, przechowujemy „kartę” wskazującą na nie przez id. To jak karta katalogowa, a nie książka.

4. Operacje bez rozstrojenia

Gdy pojawia się drugi indeks, każda operacja „utwórz obiekt” zamienia się w „utwórz obiekt + zaktualizuj indeksy”. I tu pojawiają się typowe błędy: student dodał zadanie do tasksById, ale zapomniał dodać jego id do tagToIds, a potem dziwi się, dlaczego wyszukiwanie po tagu „nic nie zwraca”.

Aby nie rozsiać takich błędów po całym kodzie, wprowadzamy prostą dyscyplinę: wszystkie zmiany danych przechodzą przez jedną funkcję. Niech to będzie AddTask(...). Tak, brzmi to nudno. Ale nudny kod zwykle działa, a zabawny kod często bawi tylko kompilator.

Najpierw przygotujemy małą funkcję, która usuwa duplikaty tagów. Zrobimy to przez chwilowy unordered_set, a potem wrócimy do vector. Kod jest krótki, ale sens ważny: wewnątrz Task tagi będą schludne.

#include <string>
#include <unordered_set>
#include <vector>

std::vector<std::string> MakeUniqueTags(const std::vector<std::string>& tags) {
    std::unordered_set<std::string> uniq(tags.begin(), tags.end());
    return std::vector<std::string>(uniq.begin(), uniq.end());
}

Skoordynowane dodawanie: aktualizujemy oba indeksy

Zwróćcie uwagę: najpierw próbujemy wstawić do tasksById. Jeśli id jest już zajęte — wychodzimy, nic więcej nie zmieniamy. Jeśli wstawienie się powiodło — aktualizujemy tagToIds. Tutaj operator[] używany jest świadomie: jeśli tag nie istnieje, chcemy utworzyć dla niego pusty zbiór.

#include <unordered_map>
#include <unordered_set>
#include <string>
#include <vector>

bool AddTask(TaskById& tasksById, TagIndex& tagToIds, Task task) {
    task.tags = MakeUniqueTags(task.tags);

    auto [it, inserted] = tasksById.emplace(task.id, task);
    if (!inserted) return false;

    for (const std::string& tag : it->second.tags) {
        tagToIds[tag].insert(it->first);
    }
    return true;
}

Zauważcie przyjemny efekt: kod wywołujący w ogóle nie musi pamiętać, że mamy „dwa indeksy”. Po prostu wywołuje AddTask, a funkcja robi całą nudną pracę. To mały krok w stronę dobrej architektury, bez wielkich słów i religii.

Skoordynowane usuwanie: usuwamy tak, żeby nie zostały „widma”

Usuwanie jest jeszcze bardziej niebezpieczne niż dodawanie. Błąd zwykle wygląda tak: usunąłeś zadanie z tasksById, ale zapomniałeś usunąć jego id z tagToIds. Potem robisz „pokaż po tagu” — dostajesz id, którego już nie ma w indeksie podstawowym. I wtedy spotykasz „widmowe zadanie”: indeks mówi, że jest, a rzeczywistość mówi „nie ma”.

Usuwanie powinno składać się z dwóch kroków: usunąć odnośniki z indeksów wtórnych, potem usunąć sam obiekt. Porządek jest właśnie taki: najpierw czyścimy „katalogi”, potem zdejmujemy „książkę z półki”. Ponownie opakowujemy to w jedną funkcję, aby nie powielać logiki w dziesięciu miejscach.

#include <string>
#include <unordered_map>
#include <unordered_set>

bool RemoveTask(TaskById& tasksById, TagIndex& tagToIds, TaskId id) {
    auto it = tasksById.find(id);
    if (it == tasksById.end()) return false;

    for (const std::string& tag : it->second.tags) {
        if (auto jt = tagToIds.find(tag); jt != tagToIds.end()) {
            jt->second.erase(id);
            if (jt->second.empty()) tagToIds.erase(jt);
        }
    }
    tasksById.erase(it);
    return true;
}

Tu jest mała estetyka: jeśli po usunięciu id zbiór stał się pusty, usuwamy sam tag z indeksu. To nie jest konieczne, ale zwykle upraszcza strukturę: nie będzie „tagów, które nie mają zadań”.

Modyfikacja danych: jak bezpiecznie zmieniać tagi i tytuł

Modyfikacja to część, w której indeksy szczególnie lubią się rozstroić. Tytuł (title) można zmieniać bez problemu: nie uczestniczy w naszym indeksie (na razie). Natomiast tagi są podstawą indeksu wtórnego. Jeśli zmienisz Task::tags, ale nie zaktualizujesz tagToIds, indeks wtórny natychmiast stanie się „fałszywy”.

Prawidłowy model aktualizacji tagów jest taki: trzeba usunąć id ze starych tagów, potem dodać id do nowych tagów, i dopiero zapisać nowe tagi w Task. W istocie to „przeprowadzka” z jednego zbioru do drugiego. Znowu tworzymy oddzielną funkcję, żeby nie rozmazywać logiki po całej aplikacji.

#include <string>
#include <vector>

bool UpdateTaskTags(TaskById& tasksById, TagIndex& tagToIds,
                    TaskId id, std::vector<std::string> newTags) {
    auto it = tasksById.find(id);
    if (it == tasksById.end()) return false;

    for (const std::string& tag : it->second.tags) {
        if (auto jt = tagToIds.find(tag); jt != tagToIds.end()) {
            jt->second.erase(id);
            if (jt->second.empty()) tagToIds.erase(jt);
        }
    }

    newTags = MakeUniqueTags(newTags);
    it->second.tags = newTags;

    for (const std::string& tag : it->second.tags) {
        tagToIds[tag].insert(id);
    }
    return true;
}

Jeśli pomyśleliście teraz: „O rany, dla tagów tyle kodu…” — to normalna reakcja. Indeksy dają szybkość zapytań, ale pobierają „podatek” w postaci starannej aktualizacji przy zmianach. W dużych systemach ten podatek płaci się architekturą i dyscypliną, a u nas — schludnymi funkcjami.

5. Zapytania i walidacja

Indeksy nie są budowane dla piękna, lecz dla zapytań. Zróbmy więc dwie małe funkcje drukujące: jedna wyświetla zadanie po id, druga wypisuje wszystkie zadania dla danego tagu. Ustalmy od razu: nie tworzymy ładnego „fancy” outputu, ważne jest pokazanie, jak używać indeksów.

Zapytania: „pokaż po id” i „pokaż po tagu”

Wyświetlenie jednego zadania:

#include <iostream>

void PrintTask(const Task& t) {
    std::cout << "#" << t.id << " " << t.title << " [";
    for (std::size_t i = 0; i < t.tags.size(); ++i) {
        std::cout << t.tags[i] << (i + 1 == t.tags.size() ? "" : ", ");
    }
    std::cout << "]\n";
}

Zapytanie „pokaż po id” to po prostu find w indeksie podstawowym:

#include <iostream>

void PrintById(const TaskById& tasksById, TaskId id) {
    if (auto it = tasksById.find(id); it != tasksById.end()) {
        PrintTask(it->second);
    } else {
        std::cout << "no such task\n"; // no such task
    }
}

Natomiast zapytanie „pokaż po tagu” używa indeksu wtórnego. Zwróć uwagę: indeks wtórny daje nam zbiór id, a następnie dla każdego id odwołujemy się do tasksById i pobieramy obiekt. To właśnie „jedno źródło prawdy” w praktyce.

#include <iostream>
#include <string>

void PrintByTag(const TaskById& tasksById, const TagIndex& tagToIds,
                const std::string& tag) {
    auto it = tagToIds.find(tag);
    if (it == tagToIds.end()) {
        std::cout << "no such tag\n"; // no such tag
        return;
    }

    for (TaskId id : it->second) {
        if (auto jt = tasksById.find(id); jt != tasksById.end()) {
            PrintTask(jt->second);
        }
    }
}

Zwróćcie uwagę na „paranoję”: nawet jeśli indeks powinien być zgodny, i tak sprawdzamy tasksById.find(id). W idealnym świecie można by tego nie robić, ale w edukacyjnym kodzie ta weryfikacja pomaga „przeżyć” błąd rozstrojenia i zauważyć problem, zamiast upaść w nieznane.

Sprawdzanie spójności: inwarianty i prosta walidacja

Gdy indeksów jest więcej niż jeden, warto sformułować „żelazne reguły”, które zawsze powinny być prawdziwe. W programowaniu takie reguły często nazywa się inwariantami. Bez formalnej matematyki, po ludzku:

Jeśli w tagToIds["cpp"] jest 42, to w tasksById musi istnieć zadanie z id 42. I odwrotnie: jeśli zadanie 42 zawiera tag "cpp", to tagToIds["cpp"] powinien zawierać 42.

W projektach edukacyjnych bardzo pomaga prosta funkcja-walidator, którą można wywołać po serii operacji i sprawdzić, czy indeksy „się nie rozjechały”. Tak, to dodatkowe przejście po danych, ale to jak latarka: nie przyspiesza chodzenia, lecz pomaga nie wpaść na słup.

Zrobimy prostą weryfikację „z wtórnego do podstawowego” (w praktyce wykrywa większość błędów):

#include <iostream>
#include <string>

bool ValidateTagIndex(const TaskById& tasksById, const TagIndex& tagToIds) {
    for (const auto& [tag, ids] : tagToIds) {
        for (TaskId id : ids) {
            if (!tasksById.contains(id)) {
                std::cout << "Broken index: tag '" << tag
                          << "' refers to missing id=" << id << '\n';
                return false;
            }
        }
    }
    return true;
}

Jeśli chcecie, można dodać także odwrotną weryfikację (z podstawowego do wtórnego), ale nawet jedna kontrola już dyscyplinuje: zaczynacie traktować indeksy jako coś, co trzeba utrzymywać, a nie jako „magiczne” przyspieszenie.

6. Typowe błędy przy pracy z wieloma indeksami

W tym temacie większość błędów nie jest „składniowa”, lecz architektoniczna: kod się kompiluje, czasem nawet działa, ale dane stopniowo zamieniają się w kryminał. Dlatego błędy lepiej rozpoznawać po symptomach i przyzwyczajeniach, nie tylko po komunikatach kompilatora.

Błąd nr 1: dwa źródła prawdy (duplikowanie obiektów w indeksach).
Często kusi, by zrobić tag → vector<Task> „bo wtedy wygodniej drukować”. Pierwsze pół godziny rzeczywiście jest wygodnie. Potem zmieniasz title zadania w tasksById, zapominasz zaktualizować kopię w drugim miejscu — i nagle masz dwie różne rzeczywistości. Zdecydowanie bezpieczniej trzymać obiekt w jednym miejscu, a w indeksach tylko id.

Błąd nr 2: zaktualizowano jeden indeks, zapomniano o drugim.
Klasyka gatunku: dodałeś zadanie do tasksById, ale nie dodałeś jego id do tagToIds; albo usunąłeś z tasksById, ale nie wyczyściłeś tagToIds. Objawy proste: „po id znajduje, po tagu — nie” lub „po tagu znajduje id, ale po id zadania nie ma”. Leczy się dyscypliną: wszystkie operacje przez funkcje typu AddTask/RemoveTask/UpdateTaskTags.

Błąd nr 3: odczyt przez operator[] i przypadkowe tworzenie pustych wpisów.
Na przykład zapis tagToIds[tag] w trybie „odczytu” utworzy nowy tag z pustym zbiorem id, a potem zaczniesz się zastanawiać, skąd wzięły się te dziwne puste tagi. Jeśli czytasz — używaj find/contains. operator[] zostaw na sytuacje, gdy utworzenie przy braku jest pożądane (jak w AddTask).

Błąd nr 4: nie czyszczą „pustych” kluczy w indeksie wtórnym.
Jeśli po usunięciu id z tagToIds[tag] zbiór stał się pusty, a Ty zostawiłeś pusty wpis — logika zwykle nie pęka, ale struktura zaczyna się „zapychać”: pojawiają się tagi bez zadań. To szczególnie widoczne w aplikacjach, gdzie tagi pokazywane są użytkownikowi w liście. Sprzątanie pustych kluczy czyni zachowanie schludnym i przewidywalnym.

Błąd nr 5: tagi w środku Task mają duplikaty i indeks zaczyna żyć „dziwnie”.
Jeśli zadanie ma tagi {"cpp", "cpp"}, to przy aktualizacji indeksów robisz zbędną pracę, a przy drukowaniu użytkownik widzi duplikaty i zaczyna podejrzewać intrygę. Normalizacja tagów (przynajmniej usuwanie duplikatów) to drobna rzecz, ale znacząco podnosi jakość danych i zmniejsza liczbę „magicznych” błędów.

1
Zadanie
C++ SELF, poziom 25, lekcja 4
Niedostępne
Indeks zadań
Indeks zadań
1
Zadanie
C++ SELF, poziom 25, lekcja 4
Niedostępne
Wyszukiwanie po tagu
Wyszukiwanie po tagu
1
Zadanie
C++ SELF, poziom 25, lekcja 4
Niedostępne
Dwa indeksy
Dwa indeksy
1
Zadanie
C++ SELF, poziom 25, lekcja 4
Niedostępne
Obsługa bazy
Obsługa bazy
1
Ankieta/quiz
map/unordered_map/set, poziom 25, lekcja 4
Niedostępny
map/unordered_map/set
map/unordered_map/set
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION