Serwis Edukacyjny w I-LO w Tarnowie Materiały dla uczniów liceum |
Wyjście Spis treści Wstecz Dalej Autor artykułu: mgr Jerzy Wałaszek |
©2024 mgr Jerzy Wałaszek |
Do reprezentacji grafów
w pamięci komputera wymyślono kilka różnych struktur danych.
Każda z nich posiada swoje zalety, lecz również wady. Dlatego
należy je rozsądnie dobierać do zadań, w których używamy grafów.
Źle dobrana reprezentacja może znacząco wydłużyć czas obliczeń
lub rozmiar zajmowanej pamięci. Graf zwykle nie jest strukturą
hierarchiczną, jak np. opisane wcześniej
drzewa. Jego węzły nie muszą się ze sobą
łączyć w określony sposób. Mogą również istnieć grupy węzłów,
które nie są w żaden sposób połączone z innymi. Dlatego sposoby
reprezentacji drzew niezbyt nadają się do reprezentacji grafów,
która powinna zapewniać dostęp do wszystkich wierzchołków bez
względu na ich wzajemne połączenia krawędziami. Takie cechy
posiadają tablice i macierze. W rozdziale tym omawiamy trzy
najczęściej spotykane sposoby przedstawiania grafów: macierz sąsiedztwa (ang. adjacency
matrix), macierz incydencji (ang. incidence matrix)
oraz
listy sąsiedztwa (ang. adjacency lists).
Cechą charakterystyczną tych implementacji jest wykorzystanie
tablic do przechowywania danych na temat wierzchołków lub
łączących je krawędzi. Wykorzystuje się również struktury
mieszane, np. tablicę, której elementami są listy.
|
Chcąc realizować algorytmy grafowe, będziemy zmuszeni wprowadzać różne grafy do pamięci komputera. Istnieje bardzo prosty sposób realizacji tego zadania i jest on następujący:
Na początku podajemy dwie liczby n – ilość
wierzchołków oraz m –
ilość krawędzi. Następnie
wprowadzamy m par liczb a i b, które definiują
kolejne krawędzie grafu, gdzie a to wierzchołek startowy, a b
wierzchołek końcowy (dla
grafu nieskierowanego
kolejność tych wierzchołków nie ma znaczenia). Umówmy się
dodatkowo, że wierzchołki w grafie posiadają numery od 0 do n - 1.
Kolejność numeracji wierzchołków nie ma znaczenia. Dla porządku krawędzie
również będą numerowane od 0 do m - 1. Dzięki tej umowie uprości się
tworzenie struktur danych w języku
Przykład:
5 6 0 1 1 2 2 3 3 0 1 4 3 4 |
– 5 wierzchołków i 6 krawędzi – krawędź e0 – krawędź e1 – krawędź e2 – krawędź e3 – krawędź e4 – krawędź e5 |
Jeśli krawędzie będą posiadały wagi, to wartości tych wag podamy jako trzecią liczbę w definicji krawędzi.
Przykład:
5 6 0 1 5 1 2 -3 2 3 0 3 0 -1 1 4 2 3 4 4 |
– 5 wierzchołków i 6 krawędzi – krawędź e0 – krawędź e1 – krawędź e2 – krawędź e3 – krawędź e4 – krawędź e5 |
Jeśli z wierzchołkami grafu zechcemy skojarzyć dane, to po podaniu dwóch pierwszych liczb n i m najpierw przekazujemy do programu n danych dla wierzchołków, a następnie m par (lub trójek) dla poszczególnych krawędzi.
Przykład:
5 6 5 3 1 6 8 0 1 1 2 2 3 3 0 1 4 3 4 |
– 5 wierzchołków i 6 krawędzi
– dane dla v0 – dane dla v1 – dane dla v2 – dane dla v3 – dane dla v4 – krawędź e0 – krawędź e1 – krawędź e2 – krawędź e3 – krawędź e4 – krawędź e5 |
Graf reprezentujemy za pomocą macierzy kwadratowej A o stopniu n, gdzie n oznacza liczbę wierzchołków w grafie. Macierz tą nazywamy macierzą sąsiedztwa (ang. adjacency matrix). Odwzorowuje ona połączenia wierzchołków krawędziami. Wiersze macierzy sąsiedztwa odwzorowują zawsze wierzchołki startowe krawędzi, a kolumny odwzorowują wierzchołki końcowe krawędzi. Komórka A [i, j ], która znajduje się w i-tym wierszu i j-tej kolumnie odwzorowuje krawędź łączącą wierzchołek startowy vi z wierzchołkiem końcowym vj. Jeśli A [i, j] ma wartość 1, to dana krawędź istnieje. Jeśli A [i, j] ma wartość 0, to wierzchołek vi nie łączy się krawędzią z wierzchołkiem vj.
Przykład:
|
Dla grafu nieskierowanego macierz sąsiedztwa A jest symetryczna względem głównej przekątnej, ponieważ jeśli istnieje krawędź od vi do vj (A [i, j] = 1), to również musi istnieć krawędź w kierunku odwrotnym, od vj do vi (A [j, i] = 1).
Przykład:
|
Interpretacja zawartości macierzy sąsiedztwa jest bardzo prosta. Poszczególne wiersze traktujemy jako wierzchołki grafu. Zatem wiersz 0 odpowiada wierzchołkowi v0, wiersz 1 odpowiada wierzchołkowi v1, itd. Weźmy dla przykładu wiersz nr 3, który odpowiada wierzchołkowi v3:
0 | 1 | 2 | 3 | 4 | |
3 | 1 | 0 | 1 | 0 | 1 |
W kolumnach o numerach 0, 2 i 4 mamy wartości 1. Oznacza to, że wierzchołek v3 jest połączony krawędziami kolejno z wierzchołkami v0, v2 i v4. Wierzchołek v3 jest początkiem tych krawędzi, a wierzchołki v0, v2 i v4 są ich końcami. Z wierzchołka v3 nie wychodzą żadne krawędzie do wierzchołków v1 i v3. Porównaj to z rysunkiem grafu umieszczonym powyżej.
Podobnie jest dla kolumn. Weźmy na przykład kolumnę nr 1, która odpowiada wierzchołkowi v1:
1 | |
0 | 1 |
1 | 0 |
2 | 1 |
3 | 0 |
4 | 1 |
W kolumnie mamy wartość 1 w wierszach o numerach 0, 2 i 4. Znaczy to, że wierzchołek v1 jest końcem krawędzi wychodzących z wierzchołków v0, v2 i v4. Do wierzchołka v1 nie dochodzą krawędzie z wierzchołków v1 i v 3.
Aby sprawdzić, czy w grafie dane dwa wierzchołki vi i vj są połączone krawędzią, sprawdzamy, czy komórka A [i, j] zawiera wartość 1. Jeśli tak, to dana krawędź istnieje. Zwróć uwagę, że operację tę można wykonać w czasie stałym, zatem dla macierzy sąsiedztwa sprawdzenie połączenia wierzchołków krawędzią ma klasę złożoności obliczeniowej równą O (1). Wadą jest klasa zajętość pamięci równa O (n2), gdzie n oznacza liczbę wierzchołków w grafie. Z drugiej strony komórki macierzy mogą być pojedynczymi bitami.
Z macierzy sąsiedztwa można odczytać wiele pożytecznych informacje. Oto niektóre z nich:
Uwaga:
Zanim uruchomisz program, przeczytaj wstęp do tego artykułu, w którym wyjaśniamy funkcje tych programów oraz sposób korzystania z nich. |
Program odczytuje definicję grafu skierowanego, tworzy macierz sąsiedztwa i wypisuje ją w czytelnej formie. |
Dane przykładowe (przekopiuj do schowka i
wklej do okna konsoli):
|
Pascal// Macierz sąsiedztwa // Data: 14.07.2013 // (C)2013 mgr Jerzy Wałaszek //--------------------------- program adj_matrix; // Typy dla dynamicznej macierzy type nptr = array of byte; // Wiersz mptr = array of nptr; // Cała macierz var n, m, i, j, v1, v2 : integer; A : mptr; begin read (n, m); // Czytamy liczbę wierzchołków i krawędzi SetLength (A, n); // Tworzymy tablicę wskaźników for i := 0 to n - 1 do SetLength (A [i], n); // Tworzymy wiersze // Macierz wypełniamy zerami for i := 0 to n - 1 do for j := 0 to n - 1 do A [i][j] := 0; // Odczytujemy kolejne definicje krawędzi for i := 1 to m do begin read (v1, v2); // Wierzchołek startowy i końcowy krawędzi A [v1][v2] := 1; // Krawędź v1->v2 obecna end; writeln; // Wypisujemy zawartość macierzy sąsiedztwa write (' '); for i := 0 to n - 1 do write (i:3); writeln; writeln; for i := 0 to n - 1 do begin write (i:3); for j := 0 to n - 1 do write (A [i][j] :3); writeln; end; // Usuwamy macierz for i := 0 to n - 1 do SetLength (A [i], 0); SetLength (A, 0); writeln; end. |
// Macierz sąsiedztwa // Data: 14.07.2013 // (C)2013 mgr Jerzy Wałaszek //--------------------------- #include <iostream> #include <iomanip> using namespace std; int main() { int n, m, i, j, v1, v2; char ** A; cin >> n >> m; // Czytamy liczbę wierzchołków i krawędzi A = new char * [n]; // Tworzymy tablicę wskaźników for(i = 0; i < n; i++) A [i] = new char [n]; // Tworzymy wiersze // Macierz wypełniamy zerami for(i = 0; i < n; i++) for(j = 0; j < n; j++) A [i][j] = 0; // Odczytujemy kolejne definicje krawędzi for(i = 0; i < m; i++) { cin >> v1 >> v2; // Wierzchołek startowy i końcowy krawędzi A [v1][v2] = 1; // Krawędź v1->v2 obecna } cout << endl; // Wypisujemy zawartość macierzy sąsiedztwa cout << " "; for(i = 0; i < n; i++) cout << setw (3) << i; cout << endl << endl; for(i = 0; i < n; i++) { cout << setw (3) << i; for(j = 0; j < n; j++) cout << setw (3) << (int) A [i][j]; cout << endl; } // Usuwamy macierz for(i = 0; i < n; i++) delete [] A [i]; delete [] A; cout << endl; return 0;} |
Basic' Macierz sąsiedztwa ' Data: 14.07.2013 ' (C)2013 mgr Jerzy Wałaszek '--------------------------- Dim As Integer n, m, i, j, v1, v2 Dim As Byte Ptr Ptr A Open Cons For Input As #1 Input #1, n, m ' Czytamy liczbę wierzchołków i krawędzi A = New Byte Ptr [n] ' Tworzymy tablicę wskaźników For i = 0 To n - 1 A [i] = New Byte [n] ' Tworzymy wiersze Next ' Macierz wypełniamy zerami For i = 0 To n - 1 For j = 0 To n - 1 A [i][j] = 0 Next Next ' Odczytujemy kolejne definicje krawędzi For i = 0 To m - 1 Input #1, v1, v2 ' Wierzchołek startowy i końcowy krawędzi A [v1][v2] = 1 ' Krawędź v1->v2 obecna Next Close #1 Print ' Wypisujemy zawartość macierzy sąsiedztwa Print " "; For i = 0 To n - 1 Print Using "###";i; Next Print: Print For i = 0 To n - 1 Print Using "###";i; For j = 0 To n - 1 Print Using "###";A [i][j]; Next Print Next ' Usuwamy macierz For i = 0 To n - 1 Delete [] A [i] Next Delete [] A Print End |
Wynik: |
6 8 0 1 1 2 2 2 1 3 3 1 2 4 4 0 4 3 0 1 2 3 4 5 0 0 1 0 0 0 0 1 0 0 1 1 0 0 2 0 0 1 0 1 0 3 0 1 0 0 0 0 4 1 0 0 1 0 0 5 0 0 0 0 0 0 |
Macierz incydencji (ang. incidence matrix) jest macierzą A o wymiarze n × m, gdzie n oznacza liczbę wierzchołków grafu, a m liczbę jego krawędzi. Każdy wiersz tej macierzy odwzorowuje jeden wierzchołek grafu. Każda kolumna odwzorowuje jedną krawędź. Zawartość komórki A [i, j] określa powiązanie (incydencję) wierzchołka vi z krawędzią ej w sposób następujący:
Przykład:
|
Jeśli graf jest nieskierowany, to definicję macierzy można uprościć:
Przykład:
|
Interpretacja macierzy incydencji jest równie prosta jak interpretacja macierzy sąsiedztwa. Weźmy dla przykładu wiersz nr 3:
0 | 1 | 2 | 3 | 4 | 5 | |
3 | 0 | 0 | -1 | 1 | 0 | 1 |
Wiersz nr 3 skojarzony jest z wierzchołkiem v3 grafu. Wierzchołek v3 jest końcem krawędzi e2 oraz początkiem krawędzi e3 i e5. Nie należy do krawędzi e0, e1 i e4.
Z kolei każda kolumna odwzorowuje jedną krawędź. Weźmy kolumnę nr 2:
2 | |
0 | 0 |
1 | 0 |
2 | 1 |
3 | -1 |
4 | 0 |
Kolumna nr 2 skojarzona jest z krawędzią e2 grafu. Krawędź ta wychodzi od wierzchołka v2 i kończy się w wierzchołku v3.
Macierz incydencji wymaga pamięci o rozmiarze O (m × n). Przydaje się wtedy, gdy algorytm musi posiadać informację o krawędziach (bo przykładowo posiadają one wagi). Pozwala w czasie O (1) sprawdzić, czy wierzchołek vi należy do krawędzi ej. Zwróć uwagę, że macierz incydencji nie nadaje się zbyt dobrze do reprezentacji grafów z pętlami, ponieważ wierzchołek startowy i końcowy muszą być różne. Co prawda, można się umówić na specjalną wartość, np. 2, dla wierzchołka, który jest jednocześnie początkiem jak i końcem krawędzie, lecz komplikuje to przetwarzanie macierzy. Z kolei macierz incydencji bez problemu pozwala reprezentować krawędzie wielokrotne.
Z macierzy incydencji możemy odczytać te same informacje, co z macierzy sąsiedztwa (chociaż czasami wymaga to więcej działań).
Uwaga:
Zanim uruchomisz program, przeczytaj wstęp do tego artykułu, w którym wyjaśniamy funkcje tych programów oraz sposób korzystania z nich. |
Program odczytuje definicję grafu skierowanego, tworzy macierz incydencji i wypisuje ją w czytelnej formie. |
Dane przykładowe (przekopiuj do schowka i
wklej do okna konsoli):
|
Pascal// Macierz incydencji // Data: 18.07.2013 // (C)2013 mgr Jerzy Wałaszek //--------------------------- program inc_matrix; // Typy dla dynamicznej macierzy type nptr = array of shortint; // Wiersz mptr = array of nptr; // Cała macierz var n, m, i, j, v1, v2 : integer; A : mptr; begin read (n, m); // Czytamy liczbę wierzchołków i krawędzi SetLength (A, n); // Tworzymy tablicę wskaźników for i := 0 to n - 1 do SetLength (A [i], m); // Tworzymy wiersze // Macierz wypełniamy zerami for i := 0 to n - 1 do for j := 0 to m - 1 do A [i][j] := 0; // Odczytujemy kolejne definicje krawędzi for i := 0 to m - 1 do begin read (v1, v2); // Wierzchołek startowy i końcowy krawędzi A [v1][i] := 1; // Wierzchołek startowy A [v2][i] := -1; // Wierzchołek końcowy end; writeln; // Wypisujemy zawartość macierzy incydencji write (' '); for i := 0 to m - 1 do write (i:3); writeln; writeln; for i := 0 to n - 1 do begin write (i:3); for j := 0 to m - 1 do write (A [i][j] :3); writeln; end; // Usuwamy macierz for i := 0 to n - 1 do SetLength (A [i], 0); SetLength (A, 0); writeln; end. |
// Macierz incydencji // Data: 18.07.2013 // (C)2013 mgr Jerzy Wałaszek //--------------------------- #include <iostream> #include <iomanip> using namespace std; int main() { int n, m, i, j, v1, v2; signed char ** A; cin >> n >> m; // Czytamy liczbę wierzchołków i krawędzi A = new signed char * [n]; // Tworzymy tablicę wskaźników for(i = 0; i < n; i++) A [i] = new signed char [m]; // Tworzymy wiersze // Macierz wypełniamy zerami for(i = 0; i < n; i++) for(j = 0; j < m; j++) A [i][j] = 0; // Odczytujemy kolejne definicje krawędzi for(i = 0; i < m; i++) { cin >> v1 >> v2; // Wierzchołek startowy i końcowy krawędzi A [v1][i] = 1; // Wierzchołek startowy A [v2][i] = -1; // Wierzchołek końcowy } cout << endl; // Wypisujemy zawartość macierzy incydencji cout << " "; for(i = 0; i < m; i++) cout << setw (3) << i; cout << endl << endl; for(i = 0; i < n; i++) { cout << setw (3) << i; for(j = 0; j < m; j++) cout << setw (3) << (int) A [i][j]; cout << endl; } // Usuwamy macierz for(i = 0; i < n; i++) delete [] A [i]; delete [] A; cout << endl; return 0; } |
Basic' Macierz incydencji ' Data: 18.07.2013 ' (C)2013 mgr Jerzy Wałaszek '--------------------------- Dim As Integer n, m, i, j, v1, v2 Dim As Byte Ptr Ptr A Open Cons For Input As #1 Input #1, n, m ' Czytamy liczbę wierzchołków i krawędzi A = New Byte Ptr [n] ' Tworzymy tablicę wskaźników For i = 0 To n - 1 A [i] = New Byte [m] ' Tworzymy wiersze Next ' Macierz wypełniamy zerami For i = 0 To n - 1 For j = 0 To m - 1 A [i][j] = 0 Next Next ' Odczytujemy kolejne definicje krawędzi For i = 0 To m - 1 Input #1, v1, v2 ' Wierzchołek startowy i końcowy krawędzi A [v1][i] = 1 ' Wierzchołek startowy A [v2][i] =-1 ' Wierzchołek końcowy Next Close #1 Print ' Wypisujemy zawartość macierzy incydencji Print " "; For i = 0 To m - 1 Print Using "###";i; Next Print: Print For i = 0 To n - 1 Print Using "###";i; For j = 0 To m - 1 Print Using "###";A [i][j]; Next Print Next ' Usuwamy macierz For i = 0 To n - 1 Delete [] A [i] Next Delete [] A Print End |
Wynik: |
6 7 0 1 1 2 1 3 3 1 2 4 4 0 4 3 0 1 2 3 4 5 6 0 1 0 0 0 0 -1 0 1 -1 1 1 -1 0 0 0 2 0 -1 0 0 1 0 0 3 0 0 -1 1 0 0 -1 4 0 0 0 0 -1 1 1 5 0 0 0 0 0 0 0 |
Do reprezentacji grafu wykorzystujemy tablicę n elementową A, gdzie n oznacza liczbę wierzchołków. Każdy element tej tablicy jest listą. Lista reprezentuje wierzchołek startowy. Na liście są przechowywane numery wierzchołków końcowych, czyli sąsiadów wierzchołka startowego, z którymi jest on połączony krawędzią. Tablica ta nosi nazwę list sąsiedztwa (ang. adjacency lists).
Przykład:
|
W przypadku grafu nieskierowanego listy są dłuższe, ponieważ muszą odzwierciedlać krawędzie biegnące w obu kierunkach.
Przykład:
|
Listy sąsiedztwa są efektywnym pamięciowo sposobem reprezentacji grafu w pamięci komputera, ponieważ zajmują pamięć rzędu O (m), gdzie m oznacza liczbę krawędzi grafu. Listy sąsiedztwa pozwalają w prosty sposób reprezentować pętle oraz krawędzie wielokrotne, co sprawia, że są bardzo chętnie stosowane w algorytmach grafowych.
Oto kilka podstawowych operacji na listach sąsiedztwa:
Uwaga:
Zanim uruchomisz program, przeczytaj wstęp do tego artykułu, w którym wyjaśniamy funkcje tych programów oraz sposób korzystania z nich. |
Program odczytuje definicję grafu skierowanego, tworzy tablicę list sąsiedztwa i wypisuje ją w czytelnej formie. W tablicy są wykorzystywane listy jednokierunkowe. |
Dane przykładowe (przekopiuj do schowka i
wklej do okna konsoli):
|
Pascal// Listy sąsiedztwa // Data: 18.07.2013 // (C)2013 mgr Jerzy Wałaszek //--------------------------- program inc_matrix; // Typy dla dynamicznej tablicy list sąsiedztwa type PslistEl = ^slistEl; slistEl = record next : PslistEl; v : integer; end; TList = array of PslistEl; var n, m, i, v1, v2 : integer; A : TList; p, r : PslistEl; begin read (n, m); // Czytamy liczbę wierzchołków i krawędzi SetLength (A, n); // Tworzymy tablicę list sąsiedztwa // Tablicę wypełniamy pustymi listami for i := 0 to n - 1 do A [i] := nil; // Odczytujemy kolejne definicje krawędzi for i := 0 to m - 1 do begin read (v1, v2); // Wierzchołek startowy i końcowy krawędzi new (p); // Tworzymy nowy element p^.v := v2; // Numerujemy go jako v2 p^.next := A [v1]; // Dodajemy go na początek listy A [v1] A [v1] := p; end; writeln; // Wypisujemy zawartość tablicy list sąsiedztwa for i := 0 to n - 1 do begin write ('A [', i, '] ='); p := A [i]; while p <> nil do begin write (p^.v:3); p := p^.next; end; writeln; end; // Usuwamy tablicę list sąsiedztwa for i := 0 to n - 1 do begin p := A [i]; while p <> nil do begin r := p; p := p^.next; dispose (r); end; end; SetLength (A, 0); writeln; end. |
// Listy sąsiedztwa // Data: 18.07.2013 // (C)2013 mgr Jerzy Wałaszek //--------------------------- #include <iostream> #include <iomanip> using namespace std; // Typy dla dynamicznej tablicy list sąsiedztwa struct slistEl { slistEl * next; int v; }; int main() { int n, m, i, v1, v2; slistEl ** A; slistEl *p, *r; cin >> n >> m; // Czytamy liczbę wierzchołków i krawędzi A = new slistEl * [n]; // Tworzymy tablicę list sąsiedztwa // Tablicę wypełniamy pustymi listami for(i = 0; i < n; i++) A [i] = NULL; // Odczytujemy kolejne definicje krawędzi for(i = 0; i < m; i++) { cin >> v1 >> v2; // Wierzchołek startowy i końcowy krawędzi p = new slistEl; // Tworzymy nowy element p->v = v2; // Numerujemy go jako v2 p->next = A [v1]; // Dodajemy go na początek listy A [v1] A [v1] = p; } cout << endl; // Wypisujemy zawartość tablicy list sąsiedztwa for(i = 0; i < n; i++) { cout << "A [" << i << "] ="; p = A [i]; while(p) { cout << setw (3) << p->v; p = p->next; } cout << endl; } // Usuwamy tablicę list sąsiedztwa for(i = 0; i < n; i++) { p = A [i]; while(p) { r = p; p = p->next; delete r; } } delete [] A; cout << endl; return 0;} |
Basic' Listy sąsiedztwa ' Data: 18.07.2013 ' (C)2013 mgr Jerzy Wałaszek '--------------------------- ' Typy dla dynamicznej tablicy list sąsiedztwa Type slistEl next As slistEl Ptr v As Integer End Type Dim As Integer n, m, i, v1, v2 Dim As slistEl Ptr Ptr A Dim As slistEl Ptr p, r Open Cons For Input As #1 Input #1, n, m ' Czytamy liczbę wierzchołków i krawędzi A = new slistEl Ptr [n] ' Tworzymy tablicę list sąsiedztwa ' Tablicę wypełniamy pustymi listami For i = 0 To n - 1 A [i] = 0 Next ' Odczytujemy kolejne definicje krawędzi For i = 0 To m -1 Input #1, v1, v2 ' Wierzchołek startowy i końcowy krawędzi p = new slistEl ' Tworzymy nowy element p->v = v2 ' Numerujemy go jako v2 p->next = A [v1] ' Dodajemy go na początek listy A [v1] A [v1] = p Next Close #1 Print ' Wypisujemy zawartość tablicy list sąsiedztwa For i = 0 To n - 1 Print Using "A [&] =";i; p = A [i] While p Print Using "###";p->v; p = p->Next Wend Print Next ' Usuwamy tablicę list sąsiedztwa For i = 0 To n - 1 p = A [i] While p r = p p = p->Next Delete r Wend Next Delete [] A Print End |
Wynik: |
6 8 0 1 1 2 2 2 1 3 3 1 2 4 4 0 4 3 A [0] = 1 A [1] = 3 2 A [2] = 4 2 A [3] = 1 A [4] = 3 0 A [5 ] = |
Zespół Przedmiotowy Chemii-Fizyki-Informatyki w I Liceum Ogólnokształcącym im. Kazimierza Brodzińskiego w Tarnowie ul. Piłsudskiego 4 ©2024 mgr Jerzy Wałaszek |
Materiały tylko do użytku dydaktycznego. Ich kopiowanie i powielanie jest dozwolone
pod warunkiem podania źródła oraz niepobierania za to pieniędzy.
Pytania proszę przesyłać na adres email:
Serwis wykorzystuje pliki cookies. Jeśli nie chcesz ich otrzymywać, zablokuj je w swojej przeglądarce.
Informacje dodatkowe.