Programovanie (1) v C/C++
1-INF-127, ZS 2024/25
Prednáška 11: Rozdiel medzi revíziami
Riadok 573: | Riadok 573: | ||
* Dá sa použiť aj na reálne problémy, ale pozor, čas výpočtu prudko (exponenciálne) rastie s dĺžkou postupností, takže vhodné len pre malé vstupy. | * Dá sa použiť aj na reálne problémy, ale pozor, čas výpočtu prudko (exponenciálne) rastie s dĺžkou postupností, takže vhodné len pre malé vstupy. | ||
* Dnes ešte jeden problém riešený prehľadávaním s návratom, kde medzi možnými riešeniami hľadáme najlepšie. | * Dnes ešte jeden problém riešený prehľadávaním s návratom, kde medzi možnými riešeniami hľadáme najlepšie. | ||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− |
Verzia zo dňa a času 17:39, 24. október 2021
Obsah
Oznamy
- DÚ2 je zverejnená, odovzdávajte do piatka 13.11. 22:00
- Nezabudnite, že ak z cvičení za týždeň neodovzdáte úspešne ani jeden príklad, dostanete -5 bodov. Takisto je vhodné nazbierať z DÚ a cvičení aspoň polovicu bodov, lebo inak budete mať problém získať celkovo dosť bodov z predmetu.
- Na DÚ pracujte samostatne. Ak nájdeme prípad opisovania, všetky zúčastnené strany dostanú z úlohy 0 bodov.
- Plán na dnes: najskôr si ukážeme dva rekurzívne algoritmy na triedenie, potom sa vrátime k minulej téme prehľadávania s návratom.
- Od budúceho týždňa prednáša kolega Kostolányi, začínate novú tému práca so smerníkmi a pamäťou. Rekurzia sa ale ešte párkrát počas semestra vráti.
- Poučenie zo včerajšej rozcvičky: pozor na prístup mimo hraníc poľa. Pýtať sa na a[i-1] pre i=0 nie je dobrý nápad.
Rýchle triedenia prostredníctvom paradigmy „rozdeľuj a panuj”
Doposiaľ sme prebrali tri triediace algoritmy: Bubble Sort, Insertion Sort a Max Sort. Všetky sú jednoduché, ale pomalé: majú kvadratickú zložitosť O(n2).
Dnes pridáme ďalšie dve triedenia, ktoré budú pre veľké polia omnoho rýchlejšie: Merge Sort a Quick Sort. Obe sú založené na paradigme rozdeľuj a panuj (angl. divide and conquer, lat. divide et impera).
Rozdeľuj a panuj je paradigma rekurzívneho riešenia problémov pracujúca v troch fázach:
- Rozdeľuj – problém rozdelíme na menšie časti (t.j. podproblémy), ktoré sa dajú riešiť samostatne.
- Vyrieš podproblémy – rekurzívne vyriešime úlohu pre každý podproblém.
- Panuj – riešenia podproblémov spojíme do riešenia pôvodného problému.
Triedenie zlučovaním (Merge Sort)
Triedenie zlučovaním (angl. Merge Sort) pracuje nasledovne:
- Pole rozdelíme na dve približne rovnaké časti
- Každú časť zvlášť rekurzívne utriedime
- Vo fáze „panuj” sa tieto dve utriedené postupnosti zlúčia (angl. merge) do výsledného utriedeného poľa.
Triedenie zlučovaním tak bude vyzerať nasledovne (pričom zostáva doimplementovať funkciu merge):
void mergesort(int a[], int left, int right) {
/* Funkcia utriedi prvky pola a[] od indexu left po index right (vratane). */
/* Trivialne pripady: */
if (left >= right) {
return;
}
/* Rozdeluj -- spocitaj priblizny stred triedeneho useku: */
int middle = (left + right) / 2;
/* Rekurzivne vyries podproblemy: */
mergesort(a, left, middle);
mergesort(a, middle + 1, right);
/* Panuj -- zluc obe utriedene casti do jednej: */
merge(a, left, middle, right);
}
Zlúčenie dvoch utriedených podpostupností
Zostáva doprogramovať zlúčenie dvoch utriedených podpostupností a[left..middle], a[middle+1..right] do jednej utriedenej postupnosti. Zlúčenú postupnosť budeme postupne ukladať do pomocného poľa aux, pričom postupovať budeme nasledovne:
- Prvým prvkom poľa aux bude menší z prvkov a[left] a a[middle+1].
- Ostanú nám postupnosti a[left+1..middle] a a[middle+1..right] alebo a[left..middle] a a[middle+2..right].
- Vo všeobecnosti máme postupnosti a[i..middle] a a[j..right].
- Ďalším prvkom poľa aux bude menčí z prvkov a[i] a a[j]
- Ostanú nám postupnosti a[i+1..middle] a a[j..right] alebo a[i..middle] a a[j+1..right].
- Toto robíme dovtedy, kým niektorú z postupností nevyčerpáme celú. Potom už len na koniec poľa aux dokopírujeme zvyšok druhej postupnosti.
Po spojení oboch postupností do utriedeného poľa aux toto pole prekopírujeme naspäť do poľa a.
void merge(int a[], int left, int middle, int right) {
int aux[maxN];
int i = left; // index v prvej postupnosti
int j = middle + 1; // index v druhej postupnosti
int k = 0; // index v poli aux
while (i <= middle && j <= right) {
// Kym su obe postupnosti a[i..middle], a[j..right] neprazdne,
// mensi z prvkov a[i], a[j] uloz do aux[k] a posun indexy
if (a[i] <= a[j]) {
aux[k] = a[i];
i++;
k++;
} else {
aux[k] = a[j];
j++;
k++;
}
}
while (i <= middle) {
// Ak nieco ostalo v prvej postupnosti, dokopiruj ju na koniec
aux[k] = a[i];
i++;
k++;
}
while (j <= right) {
// Ak nieco ostalo v druhej postupnosti, dokopiruj ju na koniec
aux[k] = a[j];
j++;
k++;
}
for (int t = left; t <= right; t++) {
// Prekopiruj pole aux naspat do pola a
a[t] = aux[t - left];
}
}
Výsledný program
#include <iostream>
using namespace std;
const int maxN = 1000;
void merge(int a[], int left, int middle, int right) {
int aux[maxN];
int i = left;
int j = middle + 1;
int k = 0;
while (i <= middle && j <= right) {
if (a[i] <= a[j]) {
aux[k] = a[i];
i++;
k++;
} else {
aux[k] = a[j];
j++;
k++;
}
}
while (i <= middle) {
aux[k] = a[i];
i++;
k++;
}
while (j <= right) {
aux[k] = a[j];
j++;
k++;
}
for (int t = left; t <= right; t++) {
a[t] = aux[t - left];
}
}
void mergesort(int a[], int left, int right) {
if (left >= right) {
return;
}
int middle = (left + right) / 2;
mergesort(a, left, middle);
mergesort(a, middle+1, right);
merge(a, left, middle, right);
}
int main() {
int N;
int a[maxN];
cout << "Zadaj pocet cisel: ";
cin >> N;
cout << "Zadaj " << N << " cisel: ";
for (int i = 0; i < N; i++) {
cin >> a[i];
}
mergesort(a,0,N-1);
cout << "Utriedene cisla:";
for (int i = 0; i < N; i++) {
cout << " " << a[i];
}
cout << endl;
}
Ukážka na príklade
Uvažujme pole a = {6, 1, 5, 7, 2, 4, 8, 9, 3, 0}.
Volanie mergesort(a,0,9) potom utriedi pole a pomocou nasledujúcich rekurzívnych volaní (mergesort(a,l,h) je na obrázku skrátené na sort(l,h) a merge(a,l,m,h) na merge(l,m,h), žltou sú vyznačené pozície v poli, ktoré sa v danom volaní triedia):
Pole a sa počas týchto volaní mení nasledovne:
merge(a,0,0,1): |6|1|5 7 2 4 8 9 3 0 -> |1 6|5 7 2 4 8 9 3 0 merge(a,0,1,2): |1 6|5|7 2 4 8 9 3 0 -> |1 5 6|7 2 4 8 9 3 0 merge(a,3,3,4): 1 5 6|7|2|4 8 9 3 0 -> 1 5 6|2 7|4 8 9 3 0 merge(a,0,2,4): |1 5 6|2 7|4 8 9 3 0 -> |1 2 5 6 7|4 8 9 3 0 merge(a,5,5,6): 1 2 5 6 7|4|8|9 3 0 -> 1 2 5 6 7|4 8|9 3 0 merge(a,5,6,7): 1 2 5 6 7|4 8|9|3 0 -> 1 2 5 6 7|4 8 9|3 0 merge(a,8,8,9): 1 2 5 6 7 4 8 9|3|0| -> 1 2 5 6 7 4 8 9|0 3| merge(a,5,7,9): 1 2 5 6 7|4 8 9|0 3| -> 1 2 5 6 7|0 3 4 8 9| merge(a,0,4,9): |1 2 5 6 7|0 3 4 8 9| -> |0 1 2 3 4 5 6 7 8 9|
Odhad zložitosti
Zlučovanie dvoch postupností, ktoré spolu obsahujú N prvkov, trvá čas O(N). Prečo?
V algoritme máme log2 N úrovní rekurzie:
- na prvej spracovávame úseky dĺžky N,
- na druhej N/2, na tretej N/4 atď,
- po log2 N úrovniach dostaneme úseky dĺžky 1.
Na každej úrovni je každý prvok najviac v jednom zlučovaní, teda celkový čas zlučovaní na každej úrovni je O(N). Celkový čas výpočtu je O(N log N).
Quick Sort
Quick Sort je tiež založený na metóde rozdeľuj a panuj. Postupuje ale nasledovne:
- V rámci fázy rozdeľuj vyberie niektorý prvok poľa (napríklad jeho prvý prvok), ktorý nazve pivotom. Prvky poľa následne preusporiada na tri skupiny: v ľavej časti poľa budú prvky menšie ako pivot, za nimi pivot samotný a napokon v pravej časti prvky väčšie alebo rovné ako pivot.
- Rekurzívne utriedi prvú a tretiu skupinu (druhá skupina má iba jeden prvok)
- Vo fáze panuj už potom nemusí robiť nič – po utriedení spomínaných dvoch skupín totiž vznikne utriedené pole.
Základ triedenia tak bude vyzerať nasledovne (pričom zostáva implementovať funkciu partition):
void quicksort(int a[], int left, int right) {
/* Utriedi cast pola a[] od indexu left po index right (vratane) */
/* Trivialne pripady: */
if (left >= right) {
return;
}
/* Rozdel pole na tri podpostupnosti: */
int middle = partition(a, left, right);
// Po vykonani funkcie:
// a[left..middle-1] su mensie ako pivot
// a[middle] je pivot
// a[middle+1..right] su vacsie ako pivot
/* Rekurzivne utried a[left..middle-1], a[middle+1..right]: */
quicksort(a, left, middle-1);
quicksort(a, middle+1, right);
}
Funkcia partition
Funkcia partition na vstupe dostane pole a spolu s hraničnými indexami left a right. Prvok a[left] vyberie ako pivot a postupnosť a[left..right] preusporiada tak, aby pre nejakú hodnotu middle takú, že left <= middle <= right platilo nasledovné:
- Prvky a[left],...,a[middle-1] sú menšie, než pivot.
- Prvok a[middle] je pivot.
- Prvky a[middle+1],...,a[right] sú väčšie alebo rovné ako pivot.
Hodnotu middle potom funkcia partition vráti ako svoj výstup.
Funkcia partition udržiava nasledujúce invarianty:
- Prvok a[left] je pivot.
- Prvky a[left+1],...,a[lastSmaller] sú menšie ako pivot.
- Prvky a[lastSmaller+1],...,a[unknown-1] sú väčšie alebo rovné ako pivot.
- Prvky a[unknown],...,a[right] sa ešte s pivotom neporovnávali.
Funkcia partition zakaždým porovnáva prvok a[unknown] s pivotom:
- Ak je menší ako pivot, je nutné „presunúť ho doľava”; vymení ho teda s a[lastSmaller+1] a hodnotu lastSmaller zvýši o jedna.
- Ak je väčší alebo rovný ako pivot, môže ostať na svojom mieste.
Následne zvýši index unknown o jedna a tento postup opakuje, až kým prejde cez všetky prvky danej časti poľa.
Nakoniec je ešte nutné vymeniť a[left] s a[lastSmaller], čím sa pivot dostane na svoje miesto.
void swap (int &x, int &y) {
int tmp = x;
x = y;
y = tmp;
}
int partition(int a[], int left, int right) {
// Ak za pivot chceme zvolit iny prvok, vymenime ho najprv s a[left]
int pivot = a[left];
int lastSmaller = left;
for (int unknown = left + 1; unknown <= right; unknown++) {
if (a[unknown] < pivot) {
lastSmaller++;
swap(a[unknown], a[lastSmaller]);
}
}
swap(a[left],a[lastSmaller]);
return lastSmaller;
}
Výsledný program
#include <iostream>
using namespace std;
const int maxN = 1000;
void swap (int &x, int &y) {
int tmp = x;
x = y;
y = tmp;
}
int partition(int a[], int left, int right) {
int pivot = a[left];
int lastSmaller = left;
for (int unknown = left + 1; unknown <= right; unknown++) {
if (a[unknown] < pivot) {
lastSmaller++;
swap(a[unknown], a[lastSmaller]);
}
}
swap(a[left],a[lastSmaller]);
return lastSmaller;
}
void quicksort(int a[], int left, int right) {
if (left >= right) {
return;
}
int middle = partition(a, left, right);
quicksort(a, left, middle-1);
quicksort(a, middle+1, right);
}
int main() {
int N;
int a[maxN];
cout << "Zadaj pocet cisel: ";
cin >> N;
cout << "Zadaj " << N << " cisel: ";
for (int i = 0; i <= N-1; i++) {
cin >> a[i];
}
quicksort(a,0,N-1);
cout << "Utriedene cisla:";
for (int i = 0; i <= N-1; i++) {
cout << " " << a[i];
}
cout << endl;
}
Ukážka na príklade
Opäť uvažujme pole a = {6, 1, 5, 7, 2, 4, 8, 9, 3, 0}.
Volanie quicksort(0,9) potom utriedi pole a pomocou nasledujúcich rekurzívnych volaní (namiesto quicksort(a,l,h) píšeme zakaždým len sort(l,h)):
sort(0,9) sort(0,5) sort(0,-1) . sort(1,5) sort(1,0) . . sort(2,5) sort(2,4) sort(2,2) . . . . sort(4,4) . . . sort(6,5) sort(7,9) sort(7,8) sort(7,7) . sort(9,8) sort(10,9)
Volania funkcie partition sú počas tohto behu nasledovné:
partition(a,0,9): |6 1 5 7 2 4 8 9 3 0| -> |0 1 5 2 4 3|6|9 7 8| partition(a,0,5): |0 1 5 2 4 3|6 9 7 8 -> |0|1 5 2 4 3|6 9 7 8 partition(a,1,5): 0|1 5 2 4 3|6 9 7 8 -> 0|1|5 2 4 3|6 9 7 8 partition(a,2,5): 0 1|5 2 4 3|6 9 7 8 -> 0 1|3 2 4|5|6 9 7 8 partition(a,2,4): 0 1|3 2 4|5 6 9 7 8 -> 0 1|2|3|4|5 6 9 7 8 partition(a,7,9): 0 1 2 3 4 5 6|9 7 8| -> 0 1 2 3 4 5 6|8 7|9| partition(a,7,8): 0 1 2 3 4 5 6|8 7|9 -> 0 1 2 3 4 5 6|7|8|9
Cvičenie:
- Ako sa bude Quick Sort správať, keď na vstupe dostane už utriedené pole?
- Ako sa bude správať, keď na vstupe dostane zostupne utriedené pole?
Odhad zložitosti
V ideálnom prípade pivot rozdelí pole na dve rovnako veľké časti. Vtedy je čas výpočtu O(N log N), podobne ako pre Mergesort.
Nepríjemné je, keď pivot je vždy najmenší alebo najväčší prvok v danom úseku. Vtedy je čas O(N2).
- Aby sme sa vyhli problémom, v praxi sa ako pivot často vyberá náhodný prvok z intervalu.
Iná implementácia
Občas sa možno stretnúť aj s nasledujúcou implementáciou triedenia Quick Sort. Skúste samostatne odôvodniť jej správnosť.
void quicksort(int a[], int left, int right) {
if (left >= right) {
return;
}
/* partition */
int pivot = a[(left + right)/2];
int i = left;
int j = right;
while (i <= j) {
while (a[i] < pivot) i++;
while (a[j] > pivot) j--;
if (i <= j) {
swap(a[i],a[j]);
i++; j--;
}
}
/* rekurzia */
quicksort(a, left, j);
quicksort(a, i, right);
}
Triediace algoritmy: zhrnutie
Jednoduché triedenia: Bubble Sort, Insertion Sort, Max Sort.
- Jednoduché, ale pomalé: zložitosť O(n2).
Rekurzívne triedenia založené na technike rozdeľuj a panuj.
- Rýchlejšie, zložitejšie.
- Merge Sort: zložitosť O(n log n).
- Quick Sort: zložitosť O(n2) v najhoršom prípade, pre väčšinu vstupov O(n log n), väčšinou rýchlejší ako Merge Sort.
Reálnu rýchlosť triedení na náhodne zvolenom veľkom vstupe možno porovnať napríklad nasledujúcim programom:
#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;
const int maxN = 100000;
void insertionsort(int a[], int n) {
for (int i = 1; i < n; i++) {
int prvok = a[i];
int kam = i;
while (kam > 0 && a[kam - 1] > prvok) {
a[kam] = a[kam - 1];
kam--;
}
a[kam] = prvok;
}
}
void merge(int a[], int left, int mid, int right) {
int aux[maxN];
int i = left;
int j = mid + 1;
int k = 0;
while ((i <= mid) && (j <= right)) {
if (a[i] <= a[j]) {
aux[k] = a[i];
i++;
k++;
} else {
aux[k] = a[j];
j++;
k++;
}
}
while (i <= mid) {
aux[k] = a[i];
i++;
k++;
}
while (j <= right) {
aux[k] = a[j];
j++;
k++;
}
for (int k = left; k <= right; k++) {
a[k] = aux[k - left];
}
}
void mergesort(int a[], int left, int right) {
if (left >= right) {
return;
}
int mid = (left + right) / 2;
mergesort(a, left, mid);
mergesort(a, mid + 1, right);
merge(a, left, mid, right);
}
void swap(int &x, int &y) {
int tmp = x;
x = y;
y = tmp;
}
int partition(int a[], int left, int right) {
int pivot = a[left];
int lastSmaller = left;
for (int unknown = left + 1; unknown <= right; unknown++) {
if (a[unknown] < pivot) {
lastSmaller++;
swap(a[unknown], a[lastSmaller]);
}
}
swap(a[left], a[lastSmaller]);
return lastSmaller;
}
void quicksort(int a[], int left, int right) {
if (left >= right) {
return;
}
int mid = partition(a, left, right);
quicksort(a, left, mid - 1);
quicksort(a, mid + 1, right);
}
int main() {
int N;
int a1[maxN];
int a2[maxN];
int a3[maxN];
cout << "Zadaj pocet nahodnych cisel v poli: ";
cin >> N;
srand(time(NULL));
for (int i = 0; i <= N - 1; i++) {
a1[i] = rand() % 1000;
a3[i] = a2[i] = a1[i];
}
clock_t start1, end1, start2, end2, start3, end3;
start1 = clock();
insertionsort(a1, N);
end1 = clock();
start2 = clock();
mergesort(a2, 0, N - 1);
end2 = clock();
start3 = clock();
quicksort(a3, 0, N - 1);
end3 = clock();
cout << "Insertion sort: "
<< (end1 - start1) * 1.0 / CLOCKS_PER_SEC << " CPU sekund" << endl;
cout << "Merge sort: "
<< (end2 - start2) * 1.0 / CLOCKS_PER_SEC << " CPU sekund" << endl;
cout << "Quick sort: "
<< (end3 - start3) * 1.0 / CLOCKS_PER_SEC << " CPU sekund" << endl;
}
Pre N = 100000 môžeme dostať napríklad nasledujúci výstup (líši sa od počítača k počítaču a od volania k volaniu):
Insertion sort: 7.474 CPU sekund Merge sort: 0.076 CPU sekund Quick sort: 0.032 CPU sekund
Prehľadávanie s návratom (backtracking)
- Videli sme ako rekurzívne generovať všetky postupnosti spĺňajúce určité požiadavky
- Ak máme špeciálne požiadavky, napr. že žiadne číslo sa neopakuje, môžeme buď generovať všetky k-tice a testovať to pred výpisom, alebo už počas generovania urezávať neperspektívne vetvy výpočtu, čo je rýchlejšie
- Táto technika sa dá použiť na riešenie rôznych hlavolamov, videli sme problém 8 dám.
- Dá sa použiť aj na reálne problémy, ale pozor, čas výpočtu prudko (exponenciálne) rastie s dĺžkou postupností, takže vhodné len pre malé vstupy.
- Dnes ešte jeden problém riešený prehľadávaním s návratom, kde medzi možnými riešeniami hľadáme najlepšie.