NUIST2026新生&个人赛题解

NUIST2026个人赛题解

河童重工

U736556 B - 河童重工

当 k 小于 n 则输出 k , 当 k 大于n , 如果 ( n - k ) 为偶数则输出 n , 否则输出n - 1

时间复杂度为O (1)。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
int main() 
{
    int n,k;
    cin>>n>>k;
    if(n>k)
    {
        cout<<k<<endl; 
    }
    else
    {
        if((n-k)%2==0) cout<<n<<endl; 
        else cout<<n-1<<endl;
    }
    return 0;
}

这个勇者明明超强却过分慎重

U736568 C - 这个勇者明明超强却过分慎重

由于可以选择至少一名队员 ,因此可以只选择属性值最大的队员组成小队。

对于任意一个小队 ,其属性值的平均值都不可能超过小队中最大的属性值。

所以只需要找到所有队员中属性值的最大值并输出即可。

时间复杂度为 O(n)。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
int main() 
{
    int n;
    cin>>n;
    int ans=INT_MIN;
    for(int i=1;i<=n;i++) 
    {
        int x;
        cin>>x;
        ans=max(ans,x); 
    }
    cout<<ans<<endl;
    return 0;
}

琪露诺的完美算术教室

U736569 D - 琪露诺的完美算术教室

每次选择两个数中较大的数 ,将它除以 9 并向下取整。

同时 ,由于可以通过加 1 让较小的数变成较大的数 ,因此每次操作后 ,都可以尝试更新答案。假设已经进行了 cnt 次操作 ,那么此时直接将两个数变得相等 ,还需要 abs(a-b) 次操作。

因此每次更新:

|----------------------------|
| ans=min(ans,cnt+abs(a-b)); |

当两个数相等时停止。

注意 ,不能只考虑不断除以 9,例如 a=1,b=2,直接将 a 加 1 只需要一次操作。时间复杂度为 O (log9 (max(a , b)))。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
void solve() 
{
    int a,b;
    cin>>a>>b;
    int ans=abs(a-b),cnt=0;
    while(a!=b)
    {
        if(a>b) a/=9;
        else b/=9;
        cnt++;
        ans=min(ans,cnt+abs(a-b)); 
    }
    cout<<ans<<endl; 
    return;
}
int main() 
{
    int T;
    cin>>T;
    while(T--)
    {
        solve(); 
    }
    return 0;
}

正确性说明: 如果某个数需要先加 1 再除以 9,可以改为先除以 9,再根据需要加至多 1,操作次数不会增加。因此可以把加法操作放到所有除法操作之后。

而在除法阶段 ,优先除以较大的数即可 ,因为除以较小的数只会扩大当前差距;如果之后还要除以较大的数,可以交换这两次操作的顺序。

因此枚举每次除以较大数后的状态 ,并计算直接补齐差值的代价 ,就能得到最少操作次数。


熊大快跑

U736570 E - 熊大快跑

由于每次到达位置 0 后 ,都会重新从第一个指令开始执行 ,因此只需要考虑两种情况。

首先 ,从初始位置 x 开始执行指令 ,找到第一次到达位置 0 所需要的时间 t1。

如果无法在 k 秒内到达位置 0,则答案为 0。

否则 ,答案至少为 1,并且剩余时间为 k-t1。

接下来 ,由于熊大每次到达位置 0 都会重新执行指令 ,因此只需要计算从位置 0 出发 ,再次回到位置 0 所需要的最短时间 t2。

如果不存在这样的时间 ,则答案为 1。

否则 ,每经过 t2 秒 ,熊大就能再次到达位置 0,因此答案为:

只需要遍历一次字符串 ,维护当前位置的相对位移 ,就可以找到 t1 和 t2。

时间复杂度为 O (n)。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
#define int long long
void solve() 
{
    int n,x,k;
    cin>>n>>x>>k;
    string s;
    cin>>s;
    int p=0,t1=-1,t2=-1;
    for(int i=0;i<n;i++) 
    {
        if(s[i]=='L') p--;
        else p++;
        if(t1==-1&&x+p==0) t1=i+1; 
        if(t2==-1&&p==0) t2=i+1;
    }
    if(t1==-1||t1>k)
    {
        cout<<0<<endl;
        return; 
    }
    int ans=1;
    k-=t1;
    if(t2!=-1) ans+=k/t2;
    cout<<ans<<endl; 
    return;
}
signed main() 
{
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int T;
    cin>>T;
    while(T--)
    {
        solve(); 
    }
    return 0;
}

