9.A Dodatak: pohlepni algoritmi

Zadatak: Raspored aktivnosti

U jednom kabinetu se subotom održava obuka programiranja. Svaki nastavnik je napisao termin u kom želi da drži nastavu (poznat je sat i minut početka i sat i minut završetka časa). Odredi kako je moguće napraviti raspored časova tako da što više nastavnika bude uključeno.

Opis ulaza

Sa standardnog ulaza se učitava prvo broj \(n\) (ukupan broj nastavnika, \(1 \leq n \leq 50000\)), a zatim u \(n\) narednih redova po četiri broja razdvojena razmacima koji predstavljaju sat i minut početka tj. završetka časa (pretpostaviti da je završetak uvek iza početka).

Opis izlaza

Na standardni izlaz ispisati najveći broj nastavnika koji mogu da održe svoje časove.

Primer
Ulaz
7 8 15 9 20 10 45 11 30 11 20 12 45 9 30 12 40 10 20 11 20 12 00 13 00 11 30 13 30
Izlaz
3
Objašnjenje

Mogu se održati, na primer, časovi od 8:15 do 9:20, zatim čas od 10:20 do 11:20 i na kraju od 11:30 do 13:30.

Rešenje

Svaki čas možemo predstaviti parom brojeva koji predstavljaju broj minuta od prethodne ponoći do početka i do kraja časa (već prilikom učitavanja sate i minute možemo prevesti samo u minute).

Iscrpna pretraga

Naivno rešenje se zasniva na ispitivanju svih mogućih podskupova skupa časova koji su takvi da se svi časovi mogu održati (nikoja dva časa iz tog skupa se ne seku). Presek dva intervala postoji ako i samo ako je kasniji početak časa posle ranijeg kraja časa. Generisanje svih podskupova možemo vršiti rekurzivno.

S obzirom na veliki broj podskupova koje treba ispitati ovo rešenje je veoma neefikasno (složenost mu je eksponencijalna \(O(2^n)\), gde je \(n\) ukupan broj časova).

Gramzivi pristup

Efikasno rešenje problema se može dobiti gramzivim pristupom. Postoji nekoliko gramzivih strategija koje je logično razmotriti, međutim, neke od njih neće garantovati optimalnost pronađenog rešenja.

Jedan pristup može biti onaj u kome prvo raspoređujemo čas koji prvi počinje. Na slici je prikazan kontra-primer, koji pokazuje da se već sa tri časa na taj način može dobiti raspored koji nije optimalan.

Jedan pristup može biti onaj u kome težimo da rasporedimo časove koji kratko traju, sa idejom da na taj način ostavljamo više prostora da se u slobodnim terminima održe drugi časovi. Na slici je prikazan kontra-primer koji pokazuje da se već sa tri časa na taj način može dobiti raspored koji nije optimalan.

Primer na kome strategija koja raspoređuje čas koji prvi počinje i primer na kome strategija koja raspoređuje čas koji je najkraći daje neoptimalno rešenje

Jedna gramziva strategija koja daje optimalno rešenje je sledeća. Od svih neraspoređenih časova biramo onaj koji se najranije završava i koji se može održati (ne seče se sa do sada održanim časovima, tj. počinje nakon završetka prethodnog časa). Intuitivno, takvim izborom ostavljamo što veću mogućnost za raspoređivanje naknadnih časova. Ovim dobijamo jednu rekurzivnu konstrukciju.

Dokažimo da je ova rekurzivna formulacija korektna, tako što ćemo da dokažemo da uvek postoji ispravan, optimalan raspored (raspored sa najviše časova) u kom učestvuje čas \(c_0\) koji se prvi završava. Pretpostavimo da je \(O\) neki ispravan, optimalan raspored. Ako u njemu učestvuje čas \(c_0\), onda je \(O\) je taj traženi raspored. Ako ne učestvuje, onda pretpostavimo da je \(o_0\) čas u \(O\) koji se prvi završava. Svi drugi časovi u \(O\) počinju nakon završetka časa \(o_0\). Zaista, pošto je \(O\) ispravan, nijedan čas u \(o_i\) se ne seče sa \(o_0\), ako bi neki počinjao pre \(o_0\), tada bi morao i da se završi pre \(o_0\), što je nemoguće, jer se od svih časova u \(O\) čas \(o_0\) prvi završava. Čas \(c_0\) se ne završava kasnije nego čas \(o_0\), jer je on čas koji se prvi završava od svih časova. Dakle, čas \(c_0\) se ne seče ni sa jednim časom u \(O\) (osim eventualno sa \(o_0\)). Kada zamenimo \(c_0\) i \(o_0\), dobijamo ispravan, optimalan raspored (broj časova se nije promenio) koji sadrži \(c_0\).

