「前端刷题」17. 电话号码的字母组合

152 阅读5分钟

这是我参与8月更文挑战的第17天,活动详情查看:8月更文挑战

题目

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

 

示例 1:

输入: digits = "23"

输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

输入: digits = ""

输出: []

示例 3:

输入: digits = "2"

输出: ["a","b","c"]

 

提示:

  • 0 <= digits.length <= 4
  • digits[i] 是范围 ['2', '9'] 的一个数字。

解题思路

思路1

其实就是将数字串“翻译”成字母串,找出所有的翻译可能。
翻译第一个数字有 3 / 4 种选择,翻译第二个数字又有 3 / 4 种选择……
从首位翻译到末位,会展开成一棵递归树。指针 i 是当前考察的字符的索引。
当指针越界时,此时生成了一个解,加入解集,结束当前递归,去别的分支,找齐所有的解。

image.png

时间复杂度:遍历所有节点,最坏的情况去到 O(4n)O(4^n)O(4n),nnn 为数字串的长度。
空间复杂度:O(n)O(n)O(n),递归栈调用的深度。

代码

const letterCombinations = (digits) => {
  if (digits.length == 0) return [];
  const res = [];
  const map = { '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' };
  // dfs: 当前构建的字符串为curStr,现在“翻译”到第i个数字,基于此继续“翻译”
  const dfs = (curStr, i) => {   // curStr是当前字符串,i是扫描的指针
    if (i > digits.length - 1) { // 指针越界,递归的出口
      res.push(curStr);          // 将解推入res
      return;                    // 结束当前递归分支
    }
    const letters = map[digits[i]]; // 当前数字对应的字母
    for (const letter of letters) { // 一个字母是一个选择,对应一个递归分支
      dfs(curStr + letter, i + 1);  // 选择翻译成letter,生成新字符串,i指针右移继续翻译(递归)
    }
  };
  dfs('', 0); // 递归的入口,初始字符串为'',从下标0开始翻译
  return res;
};

我所理解的回溯

回溯本质是暴力搜索,在问题的解空间树中,用 DFS 的方式,从根节点出发搜索整个解空间。
如果要找出所有的解,则要搜索整个子树,如果只用找出一个解,则搜到一个解就可以结束搜索。

「找出所有可能的组合」的问题,适合用回溯算法。

回溯算法有三个要点:

  1. 选择
    决定了你每个节点有哪些分支,帮助你构建出解的空间树。
    本题的选择就是,每个数字对应的多个字母,选择翻译成其中一个字母,就继续递归
  2. 约束条件
    用来剪枝,剪去不满足约束条件的子树,避免无效的搜索。这题好像没怎么体现
  3. 目标
    决定了何时捕获解,或者剪去得不到解的子树,提前回溯。扫描数字的指针到头了就可以将解加入解集了。

思路2 BFS 解法

image.png

维护一个队列。起初让空字符串入列,让当前层的字符串逐个出列,出列的字符串,会构建它下一层的新字母串,并入列。
一层层地,考察到数字串的最末位,就到了最底一层,此时队列中存的是所有构建完毕的字母串,返回 queue 即可。

这里控制了层序遍历的深度,为 digits 的长度,而不是while(queue.length){...}那样让所有的节点入列出列,最后还会剩下最后一层节点,留在 queue 中返回。

BFS 代码

const letterCombinations = (digits) => {
  if (digits.length == 0) return [];
  const map = { '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' };

  const queue = [];
  queue.push('');
  for (let i = 0; i < digits.length; i++) { // bfs的层数,即digits的长度
    const levelSize = queue.length;         // 当前层的节点个数
    for (let j = 0; j < levelSize; j++) {   // 逐个让当前层的节点出列
      const curStr = queue.shift();         // 出列

      const letters = map[digits[i]];       

      for (const l of letters) {
        queue.push(curStr + l); // 生成新的字母串入列
      }
    }
  }
  return queue; // 队列中全是最后一层生成的字母串
};

思路3

理解本题后,要解决如下三个问题:

  1. 数字和字母如何映射
  2. 两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来
  3. 输入1 * #按键等等异常情况

数字和字母如何映射

可以使用map或者定义一个二位数组,例如:string letterMap[10],来做映射,我这里定义一个二维数组,代码如下:

const string letterMap[10] = {
    "", // 0
    "", // 1
    "abc", // 2
    "def", // 3
    "ghi", // 4
    "jkl", // 5
    "mno", // 6
    "pqrs", // 7
    "tuv", // 8
    "wxyz", // 9
};

回溯法来解决n个for循环的问题

例如:输入:"23",抽象为树形结构,如图所示:

17. 电话号码的字母组合

图中可以看出遍历的深度,就是输入"23"的长度,而叶子节点就是我们要收集的结果,输出["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]。

回溯三部曲:

  • 确定回溯函数参数

首先需要一个字符串s来收集叶子节点的结果,然后用一个字符串数组result保存起来,这两个变量我依然定义为全局。

再来看参数,参数指定是有题目中给的string digits,然后还要有一个参数就是int型的index。

这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。

  • 确定终止条件

例如输入用例"23",两个数字,那么根节点往下递归两层就可以了,叶子节点就是要收集的结果集。

那么终止条件就是如果index 等于 输入的数字个数(digits.size)了(本来index就是用来遍历digits的)。

然后收集结果,结束本层递归。

  • 确定单层遍历逻辑

首先要取index指向的数字,并找到对应的字符集(手机键盘的字符集)。

然后for循环来处理这个字符集

代码

var letterCombinations = function(digits) {
    const k = digits.length;
    const map = ["","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"];
    if(!k) return [];
    if(k === 1) return map[digits].split("");

    const res = [], path = [];
    backtracking(digits, k, 0);
    return res;

    function backtracking(n, k, a) {
        if(path.length === k) {
            res.push(path.join(""));
            return;
        }
        for(const v of map[n[a]]) {
            path.push(v);
            backtracking(n, k, a + 1);
            path.pop();
        }

    }
};

最后

曾梦想仗剑走天涯

看一看世界的繁华

年少的心总有些轻狂

终究不过是个普通人

无怨无悔我走我路

「前端刷题」No.17