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:

pN+ t.c. zL,|z|p,u,v,w,x,y t.c. 
  1. z=uvwxy

  2. |vwx|p

  3. |vx|>0

  4. iN (anche nullo), uviwxiyL


Proprietà

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


Espressioni regolari

Valgono:

Operazione Sintassi Linguaggio
ALTERNANZA rs L(r)L(s)
CONCATENAZIONE rs L(r)L(s)
MOLTEPLICITÀ r {ε}{w1wk}
PRECEDENZA (r)

Precedenze

>>

Esempio:

abc(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:

pN+ t.c. zL:|z|pu,v,w t.c. z=uvw&|uv|p&|v|>0&i0,uviwL

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 (Xa):

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

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

    • Se εFirst(Y1)First(X)={First(Y1)ε}First(Y2Yn)

Per calcolare First(Y2Y3Yn) 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 BAβ (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 bFirst(α)):

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

if εFirst(α):

foreach (terminale xFollow(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:

AAα1Aαnβ1βk

con αjε e βiAγ

Si trasforma in:

Aβ1AβkAAα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:

ABabBBcAdb

In questo caso, abbiamo una ricorsione sinistra su A (indiretta): ABaAdA.

1. Procediamo per sostituzione

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

La produzione BAd diventa quindi:

BBadbd

Otteniamo quindi la grammatica aggiornata:

ABabBBcBadbdb

2. Togliamo ora la ricorsione (immediata)

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

ABabBbdBbBBcBadBε

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:

SSaSSbSScSid

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

La grammatica trasformata diventa:

  1. Livello priorità bassa (a): SSaEE

  2. Livello priorità media (b): EEbTT

  3. Livello priorità alta (c): TTcQQ

  4. Qid

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 wL(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 PS

Produzione Sif(B)S1

Produzione Sif(B)S1 else S2

Produzione Swhile (B)S1