算法和数据结构

算法

排序算法

基础排序算法

冒泡排序 (Bubble Sort)

  • 算法思想:重复遍历列表,比较相邻元素,如果顺序错误就交换。每一轮遍历都会将最大的元素“冒泡”到末尾。
  • 复杂度:$O(n^2)$
  • 稳定性:稳定
  • 适用场景:数据量极小,教学演示。

选择排序 (Selection Sort)

  • 算法思想:每次从未排序部分找到最小元素,放到已排序部分的末尾。
  • 复杂度:$O(n^2)$
  • 稳定性:不稳定
  • 适用场景:数据量小,内存写入成本高(交换次数少)。

插入排序 (Insertion Sort)

  • 算法思想:将未排序元素插入到已排序序列的正确位置。类似打扑克牌理牌。
  • 复杂度:$O(n^2)$
  • 稳定性:稳定
  • 适用场景:数据量小,或数据基本有序。

快速排序 (Quick Sort)

  • 算法思想:分治法。选择一个基准(pivot),将数组分为两部分,左边小于基准,右边大于基准,然后递归排序。
  • 复杂度:平均 $O(n \log n)$,最坏 $O(n^2)$。
  • 空间复杂度:$O(\log n)$ (递归栈)。
  • 稳定性:不稳定。
  • 适用场景:通用排序,数组排序首选。
  • C++实现
void quickSort(vector<int>& arr, int low, int high) {
    // 递归结束条件:如果区间只有一个元素或没有元素,就不排了
    if (low < high) {
        // --- 分区操作 (Partition) 开始 ---
        int pivot = arr[high]; // 选取最后一个元素作为“基准”
        
        // i 指向“比 pivot 小的区域”的最后一个元素
        // 初始时该区域为空,所以 i 在 low 的左边
        int i = low - 1; 

        // j 从头遍历到倒数第二个元素
        for (int j = low; j < high; j++) {
            // 如果当前元素比基准小
            if (arr[j] < pivot) {
                i++; // 扩充“小数区域”的边界
                swap(arr[i], arr[j]); // 把这个小数交换到“小数区域”里去
            }
        }
        // 循环结束后,i 左边(包括 i)都是小数,i 的右边都是大数
        // 现在把基准值 pivot (arr[high]) 放到中间(i+1 的位置)
        swap(arr[i + 1], arr[high]);
        
        // 记录基准值现在的下标,它已经排好序了,以后不用动它
        int pi = i + 1; 

        // --- 分区操作结束 ---

        // 递归排序基准值左边的子数组
        quickSort(arr, low, pi - 1);
        // 递归排序基准值右边的子数组
        quickSort(arr, pi + 1, high);
    }
}

归并排序 (Merge Sort)

  • 算法思想:分治法。将数组分成两半,分别排序,然后合并两个有序数组。
  • 复杂度:$O(n \log n)$。
  • 空间复杂度:$O(n)$。
  • 稳定性:稳定。
  • 适用场景:链表排序,需要稳定排序的大规模数据,外部排序。
  • C++实现
// l: 左边界, m: 中间点, r: 右边界
void merge(vector<int>& arr, int l, int m, int r) {
    // --- 第一步:计算长度并备份数据 ---
    int n1 = m - l + 1; // 左半边的长度(注意 +1,因为 m 包含在左边)
    int n2 = r - m;     // 右半边的长度

    // 创建临时数组(归并排序需要额外的内存空间,这是它的缺点)
    vector<int> L(n1), R(n2);

    // 把数据从原数组拷贝到临时数组 L 和 R
    for (int i = 0; i < n1; i++) L[i] = arr[l + i];
    for (int j = 0; j < n2; j++) R[j] = arr[m + 1 + j];

    // --- 第二步:双指针合并 ---
    int i = 0; // L 数组的指针
    int j = 0; // R 数组的指针
    int k = l; // 原数组 arr 的指针(注意从 l 开始,不是 0)

    // 当两个数组都没走完时
    while (i < n1 && j < n2) {
        // 谁小选谁,放回 arr 中
        // 注意:<= 保证了算法的“稳定性”(相同元素位置不变)
        if (L[i] <= R[j]) {
            arr[k++] = L[i++];
        } else {
            arr[k++] = R[j++];
        }
    }

    // --- 第三步:处理剩余元素 ---
    // 有可能左边剩下了,也有可能右边剩下了,把剩下的全部抄过去
    while (i < n1) arr[k++] = L[i++];
    while (j < n2) arr[k++] = R[j++];
}

