如何使用递归return N次掷硬币的所有组合?

How to return all combinations of N coin flips using recursion?

要求:

使用JavaScript,编写一个接受整数的函数。整数表示抛硬币的次数。仅使用递归策略,return 一个包含所有可能的抛硬币组合的数组。用“H”代表正面,用“T”代表反面。组合的顺序无关紧要。

例如,传入“2”将 return: ["HH", "HT", "TH", "TT"]

上下文:

我对 JavaScript 以及递归的概念都比较陌生。这纯粹是为了练习和理解,所以解决方案不一定需要符合我下面代码的方向;任何有用的方法或其他思考方式都是有帮助的,只要它是纯递归的(无循环)。

尝试:

我的尝试一开始很简单,但是随着输入的增加,“动作”逐渐变得更加复杂。我相信这适用于 2、3 和 4 的输入。但是,5 或更高的输入在输出中缺少组合。非常感谢!

function coinFlips(num){
  const arr = [];
  let str = "";

  // adds base str ("H" * num)
  function loadStr(n) {
    if (n === 0) {
      arr.push(str);
      return traverseArr();
    }
    str += "H";
    loadStr(n - 1);
  }
  
  // declares start point, end point, and index to update within each str
  let start = 0;
  let end = 1;
  let i = 0;

  function traverseArr() {

    // base case
    if(i === str.length) {
      console.log(arr);
      return arr;
    }

    // updates i in base str to "T"
    // increments i
    // resets start and end
    if(end === str.length) {
      str = str.split('');
      str[i] = "T";
      str = str.join('');
      i++;
      start = i;
      end = i + 1;
      return traverseArr();
    }

    // action
    let tempStr = str.split('');
    tempStr[start] = "T";
    tempStr = tempStr.join('');
    if(!arr.includes(tempStr)){
      arr.push(tempStr);
    };
    tempStr = tempStr.split('');
    tempStr.reverse();
    tempStr = tempStr.join('');
    if(!arr.includes(tempStr)){
      arr.push(tempStr);
    };

    tempStr = str.split('');
    tempStr[end] = "T";
    tempStr = tempStr.join('');
    if(!arr.includes(tempStr)){
      arr.push(tempStr);
    };
    tempStr = tempStr.split('');
    tempStr.reverse();
    tempStr = tempStr.join('');
    if(!arr.includes(tempStr)){
      arr.push(tempStr);
    };

    tempStr = str.split('');
    tempStr[start] = "T";
    tempStr[end] = "T";
    tempStr = tempStr.join('');
    if(!arr.includes(tempStr)){
      arr.push(tempStr);
    };
    tempStr = tempStr.split('');
    tempStr.reverse();
    tempStr = tempStr.join('');
    if(!arr.includes(tempStr)){
      arr.push(tempStr);
    };

    // recursive case
    start++;
    end++;
    return traverseArr();
  }

  loadStr(num);
}

coinFlips(5);
function getFlips(n) {
    // Helper recursive function
    function addFlips(n, result, current) {
        if (n === 1) {
            // This is the last flip, so add the result to the array
            result.push(current + 'H');
            result.push(current + 'T');
        } else {
            // Let's say current is TTH (next combos are TTHH and TTHT)
            // Then for each of the 2 combos call add Flips again to get the next flips.
            addFlips(n - 1, result, current + 'H');
            addFlips(n - 1, result, current + 'T');
        }
    }
    // Begin with empty results
    let result = [];
    // Current starts with empty string
    addFlips(n, result, '');
    return result;
}

如果这有任何意义,这里有一个不使用递归但使用 Applicative 类型的解决方案。


除了n为1时,所有可能组合的列表由每次抛硬币的所有可能结果组合得到:

  • 22 → [H, T] × [H, T] → [HH, HT, TH, TT]
  • 23 → [H, T] × [H, T] × [H, T] → [HHH, HHT, HTH, HTT, THH, THT, TTH , TTT]
  • ...

一个可以接受 n 个字符并将它们连接起来的函数可以这样写:

const concat = (...n) => n.join('');

concat('H', 'H');           //=> 'HH'
concat('H', 'H', 'T');      //=> 'HHT'
concat('H', 'H', 'T', 'H'); //=> 'HHTH'
//...

生成 n 次抛硬币结果列表的函数可以这样写:

const outcomes = n => Array(n).fill(['H', 'T']);

