如何在 JavaScript 中使用文字状态和转换实现基本的迭代下推自动机解析算法? 1.对于通用引擎2.对于特定的 +/- 输入格式

问题描述

我们希望创建一个下推自动机 (PDA),它使用以下“字母”(字母表我指的是一组唯一的符号字符串/键):

+aaa
+bbb
+ccc
+ddd
+eee
-aaa
-bbb
-ccc
-ddd
-eee

此字母表中的“符号”(带有 + 或 - 前缀的 3 个字母序列)用于制作树。以下是一些示例树:

+eee-eee
+aaa+bbb-bbb-aaa
+bbb+aaa+ccc+eee-eee-ccc+ccc-ccc+ddd+ccc+eee-eee-ccc-ddd-aaa-bbb

可视化为一棵实际的树,它们更像是:

+eee
-eee

+aaa
  +bbb
  -bbb
-aaa

+bbb
  +aaa
    +ccc
      +eee
      -eee
    -ccc
    +ccc
    -ccc
    +ddd
      +ccc
        +eee
        -eee
      -ccc
    -ddd
  -aaa
-bbb

鉴于这个字母表和这些示例树,问题是如何编写一个通用的下推自动机来解析这些树。规则是:

  1. 任何字母对(开/关对)都可以有任意数量的嵌套子项,并且将哪些字母对嵌套为子项并不重要。

您将如何用 JavaScript 编写一个下推自动机来将字符串解析为 AST?

我的意思是,实现必须字面上有 stackstatestransitions。我的意思是,不实现临时解析器,甚至不实现递归下降解析器。这应该是一个迭代 while 循环以某种方式循环转换,而不是使用递归。

输出应该是一个非常简单的“AST”,看起来像这样(对于 +aaa+bbb-bbb-aaa 树):

{
  "type": "aaa","children": [
    {
      "type": "bbb","children": []
    }
  ]
}

我想知道如何构建 PDA,以便在我使用自定义编程语言的特定情况下,转换我正在工作的相当复杂的 AST 并将其解析为对象图。这个问题太复杂了,无法在 SO 上写出来,也很难简化,这就是为什么我在这里询问 JavaScript 中非常基本的 PDA 实现。

我很想知道您在执行 PDA 算法时如何跟踪每个分支点的上下文以及转换是什么样的。

注意:我听说过/看到过这里和那里偶尔会提到的“两态”PDA。听起来你有一个状态来进行计算,还有一个“最终”状态。甚至可能只是一个 single state。如果可以这样做,那就太酷了。如果没有,请不要担心。

如有必要,您可以先编写上下文无关文法并使用它来动态构建 PDA,但这里的关键问题是 PDA 是如何在技术上实现的。我认为手工编写每个转换函数可能/直接,但我不完全确定。无论哪种方式都适合我。

解决方法

无法创建用于生成此树数据结构的 PDA,因为:

  • PDA 具有有限数量的状态和堆栈符号,并且没有输出磁带。然而,这个 PDA 应该能够以某种方式表示(并提供)的树的数量是无限的。如果我们在面向对象的结构中考虑树的节点,那么可能有无数个不同的节点,因为节点的身份也由它所拥有的引用决定。这与 PDA 具有的有限符号集相冲突。如果作为替代方案,我们不会选择面向对象的输出,那么我们将一无所获:输入已经是树的序列化表示。
  • 即使您将输出磁带添加到此自动机,使其成为 rewrite system,您也不会获得太多收益,因为输入已经以序列化格式表示树。如果我理解正确,您对序列化输出(如 JSON,因为输出顺序与输入顺序相同,这很简单)不感兴趣,但对结构化输出(类 JavaScript 对象)不感兴趣。

因此,正如其他人所指出的,我们需要以某种方式放松规则。我们可以想到几种(组合)方法来做到这一点:

  1. 允许无限多个状态,或
  2. 允许无限堆叠字母表,或
  3. 允许 PDA 访问堆栈的更多内容,而不仅仅是其顶部元素
  4. 让转换表引用堆栈顶部元素的特定属性,而不是整个元素——允许堆栈元素的其他属性有无限可能,同时确保这个特定的属性属于一个有限集。
  5. 将树建筑保持在 PDA 之外
  6. ...用于解决 PDA 与生成结构化树的要求不兼容的任何其他措施。

