Potpuno pretraživanje
Potpuno pretraživanje (complete search, brute force) skup je metoda kojima prolazimo cijelim prostorom rješenja. Na primjer, ako se traži najbolji raspored, mi razmatramo svaki i među njima biramo najbolji.
Ako unaprijed možemo odrediti da neki rasporedi sigurno nisu među najboljima, značajno smanjujemo vrijeme izvođenja - tehnike koje proizlaze iz ove ideje nazivamo tehnikama podrezivanja (pruning).
Problem 1: K-sum (iterativno)
U prvom retku zadan je broj () U drugom retku zadana je lista brojeva (). U trećem retku dan je broj ().
Postoji li podlista liste brojeva , takav da je njegova suma jednaka x? Ispišite DA ili NE.
Napomena: u zadatku se koristi izraz podlista, jer skup i podskup označavaju matematičke objekte u kojima ne postoje duplikati. Često se umjesto tih koriste multiskup i mulitpodskup, ali mi ćemo se zadržati na listama.
Analiza
Ako nam je zadana lista , a je , očito postoji rješenje .
Ako nam je zadana lista , a je , rješenje ne postoji.
Prvo rješenje problema koje nam pada napamet podrazumijeva iteraciju po svim podlistama dane liste. Intuitivnije je na ovaj problem gledati kao da iteriramo po svim podskupovima indeksa , umjesto po samim brojevima.
Na primjer, ako imamo listu veličine i predstavimo ju indeksima (primjeti prelazak na 0-indeksiranje), onda zapravo želimo razmotriti skupove:
Iteracija po podskupovima
Ovih podskupova može se predstaviti i sistemom u kojem simboliziramo kojeg originalnog skupa u podskupu ima, a kojeg ne:
- (nema niti jednog elementa)
- (ima samo onog s indeksom 0, gledamo s desna)
- (ima samo onog s indeksom 1)
- (ima samo onog s indeksom 2)
- (ima onih s indeksima 0 i 1)
- (podskup jednak skupu, svi elementi su tu)
Primjetite da svakom podskupu odgovara jedan broj, predstavljen u binarnom obliku. Ovu činjenicu možemo iskoristiti da bismo jednostavno iterirali po podskupovima.
Ako želimo proći sve podskupove skupa , razmotrit ćemo sve prirodne brojeve od do ; njihove binarne reprezentacije odgovarat će podskupovima skupa .
Za rješenje zadatka potrebno je u svakom koraku zbrojiti brojeve na indeksima podskupa kojeg smo generirali, i usporediti taj zbroj sa . Rješenje je u nastavku.
#include <bits/stdc++.h>
using namespace std;
int main(){
int n,x;
cin >> n;
vector<int> A(n);
for (int i=0;i<n;i++){
cin>>A[i];
}
cin >> x;
//2^n - 1
int p = (1<<n)-1;
//iteracija po brojevima koji predstavljaju podskupove
for(int i=0; i <= p; i++){
int sum = 0;
//iteracija po bitovima brojeva
for (int j=0;j<n;j++){
if ((i & (1<<j)) != 0){
// Broj i ima 1 na j-tom mjestu u binarom obliku.
// Pazi na poredak, != je jači od &:
// https://en.cppreference.com/w/cpp/language/operator_precedence
sum += A[j];
}
}
if (sum == x){
cout << "DA" << endl;
return 0;
}
}
cout << "NE" << endl;
return 0;
}
Primjetimo da nam limiti na brojeve (najviše brojeva, brojevi veličine do ), omogućuju da koristimo (suma je najviše , stane u ).
Kad bismo imali više brojeva, ili bi oni bili većih vrijednosti, ovo rješenje ne bi prošlo. Morali bismo koristiti tip . Ovo je izvor nebrojenih problema za početnike u natjecateljskom programiranju; budite iznimno pozorni kad analizirate ograničenja!
Analiza složenosti
Dodatni prostor koji zauzimamo (osim čitanja ulaznih podataka) konstantan je, stoga je memorijska složenost jednaka .
Vremenska složenost jednaka je ukupnom broju iteracija petlji (jer su ostale operacije u petljama konstantne složenosti). Stoga je vremenska složenost jednaka .
Budući da je manji ili jednak , vremenska složenost koju smo dobili ovim pristupom bit će dovoljno niska da rješenje prođe na većini evaluatora ispod 1 sekunde.
Problem 1: K-sum (rekurzivno)
Iako je iterativni pristup dovoljno jednostavan i efikasan, pokazat ćemo alternativni, rekurizvni način rješavanja zadatka.
Ideja je izvrtiti rekurzivnu funkciju koja je parametrizirana sljedećim vrijednostima:
- trenutna suma
- element s najmanjim indeksom koji može biti uzet
Svaki podskup moguće je poredati,
#include <bits/stdc++.h>
using namespace std;
/*
* A, n, x -> umjesto globalnih varijabli (primjeti & kod A)
* last, sum -> pravi parametri rekurzije, promjenjivi
* last -> označava najveći indeks elementa u skupu kojeg predstavlja rekurzivni poziv
* sum -> označava sumu svih elemenata skupa kojeg predstavlja rekurzivni poziv
*/
bool f(vector<int>& A, int n, int x, int last, int sum){
// uspjeli smo napraviti x
if (sum == x){
return true;
}
// kreni s ostalim elementima od kojih se mogu izgraditi novi podskupovi
for (int i=last+1;i<n;i++){
if (f(A,n,x,i,sum+A[i])){
return true;
}
}
return false;
}
int main(){
int n,x;
cin >> n;
vector<int> A(n);
for (int i=0;i<n;i++){
cin>>A[i];
}
cin >> x;
// -1 se dobro uklapa,
// jer će prvi rekurzivni poziv krenuti razmatrati sve elemente (-1+1 = 0)
if (f(A,n,x,-1,0)){
cout << "DA" << endl;
}
else{
cout << "NE" << endl;
}
return 0;
}
Analiza složenosti
Analiza dodatnog prostora koji zauzimamo suptilnija je nego u iterativno slučaju. Naime, prostor zauzimamo na stogu prilikom pozivanja rekurzivnih funkcija. Najveća dubina rekurzivnog grananja određuje ukupnu memorijsku složenost, budući da je memorijska složenost svakog rekurzivnog poziva konstantna (mogli bismo reći da je stack frame konstantne veličine, a sve je alocirano na stogu). Dakle, memorijska složenost jednaka je , jer je to najveći broj rekurzivnih poziva u nekom trenutku (odgovara razvoju podskupa koji je jednak cijelom skupu).
Vremenska složenost jednaka je ukupnom broju rekurzivnih poziva (jer su ostale operacije u pozivima konstantne složenosti). Stoga je vremenska složenost jednaka (pozivi odgovaraju podskupovima, kojih je upravo toliko, nemamo duplikate).
Primjetimo da smo vrijeme uštedjeli na zbrajanju, jer smo u ovom pristupu pametno obilazili podskupove (svaki put bismo dodali samo jedan element). Tako smo jednostavno mogli računati novu sumu, koristeći onu prethodnu, umjesto da svaki puta krećemo ispočetka.