普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月28日首页

LeetCode 4. 寻找两个正序数组的中位数:二分优化思路详解

作者 Wect
2026年3月27日 22:56

在LeetCode的Hard题目中,「寻找两个正序数组的中位数」绝对是经典中的经典。它不仅考察对中位数概念的理解,更核心的是对时间复杂度的极致要求——O(log (m+n)),这就意味着暴力合并数组(O(m+n))的思路直接出局,必须用到二分查找的思想来优化。

今天就来一步步拆解这道题,从题目分析到代码实现,再到细节坑点,带你彻底搞懂如何用二分法高效求解,同时吃透给出的代码逻辑。

一、题目回顾:明确需求与核心难点

题目给出两个正序(从小到大)排列的数组nums1和nums2,大小分别为m和n,要求找出这两个数组的中位数,并且算法的时间复杂度必须是O(log (m+n))。

先明确中位数的定义:将两个数组合并后,按从小到大排序,若总长度为奇数,中位数是中间位置的数;若为偶数,中位数是中间两个数的平均值。

核心难点在于「时间复杂度O(log (m+n))」。二分查找的时间复杂度是O(log k)(k为查找范围),因此我们需要将问题转化为“在两个正序数组中,查找第k小的数”——而中位数,本质上就是第「(m+n+1)/2」小的数(奇数情况),或第「(m+n)/2」和「(m+n)/2 +1」小的数的平均值(偶数情况)。

二、核心思路:二分法缩小查找范围

我们的目标是找到第k小的数(k = Math.floor((totalLen + 1) / 2),totalLen = m + n),核心思想是「每次排除一半不可能是第k小的元素」,从而将查找范围缩小一半,达到log级别的时间复杂度。

具体逻辑如下:

  1. 初始化两个偏移量offset1、offset2,分别表示nums1、nums2中已经排除的元素个数(即当前待查找的起始位置)。

  2. 每次从两个数组的当前起始位置开始,各取k/2个元素(k为当前剩余待查找的元素个数),比较这两个位置的元素大小。

  3. 若nums1的第(offset1 + k/2 -1)个元素小于nums2的对应位置元素,则说明nums1中从offset1到该位置的所有元素,都不可能是第k小的数,可直接排除(offset1 += k/2);反之则排除nums2中的对应元素(offset2 += k/2)。

  4. 重复上述步骤,直到offset1 + offset2等于k,此时找到的最大元素即为第k小的数(leftMax)。

  5. 根据总长度是奇数还是偶数,计算最终的中位数:奇数直接返回leftMax,偶数则需要找到leftMax的下一个最小元素,取两者的平均值。

三、代码逐行解析:吃透每一个细节

给出的代码已经实现了上述思路,并且处理了所有边界情况,下面逐行拆解,帮你理清每一步的作用。

1. 初始化变量

const len1: number = nums1.length;
const len2: number = nums2.length;
const totalLen: number = len1 + len2;
const medianIndex: number = Math.floor((totalLen + 1) / 2);
let offset1 = 0; // nums1的排除偏移量
let offset2 = 0; // nums2的排除偏移量
let leftMax = -Infinity; // 记录第k小的数(leftMax)

这里的关键是medianIndex的计算:无论总长度是奇数还是偶数,我们先找到「第medianIndex小的数」(leftMax)。比如总长度为5(奇数),medianIndex=3,leftMax就是第3小的数(中位数);总长度为4(偶数),medianIndex=2,leftMax是第2小的数,后续再找第3小的数,取两者平均即可。

2. 二分查找核心循环

while (offset1 + offset2 < medianIndex) {
  let k = medianIndex - offset1 - offset2; // 当前剩余待查找的元素个数
  k = Math.max(1, Math.floor(k / 2)); // 每次取k/2个元素,避免k为0
  let left1 = offset1 + k - 1; // nums1中待比较的位置
  let left2 = offset2 + k - 1; // nums2中待比较的位置

  // 处理数组越界:若待比较位置超出数组长度,视为无穷大(无法被选中)
  let val1 = left1 < len1 ? nums1[left1] : Infinity;
  let val2 = left2 < len2 ? nums2[left2] : Infinity;

  // 排除不可能的元素,更新leftMax和偏移量
  if (val1 > val2) {
    leftMax = Math.max(leftMax, val2);
    offset2 += k; // 排除nums2中offset2到left2的元素
  } else if (val1 < val2) {
    leftMax = Math.max(leftMax, val1);
    offset1 += k; // 排除nums1中offset1到left1的元素
  } else {
    // 两元素相等,同时排除,leftMax取该值
    leftMax = val1;
    offset1 += k;
    offset2 += k;
  }
}

