跳转至

Hot100刷题记录

语言基础

由于本人这里全部使用Python,所以再强调一下Python会在算法中使用到的语言基础。

常用到的数据结构有:列表、元组、字典(哈希表)。

列表list可以作为数组,可以作为堆栈,也可以作为队列使用。

# 数组
arr = [1, 2, 3, 4]
# 输出:3
print(arr[2])
# 在列表尾部添加元素
arr.append(5)
# -1 表示列表的末尾,输出:5
print(arr[-1])

# 模拟堆栈
stack = []
# 把列表尾部作为栈顶
# 在栈顶添加元素
stack.append(1)
stack.append(2)
# 删除栈顶元素并返回该元素
e1 = stack.pop()
# 查看栈顶元素
e2 = stack[-1]

剩下常用的数据结构就是元组tuple和字典dict。这个两个数据结构会经常作为自顶向下动态规划的“备忘录”。

memo = dict()

# 二维动态规划的dp函数
def dp(i, j):
    # 元组(i, j)作为哈希表的键
    # 用 in 判断键是否存在于哈希表中
    if (i, j) in memo:
        # 返回键对应的值
        return memo[(i, j)]
    # 状态转移方程
    memo[(i, j)] = ...

    return memo[(i, j)]

哈希

什么时候使用哈希法?当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就需要我们第一时间想到哈希法。

两数之和

题目描述:给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。

class Solution:
    def twoSum(self, nums: List[int], target: int) -> List[int]:
        records = dict()

        for idx, val in enumerate(nums):
            if target - val not in records:
                records[val] = idx
            else:
                return [records[target - val], idx]

enumerate是Python的内置函数,它的核心作用是:在遍历序列(如列表、元组、字符串等)时,同时获取元素的“索引(下标)”和“元素值”。

可以使得代码更加简洁,避免手动维护计数器的麻烦。

核心场景:遍历时替代range(len()),传统写法需要同时使用rangelen,还要通过索引取值。

函数语法:enumerate(iterable, start=0)

  • iterable:可迭代对象,如列表、元组、字符串等。
  • start:起始索引,默认为0,比如显示“第几个”),可以设置 start=1
fruits = ['apple', 'banana', 'cherry']

for count, fruit in enumerate(fruits, start=1):
    print(f"第 {count} 个水果是:{fruit}")

输出结果:

第 1 个水果是:apple
第 2 个水果是:banana
第 3 个水果是:cherry

由于我们不仅要知道元素有没有遍历过,还要知道元素对应的下标,需要使用 key-value 结构来存放元素和下标,于是使用字典 dict 来存储元素和下标。

字母异位词分组

题目描述:给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。

示例 1:

输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]

输出: [["ate","eat","tea"],["nat","tan"],["bat"]]

class Solution:
    def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
        result = defaultdict(list)
        for s in strs:
            sorted_s = ''.join(sorted(s))
            result[sorted_s].append(s)
        return list(result.values())

核心算法思想:哈希表分组

  • 思想:利用哈希表(字典)将具有相同特征的元素归类到一起。
  • Key 的设计(最关键):
    • 字母异位词排序后都会变成同一个字符串。
    • 所以,将排序后的字符串作为 key,将原字符串作为 value,将具有相同特征(排序后的字符串相同)的元素归为一类。

关键工具defaultdict

  • 作用:
    • 自动初始化:当遇到新的 key 时,会自动初始化一个空列表作为 value。
    • 简化逻辑:避免手动判断 key 是否存在,直接使用 result[key] 访问即可。

字符串处理技巧:sorted + join

  • 代码:sorted_s = ''.join(sorted(s))
  • 步骤分解:
    • sorted(s):将字符串 s 转换为列表,并排序。
    • ''.join(sorted(s)):将排序后的列表转换为字符串
  • 为什么必须join
    • 字典的 key 必须是 immutable 类型,因此,不能使用列表作为 key。
    • sorted(s) 返回的是一个列表,而不是字符串,不能作为字典的 key。
    • ''.join(sorted(s)) 可以将排序后的列表转换为字符串,从而实现字典的 key 的要求。

字典操作方法:.values()

  • 作用:
    • 获取字典中所有 value 。
    • list(...)dict_values视图对象转换为真正的列表。

时间与空间复杂度

  • 时间复杂度:\(O(N * K log K)\),其中 \(N\) 是字符串个数,\(K\) 是字符串的最大长度。
  • 空间复杂度:\(O(N * K)\),需要存储所有字符串在结果字典中。
class Solution:
    def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
        result = dict()
        for s in strs:
            sorted_s = ''.join(sorted(s))
            if sorted_s not in result:
                result[sorted_s] = [s]
            else:
                result[sorted_s].append(s)
        return list(result.values())

最长连续序列

题目描述:给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。

class Solution:
    def longestConsecutive(self, nums: List[int]) -> int:
        if not nums:
            return 0
        result = 1
        cnt = 1
        nums.sort()
        for i in range(1, len(nums)):
            if nums[i] == nums[i-1]:
                pass
            elif nums[i] - nums[i-1] == 1:
                cnt += 1
                if cnt > result:
                    result = cnt
            else:
                cnt = 1
        return result

很朴素的想到的一种暴力思路,就是将数组排序,然后遍历数组,如果当前数字和前一个数字相差1,则计数器加1,并更新结果。

列表排序

关注点:

  • 在 Python 中,判断列表(数组)是否为空,最简洁、最 Pythonic 的写法是: if not nums:
    • if len(nums) == 0:,这种写法也可以,但是略微显得啰嗦。
    • if nums == []:,这种写法效率低下,需要创建临时的空列表。
  • 列表排序:nums.sort()
    • nums.sort(),对列表进行原地排序,是属于列表list的专属方法。
    • 默认情况下,Python 的列表排序是升序的。如果需要降序,可以使用 nums.sort(reverse=True)
    • sorted(nums),返回一个新的列表,而不是原地修改列表。
    • sorted()是内置函数,不修改原对象,而是返回一个新的对象,可以用于任何可以迭代的对象(列表、元组、字符串、字典、集合等)

请你设计并实现时间复杂度为 \(O(n)\) 的算法解决此问题。

class Solution:
    def longestConsecutive(self, nums: List[int]) -> int:
        st = set(nums)  # 把 nums 转成哈希集合
        m = len(st)

        ans = 0
        for x in st:  # 遍历哈希集合
            if x - 1 in st:  # 如果 x 不是序列的起点,直接跳过
                continue
            # x 是序列的起点
            y = x + 1
            while y in st:  # 不断查找下一个数是否在哈希集合中
                y += 1
            # 循环结束后,y-1 是最后一个在哈希集合中的数
            ans = max(ans, y - x)  # 从 x 到 y-1 一共 y-x 个数
            if ans * 2 >= m:  # ans 不可能变得更大
                break
        return ans

如果还是采取之前排序的思路的话,那么时间复杂度可以优化到 \(O(nlogn)\),不符合题目的要求。

核心思路:对于nums中的元素x,以x为起点,不断查找下一个数字x + 1x + 2,...,直到不能找到下一个数字为止,同时统计序列的长度。

为了做到\(O(n)\)的时间复杂度,需要两个关键优化

  1. 创建一个哈希集合,用于存储数组中的元素,这样可以\(O(1)\)的判断某个元素是否存在。
  2. 如果某个元素x不是序列的起点,则跳过。为什么?因为以x-1为起点计算出的序列长度,一定比以x为起点计算出的序列长度要长!这样可以避免大量的重复计算。
问题 答案
set 底层是哈希吗? ✅ 是,开放寻址哈希表
为什么能 O(1) 查找? 哈希函数 + 直接定位 + 冲突解决
dict 是哈希吗? ✅ 是,和 set 共享底层实现
最坏情况? 哈希冲突极多时退化到 O(n),但实际很少见
如何保证效率? 优秀哈希函数 + 动态扩容 + 负载因子控制

一句话理解

setdict 就像"智能储物柜":存东西时算个号直接放,取东西时算个号直接拿,不用一个个找,所以快!🚀

双指针

双指针(Two Pointers)是算法刷题中最核心、最高频的技巧之一。它的本质是通过两个变量(索引或引用)的协同移动,将嵌套循环优化为单层循环

以下是双指针的使用场景、核心意义及分类总结:

核心意义:为什么要用双指针?

维度 暴力解法 双指针解法 提升点
时间复杂度 通常 \(O(n^2)\) 通常 \(O(n)\) 效率大幅提升
空间复杂度 可能需要 \(O(n)\) 哈希表 通常 \(O(1)\) 节省内存
代码逻辑 多层嵌套,易乱 单层循环,清晰 可读性更强

一句话总结

双指针是用空间换时间(哈希法)之外的另一种优化思路,它通过利用数据的有序性或结构特性,避免不必要的遍历。

四大经典使用场景

1. 左右碰撞指针(Collision Pointers)

特征:两个指针分别在两端,向中间移动。 前提:通常要求数组有序(或具有单调性)。

典型问题 逻辑 代码模板
两数之和 II 和太大 → 右指针左移
和太小 → 左指针右移
while left < right:
三数之和 固定一个数,剩下两个用双指针 外层 for + 内层 while
盛最多水的容器 移动短板,尝试寻找更高边 if h[l] < h[r]: l++
反转字符串/数组 交换两端,向中间靠拢 s[l], s[r] = s[r], s[l]

示例(两数之和 II)

def twoSum(nums, target):
    left, right = 0, len(nums) - 1
    while left < right:
        s = nums[left] + nums[right]
        if s == target:
            return [left + 1, right + 1]
        elif s < target:
            left += 1   # 和太小,左指针右移增大和
        else:
            right -= 1  # 和太大,右指针左移减小和


2. 快慢指针(Fast & Slow Pointers)

特征:两个指针在同一端,向同一方向移动,但速度不同前提:链表或数组,常用于检测环、找中点、去重。

典型问题 逻辑 代码模板
链表是否有环 快指针走 2 步,慢指针走 1 步
相遇则有环
fast = fast.next.next
链表找中点 快指针到终点时,慢指针正好在中点 slow = slow.next
删除重复元素 快指针探索,慢指针记录有效位置 if nums[fast] != nums[slow]: slow++
寻找倒数第 K 个节点 快指针先走 K 步,然后同步走 for _ in range(k): fast = fast.next

示例(链表判环)

def hasCycle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next          # 走 1 步
        fast = fast.next.next     # 走 2 步
        if slow == fast:          # 相遇
            return True
    return False


3. 滑动窗口(Sliding Window)

特征:双指针维护一个区间,右指针扩张,左指针收缩。 前提:连续子数组/子串问题(求最值、满足条件)。

典型问题 逻辑 代码模板
最大连续 1 的个数 右指针扩展,不满足条件时左指针收缩 while 不满足条件:left++
长度最小的子数组 和 ≥ target 时,尝试收缩左边界 while sum >= target:
无重复字符的最长子串 遇到重复字符,移动左指针直到无重复 while s[right] in window:

示例(长度最小的子数组)

def minSubArrayLen(target, nums):
    left = 0
    total = 0
    min_len = float('inf')

    for right in range(len(nums)):
        total += nums[right]          # 右指针扩张
        while total >= target:        # 满足条件,尝试收缩
            min_len = min(min_len, right - left + 1)
            total -= nums[left]
            left += 1
    return min_len

4. 双指针归并(Parallel Pointers)

特征:两个指针分别在两个独立的结构上遍历。 前提:两个有序数组/链表的合并或比较。

