Wyszukiwanie i sortowanie — krok po kroku
W lekcji 18 poznałeś Array.Sort i Array.BinarySearch. Teraz zajrzymy do środka: jak te algorytmy działają, dlaczego jeden jest szybszy od drugiego i jak je napisać samemu. Na egzaminie zwykle chodzi właśnie o to — o pokazanie algorytmu, nie o wywołanie gotowej metody.
Czego się dziś nauczysz
- Napiszesz wyszukiwanie liniowe i binarne
- Wyjaśnisz, dlaczego binarne wymaga posortowanych danych
- Zaimplementujesz trzy klasyczne algorytmy sortowania
- Prześledzisz działanie algorytmu krok po kroku na kartce
- Oszacujesz, ile porównań wykona algorytm dla
nelementów
Wyszukiwanie liniowe
Najprostszy możliwy algorytm: sprawdzamy element po elemencie, aż znajdziemy szukaną wartość albo skończą się dane.
int[] dane = { 42, 17, 93, 8, 55, 23 };
int szukana = 55;
int znaleziono = -1;
int porownan = 0;
for (int i = 0; i < dane.Length; i++)
{
porownan++;
if (dane[i] == szukana)
{
znaleziono = i;
break; // dalej szukac nie ma po co
}
}
if (znaleziono >= 0)
{
Console.WriteLine($"Znaleziono na indeksie {znaleziono} po {porownan} porownaniach.");
}
else
{
Console.WriteLine($"Nie znaleziono. Sprawdzono {porownan} elementow.");
}
Znaleziono na indeksie 4 po 5 porownaniach.
| Przypadek | Liczba porównań | Kiedy |
|---|---|---|
| optymistyczny | 1 | szukana wartość jest pierwsza |
| średni | około n / 2 | wartość gdzieś w środku |
| pesymistyczny | n | wartość jest ostatnia albo nie ma jej wcale |
Zaleta: działa zawsze
Wyszukiwanie liniowe nie stawia danym żadnych warunków — tablica może być w dowolnej kolejności. To jego jedyna, ale bardzo istotna przewaga nad wyszukiwaniem binarnym. Dokładnie tak działa Array.IndexOf.
Wyszukiwanie binarne
Znasz ten pomysł z gry w zgadywanie liczby z lekcji 14: pytamy o środek, a odpowiedź „za mało” albo „za dużo” pozwala odrzucić połowę możliwości. Ten sam trick działa na posortowanej tablicy.
int[] dane = { 3, 8, 12, 17, 23, 31, 42, 56, 68, 74, 89, 95 }; // POSORTOWANE
int szukana = 56;
int lewy = 0;
int prawy = dane.Length - 1;
int znaleziono = -1;
int krok = 0;
while (lewy <= prawy)
{
krok++;
int srodek = (lewy + prawy) / 2;
Console.WriteLine($"krok {krok}: lewy={lewy} srodek={srodek} prawy={prawy} " +
$"-> dane[{srodek}]={dane[srodek]}");
if (dane[srodek] == szukana)
{
znaleziono = srodek;
break;
}
else if (dane[srodek] < szukana)
{
lewy = srodek + 1; // szukana jest w prawej polowie
}
else
{
prawy = srodek - 1; // szukana jest w lewej polowie
}
}
Console.WriteLine();
Console.WriteLine(znaleziono >= 0
? $"Znaleziono na indeksie {znaleziono} w {krok} krokach."
: $"Nie znaleziono ({krok} krokow).");
krok 1: lewy=0 srodek=5 prawy=11 -> dane[5]=31
krok 2: lewy=6 srodek=8 prawy=11 -> dane[8]=68
krok 3: lewy=6 srodek=6 prawy=7 -> dane[6]=42
krok 4: lewy=7 srodek=7 prawy=7 -> dane[7]=56
Znaleziono na indeksie 7 w 4 krokach.
krok 1: lewy=0 srodek=5 prawy=11 -> dane[5]=31
krok 2: lewy=0 srodek=2 prawy=4 -> dane[2]=12
krok 3: lewy=0 srodek=0 prawy=1 -> dane[0]=3
krok 4: lewy=1 srodek=1 prawy=1 -> dane[1]=8
Nie znaleziono (4 krokow).
Trzy rzeczy, na które trzeba uważać
| Element | Dlaczego akurat tak |
|---|---|
while (lewy <= prawy) |
Znak musi być <=. Przy < pominięty zostałby przypadek, gdy do sprawdzenia został jeden element — a wtedy lewy równa się prawy. Zobacz krok 4 w obu przebiegach powyżej. |
srodek + 1 i srodek - 1 |
Środek został właśnie sprawdzony i nie pasuje, więc wykluczamy go z dalszych poszukiwań. Zapis lewy = srodek; to pętla nieskończona — przy dwóch elementach zakres przestałby się zmniejszać. |
| dane muszą być posortowane | Cały pomysł opiera się na tym, że „w lewo są mniejsze”. Na nieposortowanej tablicy algorytm odrzuci połowę, w której szukana wartość akurat leżała — i nie znajdzie jej, choć tam jest. |
Dlaczego to jest tak szybko
Każdy krok zmniejsza zakres o połowę. Dla 12 elementów wystarczą 4 kroki, dla 1000 — 10, dla miliona — 20, dla miliarda — 30. Wyszukiwanie liniowe potrzebowałoby w tym ostatnim przypadku średnio pół miliarda porównań. To najlepsza ilustracja tego, że wybór algorytmu bywa ważniejszy niż szybkość komputera.
Kiedy binarne się nie opłaca
Posortowanie tablicy kosztuje więcej niż jedno przeszukanie liniowe. Jeśli szukasz raz, po prostu przejdź tablicę. Binarne wygrywa dopiero wtedy, gdy w tych samych danych szukasz wielokrotnie — wtedy koszt sortowania rozkłada się na wiele wyszukiwań.
Zamiana dwóch wartości
Każdy algorytm sortowania na tym stoi, więc zaczynamy od tej jednej operacji. Nie da się jej wykonać bez trzeciej zmiennej:
int[] t = { 10, 20 };
// TAK NIE - obie komorki beda mialy 20
// t[0] = t[1];
// t[1] = t[0];
// poprawnie: przez zmienna pomocnicza
int pomocnicza = t[0];
t[0] = t[1];
t[1] = pomocnicza;
Console.WriteLine(string.Join(", ", t));
// krocej, od C# 7 - krotka
(t[0], t[1]) = (t[1], t[0]);
Console.WriteLine(string.Join(", ", t));
20, 10
10, 20
Dlaczego bez pomocniczej się nie da
Po pierwszym przypisaniu t[0] = t[1] stara wartość t[0] przestaje istnieć — została nadpisana. Drugie przypisanie skopiowałoby więc dwudziestkę samą na siebie. Trzecia zmienna to schowek na wartość, którą za chwilę stracimy. W kursie będziemy pisać wersję z pomocniczą, bo taka pojawia się w zadaniach egzaminacyjnych.
Sortowanie bąbelkowe
Pomysł: przechodzimy tablicę i porównujemy sąsiednie pary. Gdy stoją w złej kolejności, zamieniamy je miejscami. Po jednym przejściu największy element „wypływa” na sam koniec — jak bąbelek powietrza w wodzie. Powtarzamy, aż nic nie trzeba już zamieniać.
int[] t = { 5, 2, 9, 1, 7, 3 };
int n = t.Length;
for (int i = 0; i < n - 1; i++)
{
int zamian = 0;
for (int j = 0; j < n - 1 - i; j++) // uwaga na "- i"
{
if (t[j] > t[j + 1])
{
int pom = t[j];
t[j] = t[j + 1];
t[j + 1] = pom;
zamian++;
}
}
Console.WriteLine($"przebieg {i + 1}: {string.Join(", ", t)} (zamian: {zamian})");
if (zamian == 0)
{
break; // juz posortowane - nie ma po co isc dalej
}
}
przebieg 1: 2, 5, 1, 7, 3, 9 (zamian: 4)
przebieg 2: 2, 1, 5, 3, 7, 9 (zamian: 2)
przebieg 3: 1, 2, 3, 5, 7, 9 (zamian: 2)
przebieg 4: 1, 2, 3, 5, 7, 9 (zamian: 0)
| Fragment | Znaczenie |
|---|---|
j < n - 1 - i |
Najważniejszy szczegół. Po pierwszym przejściu ostatni element jest już na swoim miejscu, po drugim — dwa ostatnie. Odejmowanie i sprawia, że nie sprawdzamy ich ponownie. Bez tego algorytm działa poprawnie, ale robi dwa razy więcej pracy. |
n - 1 w obu pętlach |
Porównujemy t[j] z t[j + 1], więc j nie może dojść do ostatniego indeksu — wtedy j + 1 wyszłoby poza tablicę. |
zamian == 0 → break |
Jeśli w całym przejściu nic nie trzeba było zamienić, tablica jest już posortowana. Dzięki temu dla danych prawie uporządkowanych algorytm kończy się bardzo szybko. To jedyna optymalizacja, która czyni sortowanie bąbelkowe znośnym. |
Prześledź przebieg pierwszy na kartce
Start: 5, 2, 9, 1, 7, 3. Porównujemy 5 z 2 → zamiana (2, 5, 9, 1, 7, 3). Potem 5 z 9 → zostaje. Potem 9 z 1 → zamiana (2, 5, 1, 9, 7, 3). Potem 9 z 7 → zamiana (2, 5, 1, 7, 9, 3). Na koniec 9 z 3 → zamiana (2, 5, 1, 7, 3, 9). Cztery zamiany, a dziewiątka dopłynęła na sam koniec.
Sortowanie przez wybór
Pomysł bliższy temu, jak człowiek porządkuje karty w ręku: znajdź najmniejszy element w nieposortowanej części i przenieś go na początek. Potem to samo dla reszty.
int[] t = { 5, 2, 9, 1, 7, 3 };
int n = t.Length;
for (int i = 0; i < n - 1; i++)
{
// szukamy INDEKSU najmniejszego w czesci od i do konca
int indeksMin = i;
for (int j = i + 1; j < n; j++)
{
if (t[j] < t[indeksMin])
{
indeksMin = j;
}
}
// zamiana - tylko jesli trzeba
if (indeksMin != i)
{
int pom = t[i];
t[i] = t[indeksMin];
t[indeksMin] = pom;
}
Console.WriteLine($"krok {i + 1}: {string.Join(", ", t)}");
}
krok 1: 1, 2, 9, 5, 7, 3
krok 2: 1, 2, 9, 5, 7, 3
krok 3: 1, 2, 3, 5, 7, 9
krok 4: 1, 2, 3, 5, 7, 9
krok 5: 1, 2, 3, 5, 7, 9
Zapamiętujemy indeks, nie wartość
Zmienna nazywa się indeksMin, bo przechowuje numer, a nie liczbę. Znając samą najmniejszą wartość, nie wiedzielibyśmy, skąd ją zabrać przy zamianie. To ta sama zasada, co przy szukaniu najcieplejszego dnia w lekcji 17.
Dlaczego niektóre kroki niczego nie zmieniają
W kroku 2 najmniejszym elementem reszty jest dwójka, która już stoi na swoim miejscu — indeksMin równa się i, więc warunek indeksMin != i nie wpuszcza nas do zamiany. Podobnie w krokach 4 i 5. Ten warunek nie jest konieczny (zamiana elementu z samym sobą jest nieszkodliwa), ale oszczędza trzy przypisania.
Zaleta: najmniej zamian ze wszystkich
Sortowanie przez wybór wykonuje co najwyżej n − 1 zamian — po jednej na krok. Bąbelkowe potrafi ich zrobić setki. Ma to znaczenie, gdy przestawiane elementy są „ciężkie” (długie napisy, duże obiekty), bo wtedy zamiana kosztuje więcej niż porównanie.
Sortowanie przez wstawianie
Tak układa się karty, biorąc je po jednej: bierzemy kolejny element i wsuwamy go we właściwe miejsce wśród już uporządkowanych, przesuwając większe o jedno w prawo.
int[] t = { 5, 2, 9, 1, 7, 3 };
int n = t.Length;
for (int i = 1; i < n; i++) // start od 1: element 0 to juz "posortowana czesc"
{
int klucz = t[i]; // element, ktory wstawiamy
int j = i - 1;
// przesuwamy w prawo wszystko, co jest wieksze od klucza
while (j >= 0 && t[j] > klucz)
{
t[j + 1] = t[j];
j--;
}
t[j + 1] = klucz; // tu jest jego miejsce
Console.WriteLine($"krok {i}: {string.Join(", ", t)}");
}
krok 1: 2, 5, 9, 1, 7, 3
krok 2: 2, 5, 9, 1, 7, 3
krok 3: 1, 2, 5, 9, 7, 3
krok 4: 1, 2, 5, 7, 9, 3
krok 5: 1, 2, 3, 5, 7, 9
| Fragment | Znaczenie |
|---|---|
int klucz = t[i]; |
Odkładamy element na bok, bo za chwilę jego miejsce zostanie nadpisane przesuwanymi wartościami. To ta sama rola, co zmienna pomocnicza przy zamianie. |
j >= 0 && t[j] > klucz |
Dwa warunki naraz: nie wyjdź poza początek tablicy i przesuwaj, dopóki napotykasz większe. Kolejność jest ważna — dzięki skróconemu obliczaniu z lekcji 8 przy j równym −1 drugi warunek nie jest w ogóle sprawdzany, więc nie ma odwołania do t[-1]. |
t[j + 1] = klucz; |
Po zakończeniu pętli j wskazuje element mniejszy lub równy kluczowi (albo −1), więc klucz trafia o jedno miejsce dalej. |
Najlepszy z tej trójki
Dla danych prawie posortowanych wstawianie jest bardzo szybkie — pętla while prawie się nie wykonuje. Dlatego właśnie tego algorytmu używa .NET wewnątrz Array.Sort dla małych fragmentów tablicy. Jest też stabilny: elementy o równych wartościach zachowują pierwotną kolejność.
Ile to kosztuje
Wszystkie trzy poznane algorytmy sortowania mają dwie pętle zagnieżdżone, a to znaczy, że liczba porównań rośnie z kwadratem liczby elementów. Dwa razy więcej danych — cztery razy dłużej.
| Elementów | Porównań (około n²/2) | Czas przy milionie porównań na sekundę |
|---|---|---|
| 10 | 50 | natychmiast |
| 100 | 5 000 | natychmiast |
| 1 000 | 500 000 | pół sekundy |
| 10 000 | 50 000 000 | około minuty |
| 100 000 | 5 000 000 000 | ponad godzinę |
Dlatego istnieją algorytmy lepsze
Array.Sort używa algorytmów, w których liczba porównań rośnie jak n · log n, a nie n². Dla stu tysięcy elementów to około 1,7 miliona porównań zamiast pięciu miliardów — czyli sekunda zamiast godziny. Jednym z takich algorytmów jest sortowanie przez scalanie, którego serce poznałeś już w zadaniu z lekcji 18.
| Algorytm | Porównań | Zamian | Uwagi |
|---|---|---|---|
| bąbelkowe | do n²/2 | dużo | najprostsze do napisania, najwolniejsze; z wykrywaniem braku zamian szybkie dla danych prawie uporządkowanych |
| przez wybór | zawsze n²/2 | do n − 1 | liczba porównań nie zależy od danych; najmniej zamian |
| przez wstawianie | od n do n²/2 | zależnie od danych | bardzo szybkie dla danych prawie posortowanych; stabilne |
Array.Sort | około n · log n | — | w praktyce zawsze najlepszy wybór, jeśli nie chodzi o naukę algorytmu |
Kiedy pisać sortowanie samemu
W programie użytkowym — nigdy, jest Array.Sort. Własną implementację piszemy, gdy chodzi o naukę, o zadanie egzaminacyjne albo o sortowanie według reguły, której gotowa metoda nie zna. Ostatni przypadek w praktyce rozwiązuje się jednak przez Array.Sort z własnym porównywaniem albo przez LINQ.
Użyte w tej lekcji elementy
| Nazwa | Zwraca | Działanie |
|---|---|---|
(a, b) = (b, a) | — | Krotka — zamiana dwóch wartości bez zmiennej pomocniczej. Prawa strona liczy się w całości, zanim cokolwiek zostanie przypisane. |
Random.Next(a, b) | int |
Liczba losowa z zakresu od a do b — bez b. Do generowania danych testowych. |
Stopwatch | — | Klasa z System.Diagnostics do mierzenia czasu: Stopwatch.StartNew(), potem .Stop() i .ElapsedMilliseconds. |
Array.Sort(t) | nic | Gotowe sortowanie z lekcji 18 — punkt odniesienia dla własnych implementacji. |
Array.BinarySearch(t, x) | int |
Gotowe wyszukiwanie binarne. Wymaga posortowanej tablicy. |
t[..] | tablica | Kopia tablicy — konieczna, gdy chcesz porównać kilka algorytmów na tych samych danych. |
Math.Log2(x) | double |
Logarytm przy podstawie 2 — mówi, ile razy trzeba podzielić liczbę na pół. Dokładnie tyle kroków robi wyszukiwanie binarne. |
Porównanie algorytmów na tych samych danych
Program losuje tablicę, kopiuje ją trzy razy i sortuje każdą kopię innym algorytmem, licząc porównania i zamiany. Dzięki temu porównanie jest uczciwe — każdy algorytm dostaje identyczne dane.
const int Ile = 20;
Random los = new Random(42); // stale ziarno - te same dane przy kazdym uruchomieniu
int[] oryginal = new int[Ile];
for (int i = 0; i < Ile; i++)
{
oryginal[i] = los.Next(1, 100);
}
Console.WriteLine($"Dane: {string.Join(", ", oryginal)}");
Console.WriteLine();
Console.WriteLine($"{"Algorytm",-24}{"Porownan",10}{"Zamian",10}");
Console.WriteLine(new string('-', 44));
// --- 1. babelkowe ---
int[] a = oryginal[..];
int por = 0, zam = 0;
for (int i = 0; i < a.Length - 1; i++)
{
bool bylaZamiana = false;
for (int j = 0; j < a.Length - 1 - i; j++)
{
por++;
if (a[j] > a[j + 1])
{
int pom = a[j];
a[j] = a[j + 1];
a[j + 1] = pom;
zam++;
bylaZamiana = true;
}
}
if (!bylaZamiana)
{
break;
}
}
Console.WriteLine($"{"babelkowe",-24}{por,10}{zam,10}");
// --- 2. przez wybor ---
int[] b = oryginal[..];
por = 0;
zam = 0;
for (int i = 0; i < b.Length - 1; i++)
{
int indeksMin = i;
for (int j = i + 1; j < b.Length; j++)
{
por++;
if (b[j] < b[indeksMin])
{
indeksMin = j;
}
}
if (indeksMin != i)
{
int pom = b[i];
b[i] = b[indeksMin];
b[indeksMin] = pom;
zam++;
}
}
Console.WriteLine($"{"przez wybor",-24}{por,10}{zam,10}");
// --- 3. przez wstawianie ---
int[] c = oryginal[..];
por = 0;
zam = 0;
for (int i = 1; i < c.Length; i++)
{
int klucz = c[i];
int j = i - 1;
while (j >= 0)
{
por++;
if (c[j] <= klucz)
{
break;
}
c[j + 1] = c[j];
zam++;
j--;
}
c[j + 1] = klucz;
}
Console.WriteLine($"{"przez wstawianie",-24}{por,10}{zam,10}");
Console.WriteLine(new string('-', 44));
// --- sprawdzenie, czy wszystkie daly ten sam wynik ---
Console.WriteLine();
Console.WriteLine($"Wynik: {string.Join(", ", a)}");
Console.WriteLine($"Zgodne: {a.SequenceEqual(b) && b.SequenceEqual(c)}");
Dlaczego new Random(42)
Liczba w nawiasie to ziarno generatora. Przy tym samym ziarnie ciąg liczb losowych jest zawsze identyczny — więc program przy każdym uruchomieniu da ten sam wynik i możesz go porównać z kolegą albo z poprzednim uruchomieniem. Bez ziarna (new Random()) dane byłyby za każdym razem inne, co przy porównywaniu algorytmów utrudnia analizę. Konkretny ciąg zależy od wersji .NET, więc twoje liczby mogą się różnić od tych w podręczniku — ważne, że w jednym uruchomieniu wszystkie trzy algorytmy dostają to samo.
Czego się z tego dowiadujemy
| Obserwacja | Wyjaśnienie |
|---|---|
| wybór zawsze robi tyle samo porównań | Dla n elementów dokładnie n(n−1)/2 — przy dwudziestu to 190, niezależnie od danych. Wewnętrzna pętla zawsze przechodzi całą resztę tablicy. |
| bąbelkowe robi najwięcej zamian | Zamienia tylko sąsiadów, więc element z końca tablicy musi „przepłynąć” przez wszystkie pozycje po jednej. |
| wybór robi najmniej zamian | Najwyżej jedną na krok, bo od razu przenosi element na docelowe miejsce. |
| wstawianie zależy od danych | Na danych losowych wypada podobnie do bąbelkowego. Na prawie posortowanych — wielokrotnie lepiej. Spróbuj uruchomić program na tablicy już posortowanej i zobacz różnicę. |
SequenceEqual na końcu |
Sprawdzenie, że wszystkie trzy algorytmy dały identyczny wynik. To najprostszy test poprawności własnej implementacji — jeśli wypisze False, gdzieś jest błąd. |
Eksperyment do samodzielnego wykonania
Uruchom program trzy razy: na danych losowych, na tablicy już posortowanej rosnąco i na posortowanej malejąco. Zapisz wyniki w tabelce. Zobaczysz, że dane posortowane malejąco to najgorszy możliwy przypadek dla wszystkich trzech algorytmów, a posortowane rosnąco — najlepszy dla bąbelkowego i wstawiania, ale bez znaczenia dla wyboru.
Na czym się najczęściej potykamy
| Błąd | Co się dzieje i jak poprawić |
|---|---|
| zamiana bez zmiennej pomocniczej | Obie komórki dostają tę samą wartość, druga przepada. Potrzebna trzecia zmienna albo krotka (a, b) = (b, a). |
j < n w bąbelkowym |
Odwołanie do t[j + 1] wyjdzie poza tablicę. Warunek to j < n - 1 - i. |
brak - i w bąbelkowym |
Algorytm nadal sortuje poprawnie, ale porównuje elementy, o których już wiadomo, że są na miejscu — dwa razy więcej pracy. |
| zapamiętywanie wartości zamiast indeksu | W sortowaniu przez wybór trzeba wiedzieć, skąd zabrać najmniejszy element. Sama wartość tego nie mówi. |
while (lewy < prawy) w binarnym |
Pomija przypadek jednego elementu do sprawdzenia. Musi być <=. |
lewy = srodek; zamiast srodek + 1 |
Zakres przestaje się zmniejszać — pętla nieskończona. |
| binarne na nieposortowanej tablicy | Wynik jest bezsensowny, a program nie zgłasza błędu. Zawsze sortuj wcześniej. |
t[j] > klucz przed sprawdzeniem j >= 0 |
Odwołanie do t[-1] i przerwanie programu. Warunek zakresu musi być pierwszy — skrócone obliczanie zrobi resztę. |
| porównywanie algorytmów na różnych danych | Wyniki są nieporównywalne. Każdy algorytm musi dostać kopię tych samych danych. |
| sortowanie 100 000 elementów bąbelkowym | Program nie zawiesił się — on po prostu liczy i będzie liczył godzinami. Przed uruchomieniem oszacuj n². |
Zadania
Przerób sortowanie bąbelkowe tak, żeby porządkowało tablicę malejąco. Zmienić trzeba dokładnie jeden znak.
pokaż rozwiązanie
int[] t = { 5, 2, 9, 1, 7, 3 };
int n = t.Length;
for (int i = 0; i < n - 1; i++)
{
for (int j = 0; j < n - 1 - i; j++)
{
if (t[j] < t[j + 1]) // BYLO: > JEST: <
{
int pom = t[j];
t[j] = t[j + 1];
t[j + 1] = pom;
}
}
}
Console.WriteLine(string.Join(", ", t));
9, 7, 5, 3, 2, 1
Cały porządek sortowania zależy od jednego porównania. Odwrócenie znaku odwraca wynik. Ta sama zasada działa we wszystkich trzech algorytmach — w sortowaniu przez wybór wystarczy zamienić < na > i szukać maksimum zamiast minimum.
Alternatywa: posortować rosnąco i wywołać Array.Reverse — krócej, ale wolniej o jedno przejście po tablicy.
Policz, ile porównań wykonuje wyszukiwanie liniowe, a ile binarne, szukając tej samej wartości w posortowanej tablicy stu elementów.
pokaż rozwiązanie
// tablica 100 kolejnych liczb parzystych: 2, 4, 6, ... 200
int[] dane = new int[100];
for (int i = 0; i < dane.Length; i++)
{
dane[i] = (i + 1) * 2;
}
int szukana = 178;
// --- liniowe ---
int porLiniowe = 0;
for (int i = 0; i < dane.Length; i++)
{
porLiniowe++;
if (dane[i] == szukana)
{
break;
}
}
// --- binarne ---
int porBinarne = 0;
int lewy = 0;
int prawy = dane.Length - 1;
while (lewy <= prawy)
{
porBinarne++;
int srodek = (lewy + prawy) / 2;
if (dane[srodek] == szukana)
{
break;
}
if (dane[srodek] < szukana)
{
lewy = srodek + 1;
}
else
{
prawy = srodek - 1;
}
}
Console.WriteLine($"Szukana wartosc: {szukana}");
Console.WriteLine($"Liniowe: {porLiniowe} porownan");
Console.WriteLine($"Binarne: {porBinarne} porownan");
Console.WriteLine($"Szybciej: {(double)porLiniowe / porBinarne:F1} razy");
Szukana wartosc: 178
Liniowe: 89 porownan
Binarne: 6 porownan
Szybciej: 14,8 razy
Wartość 178 to element o indeksie 88 (bo (88 + 1) · 2 = 178), więc wyszukiwanie liniowe potrzebuje 89 porównań — jednego na każdy sprawdzony element.
Binarne potrzebuje sześciu, bo 2⁶ = 64, a 2⁷ = 128 — sto elementów mieści się między nimi. Ogólnie: liczba kroków to zaokrąglony w górę logarytm przy podstawie 2 z liczby elementów.
Zmień szukaną wartość na 2 (pierwszy element) i uruchom ponownie: teraz liniowe wygrywa jednym porównaniem do sześciu. To pokazuje, że przewaga binarnego jest przeciętna, a nie zawsze.
Posortuj tablicę imion alfabetycznie własnym algorytmem (nie używając Array.Sort). Do porównania napisów użyj metody CompareTo.
pokaż rozwiązanie
string[] imiona = { "Zofia", "Adam", "Ewa", "Bartek", "Celina", "Dawid" };
for (int i = 0; i < imiona.Length - 1; i++)
{
int indeksMin = i;
for (int j = i + 1; j < imiona.Length; j++)
{
// CompareTo zwraca liczbe ujemna, gdy pierwszy jest "mniejszy"
if (imiona[j].CompareTo(imiona[indeksMin]) < 0)
{
indeksMin = j;
}
}
if (indeksMin != i)
{
string pom = imiona[i];
imiona[i] = imiona[indeksMin];
imiona[indeksMin] = pom;
}
}
Console.WriteLine(string.Join(", ", imiona));
Adam, Bartek, Celina, Dawid, Ewa, Zofia
Napisów nie porównuje się operatorami < i > — kompilator zgłosi CS0019. Służy do tego metoda CompareTo, która zwraca liczbę: ujemną, gdy pierwszy napis jest wcześniej w porządku alfabetycznym, zero przy równości, dodatnią w przeciwnym razie.
Poza tym jednym miejscem algorytm jest identyczny jak dla liczb. To dobra ilustracja tego, że algorytm sortowania nie zależy od typu danych — zależy tylko od tego, jak porównujemy dwa elementy.
Zmiana zmiennej pomocniczej na typ string to druga i ostatnia różnica.
Policz medianę tablicy liczb: wartość środkową po posortowaniu, a przy parzystej liczbie elementów — średnią dwóch środkowych. Oryginalna kolejność ma zostać nienaruszona.
pokaż rozwiązanie
double[] dane = { 7.5, 2.0, 9.5, 4.0, 6.0 };
// kopia - mediana nie moze zniszczyc kolejnosci danych
double[] posortowane = dane[..];
Array.Sort(posortowane);
int n = posortowane.Length;
double mediana;
if (n % 2 == 1)
{
mediana = posortowane[n / 2]; // nieparzyscie: jeden srodkowy
}
else
{
mediana = (posortowane[n / 2 - 1] + posortowane[n / 2]) / 2.0;
}
double suma = 0;
foreach (double x in dane)
{
suma += x;
}
Console.WriteLine($"Dane: {string.Join(", ", dane)}");
Console.WriteLine($"Posortowane: {string.Join(", ", posortowane)}");
Console.WriteLine($"Mediana: {mediana:F2}");
Console.WriteLine($"Srednia: {suma / n:F2}");
Dane: 7,5, 2, 9,5, 4, 6
Posortowane: 2, 4, 6, 7,5, 9,5
Mediana: 6,00
Srednia: 5,80
Dla pięciu elementów n / 2 to 2 (dzielenie całkowite), czyli trzeci element — dokładnie środkowy. Dla sześciu byłyby to indeksy 2 i 3.
Dzielenie przez 2.0, nie przez 2 — przy typie double nie ma to znaczenia, ale przy tablicy int[] byłoby dzieleniem całkowitym i mediana z 4 i 5 wyszłaby 4 zamiast 4,5.
Zwróć uwagę, że string.Join wypisuje 2, a nie 2,0 — domyślny format liczby double pomija zbędne zera. Gdyby miały być widoczne, trzeba by wypisać tablicę pętlą z formatem F1.
Mediana 6,00 a średnia 5,80 — obie liczby opisują „środek”, ale mediana jest odporna na wartości skrajne. Dodaj do danych liczbę 100 i zobacz, jak średnia skacze, a mediana prawie się nie rusza.
Posortuj uczniów według punktów malejąco — własnym algorytmem, przestawiając jednocześnie obie tablice. To ręczna wersja Array.Sort(klucze, wartości) z lekcji 18.
pokaż rozwiązanie
string[] nazwiska = { "Kowalski", "Nowak", "Wisniewska", "Zielinski", "Adamczyk" };
int[] punkty = { 68, 91, 45, 77, 91 };
int n = punkty.Length;
for (int i = 0; i < n - 1; i++)
{
int indeksMax = i;
for (int j = i + 1; j < n; j++)
{
if (punkty[j] > punkty[indeksMax])
{
indeksMax = j;
}
}
if (indeksMax != i)
{
// zamiana w OBU tablicach naraz
int pomP = punkty[i];
punkty[i] = punkty[indeksMax];
punkty[indeksMax] = pomP;
string pomN = nazwiska[i];
nazwiska[i] = nazwiska[indeksMax];
nazwiska[indeksMax] = pomN;
}
}
Console.WriteLine($"{"Lp.",-5}{"Nazwisko",-14}{"Punkty",8}");
Console.WriteLine(new string('-', 27));
for (int i = 0; i < n; i++)
{
Console.WriteLine($"{i + 1,-5}{nazwiska[i],-14}{punkty[i],8}");
}
Lp. Nazwisko Punkty
---------------------------
1 Nowak 91
2 Adamczyk 91
3 Zielinski 77
4 Kowalski 68
5 Wisniewska 45
Obie zamiany muszą być w tym samym miejscu i z tymi samymi indeksami. Przestawienie tylko punktów rozjechałoby dane — i, jak zwykle przy takim błędzie, program nie zgłosiłby żadnego problemu.
Szukamy tu maksimum, a nie minimum (znak >), bo porządek ma być malejący. To ta sama zmiana jednego znaku co w zadaniu 1.
Nowak i Adamczyk mają po 91 punktów. Który znajdzie się wyżej? Warunek > (bez równości) nie podmienia indeksMax przy remisie, więc wygrywa ten, który był wcześniej w tablicy — czyli Nowak. Gdyby warunek brzmiał >=, kolejność byłaby odwrotna. To właśnie oznacza stabilność sortowania i warto o tym pamiętać, gdy uczniowie z tą samą liczbą punktów mają być wypisani w kolejności alfabetycznej.
Napisz wyszukiwanie binarne, które przy braku szukanej wartości podaje, między którymi elementami powinna się ona znaleźć. Wypisz też każdy krok wraz z rozmiarem pozostałego zakresu.
pokaż rozwiązanie
int[] dane = { 3, 8, 12, 17, 23, 31, 42, 56, 68, 74, 89, 95 };
Console.Write("Czego szukamy? ");
bool ok = int.TryParse(Console.ReadLine(), out int szukana);
if (!ok)
{
Console.WriteLine("Podaj liczbe calkowita.");
}
else
{
int lewy = 0;
int prawy = dane.Length - 1;
int znaleziono = -1;
int krok = 0;
Console.WriteLine();
Console.WriteLine($"{"Krok",5}{"Lewy",7}{"Srodek",8}{"Prawy",7}{"Wartosc",9}{"Zakres",8}");
Console.WriteLine(new string('-', 44));
while (lewy <= prawy)
{
krok++;
int srodek = (lewy + prawy) / 2;
int zakres = prawy - lewy + 1;
Console.WriteLine($"{krok,5}{lewy,7}{srodek,8}{prawy,7}{dane[srodek],9}{zakres,8}");
if (dane[srodek] == szukana)
{
znaleziono = srodek;
break;
}
if (dane[srodek] < szukana)
{
lewy = srodek + 1;
}
else
{
prawy = srodek - 1;
}
}
Console.WriteLine(new string('-', 44));
Console.WriteLine();
if (znaleziono >= 0)
{
Console.WriteLine($"Znaleziono {szukana} na indeksie {znaleziono} " +
$"po {krok} krokach.");
}
else
{
// po petli: prawy < lewy, wiec miejsce wstawienia to indeks 'lewy'
string przed = lewy > 0 ? dane[lewy - 1].ToString() : "poczatek";
string po = lewy < dane.Length ? dane[lewy].ToString() : "koniec";
Console.WriteLine($"Nie ma wartosci {szukana}.");
Console.WriteLine($"Jej miejsce byloby na indeksie {lewy}, miedzy {przed} a {po}.");
}
}
Czego szukamy? 56
Krok Lewy Srodek Prawy Wartosc Zakres
--------------------------------------------
1 0 5 11 31 12
2 6 8 11 68 6
3 6 6 7 42 2
4 7 7 7 56 1
--------------------------------------------
Znaleziono 56 na indeksie 7 po 4 krokach.
Czego szukamy? 10
Krok Lewy Srodek Prawy Wartosc Zakres
--------------------------------------------
1 0 5 11 31 12
2 0 2 4 12 5
3 0 0 1 3 2
4 1 1 1 8 1
--------------------------------------------
Nie ma wartosci 10.
Jej miejsce byloby na indeksie 2, miedzy 8 a 12.
Kolumna „Zakres” pokazuje sedno algorytmu: 12 → 6 → 2 → 1. Każdy krok zmniejsza obszar poszukiwań mniej więcej o połowę. Właśnie dlatego dla miliona elementów wystarcza dwadzieścia kroków.
Po zakończeniu pętli zmienna lewy wskazuje miejsce wstawienia. To nie przypadek: pętla kończy się dokładnie wtedy, gdy prawy spada poniżej lewy, a wszystkie elementy przed lewy są mniejsze od szukanej i wszystkie od lewy w górę — większe. To bardzo użyteczna właściwość: tak działa wstawianie do posortowanej listy.
Dwa warunki na końcu obsługują skrajne przypadki: gdy szukana byłaby przed pierwszym elementem (lewy równe 0) albo za ostatnim (lewy równe długości tablicy). Bez nich program przerwałby działanie na wyjściu poza zakres.
Sprawdź sam dla wartości 1 (przed początkiem) i 100 (za końcem) — powinny wyjść odpowiednio indeks 0 i indeks 12.
Co trzeba zapamiętać
- Wyszukiwanie liniowe działa zawsze, ale w najgorszym razie sprawdza wszystkie
nelementów. - Wyszukiwanie binarne potrzebuje około
log₂ nkroków, ale wymaga posortowanych danych. - W binarnym: warunek
lewy <= prawy, przesunięcia osrodek + 1isrodek - 1. - Zamiana dwóch wartości wymaga zmiennej pomocniczej albo krotki
(a, b) = (b, a). - Bąbelkowe: porównuje sąsiadów, po każdym przejściu jeden element ląduje na końcu; warunek
j < n - 1 - i. - Przez wybór: szuka indeksu najmniejszego i przenosi go na początek; najmniej zamian.
- Przez wstawianie: wsuwa kolejny element między już uporządkowane; najszybsze dla danych prawie posortowanych.
- Wszystkie trzy mają dwie pętle, więc koszt rośnie z kwadratem liczby elementów.
- W prawdziwym programie używa się
Array.Sort— własne implementacje piszemy dla nauki i na egzamin. - Porównując algorytmy, każdemu daj kopię tych samych danych.
Koniec bloku 4
Umiesz już przechowywać wiele wartości, przechodzić je pętlami, wyszukiwać i porządkować. Twoje programy zaczynają jednak robić się długie i powtarzalne — te same kilkanaście linii sortowania kopiujesz do każdego zadania. W następnym bloku poznasz metody, dzięki którym napiszesz taki fragment raz i będziesz go wywoływał jednym słowem.