<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>YYYLEGEND</title><description>Sena Lab</description><link>https://www.yyylegend.com/</link><language>zh_CN</language><item><title>4月4日刷题笔记--数组双指针与动态规划入门</title><link>https://www.yyylegend.com/posts/4%E6%9C%884%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E6%95%B0%E7%BB%84%E5%8F%8C%E6%8C%87%E9%92%88%E4%B8%8E%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E5%85%A5%E9%97%A8/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/4%E6%9C%884%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E6%95%B0%E7%BB%84%E5%8F%8C%E6%8C%87%E9%92%88%E4%B8%8E%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E5%85%A5%E9%97%A8/</guid><description>双指针核心模板整理，LeetCode 26 删除有序数组中的重复项、283 移动零、167 两数之和 II、15 三数之和、300 最长递增子序列题解</description><pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;26&quot; title=&quot;Remove Duplicates from Sorted Array&quot; zh=&quot;删除有序数组中的重复项&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;283&quot; title=&quot;Move Zeroes&quot; zh=&quot;移动零&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;167&quot; title=&quot;Two Sum II - Input Array Is Sorted&quot; zh=&quot;两数之和 II - 输入有序数组&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;15&quot; title=&quot;3Sum&quot; zh=&quot;三数之和&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;300&quot; title=&quot;Longest Increasing Subsequence&quot; zh=&quot;最长递增子序列&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;双指针基础概念&lt;/h2&gt;
&lt;h3&gt;什么是双指针&lt;/h3&gt;
&lt;p&gt;双指针是指用两个变量分别指向数组中的不同位置，通过它们的相对移动来解决问题。&lt;code&gt;for&lt;/code&gt; 循环本质上也是一种指针，只是隐式地自动 &lt;code&gt;+1&lt;/code&gt;，和手动维护指针完全等价。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 手动双指针写法
l, r = 0, 1
while r &amp;lt; len(nums):
    # ...
    r += 1  # 手动移动

# for 循环写法（等价）
for r in range(1, len(nums)):
    # r 自动 +1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;双指针通用模板&lt;/h3&gt;
&lt;h4&gt;模板① 快慢指针（原地修改数组）&lt;/h4&gt;
&lt;p&gt;适用：删除重复元素、移动零、原地过滤&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;l = 0  # 慢指针，指向下一个&quot;合法位置&quot;
for r in range(len(nums)):   # 快指针，负责遍历探测
    if &amp;lt;满足条件&amp;gt;:
        nums[l] = nums[r]    # 把合法元素写入 l 的位置
        l += 1               # l 右移，准备接下一个合法元素
# 最终 l 就是合法元素的数量
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;模板② 对撞指针（有序数组找目标）&lt;/h4&gt;
&lt;p&gt;适用：两数之和、三数之和&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;l, r = 0, len(nums) - 1
while l &amp;lt; r:
    if nums[l] + nums[r] &amp;gt; target:
        r -= 1    # 和太大，右指针左移
    elif nums[l] + nums[r] &amp;lt; target:
        l += 1    # 和太小，左指针右移
    else:
        return [l, r]  # 找到答案
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 26 · 删除有序数组中的重复项&lt;/h2&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;用快慢双指针，&lt;code&gt;l&lt;/code&gt; 记录下一个不重复元素该放的位置，&lt;code&gt;r&lt;/code&gt;（&lt;code&gt;for&lt;/code&gt; 循环）遍历数组。遇到新元素就放到 &lt;code&gt;l&lt;/code&gt; 的位置，然后 &lt;code&gt;l&lt;/code&gt; 右移。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def removeDuplicates(self, nums: List[int]) -&amp;gt; int:
        l = 1  # 第一个元素永远保留，从第二个位置开始填

        for r in range(1, len(nums)):
            if nums[r] != nums[r - 1]:  # 遇到新元素
                nums[l] = nums[r]        # 放到 l 的位置
                l += 1
        return l  # l 本身就是不重复元素的数量
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;l&lt;/code&gt; 从 1 开始&lt;/strong&gt;：第一个元素永远不需要去重，所以 &lt;code&gt;l=1&lt;/code&gt; 表示从第二个位置开始填，最终 &lt;code&gt;l&lt;/code&gt; 直接等于唯一元素的数量，不需要 &lt;code&gt;return l + 1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;l&lt;/code&gt; 的含义&lt;/strong&gt;：&lt;code&gt;l&lt;/code&gt; 不是索引，而是&quot;已处理的不重复元素个数&quot;，每次找到新元素就先赋值再 &lt;code&gt;+1&lt;/code&gt;，所以最终值就是答案。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：快慢双指针，&lt;code&gt;l&lt;/code&gt; 慢指针记录写入位置，&lt;code&gt;r&lt;/code&gt;（&lt;code&gt;for&lt;/code&gt;）快指针遍历&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n)，空间 O(1)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 283 · 移动零&lt;/h2&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;快慢双指针，&lt;code&gt;r&lt;/code&gt; 遍历数组，遇到非零元素就和 &lt;code&gt;l&lt;/code&gt; 交换，&lt;code&gt;l&lt;/code&gt; 右移。非零数字自然移到前面，零就沉到后面了。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def moveZeroes(self, nums: List[int]) -&amp;gt; None:
        l, r = 0, 0

        while r &amp;lt; len(nums):
            if nums[r] != 0:
                nums[l], nums[r] = nums[r], nums[l]  # 交换
                l += 1  # l 右移，等待下一个非零数字
            r += 1      # r 每轮都右移探测
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;和第 26 题的区别：这题用&lt;strong&gt;交换&lt;/strong&gt;而不是&lt;strong&gt;覆盖&lt;/strong&gt;，因为要保留零，只是把它们移到末尾。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：快慢双指针，遇到非零就交换&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n)，空间 O(1)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 167 · 两数之和 II&lt;/h2&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;数组已升序排列，用对撞指针。两数之和大于 target 就让右指针左移（变小），小于 target 就让左指针右移（变大），等于就返回。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def twoSum(self, numbers: List[int], target: int) -&amp;gt; List[int]:
        left = 0
        right = len(numbers) - 1

        while left &amp;lt; right:
            if numbers[left] + numbers[right] &amp;gt; target:
                right -= 1
            elif numbers[left] + numbers[right] &amp;lt; target:
                left += 1
            else:
                return [left + 1, right + 1]  # 下标从 1 开始，所以 +1

        return []  # 题目保证有解，这行可写可不写
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;为什么第二个条件必须用 &lt;code&gt;elif&lt;/code&gt; 不能用 &lt;code&gt;if&lt;/code&gt;&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;elif&lt;/code&gt; 保证每次循环&lt;strong&gt;只移动一个指针&lt;/strong&gt;。如果改成 &lt;code&gt;if&lt;/code&gt;，第一个条件成立执行 &lt;code&gt;right -= 1&lt;/code&gt; 后，会继续判断第二个 &lt;code&gt;if&lt;/code&gt;，可能在同一轮循环里同时移动两个指针，导致跳过答案甚至死循环。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：对撞指针，利用有序性有方向地移动&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n)，空间 O(1)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 15 · 三数之和&lt;/h2&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;把三数之和降维成两数之和。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;for&lt;/code&gt; 循环固定第一个数 &lt;code&gt;a&lt;/code&gt;，问题变成：在 &lt;code&gt;a&lt;/code&gt; 右边找两个数之和等于 &lt;code&gt;-a&lt;/code&gt;，这就是上一题的双指针做法。额外要处理去重。&lt;/p&gt;
&lt;p&gt;记忆方式：&lt;strong&gt;排序 → for 循环固定 &lt;code&gt;a&lt;/code&gt; → 剩下的用 twoSum 双指针 → 两处去重&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def threeSum(self, nums: list[int]) -&amp;gt; list[list[int]]:
        # 先升序排序，让双指针可以有方向地移动
        res = []
        nums.sort()

        # enumerate 同时拿到锚点的索引和值
        for i, number in enumerate(nums):

            # 锚点去重：如果当前锚点和上一个相同，跳过
            # 因为结果会完全重复，没有意义
            if i &amp;gt; 0 and number == nums[i - 1]:
                continue

            # 初始化左右指针，在锚点右边的范围内找两数之和
            l = i + 1
            r = len(nums) - 1

            # 只要左右指针没相遇就继续
            while l &amp;lt; r:
                temp_three_sum = number + nums[l] + nums[r]
                # 和太大，右指针左移（让和变小）
                if temp_three_sum &amp;gt; 0:
                    r -= 1
                # 和太小，左指针右移（让和变大）
                elif temp_three_sum &amp;lt; 0:
                    l += 1
                else:
                    # 找到和为 0 的三个数，加入结果列表
                    res.append([number, nums[l], nums[r]])
                    l += 1
                    # 左指针去重：跳过和上一个相同的数字
                    # 注意 l &amp;lt; r 要放前面，防止越界后再访问 nums[l]
                    while l &amp;lt; r and nums[l] == nums[l - 1]:
                        l += 1
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;两处去重&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;锚点 &lt;code&gt;a&lt;/code&gt; 去重：&lt;code&gt;if i &amp;gt; 0 and number == nums[i - 1]: continue&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;找到答案后 &lt;code&gt;l&lt;/code&gt; 去重：&lt;code&gt;while l &amp;lt; r and nums[l] == nums[l - 1]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;条件顺序&lt;/strong&gt;：&lt;code&gt;while l &amp;lt; r and nums[l] == nums[l - 1]&lt;/code&gt; 中 &lt;code&gt;l &amp;lt; r&lt;/code&gt; 必须放前面，利用短路求值，防止 &lt;code&gt;l&lt;/code&gt; 越界时访问 &lt;code&gt;nums[l]&lt;/code&gt; 报错。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：for 循环固定锚点 + twoSum 双指针，两处去重&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n²)，空间 O(1)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 300 · 最长递增子序列&lt;/h2&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;这题换成&lt;strong&gt;动态规划&lt;/strong&gt;，不是双指针。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LIS[i]&lt;/code&gt; 表示从 &lt;code&gt;nums[i]&lt;/code&gt; 开始的最长递增子序列长度，初始都为 1（每个元素自己就是长度为 1 的子序列）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;从右往左遍历&lt;/strong&gt;：对于每个 &lt;code&gt;i&lt;/code&gt;，看右边所有比它大的 &lt;code&gt;j&lt;/code&gt;，用 &lt;code&gt;1 + LIS[j]&lt;/code&gt; 来更新 &lt;code&gt;LIS[i]&lt;/code&gt;。为什么从右往左？因为 &lt;code&gt;LIS[i]&lt;/code&gt; 依赖右边的结果，右边必须先算好。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def lengthOfLIS(self, nums: List[int]) -&amp;gt; int:
        # 初始化 LIS 列表，每个数字的 LIS 初始值为 1（自己本身）
        LIS = [1] * len(nums)

        for i in range(len(nums) - 1, -1, -1):  # 从右往左遍历（锚点）
            for j in range(i + 1, len(nums)):    # j 从 i 右边一位遍历到末尾
                # 只有右边数字更大时，才能形成递增子序列，才更新 LIS[i]
                if nums[j] &amp;gt; nums[i]:
                    LIS[i] = max(LIS[i], 1 + LIS[j])

        # LIS 的起点可以是任意一个数字，所以返回 LIS 中的最大值
        return max(LIS)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;走一遍例子&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nums = [1, 2, 4, 3]
LIS  = [1, 1, 1, 1]  # 初始

i=3(3): 右边没有数，LIS 不变
i=2(4): 右边没有比 4 大的，LIS 不变
i=1(2): nums[2]=4&amp;gt;2，LIS[1]=max(1,1+1)=2
        nums[3]=3&amp;gt;2，LIS[1]=max(2,1+1)=2
        LIS=[1,2,1,1]
i=0(1): nums[1]=2&amp;gt;1，LIS[0]=max(1,1+2)=3
        LIS=[3,2,1,1]

返回 max(LIS) = 3  ✓  （子序列是 [1,2,4] 或 [1,2,3]）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：动态规划，从右往左保证依赖已计算&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n²)，空间 O(n)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;动态规划的心法与通用模板&lt;/h2&gt;
&lt;h3&gt;什么是动态规划（DP）？&lt;/h3&gt;
&lt;p&gt;动态规划 = &lt;strong&gt;定义状态 + 找递推关系 + 遍历顺序&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;与递归的本质区别：&lt;strong&gt;用数组记录中间结果，避免重复计算&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;动态规划的三步曲&lt;/h3&gt;
&lt;h4&gt;步骤1️⃣：定义状态（最关键！）&lt;/h4&gt;
&lt;p&gt;用一个数组或字典定义&quot;问题的子问题&quot;，每个位置代表一种情况的答案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 模板：
# dp[i] = 某个答案 / 数值
# 含义：从 i 开始（或到 i 为止）的最大/最小值、路径数等

# 300题例子：
# dp[i] = 从 nums[i] 开始的最长递增子序列长度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;定义状态的秘诀&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;问自己：&lt;strong&gt;&quot;我要存储什么中间结果？&quot;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;不要定义模糊的状态，要具体到某个位置/某种选择&lt;/li&gt;
&lt;li&gt;状态定义好了，递推关系就水到渠成&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;步骤2️⃣：找递推关系（推导过程）&lt;/h4&gt;
&lt;p&gt;用前面已算出的状态，推导当前状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 模板：
# dp[i] = f(dp[i-1], dp[i-2], ...) 或者
# dp[i] = f(dp[i+1], dp[i+2], ...)

# 300题例子：
# dp[i] = max(1, max(1 + dp[j] for j in range(i+1, n) if nums[j] &amp;gt; nums[i]))
#         ^^^^^^ 当前值本身是1    ^^^^^^^^^^^^^ 如果右边有更大的数，延伸过去
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;找递推关系的秘诀&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;问自己：&lt;strong&gt;&quot;当前状态怎样从之前的状态算出来？&quot;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;分情况讨论：选择做什么 vs. 不做什么、符合条件 vs. 不符合等&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;max()&lt;/code&gt; / &lt;code&gt;min()&lt;/code&gt; / &lt;code&gt;+&lt;/code&gt; 组合已知状态&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;步骤3️⃣：遍历顺序（至关重要！）&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;这决定了依赖关系是否满足。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 从左往右：dp[i] 依赖 dp[i-1] 等左边的值
for i in range(len(arr)):
    dp[i] = f(dp[i-1], ...)

# 从右往左：dp[i] 依赖 dp[i+1] 等右边的值
for i in range(len(arr) - 1, -1, -1):
    dp[i] = f(dp[i+1], ...)

# 300题：从右往左，因为 dp[i] = max(...dp[j] for j &amp;gt; i)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;遍历顺序的秘诀&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;先算被依赖的，再算依赖它的&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;如果 &lt;code&gt;dp[i]&lt;/code&gt; 需要 &lt;code&gt;dp[j]&lt;/code&gt;，那么 &lt;code&gt;dp[j]&lt;/code&gt; 必须先算好&lt;/li&gt;
&lt;li&gt;错的遍历顺序 = 用未初始化的值 = 结果全错&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;动态规划通用模板&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def solve(self, arr):
        n = len(arr)
        
        # 第一步：定义状态并初始化
        dp = [初始值] * n  # 或者 dp = {状态: 值}
        
        # 可能需要 base case
        dp[边界位置] = 边界值
        
        # 第二步：确定遍历顺序和递推关系
        # 方案A：从左往右（依赖左边）
        for i in range(1, n):
            # 根据 dp[i-1] 等推导 dp[i]
            dp[i] = f(dp[i-1], arr[i])
        
        # 方案B：从右往左（依赖右边）
        for i in range(n-2, -1, -1):
            # 根据 dp[i+1] 等推导 dp[i]
            dp[i] = f(dp[i+1], arr[i])
        
        # 第三步：返回答案
        return max(dp) / min(dp) / dp[n-1] / ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;常见的递推关系套路&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题型&lt;/th&gt;