void mergeSort(vector<int>& arr, int l, int r) {
    if (l < r) {
        // 1. 找中间点(防止 (l+r) 溢出的写法)
        int m = l + (r - l) / 2;
        // 2. 递归拆分左半边 [l ... m]
        mergeSort(arr, l, m);
        // 3. 递归拆分右半边 [m+1 ... r]
        mergeSort(arr, m + 1, r);
        // 4. 【重点】左右两边都排好了,现在把它们“缝合”起来
        merge(arr, l, m, r);
    }
}

堆排序 (Heap Sort)

  • 算法思想:利用堆这种数据结构。先建最大堆,然后将堆顶(最大值)与末尾交换,缩小堆范围并调整堆,重复直到有序。
  • 复杂度:$O(n \log n)$。
  • 空间复杂度:$O(1)$。
  • 稳定性:不稳定。
  • 适用场景:内存受限,需要最坏情况 $O(n \log n)$。
  • C++实现
void heapify(vector<int>& arr, int n, int i) {
    int largest = i;
    int l = 2 * i + 1;
    int r = 2 * i + 2;
    if (l < n && arr[l] > arr[largest]) largest = l;
    if (r < n && arr[r] > arr[largest]) largest = r;
    if (largest != i) {
        swap(arr[i], arr[largest]);
        heapify(arr, n, largest);
    }
}

void heapSort(vector<int>& arr) {
    int n = arr.size();
    for (int i = n / 2 - 1; i >= 0; i--) heapify(arr, n, i);
    for (int i = n - 1; i > 0; i--) {
        swap(arr[0], arr[i]);
        heapify(arr, i, 0);
    }
}

桶排序 (Bucket Sort)

  • 算法思想:将数据分到有限数量的桶里,每个桶再分别排序(通常用插入排序)。
  • 复杂度:平均 $O(n+k)$,最坏 $O(n^2)$。
  • 适用场景:数据均匀分布在某个范围内。

计数排序 (Counting Sort)

  • 算法思想:统计每个元素出现的次数,根据统计结果直接填充目标数组。
  • 复杂度:$O(n+k)$。
  • 适用场景:整数排序,范围小。

基数排序 (Radix Sort)

  • 算法思想:按照低位先排序,然后收集;再按照高位排序,再收集;依次类推,直到最高位。
  • 复杂度:$O(nk)$,k为最大位数。
  • 适用场景:整数,字符串。

四分位距法

四分位距(Interquartile Range, IQR)是统计学中衡量数据离散程度的一种方法。它表示数据集中间50%的范围,即第三四分位数($Q_{3}$)与第一四分位数($Q_{1}$)之间的差值。

计算方法

  1. 确定中位数 ($Q_{2}$):将数据按升序排列,找到中间值。
  2. 确定第一四分位数 ($Q_{1}$):中位数以下的数据的中间值。
  3. 确定第三四分位数 ($Q_{3}$):中位数以上的数据的中间值。
  4. 计算IQR:$IQR = Q_{3} - Q_{1}$

应用

IQR常用于识别异常值(outliers)。通常,如果一个数据点低于 $Q1 - 1.5 * IQR$ 或高于 $Q3 + 1.5 * IQR$,则被认为是异常值。

import numpy as np

# 示例数据
data = np.array([1, 2, 5, 6, 7, 9, 12, 15, 18, 19, 27])

