从树形结构到回溯算法:我在前端学习中遇到的「路径」难题

268 阅读16分钟

最近在刷前端算法题时,我遇到了两个特别有意思的问题:一个是把平面列表转成树形结构,另一个是用回溯算法解决数字字母组合问题。原本以为这两个问题毫无关联,直到我在调试代码时盯着 path.pop() 看了半小时——原来它们都藏着「路径探索」的底层逻辑。今天就结合我的学习笔记,和大家聊聊这两个问题背后的思维模式。

从列表转树开始:递归里的「父子关系」

作为前端,我们经常需要处理层级数据。比如菜单权限、省市区联动选择,后端往往会返回一个平面数组,每个节点有 idparentId,我们需要把它转成树形结构。

先看一个典型的列表数据:

let list = [
    { id: '1', title: '节点1', parentId: '' },
    { id: '1-1', title: '节点1-1', parentId: '1' },
    { id: '1-2', title: '节点1-2', parentId: '1' },
    { id: '2', title: '节点2', parentId: '' },
    { id: '2-1', title: '节点2-1', parentId: '2' }
]

目标是把它转成这样的树:

[
    {
        id: '1',
        title: '节点1',
        children: [
            { id: '1-1', title: '节点1-1', children: [] },
            { id: '1-2', title: '节点1-2', children: [] }
        ]
    },
    {
        id: '2',
        title: '节点2',
        children: [ { id: '2-1', title: '节点2-1', children: [] } ]
    }
]

递归解法:最直观的「找爸爸」思路

新手最容易想到的是递归。既然每个节点的 parentId 指向父节点,那我们可以先找根节点(parentId 为空),然后为每个根节点找子节点,子节点的子节点再递归查找。

看我写的第一个版本代码:

function list2Tree(list, parentId = '') {
    // 过滤出当前父节点的直接子节点
    return list.filter(item => item.parentId === parentId)
               .map(item => {
                   // 递归查找子节点的子节点
                   item.children = list2Tree(list, item.id);
                   return item;
               });
}

这段代码的逻辑很简单:用 parentId 过滤出当前层级的节点,然后为每个节点递归查找其子节点(即 parentId 等于当前节点 id 的节点)。

但它有个明显的问题:时间复杂度是 O(n²)。因为每次递归都要遍历整个列表,假设列表有 n 个节点,每个节点都会被遍历 logn 次(树的深度),最坏情况下(比如链表结构)就是 O(n²)。

哈希表优化:用空间换时间的「快速查找」

为了优化时间复杂度,我想到用哈希表预存所有节点。先把每个节点存入哈希表,键是 id,值是节点本身(带 children 数组)。然后再次遍历列表,通过 parentId 直接从哈希表中找到父节点,把当前节点添加到父节点的 children 里。

优化后的代码:

function list2Tree(list) {
    const map = {}; // 哈希表存储所有节点
    const root = []; // 根节点数组

    // 第一步:初始化哈希表,每个节点先添加空children
    list.forEach(item => {
        map[item.id] = { ...item, children: [] };
    });

    // 第二步:关联父子节点
    list.forEach(item => {
        if (item.parentId) {
            // 非根节点:找到父节点,添加到其children
            map[item.parentId].children.push(map[item.id]);
        } else {
            // 根节点:直接加入结果数组
            root.push(map[item.id]);
        }
    });

    return root;
}

这一步优化把时间复杂度降到了 O(n),因为只需要两次遍历列表,而哈希表的查找是 O(1)。这让我意识到:递归虽然直观,但涉及大量重复计算时,用空间换时间是常见的优化思路

从树到回溯:路径探索的「进」与「退」

在掌握了列表转树后,我遇到了另一个问题:用回溯算法解决「数字字母组合」问题。比如,手机键盘上数字 2 对应 "abc",3 对应 "def",输入 "23" 要输出所有可能的字母组合 ["ad","ae","af","bd","be","bf","cd","ce","cf"]。

一开始我觉得这和树形结构没关系,直到画出递归调用图——原来每个数字的字母选择,本质上是在构建一棵「决策树」。比如输入 "23",第一层是数字 2 的 "a""b""c",第二层是数字 3 的 "d""e""f",所有从根到叶子的路径就是最终的组合。

回溯算法的核心:「尝试-回退」的路径管理

回溯算法的代码模板通常是这样的:

function backtrack(路径, 选择列表) {
    if (满足终止条件) {
        结果.add(路径);
        return;
    }
    for (选择 in 选择列表) {
        做选择(路径添加当前选择);
        backtrack(路径, 选择列表);
        撤销选择(路径移除当前选择);
    }
}