芙兰达

U736575 F - 芙兰)(达

由于每张优惠券可以让所选炸弹中价格最低的一个免费 ,因此我们需要尽可能让价格较高的炸弹免费 ,从而使总花费最小。

可以采用贪心策略:

首先 ,将所有炸弹的价格按照从大到小排序 ,将所有优惠券的 k 按照从小到大排序。

然后 ,依次使用优惠券 ,每次从尚未分配的炸弹中选择价格最高的 k 个炸弹。

由于这 k 个炸弹中价格最低的一个可以免费 ,因此只需要将这 k 个炸弹中最后一个的价格从总花费中减去。如果剩余炸弹数量不足以使用当前优惠券 ,则直接结束 ,因为后面的优惠券需要选择的炸弹数量只会更多。最后输出剩余的总花费即可。

注意 ,所有炸弹的价格之和可能超过 int 范围 ,需要使用 long long。

时间复杂度为 O( n log n + m log m)。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
#define int long long
void solve() 
{
    int n,m;
    cin>>n>>m;
    vector<int> a(n),k(m);
    int ans=0;
    for(int i=0;i<n;i++)
    {
        cin>>a[i];
        ans+=a[i]; 
    }
    for(int i=0;i<m;i++) cin>>k[i];
    sort(a.begin(),a.end(),greater<int>()); 
    sort(k.begin(),k.end());
    int pos=0;
    for(int i=0;i<m;i++)
    {
        if(pos+k[i]>n) break;
        pos+=k[i];
        ans-=a[pos-1]; 
    }
    cout<<ans<<endl;
    return;
}
signed main() 
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    solve(); 
    return 0;
}

鲤鱼鉴人心

U736577 G - 鲤鱼鉴人心

这是一道模拟题。

我们按照题目给出的 m 次操作依次模拟 ,维护每名玩家的生命值上限、 当前生命值、阵营以及是否存活。需要注意以下几点:

  1. 玩家生命值小于等于 0时立即出局,但 1 号玩家永远不会出局。
  2. 每次行动结束后,轮到顺时针方向上最近的存活玩家行动。
  3. 除 1号玩家外 ,所有玩家都将 1 号玩家视为敌人。
  4. 如果与 1号玩家同阵营的玩家攻击或削弱了 1号玩家 ,那么 1 号玩家也会永久将其视为敌人。

为了快速找到下一名行动者和攻击目标 ,可以使用 set 维护存活玩家。

其中:

  • alive:所有存活玩家的编号。
  • col[0](#0)、col[1](#1):两个阵营中除 1号外的存活玩家。
  • enemy: 当前被 1 号玩家视为敌人的存活玩家。

对于普通玩家 ,优先寻找顺时针方向上编号更大的敌对阵营玩家。如果不存在 ,则攻击 1 号玩家。

对于 1 号玩家 ,直接寻找 enemy 中编号最小的玩家即可。

每次操作结束后 ,如果有玩家出局 ,就从对应的集合中删除。使用 set 可以在 O (log n) 的时间内完成查找和删除。

总时间复杂度为 O ((n + m )log n)。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
#define int long long
const int N=10005;
int n,m;
int a[N],b[N],c[N];
set<int> alive,col[2],enemy;
void del(int x) 
{
    if(x==1||b[x]>0) return;
    alive.erase(x);
    col[c[x]].erase(x);
    enemy.erase(x); 
}
int find_enemy(int x) 
{
    if(x==1)
    {
        if(enemy.empty()) return 0;
        return *enemy.begin(); 
    }
    auto it=col[c[x]^1].upper_bound(x);  
    if(it!=col[c[x]^1].end()) return *it;
    return 1; 
}
void solve() 
{
    cin>>n>>m;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i]>>c[i];
        b[i]=a[i];
        alive.insert(i);
        if(i!=1) col[c[i]].insert(i); 
    }
    for(int i=2;i<=n;i++)
    {
        if(c[i]!=c[1]) enemy.insert(i); 
    }
    int now=1;
    while(m--)
    {
        int op;
        cin>>op;
        if(op==2){b[now]=min(a[now],b[now]+(int)alive.size()); }
        else
        {
            int to=find_enemy(now);
            if(to!=0)
            {
                if(op==1)
                {
                    b[to]-=a[now]-b[now]+1; 
                }
                else
                {
                    a[to]--;
                    b[to]=min(b[to],a[to]); 
                }
                if(to==1&&now!=1&&c[now]==c[1]){enemy.insert(now);}
                del(to); 
            }
        }
        auto it=alive.upper_bound(now);
        if(it==alive.end()) now=*alive.begin();
        else now=*it; 
    }
    for(int i=1;i<=n;i++)
    {
        cout<<(alive.count(i)?1:0);
        if(i<n) cout<<" "; 
    }
    cout<<endl; 
    return;
}
signed main()
{
    ios::sync_with_stdio(false); cin.tie(nullptr);
    solve();
    return 0;
}