# 计算 Q1, Q3
q1 = np.percentile(data, 25)
q3 = np.percentile(data, 75)

# 计算 IQR
iqr = q3 - q1

print(f"数据集: {data}")
print(f"第一四分位数 (Q1): {q1}")
print(f"第三四分位数 (Q3): {q3}")
print(f"四分位距 (IQR): {iqr}")

# 识别异常值
lower_bound = q1 - 1.5 * iqr
upper_bound = q3 + 1.5 * iqr

outliers = data[(data < lower_bound) | (data > upper_bound)]
print(f"异常值界限: (下界: {lower_bound}, 上界: {upper_bound})")
print(f"异常值: {outliers}")

匈牙利算法 (Hungarian Algorithm)

核心思想

匈牙利算法主要用于解决二分图最大匹配问题,核心在于寻找增广路径

  • 二分图 (Bipartite Graph):图的顶点集可以分为两个互不相交的子集 $U$ 和 $V$,图中每一条边 $(u, v)$ 都连接 $U$ 中的一个顶点和 $V$ 中的一个顶点。
  • 匹配 (Matching):图的一个边集,其中任意两条边都没有公共顶点。
  • 最大匹配 (Maximum Matching):包含边数最多的匹配。
  • 完美匹配 (Perfect Matching):所有顶点都在匹配中的匹配。

关键概念

  1. 交替路 (Alternating Path):从一个未匹配点出发,依次经过“非匹配边、匹配边、非匹配边…”的路径。
  2. 增广路 (Augmenting Path):一条起点和终点都是未匹配点的交替路。
    • 性质:增广路上的边数一定是奇数。非匹配边比匹配边多一条。
    • 操作:把增广路上的“匹配边”和“非匹配边”取反(互换),匹配数就会 +1。

算法步骤 (DFS版本)

  1. 初始化所有边为非匹配边。
  2. 遍历左侧集合($U$)的每一个顶点 $u$:
    • 如果 $u$ 还没有匹配:
      • 尝试寻找一条从 $u$ 出发的增广路径(通常用 DFS 或 BFS)。
      • 如果找到了,就对路径取反,匹配数 +1。
    • 清空 visited 数组(用于 DFS 防止死循环)。
  3. 遍历结束,当前的匹配数即为最大匹配数。

复杂度

  • 时间复杂度:邻接矩阵 $O(V \cdot E)$ 或 $O(V^3)$。
  • 空间复杂度:$O(V^2)$ 或 $O(E)$。

C++实现

#include <iostream>
#include <vector>
#include <cstring> 

using namespace std;

const int MAXN = 505; // 最大顶点数
vector<int> adj[MAXN]; // 邻接表存图
int match[MAXN];       // match[v] = u 表示右边的点 v 匹配了左边的点 u
bool vis[MAXN];        // visited 数组,记录右边的点是否在本次 DFS 中被访问过

// 向图中添加边 u -> v (u 在左边,v 在右边)
void addEdge(int u, int v) {
    adj[u].push_back(v);
}

// DFS 寻找增广路径
bool dfs(int u) {
    for (int v : adj[u]) {
        if (!vis[v]) {
            vis[v] = true; // 标记右边的点 v 已访问
            
            // 如果 v 还没有匹配,或者 v 已经匹配了但它的匹配对象 match[v] 可以找到另一个匹配
            if (match[v] == -1 || dfs(match[v])) {
                match[v] = u; // 尝试把 v 匹配给 u
                return true;
            }
        }
    }
    return false;
}

int hungarian(int N) {
    int result = 0;
    memset(match, -1, sizeof(match)); // 初始化所有点未匹配
    
    // 遍历左边的所有点 N
    for (int i = 1; i <= N; i++) {
        memset(vis, 0, sizeof(vis)); // 每次寻找增广路前都要清空 visited
        if (dfs(i)) {
            result++;
        }
    }
    return result;
}

