Kevin's Tech Blog

Systems | AI | Research

原始论文:
https://dl.acm.org/doi/10.1145/2880150
https://dl.ccf.org.cn/article/articleDetail.html?type=xhtx_thesis&_ack=1&id=6262501625219072

操作系统(OS)是计算机系统中最具历史厚度的软件层。它的每一次范式转移,都深刻地折射出硬件能力的跃升与应用需求的演变。本文尝试梳理操作系统半个世纪以来的发展脉络,并对其未来走向作出前瞻性思考。

1. 两大阶段:从”操作自动化”到”资源管理者”

操作系统的历史可以划分为两个性质截然不同的大阶段。

第一阶段(1950s):批处理操作系统。彼时的操作系统是对”操作员”角色的自动化替代,其核心功能是将人工的磁带换装、卡片递交等流程自动化,以批处理方式串行执行用户作业。系统对资源没有动态调度,程序员需手动规划所有资源使用。

第二阶段(1965 年后):多道程序与资源管理框架。随着计算机开始服务于多个用户和多个并发任务,一个根本性的矛盾浮现:程序员对资源的手动控制既不充分(无法感知冲突),也不全面(无法充分利用空闲资源)。操作系统因此从”自动化批处理程序”升级为一个具备控制权和调度权的资源管理框架,以供其他程序在其上受控地运行。

这一阶段的标志性成果是”计算机系统工程师”总结出的八条操作系统基本原则:交互式计算、分层文件系统、容错结构、中断系统、虚拟内存、多道程序设计、模块化设计。此后几乎所有主流操作系统都是在这八条原则的基础上构建的。

2. 三次关键转型

2.1 从汇编语言到高级语言

早期操作系统以汇编语言编写,与特定硬件强绑定,不同架构的机器需要独立开发操作系统。Multics 的出现打破了这一局面:它证明了用高级语言(PL/I)编写的操作系统不仅可行,而且在可维护性和可移植性上具有显著优势。此后,以 C 语言编写 Unix 成为里程碑式的实践,将”操作系统用高级语言实现”确立为行业共识。

2.2 从通用主义到精简内核

Multics 的设计哲学是最大通用性——尽可能解决一切问题,导致系统庞大、运行成本高昂。这一经验教训推动了反向思考:操作系统应当尽可能精简(Minimal),只提供最核心的机制,其余由用户空间程序实现。

这一思想的产物正是 Unix,以及后来的 Linux。微内核(Microkernel)架构将内核功能精简至线程调度、基本 IPC 和最小内存管理,其他服务以独立进程形式运行于用户空间,实现了出色的模块化与可移植性。

2.3 不同设计目标催生多元内核形态

微内核并非唯一正确答案。Windows 将图形子系统(GUI)集成进内核,以牺牲部分安全隔离性为代价,换取了极低的用户态-内核态切换开销,满足了消费级用户对交互响应速度的需求。实时操作系统(RTOS) 则为了保证极低且可预测的调度延迟,甚至省去了内核态/用户态的权限分离。

这说明操作系统的架构选择本质上是设计目标之间的权衡,不存在普适的最优方案,只有在特定约束下的最优解。

3. 未来操作系统的演进方向

3.1 从”人机交互接口”到”世界连接层”

当下的操作系统概念正在经历一次深刻的语义扩展。传统意义上的操作系统是”设备硬件资源的管理者”,但当数十亿设备接入物联网(IoT),当数字与物理世界的边界消融,操作系统开始被抽象为连接数字世界与物理世界的数字纽带

在这一框架下,一套分布于医疗设备、工厂机器人和家庭电器的协同调度层,在逻辑上构成了一个超宏观的”操作系统”,尽管不再与任何单一设备绑定。

3.2 自然人机交互的新形态

当前人与操作系统的交互仍以”硬交互”为主——键盘、触摸屏。然而新兴的交互技术正在重塑这一范式:Apple Vision Pro 通过眼动追踪实现光标定位,脑机接口尝试建立意图与指令之间的直接通道。这些方向预示着操作系统的输入层将向感知融合(Perceptual Integration)演进,要求 OS 能够处理高维、低延迟的感知数据流。

3.3 元 OS Kit:面向多样设备的组件化架构

面对从微控制器到超级计算机的巨大设备异质性,未来可能出现一种元 OS(Meta-OS)思路:操作系统不再是针对特定设备类型的固定实现,而是一套可按需组装的组件库(Kit)——不同场景选取不同组件子集,通过标准化接口实现跨设备互操作,让物理设备在操作系统层面实现原生协同,而非依赖复杂的软件适配层。

3.4 异构计算与 CPU/XPU 融合

高性能计算(HPC)与人工智能对计算精度和并行度的要求迥异,二者长期处于不同的硬件生态。随着 AI 工作负载向 HPC 基础设施融合,操作系统需要统一调度 CPU(通用标量计算)与各类 XPU(GPU、TPU、NPU 等专用加速器),实现异构计算资源的透明化调度,降低数据在不同计算单元间迁移带来的能效损耗。

3.5 机密计算:不可信基础设施下的安全保障

传统操作系统的安全模型建立在”信任硬件”的前提下:只要物理设备不被篡改,数据即可视为安全。然而在云计算模式下,基础设施提供商对底层硬件具有完全控制权,应用数据面临来自”受信任但不透明”的基础设施的威胁

**机密计算(Confidential Computing)**架构(如 Intel SGX、AMD SEV、ARM TrustZone)通过硬件隔离的可信执行环境(TEE),使应用数据即便在不可信的基础设施上运行,也能维持端到端的隐私保护,实现”信任自己的应用,而无需信任基础设施提供商”的新安全模式。

3.6 垂直整合:消除抽象层的冗余开销

传统的”硬件 → 操作系统 → 运行时 → 应用”分层抽象带来了大量通用化开销。**垂直整合(Vertical Integration)**通过针对特定业务场景对各层进行协同设计,消除不必要的通用抽象,实现”短链条”协同:

  • 苹果 M 系列芯片与 macOS/iOS 的深度协同;
  • 谷歌 TPU 与 TensorFlow/XLA 编译器栈的联合优化;
  • 量化交易系统为极致低延迟而省去内核态/用户态切换的 kernel bypass 方案(如 DPDK、RDMA)。

4. 结语

操作系统的演进史,是一部在硬件能力应用需求工程复杂性之间反复寻找平衡的历史。从批处理自动化到多道程序,从汇编单机到高级语言可移植,从通用主义到精简微内核,每一次转型背后都有明确的技术驱动力和工程权衡逻辑。

未来,随着异构计算、物联网协同、机密计算和自然人机交互的深入,操作系统的边界将持续扩展,从管理单一设备的软件层,逐渐演化为协调数字-物理融合世界的智能基础设施。

延伸阅读

  • [Exokernel 架构]:MIT 提出的”将资源分配权下放给应用”的极简内核设计,以及它对 OS 抽象哲学的根本性挑战。
  • [Unikernel]:将应用与 OS 内核编译为单一镜像的 Library OS 思路,在云原生场景下的安全性与性能优势。
  • 下一篇预告:SEDA 阶段式事件驱动架构——互联网服务如何在极端负载波动下优雅降级。

代码链接:
https://github.com/Kevin589981/BNLJ

嵌套循环连接(Nested Loop Join)是关系型数据库最基础的连接算法,但其糟糕的缓存利用率一直是性能瓶颈所在。本文记录了在 PostgreSQL 源码层面实现块嵌套循环连接(Block Nested Loop Join,BNLJ)的完整过程:从 GUC 参数注册、执行节点状态扩展,到流水线状态机的设计,以及用 palloc 替代 malloc 踩坑的教训。最终实验表明,将块大小调至 128 可获得约 48.5% 的性能提升。

1. 背景:嵌套循环连接的缓存困境

PostgreSQL 原始嵌套循环连接的逻辑极为直白:

for each outer tuple i:
    for each inner tuple j:
        if join_condition(i, j): emit (i, j)

其时间复杂度为 $O(|R| \cdot |S|)$,每处理一个外表元组就要完整扫描一遍内表。这在 I/O 层面造成两个问题:

  1. 内表重复扫描:内表被扫描 $|R|$ 次,每次都从磁盘重新加载;
  2. CPU 缓存浪费:每次只有一个外表元组在缓存,块内比较无从发生。

块嵌套循环连接通过批量缓存外表元组来解决这两个问题:

for each block B of outer tuples (size = block_size):
    for each inner tuple j:
        for each outer tuple i in B:
            if join_condition(i, j): emit (i, j)

设外表元组数为 $R$、内表元组数为 $S$、块大小为 $B$,理论 I/O 代价对比:

$$T_{\text{NLJ}} = R \times S \times C_{\text{page}}$$

$$T_{\text{BNLJ}} = \left\lceil \frac{R}{B} \right\rceil \times S \times C_{\text{page}}$$

当 $B$ 足够大时,内表扫描次数从 $R$ 降至 $\lceil R/B \rceil$,理论加速比为 $B$(受边际效应限制)。


2. PostgreSQL 执行器架构概述

在动手修改之前,需要理解 PostgreSQL 的执行器工作模式。每个计划节点(Plan Node)实现三个标准接口:

ExecInitNode();                              // 初始化节点,分配资源
while ((tuple = ExecProcNode()) != NULL) {   // 循环拉取元组(Volcano 模型)
    // 处理元组
}
ExecEndNode();                               // 清理资源

这是经典的 Volcano/Iterator 模型:父节点调用子节点的 Next(),子节点按需产出一个元组。对于嵌套循环连接节点(nodeNestloop.c),ExecNestLoop 函数就是这个 Next() 的实现。

本实验的目标就是修改 ExecNestLoop,使其从”每次取一个外表元组”变为”每次取一批外表元组”。


3. 实现步骤

3.1 注册 GUC 参数

为了允许通过 SQL 命令动态调整块大小,在 src/backend/utils/misc/guc.c 中注册一个整型 GUC 参数:

int block_nested_loop_join_block_size;

static struct config_int ConfigureNamesInt[] =
{
    {
        {"block_nested_loop_size", PGC_USERSET, RESOURCES_MEM,
            gettext_noop("Sets the block size for blocked nested loop joins."),
            NULL,
            GUC_UNIT_BLOCKS
        },
        &block_nested_loop_join_block_size,
        4, 1, 1024,          // 默认值 4,范围 [1, 1024]
        NULL, NULL, NULL
    },
    // ... 其他参数
}

之后可以通过 SET block_nested_loop_size = 64; 在会话级别动态调整块大小。在 src/include/executor/nodeNestloop.h 中导出该变量:

extern int block_nested_loop_join_block_size;

3.2 扩展 NestLoopState 结构

原始的 NestLoopState 只需维护”是否需要新外表元组”等简单状态。BNLJ 需要额外追踪外表的状态,在 src/include/nodes/execnodes.h 中扩展:

