Essentials of Automata Theory and Formal Languages

Table of Contents

Automata Theory

Finite Automata   drill automata_theory

A finite automaton is a mathematical model of computation that can be in exactly one of a finite number of states at any given time.

What is a finite automaton?

  • 2024-08-08

Answer

A finite automaton is a mathematical model of computation that operates in one of a finite number of states at any given time.

A finite automaton is a theoretical machine with the following key characteristics:

  • Finite States: It has a limited set of states, and it can only exist in one of these states at any given moment.
  • Transitions: It changes from one state to another based on the input it receives. These changes are governed by a set of rules called transitions.
  • Start State: It has a designated starting state where it begins its operation.
  • Accept States: It has one or more designated accept states. If the automaton ends up in an accept state after processing the entire input, it's said to "accept" the input. Otherwise, it "rejects" the input.
  from typing import Dict, Set, Tuple

  class FiniteAutomaton:
      def __init__(self, states: Set[str], start_state: str, 
                   accept_states: Set[str], transitions: Dict[Tuple[str, str], str]):
          self.states = states
          self.start_state = start_state
          self.accept_states = accept_states
          self.transitions = transitions

      def accepts_string(self, input_string: str) -> bool:
          current_state = self.start_state
          for symbol in input_string:
              next_state = self.transitions.get((current_state, symbol))
              if next_state is None:
                  current_state = self.start_state  # Reset to start state on invalid transition
              else:
                  current_state = next_state
          return current_state in self.accept_states

  # Example finite automaton that accepts strings ending with 'a'
  fa = FiniteAutomaton(
      states={'q0', 'q1'},
      start_state='q0',
      accept_states={'q1'},
      transitions={
          ('q0', 'a'): 'q1',
          ('q0', 'b'): 'q0',
          ('q1', 'a'): 'q1',
          ('q1', 'b'): 'q0'
      }
  )

  # Test cases
  print(fa.accepts_string("abba"))  # True
  print(fa.accepts_string("baba"))  # False
  print(fa.accepts_string("aa"))   # True
  print(fa.accepts_string("bb"))  # False

DFA   drill automata_theory

DFA stands for Deterministic Finite Automaton. It's a finite state machine that accepts or rejects strings of symbols and only produces a unique computation of the automaton for each input string.

What is a DFA?

  • 2024-08-08

Answer

A DFA, or Deterministic Finite Automaton, is a finite state machine that accepts or rejects strings of symbols, producing a unique computation for each input string.

A DFA is a computational model with the following characteristics:

  • Finite States: It has a finite set of states, one designated as the start state, and zero or more accept states.
  • Transitions: For each state and input symbol, there's exactly one transition to another state (or possibly back to the same state). This determinism is what distinguishes DFAs from NFAs (Nondeterministic Finite Automata).
  • Input Alphabet: It operates on a finite set of input symbols, called the alphabet.
  • Acceptance: A DFA accepts a string if, starting from the start state and processing each symbol in the string, it ends up in an accept state. Otherwise, it rejects the string.
def dfa_accept(dfa, input_string):
  current_state = dfa['start']
  for symbol in input_string:
    current_state = dfa['transitions'].get((current_state, symbol))
    if current_state is None:
      return False  # No transition for this symbol, reject
  return current_state in dfa['accept']

# DFA that accepts strings ending with '0'
dfa = {
  'start': 'q0',
  'accept': {'q1'},
  'transitions': {
    ('q0', '0'): 'q1',
    ('q0', '1'): 'q0',
    ('q1', '0'): 'q1',
    ('q1', '1'): 'q0'
  }
}

print(dfa_accept(dfa, '0'))     # Output: True
print(dfa_accept(dfa, '00'))    # Output: True
print(dfa_accept(dfa, '101'))   # Output: False
print(dfa_accept(dfa, '110'))   # Output: True

NFA   drill automata_theory

NFA stands for Nondeterministic Finite Automaton. It's a finite state machine where for each pair of state and input symbol, there may be several possible next states.

What is an NFA?

  • 2024-08-08

Answer

An NFA, or Nondeterministic Finite Automaton, is a finite state machine where multiple next states may exist for each state and input symbol pair.

An NFA is a theoretical model used in computer science, particularly in the realm of formal languages and automata theory. It's a way to recognize patterns within strings of symbols. Here are its key characteristics:

  • States: It has a finite set of states, including a designated start state and one or more accept states.
  • Transitions: For each state and input symbol, there can be zero, one, or multiple transitions to other states. This nondeterminism is what sets NFAs apart from deterministic finite automata (DFAs).
  • Epsilon Transitions: NFAs can also have special transitions called epsilon transitions, which allow the automaton to change states without consuming any input symbol.
  • Acceptance: An NFA accepts a string if there exists at least one path from the start state to an accept state that consumes the entire input string.
