Resulta que este tipo de análisis es de interés para mí también, por lo que he hecho un poco más de trabajo en ella.
Parece haber un sentimiento de que cosas como la simplificación de expresiones es difícil. No estoy muy seguro. Echemos un vistazo a una solución bastante completa. (La impresión de expresiones tn
no es útil para mí, y ya tiene varios ejemplos de Scala, así que lo omitiré)
Primero, necesitamos extraer las diversas partes del idioma.Seleccionaré expresiones regulares, aunque también se pueden usar los combinadores de analizadores:
object OpParser {
val Natural = "([0-9]+)"r
val Number = """((?:-)?[0-9]+(?:\.[0-9]+)?(?:[eE](?:-)?[0-9]+)?)"""r
val Variable = "([a-z])"r
val Unary = "(exp|sin|cos|tan|sqrt)"r
val Binary = "([-+*/^])"r
val Nary = "(sum|prod|list)"r
Bastante sencillo. Definimos las diversas cosas que pueden aparecer. (He decidido que las variables definidas por el usuario solo pueden ser una sola letra minúscula, y que los números pueden ser de coma flotante ya que tiene la función exp
). El r
al final significa que esta es una expresión regular, y dará nosotros las cosas entre paréntesis.
Ahora tenemos que representar nuestro árbol. Hay varias maneras de hacerlo, pero elegiré una clase base abstracta con expresiones específicas como clases de casos, ya que esto hace que la coincidencia de patrones sea fácil. Además, es posible que deseemos una buena impresión, por lo que anularemos toString
. En su mayoría, sin embargo, usaremos funciones recursivas para hacer el trabajo pesado.
abstract class Expr {
def text: String
def args: List[Expr]
override def toString = args match {
case l :: r :: Nil => "(" + l + " " + text + " " + r + ")"
case Nil => text
case _ => args.mkString(text+"(", ",", ")")
}
}
case class Num(text: String, args: List[Expr]) extends Expr {
val quantity = text.toDouble
}
case class Var(text: String, args: List[Expr]) extends Expr {
override def toString = args match {
case arg :: Nil => "(" + text + " <- " + arg + ")"
case _ => text
}
}
case class Una(text: String, args: List[Expr]) extends Expr
case class Bin(text: String, args: List[Expr]) extends Expr
case class Nar(text: String, args: List[Expr]) extends Expr {
override def toString = text match {
case "list" =>
(for ((a,i) <- args.zipWithIndex) yield {
"%3d: %s".format(i+1,a.toString)
}).mkString("List[\n","\n","\n]")
case _ => super.toString
}
}
Sobre todo esto es bastante aburrido - cada clase caso anula la clase base, y el text
y args
rellenar automáticamente para el def
. Tenga en cuenta que he decidido que un list
es una función n-aria posible, y que se imprimirá con números de línea. (La razón es que si tiene varias líneas de entrada, a veces es más conveniente trabajar con todas juntas como una expresión; esto les permite ser una función).
Una vez definidas nuestras estructuras de datos, debemos analizar las expresiones. Es conveniente representar las cosas para analizar como una lista de tokens; como analizamos, devolveremos tanto una expresión como los tokens restantes que no hemos analizado; esta es una estructura particularmente útil para el análisis recursivo. Por supuesto, es posible que no analicemos nada, por lo que es mejor incluirlo también en Option
.
def parse(tokens: List[String]): Option[(Expr,List[String])] = tokens match {
case Variable(x) :: "=" :: rest =>
for ((expr,remains) <- parse(rest)) yield (Var(x,List(expr)), remains)
case Variable(x) :: rest => Some(Var(x,Nil), rest)
case Number(n) :: rest => Some(Num(n,Nil), rest)
case Unary(u) :: rest =>
for ((expr,remains) <- parse(rest)) yield (Una(u,List(expr)), remains)
case Binary(b) :: rest =>
for ((lexp,lrem) <- parse(rest); (rexp,rrem) <- parse(lrem)) yield
(Bin(b,List(lexp,rexp)), rrem)
case Nary(a) :: Natural(b) :: rest =>
val buffer = new collection.mutable.ArrayBuffer[Expr]
def parseN(tok: List[String], n: Int = b.toInt): List[String] = {
if (n <= 0) tok
else {
for ((expr,remains) <- parse(tok)) yield { buffer += expr; parseN(remains, n-1) }
}.getOrElse(tok)
}
val remains = parseN(rest)
if (buffer.length == b.toInt) Some(Nar(a,buffer.toList), remains)
else None
case _ => None
}
en cuenta que utilizamos coincidencia de patrones y la recursividad para hacer la mayor parte del trabajo pesado - que escoger de parte de la lista, calcular la cantidad de argumentos que necesitamos, y pasar a lo largo de los recursiva. La operación N-ary es un poco menos amigable, pero creamos una pequeña función recursiva que analizará N cosas a la vez para nosotros, almacenando los resultados en un buffer.
Por supuesto, esto es un poco desagradable de usar, por lo que añadir algunas funciones de contenedor que nos permiten interconectar con ella muy bien:
def parse(s: String): Option[Expr] = parse(s.split(" ").toList).flatMap(x => {
if (x._2.isEmpty) Some(x._1) else None
})
def parseLines(ls: List[String]): Option[Expr] = {
val attempt = ls.map(parse).flatten
if (attempt.length<ls.length) None
else if (attempt.length==1) attempt.headOption
else Some(Nar("list",attempt))
}
Bien, ahora, ¿qué pasa con la simplificación? Una cosa que podríamos querer hacer es simplificación numérica, donde precomputamos las expresiones y reemplazamos la expresión original con la versión reducida de la misma. Eso suena como una especie de operación recursiva: encontrar números y combinarlos. En primer lugar tenemos algunas funciones de ayuda a hacer cálculos en números:
def calc(n: Num, f: Double => Double): Num = Num(f(n.quantity).toString, Nil)
def calc(n: Num, m: Num, f: (Double,Double) => Double): Num =
Num(f(n.quantity,m.quantity).toString, Nil)
def calc(ln: List[Num], f: (Double,Double) => Double): Num =
Num(ln.map(_.quantity).reduceLeft(f).toString, Nil)
y luego hacemos la simplificación:
def numericSimplify(expr: Expr): Expr = expr match {
case Una(t,List(e)) => numericSimplify(e) match {
case n @ Num(_,_) => t match {
case "exp" => calc(n, math.exp _)
case "sin" => calc(n, math.sin _)
case "cos" => calc(n, math.cos _)
case "tan" => calc(n, math.tan _)
case "sqrt" => calc(n, math.sqrt _)
}
case a => Una(t,List(a))
}
case Bin(t,List(l,r)) => (numericSimplify(l), numericSimplify(r)) match {
case (n @ Num(_,_), m @ Num(_,_)) => t match {
case "+" => calc(n, m, _ + _)
case "-" => calc(n, m, _ - _)
case "*" => calc(n, m, _ * _)
case "/" => calc(n, m, _/_)
case "^" => calc(n, m, math.pow)
}
case (a,b) => Bin(t,List(a,b))
}
case Nar("list",list) => Nar("list",list.map(numericSimplify))
case Nar(t,list) =>
val simple = list.map(numericSimplify)
val nums = simple.collect { case n @ Num(_,_) => n }
if (simple.length == 0) t match {
case "sum" => Num("0",Nil)
case "prod" => Num("1",Nil)
}
else if (nums.length == simple.length) t match {
case "sum" => calc(nums, _ + _)
case "prod" => calc(nums, _ * _)
}
else Nar(t, simple)
case Var(t,List(e)) => Var(t, List(numericSimplify(e)))
case _ => expr
}
Aviso de nuevo el uso intensivo de coincidencia de patrones para encontrar cuando estamos en un buen caso, y para enviar el cálculo apropiado.
¡Ahora, seguramente la sustitución algebraica es mucho más difícil! En realidad, todo lo que necesita hacer es observar que ya se ha utilizado una expresión y asignar una variable. Como la sintaxis que he definido arriba permite la sustitución de variables in situ, podemos simplemente modificar nuestro árbol de expresiones para incluir más asignaciones de variables.Por lo que hacemos (editado para insertar variables solamente si el usuario no tiene):
def algebraicSimplify(expr: Expr): Expr = {
val all, dup, used = new collection.mutable.HashSet[Expr]
val made = new collection.mutable.HashMap[Expr,Int]
val user = new collection.mutable.HashMap[Expr,Expr]
def findExpr(e: Expr) {
e match {
case Var(t,List(v)) =>
user += v -> e
if (all contains e) dup += e else all += e
case Var(_,_) | Num(_,_) => // Do nothing in these cases
case _ => if (all contains e) dup += e else all += e
}
e.args.foreach(findExpr)
}
findExpr(expr)
def replaceDup(e: Expr): Expr = {
if (made contains e) Var("x"+made(e),Nil)
else if (used contains e) Var(user(e).text,Nil)
else if (dup contains e) {
val fixed = replaceDupChildren(e)
made += e -> made.size
Var("x"+made(e),List(fixed))
}
else replaceDupChildren(e)
}
def replaceDupChildren(e: Expr): Expr = e match {
case Una(t,List(u)) => Una(t,List(replaceDup(u)))
case Bin(t,List(l,r)) => Bin(t,List(replaceDup(l),replaceDup(r)))
case Nar(t,list) => Nar(t,list.map(replaceDup))
case Var(t,List(v)) =>
used += v
Var(t,List(if (made contains v) replaceDup(v) else replaceDupChildren(v)))
case _ => e
}
replaceDup(expr)
}
Eso es todo - una rutina de sustitución algebraica completamente funcional. Tenga en cuenta que crea conjuntos de expresiones que se ven, manteniendo una pista especial de cuáles son duplicados. Gracias a la magia de las clases de casos, todas las equidades están definidas para nosotros, por lo que solo funciona. Luego podemos reemplazar cualquier duplicado a medida que recurrimos para encontrarlos. Tenga en cuenta que la rutina replace se divide por la mitad, y que coincide con en una versión no reemplazada del árbol, pero utiliza una versión reemplazada.
Bien, ahora vamos a añadir algunas pruebas:
def main(args: Array[String]) {
val test1 = "- +^x 2^y 2 1"
val test2 = "+ + +" // Bad!
val test3 = "exp sin cos sum 5" // Bad!
val test4 = "+ * 2 3^3 2"
val test5 = List(test1, test4, "^ y 2").mkString("list 3 "," ","")
val test6 = "+ + x y + + * + x y + 4 5 * + x y + 4 y + + x y + 4 y"
def performTest(test: String) = {
println("Start with: " + test)
val p = OpParser.parse(test)
if (p.isEmpty) println(" Parsing failed")
else {
println("Parsed: " + p.get)
val q = OpParser.numericSimplify(p.get)
println("Numeric: " + q)
val r = OpParser.algebraicSimplify(q)
println("Algebraic: " + r)
}
println
}
List(test1,test2,test3,test4,test5,test6).foreach(performTest)
}
}
¿Cómo lo hace?
$ scalac OpParser.scala; scala OpParser
Start with: - +^x 2^y 2 1
Parsed: (((x^2) + (y^2)) - 1)
Numeric: (((x^2) + (y^2)) - 1)
Algebraic: (((x^2) + (y^2)) - 1)
Start with: + + +
Parsing failed
Start with: exp sin cos sum 5
Parsing failed
Start with: + * 2 3^3 2
Parsed: ((2 * 3) + (3^2))
Numeric: 15.0
Algebraic: 15.0
Start with: list 3 - +^x 2^y 2 1 + * 2 3^3 2^y 2
Parsed: List[
1: (((x^2) + (y^2)) - 1)
2: ((2 * 3) + (3^2))
3: (y^2)
]
Numeric: List[
1: (((x^2) + (y^2)) - 1)
2: 15.0
3: (y^2)
]
Algebraic: List[
1: (((x^2) + (x0 <- (y^2))) - 1)
2: 15.0
3: x0
]
Start with: + + x y + + * + x y + 4 5 * + x y + 4 y + + x y + 4 y
Parsed: ((x + y) + ((((x + y) * (4 + 5)) + ((x + y) * (4 + y))) + ((x + y) + (4 + y))))
Numeric: ((x + y) + ((((x + y) * 9.0) + ((x + y) * (4 + y))) + ((x + y) + (4 + y))))
Algebraic: ((x0 <- (x + y)) + (((x0 * 9.0) + (x0 * (x1 <- (4 + y)))) + (x0 + x1)))
Así que no sé si eso es útil para usted, pero resulta ser útil para mí. Y este es el tipo de cosas que dudaría en abordar en C++ porque varias cosas que se suponía que serían fáciles terminaron siendo dolorosas.
Edición: He aquí un ejemplo del uso de esta estructura para imprimir asignaciones temporales, sólo para demostrar que esta estructura está perfectamente bien para hacer tales cosas.
Código:
def useTempVars(expr: Expr): Expr = {
var n = 0
def temp = { n += 1; "t"+n }
def replaceTemp(e: Expr, exempt: Boolean = false): Expr = {
def varify(x: Expr) = if (exempt) x else Var(temp,List(x))
e match {
case Var(t,List(e)) => Var(t,List(replaceTemp(e, exempt = true)))
case Una(t,List(u)) => varify(Una(t, List(replaceTemp(u,false))))
case Bin(t,lr) => varify(Bin(t, lr.map(replaceTemp(_,false))))
case Nar(t,ls) => varify(Nar(t, ls.map(replaceTemp(_,false))))
case _ => e
}
}
replaceTemp(expr)
}
def varCut(expr: Expr): Expr = expr match {
case Var(t,_) => Var(t,Nil)
case Una(t,List(u)) => Una(t,List(varCut(u)))
case Bin(t,lr) => Bin(t, lr.map(varCut))
case Nar(t,ls) => Nar(t, ls.map(varCut))
case _ => expr
}
def getAssignments(expr: Expr): List[Expr] = {
val children = expr.args.flatMap(getAssignments)
expr match {
case Var(t,List(e)) => children :+ expr
case _ => children
}
}
def listAssignments(expr: Expr): List[String] = {
getAssignments(expr).collect(e => e match {
case Var(t,List(v)) => t + " = " + varCut(v)
}) :+ (expr.text + " is the answer")
}
resultados seleccionados (desde listAssignments(useTempVars(r)).foreach(printf(" %s\n",_))
):
Start with: - +^x 2^y 2 1
Assignments:
t1 = (x^2)
t2 = (y^2)
t3 = (t1 + t2)
t4 = (t3 - 1)
t4 is the answer
Start with: + + x y + + * + x y + 4 5 * + x y + 4 y + + x y + 4 y
Algebraic: ((x0 <- (x + y)) + (((x0 * 9.0) + (x0 * (x1 <- (4 + y)))) + (x0 + x1)))
Assignments:
x0 = (x + y)
t1 = (x0 * 9.0)
x1 = (4 + y)
t2 = (x0 * x1)
t3 = (t1 + t2)
t4 = (x0 + x1)
t5 = (t3 + t4)
t6 = (x0 + t5)
t6 is the answer
Segunda edición: encontrar dependencias tampoco es tan malo.
Código:
def directDepends(expr: Expr): Set[Expr] = expr match {
case Var(t,_) => Set(expr)
case _ => expr.args.flatMap(directDepends).toSet
}
def indirectDepends(expr: Expr) = {
val depend = getAssignments(expr).map(e =>
e -> e.args.flatMap(directDepends).toSet
).toMap
val tagged = for ((k,v) <- depend) yield (k.text -> v.map(_.text))
def percolate(tags: Map[String,Set[String]]): Option[Map[String,Set[String]]] = {
val expand = for ((k,v) <- tags) yield (
k -> (v union v.flatMap(x => tags.get(x).getOrElse(Set())))
)
if (tags.exists(kv => expand(kv._1) contains kv._1)) None // Cyclic dependency!
else if (tags == expand) Some(tags)
else percolate(expand)
}
percolate(tagged)
}
def listDependents(expr: Expr): List[(String,String)] = {
def sayNothing(s: String) = if (s=="") "nothing" else s
val e = expr match {
case Var(_,_) => expr
case _ => Var("result",List(expr))
}
indirectDepends(e).map(_.toList.map(x =>
(x._1, sayNothing(x._2.toList.sorted.mkString(" ")))
)).getOrElse(List((e.text,"cyclic")))
}
Y si añadimos nuevos casos de prueba val test7 = "list 3 z =^x 2 - + z^y 2 1 w = - z y"
y val test8 = "list 2 x = y y = x"
y mostramos las respuestas con for ((v,d) <- listDependents(r)) println(" "+v+" requires "+d)
obtenemos (resultados seleccionados):
Start with: - +^x 2^y 2 1
Dependencies:
result requires x y
Start with: list 3 z =^x 2 - + z^y 2 1 w = - z y
Parsed: List[
1: (z <- (x^2))
2: ((z + (y^2)) - 1)
3: (w <- (z - y))
]
Dependencies:
z requires x
w requires x y z
result requires w x y z
Start with: list 2 x = y y = x
Parsed: List[
1: (x <- y)
2: (y <- x)
]
Dependencies:
result requires cyclic
Start with: + + x y + + * + x y + 4 5 * + x y + 4 y + + x y + 4 y
Algebraic: ((x0 <- (x + y)) + (((x0 * 9.0) + (x0 * (x1 <- (4 + y)))) + (x0 + x1)))
Dependencies:
x0 requires x y
x1 requires y
result requires x x0 x1 y
así que creo que en la parte superior de este tipo de estructura, todos sus requisitos individuales se cumplen con bloques de una o dos docenas de líneas de código Scala.
Editar: aquí está la evaluación de expresiones, si se le ofrece un mapeo de Vars a valores:
def numericEvaluate(expr: Expr, initialValues: Map[String,Double]) = {
val chain = new collection.mutable.ArrayBuffer[(String,Double)]
val evaluated = new collection.mutable.HashMap[String,Double]
def note(xv: (String,Double)) { chain += xv; evaluated += xv }
evaluated ++= initialValues
def substitute(expr: Expr): Expr = expr match {
case Var(t,List(n @ Num(v,_))) => { note(t -> v.toDouble); n }
case Var(t,_) if (evaluated contains t) => Num(evaluated(t).toString,Nil)
case Var(t,ls) => Var(t,ls.map(substitute))
case Una(t,List(u)) => Una(t,List(substitute(u)))
case Bin(t,ls) => Bin(t,ls.map(substitute))
case Nar(t,ls) => Nar(t,ls.map(substitute))
case _ => expr
}
def recurse(e: Expr): Expr = {
val sub = numericSimplify(substitute(e))
if (sub == e) e else recurse(sub)
}
(recurse(expr), chain.toList)
}
y se utiliza como tal en la rutina de la prueba:
val (num,ops) = numericEvaluate(r,Map("x"->3,"y"->1.5))
println("Evaluated:")
for ((v,n) <- ops) println(" "+v+" = "+n)
println(" result = " + num)
dando resultados como estos (con entrada de x = 3
y y = 1.5
):
Start with: list 3 - +^x 2^y 2 1 + * 2 3^3 2^y 2
Algebraic: List[
1: (((x^2) + (x0 <- (y^2))) - 1)
2: 15.0
3: x0
]
Evaluated:
x0 = 2.25
result = List[
1: 10.25
2: 15.0
3: 2.25
]
Start with: list 3 z =^x 2 - + z^y 2 1 w = - z y
Algebraic: List[
1: (z <- (x^2))
2: ((z + (y^2)) - 1)
3: (w <- (z - y))
]
Evaluated:
z = 9.0
w = 7.5
result = List[
1: 9.0
2: 10.25
3: 7.5
]
El otro desafío - escogiendo los VARs que aún no han sido utilizados - substracción apenas se fija fuera de las dependencias result
lista. diff
es el nombre del método de resta establecido.
¿Cómo sabes cuándo terminaste tu suma? 'S + S 3 3 3 3 3' podría interpretarse de muchas maneras, si' S' era el operador de suma. Si no especifica su gramática, es difícil saber cómo analizarla. –
El número de argumentos también se da como el primer argumento del operador n-ary, por ejemplo: suma 3 x y z. – Ali
¿Qué significa 'exp'? –