程序员的算法趣题系列 -Q02- 数列的四则运算

本贴最后更新于 1538 天前,其中的信息可能已经沧海桑田

前言

此篇为《程序员的算法趣题》中的入门篇第二题"数列的四则运算"的相关解题分析博文。

关于该系列的介绍请看:

《程序员的算法趣题》-开坑记录

这篇拖了一个周,之前自己看牙拔牙加上带我妈去医院看病耽误了不少时间,本来应该是上周就要完成的,真是之前才立的一周一篇的 flag 马上就被打脸了,现在脸都还是生疼(拔牙之后的那种疼)。

题目

Q2 数列的四则运算

大家小时候可能也玩过“组合车牌号里的 4 个数字最终得到 10”的游戏。

组合的方法是在各个数字之间插入四则运算的运算符组成算式,然后计算算式的结果(某些数位之间可以没有运算符,但最少要插入 1 个运算符)。

例)

1234 → 1 + 2×3 - 4 = 3
9876 → 9×87 + 6 = 789

假设这里的条件是,组合算式的计算结果为“将原数字各个数位上的数逆序排列得到的数”,并且算式的运算按照四则运算的顺序进行(先乘除,后加减)。

那么位于 100~999,符合条件的有以下几种情况。

351-→-3×51 = 153
621-→-6×21 = 126
886-→-8×86 = 688

问题

求位于 1000~9999,满足上述条件的数。

问题 Hint.png

作者思路及代码实现

解决这个问题时,“计算算式的方法”会影响实现方法。如果要实现的是计算器,那么通常会用到逆波兰表示法,而本题则是使用编程语言内置的功能来实现更为简单。

很多脚本语言都提供了类似 eval 这样的标准函数。譬如用 JavaScript 实现时,可以用代码清单 02.01 解决问题。

(以下为代码清单 02.01)

var op = ["+", "-", "*", "/", ""];
for (i = 1000; i < 10000; i++) {
    var c = String(i);
    for (j = 0; j < op.length; j++) {
        for (k = 0; k < op.length; k++) {
            for (l = 0; l < op.length; l++) {
                val = c.charAt(3) + op[j] + c.charAt(2) + op[k] + c.charAt(1) + op[l] + c.charAt(0);
                if (val.length > 4) { /* 一定要插入1 个运算符 */
                    if (i == eval(val)) {
                        console.log(val + " = " + i);
                    }
                }
            }
        }
    }
}

第 10 行中的 eval 就是本题的关键点,接下来只是选择和设置运算符了。虽然有比较深的循环嵌套,但只要确定了位数就没有问题。

思路 hint1.png

基于这样的考虑,如果把代码第 1 行的 op 变量设置成以下值,可以进一步提高程序执行效率。

var op = ["*", ""];

Point

如果用其他语言实现同样逻辑,需要对 0 进行特别处理。例如在 Ruby 中,“以 0 开头的数”会被当作八进制数来处理,因此必须排除以 0 开头的数。此外,也需要排除除数为 0 的情况。

思路 hint2.png

答案

5931(5 * 9 * 31= 1395)

Column

本书以 Ruby 为主要语言编写源代码,但也有像本题一样用 JavaScript 实现的情况。凡用 JavaScript 实现时,结果都用 console.log 来输出,这个结果可以用浏览器来确认。用 Mozilla Firefox 浏览器打开加载了 JavaScript 源代码的 HTML 文件,然后打开“开发者”→“Web 控制台”(如果用 Google Chrome 浏览器,则是打开“更多工具”→“开发者工具”),就可以确认代码的执行结果了。

eval 函数的危险性
本题使用了 eval 函数。这个函数在计算算式等场景下非常方便,但 eval 可以做到的事情不止于此。例如,eval 还可以用来执行指令。

如果在 Web 应用中直接用 eval 执行用户输入的内容,那么用户可能会输入并让程序执行任意指令,包括不恰当的指令。举个例子,假设存在下面这样的用 PHP 编写的 Web 页面(代码清单 02.02)。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title> 计算器</title></head>
    <body>
        <form method="post" action="<?php echo $_SERVER['SCRIPT_NAME'];?>">
            <input type="text" name="exp" size="30">
            <input type="submit" value=" 计算">
        </form>
        <div>
            <?php
            if($_SERVER["REQUEST_METHOD"] == "POST"){
                $exp = $_POST["exp"];
                eval("echo $exp;");
            }
            ?>
        </div>
    </body>