&lt;th&gt;递推关系&lt;/th&gt;
&lt;th&gt;例题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;最长/最短序列&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[i] = max(dp[i], 1 + dp[...])&lt;/code&gt; 找条件符合的最优子状态&lt;/td&gt;
&lt;td&gt;300-LIS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;路径计数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[i] = sum(dp[j])&lt;/code&gt; 汇总所有合法前驱状态&lt;/td&gt;
&lt;td&gt;爬楼梯&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;能否到达&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[i] = dp[i] or dp[j]&lt;/code&gt; 只要有一条路径可达就算可达&lt;/td&gt;
&lt;td&gt;跳跃游戏&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数字和类&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[i] = min(dp[i], cost + dp[...])&lt;/code&gt; 选最小成本方案&lt;/td&gt;
&lt;td&gt;硬币找零&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;动态规划 vs. 贪心 vs. 递归&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;动态规划&lt;/th&gt;
&lt;th&gt;贪心&lt;/th&gt;
&lt;th&gt;递归（不记忆化）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;最优子结构&lt;/td&gt;
&lt;td&gt;✅ 有&lt;/td&gt;
&lt;td&gt;✅ 有&lt;/td&gt;
&lt;td&gt;✅ 有&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;无后效性&lt;/td&gt;
&lt;td&gt;✅ 有&lt;/td&gt;
&lt;td&gt;✅ 有&lt;/td&gt;
&lt;td&gt;✅ 有&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;子问题重叠&lt;/td&gt;
&lt;td&gt;✅ 有&lt;/td&gt;
&lt;td&gt;❌ 无&lt;/td&gt;
&lt;td&gt;✅ 有&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;需要记忆化&lt;/td&gt;
&lt;td&gt;✅ 是&lt;/td&gt;
&lt;td&gt;❌ 不需要&lt;/td&gt;
&lt;td&gt;✅ 是&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;复杂度&lt;/td&gt;
&lt;td&gt;中等&lt;/td&gt;
&lt;td&gt;最优&lt;/td&gt;
&lt;td&gt;指数（不优化）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;记住这个口诀&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;定义状态 + 列递推 + 选遍历 = 动态规划&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;状态定义好，递推自然来；遍历顺序对，结果不会坏。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;五题横向对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题目&lt;/th&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;核心技巧&lt;/th&gt;
&lt;th&gt;特殊处理&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;26 删除重复项&lt;/td&gt;
&lt;td&gt;快慢指针&lt;/td&gt;
&lt;td&gt;&lt;code&gt;l&lt;/code&gt; 记录写入位置，遇到新元素就填入&lt;/td&gt;
&lt;td&gt;&lt;code&gt;l&lt;/code&gt; 从 1 开始，返回 &lt;code&gt;l&lt;/code&gt; 即为答案&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;283 移动零&lt;/td&gt;
&lt;td&gt;快慢指针&lt;/td&gt;
&lt;td&gt;遇到非零就和 &lt;code&gt;l&lt;/code&gt; 交换&lt;/td&gt;
&lt;td&gt;用交换而非覆盖&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;167 两数之和 II&lt;/td&gt;
&lt;td&gt;对撞指针&lt;/td&gt;
&lt;td&gt;有序数组，大了缩右，小了扩左&lt;/td&gt;
&lt;td&gt;第二个条件必须用 &lt;code&gt;elif&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 三数之和&lt;/td&gt;
&lt;td&gt;对撞指针&lt;/td&gt;
&lt;td&gt;for 固定锚点 + twoSum 双指针&lt;/td&gt;
&lt;td&gt;两处去重，注意条件顺序防越界&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 最长递增子序列&lt;/td&gt;
&lt;td&gt;动态规划&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIS[i]&lt;/code&gt; 从右往左更新&lt;/td&gt;
&lt;td&gt;从右往左保证依赖已计算&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>4月3日刷题笔记--课程表/BST最近公共祖先/数组复习</title><link>https://www.yyylegend.com/posts/4%E6%9C%883%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0--%E8%AF%BE%E7%A8%8B%E8%A1%A8bst%E6%9C%80%E8%BF%91%E5%85%AC%E5%85%B1%E7%A5%96%E5%85%88%E6%95%B0%E7%BB%84%E5%A4%8D%E4%B9%A0/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/4%E6%9C%883%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0--%E8%AF%BE%E7%A8%8B%E8%A1%A8bst%E6%9C%80%E8%BF%91%E5%85%AC%E5%85%B1%E7%A5%96%E5%85%88%E6%95%B0%E7%BB%84%E5%A4%8D%E4%B9%A0/</guid><description>有向图环检测（DFS + 回溯 + 记忆化）、二叉树和二叉搜索树的LCA问题详解，三种递归与迭代方法对比</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;207&quot; title=&quot;Course Schedule&quot; zh=&quot;课程表&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;236&quot; title=&quot;Lowest Common Ancestor of a Binary Tree&quot; zh=&quot;二叉树的最近公共祖先&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;235&quot; title=&quot;Lowest Common Ancestor of a Binary Search Tree&quot; zh=&quot;二叉搜索树的最近公共祖先&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;46&quot; title=&quot;Permutations&quot; zh=&quot;全排列&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;1&quot; title=&quot;Two Sum&quot; zh=&quot;两数之和&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;167&quot; title=&quot;Two Sum II - Input Array Is Sorted&quot; zh=&quot;两数之和 II - 输入有序数组&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;📝 LeetCode 207: 课程表 (Course Schedule)&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;207&quot; title=&quot;Course Schedule&quot; zh=&quot;课程表&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;📌 核心思想&lt;/h3&gt;
&lt;p&gt;本质是“有向图检测环”。&lt;/p&gt;
&lt;p&gt;采用 &lt;strong&gt;DFS（深度优先搜索）+ 状态回溯 + 记忆化优化&lt;/strong&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;📦 步骤一：建图（魔法拆快递）&lt;/h3&gt;
&lt;p&gt;我们要把题目给的二维数组，变成一个方便查询的”字典（邻接表）”。&lt;/p&gt;
&lt;h4&gt;代码块 1️⃣：准备空容器&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;preMap = {i: [] for i in range(numCourses)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：使用&lt;strong&gt;字典推导式&lt;/strong&gt;构建邻接表。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;{...}&lt;/code&gt; 创建字典&lt;/li&gt;
&lt;li&gt;&lt;code&gt;i: []&lt;/code&gt; 键值对，&lt;code&gt;i&lt;/code&gt; 是课程号（0 到 numCourses-1），值是空列表&lt;/li&gt;
&lt;li&gt;&lt;code&gt;for i in range(numCourses)&lt;/code&gt; 遍历所有课程编号&lt;/li&gt;
&lt;li&gt;结果：每个课程号对应一个空列表，后续用来存储其先修课&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;代码块 2️⃣：填充邮箱&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;for crs, pre in prerequisites:
    preMap[crs].append(pre)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：遍历并填充邻接表。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;prerequisites&lt;/code&gt; 是二维数组，每个元素都是 &lt;code&gt;[课程号, 先修课程号]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;for crs, pre in prerequisites&lt;/code&gt; 使用&lt;strong&gt;元组拆包&lt;/strong&gt;（unpacking）——每次迭代直接将 &lt;code&gt;[crs_val, pre_val]&lt;/code&gt; 拆开赋值给 &lt;code&gt;crs&lt;/code&gt; 和 &lt;code&gt;pre&lt;/code&gt; 两个变量&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preMap[crs].append(pre)&lt;/code&gt; 将先修课程 &lt;code&gt;pre&lt;/code&gt; 添加到课程 &lt;code&gt;crs&lt;/code&gt; 的先修课列表中&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;最终结果示例&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;preMap = {
    0: [1],        # 课程0 要先修课程1
    1: [2],        # 课程1 要先修课程2
    2: [],         # 课程2 无先修课
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;🕵️‍♂️ 步骤二：DFS 找环（迷宫探险）&lt;/h3&gt;
&lt;p&gt;把每一门课当成迷宫里的房间，先修课就是通向下一个房间的门。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;visitSet&lt;/code&gt; 记录的是&lt;strong&gt;当前这单趟探险&lt;/strong&gt;沿途撒下的“面包屑”，用来防止自己绕圈子。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;死胡同（发现环）&lt;/strong&gt;：如果前面的房间地上有自己的面包屑，说明死循环了，返回 &lt;code&gt;False&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;安全屋（无先修课）&lt;/strong&gt;：如果没有前置课，说明这条路走到底且安全，返回 &lt;code&gt;True&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;撒面包屑&lt;/strong&gt;：进门探险前，记录当前位置 &lt;code&gt;visitSet.add(crs)&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;捡面包屑（回溯）&lt;/strong&gt;：这门课的所有前置路线都安全查完后，&lt;strong&gt;必须把面包屑捡起来&lt;/strong&gt; &lt;code&gt;visitSet.remove(crs)&lt;/code&gt;，以免影响其他路线的判断。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;贴安全封条（记忆化优化）&lt;/strong&gt;：既然证明了这门课绝对安全，直接清空它的前置课表 &lt;code&gt;preMap[crs] = []&lt;/code&gt;。下次别的路线再走到这里，瞬间就能放行，极大提升运行速度。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;🏝️ 步骤三：全局排查（清剿孤岛）&lt;/h3&gt;
&lt;p&gt;不能只从 0 号课程查一次就结束，因为图里可能存在&lt;strong&gt;完全不连通的孤岛（几门课互相死循环，但和其他课没关系）&lt;/strong&gt;。&lt;/p&gt;
&lt;h4&gt;代码块 3️⃣：枚举所有起点&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;for crs in range(numCourses):
    if not dfs(crs): return False
return True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;类比&lt;/strong&gt;：想象你是一个保安，要排查一个建筑群是否存在”环形走廊”（能够走一圈回到起点）。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你不能只从大厅出发检查，还要从每个楼层、每个房间都走一遍&lt;/li&gt;
&lt;li&gt;对于已经检查过的区域，上面的”记忆化优化”会让你瞬间跳过（贴了”已检查”的封条）&lt;/li&gt;
&lt;li&gt;一旦发现任何一条路径存在环，立即返回 &lt;code&gt;False&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;所有起点都检查完了还没发现环，说明整个建筑是&lt;strong&gt;安全的&lt;/strong&gt;，返回 &lt;code&gt;True&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;实际效率&lt;/strong&gt;：虽然看似要遍历所有课程，但由于 DFS 中的记忆化优化，每条边最多被访问一次，总时间复杂度仍是 $O(V + E)$（顶点数 + 边数）。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;💻 终极背诵模板 (Python)&lt;/h3&gt;
&lt;h4&gt;代码块 4️⃣a：框架与初始化&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -&amp;gt; bool:
        # 1. 建图 (Map each course to prereq list)
        preMap = {i: [] for i in range(numCourses)}
        for crs, pre in prerequisites:
            preMap[crs].append(pre)
        
        # 记录当前 DFS 路径上的课程
        visitSet = set()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;preMap&lt;/code&gt;：邻接表（每个课程 → 其先修课列表）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;visitSet&lt;/code&gt;：当前单趟探险路径上的”面包屑”集合，用来检测环&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;代码块 4️⃣b：DFS 核心逻辑（三条分支）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;        def dfs(crs):
            # ① 发现环：遇到自己留下的面包屑
            if crs in visitSet: 
                return False
            
            # ② 安全出口：没有先修课了
            if preMap[crs] == []: 
                return True

            # ③ 探险模式：标记 + 递归 + 回溯
            visitSet.add(crs)
            for pre in preMap[crs]:
                if not dfs(pre): 
                    return False
            visitSet.remove(crs)  # 关键：捡起面包屑，为其他路径让开
            
            # ④ 记忆化优化：证明当前课程绝对安全，清空其先修课
            preMap[crs] = []
            return True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;三条关键分支&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;环检测&lt;/strong&gt;（&lt;code&gt;if crs in visitSet&lt;/code&gt;）：如果当前课程已在当前路径上，说明形成了环，返回 &lt;code&gt;False&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;终止条件&lt;/strong&gt;（&lt;code&gt;if preMap[crs] == []&lt;/code&gt;）：如果没有先修课了，说明这条路是安全的，返回 &lt;code&gt;True&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态转移&lt;/strong&gt;（回溯过程）：
&lt;ul&gt;
&lt;li&gt;进入前：撒面包屑 &lt;code&gt;visitSet.add(crs)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;递归：检查所有先修课&lt;/li&gt;
&lt;li&gt;退出前：捡起面包屑 &lt;code&gt;visitSet.remove(crs)&lt;/code&gt;（必须做！否则会影响其他分支）&lt;/li&gt;
&lt;li&gt;清空先修课列表 &lt;code&gt;preMap[crs] = []&lt;/code&gt;（记忆化，下次查询秒返）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;代码块 4️⃣c：主函数（全局遍历）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;        # 遍历所有课程，防止有不连通的”孤岛”环
        for crs in range(numCourses):
            if not dfs(crs): 
                return False
        return True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;为什么要全局遍历&lt;/strong&gt;：图可能不连通，单个 &lt;code&gt;DFS&lt;/code&gt; 无法覆盖所有分量。从每个课程都试一次，确保没有隐藏的环形孤岛。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;完整模板汇总&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -&amp;gt; bool:
        # 1. 建图 (Map each course to prereq list)
        preMap = {i: [] for i in range(numCourses)}
        for crs, pre in prerequisites:
            preMap[crs].append(pre)
        
        # 记录当前 DFS 路径上的课程
        visitSet = set()
        
        # 2. 深度优先搜索
        def dfs(crs):
            # 遇到自己撒的面包屑，发现环
            if crs in visitSet: 
                return False
            # 已经是安全屋
            if preMap[crs] == []: 
                return True

            # 标记正在访问（撒面包屑）
            visitSet.add(crs)
            
            # 顺藤摸瓜查所有的前置课
            for pre in preMap[crs]:
                if not dfs(pre): 
                    return False
                
            # 回溯：撤销访问标记（捡面包屑）
            visitSet.remove(crs)
            # 记忆化优化：标记为绝对安全
            preMap[crs] = []
            
            return True

        # 3. 遍历所有课程，防止有不连通的”孤岛”环
        for crs in range(numCourses):
            if not dfs(crs): 
                return False
        return True
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;📝 LeetCode 236: 二叉树的最近公共祖先&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;236&quot; title=&quot;Lowest Common Ancestor of a Binary Tree&quot; zh=&quot;二叉树的最近公共祖先&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;适用场景&lt;/strong&gt;：普通二叉树（节点值无大小规律）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心思路&lt;/strong&gt;：&lt;strong&gt;后序遍历（左右中）&lt;/strong&gt;。因为我们要找“祖先”，必须先自顶向下查到底部，然后再&lt;strong&gt;自底向上&lt;/strong&gt;把查到的结果层层返回给父节点。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 $O(N)$，空间 $O(N)$（递归调用栈的深度）。&lt;/p&gt;
&lt;h3&gt;🔍 代码分步详解&lt;/h3&gt;
&lt;h4&gt;代码块 1️⃣：终止条件（Base Case）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;if not root or root == p or root == q:
    return root
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;if not root&lt;/code&gt;：当前节点为空（走到死胡同），直接返回 &lt;code&gt;None&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;or root == p or root == q&lt;/code&gt;：当前节点恰好是目标节点 &lt;code&gt;p&lt;/code&gt; 或 &lt;code&gt;q&lt;/code&gt;，找到了其中一个目标，直接返回当前节点&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;代码块 2️⃣：递归查找左右子树&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;分别对左子树和右子树进行递归调用&lt;/li&gt;
&lt;li&gt;&lt;code&gt;left&lt;/code&gt; 保存从左子树返回的结果（可能是 &lt;code&gt;None&lt;/code&gt;、&lt;code&gt;p&lt;/code&gt;、&lt;code&gt;q&lt;/code&gt; 或者 LCA）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;right&lt;/code&gt; 保存从右子树返回的结果&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;代码块 3️⃣：自底向上处理结果（归）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;if left and right:
    return root

return left or right
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;逻辑&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;情况 A&lt;/strong&gt;：&lt;code&gt;left&lt;/code&gt; 和 &lt;code&gt;right&lt;/code&gt; &lt;strong&gt;都不为空&lt;/strong&gt; → &lt;code&gt;p&lt;/code&gt; 和 &lt;code&gt;q&lt;/code&gt; 分别在当前节点的左右两侧 → 当前节点就是 &lt;strong&gt;LCA&lt;/strong&gt;，返回 &lt;code&gt;root&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;情况 B&lt;/strong&gt;：&lt;code&gt;left&lt;/code&gt; 和 &lt;code&gt;right&lt;/code&gt; &lt;strong&gt;只有一个不为空&lt;/strong&gt; → &lt;code&gt;p&lt;/code&gt; 和 &lt;code&gt;q&lt;/code&gt; 都在同一侧（或只找到了其中一个） → 返回那个不为空的结果 &lt;code&gt;left or right&lt;/code&gt;
&lt;ul&gt;
&lt;li&gt;这里 &lt;code&gt;left or right&lt;/code&gt; 等价于：如果 &lt;code&gt;left&lt;/code&gt; 存在则返回 &lt;code&gt;left&lt;/code&gt;，否则返回 &lt;code&gt;right&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;💻 完整代码模板&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def lowestCommonAncestor(self, root: &apos;TreeNode&apos;, p: &apos;TreeNode&apos;, q: &apos;TreeNode&apos;) -&amp;gt; &apos;TreeNode&apos;:
        # 1. 终止条件：遇到空节点，或找到 p / q
        if not root or root == p or root == q:
            return root
        
        # 2. 递归遍历左右子树
        left = self.lowestCommonAncestor(root.left, p, q)
        right = self.lowestCommonAncestor(root.right, p, q)
        
        # 3. 自底向上处理结果
        if left and right:
            return root
        return left or right
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;💡 核心要点&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;后序遍历的妙处&lt;/strong&gt;：必须先处理完左右子树，再处理当前节点。这样才能确保：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当 &lt;code&gt;left&lt;/code&gt; 和 &lt;code&gt;right&lt;/code&gt; 都不为空时，当前节点一定处于两个目标的&quot;分界点&quot;&lt;/li&gt;
&lt;li&gt;体现了&quot;自底向上&quot;的思维：先找到叶子级别的目标，再逐层向上汇报结果&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;📝 LeetCode 235: 二叉搜索树的最近公共祖先&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;235&quot; title=&quot;Lowest Common Ancestor of a Binary Search Tree&quot; zh=&quot;二叉搜索树的最近公共祖先&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;适用场景&lt;/strong&gt;：二叉搜索树 (BST)，具备“左子树所有节点 &amp;lt; 根节点 &amp;lt; 右子树所有节点”的严格规律。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心思路&lt;/strong&gt;：&lt;strong&gt;迭代法 + 分界点逻辑&lt;/strong&gt;。利用 BST 的性质，从根节点自顶向下寻找，&lt;strong&gt;第一个值介于 &lt;code&gt;p&lt;/code&gt; 和 &lt;code&gt;q&lt;/code&gt; 之间的节点&lt;/strong&gt;，必定就是最近公共祖先。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 $O(H)$（$H$ 为树高，最坏 $O(N)$），空间 $O(1)$（仅使用常数级指针）。&lt;/p&gt;
&lt;h3&gt;🔍 代码分步详解&lt;/h3&gt;
&lt;h4&gt;代码块 1️⃣：预处理大小关系&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;if p.val &amp;gt; q.val:
    p, q = q, p
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;交换 &lt;code&gt;p&lt;/code&gt; 和 &lt;code&gt;q&lt;/code&gt; 的值，确保 &lt;code&gt;p.val &amp;lt;= q.val&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;p, q = q, p&lt;/code&gt; 是 Python 的&lt;strong&gt;元组拆包赋值&lt;/strong&gt;，一行代码同时交换两个变量&lt;/li&gt;
&lt;li&gt;目的是简化后续的区间判断逻辑，无需反复比较&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;代码块 2️⃣：循环条件&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;while root:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只要 &lt;code&gt;root&lt;/code&gt; 不为空，就继续查找&lt;/li&gt;
&lt;li&gt;由于是 BST，每次都能向左或向右移动，最终必定找到答案或到达 &lt;code&gt;None&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;代码块 3️⃣：三分支判断（利用 BST 性质）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;if root.val &amp;gt; q.val:           # 目标都在左侧
    root = root.left
elif root.val &amp;lt; p.val:         # 目标都在右侧
    root = root.right
else:                           # 找到分界点
    return root
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;三个分支详解&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;条件&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;动作&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;root.val &amp;gt; q.val&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;当前节点值 &lt;strong&gt;大于&lt;/strong&gt; 最大目标值&lt;/td&gt;
&lt;td&gt;目标都在左子树，&lt;code&gt;root = root.left&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;root.val &amp;lt; p.val&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;当前节点值 &lt;strong&gt;小于&lt;/strong&gt; 最小目标值&lt;/td&gt;
&lt;td&gt;目标都在右子树，&lt;code&gt;root = root.right&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;其他（即 &lt;code&gt;p.val &amp;lt;= root.val &amp;lt;= q.val&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;当前节点值在 &lt;code&gt;[p.val, q.val]&lt;/code&gt; 区间内&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;找到分界点&lt;/strong&gt;，直接返回 &lt;code&gt;root&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;为什么第三种情况必定是 LCA&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;p&lt;/code&gt; 和 &lt;code&gt;q&lt;/code&gt; 发生了”分流”（一个在左一个在右），或其中之一就是当前节点&lt;/li&gt;
&lt;li&gt;BST 的分界点天然就是 LCA&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;💻 完整代码模板&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def lowestCommonAncestor(self, root: &apos;TreeNode&apos;, p: &apos;TreeNode&apos;, q: &apos;TreeNode&apos;) -&amp;gt; &apos;TreeNode&apos;:
        # 1. 确保 p 的值小于 q 的值，简化后续的区间判断
        if p.val &amp;gt; q.val:
            p, q = q, p
            
        # 2. 从根节点开始迭代遍历
        while root:
            # 3. 根据 BST 性质进行分支判断
            if root.val &amp;gt; q.val:      # 目标都在左侧
                root = root.left
            elif root.val &amp;lt; p.val:    # 目标都在右侧
                root = root.right
            else:                      # 当前节点就是分界点(LCA)
                return root
                
        return None
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;💡 与普通二叉树的对比&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题目&lt;/th&gt;
&lt;th&gt;方法&lt;/th&gt;
&lt;th&gt;时间复杂度&lt;/th&gt;
&lt;th&gt;空间复杂度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LeetCode 236（普通二叉树）&lt;/td&gt;
&lt;td&gt;递归（后序遍历）&lt;/td&gt;
&lt;td&gt;$O(N)$&lt;/td&gt;
&lt;td&gt;$O(H)$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LeetCode 235（BST）&lt;/td&gt;
&lt;td&gt;迭代（利用 BST 性质）&lt;/td&gt;
&lt;td&gt;$O(H)$&lt;/td&gt;
&lt;td&gt;$O(1)$&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;关键区别&lt;/strong&gt;：BST 能在 $O(H)$ 时间找到 LCA，因为可以直接排除不相关的子树&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;全排列——插入法笔记&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;46&quot; title=&quot;Permutations&quot; zh=&quot;全排列&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;一、核心思路&lt;/h2&gt;
&lt;p&gt;不直接枚举，而是把问题拆小：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;求 &lt;code&gt;[1,2,3]&lt;/code&gt; 的全排列 = 先求 &lt;code&gt;[2,3]&lt;/code&gt; 的全排列，再把 &lt;code&gt;1&lt;/code&gt; 插入每个结果的每个位置。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;每一层递归只做两件事：&lt;strong&gt;① 把第一个元素搁置，② 把它插回去&lt;/strong&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;二、完整代码&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def permute(self, nums):
    if len(nums) == 0:          # 递归终止：空数组只有一个&quot;排列&quot;——[]
        return [[]]
	
	# 我们每次递归都把nums数组的第一位丢掉 直到剩下[[]]
    perms = self.permute(nums[1:])   # 递归：先算去掉第一个元素的全排列
    res = []
	
	# 写的时候在脑子想象现在的perms已经是[[2,3],[3,2]]了
    for p in perms:                      
        for i in range(len(p) + 1):      # 遍历每个插入位置（共 len+1 个）
            p_copy = p.copy()            # 复制！不能改原来的 p
            p_copy.insert(i, nums[0])    # 把第一个元素(也就是1)插到不同的位置上
            res.append(p_copy)           # 收集结果

    return res
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;三、语法要点&lt;/h2&gt;
&lt;h3&gt;① 列表切片 &lt;code&gt;nums[1:]&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nums = [1, 2, 3]
nums[1:]   # → [2, 3]   去掉第一个，剩余所有
nums[:2]   # → [1, 2]   前两个
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;② &lt;code&gt;for x in 列表&lt;/code&gt;——遍历&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;perms = [[2,3], [3,2]]
for p in perms:
    print(p)
# 第1次：p = [2, 3]
# 第2次：p = [3, 2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;p&lt;/code&gt; 只是临时变量名，叫什么都行，每次循环自动装入下一个元素。&lt;/p&gt;
&lt;h3&gt;③ &lt;code&gt;range(len(p) + 1)&lt;/code&gt;——插入位置&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;p = [2, 3]          # 长度为 2
range(len(p) + 1)   # → 0, 1, 2   共 3 个位置 所以要len(p)+1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个长度为 n 的列表，有 n+1 个可以插入的位置（前、中间各处、后）。&lt;/p&gt;
&lt;h3&gt;④ 为什么要 &lt;code&gt;p.copy()&lt;/code&gt;？&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 不 copy 的错误情况：
p = [2, 3]
p.insert(0, 1)   # p → [1, 2, 3]  ← p 被改了！
p.insert(1, 1)   # 在 [1,2,3] 上插 → [1,1,2,3]  ← 错误

# 正确做法：每次从干净的 p 复制
p = [2, 3]
p_copy = p.copy(); p_copy.insert(0, 1)  # p_copy=[1,2,3]，p 还是 [2,3]
p_copy = p.copy(); p_copy.insert(1, 1)  # p_copy=[2,1,3]，p 还是 [2,3]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;⑤ &lt;code&gt;insert&lt;/code&gt; 和 &lt;code&gt;append&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;p = [2, 3]
p.insert(0, 1)   # → [1, 2, 3]   插到位置 0
p.insert(1, 1)   # → [2, 1, 3]   插到位置 1
p.insert(2, 1)   # → [2, 3, 1]   插到位置 2（末尾）

res = []
res.append([1,2,3])   # res → [[1,2,3]]
res.append([2,1,3])   # res → [[1,2,3],[2,1,3]]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;四、执行过程追踪&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;nums&lt;/th&gt;
&lt;th&gt;perms（上层返回值）&lt;/th&gt;
&lt;th&gt;本层 return&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;最深&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[[]]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;第3层&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[3]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[[]]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[[3]]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;第2层&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[2,3]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[[3]]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[[2,3],[3,2]]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;第1层&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[1,2,3]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[[2,3],[3,2]]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6个排列&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;每深一层，&lt;code&gt;perms&lt;/code&gt; 的排列数就翻倍——因为多了一个可以插入的位置。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;五、关键心法：信任递归&lt;/h2&gt;
&lt;p&gt;写递归时，&lt;strong&gt;不要在脑子里追踪所有层&lt;/strong&gt;。只需假设&quot;递归已经帮我算好了子问题&quot;，然后思考：拿到这个结果，我该怎么处理它？&lt;/p&gt;
&lt;p&gt;具体到这题：脑子里想象 &lt;code&gt;perms = [[2,3],[3,2]]&lt;/code&gt; 已经有了，剩下只需想&quot;怎么把 &lt;code&gt;1&lt;/code&gt; 插进去&quot;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;六、复杂度&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;复杂度&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;时间&lt;/td&gt;
&lt;td&gt;O(n × n!)&lt;/td&gt;
&lt;td&gt;共 n! 个排列，每个需 O(n) 时间 copy + insert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;空间&lt;/td&gt;
&lt;td&gt;O(n²)&lt;/td&gt;
&lt;td&gt;递归栈深度 n，每层有中间列表&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;七、同类型题目&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;leetcode 47 · 全排列 II（有重复元素）&lt;/li&gt;
&lt;li&gt;leetcode 78 · 子集&lt;/li&gt;
&lt;li&gt;leetcode 77 · 组合&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;套路一样：递归缩小问题 → 在子问题结果上做操作 → 收集结果。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;两数之和笔记&lt;/h2&gt;
&lt;h2&gt;leetcode 1 · 两数之和（无序数组）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;1&quot; title=&quot;Two Sum&quot; zh=&quot;两数之和&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;对于每个数字 &lt;code&gt;n&lt;/code&gt;，它需要的&quot;搭档&quot;是 &lt;code&gt;target - n&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;与其两层循环暴力找，不如&lt;strong&gt;边走边记录&lt;/strong&gt;——用一个字典存已经见过的数字和它的下标，每次只需检查搭档是否已经出现过。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;字典存的是 &lt;code&gt;{数字: 下标}&lt;/code&gt;，这样可以快速查&quot;某个数字存不存在&quot;，找到了还能直接拿到它的下标。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;关键细节：先查再存，不能先存再查。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;比如 nums=[3,3], target=6
先存再查：第一次循环，3 存进去，然后查 diff=3，找到自己，返回 [0,0] ← 错误
先查再存：第一次循环，查 diff=3，表里还没有，存入 {3:0}
          第二次循环，查 diff=3，找到下标0，返回 [0,1] ← 正确
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def twoSum(self, nums: List[int], target: int) -&amp;gt; List[int]:
    # 创建哈希表，存 {数字: 下标}，方便查搭档时直接拿到下标
    seen = {}

    # enumerate 同时拿到下标和值，index 在前，number 在后
    for index, number in enumerate(nums):
        # 计算当前数字需要的搭档
        diff = target - number

        # 如果搭档已经在表里，直接返回两个下标
        if diff in seen:
            return [seen[diff], index]

        # 先查后存：避免同一个元素用两次
        seen[number] = index
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;复杂度&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;复杂度&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;时间&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;只遍历一次数组，字典查找是 O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;空间&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;字典最多存 n 个元素&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;leetcode 167 · 两数之和 II（有序数组）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;167&quot; title=&quot;Two Sum II - Input Array Is Sorted&quot; zh=&quot;两数之和 II - 输入有序数组&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;数组有序这个条件让&lt;strong&gt;双指针&lt;/strong&gt;成为可能。&lt;/p&gt;
&lt;p&gt;左指针从最小的数开始，右指针从最大的数开始，两数之和：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;大于 target&lt;/strong&gt;：右指针左移，让和变小&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;小于 target&lt;/strong&gt;：左指针右移，让和变大&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;等于 target&lt;/strong&gt;：找到答案&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为数组有序，每次移动指针都能排除掉一批不可能的组合，所以不需要额外空间。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def twoSum(self, numbers: List[int], target: int) -&amp;gt; List[int]:
    # 初始化双指针，左指针指向最小值，右指针指向最大值
    left = 0
    right = len(numbers) - 1

    # left &amp;lt; right：不能用 &amp;lt;=，因为两个元素不能是同一个
    while left &amp;lt; right:
        s = numbers[left] + numbers[right]

        if s &amp;gt; target:
            right -= 1   # 和太大，右指针左移，让和变小
        elif s &amp;lt; target:
            left += 1    # 和太小，左指针右移，让和变大
        else:
            # 题目下标从 1 开始，所以返回时 +1
            return [left + 1, right + 1]

    # 题目保证有解，这行可写可不写
    return []
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;复杂度&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;复杂度&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;时间&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;两个指针最多各走 n 步&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;空间&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;只用了两个指针变量，无额外空间&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;两题对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;两数之和 I&lt;/th&gt;
&lt;th&gt;两数之和 II&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;数组是否有序&lt;/td&gt;
&lt;td&gt;无序&lt;/td&gt;
&lt;td&gt;有序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;方法&lt;/td&gt;
&lt;td&gt;哈希表&lt;/td&gt;
&lt;td&gt;双指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;时间复杂度&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;空间复杂度&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;核心思路&lt;/td&gt;
&lt;td&gt;边走边查搭档&lt;/td&gt;
&lt;td&gt;两端夹逼&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;结论：&lt;/strong&gt; 数组有序时优先考虑双指针，空间更省。数组无序时用哈希表，排序再双指针反而更慢（O(n log n)）。&lt;/p&gt;
</content:encoded></item><item><title>4月2日刷题笔记——二叉树与堆</title><link>https://www.yyylegend.com/posts/4%E6%9C%882%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E4%BA%8C%E5%8F%89%E6%A0%91%E4%B8%8E%E5%A0%86/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/4%E6%9C%882%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E4%BA%8C%E5%8F%89%E6%A0%91%E4%B8%8E%E5%A0%86/</guid><description>二叉树层序遍历（BFS）、锯齿形层序、BST第K小元素、数组第K大元素题解，含递归调用栈分析与 heapq 用法</description><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;102&quot; title=&quot;Binary Tree Level Order Traversal&quot; zh=&quot;二叉树的层序遍历&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;103&quot; title=&quot;Binary Tree Zigzag Level Order Traversal&quot; zh=&quot;二叉树的锯齿形层序遍历&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;230&quot; title=&quot;Kth Smallest Element in a BST&quot; zh=&quot;二叉搜索树中第K小的元素&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;215&quot; title=&quot;Kth Largest Element in an Array&quot; zh=&quot;数组中的第K个最大元素&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树基础回顾&lt;/h1&gt;
&lt;h3&gt;中序遍历（左 → 根 → 右）&lt;/h3&gt;
&lt;p&gt;BST 的中序遍历天然输出升序结果，这是后两道题的核心性质。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    3
   / \
  1   4
   \
    2

中序遍历：1 → 2 → 3 → 4  （天然升序）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;中序遍历没有显式的&quot;遍历列表&quot;，顺序完全靠&lt;strong&gt;递归调用的位置&lt;/strong&gt;保证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(root):
    dfs(root.left)   # ① 先递归到最左
    # 处理当前节点   # ② 自己
    dfs(root.right)  # ③ 再去右边
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dfs(root.left)&lt;/code&gt; 不返回，当前节点就永远轮不到——这是一个&lt;strong&gt;逐层解包&lt;/strong&gt;的过程，必须把最内层的拆完才能处理外层的。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 102 · 二叉树的层序遍历&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;102&quot; title=&quot;Binary Tree Level Order Traversal&quot; zh=&quot;二叉树的层序遍历&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;用队列做 BFS，每次处理完整的一层再进入下一层。关键在于用 &lt;code&gt;range(len(deque))&lt;/code&gt; 固定当前层的节点数，这样内层 for 循环结束后正好处理完一整层。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def levelOrder(self, root: Optional[TreeNode]) -&amp;gt; List[List[int]]:
        if not root: return []
        res, deque = [], collections.deque([root])
        while deque:
            tmp = []
            for _ in range(len(deque)):   # 固定当前层节点数
                node = deque.popleft()
                tmp.append(node.val)
                if node.left: deque.append(node.left)
                if node.right: deque.append(node.right)
            res.append(tmp)
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;执行过程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;初始：deque = [3]

第1层：range(1) → 处理 3，deque 变成 [1, 4]，tmp = [3]
第2层：range(2) → 处理 1、4，deque 变成 [2]，tmp = [1, 4]
第3层：range(1) → 处理 2，deque 变成 []，tmp = [2]

结果：[[3], [1, 4], [2]]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 103 · 二叉树的锯齿形层序遍历&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;103&quot; title=&quot;Binary Tree Zigzag Level Order Traversal&quot; zh=&quot;二叉树的锯齿形层序遍历&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;在 102 的基础上，奇偶层输出方向不同。核心技巧：不改变 BFS 的遍历顺序，只改变每个值&lt;strong&gt;插入 tmp 的位置&lt;/strong&gt;——偶数层追加到尾部，奇数层插入到头部，天然反转。&lt;/p&gt;
&lt;h3&gt;奇偶层判断&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;if len(res) % 2 == 0:
    tmp.append(node.val)      # 第0、2、4...层：左→右
else:
    tmp.appendleft(node.val)  # 第1、3、5...层：右→左（头部插入=反转）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;判断发生在 &lt;code&gt;res.append(tmp)&lt;/code&gt; 之前，所以 &lt;code&gt;len(res)&lt;/code&gt; 恰好等于当前层的索引。&lt;/p&gt;
&lt;h3&gt;为什么 appendleft 能反转？&lt;/h3&gt;
&lt;p&gt;BFS 永远从左到右遍历子节点，对于需要&quot;右→左&quot;输出的层，把每个值插到 tmp 头部，后来的反而排前面，自动完成反转：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;遍历顺序：  A → B → C
appendleft：C  B  A   ← 天然反转，O(1) 头插代替 reverse
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def zigzagLevelOrder(self, root: Optional[TreeNode]) -&amp;gt; List[List[int]]:
        if not root: return []
        res, deque = [], collections.deque([root])
        while deque:
            tmp = collections.deque()
            for _ in range(len(deque)):
                node = deque.popleft()
                if len(res) % 2 == 0: tmp.append(node.val)
                else: tmp.appendleft(node.val)
                if node.left: deque.append(node.left)
                if node.right: deque.append(node.right)
            res.append(list(tmp))
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 230 · 二叉搜索树中第K小的元素&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;230&quot; title=&quot;Kth Smallest Element in a BST&quot; zh=&quot;二叉搜索树中第K小的元素&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;BST 中序遍历 = 升序输出，第 K 次访问的节点就是第 K 小的元素。不需要把所有值存起来再排序，用一个倒计数器，减到 0 的那一刻就是答案。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def kthSmallest(self, root: Optional[TreeNode], k: int) -&amp;gt; int:
        def dfs(root):
            if not root: return
            dfs(root.left)
            if k == 0: return      # 已找到答案，后续节点全部跳过
            nonlocal k, res
            k -= 1
            if k == 0: res = root.val
            dfs(root.right)

        res = 0
        dfs(root)
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;① &lt;code&gt;nonlocal k, res&lt;/code&gt; 是什么？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;内层函数可以读外层变量，但不能直接修改。&lt;code&gt;nonlocal&lt;/code&gt; 显式声明&quot;我要修改外层的 k 和 res&quot;，否则 &lt;code&gt;k -= 1&lt;/code&gt; 会报 &lt;code&gt;UnboundLocalError&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;另一种写法是用 &lt;code&gt;self.k&lt;/code&gt;、&lt;code&gt;self.res&lt;/code&gt; 挂在实例上，效果相同，只是绕开了闭包限制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 用 self 的版本
self.k = k
def dfs(root):
    self.k -= 1   # self.k 不受闭包限制，可以直接改
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;② &lt;code&gt;if k == 0: return&lt;/code&gt; 为什么写在 &lt;code&gt;k -= 1&lt;/code&gt; 前面？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这行拦住的是&quot;上一轮已经找到答案&quot;的情况，防止当前节点多执行一次 &lt;code&gt;k -= 1&lt;/code&gt; 导致 k 变成 -1。顺序至关重要：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dfs(root.left)         # 左子树可能已经找到答案，k 已经是 0 了
if k == 0: return      # ← 先检查，如果是 0 就不处理当前节点了
k -= 1                 # 消费当前节点
if k == 0: res = root.val
dfs(root.right)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;③ &lt;code&gt;root&lt;/code&gt; 不是指根节点&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;递归过程中 &lt;code&gt;root&lt;/code&gt; 这个名字有点误导——只有第一次调用时它才是真正的根节点，之后每层递归里的 &lt;code&gt;root&lt;/code&gt; 都是&quot;当前正在访问的节点&quot;，叫 &lt;code&gt;node&lt;/code&gt; 或 &lt;code&gt;curr&lt;/code&gt; 更准确，但习惯上大家复用这个名字。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;④ &lt;code&gt;if k == 0: res = root.val&lt;/code&gt; 为什么在 &lt;code&gt;k -= 1&lt;/code&gt; 之后？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;因为 &lt;code&gt;k -= 1&lt;/code&gt; 就在上一行，减完之后等于 0，说明已经访问了恰好 k 个节点，当前节点就是第 k 小的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;self.k = 1
self.k -= 1  →  self.k = 0    ← 说明已访问了 k 个节点
self.k == 0  →  res = root.val ← 当前节点就是答案
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;执行过程（k=3）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;    3
   / \
  1   4
   \
    2

dfs(3)
  └─ dfs(1)
       └─ dfs(None) → 返回
       k: 3→2，访问 1
       └─ dfs(2)
            └─ dfs(None) → 返回
            k: 2→1，访问 2
            └─ dfs(None) → 返回
  k: 1→0，访问 3  ← res = 3，找到答案！
  └─ dfs(4)
       if k==0: return  ← 直接跳过，不再处理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;中序遍历是一个&lt;strong&gt;逐层解包&lt;/strong&gt;的过程：&lt;code&gt;dfs(root.left)&lt;/code&gt; 不返回，当前节点就永远轮不到，保证了访问顺序天然是升序。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心思路&lt;/strong&gt;：BST 中序遍历 = 升序，&lt;code&gt;nonlocal&lt;/code&gt; 计数器倒计到 0&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(H + k)，H 为树高，空间 O(H)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 215 · 数组中的第K个最大元素&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;215&quot; title=&quot;Kth Largest Element in an Array&quot; zh=&quot;数组中的第K个最大元素&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路一：排序（简洁但非最优）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def findKthLargest(self, nums: List[int], k: int) -&amp;gt; int:
        return sorted(nums)[len(nums) - k]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sorted&lt;/code&gt; 从小到大，倒数第 k 个就是第 k 大：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nums = [3,2,1,5,6,4]，k = 2
sorted → [1, 2, 3, 4, 5, 6]
len - k = 6 - 2 = 4
[4]   → 5  ← 第2大
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;时间 O(n log n)，面试中一般需要更优解。&lt;/p&gt;
&lt;h3&gt;思路二：最小堆（推荐）&lt;/h3&gt;
&lt;p&gt;维护一个大小为 k 的最小堆，堆里始终保留当前最大的 k 个数，堆顶就是第 k 大的元素。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def findKthLargest(self, nums: List[int], k: int) -&amp;gt; int:
        heap = []
        for num in nums:
            heapq.heappush(heap, num)
            if len(heap) &amp;gt; k:
                heapq.heappop(heap)   # 弹出最小值，保留最大的 k 个
        return heap[0]                # 堆顶 = 第 k 大
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;heapq 三个核心函数&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;函数&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;时间复杂度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heapify(h)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;把普通列表原地变成最小堆&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heappush(h, val)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;插入一个元素&lt;/td&gt;
&lt;td&gt;O(log k)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heappop(h)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;取出并返回最小值&lt;/td&gt;
&lt;td&gt;O(log k)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Python 的堆是&lt;strong&gt;最小堆&lt;/strong&gt;，&lt;code&gt;h[0]&lt;/code&gt; 可以直接查看堆顶（最小值）而不弹出。&lt;/p&gt;
&lt;h3&gt;执行过程（k=2）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nums = [3,2,1,5,6,4]

push 3 → [3]
push 2 → [2, 3]          len==k，不弹出
push 1 → [1, 2, 3] → 弹出1 → [2, 3]
push 5 → [2, 3, 5] → 弹出2 → [3, 5]
push 6 → [3, 5, 6] → 弹出3 → [5, 6]
push 4 → [4, 5, 6] → 弹出4 → [5, 6]

heap[0] = 5  ← 第2大的元素 ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;堆始终只保留 k 个元素，堆顶（最小的那个）就是&quot;最大的 k 个里最小的&quot;，即第 k 大。&lt;/p&gt;
&lt;h3&gt;两种方法对比&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;排序&lt;/th&gt;
&lt;th&gt;最小堆&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;时间复杂度&lt;/td&gt;
&lt;td&gt;O(n log n)&lt;/td&gt;
&lt;td&gt;O(n log k)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;空间复杂度&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;O(k)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;适用场景&lt;/td&gt;
&lt;td&gt;快速实现&lt;/td&gt;
&lt;td&gt;面试首选，k 远小于 n 时更优&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心思路&lt;/strong&gt;：大小为 k 的最小堆，堆顶即答案&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n log k)，空间 O(k)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;四题横向对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题目&lt;/th&gt;
&lt;th&gt;核心数据结构&lt;/th&gt;
&lt;th&gt;关键技巧&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;102 层序遍历&lt;/td&gt;
&lt;td&gt;队列（BFS）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;range(len(deque))&lt;/code&gt; 固定每层节点数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;103 锯齿形层序&lt;/td&gt;
&lt;td&gt;双端队列&lt;/td&gt;
&lt;td&gt;&lt;code&gt;appendleft&lt;/code&gt; 代替 reverse，O(1) 头插&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;230 第K小元素&lt;/td&gt;
&lt;td&gt;递归调用栈&lt;/td&gt;
&lt;td&gt;BST 中序 = 升序，nonlocal 倒计数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;215 第K大元素&lt;/td&gt;
&lt;td&gt;最小堆&lt;/td&gt;
&lt;td&gt;维护大小为 k 的堆，堆顶即答案&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;:::important
&lt;strong&gt;中序遍历的本质&lt;/strong&gt;
没有显式的&quot;遍历列表&quot;，顺序靠递归结构保证：&lt;code&gt;dfs(root.left)&lt;/code&gt; 写在前面，意味着必须把最左边的全部处理完才能回头处理自己。这是一个逐层解包的过程，调用栈天然维护了&quot;左 → 根 → 右&quot;的顺序。
:::&lt;/p&gt;
</content:encoded></item><item><title>4月1日学习笔记--LangChain 官方课程 Module 1</title><link>https://www.yyylegend.com/posts/4%E6%9C%881%E6%97%A5%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0--langchain%E5%AE%98%E6%96%B9%E8%AF%BE%E7%A8%8Bmodule1/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/4%E6%9C%881%E6%97%A5%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0--langchain%E5%AE%98%E6%96%B9%E8%AF%BE%E7%A8%8Bmodule1/</guid><description>LangChain 官方课程 Module 1 笔记，覆盖模型初始化、Prompt 设计、工具、网络搜索、记忆与多模态输入</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;目录&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#1-%E6%A8%A1%E5%9E%8B%E5%88%9D%E5%A7%8B%E5%8C%96%E4%B8%8E%E8%B0%83%E7%94%A8&quot;&gt;模型初始化与调用&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-prompt-%E6%8F%90%E7%A4%BA%E8%AF%8D%E8%AE%BE%E8%AE%A1&quot;&gt;Prompt 提示词设计&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-%E5%B7%A5%E5%85%B7-tools&quot;&gt;工具 Tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-%E7%BD%91%E7%BB%9C%E6%90%9C%E7%B4%A2%E5%B7%A5%E5%85%B7&quot;&gt;网络搜索工具&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-%E8%AE%B0%E5%BF%86-memory&quot;&gt;记忆 Memory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-%E5%A4%9A%E6%A8%A1%E6%80%81%E8%BE%93%E5%85%A5&quot;&gt;多模态输入&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-%E5%B8%B8%E7%94%A8%E5%87%BD%E6%95%B0%E9%80%9F%E6%9F%A5%E8%A1%A8&quot;&gt;常用函数速查表&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-%E5%AE%8C%E6%95%B4%E6%B5%81%E7%A8%8B%E6%A8%A1%E6%9D%BF&quot;&gt;完整流程模板&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 模型初始化与调用&lt;/h2&gt;
&lt;h3&gt;概念&lt;/h3&gt;
&lt;p&gt;LangChain 提供了一个统一的接口 &lt;code&gt;init_chat_model&lt;/code&gt;，可以用同一套代码接入不同的模型提供商（OpenAI、Anthropic、Google 等）。初始化完模型之后，可以直接用 &lt;code&gt;.invoke()&lt;/code&gt; 发消息。&lt;/p&gt;
&lt;p&gt;Agent 是在模型之上封装了一层&quot;行动能力&quot;——它不只是回答问题，还可以决定要不要调用工具、怎么调用。&lt;/p&gt;
&lt;h3&gt;初始化模型&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.chat_models import init_chat_model

# 基础初始化
model = init_chat_model(model=&quot;gpt-4o-mini&quot;)

# 调整参数（temperature 越高，回答越有创意/随机）
model = init_chat_model(
    model=&quot;gpt-4o-mini&quot;,
    temperature=0.7
)

# 切换到其他提供商
model = init_chat_model(model=&quot;claude-sonnet-4-5&quot;)       # Anthropic
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;直接调用模型&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;response = model.invoke(&quot;月球的首都是哪里？&quot;)
print(response.content)          # 打印回答文字
print(response.response_metadata) # 打印 token 用量等元信息
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;创建 Agent 并调用&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.messages import HumanMessage

agent = create_agent(model=model)

response = agent.invoke(
    {&quot;messages&quot;: [HumanMessage(content=&quot;月球的首都是哪里？&quot;)]}
)

print(response[&apos;messages&apos;][-1].content)  # 取最后一条消息（AI 的回答）
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;model vs agent 的区别&lt;/strong&gt;：&lt;code&gt;model.invoke()&lt;/code&gt; 直接返回一个 AIMessage 对象；&lt;code&gt;agent.invoke()&lt;/code&gt; 返回一个包含完整消息列表的字典，用 &lt;code&gt;response[&apos;messages&apos;][-1].content&lt;/code&gt; 取最终答案。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;多轮对话（手动传历史）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import HumanMessage, AIMessage

response = agent.invoke({
    &quot;messages&quot;: [
        HumanMessage(content=&quot;月球的首都叫 Luna City&quot;),
        AIMessage(content=&quot;好的，已记录。&quot;),
        HumanMessage(content=&quot;告诉我更多关于 Luna City 的事&quot;)
    ]
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;流式输出&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;for token, metadata in agent.stream(
    {&quot;messages&quot;: [HumanMessage(content=&quot;给我介绍一下 Luna City&quot;)]},
    stream_mode=&quot;messages&quot;
):
    if token.content:
        print(token.content, end=&quot;&quot;, flush=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Prompt 提示词设计&lt;/h2&gt;
&lt;h3&gt;概念&lt;/h3&gt;
&lt;p&gt;Prompt 是你给模型的&quot;指令&quot;，质量直接影响输出结果。常见的几种设计方式：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方式&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;基础提问&lt;/td&gt;
&lt;td&gt;简单问答&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System Prompt&lt;/td&gt;
&lt;td&gt;设定 AI 的角色和行为边界&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Few-shot 示例&lt;/td&gt;
&lt;td&gt;通过例子教模型输出特定格式&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;结构化 Prompt&lt;/td&gt;
&lt;td&gt;规定输出字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;结构化输出&lt;/td&gt;
&lt;td&gt;用 Pydantic 强制返回结构化数据&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;System Prompt（设定角色）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;agent = create_agent(
    model=&quot;gpt-4o-mini&quot;,
    system_prompt=&quot;你是一位科幻小说作家，根据用户的要求创造一座太空首都城市。&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Few-shot 示例（给例子）&lt;/h3&gt;
&lt;p&gt;在 system prompt 里直接写几个例子，模型会模仿这种风格回答：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;system_prompt = &quot;&quot;&quot;
你是一位科幻小说作家，根据用户的要求创造一座太空首都城市。

User: 火星的首都是什么？
Scifi Writer: Marsialis

User: 金星的首都是什么？
Scifi Writer: Venusovia
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;结构化 Prompt（规定输出格式）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;system_prompt = &quot;&quot;&quot;
你是一位科幻小说作家，根据用户的要求创造一座太空首都城市。

请按以下格式回答：
名称：城市名称
位置：所在星球或位置
氛围：2-3个词描述
经济：主要产业
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;结构化输出（Pydantic）&lt;/h3&gt;
&lt;p&gt;当你需要在代码里直接使用 AI 的输出时，用 Pydantic 定义数据结构，让模型强制返回 JSON 格式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel
from langchain.agents import create_agent
from langchain.messages import HumanMessage

class CapitalInfo(BaseModel):
    name: str
    location: str
    vibe: str
    economy: str

agent = create_agent(
    model=&apos;gpt-4o-mini&apos;,
    system_prompt=&quot;你是一位科幻小说作家，根据用户的要求创造一座首都城市。&quot;,
    response_format=CapitalInfo
)

response = agent.invoke({&quot;messages&quot;: [HumanMessage(content=&quot;月球的首都是什么？&quot;)]})

# 访问结构化数据
capital = response[&quot;structured_response&quot;]
print(capital.name)
print(f&quot;{capital.name} 位于 {capital.location}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;response_format=CapitalInfo&lt;/code&gt; 告诉模型按这个结构返回数据，之后通过 &lt;code&gt;response[&quot;structured_response&quot;]&lt;/code&gt; 拿到的就是一个 &lt;code&gt;CapitalInfo&lt;/code&gt; 对象，可以直接用 &lt;code&gt;.name&lt;/code&gt;、&lt;code&gt;.location&lt;/code&gt; 等属性访问。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 工具 Tools&lt;/h2&gt;
&lt;h3&gt;概念&lt;/h3&gt;
&lt;p&gt;Tool（工具）是你给 Agent 额外的&quot;能力&quot;。比如计算器、搜索引擎、数据库查询等。Agent 会在需要时自主决定是否调用工具，而不是每次都调用。&lt;/p&gt;
&lt;h3&gt;定义一个工具&lt;/h3&gt;
&lt;p&gt;用 &lt;code&gt;@tool&lt;/code&gt; 装饰器把普通函数变成 LangChain 工具。&lt;strong&gt;docstring 很重要&lt;/strong&gt;，模型靠它理解这个工具的用途。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool

@tool
def square_root(x: float) -&amp;gt; float:
    &quot;&quot;&quot;计算一个数的平方根&quot;&quot;&quot;
    return x ** 0.5

# 也可以自定义工具名称和描述
@tool(&quot;square_root&quot;, description=&quot;计算一个数的平方根&quot;)
def my_func(x: float) -&amp;gt; float:
    return x ** 0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;手动测试工具&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;square_root.invoke({&quot;x&quot;: 467})  # 返回 21.61
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;把工具给 Agent 使用&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;agent = create_agent(
    model=model,
    tools=[square_root],
    system_prompt=&quot;你是一个数学助手，用工具来计算结果。&quot;
)

response = agent.invoke(
    {&quot;messages&quot;: [HumanMessage(content=&quot;467 的平方根是多少？&quot;)]}
)
print(response[&apos;messages&apos;][-1].content)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Agent 调用工具的完整消息流&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;HumanMessage  →  &quot;467 的平方根是多少？&quot;
AIMessage     →  (空内容，但包含 tool_calls 字段，表示要调用工具)
ToolMessage   →  &quot;21.61018...&quot;（工具返回的结果）
AIMessage     →  &quot;467 的平方根约为 21.61&quot;（最终回答）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以这样查看中间调用过程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;print(response[&quot;messages&quot;][1].tool_calls)
# [{&apos;name&apos;: &apos;square_root&apos;, &apos;args&apos;: {&apos;x&apos;: 467}, ...}]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 网络搜索工具&lt;/h2&gt;
&lt;h3&gt;概念&lt;/h3&gt;
&lt;p&gt;大模型的知识有截止日期，对于实时信息（比如今天的新闻、当前的市长）它一无所知。通过添加搜索工具，Agent 可以在需要时主动上网查询。&lt;/p&gt;
&lt;p&gt;这里用的是 &lt;a href=&quot;https://tavily.com&quot;&gt;Tavily&lt;/a&gt;，一个专为 AI Agent 设计的搜索 API。&lt;/p&gt;
&lt;h3&gt;定义网络搜索工具&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool
from tavily import TavilyClient

tavily_client = TavilyClient()  # 需要设置 TAVILY_API_KEY 环境变量

@tool
def web_search(query: str) -&amp;gt; dict:
    &quot;&quot;&quot;在网上搜索信息&quot;&quot;&quot;
    return tavily_client.search(query)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;单独测试搜索&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;result = web_search.invoke(&quot;旧金山现任市长是谁？&quot;)
# 返回包含 url、title、content 的搜索结果列表
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;加入 Agent&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;agent = create_agent(
    model=model,
    tools=[web_search]
)

response = agent.invoke(
    {&quot;messages&quot;: [HumanMessage(content=&quot;旧金山现任市长是谁？&quot;)]}
)
print(response[&apos;messages&apos;][-1].content)
# Agent 会先调用 web_search，再根据结果回答
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 记忆 Memory&lt;/h2&gt;
&lt;h3&gt;概念&lt;/h3&gt;
&lt;p&gt;默认情况下，Agent 每次 &lt;code&gt;invoke&lt;/code&gt; 都是全新开始，完全不记得之前说过什么。要实现多轮对话记忆，需要用到 &lt;strong&gt;checkpointer&lt;/strong&gt; + &lt;strong&gt;thread_id&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;checkpointer&lt;/strong&gt;：负责存储会话历史（&lt;code&gt;InMemorySaver&lt;/code&gt; 是存在内存里，程序退出就没了）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;thread_id&lt;/strong&gt;：会话 ID，同一个 ID 下的对话共享历史记录&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无记忆 vs 有记忆对比&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 无记忆（每次 invoke 都是新的）
agent = create_agent(model=model)

agent.invoke({&quot;messages&quot;: [HumanMessage(content=&quot;我叫 Runqi，最喜欢蓝色&quot;)]})
response = agent.invoke({&quot;messages&quot;: [HumanMessage(content=&quot;我最喜欢的颜色是什么？&quot;)]})
# 结果：不知道，因为上一轮消息没传进来
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# 有记忆
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    model=model,
    checkpointer=InMemorySaver()
)

config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;session_001&quot;}}

agent.invoke(
    {&quot;messages&quot;: [HumanMessage(content=&quot;我叫 Runqi，最喜欢蓝色&quot;)]},
    config
)

response = agent.invoke(
    {&quot;messages&quot;: [HumanMessage(content=&quot;我最喜欢的颜色是什么？&quot;)]},
    config  # 同一个 thread_id，自动带入历史
)
print(response[&apos;messages&apos;][-1].content)
# 结果：你之前提到你最喜欢的颜色是蓝色
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;多用户场景&lt;/h3&gt;
&lt;p&gt;不同的 &lt;code&gt;thread_id&lt;/code&gt; 代表不同的会话，互相隔离：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config_user1 = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;user_001&quot;}}
config_user2 = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;user_002&quot;}}