其中一些选项会推断出无限转换表,因此在这种情况下,它不能是表,而可以是函数。

实施选择

1.对于通用引擎

考虑到您对树构建的关注以及对“堆栈、状态和转换”的要求,我选择了以下实现选择,有时还进行了简化:

  • 我从上面的列表中选择了策略 4
  • 通用引擎将自己创建树。所以它只是在有限的意义上是通用的。它只能验证和生成树。
  • 它将(仅)将转换表作为配置输入
  • 它只有两个状态(布尔值),用于指示到目前为止一切是否正常。
  • 只有在转换表中没有匹配的转换时,OK 状态才能从真变为假
  • 一旦 OK 状态为假,引擎甚至不会尝试找到转换,而只会忽略任何进一步的输入。
  • 因此,转换表将不包括当前状态和未来状态部分,因为它们都被暗示为真(即“OK”)。
  • 树将在堆栈中编码。堆栈元素的 data 属性将是用于识别转换的特殊属性。每个堆栈元素的 children 属性将用于定义树,并且不被视为转换表所针对的堆栈字母表的一部分。
  • 堆栈从一个元素开始。它的 children 属性将是引擎的输出。在每一步都可以参考这个输出,因为这个元素永远不会从堆栈中删除。
  • 输入符号不能包含空字符串,该字符串是为表示输入流结束而保留的。这允许引擎给出反馈是否是结束输入流的有效时刻。
  • 当指示输入结束时,引擎要求其堆栈为“空”(只有 1 个条目)。
  • 我假设输入符号不包含空格:空格在转换查找算法中用作分隔符
  • 将给定的转换表转换为(散列)映射,以便在给定当前状态和堆栈顶部的数据的情况下进行快速查找。该映射中的键是输入和堆栈数据值的串联,在这里选择空格作为分隔符。如果空格是输入符号中的有效字符,则应选择另一种编码机制来序列化此密钥对(例如 JSON),但我想保持简单。
  • 引擎不需要获取输入符号集,也不需要获取堆栈符号集作为配置输入,因为这些将从转换表中隐含。
  • 引擎的初始状态设置为 OK(它可以通过简单的属性分配进行不同的设置,但这将无用,因为它会忽略输入)

2.对于特定的 +/- 输入格式

解决方案的更具体部分涉及您在问题中给出的实际输入格式。一个函数将类型集转换为转换表,另一个函数将基于“+”和“-”字符标记输入,但没有任何验证(没有符号检查,也没有符号长度检查,...),因为在调用引擎时,无论如何这都会显示为错误。

输入中的任何空格都将被忽略。

实施

// To peek the top element of a stack:
Array.prototype.peek = function () { return this[this.length - 1] };

function createParser(transitions) {
    function createNode(data) {
        return { 
            data,children: [] 
        };
    }

    function addChild(parentNode,data) {
        const childNode = createNode(data);
        parentNode.children.push(childNode);
        return childNode;
    }

    let isOK = true; // It's a 2-state (boolean) engine. Transitions implicitly only apply to OK-state
    const stack = [createNode("")]; // Stack is private,and always has Sentinel node with value ""
    // Create a map for the transitions table,for faster lookup
    // We use space as a delimiter for the key pair,assuming an input symbol does not include spaces.
    const transMap = new Map(transitions.map(({whenInput,whenStack,thenPushValue}) =>
        [whenInput + " " + whenStack,thenPushValue]
    ));
    const parser = {
        read(input) { // Returns true when parser can accept more input after this one
            // Once the engine is in an error state,it will not process any further inputs
            if (!isOK) {
                return false;
            }
            // Consider the empty string as the end-of-input marker
            if (input === "") { 
                // Even when state is OK,the stack should now also be "empty"
                isOK &&= stack.length === 1;
                return false; // Not an error condition,but indication that no more input is expected
            }
            // Transitions do not include state requirements,nor new state definitions.
            // It is assumed that a transition can only occur in an OK state,and that all 
            //    included transitions lead to an OK state.
            const pushValue = transMap.get(input + " " + stack.peek().data);
            if (pushValue === undefined) { // No matching transition in the table implies that state is not OK
                isOK = false;
            } else {
                // As this is a two-state engine,with defined transitions only between OK states,// each defined transition will affect the stack: so it's either a push or pop.
                // A push is identified by the (non-empy) value to be pushed. An empty string denotes a pop.
                if (pushValue) {
                    stack.push(addChild(stack.peek(),pushValue));
                } else {
                    stack.pop();
                }
            }
            
            return isOK;
        },isOK,// Expose the (boolean) state
        output: stack[0].children // Don't expose the stack,but just the forest encoded in it
    };
    return parser;
}

