Riassunto

Grammatica / Linguaggi liberi (context-free)


Pumping lemma per linguaggi liberi

Il negato di questo lemma ci permette di dimostrare che non esiste una determinata grammatica libera che genera un dato linguaggio.

Enunciato

Sia L un linguaggio libero. Allora:

∃p∈N+ t.c. ∀z∈L,|z|≥p,∃u,v,w,x,y t.c. 
  1. z=uvwxy

  2. |vwx|≤p

  3. |vx|>0

  4. ∀i∈N (anche nullo), uviwxiy∈L


Proprietà

I linguaggi liberi sono chiusi per UNIONE, CONCATENAZIONE ma NON per intersezione.


Espressioni regolari

Valgono:

Operazione Sintassi Linguaggio
ALTERNANZA r∣s L(r)∪L(s)
CONCATENAZIONE r⋅s L(r)L(s)
MOLTEPLICITÀ r∗ {ε}∪{w1…wk…}
PRECEDENZA (r)

Precedenze

∗>⋅>∣

Esempio:

a∣b∗c⟶(a∣((b∗)⋅c))

Conversioni

Da un'espressione regolare è possibile:


Simulazione di un NFA

Costo: O(|w|⋅(n+m))


Trasformazione di un NFA in DFA

La procedura segue questi passaggi:

  1. Inizio: Si parte da T0, che contiene lo stato iniziale più tutti quelli raggiungibili tramite ε-transizioni.

  2. Costruzione Tabella: Si crea una tabella dove:

    • Sulle righe si pongono i vari insiemi di stati Tn.

    • Sulle colonne si pongono tutti i terminali dell'alfabeto.

  3. Iterazione:

    • Partendo da T0, per ogni terminale nelle colonne si calcola la transizione, creando così un nuovo stato del DFA.

    • Nota bene: Bisogna ricordarsi di calcolare la ε-chiusura (o ε-transizione) per ognuno degli stati così ottenuti!


Minimizzare un DFA

Bisogna intanto essere sicuri che il DFA sia totale, ovvero che ogni stato abbia una transizione per tutti i terminali. Se non è così, si aggiunge un SINK a cui si fanno arrivare tutte le transizioni mancanti.

A questo punto si inseriscono in un set tutti gli stati non finali ed in un altro set tutti gli stati finali. A questo punto si parte con le n-equivalenze.

Due stati appartengono allo stesso set se:

Se queste condizioni non valgono, i due stati non appartengono allo stesso set.


Linguaggi regolari

Un linguaggio L si dice regolare se e solo se:

Gerarchia delle Grammatiche

Ricordiamo che ogni grammatica regolare è anche una grammatica libera (context-free).

Proprietà di Chiusura

I linguaggi regolari sono chiusi rispetto alle seguenti operazioni:

Pumping lemma per linguaggi regolari

Sia L un linguaggio regolare. Allora:

∃p∈N+ t.c. ∀z∈L:|z|≥p∃u,v,w t.c. z=uvw&|uv|≤p&|v|>0&∀i≥0,uviw∈L

FIRST

In linea generica, il FIRST di una stringa (di terminali / non-terminali) è il primo carattere che compare in ogni produzione di quella stringa.

Dunque:

  1. Per una produzione del tipo X→ε:

    First(X)={ε}
  2. Per ogni produzione con inizio di un terminale (X→a…):

    First(X)={a}
  3. Per ogni produzione del tipo X→Y1Y2…Yn con Yi terminali o non-terminali:

    • Se ε∉First(Y1)⟹First(X)=First(Y1)

    • Se ε∈First(Y1)⟹First(X)={First(Y1)−ε}∪First(Y2…Yn)

Per calcolare First(Y2Y3…Yn) si rieseguono i passaggi del punto 3 ricorsivamente finché non si incontra un First che non contenga ε.

Se ε∈First(Yn) (ovvero tutti i componenti della produzione possono annullarsi), allora:

First(X)=First(X)∪{ε}

FOLLOW

In linea generica il FOLLOW di una stringa (di non-terminali) è l'insieme dei terminali che seguono la stringa in qualche derivazione.

Procedura di calcolo

  1. Inizio: Come primo passo si aggiunge il simbolo $ (fine input) ai follow dello start symbol.

  2. Iterazione: Per ogni produzione del tipo B→Aβ (dove A è un non-terminale e β è la stringa che segue):

    • Se β≠ε:

      Follow(A).add (First(β)∖{ε})
    • Se β=ε∨ε∈First(β):

      Follow(A).add (Follow(B))

Nello schema sopra, A rappresenta un non-terminale e β è la stringa che lo segue all'interno di una produzione.


Parsing Top-Down: LL(1) parser

Sta per "Left-to-Right Leftmost" parser. Prende in input una parola e la parsing table e restituisce in output la derivazione leftmost per ottenere la parola (se ∈L(G)), altrimenti restituisce un errore.

Procedimento

  1. Calcolare First e Follow di ogni non-terminale.

  2. Costruire la tabella di parsing.

  3. Riconoscimento della parola usando uno stack e la parsing table.

Il punto 1 l'abbiamo già esplorato in precedenza.

(2) Costruzione della tabella di parsing

Innanzitutto si crea una tabella con:

Si prende poi ogni produzione della grammatica e si applica la seguente logica:

Algoritmo di riempimento:

foreach (A→α):

  foreach (terminale b∈First(α)):

    inserire A→α nella entry [A,b] della tabella

  if ε∈First(α):

    foreach (terminale x∈Follow(A)):

      inserire A→α nella entry [A,x]