# user1 的对话不会影响 user2
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 多模态输入&lt;/h2&gt;
&lt;h3&gt;概念&lt;/h3&gt;
&lt;p&gt;多模态意味着 Agent 不只能处理文字，还能理解图片、音频等。关键是把非文字内容转成 base64 编码，再包装进 &lt;code&gt;HumanMessage&lt;/code&gt; 的 &lt;code&gt;content&lt;/code&gt; 列表里。&lt;/p&gt;
&lt;h3&gt;纯文字输入（显式格式）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;question = HumanMessage(content=[
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;月球的首都是什么？&quot;}
])
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;图片输入&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import base64

# 读取图片并转 base64
with open(&quot;image.png&quot;, &quot;rb&quot;) as f:
    img_b64 = base64.b64encode(f.read()).decode(&quot;utf-8&quot;)

question = HumanMessage(content=[
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;描述一下这张图片&quot;},
    {&quot;type&quot;: &quot;image&quot;, &quot;base64&quot;: img_b64, &quot;mime_type&quot;: &quot;image/png&quot;}
])

response = agent.invoke({&quot;messages&quot;: [question]})
print(response[&apos;messages&apos;][-1].content)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;音频输入&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import sounddevice as sd
from scipy.io.wavfile import write
import base64, io

# 录音
audio = sd.rec(int(5 * 44100), samplerate=44100, channels=1)
sd.wait()