function createTransition(whenInput,thenPushValue) {
    return {whenInput,thenPushValue}; // First two values imply the third
}

// Functions specific for the input format in the question:

function createTransitionTable(types) {
    // Specific to the input structure (with + and - tags) given in the question
    // An empty string in the second column represents an empty stack
    return [
        // Transitions for opening tags: O(n²)
        ...["",...types].flatMap(stack => 
            types.map(type => createTransition("+" + type,stack,type))
        ),// Transitions for closing tags
        ...types.map(type => createTransition("-" + type,type,""))
    ];
}

function tokenizer(str) { // Could be a generator,but I went for a function-returning function
    str = str.replace(/\s+/g,""); // remove white space from input string

    let current = 0; // Maintain state between `getNextToken` function calls
    
    function getNextToken() {
        const start = current++;
        while (current < str.length && str[current] !== "+" && str[current] !== "-") {
            current++;
        }
        const token = str.slice(start,current);
        
        console.log("read",token); // For debugging
        return token;
    }
    return getNextToken;
}

// Example input language,as defined in the question:
const types = ["aaa","bbb","ccc","ddd","eee"];
const transitionTable = createTransitionTable(types);
const parser = createParser(transitionTable);

// Example input for it:
const rawInput = `
+eee-eee
+aaa+bbb-bbb-aaa
+bbb+aaa+ccc+eee-eee-ccc+ccc-ccc+ddd+ccc+eee-eee-ccc-ddd-aaa-bbb`;
const getNextToken = tokenizer(rawInput);

// Process tokens
while (parser.read(getNextToken())) {}

console.log("Parser state is OK?: ",parser.isOK);
console.log("Parser output:\n");
console.log(JSON.stringify(parser.output,null,3));

,

首先我应该指出,我不是计算机科学家,也没有编写编译器代码的实际经验。所以在实现甚至基本思想上可能存在明显的漏洞。但是,如果您想了解一个发现这是一个有趣问题的日常程序员的想法,这里是。


我们可以编写一个 pda 函数来简单地识别我们的语法,我们可以像这样使用它。 (这里我们仅从 aaaccc,但您可以轻松地将其扩展为 eee 或其他任何内容。)

const {push: PUSH,pop: POP} = pda

