Clasa a 7-a Lecția 10: Fill recursiv (flood fill)

From Algopedia
Jump to navigationJump to search

Înregistrare video lecție


Despre flood fill

Algoritmul fill umple toate golurile accesibile de la un punct dat.

Golurile pot fi elemente zero într-o matrice, de exemplu, iar vecinii pot fi definiți ca elementele adiacente pe linie și coloană (acesta este cazul cel mai întâlnit). Alteori vecinii pot fi definiți ca având un punct comun cu elementul curent, ceea ce include și elementele alăturate pe diagonală.

Să luăm un exemplu clasic:

Se dă o matrice a[m][n] cu elemente 0 și 1, 0 semnificând liber și 1 semnificând zid, precum și o poziție în matrice, (l, c). Să se spună câte elemente 0 sunt accesibile de la (l, c) avansând doar pe linie sau pe coloană.

int fill( int l, int c ) {          // umple de la coloana l, c
  int suma = 1;                     // contorizam punctul curent

  a[l][c] = 1;                      // marcam pozitia ca parcursa
  if ( l > 0 && a[l-1][c] == 0 )    // ne deplasam in sus
    suma += fill( l - 1, c );
  if ( l < m-1 && a[l+1][c] == 0  ) // ne deplasam in jos
    suma += fill( l + 1, c );
  if ( c > 0 && a[l][c-1] == 0  )   // ne deplasam spre stinga
    suma += fill( l, c - 1 );
  if ( c < n-1 && a[l][c+1] == 0  ) // ne deplasam spre dreapta
    suma += fill( l, c + 1 );

  return suma;
}
...
int main() {
  int suma;
  ...
  suma = 0;
  if ( a[l][c] == 0 )
    suma = fill( l, c );
  printf( "%d", suma );
  ...
}


Observații:

  • Ordinea direcțiilor nu contează. Cele patru apeluri recursive pot fi în orice ordine.
  • Vecinii pot fi în număr de 4 - cei adiacenți pe linie și pe coloană, ca în exemplul de mai sus, sau pot fi în număr de 8 - și cei adiacenți pe diagonale.

Ce complexitate are acest algoritm?

Acest algoritm are complexitate O(m·n) ca timp, deoarece poate parcurge toate elementele matricei, iar prelucrarea per element este O(1). Memoria ocupată este de asemenea O(m·n) deoarece fiecare apel recursiv necesită O(1) memorie și putem avea O(m·n) apeluri recursive unul în altul.

Atenție! Acesta este principalul impediment al metodei! Ea folosește foarte multă memorie suplimentară.

Exemplificare grafică

Să vedem niște vizualizări ale algoritmului flood fill preluate de la wikipedia: algoritm flood fill.

Flood fill cu 4 direcții
Flood fill cu 8 direcții
Exemplu mare de flood fill cu 4 direcții


Optimizări ale algoritmului

Bordare matrice

O primă optimizare de timp este clasică. Pentru a face programul atât mai rapid cât și mai scurt putem borda matricea de jur împrejur cu elementul zid, în cazul nostru 1. Astfel vom scuti testul de ieșire a coordonatelor din matrice.

Funcția se simplifică:

int fill( int l, int c ) {    // umple de la coloana l, c
  int suma = 1;               // contorizam punctul curent

  a[l][c] = 1;                // marcam pozitia ca parcursa
  if ( a[l-1][c] == 0 )       // ne deplasam in sus
    suma += fill( l - 1, c );
  if ( a[l+1][c] == 0  )      // ne deplasam in jos
    suma += fill( l + 1, c );
  if ( a[l][c-1] == 0  )      // ne deplasam spre stinga
    suma += fill( l, c - 1 );
  if ( a[l][c+1] == 0  )      // ne deplasam spre dreapta
    suma += fill( l, c + 1 );

  return suma;
}


Reducere memorie folosită

Cum calculăm memoria ocupată?

Țineți minte: fiecare apel recursiv ocupă memorie echivalentul unui int. Pe un calculator de 32 de biți, cum sunt cele pe care lucrăm noi (ele sunt de fapt de 64 de biți, dar programele sunt compilate în modul 32 de biți), aceasta înseamnă patru octeți.

Aceasta este adresa de întoarcere din funcție. Alte elemente care ocupă memorie pe stivă sunt parametrii transmiși și variabilele declarate în funcție. În cazul nostru vom avea 16 octeți cei doi parametri, variabila declarată în funcție și adresa de întoarcere din funcție.