翻译成大白话就是:先选一条路走到底,走不通(或走完)就退回来,换另一条路继续试。这和我们生活中走迷宫的思路一模一样——遇到死胡同就回头,直到找到出口。

用字母组合问题拆解回溯过程

以数字组合 "23" 为例,具体看看代码如何实现:

首先,需要一个映射表记录每个数字对应的字母:

const letterMap = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"];

核心函数 letterCombinations

function letterCombinations(digits) {
    const result = []; // 存储最终结果
    const path = []; // 记录当前路径

    if (digits.length === 0) return result;

    // 回溯函数(index表示当前处理到第几个数字)
    function backtracking(index) {
        // 终止条件:路径长度等于数字长度(走到叶子节点)
        if (index === digits.length) {
            result.push(path.join('')); // 将路径转成字符串加入结果
            return;
        }

        // 获取当前数字对应的字母列表
        const digit = digits[index] - '0';
        const letters = letterMap[digit];

        // 遍历当前字母列表,做选择
        for (let i = 0; i < letters.length; i++) {
            path.push(letters[i]); // 选择当前字母,加入路径
            backtracking(index + 1); // 递归处理下一个数字
            path.pop(); // 撤销选择,回到上一层
        }
    }

    backtracking(0); // 从第一个数字开始
    return result;
}

关键步骤:path.pop() 为什么必不可少?

第一次看这段代码时,最困惑的是 path.pop() 的作用。为什么递归之后要把刚才添加的字母移除?

举个具体例子:假设当前处理数字 "2"(对应 "abc"),我们依次选择 "a"、"b"、"c"。当选择 "a" 时,路径变成 ["a"],然后递归处理下一个数字 "3"(对应 "def")。这时会进入内层递归,路径依次添加 "d"、"e"、"f",形成 ["a", "d"]["a", "e"]["a", "f"],每次到达终止条件时,这三个路径会被加入 result

但如果没有 path.pop(),当内层递归处理完 "d" 后,路径是 ["a", "d"],回到外层递归时,路径不会清空,下一次循环选择 "b" 时,路径会变成 ["a", "d", "b"],这显然是错误的。

path.pop() 的本质是「撤销选择」,让路径回到上一层的状态,这样下一次循环才能正确尝试新的选择。就像走迷宫时,你带着一个笔记本记录路径,走到死胡同时,需要把最后一步的记录擦掉,才能重新记录新的路径。

结果收集:何时「保存路径」?

在回溯算法中,终止条件触发时保存路径。比如字母组合问题中,当 index 等于 digits.length 时,说明已经处理完所有数字,当前路径就是一个完整的组合。这时候需要用 path.join('') 将数组转成字符串,否则结果会是数组形式(如 ["a", "d"])。

这里有个细节:为什么不用 path.slice() 复制数组?因为 path 是引用类型,后续的 pop 操作会修改原数组。但在这个问题中,path.join('') 已经生成了新的字符串,所以不需要额外复制。如果是对象或数组类型的结果,可能需要深拷贝。

从树到回溯:思维模式的共通性

列表转树和回溯算法看似不同,本质都是「路径探索」:

  • 列表转树是「从父到子」的路径构建,每个节点需要找到自己的子节点路径;
  • 回溯算法是「从根到叶子」的路径尝试,每条路径代表一种可能的解。

它们都体现了递归的核心思想:将大问题分解为子问题,通过解决子问题来解决整体问题。而回溯算法的特殊之处在于,它不仅需要解决子问题,还要在子问题解决后「恢复现场」,以便尝试其他可能的路径。

学习总结:这些细节需要注意

  1. 递归的终止条件:必须明确何时停止递归,否则会导致栈溢出。比如列表转树中,当没有子节点时递归自然终止;字母组合中,当处理完所有数字时终止。
  2. 路径的管理:无论是树的 children 数组还是回溯的 path 数组,本质都是记录当前探索的路径。需要注意引用类型的修改会影响所有层级,必要时进行深拷贝。
  3. 时间复杂度的优化:递归虽然直观,但可能存在重复计算。哈希表、记忆化搜索等方法可以优化时间复杂度。
  4. 回溯的核心做选择 -> 递归 -> 撤销选择 的循环,其中 撤销选择 是回溯的关键,确保路径可以重复利用。

写在最后

从列表转树到回溯算法,我最大的收获是理解了「路径」在数据结构和算法中的重要性。无论是构建树形结构还是寻找组合解,本质都是在不同的路径中探索。掌握这种思维模式后,再遇到类似的问题(如全排列、子集问题),都能快速找到解题思路。

技术学习就是这样,看似不相关的知识点,往往藏着底层的共通逻辑。多思考、多对比,才能把零散的知识串成体系。下一次遇到新问题时,不妨问问自己:这是不是某种「路径探索」问题?或许会有意外的收获。

