最短路算法及模板
最短路算法及模板
1. 最短路算法概念及解决的问题
所谓最短路问题,实际上就是求从一个节点开始到另外一个节点结束的最短路径。
这里面需要解释一些词汇:
1. 源点<=>起点
2. 汇点<=>终点
3. n表示图内顶点的数量
4. m表示图内边的数量
2. 最短路算法的类型
以上的算法在不同的情况下,有着不同的应用。要根据具体问题,选择合适的算法。
1. 如果图是稠密图(边很多,点很少)且边权都是正数,那么用朴素Dijkstra即可。
2. 如果图是稀疏图(边很少)且边权都是正数,那么用堆优化Dijkstra即可。
3. 如果存在负权边,一般而言用SPFA算法即可。
4. 如果存在负权边,且对边数存在限制(例如:求某一个点到其余所有点且边数不超过k的最短路径),那么用Bellman-Ford算法。
5. 如果是多源汇最短路,用Floyd算法即可。
接下来,依次对以上算法进行讲解。
3. 朴素Dijkstra算法思想
朴素Dijkstra算法的目标是求:1号点到其余所有点的最短路径(距离)。
朴素Dijkstra算法的执行思想如下:
1. 初始化距离
其中:dist[1] = 0
代表起点到起点的最短距离为0。
dist[i] = +∞
代表除起点之外的其余点到起点的距离为正无穷。
2. 对顶点(1-n)进行for循环,在循环的过程中我们要做如下操作:
2.1 取出不在s中的距离最近的顶点t。其中,s集合代表当前已确定最短距离的点。
2.2 将顶点t纳入到s中
2.3 用t来更新其它点x的距离。具体的更新思路就是:如果起点经过t中转后到达x的距离比起点直接到达x的距离要更近,那么就更新距离,否则不更新距离。
用代码表示:
If dist[x] > dist[t] + w。
dist[x] = dist[t] + w。
其中,w代表t->x这条边的权重。
根据上述的过程,我们可以发现:每一次循环,都可以确定一个点的最短距离。因此,循环n次就可以得到起点到所有点的最短距离。
4. 朴素Dijkstra算法举例
红颜色代表还没有确定最短距离的点。
绿颜色代表已经确定最短距离的点。
至此,算法结束。
5. 朴素Dijkstra算法模板
int g[N][N]; // 存储每条边
int dist[N]; // 存储1号点到每个点的最短距离
bool st[N]; // 存储每个点的最短路是否已经确定
// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n - 1; i ++ )
{
int t = -1; // 在还未确定最短路的点中,寻找距离最小的点
for (int j = 1; j <= n; j ++ )
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
// 用t更新其他点的距离
for (int j = 1; j <= n; j ++ )
dist[j] = min(dist[j], dist[t] + g[t][j]);
st[t] = true;
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
6. 朴素Dijkstra算法例题
https://www.acwing.com/activity/content/problem/content/918/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n,m;
const int N = 510;
//稠密图,边很多,建议邻接矩阵来存储
int g[N][N];
//st数组代表某个点是否已经确定了最短距离
bool st[N];
//dist数组代表代表顶点到起点的最短距离
int dist[N];
int dijkstra(){
//初始化最短距离为正无穷
memset(dist,0x3f,sizeof(dist));
//将起点到起点的最短距离初始化成0
dist[1] = 0;
for(int i=1;i<=n;i++){
//t代表当前距离最短的点
int t = -1;
//在还没有确定最短距离的点中,找到距离最短的点
for(int j=1;j<=n;j++){
if(!st[j] && (t == -1 || dist[t] > dist[j])){
t = j;
}
}
//代表点t已经确定了最短距离
st[t] = true;
//用当前已经确定了最短距离的点t来更新其他点的最短距离
for(int j=1;j<=n;j++){
dist[j] = min(dist[j],dist[t] + g[t][j]);
}
}
//代表起点1和终点n不连通
//换句话说,路径不存在
if(dist[n] == 0x3f3f3f3f){
return -1;
}
return dist[n];
}
int main(){
scanf("%d%d",&n,&m);
//初始化图的权重初始都为正无穷
memset(g,0x3f,sizeof(g));
for(int i=0;i<m;i++){
int a,b,c;
scanf("%d%d%d",&a,&b,&c);
//对于重边,我们只需要保留权重最小的边即可。
//对于自环,在求最短距离时可以忽略。
g[a][b] = min(g[a][b],c);
}
int res = dijkstra();
printf("%d",res);
return 0;
}
7. 堆优化dijkstra算法思想
堆优化dijkstra算法的优化思路:
在上述朴素dijkstra算法中,最花费时间的步骤实际上是找到不在s中的距离最近的点。因此,堆优化dijkstra算法主要在这一步进行了优化。由于我们要找到不在s中的距离最近的点。因此,我们可以用小根堆来进行处理。这样的话,时间复杂度就得到了改善:O(1),由于循环n次,因此这一步就是n次。但是,为了维护小根堆,每次用t来更新其它点的距离时,我们也需要对堆来进行调整。每次调整堆的时间复杂度是O(log2n),更新距离为m次,因此总的时间复杂度为O(mlog2n)。具体请见上图。
注意:在这个算法中,堆可以采用两种方式:
1. 手写堆
2. c++的优先级队列
在这里,我们采用优先级队列的写法。时间复杂度仍然是O(mlog2n)。
堆优化dijkstra算法的执行流程跟朴素dijkstra算法类似,具体改变的地方可以见上述内容,这里不再赘述。
8. 堆优化dijkstra算法模板
typedef pair<int, int> PII;
int n; // 点的数量
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储所有点到1号点的距离
bool st[N]; // 存储每个点的最短距离是否已确定
// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1}); // first存储距离,second存储节点编号
while (heap.size())
{
auto t = heap.top();
heap.pop();
int ver = t.second, distance = t.first;
if (st[ver]) continue;
st[ver] = true;
for (int i = h[ver]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > distance + w[i])
{
dist[j] = distance + w[i];
heap.push({dist[j], j});
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
9. 堆优化dijkstra算法例题
https://www.acwing.com/problem/content/852/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <queue>
using namespace std;
const int N = 1000010;
typedef pair<int,int> PII;
int h[N],e[N],ne[N],w[N],idx=0;
int n,m;
bool st[N];
int dist[N];
priority_queue<PII,vector<PII>,greater<PII>> heap;
void add(int a,int b,int c){
e[idx] = b;
w[idx] = c;
ne[idx] = h[a];
h[a] = idx++;
}
//堆里存储pair,第一个参数代表距离,第二个参数代表点
int dijkstra(){
dist[1] = 0;
heap.push({dist[1],1});
while(heap.size()){
PII t = heap.top();
heap.pop();
//取出节点和距离
int ver = t.second;
int val = t.first;
//如果该节点已经确定了最短距离,那么就不需要后续的操作
if(st[ver]){
continue;
}
//代表该节点确定了最短距离
st[ver] = true;
//更新与该节点邻接的其余点距离
for(int i=h[ver];i!=-1;i=ne[i]){
//j代表该节点的邻接节点
int j = e[i];
if(dist[j] > val + w[i]){
dist[j] = val + w[i];
heap.push({dist[j],j});
}
}
}
if(dist[n] == 0x3f3f3f3f){
return -1;
}
return dist[n];
}
int main(){
scanf("%d%d",&n,&m);
memset(dist,0x3f,sizeof(dist));
memset(h,-1,sizeof(h));
for(int i=0;i<m;i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
printf("%d",dijkstra());
return 0;
}
10. Bellman-Ford算法思想
Bellman-Ford算法的执行流程如下:
1. 遍历所有点
2. 在遍历所有点的基础上,遍历所有边,同时更新距离。更新距离的方式跟dijkstra算法类似。
dist[b] = min(dist[b],dist[a] + w);
如果原点到b的距离比原点到a的距离+a到b的距离要更远,那么就更新为:dist[b] = dist[a] + w; 否则不变即可。
经过证明发现:当Bellman-Ford算法循环完n次后,对于图中的所有边一定满足如下不等式:
dist[b] <= dist[a] + w;
上述的不等式也称为三角不等式。
注意:如果图中存在负权回路,那么从1号点到n号点的最短距离不一定存在。因此,如果某一个图中的每一个点均存在最短距离,那么这个图在一般情况下是不会存在负权回路的。
Bellman-Ford算法是有其实际意义的。如果外层循环循环了k次,那么内层循环中的距离的实际意义就是:从1号点经过不超过k条边到达所有节点的最短距离。
Bellman-Ford算法是可以求出某一个图中是否存在负权回路的。证明如下:
假设外层循环循环到了第n次,导致了内层循环的距离更新。那么就代表求出了从1号点经过不超过n条边到达所有节点的最短距离。而n条边需要n+1的点,而点只有n个。根据抽屉原理,至少有两个点是相等的。因此,图中存在负权回路。(了解即可)
Bellman-Ford算法适合求有边数限制的最短路。并且,在图中可以存在负权回路。但是spfa算法不允许图中存在负权回路。
Bellman-Ford算法的串联问题以及解决方式:
给定上述的图,我们要求从原点经过不超过1条边到达3号节点的最短距离。那么,根据上图我们很容易得出答案是3而不是2。因为只能经过一条边。
我们假设初始情况就是上述的第一张图。当我们遍历第一条边(1->2)时,上述的数组就变成了:
1 2 3
dist 0 1 +∞
当我们遍历第二条边(2->3)时,根据上述的更新距离公式,距离就会变成:
1 2 3
dist 0 1 2
这样显然是不对的。因为我们只允许经过一条边。这样的话,就不满足Bellman-Ford算法的要求了。
因此,为了解决上述问题,我们引入备份数组。
当我们遍历第二条边时,我们提前存入backup数组,backup数组存储的是上一次循环的更新情况。而不是这一次循环的更新情况。
上一次循环更新情况的backup数组内容:
1 2 3
backup 0 +∞ +∞
这一次循环更新情况的dist数组内容:
1 2 3
dist 0 1 +∞
当我们更新距离的时候,我们只用backup数组来进行更新,而不用这次循环更新的dist数组,这样的话就避免了串联问题。即,
当遍历第一条边时,
dist[2] = min(dist[2],backup[1] + w) 其中,w代表1->2这条边的权重1。
根据backup数组,dist[2] = +∞ backup[1] = 0。因此dist[2] = 1;
当遍历第二条边时,
dist[3] = min(dist[3],backup[2] + w) 其中,w代表2->3这条边的权重1。
根据backup数组,dist[3] = +∞ backup[2] = +∞,因此dist[3]就是+∞。
当遍历第三条边时,我们同样只用backup数组来进行更新,而不用这次循环更新的dist数组。即,
dist[3] = min(dist[3],backup[1] + w) 其中,w代表1->3这条边的权重3
根据backup数组,dist[3] = +∞,backup[1] = 0 w代表3。因此,dist[3] = 3;
这样的话,就解决了串联问题。最终的dist数组:
1 2 3
dist 0 1 3
11. Bellman-Ford算法模板
int n, m; // n表示点数,m表示边数
int dist[N]; // dist[x]存储1到x的最短路距离
int backup[N];
struct Edge // 边,a表示出点,b表示入点,w表示边的权重
{
int a, b, w;
}edges[M];
// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
void bellman_ford()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n; i ++ )
{
memcpy(backup,dist,sizeof(dist));
for (int j = 0; j < m; j ++ )
{
int a = edges[j].a, b = edges[j].b, w = edges[j].w;
if (dist[b] > dist[a] + w)
dist[b] = dist[a] + w;
}
}
}
int main(){
...
bellman_ford();
//由于图中会存在负权边,因此在遍历边的过程中可能会将0x3f3f3f3f进行缩小,因此不能使用dist[n] == 0x3f3f3f3f。
//从1节点找不到n节点的最短路径
if(dist[n] > 0x3f3f3f3f / 2){
...
}else{
printf("%d",dist[n]);
}
return 0;
}
12. Bellman-Ford算法例题
https://www.acwing.com/problem/content/855/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 510;
const int M = 10010;
int n,m,k;
//存储边的信息
struct{
int a;
int b;
int w;
}Edge[M];
int dist[N],backup[N];
void bellman_ford(){
memset(dist,0x3f,sizeof(dist));
dist[1] = 0;
for(int i=1;i<=k;i++){
//进行备份
memcpy(backup,dist,sizeof(dist));
for(int j=1;j<=m;j++){
int start = Edge[j].a;
int end = Edge[j].b;
int val = Edge[j].w;
dist[end] = min(dist[end],backup[start] + val);
}
}
}
int main(){
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=m;i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
Edge[i].a = x;
Edge[i].b = y;
Edge[i].w = z;
}
bellman_ford();
if(dist[n] > 0x3f3f3f3f / 2){
printf("impossible");
}else{
printf("%d",dist[n]);
}
return 0;
}
13. spfa算法思想
spfa算法是Bellman-Ford算法的优化。由于Bellman-Ford算法会遍历每一条边来计算最短路。但是,在遍历每一条边的过程中,有时候dist[b]的值并不会改变。在什么时候,dist[b]的值会进行改变呢?根据上述公式:
dist[b] = min(dist[b],dist[a] + w);
我们发现,只有dist[a]变小时,dist[b]的值才会改变(变小)。因此,spfa算法就是在这种情况下进行优化。
spfa算法主要采用队列来对Bellman-Ford算法进行优化。spfa算法的步骤如下:
1. 首先将起点加入队列。
2. while 队列不空
3. 从队列中取出一个点
4. 将该点从队列中删除
5. 更新该点的所有出边,更新方式跟Bellman-Ford算法类似。
6. 如果该点的出边更新成功,那么就将该点出边所邻接的点加入队列。
7. 持续上述过程,直到队列空为止。
spfa算法优化的核心思路就在于:spfa算法并不是遍历每一条边,只有源点到某一个点的最短路更新时,我们才需要遍历该点所邻接的边进行更新。如果源点到某一个点的最短路没有更新的话,那么该点所邻接的边也不会进行更新。因此,spfa算法对所遍历的边进行了优化,时间复杂度更小了。
需要注意的是,有些正权图也可以使用spfa算法来处理。spfa算法不存在bellman-ford算法的串联问题。spfa算法在处理正权图时,效率比dijkstra算法要高,但是很容易被卡掉。spfa算法还可以去判负环。在接下来的部分进行进一步的讲解。
14. spfa算法模板
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储每个点到1号点的最短距离
bool st[N]; // 存储每个点是否在队列中
// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue<int> q;
q.push(1);
st[1] = true;
while (q.size())
{
auto t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
if (!st[j]) // 如果队列中已存在j,则不需要将j重复插入
{
q.push(j);
st[j] = true;
}
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
15. spfa算法例题
https://www.acwing.com/problem/content/853/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <queue>
using namespace std;
const int N = 1000010;
int h[N],e[N],ne[N],w[N],idx = 0;
int n,m;
//st代表当前元素是否在队列中
int dist[N];
bool st[N];
void add(int a,int b,int c){
e[idx] = b;
w[idx] = c;
ne[idx] = h[a];
h[a] = idx++;
}
int spfa(){
memset(dist,0x3f,sizeof(dist));
queue<int> q;
dist[1] = 0;
q.push(1);
st[1] = true;
while(q.size()){
int t = q.front();
q.pop();
st[t] = false;
for(int i = h[t];i!=-1;i=ne[i]){
int j = e[i];
if(dist[j] > dist[t] + w[i]){
dist[j] = dist[t] + w[i];
//如果j已经在队列里,那么就不需要重复更新。
if(!st[j]){
//更新之后入队列,队列中的元素都是待更新边的元素
q.push(j);
//代表当前元素已在队列中
st[j] = true;
}
}
}
}
return dist[n];
}
int main(){
memset(h,-1,sizeof(h));
scanf("%d%d",&n,&m);
for(int i=0;i<m;i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
int res = spfa();
if(res == 0x3f3f3f3f){
printf("impossible");
}else{
printf("%d",res);
}
return 0;
}
16. spfa算法判断负环思想
spfa算法还可以用来判断负环。具体步骤如下:
首先我们需要解释以下两个数组:
1. dist[x] 表示当前情况下,从1号点到x号点的最短距离。
2. cnt[x] 表示当前情况下,从1号点到x号点所走的边数。
那么,当距离更新时,边数也需要进行更新。即:
If dist[x] > dist[t] + w[i]
dist[x] = dist[t] + w[i];
cnt[x] = cnt[t] + 1;
具体可参照上图。
spfa判断负环的思路就是:如果在求解最短路径的过程中,发现cnt[x] >= n。这句话的意思就是从1号点到x号点的边数大于等于n。大于等于n的边需要大于等于n+1个节点,而节点数只有n个。根据抽屉原理,至少两个节点是相等的。因此,图中存在环路,且一定是负环。这个过程跟Bellman-Ford算法判断负环的过程是一样的。只不过spfa算法判断负环的时间复杂度要更低。
17. spfa算法判断负环模板
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N], cnt[N]; // dist[x]存储1号点到x的最短距离,cnt[x]存储1到x的最短路中经过的点数
bool st[N]; // 存储每个点是否在队列中
// 如果存在负环,则返回true,否则返回false。
bool spfa()
{
// 不需要初始化dist数组
// 原理:如果某条最短路径上有n个点(除了自己),那么加上自己之后一共有n+1个点,由抽屉原理一定有两个点相同,所以存在环。
queue<int> q;
//将所有点均加入到队列中,这样就可以找到这个图中的负环,而不是从某一个点开始的负环。
for (int i = 1; i <= n; i ++ )
{
q.push(i);
st[i] = true;
}
while (q.size())
{
auto t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
cnt[j] = cnt[t] + 1;
if (cnt[j] >= n) return true; // 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环
if (!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
18. spfa算法判断负环例题
https://www.acwing.com/activity/content/problem/content/921/
#include <iostream>
#include <cstdio>
#include <queue>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 100010;
int h[N],ne[N],e[N],w[N],idx = 0;
//cnt代表存储边数
int dist[N],cnt[N];
bool st[N];
int n,m;
void add(int x,int y,int z){
e[idx] = y;
w[idx] = z;
ne[idx] = h[x];
h[x] = idx++;
}
bool spfa(){
memset(dist,0x3f,sizeof(dist));
queue<int> queue;
for(int i=1;i<=n;i++){
queue.push(i);
st[i] = true;
}
while(queue.size()){
int t = queue.front();
queue.pop();
st[t] = false;
for(int i=h[t];i!=-1;i=ne[i]){
int j = e[i];
if(dist[j] > dist[t] + w[i]){
dist[j] = dist[t] + w[i];
cnt[j] = cnt[t] + 1;
if(cnt[j] >= n){
return true;
}
if(!st[j]){
queue.push(j);
st[j] = true;
}
}
}
}
return false;
}
int main(){
scanf("%d%d",&n,&m);
memset(h,-1,sizeof(h));
for(int i=0;i<m;i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
if(spfa()){
printf("Yes");
}else{
printf("No");
}
return 0;
}
19. Floyd算法思想
Floyd算法主要用来解决多源汇最短路问题。它采用邻接矩阵的形式把图存储下来。换句话说,就是用邻接矩阵来存储所有的边。例如,d[i,j]存储的就是从i到j的权重为d[i,j]的边。当Floyd算法执行完毕之后,d[i,j]就代表从i到j的最短路径。Floyd算法是基于动态规划的,有关动态规划的相关问题我们在动态规划这一节中进行详细讲解。
Floyd算法允许图中有负权边,但是不允许图中有负权回路。
20. Floyd算法模板
//初始化: 自己到自己的最短距离为0,其余为无穷大
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
if (i == j) d[i][j] = 0;
else d[i][j] = INF;
// 算法结束后,d[a][b]表示a到b的最短距离
void floyd()
{
for (int k = 1; k <= n; k ++ )
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
21. Floyd算法例题
https://www.acwing.com/problem/content/856/
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
const int N = 210;
const int INF = 0x3f3f3f3f;
int n,m,Q;
int d[N][N];
void floyd(){
for(int k=1;k<=n;k++){
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
d[i][j] = min(d[i][j],d[i][k] + d[k][j]);
}
}
}
}
int main(){
scanf("%d%d%d",&n,&m,&Q);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(i == j){
d[i][j] = 0;
}else{
d[i][j] = INF;
}
}
}
for(int i=1;i<=m;i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
if(x != y){
d[x][y] = min(d[x][y],z);
}
}
floyd();
while(Q--){
int x,y;
scanf("%d%d",&x,&y);
if(d[x][y] > INF/2){
printf("impossible\n");
}else{
printf("%d\n",d[x][y]);
}
}
return 0;
}
作者:gao79138
链接:https://www.acwing.com/
来源:本博客中的截图、代码模板及题目地址均来自于Acwing。其余内容均为作者原创。
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现