24点计算器问题[C++实现]

24 点游戏是一个很有意思的数字游戏,也是一道常见的算法面试题。题目是这样的:任给四个数(为了便于人们心算或口算,一般都是小于 10 的数),对四个数字用各种组合进行加、减、乘、除四则运算,看看结果是否能等于 24?对于面试题来说,这是一个典型的穷举类型算法问题。这个题目比较有意思的地方是它除了要对数字组合进行枚举,还要对四个运算符进行组合。我们要介绍的方法有点特殊,它没有简单地使用穷举遍历,而是采用穷举法和分治法相结合的方法来解决这个问题,这种方法比数字 + 运算符一起枚举的方法简单,容易理解,整个算法只有大约 40 行有效代码,其中主体部分有效代码只有不到 20 行,快来看看是怎么回事儿吧。

问题分析和建模

这个算法的难点主要两个

  1. 数字的运算符和穷举遍历
  2. 将四则运算表达式作为结果输出

引言部分提到过,我们这个方法会用到分治法,分治法的主要特点之一就是通过分解子问题的方式减小问题的规模,怎么划分子问题呢?子问题和原始问题必须是同构的,所谓同构就是问题必须是一样的,问题的模式不能变,能变的只是问题的规模。

对于这个问题来说,原始问题的规模是 4 个数字计算 24 点,那么分解子问题可以从两个方向考虑:

  • 一种是只考虑减少问题的规模,对于这个问题来说,减少规模不就是变成 3 个数字计算 24 点吗?然后再减少为两个数字计算 24 点,以此类推,直到问题能够直接求解为止;
  • 另一种是在减少问题规模的同时,调整结果的范围,同样,对这个问题来说,假如说我将问题规模从 4 个变成 3 个,被排除的数字是 3,那么子问题就应该变成“3 个数字计算 21 点”。进一步将问题规模减少成两个数字时,假如被排除的数字是 7,则子问题就变成“2 个数字计算 14 点”,以此类推,直到问题能直接解决为止。

我们的思路是每次从 4 个数字中任选两个,分别应用加、减、乘、除四种运算方法得到 4 个计算结果,每个计算结果与剩下的 2 个数字一起组成一个规模为 3 个数字的子问题,一共可以得到 4 个子问题,其变化过程如图所示(图中第一行的数字为 4 个数字的索引位置,第二行的数字分别是 4 个待计算数字,第三行是计算过程),使用第一个数字和第二个数字组合计算,得到了一组 4 个子问题,然后用第一个数字和第三个数字组合计算,得到了另一组 4 个子问题:

从 3 个数字中任选两个,然后应用加、减、乘、除四种运算方法得到 4 个计算结果,每个计算结果与剩下的 1 个数字组成一个规模为两个数字的子问题,又可以得到 4 个子问题。从 3 个数字中任选两个进行不重复的排列,可以得到 $P_{3}^{2} $= 6个组合结果,也就是说总共有 6 × 4 = 24 个规模为两个数字的子问题,下图展示了其中一组,也就是第一个数 3/7 和第二个数 3 的组合情况:

第三层组合计算将问题规模减少到1个数字

以上就是我们介绍的穷举法 + 分治法解决 24 点计算问题的算法分析过程。前面提到过,这个问题的难点有两个,上述分析过程解决了第一个,即数字和运算符的穷举遍历问题。**还有第二个问题,也就是将四则运算表达式作为结果输出的问题没有解决。**可能大家已经从几个图上看到了,图的第三行就是最后要输出的中缀表达式,这是怎么做到的呢?其实很简单,我们给每个数字都指定了一个“出身”,所谓的“出身”就是描述这个数字的来历,或者是计算过程。每个数字的“出身”记录了这个数字的计算过程,当数字被带入到子问题的时候,这个计算过程也跟着被带入到子问题,并随着子问题的求解过程一步一步带到最后。对于原问题来说,4 个数字的出身就是数字本身,当两个数字参与一次计算称为一个结果数字时,就将这两个数字的计算过程作为结果数字的“出身”。