typedef struct NestLoopState
{
    JoinState   js;                  /* 基类,必须是第一个字段 */
    bool        nl_NeedNewOuter;
    bool        nl_MatchedOuter;
    TupleTableSlot *nl_NullInnerTupleSlot;

    /* === BNLJ 新增字段 === */
    int          block_size;         // 用户配置的块大小
    TupleTableSlot **outerBlock;     // 外表块缓冲区(指针数组)
    int          outerStoredCount;   // 当前块中实际存储的元组数
    int          outerCount;         // 当前正在处理的块内元组索引
    bool         nl_NeedNewBlock;    // 是否需要加载新的外表块
    bool         nl_NeedNewInner;    // 是否需要获取新的内表元组
    bool         no_more_tuple;      // 外表已无更多元组
} NestLoopState;

关键设计:outerBlockTupleTableSlot * 的指针数组,每个槽位独立持有一个元组的拷贝,由 PostgreSQL 统一的元组槽抽象管理生命周期。

3.3 初始化:分配块缓冲区

ExecInitNestLoop 末尾,从全局 GUC 参数读取块大小并分配内存:

nlstate->block_size = block_nested_loop_join_block_size;
nlstate->outerCount = 0;
nlstate->outerStoredCount = 0;

// 使用 palloc 而非 malloc!
nlstate->outerBlock = (TupleTableSlot **)palloc(
    nlstate->block_size * sizeof(TupleTableSlot *));

nlstate->nl_NeedNewBlock = true;
nlstate->nl_NeedNewInner = false;
nlstate->no_more_tuple = false;

// 为每个槽位初始化独立的 TupleTableSlot
for (int i = 0; i < nlstate->block_size; i++) {
    nlstate->outerBlock[i] = ExecInitExtraTupleSlot(
        estate,
        ExecGetResultType(outerPlanState(nlstate)),
        ExecGetResultSlotOps(outerPlanState(nlstate), NULL));
}

关键教训:这里必须用 palloc,不能用 malloc。PostgreSQL 的元组槽绑定到内存上下文(Memory Context),如果使用 malloc 分配缓冲区,部分自动清理机制会和手动 free 产生冲突,导致 SELECT COUNT(*) 结果偏大甚至段错误(见第 6 节)。

3.4 清理:释放块缓冲区

ExecEndNestLoop 中清理新分配的状态:

if (node->outerBlock != NULL) {
    for (int i = 0; i < node->block_size; i++) {
        if (node->outerBlock[i])
            ExecClearTuple(node->outerBlock[i]);  // 清除槽位,而非 pfree
    }
    node->outerCount = 0;
    node->outerStoredCount = 0;
    node->block_size = 0;
}

注意使用 ExecClearTuple 而不是直接 pfree,因为槽位的内存由上下文管理,强制 pfree 会破坏内存上下文的完整性。

3.5 核心执行逻辑:三层流水线状态机

ExecNestLoop 是最核心的改动。BNLJ 由三个嵌套的逻辑层构成,使用三个 bool 标志控制状态转移:

nl_NeedNewBlock  →  加载外表块
nl_NeedNewInner  →  获取一个内表元组
nl_NeedNewOuter  →  取块内下一个外表元组(触发匹配)

状态转移图:

初始 → [NeedNewBlock=true]
  └→ 加载块(最多 block_size 个外表元组)
  └→ [NeedNewInner=true] 重置内表扫描
     └→ 获取一个内表元组
        ├→ 内表元组为空 → [NeedNewBlock=true](内表扫描完,换下一块)
        └→ [NeedNewOuter=true],重置块内索引
           └→ 遍历块内每个外表元组,执行连接判断
              ├→ 块内元组耗尽 → [NeedNewInner=true]
              └→ 匹配成功 → 投影并返回

层一:加载外表块

if (node->nl_NeedNewBlock) {
    node->outerStoredCount = 0;
    while (node->outerStoredCount < node->block_size) {
        outerTupleSlot = ExecProcNode(outerPlan);
        if (TupIsNull(outerTupleSlot) && !node->outerStoredCount)
            return NULL;  // 外表为空,整个连接结束
        if (TupIsNull(outerTupleSlot)) {
            node->no_more_tuple = true;
            break;        // 外表已取完,块可能不满
        }
        ExecCopySlot(node->outerBlock[node->outerStoredCount], outerTupleSlot);
        node->outerStoredCount++;
    }
    node->nl_NeedNewBlock = false;
    node->nl_NeedNewInner = true;
    ExecReScan(innerPlan);  // 重置内表,准备新一轮扫描
}

层二:获取内表元组

if (node->nl_NeedNewInner) {
    innerTupleSlot = ExecProcNode(innerPlan);
    econtext->ecxt_innertuple = innerTupleSlot;
    node->nl_NeedNewInner = false;
    node->outerCount = 0;
    node->nl_NeedNewOuter = true;
    if (TupIsNull(innerTupleSlot)) {
        node->nl_NeedNewBlock = true;  // 内表扫描完,需要新块
        if (node->no_more_tuple)
            return NULL;               // 外表也完了,结束
        continue;
    }
}

层三:遍历块内外表元组(最内层)

if (node->nl_NeedNewOuter) {
    if (node->outerCount >= node->outerStoredCount) {
        node->nl_NeedNewInner = true;  // 块内遍历完,取下一个内表元组
        node->outerCount = 0;
        continue;
    }
    outerTupleSlot = node->outerBlock[node->outerCount++];
    econtext->ecxt_outertuple = outerTupleSlot;
}
// 执行连接条件判断,成功则投影返回

4. 实验结果

实验使用 PostgreSQL 内置的 restaurantphone 表(外表,2463 个元组)进行连接测试,禁用哈希连接与归并连接以强制使用嵌套循环,对不同块大小测量执行时间(每组运行两次取第二次热启动结果):

块大小 运行时间(ms) 相对未优化的加速比
修改前(原始 NLJ) 455.807 ——
1 457.915 -0.46%(略慢)
2 332.043 27.15%
4 323.550 29.02%
8 283.995 37.69%
16 255.516 43.94%
32 243.390 46.60%
64 239.891 47.37%
128 234.610 48.53%
256 231.298 49.26%
512 230.491 49.43%
1024 229.880 49.57%

初始嵌套循环连接测试(禁用 hash/merge)


5. 性能分析

块大小为 1 时反而变慢

块大小为 1 时,BNLJ 等价于原始的 NLJ,但由于 PostgreSQL 优化器会自动选择代价最低的内外表顺序,我们强制指定的内外表分配与优化器原来的选择互换了角色,导致性能略微下降。

边际效应:块大小 > 32 后收益递减

从数据可以看出:块大小 32 → 64 的提升约 0.77%,64 → 128 约 1.16%,128 → 256 约 0.73%,此后几乎平台化。原因在于:

当 $B$ 增大到 $R/k$($k$ 为小常数)后,$\lceil R/B \rceil$ 变化微乎其微,此时外表加载本身的固定代价成为瓶颈:

$$T \approx \underbrace{R \times C_{\text{page}}}{\text{固定(外表加载)}} + \underbrace{\left\lceil \frac{R}{B} \right\rceil \times S \times C{\text{page}}}_{\text{可优化部分}}$$

此外 CPU 缓存大小也构成物理上限——当块大小超出 L3 缓存容量时,块内比较本身也开始产生 cache miss。

工程建议

块大小 32 ~ 128 是性价比最高的区间:对于大多数工作负载,这已能获得接近最优的 I/O 减少效果,同时避免过大块占用过多内存。在实际 PostgreSQL 配置中,可以通过 block_nested_loop_size 在会话级别为特定查询精细调优。


6. 实验中踩的坑

坑 1:malloc 导致 SELECT COUNT(*) 结果偏大

这是本次最诡异的 bug。将块缓冲区用 malloc 分配后,执行 SELECT COUNT(*) 的结果比实际值偏大。

原因:PostgreSQL 的执行器是流水线(pipeline)式的,内存管理依赖**内存上下文(Memory Context)**的生命周期。部分元组槽和 plan node 状态会在查询结束时由上下文统一回收。malloc 分配的内存游离于上下文之外,当上下文的自动清理和 pfree 产生冲突时,可能导致内存重叠或 use-after-free,使得已释放的元组数据仍被计数。

解法:改用 palloc,所有分配纳入查询内存上下文,随查询结束自动回收。

`malloc` 导致 count 偏大的调试输出

另一次 debug 截图

坑 2:死循环定位困难

流水线状态机逻辑中,nl_NeedNewBlock 的初始值设置错误,导致程序陷入死循环。由于 PostgreSQL 是多进程架构,GDB 无法直接 attach 到查询进程。解法:

  1. 先完成用户连接(psql);
  2. ps aux | grep postgres 获取查询子进程 PID;
  3. gdb -p <pid> attach 后中断,观察调用栈和局部变量。

坑 3:GDB 调试时内外进程分离

PostgreSQL 会为每个连接 fork 一个子进程,GDB attach 父进程无法追踪查询执行逻辑。需要 attach 到实际执行查询的子进程(postgres: user db ... 进程)。


7. PostgreSQL 内部机制补充说明

Volcano 模型中的 ExecReScan

ExecReScan(innerPlan) 是 BNLJ 能够正确工作的关键调用。每当外表块切换时,内表需要从头扫描,ExecReScan 将内表计划节点的状态重置到初始状态(等价于 rewind)。

注意:只在块切换时调用,而不是每个外表元组触发一次——这正是 BNLJ 相比 NLJ 的核心优化所在(内表扫描次数从 $R$ 降为 $\lceil R/B \rceil$)。

TupleTableSlot 与 ExecCopySlot

TupleTableSlot 是 PostgreSQL 中元组的统一抽象容器。ExecCopySlot(dst, src) 将源槽位的元组深拷贝到目标槽位,确保外表块持有独立的元组数据,不受后续 ExecProcNode 调用的影响。


延伸阅读

  • [Hash Join 实现]:哈希连接适用于等值连接,内存需求更高但均摊代价 $O(|R| + |S|)$,与 BNLJ 的适用场景互补
  • [Grace Hash Join]:当哈希表超出内存时退化为分区式连接,可类比 BNLJ 的分块思路
  • 下一篇预告:PostgreSQL 查询优化器代价模型——优化器如何决定使用 NLJ 还是 Hash Join

代码地址:
https://github.com/Kevin589981/BookDataBase

一次从零开始的全栈开发实践:后端选用 FastAPI + SQLAlchemy ORM + SQLite,前端采用 Vue3 + Vite,实现了一套完整的书城图书销售管理系统。本文记录数据库 Schema 设计中的权衡、RESTful API 的认证方案,以及从 Django 转向 FastAPI、从 pytest 转向 Postman 调试的全过程思考,以及那个调了三天的”200 OK 但前端不显示”的神奇 bug。

1. 项目概述与技术选型

业务边界