Dakle, jasno je da će nas izbor časa \(c_0\) voditi do nekog optimalnog rešenja. Takođe je jasno da u tom rešenju ne može da učestvuje nijedan čas koji se seče sa \(c_0\). Ostatak časova biramo rekurzivno, pa korektnost algoritma sledi na osnovu induktivnog argumenta (pretpostavljamo da rekurzivni poziv korektno pronalazi optimalni raspored u preostalom skupu časova).

Tehnika koja je upotrebljena u prethodnom dokazu naziva se tehnika zamene tj. razmene, jer se od nekog proizvoljnog optimalnog rasporeda zamenom dobio raspored koji naša gramziva strategija bira.

Algoritam se može formulisati i iterativno. Časove možemo sortirati neopadajuće na osnovu vremena njihovog završetka i obilaziti ih u tom redosledu. Prvi čas sigurno biramo da bude održan. Redom prolazimo kroz naredne časove i ako tekući čas počinje nakon poslednjeg odabranog časa, biramo ga, a u suprotnom ga preskačemo.

Primer 9.A.1. Na slici su prikazani časovi sortirani po vremenu završetka. Gramzivom strategijom se prvo održava čas 1, zatim se čas 2 preskače (jer se preklapa sa 1), pa se zatim čas 3 održava (jer se ne preklapa sa 1, jer je desno od njega), pa se čas 4 preskače (jer se preklapa sa 3), pa se čas 5 održava (jer se ne preklapa sa 3, pa samim tim ni sa 1, jer je desno od njih), pa se časovi 6, 7 i 8 preskaču (jer se preklapaju sa časom 5), pa se čas 9 održava (jer se ne preklapa sa 5, pa samim tim ni sa 3 ni sa 1, jer je desno od njih) i na kraju se preskaču časovi 10 i 11 (jer se preklapaju sa časom 9). Maksimalan broj časova koji se mogu održati bez preklapanja je 4, međutim, časovi 1, 3, 5 i 9 nisu jedino rešenje. Moguće je, na primer, održati časove 1, 3, 6 i 11.

Rezultat primene gramzive strategije

Ispišimo i dokaz korektnosti iterativne varijante algoritma.

Formalno, pretpostavimo da je \(O=[o_1, o_2, \ldots, o_k]\), niz časova koji predstavlja neko ispravno optimalno rešenje i dokažimo da on sadrži isti broj časova kao i raspored koji bi odabrala naša strategija. Pretpostavimo da su časovi \(o_1\) do \(o_k\) sortirani neopadajuće po redosledu njihovog završetka. Pošto se svi ti časovi mogu održati, između njih nema preklapanja i svaki naredni počinje nakon završetka prethodnog.

