一些算法笔记

This post is not yet available in English. Showing the original version.

May 23, 2025

Table of Contents
Table of Contents

一些算法笔记

记录一些做题中不太知道的,免得丢掉印象。

Enum class

enum class Gesture
{
    C, // 石头
    J, // 剪刀
    B  // 布
};

对于 enum class 结构,调用的时候得用 Gesture::C 这种格式。

Map - low_bound

map 里面有一个很好用的 low_bound 函数,在map中查找第一个不小于(也就是大于或等于)给定的 key 的元素。 根据这个函数,可以实现一个区间的映射建立

std::map<int, char> gradeMap = { 
 {59, 'F'}, // 0-59分 -> F
 {69, 'D'}, // 60-69分 -> D 
 {79, 'C'}, // 70-79分 -> C 
 {89, 'B'}, // 80-89分 -> B 
 {100, 'A'} // 90-100分 -> A
  };
  
  char getGradeFromMap(int score) {
    // 如果分数小于0或者大于100,直接返回无效
    if (score < 0 || score > 100) {
        return '?';
    }

    // 查找第一个不小于score的键值对
    auto it = gradeMap.lower_bound(score);

    // lower_bound 找到了一个合法的区间上限
    if (it != gradeMap.end()) {
        // it->first 是上限分数,it->second 是对应的等级
        return it->second;
    }

    // 如果没找到(比如分数大于100,虽然我们已经处理了),则为无效
    return '?';
}
  

Set

set 调用的时候得用 auto,迭代器。 用 insert 插入值,迭代器输出值,迭代器是指针,所以得 *it 用来解应用。 见 week 2 12拼写检查

set<ll>s;
    for(ll i=0;i<n;i++){
        ll x;
        cin>>x;
        s.insert(x);
    }
    ll count=0;
    for(auto it=s.begin();it!=s.end()&&count<k;it++){
        count++;
        cout<<*it<<" ";
    }

并行

先把“并行”的本质想清楚:一个任务最早能开工的时间 = 它所有前置任务的“完成时间”的最大值

所以你不需要显式“合并时间”,只要维护“最早开始/完成时间”,并在拓扑序里用 max 推进,并行的效果自然出现。 见 luogu P1113

#include<iostream>
#include<vector>
#include<algorithm>
#include<string>
using namespace std;
//依次读入每个杂务的工作说明。计算出所有杂务都被完成的最短时间。
// 互相没有关系的杂务可以同时工作
// 有些杂务必须在另一些杂务完成的情况下才能进行。
// 有需要完成的 n 个杂务的清单,并且这份清单是有一定顺序的,杂务 k (k>1) 的准备工作只可能在杂务 1 至 k−1 中。
// 一道拓扑排序题,找到所有入度为0的点,更新其邻接点的入度,直到所有点都被更新完,但是并行计算时间是一个难点……
// 有些任务不需要第二个任务完成,但是下一个任务需要第二个和第三个共同完成,且第二个任务完成时间比第三个短的话,特殊任务不能计入计算……
// 难点是找到并行时间最大值。

  

struct task
{
	int id;
	int time;
	vector<int>next;
	int inDegree=0;
};

int main(){
	int n;
	cin>>n;
	vector<task>tasks(n+1);
	for(int i=1;i<=n;i++){
		int id,t;	
		cin>>id>>t;	
		tasks[id].id=id;
		tasks[id].time=t;
		int k=-1;
		while(cin>>k,k!=0){
			tasks[k].next.push_back(id);
			tasks[id].inDegree++;	
		}

}

vector<int>start(n+1,0); // 每个任务的最早开工时间,我该做线性统计而非归并计算……
vector<int>end(n+1,0); // 完成每个任务的最早时间
for(int i=1;i<=n;i++){
	if(tasks[i].inDegree==0){
		end[i]=start[i]+tasks[i].time;
	for(int v:tasks[i].next){
		start[v]=max(start[v],end[i]);
		tasks[v].inDegree--;
	}
//当入度为0,可以和现有线性时间的标记终止时间做比较了……
	}
}

//最后终止的时间就是最短时间。把时间当作线性看待是关键。
cout<<*max_element(end.begin(),end.end())<<endl;
return 0;
}

Kahn 拓扑排序

🟢 模板 1:固定源点 + 已知 u < v

(比如洛谷 P1807,保证输入边满足 u < v)

const long long NEG = -(1LL << 60);
vector<long long> dist(n+1, NEG);
dist[src] = 0;

for (int u = 1; u <= n; ++u) {
    if (dist[u] == NEG) continue; // 不可达跳过
    for (auto [v, w] : adj[u]) {
        dist[v] = max(dist[v], dist[u] + w);
    }
}

🟡 模板 2:固定源点 + 不保证 u < v

需要自己跑 Kahn 拓扑

const long long NEG = -(1LL << 60);
vector<long long> dist(n+1, NEG);
dist[src] = 0;

