Как написать парсер скобочной грамматики с заданным набором скобок "<> [] () -+"

Всем привет!

Ради интереса написала парсер c простой скобочной грамматикой (пока без проверки ошибок). Код ниже.

Здесь открывающей скобке (, соответствует закрывающая - ), [ - ], l - r, < - >, то есть порядок в LEFT_BRACKETS и RIGHT_BRACKETS одинаковый. Для задачи все скобки уникальны.

Вопрос: Читала, что это гораздо эффективнее делается помощью конечного автомата, но не могу понять, что для грамматики является состоянием КА, а что переходом? Подскажите, пожалуйста, куда копать, чтобы понять, как это лучше написать.

my_string = "<[(()(()))(()lrl()<><><<<>>>[]r)]>"

class node:
    LEFT_BRACKETS = ["(", "[", "l", "<"]
    RIGHT_BRACKETS = [")", "]", "r", ">"]
    MAX_INT = 100000000000000000000000000000
    is_open: bool
    is_close: bool
    parent: object
    word: str
    bracket_index: int
    children: list
    current_sym_index: int

    def __init__(self, parent):
        self.parent = parent
        self.word = ""
        self.is_open = False
        self.is_close = False
        self.bracket_index = node.MAX_INT
        self.children = []

    def parse(self, symbol):
        if self.is_close:
            return
        if not self.is_open and symbol in node.LEFT_BRACKETS:
            self.is_open = True
            self.bracket_index = node.LEFT_BRACKETS.index(symbol)
        elif self.is_open and self.no_children_open() and symbol in node.RIGHT_BRACKETS and node.RIGHT_BRACKETS[self.bracket_index] == symbol:
            self.is_close = True
            self.is_open = False
        elif self.is_open:
            if symbol in node.LEFT_BRACKETS and self.no_children_open():
                self.children.append(node(self))
            self.word += symbol
            for child in self.children:
                child.parse(symbol)

    def no_children_open(self, no_open = True):
        for child in self.children:
            no_open = no_open and child.is_close
        return no_open

    def print(self):
        print("--" + node.LEFT_BRACKETS[self.bracket_index]
            + node.RIGHT_BRACKETS[self.bracket_index])
        print(self.word)
        for child in self.children:
            child.print()


_node: node = node(None)

for symbol in my_string:
    _node.parse(symbol=symbol)

_node.print()

Ответы (1 шт):

Автор решения: mrgervant

Если ограничиться только проверкой валидности последовательности, то решение со стеком (как описано в комментарии @stanislav-volodarskiy) может быть таким:

class BracketParser:
    def __init__(self, brackets):
        self.brackets = {bracket_type[0]: bracket_type[1]
                         for bracket_type in brackets}

    def is_valid_brackets(self, parse_string):
        stack = []
        for symbol in parse_string:
            if symbol in self.brackets.keys():
                stack.append(self.brackets[symbol])
            if symbol in self.brackets.values():
                if not stack or stack.pop() != symbol:
                    return False
        return stack == []

Причем методу is_valid_brackets не нужно проходить полностью по строке - при выявлении ошибки в процессе выполнения сразу происходит возврат с False.

Так как такой метод менее ресурсоемкий (максимум 1 полное прохождение по строке), то для реального использования можно сначала проверять строку на корректность им, а в случае ошибки - запускать более подробную проверку как у Вас.

У меня получилось написать парсер без использования списка детей, т.к. по ходу работы он максимум пытается вернуться назад по цепи родителей в поисках подходящей более ранней скобки:

class BracketNode:
    def __init__(self, index, bracket, parent):
        self.index = index
        self.bracket = bracket
        self.parent = parent

    def get_node_indices(self):
        indices = [self.index]
        last_node = self.parent
        while last_node:
            indices.append(last_node.index)
            last_node = last_node.parent
        return indices


