Salta al contenuto
Note per Studenti Esercizio - ricorrenza c(i,j) con bordi dati da due array, calcolo iterativo e spazio lineare (compito del 18 giugno 2026, es. 2)

Esercizio - ricorrenza c(i,j) con bordi dati da due array, calcolo iterativo e spazio lineare (compito del 18 giugno 2026, es. 2)

Fonte: compito di Algoritmi per l'Ingegneria, Ing. Informatica UniPD, 18 giugno 2026, seconda parte, esercizio 2 (16 punti).

Testo

Sia n>0n > 0. Dati due array di numeri reali A[1..n]A[1..n] e B[1..n]B[1..n], si consideri la seguente ricorrenza:

c(i,j)={A[i](1≤i≤n) ∧ (j=1)B[j](i=n) ∧ (1<j≤n)c(i,j−1)+c(i+1,j−1)−c(i+1,j)(i<n) ∧ (j>1)c(i, j) = \begin{cases} A[i] & (1 \le i \le n) \ \land \ (j = 1) \\ B[j] & (i = n) \ \land \ (1 < j \le n) \\ c(i, j-1) + c(i+1, j-1) - c(i+1, j) & (i < n) \ \land \ (j > 1) \end{cases}

  • (a) [11 punti] Si fornisca lo pseudocodice di un algoritmo iterativo COMPUTE_c(A, B) che, dati in ingresso i due array AA e BB a nn componenti, restituisca in uscita il valore c(1,n)c(1, n). Si provi la correttezza dell'algoritmo proposto.
  • (b) [5 punti] Si determini il numero esatto TC(n)T_C(n) di operazioni aritmetiche tra numeri reali effettuate da COMPUTE_c(A, B).

Soluzione

Lettura della ricorrenza

I tre casi coprono tutte le coppie (i,j)(i, j) con 1≤i,j≤n1 \le i, j \le n senza sovrapposizioni: la prima colonna (j=1j = 1) vale A[i]A[i]; la ultima riga (i=ni = n), per j>1j > 1, vale B[j]B[j] (quindi B[1]B[1] non viene mai usato, perché c(n,1)=A[n]c(n, 1) = A[n]); le celle interne, con i<ni < n e j>1j > 1, si calcolano con una somma e una sottrazione. È la struttura tipica della programmazione dinamica (Programmazione dinamica - Fibonacci, memoizzazione e schema del paradigmala programmazione dinamica (DP) evolve il divide and conquer: invece di risolvere di nuovo le stesse sottoistanze, le salva in una tabella. Fibonacci ricorsivo costa F_n - 1 addizioni (esponenziale), quello con tabella n - 1. Tre condizioni: sottostruttura, sottoistanze ripetute, spazio dei sottoproblemi ristretto. Bottom-up o memoizzazione (ricorsione + tabella globale). Per i problemi di ottimizzazione lo schema è: sottostruttura ottima, ricorrenza sui costi, informazione addizionale, calcolo della tabella, ricostruzione.Programmazione dinamica - Fibonacci, memoizzazione e schema del paradigma →): una tabella C[1..n,1..n]C[1..n, 1..n] con i casi base sul bordo e le altre celle determinate da celle già note.

(a) Ordine di riempimento

La cella (i,j)(i, j) dipende da tre celle:

  • (i,j−1)(i, j-1): stessa riga, colonna precedente;
  • (i+1,j−1)(i+1, j-1): riga sotto, colonna precedente;
  • (i+1,j)(i+1, j): riga sotto, stessa colonna.

Va scelto un ordine in cui queste tre siano calcolate prima di (i,j)(i, j). Le dipendenze vanno verso colonne di indice minore o uguale e verso righe di indice maggiore: l'ordine naturale è per colonne crescenti (j=2,3,…,nj = 2, 3, \dots, n) e, dentro ogni colonna, per righe decrescenti (i=n−1,…,1i = n - 1, \dots, 1). Con questo ordine, quando si arriva a (i,j)(i, j):

  • (i,j−1)(i, j-1) e (i+1,j−1)(i+1, j-1) stanno nella colonna j−1j - 1, già completa (o sono nella colonna 11, che contiene i dati);
  • (i+1,j)(i+1, j) è nella stessa colonna jj, ma una riga più in basso, già calcolata o appartenente alla riga base i+1=ni + 1 = n.

(Funziona anche la scansione per righe, dalla riga n−1n - 1 alla riga 11, ciascuna per colonne crescenti: la riga i+1i + 1 è completa quando si lavora sulla riga ii, e (i,j−1)(i, j-1) precede (i,j)(i, j) nella stessa riga. Non funzionano né le righe crescenti né le colonne decrescenti.)

COMPUTE_c(A, B)
  n <- A.len
  C[1, 1] <- A[1]
  for i <- 2 to n do
    C[i, 1] <- A[i]                  { prima colonna }
    C[n, i] <- B[i]                  { ultima riga, colonne 2..n }
  for j <- 2 to n do
    for i <- n-1 downto 1 do
      C[i, j] <- C[i, j-1] + C[i+1, j-1] - C[i+1, j]
  return C[1, n]

(Per n=1n = 1 i cicli sono vuoti e si restituisce C[1,1]=A[1]=c(1,1)C[1, 1] = A[1] = c(1, 1).)

Correttezza

Affermazione. Al termine di ogni scrittura di una cella C[i,j]C[i, j], vale C[i,j]=c(i,j)C[i, j] = c(i, j).

Per induzione sull'ordine di scrittura delle celle.

  • Base. Le celle scritte nei cicli di inizializzazione, C[i,1]=A[i]=c(i,1)C[i, 1] = A[i] = c(i, 1) per 1≤i≤n1 \le i \le n e C[n,j]=B[j]=c(n,j)C[n, j] = B[j] = c(n, j) per 2≤j≤n2 \le j \le n, sono corrette per la definizione dei primi due casi.
  • Passo. Sia (i,j)(i, j) con i<ni < n, j≥2j \ge 2, calcolata nel ciclo doppio. Per l'ordine di scansione, nel momento in cui si esegue l'assegnamento le tre celle (i,j−1)(i, j-1), (i+1,j−1)(i+1, j-1) e (i+1,j)(i+1, j) sono già state scritte (vedi sopra) e quindi, per ipotesi induttiva, contengono c(i,j−1)c(i, j-1), c(i+1,j−1)c(i+1, j-1) e c(i+1,j)c(i+1, j). Allora C[i,j]=c(i,j−1)+c(i+1,j−1)−c(i+1,j)=c(i,j)C[i, j] = c(i, j-1) + c(i+1, j-1) - c(i+1, j) = c(i, j) per il terzo caso.

Poiché la cella (1,n)(1, n) è calcolata dal ciclo (per n≥2n \ge 2), l'algoritmo restituisce c(1,n)c(1, n). □\square

Spazio: basta un vettore

Per calcolare una cella della colonna jj servono solo due celle della colonna j−1j - 1 e quella immediatamente sotto, nella stessa colonna. Quindi non serve la tabella quadratica. Con un solo vettore V[1..n]V[1..n] che contiene la colonna corrente (si sovrascrive man mano la colonna j−1j - 1 con la colonna jj):

COMPUTE_c_LINEARE(A, B)
  n <- A.len
  for i <- 1 to n do V[i] <- A[i]            { V = colonna 1 }
  for j <- 2 to n do
    old <- V[n]                             { c(n, j-1) }
    V[n] <- B[j]                            { c(n, j) }
    for i <- n-1 downto 1 do
      tmp <- V[i]                           { c(i, j-1) }
      V[i] <- tmp + old - V[i+1]            { V[i+1] contiene già c(i+1, j) }
      old <- tmp                            { diventa c(i+1, j-1) per la riga i-1 }
  return V[1]

Invariante: all'inizio del passo ii della colonna jj, old =c(i+1,j−1)= c(i+1, j-1) e V[i+1]=c(i+1,j)V[i+1] = c(i+1, j). Spazio Θ(n)\Theta(n) invece di Θ(n2)\Theta(n^2). (Se si usano due vettori prev e curr bisogna che prev <- curr sia una copia o uno scambio di ruoli, non un'assegnazione che fa puntare i due nomi allo stesso array: altrimenti la colonna j−1j - 1 verrebbe sovrascritta mentre serve ancora.)

