搜索及其优化详解
深度优先搜索(DFS)
深度优先搜索的思想是从一个节点出发,不停地向深处访问它的子节点,直到它的子节点除了它的父亲外没有别的节点与之相连,那么这时就回溯,退到它的父亲节点,继续上一步的操作。
所以,深度优先搜索是一个递归的过程。
理解了深度优先搜索的思想,现在来看看它的代码是如何实现的:
void dfs (int x) {
cout << x << endl; //输出当前遍历到的点编号
vis[x] = 1; //标记这个点已经被走过
for (int i = 1; i <= n; i ++)
if (!vis[i] && (a[x][i] || a[i][x])) //邻接矩阵
dfs(i);
}
如果你还不懂,可以写一写这个程序,然后在跑一遍自己写的数据,慢慢理解qwq
所以现在来做一道水题:数独
很显然,用深搜可以暴力写出这道题,但是具体要怎么搜呢?
假设已经搜到了这个位置,如果这个点上已经有一个数字了,那就可以跳过这个点去搜下一个点;
如果这个点上没有数字,可以枚举到这些数字,判断这些数字是否可以放进去,然后再搜下一个点。
大体思路就如上面所述,这道题还有一些小细节。
在判断某个点是否可以放进这个数时,可以用三个数组来存每行,每列或每的方阵是否已经有这个数字。
还有在搜索完一遍之后,要把未知的格子上的数字以及之前三个数组重置,因为这个数字不一定是正解,要搜索的不一定是这个数。
如果已经搜到了的这个位置,那这个肯定就是答案了,就可以直接输出然后退出程序。
代码:
#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;
int a[10][10], h[10][10], l[10][10], c[10][10];
void print () {
for (int i = 1; i <= 9; i ++) {
for (int j = 1; j <= 9; j ++)
printf("%d ", a[i][j]);
printf("\n");
}
exit(0); //退出程序
}
void dfs (int x, int y) {
if (!a[x][y]) {
for (int i = 1; i <= 9; i ++) {
if (!h[x][i] && !l[y][i] && !c[(x - 1) / 3 * 3 + (y - 1) / 3 + 1][i]) {
a[x][y] = i;
h[x][i] = 1;
l[y][i] = 1;
c[(x - 1) / 3 * 3 + (y - 1) / 3 + 1][i] = 1;
if (x == 9 && y == 9) print();
if (y != 9) dfs(x, y + 1);
else dfs(x + 1, 1);
a[x][y] = 0;
h[x][i] = 0;
l[y][i] = 0;
c[(x - 1) / 3 * 3 + (y - 1) / 3 + 1][i] = 0;
}
}
}else {
if (x == 9 && y == 9) print();
if (y != 9) dfs(x, y + 1);
else dfs(x + 1, 1);
}
}
int main () {
for (int i = 1; i <= 9; i ++)
for (int j = 1; j <= 9; j ++) {
scanf("%d", &a[i][j]);
if (a[i][j] != 0) {
h[i][a[i][j]] = 1;
l[j][a[i][j]] = 1;
c[(i - 1) / 3 * 3 + (j - 1) / 3 + 1][a[i][j]] = 1;
}
}
dfs(1, 1);
return 0;
}
剪枝
- 优化搜索顺序
在一些搜索问题中,搜索树的各个层次、各个分支之间的顺序是不固定的。
改变搜索顺序会产生不同的搜索树形态,其规模也相差甚远。
例如刚才做的题目数独,就只选择合法的数字进行搜索,那些不合法的数字直接跳过。
- 排除等效冗余
在搜索的过程中,如果发现几个不同分支的搜索最后的结果是相同的,那就可以只选其中的一条来搜索。
- 可行性剪枝
在搜索的过程中,如果发现这条搜索分支不能到达递归边界,那就应该立刻回溯。
- 最优性剪枝
如果当前的搜索分支花费的代价已经大于我们已经有的最优解,那这时也应该回溯。
- 记忆化
可以记录每个搜索分支的状态,如果当先分支已经被搜索过,就可以回溯。就好比在深度优先遍历一张图的时候,如果当前节点已经被访问过,就可以跳过这个点。
看完上面这几个剪枝方法,大家肯定还是不太明白的!所以做几道题来理解以上几种方法:
剪枝题解
小猫爬山:
题目要求的是最小支付的美元,显然可以从最小的答案开始循环到最大的答案。
一旦找到一个答案符合条件,说明之前的更小的答案是不符合的,那么当前的答案就是最优的答案,这时就可以退出循环了。
那么怎么dfs呢?
从第一只小猫开始,用当前dfs的答案的数量的每个缆车(假设我们现在dfs的答案是,那最多就只能用个缆车)去装小猫。
如果最后把所有小猫都装下去了,就说明当前答案可行。
结果发现竟然会TLE,当然是因为没有剪枝啦!
我们知道每个缆车至少都可以装下一只小猫(缆车的承受重量大于任何一只小猫的重量),所以第只小猫肯定是能在前个缆车里装下的。因此在装第只小猫的时候只能选择前个缆车来装。
最后就可以AC啦qwq!!
代码:
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
int n, w, c[19], sum, ans, m[19], flag;
void dfs (int x) {
if (x == n + 1) {
flag = 1;
return;
}
for (int i = 1; i <= ans && i <= x; i ++) {
if (m[i] + c[x] <= w) {
m[i] += c[x];
dfs(x + 1);
m[i] -= c[x];
if (flag) return;
}
}
}
int main () {
scanf("%d%d", &n, &w);
for (int i = 1; i <= n; i ++) {
scanf("%d", &c[i]);
sum += c[i];
}
for (ans = sum / w; ans <= 18; ans ++) {
dfs(1);
if (flag) break;
}
printf("%d", ans);
return 0;
}
小木棍:
注意要把给出的长度超过的木棒先删掉!!!
从给出的数据中,可以很快确定答案的范围,最小值是所有小木棒中最长的一根,最大值是所有小木棒的总和(也就是说这些小木棒只由一根木棒拆成)。
接下来就可以从小的答案开始dfs了。
dfs的思路是:先循环答案范围内的目标长度,之后我们可以由所有小木棒的总长得出一共可以拼成根木棍。
之后就从第一根小木棍开始与其他小木棍拼接,如果拼接后的长度正好等于,那就开始拼剩下的根木棍了。
如果最后拼出了所有根木棍且没有剩余的小木棍,那么当前的答案可行。
而且由于是从小的答案开始遍历,所以这个答案肯定是最优的答案,这时就可以输出结果了。
但是很显然,没有剪枝会TLE...qwq
从头开始说起,显然,如果一个答案符合的话,那么一定是它的倍数。
那在选择dfs的答案时就可以剪去很多不可能的答案了!
我们知道越短的小木棍需要拼越多次才可能拼成一个长度为的木棍。
但是如果是从长的木棒开始拼,就少了刚才所说的麻烦,所以在dfs前,应该先把小木棍从大到小排序。
在dfs的过程中又该怎么剪枝呢?
如果,,是三个连续的木棍,且它们无法拼成想要的长度为的木棍。
首先从开始拼接,接上了之后,发现长度小于,再接上,长度还是小于,此时回溯。
由于拼过了,所以这时拼的是,我们已经知道,,所拼的方案是使用过且不符合的,那说明没有必要在拼上了后再拼上。
从而可以发现,已经拼了一部分的木棍只需要从上一根拼接的小木棍开始拼就可以了,因为前面的那些小木棍已经有别的方案去试过了,再试一次也是浪费。
因为已经把小木棍都排了序,所以小木棍中可能会有几段是连续且同样长的小木棍。
对于同样长的木棍,也没有必要每个都去试一遍,其中一个失败,意味着剩余的几个也是失败的。
对此可以定义一个变量,来存储上一次拼接的小木棍,在下次拼接的小木棍若是长度与记录的长度相同,那就跳过。
同样可以剪枝的还有在尝试拼接一个原始木棒的第一根小木棒时,如果当前答案可行,此时剩下的小木棒都可以正好拼成原始木棒。
但是要是这第一根小木棒都拼接失败,就可以直接以失败返回了。
为什么?因为这跟小木棒必然会跟其他小木棒尝试拼接,如果它们无法正好拼成原始木棒,可以知道这不是正确方案,直接回溯。
感觉讲得很繁琐,大家还是看代码慢慢理解吧:
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;
int n, m, lenght[101], l, r, vis[101], len, cnt;
bool cmp (int a, int b) {
return a > b;
}
bool dfs (int deep, int cab, int pos) {
if (deep > cnt) return true;
if (cab == len) return dfs(deep + 1, 0, 1);
int fail = 0;
for (int i = pos; i <= n; i ++)
if (!vis[i] && lenght[i] + cab <= len && lenght[i] != fail) {
vis[i] = 1;
if (dfs(deep, cab + lenght[i], i + 1)) return true;
vis[i] = 0;
fail = lenght[i];
if (!cab || cab + lenght[i] == len) return false;
}
return false;
}
int main () {
scanf("%d", &m);
for (int i = 1; i <= m; i ++) {
int x;
scanf("%d", &x);
if (x <= 50) {
lenght[++ n] = x;
l = max(lenght[n], l);
r += lenght[n];
}
}
sort(lenght + 1, lenght + n + 1, cmp);
for (len = l; len <= r; len ++)
if (r % len == 0) {
cnt = r / len;
memset(vis, 0, sizeof(vis));
if (dfs(1, 0, 1)) {
printf("%d", len);
break;
}
}
return 0;
}
切蛋糕:
相信大家都可以想出dfs的方法,所以我们话不多说,直接开始讲剪枝。
题目要求的是最多可以满足的嘴巴的个数,不妨把嘴巴的大小从小到大排序。
用贪心的思想,可以发现只有从小到大去满足嘴巴得出的答案才是最优的。
先把每块蛋糕加起来得到总和,不一定能大于最大的一张嘴巴,因此可以一个一个把最大的嘴巴减去,直到当前最大的嘴巴小于sum,这样就可以减少嘴巴的数量了。
这道题显然可以二分答案,刚才已经说过,只需满足嘴巴前小的人就可以。
如果在选择用第个蛋糕去喂一个人时,说明前个蛋糕要么太小,要么试过了这块蛋糕且失败了。
所以如果下一个人的嘴巴与这个人的嘴巴一样大,就可以直接从第个以后的蛋糕开始吃了。
由题知道蛋糕不可以合在一起,那么可能把蛋糕切了喂别人剩下的连最小的嘴巴都满足不了了,也就是说,这剩下的这块是浪费掉的。
那么要是减去浪费的比前个人的嘴巴还小,这条搜索分支就可以返回了。
因此在dfs前还需要处理好前缀和。
代码:
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
int n, m, sum, cake[101], tmp[101], mouth[1100], nex[1100], l, r, mid, waste;
bool dfs (int dep, int pos) {
if (!dep) return true;
if (sum - waste < nex[mid]) return false;
for (int i = pos; i <= n; i ++)
if (tmp[i] >= mouth[dep]) {
tmp[i] -= mouth[dep];
if (tmp[i] < mouth[1])
waste += tmp[i];
if (mouth[dep] == mouth[dep - 1]) {
if (dfs(dep - 1, i)) return true;
}else if (dfs(dep - 1, 1)) return true;
if (tmp[i] < mouth[1]) waste -= tmp[i];
tmp[i] += mouth[dep];
}
return false;
}
int main () {
scanf("%d", &n);
for (int i = 1; i <= n; i ++) {
scanf("%d", &cake[i]);
sum += cake[i];
}
scanf("%d", &m);
for (int i = 1; i <= m; i ++)
scanf("%d", &mouth[i]);
sort(mouth + 1, mouth + 1 + m);
while (sum < mouth[m]) m --;
for (int i = 1; i <= m; i ++)
nex[i] += nex[i - 1] + mouth[i];
r = m;
while (l <= r) {
waste = 0;
for (int i = 1; i <= n; i ++)
tmp[i] = cake[i];
mid = (l + r) >> 1;
if (dfs(mid, 1))
l = mid + 1;
else
r = mid - 1;
}
printf("%d", r);
return 0;
}
广度优先搜索(BFS)
同样是搜索,广度优先搜索与深度优先搜索的不同之处在于它从一个节点开始拓展。
每有一个节点与之相连,就把这个节点放入一个队列里,直到没有节点与它相连,那么这时就把队列首的节点拿出来拓展,重复之前的操作。这个过程就像是水蔓延那样。
所以广度优先搜索的代码里通常用了队列,具体实现如下:
void bfs () {
queue<int> q;
q.push(1);
while (!q.empty()) {
int x = q.front();
q.pop();
cout << x << endl; //输出当前遍历到的点编号
vis[x] = 1; //标记这个点已经被走过
for (int i = 1; i <= n; i ++)
if (!vis[i] && (a[x][i] || a[i][x])) //邻接矩阵
q.push(i);
}
}
同样来做一道题练练手:求细胞数量
这是一道很基础的广搜题目。
遍历每一个点,如果这个点是细菌的点,那就从这个点开始广搜,把与它相连的细菌的点都变成普通的点(也就是)。
每进行一次广搜答案都会加,最后输出答案就可以了。
代码:
#include <iostream>
#include <cstdio>
#include <queue>
using namespace std;
int m, n, a[101][101], ans;
char ch[101];
int dx[4] = {1, 0, -1, 0},
dy[4] = {0, 1, 0, -1};
struct P {
int x, y;
};
void bfs (int x, int y) {
queue<P> q;
q.push((P){x, y});
while (!q.empty()) {
int ux = q.front().x, uy = q.front().y;
q.pop();
a[ux][uy] = 0;
for (int i = 0; i <= 3; i ++) {
int nx = ux + dx[i], ny = uy + dy[i];
if (a[nx][ny] && nx >= 1 && nx <= m && ny >= 1 && ny <= n)
q.push((P){nx, ny});
}
}
}
int main () {
scanf("%d%d", &m, &n);
for (int i = 1; i <= m; i ++) {
scanf("%s", ch + 1);
for (int j = 1; j <= n; j ++)
a[i][j] = ch[j] - 48;
}
for (int i = 1; i <= m; i ++)
for (int j = 1; j <= n; j ++)
if (a[i][j]) {
bfs(i, j);
ans ++;
}
printf("%d", ans);
return 0;
}
广搜变形
双端队列BFS
有些图的边的权值如果对应的是或(不一定是要或,只要是只有两种可能),则适合用双端队列BFS解决。
很显然走权值为的边比权值为的边更优,所以如果是从由走边得到的点先走这张图,得到的结果一般会更优。
那不妨把由走边得到的点放入队首,其他的点放入队尾。
在BFS时,弹出队首的元素,因为我们把由走边得到的点优先放入队首,所以每次走的路都是最优的。
这样我们每个点都只会走一次,时间复杂度为。
例题:维修电路
题解:
可以把每个方格的周围四个点都看成一个节点,节点数量。
如果在位置的方格:
左上角的点:;
右上角的点:;
左下角的点:;
右下角的点:;
这样就可以把所有的点都表示出来了。
把\的电路的左上角和右下角的点以边权相连,左下角和右上角的点以边权相连,代表需要花费。
同理,把/的电路也以相同的方法相连。
最后,用双端队列BFS跑一遍这张图,就可以得出最小花费了。
代码:
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
const int N = 300010;
int t, r, c, que[N << 1], li, ri, tot, head[N], ver[N << 2], edge[N << 2], nex[N << 2], val[N], vis[N];
inline void add (int x, int y, int z) {
ver[++ tot] = y;
edge[tot] = z;
nex[tot] = head[x];
head[x] = tot;
}
int main () {
scanf("%d", &t);
while (t --) {
memset(que, 0, sizeof(que));
memset(head, 0, sizeof(head));
memset(ver, 0, sizeof(ver));
memset(edge, 0, sizeof(edge));
memset(nex, 0, sizeof(nex));
memset(val, 0x3f, sizeof(val));
memset(vis, 0, sizeof(vis));
li = N + 1;
ri = N;
tot = 0;
scanf("%d%d", &r, &c);
for (int i = 1; i <= r; i ++) {
char s[N];
scanf("%s", s + 1);
for (int j = 1; j <= c; j ++) {
if (s[j] == '\\') {
add((i - 1) * (c + 1) + j, i * (c + 1) + j + 1, 0);
add(i * (c + 1) + j + 1, (i - 1) * (c + 1) + j, 0);
add((i - 1) * (c + 1) + j + 1, i * (c + 1) + j, 1);
add(i * (c + 1) + j, (i - 1) * (c + 1) + j + 1, 1);
} else {
add((i - 1) * (c + 1) + j + 1, i * (c + 1) + j, 0);
add(i * (c + 1) + j, (i - 1) * (c + 1) + j + 1, 0);
add((i - 1) * (c + 1) + j, i * (c + 1) + j + 1, 1);
add(i * (c + 1) + j + 1, (i - 1) * (c + 1) + j, 1);
}
}
}
if ((r + c) % 2) {
printf("NO SOLUTION\n");
continue;
}
que[++ ri] = 1;
val[1] = 0;
while (ri >= li) {
int x;
x = que[ri --];
for (int i = head[x]; i; i = nex[i]) {
int y = ver[i], w = edge[i];
if (val[x] + w < val[y]) {
val[y] = val[x] + w;
if (w)
que[-- li] = y;
else
que[++ ri] = y;
}
}
}
printf("%d\n", val[(r + 1) * (c + 1)]);
}
return 0;
}
双向BFS
双向BFS意思就是从两端都跑一遍BFS,这两端通常是起始位置和最终位置。
使用双向BFS的原因是两端都是可以移动的,我们可以把两端为起点分别扩展一次,并分别记录从两端开始到达某个位置所耗费的时间。
最后根据题目的要求,求出所需要的答案。
例题:洪水
题解:
这道题中两个点,洪水和画家,都是可以移动的,所以很明显需要用双向BFS。
我们不妨先以洪水为起点进行一遍BFS,求出洪水到达不同的点所需的时间。
之后再以画家为起点进行一遍BFS,同样也可以求出画家到达不同的点所需的时间。
因为画家和洪水不能同时在一个点,所以可以以他们在某个点的所需时间来判断画家是否能走这个点(如果画家所花时间更少,则代表画家可以在洪水来临之前到达这个点)。
同时还需要注意题目中的小细节(如岩石无法被到达)。
代码:
#include <iostream>
#include <cstdio>
#include <queue>
using namespace std;
struct Pos {
int x, y, z;
};
int r, c, Time[51][51];
int dx[4] = {0, 0, -1, 1},
dy[4] = {1, -1, 0, 0};
queue <Pos> q1, q2;
char map[51][51];
int main () {
scanf("%d%d", &r, &c);
for (int i = 1; i <= r; i ++)
scanf("%s", map[i] + 1);
for (int i = 1; i <= r; i ++)
for (int j = 1; j <= c; j ++) {
if (map[i][j] == 'S')
q1.push((Pos){i, j, 0});
if (map[i][j] == '*')
q2.push((Pos){i, j, 0});
}
while (!q2.empty()) {
int ux = q2.front().x, uy = q2.front().y, uz = q2.front().z;
q2.pop();
Time[ux][uy] = uz;
for (int i = 0; i <= 3; i ++) {
int vx = ux + dx[i], vy = uy + dy[i];
if (vx <= r && vx >= 1 && vy <= c && vy >= 1 && map[vx][vy] == '.') {
q2.push((Pos){vx, vy, uz + 1});
map[vx][vy] = '*';
}
}
}
while (!q1.empty()) {
int ux = q1.front().x, uy = q1.front().y, uz = q1.front().z;
q1.pop();
for (int i = 0; i <= 3; i ++) {
int vx = ux + dx[i], vy = uy + dy[i];
if (vx <= r && vx >= 1 && vy <= c && vy >= 1 && (map[vx][vy] == '.' || map[vx][vy] == 'D' || map[vx][vy] == '*')) {
if (map[vx][vy] == 'D') {
printf("%d", uz + 1);
return 0;
}else if (map[vx][vy] == '.'){
q1.push((Pos){vx, vy, uz + 1});
map[vx][vy] = 'S';
}else if (map[vx][vy] == '*')
if (Time[vx][vy] > uz + 1) {
q1.push((Pos){vx, vy, uz + 1});
map[vx][vy] = 'S';
}
}
}
}
printf("KAKTUS");
return 0;
}
试题
T1:引水入城
T2:砝码称重
题解
引水入城:
这道题的结果有两种:可以使干旱区建立水利设施和不能使之建立。
判断是否可以使之建立水利设施非常简单,只需要把最上面一排的蓄水站都DFS一遍,看最下面一排是否有没被访问过的。
如果都被访问过,很显然可以使干旱区建立水利设施,否则就看有几个没被访问过。
可是怎么求出最少需要建设的蓄水站呢?
先想想,在所有干旱区可以建立水利设施的前提下,任何一个城市,其可以灌溉到的干旱区城市一定是一个区间。
这样我们在第一步DFS时,顺便可以求出所有蓄水站可以灌溉到的干旱城市的那个区间端点。
我们让作为左边界,初始值是。
然后开始选择我们需要的蓄水站:首先要满足它灌溉到的左端点小于等于,之后还需要它的右端点尽可能的大。找到了这个蓄水站后,我们把变为这个最大的右端点,并开始寻找下一个蓄水站。循环此操作,直到。
在这步过程中,我们找到的蓄水站数量,就是最终答案了。
代码:
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
const int N = 510;
int n, m, map[N][N], li[N][N], ri[N][N], vis[N][N], ans, tmp = 1, maxr;
int dx[4] = {1, 0, -1, 0},
dy[4] = {0, 1, 0, -1};
void dfs (int x, int y) {
vis[x][y] = 1;
for (int i = 0; i < 4; i ++) {
int nx = x + dx[i], ny = y + dy[i];
if (nx < 0 || nx > n || ny < 0 || ny > m || map[nx][ny] >= map[x][y])
continue;
if (!vis[nx][ny]) dfs(nx, ny);
li[x][y] = min(li[x][y], li[nx][ny]);
ri[x][y] = max(ri[x][y], ri[nx][ny]);
}
}
int main () {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i ++)
for (int j = 1; j <= m; j ++)
scanf("%d", &map[i][j]);
memset(li, 0x3f, sizeof(li));
memset(ri, 0, sizeof(ri));
for (int i = 1; i <= m; i ++) {
li[n][i] = i;
ri[n][i] = i;
}
for (int i = 1; i <= m; i ++)
if (!vis[1][i])
dfs(1, i);
for (int i = 1; i <= m; i ++)
if (!vis[n][i])
ans ++;
if (ans) {
printf("0\n%d", ans);
return 0;
}
while (tmp <= m) {
for (int i = 1; i <= m; i ++)
if (li[1][i] <= tmp && ri[1][i] >= maxr)
maxr = ri[1][i];
ans ++;
tmp = maxr + 1;
}
printf("1\n%d", ans);
return 0;
}
砝码称重:
从个砝码中去除个,数据范围又很小,显然暴搜就可以了。
表示当前正在搜索第几个砝码,表示在这之前已经舍去了几个砝码。
当代表已经搜完全部砝码,代表正好舍去了个砝码,这时就可以去找砝码组合的结果了。
但是怎么求出取出的砝码组合得到的有多少种呢?背包!
因为每个砝码最多只能被取一次,所以我们用01背包来解决。
表示这个数字能否被组合,初始值。
然后从我们选择的砝码中,一旦发现且,则标记
表示发现了一种新的组合。
代码:
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
const int N = 25;
int n, m, a[N], vis[N], f[2010], ans;
void dfs (int dep, int x) {
if (dep == n + 1 && x == m) {
int num = 0, tmp = 0;
memset(f, 0, sizeof(f));
f[0] = 1;
for (int i = 1; i <= n; i ++)
if (!vis[i]) {
for (int j = num; j >= 0; j --)
if (f[j] && !f[j + a[i]]) {
f[j + a[i]] = 1;
tmp ++;
}
num += a[i];
}
ans = max(ans, tmp);
return;
}
if (dep > n || x > m) return;
vis[dep] = 1;
dfs(dep + 1, x + 1);
vis[dep] = 0;
dfs(dep + 1, x);
}
int main () {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i ++)
scanf("%d", &a[i]);
dfs(1, 0);
printf("%d", ans);
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探