系统覆盖一个书城的核心管理流程:

  • 用户管理:超级管理员 / 普通管理员双角色,基于 Token 的会话管理,单设备登录限制;
  • 库存管理:书籍 CRUD,支持 ISBN 精确/模糊查询;
  • 进货流程:创建进货单 → 付款 → 到货确认,全流程状态机,操作员留痕;
  • 零售流程:多品类购物车 → 创建销售单 → 自动扣减库存;
  • 财务管理:进货/零售双向账单,支持日期/金额范围查询及趋势图。

技术栈选型过程

最初计划使用 Django,研究 6 小时后因上手成本过高放弃。最终选型方案:

层次 选择 理由
后端框架 FastAPI 自动生成 OpenAPI 文档,类型注解友好,异步支持
ORM SQLAlchemy 数据库无关,防 SQL 注入,Python 生态成熟
数据库 SQLite 部署简单,满足课程项目规模
前端框架 Vue3 + Vite 中文文档丰富,响应式系统直观
接口调试 Postman 可持久化保存 Token,比 FastAPI 内置 Swagger 更灵活

架构图如下:

后端架构

前端架构


2. 数据库设计

2.1 用户体系:双表分离的登录态管理

用户信息存储在两张表中——这是整个系统最重要的设计决策之一。

User(持久化用户信息):

字段 类型 说明
username String(50) PK 用户名作为主键
employee_id String(20) UNIQUE 工号
true_name String(50) 真实姓名
gender Enum(‘male’,’female’) 性别枚举
isSuperAdmin Boolean 超级管理员标识
password_hash String(128) MD5 加密密码

User 表结构

LoginedUser(活跃会话表):

字段 类型 说明
username String(50) PK 登录用户名
employee_id String(20) UNIQUE 工号(冗余字段)
isSuperAdmin Boolean 权限标识(冗余字段)
token String(256) HS256 加密的 JWT Token
expiration_time DateTime Token 过期时间戳

LoginedUser 表结构

为什么要将 employee_idisSuperAdmin 冗余到 LoginedUser 表?

这是一个典型的以空间换时间的设计:认证中间件在每次请求时都需要验证权限,如果每次都要联表查询 User,N 个并发请求就是 N 次额外的 JOIN。将这两个高频读字段冗余到会话表后,权限验证退化为单表查询。

单设备登录限制的实现:每次新登录前,先删除该用户的已有会话记录,再插入新记录。这样旧设备的 Token 在下次请求时会因找不到对应会话而失效,被响应拦截器自动踢出并跳转登录页。

2.2 进货流程:状态机约束在数据库层的落地

进货订单有四个状态:未付款 → 已付款 → 已到货,或 未付款 → 已退货。系统用三个操作员字段 operator_id / operator_id2 / operator_id3 记录每个环节的责任人,并通过 CheckConstraint 在数据库层强制执行状态-字段联动规则:

-- 规则:未付款状态时 operator_id2 必须为空,其他状态时必须存在
(operator_id2 IS NULL AND payment_status == '未付款') OR 
(operator_id2 IS NOT NULL AND payment_status != '未付款')

-- 规则:operator_id3 仅在"已到货"状态时存在
(operator_id3 IS NULL AND payment_status != '已到货') OR 
(operator_id3 IS NOT NULL AND payment_status == '已到货')

将业务规则下推到数据库约束层,而不是只在应用层检查,是一种防御性设计——即使绕过 API 直接写数据库也无法破坏数据完整性。

状态流转图:

[*] ──> 未付款 ──> 已付款 ──> 已到货
              └──> 已退货

2.3 销售体系:一对多订单明细

一笔销售单可能包含多种书籍,采用经典的订单头(SaleOrder)+ 明细行(SaleItem)拆分:

  • SaleOrder.transaction_no:后端自动生成,格式为 SO + 年月日时分秒 + 随机数
  • SaleItem.sold_price:独立于 Book.retail_price,支持折扣后价格;
  • 级联删除:SaleItem 设置 ondelete="CASCADE",删除订单时自动清理明细。

2.4 财务账单:动态外键关联

Bill 表通过 bill_type(进货/零售)+ related_order 组合实现”动态外键”:当 bill_type = "进货" 时,related_order 指向 purchase_orders.id;当 bill_type = "零售" 时,指向 sale_orders.id。SQLAlchemy 并不直接支持动态外键约束,这里在应用层保证一致性,数据库层用 CHECK 确保两字段同时存在或同时为空。

2.5 关于 ISBN 的存储类型选择

ISBN 选择 String(13) 而非 Integer

  1. 整数存储可能丢失前导零,造成格式错误;
  2. 字符串支持 LIKE 模糊搜索(contains 函数),整数无法模糊查询;
  3. 通过 CheckConstraint 验证 LENGTH(isbn) = 13 AND isbn GLOB '[0-9]*' 保证格式正确性。

2.6 关于 Numeric(10, 2) 存储金额

金额字段全部使用 Numeric(10, 2) 而非 Float。原因:Float 是近似浮点数,在涉及财务精度的场景下 0.1 + 0.2 ≠ 0.3 的问题不可接受;Numeric 是精确小数类型,确保分位精确到两位。


3. 认证与权限系统

3.1 Token 生命周期

登录成功后,后端:

  1. 生成 expiration_time = 当前时间戳 + 8小时
  2. 用 HS256 算法将 {username, expiration_time} 签名为 JWT Token;
  3. 将 Token 及 expiration_time 写入 LoginedUser 表(同时踢出旧会话);
  4. expiration_time 也单独存一列——验证时直接比较时间戳,无需解码 Token,减少计算量。

前端将 Token 存入 localStorage,实现”8 小时免登录”。

3.2 FastAPI 依赖注入实现权限分层

# 普通认证:验证 Token 有效性
router = APIRouter(dependencies=[Depends(auth.Auth.get_current_user)])

# 超管认证:额外检查 isSuperAdmin
router_admin = APIRouter(dependencies=[Depends(auth.Auth.admin_required)])

admin_required 在查询 LoginedUser 表时检查 isSuperAdmin 字段,若为 False 则返回 403。

3.3 前端的双拦截器设计

// 请求拦截器:自动注入 Authorization 头
axios.interceptors.request.use(config => {
    const token = localStorage.getItem('token')
    if (token) config.headers['Authorization'] = `Bearer ${token}`
    return config
})

// 响应拦截器:拦截 401,清除登录状态并跳转
axios.interceptors.response.use(null, error => {
    if (error.response?.status === 401) {
        localStorage.clear()
        router.push('/login')
    }
    return Promise.reject(error)
})

即使恶意用户修改前端页面绕过权限校验,后端 403/401 响应也会触发响应拦截器强制登出,防止进一步的非授权操作。


4. 前端界面展示

登录页与主仪表盘:

主页

登录页

仪表盘

进货管理与账单流水趋势图:

进货管理

账单流水趋势图


5. 踩坑记录

坑 1:200 OK 但前端页面不显示——调了三天的重定向 bug

这是本项目最匪夷所思的 bug。GET /book(注意:没有尾部斜杠)接口返回 200 但前端完全不更新。

根因链:

  1. 前端代码写的是 /book,FastAPI 路由注册的是 /book/
  2. FastAPI/Starlette 对不匹配的路径执行 301 重定向/book → /book/
  3. 浏览器遵守安全规范:重定向时会剥离 Authorization 请求头
  4. 后端收到的第二次请求(/book/)没有 Token,但因为代码中漏掉了认证依赖,直接查询并返回了结果;
  5. FastAPI 响应里没有 CORS Access-Control-Allow-Credentials 等字段,浏览器自动丢弃了这个响应。

请求重定向导致 Authorization 头丢失

修复方式:统一在路由中加尾部斜杠,并修复漏掉认证依赖的接口。

教训:状态码 200 ≠ 前端收到了正确响应,浏览器安全机制会在多个环节静默丢弃跨源或认证异常的响应。

坑 2:全局变量无法支撑多用户并发登录

最初用一个全局 dict 存储登录状态,后来发现:

  1. 服务器重启后登录状态丢失;
  2. 多用户同时登录时,全局变量存在读写竞争。

改为数据库表 LoginedUser 存储,所有问题一并解决——数据库本身提供了持久化和事务隔离。

坑 3:pytest 无法保存动态 Token

API 测试最初尝试用 pytest 编写,但 JWT Token 每次登录都会重新生成,无法在测试用例间共享。解决方案:改用 Postman,可在 Collection Variables 中持久化保存 Token,并通过 Pre-request Script 自动刷新。

坑 4:GET 请求 URL 中的可选参数为空

当前端查询参数某个字段为空字符串时,URL 变为 /books/?title=&author=,后端 SQLAlchemy 会将 title = "" 理解为”查 title 等于空字符串的记录”而非”不过滤”。

修复:

  • 前端:传参前检查,空值则不加入 URL;
  • 后端:查询构建时 if param and param.strip() 双重判断。

坑 5:外键关联禁止删除书籍

书籍被进货单或销售单引用后,直接删除会触发外键约束错误(SQLite 默认启用外键约束需要 PRAGMA foreign_keys = ON)。在书籍删除接口中,检测 PurchaseOrderSaleItem 两张表是否存在关联记录,若存在则返回 409 并提示原因。


6. 功能完成情况

所有要求的功能均已实现并测试通过:

  • 用户管理(登录/注销/权限/CRUD)
  • 书籍管理(精确/模糊查询,外键安全删除)
  • 进货管理(完整状态机流转,操作员留痕)
  • 零售管理(实时 ISBN/书名模糊搜索,消抖处理)
  • 账单管理(多条件过滤,流水趋势图)

账单管理

项目最终代码量超过 10000 行(前后端合计)。


7. 设计反思

什么没做但后来觉得应该做的:BCNF 范式分解。当前设计中有意保留了一定冗余(如 LoginedUser 中的 employee_id),这是在查询效率和规范化之间的主动权衡,并非疏忽。

什么设计了但没用上的:销售单价打折字段(discount_price)。涉及 Bill 外键关联和前后端所有接口变更,改动链过长,最终舍弃。这提醒我在项目初期应当更审慎地确认需求边界。

关于 ORM 的选择:SQLAlchemy 不仅提供了数据库无关的抽象层,其 Core 层的参数化查询机制从底层防止了 SQL 注入——这在直接拼接 SQL 字符串的方案中是需要额外处理的。

延伸阅读

  • [BCNF 范式]:数据库规范化的理论边界——何时应当分解,何时冗余是合理的工程权衡
  • [JWT 的安全边界]:HS256 签名而非加密意味着 payload 可以被解码——哪些数据应该放进 Token
  • 下一篇预告:SQLAlchemy ORM 性能陷阱——N+1 查询问题与 selectinload / joinedload 的正确用法

「Attention is All You Need」(Vaswani et al., 2017) 不仅是 NLP 的转折点,更是现代大模型(LLM)的物理基石。本文旨在从数学本质、代码实现及系统效率三个维度,深度拆解 Transformer 的核心机制。

1. Self-Attention:非局部的全局关联