Neka je \(S=[s_1, s_2, \ldots, s_{k'}]\) niz časova koji bi bio odabran našom gramzivom strategijom. Pošto je \(O\) optimalan, \(S\) ne može da sadrži više časova od njega važi da je \(k \leq k'\).

Dokažimo prvo da postoji optimalan raspored \(O\) takav da za svako \(1 \leq i \leq k'\) važi da je \(o_i = s_i\). Taj raspored možemo dobiti postupnim izmenama početnog rasporeda \(O\). Pretpostavimo da postoji neko \(1 \leq i \leq k'\) tako da je \(o_i \neq s_i\) (u suprotnom je polazni raspored taj traženi). Neka je \(i\) prvi takav indeks tj. neka za svako \(1 \leq j < i\) važi da je \(o_j = s_j\). Pokažimo da se zamenom časa \(o_i\) časom \(s_i\) u nizu \(O\) dobija takođe raspored koji je ispravan (on je svakako optimalan jer se broj časova ne menja). Pokažimo prvo da se \(s_i\) ne završava kasnije nego \(o_i\).

Ako postoje časovi u \(O\) pre časa \(o_i\), oni ostaju nepromenjeni i čas \(s_i\) se ne preklapa sa njima (jer ga je strategija bira tako da počinje nakon završetka časa \(s_{i-1} = o_{i-1}\)). Pošto se \(s_i\) ne završava kasnije nego \(o_i\) on se sigurno ne preklapa ni sa jednim časom iz \(O\) koji ide posle \(o_i\) (jer svi oni počinju i završavaju se nakon kraja časa \(o_i\)). Dakle, \(s_i\) se ne preklapa ni sa jednim časom iz \(O\) (osim eventualno sa \(o_i\), koji je u sklopu razmene uklonjen) i raspored dobijen zamenom je ispravan.

Nastavkom ovog procesa zamena stići ćemo do željenog optimalnog rasporeda \(O\) takvog da za svako \(1 \leq i \leq k'\) važi \(o_i = s_i\).

Dokažimo sada da nije moguće da važi da je \(k' < k\). Ako bi važilo da je \(k' < k\), tada bi važilo da čas \(o_{k'+1}\) pripada \(O\) a ne pripada \(S\). Pošto je \(O\) ispravan, to bi bio čas koji bi počinjao posle završetka časa \(o_{k'} = s_{k'}\). Međutim, nije moguće da takav čas postoji, jer bi on počinjao i završavao se posle časa \(s_k'\), što znači da bi naša gramziva strategija morala da ga odabere, što je u kontradikciji sa tim da se on ne nalazi u \(S\). Dakle, važi da je \(k' \geq k\).

Pošto je \(k' \geq k\) i \(k' \leq k\), važi da je \(k'=k\), pa naša gramziva strategija bira isti broj časova koji je u nekom (pa i svakom) optimalnom rasporedu. Dakle, strategijom se dobija jedan ispravan, optimalan raspored.

Primer 9.A.2. Ilustrujmo tehniku razmene na kojoj leži prethodni dokaz, tako što ćemo objasniti kako da se od rasporeda \(1, 3, 6, 11\) koji je optimalan, ali nije u skladu sa našom strategijom dobije raspored \(1, 3, 5, 9\) koji jeste u skladu sa našom strategijom.

Dakle, proizvoljni optimalni raspored smo razmenama transformisali u raspored koji preporučuje naša strategija, što znači da je broj časova koje naša strategija rasporedi za održavanje optimalan.

Recimo da postoji i dualno rešenje u kojem se bira onaj čas koji poslednji počinje i časovi se obilaze unazad, po nerastućem redosledu njihovog početka.

Primer 9.A.3. Na slici su prikazani časovi sortirani po redosledu početka. Prvo se održava čas 11, koji poslednji počinje, zatim se preskače čas 9 koji se sa njim preklapa, zatim se održava čas 6, nakon čega se preksaču časovi 10, 5 i 7 koji se sa njim preklapaju, zatim se održava čas 3, preskaču se časovi 8, 2 i 4 koji se sa njim preklapaju i na kraju se održava čas 1.

Rezultat primene dualne gramzive strategije

// casove predstavljamo uredjenim parovima (pocetak, kraj), u
// minutima
typedef pair<int, int> cas;

inline int pocetakCasa(const cas& c) {
  return c.first;
}

inline int krajCasa(const cas& c) {
  return c.second;
}

cas napraviCas(int pocSat, int pocMin,
               int krajSat, int krajMin) {
  return make_pair(pocSat*60 + pocMin, krajSat*60 + krajMin);
}

// ucitava se niz casova
vector<cas> ucitajCasove() {
  int n;
  cin >> n;
  vector<cas> casovi(n);
  for (int i = 0; i < n; i++) {
    int pocSat, pocMin, krajSat, krajMin;
    cin >> pocSat >> pocMin >> krajSat >> krajMin;
    casovi[i] = napraviCas(pocSat, pocMin, krajSat, krajMin);
  }
  return casovi;
}

// maksimalni broj casova koji se mogu odrzati tako da nema
// preklapanja medju rasporedjenim casovima
int maksBrojCasova(vector<cas>& casovi) {
  // broj casova
  int n = casovi.size();

  // sortiramo casove na osnovu vremena zavrsetka
  sort(begin(casovi), end(casovi),
       [](const cas& a, const cas& b) {
         return krajCasa(a) < krajCasa(b);
       });

  // broj odrzanih casova
  int brojOdrzanihCasova;
  // kraj poslednjeg odrzanog casa
  int kraj;
  
  // rasporedjujemo prvi cas 
  brojOdrzanihCasova = 1;
  kraj = krajCasa(casovi[0]);
  
  // analiziramo ostale casove u redosledu zavrsetka
  for (int i = 1; i < n; i++)
    // ako se tekuci cas ne preklapa sa poslednjim
    // rasporedjenim
    if (pocetakCasa(casovi[i]) >= kraj) {
      // on se odrzava
      brojOdrzanihCasova++;
      kraj = krajCasa(casovi[i]);
    }

  return brojOdrzanihCasova;
}
#include <iostream>
#include <vector>
#include <algorithm>
#include <utility>

using namespace std;

// casove predstavljamo uredjenim parovima (pocetak, kraj), u
// minutima
typedef pair<int, int> cas;

inline int pocetakCasa(const cas& c) {
  return c.first;
}

inline int krajCasa(const cas& c) {
  return c.second;
}

cas napraviCas(int pocSat, int pocMin,
               int krajSat, int krajMin) {
  return make_pair(pocSat*60 + pocMin, krajSat*60 + krajMin);
}

// ucitava se niz casova
vector<cas> ucitajCasove() {
  int n;
  cin >> n;
  vector<cas> casovi(n);
  for (int i = 0; i < n; i++) {
    int pocSat, pocMin, krajSat, krajMin;
    cin >> pocSat >> pocMin >> krajSat >> krajMin;
    casovi[i] = napraviCas(pocSat, pocMin, krajSat, krajMin);
  }
  return casovi;
}

// maksimalni broj casova koji se mogu odrzati tako da nema
// preklapanja medju rasporedjenim casovima
int maksBrojCasova(vector<cas>& casovi) {
  // broj casova
  int n = casovi.size();

  // sortiramo casove na osnovu vremena zavrsetka
  sort(begin(casovi), end(casovi),
       [](const cas& a, const cas& b) {
         return krajCasa(a) < krajCasa(b);
       });

  // broj odrzanih casova
  int brojOdrzanihCasova;
  // kraj poslednjeg odrzanog casa
  int kraj;
  
  // rasporedjujemo prvi cas 
  brojOdrzanihCasova = 1;
  kraj = krajCasa(casovi[0]);
  
  // analiziramo ostale casove u redosledu zavrsetka
  for (int i = 1; i < n; i++)
    // ako se tekuci cas ne preklapa sa poslednjim
    // rasporedjenim
    if (pocetakCasa(casovi[i]) >= kraj) {
      // on se odrzava
      brojOdrzanihCasova++;
      kraj = krajCasa(casovi[i]);
    }

  return brojOdrzanihCasova;
}


int main() {
  auto casovi = ucitajCasove();
  cout << maksBrojCasova(casovi) << endl;
  return 0;
}

Zadatak: Isplata sa posebnim novčićima

U Srbiji se koriste apoeni od 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000 i 5000 dinara. Napisati program koji formira dati iznos dinara od što manjeg broja apoena (novčanica i novčića) i ispisuje upotrebljen broj apoena.

Opis ulaza

Sa standardnog ulaza se učitava jedan ceo broj između \(0\) i \(100~000\).

Opis izlaza

Na standardni izlaz napisati najmanji broj apoena potrebnih da se isplati učitani iznos novca.

Primer
Ulaz
243
Izlaz
5
Objašnjenje

\(243 = 200 + 20 + 20 + 2 + 1\).

Rešenje

Jedan direktan način da se problem reši je da se ispitaju sva moguća razlaganja datog iznosa na zbirove sastavljene od ovih brojeva i da se pronađe onaj zbir koji ima najmanji broj novčića (jednostavnosti radi, zanemarimo razliku između novčića i novčanica). Rešenje ovog tipa bi se moglo zasnovati na dinamičkom programiranju kombinovanom sa odsecanjem tokom pretrage.

Dokaz korektnosti nije težak i ovaj pristup je neophodno primeniti kada su novčići proizvoljni. Međutim, u situaciji u kojoj su dati veoma specifični apoeni, ovaj pristup je nepotrebno komplikovan i neefikasan. Naime specifičnosti apoena omogućavaju da se zadatak reši mnogo efikasnije.


// najmanji broj novčića potreban da se naplati iznos S kada
// su nam na raspolaganju n vrednosti novčića datih u nizu v
int minBrojNovcica(const vector<int>& v, int n, int S) {
  vector<int> dp(S+1);
  // iznos 0 se naplaćuje sa 0 novčića
  dp[0] = 0;
  // računamo minimalni broj novčića za sve ostale iznose
  for (int s = 1; s <= S; s++) {
    // minimalni broj novčića da se naplati iznos s
    // (pretpostavljamo da iznos nije moguće naplatiti)
    dp[s] = INF;
    // razmatramo sve mogućnosti za poslednji novčić
    for (int i = 0; i < n; i++)
      // proveravamo da li je iznos s moguće naplatiti
      // novčićem i
      if (v[i] <= s)
        // ažuriramo minimum ako je to potrebno
        dp[s] = min(dp[s], dp[s-v[i]] + 1);
  }
  // vraćamo rezultat za iznos S
  return dp[S];
}
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

const int INF = numeric_limits<int>::max() - 1;


// najmanji broj novčića potreban da se naplati iznos S kada
// su nam na raspolaganju n vrednosti novčića datih u nizu v
int minBrojNovcica(const vector<int>& v, int n, int S) {
  vector<int> dp(S+1);
  // iznos 0 se naplaćuje sa 0 novčića
  dp[0] = 0;
  // računamo minimalni broj novčića za sve ostale iznose
  for (int s = 1; s <= S; s++) {
    // minimalni broj novčića da se naplati iznos s
    // (pretpostavljamo da iznos nije moguće naplatiti)
    dp[s] = INF;
    // razmatramo sve mogućnosti za poslednji novčić
    for (int i = 0; i < n; i++)
      // proveravamo da li je iznos s moguće naplatiti
      // novčićem i
      if (v[i] <= s)
        // ažuriramo minimum ako je to potrebno
        dp[s] = min(dp[s], dp[s-v[i]] + 1);
  }
  // vraćamo rezultat za iznos S
  return dp[S];
}

int main() {
  // učitavamo iznos
  int S;
  cin >> S;
  vector<int> v{5000, 2000, 1000, 500, 200, 100, 50, 20, 10, 5, 2, 1};
  // izračunavamo i ispisujemo najmanji potreban broj novcica
  cout << minBrojNovcica(v, v.size(), S) << endl;
  return 0;
}

I bez formalnog matematičkog objašnjenja, svaki prodavac u prodavnici i na pijaci zna da se optimalno rešenje dobija tako što se u svakom trenutku vraća najveći apoen koji je manji ili jednak od trenutnog iznosa i nakon toga se isti princip primenjuje na preostali iznos sve dok se ne vrati ceo kusur (u pitanju je, dakle, gramziva induktivno-rekurzivna konstrukcija). Ovo rešenje je veoma efikasno, lako se implementira, međutim, dokaz njegove korektnosti nije nimalo očigledan.

Naime, postojanje novčića od 4 dinara bi pokvarilo situaciju. 8 dinara bi se moglo dobiti od dva novčića od 4 dinara, dok bi gramziva strategija upotrebila tri novčića (od 5, 2 i 1 dinar). Dakle, dokaz korektnosti mora da uključi analizu konkretnih apoena koji su u opticaju i male promene ovih apoena mogu da utiču na to da opisani pristup daje ili ne daje uvek optimalno rešenje.

Dokažimo sada korektnost. Jednostavnosti radi, pretpostavićemo da su u opticaju samo apoeni od \(1\), \(2\), \(5\), \(10\), \(20\) i \(50\) dinara (za veće novčiće dokaz ide po istom principu). Metodom razmene dokazaćemo gornje granice broja novčića od svih apoena u optimalnom rešenju.

Uzevši u obzir prethodna ograničenja, razmotrimo maksimalne iznose sa optimalnim brojem novčića, koji se mogu dobiti korišćenjem samo određenih skupova novčića. Ispostaviće se da su maksimalni iznosi uvek za jedan manji od prvog većeg apoena.

Dokažimo sada da se za svaki pozitivan iznos u optimalnom rešenju mora nalaziti najveći novčić koji je manji ili jednak od tog iznosa (sve navedene konstatacije se odnose samo na optimalna rešenja).

Dakle, uspeli smo da za svaki iznos pronađemo novčić koji optimalno rešenje mora da sadrži, čime onda uspevamo da smanjimo dimenziju problema i da do rešenja dođemo direktno, bez bilo kakve pretrage i isprobavanja raznih mogućnosti.

int minBrojApoena(int iznos) {
  int brojApoena = 0;
  vector<int> apoeni
    {5000, 2000, 1000,
     500, 200, 100,
     50, 20, 10,
     5, 2, 1};
  while (iznos > 0) {
    for (int apoen : apoeni)
      if (iznos >= apoen) {
        iznos -= apoen;
        brojApoena++;
        break;
      }
  }
  return brojApoena;
}
#include <iostream>
#include <vector>

using namespace std;

int minBrojApoena(int iznos) {
  int brojApoena = 0;
  vector<int> apoeni
    {5000, 2000, 1000,
     500, 200, 100,
     50, 20, 10,
     5, 2, 1};
  while (iznos > 0) {
    for (int apoen : apoeni)
      if (iznos >= apoen) {
        iznos -= apoen;
        brojApoena++;
        break;
      }
  }
  return brojApoena;
}

int main() {
  int iznos;
  cin >> iznos;
  cout << minBrojApoena(iznos) << endl;
  return 0;
}