从树形结构到回溯算法:我在前端学习中遇到的「路径」难题

最近在刷前端算法题时,我遇到了两个特别有意思的问题:一个是把平面列表转成树形结构,另一个是用回溯算法解决数字字母组合问题。原本以为这两个问题毫无关联,直到我在调试代码时盯着 path.pop() 看了半小时——原来它们都藏着「路径探索」的底层逻辑。今天就结合我的学习笔记,和大家聊聊这两个问题背后的思维模式。

从列表转树开始:递归里的「父子关系」

作为前端,我们经常需要处理层级数据。比如菜单权限、省市区联动选择,后端往往会返回一个平面数组,每个节点有 idparentId,我们需要把它转成树形结构。

先看一个典型的列表数据:

let list = [
    { id: '1', title: '节点1', parentId: '' },
    { id: '1-1', title: '节点1-1', parentId: '1' },
    { id: '1-2', title: '节点1-2', parentId: '1' },
    { id: '2', title: '节点2', parentId: '' },
    { id: '2-1', title: '节点2-1', parentId: '2' }
]

目标是把它转成这样的树:

[
    {
        id: '1',
        title: '节点1',
        children: [
            { id: '1-1', title: '节点1-1', children: [] },
            { id: '1-2', title: '节点1-2', children: [] }
        ]
    },
    {
        id: '2',
        title: '节点2',
        children: [ { id: '2-1', title: '节点2-1', children: [] } ]
    }
]

递归解法:最直观的「找爸爸」思路

新手最容易想到的是递归。既然每个节点的 parentId 指向父节点,那我们可以先找根节点(parentId 为空),然后为每个根节点找子节点,子节点的子节点再递归查找。

看我写的第一个版本代码:

function list2Tree(list, parentId = '') {
    // 过滤出当前父节点的直接子节点
    return list.filter(item => item.parentId === parentId)
               .map(item => {
                   // 递归查找子节点的子节点
                   item.children = list2Tree(list, item.id);
                   return item;
               });
}

这段代码的逻辑很简单:用 parentId 过滤出当前层级的节点,然后为每个节点递归查找其子节点(即 parentId 等于当前节点 id 的节点)。

但它有个明显的问题:时间复杂度是 O(n²)。因为每次递归都要遍历整个列表,假设列表有 n 个节点,每个节点都会被遍历 logn 次(树的深度),最坏情况下(比如链表结构)就是 O(n²)。

哈希表优化:用空间换时间的「快速查找」

为了优化时间复杂度,我想到用哈希表预存所有节点。先把每个节点存入哈希表,键是 id,值是节点本身(带 children 数组)。然后再次遍历列表,通过 parentId 直接从哈希表中找到父节点,把当前节点添加到父节点的 children 里。

优化后的代码:

function list2Tree(list) {
    const map = {}; // 哈希表存储所有节点
    const root = []; // 根节点数组

    // 第一步:初始化哈希表,每个节点先添加空children
    list.forEach(item => {
        map[item.id] = { ...item, children: [] };
    });

    // 第二步:关联父子节点
    list.forEach(item => {
        if (item.parentId) {
            // 非根节点:找到父节点,添加到其children
            map[item.parentId].children.push(map[item.id]);
        } else {
            // 根节点:直接加入结果数组
            root.push(map[item.id]);
        }
    });

    return root;
}

这一步优化把时间复杂度降到了 O(n),因为只需要两次遍历列表,而哈希表的查找是 O(1)。这让我意识到:递归虽然直观,但涉及大量重复计算时,用空间换时间是常见的优化思路

从树到回溯:路径探索的「进」与「退」

在掌握了列表转树后,我遇到了另一个问题:用回溯算法解决「数字字母组合」问题。比如,手机键盘上数字 2 对应 "abc",3 对应 "def",输入 "23" 要输出所有可能的字母组合 ["ad","ae","af","bd","be","bf","cd","ce","cf"]。

image.png

一开始我觉得这和树形结构没关系,直到画出递归调用图——原来每个数字的字母选择,本质上是在构建一棵「决策树」。比如输入 "23",第一层是数字 2 的 "a""b""c",第二层是数字 3 的 "d""e""f",所有从根到叶子的路径就是最终的组合。

回溯算法的核心:「尝试-回退」的路径管理

回溯算法的代码模板通常是这样的:

function backtrack(路径, 选择列表) {
    if (满足终止条件) {
        结果.add(路径);
        return;
    }
    for (选择 in 选择列表) {
        做选择(路径添加当前选择);
        backtrack(路径, 选择列表);
        撤销选择(路径移除当前选择);
    }
}

翻译成大白话就是:先选一条路走到底,走不通(或走完)就退回来,换另一条路继续试。这和我们生活中走迷宫的思路一模一样——遇到死胡同就回头,直到找到出口。