注意: 普通玩家寻找敌人时 ,如果顺时针方向上没有编号更大的敌人 ,那么下一个合法目标一定是 1 号玩家,因为所有其他玩家都将鲤鱼视为敌人。 因此不需要继续遍历整个圆环。


比利时小子 正在和籽山民先生 闹矛盾,,,

U736579 H - 比利吋小子 正在和 籽山民先生 闹矛盾,,,

首先 ,长度为 n 的排列 ,其所有元素之和一定为:

如果 S 是完全平方数 ,那么无论如何排列 ,最后一个前缀和都一定是完全平方数 ,因此无解 ,直接输出 -1。

否则 ,可以采用贪心构造。

首先构造一个从 1 到 n 的升序排列:

|-------------------|
| 1 2 3 4 5 6 7 ... |

对于这个排列 ,前 i 个元素的和为:

从左到右枚举每个位置 i,如果当前前缀和是完全平方数 ,就交换第 i 个元素和第 i+1 个元素。

这样当前前缀和就会增加 1,不再是完全平方数。

同时 ,交换相邻两个元素不会影响后续位置的前缀和。并且不存在连续两个位置的上述前缀和都是完全平方数的情况 ,因此交换操作不会冲突。

最终得到的排列就是一个合法答案。

时间复杂度为 O(n)。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
#define int long long
void solve() 
{
    int n;
    cin>>n;
    int sum=n*(n+1)/2;
    int x=sqrtl(sum);
    if(x*x==sum)
    {
        cout<<-1<<endl;
        return; 
    }
    vector<int> a(n+1);
    for(int i=1;i<=n;i++) a[i]=i;
    for(int i=1;i<n;i++) 
    {
        int s=i*(i+1)/2;
        int t=sqrtl(s);
        if(t*t==s)
        {
            swap(a[i],a[i+1]); 
        }
    }
    for(int i=1;i<=n;i++) 
    {
        cout<<a[i]<<" "; 
    }
    cout<<endl; 
    return;
}
signed main() 
{
    ios::sync_with_stdio(false); 
    cin.tie(nullptr);
    int T;
    cin>>T;
    while(T--)
    {
        solve(); 
    }
    return 0;
}

鬼人正邪Ⅱ

U736580 I - 鬼人正邪Ⅱ

由于每次操作只会翻转区间 (l,r) 内的字符 ,而不会改变两个端点 ,因此整个字符串的第一个字符和最后一个字符始终不会改变。

我们分类讨论。

当 n≤2 时 ,由于不存在可以翻转的字符 ,因此答案为 1。

当 n=3 时 ,只有当第一个字符和最后一个字符相同时 ,才能翻转中间的字符。