典型问题 逻辑 代码模板
合并两个有序数组 谁小取谁,指针后移 if nums1[p1] < nums2[p2]:
合并两个有序链表 同上,操作 next 指针 if l1.val < l2.val:
寻找两个正序数组的中位数 模拟归并过程找到中间位置 双指针同步移动

示例(合并两个有序数组)

def merge(nums1, m, nums2, n):
    p1, p2 = m - 1, n - 1
    p = m + n - 1  # 从后往前填,避免覆盖

    while p1 >= 0 and p2 >= 0:
        if nums1[p1] > nums2[p2]:
            nums1[p] = nums1[p1]
            p1 -= 1
        else:
            nums1[p] = nums2[p2]
            p2 -= 1
        p -= 1

双指针 vs 哈希法(如何选择?)

这是面试中最容易纠结的点,以 两数之和 为例:

特性 哈希法 (Hash Map) 双指针 (Two Pointers)
是否需要排序 ❌ 不需要 需要排序 (O(n log n))
时间复杂度 O(n) O(n log n) (排序) + O(n) (遍历)
空间复杂度 O(n) (存哈希表) O(1) (仅指针)
返回内容 返回原始下标方便 返回方便(下标会变)
适用场景 无序数组,需返回下标 有序数组,或空间敏感,或三数之和

决策树

  1. 题目要求返回原始下标? → 选 哈希法
  2. 题目允许修改数组/排序,且追求空间 O(1)? → 选 双指针
  3. 题目是 三数之和/四数之和? → 必须 排序 + 双指针(哈希法难以处理去重)。
  4. 题目是 链表? → 只能用 双指针(链表不支持随机访问,无法建哈希索引)。

避坑指南与注意事项

  1. 边界条件
    • while left < right 还是 left <= right
    • 链表判空:while fast and fast.next(先判 fast 再判 next)。
  2. 死循环风险
    • 确保指针一定会移动(特别是在 if-else 分支中)。
    • 快慢指针中,快指针必须比慢指针快,否则永远追不上。
  3. 排序副作用
    • 双指针常需排序,排序会打乱原始下标。如果题目要求返回原下标,需先备份索引。
  4. Python 特性
    • Python 没有真正的指针,数组双指针是索引,链表双指针是对象引用
    • 注意可变对象(如链表节点)的引用修改会影响原结构。

总结速查表

场景 指针类型 移动方向 典型题目
有序数组查找 左右指针 相向而行 两数之和 II、盛水容器
链表/环/中点 快慢指针 同向不同速 链表环、找中点、去重
子数组/子串 滑动窗口 同向(一扩一缩) 最小覆盖子串、最大连续 1
多路归并 平行指针 同向同步 合并有序链表/数组

核心心法

看到“有序数组”想左右指针,看到“链表”想快慢指针,看到“连续子串”想滑动窗口。

移动零

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

请注意 ,必须在不复制数组的情况下原地对数组进行操作。

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        """
        Do not return anything, modify nums in-place instead.
        """
        slow = 0
        for i in range(len(nums)):
            if nums[i] != 0:
                nums[slow], nums[i] = nums[i], nums[slow]
                slow += 1
        print(nums)

典型的快慢指针,因为要求是在原数组中进行操作,所以不能使用额外的空间。

所谓指针其实是一种标志位,而不是例如C和C++中的实际指针,本题还有一个技巧,其实不难发现交换之前快慢指针之间的元素全部为0,因此这样一直交换下去,最终也就实现了移动零的目的。

Python中交换元素的语法是 a, b = b, a,我们可以借助这个语法来交换元素。

盛最多的水

给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0)(i, height[i])

找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

返回容器可以储存的最大水量。

class Solution:
    def maxArea(self, height: List[int]) -> int:
        ans = 0
        left = 0
        right = len(height) - 1
        while left < right:
            tmp = (right - left) * min(height[left], height[right])
            ans = max(ans, tmp)
            if height[left] < height[right]:
                # height[left]与右边不管哪一条垂线都无法组成一个比ans更大的面积了
                left += 1
            else:
                # height[right]与左边不管哪一条垂线都无法组成一个比ans更大的面积了
                right -= 1     
        return ans

盛水问题,就是两个指针,一个指向数组的左边,一个指向数组的右边,然后计算两个指针之间的面积,并更新最大面积。

左右指针而非快慢指针,同样只需要遍历一次数组即可解决问题。

三数之和

给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。

注意:答案中不可以包含重复的三元组。

class Solution:
    def threeSum(self, nums: list[int]) -> list[list[int]]:
        result = []
        nums.sort()
        n = len(nums)
        # 固定一个元素
        for i in range(n - 2):
            if nums[i] > 0: # 剪枝
                break
            # 首位元素的去重
            if i > 0 and nums[i] == nums[i - 1]: # 去重
                continue
            # 双指针
            left = i + 1
            right = n - 1
            while left < right:
                three_sum = nums[i] + nums[left] + nums[right]
                if three_sum == 0:
                    # 符合条件
                    result.append([nums[i], nums[left], nums[right]])
                    # 第二位元素的去重
                    while left < right and nums[left + 1] == nums[left]:
                        left += 1
                    # 第三个元素的去重
                    while left < right and nums[right - 1] == nums[right]:
                        right -= 1
                    # 还需要到下一个不同元素
                    left += 1
                    right -= 1
                elif three_sum > 0:
                    # 三数和偏大,右指针移动
                    right -= 1
                else:
                    # 三数和偏小,左指针移动
                    left += 1

        return result

三数之和的标准解法:

  • 外层循环:固定第一个数 nums[i],其实也就是固定一个,退化为两数之和。
  • 内层双指针:在 i+1 到末尾之间,用 left 和 right 找另外两个数。

接雨水

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

class Solution:
    def trap(self, height: List[int]) -> int:
        ans = 0
        left_max = right_max = 0
        left, right = 0, len(height) - 1
        while left < right:
            left_max = max(left_max, height[left])
            right_max = max(right_max, height[right])
            if left_max < right_max:
                ans += left_max - height[left]
                left += 1
            else:
                ans += right_max - height[right]
                right -= 1

        return ans

关键在于每个柱子能接的雨水量,即柱子两边的柱子中较小的一个减去柱子的高度。分别向左和向右找到能看到的最高的柱子。

滑动窗口

一般解决连续子串问题,可以使用滑动窗口。其实滑动窗口就是两个指针,一个指向窗口的左边界,一个指向窗口的右边界。

无重复字符的最长子串

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        char_set = set()  # 用集合存储当前窗口的字符,查找速度 O(1)
        left = 0
        result = 0

        for right in range(len(s)):
            # 如果当前字符已在窗口中,不断移动左指针直到移除该字符
            while s[right] in char_set:
                char_set.remove(s[left])
                left += 1

            # 将当前字符加入窗口
            char_set.add(s[right])

            # 更新最大长度 (此时窗口是合法的 [left, right])
            # 窗口长度 = right - left + 1
            result = max(result, right - left + 1)

        return result

由于不含有重复字符的最长子串,因此,只需要使用一个集合来存储当前窗口的元素,并使用两个指针来移动窗口。

连续子串问题,理应想到使用滑动窗口来优化解法,其本质还是双指针,利用窗口的左右边界来确定窗口内字符的集合,从而判断窗口是否合法。

窗口的左右边界,以及窗口内字符的集合,都是通过指针移动来更新的。

注意点:添加元素

  • 想要排队(有顺序、可重复),用 append (List/Deque)。
  • 想要集邮(不重复、不在乎顺序),用 add (Set)。

注意点:移除元素

  • pop 通常跟位置/键有关,且会返回被删的东西。
  • remove 跟具体的值有关,且不返回东西(返回 None)。

找到字符串中所有字母异位词

给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

class Solution:
    def findAnagrams(self, s: str, p: str) -> List[int]:
        window_size = len(p)
        total_size = len(s)
        if total_size < window_size:
            return []
        result = []
        left = 0
        need = dict()
        window = dict()
        for p_item in p:
            if p_item in need:
                need[p_item] += 1
            else:
                need[p_item] = 1

        for right in range(total_size):
            if s[right] in need:
                if s[right] in window:
                    window[s[right]] += 1
                else:
                    window[s[right]] = 1

                if need == window:
                    result.append(left)

            if right - left + 1 >= window_size:
                if s[left] in window:
                    window[s[left]] -= 1
                left += 1

        return result

上面我使用了字典的比较操作,相等是要比较全部的键和值。

同时由于本题是定长的窗口,内部可以不使用循环而仅仅采用条件判断,因为窗口内元素达到数量时,左右会跟随移动。

使用 Counter 进行比较,可以优化我们的代码。

# 请选择 Python3 提交代码,而不是 Python
class Solution:
    def findAnagrams(self, s: str, p: str) -> List[int]:
        cnt_p = Counter(p)  # 统计 p 的每种字母的出现次数
        cnt_s = Counter()  # 统计 s 的长为 len(p) 的子串 t 的每种字母的出现次数
        ans = []

        for right, c in enumerate(s):
            cnt_s[c] += 1  # 右端点字母进入窗口

            left = right - len(p) + 1
            if left < 0:  # 窗口长度不足 len(p)
                continue

            if cnt_s == cnt_p:  # t 和 p 的每种字母的出现次数都相同
                ans.append(left)  # t 左端点下标加入答案

            cnt_s[s[left]] -= 1  # 左端点字母离开窗口

        return ans

Counter的比较运算:

cnt3 = Counter({'red': 20, 'green': 10, 'blue': 0})
cnt4 = Counter({'red': 20, 'green': 10})
print(cnt3 == cnt4)
print(cnt4 < cnt3)
Output:

True
False

注意:

  • Counter之间可以进行比较运算,支持 ==, !=, <, <=, >, >=
  • 在比较时,如果不存在的元素,会用计数0进行比较,所以上面的cnt3和cnt4相等
  • 对于小于,只要Counter中有一个元素的计数值小于另一个Counter,其他元素的计数值可以相等,此时会返回True。大于同理。

子串

和为K的子数组

给你一个整数数组 nums 和一个整数 k ,请你统计并返回 该数组中和为 k 的子数组的个数 。

子数组是数组中元素的连续非空序列。

正常暴力思想,遍历所有子数组,求出每一段子数组的和并判断是否等于k。

优化技巧?前缀和思想,空间换时间,利用前缀和,将子数组的和转换为前缀和之差。

class Solution:
    def subarraySum(self, nums: List[int], k: int) -> int:
        pre_sum = [0]
        cnt = 0
        # 计算前缀和
        for idx, num in enumerate(nums):
            pre_sum.append(pre_sum[idx] + num)

        for i in range(len(nums)):
            for j in range(i + 1, len(nums) + 1):
                if pre_sum[j] - pre_sum[i] == k:
                    cnt += 1
        return cnt

超时?即使使用前缀和,还是需要两层循环,时间复杂度仍然为\(O(n^2)\)

优化思路:

  1. 创建一个字典,用于记录前缀和出现的次数。
  2. 创建一个变量,记录当前前缀和。
  3. 遍历数组,将当前前缀和加入字典中,并判断当前前缀和减去k的值是否在字典中,如果存在,则将字典中该值出现的次数加到结果中。
  4. 更新当前前缀和字典。

依旧空间换时间,利用字典,存储了前缀和出现的次数,空间复杂度\(O(n)\)

class Solution:
    def subarraySum(self, nums: List[int], k: int) -> int:
        cnt = 0
        pre_sum = 0
        records = Counter({0: 1})

        for num in nums:
            pre_sum += num
            cnt += records[pre_sum - k]
            records[pre_sum] += 1

        return cnt

滑动窗口最大值

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