const myParser = pda ('S',['S'],[
//                     ^     ^
//                     |     `----------------- accepting states
//                     +----------------------- initial state
//   +----------------------------------------- current state
//   |        +-------------------------------- token
//   |        |    +--------------------------- top of stack
//   |        |    |      +-------------------- new state
//   |        |    |      |         +---------- stack action
//   V        V    V      V         V
  [ 'S','+aaa','','A',PUSH ('A') ],[ 'S','+bbb','B',PUSH ('B') ],'+ccc','C',PUSH ('C') ],[ 'A','-aaa','AA',POP        ],'BA','CA','S',[ 'B','-bbb','AB','BB','CB',[ 'C','-ccc','AC','BC','CC',])

我们将使用它来测试一系列令牌,如下所示:

myParser (['+aaa','-aaa']) //=> true
myParser (['+aaa','-bbb']) //=> false
myParser (['+aaa','-aaa']) //=> true

这并不完全符合 PDA 的数学定义。我们没有符号来描述堆栈的开头,我们测试堆栈的顶部两个值,而不仅仅是顶部的值。但它相当接近。

然而,这只是报告语法中是否包含一串标记。你想要的不止这些。您需要使用它来构建语法树。很难抽象地看出如何做到这一点。但是从您可以使用的解析中生成一系列事件是很容易的。一种方法是在每次推入堆栈时捕获新的节点值,并从堆栈中捕获每次弹出。

有了这个,我们可能会联系到这样的事情:

const forestBuilder = () => {  // multiple-rooted,so a forest not a tree
  const top = (xs) => xs [ xs .length - 1 ]
  const forest = {children: []}
  let stack = [forest]
  return {
    push: (name) => {
      const node = {name: name,children: []}
      top (stack) .children .push (node)
      stack.push (node)
     },pop: () => stack.pop(),end: () => forest.children
  }
}

const {push,pop,end} = forestBuilder ()


push ('aaa')
push ('bbb')
pop ()
push ('ccc')
push ('aaa')
pop()
pop()
pop()
push ('bbb')
push ('aaa')
end()

这会产生这样的结果:

[
    {
        "name": "aaa","children": [
            {
                "name": "bbb","children": []
            },{
                "name": "ccc","children": [
                    {
                        "name": "aaa","children": []
                    }
                ]
            }
        ]
    },{
        "name": "bbb","children": [
            {
                "name": "aaa","children": []
            }
        ]
    }
]

因此,如果我们为 pda 函数提供一些用于推送和弹出(也用于完成和错误)的事件侦听器,我们可能能够从一系列令牌构建您的树。

这是一种尝试:

console .clear ()

const pda = (() => {
  const PUSH = Symbol(),POP = Symbol()
  const id = (x) => x
  return Object .assign (
    (start,accepting,transitions) => 
      (tokens = [],onPush = id,onPop = id,onComplete = id,onError = () => false) => {
        let stack = []
        let state = start
        for (let token of tokens) {
          const transition = transitions .find (([st,tk,top]) => 
            st == state && 
            tk == token &&
            (top .length == 0 || stack .slice (-top.length) .join ('') == top)
          )
          if (!transition) {
            return onError (token,stack)
          }
          const [,nst,action] = transition
          state = nst
          action (stack)
          if (action [PUSH]) {onPush (token)}
          if (action [POP]) {onPop ()}
        }
      return onComplete (!!accepting .includes (state))
    },{
      push: (token) => Object.assign((stack) => stack .push (token),{[PUSH]: true}),pop: Object.assign ((stack) => stack .pop (),{[POP]: true}),}
  )
})()

const {push: PUSH,])