传统的 CNN 受限于卷积核大小,RNN 受限于时间步的串行依赖。而 Self-Attention 实现了 $O(1)$ 的路径长度,让序列中任意两个 Token 都能实现“瞬间通信”。

1.1 物理意义:Q、K、V 到底在做什么?

我们可以将 Self-Attention 理解为一个寻址过程

  • Query ($Q$): “我要找什么?”(当前 Token 的需求特征)
  • Key ($K$): “我有什么?”(其他 Token 的属性标签)
  • Value ($V$): “我能提供什么信息?”(实际承载的内容)

通过 $QK^T$ 计算相似度,模型实际上在学习:在当前语境下,哪些 Token 的信息对我是最重要的。

1.2 缩放因子 $\sqrt{d_k}$ 的数学必要性

为什么公式中要除以 $\sqrt{d_k}$?

  • 防止梯度消失:当 $d_k$ 很大时,$QK^T$ 的点积结果方差会很大。
  • Softmax 饱和区:如果点积值过大,经过 Softmax 后会进入极度平缓的分散区,导致梯度接近于 0。通过缩放,我们将分布拉回梯度敏感区。

$$
\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V
$$


2. 工程实现:PyTorch 视角下的注意力掩码

在处理 Batch 数据或 Decoder 自回归生成时,Mask(掩码) 是保证模型“不作弊”的关键。

import torch
import torch.nn.functional as F

def scaled_dot_product_attention(Q, K, V, mask=None):
    """
    精简版 Scaled Dot-Product Attention 实现
    """
    d_k = Q.size(-1)
    
    # 1. 计算注意力分数: (Batch, Head, Seq, Seq)
    scores = torch.matmul(Q, K.transpose(-2, -1)) / (d_k ** 0.5)
    
    # 2. 应用掩码: 
    # 在 Decoder 中,需屏蔽未来信息;在 Padding 处,需屏蔽无效位置
    if mask is not None:
        # 将 mask 为 0 的位置设为极小值,Softmax 后权重接近 0
        scores = scores.masked_fill(mask == 0, -1e9)
    
    # 3. 归一化权重
    attn_weights = F.softmax(scores, dim=-1)
    
    # 4. 加权求和得到 context vector
    return torch.matmul(attn_weights, V), attn_weights

3. Multi-Head Attention:特征空间的“分而治之”

单一的 Attention 容易让模型陷入局部关注。Multi-Head 的本质是特征空间的并行采样

  • 子空间投影:将 $d_{model}$ 维度的特征切分为 $h$ 个低维空间。
  • 语义分工:在实际观测中,不同的 Head 会自发演化出不同的职能——有的 Head 关注句法结构(如动宾关系),有的关注实体指代,有的关注标点符号

计算复杂度分析
虽然看似增加了计算量,但在并行计算框架下,Multi-Head 实际上是通过矩阵分块实现的,总参数量与 Single-Head 保持一致(通过 $W^Q, W^K, W^V$ 的降维投影)。


4. 架构对比与系统瓶颈

作为计算机专业学生,我们需要关注算法背后的系统代价

维度 RNN / LSTM Transformer
并行度 差 (依赖前一时刻状态) 极佳 (矩阵运算天然适配 GPU)
长距离依赖 易丢失 (梯度消失/爆炸) 无损 (任意距离 $O(1)$ 通信)
计算复杂度 $O(n \cdot d^2)$ $O(n^2 \cdot d)$ (注意力矩阵平方向增长)
显存瓶颈 较低 高 (Self-Attention 矩阵随序列长度平方级爆炸)

5. 关于“Attention 局限性”的思考

尽管 Transformer 极度强大,但其 $O(n^2)$ 的复杂度限制了它处理超长文本(如整本书)的能力。目前学术界的研究热点,如 FlashAttention(通过算子融合减少内存 I/O)和 Linear Attention,正是在试图解决这个系统级的瓶颈。


延伸阅读

link to source code:
https://github.com/Kevin589981/RUDP_python3

TCP 的可靠性来自精心设计的重传机制,但其内部细节对上层应用透明。如果要在 UDP 这张”白纸”上手工绘制可靠传输,需要多少工程量?本文复盘了一次 RUDP(Reliable UDP)的完整实现,包括 Go-Back-N 与选择重传(SACK),以及针对各类异常场景的健壮性测试。

一、问题背景与协议设计

UDP 是面向无连接的无状态协议:快,但不可靠。数据包可能丢失、乱序、重复或损坏。在 UDP 之上实现可靠传输(RUDP),本质上是在应用层重新实现 TCP 的核心语义,但可以选择性地裁剪开销。

1.1 消息类型定义

RUDP 协议定义四种消息类型:startdataendack(及 sack)。每个数据包格式如下:

<type>|<sequence number>|<data>|<checksum>

checksum 用于检测传输过程中的数据损坏,接收方在序列号确认前首先验证校验和。

1.2 核心发送策略

发送端需要满足以下约束:

  • 数据包按序、可靠送达,支持丢包、乱序、重复等异常场景;
  • 支持 Go-Back-N(GBN)与选择重传(Selective ACK, SACK)两种模式;
  • 发送窗口大小为 5,超时时间 500ms。

二、发送端实现(Sender.py)

2.1 数据分片与窗口管理

文件内容按最大 1472 字节分片,加上 startend 包,构成完整报文序列。核心状态变量包括:

  • base:窗口左边界,指向最早未确认的包;
  • next_seq_num:下一个待发送包的序号;
  • timer:用于超时重传的时间戳。
# 分片与打包
chunks = [content[i:i+MAX_DATA_SIZE] for i in range(0, len(content), MAX_DATA_SIZE)]
self.packets.append(self.make_packet('start', self.isn, ""))
for i, chunk in enumerate(chunks[:-1]):
    seq = self.isn + 1 + i
    self.packets.append(self.make_packet('data', seq, chunk))
self.packets.append(self.make_packet('end', self.isn + len(self.packets), chunks[-1]))

2.2 主循环:滑动窗口 + 超时重传

while self.base < len(self.packets):
    # 填满发送窗口
    while (self.next_seq_num < self.base + self.window_size
           and self.next_seq_num < len(self.packets)):
        self.send(self.packets[self.next_seq_num])
        if self.base == self.next_seq_num:
            self.timer = time.time()    # 仅当新窗口打开时启动计时器
        self.next_seq_num += 1

    # 超时检测:重传窗口内所有未确认包
    if (self.base < self.next_seq_num
            and time.time() - self.timer > self.timeout):
        self.handle_timeout()

窗口语义的关键设计点:计时器仅在窗口从空变非空时启动,窗口滑动时重置,窗口归零时停止。这一逻辑若出现偏差,轻则包未及时重传,重则触发死锁。

2.3 ACK/SACK 处理:快速重传

def handle_new_ack(self, ack):
    self.dup_ack_count = 0
    cum_idx, sack_indices = self._parse_ack_string(ack)
    if cum_idx > self.base:
        self.base = cum_idx    # 滑动窗口左边界
    if self.base < self.next_seq_num:
        self.timer = time.time()    # 仍有未确认包,重启计时器
    else:
        self.timer = None           # 所有包已确认,停止计时器
    if self.sackMode:
        self._update_sack_bitmap(sack_indices)    # 标记已选择确认的包

三次重复 ACK 触发快速重传(不等待超时),这是对 TCP Fast Retransmit 的直接移植,可在高丢包场景下显著缩短恢复延迟。

SACK 位图的核心价值在于:GBN 模式下一旦发生丢包,窗口内所有后续包必须全部重传;而 SACK 允许接收方精确告知哪些包已到达,发送端只需重传真正缺失的包,大幅降低重传放大倍数。


三、测试设计:覆盖各类异常场景

除了基础功能测试,以下健壮性测试用例专门针对不同类型的网络异常:

测试用例 模拟场景 验证目标
AckCorruptionTest ACK/SACK 包内容损坏 发送端对无效确认的处理
AckDuplicationTest ACK/SACK 包重复 防止重复确认触发不必要的重传
AckLossTest ACK/SACK 包丢失 超时重传与窗口滑动正确性
AckReorderTest ACK/SACK 包乱序 对乱序确认的兼容性
DataCorruptionTest 数据包内容损坏 接收端丢弃损坏包并正确反馈
DataDuplicationTest 数据包重复 接收端去重能力
DataReorderTest 数据包乱序 接收端乱序缓存与窗口滑动
GBNTests / SackTests 上述场景分别用于两种模式 双模式的全覆盖验证
RandomDropTest 随机丢包 高丢包环境下的整体可靠性

3.1 测试结果分析

  • GBN 模式:所有测试(含高丢包、乱序、重复)均通过,数据可靠送达,未出现死锁或永久丢包。
  • SACK 模式:选择重传有效减少重传量,在高丢包环境下吞吐量明显优于 GBN。
  • 大文件 / 二进制文件传输:均通过,无数据错误,证明校验与分片逻辑正确。

四、遇到的问题与解决策略

4.1 窗口滑动与定时器同步

初始实现中,定时器更新时机与窗口滑动逻辑耦合不紧,导致部分已发包在某些状态下既不在定时器覆盖范围内、也未收到确认,形成”灰色包”,最终被遗忘。

解决方案:明确定义”计时器生命周期”为”窗口非空→停机”,窗口任何变动(发包、收 ACK)都触发相应的计时器操作,消除中间状态。

4.2 SACK 位图的双计数问题

SACK 模式下,如果位图更新逻辑与累计 ACK 解析之间存在重叠,可能对同一帧计数两次,导致提前重传或错误统计。通过将 cum_idx 推进逻辑与 sack_indices 位图更新逻辑严格串行化来消除此问题。

4.3 测试框架适配

实验框架对命令行参数格式和包格式有严格要求,手动测试时通过的逻辑在自动化测试中可能因格式错误失败。应在开发阶段就对齐框架期望的格式,避免后期大量调试。


五、设计权衡总结

RUDP 的实现让两个关键权衡变得清晰:

  1. 窗口大小 vs 延迟:窗口越大,吞吐量越高,但重传代价(GBN 模式)也越大。SACK 通过精确重传打破了这一耦合。
  2. 超时值 vs 响应速度:超时过短导致误重传(假阳性),过长则恢复延迟高。三次重复 ACK 触发的快速重传是应对突发丢包的低延迟补偿机制,无需等待超时周期。

从系统设计角度看,Go-Back-N 与 SACK 的选择实质上是”实现复杂度”与”网络效率”之间的权衡——在今天高丢包的无线网络环境中,SACK 早已是默认选择。


延伸阅读

  • [RFC 2018 TCP SACK 选项规范]:选择重传的标准定义,值得对照实现细节阅读。
  • [QUIC 协议设计]:Google 在 UDP 之上重新设计的可靠传输协议,融合了多路复用、0-RTT 握手等现代设计,是 RUDP 思想的工业级延伸。
  • 下一篇预告:从传输层向上走——应用层的长程记忆问题:MemOS 记忆操作系统的工程实践。