Dacă micșorarea memoriei folosite este esențială putem elimina 12 octeți din 16, declarând variabilele lc și suma globale, astfel:

int l, c, suma;       // variabile globale
...
void fill() {
  suma++;
  a[l][c] = 1;
  l--;
  if ( a[l][c] == 0 ) // apelam pentru (l - 1, c)
    fill();
  l++;
  l++;
  if ( a[l][c] == 0 ) // apelam pentru (l + 1, c)
    fill();
  l--;
  c--;
  if ( a[l][c] == 0 ) // apelam pentru (l, c - 1)
    fill();
  c++;
  c++;
  if ( a[l][c] == 0 ) // apelam pentru (l, c + 1)
    fill();
  c--;                // acum (l, c) sunt cele de la inceput
}


Este o modificare urâtă, care face codul nelizibil, dar uneori necesară la olimpiadă. Nu uitați că chiar și în această formă algoritmul va folosi 4 octeți pe apel recursiv imbricat pentru adresa de revenire din funcție.

Observație: variabilele l și c fiind globale ele trebuie să aibă aceeași valoare la ieșirea din funcție ca și la intrarea în funcție. De aceea ultima instrucțiune c--; este importantă!


Aplicații ce folosesc flood fill și concluzii

Găsire ieșire din labirint

Folosind flood fill putem găsi un punct de ieșire dintr-un labirint memorat într-o matrice. Să presupunem că elementele 0 înseamnă loc gol și cele 1 înseamnă zid. Ieșirile sunt puncte 0 pe marginile matricei. Dat un punct din interiorul matricei, există un drum spre ieșire? Dacă da, putem găsi atât coordonatele ieșirii cât și drumul parcurs.

Iată un exemplu de program care afișează coordonatele ieșirii:

int fill( int l, int c ) {
  int retval;

  if ( lab[l][c] == BORDER ) // am ajuns la margine?
    return l * MAXN + c;     // returnam (l, c)
    
  lab[l][c] = 1;                   // marcam punctul curent ca vizitat
  retval = 0;                      // 0 inseamna ca nu am gasit iesire
  if ( lab[l + 1][c] != 1 )        // daca nu este zid
    retval = fill(l + 1, c);       // cautam in jos
  if ( retval == 0 ) {             // daca nu am gasit iesire
    if ( lab[l - 1][c] != 1 )      // si nu este zid
      retval = fill(l - 1, c);     // cautam in sus
    if ( retval == 0 ) {           // daca nu am gasit iesire
      if ( lab[l][c + 1] != 1 )    // si nu este zid
        retval = fill(l, c + 1);   // cautam in dreapta
      if ( retval == 0 )           // daca nu am gasit iesire
        if ( lab[l][c - 1] != 1 )  // si nu este zid
          retval = fill(l, c - 1); // cautam in stinga
    }
  }

  return retval;
}

int main() {
  FILE *fin, *fout;
  int n, l, c, ls, cs, p;

  fin = fopen( "iesire.in", "r" );
  fscanf( fin, "%d%d%d ", &n, &ls, &cs );
  for ( l = 0; l < n; l++ ) {
    for ( c = 0; c < n; c++ )     // citim linia l a matricei
      lab[l][c] = fgetc( fin ) - '0';
    fgetc( fin ); // citim '\n'
  }
  fclose( fin );
  for ( c = 0; c < n; c++ ) { // bordare conditionala margini
    if ( lab[0][c] == 0 )
      lab[0][c] = BORDER; 
    if ( lab[n - 1][c] == 0 )
      lab[n - 1][c] = BORDER;
    if ( lab[c][0] == 0 )
      lab[c][0] = BORDER;
    if ( lab[c][n - 1] == 0 )
      lab[c][n - 1] = BORDER;
  }

  p = fill( ls - 1, cs - 1 );
  
  fout = fopen( "iesire.out", "w" );
  if ( p > 0 )
    fprintf( fout, "%d %d\n", 1 + p / MAXN, 1 + p % MAXN );
  else
    fprintf( fout, "-1\n" );
  fclose( fout );

  return 0;
}


Arie goluri

Calcul arie goluri: dorim să aflăm numărul de elemente zero accesibile din punctul inițial.

Iată un exemplu de program care afișează aria golului accesibil din punctul inițial:

#include <stdio.h>

#define MAXN 1000
#define BORDER 2

char lab[MAXN + 2][MAXN + 2]; // matricea labirint