单调队列问题?一个队列中元素全是单调的数据结构,可以用来解决滑动窗口的一系列问题,比如“滑动窗口最大值”。

一堆数字中,已知最值,如果增加一个数字,比较一下即可得到最新的最值;但如果是减少一个数字,就不能直接得到最值,因为如果减少的数字恰好是最值,就需要遍历所有数重新找出最值。

每个窗口前进时,要添加一个数同时减少一个数,因此想\(O(1)\)时间内得到新的最值,不是一件容易的事情,我们需要“单调队列”这种特殊的数据结构。

class Solution:
    def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
        if k == 1: return nums
        ans, q = [], deque()

        for i, num in enumerate(nums):
            # 1. 维护单调性 (改为 < 保留重复值)
            while q and q[-1] < num:
                q.pop()
            q.append(num)

            # 2. 窗口形成后记录答案
            if i >= k - 1:
                ans.append(q[0])
                # 3. 移除滑出窗口的元素
                if q[0] == nums[i - k + 1]:
                    q.popleft()
        return ans

上述是一种存值的思路。不过该思路存在点问题,相同大小的值也需要存,否则可能导致删除之前的值而将现在窗口中的最值丢失的情况。

class Solution:
    def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
        ans = []
        q = deque()  # 存储索引,不是值

        for i in range(len(nums)):
            # 1. 移除超出窗口的索引,即左边出
            if q and q[0] <= i - k:
                q.popleft()

            # 2. 维护单调递减队列,即右边入
            while q and nums[q[-1]] <= nums[i]:
                q.pop()

            # 3. 添加当前索引
            q.append(i)

            # 4. 当窗口形成后,记录结果
            if i >= k - 1:
                ans.append(nums[q[0]])

        return ans

较为可靠的想法,采用存储索引的思路。

这是一个降本增笑的故事:

  • 如果新员工比老员工强(或者一样强),把老员工裁掉。(元素进入窗口)
  • 如果老员工 35 岁了,也裁掉。(元素离开窗口)

裁员后,资历最老(最左边)的人就是最强的员工了

单调队列套路

  • 右边入(元素进入队尾,同时维护队列单调性)
  • 左边出(元素离开队首)
  • 记录/维护答案(根据队首)

最小覆盖子串

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        if not s or not t:
            return ""

        cnt_t = Counter(t)
        window_cnt = Counter()

        formed = 0           # 已满足数量要求的字符种类数
        required = len(cnt_t)  # 需要满足的字符种类数

        left = 0
        min_len = float('inf')
        ans = (0, 0)  # (left, right)

        for right, char in enumerate(s):
            window_cnt[char] += 1

            # 如果当前字符在 t 中,且数量已满足要求,注意是等于时才标记一次+1
            if char in cnt_t and window_cnt[char] == cnt_t[char]:
                formed += 1

            # 当所有字符都满足时,尝试收缩左边界
            while formed == required:
                # ✅ 先记录当前有效窗口,但并不急着记录子串,而是仅仅记录起始和终止位置
                if right - left + 1 < min_len:
                    min_len = right - left + 1
                    ans = (left, right)

                # 收缩左边界
                window_cnt[s[left]] -= 1
                # 不再满足
                if s[left] in cnt_t and window_cnt[s[left]] < cnt_t[s[left]]:
                    formed -= 1
                left += 1

        return "" if min_len == float('inf') else s[ans[0]:ans[1] + 1]

下面是我个人的一种写法,但复杂度比上面使用变量追踪是否满足要高。

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        if not s or not t:
            return ""

        cnt_t = Counter(t)
        cnt_zero = Counter({key: 0 for key in cnt_t})
        min_len = float('inf')
        ans = ""
        left = 0

        for right in range(len(s)):
            if s[right] in cnt_t:
                cnt_zero[s[right]] += 1

            # 收缩窗口(在满足条件时)
            while cnt_zero >= cnt_t:
                # ✅ 先记录当前有效窗口
                if right - left + 1 < min_len:
                    min_len = right - left + 1
                    ans = s[left:right + 1]

                # 再收缩
                if s[left] in cnt_t:
                    cnt_zero[s[left]] -= 1
                left += 1

        return ans

float('inf') 是 Python 中表示"无限大"的标准方式,常用于初始化最小值搜索!

1. dictget 方法吗?

有! get 是所有字典(包括 dictdefaultdict)的标准方法。

# dict 的 get 方法
d = {'a': 1}
print(d.get('a'))        # 1
print(d.get('b'))        # None (key 不存在)
print(d.get('b', 0))     # 0 (指定默认值)
print(d.get('b', -1))    # -1 (指定默认值)

2. defaultdictget 方法

defaultdict 继承自 dictget 方法行为完全相同

from collections import defaultdict

dd = defaultdict(int, {'a': 1})
print(dd.get('a'))        # 1
print(dd.get('b'))        # None (key 不存在,不会触发默认工厂)
print(dd.get('b', 0))     # 0

⚠️ 关键点get 方法不会触发 defaultdict 的默认工厂函数!


3. get vs [] 的核心区别 ⭐

操作 dict defaultdict
d.get(key) 不存在返回 None 不存在返回 None
d.get(key, default) 不存在返回 default 不存在返回 default
d[key] 不存在抛 KeyError 不存在自动创建默认值
from collections import defaultdict

dd = defaultdict(int)

# 使用 get - 不会创建新 key
print(dd.get('a'))      # None
print(dd)               # defaultdict(int, {}) 空字典

# 使用 [] - 会创建新 key
print(dd['b'])          # 0 (自动创建)
print(dd)               # defaultdict(int, {'b': 0}) 有 key 了!

4. 实际应用场景