这部分是整个算法的核心,重点注意3个细节:

  • k的计算:每次k是“剩余待查找的元素个数”,取k/2是为了每次排除一半元素;Math.max(1, ...)是避免k为0(比如剩余1个元素时,k=1)。

  • 越界处理:当left1超出nums1长度时,val1设为Infinity,意味着nums1中没有更多元素可排除,只能排除nums2的元素;反之同理。

  • leftMax的更新:每次排除元素时,要记录被排除元素中的最大值——因为这些被排除的元素都比第k小的数小,最终leftMax就是第k小的数。

3. 计算最终中位数(分奇偶情况)

if (totalLen % 2 === 0) {
  // 新增:两数组均遍历完,右半最小值等于左半最大值(所有元素已处理)
  if (offset1 === len1 && offset2 === len2) {
    return leftMax; // 此时leftMax就是中间值,两数平均后仍等于leftMax
  }
  if (offset1 === len1) {
    return (leftMax + nums2[offset2]) / 2;
  }
  if (offset2 === len2) {
    return (leftMax + nums1[offset1]) / 2;
  }
  return (leftMax + Math.min(nums1[offset1], nums2[offset2])) / 2;
} else {
  return leftMax;
}

这里处理了所有边界情况,尤其是新增的“两数组均遍历完”的场景(虽然实际中很少出现,但能避免异常):

  • 奇数情况:直接返回leftMax(第medianIndex小的数,就是中位数)。

  • 偶数情况:需要找到leftMax的下一个最小元素(即当前两个数组未排除部分的第一个元素的最小值),取两者平均。

  • 边界处理:若其中一个数组已全部排除(offset等于数组长度),则下一个最小元素就是另一个数组当前起始位置的元素。

四、关键坑点与优化说明

1. 坑点:越界处理

如果不处理left1、left2越界的情况,会导致数组下标异常。将越界后的val设为Infinity,能正确引导程序排除未越界数组的元素,避免错误。

2. 坑点:k的取值

必须用Math.max(1, Math.floor(k/2)),否则当k=1时,Math.floor(k/2)=0,会导致left1=offset1-1,出现负下标异常。

3. 优化点:新增的边界判断

代码中新增的“两数组均遍历完”的判断,虽然极端情况才会触发(比如两个数组长度之和刚好等于medianIndex),但能避免程序在特殊情况下返回错误结果,让代码更健壮。

五、总结

这道题的核心是「将中位数问题转化为第k小元素问题」,通过二分法每次排除一半不可能的元素,实现O(log (m+n))的时间复杂度。给出的代码不仅正确实现了该思路,还处理了所有边界情况,尤其是新增的两数组遍历完的判断,让代码更健壮。

其实二分法的难点在于“确定每次排除哪些元素”,只要抓住“比较两个数组的k/2位置元素,排除较小的那部分”这个核心,就能理清整个逻辑。

昨天以前首页

LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置:二分查找实战

作者 Wect
2026年3月25日 22:44

刷题路上,二分查找是绕不开的经典算法,而LeetCode 34题「在排序数组中查找元素的第一个和最后一个位置」,正是二分查找的进阶应用——它不仅要求我们找到目标值,更要精准定位其在非递减数组中的起始和结束位置,同时还要满足O(log n)的时间复杂度要求。今天就来拆解这道题,从题干分析到代码实现,再到细节坑点,一步步搞懂如何高效解决这道题。

一、题干解析:明确需求与约束

先再仔细读一遍题干,避免遗漏关键信息:

  • 输入:非递减顺序排列的整数数组nums,目标值target;

  • 输出:target在数组中的开始位置和结束位置,若不存在则返回[-1, -1];

  • 核心约束:必须设计时间复杂度为O(log n)的算法。

这里有两个关键点需要注意:

  1. 非递减数组:数组元素可能重复,且从左到右不递减(允许相等),这是二分查找的前提,也是我们定位边界的核心依据;

  2. O(log n)时间复杂度:直接遍历数组(O(n))会超时,因此必须用二分查找,且需要两次二分——分别找左边界(第一个等于target的位置)和右边界(最后一个等于target的位置)。

二、解题思路:两次二分查找,定位左右边界