Esempio (n=4n = 4)

A=(2,5,1,3)A = (2, 5, 1, 3), B=(7,4,6,2)B = (7, 4, 6, 2) (il valore B[1]=7B[1] = 7 non viene usato). Tabella c(i,j)c(i, j), con le righe ii dall'alto e le colonne jj da sinistra:

i\ji \backslash j 1 2 3 4
1 2 1 −1 3
2 5 6 8 4
3 1 0 −2 2
4 3 4 6 2

La prima colonna è AA e l'ultima riga è (3,4,6,2)(3, 4, 6, 2): c(4,1)=A[4]=3c(4, 1) = A[4] = 3 e poi B[2],B[3],B[4]B[2], B[3], B[4]. Colonna j=2j = 2 dal basso: c(3,2)=c(3,1)+c(4,1)−c(4,2)=1+3−4=0c(3, 2) = c(3, 1) + c(4, 1) - c(4, 2) = 1 + 3 - 4 = 0; c(2,2)=c(2,1)+c(3,1)−c(3,2)=5+1−0=6c(2, 2) = c(2, 1) + c(3, 1) - c(3, 2) = 5 + 1 - 0 = 6; c(1,2)=c(1,1)+c(2,1)−c(2,2)=2+5−6=1c(1, 2) = c(1, 1) + c(2, 1) - c(2, 2) = 2 + 5 - 6 = 1. Colonna j=4j = 4: c(1,4)=c(1,3)+c(2,3)−c(2,4)=−1+8−4=3c(1, 4) = c(1, 3) + c(2, 3) - c(2, 4) = -1 + 8 - 4 = 3. Il risultato è c(1,4)=3c(1, 4) = 3 con 1818 operazioni.

