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.

C# algorytmy INF.03 55 min
CEL LEKCJI

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 n elementów
TEORIA

Wyszukiwanie liniowe

Najprostszy możliwy algorytm: sprawdzamy element po elemencie, aż znajdziemy szukaną wartość albo skończą się dane.

Program.cs
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.");
}
wynik w konsoli
Znaleziono na indeksie 4 po 5 porownaniach.
PrzypadekLiczba porównańKiedy
optymistyczny1szukana wartość jest pierwsza
średniokoło n / 2wartość gdzieś w środku
pesymistycznynwartość 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.

TEORIA

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.

Program.cs
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).");
wynik dla szukanej 56
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.
wynik dla szukanej 10 (jej nie ma)
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ć

ElementDlaczego 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ń.

TEORIA

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:

Program.cs
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));
wynik w konsoli
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.

TEORIA

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ć.

Program.cs
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
    }
}
wynik w konsoli
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)
FragmentZnaczenie
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 == 0break 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.

TEORIA

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.

Program.cs
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)}");
}
wynik w konsoli
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.

TEORIA

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.

Program.cs
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)}");
}
wynik w konsoli
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
FragmentZnaczenie
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ść.

TEORIA

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ówPorównań (około n²/2)Czas przy milionie porównań na sekundę
1050natychmiast
1005 000natychmiast
1 000500 000pół sekundy
10 00050 000 000około minuty
100 0005 000 000 000ponad 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 . 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.

AlgorytmPorównańZamianUwagi
bąbelkowedo n²/2dużonajprostsze do napisania, najwolniejsze; z wykrywaniem braku zamian szybkie dla danych prawie uporządkowanych
przez wybórzawsze n²/2do n − 1liczba porównań nie zależy od danych; najmniej zamian
przez wstawianieod n do n²/2zależnie od danychbardzo szybkie dla danych prawie posortowanych; stabilne
Array.Sortokoło n · log nw 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.

METODY WBUDOWANE

Użyte w tej lekcji elementy

NazwaZwracaDział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 bbez 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.
PRZYKŁAD Z OMÓWIENIEM

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.

Program.cs
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

ObserwacjaWyjaś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.

CZĘSTE BŁĘDY

Na czym się najczęściej potykamy

BłądCo 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 .
ZADANIA

Zadania

ZAD 1Sortowanie malejąco★☆☆

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));
wynik w konsoli
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.

ZAD 2Licznik porównań★☆☆

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");
wynik w konsoli
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.

ZAD 3Sortowanie napisów★★☆

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));
wynik w konsoli
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.

ZAD 4Mediana★★☆

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}");
wynik w konsoli
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.

ZAD 5Sortowanie dwóch tablic własnym algorytmem★★☆

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}");
}
wynik w konsoli
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.

ZAD 6Wyszukiwanie binarne z licznikiem zakresów★★★

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}.");
    }
}
wynik dla szukanej 56
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.
wynik dla szukanej 10
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.

PODSUMOWANIE

Co trzeba zapamiętać

  • Wyszukiwanie liniowe działa zawsze, ale w najgorszym razie sprawdza wszystkie n elementów.
  • Wyszukiwanie binarne potrzebuje około log₂ n kroków, ale wymaga posortowanych danych.
  • W binarnym: warunek lewy <= prawy, przesunięcia o srodek + 1 i srodek - 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.