既然数组是有序的,我们可以利用二分查找的特性,通过调整判断条件,分别找到target的左边界和右边界:

  1. 左边界:第一个大于等于target的位置(如果target存在,这个位置就是第一个target的索引;如果不存在,这个位置会大于数组长度或对应元素不等于target);

  2. 右边界:第一个大于target的位置,再减1(如果target存在,减1后就是最后一个target的索引;如果不存在,减1后会小于左边界)。

为了复用代码,我们可以设计一个通用的二分查找函数,通过一个布尔参数lower来控制查找左边界还是右边界:

  • 当lower为true时,查找左边界(第一个>=target的位置);

  • 当lower为false时,查找右边界的“临界位置”(第一个>target的位置)。

最后,通过判断左边界是否小于等于右边界、且边界对应的元素是否为target,来确定最终结果——如果满足,则返回[左边界, 右边界];否则返回[-1, -1]。

三、代码实现与逐行解析

先给出完整代码(TypeScript版本),再逐行拆解核心逻辑,确保每一步都能理解:

function searchRange(nums: number[], target: number): number[] {
  const n = nums.length;

  // 通用二分查找函数:lower控制查找左边界/右边界临界值
  const binarySearch = (target: number, lower: boolean) => {
    let left = 0, right = n - 1, ans = n; // ans初始化为n,应对target大于所有元素的情况
    while (left <= right) {
      const mid = Math.floor((left + right) / 2); // 中间位置,避免溢出
      // 关键判断:根据lower调整二分方向
      if (nums[mid] > target || (lower && nums[mid] >= target)) {
        right = mid - 1; // 目标在左半区,收缩右边界
        ans = mid; // 记录当前mid,可能是我们要找的边界
      } else {
        left = mid + 1; // 目标在右半区,收缩左边界
      }
    }
    return ans;
  }

  let res: number[] = [-1, -1]; // 初始化为不存在的情况
  const leftIdx = binarySearch(target, true); // 查找左边界
  const rightIdx = binarySearch(target, false) - 1; // 查找右边界并调整

  // 验证边界的合法性:左边界<=右边界,且边界元素等于target
  if (leftIdx <= rightIdx && rightIdx < nums.length && nums[leftIdx] === target && nums[rightIdx] === target) {
    res = [leftIdx, rightIdx];
  }

  return res;
};

核心代码逐行解析

1. 初始化与通用二分函数定义

const n = nums.length; —— 记录数组长度,避免多次调用nums.length,提升效率。

binarySearch函数:接收target和lower两个参数,返回对应边界的索引。这里ans初始化为n,是为了应对target大于数组中所有元素的情况(此时二分结束后,ans仍为n,后续rightIdx = n-1,会小于leftIdx,直接返回[-1,-1])。

2. 二分查找的核心判断逻辑

if (nums[mid] > target || (lower && nums[mid] >= target)):这是整个算法的灵魂,分两种情况理解:

  • 当lower为true(找左边界):只要nums[mid] >= target,就说明左边界可能在mid或mid左侧,因此收缩右边界(right = mid - 1),并记录当前mid为候选边界(ans = mid);

  • 当lower为false(找右边界临界值):只有nums[mid] > target时,才说明右边界临界值在mid或mid左侧,收缩右边界,否则继续向右查找。

else { left = mid + 1; }:当不满足上述条件时,说明目标在mid右侧,收缩左边界,继续查找。

3. 边界验证与结果返回

leftIdx = binarySearch(target, true):得到左边界(第一个>=target的位置);

rightIdx = binarySearch(target, false) - 1:得到第一个>target的位置,减1后就是最后一个<=target的位置(即右边界);

边界验证条件:leftIdx <= rightIdx(确保边界有效,不会出现左边界在右边界右侧的情况)、rightIdx < nums.length(避免数组越界)、nums[leftIdx] === target和nums[rightIdx] === target(确保找到的边界确实是target的位置,而非其他值的边界)。

四、关键坑点与注意事项