def nfa_accept(nfa, input_string):
    def epsilon_closure(states):
        closure = set(states)
        stack = list(states)
        while stack:
            state = stack.pop()
            for epsilon_transition in nfa.get(state, {}).get('epsilon', []):
                if epsilon_transition not in closure:
                    closure.add(epsilon_transition)
                    stack.append(epsilon_transition)
        return closure

    current_states = epsilon_closure({nfa['start']})

    for symbol in input_string:
        next_states = set()
        for state in current_states:
            for transition in nfa.get(state, {}).get(symbol, []):
                next_states.add(transition)
        current_states = epsilon_closure(next_states)

    return any(state in nfa['accept'] for state in current_states)

# Example NFA that recognizes strings ending with "ab"
nfa = {
    'start': 'q0',
    'accept': {'q2'},
    'q0': {'a': {'q0'}, 'b': {'q1'}},
    'q1': {'a': {'q0'}, 'b': {'q2'}}
}

print(nfa_accept(nfa, 'ab'))    # Output: True
print(nfa_accept(nfa, 'abb'))   # Output: True
print(nfa_accept(nfa, 'a'))     # Output: False
print(nfa_accept(nfa, 'bab'))   # Output: True

  (define nfa
    '((q0 (a q0) (b q1))
      (q1 (a q0) (b q2))
      (q2)))

  (define (nfa-accept? nfa input)
    (define (epsilon-closure states)
      (let loop ((closure states))
        (cond ((null? closure) closure)
              ((member (car closure) closure) (loop (cdr closure)))
              (else (loop (append (cdr closure)
                                  (get-epsilon-transitions nfa (car closure)))))))))

    (define (get-transitions nfa state symbol)
      (let ((transitions (assoc state nfa)))
        (if transitions
            (let loop ((transitions (cdr transitions)))
              (cond ((null? transitions) '())
                    ((eq? (caar transitions) symbol)
                     (cons (cdar transitions)
                           (loop (cdr transitions))))
                    (else (loop (cdr transitions)))))
            '())))

    (define (get-epsilon-transitions nfa state)
      (get-transitions nfa state 'epsilon))

    (let loop ((current-states (epsilon-closure (list (car nfa)))))
      (cond ((null? input) (any accept-state? current-states))
            (else (loop (epsilon-closure
                         (apply append
                                (map (lambda (state)
                                       (get-transitions nfa state (car input)))
                                     current-states)))))))

    (define (accept-state? state)
      (null? (cdr (assoc state nfa))))

    (nfa-accept? nfa input))

  (display (nfa-accept? nfa "ab"))    ; Output: #t
  (display (nfa-accept? nfa "abb"))   ; Output: #t
  (display (nfa-accept? nfa "a"))     ; Output: #f
  (display (nfa-accept? nfa "bab"))   ; Output: #t

Regular Expression   drill automata_theory

A regular expression is a sequence of characters that defines a search pattern, mainly for use in pattern matching with strings.

What is a regular expression?

  • 2024-08-08

TODO Answer

A regular expression is a sequence of characters that defines a search pattern, primarily used for pattern matching within strings.

  class RegularExpression:
      def __init__(self, pattern):
          self.pattern = pattern

      def match(self, string):
          """
          Basic matching functionality.  Handles concatenation, alternation ('|'), and Kleene star ('*').
          """

          def match_here(pattern, string):
              if not pattern:  # Empty pattern matches empty string
                  return True
              elif pattern == '$' and not string:  # '$' matches end of string
                  return True
              elif len(pattern) > 1 and pattern[1] == '*':  # Kleene star
                  return match_star(pattern[0], pattern[2:], string)
              elif len(pattern) > 1 and pattern[1] == '|':  # Alternation
                  return match_here(pattern[0], string) or match_here(pattern[2:], string)
              elif string and (pattern[0] == '.' or pattern[0] == string[0]):  # Match single character or '.'
                  return match_here(pattern[1:], string[1:])
              else:
                  return False

          def match_star(char, pattern, string):
              """
              Helper function to handle Kleene star repetition.
              """
              if match_here(pattern, string):  # Zero or more repetitions
                  return True
              elif string and (char == '.' or char == string[0]):
                  return match_star(char, pattern, string[1:])
              else:
                  return False

          return match_here(self.pattern, string)

  # Example usage
  regex = RegularExpression('a*b')
  print(regex.match('b'))     # True
  print(regex.match('ab'))    # True
  print(regex.match('aab'))   # True
  print(regex.match('abb'))   # False
  ;; Import necessary modules
  (use-modules (oop goops)) 
  (use-modules (ice-9 regex))

  ;; Define the RegularExpression class with a pattern slot
  (define-class <regular-expression> ()
    ; pattern: string
    (pattern #:init-keyword #:pattern #:init-value #f #:accessor pattern))

  ;; Define the match method on the RegularExpression class
  (define-method (match (regex <regular-expression>) string) ; string: string -> boolean
    (let ((pattern (pattern regex)))
      ;; Define the match-here helper function
      (define (match-here pattern string) ; pattern: string, string: string -> boolean
        (cond 
          ((null? pattern) (null? string)) ; Empty pattern matches only empty string
          ((and (string=? pattern "$") (null? string)) #t) ; '$' matches end of string
          ((and (> (string-length pattern) 1) (char=? (string-ref pattern 1) #\*)) ; Kleene star
           (match-star (string-ref pattern 0) (substring pattern 2) string))
          ((and (> (string-length pattern) 1) (char=? (string-ref pattern 1) #\|)) ; Alternation
           (or (match-here (string->list (string-ref pattern 0)) string)
               (match-here (substring pattern 2) string)))
          ((and (not (null? pattern)) (null? string)) #f) ; Pattern not empty but string is empty - no match
          ((and (not (null? string)) ; Ensure string is not empty before accessing characters
                (or (char=? (string-ref pattern 0) #\.)
                    (char=? (string-ref pattern 0) (string-ref string 0)))) ; Match single character or '.'
           (match-here (substring pattern 1) (substring string 1)))
          (else #f)))

      ;; Define the match-star helper function
      (define (match-star char pattern string) ; char: char, pattern: string, string: string -> boolean
        (cond ((match-here pattern string) #t) ; Zero or more repetitions
              ((and (not (null? string))
                    (or (char=? char #\.)
                        (char=? char (string-ref string 0))))
               (match-star char pattern (substring string 1)))
              (else #f)))

      (match-here pattern string)))

  ;; Example usage
  ;; Create a RegularExpression object
  (define regex (make <regular-expression> #:pattern "a*b"))

  ;; Output: #t ; Test case 1
  (display (match regex "b"))

  ;; Output: #t ; Test case 2
  (display (match regex "ab"))

  ;; Output: #t ; Test case 3
  (display (match regex "aab"))

  ;; Output: #f ; Test case 4 
  (display (match regex "abb"))

Pumping Lemma   drill automata_theory

The pumping lemma for regular languages is used to prove that certain languages are not regular.

What is the pumping lemma used for?

  • 2024-08-08

Answer

Explanation of the Pumping Lemma

The pumping lemma for regular languages essentially states:

If a language L is regular, then there exists a constant p (the pumping length) such that any string w in L with length at least p can be divided into three substrings x, y, and z (where w = xyz) satisfying these conditions:

  • y is not empty (|y| > 0).
  • The length of xy is at most p (|xy| ≤ p).
  • For all i ≥ 0, the string xy^iz (i.e., pumping y zero or more times) is also in L.

In simpler terms, if a language is regular, any sufficiently long string in that language can be "pumped" (have a middle part repeated) any number of times, and the resulting string will still be in the language.

How the Pumping Lemma is Used

The pumping lemma is typically used in proofs by contradiction to show that a language is not regular. The general strategy is as follows:

  • Assume that the language L is regular
  • Let p be the pumping length given by the pumping lemma
  • Choose a specific string w in L with length at least p
  • Show that for any possible division of w into x, y, and z (satisfying the pumping lemma's conditions), there exists some i such that xy^iz is not in L
  • This contradicts the pumping lemma, proving that our initial assumption was wrong and L is not regular.
TODO Python Illustration

While Python doesn't have direct built-in support for formal language theory proofs, we can illustrate the concept with a code example that attempts to "pump" a string and checks if the result remains in a hypothetical language

def pump_string(w, x, y, z, i):
  """
  Pumps the substring 'y' 'i' times within the string 'w'
  """
  return x + y * i + z

# Hypothetical language L = {0^n 1^n | n >= 0} (not regular)
def is_in_L(s):
  """
  Checks if a string is in the language L
  """
  zeros = s.count('0')
  ones = s.count('1')
  return zeros == ones and s.startswith('0') * zeros and s.endswith('1') * ones

# Attempt to "pump" a string and see if it stays in L
w = '000111'  # String in L
p = 3         # Assume a pumping length

# Try all possible divisions of 'w' into x, y, z
for i in range(len(w) - p + 1):
  for j in range(i + 1, min(i + p + 1, len(w) + 1)):
    x, y, z = w[:i], w[i:j], w[j:]
    if len(y) > 0:  # y must not be empty
      for pump_count in range(2):  # Try pumping y 0 and 1 times
        pumped_string = pump_string(w, x, y, z, pump_count)
        if not is_in_L(pumped_string):
          print(f"Pumping '{y}' {pump_count} times results in '{pumped_string}', which is not in L")
          print("This contradicts the pumping lemma, so L is not regular")
          break 
Key Points
  • The pumping lemma is a powerful tool for proving non-regularity of languages
  • It's often used in conjunction with proof by contradiction
  • Python can be used to illustrate the concept of pumping, but formal proofs require rigorous mathematical reasoning

The pumping lemma is used to demonstrate that certain languages are not regular by showing that they cannot be represented by a finite automaton.

Context-Free Grammar   drill automata_theory

A context-free grammar (CFG) is a set of recursive rewriting rules (or productions) used to generate patterns of strings.

What is a context-free grammar?

  • 2024-08-08

TODO Answer

A context-free grammar is a collection of recursive rewriting rules used to generate patterns of strings, often used in programming languages and compilers.

def generate_string(grammar, symbol):
  """
  Recursively generates a string from the given grammar, starting with the specified symbol.
  """
  if symbol not in grammar:  # Terminal symbol
    return symbol
  else:
    production = random.choice(grammar[symbol])  # Choose a random production
    return ''.join(generate_string(grammar, s) for s in production) 

import random

# Context-free grammar for arithmetic expressions
grammar = {
    'E': ['E + T', 'T'],
    'T': ['T * F', 'F'],
    'F': ['( E )', 'id']
}

# Generate a few example strings
for _ in range(5):
    print(generate_string(grammar, 'E'))

Pushdown Automaton   drill automata_theory

A pushdown automaton (PDA) is a finite automaton that can make use of a stack containing data.

What is a pushdown automaton?

  • 2024-08-08

TODO Answer

A pushdown automaton is a type of finite automaton that utilizes a stack to store additional data, allowing it to recognize context-free languages.

class PushdownAutomaton:
    def __init__(self, states, start_state, accept_states, transitions):
        self.states = states
        self.start_state = start_state
        self.accept_states = set(accept_states)
        self.transitions = transitions

    def accepts_string(self, input_string):
        current_state = self.start_state
        stack = []  # Initialize an empty stack

        for symbol in input_string:
            # Find applicable transitions
            possible_transitions = [(next_state, pop_symbol, push_symbols) 
                                    for (state, input_sym, stack_sym), (next_state, pop_symbol, push_symbols) in self.transitions.items()
                                    if state == current_state and (input_sym == symbol or input_sym == 'ε') 
                                    and (stack_sym == stack[-1] if stack else stack_sym == '$')]

            if not possible_transitions:
                return False  # No valid transition, reject

            # Non-deterministic choice (choose the first one for simplicity)
            next_state, pop_symbol, push_symbols = possible_transitions[0]

            # Update stack
            if pop_symbol != 'ε':
                stack.pop()
            for push_symbol in reversed(push_symbols):
                if push_symbol != 'ε':
                    stack.append(push_symbol)

            current_state = next_state

        # Acceptance condition: in accept state and empty stack
        return current_state in self.accept_states and not stack

pda = PushdownAutomaton(
    states={'q0', 'q1', 'q2', 'q3'},
    start_state='q0',
    accept_states={'q3'},
    transitions={
        ('q0', '0', '$'): ('q1', '$', '0'),   # Push 0 onto the stack for each 0 in the input
        ('q1', '0', '0'): ('q1', '0', '0'),
        ('q1', '1', '0'): ('q2', '0', 'ε'),   # Pop 0 from the stack for each 1 in the input
        ('q2', '1', '0'): ('q2', '0', 'ε'),
        ('q2', 'ε', '$'): ('q3', '$', 'ε')    # Transition to accept state when stack is empty
    }
)

print(pda.accepts_string("0011"))  # True
print(pda.accepts_string("0101"))  # False
print(pda.accepts_string(""))      # True (n=0)
print(pda.accepts_string("000111"))# True

Turing Machine   drill automata_theory

A Turing machine is a mathematical model of computation describing an abstract machine that manipulates symbols on a strip of tape according to a table of rules.

What is a Turing machine?

  • 2024-08-08

Answer

A Turing machine is a theoretical model of computation that manipulates symbols on a tape according to a set of rules, capable of simulating any algorithm.

class TuringMachine:
    def __init__(self, states, start_state, accept_states, reject_states, transitions):
        self.states = states
        self.start_state = start_state
        self.accept_states = set(accept_states) 
        self.reject_states = set(reject_states) 
        self.transitions = transitions

    def run(self, tape):
        current_state = self.start_state
        head_position = 0

        while current_state not in self.accept_states | self.reject_states:
            symbol = tape[head_position]
            transition = self.transitions.get((current_state, symbol))

            if transition is None:
                return False  # No transition defined, reject

            next_state, write_symbol, move_direction = transition
            tape[head_position] = write_symbol
            head_position += 1 if move_direction == 'R' else -1
            current_state = next_state

        return current_state in self.accept_states

# Example Turing Machine to increment a binary number
tm = TuringMachine(
    states={'q0', 'q1', 'q2', 'qa', 'qr'},
    start_state='q0',
    accept_states={'qa'},
    reject_states={'qr'},
    transitions={
        ('q0', '0'): ('q0', '0', 'R'),   # Move right until the end of the input
        ('q0', '1'): ('q0', '1', 'R'),
        ('q0', 'B'): ('q1', 'B', 'L'),   # Start incrementing from the least significant bit
        ('q1', '1'): ('q1', '0', 'L'),   # Carry propagation
        ('q1', '0'): ('q2', '1', 'R'),   # Increment and move back to the start
        ('q1', 'B'): ('qa', '1', 'R'),   # If the entire number was 1s, add a leading 1
        ('q2', '0'): ('q2', '0', 'R'),
        ('q2', '1'): ('q2', '1', 'R'),
        ('q2', 'B'): ('qa', 'B', 'L')    # Reached the end, accept
    }
)

# Test cases
tape = list('101B')  # Represents binary 101
print(tm.run(tape))   # Output: True
print(''.join(tape))  # Output: 110B (incremented)

tape = list('111B')
print(tm.run(tape))   # Output: True
print(''.join(tape))  # Output: 1000B (incremented, added leading 1)

tape = list('0B')
print(tm.run(tape))   # Output: True
print(''.join(tape))  # Output: 1B 

Chomsky Hierarchy   drill automata_theory

The Chomsky hierarchy is a containment hierarchy of classes of formal grammars that generate formal languages.

What is the Chomsky hierarchy?

  • 2024-08-08

Answer

The Chomsky hierarchy is a classification of formal grammars into four types, each generating a different class of formal languages.

Type Grammar Type Language Class Automaton Example  
0 Unrestricted Recursively Enumerable Turing Machine Any computable language  
1 Context-Sensitive Context-Sensitive Linear-bounded Automaton {an bn cn n ≥ 1}
2 Context-Free Context-Free Pushdown Automaton {an bn n ≥ 0}
3 Regular Regular Finite Automaton {an n ≥ 0}
def recognize_string(string):
  """
  Recognizes strings in the language {a^n | n ≥ 0} using a simple DFA.
  """
  current_state = 'q0'  # Start state

  for symbol in string:
    if symbol == 'a':
      if current_state == 'q0':
        current_state = 'q0'  # Stay in q0
      else:
        return False  # Invalid transition, reject
    else:
      return False  # Invalid symbol, reject

  return current_state == 'q0'  # Accept if in q0 after processing the string

# Test cases
print(recognize_string(""))        # True (empty string, n=0)
print(recognize_string("a"))       # True 
print(recognize_string("aa"))      # True
print(recognize_string("aaa"))     # True 
print(recognize_string("ab"))      # False 

P vs NP Problem   drill automata_theory

The P vs NP problem is a major unsolved problem in computer science, asking whether every problem whose solution can be quickly verified can also be solved quickly.

What is the P vs NP problem?

  • 2024-08-08

Answer

The P vs NP problem questions whether every problem that can be verified quickly (in polynomial time) can also be solved quickly, and remains an open question in computer science.

def is_sorted(arr):
    """Checks if an array is sorted in ascending order."""
    for i in range(len(arr) - 1):
        if arr[i] > arr[i + 1]:
            return False
    return True

def sort_list(arr):
    """Sorts a list using a simple bubble sort algorithm (for illustration)."""
    n = len(arr)
    for i in range(n):
        for j in range(0, n - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]

# Example usage
numbers = [5, 2, 8, 1, 9]
sort_list(numbers)
print(is_sorted(numbers))  # Output: True

Author: Jason Walsh

j@wal.sh

Last Updated: 2024-08-14 06:08:49