outcomes(2); //=> [['H', 'T'], ['H', 'T']]
outcomes(3); //=> [['H', 'T'], ['H', 'T'], ['H', 'T']]
// ...

我们现在可以在这里看到一个解决方案:要获得所有可能组合的列表,我们需要在所有列表中应用 concat

但是我们不想那样做。相反,我们希望 concat 使用值容器而不是单个值。

这样:

concat(['H', 'T'], ['H', 'T'], ['H', 'T']);

产生与以下相同的结果:

[ concat('H', 'H', 'H')
, concat('H', 'H', 'T')
, concat('H', 'T', 'H')
, concat('H', 'T', 'T')
, concat('T', 'H', 'H')
, concat('T', 'H', 'T')
, concat('T', 'T', 'H')
, concat('T', 'T', 'T')
]

在函数式编程中我们说我们想要lift concat. In this example I'll be using Ramda's liftN函数。

const flip = n => {
  const concat = liftN(n, (...x) => x.join(''));
  return concat(...Array(n).fill(['H', 'T']));
};

console.log(flip(1));
console.log(flip(2));
console.log(flip(3));
console.log(flip(4));
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.27.1/ramda.min.js"></script>
<script>const {liftN} = R;</script>

下面是关于如何创建此类递归函数的详细说明。我认为所描述的步骤有助于解决大量问题。它们不是灵丹妙药,但它们可能非常有用。但首先,我们将努力实现以下目标:

const getFlips = (n) =>
  n <= 0
    ? ['']
    : getFlips (n - 1) .flatMap (r => [r + 'H', r + 'T'])

确定我们的算法

要递归解决这样的问题,我们需要回答几个问题:

我们的循环价值是多少?

对于简单的递归,它通常是单个数字参数。在所有情况下,都必须有一种方法来证明我们正在朝着某个最终状态取得进展。

这是一个简单的案例,很明显我们想要在翻转次数上进行递归;我们称它为 n.

我们的递归什么时候结束?

我们最终需要停止重复发生。在这里,我们可能会考虑在 n 为 0 或可能在 n 为 1 时停止。两种选择都可行。让我们暂时搁置这个决定,看看哪个可能更简单。

我们如何将一步的答案转换为下一步的答案?

要让递归做任何有用的事情,重要的部分是根据当前步骤计算下一步的结果。

(同样,对于更多涉及的递归,这里可能存在复杂性。例如,我们可能必须使用 all 较低的结果来计算下一个值。有关示例,请查找Catalan Numbers。在这里我们可以忽略它;我们的递归很简单。)

那么我们如何将 ['HH', 'HT', 'TH', 'TT'] 转换为下一步 ['HHH', 'HHT', 'HTH', 'HTT', 'THH', 'THT', 'TTH', 'TTT']?好吧,如果我们仔细观察下一个结果,我们可以看到前半部分所有元素都以 'H' 开头,而在后半部分它们以 'T' 开头。如果我们忽略前几个字母,每一半都是我们输入的副本,['HH', 'HT', 'TH', 'TT']。这看起来很有希望!所以我们的递归步骤可以是复制先前结果的两个副本,第一个副本的每个值前面都有 'H',第二个副本前面有 'T'.

我们的基本案例的价值是多少?

这与我们跳过的问题有关。我们不能在不知道什么时候它结束的情况下说什么它结束。但确定两者的一个好方法是向后工作。

要从 ['HHH', 'HHT', 'HTH', 'HTT', 'THH', 'THT', 'TTH', 'TTT'] 倒退到 ['HH', 'HT', 'TH', 'TT'],我们可以取前半部分并从每个结果中删除初始的 'H'。让我们再来一次。从 ['HH', 'HT', 'TH', 'TT'] 开始,我们取前半部分并从每个中删除初始的 'H' 以获得 ['H', 'T']。虽然这可能是我们的停止点,但如果我们更进一步会发生什么?取前半部分并从剩下的一个元素中删除初始 H 只剩下 ['']。这个答案有意义吗?我认为它确实如此:有多少种方法可以将硬币掷零次?只有一个。我们如何将其记录为 HT 的字符串?作为空字符串。因此,仅包含空字符串的数组对于 0 的情况是一个很好的答案。这也回答了我们的第二个问题,即递归何时结束。当 n 为零时结束。

正在为该算法编写代码

当然现在我们必须将该算法转化为代码。我们也可以通过几个步骤完成此操作。

声明我们的函数