int main() {
    int n, m, e; // n: 左边点数, m: 右边点数, e: 边数
    cin >> n >> m >> e;
    
    for (int i = 0; i < e; i++) {
        int u, v;
        cin >> u >> v;
        // 假设输入是 1-based index,如果题目要求,这里可能要做调整
        addEdge(u, v);
    }
    
    cout << hungarian(n) << endl;
    return 0;
}

数据结构

优先队列

优先队列(Priority Queue)是一种特殊的队列,其中每个元素都有一个关联的“优先级”。与普通队列“先进先出”(FIFO)的规则不同,优先队列中的元素是按照它们的优先级顺序出队的。通常,优先级最高的元素最先出队。

优先队列常用于需要高效处理“最重要”任务的场景,例如:

  • Dijkstra算法:在图中找到最短路径。
  • 任务调度:操作系统根据任务的优先级来决定先执行哪个任务。

最小堆

说明

  • 结构属性:最小堆通常是一种特殊的“树形”结构,是一棵完全二叉树,这意味着除了最后一层,所有层都是满的,并且最后一层节点都尽可能地靠左排列,这样用列表实现时,天然就满足了,因为我们总是把新元素放到队尾。
  • 堆属性:对于最小堆来说,任何一个节点(父节点)得值,都小于或等于其所有子节点的值,这个属性结果就是堆顶永远是最小元素。

添加新元素

  1. 一个新元素被添加到列表的末尾。
  2. 比较这个新元素和它的父节点。
  3. 如果新元素比父节点,这违反了最小堆的属性。于是,它们两个交换位置。
  4. 交换后,新元素跑到了父节点的位置。它继续和它的新父节点比较,重复这个过程,直到它不再比父节点小,或者它已经到达了堆顶(索引0)。这个过程就像一个“气泡”从水底不断上浮。

移除堆顶

  1. 移除堆顶元素(最小值)。
  2. 为了填补这个空缺并保持树的完整性,我们把列表的最后一个元素移动到堆顶。
  3. 这时,堆顶的这个新元素很可能非常大,破坏了堆属性。
  4. 从堆顶开始,将这个元素与它的子节点比较。
  5. 它会找到两个子节点中较小的那一个进行比较。
  6. 如果当前节点比它较小的子节点,就和这个较小的子节点交换。
  7. 交换后,它继续向下移动,重复这个过程,直到它不再比它的子节点大,或者它成为了一个叶子节点(没有子节点)。这个过程就像一块“石头”在水中不断下沉。

示例

使用 heapq 模块

Python 的 heapq 模块提供了一个最小堆(Min Heap)的实现,这可以非常方便地用来构建优先队列。在最小堆中,优先级最小的元素总是在堆顶(即列表的第一个元素)。

下面是一个使用 heapq 实现的优先队列的例子:

import heapq

class PriorityQueue:
    def __init__(self):
        self._queue = []
        self._index = 0

    def push(self, item, priority):
        # heapq是最小堆,所以我们存入一个元组 (priority, index, item)
        # index 是为了在优先级相同时,保持插入顺序
        heapq.heappush(self._queue, (priority, self._index, item))
        self._index += 1

    def pop(self):
        # heappop总是返回优先级最小的元素
        return heapq.heappop(self._queue)[-1]

    def is_empty(self):
        return not self._queue

# 示例
pq = PriorityQueue()
pq.push("任务A", 3)
pq.push("任务B", 1)
pq.push("任务C", 2)

print("按优先级顺序处理任务:")
while not pq.is_empty():
    print(pq.pop())  # 输出: 任务B, 任务C, 任务A

红黑树

核心思想

  • 红黑树是自平衡二叉搜索树,借助颜色约束与旋转保持近似平衡,使操作稳定为对数时间。

性质

  • 每个节点为红或黑。
  • 根为黑。
  • 所有叶子(NIL哨兵)为黑。
  • 红节点的两个子节点均为黑。
  • 任意节点到其所有叶子路径上的黑节点数相同(黑高)。