因此 ,如果 a[1](#1)=a[3](#3),答案为 2,否则答案为 1。

当 n=4 时 ,可以分类讨论:

  1. 如果 a[1](#1)=a[4](#4),那么中间两个字符的 4种状态均可以到达,答案为 4。
  2. 如果字符串为 0011或 1100,那么无法进行有效的翻转操作,答案为 1。
  3. 其他情况下,答案为3。

当 n≥5 时 ,可以证明 ,对于任意长度为 5 的子串 ,在保持两端字符不变的情况下 ,中间三个字符的 8 种状态可以互相转化。

因此 ,我们可以通过选择长度为 5 的区间 ,单独翻转任意一个非端点字符 ,而不改变其他字符。

也就是说 ,除了第一个和最后一个字符 ,其余 n-2 个字符都可以任意取值。

所以答案为:

ans = 2n-2 mod 998244353

直接分类讨论即可。

时间复杂度为 O(n)。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
#define int long long
const int mod=998244353;
void solve() 
{
    int n;
    cin>>n;
    string s;
    cin>>s;
    if(n<=2)
    {
        cout<<1<<endl;
        return; 
    }
    if(n==3)
    {
        cout<<(s[0]==s[2]?2:1)<<endl;
        return; 
    }
    if(n==4)
    {
        if(s[0]==s[3]) cout<<4<<endl;
        else if(s=="0011"||s=="1100") cout<<1<<endl;
        else cout<<3<<endl;
        return; 
    }
    int ans=1;
    for(int i=1;i<=n-2;i++) 
    {
        ans=ans*2%mod; 
    }
    cout<<ans<<endl;
    return;
}
signed main() 
{
    ios::sync_with_stdio(false); 
    cin.tie(nullptr);
    int T;
    cin>>T;
    while(T--)
    {
        solve(); 
    }
    return 0;
}

怜梦的棋子游戏

U736582 J - 怜梦的棋子游戏

这是一道博弈论题 ,可以使用 SG 函数结合线段树解决。

首先 ,由于不同棋子之间互不影响 ,因此可以将整个游戏看作多个独立游戏的组合。

定义 sgi 表⽰只有一个棋子位于位置 i 时的 SG 值 ,并规定: sg [0](#0) = 0

对于位置 i,如果选择位置 j,棋子就会移动到 j,或者分裂到 j 和 pj。

根据 SG 定理 ,这次操作得到的状态对应的 SG 值为:

sg *j* ⊕ sg *p* *j*

其中

⊕

表示按位异或。因此有:

sg *i* = mex {sg *j* ⊕ sg *p* *~j~* ∣ 1 ≤ j < i , a j + a *p*j < a *i*}

这里 mex 表⽰集合中最小的没有出现的非负整数。

接下来考虑如何快速计算所有 sgi。

首先 ,pi 表⽰ i 左侧最近的、满足 aj<ai 的位置 ,因此可以通过单调栈在 O (n) 时间内求出。

对于每个位置 j,定义:

v *j*= sg *j* ⊕ sg *p* ~*j*~

w *j*= a *j*+ a ~p j~那么计算 sgi 时 ,只需要考虑所有 j<i 且 wj<ai 的 vj。

我们从左到右依次计算 SG 值 ,使用线段树维护:

如果不存在对应的位置 ,则令 mnv=INF。

这样 ,对于当前的 i:

  • 如果 mnv<ai,说明 v可以通过一次合法操作得到。
  • 如果 mnv>=ai,说明 v无法通过一次合法操作得到。

因此 ,sgi 就是最小的满足 mnv>=ai 的非负整数 v。

使用线段树维护每个区间内 mn 的最大值 ,就能通过二分式下降 ,在 O (log n) 时间内找到这个位置。

求出 sgi 后 ,将:

v = sg *i* ⊕ sg *p* *i*

对应的 mnv 更新为:

min(mn *v* , a *i*+ a ~p i~)

随后继续处理下一个位置。

由于每个位置至多有 i-1 种操作 ,因此 sgi≤i-1,线段树只需要维护 0 到 n 的范围即可。

最后 ,对于每次询问 ,假设棋子分别位于:

那么整个游戏的 SG 值为:

如果 ans 不为 0,则先手必胜 ,输出 First;否则输出 Second。

时间复杂度为 O (n log n + Σ k)。

查看代码

复制代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
const int INF=1LL<<60;
struct SegTree
{
    int n;
    vector<int> mx;
    SegTree(int n):n(n)
    {
        mx.assign(4*(n+1),INF);
    }
    void update(int p,int l,int r,int x,int v)
    {
        if(l==r)
        {
            mx[p]=min(mx[p],v);
            return;
        }
        int mid=(l+r)>>1;
        if(x<=mid) update(p<<1,l,mid,x,v);
        else update(p<<1|1,mid+1,r,x,v);
        mx[p]=max(mx[p<<1],mx[p<<1|1]);
    }
    int query(int p,int l,int r,int x)
    {
        if(l==r) return l;
        int mid=(l+r)>>1;
        if(mx[p<<1]>=x)return query(p<<1,l,mid,x);
        else return query(p<<1|1,mid+1,r,x);
    }
};
void solve()
{
    int n,q;
    cin>>n>>q;
    vector<int> a(n+1),p(n+1),sg(n+1);
    for(int i=1;i<=n;i++) cin>>a[i];
    vector<int> stk;
    SegTree tr(n);
    for(int i=1;i<=n;i++)
    {
        while(!stk.empty()&&a[stk.back()]>=a[i])
        stk.pop_back();
        if(!stk.empty()) p[i]=stk.back();
        sg[i]=tr.query(1,0,n,a[i]);
        int v=sg[i]^sg[p[i]];
        if(v<=n)tr.update(1,0,n,v,a[i]+a[p[i]]);
        stk.push_back(i);
    }
    while(q--)
    {
        int k;
        cin>>k;
        int ans=0;
        while(k--)
        {
            int x;
            cin>>x;
            ans^=sg[x];
        }
        if(ans) cout<<"First"<<endl;
        else cout<<"Second"<<endl;
    }
}
signed main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    solve();
    return 0;
}

御坂网络

U736583 K - 御坂网络

这是一道最小费用最大流题 ,可以使用 CDQ 分治优化建图 + 最小费用最大流 解决。

由于每个御坂妹妹可以选择直接连接网络 ,或者连接到前面的某个御坂妹妹 ,并且每个御坂妹妹最多只能被后面的一个人连接 ,因此可以考虑拆点建图。

将每个御坂妹妹 i 拆成两个点 i 和 i+n,分别表⽰主动连接别人和被别人连接。

建立超级源点 S 和超级汇点 T,按照以下方式建边:

  1. S→i,容量为 1,费用为0。
  2. i→T,容量为 1,费用为 W,表⽰直接连接网络。
  3. i+n→T,容量为 1,费用为 0,限制每个人最多被连接一次。
  4. 对于所有 j<i,建立i→j+n,容量为 1,费用为 |ai-aj|。

这样 ,从源点到汇点发送 n 单位流量的最小费用 ,就是答案。

但是直接建图需要 O (n 2) 条边 ,运行费用流可能超时 ,因此考虑使用 CDQ 分治优化建图。

对于区间 l,r,设中点为 mid。

由于每个御坂妹妹只能连接前面的人 ,因此只需要考虑右半区间的点向左半区间的点连边。

将当前区间的所有频段排序去重 ,为每个不同的频段建立一个虚点。

对于相邻的两个频段 x,y,建立双向边 ,容量为 INF,费用为 |x-y|。

这样 ,从频段 x 对应的虚点走到频段 y 对应的虚点 ,最小费用就是 |x-y|。

随后:

  • 对于左半区间的每个 j,建立对应频段虚点到 j+n的边,容量为 1,费用为 0。
  • 对于右半区间的每个 i,建立i到对应频段虚点的边 ,容量为 1,费用为 0。

递归处理左右两个区间 ,就可以表⽰所有合法的连接关系。

这样建图的点数和边数均为 O (n log n) ,再运行最小费用最大流即可得到答案。

由于总代价可能超过 int 范围 ,需要使用 long long。

建图时间复杂度为 O (n log2 n ) ,图中点数和边数均为 O (n log n)。

费用流部分采用 ZKW 多路增广优化。

查看代码

复制代码
#include<bits/stdc++.h> 
using namespace std;
#define int long long 
const int INF=1LL<<60;
struct MCMF 
{
    struct Edge
    {
        int to,nxt,cap,cost; 
    };
    vector<Edge> e;
    vector<int> head,dis,vis,inq;
    int S,T;
    MCMF(int n,int s,int t):S(s),T(t) 
    {
        head.assign(n,-1); 
    }
    int newnode()
    {
        head.push_back(-1);
        return head.size()-1; 
    }
    void add(int u,int v,int cap,int cost) 
    {
        e.push_back({v,head[u],cap,cost});
        head[u]=e.size()-1;
        e.push_back({u,head[v],0,-cost});
        head[v]=e.size()-1; 
    }
    bool spfa() 
    {
        int n=head.size();
        dis.assign(n,INF);
        inq.assign(n,0);
        deque<int> q;
        q.push_back(T);
        dis[T]=0;
        inq[T]=1;
        while(!q.empty())
        {
            int u=q.front();
            q.pop_front();
            inq[u]=0;
            for(int i=head[u];i!=-1;i=e[i].nxt)
            {
                int v=e[i].to;
                if(e[i^1].cap>0&&dis[v]>dis[u]-e[i].cost) 
                {
                    dis[v]=dis[u]-e[i].cost;
                    if(!inq[v])
                    {
                        if(!q.empty()&&dis[v]<dis[q.front()]) q.push_front(v);
                        else q.push_back(v); 
                        inq[v]=1;
                    }
                }
            }
        }
        return dis[S]!=INF; 
    }
    int dfs(int u,int f) 
    {
        if(u==T) return f;
        vis[u]=1;
        int used=0;
        for(int i=head[u];i!=-1&&used<f;i=e[i].nxt) 
        {
            int v=e[i].to;
            if(e[i].cap>0&&!vis[v]&&dis[u]==dis[v]+e[i].cost) 
            {
                int d=dfs(v,min(f-used,e[i].cap));
                e[i].cap-=d;
                e[i^1].cap+=d;
                used+=d; 
            }
        }
        vis[u]=0;
        return used; 
    }
    int run(int n)
    {
        int ans=0,flow=0;
        while(flow<n&&spfa())
        {
            vis.assign(head.size(),0);
            int f=dfs(S,n-flow);
            if(!f) break;
            flow+=f;
            ans+=dis[S]*f; 
        }
        return ans;
    }
};
int n,W;
vector<int> a;
void build(int l,int r,MCMF &g) 
{
    if(l==r) return;
    int mid=(l+r)>>1;
    vector<int> b;
    for(int i=l;i<=r;i++)
    b.push_back(a[i]);
    sort(b.begin(),b.end());
    b.erase(unique(b.begin(),b.end()),b.end());
    int m=b.size();
    vector<int> p(m);
    for(int i=0;i<m;i++)
    p[i]=g.newnode();
    for(int i=1;i<m;i++)
    {
        int w=b[i]-b[i-1];
        g.add(p[i-1],p[i],n,w);
        g.add(p[i],p[i-1],n,w); 
    }
    for(int i=l;i<=r;i++)
    {
        int k=lower_bound(b.begin(),b.end(),a[i])-b.begin(); 
        if(i<=mid) g.add(p[k],n+i,1,0); 
        else g.add(i,p[k],1,0); 
    }
    build(l,mid,g);
    build(mid+1,r,g); 
}
void solve() 
{
    cin>>n>>W;
    a.resize(n+1);
    for(int i=1;i<=n;i++)
    cin>>a[i];
    int S=0,T=2*n+1;
    MCMF g(2*n+2,S,T);
    for(int i=1;i<=n;i++)
    {
        g.add(S,i,1,0);
        g.add(i,T,1,W);
        g.add(n+i,T,1,0); 
    }
    build(1,n,g);
    cout<<g.run(n)<<endl; 
    return;
}
signed main() 
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    solve(); 
    return 0;
}
相关推荐
啊我不会诶1 年前
BD202402跑步 线性求逆元 素数筛 数学
学习·算法·补题
匪石12 年前
K-独立钻石(dfs),G-邪恶铭刻(贪心)
深度优先·贪心·icpc·补题