跳转至

# 排序、搜索与算法设计

排序和搜索是最基础的算法操作。本文件涵盖排序算法、二分查找模式、分治法、贪心算法、动态规划和回溯。

- 每个数据结构都支持算法，每个算法都依赖数据结构。本文件涵盖了设计范式：解决问题的高级策略。一旦你识别出适用哪种范式，实现就自然而然地跟进了。

## 排序算法

- 排序是计算机科学中研究最多的问题。理解这些算法可以建立对递归、分治法和复杂度分析的直觉。

算法 | 最好 | 平均 | 最坏 | 空间 | 稳定？
--- | --- | --- | --- | --- | ---
冒泡排序 | \(O(n)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 是
插入排序 | \(O(n)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 是
归并排序 | \(O(n \log n)\) | \(O(n \log n)\) | \(O(n \log n)\) | \(O(n)\) | 是
快速排序 | \(O(n \log n)\) | \(O(n \log n)\) | \(O(n^2)\) | \(O(\log n)\) | 否
堆排序 | \(O(n \log n)\) | \(O(n \log n)\) | \(O(n \log n)\) | \(O(1)\) | 否
计数排序 | \(O(n + k)\) | \(O(n + k)\) | \(O(n + k)\) | \(O(k)\) | 是
基数排序 | \(O(d(n + k))\) | \(O(d(n + k))\) | \(O(d(n + k))\) | \(O(n + k)\) | 是

- 稳定意味着相等元素保持其相对顺序。这在按多个键排序时很重要。

- 基于比较的排序的下限是 \(\Omega(n \log n)\)。证明使用决策树（第13章）：任何比较排序必须区分所有 \(n!\) 种排列，至少需要 \(\log_2(n!) = \Omega(n \log n)\) 次比较。计数排序和基数排序通过不比较元素而超越了这个下限。

### 归并排序

- 将数组分成两半，递归排序每一半，然后合并已排序的两半。始终为 \(O(n \log n)\)，\(O(n)\) 额外空间。

    def merge_sort(arr):
        if len(arr) <= 1:
            return arr

        mid = len(arr) // 2
        left = merge_sort(arr[:mid])
        right = merge_sort(arr[mid:])

        return merge(left, right)

    def merge(left, right):
        result = []
        i = j = 0
        while i < len(left) and j < len(right):
            if left[i] <= right[j]:  # <= 保证稳定性
                result.append(left[i])
                i += 1
            else:
                result.append(right[j])
                j += 1
        result.extend(left[i:])
        result.extend(right[j:])
        return result

- 陷阱：在合并中使用 `<` 而不是 `<=` 会破坏稳定性（右半部分的相等元素会排在左半部分之前）。

### 快速排序

- 选择一个基准，将元素分为"小于基准"和"大于基准"两组，递归排序每组。平均 \(O(n \log n)\)，最坏 \(O(n^2)\)（当基准总是最小或最大元素时）。

    def quicksort(arr, lo=0, hi=None):
        if hi is None:
            hi = len(arr) - 1
        if lo >= hi:
            return

        pivot_idx = partition(arr, lo, hi)
        quicksort(arr, lo, pivot_idx - 1)
        quicksort(arr, pivot_idx + 1, hi)

    def partition(arr, lo, hi):
        pivot = arr[hi]  # Lomuto 分区：基准是最后一个元素
        i = lo
        for j in range(lo, hi):
            if arr[j] < pivot:
                arr[i], arr[j] = arr[j], arr[i]
                i += 1
        arr[i], arr[hi] = arr[hi], arr[i]
        return i

- 基准策略：最后一个元素（简单，对已排序输入不好）、随机（期望 \(O(n \log n)\)）、三数取中（实际选择）。在面试中始终优先选择随机基准以避免最坏情况的讨论。

- 陷阱：快速排序的 \(O(n^2)\) 最坏情况发生在已排序数组配合首/尾基准时。实践中，随机基准或三数取中消除了这个问题。

### 计数排序

- 当值在已知范围 \([0, k)\) 内的整数时，统计出现次数并重构：\(O(n + k)\) 时间。不是基于比较的，因此可以超越 \(O(n \log n)\)。

    def counting_sort(arr, k):
        count = [0] * k
        for x in arr:
            count[x] += 1
        result = []
        for val in range(k):
            result.extend([val] * count[val])
        return result

- 何时使用：范围 \(k\) 不比 \(n\) 大很多。如果 \(k = O(n)\)，这是 \(O(n)\)。如果 \(k \gg n\)（例如，对范围 \([0, 10^9]\) 中的 10 个数字排序），计数排序会浪费内存。

## 模式：二分查找

- 二分查找通过在已排序数组中反复减半搜索空间来以 \(O(\log n)\) 的时间找到目标。但二分查找远不止"在已排序数组中找一个数"。通用模式是：在单调条件上进行搜索。

- 模板（避免差一错误的那一个）：

    def binary_search(arr, target):
        lo, hi = 0, len(arr) - 1

        while lo <= hi:
            mid = lo + (hi - lo) // 2  # 在其他语言中避免溢出
            if arr[mid] == target:
                return mid
            elif arr[mid] < target:
                lo = mid + 1
            else:
                hi = mid - 1

        return -1  # 未找到

- 下界（第一个 \(\geq\) target 的元素）：

    def lower_bound(arr, target):
        lo, hi = 0, len(arr)
        while lo < hi:
            mid = (lo + hi) // 2
            if arr[mid] < target:
                lo = mid + 1
            else:
                hi = mid
        return lo

- 陷阱：`lo <= hi` 和 `lo < hi` 的区别，以及 `hi = mid` 和 `hi = mid - 1` 的区别，决定了你是找到精确匹配还是边界。用一个2元素数组画出来验证。

### 简单：二分查找

- 标准问题。使用上面的模板。

### 中等：搜索旋转排序数组

- 问题：一个排序数组在某个枢轴处被旋转。找到目标值。

- 模式：在每一步，有一半总是有序的。确定哪一半是有序的，并检查目标是否在这一半中。

    def search_rotated(nums, target):
        lo, hi = 0, len(nums) - 1

        while lo <= hi:
            mid = (lo + hi) // 2
            if nums[mid] == target:
                return mid

            # 左半部分有序
            if nums[lo] <= nums[mid]:
                if nums[lo] <= target < nums[mid]:
                    hi = mid - 1
                else:
                    lo = mid + 1
            # 右半部分有序
            else:
                if nums[mid] < target <= nums[hi]:
                    lo = mid + 1
                else:
                    hi = mid - 1

        return -1

- 陷阱：`nums[lo] <= nums[mid]` 中的 `<=`（而不是 `<`）至关重要。当 `lo == mid`（只剩2个元素）时，我们必须正确识别有序的一半。

### 困难：寻找两个有序数组的中位数

- 问题：在 \(O(\log(m + n))\) 时间内找到两个有序数组的中位数。

- 模式：对较小数组的分割点进行二分查找。分割将两个数组分为两部分，使得左侧所有元素都小于右侧所有元素。

    def find_median(nums1, nums2):
        if len(nums1) > len(nums2):
            nums1, nums2 = nums2, nums1  # 确保 nums1 较短

        m, n = len(nums1), len(nums2)
        lo, hi = 0, m
        half = (m + n + 1) // 2

        while lo <= hi:
            i = (lo + hi) // 2          # nums1 中的分割点
            j = half - i                 # nums2 中的分割点

            left1 = nums1[i - 1] if i > 0 else float('-inf')
            right1 = nums1[i] if i < m else float('inf')
            left2 = nums2[j - 1] if j > 0 else float('-inf')
            right2 = nums2[j] if j < n else float('inf')

            if left1 <= right2 and left2 <= right1:
                # 正确分割
                if (m + n) % 2 == 1:
                    return max(left1, left2)
                return (max(left1, left2) + min(right1, right2)) / 2
            elif left1 > right2:
                hi = i - 1
            else:
                lo = i + 1

- 这是最难的二分查找问题之一。关键在于你搜索的不是一个值，而是一个满足条件的分割点。

### 元模式：对答案进行二分查找

- 许多看起来不像二分查找的问题可以通过对答案进行二分查找来解决。如果答案是一个数字，并且你可以写一个单调的函数 `is_feasible(x)`（对所有 \(x \geq\) 最优值为 True，或对所有 \(x \geq\) 最优值为 False），那么就在 \(x\) 上进行二分查找。

- 示例："在 \(d\) 天内运送所有包裹所需的最小运力是多少？"对运力进行二分查找。对于每个候选运力，贪心地检查是否可以在 \(d\) 天内运送所有包裹。

    def ship_within_days(weights, days):
        lo, hi = max(weights), sum(weights)

        while lo < hi:
            mid = (lo + hi) // 2
            # 能否以运力 mid 在 <= days 天内运送完？
            current_load, num_days = 0, 1
            for w in weights:
                if current_load + w > mid:
                    num_days += 1
                    current_load = 0
                current_load += w

            if num_days <= days:
                hi = mid
            else:
                lo = mid + 1

        return lo

## 模式：贪心算法

- 贪心算法在每一步做出局部最优选择，希望这能导致全局最优解。贪心在问题具有贪心选择性质（局部最优导致全局最优）和最优子结构（最优解包含子问题的最优解）时有效。

### 中等：跳跃游戏

- 问题：给定一个数组，其中 `nums[i]` 是在位置 \(i\) 的最大跳跃长度，判断是否能够到达最后一个索引。

    def can_jump(nums):
        max_reach = 0
        for i, jump in enumerate(nums):
            if i > max_reach:
                return False  # 无法到达这个位置
            max_reach = max(max_reach, i + jump)
        return True

- 为什么贪心有效：我们只需要知道最远可达位置。如果当前位置超过了最远可达位置，我们就卡住了。否则，更新最远可达位置。

### 中等：合并区间

- 问题：合并重叠的区间。

    def merge_intervals(intervals):
        intervals.sort(key=lambda x: x[0])
        merged = [intervals[0]]

        for start, end in intervals[1:]:
            if start <= merged[-1][1]:
                merged[-1][1] = max(merged[-1][1], end)
            else:
                merged.append([start, end])

        return merged

- 模式：按开始时间排序，然后贪心地合并。如果当前区间与上一个合并的区间重叠，则扩展它。否则，开始一个新的合并区间。

- 陷阱：使用 `merged[-1][1] = end` 而不是 `merged[-1][1] = max(merged[-1][1], end)`。一个区间可能完全包含在另一个区间内（例如 [1, 10] 和 [2, 5]）。

## 模式：动态规划

- 动态规划（DP）通过将问题分解为重叠的子问题，每个子问题只解一次并存储结果。它适用于具有最优子结构和重叠子问题的问题。

- 两种方法：

  - 自顶向下（记忆化）：写出自然的递归解法，然后在字典中缓存结果。
  - 自底向上（制表法）：从最小的子问题开始向上构建表格。

- 如何识别 DP：问题要求最优值（最小/最大）、计数或存在性，并且当前决策依赖于先前的决策。如果你画出递归树并看到重复的子问题，那就是 DP。

### 简单：爬楼梯

- 问题：\(n\) 个台阶，每次可以爬 1 或 2 个台阶。有多少种不同的方法？

- 这就是斐波那契数列：\(f(n) = f(n-1) + f(n-2)\)。

    def climb_stairs(n):
        if n <= 2:
            return n
        a, b = 1, 2
        for _ in range(3, n + 1):
            a, b = b, a + b
        return b

- \(O(n)\) 时间，\(O(1)\) 空间。不需要完整的记忆化表，因为每个状态只依赖于前两个。

### 中等：零钱兑换

- 问题：给定硬币面额和一个目标金额，找到所需的最少硬币数量。

- 状态：`dp[amount]` = 凑成 `amount` 所需的最小硬币数。

- 转移：`dp[amount] = min(dp[amount - coin] + 1)` 对每个硬币。
- 基本情况：`dp[0] = 0`。

    def coin_change(coins, amount):
        dp = [float('inf')] * (amount + 1)
        dp[0] = 0

        for a in range(1, amount + 1):
            for coin in coins:
                if coin <= a and dp[a - coin] + 1 < dp[a]:
                    dp[a] = dp[a - coin] + 1

        return dp[amount] if dp[amount] != float('inf') else -1

- 陷阱：用 `float('inf')` 初始化（而不是 0 或 -1）。最小比较只有在不可达状态为无穷大时才有效。

### 中等：最长公共子序列

- 问题：给定两个字符串，找出它们的最长公共子序列的长度。

- 状态：`dp[i][j]` = `text1[:i]` 和 `text2[:j]` 的 LCS。

- 转移：如果 `text1[i-1] == text2[j-1]`，则 `dp[i][j] = dp[i-1][j-1] + 1`。否则，`dp[i][j] = max(dp[i-1][j], dp[i][j-1])`。

    def longest_common_subsequence(text1, text2):
        m, n = len(text1), len(text2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

        return dp[m][n]

### 困难：0/1 背包

- 问题：给定具有重量和价值的物品，以及容量 \(W\)，在不超出 \(W\) 的情况下最大化总价值。

- 状态：`dp[i][w]` = 使用前 \(i\) 个物品在容量 \(w\) 下的最大价值。

- 转移：`dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])`（跳过或取用物品 \(i\)）。

    def knapsack(weights, values, capacity):
        n = len(weights)
        dp = [[0] * (capacity + 1) for _ in range(n + 1)]

        for i in range(1, n + 1):
            for w in range(capacity + 1):
                dp[i][w] = dp[i - 1][w]  # 跳过物品 i
                if weights[i - 1] <= w:
                    dp[i][w] = max(dp[i][w],
                                   dp[i - 1][w - weights[i - 1]] + values[i - 1])

        return dp[n][capacity]

- 空间优化：由于每一行只依赖于前一行，使用一维数组并从右向左迭代 \(w\)：

    def knapsack_optimised(weights, values, capacity):
        dp = [0] * (capacity + 1)
        for i in range(len(weights)):
            for w in range(capacity, weights[i] - 1, -1):  # 从右向左！
                dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
        return dp[capacity]

- 陷阱：在一维版本中从左向右迭代会允许多次使用物品 \(i\)（无限背包）。从右向左确保每个物品最多使用一次。

## 模式：回溯

- 回溯是带剪枝的穷举搜索。逐步构建解，一旦部分解不可能导致完整的有效解，就立即放弃（回溯）。

- 模板：

    def backtrack(candidates, path, result):
        if is_solution(path):
            result.append(path[:])  # 复制！
            return

        for candidate in get_candidates(path):
            if is_valid(candidate, path):
                path.append(candidate)     # 选择
                backtrack(candidates, path, result)  # 探索
                path.pop()                 # 撤销（回溯）

### 中等：子集

    def subsets(nums):
        result = []
        def backtrack(start, path):
            result.append(path[:])
            for i in range(start, len(nums)):
                path.append(nums[i])
                backtrack(i + 1, path)
                path.pop()
        backtrack(0, [])
        return result

### 中等：组合总和

- 问题：找出所有和为目标值的唯一组合（元素可重复使用）。

    def combination_sum(candidates, target):
        result = []
        def backtrack(start, path, remaining):
            if remaining == 0:
                result.append(path[:])
                return
            for i in range(start, len(candidates)):
                if candidates[i] > remaining:
                    break  # 剪枝：已排序，后续候选都太大
                path.append(candidates[i])
                backtrack(i, path, remaining - candidates[i])  # i 而不是 i+1：允许重复使用
                path.pop()

        candidates.sort()  # 排序以便剪枝
        backtrack(0, [], target)
        return result

- 陷阱：`backtrack(i, ...)` 允许重复使用同一元素。`backtrack(i + 1, ...)` 会移动到下一个元素（不可重复使用）。搞错这个是最常见的回溯 bug。

### 困难：N 皇后

- 问题：在 \(n \times n\) 的棋盘上放置 \(n\) 个皇后，使得它们互不攻击。

    def solve_n_queens(n):
        result = []
        cols = set()
        pos_diag = set()  # (row + col) 在 / 对角线上为常数
        neg_diag = set()  # (row - col) 在 \ 对角线上为常数

        board = [['.' ] * n for _ in range(n)]

        def backtrack(row):
            if row == n:
                result.append([''.join(r) for r in board])
                return

            for col in range(n):
                if col in cols or (row + col) in pos_diag or (row - col) in neg_diag:
                    continue

                cols.add(col)
                pos_diag.add(row + col)
                neg_diag.add(row - col)
                board[row][col] = 'Q'

                backtrack(row + 1)

                cols.remove(col)
                pos_diag.remove(row + col)
                neg_diag.remove(row - col)
                board[row][col] = '.'

        backtrack(0)
        return result

- 关键洞察：对角线编码。对于 `/` 对角线，`row + col` 是常数。对于 `\` 对角线，`row - col` 是常数。使用集合跟踪列和对角线使得有效性检查变为 \(O(1)\)。

## 常见陷阱总结

陷阱 | 示例 | 修复
--- | --- | ---
二分查找中 `lo <= hi` vs `lo < hi` | 边界差一错误 | 根据 `hi` 是包含还是排除来选择
从左到右的一维背包 | 物品被多次使用 | 0/1 背包从右向左迭代
回溯中未复制路径 | `result.append(path)` — 所有条目指向同一列表 | `result.append(path[:])` 或 `path.copy()`
`backtrack(i)` vs `backtrack(i+1)` | 重复使用 vs 不重复使用元素 | 匹配问题要求
排序后的回溯中缺少 `break` | 探索过大的候选 | 排序 + 候选超过剩余时 break
DP 初始化 | `dp[0]` 错误 → 所有后续值都错 | 仔细定义基本情况
未经证明的贪心 | 贪心并不总是有效 | 验证贪心选择性质
多键排序时不稳定 | 相等元素的相对顺序丢失 | 使用稳定排序（归并排序、Python 的 `sorted`）

## 课后练习题（NeetCode）

### 二分查找

- 二分查找 — 标准模板
- 搜索二维矩阵 — 在展平矩阵上二分查找
- Koko 吃香蕉 — 对答案二分查找
- 搜索旋转排序数组 — 识别有序的一半
- 寻找旋转排序数组中的最小值 — 搜索拐点
- 寻找两个有序数组的中位数 — 基于分割的二分查找

### 贪心

- 跳跃游戏 — 跟踪最远距离
- 跳跃游戏 II — BFS 风格的层级跟踪
- 合并区间 — 排序 + 合并
- 插入区间 — 寻找重叠区域
- 无重叠区间 — 按结束时间排序

### 动态规划

- 爬楼梯 — 斐波那契 DP
- 打家劫舍 — 取或不取 DP
- 打家劫舍 II — 环形：运行两次
- 零钱兑换 — 无限背包
- 最长公共子序列 — 两个字符串上的 2D DP
- 单词拆分 — 带集合查找的 DP
- 最长递增子序列 — \(O(n^2)\) DP 或带二分查找的 \(O(n \log n)\)
- 编辑距离 — 经典 2D DP
- 分割等和子集 — 0/1 背包变体

### 回溯

- 子集 — 枚举所有子集
- 组合总和 — 允许重复使用的回溯
- 全排列 — 带使用集合的回溯
- 子集 II — 跳过重复项
- 单词搜索 — 网格回溯
- 分割回文串 — 回溯 + 回文检查
- N 皇后 — 约束传播