queue<int> q;
vector<int> indeg(n+1, 0);
// 读边时统计 indeg[v]++

for (int i = 1; i <= n; ++i) 
    if (indeg[i] == 0) q.push(i);

while (!q.empty()) {
    int u = q.front(); q.pop();
    if (dist[u] != NEG) { // 源点可达才转移
        for (auto [v, w] : adj[u]) {
            dist[v] = max(dist[v], dist[u] + w);
            if (--indeg[v] == 0) q.push(v);
        }
    } else {
        for (auto [v, w] : adj[u]) 
            if (--indeg[v] == 0) q.push(v);
    }
}

🔴 模板 3:全图最长路径(任意源 → 任意点)

先找所有入度 0 的点作为源

const long long NEG = -(1LL << 60);
vector<long long> dist(n+1, NEG);

queue<int> q;
vector<int> indeg(n+1, 0);
// 读边时统计 indeg[v]++

for (int i = 1; i <= n; ++i) {
    if (indeg[i] == 0) {
        dist[i] = 0; // 多源起点
        q.push(i);
    }
}

while (!q.empty()) {
    int u = q.front(); q.pop();
    if (dist[u] != NEG) {
        for (auto [v, w] : adj[u]) {
            dist[v] = max(dist[v], dist[u] + w);
            if (--indeg[v] == 0) q.push(v);
        }
    } else {
        for (auto [v, w] : adj[u]) 
            if (--indeg[v] == 0) q.push(v);
    }
}

long long ans = *max_element(dist.begin(), dist.end());

📌 小提示


欧拉路径

建模触发器

题面一旦出现:“把每个单词都用一次、能连就连、输出整条链” → 下意识联想到:欧拉路径(边全用一次)

先验判定(能不能存在)

有向图欧拉路径充要条件(忽略方向连通 + 入出度条件):

起点怎么“自己选”

为啥“对起点做 DFS 就对了”

核心是 Hierholzer 算法(欧拉路/回路的标准构造):

正确性的直觉:像在一个区域里“抽线”,抽到抽不动了就封口记录,最终把所有边都抽走一次,拼起来就是欧拉路;你按最小边优先走,得到的就是字典序最小。

为什么“dp/最短路/拓扑”都不合适

可复用“推导清单”

  1. 点=字母,边=单词(首→尾),边上存字符串
  2. 统计 in/out,并记录出现过的点
  3. 忽略方向做连通性检查
  4. 判度数:路径态/回路态;
  5. 起点:out=in+1 的点;若回路选最小出度>0 的点
  6. adj[u] 升序,遍历时用 back()+pop_back()
  7. 跑 Hierholzer(递归/栈),收集边,逆序输出用 . 相连
  8. 验证边数是否都用到(res.size()==n) 见 luogu P1127
#include<iostream>
#include<vector>
#include<algorithm>
#include<string>
using namespace std;

void dfs(int u,vector<vector<string>>&adj,vector<string>&res){
    while(!adj[u].empty()){
        string s=adj[u].front();
        adj[u].erase(adj[u].begin());
        dfs(s.back()-'a',adj,res);
        res.push_back(s);
    }
}

int main(){
    // 一个欧拉路径遍历的图?
    int n;
    cin>>n;
    vector<string>str(n);
    for(int i=0;i<n;i++) cin>>str[i];
    vector<vector<string>>adj(26);
    for(string &s:str){
        adj[s[0]-'a'].push_back(s); //存单词去建立映射关系
    }
    for(auto &v:adj) sort(v.begin(),v.end()); //保证字母序
    vector<int>indegree(26,0),outdegree(26,0);
    for(int i=0;i<26;i++){
        for(string &s:adj[i]){
            outdegree[i]++;
            indegree[s.back()-'a']++;
        }
    }
    // 欧拉路径必要条件:有一个起点满足 outdegree-indegree=1,有一个终点满足 indegree-outdegree=1,其余点indegree=outdegree
    int start=-1,end=-1;
    for(int i=0;i<26;i++){
        if(outdegree[i]-indegree[i]==1){
            if(start==-1) start=i;
            else { //多于一个起点
                cout<<"***"<<endl;
                return 0;
            }
        }
        else if(indegree[i]-outdegree[i]==1){
            if(end==-1) end=i;
            else { //多于一个终点
                cout<<"***"<<endl;
                return 0;
            }
        }
        else if(indegree[i]!=outdegree[i]){
            cout<<"***"<<endl;
            return 0;
        }
    }
        vector<string>res;
        if(start==-1){ //没有起点,说明是回路,从任意点开始
            for(int i=0;i<26;i++){
                if(outdegree[i]>0){
                    start=i;
                    break;
                }
            }   
        }
        dfs(start,adj,res);
        if(res.size()!=n){ //没有遍历完所有边
            cout<<"***"<<endl;
            return 0;
        }
        reverse(res.begin(),res.end());
        for(int i=0;i<res.size();i++){
            cout<<res[i]<<(i==res.size()-1?"":".");
        }
        return 0;
    }