场景一:计数(推荐用 []

from collections import defaultdict

# ✅ 推荐:用 [] 自动初始化
cnt = defaultdict(int)
for char in "hello":
    cnt[char] += 1  # 不存在时自动为 0
print(cnt)  # {'h': 1, 'e': 1, 'l': 2, 'o': 1}

# ❌ 不推荐:用 get 需要手动处理
cnt = {}
for char in "hello":
    cnt[char] = cnt.get(char, 0) + 1

场景二:安全读取(用 get

dd = defaultdict(list, {'a': [1, 2]})

# 只想读取,不想创建新 key
value = dd.get('b', [])  # [],不会在 dd 中创建 'b'
print(dd)  # defaultdict(list, {'a': [1, 2]})

# 如果用 [] 会创建空列表
value = dd['c']  # 自动创建 'c': []
print(dd)  # defaultdict(list, {'a': [1, 2], 'c': []})

场景三:滑动窗口

from collections import Counter, defaultdict

# 方案 1: 用 Counter (推荐)
window_cnt = Counter()
window_cnt['a']  # 返回 0,不会创建 key

# 方案 2: 用 defaultdict
window_cnt = defaultdict(int)
window_cnt['a']  # 返回 0,但会创建 key 'a'

# 方案 3: 用 get (不修改字典)
window_cnt = {}
count = window_cnt.get('a', 0)  # 返回 0,不创建 key

5. 完整对比表

特性 dict.get() defaultdict.get() defaultdict[]
key 不存在时 返回 None 或默认值 返回 None 或默认值 自动创建默认值
是否修改字典
触发默认工厂
适用场景 安全读取 安全读取 计数、累加

6. 代码示例

from collections import defaultdict

dd = defaultdict(list)

# get 不会触发默认工厂
print(dd.get('x'))        # None
print('x' in dd)          # False
print(dd)                 # defaultdict(list, {})

# [] 会触发默认工厂
print(dd['y'])            # [] (空列表)
print('y' in dd)          # True
print(dd)                 # defaultdict(list, {'y': []})

# get 可以指定默认值
print(dd.get('z', []))    # []
print('z' in dd)          # False (还是没有创建)

总结

问题 答案
dictget 吗? ,所有字典都有
defaultdict.get() 特殊吗? 不特殊,和 dict.get() 一样
什么时候用 get 只想读取,不想创建新 key
什么时候用 [] 自动初始化新 key(计数、累加)
会触发默认工厂吗? get 不会[]

一句话get 是安全读取,[] 是自动创建!根据需求选择使用。🎯

普通数组

最大子数组和

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组是数组中的一个连续部分。

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        f = [0] * len(nums)
        f[0] = nums[0]

        for i in range(1, len(nums)):
            f[i] = max(f[i - 1] + nums[i], nums[i])

        return max(f)

动态规划解法,维护一个f数组,f[i]表示以nums[i]为结尾的子数组的最大和。状态转移方程为:f[i] = max(f[i - 1] + nums[i], nums[i])。初始状态为f[0] = nums[0]。最后返回max(f)

还可以怎么做?分治思想!左侧最大和右侧最大以及横跨左右最大的比较。

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:

        def merge(left, mid, right):
            """O(n) 计算跨越mid的最大子数组和"""
            # 左半部分:以mid结尾的最大后缀和
            left_sum = float('-inf')
            current = 0
            for i in range(mid, left - 1, -1):
                current += nums[i]
                left_sum = max(left_sum, current)

            # 右半部分:以mid+1开头的最大前缀和
            right_sum = float('-inf')
            current = 0
            for j in range(mid + 1, right + 1):
                current += nums[j]
                right_sum = max(right_sum, current)

            return left_sum + right_sum

        def maxSum(left, right):
            if left == right:
                return nums[left]

            mid = left + (right - left) // 2
            left_max = maxSum(left, mid)
            right_max = maxSum(mid + 1, right)
            cross_max = merge(left, mid, right)

            return max(left_max, right_max, cross_max)

        if not nums:
            return 0
        return maxSum(0, len(nums) - 1)

嵌套函数

学到一招,Python 可以在函数中嵌套函数,从而将辅助函数写在主函数的内部。

避免全局变量,保持代码封闭

# ❌ 不推荐:用类属性/全局变量传状态
class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        self.result = 0  # 污染类状态,多测试用例会干扰
        return self.helper(nums)

# ✅ 推荐:内部函数 + 闭包,状态隔离
class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        def helper(left, right):
            # 直接访问外层 nums,无需 self
            ...
        return helper(0, len(nums)-1)

递归/分治时参数传递更简洁

# 分治需要 left/right 等额外参数,但题目接口固定
def maxSubArray(self, nums: List[int]) -> int:
    def dfs(l, r):          # ✅ 内部函数可自定义参数
        if l == r: return nums[l]
        ...
    return dfs(0, len(nums)-1)

避免命名冲突,作用域清晰

内部函数只在主函数内可见,不会污染模块命名空间。

合并区间

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。

class Solution:
    def merge(self, intervals: List[List[int]]) -> List[List[int]]:
        # 按照左端点从小到大排序
        intervals.sort(key=lambda p: p[0])
        ans = []
        for interval in intervals:
            # 能合并就进行合并
            if ans and interval[0] <= ans[-1][1]:
                # 更新右端点的最大值
                ans[-1][1] = max(ans[-1][1], interval[1])
            else:
                # 不相交,无法进行合并
                ans.append(interval)
        return ans

本题我之前将其视作贪心,可仔细想来有与传统的贪心算法不同,以往贪心是按照哪个排序,就选择哪个最小或者最大,这道题似乎只是一种很自然的解题思路就是:按照左端点排序,然后判断当前区间是否与前一个区间相交,相交就进行合并,否则就加入结果中。

简短回答:区间合并严格来说不算经典贪心,它更像是「排序 + 线性扫描 + 确定性合并」的直接模拟。但它体现了贪心的某些思想,所以很多资料会把它归为"贪心类"。


🔍 一、经典贪心 vs 区间合并:核心差异

特征 经典贪心(如活动选择) 区间合并
是否有"选择" ✅ 要在多个选项中取舍(选/不选某个活动) ❌ 没有选择,重叠就必须合并
决策是否可逆 ✅ 选错了可能后悔(但贪心证明不会后悔) ❌ 合并是确定性的,不存在"选错"
局部最优→全局最优 ✅ 需要证明贪心选择性质 ⚠️ 合并策略是"唯一合理"的,无需证明
排序目的 让贪心选择具有"无后效性" 让可合并区间相邻出现,便于扫描

🎨 对比代码更直观:

# 🎪 活动选择(经典贪心):有"选/不选"的决策
def maxActivities(intervals):
    intervals.sort(key=lambda x: x[1])  # 按结束时间排序
    cnt, last_end = 0, -inf
    for s, e in intervals:
        if s >= last_end:
            cnt += 1           # 🔑 决策点:选这个活动!
            last_end = e
        # else: 跳过(不选)    # 🔑 有"舍弃"的操作
    return cnt

# 📦 区间合并(确定性模拟):没有选择,只有合并
def merge(intervals):
    intervals.sort(key=lambda x: x[0])  # 按开始时间排序
    ans = []
    for s, e in intervals:
        if ans and s <= ans[-1][1]:
            ans[-1][1] = max(ans[-1][1], e)  # 🔑 必须合并,没得选
        else:
            ans.append([s, e])                # 🔑 必须添加,没得选
    return ans

💡 关键区别
活动选择中,else 分支是"主动舍弃";区间合并中,else 分支是"不得不添加"。


🎯 二、为什么很多人说区间合并是"贪心"?

因为它符合贪心的某些表层特征

✅ 特征1:排序 + 线性扫描
   → 和经典贪心写法高度相似

✅ 特征2:局部决策
   → 每遇到一个区间,立即决定"合并"或"新增"

✅ 特征3:无后效性
   → 一旦确定某个区间不能合并,它就可以安全输出,无需回溯

✅ 特征4:右端点取最大
   → 看似"局部最优":尽可能延长覆盖范围

但这些特征不足以构成严格贪心,因为:

❌ 缺少"选择空间":重叠区间必须合并,没有"合并不合并"的权衡
❌ 缺少"策略证明":不需要证明"为什么合并是最优的",因为这是问题定义
❌ 缺少"取舍权衡":不像背包、活动选择那样要在冲突选项中做决策

🧠 三、更准确的分类:区间合并是什么?

📦 区间合并 = 排序预处理 + 确定性状态机模拟

1️⃣ 排序阶段:按左端点升序
   → 目的:让"可能重叠"的区间在数组中相邻
   → 本质:数据预处理,不是贪心策略

2️⃣ 扫描阶段:维护一个"当前合并区间"状态
   → 状态转移:
      - 若重叠:更新右端点(状态内更新)
      - 若不重叠:输出当前状态,开启新状态
   → 本质:确定性有限状态机,不是贪心选择

3️⃣ 合并策略:右端点取 max
   → 看似"贪心",实则是**问题定义的必然要求**
   → 合并后的区间必须覆盖所有原始区间,所以右端点必须取最大

🎯 一句话总结
区间合并的"贪心感"来自于写法相似,但逻辑本质是确定性模拟


🔄 四、对比:什么才是真正的区间贪心?

# 🎯 真正的区间贪心:区间选点(有选择!)
def minPoints(intervals):
    """用最少的点覆盖所有区间"""
    intervals.sort(key=lambda x: x[1])  # 按右端点排序
    points = []
    last_point = -inf

    for s, e in intervals:
        if s > last_point:      # 🔑 决策点:这个区间还没被覆盖!
            point = e           # 🔑 贪心选择:点放在右端点(覆盖最多后续区间)
            points.append(point)
            last_point = point
        # else: 区间已被覆盖,跳过(主动不选新点)

    return len(points)

# ✅ 这才是贪心:
# - 有多个点可选,但贪心选择"右端点"这个局部最优位置
# - 需要证明:为什么选右端点不会导致全局更差
# - 有"选/不选"的决策空间

🎨 五、可视化:两种思维模式

📦 区间合并(确定性模拟):
输入: [[1,3], [2,6], [8,10]]

状态机视角:
state = None
→ 读[1,3]: state为空 → state = [1,3]
→ 读[2,6]: 2≤3(重叠) → state = [1, max(3,6)] = [1,6]  # 确定性更新
→ 读[8,10]: 8>6(不重叠) → 输出[1,6], state = [8,10]  # 确定性输出
→ 结束: 输出[8,10]

→ 全程没有"选择",只有"状态转移"


🎯 活动选择(经典贪心):
输入: [[1,3], [2,4], [3,5]]

贪心决策视角:
按右端点排序: [[1,3], [2,4], [3,5]]

→ 读[1,3]: 可选 → 🔑 贪心选择: 选它!(结束最早)
→ 读[2,4]: 2<3(冲突) → 🔑 贪心选择: 舍弃!
→ 读[3,5]: 3≥3(不冲突) → 🔑 贪心选择: 选它!

→ 每一步都有"选/舍"的决策,且需要证明决策的最优性

📚 六、算法分类的"灰色地带"

实际上,算法分类本身就有模糊性:

🌈 连续谱系视角:

[纯模拟] ---- [带贪心思想的模拟] ---- [经典贪心] ---- [动态规划]
    ↑                ↑                      ↑
  区间合并      区间合并(某些观点)      活动选择/区间选点

→ 区间合并在"带贪心思想的模拟"这个灰色地带

不同资料的不同归类:

资料类型 常见归类 原因
面试突击课 贪心专题 写法相似,便于记忆
算法竞赛书 排序+模拟 强调问题本质
理论教材 不单独归类 作为"扫描线"的简单应用
LeetCode标签 数组/排序 按实现特征分类

💡 七、实用建议:不用纠结分类!

🎯 刷题时关注这三点就够了:

1️⃣ 识别模式:
   - 看到"区间"+"重叠/覆盖/选择" → 考虑排序+扫描

2️⃣ 确定排序关键字:
   - 要"合并/从左处理" → 按左端点
   - 要"选择/早结束优先" → 按右端点

3️⃣ 写对判断条件:
   - 闭区间重叠: `cur.start ≤ last.end`
   - 活动可衔接: `cur.start ≥ last.end`
   - 点覆盖闭区间: `cur.start > last_point`

✅ 比纠结"这是不是贪心"更重要!

🔚 终极总结

📦 区间合并的本质:
   = 排序(预处理) + 线性扫描(顺序保证) + 确定性合并(问题定义)

🎯 它"像贪心"的原因:
   - 写法模式相似: sort + for + if-else
   - 有局部决策感: 遇到区间立即处理
   - 有"右端点取最大"的策略

❌ 但它不是经典贪心:
   - 没有"选择空间": 重叠就必须合并
   - 无需"策略证明": 合并方式是问题定义的必然
   - 没有"取舍权衡": 不存在"合并可能更差"的情况

💡 学习建议:
   把它当作"排序+模拟"的模板题掌握,
   遇到类似区间问题能举一反三,
   比纠结算法分类标签更有价值!🚀

🎯 一句话记忆
"区间合并是模拟,贪心思想沾点边;
分类模糊不用管,掌握模式是关键!"

轮转数组

给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。

解法一

class Solution:
    def rotate(self, nums: List[int], k: int) -> None:
        """
        Do not return anything, modify nums in-place instead.
        """
        k = k % len(nums)
        nums[:] = nums[-k:] + nums[:-k]

我最先想到的就是切片的方法,直接把原数组进行切片拼接不就可以了吗。

经典陷阱

k = k % len(nums)
nums = nums[-k:] + nums[:-k]

核心问题:nums = ... 没有原地修改!

可视化理解:

调用前:
外部列表: [1,2,3,4,5,6,7]  ← 内存地址 0x100
函数参数 nums 指向: 0x100

执行 `nums = nums[-k:] + nums[:-k]` 后:
1. 右边: 创建新列表 [5,6,7,1,2,3,4]  ← 新地址 0x200
2. 赋值: 局部变量 nums 现在指向 0x200
3. 但!外部列表仍在 0x100,内容还是 [1,2,3,4,5,6,7] ❌

函数返回后:
- 局部变量 nums (0x200) 被销毁
- 外部列表 (0x100) 纹丝不动 → 测试用例失败!

实验验证:

# 模拟你的代码
def rotate_wrong(nums, k):
    k = k % len(nums)
    nums = nums[-k:] + nums[:-k]  # 重新赋值
    print(f"函数内: {nums}")       # [5,6,7,1,2,3,4] ✓

arr = [1,2,3,4,5,6,7]
rotate_wrong(arr, 3)
print(f"函数外: {arr}")            # [1,2,3,4,5,6,7] ❌ 没变!

解决方法:切片赋值

class Solution:
    def rotate(self, nums: List[int], k: int) -> None:
        k = k % len(nums)
        # 🔑 关键:用 nums[:] = ... 而不是 nums = ...
        # nums[:] 表示"修改原列表的所有元素",不是"让nums指向新列表"
        nums[:] = nums[-k:] + nums[:-k]

nums[:] = ... vs nums = ... 对比:

lst = [1, 2, 3]
print(id(lst))  # 0x100

# ❌ nums = ... : 重新绑定变量
lst = [4, 5, 6]
print(id(lst))  # 0x200 (新对象!)

# ✅ nums[:] = ... : 修改原对象内容
lst = [1, 2, 3]
original_id = id(lst)  # 0x300
lst[:] = [4, 5, 6]
print(id(lst))         # 0x300 (同一对象!)
print(lst)             # [4, 5, 6] ✓

💡 记忆口诀: "等号左边加 [:],原地修改不迷路!"

Python为什么这样设计?

这是 Python 的"名称绑定"机制决定的

def f(lst):
    print(id(lst))  # 0x100
    lst = [9,9,9]   # 创建新对象,lst 绑定到新地址
    print(id(lst))  # 0x200

arr = [1,2,3]
print(id(arr))      # 0x100
f(arr)
print(id(arr))      # 0x100 (没变!)
print(arr)          # [1,2,3] (没变!)

真正修改原对象的方法:

# 方法1: 切片赋值
lst[:] = [9,9,9]

# 方法2: 逐个元素修改
for i in range(len(lst)):
    lst[i] = 9

# 方法3: 列表方法
lst.clear()
lst.extend([9,9,9])

解法二

三次反转法(\(O(1)\)空间)

如果面试要求 O(1) 额外空间,切片赋值虽然通过,但实际创建了临时列表。更优雅的做法:

class Solution:
    def rotate(self, nums: List[int], k: int) -> None:
        n = len(nums)
        k = k % n

        # 辅助函数:反转区间 [left, right]
        def reverse(left, right):
            while left < right:
                nums[left], nums[right] = nums[right], nums[left]
                left += 1
                right -= 1

        # 🔑 三次反转:
        # 1. 整体反转: [1,2,3,4,5,6,7] → [7,6,5,4,3,2,1]
        reverse(0, n-1)
        # 2. 反转前k个: [7,6,5,4,3,2,1] → [5,6,7,4,3,2,1]
        reverse(0, k-1)
        # 3. 反转后n-k个: [5,6,7,4,3,2,1] → [5,6,7,1,2,3,4] ✓
        reverse(k, n-1)

除了自身以外数组的乘积

给你一个整数数组 nums,返回 数组 answer ,其中 answer[i] 等于 nums 中除了 nums[i] 之外其余各元素的乘积 。

题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。

请 不要使用除法,且在 \(O(n)\) 时间复杂度内完成此题。

class Solution:
    def productExceptSelf(self, nums: List[int]) -> List[int]:
        # 计算前缀乘积和后缀乘积,相乘不就是答案吗
        pre_result = [1] * len(nums)
        suf_result = [1] * len(nums)
        result = [0] * len(nums)

        for i in range(1, len(nums)):
            pre_result[i] = pre_result[i-1] * nums[i-1]

        for i in range(len(nums) - 2, -1, -1):
            suf_result[i] = suf_result[i+1] * nums[i+1]

        for i in range(len(nums)):
            result[i] = pre_result[i] * suf_result[i]

        return result

由于不允许使用除法,且要在时间复杂度 \(O(n)\) 内完成,我联想到了此前的前缀和,通过空间换取时间,本题我转换了一下,前缀积和后缀积,相乘就是答案。

缺失的第一个正数

给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。

请你实现时间复杂度为 \(O(n)\) 并且只使用常数级别额外空间的解决方案。

class Solution:
    def firstMissingPositive(self, nums: List[int]) -> int:
        signs = [0] * (len(nums) + 1)
        for num in nums:
            if num > 0 and num <= len(nums):
                signs[num] += 1

        for i in range(1, len(signs)):
            if signs[i] == 0:
                return i

        return len(signs)

我很自然的想法就是用一个数组来记录数字出现的次数,然后遍历数组,找到第一个没有出现的数字。而且是只统计[1, n]这个范围内的数字。

但是显然这与题目要求的常数级别额外空间有冲突。思考如何优化?

常数级别空间要不就是确实可以使用几个变量做到,要不就利用好现在的数组空间,在这上面进行操作。

class Solution:
    def firstMissingPositive(self, nums: List[int]) -> int:
        n = len(nums)

        # Step 1: 原地哈希 - 把数字 k 放到索引 k-1 处
        for i in range(n):
            # 条件:数字在 [1, n] 范围内,且不在正确位置
            while 1 <= nums[i] <= n and nums[nums[i] - 1] != nums[i]:
                # 交换到正确位置
                correct_idx = nums[i] - 1
                nums[correct_idx], nums[i] = nums[i], nums[correct_idx]

        # Step 2: 查找第一个位置不匹配的
        for i in range(n):
            if nums[i] != i + 1:  # 索引 i 处应该是数字 i+1
                return i + 1

        # Step 3: 如果 1~n 都存在,返回 n+1
        return n + 1

注意不是使用\(nums[i] != i + 1\)而是\(nums[nums[i] - 1] != nums[i]\)进行判断对应位置是否已经有了正确的元素,因为可能有重复元素的原因,会陷入反复交换的死循环。

时间复杂度:\(O(n)\),其中 n 是 nums 的长度。虽然我们写了个二重循环,但每次交换都会把一个学生换到正确的座位上,所以总交换次数至多为 n,所以内层循环的总循环次数是 \(O(n)\) 的,所以时间复杂度是 \(O(n)\)

矩阵

矩阵置零

给定一个 m x n 的矩阵,如果一个元素为 0 ,则将其所在行和列的所有元素都设为 0 。请使用 原地 算法。

class Solution:
    def setZeroes(self, matrix: List[List[int]]) -> None:
        """
        Do not return anything, modify matrix in-place instead.
        """
        row_has_zero = [0 in row for row in matrix]
        col_has_zero = [0 in col for col in zip(*matrix)]

        for i, row in enumerate(row_has_zero):
            for j, col in enumerate(col_has_zero):
                if row or col:
                    matrix[i][j] = 0

最直观的思路,使用两个数组记录行和列的零元素,然后根据这两个数组进行置零。

注意:不能在发现matrix[i][j] == 0时,将matrix[i][j]置零,因为这样会导致后续的判断错误。例如继续遍历matrix[i][j+1]时,matrix[i][j+1]的元素被置零了,所以可能导致对应的列原本不应该被置为零的也被置零了。

列表推导式

# 语法:[表达式 for 变量 in 可迭代对象]
# 你的代码:
col_has_zero = [0 in col for col in zip(*matrix)]
# 等价于:
result = []
for col in zip(*matrix):
    result.append(0 in col)

矩阵转置速查

matrix = [[1,2,3], [4,5,6]]

# 方法1:zip(*matrix) ⭐ 推荐
list(zip(*matrix))  # [(1,4), (2,5), (3,6)]

# 方法2:列表推导式
[[row[i] for row in matrix] for i in range(len(matrix[0]))]

# 方法3:NumPy
import numpy as np
np.array(matrix).T

# 原理:*解包行 → zip打包对应位置 → 列变行

优化解法

如何使用常量空间解决该问题?

class Solution:
    def setZeroes(self, matrix: List[List[int]]) -> None:
        """
        Do not return anything, modify matrix in-place instead.
        """
        # 判断第一行和第一列是否有0
        first_row_has_zero = 0 in matrix[0]
        first_col_has_zero = any(row[0] == 0 for row in matrix)  # 记录第一列是否包含 0

        m = len(matrix)
        n = len(matrix[0])

        for i in range(1, m):
            for j in range(1, n):
                if matrix[i][j] == 0:
                    matrix[0][j] = matrix[i][0] = 0

        for i in range(1, m):
            for j in range(1, n):
                if matrix[i][0] == 0 or matrix[0][j] == 0:
                    matrix[i][j] = 0

        if first_row_has_zero:
            for i in range(n):
                matrix[0][i] = 0

        if first_col_has_zero:
            for i in range(m):
                matrix[i][0] = 0

不使用额外数组,考虑将数据直接保存到 matrix 的第一行和第一列中,类似于表格的第一行和第一列,至于原本第一行和第一列是否含有0,我们用两个额外变量存储记录即可。

any()用法

1. 详解 any() 语法与生成器表达式

代码行:

first_col_has_zero = any(row[0] == 0 for row in matrix)

A. any() 函数的作用

any(iterable) 是 Python 的内建函数。

  • 功能:判断可迭代对象(iterable)中是否存在至少一个为 True 的元素。
  • 返回值
    • 如果有任何一个元素为真,返回 True
    • 如果所有元素都为假,或者可迭代对象为空,返回 False
  • 特性(重要):它具有 短路(Short-circuit) 特性。一旦遇到第一个 True,它就会立即停止遍历并返回 True,不会继续处理剩下的元素。

B. 括号内的部分:生成器表达式

row[0] == 0 for row in matrix 是一个 生成器表达式(Generator Expression)

  • 语法(表达式 for 变量 in 可迭代对象)。注意这里最外层的括号被 any() 的括号省略了。
  • 与列表推导式的区别
    • 列表推导式:[row[0] == 0 for row in matrix] 会立即在内存中创建一个包含所有布尔值的新列表。
    • 生成器表达式:(row[0] == 0 for row in matrix) 不会立即创建列表。它返回一个迭代器,每次只计算并产出下一个值。

C. 结合后的执行流程

  1. any() 开始请求生成器的第一个值。
  2. 生成器执行 row = matrix[0],计算 matrix[0][0] == 0
  3. 如果结果是 Trueany() 立即返回 True,生成器停止,后续的行根本不会被访问
  4. 如果结果是 Falseany() 请求下一个值,生成器继续处理 matrix[1],以此类推。
  5. 如果遍历完所有行都没发现 0any() 返回 False

D. 为什么这样写很好?

  • 空间 \(O(1)\):因为是生成器,没有创建额外的列表存储中间结果。
  • 时间最优:因为短路机制,如果第一列很早就出现了 0,后面的行就不需要检查了。

2. 如果使用 list(zip(*matrix)) 判断第一列的空间分析

你提到的替代方案可能是想通过转置矩阵,把“第一列”变成“第一行”,然后判断第一行是否有 0。代码可能长这样:

# 不推荐的写法
transposed = list(zip(*matrix)) 
first_col_has_zero = 0 in transposed[0]

A. zip(*matrix) 做了什么?

  • *matrix 是解包操作,将矩阵的每一行作为独立参数传给 zip
  • zip 会将这些行的对应位置元素打包。
  • 效果:它实现了矩阵的 转置(Transpose)
    • 原矩阵:\(M\)\(N\) 列。
    • zip 结果:\(N\)\(M\) 列(实际上是 \(N\) 个元组,每个元组长度为 \(M\))。
    • 原矩阵的“第一列”,变成了转置后矩阵的“第一行”(即 transposed[0])。

B. 空间复杂度分析 这是最关键的部分。

  1. zip(*matrix) 本身:在 Python 3 中,zip 返回的是一个迭代器,此时空间开销很小。
  2. list(...) 包裹:当你使用 list() 强制转换时,Python 会遍历整个迭代器并将所有结果存入内存
    • 这意味着你创建了一个全新的、包含原矩阵所有数据的新结构。
    • 原矩阵有 \(M \times N\) 个元素。
    • 转置后的列表也有 \(M \times N\) 个元素。
  3. 结论
    • 额外空间复杂度\(O(M \times N)\)
    • 原代码空间复杂度\(O(1)\)

C. 为什么这是“杀鸡用牛刀”且违规的?

"矩阵置零" 这道题的核心难点通常要求 \(O(1)\) 额外空间复杂度(即不能使用与矩阵大小成正比的额外内存)。

  • 如果你使用 list(zip(*matrix)),你为了判断第一列\(M\) 个元素)是否有 0,竟然在内存中复制了整个矩阵\(M \times N\) 个元素)。
  • 如果矩阵是 \(1000 \times 1000\),你只是为了检查 1000 个数,却额外开辟了存储 1,000,000 个数的内存。
  • 这不仅浪费了空间,还浪费了时间(因为 list() 必须遍历完所有元素才能完成构建,无法像 any() 那样短路)。