复杂度与高度

  • 查找、插入、删除均为 O(log n)
  • 树高中最多约为 2 · log2(n+1),比普通 BST 更稳定,略逊于 AVL 但旋转更少。

基本操作

  • 左旋:将某节点的右子提升为父,原节点变为其左子,保持中序序列不变。
  • 右旋:与左旋相反。
  • 插入修复(新节点默认红):
    • 父黑:无需修复。
    • 父红、叔红:父与叔改黑,祖父改红,向上继续处理祖父。
    • 父红、叔黑、内侧:先对父做一次旋转转成外侧,再执行外侧情况。
    • 父红、叔黑、外侧:以祖父为轴旋转,父改黑、祖父改红,结束。
  • 删除修复(删除黑节点导致黑高减少时):
    • 兄弟红:兄弟改黑、父改红,围绕父旋转,转化为兄弟黑的情形。
    • 兄弟黑、兄弟的两个子皆黑:兄弟改红,问题向父上推。
    • 兄弟黑、兄弟的远侄子红:围绕父旋转,兄弟取父色、父改黑、远侄子改黑,结束。
    • 兄弟黑、近侄子红、远侄子黑:先围绕兄弟旋转使远侄子变红,再按上一条处理。

与 AVL 对比

  • AVL 更紧平衡、查找略快;红黑树插删旋转更少,工程更常用(如关联容器)。

典型应用

  • 有序映射与集合(如 TreeMap/TreeSetstd::map)。
  • 调度器、内存管理、区间集合等需要稳定对数时间的场景。

术语

  • 黑高:从某节点到其任一叶子的黑节点数量。
  • 叔叔:父的另一子。
  • 外侧/内侧:相对祖父而言,父-子是否在同一方向。

并查集

核心思想

  • 维护若干不相交的集合(等价类),支持合并与查询是否同集合,基于树结构与父指针实现。

操作

  • 初始化:每个元素自成一个集合,父为自身。
  • 查找(find,路径压缩):沿父指针找到代表元素,并在返回途中压缩路径。
  • 合并(union,按大小/按秩):将两个集合的代表合并到更“重”的集合下,降低高度。
  • 查询同集(same):比较两个元素的代表是否相同。
  • 增强:集合大小查询、当前连通分量计数。

复杂度

  • 摊还近似常数,严格上界 O(α(n)),其中 α 为阿克曼函数的反函数,增长极慢。

常见变体

  • 按秩 vs 按大小。
  • 带权并查集(维护相对差值/颜色等)。
  • 可回滚并查集(离线、分治场景)。
  • 动态连通性与区间合并。

典型应用

  • 图的连通性判定、Kruskal 最小生成树。
  • 等价类归并、岛屿计数、好友圈/群组聚合。

C++实现

