首页 > 技术文章 > OO第1.2次作业·魔鬼的三角函数化简

MisTariano 2019-03-15 10:41 原文

多年以后,面对办公室的屏幕,我会回忆起开始肝第二周OO作业的那个遥远的下午。那时的程序是一个一两百行的符号求导,基类与接口在包里一字排开,工整的注释一望到底

谁能想到,接下来的十几个小时我要经历什么样的噩梦(

转自我的个人博客

问题描述

我们面临的问题很简单:给定一个符号表达式E,E由幂函数、正弦函数和余弦函数的乘积加和而成(严谨定义略),求与E等价的尽可能短的表达式E'

即:

形如2*x*sin(x)^2+sin(x)+4*cos(x)+6*sin(x)*cos(x)^-2形式的表达式合法,而若给定

sin(x)^2+cos(x)^2,则最优解为1

胡乱分析

这个问题,第一眼看上去是个贪心,因为很容易想到,对于像sin(x)^4+2*sin(x)^2*cos(x)^2+cos(x)^4这样的表达式,只要每次合并Exp*sin(x)^2 + Exp*cos(x)^2,最后能得到最优解。合并经常是正收益的。

但是反例也很好举出:3*sin(x)^-2 + cos(x)^-2,类似这种表达式,合并后并非最优

实际上由于字符串长度在某因子系数为-2-101时会因为省略输出而变短,在合并后会发生难以控制的变化

但是简单想想就知道,大部分时候无脑合并应该是正收益。所以这个贪心可以作为一个baseline

另外一个问题来自合并顺序。考虑A和B可合并,B和C可合并,但A和C不可合并,那么优先合并哪两项?

因此简单的说我们要解决两个问题:

  1. 合与不合
  2. 先合谁

十分NP了。NP的最优解只能靠搜索求

暴搜是可能T或爆栈的,剪枝又太麻烦,容易写错,不太想走这条路子。想起来NOIP2015 D1T3 斗地主,考场上就有人用多个策略贪心取最优解的做法骗满。说是骗分,但是这种做法其实更适合实际生产环境应用。这个思想很像Embedding。所以决定借鉴一下

准备工作

一般地,对于所有这样的表达式项我们可以以一个三元向量简化表示,如

4*sin(x)^2*cos(x),可以以(0,2,1)和其系数4表示

而对于两个可合并的项,容易证明,一定满足其指数三元向量的差为(0,2,-2)(0,-2,2)

进一步的,对于一个给定的表达式,若其系数表示为root = (a,b,c),那么它及

sin(root) = (a,b+2,c)

cos(root) = (a,b,c+2)

这三者中的任意两者的线性组合可以转化为另外两者的线性组合

结合这种表述形式,我们实现一个可哈希的三元向量类,作为一个HashMap的键,将每一项的系数作为值,这样做的首要考虑是方便合并同类项,次要考虑是方便查找和给定项存在转化关系的项(几乎常数级的查询复杂度)

贪心策略

这一步主要解决第一个问题:合与不合

采用如下的组合策略:

  1. 对于三角函数因子系数不含-2-1的项,无脑合并,优先合并成root,不足以补成root的三角函数项,转换成系数绝对值较大的三角函数因子。迭代至不能再合并为止,作为初始解
  2. 对于三角函数因子系数不含-2-1的项,无脑合并,检查root+sinroot+cossin+cos三种情况中输出最短的情况,转化至这种情况。迭代至不能再合并为止。若长度得到优化接收当前解作为新解
  3. 对于所有项,执行类似1的操作,若长度得到优化接受当前解作为新解
  4. 对于所有项,执行类似2的操作,若长度得到优化接受当前解作为新解

这些策略可能不是最优的,但是实测已经能卡掉绝大多数情况了,所以也没有进一步打磨。

引入随机化

解决第二个问题:先合谁。

实际上裸的多策略贪心性能已经不错了,我们可以通过随机打乱项的顺序,将目前的方法改进为一个蒙特卡罗方法,进一步消除合并顺序的影响。

每次求一个新解前,用键集合keySet初始化一个链表并用Collections.shuffle()打乱顺序,然后使用上述的贪心策略求一个解,若优于既有解,接受新解。迭代这个过程

我只迭代了50次,因为这次作业的输入规模(100)很小,这个数量应该足够了。

进一步的讨论

目前的随机化只是简单的打乱顺序。是否可以考虑使用一些智能随机算法,比如遗传、蚁群、PSO、退火?

使用遗传算法的可能做法

将项的系数与指数三元组作为项的编码,将全部项的编码降序排列作为表达式的编码

  • 突变算子:
    • 分裂突变:随机分裂某一项为sin^2+cos^2的形式,开销是常数级
    • 合并突变:随机选择可合并的某两项合并,复杂度常数级。为了让合并突变是常数级操作,需要维护一个Flag确定每一项在当前编码中是否可合并、合并对象的索引或引用
  • 交叉算子:对于编码A中的子串A'和B中的子串B',A'和B'同源(由原始表达式中的相同项合并或分裂生成),那么一定有A'和B'的补CAA'、CBB'也同源。将A'与CBB'拼接、B'与CAA'拼接,作为两个子代。为了快速找到两个同源的子串,需要对每个编码维护一个并查集。
  • 适应函数与选择算子:显而易见,选择编码对应的表达式字符串长度作为适应函数。

总结

这次优化设计,我的感受是,一个优秀的近似解算法可能会比一个确定解算法更实用,因为它的时空需求更少,而解的质量并不会差的太多。

推荐阅读