阅读视图

发现新文章,点击刷新页面。

从零到一:彻底搞定面试高频算法——“列表转树”与“爬楼梯”全解析

在前端面试中,算法往往是决定能否拿高薪的关键。很多同学一听到“算法”就头大,觉得那是天才玩的游戏。其实,大多数面试算法题考察的不是你的数学造诣,而是你对递归(Recursion)和逻辑处理的理解。

今天,我们就通过两个非常经典的面试真题—— “列表转树(List to Tree)”和“爬楼梯(Climbing Stairs)” ,带你从小白视角拆解算法的奥秘。

第一部分:列表转树 —— 业务中的“常青树”

1. 为什么要学这个?

在实际开发中,后端返回给我们的数据往往是“扁平化”的。比如一个省市区选择器,或者一个后台管理系统的左侧菜单导航。为了存储方便,数据库通常会存储为如下结构:

id parentId name
1 0 中国
2 1 北京
3 1 上海
4 2 东城区

但前端 UI 组件(如 Element UI 的 Tree 组件)需要的是一个嵌套的树形对象。如何把上面的表格数据转换成包含 children 的树?这就是面试官考察你的数据结构处理能力。

2. 解法一:暴力递归(最符合人类直觉)

核心逻辑:

  1. 遍历列表,找到根节点(parentId === 0)。
  2. 对于每一个节点,再去列表里找谁的 parentId 等于我的 id
  3. 递归下去,直到找不到子节点。
// 代码参考
function list2tree(list, parentId = 0) {
  const result = []; 
  list.forEach(item => {
    if (item.parentId === parentId) {
      // 这里的递归就像是在问:谁是我的孩子?
      const children = list2tree(list, item.id);
      if (children.length) {
        item.children = children;
      }
      result.push(item);
    }
  });
  return result;
}

小白避坑指南:

这种方法的复杂度是 O(n2)O(n^2)。如果列表有 1000 条数据,最坏情况下要跑 100 万次循环。面试官此时会问:“有没有更优的方法?”

3. 解法二:优雅的 ES6 函数式写法

如果你想让代码看起来更“高级”,可以利用 filtermap

function list2tree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId) // 过滤出当前的子节点
    .map(item => ({
      ...item, // 展开原有属性
      children: list2tree(list, item.id) // 递归寻找后代
    }));
}

4. 解法三:空间换时间(面试官最爱)

为了把时间复杂度降到 O(n)O(n),我们可以利用 Map 对象。Map 的查询速度极快,像是一个“瞬移器”。

思路:

  1. 先遍历一遍列表,把所有节点存入 Map 中,以 id 为 Key。
  2. 再遍历一遍,根据 parentId 直接从 Map 里把父节点“揪”出来,把当前节点塞进父节点的 children 里。
// 代码参考
function listToTree(list) {
    const nodeMap = new Map();
    const tree = [];

    // 第一遍:建立映射表
    list.forEach(item => {
        nodeMap.set(item.id, { ...item, children: [] });
    });

    // 第二遍:建立父子关系
    list.forEach(item => {
        const node = nodeMap.get(item.id);
        if (item.parentId === 0) {
            tree.push(node); // 根节点入队
        } else {
            // 直接通过 parentId 找到父亲,把儿子塞进去
            nodeMap.get(item.parentId)?.children.push(node);
        }
    });
    return tree;
}

优点: 只遍历了两遍列表。无论数据有多少,速度依然飞快。

第二部分:爬楼梯 —— 掌握算法的“分水岭”

如果说“列表转树”考察的是业务能力,那“爬楼梯”考察的就是编程思维

题目描述: 假设你正在爬楼梯。需要 nn 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

1. 自顶向下:递归的艺术

我们站在第 nn 阶往回看:

  • 要到达第 10 阶,你只能从第 9 阶跨 1 步上来,或者从第 8 阶跨 2 步上来。
  • 所以:f(10)=f(9)+f(8)f(10) = f(9) + f(8)

这就是著名的斐波那契数列公式

// 基础版
function climbStairs(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    return climbStairs(n - 1) + climbStairs(n - 2);
}

致命缺陷: 这个代码会跑死电脑。算 f(10)f(10) 时要算 f(9)f(9)f(8)f(8);算 f(9)f(9) 时又要算一遍 f(8)f(8)。大量的重复计算导致“爆栈”。

2. 优化:带备忘录的递归(记忆化)

我们可以准备一个“笔记本”(memo),算过的值就记下来,下次直接拿。

const memo = {};
function climbStairs(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    if (memo[n]) return memo[n]; // 翻翻笔记,有就直接给
    memo[n] = climbStairs(n - 1) + climbStairs(n - 2);
    return memo[n];
}

3. 自底向上:动态规划(DP)

动态规划(Dynamic Programming)听起来很高大上,其实就是倒过来想。

我们不从 f(n)f(n) 往回找,而是从 f(1)f(1) 开始往后推:

  • f(1)=1f(1) = 1
  • f(2)=2f(2) = 2
  • f(3)=1+2=3f(3) = 1 + 2 = 3
  • f(4)=2+3=5f(4) = 2 + 3 = 5
