如何使用递归返回N个抛硬币的所有组合? 我们重复使用什么价值?我们的递归何时结束?我们如何将答案从一个步骤转换为下一个步骤?基本情况的价值是什么?声明我们的功能添加基本情况处理递归案例用if-else语句替换条件运算符计算默认参数以用作局部变量重新考虑递归步骤

问题描述

请求:

使用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);

解决方法

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

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

确定我们的算法

要递归解决此类问题,我们需要回答几个问题:

我们重复使用什么价值?

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

这是一个简单的情况,很明显,我们想重复翻转的次数;我们称之为n

我们的递归何时结束?

我们最终需要停止重复发生。在这里,我们可以考虑在n为0或n为1时停止。让我们暂缓此决定,看看哪个可能更简单。

我们如何将答案从一个步骤转换为下一个步骤?

对于递归来说,做任何有用的事情,重要的是根据当前步骤计算下一步的结果。

(同样,此处涉及更多的递归可能会很复杂。例如,我们可能必须使用 all 较低的结果来计算下一个值。例如,请查看{{3} }。在这里我们可以忽略这一点;我们的递归很简单。)

那么我们如何将['HH','HT','TH','TT']转换为下一步['HHH','HHT','HTH','HTT','THH','THT','TTH','TTT']?好吧,如果我们仔细观察下一个结果,我们可以看到,上半部分所有元素均以“ H”开头,而下半部分中所有元素均以“ T”开头。如果我们忽略前几个字母,那么每半部分都是我们输入['HH','TT']的副本。看起来非常有前途!因此,我们的递归步骤可以是为先前的结果制作两个副本,第一个副本的每个值都以'H'开头,第二个副本的值为'T'

基本情况的价值是什么?

这与我们跳过的问题有关。我们不能说什么结束,而又不知道什么时候结束。但是,要确定这两者的一个好方法就是往后工作。

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

该算法的编写代码

当然,现在我们必须将该算法转换为代码。我们也可以通过几个步骤来做到这一点。

声明我们的功能

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

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

添加基本情况。

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

这给我们以下内容:

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

我在这里选择使用Catalan Numbers而不是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。但是现代的解决方案可能是使用散布参数并只返回[...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)。在递归的情况下,这通常很成问题。

对此有一些明显的修复。首先是放弃对基于表达式的编程的迷恋,而只对局部变量使用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不是必需的,有些短毛猫会抱怨它。我认为其中包含的代码读起来会更好。)

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

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

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

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

重新考虑递归步骤

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

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

想到的实现此功能的第一个代码就是getFlips (n - 1) .map (r => [r + 'H',r + 'T'])。但这将是微妙的,因为它将['HH',' 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))

,
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,current + 'H');
            addFlips(n - 1,current + 'T');
        }
    }
    // Begin with empty results
    let result = [];
    // Current starts with empty string
    addFlips(n,'');
    return result;
}
,

如果有兴趣,这里的解决方案不使用递归,而是使用Applicative类型。


除了 n 为1时,所有可能组合的列表都是通过组合每次掷硬币的所有可能结果获得的:

  • 2 2 →[H,T]×[H,T]→[HH,HT,TH,TT]
  • 2 3 →[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','T','H'); //=> 'HHTH'
//...

可以编写这样的函数来生成 n 掷硬币的结果的列表:

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

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

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

但是我们不想这样做。相反,我们要使concat使用值的容器而不是单个值。

因此:

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

产生与以下相同的结果

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

在函数式编程中,我们说我们要lift concat。在此示例中,我将使用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>