class BracketParser:
    def __init__(self, brackets):
        self.brackets = {bracket_type[0]: bracket_type[1]
                         for bracket_type in brackets}

    def parse(self, parse_string):
        if self.is_valid_brackets(parse_string, self.brackets):
            return True, []

        parse_errors = self.full_bracket_parse(parse_string, self.brackets)
        if len(parse_errors) > 1:
            parse_errors_from_end = self.full_bracket_parse(
                parse_string[::-1],
                {v: k for k, v in self.brackets.items()}
            )
            reversed_parse_errors = [len(parse_string) - i - 1 for i in parse_errors_from_end]
            parse_errors = min(parse_errors, reversed_parse_errors, key=len)
        return False, sorted(parse_errors)

    @staticmethod
    def is_valid_brackets(parse_string, bracket_mapping):
        stack = []
        for symbol in parse_string:
            if symbol in bracket_mapping.keys():
                stack.append(bracket_mapping[symbol])
            if symbol in bracket_mapping.values():
                if not stack or stack.pop() != symbol:
                    return False
        return stack == []

    @staticmethod
    def full_bracket_parse(parse_string, bracket_mapping):
        error_stack = []
        current_node = None
        for idx, symbol in enumerate(parse_string):
            if symbol in bracket_mapping.keys():
                current_node = BracketNode(idx, bracket_mapping[symbol], current_node)
            if symbol in bracket_mapping.values():
                probable_error_stack = []
                last_node = current_node
                while last_node:
                    if symbol == last_node.bracket:
                        current_node = last_node.parent
                        error_stack.extend(probable_error_stack)
                        break
                    probable_error_stack.append(last_node.index)
                    last_node = last_node.parent
                else:
                    error_stack.append(idx)
        if current_node:
            error_stack.extend(current_node.get_node_indices())
        return error_stack

Если строка определяется как невалидная быстрым методом, то вызываем метод полной проверки с учетом предшествующих скобок:

  • Храним последний узел (в самом начале None) и накапливаем error_stack с индексами ошибочных скобок;
  • Если скобка левая - создаем новый узел с ссылкой на прошлый актуальный (у первого корневого в последовательности будет None) и указанием требуемой правой скобки;
  • Если скобка правая - входим в цикл итерации от последнего узла до корневого (по parent is None) и проверяем, совпадает ли символ с ранее сохраненной скобкой:
    • Если не находит совпадение до родительского включительно, то считаем проверяемую скобку некорректной. Оставляем текущий узел нетронутым;
    • Если в процессе находит совпадение, то считаем некорректными скобки в промежутке между проверяемой и найденной (возможно и нет промежутка, если совпадение было сразу же). Меняем узел на родителя удачного узла (по сути забываем про успешный узел) - в т.ч. текущий узел может стать обратно None, если совпадение было с корневым.
  • В случае, если по окончании проверки строки последний узел не None, значит в нём накоплены невостребованные левые скобки - получаем их индексы и добавляем к error_stack.

В результате получаем перечень индексов некорректных скобок из исходной строки и можем как показать ошибки, так и исправить их, например, такими методами (со @staticmethod добавляется к классу парсера, а без них - используется просто как функции):

@staticmethod
def get_error_string(origin_string, error_positions):
    if not error_positions:
        return f'{origin_string}\nNo error symbols'
    error_string = ''
    for idx, symbol in enumerate(origin_string):
        error_string += '^' if idx in error_positions else ' '
    return f'{origin_string}\n{error_string}'

@staticmethod
def get_valid_string(origin_string, error_positions):
    if not error_positions:
        return origin_string
    valid_string = ''
    for idx, symbol in enumerate(origin_string):
        valid_string += symbol if idx not in error_positions else ''
    return valid_string or 'No valid symbols'

Пример вызова:

_brackets = ['()', '[]', '<>']
parser = BracketParser(_brackets)
_string = '<[(((()(()))(()()<><><<<>>>[])]>'
is_valid, errors = parser.parse(_string)
print(parser.get_error_string(_string, errors))

# <[(((()(()))(()()<><><<<>>>[])]>
#   ^^

Но с таким алгоритмом есть нюанс - в основе лежит приоритет предыдущих скобок, а возможные будущие никак не учитываются. К чему это приводит:

# Добавим внутрь последовательности круглых скобок квадратную
_string = '<[(()(()))(()([)<><><<<>>>[])]>'
# <[(()(()))(()([)<><><<<>>>[])]>
#               ^
# Всё нормально, указывает на корректную лишнюю скобку

# "Повернем" скобку
_string = '<[(()(()))(()(])<><><<<>>>[])]>'
# <[(()(()))(()(])<><><<<>>>[])]>
#           ^  ^ ^            ^^ 
# Алгоритм преобразует к следующей последовательности:
# <[(()(()))()]<><><<<>>>[]>

Сама последовательность верная, но операций по исправлению требуется намного больше, чем могло бы быть. Для того, чтобы отчасти это исправить парсинг делается как с начала строки, так и с конца (если в первом случае требуется более 1 исправления) - из них выбирается вариант с наименьшим числом исправлений.

В результате получается более приемлемый результат:

_string = '<[(()(()))(()([)<><><<<>>>[])]>'
# <[(()(()))(()(])<><><<<>>>[])]>
#               ^ 

Но всё ещё можно "споткнуться" о такой вариант:

_string = '[((][)())]'
# [((][)())]
#  ^^  ^  ^ 
# Здесь достаточно убрать ][
→ Ссылка