</html>

这个页面的功能就是计算表单中输入的类似“1 + 2*3”这样的算式,并且显示结果。正常输入算式的情况当然没有问题,但根据输入内容不同,我们还可以执行 PHP 脚本。

举个例子,如果输入 phpinfo( ),那么 PHP 的版本等信息会被打印出来。从安全的角度来看,这是非常危险的。

自己做的思路及实现

刚看到这个题目,其实一开始自己能想到的也是用 js 中的 eval 来实现,但是因为现在是要用 java 来实现,而 java 中没有类似 eval 功能的函数,所以马上想到的就是用动态编译加动态加载的方式来实现。(正好前段时间学 spring 源码课的时候有看到相关的内容)

然后是解决遍历数字,加入操作符拼接成计算表达式的操作。然后自己动手写代码前先试了下几个数字里面加运算符,也很容易就可以知道,除了加*之外的运算符,根本不可能保持原有数字的位数。所以操作符只需要加入乘法操作符即可,另外还需考虑数字之间不加操作符,将多个数字进行整体化处理的符号。

上面那一段得出只能加*的道理很简单。就假设你是一个 10-99 的十位数,中间只能加一个操作符。除了乘法和加法,不可能进行运算之后的数字还能保持是十位数。然后考虑如果是加法的情况,将一个十位数拆分成两个个位数,通过加法计算,也不可能满足题目的要求。

因为这两个位数相加之后的取值范围是 1-18,能满足是十位数的数字只有 10-18,当中任何一个数,都不可能满足题目的要求。所以对于其他高位的数字也可以套用同样的规律,操作符只需要考虑乘法操作符的情况即可。

下面是动态编译/加载完成的一个类似 Eval 函数的方法,用于计算数学表达式。

/**
 * 自定义的实现类似JavaScript中Eval()函数的工具类,但是因为需要返回计算式的运行结果,
 * 所以这个函数中传入的str不能是语句运行是void类型的。
 * (例如:"System.out.println(\"Hello\")")
 *
 * @version V1.0
 * @Package: cn.dattyrabbit.programerArithmeticTopic.util
 * @author: 丁奕
 * @date: 2020-08-26 09:55
 **/
public class Eval {
    public static Object eval(String str) throws Exception {
        StringBuffer sb = new StringBuffer();
        sb.append("public class Temp");
        sb.append("{");
        sb.append("    public Object getObject()");
        sb.append("    {");
        sb.append("        Object obj = " + str + "return obj;");
        sb.append("    }");
        sb.append("}");
        // 调用自定义类加载器加载编译在内存中class文件
        // 说明:这种方式也需要些数据落地写磁盘的
        // 为毛一定要落地呢,直接内存里加载不就完了嘛
        // 应该也是可以的,它从磁盘读了也是进内存
        // 只不过java不允许直接操作内存
        // 写jni估计是可以
        Class clazz = new MyClassLoader().findClass(sb.toString());
        Method method = clazz.getMethod("getObject");
        // 通过反射调用方法
        return method.invoke(clazz.newInstance());
    }

    public static void main(String[] args) throws Exception {
        Object rval = eval("1+2+3+4;");
        System.out.println(rval);
    }
}
/**
 * 自定义ClassLoader。用于加载动态编译的类
 *
 * @version V1.0
 * @Package: cn.dattyrabbit.programerArithmeticTopic.util
 * @author: 丁奕
 * @date: 2020-08-28 10:18
 **/
public class MyClassLoader extends ClassLoader {
    public static final String classPath = System.getProperty("user.dir") + "\\bin\\";

    /**
     * 自定义的一个加载方法,这样的做法似乎破坏了Java的双亲委派
     * @param classFullName
     * @return
     */
    protected Class customLoadClass(String classFullName){
        String filePath = classFullName2FileName(classFullName, classPath);
        byte[] data = loadClassFromFS(filePath );
        //调用defineClass将一个字节数据转换成一个类并进行初始化工作.
        return defineClass(classFullName, data, 0, data.length);
    }

    /**
     * 将一个完整类名转换为一个当前工程classpath为基础的文件路径.
     * @param classFullName     一个完整类名
     * @param classPath         当前工程类路径
     * @return
     */
    public String classFullName2FileName(String classFullName, String classPath){
        classFullName = classFullName.replaceAll("[.]", "\\\\" );
        return classPath  + classFullName + ".class";
    }