我们从函数定义开始编写。我们的参数称为 n。我要调用函数 getFlips。所以我们从

开始
const getFlips = (n) =>
  <something here>

添加我们的基本案例。

我们已经说过我们将在 n 为零时结束。我通常更喜欢通过检查任何小于或等于零的 n 来使其更具弹性。如果有人传递负数,这将停止无限递归。在这种情况下,我们可以选择抛出异常,但我们对 [''] 对零情况的解释似乎也适用于负值。 (此外,我绝对 讨厌 抛出异常!)

这给了我们以下信息:

const getFlips = (n) =>
  n <= 0
    ? ['']
    : <something here>

我在这里选择使用 conditional (ternary) expression 而不是 if-else 语句,因为我更喜欢尽可能使用表达式而不是语句。如果您觉得更自然,可以使用 if-else 轻松编写相同的技术。

处理递归情况

我们的描述是“将之前的结果复制两份,第一份的每个值都以 'H' 开头,第二份以 'T' 开头。”我们之前的结果当然是getFlips (n - 1)。如果我们想在该数组中的每个值之前加上 'H',我们最好使用 .map。我们可以这样来标识:getFlips (n - 1) .map (r => 'H' + r)。当然,下半场只是 getFlips (n - 1) .map (r => 'T' + r)。如果我们想把两个数组合二为一,有很多技巧,包括.push.concat。但是现代的解决方案可能是使用传播参数并且只是 return [...first, ...second].

将所有这些放在一起,我们得到这个片段:

const getFlips = (n) =>
  n <= 0
    ? ['']
    : [...getFlips (n - 1) .map (r => 'H' + r), ...getFlips (n - 1) .map (r => 'T' + r)]


console .log (getFlips (3))

检查结果

我们可以在几个案例中对此进行测试。但我们应该对代码相当信服。它似乎有效,它相对简单,没有明显的边缘情况丢失。但我仍然看到一个问题。我们正在计算 getFlips (n - 1) 两次,没有充分的理由。在递归的情况下,它通常很有问题。

对此有几个明显的修复。首先是放弃我对 expression-based 编程的迷恋,并简单地使用 if-else 逻辑和局部变量:

if-else 语句替换条件运算符

const getFlips = (n) => {
  if (n <= 0) {
    return ['']
  } else {
    const prev = getFlips (n - 1)
    return [...prev .map (r => 'H' + r), ...prev .map (r => 'T' + r)]
  }
}

(从技术上讲,else 不是必需的,有些 linter 会抱怨它。我认为包含它的代码阅读起来更好。)

计算默认参数以用作局部变量

另一种方法是使用早期定义中的参数默认值。

const getFlips = (n, prev = n > 0 && getFlips (n - 1)) =>
  n <= 0
    ? ['']
    : [...prev .map (r => 'H' + r), ...prev .map (r => 'T' + r)]

这可能被正确地视为 over-tricky,当您的函数在意外情况下使用时,它可能会导致问题。例如,不要将其传递给数组的 map 调用。

重新思考递归步骤

以上任何一个都可以。不过还有更好的解决办法。

如果我们能看到另一种将 ['HH', 'HT', 'TH', 'TT'] 转换为 ['HHH', 'HHT', 'HTH', 'HTT', 'THH', 'THT', 'TTH', 'TTT'] 的方法,我们也可以使用不同的递归方法编写相同的代码。我们的技术是从中间拆分数组并删除第一个字母。但是在数组版本中还有该基本版本的其他副本,但没有一个字母。如果我们从每个字符串中删除 last 个字母,我们会得到 ['HH', 'HH', 'HT', 'HT', 'TH', 'TH', 'TT', 'TT'],这只是我们的原始版本,每个字符串出现两次。

第一个想到的实现它的代码就是 getFlips (n - 1) .map (r => [r + 'H', r + 'T'])。但这会被巧妙地关闭,因为它将 ['HH', 'HT', 'TH', ' TT'] 转换为 [["HHH", "HHT"], ["HTH", "HTT"], ["THH", "THT"], [" TTH", " TTT"]],并带有额外的嵌套级别,并且递归应用只会产生无意义的结果。但是有一个 .map 的替代方法,它删除了额外的嵌套级别,.flatMap.

这使我们找到了一个我非常满意的解决方案:

const getFlips = (n) =>
  n <= 0
    ? ['']
    : getFlips (n - 1) .flatMap (r => [r + 'H', r + 'T'])

console .log (getFlips (3))