螺旋矩阵

给你一个 m 行 n 列的矩阵 matrix ,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。

class Solution:
    def spiralOrder(self, matrix: List[List[int]]) -> List[int]:
        if not matrix or not matrix[0]:
            return []

        result = []
        top, bottom = 0, len(matrix) - 1
        left, right = 0, len(matrix[0]) - 1

        while top <= bottom and left <= right:
            # 1. 从左到右遍历上边
            for col in range(left, right + 1):
                result.append(matrix[top][col])
            top += 1  # 上边界下移

            # 2. 从上到下遍历右边
            for row in range(top, bottom + 1):
                result.append(matrix[row][right])
            right -= 1  # 右边界左移

            # 3. 从右到左遍历下边(需检查是否还有有效行)
            if top <= bottom:
                for col in range(right, left - 1, -1):
                    result.append(matrix[bottom][col])
                bottom -= 1  # 下边界上移

            # 4. 从下到上遍历左边(需检查是否还有有效列)
            if left <= right:
                for row in range(bottom, top - 1, -1):
                    result.append(matrix[row][left])
                left += 1  # 左边界右移

        return result

直接模拟即可,使用 top, bottom, left, right 四个边界指针,每遍历完一条边就收缩边界,并在遍历"回程"边时检查是否还有有效区域。

  • 边界检查:遍历下边和左边前,用 if top <= bottomif left <= right 确保不会重复遍历单行/单列
  • 边界收缩:每完成一条边立即收缩对应边界,逻辑更清晰
  • 无需特殊处理中心点:循环条件 top <= bottom and left <= right 自动处理所有情况

旋转图像

给定一个 n × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。

你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。

class Solution:
    def rotate(self, matrix: List[List[int]]) -> None:
        # 转置 + 转为列表
        matrix[:] = [list(row) for row in zip(*matrix)]
        # 每行反转(用切片,更简洁)
        for i in range(len(matrix)):
            matrix[i] = matrix[i][::-1]