class DisjointSet {
public:
    DisjointSet(int n): n_(n), components(n) {
        parent = new int[n];
        sz = new int[n];
        for (int i = 0; i < n; ++i) { parent[i] = i; sz[i] = 1; }
    }
    ~DisjointSet() {
        delete[] parent;
        delete[] sz;
    }
    int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }
    bool unite(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (sz[ra] < sz[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;
        sz[ra] += sz[rb];
        --components;
        return true;
    }
    bool same(int a, int b) { return find(a) == find(b); }
    int set_size(int x) { return sz[find(x)]; }
    int count() const { return components; }
private:
    int n_;
    int* parent;
    int* sz;
    int components;
};

KD-Tree

核心思想

KD-Tree(K-Dimensional Tree)是一种用于组织 $k$ 维空间中点集的二叉搜索树。它将 $k$ 维空间递归地划分成超矩形区域。

构建过程

  1. 选择维度:每一层选择一个维度进行划分。通常轮流选择(如 x -> y -> z -> x…)或选择方差最大的维度。
  2. 选择分裂点:在选定维度上,选择中位数作为分裂点(Pivot),将数据分为两部分。
  3. 递归构建:小于中位数的点放入左子树,大于中位数的点放入右子树,递归直到节点为空或只剩一个点。

关键操作

  1. 下探:从根节点开始,根据当前维度的值向左或向右递归,直到叶子节点。将当前叶子节点作为“当前最近点”。
  2. 回溯:向上回溯。
    • 计算当前节点与目标点的距离,如果更近则更新“当前最近点”。
    • 剪枝判断:检查以目标点为球心、以“当前最小距离”为半径的超球体,是否与当前节点的分割超平面相交。
      • 如果相交(即目标点到分割平面的距离 < 当前最小距离),则需要进入另一侧子树查找。
      • 如果不相交,则无需进入另一侧子树。

复杂度

  • 构建时间:$O(N \log N)$。
  • 空间复杂度:$O(N)$。
  • 搜索时间
    • 平均:$O(\log N)$。
    • 最坏:$O(N)$(维数灾难,当 $k$ 很大时效率退化)。

适用场景

  • 多维空间的关键搜索(如 KNN 算法)。
  • 范围查询。
  • 图像检索、点云处理。

C++实现

#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>

using namespace std;

struct Point {
    vector<int> coords;
    int id;
};

struct Node {
    Point point;
    Node *left = nullptr;
    Node *right = nullptr;
    int axis;

    Node(Point p, int ax) : point(p), axis(ax) {}
};

// 计算两点间的欧几里得距离平方
double distSq(const Point& a, const Point& b) {
    double sum = 0;
    for (size_t i = 0; i < a.coords.size(); ++i) {
        double diff = a.coords[i] - b.coords[i];
        sum += diff * diff;
    }
    return sum;
}

// 构建 KD-Tree
Node* buildKDTree(vector<Point>& points, int depth) {
    if (points.empty()) return nullptr;

    int k = points[0].coords.size();
    int axis = depth % k;

    // 根据当前轴排序
    sort(points.begin(), points.end(), [axis](const Point& a, const Point& b) {
        return a.coords[axis] < b.coords[axis];
    });

    int mid = points.size() / 2;
    Node* node = new Node(points[mid], axis);

    vector<Point> leftPoints(points.begin(), points.begin() + mid);
    vector<Point> rightPoints(points.begin() + mid + 1, points.end());

    node->left = buildKDTree(leftPoints, depth + 1);
    node->right = buildKDTree(rightPoints, depth + 1);

    return node;
}

// 最近邻搜索
void nearestNeighbor(Node* root, const Point& target, Node*& best, double& minDistSq) {
    if (root == nullptr) return;

    double d = distSq(root->point, target);
    if (d < minDistSq) {
        minDistSq = d;
        best = root;
    }

    int axis = root->axis;
    double diff = target.coords[axis] - root->point.coords[axis];
    
    // 优先搜索可能更近的一侧
    Node* near = (diff < 0) ? root->left : root->right;
    Node* far = (diff < 0) ? root->right : root->left;

    nearestNeighbor(near, target, best, minDistSq);

    // 如果分割平面与当前最小球相交,则需要搜索另一侧
    if (diff * diff < minDistSq) {
        nearestNeighbor(far, target, best, minDistSq);
    }
}

int main() {
    vector<Point> points = {
        {{2, 3}, 1}, {{5, 4}, 2}, {{9, 6}, 3}, {{4, 7}, 4}, {{8, 1}, 5}, {{7, 2}, 6}
    };

    Node* root = buildKDTree(points, 0);

    Point target = {{9, 2}, 0};
    Node* best = nullptr;
    double minDistSq = 1e9;

    nearestNeighbor(root, target, best, minDistSq);

    cout << "Nearest point: (" << best->point.coords[0] << ", " << best->point.coords[1] << ")" << endl;
    cout << "Distance squared: " << minDistSq << endl;

    return 0;
}