本文记录了一个基于树莓派的智能小车项目:通过 OpenCV 识别不同颜色的魔方,根据颜色规则从左侧或右侧绕行,并在遇到双魔方时从中间穿越。系统采用多线程架构,将颜色检测、距离测量、PWM 更新三条流水线并行化;电机控制使用 PID 闭环,编码器反馈速度;整体逻辑由有限状态机驱动。文章重点分析了 PID 在电压不稳定供电下的局限性,以及 HSV 颜色空间的实战调参经验。

1. 任务描述与规则

场地上设有三个里程碑,每个里程碑放置 1 到 2 个魔方(颜色从红/黄/蓝/绿中选取):

  • 里程碑 1:1 个魔方
  • 里程碑 2:2 个同色魔方,间距 ≥ 2 个车身宽度
  • 里程碑 3:1 个魔方

绕行规则:

魔方颜色 绕行方向
红色 / 黄色 左侧绕过
蓝色 / 绿色 右侧绕过
双魔方(同色) 两者中间穿越

小车需要在正式测试前不知道具体颜色和位置的情况下,靠实时视觉完成全程。


2. 系统硬件架构

硬件模块 型号/说明 职责
主控 树莓派 运行所有控制逻辑
直流电机 + 驱动板 L298N 类驱动 四轮驱动,PWM 调速
摄像头 USB 摄像头 捕获图像,颜色识别
超声波传感器 KS103(I2C) 测量与前方障碍物距离
编码器 Hall 效应编码器,白入 585 脉冲/圈 测量轮速,PID 反馈

引脚分配(BCM 编号):

EA, I2, I1 = 13, 19, 26   # 右电机:PWM + 方向
EB, I3, I4 = 16, 20, 21   # 左电机:PWM + 方向
LS, RS = 6, 12             # 左/右编码器输入

PWM 频率设为 100 Hz,相比更低频率可以使电机转动更平滑(减少转矩脉动)。


3. 电机控制:PID 闭环 + 守护线程分离

3.1 PID 控制原理

控制目标是让电机实际转速(由编码器采样)跟随设定转速。PID 控制器的离散化形式:

$$u_k = K_p \cdot e_k + K_i \sum_{i=0}^{k} e_i + K_d \cdot (e_k - e_{k-1})$$

其中 $e_k = v_{\text{target}} - v_{\text{actual}}$,$u_k$ 直接映射为 PWM 占空比(限幅在 [0, 100])。

class PID:
    def __init__(self, P=38.57, I=0.1, D=70, speed=0.5):
        self.Kp, self.Ki, self.Kd = P, I, D
        self.ideal_speed = speed
        self.integral = 0
        self.err_last = 0

    def update(self, feedback_value):
        err = self.ideal_speed - feedback_value
        self.integral += err
        u = self.Kp * err + self.Ki * self.integral + self.Kd * (err - self.err_last)
        self.err_last = err
        return max(0, min(100, u))   # PWM 占空比限幅

最终调定的参数为左轮 $K_p=45, K_i=0.1, K_d=70$,右轮 $K_p=40, K_i=0.1, K_d=70$(两轮略有差异是因为电机特性不完全一致)。

3.2 三线程解耦架构

直接将 PID 计算和 GPIO 写入放在主控制循环里会导致采样不均匀、响应延迟,系统采用三线程分离:

线程 职责 周期
speed_monitor 编码器脉冲计数 → 转速(圈/秒) 100 ms
pwm_update_daemon 读取全局转速 → PID 计算 → 写 PWM 100 ms
主线程 状态机决策:设置目标速度 left_target_speed / right_target_speed 事件驱动
def speed_monitor(interval=0.1):
    GPIO.add_event_detect(LS, GPIO.RISING, callback=encoder_callback)
    GPIO.add_event_detect(RS, GPIO.RISING, callback=encoder_callback)
    while running:
        rspeed = rcounter / 585.0   # 脉冲数 → 圈/秒(每圈 585 脉冲)
        lspeed = lcounter / 585.0
        rcounter = lcounter = 0
        time.sleep(interval)

def pwm_update_daemon(interval=0.1):
    while running:
        if left_pid_global and right_pid_global:
            _set_motor_pwm(left_pid_global.update(lspeed),
                           right_pid_global.update(rspeed))
        time.sleep(interval)

主线程只需调用 set_motor_speed(left, right) 设定目标速度,PID 计算完全异步完成,主控逻辑得以保持简洁。


4. 颜色识别:HSV 空间与区域采样

4.1 为什么选 HSV 而非 RGB

RGB 颜色通道与亮度强耦合:同一块红色魔方在强光下 RGB 值显著不同,难以用固定阈值分割。HSV(Hue-Saturation-Value)将色调(H)与亮度(V)解耦,只需为每种颜色定义 H 通道的区间,S/V 可设较宽范围,对光照变化鲁棒得多。

四种颜色的典型 HSV 范围(OpenCV 中 H 取值范围 [0, 179]):

颜色 H 范围 S 范围 V 范围
红色 [0,10] ∪ [170,179] [100,255] [80,255]
黄色 [20,35] [100,255] [80,255]
蓝色 [100,130] [80,255] [60,255]
绿色 [40,80] [60,255] [60,255]

注意:红色的 H 值跨越 0°(红色在色环两端),需要取两段范围做并集,再用 cv2.bitwise_or 合并掩码。

4.2 行采样优化

全帧处理(640×480)在树莓派上帧率很低。系统在图像高度 25% 处取 50 像素高度的横带进行处理:

DEFAULT_ROW_PERCENT = 0.25   # 采样行位置(图像 25% 高度处)
DEFAULT_ROW_HEIGHT  = 50     # 采样区域高度

这样每帧只需处理 640×50 = 32000 像素,计算量降低约 94%,同时该高度对应小车前方地面上的魔方区域,有效减少背景干扰。

4.3 颜色区域定位

对二值化掩码做高斯模糊去噪后,通过 cv2.findNonZero 找到非零像素,计算连续色块的横坐标范围 $[x_{\text{start}}, x_{\text{end}}]$ 和中心 $x_{\text{center}}$,返回给状态机:

def detect_color(frame):
    roi = frame[row_start:row_end, :]
    hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
    results = {}
    for color_name, (lower, upper) in COLOR_RANGES.items():
        mask = cv2.inRange(hsv, lower, upper)
        mask = cv2.GaussianBlur(mask, (5,5), 0)
        # 提取横向色块段落,返回 (x_start, x_end, x_center) 列表
        results[color_name] = extract_segments(mask)
    return results

5. 距离测量:KS103 超声波传感器(I2C)

KS103 通过 I2C 总线通信,触发测量与读取结果的时序:

def measure_distance():
    bus.write_byte_data(KS103_ADDR, REG_TRIGGER, 0x01)  # 发送触发指令
    time.sleep(0.06)                                       # 等待测量完成(约 60ms)
    data = bus.read_i2c_block_data(KS103_ADDR, REG_RESULT, 2)
    distance_cm = (data[0] << 8 | data[1]) / 10.0        # 合并高低字节,单位 mm → cm
    return distance_cm

系统对距离数据做滑动窗口中值滤波(窗口大小 5),剔除超声波在近距离或倾斜表面的反射异常值,并在距离小于阈值时触发”即将碰撞”警告信号给状态机。


6. 状态机:三阶段顺序执行

主控逻辑由 StateManager 类管理,顺序执行三个阶段,每个阶段对应一个里程碑:

class StateManager:
    def __init__(self):
        self.current_state = 0         # 当前所处里程碑阶段(1/2/3)
        self.detected_color = None     # 当前识别到的魔方颜色
        self.color_confirm_counter = {} # 颜色稳定确认计数器
        self.last_bypass_direction = None

    def determine_bypass_direction(self):
        if self.current_state in (1, 3):
            # 单魔方:由颜色决定方向
            return 'left' if self.detected_color in LEFT_TURN_COLORS else 'right'
        else:  # 状态 2:双魔方,从中间穿越
            # 基于上一次绕行方向的对称策略
            return 'right' if self.last_bypass_direction == 'left' else 'left'

颜色确认机制:避免单帧误识别,要求同一颜色连续出现 N 帧才确认。通过 color_confirm_counter 对每种颜色计数,达到阈值后方触发绕行动作。

顺序执行主循环

def main_control_sequential():
    init_gpio(); start_speed_monitor(); start_pwm_update_daemon()
    init_i2c();  start_distance_measurement()
    camera = init_camera(); start_color_detection()
    time.sleep(2)   # 等待所有线程稳定

    for state in (1, 2, 3):
        state_manager.current_state = state
        handle_state_sequential(state)   # 靠近→识别→绕行→恢复直行

    final_sprint_sequential()           # 全速冲向终点

绕行策略采用矩形绕行而非圆弧:先原地转向,直行绕过,再原地转回,优点是实现简单、调试方便、不依赖精确的弧度控制。


7. 实验分析:问题与局限

7.1 直行偏差严重

根本原因:电池输出电压不稳定,随放电程度持续下降。PID 控制器虽然理论上能通过积分项补偿稳态误差,但电压变化导致电机特性曲线本身在漂移,PID 参数无法通用于整场测试。

即便引入了基于颜色中心偏移量的方向修正(drive_with_color),实际效果也表现出明显的摆动——修正增益大了蛇行,小了又偏。

def drive_with_color(color_offset, speed=0.5, offset_factor=0.2):
    normalized = min(1.0, abs(color_offset) / 200.0)
    if color_offset > 0:
        right_speed = speed * (1 - offset_factor * normalized)  # 向右偏:压右轮
    else:
        left_speed  = speed * (1 - offset_factor * normalized)  # 向左偏:压左轮

更好的方案:使用自适应 PID,周期性重新标定电机特性(如每次起步前做短暂标定行驶),或改用步进电机消除速度控制对电压的依赖。

7.2 转角不准:时间控制转向的缺陷

系统用”旋转固定时间”来实现 90° 转向:

rotate_in_place('clockwise', speed=0.5)
time.sleep(t_90_deg)   # 经验值,与电压强相关
stop_motor()

电压不稳 → PID 漂移 → 实际转速与设计转速不符 → 旋转角度近似随机。导致绕行两阶段无法正确衔接,出现找不到下一个魔方、撞墙等问题。

更好的方案:用编码器积分计算转过的圆弧长度(轮距 × 转角),闭环控制旋转角度,而非依赖时间。

7.3 光照对 HSV 阈值的影响

即使使用了 HSV 空间,在极暗或极亮的测试环境中,S/V 的阈值边界仍需现场微调。建议在系统启动时加入自动白平衡校正步骤,或对 V 通道做直方图均衡化后再分割。