🕳️ 核心坑点

# ❌ 错误:zip 产出的是 tuple,tuple 不可修改
matrix[:] = list(zip(*matrix))  # → [(1,4,7), (2,5,8), ...] 内层是元组
matrix[0][0] = 100  # TypeError: 'tuple' object does not support item assignment

# ✅ 正确:转成 list 再赋值
matrix[:] = [list(row) for row in zip(*matrix)]  # → [[1,4,7], [2,5,8], ...]

📌 一句话zip(*matrix) 转置后,记得把内层 tuple 转成 list,否则无法原地修改。


🎨 优雅翻转写法

水平翻转(每行反转)

# 方法1:切片反转(最简洁 ✅)
for i in range(n):
    matrix[i] = matrix[i][::-1]

# 方法2:reverse() 方法(原地修改)
for i in range(n):
    matrix[i].reverse()

# 方法3:双指针交换(传统写法)
for i in range(n):
    for j in range(n//2):
        matrix[i][j], matrix[i][n-1-j] = matrix[i][n-1-j], matrix[i][j]

完整旋转90°(转置 + 翻转)

class Solution:
    def rotate(self, matrix: List[List[int]]) -> None:
        # 转置 + list转换
        matrix[:] = [list(row) for row in zip(*matrix)]
        # 每行水平翻转
        for i in range(len(matrix)):
            matrix[i] = matrix[i][::-1]  # ✨ 优雅!

纯原地算法(空间 O(1))

class Solution:
    def rotate(self, matrix: List[List[int]]) -> None:
        n = len(matrix)
        # 1. 沿主对角线转置
        for i in range(n):
            for j in range(i+1, n):
                matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
        # 2. 每行反转
        for row in matrix:
            row.reverse()  # ✨ 直接对 row 操作,更简洁


🧠 记忆口诀

旋转90° = 转置 + 水平翻转
转置用 zip 记得转 list
翻转用 [::-1] 或 reverse()

搜索二维矩阵

编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性:

  • 每行的元素从左到右升序排列。
  • 每列的元素从上到下升序排列。

下面是我自己的一种思路,其实也就是比直接暴力枚举好一些。

class Solution:
    def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
        m, n = len(matrix), len(matrix[0])
        m0, n0 = m-1, n-1  # 初始化搜索边界:最大行/列索引

        # Step 1: 遍历第一列,缩小行的搜索范围
        # 如果某行首元素 > target,说明target不可能在该行及之后的行
        for i in range(m):
            if matrix[i][0] > target:
                m0 = i - 1  # 更新最大行号
                break
            elif matrix[i][0] == target:  # 命中直接返回
                return True

        # Step 2: 遍历第一行,缩小列的搜索范围
        # 如果某列首元素 > target,说明target不可能在该列及之后的列
        for i in range(n):
            if matrix[0][i] > target:
                n0 = i - 1  # 更新最大列号
                break
            elif matrix[0][i] == target:
                return True

        # Step 3: 在缩小后的范围 [0..m0][0..n0] 内暴力搜索
        # ⚠️ 从右下角往左上角遍历,但仍是 O(m*n)
        for i in range(m0, -1, -1):
            for j in range(n0, -1, -1):
                if matrix[i][j] == target:
                    return True

        return False  # 未找到

优化

从右上角进行搜索。

class Solution:
    def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
        m, n = len(matrix), len(matrix[0])
        i, j = 0, n - 1  # 从右上角开始
        while i < m and j >= 0:  # 还有剩余元素
            if matrix[i][j] == target:
                return True  # 找到 target
            if matrix[i][j] < target:
                i += 1  # 这一行剩余元素全部小于 target,排除
            else:
                j -= 1  # 这一列剩余元素全部大于 target,排除
        return False

上述代码由于要么对列进行缩小,要么对行进行扩大,因此时间复杂度是 \(O(m+n)\)

为什么可以这么做?其实就跟我上面暴力法优化的思路是一样的。利用了自上而下,自左向右递增的矩阵特性。

链表

相交链表

给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null 。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, x):
#         self.val = x
#         self.next = None

class Solution:
    def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> Optional[ListNode]:
        p, q = headA, headB
        while p is not q:
            p = p.next if p else headB
            q = q.next if q else headA
        return p

利用了一个技巧:\((a + c) + b = (b + c) + a\),则都走\(a + b + c\)时,p和q同时到达链表相交的起始节点,要么就是都走\(a + b + 2c\)同时最终走到空节点。

具体算法如下:

  1. 初始化两个指针 p=headA, q=headB。
  2. 不断循环,直到 p=q。
  3. 每次循环,p 和 q 各向后走一步。具体来说,如果 p 不是空节点,那么更新 p 为 p.next,否则更新 p 为 headB;如果 q 不是空节点,那么更新 q 为 q.next,否则更新 q 为 headA。
  4. 循环结束时,如果两条链表相交,那么此时 p 和 q 都在相交的起始节点处,返回 p;如果两条链表不相交,那么 p 和 q 都走到空节点,所以也可以返回 p,即空节点。

is not!= 的本质区别

核心考点:对象身份(内存地址)vs 对象值。

  • is / is not:比较身份。判断是否是同一个对象(内存地址相同)。
    • 适用场景:判断 None、判断链表节点是否重合。
    • 示例:while p is not q:(判断是否指向同一个节点对象)。
  • == / !=:比较。调用对象的 __eq__ 方法。
    • 风险:如果链表节点值相同但对象不同,!= 可能误判为相等从而提前终止循环。
  • 最佳实践
    • 判断 None:✅ if x is None: / ❌ if x == None:
    • 判断节点相遇:✅ if p is q: / ❌ if p == q:

反转链表

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        # 虚拟头节点
        dummy = ListNode()
        p = head
        while p:
            node = ListNode(p.val, dummy.next)
            dummy.next = node
            p = p.next
        return dummy.next

我这里虚拟头节点的思路,最后新建了一条链表。

class Solution:
    def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        pre = None
        cur = head
        while cur:
            nxt = cur.next
            cur.next = pre  # 把 cur 插在 pre 链表的前面(头插法)
            pre = cur
            cur = nxt
        return pre

也可以直接在原链表上进行操作,改变指针即可。

回文链表

给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false 。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def isPalindrome(self, head: Optional[ListNode]) -> bool:

        def middleNode(head: Optional[ListNode]) -> Optional[ListNode]:
            slow = fast = head
            while fast and fast.next:
                slow = slow.next
                fast = fast.next.next
            return slow

        def reverseList(head: Optional[ListNode]) -> Optional[ListNode]:
            pre = None
            cur = head
            while cur:
                nxt = cur.next
                cur.next = pre  # 把 cur 插在 pre 链表的前面(头插法)
                pre = cur
                cur = nxt
            return pre

        mid = middleNode(head)
        head2 = reverseList(mid)
        while head2:
            if head.val != head2.val:
                return False
            head = head.next
            head2 = head2.next

        return True

比较简单的想法就是借由上一题的翻转链表,将链表翻转,然后比较翻转后的链表和原链表是否相同。只不过这样的方法时间复杂度是 \(O(n)\),空间复杂度是 \(O(n)\)

转换思路:利用快慢指针,找到链表的中点,然后对中点之后的链表进行翻转,最后比较两个链表是否相同,如此也不需要额外的空间。

环形链表

给你一个链表的头节点 head ,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。

如果链表中存在环 ,则返回 true 。 否则,返回 false 。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, x):
#         self.val = x
#         self.next = None

class Solution:
    def hasCycle(self, head: Optional[ListNode]) -> bool:
        slow = fast = head
        while fast and fast.next:
            slow = slow.next          # 走 1 步
            fast = fast.next.next     # 走 2 步
            if slow == fast:          # 相遇
                return True
        return False

依旧快慢指针,如果有环的链表,快慢指针最终会相遇。

环形链表Ⅱ

给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

不允许修改 链表。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, x):
#         self.val = x
#         self.next = None

class Solution:
    def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
        slow = fast = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
            if fast is slow:
                slow = head
                while slow is not fast:
                    slow = slow.next
                    fast = fast.next
                return slow

        return None

依旧快慢指针,只是这次需要确定入口位置,有点麻烦。

  • 慢指针走 b 步,则快指针就走了 2b 步。\(2b - b = kc\),其中 k 是环的周数,c 是环的长度,于是有\(b = kc\)。假设自头节点到入环口有 a 步,\(b - a = kc - a\)
  • 注 1:kc−a 是从入环口开始的步数。因为 (kc−a)+a=kc,所以从 kc−a 开始,再走 a 步,就可以走满 k 圈。
  • 注 2:慢指针从相遇点开始,移动 a 步后恰好走到入环口,但在这个过程中,可能会多次经过入环口。
  • 注 3:这个算法叫做 Floyd 判圈算法。

合并两个有序链表

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]:
        dummy = ListNode()
        p = dummy
        while list1 and list2:
            if list1.val <= list2.val:
                p.next = list1
                list1 = list1.next
            else:
                p.next = list2
                list2 = list2.next
            p = p.next

        # if list1:
        #     p.next = list1
        # if list2:
        #     p.next = list2
        p.next = list1 or list2

        return dummy.next

创建一个虚拟节点 dummy,然后创建一个指针 p 指向 dummy。然后,比较 list1 和 list2 的值,将较小的节点连接到 p 的 next,并更新指针 p 和对应链表的指针。重复这个过程,直到某个链表为空。最后,将 p 的 next 指向剩余的链表。

1. or 运算符的真实返回值

A or B 确实返回操作数本身,而不是 True/False

# 验证返回值类型
result = 3 or 5
print(result)        # 输出:3
print(type(result))  # 输出:<class 'int'>,不是 bool!

result = None or "hello"
print(result)        # 输出:"hello"
print(type(result))  # 输出:<class 'str'>,不是 bool!

result = [] or [1, 2]
print(result)        # 输出:[1, 2]
print(type(result))  # 输出:<class 'list'>,不是 bool!

2. 条件语句中的"隐式转换"

or 表达式用在 if / while 中时,Python 会自动对返回值做真值测试

# 这两行代码的实际执行过程:
if list1 or list2:           # ① 先计算 list1 or list2,返回某个对象
    ...                      # ② 再对这个对象做 bool() 隐式转换

# 等价于:
temp = list1 or list2        # 返回操作数本身
if bool(temp):               # 隐式调用 bool() 做真值测试
    ...

真值测试规则

对象 bool() 结果 在条件中视为
None False
0 False
"" (空字符串) False
[] (空列表) False
非空对象 True

3. 对比演示

list1 = ListNode(1)  # 非空节点
list2 = None         # 空

# --- 场景 1:直接打印返回值 ---
result = list1 or list2
print(result)        # 输出:ListNode 对象本身

# --- 场景 2:用在条件语句中 ---
if list1 or list2:   # ① 返回 ListNode 对象
    print("进入分支") # ② Python 隐式调用 bool(ListNode) → True

完整执行流程

if list1 or list2:
    ↓
计算表达式 → 返回 list1 (ListNode 对象)
    ↓
隐式转换 → bool(ListNode 对象) → True
    ↓
进入 if 分支


4. 危险陷阱示例

这就是为什么我说"需谨慎"——如果操作数是其他假值:

# 链表场景(安全✅)
list1 = None
list2 = ListNode(0)   # 节点值为 0,但对象本身非空
p.next = list1 or list2  # 返回 ListNode 对象 ✅

# 数字场景(危险❌)
a = 0
b = 10
result = a or b      # 返回 10,而不是 0!
if a or b:           # 进入分支(因为 10 是真值)
    print("有值")    
