1. 多线程
概念:多线程是指在同一进程内并发执行多个线程,每个线程共享进程的内存空间和资源,但有独立的栈。
优点:
提高 CPU 利用率(特别是 I/O 密集型任务)。
能在一个进程内并行处理多个任务。
缺点:
编程复杂(需要处理同步、死锁、竞态条件)。
上下文切换有开销。
常用同步方式:互斥锁(std::mutex)、读写锁、条件变量、原子操作(std::atomic)。
C++ 支持:
C++11 引入 std::thread、std::mutex、 std::condition_variable、std::future 等。
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
mutex mtx; // 全局互斥锁
void printTask(int id) {
lock_guard<mutex> lock(mtx); // 自动加锁/解锁
cout << "Thread " << id << " is running\n";
}
int main() {
thread t1(printTask, 1);
thread t2(printTask, 2);
t1.join();
t2.join();
return 0;
}
2. STL 有什么?
STL(Standard Template Library)主要包括六大组件:
1. 容器:vector、list、deque、set、map、unordered_map 等。
2. 算法:sort、find、for_each、binary_search 等。
3. 迭代器:连接算法与容器的“胶水”,如 begin()、end()。
4. 函数对象(仿函数):可调用对象,如 greater<int>。
5. 适配器:对容器、迭代器或函数对象进行改造,例如 stack(基于 deque)、priority_queue、function。
6. 分配器:内存分配与回收机制,默认 std::allocator。
3. vector 扩充方式,size 与 capacity 区别
扩充方式:
当 size == capacity 时,再插入元素会触发扩容。
C++ 标准没规定倍数,但多数实现是 按 2 倍增长(如 GCC 的 libstdc++)。
扩容会重新分配更大内存,并拷贝(或移动)旧元素过去。
区别:
size():当前已存储的元素个数。
capacity():已分配内存可容纳的最大元素数(不必重新分配)。
reserve(n):预分配至少 n 的容量(减少扩容次数)。
4. 顺序存储结构有哪些?
顺序存储结构是用一段连续的内存存储数据元素的结构:
线性结构:
顺序表(如 std::vector、数组)。
栈(顺序栈)。
队列(顺序队列)。
非线性结构(少见,但可以顺序存储):
完全二叉树(用数组存储,如堆)。
邻接矩阵(存储图)。
哈希表(开地址法)。
5. 左值引用与右值引用
左值引用(T&):
绑定到有名字的对象,可读可写。
用于传递大对象避免拷贝。
例:
int x = 10;
int& ref = x; // 左值引用
右值引用(T&&):
绑定到临时对象(右值),允许对临时对象修改。
常用于 移动语义(减少拷贝)和 完美转发。
例:
int&& rref = 10; // 绑定临时值
std::vector<int> v1, v2;
v1 = std::move(v2); // 移动语义
本质:
左值:有持久地址的对象。
右值:表达式求值结果,不再被使用的临时值。
std::move() 并不移动,只是把左值强制转为右值引用。
6. 虚函数与纯虚函数
1. 虚函数 (Virtual Function)
定义:有具体实现的函数
语法:virtual void func() { /* 实现 */ }
特点:
基类可以提供默认实现
派生类可以选择重载或不重载
基类可以实例化
2. 纯虚函数 (Pure Virtual Function)
定义:没有实现的抽象函数
语法:virtual void func() = 0;
特点:
基类不提供实现
派生类必须重载实现
基类不能实例化(抽象类)
#include <iostream>
using namespace std;
// 抽象基类 - 包含纯虚函数
class Shape {
public:
virtual double getArea() = 0; // 纯虚函数
virtual void draw() = 0; // 纯虚函数
virtual void printInfo() { // 虚函数(有默认实现)
cout << "这是一个图形\n";
}
};
// 派生类1
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double getArea() override {
return 3.14159 * radius * radius;
}
void draw() override {
cout << "绘制圆形,半径: " << radius << endl;
}
void printInfo() override { // 可以选择重载虚函数
cout << "这是一个圆形,半径: " << radius << endl;
}
};
// 派生类2
class Rectangle : public Shape {
private:
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double getArea() override {
return width * height;
}
void draw() override {
cout << "绘制矩形,宽: " << width << ",高: " << height << endl;
}
// 不重载printInfo(),使用基类的默认实现
};
int main() {
// Shape s; // 错误!抽象类不能实例化
Circle c(5);
Rectangle r(4, 6);
// 多态调用
Shape* shapes[] = {&c, &r};// 基类指针数组,存储派生类对象的地址
for (Shape* shape : shapes) { // 根据实际对象类型调用相应的方法
shape->printInfo(); // 虚函数调用
shape->draw(); // 纯虚函数调用
cout << "面积: " << shape->getArea() << endl; // 纯虚函数调用
cout << "---" << endl;
}
return 0;
}
7. 手撕memcpy 、memset
#include <stddef.h>
void *my_memcpy(void *dst, const void *src, size_t n) {
// 注意:src/dst 重叠时行为未定义,应该用 memmove
unsigned char *d = (unsigned char *)dst;
const unsigned char *s = (const unsigned char *)src;
while (n--) *d++ = *s++;
return dst;
}
void *my_memset(void *s, int c, size_t n) {
unsigned char *p = (unsigned char *)s;
unsigned char v = (unsigned char)c;
while (n--) *p++ = v;
return s;
}
++ 的优先级高于*,所以*p++ = *(p++)
8. 一行代码求平方根
double r = exp(0.5 * log(x)); // 需 #include <math.h>
double r = std::pow(x, 0.5); // 需 <cmath>
9. 排序方法对比
排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
快速排序 | O(n log n) | O(n²) | O(n log n) | O(log n)(递归栈) | ❌ 不稳定 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(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(n + k) | ✅ 稳定(若桶内用稳定算法) |
桶排序依赖输入分布,k 是桶数(范围大小),适合数据范围有限 & 分布均匀的情况。
1️⃣ 快速排序 (QuickSort)
void quicksort(vector<int>& a, int l, int r) {
if (l >= r) return;
int i = l, j = r, pivot = a[l]; // 取左端点为枢轴
while (i < j) {
while (i < j && a[j] >= pivot) j--;
if (i < j) a[i++] = a[j];
while (i < j && a[i] <= pivot) i++;
if (i < j) a[j--] = a[i];
}
a[i] = pivot;
quicksort(a, l, i - 1);
quicksort(a, i + 1, r);
}
把小的放左边,大的放右边,再递归排序两边
2️⃣ 归并排序 (MergeSort)
void merge(vector<int>& a, int l, int mid, int r) {
vector<int> tmp(r - l + 1);
int i = l, j = mid + 1, k = 0;
while (i <= mid && j <= r) {
tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
}
while (i <= mid) tmp[k++] = a[i++];
while (j <= r) tmp[k++] = a[j++];
for (int t = 0; t < tmp.size(); t++) a[l + t] = tmp[t];
}
void mergesort(vector<int>& a, int l, int r) {
if (l >= r) return;
int mid = (l + r) / 2;
mergesort(a, l, mid);
mergesort(a, mid + 1, r);
merge(a, l, mid, r);
}
👉 分而治之:把数组分成两半,分别排序,再合并两个有序数组。
原理:
递归拆分数组,直到区间只剩一个元素(天然有序)。
合并两个有序子数组成一个有序数组(像“拉拉链”一样左右比较)。
特点:
稳定(相等元素相对顺序不变)。
时间复杂度稳定 O(n log n)。
缺点是需要 O(n) 的辅助数组空间。
3️⃣ 堆排序 (HeapSort)
void heapify(vector<int>& a, int n, int i) {
int largest = i, l = 2*i+1, r = 2*i+2;
if (l < n && a[l] > a[largest]) largest = l;
if (r < n && a[r] > a[largest]) largest = r;
if (largest != i) {
swap(a[i], a[largest]);
heapify(a, n, largest);
}
}
void heapsort(vector<int>& a) {
int n = a.size();
// 建堆
for (int i = n/2 - 1; i >= 0; i--) heapify(a, n, i);
// 取堆顶元素(最大值),放到数组末尾
for (int i = n-1; i > 0; i--) {
swap(a[0], a[i]);
heapify(a, i, 0);
}
}
👉 利用堆这种数据结构(最大堆/最小堆),不断取堆顶元素。
原理:
把数组“堆化”(建成一个大顶堆:每个父节点 ≥ 子节点)。
堆顶就是最大值,把它放到数组末尾(交换堆顶和末尾元素)。
把堆的大小减 1,再调整堆,继续取最大值。
重复,直到数组有序。
特点:
不稳定(堆调整时可能打乱相等元素顺序)。
时间复杂度稳定 O(n log n),不怕最坏情况。
空间复杂度 O(1),因为原地堆化。
4️⃣ 桶排序 (BucketSort)
适合范围较小(例如 0 ~ 10000)的整数。
这里给个最简版:
void bucketsort(vector<int>& a, int maxVal) {
vector<int> bucket(maxVal+1, 0);
for (int x : a) bucket[x]++;
int idx = 0;
for (int i = 0; i <= maxVal; i++) {
while (bucket[i]--) a[idx++] = i;
}
}
👉 把数据按范围分到不同“桶”里,再对每个桶内部排序,最后依次合并。
原理:
假设数的范围已知(比如 0~100)。
创建若干个桶,把元素分配到对应桶里。
桶内可以直接放链表(保持稳定),或者再用快排/插排。
最后按桶的顺序依次输出。
特点:
时间复杂度接近 O(n)(当数据分布均匀时)。
稳定(如果桶内用稳定排序)。
但需要额外空间,且不适合范围特别大但数据稀疏的场景。
(计数排序、基数排序其实都是“桶思想”的特例)
上面是“计数排序”版桶排。更一般的桶排序会用vector<vector> 来放桶,每个桶内部再排序。
⚡ 总结:
快排 平均最快,但最坏 O(n²);工程上常用“三数取中”或随机化 pivot 来避免退化。
归并排序 稳定,但需要 O(n) 额外空间;适合链表。
堆排序 空间最优 O(1),但常数比快排大,实际一般比快排慢。
桶排序 线性时间,但依赖数据分布;适合整数范围小的场景。
“快排快,归并稳,堆排省空间,桶排看分布”
⚡️ 直观对比口诀
快排:选枢轴,左右分治,小的左,大的右。
归并:拆到最小,合并有序,像“拉拉链”。
堆排:建大顶堆,堆顶最大,换到末尾。
桶排:按范围分桶,桶内再排,最后合并。
10. 二叉树排序、堆排序、希尔排序、桶排序时间复杂度
算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
二叉树排序(Binary Tree Sort) | O(n log n) | O(n log n) | O(n²)(退化成链表时) | O(n)(建树需要指针存储) | 取决于树的实现(普通二叉查找树不稳定,平衡树可稳定) |
堆排序(Heap Sort) | O(n log n) | O(n log n) | O(n log n) | O(1)(原地排序) | ❌ 不稳定 |
希尔排序(Shell Sort) | O(n log n) ~ O(n^1.3)(取决于增量序列) | O(n log² n) 或更优 | O(n²) | O(1) | ❌ 不稳定 |
桶排序(Bucket Sort) | O(n + k)(k = 桶数) | O(n + k) | O(n²)(数据分布极不均匀) | O(n + k) | ✅ 稳定(如果桶内用稳定排序) |
📌 核心解释
- 二叉树排序
把所有元素依次插入二叉查找树,再做一次 中序遍历 → 得到有序序列。
如果树平衡(如 AVL 树 / 红黑树),时间复杂度 O(n log n)。
如果树退化成链表(比如插入已经排好序的数据),时间复杂度 O(n²)。
- 堆排序
先建大顶堆(O(n)),再执行 n 次“交换堆顶 + 堆化”操作(O(log n))。
整体 O(n log n),稳定 O(1) 额外空间。
不稳定,因为堆化时可能会打乱相等元素的顺序。
- 希尔排序
希尔排序是插入排序的优化版:先按一定的 gap(步长)分组,再对分组做插入排序,逐步缩小 gap 直到 1。
时间复杂度依赖 gap 序列:
原始 Shell 增量:最坏 O(n²)
Hibbard 增量:O(n^1.5)
Sedgewick 增量:O(n^1.3)
空间复杂度 O(1),但稳定性不好。
- 桶排序
把数据分到若干个“桶”里,每个桶内部再排序,最后合并桶。
如果数据分布均匀,复杂度接近 O(n)。
如果所有数据都挤在一个桶 → 退化为 O(n²)。
桶排序依赖输入数据的分布和范围,因此不适合比较型排序的通用场景。
✅ 总结一句话:
二叉树排序:依赖树是否平衡
堆排序:稳定 O(n log n),但不稳定
希尔排序:插入排序的“加速版”,性能介于 O(n log² n) 和 O(n^1.3) 之间
桶排序:理论上最快 O(n),但依赖数据分布
11. 树的DFS与BFS、树的遍历
🌳 一、DFS 和 BFS 是什么?
- DFS(深度优先搜索,Depth First Search)
走到底再回头。
想象你在迷宫里走路:你总是往一个方向一直走,直到走不通,再回退,换另一条路。
在树里就是:先一路走到最深的子节点,再往上回来。
实现方式:递归 或者 用栈。
- BFS(广度优先搜索,Breadth First Search)
一层一层地走。
想象你在看一棵大树:先看根,然后看根的所有孩子,再看孩子的孩子……一圈一圈往外扩散。
在树里就是:从上到下、从左到右逐层访问。
实现方式:用队列。
🌳 二、树的常见遍历方式
树的遍历就是按照某种规则,把树的所有节点走一遍。
- 二叉树的 DFS 遍历(3 种方式)
对一个二叉树节点 root:
A
/
B C
/
D E
前序遍历 (Preorder):根 → 左 → 右
👉 顺序是:A → B → D → E → C
中序遍历 (Inorder):左 → 根 → 右
👉 顺序是:D → B → E → A → C
后序遍历 (Postorder):左 → 右 → 根
👉 顺序是:D → E → B → C → A
📌 可以记口诀:
前序:根在前
中序:根在中
后序:根在后
- BFS 遍历(二叉树的层序遍历)
就是「从上到下,一层一层访问」。
对于上面那棵树:
👉 A → B → C → D → E
实现方法:用队列(先进先出)。
#include <iostream>
#include <queue>
using namespace std;
struct TreeNode {
char val;
TreeNode *left, *right;
TreeNode(char x): val(x), left(NULL), right(NULL) {}
};
// 前序遍历 DFS
void preorder(TreeNode* root) {
if (!root) return;
cout << root->val << " ";
preorder(root->left);
preorder(root->right);
}
// 中序遍历 DFS
void inorder(TreeNode* root) {
if (!root) return;
inorder(root->left);
cout << root->val << " ";
inorder(root->right);
}
// 后序遍历 DFS
void postorder(TreeNode* root) {
if (!root) return;
postorder(root->left);
postorder(root->right);
cout << root->val << " ";
}
// 层序遍历 BFS
void levelorder(TreeNode* root) {
if (!root) return;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* node = q.front();
q.pop();
cout << node->val << " ";
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
12. 对于n个实例的k维数据,建立kd tree的时间复杂度
🌟 kd-tree 的构建过程
对于 n 个 k 维点:
选取划分维度
常见做法是每层选取一个维度(比如轮流选),或者选方差最大的维度。
这个操作复杂度 ≈ O(k)(维度数通常远小于 n,可以近似忽略不计)。
找分割点(中位数)
我们要把点集在这一维上划分成两半,选中位数作为根节点。
如果用排序来找中位数,每层都要 O(n log n),总复杂度 = O(n log^2 n)。
如果用「线性时间选择算法」(比如 quickselect),每层 O(n) 就够了,总复杂度可以降到 O(n log n)。
递归建树
左子树处理一半点,右子树处理另一半点。
总层数 ≈ log n(因为每次一分为二)。
🌟 最终时间复杂度
一般实现(每次排序):O(n log^2 n)
优化实现(快速选择中位数):O(n log n)
维度 k:通常被看作常数,对复杂度影响不大,但严格来说要乘个 k:
普通实现:O(k n log^2 n)
优化实现:O(k n log n)
🌟 一句话总结
构建 kd-tree 的时间复杂度 ≈ O(n log n)(采用快速选择中位数)。
如果偷懒直接排序,那就是 O(n log^2 n)。
🌟 建树过程
Step 1:根节点(按 x 坐标划分)
点数 = 7
按 x 排序:(1,5), (2,3), (4,7), (5,4), (7,2), (8,1), (9,6)
中位数是第 4 个点:(5,4)
根节点 = (5,4)
左子集 = [(1,5), (2,3), (4,7)]
右子集 = [(7,2), (8,1), (9,6)]
Step 2:左子树(按 y 坐标划分)
子集:(1,5), (2,3), (4,7)
按 y 排序:(2,3), (1,5), (4,7)
中位数 = (1,5)
左子树根 = (1,5)
左子集 = [(2,3)]
右子集 = [(4,7)]
Step 3:右子树(按 y 坐标划分)
子集:(7,2), (8,1), (9,6)
按 y 排序:(8,1), (7,2), (9,6)
中位数 = (7,2)
右子树根 = (7,2)
左子集 = [(8,1)]
右子集 = [(9,6)]
Step 4:继续递归(按 x 坐标划分)
(2,3)、(4,7)、(8,1)、(9,6) 都只剩 1 个点,不再划分。
🌟 最终 kd-tree 结构
(5,4) ← 根(x 分割)
/ \
(1,5) (7,2) ← 第二层(y 分割)
/ \ / \
(2,3) (4,7) (8,1) (9,6)
13. 三维空间最近邻搜索的常用数据结构
🌟 1. kd-tree (k-dimension tree)
原理:递归地用坐标轴方向切分空间(3D 就是 x/y/z 轮流切分),形成一棵二叉树。
优点:
构建复杂度 O(n log n);
查询平均 O(log n),比暴力 O(n) 快很多。
缺点:
维度升高时退化(curse of dimensionality),但在 3D 表现很好;
动态更新(频繁插入/删除)成本高。
应用:点云匹配(ICP)、最近邻查找、三维重建等。
常见实现:PCL(Point Cloud Library)的 KdTreeFLANN。
🌟 2. Octree (八叉树)
原理:递归划分立方体空间,每个节点划分成 8 个子立方体。
优点:
适合 3D 点云稀疏存储,空间分割直观;
支持近邻搜索和范围查询。
缺点:
不如 kd-tree 在“精确最近邻”上高效,适合 近似搜索 和 范围查询。
应用:3D 地图(OctoMap)、体素滤波、点云压缩。
🌟 3. BVH (Bounding Volume Hierarchy,包围体层次)
原理:用包围盒(AABB、OBB、球体等)自顶向下递归包裹点/几何体,形成树结构。
优点:
在碰撞检测、光线追踪等应用中比 kd-tree 常用;
查询效率也接近 O(log n)。
缺点:
构建复杂度高;
主要针对几何体,而不是大规模点云。
🌟 4. R-tree / R*-tree
原理:用矩形(3D 就是长方体)作为节点的边界,适合做区间/范围查询。
优点:
适合数据库、GIS(地理信息系统),支持批量插入/删除;
动态更新比 kd-tree 好。
缺点:
精确最近邻性能比 kd-tree 略差。
🌟 5. 近似最近邻(ANN)结构
如果数据非常大,或者允许近似搜索,可以用 哈希/投影类算法:
LSH(Locality Sensitive Hashing):用哈希把相近的点映射到同一桶。
FLANN(Fast Library for Approximate Nearest Neighbors):自动选择 kd-tree / k-means tree 混合结构。
FAISS(Facebook AI):超大规模向量检索库。
👉 这些在 3D 点云搜索中用得不多,但在机器学习里很常见。
🌟 总结
在 三维空间最近邻搜索里:
精确搜索 → kd-tree 最常用;
近似搜索、大规模点云 → Octree / FLANN;
碰撞检测 / 光线追踪 → BVH;
数据库 / GIS → R-tree。
14. 一层楼共有n级台阶,一次可以上至少一级但不超过m级台阶,求有多少种不同的上楼方案数。由于结果可能很大,你只需要输出结果对10007取模的值即可
思路:设走到第k阶台阶的走法数为f(k),如果你现在在第 n 级台阶,可能是从第 n−1、n−2、…、n−m 过来的:
所以f(n) = f(n-1)+f(n-2)+…+f(n-m) ,它需要「最近的 m 个 f 值」的和
设计这个窗口就在于,把新得到的 f(n) 加进窗口,把最旧的 f(n−m) 移出窗口,计算窗口内m个f值的和,我们可以用循环数组或者双端队列实现
循环数组的方法 :
1.新手方法,最朴素的递归
MOD = 10007
def ways_recursive(n, m):
"""最直观的递归解法"""
if n == 0:
return 1 # 到达台阶顶
if n < 0:
return 0 # 走过头,不算
total = 0
for k in range(1, m+1): # 尝试走 1~m 步
total += ways_recursive(n-k, m)
return total % MOD
✅ 优点
代码最直观,完全符合题意定义。
容易理解,适合入门。
❌ 缺点
计算会大量重复,时间复杂度接近 指数级。
n 稍微大一点(比如 30),就已经非常慢。
比如计算 f(5) 需要算 f(4) 和 f(3),而计算 f(4) 又需要算 f(3) 和 f(2)。f(3) 被计算了多次。
进阶版:
MOD = 10007
def ways_dp(n, m):
"""用 DP 表格存储所有 f(0..n)"""
f = [0] * (n + 1)
f[0] = 1 # 初始状态
for i in range(1, n+1):
for k in range(1, m+1):
if i - k >= 0:
f[i] = (f[i] + f[i-k]) % MOD
return f[n]
递归是“自顶向下”:我们从目标 n 开始,问“要走到 n,需要先走到哪里?”,然后不断地问下去,直到问到已知的 f(0)=1。
DP是“自底向上”:我们从已知的 f(0)=1 开始,一步一步、从小到大地计算出所有台阶的方法数,直到算出我们想要的 f(n)。
k 的循环 (内层循环):解决的是 “从哪来” 的问题。对于当前台阶 i,它可能的前序台阶是 i-1, i-2, …, i-m。这个循环就是在把这些来源的方案数加起来。
i 的循环 (外层循环):解决的是 “计算顺序” 的问题。它保证了当我们计算 f[i] 时,所有需要的 f[i-1], f[i-2], … 都已经提前被计算好并存储在表格里了,我们可以直接查询使用,避免了递归的重复计算。
MOD = 10007
def ways_dp(n, m):
"""用 DP 表格存储所有 f(0..n)"""
f = [0] * (n + 1)
f[0] = 1 # 初始状态
for i in range(1, n+1):
for k in range(1, m+1):
if i - k >= 0:
f[i] = (f[i] + f[i-k]) % MOD
return f[n]
我们保存了整个数组 f[0…n],所以内存是 O(n)。
但实际上,当你算到 f(i) 时,只会用到最近 m 个状态(即 f[i-1], f[i-2], …, f[i-m])。
更远的 f(j) (比如 f[i-m-1])再也不会被访问。
👉 所以保存整个 f[0…n] 是 冗余的。
🚩 第一步优化 —— 只存最近 m 个值(循环数组)
思路:
我只要一块大小为 m+1 的循环数组 dp。
当 i 增加时,我把新算出来的 f[i] 存进去,同时覆盖掉「最老的那个已经没用了的值」。
这样空间复杂度从 O(n) → O(m)。
🚩 第二步优化 —— 再优化掉内层循环(滑动窗口)
问题:虽然只存了 m 个数,但每次算 f[i] 时还是要 for 循环加 m 项,时间复杂度还是 O(n·m)。
解决方法:
我们注意到 f[i] = f[i-1] + f[i-2] + … + f[i-m],其实就是一个 长度为 m 的滑动窗口和。
如果我们在算 f[i] 时维护一个变量 win 表示「最近 m 个 f 的和」,那么:
f[i] = win
下一步 f[i+1] = win + f[i] - f[i-m]
也就是说:
👉 我们不用每次都重新加 m 项,只要 加新数、减旧数 就行了。
这样时间复杂度从 O(n·m) → O(n)。
🚩设计细节 —— 如何“释放”旧的 f?
你提到的关键点就是:
计算 f[i] 之后,如何释放掉 f[i-m-1]?
在滑窗方案里:
用一个循环数组存最近 m 个值;
每次计算新的 f[i] 后:
把 f[i] 加入 win;
如果窗口长度超过 m,就从 win 中减去 f[i-m];
同时,dp[(i-m) % (m+1)] 的位置就可以安全覆盖掉了(因为不会再用到)。
🚩 思维进化总结
递归版:定义问题。
DP 版:避免重复计算 → O(n·m),但浪费内存。
循环数组:只保存最近 m 个 → O(m) 空间。
滑动窗口:维护窗口和 → O(n) 时间 + O(m) 空间。
3️⃣ 滑动窗口优化版
MOD = 10007
def ways_window(n, m):
"""滑动窗口优化 DP"""
if n == 0:
return 1
dp = [0] * (m+1) # 循环数组,只存最近 m 个
dp[0] = 1 # f(0) = 1
win = 1 # 窗口和,初始只有 f(0)
fn = 0 # 保存当前结果
for i in range(1, n+1):
fn = win % MOD # f(i) = 窗口和
dp[i % (m+1)] = fn # 存入循环数组
win = (win + fn) % MOD # 窗口 += 新的 f(i)
if i - m >= 0: # 超出 m 个时,减去最老的 f(i-m)
old = dp[(i - m) % (m+1)]
win = (win - old) % MOD
return fn % MOD
节省空间和时间。
目的:处理超大规模 n。
手段:只保存最近 m 个值,用窗口和快速更新。
你可以把 dp 想象成一个旋转餐盘:
一共 m+1 个盘子位置。
每次上新菜(f(i)),把它放到 (i % (m+1)) 的盘子上。
同时把盘子里最旧的菜(f(i-m))拿走。
因为餐盘有 m+1 个位置,转一圈后新菜才会覆盖到最老的那个盘子,所以不会误删。
“f(0)充当了一个占位” 完全正确,它既是数学上的基准条件,又是程序实现上的缓冲位,让整个滑动窗口逻辑既正确又优雅。
15. 拟合二维平面中的带噪声直线,其中有不超过20%的样本点远离了直线,另外80%的样本点可能有高斯噪声的偏移,要求输出为ax+by+c=0的形式,其中a>0且a2+b2=1a^2 + b^2=1a2+b2=1
可以把它当成“有离群点的总最小二乘直线拟合”问题来做:先用 RANSAC 抗 20% 离群点找出内点集合,再用 总最小二乘(TLS/PCA) 在内点上精修,最后把系数归一化并令 a>0。
下面给出一段可直接用的 Python 实现(仅依赖 numpy)。输出满足你要的形式 ax+by+c=0、a>0、a2+b2=1a^2 + b^2 = 1a2+b2=1
import numpy as np
def fit_line_robust(points, max_trials=200, seed=None):
"""
Robustly fit a 2D line ax + by + c = 0 with a>0 and a^2+b^2=1.
Assumes up to ~20% outliers; inliers may have Gaussian noise.
points: (N,2) array of [x,y].
Returns: a, b, c, inlier_mask (boolean array of length N)
"""
pts = np.asarray(points, dtype=float)
N = pts.shape[0]
if N < 2:
raise ValueError("Need at least 2 points.")
rng = np.random.default_rng(seed)
best_inliers = None
best_count = -1
best_model = None
for _ in range(max_trials):
# 1) 随机两点确定一条候选直线
i, j = rng.choice(N, size=2, replace=False)
p1, p2 = pts[i], pts[j]
v = p2 - p1
if np.allclose(v, 0):
continue
# 法向量 n = (dy, -dx),并单位化 -> (a,b)
n = np.array([v[1], -v[0]], dtype=float)
norm_n = np.linalg.norm(n)
if norm_n == 0:
continue
a, b = n / norm_n
# c 使直线过 p1
c = -(a * p1[0] + b * p1[1])
# 2) 计算所有点到直线的正交距离 |ax+by+c|
d = np.abs(a * pts[:, 0] + b * pts[:, 1] + c)
# 3) 自适应阈值:用 MAD 估计噪声尺度
md = np.median(d)
sigma = 1.4826 * md + 1e-12 # 防止零
thr = 2.5 * sigma # ~2.5σ 作为内点阈值
inliers = d < thr
cnt = int(inliers.sum())
if cnt > best_count:
best_count = cnt
best_inliers = inliers
best_model = (a, b, c)
if best_inliers is None or best_count < 2:
raise RuntimeError("Failed to find a valid consensus set.")
# 4) 在内点上做总最小二乘(PCA)精修
P = pts[best_inliers]
mu = P.mean(axis=0)
Q = P - mu
# PCA: 主方向是第一奇异向量
_, _, Vt = np.linalg.svd(Q, full_matrices=False)
direction = Vt[0] # 直线方向向量
normal = np.array([direction[1], -direction[0]], dtype=float)
normal /= np.linalg.norm(normal) # 单位法向量 -> (a,b)
a, b = normal
c = -(a * mu[0] + b * mu[1]) # 使直线过内点均值
# 5) 规范化:a>0,且已经保证 a^2+b^2=1
if a < 0:
a, b, c = -a, -b, -c
return a, b, c, best_inliers
# ========= 使用示例 =========
if __name__ == "__main__":
# 生成一条 y = 0.5x + 1 的带噪数据,并加 20% 离群点
rng = np.random.default_rng(0)
x = np.linspace(-10, 10, 200)
y = 0.5 * x + 1 + rng.normal(0, 0.3, size=x.size) # 高斯噪声
P = np.c_[x, y]
# 添加离群点
M = 40
outliers = np.c_[rng.uniform(-10, 10, M), rng.uniform(-10, 10, M)]
data = np.vstack([P, outliers])
a, b, c, mask = fit_line_robust(data, max_trials=200, seed=42)
print(f"a={a:.6f}, b={b:.6f}, c={c:.6f}, a^2+b^2={a*a+b*b:.6f}, a>0={a>0}")
print(f"inliers: {mask.sum()}/{data.shape[0]}")
RANSAC
📌 背景:
在拟合模型时(比如直线、平面、单应矩阵等),数据往往包含:
- 内点 (inliers):基本遵循某个数学模型,只是有随机噪声。
- 外点 (outliers):完全不符合模型,可能是测量错误、遮挡、异常值。
普通的最小二乘法对外点很敏感:即便只有少量外点,也可能把拟合结果严重拉偏。
RANSAC就是为了解决“在含有外点的数据中鲁棒拟合模型”的问题。
📌 典型流程(以直线拟合为例)
随机采样 从所有点中 随机选出最小样本子集(比如拟合直线只需 2 点)。用这几个点拟合一个候选模型。
验证一致性 将所有数据点代入候选模型,计算它们与模型的“残差”或“误差”。(比如直线时用点到直线的垂直距离),设置一个阈值 T,若误差 < T,则认为该点是 内点。
统计一致集大小 统计候选模型对应的内点数(即一致集大小)。内点越多,说明该模型越可信。
重复迭代 重复步骤 1–3 若干次(迭代次数 N),不断更新“目前为止最佳模型”。选择最佳模型找到拥有最大内点数的一致集,认为这是最终的内点集合。
精修模型 用一致集(大多数是内点)做更精确的拟合(比如最小二乘/TLS)。得到最终鲁棒的模型。