(3) Grammatica LL(1)

Se la tabella di parsing non ha più di una produzione per una certa cella (non ha "multiple defined entries"), allora la grammatica è LL(1).

Riconoscimento della parola

In questa fase ci servono:

Procedimento

L'algoritmo legge la parola e utilizza lo stack per verificare la correttezza della derivazione:

stack s;
s.push($); 
s.push(S); // S è lo start symbol
x = s.top();

while (x != $) {
    b = read(); // legge un carattere dalla parola

    if (x == b) {
        s.pop();
    } 
    else if (x is terminal || M[x, b] is error) {
        error();
    } 
    else if (M[x, b] == A -> Y1 ... Yn) {
        s.pop();
        s.push(Y1 ... Yn); // Push dei simboli della produzione
    }
    
    x = s.top();
}

// Se il ciclo termina correttamente: w ∈ L
Nota sulla funzione read()

La funzione read() legge un carattere alla volta dalla parola in input.


Tips per riconoscere se una grammatica è LL(1)

Data una grammatica G con produzioni nel formato A→α∣β, allora:

Casi di non-LL(1):

Rimuovere la ricorsione a sinistra

Generalmente, dato lo schema:

A→Aα1∣⋯∣Aαn∣β1∣⋯∣βk

con αj≠ε e βi≠Aγ

Si trasforma in:

A→β1A′∣⋯∣βkA′A′→α1A′∣⋯∣αnA′∣ε
In breve

Le produzioni non affette da ricorsione le tieni, ma ci attacchi A′. Le altre le sposti in A′ ma senza la A ricorsiva iniziale.

Ricorsione sinistra non immediata

In caso di ricorsione sinistra non immediata, si sostituiscono le produzioni del 1° NON-terminale nel 2° NON-terminale.

Esempio:

Dati i seguenti passaggi:

A→Ba∣bB→Bc∣Ad∣b

In questo caso, abbiamo una ricorsione sinistra su A (indiretta): A⇒Ba⇒AdA.

1. Procediamo per sostituzione

Sostituiamo le produzioni di A all'interno di B dove compare il simbolo non-terminale A.

La produzione B→Ad diventa quindi:

B→Bad∣bd

Otteniamo quindi la grammatica aggiornata:

A→Ba∣bB→Bc∣Bad∣bd∣b

2. Togliamo ora la ricorsione (immediata)

Applichiamo l'algoritmo standard per eliminare la ricorsione sinistra su B:

A→Ba∣bB→bdB′∣bB′B′→cB′∣adB′∣ε

Fattorizzazione a sinistra

Data una grammatica del tipo:

A→αβ1∣⋯∣αβn∣γ1∣⋯∣γk

Questa si trasforma in:

A→αA′∣γ1∣⋯∣γkA′→β1∣⋯∣βn

E si continua iterativamente finché la grammatica non diventa LL(1).

Rimuovere le ambiguità

Non esiste un vero e proprio algoritmo universale, ma generalmente si segue questa regola logica:

Si cerca di tenere nelle prime derivazioni (quelle più vicine allo start symbol) i caratteri/operatori con minore priorità, e nelle ultime derivazioni quelli con maggiore priorità. In questo modo, nell'albero di derivazione, gli operatori con maggiore priorità si troveranno più in basso (e verranno quindi valutati per primi).

Esempio pratico

Data la grammatica ambigua:

S→SaS∣SbS∣ScS∣id

Con priorità degli operatori: c>b>a.

La grammatica trasformata diventa:

  1. Livello priorità bassa (a): S→SaE∣E

  2. Livello priorità media (b): E→EbT∣T

  3. Livello priorità alta (c): T→TcQ∣Q

  4. Q→id

SLR(1)

Sta per Simple Left-to-right Rightmost parser. Siamo nel Bottom-Up parsing.

Procedimento

  1. Costruzione dell'automa

  2. Costruzione della tabella di parsing

  3. Riconoscimento della parola

1. Costruzione dell'automa

2. Costruzione della tabella di parsing

Ci sono essenzialmente 4 casi per il riempimento:

3. Riconoscimento della parola

L'algoritmo vero e proprio serve per ottenere il parsing di una parola w. Se w∈L(G) l'algoritmo ha successo, altrimenti restituisce error().

Configurazione iniziale:

Algoritmo

b = first_symbol_in_the_input_buffer;
stSt.push(0); // Inizializza lo stack degli stati con lo stato 0

while (true) {
    S = stSt.top();

    if (M[S, b] == shift T) {
        symSt.push(b);      // Push del simbolo letto
        stSt.push(T);       // Push del nuovo stato
        b = next_symbol();  // Avanza nell'input
    }
    else if (M[S, b] == reduce A -> beta) {
        pop |beta| symbol off symSt; // Rimuove simboli pari alla lunghezza di beta
        symSt.push(A);               // Push del non-terminale driver
        pop |beta| state off stSt;   // Rimuove gli stati corrispondenti
        stSt.push(M[stSt.top(), symSt.top()]); // Esegue il GOTO
		print(A -> beta);
    }
    else if (M[S, b] == Accept) {
	    return; // Parsing completato con successo
	}
	else {
	    error(); // Errore di sintassi
	}
}

Generazione del codice intermedio

Questa fase si occupa di tradurre le istruzioni della grammatica in un linguaggio intermedio (solitamente simile all'assembly o a tre indirizzi) utilizzando degli attributi sintetizzati o ereditati.

Produzione P→S

Produzione S→if(B)S1

Produzione S→if(B)S1 else S2

Produzione S→while (B)S1