• 分块入门与分块的经典应用


    前言

    分块是一种应用很广的根号算法

    有一个别名为“优雅的暴力”

    这篇文章偏向于介绍分块入门,并且讲解了几种OI中经典的分块套路

    (因为几道例题我做的时间间隔有点远,所以可能会有几种奇奇怪怪的不同的码风,请强迫症患者谨慎食用)

    分块入门

    例题:A Simple Problem with Integers

    给一个序列,支持区间加,区间查询

    (N<=100000) (Ai<=1e9) (M<=100000)

    M为操作数

    (其实就是线段树1)

    线段树和树状数组的板子题

    但是这里不讲线段树和树状数组的做法

    我们回归本真,思考一下使用暴力解决该题

    使用前缀和维护

    (O(n))修改 (O(1))查询

    直接加
    (O(1))修改 (O(n))查询

    当然。都会TLE(雾

    考虑优化,发现两种暴力都是有明显的复杂度瓶颈(一个在查询一个在修改)

    我们是不是可以以牺牲一种操作的复杂度为代价降低另一种操作的复杂度?(当然,总的复杂度需要比原先的复杂度低)

    这就需要使用到分块的思想

    定义

    块:将整个序列划分为多段序列,这些序列被称之为块

    块的大小:块内元素个数(一般为(sqrt(n)),但是可以根据不同的题目使用均值不等式计算出更优的块大小,一般用于卡常。平时用(sqrt{n})就可以了),记为(block)

    块的个数:(num=n/block)。即为(sqrt{n}),这也是为什么我们块的大小要选择(sqrt{n})的原因,让大小和块数尽可能均衡,使查询,修改的复杂度都为(sqrt{n})

    整块:在查询/修改操作中,一整个块都被包含在操作的区间中(如对于区间[1…10],块[1…3]即为整块)

    散块:在查询/修改操作中,部分元素被包含在操作区间中的块(如对于区间[1…10],块[10]即为散块)

    显然,对于每个操作,散块最多2个,整块最多(sqrt{n})

    一个序列,我们把它分成(sqrt{n})

    然后对于每个块分别统计前缀和

    查询的时候我们需要使用(sqrt{n})的时间来统计答案

    查询的时候是给出一个区间[l…r],因为我们把整个序列分成(sqrt{n})块,所以对于[l…r]这个区间,我们需要统计的整个的块的数目不超过(sqrt{n})个,对于两边边边角角的部分,我们直接使用暴力,也只需要(sqrt{n})时间

    总的复杂度为(O(sqrt{n}))

    对于修改操作,复杂度仍然存在瓶颈,我们仍然需要(O(n))修改每一块的前缀和

    引入一个东西:懒标记

    就是线段树下推时的那个玩意

    对于一个区间内所包含的整块

    我们只需要给当前块的懒标记加一下就好,查询的时候记得把每块的懒标记的值也给加上就好

    对于散块,我们暴力修改原数组,然后统计一下前缀和就好,因为散块最多只有两个,所以复杂度也是(O(sqrt{n}))

    总的修改复杂度为(O(sqrt{n}))

    所以对于一开始的那道题,使用分块对暴力进行优化我们可以在(O((n+m)sqrt{n}))

    可以说分块是一种优雅的暴力

    分块思想:整体维护,局部暴力

    考虑到我就这么空泛的去讲估计也很虚,所以放个代码,代码内有一定量注释(并不多,请结合上文理解)

    #include <bits/stdc++.h>
    
    #define ll long long
    #define inf 0x3f3f3f3f 
    #define il inline 
    
    namespace io {//读优
        
        #define int long long
        #define in(a) a=read()
        #define out(a) write(a)
        #define outn(a) out(a),putchar('
    ')
    
        #define I_int int 
        inline I_int read() {
            I_int x = 0 , f = 1 ; char c = getchar() ;
            while( c < '0' || c > '9' ) { if( c == '-' ) f = -1 ; c = getchar() ; } 
            while( c >= '0' && c <= '9' ) { x = x * 10 + c - '0' ; c = getchar() ; } 
            return x * f ;
        } 
        char F[ 200 ] ;
        inline void write( I_int x ) {
            if( x == 0 ) { putchar( '0' ) ; return ; }
            I_int tmp = x > 0 ? x : -x ;
            if( x < 0 ) putchar( '-' ) ;
            int cnt = 0 ;
            while( tmp > 0 ) {
                F[ cnt ++ ] = tmp % 10 + '0' ;
                tmp /= 10 ;
            }
            while( cnt > 0 ) putchar( F[ -- cnt ] ) ;
        }
        #undef I_int
    
    }
    using namespace io ;
    
    using namespace std ;
    
    #define N 100010
    #define M 5000
    
    int block , num ;
    // block - 块的大小
    // num - 块的个数
    int a[ N ] ;
    // a - 原数组
    int sum[ M ] , add[ M ] , L[ M ] , R[ M ] , bl[ N ] ;
    // sum - 区间和
    // add - 懒标记
    // L - 块左端点 R - 块右端点 bl - 当前点属于哪个块
    int n = read() , m = read() ;
     
    void build() {
        block = sqrt( n ) ;
        num = n / block ;
        if( n % block ) num ++ ;
        for( int i = 1 ; i <= num ; i ++ ) {
            L[ i ] = (i - 1) * block + 1 ;
            R[ i ] = i * block ;
        }
        R[ num ] = n ; // 有可能有不完整的块 
        for( int i = 1 ; i <= n ; i ++ ) {
            bl[ i ] = (i - 1) / block + 1 ;
            // -1 针对右端点, +1 针对左端点 
        }
        for( int k = 1 ; k <= num ; k ++ ) {
            for( int i = L[ k ] ; i <= R[ k ] ; i ++ ) {
                sum[ k ] += a[ i ] ; //处理前缀和 
            }
        }
    }
    
    void reset( int x ) { // 重新统计当前块的和 
        sum[ x ] = 0 ;
        for( int i = L[ x ] ; i <= R[ x ] ; i ++ ) 
            sum[ x ] += a[ i ] ;
    }
    
    void upd( int l , int r , int c ) {
        if( bl[ l ] == bl[ r ] ) { // 特判 
            for( int i = l ; i <= r ; i ++ ) a[ i ] += c ;
            reset( bl[ l ] ) ;
            return ;
        }
        for( int i = l ; i <= R[ bl[ l ] ] ; i ++ ) // 处理散块 
            a[ i ] += c ;
        for( int i = L[ bl[ r ] ] ; i <= r ; i ++ ) 
            a[ i ] += c ;
        reset( bl[ l ] ) ; reset( bl[ r ] ) ;
        // 处理整块 
        for( int i = bl[ l ] + 1 ; i < bl[ r ] ; i ++ ) 
            add[ i ] += c ;
    }
    
    int query( int l , int r ) {
        int ans = 0 ;
        if( bl[ l ] == bl[ r ] ) {
            for( int i = l ; i <= r ; i ++ ) 
                ans += a[ i ] + add[ bl[ i ] ] ;
            return ans ;
        }
        for( int i = l ; i <= R[ bl[ l ] ] ; i ++ ) 
            ans += a[ i ] + add[ bl[ i ] ] ;
        for( int i = L[ bl[ r ] ] ; i <= r ; i ++ ) 
            ans += a[ i ] + add[ bl[ i ] ] ;
        for( int i = bl[ l ] + 1 ; i < bl[ r ] ; i ++ ) 
            ans += sum[ i ] + add[ i ] * (R[ i ] - L[ i ] + 1) ;
        return ans ;
    }
    
    signed main() {
        for( int i = 1 ; i <= n ; i ++ ) a[ i ] = read() ;
        build() ;
        for( int i = 1 ; i <= m ; i ++ ) {
            int opt = read() , x = read() , y = read() , k ;
            if( opt == 1 ) {
                k = read() ;
                upd( x , y , k ) ;
            } else outn( query( x , y ) ) ;
        }
        return 0 ;
    }
    

    分块的应用

    在讲应用之前插播一个东西

    分块与树状数组,线段树对比

    树状数组和线段树的效率均为(O(nlogn)),树状数组常数较小,分块效率为(O(nsqrt{n}))

    一般树状数组常数优秀的话可以承受到1e6的数据范围

    线段树可以承受5e5的数据范围

    分块可以承受5e4的数据范围,常数优秀的话可以承受1e5的数据范围

    树状数组最难理解,代码实现最简单

    线段树较易理解,代码实现最复杂,常数较之树状数组会比较大

    分块易理解,代码实现难度适中,复杂度较高

    所以请根据实际情况选择不同的算法

    分块块的大小的取值问题

    最懒的取法:(sqrt{n})

    最正规的取法:用均值不等式来推

    最玄学的取法:在(sqrt{n})/均值不等式所推出来的大小上下浮动,可能会取出更优
    的块的大小

    好的取值可以帮助你卡更多的分(这点在后面蒲公英那道题很明显的体现了出来)

    然后不知道均值不等式怎么推?

    (sqrt{n})上下浮动,是上还是下根据实际情况:

    对于询问比修改多的操作,向上浮动

    对于修改比询问多的操作,向下浮动

    但其实正常情况下(即大部分题目)(sqrt{n})就够可以了,少部分题目才需要推块的大小来卡常(以及你的分块暴力如果想拿高分也可以推一下块的大小)

    几种应用将以例题形式呈现

    Luogu P2801 教主的魔法

    区间加,查询一个区间中大于等于k的数的个数

    N<=1e6

    按理说没办法过,但是事实上跑的挺快的

    将块内元素排序。

    修改时使用懒标记,对于散块暴力修改然后重新排序

    可以做到(sqrt{n})修改

    如何查询?

    因为每个块是互不影响的。所以我们可以对每个块二分查找第一个大于等于它的数的下标,区间右端点减去该下标即为该区间对答案的贡献。

    散块依旧暴力查询

    查询复杂度为(O(sqrt{n}*log2(sqrt{n})))

    #include <bits/stdc++.h>
    
    using namespace std;
    
    #define N 1000100
    
    int n,m,a[N];
    int block,num,l[N],r[N],belong[N],sum[N],add[N];
    
    void build(){
        block=sqrt(n);
        num=n/block;
        if(n%block)num++;
        for(int i=1;i<=num;i++){
            l[i]=block*(i-1)+1;
            r[i]=block*i;
        }
        r[num]=n;
        for(int i=1;i<=n;i++){
            belong[i]=(i-1)/block+1;
            sum[i]=a[i];
        }
        for(int i=1;i<=num;i++){
            sort(sum+l[i],sum+r[i]+1);
        }
    }
    
    void copy(int x){
        for(int i=l[x];i<=r[x];i++){
            sum[i]=a[i];
        }
        sort(sum+l[x],sum+r[x]+1);
    }
    
    void upd(int L,int R,int c){
        if(belong[L]==belong[R]){
            for(int i=L;i<=R;i++){
                a[i]+=c;
            }
            copy(belong[L]);
            return;
        }
        for(int i=L;i<=r[belong[L]];i++)a[i]+=c;
        copy(belong[L]);
        for(int i=l[belong[R]];i<=R;i++)a[i]+=c;
        copy(belong[R]);
        for(int i=belong[L]+1;i<=belong[R]-1;i++)add[i]+=c;
    }
    
    int find(int L,int R,int c){
        int r1=R;
        while(L<=R){
            int mid=(L+R)>>1;
            if(sum[mid]<c)L=mid+1;
            else R=mid-1;
        }
        return r1-L+1;
    }
    
    int query(int L,int R,int c){
        int ans=0;
        if(belong[L]==belong[R]){
            for(int i=L;i<=R;i++){
                if(a[i]+add[belong[i]]>=c)ans++;
            }
            return ans;
        }
        for(int i=L;i<=r[belong[L]];i++){
            if(a[i]+add[belong[i]]>=c)ans++;
        }
        for(int i=l[belong[R]];i<=R;i++){
            if(a[i]+add[belong[i]]>=c)ans++;
        }
        for(int i=belong[L]+1;i<=belong[R]-1;i++){
            ans+=find(l[i],r[i],c-add[i]);
        }
        return ans;
    }
    
    int main(){
        scanf("%d%d",&n,&m);
        for(int i=1;i<=n;i++)scanf("%d",&a[i]);
        build();
        for(int i=1;i<=m;i++){
            char ch[10];
            int L,R,c;
            scanf("%s%d%d%d",ch,&L,&R,&c);
            if(ch[0]=='M')upd(L,R,c);
            else printf("%d
    ",query(L,R,c));
        }
        return 0;
    }
    

    BZOJ 2002:弹飞绵羊

    对每个点点处理出跳出当前块要跳多少次,跳出当前块之后在哪个地方。

    因为是单点修改,所以直接修改整个块内的每个点就好,效率(O(sqrt{n}))

    对于查询,直接从当前点开始跳,只需要跳(sqrt{n})次,所以也是(O(sqrt{n}))

    #include <cstdio>
    #include <cmath>
    #include <algorithm>
    #include <cstring>
    #define ll long long
    #define N 200010
    inline void read(int &x){
        x=0;int f=1;char c=getchar();
        while(c<'0'||c>'9'){if(c=='-')f=-f;c=getchar();}
        while(c>='0'&&c<='9'){x=(x<<1)+(x<<3)+c-'0';c=getchar();}
        x*=f;
    }
    using namespace std;
    int n,a[N],m;
    int block,num,to[N],d[N],l[N],r[N],belong[N];
    void build(){
        block=sqrt(n),num=n/block;
        if(n%block)num++;
        for(int i=1;i<=num;i++){
            l[i]=(i-1)*(block)+1;
            r[i]=block*i;
        } 
        r[num]=n;
        for(int i=1;i<=n;i++){
            belong[i]=(i-1)/block+1;
        }
        for(int i=n;i;i--){
            if(belong[i+a[i]]!=belong[i]){
                d[i]=1;
                to[i]=i+a[i];
            }else {
                d[i]=d[i+a[i]]+1;
                to[i]=to[i+a[i]];
            }
        }
    }
    void upd(int x,int c){
        a[x]=c;
        for(int i=r[belong[x]];i>=l[belong[x]];i--){
            if(belong[i+a[i]]!=belong[i]){
                to[i]=i+a[i];
                d[i]=1;
            }else {
                d[i]=d[i+a[i]]+1;
                to[i]=to[i+a[i]];
            }
        }
    }
    int query(int x){
        int ans=0;
        while(x<=n){
            ans+=d[x];
            x=to[x];
        }
        return ans;
    }
    int main(){
        read(n);
        for(int i=1;i<=n;i++)read(a[i]);
        build();
        read(m);
        while(m--){
            int x,y;
            read(x);read(y);
            if(x==1)printf("%d
    ",query(y+1));
            else {
                int k;read(k);
                upd(y+1,k);
            }
        }
    }
    

    BZOJ 2120: 数颜色

    对每个点预处理出该点颜色的上一次在哪里出现,设为pre。

    那么在一个区间里面,颜色i第一次出现即意味着pre_i<l(l为区间左端点)

    所以我们可以套用教主的魔法那题的套路,对pre进行排序,查询时在块内二分查找得到该块对答案的贡献。复杂度(O(sqrt{n}*log2(sqrt{n})))

    但是这题不一样的是修改操作,这道题的修改需要O(n)的时间来修改(需要把整个的pre数组都给改了)

    因为BZOJ保证了修改的操作<=1000所以这题就可以用分块水了

    正解是带修莫队。

    luogu加强了数据这种分块写法只能水40分

    #include <bits/stdc++.h>
    
    using namespace std;
    
    inline void read( int &x ){
        x = 0 ; int f = 1 ; char c = getchar() ;
        while( c < '0' || c > '9' ) {
            if( c == '-' ) f = -1 ;
            c = getchar() ;
        }
        while( c >= '0' && c <= '9' ) {
            x = (x << 1) + (x << 3) + c - 48 ;
            c = getchar() ;
        }
        x *= f ;
    }
    
    #define N 1000100
    
    int belong[N],block,num,pre[N],last[N];
    int n,a[N],m,b[N];
    
    void reset(int x){
        int l=(x-1)*block+1,r=min(n,block*x);
        for(int i=l;i<=r;i++)pre[i]=b[i];
        sort(pre+l,pre+r+1);
    }
    
    void build(){
        block=int(sqrt(n)+log(2*n)/log(2));
        num=n/block;
        if(n%block)num++;
        for(int i=1;i<=n;i++){
            b[i]=last[a[i]];
            belong[i]=(i-1)/block+1;
            last[a[i]]=i;
        }
        for(int i=1;i<=num;i++)reset(i);
    }
    
    int find(int i,int x){
        int lt=(i-1)*block+1,l=lt,r=min(i*block,n);
        while(l<=r){
            int mid=(l+r)>>1;
            if(pre[mid]<x)l=mid+1;
            else r=mid-1;
        }
        return l-lt;
    }
    
    int query(int l,int r){
        int ans=0;
        if(belong[l]==belong[r]){
            for(int i=l;i<=r;i++){
                if(b[i]<l)ans++;
            }
            return ans;
        }
        for(int i=l;i<=belong[l]*block;i++){
            if(b[i]<l)ans++;
        }
        for(int i=(belong[r]-1)*block+1;i<=r;i++){
            if(b[i]<l)ans++;
        }
        for(int i=belong[l]+1;i<belong[r];i++){
            ans+=find(i,l);
        }
        return ans;
    }
    
    void upd(int l,int x){
        for(int i=1;i<=n;i++)last[a[i]]=0;
        a[l]=x;
        for(int i=1;i<=n;i++){
            int lt=b[i];
            b[i]=last[a[i]];
            if(lt!=b[i])reset(belong[i]);
            last[a[i]]=i;
        }
    }
    
    int main(){
        read( n ) ; read( m ) ;
        for(int i=1;i<=n;i++)read( a[i] ) ;
        build();
        for(int i=1;i<=m;i++){
            int l,r;
            char ch[10];
            scanf("%s",ch);
            read( l ) ; read( r ) ;
            if(ch[0]=='Q')printf("%d
    ",query(l,r));
            else upd(l,r);
        }
        return 0;
    }
    

    LuoguP4168 [Violet]蒲公英

    在线区间众数,经典分块题

    做法很多,这里提供一种(O(n*sqrt{n}+n*sqrt{n}*log2(sqrt{n}))的做法

    首先数的值域为1e9肯定要离散化一下,因为数最多有40000个所以开40000个vector,存一下每个数出现的位置

    预处理出每个以块的端点为左右端点的区间的众数,这种区间一共有(O(block^2))个,所以可以用(O(n*block))的时间复杂度来预处理

    可以发现的一点是,每个区间的众数,要么是散块里面的数,要么是中间所有整块的区间众数(因为散块中出现的那些数增加了中间的整块中第二大第三大的这些区间众数的出现次数,他们就有可能篡位了)

    那么我们可以在离散化之后,将每个数出现的位置存到一个vector里面,在处理散块中的数的时候,我们可以通过二分查找找出这个区间中该数出现过几次(二分查找右端点和左端点相减),效率是(O(sqrt{n}*log2(sqrt{n})))

    整块直接调用我们预处理出来的区间众数就可以了

    块的大小可以推一下均值不等式,据说在30~200之间比较好,30最快,我在洛谷上面块的大小用200跑了9000ms用30跑了3000ms,中间的数据也试过几个,都没有30的表现好(这是开了O2的,不开O2的话200跑不过去,30跑13000ms)

    #include <bits/stdc++.h>
    
    #define ll long long
    #define inf 0x3f3f3f3f
    #define il inline
    
    namespace io {
    
        #define in(a) a=read()
        #define out(a) write(a)
        #define outn(a) out(a),putchar('
    ')
    
        #define I_int int
        inline ll read() {
            ll x = 0 , f = 1 ; char c = getchar() ;
            while( c < '0' || c > '9' ) { if( c == '-' ) f = -1 ; c = getchar() ; }
            while( c >= '0' && c <= '9' ) { x = x * 10 + c - '0' ; c = getchar() ; }
            return x * f ;
        }
        char F[ 200 ] ;
        inline void write( I_int x ) {
            if( x == 0 ) { putchar( '0' ) ; return ; }
            I_int tmp = x > 0 ? x : -x ;
            if( x < 0 ) putchar( '-' ) ;
            int cnt = 0 ;
            while( tmp > 0 ) {
                F[ cnt ++ ] = tmp % 10 + '0' ;
                tmp /= 10 ;
            }
            while( cnt > 0 ) putchar( F[ -- cnt ] ) ;
        }
        #undef I_int
    
    }
    using namespace io ;
    
    using namespace std ;
    
    #define N 100010
    
    map< int , int > mp ;
    vector< int > vt[ N ] ;
    int val[ N ] , a[ N ] ;
    int t[ 5010 ][ 5010 ] ;
    int n , tot = 0 ;
    int block , num , bl[ N ] , L[ N ] , R[ N ] ; 
    int cnt[ N ] ;
    
    void pre( int x ) {
        int mx = 0 , id = 0 ;
        memset( cnt , 0 , sizeof( cnt ) ) ;
        for( int i = L[ x ] ; i <= n ; i ++ ) {
            cnt[ a[ i ] ] ++ ;
            if( cnt[ a[ i ] ] > mx || (cnt[ a[ i ] ] == mx && val[ a[ i ] ] < val[ id ] ) ) {
                mx = cnt[ a[ i ] ] ; id = a[ i ] ;
            }
            t[ x ][ bl[ i ] ] = id ;
        }
    }
    
    void build() {
        block = 30 ;
        num = n / block ;
        if( n % block ) num ++ ;
        for( int i = 1 ; i <= num ; i ++ ) {
            L[ i ] = (i - 1) * block + 1 ;
            R[ i ] = i * block ;
        }
        R[ num ] = n ;
        for( int i = 1 ; i <= n ; i ++ ) bl[ i ] = (i - 1) / block + 1 ;
        for( int i = 1 ; i <= num ; i ++ ) pre( i ) ;
    }
    
    int serach_ans( int l , int r , int x ) {
        return upper_bound( vt[ x ].begin() , vt[ x ].end() , r ) - lower_bound( vt[ x ].begin() , vt[ x ].end() , l ) ;
    }
    
    int query( int l , int r ) {
        int mx = 0 , id = t[ bl[ l ] + 1 ][ bl[ r ] - 1 ] ;
        mx = serach_ans( l , r , id ) ;
        if( bl[ l ] == bl[ r ] ) {
            for( int i = l ; i <= r ; i ++ ) {
                int x = serach_ans( l , r , a[ i ] ) ;
                if( x > mx || (x == mx && val[ a[ i ] ] < val[ id ])) { mx = x ; id = a[ i ] ; }
            }
            return id ;
        }
        for( int i = l ; i <= R[ bl[ l ] ] ; i ++ ) {
            int x = serach_ans( l , r , a[ i ] ) ;
            if( x > mx || (x == mx && val[ a[ i ] ] < val[ id ])) { mx = x ; id = a[ i ] ; }
        }
        for( int i = L[ bl[ r ] ] ; i <= r ; i ++ ) {
            int x = serach_ans( l , r , a[ i ] ) ;
            if( x > mx || (x == mx && val[ a[ i ] ] < val[ id ])) { mx = x ; id = a[ i ] ; }
        }
        return id ;
    }
    
    int main() {
        n = read() ; int m = read() ;
        int ans = 0 ;
        for( int i = 1 ; i <= n ; i ++ ) {
            a[ i ] = read() ;
            if( mp[ a[ i ] ] == 0 ) { mp[ a[ i ] ] = ++ tot , val[ tot ] = a[ i ] ; }
            a[ i ] = mp[ a[ i ] ] ;
            vt[ a[ i ] ].push_back( i ) ;
        }
        build() ;
        for( int i = 1 ; i <= m ; i ++ ) {
            int l = read() , r = read() ;
            l = (l + ans - 1) % n + 1 , r = (r + ans - 1) % n + 1 ;
            if( l > r ) swap( l , r ) ;
            outn( ans = val[ query( l , r ) ] ) ;
     	}
     	return 0 ;
    }
    

    最后

    分块在很多题目中是以非正解的形式出现的

    但是确实对于水分它是一个很好的算法

    (如果你的常数足够优秀的话甚至可以吊打正解)

    (如弹飞绵羊分块吊打lct)

    其他题目

    可以去写一下hzwer的数列分块入门

    然后如果不怕死的话可以去写一下lxl的毒瘤分块题

  • 相关阅读:
    python求余、除法运算、向下圆整、round圆整
    【转】从入门到实践 json练习详解~~和ython : groupby 结果浅解,&之后的 y_list=[v for _,v in y]
    ### 模块“*.dll”已加载,但对DllRegisterServer的调用失败,错误代码为0x80070005
    python从excel里读取数据
    文本文件和二进制文件的区别
    析构函数 声明为protected
    c语言中ln,lg,log的表示。c语言中ln,lg,log的表示。
    js设计模式--创建型--单例模式
    js设计模式--创建型--工厂模式
    解决ElementUI的table组件在flex布局下宽度不能自适应的问题
  • 原文地址:https://www.cnblogs.com/henry-1202/p/10124856.html
Copyright © 2020-2023  润新知