function climbStairs(n) {
  if (n <= 2) return n;
  const dp = new Array(n + 1);
  dp[1] = 1;
  dp[2] = 2;
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2]; // 每一个结果都是前两个的和
  }
  return dp[n];
}

4. 极致优化:滚动变量

既然 f(n)f(n) 只依赖前两个数,那我们连数组都不需要了,只需要三个变量在手里“滚”起来。

function climbStairs(n) {
    if(n <= 2) return n;
    let prePrev = 1; // f(n-2)
    let prev = 2;    // f(n-1)
    let current;
    for(let i = 3; i <= n; i++){
        current = prev + prePrev;
        prePrev = prev;
        prev = current;
    }
    return current;
}

这时的空间复杂度降到了 O(1)O(1),几乎不占用额外内存。

总结:小白如何精进算法?

通过这两道题,你应该能发现算法学习的规律:

  1. 先画图,后写码: 不管是树状结构还是楼梯台阶,画出逻辑图比直接写代码重要得多。
  2. 寻找重复子问题: 递归和 DP 的核心都在于把大问题拆解成一样的小问题。
  3. 从暴力到优化: 别指望一步写出最优解。先用最笨的方法写出来,再去思考如何减少重复计算。

现代前端工程化实战:从 Vite 到 React Router demo的构建之旅

前端技术的迭代从未停歇。当我们谈论现代前端开发时,React 19Vite 已经成为了不可忽视的标准配置。React 19 带来了更高效的并发渲染机制,而 Vite 则凭借基于 ESM 的极致冷启动速度,彻底改变了开发体验。

本文将通过一个名为 react-demo 的实战项目,带你从零开始理解如何搭建、配置并开发一个标准的现代 React 应用。我们将涵盖工程化配置、路由管理、Hooks 状态逻辑以及样式预处理等核心知识点。

一、 极速启动:Vite 与 ESM 的革命

在过去,Webpack 是构建工具的王者,但它在启动大型项目时往往需要漫长的打包等待。现代开发推荐使用 Vite(法语意为“快”)作为脚手架。

1. 为什么是 Vite?

Vite 的核心优势在于它利用了浏览器原生的 ES Modules (ESM) 机制。在开发阶段 (npm run dev),Vite 不需要对代码进行全量打包,而是按需提供模块,这实现了极致的“冷启动”体验。

当我们运行 npm init vite 拉取项目模板后,项目结构非常清晰。观察项目的 package.json 脚本配置:

"scripts": {
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview"
}

这对应了完整的开发生命周期:dev(开发) -> build(构建生产包) -> preview(本地预览生产包)。

2. 依赖管理的艺术:Dev vs Prod

在安装依赖时,区分“开发依赖”和“生产依赖”至关重要。

  • dependencies (生产依赖) :如 reactreact-dom。React 19.2.0 是核心库,负责组件定义和 diff 算法;而 react-dom 负责将组件渲染到浏览器 DOM 中。这类似于 Vue 的生态,React Core 对应 Vue Core,React DOM 对应 Vue 的渲染器。对应配置为 package.json 中的dependencies
  • devDependencies (开发依赖) :如 stylus。我们使用 npm i -D stylus 安装它,因为 Stylus 只是在开发阶段帮助我们将 .styl 文件编译为 CSS,上线后的代码并不需要 Stylus 引擎。对应配置为 package.json 中的devDependencies
// 生产依赖
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "react-router-dom": "^7.10.1"
  },
// 开发依赖
  "devDependencies": {
    "@eslint/js": "^9.39.1",
    "@types/react": "^19.2.5",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^5.1.1",
    "eslint": "^9.39.1",
    "eslint-plugin-react-hooks": "^7.0.1",
    "eslint-plugin-react-refresh": "^0.4.24",
    "globals": "^16.5.0",
    "stylus": "^0.64.0",
    "vite": "^7.2.4"
  }

二、 入口与渲染:React 19 的严谨模式

项目的入口文件 main.jsx 展示了 React 19 最标准的挂载方式。

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.styl'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

严格模式 (StrictMode)

你可能会发现,在开发环境下组件的生命周期函数(如 useEffect)会执行两次。这并非 Bug,而是 <StrictMode> 的有意为之。它通过双重调用来帮助开发者检测不安全的副作用(Side Effects)和过时的 API 使用,确保代码在生产环境中更加健壮。

样式预处理

我们引入了全局样式 index.styl。Stylus 的魅力在于其极简的语法——省略花括号、冒号和分号,通过缩进来组织代码:

*
  margin: 0
  padding: 0

body
  background-color pink

Vite 内置了对 CSS 预处理器的支持,无需繁琐的 Webpack Loader 配置,安装即用,安装指令为npm i -D stylus。其中的-D就代表了开发依赖,如果不书写-D则会默认安装至生产依赖。

三、 路由架构:单页应用的骨架

单页应用(SPA)的核心在于:页面不刷新,URL 改变,内容切换,在一个页面 (index.html) 中实现 "多页面" 的切换效果。我们使用 react-router-dom v7 来实现这一功能。首先需要通过npm i react-router-dom指令安装路由。