Verifica: lo pseudocodice (tabella e vettore) è stato confrontato in Python con la definizione ricorsiva su 300300 coppie casuali di array, con nn fino a 88.

(b) Numero esatto di operazioni

Per ogni cella interna (i<ni < n, j≥2j \ge 2) si fanno due operazioni aritmetiche (una somma e una sottrazione); le inizializzazioni non fanno operazioni. Le celle interne sono n−1n - 1 righe per n−1n - 1 colonne:

TC(n)=∑j=2n∑i=1n−12=∑j=2n2(n−1)=2(n−1)2.T_C(n) = \sum_{j=2}^{n} \sum_{i=1}^{n-1} 2 = \sum_{j=2}^{n} 2(n - 1) = 2(n - 1)^2.

(Per n=4n = 4: 2⋅9=182 \cdot 9 = 18; per n=1n = 1: 00.) Il conto si ottiene con la regola di sostituzione dei cicli con le sommatorie (SommatorieIl simbolo di sommatoria, le sue proprietà (linearità, additività, cambio di indice) e le somme notevoli di Gauss e geometrica.Sommatorie →). La versione a vettore esegue le stesse operazioni: l'unica differenza è l'uso della memoria.

Versione ripasso

Testo. c(i,j)=A[i]c(i,j) = A[i] se j=1j = 1; B[j]B[j] se i=ni = n e 1<j≤n1 < j \le n; c(i,j−1)+c(i+1,j−1)−c(i+1,j)c(i,j-1) + c(i+1,j-1) - c(i+1,j) se i<ni < n e j>1j > 1. (a) [11 punti] Algoritmo iterativo COMPUTE_c(A,B) che restituisce c(1,n)c(1,n), con prova di correttezza. (b) [5 punti] Numero esatto TC(n)T_C(n) di operazioni aritmetiche.

  • Dipendenze: (i,j)(i,j) usa (i,j−1)(i,j-1), (i+1,j−1)(i+1,j-1), (i+1,j)(i+1,j). Ordine: colonne j=2..nj = 2..n crescenti e, in ogni colonna, righe i=n−1..1i = n-1..1 decrescenti (vale anche: righe da n−1n-1 a 11, colonne crescenti). Righe crescenti o colonne decrescenti sono sbagliate.
COMPUTE_c(A, B)
  n <- A.len; C[1,1] <- A[1]
  for i <- 2 to n do C[i,1] <- A[i]; C[n,i] <- B[i]
  for j <- 2 to n do
    for i <- n-1 downto 1 do
      C[i,j] <- C[i,j-1] + C[i+1,j-1] - C[i+1,j]
  return C[1,n]
  • Correttezza (induzione sull'ordine di scrittura): le celle di bordo sono corrette per definizione; per una cella interna le tre da cui dipende sono già scritte e, per ipotesi, corrette, quindi vale il terzo caso. B[1]B[1] non è mai usato.
  • Un solo vettore V[1..n]V[1..n] (colonna corrente): per ogni jj, old <- V[n] (=c(n,j−1)= c(n,j-1)); V[n] <- B[j]; per i=n−1..1i = n-1..1: tmp <- V[i]; V[i] <- tmp + old - V[i+1]; old <- tmp. Spazio Θ(n)\Theta(n). Con due vettori, prev <- curr deve essere una copia.
  • Esempio n=4n = 4, A=(2,5,1,3)A = (2,5,1,3), B=(7,4,6,2)B = (7,4,6,2):
i\ji \backslash j 1 2 3 4
1 2 1 −1 3
2 5 6 8 4
3 1 0 −2 2
4 3 4 6 2

Per esempio c(3,2)=1+3−4=0c(3,2) = 1 + 3 - 4 = 0, c(1,4)=−1+8−4=3c(1,4) = -1 + 8 - 4 = 3.

  • Costo: due operazioni per ogni cella interna, (n−1)2(n-1)^2 celle: TC(n)=∑j=2n∑i=1n−12=2(n−1)2T_C(n) = \sum_{j=2}^{n}\sum_{i=1}^{n-1} 2 = 2(n-1)^2 (1818 per n=4n = 4, 00 per n=1n = 1).
  • Errori tipici: ordine di scansione che legge c(i+1,j)c(i+1,j) prima di averlo calcolato; usare B[1]B[1]; dimenticare che n=1n = 1 restituisce A[1]A[1]; contare una sola operazione per cella; con due vettori, assegnare per riferimento.

Teoria collegata