const forestBuilder = () => {
  const top = (xs) => xs [ xs .length - 1 ]
  const forest = {children: []}
  let stack = [forest]
  return {
    push: (name) => {
      const node = {name: name .slice (1),end} = forestBuilder ()

console .log (myParser (
  ["+ccc","-ccc","+aaa","+bbb","-bbb","-aaa","+ccc","-bbb"],push,(accepted) => accepted ? end () : 'Error: ill-formed',(token,stack) => `Error: token = '${token}',stack = ${JSON.stringify(stack)}`
))
.as-console-wrapper {max-height: 100% !important; top: 0}

这有很多种方式。也许打开事件不仅应该包含令牌,还应该包含压入堆栈的值。可能有一种很好的方法可以从更具声明性的语法生成该转换表。我们可能想要一个不同版本的堆栈操作列,一个接受字符串而不是接受函数的版本。等等。但这仍然可能是一个不错的开始。

,

在伪代码中,DPDA 可以这样实现:

transitions <- map from (state,char,char) to (state,char[0-2])
stack <- stack initialized with a sentinel value 'Z'
input <- string of tokens to parse
currentState <- initial state

for each inputCharacter in input: 
  stackCharacter <- stack.pop()
  currentState,charsToPush <- transitions[(currentState,inputCharacter,stackCharacter)]
  if currentState is not valid:
    return false
  for charToPush in charsToPush.reverse():
    stack.push(charToPush)

return (currentState in acceptingStates) and (stack contains only 'Z')

括号匹配等PDA是这样指定的:

transitions <- {
  (0,'(','Z') -> (0,"(Z"),(0,'(') -> (0,"(("),')','Z') -> nope,""),}
acceptingStates <- [0]
initialState <- 0

请注意,以上是确定性的。一般 PDA 是不确定的,并非所有上下文无关语言都可以由 DPDA 决定。您可以,并且必须注意您如何指定转换。

为了使其更通用(非确定性),转换映射需要映射到 (state,char[]) 元组列表而不是一个;循环中的每一步都需要考虑所有匹配的元组,而不仅仅是一个。

这有帮助吗?


特别是对于您的语法,您的标记是这些“+aaa”、“-aaa”的东西。您的字母表是有限的但非常大,因此您不必在过渡图中指定所有内容。因此,您必须做出一个决定:您想要一个纯粹的 PDA(完全指定的地图)还是想要编写一个类似于 PDA 的东西而不是 PDA 来避免这种情况?

如果是后者,您希望在循环中添加与 + 和 - 标记的标识匹配的检查。此时您不妨编写自己的代码,因为创建一个可以处理所有事情的通用解析器是一项繁重的工作。为您的特定需求编写解析器会更容易。

这就是人们创建像flap.js这样的库的原因,因为这些东西很复杂。


详细说明我所做的评论,如果您将转换功能设为任意而不是地图,则可以通过这种方式表达您的语言。

transition <- arbitrary function taking input (state,token,token) and output (state,token[0-2])
stack <- stack initialized with a sentinel token 'Z'
input <- string of tokens to parse
currentState <- initial state

for each inputToken in input: 
  stackToken <- stack.pop()
  currentState,tokensToPush <- transition(currentState,inputToken,stackToken)
  if currentState is not valid:
    return false
  for tokenToPush in tokensToPush.reverse():
    stack.push(tokenToPush)

return (currentState in acceptingStates) and (stack contains only 'Z')

像这样定义过渡:

function transition(state,input,stack):
  if (input.type = +) 
    return (0,[input,stack])
  else if (input.id = stack.id)
    return (0,[])
  else
    return nope

其中您的令牌具有“类型”(+ 或 -)和“ID”(“aaa”、“bbb”等)。

您必须小心使用任意转换函数,因为现在解决方案的约束更少,更有可能意外地与上下文无关。

,

以下解决方案分三个阶段工作:

  1. 输入字符串被标记为序列和前缀
  2. 标记化的结果作为数组,通过转换规则转换为 AST
  3. AST 转换为所需的 JSON 输出。

首先,定义标记、转换和标记化函数:

@jit(nopython=True)
def find_rms(data,length):
    res = []
    for i in range(length,len(data)):
        interval = data[i-length:i]
        interval = np.power(interval,2)
        sum = interval.sum()
        resI = sum/length
        resI = np.sqrt(resI)
        res.append(resI)
    return res

mydf = np.array(df.iloc[:]['c0'],dtype=np.float64)
target = find_rms(mydf,350)

接下来,定义一个函数,该函数接受标记化的字符串并在 class Token{ constructor(vals,t_type){ this.vals = vals; this.t_type = t_type } type_eq(t_type){ return this.t_type === t_type } static val_eq(t1,t2){ //check if two tokens objects t1 and t2 represent a +tag and a -tag return t1.t_type === 'o_tag' && t2.t_type === 'c_tag' && t1.vals[1].vals[0] === t2.vals[1].vals[0] } } var lexer = [[/^\-/,'minus'],[/^\+/,'plus'],[/^[a-z]+/,'label']]; var transitions = [{'pattern':['plus','label'],'result':'o_tag','t_eq':false},{'pattern':['minus','result':'c_tag',{'pattern':['o_tag','c_tag'],'result':'block','t_eq':true},'block',{'pattern':['block','block'],'t_eq':false}] function* tokenize(s){ //tokenize an input string `s` //@yield Token object while (s.length){ for (var [p,t] of lexer){ var m = s.match(p) if (m){ yield (new Token([m[0]],t)) s = s.substring(m[0].length) break } } } } 上运行迭代 shift-reduce 以自底向上构建 AST:

stack

最后,一个将 AST 转换为 JSON 的函数:

function pattern_match(stack,pattern){
    //takes in the stack from `shift_reduce` and attempts to match `pattern` from the back
    if (pattern.length > stack.length){
       return false
    }
    return Array.from(Array(pattern.length).keys()).every(x => stack[stack.length-1-x].type_eq(pattern[pattern.length - 1-x]))
}
function shift_reduce(tokens){
    //consumes `tokens` until empty and returns the resulting tree if in a valid state
    var stack = []
    while (true){
        //per your comment,the line below displays the contents of the stack at each iteration
        console.log(stack.map(x => x.t_type))
        if (!stack.length){
           //stack is empty,push a token on to it
           stack.push(tokens.shift())
        }
        var f = false;
        for (var {pattern:p,result:r,t_eq:tq} of transitions){
            //try to match patterns from `transitions`
            if (pattern_match(stack,p)){
                var new_vals = p.map(_ => stack.pop()).reverse();
                if (!tq || Token.val_eq(new_vals[0],new_vals[new_vals.length-1])){
                    //match found
                    f = true
                    stack.push((new Token(new_vals,r)))
                    break
                }
                else{
                    while (new_vals.length){
                       stack.push(new_vals.shift())
                    }
                }
            }
        }
        if (!f){
           if (!tokens.length){
              //no more tokens to consume,return root of the token tree.                       
              if (stack.length > 1){ 
                 //stack was not reduced to a single token,thus an invalid state
                 throw new Error('invalid state')
              }
              return stack[0]
            }
            //no match found,push another token from `tokens` onto the stack
            stack.push(tokens.shift())
        }
    }
}

综合起来:

function* to_json(tree){
   if (tree.vals.every(x => x.t_type === 'block')){
      for (var i of tree.vals){
         yield* to_json(i)
      }
   }
   else{
       yield {'type':tree.vals[0].vals[1].vals[0],...(tree.vals.length === 2 ? {} : {'children':[...to_json(tree.vals[1])]})}
   }
}

输出:

function to_tree(s){
   var tokens = [...tokenize(s)] //get the tokenized string
   var tree = shift_reduce(tokens) //build the AST from the tokens
   var json_tree = [...to_json(tree)] //convert AST to JSON
   return json_tree
}
console.log(to_tree('+eee-eee'))
console.log(to_tree('+aaa+bbb-bbb-aaa'))
console.log(to_tree('+bbb+aaa+ccc+eee-eee-ccc+ccc-ccc+ddd+ccc+eee-eee-ccc-ddd-aaa-bbb'))
[
    {
        "type": "eee"
    }
]
[
    {
        "type": "aaa","children": [
            {
                "type": "bbb"
            }
        ]
    }
]
,

我同意@trincot 的回答(除了他声称它不是 PDA)。

我不确定复杂的模式,但您拥有的简单模式几乎可以轻松构建机器。它是一个 DCFG(确定性上下文无关文法),即它是正则表达式和 Dyck(括号匹配)机器的交集。所有 DCFG 都对 PDA 进行编码。因此,我不同意他说它不是 PDA。

它是一个正则表达式,因为您的“标记”(括号)不是单个字符长,因此您需要将这些字符序列转换为单个符号标记的正则表达式正确。 +aaa -> 一个标记,例如 '(',-aaa -> another token ')',+bbb -> 还有另一个 '[',... 注意我为标记选择的字符不是任意的(尽管它们可以是),而是帮助您将其可视化为括号匹配。注意它们是如何配对的。

您的标记列表将是有限的(您的字符串,而无界的仍然是有限的)。并且,将有两种(或三种,类型的令牌)。会有左括号(括号等)和右括号,以及两者都没有的东西(即不需要匹配)。在左括号中,您将某些东西压入堆栈。在右括号中,您弹出堆栈。两者都不是,你要么忽略堆栈,要么同时推送和弹出——这两种模型都有效。

运行机器的 FSM 需要每对一个状态。在推送时,您会进入该状态,这会告诉您需要查看哪种令牌才能弹出它。如果您看到不同的弹出令牌,则表示有错误。

现在,只要你的token类型很容易分为这三种token,问题就不大了。如果您的标记,例如,如果您正在寻找没有中点标记的回文,(即某些标记可​​以同时是左括号和右括号,并且您无法从左上下文中分辨出它是哪个),问题变得不确定,您将需要实现一个 GLR 类型解析器,以保留仍然是候选的备选方案的解析森林(如果输入不明确,最终会得到不止一个可能的树)。

但是,我认为如果您尝试解析 AST,就不会有这个问题。在parens级别,您确实有一个非常简化的SLR(最基本的LR解析算法)版本。并且序列到正则表达式的转换也很可能是微不足道的,因为它们只是一组固定的字符串。