掘金官方【数字谜题】算法求解之路!

1,440 阅读4分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第 3 篇文章,点击查看活动详情

能让计算机干的活儿,我绝不动脑,这就是程序员的信条;

                                   -- 程序员张三最后的倔强

最近有点睡不着,或许大概可能还可以叫做失眠,就是因为掘金的小游戏 数字谜题,初级难度竟然尴尬了。出于对 程序员张三倔强信条 的尊重,决定来 破破破解 这个游戏,让电脑做它最擅长的事情。

1. 问题抽象

image.png

玩两局,虽然没通过,却很容易总结出规律。已知算子,运算符以及计算结果,求解是否存在某种运算公式使等式成立,如果存在,运算公式是什么!

如上述截图,算子就是 [9, 6, 3, 11],运算符是 -/, 计算结果为 1

2. 算法推演

计算机最擅长的就是循环,算法优化的精髓在于减少循环;

我们可以通过递归的方式,遍历所有可能性。具体的做法是,用一个列表存储所有的算子,一个列表存储所用到的运算符,每次从列表中取出两个数字,再取出一个运算符,用运算的结果和算子列表中的其他数字,组成新的算子,重复上述操作,直到算子列表中只有一个数字。此时,再判断该数字与运算结果是否相同。

需要注意的点:

  1. 除法运算的结果为浮点数,所以列表存储的数字也都要当做浮点数。最后一步判断两个浮点是否相等时,需要考虑到误差。
  2. 除法运算中,除数不能为 0,如果遇到除数为 0 则直接跳过。

3. 算法实现

虽然 PHP 是世界上最好的语言,但人生苦短,我用 Python

from typing import List


class NumGame(object):
    EPSILON = 1e-6  # 误差
    RESULTS = []

    def __init__(self, result: int, operators: List[str]):
        self.result = result  # 运算结果
        self.operators = operators

    def helper(self, nums: List[float], chars: List[str]) -> None:
        """
        递归运算
        @params nums: 算子
        @params chars: 运算公式
        """
        # 递归到列表长度为 1,判断是否与计算结果相同
        if len(nums) == 1:
            if abs(nums[0] - self.result) < self.EPSILON:
                self.RESULTS.append(f"{chars[0][1: -1]} = {self.result}")  # 这里1和-1是为了去掉首尾的括号,使公式更美观
            return

        for i in range(len(nums)):
            for j in range(i + 1, len(nums)):
                x, y = nums[i], nums[j]  # 取两个不同的数字
                char_x, char_y = chars[i], chars[j]
                rest_nums = nums[:i] + nums[i + 1:j] + nums[j + 1:]
                rest_chars = chars[:i] + chars[i + 1:j] + chars[j + 1:]

                for operator in self.operators:
                    if operator == "+":
                        self.helper(rest_nums + [x + y], rest_chars + ['(' + char_x + '+' + char_y + ')'])
                    if operator == "-":
                        self.helper(rest_nums + [x - y], rest_chars + ['(' + char_x + '-' + char_y + ')'])
                        self.helper(rest_nums + [y - x], rest_chars + ['(' + char_y + '-' + char_x + ')'])
                    if operator == "*":
                        self.helper(rest_nums + [x * y], rest_chars + ['(' + char_x + '*' + char_y + ')'])
                    if operator == "/":
                        if y != 0:
                            self.helper(rest_nums + [x / y], rest_chars + ['(' + char_x + '/' + char_y + ')'])
                        if x != 0:
                            self.helper(rest_nums + [y / x], rest_chars + ['(' + char_y + '/' + char_x + ')'])

3. 优化改进

是不是有 "海岛奇兵" 抢地盘的感觉,不管你有没有,反正我有;

用上面的代码,轻松拿下 关卡 1,本以为可以就这么轻松愉快的玩耍啦,结果 关卡 2 提示说,还可以把数字合并到一起,而且这个合并很有意思。针对测试算子 [9, 6, 3, 11],可以去除两个或两个以上的数字,组成新的算子,比如取出 [6, 3] 这两个数字,可以组成 6336,即我们的算子列表可以是 [9, 36, 11][9, 63, 11]

如此这般,算子的可能性将大大增加,不过值得庆幸的是,计算机擅长计算;

from copy import deepcopy
from typing import List
import itertools

class NumGame(object):
    ...   # 此处省略掉上面代码

    @staticmethod
    def diff(a: List[float], b: List[float]) -> List[float]:
        # 两个列表的差值: 去掉 a 列表中 b 的值
        # 考虑到数字可能重复,舍弃掉 set.difference
        new_a = deepcopy(a)
        for item in deepcopy(b):
            new_a.remove(item)
        return new_a

    def permutations(self, nums: List[float]) -> List[List[float]]:
        """
        计算数字列表的排列组合
        @params nums: 数字列表
        """
        results = []
        for i in range(len(nums)):
            tmp = []
            if i < 2:
                continue
            for item in list(itertools.permutations(nums, i)):
                tmp.append(item)
            for item in set(tmp):
                diff = self.diff(a=nums, b=item)
                diff += [int(''.join(list(map(str, item))))]
                results.append(diff)
        return results
        
    def run(self, nums: List[float]):
        permutations = self.permutations(nums)
        permutations.append(nums)
        for item in permutations:
            self.helper(item, [str(i) for i in item])
        print(set(self.RESULTS))