好了,根据上面的分析,我们已经明确了问题和子问题的定义,就是“用 m 个数排列组合计算 24 点 ( 1 ⩽ m ⩽ 4 ) (1\leqslant m \leqslant 4) 1m4”。所以我们的子问题的参数就是 m 个数,考虑用数组来组织这 m 个数。每个数除了数字本身,还有一个出身,用以下数据结构来描述这个“数”:

typedef struct
{
    double num;
    std::string num_str;
}Number;

num_str 是这个数的“出身”,用字符串描述没问题,num是数字本身,但是数据类型用了 double,这也是实际计算过程的需要,毕竟从上图中也能看到,我们的计算方法是支持分数形式的,中间计算过程会出现浮点数,最终子问题定义就是 Calc24()函数的参数:

void Calc24(const std::vector<Number>& nums)
{
    //求解子问题
}

//原始问题的定义
std::vector<Number> numbers = { { 3, "3" },{ 3, "3" },{ 7, "7" },{ 7, "7" } };
Calc24(numbers);

算法实现

递归作为一种算法的实现方式,与分治法是一对儿天然的好朋友

Calc24() 函数对子问题进行处理的时候,要对子问题规模是 1 个数的情况做处理,这实际上也是递归函数的退出(递归终止)条件。对于这个问题来说,当子问题的规模是 1 个数的时候,就要检查这个数是否是 24,如果是则输出一组结果,并退出递归处理;如果不是,说明这个穷举出来的结果是个无效结果,直接退出递归处理。这部分判断和处理的实现在第 4 行开始的 if (count == 1) 处理流程里,比较简单,就不多说了。对于子问题规模大于 1 的情况,就要选两个数进行计算,对于 P n 2 P_{n}^{2} Pn2问题,常用的代码实现模式就是两重循环。

void Calc24(std::vector<Number>& nums)
{
    std::size_t count = nums.size();
    if (count == 1) //当只有一个数时,说明计算完成,可以判断结果了
    {
        if (nums[0].num == 24)
        {
            std::cout << nums[0].num_str << " = " << nums[0].num << std::endl;
        }
        return;
    }

    //两重循环,从 nums 中找两个数的组合
    for (std::size_t i = 0; i < count; i++)
    {
        for (std::size_t j = 0; j < count; j++)
        {
            if (i == j) //排除相同的情况
                continue;

            for (auto& op : acops) //对四种运算进行枚举
            {
                Number new_num;
                //运算可能失败,比如除数是 0 的情况,不再继续处理这个运算符,相当于剪枝效果
                if (op(nums[i], nums[j], new_num))
                {
                    std::vector<Number> sub_nums;//定义子问题
                    sub_nums.push_back(new_num);
                    //除了被选出来的两个数,将剩下的数加入子问题
                    for (std::size_t k = 0; k < count; k++) 
                    {
                        if ((k != i) && (k != j))
                        {
                            sub_nums.push_back(nums[k]);
                        }
                    }
                    Calc24(sub_nums); //解决子问题
                }
            }
        }
    }
}

现在说说 acops,它是一个计算函数的数组,定义了对两个操作数的加、减、乘、除四种运算。std::function<…>是个可调用对象包装器,这里包装的是一个这样的调用接口:

bool (const Number&, const Number&, Number&)

这个接口有两个 const Number& 类型的入参,一个 Number& 类型的出参和一个 bool类型的返回值,两个入参是参与计算的操作数,出参是计算的结果。四个操作符对应的可调用对象是用lamda 表达式定义的操作函数。这些操作函数的的作用很简单,就是计算两个操作数,当然,还有很重要的一点,就是拼装计算结果的“出身”。前面分析算法的时候提到过,一个数的“出身”很重要,即使数字本身算对了,如果“出身”拼装的不正确,输出的结果也不正确。“出身”拼装很简单,就是将参与计算的两个数的“出身”用操作符连接在一起,然后两端加上一对儿括号,就得到结果数字的“出身”了。这里面只有除法比较特殊一点,因为被除数不能为 0,所以加了个判断。当其返回 false 的时候,相当于做了一次剪枝操作。