// Executa flood fill de la coordonate (l, c)
// returneaza aria zonei din care se porneste fill (cite puncte 0)
int fill( int l, int c ) {
  int aria;

  lab[l][c] = 1;                   // marcam punctul curent ca vizitat
  aria = 1;                        // contorizam punctul curent
  if ( lab[l + 1][c] == 0 )        // daca nu este zid
    aria += fill(l + 1, c);        // cautam in jos
  if ( lab[l - 1][c] == 0 )        // daca nu este zid
    aria += fill(l - 1, c);        // cautam in sus
  if ( lab[l][c + 1] == 0 )        // daca nu este zid
    aria += fill(l, c + 1);        // cautam in dreapta
  if ( lab[l][c - 1] == 0 )        // daca nu este zid
    aria += fill(l, c - 1);        // cautam in stinga

  return aria;
}

int main() {
  FILE *fin, *fout;
  int n, l, c, ls, cs, a;

  fin = fopen( "arie.in", "r" );
  fscanf( fin, "%d%d%d ", &n, &ls, &cs );
  for ( l = 1; l <= n; l++ ) {
    for ( c = 1; c <= n; c++ )  // citim linia l a matricei
      lab[l][c] = fgetc( fin ) - '0';
    fgetc( fin ); // citim '\n'
  }
  fclose( fin );
  for ( c = 1; c <= n; c++ ) // bordare margini
    lab[0][c] = lab[n + 1][c] = lab[c][0] = lab[c][n + 1] = BORDER;

  a = fill( ls, cs ); // calcul arie accesibila din (ls, cs)
  
  fout = fopen( "arie.out", "w" );
  fprintf( fout, "%d\n", a );
  fclose( fout );

  return 0;
}


Număr goluri

Calcul număr de goluri: denumite și componente conexe, putem folosi fill pentru a număra câte goluri disjuncte există (astfel încât să nu se poată ajunge de la un gol la altul). Pentru aceasta vom parcurge matricea în orice ordine și vom porni câte un fill din fiecare loc unde găsim un element 0.

Iată un exemplu de program:

#include <stdio.h>

#define MAXN 1000
#define BORDER 2

char lab[MAXN + 2][MAXN + 2]; // matricea labirint

// Executa flood fill de la coordonate (l, c)
void fill( int l, int c ) {
  lab[l][c] = 1;            // marcam punctul curent ca vizitat
  if ( lab[l + 1][c] == 0 ) // daca nu este zid
    fill(l + 1, c);         // cautam in jos
  if ( lab[l - 1][c] == 0 ) // daca nu este zid
    fill(l - 1, c);         // cautam in sus
  if ( lab[l][c + 1] == 0 ) // daca nu este zid
    fill(l, c + 1);         // cautam in dreapta
  if ( lab[l][c - 1] == 0 ) // daca nu este zid
    fill(l, c - 1);         // cautam in stinga
}

int main() {
  FILE *fin, *fout;
  int n, l, c, ls, cs, nr;

  fin = fopen( "nrgol.in", "r" );
  fscanf( fin, "%d%d%d ", &n, &ls, &cs );
  for ( l = 1; l <= n; l++ ) {
    for ( c = 1; c <= n; c++ )  // citim linia l a matricei
      lab[l][c] = fgetc( fin ) - '0';
    fgetc( fin ); // citim '\n'
  }
  fclose( fin );
  for ( c = 1; c <= n; c++ ) // bordare margini
    lab[0][c] = lab[n + 1][c] = lab[c][0] = lab[c][n + 1] = BORDER;

  nr = 0;
  for ( l = 1; l <= n; l++ )
    for ( c = 1; c <= n; c++ ) // pentru fiecare punct din labirint
      if ( lab[l][c] == 0 ) {  // daca nu este zid si nu a fost umplut deja
        nr++;                  // contorizam un nou gol
        fill( l, c );          // umplem zona
      }
        
  fout = fopen( "nrgol.out", "w" );
  fprintf( fout, "%d\n", nr );
  fclose( fout );

  return 0;
}


Perimetru goluri

Calcul perimetru goluri: în locul ariei golurilor putem calcula perimetrul lor, anume numărul de puncte din goluri care se învecinează cu un punct care nu face parte din gol. Trebuie să avem grijă când testăm dacă un punct este pe perimetru.

Un punct se află pe perimetru dacă:

  • Unul din vecinii săi este zid,
  • Sau unul din vecinii săi este margine bordată.