    /**
     * 从文件系统中加载类文件,生成一个byte[].
     * @param filePath   文件路径
     * @return   类文件的字节码数组
     */
    private byte[] loadClassFromFS(String filePath) {
        FileInputStream fis = null;
        byte[] byteSource = null;
        try {
            fis = new FileInputStream(new File(filePath ) );
            ByteArrayOutputStream tempSource = new ByteArrayOutputStream();
            int readChar = 0;
            while ((readChar = fis.read()) != -1) {
                tempSource.write(readChar );
            }
            byteSource = tempSource.toByteArray();
        } catch (IOException e) {
            //IO出错
            e.printStackTrace();
        }
        return byteSource;
    }

    @Override
    public Class<?> findClass(String str) throws ClassNotFoundException{
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        // 内存中的源代码保存在一个从JavaFileObject继承的类中
        JavaFileObject file = new JavaSourceFromString("Temp", str);
        // System.out.println(file);
        Iterable compilationUnits = Arrays.asList(file);
        // 需要为compiler.getTask方法指定编译路径:
        // 执行过程如下:
        // 1、定义类的字符串表示。
        // 2、编译类
        // 3、加载编译后的类
        // 4、实例化并进行调用。
        String flag = "-d";
        String outDir = System.getProperty("user.dir") + "\\" + "bin";
        Iterable<String> stringdir = Arrays.asList(flag, outDir); // 指定-d dir 参数
        // 建立一个编译任务
        JavaCompiler.CompilationTask task = compiler.getTask(null, null, null,
                stringdir, null, compilationUnits);
        // 编译源程序
        boolean result = task.call();
        if (result) {
            try {
                return customLoadClass( "Temp");
            } catch (Exception e) {
                throw new ClassNotFoundException("Temp", e);
            }
        }
        return null;
    }

}

class JavaSourceFromString extends SimpleJavaFileObject {
    private String code;

    public JavaSourceFromString(String name, String code) {
        super(URI.create("string:///" + name.replace('.', '/')
                + Kind.SOURCE.extension), Kind.SOURCE);
        this.code = code;
    }

    public CharSequence getCharContent(boolean ignoreEncodingErrors) {
        return code;
    }
}

这样就有一个类似 Eval()的功能可以使用。然后是解题代码的部分

/**
 * 《程序员的算法趣题》 - 入门篇 - Q02 - 数列的四则运算
 *
 * 题目:大家小时候可能也玩过“组合车牌号里的 4 个数字最终得到 10”的游戏。
 *
 * 组合的方法是在各个数字之间插入四则运算的运算符组成算式,
 * 然后计算算式的结果(某些数位之间可以没有运算符,但最少要插入 1 个运算符)。
 *
 * 例)
 *
 * 1234 → 1 + 2×3 - 4 = 3
 * 9876 → 9×87 + 6 = 789
 *
 * 假设这里的条件是,组合算式的计算结果为“将原数字各个数位上的数逆序排列得到的数”,
 * 并且算式的运算按照四则运算的顺序进行(先乘除,后加减)。
 *
 * 那么位于 100~999,符合条件的有以下几种情况。
 *
 * 351-→-3×51 = 153
 * 621-→-6×21 = 126
 * 886-→-8×86 = 688
 *
 * 问题
 *
 * 求位于 1000~9999,满足上述条件的数。
 *
 * @description 数列的四则运算实现
 *                思路:看到这个题目的时候,最初能想到的就是使用JavaScript中的eval()函数来解决是最便捷的。但是因为JAVA没有类似
 *                的功能,所以就需要自己来实现一个类似的功能。
 *
 *                这里我目前能想到的实现eval()函数功能的方式就是使用动态编译和动态加载来完成。所以得先完成实现该功能的相关工具
 *                类,然后再在这个类当中去使用工具类的方法,实现类似JavaScript的eval()函数来完成解题。
 *
 *                然后在已有实现类似eval()函数功能之后,再去遍历1000~9999,然后分别把四则运算的操作数也遍历加入到1000~9999的字
 *                符串之中,然后再使用实现的eval()方法来及时运算,获得结果再判断即可。当然这里四则运算的操作书除了基本的加减乘
 *                除外还需要多定义一个空符号"",表示不加入操作数,让前后的单个数字连起来作为一个整数计算。
 *
 *                ------------------------
 *                使用最初想到动态编译和动态加载的方式有大量的I/O操作,所以性能较低,耗时巨长,后来找了一种使用avaScriptEngine处理的方法,
 *                耗时减少很多,但是还是觉得耗时挺长的。最后才另寻方法,换为将计算表达式转化为后缀表达式再计算的方法。时间开销终于
 *                降到一个可以接受的范围。
 *
 * @version V1.0
 * @Package: cn.dattyrabbit.programerArithmeticTopic.primer.q2.sequenceArithmetic
 * @author: 丁奕
 * @date: 2020-08-26 09:37
 **/
