Algorytmy sortowania
Sortowanie to proces ustawiania elementów kolekcji w określonej kolejności (np. rosnącej lub malejącej). Jest jednym z fundamentalnych zagadnień informatyki. W Pythonie możemy korzystać z wbudowanej funkcji sorted() lub metody list.sort(), jednak znajomość klasycznych algorytmów sortowania pomaga zrozumieć pojęcia złożoności obliczeniowej, efektywności i podejścia "dziel i zwyciężaj".
Wbudowane sortowanie w Pythonie
Python udostępnia dwa wbudowane sposoby sortowania:
lista = [5, 2, 8, 1, 9, 3]
# sorted() — tworzy nową posortowaną listę, oryginał się nie zmienia
posortowana = sorted(lista)
print(posortowana) # [1, 2, 3, 5, 8, 9]
print(lista) # [5, 2, 8, 1, 9, 3]
# list.sort() — sortuje listę w miejscu
lista.sort()
print(lista) # [1, 2, 3, 5, 8, 9]
# Sortowanie odwrotne
print(sorted(lista, reverse=True)) # [9, 8, 5, 3, 2, 1]
# Sortowanie po kluczu
slowa = ["banan", "jabłko", "gruszka", "śliwka"]
print(sorted(slowa, key=len)) # ['banan', 'jabłko', 'gruszka', 'śliwka']
Sortowanie bąbelkowe (Bubble Sort)
Idea: Wielokrotnie przechodzimy przez listę, porównując sąsiednie elementy i zamieniając je, jeśli są w złej kolejności. Po każdym przebiegu największy element "wypływa" na koniec listy — stąd nazwa "bąbelkowe".
Złożoność: O(n²) — nieefektywne dla dużych zbiorów, ale prosty do zrozumienia.
def sortowanie_babelkowe(lista):
for _ in range(len(lista)):
for j in range(len(lista) - 1):
if lista[j] > lista[j + 1]:
lista[j], lista[j + 1] = lista[j + 1], lista[j]
return lista
print(sortowanie_babelkowe([5, 4, 3, 2, 1])) # [1, 2, 3, 4, 5]
print(sortowanie_babelkowe([1, 5, 2, 4, 3])) # [1, 2, 3, 4, 5]
Krok po kroku dla [5, 3, 1]:
Przebieg 1: [5, 3, 1] → [3, 5, 1] → [3, 1, 5]
Przebieg 2: [3, 1, 5] → [1, 3, 5] → [1, 3, 5]
Wynik: [1, 3, 5]
Sortowanie przez wybieranie (Selection Sort)
Idea: W każdej iteracji znajdujemy najmniejszy element z nieposortowanej części listy i przenosimy go na początek. Powtarzamy, aż lista jest posortowana.
Złożoność: O(n²) — podobna do bąbelkowego, ale wykonuje mniej zamian.
def znajdz_najmniejszy(lista):
najmniejszy_index = 0
for i in range(1, len(lista)):
if lista[i] < lista[najmniejszy_index]:
najmniejszy_index = i
return najmniejszy_index
def sortowanie_przez_wybieranie(lista):
nowa_lista = []
kopia = lista[:]
for _ in range(len(kopia)):
najmniejszy = znajdz_najmniejszy(kopia)
nowa_lista.append(kopia.pop(najmniejszy))
return nowa_lista
print(sortowanie_przez_wybieranie([5, 4, 3, 2, 1])) # [1, 2, 3, 4, 5]
print(sortowanie_przez_wybieranie([1, 5, 2, 4, 3])) # [1, 2, 3, 4, 5]
Krok po kroku dla [5, 3, 1, 4]:
Krok 1: minimum = 1 (indeks 2) → nowa_lista = [1], pozostałe = [5, 3, 4]
Krok 2: minimum = 3 (indeks 1) → nowa_lista = [1, 3], pozostałe = [5, 4]
Krok 3: minimum = 4 (indeks 1) → nowa_lista = [1, 3, 4], pozostałe = [5]
Krok 4: minimum = 5 (indeks 0) → nowa_lista = [1, 3, 4, 5]
Sortowanie szybkie (Quicksort)
Idea (podejście "dziel i zwyciężaj"):
1. Wybierz element zwany pivotem (np. pierwszy element listy).
2. Podziel pozostałe elementy na dwie grupy: mniejsze lub równe pivotowi i większe.
3. Rekurencyjnie posortuj obie grupy.
4. Złącz wyniki: [posortowane_mniejsze] + [pivot] + [posortowane_wieksze].
Złożoność: O(n log n) średnio, O(n²) w najgorszym przypadku — w praktyce jeden z najszybszych algorytmów.
def sortowanie_szybkie(lista):
if len(lista) <= 1:
return lista
pivot = lista[0]
mniejsze = [e for e in lista[1:] if e <= pivot]
wieksze = [e for e in lista[1:] if e > pivot]
return sortowanie_szybkie(mniejsze) + [pivot] + sortowanie_szybkie(wieksze)
print(sortowanie_szybkie([5, 4, 3, 2, 1])) # [1, 2, 3, 4, 5]
print(sortowanie_szybkie([1, 5, 2, 4, 3])) # [1, 2, 3, 4, 5]
Wizualizacja dla [3, 1, 4, 1, 5, 9, 2]:
pivot = 3
mniejsze = [1, 1, 2]
wieksze = [4, 5, 9]
quick([1, 1, 2]) = [1, 1, 2]
quick([4, 5, 9]) = [4, 5, 9]
Wynik: [1, 1, 2] + [3] + [4, 5, 9] = [1, 1, 2, 3, 4, 5, 9]
Porównanie algorytmów
| Algorytm | Złożoność (średnia) | Złożoność (najgorszy) | Stabilny | Uwagi |
|---|---|---|---|---|
| Bąbelkowe | O(n²) | O(n²) | Tak | Prosty, ale wolny dla dużych danych |
| Przez wybieranie | O(n²) | O(n²) | Nie | Mało zamian; wolny dla dużych danych |
| Szybkie (Quicksort) | O(n log n) | O(n²) | Nie | Bardzo szybkie w praktyce |
Wbudowane (sorted) |
O(n log n) | O(n log n) | Tak | Algorytm Timsort; zalecane |
Zalecenie praktyczne: W codziennym kodzie Python zawsze używaj wbudowanego
sorted()lub.sort(). Własne implementacje algorytmów sortowania są wartościowe edukacyjnie — pomagają zrozumieć złożoność obliczeniową — ale w produkcji wbudowane funkcje są szybsze i dokładniej zoptymalizowane.
Wyszukiwanie w liście
Szukanie elementu w liście to równie podstawowe zagadnienie. Dwie klasyczne metody to wyszukiwanie liniowe i wyszukiwanie binarne.
Wyszukiwanie liniowe
Sprawdzamy elementy jeden po drugim, aż znajdziemy szukany lub wyczerpamy listę. Działa na listach nieposortowanych.
Złożoność: O(n)
def wyszukiwanie_liniowe(lista, element):
for i in range(len(lista)):
if lista[i] == element:
return i # zwróć indeks znalezionego elementu
return -1 # element nie znaleziony
lista = [3, 1, 4, 1, 5, 9, 2]
print(wyszukiwanie_liniowe(lista, 5)) # 4
print(wyszukiwanie_liniowe(lista, 7)) # -1
Wyszukiwanie binarne
Działa tylko na listach posortowanych. Dzieli listę na pół przy każdym kroku: jeśli środkowy element jest za duży, szukamy w lewej połowie; jeśli za mały — w prawej.
Złożoność: O(log n) — znacznie szybsze niż liniowe dla dużych danych.
def wyszukiwanie_binarne(lista, element):
lewy = 0
prawy = len(lista) - 1
while lewy <= prawy:
srodek = (lewy + prawy) // 2
if lista[srodek] == element:
return srodek
elif lista[srodek] < element:
lewy = srodek + 1
else:
prawy = srodek - 1
return -1
lista = [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(wyszukiwanie_binarne(lista, 6)) # 5
print(wyszukiwanie_binarne(lista, 10)) # -1
Wizualizacja dla listy [1, 3, 5, 7, 9, 11], szukamy 7:
Krok 1: lewy=0, prawy=5, środek=2 → lista[2]=5 < 7 → lewy=3
Krok 2: lewy=3, prawy=5, środek=4 → lista[4]=9 > 7 → prawy=3
Krok 3: lewy=3, prawy=3, środek=3 → lista[3]=7 == 7 → zwróć 3
Python ma wbudowany moduł bisect do wyszukiwania binarnego:
import bisect
lista = [1, 3, 5, 7, 9, 11]
indeks = bisect.bisect_left(lista, 7)
print(indeks) # 3
print(lista[indeks]) # 7
Moduł bisect — wyszukiwanie i wstawianie do posortowanej listy
Moduł bisect zapewnia wydajne operacje na posortowanych listach bez potrzeby ponownego sortowania:
import bisect
posortowana = [1, 3, 5, 7, 9]
# bisect_left — indeks, gdzie wstawić nowy element (przed duplikatem)
print(bisect.bisect_left(posortowana, 5)) # 2
# bisect_right — indeks, gdzie wstawić (po duplikacie)
print(bisect.bisect_right(posortowana, 5)) # 3
# insort_left — wstawienie elementu w posortowane miejsce
bisect.insort_left(posortowana, 4)
print(posortowana) # [1, 3, 4, 5, 7, 9]
# insort — wstawienie (domyślnie jak insort_right)
bisect.insort(posortowana, 6)
print(posortowana) # [1, 3, 4, 5, 6, 7, 9]
bisect jest szczególnie przydatny przy utrzymywaniu posortowanych list z dynamicznie dodawanymi elementami.
Sortowanie przez scalanie (Merge Sort)
Idea (podejście "dziel i zwyciężaj"): 1. Podziel listę na dwie równe połowy. 2. Rekurencyjnie posortuj każdą z nich. 3. Scalaj posortowane połowy w jedną posortowaną listę.
Złożoność: O(n log n) — zawsze, w każdym przypadku. Stabilny algorytm.
def sortowanie_przez_scalanie(lista):
if len(lista) <= 1:
return lista
srodek = len(lista) // 2
lewa = sortowanie_przez_scalanie(lista[:srodek])
prawa = sortowanie_przez_scalanie(lista[srodek:])
return scal(lewa, prawa)
def scal(lewa, prawa):
wynik = []
i = j = 0
while i < len(lewa) and j < len(prawa):
if lewa[i] <= prawa[j]:
wynik.append(lewa[i])
i += 1
else:
wynik.append(prawa[j])
j += 1
wynik.extend(lewa[i:])
wynik.extend(prawa[j:])
return wynik
print(sortowanie_przez_scalanie([5, 3, 8, 1, 9, 2])) # [1, 2, 3, 5, 8, 9]
Sortowanie za pomocą kopca (heapq)
Moduł heapq implementuje kopiec min (ang. min-heap) — strukturę danych, w której najmniejszy element jest zawsze na szczycie:
import heapq
# heapify — zamień listę na kopiec (in-place)
lista = [5, 3, 8, 1, 9, 2]
heapq.heapify(lista)
print(lista) # [1, 3, 2, 5, 9, 8] — struktura kopca
# heappush — wstaw element
heapq.heappush(lista, 4)
# heappop — usuń i zwróć najmniejszy element
print(heapq.heappop(lista)) # 1
print(heapq.heappop(lista)) # 2
# nsmallest / nlargest — n najmniejszych/największych elementów
dane = [5, 3, 8, 1, 9, 2, 7]
print(heapq.nsmallest(3, dane)) # [1, 2, 3]
print(heapq.nlargest(3, dane)) # [9, 8, 7]
Zaawansowane sortowanie z kluczem
from operator import attrgetter, itemgetter
# Sortowanie list słowników
pracownicy = [
{"imie": "Jan", "wiek": 35, "pensja": 6000},
{"imie": "Anna", "wiek": 28, "pensja": 7500},
{"imie": "Piotr", "wiek": 42, "pensja": 5800},
]
# Po jednym kluczu
posortowani = sorted(pracownicy, key=itemgetter("wiek"))
# Po wielu kluczach — krotka
posortowani2 = sorted(pracownicy, key=itemgetter("pensja", "wiek"), reverse=True)
# Sortowanie obiektów po atrybucie
from dataclasses import dataclass
@dataclass
class Student:
imie: str
ocena: float
studenci = [Student("Jan", 4.5), Student("Anna", 5.0), Student("Piotr", 3.5)]
posortowani3 = sorted(studenci, key=attrgetter("ocena"), reverse=True)
for s in posortowani3:
print(f"{s.imie}: {s.ocena}")
# Anna: 5.0
# Jan: 4.5
# Piotr: 3.5
# Sortowanie ze złożoną logiką — sort stabilny (Timsort)
# Najpierw po ocenie malejąco, przy remisie po imieniu rosnąco
posortowani4 = sorted(studenci, key=lambda s: (-s.ocena, s.imie))
Rozbudowane porównanie algorytmów
| Algorytm | Złożoność (średnia) | Złożoność (najgorszy) | Stabilny | Uwagi |
|---|---|---|---|---|
| Bąbelkowe | O(n²) | O(n²) | Tak | Prosty, ale wolny dla dużych danych |
| Przez wybieranie | O(n²) | O(n²) | Nie | Mało zamian; wolny dla dużych danych |
| Szybkie (Quicksort) | O(n log n) | O(n²) | Nie | Bardzo szybkie w praktyce |
| Przez scalanie (Mergesort) | O(n log n) | O(n log n) | Tak | Zawsze O(n log n); dodatkowa pamięć |
Wbudowane (sorted) |
O(n log n) | O(n log n) | Tak | Timsort — hybrydowy, zoptymalizowany |
Zalecenie praktyczne: W codziennym kodzie Python zawsze używaj wbudowanego
sorted()lub.sort(). Własne implementacje algorytmów sortowania są wartościowe edukacyjnie — pomagają zrozumieć złożoność obliczeniową — ale w produkcji wbudowane funkcje są szybsze i dokładniej zoptymalizowane.