# 但你原本可能想保留 0 这个有效值!

5. 总结对比表

场景 or 的行为 返回值类型
x = A or B 返回操作数本身 A 或 B 的原类型
if A or B: 返回操作数 → 隐式 bool() 转换 条件判断用
x = bool(A or B) 显式转换为布尔值 bool

一句话理解

or 永远返回操作数本身;条件语句中是 Python 隐式调用 bool() 做了二次转换。

两数相加

给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。

请你将两个数相加,并以相同形式返回一个表示和的链表。

你可以假设除了数字 0 之外,这两个数都不会以 0 开头。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def addTwoNumbers(self, l1: Optional[ListNode], l2: Optional[ListNode]) -> Optional[ListNode]:
        dummy = p = ListNode()
        sign = 0
        while l1 or l2:
            val1 = l1.val if l1 else 0
            val2 = l2.val if l2 else 0
            sum0 = val1 + val2 + sign
            val = sum0 % 10
            sign = sum0 // 10
            p.next = ListNode(val)
            p = p.next
            l1 = l1.next if l1 else l1
            l2 = l2.next if l2 else l2
        if sign:
            p.next = ListNode(sign)

        return dummy.next

简单模拟即可,就是简单的加法运算模拟。

三元表达式

value = A if condition else B

执行逻辑:

  • 如果 condition 为 True → 返回 A
  • 如果 condition 为 False → 返回 B

确实是一个很常用的语法,可以简化代码。

删除链表的倒数第 N 个结点

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
        dummy = ListNode()
        dummy.next = head
        slow = fast = dummy
        for i in range(n):
            fast = fast.next
        while fast and fast.next:
            slow = slow.next
            fast = fast.next
        slow.next = slow.next.next
        return dummy.next

链表操作题,最简单的解法是使用两个指针,一个指针先走 n 步,然后两个指针同时走,当第一个指针到达末尾时,第二个指针指向的节点就是倒数第 n 个节点。

两两交换链表中的节点

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]:
        if not head:
            return head
        dummy = ListNode()
        dummy.next = head
        pre = dummy
        fast, slow = head.next, head
        while fast and slow:
            slow.next = fast.next
            fast.next = slow
            pre.next = fast
            pre = slow
            slow = slow.next
            if slow:
                fast = slow.next

        return dummy.next

本题也就是一前一后两个快慢指针不停移动,交换两个节点。

随机链表的复制

给你一个长度为 n 的链表,每个节点包含一个额外增加的随机指针 random ,该指针可以指向链表中的任何节点或空节点。

构造这个链表的 深拷贝。 深拷贝应该正好由 n 个 全新 节点组成,其中每个新节点的值都设为其对应的原节点的值。新节点的 next 指针和 random 指针也都应指向复制链表中的新节点,并使原链表和复制链表中的这些指针能够表示相同的链表状态。复制链表中的指针都不应指向原链表中的节点 。

例如,如果原链表中有 X 和 Y 两个节点,其中 X.random --> Y 。那么在复制链表中对应的两个节点 x 和 y ,同样有 x.random --> y 。

返回复制链表的头节点。

用一个由 n 个节点组成的链表来表示输入/输出中的链表。每个节点用一个 [val, random_index] 表示:

  • val:一个表示 Node.val 的整数。
  • random_index:随机指针指向的节点索引(范围从 0 到 n-1);如果不指向任何节点,则为 null 。 你的代码 只 接受原链表的头节点 head 作为传入参数。
class Solution:
    def copyRandomList(self, head: 'Optional[Node]') -> 'Optional[Node]':
        if not head:
            return None

        cache = {}  # 核心:原节点 -> 新节点
        cur = head

        # 第一次遍历:创建所有新节点,建立映射
        while cur:
            cache[cur] = Node(cur.val)
            cur = cur.next

        # 第二次遍历:连接 next 和 random 指针
        cur = head
        while cur:
            # 通过映射找到对应的新节点,设置指针
            cache[cur].next = cache.get(cur.next)      # cur.next 可能为 None
            cache[cur].random = cache.get(cur.random)  # cur.random 可能为 None
            cur = cur.next

        return cache[head]

我想到的最直接的方法就是建立哈希表的映射关系,然后根据映射关系建立新链表。如此,对应的next以及random指针都指向了正确的节点。

交错链表

class Solution:
    def copyRandomList(self, head: 'Optional[Node]') -> 'Optional[Node]':
        # 复制每个节点,把新节点直接插到原节点的后面
        cur = head
        while cur:
            cur.next = Node(cur.val, cur.next)
            cur = cur.next.next

        # 遍历交错链表中的原链表节点
        cur = head
        while cur:
            if cur.random:
                # 要复制的 random 是 cur.random 的下一个节点
                cur.next.random = cur.random.next
            cur = cur.next.next

        # 删除交错链表中的原链表节点,剩下的节点即为新链表
        cur = dummy = Node(0, head)
        while cur.next:
            # 删除原链表的节点,即当前节点的下一个节点
            # 如果要恢复原链表,见另一份代码【Python3 写法二】
            cur.next = cur.next.next
            cur = cur.next

        return dummy.next

一次复制每个节点,创建新节点并复制valnext指针。将新节点直接查到原节点的后面,形成一个交错链表,如此一来,原链表节点的下一个节点,就是其对应的新链表节点。

然后遍历这个交错链表,假如节点 1 的random指针指向节点 2 ,那么节点 1 的对应新链表节点 1' 的random指针指向节点 2 的对应新链表节点,也就是节点 2 的下一个节点 2'。

排序链表

给定链表的头结点 head ,请将其按升序排序并返回排序后的链表。

class Solution:
    # 876. 链表的中间结点(快慢指针)
    def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]:
        slow = fast = head
        while fast and fast.next:
            pre = slow  # 记录 slow 的前一个节点
            slow = slow.next
            fast = fast.next.next
        pre.next = None  # 断开 slow 的前一个节点和 slow 的连接
        return slow

    # 21. 合并两个有序链表(双指针)
    def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]:
        cur = dummy = ListNode()  # 用哨兵节点简化代码逻辑
        while list1 and list2:
            if list1.val < list2.val:
                cur.next = list1  # 把 list1 加到新链表中
                list1 = list1.next
            else:  # 注:相等的情况加哪个节点都是可以的
                cur.next = list2  # 把 list2 加到新链表中
                list2 = list2.next
            cur = cur.next
        cur.next = list1 if list1 else list2  # 拼接剩余链表
        return dummy.next

    def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        # 如果链表为空或者只有一个节点,无需排序
        if head is None or head.next is None:
            return head
        # 找到中间节点 head2,并断开 head2 与其前一个节点的连接
        # 比如 head=[4,2,1,3],那么 middleNode 调用结束后 head=[4,2] head2=[1,3]
        head2 = self.middleNode(head)
        # 分治
        head = self.sortList(head)
        head2 = self.sortList(head2)
        # 合并
        return self.mergeTwoLists(head, head2)

使用归并排序的思路,分治,然后合并。

  • 找到链表的中间节点,并断开中间节点与前一个节点的连接。如此,我们就把原来的链表均分成了两段更加短的链表。
  • 分治,递归调用函数对两段链表进行排序。
  • 排序后,我们得到了两个有序链表,此刻可以合并这两个有序链表,从而得到排序后的链表。

LRU 缓存

class LRUCache:

    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = OrderedDict()


    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1
        self.cache.move_to_end(key, last=False)
        return self.cache[key]


    def put(self, key: int, value: int) -> None:
        self.cache[key] = value
        self.cache.move_to_end(key, last=False)
        if len(self.cache) > self.capacity:
            self.cache.popitem()



# Your LRUCache object will be instantiated and called as such:
# obj = LRUCache(capacity)
# param_1 = obj.get(key)
# obj.put(key,value)

🔹 OrderedDict 是什么?

OrderedDict 是 Python collections 模块中的一个子类,它在普通 dict 的基础上额外维护了一个双向链表,用于记录键值对的插入顺序。

from collections import OrderedDict

# 普通 dict(Python 3.7+ 也保持插入顺序,但功能有限)
d = dict()
d['a'] = 1; d['b'] = 2; d['c'] = 3

# OrderedDict(功能更强大,支持顺序操作)
od = OrderedDict()
od['a'] = 1; od['b'] = 2; od['c'] = 3
特性 普通 dict OrderedDict
保持插入顺序 ✅ (3.7+) ✅ (所有版本)
move_to_end()
popitem(last=?) 只能删最后 可删头/尾 ✅
顺序相等性比较 值相等即可 顺序+值都需相等

🔹 核心方法详解

move_to_end(key, last=True)

将指定 key 移动到有序字典的末尾last=True)或开头last=False)。

od = OrderedDict([('a', 1), ('b', 2), ('c', 3)])

# 移到末尾(默认)
od.move_to_end('a')  
# OrderedDict([('b', 2), ('c', 3), ('a', 1)])

# 移到开头(LRU 常用!)
od.move_to_end('b', last=False)  
# OrderedDict([('b', 2), ('c', 3), ('a', 1)])
popitem(last=True)

删除并返回一个 (key, value) 对:

  • last=True(默认):删除最后插入的项(FIFO/队列尾)
  • last=False:删除最早插入的项(LRU 淘汰关键!)
od = OrderedDict([('x', 10), ('y', 20), ('z', 30)])

# 删除末尾(最新)
od.popitem(last=True)   # ('z', 30)

# 删除开头(最旧)← LRU 淘汰策略核心
od.popitem(last=False)  # ('x', 10)
✅ 其他常用操作
# 初始化
OrderedDict([('a', 1), ('b', 2)])
OrderedDict(a=1, b=2)

# 遍历(按顺序)
for k, v in od.items(): ...

# 反向遍历
for k, v in reversed(od.items()): ...

# 判断顺序相等
OrderedDict(a=1, b=2) == OrderedDict(b=2, a=1)  # False!

🔹 在 LRU Cache 中的工作原理

最近使用的 → 放在链表头(last=False)
最久未使用的 → 沉在链表尾 → 超容时淘汰
# 假设 capacity = 2
cache = OrderedDict()

# put(1, 1):  [ (1,1) ]
# put(2, 2):  [ (2,2), (1,1) ]     ← 新元素放头部
# get(1):     [ (1,1), (2,2) ]     ← 访问1,移到头部
# put(3, 3):  [ (3,3), (1,1) ]     ← 淘汰尾部(2,2)
# get(2):     返回 -1(已被淘汰)

🔑 关键逻辑:

  1. 访问/插入move_to_end(key, last=False) 提到头部(标记为"最近使用")
  2. 超容淘汰popitem(last=True) 删除尾部("最久未使用")

💡 为什么 last 参数在 getput 中都用 False,但 popitem 用默认 True
因为:我们把头当作"最新"尾当作"最旧",淘汰时自然删尾部。


🔹 完整优化版 LRU Cache 实现

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = OrderedDict()

    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1
        # 访问后移到头部(标记为最近使用)
        self.cache.move_to_end(key, last=False)
        return self.cache[key]

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            # 更新值时也需移到头部
            self.cache.move_to_end(key, last=False)
        self.cache[key] = value
        self.cache.move_to_end(key, last=False)  # 新元素放头部

        # 超容:淘汰尾部(最久未使用)
        if len(self.cache) > self.capacity:
            self.cache.popitem(last=True)  # ✅ 注意这里是 last=True!

⚠️ 你原始代码中的小问题:

self.cache.popitem()  # 默认 last=True,是对的 ✅
但为了代码可读性,建议显式写出 last=True,避免他人误解。