DP

“我设了个 dp[..],但是它到底在帮我算什么?目标到底是什么?为什么转移方程就是这么写?”


🌱 动态规划的“核心三问”

每次遇到题目时,可以强迫自己回答这三个问题:

  1. 状态是什么?

    • dp[i] / dp[i][j] 代表什么量?
    • 一句话清晰描述:它是“前 i 个物品的最优解”还是“走到 (i,j) 的最短路径”?
  2. 目标是什么?

    • 我要的最终答案是 dp[n]、还是 max(dp[..])、还是 dp[n][m]?
    • 把“输出需要什么”先对齐,避免“我设的状态跟目标无关”。
  3. 转移怎么来?

    • 当前状态能从什么子问题转移而来?
    • 每次写转移的时候,心里要对应一个“小故事”:比如“第 i 件物品选/不选”,“从左边或上边走过来”。

🌰 举个小例子

最经典:爬楼梯问题。每次可以走 1 步或 2 步,问走到第 n 层的方案数。

  1. 状态:

    • dp[i] = 到达第 i 层的方案数。
  2. 目标:

    • 我要求的是 dp[n]。
  3. 转移:

    • 走到第 i 层,要么从 i-1 走一步上来,要么从 i-2 跨两步上来。
    • 所以:dp[i] = dp[i-1] + dp[i-2]。

这个故事就非常清晰:每个状态对应一个问题,每个转移对应一个选择。


🔑 小技巧


所以“每次设定 dp 目标不清晰”,其实就是第 2 步没对齐。 解决方法就是:

  1. 明确状态定义;
  2. 明确我要的答案在哪个状态里;
  3. 再来写转移。

卡特兰数

经典栈序列。

三种常用公式

  1. 递推(DP,最常见,也就是你代码里的式子) C0=1,Cn=k=0n1CkCn1kC_0=1,\quad C_n=\sum_{k=0}^{n-1} C_k\,C_{n-1-k} 时间 O(n^2)。

  2. 闭式(组合数) Cn=1n+1(2nn)C_n=\frac{1}{n+1}\binom{2n}{n} 适合用大整数或高精度库。

  3. 线性递推(从前一项滚动推后一项) Cn+1=Cn4n+2n+2C_{n+1}=C_n\cdot \frac{4n+2}{n+2} 每步一次乘除,注意整除无误差;实现时最好用大整数或先约分。

小表(前几项) n: 1,2,3,4,5,6,7,8,9,10 Cₙ: 1,2,5,14,42,132,429,1430,4862,16796

整型范围提示 • int 最多到 2.1×1092.1\times10^9;C₁₉=1,767,263,190 还勉强能放,C₂₀=6,564,120,420 就溢出了。 • long long 可安全到 n=35(C₃₅≈3.116×10¹⁸ < 2⁶³−1)。更大就要高精度。


二维矩阵

二维矩阵题给人的压迫感,往往不在代码量,而在于: • 坐标系的混乱:到底是 a[x][y] 还是 a[y][x]?从 1 开始还是从 0 开始? • 遍历方向的多样:上下左右、斜对角、跳马步,容易漏掉或写错。 • 边界条件的麻烦:是不是越界?是不是访问过?是不是要回溯? • 可视化困难:在脑子里想“点和点的关系”比一维复杂很多。

看到矩阵题会害怕,其实是大脑在提醒你“这类题细节多、容易掉坑”。


怎么化解这种“二维恐惧症”?

技巧

一些很实用的小技巧:

  1. 固定模板 先写好一个方向数组,比如走 8 个方向:
int dx[8] = {1,1,1,0,0,-1,-1,-1};
int dy[8] = {1,0,-1,1,-1,1,0,-1};

或者像马跳的 8 个方向:

int dx[8] = {1,1,-1,-1,2,2,-2,-2};
int dy[8] = {2,-2,2,-2,1,-1,1,-1};

以后只要 for(int d=0;d<8;d++){ nx=x+dx[d]; ny=y+dy[d]; },再加边界判断。这样就不会一边想题一边还要写乱七八糟的 if/else。

  1. 边界判断写成函数
bool inside(int x,int y,int n){
    return x>=1 && x<=n && y>=1 && y<=n;
}

这样比直接写条件判断要清晰、少 bug。

  1. 先手动画一张小图 比如 3×3、4×4,用笔画出来,标记 BFS 的扩展顺序,你会发现很多规律更直观。

  2. 小数据先跑暴力 先写 BFS/DFS 版本,在 n=5,k=2 这种小范围跑出来,打印过程,心里就踏实多了。


心态

二维矩阵题 不是 DP 的专利,它可以是 BFS(可达性)、DFS(连通块)、模拟(翻格子)、数学(对称性)。你害怕其实是因为没把“矩阵”当作普通的图。把矩阵想成一个图,格子是节点,四周/八周/马步是边,其实问题就清爽了。