# 转 base64
buf = io.BytesIO()
write(buf, 44100, audio)
aud_b64 = base64.b64encode(buf.getvalue()).decode(&quot;utf-8&quot;)

# 需要支持音频的模型（如 gpt-4o-audio-preview）
agent = create_agent(model=&apos;gpt-4o-audio-preview&apos;)

question = HumanMessage(content=[
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;描述一下这段音频&quot;},
    {&quot;type&quot;: &quot;audio&quot;, &quot;base64&quot;: aud_b64, &quot;mime_type&quot;: &quot;audio/wav&quot;}
])
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;7. 常用函数速查表&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;函数 / 类&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;来源模块&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;init_chat_model(model, **kwargs)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;初始化聊天模型&lt;/td&gt;
&lt;td&gt;&lt;code&gt;langchain.chat_models&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;create_agent(model, tools, system_prompt, ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;创建 Agent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;langchain.agents&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HumanMessage(content)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用户消息&lt;/td&gt;
&lt;td&gt;&lt;code&gt;langchain.messages&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AIMessage(content)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AI 消息&lt;/td&gt;
&lt;td&gt;&lt;code&gt;langchain.messages&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@tool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;将函数转为 LangChain 工具&lt;/td&gt;
&lt;td&gt;&lt;code&gt;langchain.tools&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tool.invoke({&quot;param&quot;: value})&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;直接调用工具（测试用）&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent.invoke({&quot;messages&quot;: [...]})&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;运行 Agent（单次）&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent.stream({&quot;messages&quot;: [...]}, stream_mode=&quot;messages&quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;流式运行 Agent&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InMemorySaver()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;内存中的对话历史存储器&lt;/td&gt;
&lt;td&gt;&lt;code&gt;langgraph.checkpoint.memory&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BaseModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;定义结构化输出的数据模型&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pydantic&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TavilyClient()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;网络搜索客户端&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tavily&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;8. 完整流程模板&lt;/h2&gt;
&lt;p&gt;下面是一个集成了&lt;strong&gt;工具 + 记忆 + 结构化输出&lt;/strong&gt;的完整 Agent 模板，你可以按需拆分或组合使用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dotenv import load_dotenv
load_dotenv()

# ── 1. 导入依赖 ────────────────────────────────────────────
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from pydantic import BaseModel
from tavily import TavilyClient

# ── 2. 定义工具 ────────────────────────────────────────────
tavily_client = TavilyClient()

@tool
def web_search(query: str) -&amp;gt; dict:
    &quot;&quot;&quot;在网上搜索最新信息&quot;&quot;&quot;
    return tavily_client.search(query)

@tool
def calculate(expression: str) -&amp;gt; float:
    &quot;&quot;&quot;计算数学表达式，例如 &apos;2 + 2&apos; 或 &apos;sqrt(16)&apos;&quot;&quot;&quot;
    import math
    return eval(expression, {&quot;__builtins__&quot;: {}}, vars(math))

# ── 3. 定义结构化输出（可选）─────────────────────────────────
class Answer(BaseModel):
    summary: str      # 简短总结
    confidence: str   # 高 / 中 / 低
    source: str       # 信息来源

# ── 4. 初始化模型 ──────────────────────────────────────────
model = init_chat_model(
    model=&quot;gpt-4o-mini&quot;,
    temperature=0.3   # 低一点更稳定
)

# ── 5. 创建 Agent ──────────────────────────────────────────
agent = create_agent(
    model=model,
    tools=[web_search, calculate],
    system_prompt=&quot;你是一个智能助手，在需要时使用工具获取最新信息或进行计算。&quot;,
    checkpointer=InMemorySaver(),   # 开启记忆
    # response_format=Answer        # 需要结构化输出时取消注释
)

# ── 6. 设定会话 ID ─────────────────────────────────────────
config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;my_session&quot;}}

# ── 7. 多轮对话 ────────────────────────────────────────────
def chat(user_input: str) -&amp;gt; str:
    response = agent.invoke(
        {&quot;messages&quot;: [HumanMessage(content=user_input)]},
        config
    )
    return response[&apos;messages&apos;][-1].content

# 示例对话
print(chat(&quot;你好，我叫小明&quot;))
print(chat(&quot;北京今天的天气怎么样？&quot;))   # 会调用 web_search
print(chat(&quot;我叫什么名字？&quot;))           # 从记忆中获取
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;流程图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;用户输入
   │
   ▼
HumanMessage
   │
   ▼
Agent（内置 LLM）
   │
   ├─ 需要工具？─── 是 ──→ 调用 Tool ──→ ToolMessage ──→ 返回 Agent
   │                                                          │
   └─ 不需要 / 已有结果 ────────────────────────────────────→ AIMessage（最终回答）
   │
   ▼
checkpointer 保存消息历史（按 thread_id 区分）
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;下一步&lt;/strong&gt;：1.5 笔记本是一个综合实战项目（Personal Chef Agent），可以把上面学到的所有模块组合起来实现。建议自己动手实现一遍，加深理解。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>3月31日刷题笔记--最近公共祖先（LCA）</title><link>https://www.yyylegend.com/posts/3%E6%9C%8831%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E4%BA%8C%E5%8F%89%E6%A0%91%E6%9C%80%E4%BD%8E%E5%85%AC%E5%85%B1%E7%A5%96%E5%85%88/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8831%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E4%BA%8C%E5%8F%89%E6%A0%91%E6%9C%80%E4%BD%8E%E5%85%AC%E5%85%B1%E7%A5%96%E5%85%88/</guid><description>二叉树经典递归问题，LeetCode 236 二叉树的最近公共祖先 题解</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;236&quot; title=&quot;Lowest Common Ancestor of a Binary Tree&quot; zh=&quot;二叉树的最近公共祖先&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 236 · 二叉树的最近公共祖先&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;236&quot; title=&quot;Lowest Common Ancestor of a Binary Tree&quot; zh=&quot;二叉树的最近公共祖先&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;核心思想：递归遍历整棵树，用&lt;strong&gt;返回值&lt;/strong&gt;来&quot;上报&quot;是否找到了 p 或 q。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;递归的返回值含义&lt;/strong&gt;：当前子树里有没有 p 或 q，有则返回找到的节点，没有则返回 &lt;code&gt;None&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;终止条件&lt;/strong&gt;：遇到 &lt;code&gt;None&lt;/code&gt; 直接返回 &lt;code&gt;None&lt;/code&gt;；遇到 &lt;code&gt;p&lt;/code&gt; 或 &lt;code&gt;q&lt;/code&gt; 直接返回该节点，不再往下找。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;递归逻辑&lt;/strong&gt;：对左右子树分别递归，根据左右子树的返回值来判断 LCA 在哪里。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;情况1：left 有值，right 有值  → p 和 q 分居两侧，当前 root 就是 LCA
情况2：left 有值，right 为空  → p 和 q 都在左子树，返回 left
情况3：left 为空，right 有值  → p 和 q 都在右子树，返回 right
情况4：left 为空，right 为空  → 当前子树没有 p 或 q，返回 None（被情况3顺带处理）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None

class Solution:
    def lowestCommonAncestor(self, root: &apos;TreeNode&apos;, p: &apos;TreeNode&apos;, q: &apos;TreeNode&apos;) -&amp;gt; &apos;TreeNode&apos;:
        if not root: return root          # 到底了，返回 None
        if root == p or root == q: return root  # 找到 p 或 q，直接上报

        left = self.lowestCommonAncestor(root.left, p, q)    # 左子树有没有
        right = self.lowestCommonAncestor(root.right, p, q)  # 右子树有没有

        if not left: return right   # 左边没有，答案在右边（含两者都没有的情况）
        if not right: return left   # 右边没有，答案在左边

        return root                 # 左右都有，当前节点就是 LCA
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;为什么遇到 p 就直接返回，不继续往下找 q？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;题目保证 p 和 q 都存在于树中。如果 q 是 p 的子节点，那 p 本身就是 LCA，不需要找到 q 才能确定答案。提前返回 p，上层调用收到这个返回值就能正确判断。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;if not root: return root&lt;/code&gt; 为什么不直接写 &lt;code&gt;return None&lt;/code&gt;？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;两者效果完全一样，&lt;code&gt;root&lt;/code&gt; 此时就是 &lt;code&gt;None&lt;/code&gt;。写 &lt;code&gt;return root&lt;/code&gt; 只是习惯，看起来对称，实际没区别。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;if not left: return right&lt;/code&gt; 涵盖了哪几种情况？&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;left = None, right = None  → 返回 right（即 None），说明这棵子树没有 p/q
left = None, right = 节点  → 返回 right，p/q 都在右子树
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;左右都没找到&quot;这种情况被第一行顺带处理了，不需要单独判断。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;比较节点用 &lt;code&gt;root == p&lt;/code&gt;，不能用 &lt;code&gt;root.val == p&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;p&lt;/code&gt; 是 TreeNode 对象，不是数字。&lt;code&gt;root.val == p&lt;/code&gt; 类型不匹配，会得到错误结果。如果要用 val 比较，要写 &lt;code&gt;root.val == p.val&lt;/code&gt;，但不推荐，因为不同节点可能有相同的值。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;隐含前提：p 和 q 一定存在于树中&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果不保证这一点，代码就不再正确。比如只有 p 存在而 q 不存在时，遍历完整棵树后 &lt;code&gt;right&lt;/code&gt; 为 &lt;code&gt;None&lt;/code&gt;，最终返回的是 p，而不是 &lt;code&gt;None&lt;/code&gt;（表示找不到）。需要额外用 &lt;code&gt;(node, found_count)&lt;/code&gt; 的方式来追踪是否真的找到了两个节点。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：递归返回值做&quot;信使&quot;，把子树的查找结果逐层向上传递&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LCA 判断&lt;/strong&gt;：左右子树各自返回非空，说明 p 和 q 分居两侧，当前节点就是答案&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n)，每个节点最多访问一次；空间 O(h)，h 为树高（最坏 O(n)，平衡树 O(log n)）&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;返回值情况&lt;/th&gt;
&lt;th&gt;left&lt;/th&gt;
&lt;th&gt;right&lt;/th&gt;
&lt;th&gt;返回&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p/q 分居两侧&lt;/td&gt;
&lt;td&gt;有值&lt;/td&gt;
&lt;td&gt;有值&lt;/td&gt;
&lt;td&gt;root（当前即 LCA）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;都在左子树&lt;/td&gt;
&lt;td&gt;有值&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;left&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;都在右子树&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;有值&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;当前子树无 p/q&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None（right）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>3月30日刷题笔记--SQL峰值在线人数与次日留存率</title><link>https://www.yyylegend.com/posts/3%E6%9C%8830%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0--sql%E6%AC%A1%E6%97%A5%E7%95%99%E5%AD%98%E7%8E%87/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8830%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0--sql%E6%AC%A1%E6%97%A5%E7%95%99%E5%AD%98%E7%8E%87/</guid><description>事件拆分+窗口函数求峰值在线人数，LEFT JOIN求次日留存率，含UNION ALL/UNION区别、ON vs WHERE陷阱详解</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;两道题共用同一张表 &lt;code&gt;tb_user_log&lt;/code&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;uid&lt;/th&gt;
&lt;th&gt;artical_id&lt;/th&gt;
&lt;th&gt;in_time&lt;/th&gt;
&lt;th&gt;out_time&lt;/th&gt;
&lt;th&gt;sign_in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2021-11-01 10:00:00&lt;/td&gt;
&lt;td&gt;2021-11-01 10:00:42&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;9001&lt;/td&gt;
&lt;td&gt;2021-11-01 10:00:00&lt;/td&gt;
&lt;td&gt;2021-11-01 10:00:09&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;9001&lt;/td&gt;
&lt;td&gt;2021-11-01 10:00:01&lt;/td&gt;
&lt;td&gt;2021-11-01 10:01:50&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;9002&lt;/td&gt;
&lt;td&gt;2021-11-02 10:00:09&lt;/td&gt;
&lt;td&gt;2021-11-02 10:00:28&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;9002&lt;/td&gt;
&lt;td&gt;2021-11-02 10:00:51&lt;/td&gt;
&lt;td&gt;2021-11-02 10:00:59&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;9001&lt;/td&gt;
&lt;td&gt;2021-11-02 11:00:28&lt;/td&gt;
&lt;td&gt;2021-11-02 11:01:24&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;9003&lt;/td&gt;
&lt;td&gt;2021-11-03 11:00:55&lt;/td&gt;
&lt;td&gt;2021-11-03 11:01:24&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;9003&lt;/td&gt;
&lt;td&gt;2021-11-03 11:00:45&lt;/td&gt;
&lt;td&gt;2021-11-03 11:00:55&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;9003&lt;/td&gt;
&lt;td&gt;2021-11-03 11:00:53&lt;/td&gt;
&lt;td&gt;2021-11-03 11:00:59&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;9002&lt;/td&gt;
&lt;td&gt;2021-11-04 11:00:55&lt;/td&gt;
&lt;td&gt;2021-11-04 11:00:59&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;（uid-用户ID, artical_id-文章ID, in_time-进入时间, out_time-离开时间, sign_in-是否签到）&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;题目一：每篇文章同一时刻最大在看人数&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;题目&lt;/strong&gt;：统计每篇文章同一时刻最大在看人数，如果同一时刻有进入也有离开时，先记用户数增加再记减少，结果按最大人数降序。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;预期输出&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;artical_id&lt;/th&gt;
&lt;th&gt;max_uv&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;9001&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9002&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;核心思路&lt;/h3&gt;
&lt;p&gt;把每篇文章的阅读情况想象成一个&lt;strong&gt;房间&lt;/strong&gt;，用户进来就+1人，出去就-1人，找&lt;strong&gt;房间里同时最多有几个人&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;关键点：同一时刻有进入也有离开时，&lt;strong&gt;先加后减&lt;/strong&gt;（先+1再-1），才能捕捉到真实峰值。&lt;/p&gt;
&lt;h3&gt;解题流程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;原始数据（一条记录有两个时间点）
        ↓
   UNION ALL 拆成两类事件
        ↓
  进入事件(+1, flag=1)  +  离开事件(-1, flag=2)
        ↓
  SUM(diff) OVER(PARTITION BY artical_id ORDER BY time, flag)
  滚动累加 → 每一行 = 那一时刻的实时在线数
        ↓
  MAX(uv) → 每篇文章峰值
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;完整 SQL&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;WITH
-- 第一步：把每条记录拆成进入和离开两个事件
T1 AS (
    SELECT artical_id, in_time AS curr_time, 1 AS diff, 1 AS flag
    FROM tb_user_log
    WHERE artical_id != 0

    UNION ALL

    SELECT artical_id, out_time AS curr_time, -1 AS diff, 2 AS flag
    FROM tb_user_log
    WHERE artical_id != 0
),

-- 第二步：窗口函数滚动累加，得到每一时刻的实时在线人数
T2 AS (
    SELECT
        artical_id,
        SUM(diff) OVER (
            PARTITION BY artical_id
            ORDER BY curr_time, flag
        ) AS uv
    FROM T1
)

-- 第三步：取每篇文章的峰值
SELECT artical_id, MAX(uv) AS max_uv
FROM T2
GROUP BY artical_id
ORDER BY max_uv DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;每一步详解&lt;/h3&gt;
&lt;h4&gt;第一步 T1：为什么要用 UNION ALL 拆事件？&lt;/h4&gt;
&lt;p&gt;原始一条记录有两个时间字段 &lt;code&gt;in_time&lt;/code&gt; 和 &lt;code&gt;out_time&lt;/code&gt;，需要把它变成两行独立事件来逐个处理，一个 SELECT 无法做到行数翻倍，所以必须用 UNION ALL。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;原始一条记录：
uid=102, artical_id=9001, in_time=10:00:00, out_time=10:00:09

拆成两条 ↓

(9001, 10:00:00, diff=+1, flag=1)  ← 进入事件
(9001, 10:00:09, diff=-1, flag=2)  ← 离开事件
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;这里用 UNION ALL 而不是 UNION&lt;/strong&gt;，因为每个进入/离开事件都是独立的，不能去重，用 UNION 会把相同时间的事件误删。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;diff 和 flag 各自的作用：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;diff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;告诉 SUM 该加多少（进入+1，离开-1），&lt;strong&gt;管计算&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;flag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同一时刻进入=1优先，离开=2靠后，&lt;strong&gt;管顺序&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;第二步 T2：窗口函数滚动累加&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;SUM(diff) OVER(PARTITION BY artical_id ORDER BY curr_time, flag)&lt;/code&gt; 执行过程（以9001为例）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;curr_time&lt;/th&gt;
&lt;th&gt;diff&lt;/th&gt;
&lt;th&gt;flag&lt;/th&gt;
&lt;th&gt;uv（累计）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10:00:00&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:00:01&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:00:09&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt; ← peak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:00:11&lt;/td&gt;
&lt;td&gt;-1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:00:28&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PARTITION BY artical_id&lt;/code&gt;：9001 和 9002 各自独立累计，互不影响&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ORDER BY curr_time, flag&lt;/code&gt;：按时间排序，同一时刻进入（flag=1）先于离开（flag=2）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;为什么同一时刻要先加后减？&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;假设 A 在 10:00:11 离开，B 也在 10:00:11 进入：

先减后加（错误）：原来2人 → -1=1人 → +1=2人   峰值记录到2
先加后减（正确）：原来2人 → +1=3人 → -1=2人   峰值记录到3 ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为在现实中这一秒内两人是同时在场的，必须先加再减才能捕捉到真实峰值，这就是 flag 字段存在的意义。&lt;/p&gt;
&lt;h3&gt;常见坑&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;写错了 UNION ALL 第二个 SELECT 的时间字段：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- ❌ 错误：两个 SELECT 都用了 in_time
SELECT artical_id, in_time AS curr_time, -1 AS diff, 2 AS flag

-- ✅ 正确：离开事件必须用 out_time
SELECT artical_id, out_time AS curr_time, -1 AS diff, 2 AS flag
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;离开事件用了 &lt;code&gt;in_time&lt;/code&gt;，会导致同一时刻 +1 又 -1，uv 始终为 1，永远得不到正确峰值。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;题目二：每天新用户的次日留存率&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;题目&lt;/strong&gt;：统计2021年11月每天新用户的次日留存率（保留2位小数）&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;次日留存率 = 当天新增用户中，第二天又活跃的用户数 / 当天新增用户总数&lt;/li&gt;
&lt;li&gt;如果 &lt;code&gt;in_time&lt;/code&gt; 和 &lt;code&gt;out_time&lt;/code&gt; 跨天了，在两天里都记为该用户活跃过&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;预期输出&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;dt&lt;/th&gt;
&lt;th&gt;uv_left_rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2021-11-01&lt;/td&gt;
&lt;td&gt;0.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2021-11-02&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2021-11-03&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;核心思路&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;每天新用户里，&lt;strong&gt;第二天还回来的&lt;/strong&gt;占多少比例&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;需要知道两件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;每天有哪些&lt;strong&gt;新用户&lt;/strong&gt;（当天第一次出现的用户）&lt;/li&gt;
&lt;li&gt;这些新用户&lt;strong&gt;第二天有没有活跃&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;解题流程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;原始数据 tb_user_log
        ↓
  ┌─────────────┐        ┌──────────────┐
  │  new_users  │        │  active_days │
  │  每个用户   │        │  每个用户    │
  │  首次登录日 │        │  每天活跃日  │
  └──────┬──────┘        └──────┬───────┘
         │                      │
         └─────── LEFT JOIN ─────┘
                 条件：同一用户
                 且活跃日 = 注册日+1
                      ↓
              GROUP BY 注册日
                      ↓
         次日活跃人数 / 当天新用户总数
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;完整 SQL&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;WITH
-- 第一步：找每个用户的首次登录日期（新用户定义）
new_users AS (
    SELECT uid, MIN(DATE(in_time)) AS reg_date
    FROM tb_user_log
    GROUP BY uid
),

-- 第二步：找每个用户每天的活跃日期（含跨天处理）
active_days AS (
    SELECT uid, DATE(in_time) AS active_date
    FROM tb_user_log
    UNION
    SELECT uid, DATE(out_time) AS active_date
    FROM tb_user_log
)

-- 第三步：LEFT JOIN关联，计算次日留存率
SELECT
    n.reg_date AS dt,
    ROUND(COUNT(DISTINCT a.uid) / COUNT(DISTINCT n.uid), 2) AS uv_left_rate
FROM new_users n
LEFT JOIN active_days a
    ON n.uid = a.uid
    AND a.active_date = DATE_ADD(n.reg_date, INTERVAL 1 DAY)