这道题看似简单,但很多人在二分查找的边界处理上容易出错,总结几个高频坑点:

  1. ans的初始值:必须设为n,而不是-1。如果target大于数组中所有元素,二分结束后left会超过right,ans仍为n,此时rightIdx = n-1,leftIdx = n,leftIdx > rightIdx,直接返回[-1,-1],避免出错;

  2. 二分循环条件:必须是left <= right,而不是left < right。如果用left < right,可能会错过最后一个符合条件的元素(比如数组中只有一个target时);

  3. 边界验证:不能只判断leftIdx <= rightIdx,还要验证nums[leftIdx]和nums[rightIdx]是否等于target。比如数组为[1,2,3,4],target为5,此时leftIdx = 4,rightIdx = 3,不满足leftIdx <= rightIdx;但如果target为0,leftIdx = 0,rightIdx = -1,也不满足;如果数组为[2,2],target为3,leftIdx = 2,rightIdx = 1,同样不满足;

  4. 整数溢出:mid的计算用Math.floor((left + right) / 2),在JavaScript/TypeScript中,整数范围足够大,不会出现溢出,但如果是其他语言(如Java),建议用left + Math.floor((right - left)/2),避免left+right溢出。

五、总结与拓展

这道题的核心是「二分查找的边界定位」,通过一次二分查找函数的复用,分别找到左、右边界,既满足了O(log n)的时间复杂度,又简化了代码逻辑。

拓展思考:

  • 如果数组是递减的,如何修改代码?只需调整二分查找的判断条件,将nums[mid] > target改为nums[mid] < target即可;

  • 如果题目要求找到target的出现次数,只需用右边界 - 左边界 + 1(若存在target),否则为0;

  • 二分查找的核心是「收缩边界」,只要明确“目标在左半区还是右半区”,就能灵活调整判断条件,解决各类边界查找问题。

React Hooks 核心原理

作者 Wect
2026年3月21日 17:32

Hooks 是 React 16.8 推出的里程碑特性,核心目的是 让函数组件拥有类组件的状态管理和生命周期能力,彻底解决了函数组件无法维护状态、代码复用繁琐的痛点。其底层原理围绕「Hook 调用顺序」和「Hook 存储结构」展开,逻辑简洁但约束严格,是面试高频考点。

一、核心前提:为什么 Hooks 必须依赖固定调用顺序?

通俗理解:函数组件每次渲染(首次渲染/重渲染)都会从头到尾重新执行一遍,Hooks 要想“记住”每次渲染的状态,就必须保证每次执行时,调用的顺序完全一致——就像排队领号,每次排队的顺序不能乱,才能对应到自己的号码(状态)。

专业拆解:这是 Hooks 原理的基石,核心是「状态与 Hook 调用的一一对应」,依赖两个关键底层设计:

1. 底层存储结构:Hook 链表(核心!)

React 内部为每个组件的 Fiber 节点(组件的底层抽象表示,可理解为“组件的骨架”),维护了一个 Hook 单向链表,用于存储该组件所有 Hooks 的相关信息。

每个 Hook 本身是一个对象,官方简化结构:

// 单个 Hook 节点的极简结构
const hook = {
  memoizedState: null, // 存储当前 Hook 的状态(如 useState 的值、useEffect 的回调)
  next: null, // 指向下一个 Hook 节点,串联成链表
  queue: null, // 存储该 Hook 的更新队列(如 setState 触发的新值)
};

补充说明:组件 Fiber 节点中,通过 fiber.memoizedState 指向 Hook 链表的头节点,后续每个 Hook 节点通过 next 依次连接,形成完整的链表结构(对应题干中 fiber.memoizedState = { memoizedState, next } 的极简模型)。

除了 Hook 链表,每个 Hook 节点还包含一个「更新队列」(queue),用于存储该 Hook 的待更新状态(比如 useState 调用 setXxx 时,新值会先存入 queue,等待组件重渲染时更新)。

2. 调用顺序的核心作用

React 无法通过“变量名”识别 Hooks,只能通过「调用顺序」匹配 Hook 链表中的节点,具体流程分两步:

  1. 首次渲染:函数组件执行时,会依次调用 useState、useEffect 等 Hooks,每调用一个 Hook,就创建一个对应的 Hook 节点,挂载到 Hook 链表的末尾,同时初始化 memoizedState(状态)和 queue(更新队列)。

  2. 重渲染:组件因 setState、props 变化等触发重渲染时,函数组件会再次执行,此时 React 会从 Hook 链表的头节点开始,按「与首次渲染完全相同的顺序」遍历链表,读取每个 Hook 节点的 memoizedState,从而保证“Hook 调用”与“状态”一一对应。

举个通俗例子:

function Counter() {
  // 第1个 Hook:对应链表头节点,存储 count 状态
  const [count, setCount] = useState(0);
  // 第2个 Hook:对应链表第二个节点,存储 name 状态
  const [name, setName] = useState('React');
  return <button onClick={() => setCount(count+1)}>{count}-{name}</button>;
}

首次渲染时,count 对应链表第1个节点,name 对应第2个节点;重渲染时,依然按“先 count 后 name”的顺序读取,状态不会错乱。但如果破坏顺序(比如写在条件里),React 就无法匹配到正确的节点,直接报错。

二、核心 Hooks 原理拆解

重点拆解最常考的两个 Hooks:useState(基础)和 useEffect(高频),原理简化为“步骤化”,方便背诵。

1. useState 原理(最基础,必背)

通俗理解:useState 就像一个“带记忆的盒子”,第一次调用时放入初始值,之后每次调用,要么取出盒子里的当前值,要么通过 setXxx 替换盒子里的值,并且会通知组件重新渲染。

专业拆解:本质是「读取/更新 Hook 链表中对应节点的状态」,步骤分为3个阶段:

(1)首次渲染时

  1. 创建一个新的 Hook 节点,将传入的初始值(如 0)赋值给该节点的 memoizedState。

  2. 将这个 Hook 节点挂载到组件 Fiber 的 Hook 链表末尾(通过 next 指针连接)。

  3. 返回一个数组[memoizedState, setXxx]:第一个元素是当前状态,第二个元素是触发状态更新的函数(setXxx)。

(2)调用 setXxx 时(触发更新)

  1. 将 setXxx 传入的新值,存入对应 Hook 节点的 queue(更新队列)中。

  2. 触发组件重渲染(React 会标记该组件为“待更新”,进入调度流程)。

(3)重渲染时

  1. 按首次渲染的顺序,找到该 Hook 节点,读取其 queue 中的新值,更新 memoizedState(将旧状态替换为新状态)。

  2. 再次返回最新的 [memoizedState, setXxx],保证组件渲染的是最新状态。

补充:setXxx 是异步的(React 会批量处理更新),这也是为什么有时候 setState 后,立即打印状态还是旧值——因为此时更新还未执行,需在 useEffect 中读取最新状态。

2. useEffect 原理(高频考点,重点记依赖对比)

通俗理解:useEffect 是“副作用处理器”,用于处理组件渲染之外的操作(如请求接口、操作 DOM、监听事件),它会在组件“渲染完成后”执行,并且可以控制“什么时候重新执行”。

专业拆解:核心是「依赖对比 + 异步执行」,避免副作用干扰组件渲染,步骤同样分3个阶段:

(1)首次渲染时

  1. 创建一个 useEffect 对应的 Hook 节点,存储「副作用回调函数」和「依赖数组」(第二个参数)。

  2. 组件渲染完成后(异步执行,不会阻塞 DOM 渲染),执行副作用回调函数。

(2)重渲染时

  1. 读取该 Hook 节点中存储的「旧依赖数组」,与本次重渲染的「新依赖数组」进行浅对比(对比每一项的值,基本类型比值,引用类型比地址)。

  2. 若依赖有变化:先执行上一次副作用的「清理函数」(useEffect 返回的函数),再执行本次的副作用回调,最后更新 Hook 节点中的“旧依赖数组”为新依赖。

  3. 若依赖无变化:直接跳过副作用的执行(性能优化,避免不必要的重复操作)。

(3)组件卸载时

执行该 useEffect Hook 节点的清理函数,用于清除副作用(如取消接口请求、移除事件监听),避免内存泄漏。

补充:若 useEffect 没有第二个参数(依赖数组),则每次重渲染都会执行副作用和清理函数;若依赖数组为空([]),则只在首次渲染和组件卸载时各执行一次。

三、关键约束的原理支撑(为什么 Hooks 不能写在条件里?)

核心结论(必背):Hooks 不能写在 if、for、while 等条件判断、循环中,也不能写在 return 之后,本质是为了保证「Hook 调用顺序固定不变」,避免 Hook 链表节点匹配错位。

通俗解读:假设把 Hook 放在 if 条件里,当条件从 true 变为 false 时,重渲染时该 Hook 就不会被调用,导致后续的 Hook 调用顺序整体前移一位,React 按原顺序遍历链表时,就会匹配到错误的节点,进而导致状态错乱、报错。

专业拆解:

  1. React 源码中,是通过「遍历索引」来定位 Hook 节点的(首次渲染时记录索引,重渲染时按索引匹配),一旦调用顺序被破坏,索引对应关系就会失效。

  2. 举个反例:

function WrongComponent() {
  const [count, setCount] = useState(0);
  // 错误:Hook 写在条件里
  if (count > 0) {
    const [name, setName] = useState('React'); // 条件为 false 时,该 Hook 不执行
  }
  return <button onClick={() => setCount(count+1)}>{count}</button>;
}

当 count 从 0 变为 1 时,首次执行 if 里的 useState,Hook 链表有2个节点;当 count 再变为 0 时,if 条件不成立,该 Hook 不执行,重渲染时只调用1个 useState,React 按顺序遍历链表时,会试图读取第二个节点(不存在),直接抛出错误:“Hooks must be called in the exact same order in every render”。

四、核心总结

  1. 核心结构:每个组件 Fiber 节点维护一个 Hook 单向链表,每个 Hook 节点存储 memoizedState(状态)、next(下一个节点)、queue(更新队列),靠「fiber.memoizedState」指向链表头节点。

  2. 核心逻辑:首次渲染创建 Hook 节点并初始化,重渲染按固定顺序读取/更新节点状态;useState 负责状态的读取与更新,useEffect 基于依赖对比控制副作用的执行时机。

  3. 核心约束:Hooks 必须在函数组件顶层调用,本质是保证调用顺序固定,避免 Hook 链表节点匹配错位,导致状态错乱。

五、面试常考问题及标准回答

1. 请说说 React Hooks 的核心原理?

标准回答:Hooks 的核心是「固定调用顺序 + Hook 链表存储」。React 为每个组件的 Fiber 节点维护一个 Hook 单向链表,每个 Hook 节点存储状态(memoizedState)、下一个节点(next)和更新队列(queue);首次渲染时,按顺序创建 Hook 节点并挂载到链表,初始化状态;重渲染时,按相同顺序遍历链表,读取/更新对应节点的状态,保证 Hook 调用与状态一一对应。其核心目的是让函数组件拥有状态管理和生命周期能力。

2. 为什么 Hooks 不能写在条件判断、循环里?

标准回答:因为 Hooks 依赖「固定的调用顺序」来匹配 Hook 链表中的节点。React 无法通过变量名识别 Hooks,只能按调用顺序遍历链表、匹配状态;若写在条件/循环里,会导致组件重渲染时,Hook 调用顺序发生变化,React 无法匹配到正确的链表节点,进而导致状态错乱、抛出错误。

3. useState 的原理是什么?setXxx 是同步还是异步?

标准回答:useState 本质是操作组件 Fiber 节点上的 Hook 链表——首次渲染创建 Hook 节点,初始化状态并返回 [状态, setXxx];调用 setXxx 时,将新值存入该 Hook 的更新队列,触发组件重渲染;重渲染时,按顺序读取更新队列中的新值,更新状态并返回最新结果。setXxx 是异步的,React 会批量处理更新,避免频繁渲染,因此直接在 setXxx 后打印状态,可能得到旧值。

4. useEffect 的依赖数组作用是什么?依赖为空([])和不写依赖有什么区别?

标准回答:依赖数组的作用是「控制 useEffect 副作用的执行时机」,React 会通过浅对比新旧依赖数组,判断是否执行副作用。区别:① 不写依赖数组:每次组件重渲染,都会执行副作用和清理函数;② 依赖数组为空([]):只在组件首次渲染时执行一次副作用,组件卸载时执行一次清理函数,相当于类组件的 componentDidMount 和 componentWillUnmount。

5. Hook 链表的结构是什么?fiber.memoizedState 作用是什么?

标准回答:单个 Hook 节点的结构是 { memoizedState: 状态值, next: 下一个 Hook 节点, queue: 更新队列 },多个 Hook 节点通过 next 指针串联成单向链表。fiber.memoizedState 的作用是「指向该组件 Hook 链表的头节点」,React 通过它遍历整个 Hook 链表,读取和更新各个 Hook 的状态。

6. 函数组件重渲染时,Hooks 是如何“记住”上一次的状态的?

标准回答:因为状态存储在组件 Fiber 节点的 Hook 链表中,而非函数组件的局部变量(局部变量每次渲染都会重新初始化)。重渲染时,函数组件重新执行,React 会从 Hook 链表的头节点开始,按与首次渲染相同的顺序,读取每个 Hook 节点的 memoizedState,从而“记住”上一次的状态。

❌
❌