std::function<bool (const Number&, const Number&, Number&)> acops[] = 
{
    [](const Number& d1, const Number& d2, Number& dr) 
    { 
        dr.num = d1.num + d2.num; 
        dr.num_str = '(' + d1.num_str + '+' + d2.num_str + ')';
        return true; 
    },
    [](const Number& d1, const Number& d2, Number& dr) 
    { 
        dr.num = d1.num - d2.num;
        dr.num_str = '(' + d1.num_str + '-' + d2.num_str + ')';
        return true;
    },
    [](const Number& d1, const Number& d2, Number& dr) 
    {
        dr.num = d1.num * d2.num;
        dr.num_str = '(' + d1.num_str + '*' + d2.num_str + ')';
        return true;
    },
    [](const Number& d1, const Number& d2, Number& dr) 
    {
        if (d2.num == 0)
            return false;
        dr.num = d1.num / d2.num;
        dr.num_str = '(' + d1.num_str + '/' + d2.num_str + ')';
        return true;
    }
};

完整代码

#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <utility>

typedef struct
{
    double num;
    std::string num_str;
}Number;


std::function<bool (const Number&, const Number&, Number&)> acops[] = 
{
    [](const Number& d1, const Number& d2, Number& dr) 
    { 
        dr.num = d1.num + d2.num; 
        dr.num_str = '(' + d1.num_str + '+' + d2.num_str + ')';
        return true; 
    },
    [](const Number& d1, const Number& d2, Number& dr) 
    { 
        dr.num = d1.num - d2.num;
        dr.num_str = '(' + d1.num_str + '-' + d2.num_str + ')';
        return true;
    },
    [](const Number& d1, const Number& d2, Number& dr) 
    {
        dr.num = d1.num * d2.num;
        dr.num_str = '(' + d1.num_str + '*' + d2.num_str + ')';
        return true;
    },
    [](const Number& d1, const Number& d2, Number& dr) 
    {
        if (d2.num == 0)
            return false;
        dr.num = d1.num / d2.num;
        dr.num_str = '(' + d1.num_str + '/' + d2.num_str + ')';
        return true;
    }
};

void Calc24(const std::vector<Number>& nums)
{
    std::size_t count = nums.size();
    if (count == 1) //当只有一个数时,说明计算完成,可以判断结果了
    {
        if (nums[0].num == 24)
        {
            std::cout << nums[0].num_str << " = " << nums[0].num << std::endl;
        }
        return;
    }

    //两重循环,从numbers中找两个数的组合
    for (std::size_t i = 0; i < count; i++)
    {
        for (std::size_t j = 0; j < count; j++)
        {
            if (i == j) //排除相同的情况
                continue;

            for (auto& op : acops) //对四种运算进行枚举
            {
                Number new_num;
                //运算可能失败,比如除数是0的情况,不再继续处理这个运算符,相当于剪枝效果
                if (op(nums[i], nums[j], new_num))
                {
                    std::vector<Number> sub_nums;//定义子问题
                    sub_nums.push_back(new_num);
                    //除了被选出来的两个数,将剩下的数加入子问题
                    for (std::size_t k = 0; k < count; k++) 
                    {
                        if ((k != i) && (k != j))
                        {
                            sub_nums.push_back(nums[k]);
                        }
                    }
                    Calc24(sub_nums); //解决子问题
                }
            }
        }
    }
}

int main()
{
    std::vector<Number> numbers = { { 3, "3" },{ 3, "3" },{ 7, "7" },{ 7, "7" } };
    //std::vector<Number> numbers = { { 1, "1" },{ 5, "5" },{ 5, "5" },{ 5, "5" } };
    //std::vector<Number> numbers = { { 1, "1" },{ 6, "6" },{ 8, "8" },{ 9, "9" } };
    //std::vector<Number> numbers = { { 2, "2" },{ 7, "7" },{ 6, "6" },{ 3, "3" } };

    Calc24(numbers);

    return 0;
}

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值