WHERE DATE_FORMAT(n.reg_date, &apos;%Y-%m&apos;) = &apos;2021-11&apos;
GROUP BY n.reg_date
ORDER BY n.reg_date;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;每一步详解&lt;/h3&gt;
&lt;h4&gt;第一步 new_users：找新用户&lt;/h4&gt;
&lt;p&gt;用 &lt;code&gt;MIN(DATE(in_time))&lt;/code&gt; 取每个用户最早出现的日期，&lt;code&gt;GROUP BY uid&lt;/code&gt; 保证每人只有一行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不需要加 DISTINCT&lt;/strong&gt;，&lt;code&gt;GROUP BY uid&lt;/code&gt; 已经保证了每个uid只有一行，再加是多余的。&lt;/p&gt;
&lt;h4&gt;第二步 active_days：找每人每天的活跃记录&lt;/h4&gt;
&lt;p&gt;一条记录里有两个时间字段，一个用户可能跨天活跃，所以要把 &lt;code&gt;in_time&lt;/code&gt; 和 &lt;code&gt;out_time&lt;/code&gt; 都纳入统计。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这里用 UNION 而不是 UNION ALL&lt;/strong&gt;，因为只关心&quot;某天有没有活跃&quot;，不关心活跃了几次，需要对 uid + 日期组合去重。&lt;/p&gt;
&lt;p&gt;也可以写成 &lt;code&gt;SELECT DISTINCT uid, DATE(in_time)&lt;/code&gt;，效果完全一样。&lt;/p&gt;
&lt;h4&gt;第三步 LEFT JOIN：关联两张表&lt;/h4&gt;
&lt;p&gt;ON 后面两个条件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n.uid = a.uid&lt;/code&gt;：同一个用户才能拼在一起&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a.active_date = DATE_ADD(n.reg_date, INTERVAL 1 DAY)&lt;/code&gt;：活跃日期必须是注册日的次日&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;DATE_ADD(n.reg_date, INTERVAL 1 DAY)&lt;/code&gt; 就是把日期加一天，这个条件是整道题&quot;次日留存&quot;逻辑的核心。&lt;/p&gt;
&lt;p&gt;JOIN 之后的结果：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;n.uid&lt;/th&gt;
&lt;th&gt;n.reg_date&lt;/th&gt;
&lt;th&gt;a.uid&lt;/th&gt;
&lt;th&gt;a.active_date&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;11-01&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;11-02 ✅ 次日有活跃&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;11-01&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;NULL ← 次日没活跃，填NULL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;11-01&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;11-02 ✅ 次日有活跃&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;11-02&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;11-03 ✅ 次日有活跃&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;11-03&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;NULL ← 次日没活跃，填NULL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;常见坑详解&lt;/h3&gt;
&lt;h4&gt;① 次日条件要写在 ON 里，不能写在 WHERE 里&lt;/h4&gt;
&lt;p&gt;这是 LEFT JOIN 最经典的陷阱。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- ❌ 错误写法
LEFT JOIN active_days a ON n.uid = a.uid
WHERE a.active_date = DATE_ADD(n.reg_date, INTERVAL 1 DAY)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行顺序是先 JOIN 后 WHERE。JOIN 之后102那行 &lt;code&gt;a.active_date&lt;/code&gt; 是 NULL，NULL 无法满足 WHERE 条件，这一行被直接删掉，LEFT JOIN 形同虚设，变成了 INNER JOIN 的效果，分母少了人，结果偏高。&lt;/p&gt;
&lt;p&gt;写在 ON 里则是在 JOIN 阶段就判断，找不到匹配的填 NULL，行不丢失，分母始终正确。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;规律：只要用了 LEFT JOIN 且需要保留没有匹配的行，关联条件就要写在 ON 里，不能挪到 WHERE。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;② new_users 要放在左边&lt;/h4&gt;
&lt;p&gt;LEFT JOIN 的规则是&lt;strong&gt;左表的行一定保留，右表找不到匹配就填 NULL&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;new_users 是分母，每个新用户都必须出现在结果里。如果把 active_days 放左边，102、105 在 active_days 里根本没有次日记录，反过来 JOIN 就直接丢失了，导致分母偏小，结果偏高。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;规律：哪张表的数据不能丢，哪张表就放左边。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;③ 不能用 INNER JOIN 替代 LEFT JOIN&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;LEFT JOIN&lt;/th&gt;
&lt;th&gt;INNER JOIN&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;102（次日无活跃）&lt;/td&gt;
&lt;td&gt;保留，a.uid = NULL&lt;/td&gt;
&lt;td&gt;直接消失&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;105（次日无活跃）&lt;/td&gt;
&lt;td&gt;保留，a.uid = NULL&lt;/td&gt;
&lt;td&gt;直接消失&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-01留存率&lt;/td&gt;
&lt;td&gt;2/3 = 0.67 ✅&lt;/td&gt;
&lt;td&gt;2/2 = 1.00 ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-03留存率&lt;/td&gt;
&lt;td&gt;0/1 = 0.00 ✅&lt;/td&gt;
&lt;td&gt;该行消失 ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;④ COUNT 里必须加 DISTINCT&lt;/h4&gt;
&lt;p&gt;假设用户101次日活跃了2次（看了2篇文章），JOIN 之后同一个用户出现两行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;n.uid  a.uid  a.active_date
101    101    2021-11-02   ← 第一条活跃
101    101    2021-11-02   ← 第二条活跃
102    NULL   NULL
103    103    2021-11-02
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;不加 DISTINCT：COUNT(a.uid) = 3  ← 101被数了两次，结果偏高！
加了 DISTINCT：COUNT(DISTINCT a.uid) = 2  ← 101只数一次 ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;COUNT(DISTINCT a.uid)&lt;/code&gt; 同时还能自动跳过 NULL，102那行不会被计入分子。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;两道题对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;题目一（峰值在线）&lt;/th&gt;
&lt;th&gt;题目二（次日留存）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;核心操作&lt;/td&gt;
&lt;td&gt;事件拆分 + 窗口函数累加&lt;/td&gt;
&lt;td&gt;新用户表 LEFT JOIN 活跃表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UNION 用法&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UNION ALL&lt;/code&gt;（每个事件独立，不去重）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UNION&lt;/code&gt;（只看有没有活跃，去重）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;关键字段&lt;/td&gt;
&lt;td&gt;&lt;code&gt;diff&lt;/code&gt;管计算，&lt;code&gt;flag&lt;/code&gt;管顺序&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATE_ADD&lt;/code&gt;算次日，&lt;code&gt;LEFT JOIN&lt;/code&gt;保留无活跃用户&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最终聚合&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MAX(uv)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COUNT(DISTINCT a.uid) / COUNT(DISTINCT n.uid)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;题目一：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心&lt;/strong&gt;：一条记录拆成两个事件，用 &lt;code&gt;SUM(diff) OVER(...)&lt;/code&gt; 滚动累加求峰值&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;flag 字段&lt;/strong&gt;：同一时刻进入优先于离开，保证峰值被正确捕捉&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;常见错误&lt;/strong&gt;：离开事件的时间字段写成了 &lt;code&gt;in_time&lt;/code&gt; 而不是 &lt;code&gt;out_time&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;题目二：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心&lt;/strong&gt;：新用户表 LEFT JOIN 活跃表，条件写在 ON 里&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跨天活跃&lt;/strong&gt;：用 &lt;code&gt;UNION&lt;/code&gt; 把 &lt;code&gt;in_time&lt;/code&gt; 和 &lt;code&gt;out_time&lt;/code&gt; 两天都收进来&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ON vs WHERE&lt;/strong&gt;：次日条件写在 ON 里，写在 WHERE 里会让 LEFT JOIN 失效&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;COUNT(DISTINCT)&lt;/strong&gt;：防止同一用户因多条活跃记录被重复计数&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>3月29日刷题笔记--SQL连续登录天数</title><link>https://www.yyylegend.com/posts/3%E6%9C%8829%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0--sql%E8%BF%9E%E7%BB%AD%E7%99%BB%E5%BD%95%E5%A4%A9%E6%95%B0/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8829%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0--sql%E8%BF%9E%E7%BB%AD%E7%99%BB%E5%BD%95%E5%A4%A9%E6%95%B0/</guid><description>连续问题经典解法，日期-行号=常数技巧，含最长连续天数与连续N天用户筛选两道题解</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;题目一&lt;/strong&gt;：2023年1月用户最长连续登录天数（登陆表 &lt;code&gt;tb_dau&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;题目二&lt;/strong&gt;：找出存在连续登录 ≥ 3 天的用户（登录表 &lt;code&gt;login_tb&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;核心原理：日期 - 行号 = 常数&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;连续序列中，日期减去其行号的结果是一个固定不变的常数。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;一旦序列中断，差值就会改变，从而把不同的连续段自然地区分开。&lt;/p&gt;
&lt;h3&gt;举例说明&lt;/h3&gt;
&lt;p&gt;用户 10000 的登录数据：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;fdate&lt;/th&gt;
&lt;th&gt;rk&lt;/th&gt;
&lt;th&gt;DATE_SUB(fdate, rk)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2023-01-01&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2022-12-31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023-01-02&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2022-12-31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023-01-04&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2023-01-01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;01-01 和 01-02 连续 → 差值相同，都是 &lt;code&gt;2022-12-31&lt;/code&gt;，属于同一段&lt;/li&gt;
&lt;li&gt;01-04 中断了 → 差值变为 &lt;code&gt;2023-01-01&lt;/code&gt;，另起一段&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;解题五步模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;去重 → 编号 → 差值分组 → 聚合得到段长度 → 取结果
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;-- Step 1：去重 + 编排行号
WITH t1 AS (
    SELECT DISTINCT
        user_id,
        fdate,
        ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY fdate) AS rk
    FROM tb_dau
    WHERE fdate BETWEEN &apos;开始日期&apos; AND &apos;结束日期&apos;
),
 
-- Step 2：用&quot;日期 - 行号&quot;标记连续分组，统计每段长度
t2 AS (
    SELECT
        user_id,
        DATE_SUB(fdate, INTERVAL rk DAY) AS diff,  -- 差值相同 = 同一连续段
        COUNT(1) AS consec_days
    FROM t1
    GROUP BY user_id, DATE_SUB(fdate, INTERVAL rk DAY)
)
 
-- Step 3：按需求取结果
SELECT
    user_id,
    MAX(consec_days) AS max_consec_days
FROM t2
GROUP BY user_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;题目一：最长连续登录天数&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;题意&lt;/strong&gt;：统计 2023 年 1 月每个用户最长的连续登录天数。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输入&lt;/strong&gt;（&lt;code&gt;tb_dau&lt;/code&gt;）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;fdate&lt;/th&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2023-01-01&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023-01-02&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023-01-04&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;输出&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;max_consec_days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;完整 SQL&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;WITH t1 AS (
    SELECT DISTINCT user_id, fdate,
           ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY fdate) AS rk
    FROM tb_dau
    WHERE fdate BETWEEN &apos;2023-01-01&apos; AND &apos;2023-01-31&apos;
),
t2 AS (
    SELECT user_id, COUNT(1) AS consec_days
    FROM t1
    GROUP BY user_id, DATE_SUB(fdate, INTERVAL rk DAY)
)
SELECT user_id, MAX(consec_days) AS max_consec_days
FROM t2
GROUP BY user_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;执行过程拆解&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;t1 的结果&lt;/strong&gt;（去重 + 编号）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;fdate&lt;/th&gt;
&lt;th&gt;rk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;2023-01-01&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;2023-01-02&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;2023-01-04&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;t2 的结果&lt;/strong&gt;（差值分组 + 计数）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;diff&lt;/th&gt;
&lt;th&gt;consec_days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;2022-12-31&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;2023-01-01&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;最终&lt;/strong&gt;：&lt;code&gt;MAX(consec_days) = 2&lt;/code&gt; ✓&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;题目二：筛选连续登录 ≥ 3 天的用户&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;题意&lt;/strong&gt;：从登录记录中，找出在任意时间段内存在连续登录 ≥ 3 天的用户，只保留已注册用户。&lt;/p&gt;
&lt;h3&gt;完整 SQL&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;WITH T1 AS (
    SELECT DISTINCT user_id, DATE(log_time) AS login_time
    FROM login_tb
    WHERE user_id IN (SELECT user_id FROM register_tb)  -- 只看已注册用户
),
T2 AS (
    SELECT user_id, login_time,
           ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_time) AS rn
    FROM T1
),
T3 AS (
    SELECT user_id, COUNT(1) AS consec_days
    FROM T2
    GROUP BY user_id, DATE_SUB(login_time, INTERVAL rn DAY)
)
SELECT DISTINCT user_id
FROM T3
WHERE consec_days &amp;gt;= 3
ORDER BY user_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;与题目一的区别&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;题目一&lt;/th&gt;
&lt;th&gt;题目二&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;目标&lt;/td&gt;
&lt;td&gt;最长连续天数是多少&lt;/td&gt;
&lt;td&gt;是否存在连续 ≥ N 天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最终取法&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MAX(consec_days)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE consec_days &amp;gt;= 3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;额外处理&lt;/td&gt;
&lt;td&gt;日期范围过滤&lt;/td&gt;
&lt;td&gt;关联注册表过滤无效用户&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;常见变种汇总&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题目问法&lt;/th&gt;
&lt;th&gt;Step 3 写法&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;最长连续天数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MAX(consec_days)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;是否存在连续 N 天&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE consec_days &amp;gt;= N&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;连续 N 天的用户名单&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE consec_days &amp;gt;= N&lt;/code&gt; + &lt;code&gt;SELECT DISTINCT user_id&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;容易踩的坑&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;① 忘记 DISTINCT&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;同一天登录多次会产生重复行，行号就乱了，差值不再稳定，&lt;strong&gt;必须先去重&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;② 用 RANK 代替 ROW_NUMBER&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RANK&lt;/code&gt; 遇到并列会跳号（1, 1, 3），差值就不准了，&lt;strong&gt;必须用 ROW_NUMBER&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;③ 日期序列 vs 数字序列&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 日期序列用 DATE_SUB
DATE_SUB(fdate, INTERVAL rk DAY)
 
-- 数字序列直接相减
val - rk
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心公式&lt;/strong&gt;：连续序列中，&lt;code&gt;日期 - 行号 = 常数&lt;/code&gt;，差值相同即同一连续段&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;五步模板&lt;/strong&gt;：去重 → 编号 → 差值分组 → COUNT 段长 → 取结果&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Step 3 灵活变&lt;/strong&gt;：根据题目要求换用 &lt;code&gt;MAX&lt;/code&gt; / &lt;code&gt;WHERE&lt;/code&gt; / &lt;code&gt;MIN+MAX&lt;/code&gt; 等&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>3月28日刷题笔记--K个一组翻转链表与快速幂</title><link>https://www.yyylegend.com/posts/3%E6%9C%8828%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0k%E4%B8%AA%E4%B8%80%E7%BB%84%E7%BF%BB%E8%BD%AC%E9%93%BE%E8%A1%A8%E5%92%8C%E5%BF%AB%E9%80%9F%E5%B9%82/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8828%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0k%E4%B8%AA%E4%B8%80%E7%BB%84%E7%BF%BB%E8%BD%AC%E9%93%BE%E8%A1%A8%E5%92%8C%E5%BF%AB%E9%80%9F%E5%B9%82/</guid><description>链表反转进阶 + 快速幂，LeetCode 25 K个一组翻转链表、LeetCode 50 Pow(x,n) 题解</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;25&quot; title=&quot;Reverse Nodes in k-Group&quot; zh=&quot;K个一组翻转链表&quot; difficulty=&quot;hard&quot;}
::leetcode{id=&quot;50&quot; title=&quot;Pow(x, n)&quot; zh=&quot;Pow(x, n)&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 25 · K个一组翻转链表&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;25&quot; title=&quot;Reverse Nodes in k-Group&quot; zh=&quot;K个一组翻转链表&quot; difficulty=&quot;hard&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;整体分两层逻辑：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;外层 while 循环&lt;/strong&gt;：每次处理一组 k 个节点。先统计链表总长度 &lt;code&gt;n&lt;/code&gt;，每轮 &lt;code&gt;n -= k&lt;/code&gt;，只要 &lt;code&gt;n &amp;gt;= k&lt;/code&gt; 就说明还有完整的一组可以处理，不够 k 个则直接保留原样退出。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;内层 for 循环&lt;/strong&gt;：对当前组的 k 个节点做标准链表反转（和 LeetCode 206 完全一样），循环 k 次，每次把 &lt;code&gt;curr.next&lt;/code&gt; 指向 &lt;code&gt;prev&lt;/code&gt;，然后 &lt;code&gt;prev&lt;/code&gt;、&lt;code&gt;curr&lt;/code&gt; 各往右移一步。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;for 结束后接回主链表&lt;/strong&gt;：反转完一组之后，这组在链表里是&quot;断开&quot;的状态，需要重新把它拼回去。用 &lt;code&gt;p0&lt;/code&gt; 指针记录每组的前驱节点（即上一组的尾巴），依次完成四步接线操作。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;反转前：... → p0 → [1 → 2] → 3 → ...
反转后（for结束）：       2 → 1 → None，curr 在 3
接回后：... → p0 → 2 → 1 → 3 → ...，p0 移到 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;for循环退出去之后脑子里要有这个图片 根据这个图片重新连接指针&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image_3.28.png&quot; alt=&quot;image_3.28.png&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def reverseKGroup(self, head: Optional[ListNode], k: int) -&amp;gt; Optional[ListNode]:
        
        # 统计链表总长度
        n = 0
        curr = head
        while curr:
            n += 1
            curr = curr.next

        # 哨兵节点和p0指针的初始化，始终指向当前要翻转的组的头节点
        p0 = dummy = ListNode(0)
        dummy.next = head
        prev = None
        curr = head

        # 剩余节点够 k 个才处理，不够直接保留原样
        while n &amp;gt;= k:
            n -= k
            # prev = None # 这个也可以不写

            # 组内反转 k 次，和翻转链表模板完全一致
            for _ in range(k):
            
		        # 下面和翻转链表代码一样
                # 先保存当前节点的下一个节点
                # 再将当前节点的下一个节点指向prev,断开和原先下一个节点的连接
                # prev和curr指针各往右走一步 准备开始新一轮翻转
                
                node = curr.next      # 保存下一个节点，防止断链后找不到
                curr.next = prev      # 当前节点反向指向 prev
                prev = curr           # prev 右移
                curr = node           # curr 右移

            # 反转完成后，接回主链表
            # 当这组翻转完之后 类似于for循环里面的翻转逻辑 我们还需要对整组进行重新连接
            # 首先保存这一组的头节点 防止断链之后找不到
            # 接着将这一组的头节点指向curr指针 因为现在curr在新的一组的开头了
            # 然后将p指针指向这一组的尾节点 也就是prev 因为此时的位置是prev在这一组的尾节点 而curr在新一组的头节点
            # prev永远在curr左边
            # 最后重新设置p 移动到 grouped_first_node
            grouped_first_node = p0.next      # 记录这组的尾巴（反转前的头）
            grouped_first_node.next = curr    # 尾巴接上下一组的头
            p0.next = prev                    # p0 接上这组反转后的头
            p0 = grouped_first_node           # p0 移动到这组的尾巴，准备下一轮

        return dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;为什么要先统计长度 &lt;code&gt;n&lt;/code&gt;？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;用 &lt;code&gt;while n &amp;gt;= k&lt;/code&gt; 来判断剩余节点是否够一组。如果不统计长度，就不知道什么时候该停，容易在 &lt;code&gt;curr&lt;/code&gt; 为 &lt;code&gt;None&lt;/code&gt; 时还继续执行 for 循环，导致 &lt;code&gt;curr.next&lt;/code&gt; 报 &lt;code&gt;AttributeError&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;while n &amp;gt;= k&lt;/code&gt; 而不是 &lt;code&gt;while n&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;n -= k&lt;/code&gt; 之后 &lt;code&gt;n&lt;/code&gt; 可能变成负数，Python 里负数是 truthy，循环不会停。必须用 &lt;code&gt;&amp;gt;= k&lt;/code&gt; 明确判断剩余长度。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;接回主链表的四行顺序不能乱&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;grouped_first_node = p0.next      # ① 先保存尾巴，否则 p0.next 被覆盖后就找不到了
grouped_first_node.next = curr    # ② 尾巴接下一组
p0.next = prev                    # ③ p0 接这组的新头
p0 = grouped_first_node           # ④ p0 移动到尾巴，准备下一轮
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;①必须在③之前：③会覆盖 &lt;code&gt;p0.next&lt;/code&gt;，如果先执行③，&lt;code&gt;grouped_first_node&lt;/code&gt; 就拿不到原来的值了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;grouped_first_node.next = curr&lt;/code&gt; 不是 &lt;code&gt;grouped_first_node = curr&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;前者是修改节点的 &lt;code&gt;next&lt;/code&gt; 指针（改链表结构），后者只是给变量重新赋值，不影响任何节点的连接关系。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;prev&lt;/code&gt; 在进入下一轮 while 时不需要重置&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;for 循环结束时 &lt;code&gt;prev&lt;/code&gt; 指向这组的头节点，&lt;code&gt;p0.next = prev&lt;/code&gt; 把它接好之后，下一轮 for 循环会继续用同一个 &lt;code&gt;prev&lt;/code&gt; 变量，它会被 &lt;code&gt;curr.next = prev&lt;/code&gt; 覆盖，自然过渡，不需要手动重置为 &lt;code&gt;None&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：外层 while 控制分组 + 内层 for 做标准链表反转 + 四行接回主链表&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;p0&lt;/code&gt; 的作用&lt;/strong&gt;：始终指向&quot;已处理部分的最后一个节点&quot;，每轮反转完成后移动到本组尾巴&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n)，空间 O(1)&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;步骤&lt;/th&gt;
&lt;th&gt;对应代码&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;统计长度&lt;/td&gt;
&lt;td&gt;&lt;code&gt;while curr: n += 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;判断剩余够不够 k 个&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;分组控制&lt;/td&gt;
&lt;td&gt;&lt;code&gt;while n &amp;gt;= k: n -= k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不够 k 个则保留原样&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;组内反转&lt;/td&gt;
&lt;td&gt;&lt;code&gt;for _ in range(k)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;标准链表反转，执行 k 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;接回主链表&lt;/td&gt;
&lt;td&gt;四行接线&lt;/td&gt;
&lt;td&gt;把反转好的小段拼回大链表&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 50 · Pow(x, n)&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;50&quot; title=&quot;Pow(x, n)&quot; zh=&quot;Pow(x, n)&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;朴素做法是把 &lt;code&gt;x&lt;/code&gt; 连乘 &lt;code&gt;n&lt;/code&gt; 次，时间复杂度 O(n)。当 &lt;code&gt;n&lt;/code&gt; 很大时（比如 10⁹）会超时。&lt;/p&gt;
&lt;p&gt;快速幂的核心思想是把指数 &lt;code&gt;n&lt;/code&gt; 用二进制表示，利用二进制分解来跳过大量重复计算。&lt;/p&gt;
&lt;p&gt;以 &lt;code&gt;x=2, n=9&lt;/code&gt; 为例，&lt;code&gt;9 = 1001₂&lt;/code&gt;，所以：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;x⁹ = x^(1×1) × x^(0×2) × x^(0×4) × x^(1×8)
   = x¹ × x⁸
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只需要计算二进制位为 &lt;strong&gt;1&lt;/strong&gt; 的那几项，其余跳过。这样循环次数等于 &lt;code&gt;n&lt;/code&gt; 的二进制位数，也就是 O(log n)。&lt;/p&gt;
&lt;p&gt;具体做法：从 &lt;code&gt;n&lt;/code&gt; 的最低位开始从右往左扫，每轮检查当前位是否为 1，是则把当前的 &lt;code&gt;x&lt;/code&gt; 乘进结果；同时 &lt;code&gt;x&lt;/code&gt; 自己平方（&lt;code&gt;x¹ → x² → x⁴ → x⁸ ...&lt;/code&gt;），&lt;code&gt;n&lt;/code&gt; 右移一位进入下一位的检查。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;n = 1001
第1轮：最低位=1 → res×=x¹，x变x²，n右移→100
第2轮：最低位=0 → 跳过，  x变x⁴，n右移→10
第3轮：最低位=0 → 跳过，  x变x⁸，n右移→1
第4轮：最低位=1 → res×=x⁸，x变x¹⁶，n右移→0，结束
res = x¹ × x⁸ = x⁹ ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def myPow(self, x: float, n: int) -&amp;gt; float:
        if x == 0.0: return 0.0
        res = 1
        if n &amp;lt; 0: x, n = 1 / x, -n   # 负指数转成正指数
        while n:
            if n &amp;amp; 1: res *= x         # 当前最低位是 1，把 x 收进结果
            x *= x                     # x 自己平方，准备下一位
            n &amp;gt;&amp;gt;= 1                    # n 右移一位，检查下一位
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;n &amp;amp; 1&lt;/code&gt; 是什么&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;按位与运算，&lt;code&gt;1&lt;/code&gt; 的二进制是 &lt;code&gt;0001&lt;/code&gt;，&lt;code&gt;n &amp;amp; 1&lt;/code&gt; 的效果是只看 &lt;code&gt;n&lt;/code&gt; 的最低位：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1001 &amp;amp; 0001 = 0001 = 1  → 最低位是 1
1000 &amp;amp; 0001 = 0000 = 0  → 最低位是 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;n &amp;gt;&amp;gt;= 1&lt;/code&gt; 是什么&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;右移一位，相当于把 &lt;code&gt;n&lt;/code&gt; 的二进制整体向右移，最低位被丢弃，等价于 &lt;code&gt;n //= 2&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1001 &amp;gt;&amp;gt; 1 = 100
100  &amp;gt;&amp;gt; 1 = 10
10   &amp;gt;&amp;gt; 1 = 1
1    &amp;gt;&amp;gt; 1 = 0  → 循环结束
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;x *= x&lt;/code&gt; 每轮都执行，不管当前位是 0 还是 1&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;x&lt;/code&gt; 的平方是给下一轮准备的，和当前位是否为 1 无关：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;第1轮：x = x¹ → 平方后变 x²（给第2轮用）
第2轮：x = x² → 平方后变 x⁴（给第3轮用）
第3轮：x = x⁴ → 平方后变 x⁸（给第4轮用）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;负指数处理&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;x^(-n) = (1/x)^n&lt;/code&gt;，所以负指数时把 &lt;code&gt;x&lt;/code&gt; 换成 &lt;code&gt;1/x&lt;/code&gt;，&lt;code&gt;n&lt;/code&gt; 取绝对值，后续逻辑完全一样。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心思想&lt;/strong&gt;：把指数 &lt;code&gt;n&lt;/code&gt; 二进制分解，只把二进制位为 1 的幂次乘进结果&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;res&lt;/code&gt; 的作用&lt;/strong&gt;：收集袋，遇到二进制位为 1 就往里装当前的 &lt;code&gt;x&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(log n)，空间 O(1)&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;操作&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;n &amp;amp; 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;检查 n 的最低位是 0 还是 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;res *= x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;当前位为 1，把这个幂次收进结果&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;x *= x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;x 自己平方，准备下一位&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;n &amp;gt;&amp;gt;= 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;右移，丢弃已处理的最低位&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>3月27日刷题笔记——优先队列与中心扩展</title><link>https://www.yyylegend.com/posts/3%E6%9C%8827%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E4%B8%A4%E9%81%93%E5%AE%9E%E6%88%98%E9%A2%98/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8827%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E4%B8%A4%E9%81%93%E5%AE%9E%E6%88%98%E9%A2%98/</guid><description>双堆+懒惰删除解决动态最值问题，中心扩展基础版解决回文变体问题</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;今日用到的技巧&lt;/h2&gt;
&lt;h3&gt;懒惰删除（Lazy Deletion）&lt;/h3&gt;
&lt;p&gt;Python 的 &lt;code&gt;heapq&lt;/code&gt; 只能高效弹出堆顶元素 O(logN)。如果一个元素在最小堆里失效了，但它同时还在最大堆的中间位置，我们无法直接去最大堆里把它挖出来（强行挖取的时间复杂度是 O(N)）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：准备一本状态注册表 &lt;code&gt;deleted = [False] * n&lt;/code&gt;。
当我们要从最大堆取元素时，先对照注册表看看堆顶元素是否已经失效。如果是失效的&quot;幻影&quot;，直接 &lt;code&gt;heappop&lt;/code&gt; 扔掉，直到露出真正有效的数据为止。&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;懒惰删除的本质&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不是真的删除元素，而是&quot;标记作废 + 弹出时跳过&quot;。代价是堆里会积累一些垃圾数据，但总量有上界（每个元素最多被标记一次），整体复杂度不变。
:::&lt;/p&gt;
&lt;h3&gt;中心扩展基础法（Center Expansion）&lt;/h3&gt;
&lt;p&gt;在处理回文变体（如左右两边字符集合完全一致）时，使用双指针向两边扩展是最直观、最稳妥的思路。每次扩展后，直接比较两边的频次数组是否相等即可。&lt;/p&gt;
&lt;p&gt;:::warning
&lt;strong&gt;性能提示&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;直接使用 &lt;code&gt;if countL == countR&lt;/code&gt; 在 Python 中会进行一次长度为 26 的数组比较。代码极其易读且不易出错，但在遇到极端超大数据（如十万级字符串）时，可能会出现超时（TLE）。适合作为考场保底方案。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;题目一 · 反应堆过载防御（动态最值与懒惰删除）&lt;/h2&gt;
&lt;h3&gt;题目描述&lt;/h3&gt;
&lt;p&gt;在一个生化反应堆中，最多只能安全容纳 $m$ 个能量核。现有 $n$ 个能量核依次按顺序被推入反应堆，第 $i$ 个能量核的初始稳定度为 $a_i$。&lt;/p&gt;
&lt;p&gt;当反应堆内的能量核数量超过安全容量 $m$ 时，系统会触发过载防御机制：系统会强制排出当前反应堆内稳定度最高的那个能量核。强制排出操作会产生能量冲击，使得反应堆内所有剩余的能量核的稳定度降低 $x$ 点。如果某个能量核的稳定度降至 $0$ 或以下，它将自行湮灭并从反应堆中消失。&lt;/p&gt;
&lt;p&gt;请问：在所有能量核处理完毕后，系统总共强制排出了多少个能量核？&lt;/p&gt;
&lt;h3&gt;审题思路&lt;/h3&gt;
&lt;p&gt;拿到这道题，第一反应是&quot;维护一个动态集合，随时取最大值&quot;，自然想到最大堆。但问题在于两件事同时发生：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;全场扣血&lt;/strong&gt;：每次排出触发冲击波，所有存活核的稳定度都要减 $x$&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;稳定度归零自动湮灭&lt;/strong&gt;：扣血后可能有核直接消失，需要及时感知&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果直接遍历堆里所有元素逐个扣血，复杂度爆炸。关键洞察是：&lt;strong&gt;不需要真的扣血，只需要记录&quot;累计扣了多少&quot;&lt;/strong&gt;。每个核存入时记录登记稳定度 &lt;code&gt;registered_hp = hp + total_damage&lt;/code&gt;，之后任何时刻该核的真实稳定度 = &lt;code&gt;registered_hp - total_damage&lt;/code&gt;。这样全场扣血变成只更新一个全局变量。&lt;/p&gt;
&lt;p&gt;湮灭检测用最小堆解决：最小堆堆顶始终是登记稳定度最低的核，只要 &lt;code&gt;堆顶 &amp;lt;= total_damage&lt;/code&gt;，说明真实稳定度 ≤ 0，已经湮灭。两个堆同步维护同一批核，用 &lt;code&gt;deleted&lt;/code&gt; 数组做懒惰删除保持一致性。&lt;/p&gt;
&lt;p&gt;:::caution
&lt;strong&gt;操作顺序很关键&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;每轮流程必须是：&lt;strong&gt;入堆 → 清理湮灭 → 判断超载 → 排出最稳定核 → 触发冲击波 → 再次清理湮灭&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;特别是&quot;入堆后先清理再判超载&quot;这一步容易漏：新核入场可能恰好触发冲击波让其他核湮灭，如果不先清理就判超载，会产生&quot;假超载&quot;（以为超了 m 个但实际上已经有核湮灭了）。
:::&lt;/p&gt;
&lt;h3&gt;核心思路&lt;/h3&gt;
&lt;p&gt;典型的 &lt;strong&gt;贪心 + 双优先队列（双堆）+ 懒惰删除&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;维护一个&lt;strong&gt;最小堆&lt;/strong&gt;（随时监测谁的稳定度归零了）和一个&lt;strong&gt;最大堆&lt;/strong&gt;（用于寻找最稳定的能量核排出）&lt;/li&gt;
&lt;li&gt;每次有新能量核入堆后，先进行&quot;清理&quot;（因为可能有残血核刚好被之前的冲击波震碎，腾出位置）&lt;/li&gt;
&lt;li&gt;如果容量超载（&lt;code&gt;alive_cnt &amp;gt; m&lt;/code&gt;），从最大堆抓出最稳定的核排出&lt;/li&gt;
&lt;li&gt;排出触发冲击波（&lt;code&gt;total_damage += x&lt;/code&gt;），全场扣除稳定度，紧接着再次执行&quot;清理&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import sys
import heapq