用字母组合问题拆解回溯过程

以数字组合 "23" 为例,具体看看代码如何实现:

首先,需要一个映射表记录每个数字对应的字母:

const letterMap = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"];

核心函数 letterCombinations

function letterCombinations(digits) {
    const result = []; // 存储最终结果
    const path = []; // 记录当前路径

    if (digits.length === 0) return result;

    // 回溯函数(index表示当前处理到第几个数字)
    function backtracking(index) {
        // 终止条件:路径长度等于数字长度(走到叶子节点)
        if (index === digits.length) {
            result.push(path.join('')); // 将路径转成字符串加入结果
            return;
        }

        // 获取当前数字对应的字母列表
        const digit = digits[index] - '0';
        const letters = letterMap[digit];

        // 遍历当前字母列表,做选择
        for (let i = 0; i < letters.length; i++) {
            path.push(letters[i]); // 选择当前字母,加入路径
            backtracking(index + 1); // 递归处理下一个数字
            path.pop(); // 撤销选择,回到上一层
        }
    }

    backtracking(0); // 从第一个数字开始
    return result;
}

关键步骤:path.pop() 为什么必不可少?

第一次看这段代码时,最困惑的是 path.pop() 的作用。为什么递归之后要把刚才添加的字母移除?

举个具体例子:假设当前处理数字 "2"(对应 "abc"),我们依次选择 "a"、"b"、"c"。当选择 "a" 时,路径变成 ["a"],然后递归处理下一个数字 "3"(对应 "def")。这时会进入内层递归,路径依次添加 "d"、"e"、"f",形成 ["a", "d"]["a", "e"]["a", "f"],每次到达终止条件时,这三个路径会被加入 result

但如果没有 path.pop(),当内层递归处理完 "d" 后,路径是 ["a", "d"],回到外层递归时,路径不会清空,下一次循环选择 "b" 时,路径会变成 ["a", "d", "b"],这显然是错误的。

path.pop() 的本质是「撤销选择」,让路径回到上一层的状态,这样下一次循环才能正确尝试新的选择。就像走迷宫时,你带着一个笔记本记录路径,走到死胡同时,需要把最后一步的记录擦掉,才能重新记录新的路径。

结果收集:何时「保存路径」?

在回溯算法中,终止条件触发时保存路径。比如字母组合问题中,当 index 等于 digits.length 时,说明已经处理完所有数字,当前路径就是一个完整的组合。这时候需要用 path.join('') 将数组转成字符串,否则结果会是数组形式(如 ["a", "d"])。

这里有个细节:为什么不用 path.slice() 复制数组?因为 path 是引用类型,后续的 pop 操作会修改原数组。但在这个问题中,path.join('') 已经生成了新的字符串,所以不需要额外复制。如果是对象或数组类型的结果,可能需要深拷贝。

从树到回溯:思维模式的共通性

列表转树和回溯算法看似不同,本质都是「路径探索」:

  • 列表转树是「从父到子」的路径构建,每个节点需要找到自己的子节点路径;
  • 回溯算法是「从根到叶子」的路径尝试,每条路径代表一种可能的解。

它们都体现了递归的核心思想:将大问题分解为子问题,通过解决子问题来解决整体问题。而回溯算法的特殊之处在于,它不仅需要解决子问题,还要在子问题解决后「恢复现场」,以便尝试其他可能的路径。

学习总结:这些细节需要注意

  1. 递归的终止条件:必须明确何时停止递归,否则会导致栈溢出。比如列表转树中,当没有子节点时递归自然终止;字母组合中,当处理完所有数字时终止。
  2. 路径的管理:无论是树的 children 数组还是回溯的 path 数组,本质都是记录当前探索的路径。需要注意引用类型的修改会影响所有层级,必要时进行深拷贝。
  3. 时间复杂度的优化:递归虽然直观,但可能存在重复计算。哈希表、记忆化搜索等方法可以优化时间复杂度。
  4. 回溯的核心做选择 -> 递归 -> 撤销选择 的循环,其中 撤销选择 是回溯的关键,确保路径可以重复利用。

写在最后

从列表转树到回溯算法,我最大的收获是理解了「路径」在数据结构和算法中的重要性。无论是构建树形结构还是寻找组合解,本质都是在不同的路径中探索。掌握这种思维模式后,再遇到类似的问题(如全排列、子集问题),都能快速找到解题思路。

技术学习就是这样,看似不相关的知识点,往往藏着底层的共通逻辑。多思考、多对比,才能把零散的知识串成体系。下一次遇到新问题时,不妨问问自己:这是不是某种「路径探索」问题?或许会有意外的收获。