8. 系统设计亮点

  1. 完全守护线程化:编码器采样、PWM 更新、颜色检测、距离测量全部运行在守护线程中(thread.daemon = True),主线程退出时自动回收,无需手动管理线程生命周期;

  2. 颜色确认防抖:多帧确认机制将单帧噪声的影响降到最低,代价是引入了固定延迟(约 N × 100ms),在实际调试中需要在检测速度和稳定性之间权衡;

  3. 模块化代码结构motor_controller.py / detect_color.py / detect_distance.py / main_controller.py 四模块解耦,每个模块可独立测试,最终在 main_controller.py 中聚合。


延伸阅读

  • [PID 自整定]:Ziegler-Nichols 方法——在没有精确数学模型的情况下系统化调定 PID 参数
  • [卡尔曼滤波]:比简单中值滤波更优的传感器融合方法,适用于距离与速度的联合估计
  • 下一篇预告:在树莓派上用 YOLOv8 替代 HSV 阈值分割——目标检测的实时性与精度权衡

第一章:环境与头文件 (Environment & Headers)

  1. 万能头#include <bits/stdc++.h> (包含几乎所有STL)。
  2. I/O流#include <iostream> (cin, cout, endl)。
  3. C风格I/O#include <cstdio> (scanf, printf, getchar, puts)。
  4. 字符串#include <string> (string类)。
  5. C字符串#include <cstring> (strlen, memset, strcpy, memcpy)。
  6. 数学#include <cmath> (pow, sqrt, sin, cos, abs-浮点)。
  7. 算法#include <algorithm> (sort, max, min, reverse, unique, lower_bound)。
  8. 向量#include <vector>
  9. #include <stack>
  10. 队列#include <queue> (包含 queue 和 priority_queue)。
  11. 双端队列#include <deque>
  12. 集合#include <set> (包含 set 和 multiset)。
  13. 映射#include <map> (包含 map 和 multimap)。
  14. 位集合#include <bitset>
  15. 命名空间using namespace std; (必须写在头文件后)。

第二章:输入输出详解 (I/O Detail)

cin/cout 系列

  1. 加速指令ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); (写在main第一行)。
  2. 加速后果:开启加速后,严禁混用 scanf/printf
  3. cin读取字符串cin >> s; (遇到空格、Tab、换行符停止)。
  4. 读一整行getline(cin, s); (读取直到换行符,包含空格)。
  5. 吃掉换行符:在 cin >> n; 后若接 getline,必须先 cin.ignore();getchar();
  6. 输出换行cout << '\n'; (比 endl 快,endl 强制刷新缓冲区)。
  7. 多组输入(EOF)while(cin >> n) { ... }

scanf/printf 系列 (格式化)
23. int%d
24. long long%lld
25. float%f
26. double%lf (scanf必须用lf,printf可用f或lf)。
27. char%c
28. char数组(字符串)%s (遇到空格/换行停止,包含&)。
29. string类输出printf("%s", s.c_str()); (必须转c_str)。
30. 读取单个字符跳过空白scanf(" %c", &c); (注意%c前面的空格,可跳过回车/空格)。
31. 固定宽度输出%05d (宽度5,不足补0)。
32. 保留小数%.4f (保留4位小数,自动四舍五入)。
33. 地址输出%p
34. 输入日期格式scanf("%d-%d-%d", &y, &m, &d); (自动跳过横杠)。
35. 多组输入(EOF)while(scanf("%d", &n) != EOF) { ... }

其他I/O
36. 快读(getchar)c = getchar() 每次读一个字符,比scanf快。
37. 输出精度控制(cout)#include <iomanip>
38. cout定点cout << fixed; (后续变为定点小数模式)。
39. cout精度cout << setprecision(4) << val;


第三章:数据结构 API 细则 (Data Structures)

1. std::vector (动态数组)

  1. 定义vector<int> v;
  2. 定义带大小vector<int> v(100); (默认全是0)。
  3. 定义带初值vector<int> v(100, -1); (100个-1)。
  4. 尾部插入v.push_back(val);
  5. 尾部删除v.pop_back(); (无返回值,空vector调用会RE)。
  6. 大小v.size(); (返回 size_t,比较时建议转 (int)v.size())。
  7. 判空v.empty(); (空返回true)。
  8. 清空v.clear(); (size变0,capacity不一定变)。
  9. 首元素v.front();v[0]
  10. 尾元素v.back();v[v.size()-1]
  11. 调整大小v.resize(n); (多退少补0)。
  12. 翻转reverse(v.begin(), v.end());
  13. 排序sort(v.begin(), v.end());

2. std::stack (栈 - LIFO)

  1. 头文件<stack>
  2. 入栈st.push(val);
  3. 出栈st.pop(); (无返回值,仅删除)。
  4. 栈顶st.top(); (返回栈顶元素,不删除)。
  5. 判空st.empty();
  6. 大小st.size();
  7. RE警示:对空栈调用 pop()top() 必报错。
  8. 没有clear:清空需 while(!st.empty()) st.pop();st = stack<int>();

3. std::queue (队列 - FIFO)

  1. 头文件<queue>
  2. 入队q.push(val);
  3. 出队q.pop(); (删除队头,无返回值)。
  4. 队头q.front(); (注意不是top)。
  5. 队尾q.back();
  6. 判空q.empty();
  7. 大小q.size();

4. std::priority_queue (优先队列/堆)

  1. 头文件<queue>
  2. 默认定义priority_queue<int> pq; (大根堆,最大的在top)。
  3. 小根堆定义priority_queue<int, vector<int>, greater<int>> pq;
  4. 入堆pq.push(val); (O(logN))。
  5. 堆顶pq.top(); (注意不是front)。
  6. 出堆pq.pop(); (删除堆顶)。
  7. 结构体堆:需在结构体内部重载 < 运算符。
  8. 重载注意:大根堆按 < 排序,如果想让小的在top,重载 < 时逻辑要反过来(或者直接重载 >),或者简单点:return a.x > b.x (x小的优先级高)。

5. std::deque (双端队列)

  1. 头队插dq.push_front(val);
  2. 尾队插dq.push_back(val);
  3. 头队出dq.pop_front();
  4. 尾队出dq.pop_back();
  5. 访问dq[i] (支持随机访问)。

6. std::set (集合 - 自动排序+去重)

  1. 插入s.insert(val);
  2. 删除值s.erase(val);
  3. 删除迭代器s.erase(iterator);
  4. 查找s.find(val); (返回迭代器,找不到等于 s.end())。
  5. 计数s.count(val); (在set里只能是0或1)。
  6. 大小s.size();
  7. 清空s.clear();
  8. 最小值*s.begin()
  9. 最大值*s.rbegin()
  10. 二分查找s.lower_bound(val); (返回首个 >= val 的迭代器)。
  11. 二分查找s.upper_bound(val); (返回首个 > val 的迭代器)。
  12. 遍历for(auto x : s) { ... } (从小到大)。

7. std::map (键值对映射)

  1. 定义map<key_type, value_type> mp;
  2. 插入/修改mp[key] = val;
  3. 副作用:若 mp[key] 不存在,读取它会自动创建并赋值为0/空串,导致size+1。
  4. 安全查找if (mp.find(key) != mp.end()) ...if (mp.count(key)) ...
  5. 遍历for(auto it = mp.begin(); it != mp.end(); it++)
  6. it->first
  7. it->second
  8. 按键排序:map默认按key从小到大排序。

第四章:字符串与常用算法 (String & Algo)

String 常用函数

  1. 长度s.length()s.size()
  2. 判空s.empty()
  3. 拼接s1 += s2;s = s1 + s2;
  4. 比较if (s1 == s2) (字典序比较)。
  5. 截取s.substr(pos, len); (重要:第二个参数是长度)。
  6. 截取到末尾s.substr(pos);
  7. 查找s.find("abc"); (返回首个位置下标)。
  8. 查找失败s.find(...) == string::npos
  9. 插入s.insert(pos, "str");
  10. 删除s.erase(pos, len);
  11. 替换s.replace(pos, len, "str");
  12. 转数字stoi(s) (转int), stoll(s) (转long long)。
  13. 数字转字符to_string(123)

Algorithm 常用函数
114. 排序sort(begin, end); (默认升序)。
115. 降序sort(begin, end, greater<int>());
116. 自定义排序sort(..., cmp); (cmp函数返回true表示前者排在前)。
117. 结构体排序:必须写cmp或重载<
118. 最大值max(a, b);max({a, b, c, d}); (C++11)。
119. 最小值min(a, b);
120. 交换swap(a, b);
121. 反转reverse(begin, end);
122. 去重unique(begin, end); (只对相邻重复有效,需先sort)。
123. 去重长度int len = unique(a, a+n) - a;
124. 二分查找(>=)lower_bound(begin, end, val); (返回迭代器)。
125. 二分查找(>)upper_bound(begin, end, val); (返回迭代器)。
126. 获取下标lower_bound(...) - begin;
127. 全排列next_permutation(begin, end); (变到下一个字典序,返回bool)。
128. 填充fill(begin, end, val); (按元素填充)。
129. 最大公约数__gcd(a, b); (注意前面是两个下划线)。
130. 绝对值abs(x) (整数), fabs(x) (小数)。


第五章:C语言内存操作与类型 (C-Style & Types)

  1. memsetmemset(arr, 0, sizeof(arr)); (全0)。
  2. memsetmemset(arr, -1, sizeof(arr)); (全-1)。
  3. memsetmemset(arr, 0x3f, sizeof(arr)); (无穷大,约10^9)。
  4. memset陷阱不能用来赋值1, 2等任意数,因为它是按字节赋值。
  5. memcpymemcpy(dst, src, sizeof(src)); (复制数组)。
  6. strlenstrlen(s) (O(N)复杂度,不要放在for循环条件里!)。
  7. int范围:约 $\pm 2 \times 10^9$。
  8. long long范围:约 $\pm 9 \times 10^{18}$。
  9. unsigned long long:约 $1.8 \times 10^{19}$。
  10. LL常量long long x = 10000000000LL; (结尾加LL)。
  11. 溢出防范long long ans = 1LL * a * b;
  12. 无穷大(int)INT_MAX0x3f3f3f3f
  13. 无穷大(LL)LLONG_MAX
  14. double比较:不能直接 ==,要用 abs(a-b) < 1e-9