def solve():
    input_data = sys.stdin.read().split()
    if not input_data: return

    n = int(input_data[0])
    m = int(input_data[1])
    x = int(input_data[2])
    a = [int(v) for v in input_data[3:]]

    min_heap = []
    max_heap = []
    deleted = [False] * n

    alive_cnt = 0
    total_damage = 0
    res = 0

    def clean_reactor():
        nonlocal alive_cnt
        # 只要最弱的核的登记稳定度 &amp;lt;= 累计冲击波伤害，说明其真实稳定度 &amp;lt;= 0
        while min_heap and min_heap[0][0] &amp;lt;= total_damage:
            _, dead_idx = heapq.heappop(min_heap)
            if not deleted[dead_idx]:
                deleted[dead_idx] = True
                alive_cnt -= 1

    for i in range(n):
        hp = a[i]
        registered_hp = hp + total_damage

        # 存入双堆，Python默认小顶堆，存最大堆要加负号
        heapq.heappush(min_heap, (registered_hp, i))
        heapq.heappush(max_heap, (-registered_hp, i))
        alive_cnt += 1

        clean_reactor()  # 入场先清理，防止&quot;假超载&quot;

        # 超载防御判定
        if alive_cnt &amp;gt; m:
            # 过滤掉最大堆顶部的&quot;湮灭幻影&quot;
            while max_heap and deleted[max_heap[0][1]]:
                heapq.heappop(max_heap)

            _, target_idx = heapq.heappop(max_heap)
            deleted[target_idx] = True
            alive_cnt -= 1
            res += 1

            total_damage += x   # 触发全场冲击波
            clean_reactor()     # 冲击波后二次清理

    print(res)

if __name__ == &quot;__main__&quot;:
    solve()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::important
&lt;strong&gt;registered_hp 的设计思路&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;每个核存入堆时记录的是 &lt;code&gt;hp + total_damage&lt;/code&gt;（登记稳定度），而不是真实稳定度。这样全场扣血时不需要遍历堆里所有元素，只需要更新 &lt;code&gt;total_damage&lt;/code&gt;。判断时用 &lt;code&gt;登记稳定度 - total_damage&lt;/code&gt; 还原真实稳定度，或直接用 &lt;code&gt;登记稳定度 &amp;lt;= total_damage&lt;/code&gt; 判断是否湮灭。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;题目二 · 拦截信号解码（中心扩展基础版）&lt;/h2&gt;
&lt;h3&gt;题目描述&lt;/h3&gt;
&lt;p&gt;情报部门截获了一段由小写字母 &lt;code&gt;a-z&lt;/code&gt; 组成的敌方密文信号。密码专家定义了一种&quot;伪回文&quot;信号段：如果以某个位置为中心，其左半部分的字符集合和右半部分的字符集合&lt;strong&gt;完全一致&lt;/strong&gt;（即左半边是右半边的字母异位词），那么这段信号就被认为是有效解码段。&lt;/p&gt;
&lt;p&gt;给定截获的信号字符串，请计算其中包含多少个有效解码段。&lt;/p&gt;
&lt;h3&gt;审题思路&lt;/h3&gt;
&lt;p&gt;这道题的核心难点是&quot;异位词判断&quot;而不是&quot;回文判断&quot;。普通回文要求左右镜像对称，而这道题只要求左右字符集合相同（各字符出现次数一致即可，顺序无所谓）。&lt;/p&gt;
&lt;p&gt;暴力枚举所有子串然后判断异位词是 O(N³)，太慢。关键观察是：&lt;strong&gt;以同一个点为中心，向外扩展时可以增量更新频次数组&lt;/strong&gt;，不需要每次重新统计。每扩展一步只是给左右各新增一个字符，O(1) 更新，然后 O(26) 比较，整体 O(26N²) 可以接受。&lt;/p&gt;
&lt;p&gt;:::caution
&lt;strong&gt;奇偶中心都要枚举&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;scan(i-1, i+1)&lt;/code&gt; 处理奇数长度（以 &lt;code&gt;s[i]&lt;/code&gt; 为中心），&lt;code&gt;scan(i, i+1)&lt;/code&gt; 处理偶数长度（以 &lt;code&gt;s[i]&lt;/code&gt; 和 &lt;code&gt;s[i+1]&lt;/code&gt; 之间为中心）。两个都要调用，漏掉偶数中心会少算很多结果。
:::&lt;/p&gt;
&lt;h3&gt;核心思路&lt;/h3&gt;
&lt;p&gt;使用最直观的 &lt;strong&gt;中心扩展法&lt;/strong&gt;。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;遍历字符串的每一个字符，将其作为中心点&lt;/li&gt;
&lt;li&gt;每次同时向左和向右扩展一位，分别统计左半部分和右半部分的字符频次&lt;/li&gt;
&lt;li&gt;直接对比左右两个频次数组（&lt;code&gt;countL == countR&lt;/code&gt;），如果相等，说明找到了一个有效解码段&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import sys

def solve():
    # 务必使用 strip() 去除末尾的换行符
    s = sys.stdin.read().strip()
    if not s:
        return

    n = len(s)
    res = 0

    # 基础中心扩展函数
    def scan(left: int, right: int) -&amp;gt; int:
        countL = [0] * 26
        countR = [0] * 26
        count = 0

        while left &amp;gt;= 0 and right &amp;lt; n:
            # 分别记录左右两边新增的字符
            countL[ord(s[left]) - ord(&apos;a&apos;)] += 1
            countR[ord(s[right]) - ord(&apos;a&apos;)] += 1

            # 直接比较两个列表是否完全一致
            if countL == countR:
                count += 1

            left -= 1
            right += 1

        return count

    # 主循环：遍历每一个可能的回文中心
    for i in range(n):
        res += 1               # 单个字符本身也算一个有效段
        res += scan(i-1, i+1)  # 奇数长度中心扩展
        res += scan(i, i+1)    # 偶数长度中心扩展

    print(res)

if __name__ == &quot;__main__&quot;:
    solve()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::note
&lt;strong&gt;&lt;code&gt;countL[ord(s[left]) - ord(&apos;a&apos;)] += 1&lt;/code&gt; 是什么意思&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这行代码的作用是把字符映射成 0-25 的下标，存入频次数组。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ord(s[left])&lt;/code&gt; 获取字符的 ASCII 码，比如 &lt;code&gt;&apos;a&apos;&lt;/code&gt; 是 97，&lt;code&gt;&apos;c&apos;&lt;/code&gt; 是 99。
减去 &lt;code&gt;ord(&apos;a&apos;)&lt;/code&gt;（即 97）之后，&lt;code&gt;&apos;a&apos;&lt;/code&gt; → 0，&lt;code&gt;&apos;b&apos;&lt;/code&gt; → 1，&lt;code&gt;&apos;c&apos;&lt;/code&gt; → 2，以此类推，26 个字母刚好对应下标 0-25。&lt;/p&gt;
&lt;p&gt;举例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s = &quot;acb&quot;
# left = 2，s[left] = &apos;b&apos;
ord(&apos;b&apos;) - ord(&apos;a&apos;)  # = 98 - 97 = 1
countL[1] += 1       # 下标 1 代表字母 &apos;b&apos;，出现次数 +1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 &lt;code&gt;countL[i]&lt;/code&gt; 表示左半部分第 &lt;code&gt;i&lt;/code&gt; 个字母（&lt;code&gt;chr(i + ord(&apos;a&apos;))&lt;/code&gt;）出现的次数。&lt;code&gt;countR&lt;/code&gt; 同理统计右半部分。
:::&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;为什么单个字符也算一个有效段&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;单个字符的左半部分和右半部分都是空集，空集 == 空集，满足条件，所以每个字符本身计 1 个。
:::&lt;/p&gt;
&lt;p&gt;:::note
&lt;strong&gt;如果遇到 TLE 怎么办&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;保底方案超时时，可以用差分数组优化：维护一个 &lt;code&gt;diff[26]&lt;/code&gt; 数组，&lt;code&gt;diff[c] += 1&lt;/code&gt; 表示左边比右边多一个字符 c，&lt;code&gt;diff[c] -= 1&lt;/code&gt; 表示右边多一个。当 &lt;code&gt;diff&lt;/code&gt; 全为零时左右相等。这样比较从 O(26) 降到 O(1)（只需维护一个&quot;非零计数器&quot;），整体降到 O(N²)。
:::&lt;/p&gt;
</content:encoded></item><item><title>3月26日刷题笔记--链表</title><link>https://www.yyylegend.com/posts/3%E6%9C%8826%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E9%93%BE%E8%A1%A8/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8826%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0%E9%93%BE%E8%A1%A8/</guid><description>链表核心模板整理，LeetCode 21 合并两个有序链表、23 合并K个升序链表、86 分隔链表题解</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;21&quot; title=&quot;Merge Two Sorted Lists&quot; zh=&quot;合并两个有序链表&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;23&quot; title=&quot;Merge k Sorted Lists&quot; zh=&quot;合并K个升序链表&quot; difficulty=&quot;hard&quot;}
::leetcode{id=&quot;86&quot; title=&quot;Partition List&quot; zh=&quot;分隔链表&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;链表基础概念&lt;/h1&gt;
&lt;h3&gt;链表 vs 数组&lt;/h3&gt;
&lt;p&gt;链表的每个节点在内存里是&lt;strong&gt;独立的对象&lt;/strong&gt;，彼此通过 &lt;code&gt;next&lt;/code&gt; 指针相连，不像数组那样连续存储。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数组：[1][2][3][4]  ← 内存连续，下标直接访问

链表：[1]→[2]→[3]→[4]→None  ← 靠指针串联，只能顺序访问
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
&lt;strong&gt;链表里所有变量都是指针（引用）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;list1&lt;/code&gt;、&lt;code&gt;list2&lt;/code&gt;、&lt;code&gt;dummy&lt;/code&gt;、&lt;code&gt;cur&lt;/code&gt; 这些变量存的都是节点的&lt;strong&gt;地址&lt;/strong&gt;，不是节点本身。修改 &lt;code&gt;cur.next&lt;/code&gt; 就是改那个节点里存的地址，不会复制节点。
:::&lt;/p&gt;
&lt;h3&gt;虚拟头节点（dummy node）&lt;/h3&gt;
&lt;p&gt;链表题几乎必用的技巧。创建一个值无意义的节点放在结果链表最前面，让所有节点都能用统一的方式处理，不需要对&quot;第一个节点&quot;做特殊判断。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dummy = ListNode(0)
cur = dummy
# ... 中间构建链表 ...
return dummy.next  # dummy 本身不算，从 dummy.next 开始才是真正的结果
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;链表通用模板&lt;/h1&gt;
&lt;h2&gt;模板① 构建 / 拼接链表&lt;/h2&gt;
&lt;p&gt;适用：合并链表、删除节点、重排链表&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dummy = ListNode(0)  # 虚拟头节点，值无意义，只是占个位
cur = dummy          # cur 是&quot;建造指针&quot;，始终指向结果链表的最后一个节点

while &amp;lt;根据题目决定终止条件&amp;gt;:
    cur.next = &amp;lt;某个节点或 ListNode(val)&amp;gt;  # 把新节点接到链表尾部
    cur = cur.next                         # cur 往后移，准备接下一个

# 注意：while 的终止条件每道题不同，不一定是 while head
# 例：合并两个链表 → while list1 and list2
# 例：合并K个（堆版）→ while h（堆不空）
# 例：遍历一条链表 → while head

return dummy.next  # dummy 是占位符，真正的结果从 dummy.next 开始
                   # 顺着 next 指针走就能访问整条链表
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;模板② 快慢指针&lt;/h2&gt;
&lt;p&gt;适用：找中点、找倒数第 N 个、判断环&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slow = head  # 慢指针，每次走一步
fast = head  # 快指针，每次走两步

while fast and fast.next:
    # fast and fast.next 两个条件都要判断：
    # fast        → 防止 fast 本身为 None 时报错
    # fast.next   → 防止 fast.next 为 None，下一步 fast.next.next 报错
    slow = slow.next       # 慢指针走一步
    fast = fast.next.next  # 快指针走两步

# 循环结束后：
# 奇数个节点 → slow 在正中间
# 偶数个节点 → slow 在中间偏右的那个
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;模板③ 反转链表&lt;/h2&gt;
&lt;p&gt;适用：反转整条或部分链表&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;prev = None  # prev 指向已反转部分的头，初始为 None（反转后末尾接 None）
cur = head   # cur 是当前处理的节点

while cur:
    nxt = cur.next   # 先保存 cur 的下一个节点，否则反转后就找不到了
    cur.next = prev  # 把 cur 的 next 指向前一个节点，完成反转
    prev = cur       # prev 往前移到 cur
    cur = nxt        # cur 往前移到原来保存的下一个节点

# 循环结束时 cur 为 None，prev 指向原链表最后一个节点，即新链表的头
return prev
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;模板④ 双指针（记录前驱）&lt;/h2&gt;
&lt;p&gt;适用：删除节点时需要记录前一个节点&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dummy = ListNode(0)  # 虚拟头节点，防止删除的是 head 节点时没有前驱
dummy.next = head
prev = dummy  # prev 始终指向 cur 的前一个节点
cur = head    # cur 是当前检查的节点

while cur:
    if &amp;lt;满足删除条件&amp;gt;:
        prev.next = cur.next  # 跳过 cur，直接把 prev 连到 cur 的下一个
                              # prev 不动，因为下一个节点还没检查
    else:
        prev = cur            # 不删除，prev 跟着往前移
    cur = cur.next            # cur 每轮都往前移

return dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::important
&lt;strong&gt;模板选择原则&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;本篇三道题都用模板①，区别只在中间逻辑：双指针比大小 / 排序后建链表 / 拆成两条链表再拼接。先把框架写出来，再想中间怎么填。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 21 · 合并两个有序链表&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;21&quot; title=&quot;Merge Two Sorted Lists&quot; zh=&quot;合并两个有序链表&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;双指针同时扫两条链表，每次把较小的节点接到结果链表后面，直到其中一条走完，把剩下的直接接上。&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;不需要创建新节点&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;直接把原节点重新串联，只改指针，不 &lt;code&gt;new&lt;/code&gt; 节点。空间复杂度 O(1)。
:::&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -&amp;gt; Optional[ListNode]:
        cur = dum = ListNode(0)
        while list1 and list2:
            if list1.val &amp;lt; list2.val:
                cur.next, list1 = list1, list1.next
            else:
                cur.next, list2 = list2, list2.next
            cur = cur.next
        cur.next = list1 if list1 else list2
        return dum.next
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cur.next, list1 = list1, list1.next&lt;/code&gt;&lt;/strong&gt; — Python 多重赋值，两侧同时求值，等价于：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cur.next = list1       # 把 list1 当前节点接到结果链表
list1 = list1.next     # list1 往后移一步
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;不需要手动断开旧连接&lt;/strong&gt;：下一轮 &lt;code&gt;cur.next = ...&lt;/code&gt; 写入新值时，旧的 &lt;code&gt;cur.next&lt;/code&gt; 自然被覆盖，不需要额外操作。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cur.next = list1 if list1 else list2&lt;/code&gt;&lt;/strong&gt; — 循环结束时必有一条链表先走完，剩下那条直接整体接上，因为它本身已经有序。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：模板① + 双指针，复用原节点&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(m+n)，空间 O(1)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 23 · 合并K个升序链表&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;23&quot; title=&quot;Merge k Sorted Lists&quot; zh=&quot;合并K个升序链表&quot; difficulty=&quot;hard&quot;}&lt;/p&gt;
&lt;h3&gt;思路一：排序（暴力）&lt;/h3&gt;
&lt;p&gt;把所有节点的值倒进数组，排序，再逐个建新节点。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -&amp;gt; Optional[ListNode]:
        nums = []
        dummy = ListNode(0)
        curr = dummy

        for p in lists:        # p 拿到的是每条链表的头节点
            while p:
                nums.append(p.val)
                p = p.next

        nums.sort()            # 注意：不是 nums = nums.sort()，后者返回 None

        for i in nums:
            curr.next = ListNode(i)   # 每个整数都要包装成新节点
            curr = curr.next

        return dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
&lt;strong&gt;为什么要 &lt;code&gt;ListNode(i)&lt;/code&gt; 而不是直接用 &lt;code&gt;i&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;nums&lt;/code&gt; 是普通 Python 列表，里面装的是整数。链表需要 &lt;code&gt;ListNode&lt;/code&gt; 对象，所以必须把每个整数重新包装成节点。这和 21 题不同——那题直接复用原节点，这里是全新建节点。
:::&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;常见 bug&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;nums = nums.sort()&lt;/code&gt; 会把 &lt;code&gt;nums&lt;/code&gt; 赋值为 &lt;code&gt;None&lt;/code&gt;，因为 &lt;code&gt;.sort()&lt;/code&gt; 原地排序，返回值是 &lt;code&gt;None&lt;/code&gt;。
正确写法是 &lt;code&gt;nums.sort()&lt;/code&gt;，直接修改原列表。
:::&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(N log N)，空间 O(N)，N 为所有节点总数&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;思路二：最小堆（进阶）&lt;/h3&gt;
&lt;p&gt;堆里始终只保留 K 个节点（每条链表的当前头），每次 pop 最小值、push 它的下一个节点，堆大小始终 ≤ K。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from heapq import heapify, heappop, heappush

ListNode.__lt__ = lambda a, b: a.val &amp;lt; b.val  # 让堆可以比较节点大小

class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -&amp;gt; Optional[ListNode]:
        cur = dummy = ListNode()
        h = [head for head in lists if head]  # 把所有非空链表的头节点入堆
        heapify(h)                             # 原地堆化，O(K)
        while h:
            node = heappop(h)          # 取出当前最小节点
            if node.next:
                heappush(h, node.next) # 把它的下一个节点补充进堆
            cur.next = node            # 直接复用原节点，不建新节点
            cur = cur.next
        return dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::important