实现逻辑很简单,通过 itertools.permutations 方法获得算子列表排列组合。需要注意如下几点:

  1. 获取算子排列组合后,需要去重,有可能得到重复结果;
  2. 数字列表组合时,先转换成字符串列表,列表字符串元素相加后,再强制转换成数字。int(''.join(list(map(str, item)))) 这是中鸡贼的做法,当然你也可以按位乘10计算;
  3. 计算两个列表差集,最简单的办法是 set(a).difference(set(b)),非常方便获得存在于 a 列表中却不存在于 b 列表的元素,但 python 的元组有去重功能。考虑到列表中可能存在重复数字,用笨办法循环 remove即可;

4. 测试结果

完整代码

from copy import deepcopy
from typing import List
import itertools


class NumGame(object):



EPSILON = 1e-6  # 误差
RESULTS = []

def __init__(self, result: int, operators: List[str]):
    self.result = result  # 运算结果
    self.operators = operators

def helper(self, nums: List[float], chars: List[str]) -> None:
    """
    递归运算
    @params nums: 算子
    @params chars: 运算公式
    """
    # 递归到列表长度为 1,判断是否与计算结果相同
    if len(nums) == 1:
        if abs(nums[0] - self.result) < self.EPSILON:
            self.RESULTS.append(f"{chars[0][1: -1]} = {self.result}")  # 这里1和-1是为了去掉首尾的括号,使公式更美观
        return

    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            x, y = nums[i], nums[j]  # 取两个不同的数字
            char_x, char_y = chars[i], chars[j]
            rest_nums = nums[:i] + nums[i + 1:j] + nums[j + 1:]
            rest_chars = chars[:i] + chars[i + 1:j] + chars[j + 1:]

            for operator in self.operators:
                if operator == "+":
                    self.helper(rest_nums + [x + y], rest_chars + ['(' + char_x + '+' + char_y + ')'])
                if operator == "-":
                    self.helper(rest_nums + [x - y], rest_chars + ['(' + char_x + '-' + char_y + ')'])
                    self.helper(rest_nums + [y - x], rest_chars + ['(' + char_y + '-' + char_x + ')'])
                if operator == "*":
                    self.helper(rest_nums + [x * y], rest_chars + ['(' + char_x + '*' + char_y + ')'])
                if operator == "/":
                    if y != 0:
                        self.helper(rest_nums + [x / y], rest_chars + ['(' + char_x + '/' + char_y + ')'])
                    if x != 0:
                        self.helper(rest_nums + [y / x], rest_chars + ['(' + char_y + '/' + char_x + ')'])

@staticmethod
def diff(a: List[float], b: List[float]) -> List[float]:
    # 两个列表的差值: 去掉 a 列表中 b 的值
    # 考虑到数字可能重复,舍弃掉 set.difference
    new_a = deepcopy(a)
    for item in deepcopy(b):
        new_a.remove(item)
    return new_a

def permutations(self, nums: List[float]) -> List[List[float]]:
    """
    计算数字列表的排列组合
    @params nums: 数字列表
    """
    results = []
    for i in range(len(nums)):
        tmp = []
        if i < 2:
            continue
        for item in list(itertools.permutations(nums, i)):
            tmp.append(item)
        for item in set(tmp):
            diff = self.diff(a=nums, b=item)
            diff += [int(''.join(list(map(str, item))))]
            results.append(diff)
    return results

def run(self, nums: List[float]):
    permutations = self.permutations(nums)
    permutations.append(nums)
    for item in permutations:
        self.helper(item, [str(i) for i in item])
    print(set(self.RESULTS))


# 测试用例
game = NumGame(result=1, operators=list("-/"))
cards = [9, 6, 3, 11]
game.run(cards)
# 运算组合
6-(11-(9-3)) = 1
(9-(3-6))-11 = 1
(9-(11-6))-3 = 1
9-(3-(6-11)) = 1
(9-11)-(3-6) = 1
(6/(11-9))/3 = 1
(11-9)/(6/3) = 1
(6-(3-9))-11 = 1
3/(6/(11-9)) = 1
9-(11-(6-3)) = 1
9/(11-(6/3)) = 1
(9-3)-(11-6) = 1
6-(3-(9-11)) = 1
(6-11)-(3-9) = 1
(6-3)-(11-9) = 1
(11-(6/3))/9 = 1
(6-(11-9))-3 = 1
(6/3)/(11-9) = 1

5. 改进方向

从计算结果来看,虽然等式成立,但细心的你很快就发现,算子重复使用了。如题,我们只有两个减法运算符和一个除法,而算法给出的答案里,竟然出现了三个减法或两个除法的情况。

算法还有有待改进,接下来就留给各位掘友啦;

评论区回复时请折叠代码,避免扎眼球,Markdown 折叠代码方案参考如下

<details>
    <summary>完整代码</summary>
    <pre><code>
    your hello world
    </code></pre>
</details>

点赞,回复,关注,加好友等等,少侠来一个吧!