1. 路由模式选择

App.jsx 中,我们采用了 BrowserRouter(别名为 Router)。相比于 URL 中带有 # 号的 HashRouterBrowserRouter 利用 HTML5 History API,提供了更现代化、更美观的 URL 结构,是目前的行业标准。

2. 声明式导航:Link vs A

在 React Router 中,我们严禁使用传统的 <a href> 标签进行内部跳转。因为 <a> 标签会导致浏览器强制刷新页面,从而重置 React 的所有状态。

相反,我们使用 <Link> 组件:

<nav>
  <ul>
    <li><Link to="/">Home</Link></li>
    <li><Link to="/about">About</Link></li>
  </ul>
</nav>

<Link> 组件在内部“消化”了点击事件,通过 JavaScript 修改 URL 并通过 Context 通知路由系统更新视图,实现了无缝的页面切换。

3. 路由配置分离

为了保持代码的整洁,我们将具体的路由规则抽离到了 router/index.jsx 中:

import { Routes, Route } from 'react-router-dom';
import Home from '../pages/Home.jsx';
import About from '../pages/About.jsx';

export default function AppRoutes() {
    return (
        <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/about" element={<About />} />
        </Routes>
    )
}

这种集中管理路由表的方式,使得 App.jsx 只需关注整体布局,而将路由细节交给 AppRoutes 组件处理。

四、 核心业务逻辑:Hooks 驱动的数据流

Home.jsx 组件展示了 React 函数式组件的核心逻辑:Hooks。我们的目标是调用 GitHub API 并展示数据。

1. 响应式状态:useState

const [repos, setRepos] = useState([]);

useState 是 React 响应式系统的基石。它返回当前状态 repos 和更新函数 setRepos。每当调用 setRepos 时,React 就会感知数据变化,并触发组件的重新渲染(Re-render),更新视图。

2. 副作用管理:useEffect

网络请求属于“副作用”(Side Effect),不能直接写在组件渲染逻辑中。我们使用 useEffect 来处理组件挂载后的逻辑:

useEffect(() => {
    // 组件挂载完成 (onMounted)
    fetch('https://api.github.com/users/shunwuyu/repos')
        .then(res => res.json())
        .then(json => setRepos(json))
}, [])
  • 执行时机useEffect 确保代码在组件渲染并挂载到 DOM 之后执行,避免阻塞 UI 渲染。
  • 依赖数组 [] :第二个参数传入空数组 [],意味着这个 Effect 只在组件初始化时执行一次(相当于类组件的 componentDidMount)。如果不传此参数,每次渲染都会触发请求,导致无限循环。

3. 条件渲染与列表 Key

在 JSX 中,我们利用 JavaScript 的灵活性来构建 UI。

return (
        <div>
            <h1>Home</h1>
            {
                repos.length ? (
                    <ul>
                        {
                            repos.map(repo => (
                                <li key={repo.id}>
                                    <a href={repo.html_url} target="_blank" rel="noreferrer">
                                        {repo.name}
                                    </a>
                                </li>
                            ))
                        }
                    </ul>
                ) : null
            }
        </div>
    );
  • Diff 算法的关键:在遍历列表时,必须为每个元素提供唯一的 key(如 repo.id)。这能帮助 React 的 Diff 算法高效地识别元素的增删改,最小化 DOM 操作。
  • 条件渲染:通过三元运算符检查 repos.length,在数据加载前不渲染列表,防止页面报错。

五、 总结

通过这个项目,我们不仅搭建了一个简单的 GitHub 仓库浏览器,更重要的是实践了现代 React 开发的标准范式:

  1. 工程化:利用 Vite 极速构建,区分开发与生产依赖。
  2. 组件化:通过 Props 和 Hooks 实现逻辑复用。
  3. 路由化:使用 React Router 实现 SPA 的无感跳转。
  4. 响应式:利用 useStateuseEffect 驱动数据流向。

从 React 19 的底层优化到 Vite 的工程实践,这套技术栈为开发者提供了极其高效的开发体验,是构建未来 Web 应用的坚实基础。

六、Home.jsx 源代码

import { useState, useEffect } from 'react';

const Home = () => {
    const [repos, setRepos] = useState([]);
    // render 是第一位的
    // console.log('Home 组件渲染了');
    useEffect(() => {
        // home 组件可以看到了
        // console.log('Home 组件挂载了');
        // 发送api请求,不会和组件渲染去争抢
        fetch('https://api.github.com/users/shunwuyu/repos')
            .then(res => res.json())
            .then(json => setRepos(json))
    }, [])
    return (
        <div>
            <h1>Home</h1>
            {
                repos.length ? (
                    <ul>
                        {
                            repos.map(repo => (
                                <li key={repo.id}>
                                    <a href={repo.html_url} target="_blank" rel="noreferrer">
                                        {repo.name}
                                    </a>
                                </li>
                            ))
                        }
                    </ul>
                ) : null
            }
        </div>
    );
}

export default Home;
❌