第六章:结构体与重载模板 (Struct & Overload)

  1. 结构体定义
    cpp struct Node { int x, y; };
  2. 构造函数
    cpp struct Node { int x, y; Node(int _x, int _y) : x(_x), y(_y) {} // 方便使用 Node() {} // 必须手动补上默认构造 };
  3. 重载小于号 (用于Sort/Set)
    cpp bool operator < (const Node &a) const { if (x != a.x) return x < a.x; // x升序 return y < a.y; // x相同,y升序 }
  4. 重载小于号 (用于优先队列)
    cpp bool operator < (const Node &a) const { return x < a.x; // 大根堆:x大的在顶(逻辑反直觉,标准库默认是大根堆) // 如果想实现小根堆效果(小的在顶),这里写 return x > a.x; }

第七章:容易遗忘的坑点 (Common Pitfalls)

  1. 数组大小:全局变量数组最大可开约 5e7 (int),main内部只能开 2e5 左右。
  2. RE原因:除以0。
  3. RE原因:数组越界 (index < 0 或 index >= N)。
  4. RE原因:爆栈 (递归太深,或局部变量数组太大)。
  5. TLE原因cin 没关同步。
  6. TLE原因endl 用太多。
  7. TLE原因:在循环里重复 strlen(s)
  8. TLE原因vector 在头部 inserterase (O(N)操作)。
  9. WA原因:多组数据,该清空的全局变量没清空 (flag, vector, map, count数组)。
  10. WA原因1 << 40 溢出 (int只有32位),应写 1LL << 40
  11. 位运算优先级a & b == c 会先算 b==c必须加括号(a & b) == c
  12. 宏定义陷阱#define mul(a,b) a*b -> mul(1+2, 3) 变成 1+2*3=7。应写 (a)*(b)
  13. 变量命名:不要用 time, next, y1 (cmath/algorithm里可能有冲突)。建议用 nxt, yy1
  14. 交互题:每次输出后必须 cout.flush()fflush(stdout)
  15. Map访问:只查不改一定用 find,不要用 [],否则会增加垃圾数据导致TLE/MLE。
  16. 浮点数陷阱double ans = 1/2; 结果是0。要写 1.0/2
  17. Set修改:Set里的元素是const的,不能直接修改 it->x,必须先erase再insert新值。
  18. Mod负数(a - b) % mod 可能是负数。正确写法:((a - b) % mod + mod) % mod

第八章:图论存储与遍历 (Graph Storage & Traversal)

  1. 邻接矩阵int g[N][N]; 适用于点数 $N \le 2000$。
  2. 邻接表(Vector)vector<int> g[N]; 最通用,带权图用 struct Edge {int to, w;}; vector<Edge> g[N];
  3. 链式前向星:虽然快但容易写错,建议校赛优先用 Vector,除非卡常。
  4. 建图(无向)g[u].push_back(v); g[v].push_back(u); (记得双向push)。
  5. 建图(有向)g[u].push_back(v);
  6. DFS栈溢出:系统栈通常只有几MB,深度超过 $10^5$ 可能爆栈,需改为手写栈或扩栈。
  7. BFS队列queue<int> q; q.push(start); vis[start]=1;
  8. BFS核心入队时立刻标记 vis[x]=1,不要等到出队才标记(否则会导致大量重复节点入队,退化成指数级)。
  9. 图的清空:多组数据时,for(int i=0; i<=n; i++) g[i].clear();
  10. 最短路初始化memset(dis, 0x3f, sizeof(dis));
  11. Floyd算法:三层循环顺序必须是 k, i, j (中间点k在最外层)。
  12. Floyd核心f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
  13. Dijkstra(朴素):$O(N^2)$,适用于稠密图,不用堆。
  14. Dijkstra(堆优化):必须用 priority_queue<PII, vector<PII>, greater<PII>> (小根堆)。
  15. Pair定义typedef pair<int, int> PII; (first存距离,second存点编号)。
  16. Dijkstra出堆判断if (dis[u] < d) continue; (懒惰删除,处理重复入堆的旧数据)。
  17. SPFA/Bellman:判断负环(入队次数 > n 或 松弛次数 > n)。
  18. 拓扑排序:维护 in_degree[] 数组。
  19. 拓扑步骤:将所有 degree==0 入队 -> 循环 -> 删边(degree–) -> 若为0入队。
  20. 树的直径:两次DFS(先找最远点A,再从A找最远点B)。
  21. 并查集初始化for(int i=1; i<=n; i++) fa[i] = i;
  22. 并查集Find(路径压缩)
    cpp int find(int x) { return x == fa[x] ? x : fa[x] = find(fa[x]); }
  23. 并查集Mergefa[find(x)] = find(y);
  24. 最小生成树(Kruskal):按边权排序 + 并查集。
  25. 二分图判定:染色法 (DFS/BFS),相邻节点颜色不同。
  26. 网格图方向数组int dx[] = {0, 0, 1, -1}; int dy[] = {1, -1, 0, 0};
  27. 网格图边界检查if (nx >= 1 && nx <= n && ny >= 1 && ny <= m)
  28. 链式前向星头数组head 数组初始化为 -1。
  29. LCA(倍增法)f[u][i] = f[f[u][i-1]][i-1]
  30. 树状数组(Lowbit)int lowbit(int x) { return x & -x; }
  31. 树状数组(Update)for(; i<=n; i+=lowbit(i)) c[i] += k;
  32. 树状数组(Query)for(; i>0; i-=lowbit(i)) res += c[i];
  33. 线段树大小:数组要开 4N (tree[4 * N])。
  34. 线段树下标:左儿 2*p (或 p<<1),右儿 2*p+1 (或 p<<1|1)。

第九章:数学与数论 (Math & Number Theory)

  1. 快速幂res = 1; while(b) { if(b&1) res=res*a%mod; a=a*a%mod; b>>=1; }
  2. 取模防负(a - b % mod + mod) % mod
  3. 最大公约数(GCD)__gcd(a, b) (注意a,b不能同时为0,否则可能RE,但在算法题少见)。
  4. 最小公倍数(LCM)(a / gcd(a, b)) * b (先除后乘防溢出)。
  5. 判断素数for(int i=2; i*i<=n; i++)
  6. 埃氏筛for(i=2...n) if(!vis[i]) for(j=i*i...n, j+=i) vis[j]=1; (注意 j 从 i*i 开始)。
  7. 逆元(费马小定理):当 mod 是质数,inv(a) = pow(a, mod-2)
  8. 组合数公式:$C(n, m) = \frac{n!}{m!(n-m)!}$。
  9. 递推组合数C[i][j] = (C[i-1][j] + C[i-1][j-1]) % mod;
  10. 杨辉三角C[n][k] 对应杨辉三角第 n 行第 k 个 (0-indexed)。
  11. 卡特兰数:1, 1, 2, 5, 14, 42… (括号匹配、出栈次序、二叉树计数)。
  12. 唯一分解定理:$N = p_1^{a_1} p_2^{a_2}…$。
  13. 约数个数:$(a_1+1)(a_2+1)…$。
  14. ceil(向上取整)(a + b - 1) / b (仅限正整数)。
  15. atan2atan2(y, x) 返回弧度 $(-\pi, \pi]$,算出极角。
  16. PIconst double PI = acos(-1.0);
  17. 两点距离hypot(x1-x2, y1-y2) (返回double)。
  18. 叉积(Cross Product):$x_1 y_2 - x_2 y_1$ (判断向量旋转关系,>0逆时针,<0顺时针)。
  19. 点积(Dot Product):$x_1 x_2 + y_1 y_2$。
  20. 曼哈顿距离abs(x1-x2) + abs(y1-y2)
  21. 切比雪夫距离max(abs(x1-x2), abs(y1-y2))
  22. 等差数列求和n * (a1 + an) / 2
  23. 平方和公式n(n+1)(2n+1)/6
  24. 异或性质a ^ a = 0, a ^ 0 = a
  25. 异或交换a ^= b; b ^= a; a ^= b; (不推荐,容易出bug,直接用 swap)。
  26. 判断奇偶if (x & 1) (真为奇)。
  27. 除以2x >> 1 (正数向下取整,负数可能有区别,建议直接 /2)。
  28. 乘以2x << 1
  29. 第k位是否为1if ((x >> k) & 1)
  30. 将第k位置1x | (1 << k)
  31. 将第k位置0x & ~(1 << k)
  32. 前缀和sum[i] = sum[i-1] + a[i]; (下标从1开始)。
  33. 区间和sum[R] - sum[L-1]
  34. 差分数组diff[i] += c; diff[j+1] -= c; (区间[i, j]加c)。
  35. 二维前缀和s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]
  36. 二维子矩阵和s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1]
  37. 容斥原理:$|A \cup B| = |A| + |B| - |A \cap B|$。
  38. 抽屉原理:$n+1$ 个物品放入 $n$ 个抽屉,至少有一个抽屉有2个物品。
  39. 快速乘(防溢出)__int128 (非标准但GCC支持) 或龟速乘逻辑。
  40. 同余a == b (mod m) 意味着 (a - b) % m == 0
  41. 斐波那契1, 1, 2, 3, 5, 8, 13, 21... (增长很快,第46项超过int)。
  42. 三角形不等式a + b > c (两条短边之和大于第三边)。
  43. 海伦公式:$S = \sqrt{p(p-a)(p-b)(p-c)}$,其中 $p = (a+b+c)/2$。
  44. 直线方程:$Ax + By + C = 0$。
  45. 浮点数比较const double EPS = 1e-8;
  46. 浮点零if (abs(x) < EPS)
  47. 浮点相等if (abs(a - b) < EPS)
  48. 随机数种子srand(time(0)) (老式) 或 mt19937 rng(time(0)) (C++11推荐)。
  49. 生成范围随机数rand() % (b - a + 1) + a (生成 [a, b])。
  50. 整除分块for(int l=1, r; l<=n; l=r+1) { r=n/(n/l); ... }

第十章:动态规划 (Dynamic Programming)

  1. 01背包(一维)for(i=1...n) for(j=V...w[i]) dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
  2. 01背包关键:内层循环 从大到小 (倒序)。
  3. 完全背包(一维):内层循环 从小到大 (正序)。
  4. 最长上升子序列(LIS)dp[i] 表示以 i 结尾的 LIS 长度。O(N^2)。
  5. LIS (贪心+二分):维护一个 tails 数组,lower_bound 替换,push_back 扩展,O(NlogN)。
  6. 最长公共子序列(LCS)if(a[i]==b[j]) dp[i][j]=dp[i-1][j-1]+1; else dp[i][j]=max(dp[i-1][j], dp[i][j-1]);
  7. 区间DP枚举顺序:先枚举区间长度 len,再枚举左端点 i,算出右端点 j,最后枚举断点 k
  8. 区间DP模板
    cpp for(int len=2; len<=n; len++) for(int i=1; i+len-1<=n; i++) { int j = i+len-1; for(int k=i; k<j; k++) dp[i][j] = ... }
  9. 状压DPdp[1 << N],用位掩码表示集合。
  10. 判断状态j包含iif ((state >> i) & 1)
  11. 树形DP:通常在 DFS 的回溯阶段进行状态转移。
  12. 最大子段和dp[i] = max(nums[i], dp[i-1] + nums[i]); (贪心法:sum = max(0, sum) + x)。
  13. 数字三角形:从下往上推 dp[i][j] += max(dp[i+1][j], dp[i+1][j+1]) 不需要边界检查。
  14. 记忆化搜索
    cpp int solve(int x) { if (memo[x] != -1) return memo[x]; // ... calculation ... return memo[x] = ans; }
  15. 初始化DP数组:求最大值初始化为 0 或 -INF,求最小值初始化为 INF。

第十一章:字符串进阶与调试 (String & Debug)

  1. stringstreamstringstream ss(str); while(ss >> word) { ... } (按空格分割字符串)。
  2. ss清空ss.clear(); ss.str(""); (重复利用时必须这样)。
  3. 字符判断isdigit(c), isalpha(c), islower(c), isupper(c)
  4. 大小写转换tolower(c), toupper(c)
  5. string::npos:通常值为 -1 (但它是 unsigned 类型),最好直接用 string::npos 常量比较。
  6. 字符串字典序"apple" < "banana" 为 true。
  7. 字符串加法s = s + 'a'; (O(N) 较慢)。
  8. 字符串追加s += 'a'; (O(1) 均摊,推荐)。
  9. KMP Next数组j=next[j] 是回跳逻辑。
  10. Manacher算法:处理回文串,记得先要在字符间插入 # 处理奇偶长度。
  11. Debug输出cerr << "Value: " << x << endl; (cerr 不会被输出重定向捕获,适合本地调试,提交时最好注释掉,虽不影响AC但慢)。
  12. 断言assert(x >= 0); (如果条件为假,程序终止报错,用于查逻辑漏洞)。
  13. 本地文件输入
    cpp #ifndef ONLINE_JUDGE freopen("in.txt", "r", stdin); freopen("out.txt", "w", stdout); #endif
  14. 观察数据范围N <= 10 -> O(N!) (全排列)。
  15. 观察数据范围N <= 20 -> O(2^N) (状压/DFS)。
  16. 观察数据范围N <= 100 -> O(N^3) (Floyd/区间DP)。
  17. 观察数据范围N <= 1000 -> O(N^2)。
  18. 观察数据范围N <= 10^5 -> O(NlogN) (Sort/Set/二分)。
  19. 观察数据范围N <= 10^7 -> O(N) (线性筛/双指针)。
  20. 对拍脚本:写个 brute_force.cpp, solution.cpp, generator.cpp 跑循环比对。
  21. 段错误(SIGSEGV):数组越界、指针乱指、爆栈。
  22. 浮点错误(SIGFPE):除以0、模0。
  23. 超时(SIGXCPU/TLE):死循环、算法复杂度过高。
  24. 输出超限(OLE):输出大量调试信息忘删,或死循环输出。
  25. 编译错误(CE):C++标准选错、头文件缺失、语法拼写错误。

第十二章:STL 高级技巧与补充 (STL Advanced)

  1. bitset 定义bitset<1000> b; (1000个位,不是字节)。
  2. bitset 操作b.set(i) (置1), b.reset(i) (置0), b.flip(i) (取反)。
  3. bitset 计数b.count() (1的个数)。
  4. bitset 转换b.to_ulong() (转unsigned long)。
  5. pair 排序:默认先比 first,再比 second。
  6. tupletuple<int, int, double> t; (三元组)。
  7. tuple 访问get<0>(t), get<1>(t)
  8. tie 解包int a, b; tie(a, b) = make_pair(1, 2);
  9. auto 推导auto it = mp.begin(); (省去长类型名)。
  10. 范围Forfor(auto &x : v) x++; (加 & 可以修改原数组值)。
  11. multiset 删除st.erase(val) 会删除所有等于 val 的元素。
  12. multiset 删除单个st.erase(st.find(val)) 只删一个。
  13. deque:双端队列,常数比 vector 大,但支持头插 push_front
  14. list:双向链表 (STL),极少用,不支持随机访问 []
  15. forward_list:单向链表。
  16. unordered_map:哈希表,平均 O(1),最坏 O(N) (会被精心构造的数据卡)。
  17. unordered_map 坑:不能直接用 pairvector 做 key (需自定义哈希)。
  18. map vs unordered_map:校赛求稳建议用 map (红黑树 O(logN)),除非必然超时。
  19. resize vs reserveresize 改变大小并初始化;reserve 只预留内存,不改变 size (减少重分配次数,优化常数)。
  20. lambda 捕获[&](int a){...} 引用捕获外部变量 (所有)。
  21. lambda 捕获[=](int a){...} 值捕获外部变量 (拷贝,不可改)。
  22. rotate 函数rotate(beg, new_beg, end)new_beg 旋转到 beg 位置。
  23. nth_elementnth_element(a, a+k, a+n) 将第 k 小的数放到 a[k],且左边都比它小,右边都比它大 (O(N))。
  24. prev/nextprev(it) (前一个迭代器), next(it) (后一个)。
  25. distancedistance(it1, it2) 计算两个迭代器距离 (O(N) 或 O(1))。
  26. accumulateaccumulate(v.begin(), v.end(), 0) 求和 (注意初始值类型,决定结果类型,0为int,0LL为long long)。
  27. iotaiota(v.begin(), v.end(), 1) 生成 1, 2, 3… 序列。
  28. min_element/max_element:返回的是迭代器,需 * 解引用或减 begin() 得下标。
  29. is_sorted:判断是否序列已排序。
  30. functionfunction<int(int, int)> func; (存储函数)。

第十三章:其他杂项与心态

AI建议:

  1. 万能头副作用:编译时间稍长,命名冲突风险 (如 y1)。
  2. 命名冲突:尽量不要用全局变量 left, right, count, hash (C++11后 risk 增加)。
  3. 递归层数:默认大概支持 4w-10w 层,超深需手工扩栈。
  4. 扩栈指令#pragma comment(linker, "/STACK:102400000,102400000") (Visual C++, G++通常不支持)。
  5. 位域struct { int x:1; } (一般不用,除非压内存)。
  6. 大端小端:网络流/二进制文件处理涉及,普通算法题不涉及。
  7. 快读模板
    cpp inline int read() { int x=0,f=1;char ch=getchar(); while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();} while(ch>='0'&&ch<='9'){x=x*10+ch-'0';ch=getchar();} return x*f; }
  8. 快写模板:递归 %10 输出字符 putchar
  9. Codeforces/AtCoder:通常开 O2 优化,STL 极快。
  10. 国内OJ:POJ 等老OJ 可能不支持 C++11,注意 unorderedauto 不能用。
  11. long double:精度比 double 高,但慢。
  12. 比较函数 strict weak orderingcmp(a,b) 必须保证 cmp(a,a) 为 false。
  13. 结构体构造列表Node(int x): x(x) {}Node(int x) { this->x = x; } 略快。
  14. 引用传参void dfs(vector<int> &v) (一定要加&,否则每次递归拷贝数组,直接TLE)。
  15. 常量引用const vector<int> &v (只读不改,安全且快)。
  16. 内联函数inline (建议编译器展开,减少函数调用开销)。
  17. registerregister int i (C++17已弃用,现代编译器会自动优化,不用写)。
  18. 循环展开:手动写 4 次操作代替循环,现代编译器通常能自动做。
  19. 打表:对于很难推导的题,本地跑出前 100 个结果存数组提交。
  20. 模拟退火:玄学算法,用于求最优解,非初赛重点。
  21. 对顶堆:维护动态中位数 (一个大根堆,一个小根堆)。
  22. 单调队列:滑动窗口最大值 (deque 维护下标)。
  23. 单调栈:找左右第一个比自己大的数。
  24. 差分约束:转化为最短路/最长路问题。
  25. 背包空间优化:滚动数组,从 dp[i][j] 变成 dp[j]
  26. 位运算优先级== 优先级高于 &
  27. 逻辑短路if (x >= 0 && a[x] == 1) (如果 x<0,后面不会执行,安全)。
  28. 空指针nullptr (C++11) 优于 NULL
  29. 复数类complex<double> (自带加减乘除,计算几何可用)。
  30. 1e9+7:质数。
  31. 998244353:质数 (NTT常用)。
  32. 19260817:也是个质数 (哈希常用)。
  33. memset 0x7f0x7f7f7f7f (比 0x3f 大,约为 2e9,两个相加会溢出 int)。建议用 0x3f
  34. ios::binary:文件读写模式。
  35. cin.peek():看一眼下一个字符但不取走。
  36. cin.putback(c):把字符放回流。
  37. exit(0):强制结束程序 (但在递归中慎用,可能析构函数不执行)。
  38. return 0:main 函数必须返回 0,否则 RE。
  39. 代码规范:缩进对齐,大括号换行风格统一,方便查错。
  40. 心态:卡题超过 20 分钟换题。
  41. 心态:有人 AC 了不要慌,可能是水题,跟榜做。
  42. 心态:罚时很贵,提交前自己测一下 edge cases (0, 1, n, max_n, -1)。
  43. 读题:注意时间限制 (1s通常算 $10^8$ 次运算)。
  44. 读题:注意空间限制 (256MB 很大,但 32MB 就要小心开大数组)。
  45. 读题:再次确认是“子串(substring)”还是“子序列(subsequence)”。
  46. 读题:再次确认是有向图还是无向图。

「工具用好了,效率翻倍」——本文整理了日常开发中极为实用但常被忽视的 Git 高级用法。

为什么应该用 rebase 而不是 merge?

merge 会产生一个额外的合并提交,让历史图变得杂乱;rebase 将提交”变基”到目标分支顶端,保持线性历史。

# 在 feature 分支上,将其变基到 main 最新提交
git checkout feature/my-feature
git rebase main

# 交互式变基:合并/修改/重排最近 5 次提交
git rebase -i HEAD~5

交互式变基命令速查:

命令 含义
pick 保留提交
squash / s 合并到上一个提交
reword / r 修改提交信息
drop / d 删除提交
fixup / f 合并但丢弃提交信息

git worktree:同时检出多个分支

当你需要同时查看/修改两个分支时,无需 stash 或克隆多份仓库:

# 在 ../hotfix 目录检出 hotfix/v2.1 分支
git worktree add ../hotfix hotfix/v2.1

# 查看所有 worktree
git worktree list

# 完成后删除
git worktree remove ../hotfix

常用别名配置(放入 ~/.gitconfig)

[alias]
    lg = log --oneline --graph --decorate --all
    st = status -sb
    unstage = reset HEAD --
    undo = reset --soft HEAD~1
    save = stash push -m

下一篇预告

  • Docker 多阶段构建:把镜像体积从 1GB 压到 50MB
  • Vim 配置:打造适合系统开发的终端 IDE

欢迎来到 Kevin’s Tech Blog

这是用 Hexo 搭建并托管于 GitHub Pages 的技术博客。

博客定位

  • 底层架构与高性能计算:C/C++、并发编程、OS 内核、性能优化
  • 机器学习与科研探索:论文研读、框架源码分析、AI 数学基础
  • 实战项目复盘:大作业重构、科研项目记录、竞赛经验
  • 计算机科学沉淀:工具流技巧、英文论文技巧、技术随笔

常用命令

# 本地预览
pnpm server

# 生成静态文件
pnpm build

# 部署到 GitHub Pages
pnpm deploy

纸上得来终觉浅,绝知此事要躬行。——陆游

More info: Deployment

0%