hans7 发表于 2023-12-8 14:46

【算法】苹果一道“24点”类型的面试题详细解析,小班同学也直呼通俗易懂

本帖最后由 hans7 于 2023-12-14 00:58 编辑

## 引言

今天看到一道24点类型的问题,题目来自(https://b23.tv/kEQpICc)。因为原题题意不清晰,所以我在此描述得更清晰一些:

对于`0 0 0`到`10 10 10`这11组数,请填入运算符凑出6。允许使用的二元运算符包括`** * / % + - << >> & | ^`,允许使用的一元运算符包括`!, sqrt, fac`(根号、阶乘),允许使用括号。每一次的二元运算结果都可以使用至多一次一元运算符,比如`((0! + 0!)! + 0!)! = 6`是合法的。其中除法需要除得尽才合法。

[本文GitHub传送门](https://github.com/Hans774882968/apple-interview-24point-similar)

## 表达式树介绍

24点的dfs比较好理解,但如果没有专门学习过很难自己想出来。首先我们要了解**表达式树**。以下面这棵表达式树为例:

```
*
/ \
1   +
   / \
2   3
```

它表示表达式`1 * (2 + 3)`。表达式树的叶子节点为数字,非叶子节点为二元运算符。我们dfs遍历这棵树,走到非叶子节点,需要先获取左右子树的运算结果,再根据该点的符号获取当前子树的运算结果。

表达式树也可以很轻松地表示一元运算符。比如:

```
*
/ \
1   +
   / \
2fac
      |
      3
```

表示表达式`1 * (2 + 3!)`。实际上,表达式树是**抽象语法树**(Abstract Syntax Tree,AST)的特殊情况,如果你有前端基础,可以进(https://astexplorer.net/)快速学习。

## 算法实现

然后我们就可以理解24点的dfs了。最初有`n`个数,相当于有`n`个单节点的树。我们每次选择两棵子树进行合并,合并`n - 1`次后就得到最终的表达式树。使用dfs枚举所有可能的合并方案即可。

接下来看下实现,以(https://acm.hdu.edu.cn/showproblem.php?pid=1427)为例:

```cpp
#include <bits/stdc++.h>
using namespace std;

typedef long long LL;

const int SZ = 4;

char s;
bool vis;
int a;

template<typename Type>inline void read (Type &xx) {
    Type f = 1;
    char ch;
    xx = 0;
    for (ch = getchar(); ch < '0' || ch > '9'; ch = getchar() ) if (ch == '-') f = -1;
    for (; ch >= '0' && ch <= '9'; ch = getchar() ) xx = xx * 10 + ch - '0';
    xx *= f;
}

int getn (char s[]) {
    if (strlen (s) == 2) return 10;
    else if (s == 'A') return 1;
    else if (s == 'J') return 11;
    else if (s == 'Q') return 12;
    else if (s == 'K') return 13;
    return s - '0';
}

bool dfs (int dep) {
    if (dep == SZ - 1) {
      for (int i = 0; i < SZ; ++i) {
            if (!vis) {
                if (a == 24) return true;
            }
      }
      return false;
    }
    bool fl = false;
    for (int i = 0; i < SZ; ++i) {
      if (vis) continue;
      for (int j = i + 1; j < SZ; ++j) {
            if (vis) continue;
            vis = true;
            int x = a, y = a;
            if (y && x % y == 0) {
                a = x / y;
                if (dfs (dep + 1) ) fl = true;
            }
            if (x && y % x == 0) {
                a = y / x;
                if (dfs (dep + 1) ) fl = true;
            }
            a = x + y;
            if (dfs (dep + 1) ) fl = true;
            a = x - y;
            if (dfs (dep + 1) ) fl = true;
            a = y - x;
            if (dfs (dep + 1) ) fl = true;
            a = x * y;
            if (dfs (dep + 1) ) fl = true;
            a = x;
            vis = false;
      }
    }
    return fl;
}

int main (int argc, char **argv) {
    while (~scanf ("%s%s%s%s", s, s, s, s) ) {
      for (int i = 0; i < SZ; ++i)
            a = getn (s);
      memset (vis, 0, sizeof vis);
      puts (dfs (0) ? "Yes" : "No");
    }
    return 0;
}
```



1. `vis = true`表示`i`号子树属于其他的树,`vis = false`表示`i`号树未被合并过。
2. `a`存`i`号子树的求值结果。
3. 这里是用`i`号来存合并后的树,你想改成`j`号也行。如果你想early return,需要记得**恢复现场**:`a = x; vis = false; return true;`。
4. 如果你想获取生成的表达式,那么你必须引入一个`string ans`。

接下来考虑一元运算符怎么实现。

1. 对于叶子,我们在dfs外部枚举4进制位,约定0表示不变,1表示变为阶乘,2表示变为根号,3表示取反。如果要支持其他一元运算符,比如`- ~`,就相应改成枚举5、6进制位即可。
2. 对于非叶子,在dfs内部进行变换即可。

现在我们已经理解了算法,接下来只需要讲下实现上的细节:

1. 每个运算符都需要考虑准入条件。比如`**, fac`需要防止结果过大(引入`powTooLarge`函数)、`/ %`需要保证除数不为0、`sqrt`需要保证原数是完全平方数(引入`isPerfectSquare`函数)。
2. 输出量较大,所以我们把输出重定向到文件了。
3. 为了方便地验证输出结果的正确性,我们拼接了一段js代码,结构:`let fac = (v) => v > 0 ? v * fac(v - 1) : 1, { sqrt } = Math;\n[].every((v) => v === 6);`。把代码直接复制到浏览器控制台,执行结果为true即符合预期。

下面我给出两版代码,`v2`相比于`v1`只多了上述一元运算符实现点的“2”,但代码量却达到了两倍。

执行结果:

1. `v1`执行时间可以接受,`v2`执行时间以分钟计。
2. `8 8 8`没有找到答案,其他的都找到了不少答案。不过如果允许构造一个集合的话,就有一个比较离谱的答案:`|{ 8, 8, 8 }|! = 6`。

`v1`

```cpp
#include <bits/stdc++.h>
using namespace std;
using LL = int64_t;
// Copyright 2023 hans7

const int N = 3, M = 11;
bool vis;
int p4;
LL fac, a;
string ans;
set<string> ansSet;

void dbg() { puts(""); }
template <typename T, typename... R>
void dbg(const T &f, const R &...r) {
cout << f << " ";
dbg(r...);
}

bool powTooLarge(LL x, LL y) {
if (y >= 63) return x >= 2;
return y * log(x) >= 43.66827237527655;
}

bool isPerfectSquare(LL v) {
LL root = sqrt(v);
return root * root == v;
}

bool dfs(int dep) {
if (dep + 1 == N) {
    for (int i = 0; i < N; i++) {
      if (!vis) {
      if (a == 6) {
          ansSet.insert(ans);
      }
      return a == 6;
      }
    }
    return false;
}
bool fl = false;
for (int i = 0; i < N; i++) {
    if (vis) continue;
    for (int j = i + 1; j < N; j++) {
      if (vis) continue;
      vis = true;
      LL x = a, y = a;
      string tmpAnsI = ans, tmpAnsJ = ans;

      if (!powTooLarge(x, y)) {
      a = round(pow(x, y));
      ans = "(" + tmpAnsI + ") ** (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;
      }
      if (!powTooLarge(y, x)) {
      a = round(pow(y, x));
      ans = "(" + tmpAnsJ + ") ** (" + tmpAnsI + ")";
      if (dfs(dep + 1)) fl = true;
      }

      if (y && x % y == 0) {
      a = x / y;
      ans = "(" + tmpAnsI + ") / (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;
      }
      if (x && y % x == 0) {
      a = y / x;
      ans = "(" + tmpAnsJ + ") / (" + tmpAnsI + ")";
      if (dfs(dep + 1)) fl = true;
      }

      a = x * y;
      ans = "(" + tmpAnsI + ") * (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;

      if (y) {
      a = x % y;
      ans = "(" + tmpAnsI + ") % (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;
      }
      if (x) {
      a = y % x;
      ans = "(" + tmpAnsJ + ") % (" + tmpAnsI + ")";
      if (dfs(dep + 1)) fl = true;
      }

      a = x + y;
      ans = "(" + tmpAnsI + ") + (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;

      a = x - y;
      ans = "(" + tmpAnsI + ") - (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;
      a = y - x;
      ans = "(" + tmpAnsJ + ") - (" + tmpAnsI + ")";
      if (dfs(dep + 1)) fl = true;

      a = x << y;
      ans = "(" + tmpAnsI + ") << (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;
      a = y << x;
      ans = "(" + tmpAnsJ + ") << (" + tmpAnsI + ")";
      if (dfs(dep + 1)) fl = true;

      a = x >> y;
      ans = "(" + tmpAnsI + ") >> (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;
      a = y >> x;
      ans = "(" + tmpAnsJ + ") >> (" + tmpAnsI + ")";
      if (dfs(dep + 1)) fl = true;

      a = x & y;
      ans = "(" + tmpAnsI + ") & (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;

      a = x | y;
      ans = "(" + tmpAnsI + ") | (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;

      a = x ^ y;
      ans = "(" + tmpAnsI + ") ^ (" + tmpAnsJ + ")";
      if (dfs(dep + 1)) fl = true;

      a = x;
      ans = tmpAnsI;
      vis = false;
    }
}
return fl;
}

int main(int, char **) {
fstream file;
file.open("apple_interview_24point_similar-v1.txt", ios::out);
streambuf *stream_buffer_out = cout.rdbuf();
cout.rdbuf(file.rdbuf());
for (int i = 0; i <= N; i++) p4 = i ? p4 * 4 : 1;
for (int i = 0; i < M; i++) fac = i ? fac * i : 1;
vector<size_t> ansSetSizes;
for (int i = 0; i < M; i++) {
    ansSet.clear();
    bool hasAns = false;
    for (int S = 0; S < p4; S++) {
      for (int j = 0; j < N; j++) {
      int v = 0;
      string strV;
      int sVal = S / p4 % 4;
      if (sVal == 1 && isPerfectSquare(i)) {
          v = sqrt(i);
          strV = "sqrt(" + to_string(i) + ")";
      } else if (sVal == 2) {
          v = fac;
          strV = "fac(" + to_string(i) + ")";
      } else if (sVal == 3) {
          v = !i;
          strV = "!" + to_string(i);
      } else {
          v = i;
          strV = to_string(i);
      }
      a = v;
      ans = strV;
      }
      hasAns |= dfs(0);
    }
    ansSetSizes.push_back(ansSet.size());
    cout << i << " " << hasAns << endl;
    cout << "ansSet.size() = " << ansSet.size() << endl;
    string jsCode =
      "let fac = (v) => v > 0 ? v * fac(v - 1) : 1, { sqrt } = Math;\n[";
    for (auto v : ansSet) {
      jsCode += v + ", ";
    }
    jsCode += "].every((v) => v === 6);";
    cout << jsCode << endl;
}
cout << "ansSetSizes = [";
ostream_iterator<size_t> out_iter(cout, ", ");
copy(ansSetSizes.cbegin(), ansSetSizes.cend(), out_iter);
cout << "]" << endl;
cout.rdbuf(stream_buffer_out);
file.close();
return 0;
}
```

`v2`代码过长,在此不贴出,[传送门](https://github.com/Hans774882968/apple-interview-24point-similar/blob/main/apple_interview_24point_similar-v2.cpp)

hans7 发表于 2023-12-14 01:13

天下客 发表于 2023-12-13 17:56
如果规定数字是short int或int,然后加入!运算符会不会好一些

已经添加,commit:

https://github.com/Hans774882968/apple-interview-24point-similar/commit/65d255cfc6e22530eb7e4add255639afe9524047

hans7 发表于 2024-4-4 23:24

InWell 发表于 2024-4-1 21:10
代码上加点注释会可能会直观一点,思路都是深度遍历出所有可能找到正确结果。

对,整体思路就是dfs,只是因为运算符多代码才看上去很多。我当时的想法是,只要每种情况用一个空行隔开,就足够直观了,所以就没写注释了。

turmasi1234 发表于 2023-12-8 17:05

感谢楼主的分享

BonnieRan 发表于 2023-12-8 17:34

mark一下,明天心情好了再学

moruye 发表于 2023-12-8 21:38

lookfeiji 发表于 2023-12-9 11:29

没学过,但是貌似对于大型数据处理很有用的样子

hans7 发表于 2023-12-9 23:50

lookfeiji 发表于 2023-12-9 11:29
没学过,但是貌似对于大型数据处理很有用的样子

并木有:'(weeqw

li1991x 发表于 2023-12-11 00:24


mark一下

Mr.Cookie 发表于 2023-12-11 01:06

没看明白

alanfish 发表于 2023-12-11 08:01

很像天书。。。。。。。

xlwllm 发表于 2023-12-11 10:19

值得学习一下
页: [1] 2 3 4 5
查看完整版本: 【算法】苹果一道“24点”类型的面试题详细解析,小班同学也直呼通俗易懂