【问题标题】:How to return all combinations of N coin flips using recursion?如何使用递归返回 N 个硬币翻转的所有组合?
【发布时间】:2020-08-25 01:52:16
【问题描述】:

请求:

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

例如,传入“2”将返回: ["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);

【问题讨论】:

    标签: javascript recursion combinations coin-flipping


    【解决方案1】:

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

    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' 开头。

    我们的基本情况有什么价值?

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

    要从['HHH', 'HHT', 'HTH', 'HTT', 'THH', 'THT', 'TTH', 'TTT'] 倒退到['HH', 'HT', 'TH', 'TT'],我们可以取前半部分并从每个结果中删除初始的“H”。让我们再来一次。从['HH', 'HT', 'TH', 'TT'],我们取前半部分并从每个部分中删除最初的“H”以得到['H', 'T']。虽然这可能是我们的停止点,但如果我们更进一步会发生什么?取前半部分并从剩下的元素中删除初始的H,只剩下['']。这个答案有意义吗?我认为确实如此:有多少种方法可以将硬币翻转为零次?只有一个。我们如何将它记录为Hs 和Ts 的字符串?作为空字符串。所以一个只包含空字符串的数组对于 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 =&gt; 'H' + r)。当然,后半部分只是getFlips (n - 1) .map (r =&gt; 'T' + r)。如果我们想将两个数组合二为一,有很多技巧,包括.push.concat。但现代解决方案可能是使用传播参数并返回 [...first, ...second]

    将所有这些放在一起,我们得到了这个 sn-p:

    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)。在递归的情况下,它通常很有问题。

    有几个明显的修复方法。首先是放弃我对基于表达式的编程的迷恋,简单地使用带有局部变量的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)]
    

    这可能被正确地视为过于棘手,并且当您的函数在意外情况下使用时可能会导致问题。例如,不要将此传递给数组的 map 调用。

    重新考虑递归步骤

    以上任何一个都可以。但有更好的解决方案。

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

    想到实现这一点的第一个代码就是getFlips (n - 1) .map (r =&gt; [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))

    【讨论】:

      【解决方案2】:
      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;
      }
      

      【讨论】:

        【解决方案3】:

        如果有任何兴趣,这里有一个不使用递归而是使用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')
        ]
        

        在函数式编程中,我们说我们想要liftconcat。在这个例子中,我将使用 Ramda 的 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>

        【讨论】:

        • 一个非常好的方法!写一个可变参数lift,可能仅限于函数,在这种情况下使用会很有趣。
        • 我不确定我之前的评论是否连贯。但另一种方法是一个简单的叉积函数,如const crossproduct = (xss) =&gt; xss .reduce ((ps, xs) =&gt; ps .reduce ((r, p) =&gt; [... r, ... (xs .map ((x) =&gt; [... p, x]))], []), [[]]),具有相同的Array(n).fill(['H', 'T']) 输入。
        • 感谢您的 cmets @ScottSauyet,一直很感激;)即使我非常喜欢您的递归 flatMap,这也是有道理的。我觉得可以用它来完成,但我认为lift 也可以考虑。
        • 当然。我被你的回答迷住了。直到后来我才意识到你所做的已经被 crossproduct 函数覆盖了。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2021-11-30
        • 1970-01-01
        • 2012-04-06
        • 2012-05-11
        • 1970-01-01
        • 2010-10-03
        • 1970-01-01
        相关资源
        最近更新 更多