public class SequenceArithmetic {
    //初始化ScriptEngine
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("JavaScript");
    /**
     * 包装方法,用于调用的时候不传type,给type赋予0的默认处理方式。
     * @param min
     * @param max
     * @return
     */
    public ArrayList<Integer> searchTargetNum(int min, int max){
        return searchTargetNum(min, max, 0);
    }

    /**
     * 根据传入的最大和最小值来查找其中符合要求的数字
     * @param min 最小值,大于等于
     * @param max 最大值,小于
     * @param type 处理方式: 0|其他 -> 转换为后缀表达式计算;
     *                        1 -> 使用JavaScriptEngine 中的eval处理
     *                        2 -> 使用态编译和动态加载处理
     * @return
     */
    public ArrayList<Integer> searchTargetNum(int min, int max, int type){
        //参数验证
        if(min >= max){
            return (ArrayList)Arrays.asList(-1);
        }
        //初始化满足的数的list
        ArrayList<Integer> targetNum = new ArrayList<>();
        //设置遍历的初始值
        int num = min;
        //定义操作符的数组,空字符暂用_代替,传入eval时替换为空
        char options[] = {'_','*'};
        //循环遍历
        for(; num < max ; num++){
            //打印当前验证的数字
            System.out.println("正在处理:--- "+ num +" ---数字");
            //遍历每个数的时候根据数字转换为字符串,并再遍历操作符,在数字之间插入符号
            char operand[] = String.valueOf(num).toCharArray();
            //根据当前num的值,计算需要插入的操作符的个数,从而求出操作符的组合数量。遍历改组合数量,化为二进制。
            //需要插入的操作符的组合数量就是 (操作符的长度)^(位数 - 1)。因为操作符的长度为2,所以结果是2的N次幂
            int operatorNums = (int)Math.pow((double)options.length,(operand.length - 1));
            //判断拼接后的字符串,至少插入了一个操作符所以遍历操作符应该从1开始,到operatorNums-1结束。
            //0开始则没有添加任何操作符,遍历到operatorNums则操作符会加在数字的外面而不是数字的中间
            for(int i = 1; i < operatorNums; i++){
                //转换成二进制,且根据需要的位数进行左边补零的操作
                String operatorBinary = zeorFill(Integer.toBinaryString(i),operatorNums);
                //开始拼接放入eval函数中的语句
                String evalStatement = "";
                for(int j = operand.length - 1; j >= 0; j--){
                    evalStatement += operand[j];
                    //判断是否是最后一个数
                    if(j != 0){
                        evalStatement += options[Integer.parseInt(String.valueOf(operatorBinary.charAt(j-1)))];
                    }
                }
                //替换_符号。
                evalStatement = evalStatement.replaceAll("_","");
                //处理evalStatement语句中的各个操作数,使其符合java语法规范。因为出现08*1这样的数字是编译无法通过的。所以要对各操作数进行去除左边的0的操作。
                evalStatement = handleStatement(evalStatement);
                //加分号
                evalStatement += ";";
                try{
                    int check;
                    if(type == 1){
                        check = (int) engine.eval(evalStatement);
                    }else if(type == 2){
                        check = (int)Eval.eval(evalStatement);
                    }else{
                        check = ((Double)Calculator.calculate(evalStatement)).intValue();
                    }
                    //若满足条件,放入targetNum中
                    if(num == check){
                        targetNum.add(num);
                    }
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
        return targetNum;
    }

    /**
     * 根据传入的待计算语句进行处理,对各个操作数进行去除左边多余的0的操作,使其符合语法规范
     * @param evalStatement
     * @return
     */
    private String handleStatement(String evalStatement) {
        String[] operatorNums = evalStatement.split("\\*");
        List<String> array = new ArrayList<>();
        for (String operatorNum : operatorNums) {
            //用字符串转Int再转字符串的操作来去左边的0
            array.add(String.valueOf(Integer.parseInt(operatorNum)));
        }
        String resultStatement = StringUtils.join(array, "*");
        return resultStatement;
    }

    /**
     * 根据传入的二进制数(Integer.toBinaryString直接转换的数)和允许的最大的数字进行左边补零的操作,
     * @param originalBinary
     * @param operatorNums
     * @return
     */
    private String zeorFill(String originalBinary, int operatorNums) {
        String binaryStr = originalBinary;
        String maxOperatorNums = Integer.toBinaryString(operatorNums - 1);
        while(binaryStr.length() < maxOperatorNums.length()){
            binaryStr = "0"+binaryStr;
        }
        return binaryStr;
    }
}

代码里面我已经在写代码前把思路用注释写过一遍,所以为啥写这些方法,每个方法是干啥的。都体现在代码里了。需要多说的就是通过自己的方式实现一遍之后,运行下来非常耗时,因为自己的实现方式有大量的 I/O 操作,所以性能比较不堪,也是正常,所以才有了后面两种其他的处理方式。也就是通过给方法的 type 参数传值来控制具体的处理方式。

然后是写一个测试类,测试类代码如下

/**
 * 数列的四则运算测试
 *
 * @version V1.0
 * @Package: primer.q2.sequenceArithmetic
 * @author: 丁奕
 * @date: 2020-09-01 11:00
 **/
public class SequenceArithmeticTest {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        SequenceArithmetic sequenceArithmetic = new SequenceArithmetic();
        ArrayList<Integer> integers = sequenceArithmetic.searchTargetNum(1000, 9999);
        for (Integer integer : integers) {
            System.out.println(integer);
        }
        long end = System.currentTimeMillis();
        System.out.println("总共用时:" + (end-start) +"ms");
    }
}

这里直接给出三种模式的运行结果图作为对比吧

动态编译/加载的方式实现

自己代码运行动态编译模式.png

这种真的太久了,我都下去取了个快递回来都还没运行完,肯定不能这样做。

使用 ScriptEngine 调用 js 的 eval 实现

自己代码运行 ScriptEngine 模式.png

这个勉勉强强能接受,但是肯定不够好

使用后缀表达式计算的方式实现

自己代码运行后缀表达式计算模式.png

重要找到了一个好的方案,谢天谢地。

这部分使用到的数学表达式转后缀表达式进行计算的代码没贴出来,可以在 git 上查看源码。git 地址在前言的开坑记录中有。这部分实现的参考文章在这里:(参考文章:java 实现运算字符计算)

不同思路的对比

要对比跟作者的不同,这一节可以运行下作者提供的 JS 代码。下图为运行结果,我是直接开了个浏览器,然后再 console 中粘入代码运行的。如下图
作者代码 JS 运行结果.png

看计算时间的话,跟我最后使用的后缀表达的方案没差太多。这一回自己这边尝试了三种实现,说到底还是搞了蛮久的,不过我觉得我在对遍历数字插入计算符的操作上,有不少创新思路,而且把问题更加的泛化了,如果使用作者的方法,那么求 100-99999 中的符合要求的数,就要改代码。而且循环的层数也会有增加。而我自己实现的代码,直接改变参数即可。计算结果如下图
改变参数结果.png

总结

这次的题目,个人觉得实现还是比较满意的,虽然第一时间想到的方案并不是最佳的,但是也尝试实现了出来,并且在不满意结果的同时,又尝试了不同的解决方案,最后一步一步得到了一个相对满意的结果。

当然最后的代码也是不完善的,也还有很多地方可以继续改进优化,不过实现的时候有考虑去泛化问题,使得在后期去比对作者的实现时更具有优势这一点来说,还算是比较满意的。所以平时就算是写业务代码,也尽可能的多考虑考虑之如果有业务变更,怎样做才能在今后做最少的改动。

因为这一篇拖了一周,所以下一篇会尽力尽快在最近两天肝出来先把整体的进度补上,peace🙏 。

  • 算法
    428 引用 • 254 回帖 • 24 关注
  • 学习

    “梦想从学习开始,事业从实践起步” —— 习近平

    169 引用 • 506 回帖
  • 程序员

    程序员是从事程序开发、程序维护的专业人员。

    567 引用 • 3532 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...