Un punct nu neapărat se află pe perimetru dacă:

  • Unul din vecinii săi este un alt punct parcurs anterior.

Cum ne dăm seama dacă vecinul este margine, zid, sau punct anterior?

Vom umple golurile cu un număr distinct. În exemplul de mai jos marginile și zidurile sunt codificate cu 1, iar umplerea se face cu 2.

Iată un exemplu de program care afișează perimetrul golului accesibil din punctul inițial:

#include <stdio.h>

#define MAXN 1000
#define BORDER 1
#define FILL 2

char lab[MAXN + 2][MAXN + 2]; // matricea labirint

// Executa flood fill de la coordonate (l, c)
// returneaza perimetrul zonei din care se porneste fill (cite puncte 0)
int fill( int l, int c ) {
  int perimetrul;

  lab[l][c] = FILL;               // marcam punctul curent ca vizitat
  perimetrul = lab[l - 1][c] == 1 // verificam daca punctul curent este pe
    || lab[l + 1][c] == 1         // perimetru
    || lab[l][c - 1] == 1
    || lab[l][c + 1] == 1;        // daca da, contorizam punctul curent
  if ( lab[l + 1][c] == 0 )       // daca nu este zid
    perimetrul += fill(l + 1, c); // cautam in jos
  if ( lab[l - 1][c] == 0 )       // daca nu este zid
    perimetrul += fill(l - 1, c); // cautam in sus
  if ( lab[l][c + 1] == 0 )       // daca nu este zid
    perimetrul += fill(l, c + 1); // cautam in dreapta
  if ( lab[l][c - 1] == 0 )       // daca nu este zid
    perimetrul += fill(l, c - 1); // cautam in stinga

  return perimetrul;
}

int main() {
  FILE *fin, *fout;
  int n, l, c, ls, cs, p;

  fin = fopen( "perimetru.in", "r" );
  fscanf( fin, "%d%d%d ", &n, &ls, &cs );
  for ( l = 1; l <= n; l++ ) {
    for ( c = 1; c <= n; c++ )  // citim linia l a matricei
      lab[l][c] = fgetc( fin ) - '0';
    fgetc( fin ); // citim '\n'
  }
  fclose( fin );
  for ( c = 0; c < n; c++ ) // bordare margini
    lab[0][c] = lab[n + 1][c] = lab[c][0] = lab[c][n + 1] = BORDER;

  p = fill( ls, cs ); // calcul perimetru accesibil din (ls, cs)
  
  fout = fopen( "perimetru.out", "w" );
  fprintf( fout, "%d\n", p );
  fclose( fout );

  return 0;
}


Eliminare zid

Ne propunem să eliminăm acea pătrațică zid care reduce la minim numărul de goluri.

Cum procedăm?


Soluție forță brută: o soluție forță brută ar fi să eliminăm pe rând fiecare bucată de zid și să numărăm golurile formate.

Ce complexitate are această soluție?

Deoarece numărul de ziduri poate fi O(n2) iar numărarea golurilor este și ea tot O(n2) rezultă o complexitate de O(n4).

Se poate mai bine? Desigur.


Soluție optim: soluția optimă este similară cu cea care numără golurile:

  • Umplem toate golurile.
  • La fiecare gol umplem cu o valoare diferită de fill.
  • La final parcurgem matricea căutând pătrățica zid care are ca vecini cele mai multe goluri diferite (eticheta diferită).

Ce complexitate are această soluție?

  • Umplerea golurilor este O(n2).
  • Verificarea zidurilor este O(n2).

Timpul total va fi, deci, O(n2).


Concluzii

Fill este o unealtă simplă și utilă, ușor de folosit la concursuri. Principalul ei neajuns este memoria ocupată.

Ce facem în situația când matricea este prea mare și memoria disponibilă insuficientă pentru a folosi flood fill?

În această situație putem folosi BFS fill (breadth first search), care, atunci când este aplicat pe o matrice se mai numește și algoritmul Lee. El folosește o coadă pentru a menține frontiera deja acoperită, reducând memoria la O(m+n) însă fiind ceva mai lent.

Vom discuta acest algoritm într-o altă lecție.


Temă 10

Să se rezolve următoarele probleme (program C trimis la NerdArena):

  • Oraș dată la .campion 2011
  • Enclave de exercițiu în flood fill


Temă opțională

Să se rezolve următoarele probleme (program C trimis la NerdArena):

  • OZN dată la barajul pentru Shumen din Tudor Vianu anul 2014, juniori


Accesează rezolvarea temei 10