🔹 进阶:不用 OrderedDict 的手动实现(面试加分项)

如果面试官问"不用 OrderedDict 怎么实现?",可以用哈希表 + 双向链表

class DLinkedNode:
    def __init__(self, key=0, value=0):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity: int):
        self.cache = {}  # key -> Node
        self.capacity = capacity
        # 伪头尾节点,简化边界处理
        self.head = DLinkedNode()
        self.tail = DLinkedNode()
        self.head.next = self.tail
        self.tail.prev = self.head

    def _add_to_head(self, node):
        # 在 head 后插入 node(头插法)
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node

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

    def _move_to_head(self, node):
        self._remove_node(node)
        self._add_to_head(node)

    def _pop_tail(self):
        # 删除 tail 前的节点(最久未使用)
        node = self.tail.prev
        self._remove_node(node)
        return node

    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1
        node = self.cache[key]
        self._move_to_head(node)  # 标记为最近使用
        return node.value

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            node = self.cache[key]
            node.value = value
            self._move_to_head(node)
        else:
            node = DLinkedNode(key, value)
            self.cache[key] = node
            self._add_to_head(node)
            if len(self.cache) > self.capacity:
                removed = self._pop_tail()
                del self.cache[removed.key]

🎯 总结:OrderedDict 是 Python 内置的"哈希表+双向链表"封装,用 5 行代码搞定手动实现 50 行的逻辑,实际开发首选;但理解底层原理对面试至关重要!

class Node:
    # 提高访问属性的速度,并节省内存
    __slots__ = 'prev', 'next', 'key', 'value'

    def __init__(self, key=0, value=0):
        self.key = key
        self.value = value

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.dummy = Node()  # 哨兵节点
        self.dummy.prev = self.dummy
        self.dummy.next = self.dummy
        self.key_to_node = {}

    # 获取 key 对应的节点,同时把该节点移到链表头部
    def get_node(self, key: int) -> Optional[Node]:
        if key not in self.key_to_node:  # 没有这本书
            return None
        node = self.key_to_node[key]  # 有这本书
        self.remove(node)  # 把这本书抽出来
        self.push_front(node)  # 放到最上面
        return node

    def get(self, key: int) -> int:
        node = self.get_node(key)  # get_node 会把对应节点移到链表头部
        return node.value if node else -1

    def put(self, key: int, value: int) -> None:
        node = self.get_node(key)  # get_node 会把对应节点移到链表头部
        if node:  # 有这本书
            node.value = value  # 更新 value
            return
        self.key_to_node[key] = node = Node(key, value)  # 新书
        self.push_front(node)  # 放到最上面
        if len(self.key_to_node) > self.capacity:  # 书太多了
            back_node = self.dummy.prev
            del self.key_to_node[back_node.key]
            self.remove(back_node)  # 去掉最后一本书

    # 删除一个节点(抽出一本书)
    def remove(self, x: Node) -> None:
        x.prev.next = x.next
        x.next.prev = x.prev

    # 在链表头添加一个节点(把一本书放到最上面)
    def push_front(self, x: Node) -> None:
        x.prev = self.dummy
        x.next = self.dummy.next
        x.prev.next = x
        x.next.prev = x

K个一组翻转链表

class Solution:
    # 辅助函数:翻转从 head 开始的 k 个节点,返回新的头节点
    def reverseKNodes(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
        pre = None
        cur = head
        for _ in range(k):
            nxt = cur.next
            cur.next = pre
            pre = cur
            cur = nxt
        return pre  # 返回翻转后的新头

    def reverseKGroup(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
        dummy = ListNode(0)
        dummy.next = head
        group_prev = dummy  # 记录每一组的前一个节点

        while True:
            # 1. 检查剩余节点是否足够 k 个
            group_end = group_prev
            for _ in range(k):
                group_end = group_end.next
                if not group_end:  # 不足 k 个,直接结束
                    return dummy.next

            # 2. 记录关键节点(用于断开和重连)
            group_head = group_prev.next  # 当前组的头(翻转后将变尾)
            group_next = group_end.next   # 下一组的头

            # 3. 断开当前组,准备翻转
            group_end.next = None

            # 4. 翻转当前组
            new_head = self.reverseKNodes(group_head, k)

            # 5. 重连:前一组 -> 新头,新尾 -> 下一组
            group_prev.next = new_head
            group_head.next = group_next  # group_head 现在是尾节点

            # 6. 移动指针,准备处理下一组
            group_prev = group_head

        return dummy.next

合并K个升序链表

给你一个链表数组,每个链表都已经按升序排列。

请你将所有链表合并到一个升序链表中,返回合并后的链表。

分治

class Solution:
    # 21. 合并两个有序链表
    def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]:
        cur = dummy = ListNode()  # 用哨兵节点简化代码逻辑
        while list1 and list2:
            if list1.val < list2.val:
                cur.next = list1  # 把 list1 加到新链表中
                list1 = list1.next
            else:  # 注:相等的情况加哪个节点都是可以的
                cur.next = list2  # 把 list2 加到新链表中
                list2 = list2.next
            cur = cur.next
        cur.next = list1 if list1 else list2  # 拼接剩余链表
        return dummy.next

    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        m = len(lists)
        if m == 0:
            return None
        if m == 1:
            return lists[0]  # 无需合并,直接返回
        left = self.mergeKLists(lists[:m // 2])  # 合并左半部分
        right = self.mergeKLists(lists[m // 2:])  # 合并右半部分
        return self.mergeTwoLists(left, right)  # 最后把左半和右半合并

利用了分治的思路,将问题分解为子问题,最后合并结果。我们将lists数组分为左右两部分,分别处理,最后合并结果。如何分别合并一半的链表呢?我们可以继续一分为二,如此分下去直到只有一个链表,此时就无需合并了,直接返回。

最小堆

其实也就是每次同时比较 k 个链表,但是我们可以利用最小堆来优化。由于题目已经明确的要求,链表是升序排列的,那么最小的节点就是链表的头节点。每次找出这些头节点的最小值,并添加到新链表中,同时将下一个节点入堆。这样,每次取出的节点就是最小的节点,然后添加到新链表中,同时将下一个节点入堆。这样,我们最终得到一个有序的链表。

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

class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        cur = dummy = ListNode()  # 哨兵节点,作为合并后链表头节点的前一个节点
        h = [head for head in lists if head]  # 把所有非空链表的头节点入堆
        heapify(h)  # 堆化
        while h:  # 循环直到堆为空
            node = heappop(h)  # 剩余节点中的最小节点
            if node.next:  # 下一个节点不为空
                heappush(h, node.next)  # 下一个节点有可能是最小节点,入堆
            cur.next = node  # 把 node 添加到新链表的末尾
            cur = cur.next  # 准备合并下一个节点
        return dummy.next  # 哨兵节点的下一个节点就是新链表的头节点

🔹 核心难点:ListNode.__lt__ 是什么?

这行代码是 Python 特有的“魔法”写法,目的是让优先队列(堆)知道如何比较两个链表节点的大小

ListNode.__lt__ = lambda a, b: a.val < b.val
  • __lt__ 含义:它是 Python 的魔术方法(Magic Method),代表 Less Than(小于号 <)。
  • 底层机制:当你写 a < b 时,Python 底层实际调用的是 a.__lt__(b)
  • 这行代码作用:动态地给 ListNode挂载了一个比较规则:“比较节点时,请比较它们的 val 值”

🔹 为什么要写这行?

Python 的 heapq 模块是一个 最小堆。为了维持堆的性质,它必须能够比较元素的大小(判断谁更小,谁应该浮到堆顶)。

  • 基本类型(如 int, float):Python 天生知道 1 < 2
  • 自定义对象(如 ListNode):Python 默认不知道 怎么比较。如果你直接写 node1 < node2,会报错:
    TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'
    
  • 解决方案:必须告诉 Python 如何比较 ListNode。这行代码就是最简短的解决方案。

🔹 这种写法叫什么?

这叫 Monkey Patching(猴子补丁)

  • 含义:在运行时动态修改类或模块的行为。
  • 优点:代码极短,无需额外定义包装类,刷题神器。
  • 缺点污染全局环境。一旦修改,当前程序中所有的 ListNode 比较都会按这个规则来。在工程开发中需谨慎,但在算法竞赛/刷题中非常实用。

🔹 如果不写这行,该怎么写?

在正式工作中,我们通常不修改原类,而是创建包装类使用元组

✅ 方案 A:使用元组(最常用)

(值,节点) 放入堆。Python 比较元组时,会先比第一个元素(值)。

# 不需要修改 ListNode
h = []
for head in lists:
    if head:
        # (值,节点) 元组入堆
        heapq.heappush(h, (head.val, head)) 

while h:
    val, node = heapq.heappop(h)  # 解包
    # ... 处理 node ...
    if node.next:
        heapq.heappush(h, (node.next.val, node.next))

💡 注意:如果 val 相同,元组会比较第二个元素(节点对象),可能再次报错。但在本题中 val 相同不影响逻辑,或者可以加一个唯一索引 (val, i, node)

✅ 方案 B:包装类(最规范)
class Wrapper:
    def __init__(self, node):
        self.node = node
    def __lt__(self, other):
        return self.node.val < other.node.val

h = [Wrapper(head) for head in lists if head]
heapq.heapify(h)
# 使用时取 wrapper.node

🔹 代码逻辑全流程解析

抛开 __lt__ 的魔法,这段代码的算法逻辑非常清晰:

1️⃣ 哨兵节点(Dummy Node)
cur = dummy = ListNode()
  • 作用dummy 是一个假头节点,cur 是移动指针。
  • 好处:避免判断“结果链表是否为空”,最后直接返回 dummy.next 即可。
2️⃣ 初始化堆
h = [head for head in lists if head]
heapify(h)
  • 筛选:只把非空链表的头节点放入列表。
  • 堆化heapify 将列表在 \(O(K)\) 时间内变成最小堆。此时堆顶是所有头节点中 val 最小的。
3️⃣ 循环“摘取 - 补充”
while h:
    node = heappop(h)       # 1. 摘取:拿走当前最小节点
    if node.next:           # 2. 补充:如果该节点后面还有节点
        heappush(h, node.next)  #    把下一个节点放入堆中
    cur.next = node         # 3. 连接:接到结果链表后面
    cur = cur.next          # 4. 移动:指针后移
  • 核心思想:堆里永远维护着 K 个链表的当前头节点
  • 每次从堆里拿出一个最小的,把它所在链表的下一个节点补进堆里。
  • 就像 K 个赛跑队伍,每次只让每个队伍跑在最前面的人比赛,谁赢了谁加入总队伍,然后该队伍的下一个人补上。

🔹 复杂度分析

指标 复杂度 说明
时间 \(O(N \log K)\) \(N\) 是总节点数。每个节点进堆出堆一次,堆操作耗时 \(\log K\)
空间 \(O(K)\) 堆中最多同时存储 \(K\) 个节点(每个链表一个代表)。

🔹 总结:如何理解这段代码?

  1. ListNode.__lt__:是“规则定义”。告诉堆:“别比对象地址,比 val 值!”(刷题捷径,工程慎用)。
  2. heapify + heappop:是“调度中心”。始终从 K 个候选者中挑出最小的。
  3. if node.next:是“动态补位”。保证堆里始终有来自各链表的最新候选节点。
  4. dummy + cur:是“组装线”。负责把挑出来的节点串成新链表。

💡 一句话记忆
给节点定好比较规则(lt),建个最小堆(heapq),每次挑最小的出来,把它后面的补进去,直到堆空。