&lt;strong&gt;&lt;code&gt;ListNode.__lt__ = lambda a, b: a.val &amp;lt; b.val&lt;/code&gt; 的作用&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;堆需要比较元素大小，Python 对整数天然支持 &lt;code&gt;&amp;lt;&lt;/code&gt;，但对 &lt;code&gt;ListNode&lt;/code&gt; 对象不知道怎么比，会报错：
&lt;code&gt;TypeError: &apos;&amp;lt;&apos; not supported between instances of &apos;ListNode&apos; and &apos;ListNode&apos;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;__lt__&lt;/code&gt; 是 Python 的魔法方法（less than），给 &lt;code&gt;ListNode&lt;/code&gt; 打补丁，告诉 Python 比节点就是比 &lt;code&gt;val&lt;/code&gt;。
因为 &lt;code&gt;ListNode&lt;/code&gt; 是题目给的无法修改，所以用这种从外部打补丁的方式加上去。
:::&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;heapq 三个核心函数&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;函数&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;时间复杂度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heapify(h)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;把普通列表原地变成最小堆&lt;/td&gt;
&lt;td&gt;O(K)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heappush(h, val)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;插入一个元素，自动维护堆结构&lt;/td&gt;
&lt;td&gt;O(log K)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heappop(h)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;取出并返回最小值，自动调整&lt;/td&gt;
&lt;td&gt;O(log K)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Python 的堆是&lt;strong&gt;最小堆&lt;/strong&gt;，&lt;code&gt;h[0]&lt;/code&gt; 可以直接查看堆顶（最小值）。
:::&lt;/p&gt;
&lt;h3&gt;两种思路对比&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;排序暴力&lt;/th&gt;
&lt;th&gt;最小堆&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;堆/数组大小&lt;/td&gt;
&lt;td&gt;O(N)，全部节点&lt;/td&gt;
&lt;td&gt;O(K)，K 条链表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;时间复杂度&lt;/td&gt;
&lt;td&gt;O(N log N)&lt;/td&gt;
&lt;td&gt;O(N log K)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;是否建新节点&lt;/td&gt;
&lt;td&gt;是，&lt;code&gt;ListNode(i)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;否，复用原节点&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;适用场景&lt;/td&gt;
&lt;td&gt;K 较大，N 较小&lt;/td&gt;
&lt;td&gt;K 远小于 N 时更优&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 86 · 分隔链表&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;86&quot; title=&quot;Partition List&quot; zh=&quot;分隔链表&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;同时维护&lt;strong&gt;两条链表&lt;/strong&gt;：&lt;code&gt;small&lt;/code&gt; 收集所有小于 x 的节点，&lt;code&gt;big&lt;/code&gt; 收集大于等于 x 的节点，最后把两条拼起来。相当于模板①用了两次。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def partition(self, head: Optional[ListNode], x: int) -&amp;gt; Optional[ListNode]:
        small_curr = small_dummy = ListNode(0)
        big_curr = big_dummy = ListNode(0)

        while head:
            if head.val &amp;lt; x:
                small_curr.next = head
                small_curr = small_curr.next
            else:
                big_curr.next = head
                big_curr = big_curr.next
            head = head.next

        small_curr.next = big_dummy.next  # 把 big 链接到 small 尾部
        big_curr.next = None              # 切断 big 链末尾的旧指针

        return small_dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;关键细节&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;big_curr.next = None&lt;/code&gt; 为什么必须写？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;节点是从原链表直接摘过来复用的，它们的 &lt;code&gt;next&lt;/code&gt; 还保留着原来的指向。比如原链表是 &lt;code&gt;1→4→3→2→5→2&lt;/code&gt;，节点 5 原来指着节点 2，如果不手动切断，结果链表末尾就会出现一个环，导致死循环。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;不切断的错误情况：
... → 4 → 3 → 5 → 2 → （又回到了前面的节点）  ← 形成环！

切断后正确：
... → 4 → 3 → 5 → None  ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：模板①用两次，拆分再合并&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n)，空间 O(1)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;三题横向对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题目&lt;/th&gt;
&lt;th&gt;中间逻辑&lt;/th&gt;
&lt;th&gt;是否建新节点&lt;/th&gt;
&lt;th&gt;特殊处理&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;21 合并两个链表&lt;/td&gt;
&lt;td&gt;双指针比大小&lt;/td&gt;
&lt;td&gt;否，复用原节点&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23 合并K个（排序版）&lt;/td&gt;
&lt;td&gt;收集所有值排序&lt;/td&gt;
&lt;td&gt;是，&lt;code&gt;ListNode(i)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nums.sort()&lt;/code&gt; 别写成 &lt;code&gt;nums = nums.sort()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23 合并K个（堆版）&lt;/td&gt;
&lt;td&gt;最小堆动态取最小&lt;/td&gt;
&lt;td&gt;否，复用原节点&lt;/td&gt;
&lt;td&gt;需要给 &lt;code&gt;ListNode&lt;/code&gt; 打补丁支持 &lt;code&gt;&amp;lt;&lt;/code&gt; 比较&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;86 分隔链表&lt;/td&gt;
&lt;td&gt;拆成两条再拼&lt;/td&gt;
&lt;td&gt;否，复用原节点&lt;/td&gt;
&lt;td&gt;末尾必须 &lt;code&gt;big_curr.next = None&lt;/code&gt; 防止成环&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;:::important
&lt;strong&gt;链表题万能框架&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dummy = ListNode(0)
cur = dummy
# ... 中间各种操作 ...
return dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不管中间逻辑怎么变，头尾这个框架几乎不变。先把框架写出来，再想中间填什么。
:::&lt;/p&gt;
</content:encoded></item><item><title>3月25日刷题笔记</title><link>https://www.yyylegend.com/posts/3%E6%9C%8825%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8825%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</guid><description>二叉树类型与性质总结，BFS 模板整理，LeetCode 662 二叉树最大宽度、111 二叉树的最小深度题解</description><pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;662&quot; title=&quot;Maximum Width of Binary Tree&quot; zh=&quot;二叉树最大宽度&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;111&quot; title=&quot;Minimum Depth of Binary Tree&quot; zh=&quot;二叉树的最小深度&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;常见二叉树类型与性质&lt;/h1&gt;
&lt;h3&gt;Complete Binary Tree 完全二叉树&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;每层从左到右填满，最后一层可以不满但必须靠左&lt;/li&gt;
&lt;li&gt;编号规律（从 1 开始）：左孩子 = 父 × 2，右孩子 = 父 × 2 + 1&lt;/li&gt;
&lt;li&gt;堆（heap）就是用数组实现的完全二叉树，利用的正是这套下标规律&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::important
&lt;strong&gt;完全二叉树编号规律是 LeetCode 662 的核心&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不管实际树长什么样，按完全二叉树规则给节点编号：左孩子 = 父 × 2，右孩子 = 父 × 2 + 1。同一层宽度 = 最右编号 − 最左编号 + 1，中间的空节点自动被计入。
:::&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;      1
    /   \
   2     3
  / \   /
 4   5 6

最后一层靠左排列，是完全二叉树 ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Perfect Binary Tree 满二叉树（中文叫法）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;每一层全部填满，节点总数恰好是 2ⁿ - 1&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;      1
    /   \
   2     3
  / \   / \
 4   5 6   7

每层全满，节点数 = 2³ - 1 = 7 ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
&lt;strong&gt;中英文差异&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;英文 Full Binary Tree ≠ 中文满二叉树。Full 在英文里指每个节点要么 0 个孩子要么 2 个孩子；中文满二叉树对应的英文是 Perfect Binary Tree。看英文资料时留意。
:::&lt;/p&gt;
&lt;h3&gt;Binary Search Tree (BST) 二叉搜索树&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;左子树所有节点 &amp;lt; 根，右子树所有节点 &amp;gt; 根，每棵子树也满足此性质&lt;/li&gt;
&lt;li&gt;查找、插入、删除平均 O(log n)，最坏退化成链表 O(n)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;      5
    /   \
   3     8
  / \   / \
 1   4 7   9

中序遍历：1 3 4 5 7 8 9  ← 严格升序 ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
&lt;strong&gt;刷题常用结论&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;BST 的中序遍历结果是升序序列，遇到&quot;验证 BST&quot;或&quot;第 K 小元素&quot;类题目优先想中序遍历。
:::&lt;/p&gt;
&lt;h3&gt;Balanced Binary Tree 平衡二叉树&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;任意节点的左右子树高度差不超过 1&lt;/li&gt;
&lt;li&gt;常见实现：AVL 树、红黑树&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;      3          3
    /   \          \
   2     4           4
  /                   \
 1                     5
                        \
                         6

左边：高度差最大为 1，是平衡树 ✓
右边：右侧一直延伸，退化成链表 ✗
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
&lt;strong&gt;刷题写法&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;判断是否平衡用后序遍历，返回子树高度，一旦高度差 &amp;gt; 1 就返回 -1 向上传递&quot;已不平衡&quot;的信号，避免重复计算。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 662 · 二叉树最大宽度&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;662&quot; title=&quot;Maximum Width of Binary Tree&quot; zh=&quot;二叉树最大宽度&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;普通 BFS 无法计算宽度，因为中间的空节点不在队列里，数不出来。&lt;/p&gt;
&lt;p&gt;关键想法：&lt;strong&gt;借用完全二叉树的编号规则给每个节点编号&lt;/strong&gt;。不管实际树长什么样，都按完全二叉树的规则假设编号存在。这样同一层最右节点编号 - 最左节点编号 + 1 就是这层宽度，中间跳过的空位自动被算进去。&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def widthOfBinaryTree(self, root: Optional[TreeNode]) -&amp;gt; int:
        if not root: return 0

        # 记录全局最大宽度
        ans = 0
        # 队列存 (编号, 节点)，根节点编号为 1
        # 编号规则和完全二叉树一样：左孩子=父*2，右孩子=父*2+1
        queue = deque([(1, root)])

        while queue:
            # 每轮处理一整层
            # 初始化为正无穷，保证第一个 code 一定能更新 min_seen
            min_seen = inf
            # 初始化为 0，保证第一个 code 一定能更新 max_seen
            max_seen = 0

            # len(queue) 是当前层的节点数，循环只处理这一层
            for i in range(len(queue)):
                code, node = queue.popleft()

                # 把下一层的子节点连同编号一起入队
                if node.left: queue.append((code * 2, node.left))
                if node.right: queue.append((code * 2 + 1, node.right))

                # 更新本层出现过的最小编号（最左节点）
                min_seen = min(code, min_seen)
                # 更新本层出现过的最大编号（最右节点）
                max_seen = max(code, max_seen)

            # 本层宽度 = 最右编号 - 最左编号 + 1
            # 中间跳过的空节点因为编号连续，自动被算进去了
            ans = max(ans, max_seen - min_seen + 1)

        return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：把额外信息（编号）打包进元组和节点一起入队，是 BFS 题的常见写法&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n)，空间 O(n)（队列最多存一层节点）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::tip
&lt;strong&gt;编号溢出问题&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;树很深时编号指数级增长，可以在每层开始时把所有编号减去本层 &lt;code&gt;min_seen&lt;/code&gt; 归一化，只保留相对差值。Python 整数不会溢出，但 Java / C++ 需要特别注意。
:::&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;&lt;code&gt;inf&lt;/code&gt; 和 &lt;code&gt;0&lt;/code&gt; 的初始化技巧&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;找最小值初始化为正无穷 &lt;code&gt;inf&lt;/code&gt;，找最大值初始化为 &lt;code&gt;0&lt;/code&gt;（或负无穷），保证第一个真实值一定能更新它，不需要额外判断&quot;是否是第一个元素&quot;。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;LeetCode 111 · 二叉树的最小深度&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;111&quot; title=&quot;Minimum Depth of Binary Tree&quot; zh=&quot;二叉树的最小深度&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;BFS 按层遍历，第一次遇到叶子节点时，当前层数就是最小深度，直接返回。&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;注意坑&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;最小深度不是单纯找最短路径，必须到达&lt;strong&gt;叶子节点&lt;/strong&gt;（左右都为空）才算终点。如果用递归直接取 &lt;code&gt;min(左深度, 右深度)&lt;/code&gt;，当某侧子树为空时会返回 0，导致结果偏小。BFS 天然规避了这个问题，遇到叶子才返回，不会被空子树干扰。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    1
   /
  2

递归错误写法：min(dfs(左)=1, dfs(右)=0) = 0，答案错误
BFS 正确：第 2 层遇到叶子节点 2，返回深度 2 ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

class Solution:
    def minDepth(self, root: Optional[TreeNode]) -&amp;gt; int:
        if not root:
            return 0

        queue = deque([root])  # 队列只存节点，不需要带额外信息
        depth = 0              # 记录当前层数

        while queue:
            depth += 1  # 进入新的一层，深度 +1

            for _ in range(len(queue)):  # 处理当前层所有节点
                node = queue.popleft()

                # 把下一层的子节点入队
                if node.left:
                    queue.append(node.left)
                if node.right:
                    queue.append(node.right)

                # 遇到叶子节点，当前 depth 就是最小深度，直接返回
                if not node.left and not node.right:
                    return depth

        return depth
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心技巧&lt;/strong&gt;：BFS 天然按层遍历，第一个叶子节点一定是最浅的，直接返回无需比较&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂度&lt;/strong&gt;：时间 O(n)，空间 O(n)（最坏情况队列存满一层）&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;BFS 通用模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def bfs(root):
    if not root: return 0       # 1. 边界处理

    queue = deque([root])       # 2. 初始化队列（根据题意决定队列里存什么）
    res = ...                   # 3. 初始化返回值

    while queue:                # 4. 外层循环：队列不空就继续
        layer_var = ...         #    每层开始前初始化层级变量

        for _ in range(len(queue)):  # 5. 内层循环：处理当前层所有节点
            node = queue.popleft()

            if node.left: queue.append(node.left)    # 6. 下一层入队
            if node.right: queue.append(node.right)

            # 7. 处理当前节点（更新层级变量，或提前 return）
            ...

        # 8. for 结束 = 这层处理完，更新返回值
        res = ...

    return res                  # 9. 返回结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::important
