[OI] 图论
图论专题总结
Author : HaneDaniko
Contest
- 二叉树遍历
- 欧拉路和欧拉回路
- 最短路
- 判断负环
- 判断最小环
- 最小生成树
- 拓扑排序
- 并查集
- 分层图和分层图最短路
- tarjan
- dfs序
- 树上公共祖先
- 差分约束
- 二分图
- 扫描线
- 树链剖分
1. 二叉树遍历
二叉树分为前序遍历,中序遍历与后序遍历. 一般来说,它们的不同仅在根节点的输出先后: 遇到就输出根节点的为前序,先遍历完左子树再输出的为中序,全部遍历完成再输出的为后序.
一段求遍历序列并输出的代码如下.
点击查看代码
void TreeSec(int root,int type){ //1=xianxu 2=zhongxu 3=houxu
if(type==1){
cout<<a[root].id;
}
if(a[root].lc!=0){
TreeSec(a[root].lc,type);
}
if(type==2){
cout<<a[root].id;
}
if(a[root].rc!=0){
TreeSec(a[root].rc,type);
}
if(type==3){
cout<<a[root].id;
}
return;
}
顺序 : A 左孩子 B 右孩子 C
ABC 分别对应前中后
2. 欧拉路
欧拉路为走一遍能不重复地走过全部边的图,若欧拉路的起点与终点相同,那么就叫欧拉回路. 是欧拉路的条件为:所有的点中,入边与出边为奇数的点有且仅有两个. 是欧拉回路的条件为:所有的点中,没有入边与出边为奇数的点.
欧拉路:从奇点开始DFS
欧拉回路:从任意点开始DFS
DFS求欧拉路
void NodeSec(int begin){
cout<<begin<<endl;//输出路径
for(int i=1;i<=n;++i){
if(e[begin][i]!=0){
e[begin][i]=0;
e[i][begin]=0;
NodeSec(i);
}
}
}
判断:遍历完后不存在没遍历的点 (开vis)
3. 最短路
3.1 FLOYED
复杂度:
枚举中间点反复更新最短路
只能用邻接矩阵
Floyed
//prework:
for(int i=1;i<=m;++i){
for(int j=1;j<=m;++j){
if(node[i][j]==0){
node[i][j]=100000;
}
}
}
//
for(k 1-n){
for(i 1-n){
for(j 1-n){
if(w[i][j]>w[i][k]+w[k][j]){
w[i][j]=w[i][k]+w[k][j];
}
}
}
}
3.2 DIJKSTRA
朴素复杂度:
单调队列复杂度:
1.在没选过的点里选最近的并标记
2.松弛邻接点
3.松弛后的点入队
Dijkstra
void dij(int s){
memset(vis,0,sizeof(vis));
memset(dis,0x3f,sizeof(dis));
dis[s]=0;
q.push(node{s,dis[s]});
while(!q.empty()){
node u=q.top();
q.pop();
if(vis[u.id]){
continue;
}
vis[u.id]=true;
for(edge i:e[u.id]){
if(dis[u.id]+i.w<dis[i.to]){
dis[i.to]=dis[u.id]+i.w;
q.push(node{i.to,dis[i.to]});
}
}
}
}
3.3 SPFA
BF(SPFA 的暴力版本)复杂度:
队列优化复杂度:
BF 写法主要用于判断负环,详见 关于最小环和负权环
BF 写法基于这样的思路:不断地枚举已有的边,并尝试松弛边连接的节点,直到不能再松弛了,此时就是最短的路径
很显然,只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛操作。
那么我们用队列来维护「哪些结点可能会引起松弛操作」,就能只访问必要的边了。
SPFA的简单步骤大致如下 (
- 需要的变量 : 存图变量,
<用于存储起点到各点的距离>, <用于记录节点是否在队列里> 和一个队列. - 初始化 :
数组需要全部初始化为一个很大的值 (大于最大边权,小于1,073,741,823,否则两个数相加会超 范围变成负的,假如你用 ,推荐使用“ ”将其初始化为 ) ,如果需要重复使用,建议初始化 . - 起点的
设为0, 设为1,然后放入队列 . - 随后,每次从队列中拿出一个值,将其
修改为 0,对其所有的边进行松弛操作,如果成功松弛的边不在队列里 ( 就是在这里用的),那么入队, 修改为1.
Spfa
void spfa(int s){
//prework:
memset(dis,0x3f,sizeof(dis));
dis[s]=0;
q.push(s);
vis[s]=true; //是否在队列里
while(!q.empty()){
int x=q.front();
q.pop();
vis[x]=false;
for(edge i:e[x]){
if(dis[i.to]>dis[x]+i.w){
dis[i.to]=dis[x]+i.w;
if(!vis[i.to]){
q.push(i.to);
vis[i.to]=true;
}
}
}
}
}
4.判断负环
4.1 BFS - 基于 SPFA
进行一次SPFA
存在点的入队次数大于n,则存在负环 (开一个cnt)
这种办法十分的慢,而且它死了。但是遇到输出路径的问题还是优先选择这个方法,
因为它可以在判环的同时求最短路
4.2 DFS - 基于递归SPFA
如果能松弛一个还在DFS中的点,说明存在环
4.2 实现
//DFS
bool spfa(int s){
vis[s]=true;
for(edge i:e[s]){
if(dis[i.to]>dis[s]+i.w){
if(vis[i.to]){
return true;
}
dis[i.to]>dis[s]+i.w;
if(spfa(i.to)==true){
reutrn true;
}
}
}
vis[s]=false;
return false;
}
5.判断最小环
5.1 TOPO判最小环
拓扑排序一遍之后就剩环了,所以可以遍历环,搜索每个还有
出度入度的点所在环
但是虽然拓扑排序找存在环很容易,遍历环却很难,因此不推荐使用.
5.2 FLOYED判无向图最小环
假设存在最小环,那么FLOYED的i,j,k可以将它分成三个部分,
环中的最大点为k, 那么ans=dis[i][j]+w[i][k]+w[j][k];
5.3 SPFA判有向图最小环
SPFA死了,不讲。不过BFS的SPFA用来判存在环还挺好用的,
写个深搜,然后开个vis就行。要是写存在环推荐用深搜SPFA.
5.4 并查集求最小环
并查集需要开size数组,假如合并的时候遇到了已经在相同
集合内的数,那么此时的size就是环的大小。
思路二实现
//2
//FLOYED的最小环写法稍微有点变动:
int MinCost=inf;
for(int k=1;k<=n;k++){
for(int i=1;i<k;i++){
for(int j=i+1;j<k;j++){
MinCost = min(MinCost,dis[i][j]+mp[i][k]+mp[k][j]);
}
}
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
}
}
}
/*
每次更新最小环以后都要更新一遍DIS值,主要是防止走回去。
*/
间章 关于最小环与负权环
最小环-Floyed
<
关于Floyed能用于求解最小环的证明
对于一个环,我们总可以在环上找到任意三个点
我们设从
显然,对于有向图,我们也有
这恰好就是Floyed的写法,而上述式子便是松弛方程.
但是有一点不太一样,在进行遍历时,一个点
另一点需要注意的是,这里可能代码里可能会出现
代码实现
点击查看代码
int Floyd(int n){
int mc=inf;
for(int k=0;k<=n;++k){
for(int i=0;i<k;++i){
for(int j=i+1;j<k;++j){
mc=min(mc,e[i][k]+e[k][j]+dis[i][j]);
}
}
for(int i=0; i<=n;++i){
for(int j=0;j<=n;++j){
dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
}
}
}
return mc;
}
下面给出了存图和初始化的示例
点击查看代码
for(int i=0;i<=n;++i){
for(int j=0;j<=n;++j){
if(i==j){
w[i][j]=dis[i][j]=0;
}
else{
w[i][j]=dis[i][j]=inf;
}
}
}
for(int i=0;i<m;++i){
cin>>u>>v>>y;
w[u][v]=dis[u][v]=y;
w[v][u]=dis[v][u]=y;
}
负权环-SPFA
<
SPFA判断负环的方法
假如我们有
所以我们需要想办法记录当前最短路经过的节点数
我们只需要加上这么两句 (假设
cont[j]=cont[i]+1;
if(cont[j]=>n) return false;
其中
代码实现
点击查看代码
bool spfa(int s){
for(int i=1;i<=n;++i){
dis[i]=1000000000;
vis[i]=false;
cont[i]=0;
}
dis[s]=0;
vis[s]=true;
q.push(s);
cont[s]++; //这别忘了
while(!q.empty()){
int u=q.front();
q.pop();
vis[u]=false;
for(edge ed:e[u]){
int to=ed.to,w=ed.w;
if(dis[to]>dis[u]+w){
dis[to]=dis[u]+w;
if(!vis[to]){
q.push(to);
cont[to]++; //注意,这一段一定要在if里面
if(cont[to]>=n){
return true; //有负权环
}
vis[to]=true;
}
}
}
}
return false;
}
例题 P2850 [USACO06DEC] Wormholes G
点击查看代码
#include<bits/stdc++.h>
using namespace std;
struct edge{
int to,w;
};
vector<edge> e[501];
int dis[501],cont[501];
bool vis[501];
queue<int> q;
int f,n,m,w;
bool spfa(int s){
for(int i=1;i<=n;++i){
dis[i]=1000000000;
vis[i]=false;
cont[i]=0;
}
dis[s]=0;
vis[s]=true;
q.push(s);
cont[s]++;
while(!q.empty()){
int u=q.front();
q.pop();
vis[u]=false;
for(edge ed:e[u]){
int to=ed.to,w=ed.w;
if(dis[to]>dis[u]+w){
dis[to]=dis[u]+w;
if(!vis[to]){
q.push(to);
cont[to]++;
if(cont[to]>=n){
return false;
}
vis[to]=true;
}
}
}
}
return true;
}
int main(){
cin>>f;
for(int k=1;k<=f;++k){
for(int i=1;i<=500;++i){
e[i].clear();
}
cin>>n>>m>>w;
for(int i=1;i<=m;++i){
int x,y,z;
cin>>x>>y>>z;
e[x].push_back(edge{y,z});
e[y].push_back(edge{x,z});
}
for(int i=1;i<=w;++i){
int x,y,z;
cin>>x>>y>>z;
e[x].push_back(edge{y,-1*z});
}
bool flag=false;
for(int i=1;i<=n;++i){
if(spfa(i)==false){
cout<<"YES"<<endl;
flag=true;
break;
}
}
if(flag==false){
cout<<"NO"<<endl;
}
}
}
负权环-BellmanFord
<
当然,SPFA的代码不是很好背,但是我们可以用伟大的Bellman-Ford,这两种算法起码对菊花图来说一样快,思路十分简单:遍历
代码实现
点击查看代码
bool bf(){
memset(dis,0x3f,sizeof(dis));
dis[1]=0;
for(int k=1;k<=n;++k){
for(edge i:e){ //把所有边扔到结构体vector e里
if(dis[i.to]>dis[i.start]+i.w){
dis[i.to]=dis[i.start]+i.w;
}
}
}
for(edge i:e){
if(dis[i.to]>dis[i.start]+i.w){
return true; //有负权环
}
}
return false;
}
例题(见上SPFA)
点击查看代码
#include<bits/stdc++.h>
using namespace std;
struct edge{
int start,to,w;
};
vector<edge> e;
int dis[501];
int f,n,m,w;
bool bf(){
memset(dis,0x3f,sizeof(dis));
dis[1]=0;
for(int k=1;k<=n;++k){
for(edge i:e){
if(dis[i.to]>dis[i.start]+i.w){
dis[i.to]=dis[i.start]+i.w;
}
}
}
for(edge i:e){
if(dis[i.to]>dis[i.start]+i.w){
return true;
}
}
return false;
}
int main(){
cin>>f;
for(int k=1;k<=f;++k){
e.clear();
cin>>n>>m>>w;
for(int i=1;i<=m;++i){
int x,y,z;
cin>>x>>y>>z;
e.push_back(edge{x,y,z});
e.push_back(edge{y,x,z});
}
for(int i=1;i<=w;++i){
int x,y,z;
cin>>x>>y>>z;
e.push_back(edge{x,y,-z});
}
if(bf()==false){
cout<<"NO"<<endl;
}
else{
cout<<"YES"<<endl;
}
}
}
6.最小生成树
6.1 PRIM
PRIM 算法类似 DIJKSTRA. 基础步骤如下:
- 计算出每个点被选入的花费
. - 找到
最小的未标记的点标记,表示入树,并松弛相邻的未标记点. - 重复上述步骤.
Prim
int prim(int s){
int mst=0;
for(int i=1;i<=n;++i){
dis[i]=m[s][i];
}
dis[s]=0;
for(int i=1;i<=n-1;++i){
int mindis=0x7fffffff,minid;
for(int j=1;j<=n;++j){
if(dis[j]!=0&&dis[j]<mindis){
mindis=dis[j];
minid=j;
}
}
mst+=mindis;
dis[minid]=0;
for(int j=1;j<=n;++j){
if(dis[j]>m[minid][j]){
dis[j]=m[minid][j];
}
}
}
return mst;
}
6.2 KRUSKAL
KRUSKAL 算法是一个基于合并思想的算法,它的基本思路如下:
- 将所有边从小到大排序.
- 从小到大枚举所有边,若加入这条边不会构成回路,那么就将这条边加入树中,即合并这条边左右的两棵子树.
基于并查集的思想,我们可以对其进行实现:
- 将所有边从小到大排序.
- 从小到大枚举所有边,若这条边连接的端点与当前树不在同一个并查集,那么就合并这条边左右的两个并查集.
Kluskal
sort(a+1,a+anow+1);
for(int i=1;i<=anow;++i){
if(!ifconnect(a[i].from,a[i].to)){
connect(a[i].from,a[i].to);
tot+=a[i].w;
}
}
cout<<tot;
7.拓扑排序
7.1 拓扑排序简介
拓扑排序是针对有向无环图的,所谓有向无环图即为没有环路的有向图,这样的图必定会有一个或几个没有入边的节点与没有出边的节点. 拓扑排序就是一种对有向无环图的遍历,它保证每个点在访问时,指向这个节点的节点一定已经访问过. 我们可以如下来实现:
- 记录每条边的入度.
- 寻找入读为
的点并访问,将该点指向的点的入度减一. - 重复上述步骤.
注意到该过程可用与 DIJKSTRA 算法类似的优先队列优化. 但实际上,如果一个点初始不为
实现
cin>>n;
for(int i=1;i<=n;++i){
int x;
while(1){
cin>>x;
if(x==0){
break;
}
to[i].push_back(x); //记录自身指向的节点
cont[x]++; //记录入度
}
}
for(int i=1;i<=n;++i){
if(cont[i]==0){
q.push(i);
}
}
while(!q.empty()){
int u=q.front();
q.pop();
cout<<u<<" ";
for(int i:to[u]){
cont[i]--;
if(cont[i]==0){
q.push(i);
}
}
}
7.2 拓扑排序的用途:判断回路
因为拓扑排序仅针对有向无环图,所以,假如对一个存在环路的图进行拓扑排序,那么就会剩下环. 此时我们记录一个总遍历数
8.并查集
并查集算作一个森林,实际上相当于一个集合. 支持快速进行 合并,查询 操作. 原理为 父亲相同的节点在同一个集合内.
-
初始,每个节点即为自己的父亲.
-
合并
及所属集合, 那么将 的根节点的父亲改成 ,即把整棵树挪过来并接到后面. -
查询根节点时,采用"根节点的父亲为自己"与"父亲节点的根节点为自己的根节点"的思想递归寻找,并在回溯时将这个点的父亲直接改为子树根节点,方便以后查询.
代码
int find(int id){
if(father[id]!=id){
father[id]=find(father[id]);
}
return father[id];
}
bool ifconnect(int x,int y){
if(find(x)==find(y)){
return true;
}
return false;
}
void connect(int x,int y){
if(!ifconnect(x,y)){
father[find(x)]=y;
}
}
9.分层图和分层图最短路
详见 分层图最短路.
9.1 什么是分层图最短路
分层图是指有很多个平行的图,各个平行的图之间有特殊的连接边。
分层图最短路,是在原有的求图中某两个点之间的最短路基础上增加一个条件——可以选择
9.2 为什么它叫分层图最短路
一般来说,因为我们并不知道一条边究竟被修改了还是没有,因此不如赋予它两个权值,像这样 同一条边同时有两种及以上不能同时选择的权值 我们就把这几条边看作是在不同的“图层”,这样我们就可以通过一些特殊变形来跑最短路了.
9.3 构建分层图最短路
首先,分层图要有“图层”,因此我们需要根据题意来赋予每个图层特殊的意义.
一般来说,我们有以下定义:
当对图进行
那么有:
边
边
所以,我们需要连的边就变成了:
- 每个“图层”内部正常连边
- 假如存在边
,那么连接边 (前提是 ).
大概像下面这样:
建好图后,我们再跑最短路算法就没有问题了.
10.TARJAN
10.1 强连通分量与DFS序边的分类
DFS序即为字典序最小的深度优先搜索
-
强连通:有向图中两点都能到达对方.
-
强连通分量:有向图中任意两点都强连通的子图. 单个点也为一个强连通分量.
-
边的分类
-
树边
: 该边指向一个未访问过的节点 -
返祖边
: 该边指向自己的父亲节点 (DFS序的特性会保证这个点的父节点总是已经访问) -
横叉边
: 该边指向一个已访问节点,但不是自己的父节点. -
前向边
: 该边指向一个已访问节点,并且这个点属于自己的子树.
10.2 TARJAN算法对不同边的判断方式
Tarjan引入了
根据定义,可以得出:
-
一个结点的子树内结点的
都大于该结点的 . -
从根开始的一条路径上的
严格递增, 严格非降.
10.3 TARJAN算法
Tarjan 算法用来搜索图中的全部强连通分量. 其步骤大致如下:
-
按DFS序从根节点搜索图. 或者从超级原点开始.
-
遇到未被访问的节点,那么递归搜索,返回后根据该节点的
值更新源节点的 值. 因为存在直接路径,所以子节点能够回溯到的未构成强连通分量的结点,源节点也一定能够回溯到. -
遇到被访问过的未构成强连通分量的节点, 符合
包括的节点定义,那么直接用该点 更新源节点的 . -
遇到被访问过的已构成强连通分量的节点,该点已处理,无用处,跳过.
-
该点DFS全部结束后,只需判断
和 是否相等. 因为在同一个强连通分量内, 的值必然相等,而 和 相等的点即为该分量内第一个被访问到的节点. 从这个点开始按顺序寻找 与其相等的点即可.
我们可能还会引入一些其他的变量来存储结果,如
而对于如何判断一个点已经属于某个强连通分量的问题,Tarjan 算法引入了一个栈,进行如下表示:
-
对已访问过的节点立即入栈.
-
寻找到的强连通分量中的每个节点出栈.
-
一个点已被访问且不在栈中,那么它已经属于某个强连通分量. 若在栈中则尚未属于任何强连通分量.
略叙
- 入栈,标记,时间戳
- 搜子节点,未搜过 (
) 则搜,更新 - 已搜过且标记则更新
- 全部搜完判断相等弹栈
伪代码
TARJAN_SEARCH(int u)
vis[u]=true
low[u]=dfn[u]=++dfncnt
push u to the stack
for each (u,v) then do
if v hasn't been searched then
TARJAN_SEARCH(v) // 搜索
low[u]=min(low[u],low[v]) // 回溯
else if v has been in the stack then
low[u]=min(low[u],dfn[v])
完整代码
//vis 数组表示一个节点在不在栈内
void pbond(int s){
dfn[s]=low[s]=++cnt;
st.push(s);
vis[s]=true;
for(edge i:e[s]){
if(!dfn[i.to]){
pbond(i.to);
low[s]=min(low[s],low[i.to]);
}
else if(vis[i.to]){
low[s]=min(low[s],dfn[i.to]);
}
}
if(low[s]==dfn[s]){
int y;
++tot;
do{
y=st.top();
st.pop();
vis[y]=false;
}while(y!=s);
}
}
10.4 缩点
缩点是利用TARJAN将一个图中的每个强连通分量都看作一个点,构造一个有向无环图, 这样在处理其他问题时会更方便. 在缩点时,TARJAN算法需要维护一个
另外,一个入度为零的点的判断条件为当前其
有向无环图的搜索常用拓扑排序.
下面给出一份缩点维护出度的代码 (类似可用于拓扑排序):
代码
//维护belong size的tarjan
void tarjan(int s){
dfn[s]=low[s]=++cnt;
st.push(s);
vis[s]=true;
for(int i:e[s]){
if(!dfn[i]){
tarjan(i);
low[s]=min(low[s],low[i]);
}
else if(vis[i]){
low[s]=min(low[s],dfn[i]);
}
}
if(low[s]==dfn[s]){
int y;
++tot;
do{
y=st.top();
size[tot]++;
st.pop();
belong[y]=tot;
vis[y]=false;
}while(y!=s);
}
}
//对全部入度为零的点跑tarjan,也可以建超级原点处理
for(int i=1;i<=n;++i){
if(!dfn[i]){
cnt=0;
tarjan(i);
}
}
//维护出度 对一个点贡献出度的点一定是当前点的邻点
for(int i=1;i<=n;++i){
for(int j:e[i]){
if(belong[i]!=belong[j]){
outdegree[belong[i]]++;
}
}
}
实际上建图操作也很类似,这里以拓扑建图示例:
示例
void build(){
for(int i=1;i<=n;++i){
for(edge j:e1[i]){
if(belong[i]!=belong[j.to]){
e2[belong[i]].push_back(edge{belong[j.to]});
indegree[belong[j.to]]++;
}
}
}
}
10.5 无向图上的TARJAN
割点 : 去掉这个点,整个图分成两部分
割边 : 去掉这个边,整个图分成两部分
点双连通分量 : 不存在割点的分量
点双性质:
- 点双内若没有重边,任意两点间都存在至少两条不重点的路径
- 任意一个割点都在至少两个点双中
- 任意一个不是割点的点都只存在于一个点双中
边双连通分量 : 不存在割边的分量
边双性质:
- 割边不属于任意边双,而其它非割边的边都属于且仅属于一个边双
- 边双内任意两点之间都有至少两条不重边的路径
(注意这里的割点,割边相对于分量图而言,而不是全图)
10.5.1 TARJAN求割点
根据定义,求割点的关键在于判断当前点是其子节点到达其父节点的唯一路径.
根据TARJAN的特性,递归回溯到当前节点时,其子节点一定回溯完毕,而父节点的
略叙
直接在回溯结束后判断即可
根节点需要特判,只有根节点儿子大于
割点代码
void tarjan(int s){
dfn[s]=low[s]=++cnt;
int son=0; //用处见下
for(int i:e[s]){
if(!dfn[i]){
son++;
tarjan(i);
low[s]=min(low[s],low[i]);
if(dfn[s]<=low[i]){
if(now!=root||son>1){ //不认为开始遍历的根节点能构成割点,除非其子节点数大于1
cutpoint[s]=true; //记录割点
}
}
}
else{ //没有栈了,所以不用开vis,这里可以直接else
low[s]=min(low[s],dfn[i]);
}
}
}
10.5.2 TARJAN求割边
Tarjan求割边有
可以发现此式与割点求法极为类似,只是少了一个等于号,这是因为割点子节点的
割边因为要判断父边和重边了,所以使用链式前向星更为实用.
对于判断父边与重边. 父边的判断通常借助一个标记数组. 假如访问到的节点还没被访问过,那么访问使用的边即为其父边. 无向图中起点与终点恰好相反的一对边为重边,走重边会导致死循环. 我们可以借助
方法一略叙
先记前驱,回溯判断当前小于子节点的
方法一
void add(int from,int to){
id[++enow]=to;
nxt[enow]=head[from];
head[from]=enow;
}
void tarjan(int s,int root){
dfn[s]=low[s]=++cnt;
for(int i=head[s];i;i=nxt[i]){
int y=id[i];
if(!dfn[id[i]]){
tarjan(id[i],i);
low[s]=min(low[s],low[id[i]]);
if(low[id[i]]>dfn[s]){
cutline[i]=cutline[i^1]=true;
}
}
else if(i!=root^1){
low[s]=min(low[s],dfn[id[i]]);
}
}
}
Vector形式的方法一
void tarjan(int now,int last){
dfn[now]=low[now]=++dfncnt;
for(edge i:e[now]){
if(i.to!=last){
if(!dfn[i.to]){
tarjan(i.to,i.id);
low[now]=min(low[now],low[i.to]);
if(low[i.to]>dfn[now]){
iscut[i.id]=true;
}
}
else low[now]=min(low[now],dfn[i.to]);
}
}
}
方法二
void add(int from,int to){
enow++;
e[enow].to=to;
e[enow].nxt=head[from];
head[from]=enow;
}
void tarjan(int s,int root){
low[s]=dfn[s]=++cnt;
for(int i=head[s];i;i=e[i].nxt){
if(e[i].to==root){
continue;
}
if(!dfn[e[i].to]){
tarjan(e[i].to,s);
low[s]=min(low[s],low[e[i].to]);
if(low[e[i].to]>dfn[s]){
ans++;
}
}
else{
low[s]=min(low[s],dfn[e[i].to]);
}
}
}
方法二的另一种实现
void tarjan(int s,int root){
low[s]=dfn[s]=++cnt;
st.push(s);
for(int i=head[s];i;i=e[i].nxt){
if(!dfn[e[i].to]){
tarjan(e[i].to,i^1);
low[s]=min(low[s],low[e[i].to]);
if(low[e[i].to]>dfn[s]){
e[i].iscutline=e[i^1].iscutline=true;
}
}
else if(i^root){
low[s]=min(low[s],dfn[e[i].to]);
}
}
if(low[s]==dfn[s]){
int y;
tot++;
do{
y=st.top();
st.pop();
belong[y]=tot;
}while(y!=s);
}
}
10.5.3 无向图缩点
无向图中每个边双连通分量都可以看作是一个点,那么我们可以通过类似缩点的取代方法让无向图变为一个无向无环图,这样更方便我们处理.
方法一维护缩点后入度的代码
for(int i=2;i<=enow;++i){
if(e[i].iscutline){
indegree[belong[e[i].to]]++;
}
}
10.5.4 TARJAN求点双连通分量
根据上述点双性质,有一个定理,即点双中的割点在搜索树中最后出现. 那么,我们在搜索树中遇到一个割点的时候,就意味着我们找到了一个点双. 那么我们求点双的过程就是求割点的过程.
点双图解
10.5.5 TARJAN求边双连通分量
由于割边并不参与边双的构成,因此我们可以直接用搜索来求边双
- 先求出全部割边,将割边的端点标记为"伪割点"
- 不断寻找未访问过的非"伪割点"进行DFS
- 遇到"伪割点"即返回
- 途径的"伪割点"与非"伪割点"属于同一个边双.
或者还有一个更简单的等价的做法:
- 删去所有边双
- 直接跑连通块,同一个连通块内就是同一个边双
注意点双不能用这种办法来求
一组 Hack 数据见下:
11.DFS序
DFS序是树上DFS (或指定根节点的无向图 DFS 的节点访问顺序序列),若无特殊需求,则DFS序不唯一.
DFS序有以下几个性质 :
- 在DFS序中,父节点总在子节点前方
- 在DFS序中,父节点的一颗子树所对的编号也满足DFS序,且紧跟在父节点后方.
那么,父节点
利用这种性质,我们可以快速建立线段树,维护树上信息.
比如,下面这段代码可以完成树上单点修改与区间查询 (即查询节点与其子树权值和).
代码
#define int long long
#define tol (id*2)
#define tor (id*2+1)
#define mid (l+r)/2
int n,m,root;
vector<int> e[1000001];
vector<int> dfs;
int size[1000001]; //root i's amount of son nodes
int posi[1000001];
bool vis[1000001];
int a[1000001];
void buildgraph(int root){
dfs.push_back(root);
vis[root]=true;
for(int i:e[root]){
if(!vis[i]){ //root is i's father
buildgraph(i);
size[root]+=size[i]+1;
}
}
}
struct tree{
int l,r;
int sum;
}t[4000001];
void buildtree(int id,int l,int r){
t[id].l=l;
t[id].r=r;
if(l==r){
t[id].sum=a[dfs[l-1]];
return;
}
buildtree(tol,l,mid);
buildtree(tor,mid+1,r);
t[id].sum=t[tol].sum+t[tor].sum;
}
void change(int id,int k,int val){
if(t[id].l==t[id].r){
t[id].sum+=val;
return;
}
if(k<=t[tol].r){
change(tol,k,val);
}
if(k>=t[tor].l){
change(tor,k,val);
}
t[id].sum=t[tol].sum+t[tor].sum;
}
int sum(int id,int l,int r){
if(t[id].l>=l&&t[id].r<=r){
return t[id].sum;
}
if(t[tol].r<l){
return sum(tor,l,r);
}
if(t[tor].l>r){
return sum(tol,l,r);
}
return sum(tol,l,r)+sum(tor,l,r);
}
12.树上公共祖先
树上公共祖先即 LCA ,节点
12.1 倍增法求LCA
倍增法求 LCA 不仅需要预处理父亲是谁,还要处理节点
我们的倍增 LCA 分为两个部分. 这两个部分都是以 "向根节点跳
为了维护深度相同,我们需要先将它们跳到同一深度,即将较深的一个向上跳. 这是我们的第一步,即直接将
第二步即为将
这一步的实现,为了尽可能地少遍历,我们从最大的距离开始跳. 假如跳过这一步后,
预处理思想:
代码
int lca(int x,int y){
if(deep[x]<deep[y]){
swap(x,y);
}
for(int i=k;i>=0;--i){
if(deep[fa[x][i]]>=deep[y]){
x=fa[x][i];
}
}
if(x==y){
return x;
}
for(int i=k;i>=0;--i){
if(fa[x][i]!=fa[y][i]){
x=fa[x][i];
y=fa[y][i];
}
}
return fa[x][0];
}
12.2 树链剖分求 LCA
详见 16.1
13.差分约束
差分约束是一类 "给定不等式组,判断是否有解" 的问题. 我们可以把这类问题转化成图论求解. 具体转化如下.
假设现在有不等式组
有的题会让我们求不等式组中元素的最小值. 根据贪心策略,我们当然要尽可能使得每个不等式中的元素都取最小的可能值. 我们定义建立的图中的边权表示 "一个不等式中的满足条件的最小元素差",这样,我们把一个图跑完,所积累的就是 "满足所有不等式的最小元素差",即为答案. 根据如上思想,我们可以按如下规则建图.
,即 至少比 大 ,则从 到 建边权为 的边. ,即 至少比 小 ,则从 到 建边权为 的边. ,即 至少比 大 ,则从 到 建边权为 的边. ,即 至少比 小 ,则从 到 建边权为 的边. ,等价于 且 ,所以按 步骤建图.
建好图后,就是图论算法的事情了. 判断是否有解,只需判断能否到达. 计算最小值,跑一遍最长路即可.
当然,即便不等式形如
关于到底是跑最短路还是最长路的问题. 我们举一例来说明.
假如现在我们有一不等式
所以,我们可以总结成如下表格:
不等式 | 转化 | 最短路建边 | 最长路建边 |
---|---|---|---|
- | |||
- | |||
可以看出来,最长路与最短路在建边上正好相反.
或者你也可以这么记:
若
其余的都可以通过转化形成形如
14.二分图
14.1 二分图定义
二分图为一个无向图,满足:这个无向图可以被分成两部分,其中每部分内不存在边.
14.2 二分图判定
奇环法:一张图是二分图,当且仅当图中不存在奇环.
染色法:一张图是二分图,当且仅当图能被染成两种颜色,且没有相邻的点颜色相同.
一般我们判断二分图利用的是染色法.
BFS 染色法
bool check(int m){
queue<int> q;
int c[20001]={};
for(int i=1;i<=n;++i){
if(!c[i]){
q.push(i);
c[i]=1;
while(!q.empty()){
int u=q.front();
q.pop();
for(edge i:e[u]){
if(i.w>=m){
if(!c[i.to]){
q.push(i.to);
if(c[u]==1) c[i.to]=2;
else c[i.to]=1;
}
else if(c[i.to]==c[u]) return false;
}
}
}
}
}
return true;
}
14.3 匈牙利算法
假如我们在一个二分图中找到了若干边(称为匹配边),并且全部匹配边没有公共点,那么我们就称其为二分图的一个匹配.
增广路指的是按未匹配边、匹配边、未匹配边.... 的顺序,最终到达一个未匹配点的路径. 按这样的路径,依次将路径中的匹配边与未匹配边取反,最终匹配边个数会多一个.
匈牙利算法即是根据增广路性质写成的. 与增广路不同的是,匈牙利算法不管找什么边都是固定从某一侧开始,这也就决定了这个算法不会被环路卡死.
代码:
代码
bool dfs(int now){
for(int i:e[now]){
if(!vis[i]){
vis[i]=1;
if(!match[i]||dfs(match[i])){
match[i]=now;
return true;
}
}
}
return false;
}
int mat(){
memset(match,0,sizeof match);
int ans=0;
for(int i=1;i<=n;++i){
memset(vis,0,sizeof vis);
if(dfs(i)) ans++;
}
return ans;
}
14.4 二分图最大匹配,最小顶点覆盖,最大独立集
前置条件:是一个二分图
14.4.1 二分图最大匹配
题库 B D E F
定义: 边的集合,满足任何两条边都没有公共点,二分图最大匹配是这个集合的最大值.
求法: 跑一边匈牙利即可.
用途:
对于满足如下性质的问题给出答案
-
每个选择都有两种条件
-
两种条件至少选择一个时,选择有贡献
-
求所有选择的最大贡献
解决这类问题时,通常先找出选项的两个条件(可能不止两个,也可能会有变形),然后对每个选项的条件连有向边.
14.4.2 最小顶点覆盖
题库 C
定义: 点的集合,使得图中每条边在集合中都有公共点,最小顶点覆盖为该集合最小值.
求法: 数值上等于二分图最大匹配
用途:
对于满足如下性质的问题给出答案:
-
每个选择都有两种条件
-
两种条件至少选择一个时,选择成立
-
要求全部选择成立,求最少满足条件总数
4.1 4.2 判断方法 : 一个是最少,一个是最多,在题面上体现.
14.4.3 最大独立集
题库 G H I
定义: 点的集合,使得集合内任何两点都没有相连的边.
求法: 节点总数-二分图最大匹配
用途:
“没有相连的边”启示我们去寻找冲突. 没有相连的边就是没有冲突,那么按照题意,没有冲突的就是答案. 因此这类题的基本步骤是这样的:
-
寻找冲突
-
对冲突的对象连边(注意不是对冲突本身连边). 如 A 选 x,不选 y,B 选 y,不选 x,那么需要在 A 和 B 连边,而不是 x 和 y 连边.
-
以下两种写法是等价的:
代码
#1
for(int i=1;i<=n;++i){
for(int j=1;j<=n;++j){
if(like[i]==dislike[j]||dislike[i]==like[j]){
e[i].push_back(j);
}
}
}
#2
for(int i=1;i<=n;++i){
for(int j=1;j<=n;++j){
if(like[i]==dislike[j]){
e[i].push_back(j);
e[j].push_back(i);
}
}
}
14.5.一些小问题
14.5.1 建单向边还是双向边
还是那个问题,上面那两种代码是等价的,这是我们形式意义上的单向边或者双向边,虽然看起来不一样,但实际效果相同.
但是实际效果建单向边也是可行的. 因为我们在跑匈牙利的时候避免了这样的问题,而采用了每次都从左边寻找前往右边的点的这样一种方法,因此不会造成什么影响. 但这样会导致边数变成了原来的二倍,反过来跑的时候,两个对称节点就都满足条件了,因此答案会变成之前的二倍,所以真正结果是要除以二的. 相反,对真正的单向边则不需要这样.
假如你真的不知道,就多输出几个数,看看哪个能对上大样例.
14.5.2 能避免初始化的方法
匈牙利里每次 DFS 都要初始化,很费时间. 为此我们可以开一个整型的数组来记录,而不是布尔类型,每次搜索都赋一个时间戳,假若当前记录数组不等于时间戳就说明当前没访问过,这样就不用初始化了.
15.扫描线
扫描线用于解决一些 覆盖问题或者重复问题, 比如说现在我有这样一道题:
给出
个平行于坐标轴的矩形的坐标,求它们的面积并.
面积并指的是这些矩形的最大覆盖面积.
不难发现,当坐标绝对值很大时,我们采用标记法就会开不下,当
假如我们使用一根直线从左到右扫描(这样也可以保证每个矩形的左边总会比右边先扫到,避免了
那么现在的问题就变为:怎么更新直线被覆盖的长度?
依照线段树能够维护区间信息的思路,我们尝试用区间信息表示覆盖长度. 引入一个变量
- 当前被完全覆盖的区间对答案的贡献就是它的长度.
- 未被完全覆盖的区间对答案的贡献就是它的子区间贡献和.
这样这个题就可以完全使用线段树来解决了. 我们将矩形靠左的边称作出边,靠右的边称作入边,显然,当遇到入边时,它覆盖的区间
此外,因为这个题中的纵坐标值可能很大,这样开线段树的时候内存无法承受,因此需要进行 离散化,将纵坐标收缩到一个很小的区间内. 而且,因为这个题的扫描对象是矩形的左边和右边,所以实际上数组要开到
参考代码
By HaneDaniko 2024/2/28.
#define tol (i*2)
#define tor (i*2+1)
#define mid ((l+r)/2)
long long n;
long long ls[10000001];
struct line{ //储存矩形左右边
long long l,r;
long long len;
int type;
bool operator<(const line& A)const{
return len<A.len;
}
}l[20000001];
struct tree{
int l,r; //线段树
int cover;
long long len;
}t[40000001];
void build(int i,int l,int r){
t[i].l=l;
t[i].r=r; //建树很朴素,不需要赋任何输入值
if(l==r){
return;
}
build(tol,l,mid);
build(tor,mid+1,r);
}
void update(int i){
if(t[i].cover){ //返回更新
t[i].len=ls[t[i].r+1]-ls[t[i].l];
}
else{
t[i].len=t[tol].len+t[tor].len;
}
}
void change(int i, long long l, long long r, int m){
if(ls[t[i].l]>=r||ls[t[i].r+1]<=l){
return; //区间修改
}
if(ls[t[i].l]>=l&&ls[t[i].r+1]<=r){
t[i].cover+=m;
update(i); //假如被完全覆盖
return;
}
change(tol,l,r,m); //否则为子区间的贡献和
change(tor,l,r,m);
update(i);
}
int main(){
cin>>n;
for(int i=1;i<=n;i++){
long long x1,y1,x2,y2;
cin>>x1>>y1>>x2>>y2;
ls[i*2-1]=x1;
ls[i*2]=x2;
l[i*2-1]=line{x1,x2,y1,1};
l[i*2]=line{x1,x2,y2,-1};
}
n*=2;
sort(l+1,l+n+1);
sort(ls+1,ls+n+1);
int tail=unique(ls+1,ls+n+1)-(ls+1);
build(1,1,tail-1);
long long ans=0;
for(int i=1;i<n;i++){
change(1,l[i].l,l[i].r,l[i].type);
ans+=t[1].len*(l[i+1].len-l[i].len);
}
cout<<ans;
return 0;
}
上述通过了一道例题来深入剖析了扫描线算法的基本流程. 当坐标为浮点数时,也可以通过离散化使用扫描线解决. 这里使用到的线段树是基础线段树,因此理论上树状数组也可以做,但因为涉及区间修改区间查询,因此写树状数组实际上不如线段树.
扫描线题的变种很多,但通常都万变不离其宗,需要维护特定的内容来表示当前区间的贡献情况. 比如下述例题:
给出
个平行于坐标轴的矩形的坐标,求它们覆盖区域的周长.
16.树链剖分
16.1 讲解
重儿子:一个节点的子树大小最大的儿子
轻儿子:非重儿子
重链:节点 -> 重儿子 -> 重儿子 .. 这样的链
A beautiful Tree
蓝线为重链
可以发现,树上的所有节点一定属于且仅属于一个重链
首先要知道如何找重链
这很简单,可以通过一遍 DFS 得到:
点击查看代码
void dfs(int now){
size[now]=1;
int maxsonsize=0;
for(i:遍历所有子节点){
dfs(i)
if(size[i]>maxsonsize){
maxson[now]=i;
maxsonsize=size[i]
}
size[now]+=size[i]
}
}
其中 size
是节点的子树大小
为什么一定要剖出重链来?因为我们要进行的是在链上跳跃的操作,而我们可以跳跃的范围是一整条链,因此链越长,对复杂度就越有利,而且我们将不同的重链剖出来,还能保证每一个节点都在一条重链上,不重不漏
找出重儿子以后怎么找重链呢
这个就更简单了,我们再做一遍 DFS,记录每个链顶端的节点,然后将其赋给链中的每一个节点(或者你在这里开个 cnt 也是可以的,只要能起到区分的作用就行),这样,值相同的节点就一定在同一条链里了
点击查看代码
void dfs2(int now,int topnode){
top[now]=topnode;
if(maxson[now]==0) return; //没有儿子就返回
dfs2(maxson[now],top_node) //搜索重儿子,此时不改变链
for(i:遍历子节点){
if(i!=maxson[now]){
dfs2(i,i); //轻儿子的重链顶端就是这个轻儿子,可以看上面的图
} //如果你在这里写 cnt 的话就是 ++cnt
}
}
实际上我们还需要在这两遍 DFS 中维护一些信息,具体的信息列在下面:
DFS1
- 节点父亲
- 节点深度
- 节点子树大小
- 节点的重儿子编号
DFS2
- 构建链
- 按遍历顺序为每个节点分配新编号
- 将原节点权值迁移到新编号
可以写出下面两个代码:
点击查看代码
void dfs1(int now,int last){
fa[now]=last;
deep[now]=deep[last]+1;
size[now]=1;
int maxsonsize=0;
for(int i:e[now]){
if(i!=last){
dfs1(i,now);
if(size[i]>maxsonsize){
maxson[now]=i;
maxsonsize=size[i];
}
size[now]+=size[i];
}
}
}
void dfs2(int now,int nowtop,int last){
id[now]=++cnt;
wnew[id[now]]=w[now];
top[now]=nowtop;
if(!maxson[now]) return;
dfs2(maxson[now],nowtop,now);
for(int i:e[now]){
if(i!=last and i!=maxson[now]){
dfs2(i,i,now);
}
}
}
这里我们给每个节点都分配了新的编号,那么有什么用吗
因为我们这么分配编号有两个非常好的性质
-
同一个重链上的点,编号总是连续的,并且上面的节点编号总是比下面的节点编号要小
-
同一个子树中的点,编号是一个连续区间,并且这个区间总是
( 是子树根节点)
但是需要注意的是,为了实现这两个非常好的性质,我们需要在 DFS2 中优先遍历重儿子,因为重儿子和当前节点在一条链中,只有优先遍历了重儿子,才能保证按遍历顺序分配的编号是连续的
那么有了这两个非常好的性质,我们可以干什么呢
- 查询路径信息
假如有一道题让我们查询树上
那么我们可以考虑这样降低复杂度:
- 如果
不在一条链上,将其中链顶深度较小的那个节点跳到它所在的链顶,同时统计该节点到其顶端的答案 - 重复如上操作,直到
在一条链上 - 此时直接统计即可
以上操作中,由于我们只在同一条链上跳,因此编号总是连续的,所以可以用数据结构来维护
下面是一份线段树维护的查询
点击查看代码
int ask_path_sum(int x,int y){
int res=0;
while(top[x]!=top[y]){
if(deep[top[x]]<deep[top[y]]) swap(x,y);
res+=ask_sum(1,id[top[x]],id[x]);
x=fa[top[x]];
}
if(deep[x]<deep[y]) swap(x,y);
res+=ask_sum(1,id[y],id[x]);
return res;
}
路径修改同理
然后考虑怎么用第二个性质
第二个性质也非常好,可以用来作子树整体修改/查询
点击查看代码
int ask_subtree(int x){
return stree::ask_sum(1,id[x],id[x]+size[x]-1);
}
同样,树链剖分还能用于求 LCA,常数比倍增法略小
点击查看代码
inline int lca(int x,int y){
while(top[x]!=top[y]){
if(deep[top[x]]<deep[top[y]]) swap(x,y);
x=fa[0][top[x]];
}
return deep[x]<deep[y]?x:y;
}
16.2 例题
16.2.1 树的统计
- 单点修改
- 路径和查询
- 路径最值查询
这两个信息都能用线段树来维护
单点修改总是简单的,直接在线段树上定位即可
点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
int deep[200001],fa[200001],size[200001],maxson[200001];
vector<int>e[200001];
void dfs1(int now,int last){
fa[now]=last;
deep[now]=deep[last]+1;
size[now]=1;
int maxsonsize=0;
for(int i:e[now]){
if(i!=last){
dfs1(i,now);
if(size[i]>maxsonsize){
maxson[now]=i;
maxsonsize=size[i];
}
size[now]+=size[i];
}
}
}
int w[200001];
int id[200001],top[200001],wnew[200001];
int cnt=0;
void dfs2(int now,int nowtop,int last){
id[now]=++cnt;
wnew[id[now]]=w[now];
top[now]=nowtop;
if(!maxson[now]) return;
dfs2(maxson[now],nowtop,now);
for(int i:e[now]){
if(i!=last and i!=maxson[now]){
dfs2(i,i,now);
}
}
}
namespace stree{
struct tree{
int l,r;
int sum,max;
}t[800001];
#define tol (id*2)
#define tor (id*2+1)
#define mid(l,r) mid=((l)+(r))/2
void build(int id,int l,int r){
t[id].l=l;t[id].r=r;
if(l==r){
t[id].sum=wnew[l];
t[id].max=wnew[l];
return;
}
int mid(l,r);
build(tol,l,mid);
build(tor,mid+1,r);
t[id].sum=(t[tol].sum+t[tor].sum);
t[id].max=max(t[tol].max,t[tor].max);
}
int ask_sum(int id,int l,int r){
if(l>r) swap(l,r);
if(l<=t[id].l and t[id].r<=r){
return t[id].sum;
}
pushdown(id);
if(r<=t[tol].r) return ask_sum(tol,l,r);
else if(l>=t[tor].l) return ask_sum(tor,l,r);
else{
return (ask_sum(tol,l,t[tol].r)+ask_sum(tor,t[tor].l,r));
}
}
int ask_max(int id,int l,int r){
if(l>r) swap(l,r);
if(l<=t[id].l and t[id].r<=r){
return t[id].max;
}
pushdown(id);
if(r<=t[tol].r) return ask_max(tol,l,r);
else if(l>=t[tor].l) return ask_max(tor,l,r);
else{
return max(ask_max(tol,l,t[tol].r),ask_max(tor,t[tor].l,r));
}
}
}
int ask_path_max(int x,int y){
int res=-1;
while(top[x]!=top[y]){
if(deep[top[x]]<deep[top[y]]) swap(x,y);
res=max(res,stree::ask_max(1,id[top[x]],id[x]));
x=fa[top[x]];
}
if(deep[x]<deep[y]) swap(x,y);
res=max(res,stree::ask_max(1,id[y],id[x]));
return res;
}
int ask_path_sum(int x,int y){
int res=0;
while(top[x]!=top[y]){
if(deep[top[x]]<deep[top[y]]) swap(x,y);
res+=stree::ask_sum(1,id[top[x]],id[x]);
x=fa[top[x]];
}
if(deep[x]<deep[y]) swap(x,y);
res+=stree::ask_sum(1,id[y],id[x]);
return res;
}
int n,m;
signed main(){
scanf("%lld",&n);
for(int i=1;i<=n-1;++i){
int x,y;
scanf("%lld %lld",&x,&y);
e[x].push_back(y);
e[y].push_back(x);
}
for(int i=1;i<=n;++i){
scanf("%lld",&w[i]);
}
scanf("%lld",&m);
dfs1(1,0);
dfs2(1,1,0);
stree::build(1,1,n);
while(m--){
string op;int x,y,z;cin>>op;
if(op[0]=='C'){
scanf("%lld %lld",&x,&z);
stree::change(1,id[x],id[x],z-stree::ask_sum(1,id[x],id[x]));
}
if(op[0]=='Q' and op[1]=='M'){
scanf("%lld %lld",&x,&y);
printf("%lld\n",ask_path_max(x,y));
}
if(op[0]=='Q' and op[1]=='S'){
scanf("%lld %lld",&x,&y);
printf("%lld\n",ask_path_sum(x,y));
}
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!