李超线段树
李超线段树
李超线段树可以求解以下问题:
- 插入一条形如 \(f(x)=kx+b\) 的直线或线段
- 查询在 \(x\) 位置的函数最值(最大或最小)。
李超线段树的写法很多,但是核心思想是一致的,以求解最大值为例讲解算法过程。
修改操作
既然是线段树,自然树上每个节点为一条线段 \([l,r]\),其中树上每个节点维护 \(mid=(l+r)/2\) 处的能取到最大值的函数的信息(即这个函数的 \(k\) 和 \(b\))。
那么我们加入一条线段。有四种情况
- 在 \([l,r]\) 的每个位置,新加入的线段均比原先线段(单指那条在 \(mid\) 处函数值最大的线段)劣,那么自然在 \(l,mid,r\) 这三个位置函数值均比原先线段小。
- 在 \([l,r]\) 的每个位置,新加入的线段均比原先线段优,那么在 \(l,mid,r\) 处这三个位置函数值均比原先线段大,此时我们只需修改这个区间存储的函数转化为新加入的线段。为了复杂度正确,不用去修改 \([l,mid],[mid+1],r\) 这两个区间的信息(正确性可以在后续的查询操作中体现)。
- 在 \([l,r]\) 的 \(mid\) 位置,新加入的线段比原先线段优秀,修改这个区间存储的函数为新加入的函数。但是,如果原先的线段在 \(l\) 位置的值比新线段优秀,将原先的线段在 \([l,mid]\) 位置进行同样的修改操作(递归);如果在 \(r\) 位置的值比新线段优秀,就在 \([mid+1,r]\) 递归。
- 在 \([l,r]\) 的 \(mid\) 位置,新加入的线段没有原先线段优秀。如果新加入的线段在 \(l\) 位置比原有线段优秀,在 \([l,mid]\) 的节点进行修改;如果在 \(r\) 位置比原有线段优秀,在 \([mid+1,r]\) 的节点进行修改。
上述内容非常复杂,实际上很多内容本质是相同的,以下是算法实现
struct sgline{double k,b;}lin[N];int tot;
inline double hei(int id,int i){return lin[id].k*i+lin[id].b;}
void modify(int p,int l,int r,int id){//对线段完全覆盖的区间进行修改
if(hei(id,mid)>hei(bel[p],mid))swap(bel[p],id);//这个写法真的挺强的
if(hei(id,l)>hei(bel[p],l))modify(ls,l,mid,id);
if(hei(id,r)>hei(bel[p],r))modify(rs,mid+1,r,id);
}
void update(int p,int l,int r,int L,int R,int id){
if(L<=l&&r<=R){modify(p,l,r,id);return;}//id 这条线段完全覆盖此区间
if(L<=mid)update(ls,l,mid,L,R,id);
if(R>mid)update(rs,mid+1,r,L,R,id);
}
容易发现在 update
执行 modify
最多操作 \(\log n\) 次,而这些 modify
操作也最多递归 \(\log n\) 次,所以单次修改的复杂度是 \(O(\log^2 n)\) 的。
如果修改操作时全局修改的话,那么修改的复杂度时 \(O(\log n)\) 的。
查询操作
将线段树中每个包含位置 \(L\) 的节点存储的函数值计算 \(f(L)\) 并最值即可。这样保证了修改操作中第二种情况不递归的正确性。
ttfa query(int p,int l,int r,int L){
ttfa tmp={hei(bel[p],L),bel[p]};
if(l==r)return tmp;
if(L<=mid)tmp=max(tmp,query(ls,l,mid,L));
else tmp=max(tmp,query(rs,mid+1,r,L));
return tmp;
}
询问复杂度 \(O(\log n)\)
P4097 [HEOI2013]Segment
真板子题
要求在平面直角坐标系下维护两个操作:
- 在平面上加入一条线段。记第 \(i\) 条被插入的线段的标号为 \(i\)。
- 给定一个数 \(k\),询问与直线 \(x = k\) 相交的线段中,交点纵坐标最大的线段的编号。
强制在线。对于 \(100\%\) 的数据,保证 \(1 \leq n \leq 10^5\),\(1 \leq y_0, y_1 \leq 10^9\)。
题目加入的线段的方式是给出线段的两个端点,使用初中几何知识换算一下就可以了。
typedef pair<double,int>ttfa;
struct sgline{double k,b;}lin[N];int tot;
inline double hei(int id,int i){return lin[id].k*i+lin[id].b;}
inline void add(double x_1,double y_1,double x_2,double y_2){
if(x_1==x_2)lin[++tot]={0,max(y_1,y_2)};
else{lin[++tot].k=(y_2-y_1)/(x_2-x_1);lin[tot].b=y_1-lin[tot].k*x_1;}
}
int bel[N<<2];
#define ls p<<1
#define rs p<<1|1
#define mid ((l+r)>>1)
void modify(int p,int l,int r,int id){//对线段完全覆盖的区间进行修改
if(hei(id,mid)>hei(bel[p],mid))swap(bel[p],id);//这个写法真的挺强的
if(hei(id,l)>hei(bel[p],l))modify(ls,l,mid,id);
if(hei(id,r)>hei(bel[p],r))modify(rs,mid+1,r,id);
}
void update(int p,int l,int r,int L,int R,int id){
if(L<=l&&r<=R){modify(p,l,r,id);return;}//id 这条线段完全覆盖此区间
if(L<=mid)update(ls,l,mid,L,R,id);
if(R>mid)update(rs,mid+1,r,L,R,id);
}
ttfa max(ttfa x,ttfa y){//纵坐标相同的情况下选择编号小的
if(x.first==y.first)
return x.second<y.second?x:y;
return x.first>y.first?x:y;
}
ttfa query(int p,int l,int r,int L){
ttfa tmp={hei(bel[p],L),bel[p]};
if(l==r)return tmp;
if(L<=mid)tmp=max(tmp,query(ls,l,mid,L));
else tmp=max(tmp,query(rs,mid+1,r,L));
return tmp;
}
int lasans=0;
inline int calc1(int x){return (x+lasans-1+39989)%39989+1;}
inline int calc2(int x){return (x+lasans-1+1000000000)%1000000000+1;}
int main(){
int T=read();
while(T--){
int opt=read();
if(opt==1){
int x_1=calc1(read()),y_1=calc2(read());
int x_2=calc1(read()),y_2=calc2(read());
if(x_1>x_2)swap(x_1,x_2),swap(y_1,y_2);
add(x_1,y_1,x_2,y_2);
update(1,1,M,x_1,x_2,tot);
}else{
int L=calc1(read());
printf("%d\n",lasans=query(1,1,M,L).second);
}
}
return 0;
}
P4254 [JSOI2008]Blue Mary开公司
第一行 :一个整数 \(N\) ,表示方案和询问的总数。
接下来 \(N\) 行,每行开头一个单词
Query
或Project
。若单词为
Query
,则后接一个整数 \(T\),表示 Blue Mary 询问第 \(T\) 天的最大收益。若单词为
Project
,则后接两个实数 \(S\),\(P\),表示该种设计方案第一天的收益 \(S\),以及以后每天比上一天多出的收益 \(P\)。对于每一个
Query
,输出一个整数,表示询问的答案,并精确到整百元(以百元为单位,例如:该天最大收益为 210 或 290 时,均应该输出 2)。没有方案时回答询问要输出 0。
也是李超线段树的板子题,这里采用的是对 4 种情况直接分类讨论的写法。
同时注意这题是全局修改,不同在插入的时候不用判断线段区间 \([L,R]\)。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cmath>
using namespace std;
const int N=1000005,M=500000;
struct lictree{
struct sgline{double k,d;}lin[N];int tot;
inline double hei(int id,int i){return lin[id].k*(i-1)+lin[id].d;}
int bel[N<<2];
#define ls p<<1
#define rs p<<1|1
#define mid ((l+r)>>1)
void update(int p,int l,int r,int id){
if(hei(id,l)>=hei(bel[p],l)&&hei(bel[p],r)<=hei(id,r))
{bel[p]=id;return;}
if(hei(id,l)<=hei(bel[p],l)&&hei(bel[p],r)>=hei(id,r))
return;
if(lin[id].k>lin[bel[p]].k){
if(hei(id,mid)>hei(bel[p],mid))//说明在mid 这个位置 id 的高度已经超过了所有线段,所以右区间和左区间的一部分的最高线段就是它
update(ls,l,mid,bel[p]),bel[p]=id;//因为先前标记的直线有可能在左区间右贡献,所以这个节点的标记线段在左区间上修改
else update(rs,mid+1,r,id);//否则在右区间继续找到这条线段超过所有线段的位置
}else{
if(hei(id,mid)>hei(bel[p],mid))
update(rs,mid+1,r,bel[id]),bel[p]=id;
else update(ls,l,mid,id);
}
}
inline void insert(double k,double d){
lin[++tot]={k,d};
update(1,1,M,tot);
}
double queryhei(int p,int l,int r,int L){
if(l==r)return hei(bel[p],L);
double tmp=hei(bel[p],L);
if(L<=mid)tmp=max(tmp,queryhei(ls,l,mid,L));
else tmp=max(tmp,queryhei(rs,mid+1,r,L));
return tmp;
}
}t;
int main(){
int T;scanf("%d",&T);
char s[10];
while(T--){
scanf("%s",s);
if(s[0]=='P'){
double x,y;scanf("%lf%lf",&x,&y);
t.insert(y,x);
}else{
int L;scanf("%d",&L);
printf("%d\n",(int)floor(t.queryhei(1,1,M,L)/100));
}
}
return 0;
}
例题
LG4655 [CEOI2017] Building Bridges
有 \(n\) 根柱子依次排列,每根柱子都有一个高度。第 \(i\) 根柱子的高度为 \(h_i\)。
现在想要建造若干座桥,在第 \(i\) 根柱子和第 \(j\) 根柱子之间架桥需要 \((h_i-h_j)^2\) 的代价。
所有用不到的柱子都会被拆除。第 \(i\) 根柱子被拆除的代价为 \(w_i\),注意 \(w_i\) 不一定非负。
求通过桥梁把第 \(1\) 根柱子和第 \(n\) 根柱子连接的最小代价。注意桥梁不能在端点以外的任何地方相交。
定义 \(dp_i\) 为 \(1\sim i\) 均已经架桥的代价,\(s_i\) 为 \(w_i\) 的前缀和。则 \(dp\) 转移非常好推。
将定值从式子中分开,将 \(\min\) 中尝试写成一次函数的形式。
发现对于 \(h_i\),求出 \(k=h_j,b=dp_j-s_j+h_j^2\),\(k\times h_i+b\) 最大即可。
那么这 \(i-1\) 个一次函数可以使用李超线段树维护。
综上,这题推式子还是比较基础的,可能难点就在李超树上了。
const int N=100005,M=1000006,LIM=1000000;
const ll INF=0x3f3f3f3f3f3f3f3f;
struct lictree{//维护最小值,全局修改
struct sgline{ll k,b;}lin[N];int tot;
inline ll hei(int id,ll i){return lin[id].k*i+lin[id].b;}
int bel[M<<2];
#define ls p<<1
#define rs p<<1|1
#define mid ((l+r)>>1)
void update(int p,int l,int r,int id){
/*if(hei(id,l)<=hei(bel[p],l)&&hei(bel[p],r)>=hei(id,r))
{bel[p]=id;return;}
if(hei(id,l)>=hei(bel[p],l)&&hei(bel[p],r)<=hei(id,r))
return;*/
if(hei(id,mid)<hei(bel[p],mid))swap(bel[p],id);
if(hei(id,l)<hei(bel[p],l))update(ls,l,mid,id);
if(hei(id,r)<hei(bel[p],r))update(rs,mid+1,r,id);
}
ll query(int p,int l,int r,int L){
ll tmp=hei(bel[p],L);
if(l==r)return tmp;
if(L<=mid)tmp=min(tmp,query(ls,l,mid,L));
else tmp=min(tmp,query(rs,mid+1,r,L));
return tmp;
}
inline void insert(ll k,ll b){
lin[++tot]={k,b};
update(1,1,LIM,tot);
}
inline void init(){lin[tot=0].b=INF;}//维护最小值所以初值最大
}t;
int n;ll w[N],h[N],s[N],dp[N];
int main(){
n=read();t.init();//写了init没有执行,呜呜呜
for(int i=1;i<=n;++i)h[i]=read();
for(int i=1;i<=n;++i){
w[i]=read();
s[i]=s[i-1]+w[i];
}
dp[1]=0;t.insert(-2ll*h[1],-s[1]+dp[1]+h[1]*h[1]);
for(int i=2;i<=n;++i){
dp[i]=t.query(1,1,LIM,h[i])+s[i-1]+h[i]*h[i];
t.insert(-2ll*h[i],-s[i]+dp[i]+h[i]*h[i]);
}
printf("%lld\n",dp[n]);
return 0;
}
// 343ms / 6.77MB / 1.62KB C++14 (GCC 9) O2
CF1715E Long Way Home
有 \(n\) 座城市,城市间有 \(m\) 条双向道路,通过第 \(i\) 条道路需要花费 \(w_i\) 的时间,任意两个城市之间都有航班,乘坐城市 \(u\) 和 \(v\) 之间的航班需要花费 \((u-v)^2\) 的时间。
现在请对于任意城市 \(i(1 \le i \le n)\),求出从城市 \(1\) 出发,到达城市 \(i\) 所需要的最短时间,注意从城市 \(1\) 到 \(i\) 的过程中最多乘坐 \(k\) 次航班,\(k\leq 20\)。
看上去有点像分层图,但是 \((u-v)^2\) 的花费和 LG4655 可以说是非常相似,可以考虑使用斜率优化。
行走的过程一定是这样的:走道路(其实可以不走)、坐飞机、走道路、坐飞机、……、坐飞机、走道路。所以我们可以跑 \(k+1\) 遍最短路,并在这 \(k\) 遍最短路中跑 \(k\) 遍斜率优化。
转移是显而易见的
转化成一次函数的形式
所以我们将函数 \(f(x)=2vx+dp_v+v^2\) 存储在李超线段树中,查询 \(x\) 的最小函数值加上 \(x^2\) 即可。code
CF1303G Sum of Prefix Sums
有一颗 \(n\) 个节点的树 \((2 \leq n \leq 150000)\)
树每个节点有一个权值 \(a_i (1 \leq a_i \leq 10^6)\)
定义树上\(u \rightarrow v\)的链的权值如下:
- 将\(u\)到\(v\)的路径上点的权值依次排列在数组中
- 该数组的前缀和的和即这条路径的权值
请求出权值最大的链,输出权值
李超树的经典题目了。
实质上一个路径为 \(x_1,x_2,\dots x_k\) 的话,这条路径的权值为 \(\sum_{i=1}^{k}i\times a_{x_i}\)。
既然是求路径的问题,那么往点分治方向思考。
那么对于路径 \(x_1\to x_k\),选择一个中间点 \(x_m\),可以拆分成两个子路径。
- 第一部分是 \(x_1\to x_m\),设 \(l_1=m,v_1=\sum_{i=1}^{m}i\times a_{x_i}\)
- 第二部分是 \(x_{m+1}\to x_k\),设 \(s_2=\sum_{i=m+1}^{k}a_i,v_2=\sum_{i=m+1}^{k}(i-m)a_{x_i}\)。
那么整条路径的权值是 \(v_1+l_1s_2+v_2\)。
可以将 \(f(x)=s_2x+v_2\) 看作已经插入的一条直线,那么查询 \(f(l_1)+v_1\) 即可得到新加入的点到中转点与之前插入的路径所形成路径的权值。
那么我们可以使用点分治,将分治点的一棵子树的所有 \(s_2,v_2\) 插入李超树中,遍历另一颗子树的时候查询 \(f(l_1)+v_1\) 即可。
至于如何求 \(l,s,v1,v_2\) 这四个信息,可以在点分治的过程中求得,一些细节具体见代码。点分治时间复杂度 \(O(n\log n)\),李超树单次复杂度 \(O(\log n)\),重构李超树复杂度为树的深度,分治得到的深度和不超过 \(O(n\log n)\)。总时间复杂度 \(O(n\log ^2 n)\)。
struct lictree{
struct sgline{ll k,b;}lin[N];int tot;
inline ll hei(int id,int i){return lin[id].k*i+lin[id].b;}
int bel[N<<2],len;
#define ls p<<1
#define rs p<<1|1
#define mid ((l+r)>>1)
void build(int p,int l,int r){
bel[p]=0;
if(l==r)return;
build(ls,l,mid);
build(rs,mid+1,r);
}
void update(int p,int l,int r,int id){//注意是全局修改
if(hei(id,l)>=hei(bel[p],l)&&hei(id,r)>=hei(bel[p],r))
{bel[p]=id;return;}
if(hei(id,l)<=hei(bel[p],l)&&hei(id,r)<=hei(bel[p],r))
return;
if(hei(id,mid)>hei(bel[p],mid))swap(bel[p],id);
if(hei(id,l)>hei(bel[p],l))update(ls,l,mid,id);
if(hei(id,r)>hei(bel[p],r))update(rs,mid+1,r,id);
}
ll query(int p,int l,int r,int L){
ll tmp=hei(bel[p],L);
if(l==r)return tmp;
if(L<=mid)tmp=max(tmp,query(ls,l,mid,L));
else tmp=max(tmp,query(rs,mid+1,r,L));
return tmp;
}
inline void init(int d){tot=0,len=d;build(1,1,len);}
inline void insert(ll k,ll b){
lin[++tot]={k,b};
update(1,1,len,tot);
}
#undef ls
#undef rs
#undef mid
}t;
int n;ll a[N],ans;
vector<int>edge[N];
int siz[N],fiz[N],root,all;bool vis[N];
int dep[N],mxd,col[N],now,stk[N],top;ll v1[N],v2[N],s[N],l[N];
void findroot(int u,int f){
siz[u]=1,fiz[u]=0;
for(auto v:edge[u]){
if(v==f||vis[v])continue;
findroot(v,u);
siz[u]+=siz[v];
fiz[u]=max(fiz[u],siz[v]);
}fiz[u]=max(fiz[u],all-siz[u]);
if(fiz[u]<fiz[root])root=u;
}
void getlis(int u,int f,ll dis1,ll dis2,ll sum){//此处的sum是记录了根节点的
dep[u]=dep[f]+1;
mxd=max(mxd,dep[u]);
bool flag=0;
for(auto v:edge[u]){
if(v==f||vis[v])continue;
flag=1;
getlis(v,u,dis1+sum+a[v],dis2+a[v]*dep[u],sum+a[v]);
}
if(!flag){//答案一定是由叶子节点组成
stk[++top]=u;col[top]=now;
v1[top]=dis1,v2[top]=dis2,s[top]=sum-a[root],l[top]=dep[u];
}
}
void solve(int u){
vis[u]=1;top=0;
dep[u]=mxd=1;
for(auto v:edge[u]){
if(!vis[v]){
now=v;
getlis(v,u,a[v]+2*a[u],a[v],a[u]+a[v]);
}
}
stk[++top]=u;col[top]=0;
v1[top]=a[u],l[top]=1,v2[top]=s[top]=0;
t.init(mxd);
col[top+1]=col[0]=-1;
for(int i=1,j=i;i<=top;i=j){
while(col[j]==col[i])
{ans=max(ans,t.query(1,1,mxd,l[j])+v1[j]);++j;}
j=i;
while(col[j]==col[i])
{t.insert(s[j],v2[j]);++j;}
}
t.init(mxd);
for(int i=top,j=i;i>=1;i=j){//同时路径的答案是有方向的,所以反着做一次
while(col[j]==col[i])
{ans=max(ans,t.query(1,1,mxd,l[j])+v1[j]);--j;}
j=i;
while(col[j]==col[i])
{t.insert(s[j],v2[j]);--j;}
}
for(auto v:edge[u]){
if(vis[v])continue;
all=siz[v],root=0;
findroot(v,u);
solve(root);
}
}
int main(){
n=read();
for(int i=1;i<n;++i){
int u=read(),v=read();
edge[u].push_back(v);
edge[v].push_back(u);
}
for(int i=1;i<=n;++i)a[i]=read();
fiz[root=0]=INF,all=n;
findroot(1,0);
solve(root);
printf("%lld\n",ans);
return 0;
}
CF932F Escape Through Leaf
有一颗 \(n\) 个节点的树(节点从 \(1\) 到 \(n\) 依次编号)。每个节点有两个权值,第 \(i\) 个节点的权值为 \(a_i,b_i\)。
你可以从一个节点跳到它的子树内任意一个节点上。从节点 \(x\) 跳到节点 \(y\) 一次的花费为 \(a_x\times b_y\)。跳跃多次走过一条路径的总费用为每次跳跃的费用之和。请分别计算出每个节点到达树的每个叶子节点的费用中的最小值。
注意:就算树的深度为 \(1\),根节点也不算做叶子节点。另外,不能从一个节点跳到它自己.
\(2\leq n\leq 10^5\),\(-10^5\leq a_i\leq 10^5\),\(-10^5\leq b_i\leq 10^5\)。
可以考虑DP,假设 \(dp_u\) 为从 \(u\) 出发到叶子节点的最小代价。假设 \(v\) 是 \(u\) 子树中的点,则:
那么我们可以将 \(f(x)=b_vx+dp_v\) 加入李超线段树中,查询 \(f(a_u)\) 的最小值即可得到 \(dp_u\)。但是问题是这是个树上问题。那么我们可以使用线段树合并。
李超线段树合并时 \(O(n\log n)\) 的。核心点在于一个点只会保存一条直线的信息,每条直线的信息最多在一个点被记录。而合并节点的时候每条直线信息的存储点的深度只会变大或不变,那么深度最大变大 \(\log n\) 次,一共 \(n\) 条直线。总时间复杂度 \(O(n\log n)\)。
注意这题中 \(a_u\) 可能为负数,下标整体平移为正就行了。
inline ll read(){
ll x=0,f=1;char ch=getchar();
while(ch<'0'||'9'<ch){if(ch=='-')f=-1;ch=getchar();}
while('0'<=ch&&ch<='9'){x=(x<<3)+(x<<1)+(ch^48);ch=getchar();}
return x*f;
}
const int N=222222;
const ll BAS=100005,LIM=BAS*2;
const ll INF=0x3f3f3f3f3f3f3f3f;
int n,cnt;
ll a[N],b[N],dp[N];
vector<int>edge[N];
struct sgline{ll k,b;}lin[N];
inline ll hei(int id,ll i){return lin[id].k*i+lin[id].b;}
int lc[N<<5],rc[N<<5],bel[N<<5],root[N],tot;
#define mid ((l+r)>>1)
void update(int &p,int l,int r,int id){
if(!p){p=++tot;bel[p]=id;return;}
if(hei(id,mid)<hei(bel[p],mid))swap(bel[p],id);
if(hei(id,l)<hei(bel[p],l))update(lc[p],l,mid,id);
if(hei(id,r)<hei(bel[p],r))update(rc[p],mid+1,r,id);
}
ll query(int p,int l,int r,ll L){
if(!p)return INF;
ll tmp=hei(bel[p],L);
if(L<=mid)tmp=min(tmp,query(lc[p],l,mid,L));
else tmp=min(tmp,query(rc[p],mid+1,r,L));//这里写成max了,尴尬
return tmp;
}
int merge(int x,int y,int l,int r){
if(!x||!y)return x+y;
update(x,l,r,bel[y]);
lc[x]=merge(lc[x],lc[y],l,mid);
rc[x]=merge(rc[x],rc[y],mid+1,r);
return x;
}
void dfs(int u,int f){
for(auto v:edge[u]){
if(v==f)continue;
dfs(v,u);
root[u]=merge(root[u],root[v],1,LIM);
}
dp[u]=query(root[u],1,LIM,a[u]+BAS);
if(dp[u]==INF)dp[u]=0;
lin[u]={b[u],dp[u]-b[u]*BAS};//b[u]*BAS 因为上面右移多算的贡献
update(root[u],1,LIM,u);
}
int main(){
n=read();
for(int i=1;i<=n;++i)a[i]=read();
for(int i=1;i<=n;++i)b[i]=read();
for(int i=1;i<n;++i){
int u=read(),v=read();
edge[u].push_back(v);
edge[v].push_back(u);
}
dfs(1,0);
for(int i=1;i<=n;++i)
printf("%lld ",dp[i]);
putchar('\n');
return 0;
}
本文作者:BigSmall_En
本文链接:https://www.cnblogs.com/BigSmall-En/p/16624627.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步