&lt;strong&gt;BFS 通用模板核心要点&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;外层 &lt;code&gt;while queue&lt;/code&gt; 控制层数，内层 &lt;code&gt;for _ in range(len(queue))&lt;/code&gt; 快照当前层节点数。队列里存什么、每层初始化什么、何时提前 return——这三点决定了模板怎么变形。
:::&lt;/p&gt;
&lt;h3&gt;两题对比&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;步骤&lt;/th&gt;
&lt;th&gt;662 最大宽度&lt;/th&gt;
&lt;th&gt;111 最小深度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;队列存的内容&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(编号, 节点)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;只存节点&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;每层初始化&lt;/td&gt;
&lt;td&gt;&lt;code&gt;min_seen = inf, max_seen = 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;无，&lt;code&gt;depth += 1&lt;/code&gt; 直接加&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;处理节点&lt;/td&gt;
&lt;td&gt;更新 &lt;code&gt;min_seen / max_seen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;检查是否叶子节点&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;每层结束后&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ans = max(ans, max_seen - min_seen + 1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;遇叶子提前 &lt;code&gt;return&lt;/code&gt;，否则继续&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;:::tip
&lt;strong&gt;看到什么题用 BFS&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;关键词含&lt;strong&gt;层、深度、宽度、距离、最近、最短&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;需要&lt;strong&gt;逐层处理&lt;/strong&gt;，或者找到&lt;strong&gt;第一个满足条件&lt;/strong&gt;的节点就返回&lt;/li&gt;
&lt;li&gt;题目涉及&lt;strong&gt;从根往下扩散&lt;/strong&gt;的过程
:::&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::tip
&lt;strong&gt;队列里存什么&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;默认只存节点。如果需要额外信息（编号、步数、路径等），就把信息和节点打包成元组一起入队，弹出时解包使用。
:::&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;提前 return vs 处理完再 return&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;找&lt;strong&gt;最小/最近&lt;/strong&gt;：在 for 循环内遇到条件立刻 &lt;code&gt;return&lt;/code&gt;，不用等这层全部处理完&lt;/li&gt;
&lt;li&gt;找&lt;strong&gt;最大/统计全部&lt;/strong&gt;：等 for 循环结束（整层处理完）再更新结果&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::&lt;/p&gt;
</content:encoded></item><item><title>3月24日刷题笔记</title><link>https://www.yyylegend.com/posts/3%E6%9C%8824%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8824%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</guid><description>二叉树四种遍历（前序/中序/后序/层序）及最大深度，含示例与对比总结</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;144&quot; title=&quot;Binary Tree Preorder Traversal&quot; zh=&quot;二叉树的前序遍历&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;94&quot; title=&quot;Binary Tree Inorder Traversal&quot; zh=&quot;二叉树的中序遍历&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;145&quot; title=&quot;Binary Tree Postorder Traversal&quot; zh=&quot;二叉树的后序遍历&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;102&quot; title=&quot;Binary Tree Level Order Traversal&quot; zh=&quot;二叉树的层序遍历&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;104&quot; title=&quot;Maximum Depth of Binary Tree&quot; zh=&quot;二叉树的最大深度&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;基础结构&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例树（以下四种遍历都用这棵树演示）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        1
       / \
      2   3
     / \
    4   5
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;一、144. 前序遍历（Preorder）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;144&quot; title=&quot;Binary Tree Preorder Traversal&quot; zh=&quot;二叉树的前序遍历&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;根 → 左 → 右&lt;/strong&gt;。先记录当前节点，再递归处理左子树，最后递归处理右子树。&lt;/p&gt;
&lt;h3&gt;示例&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;访问顺序：
  1. 记录根 1
  2. 进入左子树，记录 2
  3. 继续进入左子树，记录 4
  4. 4 没有子节点，回退
  5. 进入 2 的右子树，记录 5
  6. 5 没有子节点，回退
  7. 进入根的右子树，记录 3

结果：[1, 2, 4, 5, 3]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def preorderTraversal(self, root: Optional[TreeNode]) -&amp;gt; List[int]:
        res = []

        def helper(root):
            if not root: return
            res.append(root.val)  # 根
            helper(root.left)     # 左
            helper(root.right)    # 右

        helper(root)
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
前序遍历常用于&lt;strong&gt;复制一棵树&lt;/strong&gt;或&lt;strong&gt;序列化树结构&lt;/strong&gt;，因为先输出根节点，重建时能先确定父节点再确定子节点。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;二、94. 中序遍历（Inorder）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;94&quot; title=&quot;Binary Tree Inorder Traversal&quot; zh=&quot;二叉树的中序遍历&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;左 → 根 → 右&lt;/strong&gt;。先递归处理左子树，再记录当前节点，最后递归处理右子树。&lt;/p&gt;
&lt;h3&gt;示例&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;访问顺序：
  1. 一路往左走到 4
  2. 4 没有左子树，记录 4
  3. 回退，记录 2
  4. 进入 2 的右子树，记录 5
  5. 回退到根，记录 1
  6. 进入右子树，记录 3

结果：[4, 2, 5, 1, 3]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def inorderTraversal(self, root: Optional[TreeNode]) -&amp;gt; List[int]:
        res = []

        def helper(root):
            if not root: return
            helper(root.left)     # 左
            res.append(root.val)  # 根
            helper(root.right)    # 右

        helper(root)
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
对&lt;strong&gt;二叉搜索树（BST）做中序遍历，结果一定是升序排列&lt;/strong&gt;的。很多 BST 相关的题（验证 BST、BST 第 k 小的元素等）都会用到这个性质。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;三、145. 后序遍历（Postorder）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;145&quot; title=&quot;Binary Tree Postorder Traversal&quot; zh=&quot;二叉树的后序遍历&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;左 → 右 → 根&lt;/strong&gt;。先递归处理左子树，再递归处理右子树，最后记录当前节点。&lt;/p&gt;
&lt;h3&gt;示例&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;访问顺序：
  1. 一路往左走到 4
  2. 4 没有子节点，记录 4
  3. 回退到 2，进入右子树
  4. 5 没有子节点，记录 5
  5. 左右都处理完，记录 2
  6. 回退到根，进入右子树
  7. 3 没有子节点，记录 3
  8. 左右都处理完，记录根 1

结果：[4, 5, 2, 3, 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def postorderTraversal(self, root: Optional[TreeNode]) -&amp;gt; List[int]:
        res = []

        def helper(root):
            if not root: return
            helper(root.left)     # 左
            helper(root.right)    # 右
            res.append(root.val)  # 根

        helper(root)
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
后序遍历常用于&lt;strong&gt;删除树&lt;/strong&gt;或&lt;strong&gt;计算目录大小&lt;/strong&gt;的场景——必须先处理完所有子节点，才能处理父节点。比如删除文件夹时，要先删里面的文件，再删文件夹本身。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;四、102. 层序遍历（Level Order）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;102&quot; title=&quot;Binary Tree Level Order Traversal&quot; zh=&quot;二叉树的层序遍历&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;从上到下，从左到右&lt;/strong&gt;，一层一层地访问。用 BFS（队列）实现：把根放入队列，每次取出一个节点就把它的左右子节点入队，直到队列为空。&lt;/p&gt;
&lt;h3&gt;示例&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;第 1 层：[1]
第 2 层：[2, 3]
第 3 层：[4, 5]

结果：[[1], [2, 3], [4, 5]]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;p&gt;:::caution
&lt;strong&gt;空节点判断必须写在最前面&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;和递归遍历不同，BFS 需要在函数入口处先判断 &lt;code&gt;if not root: return []&lt;/code&gt;。如果不判断直接 &lt;code&gt;deque([root])&lt;/code&gt;，会把 &lt;code&gt;None&lt;/code&gt; 入队，后面取 &lt;code&gt;node.left/node.right&lt;/code&gt; 就会报错。
:::&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

class Solution:
    def levelOrder(self, root: Optional[TreeNode]) -&amp;gt; List[List[int]]:
        if not root: return []

        res = []
        # 先让根节点入队
        queue = deque([root])

        while queue: # 当队列不为空的时候 队列为空的时候代表我们把所有层遍历完了 因为在处理完某一层的时候 我们已经把下一层的左右节点（如果有的话）提前放进队列中了 所以len(queue)是有值的
            # 每次遍历要创建当前层的列表
            level = []
            for i in range(len(queue)):  # 每次只处理当前层的节点 这一层有多少个节点for循环就执行多少次
                # 从队列左边弹出 要先弹出才能获取node的val 我们才能将这个节点加入到这层的level列表里面
                node = queue.popleft()
                # 随后加入到此层的列表里面
                level.append(node.val)
                # 如果这个节点的左右还有孩子的话就加入队列
                if node.left: queue.append(node.left)
                if node.right: queue.append(node.right)
            # 当for循环退出的时候 代表这一层已经处理完了 所以这时将当前层的level列表加入到总的大res列表中 [[],[],[]]
            res.append(level)

        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
入队前先判断 &lt;code&gt;if node.left&lt;/code&gt; 和 &lt;code&gt;if node.right&lt;/code&gt;，确保队列里只放有效节点，避免把大量 &lt;code&gt;None&lt;/code&gt; 堆进队列浪费空间。这和递归遍历的习惯不同——递归是进去再判断，BFS 是入队前就过滤。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;五、104. 二叉树最大深度（Maximum Depth）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;104&quot; title=&quot;Maximum Depth of Binary Tree&quot; zh=&quot;二叉树的最大深度&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;h3&gt;思路&lt;/h3&gt;
&lt;p&gt;一个节点的最大深度 = 左右子树深度的较大值 + 1。用递归实现：空节点深度为 0，每向上返回一层就加 1。&lt;/p&gt;
&lt;h3&gt;示例&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;        1          ← 深度 3
       / \
      2   3        ← 深度 2
     / \
    4   5          ← 深度 1

maxDepth(4) = 1
maxDepth(5) = 1
maxDepth(2) = 1 + max(1, 1) = 2
maxDepth(3) = 1
maxDepth(1) = 1 + max(2, 1) = 3
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;代码&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def maxDepth(self, root: Optional[TreeNode]) -&amp;gt; int:
        if not root: return 0  # 空节点深度为 0

        left = self.maxDepth(root.left)   # 左子树深度
        right = self.maxDepth(root.right) # 右子树深度

        return 1 + max(left, right)       # 当前节点深度 = 较深的子树 + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
注意空节点要返回 &lt;code&gt;0&lt;/code&gt;（整数），不能返回 &lt;code&gt;[]&lt;/code&gt;。因为后面要做 &lt;code&gt;1 + max(left, right)&lt;/code&gt;，&lt;code&gt;max&lt;/code&gt; 接收的必须是数字，传入列表会报错。返回值的类型要和题目要求一致。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;总结对比&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;        1
       / \
      2   3
     / \
    4   5

前序（根左右）：[1, 2, 4, 5, 3]
中序（左根右）：[4, 2, 5, 1, 3]
后序（左右根）：[4, 5, 2, 3, 1]
层序（逐层）：  [[1], [2, 3], [4, 5]]
最大深度：      3
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题目&lt;/th&gt;
&lt;th&gt;思路&lt;/th&gt;
&lt;th&gt;实现&lt;/th&gt;
&lt;th&gt;典型用途&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;前序遍历&lt;/td&gt;
&lt;td&gt;根→左→右&lt;/td&gt;
&lt;td&gt;递归&lt;/td&gt;
&lt;td&gt;复制树、序列化&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;中序遍历&lt;/td&gt;
&lt;td&gt;左→根→右&lt;/td&gt;
&lt;td&gt;递归&lt;/td&gt;
&lt;td&gt;BST 升序输出&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;后序遍历&lt;/td&gt;
&lt;td&gt;左→右→根&lt;/td&gt;
&lt;td&gt;递归&lt;/td&gt;
&lt;td&gt;删除树、计算大小&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;层序遍历&lt;/td&gt;
&lt;td&gt;逐层从左到右&lt;/td&gt;
&lt;td&gt;BFS 队列&lt;/td&gt;
&lt;td&gt;求树的高度、锯齿遍历&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最大深度&lt;/td&gt;
&lt;td&gt;左右子树深度较大值 + 1&lt;/td&gt;
&lt;td&gt;递归&lt;/td&gt;
&lt;td&gt;判断平衡树、路径问题&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;记忆口诀：&lt;strong&gt;前中后指的是根的位置&lt;/strong&gt;，左永远在右前面。&lt;/p&gt;
</content:encoded></item><item><title>3月23日刷题笔记</title><link>https://www.yyylegend.com/posts/3%E6%9C%8823%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8823%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</guid><description>字符串加法模拟、螺旋矩阵、最长回文子串（中心扩展 + Manacher）</description><pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;415&quot; title=&quot;Add Strings&quot; zh=&quot;字符串相加&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;54&quot; title=&quot;Spiral Matrix&quot; zh=&quot;螺旋矩阵&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;5&quot; title=&quot;Longest Palindromic Substring&quot; zh=&quot;最长回文子串&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;一、415. 字符串相加&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;415&quot; title=&quot;Add Strings&quot; zh=&quot;字符串相加&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;思路：&lt;/strong&gt; 模拟竖式加法，从个位开始往高位算，每一位相加后记录进位。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;对齐个位，从右往左：用 &lt;code&gt;i&lt;/code&gt;、&lt;code&gt;j&lt;/code&gt; 两个指针分别指向两个字符串末尾，每轮往左移一位&lt;/li&gt;
&lt;li&gt;每一位做三件事：取出当前位的数字 → 加起来（含进位）→ 本位写入结果，进位留给下一轮&lt;/li&gt;
&lt;li&gt;循环结束检查进位：如果最后还有进位（比如 999+1），在结果最前面补一个 &lt;code&gt;&quot;1&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def addStrings(self, num1: str, num2: str) -&amp;gt; str:
        res = &quot;&quot;
        i, j, carry = len(num1) - 1, len(num2) - 1, 0

        # 只要其中一个指针还没有跳到边界外就继续执行这个循环
        while i &amp;gt;= 0 or j &amp;gt;= 0:
            n1 = int(num1[i]) if i &amp;gt;= 0 else 0
            n2 = int(num2[j]) if j &amp;gt;= 0 else 0

            tmp = n1 + n2 + carry  # 实际加起来得到的值
            carry = tmp // 10      # 用 // 得到要进位的数字，比如 9+5=14 写 4 进 1
            res = str(tmp % 10) + res  # tmp%10 是实际写在竖式下面的值

            i -= 1
            j -= 1

        # 如果两个指针都走完了且 carry 不等于 0，在最前面补 &quot;1&quot;
        return &apos;1&apos; + res if carry else res
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::caution
&lt;strong&gt;注意点&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;i&lt;/code&gt;、&lt;code&gt;j&lt;/code&gt; 从末尾往左移，模拟从个位开始的竖式加法&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tmp // 10&lt;/code&gt; 取进位，&lt;code&gt;tmp % 10&lt;/code&gt; 取本位&lt;/li&gt;
&lt;li&gt;新结果拼在 &lt;code&gt;res&lt;/code&gt; 前面，因为是从低位往高位算的&lt;/li&gt;
&lt;li&gt;&lt;code&gt;if carry&lt;/code&gt; 等价于 &lt;code&gt;if carry != 0&lt;/code&gt;，数字 0 在 Python 中为 False
:::&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::tip
&lt;code&gt;//&lt;/code&gt; 和 &lt;code&gt;%&lt;/code&gt; 常用技巧&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;a = (a // b) * b + (a % b)   # 验算公式

n // 10       # 去掉个位
n % 10        # 取个位
n % 2         # 判断奇偶（0偶1奇）
i % len(arr)  # 循环索引，超出边界自动绕回
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;二、54. 螺旋矩阵&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;54&quot; title=&quot;Spiral Matrix&quot; zh=&quot;螺旋矩阵&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;思路：&lt;/strong&gt; 用 &lt;code&gt;top&lt;/code&gt;、&lt;code&gt;bottom&lt;/code&gt;、&lt;code&gt;left&lt;/code&gt;、&lt;code&gt;right&lt;/code&gt; 四条边界标记还没读过的区域，顺时针依次读完每条边后把对应边界往内收缩一格，不断循环直到区域为空。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def spiralOrder(self, matrix: List[List[int]]) -&amp;gt; List[int]:
        if not matrix or not matrix[0]:
            return []

        res = []

        # 初始化四个边界指针
        top, bottom = 0, len(matrix) - 1
        left, right = 0, len(matrix[0]) - 1

        while top &amp;lt;= bottom and left &amp;lt;= right:

            # 1. 上边：从左到右
            for j in range(left, right + 1):
                res.append(matrix[top][j])
            top += 1

            # 2. 右边：从上到下
            for i in range(top, bottom + 1):
                res.append(matrix[i][right])
            right -= 1

            # 3. 下边：从右到左（需要判断 top &amp;lt;= bottom，防止重复读）
            if top &amp;lt;= bottom:
                for j in range(right, left - 1, -1):
                    res.append(matrix[bottom][j])
                bottom -= 1

            # 4. 左边：从下到上（需要判断 left &amp;lt;= right，防止重复读）
            if left &amp;lt;= right:
                for i in range(bottom, top - 1, -1):
                    res.append(matrix[i][left])
                left += 1

        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::caution
&lt;strong&gt;注意点&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;读完上边和右边后，&lt;code&gt;top&lt;/code&gt; 和 &lt;code&gt;right&lt;/code&gt; 已经改变，&lt;code&gt;while&lt;/code&gt; 不会在中途重新检查，所以读下边和左边前需要手动 &lt;code&gt;if&lt;/code&gt; 判断，防止单行或单列时重复读&lt;/li&gt;
&lt;li&gt;反向循环 &lt;code&gt;range(right, left-1, -1)&lt;/code&gt; 中 stop 写 &lt;code&gt;left-1&lt;/code&gt;，是因为 &lt;code&gt;range&lt;/code&gt; 取不到 stop，多减一才能让 &lt;code&gt;left&lt;/code&gt; 本身被包含&lt;/li&gt;
&lt;li&gt;四个方向：上边 &lt;code&gt;j&lt;/code&gt; 增，右边 &lt;code&gt;i&lt;/code&gt; 增，下边 &lt;code&gt;j&lt;/code&gt; 减，左边 &lt;code&gt;i&lt;/code&gt; 减
:::&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;三、5. 最长回文子串&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;5&quot; title=&quot;Longest Palindromic Substring&quot; zh=&quot;最长回文子串&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;h3&gt;思路一：中心扩展 O(n²)&lt;/h3&gt;
&lt;p&gt;以每个字符为中心向两边扩展，奇偶回文分开处理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def longestPalindrome(self, s: str) -&amp;gt; str:
        n = len(s)
        ans_left = ans_right = 0

        for i in range(n):
            # 设置两个指针
            # 左指针向左走 右指针向右走
            l = r = i
            # 只要左右指针没越界，同时左指针指向的元素等于右指针指向元素的时候就循环
            while l &amp;gt;= 0 and r &amp;lt; n and s[l] == s[r]:
                l -= 1
                r += 1

            # 注意这时候跳出循环了，证明左右指针现在指向的两个元素不相同了
            # 那么现在这两个指针的上一个位置形成的区间就是一个回文串
            # 所以左指针 l 要 +1，右指针 r 要 -1，然后判断回文串是否比之前记录的长
            # 当前回文的长度是 (r-1) - (l+1) + 1 = r - l - 1
            if r - l - 1 &amp;gt; ans_right - ans_left:
                ans_left, ans_right = l + 1, r

        # 这里要n-1 因为r是从i+1开始的
        for i in range(n - 1):      # 偶回文
            l, r = i, i + 1
            while l &amp;gt;= 0 and r &amp;lt; n and s[l] == s[r]:
                l -= 1
                r += 1
            # 当前回文的长度是 (r-1) - (l+1) + 1 = r - l - 1
            if r - l - 1 &amp;gt; ans_right - ans_left:
                ans_left, ans_right = l + 1, r

        return s[ans_left: ans_right]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
循环结束时 &lt;code&gt;l&lt;/code&gt; 和 &lt;code&gt;r&lt;/code&gt; 各多走了一步，真正的回文是 &lt;code&gt;s[l+1..r-1]&lt;/code&gt;，长度为 &lt;code&gt;r - l - 1&lt;/code&gt;。用左闭右开区间 &lt;code&gt;[l+1, r)&lt;/code&gt; 存储，最后 &lt;code&gt;s[ans_left:ans_right]&lt;/code&gt; 直接切片。
:::&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;奇回文示例：&lt;/strong&gt; 以 &lt;code&gt;&quot;babad&quot;&lt;/code&gt; 为例，&lt;code&gt;i=2&lt;/code&gt;，中心是 &lt;code&gt;&apos;b&apos;&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;扩展过程：
  l=2, r=2  →  s[2]=&apos;b&apos; == s[2]=&apos;b&apos;  ✓  继续
  l=1, r=3  →  s[1]=&apos;a&apos; == s[3]=&apos;a&apos;  ✓  继续
  l=0, r=4  →  s[0]=&apos;b&apos; != s[4]=&apos;d&apos;  ✗  停止

停止后：l=0, r=4

  b  a  b  a  d
  ↑           ↑
  l           r   ← 这两个已经不匹配了，各多走了一步

真正的回文是 l+1 到 r-1：
  b  a  b  a  d
     ↑     ↑
    l+1   r-1  →  &quot;aba&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;偶回文示例：&lt;/strong&gt; &lt;code&gt;s = &quot;abba&quot;&lt;/code&gt;，&lt;code&gt;n=4&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i=0: l=0,r=1  s[0]=&apos;a&apos; != s[1]=&apos;b&apos;  扩展失败，长度 2
i=1: l=1,r=2  s[1]=&apos;b&apos; == s[2]=&apos;b&apos;  继续扩展
     l=0,r=3  s[0]=&apos;a&apos; == s[3]=&apos;a&apos;  继续扩展
     l=-1,r=4 越界，停止
     长度 = r - l - 1 = 4 - (-1) - 1 = 4  → &quot;abba&quot;
i=2: l=2,r=3  s[2]=&apos;b&apos; != s[3]=&apos;a&apos;  扩展失败，长度 2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;思路二：Manacher 算法 O(n)&lt;/h3&gt;
&lt;p&gt;利用回文的对称性，避免重复计算，把时间复杂度从 O(n²) 降到 O(n)。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心步骤：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;插入 &lt;code&gt;#&lt;/code&gt; 消除奇偶差异：&lt;code&gt;s = &quot;bab&quot;&lt;/code&gt; → &lt;code&gt;t = &quot;^#b#a#b#$&quot;&lt;/code&gt;，所有回文都变成奇回文&lt;/li&gt;
&lt;li&gt;&lt;code&gt;half_len[i]&lt;/code&gt; 记录以 &lt;code&gt;t[i]&lt;/code&gt; 为中心的最长回文半径&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;box_m&lt;/code&gt;（当前右边界最远的回文中心）和 &lt;code&gt;box_r&lt;/code&gt;（其右边界）复用已知结果&lt;/li&gt;
&lt;li&gt;每次扩展都会让 &lt;code&gt;box_r&lt;/code&gt; 右移，总扩展次数为 O(n)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;:::important
&lt;strong&gt;Manacher 时间复杂度为 O(n) 的原因&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;box_r&lt;/code&gt; 只会单调右移，每次 &lt;code&gt;while&lt;/code&gt; 扩展都让 &lt;code&gt;box_r&lt;/code&gt; 增加，所以整个算法的总扩展次数不超过 &lt;code&gt;len(t)&lt;/code&gt;，即 O(n)。利用镜像对称性跳过已知结果是关键。
:::&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def longestPalindrome(self, s: str) -&amp;gt; str:

        # 第一步：改造字符串，消除奇偶差异
        # 在每个字符之间插入 #，首尾加哨兵 ^ 和 $
        # 例：s = &quot;bab&quot; → t = &quot;^#b#a#b#$&quot;
        t = &quot;#&quot;.join(&quot;^&quot; + s + &quot;$&quot;)

        # 第二步：初始化 half_len 数组
        # half_len[i] = 以 t[i] 为中心的最长回文的半径
        half_len = [0] * (len(t) - 2)
        half_len[1] = 1

        # 第三步：初始化 box
        # box_m = 右边界最靠右的回文中心
        # box_r = 该回文的右边界（不含）
        box_m = box_r = max_i = 0

        # 第四步：遍历每个位置，计算 half_len[i]
        for i in range(2, len(half_len)):
            hl = 1

            if i &amp;lt; box_r:
                # i 在 box 内，利用镜像对称性初始化
                # i&apos; = box_m * 2 - i（i 关于 box_m 的镜像）
                hl = min(half_len[box_m * 2 - i], box_r - i)

            # 暴力扩展，哨兵保证不越界
            while t[i - hl] == t[i + hl]:
                hl += 1
                box_m, box_r = i, i + hl

            half_len[i] = hl

            if hl &amp;gt; half_len[max_i]:
                max_i = i

        # 第五步：还原回 s 的下标
        hl = half_len[max_i]
        return s[(max_i - hl) // 2 : (max_i + hl) // 2 - 1]
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>3月22日刷题笔记</title><link>https://www.yyylegend.com/posts/3%E6%9C%8822%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8822%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</guid><description>二叉树递归思想、BFS层序遍历模板及常见树题套路总结</description><pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;102&quot; title=&quot;Binary Tree Level Order Traversal&quot; zh=&quot;二叉树的层序遍历&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;617&quot; title=&quot;Merge Two Binary Trees&quot; zh=&quot;合并二叉树&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;一、理解递归的核心思想&lt;/h2&gt;
&lt;p&gt;不要试图在脑子里追踪每一层调用，只想当前节点该做什么，子树的事交给递归。&lt;/p&gt;
&lt;p&gt;:::important
&lt;strong&gt;递归三步法&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;base case&lt;/strong&gt; → 空节点返回什么？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;当前层逻辑&lt;/strong&gt; → 这个节点做什么？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;组合结果&lt;/strong&gt; → 用左右子树的结果拼出当前结果
:::&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;万能模板：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solve(root):
    if not root:          # 1. base case
        return ...

    do_something(root)    # 2. 当前节点逻辑

    left  = solve(root.left)   # 3. 交给递归
    right = solve(root.right)

    return combine(root, left, right)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;二、102. 二叉树的层序遍历（BFS）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;102&quot; title=&quot;Binary Tree Level Order Traversal&quot; zh=&quot;二叉树的层序遍历&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;p&gt;核心：用 &lt;code&gt;deque&lt;/code&gt;，右边进左边出，每轮用 &lt;code&gt;len(queue)&lt;/code&gt; 快照当前层节点数&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def levelOrder(root):
    if not root:
        return []

    result = []
    queue = deque([root])       # 初始化：根节点入队

    while queue:
        level_size = len(queue) # 快照当前层节点数
        level = []

        for _ in range(level_size):   # _ 表示不需要循环变量
            node = queue.popleft()    # O(1) 头部取出
            level.append(node.val)
            if node.left:  queue.append(node.left)
            if node.right: queue.append(node.right)

        result.append(level)

    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::tip
为什么用 &lt;code&gt;deque&lt;/code&gt; 不用 &lt;code&gt;list&lt;/code&gt;？
&lt;code&gt;list.pop(0)&lt;/code&gt; 是 O(n)，因为要把后面所有元素往前移；&lt;code&gt;deque.popleft()&lt;/code&gt; 是 O(1)，专门为队列设计。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;三、617. 合并二叉树（递归典型题）&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;617&quot; title=&quot;Merge Two Binary Trees&quot; zh=&quot;合并二叉树&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;p&gt;思路：只想当前节点，两树都有就值相加，有一个为空就返回另一个&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def mergeTrees(root1, root2):
    if not root1: return root2   # 一棵为空，返回另一棵
    if not root2: return root1

    node = TreeNode(root1.val + root2.val)  # 当前节点相加
    node.left  = mergeTrees(root1.left,  root2.left)   # 交给递归
    node.right = mergeTrees(root1.right, root2.right)
    return node
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;四、常见语法速查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# deque 初始化
queue = deque([root])     # 等价于 deque() + append(root)
queue = deque([1, 2, 3])  # 用列表初始化多个元素

# _ 占位符：只想循环 N 次，不需要循环变量
for _ in range(n):
    ...

# 判空写法（三种等价，推荐 is None 最精确）
if not root:        # 最简洁，常用
if root is None:    # 最精确，推荐
if root == None:    # 不推荐（可被 __eq__ 覆盖）
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>3月21日刷题笔记</title><link>https://www.yyylegend.com/posts/3%E6%9C%8821%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://www.yyylegend.com/posts/3%E6%9C%8821%E6%97%A5%E5%88%B7%E9%A2%98%E7%AC%94%E8%AE%B0/</guid><description>滑动窗口、链表经典题型总结，含模板代码与解题要点</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;本篇题目&lt;/h2&gt;
&lt;p&gt;::leetcode{id=&quot;3&quot; title=&quot;Longest Substring Without Repeating Characters&quot; zh=&quot;无重复字符的最长子串&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;438&quot; title=&quot;Find All Anagrams in a String&quot; zh=&quot;找到字符串中所有字母异位词&quot; difficulty=&quot;medium&quot;}
::leetcode{id=&quot;206&quot; title=&quot;Reverse Linked List&quot; zh=&quot;反转链表&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;141&quot; title=&quot;Linked List Cycle&quot; zh=&quot;环形链表&quot; difficulty=&quot;easy&quot;}
::leetcode{id=&quot;146&quot; title=&quot;LRU Cache&quot; zh=&quot;LRU 缓存机制&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;一、滑动窗口&lt;/h2&gt;
&lt;p&gt;适用场景：子串 / 子数组问题，要求连续，窗口内维护某种状态&lt;/p&gt;
&lt;p&gt;万能模板：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;window = set()   # 记录窗口内的元素
l = 0

for r in range(len(s)):
    # 1. 窗口不合法就收缩左边
    while 窗口不合法:
        window.remove(s[l])
        l += 1

    # 2. 右边扩张，加入新元素
    window.add(s[r])

    # 3. 更新答案
    update(result)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1. 无重复字符的最长子串&lt;/h3&gt;
&lt;p&gt;::leetcode{id=&quot;3&quot; title=&quot;Longest Substring Without Repeating Characters&quot; zh=&quot;无重复字符的最长子串&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要点：窗口内不能有重复字符，有重复就移动 left 直到合法&lt;/li&gt;
&lt;li&gt;答案：每轮更新 &lt;code&gt;max(result, right - left + 1)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def lengthOfLongestSubstring(self, s: str) -&amp;gt; int:
        window = set()
        max_len = 0
        l = 0

        for r in range(len(s)):
            while s[r] in window:   # 有重复就收缩左边
                window.remove(s[l])
                l += 1

            window.add(s[r])
            max_len = max(max_len, r - l + 1)

        return max_len
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;438. 找到字符串中所有字母异位词&lt;/h3&gt;
&lt;p&gt;::leetcode{id=&quot;438&quot; title=&quot;Find All Anagrams in a String&quot; zh=&quot;找到字符串中所有字母异位词&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要点：固定窗口大小 = &lt;code&gt;len(p)&lt;/code&gt;，每个窗口排序后和 &lt;code&gt;p&lt;/code&gt; 比较&lt;/li&gt;
&lt;li&gt;复杂度：O(m·n·log n)，每个窗口都排序，数据量大时较慢&lt;/li&gt;
&lt;li&gt;优化方向：改用 Counter 或频次数组比较，可降到 O(m)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def findAnagrams(self, s: str, p: str) -&amp;gt; List[int]:
        p = sorted(p)
        m, n = len(s), len(p)
        ans = []

        if m &amp;lt; n:
            return ans

        for i in range(m - n + 1):
            if sorted(s[i: i+n]) == p:   # 每个窗口排序比较
                ans.append(i)

        return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;二、链表&lt;/h2&gt;
&lt;p&gt;:::important
&lt;strong&gt;链表题核心：画图！搞清楚指针指向再写代码&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;动手写代码前先在纸上或脑子里把每个指针的变化画清楚，否则极容易断链或死循环。
:::&lt;/p&gt;
&lt;p&gt;链表题核心：画图！搞清楚指针指向再写代码&lt;/p&gt;
&lt;h3&gt;206. 反转链表&lt;/h3&gt;
&lt;p&gt;::leetcode{id=&quot;206&quot; title=&quot;Reverse Linked List&quot; zh=&quot;反转链表&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要点：双指针，&lt;code&gt;prev&lt;/code&gt; 和 &lt;code&gt;curr&lt;/code&gt; 同向前进，每步反转箭头方向&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def reverseList(self, head: Optional[ListNode]) -&amp;gt; Optional[ListNode]:
        curr = head
        prev = None

        while curr:
            next_node = curr.next  # 先保存下一个，防止断链
            curr.next = prev       # 反转箭头
            prev = curr            # prev 前进
            curr = next_node       # curr 前进
        return prev
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;141. 环形链表&lt;/h3&gt;
&lt;p&gt;::leetcode{id=&quot;141&quot; title=&quot;Linked List Cycle&quot; zh=&quot;环形链表&quot; difficulty=&quot;easy&quot;}&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要点：快慢指针，slow 走1步，fast 走2步，相遇说明有环&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def hasCycle(head):
        slow, fast = head, head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
            if slow == fast:
                return True
        return False
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;146. LRU 缓存机制&lt;/h3&gt;
&lt;p&gt;::leetcode{id=&quot;146&quot; title=&quot;LRU Cache&quot; zh=&quot;LRU 缓存机制&quot; difficulty=&quot;medium&quot;}&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要点：哈希表（O(1)查找）+ 双向链表（O(1)维护顺序）&lt;/li&gt;
&lt;li&gt;技巧：用 Python &lt;code&gt;OrderedDict&lt;/code&gt; 可以一行搞定顺序维护，下面是手写 Node 的实现&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class Node:
    # key=0,val=0 代表创建节点且不传参时候的默认值 Node()
    def __init__(self, key=0, val=0):
        self.key = key
        self.val = val
        self.prev = None
        self.next = None

class LRUCache:

    def __init__(self, capacity: int):
        self.cap = capacity
        self.cache = {}       # key → node 的映射，用来 O(1) 查找节点
        self.head = Node()    # 哨兵头节点，不存真实数据
        self.tail = Node()    # 哨兵尾节点，不存真实数据
        self.head.next = self.tail
        self.tail.prev = self.head

    def remove(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def add_to_head(self, node):
        node.next = self.head.next   # ① node 的 next 指向原来的第一个节点
        node.prev = self.head        # ② node 的 prev 指向 head
        self.head.next.prev = node   # ③ 原来第一个节点的 prev 指向 node
        self.head.next = node        # ④ head 的 next 指向 node

    def get(self, key: int) -&amp;gt; int:
        if key not in self.cache:
            return -1
        node = self.cache[key]
        self.remove(node)
        self.add_to_head(node)
        return node.val

    def put(self, key: int, value: int) -&amp;gt; None:
        if key in self.cache:
            self.remove(self.cache[key])
            del self.cache[key]

        node = Node(key, value)
        self.cache[key] = node
        self.add_to_head(node)
        if len(self.cache) &amp;gt; self.cap:
            lru = self.tail.prev
            self.remove(lru)
            # 注意用的是 lru.key 而不是 key——key 是刚插入的新节点，lru.key 才是被淘汰节点的键
            del self.cache[lru.key]

# Your LRUCache object will be instantiated and called as such:
# obj = LRUCache(capacity)
# param_1 = obj.get(key)
# obj.put(key,value)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::note
&lt;strong&gt;remove() 实现细节&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;node&lt;/code&gt; 自身的 &lt;code&gt;prev/next&lt;/code&gt; 不需要清空，因为后续调用 &lt;code&gt;add_to_head()&lt;/code&gt; 时会重新设置这两个指针。
:::&lt;/p&gt;
&lt;p&gt;:::caution
&lt;strong&gt;LRU 淘汰时的易错点&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;del self.cache[lru.key]&lt;/code&gt; 用的是 &lt;code&gt;lru.key&lt;/code&gt;（被淘汰节点的键），而不是 &lt;code&gt;key&lt;/code&gt;（刚插入的新节点的键），两者不要混淆。
:::&lt;/p&gt;
</content:encoded></item></channel></rss>