问题描述
我们希望创建一个下推自动机 (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
鉴于这个字母表和这些示例树,问题是如何编写一个通用的下推自动机来解析这些树。规则是:
- 任何字母对(开/关对)都可以有任意数量的嵌套子项,并且将哪些字母对嵌套为子项并不重要。
您将如何用 JavaScript 编写一个下推自动机来将字符串解析为 AST?
我的意思是,实现必须字面上有 stack
、states
和 transitions
。我的意思是,不实现临时解析器,甚至不实现递归下降解析器。这应该是一个迭代 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 对象)不感兴趣。
因此,正如其他人所指出的,我们需要以某种方式放松规则。我们可以想到几种(组合)方法来做到这一点:
- 允许无限多个状态,或
- 允许无限堆叠字母表,或
- 允许 PDA 访问堆栈的更多内容,而不仅仅是其顶部元素
- 让转换表引用堆栈顶部元素的特定属性,而不是整个元素——允许堆栈元素的其他属性有无限可能,同时确保这个特定的属性属于一个有限集。
- 将树建筑保持在 PDA 之外
- ...用于解决 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
函数来简单地识别我们的语法,我们可以像这样使用它。 (这里我们仅从 aaa
到 ccc
,但您可以轻松地将其扩展为 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”等)。
您必须小心使用任意转换函数,因为现在解决方案的约束更少,更有可能意外地与上下文无关。
,以下解决方案分三个阶段工作:
- 输入字符串被标记为序列和前缀
- 标记化的结果作为数组,通过转换规则转换为 AST
- 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解析算法)版本。并且序列到正则表达式的转换也很可能是微不足道的,因为它们只是一组固定的字符串。