xcpc

快捷键alt+鼠标左键多行同时编辑

ctrl+shift+L 选中编辑代码中相同的内容

选中多行,按tab键可统一向右移动

选中文本后,Ctrl + [ 和 Ctrl + ] 可实现文本的向左移动 和 向右移动

按住Ctrl + Alt,再按键盘上的上或下键,可以使一列上出现多个光标

按shift+alt,再使用鼠标拖动,也可以出现竖直的列光标,同时可以选中多行

ctrl+/进行注释

 

vector是动态数组,除了熟悉的添加操作外,也可以使用a.erase(a.begin()+1)进行删除

要注意指针的使用,例如,*max_elements(a.beign()+1,a.begin()+n-1)当a中只有两个元素,这个表达式所表示的范围实际上是空,但是会返回a.begin()+n-1的元素

 

空间复杂度 online judge 并不在意,只要使用的空间不太离谱,不要一上来就 int[] dp = new int[Integer.MAX_VALUE] 就好,这样会导致程序无法运行RE

如果不告诉错误数据:把题干和代码重新读一遍,确保自己没有读错题;检查一遍代码,甚至重写一遍;写一个暴力版本的代码,保证可以通过小数据,然后造一些随机数据,分别传入暴力版本和WA了的版本,如果返回的值不一样,说明找到了错误数据

万能头文件 #include <bits/stdc++.h>

 

#include <bits/stdc++.h>

#define ll long long

Using i64 = long long

 

using namespace std;

 

int main()

 

加快执行效率

ios::sync_with_stdio(false),cin.tie(nullptr);

ios::sync_with_stdio(0);cin.tie(0);

关闭流同步

 

手写max、min函数来卡常

inline int max(int u,int v){return u>v?u:v;}

inline int min(int u,int v){return u<v?u:v;}

 

O(logn)基本都可

O(n)10^7

O(nlogn)10^5 ~ 5 * 10^5

O(n^2)1000 ~ 5000

O(n^3)200 ~ 500

O(2^n)20 ~ 24

O(n!)12

 

对拍

#include<bits/stdc++.h>

using namespace std;

int main()

{

    while (1) //一直循环,直到找到不一样的数据

    {

        system("data.exe > in.txt");

        system("baoli.exe < in.txt > baoli.txt");

        system("std.exe < in.txt > std.txt");

        if (system("fc std.txt baoli.txt > diff.log")) //当 fc 返回 1 时,说明这时数据不一样

            break;                          //不一样就跳出循环

    }

    return 0;

}

 

Cin使用完毕后如果想使用getline(cin,s),要先使用cin.get()读取末尾的换行符

 

数据量较大时可以用scanf优化读入

0x3f3f3f3f是1e9级别,对付int是足够的,如果还是不够大,可以用1e18或者LLONG_MAX

 

避免爆int,一方面答案要用ll,另一方面,ans+=i*j时要写(ll)i*j

 

异或只是操作时的处理逻辑有些差异,但实际上与四则运算是等价的,因此碰到异或运算相关的题目时不要先去考虑其特殊性,就按照一般的运算去思考,该用动规用动规,该暴力就暴力

 

当保证数组中的元素出现次数相同时,就可以思考输入的数组实际上就是一些元素的排列,由此可以得到一些性质(cf1944c)

 

判断奇偶性比较好的方法是利用位运算,也就是&1后是否为1进行判断,否则如果用模运算可能会因为负数而出现误判

 

如果对一个数组先排序再使用a.erase(unique(a.begin(),a.end()),a.end()),那么起到的是离散化的效果

如果是直接使用a.erase(unique(a.begin(),a.end()),a.end()),那么相当于对原数组中连续数字相同的段进行了压缩,例如 cf 2108 C,就是利用这一点来解决的

 

视图和范围:
cf 2085B

给定一个数组,每次操作选择数组中的区间,将这个区间替换为区间mex

问进行若干次这样的操作,能否使数组第一次长度为1时,数组元素是0

显然优先消去数组中的0,且最后一次操作是选择整个数组,因此考虑将数组划分为两部分,同时如果其中有0,就执行操作,否则可以跳过,这样能实现操作完成后数组长度不会先变成1

void solve() {

    int n;

    cin >> n;

   

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

   

    vector<array<int, 2>> ans;

    if (ranges::count(a | views::drop(2), 0)) {

        ans.push_back({2, n});

        n = 3;

    }

    if (ranges::count(a | views::take(2), 0)) {

        ans.push_back({0, 2});

        n--;

    }

    ans.push_back({0, n});

    cout << ans.size() << "\n";

    for (auto [l, r] : ans) {

        cout << l + 1 << " " << r << "\n";

    }

}

这些使用了 C++20 的范围库功能:

ranges::count(range, value):

功能:计算范围内等于指定值的元素数量

返回:计数结果(整数)

在这里用于检查特定范围内是否存在0元素

 

views::drop(n):

功能:创建一个视图,跳过原始范围的前n个元素

a | views::drop(2) 表示忽略 a 的前两个元素,只看剩余元素

 

views::take(n):

功能:创建一个视图,只包含原始范围的前n个元素

a | views::take(2) 表示只考虑 a 的前两个元素

 

a | views::drop(2):

这是 C++20 范围库引入的管道操作符,用于组合视图和范围

它将左侧的范围传递给右侧的视图适配器,生成一个新的视图

 

排序;

Cf2034 d

给定一串仅有0,1,2的数组,每次进行交换只能交换相差为1的元素,问一组操作次数不超过n的方案,使得数组变得有序

因为元素种类少,因此并不需要先对数组进行排序预处理以知道每个元素应该在哪个位置

没有具体思路的时候,考虑顺序遍历元素,如果是1或者2,显然要和0进行交换,当没有0时再考虑1

并不需要用cnt各自记录有多少个数,可以通过set的个数进行存储

auto get(vector<int> a){

    int n=a.size();

    vector<array<int,2>> ans;

    set<int> s[3];

    for(int i=0;i<n;i++){

        s[a[i]].insert(i);

    }

    auto swap1=[&](int i,int j){

        ans.push_back({i+1,j+1});

        s[a[i]].erase(i);

        s[a[j]].erase(j);

        swap(a[i],a[j]);

        s[a[i]].insert(i);

        s[a[j]].insert(j);

    };

    for(int i=0;i<n;i++){

        if(s[1].empty()){

           

        }else if(s[0].empty()){

            if(a[i]==2){

                swap1(i,*s[1].begin());

            }

        }else{

            if(a[i]==2){

                swap1(i,*s[1].begin());

            }

            if(a[i]==1){

                swap1(i,*s[0].begin());

            }

        }

        s[a[i]].erase(i);

    }

    return ans;

}

 

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    auto ans=get(a);

    if(ans.size()>n){

        reverse(a.begin(),a.end());

        for(auto &x:a){

            x=2-x;

        }

        ans=get(a);

        for(auto &[u,v]:ans){

            u=n+1-u;

            v=n+1-v;

        }

    }

    cout<<ans.size()<<'\n';

    for(auto [u,v]:ans){

        cout<<u<<' '<<v<<'\n';

    }

}

 

Cf2052 A

实际上是一个构造题,给定一个数组,可以看作是由一个有序数组进行两两之间的元素交换得到,问最多进行的操作次数是多少,两两元素之间最多进行两次操作

可以发现对于已经确定的序列,可以通过ab进行交换,再ba进行交换使得在序列不变的情况下增加操作的次数

但能进行这样操作的前提是这两个元素之前并没有进行过交换,这一点我们可以先假设最开始的得到的序列一定是只通过必要的交换得到的,也就是只有需要大的元素在前面时才进行交换

我们已知的是最终状态,因此显然是可以从末尾状态返回到最初状态,再将操作序列反转得到

从而为了使操作次数最大,先将数组变为从大到小,再将用冒泡排序变为从小到大,冒泡排序保证了最多只会进行一次交换

因此进行两次冒泡排序使合理的

void solve(){

    int n;

    cin>>n;

    vector<int> c(n);

    for(int i=0;i<n;i++){

        cin>>c[i];

        c[i]--;

    }

    vector<array<int,2>> ans;

    for(int i=0;i<n;i++){

        for(int j=0;j<n-i-1;j++){

            if(c[j]<c[j+1]){

                ans.push_back({c[j+1],c[j]});

                swap(c[j],c[j+1]);  

            }

        }

    }

    for(int i=0;i<n;i++){

        for(int j=0;j<n-i-1;j++){

            if(c[j]>c[j+1]){

                ans.push_back({c[j+1],c[j]});

                swap(c[j],c[j+1]);

            }

        }

    }

    reverse(ans.begin(),ans.end());

    cout<<ans.size()<<'\n';

    for(auto [x,y]:ans){

        cout<<y+1<<' '<<x+1<<" \n";

    }  

}

 

 

使用sqrt时可能因为取整时等问题导致x*x的值大于n

cf2020B

一个数n如果它的因数个数是偶数则为符合条件,求使得在1到n中这样数的个数为k的n

判断得这样子的数是非平方数

因此即计算n-sqrt(n)为k即可

但当数字是854258780613619262时这样子计算得到的结果有误,因此要重新计算小于等于某个数的平方数个数

i64 isqrt(i64 n){

    i64 x=sqrt(n);

    if(x*x>n){

        x--;

    }

    return x;

}

 

void solve(){

    i64 k;

    cin>>k;

    i64 l=k+1,r=2E18+5;

    while(l<=r){

        i64 mid=(l+r)>>1;

        if(mid-isqrt(mid)>=k){

            r=mid-1;

        }else{

            l=mid+1;

        }

    }

    cout<<l<<'\n';

}

证明:

一个整数分解因数,表达为A=XY,X、Y是两个不同的整数,即因子总是成对出现。如6=1x6=2x3。如果Y=X,则A=XY=Xx,A当然是完全平方数。x、x是两个因子,但按集合观点来看,根据元素互异性,只算一个,这次分解因子相重:个数就为奇数了

 

 

在遍历元素并且进行操作的时候,如果希望对应数组中的元素就是要进行例如排序操作的话,需要在遍历时加上引用

ICPC2024 网络赛第二场A

    for(auto &[_,a]:mp){

        sort(a.begin(),a.end(),greater<int>());

        for(int i=0;i<min((int)a.size(),c);i++){

            aa.emplace_back(a[i]);

        }

    }

 

 

cf 1955B

判断是否可以由一个数组中的元素排列得到另一个数组,用哈希表存储并每次删除会超时

for(int j=1;j<=n;j++){

            int k=mn+(j-1)*d;

            mp[k]--;

            if(mp[k]<0){

                cout<<"NO\n";

                return;

            }

        }

我们可以根据构造的方式重新开一个数组,在排序后判断两个数组是否相同即可

vector<int> a(n * n);

    sort(a.begin(),a.end());

    vector<int> b(n * n);

    b[0] = a[0];

    for (int i = 1; i < n; ++i) {

        b[i] = b[i - 1] + c;

    }

    for (int i = 1; i < n; ++i) {

        for (int j = 0; j < n; ++j) {

            b[i * n + j] = b[(i - 1) * n + j] + d;

        }

    }

    sort(b.begin(),b.end());

    cout << (a == b ? "YES" : "NO") << '\n';

 

 

判断一个数是不是完全平方数,一方面可以提前打表将各个平方数加到set中,另一方面可以ll p=sqrt(x);if(p*p==x)来判断,同时相邻的平方数就是p*p,(p+1)*(p+1)

 

数据量不大的情况下可以没必要记录具体修改的位置,直接遍历即可

对于一些违反我们所需要大小关系的位置,可以用bool运算来存储相应的位数

cf1980D

vector<int> b(n - 1);

for (int i = 0; i < n - 1; i++) {

    b[i] = gcd(a[i], a[i + 1]);

}

 

int bad = 0;

for (int i = 1; i < n - 1; i++) {

    bad += b[i] < b[i - 1];

}

int ans = 0;

if (bad - (b[1] < b[0]) == 0) {

    ans = 1;

}

if (bad - (b[n - 2] < b[n - 3]) == 0) {

    ans = 1;

}

//数据量不大的情况下没必要定位出要修改的位置,直接枚举修改的位置即可

for (int i = 1; i < n - 1; i++) {

    int tmp = bad;

    tmp -= b[i] < b[i - 1];

    int g = gcd(a[i - 1], a[i + 1]);

    if (i - 1 > 0) {

        tmp -= b[i - 1] < b[i - 2];

        tmp += g < b[i - 2];

    }

    if (i + 1 < n - 1) {

        tmp -= b[i + 1] < b[i];

        tmp += b[i + 1] < g;

    }

    if (tmp == 0) {

        ans = 1;

    }

}

 

构建nxt数组来实现跳跃

Cf 2117C

有一个大小为 n 的数组 a。他想将这个数组分割成一个或多个连续的段,使得每个元素 ai恰好属于一个段。

如果对于每一个段 b[j] ,所有在 b[j] 中的元素也出现在 b[j+1]中(如果存在的话),那么这个分割就被称为酷分割。也就是说,一个段中的每一个元素必须也出现在它之后的段中。

如果反向考虑,那就是每次找当前出现次数中,位置最小的那一个,然后每次跳跃到这个位置,如果每次使用二分进行查找,那么会超时,比较合适的是提早构建一个跳跃数组,然后每次就是取当前这个分段中下一个位置的最值然后移动到对应位置

因为如果反向移动实际上不太好操作,因为一开始的移动不是很方便,如果考虑正向,那么就比较简单了,可以证明,我们第一个划分只选择第一个元素一定不会劣,因此我们初始就可以选择l=0,r=0

void solve() {

    int n;

    cin >> n;

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

        a[i]--;

    }

    vector<int> pos(n, n);

    vector<int> nxt(n);

    for (int i = n - 1; i >= 0; i--) {

        nxt[i] = pos[a[i]];

        pos[a[i]] = i;

    }

    int l = 0, r = 0;

    int ans = 1;

    while (true) {

        int mx = 0;

        for (int i = l; i <= r; i++) {

            mx = max(mx, nxt[i]);

        }

        if (mx == n) break;

        l = r + 1, r = mx;

        ans++;

    }

    cout << ans << '\n';

}

另一种方法

题意简单来说就是一个分段中的每个元素都必须同时出现在它后面的分段中

为方便存储,我们可以用 set 来存储,因为这个容器里不会出现同样的数,因为要尽可能多分,所以比较两个分区的数字数量是否相同就行了

while(T--){

    int n,ans=0;

    set<int>s,g;           //set数组,其中s容器表示下一个要分的分区,g容器表示此前出现的所有数

    cin>>n;

    int a[n+1];

    for(int i=1;i<=n;i++)cin>>a[i];

    for(int i=1;i<=n;i++){

        g.insert(a[i]);

        s.insert(a[i]);

        if(g.size()==s.size()){

            ans++;

            s.clear();         //记得清空

        }

    }

    if(ans==0)cout<<1<<'\n';else cout<<ans<<'\n';      //记得带上特判

}

 

 

 

打表会超数组大小上限时就要尝试找规律了

 

避免非整数运算(精度问题),例如n/2-i->n-2i进行判断,否则就要用

pos = fabs(n*1.0/2-i) < fabs(n*1.0/2-pos) ? i : pos;

关于double的精度问题可以尝试使用long double

 

int 最大值对应10^9(2147483647

声明的是固定大小的数组会有开数组的限制,为了突破这个限制,可以使用new来分配,就没有大小的限制了。比如 int* a = new int[100000000],可以随便用。记得delete[] a就行)

数据范围开到264时需要使用unsigned long long

 

类似中位数和而二分查找的问题要注意改变一个数字的时候,是整个数组都会发生变化(指排完序之后的位置或其他)还是只影响当前位置的元素

 

规律题注重一些特殊位置的元素,例如第一个元素和最后一个元素,以及每次操作中不变的一些数据,根据以上特点进行操作cf 1954C

 

在输入时已经知道对应元素排完序之后的位置时,可直接写作int tmp;cin>>tmp;p[tmp]=i,没必要后续再构建一个下标数组再排序

 

答案要*1000并且舍弃小数,所以可以把原数列的每一项*10000最后/10,因为要注意小数加减的影响,因此不能只乘1000

 

斐波那契数列增长很快,在1e9范围内只有不到50个数,因此在这类数据范围条件下,可以直接进行打表去解决0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040, 1346269,

 

从特殊到一般的思考方式

牛客24寒假6J   从特殊情况考虑(必不可能合法的情况),考虑选择的时候先统一赋值(因为只有红色节点的子树才是有要求的,对于不符合了,再去考虑修改

 

数组分割为若干区间

Cf 2051F

可以把小丑牌所在区间表示为不重复的线段[l,r],也就是l,r之间的位置都是可以到达的,这样每次只需要考虑每次操作边界会如何变化

对于操作的位置a[i]<l的情况,可能的位置段就会变成[l-1,r],a[i]>r的情况,可以类似得到将变成[l,r+1]

如果是l<=a[i]<=r,依然可以分成3段看,[l,a[i]-1],[a[i+1],r]以及[a[i],a[i]],操作之后,可以发现[l,r]仍然存在,只不过增加了[1,1]和[n,n]两段

事实上,从一开始可能所处位置对应的线段个数就不会超过3,具体来说,看一看作是[m,m]向左右两侧扩展,[1,1]只会向右侧扩展,而[n,n]只会向左侧扩展

void solve(){

    int n,m,q;

    cin>>n>>m>>q;

    int pre=0,suf=n+1,l=m,r=m;

    // pre indicate [1,pre]   suf indicate [suf,n]

    // l,r indicate [m,m]->[l,r]

    for(int i=0;i<q;i++){

        int a;

        cin>>a;

        int npre=pre,nsuf=suf;

        if(l<=r){

            if(l<=a && a<=r){

                npre=max(npre,1);

                nsuf=min(nsuf,n);

            }

            if(l==a && r==a){

                l=1;

                r=0;

            }else if(a>r){

                r++;

            }else if(a<l){

                l--;

            }

        }

        if(a<=pre || a>=suf){

            npre=max(npre,1);

            nsuf=min(nsuf,n);

        }

        if(pre>=1 && a>pre){

            npre=max(npre,pre+1);

        }

        if(suf<=n && a<suf){

            nsuf=min(nsuf,suf-1);

        }

        pre=npre;

        suf=nsuf;

        if(l<=r && l<=pre){

            pre=max(pre,r);

            l=1;

            r=0;

        }

        if(l<=r && r>=suf){

            suf=min(suf,l);

            l=1;

            r=0;

        }

        int ans=min(n,pre+n+1-suf+r-l+1);

        cout<<ans<<" \n"[i==q-1];

    }

}

 

 

Cf 2038B

给定一个数组,可以对数组的某一位置i对应的元素-2并且对(I mod n )+1位置对应的元素+1

问将数组所有元素变得相等的最小操作次数或者输出无解

全部都做一次的话就是所有元素都减一,这样显然不会让元素变得相等,因此一定至少有一个位置是不操作的

若某个位置的操作次数为b[i],不妨先假设将所有数都变成0,那么就是2*b[i]-b[i-1]=a[i],那么b[i]的和就是a[i]的和

这样操作后,数组可以是全0,全1或者全2,但如果不相等,那么就说明不可能再继续修改使其数组元素全相等了

在将所有元素都变成0之后,再计算操作次数,是当前的次数减去最小的操作*n(相当于每个元素再都加上对应的次数),这样一定能保证是最优的

void solve(){

    int n;

    cin>>n;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<i64> b(n);

    while(*max_element(a.begin(),a.end())>=3){

        vector<i64> c(n);

        for(int i=0;i<n;i++){

            c[i]=a[i]/2;

            b[i]+=c[i];

        }

        for(int i=0;i<n;i++){

            a[i]-=c[i]*2;

            a[(i+1)%n]+=c[i];

        }

    }

    if(a!=vector(n,a[0])){

        cout<<-1<<'\n';

        return;

    }

    cout<<accumulate(b.begin(),b.end(),0ll)-*min_element(b.begin(),b.end())*n<<'\n';

}

(先不考虑将哪个元素作为标准而是将所有元素都变成0再减去操作次数最小的那个元素是这题的一个切入点)

 

数组修改

ICPC2024 成都I

给定一个长度为n的序列,称一个好的划分大小满足:按照给定方式划分的每份序列单调不降,q次修改,每次修改序列中一个位置的值,询问每次修改前后的好的划分大小的数量

对于这种修改是被保留的,考虑一种特征来记录数组,然后每次修改的时候,先减周围和他相关的,修改完毕后再加上

显然,对于a[i]>a[i+1]的位置,划分过程中,a[i]和a[i+1]一定在不同的划分序列中,也就是i可以作为划分的一个断点

因此划分大小是断点i的因数,总的划分方案数就是所有断点的gcd的因数个数

可以利用哈希来进行存储

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> a(n+2);

    a[0]=0,a[n+1]=INT_MAX;

    vector<int> b[n+1];

    vector<int> f(n+1),c(n+1);

    int target=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    for(int i=1;i<=n;i++){

        if(a[i]>a[i+1]){

            target++;//特殊点个数

        }

    }

    for(int i=1;i<=n;i++){

        for(int j=i;j<=n;j+=i){

            if(a[j]>a[j+1]) c[i]++;//i作为因数的特殊点个数

            b[j].emplace_back(i);//记录j的因子

        }

        f[c[i]]++;

    }

    cout<<f[target]<<'\n';

    auto modify=[&](int x,int val)->void{

        if(a[x]<=a[x+1]) return;//不需要修改

        target+=val;

        for(int i=0;i<b[x].size();i++){

            int j=b[x][i];

            f[c[j]]--;

            c[j]+=val;

            f[c[j]]++;

        }

    };

    while(q--){

        int idx,x;

        cin>>idx>>x;

        if(idx){

            modify(idx-1,-1);//del

        }

        modify(idx,-1);

        a[idx]=x;

        if(idx>0){

            modify(idx-1,1);//update

        }

        modify(idx,1);

        cout<<f[target]<<'\n';

    }

}

 

记录数组元素的位置

Cf2028 D
对于他自己而言,只能从小往大进行交换

a->b 要求在他们的排列中 p[a]>p[b]

连着3个下降可以换成两步换

自己的交换是单调的,因此可以存储每个的最大值

void solve(){

    int n;

    cin>>n;

    vector p(3,vector<int>(n)),invp(3,vector<int>(n));

    for(int i=0;i<3;i++){

        for(int j=0;j<n;j++){

            cin>>p[i][j];

            p[i][j]--;

            invp[i][p[i][j]]=j;//记录元素的对应下标

        }

    }

    vector<array<int,2>> g(n,{-1,-1});

    g[0]={0,0};

    int mx[3] {p[0][0],p[1][0],p[2][0]};//表示当前排列中的最大值

    for(int i=1;i<n;i++){//保证了单调,因此只要在排列中对应的元素更大那么就可以进行交换

        for(int j=0;j<3;j++){

            if(mx[j]>p[j][i]){

                g[i]={j,invp[j][mx[j]]};//与谁交换,被交换的元素位次(也就是手中的牌的数字)

            }

        }

        if(g[i][0]!=-1){

            for(int j=0;j<3;j++){

                mx[j]=max(mx[j],p[j][i]);

            }

        }

    }

    if(g[n-1][0]==-1){

        cout<<"NO\n";

        return;

    }

    vector<array<int,2>> ans;

    for(int i=n-1;i;){

        auto [j,x]=g[i];

        ans.push_back({j,i});

        i=x;

    }

    reverse(ans.begin(),ans.end());

    cout<<"YES\n";

    cout<<ans.size()<<'\n';

    for(auto [x,y]:ans){

        cout<<"qkj"[x]<<' '<<y+1<<'\n';

    }

}

 

在当前数组中查找大于某个元素的元素个数

这种相交重叠区间的相关的做法类似地:Cf 2103F,2025天梯赛 L2-3

CCPC2024 山东 C

给定数轴上的n条线段,每条线段可以涂上k种颜色中的一种,要求颜色相同的线段不能相交,求方案数

想要利用multiset进行存储,但是set和multiset的迭代器都不能减,且lowbound函数在符合条件的元素存在时会返回该元素对应的迭代器,否则返回当前容器内的元素个数

一个比较巧妙的方法不是用pair存储左右端点然后排序

而是将左端点右端点分开存储,同时左端点标号为1,右端点标号为-1(类似于扫描线中的操作)

再进行排序

这样用cur来维护,cur的数值就代表了当前有多少个l小于当前的r,也就是当前加入的线段与多少条线段相交

void solve(){

    int n,k;

    cin>>n>>k;

    vector<pair<int,int>> a;

    for(int i=0;i<n;i++){

        int l,r;

        cin>>l>>r;

        a.push_back({l,1});

        a.push_back({r+1,-1});

    }

    sort(a.begin(),a.end());

    i64 ans=1ll;

    //计算在其前面与其重叠的个数

    int cur=0;

    for(auto [_,y]:a){

        if(y==1){

            (ans*=(k-cur))%=mod;

        }

        cur+=y;

    }

    cout<<ans<<'\n';

}

题解中的做法是将问题看作最少把区间分成几组,才能让组内区间不相交(也就是视作一个interval partitioning 区间划分问题)

贪心解法是:将所有的区间按照左端点从小到大排序,维护每一组的右端点g[j],若存在一组的右端点小于当前区间的左端点l[i],那么就可以将当前区间分入那一组,否则新开一组

因为有多组满足要求的前提下,随便选一组都可以,因此可以将g[j]<l[i]的组都看成g[j]=0

因为k很大,所以可以用一个堆来维护g[j]!=0的值

 

对于求一个元素被包含在多少个区间中就可以按照这种算法进行操作

Cf 2051E

有n个顾客,如果商品的价格<=a,那么顾客会购买,如果商品的价格>a但<=b,那么顾客也会购买,但是会留下一个差评

问收到差评个数不超过k个的情况下,最大的利润为多少

对于一个价格,所得到的利润是大于等于该价格的b的个数,而受到差评的个数是该价格被包括在多少个(a,b)区间中

同时,显然我们所选择的价格要么是a要么是b,因此可以进行预处理出每个a和b对应处于的区间个数

于是可以用下标数组进行排序,同时采用上面相同的算法,将a表示为-1,将b表示为+1,特别要注意的是对于相同数的处理,对于相同的b,所处的交集个数是相同的,且对于a和b相同的位置,需要先将a的交集个数处理完毕,再处理b的交集个数

void solve(){

    int n,k;

    cin>>n>>k;

    vector<int> b(n);

    vector<int> c(2*n);

    for(int i=0;i<n;i++){

        cin>>c[i];

    }

    for(int i=0;i<n;i++){

        cin>>b[i];

        c[i+n]=b[i];

    }

    vector<int> it(2*n);

    iota(it.begin(),it.end(),0);

    sort(it.begin(),it.end(),[&](int i,int j){

        if(c[i]==c[j]) return i>j;

        return c[i]<c[j];

    });

    vector<int> cnt(2*n);

    int cur=0;

    for(int i=0;i<2*n;i++){

        if(it[i]>=n){

            vector<int> tmp;

            tmp.emplace_back(it[i]);

            while(i+1<2*n && it[i+1]>=n && c[it[i+1]]==c[it[i]]){

                tmp.emplace_back(it[i+1]);

                i++;

            }

            for(int j:tmp){

                cnt[j]=cur;

            }

            if(i+1<2*n && it[i+1]<n && it[i]>=n && c[it[i+1]]==c[it[i]]){

                i++;

                vector<int> tmp2;

                tmp2.emplace_back(it[i]);

                while(i+1<2*n && it[i+1]<n && c[it[i+1]]==c[it[i]]){

                    tmp2.emplace_back(it[i+1]);

                    i++;

                }

                for(int j:tmp2){

                    cnt[j]=cur;

                }

                cur-=tmp.size();

                cur+=tmp2.size();

            }else{

                cur-=tmp.size();

            }

        }else{

            vector<int> tmp2;

            tmp2.emplace_back(it[i]);

            while(i+1<2*n && it[i+1]<n && c[it[i+1]]==c[it[i]]){

                tmp2.emplace_back(it[i+1]);

                i++;

            }

            for(int j:tmp2){

                cnt[j]=cur;

            }

            cur+=tmp2.size();

        }

    }

    sort(b.begin(),b.end());

    // for(int i=0;i<2*n;i++){

    //     cout<<c[it[i]]<<' '<<cnt[it[i]]<<'\n';

    // }

    i64 ans=0;

    for(int i=0;i<2*n;i++){

        if(cnt[it[i]]>k) continue;

        int tmp=b.end()-lower_bound(b.begin(),b.end(),c[it[i]]);

        // cout<<tmp<<'\n';

        ans=max(ans,1ll*tmp*c[it[i]]);

    }

    cout<<ans<<'\n';

}

Jiangly

整体的思想是相同的,不过使用了cnt0和cnt1分别用于存储未闭合的区间左端点和未出现过的区间个数

同时,通过设置lst,可以直接避免相同位置的重复计算,通过cnt0和cnt1的分别设置,保证了对于数值相同的a和b的不同处理

因为数值相同的a和b,在具体计算利润的时候是相同的,因此虽然是在对a处理,但计算的结果可以视作是b的

void solve(){

    int n,k;

    cin>>n>>k;

    vector<int> a(n),b(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    for(int i=0;i<n;i++){

        cin>>b[i];

    }

    vector<array<int,2>> e;

    for(int i=0;i<n;i++){

        e.push_back({a[i],0});

        e.push_back({b[i],1});

    }

    int cnt1=n,cnt0=0;

    sort(e.begin(),e.end());

    i64 ans=0;

    int lst=0;

    for(auto [x,i]:e){

        if(x>lst && cnt0<=k){

            ans=max(ans,1ll*x*(cnt0+cnt1));

        }

        lst=x;

        if(i==0){

            cnt1--;

            cnt0++;

        }else{

            cnt0--;

        }

    }

    cout<<ans<<'\n';

}

 

 

求出数组中所有包含某个区间的区间的交集

Cf 2042D

void solve(){

    int n;

    cin>>n;

    vector<int> l(n),r(n);

    for(int i=0;i<n;i++){

        cin>>l[i]>>r[i];

    }

    vector<int> L(n,-1),R(n,-1);

    vector<int> p(n);

    iota(p.begin(),p.end(),0);

 

    {

    // the right boundry of the intersection

    sort(p.begin(),p.end(),[&](int i,int j){

        if(l[i]!=l[j]){

            return l[i]<l[j];

        }else{

            return r[i]>r[j];

        }

    });

    set<int> s;

    for(int j=0;j<n;j++){

        int i=p[j];

        auto it=s.lower_bound(r[i]); // garantee the elemenet in the set l[k]<=l[i]

        if(it!=s.end()){

            R[i]=*it;

        }

        s.insert(r[i]);

        if(j+1<n && l[i]==l[p[j+1]] && r[i]==r[p[j+1]]){// same , the intersection is itself

            R[i]=r[i];

        }

    }

    }

 

    {

    sort(p.begin(),p.end(),[&](int i,int j){

        if(r[i]!=r[j]){

            return r[i]>r[j];

        }else{

            return l[i]<l[j];

        }

    });

    set<int> s;

    for(int j=0;j<n;j++){

        int i=p[j];

        auto it=s.upper_bound(l[i]);

        if(it!=s.begin()){

            L[i]=*prev(it); // should garantee that l[*it]<=l[i]

        }

        s.insert(l[i]);

        if(j+1<n && l[i]==l[p[j+1]] && r[i]==r[p[j+1]]){

            L[i]=l[i];

        }

    }

    }

 

    for(int i=0;i<n;i++){

        int ans;

        if(L[i]==-1){

            ans=0;

        }else{

            ans=R[i]-L[i]-(r[i]-l[i]);

        }

        cout<<ans<<'\n';

    }

}

 

 

类似于前缀和数组的思想

当我们要对数组进行分段,并且每段的所贡献的数值逐个增加的时候

并不是考虑每碰到一段加上对应的数值,而是考虑遍历分段的左端点,当遇到一个端点时加上递增的数值,这样类似于每次区间加的思想使得当前的数值就是实际的数值

Cf 2042c

要求对数组分段,数组中每个元素具有所属的对象,要求其中一人的最终分数比另一人大k的最小组数

显然想到利用二者的计数差,同时划分是从前往后的,因此我们每次加上的段落是逐渐减小的,因此我们维护的计数差是后缀的

某一个点作为分割点就是将这个点之后的差值加入贡献中

因为要尽可能拉大差距,因此可以将后缀差值数组进行排序,我们从大到小地加入差值

可以证明这样操作是可行的,因为我们每次增加都是区间加,且每个区间所对应的数值是相等的,因此一个区间先加和后加它的贡献是恒定的(如果本来应该后加的顺序上变成了先加的,那么在原来在其之前加入的区间加入时会覆盖该区间,从而这个区间的数值仍然是正确的),只是如果要具体输出顺序下的分段端点会有所影响,但是仅仅计算数值时可以任意地进行加

void solve(){

    int n,k;

    cin>>n>>k;

    string s;

    cin>>s;

    vector<int> f(n);

    for(int i=n-1;i>=0;i--){

        if(i<n-1){

            f[i]=f[i+1];

        }

        if(s[i]=='1'){

            f[i]++;

        }else{

            f[i]--;

        }

    }

    f.erase(f.begin());// the first's score is 0

    sort(f.begin(),f.end(),greater<int>());

    i64 sum=0;

    for(int i=1;i<n;i++){

        sum+=f[i-1];

        if(sum>=k){

            cout<<i+1<<'\n';

            return;

        }

    }

    cout<<-1<<'\n';

}

这题的trick之前好像区域赛上也出现过,就是对区间加时用某一个特征去排序,然后按照大小顺序贪心地选择即可,最后的结果是正确的,因为被选择的那些值一定可以整理出一个对应的合法方案

 

Cf2046 a

一个2行n列的矩阵,我们可以无限次任意交换其中的两列,找出一条路径,路径只能向下走或者向右走,问路径上的元素和的最大值是多少

同样实际上的交换我们并不关心,因为可以进行无限次的操作,因此只要确定哪些是需要取上方的,哪些是取下方的,就可以任意排列使得去上方的都在前面,取下方的都在后面

从而可以贪心地对每个列都取两个元素的最大值,同时从上到下的那一步也找最大值 

void solve(){

    int n;

    cin>>n;

    vector a(n,array<int,2>());

    for(int i=0;i<n;i++){

        cin>>a[i][0];

    }

    for(int i=0;i<n;i++){

        cin>>a[i][1];

    }

    int ans=0;

    int change=INT_MIN;

    for(int i=0;i<n;i++){

        ans+=max(a[i][0],a[i][1]);

        change=max(change,min(a[i][0],a[i][1]));

    }

    ans+=change;

    cout<<ans<<'\n';

}

 

 

每次都要记录划分的位置显得太麻烦时,可以直接进行操作,碰到需要划分的位置时利用递归划分

 

能通过重复排列构成时,可以只检查前缀和后缀(因为最多只能有一个循环节不满足),两个情况种有一个情况满足就说明可行;答案具有单调性但又需要满足一定特殊条件的不能用二分,如找对于n符合条件的最小因数,可以直接从小到大枚举,如果可行,直接返回当前的i(cf1950E)

 

利用方向数组在矩形中进行遍历

int dx[5]={0,0,0,-1,1},dy[5]={0,-1,1,0,0};

for(int i=1;i<=4;i++)

 //用direct数组来向四方扩展。

      int xx=a[head].x+dx[i],yy=a[head].y+dy[i];

同时注意有些题目中是否有顺序的要求(如P1238中搜索的方向要遵循左上右下的优先顺序)

在矩阵中进行上下左右四个方向的移动时,要注意判断下标是否越界,但这样的影响代码的简洁,我们可以采用在矩阵的最外侧封上墙,即例如矩阵的最大大小是14*14,我们就开一个16*16的二维矩阵,这样走到边界时自然无法再进行深搜了

 

而对于一些特殊的矩阵形式(如矩阵中i,j位置的元素是由ai*bj得到),那么求子矩阵的最大值,实际上是在求两个子数组乘积的最大值(因为有(ai+aj)*(bx+by)=aibx+aiby+ajbx+ajby)

对于一般形式最大子矩阵可以参考线性dp做法

 

需要存储我们搜索得到的路径时,可以用一个node结构体数组a,利用回溯去存储,即回溯时同时记录这是路径上的第k个节点,判断这个点在路径上就可以更新a[k],在路径终点上输出

 

cf1976a

数字的ascii码小于字母,因此直接判断原字符串是否有序即可

if(is_sorted(all(s))){

        cout<<"YES\n";

        return;

    }else{

        cout<<"NO\n";

        return;

    }

 

计算2的幂直接__lg即可

 

与除法相关的比较时,为避免精度问题,可以使用交叉相乘比较

或者说为了避免浮点数运算,一般总是使用乘的方式去解决问题,例如判断两条直线的斜率相同,为了避免浮点数之间的比较,可以通过两方向向量共线去判断,即x1y2-x2y1==0

或者距离相关的问题利用平方去解决

 

答案的数值可能超过10^9时就要考虑用高精度了

 

字符串输入时如果有换行且我们之后输出时要包含回车,那我们可以手动加上

while(cin.getline(line,88))

{

    strcat(org,line);

    strcat(org,"\n");

}

字符串用cin读到空格就会停止,用getchar可以读入空格字符

可以利用stringstream和getline去分割字符串

 stringstream ss(preorder);  // 使用 stringstream与getline 分割字符串

 string temp;

// getline(输入流,暂存从流中读取的字符串,读取终止符)

while (getline(ss, temp, ','))

 

 

与数组中数据的大小关系有关的题目时,可以将数组按照各元素的大小画图去直观地看元素之间的关系,对于单调递增问题,一般将数组中的元素看作是几个单调段,一般利用段中的连续性去解决问题,并且最后的单调子序列一定是这其中的单调段拼接而成的,如果要删除的是连续子数组时,一般肯定是要从第一和最后一个单调段中找数字

 

交互题

ICPC2024 南京G

给定一棵n个点的二叉树,树上有一个特殊节点s,设d(u,v)表示树上两点之间的距离,每次询问提供两个点u,v,返回d(u,s)和d(v,s)的大小关系

要求在[log2n]次询问内求出s的编号

交互的本质是二分,树上的二分考虑树的重心

节点个数为n的树的重心u满足:去掉u之后,剩下的连通块个数小于等于n/2,因为给定的是二叉树,因此可以对u的度数进行讨论:
u的度数为0,说明答案就是u

U的度数为1,说明n=2,那么询问u和邻居即可

U的度数为2,询问u的两个邻居,就可以把问题规模缩小到[n/2]

U的度数为3,询问三个连通块里较大的两个,最小的那个连通块加一,一定小于等于[n/2],可以按照n的奇偶性来讨论反证出3*[n/2]+1<=n是不满足的

这样每次询问都可以把问题规模从n变为[n/2]

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n+1);

    for(int i=0;i<n;i++){

        int x,y;

        cin>>x>>y;

        if(x){

            adj[i+1].emplace_back(x);

            adj[x].emplace_back(i+1);

        }

        if(y){

            adj[i+1].emplace_back(y);

            adj[y].emplace_back(i+1);

        }

    }

    vector<int> siz(n+1);

    vector<int> w(n+1);

    vector<int> vis(n+1);

    int sum=n;

    int ct=-1; //centroid

    auto dfs=[&](auto self,int u,int p)->void{

        siz[u]=1;

        for(int v:adj[u]){

            if(v==p || vis[v]) continue;

            self(self,v,u);

            siz[u]+=siz[v];

        }

    };

    auto get=[&](auto self,int u,int p)->void{

        siz[u]=1;

        w[u]=0;

        for(int v:adj[u]){

            if(v==p || vis[v]) continue;

            self(self,v,u);

            siz[u]+=siz[v];

            w[u]=max(w[u],siz[v]);

        }

        w[u]=max(w[u],sum-siz[u]);

        if(w[u]<=sum/2){

            ct=u;

        }

    };

    auto ask=[&](auto self,int u)->void{

        dfs(dfs,u,0);

        if(siz[u]==1){

            cout<<"! "<<u<<'\n';

            cout.flush();

            return;

        }

        int v1=0,v2=0,v3=0;

        for(int v:adj[u]){

            if(vis[v]){

                continue;

            }

            if(!v1){

                v1=v;

            }else if(!v2){

                v2=v;

            }else{

                v3=v;

            }

        }

        if(v3){

            if(siz[v1]==min({siz[v1],siz[v2],siz[v3]})){

                swap(v1,v3);

            }

            if(siz[v2]==min({siz[v1],siz[v2],siz[v3]})){

                swap(v2,v3);

            }

            cout<<"? "<<v1<<' '<<v2<<'\n';

            cout.flush();

            int x;

            cin>>x;

            if(x==0){

                int v=v1;

                vis[u]=1;

                sum=siz[v];

                get(get,v,u);

                self(self,ct);

                return;

            }

            if(x==1){

                vis[v1]=vis[v2]=1;

                int v=u;

                sum=siz[v3]+1;

                get(get,u,v1);

                self(self,ct);

                return;

            }

            if(x==2){

                int v=v2;

                vis[u]=1;

                sum=siz[v];

                get(get,v,u);

                self(self,ct);

                return;

            }

        }

        if(v2){

            cout<<"? "<<v1<<' '<<v2<<'\n';

            cout.flush();

            int x;

            cin>>x;

            if(x==0){

                int v=v1;

                vis[u]=1;

                sum=siz[v];

                get(get,v,u);

                self(self,ct);

                return;

            }

            if(x==1){

                cout<<"! "<<u<<'\n';

                cout.flush();

                return;

            }

            if(x==2){

                int v=v2;

                vis[u]=1;

                sum=siz[v];

                get(get,v,u);

                self(self,ct);

                return;

            }

        }

        if(v1){

            cout<<"? "<<v1<<' '<<u<<'\n';

            cout.flush();

            int x;

            cin>>x;

            if(x==0){

                cout<<"! "<<v1<<'\n';

                cout.flush();

                return;

            }

            if(x==2){

                cout<<"! "<<u<<'\n';

                cout.flush();

                return;

            }

        }

    };

    get(get,1,0);

    ask(ask,ct);

}

 

Cf 2077 B

有两个隐藏整数x和y,可以进行两次询问,每次给出n,然后会返回n|x+n|y的值

在进行这样的两次询问后,会收到整数m,要求输出m|x+m|y的值

只能查询两次以及位运算各个位独立运算,联想到奇数位偶数位分别计算

void solve() {  

    int even = 0, odd = 0;

    for(int i = 0; i < 30; i++){

        if ( i % 2 == 0) {

            even |= 1<<i;

        }else {

            odd |= 1<<i;

        }

    }

    int a[2];

    cout<<even<<endl;

    cin>>a[0];

    cout<<odd<<endl;

    cin>>a[1];

    a[0]-=even*2; // a[0]=even|x+even|y

    // 减去之后就是奇数位置的数,如果只有一个数这个位上是1,那么对应位置是1,否则是左移一位的位置是1

    a[1]-=odd*2;

    swap(a[0],a[1]);

    int cnt[30]{};// x+y

    for(int i=0;i<30;i++){

        if(a[i%2] >> i & 1){

            cnt[i]=1;

        }else if(a[i%2] >> (i+1) & 1){

            cnt[i]=2;

        }

    }

    cout<<"!"<<endl;

    int m;

    cin>>m;

    int ans=0;

    for(int i=0;i<30;i++){

        if(m >> i & 1){// 这一位一定是1

            ans+=2<<i;

        }else{// 取决于x和y

            ans+=cnt[i]<<i;

        }

    }

    cout<<ans<<endl;

}

 

 

Cf 2038G

不超过3次查询猜出字符串s中的至少一个字符

每次询问时给出一个字符串t,将会返回t作为连续字串在s中出现了几次

因为一定无法猜出整个串,因此可以尝试去猜第一个或者最后一个

显然只通过个数并不能得到信息,因此要考虑相邻之间的关系

加起来等于0的个数,说明每个0前面都有一个0或者1,也就是开头是1

相当于在对原字符串进行拆分,显然,询问0得到的就是原字符串中的0的个数,而询问00得到的原字符串中0的个数减去连续为0的段数(例如1000100就是0的个数减去2),而询问10就是连续段前有1的个数(因为连续为0的段被分隔一定是因为这中间有个1存在,因此如果这两者相加的个数等于0的个数,那么就说明开头也是1,否则就说明开头没有1,1只存在于各个连续段的中间)

(有点类似于对1进行了压缩)

 

Cf 2029B
当0和1的个数都不为0时,无论字符串的结构如何,一定存在一个位置是0和1相邻的,将”01”段或者是”10”段替换成0,就是相当于将原串的1的个数减1,替换成1同理,当其中一个字符的个数减为0时,就不能继续进行操作了

 

杂项

摩尔投票法

摩尔投票法是一种用于求绝对众数的算法

在一个集合中,如果一个元素的出现次数比其他所有元素的出现次数之和还要多,那么就称其为该集合的绝对众数,等价的说,绝对众数的出现次数大于总元素数的一半

将找到绝对众数的过程想象称一次选举,维护一个m,表示当前的候选人,然后维护一个cnt,对于每一张新的选票,如果它投给了当期的候选人,就把cnt加1,否则就把cnt减1(也许你可以想象成,B的一个狂热支持者去把A的一个支持者揍了一顿,然后两个人都没法投票),特别的,如果cnt=0,就可以认为目前谁都没有优势,所以投票给谁,谁就会成为新的候选人

int m=0,cnt=0;//m是当前的候选人

for(int i=0;i<n;i++){

    if(cnt==0){

        m=a[i];

    }

    if(m==a[i]){

        cnt++;

    }else{

        cnt--;

    }

}

 

如果绝对众数确实存在,那么m一定是正确的,否则将给出错误的解,因此最好验证一下最后的答案

拓展摩尔投票

如果要需拿出N个候选人,其中每个人的得票都超过总票数的 ,可以用类似上面的算法

int m[N],cnt[N];

for(auto e:nums){//投票的情况

    int i=find(m,m+N,e)-m;

    if(i!=N){//在候选人候补中

        cnt[i]++;

        continue;

    }

    int j=find(cnt,cnt+N,0)-cnt;

    if(j!=N){//确实有空位

        m[j]=e;

        cnt[j]=1;

        continue;

    }

    for(auto &c:cnt){//没有空位,所有人票数减一

        c--;

    }

}

最后仍然需要验证答案

证明:在统计结束后,可以将投票分为两部分,一部分是投给了候选人候补的,即sum(cnt),以及被抵消的部分,因为抵消的票一定是投给了不属于前面候补的人,每一张投给其他人的票将抵消候选人集合中的N张票,因此可以将这部分的票分为N+1部分,假设都归于另外一个人,那么他能获得的最多票数是(n-cnt)/(N+1),显然不大于总票数的

区间绝对众数

可以利用线段树上的摩尔投票解决该问题

因为我们只关心m而不关心cnt,而当绝对众数存在时,可以任意交换摩尔投票的计票顺序而不改变选出的候选人

因此可以在线段树上进行摩尔投票,在合并左右两个区间时,相当于是先对左右两边分别进行摩尔投票,再对两边抵消后的结果进行摩尔投票

struct Node{

    int m, cnt;

    Node operator+(const Node &o) const{

        if (m == o.m)

            return {m, cnt + o.cnt};

        else if (cnt > o.cnt)

            return {m, cnt - o.cnt};

        else

            return {o.m, o.cnt - cnt};

    }

};

然后写一个支持区间求和的线段数即可

因为用摩尔投票法球绝对众数需要验证,因此可以对每个权值预处理一个下标数组然后二分查找,判断其与区间长度的关系就可以对若干个区间的询问快速进行验证

// vector<int> pos[MAXV];

// pos[m]表示权值m的下标数组,需要预处理

// 如果权值值域过大,需要改用map或哈希表

auto itr = upper_bound(pos[m].begin(), pos[m].end(), right);

auto itl = lower_bound(pos[m].begin(), pos[m].end(), left);

if (itr - itl > (right - left + 1) / 3) return m;

else return -1;

 

并行排序算法

log^2 n的时间复杂度

牛客24多校7 C

双调排序

 

随机化
模拟退火

当一个问题的方案书极大而且不是一个单峰函数时,利用模拟退火求解

爬山算法中,对于一个当前最优解附近的非最优解,爬山算法是直接舍去,而在模拟退火算法中,我们需要取接受这个非最优解而非跳出这个局部最优解

模拟退火算法是解决TSP问题的有效方法之一

模拟退火的出发点是基于物理中固体物质退火过程与一般组合优化问题之间的相似性

将热力学的理论套用到统计学上,将搜寻空间内每一点想像成空气内的分子;分子的能量,就是它本身的动能;而搜寻空间内的每一点,也像空气分子一样带有“能量”,以表示该点对命题的合适程度。演算法先以搜寻空间内一个任意点作起始:每一步先选择一个“邻居”,然后再计算从现有位置到达“邻居”的概率

即:如果新状态的解更优则修改答案,否则以一定概率接受新状态

基本要素:
(1).搜索空间(又叫状态空间)。一般范围比较大,是自定义的可行解的集合。

(2).状态函数。状态函数将决定是否要选用当前的解,对于一个好的退火来说,状态函数的搜索空间应该足够大。

(3).候选解。一般采用随机数来在一定密度内随机选取。

(4).概率分布。大多采取均匀分布或指数分布。

状态转移概率

(1).状态转移概率是指从一个状态向另一个状态的转移概率。

(2).通俗的理解是接受一个新解为当前解的概率。

(3).它与当前的温度参数T有关,随温度下降而减小。

(4).一般采用Metropolis准则。

定义当前温度为t,新状态s’和已知状态s(新状态由已知状态通过随机的方式得到)之间的能量差为 则发生状态转移的概率为

 

有时为了得到的解更优,可以在模拟退火结束之后,以当前温度在得到的解附近多次随机状态,判断是否有更优的解

模拟退火是有三个参数,初始温度T0,降温系数d,终止温度Tk;其中T0较大,d是一个非常接近1但小于1的数,Tk是一个接近于0的正数

首先让温度T=T0,然后按照上面的过程进行一次转移尝试,再让T=d*T,当T<Tk时结束退火过程

为了时得到的解更精确,一般不直接取当前解为答案,而是在退火过程中维护遇到的所有解的最优解

一些技巧

分块模拟退火

将整个值域分成几段,每段跑一边模拟退火,然后取最优解

卡时

利用clock函数,返回程序运行时间

同时将主程序中的simulateAnneal()换成while((double)clovk()/CLOCKS_PER_SEC<MAX_TIME) simulateAnneal()

使得已知模拟退火直到用时即将超过时间限制

P1337

题目描述:
有n个重物,每个重物系在一条足够长的绳子上。每条绳子自上而下穿过桌面上的洞,然后系在一起。图中X处就是公共的绳结。假设绳子是完全弹性的(不会造成能量损失),桌子足够高(因而重物不会垂到地上),且忽略所有的摩擦,问绳结X最终平衡于何处。

根据物理的知识可以得到当系统处于平衡状态时,系统的总能量最小

又因为此时系统的总能量等于各个物体的重力势能之和,也就是离地面要尽可能近

可以转化成绳子在桌子上的长度要尽可能小

于是即sigma(m[i]*dis[i][x])最小

在扩展状态时,用(rand()*2-RANDMAX)*T,因为(rand()*2-RANDMAX)的范围是从负数到正数,因此,这样在扩展坐标时不会只在一个方向上更新

constexpr int N=2000;

struct node{

    double x,y,w;

}e[N];

int n;

double ansx,ansy;

const double eps=1e-15;

double dis(double x,double y){

    double tot=0;

    for(int i=1;i<=n;i++){

        double dlx=e[i].x-x;

        double dly=e[i].y-y;

        tot+=sqrt(dlx*dlx+dly*dly)*e[i].w;

    }

    return tot;

}

void solve(){

    srand((int)time(NULL));

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>e[i].x>>e[i].y>>e[i].w;

        ansx+=e[i].x;

        ansy+=e[i].y;

    }

    ansx/=(double)n;

    ansy/=(double)n;//初始化

    double t=2500;//初始温度

    while(t>eps){//eps近似0

        double curx=ansx+(rand()*2-RAND_MAX)*t;

        double cury=ansy+(rand()*2-RAND_MAX)*t;//移动

        double delta=dis(curx,cury)-dis(ansx,ansy);

        if(delta<0){

            ansx=curx;

            ansy=cury;

        }else if(exp(-delta/t)*RAND_MAX>rand()){

            ansx=curx;

            ansy=cury;

        }

        t*=0.998;//降温

    }

    printf("%.3lf %.3lf\n",ansx,ansy);

}

 

倍增

24牛客多校3 J

两队打比赛,大分Bo2b-1,小分Bo2a-1(双方轮流得分,先得到a分的一方获胜,并且在此基础上有大比分的限制)

给定长度为n的01串(01表示哪一队在这局比赛中获胜),两队比赛的每个小分结果是这个串的循环重复,问从该串的每个位置开始,最终谁会赢得整个比赛

因为串里的每个位置都可能是某一小局或者某一大局的开始

因此首先对每个位置,求出从它开始Bo2a-1后的胜负,以及比赛结束时在循环串的位置

因为一个Bo2a-1的比赛不一定会打满2a-1场,因此要确定从这个位置开始打完几场之后这个Bo2a-1就结束了

上述操作可以用倍增,f(i,j)表示从位置i开始进行2^j局小分的状态(两队分别赢了几分,以及结束时在循环串的位置),这样我们就可以通过f(i,j-1)得到f(i,j),然后就能够知道每个位置开始小局的胜负

处理完每一局从每个位置开始的结果后,再用倍增求每一大局的结果,即用g(i,j)表示从位置i开始进行了2^j小局后的状态(两队分别赢了几分,以及结束时在循环串的位置)

void solve(){

    int n,a,b;

    cin>>n>>a>>b;

    //小分Bo2b-1

    swap(a,b);

    string s;

    cin>>s;

    int one=count(s.begin(),s.end(),'1');

    if(!one || one==n){

        cout<<s<<'\n';

        return;

    }

    vector<array<int,2>> next0(n);

    vector<array<i64,2>> next1(n);

    array<int,2> cur={0,0};

    for(int i=0;i<2;i++){

        for(int j=n-1;j>=0;j--){

            //运行两次,以处理出每个位置下一个0和1的位置

            cur[s[j]-'0']=j;

            next0[j]=cur;

        }

    }

    for(int c=0;c<2;c++){

        int cur=0;

        i64 sum=0;

        for(int j=0;j<b;j++){

            sum+=(next0[cur][c]+n-cur)%n+1;

            //记录进行了几局

            cur=(next0[cur][c]+1)%n;

        }

        next1[0][c]=sum;

        for(int i=1;i<n;i++){

            sum-=1;

            if(s[i-1]=='0'+c){

                sum+=(next0[cur][c]+n-cur)%n+1;

                cur=(next0[cur][c]+1)%n;

            }

            next1[i][c]=sum;

            //根据前一局的情况进行更新

        }

    }

    //next1用于记录每个位置在Bo2b-1中以胜利以及失败结束所需的局数

    vector<pair<int,int>> next2(n);

    for(int i=0;i<n;i++){

        next2[i].first=(i+min(next1[i][0],next1[i][1]))%n;

        next2[i].second=next1[i][0]>next1[i][1];

    }

    //next2用于记录该位置Bo2b-1结束的位置以及胜负情况

    vector<array<int,18>> f(n),g(n),h(n);

    for(int i=0;i<n;i++){

        f[i][0]=next2[i].first;

        (next2[i].second?g:h)[i][0]++;

        //g,h用于记录(大比分中)赢了几分,f用于记录经过若干大比分场次后的结束位置

    }

    //倍增

    for(int i=1;i<18;i++){

        for(int j=0;j<n;j++){

            f[j][i]=f[f[j][i-1]][i-1];

            g[j][i]=g[j][i-1]+g[f[j][i-1]][i-1];

            h[j][i]=h[j][i-1]+h[f[j][i-1]][i-1];

        }

    }

    for(int i=0;i<n;i++){

        int fi=i,gi=0,hi=0;

        for(int j=17;j>=0;j--){

            if(gi+g[fi][j]<a && hi+h[fi][j]<a){//未分出胜负

                gi+=g[fi][j];

                hi+=h[fi][j];

                fi=f[fi][j];

            }

        }

        cout<<next2[fi].second;//此时小比分的胜负也是大比分的决胜局的胜负

    }

}

 

 

连续子数组

对连续子数组进行操作,并且只能操作一次时,关注的往往是第一个出现的位置和最后一个出现的位置,因为如果保留中间的数据,显然不能覆盖最大的面积,并且,保存l,r能够直接表示出该元素出现的范围(题目也是求mex)  cf1820c

 if(l[m+1]<l[i] && r[i]<r[m+1]) {cout<<"no\n";return;}

对于连续数组元素中的合并一般还是需要按顺序去遍历(P1182)

 

找递推公式时,可以先写出一般的推导过程,然后将题目中的主要变量(一般是第一维的i)用i+1或i-1替换,看看能不能得出i状态下与i+1的关系(一般递推的式子项数不会很多)(P1246)

有序的序列(包括操作序列,数位序列)都可以尝试用递推去写,递推本质上与动态规划是相似的

 

确保得到一个奇数w=a+!(a&1)

 

进行算法分析时,可以假设进行操作,再看我们所需的答案是否变得更优,一般也可以在这样的操作中得到我们要在何时进行操作P1080

 

逆向思维,两条对角线确定一个交点,即一个交点对应4个顶点,因此在所有顶点中取4个顶点的方法数就是交点的个数

 

有时数据量较大时,先读入再操作可能会导致超时,因此要边读入边算P3817

 

注意一些特殊操作带来的影响,例如对于数组中的一个元素+1,另一个元素-1,那么可以保证数组中元素的总和不变,若能操作任意次数,说明可以对数组中的元素进行任意分配(牛客寒假4D)

 

sort中的比较函数[&](int i,int j)直接是指数组中的元素,而不是指下标

从大到小排序可以直接使用反向迭代器

即 sort(a.rbegin(),a.rend());

 

两维数据都在10^5的范围内时,可以将一维数据乘上10^5再加上另一维数据,把两个int类型的数据压成一个long long类型的数据

 

利用图形去理解题目操作,进而想算法(P4447),利用二分查找等方法时都要用到数组的有序性,因此在对数组进行操作时,要考虑怎样去变化其中的数据的值能维护这其中的有序性

 

解决分组问题,除了模拟解决之外,也可以考虑查找的思路,对于数组中的一个元素,去考虑它能加到现有的哪一组之中(P4447),而对应的查找数组中,就可以存储向该组添加的元素应满足的条件

 

题目的数据量比较大时可以考虑用Dp或贪心,可以先从数据量较小的情况去找性质,借此猜想数据量大时也可以有类似的操作

 

用数组去查询修改的时间效率比vector高

 

对于数据处理,例如线段覆盖的情况,可以考虑要让对之后数操作的影响越小越好,因此采用对右端点进行从小到大排序,右端点越靠左,自然对其的影响越小

 

寻找一个数组中一个数与另一个数有一定关系的对数时(包括但不限于相等、相加为某个数,异或为某个数,或者是同一个字符串的不同排列,或者是相减取模后为一个值),可以利用哈希表(T1,T187,T2586,T49,T2845)

哈希表就是提取关键特征

寻找当前数组中是否有某一段(子数组)的和为0,同样可以使用哈希解决,用哈希存储当前序列的和,如果这个和在之前已经出现过,说明有一段的哈希值是0,在进行这种操作时,数据量较大的情况下,最好使用set进行存储(指不能用数组去开到很大,同时每次用vector去存储的话会导致超时),每次找到一个区间后对set进行clear(牛客24寒假4E)

 

如果对一个数组进行操作,删去其中的元素,但被删除的元素并不一定是连续的,就相当于我们得到的是一个子序列,而统计子序列的个数,一般可以用dp去做,所以关键是确定如何去转移

并且状态一般可以设定为以该下标作为子序列最后一个元素的个数,这样要转移就是要找倒数第二个元素是哪一个,这就需要按题目要求去进行判断(cf edu160 D,哪些数字可以作为子序列的最后一个数,如何进行转移,利用前缀和来维护数据,来减少向前询问的操作,同时回答区间询问,用一个变量来直接维护单调栈中的值,因为单调栈里面的元素是已经经过筛选的,并不是原数组中的连续区间中的数,因此不能用前缀和去得出里面的值,因此额外用一个变量去维护这里面的综合值)

 

对一个数组(实际中可能是代表一个路径),最少修改其中几个元素,使得该数组中的元素可以全部相等,自然会想到求这其中的众数,这样能保证修改至相等的操作数最小(T2846)

 

把贪心的思想放到具体执行时的操作中(指修改时修改右侧体现在要修改时让i++),而不是在遍历时体现要进行的操作(指什么时候都让i+=2然后判断是否要修改),不然可能导致得到的答案不是最优的(T2957),单向(指只向左或右判断能保证修改后逻辑的正确)

 

因为c++是从左到右进行判断的,因此为了避免下标越界,要写作j<n && nums[j]==nums[i]

 

求数组中有多少个连续子区间的元素和小于等于某一个值

双指针

void solve(){

    //有多少个区间的元素和小于等于x

    int n,x;

    cin>>n>>x;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    i64 sum=0;

    int i=0,j=0;

    i64 ans=0;

    while(i<n && j<n){

        while(j<n && sum<=x){

            cout<<i<<' '<<j<<'\n';

            ans+=j-i;

            sum+=a[j++];

        }

        while(i<n && sum>x){

            sum-=a[i++];

        }

        if(j==n){

            ans+=j-i;

        }

    }

    cout<<ans<<'\n';

}

cf1994C

和上面的有所不同的是,当一段子区间的和超过x之后和会归0,问在这种情况下,有多少个连续的子区间能够使得最终的和不是0

考虑反向,计算是0的方案数

计算对于左端点i第一个使得区间和大于x的位置j,dp[i]由dp[j]进行转移

dp[i]的方案数是dp[j]+1,因为必须从i开始,同时每次都是在充值为0后找下一个会被重置为的0 子区间(相似的子问题),因此只是比dp[j]的方案数多了[i,j]这一段

特别的,如果j=n+1,那么说明其到数组末尾还无法构成一个方案,因此设置为0

void solve(){

    int n,x;

    cin>>n>>x;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<i64> pre(n+1);

    pre[0]=0;

    for(int i=0;i<n;i++){

        pre[i+1]=pre[i]+a[i];

    }

    //所有可能的子区间是n*(n+1)/2

    i64 ans=1ll*n*(n+1)/2;

    vector<int> dp(n+1);

    dp[n]=0;

    for(int i=n-1,j=n+1;i>=0;i--){

        //找第一个大于x的位置

        while(pre[j-1]-pre[i]>x){

            j--;

        }

        dp[i]=(j<=n)?1+dp[j]:0;

        ans-=dp[i];

    }

    cout<<ans<<'\n';

}

 

 

最大字段和

求连续子数组中的最大/最小和(最大子序列和/最大子段和)

相当于利用前缀和进行计算,只不过是在遍历的过程中完成了计算

auto solve=[&](auto a) -> pair<ll, ll>{

    ll mx=-inf, mn=inf, cur=0, cmn=0, cmx=0;

    for (ll x:a){

        cur+=x;

        mx=max(mx, cur-cmn);

        mn=min(mn, cur-cmx);

        cmx=max(cmx, cur);

        cmn=min(cmn, cur);

    }

    return {mx, mn};

};

或者

for (int i = 0; i < n; i++) {

        cur += a[i];

        cur = max(cur, 0ll);

        S = max(S, cur);

    }

求连续子数组最大值

(当前前缀的和小于0了,那么对之后肯定是负贡献,因此可以直接更新为0,重新开始计算)

cf1373D

翻转时,只有当选择的数组长度为偶数时才是有效的

同时因为我们要增大我们所选择的数组,因此我们可以比较翻转前后的数组,利用差值作为新数组的元素

同时因为我们只能选择其中一段子数组进行操作,因此我们要选择能够得到最大贡献的一段

即相当于求连续子数组最大值

int a[200005];

void solve(){

    int n;

    cin>>n;

    i64 sum=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        if(i&1){

            sum+=a[i];

        }

    }

    vector<int> k1,k2;

    /*

    1 2 -> 2 1 a[1]-a[2];

    2 3 -> 3 2 a[3]-a[2]

    1 2 3 4

    2 4-> 1 3 a[i]-a[i+1]  a[i-1]-a[i]

    2 3 4 5

    2 4-> 3 5 a[i]-a[i-1]

    */

    for(int i=2;i<=n;i++){

        if(i&1){

            k1.emplace_back(a[i-1]-a[i]);

        }else{

            k2.emplace_back(a[i]-a[i-1]);

        }

    }

    // cout<<sum<<'\n';

    // for(auto &i:k1){

    //     cout<<i<<' ';

    // }

    // cout<<'\n';

    // for(auto &i:k2){

    //     cout<<i<<' ';

    // }

    // cout<<'\n';

    i64 sum1=0,sum2=0;

    i64 t1=0,t2=0;

    for(int i=0;i<k1.size();i++){

        sum1+=k1[i];

        if(sum1<0){

            sum1=0;

        }

        t1=max(t1,sum1);

    }

    for(int i=0;i<k2.size();i++){

        sum2+=k2[i];

        if(sum2<0){

            sum2=0;

        }

        t2=max(t2,sum2);

    }

    cout<<sum+max(t1,t2)<<'\n';

}

//举的例子是偶数情况下的

 

Cf 2043c

给定一个数组,数组中除了一个元素外,其他元素都为1或者-1,求出该数组中所有可能的子数组和

假设数组中的元素只有-1和1,那么当我们移动边界时,和将发生+1或者-1的变化,也就是从整数的视角来看,我们一定能得到从最小值到最大值的所有整数值,因此,只要能得到数组的连续子数组的最小值和最大值,那么我们就得到了该数组所有可能出现的子数组和

现在再考虑当前问题,如果我们将唯一不是1或-1的元素视作一个分界点,那么整个数组就被分成了3部分,其中三部分可以按照上面的操作进行考虑

而对于包含该特殊元素的子数组,我们仍然可以将其拆分为左侧和右侧部分,对于每个单侧部分,我们仍然可以通过只向右侧或者只向左侧扩展来得到单侧可以得到的最小值和最大值,进而将两侧结合就可以得到包含特殊元素的子数组和的取值范围

(因为是连续变化,因此将子数组的两侧变化固定为单侧变化求最值;但是对于包含特殊元素,也就是确实只能单侧变化的情况,直接遍历维护最值即可)

 

暴力枚举

有时适当枚举也是必要的思路(T2953枚举窗口长度,T2008枚举满足要求的数据,T2735,因为没法直接判断那种最优因此枚举观察)

cf1821C

每次操作若选择所有间隔为1的数字,那么一次操作后数组长度将会减半

同时,虽然我们并没有一个确切的原则可以确定到底应该选择哪一个字母作为最终的结果,但因为总共只有26个字母,因此我们可以通过枚举的方式去检查哪个答案是最优的

通过分割,保证了删除之后相邻的字母仍然是分割的,因此相当于对这些段独立操作

对于每个段来说,每次删除相当于长度/2下取整

因此每段的操作次数都是log2n+1

这样这需要判断哪一段需要的次数最多即可

void solve(){

    string s;

    cin>>s;

    int ans=INT_MAX;

    for(int c='a';c<='z';c++){

        int len=0,cur=0;

        for(char a:s){

            if(a!=c) cur++;

            else{

                len=max(cur,len);

                cur=0;

            }

        }

        len=max(cur,len);

        if(len==0){

            cout<<0<<'\n';

            return;

        }

        ans=min(ans,(int)log2(len)+1);

    }

    cout<<ans<<'\n';

}

 

枚举在一定情况下是对复杂度计算的考验

外层循环1到n,内层循环1到n/i的时间复杂度大致是nlogn,因此1e6的数据量是可以接受的

cf1996D

求ab+ac+bc<=n 同时 a+b+c<=x的方案数

固定两个(枚举两个),计算第三个的方案数(可行范围)

void solve(){

    int n,x;

    cin>>n>>x;

    i64 ans=0;

    for(int i=1;i<n;i++){

        for(int j=1;i*j<n && i+j<x;j++){

            ans+=min((n-i*j)/(i+j),x-i-j);

        }

    }

    cout<<ans<<'\n';

}

 

杭电24多校3 1001

递推

考虑合法的树,发现每个节点所有的子树的形态必定完全相同,因此可以递推

令f[i]表示i个点的合法的树的个数,枚举根的儿子个数,有f[i]=sigma(f[d]),其中d|i-1,这一转移可以用枚举倍数的方法加速,复杂度为调和奇数(也就是可以加1然后转移到任何它的倍数)

同样对于合法的森林个数,同样有ans[i]=sigma(f[d]),其中d|i,同样也是调和级数的复杂度

constexpr int mod=998244353;

void solve(){

    int n;

    cin>>n;

    vector<int> f(n+5);

    f[1] = 1;

    for(int i = 2; i <= n + 1; i++) {

        f[i]++;

        for (int j = i; j <= n; j += i) {

            f[j + 1] += f[i];

            f[j + 1] %= mod;

        }

    }

    for(int i=1;i<=n;i++){

        cout<<f[i+1]<<" ";

    }

}

 

 

 

对于通过删除有限字符使得子串符合某一特定形式的,可以使用枚举的方式来得到所有操作前可能的子串形式,而对于哪些操作方案使得操作后的字符串能包含这特定形式子串的则可以用贡献法解决(操作前的特定子串形式,与两边任意操作的方案数相乘)(并且有结论:对长度为i的子串进行任意消除的方案数是一个斐波那契数列,且f[1]=1,f[2]=2,因此我们可以对其进行预处理)

证明:注意到这是随意删,因此方案数和具体是什么字符串无关,只和字符串长度有关,因此我们可以预处理,不用每次都算。具体来说,我们需要预处理出f[i],表示长度为i的字符串,不删连续字符的前提下有多少种删除方案(题目保证不连续删字符)。这可以用一个递推实现,如果第i个字符被删了,那么第i-1个就不能删,再往前的随意,这样的方案数等于f[i-2],如果第i个没被删,那么前i-1个都随意,这样的方案数是f[i-1]。最终得到f[i]=f[i-1]+f[i-2],初始条件是f[1]=1,f[2]=2。这实际上就是斐波那契数列的递推式

for(auto j:t){

            string x=s.substr(i,j.size());//检查是否出现八种字符串

            if(check(j,x)){

                ans=(ans+1ll*f[i-1]*f[n-(i+j.size())+1]%M)%M;//计算贡献

            }

        }

 

对于将第一个数字放到末尾的操作,可以转化成用一个指针,来指向当前实际数组的第一个数(T2659),每次操作就是将指针顺序向后移动一位,其实数组的翻转也是类似类似的思路,不过因为数组翻转的操作是将末尾的数字移到数组开头,因此同样可以考虑成用一个指针来指向当前实际数组的第一个数,不过此时指针的移动方向是逆序的

 

要习惯于用指针作为vector的参数,使用时不能用.要用->,判断一个数组是否为空时可以用cur.size()

判断树的下一节点是否为空节点可以是if (node->left)  也可以是if(n->left !=nullptr)

 

判断一个元素是否出现过可以在统计的时候一起完成,不需要再另外判断

 

索引二维转一维,ma[i][j] 压缩后index = i * n + j

 

对于对数组中的值进行匹配,即一个较小的数与一个较大的数进行匹配,往往可以先将数组进行排序,从小到大一次进行寻找,往往会用到贪心或二分之类的思路(T2576,先不要考虑一个数与那个数匹配是最佳的,只要去考虑最容易进行匹配的一定是最小的那几个数,因此也优先让小的数去和较大数的较小值去匹配,如果采用二分答案,那就是判断最小的k个数能否都能得到匹配;字节OA讨论)

 

C++不能随意使用-1下标索引

 

prev 原意为“上一个”,但 prev() 的功能远比它的本意大得多,该函数可用来获取一个距离指定迭代器 n 个元素的迭代器

next 原意为“下一个”,但其功能和 prev() 函数类似,即用来获取一个距离指定迭代器 n 个元素的迭代器
auto newit = next(it, 2);
auto newit = prev(it, 2);

 

对数组元素进行移动时,左移右移有些时候是可以等价的(T2946),因为i右移k会移动到i+k,而i+k左移k就会得到i,因此在判断移动后数组是否是与原数组相同时,右移和左移是等价的

 

写的时候可以注意一些初始化的写法,例如在数组中逐个添加元素,并且在这个数组中要查找第一个大于等于这个值的数,如果代码的起始阶段,这个数组中可能只有一个数,那么可能找不到满足要求的数,那么可以在一开始就向数组中添加一个最大值和最小值来避免要另写if的情况(T2817),同时要注意添加的数例如INT_MAX会不会在之后的加减操作中导致溢出

 

利用迭代器的特性,例如lower_bound得到第一个大于等于所求值的迭代器,并且我们实际要求的是和所找的数差值的绝对值最小,那么可以写作min(*it - y, y - *prev(it),注意这里不能用 *--it,这是未定义行为:万一先执行了 --it,前面的 *it-y 就错了

查找后直接用迭代器修改会较为方便(即find返回时直接用it去存储而不用刻意计算下标)(T2349)

 

计算某个数的三分之一次,用pow(x,1.0/3)

 

当题目的取值范围较小时,可以直接用桶来代替排序,增加运算的速度(T2952),先遍历一遍,将存在的数对应的桶的值加1,然后再次遍历时,就从0开始遍历桶来达到遍历原数组的目的

 

子序列的本质是对于数组中的任意一个元素是选还是不选

 

切分的思想(有点像对顶堆),要求能以较高的效率修改一个队列的首、尾以及中间,可以将一个队列分成两个双向队列,一个用于维护左半部分,一个用于维护右半部分,同时用一定的规则来处理中间部分元素:左右队列大小相等时添加到左队列,否则添加到右队列(T1670)

 

倍增的思想就是将数据翻倍,可以将线性的处理转化为对数级的处理,常见的用法就是ST表和树上倍增求LCA

 

对于连续的数组元素,我们可以用一个指针来表示他的最小值以及删除最小值的操作,但如果只是是对于一个数组,我们需要使用set来进行维护(删除最小值可以写为s.erase(s.begin());

),但这两种方法并不是互斥而是可以结合着使用的,仍然可以考虑一种划分的思想,一部分有序的我们仍采用指针,而对于无序的那部分,我们用set来维护(T2336)

 

在数组中要考虑相邻元素之间的相等关系,在一次遍历中,可以利用一个指针,在正常情况下,指针每次移动两个单位(要求是奇数位不等于偶数位),除非遇到相等,指针多移一位(T2216)

 

算法的基本优化在于减少重复操作

例如leetcode T337,最次操作是依次计算爷节点和孙节点以及父节点的数值大小,接着发现当子节点成为父节点时,其数值会被重新计算一遍,因此用记忆化,将结果进行存储,再进一步优化发现,即使如此,我们每次判断仍需要三层数据:爷、父、子,因此通过将情况分类,再次减少对孙节点的重复计算,达到每次判断只需两层结构的情况

对于特殊情况的操作也是能少则少,有些特殊情况虽然不同,但进行的操作是一样的,这样就可以用更一般的标准去判断其为特殊情况(T57)

 

优化空间,类似于上文的思路,对于相关操作只涉及相邻的数据时,相当于我们对相隔两次的数据不再用到的时,可以将数据覆盖以减少数据占用的空间,这是类似于滚动数组的思路,可以衍生到用一维数组解决二维数组的问题,如leetcode T62

 

要能将每个数据的一一处理整体化分,分类,以便于找到问题实际求解的核心,方便与已经熟悉的算法进行联系  leetcodeT494

 

解题时可以根据我们需要的操作进行一些预处理,例如要进行字符串的拼接,并且题目要求是最小重叠部分进行拼接,就可以预处理重叠部分,并且是一个字符串的后缀与另一个字符串的前缀相同,为了保证最小,第一个循环可以从a单词的尾部进行逆序遍历,即遍历重叠部分的起点,第二个循环从这个起点开始再顺序遍历判断是否相同,若都相同就可以退出循环了(P1019,要逐个判断是否可以使用时还是得用回溯去写)

 

注意初始赋值,是否可以从0开始赋值

 

对于数组部分和相关的问题,处理的时候可以先列出相关的等式,进行适当的简化处理(cf33C)

 

数据量较大时,递归会导致超时的可能性,因此可以尝试将递归的值用数组或者滚动数组进行存储 leetcode. T198

同样类似于重复的操作,例如遍历,在数据量较大的情况下容易出现超时的情况,此时要将遍历的最终目的简单化,利用可变的进程进行表示 leetcode. T76

 

能将多数据的遍历转化成某个特定结果的寻找 T901

 

优化一些遍历,例如找数组中除了最大值和最小值的元素我们可以只看前面三个元素,这三个元素中第二大的一定就是既不是最大值又不是最小值的元素(T2733)

当我们需要数组满足某一条件的元素中两个元素和的最大值,不需要将全部元素都存储起来,而只存储已经判定过的数组元素的最大值(T2815)

ans = max(ans, v + max_val[max_d]);   //先更新和的最大值

max_val[max_d] = max(max_val[max_d], v);   //再更新对应数组中的最大值

 

数组中的加或乘运算并不会收到下标的限制,因为交换律的存在,我们可以放心地进行排序后再按要求去查找(T2824)  关键词:子序列,求和,说明要求的和数组元素在数组中的顺序无关

同时要知道最多多少元素的和可以小于等于queries中的元素,那可以求前缀和后每次对queries中的元素再进行二分查找(T2389)

 

j-i有下界时,可以一边枚举j,一边维护与i有关的数据,因为此时i存在一个范围,维护最大值最小值时,如果最终是要下标就可以直接去维护下标,最大值最小值用下标来引用

 

循环节:
考虑偏移量的计算使得到具体某一位置时的坐标正确

cf1717B

找出(r,c) 在行内循环节中的相对位置c≡c′(modk) ,然后在行内的每一个满足 x≡c′(modk) 的 x 位置输出 X 。

再来分析列上的偏移:这个也很好做。只要把当前行与目标行 r 的距离当作偏移量(记得模上 k )

 

cf19484D

字符串,循环节

void solve(){

    string s;

    cin>>s;

    const int n=s.size();

    //如果全是a,那么t取2~n个'a'都可以,因此总共是n-1种

    if(count(s.begin(),s.end(),'a')==n){

        cout<<n-1<<'\n';

        return;

    }

    //否则取出非a的字符,枚举t里面包含几个非a的字符

    //因为字符分隔成的字符串只能是'a'或者是t

    //因此对于非'a'的字符数组来说,我们选择的t必须是这个字符数组的循环节

    //于是判断当前方案是否可行就是查看是否能满足这个循环节

    vector<int> p;

    for(int i=0;i<n;i++){

        if(s[i]!='a'){

            p.push_back(i);

        }

    }

    //c就是t里面非a的字符的个数

    const int m=p.size();

    i64 ans=0;

    for(int c=1;c<=m;c++){

        //这样枚举的数目就比较少,只是m的因子数

        if(m%c!=0){

        //如果不能整除,说明必定没法通过t将整个数组覆盖

            continue;

        }

        int ok=1;

        for(int i=c;i<m;i++){

            if(s[p[i]]!=s[p[i-c]]){

                //不满足循环

                ok=0;

                break;

            }

            if(i%c!=0 && p[i]-p[i-c]!=p[i-1]-p[i-c-1]){

                //如果不是循环节的开头

                //这说明在非a字符数组内部满足了循环节的情况

                //但在原字符串中,中间有不等量'a'的插入使得其不能成为符合要求的子字符串

                ok=0;

                break;

            }

        }

        if(!ok){

            continue;

        }

        //接着还要计算方案数

        //左边的a个数

        //枚举开头和结尾a的个数

        int l=p[0],r=n-1-p[m-1];

        int s=n;

        for(int i=c;i<m;i+=c){

            //两个t中间'a'的个数

            s=min(s,p[i]-p[i-1]-1);

        }

        for(int x=0;x<=l;x++){

            //相当于在初始t的左右侧添加若干个'a'

            //x表示在t的左侧添加a的个数

            //ans+就表示这种情况下,右侧可以加几个a

            ans+=max(0,min(r+1,s-x+1));

        }

    }

    cout<<ans<<'\n';

}

 

思维:
可以先考虑暴力,再尝试去优化;手玩模拟找找规律,看看有没有什么状态是可以相互进行转移的

牛客24寒假1B,4M

先考虑最差的情况,有时并不需要去分析具体那些位置是有关系,因为不考虑任何优化的方式,光是暴力删去的步数就已经比较可观了,除此之外只要分析更优的情况即可

 

一个物品花费1,一个物品花费2,可以看作先花费1,再花费2,因此,在消耗两个物品时,我们可以有选择地去消耗那个较多的物品,考虑到(a<b) b>=2a时,可以一直多消耗b,反之在过程中一定会出现两者相同的情况,此时要可以考虑(2,1;1,2)组合使用,并且a,b本质上是等价的,因此总和来说就是3个物品得到一价值

 

对一个数组中的所有元素进行与运算,满足两个相同元素的与仍是那个元素,一个元素a和另一个一个元素b的与如果等于b,那么一定有a>=b,并且满足b的二进制位上为1的,a中也一定为1,并且一定程度上说,一个数组所有元素最终与出来的结果受最小元素的影响最大(因为与运算,一个数字上某一bite位如果为0,那么结果就一定为0,一票否决的感觉),又这里要求pre1=suf2,pre(n-1)=suf(n),(a&b=c且c&b=a,则有a=c)因此至少a[1]和a[n]是相等的,且必须满足整个数组元素中的与也等于a[1]   cf1513b

 

Cf 2108A

定义了函数: f(p)=∑|pi−i|,给你一个数字 n ,你需要计算当考虑从 1 到 n 的所有可能排列时,函数 f(p) 可以取多少个不同的值

首先可以得到对于任一排列,f(p) 的值一定是偶数:这是因为交换位置其实会成对出现“正负偏离”,证明可以用奇偶性归纳得到。

可以证明 f(p) 的最小值为 0(当排列为升序时);而 f(p) 的最大值出现在排列逆序时

接下来需要证明的是“所有偶数值从 0 到 f_max 都是可达到的”。也就是说,只要是偶数 0,2,4,…, f_max(共 f_max/2 + 1个值),都可以由某个排列使 f(p) 取得。证明这点可以构造交换方案或用数学归纳法证明。

 

Cf 2098B

给定一个长度为n的数组a,a[i]表示第i个酒吧处于位置a[i]上

同时有操作数k,表示可以从中删除k个酒吧

现在Sasha想要在1到1e9的整数坐标上买一栋房子,他会在这个位置上买房子当且仅当存在一种删除方案,删除不超过k个酒吧后,剩余酒吧到它买下位置的距离之和是所有位置中最小的

显然,可能的位置是当前存在酒吧位置中的中位数(或者对于偶数酒吧,是两个中位数中间的任意点)

现在问题简化为:如果我们删除不超过 k的原始元素,有多少个这样的点仍然是中值

为了使得房子位置成为剩余酒吧中距离和最小的点,理想情况下应该留下连续的一段酒吧(因为连续区间的中位数能够最小化绝对距离之和)。因此,允许删除 k 个酒吧之后,我们实际上可以选出任一长度为 n‑k 的连续子区间作为剩余酒吧,然后房子应选在这段区间的中位数位置。

证明可知当 start 取最小值 0 和最大值 k 时,中位数分别为 a[ (n‑k‑1)/2 ] 和 a[ n‑1‑(n‑k‑1)/2 ];因此,允许选择的中位数必然落在整个区间 [a[ (n‑k‑1)/2 ], a[ n‑1‑(n‑k‑1)/2 ]],该区间上的整数个数即差值加 1。

 

牛客24寒假4H

考虑的时候如果对每个位置单独去考虑,显然会非常复杂,并且要考虑的情况很多

于是尝试对一串数据同时考虑,注意到对于连续的一串数字,将它们逆序相加之后可以得到一串相同的数字(题干中体现的是每个位置+i,并且挑选的元素范围是1-n,即我们如果按逆序去放置就得到了一串相同的数字),于是只要l+r是质数,那么就相当于我们将l-r范围内的数字都处理完毕了

于是问题转化成先确定右端点r=n,从后往前寻找一个左端点,使得l+r为质数,再将这个区间的数字序列倒过来放入这个区间,处理完一个区间后再接着处理下另一个区间,直到覆盖1-n为止

并且对于质数打表(利用bitset代替数组),可以考虑反面情况,即将所有合数标为1,这样不为1的就是质数

int i,j,k,n,m,t,p[N+50],cur;

bitset<N+50> b;

vector<int> v;

int main(){

    ios::sync_with_stdio(0); cin.tie(0);

    for(i=2;i<=N;i++)

        if(!b[i]){//说明i是质数

            v.push_back(i);

            for(j=i+i;j<=N;j+=i)b[j]=1;//将对应的倍数标记成合数

        }

   

    cin>>n;

    cur=n;

    while(cur>=1){

        auto w=v.back(); v.pop_back();

        t=cur;

        //相加是质数,并且确保是可以通过当前数据范围内的数相加得到的

        for(i=t;w-i<=t&&i>=1;i--){

            p[i]=w-i; cur--;

        }

    }

    for(i=1;i<=n;i++)cout<<p[i]<<' ';

    return 0;

}

 

对于一些问题关注不变量来考虑

Cf 2092 A

一个长度为n的数组,可以给数组中的所有元素同时加上某个值,问在进行完操作后,从数组中选择两个数,这两个数的gcd最大是多少

因为有操作,先考虑操作有什么影响,因为是对数组中所有元素同时加,因此虽然整体的元素值是增大了,但是元素之间的差值是不变的

再联想到gcd的含义,也就是两个数都会被gcd整除,联想到前面的差值,也就是x能被gcd整除,x+c也能被gcd整除,于是c一定要被gcd整除,为了最大,gcd就可以取到c

因此答案就是数组中最大值和最小值的差值

 

Cf 2092 C

给定一个长度为n的数组,每次操作选择两个元素a[i]和a[j],要求a[i]+a[j]是计数,然后a[i]-1,a[j]+1,问这样进行任意次操作后,数组中元素的最大值是多少

因为操作一个+1一个-1,显然可以得到元素和是不变的,同时两个数直到一个为0时可以一直操作

同时只有奇数和偶数能进行操作,也不难想到最后一定是最大的奇数和偶数进行加

这里的不变量体现在哪里呢,注意到奇数表示成偶数+1,一个奇数-2,一个偶数+2,可以实现奇偶性不变的同时将奇数的贡献集中到偶数上,而且是没有浪费的可以最优的(注意这里的不变量体现在加减偶数上的奇偶性不变的,如果让偶数减至1,虽然也能让贡献进行集中,但是奇偶性发生了转变,思考起来就会比较困难)

于是最后的答案就是所有数的总和减去奇数的个数再+1(最后一个奇数看作把所有偶数加到这个数上,因此不需要留到1)

void solve() {

    int n;

    cin >> n;

    vector<int> a(n);

    int odd = 0;

    for (int i = 0; i < n; i++) {

        cin >> a[i];

        odd += a[i] % 2;

    }

    if (odd == 0 || odd == n) {

        int ans = *max_element(a.begin(), a.end());

        cout << ans << "\n";

        return;

    }

    i64 ans = accumulate(a.begin(), a.end(), 0LL) - odd + 1;

    cout << ans << "\n";

}

 

牛客24寒假5F

对操作次数进行计算,有时在脑内模拟也可能是一个好方法,因为能时刻提醒自己进行的是区间(连续性操作),从而可能将思考时的问题简化,因为相邻数修改的时候每次操作差值只变化1,而且逆序遍历时能保证计算所得的次数是正确的

要变成非降序,有an>=a1,当n为偶数的情况下,操作并不影响。当n为奇数时,an-a1就是最多的操作次数,因为最后一个数不能被操作影响到。操作对于已经有序的不产生影响,因此每次选择的k都可以设定为可以选择的最大值

显然不能每次操作完成之后都去判断是否满足非降序n^2,无论数组中的元素个数奇偶性,能不能在一次遍历后得到需要操作的最大数,一次操作,对于i,j使其差值减小j-i

假设非递减数列1,1,4,5  选哪个去比较

相邻的差的是最大的,并且每次差距缩小的也是最小的,但如果有多段递增数列,不能确定与哪个进行比较。如果记录每次递增段的最后一个数,以此作为判断依据,但这样数据量仍然可能很大,最坏情况仍可能是n^2的(原数列是递减数列)

注意到如果能使其变成为非递减数列的话,增加(对相邻进行操作的)最大次数后,数列一定是变得的有序了,并且每次的操作我们保证一定是必须的,因此可以从后往前遍历,碰到相邻逆序对,就进行操作,因为是对整个数组进行操作,因此直接记录操作数,枚举到一个数时判断当前操作数能否使相邻满足非递减,这样一次遍历模拟过去,就能得到所需的最小操作次数了

同时注意数组长度时奇数和偶数之间的区别,如果是偶数,每次操作可以对整个数组进行操作,而如果是奇数,倒数第二位的操作次数是有限制的,又因为要尽可能地完成目标,因此继续进行操作,此时倒数第二位已经不能操作了,于是剩下的子数组仍然是一个奇数串,同理去判断倒数第四位的最大操作次数,一次类推,判断最后是否有序

 

牛客24寒假5J

贪心+dp

如果所有区间的交集不为空,则在交集中任取一个陡峭值都为0

如果所有区间的交集为空,那么可以将所有区间变成一些连续且独立的交集,并且相邻两个交集之间一定不相交(否则可以合并为一个交集),每个交集内部的陡峭值都是0,因为相邻交集是不相交的,并且每次处理的问题是类似的,因此可以用动态规划解决交集之间的陡峭值

相互不相交,处理完一个区间后剩下区间的处理是相似的,并且最终是求最值,考虑用dp

对于区间的dp操作,考虑在数轴上去判断,如果前后两个区间分居这个区间的左右,那么这个区间中的取值是等价的,如果都在左侧,那么取左端点较优,如果在右侧,取右端点较优,因此定义状态dp[i][0/1]表示当前第i个区间取左端点/右端点,最小的陡峭值为dp[i][0/1]

关于拆分这些连续且独立的交集,可以按贪心的顺序选择交集,第一个区间一定在第一个交集里,第二个区间如果与一个交集相交,那么更新第一个交集,否则将第二个区间变成第二个交集

结论:对于一个区间,如果这个区间的前后两个交集都已知,并且这个区间既与前一个交集相交又与后一个交集相交(保证前后两个交集是不相交的),因为这个区间包含了前后连个交集之间空出的一部分,因此这个区间与前一个交集合并和与后一个交集合并都不影响两个交集之间的陡峭值(类似于有一个点取在两点之内,那么可以保证距离和是第一个定值),但如果放在后面,可能导致交集变小,导致后续可继续合并在该交集的区间数量减少(或者一般的说,对于数组之间的贪心,都是能与前面操作就与前面操作,这样能提供给后面最大的可能性)

#include<bits/stdc++.h>

using namespace std;

int main(){

    int n;

    cin>>n;

    vector<pair<int,int>> ve(n);

    for(auto &[l,r]:ve){

        cin>>l>>r;

    }

    int L=1,R=1e9;

    vector<vector<int>> v;

    for(auto &[l,r]:ve){

        if(L>r || R<l){

            v.push_back({L,R});

            L=l,R=r;

        }

        L=max(L,l),R=min(R,r);

    }

    v.push_back({L,R});

    vector dp(v.size(),vector(2,0ll));

    for(int i=1;i<v.size();i++){

        dp[i][0]=min(dp[i-1][0]+abs(v[i][0]-v[i-1][0]),dp[i-1][1]+abs(v[i][0]-v[i-1][1]));

        dp[i][1]=min(dp[i-1][0]+abs(v[i][1]-v[i-1][0]),dp[i-1][1]+abs(v[i][1]-v[i-1][1]));

    }

    cout<<min(dp.back()[0],dp.back()[1])<<"\n";

    return 0;

}

 

cf789D

比较朴素的想法是将矩阵展成横向的一长列,从n到1逆序遍历,每次计算出对应的值(模拟)

但这种每次都要重新计算显然让我们觉得效率不够高,所以考虑如何去利用之前的结果

既然题目中考虑的是整行整列,那我们在计数时也尝试从行列的整体变化上去考虑

对于列的答案,我们可以观察到,由于总共只有 n⋅m个学生,没有学生会离开,每当有一个新学生进入会场,所有列都会向右循环移动一步,因此答案不会减少。如果 i-th的学生是个认真的学生,那么之前所有下标为 j (其中 0<j<i和 j%m=i%m (这从数列上去分析显然满足这个关系的是同一列)(并且这里也不要每次都去遍历满足这个条件的元素了,因为m不变,因此对于各列余数可以作为一个特征值,因此直接维护col[i%m] == 0即可)是调皮的学生)的学生,该列的答案都会增加 1 。

对于行的答案,我们可以从 i−m 的答案中转移出来,这相当于在 i−m 的答案中增加了一行新的答案(这就是为什么不要一个一个元素地去考虑,整行更有规律性并且要求也更宽松)。假设最后一个认真学习的学生是 j这个学生。如果是 i−j<m ,答案将增加 1 ,否则答案将与 i−m 名学生进入会场时的答案相同。

 

ICPC2024 南京B

有点类似于贪心的思想,先不具体分配可以任选的元素的数值,而是在操作过程中依据我们的策略自动地去修改

给定一个由012组成的字符串,将所有的2修改为0和1中的一个,然后不断删去字符串中两个相邻的相同的字符,求最后可能得到的最小字符串长度

将字符串所有偶数位置取反,也就是对于所有偶数下标,如果原来是0,那么就修改为1

那么原串中相邻的相同字符,对应了新串中两个相邻的不同字符,不断删去两个相邻的不同字符知道不能进行删除,得到的字符串要么全部为0,要么全部为1

这是因为,如果字符串中同时存在0和1,那么必然会有一个0和一个1相邻,而对于这个位置我们可以继续操作

每次操作会删除一个0和一个1,因此最终的字符串长度就是当前字符串中两种字符个数的差,对于每个2来说,将其修改为出现次数较小的那种字符,就可以使得出现次数的差尽量小

(不能多次模拟去判断,找到一种能够判断或者模拟删除操作的方案,“相邻相同元素删除的策略”)

void solve(){

    string s;

    cin>>s;

    int n=s.size();

    int a,b,c;

    a=b=c=0;

    for(int i=0;i<n;i++){

        if(s[i]=='2') c++;

        else if(((s[i]-'0')^(i&1))==1) a++;

        else b++;

    }

    cout<<max(n&1,abs(a-b)-c)<<'\n';

}

 

 

找操作中的不变量

cf1983b

每次操作选择一个子矩形,对子矩形的四个对角元素进行操作,其中一对加1模三,另一对加2模三,可以转化成一对加一,一对减一,因此在同一行以及同一列上最终的和并不发生变化

将相等问题转化为差是否为0问题,即将原数组减去目标数组

/*

选四个顶点,其中一对加1,另一对减1

每一行和每一列的和都是不变的

因此判断是否每行的和以及每列的和是相等的

*/

void solve(){

    int n,m;

    cin>>n>>m;

    string s;

    vector<int> r(n),c(m);

    for(int i=0;i<n;i++){

        cin>>s;

        for(int j=0;j<m;j++){

            r[i]+=s[j]-'0';

            c[j]+=s[j]-'0';

        }

    }

    for(int i=0;i<n;i++){

        cin>>s;

        for(int j=0;j<m;j++){

            r[i]-=s[j]-'0';

            c[j]-=s[j]-'0';

        }

    }

    //矩阵中的值是在模三意义下的,因此判断模三是否为0

    bool ok=1;

    for(int i=0;i<n;i++){

        if(r[i]%3!=0){

            ok=0;

        }

    }

    for(int j=0;j<m;j++){

        if(c[j]%3!=0){

            ok=0;

        }

    }

    cout<<(ok?"YES":"NO")<<'\n';    

}

 

 

奇偶性

cf1983d

目标是要让两个数列变得相等,每次交换可以在两个数组中交换两个距离相等的数

而任意不同距离元素的交换在操作次数没有限制的情况下可以转化为相邻元素的交换(即将目标元素通过相邻元素的交换逐个交换到目标位置,再将原目标位置上的元素以相同的方式交换到对应的位置)

同时,交换数组中任何两个相邻元素都会导致该数组的逆序对数加一或减一,也就是数组的逆序对奇偶性发生了变化

因此可以比较两个数列中的反转奇偶性

这题中保证了数组中元素均不同,如果这个条件不一定满足,即序列中可能存在两个数相同,那么交换这两个相同的数可以使奇偶性发生改变,在这种情况下一定有解

证明当两个数组具有相同的奇偶性时两数组最后一定能变得相同,因为操作次数没有限制,不妨让两数组都通过冒泡排序变得有序,那么排序过程中的交换次数显然就是与逆序对数相关,在一个数组先与另一个数组排完序的情况下,可以通过不断交换两个元素使得进度与另一个数组想同,而为了最终仍然是有序的,每次交换都是重复交换2次,因此奇偶性不发生变化,也就是如果两个数组的奇偶性相同,那么最终一定能通过若干次操作使得两数组相等

int parity(vector<int> &a){

    const int n=a.size();

    vector<int> vis(n);

    int p=n%2;

    for(int i=0;i<n;i++){

        if(vis[i]) continue;

        for(int j=i;!vis[j];j=a[j]){//置换环的交换

            vis[j]=true;

        }

        p^=1;//统计置换环个数的奇偶性

    }

    return p;

}

 

void solve(){

    int n;

    cin>>n;

    vector<int> a(n),b(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]--;

    }

    for(int i=0;i<n;i++){

        cin>>b[i];

        b[i]--;

    }

    //因为不是排列

    auto va=a,vb=b;

    sort(all(va));sort(all(vb));

    if(va!=vb){

        cout<<"NO\n";

        return;

    }

    //相当于离散化

    for(int i=0;i<n;i++){

        a[i]=lower_bound(all(va),a[i])-va.begin();

    }

    for(int i=0;i<n;i++){

        b[i]=lower_bound(all(vb),b[i])-vb.begin();

    }

    if(parity(a)!=parity(b)){

        cout<<"NO\n";

    }else{

        cout<<"YES\n";

    }

}

 

2024ICPC网络赛第一场C

给定n个限制(li,ri),问长度为n的全排列中,且满足第i个数位于区间(li,ri)内的排列数个数的奇偶性

首先题目既然只问奇偶性,说明不太可能暴力求出真正的总个数

对每个限制(li,ri)建一条边l-1,r,看最后建出来的图是不是一棵树

矩阵秩的考虑方向:
考虑所有向量是不是线性无关

如果有一组向量异或是0,就相当于给l-1到r连边,就会产生环

按照连边的方法来做,判断是否有环可以用并查集也可以用dfs

用并查集的写法:

void solve(){

    int n;

    cin>>n;

    DSU dsu(n+1);

    bool ok=1;

    for(int i=1;i<=n;i++){

        int l,r;

        cin>>l>>r;

        l--;

        ok&=dsu.merge(l,r);

    }

    cout<<ok<<'\n';

}

如果用dfs

    auto dfs=[&](auto self,int u)->void{

        if(vis[u]==0){

            sum++,vis[u]=1;

        }

        for(auto v:adj[u]){

            if(vis[v]==0){

                self(self,v);

            }

        }

    };

    dfs(dfs,0);

    cout<<(sum==n+1)<<'\n';

当两个区间为包含关系时,大区间的相交部分可以去掉,因为可以和小区间互换,方案数为偶数

也就是考虑构造一些双射,让他们抵消掉,因为如果两个区间是一样的,那么他们可以交换,因此说明是偶数(可以将这两个区间的方案数*2)

所以把区间之间的包含关系拆开,然后看是否有一样的区间

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> vec(n+1);

    for(int i=1;i<=n;i++){

        int l,r;

        cin>>l>>r;

        vec[l].emplace_back(r);

    }

    for(int i=1;i<=n;i++){

        auto &a=vec[i];

        if(a.empty()){//说明这个数无法放置到任意一个位置上,方案数为0

            cout<<0<<'\n';

            return;

        }

        sort(a.begin(),a.end());

        int siz=a.size();

        for(int j=0;j<siz-1;j++){

            if(a[j]==a[j+1]){

                cout<<0<<'\n';

                return;

            }

            vec[a[j]+1].emplace_back(a[j+1]);

            //拆区间

        }

    }

    cout<<1<<'\n';

}

 

 

构造题:

Cf 2114G

一个空数组,为了向数组中添加一些整数,可以进行无限次下述操作:

向数组的左端或右端添加任意一个整数。

添加之后,只要数组中有一对相邻的数相同,它们就会被替换为它们的和。

可以证明数组中不会同时出现两对相邻的数相同。

在进行了恰好 k 次操作后,得到了一个长度为 n 的数组 a

请判定数组 a 是否能由一组 k 次操作序列得到。

因为本身是一个逆向的题目,因此核心思路也是从最终状态反推到初始状态,依据最多需要的次数来判断是否可行

因为正向是添加元素后合并相同相邻元素,那么逆向就是拆分元素

每次都是相同的进行合并,从而这里有一个关键的结论就是可以每个数字都可以表示为 奇数 * 2^k

于是我们依据这个结论来做一些拆分

由这个形式,我们可以想到使用 a & -a,这一般在树状数组中用到,用于获取a的最低位的1对应的值,对应于a中2的最大幂次,将这个值记为la,那么a/la就是其奇数部分

因为每个数的拆分受到其相邻数的影响,因此我们前缀和后缀各计算一次

定义一个get函数,get(a, b)表示在b已经存在的前提下,产生a需要的操作次数

两种情况:

情况1:a / la != b / lb || a < b

如果 a 和 b 的奇数部分不同,或者 a < b

返回 la:这意味着 a 是独立添加的,需要 la 次操作

情况2:a / la == b / lb && a >= b

a 和 b 有相同的奇数部分,且 a >= b

这说明 a 可能是由 b 合并得来的

返回 la - 2 * lb + 1:这是合并的成本

例如: a = 12, b = 6,都等于 3 × 2^k:

a = 3 × 4, b = 3 × 2

要得到这个相邻数对,我们可以:

先添加两个 3,合并得到 6

再添加一个 6,与之前的 6 合并得到 12

总操作数 = 4 - 2×2 + 1 = 1(实际上这个公式计算的是增量操作数)

因为每次操作就是幂次加1,因此la实际表示从第一个奇数开始合成要多少次,而b存在时,不能先先添加一个b,因为这样已存在的b会变成2*b,而我们又只能在一侧加,那么b不能再得到,因此我们必须一开始就添加一个2*b来隔开,在此基础上再继续合成,因此次数为la - 2 * lb + 1

计算答案时即先从后往前计算一次,然后从前往后计算一次

最后再计算最优的分割点

要注意的是因为每次只能在一侧添加数,因此我们合成、计算get时两个传入参数的顺序是有严格要求的,也因此要进行前后缀的计算

int get(int a, int b) {

    int la = a & -a;

    int lb = b & -b;

    if (a / la != b / lb || a < b) {

        return la;

    }

    return la - 2 * lb + 1;

}

 

void solve() {

    int n, k;

    cin >> n >> k;

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

    vector<i64> pre(n), suf(n);

    for (int i = 1; i < n; i++) {

        pre[i] = pre[i - 1] + get(a[i - 1], a[i]);

    }

    for (int i = n - 2; i >= 0; i--) {

        suf[i] = suf[i + 1] + get(a[i + 1], a[i]);

    }

    for (int i = 0; i < n; i++) {

        if (pre[i] + (a[i] & -a[i]) + suf[i] >= k) {

            cout << "YES\n";

            return;

        }

    }

    cout << "NO\n";

}

 

Cf 2116D

Gellyfish 有一个长度为 n 的整数数组 c,初始状态为 c=[a[1],a[2] ,…,a[n]]。接下来,Gellyfish 对数组进行 q 次修改。每次修改由三个整数 x[i],y[i],z[i]描述,表示将 c[z[i]]的值设置为 min(c[x[i]],c[y[i]])。经过 q 次修改后,数组变为 c=[b[i],b[i] ,…,b[n]]。

Flower 知道最终数组 b 和所有修改操作 (x[i], y[i], z[i]),但不知道初始数组 a。她希望找到任意一个满足条件的初始数组 a,或者判断不存在这样的 a。如果存在多个解,输出任意一个即可。

直接构造 a 使得操作后 a[i] = b[i]非常不好做,考虑放宽条件,构造 a 使得最后 a[i]⩾b[i] ,其中每个 a[i]尽可能小(显然初始值尽可能小最后的结果也会趋近于 b[i]),最后只要测试一遍构造出的 a 跑出来的结果是否等于 b 即可

正难则反。直接构造困难,因此考虑从最后往前处理操作,也就是如何恢复,假如遇到i,那么就令 a[x[i]]←max{a[x[i]],a[z[i]]}, a[y[i]]←max{a[y[i]],a[z[i]]} 因为想要达成目标,必须满足 min{a[x[i]], a[y[i]]}=a[z[i]] ,我们这样做就是相当于令 min{a[x[i]],a[y[i]]}≥a[z[i]]。然后令 a[z[i]]←0,因为 a[z[i]]在操作后被覆盖了导致我们不能知道原来的,所以在前面假如 z[i]成为了 x[j]或者 y[j]刚好就可以用前面的操作还原 a[z[i]]

然后我们就发现这样处理就行了。最终的话就直接正着跑一遍判断是否无解即可

具体来说,由于这些变量是具有传递性的,所以可以把序列上每个位置的每次修改都看做一个节点,对于一次 (x,y,z) 的修改,就将 x,y 的当前版本向 z 的当前版本连边,以表示两者取 min 的赋值操作

void solve() {

    int n, q;

    cin >> n >> q;

    vector<int> b(n);

    for (int i = 0; i < n; i++) {

        cin >> b[i];

    }

    vector<int> p(n);

    iota(p.begin(), p.end(), 0);

    vector<array<int, 3>> op(q);

    vector<int> u(n + q), v(n + q);

    for (int i = 0; i < q; i++) {

        int x, y, z;

        cin >> x >> y >> z;

        x--, y--, z--;

        op[i] = {x, y, z};

        u[n + i] = p[x];

        v[n + i] = p[y];

        p[z] = n + i;

    }

    vector<int> val(n + q);

    for (int i = 0; i < n; i++) {

        val[p[i]] = b[i];

    }

    for (int i = n + q - 1; i >= n; i--) {

        val[u[i]] = max(val[u[i]], val[i]);

        val[v[i]] = max(val[v[i]], val[i]);

    }

    val.resize(n);

    auto a = val;

    for (auto [x, y, z] : op) {

        a[z] = min(a[x], a[y]);

    }

    if (a != b) {

        cout << -1 << '\n';

        return;

    }

    for (int i = 0; i < n; i++) {

        cout << val[i] << " \n"[i == n - 1];

    }

}

通过n + q个p数组的元素,实际上实现了类似于主席树的可持久化存储

一开始p[i] = i,也就是各自对应版本的元素位置,然后每次更新时,存储此时x,y对应的实际位置(版本),用u[i]和y[i]进行维护,以及更新z对应的版本

因为是逆向操作,因此之后是从后往前进行更新

 

Cf 2103D

定义一个元素是区间小,当其小于它周围的元素时;定义一个元素是区间大时,当其大于它周围的元素时

从回合1开始,轮流进行下面的删除操作:删除数组中不是区间小的元素;删除数组中不是区间大的元素

给定一个数组a来表示每个位置被删除的位次

要求构造出一个符合数组a删除情况的排列

排列也就是每个元素只出现一次,从有序填充的角度考虑,可以使用l来表示当前可以被填充的数字

可以发现,每一轮中删除的数字具有一定的大小关系

例如 1 1 1 ,那么一定有1>2>3,这是由边界情况导致的

且删除完后下一组的边界也由同样的情况

因为是删除非区间大和区间小交替进行的,因此我们可以使用l,r表示可用区间的左端点和右端点,同时也交替用l和r进行填充

也因为交替的填充,能得到相邻元素正确的大小关系

void solve() {

    int n;

    cin >> n;

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

    vector<int> p(n);

    int l = 1, r = n;

    vector<int> b(n);

    iota(b.begin(), b.end(), 0);

    for (int t = 1; b.size() > 1; t++) {

        int x = 0;

        while (a[b[x]] == t) {

            x++;

        }

        for (int i = 0; i < x; i++) {

            if (a[b[i]] == t) {

                p[b[i]] = t % 2 == 1 ? r-- : l++;

            }

        }

        for (int i = b.size() - 1; i > x; i--) {

            if (a[b[i]] == t) {

                p[b[i]] = t % 2 == 1 ? r-- : l++;

            }

        }

        vector<int> nb;

        for (auto i : b) {

            if (a[i] != t) {

                nb.emplace_back(i);

            }

        }

        b=move(nb);

    }

    p[b[0]] = l;

    for (int i = 0; i < n; i++) {

        cout << p[i] << " \n"[i == n - 1];

    }

}

 

Cf 2111F

在一张方格纸中,能不能填上若干个小正方形使得得到的图形满足下列条件:

拼图块平行于坐标轴排列;

拼图之间没有重叠

所有拼图组成一个连通的整体(即存在一条从每块拼图到其他每块拼图的路径,其中每两个连续的拼图共享一条边);

此组件的周长与面积之比等于p/s

涂上的格子数不超过5000

首先看到这种和坐标轴平行的图形想到一个结论:一个长方形横平竖直地削去一个角之后周长不变

这启发我们枚举周长,看面积能否通过这种方式表示。

我们相当于是框定了一个矩形,然后选择其中的一个角来填充

第一步,很重要,将比值约分成最简形式,只有这样周长才一定是其倍数。

首先周长一定是偶数,所以若输入为奇数则两者同时乘 2。

于是我们就可以知道长和宽的和,设长 x,宽 y 那此时能用这种方法表示出来的面积范围为[x + y – 1,xy]

上下界都是确定的,下界显然,上界自然是当x, y都取到p/4时得到

如果面积已经落在这个范围里了,那自然是最好

如果小于这个范围,那么说明无解了,如果是大于这个范围,注意到我们同时将p, s乘上一个倍数时,下界以及s的变化速度实际上都可以看作O(p)的,但是我们的上界因为是乘所以时O(p^2),因此变化速度更快,当乘到一定的倍数时,至少能使得上界大于s

注意到我们的格子数量还有限制,但经过稍作证明就可以发现实际上这样的限制是一定无法达到的

知道了长宽之后就好办了,可以先把框架的 x+y−1个定好,然后只需要按照面积填进去就可以了

void solve() {

    int p, s;

    cin >> p >> s;

    int tmp = gcd(p, s);

    p /= tmp;

    s /= tmp;

    if (p % 2 == 1) {

        p *= 2;

        s *= 2;

    }

    int x = (p / 2) / 2, y = p / 2 - x;

    int i;

    for (i = 2; x * y < (s * (i - 1)); i++) {

        x = ((p * i) / 2) / 2, y = (p * i) / 2 - x;

    }

    p *= (i - 1);

    s *= (i - 1);

    if (s - x - y + 1 < 0) {

        cout << -1 << '\n';

        return;

    }

    cout << s << '\n';

    s -= x + y - 1;

    for (int i = 1; i < x; i++) {

        cout << i << " " << 0 << '\n';

    }

    for (int i = 0; i < y; i++) {

        cout << 0 << " " << i << '\n';

    }

    for (int i = 1; i < x; i++) {

        for (int j = 1; j < y; j++) {

            if (s) {

                s--;

                cout << i << " " << j << '\n';

            }

        }

    }

}

 

 

cf1983c

每个元素有一个价值,不同人的价值不一样,但sum是相同的

分成3段,每一段给一个人,要求每一段在对应数组中的元素和均大于等于总和的三分之一

假设只有一个数组,为了满足上述的要求我们显然是贪心地去划分数组,即当前元素值之和仍然不足的情况下就继续加入元素,否则就切换到下一段

而有三个数组的时候我们仍然可以类似地去操作,只不过可能因为对三个数组的操作顺序不同导致答案发生变化,因此对三个数组的顺序进行排列,并逐个查找

void solve(){

    int n;

    cin>>n;

    array<vector<int>,3> a;

    for(int i=0;i<3;i++){

        a[i].resize(n);

    }

    i64 tot=0;

    for(int i=0;i<3;i++){

        for(int j=0;j<n;j++){

            cin>>a[i][j];

            if(i==0){

                tot+=a[i][j];

            }

        }

    }

    array<int,3> perm{0,1,2};

    do{

        array<int,3> l{},r{};

        int cur=0;

        bool ok=1;

        for(int i=0;i<3;i++){

            l[perm[i]]=cur+1;

            i64 sum=0;

            while(sum<(tot+2)/3 && cur<n){//上取整

                sum+=a[perm[i]][cur++];

            }

            if(sum<(tot+2)/3){

                ok=0;

                break;

            }

            r[perm[i]]=cur;

        }

        if(ok){

            for(int i=0;i<3;i++){

                cout<<l[i]<<' '<<r[i]<<' ';

            }

            cout<<'\n';

            return;

        }

    }while(next_permutation(all(perm)));

    cout<<-1<<'\n';

}

 

Cf 2092d

一个只包含3种字符的字符串,每次操作可以在两个不同的字符里插入第三种字符,构造一个不超过2n次操作的方案,使得三个字符数量相等 这份代码是用来解决这个问题的,请分析代码逻辑

ab->acb->abcb,这样能够保留ab,从而继续增加

相当于减少了一个a,因为增加了一个b和一个c而a的数量的不变

于是每次操作可以把较多的字母减少一个,且操作完成后ab,bc都存在

只要整个字符串不是全相等,每个字符一定可以存在和其他字符相邻的情况(例如上面操作完一次后下面选择操作b,那么就是acabcb,构造出了ab,bc,ca都存在的情况)

因此对于不是最小的,枚举三种元素逐个操作

按照上面的操作思路也可以看到,对于目标字符,可能存在在左侧和在右侧的情况,也就是ab(对a)和cb(对c),这两种操作有所差异,因此分开解决

void solve() {

    int n;

    cin>>n;

    string s;

    cin>>s;

    if(count(s.begin(),s.end(),s[0])==n){

        cout<<-1<<'\n';

        return;

    }

    vector<int> a(n);

    int cnt[3]{};

    for(int i=0;i<n;i++){

        a[i]=s[i]=='L'?0:s[i]=='I'?1:2;

        cnt[a[i]]++;

    }

    vector<int> ans;

    int min=*min_element(cnt,cnt+3);

    for(int x=0;x<3;x++){

        if(cnt[x]==min){

            continue;

        }

        int p=1;

        while((a[p]==x) == (a[p-1]==x)){

            p++;

        }

        while(cnt[x]>min){

            if(a[p]==x){

                int y=a[p-1];

                a.insert(a.begin()+p,3-x-y);

                ans.emplace_back(p);

                a.insert(a.begin()+p+1,y);

                ans.emplace_back(p+1);

                p+=2;

            }else{

                int y=a[p];

                a.insert(a.begin()+p,3-x-y);

                ans.emplace_back(p);

                a.insert(a.begin()+p,y);

                ans.emplace_back(p);

            }

            cnt[x]--;

        }

    }

    cout<<ans.size()<<'\n';

    for(int x:ans){

        cout<<x<<'\n';

    }

}

 

Cf 2092 E

给定一个n*m的网格,其中有k个单元的颜色已经确定了(黑或白),现在要把剩余的格子都涂上颜色,要求涂完颜色后,棋盘上相邻不同颜色的单元格的对数是偶数,问这样的涂色方案

相邻不同,两个异或得到的结果是1,然后总的对数是偶数,等价于所有异或的结果再异或起来最后的值是0

因此只有边上的格子才有用(因为中间会被计算到偶数,可以手玩一下,角上两次,无效,中间4次,无效,边上3次有效),同时中间必须有偶数个0和偶数个1,但因为边上的格子数一定是偶数,因此满足其中之一,如偶数个0即可

从而初始的方案数是2^(n+m-k),也就是这些位置可以任意选,但还要/2,这是根据二项式系数的基本特性得到的,或者说这种情况(边界区块的颜色并非全部都确定)下,要填充的边界区块的颜色的奇偶性是确定的,因此要排除掉违法的情况,已有的黑色是奇数个,那么要求当前填充方案中黑色也是奇数个,因此对于偶数情况要删除,虽然对于颜色翻转的方案还是允许的,但是注意颜色填充方案被划分成了均匀分布和不均匀分布两种,而根据二项式系数可知这两种方案数是相等的,所以把计算出来全部任意情况除2

void solve() {

    int n,m,k;

    cin>>n>>m>>k;

    int cnt[2]{};

    for(int i=0;i<k;i++){

        int x,y,c;

        cin>>x>>y>>c;

        if((x==1)^(x==n)^(y==1)^(y==m)){

            cnt[c]++;

        }

    }

    Z ans=power(Z(2),1ll*n*m-k)/2;

    if(cnt[0]+cnt[1]==2ll*(n+m-4)){

        if(cnt[0]%2==0){

            ans*=2;

        }else{

            ans=0;

        }

    }

    cout<<ans<<'\n';

}

 

 

Cf 2089A

构造一个长度为n的排列,得到另一个数组c,c[i]=(p[1]+p[2]+…+p[i])/i上取整,要求c中至少有n/3-1个素数

总的平均值是n/2,在附近找一个素数

n/3和2*n/3之间一定有一个素数

构造 p 的前n/3项为 a,a−1,a+1,a−2,a+2,⋯,a 是素数,满足 2×min(n−a,a−1)+1>=n/3-1,则 c1=c2=...=c[n/3-1]=a

void solve(){

    int n;

    cin>>n;

    sieve(n);

    int p=max(1,n/3);

    while(minp[p]!=p){

        p++;

    }

    vector<int> a{p};

    int l=p,r=p;

    while(l>1 && r<n){

        l--;

        r++;

        a.emplace_back(l);

        a.emplace_back(r);

    }

    for(int i=1;i<l;i++){

        a.emplace_back(i);

    }

    for(int i=r+1;i<=n;i++){

        a.emplace_back(i);

    }

    for(int i=0;i<n;i++){

        cout<<a[i]<<" \n"[i==n-1];

    }

}

(使用的结论:n到2*n之间一定有一个素数,也就是一个素数到下一个素数的距离不可能大于我们出发的这个数)

 

Cf 2085 E

给定两个数组a和b,判断数组b有没有可能是数组a中的元素模上k后打乱顺序得到的

如果不行,则输出-1,否则输出k

因为是打乱顺序得到的,因此不妨先进行排序,如果a和b完全相同,可以视作a模上一个很大的数得到的

同时,模运算得到的数必定小于等于原数,因此如果数组b的和大于数组a的和,那么显然是不可行的

然后可以计算两个数组和的差,因为模运算后减小的值一定是模数的整数倍,因此可以据此判断可能的模数

因为可能的只是因数大小,同时每次判断是O(n)的,因此可以直接判断

void solve() {

    int n;

    cin>>n;

    vector<int> a(n),b(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    for(int i=0;i<n;i++){

        cin>>b[i];

    }

    sort(a.begin(),a.end());

    sort(b.begin(),b.end());

    if(a==b){

        cout<<int(1e9)<<'\n';

        return;

    }

    i64 suma=accumulate(a.begin(),a.end(),0ll);

    i64 sumb=accumulate(b.begin(),b.end(),0ll);

    i64 diff=suma-sumb;

    if(diff<0){

        cout<<-1<<'\n';

        return;

    }

    auto na=a;

    auto check=[&](i64 k){

        if(k>int(1e9)){

            return false;

        }

        na=a;

        for(auto &x:na){

            x%=k;

        }

        sort(na.begin(),na.end());

        return na==b;

    };

    for(i64 i=1;i*i<=diff;i++){

        if(diff%i==0){

            if(check(i)){

                cout<<i<<'\n';

                return;

            }

            if(check(diff/i)){

                cout<<diff/i<<'\n';

                return;

            }

        }

    }

    cout<<-1<<'\n';

}

 

 

ICPC2024 成都

定义一个箭串是一个长度大于等于5的,前面三个字符和最后一个字符为’>’,其余字符为’-’的串

给定一个长度为n的只有’>’和’-’的串,问能否可以通过若干个箭串构造得到

显然可以得到一些不可能构造出来的充分条件,例如最开始的字符是’-’,整串字符串全是’>’等

考虑到在箭串的定义中,>的个数和相对位置是固定的,而-的数量是不固定的,因此考虑以>作为标准去构造,进而考虑到以左右边界为基准进行构造

void solve(){

    string s;

    cin>>s;

    int n=s.size();

    if(s[0]=='-' || s[n-1]=='-' || s[n-2]=='-' || s[n-3]=='-'){

        cout<<"No\n";

        return;

    }

    if(set(s.begin(),s.end()).size()==1){

        cout<<"No\n";

        return;

    }

    vector<pair<int,int>> ans;

    int r=n-1;

    while(s[r]=='>'){

        r--;

    }

    int last=++r;

    int l=0;

    r=n-3;

    // cout<<last<<'\n';

    int ok=0;

    while(l<last){

        while(l<last && s[l]=='-'){

            l++;

        }

        if(l==last) break;

        // cout<<l<<' '<<r<<'\n';

        if(r==last){

            ok=1;

        }

        ans.push_back({l+1,r-l+1+2});

        l++;

        r=max(last,r-3);

    }

    l--;

    while(s[l]=='-'){

        l--;

    }

    while(ok==0){

        if(r==last){

            ok=1;

        }

        ans.push_back({l+1,r-l+1+2});

        r=max(last,r-3);

    }

    cout<<"Yes "<<ans.size()<<'\n';

    for(auto [l,r]:ans){

        cout<<l<<' '<<r<<'\n';

    }

}

 

欧拉路径

欧拉回路:图中经过每条边恰好一次并回到起点的回路。

欧拉路径:图中经过每条边恰好一次的路径(不要求回到起点)。

存在条件:

欧拉回路:所有顶点的度数都是偶数

欧拉路径:恰好有0个或2个度数为奇数的顶点

 

cf 1981d

相邻数的乘积是互不相同的,并且要让不同的数字的个数尽可能少

首先可以全部用素数,素数保证了相互的乘积是不相同的,那么此时如果两个数乘积相同当且仅当两个数所用的素数的集合是一样

这样我们就能取到答案的下界(即不同数字的个数最多)

把 (ai,ai+1)看作一条边,那么问题就可以转化为寻找节点最少的无向完全图(每个节点都有一个自循环),使得这个完全图包含一条由 n−1条边组成的路径,并且不重复任何一条边(重复就代表了乘积相同)(完全图:每对不同的顶点之间都恰有一条边相连)

假设完全图中的顶点数为 m。如果 m是奇数,那么每个节点的度数都是偶数,因此这个图包含一条欧拉路径,路径长度等于边的数量,即 m(m+1)/2

如果 m是偶数,那么每个节点的度数是奇数,我们需要删除一些边,使这个图包含一条欧拉路径。不难看出,每移除一条边,奇数度顶点的数量最多会减少 2,因此我们至少需要移除 m/2−1 条边。移除(2,3),(4,5),…,(m−2,m−1)这些边就足够了,路径长度将为 m(m−1)/2−m/2+1+m=m^2/2+1

当 n=1e6 时,最小的 m是 1415 ,而 14151-th 最小的素数是 11807 ,满足 ai≤3e5

//筛素数

vector<int> minp,primes;

void sieve(int n){

    minp.assign(n+1,0);

    primes.clear();

    for(int i=2;i<=n;i++){

        if(minp[i]==0){

            minp[i]=i;

            primes.push_back(i);

        }

        for(auto p:primes){

            if(i*p>n) break;

            minp[i*p]=p;

            if(p==minp[i]){

                break;

            }

        }

    }

}

void solve() {

    int n;

    cin>>n;

    int m=1;

    while(n-1>(m%2==1?m*(m+1)/2:m*m/2+1)){

        m++;//找最少的顶点数

    }

    vector<int> a;

    a.reserve(n);

    //找一条欧拉路径

    /*

    不同数的个数尽可能小:完全图的顶点数尽可能小;要满足数组的长度为n,最长路径不能小于n-1,因此选择完全图

    因为a到b和b到a是不能同时走的,因此应该是一个无向图,故还要考虑度数

    奇数个点,每个图的度数都是偶数

    偶数个点,每个点的度数都是奇数

 

    对于偶数顶点的图来说每个点的度数都是奇数,但是至少要有m-2个点要变成偶数

    奇数图的最长路径是m*(m+1)/2

    偶数图的最长路径是m*m/2+1

    奇偶分类比较麻烦因此直接建图

    */

 

    //因为是完全图

    vector<vector<int>> g(m,vector<int>(m,1));

    vector<int> cur(m);//直接dfs可能复杂度不对

    if(m%2==0){

        for(int i=1;i<m-1;i+=2){//断m/2-1条边

            g[i][i+1]=g[i+1][i]=0;

        }

    }

    //Fleury

    auto dfs=[&](auto &&self,int x)->void{

        for(int &i=cur[x];i<m;i++){//类似于当前弧的优化

            if(g[x][i]){

                g[x][i]=g[i][x]=0;

                self(self,i);

            }

        }

        a.push_back(primes[x]);

    };

    dfs(dfs,0);

    a.resize(n);//答案数组,大小为n

    for(int i=0;i<n;i++){

        cout<<a[i]<<' ';

    }

    cout<<'\n';

}

 

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    sieve(3e5);

    int t=1;

    cin>>t;

    while(t--) solve();

    return 0;

}

 

Cf 2110E

为了创作音乐,机器人拥有一种特殊的乐器,能够产生 n 种不同的声音。每种声音由其音量和音高来表征。一系列声音被称为音乐。如果任意两个连续的声音仅在音量或仅在音高上有所不同,则该音乐被认为是优美的。如果任意三个连续的声音在音量或音高上相同,则该音乐被认为是单调的。

你需要创作一段优美且不单调的音乐,其中包含乐器产生的每个声音恰好一次。

题意要求任意两个连续的声音仅在音量或仅在音高上不同,且任意三个连续的声音不能在音量或在音高上相同。

通过这个信息我们可以知道,若第 i 个声音和第 i+1 个声音在音高上相同,那么第 i+1 个声音和第 i+2 个声音一定在音量上相同。

所以所有相邻的声音组一定构成一个“音量相同”和“音高相同”交错的序列。

考虑将每一个声音抽象成一个连接自身音高和自身音量的一条边,这个时候我们发现我们要求的整个声音序列构成一个欧拉路径。

这个时候直接将音量和音高进行一个离散化然后做二分图无向图欧拉路径就可以了。

建图策略:

将每个唯一的音量值和唯一的音高值都作为图的顶点

每个声音作为一条边,连接其对应的音量顶点和音高顶点

问题转化为:找一条欧拉路径,经过每条边恰好一次

通过这样建图保证了:

每个声音作为边:确保每个声音被使用恰好一次

音量/音高作为顶点:相邻的两个声音必须通过共同的音量或音高连接,因为两条边的公共顶点保证了其音量或者音高相同

欧拉路径的性质:保证了相邻声音只在一个属性上不同

并且由于图的结构(构建的是二分图),不会出现连续三个声音在同一属性上相同的情况

void solve() {

    int n;

    cin >> n;

    vector<int> v(n), p(n);

    for (int i = 0; i < n; i++) {

        cin >> v[i] >> p[i];

    }

    auto vs = v, ps = p;

    sort(vs.begin(), vs.end());

    sort(ps.begin(), ps.end());

    for (int i = 0; i < n; i++) {

        v[i] = lower_bound(vs.begin(), vs.end(), v[i]) - vs.begin();

        p[i] = lower_bound(ps.begin(), ps.end(), p[i]) - ps.begin();

    }

    vector<int> deg(2 * n);

    vector<vector<array<int, 2>>> adj(2 * n);

    for (int i = 0; i < n; i++) {

        adj[v[i]].push_back({p[i] + n, i});

        adj[p[i] + n].push_back({v[i], i});

        deg[v[i]]++;

        deg[p[i] + n]++;

    }

    vector<int> ans;

    int s = 0;

    while (s < 2 * n && deg[s] % 2 == 0) {

        s++;

    }

    if (s == 2 * n) {

        s = 0;

        while (!deg[s]) {

            s++;

        }

    }

    int cnt = 0;

    for (int i = 0; i < 2 * n; i++) {

        cnt += deg[i] % 2;

    }

    // 奇数顶点超过2个,无欧拉路径

    if (cnt > 2) {

        cout << "NO\n";

        return;

    }

    // Hierholzer算法求欧拉路径

    vector<int> cur(2 * n);

    vector<bool> vis(n);

    auto dfs = [&](auto self, int x) -> void {

        for (int &j = cur[x]; j < adj[x].size(); j++) {

            auto [y, i] = adj[x][j];

            if (!vis[i]) {

                vis[i] = true;

                self(self, y);

                ans.push_back(i);

            }

        }

    };  

    dfs(dfs, s);

    if (ans.size() != n) {

        cout << "NO\n";

        return;

    }

    cout << "YES\n";

    for (int i = 0; i < n; i++) {

        cout << ans[i] + 1 << " \n"[i == n - 1];

    }

}

 

 

cf1990B

给定一个数组的前缀最值和后缀最值下标,数组中每个元素要么是1要么是-1,构造数组使得满足条件

首先因为可能出现...x...y...和..yx......两种情况,因此不能简单的将小于x的都赋值为1,大于y都赋值为1

可以从x,y的角度去考虑,先不考虑x,y之前以及之后具体是怎么样的赋值,首先要考虑的是在确定x是最大值以后,如何构造使得它之后的位置不会出现更大的前缀和

其中一种是像前面想到的那样用-1去填充,但实际上也可以用-1,1轮流填充,这样可以保证后面的前缀和一定不会大于x的前缀和

y同理,第一位开始用1,-1轮流填充,同时要保证y前面一位一定要是-1

在大于y和小于x这段区间内,可以全部用1填充

 

牛客24多校3 D

有n张骨牌,每个骨牌上写有两个数字xi,yi

要求将n张骨牌横着摆成一排,一共2n个数字,保证对于相邻的不属于同一骨牌的数字互不相同

对于这样的问题,先找无解的条件(什么情况会无解)

显然当一个数字出现次数极大时,一定会导致无解的情况,这样的出现次数的临界值是n+2

(横着摆放,编号从1开始)

可以类似鸽巢原理地去考虑,(2,3)(4,5)...(2n-2,2n-1)的数必须互不相同,这种情况下,我们最多不同的放置方案是1,3,5,...,2n-1,因为同一个骨牌不会影响,于是2n也可以放置一个,这样一共的出现次数是n+1,因此n+2一定无解,否则一定有解

考虑从左到右构造方案,如果所有骨牌都满足xi!=yi,那可以按任意顺序依次加入每一张骨牌的右侧,因为它一定有一个点数和它左侧的点数不同,因为假设当前最右侧的数字是x,那么如果放置当前骨牌后,它左侧数字和x相同,又因为该骨牌两个数字不同,因此一定有它右侧的数字和x不同,因此可以考虑翻转这个骨牌后进行放置

所以优先考虑如何摆放xi=yi的骨牌,可以用类似贪心的想法去做,这里有若干个xi=yi的牌,实际上可以把这些牌看作是一个数字,然后就是要把这些数字排起来,并且相邻两个数字不能相同,贪心的想法就是每次将当前出现次数最多的放在最左边

将所有xi=yi的骨牌按照每个点数的出现次数放在一个堆中,每次选择出现次数最大(并且和上一个放置的不同)的骨牌放在右侧(显然如果这样的骨牌没有一个的出现次数是一半加一的话,我们一定是可以按照这个策略将骨牌放置完毕的)

如果发现剩下xi=yi的骨牌点数都和上一个相同,不妨设该点数为p,从xi!=yi的骨牌对中挑一个xi!=p&&,yi!=p的骨牌作为间隔

可以证明,当所有数字出现次数不超过n+1时,上述方法一定能构造一个合法的方案

void solve(){

    int n;

    cin>>n;

    set<pair<int,int>> pq;//模拟堆

    map<int,multiset<int>> g;

    map<int,int> cnt;

    for(int i=0;i<n;i++){

        int u,v;

        cin>>u>>v;

        g[u].insert(v);

        g[v].insert(u);

        cnt[u]++;

        cnt[v]++;

    }

    for(auto &[x,y]:cnt){//要进行x,y位置的交换时,要加引用

        pq.emplace(y,x);//以出现次数排序

    }

    vector<pair<int,int>> ans;

    while(!pq.empty()){

        auto [c1,x1]=*prev(pq.end());

        pq.erase({c1,x1});

        if(!ans.empty() && x1==ans.back().second){

            if(pq.empty()){

                cout<<"No\n";

                return;

            }

            //如果相同的话选择不同的出现次数最多的

            auto [c2,x2]=*prev(pq.end());

            pq.erase({c2,x2});

            pq.emplace(c1,x1);

            c1=c2;

            x1=x2;

        }

        int u=x1,v=*g[x1].begin();

        ans.emplace_back(u,v);

        pq.erase({cnt[v],v});

        cnt[u]--;

        cnt[v]--;

        g[u].erase(g[u].find(v));

        g[v].erase(g[v].find(u));

        if(cnt[u]>0) pq.emplace(cnt[u],u);

        if(cnt[v]>0) pq.emplace(cnt[v],v);

    }

    cout<<"Yes\n";

    for(auto [x,y]:ans){

        cout<<x<<' '<<y<<'\n';

    }

}

 

cf2003C

(i,j)是好段当且仅当其中存在一个k,使得s[k]!=s[k+1]并且s[i]!=s[k]或s[i+1]!=s[k+1],求使得好段最多的方案数

s可以被划分成若干个相同字符组成的连续段(排序后)

(i,j)是一个好对子,当且仅当i,j所在的连续段不相邻

设最多出现次数的字符为x,第二多的为y,可以先在开头填x-y个出现最多的字符,之后再把所有字母按照出现次数从大到小排序,轮流填即可,这样能保证除了开头的连续段长度>=1,其他连续段长度都=1

(让相邻字符尽可能不相等,也可以是轮流插入26个字符)

void solve(){

    int n;

    cin>>n;

    string s;

    cin>>s;

    int cnt[26]{};

    for(char x:s){

        cnt[x-'a']++;

    }

    string ans;

    int ok=1;

    while(ok){

        ok=0;

        for(int i=0;i<26;i++){

            if(cnt[i]){

                ok=1;

                ans+=char(i+'a');

                cnt[i]--;

            }

        }

    }

    cout<<ans<<'\n';

}

 

Cf2048b

要求构造出一个排列,使得所有长度为k的子数组的最小值的和最小

并不是按照元素的大小顺序依次填入数组的开头和结尾

而是考虑使得每个小的元素贡献尽可能多的次数

也就是放置在每一段的末尾

void solve(){

    int n,k;

    cin>>n>>k;

    vector<int> a(n,-1);

    int cur=1;

    for(int i=k-1;i<n;i+=k){

        a[i]=cur++;

    }

    for(int i=0;i<n;i++){

        if(a[i]==-1){

            a[i]=cur++;

        }

    }

    for(int i=0;i<n;i++){

        cout<<a[i]<<" \n"[i==n-1];

    }

}

 

 

区间合并(T2963,T56)

搜寻记录一个数字再数组中出现的第一个位置和最后一个位置

unordered_map<int, pair<int, int>> ps;

        for (int i = 0; i < nums.size(); i++) {

            int x = nums[i];

            auto it = ps.find(x);

            if (it != ps.end()) {

                it->second.second = i; // 更新区间右端点

            } else {

                ps[x] = {i, i};

            }

        }

区间合并的核心:按区间的左端点排序,左端点相同再比较右端点,使用双指针,一个指向当前区间的左端点i,一个指向目前已经合并完的区间的右端点j,如果i<j,说明有重叠了,可以合并,此时更新j,然后继续移动i

优化:不需要判断i与j的关系

遍历原数组,将当前位置记为i,合并后区间的右端点max_r = max(max_r, r[nums[i]]);(r记录需要合并的区间的右端点),当一个区间合并完的标志就是max_r更新完之后,max_r==i,说明这就是一个合并完成后的区间,可以将最终的区间个数+1(或者当前左端点小于之前区间的max_r)

判断有几种分割方法可以遍历判断数组中的每条可分割线选还是不选,即2k

 

分组循环:适用于数组会被分割成若干组,且每一组的判断/处理逻辑是一样的(根据某些条件,数组可以被划分)

核心思想就是用两个循环,外层循环负责遍历组之前的准备工作(记录开始位置),和遍历组之后的统计工作(更新答案最大值),内层循环负责遍历组,找出这一组在哪结束(T2760,T2948,T2953,T2943)

例:
           vector<int> ans(n);

        for (int i = 0; i < n;) { //开始位置

            int st = i;

            for (i++; i < n && nums[ids[i]] - nums[ids[i - 1]] <= limit; i++);  //结束位置

//ids为以nuns中的大小排完序后的下标数组

            vector<int> subIds(ids.begin() + st, ids.begin() + i);  //利用迭代器进行赋值

            sort(subIds.begin(), subIds.end()); //以在原来数组中的下标大小进行排序

            for (int j = 0; j < subIds.size(); j++) {

                ans[subIds[j]] = nums[ids[st + j]]; //更改ans的值,在原数组的下标集合中依次修改

            }

        }

 

T2589

基本算法:
按照区间右端点从小到大排序

排序后,对于区间 tasks[i]来说,它右侧的任务区间要么和它没有交集,要么包含它的一部分后缀

遍历排序后的任务,先统计区间内的已运行的电脑运行时间点,如果个数小于 duration,则需要新增时间点。根据提示 2,尽量把新增的时间点安排在区间 [start,end]的后缀上,这样下一个区间就能统计到更多已运行的时间点

 

或者使用线段树

由于有区间更新操作,需要用 lazy 线段树

对于本题,在更新的时候需要优先递归右子树,从而保证是从右往左更新

 

由于每次都是从右到左新增时间点,如果把连续的时间点看成闭区间,那么从右到左新增时间点,会把若干右侧的区间合并成一个大区间,也就是从 end倒着开始,先合并右边,再合并左边,因此可以用栈来优化

栈中维护闭区间的左右端点,以及从栈底到栈顶的区间长度之和(类似前缀和)

由于一旦发现区间相交就立即合并,所以栈中保存的都是不相交的区间

合并前,先尝试在栈中二分查找包含左端点 start的区间。由于栈中还保存了区间长度之和,所以可以快速得到 [start,end]范围内的运行中的时间点个数

如果还需要新增时间点,那么就从右到左合并

ranges::sort(tasks, [](auto& a, auto& b) { return a[1] < b[1]; });

        // 栈中保存闭区间左右端点,栈底到栈顶的区间长度的和

        vector<array<int, 3>> st{{-2, -2, 0}}; // 哨兵,保证不和任何区间相交

        for (auto& t : tasks) {

            int start = t[0], end = t[1], d = t[2];

            auto [_, r, s] = *--ranges::lower_bound(st, start, {}, [](auto& x) { return x[0]; });

            d -= st.back()[2] - s; // 去掉运行中的时间点

            if (start <= r) { // start 在区间 st[i] 内

                d -= r - start + 1; // 去掉运行中的时间点

            }

            if (d <= 0) {

                continue;

            }

            while (end - st.back()[1] <= d) { // 剩余的 d 填充区间后缀

                auto [l, r, _] = st.back();

                st.pop_back();

                d += r - l + 1; // 合并区间

            }

            st.push_back({end - d + 1, end, st.back()[2] + d});

        }

        return st.back()[2];

 

线段树维护区间合并相关的信息

一种有些常见的合并是需要维护左子树的左端点,右端点、右子树的左端点、右端点

当左子树的右端点等于右子树的左端点时,需要额外进行一些信息的维护

 

25 杭电 春 7 1004

给定一个字符串和若干次查询,每次查询询问是否有连续的0/1串长度大于等于k,如果有,则将该段中的0/1进行翻转

每次都是区间查询和区间修改,因此想到使用线段树来维护

同时发现,只有在一段连续的首字符才会被修改,因此用线段树在首字符的位置维护这段的长度,然后用线段树二分的方法找到最早出现大于等于k的位置,注意每次翻转可能会出现前后拼接的情况

这题注意并不能维护0,1连续段最大值;开头字母,结尾字母,开头连续段长度,结尾连续段长度,这样虽然修改等操作是可行的

但是并不能使用于查找,因为对于单个节点来说,长度一定只会是1,而不是它作为左端点的连续段的长度,因此不能使用线段树二分来查找

注意对于我们所使用findFirst函数,例如查找区间最大值大于某个数的时候,最终一定能落实到一个叶子节点,而这个叶子节点的数值大于设定的那个数;在这里,虽然我们的目标也是落到叶子节点,但是我们设定的维护的节点的信息并不能用于判断,也就是如果查询的连续段的长度大于1那么我们的查询一定会返回-1,虽然实际这样的连续段是存在的

 

因此正解的思路是维护以该点为起点的连续段长度,并且因为有0,1两种情况,与其在同一个线段树中分别使用mx0和mx1,可以直接使用两个线段树分别维护相关信息

因为我们查询的时候对于同样满足条件的连续段我们总是选择左端点下标最小的那个,因此最开始可以逆序遍历求出每个分界点,以及这个分界点对应的长度,因为我们选择的时候总是选择这些分界点

http://acm.hdu.edu.cn/contest/view-code?cid=1156&rid=10050

struct Info {

    int v;

    Info (){

        v = 0;

    }

    Info (int x){

        v = x;

    }

};

 

Info operator+(const Info &a, const Info &b) {

    return Info(max(a.v, b.v));

}

 

void solve() {

    string s;

    cin >> s;

    int n = s.size();

    s = ' ' + s;

    vector<Info> tab[2] {vector<Info>(n + 1), vector<Info>(n + 1)};

    set<int> pos;  // 之所以维护pos是因为翻转后可能和前后区间合并

 

    int sgn = (s[n] == '1'), cnt = 0;

    for (int i = n; i >= 1; i--){

        cnt++;

        if (i > 0 && s[i] != s[i-1]){

            tab[sgn][i] = Info(cnt);

            pos.insert(i);

            sgn ^= 1;

            cnt = 0;

        }

    }

 

    SegmentTree<Info> seg[2] {tab[0], tab[1]};

    // 仍维护tab方便单点查询

    int q;

    cin >> q;

    while (q--) {

        int op, k;

        cin >> op >> k;

        int rv = op ^ 1;

        int ans = seg[op].findFirst(1, n + 1, [&](Info v) { return v.v >= k; });

        if (ans == -1) {

            cout << -1 << '\n';

            continue;

        }

        cout << ans  << '\n';

        int len = k;

        if (ans + k <= n) {  // ans + k 是连续段右端点

            if (tab[rv][ans + k].v) { // 有值,说明翻转后需要合并

                len += tab[rv][ans + k].v;

                seg[rv].modify(ans + k, Info(0));

                tab[rv][ans + k] = Info(0);

                pos.erase(ans + k);

            } else { // 否则翻转后会导致区间分裂

                pos.insert(ans + k);

                seg[op].modify(ans + k, tab[op][ans].v - k);

                tab[op][ans + k].v = tab[op][ans].v - k;

            }

        }

        if (ans > 1) { // 处理左侧区间

            int pre = *(prev(pos.find(ans)));

            len += tab[rv][pre].v;

            seg[op].modify(ans, 0);

            seg[rv].modify(pre, len);

            tab[op][ans] = Info(0);

            tab[rv][pre] = Info(len);  

            pos.erase(ans);

        } else {

            seg[rv].modify(ans, len);

            seg[op].modify(ans, 0);

            tab[op][ans] = 0;

            tab[rv][ans] = len;

        }

    }

}

因为这样修改后也变成了每次都是单点修改,因此只需要一般的线段树即可,不需要使用懒标记线段树

 

 

解决最大正方形面积(T2942):

考虑最大矩形面积,然后再考虑正方形的面积。

矩形面积是长和宽的乘积,长、宽可以分别求出来。

以宽为1的矩形网格为例:

如果不做任何移除,那么最长长度为 1;如果移除一条线,那么最长长度为2;如果移除两条编号相邻的线,那么最长长度为3;如果移除三条编号连续的线(例如 2,3,4),那么最长长度为4,依此类推。

所以把数组排序后,求出最长连续递增长度即可,这就可以用分组循环来解决(注意内循环中前提已经有i<n了,因此在退出循环后不需要再对末尾位置进行特判)

求出后,正方形的边长是长宽中的较小值,其平方即为正方形的面积。

 

要快速判断剩余字符的最小值,可以先统计s每个字符的出现次数,然后在遍历s的过程中更新cnt,这样cnt中第一个正数对应的字符就是剩余字符中最小的,或者也可以用后缀数组维护[i,s.size()]区间内的最小字母及其位置(T2434)

 

根据具体题目情况来减少一些状态量

牛客24寒假1J

一个操作之后,其中一个人必定在ai处,这个人的位置是确定的就不用记录了,因此只需要关心另一个人的位置,这样就可以少考虑一个状态

最大值的最小值显然是一个二分答案的问题,关键就在于如何写check函数,每次记录当前位置的可能情况太多,利用上面的结论,压缩状态,直接考虑另一个人可能的情况,两个人到底是谁去做这个任务本质上是等价的,并且每个位置都满足差<=mid是一个必要条件,因此check时可以针对每个位置间隔进行判断

#include <bits/stdc++.h>

 

using i64 = long long;

 

int main() {

    std::ios::sync_with_stdio(false);

    std::cin.tie(nullptr);

   

    int n, x, y;

    std::cin >> n >> x >> y;

   

    std::vector<int> a(n);

    for (int i = 0; i < n; i++) {

        std::cin >> a[i];

    }

   

    auto check = [&](int d) {

        int lst = y;

        std::set<int> S;

        if (std::abs(x - y) <= d) {

            S.insert(x);

        }

       

        for (auto x : a) {

            if (!S.empty() && std::abs(x - lst) <= d) {

                S.insert(lst);

            }

            //因为set是有序的,所以正的遍历一遍再倒着遍历一遍

            while (!S.empty() && *S.begin() < x - d) {

                S.erase(S.begin());

            }

            while (!S.empty() && *S.rbegin() > x + d) {

                S.erase(*S.rbegin());

            }

            //记录末尾位置

            lst = x;

        }

        return !S.empty();

    };

   

    int lo = 0, hi = 1E9;

    while (lo < hi) {

        int m = (lo + hi) / 2;

        if (check(m)) {

            hi = m;

        } else {

            lo = m + 1;

        }

    }

    std::cout << lo << "\n";

   

    return 0;

}

 

正难则反

cf2028 B

每次将当前最大的元素修改为mex,问经过几次操作后才能使得数组变成一个排列

因为数组元素的形式是一次函数b*i+c
因此对于小于c的元素必须保留

对于之后的元素可以考虑有多少个数原来就在,这些数是不需要通过操作得到的

当b等于0时,会出现数相同,此时需要特判

mex的特殊情况:0,0,0  0,0,1  0,0,2

 

数据量大的时候一般就考虑数学公式(T2849),对于矩形一类的,要尝试找一些比较特殊的情况(10月月赛)

与矩形中移动相关的,往往拆解为水平与竖直的移动,如果能斜移,移动步数就是水平移动距离与竖直移动距离的最大值,否则就是两者之和,因此计算需要移动的步数时,仅需要知道起点与终点,而有多个起点与终点求移动步数的最小值时,可以对其中例如起点进行全排列,然后 一一对应进行计算,维持其中的最小值(T2580)

 

硬编码(T12),对于数组的前一半和后一半有不同的操作时,可以在一次for循环中完成操作,下标分别为i和i+n/2(T1029)

 

正难则反(T713),对于双指针的题,容易的是数字不够的时候移动,最终能找到所有符合条件的子集,但是如果要找到所有符合大于k的子集,指针的移动就不好调整了,因此合适的是计算乘积小于等于k的子集数目,然后与总子集数相减

如果对于dfs问题,要同时维护是否进行操作比较困难,可以用相反的思路,假设全部都进行操作,再一次遍历,将操作撤销来找到符合题意的最大值,特别是题目的条件类似于只要有一个满足...的元素,这样通过撤销操作去确定这些数会变得更为简单(T2925)

从首和尾总共取走k个元素,使得剩下的元素和最大,可以转化成比较简单的连续问题,拿走的最多换言之就是剩下的最少,因此就是一个固定大小的滑动窗口,要让窗口中的元素和最小(T1423)

(P3197)考虑哪些情况不越狱

 

贡献法:单独考察每个元素在最后答案中的影响或总操作次数(T2425)(一个元素会被多次统计)

怎么样的情况可以被计算在答案中,分析对于答案的贡献

T828,例如要统计字符串中的唯一字符,就说明当一个字符仅在子字符串中出现一次时,它才会对这个字符串统计唯一字符作出贡献。因此只要对每个字符,计算有多少字符串仅包含该字符一次即可(相当于对于这个字符,对这些字符串的答案统计有贡献),对i处的字符,记上一次出现的位置为j,下一次出现的位置为k,那么这样的子字符串就有(i-j)*(k-i)种(类似于前后缀的想法,以这个字符向前推进有i-j种字符串,向后推进有k-i种,根据乘法原理,总共有这么多种可能),因此可以预处理s,将相同字符的下标放入数组中,方便计算。最后对所有字符进行这种计算即可

T907,子数组的元素最小值之和

贡献就是这个元素是那几个区间中的最小值,因此我们需要去找区间的左边界和右边界,即本质上是找这个元素的左侧和右侧第一个小于他的元素的位置,记为L,R,那么包含它的区间个数就是(i-L)(R-i),如果左侧没有比arr[i]小的元素,则L=-1,如果右侧没有比arr[i]小的元素,那么R=n

同时对于[1,2,5,2,3,1]这种数组,找元素2的区间时就要通过修改边界定义来避免重复统计子数组[2,5,2,3],即可以把右边界改为小于等于arr[i]的下标,同时可在遍历前,向arr末尾和栈顶分别加一个-1来简化代码逻辑,因为是一个递增栈,因此弹出的栈顶元素后的下一个栈顶就是弹出元素的左边界

class Solution {

    const int MOD = 1e9 + 7;

public:

    int sumSubarrayMins(vector<int> &arr) {

        long ans = 0L;

        arr.push_back(-1);

        stack<int> st;

        st.push(-1); // 哨兵

        for (int r = 0; r < arr.size(); ++r) {

            while (st.size() > 1 && arr[st.top()] >= arr[r]) {

                int i = st.top();

                st.pop();

                ans += (long) arr[i] * (i - st.top()) * (r - i); // 累加贡献

            }

            st.push(r);

        }

        return ans % MOD;

    }

};

 

由某些点到根节点的距离计算,可以转化成某条边有多少个点会经过,因为这样可以作为标记元素会更多,并且不需要记录子树的父节点(不然不清楚两条路是否有交集,就不好计算车能不能坐得下人),这样计算时也会更方便,相当于就转化成了计算每个节点作为根节点的树的节点数(利用dfs,因为可以在计算中直接得出,因此不需要另开数组了)(直接看总共有多少人回来,进而计算车辆),从自底向上转化为了自顶向下(T2477)(这个思路就是之前所说的正难则反,对于记录父节点较困难的操作尝试直接在父节点上进行操作,T2641)

对于路径问题很多都可以利用贡献法去解决,一个节点总共被经过了几次,或者说在多少条路径中被用到(T2646)

或者说,一个元素的加入会对我们的操作产生哪些影响(它的贡献值发生了什么变化)(T2968)

对于要求一个数组中所有长度为k的子数组的最小值时,也可以用贡献法来考虑(T2735)

记L[i]表示nums[i]左侧连续大于nums[i]的元素个数,R[i]表示nums[i]右侧连续大于nums[i]的元素个数。当操作次数为k时,最小成本实际上就是一个在数组 nums上长度为k+1的滑动窗口中的最小值。当这个滑动窗口恰好落在[i−L[i],i+R[i]] 的范围内,且包含下标i时,其中的最小值即为 nums[i],称这类滑动窗口是满足要求的滑动窗口

对于区间[i−L[i],i+R[i]] 以及长度为k+1的滑动窗口:

当k≤min{L[i],R[i]} 时,下标i可以作为滑动窗口中的任一位置,满足要求的窗口数量为k+1;

当min{L[i],R[i]}<k≤max{L[i],R[i]} 时,L[i]和R[i]中值更小的那一侧中的任一位置可以作为滑动窗口的起始位置,满足要求的窗口数量为min{L[i],R[i]}+1;

当max{L[i],R[i]}<k≤L[i]+R[i] 时,落在[i−L[i],i+R[i]] 的范围内任一滑动窗口都包含下标i,满足要求的窗口数量L[i]+R[i]−k+1;

当k>L[i]+R[i]时,滑动窗口比区间[i−L[i],i+R[i]] 更长,因此不存在满足要求的窗口

因此当操作次数为k时,我们先判断 nums[i]属于上述四种情况中的哪一种,得到满足要求的窗口数量,乘以nums[i]并累加入答案。当我们枚举了所有的nums[i]后,就可以得到在操作次数为k时的最小成本

 

找一个元素的前后缀中第一个小于它的元素,因为每个元素的大小各不相同,因此无法像进行预处理一样找出前后缀中的最大值,但可以借助相邻元素来达到一次遍历后完成统计,即转化成连续大于该元素的最远下标(不包括该下标)(T907)

for(int i=n-2,j=n-1;i>=0;j=i--){

            while(j<n&&arr[j]>arr[i])j=rightpos[j];

            rightpos[i]=j;

        }

for(int i=1,j=0;i<n;j=i++){

            while(j>=0&&arr[j]>=arr[i])j=leftpos[j];

            leftpos[i]=j;

      }

或者可以借助单调栈的想法,构造一个从栈顶到栈底递减的单调栈,有点类似于接雨水的思路

一般单调栈常用

 

cf 1976D

//转化成图形

/*

例如左括号,函数值+1;右括号,函数值-1

那么反转前后左右两个点的高度是一样的

另外的限制就是端点高度是x的情况下,这个区间范围内不能有点的高度超过2*x

*/

void solve(){

    string s;

    cin>>s;

    const int n=s.size();

    vector<int> pre(n+1);

    for(int i=0;i<n;i++){

        pre[i+1]=pre[i]+(s[i]=='('?1:-1);

    }

    set<int> high;

    vector<vector<int>> vec(n+1);//高度对应的元素的下标

    for(int i=0;i<=n;i++){

        vec[pre[i]].emplace_back(i);

    }

    i64 ans=0;

    for(int i=n,j=n;i>=0;i--){

        //这些high会将我们的区间分隔开

        while(j>2*i){

            for(auto x:vec[j]){

                high.insert(x);

            }

            j--;

        }

        int cnt=1;

        //这里类似于以前的贡献法,我们是在枚举右端点,cnt实际上是代表可供选择的左端点

        //或者说是子数组统计的方法

        for(int k=1;k<vec[i].size();k++){

            auto it=high.lower_bound(vec[i][k-1]);

            if(it!=high.end()&&*it<vec[i][k]){

                //存在high将我们区间隔开了,前面的左端点不能与该点匹配了

                cnt=0;

            }

            ans+=cnt;

            cnt++;

        }

    }

    cout<<ans<<'\n';

}

 

 

考虑用一个数据结构同时存储下标和一个对应的数据时,可以考虑用vector,或者是用unordered_map存储后,将数据迁移到vector中(见unordered_map中的示例),因为在对unordered_map排完序后并不能利用下标去遍历其中的数据,只能输出其中的最值(T1029)

 

分治的思想就是将一个问题拆分为多个子问题,最典型的就是将K个合并问题转化为两两合并

归并排序本质上也是一种分治的思想,将一个数组排序的问题划分为左右两部分,对左右两部分再进行分割排序,最后将左右两部分合起来(T2426,利用归并排序的特性)并且要记得递归的性质,在上面语句中还有递归调用的函数时,并不会进行下面的判断语句,进行到下面的语句时就说明其子序列都已经完成操作了

分治在数组中的思想体现就是不断对数组进行对半分,维护被分开的两个数组中各自的特征值

 

CCPC2024 深圳A

给定一个长度为n的序列b,要把一个全零序列变成b,允许两种操作:

把所有值为x的数+1;把第x个数+1

对于n<=1000,要在<=20000次操作数内完成

关键在于数变大后不能变小,所以主要的问题是1操作会让一些不希望变大的数变大

通过具体操作可以发现,先用1操作统一加,后用2操作部分进行统一,或者对最大的用2加再用1进行操作都不行

因此大致需要用2操作分裂出一个集合,用1操作将这个集合整体加

为了降低分裂出的集合大小,同时要保持加操作不会太多,想到分治(利用分治对两个操作进行平衡)

具体地,可以对值域进行分治,每次递归到一个值域区间[l,r],选取mid=(l+r)/2,将所有>=mid的数先用2操作加1,与其他数分开,然后将这些数用1操作一起+1直到均变成mid,然后递归操作[l,mid-1]和[mid,r]

当达到某一个值后,需要对大于该值的所有下标都加1,这一步可能会消费很多,因此比较合适的是相对较大的统一加,再处理较小值,也就是分组进行操作,但如果直接从大到小进行操作则缺少了很多不同值但可以一起加的过程,因此分治地对每个区间进行考虑

从数据的角度来看,20000操作是O(nlogn)级别,因此也可以联想到分治,且数据的范围限定了是0~n+1,因此也适合值域分治

void solve(){

    int n;

    cin>>n;

    vector<int> b(n);

    vector<pair<int,int>> ans;

    for(int i=0;i<n;i++){

        cin>>b[i];

    }

    auto work=[&](auto self,int l,int r)->void{

        if(r-l==1){

            return;

        }

        int m=(l+r)>>1;

        for(int i=0;i<n;i++){

            if(m<=b[i] && b[i]<r){

                ans.emplace_back(2,i+1);

            }

        }

        for(int i=l+1;i<m;i++){

            ans.emplace_back(1,i);

        }

        self(self,l,m);

        self(self,m,r);

    };

    work(work,0,n+1);

    cout<<ans.size()<<'\n';

    for(auto [x,y]:ans){

        cout<<x<<' '<<y<<'\n';

    }

}

 

根号分治:

 

CDQ分治:
CDQ分治是一种思想而不是具体的算法,大致分为三类:解决和点对有关的问题,一维动态规划的优化和转移,通过CDQ分治,将一些动态问题转化为静态问题

解决与点对有关的问题:

这类题目类似于给定一个长度为n 的序列,统计有一些特性的点对(i,j)的数量/找到一对点使得一些函数的值最大

流程一般为:找到这个序列的中点,将所有点对划分为3类:(i,j)都在mid左侧的,i在左侧j在右侧的

 

contains函数可以用来查找一个数据结构中(map,set等)一个key是否存在

 

assert的作用是先计算表达式expression,如果其值为假(即为0),那么它先向stderr打印一条出错信息,然后通过调用abort来终止程序运行

 

赋值语句是从右向左计算,因此 return a=b=c==d;的返回值是1

 

判断一个数组中一个字符的出现次数是否满足另一个数组时,一种方法是新设一个字典,判断两个字典是否相同,也可以采用统计的思想,当一个key对应的value变为0时,删除这个key,最后只要看这个字典是否为空即可(T30,官方题解)

 

子数组统计问题的技巧(T2348)子数组可用左端点和右端点来确定,可以枚举右端点,看以这个右端点结束的子数组有多少个(其实就类似于增量法)对于满足特定条件的数组(如全为0)右端点往右移一位时,增加的子数组个数就是数组长度

不仅如此,与子数组组合有关的问题也可以利用相似的思路去解决(T2952)(也可以看作是归纳法),例如前面能组合出一些和,那么再添加一个数字就是在前面能组合出的和上面再加上这个数(因为要让这个新来的数参与组合)

 

下标数组

当我们具体解题时需要借助下标来操作时,如在一个无序数组中要知道从小到大的数在数组中各自的下标为什么时,我们可以特别建立一个下标数组,并且通过对sort函数排序方式的定义,直接对下标数组进行排序,这样也方便我们进行后续的操作,不一定要开一个数组元素与下标对应的map

类似的交换问题,对于数组的连续段,满足交换条件的元素间连一条边,如果这个子数组相互之间能够连通,这个数组就可以进行任意排序(T2948),或者换言之,将nums带着下标进行排序,找到一个最长的连续段,相邻数字的差满足交换的条件,那么这一段对应的下标集合,这些位置之间是可以随意排序的

可用于离散化以及带下标排序

具体实现(T2659)

int n = nums.size(), id[n];

        iota(id, id + n, 0);

        sort(id, id + n, [&](int i, int j) {

            return nums[i] < nums[j];

        });

 

写循环时可以直接写while (true) 因为我们可以在循环内部中断循环,同时偷懒不在while后面的判断语句中写条件,同时只需要在循环结构体中达到边界条件时写一个break,或者如果循环是在函数中写一个return即可(例如T2642,T2507)

 

逆元

单位元:对于某种云端,如果对于任何发集合元素a,和元素e运算,得到的还是集合元素a本身,则称e为这个运算下的单位元,如加法运算中单位元是0,乘法运算中单位元是1,而模运算中单位元是1(mod n)

逆元:一个集合中,对于某种运算,如果任意两个元素的运算结果等于单位元,则称这两个元素互为逆元,加法运算中,元素a的逆元是-a,乘法运算中元素a的逆元是a-1,模运算的逆元是a-1(mod n)

 

子集枚举(T78)

一个数组的子集可以变为每个位置上的数选或不选,是否存在

利用二进制来表示一个数是否存在于自己中,这样要找所有的子集就变成了利用迭代法,从0到全集枚举,每次枚举时再从0到数组长度逐个判断该数是否存在

因为每次都是进行选与不选的操作,即对于每个位置都有相似的操作,因此可以用dfs来解决,注意在函数体中是如何去实现选与不选的,因为每个数这两个操作都要运行,因此并不需要在意上一个位置的数进行了什么操作,关键是在于如何有序地去完成这个操作,因为path在函数体中是唯一的,因此如果是先进行选了的操作,那么之后进行不选的操作时要现将path中的最后一个数删除,要理解dfs的含义,在进入dfs后,相当于将当前的path传入了,后续再对path进行修改不会对上面的dfs再产生影响,或者说,这个函数仍然是自上而下运行的,不过dfs中包含了相当多的步骤(递归),因此要注意dfs放置的位置,并且注意用return终止递归

对每个位置操作完(选或不选)之后,要对剩下的数组进行操作,并且操作的方式与原数组的操作完全相同,因此找到了一个子问题,这样就可以用动态规划来思考了,ans[i]视作以i为右端点的数组的所有子集,ans[i+1]=ans[i]中的每个元素(相当于不选nums[i+1])+ans[i]中的每个元素末尾加上nums[i+1],因为每次操作都只与上一个状态有关,因此可以只用一个数组ans来记录(这也有一种逐个加入元素的感觉)

与动态规划相似的就是bfs的解法,遍历每个节点(因为这里相当只有一层的数,等价于遍历每个元素),再进行操作

回溯算法,因为这题相当于是要对所有情况进行遍历,因此也可以考虑回溯算法,回溯的难点在于在emplace_back()和pop_back()之间往往只能进行一种操作,即经过某些处理后继续回溯的进程,可以在回溯之前直接添加(代表不选这个数在内的之后的数)

 

判断某一个字符类型的函数

isalpha()用来判断一个字符是否为字母

isalnum()用来判断一个字符是否为数字或者字母,也就是说判断一个字符是否属于a~ z||A~ Z||0~9。

isdigit() 用来检测一个字符是否是十进制数字0-9

islower()用来判断一个字符是否为小写字母,也就是是否属于a~z。

isupper()和islower相反,用来判断一个字符是否为大写字母。

以上如果满足相应条件则返回非零,否则返回零

(c >> 6 & 1) * 2 - 1

因为对于任意的大写/小写英文字母字符,其ASCII码的二进制都形如01xxxxxx;对于任意数字字符,其ASCII码的二进制都形如0011xxxx,因此可以根据二进制从低到高是0还是1来判断(不过也只限于只有数字和字母的情况下),然后通过*2-1就可以让字母的值变为1,数字的值变为-1

is_sorted(),判断一个序列是否有序,传入迭代器起点,终点(,以及比较函数,默认是小于)

例is_sorted(b.rbegin(), b.rend())

 

点分治:
点分治适合处理大规模的树上路径信息问题

点分治是一种针对可带权树上简单路径统计问题的算法,对于树上路径,不要求这棵树有根,只需要对无根树进行统计

核心思想在于依据重心划分子连通块,其良好的性质保证了最多只会分治logn层

 

模板P3806

给定一棵有n个点的树,询问树上距离为k的点对是否存在

先随意指定一个根,将这棵树转化为有根树

树上的路径可以分为两类,经过根节点的路径以及不经过根节点的路径(只存在于某个子树中)

对于前者,定义dis[u]为u到根节点的路径,那么u到v的路径长即为dis[u]+dis[v]

对于后者,因为u到v的路径包含在root的某个子树中,因此可以找到该子树的根,然后就可以转化为求第一种路径

因此分治的操作就是:
将原来的树分成很多小的子树,并对每个子树分别求解第一类路径

但在分治操作的递归过程中,如果树退化成一条连,那么递归层数是N,同时每一层的所有递归过程合计对每个点处理一次,因此总时间复杂度是O(N^2)

因此我们要让树的层数尽可能少,因此要找树的重心

用maxp[u]表示删除u后产生的子树中,最大子树的大小

则树的重心就是maxp[]值最小的那个节点

void getrt(int u,int fa){

    siz[u]=1;

    maxp[u]=0;

    for(int i=head[u];i;i=e[i].nxt){

        int v=e[i].to;

        if(v==fa[u] || vis[v]) continue;

        getrt(v,u);

        size[u]+=size[v];

        //因为本身是无根数,将u看作是当前子树的的根节点

        //删除u为根节点的子树相当于只保留子树v

        maxp[u]=max(maxp[u],size[v]);

    }

    //sum是总节点数

    maxp[u]=max(maxp[u],sum-size[u]);

    //之前考虑的都是u的子节点

    //这里是计算删除u后,父节点一侧的子树大小

    if(maxp[u]<maxp[rt]) rt=u;//更新重心

}

在点分治过程中每次选取子树的重心为子树的树根进行处理,这样总的递归深度不会超过logN层

整个的时间复杂度是O(NlogN)

对于不同的问题设计不同的calc函数

在此题中,询问可以离线记录并且直接在分治过程中处理

对于一棵树的根root,记录其子树到root的距离dis到rem数组中,并且令Judge[dis]用于表示在子树中是否存在某个节点到root的距离为dis

遍历每个离线记录的询问,对每个询问遍历一次当前子树的rem,若当前询问距离为query[k],那么如果judge[query[k]-rem[j]]==1,那么就说明询问的路径存在

这样处理完成后将这棵子树的rem保存进judge数组,继续下一个子树的处理

当root为根的查询完成后清空judge数组,然后对其他子树进行分治

const int maxn=1e5+10;

int n,m;

struct node{

    int to,nxt,dis;

}e[maxn<<1];

int tot,head[maxn];

int maxp[maxn],siz[maxn],dis[maxn],rem[maxn];

int vis[maxn],ans0[10000005],judge[10000005],q[maxn];

int query[1010];

int sum,rt;

int ans;

 

void add(int u,int v,int dis){

    e[++tot].to=v;

    e[tot].dis=dis;

    e[tot].nxt=head[u];

    head[u]=tot;

}

void getrt(int u,int fa){

    siz[u]=1;

    maxp[u]=0;

    for(int i=head[u];i;i=e[i].nxt){

        int v=e[i].to;

        if(v==fa || vis[v]) continue;

        getrt(v,u);

        siz[u]+=siz[v];

        //因为本身是无根树,将u看作是当前子树的的根节点

        //删除u为根节点的子树相当于保留了子节点对应的树v

        maxp[u]=max(maxp[u],siz[v]);

    }

    //sum是总节点数

    maxp[u]=max(maxp[u],sum-siz[u]);

    //之前考虑的都是u的子节点

    //这里是计算删除u后,父节点一侧的子树大小

    if(maxp[u]<maxp[rt]) rt=u;//更新重心

}

void getdis(int u,int fa){

    rem[++rem[0]]=dis[u];

    for(int i=head[u];i;i=e[i].nxt){

        int v=e[i].to;

        if(v==fa || vis[v]) continue;

        dis[v]=dis[u]+e[i].dis;

        getdis(v,u);

    }

}

void calc(int u){

    int p=0;

    for(int i=head[u];i;i=e[i].nxt){

        int v=e[i].to;

        if(vis[v]) continue;

        rem[0]=0;//rem[0]用于记录有多少个dis,其余rem中的元素是表示dis

        dis[v]=e[i].dis;

        getdis(v,u);

        //处理u子树的dis

        for(int j=rem[0];j;--j){

            //遍历子树的dis

            for(int k=1;k<=m;k++){

                //遍历每个询问

                if(query[k]>=rem[j]){

                    ans0[k]|=judge[query[k]-rem[j]];

                    //子树中存在对应路径,标记该询问

                }

            }

        }

        for(int j=rem[0];j;j--){

            //保存出现过的dis到judge

            q[++p]=rem[j];

            judge[rem[j]]=1;

        }

    }

    for(int i=1;i<=p;i++){//处理完这个子树清空judge

        judge[q[i]]=0;

        //memset会T

    }

}

void dfs(int u){

    vis[u]=judge[0]=1;//judge表示子树到根距离为i的路径是否存在

    calc(u);//处理以u为根的子树

    for(int i=head[u];i;i=e[i].nxt){//对每个子树进行分治

        int v=e[i].to;

        if(vis[v]) continue;

        sum=siz[v];

        maxp[rt=0]=inf;

        getrt(v,0);//sum是以v为根的子树大小

        dfs(rt);//在子树中找重心并递归处理

    }

}

void solve(){

    cin>>n>>m;

    for(int i=1;i<n;i++){

        int u,v,dis;

        cin>>u>>v>>dis;

        add(u,v,dis);

        add(v,u,dis);

    }

    for(int i=1;i<=m;i++){

        cin>>query[i];

    }

    maxp[rt]=sum=n;//第一次先找整棵树的重心

    getrt(1,0);

    //对树进行点分治

    dfs(rt);//以重心为根,处理树,再找到各个子树的重心,递归处理各子树

    for(int i=1;i<=m;i++){

        if(ans0[i]) cout<<"AYE\n";

        else cout<<"NAY\n";

    }

}

 

点分树

P6329

 

牛客24多校10 J

给定无根树X,有根树Y(但根未知)

判断Y是否可能是X的点分树

如果是给定点判断是否是点分树,只需要按照点分树的构造法判断即可

在T2上dfs,回溯时检查x在T1上的所有已经被dfs到的邻居是否可以通过T2已经回溯过的部分与x连通,使用并查集维护即可

于是目标转化为寻找根

 

模拟:
cf1972c  如何计算从最小值开始逐渐向上补齐(使数组的最小值尽可能小/使数组元素尽可能均匀)

并且本题最后的答案能根据最小值直接推得:nw-(cnt-1)

cnt是最小值的个数,因为按照循环节排列得到的每一个长度为n的子数组都是满足要求的,在最优情况下,nw子数组的每一个元素都可以当作目标子数组的开头,因为如果最小值的元素不知末尾元素,那么倒推,即以末尾元素开头的长度为n无法满足要求,因此要删除,其余同理,因此总共要减去cnt-1个

void solve(){

    int n;

    ll k;

    cin>>n>>k;

    vector<ll>a(n);

    for(int x=0;x<n;x++) cin>>a[x];

    sort(a.begin(),a.end());

    reverse(a.begin(),a.end());

    long long lst=a.back(),cnt=1;

    a.pop_back();

    while(!a.empty()&&lst==a.back()) a.pop_back(),cnt++;//记录最小值元素的个数

    while(!a.empty()){

        ll delta=a.back()-lst; //最小值与次小值之间的差值

        if(k<delta*cnt) break; //能否补齐

        k-=delta*cnt;

        lst=a.back();

        while(!a.empty()&&lst==a.back()) a.pop_back(),cnt++;//补齐后就相等了,因此在之前的cnt上继续增加,相当于之后的连续段在不断增长

    }

    lst+=k/cnt; //最小值还能统一增加多少

    k%=cnt;

    cnt-=k;  //剔除那些被额外增加的元素后最小值的个数

    cout<<lst*n-cnt+1<<endl;

}

 

Cf 1092D2

有一个残缺的墙,有n个位置,问是否可以把它堆叠至平整,每次操作可以横着放着长为2宽为1的块,也就是如果有两个位置相邻且高度相等,那么可以让它们的高度增加1

按照题意,相当于问所有数能否做到任意提升,而且因为我们是两个一起增加的,因此能任意增加的段一定是偶数长度

因为偶数的高度是可以任意变化的,因此相当于直接丢掉,于是我们只需要保留奇数的情况,也就是奇数段拆分成一个点和一个偶数段

而奇数点的情况下,如果这个现在新加的点比那个点大,那么一定无法增加到对应的高度

如果最后还剩一个元素,那么说明左侧都是可以任意增加高度的偶数串,那么只要剩下的元素是最高的点高度一定是达到的

否则如果它比较低又没有可以与其一起增大的元素那么就不可行

void solve() {  

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> st;

    for(int i=0;i<n;i++){

        if(!st.empty() && a[i] == st.back()){

            st.pop_back();  // env

        }else if(!st.empty() && a[i]>st.back()){

            cout<<"NO\n";

            return;

        }else{

            st.push_back(a[i]);

        }

    }

    if(st.empty() || (st.size() == 1 && st[0] >= *max_element(a.begin(),a.end()))){

        cout<<"YES\n";

    }else{

        cout<<"NO\n";

    }

}

或者考虑我们进行操作的过程中一定是从低处往高处填

于是从低到高枚举当前高度h,不难发现有解的充要条件是对于每个h,<=h的a[i]构成的连通块的长度都是偶数

因此可以从小到大依次加点,然后并查集维护每个连通块和连通块的大小

 

Cf 2071B

调整法

找不到什么特定的规律去构造一个方案,可以按照逐个遍历判断当前情况是否符合情况来进行修正

首先,因为最后一位时,一定恰好是所有数字的和,因此如果这个数是完全平方,那么一定是不可能有合法序列的

对于其他情况,从同一排列p[i]=i,遍历从1到n的缩影,如果当前为止的前缀和不是完全平方,那么就继续迭代,直到某个前缀和变成了完成平方,那么我们交换当前的k和k+1,显然这时候的前缀和将会是原前缀和+1,也就是一个完全平方数+1,而这个数一定不是完全平方,这点是显然的

同时因为此时交换了k,那么下一位的总和是(k+1)*(k+2)/2,我们可以证明如果k*(k+1)/2是完全平方数,那么(k+1)*(k+2)/2一定不是完全平方,因此这一步可以重复进行,直到所有索引都处理完毕

bool isSuqre(i64 n){

    int x=sqrt(n);

    return 1ll*x*x==n;

}

 

void solve() {

    int n;

    cin>>n;

    i64 sum=1ll*(n)*(n+1)/2;

    if(isSuqre(sum)){

        cout<<-1<<'\n';

        return;

    }

    vector<int> p(n);

    iota(p.begin(),p.end(),1);

    for(int i=1;i<n;i++){

        if(isSuqre(1ll*i*(i+1)/2)){

            swap(p[i],p[i-1]);

        }

    }

    for(auto x:p){

        cout<<x<<" \n"[x==p.back()];

    }

}

 

 

 

增量法

Cf 2061 F1

给定两个长度相等的序列s和t,每次操作可以将s中相邻的两个连续段交换位置,问最少通过多少次操作可以将s和t变得相同

核心性质是连续段的长度只会不断变长,同时方案本质上是唯一的,也就是将s的0或1通过若干操作合并成一段0和一段1

因此直接模拟即可

进行类似于增量法的过程,将s[i-1]及之前的部分操作到和t相同时,不妨令此时的i位置,s为0,t为1,那么若i>1,且s[i-1]和t[i-1]都是0,那么这种情况时是无解的,因为连续段只会变长,因此若保证s[i-1]仍然是0,那么s[i]也一定是0

否则,就找到对于t来说这一段需要多少个1,并在s的[I,n]中找到符合情况的k使得[I,k]中有足够数量的1,若k<n且s[k+1]也无解,因为同样,s[k]和s[k+1]无法分开,即无法找到符合数量的段

在排除这些不可能情况后,若s的[I,k]中有cnt个1的连续段,则需要cnt次操作才能将1全部搬到前面,则ans+=cnt

增量法体现在连续段的长度会不断增长,同时前面操作好的部分不会再进行改变

void solve(){

    string s,t;

    cin>>s>>t;

    int n=s.size();

    vector<int> f(n+1);

    for(int i=0;i<n;i++){

        f[i+1]=f[i]+s[i]-t[i];

    }

    if(f[n]!=0){

        cout<<-1<<'\n';

        return;

    }

    int lst=0;

    for(int i=1;i<=n;i++){

        if(f[i]==0){

            if(i-lst>1){

                int cnt=0;

                for(int j=lst+1;j<i;j++){

                    if(t[j]!=t[j-1]){

                        cnt++;

                    }

                }

                if(cnt!=1){

                    cout<<-1<<'\n';

                    return;

                }

                if(lst>0 && (t[lst]!=t[lst-1] || s[lst]==s[lst-1])){

                    cout<<-1<<'\n';

                    return;

                }

                if(i<n && (t[i]!=t[i-1] || s[i]==s[i-1])){

                    cout<<-1<<'\n';

                    return;

                }

            }

            lst=i;

        }

    }

    int ans=0;

    if(s[0]!=t[0]){

        ans++;

    }

    if(s[n-1]!=t[n-1]){

        ans++;

    }

    for(int i=1;i<n;i++){

        if(s[i]!=s[i-1]){

            ans++;

        }

        if(t[i]!=t[i-1]){

            ans--;

        }

    }

    ans/=2;

    cout<<ans<<'\n';

}

 

Cf 2065G

给定一个长度为n的数组,定义半素数为一个能被拆分为两个素数乘积的数,问该数组有多少对下标I,j,使得lcm(a[i],a[j])是一个半素数,I,j可以相等

显然,能满足条件的元素情况是有限的:两个素数,两个相同的半素数,一个半素数和作为它因子的素数

因此我们要考虑进行素因数分解

相较于对于每个元素进行Pollard-Rho算法来素因数分解,注意到每个元素的范围是1到n,因此不妨直接初始化,利用sieve,预处理出该范围内所有元素素因数分解个数和其中的最小素因子

因为我们关注的元素最多只有两个素因子,也就是另一个素因子可以通过该元素除minp得到,因此sieve是恰好的

然后就是计算答案的过程了,自然可以通过多次遍历得到答案

void solve() {

    int n;

    cin>>n;

    sieve(n);

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> cnt1(n+1),cnt2(n+1);

    i64 cnt0=0ll;

    i64 ans=0ll;

    vector<array<int,2>> tmp;

    for(int i=0;i<n;i++){

        if(ps[a[i]]==1){

            cnt0++;

            cnt1[a[i]]++;  

        }else if(ps[a[i]]==2){

            cnt2[a[i]]++;

            tmp.push_back({minp[a[i]],a[i]/minp[a[i]]});

        }

    }

    for(int i=0;i<=n;i++){

        cnt0-=cnt1[i];

        ans+=cnt0*cnt1[i];

    }

    for(int i=0;i<=n;i++){

        if(cnt2[i]>1) ans+=1ll*(cnt2[i])*(cnt2[i]-1)/2;

        ans+=cnt2[i];

    }

    for(auto [x,y]:tmp){

        if(x==y){

            ans+=cnt1[x];

        }else{

            ans+=cnt1[x]+cnt1[y];

        }

    }

    cout<<ans<<'\n';

}

但是注意到与一个数相关的可能计入答案的cnt只可能比它小,因此不妨先进行遍历,然后就能在一次遍历中解决了

void solve() {

    int n;

    cin>>n;

    sieve(n);

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> cnt(n=1);

    sort(a.begin(),a.end());

    int cntp=0;

    sieve(n);

    i64 ans=0ll;

    for(auto x:a){

        int p=minp[x];

        if(x==p){

            ans+=cntp-cnt[p];

            cntp++;

            cnt[p]++;

        }else{

            int q=minp[x/p];

            if(x!=p*q){

                continue;

            }

            if(p==q){

                ans+=cnt[x];

                ans+=cnt[p];

            }else{

                ans+=cnt[p];

                ans+=cnt[q];

                ans+=cnt[x];

            }

            cnt[x]++;

        }

    }

    cout<<ans<<'\n';

}

每次遍历当前元素,如果是素数,那么就可以与所有前面已经记录的素数构成合法的下标对,因此答案加上素数计数,然后对相应计数加1,半素数的情况类似,因为小于它的素数一定都被处理完毕,因此直接加上因数的个数以及与其相等的元素的个数即可

 

Cf 2069C

给定一个数组,数组元素仅由1,2,3组成,定义一个序列是美丽的,当且仅当对于除了第一个元素外的所有元素的左侧都存在一个元素严格小于它,对于除了最后一个元素,所有元素的右侧都存在一个元素严格大于它,问对于给定的数组,它有多少个美丽的子序列

容易得到一个结论就是一个美丽的子序列一定是第一个元素是1,最后一个元素是3,然后中间有若干个2

一种比较暴力的想法是枚举所有可能的1和3,并计算中间2的个数,这可以通过前缀和数组实现,同时在知道了中间2的个数后,那么对应的方案数就是C(n,1)+C(n,2)+…+C(n,n)=2^n-1

但这样最劣复杂度是n^2,会TLE

只要考虑1和3的组合就不可避免的涉及到这个复杂度,且并没有什么方式可以进行优化

因此考虑其他算法,例如考虑贡献法

注意到,虽然中间的2是影响子序列方案数的关键,但如果考虑2的方案数是不现实的,因为需要考虑左侧和右侧是否有1和3,以及是否有其他的2,一方面,这些内容无法再一次遍历中完成,另一方面,不容易想到方案数的表示方法

想到一般对数组等类型的处理方式,例如增量法,一般都是考虑遍历到的元素,也就是作为子序列的最后一个元素,或者以2考虑的贡献法不可取,那么就考虑1或3对答案的贡献

想到对于一个3,相匹配的方案数前面的部分可以是1 + 若干个2,每遍历到一个1,那么那个方案只能从头开始,于是方案数加1,而遍历到一个2,所有前面的方案都可以在末尾增加一个2,也就是方案数翻倍(将1和2合并起来考虑,而不是继续着眼于以当前3结尾,可能的2的个数,这样以因为2的数量会被1分割,因此本质上依然是在枚举1和3)

但对于一个合法的方案,前面不能只有1,因此额外维护1的个数(也就是只有1的方案数)

void solve() {

    int n;

    cin>>n;

    i64 ans=0;

    i64 p=0;

    i64 q=0;

    for(int i=0;i<n;i++){

        int x;

        cin>>x;

        if(x==1){

            p++;

            q++;

        }else if(x==2){

            p*=2;

            p%=mod;

        }else{

            ans+=p-q;

            ans%=mod;

        }

        // cout<<"i:"<<i<<" p:"<<p<<" q:"<<q<<" ans:"<<ans<<'\n';

    }

    cout<<(ans+mod)%mod<<'\n';

}

 

 

Cf2040 B

容易看出具体应该是log2级别的数据

每次操作最多可以将当前1的个数增加到2*(cur+1),但反向操作计算就比较麻烦,因此不如直接正向模拟

void solve(){

    int n;

    cin>>n;

    int ans=1;

    int cur=1;

    while(cur<n){

        ans++;

        cur=(cur+1)<<1;

    }

    cout<<ans<<'\n';

}

 

 

cf1976c
对于这种处理有分段性的可以先模拟一遍找出分段的点:分段后的数字减少就是单纯的减去哪个数字,分段前的数字减少再单独判断

从整体来看,分段前少了一个元素就相当于整体多出了一个位置,因此加入这个位置之后再重新模拟一遍,然后再对少那个元素进行单独处理

void solve(){

    int n,m;

    cin>>n>>m;

    const int N=n+m+1;

    vector<int> a(N),b(N);

    for(int i=0;i<N;i++){

        cin>>a[i];

    }

    for(int i=0;i<N;i++){

        cin>>b[i];

    }

    int t=0;

    int x=0,y=0;

    i64 sum=0;

    while(x<n && y<m){//模拟一遍找到分界点

        if(a[t]>b[t]){

            sum+=a[t];

            x++;

        }else{

            sum+=b[t];

            y++;

        }

        t++;

    }

    vector<i64> ans(N);

    i64 res=sum;

    for(int i=t;i<N;i++){

        res+=(x==n)?b[i]:a[i];

    }

    for(int i=t;i<N;i++){//分段处理

        ans[i]=res-(x==n?b[i]:a[i]);

    }

    for(int i=0;i<t;i++){//对于不受限制的元素就是直接删原来被采用的数值

        if((x==n) == (a[i]<b[i])){//利用==对于两种情况进行了分类

            ans[i]=res-max(a[i],b[i]);

        }

    }

    //删去一个元素之后就多了一个位置,再继续模拟一次

    int ot=t;

    int ox=x;

    if(x==n){

        x--;

    }else{

        y--;

    }

    while(x<n && y<m){

        if(a[t]>b[t]){

            sum+=a[t];

            x++;

        }else{

            sum+=b[t];

            y++;

        }

        t++;

    }

    res=sum;

    for(int i=t;i<N;i++){

        res+=(x==n)?b[i]:a[i];

    }

    for(int i=0;i<ot;i++){

        if((ox==n) == (a[i]>b[i])){//是原来被限制的元素,现在可以删了

            ans[i]=res-max(a[i],b[i]);

        }

    }

    for(int i=0;i<N;i++){

        cout<<ans[i]<<' ';

    }

    cout<<'\n';

}

 

cf2019c

给定一个数组a,a[i]代表数字i的卡片有多少张,将所有卡片进行分组,每组内的卡片数字不能相同,最多可以购买k张卡片,问一组内最多的卡片数量是多少

首先显然是想到列出卡牌数量进行判断,于是难点在于判断方式的考虑

首先显然将原数组进行排序,判断一组中的数量是否可行,一个必要条件是卡片的数量一定要大于等于数量最多的卡片乘一组内的卡片数,同时因为是所有卡片都要被分到某一组中,因此要让总的卡片数是i的倍数

能证明,满足这两个条件后,卡片一定能够按照能按照一组张数i进行分组的(因为之后的数字一定是小于最大值的,因此一定有超过i-1的数的种类,于是可以这样配凑出)

因此只需要判断这样的卡片数量能否达到

因为这样的判断是O(1)的,因此可以直接O(n)枚举所有可能的答案进行判断

void solve(){

    i64 n,k;

    cin>>n>>k;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    sort(a.begin(),a.end());

    i64 sum=accumulate(a.begin(),a.end(),0LL);

    i64 mx=a[n-1];

    for(int i=n;i>=1;i--){

        i64 need=mx*i;

        need=max(need,sum+(i-sum%i)%i);

        if(need-sum<=k){

            cout<<i<<'\n';

            return;

        }

    }

}

类似的结论使用:

cf2022B

同样是每次选择的时候只能用不同种类的,因此是取a中的最大值和数组除以上限的较大者

void solve(){

    int n,x;

    cin>>n>>x;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    sort(a.begin(),a.end(),greater());

    i64 sum=accumulate(a.begin(),a.end(),0ll);

    i64 ans=max<i64>(a[0],(sum+x-1)/x);

    cout<<ans<<'\n';

}

 

cf1981c
对于这种一个元素的存在对于周围元素有限制的问题,其中一种方式就是去模拟,最后判断整个数组,或者这样模拟过后的子数组是否出现了矛盾的情况

不一定要通过图论等方式解决,需要注意的是就是模拟时的处理方案显然应该是要保证能让数组尽可能地满足条件,这样才能通过矛盾得到这个数组显然是不合法的结论

void solve() {

    //数组元素的限制,对于i和i+1,要么a[i]=a[i+1]/2,要么a[i+1]=a[i]/2

    //假设可以是0,就是每次把大的那个数除以2

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    if(count(a.begin(),a.end(),-1)==n){//全是-1,自行分配,1,2串即可满足

        for(int i=0;i<n;i++){

            a[i]=i%2+1;

        }

    }else{

        for(int i=0,j=-1;i<=n;i++){

            if(i==n || a[i]!=-1){//就是对于周围的数产生限制了,模拟赋值

                if(j==-1){//之前没有被限制过,从大到小开始赋值

                    for(int k=i-1;k>=0;k--){//实际上不能是0,因此如果前一个数是1,那么这个数就只能是2

                        a[k]=a[k+1]==1?2:a[k+1]/2;

                    }

                }else if(i==n){//边界元素的特殊处理,因此在循环时i的范围开到正常下标的下一个,表示要对例如n-1处的元素进行处理

                    for(int k=j+1;k<n;k++){//如果j=n-1,那么实际上是不会进行操作的

                        a[k]=a[k-1]==1?2:a[k-1]/2;

                    }

                }else{

                    int l=j,r=i;

                    while(l+1<r){

                        //因为要把控整个数组最后时满足要求的,因此对于区间中的赋值,要让最后在数组中间的元素相互尽可能接近才有可能满足要求

                        //于是对当前边界较大的元素进行处理

                        if(a[l]>a[r]){

                            a[l+1]=a[l]==1?2:a[l]/2;

                            l++;

                        }else{

                            a[r-1]=a[r]==1?2:a[r]/2;

                            r--;

                        }

                    }

                    //退出循环后,l和r是相邻的两个元素,判断这样赋值后是否满足条件

                    if(a[l]!=a[r]/2 && a[r]!=a[l]/2){

                        cout<<-1<<'\n';

                        return;

                    }

                }

                j=i;//记录上一次有限制的位置

            }

        }

    }

    for(int i=0;i<n;i++){

        cout<<a[i]<<' ';

    }

    cout<<'\n';

}

另一种思考方式是分段处理

考虑提取所有值不是−1 的位置,表示为 c1,c2,…,ck。带有−1=的 [1,c1−1]和 [ck+1,n] 段很容易处理,只需重复乘除 2 即可。不难看出, [c1+1,c2−1],[c2+1,c3−1],…,[ck−1+1,ck−1] 段的构造是相互独立的。因此,我们现在只需解决 a′1≠−1、 a′n≠−1 和 a′2=a′3=⋯=a′n−1=−1的问题

显然,如果确定了 ai ,那么 ai+1 只能是 ai/2 、 2ai或 2ai+1中的一个。

我们注意到, ai→ai+1 的转换实质上是沿着一棵完整的二叉树(节点分别为1,2,3,4,5,6,7...)中的一条边移动。因此,问题简化为在一棵完整的二叉树中找到一条起点为 a1′ 、终点为 an′ 、经过 n 个节点的路径

首先,考虑找出完整二叉树中从 a1′ 到 an′ 的最短路径(可通过计算 a1′ 和 an′ 的 LCA 找出;最短路径为 a1′→LCA(a1′,an′)→an′。设这条最短路径的节点数为 l 。只有当且仅当 l>n 或 l和 n的奇偶性不同时才无解(因为二叉树的性质最短路的长度是l,那么其余达到这个点的路径长度一定是l+2k)。否则,我们首先将最短路径中的节点填入 a1′,a2′,…,al′中,然后将 al′ 和 2al′交替填入其余位置

 

cf1979D

利用类似于分块的思想

void solve() {

    int n,k;

    cin>>n>>k;

    string s;

    cin>>s;

    //实际上可以看作是将后缀翻转,因为这种情况逆序遍历的结果和题目中操作后正序遍历的结果是一样的

    vector<int> pre(n+1);

    for(int i=0;i<n;i++){

        pre[i+1]=pre[i]+s[i]-'0';

    }

    vector<int> f(n+1);

    f[n]=1;

    for(int i=n-1;i>=0;i--){

        int j=min(n,i+k);

        f[i]=f[j] && (pre[j]-pre[i] == j-i || pre[j]-pre[i] == 0) && (j==n || s[i]!=s[j]);

        //以i为起点的翻转后的字符串能否满足要求

        //因为对于任意位置元素的判定都是相同的,因此可以从j转移到i

        //如果f[j]成立,说明s[j]...s[j+k]都是相同的,也只有在这种情况下,当s[i]!=s[j]时,能保证s[i]!=s[i+k]恒成立

        //否则,若f[j]=0,说明要么从j开始,就不能一直满足f[i]!=f[i+k],要么说明在s[i]~s[i+k-1]中,必有元素使得s[i]==s[i+k]

    }

    for(int p=1;p<=n;p++){

        //0的ascii码为48,1的ascii码为49,因此'0'^1恰好是'1',而%2就是实际是否进行了异或,两次异或1等于没有进行操作

        //根据上面的判断也可以知道,题目中要能够满足条件,从翻转后的第一个字符开始,就一定是连续的0,连续的1交替出现,并且每个连续段的长度都是k

        //因此这里根据s[p-1]和s[0]进行判断有点类似于找出了分块中的代表元素

        //因为无论s[p-1]属于哪一个分块,在一直向后和k个进行比较的时候s[0]一定会和它所在区块的其中一个元素进行比较

        if (s[p-1]!=(((p-1)/k%2)^s[0])){//s[p-1]是翻转范围中的最后一个元素

            break;

        }

        if (f[p]&&(p==n||s[p]==(((n-1)/k%2)^s[0]))){//相当于我们进行后缀的翻转,那么s[p]就会到n-1的位置,然后就是和上面类似的从后向前进行判断

            cout<<p<<"\n";

            return;

        }

    }

    cout<<-1<<'\n';

}

 

 

最长公共前缀(LCP)(T14T2937

最朴素的方法就是写一个while循环来逐个判断第几个位置的时候他们的前缀不相等了,易想且有用

或者

strs的类型:vector<string>

const auto [str0, str1] = minmax_element(strs.begin(), strs.end());  

//string的比较操作是基于字典序的排序 不是单纯的谁长谁短

for(int i = 0; i < str0->size(); ++i)

if(str0->at(i) != str1->at(i)) return str0->substr(0, i);

return *str0;

 

cf1701E

求由一个字符串中获取另一个字符串的最小操作次数

因为我们的操作中没有添加字符的操作,因此本质上就是删除一些字符使得两字符串相同

因此将t看作是模式串,用s去匹配

如果从来不会到开头删除字符,那么操作次数就是n-len,len表示s和t的lcp,因为对于每一个字符要么删除,要么通过光标移动跳过这个字符

如果要回到开头删除字符,考虑怎么样的操作是最优的,一定是在最后删掉一些再到开头删掉一些,若有不同的字符则需要先向右移动一个位置再删除,这样中间有一段是和t匹配的,因此不需要删除

同时这样的操作只能进行一次,因为再次回到字符串开头或者结尾一定是要多走一段

因为删除的操作次数是确定的,因此可以先只计算移动的次数

因此就移动情况来说,其中一种情况就是只从结尾删除,那么就是从结尾开始匹配,符合我们对suf数组的操作,这样最后匹配到的字符位置就是suf[0](这种情况实际上被包含在下面的计算中了,因此可以直接将ans初始化为n)

除此之外我们相当于枚举哪一段作为我们保留的中缀

枚举匹配的开始位置i,并枚举实际中缀的开始位置pre[i],通过预处理的lcp得到中缀的结束位置,同时通过判断是否suf[i+len]>=j+len来确定是合法的中缀匹配

进而计算这种情况的移动次数,因为从末尾删除的部分不用考虑,因此末尾的所需的移动次数是那些中缀之后的字符个数,也就是m-i-len,而回到开头之后的部分无论需不需要删除,都要进行移动,因此这部分的花费是j,同时加上移动到开头所需要的花费1

特别的,如果j=0,也就是不需要移动,只从结尾删除,那么移动到开头的花费自然不用支出,因此这部分的花费可以记为(j>0)+j

因为数据量比较小,因此可以用这种方法遍历操作

void solve(){

    int n,m;

    cin>>n>>m;

    string s,t;

    cin>>s>>t;

    vector<int> pre(m+1),suf(m+1);

    for(int i=0,j=0;i<m;i++){

        while(j<n && s[j]!=t[i]){

            j++;

        }

        if(j==n){//不能提取出目标串

            cout<<"-1\n";

            return;

        }

        pre[i+1]=++j;//目标串的i+1个字符至少从++j开始匹配

    }

    suf[m]=n;

    for(int i=m-1,j=n-1;i>=0;i--){

        while(s[j]!=t[i]){

        //上面已经保证了一定能找到,因此不需要j>=0的判断

            j--;

        }

        suf[i]=j--;//目标串的第i个字符最多到j

    }

    vector lcp(n+1,vector<int>(m+1));

    //lcp[i][j]表示s从i开始,t从j开始的最长公共前缀

    for(int i=n-1;i>=0;i--){

        for(int j=m-1;j>=0;j--){

            lcp[i][j]=s[i]==t[j]?1+lcp[i+1][j+1]:0;

        }

    }

    int ans=n;

    for(int i=0;i<m;i++){

        for(int j=pre[i];j<=suf[i];j++){//从最初可匹配的位置进行枚举

            int len=lcp[j][i];

            if(suf[i+len]>=j+len){//说明是一次可行的匹配

                ans=min(ans,(j>0)+j+(m-i-len));

            }

        }

    }

    ans+=n-m;//统一计算删除操作所需的次数

    cout<<ans<<'\n';

}

 

同时也可用dp进行操作

 

最长公共子序列(LCS)(LCR095

子序列还是用选或不选来思考,也可以视作是字符串匹配的一种

从最后一对字母开始考虑,此处的操作经过排列组合后一共有4种(2*2)

并且这类题目都可以先注意一下一些递增关系,例如随着字符串长度的增加,他们的最长公共子序列一定是大于等于长度未增加之前的长度

f[i][j]表示在a的前i个数,b的前j个数中取出的LCS长度,对于每个f[i][j],有3种情况:
不选a[i],不选b[j],a[i]=b[j],可以选a[i]

因此根据 两个字符串text[i],text2[j]是否相同,转移方程为

dp[i-1][j-1]+1(相同时),max(dp[i-1][j],dp[i][j-1])

这种匹配问题的初始化一般都是考虑特殊情况,就是dp[0],空字符的情况,当字符串为空时,其LCS一定为0

又因为都只与前一维有关,因此可以用一维dp空间优化,遍历顺序:i,j都是顺序遍历,并且向这种需要知道上一次的j-1的,我们可以在更新时先令tmp=f[j]在更新完后再令pre=tmp

类似的想法有T72,通过对两个字符串各自位置上的字母相等与否来写不同的状态转移方程

最终答案为每一个元素都可能取到的情况,即f[n][m]

 

最长上升子序列(LIS)(T300

法一:动态规划(子序列问题一般是设定当前位置为序列中最后一个数)

子序列相当于数组的一个子集,因此可以用子集型回溯思考(选或不选,枚举选哪个)

一般倒着思考,碰到一个元素,假设这是子序列的最后一个数,考虑选或不选的话(还要考虑能不能选),就要与上一个数字比较大小,因此要传入两个参数,当前下标和上一个数字的下标,如果是枚举选哪个,就可以枚举数组前半部分小于这个数的元素,这样只需要知道当前所选的数字的下标即可,这样只需要一个参数,相对更好写  dfs[i]表示以nums[i]结尾的LIS的长度

写成递推形式就是:
class Solution {

public:

    int lengthOfLIS(vector<int> &nums) {

        int n = nums.size(), f[n];

        for (int i = 0; i < n; ++i) {

            f[i] = 0;

            for (int j = 0; j < i; ++j)

                if (nums[j] < nums[i])

                    f[i] = max(f[i], f[j]);

            ++f[i];

        }

        return *max_element(f, f + n);

    }

};

并且nums的LIS等价于nums与排序去重后的nums的LCS(最长公共子序列)

如果要记录答案序列,可以开一个pre数组,每次更改状态时,将当前pre值改为前一个数组下标,最后的时候将最终的pre值向前回溯,开一个栈,把目标数字放在栈里逆序输出(因为记录的是pre数组,因此正常遍历相当于将整个序列反了过来)

// ans 为答案,sp 为当前指向的位置,初始值均为0
             for(int i = 1; i <= n; ++j)
                          if(ans < f[i]) {
                                      ans = f[i];
                                      sp = i;
                          }
             printf("%d\n", ans);
             for(; sp; sp = pre[sp])
                          st[++tot] = a[sp]; // 将要求的序列放入栈中
    while(tot) printf("%d ", st[tot--]); // 逆序输出

 

 

方法二:贪心+二分查找

动态规划问题想要优化时间复杂度,可以尝试交换状态与状态值,例如f[i]表示以nums[i]结尾的LIS的长度,将长度和末尾元素表示状态交换一下,定义g[i]表示子序列长度为一个定值i+1的时候,上升子序列的末尾元素的最小值,但此时因为没有重复子问题,因此不能算作是一个动态规划问题,而是作为一种贪心算法

并且我们能推得推得一些性质:g[i]一定是严格单调递增的,一次只能更新一个位置,更新的位置是第一个大于等于nums[i]的g[j],又由g是一个严格递增序列,因此我们在找第一个大于等于nums[i]的g[j]时,可以利用二分查找快速知道要修改哪个位置

同时由于nums中的数是依次遍历的,即已经遍历过的元素之后不会再用到,因此也可以直接将nums数组作为g数组,以此通过原地修改来减少额外空间的使用

如果LIS中允许存在相同元素,那么nums[i]=g[j]的就不用去修改而是变成修改第一个>nums[i]的g[j]

int lengthOfLIS(vector<int> &nums) {

    auto end = nums.begin();

    for (int x : nums) {

        auto it = lower_bound(nums.begin(), end, x);

        *it = x;

        if (it == end) // >=x 的 g[j] 不存在

            ++end;

     }

     return end - nums.begin();

}

//不能用i同时表示遍历到的数组位置和g的长度,因为前面的元素不一定能构成递增序列

 

山形子序列可以看成是一个严格递增子序列,拼接一个严格递减子序列(T1671),以 suf为例,从右往左遍历nums,就相当于是在求最长严格递增子序列,可以使用 O(nlogn) 的做法解决。当我们遍历到 nums[i]时,二分下标加一就是此时 suf[i]的值

不进行原地修改的方法

int n = nums.size();

        vector<int> suf(n), g;

        for (int i = n - 1; i; i--) {

            int x = nums[i];

            auto it = lower_bound(g.begin(), g.end(), x);

            suf[i] = it - g.begin() + 1; // 从 nums[i] 开始的最长严格递减子序列的长度

            if (it == g.end()) {

                g.push_back(x);

            } else {

                *it = x;

            }

        }

 

求LIS

int lis(vector<int> &nums){

    vector<int> g;

    for(int x:nums){

        auto it=lower_bound(g.begin(),g.end(),x);

        if(it==g.end()) g.emplace_back(x);

        else *it=x;

    }

    return g.size();

}

 

25 杭电 春2 1004

给出一个小写字母构成的字符串S,将S拼接k次后会得到一个新字符串S’,求S’中的最长子序列长度,使得字符中的ASCII码严格递增

因为要求严格递增,因此如果k>=26,那么答案一定可以取到S中不同字符的个数

否则,注意到S的长度被限制在1到100中,那么可以暴力算出S’,然后计算最长上升子序列即可

void solve() {  

    string s;

    string kk;

    cin>>s>>kk;

    set<char> u(s.begin(),s.end());

    // if(k>=u.size()){

    if(kk.length()>=3){

        cout<<u.size()<<'\n';

        return;

    }

    int k=stoi(kk);

    string ss="";

    while(k--){

        ss+=s;

    }

    vector<int> dp(26,-inf);

    for(auto c:ss){

        dp[c-'a']=max(dp[c-'a'],1);

        for(int i=0;i<c-'a';i++){

            dp[c-'a']=max(dp[c-'a'],dp[i]+1);

        }

    }

    cout<<*max_element(dp.begin(),dp.end())<<'\n';

}  

 

 

求最长单调不升子序列

int mt(vector<int> &nums){

    vector<int> g;

    for(int i=n-1;i>=0;i--){

        auto it=upper_bound(g.begin(),g.end(),nums[i]);

        if(it==g.end()) g.emplace_back(nums[i]);

        else *it=nums[i];

    }

    return g.size();

}

(P1020,第一问,最长单调不升子序列)第二问,考虑贪心,从左到右依次枚举每个导弹。假设现在有若干个导弹拦截系统可以拦截它,那么我们肯定选择这些系统当中位置最低的那一个。如果不存在任何一个导弹拦截系统可以拦截它,那我们只能新加一个系统了,同样每次按照高度从小到大排列,能发现更新之后与二分求LIS相同,仍保持着单调性

结论:将一个序列剖成若干个单调不升子序列的最小个数等于该序列最长上升子序列的大小

 

有两个维度的情况(P1233,不过这题的区别在于该题并不是子序列,因为可以改变数组中元素的顺序),但本质上还是求最少可以分割成多少个不上升(二维数据都不上升)的子序列(此处用dilworth定理时要注意分别此处不上升的逆情况是什么,应该是二维数据有一个上升而不是都上升),这类题目可以先进行排序,排序时的规则是先以其中一维为关键字进行降序排序,该维度大小相同时再按照另一维的大小降序排列

这样之后进行二分的方法时

for(int i=1;i<=n;i++)

         {

                  if(a[i].w>f[ans])  在排完序的情况下恒有后面元素的l小于等于前面元素,因此是否是上升数组(只要有一个维度的值上升)就在于w是否上升

                          f[++ans]=a[i].w;

                  else

                  {

                          int tmp=lower_bound(f+1,f+1+ans,a[i].w)-f;

                          f[tmp]=a[i].w;

                  }

         }

 

2024 DHU 新生赛 C

给定一个矩阵,以及若干条捷径,捷径只会是从(x,y)到(x+1,y+1)

给出一个起点和终点,问到达终点的最少步数

假如没有斜边,那么答案显然就是两个点之间的曼哈顿距离,同时每走一条斜边,答案就能够减少1

于是问题转化为在不绕路的情况下最多能走多少斜边

但处理的难度在于每一行只能选择一条斜边,同时基于选择的斜边的位置,下一行将有部分斜边不能选择

用斜边的左上角的点表示一条斜边,假设最后选的斜边集合为{(x1,y1),(x2,y2),…,(xl,yk)},显然需要满足x1<x2<…<xk,y1<y2<…<yk

目的是让这个集合的大小尽量大,如果将所有斜边先按照x从小到大排序并且假设没有相同的x,那么问题就变成了求y的最长上升子序列

但现在有相同的x存在,而一行最多只能选择一条斜边,因此如果只是按x从小到大进行排序再选择y的最长上升子序列那么会导致多选斜边

为了解决这个问题,在x相同的情况下,可以将y按从大到小排序(*关键点),这样再将y排成一个序列,能保证对于一行,最多只会选一条斜边

求最长上升子序列,可以用二分的求法

最后答案就是两点之间的曼哈顿距离减去最多经过的斜边个数

 

LCIS

即长度为n,m的序列a,b,求一个最长的序列s,满足s同时为a和b的子序列,并且对任意i<j都有s[i]<s[j]

像LCS一样,考虑设f[i][j]表示在a的前i个数,b的前j个数中取出的LCIS长度,但这样不好转移。

所以综合LIS的思想,设f[i][j]表示在a的前i个数中取出,且以b[j]结尾的LCIS长度

若a[0]=b[0]且均比两个序列中任何数逗笑,设f[0][0]=0,则有两种情况,a[i]!=b[j]此时不能选a[i](因为序列以b[j]结束,因此默认b[j]是被选择的);当a[i]=b[j]那么就枚举之前b所有可能的结尾,检查能不能将a[i]加入(设之前的结尾为k,那么就是要满足a[i]>b[k])

同样如果要记录路径,就开一个pre数组

因为是以b[i]结尾,因此存储的是b中的位置

for(int j = 1; j <= m; ++j)
                          if(ans < f[n][j]) {
                                      ans = f[n][j];
                                      sp = j;
                          }
             printf("%d\n", ans);
             for(; sp; sp = pre[n][sp])
                          st[++tot] = b[sp];
             while(tot) printf("%d ", st[tot--]);

 

 

结论题

一个排列能够交换任意两个位置,变成另一个排列,对于长度为3的排列,最多2次就能完成

 

如果一个数列的长度小于等于2,那么这个数列一定不是无序的

而如果一个数列存在一个无序的子序列(可不连续),那么一定能找到一个a[1],a[i],a[i+1]是无序的情况   (cf27C)

 

交换问题:P1327(最少交换几次使得数组有序,可以先排序得到其真实应在的位置,再依次遍历使得其坐到自己的位置上)(实际上是置换环)

sort(q+1,q+1+n,cmp);  //对q进行排序,此时q某一元素对应的下标就是它应该在的位置

         for(i=1;i<=n;i++)

         s[q[i].seat]=i;  //seat是该元素原来所在的位置

         for(i=1;i<=n;i++)

         {

                  while(s[i]!=i)

                  {

                          swap(s[i],s[s[i]]);

                          ans++;

                  }

         }

最优性证明对于“把 ai 放回它应该在的位置”这个操作而言,每次对ai i” 的个数的贡献不是−1 就是−2(着重:必不为 0)。同时,若贡献可以是 −2,则必又aai=i。此时,这个操作贡献一定是 −2,所以操作次数一定最优(最少)

对于每个位置 while(a[i]!=i) 执行操作; 这个过程是互相独立的。即:ai→aai成环。

成环原因显然,因为会回到ai(ai=i)。(这种成环交换的类似的还有T765)

所以,最终的答案即为:ans=   因为有环因此可以尝试用并查集做(但可以像上面那样不用并查集而直接进行交换达到类似于找环的操作)

并且注意只有相邻元素交换的时候才是求逆序对的个数

 

逆序对:指排列中(i,j)(i<j)满足ai>aj的数量,对任意两个数进行交换,逆序对的数量奇偶性发生变化,因此若要一个排列经过奇数次交换后变成另一个排列,那么就是要求这两个排列的逆序对数量奇偶性不同

常用变化:nums1[i] - nums1[j] <= nums2[i] - nums2[j] + diff  变为 nums1[i] - nums2[i] <= nums1[j] - nums2[j] + diff   令 a[i]=nums1[i]-nums2[i],则原式即:a[i]<=a[j]+diff(T2426)

 

对数组进行拼接:原数组是x+y将其变成y+x,可以证明,这个操作不能够使我们在数组中间插入一个数,即同时改变了这个数字左右两侧的数,因此我们如果能通过若干次操作使得数组变成不递减的数组,那么我们可以通过不超过一次的运算使 a变得不递减(cf 1975a)

 

显然一些操作是有类似于滚雪球性质的,只要能进行一次操作,那么之后一定能一直进行操作,因此关键就在于确定这个第一次操作可能的最大值

在只有两个元素的子数组中,我们得到的是其中较小的数,而在包含这个子数组的3个元素的子数组中,我们可能可以得到其中更大的数,显然4个元素的情况并不会优于3个元素的情况,5个元素的情况可能会更大,但因为我们的操作次数是无限的,不妨将5个元素的数组拆分成两个3个元素的数组,因为进行一次操作之后我们就能进行持续操作,因此我们能够得到的是两个中位数中的较大者,显然这样操作是比较优的

于是如果是 n=2 ,答案就是最小元素。如果是 n≥3 ,则遍历所有长度为 3 的子数组,答案为所有长度为3 的子数组的中位数的最大值(cf 1975c)

 

找规律

杭电24多校2 1003

因为中间的板块一定不会被移动,会发生变化的只有8个角的贴纸,因此实际上可以看作是一个二阶魔方

同时因为一个正确魔方的8个角的贴纸的相对位置并不会发生改变,因此根据这个不变量可以直接遍历判断每个情况即可,至多只有1个角不满足正确的可能情况,因此找到的即为答案

void solve(){

    string s[9];

    for(int i=0;i<9;i++){

        cin>>s[i];

    }

    bool ok=true;

    auto check=[&](char a,char b,char c){

        const string le[]={"231","341","451","521","326","436","546","256"};  

        for(int i=0;i<8;i++){

            for(int j=0;j<3;j++){

                if(a==le[i][j]&&b==le[i][(j+1)%3]&&c==le[i][(j+2)%3]){

                    return;

                }

            }

        }

        ok=false;

        array ans={a,b,c};

        sort(all(ans));

        cout<<ans[0]<<' '<<ans[1]<<' '<<ans[2]<<'\n';

    };

    check(s[3][2],s[3][3],s[2][3]);

    check(s[3][5],s[3][6],s[2][5]);

    check(s[3][8],s[3][9],s[0][5]);

    check(s[3][11],s[3][0],s[0][3]);

    check(s[5][3],s[5][2],s[6][3]);

    check(s[5][6],s[5][5],s[6][5]);

    check(s[5][9],s[5][8],s[8][5]);

    check(s[5][0],s[5][11],s[8][3]);

    if(ok){

        cout<<"No problem\n";

    }

}

 

 

CCPC2024 重庆

结论:度为2的节点一定能够被保留到最后,度为1的节点所能贡献的最大值是所有度为1的节点中的次大值

 

Cf 2068J

考虑什么情况下能达到目标

因为直观的找或者去分配每个下标非常困难,我们不妨先去构造合法的形式,也就是目标的必要条件

假设我们划分好了两个子集,分别是A={a[1],..a[n]},B={b[1],…b[n]},这两个子集大小都是n,并且这两个子集互不相交,并且交换后能够得到字符串WWW…RRRR

令a[h]是a[i]<=n的最后一个元素,b[k]是b[i]<=n的最后一个元素,因为a和b构成了划分,因此有h+k=n,因为a和b是对称的(也就是我们可以交换a,b),于是不妨假设h<k,则根据交换后前n个元素必须是’W’的定义,必须有a[1]…a[h]对应的元素都是W,同样,b[1]…b[h]的元素也必须都是W,以及b[h]…b[k]会被交换到大于n的位置,又因为共计只有n个W,那么a[h]…a[k]一定是R

于是可以得到,在前n个元素中,又2*h个W,k-h个R

接着考虑初始排列中的前h个瓶子,可以发现,无论怎么分配,它们都会属于a[1]…a[h]或者b[1]…b[h],因此前h个瓶子一定是W,同理,因为后半部分的推导是类似的,因此后h个瓶子一定是R

这是我们推导得到的必要条件,已经有一定规律的形式了,再将其作为充分条件,发现这也是成立的

因此就得到了判断的方式

void solve() {

    int n;

    cin>>n;

    string s;

    cin>>s;

    int cnt=count(s.begin(),s.begin()+n,'W');

    if(cnt%2==0 && count(s.begin(),s.begin()+cnt/2,'R')==0

        && count(s.end()-cnt/2,s.end(),'W')==0){

            cout<<"YES\n";  

    }else{

        cout<<"NO\n";

    }

}

 

 

 

Cf 2072F
一个n行的等腰三角,用下标(i,j)表示第i行j列的元素 对于这个三角,(1,1)=1 对于(i,j)位置的元素,如果j=1,那么(i,j)=1;如果j=i,那么(i,j)=1;否则(i,j)=(i-1,j-1)^(i-1,j),其中^表示异或运算 对于这个n行的三角形,给出第n行的所有元素(或者说给出第n行元素的规律)

先进行打表,注意到如果n是2的幂次,那么那一行的元素都是k,如果是2的幂次+1,那么那一行的元素首和尾是k,其余都是0

对于其他行,注意到例如对于3=1+2,那么第3行的两侧是第1行的形式,然后中间都是0,这主要是因为考虑到2的幂次是特殊情况,那么能否依据这个进行类似于分段的考虑

由此可以得到递归的考虑,例如11=8+3,3=2+1,也就是可以按照2进制进行拆分,每次中间0的个数是减去2倍的加数,也就是如11,加数是3,那么中间0的个数是11-3*2=5,3中间0的个数是3-1*2=1

void solve() {

    int n,k;

    cin>>n>>k;

    int hi=31-__builtin_clz(n);

    auto work=[&](auto self,int x)->string{

        int hi=31-__builtin_clz(x);

        if((1<<hi)==x){

            string res(x,'1');

            return res;

        }

        int tmp=x-(1<<hi);

        int len=x-(tmp<<1);

        string res(len,'0');

        string res1=self(self,tmp);

        // cout<<tmp<<' '<<len<<' '<<'\n';

        // cout<<res1<<'\n'<<res<<'\n';

        res=res1+res+res1;

        return res;

    };

    string res=work(work,n);

    // cout<<res<<'\n';

    for(int i=0;i<n;i++){

        if(res[i]=='1'){

            cout<<k;

        }else{

            cout<<0;

        }

        cout<<" \n"[i==n-1];

    }

}

另一方面,因为是2的幂次时都是1,以及上面的每次减去最高位1,因此自然想到是否与二进制有关

这个三角形实际上类似于杨辉三角(帕斯卡三角形),只不过每次都是异或运算,但是因为不可能有其他元素出现,因此仍然尝试用一般的帕斯卡三角形找规律,依据异或的性质,若当前位置的值为奇数时,这个位置输出k,否则输出0

因为帕斯卡三角形第i行的元素是可以用组合数表示的,第n行第k个元素可以表示为C(n,k),但是要注意这里的n是从0开始计数的,也就是第0行只有一个元素1,第2行的元素是1 1,而k的取值是0到n

因此大小为n的三角形最后一行实际上编号是n-1
而依据卢卡斯定律二项式系数C(n,m)是奇数当且仅当m的二进制表示中没有1在n的二进制表示中为0,也就是m是n的二进制子集,而这一点可以用位运算来判断,也就是当(m&n)==m时,该组合数为奇数,因此可以得到这题的简单代码

 

排序:
cf2024C

数组中的每个元素是一个pair对,要求找到一个顺序使得数组中的逆序对最少

显然考虑用排序去解决

因此思考以什么原则进行排序

如果只考虑相邻元素,那么显然是如果交换前的逆序对个数大于交换后的逆序对个数,那么进行交换,如果这样写,那么sort函数中的cmp为cnt<=0(cnt是交换前减交换后的)

但这样只能保证相邻的情况正确,但放在全局上还是应该考虑影响

因为无法改变pair中元素的顺序,因此考虑能否有一个特征去表示这个pair对

又逆序对依赖于元素的大小关系,因此提取出pair中的最大元素和最小元素

显然如果是将最大元素较小的放在前面而如果最大值相同时,将最小值更小的放在前面

void solve(){

    int n;

    cin>>n;

    vector<pair<int,int>> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i].first>>a[i].second;

    }

    sort(a.begin(),a.end(),[&](pair<int,int> i,pair<int,int> j){

        if(max(i.first,i.second)==max(j.first,j.second)){

            return min(i.first,i.second)<min(j.first,j.second);

        }else{

            return max(i.first,i.second)<max(j.first,j.second);

        }

    });

    for(int i=0;i<n;i++){

        cout<<a[i].first<<' '<<a[i].second<<" \n"[i==n-1];

    }

}

 

 

字符串:

平方序列:

当字符串中出现XX结构(X表示一个子串),称这个字符串是平方序列

免平方序列Thue-Morse

如果只有字符0和1,其免平方字符串是有限的

但如果有0,1,2三个字符的话,能构造出任意长度的免平方字符串

定义序列t=01101001100...

实际上是表示下标的二进制表示中有多少个数字1,序列t也可以递归的进行构建,即t(n)=t(n/2),n为偶数时,t(n)=1-t(n-1),n为奇数时,最后规定t(0)=0

由于对于所有的偶数n,t(n)=t(n/2)始终成立,因此当序列删除所有奇数项后剩余的序列和原序列完全相同,或者去掉所有偶数项,得到的是原序列的反

还有一种构造方式是把当前的串取反并后置即0  01  0110  01101001

这种方法仍然是在记录二进制下1的个数,每次翻倍相当于乘二,转到二进制上就是最高位增加了一个1,因此与之前的情况相比奇偶性发生了变化

如果某个字符串中连续出现了两个相同的片段,但有一个字符的交叉,即aXaXa的形式,就称aXa在这个字符串中是重叠的

如果整个字符串中没有重叠的情况出现,那么就称这个序列是免重叠的,可以证明Thue-Morse序列是免重叠的

进而借助Thue-Morse序列,可以得到一个无限长的免平方字符串,其中只包含0,1,2三种字符

具体的构造方式是依次列出相邻两个0之间有多少个数字1,也就是得到2,1,0,2,0,1,0..

其他性质:免立方,复现(不是循环序列的复现序列)

用于构造:
对于哪些正整数k>=2,存在两个大小相等的整数集合使得a1+a2+...=b1+b2+...

且a1^2+a2^2+..=b1^2...;...;a1^k...=b1^k...

对于所有的整数k>=2,满足要求的解都是存在的,且利用Thue-Morse序列可以构造长度为2^k的解,取出前2^(k+1)项,如果T(i)=0,放进集合1中,否则放进集合2中

构造幻方,对于2^n*2^n的幻方,先将1到2^(2n)的数从左到右从上到下填入,如果第i个数是0(即t(i-1)=0),则拿出,然后把所有拿出来的数倒序放回方格即可

 

25 杭电 春 6 1009

定义一个字符串是好串当一个只由小写字母组成的字符串中不存在超过k个元音相邻

想知道长度为n的字符串中,有多少个好串

一个比较暴力的状态定义就是令dp[i][j]表示长度为i,最后j个字母为元音的字符串种数

那么转移是dp[i][0]=sum(dp[i-1][0],…,dp[i-1][k-1])*21,dp[i][j]=dp[i-1][j-1]*5

考虑优化:如果t到i都是元音,且第t-1个字母是辅音,那么len=j-i+1,dp[i][len]=dp[t-1][0]*pow(5,len)

于是可以压缩第二维

因为记录末尾的原因的字符数能自然推出前一种状态,也就是dp[i-1][k-1]可以由dp[i-k][0] O(1)得到,从而并不需要在第二维存储这么多状态,转移的时候只要枚举末尾有多少个t是元音

第二维仅有1/0,分别表示第i个字母是/不是元音字母,从而转移形如:

dp[i][1]=sum(dp[i-t][0]*pow(5,t))

dp[i][0]=(dp[i-1][0]+dp[i-1][1])*21

但这样dp[i][1]的转移仍然是比较困难的,一种优化方式是记f[i]=dp[i][0]*5^(n-i-1),然后对f数组求前缀和,转移到i时,使用(f[i]-f[i-k-1])/5^(n-i+1)

进一步考虑其他的方式

滑动窗口优化转移,注意到转移的范围大小是固定的,因此一边遍历一边维护

令dp[i]表示长度为i的好串,并且限定最后一个位置是辅音的数量,也就是上文中的dp[i][0]

并用变量sum维护一个滑动窗口,准确来说就是维护上面转移时需要枚举转移的位置,用于计算新的dp值

对于i=1到k的部分,每个位置可以选择放置辅音或者元音,如果放置辅音,则用21乘上之前所有好串的总数,即dp[i]=21*sum,如果放置元音,那么就是加上5*sum,但这部分不存储在dp中

这两种状态都存放到sum中,因为这时长度都一定小于等于k,因此不需要减操作

大于k时,因为sum维护的就是转移的位置,因此对于dp[i],其更新仍然是dp[i]=21*sum

但是此时因为sum是一个固定大小的窗口,因此要踢出不在窗口的元素,即sum-=dp[i-k-1]*pow(5,k),每次只会删除一个元素,也就是排除末尾有连续K个元音的情况

最后的答案就是dp[n]再加上末尾是元音的情况即可

void solve() {

    int n,k;

    cin>>n>>k;

    vector<int> dp(n+2);

    dp[0]=1;

    int mul5=1;

    int sum=1;

    for(int i=1;i<=k;i++){

        dp[i]=21*sum % mod;

        sum=5*sum % mod;

        sum+=dp[i];

        mul5*=5;

        mul5%=mod;

    }

    for(int i=k+1;i<=n;i++){

        dp[i]=21*sum%mod;

        sum-=dp[i-k-1]%mod*mul5%mod;

        if(sum<0){

            sum+=mod;

        }

        sum=5*sum%mod;

        sum+=dp[i];

    }

    mul5=1;

    int ans=0;

    for(int i=n;i>=n-k;i--){

        ans+=mul5*dp[i]%mod;

        ans%=mod;

        mul5*=5;

        mul5%=mod;

    }

    cout<<ans<<'\n';

}

 

 

动态规划解决计算本质不同的子序列个数(计数dp)

令dp[i]表示以i结尾的本质不同的子串的个数,last数组last[a[i]]是指a[i]上一次出现的下标

当a[i]没有在之前的位置出现过,那么dp[i]=2*dp[i-1]+1,1指的是单独a[i]这个元素,2*dp[i-1]是指对dp[i-1]的方案原样保留以及在末尾添加一个a[i]两种情况

如果a[i]已经在之前的位置出现过了,那么dp[i]=2*dp[i-1]-dp[last[i]-1],因为已经出现a[i],因此对于last[i]之前的那些子序列来说,只是在它们末尾添加一个a[i]的情况已经在dp[last[i]]中计算过了,因此要减去,同时,a[i]单独出现的情况也已经记录过了

(leetcode 940)

void solve(){

    const int mod=1e9+7;

    string s;

    cin>>s;

    int n=s.size();

    vector<i64> dp(n+1);

    dp[0]=0;

    unordered_map<char,int> last;

    for(int i=1;i<=n;i++){

        if(!last[s[i-1]]){

            dp[i]=2*dp[i-1]+1;

        }else{

            dp[i]=2*dp[i-1]-dp[last[s[i-1]]-1];

        }

        dp[i]=(dp[i]+mod)%mod;

        last[s[i-1]]=i;

    }

    cout<<dp[n]<<'\n';

}

 

 

Find 函数

s.find(‘s’),会返回该字符第一次出现在字符串中的下标

s.find(‘s’,a),会返回该字符在下标a位置之后,第一次出现位置的下标

如果没有查找到,就会返回-1

Cf2049b

给定一个字符串,问是否能构造出一个排列,使得:如果给定字符串某一个位置i的元素为p,则对应排列的0~i,恰好时i+1的排列,如果时s,则i~n-1恰好是n-i的排列

因为需要构造的本身就是一个排列,因此每个元素只能出现一次,于是可以根据字符串中的各元素代表的必要性来找到矛盾的位置

首先可以猜测,显然不需要对所有的s,p都进行判断,因为有些条件显然是更强的,例如,对于pp,如果能够满足后面一个p,那么前面一个p一定也别满足了

而对于矛盾条件,我们只需要找最小的即可

对于…p…s…的情况,显然是无法满足的,因为两个没有交集,但又需要各自满足各自的排列

对于.sp…的情况也是无法满足的,因为任意情况都要1~i的所有数,但是s和p交集的范围并不是s或者p,也就是说必然又空缺,那么空缺部分的大小(即s前面.的个数)就是最后会重复元素的个数

而对于s..p….的情况是可以满足的,因为重叠部分恰好就是p的范围,相当于没有受到影响

void solve(){

    int n;

    cin>>n;

    string s;

    cin>>s;

    int p=s.find('p');

    if(p==-1){

        cout<<"YES\n";

        return;

    }

    if(s.find('s',p)!=-1){

        cout<<"NO\n";

        return;

    }

    if(p>0 && p<n-1 && count(s.begin()+1,s.begin()+p,'s')>0){

        cout<<"NO\n";

    }else{

        cout<<"YES\n";

    }

}

 

 

匹配问题:

ccpc2024网络赛

如果求的是一个字符串S在T中的出现次数,那么可以用矩阵乘法做

具体的,设A[j][k]表示某个串匹配S[j][k]的方案数,那么一个字符c的贡献可以用一个矩阵F[c]表示,最终求得F[s1]xF[s2]x...xF[sn]即可求出答案

对于本题可以用类似的做法,用G[i]表示S’[i]所表示的矩阵,那么有G[i]=G[i-1]xF[si]xG[i-1]

直接递推即可

区间dp

 

Cf 2104E

认为前k个字符是允许字符

一个长度为n的字符串s,全由允许字符组成

现在给定一个s的子串t,问在其末尾至少添加多少个字符会使得其不再是s的子串

因为要考虑最劣情况,因此首先要做的是找到最早匹配到这个子串的位置,除了暴力的逐个匹配外,一种比较快速的方式是使用类似状态机的形式,对每个字符找到下一个字符出现的位置

在这个位置后,我们考虑剩余的字符串如何选择,能得到长度最小的子串

一种贪心的想法是找到剩余字符中出现数量最少的字母,但这个思路会出现一些问题,因为没有考虑到子串中不用元素位置之间所造成的影响

例如aaaabbbbab,如果选择bab,只需要3个,但是如果使用出现次数最少的字符,需要5个

因此正确的方法是逆序进行处理,对于之前存储的下一个位置的信息,逆序处理最小形式的次数

void solve() {

    int n, k;

    cin >> n >> k;

    string s;

    cin >> s;

    vector nxt(k, vector<int>(n + 2, n + 1));

    for (int x = 0; x < k; x++) {

        for (int i = n - 1; i >= 0; i--) {

            nxt[x][i] = s[i] == 'a' + x ? i + 1 : nxt[x][i + 1];

        }

    }

    vector<int> f(n + 2);

    for (int i = n; i >= 0; i--) {

        f[i] = 1e9;

        for (int x = 0; x < k; x++) {

            f[i] = min(f[i], 1 + f[nxt[x][i]]);

        }

    }

    int q;

    cin >> q;

    for (int i = 0; i < q; i++) {

        string t;

        cin >> t;

        int x = 0;

        for (auto c : t) {

            x = nxt[c - 'a'][x];

        }

        cout << f[x] << '\n';

    }

}

 

 

cf2005C
给定 n个长度为m 的字符串,一个人 A 和GPT 玩游戏,对于一个字符串 S,

A 的策略是循环选取narek,每选出一组,scoreA+=5,不足一组不算 A 选取的。

对于 A 没有选取narek,每一个字符使scoreGPT+=1。

目标是,在不改变 n 的字符串的相对顺序的情况下,选取一些字符串依次连接构成 S,使 scoreA−scoreGPT 最大

贪心的方法是行不通的,因为一个字符串的末尾可以连接到下一个字符串的开头,因此尝试用动态规划

在处理下一个字符串之前,我们只需要知道从上一个选择中找到了哪个字母。

在 n字符串中循环,并将 dpi定义为最大答案,如果我们当前正在查找 "Narek "这个单词中的 i /th字母。最初是 dp0=0 和dp1=⋯=dp4=−∞ 。对于当前字符串,我们将对之前可能结束的所有五个字母进行蛮力搜索。假设当前字母是 j/-th,其中 0≤j<5。如果 dpj不是 −∞ ,我们可以复制选择这个字符串作为子集的过程,并计算分数差(即答案)。最终,我们将在 "Narek "一词中找到 k个字母。如果从dpj到达 dpk 比之前的值 dpk 大,我们就会通过 dpj+counted _ scored更新 dpk。

最后,答案是 dpi−2*i 。这是因为如果 i 不是 0,那么我们就没有完全完成整个单词(问题指出,在这种情况下,这些字母会被计入 GPT 的分数中,因此我们要从自己的分数中减去这些字母,然后将其加入 GPT 的分数中)。

注: 更新数组 dp 是不正确的,因为我们可能会更新某个字符串的 dpi ,然后将更新后的 dpi 用于同一字符串。为了避免这种情况,我们可以使用两个数组,并为每个字符串覆盖一个数组

void solve(){

    int n,m;

    cin>>n>>m;

    vector<string> s(n);

    for(int i=0;i<n;i++){

        cin>>s[i];

    }

    array<int,5> dp;//开始(上一轮结束时)的字符位置

    dp.fill(-inf);

    dp[0]=0;

    const string t="narek";

    for(int i=0;i<n;i++){

        auto ndp=dp;

        for(int x=0;x<5;x++){

            int y=x;

            int res=dp[x];

            for(auto c:s[i]){

                if(c==t[y]){

                    y++;

                    if(y==5){

                        y=0;

                        res+=5;

                    }

                }else if(t.find(c)!=-1){

                    res--;

                }

            }

            ndp[y]=max(ndp[y],res);//如果更大就更新

        }

        dp=ndp;

    }

    int ans=0;

    for(int i=0;i<5;i++){

        ans=max(ans,dp[i]-i);// i不是0,就没有完全完成整个单词

    }

    cout<<ans<<'\n';

}  

 

CCPC204 深圳 G

定义两个长度相同均为M的字符串的距离为相同位置对应字符不同的个数

给定一组模式串s1…sn,q次询问,每次给出tj,问满足d(si,tj)<=k的模式串si的数量

如果k=0,那么就是判断两个字符串是否相同

可以对模式串预处理Hash,输入目标串时计算hash即可

当k=1时,考虑能否借鉴哈希的思路,因为只在下标m处不同,于是考虑计算前缀和后缀的哈希值,也就是1~m-1和m+1~M的哈希值相等是否成立

虽然实际上我们并不知道m的具体数值,但是注意到前缀hash值是否相同具有单调性

也就是m<m0时,一定相同,m>=m0,有很大概率前缀哈希值不相同(取决于哈希的方式)

因此,可以先二分查找m0的位置,再通过后缀hash是否相等来推测是否只有一个位置不同

对于k更大的情况可以类似地进行操作,利用二分查找出前k个满足s[i][m]!=t[j][m]的下标m,如果找不到k个,一定说明其d<k

对第k个找到的下标,判断其后缀是否相同即可

或者利用分块

也就是每一块通过hash判断是否可能包含差异,如果hash不同才对块内逐字符比较

因为只需要判断d<=k是否成立,因此最多只有k块需要块内逐个比较,因为如果有k+1块hash 不同,则说明至少有k+1个字符不同,最坏情况下的比较次数:M/B+KB,因此块长取

// 分块

constexpr int base=233;

const int B = 512;

void solve(){

    int n,q,m,k;

    cin>>n>>q>>m>>k;

    // int B=sqrt(m/k); TLE

    vector<string> s(n);

    auto hash=[&](const string &s){

        vector<u64> res;

        for(int l=0,r;l<m;l=r){

            r=min(l+B,m);

            u64 cur=0;

            for(int i=l;i<r;i++){

                cur=cur*base+s[i];

            }

            res.emplace_back(cur);

        }

        return res;

    };

    vector<vector<u64>> st(n);

    for(int i=0;i<n;i++){

        cin>>s[i];

        st[i]=hash(s[i]);

    }

    while(q--){

        string t;

        cin>>t;

        auto cur=hash(t);

        int siz=cur.size();

        int ans=0;

        for(int i=0;i<n;i++){

            int cnt=0;

            for(int j=0;j<siz;j++){

                if(st[i][j]!=cur[j]){

                    for(int k=j*B;k<min((j+1)*B,m);k++){

                        if(s[i][k]!=t[k]){

                            cnt++;

                        }

                    }

                    if(cnt>k){

                        break;

                    }

                }

            }

            if(cnt<=k){

                ans++;

            }

        }

        cout<<ans<<'\n';

    }

}

 

字符串的长度不大,考虑状态压缩,用一个bitset来标记模式串集合

const int N=305,M=60010;

bitset<N>A,C,B[M][N];

 

void solve(){

    int n,q,m,K;

    cin>>n>>q>>m>>K;

    string s;

    for(int i=0;i<n;i++){

        cin>>s;

        for(int j=0;j<m;j++){

            for(int k=0;k<26;k++){

                if(s[j]!='a'+k){

                    B[j][k][i]=1;

                    // 存储第j个位置字符不是k的字符串集合,为0说明该字符串在该位置的字符是k

                }

            }

        }

    }

    while(q--){

        cin>>s;

        vector<int> cnt(n);

        // 不能用A.set() ,只需要将n个位置的字符集合置为1即可

        for(int i=0;i<n;i++){

            A[i]=1;

            // 初始化

        }

        for(int j=0;j<m;j++){// 每个位置逐个判断

            C=A&B[j][s[j]-'a'];// c是模式串的集合,为1说明该位置不同

            for(auto k=C._Find_first();k!=C.size();k=C._Find_next(k)){

                if((++cnt[k])>K){// 对不符合要求的串进行标记

                    A[k]=0;

                }

            }

        }

        cout<<A.count()<<'\n';// 剩下还是1的就是合法的字符串

    }

}

直接存储不匹配的位置个数,在不同的字符串之间通过维护的不同位置进行更改

void solve(){

    // 排序后相邻字符串差异位置不大,记录不同位置的下标,直接在前一个字符串的结果上修改

    int n,q,m,k;

    cin>>n>>q>>m>>k;

    vector<string> s(n);

    for(int i=0;i<n;i++){

        cin>>s[i];

    }

    sort(s.begin(),s.end());

    vector<vector<int>> dif(n+1);

    for(int i=0;i<n-1;i++){

        for(int j=0;j<m;j++){

            if(s[i][j]!=s[i+1][j]){

                dif[i].emplace_back(j);

            }

        }

    }

    while(q--){

        string t;

        cin>>t;

        int cnt=0;

        for(int i=0;i<m;i++){

            if(s[0][i]!=t[i]){

                cnt++;

            }

        }

        int ans=cnt<=k;

        for(int i=0;i<n-1;i++){

            for(auto k:dif[i]){

                if(t[k]!=s[i][k]){

                    cnt--;

                }

                if(t[k]!=s[i+1][k]){

                    cnt++;

                }

            }

            if(cnt<=k){

                ans++;

            }

        }

        cout<<ans<<'\n';

    }

}

 

回文串:
cf 2069D

给定一个字符串s,可以对字符串执行操作:选择s的一个连续子串并将其洗牌,也就是可以以任意顺序重新排列这个字串中的元素

要求输出将字符串转换为回文字符串必须执行上面操作的字串的最小长度

首先,能不操作一定是不操作的,因此对于原字符串,如果第一个字符等于最后一个字符,那么可以直接删去这两个字符

这样操作后可以得到第一个字符不等于最后一个字符的字符串,那么显然,我们需要对这个字串的前缀或者后缀进行操作,我们可以不失一般性的认为我们要对字符串的前缀进行操作

假设通过对长度为m的前缀进行操作可以使我们得到回文串,那么对长度为m+1的前缀进行操作显然也可以得到答案,也就是具有二段性,可以考虑用二分查找来得到需要进行的前缀的最短长度

那么就要考虑如何进行判断,对于在现在的子串中的字符,如果s[i]!=s[n-i+1],那么这个位置显然需要发生变化,因此应该属于前缀

因为输入中保证字符串的长度是偶数,因此总是可能将所有字符串拆分为字符对

判断标准可以视作要有足够的字符来填满需要固定字符的位置对,同时所有剩余位置可以由任何剩余的字符对填入

回文必然保证数字出现的次数是成对的,也可以由此进行判断

 

一个字符串作为另一个字符串的子序列的出现次数

牛客:躲藏

显然可以用动态规划

令f[i][j],表示前i个字符中,匹配了字符串”cwbc”的前多少位,那么显然可以得到转移方程:
f[i][3]=(f[i-1][3]+(s[i]==’b)*f[i-1][2])%mod

这样开数组是会超内存限制的,因为每次转移都只与上一个状态有关,因此可以只开一维

 

cf1948D

在字符串进行子串进行,想到利用双指针的时候,因为考虑到并不能得到一个两个指针移动的规律,因此可以考虑使用枚举字符串长度的思路,这样可以可以直接匹配si 和 si+L,其中 L 表示当前枚举的区间长度。如果匹配,那么令计数器 +1。否则计数器归零。这样,如果可以连续匹配 L 个字符,那么说明答案合法

还有一种做法是考虑英文字母的权值,其中全能字符“?”的权值定义为0,这样对于两个长度相等的串A,B就可以得到一个匹配相关的值f(i,j)=(si-sj)2sisj,si,sj分别表示A串和B串中对应的元素,因为保证了f(i,j)是一个正值,因此当两个字符串能够匹配上时,当且仅当那段区间对应的f的和为0

因此可以考虑枚举答案长度L,以此去预处理前缀和

inline void solve(){

    scanf("%s",S + 1); n = strlen( S + 1 );

    for( int i = 1 ; i <= n ; i ++ )

        for( int j = 1 ; j <= n ; j ++ ) a[i] = ( S[i] == '?' ) ? 0 : ( S[i] - 'a' + 1 );

    for( int i = 1 ; i <= n ; i ++ )

        for( int j = 1 ; j <= n ; j ++ ) f[i][j] = ( a[i] - a[j] ) * ( a[i] - a[j] ) * a[i] * a[j];

    for( int len = 1 ; len <= n ; len ++ )

        for( int i = 1 ; i + len - 1 <= n ; i ++ ) s[len][i] = s[len][i - 1] + f[i][i + len];

    int Ans = 0;

    for( int len = 1 ; len <= n ; len ++ )

        for( int l = 1 ; l + 2 * len - 1 <= n ; l ++ )

            if( s[len][l + len - 1] - s[len][l - 1] == 0 ) Ans = max( Ans , len * 2 );

    printf("%d\n",Ans);

}

 


动态规划可以用来解决字符串匹配问题(T10),将匹配字符串的两个下标i,j的位置作为状态,当要选择多个时,并不需要一下子计算出到底会替代几个,这样往往会使得代码思路不连贯,我们只要按照最低次数来操作即可,因为总共的次数也是这样一个一个判断下去的,也更符合动态规划转移的感觉(只与最近的操作相关联)

 dp[i][j−2] or  dp[i−1][j],  p[j]=‘∗‘ & s[i]=p[j−1]

 

字符串匹配问题一般有一个思路就是能匹配就尽量匹配,因为并不清楚这个位置不去匹配,之后能否还能进行匹配(T2825),且一般外层循环是枚举那个长度更长的字符串,这样内层只需要写一个if即可

包括一些字符串分割,也是能采用就尽量先采用,因为不清楚数组的后半部分到底会如何影响这个位置上的值,本质也是一种贪心的思想(T2522),或者说给后面留下的字符串长度越短,那么答案显然不会变大

 

字符串的子串处理,并且含有最大最小的要求时,字符串覆盖,不能有重叠,要使留下的元素数最少,这种只是单纯叠加的可以用dp去解决,用dp表示以下标为i结束的子串的留下最少元素的个数,并且虽然覆盖的字符串是连续的,但我们只有在边界的时候才会变化(这有点像之前的旅行家的预算),从边界开始枚举子串(这里有点像P1470)

unordered_set<string> set(dictionary.begin(), dictionary.end());

        int n = s.size();

        vector<int> f(n + 1);

        for (int i = 0; i < n; i++) {

            f[i + 1] = f[i] + 1; // 不选

            for (int j = 0; j <= i; j++) { // 枚举选哪个

                if (set.count(s.substr(j, i - j + 1))) {

                    f[i + 1] = min(f[i + 1], f[j]);

                }

            }

        }

 

 

字符串匹配问题可以尝试用dp去解决(T466),不过此处的dp更像是一种预处理,预处理出从这个位置开始能匹配多少个元素,之后进行模拟时就可以利用这个结果(也可以说是对这种重复匹配问题进行一个记忆化,相似循环的子问题,或者说可以暴力模拟的问题,一定可以进行优化或者说有固定规律可循,关键就是找到以哪个状态,在哪个循环操作中进行处理);或者这种重复匹配问题可以从循环节的思路去考虑,暴力做法的问题在于假设n个s1正好能够完全匹配m个s2,那么从第n+1个s1开始,又重新从s2的第0个字符开始匹配,可以知道又会出现n个s1正好能够完全匹配m个s2,如此重复下去直到n1个s1匹配结束。因此,后面这一部分是重复的无用的计算,造成了超时。当发现n个s1正好能够完全匹配m个s2时即可跳出循环匹配,这n个s1构成一个循环节;如果要继续对这种做法进行优化,那就要加速循环节的查找了,这需要重新定义循环节:将不断循环的 s2 组成字符串S2,然后去找是否存在一个子串,即「循环节」,满足不断在 S2 中循环(即经过若干个s1后的idx在之前的匹配中已经出现过,那么从上一个的这个idx到此时的idx就是s2中的循环节),且这个循环节能对应固定数量的 s1

 

以动态规划为基础去解决匹配问题  P1470

用set优化(用于匹配的字符串“元素”个数有限,根据大小去分类)

匹配问题dp存储的是状态,即当前匹配到位置是否满足,在这题中,这个匹配更像是拼接,能否用上述的那些字符串元素取组合成这一个长串,同时这是一个连续前缀问题,因此前面能匹配的,同时后面一段也能匹配的(因为前面部分已经处理过了,因此考虑从当前子串的末尾开始截取),才会满足这整一段是匹配的,否则,子串不匹配或者截去这一子段的前面不能匹配,那么这整一段显然也是不能匹配的,用set的原因是我们没有必要把子串与所有集合里的串作比较,只有比较跟他长度相等的就行了,而在set里面查找的速度是O(logn)效率较高

 

bool dp[200005];

int m;

set<string> s[20];

int main(){

    string tp;

    while (cin>>tp){

        if (tp==".") break;

        s[tp.size()].insert(tp);//存到他大小的集合中

        m=max(m,int(tp.size())); //不加int会导致max里面两个元素的类型不同不能使用

    }

    int ans;

    dp[0]=1;//初始化

    string n;

    n=" ";

    while (cin>>tp){

        n=n+tp;//将所有的串合成一个,开始是“ ”保证了下标从1开始

    }

    for (int i=1;i<n.size();i++){//枚举子串

        for (int j=min(i,m);j>=1;j--){//枚举截出的子串长度

            string tt=n.substr(i-j+1,j);//截除子串

            if (s[tt.size()].count(tt)==1&&dp[i-j]==1){//如果合法

                ans=i;//必定是最大的,因为i是递增的

                dp[i]=1;//本身也合法

                break;//没必要搜下去了,因为j是递减的

            }

        }//如果不匹配也不能直接break,因为后面再逐渐增加元素可能就变得可以匹配了

    }

    cout<<ans;

}

 

KMP

基本还是用dp判断每个前缀是否合法

通过枚举每个元素(相当于决策了),确定 f [ i ] ,不难得出状态转移方程:

f [ i ] = f [ i ] | | f [ i - m [ j ] ]

其中j是枚举的元素下标,m数组记录每个元素的长度。

不同之处就是在此处用KMP去处理每个元素在s的每个位置上是否出现,即预先匹配一遍

int c,n,m[205];

int pre[205][15];

//pre为最长公共真前缀后缀长度,因为这里有不止一个模式串,因此要开二维数组

bool pl[205][200005];

//pl[i][j]=true表示第i个字符串在以j为末尾处可以匹配上

string s,p[205];

bool f[200005];

inline void get_pre(int c,string p)  //KMP预处理的模版,c表示模式串编号

//求最长公共真前缀后缀

{

    int j=0;

    pre[c][1]=0;

    for(int i=2;i<=m[c];i++){

        while(j && p[i]!=p[j+1]) j=pre[c][j];

        if(p[i]==p[j+1]) j++;

        pre[c][i]=j;

    }

}

 

inline void KMP(string s,int c,string p)

{

    int j=0;

    for(int i=1;i<=n;i++){

        while(j && s[i]!=p[j+1]) j=pre[c][j];

        if(s[i]==p[j+1]) j++;

        if(j==m[c]){

            pl[c][i]=true;//记录位置

            j=pre[c][j];

        }

    }

}

 

int main()

{

    while(cin>>p[++c] && p[c]!="."){

        m[c]=p[c].length(); //m为了用来记录长度

        p[c]='#'+p[c];//为了使字符串从1开始

    }

    c--;//"."不算

    string x;

    while(cin>>x) s+=x;//注意可能不止一行

    n=s.length();

    s='#'+s;

    for(int i=1;i<=c;i++){

        get_pre(i,p[i]);

        KMP(s,i,p[i]);

    } //预处理

    f[0]=true;//给个true要不然会爆

    for(int i=1;i<=n;i++){

        for(int j=1;j<=c;j++){

            if(pl[j][i]) f[i]=f[i]||f[i-m[j]];

        }

    }

    for(int i=n;i>=0;i--){ //逆序看,只要当前i可行,那小于i也一定可行

        if(f[i]){

            cout<<i<<endl;

            return 0;

        }

    }

    return 0;

}

 

exKMP,kmp扩展

对于一个长度为n的字符串s,定义函数z[i]表示s和s[i,n-1](也就是以s[i]开头的后缀)的最长公共前缀(LCP)的长度,则z被称为s的Z函数,特别地,z[0]=0

线性算法:
如同大多数字符串主题介绍的算法,关键在于利用自动机的思想寻找限制条件下的状态转移函数,使得可以借助之前的状态来加速新的状态

也就是在计算z[i]的过程中,利用已经计算好的z[0]...z[i-1]

实际应用的时候,不一定需要与s取LCP,可以用于在线性时间内处理出文本串a的所有后缀和模式串b的最长公共前缀

nxt数组,nxt[i]表示模式串b与模式串b以b[i]开头的后缀的最长公共前缀的长度

假设nxt[i]已经得到,现在要求nxt[x]的值

对于所有的0<i<x,找到i+nxt[i]-1的最大值,设k为这个最大值对应的i,定义p为k+nxt[k]-1,p就是目前匹配到的最大下标,因为当i=0时,后缀和原串是完全相同的,因此没有必要去计算,于是将k的初值设定为1

显然根据定义可以得到,在模式串b中,[0,nxt[k]-1]和[k,p]两段是相等的

现在要求模式串中[x,n-1]与[0,n-1]的最长公共前缀

因为k<x,显然可以得到b[x-k,nxt[k]-1]和b[x,p]也是相等的

定义l为nxt[x-k],可以得到[0,l-1]=[x-k,x-k+l-1]=[x,x+l-1]

如果x+l-1<p,也就是x+l<=p,那么可以确定nxt[x]=l

但如果x+l-1>p,那么不能保证在[nxt[k]-1,x-k+l-1]范围的字符和[p,x+l-1]中的相同,因为超过了由k得到的前缀部分,此时就要逐位比较了

因为p是已经处理过的最大下标,因此可以直接从p-x+1和p+1进行逐位比较,从而求出nxt[x]的值,此时的x+nxt[x]-1一定刷新了最大值,于是要重新赋值k,在这整个过程中,p是单增的

ext数组

用ext[i]表示模式串b与文本串a以a[i]开头的后缀的最长公共前缀的长度

同样,假设ext[i]的值均已求出,现在要求ext[x]的值,并且找到i+ext[i]-1的最大值,设k为这个最大值对应的i,p为k+ext[k]-1,l为nxt[x-k],通过与nxt类似的推导方式就可以得到ext的代码

 

应用:
匹配所有子串

将t称作文本,将p称作模式,问题就是在文本t中模式p的所有出现

构造一个新的字符串s=p+#+t,将p和t连接在一起

首先计算s的Z函数

然后对于所有k=z[i+|p|+1],其中i属于[0,|t|-1],如果k=|p|就说明有一个p的出现位置位于t的第i个位置

 

本质不同的子串数

给定一个长度为n的字符串s,计算s的本质不同子串的数目

考虑计算增量,即在知道当前s的本质不同子串数的情况下,计算出在s末尾添加一个字符后的本质不同子串数。

令k为当前s的本质不同子串数。我们添加一个新的字符c至s的末尾。显然,会出现一些以c结尾的新的子串(以c结尾且之前未出现过的子串)。

设串t是s的反串(反串指将原字符串的字符倒序排列形成的字符串)。我们的任务是计算有多少t的前缀未在t的其他地方出现。考虑计算t的Z函数并找到其最大值zmax。则t的长度小于等于zmax的前缀的反串在s中是已经出现过的以c结尾的子串。

所以,将字符c 添加至s 后新出现的子串数目为|t|-zmax

 

字符串整周期

给定一个长度为n的字符串s,找到其最短的整周期

考虑计算s的Z函数,则其整周期的长度为最小的n的因数i,满足i+z[i]=n

 

 

模板 P5410

给定两个字符串a,b,要求求出b的z函数数组z,以及b与a的每一个后缀的lcp长度数组p

constexpr int N=2e7+10;

i64 nxt[N],ext[N];

void qnxt(string c){

    int len=c.size();

    int p=0,k=1,l;

    nxt[0]=len;//0开始的后缀就是原串

    while(p+1<len && c[p]==c[p+1]) p++;//先逐位比较出 nxt[1] 的值

    nxt[1] = p;

    for(int i=2;i<len;i++){

        p=k+nxt[k]-1;

        l=nxt[i-k];

        if(i+l<=p) nxt[i]=l;//如果在已知相等的区间中,直接得到nxt[i]的值

        else{//否则在未知的区间逐位比较

            int j=max(0,p-i+1);

            while(i+j<len && c[i+j]==c[j]) j++;

            nxt[i]=j;

            k=i;//显然此时的x+nxt[x]-1大于之前的,因此更新k

        }

    }

}

void exkmp(string a, string b){

    int la=a.size(),lb=b.size();

    int p=0,k=0,l;

    while(p<la && p<lb && a[p]==b[p]) p++;//先算出初值用于递推

    ext[0]=p;

    for(int i=1;i<la;i++){//和nxt数组类似的思路

        p=k+ext[k]-1;

        l=nxt[i-k];

        if(i+l<=p) ext[i]=l;

        else{

            int j=max(0,p-i+1);

            while(i+j<la && j<lb && a[i+j]==b[j]) j++;

            ext[i]=j;

            k=i;

        }

    }

}

 

void solve(){

    string a,b;

    cin>>a>>b;

    int la=a.size(),lb=b.size();

    qnxt(b);

    exkmp(a, b);

    i64 ans=0;

    for(int i=0;i<lb;i++){

        ans^=(i+1)*(nxt[i]+1);

    }

    cout<<ans<<'\n';

    ans=0;

    for(int i=0;i<la;i++){

        ans^=(i+1)*(ext[i]+1);

    }

    cout<<ans<<'\n';

}  

 

 

 

AC自动机(模板P3796P3808P5357

AC 自动机是 以 Trie 的结构为基础,结合 KMP 的思想建立的自动机

可以用于解决多模式匹配问题

建立AC自动机的基本步骤

1.基础的tire结构,将所有模式串构成一棵Trie;

2.KMP的思想:对Trie树上所有节点构造失配指针

在此处,Trie中的节点表示的是某个模式串的前缀,或者也可以称为状态,一个节点表示一个状态,Trie的边就是状态的转移

KMP通过next数组来转移,AC自动机通过fail指针来辅助多模式串的匹配,并且这两者本质上的目的是类似的,都是在匹配失败之后,尽可能“挽回损失”,即在所有模式串(如果是KMP就是只有一个模式串)中找到能与当前进行匹配的模式串的后缀重叠长度越长越好的前缀的位置,我们在匹配失败后就转移到那个位置继续进行匹配

首先,显然第二层的所有失配指针直接指向trie树的根节点,其次,其余层的失配节点会沿着父节点的失配指针一直向上,直到有一个失配指针所指向的状态,有指向与当前在寻找失配指针的节点的字母相同的节点(最长(只这是父节点失效后所能匹配的最长前缀)加能延长,那么那个节点一定是当前在寻找节点的最长),此时该节点的失配指针就指向这一个节点

因此,可以说求失配指针是一个BFS的过程,相当于每次利用失配指针跳跃到一个节点之后都要遍历那个节点的子节点,并且是逐层扩展的,因为要用到父节点的失配指针

并且事实上,我们并不需要每一次求失配指针都要沿着之前的失配指针走一遍

因为字典树的构建确保了相同前缀的合并,并且模式串又一定是从根节点开始的(或者说可供选择的路径只能从根节点开始),所以如果父节点的失配指针所指向的节点不存在与当前节点字母相同的子节点,那么在Trie树上也就一定没有一条路径(一个模式串的前缀)能够匹配到这个后缀了(想象父节点和失配指针对齐后再往后进行匹配),因此要像next指针一样,缩小后缀长度后(即当前这个后缀的后缀,也就是失配指针指向的节点的失配指针,这个操作在下面的else中完成),继续尝试寻找

通过把Trie树补全,变成Trie图的方式

#include <bits/stdc++.h>

#define ll long long

using namespace std;

struct Tree//字典树

{

     int fail;//失配指针

     int vis[26];//子节点的位置

     int end;//标记有几个单词以这个节点结尾

}AC[1000000];//Trie树

int cnt=0;//Trie的指针

inline void build(string s)//字典树的构建

{

    int l=s.length();

    int now=0;//字典树的当前指针

    for(int i=0;i<l;++i)//构造Trie树

    {

        if(AC[now].vis[s[i]-'a']==0)//Trie树没有这个子节点

          AC[now].vis[s[i]-'a']=++cnt;//构造出来

        now=AC[now].vis[s[i]-'a'];//向下构造

    }

    AC[now].end+=1;//标记单词结尾

}

void get_fail()//构造fail指针

{

    //利用bfs去构建,因此创建一个队列

    queue<int> Q;//队列

    for(int i=0;i<26;++i)//第二层的fail指针提前处理一下,一定指向根节点

    {

        if(AC[0].vis[i]!=0)

        {

            AC[AC[0].vis[i]].fail=0;//指向根节点

            Q.push(AC[0].vis[i]);//压入队列

        }

    }

    while(!Q.empty())//bfs求fail指针

    {

        int u=Q.front();

        Q.pop();

        for(int i=0;i<26;++i)//枚举所有子节点

        {

            if(AC[u].vis[i]!=0)//存在这个子节点

            {

                AC[AC[u].vis[i]].fail=AC[AC[u].fail].vis[i];

                //子节点的fail指针指向当前节点的fail指针所指向的节点的相同子节点

                //如果没有这样的节点,按照下面的处理,就会继续跳到上一个失配指针

                Q.push(AC[u].vis[i]);//压入队列

            }

            else//不存在这个子节点

                AC[u].vis[i]=AC[AC[u].fail].vis[i];

                //当前节点的这个子节点指向当前节点fail指针的这个子节点

                /*可以看作是预处理操作吧,如果之后有节点的失配指针跳到了这个节点上,

                而那个节点有接下去的子节点,那么就注定不能再往下匹配了,

                因此要像next指针一样,继续跳转到第二长的前缀节点那里继续尝试匹配

                */

        }

    }

}

int AC_query(string s){//匹配

    int l=s.length();

    int now=0,ans=0;

    for(int i=0;i<l;++i){

        now=AC[now].vis[s[i]-'a'];//向下一层

        for(int t=now;t && AC[t].end!=-1;t=AC[t].fail){//循环求解,当前前缀中覆盖了多少个模式串

            ans+=AC[t].end;//ans记录的是有多少个模式串在文本串中出现过

            AC[t].end=-1;//表示已经记录过了,只统计种类,因此记录一次之后就不再记录了

        }

    }

    return ans;

}

int main(){

    int n;

    string s;

    cin>>n;

    for(int i=1;i<=n;++i){//读入模式串

        cin>>s;

        build(s);

    }

    AC[0].fail=0;//结束标志

    get_fail();//求出失配指针

    cin>>s;//文本串

    cout<<AC_query(s)<<endl;

    return 0;

}

 

统计出现次数(P3796)

#include <bits/stdc++.h>

#define ll long long

using namespace std;

struct tree{

    int fail;

    int vis[26];

    int end;

}ac[100005];

 

int cnt=0;

inline void clean(int x){//因为在同一组测试数据中有多个输入,因此要清空

    memset(ac[x].vis,0,sizeof(ac[x].vis));

    ac[x].fail=0;

    ac[x].end=0;

    //碰到一个需要清空的点就清空一个点

}

inline void build(string s,int num){//要记得字典树构建的是模式串

    //因为这题要记录的是一个模式串出现了几次,并且保证了模式串的不同,

    //因此要记录的是以该单词结尾的模式串是哪一个,之后查询的时候要将其统计到对应的node上

    int l=s.length();

    int now=0;

    for(int i=0;i<l;i++){

        if(ac[now].vis[s[i]-'a']==0){

            ac[now].vis[s[i]-'a']=++cnt;

            clean(cnt);

        }

        now=ac[now].vis[s[i]-'a'];

    }

    ac[now].end=num; //编号num的模式串在此处结束了

}

void get_fail(){

    queue<int> q;

    for(int i=0;i<26;i++){

        if(ac[0].vis[i]!=0){

            ac[ac[0].vis[i]].fail=0;

            q.push(ac[0].vis[i]);

        }

    }

    while(!q.empty()){

        int x=q.front();

        q.pop();

        for(int i=0;i<26;i++){

            if(ac[x].vis[i]!=0){

                ac[ac[x].vis[i]].fail=ac[ac[x].fail].vis[i];

                q.push(ac[x].vis[i]);

            }

            else{

                ac[x].vis[i]=ac[ac[x].fail].vis[i];

            }

        }

    }

}

struct node{

    int num,pos;

}ans[100005];

void ac_query(string s){//匹配

    int l=s.length();

    int now=0;

    for(int i=0;i<l;i++){

        now=ac[now].vis[s[i]-'a'];

        for(int t=now;t;t=ac[t].fail){

            //不会出现重复记录的问题,因为每次跳转都是针对当前节点进行跳转的,不会说沿着当前前缀的路径跳转

            ans[ac[t].end].num++;//对应模式串的出现次数+1

        }

    }

    return;

}

bool operator <(node x,node y){

    if(x.num==y.num){

        return x.pos<y.pos;

    }

    return x.num>y.num;

}

string s[100005];

int main(){

    int n;

    while(1){

        cin>>n;

        if(!n) break;

        clean(0);

        for(int i=1;i<=n;i++){

            cin>>s[i];

            ans[i].num=0,ans[i].pos=i;

            build(s[i],i);

        }

        ac[0].fail=0;

        get_fail();

        cin>>s[0];

        ac_query(s[0]);

        sort(&ans[1],&ans[n+1]);

        cout<<ans[1].num<<endl;

        cout<<s[ans[1].pos]<<endl;

        int i=2;

        while(i<=n && ans[i].num==ans[i-1].num){

            cout<<s[ans[i].pos]<<endl;

            i++;

        }

    }

    return 0;

}

 

如果要统计次数,并且模式串重复有影响

 

字符串子序列问题

牛客24多校2 H

题意:给定一个WASD的串,选其中某个连续子串,要求经过(x,y)

如果可以对于每个起点i,求出最小的j使得操作串[i,j]后经过(x,y)就好了,这样以i为起点的串有n-j+1个符合条件

先遍历整个串,把所有经过的坐标以及经过这个坐标的时间标记存起来。

对于(x,y),我只需要在这个数据结构里查询这个坐标是否存在,如果存在,返回经过这个坐标的最小时间标记

之后询问其他位置时,可以把t=1的那个点从数据结构里删掉,然后让数据结构里面的所有点平移,但实际上只需要打一个全局懒标记就行了,或者让坐标(x,y)进行相对运动

特别的,对于整数类型的直角坐标系上点的位置(x,y),可以利用i64类型进行哈希映射

具体的方式是i64 hash=(x<<32)+y,因为位运算的优先级,一定要加上括号

这种哈希映射可以保证不冲突同时正负都可行

void solve(){

    i64 n,x,y;

    cin>>n>>x>>y;

    unordered_map<i64, vector<int>> ti;

    string s;

    cin>>s;

    if(x==0 && y==0){

        cout<<(n+1)*(n)/2<<'\n';

        return;

    }

    ti[0].emplace_back(0);

    i64 dx=0,dy=0;

    i64 ans=0;

    for(int i=0;i<n;i++){

        if(s[i]=='W'){

            dy++;

        }else if(s[i]=='S'){

            dy--;

        }else if(s[i]=='A'){

            dx--;

        }else{

            dx++;

        }

        // cout<<i<<' '<<dx<<' '<<dy<<'\n';

        i64 ha=(dx<<32)+(dy);

        ti[ha].emplace_back(i);

    }

    i64 ha=(x<<32)+(y);

    auto it=lower_bound(ti[ha].begin(),ti[ha].end(),-1);

    if(it!=ti[ha].end()){

        // cout<<i+1<<' '<<*it<<'\n';

        ans+=n-*it;

    }

    for(int i=0;i<n;i++){

        if(s[i]=='W'){

            y++;

        }else if(s[i]=='S'){

            y--;

        }else if(s[i]=='A'){

            x--;

        }else{

            x++;

        }

        // cout<<i<<' '<<x<<' '<<y<<'\n';

        ha=(x<<32)+(y);

        auto it=lower_bound(ti[ha].begin(),ti[ha].end(),i);

        if(it!=ti[ha].end()){

            // cout<<i+1<<' '<<*it<<'\n';

            ans+=n-*it;

        }

    }

    cout<<ans<<'\n';

}

 

牛客24多校7 K

给定两个字符串S,T,求S中有多少本质不同的子序列,包括前缀T并且要求该子序列的反串也同时包含前缀T

序列自动机:在一个字符串中找到一个满足要求的子序列,那么就是贪心地从前往后遍历寻找,对于每一个目标串中的字符,将第一个能够匹配的位置记录下来

那么对于这题,因为相当于对于序列的前后都有要求,于是我们可以分两次进行上述的操作,首先先贪心,从S的前缀中去找一个子序列T,找到能够包含该子序列且最短的前缀位置,然后反过来从后往前找能包含该子序列且最短的后缀位置,而这两个位置中间的部分就是可以任意选择的

(从这些第一次出现的位置开始往后拼接的)

如果这两个位置没有交叉,首先答案至少是从这两个位置切开,剩余部分本质不同的子序列数目,这是一个经典字符串dp(子序列自动机dp)

而如果这两个位置出现了交叉,因为我们选择的都已经是必须选择的部分,因此将构造出一个回文串,

因为可能的回文情况是有限的,因此考虑直接枚举重叠的回文后缀部分的长度,并检查s中是否有对应子序列出现计科,对于某个充电的回文后缀部分的长度发现只要有下列条件即有贡献:

T[m-l+1:m]是回文的,可以用哈希判断

s的开头匹配前缀T[1:m-l]的位置为p1,s的尾部匹配T的位置为p2,有p1<p2成立,可以预处理各个匹配的位置

(可以直接用manacher找到包含t串末尾的回文串的长度,这些长度可能就是上述的重叠部分)

const int mod=1e9+7;

vector<int> manacher(string t){//返回包含字符串末尾的回文串的长度

    string s="?#";

    for(auto ch:t){

        s+=ch;

        s+='#';

    }

    s+='!';

    vector<int> p(s.size()),len;

    int mr=0,mid=0,ans=0;

    for(int i=1;i+1<s.size();i++){

        if(i<=mr) p[i]=min(p[mid*2-i],mr-i+1);

        else p[i]=1;

        while(s[i-p[i]]==s[i+p[i]]) p[i]++;

        if(i+p[i]>mr){

            mr=i+p[i]-1;

            mid=i;

        }

        if(i&1){

            if(p[i]==1) continue;

            int l=p[i]/2;

            int id=(i-1)/2;

            if(id+l>=t.size()) len.emplace_back(l*2);

        }else{

            int id=i/2-1;

            if(id+(p[i]-1)/2+1>=t.size()){

                len.emplace_back(p[i]-1);

            }

        }

    }

    return len;

}

 

inline int cal(string s){//本质不同非空子序列数量

    int n=s.size();

    s=" "+s;

    vector<i64> dp(n+1),lst(26);

    for(int i=1;i<=n;i++){

        dp[i]=dp[i-1];

        if(!lst[s[i]-'a']){

            (dp[i]+=dp[i-1]+1)%=mod;

        }else{

            (dp[i]+=(dp[i-1]-dp[lst[s[i]-'a']-1])%mod+mod)%=mod;

        }

        lst[s[i]-'a']=i;

    }

    return dp[n];

}

void solve(){

    int n,m;

    cin>>n>>m;

    string s,t;

    cin>>s>>t;

    auto len=manacher(t);

    s=' '+s;

    t=' '+t;

    vector<int>l,r;

    for(int i=1,j=1;i<=n;i++){

        if(j<=m && s[i]==t[j]){

            l.emplace_back(i);

            j++;

        }

    }

    for(int i=n,j=1;i>=1;i--){

        if(j<=m && s[i]==t[j]){

            r.emplace_back(i);

            j++;

        }

    }

    if(l.size()<m || r.size()<m){

        cout<<0<<'\n';

        return;

    }

    int ans=0;

    if(l[m-1]<r[m-1]){//不交叉直接计算

        string ss="";

        for(int i=l[m-1]+1;i<=r[m-1]-1;i++){

            ss+=s[i];

        }

        ans+=cal(ss)+1;//内部是空串也是一种方案

    }

    for(auto it:len){

        if(it==m){

            ans++;

        }else{

            ans+=(l[m-it-1]<r[m-1]);

            //通过预处理的位置判断该回文串是否可行

        }

    }

    cout<<ans%mod<<'\n';

}

 

 

 

后缀自动机(SAM)

(整体来说有点像字典树,同时也有类似失配指针的结构)

字符串s的sam是一个可以接受s所有后缀的最小dfa(有限状态自动机)

sam最常用的一个功能是存储一个文本串s的每一个子串,同时可以在线性时间内解决:在另一个字符串中搜索一个字符串的所有出现位置,计算给定的字符串中有多少个不同的子串

直观上,字符串的sam可以理解为给定字符串的所有子串的压缩形式

对于长度为O(n)的字符串s,sam可以在o(n)的空间中存储s的所有子串,并且预处理的时间复杂度为o(n)

任意从初始状态t0开始的路径,如果将转移路径上的标号写下来,都会形成s的一个子串,反之每个s的子串

因为每个后缀对应了dag上的一条路径,那么沿着一个后缀的路径走若干条边得到的就是后缀的前缀,一个字符串所有后缀的所有前缀显然就是这个字符串的所有子串,反之每个s的子串对应从t0开始的某条路径

结束位置endpos

对于字符串s的任意非空子串t,记endpos(t)为在字符串s中t的所有结束位置(令字符编号从0开始),例如字符串abcbc,endpos(bc)=2,4

两个子串的endpos集合可能相同,这种情况我们称这两个子串等价,于是字符串s的所有非空子串都可以按照endpos集合被分为若干等价类,同样,sam中的状态数等于所有子串的等价类的个数+1(初始状态,也就是空串的情况)

根据上面的定义我们可以得到一些性质:字符串s的两个非空子串u和w的endpos相同,当且仅当字符串u在s中的每次出现,都是以w的后缀形式存在;如果u不是w的后缀,那么一定有u的endpos集合和w的endpos集合的交集为空集;在一个endpos类中,将类中所有子串按长度非递增的顺序排序,每个子串都不会比它前一个子串长,同时每个子串也是前一个子串的后缀

考虑sam中某个不是t0的状态v,已知v对应于具有相同endpos的等价类,如果定义w位这些字符串中最长的一个,那么所有其他字符串都是w的后缀,并且按顺序排列,一定有前几个后缀包含于这个等价类,而其他后缀存在于另外的等价类中,令t为最长的后缀,然后将v的后缀链接到t上,也就是说一个后缀链接link(v)连接到对应于w的最长后缀的另一个endpos等价类的状态

这样得到的一棵数也可以表示endpos集合间的包含关系

对于每一个状态v,记longest(v)为其中最长的一个字符串,记len(v)为它的长度,类似的,记shortest(v)为最短的子串,它的长度为minlen(v),定义后缀链接为连接到对应字符串longest(v)的长度为minlen(v)-1的后缀的一条边

因此对于t0以外的状态v,可用后缀链接link(v)表达minlen(v):minlen(v)=len(link(v))+1

有了后缀链接之后,从任意状态v0开始顺着后缀链接遍历,总会到达t0状态

如(abcbc的sam,箭头表示后缀链接,可以发现状态机中的路径包含了所有子串)

 

进一步能得到状态机

 

sam是在线算法,可以逐个加入字符串中的每个字符,并在每一步中对应维护sam

为了保证线性的空间复杂度,只保存len和link的值和每个状态的转移列表

添加字符的过程:

1.令 last为添加 c 之前整个字符串 S 所对应的结点(从 P 出发走到 last的路径上边组成的串是 S ,一开始设 last=0 ,算法的最后一步更新它)。

2.创建一个新的状态 cur,并将 len(cur)赋值为 len(last)+1

3.从 last开始遍历后缀链接,如果当前结点 v有标记字符 c 的出边,创建一条 v→cur的边,标记为 c。

(因为sam的工作是记录串的每一个后缀,现在新串是s+c,那么只要在原来记录的每一个包含尾节点的后缀后面再添加一个c即可,那么根据上面的内容,我们从last开始通过link进行移动,就能找到所有原串s的后缀,同时加边(不是添加后缀链接)连向cur)

4.如果遍历到了 P,赋值 link(cur)=0,转8 。

(说明加入的字符c在之前的串中没有出现过,也就是s中不存在s+c的任何一个非空后缀,因此link应当连向空串)

5.如果当前结点 v 已经有了标记字符 c的出边,停止遍历,并把这个结点标记为 p ,标记 p沿着标记字符 c 的出边到达的点为 q。

6.如果 len(p)+1=len(q),赋值 link(cur)=q,转 8 。

7.否则情况会很复杂。

复制状态 q到一个新的状态 copy里(包括 link(q) 以及所有 q在 DAG 上的出边),将 len(copy) 赋值为 len(p)+1 。

复制之后,再赋值 link(q)=copy,link(cur)=copy。

然后从 p遍历后缀链接,设当前遍历到的点为 v,若 v有标记为 c的出边 v→q ,则重定向这条边为 v→copy。

若 v没有标记为 c的出边或者 v的标记为 c的出边所到达的点不是 q,停止遍历,转 8 。

(说明:
         从Last开始按照后缀链接进行移动,每一次访问到的某个等价类p中,p中包含的所有字符串都是原串s的后缀,并且long(p)递减,于是long(p)+1的长度也递减

从last跳后缀链接,第一次遇到的拥有c的出边的节点为p,而p经过c的边能到达q

(以上面的为例,假设在abcbc后面再加一个b,abcbc跳后缀链接到了bc,bc原先就有一条边权值为b,表明原串中有子串bcb)

已知long(p)是原串s的后缀,因此long(p)+c是新串的后缀,因为long(p)+c长度的单调递减性质,我们找到的第一个串long(p)+c对于long(cur)来说就是最长的不和cur属于同一个等价类的(因为在原串中能出现,那么其endpos的元素一定比cur多),于是根据后缀链接的定义这就是link(cur)

但因为link应当连向昂最长串是long(p)+c的节点,虽然能够保证q中包含串long(p)+c,但不能保证这是该等价类中的最长串,再以上面的威力,bc有边b,但连向的是abcb,并不是我们需要的bcb,因此我们要将long(p)+c以及在q中的所有后缀从q中拿出,使其单独构成一个新的等价类,再将其与cur进行连接)

8.把 last赋值为 cur,转 1 。

 

应用:
检查字符串是否出现:
给一个文本串t和多个模式串p,检查p是否作为t的一个子串出现

首先对t构造后缀自动机,通过转移边(next)从t0开始根据p的字符进行转移,如果在某个点不能继续转移,则p不是t的一个子串

不同子串的个数:
每个s的子串都相当于自动机中的一些路径,因此不同子串的个数等于自动机中以t0为起点的不同路径的条数

因为sam是有向无环图,因此不同路径的条数可以通过动态规划计算,dp[v]=sigma(dp[w])+1,w是状态v的下一个状态,同时不同子串的个数为dp[t0]-1(减去空串)

后缀自动机 (SAM) - OI Wiki (oi-wiki.org)(其余问题)

 

杭电24多校2 1011

先用kmp字符串匹配方法判断b’中是否包含c,筛选出合法的b,再在字符串a中利用后缀自动机找b是否出现过

void solve(){

    int n;

    cin>>n;

    //a是主串,b是模式串;b'是主串,c是模式串

    string a,c;

    cin>>a>>c;

    bool first=true;

    SAM sam;

    int p=1;

    for(auto k:a){

        p=sam.extend(p,k);

    }

    const int l=c.size();

    vector<int> f(l+1);//kmp的nxt数组,对模式串进行处理

    for(int i=1,j=0;i<l;i++){

        while(j && c[i]!=c[j]){

            j=f[j];

        }

        j+=(c[i]==c[j]);

        f[i+1]=j;

    }

    for(int i=1;i<=n;i++){

        string b,b0;

        cin>>b>>b0;

        if(b0.size()<c.size()){

            continue;

        }

        int p=1;

        for(auto k:b){

            p=sam.next(p,k);

            if(p==0){

                break;

            }

        }

        if(p==0){//a中不存在b

            continue;

        }

        int j=0;

        bool ok=false;

        for(auto k:b0){//kmp匹配

            while(j>0 && k!=c[j]){

                j=f[j];

            }

            j+=(k==c[j]);

            if(j==l){

                ok=true;

                break;

            }

        }

        if(ok){//两个要求都满足了

            if(!first){

                cout<<' ';

            }

            first=false;

            cout<<i;

        }

    }

    cout<<'\n';

}

 

 

 

回文问题

T1457,排列后能构成回文路径的数字特征

长度为偶数,即出现次数为奇数的字符个数为 0 个

长度为奇数,即出现次数为奇数的字符个数为 1 个(位于中间)

而维护路径中出现数字次数的奇偶性可以用数组或位运算

用数组:从根节点开始递归,假设我们遇到了 5,如果 p[5]=0,那么把 p[5]从 0改成 1,否则从 1改成 0,这可以用「异或 1」实现,即 p[5] ^= 1(因为只在意奇偶性不在意具体次数)

节点值范围为 [1,9],除了使用固定大小的数组进行词频统计以外,还可以使用一个 int 类型的变量 cnt 来统计各数值的出现次数奇偶性:若 cnt的第 k 位为 1,说明数值 k的出现次数为奇数,否则说明数值 k 出现次数为偶数或没出现过,两者是等价的(即二进制压缩)

如果 mask中只有一个 1,那么去掉这个 1 之后,mask 就变成 0 了,所以可以用上「删除最小元素」的方法,判断mask&(mask−1)==0是否成立,如果成立则说明 mask中要么只有一个1,要么全为0

在一个数据范围内预处理回文数(T2967):简单的来想,可以利用左右对称的特性,枚举左半边,将左半边进行翻转后显然就能得到相应的回文数了,但注意一个数可能可以生成多个回文数(例如10,可以生成101,也可以生成1001)

严格按照从小到大生成回文数的方法:最外层循环相当于是生成回文数的长度,相当于是用来生成长度为奇数的回文数,相当于先去掉最低位后,再逐个加入当前的最低位(不用字符串去生成)

vector<int> pal;

 

auto init = [] {

    // 严格按顺序从小到大生成所有回文数(不用字符串转换,效率更高)

    for (int base = 1; base <= 10000; base *= 10) {

        // 生成奇数长度回文数

        for (int i = base; i < base * 10; i++) {

            int x = i;

//先去掉最低位(作为中间的数字),再逐个加入当前的最低位(逐个转移)

            for (int t = i / 10; t; t /= 10) {

                x = x * 10 + t % 10;

            }

            pal.push_back(x);

        }

        // 生成偶数长度回文数

        if (base <= 1000) {

            for (int i = base; i < base * 10; i++) {

                int x = i;

                for (int t = i; t; t /= 10) {

                    x = x * 10 + t % 10;

                }

                pal.push_back(x);

            }

        }

    }

    pal.push_back(1'000'000'001); // 哨兵,防止下面代码中的 i 下标越界

    return 0;

}();

 

cf 7d

字符串长度较小时,可以利用哈希去解决回文问题

在已经是回文的前提下判断回文阶数,就不需要再去考虑前后缀是否相同了

int n,dp[5000005],ans,fac=1,fro,bac;

for(int i=0;i<n;++i){

    fro=fro*13+c[i],bac=bac+fac*c[i],fac=fac*13;

    !(fro^bac)?ans+=(dp[i]=dp[i-1>>1]+1):0;

}

/*

fac计算base的幂

fro计算从前往后的哈希值

bac计算从后往前的哈希值,因为后缀不是逆序,因此需要乘上fac

这里是直接推过去的,也可以预处理一遍

*/

 

cf 1944D

对于题目所定义的类型可以考虑是正面判定比较快还是反面判定比较快

例如此处,对于k-good的定义,如果就是按照题目中的定义去判定,那么就是要遍历所有长度为k的子串去判断是否是回文串,因为任意询问都要进行遍历操作感觉算法效率不高

但如果是从反面去判断,即任意长度为k的子串都是回文串,这种全是的情况显然要求更强,并且判断回文的算法较多

并且因为所有该长度的子串都是回文,因此尝试找出一些规律

令k=j-i+1,从s[i...j]以及s[i+1...j+1]都是回文入手,可以推出si=si+2=si+4...,si+1=si+3...

同时,如果k是偶数,那么i和j的奇偶性不同,因此是错位相等,而如果k是奇数,那么就说明实际上需要整个数组元素完全相同

由此可以从反面得到一个字符串如果是k-good的条件:k=2~n-1,如果是奇数,那么应当不是交替字符串,如果是偶数,那么应当不是所有字符都相等,当k等于n,因为上面的规律都是通过至少两个子串都是回文推得的,因此对于原串的判断按照一般回文的判定即可

vector<int> manacher_odd(string s){

    int n=s.size();

    s='&'+s+'^';//增加边界

    vector<int> p(n+2);

    int l=1,r=1;

    for(int i=1;i<=n;i++){

        p[i]=max(0,min(r-i,p[l+(r-i)]));//判断能否根据对称得到初始值

        while(s[i-p[i]]==s[i+p[i]]){

            p[i]++;

        }

        if(i+p[i]>r){

            l=i-p[i],r=i+p[i];//更新

        }

    }

    return vector<int>(begin(p)+1,end(p)-1);//返回回文半径,删去边界

}

 

vector<int> manacher(string s){

    string t;

    for(auto c:s){

        t+=string("#")+c;

    }//将奇数和偶数情况同一计算

    auto res=manacher_odd(t+"#");

    return vector<int>(begin(res)+1,end(res)-1);//删去两侧的'#'边界

}

 

void solve(){

    ll n,q;

    cin>>n>>q;

    string s;

    cin>>s;

    auto v=manacher(s);

    for(auto &x:v) x--;

    set<ll> s1,s2;

    for(ll i=0;i<n-1;i++){

        if(s[i]!=s[i+1]) s1.insert(i);

        if(i!=n-1 && s[i]!=s[i+2]) s2.insert(i);

        //判断是否是全部相等或者交替相等

    }

    while(q--){

        ll l,r;

        cin>>l>>r;

        l--;r--;

        if(l==r){

            cout<<0<<'\n';

            continue;

        }

        ll len=r-l+1;

        ll ans;

        auto it=s1.lower_bound(l);

        if(it==s1.end() || (*it)>=r){//得不是交错相等/全相等才能计入答案

            ans=0;//说明在该段区间中偶数情况的k-good不存在

        }

        else{

            it=s2.lower_bound(l);

            if(it==s2.end() || (*it)>=r-1){

                ans=((len-1)/2)*(((len-1)/2)+1);

            }

            else{

                ans=len*(len-1)/2-1;//排除k=1的情况

            }

        }

        //判断k=n的情况

        if(v[l+r]<len) ans+=len;

        cout<<ans<<'\n';

    }

}

 

cf1981b
每秒与左右相邻的两个数进行或运算,m秒后,该位置实际上与(max(n-m,0),n+m)这个范围内的数进行了异或

从整体上看可以是找到l与r第一个不同的数,在这个数之前的数位一定相同,在这个数之后的因为是不断用1加上去的,因此一定能全部取到1

int kk = 32 - __builtin_clz(r);

    int k=-1;

    for(int i=kk;i>=0;i--){

        if(((l>>i)&1) != ((r>>i)&1)){

            k=i;

            break;

        }

    }

    ll ans=((r>>(k+1))<<(k+1))|((1<<(k+1))-1);

或者也可以通过枚举l的每一位是否能变成1(变成1后的数字是否在我们的变化范围内),来统计答案

 

Manacher算法

描述:给定一个长度为n的字符串s,请找到所有对(i,j)使得子串s[i...j]为一个回文串。当t=trev时,字符串t是一个回文串( trev是t的反转字符串)

关于回文串的信息可以用一种更紧凑的方式表述:对于每个位置i=0...n-1,我们找出值d1[i]和d2[i],分别表示以i为中心的长度为奇数和偶数的回文串个数,换个角度,二者也表示了以位置i为中心的最长回文串的半径长度(因为更长的半径能满足是回文串,那么半径减小的子串肯定也是回文串,一个单位长度对应一个回文串)

因此关键思路是,如果以某个位置i为中心,我们有一个长度为l的回文串,那么我们有以i为中心的长度为l-2 ,l-4 等等的回文串。所以d1[i]和d2[i] 两个数组已经足够表示字符串中所有子回文串的信息

朴素的解决方法就是利用两个循环,时间复杂度O(n2

vector<int> d1(n), d2(n);

for (int i = 0; i < n; i++) {

d1[i] = 1;

while (0 <= i - d1[i] && i + d1[i] < n && s[i - d1[i]] == s[i + d1[i]]) {

d1[i]++;

}

d2[i] = 0;

while (0 <= i - d2[i] - 1 && i + d2[i] < n && s[i - d2[i] - 1] == s[i + d2[i]]) {

d2[i]++;

}

}

Manacher算法

为了快速计算,我们维护已找到的最靠右的子回文串的(l,r)边界(即具有最大r值的回文串,其中l和r分别为该回文串左右边界的位置)。初始时,我们置l=0和r=-1(-1需区别于倒序索引位置,这里可为任意负数,仅为了循环初始时方便)

如果要对下一个i计算d1[i],而之前的d1都已经计算完毕,那么可以按如下进行计算:

如果i位于当前子回文串之外,即i>r,那么我们调用朴素算法

因此我们将连续地增加d1[i],同时在每一步中检查当前的子串[i-d1[i]...i+d1[i]](d1[i] 表示半径长度,下同)是否为一个回文串。如果我们找到了第一处对应字符不同,又或者碰到了s的边界,则算法停止。在两种情况下我们均已计算完  。此后,仍需记得更新(l,r) (因为我们的i是在不断向右移动的,因此能对我们产生帮助的是最能靠近i的那段回文串)。

现在考虑i<=r的情况。我们将尝试从已计算过的d1的值中获取一些信息。首先在子回文串(l,r)中反转位置i,即我们得到j=l+(r-i)。现在来考察值d1[j](因为对称性,j的情况和i是类似的,但是我们已经计算过j,因此就不需要重复计算i了),即d1[i]=d1[j]

但较为特殊的情况是对称的部分被消耗完了,即j-d1[j]+1<=l(即i+d1[j]-1>=r),因为回文串外部的对称性不能保证,因此不能直接令d1[i]=d1[j],对此,我们可以点到为止,先把可以得到的部分计入,即d1[i]=r-i,之后再用朴素算法尽可能增加d1[i]的值,此后,仍需记得更新(l,r) 。

计算d1

vector<int> d1(n);

for (int i = 0, l = 0, r = -1; i < n; i++) {

int k = (i > r) ? 1 : min(d1[l + r - i], r - i + 1);

while (0 <= i - k && i + k < n && s[i - k] == s[i + k]) {

k++;

}

d1[i] = k--;

if (i + k > r) {

l = i - k;

r = i + k;

}

}

d2的计算类似d1正如朴素算法中的写法,其l是i-k-1,因此其min中应该是d2[l + r - i + 1],判断中是s[i - k - 1] == s[i + k],修改中是l = i - k - 1;

可以通过一些技巧将d1,d2进行统一计算(有点类似于leetcode对于中位数计算时的技巧,即在任意两个元素中间加一个特殊字符)

并且要注意这里的i,j是最长回文子串的边界各加1,即是开区间而不是闭区间

 

kmp算法(也可用于字符串匹配)

核心是利用了状态机的思想,将一个需要匹配的字符串的各个下标当做状态,可以从一个状态进入下一个状态的条件是在字符串中进行匹配的下一个字符和该下标下一个字符元素相同,这样可以做到永不回溯主串的指针,并且利用已经得到的部分匹配,将待匹配字符串中的指针回退地尽量少

因此重点就在于如何刻画next数组(或者说dp的状态转移方程),当不能匹配时,在已经匹配的前缀中,查看是否有可以重复利用的部分,即ABABAA,当ABABA匹配完成时,下一个字符匹配失败时,并不是直接转移到0处重新匹配,而是转移到2处,因为前缀的ABA和后缀的ABA相同

自动机:
在实际匹配中,只需要存储字符串s+#和对应的前缀函数值,之后就可以动态计算对于之后所有字符的前缀函数值,并且只计算下一个时不需要用到任何其他的t字符和对应的前缀函数值,因此可以构造一个优先状态机,其状态为当前的前缀函数值,而从一个状态到另一个状态的转移则由下一个字符确定,这样即使没有实际的字符串也可以构造出一个转移表

void compute_automaton(string s, vector<vector<int>>& aut) {

s += '#';

int n = s.size();

vector<int> pi = prefix_function(s);

aut.assign(n, vector<int>(26));

for (int i = 0; i < n; i++) {

for (int c = 0; c < 26; c++) {

if (i > 0 && 'a' + c != s[i])

aut[i][c] = aut[pi[i - 1]][c];

else

aut[i][c] = i + ('a' + c == s[i]);

}

}

}

这样可以加速计算字符串s+#+t的前缀函数

代码实现(利用前后缀)

Next数组主要用来存放最大公共前后缀的长度值,此值定义为K值,即Next[]数组存储一组k值

int* KmpNext(string str)

{

    int* next = new int[str.size()];

    next[0] = 0;  //第一个不匹配一定是从头开始了

    for(int j = 1,k = 0; j < str.size(); j++)

    {

        while(k > 0 && str[k] != str[j])

        {

            k = next[k - 1];

        }

        if(str[j] == str[k])

        {

            k++;

        }

        next[j] = k;

    }

    return next;

}

kmp代码

int KMP(string s, string t,int* next)

{//s主串  t 模式串

    for(int i = 0, j = 0; i < s.size(); i++)

    {

 

        while(j > 0 && s[i] != t[j])

        {

            j = next[j - 1];    //下一个匹配位为next数组的第j-1位

        }

        if(s[i] == t[j])

        {

            j++;//主串通过i进行加1,模式串通过j加1

        }

        if(j == t.size())

        {

            return i-j+1;//返回匹配位置

        }

    }

    return -1;

}

另一套写法(在一些细节处进行了变化)

前缀函数:真前缀和真后缀相等的最长长度(注意并不是回文而是完全相同,这也是为什么在不匹配的我们可以从已经匹配的前缀(即此时的pai[i])中去再次寻找相等的真前缀和后缀,因为已经匹配的部分是相同的,即该子串的开头和结尾是相同的,因此等价于在这个子串中查找,又这个子串一定是包含在我们现在i所在的子串中,即它的前缀函数值是已知的,因此可以直接利用,又从下标与长度的关系是-1,且匹配时是从前一个子串的前缀函数值直接接下去判断的,因此不匹配时j=pai(j-1))

构造一个字符串s+#+t,其中#为一个既不出现在s中也不出现在t中的分隔符

现在考虑该前缀函数除去最开始n+1个值(即属于字符串s和分隔符的函数值)后其余函数值的意义。根据定义,pai[i]为右端点在i且同时为一个前缀的最长真子串的长度,具体到我们的这种情况下,其值为与s 的前缀相同且右端点位于i 的最长子串的长度。由于分隔符的存在,该长度不可能超过n 。而如果等式pai[i]=n成立,则意味着s完整出现在该位置(即其右端点位于位置i)。注意该位置的下标是对字符串s+#+t而言的。

因此如果在某一位置i有pai[i]成立,则字符串s在字符串t的i-(n-1)-(n+1)=i-2n处出现

前缀函数实现:

vector<int> prefix_function(string s) {

int n = (int)s.length();

vector<int> pi(n);

for (int i = 1; i < n; i++) {

int j = pi[i - 1];

while (j > 0 && s[i] != s[j]) j = pi[j - 1];

if (s[i] == s[j]) j++;

pi[i] = j;

}

return pi;

}
kmp实现:

vector<int> find_occurrences(string text, string pattern) {

string cur = pattern + '#' + text;

int sz1 = text.size(), sz2 = pattern.size();

vector<int> v;

vector<int> lps = prefix_function(cur);

for (int i = sz2 + 1; i <= sz1 + sz2; i++) {

if (lps[i] == sz2) v.push_back(i - 2 * sz2);

}

return v;

}

 

模板P3375:
KMP核心:即是用于确定失配后变化位置的数组

用 kmp 数组记录到它为止的模式串前缀的真前缀和真后缀最大相同的位置

换言之kmp[j]表示当匹配到b数组的第j个字母而第j+1个字母不能匹配了时,新的j最大是多少。kmp[j]应该是所有满足b[1..kmp[j]]=b[j-kmp[j]+1..j]的最大值,回退到那个j之后,再用j+1去判断

int kmp[1000005]{};

int la,lb;

string a,b;

//a为文本串,b为模式串

int main()

{

    cin>>a>>b;

    la=a.length();

    lb=b.length();

    a='1'+a;

    b='1'+b;

    int j=0; //kmp[j]的j表示的是前缀的长度

    //相当于b和自己匹配,因为是匹配,所以是当前指向位置的下一个去判断

    for (int i=2;i<=lb;i++){ //因为是前缀和后缀相等,因此自然不是从同一位置开始匹配

       while(j && b[i]!=b[j+1]) j=kmp[j];    

       if(b[j+1]==b[i]) j++;    

       kmp[i]=j; //i是当前的前缀长,j是当前前缀的真前缀

    }

    j=0;

    for(int i=1;i<=la;i++)

       {

          while(j && b[j+1]!=a[i]) j=kmp[j];

          if (b[j+1]==a[i]) j++;

          if (j==lb) {cout<<i-lb+1<<endl;j=kmp[j];}

       }

 

    for (int i=1;i<=lb;i++)

        cout<<kmp[i]<<" ";

    return 0;

}

 

 

字符串操作问题:
1941C

要操作次数最小,显然可以想到我们对于匹配到的合适的字符串删除中间的元素是最优的,但特殊情况是”mapie”,如果我们是遍历搜索,即

string ss=" ";

    int top=1;

    ss+=s[1];

    for(int i=2;i<=n-1;++i){

        string k="";

        k+=ss[top];k+=s[i];k+=s[i+1];

        if(k=="map" || k=="pie"){

            continue;

        }else{

            ss+=s[i];

            top++;

        }

    }

这种情况我们会记录两次操作,但实际上,我们对于这种情况只需要进行一次操作即可

并且我们进行删除操作时,并不需要像上面那样再单独开一个字符串记录未被删除的字符,而是可以直接在原来的字符串上进行操作,例如直接将其改为一个正常情况下不会出现的字符,如”?”

void solve() {

    ll n;

    cin >> n;

    string s;

    cin >> s;

    vector<long long> de;                

    for (string sul : {"mapie", "pie", "map"}) {//直接枚举需要进行操作的子串情况,这个语句实际上就是string a:ss的写法

        for (size_t pos = 0; (pos = s.find(sul, pos)) != string::npos;) {//当还能找到时就一直操作

        //npos可以表示string的结束位子,是string::type_size 类型的,也就是find返回的类型。find函数在找不到指定值得情况下会返回string::npos

        //string中的find函数传入的第一个参数是需要查找的字符,第二个是开始查找的位置

            s[pos + sul.length() / 2] = '?';

            de.push_back(pos + sul.length() / 2);

        }

    }

    cout << de.size() << endl;

}

或者可以是直接令i移动两个单位,因为i之前的情况已经判断完毕肯定不会构成所需要的字符串了

 

mex运算
CF1935B

将数组分成k个子段,使得每个子段的mex相同,k只要求>2

假设我们正确地将数组划分为 k>2 段(1,r1),(l2,r2),…,(lk,rk)段。那么我们可以合并前两个分段,因为在这两个分段中会遇到从 0 到 mex−1的数字,而不会遇到数字 mex 。因此,如果可以划分为 k>2个分段,那么一定可以划分为 k>2 个分段。

因此,只需检查数组是否可以分为 k=2段即可,在 O(n)中可以通过对前缀和后缀进行 MEX计算,然后我们需要为 MEX(1,i)=MEX(i+1,n) 找到一个 i即可

 for (int i = 0; i < n; ++i) {

        cin >> a[i];

        cnt2[a[i]]++;

    }

    int mex1 = 0, mex2 = 0;

    while (cnt2[mex2])//mex2存在,自然其不是整个数组的mex

        ++mex2;

    for (int i = 0; i < n; ++i) {

        cnt1[a[i]]++;//在数组前面部分中的元素的出现次数

        if (--cnt2[a[i]] == 0 && mex2 > a[i]) {//如果a[i]在之后的数组中不再出现,并且整个数组的mex大于a[i]

            mex2 = a[i];//对于之后的数组来说,mex即是a[i]

        }

        while (mex2 && !cnt2[mex2 - 1])

            --mex2;//重新判断之后的数组中的mex

        while (cnt1[mex1])

            ++mex1;

        if (mex1 == mex2) {//说明已经可以划分成两段了

 

对于添加一个数到集合中;查询这个集合的mex;从集合中删除一个数之类的操作可以可以维护一个set,表示未出现的数的集合,初始set=[0,1,2,3,4......],当添加一个新的数,他就出现过了,所以我们把它从set中删除。如果删除操作使得一个数没有出现过,就把他加添会set。那么查询mex就直接查询set中的最小值即可

cin >> op;

        if (op == 0) {//添加

            cin >> x;

            if (cot[x] == 0) {

                st.erase(x);

            }

            cot[x]++;

        }

        else if (op == 1) {//查询

            cout << *st.begin() << endl;

        }

        else {//删除

            cin >> x;

            if (cot[x] == 1) {

                st.insert(x);

            }

            cot[x]--;

        }

    }

 

Cf 2066B

给定一个序列,一个序列是神奇的当且仅当对于任意i,min{a[0],..,a[i]}>=mex{a[i+1],..,a[n-1]}

求一个序列的最大神奇子序列

显然,没有0的序列是神奇的,拥有超过两个0的序列一定是不神奇的,有且仅有一个0的序列,若0存在于i及之前,那么这个i一定是符合要求的

换言之,答案只可能是n-cnt[0]或者n-cnt[0]+1

要最大就考虑n-cnt[0]+1能不能取到,也就是检查能不能加入一个0

首先可以想到一定只需要判断加入0的左侧好了,因为对于一个序列,min和mex都是可以在O(n)复杂度时间内得到的,同时若能够加入这个0,那么这个0左侧的所有位置都要有min>=mex,因此预处理出min和mex,然后枚举判断即可

void solve() {

    int n;

    cin>>n;

    vector<int> a(n);

    int ans=n;

    for(int i=0;i<n;i++){

        cin>>a[i];

        if(a[i]==0){

            ans--;

        }

    }

    auto mn=a;

    for(int i=1;i<n;i++){

        mn[i]=min(a[i],a[i-1]);

    }

    vector<int> mex(n+1);

    mex[n]=1;

    unordered_map<int,int> mp;

    //[0~i].min  [i+1,n].mex

    for(int i=n-1;i>=0;i--){

        mex[i]=mex[i+1];

        mp[a[i]]++;

        while(mp[mex[i]]){

            mex[i]++;

        }

        // if(a[i]==mex[i+1]){

        //     mex[i]=mex[i+1]+1;

        // }else{

        //     mex[i]=mex[i+1];

        // }

    }

    int flag=1;

    for(int i=0;i<n;i++){

        if(flag && a[i]==0){

            cout<<ans+1<<'\n';

            return;

        }

        flag &= (mn[i]>=mex[i+1]);

    }

    cout<<ans<<'\n';

}

 

注意mex的计算,需要加上一个cnt数组来处理

 

区间查询mex(P4137)

以 为大小进行分块,用cot[i]记录i出现了多少次,用sum[i]来表示第i个块中出现了多少个数,那么就能在O(1)时间内修改cot,sum

查询的时候,找到第一个没有放满的块,这个操作的时间复杂度是O( ),然后在这一块中暴力地去找,复杂度同样是O( )

int sum[500],cot[N], ans[N],a[N],n, m;

struct query {

    int l, r, id;

} Q[N];

bool cmp(query& a, query& b) {

    if (a.l / sq != b.l / sq)

        return a.l < b.l;

    if ((a.l / sq) & 1)

        return a.r < b.r;

    return a.r > b.r;

}

inline void add(int p){

    cot[p]++;

    if (cot[p] == 1)sum[p / sq]++;

}

inline void del(int p){

    cot[p]--;

    if (cot[p] == 0)sum[p / sq]--;

}

inline int que() {

    for (int i = 1; i <= sq; i++) {

        if (sum[i - 1] != sq) {

            for (int j = (i - 1) * sq; j < i * sq; j++) {

                if (!cot[j])return j;

            }

        }

    }

}

 

或者可以利用权值线段树,从1到n开始,不断的添加a[i]进去,权值线段树维维护某个数,最后一次出现在数组中的位置,初始所有的数都没有出现

这时,对于所有R=i的询问,可以利用L进行查询

即找到最后一次出现的位置小于L的最小的数

struct node {

    int mi = 0;

    int l, r;

}tr[N << 2];

void pushup(int x) {

    tr[x].mi = min(tr[ls].mi, tr[rs].mi);

}

//权值线段树存储的是值域范围内的数,mi维护的这个数在序列中最后一次出现的位置

//那么区间mi维护的就是该区间中的元素最后一次出现位置的最小值

void build(int x, int l, int r) {

    tr[x].l = l, tr[x].r = r;

    if (l == r)return;

    int mid = (l + r) / 2;

    build(ls, l, mid), build(rs, mid + 1, r);

    pushup(x);

}

void upd(int x, int pos, int v) {

    if (tr[x].l == tr[x].r) {

        tr[x].mi = v;

        return;

    }

    int mid = (tr[x].l + tr[x].r) / 2;

    if (pos <= mid)upd(ls, pos, v);

    else upd(rs, pos, v);

    pushup(x);

}

int que(int x, int L) {

    if (tr[x].l == tr[x].r)return tr[x].l;

    if (tr[ls].mi < L)return que(ls, L);//在该区间内出现的数,至少有一个出现的位置小于L,因此在左子树内找

    else return que(rs, L);

}

int n, m, a[N], ans[N];

vector<pair<int, int>>g[N];

void solve() {

    cin >> n >> m;

    for (int i = 1; i <= n; i++)cin >> a[i];

    build(1, 0, n);

    for (int i = 1; i <= m; i++) {

        int L, R; cin >> L >> R;

        g[R].push_back({ L,i });//离线询问

    }

    for (int i = 1; i <= n; i++) {

        upd(1, a[i], i);

        for (auto p : g[i]) {

            int L = p.first, id = p.second;

            ans[id] = que(1, L);

        }

    }

    for (int i = 1; i <= m; i++)cout << ans[i] << endl;

}

 

cf 1956D

题目中定义的操作:选择1<=l<=r<=n的两个整数l,r,计算x为mex(al...ar),并且将该区间内的所有数都赋值为x

结论:存在一种方式使得整个区间的值都为区间长度len

具体的构造方式为:
不管怎么操作,首先我们都要让原区间归0

先从小考虑:
当l=2时:
[0,0]->[1,0]->[2,2]

当l=3时:

[0,0,0]->[1,0,0]->[2,2,0]->[0,2,0]->[1,2,0]->[3,3,3]

于是发现了规律,如果想要将[l,r]全部变成r-l+1,那么首先要让[l,r-1]全变成r-l,然后将[l,r-1]全部变成r-l-1,最后再将[l,r]整体变为mex(l,r)

(这里或者说有点数学归纳法的思想,由特殊的情况l=2可以发现能满足将[l,r]全部变成r-l+1,于是假设,再证明,具体实现的时候又有种递归的感觉,不断拆分小区间使得其区间中的元素为r-l+1,直到最后成为一个连续的递增序列)

(当题目中不限定操作次数的时候,就考虑最暴力的操作,使每一个值都变成我们需要的,可以从样例的操作中尝试找规律(也不排除样例有误导,如cf1956c),这种题的结论一定是有特殊性的)

由于数据范围极小,因此可以直接暴力解决求mex

于是问题转化为给原序列分段,区间[l,r]的价值是max( ,(r-l+1)2),最后要求如何使区间取值最大,显然可以从小区间转移到大区间,即使用区间dp,记录转移点,然后倒退回去即可

int n,a[20],cnt[20];

vector <pair<int,int>> I;

void oper(int l,int r) {//使得都为mex,并记录操作

    fill(cnt,cnt+n+1,0);

    for(int i=l;i<=r;++i) if(a[i]<=n) ++cnt[a[i]];

    int mex=0;

    while(cnt[mex]) ++mex;

    for(int i=l;i<=r;++i) a[i]=mex;

    I.push_back({l,r});

}

void build(int l,int r) {

    if(l==r) {//初始都变成0

        if(a[l]) oper(l,r);

        return ;

    }

    build(l,r-1);

    if(a[r]!=r-l) oper(l,r),build(l,r-1);

}

void solve() {

    scanf("%d",&n),I.clear(),memset(a,0,sizeof(a));

    for(int i=0;i<n;++i) scanf("%d",&a[i]);

    int cur=0,ans=0;

    for(int s=0;s<(1<<n);++s) {//因为数据量较少,这里采用枚举子集的方式,判断对哪些区域进行操作

        int tmp=0;

        for(int l=0;l<n;++l) {

            if(s&(1<<l)) {

                int r=l;

                while(r+1<n&&(s&(1<<(r+1)))) ++r;

                tmp+=(r-l+1)*(r-l+1);

                l=r;

            } else tmp+=a[l];

        }

        if(tmp>ans) ans=tmp,cur=s;//存储方案

    }

    /*

    如果这里采用区间dp的方式

    for(int p=1;p<=n;p++){

        for(int l=1,r=p;r<=n;l++,r++){

            f[l][r]=std::max(1ll*p*p,sum[r]-sum[l-1]);

            for(int k=l;k<r;k++){

                if(f[l][k]+f[k+1][r]>f[l][r]){

                    f[l][r]=f[l][k]+f[k+1][r];

                    g[l][r]=k;

                }

            }

        }

    }

    通过枚举区间长度进行操作,每次转移时记录转移点,依次后续记录操作时根据转移点操作

    solve1(l,g[l][r]),solve(g[l][r]+1,r);

    */

    for(int l=0;l<n;++l) if(cur&(1<<l)) {

        int r=l;

        while(r+1<n&&(cur&(1<<(r+1)))) ++r;

        build(l,r),oper(l,r),l=r;//此时再进行最后一步

    }

    printf("%d %d\n",ans,(int)I.size());

    for(auto i:I) printf("%d %d\n",i.first+1,i.second+1);

}

 

cf2003D1

每一次操作可以选择一个序列将x放入,然后将x更新为这个序列的mex

不妨令一个序列最小的没有出现的非负数为ui,第二小的没出现的非负数为vi

显然经过了至少一次操作后,最后的值不会超过max(vi),要达到这个最大值,可以选择两次最大值对应的i

(mex的性质保证了操作不会很多,因为第一次能够更新,下次再想更新时,之前缺失的ui又变得空缺了,因此不会一直增大,这也是很多mex操作要考虑)

同时,当初始值达到一定程度时,初始值就是最后的结果,因为无法进行更新

void solve(){

    int n,m;

    cin>>n>>m;

    int x=0;

    for(int i=0;i<n;i++){

        int l;

        cin>>l;

        vector<int> cnt(l+2);

        for(int j=0;j<l;j++){

            int a;

            cin>>a;

            if(a<=l+1){

                cnt[a]++;

            }

        }

        int a=0;

        while(cnt[a]>0){

            a++;

        }

        a++;

        while(cnt[a]>0){

            a++;

        }

        x=max(x,a);

    }

    i64 ans;

    if(x<=m){

        ans=1ll*x*x+1ll*(x+m)*(m-x+1)/2;

    }else{

        ans=1ll*x*(m+1);

    }

    cout<<ans<<'\n';

}

 

cf2003D2

问题的区别是操作的时候不能两次或者多次选择同一个整数

由简单版的写法可知,当我们此时的数字是ui时,可以通过选择第i个序列,将数字变为vi,这种变化的关系可以连边来表示

于是可以建一个有向图,对于第i个序列,连一条有向边:ui->vi

一次操作可以沿着x的一条出边移动,或者选择移动到其中一个u并断开u对应的一条出边

令fi为i在有向图中,每次选择一条边移动,能到达的最大值,这可以通过从大到小倒序dp求出

首先一个x的答案至少是fx,然后所有x的答案都至少是max ui (1<=i<=n)(因为这是可以通过任意一次选择得到的),同时如果有出度>1的点i,所有x的答案都至少是fi,因为可以通过断掉除了通往fi的那条出边外的i的任意一条出边来得到fi

并且令k=max vi,对于<=k的数可以直接枚举统计,对于>k的数一定不用再操作了,因为操作会使得答案变小

void solve(){

    int n,m;

    cin>>n>>m;

    int maxa=0;

    int maxb=0;

    vector<int> A(n),B(n);

    for(int i=0;i<n;i++){

        int l;

        cin>>l;

        vector<int> cnt(l+2);

        for(int j=0;j<l;j++){

            int a;

            cin>>a;

            if(a<=l+1){

                cnt[a]++;

            }

        }

        int a=0;

        while(cnt[a]>0){

            a++;

        }

        maxa=max(maxa,a);

        int b=a+1;

        while(cnt[b]>0){

            b++;

        }

        maxb=max(maxb,b);

        A[i]=a;

        B[i]=b;

    }

    vector<int> cnt(maxb+1);

    for(int i=0;i<n;i++){

        cnt[A[i]]++;//统计出度

    }

    vector<vector<int>> adj(maxb+1);

    for(int i=0;i<n;i++){

        adj[A[i]].emplace_back(B[i]);

    }

    vector<int> dp(maxb+1);

    for(int x=maxb;x>=0;x--){

        dp[x]=x;

        for(auto y:adj[x]){

            dp[x]=max(dp[x],dp[y]);

        }

        if(cnt[x]>1){

            maxa=max(maxa,dp[x]);

        }

    }

    i64 ans=0;

    for(int x=0;x<=maxb && x<=m;x++){

        ans+=max(dp[x],maxa);

    }

    if(maxb<m){//此时直接取m

        ans+=1ll*(maxb+1+m)*(m-maxb)/2;

    }

    cout<<ans<<'\n';

}

 

 

逆序对
逆序对的解决方法通常有两种:归并排序,树状数组

数据值域小时树状数组更快

因为逆序对本质就是计算一个元素的右侧中大于他的元素个数,因此可以用逆序遍历并且同时用树状数组去维护这段区间上的数字大小

另一种则是用归并排序,每次在合并时利用双指针去统计逆序对的个数,并在合并完之后使得数组有序(也是因为这样才能利用双指针去逐个判断),因为归并排序是将数组对半分开,因此分开后的两个数组,右半部分数组中的元素一定本来就是在左半部分数组元素的后面

T315,LCR170,P1908,P1774

归并排序

class Solution {

public:

    int reversePairs(vector<int>& record) {

        vector<int> tmp(record.size());

        return mergeSort(0, record.size() - 1, record, tmp);

    }

private:

    int mergeSort(int l, int r, vector<int>& record, vector<int>& tmp) {

        // 终止条件

        if (l >= r) return 0;

        // 递归划分

        int m = (l + r) / 2;

        int res = mergeSort(l, m, record, tmp) + mergeSort(m + 1, r, record, tmp);

        // 合并阶段

        int i = l, j = m + 1;

        for (int k = l; k <= r; k++)

            tmp[k] = record[k];

        for (int k = l; k <= r; k++) {

            if (i == m + 1)

                record[k] = tmp[j++];   //record合并后的有序数组,tmp临时数组

            else if (j == r + 1 || tmp[i] <= tmp[j])

                record[k] = tmp[i++];

            else {

                record[k] = tmp[j++];

                res += m - i + 1; // 统计逆序对,因为左侧已经有序,较小的值都构成逆序对了,更大值肯定也能构成

            }

        }

        return res;

    }

};

树状数组(离散化)

int tree[500010],ranks[500010],n;long long ans; struct point

{

    int num,val;

}a[500010];

inline bool cmp(point q,point w){

    if(q.val==w.val)

        return q.num<w.num;

    return q.val<w.val;

}inline void insert(int p,int d){

    for(;p<=n;p+=p&-p)

        tree[p]+=d;

}inline int query(int p){

    int sum=0;

    for(;p;p-=p&-p)

        sum+=tree[p];

    return sum;

}int main(){

    scanf("%d",&n);

    for(int i=1;i<=n;i++)

        scanf("%d",&a[i].val),a[i].num=i;

    sort(a+1,a+1+n,cmp);

    for(int i=1;i<=n;i++)

        ranks[a[i].num]=i;  //进行离散化,因为要直接知道对应下标的位次因此没用平时的离散化代码

    for(int i=1;i<=n;i++)

    {

        insert(ranks[i],1);

        ans+=i-query(ranks[i]);  //区间询问的是当前树状数组中小于等于当前元素的元素个数

    }

    printf("%lld",ans);

    return 0;

}

当只有01并且只有相邻才能移动时,也可以看作是逆序对问题,不过此处的逆序对个数的判断比较简单,只需要统计0左侧1的个数即可(T2938,有点类似于冒泡的思路),只需要一次遍历即可,因为在遍历到该位置之前的1无论怎么移动还是在该位置之前

 

P1966

有两列数组,数组的距离定义为每个位置两元素差的平方和,要求使两数组距离最短时的最少交换次数

首先对于式子进行变形

和式中平方的和是一个定值,因此就是要让2*ai*bi的值最大

利用数学中的不等式可知,顺序*顺序>=乱序*乱序

因此实际上就是要将原来两个数组中对于位置在各自数组中的大小关系相同

首先因为数据的范围比较大,因此进行离散化

之后进行遍历每个位置找逆序对即可

计算逆序对的原理:只要序列原来对应的数是符合要求的,他们编号相同,那么我们排完序两数的相对位置不发生改变,因此不会产生逆序;一旦A中编号与B中的不同,即大小顺序不同(顺序的整理快排都帮我们实现了),那么这个数是不符合要求的,我们需要处理一下,剩下的在c数组中的数都是符合要求的(也就就是计入逆序对)

inline int lowbit(int x){

    return x&(-x);

}

 

inline void modify(i64 x,i64 y=1){

    for(i64 i=x;i<=n;i+=lowbit(i)){

        tree[i]+=y;

        tree[i]%=mod;

    }

}

inline i64 query(int x){

    i64 res=0;

    for(i64 i=x;i;i-=lowbit(i)){

        res+=tree[i];

        res%=mod;

    }

    return res;

}

int a[100005],b[100005];

int idx[100005],idx1[100005];

void solve(){

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        idx[i]=i;

    }

    for(int i=1;i<=n;i++){

        cin>>b[i];

        idx1[i]=i;

    }

    sort(idx+1,idx+n+1,[&](int x,int y){

        return a[x]<a[y];

    });

    sort(idx1+1,idx1+n+1,[&](int x,int y){

        return b[x]<b[y];

    });

    for(int i=1;i<=n;i++){

        a[idx[i]]=idx1[i];

    }

    //将b数组中的元素排列向a数组进行调整

    //于是就是求初始序列调整至当前序列需要的操作次数

    //仍然可以用逆序对的模板

    i64 ans=0;

    for(int i=n;i>=1;i--){

        modify(a[i]);

        ans=(ans+query(a[i]-1))%mod;

    }

    cout<<(ans%mod)<<'\n';

}

 

cf2003 E1,E2

在E1版本中,询问的[l,r]满足r[i]<l[i+1]

把所有数分成两种数,小数(0)和大数(1)并且任意小数都小于任意大数,这样就可以把排列转化成一个01序列

那么一个排列有趣的充要条件是,存在一种把所有数分成两种数的方案,使得对于任意一个题目给定的区间[l,r],所有0都在1前面,并且0和1都至少有一个,称这样的01序列是有趣的

如果已经确定了这样的01排列,那么为了使得排列的逆序对数最多,可以贪心地让所有0从大到小排列,所有1从小到大排列,那么如果0的数量为x,1的数量为y,第i个数是1并且第j个数是0的下标对(i,j)的数量为z,那么逆序对的值就是

在E1版本中,区间互不相交,因此可以直接dp,设dp[i][j]为考虑完[1,i]的所有数,其中有j个1,10对数量的最大值,转移时,如果i是一个区间的左端点,设这个区间的右端点是p,可以枚举这个区间的0的个数及对应1的个数进行转移(因为只与上一轮的dp有关,因此可以减至一维,同时因为从小到大会覆盖上一轮的结果,因此倒序处理)

那么最后的答案是max

在E2版本中,因为有区间相交,考虑相交的区间[l1,r1],[l2,r2],[l1,l2-1]一定全是0,[r1+1,r2]一定全是1,然后可以删掉[l1,r1],[l2,r2],添加一个新区间[l2,r1],并记下有哪些数一定是0,哪些数一定是1,这样处理后问题就转成了E1版本

void solve(){

    int n,m;

    cin>>n>>m;

    vector<array<int,2>> a(m);

    for(int i=0;i<m;i++){

        int l,r;

        cin>>l>>r;

        a[i]={l-1,r-1};

    }

    sort(a.begin(),a.end());

    vector<int> dp(n+1,-inf);

    dp[0]=0;

    for(int l=0,r=0,j=0;l<n;l=r+1){

        r=l;

        int i=j;

        while(j<m && a[j][0]<=r){//有重叠的部分

            r=max(r,a[j][1]); //常见的合并区间

            j++;

        }

        int L=l-1,R=r;

        for(int k=i;k<j;k++){

            L=max(L,a[k][0]);

            R=min(R,a[k][1]-1);

        }//(L,R)是可自由支配的区间,小于L大于等于l全是0,大于R小于等于r全是1

        if(L>R){

            cout<<-1<<'\n';

            return;

        }

        for(int i=l;i>=0;i--){//i表示当前区间前(l左侧),有i个1时最大01对数

            int v=dp[i];

            dp[i]=-inf;

            for(int j=L;j<=R;j++){//j枚举的是[L,R]之间01的分界线

                dp[i+r-j]=max(dp[i+r-j],v+(j-l+1)*i);

            }

        }

    }

    int ans=0;

    //在1的个数已知时,1内部的逆序对个数可直接计算

    //因此用dp计算的是01对构成的最大逆序对个数

    for(int i=0;i<=n;i++){

        ans=max(ans,dp[i]+i*(i-1)/2+(n-i)*(n-i-1)/2);

    }

    cout<<ans<<'\n';

}

 

 

 

置换环

置换环是用来求解数组排序元素间所需最小交换次数这类问题。

置换环思想:置换环将每个元素指向其排序后应在的位置,最终首位相连形成一个环(若数字在最终位置,则其自身成环),可知元素之间的交换只会在同一个环内进行,而每个环内的最小交换次数为环上元素数量-1

//置换环

pair<int, int> arrpos[len];//一维记录数值,一维记录位置

for(int i = 0;i < len;i++){

    arrpos[i].first = tmp[i];

    arrpos[i].second = i;

}

//跑次数

sort(arrpos,arrpos + len);

vector<int> vis(len,0);

for(int i = 0;i < len;i++){

    int cycelsize = 0;

    //自环或者访问过

    if(vis[i] || arrpos[i].second == i) continue;

    int j = i;

    while(!vis[j]){

        vis[j] = 1;

        j = arrpos[j].second;

        cycelsize += 1;

    }

    if(cycelsize > 0){

        ans += (cycelsize - 1);

    }

}

 

Cf 2086C

给定一个长度为n的排列

每次查询会修改其中的一些位置为0,在这个修改后可以进行操作:将数组中的第i个元素修改为i,求最小操作次数使得数组重新变为一个排列

可以注意到在修改后的操作相当于是让元素回归到正确的位置,也就是构成置换环,如果环中的任意一个元素被修改为0,那么整个环中的都要进行操作,因此存储每个元素属于的置换环,以及环的大小

https://codeforces.com/contest/2086/submission/313782422

 

显然我们可以得到,我们执行环的大小减一次操作就能将环中的元素变得有序,从另一个方向看,相当于每一个环内相当于只有一个点是不会改变的,因此我们将数组变得有序的操作次数就是n-环的个数

对于cf842d,我们还要保证在操作完成之后数组中的逆序对数目为1(注意因为是整个数组的逆序对数目唯一,因此这个逆序对只能是相邻位置的逆序对),因此一般情况下是使数组变得有序之后再增加一次操作次数,另一种情况是存在一个环其中本身就具有相邻的元素,那么最终答案是操作次数-1

int n, p[N], vis[N];

map<int, bool>ring;//记录环

bool flag = 0;

void dfs(int u) {//因为已经得到结论操作次数可以只与环的个数有关,那么就不用记录环的大小了

    if (vis[u]) return;

    vis[u] = 1;

    ring[u] = 1;

    int v = p[u];// 找到当前数组中对应下标为u的元素

    if (ring[u - 1] || ring[u + 1])flag = 1;//记录在这个环中是否有相邻的两个点

    dfs(v);

}

 

void slove() {

    cin >> n;

    for (int i = 1; i <= n; i++)vis[i] = 0;

    for (int i = 1; i <= n; i++)cin >> p[i];

    int cur = 0;

    flag = 0;

    for (int i = 1; i <= n; i++) {

        if (!vis[i]) {

            cur++;

            ring.clear();//每次重新建环

            dfs(i);

        }

    }

    if (flag)cout << n - cur - 1 << endl;

    else cout << n - cur + 1 << endl;

}

 

牛客24多校4 C

给定一个排列,每次选择四个位置任意交换其中的元素,求排序的最小次数

目标是有序,因此想到用置换环,显然对于大小等于4或者小于4的,我们可以直接将其恢复到正确位置,但为了使得操作次数尽可能少,因此将对大小为2的两次操作合并为一次,因为不存在大小为1的环,因此大小为3的只能单独操作,对于大小大于4的环,因为不可能在一次操作中就将对应的四个元素放回对应位置,因此每次只能先将3个元素放回正确位置

void solve(){

    int n;

    cin>>n;

    vector<pair<int,int>> cc(n);

    for(int i=0;i<n;i++){

        cin>>cc[i].first;

        cc[i].first--;

        cc[i].second=i;

    }

    sort(all(cc));

    vector<int> vis(n,0);

    i64 ans=0;

    vector<int> cnt(5,0);

    for(int i=0;i<n;i++){

        int siz=0;

        if(vis[i] || cc[i].second==i) continue;

        int j=i;

        while(!vis[j]){

            vis[j]=1;

            j=cc[j].second;

            siz++;

        }

        if(siz==4) ans++;

        else if(siz<4) cnt[siz]++;

        else{

            while(siz>=4){

                if(siz==4){

                    ans++;

                    siz-=4;

                }else{

                    ans++;

                    siz-=3;

                }

                //2 3 4 5 6 7 1

                //每次最多选择4个

                //可以选3个

            }

            if(siz) cnt[siz]++;

        }

    }

    ans+=cnt[3]+cnt[2]/2+(cnt[2]%2);

    cout<<ans<<'\n';

}

 

 

 

强制类型转换

注意返回值是否会溢出

如max1,max2都是int型,如果直接max1 * max2 % MOD会报错,而(long long) max1 * max2 % MOD不会,但(long long) (max1 * max2) % MOD也会报错(这样仍然相当于以int型进行运算,计算完后再改类型,因此依旧会超出数据的大小限制)

max函数内的参数每个数据类型要求是相同的,不能一个为int一个为long long,或者一个为int一个为str.size(),因为size函数的返回值是size_t型,数据类型不同,所以不能比较

将char a=’3’转化为对应的数字,只能用a-’0’,(int) a 返回的是对应的ASCII码

 

获取一个十进制数二进制各个位上的值

循环执行:n&1,判断二进制最右边一位是否是1;n>>1,n右移一位;

快速幂:本质上算是分治思想,将x^n转化为x^(n/2)*x^(n/2),这样逐步转化就等价于求出十进制幂的各个二进制位,这样求一个数的幂次就变成了计算x^1,x^2...的值以及个个二进制位,而这些操作都能用较高效率的循环解决(T50);类似的有快速乘的思路(T29)

         double myPow(double x, int n) {

        if(x==0.0) return 0.0;

        double res=1;

        if (n<0){

            x=1/x;

            n*=-1;

        }

        while(n){

            if (n&1) res*=x; //二进制只有0,1两种情况,因此为1就说明这个数可以乘上去

            x*=x;

            b>>=1;

        }

        return res;

}

 

new

1. new( ) 分配这种类型的一个大小的内存空间,并以括号中的值来初始化这个变量;

2. new[ ] 分配这种类型的n个大小的内存空间,并用默认构造函数来初始化这些变量; 

char* p=new char[6];     strcpy(p,"Hello"); 

3. 当使用new运算符定义一个多维数组变量或数组对象时,它产生一个指向数组第一个元素的指针,返回的类型保持了除最左边维数外的所有维数。例如: 

 int *p1 = new int[10];  

返回的是一个指向int的指针int* 

int (*p2)[10] = new int[2][10];

new了一个二维数组, 去掉最左边那一维[2], 剩下int[10], 所以返回的是一个指向int[10]这种一维数组的指针int (*)[10]. 

int (*p3)[2][10] = new int[5][2][10];  new了一个三维数组, 去掉最左边那一维[5], 还有int[2][10], 所以返回的是一个指向二维数组int[2][10]这种类型的指针int (*)[2][10]

new创建对象需要指针接收,一处初始化,多处使用

如典型的建立一个虚拟头结点 ListNode* dummy= new ListNode(0);

 

all_of,any_of,none_of

头文件<algorithm>,参数:容器的起始迭代器、容器的结束迭代器,以及一个用于判断条件的谓词(可以是函数指针、函数对象或Lambda表达式)

all_of:该算法函数用于检查容器中的所有元素是否都满足给定的条件。如果容器中的每个元素都满足条件,则返回true;否则,返回false。

any_of:该算法函数用于检查容器中是否至少存在一个元素满足给定的条件。如果容器中的至少一个元素满足条件,则返回true;否则,返回false。

none_of:该算法函数用于检查容器中是否没有任何元素满足给定的条件。如果容器中没有任何元素满足条件,则返回true;否则,返回false

使用示例:

// 使用all_of检查所有元素是否都为偶数

    if ( std::all_of(numbers.begin(), numbers.end(), [](int num) { return num % 2 == 0; }) ) {

        std::cout << "All elements are even." << std::endl;

}

// 使用any_of检查是否至少存在一个元素大于10

    if (std::any_of(numbers.begin(), numbers.end(), [](int num) { return num > 10; })) {

        std::cout << "At least one element is greater than 10." << std::endl;

    }

// 使用none_of检查是否没有元素小于0

    if (std::none_of(numbers.begin(), numbers.end(), [](int num) { return num < 0; })) {

        std::cout << "No element is less than 0." << std::endl;

    }

 

distance函数

传入两个迭代器,计算迭代器之间的距离(注意并不是元素的个数,因此要确保传入的迭代器范围有效),对于顺序容器(向量,列表)时间复杂度为O(N),对于随机访问迭代器(指针,数组)时间复杂度为O(1)

 

floor函数

函数原型double floor(double x);

作用:将一个小数向下取整,返回一个double类型的数,默认为6为小数,不过自然也可以强制转为int类型的

ceil函数

函数原型double ceil(double x);

作用:将一个小数向上取整

也可以直接写作(x+1)/2

round函数

函数原型double round(double x);

作用:将一个小数进行四舍五入

 

count类函数

count函数返回[first,last)范围内等于val的元素数,返回的是有符号整数类型

count_if  参数:

first,last:

在元素序列的初始和最终位置输入迭代器。使用的范围是[first,last),它包含first和last之间的所有元素,包括first指向的元素,但不包括last指向的元素。

pred:

一元函数,接受范围内的元素作为参数,并返回一个可转换为 bool 的值,返回的值指示此函数是否对元素进行计数。函数不应修改其参数,这既可以是函数指针对象,也可以是函数对象

例(T1334):
int ans = 0, cnt = n + 1;

        for(int i=n-1;i>=0;--i)

        {

            int t = count_if(g[i], g[i] + n, [&](int x) { return x <= distanceThreshold; });

         if(t < cnt)

            {

                cnt = t;

                ans = i;

            }

        }

对于这种要逐个判断一个点作为起点到其他点的距离小于某一值的可以直接使用count_if,同时对于数值相同取大值的要求可以直接像这里一样采取逆序遍历,这样就减少了如果相同还要进行比较的操作,能减少时间复杂度

 

resize

函数原型:void resize (size_type n);  或   void resize (size_type n, const value_type& val);

作用:改变容器的大小,使得其包含n个元素

1、如果n小于当前的容器大小,那么则保留容器的前n个元素,去除(erasing)超过的部分。

2、如果n大于当前的容器大小,则通过在容器结尾插入(inserting)适合数量的元素使得整个容器大小达到n。且如果给出val,插入的新元素全为val,否则,执行默认构造函数。

3、如果n大于当前容器的容量(capacity)时,则会自动重新分配一个存储空间。

注意:如果发生了重新分配,则使用容器的分配器分配存储空间,这可能会在失败时抛出异常

例:vector<int> cnt1, flip;     cnt1.resize(n * 4);

 

decltypeauto

auto,用于通过一个表达式在编译时确定待定义的变量类型,auto 所修饰 的变量必须被初始化,编译器需要通过初始化来确定auto 所代表的类型,即必须要定义变 量

decltype 关键字,用来在编译时推导出一个表达式的类型,在仅希望得到类型,而不需要(或不能)定义变量的时候使用

decltype(exp) 的推导规则:

推导规则 1: exp 是标识符、类访问表达式,decltype(exp) 和 exp 的类型一致。

推导规则 2: exp 是函数调用,decltype(exp) 和返回值的类型一致。

推导规则 3: 其他情况,若 exp 是一个左值,则 decltype(exp) 是 exp 类型的左值引用,否则和exp 类型一致

*左值和右值:左值是指那些在表达式执行结束后依然存在的数据,也就是持久性的数据;右值是指那些在表达式执行结束后不再存在的数据,也就是临时性的数据。有一种很简单的方法来区分左值和右值,对表达式取地址,如果编译器不报错就为左值,否则为右值

返回类型后置语法——auto 和decltype 的结合使用

在C++11 中增加了返回类型后置(trailing-return-type,又称跟踪返回类型)语法,将decltype 和auto 结合起来完成返回值类型的推导。

返回类型后置语法是通过auto 和 decltype 结合起来使用的。

template <typename R, typename T, typename U>
R add(T t, U u)
{
return t+u;
}
int a = 1; float b = 2.0;
auto c = add<decltype(a + b)>(a, b);

上面的add 函数,使用新的语法可以写成:

template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u)
{
return t + u;
}

我们并不关心a+b 的类型是什么,因此,只需要通过decltype(a+b) 直接得到返回值 类型即可

再如
int& foo(int& i);
float foo(float& f);
template <typename T>
auto func(T& val) -> decltype(foo(val))
{
return foo(val);
}

 

特殊形状的排列

例如T6 N字形变换,或者排座位时的蛇形排列,一种是利用排列至第几次,奇数次顺序排列,偶数次倒序排列,一种是利用flag,每到整除时就改变flag的正负,利用flag为+/-1,每次加flag来实现排列

螺旋矩阵:通过设置边界值来进行记录,设定的边界值就是当前要遍历的矩形(T54)

 

模运算

当数据量较大时,题目中往往要求进行对答案取模

而一般涉及到取模的题目,会用到如下两个恒等式:

(a+b) mod m=((a mod m)+(b mod m)) mod m

(a⋅b) mod m=((a mod m)⋅(b mod m)) mod m

  //虽然但是在用a*=b的时候还是不要写成a*=(b)%mod,不知道为什么,但是WA了几次了

但注意如果是两个数相减后取模,要注意各自取模相减后可能会变成负数,因此一般还要再加上mod后再取模避免负数情况出现

对一个字符串判断其前缀是否能被某个数整除,当字符串比较长时,在遍历过程中套用sum*10+nums[i]往往会使数据溢出,此时就要用到上面等式(T2575)

取模运算结果的正负是由左操作数的正负决定的。C99标准规定:如果%左操作数是正数,那么取模运算的结果是非负数;如果%左操作数是负数,那么取模运算的结果是负数或0

5 % 2 = 1    2*2+1=5

5 % -3 = 2   -3*-1+2=5

-13 % -3 = -1 -3*4+-1=13

-13 % 5 = -3   5*-2-3=13

 

一般自行解决数据量较大的数进行的取模用131,131313(T187,前缀和+字符串哈希)

 

string s(num,c) //生成一个字符串,包含num个c字符

可用于实现python类似'.' * c的作用,例如string(col[i], '.')

 

a,b在mod k意义下同余,当且仅当k|(a-b),即k是(a-b)的一个因子

因此需要一个数字使得数组中的元素两两不互余(P1154),可以先预处理出所有元素差,再从小到大枚举x,如果x未被标记,那么我们就看他的λ倍是否被标记(λ也是枚举的,可以写作for(int j=i;j<K;j+=i))如果都没被标记,则说明x是合法的。输出x即可

 

分数(小数)取模

a在模p意义下的乘法逆元是x,那么 ,0没有乘法逆元,a在模p意义下的乘法逆元均为[1,p-1]的整数

求模意义下的a/b,就是求 的c,两侧同乘b的逆元就消去了乘b的影响,得到 ,也就是模意义下除以一个数等价于乘这个数的乘法逆元

性质:
存在唯一性:对a来说,若它有逆元,则在p范围里一定只有一个逆元

a在模p意义下的乘法逆元存在,当且仅当a,p互质

快速幂求逆元

对于 求x

由费马定理可以得到 ,于是就有

也就是说明a的逆元就是

因此可以用快速幂来求逆元

i64 fp(i64 x,i64 y,int mod){

    int res=1;

    for(;y;y>>=1){

        if(y&1) res=(1ll*res*x)%mod;

        x=(1ll*x*x)%mod;

    }

    return res;

}

 

//求a的模意义下的乘法逆元

int get(int a,int p){

    if(a%p==0) return -1;

    else return fp(a,p-2,p);

}

或者也可以使用扩展欧几里得算法来求逆元

等价于ax+py=1

因为乘法逆元存在,当且仅当a,p互质,即gcd(a,p)=1

于是可以直接利用扩展欧几里得算法来解ax+py=gcd(a,p)这个方程

 

区间性质

极大性

牛客24多校4 I

有n个人排成一列,编号分别为1~n

其中有m对是好朋友

一个编号区间[l,r]被称为好区间当且仅当所有编号在内的人彼此之间都是好朋友

(如果将好朋友的关系看作是边,那么相当于这段区间内的各个点组成了一个完全图)

问好区间的数量

爆内存的一种做法:
连的边相当于是无向边,我们人为规定只记录从大编号的点连向小编号的点

那么显然,当一个区间可行的时候必然是可以通过类似于追加末尾数字的方法解决的

并且追加的元素所连的边一定是前面区间的元素个数

并且如果当前区间不行,那么含有当前区间的所有区间也一定不行

因此可以用双指针的技巧去实现

void solve(){

    int n,m;

    cin>>n>>m;

    vector pre(n+1,vector<int>(n+1,0));

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        if(u<v) swap(u,v);

        pre[u][v]=1;

    }

    for(int i=1;i<=n;i++){

        for(int j=1;j<=i;j++){

            pre[i][j]+=pre[i][j-1];

        }

    }

    int i=1,j=2;

    i64 ans=0;

    while(i<=n && j<=n){

        while(j<=n && pre[j][j]-pre[j][i-1]==(j-i)){

            ans+=j-i;

            j++;

        }

        while(j<=n && i<=n && pre[j][j]-pre[j][i-1]!=(j-i)){

            i++;

        }

    }

    ans+=n;

    cout<<ans<<'\n';

}

正确写法(思路实际上是一样的):
从上面的做法中可以发现好区间具有极大性,也就是如果[l,r]为好区间,那么[l,r-1],[l+1,r]也一定是好区间

因此可以考虑每次对固定的左端点求出最大的右端点

设[l,r]是好区间,且[l,r+1]不是好区间,则[l+1,r]一定是好区间

于是对于l+1为左端点的区间,只需要考虑r+1是否与[l+1,r]是好朋友,以此类推

r+1是否与[l+1,r]是好朋友这个信息可以暴力从r开始往前检验

void solve(){

    //不用前缀,直接存图然后暴力搜索即可,时间复杂度O(mlogm)

    //用前缀相当于开了一个邻接矩阵,因此会爆内存

    int n,m;

    vector<int> a,b;

    vector<vector<int>> adj;

    int l,r;

    i64 now;

    auto add=[&](int u,int k)->void{

        for(const int v:adj[u]){

            if(l<=v && v<r){

                now+=k;

            }

        }

    };

    cin>>n>>m;

    a.resize(m);

    b.resize(m);

    for(int i=0;i<m;i++){

        cin>>a[i]>>b[i];

        --a[i];--b[i];

    }

    adj.assign(n,vector<int>());

    for(int i=0;i<m;i++){

        adj[a[i]].emplace_back(b[i]);

        adj[b[i]].emplace_back(a[i]);

    }

    i64 ans=0;

    for(l=r=0,now=0ll;l<n;l++){

        for(;r<n;r++){

            add(r,1);

            const i64 n=r-l+1;

            if(now<n*(n-1)/2){

                add(r,-1);

                break;

            }

        }

        ans+=(r-l);

        add(l,-1);

    }

    cout<<ans<<'\n';

}

 

 

前后缀

特别在数组中,很多题目实际上就是对于某一元素前后缀的考察,例如子数组的和就可以看作两个前缀和的减,且一般前缀和数组比原数组的长度多一位,因为第一个数的前缀和为0,同时pre[nums.size()]=accumulate(nums.begin(),nums.end(),0)

计算前后缀乘积:pre[i]=pre[i-1]*nums[i]  suf[i]=suf[i+1]*nums[i]

后缀最大值:逆序遍历,suf_max[i]=max(nums[i],suf_max[i+1])

同理,前缀最大值就是去顺序遍历 pre_max=max(pre_max[i-1],nums[i])

在数组的前缀中求有下标要求的(如i<j)最大差值,可以参考买卖股票的思路(T121),但因为买卖股票是一次性的,而我们在解题时显然不能重复去搜索新数组的最大差值,因此要维护nums[i]-nums[j]的最大值max_diff,可以采取之前求前缀最大值的思路,虽然要求下标(i<j<K)但在枚举k时仍将其当做j,并转化成求前缀最大值,像这种顺序遍历的情况,维护时就只需要单个空间了,因为在已被遍历过的i,pre_max[i]并不会被用到,max_diff同理   但如果是前后缀同时需要时,那么至少有一个(如顺序遍历时,后缀最大值只能用数组来存储)需要用数组存储  T2864

对于一些操作次数的问题,也可以转化成前后缀问题(T2968,T2602,要让一个区间内的元素都变成某一个数,并且操作是对数组中元素+1或-1,就可以利用面积的思想,减1的次数是比k大元素的元素和-区间长度*k,加1的次数同理,这样就可以使用前后缀去维护了)

对于一个点作为中心点(左右元素与之对称),可以用前后缀来确定这个点作为中心点对应的数组个数,设这个点左,右,下三个方向连续11的个数为l,r,d,则以这个点为中心点的字母T的数量为(min(l,r)−1)×(d−1)(P6600)

 

对于数组中的三数或者四数问题,通用的做法是枚举中间那个数,如果仅仅只是需要最小值,并不一定要利用优先队列在遍历的过程中查找,可以通过预处理提前算出前后缀的最小值(T2909)

Suf[i]=min(suf[i+1],nums[i])

 

Cf2063E
给定一棵n个点的树,dis(u,v)定义为从u到v的唯一简单路径上的边数,lca(u,v)表示u和v的最近公共祖先函数

f(u,v)表示当 u 不为 v 的祖先,且 v 不为 u 的祖先时,存在多少个整数 x 使得三条边长分别为dist(u,lca(u,v)) , dist(v,lca(u,v)),x的边能成为一个三角形。否则函数值为 0。

最后求出

因为给出了要求能组成三角形的限制,且三条边中只有两条边不知道,因此可以先得到x相关的约束dist(u,lca(u,v))−dist(v,lca(u,v))<x<dist(u,lca(u,v))+dist(v,lca(u,v))

依据树形结构,可以将距离函数拆分成深度,令d[u]表示为u的深度,那么式子可更新为d[u]-d[v]<x<d[u]+d[v]-2d[lca]

这样x的范围就确定下来了,因为x要求取值为整数,因此也就得到了f(u,v)的值,f(u,v)=d[u]+d[v]-2d[lca]-|d[u]-d[v]|-1,然后对这个式子求和

因为是求和,因此可以从整体上考虑每个点的贡献,对于d[u]和d[v]的求和是简单的,每个点都有n-1个询问和它有关,因此和部分的贡献就是d[u]*(n-1),而绝对值求和的部分也容易求解,可参考(ABC168D),显然,所有数之间都进行了一次计算,因此不妨将数组先进行排序,那么原求和形式中的绝对值符号就可以删去,将式子进行变形,可以得到 ,前面的部分是一个前缀和的形式,如果预处理出前缀和的形式,那么就可以表示为pre[n]-pre[i]的形式,从而枚举每个i即可进行计算

因此剩下整个形式中就只有d[lca]还没有进行处理,这代表的是有多少对点的最近公共祖先是lca,这显然是lca两个不同子树内的任意点的组合,这可以通过前缀和优化做到线性

同时每个f还要减1,但对于u为v的祖先或者v为u的祖先的情况,因为不属于我们计算的情况,因此并不需要减,到时候直接加上即可

void solve() {

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    i64 ans=0ll;

    vector<int> dep(n);

    vector<int> siz(n,1);

    dep[0]=0;

    auto dfs=[&](auto self, int u ,int p)->void{

        i64 x=0ll,y=0ll;

        for(int v:adj[u]){

            if(v==p) continue;

            dep[v]=dep[u]+1;

            self(self,v,u);

            siz[u]+=siz[v];

            y+=1ll*siz[v]*x; // 计算u的不同子树中两两配对的个数,用前缀和计算

            x+=siz[v];

        }

        ans+=1ll*(n-1)*dep[u];// 每个点的贡献

        ans-=2ll*(y+siz[u]-1)*dep[u];// 作为lca时的负贡献

        ans+=siz[u]-1;// 计算v为u的祖先时本不用-1的部分

    };

    dfs(dfs,0,-1);

    sort(dep.begin(),dep.end());

    auto pre=dep;

    for(int i=1;i<n;i++){

        pre[i]+=pre[i-1];

    }

    for(int i=0;i<n;i++){

        ans-=(pre[n-1]-pre[i]-(n-i-1)*dep[i]);  // 绝对值部分

    }

    ans-=1ll*n*(n-1)/2;  // 不考虑v为u祖先情况时的-1部分

    cout<<ans<<'\n';

}

(有部分错误)

 

Cf2031 D

这题的难点在于我们需要的是左侧最后一个大于它的数的位置,这样可以贪心地得到尽可能大的能够使用的范围,因此无论是单调栈(遇到2 4 1 6 3 8 5 7时发现被分隔开了,分成了了2 4 1 6和3 5 8 7两部分)还是一般的直接前缀最大值(未知可以这样反复横跳几次,如果直接用并查集连接则可能出现无法进行操作的被我们归入了同一个并查集的情况)处理都是不可行的

因此增加一个pre[i-1]<=suf[i]的判断,说明无论如何,都无法变得更大了,因此这段区间内的数的答案就是该区间中的最大值(也就是考虑什么情况下操作停止,以段的视角去分析)

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> pre(n);

    vector<int> suf(n);

    pre[0]=a[0];

    for(int i=1;i<n;i++){

        pre[i]=max(pre[i-1],a[i]);

    }

    suf[n-1]=a[n-1];

    for(int i=n-2;i>=0;i--){

        suf[i]=min(suf[i+1],a[i]);

    }

    vector<int> ans(n);

    for(int i=1,j=0;i<=n;i++){

        if(i==n || pre[i-1]<=suf[i]){

            for(int k=j;k<i;k++){

                ans[k]=pre[i-1];

            }

            j=i;

        }

    }

    for(int i=0;i<n;i++){

        cout<<ans[i]<<" \n"[i==n-1];

    }

}

 

Cf 2041m

给定排序一段数组的代价是数组长度的平方

问使得给定数组有序的最少代价

显然考虑到能否进行划分,预处理出一个数组用来表示排序好后对应元素的位置

至多两次操作可以将数组变得有序,一次将需要的元素排成有序,另一次将剩余部分进行划分

考虑到部分位置可能本来就有序了,因此判断能否避免这一段的开销

但是如果前面进行排序所需要的元素在后方是不能进行操作的,因此进行操作

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> ip(n);

    iota(ip.begin(),ip.end(),0);

    stable_sort(ip.begin(),ip.end(),[&](int i,int j){// 相等元素保持其原始顺序

        return a[i]<a[j];

    });

    i64 ans=1ll*n*n;

    // 从前到后和从后到前进行分割

    int x=0;

    for(int i=1;i<=n;i++){

        x=max(x,ip[i-1]+1);// 到i为止排序完成需要到哪个位置

        ans=min(ans,1ll*x*x+1ll*(n-i)*(n-i));// *x*x表示对这段进行操作,同时前i个已经保证了有序,再对后(n-i)个进行操作

    }

    x=n;

    for(int i=n-1;i>=0;i--){

        x=min(x,ip[i]);

        ans=min(ans,1ll*(n-x)*(n-x)+1ll*i*i);

    }

    vector<int> pre(n+1,-1),suf(n+1,n);

    for(int i=0;i<n;i++){

        pre[i+1]=max(pre[i],ip[i]);

    }

    for(int i=n-1;i>=0;i--){

        suf[i]=min(suf[i+1],ip[i]);

    }

    vector<bool> f(n+1);

    // pre[i]>=i,suf[i]<=i

    for(int i=0;i<=n;i++){

        f[i]=(pre[i]<suf[i]);

    }

    for(int i=0,j=0;i<=n;i++){// 恰好可以进行分割的

        if(!f[i]){

            continue;

        }

        if(j<i){

            j=i;

        }

        while(j<n && f[j+1]){// i到j是有序的

           j++;

        }

        ans=min(ans,1ll*i*i+1ll*(n-j)*(n-j));

    }

    cout<<ans<<'\n';

}

 

 

(T689)对于要使用数组[i,j]的和的问题,可以用前缀和去预处理数组,这样能在O(1)时间内得到这段区间内的数据和,同样前后缀的最大值可以不仅局限于单个数的最大值,也可以是指的是前后缀满足某一条件的最值,同样可以用pre和suf去存储(但如果是有序(指三个区间只会向右移动)且只需要一次遍历的,可以不用预处理来减少循环次数)

 

分块加预处理(T239)对于在数组中大小找固定大小的窗口中的最大值,可以采取分类的思想(同时类别要少,这样才适合找答案的一般性)如果 i不是 k的倍数,那么 nums[i]到 nums[i+k−1](当前的实际窗口)会跨越两个分组,占有第一个分组的后缀以及第二个分组的前缀。假设 j是 k的倍数,并且满足i<j≤i+k−1,那么nums[i] 到nums[j−1] 就是第一个分组的后缀,nums[j] 到nums[i+k−1] 就是第二个分组的前缀。如果我们能够预处理出每个分组中的前缀最大值以及后缀最大值,同样可以在 O(1) 的时间得到答案(即将问题又转化成了前后缀)

 

前缀和+哈希表(T2949,面试17.05)

要求一个数组中两种类型的元素的个数相同,可以将其中一种元素视作1,另一种元素视作-1,这样问题就转换成了求一个元素和为0的数组,这样问题就等价于两个前缀和之差为0,进而等价于两个前缀和相等的下标,这种数组中找相关联的两个元素,就想到可以使用哈希表了,用一个数组或哈希表记录s[i]首次出现的下标,要计算的就是差的最大值

 

25天梯赛选拔 2-4

有n个物品按照顺时针环型放置,两两之间有一个距离,问有几对从s顺时针到t的最短距离为M的倍数

如果只是一个序列,问其中有多少对从s到t的距离是M的倍数,那么显然是通过前缀和再加航啊哈希表实现

但现在是一个环,也就是可能出现4->1这样的情况,那我们可以将其进行复制一次,在序列上来说就是将相同的序列在末尾进行拷贝,在环上来说就是再走到一轮

但是因为我们是要求顺时针的最短距离,也就是不能出现第一轮的1->第二轮的2这样的情况出现,因此我们要及时删除第一轮中对应的距离,也就是保证我们在哈希表中记录的距离是第一轮的i+1到当前第二轮的i,从序列上来说,相当于我们是在维护一个滑动窗口,保持窗口的大小是n

void solve() {

    int n,M;

    cin>>n>>M;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]%=M;

    }

    int ans=0;

    vector<int> pre(n+1);

    vector<int> cnt(M+1);

    cnt[0]=1;

    for(int i=0;i<n-1;i++){

        pre[i+1]=(pre[i]+a[i]+M)%M;

        ans+=cnt[pre[i+1]];

        cnt[pre[i+1]]++;

    }

    vector<int> pre1(n+1);

    pre1[0]=pre[n-1]+a[n-1];

    cnt[0]--;

    ans+=cnt[pre1[0]];

    for(int i=0;i<n-1;i++){

        cnt[pre[i+1]]--;

        pre1[i+1]=(pre1[i]+a[i]+M)%M;

        ans+=cnt[pre1[i+1]];

    }  

    cout<<ans<<'\n';

}

 

可以用于记录方案数,如果将前缀和更换成特征值那么就可以用于计算特定情况的方案数

Cf 2070E

给定一个字符串,两个玩家选择相邻的两个字符并删除(首尾两个字符视为相邻),其中第一位棋手选择的两个字符都为0,第二位棋手选择的字符至少有一个是1,第一个无法下棋的选手输掉比赛

问这个字符串能让第一位棋手必胜的子串数

首先对游戏进行一些修改,假设玩家可以删除任意一对所需类型的字符,而不是删除两个相邻的字符,于是第一个玩家可以删除任意两个等于0的字符,第二个可以删除任意两个字符,但其中至少有一个是1,不妨令c0是字符0的个数,c1是字符1的个数

显然,在这种情况下第二位玩家一定是尽可能删除一个0和一个1,从而一局游戏后,都要去掉一个1和三个0

如果字符串的长度可以被4整除,那么若c0=c1*3,那么恰好会完全下完,同时第一位输,如果c0<c1*3,那么说明0的个数会不够,也是第一位输,如果c0>c1*3,那么会是1先被消耗完,这时候第一位玩家获胜

这是字符串长度是被4整除的情况,对于有其他模数的情况,因为现在的情况实际上只与0和1的个数有关,因此总是能得到c0>=f(c1),则第一位获胜的形式

那么考虑模数的情况,初始数量够的时候一定是按照上面的情况进行操作的,那么例如长度的模数是3,双方都进行了相同的步数后,也就是要讨论剩下的字符,如果至少有2个0,那么第一位获胜,否则第二位获胜,因此c0-3=c1*3或者c0-2=(c1-1)*3,则第一位获胜,可以表示为c0>=c1*3-1

(在数字很大的时候一般都有一种固定的操作策略,从而可以将数据量大的情况减小至小数据的情况进行讨论)

在这种情况下,我们可以推导出任意长度下,第一个棋手必胜的计数关系

但是这是允许玩家取非相邻的字符的情况,考虑这与原规则的差距,显然,对于第二个玩家来说这是不重要的,因为只要字符串同时存在0和1,那么他总是可以取到的,但是对于第一个玩家来说,我们让他得到了一些优势

因此尝试证明在我们已有的必胜条件下,这个限制条件并不会影响到结果,因为如果第一位玩家无法按照规则操作,也就是不存在0 0子串,同时第一个和最后一个字符都是1,那么一定有c0<=c1,也就是不可能是我们推导出来的必胜情况

于是现在的问题就变成了对于长度为I mod 4的每个余数,我们有对应的数k[i],当c0>c1*3+k[i]时,必胜

这种形式我们一般都是将维护两个计数器更改成维护一个数值,也就是将其转换成c0-3*c1,即1的贡献值是-3,0的贡献值是1,然后就可以用类似于前缀和的思想,处理任意l,r区间内的数值,同时利用cnt,记录对应情况出现的次数

0字符的特征值是1,1字符串的特征值是-3

初始化为3*n,保证特征值非负

子串的特征值是f[r+1]-f[l]

接着计算方案数

其中一种情况是子串的特征值是-1

另一种情况是子串的特征值是2

 

不能直接想出结论,考虑将游戏的规则进行一定的弱化再考虑

void solve() {

    int n;

    cin>>n;

    string s;

    cin>>s;

    vector<int> f(n+1);

    f[0]=3*n;

    for(int i=0;i<n;i++){

        f[i+1]=f[i]+(s[i]=='0'?1:-3);

    }

    vector<int> cnt(4*n+1);

    i64 ans=0;

    int sum=0;

    for(int i=0,j=3*n;i<=n;i++){

        if(f[i]+1<=4*n){

            ans+=cnt[f[i]+1];

        }

        int r=max(-1,f[i]-2);

        while(j<r){

            sum+=cnt[++j];

        }

        while(j>r){

            sum-=cnt[j--];

        }

        ans+=sum;

        cnt[f[i]]++;

        if(f[i]<=j){

            sum++;

        }

    }

    cout<<ans<<'\n';

}

 

两个数据(01)数量相等转化为和为0,一个设定为1,另一个设定为-1,然后进一步用前缀和进行操作

cf1996E

题意:对于给定的二进制字符串s,对于每一对整数(l,r),计算其中(x,y)的个数,(x,y)满足这个子串中0的个数等于1的个数

也就是对于每个子串计算有多少个子串0的个数等于1的个数(子串的子串)

对于相等问题,按照上面的思路利用前缀和进行计算

遍历数组,可以得到当前的前缀和是pre[i]

判断数组中是否有和为0的段一般是用哈希

如果用数组做哈希要保证值并不是负值,因此加上n

要计算这个区间对于总数组的贡献

因为我们所选择的子串显然是要包含当前子串,因此方案数可以对该区间的左右端点进行计算,左边的元素为x,右边的元素为y,那么贡献就是x*y

于是可以用哈希f[t]来存储所有可以配对的左端点的左侧元素个数

因此每次遍历到一个元素,先计算ans+=f[t]*(n-i+1),再将其左侧的元素数加入f[t]中

constexpr int mod = 1e9 + 7;    

void solve(){

    string s;

    cin>>s;

    int n=s.size();

    vector<int> pre(n+1);

    pre[0]=0;

    for(int i=0;i<n;i++){

        pre[i+1]=pre[i]+(s[i]=='0'?1:-1);

    }

    vector<i64> f(2*n+1);

    i64 ans=0;

    //对于相同的进行计算

    for(int i=0;i<=n;i++){

        int t=pre[i]+n;

        ans+=f[t]*(n-i+1);

        ans%=mod;

        f[t]+=(i+1);

        f[t]%=mod;

    }

    cout<<ans<<'\n';

}

 

可以对于一些特殊的点运用前后缀去解决,如山峰问题,可借助山峰为划分点进行解决(T2866),并且利用贪心思想,每次要让山峰尽可能的高就是让其达到最大高度,这可以视为一个特征,因为如果是枚举成为山峰的那个点,递增序列上,达到最大高度之间的点是能相互转移的,每次判断左右都是类似的思路,因此可以将其作为dp的状态来存储;

或者利用撤销操作,每次都按当前可加的加入,利用单调栈,发现不能增加时,再撤回,再加入

long long maximumSumOfHeights(vector<int>& maxHeights) {

        int n = maxHeights.size();

 

        auto gao = [&](long long *f) {

            stack<int> stk;

            for (int i = 0; i < n; i++) {

                // 单调栈求左边最近的更小值

                while (!stk.empty() && maxHeights[stk.top()] >= maxHeights[i]) stk.pop();

                // 套递推方程

                if (stk.empty()) f[i] = 1LL * maxHeights[i] * (i + 1);

                else f[i] = f[stk.top()] + 1LL * maxHeights[i] * (i - stk.top());

                stk.push(i);

            }

        };

 

        long long f[n];

        gao(f);

        // 把数组倒过来,右边的处理和左边的处理是一样的,可以复用 gao 函数

        long long g[n];

        reverse(maxHeights.begin(), maxHeights.end());

        gao(g);

        reverse(maxHeights.begin(), maxHeights.end());

 

        long long ans = 0;

        // 枚举山峰,求最佳答案

        for (int i = 0; i < n; i++) ans = max(ans, f[i] + g[n - 1 - i] - maxHeights[i]);

        return ans;

    }

 

cf1937D

显然,只有对p位置而言左侧的第一个>或者右侧的第一个<有可能改变最初位置在p的弹球的运动方向

不失一般性的考虑,假设sp为<,k=min(countright(1,p),countlet(p+1,n))(哪边个数少,那边就是倒数第二次反弹的地方),弹球从左边界离开

我们可以通过前缀和和二分搜索的方式得到right和left数组,其中right数组表示>在p左侧的索引(降序排列),而left数组表示<在p右侧的索引(升序排列)(right[1]表示左侧第一个>其余同理)

移动的过程就可以用right和left数组来描述:

第一次:从right1到left1,第二次:从left1到right2...第2k次:从left[k]到左边界

可以使用前缀和来存储索引之和,然后快速计算弹球移动时间

const int N=1000010;

int  n;

ll Sl[N],Sr[N],IDl[N],IDr[N];

string s;

//因为将符合我们移动方向的数据元素赋为1,因此数据一定是单调不减的

//这样我们就可以利用前缀数组的值和二分的思想求出我们需要的移动方向的位置

//而不仅仅是利用单调栈后找出每一个元素右侧第一个<或左侧第一个>,因为这样本质上还是在模拟,无法更快的得到答案

//时间的优化就在于找到这些重要的转折点,即与答案直接相关联的数据,不去考虑中间移动的过程

int findpre(int x){

    int L=0,R=n+1,M;

    while(L+1!=R){

        M=(L+R)>>1;

        if(Sr[M]<x) L=M;

        else R=M;

    }

    return R;

}

int findsuf(int x){

    int L=0,R=n+1,M;

    while(L+1!=R){

        M=(L+R)>>1;

        if(Sl[n]-Sl[M-1]<x) R=M;

        else L=M;

    }

    return L;

}

void solve() {

    //sr用前缀和数组存储该点左侧有多少个向右的箭头,处理原始数据时,1为>,0为<

    //sl同理

    cin>>n>>s;

    for(int i=1;i<=n;i++) {

        Sr[i]=Sr[i-1]+(s[i-1]=='>');

        Sl[i]=Sl[i-1]+(s[i-1]=='<');

        IDr[i]=IDr[i-1]+i*(s[i-1]=='>'); //记录数组中元素是>的索引之和(这里是i-1是因为s下标从0开始)

        IDl[i]=IDl[i-1]+i*(s[i-1]=='<');    

    }      

    //因为是对于每个i都要计算,因此重点在于如何预处理出一些信息使得我们能够快速计算出时间

    for(int i=1;i<=n;i++){

        if(s[i-1]=='>'){

            if(Sr[i]>Sl[n]-Sl[i]){//初始向右,并且左侧向右的箭头数大于右侧向左的箭头数,因此最后是右出

                //计算最后一次改变移动方向的位置

                int p=findpre(Sr[i]-(Sl[n]-Sl[i]));

                //因为每次移动是从right[i]->left[j],其中经过的格子数实际上是通过下标差计算得到的

                //因此将所有移动的路程表达式列出后求总和,将l的放一起,r的放一起,可以得到以下的类似形式

                //相当于以一段区间内的下标和,因此可以前缀和去优化

                cout<<2*((IDl[n]-IDl[i])-(IDr[i]-IDr[p-1]))+i+(n+1)<<' ';

            }

            else{

                int p=findsuf((Sl[n]-Sl[i])-Sr[i]+1);

                cout<<2*((IDl[p]-IDl[i])-(IDr[i]-IDr[0]))+i<<' ';

            }

        }

        else{

            if(Sr[i]>=Sl[n]-Sl[i-1]){

                int p=findpre(Sr[i]-(Sl[n]-Sl[i-1])+1);

                cout<<2*((IDl[n]-IDl[i-1])-(IDr[i]-IDr[p-1]))-i+(n+1)<<' ';

            }

            else{

                int p=findsuf((Sl[n]-Sl[i-1])-Sr[i]);

                cout<<2*((IDl[p]-IDl[i-1])-(IDr[i]-IDr[0]))-i<<' ';

            }

        }

    }

    cout<<'\n';

}

 

cf1922c

在路径选择确定的情况下,每次从一个点移动到另一个点的花费是一个定值,这种情况下,对于每组从一个点移动到另一个点不需要每次都模拟,因为实际上每两个点之间实际上是一个确定的权值,因此可以直接写一个前缀数组优化,即记录如果从起点移动到这个点需要多少花费

to[1]=2;

    for(int i=2;i<n;i++){

        if(a[i]-a[i-1]<a[i+1]-a[i]){

            to[i]=i-1;

        }else{

            to[i]=i+1;

        }

    }

    to[n]=n-1;

    int m;

    cin>>m;

    suf[n]=0;

    for(int j=n-1;j>=1;j--){

        if(to[j+1]==j) suf[j]=suf[j+1]+1;

        else suf[j]=suf[j+1]+a[j+1]-a[j];

    }

    pre[1]=0;

    for(int j=2;j<=n;j++){

        if(to[j-1]==j) pre[j]=pre[j-1]+1;

        else pre[j]=pre[j-1]+a[j]-a[j-1];

    }

    for(int i=1;i<=m;i++){

        int l,r;

        cin>>l>>r;

        if(l>r) cout<<suf[r]-suf[l]<<'\n';

        else cout<<pre[r]-pre[l]<<'\n';

    }

 

cf2019 D

n个城市,给定一个数组a,ai表示必须在不晚于ai的时间内征服城市i

征服的步骤是:初始选定一个城市作为起始城市,然后在之后的时间中,可以选择一个与迄今为止征服的相邻的城市进行征服

因为这个操作与起始城市有关,因此问有多少个起始城市使得能够获胜

显然,实际上我们是从一个点开始向左右进行扩展,并且数组的长度就是我们经过的时间,于是联想到能否从数组下手,也就是数组长度,数组左右边界等属性

考虑限制条件:什么情况下,一个起始点的选择是不可行的,显然是在某个时刻,对应最迟界限的点至少有一个没法被达到,显然此时,这个点必然应该是数组的边界,且(当前)数组的长度必然会小于这个时刻,考虑如何去刻画这种情况

我们可以按照顺序遍历每个时刻,因为每个时间只能选择一个点,因此可以将时间作为索引存储这个时间点最左侧和最右侧的点,这两个点如果能被满足,那么这个时刻对应的中间的点也一定能被满足

,同时这两个点在此时(或之前)被达到是结论成立的必要条件,因此可以维护当前时刻最左侧和最右侧的边界点

显然,如果r-l<=i(i是当前时刻),那么这是可能可以成功的情况,同时此时最为极限的情况是[r-i,l+i](我们可以以最优策略去实现,因此边界点是可行的,l和r就是限制条件,因此只要能满足这两点即可),我们用差分数组去标记这个区间,说明这个时刻下这个区间中的点是可选点

那么在遍历完所有时刻(n)后,被标记的次数为n的点说明以它们为起始点能够满足所有的条件,因此此时count(n)的个数就是答案

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    vector<int> l(n,n),r(n,-1);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]--;

        l[a[i]]=min(l[a[i]],i);

        r[a[i]]=max(r[a[i]],i);

    }

    int L=n,R=-1;

    vector<int> f(n);

    for(int i=0;i<n;i++){

        L=min(L,l[i]);

        R=max(R,r[i]);

        if(R-L<=i){

            int l=max(0,R-i);

            int r=min(n-1,L+i);

            f[l]++;

            if(r<n-1){

                f[r+1]--;

            }

        }

    }

    for(int i=1;i<n;i++){

        f[i]+=f[i-1];

    }

    int ans=count(f.begin(),f.end(),n);

    cout<<ans<<'\n';

}

 

 

cf2030D

给定一个排列和操作数组,可以进行任意次数的操作:选择操作数组中的一个下标i,如果该下标对应的元素为L,则交换排列中的i和i-1两个元素

问进行任意次这样的操作之后,能否使数组变得有序

显然,如果位置i的元素想要进行交换,那么要求操作数组中的i为R或者i+1为L

因此,当且仅当相邻的两个元素为LR时,这构成了一个分界线,也就是左侧的元素不能移动到右侧

于是这种情况下要满足数组可以变得有序要求各个区间中的元素就是对应下标的排列,或者说当前区间的最小值和最大值就是区间的左右端点下标

但询问任意区间的最大和最小值比较困难,但相对的维护前缀最大值或最小值是比较容易的,因此尝试思考如果我们知道前缀最大值能否判断一个区间是可满足或者不可满足的

判断能够满足显然前缀最大值不可行,考虑不可满足的情况,按照上面的分析,很显然,只要最大值大于区间右端点下标即可,因为是个排列,因此最大值和最小值可以说相邻的,因为如果一个区间中的最大值大于了该区间的右端点,那么在此右侧一定有区间有最小值小于区间左端点,因此只考虑最大点即可

又区间最大值一定是小于等于前缀最大值,同时,不满足是只要有一个区间不满足就是整个数组不满足,于是如果左侧区间的最大值已经大于了当前的最大值,那么显然已经不可行了,因此可以直接用前缀最大值来判断

因为排列数组是不变的,因此我们可以预处理出哪些端点作为右端点是不可行的,在后续修改的时候就可以O(1)判断了

修改时的操作也是比较常规的,先删除原来的情况,再计算修改后的情况

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]--;

    }

    string s;

    cin>>s;

    vector<int> pre(n);

    pre[0]=a[0];

    for(int i=1;i<n;i++){

        pre[i]=max(pre[i-1],a[i]);

    }

    vector<int> ok(n);

    int cnt=0;

    for(int i=0;i<n;i++){

        if(i<pre[i]){//i是区间理论最大

            ok[i]=0;

        }else{

            ok[i]=1;

        }

    }

    for(int i=0;i<n-1;i++){

        if(s.substr(i,2)=="LR" && !ok[i]){

            cnt++;

        }

    }

    while(q--){

        int it;

        cin>>it;

        it--;

        //会影响两个相邻的点

        if(!ok[it] && s.substr(it,2)=="LR"){

            cnt--;

        }

        if(!ok[it-1] && s.substr(it-1,2)=="LR"){

            cnt--;

        }

        s[it]=s[it]=='L'?'R':'L';

        if(!ok[it] && s.substr(it,2)=="LR"){

            cnt++;

        }

        if(!ok[it-1] && s.substr(it-1,2)=="LR"){

            cnt++;

        }

        if(cnt==0){

            cout<<"YES\n";

        }else{

            cout<<"NO\n";

        }

    }

}

 

 

 

差分

既然是差分数组,那一般写的时候就用数组来实现即可

a数组是b数组的前缀和数组,反过来我们把b数组叫做a数组的差分数组(由a数组相邻元素的相减能得到b数组的元素),差分数组进行前缀和就是原数组

当差分数组的大小会很大时,需要把数组改成有序集合,或者用哈希表+排序(T2251)

差分数组的使用:

一维差分 例如对于数组[0,0,0,0,0,0]  我们每次操作都将一个区间里的任何数+1,我们自然可以用for循环去模拟操作,但我们也可以直接令该次操作区间的起始点+1,结束点的后一位-1,例,使[1,3]区间内的数加1,那我们可以操作为[0,1,0,0,-1,0],在进行求前缀和(包含当前位置的),即为[0,1,1,1,0,0]就是正常操作的结果,进行多次操作同理,这样就可以在O(1)的时间里将操作记录下来

不一定是加一,我们当然也可以进行任意数的加减,就是通过原数组中右边元素减左边元素得到最初的差分数组,然后按需要的操作去改变对应下标的值,如T2528,也可以用来解决区间问题(T2848,将区间求并集就是看这个区间上的数是否出现过,因此转化为对区间内的数都加1来表征)

二维差分 一种是逐行进行操作,转化为一维差分;第二种就是对二维的矩阵进行操作,与一维的操作类似,将矩阵规模增大一个单位,矩阵起始点为[i][j]结束点为[m][n],则[i][j]处、[m][n]处+1,[i][n]、[m][j]处-1

代码例:

for (auto &q : queries) {

                   vector<vector<int>> diff(n + 2, vector<int>(n + 2));

            int r1 = q[0], c1 = q[1], r2 = q[2], c2 = q[3];//因为设置的差分数组增大了一个单位

            ++diff[r1 + 1][c1 + 1]; //因此基础的都要+1

            --diff[r1 + 1][c2 + 2];//同时对原本要+1的结束位置再往后移一位

            --diff[r2 + 2][c1 + 1];

            ++diff[r2 + 2][c2 + 2];//用矩形去思考,如果矩形的下标移到了[r2 + 2][c2 + 2]发现多减了一次,因此要加回来

        }

 

        // 用二维前缀和复原(原地修改)

        for (int i = 1; i <= n; ++i)

            for (int j = 1; j <= n; ++j)

                diff[i][j] += diff[i][j - 1] + diff[i - 1][j] - diff[i - 1][j - 1]; //利用矩形面积理解

                            //如果是一开始重新建立一个二维前缀数组

                //s[i + 1][j + 1] = s[i + 1][j] + s[i][j + 1] - s[i][j] + grid[i][j];

        // 保留中间 n*n 的部分,即为答案

// 因为差分矩阵的大小是n+2,因此删去首尾两行,同时再对每一行删去头尾

diff.pop_back(), diff.erase(diff.begin());

        for (auto &row : diff)

            row.pop_back(), row.erase(row.begin());

利用前缀和数组去判断一个固定大小的矩形(该矩形的和为0说明是空的)能否放入

vector<vector<int>> d(m + 2, vector<int>(n + 2));  //二维差分数组

        for (int i2 = stampHeight; i2 <= m; i2++) {  //要放入,必定是从能放入的长宽开始判断

            for (int j2 = stampWidth; j2 <= n; j2++) {

                int i1 = i2 - stampHeight + 1;

                int j1 = j2 - stampWidth + 1;

                if (s[i2][j2] - s[i2][j1 - 1] - s[i1 - 1][j2] + s[i1 - 1][j1 - 1] == 0) {

                    d[i1][j1]++;

                    d[i1][j2 + 1]--;

                    d[i2 + 1][j1]--;

                    d[i2 + 1][j2 + 1]++;

                }

            }

        }

如果无法理解前缀和合并后的具体值,可以直接从最初每个操作的含义去理解

二维差分可以用于网格图或者矩形中的覆盖操作(T2132)

利用平衡树代替差分数组(T1094),数据量较大时用数组可能会超过存储上限,因此可以利用平衡树或者哈希表来存储差分相关的操作,因为只有在这些节点数组的值才会发生改变,其他位置数据的大小是不变的

例:
class Solution {

public:

    bool carPooling(vector<vector<int>> &trips, int capacity) {

        map<int, int> d;

        for (auto &t : trips) {

            int num = t[0], from = t[1], to = t[2];

            d[from] += num;

            d[to] -= num;

        }

        int s = 0;

        for (auto [_, v] : d) {

            s += v;

            if (s > capacity) {

                return false;

            }

        }

        return true;

    }

};

有时也可以使用差分的思想而不实际写出差分数组,例如T2960,就统计每个数一共要被减几次,并且利用类似bool判断的加法来简化代码

int dec=0;

        for(int x:batteryPercentages){

            dec+= x>dec;

        }

        return dec;

 

差分数组最主要的作用就是O(1)地去维护对于区间的贡献

Cf 2091F

给定一个描述攀岩场地的情况,要规划线路使得从最底层爬到最顶层,每一层可以最多选择两个点作为路径上的点,同时每次移动的时候,岩点之间的欧式距离不能超过攀登者的臂展,问总共有多少种可行的方案数

方案数问题考虑dp,因为每层最多只能选择2个点,这种较少的情况直接放在第二维,也就是dp[i][0]表示这层只选了i的方案数,dp[i][1]表示选择i作为第二个点的方案数

显然dp[i][1]可以通过dp[i][0]转移得到,但如果枚举从哪里转移需要逐个判断,这可能导致超时

但注意到这个转移实际上可以表示为贡献,也就是i对i-d,i+d(除了i本身)区域都有dp[i][0]的方案数增量的贡献,于是可以枚举i时顺带维护贡献

然后对差分数组做一次前缀和,再加上原来的dp数值,就可以得到dp[i][1]的值

对下一层的贡献也是类似的用差分数组做贡献

https://codeforces.com/contest/2091/submission/312452908

 

cf 2085F

给定一个数组a,1到k中的每个整数在数组a中都至少出现一次,定义一个彩色数组是一个长度为k的数组,且1到k中的每个整数都恰好在该数组中出现一次,每次操作可以交换数组a中相邻两个元素的位置,问至少需要多少次操作才能保证数组a中至少有一个子数组是彩色数组

将每个数字x找出出现的位置

因为彩色数组要求每种数字x都出现一次,因此除了滑动窗口判断其中缺少什么数字外,也可以考虑使用每个数字的特定出现位置来构建彩色数组

因为每次移动只能交换相邻位置,因此从某个位置移动到另一个位置的操作次数就是各位置之间的距离,从而可以使用差分数组来维护移动所需的代价

最终再找到最优子数组的起始位置来使得总移动代价最小

具体代价的计算划分为常数项和线性项系数

如d0[i]表示将所有需要的元素移动到以i为起始的子数组中的常数项

最终代价就是d0[i]+d1[i]*i

具体来说,当前位置是p,目标位置是i,p<i则移动代价i-p=1*i+(-p),否则移动代价是p-i=(-1)*i+p

因为数组并不要求有序,于是对于从i开始的彩色子数组,还要考虑放置在这其中的某个具体位置j,于是代价为|p-(i+j)|

具体来说:
在[0, p)区间内:代价 = i + (j-p)

在[p, p-j+1)区间内:代价 = 0

在[p-j+1, n)区间内:代价 = p - i – j

依次处理出现位置:
先小于v[0]的,此时一定是使用v[0]

v[0]到v.back的,

当子数组起始位置 < v[i] 时,选择v[i]位置的元素更优

当 v[i] <= 子数组起始位置 < m 时,选择v[i]位置的元素更优

当 m <= 子数组起始位置 < v[i+1] 时,选择v[i+1]位置的元素更优

当子数组起始位置 >= v[i+1] 时,选择v[i+1]位置的元素更优

再处理最后一个位置的,类似于v[0]

然后将差分数组做前缀和得到每个位置的实际值,再计算每个位置的答案d0[i]+d1[i]*i

最后进行修正,因为我们计算的是距离,但通过交换相邻元素来实现时,实际的交换次数可能更少

void solve() {

    int n,k;

    cin>>n>>k;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]--;

    }

    i64 ans=1e18;

    vector<i64> d0(n),d1(n);

    vector<vector<int>> vec(k);

    for(int i=0;i<n;i++){

        vec[a[i]].emplace_back(i);

    }

    for(int x=0;x<k;x++){

        auto &v=vec[x];

        d0[0]+=v[0];

        d1[0]-=1;

        d0[v[0]]-=v[0];

        d1[v[0]]+=1;

        for(int i=0;i+1<v.size();i++){

            int m=(v[i]+v[i+1]+1)>>1;

            d0[v[i]]-=v[i];

            d1[v[i]]+=1;

            d0[m]+=v[i];

            d1[m]-=1;

 

            d0[m]+=v[i+1];

            d1[m]-=1;

            d0[v[i+1]]-=v[i+1];

            d1[v[i+1]]+=1;

        }

        d0[v.back()]-=v.back();

        d1[v.back()]+=1;

    }

    for(int i=1;i<n;i++){

        d0[i]+=d0[i-1];

        d1[i]+=d1[i-1];

    }

    for(int i=0;i<n;i++){

        ans=min(ans,d0[i]+d1[i]*i);

    }

    int l=(k-1)>>1;

    int r=k>>1;

    ans-=1ll*l*(l+1)/2;

    ans-=1ll*r*(r+1)/2;

    cout<<ans<<'\n';

}

 

 

 

二次差分(T2735) 将一段区间内的元素增加d,可以使用一维差分,但如果增量是一个一次函数就可以进行二次差分

不妨令进行一次函数区间加的区间是[l,r],那么就相当于对于差分数组的[l]先增加一个常值p(例如函数是ki+b时,p就是k * l + b),再对差分数组的[l+1...r]区间加一个k,最后和一次差分一样在i+1处减去区间增量,即p+(r-l)k

因为是对差分数组的操作,因此要得到原数组需要计算两次前缀和(并没有再开一个差分数组的差分数组,要注意的是为了代码思维的一致,要将增加d的情况也当作二维差分去做)

// 辅助函数,一次差分,将 F[l..r] 都增加 d

        auto diff_once = [&](int l, int r, long long d) {

            if (l > r) {

                return;

            }

            if (l < n) {

                F[l] += d;

            }

            if (r + 1 < n) {

                F[r + 1] -= d;

            }

        };

        // 辅助函数,二次差分,将 F[l..r] 增加 ki + b,i 是下标

        auto diff_twice = [&](int l, int r, long long k, long long b) {

            if (l > r) {

                return;

            }

            diff_once(l, l, k * l + b);

            diff_once(l + 1, r, k);

            diff_once(r + 1, r + 1, -(k * r + b));

        };

// 计算两次前缀和

        for (int i = 0; i < 2; ++i) {

            vector<long long> G(n);

            partial_sum(F.begin(), F.end(), G.begin());

            F = move(G);

        }

 

状态压缩

状态压缩不仅是用于dp,更是对于一系列问题的一种通用思考方式,通过位运算,压缩一串字符串,将其转化为某一个整数,再利用哈希表,可用于判断在该数组中是否出现过相同字符,或者判断一个数组中是否有另一个数组的元素(T318)

缺陷是只能用于统计频次(即是否出现,因为相当于是将二进制中的每一位赋予一个数用来标记),对于重复出现的数没法解决,对于有重复出现的字符则可以考虑用字符哈希(T187,T30)

用来计算一个字符是否在某一个数据范围内(如T2949,判断是否为元音),就可以将那个数据范围压缩为一个二进制数,从而快速判断

 

数据量较小时用状态压缩进行表示

进而通过枚举子集来遍历每一种情况

Cf 2041C

void solve(){

    int n;

    cin>>n;

    vector a(n,vector(n,vector<int>(n)));

    for(int i=0;i<n;i++){

        for(int j=0;j<n;j++){

            for(int k=0;k<n;k++){

                cin>>a[i][j][k];

            }

        }

    }

    // i->x  0~n->y  n~2n->x

    // n<=12 状压

    // x,y,z不能有相同,标记

    // 3维坐标,每次一定y部分和z部分各自选1个位置,也就是1的数量+2

    // 因此每个i也只会被选择一次

    vector<int> dp(1<<(2*n),inf);

    dp[0]=0;

    for(int s=0;s<(1<<(2*n));s++){

        if(dp[s]==inf) continue;

        int i=__builtin_popcount(s)/2;

        for(int j=0;j<n;j++){

            if(s>>j&1){

                continue;

            }

            for(int k=0;k<n;k++){

                if(s>>(n+k)&1){

                    continue;

                }

                int ns=s | 1<<j | 1<<(n+k);

                dp[ns]=min(dp[ns],dp[s]+a[i][j][k]);

            }

        }

    }

    cout<<dp.back()<<'\n';// 最后每个位置都选择完毕

}

 

 

可以尝试用二维前缀和解决最大子矩阵

关键仍是固定,这样能有序去考虑矩阵大小

class Solution {

    public int[] getMaxMatrix(int[][] matrix) {

        int n = matrix.length, m = matrix[0].length;

        //二维前缀和

        int[][] preSum = new int[n + 1][m + 1];

        for(int i = 1; i < n + 1; i ++) {

            for(int j = 1; j < m + 1; j ++) {

                preSum[i][j] = matrix[i - 1][j - 1] + preSum[i - 1][j] + preSum[i][j - 1] - preSum[i - 1][j - 1];

            }

        }

        //开始最大子序和

        int gobalMax = Integer.MIN_VALUE;

        int[] ret = new int[4];

        //先固定上下两条边

        for(int top = 0; top < n; top ++) {

            for(int bottom = top; bottom < n; bottom ++) {

                int localMax = 0, left = 0;

                //然后从左往右一遍扫描找最大子序和

                for(int right = 0; right < m; right ++) {

                    //利用presum快速求出localMax

                    localMax = preSum[bottom + 1][right + 1] + preSum[top][left] - preSum[bottom + 1][left] - preSum[top][right + 1];

                    //如果比gobal大,更新

                    if(gobalMax < localMax) {

                        gobalMax = localMax;

                        ret[0] = top;

                        ret[1] = left;

                        ret[2] = bottom;

                        ret[3] = right;

                    }

                    //如果不满0,前面都舍弃,从新开始计算,left更新到right+1,right下一轮递增之后left==right

                    if(localMax < 0) {

                        localMax = 0;

                        left = right + 1;

                    }

                }

            }

        }

        return ret;

    }

}

 

 

二维前缀和用来解决矩阵中的相关问题

牛客24寒假6K

在满足要求的情况数较小的情况下,可直接打表出所有可能的情况,因为只有这些情况是满足的,所以要修改的点实际上就是这些点与目标矩阵不同的位置,而总共的计算步骤可用二维前缀和完成

 

二维前缀和

cf1982D

两个种类的和要相等转化为一个为正值,一个为负值,这样就是判断是否和为0即可

每次修改是对一个固定大小的子矩阵进行修改,对于该区间内的数同一加上或减去某一个数,自然想到用差分或者前缀和解决

因为我们最后关注的整个矩阵的总和,因此考虑用前缀和计算每次修改对总和产生的影响,于是相当于我们有若干个操作,问能否通过某些方案使得最终的总和变成0

利用贝祖定理知,若干个元素的gcd可以表示为对应元素的线性组合

因此如果总和能被gcd整除,就说明总和可以通过某种方案变成0

int a[505][505];

int b[505][505];

string s[505];

void solve(){

    int n,m,k;

    cin>>n>>m>>k;

    for(int i=1;i<=n;i++){

        for(int j=1;j<=m;j++){

            cin>>a[i][j];

        }

    }

    for(int i=1;i<=n;i++){

        cin>>s[i];

        s[i]=' '+s[i];

    }

    ll sum=0;

    for(int i=1;i<=n;i++){

        b[i][0]=0;

        for(int j=1;j<=m;j++){

            b[i][j]=s[i][j]=='0'?1:-1;

            sum+=b[i][j]*a[i][j];

            b[i][j]+=b[i][j-1];

        }

    }

    for(int i=1;i<=n;i++){

        for(int j=1;j<=m;j++){

            b[i][j]+=b[i-1][j];

        }

    }

    for(int i=1;i<=m;i++){

        b[0][i]=0;

    }

    int g=0;

    for(int i=0;i<=n-k;i++){

        for(int j=0;j<=m-k;j++){

            int t=b[i][j]-b[i][j+k]-b[i+k][j]+b[i+k][j+k];

            g=gcd(g,t);

        }

    }

    if(sum==0 || (g!=0 && sum%g==0)){

        cout<<"YES\n";

    }else{

        cout<<"NO\n";

    }

}

 

25 天梯赛 L3-2

定义一个元素的贡献是其元素值乘上矩阵中其余元素和它的距离和,距离定义为max(|x1-x0|,|y1-y0|)

可以发现距离矩阵是固定的,也就是可以提前计算得到的(这种预先计算一些距离找规律还是比较常见的),每个元素的计算实际上是用当前矩阵的大小去框选这个距离矩阵,并求这块元素值的和

于是可以提前处理出这个距离矩阵的二维前缀和来进行预处理

void solve(){

    int n,m;

    cin>>n>>m;

    vector<vector<i64>> a(n,vector<i64>(m));

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            cin>>a[i][j];

        }

    }

    vector<vector<i64>> ans(n,vector<i64>(m));

    vector<vector<i64>> cnt(2*n+1,vector<i64>(2*m+1));

    for(int i=1;i<=2*n-1;i++){

        for(int j=1;j<=2*m-1;j++){

            cnt[i][j]=cnt[i-1][j]+cnt[i][j-1]-cnt[i-1][j-1]+max(abs(n-i),abs(m-j));

        }

    }

//    for(int i=1;i<=2*n-1;i++){

//        for(int j=1;j<=2*m-1;j++){

//            cout<<cnt[i][j]<<" \n"[j==2*m-1];

//        }

//    }

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            int x1=n-i,x2=2*n-i-1;

            int y1=m-j,y2=2*m-j-1;

            ans[i][j]=a[i][j]*(cnt[x2][y2]-cnt[x1-1][y2]-cnt[x2][y1-1]+cnt[x1-1][y1-1]);

            cout<<ans[i][j]<<" \n"[j==m-1];

        }

    }

//    for(int i=0;i<n;i++){

//        for(int j=0;j<m;j++){

//            cout<<ans[i][j]<<" \n"[j==m-1];

//        }

//    }

}

 

杭电 25 春 9 1008

定义一个区间中的f值表示 max(a[l],...a[l+k]) < min(a[l+k+1]..a[r])的k的个数

先给定一个长度为n的两两不同的数组a,有q次查询,每次查询给出数组中的一个范围[l,r],要求计算子数组[l,r]的f值

因为这里限定了n的大小只有2000,以及查询次数的数据范围很大,因此基本只可能是预处理所有区间内的答案并O(1)回答单次询问

同样是由于n的范围很小,因此可以尝试O(n^2)的预处理

O(1)回答,以及找的是区间中划分点的个数,因此自然想到要用贡献来处理

从划分点的定义入手,也就是左侧的最大值小于右侧的最小值,因此我们先预处理出所有区间下的最大值以及最小值

因为大区间满足小区间也一定要满足,因此小区间是大区间的充分条件,从而在处理最大值的过程中就可以逐步缩小右侧区间的允许范围了

并且对于任何一个左端点,都可以进行区间贡献

因此虽然是二维的,但实际上是多个一维的贡献差分数组

void solve() {

    int n, q;

    cin >> n >> q;

    vector<int> a(n);

    for (int i = 0; i < n; i++){

        cin >> a[i];

    }

    vector<vector<int>> mn(n + 1, vector<int>(n + 1)), mx(n + 1, vector<int>(n + 1));

    vector<vector<int>> res(n + 1, vector<int>(n + 1));

    for (int i = 0; i < n; i++) {

        int mn1 = INT_MAX;

        for (int j = i; j < n; j++) {

            mn1 = min(mn1, a[j]);

            mn[i][j] = mn1;

        }

    }

    for (int i = 0; i < n; i++) { // 枚举划分点

        int p = n - 1, mx1 = 0;

        for (int j = i; j >= 0; j--) {

            mx1 = max(mx1, a[j]);

            while (p > i && mn[i + 1][p] < mx1) {

                p--;

            }

            if (p <= i) {

                break;

            }

            res[j][i + 1]++;

            res[j][p + 1]--;

        }

    }

    for (int i = 0; i < n; i++) {

        for (int j = 1; j < n; j++) {

            res[i][j] += res[i][j - 1];

        }

    }

    while (q--) {

        int l, r;

        cin >> l >> r;

        l--, r--;

        cout << res[l][r] << "\n";

    }

}

 

 

二进制枚举:可用于解决那些可以删除若干节点的问题(T2959),通过枚举判断哪些方案是可行的

for (int s = 0; s < (1 << n); s++) { // 枚举子集

ans += check(s);

}

在具体代码实现中,就可以利用位运算判断这个节点是否仍在图中

for (int i = 0; i < n; i++) {

if ((s >> i) & 1) {

f[i] = g[i];

}

或者在数据量较大的覆盖问题上,并且可能的方案数较多,也可以使用二进制枚举(T2397)

数据量较小时,考虑遍历所有情况去解决时,也可以利用二进制枚举

 

Gosper’s Hack

位运算枚举中,用于生成具有固定位数1的二进制序列的算法

如x=0101110

下一个序列y应该就是0110011

先算出lowbit=0000010

lowbit=x&-x

y的左半部分是指0110000 

即左半部分:x+lowbit

右半部分:(x^(x+lowbit))/lowbit>>2

x^(x+lowbit)得到的是 0011110

此时除以lowbit得到的就是1111

再右移两位得到的就是右半部分的1

因此下一个序列就是左半部分与右半部分的连接(即左半部分与右半部分进行或运算)

int lb = subset & -subset;

int x = subset + lb;

subset = ((subset ^ x) / lb >> 2) | x;

 

int&

这里的&不是取地址符号,而是引用符号,引用是C++对C的一个重要补充。变量的引用就是变量的别名,讲的通俗一点就是另外一个名字,由于引用不是独立的变量,编译系统不给它单独分配存储单元(因此可以减少空间使用),因此在建立引用时只有声明没有定义,只是声明它与原有的某一变量的关系。在声明一个引用时,必须同时使之初始化,即声明它代表哪一个变量,声明后无论a,b中任何一个值改变,另外一个也相应的改变

int a=2;

int &b=a;//这个声明语句中的&是一个引用

int *p=&b;//这个指针初始化语句中的&是取地址运算符

 

不能建立引用的数组,因为数组名是数组首元素的地址,本身不是一个占有存储空间的变量

char c[6]="hello";

char &rc=c;//错误

可以将变量的引用的地址赋给一个指针,此时指针指向的是原来的变量。
这句话可以这样说:将引用变量的地址赋给一个指针,此时指针指向的是引用变量,相当于指向原来的变量

int a=2;

int &b=a;//这个声明语句中的&是一个引用

int *p=&b;//这个指针初始化语句中的&是取地址运算符

等价于   int *p=&a;

不能定义指向引用类型的指针变量 即int & *p=&a;//企图定义指向引用类型的指针变量p,错误

可以建立指针变量的引用如

int i=5;

int *p=&i;

int * &pt=p;//建立指针变量p的引用pt

引用变量pt代表一个int *类型的数据对象(即指针变量)

此处的&是一个标记,表明这是个别名

 

函数

C++在函数内部不能再定义函数

但能利用function来做到函数内使用函数(主要是为了使其他函数能够使用只在该函数内被申明的量)

<int(int)>为函数传入的参数,注意[&]不能少,<int(int)>分别代表返回值和输入值(括号内)

 function<int(int)> dfs = [&](int x) -> int {

            int maxLen = 0;//最长路径为所有子节点中路径最长的两个之和

            for (int y : g[x]) {//依次遍历

                int len = dfs(y) + 1;

                if (s[y] != s[x]) {//没有分配到相同字符

                    ans = max(ans, maxLen + len);

                    maxLen = max(maxLen, len);//存储最大值

                }

            }

            return maxLen;

};//这是其中的一个语句因此需要有分号

函数要返回两个值时可以使用pair,而在接受函数返回值时可以利用auto

 pair<int, int> dfs(TreeNode *node) {

        if (node == nullptr) // 递归边界

            return {0, 0}; // 没有节点,怎么选都是 0

        auto [l_rob, l_not_rob] = dfs(node->left);  // 递归左子树

        auto [r_rob, r_not_rob] = dfs(node->right); // 递归右子树

        int rob = l_not_rob + r_not_rob + node->val; // 选

        int not_rob = max(l_rob, l_not_rob) + max(r_rob, r_not_rob);  // 不选

        return {rob, not_rob};

}

 

如果输出为void那就不需要再加->了

 

位运算

位运算函数__builtin

__builtin_ctz( ) ,返回括号内数的二进制表示数末尾0的个数(T421)

__buitlin_clz( ),返回括号内数的二进制表示数前导0的个数

得到n的二进制长度int m = 32 - __builtin_clz(n); // n 的二进制长度

例:

int main(){
    cout << __builtin_ctz(8) << endl ;
    cout << __builtin_clz(8) << endl ;
    return 0 ;}

返回3和28,8 = 0000 0000 0000 0000 0000 0000 0000 1000 , 整型(int)为32位,有28个前导0和三个后置0

__builtin_popcountll( ),返回括号内数的二进制表示数1的个数

对于每次会翻倍的情况,考虑用二进制去表示,任何位置上的1经过若干次翻倍后都可以转移到其他位上,因此一个数字最少需要原始加入几个数字就是按照二进制上1的个数来判断cf 579A

__builtin_popcount() 也是返回二进制表示数中1的个数,只是加上ll表示处理的数是long long类型

__builtin_ffs( ),返回括号中数的二进制表示数的最后一个1在第几位(从后往前算)

__builtin_sqrt( ),快速开平方,与sqrt相比效率更高

 

cf1988c

实际上就是删除按照bite位上的1进行操作

从低到高位将1变成0

如果是int类型,位数最多是30,如果是ll,位数最多是59

同时如果是ll类型时,一些数据要相应的进行修改,例如1ll<<n以及统计1的个数的函数末尾要加上ll

void solve(){

    i64 n;

    cin>>n;

    int c= __builtin_popcountll(n);

    if(c==1){

        cout<<1<<'\n'<<n<<'\n';

    }else{

        cout<<c+1<<'\n';

        for(int i=59;i>=0;i--){

            if((n>>i) & 1){

                cout<<(n^(1ll<<i))<<' ';

            }

        }

        cout<<n<<'\n';

    }

}

 

Cf 2103F

给定一个数组a,定义nor运算:nor(0,0)=1,nor(0,1)=0,nor(0,1)=0,nor(1,1)=0,同时对于一个数组b,子数组的nor值为迭代的结果,也就是nor(b1,b2,…,bm)=nor((nor(nor(b1),b2))…,bm),特别地nor(b1)=b1

对于给定的数组,对于1到n的每个下标,输出包含这个下标的区间的最大可能nor值

因为定义的运算并没有区间同时操作,而是拆分为两个逐个进行操作,因此首先观察一般的形式,除了对一个数本身进行nor运算外,两个数进行nor运算就是先进行or运算然后取反

由此再考虑区间的情况,因为每次操作后都会取反,同时加上或的性质,因此对于一个区间[l,r]在某一位上的数值,我们只关心r之前最后一个1的位置

假设该位置为x,如果x>l,那么如果x和r的奇偶性相同(x上是0,与x奇偶性不同的,如x+1,该位上就是1),那么是0,否则是1

如果x=l,因为第一个数的nor是其本身,因此如果l和r的奇偶性相同,那么是1,否则是0

如果x<l,就是和上一种情况相反,于是如果l和r的奇偶性相同,那么是0,否则是1

从而,显然要预处理出每个bite位下,每个下标左侧第一个1出现的位置

同时这些为1的下标位置,也是影响答案的那些位置

虽然我们要找的是包含一个下标的区间,但预计算的时候可以固定右端点,依据左端点来求最值

void solve() {

    int n, k;

    cin >> n >> k;

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

    vector left(k, vector<int>(n));

    for (int j = 0; j < k; j++) {

        for (int i = 0; i < n; i++) {

            left[j][i] = (a[i] >> j & 1) ? i : i > 0 ? left[j][i-1] : -1;

        }

    }

 

    auto get = [&](int l, int r) {

        int ans = 0;

        for (int i = 0; i < k; i++) {

            int j = left[i][r];

            int d;

            if (j < l) {

                d = (r - l) % 2;

            } else if (j == l) {

                d = (r - l + 1) % 2;

            } else {

                d = (r - j) % 2;

            }

            ans |= d << i;

        }

        return ans;

    };

 

    vector<vector<int>> add(n), del(n);

    for (int r = 0; r < n; r++) {

        vector<int> cand;

        for (int i = 0; i < k; i++) { // 每个位找那些关键下标

            int L = left[i][r];

            for (auto l : {L, L + 1, L + 2}) {

                if (l < 0 || l > r) {

                    continue;

                }

                cand.emplace_back(l);

            }

        }

        for (int l = 0; l <= r && l <= 2; l++) {

            cand.emplace_back(l);

        }

        sort(cand.begin(), cand.end());

        cand.erase(unique(cand.begin(), cand.end()), cand.end());  // 对于所有位来说,有些下标是重复的,因此去重

        for (auto l : cand) {

            int res = get(l, r);

            add[l].push_back(res);

            del[r].push_back(res);

        }

    }

    multiset<int> s;

    // 类似于之前通过左端点和右端点来统计有重叠区间的做法

    // 这里左端点说明该答案可以加入,右端点说明该区间答案已经结束

    // 而包含该下标的区间答案,也就是该下标所在的那些重叠区间的答案

    // 因此用类似的方法处理,左端点加入,右端点删除

    for (int i = 0; i < n; i++) {

        for (auto x : add[i]) {

            s.insert(x);

        }

        cout << *s.rbegin() << " \n"[i == n - 1]; // 输出这些满足条件区间中的最值

        for (auto x : del[i]) { // 因为右端点是包含,因此统计完答案之后再删除

            s.extract(x);

        }

    }

}

 

 

Cf 2061E

给定一个长度为n的整数序列a和一个长度为m的整数序列b,每次操作选择两个元素i,j,并将a[i]更新为a[i]&b[j],问最多进行k次运算后,a中所有数字之和的最小值

首先可以考虑到依据与运算的性质,增大操作次数一定不会使结果变劣,因此不妨设置我们一定会执行尽可能多的操作次数

注意到m的大小只有10,因此考虑通过状态压缩来进行预处理

用num[i][j]表示a[i]经过j次操作后可能的最小值,num[i][0]=a[i]

因为每次操作一定是使总和减小,因此考虑减小量,于是定义res[i][j]=num[i][j-1]-num[i][j]表示第j次操作减小了多少

这种考虑下,对于所有j>1,只有选择了res[i][j-1]进入答案,才能选择res[i][j],这样问题转变成一个类似于树上背包的问题,因为有些数值的选择具有依赖性

但因为k较大,因此并不能这样操作

重新考虑我们定义的状态,依据与运算的性质,可以发现num[i][j]对每个i都是单调不上升的,进一步的,res[i][j]对每个i也是单调不上升的(实际上,可以通过num是一个凸函数,从而res作为它的差分序列是单调不升的)

于是可以贪心地考虑选择最大的k个res[i][j]加入答案,这也确保了依赖性,因为从大到小选择保证了它之前的元素一定被选择了

void solve(){

    int n,m,k;

    cin>>n>>m>>k;

    vector<int> a(n),b(m);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    for(int i=0;i<m;i++){

        cin>>b[i];

    }

    i64 ans=accumulate(a.begin(),a.end(),0ll);

    vector<int> f(1<<m);

    f[0]=(1<<30)-1;

    for(int s=1;s<(1<<m);s++){

        int u=__builtin_ctz(s);

        f[s]=f[s^(1<<u)]&b[u];

    }

    vector<int> h;

    h.reserve(n*m);

    for(int i=0;i<n;i++){

        vector<int> g(m+1);

        for(int s=0;s<(1<<m);s++){

            int c=__builtin_popcount(s);

            g[c]=max(g[c],a[i]-(a[i]&f[s]));

        }

        for(int j=1;j<=m;j++){

            h.emplace_back(g[j]-g[j-1]);

        }

    }

    sort(h.begin(),h.end(),greater<int>());

    for(int i=0;i<h.size() && i<k ;i++){

        ans-=h[i];

    }

    cout<<ans<<'\n';

}

 

f[s]存储s中为1的元素与之后的结果

g[c]存储操作c次后减小的最大值

h用于存储每次操作后的减小值

可以熟悉一下f[s]的处理,通过从小到大的操作,每次在常数复杂度内进行更新,得到状态压缩后对应情况的数值

 

Cf 2082A
给定一个元素只有0或1的矩阵,每次操作可以将某个元素异或上1,问最少多少次操作后可以让矩阵的行和列的异或和都是0

首先,将1变成0肯定是比0变成1优的,因为变成0一定不会有更多的影响,而变成1后会对对应的列或行产生影响

因此实际上问题变成要删除多少个1,这可以行和列分开看

因为确定哪些行和列要进行删除后,我们可以再确定删除哪个位置的1

于是求出行和列中各自需要删除的个数后两者中取大即可

void solve() {  

    int n,m;

    cin>>n>>m;

    vector<vector<int>> a(n,vector<int>(m));

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            char ch;

            cin>>ch;

            a[i][j]=ch-'0';

        }

    }

    int r=0,c=0;

    for(int i=0;i<n;i++){

        int xr=0;

        for(int j=0;j<m;j++){

            xr^=a[i][j];

        }

        r+=xr;

    }

    for(int j=0;j<m;j++){

        int xc=0;

        for(int i=0;i<n;i++){

            xc^=a[i][j];

        }

        c+=xc;

    }

    cout<<max(r,c)<<'\n';

}  

 

25 杭电 春4 1003

给定正整数k,b,c,v,问有多少非负整数x满足令p[x]=kx+b有p[x]^c<=v

看到<=,可以考虑拆分成<和=

而关于二进制,看到<,可以想到拆分为不同位,只要高位不同那么低位就可以任选

因此这题的思路就是拆分为小于和等于

等于的情况直接移项求值即可

小于的话,就从高到底枚举第一个v为1,p[x]^c是0的位,低位任意取,高位必须和v一样,同时这样每个位满足条件的数一定是构成连续区间(因为是从0~1<<n-1),可以直接算区间中合法的x即可(类似于按位贪心的思路)

因为高位和v相同,可以等价于当前p[x]的高位和v^c相同,因此可以维护一个cur直接计算高位的情况

void solve() {

    i64 k,b,c,v;

    cin>>k>>b>>c>>v;

    i64 ans=0,cur=0;

    auto cal=[&](i64 l,i64 r)->i64{

        if(r<b){

            return 0;

        }

        return (r-b)/k-max(0ll,(l-1-b)/k)+(l<=b && b<=r);

        //(l<=b && b<=r) -> x=0

    };

    for(int i=59;i>=0;i--){

        i64 x=(v>>i)&1,y=(c>>i)&1;

        if(x) {

            // kx+b xor c 1<<i 0

            ans+=cal(cur+(y<<i),cur+(y<<i)+(1ll<<i)-1);

        }

        cur|=(x^y)<<i;

    }

    v^=c; // kx+b xor c == v

    ans+=cal(v,v);

    cout<<ans<<'\n';

}

 

 

25 杭电 春2 1001

给定一个集合,其中所有的元素满足如果元素u和v在集合S中,那么u&v和u|v都在S中,特别的,0和2^64-1都在S中,现在给定S中的n个元素,一共有q次询问,每次给定一个数x,要求输出S中最小的满足大于等于x的元素

先把所有元素或一遍得到集合中的最大值mx,然后全部与一遍得到集合中的最小值mn(除去0和2^64-1),然后对每个查询x,如果x=0,则y是0;如果x>mx,则y是2^64-1;如果mn>x,则输出mn;否则 令tmp=mn|(x&mx),因为mx中为0的位置表示S中的元素该位置也一定为0,因此在或的时候这些位置为0,同时存储在这个过程中x中原本为1被消去为0的最高位的位置i

然后对于tmp,查找二进制表示大于i的位置中,第一个tmp上为0而mx中为1的位置j,给tmp或上1<<j,因为这样已经可以保证现在的tmp大于x了,所以将小于j的二进制位能变成0的都变成0(也就是按照mn中的0的分布进行操作)

通过处理后可以得到代码

void solve() {  

    int n,q;

    cin>>n>>q;

    u64 sum1=0ull;

    u64 sum2=~0ull;

    for(int i=0;i<n;i++){

        u64 x;

        cin>>x;

        sum1|=x; // max

        sum2&=x; // min

    }

    u64 res=0ull;

    // cout<<sum1<<' '<<sum2<<'\n';

    while(q--){

        u64 x;

        cin>>x;

        u64 ans=sum2;

        if(x==0){

            continue;

        }

        if(x>sum1){

            ans=~0ull;

            res^=(ans % mod);

            continue;  

        }

        if(ans>=x){

            res^=(ans % mod);

            continue;

        }

        ans|=(x&sum1);

        if(ans>=x){

            res^=(ans % mod);

            continue;

        }

        u64 removed_bits = x & (~(x&sum1));

        if (removed_bits) {

            int highest_removed_bit = 63 - __builtin_clzll(removed_bits);

            u64 mask = (~x) & sum1;

            u64 high_mask = mask & (~((1ULL << (highest_removed_bit+1)) - 1));

            if(high_mask){

                int pos = __builtin_ctzll(high_mask);

                ans |= (1ULL << pos);

                ans &= sum2 | (~((1ULL << (pos)) - 1));

            }else{

                ans=~0ull;

                res^=(ans % mod);

                continue;

            }

        } else {

            u64 mask = (~x) & sum1;

            if(mask){

                int pos = __builtin_ctzll(mask);

                ans |= (1ULL << pos);

                ans &= sum2 | (~((1ULL << (pos)) - 1));

            }else{

                ans=~0ull;

                res^=(ans % mod);

                continue;

            }

        }

        res^=(ans % mod);

        // cout<<ans<<'\n';

    }

    cout<<res<<'\n';

}

但这样的代码是有问题的,做法的主要的问题在于我们并不能保证可以只让某个位变成1

换句话说,让1个位变成1是有最小花费的

因此我们需要预处理每个位变成1的最小花费,这可以通过将所有在这个位上为1的元素取与得到

然后就可以计算答案了,因为二进制的性质,如果一个数在高位上是1,而另一个数是0,那么这个数肯定更大,因此从大到小遍历,x中是1的答案里肯定也要是1,为了做到这一点就要或上我们刚刚得到的最小代价,前面是必要性,而对于x是0的位置,如果我们让这一位是1,那么得到的数就一定包含了(大于等于)x了的,因此在这个位置判断是不是答案

constexpr u64 mod = 10000000000000000ULL + 1029ULL;

void solve() {  

    int n,q;

    cin>>n>>q;

    vector<u64> a(64,~0ull);

    for(int i=0;i<n;i++){

        u64 x;

        cin>>x;

        for(int i=0;i<64;i++){

            if((1ull<<i) & x){

                a[i]&=x;

            }

        }

    }

    u64 res=0ull;

    while(q--){

        u64 x;

        cin>>x;

        u64 ans=~0ull,cur=0ull;

        for(int i=63;i>=0;i--){

            if((1ull<<i) & x){

                cur|=a[i];

            }else{

                ans=min(ans,cur|a[i]);

            }

        }

        res^=min(cur,ans)%mod;

    }

    cout<<res<<'\n';

}

 

Cf 2075D
判断两个数是否相等,或者找到两个数第一个不同的位置可以用异或

如果只是要直到最高位的1可以使用__lg,不需要使用__builtin_clz

给定两个数x和y,每次操作可以将x或y除以2^k,注意每次操作的k不能相同,这样一次操作的花费是2^k

问将x和y变成相等的最小花费

因为除2的幂次相当于是在二进制表示上右移,于是可以从二进制的意义下进行考虑

一种显然的想法是找到两个数各自右移多少后能相等,假设右移的位分别是i和j,那么k1+k2..+km的总和就是i和j,因为两两不相等,同时和是2^k,所以想到贪心地构造这样的序列,具体来说,是从1+2+…+p的形式进行调整,p是p*(p+1)/2<=i+j的最大的数,然后对于剩余的i+j-p*(p+1)/2,逆序依次加到各个元素上

但是这样构造的方法似乎有些问题

一个可以优化的点,就是将不需要依次增加i和j然后判断,可以直接计算得到i和j的值,因为首先最高位一定要对齐,对齐后再找到异或第一个不相同位即可

其次,既然那种构造方式不可行,因为是在二进制表示意义下操作的,因此转移的位数最多只有60*60,于是可以通过dp来预处理最优的转移情况

类似于01背包的处理,每个元素只能被使用一次,dp[i][j]表示两个序列分别向右移动i和j的花费,

因为当我们已经相等情况下,多移动并不会影响正确性,但可能会使得答案变小,因此另外设定g[i][j]表示移动大于等于i和j的花费

同时,在计算答案时,一种显然的答案是分别移动a和b,也就是将两个数都变成0,否则就从计算得到需要移动的位数进行操作,对于较小的数,花费就是不同的位,较大的数,就是一开始使得最高位相同的花费再加上后面不同的位的花费,之所以对于这种情况要逐个加是因为此时一定要保证两者增加的量是相同的,因此不能简单地用g来计算

https://codeforces.com/contest/2075/submission/311218361

 

cf 2075E

给定两个数组a,b,定义一个xor矩阵x,其中(I,j)位置的元素由a[i]^b[j]得到,给定n,m,A,B,求符合条件的数组对(a,b)的个数,条件为:a由n个整数组成,每个元素都在0到A之间;b类似;得到的矩阵x中,不同的值不超过两个

首先有一些比较显然的情况,例如a中的元素全部相同,b中的元素也全部相同,这样的方案数是A*B,或者a中的元素全部相同,b中仅有两种元素,这样的方案数是A*B*(B-1)*(2^(m-1)-1),也就是A中任意元素,B中选择两种元素,首先b中第一个固定为选择的元素,然后后面的位置每个都有两种情况,再减去后面位置和第一个位置全相同的情况

然后就是要计算a数组和b数组中都有不同元素的情况,可以证明,这样的情况只可能是a和b中都有2个不同的元素,也就是a[1]^b[1],a[2]^b[1],a[1]^b[2],a[2]^b[2],因为不同的数和一个数异或的结果一定不同,因此不可能有超过3个的元素

因为排列并不影响最后的结果,因此可以预先处理出选定元素后的方案数,也就是(2^(n)-2)*( 2^(m)-2)

然后计算则查找哪些数是可行的

 

25 杭电 春 1009

将长度为n,值域为[0,2^m)的整数数组划分为若干个互不相交的子数组,每个子数组a[l]~a[r]的价值f(l,r)为区间不全为0/1的按位位运算,g是输入给定的映射函数,要求最大化划分得到的总价值

根据f运算的定义,不全为1或0时,取值为1,考虑有没有办法转化成一般的位运算,可以发现,这等价于区间按位或减去区间按位与

考虑dp,令f[i]表示a[1]~a[i]划分后的最大价值,这样转移需要从每一个可能的右端点j进行转移,复杂度是O(n^2)

思考如何进行优化

但是在i固定的时候,f(l,r)不同的j可以被划分为不超过O(m)个区间,因此只需要维护出每个区间上dp的最大值即可

用vector存储这些区间的左端点,对应的dp最值,区间与和区间或,每次i加一的时候相当于往vector里面增加一个新区间,然后进行区间的变化,因为vector只会有O(m)个区间,因此直接扫描即可

等到f[l,r]相同时可以合并相邻区间

也可以进一步优化,也就是不存储左端点

也就是用dp存储以i结尾的情况的最大值,而用vec来具体存储dp的状态集合

每个状态是一个三元组{val,sand,sar},分别表示当前划分完毕子数组累计的价值、当前子数组所有元素按位与的结果以及当前子数组所有元素按位或的结果

状态集合初始包含{0,(1<<m)-1,0},而遍历到每个位置,就更新每个状态,相当于将这个元素并入到对应的划分情况中

(直接从每个位置进行转移不现实,因为目标是最优值,考虑能不能根据状态的特征相同值只维护一个最优状态来减少状态数)

减少状态数的方法是每次更新完毕后进行状态合并,每次进行转移的时候实际上是取决于数组的按位与和按位或结果(因为贡献g是按照这两个位运算结果来计算的),因此对于这两个值相同的状态我们只去维护其中的最值,因为我们的划分块数并没有受到限制,因此这样操作是可行的

因为贡献是在一个块划分结束后加入的,因此更新时并不需要对val进行更新,只需要更新两个位运算的结果即可,在当前元素作为一个划分的右端点时,再将dp[i]作为新的val加入

void solve() {  

    int n,m;

    cin>>n>>m;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> g(1<<m);

    for(int i=0;i<(1<<m);i++){

        cin>>g[i];

    }

    vector<i64> dp(n,-inff);  

    vector<array<i64,3>> vec;

    vec.push_back({0,(1<<m)-1,0});

    for(int i=0;i<n;i++){

        for(auto &[val,sand,sor]:vec){

            sand&=a[i];

            sor|=a[i];

            dp[i]=max(dp[i],val+g[sor-sand]);

        }

        vector<array<i64,3>> tmp;

        for(int i=0;i<vec.size();i++){

            if(tmp.empty() || tmp.back()[1]!=vec[i][1] || tmp.back()[2]!=vec[i][2]){

                tmp.push_back(vec[i]);

            }else{

                tmp.back()[0]=max(tmp.back()[0],vec[i][0]);

            }

        }

        tmp.push_back({dp[i],(1<<m)-1,0});

        vec=tmp;

    }

    cout<<dp[n-1]<<'\n';

}

 

 

 

要注意位运算的优先级,如在二分中,应写为mid=l+((r-l)>>1),否则会运行错误

位运算的特点:每个比特位互不相干

因此可以拆分成每个比特单独看(T2527),就转换成只有0和1怎么看,对于异或的结果,0没有任何影响,因此相当于统计每个比特位上1的个数,为奇该位的结果就为1,为偶即为0,当然我们也可以逆向推理,如果各个数异或出来为1,说明这个比特位上1的个数应该是奇数

 

查看一个数据是否已经出现过可以利用位运算,每个bite位分别代表一个数据,为1 为已经出现,在遍历时就可以用|运算来统计(T2103)

 

或运算或出来的数一定大于等于原数,注意二进制拆位的思想(1,2,4能或出1-7的任何数)(T2568),只有2^k的数不能被其他数通过或运算得到

快速判断一个数是否是2的幂次:(x & (x-1))== 0

low bit法

找一个二进制数中最低位的1:

mask=~mask

return mask & -mask

#(~x)+1等价于-x

 

判断某一位是否为1:(x>>i )&1  注意不是(num & (1<<i)) == 1例如num=2,i=1按照思路应该返回1,但这里会返回false,因为这样操作括号内的值为2(T2917)

 

对该思路进行扩展,每个数字都可以用二进制01来表示,对于每个比特位,各个位上都是0、1的叠加,所以如果只有一个数出现了一次,其他数都出现了3次,那么各个比特位上模3得到数就是只出现一次数的二进制(T137)

 

位运算核心考虑:每个bite位单独计算

Cf2043E

给定两个矩阵:a和b,有两种操作,一种是对某一行的所有元素取与,另一种是对某一列的所有元素取或

问能否将a通过这两种操作变成b

影响多个位的运算都可以拆分为多个只影响一个位的运算

因此,能执行的操作可以转变为将某一行中的所有元素设为0和将某一列中的所有元素设为1(因为如果将矩阵中的元素与1取与那么相当于没有发生任何变化,因此只能选择和0取与)

于是找到所有a[i][j]!=b[i][j]的位置,这些位置是一定要发生变化的并且通过为0和为1的关系,可以判断出要进行哪种操作方式,同时显然任何一种操作最多只需要执行一次,只是两种操作的先后关系需要考虑

因为两种操作实际上是将某一位的元素取异或,因此原来相同的位置经过其中一种操作后会变得不同,这种情况下就必须进行另外一种操作

因此可以建立一个图,在这个图中,每个操作由一个顶点表示,有向边表示如果执行了x操作,那么必须执行y操作,且图中的某些顶点代表我们必须执行执行的操作

如果从改图中的某个顶点出发,路径构成了一个环,那么这个变换显然是不可能的,因为我们将一直重复执行这些操作,反之,就可以利用拓扑排序得到正确的操作顺序

在有向图中搜索玄幻可以使用三色dfs,为每个顶点保留三种状态中的一种:没有到达过,已经到达过但现在还没有出栈,已经到达过但已经出栈

如果在执行dfs的过程中,扩展的节点已经在堆栈中了,那么就说明找到了一个循环

或者使用队列,对每一位都进行判断,如果这一行或列需要操作的位置等于对应的元素个数,则加入,因为通过C和R数组保证了一行和一列只会被操作一次,因此如果当队列元素已经清空后仍没有转换成目标数组,那么就说明是不可行的

void solve(){

    int n,m;

    cin>>n>>m;

    vector A(n,vector<int>(m));

    vector B(n,vector<int>(m));

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            cin>>A[i][j];

        }

    }

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            cin>>B[i][j];

        }

    }

    for(int d=0;d<30;d++){

        vector x(n,vector<array<int,2>>(m));

        vector<int> R(n),C(m);

        queue<int> q;

        for(int i=0;i<n;i++){

            for(int j=0;j<m;j++){

                int c=B[i][j] >> d & 1;

                x[i][j][c]=1;

                if(c==0 && ++R[i]==m){

                    q.push(i);

                }

                if(c==1 && ++C[j]==n){

                    q.push(n+j);

                }

            }

        }

        while(!q.empty()){

            int i=q.front();

            q.pop();

            if(i<n){

                for(int j=0;j<m;j++){

                    if(!x[i][j][1]){

                        x[i][j][1]=1;

                        if(++C[j]==n){

                            q.push(n+j);

                        }

                    }

                }

            }else{

                i-=n;

                for(int j=0;j<n;j++){

                    if(!x[j][i][0]){

                        x[j][i][0]=1;

                        if(++R[j]==m){

                            q.push(j);

                        }

                    }

                }

            }

        }

        for(int i=0;i<n;i++){

            for(int j=0;j<m;j++){

                if(!x[i][j][A[i][j] >> d & 1]){

                    cout<<"No\n";

                    return;

                }

            }

        }

    }

    cout<<"Yes\n";

}

 

Cf 2057C

给定范围[l,r],要求在其中选出三个数a,b,c,使得a^b+b^c+a^c的值最大

因为位运算的每个位都可以单独计算,对于任意一个bite位,可以发现,当其的元素分别是0,0,1或者是1,1,0时,那么根据上述运算规则得到的数值是最大的

又因为我们选择的元素要在[l,r]范围内,因此考虑对l,r两个元素进行逐位的遍历来判断每一位应该如何填充

首先可以想到,我们一定要找到元素不同的最高位,也就是此时r为1,l为0

在该位之前的,a,b,c三个元素一定都是和l,r的取值是相同的,而在该位之后的,因为只要高位大于,那么后续一定是大于的,因此,当令a=(1<<i),b=(1<<i)-1后,一定有a<=r并且b>=l,也就是满足了取值范围的条件,同时在每一位上都满足了a.b的取值不同,也就是无论c取什么值,都满足每一位上是0,0,1或者1,1,0的分布

于是将c设定为l,判断是否与a,b相同,如果相同,就加上1

 

元素与集合:

利用位运算来删除集合中的元素   s&∼(1 << i)

删除元素(一定在集合中)  s⊕(1 << i)

删除最小元素 s&(s−1)

添加元素 s ∣ (1 << i)

全集 (1 << n)−1

补集     ∼s 或者((1 << n)−1)⊕((1 << n)−1)⊕s

属于    (s >> i) & 1=1

 

集合与位运算

集合可以用二进制表示,二进制从低到高第i位为1表示i在集合中,为0表示i不在集合中。即f(s)=

&按位与(可用于求集合之间的交集),|按位或(可用于求集合之间的并集),^按位异或(可用于求集合之间的对称差,特别的当b a时,即是求两集合的差)(如果两个相应的二进制位值不同则为1,否则为0)

求a\b时( a, b)可以a&b再按位取反,写作a&~b包含于就是a&b=a,a|b=b

移位运算

<<表示左移,>>表示右移,左移i位相当于乘 ,右移i位相当于除

单元素集合{i} 等价于1<<i (按上述的压缩就是 )  全集 等价于(1<<n)-1 

属于 等价于 (s>>i)&1=1

利用位运算遍历元素

for (int i = 0; i < n; i++) {

    if ((s >> i) & 1) { // i 在 s 中

        // 处理 i 的逻辑

    }

}

保留二进制最低位的1:s & -s

对于数组中的元素x,得到x的第i个二进制位:(x>>i)&1 (可视为1的前面有若干个前置0)

因此对数组中任意元素在此比特位上1的个数就可以写为 for (int x: nums)  cnt1 += x >> i & 1;

最后结果在此比特位上就可以视作(将数都自动补全0会好理解很多,ans原来在这个位上并没有值,因此此位可视作0,因而能用或运算)ans |= cnt1 % 3 << i(与或运算的优先级较低)

 

位运算具有结合性,也即先计算哪个运算符结果都是相同的

cf 2030C

给定一个01字符串,玩家先后在各个位置中间放置 与 或者 或,玩家一想让最后的表达式为真,玩家二想让最后的表达式为假

注意运算的优先级顺序,or的运算要晚于and,也就是or将在所有的and计算完毕后计算

而or只需要其中有一个1就能保证获胜

因此如果数组的第一个或者最后一个元素是1那么Alice获胜

而如果数组中存在11,那么也是Alice获胜,因为可以先构造成or 11,之后无论Bob在11中间放置什么:如果放置or,那么获胜;如果放置and,那么只需要在11后再放置一个or即可

两个or之间的语句是1,一定能保证最后的语句是1

 

Cf 2050C

Bitset实现dp以及模运算

可以dp,每一位有两种可能,要么平方要么不平方

通过同余的性质可以知道,一个数模9等于是其数位和模9

用长度为9的01串来表示可以取到的模数,1-9位分别代表0-8

每一轮加上一个数通过左移实现

通过右移可以得到大于9的部分对9取模的结果(也就是减9)

因为存储的是可行的情况,因此将原来的串和右移之后的结果取或

每一轮操作完后通过和1<<9-1取与来消去大于9的部分

遍历完成后判断最后一位,也就是代表0的那一位是否为1就可以知道模9为0是否可行

void solve(){

    string s;

    cin>>s;

    int f=1;

    for(auto c:s){

        int d=c-'0';

        int x=d*d;

        int nf=f<<d;

        if(x<10){

            nf|=f<<x;

        }

        nf|=nf>>9;

        nf&=(1<<9)-1;

        f=nf;

    }

    if(f&1){

        cout<<"YES\n";

    }else{

        cout<<"NO\n";

    }

}

 

 

2024哈尔滨A

给定L,R,要构造一个DAG,满足仅有一个起点和一个终点,每条边有权值0/1,L,R之间的任意数都可以由起点到终点的一条路径表示处理啊,同时不能有前导零,给出构造方案

显然对于两个节点分别连接0,1两条边的结构可以构造出任意的结构

同时通过连向子图的某个节点,就可以应用其结构

于是,对于询问,可以拆成若干个子询问,分别利用子图来构造

可以从L在二进制表示下的低位枚举到高位,遇到一个0时,考虑把0变成1,同时右边所有数全部改为0,得到一个新的数L’;全部改为1,得到一个新的数R’

若R’<=R,则拆分出一个新的子询问[L’,R’],然后枚举下一位

否则对R进行类似的操作,也就是遇到1时,考虑把1变成0,同时将右边的所有数变成0(L’)或者全部变成1(R’)

通过这样的拆分,可以保证任意两个子询问的交为空,且所有子询问的并即为[L,R]

map<int,int> mp[1000010];

vector<pair<int,int>> e[1000010];

 

void solve(){

    int l,r;

    cin>>l>>r;

    mp[0][0]=0;

    int n=1;

    auto dfs=[&](auto self,int l,int r)->int{

        if(mp[l][r]){

            return mp[l][r];

        }

        mp[l][r]=++n;

        if(l==0 && r==0){

            return n;

        }

        if(l==1){

            e[1].emplace_back(n,1);

            l++;

        }

        int x=n;

        int l0=(l%2==0?l/2:(l+1)/2);

        int r0=(r%2==0?r/2:(r-1)/2);

        int l1=(l%2==1?l/2:(l+1)/2);

        int r1=(r%2==1?r/2:(r-1)/2);

        if(l0<=r0){

            int id=self(self,l0,r0);

            e[id].emplace_back(x,0);

        }  

        if(l1<=r1){

            int id=self(self,l1,r1);

            e[id].emplace_back(x,1);    

        }

        return x;

    };

    dfs(dfs,l,r);

    cout<<n<<'\n';

    for(int i=1;i<=n;i++){

        cout<<e[i].size()<<' ';

        for(int j=0;j<e[i].size();j++){

            cout<<e[i][j].first<<' '<<e[i][j].second<<" ";

        }

        cout<<'\n';

    }

}

 

2024CCPC 重庆C

可以得到结论,因为递推关系式相当于是每次乘3,因此可以看作是3进制下每一位的影响

void solve(){

    int n;

    cin>>n;

    string s,t;

    cin>>s>>t;

    for(int i=0;i<n;i++){

        if(s[i]!='1' && t[i]!='1'){

            cout<<0<<'\n';

            return;

        }

    }

    cout<<1<<'\n';

}

 

Cf 2048c

给定一个最高位为1的字符串

求取出两个字串,使得两个字串经过xor运算后的结果最大

例如取出的1000和100,那么进行xor运算后的结果是1100

因为判断这样的二进制数的大小的方式和字符串是相同的,也就是比较字符串的字典序,因此可以用字符串来存储最值

同时虽然字符串之间不能进行异或运算,但可以通过对字符串的某个位置异或上1进行操作

因为只有1会对具体的值产生影响,因此只需要判断为1的位置即可

对于位元算来说,显然1所在的位置越高则值越大,于是记录当前使得最高位为1的位置

由于计算时是从右到左进行位运算的,因此枚举右端点,再遍历左端点

void solve(){

    string s;

    cin>>s;

    string ans;

    int n=s.size();

    int L=-1,R=-1;

    for(int r=0;r<n;r++){

        string t=s;

        int i=r;

        for(int l=r;l>=0;l--){

            if(s[l]=='1'){

                t[n-1-(r-l)]^=1;

                if(t[n-1-(r-l)]=='1'){

                    i=l;

                }

            }

        }

        t=s;

        for(int l=r;l>=i;l--){

            if(s[l]=='1'){

                t[n-1-(r-l)]^=1;

            }

        }

        if(t>ans){

            ans=t;

            L=i;

            R=r;

        }

    }

    cout<<1<<' '<<n<<' '<<L+1<<' '<<R+1<<'\n';  

}

 

 

2024 ICPC成都G

给出一个整数序列,可以在相邻元素之间插入ai ^ ai+1,ai & ai+1,ai | ai+1,问进行无数次这样操作之后,最后数组中不同元素个数的最大值

由于新插入的整数只能插入到相邻整数之间,因此新插入的整数不会对除这两个整数之外的其他整数产生影响,所以只需要对相邻整数进行考虑

首先,利用异或的性质,原序列中相邻的整数,利用异或可以无限扩展,例如:
x , y , x^y , x , y , x^y , ….

因此,可以构造出无限个x与y相邻的情况

因为x和y的第i位的搭配是有限的,因此可以直接枚举考虑

对于x和y的二进制的第i位和第j位,如果xi=xj且yi=yj,那么操作之后的得到的数z的二进制中一定有zi=zj(位运算操作的独立性,每个位都经历了相同的操作)

同时可以发现,0是一定可以得到的

因此对于相邻的两个整数x,y,可以生成的整数有x,y,x&y, x| y, x^y,x & (x^y) ,y & (x^y),0

用set维护不重复元素

void solve(){

    int n;

    cin>>n;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    set<i64> s=set(a.begin(),a.end());

    for(int i=0;i<n-1;i++){

        int x=a[i]|a[i+1];

        int y=a[i]^a[i+1];

        int z=a[i]&a[i+1];

        s.insert(x);

        s.insert(y);

        s.insert(z);

        s.insert(x^a[i]);

        s.insert(x^a[i+1]);

    }

    s.insert(0);

    cout<<s.size()<<'\n';

}

 

 

位运算贪心

cf1935E

对于每一位,只要区间内存在一个数该位为 11就能产生贡献

对于区间 [x,y],设最终选定的数x≤c≤y。考虑x 和 y 在二进制下最高的不同位 k,则只要保证 c 的第 k 位为 0,y 的第 k 位为 1,此时一定能贪心地让 c 的第 0∼k−1 位为 1,这样不仅能够最大程度地满足 x≤c 的限制(因为这样与y的相差最小,因此一定可以满足大于等于x),也能让 c 的每一位都取到最大贡献

于是,设 x 和 y 在二进制下最高的不同位为 k(最高的不同位实际上也是在求x,y的二进制表示下最长的前缀),则可以让 y 的第 k′ 位的 1 变成 0,第0∼k′−1 位全部变成 1,要求 k′≤k 且 y 的第 k′ 位确实为 1

令f(k,l,r) 表示yl∼r中y的第k 位 为1 的个数,g(k,l,r) 表示第 k 位yl∼r可以变为 0 的 1 的个数,即yi (l≤i≤r) 产生贡献当且仅当yi 的第 k 位为 1 且 k 不高于xi 和 yi在二进制下最高的不同位。f,g 均可预处理前缀和 O(1) 计算

void solve() {

  cin >> n;

  for(int i = 1; i <= n; i++) {

    cin >> x[i] >> y[i];

    int d = log2(y[i] ^ x[i]);//与非+log2找数字不同的最高位,而且一定是该位上y为1,x为0

    for(int j = 0; j < 30; j++) {

      cnt[j][i] = cnt[j][i - 1] + (y[i] >> j & 1);//j位为1的个数

      _cnt[j][i] = _cnt[j][i - 1] + ((y[i] >> j & 1) & (j <= d));//之所以要求j也为1,是为了避免有些情况下y作为最大值之前的1已经确定用过了

      //因此不能从最高位的下一位开始就全部为1,加了j&1的条件后,就可以去判断从j位之后可以全部变为1

    }

  }

  cin >> q;

  for(int i = 1; i <= q; i++) {

    int l, r, ans = 0;

    cin >> l >> r;

    for(int j = 29; ~j; j--) {

      int c = cnt[j][r] - cnt[j][l - 1];

      if(c == 0) continue;

      if(c == 1) ans |= 1 << j;

      if(c > 1) {//c>=2保证了至少有一个y使得这一位为1,而判断是否有另一个y使得这之后的bite都为1

        int _c = _cnt[j][r] - _cnt[j][l - 1];

        if(_c) {

          ans |= (1 << j + 1) - 1;

          break;

        }

        else ans |= 1 << j;

      }

    }

    cout << ans << " ";

  }

  cout << "\n";

}

 

popcount问题

计算1~n的popcount和

通过列出0~2n-1的每一个数的二进制形式可以发现

在每一位中,都是一半是0,一半是1

于是

因此对于n,我们对每一位进行判断

例如214,其二进制形式是11010110,其第一位为1,因此我们可以计算从00000000~01111111的popcount和,因为这满足上面的形式因此可以直接带入公式,但同时不要忘记高位不变的1的个数;其第二位为1,因此可以计算1000000~1011111的popcount和,为1*26+6*25;第三位为0,将被之后的情况包括,因此对于答案并没有贡献,依次类推,能够计算出[0,n)区间内的popcount和,最后只要再单独计算n的popcount即可

cin>>n;

long long tot = 0;

int cnt = 0;

int x = n;

while(x)

{

    if(x & 1)

        tot += (cnt * (1 << (cnt - 1))) + (1 << cnt) * __builtin_popcount(x >> 1);

    x >>= 1;

    cnt++;

}

tot += __builtin_popcount(n);

 

cf1734F

popcount有个性质:popcount(x)^popcount(y)=popcount(x^y)

通常可以考虑从低到高进行dp

 

cf1982E

问题:要求元素对应的二进制中1的数量不超过k,求满足这类情况的子数组的个数,初始数组是长度为n的0~n-1的元素

考虑popcount的规律

令f(n,k)是解决问题的函数,其包含l,r,ans三个值

其中l表示对于前l个数字(即从0到l-1),bit(x)<=k成立,bit(l)>k或者l>=n

同理r表示末尾的r个数字(即从n-r到n-1),bit(x)<=k成立、

ans则是表示对于(n,k),问题的答案

进而采用分治的思想,将0到n的线段分成两段:从0到2m-1,其中m是满足2m<n的最大值,以及从2m到n-1的部分

于是我们可以根据f(2m,k)和f(n-2m,k)计算得到f(n,k)

 

 

模三加法

对异或的思路再进行扩展,相当于现在是在0,1,2之间不断转换,而)0,1,2要两个比特才能实现,因此设这两个比特为a,b,则a=0,b=0代表数字0,a=0,b=1代表数字1,a=1,b=0代表数字2

这其中的转换可以用异或运算实现,但也有特殊情况,即a=0,b=0时,进行异或后a应该仍然是1,a=1时,b进行异或后应该仍旧是0;因此可以写出a,b的转换代码

for (int x: nums) {

            b = (b ^ x) & ~a;

            a = (a ^ x) & ~b;

        }

又位运算有并行计算的特点,因此上述的运算规则可直接对多比特的x进行操作(a,b事实上是对于二进制中的一个bite位的操作,相当于a是另外辅助存储的一个进一位,因此可以扩展到多个比特的情况),而此题的模3 的结果只会是0,1,因此直接输出b即可

 

/2等价于>>1,并且右移操作可以累加,即>>1>>1等价于>>2(T2920)

 

当要逐个判断前一个数和后面所有数的与值是否为0时,可以直接将后面的数或起来,相当于统计一下那些bite位是已经为1的,然后需要判断与值的时候直接将前一个数与这个数与一下即可(T309)

for (int i = 0; i < nums.size(); i++) { // 枚举子数组右端点 i

            int or_ = 0, j = i;

            while (j >= 0 && (or_ & nums[j]) == 0)

// nums[j] 与子数组中的任意元素 AND 均为 0

                or_ |= nums[j--]; // 加到子数组中

            ans = max(ans, i - j);

        }

将一些匹配问题用位运算转化(T2397),每一行中1的位置与我们选择的列数相同,就不妨将我们选择的列标记为1,进行二进制压缩后与那一行对应的数进行&运算,若运算结果相同,说明是匹配成功的,并且,我们选择列的情况(当列数并不是很多时)也可以用二进制去枚举表示我们选了哪些列

 

位运算贪心

cf1957b
要让1的个数最多,显然想到2x-1这个数,因为它的所有bite位上全是1,这已经使或之前的结果最优了,并且我们只有在k==2(x+1)-1的情况下才可能最后得到x+1个1,不然因为2进制是可以任意拆分的(比如6=2+4+1,无论怎么拆分,最高位是0还是1一定是被确定的),如果我们最终bite位一共有x+1个1那么一定是超过了k,而且注意我们进行的非负数的相加,因此可以是取0,因此我们不妨分解为2x-1,k-(2x-1),0....

void solve() {

    int n, k;

    cin >> n >> k;

    vector<int> a(n);

    if (n == 1) {

        a[0] = k;

    }

    else {

        int msb = 0;

        for (int i = 0; i < 31; i++) {//找最高位的1

            if (k & (1 << i)) {//实际上也可以用这种写法进行二进制转化

                msb = i;

            }

        }

        a[0] = (1 << msb) - 1;

        a[1] = k - a[0];

        for (int i = 2; i < n; i++) {

            a[i] = 0;

        }

    }

    for (int i = 0; i < n; i++) {

        cout << a[i] << " ";

    }

    cout << "\n";

}

 

 

位运算,或者说状态压缩,要对于数字(范围)的大小要敏感,大部分都是考虑31范围内可以这样表述

cf1966d

题意:给定 n(n≤106) 和 k(k≤n)。构造一个长度小于等于25 的序列 a 满足:
1. 不存在一个子序列的和为 k。
2. 对于 1≤i≤n,i≠k,存在一个子序列的和为i。

看到长度为 25,首先肯定会想到二进制。那么我们先构造出一个序列 [20,21,…,219],会发现这样一定可以表示出所有1 到n 的数。但是要求不能表示出k,所以我们把2删掉,其中 p 是 k 的最高位,这样就一定表示不出 k 了。

删掉之后会出现一个问题,除了 k 之外第 p 位为 1 的数也不能表示出来了,考虑如何解决。

一个结论是:再加上k−2p,k+1,k+1+2p 这三个数,最终的序列一定合法。

Prove:

1≤i≤k−2p
因为k−2p≤2p−1,所以直接用[20,21,…,2p−1] 表示即可。

k−2p<i≤k−1
先将i 减去k−2p,因为i−(k−2p)≤2p−1,所以剩下的直接用[20,21,…,2p−1] 表示即可。

i=k
所有小于等于 k 的数的和才k−1,显然表示不出 k。

k+1≤i≤n
先将 i 减去k+1,如果减完之后 i 的第 p 位是 1 的话就改为减 k+1+2p。这样减完之后的 i 的第 p 位一定为 0,所以一定能表示出来

(利用二进制去表示数)

# __lg和log2都是计算以2为底的对数的函数,但__lg返回整数,log2返回浮点数

int n,k,ans[100];

void solve(){

    cin >> n >> k;

    int p = __lg(k),tot = 0;

    for(int i = 0;i <= 19;i++)

        if(i != p) ans[++tot] = (1ll << i);

    ans[++tot] = k - (1ll << p);

    ans[++tot] = k + 1;

    ans[++tot] = k + 1 + (1ll << p);

    cout << tot << endl;

    for(int i = 1;i <= tot;i++)

        cout << ans[i] << " ";

    cout << endl;

}

 

2024 东北四省 E

根据题目要求,在之前的计算中,只有1的个数是对答案产生的影响的,又因为A中1的个数已知,因此可以考虑枚举一下B中1的个数,因为最多只有20个bite位,于是枚举是可行的

假设有i个1,这样就能算出总共有多少个1,然后根据它的操作原理就能得到D

如果合法D就是B,接下来只要再判断一下D里面1的个数是否是i个,如果是就说明D确实是B

void solve(){

    int n,k;

    cin>>n>>k;

    string s;

    cin>>s;

    s=' '+s;

    int cnt=0;

    for(int i=1;i<=n;i++){

        if(s[i]=='1') cnt++;

    }

    for(int i=0;i<=k;i++){//枚举1的个数

        int tmp=cnt+i;

        tmp&=((1<<(k+1))-1);//D即B

        int kk=0;

        for(int j=0;j<=k-1;j++){

            if((tmp>>j)&1==1){

                kk++;

            }

        }

        if(kk==i){

            for(int j=k-1;j>=0;j--){

                cout<<((tmp>>j)&1);

            }

            cout<<'\n';

            return;

        }

    }

    cout<<"None\n";

}

 

 

异或

任何数和 0做异或运算,结果仍然是原来的数,即 a⊕0=a

任何数和其自身做异或运算,结果是 0,即 a⊕a=0

异或运算满足交换律和结合律,即 a⊕b⊕a=b⊕a⊕a=b⊕(a⊕a)=b⊕0=b

因此,寻找数组中只出现一次的数就可以将数组中全部的元素进行异或,剩下的那个就是唯一出现的数字

求异或相关的问题时,可以对每个bite位逐个考虑,然后再总结出普遍处理的规律(T2939),当两个数的bite位上1的总数是一个定值时(指那些不会出现a,b在某一位上同时为1的情况),有a+b也是一个定值,这样要让乘积最大,就是让a,b尽可能接近,也就是可以让a的最高位为1 ,其余1 都给到b

奇数次加入,偶数次抵消可以用异或来做,即异或1

 

cf 1946D

方便起见,将最后的判断标准设定为x+1,这样就只有最终结果小于x的情况了

然后遍历最终数字应当小于x的位,可以从最高有效位遍历到最低有效位,从x的范围来看,初始位为30,然后对于每个bite位,判断数组中有多少个元素该位为1

如果存在奇数个这样的数字,那么至少有一个子段在该位上有1的元素的个数为1,就意味着最后异或的结果该位上的数字为1,因此进行最后的或运算后该位上仍然是1;但此时,如果x在该位上的数字为0,那么应当退出循环过程,因为最终结果一定会比x大了

如果存在偶数个这样的数字,为了使这个位在最终的数字中为0,那么每个分段必须包含偶数个这样的数字,又因为我们想要最大化分段数,因此每个分段应当恰好包含2个这样的数字,因此就让每个索引l,r,al和ar在这位上的数字是1,在这样的操作之后,如果发现x在这位上的数字为1,那么就更新答案,因为不论之后的位数如何,最终数字一定是小于x

并且对于每个l,r,因为我们是从高位到低位进行操作的,因此高位的操作对于低位来说是必要的,因此我们可以直接将遍历过程中的分组(即al和ar在这位上的数字是1这一步)直接更新为新的元素,即将l-r区间内的异或结果替代l-r的数组元素

void solve() {

    int n, x;

    cin >> n >> x;

    ++x;//使得之后的判断是小于

    vector<int> a(n);

    for (int &i: a)

        cin >> i;

    int res = -1;

    for (int i = 30; i >= 0; --i) {

        vector<int> b;

        bool open = false;

        for (int j = 0; j < a.size(); ++j) {

            if (!open)//open使得更新后的b,即在a数组中取的区间端点在该位上都是1

                b.push_back(a[j]);

            else

                b.back() ^= a[j];

            if (a[j] & (1 << i))

                open = !open;

        }

        if (!(x & (1 << i))) {

            if (open) {//从open也能看出在a数组中该位上为1的元素个数是奇数还是偶数

                cout << res << '\n';//奇数情况下,最终数字的该位上为1,因此最终数已经大于x了,直接输出

                return;

            }

            //从高位到低位进行操作,因此这些操作对之后的操作是必须的,因此可以直接修改

            a = b;

        } else {

            if (!open)

                res = max(res, (int) b.size());//当前答案可能是可行的

        }

    }

    cout << res << '\n';

}

 

P9236

因为对于任何数字,异或其本身就是0

因此区间异或和就可以像前缀和一样处理了

于是可以得到暴力的做法:


ll a[100005];

void solve() {

    int n;

    cin>>n;

    a[0]=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        a[i]^=a[i-1];

    }

    ll sum=0;

    for(int i=1;i<=n;i++){

        for(int j=i;j<=n;j++){

            sum+=a[j]^a[j-i];

        }

    }

    cout<<sum<<'\n';

}

但这是O(n^2)的复杂度,因此需要在这个的基础上继续优化

对于我们最终的答案,可以表示成: (即将每一个区间的异或值都加入答案,因此除了枚举区间长度外,也可以通过这种方式进行枚举)

在这个式子中,可以观察到,对于每一对i,j不相等的有序数对(i,j),pi和pj都恰好只互相异或了一次,因此,问题可以转化为n个数,其中两两异或的求和

这时候就式子而言已经推导完毕了,因此必须从其他方面考虑问题,比如异或运算的计算原理方面

看到异或就说明是位运算,而位运算的其中一个特征就是在各个bite位上的运算各自独立,又数据范围给到的是220,这种表示法自然也想到了对每一位进行分析

因此可以考虑把每个数按照二进制拆分,在每一位上统计该数的贡献,由于最后是两两异或的求和,所以二进制拆分后打乱不会影响结果

由于异或的运算法则是如果同位数字不同,那么运算结果的这一位为 1。我们知道,只有二进制位为 1对最终的结果(加和)有贡献,所以我们可以统计二进制结果为 1 的情况

对于每一个pi,我们将其按位拆分,并将结果存入计数数组w[i][j]中,其中i表示第i个二进制位,j表示这一位上为j(只能为0或1),w[i][j]表示在所有数中,第i个二进制位上为j的有w[i][j]个

由于这些数中必定两两异或,因此可以直接使用乘法原理,求出最终该位为1的个数,最后乘上该位的权值,因此最后的答案为:

ll n,a[100010],q[100010],w[100010][2],ans=0;

void solve() {

    cin>>n;

    for(int i=1;i<=n;i++)cin>>a[i];

    for(int i=1;i<=n;i++)q[i]=q[i-1]^a[i];

    for(int i=0;i<=n;i++)

        for(int j=20;j>=0;j--)

            w[j][(q[i]>>j)&1]++;

    for(int i=0;i<=20;i++)

        ans+=w[i][0]*w[i][1]*(1<<i);

    cout<<ans;

}

 

蓝桥杯 22 选数异或

由异或的性质可以得到a b=x  <->  a x=b

因为数组a以及目标x都已经提前给出,因此可以提前进行预处理,因为考虑到之后是区间查询,因此定义pre[i]为能进行合法匹配的最大的左端点,最后只需要检验pre[r]是否大于等于l即可

int a[100005],pre[100005];

unordered_map<ll,int> mx;

ll x;

void solve(){

    int n,m;

    cin>>n>>m;

    cin>>x;

    pre[0]=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        mx[a[i]]=i;

        pre[i]=max(mx[a[i]^x],pre[i-1]);

    }

    for(int i=1;i<=m;i++){

        int l,r;

        cin>>l>>r;

        cout<<(pre[r]>=l?"yes\n":"no\n");

    }

}

 

cf1955F

注意到面值为 4 的银币比较特殊,其异或其他面额的硬币任意枚的结果都不为 0,所以我们可以先让 4 的数量为偶数,把其他三类移空后再移去 4,这样在只有面值为4的硬币时其对答案的贡献为a4/2下取整

然后默认 44不存在,考虑其它三类,由于 1⨁2⨁3=0 ,因此如果 a1,a2,a3均是奇数,那么可以算一次贡献。然后让他们全部变成偶数,也就是除了全奇数的情况需要加一次贡献,其余直接舍弃掉减一。接下来可以让三个位置都减一,这样每三场比赛赢一场,或者让其中一个位置先减二,这样两场比赛赢一场,显然后面的方案更优,因为我们无法保证三个位置的数相同,即总会有一个位置先减完,不如挨个减

最后

 

cf 1934d

首先注意到如果 m 已经是 n 的子集则可以一次操作使得 n→m

且生成任何可能的 m 最多需要两次操作

这类题有一个关键点在于只要保证了从高到低有一位x=1但y不等于1就能保证x>y

而且可以注意到重要的位数在于n中(从大到小)第一位为1的和第二位为1,如果m中第一个为1的在这两者之间,那我们显然是无法通过一个比n小的数与n异或得到m,反之,我们一定能做到:当m的第一位与n相同时,因为m<n,因此我们一定可以一次操作将n转变为1;如果不是,我们采用贪心的策略,因为只要m是当前n的子集那我们一定能够将n变为m,所以为了增大可能性,那我们可以制造尽可能多的1,即使第二位及以后的全部变成1,显然这个操作是可行的

注意,右移的位数比较大的时候,会超出int的上限,因此要定义成ll tmp=(1ll<<(r+1))-1;

并且位运算的优先级很低,判断相等时一定要加上括号,例如if((m>>l)&1),if((n&m)==m)

void solve() {

    ll n,m;

    cin>>n>>m;

    if((n&m)==m) {cout<<1<<'\n'<<n<<' '<<m<<'\n';return;}

    int l=-1,r=-1;

    for(int i=62;i>=0;i--){

        if((n>>i)&1){

            if(l==-1) l=i;

            else{

                r=i;

                break;

            }

        }

    }

    if(r==-1){

        cout<<-1<<'\n';

        return ;

    }

    if((m>>l)&1){

        cout<<1<<'\n'<<n<<' '<<m<<'\n';

        return ;

    }

    for(int i=l-1;i>r;i--){

        if((m>>i)&1){

            cout<<-1<<'\n';

            return;

        }

    }

    ll tmp=(1ll<<(r+1))-1;

    if(tmp==m){

        cout<<1<<'\n'<<n<<' '<<m<<'\n';

        return;

    }

    cout<<2<<'\n'<<n<<' '<<tmp<<' '<<m<<'\n';

}

 

cf 1957d

异或题目还有一个比较常用的数据是元素对应二进制位中最高位的1

首先,对于该题的不等式,我们显然可以将其转化为f(x,z) a[y]>f(x,z),用语言来表述就是找那些将a[y]的贡献记入时,其整体的xor值会减小的子数组

考虑什么情况下才会实现这一点:当a[y]的某一个bite位抵消了f(x,y-1) f(y+1,z)中已有的为1的bite位,才有可能达到使整个子数组的异或值减小的情况,这时再异或上a[y]就满足了上述的不等式

考虑a[y]中的最高位1,因为显然是这个位决定了异或上该数后整体的值是增大还是减小,因为我们显然可以得到哪怕这第i之后的所有bite位都是1,他们所影响的值也不过是2i-1,仍然小于我们这一位造成的贡献值,因此,如果第i位是前后缀子数组中都为0的bite位,那么异或上a[y]会产生正贡献,而当前后缀子数组中只有一个在这位上为1时,这个子数组就是满足我们要求的

并且这种以3个数作为一组的,我们一般都是固定枚举中间一个数去计算两侧可行的边界

const int Z = 30;

const int MAX_N = 1e5 + 3;

//前后缀在这个位上是否是1,并记录个数

//因为要记录个数,因此不能是简单的做异或和的前后缀数组了

int pref[Z][MAX_N][2];

int suff[Z][MAX_N][2];

void solve() {

    int n;

    cin >> n;

    vector<int> a(n + 1);

    for (int i = 1; i <= n; i++) cin >> a[i];

    for (int i = 0; i < Z; i++) suff[i][n + 1][0] = suff[i][n + 1][1] = 0;

    for (int i = 0; i < Z; i++) {

        for (int j = 1; j <= n; j++) {

            //int t = !!(a[j] & (1 << i));

            //如果是采用上面的写法,如果该位上为1,得到的是1<<i,取反一次得到1,再取反一次才得到1

            //因此不能直接a[i]&(1<<i)

            int t=(a[j]>>i)&1;

            for (int k = 0; k < 2; k++) pref[i][j][k] = (t == k) + pref[i][j - 1][k ^ t];

            //异或类的预处理,加上这个数后该位上的值会发生变化

        }

        for (int j = n; j >= 1; j--) {

            //int t = !!(a[j] & (1 << i));

            int t=(a[j]>>i)&1;

            for (int k = 0; k < 2; k++) suff[i][j][k] = (t == k) + suff[i][j + 1][k ^ t];

        }

    }

    long long ans = 0;

    for (int i = 1; i <= n; i++) {

        int z = 31 - __builtin_clz(a[i]);//找到最高位的1

        //将a[i]作为y

        ans += 1ll * pref[z][i - 1][1] * (1 + suff[z][i + 1][0]);

        ans += 1ll * (1 + pref[z][i - 1][0]) * suff[z][i + 1][1];

    }

    cout << ans << "\n";

}

 

cf 1971G

题目中最奇怪的地方在于为什么异或后的值要小于 4,就从这入手

题目要求异或后小于4的两个数可以任意交换,求交换过后数组字典序最小的排序

从异或的概念可以得到,当两个整数异或值小于4时,说明它们除了最后两位之外的所有位是相同

因此我们可以将所有数右移两位(除以4),并将其放入一个集合中(表示这些数归于同一类,并且同一类的数可以任意交换),因为最终要得到的字典序最小的排序,因此可以直接用map存储

然后就可以对map中的数放入对应a中的位置

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    map<int,vector<int>>p;

    for(int i=0;i<n;i++){

        cin>>a[i];

        p[a[i]/4].push_back(i);

    }

    for(auto [_,p]:p){

        vector<int> b(p.size());

        for(int i=0;i<p.size();i++){

            b[i]=a[p[i]];

        }

        sort(b.begin(),b.end());//取出下标对应的元素进行排序

        for(int i=0;i<p.size();i++){

            a[p[i]]=b[i];

        }

    }

    for(int i=0;i<n;i++){

        cout<<a[i]<<' ';

    }

    cout<<'\n';

}

 

cf1994 G

给定一个数组和s,找出一个数x,使得异或x后所有数的和等于s

也就是每一位有两种选择

可以按位操作,不同位之间操作独立

这样n个数具体是多少就不重要,重要的只是每一位上有多少个1

对于第i位有ai个1,如果答案这一位选0,那么这一位贡献ai*(2^i),否则贡献(n-ai)*(2^i)

于是问题变成了每一位上有两种选择方法,询问能否凑出某个数,但直接这样做比较困难

从大到小位考虑,定义dp[i][j]表示在前i位确定后距离s还有j*2^(k-i)的距离时是否可行以及这种情况是从哪里转移过来的

当后面所有位都选择时,因为每位最多增加n*2^(k-x),因此即使在这种情况下总和也不到n*2^(k-i)

于是dp的第二维只需要开到n即可

void solve(){

    int n,k;

    cin>>n>>k;

    vector<int> s(k);

    for(int i=0;i<k;i++){

        char c;

        cin>>c;

        s[i]=c-'0';

    }

    vector<int> cnt(k);//只关心每个位上的cnt

    for(int i=0;i<n;i++){

        for(int j=0;j<k;j++){

            char c;

            cin>>c;

            cnt[j]+=c-'0';

        }

    }

    //更低位的进位,至多是n

    vector dp(k+1,vector<pair<int,int>>(n,make_pair(-1,-1)));

    //第一位表示取值,第二位表示转移到的状态(与s的差值)

    dp[k][0]={0,0};

    for(int i=k-1;i>=0;i--){

        for(int j=0;j<n;j++){

            if(dp[i+1][j].first!=-1){

                //从前一种状态进行转移,前一种状态至少是要能达到的

                for(int x=0;x<2;x++){

                    int nj=j+(x==0?cnt[i]:n-cnt[i])-s[i];

                    if(nj%2!=0){

                        continue;

                    }

                    nj/=2;//j为下一位转移时的状态,在下一位时,所表示的2的幂次会增加一位

                    //因此需要nj/=2,也因此,如果是奇数,说明该转移不可行

                    // assert(0<=nj && nj<n);

                    dp[i][nj]={x,j};

                }

            }

        }

    }

    if(dp[0][0].first==-1){//转移不到0的情况

        cout<<-1<<'\n';

    }else{

        int j=0;

        for(int i=0;i<k;i++){

            cout<<dp[i][j].first;

            j=dp[i][j].second;

        }

        cout<<'\n';

    }

}

 

二进制相关

Cf 2071D1

给定一个正整数n和一个无限进制二进制数列a的前n项,对于m>n的项a[m]=a[1]^a[2]…a[m/2]

要求对于任意给定的i,求出a[i]的值

显然如果直接模拟是可以而得到任意位置的值的,但这样会超时

依据异或的性质,可以想到,对于m>n的位置,如果m1/2==m2/2,那么a[m1]=a[m2],也就是异或为0,同时一定有m2=m1+1这种形式,且一直在序列中存在

换言之,若n是奇数(这样(n+1)/2=(n+2)/2),则a[2*m]=a1^a2…^a[n]^a[n+1]^a[n+2]..^a[m]

当m是奇数时,a[2*m]= a1^a2…^a[n],否则等于a1^a2…^a[n]^a[m],相当于每次只需要递归处理a[m/2]即可,直到m<=n,而前面的a1^a2…^a[n]部分一直是不变的,因此可以预处理得到,并且每次除2异或上这个定值即可

void solve() {

    int n;

    i64 l,r;

    cin>>n>>l>>r;

    vector<int> a(n+1);

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    vector<int> pre(n+1);

    for(int i=1;i<=n;i++){

        pre[i]=a[i]^pre[i-1];

    }

    // if n is envn , a[n+1] != a[n+2]

    if(n%2==0){

        a.emplace_back(pre[(n+1)/2]);

        pre.emplace_back(pre[n]^a[n+1]);

        n++;

    }

    auto get=[&](i64 x)->int{

        if(x<=n){

            return a[x];

        }

        int res=0;

        i64 i=x/2;

        while(i%2==0 && i>n){

            res^=pre[n];

            i/=2;

        }

        res^=pre[min<i64>(i,n)];

        return res;

    };

    cout<<get(l)<<'\n';

}

 

对于要计算[l,r]内的元素和时,可以同样利用奇数和偶数值相等的特征分开计算前缀和,并且利用1~n部分是定值的条件来计算

 

bitset

可以用于降低存放数据的空间大小

相当于一个二进制的数组,并且可以直接用01串赋值

bitset<4>a1;//长度为4,默认以0填充

bitset<8>a2;//长度为8,将12以二进制保存,前面用0补充

string s = "100101";

bitset<10>a3(s);//长度为10,前面用0补充

//要开c++11

cout<<a1<<endl;//0000

cout<<a2<<endl;//00001100

cout<<a3<<endl;//0000100101

如果超出了bitset定义的范围(整数赋初值和字符串赋初值不同):

bitset<2>bitset1(12);//12的二进制为1100(长度为4),但bitset1的size=2,只取后面部分,即00

string s="100101";

bitset<4> bitset2(s);//s的size=6,而bitset的size=4,只取前面部分,即1001

cout << bitset1 << endl;  //00

cout << bitset2 << endl;  //1001

位运算操作

bitset<4> foo (string("1001"));//这种赋值方式就可以直接用,没有限制

bitset<4> bar (string("0011"));

cout << (foo^=bar) << endl;       // 1010 (foo对bar按位异或后赋值给foo)

cout << (foo&=bar) << endl;       // 0010 (按位与后赋值给foo)

cout << (foo|=bar) << endl;       // 0011 (按位或后赋值给foo)

cout << (foo<<=2) << endl;        // 1100 (左移2位,低位补0,有自身赋值)

cout << (foo>>=1) << endl;        // 0110 (右移1位,高位补0,有自身赋值)

cout << (~bar) << endl;           // 1100 (按位取反)

cout << (bar>>1) << endl;         // 0001 (右移,不赋值)

cout << (foo==bar) << endl;       // false (0110==0011为false)

cout << (foo!=bar) << endl;       // true  (0110!=0011为true)

cout << (foo&bar) << endl;        // 0010 (按位与,不赋值)

cout << (foo|bar) << endl;        // 0111 (按位或,不赋值)

cout << (foo^bar) << endl;               // 0101 (按位异或,不赋值)

单一元素访问和修改

bitset<4>a1("1011");//这个赋值方法只能在c++11里用

//可以用上面位运算时的方法即bitset<4>a1(string("1011"));

cout<<a1[0]<<endl;//1

cout<<a1[1]<<endl;//1

cout<<a1[2]<<endl;//0

cout<<a1[3]<<endl;//1

//注意!这两种赋值方式都是反序赋值的,即从右往左传入a1

//所以输出值为1101;

//可以直接输出a1来输出正序

//bitset支持单点修改

a1[0]=0;

cout<<a1[0]<<endl;//0   原来a1是1101,此时a1是0101

cout<<a1<<endl;//0101

各种函数

bitset<8>foo(string("10011011"));

 

cout<<foo.count()<<endl;//5  (count函数用来求bitset中1的位数,foo中共有5个1

cout<<foo.size()<<endl;//8  (size函数用来求bitset的大小,一共有8位

cout<<foo.test(0)<< endl;//true  (test函数用来查下标处的元素是0还是1,并返回false或true,此处foo[0]为1,返回true

cout<<foo.test(2)<<endl;//false  (同理,foo[2]为0,返回false

cout<<foo.any()<<endl;//true  (any函数检查bitset中是否有1

cout<<foo.none()<<endl;//false  (none函数检查bitset中是否没有1

cout<<foo.all()<<endl;//false  (all函数检查bitset中是全部为1

 

bitset中每一个元素可以通过下标的方式访问。一个长度为N的bitset下标编号为[0,N)。

进行单点修改时,直接访问位置然后赋值即可

bitset重载了<<和>>输入输出流,可以使用std::cin和std::cout来读入和输出一个bitset的所有元素。

当读入的长度小于bitset的位数时,会从第0位开始赋值直到字符串结束。

注意,输出时bitset是反着输出的,即第0位是从右向左数第1个

 

bitset提供两个转换函数,可以转换为std::string型,unsigned long int型(即unsigned int)。函数名为别为to_string()和to_ulong();

其中转换成std::string型会转换成01串,其他两个类型会按照二进制位转换成十进制数字

 

reset

bitset的清空操作为reset。将集合内元素全部置零:

s.reset();

 

set

set有两种用法,第一种是直接调用set不带参数,会将bitset内所有元素置1,另一种是set后加两个参数,分别是pos和val,意为将bitset中第pos个元素的值置为v。当v为true时可以省略不写。

s.reset();

s.set()                         //11111111

s.set(3,false)              //11110111

s.set(3)                       //11111111

使用set进行单点修改的复杂度为 O(1), 将所有元素修改的复杂度为 O(n/w)

 

test

test有一个参数pos,返回一个bitset内第pos位的值。

s.reset();

s.set(7);int k = s.test(7);            // k is true

k = s.test(6);                      // k is false

test的时间复杂度为 O(1)

 

any

bitset有一个成员函数为any,返回一个布尔量。若bitset内部存在一位的值为1,则返回true,否则返回false:

s.clear();

bool k = s.any();                        //k is false

s[1] = true;

k = s.any()                                  //k is true

复杂度同上。按照不同编译器版本的实现方法,.any()的常数甚至有可能小于理论值。

 

none

与any相对,返回一个布尔量,不存在任何一个位置的值为1则返回true,否则返回false。

s.clear();

bool k = s.none();                      //k is true

s[1] = true;

k = s.none()                                //k is false

 

count

count返回一个bitset内1的个数,是一个无符号整形:

s.reset();

int k = s.count();                                 // k is 0                                      

s[1] = true;

k = s.count();                                       // k is 1

当然想知道0的个数可以用总长度减去cout喽

 

flip

flip函数类似于按位取反,它的两个声明如下:

bitset& flip();

bitset& flip (size_t pos);

当调用s.flip()且括号内无参数时,会将集合内所有元素取反(0变1,1变0)

当调用s.flip(x)时,会将第x位取反(从0编号)

s.reset();

s[1] = true;       //s is "01000000"

s.flip();               //s is "10111111"

s.flip(1);            //s is "11111111"

 

ABC348F

题意:给出长度为M的N个序列,定义两个序列(A,B)是相似的,仅当有奇数个i,使得Ai=Bi

求有多少对是相似的

当数据比较小时就往暴力方面想,又因为要满足奇数次,与其统计相同的个数,不如将加变为异或运算,这样只需要判断最终有多少行结果是1

最暴力的想法是枚举所有(i,j)暴力判断,进行优化就想到固定i,寻找有多少个j是满足相似的

对此,我们要做的是把所有满足Ai,j=x的数放入vector中(这里数据量较少可以直接放到bitset中),用s[j][x]存储有哪些行满足第j个数是x,然后枚举i行时遍历这一行的s[j][a[i][j]]

(一看到这种题就应该往 bitset 的方向想;如果用 bitset,就应该跳脱之前的思维,尝试从最朴素的暴力重新想起)

const int N = 2007;

long long n, m, ans;

int a[N][N];

bitset <N> s[N][1007], res;

void solve() {

    cin>>n>>m;

    for (int i = 1; i <= n; i ++) for (int j = 1; j <= m; j ++) {

        cin>>a[i][j];

        s[j][a[i][j]][i - 1] = 1;//第i行第j列元素为a[i][j],在对应存储的s中修改

    }

    for (int i = 1; i <= n; i ++) {

        for (int j = 1; j <= n; j ++)

            res[j - 1] = 0;//初始都设定为0,ans就具有了计数的功能

        for (int j = 1; j <= m; j ++)

            res ^= s[j][a[i][j]];//每次对应地以行为编号进行异或

        for (int j = i; j < n; j ++)

            if (res[j]) ans ++;//如果对于第j行与i行有奇数个元素是相同的,那么最终在该位上就会显示为1

    }

    cout << ans;

}

 

 

脑筋急转弯:将两球的碰撞视作两球的穿透

T2810 碰到i就将已有字符串反转,其余情况就在已有字符串末尾添加字符,自然可以利用模拟去解题,但当字符串长度变长时,每次反转的效率会降低,因此变化思路,既然反转后仍然是在字符串末尾添加,那我们可以让字符串不翻转,每次直接在字符串的头部添加字符,我们可以利用双向队列来实现这一操作

构造题,找最难得到的子序列(T2350),考虑包含 1到 k的最短前缀 ,无法得到的子序列的第一个数必然在里面。(有点类似于稀疏表的思路)

提示 2-1

这个前缀的最后一个数 x,在前缀中只会出现一次。

反证:如果 x出现多次,那么我们可以缩短前缀,同样可以包含 1 到 k。

提示 2-2

我们可以取 x当做子序列的第一个数。

提示 3

去掉这个前缀,考虑下一个包含 1到 k的最短前缀。在提示 2-2 的前提下,子序列的第二个数必然在这个前缀中。同样地,取前缀最后一个数当做子序列的第二个数。

根据提示 2-1,按照这种取法,取到的这两个数组成的子序列,一定不会都位于第一个前缀中(读者可以用这两个数相同和不同来分类讨论)。因此这种取法是正确的。

提示 4

不断重复这一过程,直到剩余部分无法包含 1到 k时停止。设我们取到了 m个数,对应着 rolls的 m个子段。由于每一段都包含 1 到 k,rolls必然包含长度为m的子序列:每一段都选一个元素即可组成这样的子序列,因此答案至少为 m+1

可以在遍历数组的同时,用一个 mark 数组标记当前元素在哪个子段已经出现过了。这种做法的好处是每次调用时只会申请一次空间

for (int v : rolls)

            if (mark[v] < ans) {

                mark[v] = ans;

                if (--left == 0) {

                    left = k;

                    ++ans;

                }

            }

 

一般情况下,C语言运行时I/O函数比C++的效率高一些,所以处理大数据量的时候,建议使用scanf/printf组合

头文件cstdio

%a 浮点数、十六进制bai数字和p-记法(C99)

%c 一个字符

%d 有符号十进制整数

%e 浮点数、e-记数法

%f 浮点数、十进制记数法

%g 根据数值不同自动选择%f或%e.

%i 有符号十进制数(与%d相同)

%o 无符号八进制整数

%p 指针

%s 字符串

%u 无符号十进制整数

%x 使用十六进制数字0f的无符号十六进制整数

%% 打印一个百分号

“%m.nf”:输出浮点数,m为宽度,n为小数点右边数位

附加格式l用于长整型数(%ld、%lo、%lx) 或double型实数(%lf、%le)

使用举例scanf("%s %d", str, &i);

 

连续输入多组数据的方法(不需要将两个一起输入)

while(cin>>s){

        cin>>n;

cin.clear(); // 使输入流重新有效

 

连续输入多组字符串

while(getline(cin,temp))

 

gets函数

gets从标准输入设备读字符串函数。可以无限读取,不会判断上限,以回车结束读取

在开始读取前最好用getchar()清理一下之前的回车键,因为若不清理,会将回车键作为一个字符串的结束标志

但如果是循环用gets不需要,因为gets并不会将回车留在输入流中

gets函数的读取目标是字符数组,要得到实际读入了多少个字符:len= strlen(a);

 

getchar 函数

getchar()函数以字符为单位对输入的数据进行读取

在控制台中通过键盘输入数据时,以回车键作为结束标志。当输入结束后,键盘输入的数据连同回车键一起被输入到输入缓冲区中。在程序中第一次调用getchar()函数从输入缓冲区中读取一个字节的数据。需要注意的是,如果此时在程序中第二次调用getchar()函数,因为此时输入缓冲区中还有回车键的数据没有被读出,第二个getchar()函数读出的是回车符

 

next_permutation

next_permutation函数将按字母表顺序生成给定序列的下一个较大的排列,直到整个序列为降序为止,prev_permutation函数与之相反,是生成给定序列的上一个较小的排列   头文件<algorithm>

使用方法:next_permutation(数组头地址,数组尾地址);若下一个排列存在,则返回真,如果不存在则返回假

并且新的排列是直接赋值给原数组的

move函数以及左值右值

左值和右值的概念:左值是可以放在赋值号左边可以被赋值的值;左值必须要在内存中有实体;右值当在赋值号右边取出值赋给其他变量的值;右值可以在内存也可以在CPU寄存器。一个对象被用作右值时,使用的是它的内容(值),被当作左值时,使用的是它的地址

 

引用:引用是C++语法做的优化,引用的本质还是靠指针来实现的;引用相当于变量的别名

引用可以改变指针的指向,还可以改变指针所指向的值

 

引用的基本规则:声明引用的时候必须初始化,且一旦绑定,不可把引用绑定到其他对象;即引用必须初始化,不能对引用重定义;

对引用的一切操作,就相当于对原对象的操作

 

std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值;从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);使用完move后原对象会被清空

C++ 标准库使用比如vector::push_back 等这类函数时,会对参数的对象进行复制,连数据也会复制.这就会造成对象内存的额外创建, 本来原意是想把参数push_back进去就行了,通过std::move,可以避免不必要的拷贝操作

std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝

使用:v.push_back(std::move(str)); 该操作后str为空字符串

 

字符串转数字 stoi()  数字转字符串to_string() 头文件可不添加

如果数据会溢出,可考虑用stoll

 

swap函数,交换两个数的值

 

在c++中,总是要注意两个整数相加是否会出现数据溢出的情况,如若测试用例有两个数相加超过int的数据,需要在if里加上dp[i] < INT_MAX - dp[i - num](T337),或如二分法中mid的计算

 

时间复杂度是指操作次数与输入的操作单元数量之间的函数关系,具体要通过简化算法中的表达式得到

空间复杂度是判断一个算法在运行过程中占用的内存空间的大小,是消耗空间和输入数据量之间的函数关系

 

在使用INT_MAX和INT_MIN时,需要 #include<limits.h>;即代表整型的最大最小值

long long型的最小值LLONG_MIN

 

/2可以用位运算表示>>1,效率更高,但要注意优先级的差异

对一个数据类型重新命名 typedef,如typedef long long ll;

限定三位小数输出  printf(“%.slf\n”,ans)

#include <iomanip>

cout<<setprecision(2)<<fixed<<s<<endl;

 

左边空空格

Cout<<right;

Cout<<setw(3)<<s<<endl;

 

nan:not a number 非数字,判断isnan()

isnan(NaN) = true
isnan(0.0 / 0.0) = true

isnan(Inf - Inf) = true

 

inf :  infinity 无穷大的数,判断isinf
isinf(NaN) = false

isinf(Inf) = true

isinf(1.0/0.0) = true

 

Accumulate的头文件是#include<numeric>,能够用于处理总和,另一个自定数据类型的处理(先不讨论)

用法:accumulate带有三个形参:头两个形参指定要累加的元素范围,第三个形参则是累加的初值

举例:累加字符:string sum = accumulate(v.begin() , v.end() , string(" "));  

      累加求和:int sum = accumulate(vec.begin() , vec.end() , 42);  

如果最终赋值的数为long long型,那么第三个形参应写为0LL

 

memset(结构体/数组名 , "用于替换的字符“ , 前n个字符 );

有些时候memset好像比手写重新赋值要慢

函数解释:将s中的前n个字节用ch替换并且返回s

如:memset(dp,0,sizeof(dp)),值得注意的是memset赋值时是按照字节赋值的,因此要用sizeof函数

头文件 不用额外头文件 或<string.h>

为地址str开始的n个字节赋值c,注意:是逐个字节赋值,str开始的n个字节中的每个字节都赋值为c。

(1) 若str指向char型地址,value可为任意字符值;

(2) 若str指向非char型,如int型地址,要想赋值正确,value的值只能是-1或0,因为-1和0转化成二进制后每一位都是一样的,设int型占4个字节,则-1=0XFFFFFFFF, 0=0X00000000

如若赋值为100,通过memset(a,100,sizeof a)给int类型的数组赋值,你给第一个字节的是一百,转成二进制就是0110 0100,而int有四个字节,也就是说,一个int被赋值为

0110 0100,0110 0100,0110 0100,0110 0100,对应的十进制是1684300900,根本不是你想要赋的值100,这也就解释了为什么数组中的元素的值都为1684300900

memcpy

函数原型void *memcpy(void*dest, const void *src, size_t n);

头文件#include<string.h>

返回值:由src指向地址为起始地址的连续n个字节的数据复制到以dest指向地址为起始地址的空间内

与strcpy相比,memcpy并不是遇到'\0'就结束,而是一定会拷贝完n个字节

如果目标数组destin本身已有数据,执行memcpy后,将覆盖原有数据(最多覆盖n)。如果要追加数据,则每次执行memcpy后,要将目标数组地址增加到你要追加数据的地址

例:memcpy(d,s+14,4);//从第14个字符(V)开始复制,连续复制4个字符(View)

//memcpy(d,s+14*sizeof(char),4*sizeof(char));也可

memcpy(ind, a, sizeof ind);

 

 

递归算法的时间复杂度本质上是要看: 递归的次数 * 每次递归中的操作次数

递归算法的空间复杂度 = 每次递归的空间复杂度 * 递归深度(递归次数)

int function2(int x, int n) {
    if (n == 0) {
        return 1; // return 1 同样是因为0次方是等于1的
    }
return function2(x, n - 1) * x;}
每次n-1,递归了n次时间复杂度是O(n),每次进行了一个乘法操作,乘法操作的时间复杂度一个常数项O(1),所以这份代码的时间复杂度是 n × 1 = O(n)
通过减少对于递归的调用,能够减少时间复杂度,避免出现超时的情况,常见的就是将两个调用转为一个调用但输出两个数字
int function3(int x, int n) {
    if (n == 0) return 1;
    if (n == 1) return x;
 
    if (n % 2 == 1) {
        return function3(x, n / 2) * function3(x, n / 2)*x;
    }
    return function3(x, n / 2) * function3(x, n / 2);}
----->
int function4(int x, int n) {
    if (n == 0) return 1;
    if (n == 1) return x;
    int t = function4(x, n / 2);// 这里相对于function3,是把这个递归操作抽取出来
    if (n % 2 == 1) {
        return t * t * x;
    }
return t * t;}
function4的时间复杂度便是O(logn)
求解斐波那契数列时,若用return fibonacci(i-1) + fibonacci(i-2);  时间复杂度就是O(2^n)(递归二叉树,满节点二叉树的节点数为2*n-1)
优化后
int fibonacci(int first, int second, int n) {
    if (n <= 0) {
        return 0;
    }
    if (n < 3) {
        return 1;
    }
    else if (n == 3) {
        return first + second;
    }
    else {
        return fibonacci(second, first + second, n - 1);
    }}

这里相当于用first和second来记录当前相加的两个数值,此时就不用两次递归了。因为每次递归的时候n减1,即只是递归了n次,所以时间复杂度是 O(n)

动规的时间复杂度可以看作是状态的个数*每个状态运行的时间  而空间复杂度就是状态的个数

时间复杂度可以直接看对每个元素进行了几次操作?

Stringstream

头文件#include <sstream>  ,<sstream> 主要用来进行数据类型转换

将int转化为string

如  stringstream sstream;

    string strResult;

    int nValue = 1000;

 

    // 将int类型的值放入输入流中

    sstream << nValue;

    // 从sstream中抽取前面插入的int类型的值,赋给string类型

sstream >> strResult;

 多个字符串拼接(可以直接利用“+”实现,可以进行字符串的拼接,也能进行字符串与字符的拼接)

如 stringstream sstream;

 

    // 将多个字符串放入 sstream 中

    sstream << "first" << " " << "string,";

    sstream << " second string";

    cout << "strResult is: " << sstream.str() << endl;

 

    // 清空 sstream

    sstream.str("");

可以使用 str() 方法,将 stringstream 类型转换为 string 类型;
可以将多个字符串放入 stringstream 中,实现字符串的拼接目的;
如果想清空 stringstream,必须使用 sstream.str(""); 方式;clear() 方法适用于进行多次数据类型转换的场景
如 stringstream sstream;
    int first, second;
 
    // 插入字符串
    sstream << "456";
    // 转换为int类型
    sstream >> first;
    cout << first << endl;
 
    // 在进行多次类型转换前,必须先运行clear()
    sstream.clear();
 
    // 插入bool值
    sstream << true;
    // 转换为int类型
    sstream >> second;
cout << second << endl;
利用stringstream对字符串进行空格分词
如  stringstream ss; //stream根据空格分词            
string w;
vector<string> a
ss>>s;
while(ss<<w){
           a.emplace_back(w);
}
 

求数组中的最值函数

int mn = *min_element(gem.begin(), gem.end());

int mx = *max_element(gem.begin(), gem.end());

排序

sort(candidates.begin(), candidates.end());

*不能省略,返回元素值需要加*,不加*表示返回迭代器

如果需要地址,需要减去序列头以获得下标,如:

Int a[]={1,2,3,4}

Int m=max_element(a,a+2)-2

若某值x并非偶数,int m=x/2;与m/2不等价,int m=x/2相当于向下取整

取两个值中的较大值直接max(a,b)即可

判断数组是否为空:nums.empty()

取得数组的长度:nums.size()

两个元素取大取小可以直接用max,min函数

对于多个元素用max函数时要用{},如min({l_choose + r_by_children, l_by_children + r_choose, l_choose + r_choose})

minmax_element(),返回一个 std::pair<ForwardIt,ForwardIt> (pair 对),返回的类中的数据成员 first 对应着最小值,second 对应着最大值

可用于解决最长公共前缀(T14)

strs的类型:vector<string>

const auto [str0, str1] = minmax_element(strs.begin(), strs.end());  

//string的比较操作是基于字典序的排序 不是单纯的谁长谁短

for(int i = 0; i < str0->size(); ++i)

if(str0->at(i) != str1->at(i)) return str0->substr(0, i);

return *str0;

 

 

数组赋初值 int a[3]={0};

C/C++不支持数组的整体赋值,可以在声明数组时整体初始化,无论数组有多大,全部初始化为0的操作很简单,如int a[100]={0};可将a的100个元素全部置为0,但若要将其全部赋为其他值,则不行,若要整体赋其他初始值,只能用memset、fill、for循环

int d['Z']{['R'] = 1, ['G'] = 2, ['B'] = 4};

定义了一个名为d的整数数组,数组的大小是'Z',它会根据ASCII编码中字符'Z'的值来确定数组的大小。ASCII 编码中字符 'Z' 的值是 90。

['R'] = 1:这表示将数组d的索引'R'处的值设置为1。['G'] = 2:这表示将数组d的索引'G'处的值设置为2。['B'] = 4:这表示将数组d的索引'B'处的值设置为4。

赋初值时,如果写为int m[10]其各个位置的数据时随机赋值的,而写为int m[10]{}则均赋为0

数组初始化:int d[26]{};

向量初始化vector<int> d(26,0);

 

memset函数(一般只能用来填充char型数组)

·按照字节填充某字符

·在头文件<cstring>里面(或不加头文件)

·使用方法memset(数组名,要填充的内容,单元大小)

fill函数(可以赋值任何)

·按照单元赋值,将一个区间的元素都赋同一个值

·在头文件<algorithm>里面

·使用方法:fill(起始地址,结束地址, 要填入的内容);

 如 int v[10];fill(v,v+10,-1);(一维)      int v[10][10];fill(v[0],v[0]+10*10,-1);(多维)

 

sizeof是用于输出数据类型的大小

指针指向的是地址,对应地址的数值改变,指针所导向的数值也会改变

C++不能像Python一样进行first, second = second, max(first + nums[i], second)的操作,但可以使int 1=0,b=0

字符串的长度可用s.length()

定义一个长为n的字符串,string s(n,0); 构建初始值均为False的bool型数组 vector<bool> used(candidates.size(), false);

构建二维整数型数组int f[C + 1][M + 1]

获得下标:MAPPING[digits[i] - '0']

MAPPING是字符0-9对应的字符串的一个集合(哈希表),通过字符-’0’来快速得到下标

 

pair是将2个数据组合成一组数据,当需要这样的需求时就可以使用pair

另一个应用是,当一个函数需要返回2个数据的时候,可以选择pair

pair将一对值(T1和T2)组合成一个值,这一对值可以具有不同的数据类型(T1和T2),两个值可以分别用pair的两个公有函数first和second访问 即pair.first

在创建pair对象时,必须提供两个类型名,当然也可以在定义时进行成员初始化

还可以利用make_pair创建新的pair对象,如newone = make_pair(a, m)

当函数以pair对象作为返回值时,可以直接通过std::tie进行接收,如std::tie(name, ages) = getPreson()

 

tuple是一个固定大小的不同类型值的集合,是泛化的std::pair,头文件 #include <tuple>

std::make_tuple(v1, v2); // 像pair一样也可以通过make_tuple进行创建一个tuple对象

当tuple的值为引用时

std::string name;

std::tuple<string &, int> tpRef(name, 30);

// 对tpRef第一个元素赋值,同时name也被赋值 - 引用

std::get<0>(tpRef) = "Sven";

// name输出也是Sven

std::cout << "name: " << name << '\n';

 

某些时候tuple可以等同于结构体一样使用

struct person {

    char *m_name;

    char *m_addr;

    int  *m_ages;

};

//可以用tuple来表示这样的一个结构类型,作用是一样的。

std::tuple<const char *, const char *, int>

 

获取tuple中的某一元素,通过get<Ith>(obj)方法:

Ith - 是想获取的元素在tuple对象中的位置

obj - tuple对象,如std::get<2>(mytuple)

tuple不支持迭代,因此get中的索引不能是变量

 

获取元素个数:std::tuple_size<decltype(mytuple)>::value

 

Struct

struct的用处是定义一个新的类型,而这个类型里面可以是各种各样的东西

定义时可以是struct 结构体名 变量名={成员值1,成员值2,...}

struct node{

//定义一个新的类型叫node

int a;

int b[110];

char c;

double d;

};//别忘了分号,毕竟这是个语句

调用时可以直接用.来进行,如node.a=10

同样,定义完结构体之后也可以构建结构体数组

struct student stuarr[3] = {{"张三",18,100},{"李四",17,90},{"王二",16,80}};

因为是新的结构,自然也可以用指针

int main() {

     //1、创建学生结构体变量

     student s1 = { "李四",19,90 };

     //2、通过指针指向结构体变量

     student *p = &s1;

     //3、通过指针访问结构体变量中的数据

     cout << "姓名:" << p->name << "\t年龄:" << p->age << "\t分数:" << p->score<<endl;

     //通过结构体指针 访问结构体中的属性,需要利用“->”

     system("pause");

     return 0;

}

也可以作为函数参数,并且,当作为参数时加入const,一旦有修改操作就会报错,可以防止我们的误操作对外面数据进行修改

如 void print3(const student *s)

在使用结构体时,如果它是地址,就在它后边用 ->,如果它不是地址,就在它后边就用 .

 

定义一个edge

struct edge{

int u,v;

void read(){

cin>>u>>v;

}
};

 

vector<edge> e(m);

for(int i=0;i<m;++i) e[i].read();

之后利用数据:for(auto [u,v]:e)

 

Class

class代表的是类,C++通过public、protected、private三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符:(1)、在类内部,无论成员变量或成员函数被声明为public、protected还是private,都是可以互相访问的,无访问权限限制;(2)、在类外部,类创建的对象无法访问private、protected属性的成员变量和成员函数,而仅可以访问public属性的成员变量和成员函数

在C++中,class与struct的区别:(1)、默认的继承访问权限:struct是public的,class是private的;(2)、成员的默认访问权限:struct默认是public权限,class默认是private权限;(3)、”class”关键字还用于定义模板参数,就像”typename”,但关键字”struct”不能用于定义模板参数

基本形式:
class 类名

{

    public:

    //公共的行为或属性,一般留给外界用来调用的函数接口

 

    private:

    //私密的行为或属性,只能被本类内部访问

};

成员函数的实现可以在类定义时同时完成

class Point{

    public:

    void setPoint(int x, int y) //实现setPoint函数

    {

        xPos = x;

        yPos = y;

    }

    void printPoint() //实现printPoint函数

    {

        cout<< "x = " << xPos << endl;

        cout<< "y = " << yPos << endl;

    }

    private:  //类内部的变量

       int xPos;

       int yPos;

};

 

int main()

{

    Point M; //用定义好的类创建一个对象点M

    M.setPoint(10, 20); //设置 M点 的x,y值  调用时就可以直接调用类内部的函数

    M.printPoint(); //输出 M点 的信息

    return 0;

}

输出时就会输出x=10;y=20;

构造函数中,变量初始化的顺序,是以变量定义的顺序(private中)来定的,而不是简单的以构造函数中变量出现的顺序来定的

class A{

private:

int n1;

int n2;

public:

A() :n2(34), n1(n2+1) {}

void Print() {

cout << "n1:" << n1 << ", n2: " << n2 << endl;

}

};

此时n1并不能被正常赋值,因为变量定义的顺序是n1在前,因此在对变量初始化时,还是先对n1进行初始化

 

下面两种写法是一个意思: 
写法一:

public:

    A(int k1,int k2) {

        n1 = k1;

        n2 = k2;

    }

写法二:

public:

    A(int k1,int k2) :n1(k1),n2(k2){}


几个定义举例:

链表

class MyListNode{

public:

    int val;

    MyListNode* next;

    MyListNode(int x) {

        val = x;

        next = nullptr;

    }

};

 

二叉树

struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {};
};
 
单调队列
class MyQueue { //单调队列(从大到小)public:
    deque<int> que; // 使用deque来实现单调队列
    // 每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。
    // 同时pop之前判断队列当前是否为空。
    void pop(int value) {
        if (!que.empty() && value == que.front()) {
            que.pop_front();
        }
    }
    // 如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。
    // 这样就保持了队列里的数值是单调从大到小的了。
    void push(int value) {
        while (!que.empty() && value > que.back()) {
            que.pop_back();
        }
        que.push_back(value);
 
    }
    // 查询当前队列里的最大值 直接返回队列前端也就是front就可以了。
    int front() {
        return que.front();
}
};

 

nullptr

C++中为了避免“野指针”(即指针在首次使用之前没有进行初始化)的出现,我们声明一个指针后最好马上对其进行初始化操作。如果暂时不明确该指针指向哪个变量,则需要赋予NULL值。除了NULL之外,C++11新标准中又引入了nullptr来声明一个“空指针”

nullptr关键字用于标识空指针,是std::nullptr_t类型的(constexpr)变量。它可以转换成任何指针类型和bool布尔类型(主要是为了兼容普通指针可以作为条件判断语句的写法),但是不能被转换为整数(相较于null的优势,虽然NULL很明显是为了指针被制造出来的,但是如果你把它当作参数传到函数中去,它就会“原形毕露”——编译器会把它当作一个int类型,或一个long类型,而不是一个指针类型,而nullptr不能转化成整数,编译器在调用时不会将其处理为整型)

 void kissGirlfriend(GirlFriend* gf);
 void kissGirlfriend(int gfId);
 
 int main() {
kissGirlfriend(nullptr);  //调用第一个重载函数
kissGirlfriend(NULL);  
// 参数匹配后,其实调用的是第二个函数,你的第零号女朋友莫名其妙被亲了一下
}
 

 

指针

指针就是用来表示这些地址的,即指针型数据不是什么字符型数据,而存的是我们内存中的地址编码

指针变量的定义形式

int* p, i; //定义指针变量p,i为整形变量

p = &i; //指针变量p指向i,将i的地址放到p
指针的类型其实就是它所指向的对象的类型

 

int a, * p = &a;

a = 100;//直接访问a(对象直接访问)

*p = 100;//*p就是a,间接访问a(指针间接访问)

*p = *p + 1;//等价于a=a+1

&*p1 的含义:

&和*都是自右向左运算符,因此先看p1是指针,*p1即为p1指向的对象,

因此*p1等价于a,因此a 的前面加一个&,表示的就是 a 的地址

因此:&*p1 ,p1 ,&a 三者等价

*&a 的含义:a的地址前加*表示的就是a本身,指针就是用来放地址的,地址前面加*表示的就是这个对象

因此:*&a ,a ,*p 三者等价

 

地址初值不能是变量,整型变量不能作为指针(即使值为0),指针只允许0值常量表达式(即int *p=0)

定义时的*表示之后的对象是一个指针类型,而之后*就是一个运算符,输出指针指向的对象

int a=100,*p=&a;

cout<<p<<endl;  //输出一个地址,因此如果在此时赋值,可写作p=&a

cout<<*p<<endl; //输出100

int b[10],* px = &b[2]; //可以直接指向数组的一个有效地址

int* py = &b[1];

cout<<((py+1)==px)<<endl;  //输出1,b[1]地址的下一个就是b[2]的地址

*px=2;

cout<<b[2];  //输出2

 

同样指针之间可以进行加减以及关系运算,前提是是同一个指向类型的指针(如用find得到下标实际上就是用了地址之间的加减)

指向const的指针(指向只读型变量,这样就不允许通过指针来改变所指向的const对象的值,即通过间接引用来改变它所指向的对象的值)常用作函数的形参,以此确保传递给函数的参数对象在函数中不能被修改

void fun(const int* p)

{

         ...

}

int main()

{

         int a;

         fun(&a);

}

指针作为函数的形参,在主函数中,我们定义整型变量a然后将a的地址传递给了子函数,对于子函数来说,

他的形参就是用const修饰过的整型变量p指向主函数里a这个变量

这样的一系列操作就使得我们不能在子函数中通过p间接引用a 来改变a 的值,因为a 是用const修饰过的

 

用指针来操作字符串

当指针p指向的是字符数组时,cout<<p<<endl是从指向的下标一直输出到末尾,而整型数组只是输出一个地址

通过字符指针来遍历字符串

char str[] = "C Language", * p = str;

while (*p!='\0')cout << *p++;

(*p!='\0')判断p指向的元素是不是字符串结束的标志

*p++ 的含义:先输出p指向的元素然后p++(后置增增,先做完其他事再自增)

假设从str[0]开始,p指向的是C满足(*p!='\0')因此执行循环,下一个循环p指向“空格”不是字符串结束的标志,继续循环直到遇到字符串结束的标志后结束循环

 

利用指针遍历

for(auto it= v.begin();it!=v.end();++it){

cout<< *it <<" ";

}

cout<<"\n"<<endl;

 

利用指针遍历map

map<int, int> diff;

auto it = diff.begin();

        int sum = 0;

        for (int i : id) {

            while (it != diff.end() && it->first <= people[i])

                sum += it++->second; // 累加不超过 people[i] 的差分值

            people[i] = sum; // 从而得到这个时刻花的数量

        }

 

sort函数

(1)第一个是要排序的数组的起始地址。

(2)第二个是结束的地址(最后一位要排序的地址的下一地址)。

(3)第三个参数是排序的方法,可以是从大到小也可是从小到大,还可以不写第三个参数,此时默认的排序方法是从小到大排序。

我们可以根据自己的需求自定义第三个参数cmp函数,比如若要对整型数组降序排序,则可以这样来写cmp函数

bool cmp(int a, int b)

{

    return a>b;//升序则为a<b

}

若写作

bool cmp(pair<int, int>a, pair<int, int>b)

{

    return a.first<b.first;//根据fisrt的值升序排序

    //return a.second<b.second;//根据second的值升序排序

}

或者可写作

bool cmp(pair<char, int>& l, pair<char, int>& r)

{

    // 如果value相等,比较key值

         if (l.second == r.second)

                  return l.first < r.first;

         else

         // 否则比较value值

                  return  l.second < r.second;

}

就可以对我们需要的排序进行自定义,这样就能使根据对pair的first值进行排序

数组可以是arr.sort();

或arr2.sort(function (a, b) { return a - b }) //a,b表示相邻的两个元素 ,若返回值>0,数组元素将按升序排列 ,若返回值<0,数组元素将按降序排列

或sort(arr,arr+n,cmp)

 

直接将函数写在第三个参数位中,如

sort(id.begin(), id.end(), [&](int i, int j) { return people[i] < people[j]; });  这样是得到一个升序列

cmp函数中的return可以看作是排完序之后数组相邻元素之间的大小关系

 

根据可以传入地址,因此可以用&写作sort(&ans[1],&ans[n+1])

 

multiset可以看成一个序列,插入一个数,删除一个数都能够在O(logn)的时间内完成,而且他能时刻保证序列中的数是有序的,而且序列中可以存在重复的数 头文件#include <set>

c.insert(elem)          插入一个elem副本,返回新元素位置,无论插入成功与否。

c.insert(pos, elem)  加入elem,返回新元素位置,pos为收索起点,提升插入速度。

c.erase(elem)          删除与elem相等的所有元素,返回被移除的元素个数。

c.erase(pos)             移除迭代器pos所指位置元素,无返回值。

c.erase(beg,end)  移除区间[beg,end)所有元素,无返回值。

c.clear()                    移除所有元素,将容器清空

c.begin()                    返回一个随机存取迭代器,指向第一个元素

c.end()                       返回一个随机存取迭代器,指向最后一个元素的下一个位置

c.rbegin()                  返回一个逆向迭代器,指向逆向迭代的第一个元素

c.rend()                     返回一个逆向迭代器,指向逆向迭代的最后一个元素的下一个位置

count (elem)                              返回元素值为elem的个数

find(elem)                                  返回元素值为elem的第一个元素,如果没有返回end()

lower _bound(elem)                 返回元素值为elem的第一个可安插位置,也就是元素值 >= elem的第一个元素位置

upper _bound (elem)               返回元素值为elem的最后一个可安插位置,也就是元素值 > elem 的第一个元素位置

equal_range (elem)                  返回elem可安插的第一个位置和最后一个位置,也就是元素值==elem的区间

(set类似,不重复有序)

同样,插入可以用emplace

 

find函数,头文件#include<algorithm>

看到find函数的返回结果与vector的end比较可以看出其实这是一个指针,那么如果我们想要获得索引,那么将返回结果与begin做差即可,即find(ar1.begin(), ar1.end(), "bbb")-ar1.begin()

查找是否存在find(ar1.begin(), ar1.end(), "bbb") != ar1.end()

在string中用find函数

查找单个字符str.find(ch)!=string::npos  如果找到了,返回的结果是匹配到的字符的第一个位置

我们还可以通过第二个参数来控制开始查找的位置,如str.find(ch, p)!=string::npos

这样通过一个位置p不断的更新上一次的匹配位置,我们可以不断地更新开始位置

int p = 0;

while(str.find(ch, p)!=string::npos){

            p = str.find(ch, p);

            cout<<p<<endl;

            p = p + 1;

        }

同时,也能用来查找字符串 str.find(ps)!=string::npos 返回的是字符串开始的位置

s.find_first_of(str) 和 s.find_last_of(str)

找到目标字符在字符串中第一次出现和最后一次出现的位置

查找目标字符串在字符串中出现的总次数

int index = 0;//用来存储不断更新最新找到的位置

int sum = 0;//累加出现的次数

while ((index = s.find(c,index)) != string::npos) {

   cout << "sum: " << sum+1 << " index: " << index <<endl;

   index += c.length();//上一次s中与c完全匹配的字符应跳过,不再比较

   sum++;

}

rfind函数

rfind(str)是从字符串右侧开始匹配str,并返回在字符串中的位置(下标);

rfind(str,pos): 从pos开始,向前查找符合条件的字符串;

如果没有符合条件的字符串,返回string::npos,如果以pos开始的str.length()长度的字符串恰好符合所要找的字符串,那么就会返回pos

利用rfind解决删除某些字符达到满足要求的题目(T2844)

auto f = [&](string tail) {

            int i = num.rfind(tail[1]);

            if (i == string::npos || i == 0) return n;

            i = num.rfind(tail[0], i - 1);

            if (i == string::npos) return n;

            return n - i - 2;

        };

        return min({n - (num.find('0') != string::npos), f("00"), f("25"), f("50"), f("75")});

 

count函数 头文件#include<algorithm>

这个函数使用一对迭代器和一个值做参数,返回这个值出现次数的统计结果 count(ivec.begin() , ivec.end() , searchValue)

 

得到对应大小写字母序号的代码实现

int getIdx(char x) {return x >= 'A' && x <= 'Z' ? x - 'A' + 26 : x - 'a';}

 

截取字符串 s.substr(开始坐标,长度)  如ans = s.substr(j, i - j + 1);       

 

向量(Vector)是一个能够存放任意类型的动态数组

vector(int nSize):创建一个vector,元素个数为nSize

vector(int nSize,const t& t):创建一个vector,元素个数为nSize,且值均为t

1.push_back 在数组的最后添加一个数据(emplace_back的效果相同,但运行效率更高)

2.pop_back 去掉数组的最后一个数据

3.at 得到编号位置的数据

4.begin 得到数组头的指针

5.end 得到数组的最后一个单元+1的指针

6.front 得到数组头的引用

7.erase 删除指针指向的数据项,可以删除[beg,end)指定区域内的所有元素,并返回指向被

删除区域下一个位置元素的迭代器

也可以利用迭代器截取一段赋值给一个新的向量

如:vector<int> subIds(ids.begin() + st, ids.begin() + i);

insert 在指定位置加入一个或多个元素

注意,在添加已经构造好的数组元素时不能用emplace_back(),但是如果是添加一个vector<int>的元素是可行的,比如常见的path是一个vector<int>的数据,ans是一个vector<vector<int>>的数据,ans.emplace_back(path)是可行的,同样如果内部的元素是pair<int,int>,也是可行的nq.emplace_back(x, y);

例:

v.push_back({1,2,3}); // OK

v.emplace_back({1,2,3}); // error:no matching member function for call to 'emplace_back'

并且

 

emplace_back向容器中添加元素的步骤:

 

        找到容器尾部->调用构造函数;

 

push_back向容器中添加元素的步骤:

 

        若元素需要被构造->调用构造函数->找到容器尾部->调用移动构造函数;

 

        若元素已经构造完成->找到容器尾部->调用移动构造函数;

所以当元素已经构造完成时,emplace_back的效率不一定比push_back高

back()能直接得到尾元素的引用

sort(obj.begin(),obj.end());//从小到大  实现排序

reverse(nums.begin(), nums.end()); 实现反转

创建示例:vector<int> c1(60)  vector<int> result (A.size(), 0)

区别vector与array  vector的底层实现是array,vector是容器

定义一个n*n的二维数组vector<vector<int>> res(n, vector<int>(n, 0));

一维数组的初始化

1. vector < int > v;

2. vector < int > v = {1,2,3,4,5};

3. vector < int > v(n); vector < int > v(n,m);

4. vector < int > v(v0);

5. vector < int > v(*p, *q);                                        

初始读入vector的值,可以采用

vector<int> a(n);
for (int &v: a) {
        cin >> v;
 }

给ans初始赋一个空集即[[]]vector<vector<int> > res(1);

 

在C++中函数传递数组参数,并不是整个数组拷贝传入,而是传入数组首元素地址,也就是说对一个数组进行递归时,每一层递归的空间复杂度都是常数,O(1)

 

利用for循环遍历二维数组时,可以写作for (auto &p: nums) {,后续用p[0],p[1]使用数值,但不能用for(auto [a,b]:nums){,这是pair类型的遍历

以pair为数据类型的数据添加form.emplace_back(i, j);

 

assign()功能函数实现vector的截取(类似python中的a[mid:])

原型:void assign(const_iterator first,const_iterator last); //两个指针,分别指向开始和结束的地方

直接使用举例:
vector<int> Arrs {1,2,3,4,5,6,7,8,9}; // 假设有这么个数组,要截取中间第二个元素到第四个元素:2,3,4

vector<int>::const_iterator Fist = Arrs.begin() + 1; // 找到第二个迭代器

vector<int>::const_iterator Second = Arrs.begin() + 2; // 找到第四个迭代器

vector<int> Arr2;

Arr2.assign(First,Second); //使用assign() 成员函数将Arrs对应位置的值存入Arrs2数组中

或者容器.assign(size, value);   作用:向容器中存储size个value

 

数组转哈希,可直接用于记录某个元素是否出现在数组中(T2956)

unordered_set<int> set1(nums1.begin(), nums1.end());

 

 

也可以将函数打包

//功能描述: 裁剪vector数组某一区间的元素到新的vector数组

// 参数说明:

// Arrs: 将要被裁剪的vector数组

// begin:左边界相对于Arrs.begin()的位置

// end: 右边界相对于Arrs.begin()的位置

vector<int> CutArrs(vector<int>& Arrs, int begin, int end){ // begin <= end;

        if(end > Arrs.size())  return;  //越界判断

        vector<int> result;

        result.assign(Arrs.begin() + begin, Arrs.begin() + end);

        return result;

}

 

iota()

iota() 是用来批量递增赋值vector的元素的,例:

vector<int> id(n);        iota(id.begin(), id.end(), 0); // id[i] = i

前两个参数是定义序列的正向迭代器,第三个参数是初始的 T 值。第三个指定的值会被保存到序列的第一个元素中。保存在第一个元素后的值是通过对前面的值运用自增运算符得到的

可以将 iota() 算法应用到任意类型的序列上,只要它有自增运算符。下面是另一个示例:

string text {"This is text"};

std::iota(std::begin(text), std::end(text), 'K');

std::cout << text << std::endl;   // Outputs: KLMNOPQRSTUV

只要 ++ 可以应用到序列中的元素类型上,就能将 iota() 算法应用到序列上

特别的

std::vector<string> words (8);

std::iota(std::begin(words), std::end(words), "mysterious");

std::copy(std::begin(words), std::end(words),std::ostream_iterator<string>{std::cout, " "});

std::cout << std::endl;

// mysterious ysterious sterious terious erious rious ious ous

输出如注释所示。这是该算法的一个有趣应用,但没有什么用处。这只适用于第三个参数是一个字符串常量的情形。如果参数是 string{“mysterious”},将无法通过编译,因为没有为 string 类定义 operator++()。字符串常量对应的值是一个 const char* 类型的指针,可以将 ++ 运算符应用到它上面。因此对于 words 中第一个元素后的每个元素,指针的递增会导致字符串常量前面的字母被丢弃。将 ++ 应用到指针的结果是生成一个 string 对象,然后它会被保存到当前的元素序列中

 

区间k大问题可以利用划分树,主席树,CDQ分治

划分树和归并树都是用线段树作为辅助的,其中划分树的建树是模拟快排过程,自顶向下建树,归并树是模拟归并排序过程,自底向上建树,两种方法含有分治思想

 

减治算法。插入排序,DFS, BFS, 拓扑排序也隐含了减治的思想:每完成一轮,下一轮就可以少考虑一个数据

 

sort是快排,本质是二分的思想,但每次排序会将数组中的元素全部进行一遍修改,因此对于哪些相邻元素可能位置不变的情况下会产生时间的浪费,这种情况下可以考虑归并排序merge(P1309)

归并排序的思想就是合并两个同序数组的线性方式——每次比较两个有序数组指针指向的值,谁更小(大)则放到 temp 数组里,然后删掉进入 temp 的元素,指针 ++

优势就在于能利用原相邻的大小关系

并排序:

void merge(int l,int r){

    if(l==r)return 0;

    int mid=(l+r)/2;

    merge(l,mid);

    merge(mid+1,r);

    int i=l,j=mid+1,p=l;

    while(i<=mid&&j<=r){

        if(a[i]>a[j])temp[++p]=a[++i];

        else temp[++p]=a[++j];

         }

    while(i<=mid)temp[++p]=a[++i];

    while(j<=r)temp[++p]=a[++j];

    for(int i=l;i<=r;i++)a[i]=temp[i];

}

最后,一定要把过程数组 temp 覆盖原数组a的值,保证每次传递到上一级区间(大区间)的数值都有序

归并排序每次的操作只针对相邻区间,或者说合并时是对相邻几个区间的操作,所以这符合只需要修改相邻几个分数的排布状况的题意

在P1309中实际上使用的并不是完全的归并排序的代码,但是用到了类似的思想,将胜者组和败者组分开放置,因为胜败本身就是在原数组中相邻的两个元素,因此,在这两个数组中分别用指针去比较可以利用原来的大小关系来提高效率

void merge()  { 

  int i,j; 

  i=j=1,a[0]=0; 

  while(i<=win[0] && j<=lose[0]) 

    if(cmp(win[i],lose[j])) 

      a[++a[0]]=win[i++]; 

    else  

      a[++a[0]]=lose[j++]; 

  while(i<=win[0])a[++a[0]]=win[i++]; 

  while(j<=lose[0])a[++a[0]]=lose[j++];         

 

速排序:

也可用于求解第k大问题(T215)

快速排序的核心包括“哨兵划分” 和 “递归” 。

哨兵划分:以数组某个元素(一般选取首元素)为基准数,将所有小于基准数的元素移动至其左边,大于基准数的元素移动至其右边。

递归:对左子数组和右子数组递归执行哨兵划分,直至子数组长度为1时终止递归,即可完成对整个数组的排序。

「快速选择」:设 N为数组长度。根据快速排序原理,如果某次哨兵划分后,基准数的索引正好是 N−k,则意味着它就是第 k大的数字。此时就可以直接返回它,无需继续递归下去了。

然而,对于包含大量重复元素的数组,每轮的哨兵划分都可能将数组划分为长度为 1和 n−1的两个部分,这种情况下快速排序的时间复杂度会退化至 O(N2)

一种解决方案是使用「三路划分」,即每轮将数组划分为三个部分:小于、等于和大于基准数的所有元素。这样当发现第 k大数字处在“等于基准数”的子数组中时,便可以直接返回该元素。

为了进一步提升算法的稳健性,我们采用随机选择的方式来选定基准数

class Solution {

public:

    int findKthLargest(vector<int>& nums, int k) {

        return quickSelect(nums, k);

    }

   

private:

    int quickSelect(vector<int>& nums, int k) {

        // 随机选择基准数

        int pivot = nums[rand() % nums.size()];

        // 将大于、小于、等于 pivot 的元素划分至 big, small, equal(可以不记录,因为在接下来的排列中用不到)中

        vector<int> big,small;

        for (int num : nums) {

            if (num > pivot)

                big.push_back(num);

            else if (num < pivot)

                small.push_back(num);

        }

        // 第 k 大元素在 big 中,递归划分

        if (k <= big.size())

            return quickSelect(big, k);

        // 第 k 大元素在 small 中,递归划分

        if (nums.size() - small.size() < k)

            return quickSelect(small, k - nums.size() + small.size());

        // 第 k 大元素在 equal 中,直接返回 pivot,因为equal中的任意元素都与pivot相等

        return pivot;

    }

};

 

阶乘问题:

n!可能很大,而计算机能表示的整数范围有限,需要使用高精度计算的方法。使用一个数组A来表示一个大整数a,A[0]表示a的个位,A[1]表示a的十位,依次类推。将a乘以一个整数k变为将数组A的每一个元素都乘以k,请注意处理相应的进位。首先将a设为1,然后乘2,乘3,当乘到n时,即得到了n!的值(高精度,将数用数组来表示)

 

排列组合问题(组合数学):

从特殊到一般再利用组合数(T2954)

转化为组合数问题,因为排列默认升序,因此只要求有多少种取字母的方式即可(P1246,递推或组合数)

在运行时,要防止两个int相乘溢出,所以不能把算式的分子都算出来,分母都算出来再做除法

需要在计算分子的时候,不断除以分母

long long numerator = 1; // 分子

        int denominator = m - 1; // 分母

        int count = m - 1;  //计数器,表示剩余需要计算的乘积项个数

        int t = m + n - 2;  //初始乘积项

        while (count--) {

            numerator *= (t--); //计算乘积项的分子部分

            while (denominator != 0 && numerator % denominator == 0) {

                numerator /= denominator;  //约简分子

                denominator--;  //递减分母

            }

        }

计数器就是为了确保分子为(m+n-2)!/(m-1)!

(T2400)

或者用 的递推式来计算组合数

vector<vector<long long>> f;

        f.resize(K + 1, vector<long long>(K + 1));

        for (int i = 0; i <= K; i++) {

            f[i][0] = 1;

            for (int j = 1; j <= i; j++) f[i][j] = (f[i - 1][j] + f[i - 1][j - 1]) % MOD;

        }

        return f[K][(d + K) / 2];

也可以通过 ,需要用到乘法逆元(也就是倒数)(时间和空间复杂度都能优化很多)

// 线性求逆元

        vector<long long> inv(K + 1);

        inv[1] = 1;

        for (int i = 2; i <= K; i++) inv[i] = (MOD - MOD / i) * inv[MOD % i] % MOD;

        // 递推求组合数,初值 C(k, 0) = 1

        long long ans = 1;

        for (int i = 1; i <= (d + K) / 2; i++) ans = ans * (K - i + 1) % MOD * inv[i] % MOD;

        return ans;

 

模版

const int MOD = 1'000'000'007;

const int MX = 100'000;

 

long long q_pow(long long x, int n) {

    long long res = 1;

    for (; n > 0; n /= 2) {

        if (n % 2) {

            res = res * x % MOD;

        }

        x = x * x % MOD;

    }

    return res;

}

 

long long fac[MX], inv_fac[MX];

 

auto init = [] {

    fac[0] = 1;

    for (int i = 1; i < MX; i++) {

        fac[i] = fac[i - 1] * i % MOD;

    }

    inv_fac[MX - 1] = q_pow(fac[MX - 1], MOD - 2);

    for (int i = MX - 1; i > 0; i--) {

        inv_fac[i - 1] = inv_fac[i] * i % MOD;

    }

    return 0;

}();

 

long long comb(int n, int k) {

    return fac[n] * inv_fac[k] % MOD * inv_fac[n - k] % MOD;

}

(这样计算的是 )

 

n * (n-1) * (n-2) * (n-3) / 24可以化简为n * (n-1) / 2 * (n-2) / 3 * (n-3) / 4,因为n,n-1中一定有一个是2的倍数,同理n,n-1,n-2中一定有一个是3的倍数,同理一定有一个是4的倍数

 

朱世杰恒等式

若m,n是非负整数,则有

等价于

 

牛客24多校

(赛时读错题的想法:要让一个序列的and和为1,相当于将(1<<m)-1除末尾1以外的1进行分配,每个Bite位上的1都有n个位置可以选择,又因为可以不选,因此总的方案数是(m-1)^(n+1),对于没有分配到1的位置全部都用1填充)

分成个位数为0和个位数为1

显然个位数为0的不会出现在and和为1的子序列中

又and和为1等价于所有的奇数and和为1

因此等价于有多少个序列,它们的奇数的and和为1

显然,对于二进制最低位为1的数,如果存在一个子序列的and和为1,那么包含这个子序列的子序列的and和也是1(存在小区间向大区间的转移)

因此只要所有二进制最低位为1的数and和是1就能满足条件

记二进制最低位为1的数有k个,这些数除了最低位的and和都要是0,也就是每一位上这些数中都至少有一个在这位上是0(都是拆分到每一个bite位上考虑),那么方案数是 ,对k从1到n求和即可

如果有k个奇数,对于每一位,我们将其在k个元素上的情况进行分析,它的取值总数有2^(k)-1种(每个元素都可以为1或者为0,但不可以全为1,因此最终要减一),因为总共有m-1位的01情况需要讨论,因此这些位置的情况是 ,由于位置并不固定,因此要乘上组合数 ,同时还有剩余偶数位置的影响,这些位置的情况是(n-k)(m-1),在这些位置上是可以任选的(因为只要保证最后一位是0即可),因此对于每一个k,其总的方案数是

(因为不一定是质数因此不能求逆元,但可以用杨辉三角处理)

int C[5050][5050];

void solve(){

    int n,m,q;

    cin>>n>>m>>q;

    for(int i=0;i<=n;i++){

        C[i][0]=C[i][i]=1;

        for(int j=1;j<=i-1;j++) C[i][j]=(C[i-1][j-1]+C[i-1][j])%q;

    }

    int ans=0;

    for(int i=1;i<=n;i++){

        int m1=power(2,i,q)-1;

        m1=power(m1,m-1,q);

        int m2=power(2,m-1,q);

        m2=power(m2,n-i,q);

        ans=(ans+1ll*m1*m2%q*C[n][i]%q)%q;

    }

    ans=(ans+q)%q;

    cout<<ans<<'\n';    

}

 

Cf 2086D

构造一个字符串s,满足下列条件:
对于每一对满足s[i]=s[j]的索引,这些索引的差值都是偶数

同时给出每个字母在s中的出现次数,问不同字符串s的数量

因为相同元素的下标索引差都是偶数,可以转化成相同元素下标的奇偶性相同,于是可以将下标奇偶分开考虑

将下标分成两组,接着就是要将26种字符分配到两组中,这是子集和问题,可以通过01背包解决

分配完成后的计算是容易的,对于奇数位置,odd!/(分配到奇数位置上的每个元素出现次数阶乘的累乘),偶数位置同理

因此整体的式子实际上是固定的,也就是奇数位置的阶乘乘上偶数位置的阶乘再除以每个位置的出现次数阶乘的累乘

最终答案只需要在这个基础上再乘上分配的方案数即可

分配的计算正如上面提到的只需要用01背包即可,因为分配到奇数位置的方案自然和全部的分配相匹配,因此将奇数位置数作为背包大小,将每个元素的出现次数作为物品放入即可

https://codeforces.com/contest/2086/submission/314868735

 

cf1983E

计算一个游戏双方的期望得分

球的排列实际上是不确定的,因而产生了不同得分的情况

因此可以考虑将普通球排成一排,此时两人一定是交替的选择,并且其中一人选出的普通球数量确定,用选和普通球个数的比例乘上普通球权值之和可以得到需拿出的普通球贡献期望

将特殊求看成各班,插入普通球的空之中,因为首尾都可以放隔板,因此总共有n-k+1(k是特殊球的个数)个空,然后每个放入空的特殊球属于哪个玩家是可以可以确定的,因此可以同样计算出其中一个玩家的空所占比例,再乘上特殊球的权值之和就可以计算出特殊球的贡献期望

一个球被计入先手的贡献当且仅当它前面的非特殊球个数为偶数,因此可以枚举球的位置来得到逐个计算,以普通球为例,其贡献的计算方式为

(或者也可以化简后得到最后的答案是 ,A是特殊球的价值总和,B是普通球的价值总和)

void solve(){

    int n,k;

    cin>>n>>k;

    vector<Z> v(n);

    for(int i=0;i<n;i++){

        cin>>v[i];

    }

    array<Z,2> ans{};

    Z special=0;

    Z common=0;

    for (int i = 0; i < n - k; i += 2) {

        common += comb.binom(n - k - 1, i) * comb.fac(i) * comb.fac(n - k - 1 - i) * comb.binom(n, k) * comb.fac(k) * comb.invfac(n);

    }

    for (int i = 0; i <= n - k; i += 2) {

        special += comb.binom(n - k, i) * comb.fac(i) * comb.fac(n - k - i) * comb.binom(n, k - 1) * comb.fac(k - 1) * comb.invfac(n);

    }

    for(int i=0;i<n;i++){

        if(i<k){

            ans[0]+=v[i]*special;

            ans[1]+=v[i]*(1-special);

        }else{

            ans[0]+=v[i]*common;

            ans[1]+=v[i]*(1-common);

        }

    }

    cout<<ans[0]<<' '<<ans[1]<<'\n';

}

或者

void solve(){

    int n,k;

    cin>>n>>k;

    i64 suma=0,sumb=0,cha=0,chb=0;

    vector<i64> v(n+1);

    for(int i=1;i<=n;i++){

        cin>>v[i];

        if(i<=k){

            suma=(suma+v[i])%mod;

        }else{

            sumb=(sumb+v[i])%mod;

        }

    }

    cha=ceil((n-k+1)*1.0/2);

    chb=ceil((n-k+1)/2);

    i64 ans=(i64)ceil((n-k)*1.0/2)*qpow(n-k,mod-2)%mod*sumb%mod

            +cha*qpow(cha+chb,mod-2)%mod*suma%mod;

    ans%=mod;

    cout<<ans<<' '<<(suma+sumb+mod-ans)%mod<<'\n';

}

 

cf 2025B

当计算模式固定但复杂度会超时的情况下,可以考虑查找规律

C[n][k]=C[n][k-1]+C[n-1][k-1]时,可以证明C[n][k]=2^k(或者直接打表找规律也可)

证明:不断展开,

原式=C[n][k-2]+2*C[n-1][k-2]+C[n-2][k-2]=C[n][k-3]+3*C[n-1][k-3]+3*C[n-2][k-3]+C[n-3][k-3]

=sum(C(j,i)C[n-i][k-j]) (i=0,...j)=sum(C(k,i)C[n-i][0]) (i=0,...k)   有二项式顶板管理知即为2^k

 

错位全排列

一般这类问题要在之前排列的基础上考虑如何进行转移,因此一般假设当前最后一位是正确的(或者只放置一个球),尽量不去影响前面的排列,再对前面的一些特殊排列考虑转移到现在的情况


将问题具体化,5封不同的信,编号分别是 1-5 ,现在要把这五封信放在编号1-5的信封中,要求信封的编号与信的编号不一样。问有多少种不同的放置方法?

假设考虑到第5个信封,初始时暂时把第5 封信放在第  5个信封中,然后考虑两种情况的递推:

前面  4个信封全部装错;

前面  4个信封有一个没有装错其余全部装错。

对于第一种情况,前面 4 个信封全部装错:第5封只需要与前面任一一个位置交换即可,信封5必然不可能与前面的信箱匹配上,总共有的d(n-1)*(n-1)种情况。

对于第二种情况,前面4个信封有一个没有装错其余全部装错:考虑这种情况的目的在于,若4个信封中如果有一个没装错,那么其方案数就等价于d(n-2)那么把那个没装错的与5交换,即可得到一个全错位排列情况

因此,错位全排列满足递推关系:dn=(n-1)(d(n-1)+d(n-2))

实际上也有另一个递推关系式  dn=nd(n-1)+(-1)n

 

第二类斯特林数

第二类Stirling数表示把n nn个不同的数划分为m mm个集合的方案数,要求不能为空集,写作S(n,m).

和第一类Stirling数不同,划分集合不必考虑排列次序

考虑转移:
1、S(n-1,m-1),将n-1个不同元素划分为了m-1个集合,则第n个元素必须单独放入第m个集合。方案数:S(n-1,m-1)

2、S(n-1,m),将n-1个不同元素已经划分为了m个集合,则第n个元素可以放在m个集合中任意一个里面。方案数:S(n-1,m)*m

所以可得:S(n,m)=S(n-1,m-1)+S(n-1,m)*m

一般来说,有

S(0,0)=1  S(n,0)=1   S(n,1)=1   S(n,n)=1  S(n,2)=2^(n-1)-1  S(n,n-1)=C(2,n)

具体问题具体分析

 

反悔贪心:
对于一个或多个数据组成的选择方案,我们可以秉着贪心的原则,先将其中一个方案中最大的全部取出,但与一般贪心不同的是,这样我们本次的计算并没有结束,因为有多个数据同时对最后的利润产生影响,因此我们还要判断将当前选中的其中一个数据改为其他数据能否产生更大的利润(反悔的过程),并且,在比较两种方案的优劣时,比较变化前后的相对值,比算绝对值要方便(这样相当于可以对不同组成部分分别计算,也利于分析)(T2813)

24天梯选拔2-4

记录当前总用时,然后对于当前数据尝试选取判断能否使答案更优,可以就选取,不行就尝试修改前面已经选择完毕的方案

pair<int,int> t[100005];

void solve(){

  int n;

  cin>>n;

  //通过pair将同一题的耗时记录下来,显然排序后在前的题要尽量早点完成

  for(int i=1;i<=n;i++) cin>>t[i].second;

  for(int i=1;i<=n;i++) cin>>t[i].first;

  sort(t+1,t+n+1);

  priority_queue<int> pq;//pq记录的是当前选择的元素

  ll sum=0,lim=0;

  int ans=0;

  for(int i=1;i<=n;i++){

    auto [b,a]=t[i];

    lim+=b;//当前的时间限制,小于该限制可以拿到一血

    //能选则选

    if(a+sum<=lim){

      sum+=a;

      pq.push(a);

    }

    //否则进行反悔,在保证总做题数不变的同时,压缩总时间,这样后续可以有更多的选择

    else if(pq.size() && a<pq.top()){

      sum-=pq.top();

      pq.pop();

      sum+=a;

      pq.push(a);

    }

    ans=max(ans,(int)pq.size());

  }

  cout<<ans<<'\n';

}

 

Cf 2063D

题意见三分处的题解

Kmax是可以直接求出的不考虑具体删哪些点,点数多的删两个,点数少的删一个,模拟一遍即可

考虑反悔贪心,首先显然的答案的形式一定是形如 a 中最大的 x 个区间和 b 种最大的 y 个区间,组成答案 ans[x+y​],并且第 k 大区间一定可以调整成 a[n−k+1]​−a[k]​ 的形式,那我们考虑动态的加入当前最大的区间然后带走对面一个点

注意到知道删掉具体的哪个点是不必要的,你只需要维护有多少个点没有被删,相当于最后再分配就行

那么考虑反悔,这里只会有一种情况,就是己方点数大于等于 2,对面没点了,这时考虑删掉对面的一个已删最小区间,换得两个点,这样可能带来己方两个区间的收益

其实还有一种情况就是己方只有一个点或者没有点,考虑删掉对面的已删最小区间,换得两个点,但是这样一定不优,因为只会带来一个区间的收益

代码上维护四个堆即可,即a的最大区间,b的最大区间,以及a最近删除区间和b最近删除区间

 

中位数贪心(T2967,T2968):
给一个数组,找到一个数字,让数组中的元素到这个数字的距离之和最短

对于这个问题,取中位数比取平均值更优:设想一种情况,大部分的数是相近的,但有一个数特别大,如果是平均数,这个平均数也会被相应的拉大,因此较小那几个数到这个这个数的距离也会变大,但如果是中位数,只有最大的那一个数到这个数的距离比较大,其他值都是可以接收的较小值,因此选用中位数更优

 

树上贪心(Cf 916 (Div. 3) F):

一般是针对子树去操作

 

贪心:

对于可以任意选择元素,然后反转的,一般可以考虑直接排序,因为我们总可以通过选择把较小的元素换至前面,只是如果只能进行有限次的操作那么不能保证元素的顺序,但是可以保证元素的总和

 

Cf 2118C

给定数组 a,最多进行 k 次操作(每次操作将某个元素 +1),使数组的"美丽值"最大。美丽值定义为所有元素二进制表示中 1 的个数之和。

初始化和统计当前的美丽值

贪心策略:优先翻转低位的 0

从低位到高位处理,因为翻转低位的代价更小

对于第 i 位,要将一个 0 翻转为 1,需要 2^i 次操作

[k >> i](cf C.cpp ) 表示当前剩余操作次数最多能翻转多少个第 i 位的 0

[t = min(cnt[i], k >> i)](cf C.cpp ) 是实际能翻转的个数(受限于 0 的个数和操作次数)

void solve() {

    int n;

    i64 k;

    cin >> n >> k;

    int ans = 0;

    i64 cnt[60]{};

    for (int i = 0; i < n; i++) {

        i64 a;

        cin >> a;

        ans += __builtin_popcountll(a);

        for (int i = 0; i < 60; i++) {

            if (~a >> i & 1) {

                cnt[i]++;

            }

        }

    }

    for (int i = 0; i < 60; i++) {

        i64 t = min(cnt[i], k >> i);

        ans += t;

        k -= t << i;

    }

    cout << ans << '\n';

}

 

 

Cf 2117G

给定一个图,图中任意两点的路径成本为路径上最大边的权重+最小边的权重

问从1到n的路径中,成本最小的路径的成本

显然,我们按照价值从小到大选取一定是最优的,因为我们并不限制中间要使用多少,或者说在意中间使用的边的个数,于是,如果我们能到达某个点,我们刻意绕到某个更小的边上,反而能使得答案变优

于是可以考虑类似于构建最小生成的树的形式,每次加上当前边中最小的边,直到1和n连通

void solve() {

    int n, m;

    cin >> n >> m;

    vector<array<int, 3>> edges(m);

    for (int i = 0; i < m; i++) {

        int u, v, w;

        cin >> u >> v >> w;

        u--, v--;

        edges[i] = {w, u, v};

    }

    sort(edges.begin(), edges.end());

    vector<int> f(n, inf);

    DSU dsu(n);

    int ans = 2 * inf;

    for (auto [w, u, v] : edges) {

        u = dsu.find(u);

        v = dsu.find(v);

        f[u] = min({f[u], f[v], w});

        dsu.merge(u, v);

        if (dsu.same(0, n - 1)) {

            ans = min(ans, w + f[dsu.find(0)]);

        }

    }

    cout << ans << '\n';

}

 

 

25 杭电 春 10 1008

n个朋友,m种食物,每个朋友对每种食物的好感度是a[i][j]

要设计一个美食分配方案,使得总好感度最高,并且保证至少有一种食物被超过一半的朋友品尝过

这里n的数据范围是1到100,m的数据范围是1到1000

一种做法是dp,首先枚举被选择次数最多的一个食品x,f[i][j]表示选择到了第i个人,且选择x的数量和其他食品的数量的差值位j,所能得到的最大总好感度

这样每次转移只需要枚举是否选择x,如果不选择x,那么为了使得最后的价值最大,一定是选择其余食品种好感度最高的

另外一种做法就是贪心,同样是枚举一个被选择次数最多的食品x,然后对于每一个人i我们求出对于他来说食品好感度的最大值mx,然后考虑如果让这个人选择x会损失多少

因为要让总和最大,并且x被选择的次数至少位n/2+1,因此可以将mx[i] – a[i][x]从小到大排序,并且选择最小的n/2 + 1个损失,这样逐个对比即可

 

Cf 2106D

给定一个数组a表示每朵花的美丽值a_i,Igor想要收集正好m朵花

他从左向右走,可以选择是否收集当前位置的花,他收集的第i朵花的美丽值必须至少为b_i

此外为了收集满足要求的花,他可以进行魔法操作:Igor可以使用一次魔法,在任何位置种植一朵新的花,这朵新花的美丽值可以是任意整数,新花可以放在任何位置(两朵花之间、最左边或最右边)

首先,对于花一定是能采就采,因为如果能采但不采只会白白浪费这朵花

因此我们可以贪心地进行一遍操作来判断能够不通过魔法操作来达成目标

如果不行,则也是贪心地从左往右扫一遍,从右往左扫一遍,这样第一次得到的是能得到的b数组的最右侧记为right,第二次得到的是b数组的最左侧记为left,如果left>right那么显然是不可行,否则我们选择去生成的一定是left和right之间的一个元素

这里注意我们在进行贪心操作的时候要额外进行剪枝,也就是如果当前剩余元素加上可以用魔法生成的元素仍然不能小于剩余需要采集的花朵数,那么直接跳出即可,否则会影响端点的正确计算,因为我们是要基于这两个端点来判断能否合并

对于当前判断的元素,这个元素是直接生成得到的,因此要判断的是它左侧的b数组的元素和右侧的b数组的元素是不是能正确地从原数组中得到,为此第一次遍历得到的对应下标是可能的最小的下标,后一次则是可能的最大下标,因此依据这两个下标映射判断即可

void solve() {

    int n, m;

    cin >> n >> m;

    vector<int> a(n), b(m);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

    for (int i = 0; i < m; i++) {

        cin >> b[i];

    }

    vector<int> it1(m, -1);

    int right = 0;

    for (int i = 0; i < n; i++) {

        if (a[i] >= b[right]) {

            it1[right] = i;

            right++;

            if (right == m) {

                break;

            }

        }

        // if (n - i - 1 < m - right) {

        if (n - i < m - right && i != n - 1 && a[i + 1] != b[right]) {

            // cout << i << ' ' << '\n';

            break;

        }

    }

    // cerr << "right: " << right << '\n';

    if (right == m) {

        cout << 0 << '\n';

        return;

    }

    int left = m - 1;

    vector<int> it2(m, -1);

    for (int i = n - 1; i >= 0; i--) {

        if (a[i] >= b[left]) {

            it2[left] = i;

            left--;

            if (left == -1) {

                break;

            }

        }

        // if (i < left + 1) {

        if (i + 1 < left + 1 && i != 0 && a[i - 1] != b[left]) {

            // cout << i << '\n';

            break;

        }

    }

    // cerr << "left: " << left << '\n';

    if (left > right) {

        cout << -1 << '\n';

        return;

    }

    // if (left == right) {

    //     cout << b[left] << '\n';

    //     return;

    // }

    int ans = INT_MAX;

    for (int i = left; i <= right; i++) {

        if (i == 0) {

            ans = min(ans, b[i]);

            continue;

        }

        if (i == m - 1) {

            ans = min(ans, b[i]);

            continue;

        }

        if (it1[i - 1] < it2[i + 1]) {

            ans = min(ans, b[i]);

        }

    }

    if (ans == INT_MAX) {

        cout << -1 << '\n';

        return;

    }

    cout << ans << '\n';

}

 

Cf 2111E

给定一个仅由3个字母a, b, c组成的字符串s,同时给出操作序列,对于每个操作,可以不做,或者选一个位置进行替换。最后需要整个字符串字典序最小

进行替换的方式是选择s中的一个y字符,将其替换成x字符

显然若s[i] = a,一定不需要替换

如果 s[i] = b,优先使用直接变成a的,否则再考虑能否变成c再变成a

对于s[i] = c的同理

因为要让字典序最小,于是可以顺序遍历,这样能改变的一定优先变小

同时因为仅有3种字符,因此可以直接用一个矩阵来记录从一种字符到另一种字符的操作

需要特殊进行操作的就是上面传递性的形式,因为这里存在顺序的问题,可以采用双指针的方式来记录

但算法的正确性实际也基于这种时间顺序,使得预先计算的cba值在整个过程中保持有效。

cba表示最多能进行多少次 c→b→a 的组合操作

当我们使用一次 c→b→a 组合时:消耗一个 c→b 操作(时间戳较小),消耗一个 b→a 操作(时间戳较大)

单独使用 b→a 操作不会影响cba的有效性,因为:

单独使用的 b→a 操作不在任何 c→b→a 组合中

或者其对应的 c→b 操作已经不可用,因此不需要同步衰减cba

而单独使用c->b是不可能的,因为这个的优先级是最低的

因此只要标记存在,就说明是可行的

int get(vector<int> a, vector<int> b) {

    int n = a.size();

    int m = b.size();

    int k = 0;

    for (int i = 0, j = 0; i < n; i++) {

        while (j < m && a[i] > b[j]) {

            j++;

        }

        if (j < m) {

            k++;

            j++;

        }

    }

    return k;

}

 

void solve() {

    int n, q;

    cin >> n >> q;

    string s;

    cin >> s;

    vector<int> vec[3][3];

    int cnt[3][3] {};

    for (int i = 0; i < q; i++) {

        char a, b;

        cin >> a >> b;

        vec[a - 'a'][b - 'a'].emplace_back(i);

        cnt[a - 'a'][b - 'a']++;

    }

    int cba = get(vec[2][1], vec[1][0]);

    int bca = get(vec[1][2], vec[2][0]);

    for (int i = 0; i < n; i++) {

        if (s[i] == 'a') {

            continue;

        } else if (s[i] == 'b') {

            if (cnt[1][0]) {

                cnt[1][0]--;

                s[i] = 'a';

            } else if (cnt[1][2] && cnt[2][0] && bca) {

                cnt[1][2]--;

                cnt[2][0]--;

                bca--;

                s[i] = 'a';

            }

        } else {

            if (cnt[2][0]) {

                cnt[2][0]--;

                s[i] = 'a';

            } else if (cnt[2][1] && cnt[1][0] && cba) {

                cnt[2][1]--;

                cnt[1][0]--;

                cba--;

                s[i] = 'a';

            } else if (cnt[2][1]) {

                cnt[2][1]--;

                s[i] = 'b';

            }

        }

    }

    cout << s << '\n';

}

 

 

25 杭电 春4 1005

因为减的值是固定的,而打折所得到的优惠取决于当前的值,因此一定是先用打折的再用减的,换言之,当我们开始使用减后,一定不会再用打折

 

选择贪心可能会更易于计算

Cf 2085D

在一个寿司店,每个时间点,会有一盘寿司上来,i时刻上的寿司,盘中有k个寿司,吃完这k个寿司后能得到d[i]的分数,可以选择拿或不拿这盘寿司,如果不拿,该分钟可以吃一个寿司,求最大的分数值

不需要去考虑dp写法

显然可以得到,最后i次取寿司不应该晚于n-i*(k+1)+1,这实际上限制了取寿司的时间,因此可以按照时间序列枚举每个时刻,若当前时间是某个i的n-i*(k+1)+1时刻,那么可以拿走这个时刻前最优的那盘寿司

这可以用(n-1-i)%(k+1)==k来等价,因为最开始是n-1-I mod k-1,然后是+1,…一直到k,恰好是吃完一盘的位置,也就是类似于哈希表存储余数的思想,当此时模数是k时,说明是某个i的k时刻

实际上是尽量不浪费取用次数的思想,因为每次取用后需要的时间是确定的,那么也就可以从每个寿司取或不取转到每个时间区间中选哪一个,同时可以保证每个我们划分的时间片中,一定只能选择一个点

因为这样最大化了时间,因此是最优的

有点类似于离线,也就是实际上是每个时间点直接取,但我们将其视作在每个最后选择的点统一选择

void solve() {

    int n,k;

    cin>>n>>k;

    vector<int> d(n);

    for(int i=0;i<n;i++){

        cin>>d[i];

    }

    i64 ans=0;

    priority_queue<int> pq;

    for(int i=0;i<n;i++){

        pq.push(d[i]);

        if((n-1-i)%(k+1)==k){

            ans+=pq.top();

            pq.pop();

        }

    }

    cout<<ans<<'\n';

}

 

 

有时候要敢于写贪心,相信代码的思路是简洁,特别是在子数组问题中(T2216)

选择我们刚好缺失的那个数字(T2952)

证明:我们现在在nums[i]处(i%2=0),发现nums[i+1]=nums[i]如果我们选择删除nums[i+1]之后的任一元素(nums[i+2], nums[i+3], ...),由于所谓的删除是后面的元素往前补,所以都不会对i+1及i+1之前的元素造成任何影响;而如果选择删除i-1及之前的任一元素呢?那么前面已经排好的元素会被打乱,我们显然需要花更多的时间来恢复,不难得出,我们唯一且必要的选择就是删掉nums[i]或nums[i+1]删掉这两个是等效的,在这我们选择删除nums[i+1] 最后环节中,我们只有可能: 1.得到一个只剩下nums[i]的数组 2.得到一个符合核心规则但不符合偶数长度的数组 3.得到一个恰好完美的数组 第1和2种情况其实可以共同判断,对于第三种情况直接返回已有计数,本题得解

(在于分辨是否进行这个操作的导致的结果)

有时的思路可以是使最小单元满足条件从而使整体满足条件,例如要01串连续的0或1段的长度为偶数,那我们就找出长度为2的单元,通过操作使其保证为00或11,这样最终一定能使得整段区间的0或1段的长度为偶数,又因为在操作次数最少的情况下使得段数最少,因此就默认按照能与前面相连的方式去修改(这个操作并不需要体现在代码中),只有对于不需要修改的情况才去增加段数

void slove() {
    cin >> n;
    cin >> s;
    long long x = 0, y = 0;
    char pre = '?';
    for (int i = 0; i < s.length(); i += 2) {
        if (s[i] != s[i + 1])x++;
        else {
            if (pre != s[i])y++;
            pre = s[i];
        }
    }
    cout << x << " " << max(1ll, y) << endl;}

 

cf 2078D

有两个车道,初始人数都是1,每次两个车道上都各自有一扇门,可能是对当前人数加上一个数字或者乘上一个数字,每次新增的人数都可以任意分配到某个车道上,问这样操作过后最终的人数最大可以是多少

因为要计算的是两个车道的总人数,每次碰到加操作对总人数的增量是确定的,因此每次要到下一个门的时候尽量把当前新增的人数加到下一个是乘(或乘的因子更大的)那个车道上,这样能最大化下次的增量,这样每一轮都让增量最大化

证明:因为每次都至少*2,也就是增加的量至少和原来的数是相同的,这样的话一个值保持在一个车道上,最后再乘,不如先去另一个车道复制一次的结果优

void solve() {  

    int n;

    cin>>n;

    vector<char> a(n),b(n);

    vector<i64> c(n),d(n);

    vector<int> s,t;

    for(int i=0;i<n;i++){

        cin>>a[i]>>c[i]>>b[i]>>d[i];

        if(a[i]=='+' && b[i]=='x'){

            s.push_back(i);

            t.push_back(1);

        }else if(a[i]=='x' && b[i]=='+'){

            s.push_back(i);

            t.push_back(0);

        }else if(a[i]=='x' && b[i]=='x'){

            if(c[i]!=d[i]){

                s.push_back(i);

                if(c[i]>d[i]){

                    t.push_back(0);

                }else{

                    t.push_back(1);

                }

            }

        }

    }

    i64 x=1,y=1;

    int len=s.size();

    for(int i=0;i<n;i++){

        i64 add=0;

        if(a[i]=='+'){

            add+=c[i];

        }else{

            add+=x*(c[i]-1);

        }

        if(b[i]=='+'){

            add+=d[i];

        }else{

            add+=y*(d[i]-1);

        }

        int e=upper_bound(s.begin(),s.end(),i)-s.begin();

        if(e==len){

            x+=add;

            continue;

        }

        if(t[e]==0){

            x+=add;

        }else{

            y+=add;

        }

    }

    cout<<x+y<<'\n';

}

 

 

cf 2055B
n种材料,每种材料初始有a[i]个,而我们需要每种材料至少b[i],每次我们可以用1单位的所有其他材料来制造1单位的某种材料,判断能否通过任意次操作来满足每种材料都符合需求

实际上只会制造一种材料

因为如果制造完一种再去制造另一种,显然首先要把制造前一种的损耗弥补上,但是这样操作后,因为一次制造要抵消掉其他所有材料的其中一份,因此这样操作后这两种材料没法都达到目标所需,反而使得其他所有材料的数量减少了

于是可以得出结论,我们最多只会对一种材料进行操作

void solve() {

    int n;

    std::cin >> n;

   

    std::vector<int> a(n), b(n);

    for (int i = 0; i < n; i++) {

        std::cin >> a[i];

    }

    for (int i = 0; i < n; i++) {

        std::cin >> b[i];

    }

   

    int i = 0;

    while (i < n && a[i] >= b[i]) {  

        i++;

    }

   

    if (i == n) {

        std::cout << "YES\n";

        return;

    }

   

    for (int j = 0; j < n; j++) {

        if (j != i && a[j] - (b[i] - a[i]) < b[j]) {

            std::cout << "NO\n";

            return;

        }

    }

    std::cout << "YES\n";

}

 

Cf 2055C

给定一个n*m的矩阵,矩阵上的每个位置都有一个数值,给定一个从(1,1)开始到(n,m)的路径,路径上每个点的数值都被涂抹为0,要求给出所有位置(也就是路径上的这些位置)都被重新填充好的矩阵,并且满足每行每列的值的总和相同

因为填入的值是任意的,相当于我们如果确定了该行的总和,那么就可以通过计算其他位置的和来得到该位置的值,因此,相较于考虑计算出总和x,不如直接确定x为0

移动方式是只能往右或者往下,也就是每一列和每一行最多只会被经过一次

例如,如果当前位置向右移动,那么就说明该位置的列其他元素都是确定的,那么根据总和为0就可以推出该位置的值,同样,如果当前位置向下移动,那么就说明这是最后一次处于这一行,那么通过前面的操作,这样的其他元素也就都确定下来了,那么这个元素也就可以计算

void solve(){

    int n,m;

    cin>>n>>m;

    string s;

    cin>>s;

    vector a(n,vector<i64>(m));

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            cin>>a[i][j];

        }

    }

    int i=0,j=0;

    while(i+j<n+m-1){

        if(i+j==n+m-2 || s[i+j]=='D'){

            i64 res=0;

            for(int x=0;x<m;x++){

                res-=a[i][x];

            }

            a[i][j]=res;

            i++;

        }else{

            i64 res=0;

            for(int x=0;x<n;x++){

                res-=a[x][j];

            }

            a[i][j]=res;

            j++;

        }

    }

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            cout<<a[i][j]<<" \n"[j==m-1];

        }

    }

}

 

Cf 2055d

有一只乌鸦和若干个稻草人,稻草人每一时刻可以向左或向右移动一个单位,乌鸦如果与当前最远的一个稻草人的距离相差距离小于k,那么乌鸦会移动到该稻草人位置坐标加k处,问乌鸦到达距离起点l处的最短时间的两倍

为了计算两倍的时间,可以先将l,a[i]等数值都乘上2

可以注意到,第一步一定是坐标最小的稻草人来到0,然后乌鸦移动到k

其次,如果抛去瞬间移动不看,那么乌鸦的速度要么没有,要么就是1,不可能大于1 ,又因为我们总的路程已经确定了,因此显然是让乌鸦瞬间移动的距离之和最大

要做到这点,就要用调整法对稻草人的位置进行处理

可以发现,最优策略下稻草人的行动只有两种:从右边向左去找乌鸦,然后把它弹到右边;从左边一步步走,每次顶着乌鸦前进。

由于更左侧的稻草人总是先接触到乌鸦,且右侧的稻草人在这时总是尽可能地向乌鸦在被左侧稻草人遇到后所处的位置移动。所以考虑贪心,计算出操作完后最靠右的位置即可

代码编写时,因为数值都乘了2,因此稻草人移动速度也从1变成了2

void solve(){

    int n,k,l;

    cin>>n>>k>>l;

    k*=2;

    l*=2;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]*=2;

    }

    int ans=a[0];

    int x=k;

    for(int i=1;i<n;i++){

        if(a[i]>x){

            a[i]=max(x,a[i]-ans);

            assert((a[i]-x)%2==0);

            ans+=(a[i]-x)/2;

            x=(x+a[i])/2+k;

        }else{

            a[i]=min(x,a[i]+ans);

            assert(x<=a[i]+k);

            x=a[i]+k;

        }

    }

    if(x<l){

        ans+=l-x;

    }

    cout<<ans<<'\n';

}

 

 

cf 3b

看似是一个背包的问题,但因为物品的体积的种类较少,因此可以考虑用贪心去解决

因为最后要输出方案,因此可以用pair存储物品的编号和价值,因为是贪心,因此我们对数组中的元素按照价值进行排序,从而判断方案时,就可以通过记录这是数组中的第几个元素来记录方案

 

cf 2028c

相当于是要剩下的元素尽可能多,因此采用贪心的方案

一共只能分出m+1块,于是可以预处理从前到后和从后往前的位置,枚举中间段,维护最大值

void solve(){

    int n,m,v;

    cin>>n>>m>>v;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<i64> pre(n+1);

    for(int i=0;i<n;i++){

        pre[i+1]=pre[i]+a[i];

    }

    i64 ans=-1;

    vector<int> f(m+1),g(m+1);

    for(int i=1,j=0;i<=m;i++){

        while(j<=n && pre[j]-pre[f[i-1]]<v){

            j++;

        }

        f[i]=j;

    }

    g[0]=n;

    for(int i=1,j=n;i<=m;i++){

        while(j>=0 && pre[g[i-1]]-pre[j]<v){

            j--;

        }

        g[i]=j;

    }

    for(int i=0;i<=m;i++){

        if(f[i]<=g[m-i]){

            ans=max(ans,pre[g[m-i]]-pre[f[i]]);

        }

    }

    cout<<ans<<"\n";

}

 

 

cf`1992d

如果用动态规划的思路做,那么就是处理到达i这个点时最少需要游多少距离

但实际上我们每次的选择都是确定的

首先,为了让我们游泳的距离尽可能少,那么我们每次能跳的时候,一定是尽可能跳满最远的距离,这里分为两种情况,一种是在m距离内就有新的木板,那我们显然是跳到新木板上;如果没有,那么我们显然是跳到m距离的水中

同时如果当前这一格是水,理论上我们需要判断这是不是在我们跳跃的距离内,但因为我们设定了pre这个变量,因此只要判断pre对应的值是木板还是水就可以判断了,如果是水,那么显然只能往前游,对于鳄鱼的位置同理,本来需要判断能否通过木板避开鳄鱼,现在只要判断是否pre是鳄鱼即可,因为相当于我们每次的移动都是确定且最优的

同时因为我们最后一段上岸的距离也被计入在游泳距离中,因此i需要遍历到n

void solve(){

    int n,m,k;

    cin>>n>>m>>k;

    string s;

    cin>>s;

    int pre=-1;

    for(int i=0;i<=n;i++){

        if(pre>=0 && s[pre]=='W'){

            pre=i;

            k--;

        }else if(i==n || s[i]=='L' || i-pre==m){

            pre=i;

        }

        if(pre>=0 && pre<n && s[pre]=='C'){

            cout<<"NO\n";

            return;

        }

    }

    if(k>=0){

        cout<<"YES\n";

    }else{

        cout<<"NO\n";

    }

}

 

 

cf1997C

代价是匹配括号的距离

想要代价尽量小,就需要将右括号尽量靠近左括号,也就是去匹配最近的左括号

因此从前往后扫,能放右括号就不放左括号

可以用栈解决

void solve(){

    int n;

    cin>>n;

    string s;

    cin>>s;

    //因为个数一定是匹配的

    //怎么计算分数

    stack<int> st;

    i64 ans=0ll;

    for(int i=0;i<n;i++){

        if(s[i]=='_'){

            if(st.empty()){

                st.push(i);

            }else{

                ans+=i-st.top();

                st.pop();

            }

        }else if(s[i]=='('){

            st.push(i);

        }else{

            ans+=i-st.top();

            st.pop();

        }

    }

    cout<<ans<<'\n';

}

或者将代价转化成每个位置深度的和,也就是每个位置左括号减右括号加起来

同样可以说明应该把左括号尽量往后放

不用栈的写法

void solve(){

    int n;

    cin>>n;

    string s;

    cin>>s;

    int pre=0;

    i64 ans=0;

    for(int i=0;i<n;i+=2){//因为只有奇数位是未知的

        if(pre>0){

            pre--;

        }else{

            pre++;

        }

        ans+=pre;

        pre+=s[i+1]=='('?1:-1;

        ans+=pre;

    }

    cout<<ans<<"\n";

}

 

ICPC2024 网络赛第二场 J

有时贪心是一种列方程的思考方向,我们假设只有两个物品,考虑什么情况下是比较优的

于是列出w1*(c2)>c1*(w2)

于是得到最优顺序一定是按ci/wi不增的顺序排列的,同时当这个比例相同的时候,让重量大的在上面,这样可以得到更多的贡献(c和w同时对排列造成影响,因此思考哪一项占的比重更大,或者想到考虑c和w之间的关系,w相对c造成的影响更大,就将其放在上面)

 

对贪心进行修正

ICPC2024 南京K

有w个格子排成一行,从左到右编号从1到w,这些格子中,有n个是红色的,有m个是黑色的,剩下的各自是白色的

要用一些不相交的纸条覆盖所有红色格子,每张纸条的长度都是k,每个红色格子都要被覆盖,每个黑色格子都不能被覆盖,求纸条数尽量小的方案

显然黑色格子将所有格子分成了几段,因此可以对每段分别求解

对于单独的一段,贪心想法是显然的,从左到右枚举红色格子,若当前格子没有被覆盖,那么就以当前格子为左端点添加一张纸条

但如果这样操作不符合条件,那我们并不需要考虑任何情况下怎么构造才是合适的(这样往往是比较困难的,因为并没有什么特征或者结论可以被使用,于是遇到这种情况,考虑怎么从已有情况进行修改),而是对上面的方案进行调整,例如把超出的边界的纸条向左移动

注意这里只需要对红色的格子进行遍历,如果是在真个数组中对红色格子进行标记再遍历整个数组会超时,因为整数组的大小是1e9级别的,而白色格子对我们的方案没有影响

void solve(){

    int w,m,k,n;

    cin>>w>>m>>k>>n;

    vector<int> b(m);

    vector<int> a(w);

    for(int i=0;i<w;i++){

        cin>>a[i];

        a[i]--;

    }

    for(int i=0;i<m;i++){

        cin>>b[i];

        b[i]--;

    }

    sort(a.begin(),a.end());

    sort(b.begin(),b.end());

    vector<pair<int,int>> v;

    int pre=-1;

    int id=0;

    for(int j=0;j<=m;j++){

        int last=-1;

        for(;id<w && a[id]<(j<m?b[j]:n);id++){

            if(a[id]>last){

                v.push_back({a[id],a[id]+k-1});

                last=a[id]+k-1;

            }

        }

        if(last<(j<m?b[j]:n)){

            pre=v.size()-1;

            continue;

        }

        int modify=last-(j<m?b[j]:n)+1;

        for(int i=v.size()-1;i>pre;i--){

            v[i].second-=modify;

            v[i].first-=modify;

            if(i>pre+1 && v[i].first>v[i-1].second){

                break;

            }

            if(i>0) modify=v[i-1].second-v[i].first+1;

        }

        if(v[pre+1].first<(j==0?0:b[j-1]+1)){

            cout<<-1<<'\n';

            return;

        }

        pre=v.size()-1;

    }

    cout<<v.size()<<'\n';

    for(auto [x,_]:v){

        cout<<x+1<<' ';

    }

    cout<<'\n';

}

 

 

cf 1934b

看似是背包,但首先数据量较大,因此不可能使用dp数组

因此考虑其他方法,这里的关键在于我们能使用的数据是有上界的

即需要的数字永远不会超过 2 个一, 1 个三, 4 个六和 2 个十

对于 1 :假设如果使用 k > 2 个一,那么就可以使用一个 3 和 k- 3 个1代替。

对于 3 :假设如果使用 k > 1 个三,那么就可以使用一个 6 和 k- 2个3代替。

对于 6 :假设如果使用 k > 4个 六,那么你可以使用两个 15 和 k - 5个六代替。

对于 10 :假设如果使用 k> 2个 十,那么就可以使用两个 15 和 k-3 个十代替。

现在,由于对它们的计数约束较小,我们可以对这些计数进行暴力计算

 

cf1937b

贪心在于,一方面要使当前的答案最优,另一方面要使之后的可能方案最多

由于只能向下走一次,所以我们可以直接模拟整个走的过程。开头与结尾已经确定,中间部分我们不妨贪心选择,具体的,当这一步向右比向下优时,我们向右走;当这一步向下比向右优时,我们向下走;向右与向下一样时,向右走肯定不比向下走劣。(这是因为在上面会有更多的选择权)

具体来说:
如果我们在第 2行,则向右移动。

如果我们在第 n 列,向下移动。

从这里开始,让右边的字符为 a ,下面的字符为 b 。

如果是 a>b,则向下移动。

如果是 a<b ,则向右移动,并将计数器重置为 1。

如果 a=b ,向右移动并递增计数器

 

cf1948c

有时贪心可以说是只要能操作/需要进行操作就进行操作

这里我们逐个进行判断,显然,当当前元素出现了逆序关系,那我们必须进行操作,因为这个操作是必须进行的,因此可以先不去考虑操作对于后续的影响,先将这个操作存储下来,等到所有操作完成之后再去判断最后是否满足有序的要求

 

cf1950F

在模拟的过程中体现贪心,一共要进行a次1操作,b次2操作,但我们每次操作想要优先进行1操作,因此可以可以在for循环中写上if(i<a){...}来表示这一过程

同时,在模拟的过程中可以进行一些动态维护来简化运算,即维护当前的空闲节点,以及下一层的空闲节点,并且在进行1操作的时候,因为添加的是一个具有两个子节点的节点,因此对此下一层的空闲节点数与当前层相比可以+1,而如果进行的是2操作,那么下一层的空闲数和当前的空闲数之和应该是不变的,具体可以写作

void solve() {

    int a, b, c;

    cin >> a >> b >> c;

    if (a + 1 != c) {cout << -1 << '\n'; return;}

    if (a + b + c == 1) {cout << 0 << '\n'; return;}

    int curr = 1, next = 0, res = 1;

    for (int i = 0; i < a + b; i++) {

        if (!curr) {//说明当前层填充完了,可以进入下一层了

            swap(next, curr);

            res++;

        }

        curr--;

        next++;//即表示两者之和相同

        if (i < a) {next++;}//与上一层相比多了一个空闲,并且i是递增的,说明我们是优先选择2个子节点的点

    }

    cout << res << '\n';

}

 

cf1955 E

字符串中的任何字符都不需要操作两次

因为我们是要选取进行操作的区间长度,因此如果前i个字符已经等于1,但s[i+1]等于0,说明我们需要反转从i+1到i+k的所有位,即我们不去反转前i个字符,因为如果反转了其中的任何一个,我们必须再反转它,这将使s[i+1]重新变为0,这样我们相当于做了无效的操作,或者我们需要令k变小才能使得在不影响s[i+1]的前提下使其s[1]~s[i]变为原来的状态,因此一定是向后操作更优,而对当前位进行操作之后我们就可以直接继续向后判断了,因为这个操作是必须的,并且按照上述的思想,后续的操作不会再影响前面

当不能保证有二段性(即不能二分)以及O(n^2)的复杂度已经可行时就不考虑二分了

因此我们先遍历k,并进行check,找出能使字符串全部为1的最大值

具体来说,我们每次遇到0都向后反转k个字符,最后判断这样贪心的操作后是否使得整串字符都变成1

#define all(x) (x).begin(),(x).end()

void solve() {

    int n;

    string s;

    cin >> n >> s;

    for (int k = n; k > 0; --k) {

        vector<int> t(n,0), end(n + 1,0);

        for (int i = 0; i < n; ++i) {

            t[i] = s[i] - '0';//初始的数值

        }

        int cnt = 0;//表示的是对i位置上的元素要进行多少次操作

        for (int i = 0; i < n; ++i) {

            cnt -= end[i];//有多少个区间反转操作在该位置前结束了

            t[i] ^= (cnt & 1);//&1表示是否要进行反转,异或表示进行了反转操作

            if (t[i] == 0) {//仍等于0,说明要进行操作,对从该位置开始的k个字符进行操作

                if (i + k <= n) {

                    ++end[i + k];//此处的记录有点像差分数组的思想,因为从i到i+k-1都要操作

                    ++cnt;

                    t[i] = 1;

                } else {//已经不能再继续操作了

                    break;

                }

            }

        }

        if (*min_element(all(t)) == 1) {

            cout << k << '\n';

            return;

        }

    }

}

 

cf1923a

要使得操作最优,即用最少的步数使得所有1移动到一起,显然我们操作的时候1的个数是确定的,相当于我们最后连续块的长度是已知的,现在就是要通过若干次操作使得变成这种形状,因为我们块的长度实际上是由左端点和右端点决定的

表示最左边筹码的位置为 l ,最右边筹码的位置为 r ,1的个数是 c 。

将所有的筹码都放在一个没有空位的棋块中,意味着我们需要达到 r−l=c−1的情况。由于 r−l≥c−1 总是被满足( r−l=c−1时的情况是已经变成了一个连续块了),我们需要尽可能快地减少 r−l的值

其中一种方案就是我们每次都视作是在对最右侧的1进行移动,如果其-1位置处已经有1了,视作将那个1向里面不是1的位置挤,如果不是1,那么可以直接移到那个位置,即无论如何都可以视作最右侧的向左移动了一位

因此最终的答案是(r-l)-(c-1)

 

cf1923c

要使得数组中的每个元素都不一样可以先考虑数组中最小元素是什么情况

要回答查询 l , r,首先让我们试着找到最小值为 ∑bi的数组 b 。为此,对于其中的 ci=1我们必须设置bi=2,而对于ci>1的我们设置bi=1。因此,数组 b 中所有元素的总和等于 cnt1⋅2+(r−l+1)−cnt1 ,现在有三种情况:

如果总和大于 ∑ci ,那么答案就是 NO ;

如果这个和等于 ∑ci,那么答案就是 YES ;

如果总和小于 ∑ci,那么我们可以将超出部分加到数组 b中的2上。在这种情况下,答案为 YES

 

cf1925c
构造反例

我们将尝试构建一个反例。如果不能,答案就是可行,否则就是不可行。

我们将贪心地构建反例。最佳的方法是选择我们反例的第一个字符,即在 s中出现的第一个索引最高的字符(在前 k个英文字母中),因为显然我们这样做能够使得可能的子串形式最少。将该字符添加到我们的反例中,然后从 s中移除直到该字符为止的前缀,如此重复,直到反例的长度达到 n或到达 s的末尾。

如果计数词组的长度小于 n,则找到一个没有出现在 s的最后一个后缀中的字符。继续将该字符添加到反例中,直到其长度变为 n 。显然这是一个有效的字符串,并且不会出现在 s 的后缀中。

否则,所有可能的长度为 n 的字符串都会作为 s 的子序列出现

string res="";

    bool found[30];

    memset(found, false, sizeof(found));

    int count=0;

    for(char c:s){

        if(res.size()==n)

            break;

        count+=(!found[c-'a']);

        found[c-'a']=true;

        if(count==k){

            memset(found, false, sizeof(found));

            count=0;

            res+=c;

        }

    }

    if(res.size()==n)

        cout<<"YES\n";

    else{

        cout<<"NO\n";

        for(int i=0;i<k;i++){

            if(!found[i]){

                while(res.size()<n)

                    res+=(char)('a'+i);

            }

        }

        cout<<res<<"\n";

    }

 

cf1989C

选择一种分配方式,让a和b的最小值最大

只有正负1和0可以选,因此如果两个不一样,一定是选大的那个,因为这样不会让最小值进一步变小,如果两个都一样,就可以选其中一个进行操作,不能确定具体选哪一个因此放到一个公有数组中进行操作,这也是为了维护两个元素当前的性质,因此都是-1的时候,选择将两个都减,然后将公有部分+1,最终的答案就是将公有部分全部归于a,或者全部归于b,或者让两者尽可能接近,即(a+b+c)/2下取整

int a[200005],b[200005];

void solve(){

    int n;

    cin>>n;

    int A=0,B=0,C=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    for(int i=1;i<=n;i++){

        cin>>b[i];

    }

    for(int i=1;i<=n;i++){

        if(a[i]>b[i]){

            A+=a[i];

        }else if(a[i]<b[i]){

            B+=b[i];

        }else if(a[i]==1){

            C++;

        }else if(a[i]==-1){

            C++;

            A--;

            B--;

        }

    }

    int ans=min({A+C,B+C,(A+B+C)>>1});

    cout<<ans<<'\n';

}

 

cf 1978D

如果候选人i最初没有获胜,那么为了它的胜利,我们必须删除所有票数小于i的候选人,否则即使我们增加了可以自由分配的票,候选人i的票数仍然不会增加

于是可以先找出选举的获胜者,对于他们来说答案是0,对于其他所有人我们要删除所有票数小于他们的人,若这样还不够,那我们可以删除得票数最多的那个人,显然这样操作之后

void solve(){

    int n,c;

    cin>>n>>c;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    a[0]+=c;

    int t=max_element(a.begin(),a.end())-a.begin();

    i64 sum=0;

    for(int i=0;i<n;i++){

        sum+=a[i];

        int ans=0;

        if(i==t){

            ans=0;

        }else if(i>t){

            ans=i;

        }else{

            ans=i+(sum<a[t]);

        }

        cout<<ans<<' ';

    }

    cout<<'\n';

}

 

cf545C
尽量不影响选择,第一棵向左,最后一棵向右

中间能向左就向左,否则向右

向左是因为左侧已经是处理过的数据,不影响后续的选择

直接向右是否影响,因为在一个区间上对于它来说,要么不移动,它对这个区间没有贡献,如果向右倒后导致原来下一棵向左倒可行的变得不可行,但对于这个区间来说总的答案数仍然是不变的

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i]>>b[i];

    }

    int ans=2;

    for(int i=2;i<=n-1;i++){

        if(a[i]-b[i]>a[i-1]) ans++;

        else if(a[i]+b[i]<a[i+1]){

            ans++;

            a[i]+=b[i];

        }

    }

    cout<<(n==1?1:ans)<<'\n';

}

 

cf892C
如果有1,显然是1和其他数进行操作使得gcd为1

如果没有1,显然是要通过一些操作使得1出现

因为是相邻元素进行操作

于是就是要找到最小的区间gcd为1 ,这可以通过枚举左端点和右端点实现,并且在枚举右端点的时候,因为gcd的性质,可以直接用上一个区间的结果

特别的,如果整个数组的gcd不为1,那么是无解的

void solve(){

    int n;

    cin>>n;

    int cnt=0;

    int sg=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        sg=gcd(sg,a[i]);

        if(a[i]==1) cnt++;

    }

    //cout<<sg<<'\n';

    if(cnt){

        cout<<(n-cnt)<<'\n';

        return;

    }

    if(sg!=1){

        cout<<-1<<'\n';

        return;

    }

    i64 ans=INT_MAX;

    for(int i=1;i<=n;i++){

        sg=a[i];

        for(int j=i+1;j<=n;j++){

            sg=gcd(sg,a[j]);

            if(sg==1){

                ans=min<i64>(ans,j-i);

                break;

            }

        }

    }

    cout<<(ans+n-1)<<'\n';

}

 

牛客24多校3 A

n个人在河的一侧,第i个人有体力值h[i],表示能够跑几趟

有且仅有一艘能容纳l到r的船

问是否能将所有人从河的左侧运送到右侧

贪心的考虑,从左侧向右侧运送时应该尽可能让多的人上船,也就是r个人,而返回的时候因为仍然需要有人在上面,因此选择让l个人上船

但因为有体力值的限制,因此不能直接按上述方案重复操作

需要从河的右侧往回运送趟数的最小值是S=(n-r)/(r-l)上取整(因为假设最后一次运输r个人后使得所有人都已经在右侧,那么相当于其余的n-r个人是在向右运输,向左运输这样的过程中操作完毕的,这样的向右和向左的操作是一一对应的,因此整除每次净运送过去的人)

令ai=(hi-1)/2下取整为第i个人最多能来回的趟数,例如从左到右,从右到左,再从左到右,需要3点体力,但只能算一个来回,因为我们最终结束一定是以从左到右结束的

那么一个必要条件是 ,右侧表示至少要有S*L人次走回程(也就是向左走),而对于每个人来说,他走回程的次数不超过ai,而整体来说,一个人没必要走超过S次的回程

下述贪心保证条件的充分性(也可以用于输出方案):每次将体力值最高的某个人从一侧运送到另一侧

贪心成立性的原因在于每次从右侧往回运的过程相当于与每次将a1,a2,...,an中最大的L个元素减1,那么更新后的数组a’(保证元素均大于等于0)仍然有

void solve(){

    int n,l,r;

    cin>>n>>l>>r;

    int s=(n-r+r-l-1)/(r-l);

    i64 sum=-s*l;

    for(int i=0;i<n;i++){

        int h;

        cin>>h;

        int a=(h-1)/2;

        sum+=min(a,s);

    }

    cout<<(sum>=0?"Yes":"No")<<'\n';

}

 

牛客24多校9 K

因为要让每个怪兽的血量都归零,因此对于一个怪兽来说,显然是优先进行除操作再统一进行减操作,并且在有多个怪兽可以同时选择进行操作二时,一定是优先对当前最大的数执行

于是用有限队列动态维护最值,同时枚举操作次数即可

(因为这里不是边界,而是要控制操作次数最小,因此不应该用二分而应该用枚举,枚举的边界是已经不能继续进行操作二,也就是用操作二将所有怪物的血量都归零了)

void solve(){

    int n,k;

    cin>>n>>k;

    i64 mx=0ll;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        mx=max(mx,a[i]);

    }

    if(k==1){

        cout<<mx<<'\n';

        return;

    }

    priority_queue<i64> pq;

    i64 ans=mx;

    for(int i=0;i<n;i++){

        pq.push(a[i]);

    }

    for(int i=1;;i++){

        i64 p=pq.top();

        pq.pop();

        if(p==0){

            break;

        }

        p/=k;

        pq.push(p);

        ans=min(ans,pq.top()+i);

    }

    cout<<ans<<'\n';

}

 

 

 

离散化:
可以使之后的二分变得便利,假如一个数组中有正有负,同时数组中有重复出现的数字,并且我们要在这个数组中找第一个小于某个数k的值,对于这种问题显然会想到用二分去解决,因此将当前数组copy一份到另一个(因为会涉及到去重,因此要重新拷贝一个数组),并且将赋值得到的数组进行排序,这样就能从数组的下标进行二分找到第一个小于某个数的值(离散化操作后可以让原数组中的数排布地更紧密)

(T2426)

离散化的特点:

数据的值域比较大,个数比较少【一般l和 r较小的话,比如<=10^5,用前缀和】

把值域里的数映射到连续的自然数序列里

这个映射其实就是把数组的值映射到其对应的下标

当数据只需表示出它们之间的相对大小关系,而不需表示出具体数值时,我们就要用离散化

 

离散化模版:

vector<int> alls; //存储所有待离散化的值(也可以是原数组的拷贝)

sort( alls.begin(),alls.end() ); //将所有的值排序

alls.erase( unique( alls.begin(),alls.end() ),alls.end() ) ); //去掉重复元素

 

//unique函数:头文件#include <algorithm>该函数的作用是“去除”容器或者数组中相邻元素的重复出现的元素

(1) 这里的去除并非真正意义的erase,而是将重复的元素放到容器的末尾,返回值是去重之后的尾地址。

(2) unique针对的是相邻元素,所以对于顺序顺序错乱的数组成员,或者容器成员,需要先进行排序,可以调用std::sort()函数

 

// 二分求出对应的离散化的值 (自然可以用lower_bound实现)

int find(int x) //找到第一个>=x的位置

{

    int l=0,r=alls.size()-1;

    while(l<r)

    {

        int mid = l+r>>1;

                  if(alls[mid]>=x) r=mid;

                  else l=mid+1;

    }

    return r+1; //映射到 1~n,不 +1的话就是 0~n

}

 

二分查找:

最大值最小化,二分答案

二分是一个很有效的优化途径,因为可以做到log的复杂度,对于二分有一个界定标准:状态的决策过程或者序列是否满足单调性或者可以局部舍弃性;例如P1083,虽然答案不符合单调性(天数越多越难满足),但具有局部舍弃性(这是符合二段性的),即前一份订单都不满足,那么之后的所有订单都不用继续考虑;而如果后一份订单都满足,那么之前的所有订单一定都可以满足,即对于我们最终的答案,满足答案之前的数据都可以满足,答案之后都不能满足   直接一次遍历过不了时就可以考虑用二分去优化了

集合中的元素有存在分界线,给定条件可以将集合中元素分为两部分,一部分满足条件,一部分不满足条件,即分界线之前的第一个元素,也就是最后一个满足条件的值;分界线之后的第一个元素,也就是第一个不满足该条件的值

这样的性质成为二段性,在 T33.中,我们强调,二分的本质是「二段性」而非「单调性」,而经过本题,我们进一步发现「二段性」还能继续细分,不仅仅只有满足 01 特性(满足/不满足)的「二段性」可以使用二分,满足 1?特性(一定满足/不一定满足)也可以二分

题目让我们实现一个 O(logn)算法,就是对使用「二分」的强烈暗示

对于一个通过判断某个条件而逐渐累加的值可以利用二分法,相当于对于这个答案,答案左侧是都要继续增加的,答案右侧是不需要增加的,因此也具有二段性(T29)

返回值与题干相关的数据有单调性,因此可以用对答案二分的方法(T1954)

寻找最大值的最小值之类的问题可以考虑二分法,二分答案(T274,对答案进行二分,满足答案的那个最大h,在以它为分割点的数轴上具有「二段性」,左端是满足h定义,右端是不满足的)

 

Cf 2089B2

给定长度为n的两个正整数数组a和b,保证a中的元素和小于b中的元素和

操作1:选择a中的一个元素,将其元素值减1

操作2:对于每一个下标i,将a[i]和b[i]同时减去min(a[i],b[i]),然后将数组a进行一次轮换

在最多执行k次1操作的情况下,最少需要进行几次操作2使得数组a中的元素全为0

首先考虑操作1执行次数为0的情况

因为数组a的元素和小于数组b的元素和,而且每次操作后两个数组的元素和是同时减去一个值的,从而该元素和大小关系保持不变,因此我们每一轮至少会有一个下标i,使得执行操作2后该位置变为0,最劣情况显然是每一轮只有一个变成0,因此答案上界是n

因为每次操作完后会循环右移,也就是一个环结构

但由于操作次数是有限的,从而可以破环成链,也就是将a和b都循环延长(复制一遍添加到末尾),同时,因为最多执行n次操作1,于是对于a[i](0<=i<n)只可能和b[j](i<=j<i+n)(a[i]每次向右移动,对应的b就是i+c)匹配(指匹配时a[i]在这轮操作后变为0)

可以仿照括号匹配的思路,a[i]作为左括号,对每个b[i]优先和左边第一个不为0的a[i]匹配(只是类似,本质是当前的b[j]一定是优先和左侧较近的a[i]进行操作)

于是从左到右枚举i,将每个不为0的a[i]入栈,然后b[i]不断和栈首的a[j]操作,直到存放元素a的栈为空或者b[i]=0,匹配的同时更新最大操作轮数,轮数实际就是i和j之间的距离i-j+1

现在再考虑操作1

上面讨论的情况都是在操作1完成后的,并且是O(n)完成计算答案,同时操作1也是有界的,因此考虑二分答案(需要的轮次)

每次check,检查需要多少次操作1才能使轮数不超过mid,具体来说,每次我们都可以知道b[i]和b[j]匹配需要经过i-j+1轮,而当轮数超过mid时,我们就需要进行操作1,于是将当前的a[j]加到操作次数中

而在循环数组操作中,可以使用rotate来优化复制数组的操作,这能保持数组的原始大小同时不引入其余数据,具体实现方式是用a[i]-b[i]来计算一个pre数组,将数组a和b都移动到pre数组最小的位置之后一位的位置(相当于把累计b[i]-a[i]最大的位置移动到最后,从而后续用b[i]去消耗栈中的元素时,有较多的可用值),这样能够保证在从0到n-1枚举完成后,一定能使得栈是空的

https://codeforces.com/contest/2090/submission/313590190

 

Cf 2070c

给定一条n个单元格组成的条带,初始都是红色

可以进行最多K次操作,每次操作选择一段涂成蓝色,操作完成后,如果一个单元格的颜色错误,那么会被惩罚a[i],最终的惩罚值是a[i]中的最大值,问可能的最小乘法值是多少

最大值的最小值问题,可以考虑二分答案,因为显然越大的乘法值越容易达到

有一种贪心的思想是每次选择惩罚值最高的蓝色进行操作,同时染色后周围也有目标蓝色的那么可以一起染色了,但这种方法对于涂错一些红色来降低惩罚值的情况不好讨论

我们二分最后的惩罚值,因为要在乘法值数组中,因此相当于用离散化保证我们二分的答案一定存在,然后进行check,如果是大于m的蓝色,则一定要染色,同时染色完毕后,如果两侧是小于的m的红色,因为我们假定最后的答案是m,因此小于它的红色涂错并不会被影响,因此进行衍生

void solve() {

    int n,k;

    cin>>n>>k;

    string s;

    cin>>s;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    auto val=a;

    val.emplace_back(0);

    sort(val.begin(),val.end());

    int l=0,r=n+1;

    auto check=[&](int m)->bool{

        int tmp=k;

        for(int i=0;i<n;i++){

            if(s[i]=='B' && a[i]>m && tmp>0){

                // cout<<i<<' '<<tmp<<"\n";

                i++;

                while(i<n && (a[i]<=m || s[i]=='B')){

                    // cout<<"add\n";

                    // cout<<i<<"\n";

                    i++;

                }

                i--;

                // cout<<i<<"\n";

                tmp--;

            }else if(s[i]=='B' && a[i]>m){

                return false;

            }

        }

        return true;

    };

    while(l<r){

        int m=(l+r)>>1;

        // cout<<l<<' '<<r<<'\n';

        // cout<<m<<' '<<val[m]<<' '<<check(val[m])<<'\n';

        if(check(val[m])){

            r=m;

        }else{

            l=m+1;

        }

    }

    cout<<val[l]<<'\n';

}

 

 

二分答案是平均数一类问题的常用思路(P1404),因为一定满足大于某一个值之后,平均数取不到大于这个值的数

求任意区间内的平均数一般都要预处理出前缀和,判断是否存在一个连续区间(有长度限定)的平均值大于等于我们所确定的平均值mid,可以先将数列每一项减去mid,这样如果存在前缀和s[i]<=s[j](只要这段区间内的和>=0,就说明平均值大于等于mid) &&  j-i>=m(m就是限制的长度)就说明存在,所以我们重点可以用于存储保证区间长度m下的最小值

bool check(int mid){

    ll minn=0;

    for(int i=1;i<=n;i++){

        sum[i]=sum[i-1]+a[i]-mid;//减去均值之后的前缀和

        if(i>=m){

            minn=min(minn,sum[i-m]);//保证了区间长度至少为m

            if(sum[i]>=minn) return true;

        }

    }

    return false;

}

 

二分查找常见的处理方式

中位数

cf1993D

为了突出中位数,可以将序列中<mid的数设为-1,>=mid的数设为1,这样可以用于观察原数组中的数量关系,也利于直观的感受数据的影响

同时问题可以转化为从序列中删除 个长度为m的连续段,最大化剩下的值

一种显然的dp方法是dp[i][j]表示前i个数删除了j段的最优解,但状态数是平方级别的因此并不能接受,但因为j必须大于等于 ,同时又必须小于等于 ,相当于每个i来说,有用的j只有两个,因此可以用dp[i][j],其中j=0或1来表示前i格分成 +j段的最优解,可以用记忆化转移搜索

另一种思路:一般涉及到中位数的题,很多可以尝试用二分解决,因为越大越好,越小越可行,这是具有单调性的

于是可以二分x作为答案,问题转化为最终中位数是否可以>=x

在进行check的时候,显然我们只关心>=x,<x的个数,并不关心具体它们具体的值,同时操作的目的变成了使得余下<x的数尽可能少

因为如果进行删除,那么一定是按照连续段进行删除

于是可以确定dp的转移方程

dp[i]=dp[i-k],如果删除第i个;否则dp[i]=dp[i-1]+(a[i]<x)

而dp[i]就是在这两种情况中取min

结合一下上面两种思路,我们直接将小于x的数设为-1,将大于x的数设为1

因为要判断x作为中位数是否可行,有一个小于x和一个大于x的数相当于恰好抵消

这样只需要判断我们最终的值能否大于0

当(i%k==0),因为我们下标是从0开始的,实际上表示这是第nk+1个数,可以直接将前面的数全部删除,这种情况是不能用之前的状态转移过来的,因此要特殊计算

除此之外的点,如果不被选择,那么就是上面提到的dp[i]=dp[i-1]+a[i],同时在与被选择的情况中取max

void solve(){

    int n,k;

    cin>>n>>k;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> dp(n);

    auto check=[&](int x){

        for(int i=0;i<n;i++){

            int v=a[i]>=x?1:-1;

            if(i%k==0){

                dp[i]=v;

            }else{

                dp[i]=dp[i-1]+v;

            }

            if(i>=k){

                dp[i]=max(dp[i],dp[i-k]);

            }

        }

        return dp[n-1]>0;

    };

    int l=1,r=1e9;

    while(l<r){

        int mid=(l+r+1)>>1;

        if(check(mid)){

            l=mid;

        }else{

            r=mid-1;

        }

    }

    cout<<l<<'\n';

}

 

2024ICPC网络赛第一场G

中位数进行二分的时候除了设置成1和-1统计最后的和是否大于0,也可以是设成1和0判断最后的个数是否大于了数组长度的一半

题意是给定我们一个数组a,我们通过对数组中的每一个子串取中位数得到新的数组b,再对b进行一次这样的操作得到c,问c数组的中位数是多少

首先为了得到b数组,我们可以通过对枚举a数组中区间内的左端点以及右端点,对于一个固定的左端点,使用对顶堆来动态维护之后添加元素过程中的中位数,并将其放入b数组中

对于中位数我们考虑二分

在设定了小于和大于数的数值后,对于当前的mid,c中小于等于当前数的个数要大于长度的一半,这个比较可以使用二维前缀和

struct Mid{//维护中位数

    priority_queue<int> l;

    priority_queue<int,vector<int>,greater<int>> r;

    void push(int x){

        if(l.empty() || x<l.top()){

            l.push(x);

        }else{

            r.push(x);

        }

        while(r.size()<l.size()){

            r.push(l.top());

            l.pop();

        }

        while(l.size()<r.size()){

            l.push(r.top());

            r.pop();

        }

    }

    int get(){

        return l.top();

    }

};

 

void solve(){

    int n;

    cin>>n;

    vector<int> a(n+1);

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    vector b(n+1,vector<int>(n+1,0));

    int len=0;

    for(int i=1;i<=n;i++){

        Mid mid;

        for(int j=i;j<=n;j++){

            mid.push(a[j]);

            b[i][j]=mid.get();

            len++;

        }

    }

    len=(len+1)/2;

 

    auto check=[&](int x){

        vector b_1(n+1,vector<int>(n+1,0));

        for(int l=1;l<=n;l++){

            for(int r=l;r<=n;r++){

                if(b[l][r]<=x){

                    b_1[l][r]=1;

                }else{

                    b_1[l][r]=0;

                }

            }

        }

        vector c(n+1,vector<int>(n+1,0));

        //用二维前缀和来体现c的构造过程

        //因为对于c(l,r),是通过b(i,j) (l<=i<=j<=r)序列的中位数得到的

        //因此对于c这个位置最后的元素是否大于mid可以通过包含的b(i,j)的情况判断,因为有i,j两个变量因此用二维前缀和存储

        for(int l=1;l<=n;l++){

            for(int r=1;r<=n;r++){

                c[l][r]=b_1[l][r]+c[l][r-1]+c[l-1][r]-c[l-1][r-1];

            }

        }

        int tot=0;

        for(int l=1;l<=n;l++){

            for(int r=l;r<=n;r++){

                int num=(r-l+1)*(1+r-l+1)/2;//c(l,r)对应的b子序列的长度

                num=(num+1)/2;

                int cnt=c[r][r]-c[l-1][r]-c[r][l-1]+c[l-1][l-1];

                if(cnt>=num){//说明在最后的(一维的)c数组中(l,r)对应的元素是小于等于mid的

                    tot++;

                }

            }

        }

        return tot>=len;//是否能满足至少大于(n+1)/2个数

    };

 

    // int l=0,r=inf;

    sort(a.begin(),a.end());

    a.erase(unique(a.begin(),a.end()),a.end());//离散化

    int l=1,r=a.size()-1;

    int ans=0;

    while(l<=r){

        int mid=(l+r)>>1;

        if(check(a[mid])){

            ans=mid;

            r=mid-1;

        }else{

            l=mid+1;

        }

    }

    cout<<a[ans]<<'\n';

}

特别的,如果设大于mid的为1,小于mid的为-1,可以直接

int cnt=c[r][r]-c[l-1][r]-c[r][l-1]+c[l-1][l-1];

if(cnt>=0){//说明在最后的(一维的)c数组中(l,r)对应的元素是小于等于mid的

    tot++;

}

不用去计算num

 

2024哈尔滨 K

求得一种方案使得利润最大

初始情况下每种作物的时间至少要到其最小值,这一部分是不会发生改变的

其次,考虑对哪种作物进行改变

如果我们对编号为i的作物进行了操作,很显然,我们一定会把多的部分从大到小地塞给价格比它高的,同时如果还有多的那么就交给自己消化绝不会交给比它小的部分

也因此,操作的元素不一定是第一个元素和最后一个,于是O(n)地遍历查看

为了加速用前缀和进行预处理使得对每个元素的判断都可以在O(logn)的复杂度内完成

void solve(){

    i64 n,m;

    cin>>n>>m;

    vector<pair<int,pair<int,int>>> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i].first>>a[i].second.first>>a[i].second.second;

    }

    sort(a.begin(),a.end(),greater());

    vector<i64> del(n),l(n),delpro(n);

    i64 sum=0ll;

    for(int i=0;i<n;i++){

        m-=a[i].second.first;

        del[i]=a[i].second.second-a[i].second.first;

        delpro[i]=1ll*a[i].first*del[i];

        l[i]=1ll*a[i].first*a[i].second.first;

        sum+=l[i];

    }

    for(int i=1;i<n;i++){

        del[i]+=del[i-1];

        delpro[i]+=delpro[i-1];

    }

    i64 ans=sum+m*a[0].first;

    for(int i=1;i<n;i++){

        int id=upper_bound(del.begin(),del.end(),a[i].second.first+m)-del.begin()-1;

        id=min(id,i-1);

        ans=max(sum-l[i]+(a[i].second.first+m-(id>=0?del[id]:0))*a[id+1].first+(id>=0?delpro[id]:0),ans);

    }

    cout<<ans<<'\n';

}

 

不止在二分查找中,一般的涉及到判断是否为中位数的也可以用类似的方法进行处理

Cf 2103C

给定一个数组a,数组中的中位数定义为排序后的数组的第m/2上取整的元素

现在给定一个元素k,要求判断是否存在一种方案:将数组划分为三个子数组,三个子数组的中位数的中位数小于等于k

容易得到这等价于这些子数组中至少有两个的中位数<=k

不妨将<=k的元素设为-1,大于k的元素设为1

那么当且仅当该区间中的元素和为非正数时,其中位数小于等于0

进一步可以推得,如果满足条件,那么那两个数组的和也一定为非正

因为每次每隔一个元素最多+1或者-1,因此如果和小于0,那么一定说明可以分成两个非正的数组

void solve() {

    int n, k;

    cin >> n >> k;

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

    vector<int> pre(n + 1);

    for (int i = 0; i < n; i++) {

        pre[i + 1] = pre[i] + (a[i] <= k ? -1 : 1);

    }

   

    // 分离出第一个元素

    if (*min_element(pre.begin() + 2, pre.begin() + n) < 0) {

        cout << "YES\n";

        return;

    }

    if (count(pre.begin() + 2, pre.begin() + n, 0) > 1) {

        cout << "YES\n";

        return;

    }

    // 分离出最后一个元素

    if (*max_element(pre.begin() + 1, pre.begin() + n - 1) > pre[n]) {

        cout << "YES\n";

        return;

    }

    if (count(pre.begin() + 1, pre.begin() + n - 1, pre[n]) > 1) {

        cout << "YES\n";

        return;

    }

   

    // 贪心地进行两侧数组的分割

    int l = 1, r = n - 1;

    while (l < r && pre[l] > 0) {

        l++;

    }

    while (l < r && pre[r] < pre[n]) {

        r--;

    }

    if (l < r) {

        cout << "YES\n";

        return;

    }

   

    cout << "NO\n";

}

 

 

二分搜索(meet in the middle)

想要对于一般数组进行二分查找,显然要让数组有序,于是利用前缀或者后缀最值数组

分两段处理的思路有点像分块降低复杂度

2024上海市赛K

/*

总的ai的和小于等于m

bi的系数是0~k-1,最大化这个答案

但我们选择的集合确定时,显然从大到小选择bi将是最优的

因此我们将b排序后分成两部分去计算

*/

void solve(){

    int n,m;

    cin>>n>>m;

    vector<int> a(n),b(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    for(int i=0;i<n;i++){

        cin>>b[i];

    }

    vector<int> p(n);

    iota(all(p),0);

    sort(all(p),[&](int i,int j){//先对b排序

        return b[i]<b[j];

    });

    //第一段的元素个数乘第二段的贡献总和

    //因为数据元素总共只有30个,因此第一段的个数实际上很少

    const int half=n/2;

    vector<vector<pair<int,i64>>> f(half+1);

    //因此可以直接去枚举

    auto dfs=[&](auto &&self,int i,i64 s,int c,int cost){

        if(cost>m){

            return;

        }

        if(i==half){

            f[c].emplace_back(cost,s);//因为后续要用该部分的个数去计算最终答案,因此以个数为关键字

            return;

        }

        self(self,i+1,s,c,cost);//不选

        self(self,i+1,s+1ll*c*b[p[i]],c+1,cost+a[p[i]]);

    };

    dfs(dfs,0,0,0,0);

    //预处理

    for(int c=0;c<=half;c++){

        sort(f[c].begin(),f[c].end());

        int n=0;

        for(auto[x,y]:f[c]){//因为要满足对最优贡献的查询,因此维护前缀最大值数组

            if(n==0 || y>f[c][n-1].second){//能产生的贡献更大

                f[c][n++]={x,y};

            }

        }

        f[c].resize(n);//其余元素无用,并且保持数组有序

    }

    i64 ans=0;

    auto dfs1=[&](auto &&self,int i,i64 s,int c,i64 sum,int cost){

        if(cost>m){

            return;

        }

        if(i==n){//第二部分也处理完了

            //枚举cnt

            for(int c0=0;c0<=half;c0++){

                //二分搜索之前的花费

                auto it=lower_bound(f[c0].begin(),f[c0].end(),make_pair(m-cost+1,0ll));

                if(it==f[c0].begin()){

                    continue;

                }

                it--;//因为当花费不超出m,并且元素个数相同时,我们显然是选择第一段贡献也最大的

                ans=max(ans,s+c0*sum+it->second);

            }

            return;

        }

        self(self,i+1,s,c,sum,cost);//不选

        self(self,i+1,s+1ll*c*b[p[i]],c+1,sum+b[p[i]],cost+a[p[i]]);

    };

    dfs1(dfs1,half,0,0,0,0);

    cout<<ans<<'\n';

}

 

Cf 2029C

跳过一个连续段,使得我们的得分尽可能大

显然可以用二分答案来做,关键是check函数的设计

令f[i]为直接按照题目描述进行操作得到的分数

同时为了进行判断,我们令g[n]=m,此时,对于g,我们逆序进行操作(正序相当于是模拟,逆序就是为了得到充分条件从而进行判断)

当a[i]>=g[i+1]时,g[i]=g[i+1]-1(看作是正向进行的,经过该位置的操作后g[i]变成了g[i+1],因此如果a[i]>=g[i+1],那么就说明g[i]+1得到g[i+1])

g[i]计算了如果最终比分要是m,这个位置至少应该是多少

因为我们可以跳过一个连续段,因此判断是否存在f[l-1]使得f[l-1]>=g[r+1],这样跳过[l,r]这段后,以f[l-1]再进行操作,一定能够达到m,同样,如果不存在这样的数,那么就说明最终一定无法得到m

我们可以一边逆序操作(相当于是在枚举r),一边进行判断,因为f的值是固定的,同时我们并不需要使得跳过的连续段长度最小,因此可以用前缀max来检查

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> pre(n);

    int cur=1;

    pre[0]=1;

    for(int i=1;i<n;i++){

        if(a[i]<cur){

            cur--;

        }else if(a[i]>cur){

            cur++;

        }

        pre[i]=max(pre[i-1],cur);

    }

    int l=0,r=n+1;

    auto check=[&](int m)->bool{

        int curf=m;

        for(int i=n-1;i>=0;i--){

            if(i && pre[i-1]>=curf){

                return true;

            }

            if(a[i]<curf){

                curf++;

            }else{

                curf--;

            }

        }

        return false;

    };

    while(l<r){

        int m=(l+r+1)>>1;

        if(check(m)){

            l=m;

        }else{

            r=m-1;

        }

    }

    cout<<l<<'\n';

}

如果使用动态规划

因为每次竞赛只可能有三种状态:在跳过的时间间隔之前、之中或之后

因此可以设计一个三维dp

设dp i,0/1/2表示i次比赛后的最高评分,0/1/2分别表示i次是在跳过的间隔之前/之中/之后

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    array<int,3> dp{0,-inf,-inf};

    for(int i=0;i<n;i++){

        dp[2]=max(dp[2],dp[1]);

        dp[1]=max(dp[1],dp[0]);

        for(int j=0;j<3;j++){

            if(j==1){

                continue;

            }

            int &x=dp[j];

            if(a[i]>x){

                x++;

            }else if(a[i]<x){

                x--;

            }

        }

    }

    cout<<max(dp[1],dp[2])<<'\n';

}

 

 

杭电24多校4 1007

每次查询给定k,更新序列a,a[i]=max(a[i],b[(i+k)%k]),并且要求输出每次数组a的和

每次模拟显然会t,因此要减小比较次数,将b按照权值大小进行排序,要能修改,显然b至少要大于a的最小值

struct node{

    int id,val;

};

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> a(n);

    vector<node> b(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    for(int i=0;i<n;i++){

        cin>>b[i].val;

        b[i].id=i;

    }

    multiset<int> mt(all(a));

    i64 sum=accumulate(all(a),0ll);

    sort(all(b),[&](node x,node y){

        return (x.val==y.val?x.id<y.id:x.val>y.val);

    });

    for(int i=1;i<=q;i++){

        int k;

        cin>>k;

        //每个i修改为max(a[i],b[(i+k)%n])

        int mn=*mt.begin();

        int l=0,r=n-1;

        while(l<=r){

            int mid=(l+r)>>1;

            if(b[mid].val<mn){

                r=mid-1;

            }else{

                l=mid+1;

            }

        }

        //减少需要判断更新的次数

        //b至少大于a的最小值

        for(int i=0;i<l;i++){

            int pos=(b[i].id-k+n+n)%n;

            if(a[pos]<b[i].val){

                sum+=b[i].val-a[pos];

                mt.erase(mt.find(a[pos]));

                mt.insert(b[i].val);

                a[pos]=b[i].val;

            }

        }

        cout<<sum<<'\n';

    }

}

 

 

找右侧最后一个小于的数可以利用后缀数组

利用类似的单调栈的想法,使得后缀数组是单调不减的,于是最后一个小于的数将作为一个分界线,在这个数之后对应的后缀数组的值就都是大于等于目标元素了,因此可以用二分查找到这个位置

cf91B

int a[200005];

int b[200005];

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    b[n]=a[n];

    for(int i=n-1;i;i--){

        b[i]=min(b[i+1],a[i]);

    }

    for(int i=1;i<=n-1;i++){

        if(b[i+1]>=a[i]){

            cout<<-1<<' ';

            continue;

        }

        int l=i+1,r=n;

        int k=lower_bound(b+i+1,b+n+1,a[i])-b;

        cout<<k-i-1-1<<' ';

    }

    cout<<-1<<'\n';

}

 

cf1941F

要将序列中的差值的最大值最小化,显然是利用二分答案的思想,又因为我们最多只能对其中一段进行操作,因此可以初始排序好a,每次check时,枚举相邻两个元素的差,即当前在进行check的答案是k,如果当前两个元素的差大于k,说明要进行操作,因此打上标记,如果遍历结束,最后打上标记的个数大于1,说明这个答案显然是不可行的

判断某个段内是否可以通过插入一个元素的方式让区间变小,本质上就是求是否可以在两个序列中各取出一个数使其和在某范围内

这个问题通常也是通过二分解决,我们枚举其中一个序列中的元素,求出如果和为f那么需要的最大值和最小值是多少,再按照这个范围在f中二分即可

注意solve函数中对于“在两个序列中各取出一个数使其和在某范围内”的解决

void solve() {

    int n,m,k;

    cin>>n>>m>>k;

    vector<ll> a(n),d(m),f(k);

    for(auto &x:a) cin>>x;

    for(auto &x:d) cin>>x;

    for(auto &x:f) cin>>x;

    sort(a.begin(),a.end());

    sort(d.begin(),d.end());

    sort(f.begin(),f.end());

    ll l=0,r=2e9+1;

    function<bool(ll)> check=[&](ll mid)->bool{

        //答案mid具有单调性:mid越小越难满足

        bool flag=false;

        for(int i=1;i<n;i++){

            if(a[i]-a[i-1]>mid){

                if(flag) return false;

                flag=true;

                for(ll j:d){

                    auto it=a[i]-mid>=j?lower_bound(f.begin(),f.end(),a[i]-mid-j):f.begin();

                    auto jt=a[i-1]+mid>=j?upper_bound(f.begin(),f.end(),a[i-1]+mid-j):f.begin();

                    //枚举j,得到如果至少要让mid成立,两个序列中应当取出的元素和,据此在f中查找对应的元素是否存在

                    //因为我们固定了j,因此实际上是在f中找范围为(a[i]-mid,a[i+1]+mid)的元素

                    //我们定义对应的边界为it,jt,显然得有it<jt,我们所需的范围才成立

                    //具体来说有:j+jt-a[i-1]>mid   a[i]-(j+it)<=mid

                    //因此如果it>=jt,那么我们最终得到的差值(两个值取大)一定是大于mid的,说明mid是不合法的

                    //但如果it<jt,因为jt是第一个使得j+jt-a[i-1]>mid满足的,因此当我们选择it时,能够取到小于等于mid的差值

                    if(it<jt) goto ok;

                    //goto语句比较好用的方法是让运算从一个深层嵌套程序中退出,避免使用多个附加测试去break

                    //goto语句将直接跳转到指定的标签处,而不会执行任何中间代码

                }

                return false;

                ok:;//跳转到这里

            }

        }

        return true;

    };

    while(l<r){//左闭右开

        ll mid=l+r>>1;

        if(check(mid)) r=mid;

        else l=mid+1;

    }

    cout<<l<<'\n';

}

 

Cf 2110D

有n个检查点,每个检查点都包含b[i]个电池,有m条有向边,第i条边允许从第s[i]个点移动到第t[i]个点,并且这条边只有当前持有的电池数大于w[i]时才可以使用

现在问从第一个点出发,到最后一个点时至少有多少个电池

如果简单考虑最短路,那是不可行的,因为有获取电池的限制,找到的最短路不一定满足通过的条件

同样,如果仅考虑二分答案,那也不太可行,较小的电池数可以在通路中判断是否可行,但是较大的电池数可能同样因为无法获取而变得不可行,因此似乎不具有二段性

但如果我们在check中增加一个min,也就是当取值较大时我们保证只取得能取得的最大值,那么就可行了,也就是我们进行check的x并不是最后拥有的电池数,而是人为限制的最终拥有的电池数的最大值,这样就具有二段性了,于是我们找到的是满足条件的最小的上限,也等价于最后拥有的电池数

类似地,在check时就不需要考虑是否是最短路,只要可以通过这条边那么我们就通过,并相应更改对应持有的电池数

这样我们只需要进行dp即可,若终点的dp值不为-1,那么就说明可以到达,对应进行check的x是可行的,可以相应更改hi值

void solve() {

    int n, m;

    cin >> n >> m;

    vector<int> b(n);

    for (int i = 0; i < n; i++) {

        cin >> b[i];

    }

    vector<vector<array<int, 2>>> adj(n);

    for (int i = 0; i < m; i++) {

        int s, t, w;

        cin >> s >> t >> w;

        s--, t--;

        adj[s].push_back({t, w});

    }

    auto check = [&](int x) {

        vector<int> dp(n, -1);

        dp[0] = min(x, b[0]);

        for(int i = 0; i < n; i++) {

            for(auto [j, w] : adj[i]) {

                if(dp[i] >= w) {

                    dp[j] = max(dp[j], min(x, dp[i] + b[j]));

                }

            }

        }

        return dp[n - 1] >= 0;

    };

    int l = 0, h = 1e9 + 1;

    while(l < h) {

        int m = (l + h) >> 1;

        if (check(m)) {

            h = m;

        } else {

            l = m + 1;

        }

    }

    if (l == 1e9 + 1) {

        cout << -1 << '\n';

        return;

    } else {

        cout << l << '\n';

    }

}

 

 

cf1946C

二分答案+贪心

显然,随着x的增大,我们能删去的边将越来越小,因此x满足单调性,因此可以对x进行二分,每次判断当前mid是否能够满足可以删去至少k条边

(实际上对于k我们同样有单调性,因为每次k增大,最后最小的连通区块的大小一定是减少的,但这样我们每次都要对删边进行决策,并且x是答案而k是定值,因此是二分答案二分x,判断能不能满足条件)

关于check函数,我们可以贪心地去解决,我们用dfs函数去查找当前子树对应的节点数,因为dfs是先不断从向下搜索然后再逐个返回,因此当检查到当前节点的子树的节点数大于x时,我们就可以将其与其父节点断开。这样从下向上断开,保证了底下的区块节点数都是大于X的,最后就回到根节点,因此再判断此时的根节点子树的大小是否大于等于x,如果不满足,再看看能否少断一条边使得节点补足

void solve() {

    int n,k;

    cin>>n>>k;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int v,u;

        cin>>v>>u;

        u--,v--;

        adj[v].emplace_back(u);

        adj[u].emplace_back(v);

    }

    function<bool(int)> check=[&](int x)->bool{

        int res=0;

        function<int(int,int)> dfs=[&](int v,int f)->int{

            int sz=1;

            for(int u:adj[v]){

                if(u==f) continue;

                sz+=dfs(u,v);

            }

            if(sz>=x && f!=v){//同时也要保证最后不是根节点

                res++,sz=0;//断边,这个节点对于之上节点的大小贡献就为0

            }

            return sz;

        };

        int t=dfs(0,0);

        return (res>k || (t>=x && res==k)?true:false);

    };

    int l=1,r=n/(k+1)+1;//断k条边,理论上每个区块能有多少个节点

    while(l<=r){

        int mid=(l+r)>>1;

        if(check(mid)) l=mid+1;

        else r=mid-1;

    }

    cout<<r<<'\n';

}

 

 

二分答案

cf1996F

比较常见且有用的想法是对于一些模拟题,边界有限且逐渐缩小,可以直接二分最后被选择的元素,同时以此计算可行的操作次数进行check,同时也可以直接计算贡献

大致题意:

每次选取当前数组中的一个元素,选择完毕后将其加入到sum中,并且将其更新为max(a[i]-b[i],0)

询问在k次操作后能够得到的最大sum是多少

直观的模拟思路是用单调队列模拟,但是数据量较大的情况下会T,因此要考虑其他的方式

正如上面说的,我们模拟最后一次被选择的数字x,check时判断可行的操作次数是否大于了k,并以此进行二分

void solve(){

    int n,k;

    cin>>n>>k;

    vector<int> a(n),b(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    for(int i=0;i<n;i++){

        cin>>b[i];

    }

    //计算总贡献,判断最后被选择的值

    //因此大于这个值的数在操作过程中一定会被选择

    //可以通过原来的值计算出对应的贡献

    auto get=[&](int v){

        i64 sum=0;

        i64 cnt=0;

        for(int i=0;i<n;i++){

            if(a[i]>=v){

                i64 t=(a[i]-v)/b[i]+1;

                cnt+=t;

                sum+=a[i]*t-t*(t-1)/2*b[i];

            }

        }

        return make_pair(cnt,sum);

    };

    int l=0,r=1e9+1;//左闭右开

    while(l<r){

        int x=(l+r)>>1;

        if(get(x).first<=k){

            r=x;

        }else{

            l=x+1;

        }

    }

    auto [cnt,sum]=get(l);//结束时的l保证cnt<k

    if(l>0){

        sum+=(k-cnt)*(l-1);

        //假设剩余操作不是全增加l-1

        //那么相当于在cnt<k的条件下,最后一次被选择的数应当是l-1

    }

    cout<<sum<<'\n';

}

 

cf1977D

最大化最小化问题一方面使用dp,另一方面就是二分答案

如果dp不好想,但是换个方向,去检验,也就是带入值之后模拟判断是否可行比较容易那么就可以用二分做

这里二分可能增加的分数,如果一个点没有达到mid,那么它所有的子节点就要为其额外付出mid-a[x]的数量,直接放在dfs的参数中

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    int l=INT_MAX,r=-1;

    for(int i=0;i<n;i++){

        cin>>a[i];

        if(i){

            l=min(l,a[i]);

            r=max(r,a[i]);

        }

    }

    vector<vector<int>> adj(n,vector<int>());

    for(int i=0;i<n-1;i++){

        int x;

        cin>>x;

        x--;

        adj[x].push_back(i+1);

    }

    int ans=0;

    int mx=r;

    auto check=[&](auto self,int x,i64 m)->bool{

        bool res=true;

        if(x){

            if(m>mx){

                return false;

            }

            if(adj[x].empty()){

                if(a[x]<m){

                    return false;

                }

                return true;

            }

            if(a[x]<m){

                m+=m-a[x];

            }

        }

        for(int y:adj[x]){

            res&=self(self,y,m);

        }

        return res;

    };

    while(l<=r){

        i64 m=(l+r)>>1;

        if(check(check,0,m)){

            ans=m,l=m+1;

        }else{

            r=m-1;

        }

    }

    cout<<ans+a[0]<<'\n';

}

 

杭电24多校4 1003

要求最大化数组中我们选择字段和的最小值,同时每一段的长度是质数

对于check函数,其中一种想法是在判断mid时,我们的目标是最大化,因此如果当前和小于mid,那么负数可以不选(之后直接选正数可以保证大于mid),同时与遍历左端点再判断选择长度相比,我们选择枚举子段右端点更优,也就是在当前段的和已经大于Mid时,再回过去找左端点

下面的写法有一点问题

void solve() {

    int n,k;

    cin>>n>>k;

    vector<int> a(n);

    vector<int> pre(n+1);

    for(int i=0;i<n;i++){

        cin>>a[i];

        pre[i+1]=pre[i]+a[i];//[l,r]=pre[r+1]-pre[l]

    }

    //最大化最小值

    //要求每一段的长度是质数

    //最小要用2

    if(2*k>n){

        cout<<"impossible\n";

        return;

    }

    i64 l=-2000,r=2e8;

    auto check=[&](i64 mid)->bool{

        //选择的段和均>=mid

        i64 cur=0ll;

        int tot=0;

        int st=0;

        //目标是最大化,如果当前和小于mid,那么负数可以不选(之后直接选正数可以保证大于mid)

        //枚举子段右端点

        for(int i=0;i<n;i++){

            cur+=a[i];

            if(cur>=mid){

                int len=i-st+1;

                for(int j=1;j<=cnt;j++){

                    if(p[j]>len){

                        break;

                    }

                    if(pre[i+1]-pre[i-p[j]+1]>=mid){

                        tot++;

                        cur=0ll;

                        st=i+1;

                        break;

                    }

                }

            }else if(cur<0){

                cur=0ll;

                st=i+1;

            }

        }

        return tot>=k;

    };

    while(l<=r){

        i64 mid=(l+r)>>1;

        if(check(mid)){

            l=mid+1;

        }else{

            r=mid-1;

        }

    }

    cout<<r<<'\n';

}

设si=a1+a2+...+ai,从左往右贪心进行划分

维护一个集合T,从左往右一次向T中家兔每个下标,当遍历到下标i,但i还没有加入,若发现了有一个区间左边界j,使得si-sj>=mid并且i-j是质数,说明满足条件

可以发现只需要考虑i-j是质数同时sj最小的j

因此可以暴力枚举j,直至i-j为质数

 

中位数想到使用二分答案

 

二分常用的操作还有将大于mid值的设定为1,小于mid值的设定为0,而且往往我们只会去关注那些为1的数,这样操作后通过求和函数可以用于检验大于该值的个数(例如图上的路径以及数组中的第K大)

HDU6231

判断有多少个区间可行时,如果对左右端点都进行枚举显然会达到O(n2)的时间复杂度,因此一般去注意到区间长度与我们所关注值之间的关系,例如那是一个线性关系,区间长度增加,能保证其大于mid的值的个数一定是只增不减的,那么就可以利用滑窗的思想,枚举右端点,在此基础上判断后移动左端点,每次移动完成后,都可以将答案+=l-1

int check(int x) {
          ll sum = 0;
          for(int i = 1; i<=n; i++) qq[i] = a[i] >= x;
          int l = 1,r = 1,tmp = 0;
          while(r<=n) {
                   tmp += qq[r];
                   while(tmp >= k) {
                             tmp -= qq[l];
                             l++;
                   }
                   sum += l-1;
                   r++;
          }
          return sum>=m;}

并且注意如果二分答案时选择数组中的元素最大值会超时的话,可以考虑用类似离散化的思路去二分,即

sort(b+1,b+n+1);
int l=1,r=n

 

cf 1998C

给定一个长度为n的数组a以及一个同样长度为n的数组b,我们最多可以进行k次操作,每次操作选择一个下标i,如果b[i]=1,那么给a[i]+1

问最后能得到的最大得分是多少,得分计算方式是a[i]+数组a去除a[i]后的中位数

首先显然当a中最大值对应的b取值为1时,那么答案一定是取最大值的下标,因为这样可以保证增加的值一定没有浪费,如果选择增加到中位数上,那么中位数可能会发生变化,因此会有浪费

如果a中最大值不能进行增加,那么就有两种选择,一种是类似地增加到可以增加的元素的最大值上,这是可以简单计算得到的

另一种就是我们想办法让中位数增大

因为我们并不确定可以增加到多少,因此可以使用二分答案来进行判断,较为困难的就是check函数的编写,因为我们可以对元素进行增值,因此不能只基于原来的数组元素来赋值0/1,由于最后要进行判断的显然是能不能在k步内使得有(n+1)/2个元素大于当前二分的答案,因此对于大于的直接存入即可,否则,如果是可以加的(b[i]=1),则加入需要增加的次数,最后对较小的(n+1)/2个求和即可

 

这里使用了nth_element函数,用于解决部分排序问题,它可以替代完整排序并提高效率。

这个函数会:

部分排序向量v,将第need个元素(从0开始计数)放到正确的位置

保证:

位置need之前的所有元素都 不大于 位置need的元素

位置need之后的所有元素都 不小于 位置need的元素

不保证:位置need前后的元素是有序的

void solve() {  

    int n,k;

    cin>>n>>k;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<int> b(n);

    for(int i=0;i<n;i++){

        cin>>b[i];

    }

    int mx=-1;

    for(int i=0;i<n;i++){

        if(b[i]==1 && (mx==-1 || a[i]>a[mx])){

            mx=i;

        }

    }

    i64 ans=0;

    if(mx!=-1){

        auto aa=a;

        i64 res=aa[mx]+k;

        aa[mx]=0;

        sort(aa.begin(),aa.end());

        res+=aa[n/2];

        ans=max(ans,res);

    }

    mx=max_element(a.begin(),a.end())-a.begin();

    i64 lo=0,hi=2E9;

    vector<i64> v;

    v.reserve(n);

    const int need=(n+1)/2;

    while(lo<hi){

        i64 m=(lo+hi+1)>>1;

        v.clear();

        for(int i=0;i<n;i++){

            if(i==mx){

                continue;

            }

            if(a[i]>=m){

                v.emplace_back(0);

                continue;

            }

            if(b[i]==0){

                continue;

            }

            v.emplace_back(m-a[i]);

        }

        i64 sum=k+1;

        if(v.size()>=need){

            nth_element(v.begin(),v.begin()+need,v.end());

            sum=accumulate(v.begin(),v.begin()+need,0ll);

        }

        if(sum<=k){

            lo=m;

        }else{

            hi=m-1;

        }

    }

    ans=max(ans,a[mx]+lo);

    cout<<ans<<'\n';

}

 

 

acwing 4656

容易想到如果要直接模拟应当使用优先队列,即

struct node{

    int val,det;

}k;

struct cmp{

    bool operator()(node a,node b){

        return a.val<b.val;

    }

};

void solve() {

    int n,m;

    cin>>n>>m;

    priority_queue<node,vector<node>,cmp> q;

    for(int i=1;i<=n;i++) cin>>k.val>>k.det,q.emplace(k);

    ll ans=0;

    while(m--){

        node t=q.top();q.pop();

        ans+=t.val;

        t.val=max(0,t.val-t.det);

        q.emplace(t);

    }

    cout<<ans<<'\n';

}

但很可惜,这样会超时

能够想到,如果一个答案越大,那么我们在限定次数内达到这个值越困难(显然我们可以得到的最大值是确定的)

但我们如果仅仅只是二分最终得到的答案,这样显然对于解题没有任何帮助,因为check函数还是判断这个数能否达到,但这仍然是在模拟我们的操作过程(对于模拟操作的结果一般不直接去二分,因为这个check仍然需要一个一个检查,并没有达到快速判断的效果)

这种情况下一般可以考虑切换check中的判断,例如上面如果写出具体函数,一种写法是对于可以得到的res与k进行比较,所谓切换思考就是如果我们对于操作进行比较呢,即对于一个答案来说,答案越大,我们需要的操作次数越多,因此我们将res设定为当前进行(可以进行)的操作次数,去比较res与m

分析导致我们超时的原因是每次都将操作完整的表示了出来,因此想要优化我们所消耗的时间应当想办法避免每一次操作都要进行

想到对于一些求和最基础的优化就是进行等差数列求和,并且对于操作进行一些抽象化的考虑,我们实际上是每次都在选择最大值,但如果对于多次操作进行考虑,我们应该可以找到x,使得前i次的操作所增加的数值都大于x,而确定这个x之后,我们计算增加的值就可以使用等差数列求和了

bool check(int x){

    ll res=0;

    for(int i=1;i<=n;i++){

        if(a[i]>x){

            res+=ceil((double)(a[i]-x)/b[i]);

        }

    }

    return res<=m;

}

void solve() {

    cin>>n>>m;

    for(int i=1;i<=n;i++) cin>>a[i]>>b[i];

    int l=0,r=1e6;

    while(l<r){//因为对于结果值等于x的,我们不一定每个都会加入,因此这里的判断标准设定为严格大于

        int mid=l+r>>1;

        if(check(mid)) r=mid;

        else l=mid+1;

    }

    ll ans=0;

    int cnt=0;

    for(int i=1;i<=n;i++){

        if(a[i]>l){

            int now=ceil((double)(a[i]-l)/b[i]);

            cnt+=now;

            ans+=sum(a[i],now,b[i]);

        }

    }

    cout<<ans+(ll)l*(m-cnt);

}

 

acwing 5407

假如说在t时刻满足题目要求,那么比t大的时刻也一定满足要求,故具有单调性,此时可以想到用二分来做

对于每个二分出来的时刻,可以将每一个水阀所能检测的范围算出来(若该时刻小于水阀打开的时刻,则该水阀跳过)。此时问题就变成了对于这些所检测出来的范围是否能覆盖整个管道,显然是一个区间合并知识点。将所有区间合并之后,再看看该区间是否能覆盖1~len的位置即可

此题的区间合并并不是两个区间有交集才能合并,例如[1, 2]和[3, 4]也可以进行合并成[1, 4]

bool check(LL x){

    vector<PII> segs;

    for(int i = 0; i < n; i ++){

        int l = a[i].first, s = a[i].second;

        if(s > x) continue; // 若当前时刻小于水阀打开的时刻,则该水阀直接跳过

 

        // 计算每个水阀所能检测的的范围

        int left = max(1ll, l - x + s), right = min((LL)len, l + x - s);

        segs.push_back({left, right});

    }

    int cnt = segs.size();

    sort(segs.begin(), segs.end()); // 按左端点从小到大排序

    if(segs.empty()) return false;

    if(segs[0].first > 1) return false;

    int dr = segs[0].second;

    for(int i = 1; i < cnt; i ++){//区间合并,并判断能不能覆盖所有区间

        if(segs[i].first > dr + 1) return false;//因为不需要有交集,因此条件是dr+1

        dr = max(dr, segs[i].second);//时刻记录当前最右区间端点

    }

    return dr == len;

}

 

二分搜索的特征不一定在于数值上的单调性,主要是在于是否符合某一条件的单调性:例如若[l,r]满足,则[l,r+1]一定满足

cf1923d

对于该题我们可以进行转化,要能合并,那么就是在前后缀上能够找到一个子数组使得其总和大于当前值,进一步的,那个子数组的数值能够这样计算要满足:
子数组的长度为1  或者  子数组中至少有两个不同的值(否则在那个子数组中各个元素不能进行合并)

以前缀为例,显然我们对应的子数组的右端点为i-1,因此就是要找到对应的左端点j,并且j要尽可能大,我们可以发现如果[j,i-1]是满足要求的子数组,那么[j-1.i-1]一定也是符合要求的子数组,在这种特征下我们就想到可以对j进行二分搜索了

因此我们可以预处理两个数组:前缀数组,另一个是数组p,pi是与ai不同的最近位置,用于判断对应子数组中是否有两个不同的元素

void solve() {

    int n;

    cin >> n;

    vector<int> a(n);

    for (auto& x : a) cin >> x;

    vector<int> ans(n, n);

    for (int z = 0; z < 2; ++z) {

        vector<long long> s(n + 1);

        for (int i = 0; i < n; ++i) s[i + 1] = s[i] + a[i];

        vector<int> p(n, -1);

        for (int i = 1; i < n; ++i) {

            //利用z来判断当前是顺序遍历还是逆序遍历

            //因为j要对应到答案数组,因此逆序遍历时要确定在原数组中的位置

            int j = (z ? n - i - 1 : i);

            int l = 1, r = i;

            //这里进行二分的是长度,因为区间的右端点已经确定,二分区间和二分左端点是等价的

            while (l <= r) {

                int m = (l + r) / 2;

                if (s[i] - s[i - m] > a[i] && p[i - 1] >= i - m) {

                    ans[j] = min(ans[j], m);

                    r = m - 1;

                } else {

                    l = m + 1;

                }

            }

            if (a[i - 1] > a[i]) ans[j] = 1;

            p[i] = (a[i] == a[i - 1] ? p[i - 1] : i - 1);

        }

        reverse(a.begin(), a.end());

    }

    for (int i = 0; i < n; ++i)

        cout << (ans[i] == n ? -1 : ans[i]) << ' ';

    cout << '\n';

}

 

 

check函数的设计

需要造出若干台机器去对抗怪物,每台机器有战斗和创造两种功能,并且每台机器两种功能都只能使用一次,并且如果使用了战斗功能,那么机器会直接报废

使用战斗功能能够使怪兽血量减1,使用创造功能需要m台机器,在这之后会产生k台新的机器

问至少需要多少台机器才能打败怪兽

可以先简化成汽水瓶问题,如果两瓶能够换成一瓶,那么每次相当于-2+1,如果看作整体,也就是每次让初始瓶子的数目-1,假设能借空瓶(最多也只能借一个),若当前有10个瓶子,那么答案等于10+10/1=20(直接除是因为之前都是满足可以一直减1的,但最后只剩1的时候如果不能借的话是不能再交换的,但因为如果将能够得到的瓶子事先支付,因为本身减少的瓶子就是m-k,因此可以满足交换的界限,于是可以通过除来操作)。现在这个问题等于不能借空瓶的情况,也就是减差的过程中,不能让空瓶的数目小于2,不然实际上最后一次是换不出水的。所以一开始就给他总数先没收两瓶即(10-2),然后这个时候因为预留了2瓶,转化成可以借空瓶的情况(相当于预先换好一个瓶子,作为如果要借时候的瓶子),也就是8/1=8,但我们最初还预先换好了一个瓶子,因此可以得到19瓶

可以将上述模型扩展成任何m<k的情况

也就是x个机器使用创造功能的次数是cnt=(x-m)/d+1,因此最大伤害是x+cnt*k

特别的,当h<=m或者m=k时,可以直接得到答案min(h,m),因为我们没有必要再去拿更多瓶子

void solve(){

    i64 m,k,h;

    cin>>m>>k>>h;

    auto check=[&](i64 x)->bool{

        if(x<m){

            return x>=h;;

        }

        i64 cnt=(x-m)/(m-k)+1;

        return x+cnt*k>=h;

    };

    if(m==k || h<=m){

        cout<<min(h,m)<<'\n';

        return;

    }

    i64 l=0,r=h,ans=h+1;

    while(l<=r){

        i64 mid=(l+r)>>1;

        if(check(mid)){

            ans=mid;

            r=mid-1;

        }else{

            l=mid+1;

        }

    }

    cout<<ans<<'\n';

}

 

利用二分进行优化

牛客24多校10 K

给定数轴上n个整点,初始在原点

每次选择一个点,向它的方向移动一格,如果选择的点和当前位置重合,那么加一分,同时该点消失,要求最大化分数

如果到达过的区间是[l,r],那么存在一种最优方案使得每次移动所用的点都在(l,r)外

假设先往左再往右,然后枚举左右最远到达的位置,用前缀和检查是否可行,如果可行,那么计算相应的答案,如果只往一个方向走,处理是类似的

直接暴力计算的时间复杂度是n^2,但随着l减小,r也是非严格递减的,因此可以用二分进行优化

有可能把原来贡献给答案的用于移动了

如果往左走i步,一定是最小的i个用来走,但如果最小的i个走不到i步,答案不会更劣

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    int ans=0;

    auto work=[&](){

        //先向左走

        //每个点都用于移动,那么就是最多走n步

        int m=lower_bound(a.begin(),a.end(),0)-a.begin();

        for(int i=0;i<=m;i++){

            //向左走i步

            int res=a.begin()+m-lower_bound(a.begin()+i,a.begin()+m,-i);

            //计算有多少个点是可以计入贡献的

            int lo=m,hi=n;

            //计算右边界,也就是>0的部分,从m开始

            //因为用于移动的步数越多,右边界就会越大,因此可以二分

            while(lo<hi){

                int x=(lo+hi+1)>>1;

                //现在能走n-x步

                if(a[x-1]>-i+(n-x)){

                    //还是够不到,那么再走一步

                    hi=x-1;

                }else{

                    lo=x;

                }

            }

            res+=lo-m;

            ans=max(ans,res);

        }

    };

    work();

    reverse(a.begin(),a.end());

    //先向右走

    for(auto &x:a){

        x=-x;

    }

    work();

    cout<<ans<<'\n';

}

 

 

发现要求的答案只有一个,且有一个可以对应的条件,就可以基本断定是一道二分答案的题(P1577)

对答案进行二分一定要证明好二段性,例如P1154当 k = 奶牛中编号最大的那个时 保证所有数余它都是唯一的,但在n到kmax之间小的不满足,不代表大的就满足,所以不能二分答案

根据要找的值,来确定二分中的判断条件(或者说要找的这个数在数列中有什么性质,因为这个值未知,我们需要一个特征来缩小我们查找的范围,找一个能确定出必定有解的操作进行进一步的操作),T153,T162,T1901(162加强)(例如寻找峰值最开始能想到的就是找到一条递增路径,但我们应该进一步去想递增能不能给我们一个能够快捷到达路径末尾的方法,递增就是前面的数比后面的数小,因此如果一个数大于它左边的数,说明这条路径还没有结束,答案在右侧,同理若大于其右边的数,那么答案一定在左侧,这也是一种二段性,对于一个分割点,通过判断相邻元素的大小关系,可以得到一段区间里我们能确定有解,一段区间里我们不能确定,因此可用二分来做,那么同样,二维问题就能先转化成一维,判断一行里面的最大元素是否大于其相邻位置)

有单调性就可以二分,如果对于答案相关联的数,其越大越难满足题目中的条件,越小越容易满足题目中的条件,这也属于一种单调性,这样就用于求最小值的最大值

例:T2439,T2513,T2517,T2258

在一个有序数组上,查找小于等于某个数的个数(常见的二分模型,T2426)

判断是哪种开闭类型可以从初始的l,r赋值中看出,同时while中如果是l<=r,说明l==r有意义,因此也是闭区间(T34的查找)

写二分法,区间的定义一般为两种,左闭右闭即[left, right],或者左闭右开即[left, right),重点就在于选择区间的类型(寻找区间的范围),这决定了每次指针移动的赋值以及循环判断

左闭右闭时因为right要能取到,因此初次赋值时,right=数组长度-1,移动指针时,right=mid-1

而左闭右开时,right不用取到,因此可以相应的取到所要搜索的数组右边界+1

当然,左右均开同理,而体现在while循环中,就是l和r满足哪个条件下这个数组是非空的,如开区间则应为l+1<r,左闭右开l<r

开区间左边是false右边是true返回最小值的最大值就可以让二分return left(T2528)

通过染色,形象化循环不变量:L-1为红(<target),R+1为蓝(>=target),这样也能清晰的确定循环退出时,我们需要找的值是哪一个(退出条件为R<L,所以为R+1或L)

二分的思路解贪心,贪心的本质是求最大最小,而二分可以用来解决类似最大中的最小这类问题,因此贪心可以考虑能否可以用二分来解决,这里的重点就在于一个check函数,在一个单调的区间内,存在一个值,使得<=时可以成立,>不成立  T2591  T2560的代码

对于操作的描述可以特殊化出最大最小值时,可以从这个区间进行二分,得到目标值  数列分段II

对于C++,(L+R)//可能会有数据溢出的问题,因此可以写作 L+((r-l)>>1)

基础的写法是>= x,其余的都能进行转换>x看作>=x+1,<x看作(>=x)-1,<=x看作(>x)-1

 

二分查找的函数lower_bound( )和upper_bound( )

头文件#include <algorithm>

lower_bound( begin,end,num):从数组的begin位置到end-1位置二分查找第一个大于或等于num的数字,找到返回该数字的地址,不存在则返回end。通过返回的地址减去起始地址begin,得到找到数字在数组中的下标

upper_bound( begin,end,num):从数组的begin位置到end-1位置二分查找第一个大于num的数字,找到返回该数字的地址,不存在则返回end。通过返回的地址减去起始地址begin,得到找到数字在数组中的下标

x:数组中小于某一个元素的个数

y:数组中大于某一个元素的个数

int x=lower_bound(a+1,a+n+1,b[i])-(a+1);

int y=c+n+1-upper_bound(c+1,c+n+1,b[i]);

 

也可以自行定义查找规则,作为第四个参数

class mycomp2 {

public:

bool operator()(const int& i, const int& j) {

return i>j;

}

};

vector<int> myvector{ 4,5,3,1,2 };

//根据 mycomp2 规则,从 myvector 容器中找到第一个违背 mycomp2 规则的元素

vector<int>::iterator iter = lower_bound(myvector.begin(), myvector.end(),3,mycomp2());

cout << "*iter = " << *iter;

return 0;

}

将会输出*iter = 3

lower_bound返回的是一个指针,要得到下标需要减去起始地址,如p-a(a为数组)

如果传入的是一个pair数组,需要用其中的第一个值去比较,可写为

auto it = lower_bound(st.begin(), st.end(), y,

                          [](const auto &p, int val) { return p.first < val; });(比较的规则,返回的还是第一个大于等于的数)

 

注意s.lower_bound(t)和lower_bound(s.begin(),s.end(),t)的区别

如果是后者,相当于每次给了一个位置,一次运行的时间复杂度是O(n),如果是在循环中多次运行,可能会导致最后程序超时,lower_bound 在支持随机访问的迭代器上是 O(logn) 的。如果迭代器不支持随机访问(比如 set 的迭代器),那么时间复杂度是 O(n) 的,相当于要从 s.begin() 开始不断向后一个一个访问,直到找到目标元素

 

找数组中最接近某一目标值的元素,可以使用二分查找,如果有目标值数组,找差值最小,可以枚举目标数组中的元素,对每个元素作二分查找(或者可以考虑对两个数组进行排序后,利用两个指针去寻找,谁小了移谁,记录每个位置的差值  牛客24寒假6B)

 

wqs二分

wqs 二分适用的题目类型:

给定 n个物品,我们需要在其中恰好选择 k个,并且需要最大化收益。设对应的收益为 gk ,那么需要满足在最大化收益的前提下,每多选择一个物品,额外产生的收益是单调递减的,也就是 gk+1−gk≤gk−gk−1。同时,如果我们对物品的选择数量没有限制,即 k不存在,那么我们应当能够快速地计算出最大的收益,以及达到最大的收益需要选择的物品数量

核心思想:

我们设恰好完成 k笔交易时,能够获取的最大收益为 gk ,那么gk+1−gk≤gk−gk−1是成立的。我们可以这样想:我们每额外增加一笔交易 gk→gk+1,那么这一笔交易一定不会比上一笔交易 gk−1→gk产生的收益高,否则我们就可以交换这两笔交易,使得 gk更大,那么就与 gk是恰好完成 k笔交易时的最大收益这个事实相矛盾了。

如果我们把 (k,gk)看成平面直角坐标系上的点,那么这些点就组成了一个上凸壳,即随着k的增大,以(k,gk)(k+1,gk+1)为端点的斜率是单调递减的,因此我们可以对斜率进行二分,找到一个与凸壳相切的斜率c,且过(k,gk)的线能够满足是所有斜率为c的直线中y截距最大的,为gk−k*c,而是指k笔交易带来的收益,而k*c就可以视作每一笔交易都包含了c的手续费

如果我们选择了一个合适的斜率 c′,使得其与上凸壳相切在了某一个我们需要的 (k,gk)的位置(例如本题中给出的参数 k),这样我们就可以在 O(n)的时间内直接求出不限制交易次数的最大收益,并且我们知道它实际上就是交易了 k次

在最大收益的前提下,交易的次数是具有单调性的,随着斜率(手续费)c 的增大,我们会趋向于进行更少次数的交易,这也是由上凸壳保证的。例如在极端情况下 c=∞,我们不会进行任何一笔交易。因此我们就可以对 c进行二分,如果找到了恰好进行 k 次交易的 c,那么我们就得到了正确的答案。而且如果k在上凸壳的左半部分,最优答案一定是进行k次交易

上凸壳上有若干连续的且斜率相等的线段时,我们在求解时可以尽可能地多进行交易,求解出最大的那个 k 值,从本质上来说,红色的点与绿色的点之间实际上只是相差了若干笔收益为 0的交易而已

对于c的范围,可以规定下界为1,上界为价格的最大值(斜率不可能超过这个值)

如果二分查找失败,那么说明最大收益对应的交易次数是严格小于题目中给定的 k 的(即k存在于上凸壳的右半部分,这样并不能得到真正的最大交易值),这就说明 交易次数的限制并不是瓶颈,而价格才是,这时候可以运用二分

我们求解子问题时得到的收益是 gk−k*c,所以将这个收益加上 k*c 才会得到最终的答案

class Solution {

public:

int maxProfit(int k, vector<int>& prices) {

if (prices.empty()) {

return 0;

}

int n = prices.size();

// 二分查找的上下界

int left = 1, right = *max_element(prices.begin(), prices.end());

// 存储答案,如果值为 -1 表示二分查找失败

int ans = -1;

 while (left <= right) {

// 二分得到当前的斜率(手续费)

int c = (left + right) / 2;

// 使用动态规划方法(可见背包问题通解,放在记录文档里)求解出最大收益以及对应的交易次数  

int buyCount = 0, sellCount = 0;

 int buy = -prices[0], sell = 0;

 

//动规的核心,保证每次操作前手中一定是最大的

for (int i = 1; i < n; ++i) {

if (sell - prices[i] >= buy) {

buy = sell - prices[i];

buyCount = sellCount;

}

if (buy + prices[i] - c >= sell) {

sell = buy + prices[i] - c;

sellCount = buyCount + 1;

}

}

 // 如果交易次数大于等于 k,那么可以更新答案

// 这里即使交易次数严格大于 k,更新答案也没有关系,因为总能二分到等于 k 的 ,k为最优情况的确定值

//c与k是一一对应关系,除非如上文所说斜率相同,但与最大k仍是对应的

if (sellCount >= k) {   

(如果二分不到大于等于k次的情况,证明交易次数始终小于k,不用限制交易次数。由于代码中ans还没更新,就走下面不限制交易次数的代码,直接贪心)

// 别忘了加上 kc

ans = sell + k * c;

left = c + 1;

 }

else {

right = c - 1;

}

}

// 如果二分查找失败,说明交易次数的限制不是瓶颈

// 可以看作交易次数无限,直接使用贪心方法得到答案

if (ans == -1) {

ans = 0;

for (int i = 1; i < n; ++i) {

ans += max(prices[i] - prices[i - 1], 0); }

}

return ans;

}

};

 

cf1993D(洛谷上的一篇题解)

若令f(x)为操作x次后的最大元素和,则f(x)是一个凸函数

因此可以用wqs二分来限制操作次数

 

凸包优化二分答案(P1404)

对于平均值问题,大体思路是先求前缀和s[x],那么根据表达式可以看出连续子序列平均值问题就转化为s-x平面上的斜率:

ave(x,y)=(s[y]-s[x-1])/(y-x+1)

所以找尽可能大的平均值就变成了在前面的区间里找一个点与目前将要加入的点的连线斜率最大

因为在前缀和数组中,s(x)是单调递增的(就是要注意不是y=x2那种形状的图线,而是类似于ln x的那种图线)

所以,如果在x<y<z三个点中s[y]是上凸的,那么y这个点一定对之后的斜率计算一定没贡献,所以我们是要维护一个下凸包。

那么可以用一个队列去维护这个折线,加入新点时(因为这题中有子序列长度下限,因此如当前点为i,那么新点应该为i-m),如果与队尾2个点形成上凸(kl,i<kl-1,i),则删除队尾点,,同样如果队首2个点与当前点形成上凸(kl,i<kl+1,i),那么删除队首点。最后每次队首元素都是与点i斜率最大的点,再求最值就可以得到全局的斜率最大值了

根据上面的思路可知这个方法还可以求以每个点结尾的满足条件的最大平均数,不过这题没要求存储这些值

ll n,m,s[100005];

double ans=0.0;

ll q[100005],head,tail;   // 队列

 

double k(ll x,ll y){  // 计算s[x],s[y]的斜率

    return (s[y]-s[x]+0.0)/(y-x);

}

 

int main() {

    cin>>n>>m;

    for (ll i=1,x;i<=n;i++){

        cin>>x; s[i]=s[i-1]+x;

    }

 

    for (ll i=m;i<=n;i++){

        //因为要斜率,所以至少队列中要有两个点

        while (tail-head>=2 && k(i-m,q[tail-1])<k(i-m,q[tail-2])) tail--;   // 在队尾处删除上凸点

        q[tail++]=i-m;  // 入队

        while (tail-head>=2 && k(i,q[head])<k(i,q[head+1])) head++;  // 在队首处移动得到最大斜率点

        ans=max(ans,k(i,q[head])); //求全局斜率最大点

    }

   

    cout<<(ll)floor(ans*1000)<<endl;

    return 0;

}

 

cf1945E

注意二分查找的时候判断标准是与x进行比较,因此基准是不变的

要求我们去做交换操作,使得交换完成后进行二分操作得到的结果就是我们所需的数字

这题的特殊之处在于可以证明我们至多只需要一次操作就可以将x放到我们需要的位置(先猜想要进行的操作,即先进行一次操作,之后再尝试证明)

假设我们第一次操作得到位置上的元素是l,那么有3种情况

其一:最后得到的值即为x,那么不需要进行任何操作

其二:最后得到的值比x小,那么这次交换并不会对二分的过程产生任何影响,因为我们对边界进行移动的依据是当前m是否小于等于x,因此在搜索过程中任何小于或等于x的数字会对边界的改变方向产生影响,因此假设将x与l交换位置,在原来的x所在位置,仍然有ax<=x,因此不影响进程,其他位置不发生变化,因此发生的边界变化也不变

其三:如果最后得到的值比x大,有这样这样一个事实,如果最后得到的l不是1,那么l一定是在二分搜索的过程中被当作m检查过的(因为最终是向着m靠近),因此如果进行了交换,那么x一定会落在在之前进程中一个m的位置上,而其他的元素是不变的,因此一定会到达这一个m,达到之后,x一定可以被确定下来,而对于特殊情况l为1,说明这之前每一次取到的m对应的值都比x大,因此右边界才会不断左移,显然,这种情况下,将x与第一个元素交换不会影响二分的过程

 

三分法

可用于求单峰函数的极值点(既可以用于求最大值也可以用于求最小值,下面以求解最小值为例)

每次操作在区间[l,r]任取两点limd,rmid(lmid<rmid),如果f(lmid)<f(rmid),则说明[rmid,r]区间中函数必然单调,则最小值必然不在这一区间内,可以直接删除这个区间,反之同理

于是,三分法每次操作会舍去两侧区间中的一个,为了减少三分法的操作次数,应当使两侧的区间尽可能大,因此lmid和rmid分别取mid-e和mid+e

P3382

给定一个n次函数,保证在给定的区间中是一个单峰,求极值点(最大值)

constexpr double eps=1e-7;

void solve(){

    int n;

    cin>>n;

    double l,r;

    cin>>l>>r;

    vector<double> a(n+1);

    for(int i=n;i>=0;i--){

        cin>>a[i];

    }

    auto f=[&](double x)->double{

        double res=0;

        for(int i=n;i>=0;i--){

            res=res*x+a[i];

        }

        return res;

    };

    while(abs(l-r)>=eps){

        double mid=(l+r)/2;

        if(f(mid+eps)>f(mid-eps)){

            l=mid;

        }else{

            r=mid;

        }

    }

    printf("%.5lf",r);

}

 

P3745

 

杭电24多校3  1011

周长等于2*(maxx-minx)+2*(maxy-miny)

因为在移动过程中,方向以及移动速度都是确定的,因此如maxx-minx的大小变化是规律的,且变化速度只可能是-2,-1,0,1,2的顺序(也可能直接从2开始,或者其中某一部分不存在)

于是整体来看,边界关于时间是一个单峰函数,因此可以用三分解决

三分自变量,也就是三分时间

struct node{

    i64 x,y;

    int d;

};

int dic[4][2]={{-1, 0}, {0, -1}, {0, 1}, {1, 0}};

void solve(){

    int n;

    cin>>n;

    vector<node> a(n);

    for(int i=0;i<n;i++){

        string s;

        cin>>a[i].x>>a[i].y>>s;

        if(s[0] == 'W') a[i].d = 0;

        if(s[0] == 'S') a[i].d = 1;

        if(s[0] == 'N') a[i].d = 2;

        if(s[0] == 'E') a[i].d = 3;        

    }

    //关于时间是单峰的

    auto check=[&](i64 ti)->i64{

        i64 mxx=-inff,mxy=-inff;

        i64 mnx=inff,mny=inff;

        for(int i=0;i<n;i++){

            mxx=max(mxx,a[i].x+ti*dic[a[i].d][0]);

            mxy=max(mxy,a[i].y+ti*dic[a[i].d][1]);

            mnx=min(mnx,a[i].x+ti*dic[a[i].d][0]);

            mny=min(mny,a[i].y+ti*dic[a[i].d][1]);

        }

        return 2*(mxx-mnx+mxy-mny);

    };

    i64 l=0,r=2e9;

    while(l<=r){

        i64 mid1=l+(r-l)/3;

        i64 mid2=r-(r-l)/3;

        if(check(mid1)>check(mid2)) l=mid1+1;//舍去左半区间

        else r=mid2-1;

    }

    cout<<check(l)<<'\n';

}

 

Cf 2063D

给定两条直线和n+m个不同的点,分别为(a[0],0),…,(a[n],0),和(b[0],2),…,(b[m],2)

每次操作选择3个不在同一条直线上的点,将这三个点组成的三角形面积增加到分数上,定义k为可执行操作的最大次数,要求给出执行1,2,…,k次的最大分数

因为直线间距离是确定的,因此题目操作可以转化成:在一条直线上选择两个点删去,在另一条直线上删除任意一个点,并且将价值加上|x-y|,要求最大化价值

如果只考虑一条直线,显然最优策略是每次在这条直线上选择距离最远的点对删去,这是易证的,两条直线的情况下,自然想到能否类似的每次选择两条直线中距离最远的点对,想到先不考虑每次需要在另一条直线上删除点,在点对删除完毕后,如果点的数量足够,再任意进行删除即可

令第一条直线上得到的分数是f(x),第二条直线上得到的分数是f(y),那么显然我们的目标是对于i=1,2…,k,找到一个t,使得f(t)+g(i-t)最大,可以注意到,f(t)的增长速度是不断变慢的,因为随着取出的对数增多,新取出来的点对距离一定会越来越小,但整体上分数是在增加的,因此f(t)是上凸的,同理g(t)也是凸函数,依据两个凸函数的线性组合仍然是凸函数,因此f(t)+g(i-t)也是凸函数,因此这个函数的最值可以考虑通过三分实现

void solve() {

    int n,m;

    cin>>n>>m;

    vector<i64> a(n+1);

    vector<i64> b(m+1);

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    for(int i=1;i<=m;i++){

        cin>>b[i];

    }

    sort(a.begin()+1,a.end());

    sort(b.begin()+1,b.end());

    for(int i=1;i<=n/2;i++){

        a[i]=a[n-i+1]-a[i];

    }

    for(int i=1;i<=m/2;i++){

        b[i]=b[m-i+1]-b[i];

    }

    for(int i=1;i<=n/2;i++){

        a[i]+=a[i-1];

    }

    for(int i=1;i<=m/2;i++){

        b[i]+=b[i-1];

    }

    int k=1;

    vector<i64> ans(n+m);

    while(1){

        int l=max({0,k-m/2,k*2-m}),r=min({n/2,k,n-k});

        while(l+1<r){

            int mid=(l+r)>>1;

            i64 x=a[mid-1]+b[k-mid+1];

            i64 y=a[mid+1]+b[k-mid-1];

            if(x<y){

                l=mid;

            }else{

                r=mid;

            }

        }

        for(int i=l;i<=r;i++){

            ans[k]=max(ans[k],a[i]+b[k-i]);

        }

        if(ans[k]==0){

            break;

        }

        k++;

    }

    cout<<k-1<<'\n';

    for(int i=1;i<k;i++){

        cout<<ans[i]<<" \n"[i==k-1];

    }

}

 

双指针

快慢指针

通过一个快指针和慢指针在一个for循环下完成两个for循环的工作

实际上也是通过标记来减少重复的计算(慢指针标记上次变化的位置,实际上相当于新数组的指针),快指针起到判断特殊条件的作用leetcode T27

快慢指针的使用也可以泛化,可以表征数据的变化,如在T202中判断数据是否重复出现,fast作为多运行一次的快数值,而slow作为正常运行的数值,若slow=fast可以证明数据重复出现了

 

Acwing 统计子矩阵

如果直接用前缀和+暴力,复杂度将是O(N4),必须优化

优化的方法是:

1)枚举子矩阵的左边界i和右边界j,

2)用快指针t 枚举子矩阵的下边界,慢指针s 维护子矩阵的上边界 (s≤t)

3)如果得到的子矩阵的权值和大于k,则慢指针s前进,而子矩阵和必将单调不增

4)慢指针s 继续前进,直到子矩阵的和不大于k,慢指针没必要前进了,因为该子矩阵的所有宽度为 j - i + 1 的子矩阵(总共 t - s + 1 种)一定满足要求,更新该情况对答案的贡献 t - s + 1;反之,如果慢指针s越界(s > t),则不操作,直接进入下层循环

for(int i=1; i<=m; i++){

    for(int j=i; j<=m; j++){

        for(int s = 1, t = 1; t <= n; t ++ ){

            while(s <= t && a[t][j] - a[s - 1][j] - a[t][i - 1] + a[s - 1][i - 1] > k) s ++ ;

            if(s <= t) ans += t - s + 1;

        }

    }

}

 

 

一般双指针

从不同方向进行移动,以便进行符合条件的遍历  leetcode T977,T18(四数之和)

同时寻找多个数值时可以考虑用双指针,双指针在有序数组的前提下能加快查找速率

对于遍历问题也可以考虑如何去使用指针,指针的数量、指针每次移动的距离等等

同方向移动的双指针,相较于用一个判断条件来操纵两根指针(小于时r增加,大于时l增加),还是一个指针进行正常的遍历,利用判断对另一个指针进行操作比较方便(T713)

 

双指针用于遍历数组中的元素是否满足特征

T881,将数组排序后,其中一个指针指向数组末尾,另一个指针指向数组开头,然后同时开始移动,如果两个指针指向的元素之和大于target,则只移动右指针使和变小,否则说明满足要求,因此同时移动左右指针

 

利用数组中的单调性特征,例如P1366由于ai<ai+1,bi≤bi+1,考虑双指针解法,因为ax在 b 中必然是一段连续的区间,于是让l,r 分别为ax 在b 的左右区间下标(即满足  y∈[l,r],by=ax),这样统计个数的时候就可以直接cnt=r−l+1

并且这样操作可以发现每个数字都只被遍历到了一次,因此时间复杂度是线性的

 

要求数组中所找两个元素的下标差要大于等于某一个数时也可以用双指针,初始一个指向数组开头一个指向k,然后逐个枚举移动,每次将i指向的元素加入一个数组(或者集合中,具体看题目所求),这样对于j指向的元素,数组中的元素都是满足下标差>=k的(T2817);

有些问题也能进行转化成类似的问题,例如求无重复子数组和的最大值(T689)本质上也是指这两个子数组开始的下标i,j中间至少相差子数组长度k,因此我们同时向右滑动这两个窗口,并维护 sum1的最大值 maxSum1及其对应位置。每次滑动时,计算当前 maxSum1与 sum2之和。统计这一过程中的 maxSum1+sum2 的最大值(记作 maxSum12)及其对应位置

 

正如将三数之和(T15)转化成两数之和一样,我们同样可以将类似的思路用到从两个不重叠的子数组的最大和变成求三个不重叠的子数组的最大和,同样也是构建三个滑动窗口,将第三个(也就是最后一个滑动窗口)视为标记窗口(也就是保证了前两个擦混港口所在的范围得出的值一定是能满足不重叠这一条件的),并且对前两个数组再采用之前所说的方法维护maxSum12(有点分治的感觉),注意具体实现时的修改顺序

 

在排完序的数组中找到和小于某一个值的元素对个数,指针指向数组的首尾,如果这两个值的元素和小于target说明左指针指向的元素加上这个区间内的任何数的值都小于target,若大于,说明右指针指向的元素加上这个区间内的任何元素的值都大于target,因此可以左移右指针(T2824)

 

与子数组相关的问题一般也能用双指针来解决

特别的,对于要遍历所有子数组的问题,可以采用增量法的思想,每次添加一个元素(相当于右指针右移),再从这个元素出发,将一个指针向左移动代表子数组的左端点,并在端点处记录题目要求的数据(T2262),在这样思考一遍后,再考虑能否对算法进行进一步的优化,例如总结我们记录数据变化的规律

对于选取哪些子数组可行,并且对复杂度要求不高时,可以利用双指针,初始直接将一个指针指向数组首一个指向数组末尾,持续判断以这两个指针指向的位置成为的子数组是否可行即可(23蓝桥杯更小的数)

子数组用双指针去解决,因为子数组是连续的(而且也保证了可操作的元素也是连续的),可以通过确定左端点和右端点的方式来确定这个子数组,因此统计子数组的个数,可以用枚举一个端点,同时看另一个端点有多少种可能性来统计分类,同时枚举往往可以通过一定的方式去转化,例如可以操作的数是连续的(并且首先找到连续的那一段数),那么只需要直接去找可行数里面的最大值或最小值即可,而这种处理一般是遍历一遍数组去解决,而且一般是从后往前枚举右端点,每次判断当前左端点是否可行(找出当前元素能否操作的特征)

双指针也可以用于解决与子数组长度相关的数据(T2735,将操作问题转化为子数组长度问题,需要原数组中某一长度的所有子数组的最小元素和,可以分别枚举子数组的左端点和右端点,枚举右端点的同时,维护从nums[i]到nums[j]的最小值mn,之后就可以把mn加到s[j-i]中,并且因为题干中的操作尾元素是可以到首元素的位置的,因此枚举右端点时为int j=i;j<n+i;j++,即看作是一个环形数组)

 

奇偶位指针(T2216):如果要满足子数组中偶数索引的元素不等于右边的元素,不妨设置指针i,j,其中指针i固定索引数组中的偶数位,而j依次遍历代表下一个元素,即j将始终代表奇数位,而在j代表的元素确定后,j+1也就自然是下一个偶数位,因此将i移到j+1处

 

用栈可能超时时,可以用双指针来模拟入栈出栈的过程,一个指针用于遍历数组表示逐个添加元素,一个指针用来表示实际栈中栈顶的位置(T2390)

使用双指针时要注意判断的顺序,应该是先放入当前指针指向的,再判断下一个元素是否可以入栈(T2390中while放置的位置)

 

滑动数组:

一般的记忆化缓存可用数组、滑动数组来解决,而二叉树一般可以利用哈希表来存储,将TreeNode当作转化前的下标

二叉树的递归法其实就是dfs,而二叉树的迭代法就是bfs

一定程度上,滑动数组也是双指针的体现,可用于处理子序列问题leetcodeT209,leetcodeT3

 

滑动窗口:

维护左端点,枚举右端点,并且在移动过程中可以保证左指针只会右移并不会左移,这就需要保证数组中的元素处理也具有一定的单调性(例如数组中的元素越多,越难满足题目所需条件,那么就保证了当前滑窗内的元素不能满足要求时,我们移动左端点)

子数组的元素和为target,S[i]代表下标为i的前缀和

那么就有S[r]-S[l]=target   S[l]=S[r]-target  若数组有序,那么S[r]一定是递增的,那么相应的S[l]也是递增的,因此l只会右移

考察连续子数组的长度考虑能否使用滑动窗口去解决(T2968,也可以参考T2602)

如果数组中有负数就考虑用前缀和再加上哈希表,利用哈希表得到最近的负数的下标

滑动窗口的思想可以用在单个数组中,也可以用在循环数组中(相当于把该数组复制拼接)

子数组统计问题,常用双指针(不定长滑动窗口)实现

题目中存在两个下标i,j,并且i,j之间的差值有一定的要求自然就想到了双指针,并且在j移动的同时,i实际上是在一个范围[0,j-indexDifference]中,因此可以枚举j,在确定了j之后,再对i范围内的数据进行操作(例T2903,T2537)

对滑动窗口算法的优化往往在于判断什么时候可以移动指针(T30),或者对于固定长度的窗口,减少窗口整体移动的次数,即左指针i的遍历范围(因为移动一定距离后,之后的情况已经在前面的i中包含了)

固定长度的滑动窗口(T2953),枚举窗口的右端点,进而计算出左端点,如果左端点计算出来大于等于0,那么就对该窗口的元素+1(这样操作主要是因为可以顺带遍历一遍窗口中的元素,不然也可以直接将右端点从窗口长度处开始枚举)

从另一个思路理解T30:多起点滑动窗口

滑动窗口的目的是在线性复杂度内,在一段连续的区间上通过进行窗口滑动来对问题进行求解,也就是说,窗口滑动的时间复杂度是O(n)的,但每次滑动后,窗口内的计算复杂度应该是常量O(1)的(例如常见的思路,用一个数据去维护滑动窗口内的与题目有关的数据量,通过这个数据来判断窗口是否需要移动),因此一般不可能是在窗口移动后还要对窗口内的子数组进行遍历,简单来说就是窗口移动后,变化的应该只有被删除的元素和新加入的元素,窗口内的其他元素应该是不受到影响的

 

可用于统计个数的滑动窗口(满足某一条件的子数组个数)(T2962,T713)

如T2962的算法思路:

设mx=max(nums)。

右端点right从左到右遍历nums。遍历到元素 x=nums[right]时,如果 x=mx,就把计数器 cntMx加一。

如果此时 cntMx=k,则不断右移左指针 left,直到窗口内的 mx的出现次数小于 k为止。此时,对于右端点为 right且左端点小于 left的子数组,mx的出现次数都至少为 k,把答案增加 left

 

利用滑动窗口计算最长连续段的长度 T2009

利用如果是排完序并且去重的连续段,那么右节点的元素值满足nums[left]+n-1;

for (int i = 0; i < m; i++) {

            while (nums[left] < nums[i] - n + 1) { // nums[left] 不在窗口内

                left++;

            }

            ans = max(ans, i - left + 1);

        }

 

树状数组维护一个数组,通过二进制的方式对前缀和进行分块存储,核心思想是利用数组的下标和二进制表示的性质,将数组划分为若干区间,每个区间的大小为2的某个次方,树状数组的每个元素代表了所对应区间的元素和

例如:7表示为2+4+1也就是1 1 1 , 1 1 1对应的7由a[7]存储 ,1 1 0对应的5~6由a[6]存储,1 0 0对应的1~4由a[4]存储,于是原数组中前7个元素的前缀和表示为a[7]+a[6]+a[4]

树状数组可以高效处理动态数组的前缀和问题,适用于单点更新和区间查询,但不适用于区间更新

 

需要一个数据结构,能够添加元素,并查询<=x的元素个数(查询前缀)(有点类似于两数之和哈希表的思路,但不完全相同),即单点修改和区间查询,可以使用树状数组、线段树、名次树,python中的Sortedlist

 

线段树的思想是,把区间表示成若干区间的并集

树状数组的思想是,把区间表示成两个前缀区间的差集(因此没法很高效地求max)

前缀区间又可以表示成若干个区间的并集

树状数组的核心是lowbit,线段树的核心是mid

 

对于各类区间和问题的解决方案

数组不变,求区间和:「前缀和」、「树状数组」、「线段树」

多次修改某个数(单点),求区间和:「树状数组」、「线段树」

多次修改某个区间,输出最终结果:「差分」

多次修改某个区间,求区间和:「线段树」、「树状数组」(看修改区间范围大小)

多次将某个区间变成同一个数,求区间和:「线段树」、「树状数组」(看修改区间范围大小)

 

Cf 2064D

求左侧第一个大于当前元素的,可以使用单调栈,也可以使用线段树

单调栈只能实现对于数组末尾的元素进行查找,或者在构建单调栈的过程中维护数组中每个元素左侧第一个大于等于他的元素,用单调递减栈,在处理栈的过程中实现,如果小于,弹出栈顶元素

但线段树可以实现任意区间,对任意一个数值的查找

题目大意:给定一个数组,q次询问,每次询问给定一个元素,放置于数组末尾,如果这个元素大于等于左侧的元素,那么这个元素更新为x^y,同时覆盖左侧的元素

对每次询问,回答能这样吞并几次

暴力是显然的,也就是直接模拟即可,但是因为数据量较大,因此时间复杂度不对

考虑到每次操作是异或,同时在二进制下,判断元素的大小是容易的,一个数如果大于另一个数,那么其必要条件是这个数的最高位1大于等于另一个数的最高位1,通过这个性质,我们可以加快查找的速度,也就是找左侧第一个大于等于当前元素的数的位置,在此范围之中的数是一定可以被吞并的,并且这中间的异或不会影响到大小的判断

而对于查找到的位置,判断这个区间中的数异或之后的结果是否小于,如果小于,则退出循环,否则更新后继续操作

struct Info{

    int x=0;

};

 

Info operator+(const Info &a,const Info &b){

    return {std::max(a.x,b.x)};

}

 

void solve(){

    int n,q;

    cin>>n>>q;

    SegmentTree<Info> seg(n);

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        seg.modify(i,{32-__builtin_clz(a[i])});    

    }

    auto suf=a;

    suf.emplace_back(0);

    for(int i=n-1;i>=0;i--){

        suf[i]^=suf[i+1];

    }

    while(q--){

        int x;

        cin>>x;

        int p=32-__builtin_clz(x);

        int ans=0;

        int pre=n;

        int l=seg.findLast(0,pre,[&](Info v){return v.x>=p;});

        // cout<<"q="<<q<<'\n';

        // cout<<"x="<<x<<'\n';

        while(1){

            // cout<<"pre:"<<pre<<' '<<"l:"<<l<<'\n';

            if(l==-1){

                ans+=pre;

                break;

            }else if(a[l] > (x^(suf[l+1]^suf[pre]))){

                ans+=pre-l-1;

                break;

            }else{

                ans+=pre-l;

                x^=(suf[l]^suf[pre]);

                pre=l;                

                // cout<<"x:"<<x<<'\n';

                if(x==0){

                    break;

                }

                p=32-__builtin_clz(x);

                l=seg.findLast(0,pre,[&](Info v){return v.x>=p;});

            }

        }

        // cout<<'\n';

        cout<<ans<<" \n"[q==0];

    }

}

因为__builtin_clz(0)会产生错误,因此要特判x为0的情况,同时该函数是以int也就是32位二进制进行计数的,若要保证数据非负,用32-__builtin_clz(x)

 

官方题解:
首先结论是相同的,如果无法吃掉下一个粘液,那么这个粘液的msb(最高位1)必须至少和当前的x一样大,如果一个粘液的msb严格小于x,我们总是可以吃掉它

在任何时候,都应该吃掉左边尽可能多的msb较小的粘液(要计算x的新值,也就是要进行任何范围的xor查询,这和上面的考虑一样,可以使用前缀求和),由于msb在每次运算后都会减小,因此最多只需要进行log(x) 此运算

而要求出左边第一个粘液的msb大于等于当前的msb的x,可以使用前缀最值,表示为pre[i][j](利用数组对于任意可能情况进行存储,而不是使用单调栈),如果msb(w[i])<j,那么pre[i][j]=pre[i-1][j],否则pre[i][j]=i,i表示当前位置,j表示目标msb

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> w(n);

    for(int i=0;i<n;i++){

        cin>>w[i];

    }

    vector<array<int,30>> left(n);

    vector<array<int,30>> pre(n);

    for(int i=0;i<n;i++){

        if(i){

            left[i]=left[i-1];

            pre[i]=pre[i-1];

        }else{

            left[i].fill(-1);

        }

        int d=__lg(w[i]);

        for(int j=0;j<=d;j++){

            left[i][j]=i;

            pre[i][j]=0;

        }

        for(int j=d+1;j<30;j++){

            pre[i][j]^=w[i];

        }

    }

    for(int i=0;i<q;i++){

        int x;

        cin>>x;

        int j=n-1;

        while(x>0 && j>=0){

            int d=__lg(x);

            int k=left[j][d];

            x^=pre[j][d];

            j=k;

            if(j==-1){

                break;

            }

            if(x<w[j]){

                break;

            }

            x^=w[j];

            j--;

        }

        cout<<n-1-j<<" \n"[i==q-1];

    }

}

 

 

可持久化数据结构

可持久化可以分为部分可持久化(所有版本都可以访问,但是只有最新版本可以修改)和完全可持久化(所有版本都既可以访问又可以修改)

可以应用于几何计算(几何计算中有许多离线算法,如扫描线算法,但强迫在线的情况下,如果不用可持久化数据结构,询问操作的时间复杂度就会降低为线性复杂度)以及子串处理

 

主席树(可持久化权值线段树)(洛谷 3567)

权值:数列中每个数的值

权值线段树:以权值作为下标的线段数,每个节点上记录的是对应的一段值域中数的出现次数(叶子节点就是仅以一个值为值域的点,就是如最开始所说的:权值(值域)就相当于一般线段树中的下标),主要用途是求一段值域中的数的总出现次数

如果要查询一段区间内的某个值的出现次数:查找某个值的出现次数想到用权值线段树,而查找某一个区间[l,r]的相关信息,可以想到用前缀和,前缀和的本质实际上是利用了区间减法的性质,通过预处理从而达到O(1)回答每个询问,即我们只需要用[1,r]的信息减去[1,l-1]的信息即可

前缀和加线段树的想法就可以视作是主席树,主席树的本质就是节省内存的前缀线段树

例:要查找序列a的闭区间[l,r]中第k小值,一种可行的方案是,保存每次插入操作的历史版本,以便查询区间第k小,因为我们可以很简单的得到查询[1,r]的第k小的数字,只需要找到插入r的根节点版本,然后用普通权值线段树做就行了,那么问题就在于如何保存历史版本,总不能每次都开一棵线段树,又可以发现每次修改操作的点的个数是一样的,因为每次添加数值一定是从叶子节点开始,逐个向上改变的,也就是说,每次更改的节点数=树的高度,只更改了O(logn)个节点,这些节点组成了一条链,而除此之外的节点可以仍然使用原来的线段树,因此相当于只要在记录左右儿子的基础上,保存插入每个数的时候的根节点就可以实现持久化了(这些根节点是这棵线段树在操作过程中唯一改变的点)

注意主席树不能使用堆式存储法,就是说不能用2*x,2*x+1来表示左右儿子,而是应该动态开点,并保存每个节点的左右儿子编号

实现(区间第k小Range Kth Smallest-Library Checker)

#include <algorithm>

#include <cstdio>

#include <cstring>

using namespace std;

const int maxn = 1e5; // 数据范围

int tot, n, m;

int sum[(maxn << 5) + 10], rt[maxn + 10], ls[(maxn << 5) + 10],rs[(maxn << 5) + 10];

int a[maxn + 10], ind[maxn + 10], len;

//不需要吝啬空间,因此直接将树相关的数组开到25*105,接近原空间的两倍

//sum数组代表下标对应的节点中存储的数值的个数

 

int getid(const int &val) { // 离散化后的下标

return lower_bound(ind + 1, ind + len + 1, val) - ind;

}

 

int build(int l, int r) { // 建树,下标从1开始,以原始数组的不重复元素的大小创建树,因为节点还有出现几次的数据维护,因此是离散化后的数据个数,并且这是初始树,后续修改都在此树的基础上

int root = ++tot;//编号是按目前是第几个点来记的,动态开点的特征

if (l == r) return root;

int mid = l + r >> 1;

ls[root] = build(l, mid);//因为是动态开点(点的编号不一定是有序的),因此用数组来对应存储该下标为根节点的左右子树的编号

rs[root] = build(mid + 1, r);

return root; // 返回该子树的根节点,如果是建树的最后返回值就是最初这棵树的根节点下标

}

 

int update(int k, int l, int r, int root) { // 插入操作,k是离散化中的下标,root是这棵树上一个版本的根节点编号

int dir = ++tot;//不断增加编号;并且将上一个版本的左右子树拷贝给这个节点,做到了只修改部分节点达到更新的目的,并且更新之后的那些节点的编号就不再是其他版本这个相同位置的编号了,达到了利用编号记录版本的目的,并且在建树之后的编号是可以任意得到的,因为有rt[]去存储,因此在之后查询的时候能做到O(1)内得到版本

ls[dir] = ls[root], rs[dir] = rs[root], sum[dir] = sum[root] + 1;

if (l == r) return dir;
     int mid = l + r >> 1;

if (k <= mid)

ls[dir] = update(k, l, mid, ls[dir]);

else

rs[dir] = update(k, mid + 1, r, rs[dir]);

return dir;

}

 

int query(int u, int v, int l, int r, int k) { // 查询操作,查询第k小的数字

int mid = l + r >> 1,x = sum[ls[v]] - sum[ls[u]]; // 通过区间减法得到这个区间中左儿子中所存储的数值个数,即是增加的个数,并且因为位运算的优先级小于+,因此这里计算出的mid就是(l+r)/2

if (l == r) return l;

if (k <= x) // 若 k 小于等于 x ,则说明第 k 小的数字存储在在左儿子中

return query(ls[u], ls[v], l, mid, k);

else // 否则说明在右儿子中

return query(rs[u], rs[v], mid + 1, r, k - x);

}

 

void init() {//初始化

scanf("%d%d", &n, &m);

for (int i = 1; i <= n; ++i) scanf("%d", a + i);//利用迭代器的数组读入

memcpy(ind, a, sizeof ind);

sort(ind + 1, ind + n + 1);

len = unique(ind + 1, ind + n + 1) - ind - 1;   //离散化去重

rt[0] = build(1, len);

for (int i = 1; i <= n; ++i) rt[i] = update(getid(a[i]), 1, len, rt[i - 1]);

//逐个插入元素,并且保存插入每个元素时候的根节点

}

 

int l, r, k;

 

void work() {

while (m--) {

scanf("%d%d%d", &l, &r, &k);

printf("%d\n", ind[query(rt[l - 1], rt[r], 1, len, k)]); // 回答询问

}

}

 

int main() {

init();

work();

return 0;

}

constexpr int maxn = 2e5;

int tot, n, m;

int sum[(maxn << 5) + 10], rt[maxn + 10], ls[(maxn << 5) + 10],rs[(maxn << 5) + 10];

int a[maxn + 10], ind[maxn + 10], len;

//sum数组代表下标对应的节点中存储的数值的个数

 

int getid(const int &val) { // 离散化后的下标

    return lower_bound(ind + 1, ind + len + 1, val) - ind;

}

 

int build(int l, int r) { // 建树,下标从1开始,以原始数组的不重复元素的大小创建树,后续修改都在此树的基础上

    int root = ++tot;//动态开点

    if (l == r) return root;

    int mid = l + r >> 1;

    ls[root] = build(l, mid);

    rs[root] = build(mid + 1, r);

    return root; // 返回该子树的根节点,如果是建树的最后返回值就是最初这棵树的根节点下标

}

 

int update(int k, int l, int r, int root) { // 插入操作,k是离散化中的下标,root是这棵树上一个版本的根节点编号

    int dir = ++tot;

    //不断增加编号;

    //并且将上一个版本的左右子树拷贝给这个节点,做到了只修改部分节点达到更新的目的

    //更新之后的那些节点的编号就不再是其他版本这个相同位置的编号了,达到了利用编号记录版本的目的,并且在建树之后的编号是可以任意得到的,因为有rt[]去存储,因此在之后查询的时候能做到O(1)得到版本

    ls[dir] = ls[root], rs[dir] = rs[root], sum[dir] = sum[root] + 1;

    if (l == r) return dir;

    int mid = l + r >> 1;

    if (k <= mid)

        ls[dir] = update(k, l, mid, ls[dir]);

    else

        rs[dir] = update(k, mid + 1, r, rs[dir]);

    return dir;

}

 

int query(int u, int v, int l, int r, int k) { // 查询操作,查询第k小的数字

    int mid = l + r >> 1,x = sum[ls[v]] - sum[ls[u]]; // 通过区间减法得到这个区间中左儿子中所存储的数值个数

    if (l == r) return l;

    if (k <= x) // 若 k 小于等于 x ,则说明第 k 小的数字存储在在左儿子中

        return query(ls[u], ls[v], l, mid, k);

    else // 否则说明在右儿子中

        return query(rs[u], rs[v], mid + 1, r, k - x);

}

 

void solve() {

    int n,q;

    cin>>n>>q;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    memcpy(ind,a,sizeof(ind));

    sort(ind+1,ind+n+1);

    len=unique(ind+1,ind+n+1)-ind-1;

    rt[0]=build(1,len);

    for(int i=1;i<=n;i++){

        rt[i]=update(getid(a[i]),1,len,rt[i-1]);

        //逐个插入元素,并且保存插入每个元素时候的根节点

    }

    while(q--){

        int l,r,k;

        cin>>l>>r>>k;

        l++,k++;

        cout<<ind[query(rt[l-1],rt[r],1,len,k)]<<'\n';

    }

}

(树状数组只能查询整个数组中的Kth数据)

 

 

树形结构

Cf2040 d
给定一棵树,n个点,构造一个数组长度为n,值域为1到2n,每个值最多只能出现一次,树上的每条边,其两个节点的值的差的绝对值不能是素数

按照dfs序来进行操作,一棵子树考虑其值(下一个填入的值)会增加多少,因为看到值域是1到2n,因此联想会不会是子节点个数的两倍

保证不是素数容易联想到大于2的偶数,不妨假设每个节点逐个加2,那确实不会超过2n

大体方向正确后考虑如何操作,因为2是素数,因此不能简单地逐个加2,想到1并不是素数,因此可以先加1,对这个值操作完后再手动加上一个1,使得当前数字增加了2,因此可以得到操作:

如果是第一个子树就先加1,然后递归完所有子树再加1,这样刚好加了2,同时子树的差是1,否则就先加2再递归子树,这样差一定是一个大于2的偶数

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    vector<int> a(n);

    int cur=1;

    auto dfs=[&](auto self,int u,int p=-1)->void{

        a[u]=cur;

        bool first=true;

        for(auto v:adj[u]){

            if(v==p) continue;

            if(first){

                first=false;

                cur++;

                self(self,v,u);

                cur++;

            }else{

                cur+=2;

                self(self,v,u);

            }

        }

    };

    dfs(dfs,0);

    for(int i=0;i<n;i++){

        cout<<a[i]<<" \n"[i==n-1];

    }

}

 

https://codeforces.com/problemset/gymProblem/102253/C

有一棵树,树上有n个节点,其中第i个节点的颜色类型用整数c[i]表示

显然每两个节点之间的路径都是唯一的,并且我们将该路径的值定义为路径上出现的不同颜色类型的个数

计算树上所有可能n*(n-1)/2条路径的值的总和

直接求出所有路径并进行求和显然不太可行

因此比较合适的想法是依据颜色来计算贡献,由于路径上出现了某种颜色的情况不好计算,考虑反向计算路径上不包含某种颜色时的路径数

答案就是 路径总数*颜色总数 – 每种颜色没参与的贡献和

可以想到,如果我们能够得到删除该颜色后的连通块情况,那么此时没参与的贡献就是对于每个连通块的大小m,求m*(m-1)/2的总和

考虑怎么样得到该信息,如果每次使用并查集维护会比较复杂

可以从原树上进行考虑,类似于树形dp的想法

从根开始,如果一个子节点的颜色是要删除的颜色c,那么就减去该子树大小,同时对于这个子节点的各个子树,再迭代进行处理

具体来说

对于结点u,若u的颜色为x,那么dfs(u)的过程中,我们就想知道对于u的每个儿子v构成的子树中最高的一批颜色也为x的节点是哪些,要是知道这些节点子树的大小,只要拿子树v的大小减去这些节点子树的大小,就可以得到包括v在内的没有颜色x的连通块大小。

可以说每个颜色节点对树进行了划分,划分成若干个以该种颜色为根的子树,sum[i]维护的就是这些子树的大小和

在根节点为这种颜色的情况下,它的子树相互不连通了,因此siz[u]-delta计算的就是该部分 的连通块大小,直接可以得出这部分的答案贡献

这样处理完成之后,因为是自底向上的,因此根节点可能还没有计算,此时i=color[0]时,sum[i]存储的已经是整棵树的大小,i!=color[0]时,sum[i]存储的是除了根节点构成的那一个连通块外其他节点的个数(也就是根节点下该颜色对应的子树的大小和)

void solve() {

    int n;

    cin >> n;

    vector<int> color(n);

    vector<int> vis(n);

    int cnt = 0;

    for (int i = 0; i < n; i++) {

        cin >> color[i];

        color[i]--;

        vis[color[i]]++;

        cnt += (vis[color[i]] == 1);

    }

    vector<vector<int>> adj(n);

    for (int i = 0; i < n - 1; i++) {

        int u, v;

        cin >> u >> v;

        u--, v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<int> siz(n);

    vector<int> sum(n);

    i64 ans = 0;

    auto dfs = [&](auto&& self, int u, int p) -> void {

        siz[u] = 1;

        // sum表示以color[u]为根的子树的大小的和

        sum[color[u]]++; // u本身

        for (int v : adj[u]) {

            if (v == p) continue;

            int s = sum[color[u]]; // 递归之前的sum值

            self(self, v, u);

            siz[u] += siz[v];

            int t = sum[color[u]]; // 递归之后的sum值

            int delta = t - s; // v为根的子树中color[u]为根的子树的大小和

            ans += 1ll * (siz[v] - delta) * (siz[v] - delta - 1) / 2; // u之下,该部分就是需要计算的连通块

            sum[color[u]] += siz[v] - delta; // 将这部分的节点加回到u为根的子树中

        }

    };

    dfs(dfs, 0, -1);

    for (int i = 0; i < n; i++) { // 对于根节点特殊处理

        if (vis[i]) {

            ans += 1ll * (n - sum[i]) * (n - sum[i] - 1) / 2;

        }

    }

    ans = 1ll * n * (n - 1) / 2 * cnt - ans;

    cout << ans << '\n';

}

 

 

https://ac.nowcoder.com/acm/contest/131/C
有一棵树包含 N 个节点,节点编号从 1 到 N。节点总共有 K 种颜色,颜色编号从 1 到 K。第 i 个节点的颜色为 Ai
Fi 表示恰好包含 i 种颜色的路径数量,求F[1]…F[N]

题目的数据范围限制了K<=10,因此即使暴力,也只有2^k种情况,于是可以对于每一种情况计算有多少种不同的路径

令dp[x]表示x状态下路径的条数,例如k=5,x=10010,那么dp[x]就表示在保留所有颜色为2,5的情况下,删除所有其他颜色节点的情况下的路径的条数

由于dp[x]并不是一定经过所有指定颜色的路径条数,因此还要进行容斥,奇加偶减

子集枚举和容斥原理

子集枚举就是for(int i = 0; i < (1<<K) ; i++)

容斥原理是对每个 i,枚举其所有非空的真子集 u(即 u 是 i 的一个子mask,但 u ≠ i),并从 f[i] 中减去 f[u] 的贡献

表达式 (i - 1) & i 能够取得 i 的一个真子集。之后,通过 u = (u - 1) & i 可以枚举出 i 所有的非零子集。

void solve() {

    int n, k;

    cin >> n >> k;

    vector<int> color(n);

    for (int i = 0; i < n; i++) {

        cin >> color[i];

        color[i]--;

    }

    vector<vector<int>> adj(n);

    for (int i = 0; i < n - 1; i++) {

        int u, v;

        cin >> u >> v;

        u--, v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<int> vis(n);

    vector<Z> f(1 << k, 0);

    vector<int> siz(n, 0);

    int tot = 0;

    int m;

    // 这里不能简单使用siz[u],因为这样计算的是所有节点都可用的情况

    // f[m] 最终保存的是:在允许颜色 m 内,每个连通块中所有节点对路径数(包括单节点路径)的累加值。

    // 这里需要的是允许的颜色情况下,连接的子树的大小, 因此颜色不同直接返回0

    auto dfs = [&](auto self, int u, int p) -> int {

        vis[u] = tot;

        if (!((m >> color[u]) & 1)) return 0;

        int cnt = 1; // 单节点路径

        f[m] += 1;

        for (int v : adj[u]) {

            if (v == p) continue;

            int sub = self(self, v, u);

            f[m] += 1ll * cnt * sub;  // 统计节点对,也就是路径数

            cnt += sub;

        }

        return cnt;

    };

    vector<Z> ans(k + 1);

    for (int i = 0; i < (1 << k); i++) {

        tot++; // 每一轮进行dfs,vis[u] != tot 说明这一轮没有被访问过

        m = i;

        for (int u = 0; u < n; u++) {

            if (vis[u] != tot) dfs(dfs, u, -1);

        }

        for (int u = (i - 1) & i; u; u = (u - 1) & i) {

            f[i] -= f[u];

        }

        int u = __builtin_popcount(i);

        ans[u] += f[i];

    }

    Z res = 0;

    Z base = 131;

    for (int i = 1; i <= k; i++, base = base * 131) {

        res += base * ans[i];

    }

    cout << res << '\n';

}

 

P2664

lrb有一棵树,树的每个节点有个颜色。给一个长度为n的颜色序列,定义s(i,j) 为i 到j 的颜色数量。Sum[i] = sigma s(I, j),要输出所有的sum[i]

实际上上面的题目也是,核心是把路径颜色数量的总和拆分成了颜色对应路径条数的总和,这里主要的难点在于要对每个点单独计算,因此不能按上面的方法解决

同样考虑一种颜色对答案的贡献,如果把树种这种颜色的点都删掉,那么就会有很多的小树,因为计算的是这种颜色的贡献,因此这些小树中的点互相之间不会产生贡献,而不同树的两个点之间会产生贡献

因此,对于这种颜色,对这个点的sum值的贡献是 n – 所在小树的大小

从而,一个点的sum 就是 n * 颜色数 – 每种颜色节点时所在小树的大小

考虑将一棵小树的size存储在深度最小的节点上,那么后面就可以用树上差分实现覆盖。

求size,在回溯时遇到与当前颜色相同的点,就把整一颗子树的节点都删掉,那么一个点的答案就是,子树的size - (当前删的个数 - 遍历时删的个数)。

因为每个节点只有一个颜色,所以一个节点会记录一次答案,特别的,根节点的父亲设为所有颜色都有,全部统计一下。然后就树上差分传递下去,统计答案。

具体来说,因为每个节点都只有一种颜色,假设当前枚举到了节点x,它有若干子节点y1,y2…,删除x的颜色时,剩下的连通块的顶部一定时y1,y2…,并且这些连通块的大小是以y节点为根节点的子树减去其中深度最小的颜色为x的颜色的节点为根的子树

这里有点像上面那题的处理分析的思路,只是这里因为要维护每个点的信息,因此还要进一步考虑

也就是我们在一个点只处理这个颜色,也只需要处理这个颜色

因为一个点的sum是n * 颜色数 – 每种颜色节点时所在小树的大小,前者是常数,于是重点后面部分的计算

每个点都只有一种颜色,因此从上到下处理的时候如果按照上面的思路进行,一定能完成对所有颜色的处理

又由于每种颜色下的子树都是互相独立的,所以我们处理一个节点时可以用树上差分来维护

也就是依据上面的分析,假设此时dfs到了x,枚举到了子节点y,这个y在x的颜色下的子树大小是s,那么就在节点y处加上s,同时因为是树上差分,且对于那些颜色为x的颜色c的节点是不拥有这个贡献的

因此给子树内那些最靠上的颜色为c的节点处减s(和之前那题类似,看作这些颜色的点对树进行了划分)

这样最后再做一次前缀和后,每个节点上的值就是不同颜色下子树大小的总和

 

Cf 2071C
有一棵n个点的树,最开始位于st,有一个操作序列p,i时刻,会向p[i]移动,若已经在p[i]处,那么就停留,问这样n时刻后,能否位于end点

因为我们是要在图上进行移动,因此先考虑当前树形结构,但因为并没有给定树根,因此实际上形式是不确定的,因为给定了st和en,自然想到以这两者中的一个作为树根,考虑到我们最终是要移动到en,同时树的叶子是可以有多个的,但是树根是唯一的,因此作为终点,我们不妨将en作为树根,每次移动的目标就是减少到树根的距离

有一种想法是我们按照en将无向图进行划分,将st安排在en的一侧,其余位于另一侧,但这样但是因为结构可能会很不平衡,同时因为要输出序列,因此序列点的选择应该是简单的或者说是有一定规律的,不可能每次去判断选哪个点

而有根树上的有规律的序列或者每棵树都具有的序列自然想到树的遍历,例如后序遍历、层序遍历等,而这确实是答案序列

树的后序序列的计算:先确定以en为根的树形结构,这可以用bfs实现,然后dfs确定后序遍历序列

void solve() {

    int n,st,en;

    cin>>n>>st>>en;

    st--,en--;

    vector<vector<int>> adj(n);

    for(int i=0;i<n-1;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<int> p;

    p.assign(n,-2);

    p[en]=-1;

    queue<int> q;

    q.push(en);

    while(!q.empty()){

        int u=q.front();

        q.pop();

        for(int v:adj[u]){

            if(p[v]==-2){

                p[v]=u;

                q.push(v);

            }

        }

    }

    // cout<<st<<' '<<en<<'\n';

    // cout<<p[0]<<' '<<p[1]<<'\n';

    vector<int> ans;

    auto dfs=[&](auto self,int u,int pa)->void{

        for(int v:adj[u]){

            if(v==pa) continue;

            if(p[v]==u) self(self,v,u);

        }

        ans.emplace_back(u);

    };

    dfs(dfs,en,-1);

    for(int i=0;i<n;i++){

        cout<<ans[i]+1<<" \n"[i==n-1];    

    }

}

还有一种是层序遍历,或者说按照深度输出序列

因为每个节点都要被输出一次,因此考虑从最深的节点开始输出,这样能防止在之后的操作中远离顶点en,同时会逐步靠近顶点en

观察可以发现,在处理了最大深度n-1的所有顶点后,当前的深度不可能超过n-1,对下一个深度n-2也进行这样的操作,深度也不会超过n-1,于是按照深度从大到小的顺序处理即可

void solve() {

    int n,st,en;

    cin>>n>>st>>en;

    st--,en--;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    vector<int> q;

    q.emplace_back(en);

    vector<int> vis(n);

    vis[en]=1;

    for(int i=0;i<n;i++){

        int x=q[i];

        for(int y:adj[x]){

            if(vis[y]) continue;

            vis[y]=1;

            q.emplace_back(y);

        }

    }

    reverse(q.begin(),q.end());

    for(int x:q){

        cout<<x+1<<" \n"[x==q.back()];

    }

}

 

 

Cf 2062E1

给定一棵树,每次操作选择一个节点i并删除节点i的子树,下一次的选择的时候只能选择比该节点权值更大的节点,当轮到Cirno但他没有节点可以选择时,Cirno获胜,问Cirno能否获胜,如果能,则输出他第一轮可以选择的任一节点

可以考虑必败的条件,这是容易得到的,也就是若此时子树外没有大于w[u]的,那么就是必败的

从这一点可以得到必胜的条件,也就是存在v不在u的子树中,且w[v]>w[u],那么最大的u就是我们的必胜点

因此问题就变成得到这个u,依据数据范围,考虑能否在O(log n)中完成判断,按照复杂度以及要求最大值,想到使用线段树或者树状数组(BIT)

但为了实现查询,要使得区间连续,这可以通过dfs序实现,这时查询的最大值可以分成dfn中的两端,依照dfn序,子树外的节点分别分布在[0,in[i]-1],[out[i]+1,n-1]中,这样通过线段树的区间查询就可以得到满足条件的最大点

(或者使用树状数组进行记录,在dfs的过程中差分)

核心的代码部分:

    for(int i=0;i<n;i++){

        if(max(seg.rangeQuery(0,in[i]).x,seg.rangeQuery(out[i]+1,n).x)>a[i] && (flag==0 || a[ans]<a[i])){

            flag=1;

            ans=i;

        }

    }

通过dfn判断子树的从属关系,从而通过线段树查询对应区间的最值来判断该节点的值是否大于子树外所有节点的值

也由此,构建线段树时,是按照dfn序构建,也就是线段树的下标表示的是dfn的时间戳

    auto dfs=[&](auto self,int x,int p)->void{

        in[x]=++T;

        id[T]=x;

        for(int y:adj[x]){

            if(y==p) continue;

            self(self,y,x);

        }

        out[x]=T;

    };

完整代码:

struct Info{

    int x=0;

};

 

Info operator+(const Info &a,const Info &b){

    return {max(a.x,b.x)};

}

 

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<int> in(n),out(n),id(n);

    int T=-1;

    auto dfs=[&](auto self,int x,int p)->void{

        in[x]=++T;

        id[T]=x;

        for(int y:adj[x]){

            if(y==p) continue;

            self(self,y,x);

        }

        out[x]=T;

    };

    dfs(dfs,0,-1);

    // for(int i=0;i<n;i++){

    //     cout<<id[i]<<" \n"[i==n-1];

    // }

    // for(int i=0;i<n;i++){

    //     cout<<i+1<<' '<<in[i]<<' '<<out[i]<<'\n';

    // }

    SegmentTree<Info> seg(n);

    for(int i=0;i<n;i++){

        seg.modify(i,{a[id[i]]});

    }

    int flag=0;

    int ans=0;

    for(int i=0;i<n;i++){

        if(max(seg.rangeQuery(0,in[i]).x,seg.rangeQuery(out[i]+1,n).x)>a[i] && (flag==0 || a[ans]<a[i])){

            flag=1;

            ans=i;

        }

    }

    if(flag==0){

        cout<<0<<'\n';

    }else{

        cout<<ans+1<<'\n';

    }

}

 

 

Cf2053 E

给定一棵树,有两人在玩游戏:有一条蛇,初始头位于p,尾位于q,每轮游戏,a先移动p,然后b移动q,他们都会以最优的方案进行移动,当其中一个端点移动到了叶子节点时,那一方就获胜了,平局条件是头和尾都位于叶子节点,或者经过足够长的游戏轮次后,二者仍未分出胜负

问能够让b获胜的(p,q)的对数

显然,平局的其中一种情况是初始我们就将p,q放置在叶子节点上

另一种情况是当其中一个玩家意识到不可能获胜时,它始终将蛇保持在某一位置不动

因为b是后手,因此如果p放置的位置距叶子节点的距离为1时是必输的;如果要获胜,只能让a移动后,q距叶子节点的距离为1;或者初始情况下,将q放置在叶子节点上,p放置在非叶子节点上

如果树的结构是固定的,就相当于初始情况下q是叶子节点的兄弟节点,而p放置在距离叶子节点的距离大于1的位置处,但因为树的结构并没有被确定,也就是从某一方向看,q可能是叶子节点的兄弟节点,但是如果此时再将树根节点定义为比叶子节点更深的节点时,那么这个关系就不成立了,因此不能固定地进行计算

但这种获胜关系依然是正确的,同时考虑到距离叶子节点距离为1是容易且确定的,在树形结构不确定的情况下,可以考虑用dfs遍历整棵树,这样对遍历到的节点逐个计算合法的方案数贡献,同时对于不同可能的树形结构,可以通过对遍历到的节点交换父子关系进行计算,因为移动一定是沿着这条父子边移动的,于是交换计算就相当于更换了树形结构,为了应用我们得到的获胜关系,不妨就以a移动一步后的情况进行考虑

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    vector<int> d(n,2);

    for(int i=0;i<n;i++){

        if(adj[i].size()==1){

            d[i]=0;

            int x=adj[i][0];

            d[x]=min(d[x],1);

        }

    }

    int tot1=count(d.begin(),d.end(),1);

    int tot2=count(d.begin(),d.end(),2);

    vector<int> siz1(n),siz2(n),p(n,-1);

    i64 ans=0;

    auto dfs=[&](auto self,int x)->void{

        siz2[x]=(d[x]==2);

        siz1[x]=(d[x]==1);

        for(auto y:adj[x]){

            if(y==p[x]){

                continue;

            }

            p[y]=x;

            self(self,y);

            siz2[x]+=siz2[y];

            siz1[x]+=siz1[y];

            if(d[x]==0){

                ans+=siz1[y]+siz2[y];

            }else if(d[y]==1){

                ans+=siz2[y];

            }

            if(d[y]==0){ // another side

                ans+=tot1-siz1[y]+tot2-siz2[y];

            }else if(d[x]==1){

                ans+=tot2-siz2[y];

            }

        }

    };

    dfs(dfs,0);

    cout<<ans<<'\n';

}

 

 

Cf2031 E

给定一个有根树,求最小的完美二叉树深度d,使其能经过若干次删点操作后和另一棵树同构

删点操作为:选择一个非根节点u,删去点u,将u的所有儿子节点和u的父亲连边

问题具有局部最优性,于是考虑树形dp

显然我们只能够从被给定的树形结构进行倒退,也就是通过儿子节点的f来求出当前节点的f,于是可以列出一些情况进行模拟

令dp[u]表示通过删点操作与以u为根的子树同构的满二叉树高度最小值

考虑转移,对于任意节点u,当已知所有儿子对应的dp[v]时,dp[u]可以贪心求得

每次选取所有儿子中二叉树高度最小的两个,设为v1和v2,将这两颗二叉树进行合并,合并后的二叉树大小为max(dp[v1],dp[v2])+1,不断进行合并操作直到只剩下一棵树即可,过程可用优先队列实现

同时要特判节点只有一个儿子的情况

void solve(){

    int n;

    cin>>n;

    vector<int> p(n);

    p[0]=-1;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        cin>>p[i];

        p[i]--;

        adj[p[i]].emplace_back(i);

    }

    vector<int> dp(n);

    auto dfs=[&](auto self,int x)->void{

        priority_queue<int,vector<int>,greater<>>a;

        for(auto y:adj[x]){

            self(self,y);

            a.push(dp[y]);

        }

        if(a.empty()){

            dp[x]=0;

        }else if(a.size()==1){

            dp[x]=a.top()+1;

        }else{

            while(a.size()>1){

                int x=a.top();

                a.pop();

                int y=a.top();

                a.pop();

                a.push(y+1);

            }

            dp[x]=a.top();

        }

    };

    dfs(dfs,0);

    cout<<dp[0]<<'\n';

}

 

 

cf 1975D

从简单情况入手,即Pa,Pb都到达了同一个顶点,那个顶点被染成了蓝色,在这之后的移动过程中我们可以忽略先将顶点涂成红色,然后再将其涂成蓝色的过程

我们将第一个被涂成蓝色的顶点称为 r。那么不难发现, Pa比 Pb更早到达这个顶点。考虑到 Pb、 Pa在到达 r 后的所有后续运动, Pb可以逐一恢复这些运动,那么 Pb将经过所有被涂成红色的顶点

如果我们知道哪个顶点是 r,这将是一个经典问题,假设树上离 r最远的顶点的距离是 d ,那么答案就是 2(n−1)−d ;考虑遍历每个节点都要经过两次它到它父亲的边,即进入子树和离开子树,如果最后回到原点,答案显然为边数,但不需要,所以我们考虑减去回去的步数,显然为某一点到原点的路程,然后取最大,使得我们走的步数最小

然后我们考虑最开始两个节点并不在同一节点上,此时就需要让 Pa和 Pb尽可能快地移动到同一个节点上,即选取的第一个被染成蓝色的节点r必须与这两个顶点相互靠,因此我们选取从a到b直接路径上的中间节点。如果另一个顶点作为 r ,虽然 d的值可能会增加,但是 d的值每增加 1 , Pa和 Pb相遇的时间也会至少增加 1,所以总时间并不会减少

vector<int> g[N];

int dep[N],f[N],mx,n,a,b;

void dfs(int x,int fa){//一次dfs求出数的深度,以及距离x最远的节点

    dep[x]=dep[fa]+1;

    mx = max(mx,dep[x]);

    f[x]=fa;

    for(auto i:g[x]){

        if(i==fa)continue;

        dfs(i,x);

    }

}

vector<int> move(int x,int y){//找从一个节点移动到另一个节点的路径

    if (dep[x] > dep[y]) swap(x, y);

    vector<int> track,ano;

    int tmp = dep[y] - dep[x], ans = 0;

    track.push_back(y);

    while(tmp--){//先移动到同一深度

        y = f[y];

        track.push_back(y);

    }

    if (y == x) return track;

    ano.push_back(x);

    while (f[x] != f[y]) {//同时向上走,直到将要达到公共祖先节点

        x = f[x];

        y = f[y];

        ano.push_back(x);

        track.push_back(y);

    }

    track.push_back(f[y]);

    reverse(ano.begin(),ano.end());

    for(auto i:ano)track.push_back(i);

    return track;

}

void solve() {

    dep[0]=-1;

    mx = -1;

    cin>>n;

    for(int i=1;i<=n;i++) g[i].clear();

    cin>>a>>b;

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        g[u].push_back(v);

        g[v].push_back(u);

    }

    if(a==b){//如果初始就是在同一个节点,那么就是从这个节点开始移动

        dfs(a,0);

        cout<<2*(n-1)-mx<<"\n";

        return;

    }

    dfs(1,0);

    //找从a到b的路径

    auto tr = move(a,b);

    int m = tr.size();

    if(tr[0]!=a) reverse(tr.begin(),tr.end());

    int x = tr[(m-1)/2];

    mx = -1;

    dfs(x,0);//从第一个相遇节点开始移动

    cout<<2*(n-1)-mx+(m-1-(m-1)/2)<<"\n";

}

 

2024CCPC女生赛 E

有一棵N个点的树,给定每个点的儿子子树的重心,还原出一棵符合条件的树

注意到重心的条件其实并不重要,只需要知道它是子树中的一个点,然后找到这个点所在连通块的根即可

根据题目中保证的父亲的节点的编号一定小于它的编号,可以逆序进行操作,就可以从下到上依次连边,使用并查集维护连通块以及连通块的根,这个连通块所在的根就是整个连通块的最小值

struct DSU{

    //

    bool merge(int x,int y){

        x=find(x);

        y=find(y);

        if(x==y){

            return false;

        }

        siz[x]+=siz[y];

        f[x<y?y:x]=x<y?x:y;

        return true;

    }

};

 

void solve(){

    int n;

    cin>>n;

    DSU dsu(n);

    vector<int> siz(n);

    vector<vector<int>> a(n);

    for(int i=0;i<n;i++){

        cin>>siz[i];

        a[i].resize(siz[i]);

        for(int j=0;j<siz[i];j++){

            cin>>a[i][j];

            a[i][j]--;

        }

    }

    for(int i=n-1;i>=0;i--){

        if(siz[i]){

            for(auto v:a[i]){

                cout<<(i+1)<<' '<<(dsu.find(v)+1)<<'\n';

                dsu.merge(i,v);

            }

        }

    }

}

 


 

cf1994 E

因为每棵树的总节点数是已知的,并且操作数是任意的,因此对于每棵树不需要关心具体的操作,因为首先所有的或运算的结果一定是小于等于树的总结点数n的,也就是考虑所有小于等于n的数进行或运算求最值

又选取更小的子树进行处理一定会使得高位的1发生变化,因此我们先优先保证高位是1,然后,可以每次都只选择叶子节点删除,这样总的操作完成之后能够令最高位以下的每个bite位上都是1

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        for(int j=1;j<a[i];j++){

            int x;

            cin>>x;

        }

    }

    vector<int> vec[20];

    for(int i=0;i<n;i++){

        vec[__lg(a[i])].emplace_back(a[i]);

    }

    int ans=0;

    for(int i=19;i>=0;i--){

        if(vec[i].size()>=2){

            //这一位上有两个数是1,一定能保证这一位及以下都是1

            ans|=(1<<(i+1))-1;

            break;

        }

        if(vec[i].size()==1){

            if(ans>>i & 1){

                //已经是1,当前数可用于贡献低位的1

                ans|=(1<<i)-1;

                break;

            }

            //否则优先保证高位是1

            ans|=1<<i;

            //处理剩余的数字

            int x=vec[i][0]^(1<<i);

            if(x>0){

                vec[__lg(x)].emplace_back(x);

            }

        }

    }

    cout<<ans<<'\n';

}

 

cf2007D

给定一棵树,点权为01,定义一个叶子的权值为:考虑从根到叶子的这条路径的点权组成的字符串,权值为其中01作为连续子串出现次数减去10作为连续子串出现次数;定义树的价值为权值非零的叶子个数

现在一些点权变为?,博弈的两人分别填充,目标分别是要最大化和最小化树的价值,求最终树的价值

对于010和101或者0010000或者1001都是10和01串相同,因为连续的1或者0并不对10和01的计数产生影响,因此考虑将连续的0和连续的1进行压缩,压缩后1后面必定是0,0后面必定是1,因此可以通过首尾的字符就确定整串,通过观察可知中间部分不计入贡献,例如001000111就变成了0101,发现中间的1 0的贡献为0,而在边界的1 0有贡献

于是一条路径计入答案当且仅当叶子和根权值不同,所以只和根和叶子的权值有关,若根的权值确定,则策略显然;若根的权值不确定,一个思路是先看叶子节点填过的0,1哪个多,按自己的目标填

但这样操作当叶子节点的0,1个数相同时会出错,因为先填根的人必然会亏,因此此时先填非根非叶子节点,考虑问号个数的奇偶性即可,路径上的?决定谁先手

void solve(){

    int n;

    cin>>n;

    vector<int> deg(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--;

        v--;

        deg[u]++;

        deg[v]++;

    }

    string s;

    cin>>s;

    int cnt[2]{},cntq=0;

    for(int i=1;i<n;i++){

        if(deg[i]>1){

            continue;

        }

        if(s[i]=='?'){

            cntq++;

        }else{

            cnt[s[i]-'0']++;

        }

    }

    int ans=-1;

    if(s[0]!='?'){

        ans=cnt[(s[0]-'0')^1]+(cntq+1)/2;

    }else{

        ans=max(cnt[0],cnt[1])+cntq/2;

        if((count(s.begin(),s.end(),'?')-cntq)%2==0 && cnt[0]==cnt[1]){

            ans=max(ans,cnt[0]+(cntq+1)/2);

        }

    }

    cout<<ans<<'\n';

}

 

cf2007E

给定一棵dfs序标号的树,每条边有不确定的非负整数边权,但只知道边权和,定义fi为当前边权的情况下,i与(i mod n)+1的距离的最大值,每次给定一条边的边权,求此时所有fi的和(并且在计算i和i+1的距离和j和j+1的距离时树上边的边权分配可能不同)

因为边权非负,因此我们可以将所有没有被分配的边都挪到自己的路径上,剩下边权的未知的边都赋值为0,并且因为对于不同的i没有要求边的边权分配方案相同,因此我们每次都可以这样进行操作,例如,初始时的最大值就是n*w

简单来说对于每个path,其收益为w-路径外已经确定的边权和,特别的,如果路径上所有边都已经确定了,那么其值就是路径的权值和

同时,后面每增加一条边,除了该边出现的两条路径,所有的路径的值都会-2f

由于树是按照dfs序进行标号的,因此最终的n条路径中(i->i+1),每条边都恰好会被经过两次(有点类似于括号序列),于是可以记录每个路径上的边被覆盖的次数

由于给的是dfs序,因此lca(i,(i mod n)+1)=fa(i+1),因此可以处理出每条路径的边的数目

void solve(){

    int n;

    i64 w;

    cin>>n>>w;

    vector<int> p(n);

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        cin>>p[i];

        p[i]--;

        adj[p[i]].emplace_back(i);

    }

    int cur=0;

    vector<vector<int>> f(n);//记录会经过这条边的路径的端点i

    vector<int> cnt(n);//i到(i+1)mod n之间边的数目

    auto dfs=[&](auto self,int x)->void{

        cur++;//cur是当前的序号

        for(auto y:adj[x]){

            f[y].emplace_back(cur-1);

            cnt[cur-1]++;

            self(self,y);

            f[y].emplace_back(cur-1);

            cnt[cur-1]++;

            //该点的父节点点以及子树中最后一个被遍历到的点

        }

    };

    dfs(dfs,0);

    i64 sum=0;//被确定的边权和

    int tot=n;//没有被确定的路径数目

    for(int i=1;i<n;i++){

        int x;

        i64 y;

        cin>>x>>y;

        x--;

        for(auto j:f[x]){

            cnt[j]--;

            if(cnt[j]==0){

                tot--;

            }

        }

        sum+=y;

        cout<<w*tot-sum*(tot-2)<<' ';//每条确定边对于不在的路径都会使得最值-y

    }

    cout<<'\n';

}

 

 

cf2019E

给定一棵树,每次操作可以删除一个叶子节点以及与其相连的边,问最少经过多少次操作之后能够使得树中所有节点的深度相同

首先,我们并不能简单地计算出要删除的叶子节点的个数,也就是说,删除的叶子节点并不是小于该深度的叶子节点数再加上大于该深度的所有节点,因为删除叶子节点后,可能会有新的叶子节点产生,例如它的父节点这有叶子节点这一个儿子

既然直接计算删除的节点比较困难,那么可以考虑应当被保留的节点

我们首先可以判断得到的是对于一个最终作为答案的深度,我们需要在树中保留深度为d的所有节点以及他们的祖先节点

这些节点满足:它们的深度ai<=d,并且它们的子树(bi)中节点的最大深度>=d

(不要去考虑在需要保留的链上逐个判断,也就是从叶子向上判断,这是比较困难的,因为dfs是自上而下的,因此一般的树形结构都是考虑能否在父节点处进行操作,判断一个子树是否需要被保留就看它的最大深度是否达到了我们的要求,不然它没有最终需要被保留的节点那么这一个分支就一定是会被优化的)

由此可以再反推出一个点会被删除的条件,即深度i<ai,或者深度i>mx[u],这又是一个区间的操作,因此可以用差分数组来统计,所有节点判断完成后再做一次前缀和得到的结果就是每个深度要操作的次数

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=0;i<n-1;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    vector<int> dep(n),mx(n);

    vector<int> f(n);

    auto dfs=[&](auto self,int u,int p)->void{

        mx[u]=dep[u];

        for(auto v:adj[u]){

            if(v==p) continue;

            dep[v]=dep[u]+1;

            self(self,v,u);

            mx[u]=max(mx[u],mx[v]);

        }

        f[0]++;//对于0到dep[u],这个点都是要被删除的

        f[dep[u]]--;

        if(mx[u]+1<n){

            f[mx[u]+1]++;//对于mx[u]+1的深度,这棵子树也会被剔除

        }

    };

    dfs(dfs,0,-1);

    for(int i=1;i<n;i++){

        f[i]+=f[i-1];

    }

    int ans=*min_element(f.begin(),f.end());

    cout<<ans<<'\n';

}

 

 

最长链(树dp)

杭电24多校2 1006

题意:有一个n个节点的有根树,需要从1号节点走到任意一个叶子节点,每次都有pi/15的可能性向任意一个儿子节点移动,问期望最多可以在这棵树上停留多久

相当于每个点有一个期望的停留时间,然后找最长链使得期望时间最长,同时这条链的一段为根节点

constexpr int d=360360;

//分母个数:8*9*5*7*11*13

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        --u,--v;;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    vector<int> p(n);

    for(int i=0;i<n;i++){

        cin>>p[i];

    }

    vector<i64> dp(n);

    auto dfs=[&](auto &&self,int x,int fa)->void{

        for(auto y:adj[x]){

            if(y==fa) continue;

            self(self,y,x);

            dp[x]=max(dp[x],dp[y]);

        }

        dp[x]+=d/p[x]*15;

    };

    dfs(dfs,0,-1);

    i64 a=dp[0],b=d;

    i64 g=__gcd(a,b);

    a/=g,b/=g;

    cout<<a<<'/'<<b<<'\n';

}

 

prufer序列

一个树的prufer序列为:每次删除编号最小的叶子,并且把相邻的点(父节点)加进序列末尾,直到只剩两个点为止(用堆可以做到nlogn复杂度)

对于所有的nn-2种prufer序列,都可以唯一还原出一棵子树,也说明n个点有nn-2棵无根树

一般用来计数,尤其是带度数要求的(每个点的度数为prufer序列出现次数+1)

prufer序列将带标号n个节点的数用[1,n]中的n-2个整数表示,也可以看作完全图的生成树与数列之间的双射(由下面的构造可以看出)

线性构造

本质是维护一个指针指向要删除的节点,叶节点数是非严格单调递减的,删去一个叶节点,节点总数要么不变要么减少1

因此可以维护一个指针,初始时p指向编号最小的叶节点,同时维护每个点的度数,方便直到删除节点时是否会出现新的叶节点,如果产生记为x,比较两者的大小关系,如果新产生的小于指向指针指向的节点,则直接删除x,并且继续判断,否则p自增找到下一个节点

// 从原文摘的代码,同样以 0 为起点 OI-wiKi

vector<vector<int>> adj;

vector<int> parent;

void dfs(int v) {

  for (int u : adj[v]) {

    if (u != parent[v]) parent[u] = v, dfs(u);

  }

}

 

vector<int> pruefer() {

  int n = adj.size();

  parent.resize(n), parent[n - 1] = -1;

  dfs(n - 1);

  int ptr = -1;

  vector<int> degree(n);

  for (int i = 0; i < n; i++) {

    degree[i] = adj[i].size();

    if (degree[i] == 1 && ptr == -1) ptr = i;

  }

  vector<int> code(n - 2);

  int leaf = ptr;

  for (int i = 0; i < n - 2; i++) {

    int next = parent[leaf];

    code[i] = next;

    if (--degree[next] == 1 && next < ptr) {

      leaf = next;

    } else {

      ptr++;

      while (degree[ptr] != 1) ptr++;

      leaf = ptr;

    }

  }

  return code;

}

构造完prufer序列后会剩下两个节点,其中一个一定是编号最大的点

每个节点在序列中的出现次数是度数-1(没有出现的就是叶节点)

用prufer序列重建树是类似的

根据原序列的性质,可以得到原树上每个点的度数,同时在未出现的点(叶子结点)中,也容易得到编号最小的叶节点,而这个节点显然也和prufer序列的第一个数连接,因此可以同时删除这两个节点的度数,之后的操作同理,用堆nlogn

同样可以线性构造,删除度数的时候可能会出现新的叶节点,因此判断这个叶节点与指针p的大小关系

vector<pair<int, int>> pruefer_decode(vector<int> const& code) {

  int n = code.size() + 2;

  vector<int> degree(n, 1);

  for (int i : code) degree[i]++;

  int ptr = 0;

  while (degree[ptr] != 1) ptr++;

  int leaf = ptr;

  vector<pair<int, int>> edges;

  for (int v : code) {

    edges.emplace_back(leaf, v);

    if (--degree[v] == 1 && v < ptr) {

      leaf = v;

    } else {

      ptr++;

      while (degree[ptr] != 1) ptr++;

      leaf = ptr;

    }

  }

  edges.emplace_back(leaf, n - 1);

  return edges;

}

其余的结论:一个n个点m条边的带标号无向图有k个连通块,若添加k-1条边可以将整个图连通,求方案数,为 (si表示每个连通块的点数)

 

cf156D

题意转化后就是上面求方案数的问题

初始有若干个连通块,问有多少种方案添加最少边可以将这张图连通

void solve(){

    i64 n,m;

    cin>>n>>m>>mod;

    DSU dsu(n);

    for(int i=1;i<=m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        dsu.merge(u,v);

    }

    if(mod==1){

        cout<<0<<'\n';

        return;

    }

    int k=0;

    i64 ans=1ll;

    for(int i=0;i<n;i++){

        if(i==dsu.find(i)){

            k++;

            ans*=dsu.size(i)%mod;

            ans%=mod;

        }

    }

    if(k==1){

        cout<<1<<'\n';

        return;

    }

    ans=qpow(n,k-2)*ans;

    ans%=mod;

    cout<<ans<<'\n';

}

 

 

树上排列结论:

给定一个树,标上1-n

要求每个点比父亲小/大,

类似于堆的性质

方案数是 (分母是子树大小的乘积)

 

生成树结论

每个节点的度数分别是d1,d2...dn

显然sigma(di)=2n-2

这样生成树的方案数是

 

霍夫曼树

设二叉树有n个带权叶节点,从根节点到各叶节点的路径长度与相应叶节点权值的乘积之和称为树的带权路径长度(WPL)

给定一组具有确定权值的叶节点,可以构造出不同的二叉树,其中,WPL最小的二叉树称为霍夫曼树

对于霍夫曼树来说,其叶节点权值越小,离根越远,叶节点权值越大,离根越近,此外其仅有叶节点的度为0,其他节点度均为2

霍夫曼算法用于构造一棵霍夫曼树

初始化:由给定的n个权值构造n棵只有一个根节点的二叉树,得到一个二叉树集合F 。

选取与合并:从二叉树集合F中选取根节点权值最小的两棵二叉树分别作为左右子树构造一棵新的二叉树,这棵新二叉树的根节点的权值为其左、右子树根结点的权值和。

删除与加入:从F中删除作为左、右子树的两棵二叉树,并将新建立的二叉树加入到F中。

重复 2、3 步,当集合中只剩下一棵二叉树时,这棵二叉树就是霍夫曼树。

typedef struct HNode {

int weight;

HNode *lchild, *rchild;

} * Htree;

 

Htree createHuffmanTree(int arr[], int n) {

Htree forest[N];

Htree root = NULL;

for (int i = 0; i < n; i++) { // 将所有点存入森林

Htree temp;

temp = (Htree)malloc(sizeof(HNode));

temp->weight = arr[i];

temp->lchild = temp->rchild = NULL;

forest[i] = temp;

}

for (int i = 1; i < n; i++) { // n-1 次循环建哈夫曼树

int minn = -1, minnSub; // minn 为最小值树根下标,minnsub 为次小值树根下标

for (int j = 0; j < n; j++) {

if (forest[j] != NULL && minn == -1) {

minn = j;

continue;

}

if (forest[j] != NULL) {

minnSub = j;

break;

}

}

for (int j = minnSub; j < n; j++) { // 根据 minn 与 minnSub 赋值

if (forest[j] != NULL) {

if (forest[j]->weight < forest[minn]->weight) {

minnSub = minn;

minn = j;

}

else if (forest[j]->weight < forest[minnSub]->weight) {

minnSub = j;

}

}

}

// 建新树

root = (Htree)malloc(sizeof(HNode));

root->weight = forest[minn]->weight + forest[minnSub]->weight;

root->lchild = forest[minn];

root->rchild = forest[minnSub];

forest[minn] = root; // 指向新树的指针赋给 minn 位置

forest[minnSub] = NULL; // minnSub 位置为空

}

return root;

}

求霍夫曼数的WPL

typedef struct HNode {

int weight;

HNode *lchild, *rchild;

} * Htree;

int getWPL(Htree root, int len) { // 递归实现,对于已经建好的霍夫曼树,求 WPL

if (root == NULL)

return 0;

else {

if (root->lchild == NULL && root->rchild == NULL) // 叶节点

return root->weight * len;

else {

int left = getWPL(root->lchild, len + 1);

int right = getWPL(root->rchild, len + 1);

return left + right;

}

}

}

未建好的霍夫曼树

int getWPL(int arr[], int n) { // 对于未建好的霍夫曼树,直接求其 WPL

priority_queue<int, vector<int>, greater<int>> huffman; // 小根堆

for (int i = 0; i < n; i++) huffman.push(arr[i]);

int res = 0;

for (int i = 0; i < n - 1; i++) {

int x = huffman.top();

huffman.pop();

int y = huffman.top();

huffman.pop();

int temp = x + y;

res += temp;

huffman.push(temp);

}

return res;

}

 

K-D Tree

KD树是一种可以高效处理k维空间信息的数据结构

在节点数远大于2k时,应用KD树的时间效率很好

并且在算法竞赛的题目中,一般有k=2(P1256,P4148,P1429)

K-D Tree具有二叉搜索树的形态,二叉搜索树上每个节点都对应k维空间内的一个点,其每个子树中的点都在一个k维的超长方体内,这个超长方体内的所有点也都在这个子树中

建树:
对已知k维空间内的n个不同的点的坐标,要将其构建成一颗KD树,步骤为

如果当前超长方体内只有一个点,返回这个点

选择一个维度,将当前超长方体按照这个维度分成两个超长方体

选择切割点:在选择的维度上选择一个点,这一维度上的值小于这个点的归入一个超长方体(左子树),其余的归于另一个超长方体(右子树)

将选择的点作为这棵子树的根节点,递归分割出的左右子树,维护子树的信息

 

因为按上述步骤每次选择维度以及选择点都是任意的,会导致复杂度无法保证,因此规定每次轮流选择k个维度,以保证在任意连续k层里每个维度都被切割到,每次选择切割点时尽量选择该维度上的中位数,这样可以保证每次分成的左右子树的大小尽可能相等

在 algorithm 库中,有一个实现相同功能的函数 nth_element(),要找到 s[l] 和 s[r] 之间的值按照排序规则 cmp 排序后在 s[mid] 位置上的值,并保证 s[mid] 左边的值小于 s[mid],右边的值大于 s[mid],只需写 nth_element(s+l,s+mid,s+r+1,cmp)

 

高维空间上的操作:记录每个节点子树内每一维度上的坐标的最大值和最小值,如果当前子树对应的矩形与所求的矩形没有交点就不继续搜索其子树;...

 

插入/删除

常见的维护方式:

根号重构:每B次插入/删除进行一次重构

二进制分组:考虑维护若干棵   的自然数次幂的 k-D Tree,满足这些树的大小之和为  。

插入的时候,新增一棵大小为   的 k-D Tree,然后不断将相同大小的树合并(直接拍扁重构)。实现的时候可以只重构一次;查询的时候直接在每棵树上面查询(P4148)

 

/*

给定n*m01矩阵,求所有位置的距离最近的1的曼哈顿距离

这个东西显然可以用kdtree去做

把1的点坐标插入到kdtree中,0完全可以不管它

之后所有的坐标都在kdtree中查询

*/

 

const int N=190,K=2,INF=2147483647;

int n,m,f,cnt;

struct point    //结构体  k维上的点,实际上是2维,即k=2

{

         int d[K];

         inline point(int x=0,int y=0){d[0]=x;d[1]=y;} //构造函数,没有赋值默认0,0

         inline const bool operator<(const point &p)const

         {

                  return d[f]<p.d[f];

         }

         //计算曼哈顿距离

         inline const friend int manhattan(const point &x,const point &y)

         {

                  int dis=0;

                  for (int i=0;i<K;i++)dis+=abs(x.d[i]-y.d[i]);

                  return dis;

         }

}a[N*N];  //最多有N*N个点

template<int k>class KD_Tree

{

         private:

                  struct tree

                  {

                          tree *son[2];//左右子树

                          point range,mn,mx;

                          inline const void pushup()

                          {

                                   for (int i=0;i<k;i++)//记录每个节点子树上每一维度的坐标的最大值和最小值

                                            mx.d[i]=max(range.d[i],max(son[0]->mx.d[i],son[1]->mx.d[i])),

                                            mn.d[i]=min(range.d[i],min(son[0]->mn.d[i],son[1]->mn.d[i]));

                          }

                          inline const int fmin(const point &x)

                          {  //有交集:min<x || max>x  所以只要f不为0就不是完全包含

                                   int f=0;

                                   for (int i=0;i<k;i++)

                                            f+=max(mn.d[i]-x.d[i],0)+max(x.d[i]-mx.d[i],0);

                                   return f;

                          }

                  }*root,memory_pool[N*N],*tail,*null;

                  inline const void init()

                  {

                          tail=memory_pool;

                           null=tail++;

                          null->son[0]=null->son[1]=null;

                          for (int i=0;i<k;i++)null->mn=INF,null->mx=-INF;

                  }

                  inline tree *spawn(const point &x)

                  {

                          tree *p=tail++;

                          p->range=p->mn=p->mx=x;

                          p->son[0]=p->son[1]=null;

                          return p;

                  }

                  inline tree *build(int l,int r,int d)

                  {//d是当前选择的维度,为了保证复杂度,每次轮流选择k个维度

                          if (l>r)return null;

                          int mid=l+r>>1;f=d;

                          nth_element(a+l,a+mid,a+r+1);

                          tree *p=spawn(a[mid]);

                          if (l==r)return p;

                          p->son[0]=build(l,mid-1,(d+1)%k);

                          p->son[1]=build(mid+1,r,(d+1)%k);

                          p->pushup();

                          return p;

                  }

                  int mn;

                  inline const void query(tree *p,const point &x)

                  {

                          mn=min(mn,manhattan(p->range,x));

                          int f[2]={INF,INF};

                          if (p->son[0]!=null)f[0]=p->son[0]->fmin(x);

                          if (p->son[1]!=null)f[1]=p->son[1]->fmin(x);

                          bool t=f[0]>=f[1];

                          if (f[t]<mn)query(p->son[t],x);t^=1;

                          if (f[t]<mn)query(p->son[t],x);

                  }

         public:

                  inline const void build()

                  {

                          init();root=build(1,cnt,0);

                  }

                  inline const int query(int x,int y)

                  {

                          mn=INF;query(root,point(x,y));return mn;

                  }

};

KD_Tree<K>kdt;

int main()

{

         scanf("%d%d",&n,&m);char x;

         for (int i=1;i<=n;i++)

                  for (int j=1;j<=m;j++)

                          if (scanf(" %c",&x),x^48)

                                   a[++cnt]=point(i,j);

         kdt.build();

         for (int i=1;i<=n;putchar('\n'),i++)

                  for (int j=1;j<=m;j++)

                          printf("%d ",kdt.query(i,j));

         return 0;

}

 

树状数组(Fenwick

有些时候可以通过复杂度来判断算法,复杂度要优于O(n),即去考虑log

树状数组能解决的问题是线段树能解决的问题的子集:树状数组能做的,线段树一定能做;线段树能做的,树状数组不一定可以。然而,树状数组的代码要远比线段树短,时间效率常数也更小,因此仍有学习价值

普通树状数组维护的信息及运算要满足结合律且可差分,如加法(和)、乘法(积)、异或等

模意义下的乘法若要可差分,需保证每个数都存在逆元(模数为质数时一定存在);

例如  max不可差分,所以不能用普通树状数组处理

类似的,树状数组的下标也是从1开始的,因此开数组时其大小是原数组的大小+1

树状数组自然也可以用于维护一些比较简单的数据,例如一边遍历一个数组,一边添加数据到一个树状数组中,此时树状数组中维护的数据就是该下标下有几个数(小于该下标对应的数的个数)(T2426,注意class的写法,定义树状数组结构)

可以用于维护一个区间(即i<=x<=j内,符合树状数组的特征,查询区间个数)内有几个元素被删除(可以将被删除的元素加到树状数组中,这样这个区间内的元素个数就是被删除的个数,重点就是要把需求与现有的数据结构去联想)(T2659,数据量的大小是确定的,就更适合使用树状数组了,相当于每个数的位置都对应好了,数的规模是确定的,这样再要以较高的效率去查找这个区间内有几个数已经被标记为“1”就更容易想到树状数组了,换句话说,这些题目中所谓的树状数组中加入,并非真的扩大了树状数组的规模,而是在对应的节点(下标)上进行了数据的更改,这在题目的代码中也有所体现,在定义class完毕开始解题时,往往都有一句  BIT t(n + 1);  定下树状数组的大小,从这个层面上,树状数组更像是一种支持快速查询,修改区间问题的哈希数组,相当于一个哈希数组,已经被删除了就将对应的value值修改为1)

树状数组中的节点是用下标去对应,并不是用nums中实际的值,不一定是原数组的下标,但一定是对应一个数组的下标,如T2426中是小于下标对应的数的个数,就专门为此进行了离散化,将离散化得到的数组下标去与树状数组相匹配,而T2659中就是用的原数组的下标,因为维护的值是关于数组区间的,这样去想,那树状数组的下标对应的肯定是原数组的下标,因为我们要截取一个区间肯定使用下标去截取而不是用实际的元素值去截取,包括树状数组中越界的判断,也是与数组的长度去比较

相当于新建了一个数组去标记维护原数组的某些值

 

但树状数组因为查找或添加值都要反复地跳跃检索,因此数据量较大时容易超时,所以有些数据在二分查找之后依据下标直接可以得到的就不需要再用树状数组去维护了(如有序数组中,小于某一值的数有几个,T2300),这题与之前T2426的区别在于,T2426对于小于这个数的元素还有另外的要求,就是其在原数组的下标也要小于这个数的下标,因此不能用排序后的数组下标来判断有几个数小于,因此得一遍遍历,一遍添加,来保证当前树状数组中存在的元素下标都是小于当前数的

 

树状数组查询区间小于k的数

cf789c

题干中原来的限制看上去较多,需要四个数字的下标,以及对应元素的大小关系,但实际上可以进行缩减,可以只将有直接大小关系的元素提出,例如b,c,因此接下来需要满足下标b<c,以及在一些区间中找小于b,c的元素值即可

对于b,c的下标位置显然只能进行枚举,然后问题就变成了求在 [1,b-1] 中小于 p[c] 的个数,和在 [c+1,n] 中小于 p[b] 的个数

离线加树状数组的复杂度是O(n2logn),但当复杂度允许的情况下,也可以用O(n2)的数据去处理

定义pre[i][j]为区间[1,i]中小于等于j的个数

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++)pre[i][j] += pre[i - 1][j];//转移   
for (int j = p[i]; j <= n; j++)pre[i][j]++;//对于大于等于p[i]的数小于的个数加1}

 

 

管辖区间

c[x]管辖的一定是右边界是x的区间总信息,并且在树状数组中,规定c[x]管辖的区间长度为2^k,其中若二进制最低位为第0位,则k就是x二进制表示中,最低位1所在的二进制位数,这样2^k就恰好为二进制表示中,最低位的1以及后面所有0组成的数

lowbit:得到最低位的1:x&-x(注意lowbit指的不是1所在的位数,而是2^k)

因此去掉最低位的1的数为:x-= x&-x 或 x&=x-1

 

区间查询

任何一个区间查询都可以这样做:查询a[l...r]的和,就是a[l...r]的和减去a[1...l-1]的和,从而将区间问题转化为前缀问题,再差分即可

而前缀查询就是每次从当前管辖区间的右端点往前跳(跳管辖的长度,即lowbit(x)),跳到区间左端点的左一位,作为新区间的右端点

例如我们要维护的信息是和,直接令初始ans=0,然后每跳到一个c[x]就ans=ans+c[x]

int getsum(int x) { // a[1]..a[x]的和

int ans = 0;

while (x > 0) {

ans = ans + c[x];

x = x - lowbit(x);     //或 x&=x-1;

}

return ans;

}

查询区间和

P2068

询问单点值实际上是表示了小于等于该下标的数值的和,即树状数组是一个利用了二进制表示的前缀和数组

i64 query(int x){

    i64 res=0;

    for(int i=x;i>0;i-=lowbit(i))

        res+=tree[i];

    return res;

}

cout<<(query(c)-query(b-1))<<'\n';

 

差分及前缀和

P4939

对区间的修改利用差分转化为单点修改

然后利用树状数组的前缀和得到单点查询

int n,tree[10000001],m;

int lowbit(int i){

    return i&(-i);

}

int query(int x){

    int res=0;

    for(int i=x;i>0;i-=lowbit(i))

        res+=tree[i];

    return res;

}

void modify(int x,int k){

    for(int i=x;i<=n;i+=lowbit(i))

        tree[i]+=k;

}

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    cin>>n;

    cin>>m;

    for(int i=1;i<=m;i++){

        int a,b,c;

        cin>>c;

        if(c==1){

            cin>>a;

            cout<<query(a)<<'\n';

        }

        else {

            cin>>a>>b;

            modify(a,1);

            modify(b+1,-1);

        }

    }

    return 0;

}

P5057

翻转区间相当于对一个区间进行操作数上的区间加操作

又操作本质上是一个异或操作

因此可以对原来差分区间的后一个位置减一改为加一,因为这样相当于对该区间之后的元素操作了两次,相当于元素值没有操作

询问时仍然可以用前缀和查询将之前的差分操作转回到单点的数值

if(k==1){

            int x,y;

            cin>>x>>y;

            modify(x,1);

            modify(y+1,1);

        }else if(k==2){

            int x;

            cin>>x;

            cout<<(query(x)%2)<<'\n';

        }

P1438

因为我们树状数组初始设定的就是维护差分

因此对一开始输入的原数组也要转化成差分维护

因为有等差数列的添加

因此只使用一阶差分会t

因此考虑使用二阶差分

d指差分数组,d2指二阶差分数组

第一种情况,首项为k,公差为0

体现在一阶差分数组上,d[l]=d[l]+k,d[r+1]=d[r+1]-k

二阶差分数组是一阶差分数组的差分,于是d2[l]=d2[l]+k,d2[l+1]=d2[l+1]-k,d2[r+1]=d2[r+1]-k,d2[r+2]=d2[r+2]+k

第二种情况,首项为k(不妨先令其为0),公差为d

于是d[l+1]=d[l+1]+d,d[l+2]=d[l+2]+d...d[r]=d[r]+d

d[r+1]=d[r+1]-(r-l)*d
因此d2[l+1]=d2[l+1]+d,d2[r+1]=d2[r+1]-(r-l+1)*d (由-(r-l)*d-d得到),d2[r+2]=d2[r+2]+(r-l)*d

二阶前缀和,也就是原来的a数组,最暴力的解法是先求一遍前缀和再求一遍前缀和,显然超时

因为

即只需要维护d2[i]的前缀和以及d2[i]*i的前缀和即可

i64 n,tree1[100005],tree2[100005],m,tot;

inline int lowbit(int i){

    return i&(-i);

}

inline void modify(i64 x,i64 k){

    for(i64 i=x;i<=n;i+=lowbit(i)){

        tree1[i]+=k;

        tree2[i]+=k*x;

    }

}

inline i64 query(int x){

    i64 res=0;

    for(i64 i=x;i;i-=lowbit(i)){

        res+=(x+1)*tree1[i]-tree2[i];

    }

    return res;

}

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    cin>>n>>m;

    i64 pre=0;

    i64 pre1=0;

    for(int i=1;i<=n;i++){

        i64 x;

        cin>>x;

        modify(i,(x-pre)-pre1);

        pre1=x-pre;

        pre=x;    

    }

    for(int i=1;i<=m;i++){

        int op;

        cin>>op;

        if(op==1){

            int l,r,k,d;

            cin>>l>>r>>k>>d;

            modify(l,k);

            modify(l+1,d-k);

            modify(r+1,-(r-l+1)*d-k);

            modify(r+2,k+(r-l)*d);

        }else if(op==2){

            int x;

            cin>>x;

            cout<<query(x)<<'\n';  

        }

    }

    return 0;

}

 

杭电24多校3 1007

对于区间的操作,通过维护差分数组实现,同时只需要知道相邻的相对关系

可以利用线段树维护区间是否完全相同,单调递增(减),以及是否是单峰

也可以用差分相关的三个情况,正,负,相等(为0)进行维护

void solve() {

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<i64> d(n);

    for(int i=1;i<n;i++){

        d[i]=a[i]-a[i-1];

    }

    set<int> Z,P,N;

    auto work=[&](int i){

        Z.erase(i);

        P.erase(i);

        N.erase(i);

        if(d[i]>0){

            P.insert(i);

        }else if(d[i]<0){

            N.insert(i);

        }else{

            Z.insert(i);

        }

    };

    auto query=[&](int o,int l,int r)->bool{

        if(o==2){

            //全部相同,没有p和n

            return P.upper_bound(l)==P.lower_bound(r)&&N.upper_bound(l)==N.lower_bound(r);

        }else if(o==3){

            //严格增,没有z和n

            return Z.upper_bound(l)==Z.lower_bound(r)&&N.upper_bound(l)==N.lower_bound(r);

        }else if(o==4){

            //没有p和z

            return P.upper_bound(l)==P.lower_bound(r)&&Z.upper_bound(l)==Z.lower_bound(r);

        }else{

            //首先不能有相等的部分

            if(Z.upper_bound(l)!=Z.lower_bound(r)){

                return false;

            }//必须是0

            auto Pl=P.upper_bound(l);

            auto Pr=P.lower_bound(r);

            auto Nl=N.upper_bound(l);

            auto Nr=N.lower_bound(r);

            if(Pl==Pr || Nl==Nr){

                return false;

            }//要求严格,也就是正负都有

            //最后一个正要小于第一个负的

            return *prev(Pr)<*Nl;

            //pr是大于等于r的正值,其前一个是在l,r范围内的正值

        }

    };

    for(int i=1;i<n;i++){

        work(i);

    }

    int q;

    cin>>q;

    for(int i=1;i<=q;i++){

        int o,l,r;

        cin>>o>>l>>r;

        l--;

        if(o==1){

            int x;

            cin>>x;

            //区间修改只影响边界的差分情况

            if(l>0){

                d[l]+=x;

                work(l);

            }

            if(r<n){

                d[r]-=x;

                work(r);

            }

        }else{

            cout<<query(o,l,r)<<'\n';

        }

    }

}

 

 

Cf2056D

定义一个数组为好数组当且仅当该数组排序后的第(m+1)/2上取整和(m+1)/2下取整位置对应的元素相等,显然,奇数长度的数组一定是好数组

给定一个由n个整数组成的数组a,要求计算好子数组的数目

中位数相关的问题联想到确定中位数,然后将大于等于中位数的数设定为1,小于的数设定为1,计算整个数组的总和,若不是0,就说明这个元素不是正确的中位数(这种情况不需要再说明长度为偶数,因为长度为奇数的情况不可能和为0)

考虑一个子段[l,r]什么时候是坏的,必要条件是r-l+1也就是区间长度是偶数;其次,假设[l,r]的中位数是x,如果这个子段是坏的,一定有(r-l+1)/2个数小于等于x同时有(r-l+1)/2个数大于x(例如 1 2 3 4 5 6,3和4都满足这个情况,但如果是 1 2 3 3 4 5,就不只(r-l+1)/2个数小于等于中位数,奇数情况同理)

因为题目额外限定了数组元素的值域范围,因此可以从这里入手,枚举中位数是x时的答案,按照上面类似的计算思路,设定每个元素是-1还是1,只要有一个子段的和是0那么这个子段就是坏的,直接记录个数即可

复杂度O(nV),V是此处的值域,只有10

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]--;

    }

    i64 ans=0;

    for(int x=0;x<10;x++){

        vector<int> f(n+1),g(n+1);

        for(int i=0;i<n;i++){

            f[i+1]=f[i]+(a[i]>=x?1:-1);

            g[i+1]=g[i]+(a[i]<=x?1:-1);

        }

        vector<int> p(n+1);

        iota(p.begin(),p.end(),0);

        sort(p.begin(),p.end(),[&](int i,int j){

            if(f[i]!=f[j]){

                return f[i]<f[j];

            }

            return g[i]>g[j];

        });

        Fenwick<int> fen(2*n+1);

        for(auto i:p){

            ans+=fen.sum(n+g[i]);

            fen.add(n+g[i],1);

        }

    }

    cout<<ans<<"\n";

}

而记录为0的子段个数可以通过哈希表实现,依照以往的思路,就是记录当前数组的前缀和,如果当前的前缀和在之前已经出现过,那么就说明中间这一段是0,因为我们不仅是要判断是否出现过,还要记录对应的出现个数,因此用cnt数组来记录被选择的子段左端点个数

如果用unordered_map可能会出现内存超限等问题,因为数组长度有限,因此用2*n+1的数组来存储即可,同时避免出现负数,将前缀和的初始值设定为n

这样计算得到的是数组的坏子段个数,正确解只需要将总的可能数减一下即可

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]--;

    }

    i64 ans=0;

    for(int x=0;x<10;x++){

        vector<int> b(n);

        for(int i=0;i<n;i++){

            b[i]=a[i]>x?1:-1;

        }

        int sum=n;

        vector<int> pref(n);

        for(int i=0;i<n;i++){

            pref[i]=sum;

            sum+=b[i];

        }

        vector<int> cnt(2*n+1);

        sum=n;

        int j=0;

        for(int i=0;i<n;i++){

            if(a[i]==x){

                while(j<=i){

                    cnt[pref[j]]++;

                    j++;

                }

            }

            sum+=b[i];

            ans+=cnt[sum];

        }

    }

    ans=1ll*n*(n+1)/2-ans;

    cout<<ans<<"\n";

}

 

 

cf1997E

从1级开始,如果等级严格大于怪的等级怪会跑,每打k个怪会升一级,要求对于每种可能的k,会打哪些怪

假设要进行模拟,对于固定的k,打前k个>=1的,前k个>=2的...

要加快这个过程,可以用树状数组(用于快速检索区间中大于某个数的个数以及位置)

建一棵以原数组下标为x轴的树状数组,枚举每个i的时候保证梳妆数组内值保留了a[j]>=i的所有j(可以一开始把所有数插进去,然后枚举到i的时候删掉所有a[j]=i-1的数)

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<bool> ans(q);

    vector<vector<pair<int,int>>> ask(n+1);

    for(int j=0;j<q;j++){

        int i,x;

        cin>>i>>x;

        i--;

        ask[x].emplace_back(i,j);

    }

    //不能对于每个ask进行操作,要按照等级,也就是第几轮进行操作

    for(int k=1;k<=n;k++){

        sort(ask[k].begin(),ask[k].end());

    }

    vector<int> pos(n+1);

    vector<int> cur(n+1);

    Fenwick<int> fen(n);

    vector<vector<int>> vec(n+1);

    for(int i=0;i<n;i++){

        if(a[i]<=n){

            vec[a[i]].emplace_back(i);

        }

    }

    for(int i=0;i<n;i++){

        fen.add(i,1);

    }

    for(int t=1;t<=n;t++){

        for(int k=1;k<=n && n>k*(t-1);k++){

            if(pos[k]==n){

                continue;

            }

            //新的pos,是pos往后跳k个

            int npos=fen.select(fen.sum(pos[k])+k);

            int &i=cur[k];

            while(i<ask[k].size() && ask[k][i].first<npos){

                auto [x,j]=ask[k][i];

                ans[j]=(a[x]>=t);

                i++;

            }

            pos[k]=npos;

        }

        for(auto i:vec[t]){

            //处理完后将i去掉

            fen.add(i,-1);

        }

    }

    for(int i=0;i<q;i++){

        cout<<(ans[i]?"Yes":"No")<<'\n';

    }

}

 

 

树状数组的性质

对于任意正整数x,总能将x表示成s*2^(k+1)+2^k的形式,其中lowbit(x)=2^k

c[x]真包含于c[x+lowbit(x)],对于x<=y,要么有c[x]与c[y]不交,要么有c[x]包含于c[y](这里的交与包含都是指管辖范围的交与包含),对于任意的x<y<x+lowbit(x),有c[x]和c[y]不交

树状数组的数形态就是x向x+lowbit(x)连边得到的图,其中x+lowbit(x)是x的父节点

//注意,在考虑树状数组的树形态时,我们不考虑树状数组的实际大小,即我们认为这是一颗无限大的树,方便分析。而实际实现时,我们只需用到x<=n的c[x],其中n是原数组长度(这在单点修改以及建树中都能有所体现)

举例来直观化:

对于2^k的节点,如1、2、4、8等,它们的lowbit就是他们本身,所以他们涵盖的范围就是0-2^k,例如对于6=4+2,其lowbit就是2,因此其真包含于8,而对于7,6<7<6+2,7与6就不相交

 

单点修改

目标是要快速正确地维护c数组,为保证效率,我们只需遍历并修改管辖了a[x]的所有c[y],因为其他c并没有发生变化,因此修改的思路就是从x开始不断跳父亲,直到跳得超过了原数组长度为止

区间信息和单点修改的种类共同决定c[x’]的修改方式,且单点修改的自由性使得修改的种类和维护的信息不一定是同种运算,比如,若c[x’]维护区间和,修改种类是将a[x]赋值为p,可以考虑转化为将a[x]加上p-a[x]。如果是将a[x]乘上p,就考虑转化为a[x]加上 a[x]*p-a[x]

以c[x]维护区间和,单点加为例

void add(int x, int k) {

while (x <= n) { // 不能越界

c[x] = c[x] + k;

x = x + lowbit(x);

}

}

 

建树:

也就是根据最开始给出的序列,将树状数组建出来,一般可以直接转化为n此单点修改

O(n)建树(以维护区间和为例),但单次修改要不断跳跃,所以时间复杂度为O(nlogn)

法一:每一个节点值都是由与自己直接相连的儿子的值求和得到的,因此可以倒着考虑贡献,即每次确定完儿子的值后,用自己的值更新自己的直接父亲

void init() {

for (int i = 1; i <= n; ++i) {

t[i] += a[i];

int j = i + lowbit(i);

if (j <= n) t[j] += t[i];

}

}

 

法二:因为c[i]表示的区间是[i-lowbit(i)+1,i]所以可以预处理一个sum前缀和数组,再计算c数组

void init() {

for (int i = 1; i <= n; ++i) {

t[i] = sum[i] - sum[i - lowbit(i)];

}

}

 

类的定义:

class BIT {

    vector<int> tree;

public:

    BIT(int n) : tree(n) {}

 

    // 将下标 i 上的数加一

    void inc(int i) {

        while (i < tree.size()) {

            ++tree[i];

            i += i & -i;

        }

    }

 

    // 返回闭区间 [1, i] 的元素和

    int sum(int x) {

        int res = 0;

        while (x > 0) {

            res += tree[x];

            x &= x - 1;

        }

        return res;

    }

 

    // 返回闭区间 [left, right] 的元素和

    int query(int left, int right) {

        return sum(right) - sum(left - 1);

    }

};

 

 

或者直接在public中写

int sz;

vector<int> tr;

修改中的判断就变为i <= sz

 

然后在solution中写

sz = list.size();

tr.resize(sz + 10, -1);

 

对于形如nums[i]-nums[j]>=i-j,或nums1[i]-nums1[j]<=nums2[i]-nums2[j]+diff形式的式子,一般都考虑进行移项,将i,j放在同一边,从而构建出一个新的数组,原问题就变成了找数组中满足某一条件的元素,这样就将问题简化了(T2926,T2426,这两题还有一个相同的技巧就是要利用树状数组的下标就需要树状数组下标对应的原数组的相关值(可能是下标也可能是nums[i])都是正数,因此对于有负数存在的情况要利用离散化)

树状数组不止可以用来存储一个数组下标为右端点的区间内的某一数据,也可以用来存储与一个连续的数据区间相关的一些值(例如树状数组的编号实际代表的是数组b[i]的值域,而编号存储的就是b数组中小于等于这个编号对应的值的下标,特别的,如果我们是要求最值,那我们自然可以用dp来解决,或者说用一个数组来维护最大值,那么此时,编号存储的就是第一个小于等于这个编号的值(这里就想到可以对已经有序化的数组b进行二分查找)的下标所对应的dp[i],T2926)

 

处理合法数对问题:问有多少合法数对,暴力做法是n^2枚举所有组合,树状数组做法是,每读一个数,在权值树状数组上存起来,并且利用树状数组的区间查询,计算有多少可以和当前数配对的数。每个数都有一个能配对的范围,用这个范围决定查询区间。根据当前枚举的位置,也可以把树状数组中那些作用范围到不了当前位置的数删掉

牛客24寒假4H

在这道题里我们要统计的点对就是三角形的顶点和底边中点(当然也可以选别的,比如右下角顶点),作用范围就是这个顶点/底边中点,最远可以和多远的点组成三角形,对底边中点来说就是左右两侧底边长度的最小值,对顶点来说就是左右延伸出的斜边长度的最小值。这些我们可以先预处理,这样在枚举到时可以得到查询范围

然后枚举每一列的所有点,每读一个星号,就在树状数组上插入这个点,然后统计这个点和已存在的点能组成的合法点对(三角形)数。横坐标每移动一次,就把失效的底边中点删掉

 

底边中点与顶点一定是在同一列上,并且等腰三角形的形状是确定的,因此当对j是逆序遍历时,能保证有效范围内,可能中点的个数就是构成合法三角形的个数(不会包括三角形顶点上方的点),并且根据这个中点左右的有效长度,倒推出这个中点能对应到的最小的定点位置j0,当j小于j0时,这个中点显然不应该包括在有效的中点范围内,因此删除,而横坐标移动(i的遍历),则清空树状数组

#include <bits/stdc++.h>

using namespace std;

using i64=long long;

#define ll long long

typedef double ld;

typedef pair<int,int> pii;

typedef pair<ll,ll> pll;

typedef pair<double,int> pdi;

//const int N=1e5+10,INF=0x3f3f3f3f,mod = 998244353;

const int N=3e3+10;

char a[N][N];

int t[N],n,m,l[N][N],r[N][N],ul[N][N],ur[N][N];

int lowbit(int x){

    return x&(-x);

}

int sum(int x){

    int sum=0;

    while(x>=1){

        sum+=t[x];

        x-=lowbit(x);

    }

    return sum;

}

void add(int i,int x){

    while(i<=n){

        t[i]+=x;

        i+=lowbit(i);

    }

}

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    cin>>n>>m;

    for(int i=1;i<=n;i++){

        for(int j=1;j<=m;j++){

            cin>>a[i][j];

        }

    }

    for(int i=n;i>=1;i--){

        for(int j=1;j<=m;j++){

            if(a[i][j]=='*'){

                l[i][j]=l[i][j-1]+1;//底边中点向左最大长度

                ul[i][j]=ul[i+1][j-1]+1;//向左下斜边最大长度

                ur[i][j]=ur[i+1][j+1]+1;//右下斜边最大长度

            }

            else{

                l[i][j]=ur[i][j]=ul[i][j]=0;

            }

        }

        for(int j=m;j>=1;j--){

            if(a[i][j]=='*'){

                r[i][j]=r[i][j+1]+1;//底边中点向右最大长度

            }

            else r[i][j]=0;

        }

    }

    ll ans=0;

    for(int i=1;i<=m;i++){

        memset(t,0,sizeof(t));//树状数组清空

        vector<vector<int>>del(n+1);

        for(int j=n;j>=1;j--){//枚举这一列所有点

            if(a[j][i]=='*'){

                int delpos=j-min(l[j][i],r[j][i])+1;

                if(delpos>=1)del[delpos].push_back(j);//计算失效位置

                int pos=min(ur[j][i],ul[j][i])+j-1;//作为顶点的范围

                ans+=sum(pos);//统计范围内底边中点数

                add(j,1);//作为可能的底边中点插入

            }

            for(auto &k:del[j]){//删除失效的底边中点

                add(k,-1);

            }

        }

    }

    cout<<ans;

}

 

解决统计一侧小于的元素个数(T315,T2824)

因为相当于是对小于某一个数的区间进行更新,因此可以考虑使用树状数组

在具体实现query查询时,每次单点更新的时候,例如1,是将1,2,4都+1,假设从右往左,依次遍历的数字是1,3,那么在查询3的时候query的意思并不是要把下标为1,2的C都加一遍,得到的值并不是不是变成2了,因为要记住树状数组每次减的是lowbit,换言之,当下标为2(或者那些作为总和的点时),他们的lowbit就是他们本身,减过一次后就为0 了,因此并不会出现重复增加的问题,每次减完后不为0的情况是对于类似7这种,是需要把不是2n范围内但小于自己的点相加的下标

 

蓝桥杯 22 最长不下降子序列

首先对于序列每一个位置,求出 Li,Ri,Li表示以i 位置为结尾的最长不下降子序列,Ri表示以i为开始的最长不下降子序列,求法就是正着做一遍朴素的最长不下降子序列,反正做一遍最长不上升子序列,注意用 nlogn 做法。

接着考虑拼接。

首先我们可以得出一个结论:改变 k 个数一定比改变更少的数优秀。

如果改变[L,R] 这 k 个数,我们只考虑其去配合以 R+1 为左端点的最长不降序列,因为就算它去配合更后面的,以数x 为左端点的最长不降序列有更优答案,那它也不会比枚举改变[x−k,x−1] 这k 个数时去配合x 更优,因为那时Lj选择更广。并且改变[L,R] 这k 个数时,自然是改变成 valR+1更优,因为这样能不影响前面部分的递增递减性

那接下来就按照上述的贪心思路进行操作,我们对每一个位置 i ,考虑它前面 k 个数被改成与它相同,那只需要找一个 j 属于[1,i−k−1] ,使得 valj≤vali且Lj 最大,然后用 Lj+k+Ri更新答案就行了。

因为要用三个树状数组,最好把树状数组写成类更方便。

const int N=1e5+10;

int n,k,ans,val[N],L[N],R[N];

vector<int> vec;

struct BIT{//树状数组类

    //对于一个数x,C[x]表示当前子序列最大值为x时,最长的不下降子序列长度

    int C[N];

    inline void add(int x,int y){for(;x<=n;x+=x&-x) C[x]=max(C[x],y);}

    inline int query(int x){

        int ans=0;

        for(;x;x-=x&-x) ans=max(ans,C[x]);

        return ans;

    }

}le,re,s;

void solve(){

    cin>>n>>k;

    val[0]=1; val[n+1]=n+1;

    for(int i=1;i<=n;i++)   cin>>val[i];

    for(int i=1;i<=n;i++)   vec.push_back(val[i]);

    sort(vec.begin(),vec.end());

    vec.erase(unique(vec.begin(),vec.end()),vec.end());//离散化

    for(int i=1;i<=n;i++)   val[i]=lower_bound(vec.begin(),vec.end(),val[i])-vec.begin()+1;//相当于离散化后的标记

    //O(nlogn)的求最长不下降子序列

    for(int i=1;i<=n;i++){//按顺序插入

        L[i]=le.query(val[i])+1;//因为是子序列,查找当前范围内有多少个小于等于其的数

        le.add(val[i],L[i]);

    }

    for(int i=n;i>=1;i--){//逆序再做一遍,因为是反着做所以是求不上升子序列

        R[i]=re.query(n-val[i]+1)+1;//因为树状数组中存储的仍然是最长不下降子序列的长度,因此我们将大转小,就能达到实际上是求最长不上升子序列的目的

        re.add(n-val[i]+1,R[i]);

    }

    for(int i=k+1;i<=n+1;i++){//将i-1设定为修改区间的右端点

        s.add(val[i-k-1],L[i-k-1]);//找出修改段之前最长不下降子序列的长度

        ans=max(ans,s.query(val[i])+k+R[i]);//三段长度的和

    }  

    cout<<ans;

}

 

权值分散时,用离散化转化

P1168

求给出的数组中每一个奇数长度的中位数

而对于前2k-1的中位数实际上就是找第k小

由于树状数组本身的结构就可以看成二进制拆分,因此直接倍增查询第k小即可

有些类似于权值线段树是因为每个离散化后的点所对应位置的值实际上表示了在元素数组中有多少个这个元素,即该位置的权值

i64 n,tree[10000005],m,tot;

inline int lowbit(int i){

    return i&(-i);

}

i64 query(int x){

    i64 res=0;

    for(int i=x;i>0;i-=lowbit(i))

        res+=tree[i];

    return res;

}

void modify(i64 x,i64 k){

    for(i64 i=x;i<=n;i+=lowbit(i))

        tree[i]+=k;

}

inline int kth(int k){

    int ans=0,now=0;

    //ans是答案,now是比当前找到的数的小的数字的个数

    for(int i=20;i>=0;i--){//20一般足够进行倍增了

        ans+=(1<<i);

        if(ans>tot || now+tree[ans]>=k) ans-=(1<<i);

        //如果超了总体的最大值(写在前面防止数组越界),或者已经不是第k小的数了

        else now+=tree[ans];

        //这里不用担心统计小于ans的数是否会被重复统计,因为树状数组是按照二进制进行划分的

        //而我们又是按照2的幂次进行倍增的

        //因此每次添加的数相当于是一个区间中的个数

    }

    return ans+1;

}

int a[100005],b[100005];

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    tot=0;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[++tot];

        b[tot]=a[tot];

    }

    sort(a+1,a+n+1);

    tot=unique(a+1,a+1+tot)-a-1;

    for(int i=1;i<=n;i++){

        b[i]=lower_bound(a+1,a+1+tot,b[i])-a;

    }//离散化

    for(int i=1;i<=n;i++){

        modify(b[i],1);

        if(i&1){

            int t=kth((i+1)>>1);

            cout<<a[t]<<'\n';

        }

    }

    return 0;

}

另一种解法,利用vector

因为每次插入后排序时间代价太大,则插入时采用lower_bound来二分大于等于该数的数的指针,使得每次插入完都是有序的

因此每次插入完成后,如果是奇数,那么直接输出当前容器中的第(size()-1)/2项即可(因为vector是从第0项开始存储的)

 

 

 

二维偏序模板(T2736)

二维偏序是给定若干点对,并定义某种偏序关系

二维偏序的一般解决方法是排序一维,用数据结构处理第二维(这种数据结构一般是树状数组)

把 (nums1[i], nums2[i]) 看成二维平面上的一个红点,(queries[i][0], queries[i][1]) 看成二维平面上的一个蓝点。我们要对每个蓝点求出它的右上方横纵坐标之和最大的红点。

我们将所有点先按横坐标从大到小排序,然后依此枚举每个点。这样遇到一个蓝点 (x, y),我们只要求之前枚举过的,且纵坐标大于等于 y 的红点中,横纵坐标之和最大是多少。用树状数组维护即可(维护区间最值)

因为 nums1,nums2 和 queries 里面的数很大,但是我们只关心数的相对大小关系,因此先把所有数离散化再计算(当然,离散化之前要把 nums1[i] + nums2[i] 的值存起来)

在只对nums2的数进行查询时,相当于这一个(对应的)数组来返回答案,即我们循环将nums中所有大于等于x的元素(nums1大小的限定)的nums2的值插入到树状数组中,树状数组维护的是离散化后的nums2的区间中nums1+nums2的最大值,那我们只需要在树状数组中查询大于等于离散化后的y区间对应的最大值。因为树状数组维护的是前缀最大值,因此实现时,可以将nums2反序插入到树状数组中

(对于每个询问,都需要找到已遍历过的大于 nq[i][1]的位置上的最大值,把离散化后的值域换成数组坐标,相当于求后缀最大值,后缀最大值可通过相反数,变成求前缀最大值)

要去更新需要知道下标,如果不进行离散化,每次都需要二分查找有序数组来得到编号

想要快速反序,可以尝试用加一个负号的方式,或者反序编号int k = nums2.end() - lower_bound(nums2.begin(), nums2.end(), nums[j].second);

 

线段树:

线段树去处理的问题,往往都是满足单点修改,区间询问,关键就是在于确定树的每个节点究竟要存储什么信息

 

维护最大子段和

GSS1 - Can you answer these queries I

区间最大前缀和,区间最大后缀和:

一种是当前区间的某个儿子的最大子段和直接成为了他的最大子段和

另一种是当前区间的最大子段和在左右儿子中各有一部分

如果一段区间全部都是负数,那么最大子段和就是这个区间的最大值

(所以在赋初始值时不要因为它是负数就让区间最大前缀和,后缀和,子段和为0)

struct SegmentTree{

    int sum;

    int premax,sufmax,mx;

}tr[50010<<2];

 

void pushup(int p){

    tr[p].sum=tr[p<<1].sum+tr[p<<1|1].sum;

    tr[p].premax=max(tr[p<<1].premax,tr[p<<1].sum+tr[p<<1|1].premax);

    tr[p].sufmax=max(tr[p<<1|1].sufmax,tr[p<<1|1].sum+tr[p<<1].sufmax);

    tr[p].mx=max(max(tr[p<<1].mx,tr[p<<1|1].mx),tr[p<<1].sufmax+tr[p<<1|1].premax);

}

 

void build(int p,int l,int r){

    if(l==r){

        int x;

        cin>>x;

        tr[p].sum=tr[p].premax=tr[p].sufmax=tr[p].mx=x;

        return;

    }

    int m=(l+r)>>1;

    build(p<<1,l,m);

    build(p<<1|1,m+1,r);

    pushup(p);

}

 

inline SegmentTree query(int p,int l,int r,int ql,int qr){

    if(ql<=l && qr>=r){

        return tr[p];

    }

    int m=(l+r)>>1;

    if(ql>m){

        return query(p<<1|1,m+1,r,ql,qr);

    }

    if(qr<=m){

        return query(p<<1,l,m,ql,qr);

    }else{//查询区间被左右节点包含

        SegmentTree res,a,b;

        a=query(p<<1,l,m,ql,qr);

        b=query(p<<1|1,m+1,r,ql,qr);

        res.sum=a.sum+b.sum;

        res.premax=max(a.premax,a.sum+b.premax);

        res.sufmax=max(b.sufmax,b.sum+a.sufmax);

        res.mx=max(max(a.mx,b.mx),a.sufmax+b.premax);

        return res;

    }

}

 

void solve(){

    int n;

    cin>>n;

    build(1,1,n);

    int m;

    cin>>m;

    while(m--){

        int x,y;

        cin>>x>>y;

        cout<<query(1,1,n,x,y).mx<<'\n';

    }

}

 

Cf 2117H

给定一个长度为 n 的数组 a 和 q 个查询。在每个查询中,她替换数组中的一个元素。在每个查询后,她将询问你 k 的最大值,使得存在一个整数 x 和 a 的一个子数组 ,其中 x 是该子数组的 k-多数。

若 y 在数组 b 中出现了至少⌊(|b|+1 )/2⌋+k 次(其中 ∣b∣ 表示 b 的长度),则称 y 是数组 b 的 k-多数。注意 b 可能不存在一个 k-多数。

首先可以发现,k实际上就是|b|/2,因为如果是大于这个数,那么总体的数组长度就超出了整个数组,而如果小于|b|/2,那一定可以找到一个长度等于|b|/2的数组

假设我们已经确定了 k-多数是 x,考虑求最大的 k。

将原数组的每一个位置重新赋值:将等于 x 的位置赋成 1,其他位置赋成 −1。求出最大子段和 s,那么 k 最大为 ⌊s/2⌋。

每次单点修改只需要改变正负号。以上过程可以使用线段树维护。

现在考虑枚举 x,但每次都全部跑一边肯定会超时。可以发现,对于当前的 x,有一些操作是无用的。准确来说,会改变当前数组取值的操作,假设是将 a[i]修改为 y,要么满足 a[i]=x 要么满足 y=x。因此我们只需要将这些操作记录下了,然后只跑这些操作

同时为了复杂度正确,我们不能每次都单开一个线段树,而是要把当前 x 影响的数都还原

void solve() {

    int n, q;

    cin >> n >> q;

   

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

        a[i]--;

    }

   

    vector<vector<array<int, 3>>> op(n);

    for (int i = 0; i < n; i++) {

        op[a[i]].push_back({0, i, 1});

    }

    for (int i = 0; i < q; i++) {

        int x, y;

        cin >> x >> y;

        x--;

        y--;

        op[a[x]].push_back({i, x, -1});

        a[x] = y;

        op[a[x]].push_back({i, x, 1});

    }

    for (int i = 0; i < n; i++) {

        op[a[i]].push_back({q, i, -1});

    }

   

    SegmentTree<Info> seg(n);

    for (int i = 0; i < n; i++) {

        seg.modify(i, {0, -1, 0, 0});

    }

   

    vector<vector<array<int, 2>>> e(q);

    for (int v = 0; v < n; v++) {

        int lst = 0;

        for (auto [i, x, t] : op[v]) {

            int res = seg.rangeQuery(0, n).ans;

            if (lst < i) {

                e[lst].push_back({res / 2, 1});

                if (i < q) {

                    e[i].push_back({res / 2, -1});

                }

            }

            lst = i;

            seg.modify(x, {max(0, t), t, max(0, t), max(0, t)});

        }

    }

   

    multiset<int> S;

    for (int i = 0; i < q; i++) {

        for (auto [x, t] : e[i]) {

            if (t == 1) {

                S.insert(x);

            } else {

                S.extract(x);

            }

        }

       

        assert(!S.empty());

        cout << *S.rbegin() << " \n"[i == q - 1];

    }

}

 

 

Cf 2050F

给定一个数组,每次询问给定待查询数组的边界l和r,求最大的m使得该区间内的所有数模m得到的余数相同

根据同余的定义,两个数模m同余那么这两个数的差是m的倍数,因此即求相邻数差的gcd
对每次询问都遍历进行操作会T,因此需要使用线段树或者ST表

线段树一般的复杂度是loglog,ST表预处理也需要loglog

但线段树可以改成一个log,一般是从底向上进行修改,但是如果从左往右进行修改就可以实现一个log,这里仍使用一般的写法即可

手写:
struct SegmentTree{

    int gcd=0;

}seg[200005<<2];

 

void push(int p){

    seg[p].gcd=__gcd(seg[p<<1].gcd,seg[p<<1|1].gcd);

}

 

void build(int p,int l,int r){

    if(r-l==1){

        seg[p].gcd=0;

        return;

    }

    int m=(l+r)>>1;

    build(p<<1,l,m);

    build(p<<1|1,m,r);

    push(p);

}

 

void modify(int p,int l,int r,int x,int val){

    if(r-l==1){

        seg[p].gcd=val;

        return;

    }

    int m=(l+r)>>1;

    if(x<m){

        modify(p<<1,l,m,x,val);

    }else{

        modify(p<<1|1,m,r,x,val);

    }

    push(p);

}

 

int query(int p,int l,int r,int x,int y){

    if(l>=y||r<=x){

        return 0;

    }

    if(l>=x&&r<=y){

        return seg[p].gcd;

    }

    int m=(l+r)>>1;

    return __gcd(query(p<<1,l,m,x,y),query(p<<1|1,m,r,x,y));

}

 

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    build(1,0,n);

    for(int i=1;i<n;i++){

        modify(1,0,n,i,abs(a[i]-a[i-1]));

    }

    while(q--){

        int l,r;

        cin>>l>>r;

        cout<<query(1,0,n,l,r)<<" \n"[q==0];

    }

}

Jly板子:

template<class Info>

struct SegmentTree {

    int n;

    std::vector<Info> info;

    SegmentTree() : n(0) {}

    SegmentTree(int n_, Info v_ = Info()) {

        init(n_, v_);

    }

    template<class T>

    SegmentTree(std::vector<T> init_) {

        init(init_);

    }

    void init(int n_, Info v_ = Info()) {

        init(std::vector(n_, v_));

    }

    template<class T>

    void init(std::vector<T> init_) {

        n = init_.size();

        info.assign(4 << std::__lg(n), Info());

        std::function<void(int, int, int)> build = [&](int p, int l, int r) {

            if (r - l == 1) {

                info[p] = init_[l];

                return;

            }

            int m = (l + r) / 2;

            build(2 * p, l, m);

            build(2 * p + 1, m, r);

            pull(p);

        };

        build(1, 0, n);

    }

    void pull(int p) {

        info[p] = info[2 * p] + info[2 * p + 1];

    }

    void modify(int p, int l, int r, int x, const Info &v) {

        if (r - l == 1) {

            info[p] = v;

            return;

        }

        int m = (l + r) / 2;

        if (x < m) {

            modify(2 * p, l, m, x, v);

        } else {

            modify(2 * p + 1, m, r, x, v);

        }

        pull(p);

    }

    void modify(int p, const Info &v) {

        modify(1, 0, n, p, v);

    }

    Info rangeQuery(int p, int l, int r, int x, int y) {

        if (l >= y || r <= x) {

            return Info();

        }

        if (l >= x && r <= y) {

            return info[p];

        }

        int m = (l + r) / 2;

        return rangeQuery(2 * p, l, m, x, y) + rangeQuery(2 * p + 1, m, r, x, y);

    }

    Info rangeQuery(int l, int r) {

        return rangeQuery(1, 0, n, l, r);

    }

    template<class F>

    int findFirst(int p, int l, int r, int x, int y, F &&pred) {

        if (l >= y || r <= x) {

            return -1;

        }

        if (l >= x && r <= y && !pred(info[p])) {

            return -1;

        }

        if (r - l == 1) {

            return l;

        }

        int m = (l + r) / 2;

        int res = findFirst(2 * p, l, m, x, y, pred);

        if (res == -1) {

            res = findFirst(2 * p + 1, m, r, x, y, pred);

        }

        return res;

    }

    template<class F>

    int findFirst(int l, int r, F &&pred) {

        return findFirst(1, 0, n, l, r, pred);

    }

    template<class F>

    int findLast(int p, int l, int r, int x, int y, F &&pred) {

        if (l >= y || r <= x) {

            return -1;

        }

        if (l >= x && r <= y && !pred(info[p])) {

            return -1;

        }

        if (r - l == 1) {

            return l;

        }

        int m = (l + r) / 2;

        int res = findLast(2 * p + 1, m, r, x, y, pred);

        if (res == -1) {

            res = findLast(2 * p, l, m, x, y, pred);

        }

        return res;

    }

    template<class F>

    int findLast(int l, int r, F &&pred) {

        return findLast(1, 0, n, l, r, pred);

    }

};

 

struct Info {

    int g = 0;

};

 

Info operator+(const Info &a, const Info &b) {

    return {std::gcd(a.g, b.g)};

}

 

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    SegmentTree<Info> seg(a);

    for(int i=1;i<n;i++){

        seg.modify(i,{abs(a[i]-a[i-1])});

    }

    for(int i=0;i<q;i++){

        int l,r;

        cin>>l>>r;

        cout<<seg.rangeQuery(l,r).g<<" \n"[i==q-1];

    }

}

 

 

线段树合并

大致合并过程

如果a有pos位置,b没有,那么可以直接将pos赋值为a,反过来同理

如果是已经赋值到叶子节点,那么直接将两者的和作为当前的节点即可

递归处理左子树,右子树,用左右子树的值更新当前节点

 

P4556

整体上是树上差分的思想,对于树上路径进行操作

u到v的路径可以拆分为u到lca(u,v),以及v到lca(u,v),因为lca也被包括在路径中,因此可以将u的位置+1,v的位置+1,lca的位置-1(因为直接这样操作lca位置会被加两次),fa(lca)-1

因此可以每个点单独开一个权值线段树用来这个节点上的数字,每个点的权值线段树就是子节点的线段树合并

同时线段树需要维护的最大值就是区间最值

 

杭电24多校3 1002

找若干条路径,每个点只在其中一条路径上,且每条路径的起点和终点类型相同,总权重和最大

用dp解决

可以在lca除枚举经过这个点的链,如果这个点不被任何链经过,那么把所有孩子节点的dp值加起来,否则需要找一条经过lca的链,并进行维护这条链上其他点的其他孩子的dp值

显然是可以线段树合并的,线段树的下标为颜色,把链上的dp维护起来,同时在合并时可以顺带求出最优解

void merge(auto &fx,auto &fy,i64 &ax,i64 &ay,i64 &sx,i64 &sy,i64 &mx){

    mx+=sy;

    if(fx.size()<fy.size()){

        swap(fx,fy);

        swap(ax,ay);

        swap(sx,sy);

    }

    //小区间合并到大区间

    for(auto [c,d]:fy){

        if(fx.contains(c)){

            i64 &fxc=fx[c];

            mx=max(fx[c]+ax+d+ay,mx);

            fxc=max(fxc,d+ay+sx-ax-sy);//跨过根节点x的路径

        }else{

            fx[c]=d+ay+sx-ax-sy;

        }

    }    

    ax+=sy;

    ay+=sx;

    sx+=sy;

}

void solve() {

    int n;

    cin>>n;

    vector<int> c(n);

    for(int i=0;i<n;i++){

        cin>>c[i];

    }

    vector<int> w(n);

    for(int i=0;i<n;i++){

        cin>>w[i];

    }

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<map<int,i64>> f(n);

    vector<i64> dp(n),add(n);

    auto dfs=[&](auto self,int x,int fa)->void{

        i64 sum=0;

        i64 mx=0;

        for(auto y:adj[x]){

            if(y==fa) continue;

            self(self,y,x);

            merge(f[x],f[y],add[x],add[y],sum,dp[y],mx);

        }

        dp[x]=max(sum,mx);

        // add[x]+=w[x];  只是起始点和终点的权值和,所以不需要

        if(f[x].contains(c[x])){//由根节点为起始点的一条链

            i64 &fxc=f[x][c[x]];

            // dp[x]=max(dp[x],fxc+add[x]);

            dp[x]=max(dp[x],fxc+add[x]+w[x]);

            fxc=max(fxc,w[x]+sum-add[x]);//一个点只能被经过一次,选择是否要经过这个点

        }else{//路径没经过x

            f[x][c[x]]=w[x]+sum-add[x];//该点的权值再加上子树的路径权值和

        }

    };

    dfs(dfs,0,-1);

    cout<<dp[0]<<'\n';

}

 

 

cf2000H

考虑题目中所给k的性质,其实就是在集合中从左到右找到第一段两两相差长度至少为k的位置

因此可以维护一个权值线段树,对于集合中出现的数,可以在线段数中标记为1,那么两个1之间相差的0的个数就等于两数相差的长度,因此只需要维护这个区间中最大的0的个数即可

也就是一个比较板的维护0/1串种最大连续0的个数的线段树

对于找长度为k的位置,我们可以线段树上二分,因为是从左往右找,因此找完走遍后还得看中间是否符合,也就是判断左区间连续0+有序见前缀连续0是否>=k,最后找右区间

每次可以维护一个set表示集合中的数,最后将线段树清空,减少每次新开线段树带来的时间和空间上的损耗

有点问题的代码:

constexpr int N=2e6+10,M=2e6;

struct Node{

    i64 l,r;

    i64 presum,sufsum,mx;

}tr[N<<2];

void pushup(Node &a,Node &b,Node &c){

    a.l=b.l;a.r=c.r;

    if(b.presum==b.r-b.l+1){

        a.presum=b.presum+c.presum;

    }else{

        a.presum=b.presum;

    }

    if(c.sufsum==c.r-c.l+1){

        a.sufsum=c.sufsum+b.sufsum;

    }else{

        a.sufsum=c.sufsum;

    }

    a.mx=max({b.mx,c.mx,b.sufsum+c.presum});

}

void build(int p,int l,int r){

    tr[p].l=l,tr[p].r=r;

    if(l==r){

        tr[p]={l,r,1,1,1};

        return;

    }    

    i64 mid=(l+r)>>1;

    build(p<<1,l,mid);

    build(p<<1|1,mid+1,r);

    pushup(tr[p],tr[p<<1],tr[p<<1|1]);

}

void modify(int p,int x){//单点修改

    if(tr[p].l>=x && tr[p].r<=x){

        tr[p].mx^=1;

        tr[p].presum=tr[p].sufsum=tr[p].mx;

        return;

    }

    int mid=(tr[p].l+tr[p].r)>>1;

    if(x<=mid){

        modify(p<<1,x);

    }else{

        modify(p<<1|1,x);

    }

    pushup(tr[p],tr[p<<1],tr[p<<1|1]);

}

 

int query(int p,int k){

    if(tr[p].l==tr[p].r){

        return tr[p].l;

    }

    if(tr[p<<1].presum>=k){

        return query(p<<1,k);

    }

    if(tr[p<<1].sufsum+tr[p<<1|1].presum>=k){

        return tr[p<<1].r-tr[p<<1].sufsum+1;

    }

    return query(p<<1|1,k);

}

void solve(){

    int n;

    cin>>n;

    set<int> s;

    for(int i=1;i<=n;i++){

        int x;

        cin>>x;

        s.insert(x);

        modify(1,x);

    }

    int m;

    cin>>m;

    while(m--){

        char op;

        int x;

        cin>>op>>x;

        if(op=='+'){

            s.insert(x);

            modify(1,x);

        }else if(op=='-'){

            s.erase(x);

            modify(1,x);

        }else{

            if(x>tr[1].mx){

                cout<<M-tr[1].sufsum+1<<' ';

            }else{

                cout<<query(1,x)<<' ';

            }

        }

    }

    cout<<'\n';

    for(auto x:s){

        modify(1,x);

    }

}

 

 

 

牛客24寒假4K

对于该题,操作1就是简单的单点修改操作,自然可以用字符串去维护,但如果只是仅仅利用字符串,那么之后每次查询都是一次模拟,显然复杂度是不对的

对于这种区间询问,并且每次询问是进行一次模拟操作,又想到能否用前缀和去维护我们所需的值,但又可以发现,方块的数量并不是简单的前后相减可以得到的,因为不同的操作对于总数量的影响不同

之后,对于区间询问这个特点,就往线段树去考虑了,同时,该题的操作比较复杂,逆操作难求,而线段树也只有正着可以操作,因此从这点上来说,这两者是符合的;首先已经明确了模拟的这个思想是不可避免的,因此我们需要做的,就是提高模拟的效率,使得其并不需要每次都遍历完成模拟的过程

定义线段树的区间信息,显然,其中一个数据是方块数量,因为这个就是答案所需,其次,根据题干中的具体操作,我们需要知道当前的方块数量是多少,因为红方块是对当前(列的)数量进行翻倍,而进行列移动操作的是蓝方块(不要再局限于遍历模拟,现在在思考优化就是要找到各个操作的本质,以及之间的联系来找到我们需要维护的数据),因此,决定当前列的是字符串中红方块左侧最右的蓝方块,因此我们还需要维护最后一个蓝方块的位置

接着,针对当前分析的内容,考虑是否要对区间信息(方块数量)做进一步的划分(只需要整段区间的方块数量即可吗),或者说,现在能否写出查询函数的操作

#include <bits/stdc++.h>

using namespace std;

using i64=long long;

#define ll long long

typedef double ld;

typedef pair<int,int> pii;

typedef pair<ll,ll> pll;

typedef pair<double,int> pdi;

//const int N=1e5+10,INF=0x3f3f3f3f,mod = 998244353;

const int mod=7+1e9;

const int N=3+2e5;

struct Data

{

    ll a,b;

    //a表示方块数,b表示红方块数(2的幂次)

};

struct treenode{

    int l,r;

    Data ld,rd;

    ll mid;

    int blue;

    Data d;

    //blue代表这段区间内有无蓝方块

    //在之后与其他区间进行连接的时候,起关键切断作用的是蓝方块

    //对于在区间右侧进行连接的,(如果有红方块),重点关注的是当前最右侧的蓝方块

    //而对于在区间左侧进行连接的,红方块产生影响的也只有到下一次蓝方块出现前

    //因此分别记录最右侧蓝方块和最左侧蓝方块,在这两个蓝方块中间的不受到连接的区间影响,因此可以用ll直接记录个数

    //对于特殊情况blue=0,说明没有蓝方块,直接进行连接,此时用d去记录

}tree[N*4];

int n,q,a[N];

char s[N];

ll pow2[N];//预处理2的幂次

Data operator+(const Data&left,const Data&right){

    Data res{};

    //翻倍的好处是可以分别计算(线性运算),((x+y)*2+z)*2->x*(2*2)+y*(2*2)+z*2,不像平方在连接时计算最后的结果会比较麻烦

    res.a=(left.a*pow2[right.b]+right.a)%mod;

    res.b=left.b+right.b;

    return res;

}

treenode operator+(const treenode&left,const treenode&right){

    treenode res{};

    res.l=left.l,res.r=right.r;

 

    if(left.blue && right.blue){

        res.ld=left.ld,res.rd=right.rd;

        auto temp=left.rd+right.ld;

        res.mid=(left.mid+temp.a+right.mid);//temp只需要用到方块数量,不直接相加是因为没有定义ll与data的相加

        res.blue=1;

    }else if(left.blue){

        res.ld=left.ld;

        res.rd=left.rd+right.d;

        res.mid=left.mid;

        res.blue=1;

    }else if(right.blue){

        res.ld=left.d+right.ld;

        res.rd=right.rd;

        res.mid=right.mid;

        res.blue=1;

    }else{

        res.d=left.d+right.d;

    }

    return res;

}

 

void build(int k,int l,int r){

    tree[k].l=l;

    tree[k].r=r;

    if(l==r){

        if(s[r]=='Y') tree[k].d.a=1;

        else if(s[r]=='R') tree[k].d.a=1,tree[k].d.b=1;

        else tree[k].rd.a=1,tree[k].blue=1;

        return;

    }

    int mid=l+r>>1;

    build(k<<1,l,mid);

    build(k<<1|1,mid+1,r);

    tree[k]=tree[k<<1]+tree[k<<1|1];//子区间的合并操作

}

 

void update(int k,int p,int c){

    if(tree[k].l=tree[k].r){

        s[p]=c;

        memset(&tree[k],0,sizeof(tree[k]));

        tree[k].l=p,tree[k].r=p;

        if(s[p]=='Y') tree[k].d.a=1;

        else if(s[p]=='R') tree[k].d.a=1,tree[k].d.b=1;

        else tree[k].rd.a=1,tree[k].blue=1;

        return;

    }

    int mid=tree[k].l+tree[k].r>>1;

    if(p<=mid) update(k<<1,p,c);

    else update(k<<1|1,p,c);

    tree[k]=tree[k<<1]+tree[k<<1|1];

}

 

//查询时就按照一般的区间查询操作,将计算的过程放在合并中

treenode query(int k,int l,int r){

    if(l<=tree[k].l && tree[k].r<=r){

        return tree[k];

    }

    int mid=tree[k].l+tree[k].r>>1;

    if(l<=mid && mid<r){

        return query(k<<1,l,r)+query(k<<1|1,l,r);

    }

    if(l<=mid){

        return query(k<<1,l,r);

    }else{

        return query(k<<1|1,l,r);

    }

}

 

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    cin>>n>>q;

    cin>>s+1;

    pow2[0]=1;

    for(int i=1;i<=n;i++){

        pow2[i]=pow2[i-1]*2%mod;

    }

    build(1,1,n);

    while(q--){

        int op,l,r,p;

        char c;

        cin>>op;

        if(op==1){

            cin>>p>>c;

            update(1,p,c);

        }else{

            cin>>l>>r;

            auto ans=query(1,1,r);

            cout<<(ans.d.a+ans.ld.a+ans.rd.a+ans.mid)%mod<<'\n';

        }

    }

    return 0;

}

 

牛客24多校7 D   原题cf1418G

给定一个长度为n的序列,序列中的某个区间[l,r]是好的,当且仅当al,al+1,...ar中的每个元素在当前区间中恰好出现了k次

求出好区间的数量

考虑固定左端点,统计右端点,倒序枚举左端点,对于第i个元素:
它向右第k个与ai相同的元素的下标,记为l,它向右第k+1个和ai相同元素下标,记为r
那么[l,r-1],[1,i-1]为i的合法区间(因为对于区间[i,l],[i,l+1]...[i,r-1],ai都恰好出现了恰好k次(有点类似于之前我们只关注一个元素最后出现的位置,这里要求一个元素要出现k次,那么就关注出现k次的位置);对于区间[1,i-1],因为我们是倒序处理,所以[1,i-1]不在第i个元素的影响范围)

对于以i为左端点的好区间,它对答案的贡献是所有合法区间的交集在i...n的长度

这些操作可以用线段树实现

//固定右端点进行计算

const int N=5e5+5;

int n,m,a[N],aa[N];

i64 ans;

vector<int> b[N];

struct SegmentTree{

    int mx,cnt,tag;

    //cnt是当前可行的区间左端点的数量

    //mx是计数器,用于记录此时的区间内未出现的元素种数

    //在这段区间中该元素出现过,mx-1,当恰好出现m次时,mx+1,这样就可以用mx来判断是否满足好区间的定义

}tree[N<<2];

inline void pushup(int p){

    tree[p].mx=max(tree[p<<1].mx,tree[p<<1|1].mx);

    tree[p].cnt=(tree[p].mx==tree[p<<1].mx?tree[p<<1].cnt:0)+(tree[p].mx==tree[p<<1|1].mx?tree[p<<1|1].cnt:0);

}

inline void modify(int p,int val){

    tree[p].mx+=val;

    tree[p].tag+=val;

}

inline void pushdown(int p){

    modify(p<<1,tree[p].tag);

    modify(p<<1|1,tree[p].tag);

    tree[p].tag=0;

}

inline void build(int p,int l,int r){

    tree[p]=(SegmentTree){0,r-l+1,0};

    if(l==r) return;

    int mid=(l+r)>>1;

    build(p<<1,l,mid);

    build(p<<1|1,mid+1,r);

}

inline void update(int p,int l,int r,int ql,int qr,int val){

    if(l>qr || r<ql) return;

    if(l>=ql && r<=qr){

        modify(p,val);

        return;

    }

    pushdown(p);

    int mid=(l+r)>>1;

    update(p<<1,l,mid,ql,qr,val);

    update(p<<1|1,mid+1,r,ql,qr,val);

    pushup(p);

}

void update1(int x,int val){

    int t=b[x].size();

    update(1,1,n,b[x][t-1]+1,n,val);

    //对最后一次出现的右侧区间进行修改

    if(t>m){

        update(1,1,n,b[x][t-m-1]+1,b[x][t-m],val);

        //因为初始b[x]就有一个0,因此满足出现m次的话b[x]的长度至少是m+1

        //出现了m次就说明这个元素在这段区间中满足了恰好出现m次,因此可以将这个元素在“未出现种类数”中进行还原

        //这样当mx仍然是aa[0]时,就说明这段区间中都是满足好区间定义的可行的左端点

        //对当前的可以满足好区间条件的区间左端点在对应位置加1,这样cnt就是用于记录个数

    }

}

void solve(){

    cin>>n>>m;

    ans=aa[0]=0;

    //离散化后的数组a,aa[0]用于记录数组长度

    build(1,1,n);

    for(int i=1;i<=n;++i){

        cin>>a[i];

        aa[++aa[0]]=a[i];

    }

    //aa[0]是实际上的元素种类

    sort(aa+1,aa+aa[0]+1);

    aa[0]=unique(aa+1,aa+aa[0]+1)-aa-1;

    for(int i=1;i<=aa[0];++i){

        b[i]={0};

        update(1,1,n,1,n,1);

        //初始将mx设定为aa[0]

    }

    for(int i=1;i<=n;++i){

        a[i]=lower_bound(aa+1,aa+aa[0]+1,a[i])-aa;

        //离散化

    }

    for(int i=1;i<=n;++i){

        update1(a[i],-1);

        b[a[i]].emplace_back(i);//存储出现位置

        update1(a[i],1);

        //实际上是对上一次出现位置和当前位置之间进行了减1操作,说明这段区间内未出现的元素种类数减少了

        ans+=tree[1].mx==aa[0]?tree[1].cnt-n+i:0;

        //-(n-i)是因为我们是顺序操作的,因此在i之后的区间并没有进行过判断,不能计入合法的左端点选择中

        cerr<<ans<<'\n';

    }

    cout<<ans<<'\n';

}

其他的哈希做法:
思考前缀和的想法

如果只有一个数,如果sum[]记录这个数字的出现次数

如果sum[r]-sum[l-1]==k,那么就说明符合,如果sum[r]-sum[l-1]>k,l++,否则r++

那么对于n个数,就是n个数都要满足[l,r]内前缀差是k

要快速的计入每个位置较多数字的状态可以用哈希

把每个数字哈希处理之后,我们可以用相加的方法,将哈希值之和认为是状态

之后可以考虑枚举右端点,用a[i]记录这个端点的状态,如果之前出现过这个状态,那么这个区间就合法,ans+=m,m表示这个状态之前出现过几次

而对于左端点的移动,我们在出现次数超过k之后要移动左端点,而为了存储x在i位置出现了几次,可以将x出现的位置记录下来,也可以方便左端点的移动

如果po[x].size()<k,很明显i不会是合法右端点,a[i]直接加ha[x],mp[a[i]]++

如果po[x].size()>k,很明显[1,i]不会是合法的,这个时候,我们要找到那个正好包含k个x的地方

po[po[x].size-1]指向的是x的最后一个位置,也就是我们当前的位置,所以我们的目标是po[po[x].size()-1-k]。因此移动左指针j到这个位置

注意每次移动前要把该处状态移除,即先mp[a[j]]--,再j++

如果po[x].size()%k==0,那么需要更新x的状态——我们知道,一个合法状态的x的数量是<=k的,所以到k需要做操作:

考虑第一个k,当第k个x加入时,这一“轮”走完了完整的一轮,需要剔除之前(k-1)个x的影响(当前这个x还没进来)

a[i]=a[i-1]-(k-1)*ha[x]

以上操作做完之后,先算答案,再记录当前状态

void solve(){

    map<i64,i64> mp,vis,ha;//mp记录当前状态的出现次数,ha记录哈希值,vis标记这个数否出现过

    map<int,vector<int>> pos;//pos存这个数出现的位置

    int n,k;

    cin>>n>>k;

    vector<i64> a(n+1);//更新i时的状态

    i64 l=0,ans=0ll;//l是左端点,ans记录答案

    mp[0]++;//状态计数归零

    a[0]=0;//初始化状态

    for(int i=1;i<=n;i++){

        int x;

        cin>>x;

        if(!vis[x]){//若这个数没出现过,标记,并给定一个哈希值

            vis[x]=1;

            ha[x]=rand()*rand()*rand()*rand();

        }

        pos[x].emplace_back(i);//存储出现位置

        if(pos[x].size()>k){//更改窗口

            while(l<pos[x][pos[x].size()-1-k]){

                mp[a[l++]]--;

            }

            //左端点右移到 pos[x][pos[x].size()-1-k] 处,然后先删除状态再右移

        }

        if(pos[x].size()%k==0){//删除前面k-1个x造成的影响

            a[i]=a[i-1]-ha[x]*(k-1);

        }else{//不然直接计算这一状态

            a[i]=a[i-1]+ha[x];

        }

        //更新答案

        ans+=mp[a[i]];

        //状态记录

        mp[a[i]]++;

    }

    cout<<ans<<'\n';

}

 

牛客24多校9 B

给出一个数列a和一个集合X

求有多少种方法可以将a划分成若干个子串,使得每个子串内任意一个数的出现次数都不在集合X中

考虑dp[i]表示以i结尾,分成若干个合法的段的方案数

考虑转移,显然可以枚举j,判断i+1~j这一段是否合法

进一步考虑优化

注意到在每个右端点,对于每种值,其出现次数在S中的情况对应的左端点一定形成O(|S|)个区间(假设当前数字 a[i] 是第 k 次出现,对于集合 s 中的某个元素 x≤k,其对应的不合法位置是 a[i] 第 k−x 次至 k−x+1 次出现的整个区间)

于是,在每次扫到ai的时候,更新一下这一些区间,我们不允许从这些区间内转移,因此可以在这些区间上用线段树打上tag,而每次查询时就是计算未被标记的dp之和,区间查询tag最少的位置j对应的dp[j]的和,如果tag的min是0那么就转移到目前位置的dp[i]即可

和24多校7D不同的是维护信息的第二维不是个数而是对应位置的dp方案数之和,同时仍然是在最大值等于数的总数时更新答案(在这边的写法中是tag=0,因为是对不合法的位置进行了标记,因此首先进行操作的区间其tag一定是最小的,同时要满足tag=0,说明这是个合法区间)

const int N=2e6+7,mod=998244353;

int n,q,a[N],f1[N],f2[N],dp[N],tg[N]; vector<int>v[N];

void pushdown(int t){

    f2[t<<1]+=tg[t];

    f2[t<<1|1]+=tg[t];

    tg[t<<1]+=tg[t];

    tg[t<<1|1]+=tg[t];

    tg[t]=0;

}

void update(int t){

    f2[t]=min(f2[t<<1],f2[t<<1|1]),f1[t]=0;

    if(f2[t]==f2[t<<1]) f1[t]=(f1[t]+f1[t<<1])%mod;

    if(f2[t]==f2[t<<1|1]) f1[t]=(f1[t]+f1[t<<1|1])%mod;

}

void modify(int l,int r,int t,int ql,int qr,int c){

    pushdown(t);

    if(l==ql&&r==qr){

        tg[t]+=c,f2[t]+=c; return;

    }

    int d=(l+r)/2;

    if(ql<=d) modify(l,d,t<<1,ql,min(d,qr),c);

    if(d+1<=qr) modify(d+1,r,t<<1|1,max(d+1,ql),qr,c);

    update(t);

}

void add(int l,int r,int t,int ql,int qr,int c){

    pushdown(t);

    if(l==ql&&r==qr){

        f1[t]=c; return;

    }

    int d=(l+r)/2;

    if(ql<=d) add(l,d,t<<1,ql,min(d,qr),c);

    if(d+1<=qr) add(d+1,r,t<<1|1,max(d+1,ql),qr,c);

    update(t);

}

 

void solve(){

    cin>>n>>q; int k;

    add(0,n,1,0,0,1);

    vector<int>S(q);

    for(int i=1;i<=n;i++) cin>>a[i];

    for(int i=0;i<q;i++){

        cin>>S[i];

    }

    for(int i=1;i<=n;i++){

        for(int j:S){

            if(j<(int)v[a[i]].size()){

            modify(0,n,1,v[a[i]][v[a[i]].size()-j-1],v[a[i]][v[a[i]].size()-j]-1,-1);

            }

            else if(j==(int)v[a[i]].size()){

            modify(0,n,1,0,v[a[i]][v[a[i]].size()-j]-1,-1);

            }

        }

        v[a[i]].push_back(i);

        for(int j:S){

            if(j<(int)v[a[i]].size()){

            modify(0,n,1,v[a[i]][v[a[i]].size()-j-1],v[a[i]][v[a[i]].size()-j]-1,1);

            }

            else if(j==(int)v[a[i]].size()){

            modify(0,n,1,0,v[a[i]][v[a[i]].size()-j]-1,1);

            }

        }

        dp[i]=f1[1];

        add(0,n,1,i,i,dp[i]);

    }

    cout<<dp[n]<<'\n';

}

 

 

 

lazy线段树

用于解决的问题:一个数组,更新一个数组的值(区间加1,都加上一个数,把子数组内的元素取反。。。),求解会改变的数组的区间和问题

查询一个子数组的值(求和,求最大值。。。)(T2569)(T2916)

区间问题可以考虑线段树,但问题不仅在于线段树,也在于如何去使用线段树,例如P1083,要想到每次区间操作后,在区间查询时到底需要什么值,显然这里需要的并不是区间和,判断这个操作能否进行需要的是这个区间里是否有不能满足的点,即需要的是区间最小值,又最小值就能决定能否操作

两大思想:

1.挑选O(n)个特殊区间,使得任意一个区间可以拆分为O(log n)个特殊区间(一个结论,特殊区间的个数不会超过4n,将区间看作树的节点,在叶子节点处粗略估计节点个数,叶子节点处的节点个数一定小于2n,n是将区间用树表示后的节点数,并且拆分的原则是将区间从中间分开,即(1,10)拆为(1,5)(6,10)以此类推,这样得到的区间我们就作为特殊区间),用递归(最近公共祖先)来思考如何找特殊区间;

因为特殊区间的个数有上界,因此可以用数组来存储特殊区间

基础的线段树操作:

void build(int l, int r, int rt){

    if(l == r){

        SegTree[rt].mx = arr[l];

        return;

    }

    int m = (l + r) / 2;

    build(l, m, rt * 2);

    build(m + 1, r, rt * 2 + 1);

    SegTree[rt].mx = max(SegTree[rt * 2].mx, SegTree[rt * 2 + 1].mx);

}

 

void Update(int L, int C, int l, int r, int rt){

    if(l == r){

        SegTree[rt].mx = C;

        return;

    }

    int m = (l + r) / 2;

    if(L <= m){

        Update(L, C, l, m, rt * 2);

    }else{

        Update(L, C, m + 1, r, rt * 2 + 1);

    }

    SegTree[rt].mx = max(SegTree[rt * 2].mx, SegTree[rt * 2 + 1].mx);

}

 

int Query(int L, int R, int l, int r, int rt){

    if(l >= L && r <= R){

        return SegTree[rt].mx;

    }

    if(l > R || r < L){

        return 0;

    }

    int ans = 0;

    int m = (l + r) / 2;

    if(L <= m){

        ans = max(ans, Query(L, R, l, m, rt * 2));

    }

    if(R > m){

        ans = max(ans, Query(L, R, m + 1, r, rt * 2 +1));

    }

    return ans;

}

 

 

初始化:build

python:

def build(o:int ,l:int ,r:int)->None:    #o为节点编号,l为左端点的值,r为右端点的值

if l==r:

#....进行操作

return

m=(l+r)//2

build(o*2,l,m)  #二叉树的性质,左节点的编号是根节点编号的2倍

build(o*2+1,m+1,r)

#维护...

#假设 要更新[L,R]

def update(o:int ,l:int ,r:int,L:int,R:int)->None:

#什么时候不需要继续递归:当前查询的区间被[L,R]包含时

if L<=l and r<=R:

#....进行操作

return

m=(l+r)//2

#有交集再继续递归,不然之后得到的区间肯定不在[L,R]中

if m>=L: update(o*2,l,m,L,R) 

if m<R: update(o*2+1,m+1,r,L,R)

#维护...

2.lazy更新/延迟更新 ,带有标记的二叉树(有时堆也能用类似方法操作) lazy tag:用一个数组维护每个区间需要更新的值   如果这个值=0,表示不需要更新  如果这个值!=0,表示更新操作在这个区间停住了,不继续递归更新子区间了,如果后面又来了一个更新,在这个lazy tag的区间之中,那么此时这个区间就得继续递归

 

const int maxn=1000005;

 

inline int read(){   //快读

         int x=0,f=1;char ch=getchar();

         while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();}

         while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}

         return x*f;

}

 

struct node{

    int l,r,minn,tag;

}t[maxn<<2];  //4n的大小

 

void build(int ll,int rr,int x){

    t[x].l=ll;t[x].r=rr;t[x].tag=0;t[x].minn=INT_MAX;

    if(ll==rr){

        t[x].minn=read();return;   //读入线段树的初值

    }

    int mid=ll+((rr-ll)>>1);

    build(ll,mid,x<<1);

    build(mid+1,rr,x<<1|1);

    t[x].minn=min(t[x<<1].minn,t[x<<1|1].minn);

}

 

void down(int x){  //标记的下放

    t[x<<1].tag+=t[x].tag;

         t[x<<1|1].tag+=t[x].tag;

         t[x<<1].minn-=t[x].tag;

         t[x<<1|1].minn-=t[x].tag;

         t[x].tag=0;

}

 

void update(int num,int ll,int rr,int x,int &ff){

    if(t[x].l>=ll && t[x].r<=rr){

        if(t[x].minn<num){ff=1;return;}

        t[x].minn-=num;

        t[x].tag+=num;

        return;

    }

    if(t[x].tag) down(x);

    int mid=t[x].l+((t[x].r-t[x].l)>>1);

    if(ll<=mid) update(num,ll,rr,x<<1,ff);

    if(rr>mid) update(num,ll,rr,x<<1|1,ff);

    t[x].minn=min(t[x<<1].minn,t[x<<1|1].minn);

}

维护函数根据题目需求写,因为我们已经可以用不大于4n个特殊区间来表示整棵树,因此维护时一般也可以用一个4n长度的数组来存储相关数据

并且标记数组的传递也可以在更新操作函数中一起执行

并且注意初始化时o从1开始(线段树下标从1开始),l从1开始,r到n结束,因此传入下标时需要+1

动态开点(T715):
题目数据量较大时,为了节省空间,可以不一次性建好树,而节点只有在有需要的时候才被创建

这样我们不再用2p和2p+1代表p节点的儿子,而是用ls和rs记录儿子的编号(类似于左右子树)

// root 表示整棵线段树的根结点;cnt 表示当前结点个数

int n, cnt, root;

int sum[n * 2], ls[n * 2], rs[n * 2];

// 用法:update(root, 1, n, x, f); 其中 x 为待修改节点的编号

void update(int& p, int s, int t, int x, int f) { // 引用传参

if (!p) p = ++cnt; // 当结点为空时,创建一个新的结点,以cnt作为节点编号

if (s == t) {

sum[p] += f;

return;

}

int m = s + ((t - s) >> 1);

if (x <= m)

update(ls[p], s, m, x, f);

else

update(rs[p], m + 1, t, x, f);

sum[p] = sum[ls[p]] + sum[rs[p]]; // pushup

}

此时区间询问就变成

// 用法:query(root, 1, n, l, r);

int query(int p, int s, int t, int l, int r) {

if (!p) return 0; // 如果结点为空,返回 0

if (s >= l && t <= r) return sum[p];

int m = s + ((t - s) >> 1), ans = 0;

if (l <= m) ans += query(ls[p], s, m, l, r);

if (r > m) ans += query(rs[p], m + 1, t, l, r);

return ans;

}

 

(T2276)

线段树的除了用数组表示外,也可以形象地用树去实现,树的各节点对应保存范围的左右端点l,r,这样也方便动态开点的实现,同时,统计区间是否被覆盖过也不用通过区间访问计算了,可以直接通过线段树上现存的左右端点进行记录(只有在覆盖的时候会进行开点,不存在这个左右端点就说明这段区间没有被添加过)

例:
class CountIntervals {

    CountIntervals *left = nullptr, *right = nullptr;

    int l, r, cnt = 0;

 

public:

    CountIntervals() : l(1), r(1e9) {}

 

    CountIntervals(int l, int r) : l(l), r(r) {}

 

    void add(int L, int R) { // 为方便区分变量名,将递归中始终不变的入参改为大写(视作常量)

        if (cnt == r - l + 1) return; // 当前节点已被完整覆盖,无需执行任何操作

        if (L <= l && r <= R) { // 当前节点已被区间 [L,R] 完整覆盖,不再继续递归

            cnt = r - l + 1;

            return;

        }

        int mid = (l + r) / 2;

        if (left == nullptr) left = new CountIntervals(l, mid); // 动态开点

        if (right == nullptr) right = new CountIntervals(mid + 1, r); // 动态开点

        if (L <= mid) left->add(L, R);

        if (mid < R) right->add(L, R);

        cnt = left->cnt + right->cnt;

    }

 

    int count() { return cnt; }

};

//不过空间和时间效率都没有珂朵莉树高

 

P3372

区间加,询问区间和

inline void f(ll p,ll l,ll r,ll k)

{

   tag[p]=tag[p]+k;

   ans[p]=ans[p]+k*(r-l+1);

   //由于是这个区间统一改变,所以ans数组要加元素个数次啦

}

//我们可以认识到,f函数的唯一目的,就是记录当前节点所代表的区间

inline void push_down(ll p,ll l,ll r)

{

   ll mid=(l+r)>>1;

   f(ls(p),l,mid,tag[p]);

   f(rs(p),mid+1,r,tag[p]);

   tag[p]=0;

   //每次更新两个儿子节点。以此不断向下传递

}

inline void update(ll nl,ll nr,ll l,ll r,ll p,ll k)

{

   //nl,nr为要修改的区间

   //l,r,p为当前节点所存储的区间以及节点的编号

   if(nl<=l&&r<=nr)

   {

    ans[p]+=k*(r-l+1);

    tag[p]+=k;

    return ;

   }

   push_down(p,l,r);

   //回溯之前(也可以说是下一次递归之前,因为没有递归就没有回溯)

   //由于是在回溯之前不断向下传递,所以自然每个节点都可以更新到

   ll mid=(l+r)>>1;

   if(nl<=mid)update(nl,nr,l,mid,ls(p),k);

   if(nr>mid) update(nl,nr,mid+1,r,rs(p),k);

   push_up(p);

   //回溯之后

}

ll query(ll q_x,ll q_y,ll l,ll r,ll p)

{

    ll res=0;

    if(q_x<=l&&r<=q_y)return ans[p];

    ll mid=(l+r)>>1;

    push_down(p,l,r);

    if(q_x<=mid)res+=query(q_x,q_y,l,mid,ls(p));

    if(q_y>mid) res+=query(q_x,q_y,mid+1,r,rs(p));

    return res;

}

 

25 杭电 春 1004

给定一个长度为n的序列h[1]…h[n],定义其上某个区间为海浪当且仅当存在实数x,使得区间中每对相邻数一个大于x,一个小于x,q次询问区间内最长海浪

通过构造一些数列可以发现,如果要满足这是一个较长的海浪,则必须是一高一低这种形式,并且高的数中的最小值要大于低的数中的最大值

因为一定是一高一低,方便起见我们将其转变为奇数坐标和偶数坐标,也就是奇数位置元素的最大值要小于偶数位置元素的最小值,或者偶数位置元素的最大值要小于奇数位置元素的最大值,可以发现,这和上面的条件是等价的(抽象的数值关系,特别是像这样相邻位置的关系,最好转化成容易处理的下标情况,因为下标是直接在原数组中的,单调递增的情况是也是上面不合法的特殊情况,但这样的处理更一般化了,也更适合后面的判断)

以前者情况为例,这样可以维护线段树或者ST表来处理这个问题,后者的情况只需要对整个序列取负再做一遍即可

可以先考虑单组询问的情况,可以双指针,枚举右端点并维护最小左端点,若当前区间不是海浪,左端点就加一,因为海浪的子区间一定也是海浪,而包含了不是海浪的区间也一定不是海浪,因此这样双指针去遍历是合法的

而对于多组询问,因为都是对同一个数组进行查询,因此可以发现,我们在进行一轮双指针遍历后,可以得到每个右端点对应的左端点,而对于每个查询,最长的海浪可能是:

(注意维护的是每个右端点对应的最小左端点,因此遍历右端点)
右端点在查询区间内,而最小左端点在查询区间的左侧,那么最优解一定是最大右端点,二分这个位置即可

右端点在查询区间内,同时对应的左端点也在查询区间内,这种情况一定是在前一种情况的右端点的右侧(因为显然可以得到我们维护的海浪一定是不交的,也就是最小左端点关于右端点有单调性,即随着右端点的增大,对应的最小左端点可能不变,或者跟着增大),为了便于查询,可以维护右端点对应的最长海浪长度

右端点在查询区间的右侧,同样因为左端点的单调性,这种情况一定不如前两种情况优

struct Info{

    int l=0;

    int mx=0;

};

 

Info operator+(const Info &a,const Info &b){

    return Info{min(a.l,b.l),max(a.mx,b.mx)};

}

 

constexpr int mod = 1E9+7;

 

void solve() {  

    int n,q;

    cin>>n>>q;

    vector<int> h(n);

    vector<array<int,2>> qv(q);

    for(int i=0;i<n;i++){

        cin>>h[i];

    }

    SegmentTree<Info> seg1(n);

    SegmentTree<Info> seg2(n);

    {

        multiset<int> odd,env;

        int l=0;

        for(int i=0;i<n;i++){

            if(i&1){

                odd.insert(h[i]);

            }else{

                env.insert(h[i]);

            }

            while(!odd.empty() && !env.empty() && *odd.rbegin()>=*env.begin()){

                if(l&1){

                    odd.erase(odd.find(h[l]));

                }else{

                    env.erase(env.find(h[l]));

                }

                l++;

            }

            // cout<<l<<" \n"[i==n-1];

            seg1.modify(i,Info{l,i-l+1});

        }

    }

    for(int i=0;i<n;i++){

        h[i]=-h[i];

    }

    {

        multiset<int> odd,env;

        int l=0;

        for(int i=0;i<n;i++){

            if(i&1){

                odd.insert(h[i]);

            }else{

                env.insert(h[i]);

            }

            while(!odd.empty() && !env.empty() && *odd.rbegin()>=*env.begin()){

                if(l&1){

                    odd.erase(odd.find(h[l]));

                }else{

                    env.erase(env.find(h[l]));

                }

                l++;

            }

            // cout<<l<<" \n"[i==n-1];

            seg2.modify(i,Info{l,i-l+1});

        }

    }

    i64 ans=0ll;

    for(int i=1;i<=q;i++){

        int x,y;

        cin>>x>>y;

        x--,y--;

        int tmp1=seg1.findLast(x,y+1,[&](Info v){return v.l<=x;});

        int tmp2=seg2.findLast(x,y+1,[&](Info v){return v.l<=x;});

        ans+=1ll*i*max({tmp1-x+1,seg1.rangeQuery(tmp1+1,y+1).mx,tmp2-x+1,seg2.rangeQuery(tmp2+1,y+1).mx});

        ans%=mod;

    }

    cout<<ans<<'\n';

}

 

 

解决区间[j+1,n-1]范围中大于nums[i]的最小下标的问题可以用线段树二分来解决(T2940)(线段树用于维护区间最大值)(T2286)

由于代码中线段树的下标是从 1 开始的,所以区间是 [j+2,n]。不过为了避免讨论 j+2>n的情况,代码中仍用用j+1。

如果当前区间 mx≤v,则整个区间都不存在大于 v 的数,返回 0。

如果当前区间只包含一个元素,则找到答案,返回该元素的下标。

如果左子树包含下标 j+1,则递归左子树,因为答案可能在左子树中(也有可能最大值对应的下标小于j+1,那么答案可能仍在右子树当中)。

如果左子树返回 0,则返回递归右子树的结果

vector<int> mx;//建树

 

    void build(int o, int l, int r, vector<int> &heights) {

        if (l == r) {

            mx[o] = heights[l - 1];

            return;

        }

        int m = (l + r) / 2;

        build(o * 2, l, m, heights);

        build(o * 2 + 1, m + 1, r, heights);

        mx[o] = max(mx[o * 2], mx[o * 2 + 1]);

    }

 

    // 返回 [L,n] 中 > v 的最小下标(前三个参数表示线段树的节点信息)

    int query(int o, int l, int r, int L, int v) {

        if (v >= mx[o]) { // 最大值 <= v,没法找到 > v 的数

            return 0;

        }

        if (l == r) { // 找到了

            return l;

        }

        int m = (l + r) / 2;

        if (L <= m) {

            int pos = query(o * 2, l, m, L, v); // 递归左子树

            if (pos > 0) { // 找到了

                return pos;

            }

        }

        return query(o * 2 + 1, m + 1, r, L, v); // 递归右子树

    }

 

矩阵中行列的分别查询可以分别用行和列的线段树去维护(P3801)

因为这题的修改实际上是只对于行或列的修改(将行列各看成是一个数组之后实际上就是单点修改),并不是对于区间中的修改,而且询问时是一个范围内的询问(区间询问),因此并不适合用二维差分去做,应考虑用线段树去完成

int x[400005],y[400005];

 

//这里用的是不开结构体数组的方法

void update(int *a,int p,int l,int r,int num){  //有两个线段树数组时就要说明要更改的是哪个

    if(l==r){

        a[num]^=1;

        return ;

    }

    int mid=r+l>>1;

    if(p<=mid) update(a,p,l,l+r>>1,num<<1);

    else update(a,p,(l+r>>1)+1,r,num<<1|1);

    a[num]=a[num<<1]+a[num<<1|1];

}

 

int query(int *a,int ll,int rr,int l,int r,int num){

    if(ll<=l && r<=rr){

        return a[num];

    }

    int mid=l+r>>1,ans=0;

    if(ll<=mid) ans+=query(a,ll,rr,l,l+r>>1,num<<1);

    if(mid<rr) ans+=query(a,ll,rr,(l+r>>1)+1,r,num<<1|1);

    return ans;

}

 

权值线段树

用于解决变化数组中第k大元素的查询

cf1945F

给定一个数组v和排列p,若在v中选取k个数,那么v中下标为p1,p2...pk-1的元素都将被修改为0,并且选取k个数的最后权值为k*min_element

显然应该枚举k的选取,针对每个k,求可能获得的最大值

可以用权值线段树完成

动态操作体现在add(, ,-1)

int t,n,cnt,id;

int a[N],b[N],c[N],f[N<<2];

ll ans;

void add(int k,int l,int r,int x,int y){

    if(l>x || r<x) return;

    if(l==r){

        f[k]+=y;

        return;

    }

    int mid=(l+r)>>1;

    add(k<<1,l,mid,x,y);;

    add(k<<1|1,mid+1,r,x,y);

    f[k]=f[k<<1]+f[k<<1|1];

}

 

int ask(int k,int l,int r,int x){

    if(l==r) return l;

    int sum=f[k<<1],mid=(l+r)>>1;

    if(sum>=x) return ask(k<<1,l,mid,x);//判断第k大在当前节点的左子树还是右子树

    else return ask(k<<1|1,mid+1,r,x-sum);

}

void solve() {

    ans=0;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        b[i]=a[i];

    }

    for(int i=1;i<=n;i++){

        cin>>c[i];

    }

    //离散化

    sort(b+1,b+n+1);

    int m=unique(b+1,b+n+1)-b-1;

    for(int i=1;i<=n;i++){

        a[i]=lower_bound(b+1,b+m+1,a[i])-b;//标记这是第几大的元素

        add(1,1,m,a[i],1);//对应线段树节点元素的个数+1

    }

    for(int i=1;i*2-1<=n;i++){

        //线段树是从小到大赋值的,因此将第i大转化为第x小

        int k=ask(1,1,m,(n-i+1)-i+1);

        if(1ll*b[k]*i>ans){

            ans=1ll*b[k]*i,id=i;

        }

        //根据题意,将对应元素的权值修改为0

        add(1,1,m,a[c[i]],-1);

    }

    cout<<ans<<' '<<id<<'\n';

    memset(f,0,sizeof(f));//清空

}

 

P3372

模板题

单点修改,区间查询

利用线段树是对管理区间中特定数值的维护,线段树中的节点维护对应区间的权值

同时利用懒标记进行优化

inline void build(i64 p,i64 l,i64 r){

    if(l==r){

        tree[p]=a[l];

        return;

    }

    i64 mid=(l+r)>>1;

    build(p<<1,l,mid);

    build(p<<1|1,mid+1,r);

    tree[p]=tree[p<<1]+tree[p<<1|1];

}

inline void update(i64 nl,i64 nr,i64 l,i64 r,i64 p,i64 k){

    if(nl<=l&&r<=nr){

        tree[p]+=(r-l+1)*k;

        tag[p]+=k;

        return;

    }

    i64 mid=(l+r)>>1;

    if(tag[p]){

        tree[p<<1]+=(mid-l+1)*tag[p];

        tree[p<<1|1]+=(r-mid)*tag[p];

        tag[p<<1]+=tag[p];

        tag[p<<1|1]+=tag[p];

        tag[p]=0;

    }

    if(nl<=mid) update(nl,nr,l,mid,p<<1,k);

    if(nr>mid) update(nl,nr,mid+1,r,p<<1|1,k);

    tree[p]=tree[p<<1]+tree[p<<1|1];

}

inline i64 query(i64 ql,i64 qr,i64 l,i64 r,i64 p){

    if(ql<=l&&r<=qr) return tree[p];

    i64 mid=(l+r)>>1;

    if(tag[p]){

        tree[p<<1]+=(mid-l+1)*tag[p];

        tree[p<<1|1]+=(r-mid)*tag[p];

        tag[p<<1]+=tag[p];

        tag[p<<1|1]+=tag[p];

        tag[p]=0;

    }

    i64 res=0;

    if(ql<=mid) res+=query(ql,qr,l,mid,p<<1);

    if(qr>mid) res+=query(ql,qr,mid+1,r,p<<1|1);

    return res;

}

void solve(){

    int n,m;

    cin>>n>>m;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    build(1,1,n);

    for(int i=1;i<=m;i++){

        int op;

        cin>>op;

        if(op==1){

            int l,r,k;

            cin>>l>>r>>k;

            update(l,r,1,n,1,k);

        }else{

            int l,r;

            cin>>l>>r;

            cout<<query(l,r,1,n,1)<<'\n';

        }

    }

}

 

P3373

模板题

区间修改,区间查询

不同的是有区间加和区间乘两种操作

进行修改的时候显然还是用懒标记进行优化

先乘后加是指在做区间乘法操作的时候把加法标记也乘上这个数

但如果在处理标记时选择先加再乘,则会出现问题

首先标记的含义并不是对父节点进行操作,因为在打上标记的同时父节点的值已经处理完毕了,因此标记实际上是用来提示对子节点应该如何操作的

如果我们进行的操作是先加2,再乘3,最后再加4

如果加法标记先处理

在第三次操作时,我们的处理是

sum=(a[1]+2+4)*3+(a[2]+2+4)*3+(a[3]+2+4)*3;

并不是正确情况,即

sum=(a[1]+2)*3+4+(a[2]+2)*3+4+(a[3]+2)*3+4;

如果要使得形式不变但结果等价,则必须将加法标记的数值除以乘法标记的数值

sum=(a[1]+2+4/3)*3+(a[2]+2+4/3)*3+(a[3]+2+4/3)*3;

但这样操作会带来精度温度

但如果是选择先乘后加,即进行乘法标记的处理时,将乘法的值赋给加法标记

则得到

sum=(a[1]*3+2*3+4)+(a[2]*3+2*3+4)+(a[3]*3+2*3+4);

就不会出现精度问题

//区间修改,区间查询

struct node{

    i64 val,mul,add;

}tree[400005];

i64 a[100005];

int mod;

inline void build(int p,int l,int r){

    tree[p].mul=1;

    tree[p].add=0;

    if(l==r){

        tree[p].val=a[l];

        return;

    }

    i64 mid=(l+r)>>1;

    build(p<<1,l,mid);

    build(p<<1|1,mid+1,r);

    tree[p].val=tree[p<<1].val+tree[p<<1|1].val;

    tree[p].val%=mod;

}

 

inline void push_down(int p,int l,int r){

    i64 mid=(l+r)>>1;

    //乘法优先进行标记的传递

    //因为先乘后加不会出现精度问题

    tree[p<<1].val=(tree[p<<1].val*tree[p].mul+tree[p].add*(mid-l+1))%mod;

    tree[p<<1|1].val=(tree[p<<1|1].val*tree[p].mul+tree[p].add*(r-mid))%mod;

    tree[p<<1].mul=(tree[p<<1].mul*tree[p].mul)%mod;

    tree[p<<1|1].mul=(tree[p<<1|1].mul*tree[p].mul)%mod;

    tree[p<<1].add=(tree[p<<1].add*tree[p].mul+tree[p].add)%mod;

    tree[p<<1|1].add=(tree[p<<1|1].add*tree[p].mul+tree[p].add)%mod;

    tree[p].mul=1;

    tree[p].add=0;

}

//乘

inline void update1(int nl,int nr,int l,int r,int p,i64 k){

    if(nl<=l&&r<=nr){

        tree[p].val=(tree[p].val*k)%mod;

        tree[p].mul=(tree[p].mul*k)%mod;

        tree[p].add=(tree[p].add*k)%mod;

        return;

    }

    i64 mid=(l+r)>>1;

    push_down(p,l,r);

    if(nl<=mid) update1(nl,nr,l,mid,p<<1,k);

    if(nr>mid) update1(nl,nr,mid+1,r,p<<1|1,k);

    tree[p].val=(tree[p<<1].val+tree[p<<1|1].val)%mod;

}

//加

inline void update2(int nl,int nr,int l,int r,int p,i64 k){

    if(nl<=l&&r<=nr){

        tree[p].val=(tree[p].val+k*(r-l+1))%mod;

        tree[p].add=(tree[p].add+k)%mod;

        return;

    }

    i64 mid=(l+r)>>1;

    push_down(p,l,r);

    if(nl<=mid) update2(nl,nr,l,mid,p<<1,k);

    if(nr>mid) update2(nl,nr,mid+1,r,p<<1|1,k);

    tree[p].val=(tree[p<<1].val+tree[p<<1|1].val)%mod;

}

inline i64 query(int ql,int qr,int l,int r,int p){

    if(ql<=l&&r<=qr){

        return tree[p].val;

    }

    i64 mid=(l+r)>>1;

    push_down(p,l,r);

    i64 res=0;

    if(ql<=mid) res+=query(ql,qr,l,mid,p<<1);

    if(qr>mid) res+=query(ql,qr,mid+1,r,p<<1|1);

    return res%mod;

}

标记内容较多时还是开一个结构体会比较方便

 

P2412

维护区间最值

比较特殊的是返回的时候要返回原来的字符串

struct node{

    int l,r;

    string maxn,out;

}tr[1200001];

string max(string a,string b){return a>b?a:b;}

string min(string a,string b){return a<b?a:b;}

string ans,kp,Out;

inline void pushup(int x){

    if(tr[x<<1].maxn>tr[x<<1|1].maxn){

        tr[x].maxn=tr[x<<1].maxn;

        tr[x].out=tr[x<<1].out;

    }

    else {

        tr[x].maxn=tr[x<<1|1].maxn;

        tr[x].out=tr[x<<1|1].out;

    }

}

void build(int l,int r,int x){

    tr[x].l=l;

    tr[x].r=r;

    if(l==r){

        cin>>tr[x].maxn;tr[x].out=tr[x].maxn;

        for(int i=0;i<tr[x].maxn.size();++i)tr[x].maxn[i]=tolower(tr[x].maxn[i]);

        ans=min(ans,tr[x].maxn);

        return;

    }

    int mid=l+r>>1;

    build(l,mid,x<<1);

    build(mid+1,r,x<<1|1);

    pushup(x);

}

int n,m;

void query(int l,int r,int x){

    if(tr[x].l>=l&&tr[x].r<=r){

        if(tr[x].maxn>ans){

            ans=tr[x].maxn;

            Out=tr[x].out;

        }

        return;

    }

    int mid=tr[x].l+tr[x].r>>1;

    if(l<=mid)query(l,r,x<<1);

    if(mid<r)query(l,r,x<<1|1);

}

 

P1253

维护区间最值,支持区间修改,修改分为两种操作,一种是直接进行值的覆盖,另一种是进行区间加操作

关键仍就是对标记传递的处理

加法打标记时如果复制标记存在则直接给复制标记加上对应值,不修改加标记,否则再修改加标记

打覆盖标记时先清空加标记即可

因为覆盖标记有清空的影响,因此对两个标记进行操作时,优先处理覆盖标记

//线段树维护区间最大值

struct node{

    i64 val,add,tag;

}tree[4000005];

i64 a[1000005];

inline void build(int p,int l,int r){

    if(l==r){

        tree[p].val=a[l];

        return;

    }

    tree[p].add=0;

    tree[p].tag=INT_MAX;

    i64 mid=(l+r)>>1;

    build(p<<1,l,mid);

    build(p<<1|1,mid+1,r);

    tree[p].val=(tree[p<<1].val>tree[p<<1|1].val?tree[p<<1].val:tree[p<<1|1].val);

}

inline void push_down(int p,int l,int r){

    if(tree[p].tag!=INT_MAX){

        tree[p<<1].tag=tree[p<<1|1].tag=tree[p].tag;

        tree[p<<1].val=tree[p].tag;

        tree[p<<1|1].val=tree[p].tag;

        tree[p<<1].add=tree[p<<1|1].add=0;

        tree[p].tag=INT_MAX;

    }else if(tree[p].add!=0){

        if(tree[p<<1].tag!=INT_MAX) tree[p<<1].tag+=tree[p].add;

        else tree[p<<1].add+=tree[p].add;

        if(tree[p<<1|1].tag!=INT_MAX) tree[p<<1|1].tag+=tree[p].add;

        else tree[p<<1|1].add+=tree[p].add;

        tree[p<<1].val+=tree[p].add;

        tree[p<<1|1].val+=tree[p].add;

        tree[p].add=0;

    }

}

inline void update1(int nl,int nr,int l,int r,int p,i64 k){

    if(nl<=l&&r<=nr){

        tree[p].val=k;

        tree[p].tag=k;

        tree[p].add=0;

        return;

    }

    i64 mid=(l+r)>>1;

    push_down(p,l,r);

    if(nl<=mid) update1(nl,nr,l,mid,p<<1,k);

    if(nr>mid) update1(nl,nr,mid+1,r,p<<1|1,k);

    tree[p].val=(tree[p<<1].val>tree[p<<1|1].val?tree[p<<1].val:tree[p<<1|1].val);

}

//加

inline void update2(int nl,int nr,int l,int r,int p,i64 k){

    if(nl<=l&&r<=nr){

        tree[p].val+=k;

        if(tree[p].tag!=INT_MAX) tree[p].tag+=k;

        else tree[p].add+=k;

        return;

    }

    i64 mid=(l+r)>>1;

    push_down(p,l,r);

    if(nl<=mid) update2(nl,nr,l,mid,p<<1,k);

    if(nr>mid) update2(nl,nr,mid+1,r,p<<1|1,k);

    tree[p].val=(tree[p<<1].val>tree[p<<1|1].val?tree[p<<1].val:tree[p<<1|1].val);

}

inline i64 query(int ql,int qr,int l,int r,int p){

    if(ql<=l&&r<=qr){

        return tree[p].val;

    }

    i64 mid=(l+r)>>1;

    push_down(p,l,r);

    i64 a=-inff,b=-inff;//必须初始化

    if(ql<=mid) a=query(ql,qr,l,mid,p<<1);

    if(qr>mid) b=query(ql,qr,mid+1,r,p<<1|1);

    return ((a>b)?a:b);

}

 

cf242E

维护区间和,支持区间异或

因为是异或操作,但如果是直接对原数组进行操作,显然我们并不能同时维护区间和

因此想到位运算

如果将异或运算放到每一位上去看,那么每一次操作无非是将0变成1或者将1变成0,但因为整段区间的长度是已知的,因此可以根据我们原来维护的值得到修改后的值

因此将每个元素按位拆开维护,即相当于对每一位用一棵线段树去处理

int a[100005];

int tree[400005][20],tag[400005];

inline void build(int p,int l,int r){

    if(l==r){

        for(int i=0;i<20;i++){

            tree[p][i]=((a[l]>>i)&1);

        }

        return;

    }

    int mid=(l+r)>>1;

    build(p<<1,l,mid);

    build(p<<1|1,mid+1,r);

    for(int i=0;i<20;i++){

        tree[p][i]=tree[p<<1][i]+tree[p<<1|1][i];

    }  

}

void push_down(int p,int l,int r){

    int mid=(l+r)>>1;

    for(int i=0;i<20;i++){

        if((tag[p]>>i)&1){

            tree[p<<1][i]=mid-l+1-tree[p<<1][i];

            tree[p<<1|1][i]=r-mid-1+1-tree[p<<1|1][i];

        }

    }

    tag[p<<1]^=tag[p];

    tag[p<<1|1]^=tag[p];

    tag[p]=0;

}

void update(int l,int r,int nl,int nr,int p,int k){

    if(l>=nl&&r<=nr){

        for(int i=0;i<20;i++){

            if((k>>i)&1){

                tree[p][i]=r-l+1-tree[p][i];

            }

        }

        tag[p]^=k;

        return;

    }

    int mid=(l+r)>>1;

    push_down(p,l,r);

    if(nl<=mid) update(l,mid,nl,nr,p<<1,k);

    if(nr>mid) update(mid+1,r,nl,nr,p<<1|1,k);

    for(int i=0;i<20;i++){

        tree[p][i]=tree[p<<1][i]+tree[p<<1|1][i];

    }

}

inline i64 query(int l,int r,int nl,int nr,int p){

    if(l>=nl&&r<=nr){

        i64 ans=0;

        for(int i=0;i<20;i++){

            ans+=(1ll<<i)*tree[p][i];

        }

        return ans;

    }

    int mid=(l+r)>>1;

    push_down(p,l,r);

    i64 ans=0;

    if(nl<=mid) ans+=query(l,mid,nl,nr,p<<1);

    if(nr>mid) ans+=query(mid+1,r,nl,nr,p<<1|1);

    return ans;

}

 

牛客24多校3 H

给k个在n*m方格表上的矩形,求所有至少一个矩形的组合的非空交的最小面积大小

线性算法

首先,矩形交仍然是矩形,为了方便说明,额外引入一个覆盖整个方格表的大矩形

利用前缀和以及简单的动态规划,对某个各自(x,y),寻找如下四个值

最大的u使得(u,y)是某个矩形最上边一行的格子

最小的d使得(d,y)是某个矩形最下边一行的格子

最大的l使得(x,l)是某个矩形最左边一列的格子

最小的r使得(x,r)是某个矩形最右边一列的格子

如果u与x相等且l与y相等用(r-l+1)(d-u+1)和当前存储的最小答案取较小值更新答案

 

单点修改,区间最大子段和

 

 

半群单点修改,区间求积

给定n个一次函数形成的序列,f0,f1,...fn-1,按照顺序执行以下q个询问

p,a,b  将fp修改为fp(x)=ax+b

l,r,x  求fr-1(...fl+1(fl(x))) mod 998244353

也就是给一个序列,每个序列里的元素是个一次函数,函数形式以ax+b的方式给出

 

半群,若集合S和二元运算op:SxS->S满足对于任意x,y,z属于S都有op(op(x,y),z)=op(x,op(y,z)),则称(S,op)为半群

两个一次函数复合仍然是一个一次函数,也就是a2(a1x+b1)+b2=a2a1x+a2b1+b1,f1,f2

现在考虑一系列这样的操作,也就是fl,fl+1,...,fr-1,因为两个线性复合得到的仍然是线性函数,并且先将整个序列分成两部分分别操作再将两部分操作所得到的结果是一致的,也就是说我们可以把一个区间的询问拆分成两个区间的询问再合并起来

(也就是具有结合律)

考虑如何去维护

这种区间合并拆分的性质容易联想到线段树

根节点对应区间为[0,n)

叶子节点对应区间为满足r-l=1的区间[l,r)

每个节点维护对应区间半群积

考虑修改,就是从叶子节点出发,一直往上,把值修改后,对于父节点的值,重新计算左节点和右节点复合起来的值,这样修改的复杂度就是O(logn)

对于查询,将区间进行拆分,然后从左到右这样结合起来

 

半群区间自同态作用,区间求积

给定长度为n的整数序列a0,a1...am-1,按顺序执行以下q个询问

l,r,b,c,对所有i属于[l,r),将ai修改为ai*b+c

l,r, 求该区间内的ai求和 mod 998244353

 

自同态

若f:S->S满足对任意x,y属于S,f(op(x,y))=op(f(x),f(y)),称f为S的自同态,S的所有自同态的集合即为End(S)

考虑全局的区间,我们需要维护什么信息才能够在给出这样一个修改之后能够马上知道修改后的和

首先发现我们对这个区间实际上要知道两个事情,第一个是本来这个区间中元素的和,还有一个就是原来这个区间的长度,这样在一次操作之后,这个区间和就会变为b*sigma(a)+c*len

并且在小区间合并的过程中,这两个东西是可以直接相加的

并且同样是具有结合律的,因此这样的和以及长度构成的二元组同样是一个半群

现在再考虑这个修改操作,发现它直接作用在父节点上的结果和分别作用在左边和右边,再加起来得到的结果是一致的,相当于具有一种分配律的性质

现在考虑用线段树去维护这个问题

结点除了要维护半群积,还需要维护自同态

结点实际的半群积是其维护的半群积按由近到远(因为操作有顺序限制,例如b2*c1等)经过所有祖先的自同态作用后得到的元素

作用某个结点对应区间时,只需要修改该结点

在访问某个结点前,其所有祖先的自同态都需要通过分别作用于其左右子结点,修改为恒等映射

通常半群自同态-半群元素对也称为标记-值对,常用于Treap等其他平衡树

就是每个点还要另外维护它所拥有的作用(类似于懒标记),而一个区间真实的值就是要从下往上处理它祖先的作用所得到的值

现在再考虑怎么去修改

对每个小区间,直接把作用的标记打在节点上,这样,它底下的所有节点在形式上就已经修改完成了(因为下面的节点在计算真实值时一定会经过这个节点)

对于查询,首先仍然是分割成若干个区间,现在我们需要知道它真实的值是什么,而不是要知道这个区间需要加什么,因此我们需要在每次访问节点之前将节点的标记给它下放到两个子节点标记中,这样就保证了进行完操作之后,每个节点它对应的真实值是不会变的,这样对于一个节点来说,它祖先的所有作用都变成了同等作用,那么它现在区间里面存的值就是它真实的值

(一个节点,它真实的值是一直往上走经过的标记依次操作得到的,感觉本质上和懒标记是一致的,不过不是简单的加,而是一种函数映射,因此可能标记下放时具体的计算有些顺序要注意)

 

树上一次函数单调修改,路径复合

给定n个节点的数,每个节点u有一个一次函数fu,按顺序执行以下q个询问

p,a,b  将fp修改为fp(x)=ax+b

u,v,x  求fpk(...fp1(fp0(x))) mod998244353  其中p0=u,p1...pk=v是树上简单路径

 

也就是将之前的问题放到树上,之前是对一个区间进行操作,现在是对树上的一条路径进行操作

可以用数链剖分的思想,将问题转化为O(logn)个区间问题

找到询问路径的lca,然后这样就拆分成了两条小链,相当于把修改和询问都拆分成了重链上面的一些操作,而具体一条链上的操作等价于一个区间上的操作,可以用之前的方法去解决,但因为路径,有些是从下往上走而有些是从上往下走的,因为操作顺序的不同,这两者的结果是不同的,因此每条重链我们需要用两个线段树去维护,分别表示从左往右还是从右往左的运算规则

 

珂朵莉树:
珂朵莉树(Chtholly Tree),又名老司机树 ODT(Old Driver Tree)(T715)(T2276)

这种想法的本质是基于数据随机的「颜色段均摊」,而不是一种数据结构

核心思想是把值相同的区间合并成一个结点保存在 set 里面

只要是有区间赋值操作的数据结构题都可以用来骗分,在数据随机的情况下一般效率较高,但在不保证数据随机的场合下,会被精心构造的特殊数据卡到超时

 

节点的保存方式

struct Node_t {

int l, r;

mutable int v;

Node_t(const int &il, const int &ir, const int &iv) : l(il), r(ir), v(iv) {}

bool operator<(const Node_t &o) const { return l < o.l; } 

//这里说明了是按照左端点的大小进行排序的,set满足不重复且有序的存储

};

其中 int v 是自行指定的附加数据    mutable关键字的意思是可变的,让我们可以在后面的操作中修改v的值,在 C++ 中,mutable 是为了突破 const 的限制而设置的。被 mutable 修饰的变量(mutable 只能用于修饰类中的非静态数据成员),将永远处于可变的状态,即使在一个 const 函数中,这意味着我们可以直接修改已经插入set的元素的v值,而不用将该元素取出后重新加入set

 

然后,我们定义一个set<Node_t>odt 来维护这些节点,为简化代码,也可以typedef set<Node_t>::iterator iter,也可以使用auto

 

split

split是最核心的操作之一,用于将原本包含点x的区间(设为[l,r])分裂为两个区间[l,x)和[x,r]并返回指向后者的迭代器

auto split(int x) {

if (x > n) return odt.end();  x大于所有能表示的区间

auto it = --odt.upper_bound(Node_t{x, 0, 0});  //二分查找第一个小于等于x的地址

if (it->l == x) return it;  //相当于原本的区间就是[x,r]

int l = it->l, r = it->r, v = it->v;

odt.erase(it);

odt.insert(Node_t(l, x - 1, v));

return odt.insert(Node_t(x, r, v)).first;

}

这样对于任意[l,r]的区间操作,都可以转换成set上[split(l),split(r+1)]的操作(转换成两个区间的操作)

 

assign

用于对一段区间进行赋值,对于 ODT 来说,区间操作只有这个比较特殊,也是保证复杂度的关键。 如果 ODT 里全是长度为1的区间,就成了暴力,但是有了 assign,可以使 ODT 的复杂度大小下降

void assign(int l, int r, int v) {

auto itr = split(r + 1), itl = split(l);  

//itr指向[r+1,...  itl指向[l,...  的区间,注意这两个区间不一定是相邻的

odt.erase(itl, itr);    //注意这里是迭代器的删除操作,即删除这两个迭代器中的所有元素

odt.insert(Node_t(l, r, v));

}

 

其他操作,套模版即可

void performance(int l, int r) {

auto itr = split(r + 1), itl = split(l);

for (; itl != itr; ++itl) {

// Perform Operations here

}

}
珂朵莉树在进行取区间左右端点操作时,必须先split右端点,再split左端点,若先split左端点,返回的迭代器可能在split右端点时失效,可能会导致PE

 

因此也可以用来解决数组区间上的增加合并区间操作,用一颗平衡树(即set,map这类)维护不相交的区间,每次 add(left,right) 时,删除被该区间覆盖到的区间(部分覆盖也算),然后将被删除的区间与[left,right]合并成一个新的大区间(并集),插入平衡树中

代码实现时,为方便找到第一个被 [left,right]覆盖到的区间,我们可以用平衡树的 key存区间右端点,value存区间左端点。我们要找的就是第一个 key≥left的区间

例(T2276)

class CountIntervals {

    map<int, int> m;

    int cnt = 0; // 所有区间长度和

 

public:

    CountIntervals() {}

 

    void add(int left, int right) {

        // 遍历所有被 [left,right] 覆盖到的区间(部分覆盖也算)

        for (auto it = m.lower_bound(left); it != m.end() && it->second <= right; m.erase(it++)) {

 //注意不能使用erase(it),it++;erase后不能使用原来的it,因此要先把it移动后再删除

            int l = it->second, r = it->first;

            left = min(left, l);   // 合并后的新区间,其左端点为所有被覆盖的区间的左端点的最小值

            right = max(right, r); // 合并后的新区间,其右端点为所有被覆盖的区间的右端点的最大值

            cnt -= r - l + 1;

        }

        cnt += right - left + 1;

        m[right] = left; // 所有被覆盖到的区间与 [left,right] 合并成一个新区间

    }

 

    int count() { return cnt; }

};

 

 

字典树(Trie)(T208):

Trie树是一种用于快速查询某个字符串/字符前缀是否存在的数据结构,核心是用边来代表有无字符,使用点来记录是否为单词结尾以及后续字符串的字符是什么

(只保存小写字符的)「前缀树」是一种特殊的多叉树,它的 TrieNode 中 chidren 是一个大小为 26 的一维数组,分别对应了26个英文字符 'a' ~ 'z',也就是说形成了一棵 26叉树。

前缀树的结构可以定义为下面这样

里面存储了两个信息:

isWord 表示从根节点到当前节点为止,该路径是否形成了一个有效的字符串。

children 是该节点的所有子节点。

其中根节点不保存任何信息,关键词放到前缀树时,需要把他拆成各个字符,下一个字符是当前字符的子节点,一个输入字符串构建前缀树结束的时候,需要把该节点的isWord标记成true,说明根节点到当前节点的路径构成了一个关键词

性质:

Trie 的形状和单词的插入或删除顺序无关,也就是说对于任意给定的一组单词,Trie 的形状都是唯一的。

查找或插入一个长度为 L 的单词,访问 next 数组的次数最多为 L+1,和 Trie 中包含多少个单词无关。

Trie 的每个结点中都保留着一个字母表,这是很耗费空间的。如果 Trie 的高度为 n,字母表的大小为 m,最坏的情况是 Trie 中还不存在前缀相同的单词,那空间复杂度就为 O(m^n)

class Trie {

private:

    bool isEnd;

    Trie* next[26];

public:

    Trie() {

        isEnd = false;

        memset(next, 0, sizeof(next));

    }

   

    void insert(string word) {

        Trie* node = this;

        for (char c : word) {

            if (node->next[c-'a'] == NULL) {

                node->next[c-'a'] = new Trie();

            }

            node = node->next[c-'a'];

        }

        node->isEnd = true;

    }

   

    bool search(string word) {

        Trie* node = this;

        for (char c : word) {

            node = node->next[c - 'a'];

            if (node == NULL) {

                return false;

            }

        }

        return node->isEnd;

    }

   

    bool startsWith(string prefix) {

        Trie* node = this;

        for (char c : prefix) {

            node = node->next[c-'a'];

            if (node == NULL) {

                return false;

            }

        }

        return true;

    }

};

应用:解决与字符前缀相关的问题(T2416,转化为每次插入节点时,该节点代表的分数+1)

实际实现时可只定义一个node结构体

struct Node {

            Node *son[26]{};

            int score = 0;

        };

        Node *root = new Node();

        for (auto &word : words) {

            auto cur = root;

            for (char c : word) {

                c -= 'a';

                if (cur->son[c] == nullptr) cur->son[c] = new Node();

                cur = cur->son[c];

                ++cur->score; // 更新所有前缀的分数

            }

        }

 

        int n = words.size();

        vector<int> ans(n);

        for (int i = 0; i < n; ++i) {

            auto cur = root;

            for (char c : words[i]) {

                cur = cur->son[c - 'a'];

                ans[i] += cur->score; // 累加分数,即可得到答案

            }

        }

 

字典树的应用:

检索字符串:查找一个字符串是否在字典中出现过

AC自动机:trie树是AC自动机的一部分

 

维护异或极值:将数的二进制看作一个字符串,就可以建出字符集为{0,1}的trie树

P4551

给定一个n个节点的带权数,节点下标从1开始到n,求树中两个节点的最长异或路径

随便指定一个根root,用T(u,v)表示u和v之间路径的边权异或和,那么T(u,v)=T(root,u)^T(root,v),因为LCA以上的部分可以通过相同数字的异或抵消掉

因此,可以考虑将所有T(root,u)插入到一棵trie中,这样就可以对每个T(root,u)快速求出它异或和最大的T(root,v)(例如,1号节点到根的异或和是0,2号节点到根的异或和是3,3号节点到根的异或和是7,4号节点到根的异或和是4,那么他们的二进制就分别表示为0000,0011,0111,0101,这样就可以看作是字符集为{0,1}的字符串,进而就可以构建trie树了)

于是我们可以进行贪心的路径选择:从trie的根开始,如果能和T(root,u)当前位不同的子树走,就顺着这条路径向下走,否则就只能按着当前的路径向下走,没有其他选择

贪心的正确性:我们走过的路径从前往后构成了我们获得的路径权值,而显然位运算中高位的1是优于低位的1的,因此能使得高位为1就贪心的使得其为1

void insert(int x){

    for(int i=30,u=1;i>=0;i--){

        int c=((x>>i)&1);

        if(!ch[u][c]){

            ch[u][c]=++tot;//类似于动态开点

        }

        u=ch[u][c];

    }

}

 

void get(int x){

    int res=0;

    for(int i=30,u=1;i>=0;i--){

        int c=((x>>i)&1);

        if(ch[u][c^1]){//如果能走向与这一位不同的

            u=ch[u][c^1];

            res|=(1<<i);//结果的这一位为1

        }else{

            u=ch[u][c];

        }

    }

    ans=max(ans,res);

}

void add(int u,int v,int w){

    nxt[++cnt]=head[u];

    head[u]=cnt;

    to[cnt]=v;

    weight[cnt]=w;

}

void dfs(int u,int f){//遍历树上的每一个节点

    insert(dis[u]);

    get(dis[u]);

    for(int i=head[u];i;i=nxt[i]){

        int v=to[i];

        if(v==f) continue;

        dis[v]=dis[u]^weight[i];//一个节点到根的路径是唯一的

        dfs(v,u);

    }

}

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<n;i++){

        int u,v,w;

        cin>>u>>v>>w;

        add(u,v,w);

        add(v,u,w);//无向边

    }

    dfs(1,0);

    cout<<ans<<'\n';

}

 

25 杭电 春3 1007

给定一个长度为n的数组a,q次查询,每次查询给定l,r,x,表示要求出数组[l,r]区间内,元素异或x的最值

如果只是一般的前缀询问,实际上就可以用一般的trie树解决,也就是从树根开始,判断有没有符合我们要求的节点,即询问的x在当前位是0,那么就查看当前节点有没有为1的子节点,如果有那么就向那个方向移动

但是问题在于现在是给定了一个区间,考虑到前缀和区间的关系,自然联想到能不能构造出类似于前缀和数组的形式,只不过数组中的每个元素是个字典树

因为trie树上维护的信息具有可减性,因此是可行的,为了维护前缀的信息,可以类似主席树的形式写一个持久化字典树

具体来说,用tr存储所有的字典树节点,用rt来存储每个前缀(也就是1~i个树)的字典树树根

每次插入新数的时候,首先创建当前节点的副本,然后更新当前指向的节点,并增加当前节点的计数(注意是在原拷贝上更新的,因此正确记录了前缀下有多少个数在这个位置,从而查询的时候可以依据这个信息判断给定区间中有没有符合条件的数存在)

因为每个数都会有32个位置(长度),因此字典树节点的大小是元素个数*32

constexpr int N = 2e5 * 32 + 5;

constexpr int M = 2e5 + 5;

 

struct node{

    int child[2];

    int sum;

}tr[N];

 

int tot;

int rt[M];

 

void insert(int &p,int t,const int x){

    tr[++tot]=tr[p];

    p=tot;

    ++tr[p].sum;

    if(t<0){

        return;

    }

    int v=(x>>t)&1;

    insert(tr[p].child[v],t-1,x);

}

 

int query(int l,int r,int x){

    int res=0;

    for(int k=30;k>=0;k--){

        int fv=((x>>k)&1)^1;

        if(tr[tr[r].child[fv]].sum-tr[tr[l].child[fv]].sum>0){

            res|=(1<<k);

            if(r!=0) r=tr[r].child[fv];

            if(l!=0) l=tr[l].child[fv];

        }else{

            if(r!=0) r=tr[r].child[fv^1];

            if(l!=0) l=tr[l].child[fv^1];

        }

    }

    return res;

}

 

void solve() {  

    tot=0;

    int n,q;

    cin>>n>>q;

    for(int i=1;i<=n;i++){

        int x;

        cin>>x;

        rt[i]=rt[i-1];

        insert(rt[i],30,x);

    }

    while(q--){

        int l,r,x;

        cin>>l>>r>>x;

        cout<<query(rt[l-1],rt[r],x)<<'\n';

    }

}  

 

 

cf 1980G

通过trie树的构建,可以在O(logx)时间 中找到数字 x这样的集合中的 y ,即 x⊕y是最大值

和上面一题一样,可以先用一次dfs构建出从它到根的路径上的xor,这样的时间复杂度是O(n)的

第一种对所有边异或上一个值的操作可以直接执行,因为异或的性质,只有奇数深度的顶点的值才会发生变化(因为偶数的话我们在计算根到该点的边权重异或之和的时候y就已经被抵消了)

对于第二类操作,因为在v和u之间(虚拟)增加了一条从v到u权重为x的双向边,因此简单循环v->lca(v,u)->u->v的边权xor等于dv^dx^x

//字典树

constexpr int N=1e7;

int trie[N][2];

int cnt[N][2];//因为要奇偶分别讨论,因此cnt分别记录

int tot=0;

int newNode(){

    int x=++tot;

    trie[x][0]=trie[x][1]=0;

    cnt[x][0]=cnt[x][1]=0;

    return x;

}

void add(int x,int d,int t=1){

    int p=1;

    cnt[p][d]+=t;

    for(int i=29;i>=0;i--){//因为值域范围就是1e9

        int u=x>>i&1;

        if(!trie[p][u]){

            trie[p][u]=newNode();

        }

        p=trie[p][u];

        cnt[p][d]+=t;//一般trie树中就是cnt[p]++

    }

}

int query(int x,int d){

    int p=1;

    if(!cnt[p][d]){

        return 0;

    }

    int ans=0;

    for(int i=29;i>=0;i--){

        int u=x>>i&1;

        if(cnt[trie[p][u^1]][d]){//走该位不同的边

            ans|=1<<i;

            p=trie[p][u^1];

        }else{

            p=trie[p][u];

        }

    }

    return ans;

}

void solve() {

    //让环上的路径异或和最大

    //对深度(根节点的深度为0)是奇数还是偶数进行分类讨论

    //因为在我们后续查找路径的时候是对所有边进行异或运算

    //因此如果深度是偶数,那么异或上的y实际上并不会对路径的权值产生影响

    int n,m;

    cin>>n>>m;

    vector<vector<pair<int,int>>> adj(n);

    for(int i=1;i<n;i++){

        int u,v,w;

        cin>>u>>v>>w;

        --u,--v;

        adj[u].push_back({v,w});

        adj[v].push_back({u,w});

    }

    vector<int> a(n),d(n);

    auto dfs=[&](auto &&self,int x,int p)->void{

        for(auto [y,w]:adj[x]){

            if(y==p) continue;

            d[y]=d[x]^1;//记录深度是不是奇数

            a[y]=a[x]^w;

            self(self,y,x);

        }

    };

    dfs(dfs,0,-1);

    tot=0;

    newNode();

    for(int i=0;i<n;i++){//将当前的各个边放入trie树中

        add(a[i],d[i]);

    }

    int w=0;//记录总共异或了多少

    while(m--){

        char o;

        cin>>o;

        if(o=='^'){

            //异或上y

            int y;

            cin>>y;

            w^=y;

        }else{

            int v,x;

            cin>>v>>x;

            v--;

            //第一种情况,距离是偶数,那么w是没用的,否则就是异或上w同时d[v]取反

            add(a[v],d[v],-1);

            //因为u,v不能是同一个节点,因此在trie树中将v删去

            int ans=max(query(a[v]^x,d[v]),query(a[v]^x^w,d[v]^1));

            //v是确定的,而u是不确定的,但u是哪一个点并不重要,因为我们只是需要最大值

            //于是只需要对u深度的奇偶性进行讨论,如果和v同奇偶,那么无论如何w都不需要异或上,因为仍然会被抵消

            //相当于实际上有两个trie树,我们在其中一个树上计算异或极值

            add(a[v],d[v]);//再加回来

            cout<<ans<<' ';

        }

    }

    cout<<'\n';

}

 

Cf 2093G

Trie树来计算数组中与给定数异或的最大值

如果数组中存在两个数的异或值大于等于k,那么这个数组就称为美丽的

给定一个长度为n的数组a,求长度最小的美丽子数组,或者输出这样的数组不存在

判断数组中最大的异或和比较困难,但如果限制其中一个元素后,就可以通过trie树在常数时间中得到最值了

如果我们固定当前数组的右边界,那么我们一定可以得到最小的左边界

(可修改trie树,通过add时增加参数val,实现trie树的加和减)

因为我们需要的是最小的子数组长度,因此再继续增加右边界,我们一定不会让左边界减小,否则答案肯定不会变优

于是就得到了正解:用双指针遍历整个数组,右指针添加元素,左指针在最值大于等于k时向右移动,并用trie树维护当前子数组

https://codeforces.com/contest/2093/submission/314891731

 

维护异或和

01-trie是指字符集为  {0,1}的trie。01-trie可以用来维护一些数字的异或和,支持修改(删除 +重新插入),和全局加一(即:让其所维护所有数值递增 1,本质上是一种特殊的修改操作)

如果要维护异或和,需要按值从低位到高位建立 trie

插入删除

如果要维护异或和,只需要知道某一位上0和1的个数的奇偶性即可

 

P6018

一个很常见的trick

统一维护一个点所有儿子的异或和,单独维护父亲

考虑一个节点,我们要处理的就是单点修改和全局加,然后要查询全局异或和

在位运算中,对 1 个数 +1 可以看作将从低到高位的第一个 0 修改成 1,该 0 后面的 1 修改成 0

将一个节点的所有儿子对应的数放到该节点对应的 01 trie 中进行修改的话就是:将 0,1 翻转后走 0 那边,然后递归地进行翻转就行

 

P6623

 

树:
cf1060E

题目中需要计算的是点对(i,j)经过的边数,方便起见,我们将经过的边数称为距离

(当问题直接思考没什么思路的时候,可以尝试画图感受一下)

对于这种经过边的个数的问题,我们可以换个方向考虑,即考虑每条边对于答案的贡献,我们先考虑一个简化的问题:“有一个树,要求求出每个点对(i,j)经过的边数和”

我们考虑每一条边,都相当于将一棵树分成了两部分,我们可以维护该边其中一个端点作为根节点的子树的大小size,这个就是其中一部分的节点数,又因为树总的节点数目是确定的,因此剩下部分的节点数也可以得到,而经过这条边的点对组合就是两部分各选一个节点的方案数

而这道题与上面简化版本的区别在于,该题在相隔为1的节点间又连了一条边,因此,我们可以得到,一个点对的距离是在原图中的距离除二再向上取整

具体来说:偶数长度(在该新树中的距离就是除以二的结果),奇数(在该树中的距离就是加一再除以二)

从答案的定义考虑ans=∑i=1,n​∑j=i,n​(d(i,j)+[d(i,j)mod2==1])​/2

因此对于偶数长度(d(i,j)),就是之前简化版本的答案除以二,因为对于每个点对来说,他们的距离都减了一半,相当于每条边的贡献都减少了一半,而对于原树上两点之间的距离为奇数(d(i,j)mod2==1),那么在新图中它们一定要经过一条原来的边,这就是偶数项之外的贡献,并且每条奇数边的多的贡献为1,于是我们要计算∑i=1,n​∑j=i,n d(i,j)mod2==1的情况数,即奇数边的个数

这时,我们需要把树的深度求出来

因为把奇数层的节点个数乘上偶数层的节点个数就是奇数长度的个数了,因为我们一定可以将点对分成两类,一类是父子关系,一类是没有关系,对于父子关系的,一定是一个在奇数层,一个在偶数层,如果是没有关系的,那么可以分解成较浅的节点和较深的节点,对于较浅的节点,从它到较深节点的祖先节点的边数一定是偶数,因此需要关注的只有从较浅节点的那一层到较深节点的边的条数,而这正好是层数差,因为一层对应一条边,又因为整体是奇数,因此又保证这个点对其中一个点在奇数层,一个点在偶数层

void add(int x,int y){

  to[++tot]=y;

  nxt[tot]=head[x];

  head[x]=tot;

}

ll ans=0;

void dfs(int now,int fa){

  depth[now]=depth[fa]+1;

  cnt[depth[now]%2]++;

  sze[now]=1ll;

  for(int i=head[now];i;i=nxt[i]){

    if(to[i]==fa) continue;

    dfs(to[i],now);

    sze[now]+=sze[to[i]];

    ans+=sze[to[i]]*(n-sze[to[i]]);

  }

}

void solve(){

  cin>>n;

  for(int i=1;i<=n-1;i++){

    int x,y;

    cin>>x>>y;

    add(x,y);

    add(y,x);

  }

  dfs(1,0);

  ans+=cnt[0]*cnt[1];

  cout<<ans/2ll;

}

 

树的直径

cf1943C

找树的直径可以使用两次bfs(一次随机选择一个点,找到最远的点之后从那个点重新bfs找到另一个点),这样这两个点就构成了树的直径,同时要把路径记录下来

但数据范围比较小的也可以用dfs去满足这个需求

如果直接考虑树感觉有些抽象,可以先从简化的线段上去考虑操作

显然,如果是奇数长度,可以从直线的中间点开始向两端扩展染色

接着,考虑偶数的情况,因为每次染色的时候都是从中心点向两侧路径长度相等的节点进行染色,因此每次被染色的两个节点应当满足dis(u,v)为偶数

如果把直径上的点黑白间隔染色,那么这样就保证了任意同色的两个点它们之间的距离是偶数的,即我们每次染色的时候可以使其中的两个点变黑

因此如果线段上点的个数mod4余2,那么黑白两色的点的个数为奇数,即黑色m/2,白色m/2,因此对于m/2的点,黑白都只需要进行(m/2-1)/2次操作,并且剩余的一个点需要一个额外的操作去染色,因此总共的操作次数是m/2+1

但特殊情况就是m|4,这种情况下的最小操作次数是m/2而不是m/2+1

因为可以选择中间点(x,y),分别进行以x/y为中心,半径为1,3...的染色操作(可以用一些特殊情况如点数为4之类的去手玩)

有了对线段的处理模式之后,就可以考虑对树的操作了

显然我们是要对树的直径进行操作,因为直径是树上最长的路径,因此,如果直径上的点都染色完毕了,那么路径长度更短的其他相连路径也就一定被染色完毕了

typedef pair<int,int> PII;

const int N = 2e3+10, M = 30010;

vector<vector<int>> e(N);

vector<int> path;

vector<int> dfs(int u, int fa){

    vector<int> d1, d2;

    for(auto v: e[u]){

        if(v == fa) continue;

        auto res = dfs(v, u);

        if(res.size() > d1.size()){//dfs计算树的直径

            d2 = d1;

            d1 = res;

        }

        else if(res.size() > d2.size()){

            d2 = res;

        }

    }

    d1.push_back(u);

    //树的直径看作是中间一个节点作为转折点的路径连接

    //因此记录次长和最长,路径就是这两个路径上的节点的连接

    if(d1.size() + d2.size() > path.size()){

        path = d1;

        for(auto x: d2) path.push_back(x);

    }

    return d1;

}

void solve(){

    int n;

    cin>>n;

    for(int i = 1;i < n ;i++){

        int u, v;

        cin>>u>>v;

        e[u].push_back(v);

        e[v].push_back(u);

    }

    dfs(1, -1);

    int len = path.size();

    vector<PII> ans;

    if(len % 2){

        int idx = path[len / 2];

        int k = len / 2;

        while(k >= 0){

            ans.push_back({idx, k});

            k--;

        }

    }

    else{

        int idx1 = path[len / 2 - 1], idx2 = path[len / 2];

        int k = len / 2 - 1;

        while(k >= 0){

            ans.push_back({idx1, k});

            ans.push_back({idx2, k});

            k -= 2;

        }

    }

    cout<<ans.size()<<endl;

    for(auto x: ans) cout<<x.first<<" "<<x.second<<endl;

    for(int i = 1 ;i <= n ;i++) e[i].clear();

    path.clear();

}

 

杭电24多校2 1008

因为树的变化是与度数相关的,因此可以先考虑度数的变化

对于一个度数为2的点,在一次成长后会变成两个度数为2的点

对于一个度数为3的点,在一次成长后会变成一个度数为3的点和两个度数为2的点

对于一个度数为d的点,在一次成长后会变成d-2个度数为3的点和两个度数为2的点(原来与其相连的d个点各自连接一个得到的点,因此边界上的点度数为2,其余中间的点度数为3)

因此在成长之后数的度数最大为3

对以上规律进行推导,就可以得到一个度数为d的点,在成长m次后,可以得到

(d-1)*(2^m-1)+1个点(在一次成长后得到d-2个度数为3的点,2个度数为2的点,在之后的m-1次成长过程中,每次度数为2的点的个数都会乘2,也就是2*2^(m-1),而d-2个度数为3的点,每次除了自身保持不变以外,还会多增加2个度数为2的点,于是2*(d-2)*(2^(m-1)+...2^0),两者相加就可以得到上面的结果)

于是在m较小时,可以直接将改变后的点权计算出来(直接比较)

在m较大时,可以把总度数减去总点数作为第一关键字,总点数作为第二关键字,在通过总度数和点数得到直径的长度(优先让2^m的系数最大)

之后就是一般的树直径dp做法

const int mod=1e9+7;

int m;

struct len{

    int a;

    int b;

};

i64 fp(i64 x,i64 y){ // 快速幂

    int res=1;

    for(;y;y>>=1){

        if(y&1) res=(1ll*res*x)%mod;

        x=(1ll*x*x)%mod;

    }

    return res;

}

bool operator<(const len &x,const len &y){

    if(m<=30){

        return x.a*(1ll<<m)+x.b<y.a*(1ll<<m)+y.b;

    }

    return make_pair(x.a,x.b)<make_pair(y.a,y.b);

}

len operator+(const len &x,const len &y){

    return len{x.a+y.a,x.b+y.b};

}

 

void solve(){

    int n;

    cin>>n>>m;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        --u,--v;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<len> dp(n);

    len ans{0,1};

    auto dfs=[&](auto self,int x,int fa)->void{

        int d=adj[x].size();

        len fx;

        if(d<=1){

            fx=len{0,1};

        }else{

            fx=len{d-1,2-d};

        }

        for(auto y:adj[x]){

            if(y==fa) continue;

            self(self,y,x);

            ans=max(ans,dp[x]+fx+dp[y]);

            dp[x]=max(dp[x],dp[y]);

        }

        dp[x]=dp[x]+fx;

    };

    dfs(dfs,0,-1);

    i64 out=((ans.a)%mod)*fp(2,m)+(ans.b%mod);

    out%=mod;

    cout<<out<<'\n';

}

 

 

链表:
链表只能按照节点顺序一个一个地遍历,不能通过下标来索引,也不能直接知道链表的长度,因此要找到距离尾节点若干距离的节点时,比较合适的方法是用双指针,一个先移动n个节点,再两个同时移动,直到快指针移动到链表末尾(T19,T61)

因为链表的数据结构特性,链表一般是采用迭代或递归的方式去解决题目,递归相当于拆解链表,当当前节点处理完后,利用递归来处理接下来的部分

哨兵节点或虚拟头结点:可以减少一些关于空节点的判断逻辑,用于指向head,或者作为一个空链表的头结点,返回答案所需链表时也可以直接返回为dummy->head,虚拟头结点的存在可以使链表各个部分的处理都能用相同的方式解决,也可以作为答案返回链表的头结点

虚拟头结点的建立:ListNode* dummy=new ListNode(0,head);

迭代不一定是指使用for循环遍历,如在链表中就是逐个移动节点,对链表结构的处理要能够抽象化,每次链表指向的节点并不只是一个值,而是包括这个值在内的后半段链表,但在操作时仅仅将其看作一个节点,但要修改next时要注意修改顺序(T24),所以比较合适的是建立一个模型(类似写一个递归函数的思路),每次cur移动后将值传入或修改

反转链表,利用迭代,并且迭代之后,节点已经在k个之后的位置,逐个反转,重要的是记录反转前的节点,并且注意在反转后,前一个节点的next还没有被修改,指向的是反转范围内的第一个节点,也是反转后的最后一个节点

cf 1922d

因为每个元素最多被删除一次,因此删除操作的总数不超过n,因此时间复杂度为O(n)

因此问题在于如何快速找到要删除的元素

首先,假设在某一轮,元素x没有被删掉,并且其左侧和右侧的元素(或者说其前驱和后继)都没有被删掉,那么显然其在下一轮仍然不会被删去

因此我们需要找到的是每一轮中前驱和后继发生变化的元素,发现不好直接考虑,因此反向思考,即我们在当前轮删掉的元素的前驱和后继满足上述的要求(即前者的后继,后者的前驱显然发生了变化),因此可以从上一轮被删掉元素的前驱和后继中判断是否存在将在这一轮中被删除的元素

因为只需要关心节点的前驱和后继因此可以考虑使用双向链表来维护

const int N=3e5+5;

int n,L[N],R[N],a[N],d[N];

vector<int> islst,dellst;

bool vis[N],isout[N];

void add(int u){//将 u 放置可能被删元素集合

    if(u<1||u>n){

        return;

    }

    if(!vis[u]&&!isout[u]){//没被删且仍未加入可能被删集合

        vis[u]=1;//vis数组表示该元素是否在islst中

        islst.emplace_back(u);

    }

}

void del(int u){//链表删除操作

    R[L[u]]=R[u];

    L[R[u]]=L[u];

    add(L[u]);

    add(R[u]);

}

void solve(){

    cin>>n;

    a[0]=a[n+1]=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        isout[i]=0;//初始情况下都没有被删

    }

    for(int i=1;i<=n;i++){

        cin>>d[i];

    }

    for(int i=1;i<=n+1;i++){//初始化链表

        L[i]=i-1;

        R[i-1]=i;

    }

    for(int i=1;i<=n;i++){//第一轮特判,每一个元素都可能被删除

        add(i);

    }

    for(int i=1;i<=n;i++){

        for(int j=0;j<islst.size();j++){//遍历可能被删元素集合

            int x=islst[j];

            vis[x]=0;//还原

            if(d[x]<a[L[x]]+a[R[x]]){

                dellst.emplace_back(x);//放到确定被删元素集合

            }

        }

        //d[x]<a[L[x]]+a[R[x]] 语句会涉及到之前被删的元素,若直接删除可能会影响后面的判断结果

        islst.clear();

        int cnt=0;

        for(int j=0;j<dellst.size();j++){

            int x=dellst[j];

            isout[x]=1;//已经被删

        }

        //这里也是同样,不能将上下两个遍历合并成一个是因为一个元素的后缀可能是要被删除的

        //但如果合并成一个语句,在add时,因为isout[x]还没有被标记,可能会被错误地加入下一轮可能删除的元素中

        for(int j=0;j<dellst.size();j++){

            int x=dellst[j];

            del(x);

            ++cnt;

        }

        dellst.clear();

        cout<<cnt<<' ';

    }

    cout<<'\n';

}

 

 

析合树:

段:称一个值域连续的区间为段(即该值域区间内的所有数都存在于在数组的一个连续区间),如{5,3,4,1,2}的段有:[1,1],[2,2]...,[2,3],[4,5],[1,3],[2,5],[1,5]

定义连续段(P,[l,r])来表示这一区间,l,r代表的是区间上的下标+1

析合树正是由连续段组成的一棵树,并且我们定义本原段为在集合P中不存在与之相交且不包含的连续段,即本原段之间只有相离或包含关系,并且一个连续段可以由几个互不相交的本原段构成,最大的本原段就是整个排列本身,它包含了所有其他本原段,因此我们认为本原段可以构成一个树形结构(就从形式上来看有点类似于线段树,几段小区间合并而成一个更大的区间作为这些小区间在树上的父节点)

以P={9,1,10,3,2,5,7,6,8,4}为例说明定义

值域区间:对于一个结点u ,用[ul,ur]表示该结点的值域区间(ul,ur分别代表区间最小值和区间最大值)。

儿子序列:对于析合树上的一个结点u  ,假设它的儿子结点是一个 有序 序列,该序列是以值域区间为元素的(单个的数 x可以理解为 [x,x]的区间)。我们把这个序列称为儿子序列。记作Su

儿子排列:对于一个儿子序列Su ,把它的元素离散化成正整数后形成的排列称为儿子排列。举个例子,对于结点[5,8],它的儿子序列为{[5,5],[6,7],[8,8]},那么把区间的值域排序标个号,则它的儿子排列就为{1,2,3};类似的,结点[4,8]的儿子序列为{[5,8],[4,4]} (儿子区间的排序是按原数组中的顺序来的),它的儿子排列就为{2,1}。结点u的儿子排列记为Pu

合点:我们认为,儿子排列为顺序或者逆序的点为合点。叶子结点没有儿子排列,我们也认为它是合点。

析点:不是合点的就是析点。

对于析合树中任何节点u,其儿子序列区间的并集就是节点u的值域区间,而对于一个合点u,其儿子序列的任意子区间都构成一个连续段,而对于一个析点,其儿子序列的任意长度大于1的子区间都不构成一个连续段

O(nlogn)的增量法构造:

用一个栈维护前i-1个元素构成的析合森林。在这里需要强调,析合森林的意思是,在任何时侯,栈中结点要么是析点要么是合点。现在考虑当前结点Pi。(因为连续段时要求在数组中连续的,因此需要使用栈来表示这种元素相邻的关系)

我们先判断它能否成为栈顶结点的儿子,如果能就变成栈顶的儿子,然后把栈顶取出,作为当前结点。重复上述过程直到栈空或者不能成为栈顶结点的儿子。

如果不能成为栈顶的儿子,就看能不能把栈顶的若干个连续的结点都合并成一个结点(判断能否合并的方法在后面),把合并后的点,作为当前结点(仍是本原段,不过不是最小本原段(指值域只有一个值的本原段))。

重复上述过程直到不能进行为止。然后结束此次增量,直接把当前结点压栈。

判断能否合并:一个连续段(P,[l,r])等价于区间极差与区间长度减1相等(因为这说明了这值域的元素都与一个下标相对应),又因为P是一个排列,因此对于它的任何一个子区间都有区间极差大于等于区间长度-1

因此可以维护一个maxl<=i<=rPi-minl<=i<=rPi-(r-l),要找到一个连续段相当于查询一个最小值

于是对于增量过程中的i,维护一个数组Q表示区间[j,i]的极差减长度

计算Li表示右端点下标为i的连续段中,左端点<l的最大值,l为当前点所在区间的左端点,若Li不存在,表示当前节点无法合并,如果恰好是栈顶节点的左端点为Li,那么就是这两个节点合并成一个合点,否则是栈中存在一个点可以合并成一个析点

第i次增量结束时,需要把Q数组更新到i+1的情况

因为对Q的维护都是区间上进行的操作,例如修改区间上的最大值或最小值,并且查询也是查询Q上的最小值的下标,因此可以用线段树来维护Q

  1. .         #define all(v) (v).begin(), (v).end()
  2. .         #define unq(v) (v).erase(unique(all(v)), (v).end())
  3. .         #define ii int, int
  4. .         #define li int, int
  5. .         #define ll2 ll, ll
  6. .         #define vec vector
  7. .         #define pii pair <int, int>
  8. .         #define pli pair <ll, int>
  9. .         #define pll2 pair <ll, ll>
  10. .         #define mp make_pair
  11. .         #define pb push_back
  12. .         #define fi first
  13. .         #define se second
  14. .         #define MULTI int T; cin >> T; while(T--)
  15. .         #define sqr(x) ((x) * (x))
  16. .         #define test cerr << '!' << endl;
  17. .         using namespace std;
  18. .          
  19. .         typedef long long ll;
  20. .         typedef unsigned long long ull;

 

  1. .         const int N = 6e5 + 5;
  2. .         const int M = 1e5;
  3. .         const int INF = 0x3f3f3f3f;
  4. .         const ll INFF = 0x3f3f3f3f3f3f3f3f;
  5. .         const int mod = 998244353;
  6. .          
  7. .         struct node {
  8. .         int l, r, lc, rc;
  9. .         ll minn, tag;
  10. .         } t[N * 2];
  11. .         int trcnt = 0;
  12. .          
  13. .         #define lc t[u].lc
  14. .         #define rc t[u].rc
  15. .          
  16. .         void pushup (int u) {
  17. .         t[u].minn = min(t[lc].minn, t[rc].minn);
  18. .         }
  19. .          
  20. .         void build (int l, int r) {
  21. .         int u = ++trcnt;
  22. .         t[u].l = l, t[u].r = r;
  23. .         if (l == r) {
  24. .         t[u].minn = l;
  25. .         } else {
  26. .         int mid = (l + r) / 2;
  27. .         lc = trcnt + 1, build(l, mid);
  28. .         rc = trcnt + 1, build(mid + 1, r);
  29. .         pushup(u);
  30. .         }
  31. .         }
  32. .          
  33. .         void pushdown (int u) {
  34. .         t[lc].minn += t[u].tag;
  35. .         t[rc].minn += t[u].tag;
  36. .         t[lc].tag += t[u].tag;
  37. .         t[rc].tag += t[u].tag;
  38. .         t[u].tag = 0;
  39. .         }
  40. .          
  41. .         void update (int u, int l, int r, ll x) {
  42. .         if ((l <= t[u].l) && (t[u].r <= r)) {
  43. .         t[u].minn += x;
  44. .         t[u].tag += x;
  45. .         return;
  46. .         }
  47. .         pushdown(u);
  48. .         int mid = (t[u].l + t[u].r) / 2;
  49. .         if (l <= mid) update(lc, l, r, x);
  50. .         if (mid + 1 <= r) update(rc, l, r, x);
  51. .         pushup(u);
  52. .         }
  53. .          
  54. .         int querymin (int u, int pos) {
  55. .         if (t[u].l == t[u].r) return t[u].minn;
  56. .         pushdown(u);
  57. .         int mid = (t[u].l + t[u].r) / 2;
  58. .         if (pos <= mid) return querymin(lc, pos);
  59. .         if (mid + 1 <= pos) return querymin(rc, pos);
  60. .         }
  61. .          
  62. .         int querypos (int u) {
  63. .         if (t[u].l == t[u].r) return t[u].l;
  64. .         pushdown(u);
  65. .         if (t[lc].minn == 0) return querypos(lc);
  66. .         else return querypos(rc);
  67. .         }
  68. .          
  69. .         #undef lc
  70. .         #undef rc
  71. .          
  72. .         vec <int> e[N]; // 析合树
  73. .         int l[N], r[N], sl[N]; // 节点的左端点下标、节点的右端点下标、合点的最右儿子的左端点下标
  74. .         int dc[N]; // 节点的析合性 析0合1
  75. .         int cnt = 0; // 节点数
  76. .          
  77. .         int a[N];
  78. .         int s1[N], p1 = 0; // max的作用域用单减栈维护,栈内存放pos
  79. .         int s2[N], p2 = 0; // min 单增栈
  80. .         int s[N], p = 0; // 析合森林栈,存的是节点编号
  81. .         int id[N]; // 原序列下标 -> 叶子节点编号
  82. .          
  83. .         ll dfs (int u) {
  84. .         ll s = e[u].size();
  85. .         if (!s) return 1;
  86. .         ll ret = dc[u] ? s * (s - 1) / 2 : 1;
  87. .         for (auto v : e[u]) {
  88. .         ret += dfs(v);
  89. .         }
  90. .         return ret;
  91. .         }
  92. .          
  93. .         int main0 () {
  94. .         int n;
  95. .         cin >> n;
  96. .         for (int i = 1;i <= n;++i) {
  97. .         int x, y;
  98. .         cin >> x >> y;
  99. .         a[x] = y;
  100. .         }
  101. .         build(1, n);
  102. .          
  103. .         for (int i = 1;i <= n;++i) {
  104. .         // recall that 我们维护的是 max - min - r + l
  105. .         // 右移 r
  106. .         update(1, 1, n, -1);
  107. .         // 从栈内踢掉是减
  108. .         while (p1 && (a[s1[p1]] < a[i])) {
  109. .         update(1, s1[p1 - 1] + 1, s1[p1], -a[s1[p1]]);
  110. .         p1--;
  111. .         }
  112. .         while (p2 && (a[s2[p2]] > a[i])) {
  113. .         update(1, s2[p2 - 1] + 1, s2[p2], a[s2[p2]]);
  114. .         p2--;
  115. .         }
  116. .         // 压进栈内是加
  117. .         update(1, s1[p1] + 1, i, a[i]);
  118. .         s1[++p1] = i;
  119. .         update(1, s2[p2] + 1, i, -a[i]);
  120. .         s2[++p2] = i;
  121. .          
  122. .         id[i] = ++cnt;
  123. .         int cur = cnt;
  124. .         l[cur] = r[cur] = i;
  125. .         //for (int j = 1;j <= n;++j) cout << querymin(1, j) << ' ';cout << endl;
  126. .         int L = querypos(1); // 可合并的最左端
  127. .         while (p && (L <= l[s[p]])) { // 栈中存在可合并的位置
  128. .         if (dc[s[p]] && (querymin(1, sl[s[p]]) == 0)) { // cur 成为栈顶的最右儿子
  129. .         r[s[p]] = r[cur], sl[s[p]] = l[cur];
  130. .         e[s[p]].pb(cur);
  131. .         cur = s[p], p--;
  132. .         } else if (querymin(1, l[s[p]]) == 0) { // 新建合点,cur 作为栈顶的右兄弟
  133. .         dc[++cnt] = 1;
  134. .         l[cnt] = l[s[p]], r[cnt] = r[cur], sl[cnt] = l[cur];
  135. .         e[cnt].pb(s[p]), e[cnt].pb(cur);
  136. .         cur = cnt, p--;
  137. .         } else { // 新建析点,cur 作为最右儿子
  138. .         dc[++cnt] = 0;
  139. .         int tmp = p;
  140. .         while (querymin(1, l[s[tmp]]) != 0) tmp--;
  141. .         l[cnt] = l[s[tmp]], r[cnt] = r[cur];
  142. .         for (int j = tmp;j <= p;++j) {
  143. .         e[cnt].pb(s[j]);
  144. .         }
  145. .         e[cnt].pb(cur);
  146. .         cur = cnt, p = tmp - 1;
  147. .         }
  148. .         }
  149. .         s[++p] = cur; // cur 不再可合并,压栈
  150. .         } // 建树结束,析合树根是 s[1]
  151. .          
  152. .         ll ans = dfs(s[1]);
  153. .         cout << ans << endl;
  154. .         }

 

 

 

二叉树:

熟悉如何进行定义

C++:

struct ListNode {

    int val;  // 节点上存储的元素

    ListNode *next;  // 指向下一个节点的指针

ListNode(int x) : val(x), next(NULL) {}  // 节点的构造函数};

如果不定义构造函数,C++默认生成一个构造函数。但是这个构造函数不会初始化任何成员变量

通过自己定义构造函数初始化节点:ListNode* head = new ListNode(5);

2.使用默认构造函数初始化节点:

ListNode* head = new

ListNode();head->val = 5;

所以如果不定义构造函数使用默认构造函数的话,在初始化的时候就不能直接给变量赋值Python:

class ListNode:

    def __init__(self, val, next=None):

        self.val = val

        self.next = next

如果对链表进行删除操作,被删除的D节点依然存留在内存里,只不过是没有在这个链表里而已,所以在C++里最好是再手动释放这个D节点,释放这块内存。其他语言例如Java、Python,就有自己的内存回收机制,就不用自己手动释放了

leetcodeT203 可以去参考一下5种思路,练习思维(这几个思路中的其中一个重点便是对头结点的不同处理方法,是将其单独拿出处理接下来的还是化作整体)(递归、虚拟头结点,迭代、双指针、栈)

虚拟头结点的设置

ListNode* dummyHead = new ListNode(0); // 设置一个虚拟头结点

dummyHead->next = head; // 将虚拟头结点指向head,这样方面后面做删除操作

要调换二叉树中的节点值,特别是反转这种,可以利用层序遍历,将节点存储在一个数组中,再利用数组中的编号,交换节点的val值,注意并不是交换节点,因为节点中还包含着子树的信息,因此只是交换val(T2415),或者直接利用深度优先搜索传递参数,因为本质上是配对交换,考虑最少的情况,第1层就是只需要左右子树交换即可

例:

TreeNode* reverseOddLevels(TreeNode* root) {

        dfs(root->left, root->right, true);

        return root;

    }

 

    void dfs(TreeNode *root1, TreeNode *root2, bool isOdd) {

        if (root1 == nullptr) {

            return;

        }

        if (isOdd) {

            swap(root1->val, root2->val);

        }

        dfs(root1->left, root2->right, !isOdd);

        dfs(root1->right, root2->left, !isOdd);

    }

 

节点二进制即为路径(T1261)

将根节点的节点值设为1,修改后,左儿子的值是当前节点值乘2,右儿子节点值是当前节点值乘2+1,如此操作后,将节点值转化为二进制,由于都是正数,因此最高位都是1,由节点值的奇偶性可知,遇到0就往左儿子走,遇到1就往右儿子走,这样得到的就是二叉树中的路径

for (int i = 30 - __builtin_clz(target); i >= 0; i--) { // 从次高位开始枚举

            int bit = target >> i & 1; // target 第 i 位的比特值

            cur = bit ? cur->right : cur->left;

            if (cur == nullptr) { // 走到空节点,说明 target 不在二叉树中

                return false;

            }

        }

也可以通过计算出度和入度为0,根节点的入度为0,每个空节点会提供0个出度和1个入度,每个非空节点会提供两个出度和一个入度,并且对于任意节点都应当有出度大于等于入度,最后统计完成时,应当有出度等于入度

 

真二叉树(T894)每个节点恰好有0个或2个节点,因此一棵二叉树其整棵树以及每棵子树的节点个数一定是奇数1,3,5...

并且要时常记住树的结构都是具有相似性的,因此对于这种树的构造问题可以通过动态规划的思路来完成(类似cf9d 树形Dp)

并且,由于每增加2个节点,真二叉树就会多1个叶子,所以一棵有n个节点的真二叉树恰好有 个叶子,对于树的构造问题,因为总的节点个数已经确定,因此可以通过枚举左子树的节点个数的方式来计算总的方案数,又因为这里要输出的是具体的树的结构,因此定义f[i]为有i个叶子的所有真二叉树的列表,具体来说f是vector<TreeNode>

for (int j = 1; j < i; j++) { // 枚举左子树叶子数

            for (auto left : f[j]) { // 枚举左子树

                for (auto right : f[i - j]) { // 枚举右子树

                    f[i].push_back(new TreeNode(0, left, right));

                }

            }

        }

 

 

二叉搜索树

二叉搜索树满足下列约束条件:节点的左子树仅包含键 小于 节点键的节点;节点的右子树仅包含键 大于 节点键的节点;左右子树也必须是二叉搜索树。

并且二叉搜索树的中序遍历是一个升序序列,那么大于等于每个节点值的所有节点值都在该节点之后,即为包括该节点在内的后缀和,如果要得到降序序列,就是把原本的遍历顺序反过来,即中序遍历 -> 逆中序遍历。中序遍历是 “左中右”,那么逆中序遍历就是 “右中左”:先处理右子树,再处理当前节点,最后处理左子树(T1038)

在二叉搜索树中寻找相关节点值就可以利用这条性质(T2476),中序遍历后就可以转化为二分查找问题

class Solution {

public:

    int prefix;

    void travel(TreeNode* node){

        if(!node) return;

        travel(node->right);

        node->val = prefix + node->val;

        prefix = node->val;

        travel(node->left);

    }

    TreeNode* bstToGst(TreeNode* root) {

        if(!root) return NULL;

        travel(root);

        return root;

    }

};

 

回溯法,一般可以解决如下几种问题:

组合问题:N个数里面按一定规则找出k个数的集合

切割问题:一个字符串按一定规则有几种切割方式

子集问题:一个N个数的集合里有多少符合条件的子集

排列问题:N个数按一定规则全排列,有几种排列方式

(组合无序,排列有序)

棋盘问题:N皇后,解数独等等

思考回溯:当前操作是什么(以及相应的影响,来确定回溯算法中需要输入的参数),子问题是什么,下一个子问题是什么

回溯的本质是穷举,因此效率并不高,只是在暴力求解时通过可以有序的解决,最多就是再进行剪枝

回溯法解决的问题都可以抽象为树形结构,因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,就构成的树的深度

回溯算法的时间复杂度可以用叶子的个数*根到叶子的路径长度来算

而节点个数可以最终推得为e*n!(从叶子逐层向上计算)

分析完过程,回溯算法模板框架如下:

void backtracking(参数) {         (返回值一般是void)

    if (终止条件) {

        存放结果;

        return;           

    }

 

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { (实现遍历节点)

        处理节点;

        backtracking(路径,选择列表); // 递归

        回溯,撤销处理结果

    }

}

递归来做层叠嵌套(可以理解是开k层for循环,k就是树的深度),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题

分析遍历起点的上界,其实是在深度优先遍历的过程中剪枝,剪枝可以避免不必要的遍历,剪枝剪得好,可以大幅度节约算法的执行时间

回溯就在于对已进行操作的删除,即使是递归,每次递归中的for也是有序进行的,因此在一次循环完之后可以删除进行下一循环

当没有组合数量要求,仅仅是总和的限制时,递归就没有层数的限制

如果是一个集合来求组合的话,就需要startIndex(标记在这个集合中的位置),例如:77.组合216.组合总和III

如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex(因为是不同集合的组合,可以对一个集合进行遍历时同时进行下一个集合的遍历,或者说,此时遍历到哪一个集合,才是此时的startindex),例如:17.电话号码的字母组合

注意进行下一次函数调用时,为f(  ,j+1)而不是j++,否则会报错

 

N皇后:不能在同一斜线上,右上:即是i,j的和不能重复,左上:即是i,j的差不能重复,本质上是求截距,其余同排列问题,输入dfs的参数为当前填入的位置(排列中的第几位)以及可供选择的数(这里存在枚举,也就是回溯中的for),同时对于要遍历判断某个值是否出现过(如此处,在每次添加排列的数时,我们要遍历已经排列过的数,判断i-j是否出现过时),我们可以利用bool数组(哈希表)来优化,这样每次的判断都可以在O(1)的时间复杂度中完成

暴力写法:

int N,ans;

char f[MAX][MAX];//f为输入的数组

bool use1[MAX],use2[MAX],use3[MAX];//use1表示横向使用情况,use2表示左上-右下使用情况,use3表示右上-左下使用情况。

void DFS(int y)//按每一个数列搜索 {

    if (y>N) {ans++; return;}

    for (register int x=1; x<=N; x++)

    {

        if (f[x][y]=='.'||use1[x]||use2[y-x+N]||use3[y+x])   //棋盘中为’.’表示不可放置

            continue;

        use1[x]=use2[y-x+N]=use3[y+x]=1;

        DFS(y+1);

        use1[x]=use2[y-x+N]=use3[y+x]=0;

    }

}

 

N皇后(P1562)对于这题,用暴力会TLE,因此要考虑优化

尝试位运算

但是,这里的位压缩并不是指把use1,use2等压成一个整数或者bitset之类的,那样对程序效率没有任何优化。

我们需要的是把每一列表示能否放置的01串压缩成为整数,并放在DFS参数里向下传递(有点类似于状压dp的思路)

我们可以用dfs的深度来减少需要表示的状态(正如上面的代码写的那样,默认从上向下进行考虑)

这样考虑use2和use3对于我们接下来(下一行)放置的影响

对于向左上的对角线:

从左上到右下,所以当前这一行影响的应该是下一行的右下一个(↘),然后这里需要注意的是这一行的最后一个的这种对角线是对下一行是没有影响的。举个例子(单就对角线来说):

         这一行: 0 1 1 0 1(1表示有棋子)

         下一行: 0 0 1 1 0

这个时候可以看出来,这个状态相当于是>>=1。(因为最后一个1对于下一列没有影响所以它被消掉也没有影响)

对于右上的对角线:

从右上到左下,影响下一行的左一个(↙)其他都和向左上的一样,只是这一行的第一个对下一行没有影响。举个例子:

         这一行: 1 1 0 0 1(1表示有棋子)

         下一行: 1 0 0 1 0

这个时候可以看出来,这个状态相当于是<<=1。(第一个会被移到前面,超出查找范围,会被接下来的运算消掉)

于是对这一行来说,可以取的点是上一行所有状态求并集

而要快速找到并集中可取的位置,可以对上一步得到的状态进行取反,转化成快速得到1的位置,这样利用树状数组中的lowbit就可以得到从右向左的第一个1,将状态中的1减掉就可以继续找下一个1了

当当前行的状态全部为1时,即每一行都有一个皇后,ans++

并且注意因为右移左移并不会控制位数,因此将并集取反后再和all(全集为(1<<n)-1)与一下来删去超出的部分(避免11001变成1 10010后超出部分的1对后续计算的影响)

int lowbit(int p){

    return p&(-p);

}

//用了二进制压缩就不用数组了,可以用整数来代替数组了

int n,all,use1[15],ans;

 

void dfs(int use0,int use2,int use3,int i){

//i相当于代表行,use0表示列,use2表示右上,use3表示左上

    if(use0==all) {ans++;return;}

    int use=all&~(use0 | use2 | use3 | use1[i]);

    while(use){

        int j=lowbit(use);

        use-=j;

        //j的位置一定是原来为0的位置,因此可以用+,实际上最好还是用|

        dfs(use0+j,(use2+j)<<1,(use3+j)>>1,i+1); //回溯的进程相当于在这里完成

    }

}

 

回溯中的遍历过程有时也可以利用递归函数来解决,将增加的值作为函数的返回值,这样可以节省撤销的步骤(T2698)

 

例如操作时字符串的叠加,这样子每次进行回溯都要添加减少字符串比较繁琐(P1019)因此可以只记录我们需要的数据,例如当前字符串的长度,这样每次操作时只要+=即可

 

蓝桥杯 23 像素放置

对于棋盘上每个放置如何摆放,最简单的想法就是暴力dfs去判断

const int N = 15;

char a[N][N];

int n, m;

int g[N][N];

int lim[N][N], now[N][N];

bool vis[N][N];

void init(){

    for (int i = 0; i < n; i++)

        for (int j = 0; j < m; j++)

            if (a[i][j] != '_')

                lim[i][j] = a[i][j] - '0';

            else

                lim[i][j] = 100;

}

bool check(){//判断是否满足

    for (int i = 0; i < n; i++)

        for (int j = 0; j < m; j++){

            if (lim[i][j] == 100)

                continue;

            if (now[i][j] != lim[i][j])

                return false;

        }

    return true;

}

int dx[4] = {-1, 1, 0, 0};

int dy[4] = {0, 0, -1, 1};

bool dfs(int x, int y, int cnt, int pat){

//x,y表示当前位置,cnt表示当前还没有操作的格子数,pat表示当期格子要修改为什么状态

    int pre = g[x][y];//因为后续要回溯,因此记录当前状态

    g[x][y] = pat;//修改

    if (pat){//如果为1,那么就对应修改能被影响到的格子

        for (int i = x - 1; i <= x + 1; i++)

            for (int j = y - 1; j <= y + 1; j++){

                if (i < 0 || i >= n || j < 0 || j >= m)

                    continue;

                now[i][j]++;

            }

    }

    vis[x][y] = 1;

    if (cnt == 1){//已经到最后一个格子了

        if (check())

            return true;

        else{//回溯

            if (pat){

                for (int i = x - 1; i <= x + 1; i++)

                    for (int j = y - 1; j <= y + 1; j++){

                        if (i < 0 || i >= n || j < 0 || j >= m)

                            continue;

                        now[i][j]--;

                    }

            }

            g[x][y] = pre;

            vis[x][y] = 0;

            return false;

        }

    }

    //开始进行接下来的填充,一个一个向后填

    int ny = y + 1, nx = x;//一行填完,换下一行

    if (ny == m){

        ny = 0;

        nx = x + 1;

    }

    //dfs是深度优先,即按照这个填充方案一直向下查找直到最后,因此从每个dfs退出后,应当回溯为原来的状态,继续进行下一种填充

    if (dfs(nx, ny, cnt - 1, 0))

        return true;

    if (dfs(nx, ny, cnt - 1, 1))

        return true;

    if (pat){//回溯

        for (int i = x - 1; i <= x + 1; i++)

            for (int j = y - 1; j <= y + 1; j++){

                if (i < 0 || i >= n || j < 0 || j >= m)

                    continue;

                now[i][j]--;

            }

    }

    g[x][y] = pre;

    vis[x][y] = 0;

    return false;

}

int main(){

    cin>>n>>m;

    for (int i = 0; i < n; i++)

        cin>>a[i];

    init();

    for (int i = 0; i < 2; i++)

        if (dfs(0, 0, n * m, i))

            break;

    for (int i = 0; i < n; i++){

        for (int j = 0; j < m; j++)

            cout<<g[i][j];

        cout<<'\n';

    }

    return 0;

}

 

棋盘相关的问题,特别是棋盘较小时可以考虑使用状压

我们通过记忆化来进一步提高效率,dp[i][j][st]表示当前进行到(i,j)位置,且前两行加上(i,j)左边两个点的状态为st的方案是否被搜索过

如果前面不足两行,或者左边不足两格,那么就是0(默认情况下bitset就是0)

这里用bitset的原因是int存不下

像是这种带有是否成功标准的dfs函数,既可以写成bool函数,也可以维护一个全局变量,来判断是否找到答案

这道题像素之间在同一个九宫格内不仅会“相互影响”,而且这个影响是具体的:这个九宫格内存在一个像素的数量限制,对于被夹在中间的那一行来说,它既要向前看也要向后看,所以要压两行

const int S=15,N=10,M=1<<22,INF=0x3f3f3f3f;

//只需要当前格子的前两行以及左侧两个格子就能判断(x-1,y-1)对应的九宫格是否满足

//如果前面不足两行,或者左边不足两格,那么就是0(默认情况下bitset就是0)

int n,m,a[S][S],b[S][S],cur[S][S];

bitset<M>dp[N][N];

char s[S][S];

bool ok;

bool in(int x,int y){//判断是否在棋盘中并且这个点是否有要求

    return 0<=x && x<n && 0<=y && y<m && a[x][y]!=INF;

}

bool can(int x,int y,int v){

    for(int i=-1;i<=1;++i){

        for(int j=-1;j<=1;++j){

            int nx=x+i,ny=y+j;

            //因为v还没有被正式填充进去,因此用+v实现

            if(in(nx,ny) && cur[nx][ny]+v>a[nx][ny]) return 0;

        }

    }//判断到这个位置了我们填充的棋子是不可能减少的,因此如果大于那么肯定是不满足的

    //在这个位置填充完毕后,其左上方的格子对应的九宫格就完全确定了,因此如果不能满足a[x-1][y-1]的需求,说明这个方案也不可行

    if(in(x-1,y-1) && cur[x-1][y-1]+v<a[x-1][y-1])return 0;

    //对特殊的边界情况进行处理,因为这些位置是无法通过(x-1,y-1)达到的

    //之所以对第一行以及最左侧一列不进行判断是因为这些能通过(x-1,y-1)达到,只不过判断的时候会超出边界而已

    if(x==n-1 && in(x,y-1) && cur[x][y-1]+v<a[x][y-1])return 0;

    if(y==m-1 && in(x-1,y) && cur[x-1][y]+v<a[x-1][y])return 0;

    return 1;

}

void dfs(int x,int y,int w){

    if(x==n && y==0){//能到最终状态说明已经找到了方案

        ok=1;

        for(int i=0;i<n;++i){

            for(int j=0;j<m;++j){

                cout<<b[i][j];

            }

            cout<<'\n';

        }

        return;

    }

    if(ok) return;

    if(dp[x][y][w]) return;//已经搜索过了

    dp[x][y][w]=1;

    for(int v=1;v>=0;--v){

        if(ok)return;//已经找到方案了

        if(can(x,y,v)){//对已经确定的点进行检查,如果连内部都无法满足就没必要继续填充了

            b[x][y]=v;

            if(v){

                for(int i=-1;i<=1;++i){

                    for(int j=-1;j<=1;++j){

                        int nx=x+i,ny=y+j;

                        if(!in(nx,ny))continue;

                        cur[nx][ny]++;//只在需要有需求的点上进行更新

                    }

                }

            }

            int nw=(w<<1)|v;

            //在我们dfs的过程中,w会一直左移,那些高位的1就一直往左移动,如果不和这个数字取余的话,高位的、已经离开两行以内范围的1就一直消不掉,就会导致我们状态转移不断积累,一直变大

            //而通过与(1<<(2*m))-1)进行与运算就能将高位重置为0

            if(y==m-1)dfs(x+1,0,nw&((1<<(2*m))-1));

            else dfs(x,y+1,nw&((1<<(2*m+2))-1));

            if(v){//回溯

                for(int i=-1;i<=1;++i){

                    for(int j=-1;j<=1;++j){

                        int nx=x+i,ny=y+j;

                        if(!in(nx,ny))continue;

                        cur[nx][ny]--;

                    }

                }

            }

        }

    }

}

void solve(){

    cin>>n>>m;

    for(int i=0;i<n;++i){

        cin>>s[i];

        for(int j=0;j<m;++j){

            a[i][j]=INF;

            if(s[i][j]!='_')a[i][j]=s[i][j]-'0';

        }

    }

    dfs(0,0,0);

}

 

 

动态规划

看到与矩阵中选择并且求最大值的考虑动态规划

操作是修改原数组中的元素对应的含义,并且仍然是求最小值时,可以仍然使用动态规划去考虑,只不过状态是进行操作若干次时对于该元素的最优解(通过操作写状态 T2735)

找子问题,或者说找处理逻辑或操作的影响,因为最后要思考如何去转移,当之前的状态对现在有影响时,假设之前情况下的分类讨论就是一种处理(T1349)

 

动态规划的一个核心特征实际上是问题的最优子结构特性,也就是问题的最优解包含了子问题的最优解,以及每个位置的选择都是类似的,这样能近似成动态规划选或不选的结构

Cf 2091G

一个人从起点0出发,目标是到达终点s,初始功率为k,每次移动前进k距离,移动只能在[0,s]范围内,可以选择掉头,但掉头后功率减1(最低为1),不能连续转向两次,求到达点s时的最大功率

这个问题能满足上面所述的特征,因为我们可以将这个问题分解为能否到达中间某个位置,以及能否从位置x移动到终点s,同时每次移动时有两个选择,继续前进或者掉头,这种有限的决策序列符合动态规划的转移形式

具体来说,先判断特殊情况,也就是s能被k整除或者s<k^2

否则令f[i]表示能否到达位置i,从大到小枚举当前功率

然后可以根据(k-i)的奇偶性来判断此时的移动方向,同时因为每个位置都可以选择掉头,因此不需要枚举每个位置转移,而是直接按照当前功率进行操作

因为题目中的限制可以转换为先移动一次再转向,因此先依据移动的方向判断当前位置是否可行,再更新以现在的功率可以移动到的位置,因为后续的移动可以选择的,因此这一步是或,同时移动次数是任意的(我们限制了已经进行一次移动),因此类似于多重背包,直接从小到大枚举,实现多次移动后的可达性,而最开始的移动是必须的,因此是直接转移

如果这一轮转移完成后f[s]是1,那么直接返回答案即可

void solve(){

    int s,k;

    cin>>s>>k;

    if(s%k==0){

        cout<<k<<'\n';

        return;

    }

    if(s>k*k){

        cout<<max(1,k-2)<<'\n';

        return;

    }

    vector<int> f(s+1);

    f[0]=true;

    for(int i=k;i>=1;i--){

        if((k-i)%2==0){

            for(int x=s;x>=0;x--){

                f[x]= x>=i && f[x-i];

            }

            for(int x=i;x<=s;x++){

                f[x]=f[x] || f[x-i];

            }

        }else{

            for(int x=0;x<=s;x++){

                f[x]= x+i<=s && f[x+i];

            }

            for(int x=s-i;x>=0;x--){

                f[x]=f[x] || f[x+i];

            }

        }

        if(f[s]){

            cout<<i<<'\n';

            return;

        }

    }

    cout<<1<<'\n';

}

 

2025 北京市赛 G

https://codeforces.com/gym/105851

给定序列,求满足相邻元素的颜色不同且亮度不互质的最长子序列长度。

不互质是简单的,DP 转移的时候从素因子来再转移到素因子去就好了

为了颜色不同,给每个 DP 值存两种不同颜色的最优解,就一定能转移了

以p为结尾定义状态,不同颜色限制维护最大和次大值

根据不同的颜色情况做转移:

对于每个数分解的约数p,去找能拿到的最长链,将当前第i位加到那条链上

void solve() {

    int n;

    cin >> n;

    vector<int> w(n);

    for (int i = 0; i < n; i++) {

        cin >> w[i];

    }

    vector<int> c(n);

    for (int i = 0; i < n; i++) {

        cin >> c[i];

    }

    int ans = 0;

    vector<array<int, 2>> dp1(500005, {0, 0});

    vector<array<int, 2>> dp2(500005, {0, 0});

    for (int i = 0; i < n; i++) {

        vector<int> fac;

        while(w[i] > 1) {

            int p = minp[w[i]];

            // cerr << p << ' ';

            if (fac.empty() || fac.back() != p) {

                fac.push_back(p);

            }

            w[i] /= p;

            // while (w[i] % p == 0) {

            //     w[i] /= p;

            // }

            // fac.push_back(p);

        }

        // cout << '\n';

        int res = 0;

        for (int j : fac) {

            // if (i == 3) {

            //     cout << "j: " << j << ", dp1[j]: " << dp1[j][0] << ", dp1[j][1]: " << dp1[j][1] << ", dp2[j]: " << dp2[j][0] << ", dp2[j][1]: " << dp2[j][1] << '\n';

            // }

            if (c[i] != dp1[j][1]) {

                res = max(res, dp1[j][0] + 1);

            }

            if (c[i] != dp2[j][1]) {

                res = max(res, dp2[j][0] + 1);

            }

        }

        ans = max(ans, res);

        for (int j : fac) {

            if (res > dp1[j][0]) {

                if (c[i] == dp1[j][1]) {

                    dp1[j][0] = res;

                } else {

                    dp2[j] = dp1[j];

                    dp1[j] = {res, c[i]};

                }

            } else {

                if (res > dp2[j][0]) {

                    dp2[j] = {res, c[i]};

                }

            }

        }

        // cout << "res: " << res << '\n';

    }

    cout << ans << '\n';

}

 

 

简单动态规划

本质思想就是从最优情况进行转移

Cf2050E

要最小的次数,因此考虑使用动态规划

因为每个元素都会依序填入某个位置,因此遍历来进行转移

同时两个序列中的每个元素都要依次填入,因此可以直接对两个序列通过两个for循环进行遍历,因为确定已填入的个数之后可以直接得到当前最终序列的位置,于是可转移

(类似于链表插入点,依次插入,最后剩余的部分仍然是有序加入的)

void solve(){

    string a,b,c;

    cin>>a>>b>>c;

    int n=a.size();

    int m=b.size();

    vector dp(n+1,vector<int>(m+1,inf));

    dp[0][0]=0;

    for(int i=0;i<=n;i++){

        for(int j=0;j<=m;j++){

            if(i<n){

                dp[i+1][j]=min(dp[i+1][j],dp[i][j]+(a[i]!=c[i+j]));

            }

            if(j<m){

                dp[i][j+1]=min(dp[i][j+1],dp[i][j]+(b[j]!=c[i+j]));

            }

        }

    }

    cout<<dp[n][m]<<'\n';

}

 

高维动态规划

25 杭电 春2 1007

一个n行m列的方格,每个格子都有0123456789+-*中的某个字符

从左上角出发,每次可以向右或者向下移动,直到移动到右下角,问有多少个合法的路径使得算式的得数是k的倍数

对于网格上的操作,显然要逐个进行移动,同时是方案数问题,考虑使用dp解决

问题就在于如何维护算式,从前往后扫描表达式,用sum,mul,cur分别表示当前已经结束的所有项之和、当前项的乘数、以及当前要计算的数字

于是对于读取到的每个字符

如果是数字,则cur=cur*10+d

如果是乘号,则mul=mul*cur,cur=0

如果是加号,则sum=sum+mul*cur,mul=1,cur=0

如果是减号,则sum=sum+mul*cur,mul=1,cur=0

这些运算都要在模意义下进行

然后就可以令dp(x,y,sum,mul,cur)来存储每个位置的状态,这个dp存储的是方案数

这个复杂度实际上已经能过了,如果要进一步优化,可以发现将(sum,mul,cur)当成状态,并赋予0~k^3-1的编号,那么上述的计算流程就可以构成自动机,并且自动机只与k有关

从而可以在dp前先完成自动机的构造,然后就可以令dp(x,y,state)表示到x,y时的状态为state的方案数,同时可以略去转移

int dp[2][105][8005],tr[128][8005];

 

void solve() {  

    memset(dp,0,sizeof(dp));

    int n,m,k;

    cin>>n>>m>>k;

    int mx=k*k*k;

    auto cal=[&](int sum,int mul,int cur)->int{

        return (sum*k+mul)*k+cur;

    };

    for(int sum=0;sum<k;sum++){

        for(int mul=0;mul<k;mul++){

            for(int cur=0;cur<k;cur++){

                for(char i='0';i<='9';i++){

                    tr[i][cal(sum,mul,cur)]=cal(sum,mul,(cur*10+i-'0')%k);

                }

                tr['+'][cal(sum,mul,cur)]=cal((sum+mul*cur)%k,1,0);

                tr['-'][cal(sum,mul,cur)]=cal((sum+mul*cur)%k,k-1,0);

                tr['*'][cal(sum,mul,cur)]=cal(sum,(mul*cur)%k,0);

            }

        }

    }

    vector<string> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]=' '+a[i];

    }

    a[n-1]+='+';

    dp[0][1][cal(0,1,(a[0][1]-'0')%k)]=1;

    int cur=0;

    for(int i=0;i<n;i++){

        for(int j=1+(i==0);j<=m+(i==n-1);j++){

            memset(dp[cur][j],0,sizeof(dp[cur][j]));

            char ch=a[i][j];

            for(int st=0;st<mx;st++){

                if(isdigit(ch) || (i && isdigit(a[i-1][j]))){

                    dp[cur][j][tr[ch][st]]+=dp[cur^1][j][st];

                    if(dp[cur][j][tr[ch][st]]>=mod){

                        dp[cur][j][tr[ch][st]]-=mod;

                    }

                    // cout<<i<<' '<<j<<' '<<dp[cur][j][tr[ch][st]]<<'\n';

                }

                if(isdigit(ch) || isdigit(a[i][j-1])){

                    dp[cur][j][tr[ch][st]]+=dp[cur][j-1][st];

                    if(dp[cur][j][tr[ch][st]]>=mod){

                        dp[cur][j][tr[ch][st]]-=mod;

                    }

                    // cout<<i<<' '<<j<<' '<<dp[cur][j][tr[ch][st]]<<'\n';

                }

            }

        }

        cur^=1;

    }

    cout<<dp[cur^1][m+1][cal(0,1,0)]<<'\n';

}  

 

在计算一些整数分解相关的问题时来计算次数 数论动态规划

Cf 2114F

给定两个正整数 x,k。进行以下两种变换之一称为一次操作:

选择一个满足 1≤a≤k 的正整数 a,使 x 变为 x⋅a;

选择一个满足 1≤a≤k 的正整数 a,使 x 变为 a/x ,要求操作完后 x 值是整数。

你需要找出使 x 变为给定正整数 y 的最小操作次数,或判断无解

显然容易想到类似于整数分解的方式,然后用k来处理两者之间的差异

方便起见,我们可以将乘法操作看作是对y的除法

首先用gcd来得到x和y的公共部分,这部分不需要任何操作

然后重点就是如何计算剩下的部分

一般的分解实际上都用不到随机化算法,特别是这里的大小只有1e6,因此一般的根号复杂度也可以接受

具体的操作次数,可以用类似于背包问题的形式来计算,每次从前者(自己的因数)转移,因为这部分的复杂度一定优于O(n),因此也是可以接受的

因为计算因子时是从1开始的,因此保证了最后的因子一定是其本身,也就实现了计算使用小于等于k的数来得到a的最少操作次数

void solve() {

    int x, y, k;

    cin >> x >> y >> k;

    int g = gcd(x, y);

    auto get = [&](int a) -> int {

        vector<int> divs;

        for (int i = 1; i * i <= a; i++) {

            if (a % i == 0) {

                divs.push_back(i);

                if (i * i < a) {

                    divs.push_back(a / i);

                }

            }

        }

        sort(divs.begin(), divs.end());

        int d = divs.size();

        vector<int> dp(d, inf);

        dp[0] = 0;

        for (int i = 1; i < d; i++) {

            for (int j = 0; j < i; j++) {

                if (divs[i] % divs[j] == 0 && divs[i] / divs[j] <= k) {

                    dp[i] = min(dp[i], dp[j] + 1);

                }

            }

        }

        return dp[d - 1];

    };

    int ans = get(x / g) + get(y / g);

    if (ans >= inf) {

        ans = -1;

    }

    cout << ans << '\n';

}

 

Cf 2116C

给定一个长度为n的正整数数组,进行若干次操作直到a中所有元素都相等:选择满足 1≤i,j≤n 和 i≠j 的两个索引 I,j,将 ai 替换为 gcd(ai,aj)。

问实现目标的最少操作次数

因为每次操作只能选择其中的一个元素变小,且变为gcd,因此显然可以得到最终数组中的元素都将变为原数组元素的gcd,比较特殊的情况是如果数组中已经有元素等于目标值 g,那么答案就是 n – cnt

否则我们需要找出通过若干次操作使得数组中的其中一个元素变为gcd的最少操作次数,这里可以使用dp来操作

因为每次从一个数变成另一个数是通过gcd来实现的,因此可以考虑类似于连边转移的形式

定义dp[x] = 从数组中的元素出发,得到值 x 的最少操作次数

状态转移:

对于每个数组元素 a[i],对于每个可能的中间值 x

可以通过一次操作从 x 得到 gcd(a[i], x)

更新 [dp[gcd(a[i], x)]]的最小值

并且因为这里有很多次都是会重复计算计算gcd,因此可以事先进行一些预处理

这样操作本质是转成一个最短路径问题

节点:所有可能的数值(0 到 N)

边:从 x 到 gcd(a[i], x) 有一条权重为 1 的边

目标:找到从"初始状态"到目标值 g 的最短路径

int G[N + 1][N + 1];

 

void solve() {

    int n;

    cin >> n;

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

    int g = 0;

    for (int i = 0; i < n; i++) {

        g = gcd(g, a[i]);

    }

    int cnt = count(a.begin(), a.end(), g);

    if (cnt) {

        cout << n - cnt << "\n";

        return;

    }

    vector<int> dp(N + 1, inf);

    dp[0] = 0;

    for (int i = 0; i < n; i++) {

        for (int x = 0; x <= N; x++) {

            int y = G[a[i]][x];

            dp[y] = min(dp[y], dp[x] + 1);

        }

    }

    cout << dp[g] + n - 2 << "\n";

}

因为a[i]是数组中的原元素,因此是从dp[x]转移过去的

能这样处理的原因在于gcd的操作不存在顺序性,因此遍历a数组的元素更新dp数组是可行的

 

动态规划用于处理最短路,最优方案等问题

特别是并不需要找出具体的最短路径,而是求出最小的开销

Cf 2049

给定一个矩阵,我们要从(1,1)移动到(n,m),每次只能选择向下或者向右移动,但在移动之前,可以进行操作,具体操作是将某一行的所有元素向左移动,执行这样一次操作的代价是k

问最终从(1,1)移动到(n,m)时的最小代价是多少

对于矩阵中的移动,可以考虑到最终列方向一定是移动了n步,行方向一定是移动了m步

并且移动就类似于转移过程,也就是说到达每一行的花费都只与上一个状态(上一行)有关,(列之间实际上也同理),但因为我们在移动前进行的操作是在行上执行的,因此定义状态为每行各个位置的最小开销,这样在行与行之间进行转移

void solve(){

    int n,m,k;

    cin>>n>>m>>k;

    vector a(n,vector<int>(m));

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            cin>>a[i][j];

        }

    }    

    vector<i64> dp(m,inff);

    dp[0]=0;

    for(int i=0;i<n;i++){

        vector<i64> ndp(m,inff);

        for(int j=0;j<m;j++){// operation times

            auto g=dp;

            for(int q=0;q<m;q++){

                if(q){

                    g[q]=min(g[q],g[q-1]);

                }

                g[q]+=a[i][(j+q)%m];

                ndp[q]=min(ndp[q],g[q]+1ll*j*k);

            }

        }

        dp=ndp;

    }

    cout<<dp[m-1]<<'\n';

}

 

 

dp的本质并不在于对于某种花费之间的关系是相加,而是在于利用之前的状态的最优解转移到当前状态上来,因此不论花费的方式是相加还是异或,核心式子都是f[x]=min(f[x],...)

类背包问题关键就在于物品和体积的循环顺序

24天梯选拔3-2

for(int i=1;i<=n;i++)

for(int j=0;j<(1<<l2);j++)

s[j^a[i]]=min(s[j^a[i]],s[j]+b[i])

 

leetcodeT2369

本质特征是具有相似子问题,从而在子问题之间进行转移,不一定只用于解决数量问题,也可以用于判定能否成功划分(01)状态,状态转移就是与或运算

实际上就是遍历解决,只是根据转移时判断的不仅是相邻的元素,因此不能只保留一个flag(标志当前是否满足),而要存储不同位置的flag,实际上这就等价于这题当中的dp数组

 

路径上的最大值可以用动态规划解决(P1544),直接用dfs会超时,而用dp可以同时起到记忆化和记录最大值的效果(只需要最大值,并且修改次数优先,但修改后并无后效性,因此可以用dp更有效),不然每次都要记录路径会tle,并且每次路径的移动都是遵循相近的原则,因此转移也比较方便

矩阵中路径有关的问题使用动态规划去解决(P1434),但动态规划要求没有后效性,一方面前面的操作不能对后面的操作有影响,另一方面在转移过程中不能再返回去对前面的点进行操作,因此需要将点按照高度升序排序,再按照排序结果进行计算。因为只有这样,才能保证状态转移时前面的状态都已经计算过了,这样的话对于二维矩阵我们需要压缩成一维去排序,或者通过优先队列进行优化

二维压一维

int a[100005],it[100005];

int dp[100005];

int n,m;

bool cmp(int x,int y){

    return a[x]<a[y];

}

int main(){

    cin>>n>>m;

    int l=n*m;

    //为了避免排序时打乱点的相对位置,这里使用间接排序,相当于下标数组

    for(int i=1;i<=l;i++) it[i]=l;

    for(int i=0;i<n;i++){

        for(int j=1;j<=m;j++){

            a[i*n+j]=read();

        }

    }

    sort(it+1,it+1+l,cmp); //间接排序

    /*

    如何判断边界?

    当x-m<=0时,x在最上一层

    当x+m>n*m时,x在最下一层

    当x mod m == 0 ,x处于最右一列

    当(x-1) mod m==0 ,x处于最左一列

    */

    for(int i=1;i<=l;i++){ //dp

        dp[it[i]] = 1;

        if(it[i]-m>0 && a[it[i]]>a[it[i]-m]) dp[it[i]] = max(dp[it[i]], dp[it[i]-m]+1);

        if((it[i]-1)%m && a[it[i]]>a[it[i]-1]) dp[it[i]] = max(dp[it[i]], dp[it[i]-1]+1);

        if(it[i]%m && a[it[i]]>a[it[i]+1]) dp[it[i]] = max(dp[it[i]], dp[it[i]+1]+1);

        if(it[i]+m<=l && a[it[i]]>a[it[i]+m]) dp[it[i]] = max(dp[it[i]], dp[it[i]+m]+1);

    }

}

 

cf1955G

要求路径上的最大gcd

首先起点和终点是确定的,因此最大的gcd肯定是起点和终点gcd的因子

接着就可以开始枚举因子,判断是否有一条路径上的元素全部可以被该因子整除(这种确定因子后逐个遍历去判断是否可行的思路还是比较常见的)

因为我们并不需要真的求出路径,换言之我们只需要检验从起点最终能够到终点,因此我们只需要从上一个可能所在的位置进行转移,即利用动态规划去转移

我们可以将路径上的元素进行一定的抽象,即如果能被整除,那么就设定为1,否则就设定为0(这个操作也常常在路径相关的check中见到)

void solve() {

    int n, m;

    cin >> n >> m;

    vector<vector<int> > a(n, vector<int>(m));

    for (int i = 0; i < n; ++i) {

        for (int j = 0; j < m; ++j) {

            cin >> a[i][j];

        }

    }

    int ans = 1, g = gcd(a[0][0], a[n - 1][m - 1]);

    vector<vector<char> > dp(n, vector<char>(m));

    for (int x = 1; x * x <= g; ++x) {

        if (g % x > 0) {

            continue;

        }

        vector<int> cand = {x, g / x};

        for (int el : cand) {

            for (int i = 0; i < n; ++i) {

                dp[i].assign(m, 0);

            }//vector不能用memset重置

            dp[0][0] = 1;

            for (int i = 0; i < n; ++i) {

                for (int j = 0; j < m; ++j) {

                    if (a[i][j] % el > 0) {

                        continue;

                    }

                    if (!dp[i][j] && i) {

                        dp[i][j] = (dp[i - 1][j] == 1 ? 1 : 0);

                    }

                    if (!dp[i][j] && j) {

                        dp[i][j] = (dp[i][j - 1] == 1 ? 1 : 0);

                    }

                }

            }

            if (dp[n - 1][m - 1]) {

                ans = max(ans, el);

            }

        }

    }

    cout << ans << '\n';

}

 

cf2025D

给定一个数组a,其中数组元素为0,表示可以给两项属性中的一种数值增加一点,如果元素大于0,说明要对力量进行检测,否则要对智慧进行检测,检测通过的标准是当前该项属性的数值是否大于该元素的绝对值

问最多可以通过多少次检测

显然如果知道了智力的水平,那么能相应得到力量的水平,因此我们只需要维护智力的水平,不需要将力量也存储在状态中,因为我们只需要维护一个常量cnt

于是设定状态dp[i][I],表示处理了i条记录,且当前智力水平为I

进而考虑状态转移

如果最后一条记录为0,那么是一个提升点,因此最后的状态是dp[i-1][I-1]或者dp[i-1][I]

如果a[i]>0,那么不会影响状态只会影响答案,对于所有的I>=a[i],则dp[i][I]=dp[i-1][I]+1,否则是dp[i-1][I]

如果a[i]<0,同理

第一种情况只出现了O(m)次,第二和第三种情况只是范围加法,因此,如果能在O(1)时间内完成加法运算,那么用线性时间处理第一种情况就足以达到O(m^2+n)的复杂度

而为了在常数时间内处理范围加法,可以用差分数组来实现

同时可以降低空间复杂度,因为只与上一层有关,只记录dp[I],在a[i]=0时,首先计算一遍前缀和,然后让dp[I]=max(dp[I],dp[I-1]),因为要使用上一层的数据,因此逆序遍历

void solve(){

    int n,m;

    cin>>n>>m;

    vector<int> dp{0};//dp[i]表示智力为i时的最大值

    vector<int> f{0};//dp变化值(范围加法)的差分

    int cnt=0;

    for(int i=0;i<n;i++){

        int r;

        cin>>r;

        if(r>0){

            if(r<f.size()){

                f[r]++;

            }

        }else if(r<0){

            r=-r;

            r=cnt-r;

            if(r>=0){

                f[0]++;

                f[r+1]--;

            }

        }else{

            for(int j=1;j<=cnt;j++){

                f[j]+=f[j-1];

            }

            //f[j]前缀和操作后,表示dp[j]需要加f[j]

            for(int j=0;j<=cnt;j++){

                dp[j]+=f[j];

            }

            dp.emplace_back(0);

            cnt++;

            for(int j=cnt;j>=1;j--){//dp存储的是前一轮的,因此逆序更新

                dp[j]=max(dp[j],dp[j-1]);

            }

            f.assign(cnt+1,0);//直接归零即可,同时cnt增加了,因此f对应的大小也要变化

        }

    }

    for(int j=1;j<=cnt;j++){

        f[j]+=f[j-1];

    }

    for(int j=0;j<=cnt;j++){

        dp[j]+=f[j];

    }

    int ans=*max_element(dp.begin(),dp.end());

    cout<<ans<<'\n';

}

 

 

cf2000F

动规有一种整体化的操作思想在,因为它将最优方案从一个一个统一在了一个状态中

对于状态的转移,通常有逆序和顺序两种,逆序一般用于减小一维的情况下,也就是当前这个状态还没有确定,如果顺序更可能会导致重复计算,因此要在前一轮的情况下进行更新

这题显然我们每一分都要选择最少的步数取完成

但如果贪心的用优先队列对每一步进行操作显然是不太对的

如果dp时每次只是考虑加一,那么本质上和上面的做法并没有区别

因此要想着如何修改和上面有所不同

想到的就是每次dp时,不止加1,我们尝试对若干分数一起处理

一种显然的情况是,在某些次数下,一定是重复对某个矩形进行操作

那为什么不把这些分数所需要的操作直接提取出来呢

这样转移的时候就不需要一分一分地进行移动了

同时,对于一个矩形来说,我们的操作逻辑是确定的,一定是优先使用边长较短的那一边,因此在一个矩形中,一个分数所需要的步数是可以简单处理出来的

进而我们并不需要全局性的考虑所有矩阵中的最优方案

我们只需要遍历每一个矩阵,处理出对应的分数并对相应的dp方案取min

因为每次是在前一轮的情况下更新的,因此逆序更新

能保证最后所有矩阵遍历完后,dp存储的消耗一定是最小的

void solve(){

    int n,k;

    cin>>n>>k;

    vector<int> dp(k+1,inf);

    dp[0]=0;

    for(int i=0;i<n;i++){

        int a,b;

        cin>>a>>b;

        if(a>b){

            swap(a,b);

        }

        for(int j=k;j>=0;j--){

            for(int u=1;u<=a+b && j+u<=k;u++){

                int x,y;

                if(u<=b-a){

                    x=a;

                    y=b-u;

                }else{

                    x=(a+b-u)/2;

                    y=a+b-u-x;

                }

                int res=dp[j]+a*b-x*y;

                dp[j+u]=min(dp[j+u],res);

            }

        }

    }

    if(dp[k]==inf){

        cout<<-1<<'\n';

    }else{

        cout<<dp[k]<<'\n';

    }

}

 

 

字符串中的操作 cf1096D

和求最长不递减子序列一样,dp的转移是和当前的次序有关的

即转移是从它所在的之前位置转移得到的

题目要求是删除一些字符使得字符串中不出现特定子序列

子序列上的问题,删除的时候只需要从其前面一个位置进行转移

因为要子序列不出现,只要其中一个元素不能按顺序出现即可,也因为如此,每次修改dp[i]都会影响i之后的dp值

按照字符串匹配的思路

利用g进行转移,与前一个状态的dp进行区分,dp[i]表示让字符串最多包含hard的前i个(不包含i)字符,如dp[i][0]表示不包含h的最小修改代价

相当于每一轮的hard都要满足不能构成,因此要保存上一轮的dp,即在dp的基础上进行修改

因此,每次转移前,先对g进行初始化,因为显然有dp[i+1]>=dp[i],因此可以直接取g[j+1]=min(g[j+1],dp[j])(因为匹配过程中只要其中一个位置匹配不上即可,因此不需要在这一轮转移完毕之后再修改g[j+1]),而如果s[i]能与hard中j位置的元素匹配上,那么是一定要删除,比较是保证这一位不出现更优还是前一位就不能出现更优

(在选择不删除的时候,为了不凑出 hard的前j个字符,因此在前面最多只能凑出前j−1(使得hard[j] 与前面的字符断开))

void solve(){

    int n;

    cin>>n;

    string s;

    cin>>s;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<i64> dp(4);

    for(int i=0;i<n;i++){

        vector<i64> g(4,1e18);

        for(int j=0;j<4;j++){

            if(j<3){

                g[j+1]=dp[j];

                // g[j+1]=min(g[j+1],dp[j]);

            }

            g[j]=min(g[j],dp[j]+(s[i]=="hard"[j]?a[i]:0));

        }

        dp=g;

    }

    i64 ans=*min_element(all(dp));

    cout<<ans<<'\n';

}

 

 

动态规划和组合数:
cf 2060F

给定k,n,对于任意x属于[1,k]求有多少种长度不大于n的数组a满足数组中的每个元素都在[1,k]的范围内,同时数组元素的乘积为x

有一个显然的性质是,当数组长度较大时,数组中的绝大多数元素都应该时1,其中非1的元素个数不会超过[log x]个

定义dp[i][j]表示一类数组的个数,其中数组大小为j,元素乘积为i,且元素不含1

初始化dp[i][1]=1

转移式显然,有dp[i*d][j+1]=dp[i][j]

因为上面的性质,对于任意一个x,j这一位都只需要枚举到[log k]

进一步考虑,现在有dp[x][c],也就是c个非1元素乘积为x的数组个数,现在要往这个数组里插入若干个1,使得插入后数组长度为m,这是一个比较经典的模型,也就是m-c个小球放入c+1个不同盒子,且允许盒为空,这个方案数为C(m,c)

于是对于乘积为x的情况,数组个数总数为

可以等价为

因为n特别大,因此不能预处理阶乘,但因为c很小,因此可以直接暴力计算

 

Cf 2066 D

有一栋n层高的楼,居民要共同发射c架纸飞机,楼层高的居民看不到楼层低的居民发射的纸飞机,每位居民会在自己看到的纸飞机数量c后停止,结束后,居民总共发射了m个纸飞机,有一个a数组记录了每只纸飞机是由哪层的人扔出的,部分a[i]缺失,也就是a[i]=0,需要找出填补这些a[i]的总方案数

在简单版本问题的中,a数组所有元素都是0

可以将楼看作是一个序列,也就是要求sigma(a[j]>=i)=c,显然,顶层必须扔c只飞机,也就是值为n的数出现了c次

转成序列且要求求方案数,考虑怎么设计状态,因为最高楼的飞机每一层都可以看见,因此就是考虑剩下的飞机放置到剩余的位置上

Dp[1][c]=1,dp[i][j]=sigma (c,k)*dp[i-1][j-k]

 

优先队列:

加了greater<int>的是小根堆

优先队列如果想使用自定义的比较函数,可以通过delctype,也就是

    auto cmp=[&](int x,int y){

        return deg[x]<deg[y];

    };

    priority_queue<int,vector<int>,decltype(cmp)> pq(cmp);

同时需要注意优先队列是基于创建时的 deg 值进行排序的,当你修改了 deg 数组的值后,优先队列并不会自动重新排序。

 

Cf 2061E

给定两个数组a,b,对于a中的两个元素x,y,如果|x-y|<=1,那么可以删去这两个元素然后将x+y加入数组中,问能否通过若干次操作后,将a数组变得和b数组一样

首先能自然的想到对两个数组进行排序,接着就是两种方案的考虑,是将a数组从小到大开始加,就还是将b数组从大到小进行拆分

因为例如a中元素是1,1,2 而b中有一个3,1+2是可以满足条件的,但是如果一开始就1+1,那么就会导致方案不可行

也就是不一定是让当前最小的两个元素相加得到的,但另一方面,如果b中最大的元素和a中最大的元素不相等,那么一定是要进行拆分的,这是必要条件,因此对b数组从大到小进行操作

void solve(){

    int n,m;

    cin>>n>>m;

    priority_queue<int> p,q;

    for(int i=0;i<n;i++){

        int x;

        cin>>x;

        p.push(x);

    }

    for(int i=0;i<m;i++){

        int x;

        cin>>x;

        q.push(x);

    }

    while(!p.empty()){

        if(q.empty() || p.size()<q.size()){

            cout<<"No\n";

            return;

        }

        int x=p.top();

        int a=q.top();

        q.pop();

        if(a<x){

            cout<<"No\n";

            return;

        }

        if(x==a){

            p.pop();

        }else{

            q.push(a/2);

            q.push((a+1)/2);

        }

    }

    if(q.empty()){

        cout<<"Yes\n";

    }else{

        cout<<"No\n";

    }

}

 

 

每次取出优先队列中的点进行转移,注意仍然需要判断这个点周围的值是否比它大,因此这点来说此题的效率不如记忆化优秀


int a[105][105];

int s[105][105]{};

struct node{

    int x,y,val;

};

struct cmp{

    bool operator()(node x, node y){

        return x.val>y.val;

    }

};

priority_queue<node,vector<node>,cmp> pq;

 

动态规划解决方案数问题

特征:符合条件的方案容易构造,但是难以统计个数;或者方案数会很多,明显是需要用组合数学或者说是动态规划;往往方案要满足K个特征;并且是在有序序列上进行操作

设想不论要满足多少个特征,我们都可以看作是从1一个一个推上去的(动态规划的核心,相似的子问题,或者说一种有序思考的方式,不要一下子就去找满足k的情况),因此将特征数作为我们状态的一个维度

 

Cf 2073A

给定一个矩阵,我们可以将1,2,3,4四个元素放入矩阵元素为”.”的位置,同时要求k和k+1(k=1,2,3)在同一行或者同一列上,问可行的方案数有多少

直接操作对于大部分情况是可行的,例如都放置在同一行或者都放置在同一列上,但是对于一些情况,例如

.

.  .

的情况,我们可以遍历每一行下的每一列,然后利用增量法的思路实现,具体来说,类似于

    for(int i=0;i<r;i++){

        i64 tmp1=0;

        for(int j=0;j<c;j++){

            if(a[i][j]=='.'){

                ans+=tmp1*(cnt2[j]-1)*2;

                tmp1+=cnt2[j]-1;

            }

        }

    }

    cout<<ans<<'\n';

每次可以构成类似形状的都可以和前面已经有的情况进行匹配

但是这样可能无法计算

.    .

  .  .

的情况,如果再遍历列进行相同的操作有可能出现状态重复不好计算

因此考虑使用动态规划来解决

如果两个或更多的塔可以放在同一个单元格中,那么dp[k][i][j]表示塔的方式数量,其中k个塔已经放置完毕,且第k个塔放置在i,j位置上(因为只与前一位置有关),状态转移可以通过被放置的每行和每列的预处理来进行计算

同时令sum(k)表示放置k个塔的总方案数

通过小的调整,可以迫使dp禁止相邻的塔位于同一单元格,剩下的情况就是消除非相邻塔在同一单元格的情况

也就是消除1=3,2=4和1=4的情况,并且因为相邻的元素之间不能位于同一位置,因此1=4和1=3、2=4的情况是不会同时成立的

对于1=3和2=4的情况可以使用容斥原理,如果1=3但2!=4,则相当于放置3个塔,而2=4但1!=3的情况本质上也是相同的,而1=3且2=4的情况相当于就放了两个塔,于是可以从sum(4)中减去2*sum(3)然后再加上sum(2)

最后1=4的情况,可以发现1,2,3一定处于同一行或者同一列,因此答案减去p*(p-1)*(p-2),p是对应行或列的空白区域个数

void solve() {

    int r,c;

    cin>>r>>c;

    vector<string> a(r);

    for(int i=0;i<r;i++){

        cin>>a[i];

    }

    vector dp(4, vector<vector<i64>>(r, vector<i64>(c, 0)));

    vector<i64> rows(r);

    vector<i64> cols(c);

    for(int i=0;i<r;i++) {

        for(int j=0;j<c;j++) {

            if(a[i][j]=='.') {

                dp[0][i][j]=1;

                rows[i]+=dp[0][i][j];

                cols[j]+=dp[0][i][j];

            }

        }

    }

    for(int k=1;k<4;k++) {

        vector<i64> nrows(r);

        vector<i64> ncols(c);

        for(int i = 0; i < r; i++) {

            for(int j = 0; j < c; j++) {

                if(a[i][j] == '.') {

                    dp[k][i][j] = rows[i]+cols[j]-2*dp[k-1][i][j];

                    nrows[i] += dp[k][i][j];

                    ncols[j] += dp[k][i][j];

                }

            }

        }

        rows = nrows;

        cols = ncols;

    }

    vector<i64> sum(4);

    for(int i = 0; i < r; i++) {

        for(int j = 0; j < c; j++) {

            sum[3] += dp[3][i][j];

            sum[2] += dp[2][i][j];

            sum[1] += dp[1][i][j];

        }

    }

    i64 ans=sum[3]-2*sum[2]+sum[1];

    for(int i=0;i<r;i++){

        i64 cnt=0;

        for(int j=0;j<c;j++){

            if(a[i][j]=='.'){

                cnt++;

            }

        }

        ans-=cnt*(cnt-1)*(cnt-2);

    }

    for(int j=0;j<c;j++){

        i64 cnt=0;

        for(int i=0;i<r;i++){

            if(a[i][j]=='.'){

                cnt++;

            }

        }

        ans-=cnt*(cnt-1)*(cnt-2);

    }

    cout<<ans<<'\n';

}

 

 

如P1521

要求逆序对的数量为K,那一般就可以把逆序对的数量作为dp数组中的其中一维,现在考虑另一维,显然的对于这种数组上的问题,考虑将下标作为一个维度,但这样设定后尝试进行转移,下标确实逐个移动了,但是好像并不能和之前的状态构建什么关系,因此要另谋出路,因为逆序对需要考虑数组中元素的大小,因此考虑能不能用数组中的具体元素来代替下标作为状态的另一个维度,事实上这是可行的,因为原数组中的元素是连续的(不连续的话进行离散化应该也是可以做的),并且数组元素也同时可以代表数组当前的大小

因此构造状态f[i][j]接下来就是思考转移,因为i即表征着数组中的最大元素,也表征着数组的大小,一般思考转移时先不动j,考虑i和i-1之间的关系,这其中的变化形象地来看是“插入”(数组中这种不断加入元素的思想很重要)了i大小的元素,又此时我们从[i-1][j]到[i][]的j并没有变,因此自然要考虑插入会对我们的j产生什么影响,因为插入的是最大的元素,因此插入的位置会影响增加的逆序对的个数,于是为了保证为[i][j],就要按照插入位置适当调整[i-1]时的j

f[i][j]=sum(f[i−1][j−k])  0≤k≤i−1

展开来观察  f[i][j]=f[i-1][j]+f[i-1][j-1]+...+f[i-1][j-(i-1)]

对于累加式,尝试优化,一般形式是去发现一串式子实际上等价于i、j相近的某一个状态

因为这里i和i-1是绑定的,因此不能换i,又注意到j的变化实际上是连续的,因此将j变为j-1,发现是f[i][j-1]=f[i-1][j-2]+f[i-1][j-3]....f[i-1][j-i]

故f[i][j]=f[i-1][j]+f[i][j-1]-f[i-1][j-i]

或者另开一个前缀和数组,这种累加和的dp方程可以考虑前缀和优化(即f[i][j-1]的部分用前缀和代替)

 

Cf 2066C
给定一个数组以及P,Q,R三个初始为0的整数,按照1到n的顺序处理所有数字,每次将当前的数字异或到P,Q,R任意一个整数上,但要求每次操作完后三个数至少有两个数相同的情况出现,问这样的操作方案数有多少种

考虑到题目本身就是逐个操作,然后每次操作有三种选择,因此自然考虑到能否使用动态规划计算

问题在于如何设计状态

考虑到如果有两个数相同,那么它们的异或结果是0,同时想到无论我们怎么去分配这些操作,我们总的进行异或得到的数字是一定的,也就是从0~i的元素的异或和,记为p[i],同时这也是P^Q^R的值,又因为这之中有两个数是相同的,因此这三个数中一定有一个数为p[i]

这样三元组的状态是确定的,也就是(p[i],x,x),(x,p[i],x),(x,x,p[i]),三种情况,不妨记作f[i][x]

在此基础上考虑转移就比较容易了

对于f[i-1][x]可以转移到f[i][x],只需要把当前的元素操作到p[i]上即可

对于f[i-1][p[i]]可以转移到f[i][p[i]-1],因为此时的情况是(p[i-1],p[i],p[i]),并且p[i]^a[i]=p[i-1],因此有两个位置可选

类似的,对于f[i-1][p[i-1]]可以转移到f[i][p[i-1]],并且有三个位置可选,并且这种情况是包含了第一种情况的,因此考虑增加的方案数的话也是有两个位置可选择

直接dp会爆空间,但实际上有用的情况很小,同时每次状态转移只与上一次有关,因此可以压成一维

void solve() {

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    int s=0;

    map<int,i64> dp;

    dp[0]=1;

    for(int i=0;i<n;i++){

        i64 add=0;

        if(dp.contains(s)){

            add+=2*dp[s];

        }

        if(dp.contains(s^a[i])){

            add+=2*dp[s^a[i]];

        }

        add%=mod;

        dp[s]+=add;

        dp[s]%=mod;

        s^=a[i];

    }

    i64 ans=0;

    for(auto [x,v]:dp){

        ans+=v;

        ans%=mod;

    }

    cout<<(ans+mod)%mod<<'\n';

}

 

cf1989E

a数组中每个位置元素实际为多少并没有影响,b数组只由a数组中的连续段的情况确定

因为要求1~k每个数字都要出现一次,因此至少有k个连续段

在最左和最右两侧,连续段的长度都是可以被确定的,例如最左侧的连续段长度如果为n,那么b数组中第一个元素的值就是n

但是在这两部分中间的部分长度为2和长度为1的连续段就无法区分,因为在b中一个显示1 1,另一个显示1,因此不能区分在a中是一个长为2的连续段还是两个长为1的连续段

因为最终要求的是b的情况,既然这两种情况不能区分,那么就人为规定中间不能有长度为2的连续段,因为这样不影响答案的计算,同时也可以尽量满足连续段的个数至少为k,因此要么第一段长度为2,要么最后一段长度为2,要么没有长度为2的段
dp[i][j]表示长度为i的数组至少有j个连续段时的方案数

状态转移的时候不需要考虑插入位置,因为只与连续段的存在情况相关,因此加sum[j]的时候包括了例如3+1和1+3两种情况

void solve(){

    int n,k;

    cin>>n>>k;

    vector<vector<Z>> dp(n+1,vector<Z>(k+1,Z(0)));//有k个数至少要k段

    dp[0][0]=1;

    vector<Z> sum(k+1);

    sum[0]=1;

    for(int i=1;i<=n;i++){//数组的长度

        for(int j=k;j>=0;j--){//正序,逆序均可以

            int nj=min(j+1,k);

            dp[i][nj]+=sum[j];//sum[j]是具有连续段个数为j的方案数,与数组的长度无关

            //相当于在sum[j]中对应方案的基础上,在原数组末尾插入一个长度为(i-a)的连续段

            if(i>2 && i!=n){//除去长度为2的连续段

                dp[i][nj]-=dp[i-2][j];

            }

        }

        for(int j=0;j<=k;j++){//sum应该最后更新,不然sum可能会加两遍

            sum[j]+=dp[i][j];//不同长度下的总方案数

        }

    }

    cout<<dp[n][k]<<'\n';

}

 

对于分配问题也可以用动态规划去思考

(P1025)

f[i][x] 表示 i 分成 x 个非空的数的方案数。 

    分配问题直接讨论显然比较繁琐,因此思考如何有序的得到答案,一种想法就是从较小规模的问题去转移,这样依次的转移使得答案显得比较严谨,又与答案相关,因此考虑用题干相关的变量作为状态,所以关键还是在于思考如何转移

显然 i<x 时 f[i][x]=0 , i=x 时 f[i][x]=1;

其余的状态,我们分情况讨论:

①有1的 ②没有1的

第一种情况,方案数为 f[i-1][x-1] (预留一个1,保证有1)

第二种情况,方案数为 f[i-x][x] (此时 i 必须大于 x) (全部位置都先填上1,在此基础之上再分配)

所以,状态转移方程为: f[i][x]=f[i-1][x-1]+f[i-x][x]

 

动态规划与递推,因为动态规划一定是有状态转移的,例如从i转移到i+1或者j从j+1得到,那我们一般可以从动态规划的边界入手,按照原来的推导的顺序,反向进行递推,即dfs 改成 f数组,递归改成倒序循环 i,递归边界改成 f 数组的初始值,递归入口即为答案,翻译成递推之后的时间复杂度能优化较多,且代码更简洁,并且原来需要的记忆化数组,可以直接利用题目中的条件数组替代,实现原地修改,节省了空间

 

cf 1957c
也是作为解决方案数的问题,其中的一个特点在于我们下棋的顺序不计入方案数的考虑,换句话说我们就可以以任意我们期望的顺序来解决问题,这样我们自然选择用逆序或者顺序的方式去选择

这题的方案数选择中,显然每次选择时都是相似的:有一些位置已经被选去了不能再选择,要在剩下的位置中选一个落下我们的棋子

对于这种问题,不难发现,我们具体下在哪个位置实际上是不影响最终结果的,因此对于这种过程往往都是抽象成剩余有多少位置可供选择的,并且将其作为动态规划的状态

并且在这题中,行和列是一一对应的,因此并不需要同时维护剩下可选择的行列数

在基本想法确定好后就不用考虑每个方案到底要怎么安排了,接着只需要对两种操作分别考虑转移

设定dp[i]表示当前剩下i行和列可以选择时的方案数

方便起见,我们设定是对当前可选择的最后一行进行操作

我们将棋子放在(i,i)处,这种操作只有一种,并且剩下可选择行数减1,方案数为dp[i-1]

或者在该行的其他位置落下棋子,可选的位置有(i-1)个,并且之后电脑会跟随下到(j,i),使得j行也不能在落子了,因此可用的行数减2,并且因为我们和电脑下棋的位置是被计入影响的,因此同一个方案应当被计入两次该方案数为2*(i-1)*dp[i-2]

故dp[i]=dp[i-1]+2*(i-1)*dp[i-2],初始情况dp[0]=1,dp[1]=1

int dp[(int)3e5+5];

void solve() {

    int n, k;

    cin >> n >> k;

    int used = 0;

    for (int i = 0; i < k; i++) {

        int r, c; cin >> r >> c;

        used += 2 - (r == c);

    }

    int m = n - used;

    dp[0] = dp[1] = 1;

    for (int i = 2; i <= m; i++)

        dp[i] = (dp[i-1] + 2ll * (i-1) * dp[i-2] % mod) % mod;

    cout << dp[m] << "\n";

}

 

动态规划计算方案数

2024 东北四省  I

令dp[i]表示以i结尾刚好构成一个新的1~k的排列,那么最后的答案就是dp[n]

dp[j]可以由dp[i]加入(j-i)个数字转移

令加入j-i个数字产生的新方案数为f[j-i],则dp[j]+=dp[i]*f[j-i]

考虑如何求解f[i],因为只有添加完i个数字之后才可以构成新排列,所以在添加完[1,i)个数字的过程中
不能构成新排列,我们可以容斥

总的添加方案数有i!,然后减去不合法的情况,不合法的情况可以拆分成两部分,前面一段是指添加上就已经变成了一个新排列,后半部分就是添加后将变成排列,因此我们可以枚举断点j,使得添加完第j个不构成新排列,这种情况的方案数是j!,因为后面[j,i]区间内的数字添加完毕后要能够构成新排列,这就是之前处理过的子问题了,方案数就是f[i-j],根据乘法原理,这样子的不合法方案数就是j!*f[i-j]

void solve() {

    //对于满足要求的区间来说,每一段是某一个排列的循环,并且该段的长度>=k

    //例如n=4,k=2就可以分两种情况,一种是一整段的循环,例如1 2 1 2,一种是分成两段,每一段又各是一个排列

    //这样每个位都被好区间覆盖过了

    //只不过在该情况中,一整段的循环被第二种情况包括了

 

    //可以考虑dp

    //假设知道哪些位置是排列,就可以算方案数

    //每一段如果有若干个数还没有出现过,也就是和上一个排列的位置差了距离是x,直接乘上x的阶乘

    //并且还要去重,因为这样只是确定了某些位置是排列,并没有其他位置不能h是排列

    //因此为了确保其他不是排列还要再容斥

    //总共有三种情况:确定是排列;实际上不是,那要减去;或者直接不管

    int n,k;

    cin>>n>>k;

    if(n<k){

        cout<<0<<'\n';

        return;

    }

    //f为转移系数,保证此处是排列但中间都不是排列

    vector<Z> f(k+1);

    //预处理

    f[0]=-1;

    for(int i=1;i<=k;i++){

        for(int j=0;j<i;j++){

            f[i]-=f[j]*comb.fac(i-j);

        }

    }

    vector<Z> dp(n+1);

    dp[k]=comb.fac(k);//k阶乘

    for(int i=k;i<n;i++){

        for(int j=i+1;j<=n && j-i<=k;j++){

            dp[j]+=dp[i]*f[j-i];

        }

    }

    cout<<dp[n]<<'\n';

}

 

Cf 2061C(2-SAT)

给定一个数组,数组的每个元素表示该位置的人说左侧有多少个说假话的人,说假话的人可能说真话,两个相同说假话的人不可能在一块,问可行的方案数有多少

方案数问题,首先应该考虑到,不可能直接计算出合法的情况,也就是通过数组元素的性质得到答案,因此只能从小到大进行扩展,也就是按照一般的思路,每次假设一个位置后进行检查

从而问题转化为如何进行判断,依照动态规划的思路,我们处理完一个位置之后,可以保证状态所对应的答案是最优的,同时后面元素的设定不改变这种有效性,也就是动态规划的无后效性,在这里,体现为我们判断完该位置后,后面元素的设定不影响这个位置之前方案的合法性,于是,我们需要考虑的是状态的转移,也就是在已知之前方案合法性的前提下,判断填入该位置后的合法性

合法性的判断其实是显然的,也就是该位置如果是假,那么是否是两个假的排在一起,如果是真,是否确实为真,确实为真就要从之前方案数的合法性得到的说慌人数和实际的数组元素进行对比

因此需要当前位置前一个元素的真假情况,而dp维护的可以就只是方案数,转移时,给对应的合法情况加上前一个位置对应状态的方案数即可

因为只有真假两种情况,以及判断只与前一位置有关,因此只需要两个元素的dp

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    array<int,2> dp{};

    dp[0]=1;

    for(int i=0;i<n;i++){

        array<int,2> ndp{};

        // 0 honest   1 lier

        for(int x=0;x<2;x++){// i-1

            int l;

            if(i==0){

                l=0;

            }else if(x==0){

                l=a[i-1];

            }else if(i==1){

                l=1;

            }else{

                l=a[i-2]+1;

            }

            for(int y=0;y<2;y++){// i

                if(y==0 && a[i]!=l){

                    continue;

                }

                if(x==1 && y==1){

                    continue;

                }

                ndp[y]+=dp[x];

                ndp[y]%=mod;

            }

        }

        dp=ndp;

    }

    int ans=(dp[0]+dp[1])%mod;

    cout<<ans<<'\n';

}

 

 

 

利用动态规划去解决传球问题

cf1941D

显然可以想到去使用dfs或者set(因为序列中不可能有重复的元素,每次枚举存储当前所有可能性的集合去进行转移)去存储问题的情况

但实际上也可以利用动态规划去进行转移,因为每次的运动逻辑实际上是相同的,因此我们可以用类似于子问题处理的方式去写传球的过程

用 dp[i,j]​ 表示第 i 次传球后 j 号成员有没有可能拿到球,转移的时候就是进行或运算,如dp[i][j + (n - r)] |= dp[i - 1][j];

 

概率相关的动态规划

牛客24多校3 E

有一个01打字机,每次输入0或1,会以p的概率打出正确的数字 ,1-p的概率打出错误的数字

给定n个长度均为m的01串,要求用打字机以任意顺序将这n个串输出(不能有额外的错误输出),求最大成功概率

如果是给定顺序输出字符串,那么显然概率是确定的,但因为我们可以自行确定输出的顺序,因此可以每次考虑在当前位置选择打1还是打0来得到尽可能高的正确率,打出1之后,再继续考虑下一步,这样的操作思路联想到将所有字符串放在一起决策

因为给出了若干个串,想到用trie树

对于每个trie树上的一个节点u,记它的左右孩子分别为ls,rs,记sz为u节点子树下 的叶子节点个数(也就是有多少01串以该点为前缀)

答案为 ,其中f(x,y)表示用这个打字机敲出恰好x个1和y个0的最大成功概率

(把每个非叶子节点的贡献乘起来)

f可以预处理,转移f(x,y)=max(pf(x-1,y)+(1-p)f(x,y-1),pf(x,y-1)+(1-p)f(x-1,y)}

(由于不超过1000,因此可以n^2的预处理,优先打1还是打0,实际上还有种贪心的思路,当前打1比0多,那么打1,否则打0)

说明为什么可以独立计算trie上每个节点的贡献

如果要成功打出n个串,相当于要在trie树上走n趟,每次走完后删掉对应的单词,一旦走出trie树相当于失败

要成功的走完这n趟,那对于trie树上每个非叶子节点u,不妨设sz(ls)=x,sz(rs)=y,我们需要经过该点恰好x+y次,每次经过该点,需要决策输入0或1,目标是恰好x次走向左儿子,y次走向右儿子

这里会发现,在trie上每个节点u的决策是独立的,相当于要在该节点恰好打出x次0和y次1,这一概率可以预先处理记为f(x,y)

void solve(){

    int n,m;

    double p;

    cin>>n>>m>>p;

    int idx=1,root=1;

    vector<array<int,2>> ch(n*m+5);

    vector<int> sz(n*m+5);

    auto insert=[&](auto self,int cur,const string &s,int pos)->void{

        sz[cur]++;

        if(pos==m) return;

        int ty=s[pos]-'0';

        if(!ch[cur][ty]) ch[cur][ty]=++idx;

        self(self,ch[cur][ty],s,pos+1);

    };

    for(int i=1;i<=n;i++){

        string s;

        cin>>s;

        insert(insert,root,s,0);  

    }

    // cerr<<idx<<' '<<p<<' '<<root<<'\n';

    vector<vector<double>> dp(n+5,vector<double>(n+5));

    dp[0][0]=1.0;

    for(int i=0;i<=n;i++){

        for(int j=0;j<=n;j++){

            if(i>0 && j>0){

                dp[i][j]=max(dp[i-1][j]*p+dp[i][j-1]*(1-p),dp[i-1][j]*(1-p)+dp[i][j-1]*p);

            }else if(i>0){

                dp[i][j]=dp[i-1][j]*p;

            }else if(j>0){

                dp[i][j]=dp[i][j-1]*p;

            }

            // cerr<<i<<' '<<j<<' '<<dp[i][j]<<'\n';

        }

    }

    double ans=1;

    for(int i=1;i<=idx;i++){

        if(sz[i]==sz[ch[i][0]]+sz[ch[i][1]]){

            ans*=dp[sz[ch[i][0]]][sz[ch[i][1]]];

            // cerr<<dp[sz[ch[i][0]]][sz[ch[i][1]]]<<'\n';

        }

    }

    cout<<fixed<<setprecision(12)<<ans<<'\n';

}

 

牛客24多校4 J(二项式)

给出一个01带?的串,?等概率变成0或1

一段连续相同数的贡献是区间和的k次幂

问期望贡献和

简单来说是对每个连续1进行操作如果当前数字为0,那么将f加入贡献,否则讲f/2加入贡献

因此主要是要递推f

const int mod = 998244353;

int n, m, a[100005], pw[100005], C[35][35], sum[100005], dp[100005][35], ans;

int power(int a, int b) {

    int ans = 1;

    while (b) {

        if (b & 1) ans = (long long)ans * a % mod;

        a = (long long)a * a % mod;

        b >>= 1;

    }

    return ans % mod;

}

void solve(){

    cin>> n >> m;

    //预处理

    for (int i = 1; i <= n; i++) {

        char c;

        cin >> c;

        if (isdigit(c)) a[i] = c - '0';

        else a[i] = -1;

    }

    pw[0] = 1;

    for (int i = 1; i <= n; i++) pw[i] = pw[i - 1] * 2 % mod;

    for (int i = 0; i <= m; i++){

        for (int j = 0; j <= i; j++){

            if (j == 0 || j == i) C[i][j] = 1;

            else C[i][j] = (C[i - 1][j - 1] + C[i - 1][j]) % mod;

        }

    }

 

    for (int i = 1; i <= n; i++) sum[i] = sum[i - 1] + (a[i] == -1);

    for (int l = 1, r; l <= n; l = r + 1) {

        r = l;

        if (!a[l]) continue;

        while (r < n && a[r + 1]) r++;

        for (int i = l; i <= r; i++) {

            for (int j = 0; j <= m; j++) {

                for (int k = 0; k <= m; k++) dp[i][j] = (dp[i][j] + (long long)C[j][k] * dp[i - 1][k]) % mod;

                dp[i][j] = (dp[i][j] + pw[sum[i - 1]]) % mod;

            }

            ans = (ans + (long long)pw[sum[n] - sum[i]] * dp[i][m]) % mod;

        }

    }

    ans = (long long)ans * power(pw[sum[n]], mod - 2) % mod;

    cout<< ans << '\n';

}

 

牛客24多校10 D

给定每场比赛rating的更新方式是r=k*p+(1-k)*r

其中p为比赛的表现分

计算从一个初始rating出发,在n场比赛中至多让m场不计分的情况下,最后rating的最大值

注意到0.9^(200)<1e-9,说明在进行200场rated比赛后,初始rating的影响会在误差范围内,也就是可以直接忽略了,也就是若干个比赛中之后最后x场是有贡献的

因此可以从后往前dp

当n>=m+200时,最后m+200场里至少要选200场比赛算分,而在这之前的比赛都无所谓,因此可以视作是全选

当n<m+200时,我们至少要选择n-m场来算分,但具体的处理方案和上面的情况是类似,因此可以令d=min(200,n-m),则在最后m+d场里选择至少d场计分,使用f[i][j]表示前i场比赛里选择j场的最高分,答案为f[m+d][d]

void solve(){

    int n,m;

    cin>>n>>m;

    double k;

    cin>>k;

    int r0;

    cin>>r0;

    vector<int> p(n);

    for(int i=0;i<n;i++){

        cin>>p[i];

    }

    //最多只会影响B场

    vector<double> dp(B+1,-inff);

    dp[0]=0;

    vector<double> pw(B+1);

    pw[0]=k;

    for(int i=1;i<=B;i++){

        pw[i]=pw[i-1]*(1-k);

    }//pw用于计算第i场对最终分数的占比

    double ans=0;

    for(int i=n-1;i>=0;i--){

        //dp记录该场比赛作为B场比赛中第B-j场的最大分数

        //同时因为场次的顺序不会变,因此前面的比赛如果被选择,那么一定会乘上更多的次数,因此是j向j+1转移

        //因为要从上一轮的dp转移,因此逆序

        for(int j=B-1;j>=0;j--){

            dp[j+1]=max(dp[j+1],dp[j]+p[i]*pw[j]);

        }

        for(int j=0;j<=B;j++){

            //是最后m+j场比赛,根据总场次的数目,如果n较大,那么j==B,否则需要i==0的情况

            //最多只能不选m场,最多只会有j场被计入贡献

            if(n-i-j<=m && (j==B || i==0)){//只有在这种情况下才能更新

                ans=max(ans,dp[j]+r0*pw[j]/k);

                //r0不需要乘k

            }

        }

    }

    cout<<fixed<<setprecision(12)<<ans<<'\n';

}

 

 

特殊的状态设计:
cf 1969c

大致题意:给出一个长度为n的数组a,每次可以选择两个数字i,j,其中|i-j|=1,将aj的值变为ai,问在最多进行k次操作的情况下,数组a的和的最小值为多少

要求经过若干次操作之后和的最小值,自然想到用dp,因为k的值很小,因此设定dp状态:设f[i][j]为对a的前i项进行j次操作后,前i个元素的和的最小值

因为我们可以通过d次操作将长度为d+1的线段转化为最小值(这个是独立于我们数组的前i个元素的,我们只关注数据最小的元素,我们能通过类似传染的方法将长度为d+1的连续子数组都变成该元素;或者说,对于长度为d的数组,能通过至多(d-1)次操作将其全部变为该数组中的最小元素),这样,在有了这种(变形后得到的)区间变化方式,我们就能够进行状态转移了,因为设定的状态是前i个元素,因此我们在实际操作时实际上是在从前往后进行操作

即   f[i][j]=min(f[i-t-1][j-t]+(t+1)min(a’))

但如果每次都要去暴力计算数组中的元素最小值,那么时间复杂度可能是O(n)的,但因为我们本身在转移的过程中就是在枚举,因此可以每次维护一个min a[i],在转移的时候将这个最小值与ai进行比较

void solve(){

    int n, k;

    cin >> n >> k;

    vector<ll> a(n);

    for (auto& x : a) cin >> x;

    vector<vector<ll>> dp(n + 1, vector<ll>(k + 1,inf));

    dp[0][0] = 0;

    for (int i = 0; i < n; ++i) {

      for (int j = 0; j <= k; ++j) {

        ll mn =inf;

        for (int d = 0; j + d <= k && i + d < n; ++d) {

          mn = min(mn, a[i + d]);

          dp[i + d + 1][j + d] = min(dp[i + d + 1][j + d], dp[i][j] + (d + 1) * mn);

        }

      }

    }

    cout << *min_element(dp[n].begin(), dp[n].end()) << '\n';

}

 

2022 ICPC jinan J

有三种技能,接下来n天,每天可以选择其中一种技能进行联练习,该技能的熟练度将会增加a[i][j],同时如果当天已经连续有k天没有联系某项技能了,那么那项技能的熟练度将会减少k,同时,每项技能熟练度的最小值是0,问n天之后,三种技能熟练度之和的最大值

可以想到,一个技能如果开始学习了,那么中间过程一定不会减到0,dp考虑的是最优解,如果一个状态会小于0,那么我们一定是将之前投入到这个技能上的资源投入到其他技能上更优,因为放在这个技能上相当于是在浪费

同时对于一个技能的熟练度,我们计算得到最大停止的天数,因为这段时间内其熟练度将会减少1+2+…+n,也就是n^2的浪费,因此可以大致估算出为根号天

因为我们计算的数值是总和,但每一天增加的数值仅与当前天数和选择的技能有关,也就是可以遍历得到的,但减小的数值是不进行训练的天数,因此我们需要维护这个数值

于是用动态规划dp[i][k1][k2][k3]表示第i天,三个技能分别有k1,k2,k3天没有学习,同时我们可以计算得到最大不进行练习的天数,因此状态数也是有限的

每次都只会从前一天转移过来的,于是可以进行压缩一维,也就是不需要天数,同时j,k,x中一定有一个值为0,因此可以将状态转成当前选择的技能,以及其余两个技能没有练习的天数,因为是哪个技能没有练习并没有影响,因此可以任意维护

void solve() {

    int n;

    cin>>n;

    vector<array<int,3>> a(n+1);

    for(int i=1;i<=n;i++){

        cin>>a[i][0]>>a[i][1]>>a[i][2];

    }

    int N=min(205,n+1);

    vector dp(3,vector<vector<int>>(N,vector<int>(N)));

    for(int i=1;i<=n;i++){

        vector ndp(3,vector<vector<int>>(N,vector<int>(N)));

        for(int j=0;j<=2;j++){// this day practice j

            for(int k=0;k<min(N,i);k++){// enumerate the number of unpracticed days

                for(int l=0;l<min(N,i);l++){

                    int u=k?k+1:0;

                    int v=l?l+1:0;

                    if(u<N && v<N){

                        ndp[j][u][v]=max(ndp[j][u][v],dp[j][k][l]+a[i][j]-u-v);

                    }

                    if(v<N){

                        ndp[(j+1)%3][v][1]=max(ndp[(j+1)%3][v][1],dp[j][k][l]+a[i][(j+1)%3]-1-v);

                    }

                    if(u<N){

                        ndp[(j+2)%3][1][u]=max(ndp[(j+2)%3][1][u],dp[j][k][l]+a[i][(j+2)%3]-1-u);

                    }

                }

            }

        }

        dp=ndp;

    }

    int ans=0;

    for(int i=0;i<N;i++){

        for(int j=0;j<N;j++){

            for(int k=0;k<=2;k++){

                ans=max(ans,dp[k][i][j]);

            }

        }

    }

    cout<<ans<<'\n';

}

在状态设计中,因为我们设定了我们的熟练度并不会减少到0以下,为了保证这一点,我们将未练习天数设定为0表示我们这个技能目前的熟练度是0(还没有开始练习),这种情况下,即使我们没有练习,我们也不应该去减小对应的数值,于是k==0时,u仍然是0

因此,在枚举剩下两个情况的未练习天数时,如果k!=0,那么就说明我们前面已经不进行练习了,此时我们需要减去k+1

但这样设计后有个问题,那就是仅仅按照这样的逻辑,我们并不会计算到u或者v=1的情况,也就是从初始情况跳出(第一次进行了训练),或者前一天刚训练过的情况

为了解决这一点,我们直接进行遍历,因为每一轮必定有一个技能未训练的天数是1,因此此时的j+1表示今天我训练j+1,而j是前一天训练的

这样遍历之后,今天训练2、3,昨天训练1,今天训练3、1,昨天训练2等的情况都可以被计算到

其余由多个未训练的情况就正常按照k+1或者l+1即可

 

牛客24多校6 I

关键在于状态设计中的强制选

给定一个数字矩阵,每一行选择一个区间,要求相邻行的区间有交,最大化选中的数字和

令f[i][j]表示考虑前i行,第i行强制选择第j个的答案,转移枚举上一行强制选择的数字,并强制他们有交,则

f[i][j]=max(f[i-1][k]+sum[k...j]+pre[k]+suf[j]])   k<=j时

f[i][j]=max(f[i-1][k]+sum[j...k]+pre[j]+suf[k]])   k>j时

(强制j到k的数字这一行都要选,这样就满足有交了,因此可以在这些数字的基础上再加上前缀以及后缀)

其中pre[i]代表以i结尾的最长连续子段和(长度可以为0),suf[i]表示以i开头的最长连续子段和(长度可以为0),这样操作的时间复杂度是O(nm^2)

因为转移中没有同时与k和j有关的,因此可以拆开来进行操作

预处理pre和suf之后可以使用前缀/后缀max优化转移,时间复杂度O(nm)

void solve(){

    int n,m;

    cin>>n>>m;

    vector<vector<i64>> a,pre,suf,dp;

    a.resize(n+1),pre.resize(n+1),suf.resize(n+1),dp.resize(n+1);

    for(int i=1;i<=n;i++){

        a[i].resize(m+1),pre[i].resize(m+1),suf[i].resize(m+1),dp[i].resize(m+1);

        for(int j=1;j<=m;j++){

            cin>>a[i][j];

        }

        i64 sum=0ll;

        for(int j=1;j<=m;j++){

            sum+=a[i][j];

            pre[i][j]=suf[i][j]=sum;

        }

        for(int j=1;j<=m;j++){

            pre[i][j]=min(pre[i][j-1],pre[i][j]);

        }

        for(int j=m-1;j;j--){

            suf[i][j]=max(suf[i][j+1],suf[i][j]);

        }

        //第i行中j左侧的最小值以及右侧最大值

    }

    for(int j=1;j<=m;j++){

        dp[1][j]=suf[1][j]-pre[1][j-1];

        //包含j的区间最值

    }

    for(int i=1;i<n;i++){

        vector<i64> ndp;

        ndp.resize(m+1);

        for(int j=1;j<=m;j++){

            ndp[j]=dp[i][j]+suf[i+1][j];

        }

        //前一行保证选了j

        for(int j=m-1;j;j--){

            ndp[j]=max(ndp[j],ndp[j+1]);

        }

        //所有一定包含j的情况中选择最大的

        for(int j=1;j<=m;j++){

            dp[i+1][j]=ndp[j]-pre[i+1][j-1];

        }

        for(int j=1;j<=m;j++){

            ndp[j]=dp[i][j]-pre[i+1][j-1];

        }

        for(int j=2;j<=m;j++){

            ndp[j]=max(ndp[j],ndp[j-1]);

        }

        for(int j=1;j<=m;j++){

            dp[i+1][j]=max(dp[i+1][j],ndp[j]+suf[i+1][j]);

        }

        //在前缀和后缀的两种方案中取大的

    }

    i64 ans=dp[n][1];

    for(int j=1;j<=m;j++){

        ans=max(ans,dp[n][j]);

    }

    cout<<ans<<"\n";

}

 

Cf 2060G

给定两个长度为n的数组a和b,保证1到2*n中的每个元素在a和b数组的并集中都出现过一次

定义一种操作(I,j),表示交换a[i]和b[j]以及a[j]和b[i],问经过任意次操作后,能否使得

对于这个操作可以注意到一个性质,也就是对于同一列的两个数,不论如何进行操作,它们始终在同一列

这是因为操作相当于是将两列的元素交换后进行翻转

进一步考虑这个性质有什么作用,能否把一列看作是一个整体,然后单纯地考虑交换

猜测和每一列的最小值有关,即假设:如果有合法解,那么解的每一列一定按照min(a[i],b[i])升序排列(这是必要条件,并不是充分条件,例如(2,5),(3,4))

要证明我们的猜想一般使用反证法(或者直接假设是对的)

也就是存在一组i<j的(i,j),但第i列和第j列不符合这个情况,那么也就是i列的任意一个数都大于j,于是和两个数组中的元素都单增矛盾

(可以类似地得到另一个结论,也就是合法解一定满足列的总和是升序排列的,证明显然,对于这类问题,确实不需要得到充分条件,只需要较弱的必要条件即可,依照必要条件的形式去构造,再通过判断这样的操作是否可能

通过a+b或者上面所说的min进行排序,可以发现如果存在合法解,那么排列是固定的,只是要判断翻转是否可能)

因为每次操作后不仅发生了交换,元素之间还进行了翻转,因此考虑能否通过一种操作序列,使得交换的同时不发生反转,可以证明,假设有a,b,c三列,要交换a,b两列,只需做操作(b,c),(a,c),(b,c)即可

同样考虑能否不换位置的前提下,进行反转,类似地,有a,b,c三列,要反转a,b两列,则做(a,b),(b,c),(a,c),(b,c)

同时可以证明我们没法对奇数列进行这样的操作,也就是在不交换位置的前提下翻转奇数列

假设我们现在翻转了 k 列且 k 为偶数,设再做一次操作后翻转了 k1​ 列。如果翻转两个未被翻转的列则 k1​ 为 k+2;如果翻转一个未被翻转的列,一个已经被翻转的列则 k1​ 为 k;如果翻转两个已经被翻转的列则 k1​ 为 k−2。显然这三种情况得到的 k1​ 都为偶数

得到这些结论后就可以对原数组进行分析了

(翻转奇偶性,排列奇偶性(也就是交换的次数),因为我们上面的推导,可以发现翻转奇偶性和排列奇偶性是无关的,因为可以实现不翻转交换/不交换翻转)

于是考虑维护翻转的奇偶性,这用dp实现,dp表示能不能总翻转是奇数或者偶数,因此设定两个dp状态

void solve(){

    int n;

    cin>>n;

    vector<array<int,2>> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i][0];

    }

    for(int i=0;i<n;i++){

        cin>>a[i][1];

    }

    sort(a.begin(),a.end(),[&](auto x,auto y){

        return x[0]+x[1]<y[0]+y[1];

    });

    array<bool,2> dp{true};

    for(int i=1;i<n;i++){

        array<bool,2> ndp{};

        for(int x=0;x<2;x++){

            if(!dp[x]){

                continue;

            }

            for(int y=0;y<2;y++){//下一个是否翻转

                if(a[i][y]>a[i-1][0] && a[i][y^1]>a[i-1][1]){

                    ndp[x^(y?i%2:0)]=true;

                }

            }

        }

        dp=ndp;

    }

    if(dp[0] || (dp[1] && n%2==1)){

        cout<<"YES\n";

    }else{

        cout<<"NO\n";

    }

}

 

 

牛客24多校9 D

令a0,a2,..,ai中所有取值为1的数的下标为s1,x[i+1],...,x[n]中所有取值为1的数的下标为s2...

 

牛客24多校10 I

给定一个n张牌的排列,使用riffle shuffle 将1..n洗成给定排列

riffle shuffle是将牌任意切为两叠,以任意顺序归并

最小化洗牌次数并构造方案

看作把最后p这个排列,p是最后一个关键字排序,前面每个数有一个k位的二进制数,最后才按照p中的位置排序

倒着考虑这个过程,就是给定一个排列,使用以下操作进行排序:选择一个子序列,将这个子序列按顺序放在牌堆顶,其余牌顺序不变

如果有最后一步,那么选择的排列一定是形如1...i,没被选择的部分为i+1,....,n

如果有倒数第二步,那么在操作后的排列一定要能拆成这两个连续子序列

因为如果正着操作,那么一次操作对于一个上升子序列来说就是在第一部分和第二部分中分别构成上升子序列,那么数量会翻倍,但如果我们逆着去操作,那么就是除二上取整

先考虑n个数里有多少个连续上升的子串,假设有x个

结论就是最少操作次数是log2 x上取整

把x个串按照数字的大小排序,因为是连续的,相当于将n个数划分成了x个块

把编号为奇数的放在前面,偶数的放在后面

就是逆操作,本来是分成两叠再归并,反过来就是在完成的情况中,抽出两串,一个放在前面,一个放在后面,再按照大小顺序进行合并

这样原来是1,2,...x,分成两叠后就是1,3,,...   2,4...

在合并后两段将连成一段,这样上升子序列的个数相当于除了2

然后一直这样操作,直到序列变的有序

void solve(){

    int n;

    cin>>n;

    vector<int> p(n),invp(n);

    //我们是逆操作,因此是要将p变的有序

    for(int i=0;i<n;i++){

        cin>>p[i];

        p[i]--;

        invp[p[i]]=i;//标记p[i]在哪个位置

    }

    int x=0;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        if(i>0 && invp[i]<invp[i-1]){

            //说明不构成递增序列

            x++;

        }

        a[i]=x;//编号

    }

    int k=__lg(x*2+1);

    cout<<k<<'\n';

    vector<string> ans;

    for(int i=0;i<k;i++){

        string s;

        //进行逆操作

        for(int j=0;j<n;j++){

            s+='A'+(a[p[j]]>>i&1);

        }

        ans.emplace_back(s);

        vector<int> np;

        for(int j=0;j<n;j++){

            if(s[j]=='A'){

                np.emplace_back(p[j]);

            }

        }

        for(int j=0;j<n;j++){

            if(s[j]=='B'){

                np.emplace_back(p[j]);

            }

        }

        p=np;

        // cout<<p.size()<<'\n';

    }

    // assert(is_sorted(all(p)));

    //因为操作时是逆操作,因此输出时要逆输出

    reverse(ans.begin(),ans.end());

    for(auto s:ans){

        cout<<s<<'\n';

    }

}

 

 

动态规划的时间复杂度 === 状态个数 ×\times× 单个状态的计算时间

动规在于对子问题的解决,贪心则是只要符合条件的能拿就拿;动规是由前一个状态推导出来的,而贪心是局部直接选最优的

最值问题可以考虑动态规划,有相互独立的性质也适合用动态规划

并且在写状态转移方程时,最好能减少状态的数量

题目只要求返回结果,不要求得到最大的连续子数组是哪一个。这样的问题通常可以使用「动态规划」

动态规划要保证每一个子问题只求解一次,即以后求解问题的过程不会修改以前求解的子问题的结果

 

最基本的思路就是选或不选,遇到最值问题考虑dp,再思考题目中提及的操作能不能用选或不选来抽象,子序列问题也可以视为选或不选(这个数字要不要包含在这个子序列当中)

 

cf1989D

每种材料是独立的,>=a,-a+b,得到两点经验

贪心的考虑,从a-b最小的开始用,因为这样的损耗最小

void solve(){

    int n,m;

    cin>>n>>m;

    vector<int> a(n);

    int s=0;

    for(int i=0;i<n;i++){

        cin>>a[i];

        s=max(s,a[i]);

    }

    vector<int> f(s+1,2*s+1);

    for(int i=0;i<n;i++){

        int b;

        cin>>b;

        f[a[i]]=min(f[a[i]],a[i]-b);//这类物品每次操作后的最小损耗

    }

    for(int i=1;i<=s;i++){

        f[i]=min(f[i],f[i-1]);//初始需求更大的都能操作,那需求更小的肯定也能操作

        //因为每个操作的价值都是相同的

        //故要尽可能多的进行操作,因此肯定是优先做损耗较小的

        //于是求前缀最小数组,具体含义是当当前材料为i时,能进行的操作中损耗最小的是f[i]

    }

    vector<int> dp(s+1);

    for(int i=1;i<=s;i++){

        if(f[i]<=i){//当前进行操作后,下一次的材料会变为i-f[i]

        //于是如果可以操作,那就用动规进行方案数的转移

            dp[i]=dp[i-f[i]]+1;

        }

    }

    i64 ans=0;

    while(m--){

        int c;

        cin>>c;

        if(c>s){//小于等于s的已用dp预处理

            int t=(c-s+f[s]-1)/f[s];//即(c-s)/f[s]上取整,能损耗几次

            c-=t*f[s];

            ans+=t;

        }

        ans+=dp[c];

    }

    cout<<2*ans<<'\n';//这样执行一次可以得到两倍的经验值

}

 

cf1982c

分割原数组使得满足要求的子区间段最多

std::extract 可以用于从容器中获取元素的同时将其从容器中删除,具体来说,它返回指向容器中给定位置的迭代器,并将该元素从容器中删除。这样可以避免使用传统的 erase 函数导致的迭代器失效问题,从而使代码更加简洁和易于维护

类似于数组最长不递减子序列的做法,将以该元素为结尾(包括该元素)的最大值作为dp的数值(在当前子数组,满足要求的分配最多是多少个),然后对于当前元素,枚举可能成为答案的起点,然后转移就是对选取其中最大的方案数+1

ll a[100005];

int dp[100005];

void solve(){

    int n;

    cin>>n;

    int l,r;

    cin>>l>>r;

    a[0]=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        a[i]+=a[i-1];

    }

    multiset<int> s;

    for(int i=1,j=0,k=0;i<=n;i++){

        dp[i]=dp[i-1];

        while(k<i && a[i]-a[k]>=l){//k+1~i

            s.insert(dp[k++]);//k之后的可能成为有效的子区间

        }

        while(j<i && a[i]-a[j]>r){

            s.extract(dp[j++]);

        }

        if(!s.empty()){

            dp[i]=max(dp[i],1+*s.rbegin());

        }

    }

    cout<<dp[n]<<'\n';

}

 

存储状态

cf 1984C1

因为选择时可以是直接相加也可以是相加后取绝对值,因此状态存储时一方面存储最小值,另一方面存储最大值,没必要用操作的类型作为存储的状态

ll a[200005];

ll dp[200005][2];

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    dp[1][0]=a[1];

    dp[1][1]=abs(a[1]);

    //因为有负数的存在所以存一个最小值

    for(int i=2;i<=n;i++){

        dp[i][0]=dp[i-1][0]+a[i];

        dp[i][1]=max(abs(dp[i-1][0]+a[i]),abs(dp[i-1][1]+a[i]));

    }

    cout<<dp[n][1]<<'\n';

}

hard version

计算方案数

核心思想实际上和简单版一样,每次可能对之后的最优结果产生影响的只可能是当前的最小值或者最大值,因此我们可以存储这两种情况所对应的方案数传递给下一次操作,并且在之后的状态转移中将方案数进行维护

那么最终得到最大值的方案数就是数组中每个位置都操作完成一次之后对应状态的权值

因为并不知道每次操作过程中我们得到的值为多少,因此用map去维护,同时map保持了key的有序性,因此每次只需要保留map中的第一个元素和最后一个元素即可

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    map<ll,Z> dp;

    dp[0]=1;

    for(int i=1;i<=n;i++){

        map<ll,Z> ndp;

        for(auto [x,y]:dp){

            ndp[x+a[i]]+=y;

            ndp[abs(x+a[i])]+=y;

        }

        dp.clear();

        dp[ndp.begin()->first]=ndp.begin()->second;

        dp[ndp.rbegin()->first]=ndp.rbegin()->second;

    }

    cout<<dp.rbegin()->second<<'\n';

}

 

另一种想法是思考操作2的特殊性,显然只有当前缀和数组变成整个数组中的最小值时,我们只能通过操作2将其转正,并且在这个位置之前,我们所进行的其余操作2显然是要与操作1产生的效果是相同的(当操作完后取最大值的情况下),换言之,就是此时的前缀和数组加上aj是非负数,但是在这个位置之后,因为i处已经是前缀和最小值了,因此在取绝对值之后不可能再有成为负数的情况了,因此在i之后的位置,1或2操作可以任选

于是,如果我们在i处执行了关键的操作2,那么其对答案方案数的贡献就是2x+y

因此,我们再遍历一次前缀数组,对于每一个满足是最小值的位置都计入贡献

const ll MAX_N = 400001;

const ll MOD = 998244353;

vector<ll> p2(MAX_N);

void solve(){

    int n;

    cin >> n;

    vector<int> arr(n);

    for (int i = 0; i < n; ++i) cin >> arr[i];

    ll sum = 0, mn = 0, ans = 0, abses = 0;

    for (int i = 0; i < n; ++i) sum += arr[i], mn = min(mn, sum);

    if (mn == 0) {//所有前缀都是正的,每个位置都可有两种操作

        cout << p2[n] << '\n';

        return;

    }

    sum = 0;

    for (int i = 0; i < n; ++i) {

        sum += arr[i];

        if (sum == mn) {

            ans = (ans + p2[n - i - 1 + abses]) % MOD;

        }

        if (sum >= 0) ++abses;

    }

    cout << ans << '\n';

}

 

cf14E

方案数,找不到规律时可以考虑取用dp进行解决

一般题目中的元素范围较小,如这里的1-4,就考虑直观化地去用二维坐标系中的点去表示,这样原题中的波峰和波谷就成了二维图形中的转折点

而一个点是否是转折点可以根据与前一个点的高度关系判断出,因此也可以得到dp的状态转移方程

定义状态dp[x][y][t]表示最后一点落在(x,y)上,并且此时出现t个转折点的方案数

 

特殊的选或不选类型(牛客24寒假4F)

选中的元素个数达到6个时作为完整的一个操作,其相应分数有一个计算公式,接着对剩下的元素继续进行该操作(相似子问题),求最终这些操作分数总和的最大值

每次操作完成后,下一次操作就不能使用前一次所选择的最后一个数i所对应的区间[1-i]中的元素

显然,不能用dp[i]去表示该范围内进行一次操作的分数最大值,dp含义的设置一般与题干发要求是相匹配的,因此应该是这段区间内,(无论操作多少次,)分数的最大值

考虑转移,显然有从下标j到下标i选6个数,使得得到的分数最大

因此剩余的问题就在于如何计算出最大分数

对于计算较为复杂的情况下得到可能的最大值

对于这6个数,前五个数每个数分别维护对应的最大值/最小值(因为计算过程中有乘法,可能存在两个负数相乘得到的结果更大)

constexpr int inf=1e9;

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++) cin>>a[i];

    vector f(n+1,vector(n+1,-inf));

    //O(n^2)处理出一个数据范围内的结果

    for(int l=0;l<=n;l++){

        f[l][l]=0;

        array<int,7> mx{},mn{};

        mx.fill(-inf);

        mn.fill(inf);

        mx[0]=mn[0]=1;

        for(int r=l;r<n;r++){

            array<int,7> mx1=mx,mn1=mn;

            for(int i=0;i<6;i++){

                //根据具体操作去计算,考虑到正负的情况

                 //并且i是依次变化的,保证mx[i],mn更新时是依赖最新的mx[i-1]

                if(i%2==0){

                    if(mx[i]!=-inf){//对每个选择的位置去进行判断,因为r是按顺序进行去进行查找的,因此能保证最后选出来的最优方案是按要求选取的

                        mx1[i+1]=max(mx1[i+1],mx[i]*a[r]);

                        mn1[i+1]=min(mn1[i+1],mx[i]*a[r]);

                    }

                    if(mn[i]!=inf){

                        mx1[i+1]=max(mx1[i+1],mn[i]*a[r]);

                        mn1[i+1]=min(mn1[i+1],mn[i]*a[r]);

                    }

                }else{

                    if(mx[i]!=-inf){

                        mx1[i+1]=max(mx1[i+1],mx[i]-a[r]);

                    }

                    if(mn[i]!=inf){

                        mn1[i+1]=min(mn1[i+1],mn[i]-a[r]);

                    }

                }

            }

            mx = mx1;

            mn = mn1;

            f[l][r + 1] = mx[6];

        }

    }

    vector<int> dp(n + 1);

    for (int l = 0; l <= n; l++) {

        for (int r = l + 1; r <= n; r++) {

            dp[r] = max(dp[r], dp[l] + f[l][r]);

        }

    }

    cout<<dp[n]<<'\n';

    return 0;

}

与子数组相关的,一般将状态定义为对应子数组的最后一个元素

对于步骤确定的操作中找最优,基本思想仍然是考虑当前元素选与不选,不过要对于每个位置(具体操作的一步)维护最优,再得到整个操作的最优值

 

子序列最值问题的状态往往定义为以该位置的元素为结尾的序列的最大值为多少,在此基础上再进行转移(T53)

 

动态规划状态的转移与前一个元素有关时(实际上相当于两个数为一个状态,因为动态规划 的子问题不应该有后效性),可以用不同的数据结构去维护,例如map存储前一个元素的对应值,优先队列用于存储dp值(方便找最大)(T2216)

 

动态规划与枚举结合:用于一个选择会影响之后的选择(但依然是选或不选,或者能不能选),这种情况就是将这个选择所影响到的数据依次进行枚举,在其中求最值或者进行状态转移(T2008,T2944)

 

矩形上的操作一定要保证转移前后的图形都是矩形,即确保高度相同的情况下对宽讨论,即避免操作时产生不规则形状,因此可以选择切割转移,枚举切割的长宽的位置(T2312)

维护好了dp数组,代表我们已经得到了最大价值,不要再去考虑具体什么方案能得到这个最优解,转移好了就直接用这个数据

 

对于枚举动态规划的优化

牛客24寒假4E,每个元素都一定会被覆盖,因此考虑完一段数组之后对于之后数组的解决方案是个相似的子问题,并且要求的是最大值,因此可以用动态规划去解决

显然可以找到一般的状态转移是dp[i]=max{dp[j]+sum(i,j)%k?0:1},但显然这样操作其时间复杂度将是O(n^2)的,如何进行优化

这里实际上仍然是考虑最优,有点贪心的感觉,首先,要能使dp[i]尽可能的大,我们最终找到的一定是sum(i,j)能被k整除的,接着就能继续思考了,i是固定的,是当前的右端点,那么j呢,假设有多个j满足要求,我们显然是要最靠右的j,因为这样使用的元素个数较小,可能在j1-j0中能再找到一段满足要求的子数组,因此我们去维护每个pre对应的最后一次的出现位置来转移(这里也是哈希的运用,两个相同的pre,说明中间一段的和是能被整除的)

map<int, int> last;

   last[0] = 0;

   vector<int> dp(n + 1);

   for(int i = 1; i <= n; i++){

       dp[i] = dp[i - 1];

       if (last.count(sum[i])){

           dp[i] = max(dp[i], dp[last[sum[i]]] + 1);

       }

       last[sum[i]] = i;

   }

 

前缀和优化dp

ICPC2024 B

给定一个abc和问号构成的串,要将每个问号修改为abc中的一个字母,使得序列变成一个合法的串,合法的串是指没有相邻字符相同的串,Q个询问,每次给定x,y,z,表示a,b,c各自可用的数量,问额能构成多少不相同的合法串

令dp[i][j][k][p=a/b/c]来表示考虑前i个字符,当使用j个a,k个b,且最后一个字符是p的合法串的个数

而这一步可以通过前缀和优化来进行,因此可以通过dp数组得到f[x][y][z]为询问(x,y,z)的答案,每次询问就可以O(1)回答

对于方案数问题,一般考虑用dp去解决,设定的一个状态往往是前i个….,而对于选择会影响后续选择的情况,一般都会将序列末尾的元素也一并存入状态中,同时对于三个元素存在一定关系(例如相加为定值)的形式,一般都可以减小一个维度去存储

因为这里还涉及每个元素的个数,因此这里设定f[i][j][k][p]作为状态,同时每次询问对于状态设计实际上没有影响,于是可以用前缀和进行优化

const int mod=1e9+7;

int f[2][310][310][3];

int pre[310][310];

//f[i][j][k][p]表示为考虑了前i个人,用了j套a,k套b,且第i个人穿的是p的方案数

//连续3位一定是实现了一组abc,因此只要考虑前两位即可,于是对于i,只需要0/1即可

void solve(){

    int n,q;

    cin>>n>>q;

    string s;

    cin>>s;

    s=" "+s;

    int cnt=0;

    if(s[1]=='?'){

        f[1][1][0][0]=1;

        f[1][0][1][1]=1;

        f[1][0][0][2]=1;

        cnt++;

    }else{

        f[1][0][0][s[1]-'a']=1;

    }

    for(int i=2;i<=n;i++){

        if(s[i]=='?') cnt++;

        for(int j=0;j<=min(cnt,300);j++){

            for(int k=0;k<=min(cnt,300);k++){

                for(int p=0;p<3;p++){

                    f[i&1][j][k][p]=0;//0代表前面一个人,1代表前面两个人

                }

                if(s[i]=='?'){

                    if(j){//也就是当前位选择a,同时前一位不能选择a

                        f[i&1][j][k][0]=(f[i&1^1][j-1][k][1]+f[i&1^1][j-1][k][2])%mod;

                    }

                    if(k){

                        f[i&1][j][k][1]=(f[i&1^1][j][k-1][0]+f[i&1^1][j][k-1][2])%mod;

                    }

                    if(j+k!=cnt){

                        f[i&1][j][k][2]=(f[i&1^1][j][k][0]+f[i&1^1][j][k][1])%mod;

                    }

                }else{

                    if(s[i]=='a'){//这一位是确定的

                        f[i&1][j][k][0]=(f[i&1^1][j][k][1]+f[i&1^1][j][k][2])%mod;

                    }else if(s[i]=='b'){

                        f[i&1][j][k][1]=(f[i&1^1][j][k][0]+f[i&1^1][j][k][2])%mod;

                    }else{

                        f[i&1][j][k][2]=(f[i&1^1][j][k][0]+f[i&1^1][j][k][1])%mod;

                    }

                }

            }

        }

    }

    for(int i=0;i<=300;i++){

        for(int j=0;j<=300;j++){

            if(j){

                pre[i][j]=pre[i][j-1];

            }

            for(int k=0;k<3;k++){

                (pre[i][j]+=f[n&1][i][j][k])%=mod;

            }

            //计算(i,j)的前缀和

        }

    }

    while(q--){

        int x,y,z;

        cin>>x>>y>>z;

        int res=0;

        for(int i=0;i<=x;i++){

            int down=cnt-z-i;//y的下界

            if(y>=down){

                (res+=pre[i][y])%=mod;

                if(down>0){

                    res=(res-pre[i][down-1]+mod)%mod;

                }

            }

        }

        cout<<res<<'\n';

    }

}

 

 

利用单调队列优化dp(T2944)

单调队列:求滑动窗口最小值,适用于翻译成递推后实际上求的是一个区间中的最小值的动规问题,同样因为头部尾部的元素都有可能被移除,所以用双向队列来模拟

deque<pair<int, int>> q;

        q.emplace_front(n + 1, 0); // 哨兵

        for (int i = n; i ; i--) {

            while (q.back().first > i * 2 + 1) { // 右边离开窗口

                q.pop_back();

            }

            int f = prices[i - 1] + q.back().second;

            while (f <= q.front().second) {

                q.pop_front();

            }

            q.emplace_front(i, f); // 左边进入窗口

        }

        return q.front().second;

 

T2617

题目中需要求根据某种移动规则达到矩阵右下点的最小移动次数

自然想到使用dp去做

但如果是每次转移的时候都是枚举操作,那么显然这样的算法太慢了

因为每次进行转移的时候是进行区间查询,转移后是对f[i][j]进行更改,这种区间查询和单点修改的操作可以想到用线段树或树状数组去完成

但实际上,这里也可以用单调栈去优化

对于(j+1)~(j+g)的区间,在倒序枚举j的时候,k的左边界j+1是单调减小的,但有编辑诶没有单调性,联想到滑动窗口最小值的做法,可以用一个根据f值进行排列的单调栈来维护f[i][j]及其下标j,这样对于f,单调栈的顶部是最大值,而因为是倒序枚举,因此对于j,单调栈的顶部是最小值,这样在进行区间查询找最小值时就可以在j的单调栈上进行二分查找,找最大的不超过j+g的下标k,这样对应的f就是最小的

对列的操作同理

 vector<vector<pair<int,int>>> col_stacks(n);

        vector<pair<int,int>> row_st;

        for (int i = m - 1; i >= 0; i--) {

            row_st.clear();

            for (int j = n - 1; j >= 0; j--) {

                int g = grid[i][j];

                auto &col_st = col_stacks[j];

                mn = i < m - 1 || j < n - 1 ? INT_MAX : 1;

                if (g) { // 可以向右/向下跳

                    // 在单调栈上二分查找最优转移来源

                    //分别进行行列上的转移

                    auto it = lower_bound(row_st.begin(), row_st.end(), j + g, [](const auto &a, const int b) {

                        return a.second > b;//根据j进行二分

                    });

                    if (it < row_st.end()) mn = it->first + 1;

                    it = lower_bound(col_st.begin(), col_st.end(), i + g, [](const auto &a, const int b) {

                        return a.second > b;

                    });

                    if (it < col_st.end()) mn = min(mn, it->first + 1);

                }

                if (mn < INT_MAX) {

                    // 插入单调栈

                    while (!row_st.empty() && mn <= row_st.back().first) {

                        row_st.pop_back();

                    }

                    row_st.emplace_back(mn, j);

                    while (!col_st.empty() && mn <= col_st.back().first) {

                        col_st.pop_back();

                    }

                    col_st.emplace_back(mn, i);

                }

            }

        }

 

 

cf1941E

显然行与行之间是没有关系的,因此应当先单独对每个行进行操作

就单独对行进行考虑来说,相当于一个序列上的Dp,因此一般设定为dp[i]表示只考虑前i列,并且第i列一定被选择的情况下,所耗费的最小值

显然,我们需要枚举转移的上一个桥墩的位置

即dp[i]=c[i]+min(j=i-d-1 ~ i-1) dp[j]

当然,直接这样做是O(nm^2)的,需要进行优化

对于这种维护之前一段序列区间内的最小值,可以用线段树或者单调队列维护,从而实现优化

利用单调队列:


deque<ll> q;

ll t,n,m,k,d,a[105][200005],dp[200005];

void solve() {

    cin>>n>>m>>k>>d;

    ll ans[105]{};

    for(int i=1;i<=n;i++){//每一行各自进行dp

        ll sum=0;

        q.clear();

        memset(dp,0,sizeof(dp));

        for(int j=1;j<=m;j++){

            cin>>a[i][j];

            while(!q.empty() && j-q.front()-1>d){//为了方便,q存储的是下标,而不是值

                q.pop_front();//及时删除无效元素

            }

            dp[j]=dp[(q.empty()?0:q.front())]+a[i][j]+1;

            while(!q.empty() && dp[q.back()]>=dp[j]){

                q.pop_back();//哪怕是相等,其对于之后元素也没有用处了

            }

            q.push_back(j);

        }

        ans[i]=dp[m];

    }

    ll sum=LLONG_MAX;

    for(int i=1;i<=n;i++){

        ans[i]+=ans[i-1];//利用前缀和,找一个长度为k并且总花费最小的线段

        if(i>=k){

            sum=min(sum,ans[i]-ans[i-k]);

        }

    }

    cout<<sum<<'\n';

}

 

利用multiset实现单调队列的版本

int N, M, K, D;

cin >> N >> M >> K >> D;

vector<long long> a(N);

for (int i = 0; i < N; i++) {

    vector<long long> dp(M, 1e9);

    vector<int> v(M);

    multiset<long long> mst = {1};

    dp[0] = 1;

    cin >> v[0];

    for (int j = 1; j < M - 1; j++) {

        cin >> v[j];

        dp[j] = *mst.begin() + v[j] + 1;

        if (j - D - 1 >= 0)

            mst.erase(mst.find((dp[j - D - 1])));

        mst.insert(dp[j]);

    }

    cin >> v.back();

    dp.back() = 1 + *mst.begin();

    a[i] = dp.back();

}

 

利用线段树

int n, m, k, d;

int a[N][M];

int sum[N];

int f[M];

struct Tree {

    int l, r, v;

}tr[M << 2];

void pushup(int u) {

    tr[u].v = min(tr[u << 1].v, tr[u << 1 | 1].v);

}

void build(int u, int l, int r) {

    tr[u] = {l, r, 0};

    if (l == r) tr[u].v = 0;

    else {

        int mid = l + r >> 1;

        build(u << 1, l, mid);

        build(u << 1 | 1, mid + 1, r);

    }

}

void modify(int u, int x, int d) {

    if (tr[u].l == tr[u].r) tr[u].v += d;

    else {

        int mid = tr[u].l + tr[u].r >> 1;

        if (x <= mid) modify(u << 1, x, d);

        else modify(u << 1 | 1, x, d);

        pushup(u);

    }

}

int query(int u, int l, int r) {

    if (tr[u].l >= l && tr[u].r <= r) return tr[u].v;

    int mid = tr[u].l + tr[u].r >> 1, res = 1e18;

    if (l <= mid) res = query(u << 1, l, r);

    if (r > mid) res = min(res, query(u << 1 | 1, l, r));//在左右区间中找最小值

    return res;

}

void solve() {

    cin >> n >> m >> k >> d;

    for (int i = 1; i <= n; ++ i ) {

        for (int j = 1; j <= m; ++ j ) cin >> a[i][j];

        build(1, 1, m);

        f[1] = a[i][1] + 1;

        modify(1, 1, f[1]);

        for (int j = 2; j <= m; ++ j ) {

            f[j] = a[i][j] + 1 + query(1, max(1, j - d - 1), j - 1);

            modify(1, j, f[j]);

        }

        sum[i] = sum[i - 1] + f[m];

    }

    int res = 1e18;

    for (int l = 1, r = k; r <= n; ++ l, ++ r ) res = min(res, sum[r] - sum[l - 1]);

    cout << res << '\n';

    return;

}

 

优先队列优化

cf2000G

n点m边简单无向连通图

问多晚离开能够到达目的地

可以用dp解决,也可以用二分解决

如果二分,那就是二分开始的时间,check函数中用dijkstra进行计算

因为有t1到t2中不能用公交车的限制,因此在一般的dijkstra中增加一个判断

for (auto [v, l1, l2] : G[u]) {

    auto now = t + l2;

    if (max(t, t1) < min(t + l1, t2)) {

        now = min(now, t2 + l1);

    } else {

        now = min(now, t + l1);

    }

    if (dis[v] > now) {

        dis[v] = now;

        Q.push({ -dis[v], v});

    }

}

用dp的写法,因为是要最晚出发时间,因此直接从末尾逆序处理,依次确定每个点的最晚出发时间,可以用priority_queue进行优化

 

动态规划解决路径问题

cf1766C

要求,不能经过白色节点,要经过所有黑色节点

实际上是是类似于dfs查找路径的过程

首先要能走过去,这个节点必须是黑色节点

之后就可以从相邻的节点转移过去,因为目标是走过所有黑色节点,因此可以人为规定是向右移动

特别的,对同一列上的两个元素进行处理,我们可以选择这个点是从原来的路径移动过来的,还是从与其上下相邻的节点移动过来,显然,我们选取能覆盖更多的节点的那条路径

void solve(){

    int n;

    cin >> n;

    string s[2];

    cin >> s[0] >> s[1];

    int cnt= count(s[0].begin(),s[0].end(),'B') + count(s[1].begin(),s[1].end(),'B');

    vector<vector<int>> dp(2,vector<int>(n,0));

    for(int j=0;j<n;j++){

        for(int i=0;i<2;i++){

            if(s[i][j] == 'W') continue;

            if(!j) dp[i][j] = 1;

            else if(s[i][j-1] == 'B') dp[i][j] = dp[i][j-1] + 1;

        }

        if(s[0][j] == 'B' && s[1][j] == 'B'){

            int t= dp[0][j];

            dp[0][j] = max(dp[0][j],dp[1][j] + 1);

            dp[1][j] = max(dp[1][j],t + 1);

        }

        if(dp[0][j] == cnt || dp[1][j] == cnt){

            cout << "YES\n";

            return;

        }

    }

    cout <<"NO\n";

}

相同的题:牛客24多校E

就是求能经过最多红色节点的路径

其中略有不同的是因为从任意位置开始均可行,因此dp[i][j]一定可以取到dp[i][j-1]+1

void solve(){

    int n;

    cin >> n;

    string s[2];

    cin >> s[0] >> s[1];

    //不会成环,尽可能从边界开始移动

    i64 ans=0;

    vector<vector<int>> dp(2,vector<int>(n,0));

    for(int j=0;j<n;j++){

        for(int i=0;i<2;i++){

            if(s[i][j] == 'W') continue;

            if(!j) dp[i][j] = 1;

            else dp[i][j] = dp[i][j-1] + 1;

        }

        if(s[0][j] == 'R' && s[1][j] == 'R'){

            int t= dp[0][j];

            dp[0][j] = max(dp[0][j],dp[1][j] + 1);

            dp[1][j] = max(dp[1][j],t + 1);

        }

        ans=max<i64>({ans,dp[0][j],dp[1][j]});

    }

    if(ans==0){

        cout<<0<<'\n';

    }else{

        cout<<ans-1<<'\n';

    }

}

 

对于只有行数较少的,实际上可以考虑直接暴力地去列出所有可能的方案,从而将状态仍然设置在一行中,或者直接将其设置为状态的第二维(因为其情况非常有限),从而在转移过程中暴力地进行转移,因此关键是状态的设计和转移

cf2022c

问一种最佳的划分方案使所得的票数最多

同样是只有两行,我们是每3格进行划分,很显然,这样的划分形状是有限的,因此可以考虑从这几个有限的形状中去设计状态

因为每3格进行划分,因此可以注意到在被3整除的位置中,一定是恰好竖直的一条,这是转移时需要注意的特殊情况,因为最终的n也是3的倍数,因此我们要保证此时一定是上下都进行了填充

同时可以观察得到,如果在上面一行中使用了水平的3格,那么在下面一行中也要使用水平的3格,避免出现漏洞而无法填补

让dp[i][j] 代表阿尔瓦罗考虑到 i th列所能获得的最大票数。第二个维度 j 代表当前已填充单元格的配置:

j=0 :截至 i /th列的所有单元格都已完全填充(包括 i /th)。

j=1 :第 i /th列之前的所有单元格都已填满(包括 i /th),并且下一列的第一行中多了一个单元格。 

j=2 :第i /th列被填满,下一列的第二行多出一个单元格

下面的程序中f,g,h实际上也就是对应dp[i]的dp[i][0],dp[i][1],dp[i][2]的情况

int get(char a,char b,char c){

    return (a=='A')+(b=='A')+(c=='A')>=2;

}

 

void solve(){

    //确保了是两行有什么作用

    int n;

    cin>>n;

    string s[2];

    cin>>s[0]>>s[1];

    //dp

    vector<int> f(n+1),g(n+1),h(n+1);

    for(int i=0;i<n;i++){

        if(i%3==0){

            f[i+3]=max(f[i+3],f[i]+get(s[0][i],s[0][i+1],s[0][i+2])+get(s[1][i],s[1][i+1],s[1][i+2]));

            g[i+1]=max(g[i+1],f[i]+get(s[0][i],s[0][i+1],s[1][i]));

            h[i+1]=max(h[i+1],f[i]+get(s[1][i],s[1][i+1],s[0][i]));

        }

        if(i%3==1){

            if(i+3<n){

                g[i+3]=max(g[i+3],g[i]+get(s[0][i+1],s[0][i+2],s[0][i+3])+get(s[1][i],s[1][i+1],s[1][i+2]));

                h[i+3]=max(h[i+3],h[i]+get(s[1][i+1],s[1][i+2],s[1][i+3])+get(s[0][i],s[0][i+1],s[0][i+2]));

            }

            f[i+2]=max(f[i+2],g[i]+get(s[0][i+1],s[1][i],s[1][i+1]));

            f[i+2]=max(f[i+2],h[i]+get(s[1][i+1],s[0][i],s[0][i+1]));

        }

    }

    cout<<f[n]<<'\n';

}

 

Cf2052 F

类似的情况,给定一个2*n的图形,每个位置是#或.两种元素,其中.是需要填充的位置,问用1*2的矩形进行填充,其可行的方案数为多少

这类问题上本质是通过枚举各种组合情况得到

void solve(){

    int n;

    cin>>n;

    string s[2];

    cin>>s[0]>>s[1];

    bool unique=true;

    for(int i=0;i<n;i++){

        if(s[0][i]=='#' && s[1][i]=='#'){

            continue;

        }

        if(s[0][i]=='.' && s[1][i]=='.'){

            if(i<n-1 && s[0][i+1]=='.' && s[1][i+1]=='.'){

                unique=false;

            }

            continue;

        }

        if(s[0][i]=='.'){

            if(i<n-1 && s[0][i+1]=='.'){// 因为这里一定要使用一个木板,因此这个位置不能被自由选择

                s[0][i+1]='#';

            }else{

                cout<<"None\n";

                return;

            }

        }else{

            if(i<n-1 && s[1][i+1]=='.'){

                s[1][i+1]='#';

            }else{

                cout<<"None\n";

                return;

            }

        }

    }

    if(unique){

        cout<<"Unique\n";

    }else{

        cout<<"Multiple\n";

    }

}

 

 

最值问题

cf1881 E

要让数列变得满足条件,本质上是考虑这个元素需不需要保留,选或不选的思路,因为我们数组元素代表的是段的长度,因此是从后向前转移,如果选,那么就从dp[i+a[i]+1]转移过来,否则不选就是要进行一次操作,dp[i+1]+1

同时需要特判当前位置加上段的长度是否超出了数组的长度

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    memset(dp,0,sizeof(dp));

    dp[n]=!(a[n]==0);

    for(int i=n-1;i>=1;i--){//1 3

        if(i+a[i]<=n) dp[i]=min(dp[i+1]+1,dp[i+a[i]+1]);

        else dp[i]=dp[i+1]+1;

    }

    cout<<dp[1]<<'\n';

}

 

cf1842C

最多能删多少个元素

选不选这个元素作为删除区间的右端点(左边是已经处理好了的)

如果不选,就是dp[i-1]

否则实际上是枚举与其元素值相同的位置进行转移dp[j-1]+i-j+1

这样相当于每到一个位置都要遍历,n^2时间复杂度

考虑优化,注意到上面的式子等价于dp[j-1]-(j-1)+i

因为i是确定的,因此只需要维护dp[j-1]-(j-1)的最值即可

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    memset(dp,0,sizeof(dp));

    memset(mx,-inf,sizeof(mx));

    for(int i=1;i<=n;i++){

        dp[i]=max(dp[i-1],mx[a[i]]+i);

        mx[a[i]]=max(mx[a[i]],dp[i-1]-(i-1));

    }

    cout<<dp[n]<<'\n';

}

tanxing

 

存在性问题

cf1741E
因为可能在左侧也可能在右侧,于是分两种情况进行转移

如果是在左侧,那么就从前一个位置进行转移,并且因为我们的元素是指当前段的长度,因此dp[i+a[i]]

|=dp[i-1],还有一种是在右侧,那么上一段的末尾的位置是dp[i-a[i]-1],同样是进行或运算

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    memset(dp,0,sizeof(dp));

    dp[0]=1;

    for(int i=1;i<=n;i++){

        if(i+a[i]<=n) dp[i+a[i]]|=dp[i-1];

        if(i-a[i]-1>=0) dp[i]|=dp[i-a[i]-1];

    }

    cout<<(dp[n]?"YES":"NO")<<'\n';

}

 

动态规划处理最优策略

cf 1875D
直觉想到的一种解决策略是先删0,这样可以使得之后的元素删除的花费都是0,但是这样的做法是存在问题的,假设之后的每种元素都只有一个,一直到最大元素,但0有若干个,于是这样操作的花费是max*n,如果从大到小进行删除的花费是(2+max+1)*(max-1)/2+n,与max*n进行比较,若n极大,而max=1,显然不是最优的

于是考虑删0前应该先删除什么数字,显然mex以上的不用去考虑,并且如果要删,一定是全删,因为是mex以下的数,因此都对mex有影响,因此从大到小进行转移,对于每个数来说都是相似的子问题,因此可以用dp,dp[i]表示删除i的最小花费,则dp[j]=dp[i]+i*(cnt-1)+j

int dp[5005];

int mp[5005];

void solve(){

    int n;

    cin>>n;

    //unordered_map<int,int> mp;

    memset(mp,0,sizeof(mp));

    for(int i=1;i<=n;i++){

        int x;

        cin>>x;

        if(x<=5005)mp[x]++;

    }

    int mex=0;

    while(mp[mex]){

        mex++;

    }

    memset(dp,0x3f,sizeof(dp));

    dp[mex]=0;//mex本身是不存在的

    for(int i=mex;i>=1;i--){

        for(int j=0;j<i;j++){

            dp[j]=min(dp[j],dp[i]+i*(mp[j]-1)+j);

        }

    }

    cout<<dp[0]<<'\n';

    //之后mex就是0

}

注意这题如果数组开的过大会爆T

 

动态规划用来解决选与不选的问题,但如果对于一个选择,对后续的操作可能会有影响时(这种后效性是可以删除的,因为只是限制了有限项能否选择,依然可以按数组中的顺序进行状态转移),可以进行排序,然后用二分查找来寻找下一个可以进行操作的是哪一个节点(T2008),且相较于将距离作为状态,利用动态规划的话一般都是选择到第i个点作为状态,因为这样的状态量更小,并且在数组上是连续的,并且信息量也更大(包括了开始结束的距离)

如果选择距离为状态,那么判断的依据就变成了是否有人在该点下车(以end作为key构建对应的表),如果有,则需要枚举求最大值,否则f(i)=f(i-1),依次进行传递(为了解决前一个人下车到后一个上车之间的数据)(注意为什么要以end作为状态以及转移的依据,这样修改是修改下车之后那一刻的值(这点有点类似于旅行家的预算的remain,记录的是刚好到的那一刻的值),这样就保证了在没下车之前依旧是当做没有接客的状态(而不是一旦接上客,就对这位客人路上的利润都更改),这样就可以不用考虑是否现在选择的客人与之前选择的客人路径上有重叠了)(T2008)

 

dp求最值,关键在于如何递推思考,即使有删除操作,也应该一步步去思考对于某一个位置上的字符到底进行什么操作,怎样去进行状态转移,特别是题目中可进行的操作有限时(T72,考虑初始化,并且根据原本这位上的字符进行判断加转移,这一点和之前的字符串匹配有些类似)

 

子序列的相邻元素不能间隔太大(T2919),不能间隔太近(打家劫舍T213)

考虑间隔问题时(与上一个改变过的数的下标差时),可以新增一个参数去表示这个数的右边(从遍历或递归的方向看)没有改变的数的个数,这样就不用去记录下标,只需要考虑当前这个数需不需要选

 

给定n个元素的数组a,如果选择了i,那么i-1和i+1位置的元素不能都选,这种情况下计算出可以得到的元素和的最大值

该问题可以转化为「不能选中连续 3 个元素」的问题,因为如果一段连续的三个位置都被选中,那么中间元素的左右两边都被选中了,不满足题意。也就是说,对于连续选取,其上限为 2 个连续选取。

为此可以定义状态 dp[i][k] 表示前 i 个数(下标 0 到 i)的最优解,其中在位置 i 选取后连续选取了 k 个数(k 的取值为 0、1、2,其中 k = 0 表示 i 未选取,k = 1 表示 i 被选取且前面断开了连续性,k = 2 表示 i 被选取且与前一个数连续选取)。

状态转移如下:

如果不选第 i 个数,那么连续个数变为 0,即

dp[i][0] = max{ dp[i-1][0], dp[i-1][1], dp[i-1][2] }

如果选第 i 个数,并且前一个数未选(断开连续),那么连续数为 1:

dp[i][1] = dp[i-1][0] + a[i]

如果选第 i 个数,并且前一个数选了且正好是连续1个(续接),那么连续数为 2:

dp[i][2] = dp[i-1][1] + a[i]

 

动态规划问题想要优化时间复杂度,可以尝试交换状态与状态值(T300)

 

如何回溯出字典序最小的具体方案,常规的回溯具体方案的做法是,从最终答案开始往回追溯,通过判断是从哪个状态转移过来的来确定回溯点为何值,不过这样只能确保回溯出字典序最大的方案是正确的,而我们需要回溯出字典序最小的方案,因此有两种做法

一种是将正序dp调整为反序dp,修改状态定义,转移过程的分析同理,然后从下标idx=0开始进行回溯,优先采纳idx小的方案

另一种是仍然采取正序dp的做法,但对原数组进行翻转,从而将回溯字典序最大转换为字典序最小(并且为了避免对边界的处理,我们令动规数组和前缀和数组sum的下标从1开始)

 

注意那篇股票问题的题解:重点是要什么,每次操作前都保证最小,这样能快速找到所需的状态(T2919  88ms解法,各操作对应的状态以及相应的状态转移)

 

动规解决顺序:

确定dp数组(dp table)以及下标的含义;确定递推公式;dp数组如何初始化;确定遍历顺序;举例推导dp数组(打印检验正确性)

 

计算数组中插入乘号的最大可能结果(P1018),一种就是利用dp,dp[i][j]表示在第i个数字后面放第j个乘号最大的乘积值,另一种是利用dfs,利用三个参数,已插入的乘号个数,当前乘积,上一个乘号的插入位置

 

背包问题

特征:有背包的容量(目标值),且该背包与小背包有直接关系(能得到递推公式,通过子问题的最优解来得到该题解),有物品的价值和重量(两者可以实际上指向同一数值),主要问题是为了解决类似填充的问题(求和也可抽象为填充,理解为已有和,从物品中去找使得这个容器恰好被填满)

装满有几种方法,一般递推公式中为累加

对于背包问题可以先用回溯去思考,再转成状态方程以及记忆化搜索

 

转化为背包问题

为什么不能直接算单价?我们的有效消费是请那个人的消费/需要通知的人数,当需要通知的人比较少时,去请那些单价低的并不一定能得到最小消费

soyorin至少要花一次p的代价将消息通知给1个人,然后再让这个人将消息通知给剩下的n-1个人

于是可以将问题转化成:将消息通知给n-1个人的最小代价,将消息通知给bi个人需要花费ai的代价,并且ai,bi能用多次,也就是一个完全背包(bi是体积,ai是价值)

 

int main(){

    ios::sync_with_stdio(0); cin.tie(0);

    cin>>n>>m;

    res=n*m;

    memset(f,1,sizeof(f)); f[0]=0;

    for(i=1;i<=n;i++)cin>>a[i]>>b[i];

   

    for(i=1;i<=n;i++){

        for(j=1;j<=n;j++){

            f[j]=min(f[j],f[max(0ll,j-b[i])]+a[i]);

        }

    }

   

for(i=1;i<=n;i++){

    //对于有些数据可能用m反而会更优,且任何方案至少使用一次m

        res=min(res,max(1ll,n-i)*m+f[i]);

    }

   

    cout<<res;

}

 

 

存在性背包

已知n件物品的体积分别为pi,还有m个背包的容量分别为ai,每件物品可以取任意多次,询问可以装满多少个背包,其中n<=500,m<=300000,p[i]<=100000<=a[i]<=40000000

如果无关数据范围,可以套用完全背包

for(int i=1;i<=n;i++){

    for(int j=maxv;j>=p[i];j--){

        dp[j]=dp[j]|dp[j-p[j]];

    }

}

令物品里最小的体积是pmin,对于一个背包的容量x,如果x%pmin=y,那么如果可以用除最小价格物品以外的其他物品凑出一个z,使得z%pmin=y,即z和x同余,并且z<x,那么显然x就可以用z和若干个pmin凑出

于是可以设置一个数组dis[j],用于表示v%pmin=j时,v的最小值,对于每一个背包容量,只需要判断dis[a[i]%pmin]与a[i]的大小关系即可

而dis数组可以用最短路处理,将每个体积抽象为点,然后分别在当前更新的体积的基础上加上其他物体的体积

const int maxn=510,maxm=100010;

int n,m,ans,dis[maxm],p[maxn];

bool inq[maxm];

queue<int> q;

void spfa(){

    int x,y;

    memset(dis,0x3f,sizeof(dis));

    dis[0]=0;

    q.push(0);

    inq[0]=true;

    while(!q.empty()){

        x=q.front();

        q.pop();

        inq[x]=false;

        for(int i=2;i<=n;i++){

            y=(x+p[i])%p[1];//除了pmin外的数去组合,因为是pmin的余数,因此是有限的

            if(dis[y]>dis[x]+p[i]){

                dis[y]=dis[x]+p[i];

                if(!inq[y]) q.push(y),inq[y]=true;

            }

        }

    }

}

 

void solve(){

    int x;

    cin>>n>>m;

    for(int i=1;i<=n;i++) cin>>p[i];

    sort(p+1,p+n+1);

    spfa();

    for(int i=1;i<=m;i++){

        cin>>x;

        if(dis[x%p[1]]<=x)  ans++;

    }

    cout<<ans<<'\n';

}

 

上海市赛2024F

将羁绊看作是点,英雄看作是边,那么相当于在一个图中选择若干条边,使得被覆盖的度为2的节点个数尽可能多

对于边的情况,如果是链,那么n个满足要求的点对应n+1条边,如果是环,那么n个满足要求的点对应n条边,因此尽可能用环去覆盖图,对于链的个数应该是尽可能少

因此先预处理出每个环以及链所对应的长度(因为每个点的度数最多只会为2,因此不会出现一个环连接链,或者两个环共用多个顶点的情况)

对于询问,每次相当于是给出了可供选择的边的个数,因此在预处理出这些数据之后,就相当于对每个询问作背包问题

当前的边数小于环长度的总和时,如果能用若干个环恰好覆盖边,那么所得到的点的个数就是i,否则是i-1(因为实际我们选取的时候不一定要把边或环完整的选入,就是之前提及的“不完整的环”,在尽可能选择环去填充的前提下,这部分的边数即度为2的点的个数,我们选取剩余环中的一部分用于填充空余的边的位置,因为当前的总边数还小于总的环的长度之和,所以我们最多只需要选择一个不完整的环即可,因此只有一个节点的亏损,得到的度为2的点的个数是i-1)

而当可供选择的边数大于环的总长度时,因为每选择一条边就会亏损一个点,因此可以贪心地选择链,每次选择当前可以选择的边中长度最短的那条边,直到不能再继续选择为止

 

/*

求出每个链或者环的长度

如果以羁绊为点,英雄为边,那么相当于连上若干条边,考虑这些边导出的子图

因为每个度至多为2,完整的图是由环和链组成的,为了让导出的子图中,由两个度的点尽可能多,应该导出尽可能多的环,使得链上的边尽可能少

因此优先放环,链、不完整的环的个数要尽可能少

 

并查集

*/

constexpr int N=1e5;

struct DSU{

    vector<int> f,siz;

    DSU(){};

    DSU(int n){

        init(n);

    }  

    void init(int n){

        f.resize(n);

        iota(f.begin(),f.end(),0);

        siz.assign(n,1);

    }

    int find(int x){

        while(x!=f[x]){

            x=f[x]=f[f[x]];

        }

        return x;

    }

    bool same(int x,int y){

        return find(x)==find(y);

    }

    bool merge(int x,int y){

        x=find(x);

        y=find(y);

        if(x==y){

            return false;

        }

        siz[x]+=siz[y];

        f[y]=x;

        return true;

    }

    int size(int x){

        return siz[find(x)];

    }

};

 

void solve(){

    int n,m;

    cin>>n>>m;

    DSU dsu(m);

    vector<int> cyc(m);

    for(int i=0;i<n;i++){

        int a,b;

        cin>>a>>b;

        a--;b--;

        if(!dsu.merge(a,b)){//成环

            cyc[dsu.find(a)]=1;

        }

        //因为一个点的度最多就只有2,因此一个点最多只会属于一个环

    }

    vector<int> x,y;

    for(int i=0;i<m;i++){

        if(dsu.find(i)==i){

            if(cyc[i]){

                x.emplace_back(dsu.size(i));

            }else{

                y.emplace_back(dsu.size(i)-1);//链的长度要减1

            }

        }

    }

    //长度实际上就是需要的英雄的数量

    sort(x.begin(),x.end(),greater<int>());

    sort(y.begin(),y.end(),greater<int>());

    int sumx=accumulate(x.begin(),x.end(),0);

    /*

    存在性背包

    bitset优化背包

    */

    bitset<N+1> dp{};

    dp[0]=1;

    vector<int> cnt(n+1);//最多的边数不超过n

    for(auto v:x){

        cnt[v]++;

    }

    for(int v=1;v<=n;v++){

        if(cnt[v]){

            int s=cnt[v];//这个大小的环的个数

            for(int k=1;k<s;k*=2){

                dp|=dp<<(k*v);

                s-=k;

            }

            dp|=dp<<(s*v);

        }

    }

    vector<int> ans(n+1);

    int sum=sumx;//环的总和

    int j=0;

    //将i看作是背包,环或者链看作是物品

    //最优情况就是全用环去填充

    //因此等价于对每个i,找到最大的x,x是若干个环的长度的和,并且x<=i

    for(int i=1;i<=n;i++){

        if(i<=sumx){

            ans[i]=i-!dp[i];//如果完美覆盖若干个环,这样不需要-1

        }else{

            while(sum<i){//环用完了,用链补充

                sum+=y[j++];

            }

            ans[i]=i-j;

        }

    }

    for(int i=1;i<=n;i++){

        cout<<ans[i]<<' ';

    }

    cout<<'\n';

}

 

 

分组背包

牛客24多校G

题意:定义一个多重数集合是好的,当且仅当集合中元素乘积是一个的正整数的平方,集合的权值为这个正整数的值

求大小为n的多重数集的所有子集中所有好集的权值和

(经典状压,然后暴力dp,状压小素数,枚举大素数来转移)

因为根号1000内的质数只有11个,并且对于1000以内的数,质因子中不可能出现两个大于32的质数(当数据个数比较少时,考虑状压,通过遍历子集进行转移)

因此可以按照大质数分组,小质数状态压缩成背包的体积做分组背包

constexpr int primes[] = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31};

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    const int m=*max_element(all(a));

    vector<vector<pair<int,Z>>> v(m+1);

    for(auto x:a){

        int mask=0;

        Z val=1;

        //状压小素数

        for(int i=0;i<11;i++){

            int p=primes[i];

            while(x%p==0){

                x/=p;

                if(mask>>i & 1){

                //自身已经构成了这个质因子的平方

                    val*=p;

                }

                mask^=1<<i;

            }

        }

        v[x].emplace_back(mask,val);

        //x所包含的质数的平方对答案的贡献,压缩剩余的质因子

    }

    vector<Z> dp(1<<12);

    dp[0]=1;//空集设定为1,方便后续转移

    for(int p=1;p<=m;p++){

        if(p*p<=1000 && p!=1){

        //此时是枚举大素数

            continue;

        }

        if(v[p].empty()){//不在数组中

            continue;

        }

        for(auto [mask,val]:v[p]){

            auto ndp=dp;//不选

            if(p!=1){

                mask|=1<<11;

                //给第12位上打上标记,表示大质数

            }

            //枚举dp中的状态

            for(int s=0;s<(1<<12);s++){//转移

                int ns=s^mask;

                Z res=dp[s]*val;//之前已有的权值

                int both=s&mask;

                for(int i=0;i<11;i++){

                    if(both>>i & 1){

                        res*=primes[i];

                    }

                }

                if(both>>11 & 1){

                    res*=p;

                }

                ndp[ns]+=res;//在选了p之后剩余的质因子

                //相当于对于这种状态已有多少贡献

            }

            dp=move(ndp);

        }

        //清理,没消完的大质数对之后方案的贡献就等于0

        //对于1000以内的数,质因子中不可能出现两个大质数

        for(int s=1<<11;s<(1<<12);s++){

            dp[s]=0;

        }

    }

    //因为计算了空集,因此这里-1

    cout<<dp[0]-1<<'\n';

}

 

 

01背包(只用一次,选与不选

选与不选当然也可以转化为选哪一个,例如有两种选择,对于每一个物品我自然也可以选择其中一种方式,那么换个视角,也可以看作对于其中一个操作,这个物品是否被选择在其中(T2742),同样的对于正负问题,选那些数为正那些数为负,再根据答案最后的要求整体看这两个选择,抽象出一个背包,得到它的体积和价值(T494)

 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])  dp[i][j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少

初版:二维数组,起始:dp[i][0]=0  dp[0][j](j>=weight[i])=value[i]

之后的遍历顺序,此时的遍历有两个维度,物品下标i以及重量j,此时两种遍历方式均可行

如何判断:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的,dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),而无论用那种方式遍历,都保证了左上角有数据

滚动数组(一维dp数组):

在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);(数据只与上一层有关,并且直接使用的就是上一层数据,因此可以覆盖)

与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。

这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层

初始化同理

差别在于遍历顺序:

二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小

即for(int i = 0; i < weight.size(); i++) { // 遍历物品

    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量

        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

 

    }

}

倒序遍历是为了保证物品i只被放入一次,如果一旦正序遍历了,那么物品0就会被重复加入多次,从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了

因为此时的递推公式赋值只与左侧数据有关,因此从后往前遍历,后面的数据改动不会影响到前面,但从前往后遍历时,前面的数据已经被更新过了,对于相同的一个物品,可能在前一次的最大值情况下被使用了,即实际上的dp[i],但程序运行时,依然当作是dp[i-1]的值,因此可能会重复加上该物品,但如果是二维数组,数据不存在覆盖的情况,因此依然可以从前往后遍历(倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,所以从右向左覆盖)

同样,此时也不能先遍历背包容量再遍历物品了,例如容量最大的情况,当其从i开始遍历物品时,无论如何它都只能放入少量的物品,因为其左侧的数据在遍历时还未被覆盖过,依然是最初的状态,即不是二维数组思维上的上一层的数据

总而言之,如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历

 

先遍历物品再遍历背包得到的是组合数,而先遍历背包再遍历物品得到的是排列数

前者是1-i有序放入的,因此不会出现(2,1)的情况,而后者是先放入一次后再循环放入一次,可能出现前一次放入2之后,在这一次的循环中放入了1,这就会出现(2,1)的情况

然后再解释一下为什么最经典的多重背包为什么先背包和先物品都可以:因为需要装入背包的最大价值,就算出现了物品2在物品1之前的情况,只要计算出最大价值,也符合题意

 

要能分辨不同背包问题,01背包的特征就在于每个物品只能被用一次,其他的具体变化并不影响对于题目类型的分类,如leetcode T474,对于背包的容量,物品的质量可以同时有多个维度,但整体的框架并不影响,只是dp数组的形式发生了变化

 

关键词:子序列,和为target(T2915,T494)

特别是求方案数时,其状态转移方程就是将求最大值改为加法(加法原理)

变形:

恰好装capacity

function<int(int, int)> dfs = [&](int i, int c) -> int {

            if (i < 0) return c == 0;  //c为0是恰好等于目标值,是一个合法的方案

            int &res = cache[i][c];  //cache为记忆化数组

            if (res != -1) return res;

            if (c < nums[i]) return res = dfs(i - 1, c);

            return res = dfs(i - 1, c) + dfs(i - 1, c - nums[i]);

        };

 

用1维数组写时就要加一个判断,不能每次都从背包的最大容量开始倒序,这样不满足恰好填满背包

应为

vector<int> f(target + 1, INT_MIN);

        f[0] = 0;

        int s = 0;

        for (int x : nums) {

            s = min(s + x, target); 

//只有目前所有体积加起来有可能达到target时再从target开始倒序遍历

            for (int j = s; j >= x; j--) {  //j>=x避免出现背包容量为负数的情况

                f[j] = max(f[j], f[j - x] + 1);

            }

        }

        return f[target] > 0 ? f[target] : -1;

    }

 

至多装capacity

function<int(int, int)> dfs = [&](int i, int c) -> int {

            if (i < 0)  return 1;  //就不需要判断了,能递归到终点说明c>=0

            int &res = cache[i][c];

            if (res != -1) return res;

            if (c < nums[i]) return res = dfs(i - 1, c);  //因为这一句的存在保证了c不小于0

            return res = dfs(i - 1, c) + dfs(i - 1, c - nums[i]);

        };

 

至少装capacity

function<int(int, int)> dfs = [&](int i, int c) -> int {

            if (i < 0) return c <= 0;  //即物品所占的体积大于等于c,因为是减下来的所以c应该小于等于0

            int &res = cache[i][c];

            if (res != -1) return res;

            return res = dfs(i - 1, c) + dfs(i - 1, c - nums[i]);

        };

 

01背包,bite型

先考虑,每个物品的重量都只含1bit的情况,这可以帮助对这题要解决什么问题有个大致了解;

• 记所选物品重量或起来是c,枚举c里是1的某个bit,强制c里该位为0,则该位将c分成了前

后两部分:

• 对于前面的那部分位(更高位),要求所选的物品这些位必须是c的子集(即c对应位是1

才能选)

• 对于后面的那部分位(更低位),没有任何限制

• 因此,枚举c里每一位作为这个分界,每个物品就变成了要么能选要么不能选、彼此之间也不

影响,所以把能选的都选上就好

void solve() {

    int n, m;

    cin >> n >> m;

    vector<int> v(n), w(n);

    for (int i = 0; i < n; i++) {

        cin >> v[i] >> w[i];

    }

    //高于那一位的必须是子集,小于那一位的可以任意

    auto get = [&](int x) {

        i64 res = 0;

        for (int i = 0; i < n; i++) {

            if ((x & w[i]) == w[i]) {

                res += v[i];

            }

        }

        return res;

    };

 

    //从低到高枚举原来为1,现在为0的bite位

    //任何情况一定满足小于等于m,则对于那些小于的方案至少有一位在m中为1,方案重量中为0

    i64 ans = get(m);

    for (int i = m; i; i -= i & -i) {

        ans = max(ans, get(i - 1));

    }

    cout << ans << '\n';

}

 

 

完全背包

数据可以重复使用,因此在背包容量的循环中,应为从小到大循环

因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了,就理论而言,两个for循环的顺序不影响,但针对于具体的组合数和排列数问题,循环的顺序有影响

 

求最少的数量的情况时,是组合数还是排列数都无关,所以两个for循环的顺序可以任意

 

dp的核心在于状态转移方程,因此重点在于找好状态。状态并不一定只有两种,也并非直接表征子问题中的题干所求。如股票最佳时机的dp[i]并不是指若干天时买卖两次的最大所得。状态来源于操作,通过操作前后的关系来写转移方程,根据题目描述的情况来找有几种状态,具体就是每次操作前后,该操作的性质是什么。这类要与背包问题进行区分,因为背包问题的操作情况只有一种,所以下标表示的情况更易于理解,而例leetcode如T188一类,答案只是所有状态中的其中一种情况

 

(利用相同题的不同解法来更好理解概念,如股票问题,本质上就是买和卖)

 

关于回溯,动规和深搜

「回溯算法」强调了「深度优先遍历」思想的用途,用一个 不断变化 的变量,在尝试各种可能的过程中,搜索需要的结果。强调了 回退 操作对于搜索的合理性,因此深搜和回溯本质上是一致的;动态规划只需要求我们评估最优解是多少,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果;

回溯算法可以搜索得到所有的方案(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高

每一个结点表示了求解全排列问题的不同的阶段,这些阶段通过变量的「不同的值」体现,这些变量的不同的值,称之为「状态」;

使用深度优先遍历有「回头」的过程,在「回头」以后, 状态变量需要设置成为和先前一样 ,因此在回到上一层结点的过程中,需要撤销上一次的选择,这个操作称之为「状态重置」;

深度优先遍历,借助系统栈空间,保存所需要的状态变量,在编码中只需要注意遍历到相应的结点的时候,状态变量的值是正确的,具体的做法是:往下走一层的时候,path 变量在尾部追加,而往回走的时候,需要撤销上一次的选择,也是在尾部操作,因此 path 变量是一个栈;

深度优先遍历通过「回溯」操作,实现了全局使用一份状态变量的效果

对于回溯,通常是在「递」的过程中增量地构建答案,并在失败时能够回退,例如八皇后。对于递归,是把原问题分解为若干个相似的子问题,通常会在「归」的过程中有一些计算。如果一个递归能考虑用记忆化来优化,就需要 return 一个值并加以保存

 

bitset优化背包

「LibreOJ β Round #2」贪心只能过样例

题面:一共有n个数,对于每个数xi可以取ai到bi中的任意值,若s为所有xi的平方和,问s总共有多少种可能性

这题显然可以用dp做

f(i,j)表示前i个数的平方和能否为j,那么f(i,j)=

直接计算的时间复杂度是O(n^5),但可以用bitset进行优化

int n,a[N],b[N];

bitset<N*N*N> f[N];

void solve(){

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i]>>b[i];

    }

    f[0][0]=1;

    for(int i=1;i<=n;i++){

        for(int j=a[i];j<=b[i];j++){

            f[i] |=(f[i-1]<<(j*j));

            //bitset中的第i位就表示了i这个数字,为1,就说明可以表示

            //左移相当于我们转移方程中的加,例如0在之前时可以被表示的,左移8位后就表示8也是可以被表示的

            //或运算使得之前的所有表示形式被压缩起来了

            //因此可以用于存在性表示,即只有0/1两种状态

        }

    }

    cout<<f[n].count();

}

 

P1537

正解是利用二进制转化为01背包

但可以直接用bitset解决,转化为存在性背包,判断(sum/2)能否被组合出来

int a[15];

bitset<200005> dp;

void solve(){

    int t=0;

    while(cin>>a[1]){

        for(int i=2;i<=6;i++){

            cin>>a[i];

        }

        if (a[1] == 0 && a[2] == 0 && a[3] == 0 && a[4] == 0 && a[5] == 0 && a[6] == 0) break;

        cout << "Collection #" << ++t << ":\n";

        dp.reset();

        bool ok=1;

        int sum=0;

        for(int i=1;i<=6;i++){

            sum+=a[i]*i;

        }

        if(sum%2){

            cout<<"Can't be divided.\n\n";

            continue;

        }

        dp[0]=1;

        for(int i=1;i<=6;i++){

            while(a[i]--){

                dp|=dp<<i;

            }

        }

        ok&=dp[sum/2];//总和是确定的,能凑出一半那么另一半必然也能凑出

        if(ok){

            cout<<"Can be divided.\n\n";

        }else{

            cout<<"Can't be divided.\n\n";

        }

    }

}

正解:
         在上面的算法中,是将弹珠数量作为组数,即若干个权值为i的弹珠,这样子不同的状态(在单种弹珠权重总和以下的状态)都能被若干个i表示,这是一种对单种弹珠分组的方法

但可以换个方式进行分组,x为单种弹珠数,将其分成若干个组,使得总数以下的值都能被某些组的数之和表示,算法核心代码:

for(int t=x;t>0;t>>=1){

    f=(t>>1)+(t&1);

}

例如经过这样的划分之后,11=6+3+1+1,保证了1~11都可以被表示

这样就可以将每个组都当作一个独立的物品带入背包了,复杂度变成了log(即利用了二进制进行优化)

bool Solve(){

    memset(v , false , sizeof(v)); v[0] = true; // 清空 dp 数组(背包)

    // 类似经典的拔河问题,本来应给 s 除以 2 做 dp

    // 这里不给 s 除以二,以免 s 为奇数,因此给权值乘 2

    for(int i = 1 ; i <= 6 ; i++)

        for(int t = a[i] ; t > 0 ; t >>= 1){// P2320中的类似做法,二进制优化多重背包

            int f = (t >> 1) + (t & 1); // 变量 f 存储弹珠分组的当前组量,作为物品

            for(int j = s ; j >= f * i * 2 ; j--)

                if(v[j - f * i * 2])

                    v[j] = true;

                // dp[j]|=dp[j-f*i*2];//即01背包的计算方式,因为现在分组后得到的物品是唯一的          

        }

    return v[s]; // 返回弹珠是否能按价值平分

}

 

P9509

int a[10005];

bitset<3000005> dp,dp1;

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    ll sum=accumulate(a+1,a+n+1,0ll);

    if(sum%n){

        cout<<"No\n";

        return;

    }

    //分部平均,要求能被划分成若干个部分,其中每个部分的平均值相同

    //因为不一定是划分成两部分,因此如果只是判断dp[sum/2]是否为true不能保证划分成功

    sum/=n;

    for(int i=1;i<=n;i++){

        a[i]-=sum;

        if(a[i]==0){

            cout<<"Yes\n";//可以分成这个数,以及除这个数以外的部分,对于一个数组来说减去一个平均数不改变这个数组的平均数

            return;

        }

    }

    dp.reset();

    dp1.reset();

    dp[0]=1,dp1[0]=1;

    for(int i=1;i<=n;i++){

        if(a[i]<0){

            a[i]*=-1;

            dp1|=dp1<<a[i];

        }else{

            dp|=dp<<a[i];

        }

    }

    dp&=dp1;

    /*

    dp,dp1分别表示选取正数和负数的情况

    显然全不选时,即dp[0]和dp[1]都是1

    而正数全选和负数全选对应的dp和dp1也都是1,因为总和的平均仍然是原平均数,因此这两者的和为0

    而除了这两种情况外如果有一位为1,那么就可以将对应的正数选择和负数选择放在一个集合中

    又因为正数和负数的总和是一样的,因此按上述划分后,剩下的部分的和也是0

    */

    if(dp.count()>2) cout<<"Yes\n";

    else cout<<"No\n";

}

 

树形dp

由于二叉树的左右子树本来就和整棵树是相似的,所以天然的有原问题和子问题之间的关系,因此可以借助递归或者动态规划来解决问题

邻居:如果两个点x、y之间有边相连,那么可以说x是y的邻居,y是x的邻居,在二叉树中一个节点最多有三个邻居,而在一般树中,我们就需要用for循环挨个遍历一个节点的邻居

类型一:求树中的最大深度,最长路径长,最大节点和等(leetcode T543,T124,T2246)通过递归,得到子树中的对应数值

以及类似的变形,通过对问题的分析,转化到这类题上(T2538,一般树的直径)

(一般树的直径中要注意,遍历邻居时要判断是否为父节点,要排除父节点的情况)

自底向上的递推或自顶向下的记忆化,一个位置上的操作会对后续位置有影响(T2920)

类型二:父节点的操作对子树的操作有影响(例如父节点操作后子节点不能再进行操作T2646,这个操作与打家劫舍也有些类似T337)

要记录是否对父节点进行操作较困难时,可以正难则反地去考虑,并且如果递归边界是叶子节点时,为了避免将根节点误判断为叶子节点,可以事先为根节点增加一个不存在的节点-1,之后判断是否是叶子节点就可以用if(g[i].size()==1)来判断了(T2925)

对数递归时一般要同时传入父节点,避免进行dfs时向上搜索

 

树的最长直径

dp[u]+dp[v]+1是指最长直径经过当前根节点u的情况,即将u当作是路径上的转折点

dp[u]计算的是u为根节点的数的最大深度,其最大深度是子树最大深度+1

void dfs(ll u, ll pre) {
             dp[u] = 0;
             for(ll i = head[u], v; i; i = e[i].nxt) {
                          v = e[i].to;
                          if(v == pre) continue;
                          dfs(v, u);
                          sum = max(sum, dp[u] + dp[v] + 1);
                          dp[u] = max(dp[u], dp[v] + 1);
             }
             return ;
}//搜索,求树的直径

cf14d

求树上两条路径乘积的最大值,并且这两条路径不能有公共节点,即说明这两条路径互不相连,说明这两条路径在不同的区块内

因为题目保证这是一个树,不存在环,因此每一条边都连接两个区块,同时树的节点个数较少,因此可以枚举所有边,判断切割这条边之后的两路径的乘积的最大值

dfs(u[i], v[i]);//对第一个区块进行搜索

s *= sum, sum = 0;

dfs(v[i], u[i]);//对第二个区块进行搜索

s *= sum, sum = 0;

ans = max(ans, s);//求最大

 

cf 2107D

给定一棵树,树上的节点初始都标记为0,每次求出当前标记为0的节点中的最长路径,如果长度相等,则选择起点终点序列(u,v)最大的那个,并将这条路径上的节点都标记为1,并将路径长度d和u,v依次加入答案数组中,重复这样操作直到树上所有节点的标记都为0

显然就是每次求出当前仍存在的节点中的最长直径

关键是如何实现

因为每次不仅要找出最长直径,还要对路径上的所有顶点标记为查询过,因此可以使用bfs

每次两次bfs,第一次找出距离根节点最远的节点,第二次从这个节点开始找最远的终点,可以保证这样找到的路径一定是整棵树中最长的

void solve(){

    int n;

    cin >> n;

    vector<vector<int>> adj(n);

    for (int i = 0; i < n - 1; i++) {

        int u, v;

        cin >> u >> v;

        u--, v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

 

    vector<array<int, 3>> ans;

 

    vector<bool> vis(n);

 

    vector<int> dis(n, -1), p(n);

    vector<int> q;

    auto bfs = [&](int x) {

        q = {x};

        dis[x] = 0;

        p[x] = -1;

        for (int i = 0; i < q.size(); i++) {

            int x = q[i];

            for (auto y : adj[x]) {

                if (y != p[x] && !vis[y]) {

                    dis[y] = dis[x] + 1;

                    p[y] = x;

                    q.push_back(y);

                }

            }

        }

        int t = x;

        for (auto x : q) {

            if (dis[x] > dis[t] || (dis[x] == dis[t] && x > t)) {

                t = x;

            }

        }

        return t;

    };

 

    auto work = [&](auto self, int x) -> void {

        int a = bfs(x);

        int b = bfs(a);

        ans.push_back({dis[b] + 1, max(a, b) + 1, min(a, b) + 1});

        for (int i = b; i != -1; i = p[i]) {

            vis[i] = true;

        }

        for (int i = b; i != -1; i = p[i]) {

            for (auto x : adj[i]) {

                if (!vis[x]) {

                    self(self, x);

                }

            }

        }

    };

 

    work(work, 0);

 

    sort(ans.begin(), ans.end(), greater());

    for (int i = 0; i < ans.size(); i++) {

        cout << ans[i][0] << ' ' << ans[i][1] << ' ' << ans[i][2] << " \n"[i == ans.size() - 1];  

    }

}

 

 

cf9d

找数学规律比较困难的话可以尝试使用动态规划解决,因为只要考虑如何通过数据量较小的情况去进行转移,组成一棵二叉树的情况数可以由左子树的总数乘以右子树的总数

而类似于区间问题(这题里是高度问题),可以利用类似于前缀和相减得到某一区间里的数值的方法去解决,将高度设定为其中一个状态,就可以利用动态规划解决对应方案数问题了设f[n][h]表示n个节点建立的深度不大于h的二叉树总数,i为左子树的节点数,n-i-1为右子树的节点数,由于左子树的总数乘以右子树的总数即为该二叉树的总数,由此可以得出状态转移方程为枚举左子树的节点个数每一种情况对应f(n,h)=f(i,h-1)*f(n-i-1,h-1),初始状态f(i,0)=0 (1≤i≤n),f(0,i)=1 (0≤i≤n)。那么根据题意,答案即为f[n][n]-f[n][h-1]

 

acwing 1220

注意这个所谓的序列是可以有重复节点出现的,因此最大值并不是类似于树的直径的形式而是树中一个连通块,使得它的节点权值的总和最大

一般用dfs来递归处理,定义f[u]表示为以u为根的子树中包含u的所有连通块的权值的最大值,因为要权值尽可能大,因此转移方程为f[u]=w[u]+max(f[s1],0)+max(f[s2],0)+…max(f[sk],0)

void add(int a, int b){

    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;

}

 

void dfs(int u, int father){

    f[u] = w[u];

    for (int i = h[u]; i != -1; i = ne[i]){

        int j = e[i];

        if (j != father){

            dfs(j, u);

            f[u] += max(0ll, f[j]);

        }

    }

}

 

cf1929

无论如何动态规划,我们显然都要设定状态,因此,我们显然不能将一个很复杂的内容设定为状态,例如在树形dp中,不可能将整棵子树作为一个状态,可能在实际处理的时候,是利用类似于dfs的思想,最终是对整棵子树在进行操作,但我们在一开始考虑的时候,我们只关注一些能够代表这棵子树的东西,例如子树的根节点,具体是怎么完成的可能是在转移过程中,或者是从边界条件确定的,但这和我们现在都没关系,对于动态规划,我们要考虑的只有如何不是一般性地去表示我们的方案以及如何去转移,因此每次进行树上的转移都可以看作一棵深度为2的树,并且要使根节点所表示方案就是可以直接用的数据了,不要说在转移的时候还要考虑其子节点了,不然就说明这是个不完善的状态定义

我们把选取到的点称为黑点,由题意得,一个合法的点集能使树中任意一条简单路径上的黑点数量不超过两个。也就是说,如果黑点数量多于 22,对于任意两个黑点,它们如果在同一个节点的子树内,必然是兄弟关系

设状态dpi,j表示以 i 为根的子树内,从根到叶子节点最多经过 j 个黑点。显然,只有 j 等于 0,1,2 时,状态是合法的。

考虑如何转移,对于 dpi,0,显然等于 1

对于dpi,1,由于根到叶子节点最多经过黑点数最大值为 1,在这棵子树内,经过黑点数最多的情况就是两条到根的路径合并,经过 1+1=2 个黑点,所以可以对任何一个子节点都可以进行操作。另外,每个子树内还可以从没有黑点涂子树的根上的黑点变成有 1 个黑点,所以还需要加入没有黑点的方法数

故最终有dp[i][1]=

对于dpi,2,由于根到叶子节点最多经过黑点数最大值为2,所以这时只能选择一个子树进行了操作了,否则必然会可以构造一条黑点数量超过两个的简单路径。另外,每个子树内还可以从 1 个黑点涂子树的根上的黑点变成有 2个黑点,所以还需要加入 1 个黑点的方法数

故最终有dp[i][2]=

struct edge{

    ll v,nxt;

}e[800000];

ll t,u,v,n,mod=998244353,h[800000],f[800000][3],cnt=0;

void add_edge(ll u,ll v){

    e[++cnt].nxt=h[u];

    e[cnt].v=v;

    h[u]=cnt;

}

void init(){

    for(int i=1;i<=n;i++)h[i]=0,f[i][1]=1,f[i][2]=0;

    cnt=0;

}

void dfs(ll x,ll fa){

    for(int i=h[x];i;i=e[i].nxt)

        if(e[i].v!=fa){

            dfs(e[i].v,x);

            f[x][1]=(f[x][1]*(f[e[i].v][1]+1)%mod)%mod;

            f[x][2]=(f[x][2]+f[e[i].v][1]+f[e[i].v][2])%mod;

        }

}

void solve() {

    cin>>n;

    init();

    for(int i=1;i<=n-1;i++){

        cin>>u>>v;

        add_edge(u,v),add_edge(v,u);

    }

    dfs(1,0);

    cout<<(f[1][1]+f[1][2]+1)%mod<<'\n';

}

 

P8744

首先贪心地去考虑,一棵树能得到的最大高度是其孩子节点的个数+孩子中的最大高度

而孩子中的最大高度又是一个类似的问题,因此用动态规划进行解决

vector<vector<int>> adj(100005);

int dp[100005];

void solve(){

    int n;

    cin>>n;

    for(int i=2;i<=n;i++){

        int x;

        cin>>x;

        adj[x].push_back(i);

    }

    auto dfs=[&](auto && self,int x)->void{

        for(int i=0;i<adj[x].size();i++){

            self(self,adj[x][i]);

            dp[x]=max(dp[x],dp[adj[x][i]]);//孩子中的最大高度

        }

        dp[x]+=adj[x].size();//孩子节点个数

    };

    dfs(dfs,1);

    cout<<dp[1]<<'\n';

}

 

P8625

对于一棵树,找到其点权和最大的一个连通分量

因为要最大,显然如果子节点的最大点权和为负就不去选择

其余情况就都计入答案

vector<vector<int>> adj(100005);

i64 dp[100005];

i64 val[100005];

void solve(){

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>val[i];

    }

    for(int i=1;i<=n-1;i++){

        int u,v;

        cin>>u>>v;

        adj[u].push_back(v);

        adj[v].push_back(u);    

    }

    i64 ans=0;

    auto dfs=[&](auto && self,int x,int fa)->void{

        dp[x]=val[x];

        for(auto y:adj[x]){

            if(y==fa) continue;

            self(self,y,x);

            dp[x]+=max(0ll,dp[y]);

        }

        ans=max(ans,dp[x]);

    };

    dfs(dfs,1,-1);

    cout<<ans<<'\n';

}

 

P9745

和25杭电春的切割木材类似,都是用dp去求解,但如果直接转移那么复杂度是不可行的,因此要考虑进行优化,并且优化都是根据贡献所需的值作为dp的状态

给定一棵n节点的树,第i个节点的点权为x[i],对于树上的n-1条边,每条边都可以选择删除或不删除,对于每种删除边的方案,都可以得到若干个连通块,定义这个方案的权值为图中连通块点权的异或和的乘积,求所有方案的权值之和

令根节点为1号节点,连通块的权值为连通块内点的异或和

树的情况较为困难,先看作是对链进行操作,把链看作是对序列计数,显然如果f[i]是前i个点所有断边方案的权值和,那么对于每个点枚举上一条断的边进行转移,那么f[i]=sigma(s[i]^s[j])*f[j],其中s[i]表示前缀异或和

一般在处理异或运算发现不好转移的时候考虑拆位,通过拆位来计算贡献(同时将每次要从每个序列中的点进行转移转化成对于数值有关联的状态相关值的转移,也就是答案的计算是和异或值相关的,这里直接将异或值作为状态),令g[i][j][k]表示所有方案中,和i相连的连通块的价值在二进制下第j位是k,同时和不与i相连的连通块的价值乘积的和

同时返回到树上的情况,实际上还是类似的,也可以通过断边来转移,若f[u]表示子树u的所有断边方案的权值和,为了转移,再令g[u][i][j]表示以u位根的子树里断开若干边,所有断边方案中,与u相连的连通块的价值在二进制下第i位是j的,不与u相连的连通块的价值乘积的和

因为要进行转移,所以要定义状态中的连通块是和i连通的

初始状态,若a[u]第i位是1,则g[u][i][1]=1,否则g[u][i][0]=1,转移时,避免后效性,先存储t[0]=g[u][i][0],则

g[u][i][0]=t[0]*(g[v][i][0]+f[v])+t[1]*g[v][i][1]

这样可以将转移的情况数限制在儿子节点中,而不是要遍历所有的子节点,同时转移的计算仍然是方便的

转移完成之后,f[u]就是2^i*g[u][i][1],也就是将各个二进制上的贡献统一起来

树形dp可以用链的部分类比得出正解,位运算考虑拆位,整体不能拆位的情况下,在计算过程中进行拆位

void solve() {  

    int n;

    cin>>n;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<vector<int>> adj(n,vector<int>());

    for(int i=1;i<n;i++){

        int x;

        cin>>x;

        x--;

        adj[x].emplace_back(i);

        adj[i].emplace_back(x);

    }

    vector<Z> dp(n);

    vector g(n,vector<vector<Z>>(64,vector<Z>(2)));

    auto dfs=[&](auto self, int u, int p)->void{

        for(int i=0;i<64;i++){

            g[u][i][a[u]>>i & 1]=1;

        }

        for(int v:adj[u]){

            if(v==p) continue;

            self(self,v,u);

            for(int i=0;i<64;i++){

                Z t0=g[u][i][0],t1=g[u][i][1];

                g[u][i][0]=t0*(g[v][i][0]+dp[v])+t1*g[v][i][1];

                g[u][i][1]=t0*g[v][i][1]+t1*(g[v][i][0]+dp[v]);

            }

        }

        for(int i=0;i<64;i++){

            dp[u]+=g[u][i][1]*(1ll<<i);

        }

    };

    dfs(dfs,0,-1);

    cout<<dp[0]<<'\n';

}

难点在于异或的处理,由于异或并不满足乘法分配律,因此不能用求和再相乘的方法dp,但二进制拆位后满足乘法分配律,所以可以按位计算贡献

于是定义dp[i][j][0/1]来表示对于i所在的连通块,它的异或值的第 j 位为 0 或 1,这个状态下除了点 i 所在的连通块以外的所有连通块的乘积。而 f i则表示对于 i 节点所在情况的所有连通块异或值的乘积和,即答案

对于父节点 u 和它的子节点 v,u 表示的连通块是对于 u 节点本身和在 v 之前的子树的处理情况。而 v 表示的连通块则是对于 v 节点本身这棵子树的连通块处理情况。考虑两个节点之间的关系

如果连边则表示要将 u 和 v 所在的连通块连起来。因此有:dp u,j,0 ←dp u,j,0 × dp v,j,0+dp u,j,1×dp v,j,1 (也就是异或是0的情况),对取值为1的情况类似

如果不连边,就将两个连通块的值相乘计算,又由于是以 u 为父节点,因此不包含 u 所在的连通块,但 v 子树内所有的连通块都不在 u 所在的连通块之中,因此用 f v表示所有连通块的价值进行计算,也就是dp u,j,0 = dp u,j,0 * f v

实际计算的时候将两种情况合并即可

 

CCPC2024女生

给定一个n个点的数,使用一些线段覆盖树的边,要求最小化线段长度的最大值

策略是显然的,对于一个节点,我们用较小深度的边去连接上方,让较大深度的边止步于此

因此有一种贪心的思路,对每个叶子按照深度进行排序后,深度小的有心啊覆盖所有没有被覆盖的祖先节点,但这样不是很好写,因此可以用树dp

按照上面的思路我们设定dp,这并不代表最终的答案,而是用于传递的数值,也就是在父节点考虑让谁向上传递时用dp值来进行比较,将最小的那个+1作为自己的权值

而真正的答案是ans,也就是计算最大值,包括被截断的部分,一般性的,可以直接对每个子节点加1的值取max

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int x;

        cin>>x;

        x--;

        adj[x].emplace_back(i);

    }

    vector<int> dp(n);

    int ans=-1;

    auto dfs=[&](auto self,int u)->void{

        int mx=-1;

        int mn=n+1;

        if(adj[u].size()==0){

            dp[u]=0;

            return;

        }

        for(auto v:adj[u]){

            self(self,v);

            mx=max(mx,dp[v]);

            mn=min(mn,dp[v]);

            ans=max(ans,dp[v]+1);

        }

        dp[u]=mn+1;

    };

    dfs(dfs,0);

    cout<<ans<<'\n';

}

 

P5007

找出树中所有内部元素不存在祖先后代关系的集合,定义一个集合的权值为集合中所有元素的权值和,再计算所有集合的权值

直接对子树进行计算比较困难,因此利用类似状态转移的方式遍历子节点并进行统计

令fi是i(当前)的子树内所有集合的价值之和,gi为i子树内的集合个数

利用乘法原理,显然有g[u]=g[u]*g[v]+g[u]+g[v]

带权值的可以类似得到f[u]=f[u]*g[v]+f[v]*g[u]+f[u]+f[v]

i64 f[1000005],g[1000005];

//f,计算权值的情况下;g,集合个数

void solve(){

    memset(head,0,sizeof(head));

    int n,T;

    cin>>n>>T;

    if(T) iota(val,val+n+1,0);

    else{

        for(int i=1;i<=n;i++){

            val[i]=1;

        }

    }

    for(int i=1;i<=n-1;i++){

        int u,v;

        cin>>u>>v;

        add_edge(u,v);

        add_edge(v,u);  

    }

    auto dfs=[&](auto &&self ,int x,int fa)->void{

        for(int i=head[x];i;i=e[i].nxt){

            int y=e[i].to;

            if(y==fa) continue;

            self(self,y,x);

            f[x]=(f[x]*g[y]+f[y]*g[x]+f[y]+f[x])%mod;

            //f[x]*g[y]:f[x]在与g[y]组合时为总的权值的贡献

            //+f[y]:不与f[x]组合,即只有y中的集合时

            g[x]=(g[x]*g[y]+g[x]+g[y])%mod;

        }

        //只有根节点的情况

        f[x]=(f[x]+val[x])%mod;

        g[x]++;

    };

    dfs(dfs,1,-1);

    cout<<f[1]<<'\n';

}

i64 f[1000005],g[1000005];

//f,计算权值的情况下;g,集合个数

void solve(){

    memset(head,0,sizeof(head));

    int n,T;

    cin>>n>>T;

    if(T) iota(val,val+n+1,0);

    else{

        for(int i=1;i<=n;i++){

            val[i]=1;

        }

    }

    for(int i=1;i<=n-1;i++){

        int u,v;

        cin>>u>>v;

        add_edge(u,v);

        add_edge(v,u);  

    }

    auto dfs=[&](auto &&self ,int x,int fa)->void{

        for(int i=head[x];i;i=e[i].nxt){

            int y=e[i].to;

            if(y==fa) continue;

            self(self,y,x);

            f[x]=(f[x]*g[y]+f[y]*g[x]+f[y]+f[x])%mod;

            //f[x]*g[y]:f[x]在与g[y]组合时为总的权值的贡献

            //+f[y]:不与f[x]组合,即只有y中的集合时

            g[x]=(g[x]*g[y]+g[x]+g[y])%mod;

        }

        //只有根节点的情况

        f[x]=(f[x]+val[x])%mod;

        g[x]++;

    };

    dfs(dfs,1,-1);

    cout<<f[1]<<'\n';

}

 

Cf 2050G
         给定一个n个节点的树,进行一次操作,选择其中的两个节点(可以相同),删除包括这两个节点在内的所有路径上的点,问最多可以分成多少个连通块

因为树的每个节点都只有一条边连向父节点,因此删除父节点后该节点及其子节点就构成了一个连通块,因此可以对被删除的节点进行计数,于是可以得到最后的连通块个数是sigma(degree-2)+2

选择的是树上的一条路径,于是转化成求出dgree-2所对应的直径,可以直接树形dp做,就是一般的求树直径的方法

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    vector<int> dp(n);

    int ans=1;

    auto dfs=[&](auto self,int u,int p)->void{

        int val=adj[u].size()-2;

        dp[u]=val;

        ans=max(ans,val+2);

        for(auto v:adj[u]){

            if(v==p){

                continue;

            }

            self(self,v,u);

            ans=max(ans,dp[u]+dp[v]+2);

            dp[u]=max(dp[u],dp[v]+val);

        }

    };

    dfs(dfs,0,-1);

    cout<<ans<<'\n';

}

 

25 杭电 春 1002

N个人淘汰赛,钦定某个人是赢家,除此之外其他人获胜率五五开,求赢家不碰上另外特定k个人的概率

类似于锦标赛排序,这样一个比赛淘汰的过程实际上可以建成树,树上的叶子代表水手,非叶子节点代表一次淘汰赛,节点的儿子代表这次淘汰赛的两个选手,同时,如果一轮淘汰赛中有人直接晋升,也可以多创建一个仅有单个儿子的节点代表这个直接晋升的人进行的淘汰赛

在这样的树结构上dp,设f[i]表示淘汰赛进行到节点i处剩下来的胜者是能对最终赢家造成威胁的人,若i的两个儿子是x和y,则转移方程是f[i]=(f[x]+f[y])/2,一个儿子的情况就是f[i]=f[x]

对于最终赢家,只需要模拟他的比赛过程,将他路径上的兄弟节点的胜者不会对他造成威胁的概率乘起来就是答案,如果这些有威胁的兄弟节点为x[1],x[2],…,x[k],则答案为(1-f[x[i]])的积

但如果完全模拟,那么节点个数可能会过多而不能接受,但有影响的点较少,同时树高为log n,因此可以考虑只对这些节点进行dp

具体实现也不需要真的建树,直接进行类似于模拟淘汰赛过程的写法即可

void solve() {

    int n,k;

    cin>>n>>k;

    int win;

    cin>>win;

    win--;

    vector<int> p(k);

    vector<i64> dp(k,1);

    for(int i=0;i<k;i++){

        cin>>p[i];

        p[i]--;

    }

    sort(p.begin(),p.end());

    i64 ans=1;

    while(!p.empty()){

        vector<int> np;

        vector<i64> ndp;

        win>>=1;

        for(int i=0;i<p.size();i++){

            if(win==(p[i]>>1)){

                // cout<<p[i]<<' '<<dp[i]<<'\n';

                ans*=(mod+1-dp[i])%mod;

                ans%=mod;

            }else{

                np.emplace_back(p[i]>>1);

                if(i+1<p.size() && ((p[i]>>1)==(p[i+1]>>1))){

                    ndp.emplace_back((dp[i]+dp[i+1])*inv2%mod);

                    i++;

                }else if((p[i]^1)<n){

                    ndp.emplace_back(dp[i]*inv2%mod);

                }else{

                    ndp.emplace_back(dp[i]);

                }

            }

        }

        p=np;

        dp=ndp;

        n=(n+1)>>1;

    }

    cout<<(ans+mod)%mod<<'\n';

}

 

注意这里有很多次计算Inv(2)的步骤,如果每次都调用函数会超时,因此提前存储inv(2)的数值

 

 

P3177

题目要求将k个点染成黑色,求黑点两两距离及白点两两距离,使两者的和最大

可以将距离转化为路径,进而将路径拆分为边

于是问题等价于统计同色点间的边权之和

考虑贡献法

每条边对答案的贡献就是它被经过的次数

显然,边两侧每有一对同色点,它就会被经过一次

于是被经过的次数tot=k*(m-k)(黑色)+(sz[v]-k)*(n-m-sz[v]+k)(白色)

m是黑色点的总个数,sz[v]是这条边所连接点的子树大小

于是dp[u][j]=max(dp[u][j],dp[u][j-k]+dp[v][k]+tot*e[i].w)

即枚举在当前子树之上染色的数量

因为在枚举染色数量的时候相当于01背包,即每种数量只能被计算1次,因此枚举j时应当倒序枚举避免被重复枚举

k=0之所以要提前转移,是因为先转移后合并比先合并再转移答案更大

k若逆序遍历,最后一重循环dp[x][j] = max(dp[y][0] + dp[x][j - 0] + val),但此时dp[x][j]在之前的遍历中可能已经不是初始的dp[x][j]了,也就是再加的dp[x][j]已经是下一个状态的了,同时这种情况下因为val一定是大于0的,因此相当于一定会选择dp[y][0] + dp[x][j - 0] + val这一项,所以答案会变大

同时边转移状态边计算size就不会T,但是事先把size计算好的话,遇到极端数据(比如一条链)就会超时

树形dp除了子节点与父节点之间的转移外,还有就是遍历过程中的从之前已经确定的最优情况与当前子节点之间的转移(类似于数组上的动态规划,只不过每个元素都是一个子树的根节点),这样使得最终遍历完所有子节点后,根节点所对应的最优情况也处理完毕了

正如上面P5007一样,我们在遍历过程中的dp[x][j]实际上指的是在当前已经遍历过的子树中染上了j个颜色为黑色的节点时的最优情况

因此如果dp[x][j](dp[x][j-k])仍然是-1,说明在当前的情况中还并没有这样的染色方案出现,自然不能进行转移

相当于已经遍历过的子树和根节点作为当前统计的边的一侧,该边所连接的点所对应的子树作为边的另一侧,然后计算这条边的贡献

i64 dp[2005][2005];

int sz[2005];

void solve(){

    int n,m;

    cin>>n>>m;

    m=min(m,n-m);//染哪种颜色都是等价的

    for(int i=1;i<=n-1;i++){

        int u,v,w;

        cin>>u>>v>>w;

        add_edge(u,v,w);

        add_edge(v,u,w);

    }

    memset(dp,-1,sizeof(dp));

    auto dfs=[&](auto &&self ,int x,int fa)->void{

        sz[x]=1;

        dp[x][0]=dp[x][1]=0;

        for(int i=head[x];i;i=e[i].nxt){

            int y=e[i].to;

            if(y==fa) continue;

            self(self,y,x);

            sz[x]+=sz[y];

            for(int j=min(m,sz[x]);j>=0;j--){

                if(dp[x][j]!=-1){//子树全是白色的情况

                    dp[x][j]+=dp[y][0]+(ll)sz[y]*(n-m-sz[y])*e[i].val;

                }

                for(int k=1;k<=min(j,sz[y]);k++){//正序逆序都可以

                    if(dp[x][j-k]==-1) continue;//还没有被处理过

                    i64 val=(ll)(k*(m-k)+(sz[y]-k)*(n-m-sz[y]+k))*e[i].val;

                    dp[x][j]=max(dp[x][j],dp[x][j-k]+dp[y][k]+val);

                }

                // for(int j=max(m,sz[x]);j>=0;j--){

                //     for(int k=max(j-sz[x]+sz[y],0);k<=min(j,sz[y]);k++){

                //         ...

                //     }

                // }

            }

        }

    };

    dfs(dfs,1,-1);

    cout<<dp[1][m]<<'\n';

}

 

cf1900C

问至少修改多少次才能走到叶节点

自底向上显然是不可取,因为无法构建与父节点之间的关系

我们可以将每次修改看作是移动的花费

于是可以从根节点开始移动,分别计算到左右孩子的花费

如果到达了叶子节点维护一下最小花费即可

int l[300005],r[300005];

int leaf[300005];

int dp[300005];

void solve(){

    memset(leaf,0,sizeof(leaf));

    int n;

    cin>>n;

    string s;

    cin>>s;

    s=' '+s;

    for(int i=1;i<=n;i++){

        int x,y;

        cin>>x>>y;

        if(!(x || y)) leaf[i]=1;

        l[i]=x,r[i]=y;

    }

    dp[1]=0;

    int ans=INT_MAX;

    auto dfs=[&](auto &&self ,int x)->void{

        if(s[x]=='L') dp[l[x]]=dp[x];

        else dp[l[x]]=dp[x]+1;

        if(s[x]=='R') dp[r[x]]=dp[x];

        else dp[r[x]]=dp[x]+1;

        if(l[x]) self(self,l[x]);

        if(r[x]) self(self,r[x]);

        if(leaf[x]) ans=min(ans,dp[x]);

    };

    dfs(dfs,1);

    cout<<ans<<'\n';

}

 

 

数位dp

经典的dp思路,只不过不是选或不选,而是选哪一个,有一点回溯+位压缩的感觉

并且对于只是用来判断的参数,可以不加在记忆化搜索中,来减少记忆化搜索的维度,只是放在dp函数传入的参数中

定义 f(i,mask,isLimit,isNum)表示构造第 i 位及其之后数位的合法方案数,其余参数的含义为(这些参数不一定都需要,根据题目要求去保留):

mask表示前面选过的数字集合,换句话说,第 i位要选的数字不能在 mask中(要求各位数字不重复时)

isLimit表示当前是否受到了 n 的约束(注意要构造的数字不能超过 n)。若为真,则第 i位填入的数字至多为s[i],否则可以是 9。如果在受到约束的情况下填了 s[i],那么后续填入的数字仍会受到 n 的约束。例如 n=123,那么 i=0填的是 1的话,i=1的这一位至多填 2。

isNum 表示 i前面的数位是否填了数字。若为假,则当前位可以跳过(不填数字)(如果用回溯,每次循环时要判断的参数以及需要写的情况较多,因此相对还是用dp比较方便),或者要填入的数字至少为1;若为真,则要填入的数字可以从 0开始。例如 n=123,在 i=0时跳过的话,相当于后面要构造的是一个 9以内的数字了,如果 i=1不跳过,那么相当于构造一个 10到 99的两位数,如果 i=1跳过,相当于构造的是一个 9以内的数字

递归入口:f(0, 0, true, false),表示:

从 s[0]开始枚举;

一开始集合中没有数字;

一开始要受到 n的约束(否则就可以随意填了,这肯定不行);

一开始没有填数字

递归终点:当 i 等于 s 长度时,如果 isNum为真,则表示得到了一个合法数字(因为不合法的不会继续递归下去),返回 1,否则返回 0

模板(T2376)

class Solution {

public:

    int countSpecialNumbers(int n) {

        auto s = to_string(n);

        int m = s.length(), memo[m][1 << 10];  //memo记忆化数组

        memset(memo, -1, sizeof(memo)); // -1 表示没有计算过

        function<int(int, int, bool, bool)> f = [&](int i, int mask, bool is_limit, bool is_num) -> int {

            if (i == m)

                return is_num; // is_num 为 true 表示得到了一个合法数字

            if (!is_limit && is_num && memo[i][mask] != -1)

                return memo[i][mask];

            int res = 0;

            if (!is_num) // 可以跳过当前数位

                res = f(i + 1, mask, false, false);

            int up = is_limit ? s[i] - '0' : 9; // 如果前面填的数字都和 n 的一样,那么这一位至多填数字 s[i](否则就超过 n 啦)

            for (int d = 1 - is_num; d <= up; ++d) // 枚举要填入的数字 d

                if ((mask >> d & 1) == 0) // d 不在 mask 中

                    res += f(i + 1, mask | (1 << d), is_limit && d == up, true);

            if (!is_limit && is_num)

                memo[i][mask] = res;

            return res;

        };

        return f(0, 0, true, false);

    }

};

或者是更为形象的按照名字:数位dp,即按照数位逐个进行推导(P1239),当从高位向低位推导比较困难时,就考虑dp的思路(或者说思考dp入口),从低位开始依次逐位转移

 

与数字有关的问题可以尝试利用数位dp去解决,例如特殊进制问题等

很多时候这些问题往往是通过拆分解决的,找不同数位上的规律

P1590

第一种可以使用进制转换的思路,pascal可以视作为一种九进制数,但也不能这么直接地进行抽象,因为如果当前的数字为8,按照单纯的九进制转十进制,仍就是8,但实际上的数值应该是7,相当于我们的进位点在于7,或者说pascal数中8实际表示的是7,9实际表示的是8,在这种条件下再进行9进制转化即可(这个方法是自己想出来的,诶嘿)

int main(){

    int t;

    cin>>t;

    while(t--){

        string s;

        cin>>s;

        int n=s.length();

        int ans=0;

        for(char m:s){

            if(m>'7') ans=ans*9+m-'0'-1;

            else ans=ans*9+m-'0';

        }

        cout<<ans<<endl;

    }

    return 0;

}

 

Cf 2086E

定义一个数是斑马数,当其二进制表示形如1010…1

定义一个数的斑马值是e,当其能被表示为e个斑马数的和

给定l,r,k,要求计算l<=x<=r中,斑马值为k的x的个数

先考虑观察斑马数有没有什么特征,每个斑马数都是在前一个数的基础上最高位+10,但这并不好计算,因为每次相当于要计算当前数的二进制位数再进行操作,同时也没有什么可以进一步使用的性质,因为我们都是从小到大计算的,因此考虑能不能从尾部推导

1,101,10101,从尾部看就是每次末尾加上01,即乘4再加1

因为是乘4+1,因此斑马数在四进制下每一位都是1

同时,最多只有30个<=1^18的斑马数,因为一个数可能有多个斑马值,不妨先计算最小的斑马值,那么x=sum{vi*ti},有max vi <=4因为如果vi大于4,那么可以换成t(i+1)

https://codeforces.com/contest/2086/submission/314873952

 

线性dp:在前缀后缀上进行转移,感觉核心就在于如何感受转移,(T828,有关子串的问题)定义f[i]]为考虑以s[i]为结尾的所有子串中的唯一字符个数,不失一般性考虑f[i]该如何转移:以s[i]为结尾的子串包括在所有以s[i−1]为结尾的子串结尾添加一个字符而来,以及s[i]字符本身组成的新子串,再以此左端点的改变判断对计数的影响(右端点固定在下标i处,原来左端点在没出现过的地方,原来左端点在出现过字符i的地方...)(初始都没出现过赋为-1)

最长子数组和(T53),状态f[i],代表包含下标i的子数组的最大子数组和,转移方程为f[i-1]+a[i],a[i],因为如果前面元素的和小于0,那么我们就抛弃前面元素,以该元素为开头新建一个子数组,如果大于0,说明与前面接轨能够增大我们的子数组的大小

vector<int> maxSubArray(vector<int>& nums) {

        int maxsum=INT_MIN;

        int dp_i = nums[0];

        vector<int> ans(2);//用来记录答案

        int begin = 0;

 

        for(int i=1 ; i < nums.size() ; i++ ){

            if( dp_i > 0 ){    //dp[i-1] > 0 时

                dp_i+=nums[i];

            }

            else{              //dp[i-1]<0时

                dp_i=nums[i];

                begin = i;     //当nums[i]自立门户时候,我们记录下子序列的起始位置

            }

            if(dp_i > maxsum){//更新答案

                maxsum = dp_i;

                ans[0] = begin;//记录下起始和终止位置

                ans[1] = i;

            }  

        }

        return ans;

    }

 

对这个问题进行拓展就是矩阵版本,要将二维转成一维,而不能直接对二维进行操作,因为要求是一个子矩阵,无法在在各行各列中找到最大的那一段进行拼接,为了保证操作的时候我们的长宽是对齐,我们可以选择将同一列的元素进行求和压缩,这样就转化到同一行上的求最大值了,就可以利用之前的方法了(最大子矩阵)

class Solution {

public:

    vector<int> getMaxMatrix(vector<vector<int>>& matrix) {

        vector<int> ans(4);//保存最大子矩阵的左上角和右下角的行列坐标

        int N = matrix.size();

        int M = matrix[0].size();

        vector<int> b(M,0);//记录当前i~j行组成大矩阵的每一列的和,将二维转化为一维

        int sum;//相当于dp[i],dp_i

        int maxsum=INT_MIN;//记录最大值

        int bestr1,bestc1;//暂时记录左上角,相当于begin

 

        for(int i=0;i<N;i++){     //以i为上边,从上而下扫描

            for(int t=0;t<M;t++ ) b[t]=0;    //每次更换子矩形上边,就要清空b,重新计算每列的和

            for(int j=i;j<N;j++){    //子矩阵的下边,从i到N-1,不断增加子矩阵的高

                //一下就相当于求一次最大子序列和

                sum = 0;//从头开始求dp

                for(int k=0;k<M;k++){

                    b[k]+=matrix[j][k];  

//我们只是不断增加其高,也就是下移矩阵下边,所有这个矩阵每列的和只需要加上新加的哪一行的元素

//因为我们求dp[i]的时候只需要dp[i-1]和nums[i],所有在我们不断更新b数组时就可以求出当前位置的dp_i

                    if(sum>0){

                        sum+=b[k];

                    }

                    else{

                        sum=b[k];

                        bestr1=i;//自立门户,暂时保存其左上角

                        bestc1=k;

                    }

                    if( sum > maxsum){

                        maxsum = sum;

                        ans[0]=bestr1;//更新答案

                        ans[1]=bestc1;

                        ans[2]=j;

                        ans[3]=k;

                    }

                }

            }

        }

        return ans;

    }

};

 

 

区间dp:从小区间转移到大区间  T516(注意从动规到递推转变过程中i,j的循环顺序)T1039,

牛客SCOI2003,状态的两个下标表示的是数组这段区间内的符合题目所述的最小值,

P1388,表示状态的两个维度是标定区间的左端点和右端点,区间dp可以用来解决不是有序地对序列上的点进行操作,而是可以任意对数组中的元素进行操作,且操作完之后可以继续对其他操作产生影响,如此处的添加+*号

for(int i=1;i<=n;i++)

                  f[i][i]=a[i];//初始化

         int j;

    //区间dp模板

         for(int l=1;l<=n;l++)//长度

                  for(int i=1;i+l-1<=n;i++)//左端点

                  {

                          j=i+l-1;//右端点

                          for(int k=i;k<j;k++)

                                   f[i][j]=max(f[i][j],cal(f[i][k],f[k+1][j],s[k]));

                //取原数或合并后的数的较大值,这一步有点像floyd

                  }

蓝桥杯 2023 更小的数

我们要判断反转后的数是否有变小,显然变化的只有进行翻转的区间,因此实际上就是在比较该区间顺序和逆序哪一个更大,对于这种比较大小的问题,通常有的一种思路是只要从高位到低位有一位小于了那么就奠定了两个数字的大小关系,而如果这个位上两个数相同,那么就继续向后比较,在这题中,向后比较就是区间dp的操作,因为我们从一个小区间转移到了一个更大的区间

具体来说,如果ai<aj那么dp[i][j]=0,如果ai>aj,那么dp[i][j]=1,如果ai=aj,那么dp[i][j]=dp[i+1][j-1]

在最后统计方案数的时候就是 ans+=dp[i][j]

for(int i=0;i<n;i++) dp[i][i]=0;

         for(int i=0;i<n-1;i++) dp[i][i+1]=(s[i]>s[i+1]);//初始值

         for(int len=3;len<=n;len++){

                  for(int i=0;i<n-len+1;i++){

                          int j=i+len-1;

                          if(s[i]==s[j]) dp[i][j]=dp[i+1][j-1];

                          else if(s[i]>s[j]) dp[i][j]=1;

                  }

         }

 

牛客24多校2 I

题意:给定一个长度为2*n的数组,1到n每个元素恰好出现两次

每次操作可以删除一个长度不小于2的连续子数组,需要满足该子数组首尾相同,可以获得连续子数组“首尾元素值” 乘以“元素数量”的分数

问可以得到的最大分数

设1,2...,n的位置分别是(l1,r1),(l2,r2)...

并且令f(i)是区间[li,ri]的答案

因为我们权值的计算方式,可以先假设[li,ri]中的每个数的贡献都是i

如果碰到一个更大的区间[lj,ri],完全包含了[li,ri],可以再用f(j)来替代[lj,rj]这个区间的贡献

令dp[k]=[li,k]的最大贡献

然后考虑状态转移,考虑当前遍历到的大区间的左端点进行操作

一般情况下dp[k]=dp[k-1]+i,当k=rj,同时满足lj>li时,即i的区间包含了j的区间,则dp[k]=max(dp[k-1]+i,dp[lj-1]+f(j))

在遍历到ri时,根据定义,就有f[i]=dp[ri]

一个技巧是在a两端补0,根据操作的定义可知这样改变数组并不影响数组的值,但在这样操作之后f(0)就是答案

void solve(){

    int n;

    cin>>n;

    n++;

    vector<int> a(2*n);

    vector<int> l(n,-1),r(n);

    for(int i=1;i<2*n-1;i++){

        cin>>a[i];

    }

    for(int i=0;i<2*n;i++){

        if(l[a[i]]==-1){

            l[a[i]]=i;

        }else{

            r[a[i]]=i;

        }

    }

    vector<int> f(n);

    for(int x=n-1;x>=0;x--){

        vector<int> dp(2*n+1);

        for(int i=l[x]+1;i<r[x];i++){

            dp[i+1]=max(dp[i+1],dp[i]+x);

            if(i==l[a[i]] && r[a[i]]<r[x]){

                dp[r[a[i]]+1]=max(dp[r[a[i]]+1],dp[i]+f[a[i]]);

            }

        }

        f[x]=2*x+dp[r[x]];//dp只计算了端点之内的数值

    }

    cout<<f[0]<<'\n';

}

 

 

序列dp考虑某一个元素是否被包含在最优解中,并由此思考状态转移方程 T618

         杭电 25 春4 1008

         要将序列分为k段,从左往右分别是1,2,…,k段(允许某些段为空)

         序列中的第i个元素被分到第j段会产生a[i][j]的分数,希望分段之后的分数之和最大

         序列型的,一般将当前位置设置到dp状态中,因为贡献又取决于分段的情况,因此将当前段落数也放到状态中

         于是dp[i][j]表示当前考虑了序列位置[1,i],且已经分了j段,每次转移:

         可以划分出一个空段,则dp[i][j+1]=dp[i][j]

         可以将下一位融入到当前段,对应dp[i+1][j]=dp[i][j]+a[i+1][j]

 

划分型dp:将一个长度为l的数组切割为k份,那么操作一次之后,其子问题可以看作是将长度为l-m长度的数组切割为k-1份,因此在设置dfs函数时要传入两个参数,且为了方便后续同步为递推的思路,比较适宜从后往前移动(即右端点的初值为n-1),每次传入现在的切割次数以及此时待切割数组的右端点,接下来就是要确定每次移动后右端点的更新(也就是每次划分时,待划分数组可供选择的左端点的范围,例如若还要划分i不等于0次,那么自然此次划分其左端点不能在下标为0处,确定了左端点,才有了我们状态转移思路中数组长度的值)T2911

 

概率dp

蓝桥杯 23 爬树的甲壳虫

题目给出了每一层调回树根和爬高的概率,而一层和一层之间显然是相关联的,因此考虑使用dp,有因为任何情况下甲壳虫都有可能调回树根个,因此正推比较难,考虑逆推

令fi为甲壳虫从i到n的期望时间,所以边界fn=0

因此有状态转移方程:fi=p[i+1]f[0]+(1-p[i+1])f[i+1]+1

而f0就是我们所需要的,因此可以考虑用类似解方程的方法来推出f0

通过不停地带入以及移项,最终可以得到类似于f0=s1fn+s2f0+s3的形式,计算出这三项即可

并且最后的答案是有理数取模的问题,因为模数是质数,所以使用费马小定理求解

const int N=1e5+5;

const int P=998244353;

int n,a[N],b[N];

int fp(int x,int y){ // 快速幂

    int res=1;

    for(;y;y>>=1){

        if(y&1) res=(1ll*res*x)%P;

        x=(1ll*x*x)%P;

    }

    return res;

}

int main(){

    cin>>n;

    for(int i=1;i<=n;i++) cin>>a[i]>>b[i];

    int s1=1,s2=0,s3=0;

    for(int i=1;i<=n;i++){

        int p1=(1ll*a[i]*fp(b[i],P-2))%P; //费马小定理求出概率

        int p2=(1ll*(b[i]-a[i])*fp(b[i],P-2))%P;

        s3=(s3+s1)%P; // 计算系数

        s2=(s2+1ll*s1*p1)%P;

        s1=(1ll*s1*p2)%P;

    }

    cout<<(1ll*s3*fp(1-s2+P,P-2))%P;

    return 0;

}

 

25 杭电 春 10 1004

给定一个排列,初始这个排列的第i个数等于i,然后这个排列会进行m次变换,每次变换会选定排列上的一个区间[l, r],然后随机打乱这个区间,也就是所有可能出现的情况出现概率相同

求经过m次打乱后,排列的期望逆序对数量

每次rand操作之后,区间内位置之间存在逆序对的概率是1/2,因为打乱后形成的排列是均匀随机的,因此对于区间中的任意两个位置,两种相对顺序是等概率出现的,于是这两个位置构成逆序对的概率是1/2

现在考虑区间外的数和区间内的数,同样选择一对数,且假设区间外的数位置是确定的,那么当区间内的数打乱后,其数值排序将会完全随机,从而对于区间内的每个位置,逆序的概率实际上是之前该位置逆序概率均摊的结果,因此区间外的位置对每一个区间内的位置存在逆序对的概率相等,为之前的概率和平均到每个位置上(区间中的每个位置都进行了这样的操作,因此就是区间外固定一个位置对区间内每个位置概率总和的平摊)

换言之,一次打乱之后,区间内所有位置的期望逆序贡献变成一个平均值,一方面是区间内任意一对位置都是1/2的逆序概率,另一方面是区间外的位置对区间内的原每个位置贡献都均摊了

因此,如果用f[i][j]表示i和j位置之间现在存在逆序对的概率,那么每次rand操作会使得f[i][j]=1/2(i,j在[l, r]中时),f[i][j]=sum_{k=l}^{r}f[i][k] / (r – l + 1) (i不在[l,r]中,j在[l, r]中)

最终的答案是每两个位置之间的逆序对的概率的和,因此直接dp维护即可

在计算中,dp[i][j]记录的是位置 i 与 j 之间的贡献(比如说,相当于标记 i 在 j 前面形成逆序的概率),而实际上对于一对位置 (i, j)(i ≠ j),逆序对只应算一次,而 dp 矩阵中既有 dp[i][j] 又有 dp[j][i] 两个记录。

更新操作中对于区间内的任意两个不同位置,直接赋值为1,相当于“贡献”设成了2倍于实际逆序概率(应该是1/2,但赋成1),而区间外和区间内的贡献也是“平摊”了之前的值。整体来看,每个逆序对的“贡献”在 dp 矩阵中被计算了4倍。

因此最后除4

void solve() {

    int n, m;

    cin >> n >> m;

    vector<vector<int>> dp(n, vector<int>(n));

    while (m--) {

        int l, r;

        cin >> l >> r;

        l--, r--;

        for (int i = l; i <= r; i++) {

            for (int j = l; j <= r; j++) {

                if (i != j) dp[i][j] = 1;

            }

        }

        for (int j = 0; j < n; j++) {

            if (j >= l && j <= r) continue;

            int now = 0;

            for (int k = l; k <= r; k++) {

                now = (now + dp[j][k]) % mod;

            }

            now = 1LL * now * qpow(r - l + 1, mod - 2, mod) % mod;

            for (int k = l; k <= r; k++) {

                dp[j][k] = dp[k][j] = now;

            }

        }

    }

    int sum = 0;

    for (int i = 0; i < n; i++) {

        for (int j = 0; j < n; j++) {

            if (i == j) continue;

            sum = (sum + dp[i][j]) % mod;

        }

    }

    cout << 1LL * sum * qpow(4, mod - 2, mod) % mod << '\n';

}

 

 

状压dp

利用二进制以及位运算来实现对于本来应该很大的数组的操作,这就是状态压缩,而使用状态压缩来保存状态的 DP 就叫做状态压缩 DP(T1349)

利用状态压缩来表示当前所有数据元素各自的情况

通常可以用于优化一些与排列有关的动态规划问题(T2741),针对每一位的选择,避免前面因选择顺序不同而导致的重复递归(即已选择的部分是全排列中的不同排列方式,这些情况下,这一位的数字选择实际上是同一种情况),状压dp实际上与集合有关 (排列型回溯,记忆化搜索以及集合转为位运算),该数字是否使用过可以利用位运算传入一个数字来代表可以使用数据的集合,每次操作后,在集合中删去这个数

状压,状态压缩,将前面几种选择的情况根据某种规则判断为同一种,从而减少重复搜索的次数,因此传入参数时,要将“规则”也一并传入,全排列只判断这个数字有没有用过,因此依次往后选择即可,而状压dp因为每次选择数字有一定的要求,因此在与全排列的相同的位置,可能全排列能选的数字这里不能选,或全排列理论上用过的数字这里仍然能用,因此为了避免重复的查找,将能用的数集合作为参数传入,只要这个集合相同,这个位的数字的选择就视为同一情况

(T2850,T1947)

全排列的匹配的问题,当需要排列的个数不多时,可以转化为状压dp,状态压缩就是将另一个去匹配的数据是否使用过作为二进制压缩,一方面依旧是遍历其中一个列表(相当于全排列中这个位置选择哪一个数字),同时传入压缩状态的关于另一个列表选择情况的列表,(递推实现时就是将压缩后的数字作为Dp状态),另一方面对一个数字遍历完后同步更新列表中的每个数据(因为又有一个数字选择过了)

        int m = students.size(), n = students[0].size();

        memset(dp, 0, sizeof dp);

        vector<int> options = {0};  //另一个数据被选择的情况

        for(int i = 0; i < m; i ++)   //遍历学生

        {

            vector<int> t;

            for(auto op : options)

                for(int j = 0; j < m; j ++)      //遍历每个bite位

                    if(((op >> j) & 1) != 1)  //如果没被选过

                    {

                        int val = 0;

                        for(int k = 0; k < n; k ++)

                            val += (students[i][k] == mentors[j][k]);  //计算所得权值

                        t.push_back(op + (1 << j));  //存储新的选择状态

                        if(dp[op + (1 << j)] < dp[op] + val)

                            dp[op + (1 << j)] = dp[op] + val;   

//确保dp为最大值,转移方程,以另一个数据被选择的情况作为下标

                    }

            options = t;    //更新

        }

        return dp[(1 << m) - 1]; (全集,表示每个bite位上都是1,每个数据都被选择过了)

(注意直接用全排列匹配时要用do.. while循环)

 

对于数据量较小的题目,并且一般要判断每个位置的数据是否存在,都可以考虑是否能用状态压缩,同时要求计算方案的最值,考虑使用状压dp

牛客24寒假4J

从一个已经被修改颜色的点画直线,使得没被染色的点被染色,这样任意时刻最多只有一种颜色(从多种颜色变成一种颜色一定也能遵循该规则使得最终情况相同,保证只有一种颜色对于问题的简化较有益,相当于01状态),而染色的过程就是一个增量的过程,即不断加入直线,加入直线的过程即是在已经被染色的点集中选取一点去引直线,两点能确定一条直线,而是否有其他点在这条直线上就可以用叉积去判断

根据题意,定义dp[i],在i的二进制表示下,第j位如果是0,说明还没有被染色,dp[i]是状态i所需要的最少直线数量

    int n;

    cin >> n;

    vector<int> x(n), y(n);

    for (int i = 0; i < n; i++) {

        cin >> x[i] >> y[i];

    }

   

    vector f(n, vector<int>(n));

    for (int i = 0; i < n; i++) {

        for (int j = 0; j < n; j++) {

            if (i == j) {

                continue;

            }

            for (int k = 0; k < n; k++) {

                if (1LL * (x[k] - x[i]) * (y[j] - y[i]) - 1LL * (y[k] - y[i]) * (x[j] - x[i]) == 0) {

                    f[i][j] |= 1 << k;//i,j确定的直线所覆盖的点

                }

            }

        }

    }

   

    vector<int> dp(1 << n, n);

    dp[1] = 0;

    for (int s = 1; s < (1 << n); s++) {//从状态开始枚举,因为这样能遍历所有情况,自然不会对最终的最优方案产生影响

        for (int i = 0; i < n; i++) {

            if (s >> i & 1) {//模拟在已经被染色的点中任意选取一个点再引一条直线

                for (int j = 0; j < n; j++) {

                    dp[s | f[i][j]] = min(dp[s | f[i][j]], dp[s] + 1);

                }

            }

        }

    }

    if (n == 1) {

        cout << 1 << "\n";

        return 0;

    }

    cout << dp[(1 << n) - 1] << "\n";

 

同样可以用来计算一个数组中选择某些数可以达到的最大值,用状态压缩来表示方案

int a[20];

int f[1<<20];

void solve(){

  int n,q;

  cin>>n>>q;

  for(int i=0;i<n;i++) cin>>a[i];

  for(int m=0;m<(1<<n);m++){

    int res=0;

    for(int i=0;i<n;i++) if(m>>i & 1) res^=a[i];//先计算按照状压所表示状态的值

    f[m]=res;

  }

  for(int i=0;i<n;i++){

    for(int m=0;m<(1<<n);m++){

      if(m>>i & 1) f[m]=max(f[m],f[m^(1<<i)]);//因为答案是异或得到的,因此判断能不能通过不选某个数据来优化最大值

    }

  }

  while(q--){

    int x=0;

    for(int i=0;i<n;i++){

      char ch;

      cin>>ch;

      x|=(ch=='1')<<i;

    }

    cout<<f[x]<<'\n';

  }

}

 

cf1955H

状压也可以用于表示方案

根据题目的数据范围先估计出我们可能可进行操作的R,发现数据量较小时,就想到用状压去描述这些情况

利用正方形去计算圆所覆盖到格子数,利用状压dp计算当前方案下的最大伤害值

//根据最优情况进行估计:令塔的伤害为最高值500,无论半径如何扩大一定被包含在敌人路过的路径中

//这样与敌人生命值增幅比较得到我们最多将半径增大到12

const int R = 12, INF = 2e9;

bool check(int x, int n) { return (0 <= x && x < n); }

void solve() {

    int n, m, k;

    cin >> n >> m >> k;

    vector<string> gr(n);

    for (int i = 0; i < n; ++i) {

        cin >> gr[i];

    }

    vector<pii> cord(k);

    vector<int> p(k);

    for (int i = 0; i < k; ++i) {//cord记录塔的位置,p记录塔的攻击力

        cin >> cord[i].first >> cord[i].second >> p[i];

        --cord[i].first;

        --cord[i].second;

    }

    vector<vector<int> > cover(k, vector<int>(R + 1));

    for (int i = 0; i < k; ++i) {

        int x = cord[i].first;

        int y = cord[i].second;

        for (int r = 1; r <= R; ++r) {

            //只需要遍历边长为 2⋅r、中心与塔的位置重合的正方形,就可以在计算出一个塔在当前半径下能覆盖的单元数

            for (int dx = -r; dx <= r; ++dx) {

                for (int dy = -r; dy <= r; ++dy) {

                    int nx = x + dx;

                    int ny = y + dy;

                    if (!check(nx, n) || !check(ny, m)) {

                        continue;

                    }

                    if ((x - nx) * (x - nx) + (y - ny) * (y - ny) <= r * r) {

                        cover[i][r] += (gr[nx][ny] == '#');//能覆盖到,并且这个点是敌人会经过的

                    }

                }

            }

        }

    }

    //因为每个r都只能作用于一座塔,并且r的范围较小,考虑使用状态压缩

    vector<vector<int> > dp(k + 1, vector<int>(1 << R, -INF));

    dp[0][0] = 0;

    //dp[i][mask]表示前i座塔采取了mask中半径分配时,所能输出的最大伤害

    for (int i = 1; i <= k; ++i) {

        for (int mask = 0; mask < (1 << R); ++mask) {

            dp[i][mask] = dp[i - 1][mask];

            for (int r = 1; r <= R; ++r) {

                int j = r - 1;

                if (!(mask & (1 << j))) {

                    continue;

                }

                dp[i][mask] = max(dp[i][mask], dp[i - 1][mask ^ (1 << j)] +p[i - 1] * cover[i - 1][r]);

            }

        }

    }

    int ans = 0;

    for (int mask = 0; mask < (1 << R); ++mask) {

        int hp = 0, mlt = 3;//hp表示采取了这些半径前提下敌人的生命值增幅

        for (int j = 0; j < R; ++j) {

            if (mask & (1 << j)) {

                hp += mlt;

            }

            mlt *= 3;//利用基数的方式进行计算

        }

        for (int i = 0; i <= k; ++i) {

            ans = max(ans, dp[i][mask] - hp);

            //最极限的情况:敌人的初始生命值+敌人的生命值增幅=我们的最大伤害

        }

    }

    cout << ans << '\n';

}

 

Cf 2051G

有n条蛇排列在一行上,每次操作会使得一条蛇变大或者变小,变大时,若原来处于[l,r]则会变成[l,r+1],变小时则会变成[l+1,r],问蛇所占据的最远的单元格的最小值是多少

要让排列最远的占据的最近,那么显然需要让我们排列的时候排的尽可能紧凑,因为可以任意安排顺序,所以应该是线处理出一条蛇与其他所有蛇的可能距离,在此基础上再安排排列

于是,首先要做的就是计算出mindis,我们用f维护,f[i][j]表示i在j前面时所需要的最小距离

具体的计算我们通过a,b来操作,如果蛇变大了,那么就是右边界扩宽了,那么a就相应的要增加,这样可以发现f[x][y]所对应的a[x]-b[y]也对应的增大了,相反,如果蛇减小了,那么左边界就向右移动了,那么同样,将b进行增大后,a[x]-b[y]的值也就相应的减小了,因为我们需要的是两者之间至少需要保持的距离,因此维护f[x][y]的最大值,又由于我们的顺序可以任意排列,于是还要同时处理f[y][x]

当知道了相邻蛇之间的最小距离后,就可以去找最佳顺序了,由于无需得到实际的方案,因此可以用状压dp来实现,定义dp[mask][lst],因为每个状态下我们只需要知道已经使用的蛇的集合和最后一条蛇的编号就可以进行转移了,转移的方法也十分显然,只要选择任意一条未被使用的蛇再将其放置在最小距离的位置上即可

根据dp状态的定义也可以知道,初始状态dp[1<<i][i]=1,表示当前只使用了i,同时最后一个被使用的是i,该情况下目前被占用的最远位置是1

constexpr int inf = 1e9+1;

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> a(n),b(n);

    vector f(n,vector<int>(n));

    for(int i=0;i<q;i++){

        int x;

        cin>>x;

        x--;

        char ch;

        cin>>ch;

        if(ch=='+'){

            a[x]++;

        }else{

            b[x]++;

        }

        for(int y=0;y<n;y++){

            f[x][y]=max(f[x][y],a[x]-b[y]);

            f[y][x]=max(f[y][x],a[y]-b[x]);

        }

    }

    vector dp(1<<n,vector<int>(n,inf));

    for(int x=0;x<n;x++){

        dp[1<<x][x]=1;

    }

    int ans=inf;

    for(int s=1;s<(1<<n);s++){

        for(int x=0;x<n;x++){

            if(dp[s][x]==inf){

                continue;

            }

            if(s==(1<<n)-1){

                ans=min(ans,dp[s][x]+a[x]);

            }

            for(int y=0;y<n;y++){

                if(~s >> y & 1){

                    int ns=s|1<<y;

                    dp[ns][y]=min(dp[ns][y],dp[s][x]+f[x][y]+1);

                }

            }

        }

    }

    cout<<ans<<'\n';

}

最后找最小值的过程实际上有点容易联想到最短路一类的,但因为题目中的n大小只有20(一般30以内的都可以)考虑使用状压来计算最小值

 

换根dp

在树中,计算一个与根节点选择有关的量(T2858,T834),尝试使用换根dp

从「以 0为根」换到「以 2为根」时,原来 2的子节点还是 2的子节点,原来 1的子节点还是1的子节点,唯一改变的是 0 和 2的父子关系。由此可见,一对节点的距离的「变化量」应该是很小的,那么找出「变化量」的规律,就可以基于ans[0]算ans[2]了。这种算法叫做换根 DP

首先依然是基础的建立图,然后遍历的时候依据题目要求计算深度或子树大小

如果是有向图,就在建图时增加一个参数表示谁指向谁

vector<vector<pair<int, int>>> g(n);

        for (auto &e: edges) {

            int x = e[0], y = e[1];

            g[x].emplace_back(y, 1);

            g[y].emplace_back(x, -1);

// -1表示从 y 到 x 需要反向(在计算总共要反向几次时就可以写作ans[0] += dir < 0)

        }

 

T2581 猜测哪个节点为正确的根节点时,正常情况下要遍历所有可能的点,并且每次对遍历到的点都要进行dfs,但如果使用换根dp,能注意到两个连边的点之间,它们之间的父子关系是不受其他点影响的,因此利用换根的思路可以继续使用上一次得到的数据,降低复杂度

 

树上dp

树上带权最大独立集

void DFS(int x,int fa){

    dp[x][1]=A[x];

    for(int i=0;i<G[x].size();i++){

        int t=G[x][i];

        if(t==fa)continue;

        DFS(t,x);

        dp[x][0]+=max(dp[t][0],dp[t][1]);

        dp[x][1]+=dp[t][0];

    }

}

 

2024上海市赛L

void solve(){

    int n,m,k;

    cin>>n>>m>>k;

    vector<vector<int>> adj(n);

    for(int i=0;i<n;i++){

        int k;

        cin>>k;

        for(int j=0;j<k;j++){

            int c;

            cin>>c;

            c--;

            adj[i].emplace_back(c);

        }

    }

    vector<int> vis(n);

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        vis[v]=1;

    }

    vector<int> ok(n);

    for(int i=0;i<k;i++){

        int x;

        cin>>x;

        x--;

        ok[x]=1;

    }

    vector<int> dp(n,n);

    auto dfs=[&](auto && self,int x)->void{//树形dp,自底向上进行转移

        if(adj[x].empty()){//题目保证所有目标节点都是叶子节点

            if(ok[x]){//从目标节点向上传递

                dp[x]=0;

            }

            return;

        }

        i64 sum=0;

        for(auto y:adj[x]){//一方面是对子节点的dp值进行计算

            self(self,y);

            sum+=dp[y];

        }

        dp[x]=min<i64>(dp[x],sum);//这一步使得原来只有一个子节点(移动也是确定)的情况得到计算

        for(auto y:adj[x]){

            if(vis[y]){

                dp[x]=min(dp[x],dp[y]+1);//确定性的移动,花费是子节点+1

            }

        }

    };

    dfs(dfs,0);

    int ans=dp[0];

    if(ans==n){//由树的结构知花费不可能大于n-1,因此这种情况说明无法到达

        ans=-1;

    }

    cout<<ans<<'\n';

}

 

         25 杭电 春 10 1007

         给定一个n个节点的树,每个节点都有一个非负权值,代表该点的能量,现在想要选择一个包含根节点的连通子树,使得子树中所有节点的权值之和不超过数值m,问有多少种不同的包含根节点的连通子图,满足子图中所有节点的权值之和不超过m

         不超过数值m,然后求方案数似乎有点像树上背包,例如从叶子节点向上进行转移,但是具体转移过程中似乎方案数的计算有些复杂

         首先考虑建出这棵树的dfs序,然后根据dfs序从小到大来将节点依次排开

         注意到一个节点u对应的子树在dfs序上是连续的,因此可以记录每个点对应的子树中的dfs序的最大值

         令dp[i][j]表示当前位于dfs序上的第i个节点,同时权值总和为j的方案数,且此时第i个节点还没有做出决策

         于是dp[1][0] = 1,也就是处于dfs序上第一个节点,并且权值为0

         对于每个状态dp[i][j],有两种策略(选或不选):

                  选择当前节点加入我们选取的点集,那么就转移到dp[i + 1][j + a[u]]

                  不选择当前节点,那么该节点对应的子树也都不能选择,因此转移到dp[mx + 1][j]

         因为我们是按照dfs序来操作的,因此最后只需要统计sigma_{i = 0}^{m} dp[n + 1][i]即可

         void solve() {

    int n, m;

    cin >> n >> m;

    vector<int> a(n);

    vector<vector<int>> adj(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

    for (int i = 1; i < n; i++) {

        int u, v;

        cin >> u >> v;

        u--, v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<int> dfn(n), idx(n), lst(n);

    int cnt = 0;

    auto dfs = [&](auto self, int u, int fa) -> void {

        dfn[u] = lst[u] = cnt;

        idx[cnt++] = u;

        for (int v : adj[u]) {

            if (v == fa) continue;

            self(self, v, u);

            lst[u] = max(lst[u], lst[v]);

        }

    };

    dfs(dfs, 0, -1);

 

    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));

    dp[0][0] = 1;

    for (int i = 0; i < n; i++) {

        for (int j = 0; j <= m; j++) {

            int cur = dp[i][j];

            // dp值为零,没有贡献

            if (!cur) continue;

            if (j + a[idx[i]] <= m) {

                dp[i + 1][j + a[idx[i]]] = (dp[i + 1][j + a[idx[i]]] + cur) % mod;

            }

            if (i > 0) {

                dp[lst[idx[i]] + 1][j] = (dp[lst[idx[i]] + 1][j] + cur) % mod;

            }

        }

    }

    i64 ans = 0;

    for (int j = 0; j <= m; j++) {

        ans = (ans + dp[n][j]) % mod;

    }

    cout << (ans + mod) % mod << '\n';

}

 

 

牛客24多校6 A

对于Grammy的对手,他需要划分为m块,但其中一些块可以是空,因此它会尽可能让自己得到多的分数,并且它的操作是不受之前限制的,也就是说,当前面树上路径确定下来后,它的策略是一定的

因此,在他的操作中,他会选择自己,也就是0的个数占比最多的一段进行均分,如果字符串第一个就是0,那么显然Grammy将不会得到任何蛋糕

于是在树上选择路径的时候,令cnt1/(cnt1+cnt2)作为判断标准,Oscar将让这个值尽可能大,而Grammy将让这个值尽可能小

在树上进行dfs操作并且有值需要维护的时候可以考虑用树上dp去维护每一个点的信息,这题是这样,之前上海24市赛也是,因为在全局进行操作的时候即使有回溯也不一定能够很好地处理到各个点的信息,因此应该用树上dp

这里定义dp值就是上面的比值,因为就像上面说的,当路径确定后就是Oscar开始操作,因此他的策略是一定的,于是dp值定义为当前路径上比值的最值,因为两人是轮流操作的,因此Grammy会在所有可能的方案(子树的情况中),选择最小的那个,而对方会选择最大的那个

void solve(){

    int n;

    cin>>n;

    vector<vector<pair<int,int>>> adj(n);

    for(int i=1;i<n;i++){

        int u,v,w;

        cin>>u>>v>>w;

        u--,v--;

        adj[u].emplace_back(v,w);

        adj[v].emplace_back(u,w);

    }

    vector<double> dp(n);

    //cnt1:0 cnt2:1

    auto dfs=[&](auto self,int x,int fa,int cnt1,int cnt2,int k)->double{

        dp[x]=max(dp[fa],1.0*cnt1/(cnt1+cnt2));

        if(adj[x].empty() || (adj[x].size()==1 && adj[x][0].first==fa)) return dp[x];

        double val=k;

        for(auto [y,w]:adj[x]){

            if(y==fa) continue;

            double res;

            if(w==0) res=self(self,y,x,cnt1+1,cnt2,k^1);

            else res=self(self,y,x,cnt1,cnt2+1,k^1);

            if(k) val=min(val,res);

            else val=max(val,res);

        }

        return val;

    };

    double ans=1-dfs(dfs,0,-1,0,0,1);

    cout<<fixed<<setprecision(12)<<ans<<'\n';

}

 

 

cf1988d

每次操作选择一个独立集,使得最终我们受到的伤害最小

显然可以在两轮内将所有的怪物消灭,但这不一定是最优解

例如在一条链上是100 1 1 100,显然应该是先将两个100删掉再依次删掉两个1

首先考虑dp
         dp第i个点在第j轮删掉的最小代价

因此j的取值范围比较重要(最多只会是度数+1)

只需要维护两个可能的值:最优和次优的

这个点的值要么取0,那么取其他点的最优或者次优+1

取值的范围是0~度数+1

d个点是坏的,Max那个点之后的一定没用了,因此只可能是0到d

因为又要次小值,因此d+1

      因为不能在同一轮中删除一条边连接的顶点,因此统计这个点的度数(子节点个数),这样能得到与其相关的删除的轮数            

      对于这个节点,计算它在这个子树中应该在第几轮被删除

      如果选择这个点在第i轮被删除,那么其子节点就不能在这一轮上删除

      因为选择的是独立集,因此子节点的选择只会影响到父节点而不会影响到祖先节点,同样父节点只会影响到子节点是否可以被选择

      因此对于子节点来说如果次优解存在,那么最优解和次优解的选择方案一定是不同的

      因为对于一个节点来说,只有选或不选两种可能,于是只需要对应记录最优和次优两种情况即可

void solve(){

    int n;

    cin>>n;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        --u,--v;

        adj[u].push_back(v);

        adj[v].push_back(u);  

    }

    vector<i64> f(n,inff),g(n,inff);//最优和次优

    vector<int> r(n);//记录最优情况所选择的轮数

    auto dfs=[&](auto &&self,int x,int fa)->void{

        int d=0;//度数

        for(auto y:adj[x]){

            if(y==fa){

                continue;

            }

            d++;

            self(self,y,x);

        }

        vector<i64> val(d+2);

        i64 sum=0;

        for(auto y:adj[x]){

            if(y==fa){

                continue;

            }

            sum+=f[y];

            //子节点度数为d,那么度为d+1,那么与其相关的选择轮数为d+2

            if(r[y]<d+2){

                val[r[y]]+=g[y]-f[y];

                //如果选择在这一轮删除该节点,那么会有若干节点没法选到最优情况

                //导致的结果的变化

            }

        }

        for(int i=0;i<d+2;i++){

            i64 res=sum+val[i]+(i+1)*a[x];

            //因为是先被打的,因此是(i+1)

            if(res<f[x]){

                g[x]=f[x];

                f[x]=res;

                r[x]=i;

            }else if(res<g[x]){

                g[x]=res;

            }

        }

    };

    dfs(dfs,0,-1);

    cout<<f[0]<<'\n';

}

 

杭电 25 春 6 1010

给定一棵无根树,令f(I,j)表示以i为根的情况下,一对节点的Lca为j的节点对数

要求不同点为根的情况下,f(i,1)的总和,f(I,2)的总和…

显然不能真的每次确定树根然后进行计算

同样考虑先固定树根,然后再考虑变换树根的影响

不妨令1为初始的树根,然后建树,那么与每个节点相关连的连通块为子树以及祖先节点,我们以这个点的贡献来进行计算,发现一种特殊情况是这个点就是树根,那么贡献是子树两两之间的大小乘积,其余情况,如果选点在某一个子树中,那么排除这一块的剩下几块作为子树

具体来说,此时再分为两种:点对中的其中一个点是当前顶点,那么贡献就是此时的子树大小总和;否则就是剩下子树的两两乘积;同时因为块中的任意点作为根时,这个点的贡献是相同的,因此计算完成后再乘上节点的大小即可

void solve() {

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    vector<int> siz(n,1);

    for(int i=0;i<n-1;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    vector<int> pa(n,-1);

    auto dfs=[&](auto self,int u,int p)->int{

        for(int v:adj[u]){

            if(v==p) continue;

            pa[v]=u;

            siz[u]+=self(self,v,u);

            // cout<<v<<'\n';

        }

        return siz[u];

    };

    dfs(dfs,0,-1);

    for(int i=0;i<n;i++){

        i64 ans=n;

        vector<int> b;

        for(int v:adj[i]){

            if(v==pa[i]){

                b.emplace_back(n-siz[i]);

                continue;

            }

            b.emplace_back(siz[v]);

        }

        // cout<<i<<':';

        // for(auto x:b){

        //     cout<<x<<' ';

        // }

        // cout<<'\n';

        i64 sum=accumulate(b.begin(),b.end(),0LL);

        i64 sum0=sum;

        i64 res=0;

        for(auto &x:b){

            sum-=x;

            res+=sum*x;

        }

        ans+=res;

        for(auto &x:b){

            ans+=x*(res-x*(sum0-x));

            ans+=x*(n-x);

            // cout<<x*(res-x*(sum0-x))<<' ';

        }

        // cout<<'\n';

        cout<<ans<<" \n"[i==n-1];

        // cout<<'\n';

    }

}

 

Cf 2062d

给定一棵树,第i个节点的可选初值范围是[l[i],r[i]],每次可以选择一个根节点u,再选择一个u为根时的子树,将子树中所有节点的点权+1,要求在这样操作后所有节点的点权相同,可以证明对于任何一棵树这样的情况总是可以实现的,问最小的点权是多少

因为任意选择根会导致树的结构发生变化,此时再考虑子树可能会比较复杂,所以考虑能否通过其他形式来表示这个操作

假设固定以1为根,然后观察操作和原来的区别,换成u为根后,与选择1为根时相比,u子树中的节点的操作不会发生变化,只有u的祖先上的操作会变化,令v是u的祖先节点,那么在u为根时v的子树全部加1,在当前情况下转变成了除了v的子树外的节点全部加1

为了使得操作具有统一性,我们将这个操作视作是v的子树减1, 同时全局节点权值加1,我们的目标是让树上的所有节点数值相同,因此全局加1并不会改变相对点权,也就是对操作没有影响,可以在最后计算答案的时候再考虑

计算答案考虑根节点的权值,也就是计算出来的权值和子树减1(全局加1)的次数之和,因为子树加1的操作并不会影响到根节点的权值

因此只要让每个节点的点权尽可能小即可,这种最小性可以用树上dp实现(因为树dp固定了根也就是树的结构,对于已经相等的子树可以通过类似于缩点的考虑变成一个节点,后续对这个节点的操作实际上是对于子树的整体操作,这是符合我们操作的定义的,也就是对于该子树确定完成后,后续的操作不会有后效性),用f[i]表示i子树中的节点权值相同,不考虑全局加的贡献,i的最凶啊权值,ans为子树减的次数,那么最后的答案就是f[1]+ans

同时,因为初始节点的权值是在一个范围之中,同时我们的目标是让最终的值尽可能小,因此一个贪心的想法是直接让a[i]=l[i],显然这样的操作对于叶子节点是没有问题的,但是当一个节点的l[i]小于它的子树时,为了使得权值相等,就需要让子树的节点都减1,也就是会产生全局加1的情况,但显然全局加不优于子树加,因此该情况下要考虑子树最值来选择

void solve(){

    int n;

    cin>>n;

    vector<i64> l(n),r(n);

    for(int i=0;i<n;i++){

        cin>>l[i]>>r[i];

    }

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--;

        v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<i64> f(n);

    i64 ans=0;

    auto dfs=[&](auto self,int x,int p)->void{

        i64 mx=0ll;

        for(int y:adj[x]){

            if(y==p) continue;

            self(self,y,x);

            mx=max(mx,f[y]);

        }

        if(r[x]>=mx){

            f[x]=max(l[x],mx);

        }else{

            for(int y:adj[x]){

                if(y==p) continue;

                ans+=max(0ll,f[y]-r[x]);

            }

            f[x]=r[x];

        }

    };

    dfs(dfs,0,-1);

    cout<<ans+f[0]<<'\n';

}

 

 

环形dp

P6064

关键在于两次dp:假设并不是一个环,那么就相当于在一个数组中进行dp,但问题在于现在是首尾相连的,因此相当于要强制将首和尾连接起来,这个改变可以体现在对于初值设定的改变,这样再进行一次dp,最终答案就是这两个dp得出来的结果中取最大值

 

插头dp

P5056

是一类基于连通性的状态压缩动态规划,是用状压DP处理联通问题,常见的类型有棋盘插头DP和CDQ论文里的两个非棋盘上的模型。

常见的联通问题:多回路问题、路径问题、简单回路问题、广义路径问题、生成树问题

因为这是基于连通性的状压dp,因此本质上还是状压dp

一般设dp[i][j][state]为(i,j)位置,状态为state的方案数

状压的是轮廓线

DP求解棋盘问题是逐格转移的。所以已经转移过的格子和没转移过的格子被一个折线分成了两部分,这个折线就是轮廓线

一个格子最多存在四个插头,一个存在的插头表示在它代表的方向上能与相邻的格子连通

引入插头的原因是,我们需要求一个回路,也就意味着最后所有的非障碍格子通过插头连接成了一个连通块,因此转移时需要记录格子的连通情况

在进行递推的时候,就是依据轮廓线上的插头存在性,求出所有能转移到的合法状态

而对于回路问题来说一个格子恰好只有两个插头,即右插头和左插头

状态的记录:
                  最小表示法:所有的障碍格子标记为0,第一个非障碍格子以及与它连通的所有格子标记为                1,然后再找第一个未标记的非障碍格子以及与它连通的格子标记为2,依次类推;例如连通信         息 ((1,2,5),(3,6),(4)) 表示为{1,1,2,3,1,2}

括号表示法:轮廓线上从左到右4个插头a,b,c,d,如果a,c连通,并且与b不连通,那么b,d一定不连通(这个性质对于所有的棋盘模型都适用);而“两两匹配”“不会交叉”的性质自然让人联想到括号匹配,将轮廓线上每一个连通分量中左边那个插头标记为左括号,右边那个插头标记为右括号,由于插头之间不会交叉,那么左括号一定可以与右括号一一对应,这样我们就可以使用 3进制, 0表示无插头, 1表示左括号插头, 2 表示右括号插头,记录下所有的轮廓线信息

状态转移:
                  因为逐行递推要讨论的情况还是比较难处理的,除非要求解的问题特别适合这样做,要不然我们一般使用逐格递推,这样可以方便讨论情况

一般来说轮廓线上很多状态都是无用的,并且一般来说插头dp的状态数很多,因此一般用一个线性数据(如vector)去存储状态,每次读取上一格所有合法状态扩展,而不是枚举状态去扩展

 

记忆化搜索

记忆化搜索和递归一样都是自顶向下的思路(从数组末端向前推进,这样也方便转成递推,可以从边界和入口看是自顶向下还是自底向上,但递归调用的时间复杂度较高,用动规转一维数组就会好很多),而动态规划则是自底向上的思路(从数组开始向后推进),但本质上是可以互写的,因为递归的边界就是动态规划dp数组的初始化,递归的向前推进就是动态规划的顺序遍历

记忆化因为一般状态个数已知并且n的大小也已经确定因此一般用数组去实现

记忆化搜索是一种通过记录已经遍历过的状态的信息,从而避免对同一状态重复遍历的搜索实现方式,用记忆化搜索的条件是,重复的遇到相同的递归参数

因为记忆化搜索确保了每个状态只访问一次,它也是一种常见的动态规划实现方式

可以避免因为重复的遍历导致超时

我们在朴素的 DFS 的基础上,增加一个数组 mem 来记录每个 dfs(pos,tleft) 的返回值。刚开始把 mem 中每个值都设成 -1(代表没求解过)。每次需要访问一个状态时,如果相应状态的值在 mem 中为 -1,则递归访问该状态。否则我们直接使用 mem 中已经存储过的值即可

在求解动态规划的问题时,记忆化搜索与递推的代码,在形式上是高度类似的。这是由于它们使用了相同的状态表示方式和类似的状态转移。也正因为如此,一般来说两种实现的时间复杂度是一样的(注意是递推而不是递归)

如何写记忆化搜索

方法一

把这道题的 dp 状态和方程写出来

根据它们写出 dfs 函数

添加记忆化数组

方法二

写出这道题的暴搜程序(最好是 dfs

将这个 dfs 改成「无需外部变量」的 dfs

添加记忆化数组

 

动规改递推:将dfs改为f数组,将递归改为循环

 

搜索:
bfs的队列中天然带有着按照操作步数排序的概念,因此对于一个形态经过若干次操作后变成另一个形态求最小步数的问题在保证复杂度正确的情况下可以使用bfs遍历去做(P1132),不过具体每个数值需要多少步数还是要维护一个数据去保存(例如维护一个结构体)

struct node{

    int a,ans;//a存储当前数字,ans存储当前执行的操作次数

};

bool flag[N]; //存的是该数字能否生成

int f[N];     //存的是生成该数字最小操作次数

queue<node> q;

 

要将dfs搜索得到的路径进行存储,可以使用栈或vector进行存储,关键就是在最后要逆序输出

int dfs(int x,int y){

    if(x==ex && y==ey){

        ans.push(make_pair(x,y));

        return 1;

    }

    for(int i=0;i<4;i++){

        int xx=x+dx[i],yy=y+dy[i];

        if(!vis[xx][yy] && !a[xx][yy]){

            vis[xx][yy]=1;

            if(dfs(xx,yy)){

                ans.push(make_pair(x,y));

                return 1;

            }

            vis[xx][yy]=0;

        }

    }

    return 0;

}

 

牛客24多校I

有一个n*m的矩形镜子迷宫,其中镜子有若干种,每种镜子右特定的光纤反射方向,其中直接通过镜子的情况不算被反射

有q个询问,每个询问会给定一个点光源表示在(x,y)位置向dir方向发射一束光线,问经过足够时间之后,这个光线被多少个不同的镜子反射过

因为光路可逆,因此不可能有光束分叉或者汇合的情况(因为只有一条入射光线,并且每个镜子只会造成一个入度以及出度,因此在光路中不可能有多束光存在),所以所有的光束会构成若干个环或者若干条链

因此可以将这些光环和光链抽出来,然后预处理出每个点光源的答案,之后对于询问就可以查表解决

对于环来说,因为会在这个路径中不断重复,因此环的长度就是镜子的个数,但是对于链来说因为是单向的,因此在链中的不同位置所得到的答案会有所不同,因此在dfs处理的时候要记录镜子是否被经过以及镜子的个数

void solve(){

    int n,m;

    cin>>n>>m;

    vector<string> s(n);

    for(int i=0;i<n;i++){

        cin>>s[i];

    }      

    auto V=[&](int x,int y){

        return x*(m+1)+y;

    };

    auto H=[&](int x,int y){

        return n*(m+1)+x*m+y;

    };

 

    const int N=n*(m+1)+(n+1)*m;

    vector<array<pair<int,int>,2>> adj(N,{make_pair(-1,-1),make_pair(-1,-1)});

    vector<array<int,2>> tot(N);

    auto addedge=[&](int u,int v,int du,int dv,int w){

        adj[u][du]={v*2+dv,w};

        adj[v][dv]={u*2+du,w};

    };

    //根据反射的情况连边建图

    //表示在这个点遇到镜子后,可能移动的方向,V表示横向,H表示纵向

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            int mir=i*m+j;//mir标记镜子的位置

            if(s[i][j]=='-'){

                addedge(V(i, j), V(i, j + 1), 1, 0, -1);//横向穿过

                addedge(H(i, j), H(i, j), 1, 1, mir);

                addedge(H(i + 1, j), H(i + 1, j), 0, 0, mir);

            }else if(s[i][j]=='|'){

                addedge(V(i, j), V(i, j), 1, 1, mir);

                addedge(V(i, j + 1), V(i, j + 1), 0, 0, mir);

                addedge(H(i, j), H(i + 1, j), 1, 0, -1);//竖着穿过

            }else if(s[i][j]=='/'){

                addedge(V(i, j), H(i, j), 1, 1, mir);

                addedge(V(i, j + 1), H(i + 1, j), 0, 0, mir);

            }else if(s[i][j]=='\\'){

                addedge(V(i, j), H(i + 1, j), 1, 0, mir);

                addedge(V(i, j + 1), H(i, j), 0, 1, mir);

            }

        }

    }

    vector<bool> vis(N);

    vector<int> tm(n*m,-1);

    for(int x=0;x<N;x++){

        if(adj[x][0].first==-1 || adj[x][1].first==-1){

            int a=x;

            int d=(adj[a][1].first==-1);

            int sum=0;

            while(a!=-1){

                vis[a]=true;

                tot[a][d]=sum;

                auto [b,w]=adj[a][d^1];

                if(w!=-1){

                    sum+=(tm[w]!=x);

                    tm[w]=x;

                }

                if(b==-1){

                    a=-1;

                }else{

                    d=b%2;

                    a=b/2;

                }

            }

        }

    }

    for(int x=0;x<N;x++){

        if(vis[x]){

            continue;

        }

        int a=x;

        int d=0;

        int sum=0;

        do{

            vis[a]=true;

            auto [b,w]=adj[a][d^1];

            if(w!=-1){

                sum+=(tm[w]!=x);

                tm[w]=x;

            }

            d=b%2;

            a=b/2;

        }while(a!=x || d!=0);

        do{

            tot[a][0]=tot[a][1]=sum;

            auto [b,w]=adj[a][d^1];

            d=b%2;

            a=b/2;

        }while(a!=x || d!=0);

    }

    int q;

    cin>>q;

    while(q--){

        int x,y;

        string dir;

        cin>>x>>y>>dir;

        int d;

        x--;y--;

        int a;

        if(dir=="above"){

            a=H(x,y);

            d=0;

        }else if(dir=="below"){

            a=H(x+1,y);

            d=1;

        }else if(dir=="left"){

            a=V(x,y);

            d=0;

        }else{

            a=V(x,y+1);

            d=1;

        }

        cout<<tot[a][d]<<'\n';

    }

}

 

 

折半搜索

P4799

折半搜索,又称为meet-in-the-middle。其做法为将整个搜索的过程分为两部分,然后每部分分别进行搜索,最后将得到两个答案序列,再将答案序列进行合并,即可得到最终的答案。

我们知道,搜索的时间复杂度往往是指数级别的。

比如,在每一层搜索时,假如都有两种选择,那么其时间复杂度为 (2n) 。当 n较大时,往往会导致超时。此时,如果使用折半搜索,其时间复杂度将缩小为 合并操作的时间复杂度(2n/2+合并操作的时间复杂度) 

若忽略数据范围,这题实际上就是一道裸的0/1背包题。但很遗憾,由于数据范围过大,我们不可能开出一个 1018 大小的数组来进行DP。所以这题应当要使用搜索算法。

但是仔细一算我们就能发现,每一场比赛都存在看与不看两种选择,比赛的场次最大可能达到40场,则时间复杂度 (2n) 的裸搜索算法必然会导致超时。

这时,折半搜索就派上用场了。

我们可以将所有比赛分成两部分,对这两部分分别进行搜索,然后使用vector来储存搜索过程中产生的可能的花费。最后再将两部分进行合并,得出最终的答案。

最终的时间复杂度为 (2n/2+1+n/2×log(n/2)) ,前者为分别搜索的时间复杂度,后者为合并的时间复杂度。

合并时,我们可以先将一部分进行排列使其有序,然后枚举另一部分中的状态,因为我们实现进行了排序操作,因此可以每次进行二分搜索查找可行的答案,然后叠加可行方案数

int n;

long long m;

long long moy[45];

vector <long long>a,b;

void dfs(int st,int en,long long sum,vector<long long>&now)

{

    if(sum>m) return;//如果当前花费超过拥有的钱数,则返回

    if(st>en)//起点超过终点说明该部分已经全部搜索完毕

    {

        now.push_back(sum);//则将可行的花费塞入vector中

        return;

    }

    dfs(st+1,en,sum+moy[st],now);//选择买这场比赛的门票

    dfs(st+1,en,sum,now);//选择不买这场比赛的门票

}

int main()

{

    ios::sync_with_stdio(false);

    cin>>n>>m;

    for(int i=1;i<=n;i++)

        cin>>moy[i];//输入每场比赛门票价格

    dfs(1,n/2,0,a);//搜索第一部分

    dfs(n/2+1,n,0,b);//搜索第二部分

    sort(a.begin(),a.end());//将第一部分排序,使其有序

    long long ans=0;

    int lenb=b.size();

    for(int i=0;i<lenb;i++)//遍历第二部分

        ans+=upper_bound(a.begin(),a.end(),m-b[i])-a.begin();

    //每次寻找花费比剩下的钱还要少的方案数,注意这里要使用upper_bound

    //若使用lower_bound,则出现等于的情况时,方案数会有错误

    cout<<ans<<endl;

    return 0;

}

 

蓝桥 23 买瓜

对于每一个元素有三种状态:不买,砍一刀买一半,不砍,买整个

为了防止浮点数的精度问题,可以将我们的目标值乘2,这样买一半瓜的时候直接加上ai即可,而买整个瓜的时候就加上ai乘2

直接用数组去存储对应重量的m需要操作几次可能会爆内存,因此用速度较快的unordered_map去存储

由上述的分析易得到朴素的dfs写法,即每次dfs的时候依次枚举可能的情况

void LHQ(int G,int P,int J,long long sum){

    if(sum>m) return;

    if(G>n){

        //cout<<sum<<endl;

        if(sum==m) ans=min(ans,J);

        return;

    }

    if(P==0) sum+=a[G]*2;

    else if(P==1) sum+=a[G],J++;

    for(int i=0;i<3;i++) LHQ(G+1,i,J,sum);

}

对于每个元素都有三种可能,因此这个算法时间复杂度为O(3n),显然是比较难过的

因此想到使用折半搜索去优化,这样时间复杂度就是O(3n/2)

这样可能仍然会T,因此继续优化,具体来说:利用排序来优化搜索顺序,并且利用剪枝,例如当前重量大于m就不继续搜索了,或者目前的操作次数大于当前的最优解也不继续操作,以及利用位运算来优化乘除运算

int n,m,ans,N,a[35];

unordered_map<int,int> q;

inline void dfs1(int cnt,int sum,int num){

    //  cnt表示第几个瓜,sum表示当前买的瓜的总重量,num表示砍了多少刀

    if(sum > m || num > ans) return ;

    //  如果当前重量大于m,不用搜了,直接返回

    //  如果当前砍瓜次数已经大于目前最优值ans,不用搜了,直接返回

    if(sum == m) { // 如果当前重量刚好等于m

        ans = ans < num ? ans : num; // 更新ans

        return ;

    }

    if(cnt == N + 1) {//这是折半搜索的前半部分

        if(sum <= m) {

            if(q.count(sum)) // unordered_map的操作:查找sum在unordered_map中是否出现

                q[sum] = q[sum] < num ? q[sum] : num;

            else

                q[sum] = num;

            // 当前重量为sum时最少要砍多少刀

        }

        return;

    }

    dfs1(cnt + 1, sum, num); // 不买这个瓜

    dfs1(cnt + 1, sum + a[cnt], num + 1); // 买一半瓜

    dfs1(cnt + 1, sum + (a[cnt] << 1), num); // 买整个瓜

}

inline void dfs2(int cnt,int sum,int num){

    if(sum>m || num>ans) return ;

    if(sum==m){

        ans=ans<num?ans:num;

        return;

    }

    if(cnt==n+1){

        if(q.count(m-sum) && sum<=m){//折半搜索,但肯定不是相互之间完全独立,要利用执之前更新过的map

            ans=(ans<num+q[m-sum])?ans:num+q[m-sum];

        }

        return;

    }

    dfs2(cnt+1,sum,num);

    dfs2(cnt+1,sum+a[cnt],num+1);

    dfs2(cnt+1,sum+(a[cnt]<<1),num);

}

void solve() {

    n=read(),m=read();

    for(int i=1;i<=n;i++) a[i]=read();

    m=(m<<1);

    N=(n>>1);

    sort(a+1,a+n+1);

    ans=inf;

    dfs1(1,0,0);

    dfs2(N+1,0,0);

    cout<<(ans==inf?-1:ans);

}

 

Cf 2053d

给出两个数组,计算两个数组中的较小值的乘积,可以任意排列这两个数组,问改乘积的最大值是多少

显然将两个数组都排序后计算得到的结果是最优的

但这样计算一次的复杂度是排序的复杂度再加上遍历一次的复杂度,因此要考虑修改后如何快速得到新的结果

注意到每次增加只会+1,也就是数值是连续变化的,因此对于之前排完序的数组,修改后的数只会发生数值上的变化而不会发生位置的移动

因此当我们定位到发生修改后的元素后,就可以O(1)地进行计算新的结果了,因为是在模意义下的乘法,因此除以之前的较小值时需要计算逆

对于数组元素的修改,仍然是先还原这个位置的贡献,再计算新的贡献

i64 fp(i64 x,i64 y,int mod=998244353){

    i64 res=1;

    for(;y;y>>=1){

        if(y&1) res=(1ll*res*x)%mod;

        x=(1ll*x*x)%mod;

    }

    return res;

}

 

//求a的模意义下的乘法逆元

i64 inv(i64 a,int p){

    if(a%p==0) return -1;

    else return fp(a,p-2,p);

}

 

void solve(){

    int n,q;

    cin>>n>>q;

    vector<i64> a(n);

    vector<i64> b(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    for(int i=0;i<n;i++){

        cin>>b[i];

    }

    vector<i64> s_a(n),s_b(n);

    s_a=a;

    s_b=b;

    sort(s_a.begin(),s_a.end());

    sort(s_b.begin(),s_b.end());

    i64 ans=1ll;

    for(int i=0;i<n;i++){

        ans*=min(s_a[i],s_b[i]);

        ans%=mod;

    }

    cout<<ans<<" ";

    while(q--){

        int o,x;

        cin>>o>>x;

        x--;

        if(o==2){

            i64 k=b[x];

            int it=lower_bound(s_b.begin(),s_b.end(),k+1)-s_b.begin()-1;

            i64 tmp=min(s_a[it],k);

            tmp=inv(tmp,mod);

            ans=ans*tmp%mod;

            b[x]++;

            s_b[it]++;

            ans=ans*min(s_a[it],s_b[it])%mod;

        }else if(o==1){

            i64 k=a[x];

            int it=lower_bound(s_a.begin(),s_a.end(),k+1)-s_a.begin()-1;

            i64 tmp=min(s_b[it],k);

            tmp=inv(tmp,mod);

            ans=ans*tmp%mod;

            a[x]++;

            s_a[it]++;

            ans=ans*min(s_a[it],s_b[it])%mod;

        }

        cout<<ans<<" \n"[q==0];

    }

}

 

 

多种搜索方法

P1379

因为每次移动只能移动空格周围的棋子,并且,当周围的棋子移动到了空格的位置时,原先棋子的位置就变成了空格,因此不妨看作是空格在移动

经典bfs

bfs关键就在于一个搜索队列

我们可以用bfs,在队列中存储每一步的状态,并将这一个状态取hash值,如果当某一个状态的hash值已经等于了目标状态的hash值,那么直接输出它的步数即可。(因为广搜有一个第一个搜到的目标状态必定是最优的特性),因为我们实际看作空格在移动,因此每个状态下我们需要记录下空格的位置(这里哈希可以用康拓展开,也可以直接用map解决,康拓展开的速度快一点)

广度优先搜索不仅仅用于树上的遍历,也可以用于矩阵上的路径探索(这种做法其实之前是用过的),核心仍然是在于将每次移动过后的情况加入一个队列中tail++(在树中实际上也是这样的,相当于规定可以移动的方式是向子树移动,因此每次移动将子节点加入队列中)(此处因为可能多次移动后最终会变成同一种情况,因此用hash来排重),对于一个节点可能移动的方式都操作完之后,将head指针后移,就样就能完成所有路径情况的遍历

typedef unsigned long long ull;

ull e;

int vis[3000000]{}; //用作哈希,同时顺带用于存储步数

int dx[]={0,1,0,-1},dy[]={1,0,-1,0};

struct node

{

    int x,y;  //x,y表示0在a数组中的下标

    ull hash1;    //康托展开的哈希值

    int a[4][4];  //状态数组

} que[2000001];

ull hash1(string s)//康托展开

{

    int f[9]={0,1,2,6,24,120,720,5040,40320}; //先将每一个阶乘的值存下来,方便直接运算

    int used[9]={0}; //标记数组

    ull ans=0,x=8;

    for(int i=0;i<s.length();i++)

    {

        int num=0;   //num存储小于第i个数的数的数目

        used[s[i]-'0']=1;

        for(int j=0;j<s[i]-'0';j++) //遍历s[i]之前的数,如果这个数没出现过说明在这个数位之后

            if(!used[j])  num++;

        ans+=num*f[x--];  //康托展开的公式

    }

    return ans;

}

int main()

{

    int head=1,tail=2;

    int i,j,k,n;

    string s;

    cin>>s;

    e=hash1("123804765");  //首先将目标状态的值存下来

    que[head].hash1=hash1(s);  

    vis[que[head].hash1]=1;  //用map的差别就在于此,map是用字符串作为key

    for(i=1;i<=3;i++)

        for(j=1;j<=3;j++)

        {

            que[head].a[i][j]=s[(i-1)*3+j-1]-'0';

            if(que[head].a[i][j]==0)

            {

                que[head].x=i;

                que[head].y=j;

            }

        }//将初始状态存进二维数组,并记录下0的位置

    while(head<=tail) //bfs模板,这里用数组来模拟队列

    {

        if(que[head].hash1==e) //如果当前状态的值等于目标状态的值,那么就输出步数

        {

            cout<<vis[e]-1; //因为初始状态赋为了1

            return 0;

        }

        for(k=0;k<4;k++) //否则,枚举四个方向

        {

            string s="123456789";  //用来转换的字符串

            int tx=que[head].x+dx[k],ty=que[head].y+dy[k];//0移动后的位置

            if(tx<1 || tx>3 || ty<1 || ty>3) continue; //判断边界

            for(i=1;i<=3;i++)

                for(j=1;j<=3;j++)

                    que[tail].a[i][j]=que[head].a[i][j];

            swap(que[tail].a[tx][ty],que[tail].a[que[head].x][que[head].y]); //0的移动相当于交换位置

            for(i=1;i<=3;i++)

                for(j=1;j<=3;j++)

                    s[(i-1)*3+j-1]=que[tail].a[i][j]+'0';

            ull ans=hash1(s);  

            if(!vis[ans]) //如果这个状态没有出现过,加入队列,因为满足相同情况先加入的步数一定小于等于

            {

                vis[ans]=vis[que[head].hash1]+1;

                que[tail].hash1=ans;  

                que[tail].x=tx; que[tail].y=ty;  

                tail++;

            }

        }

        head++; //当前位置操作完成,后移

    }

    return 0;

}

 

双向bfs

双向BFS的使用要求之一就是知道终止状态,因此已知目标和初始状态的题目就可以使用双向广搜

一种方法:利用map数组

这里可以将判重数组的值设为0,1,2,分别代表未访问过,顺序访问过,逆序访问过,当某个状态被顺序逆序都访问过时,那么这就是连接答案的那个状态

实际实现时,并不是直接两次bfs(即两个queue或deque),而是将起点和终点同时入队,即一个queue就能完成,只是在排重数组中用不同的值去标记,从而相当于在同时从前从后bfs遍历

ll s,e=123804765;   //这里直接用ll当作状态了

int a[4][4],fx,fy,nx,ny;

int dx[4]={1,-1,0,0};

int dy[4]={0,0,1,-1}; //方向数组

queue<ll> q; //bfs特征,队列

map<ll,int> vis;  //标记数组

map<ll,int> ans;  //记录答案

void solve()

{

    if(s==e)         //特判起点和终点相同

    {

        cout<<0;

        return;

    }              

    q.push(s);      //起始状态与终止状态同时入队,双向队列的核心

    q.push(e);

    ans[s]=0;

    ans[e]=1;       //到终点是需要一步的,因为是逆推,而起始状态时不需要步数的

    vis[s]=1;           //将两个方向标记成不同的数字

    vis[e]=2;

    while(!q.empty())

    {

        ll now,cur=q.front();

        q.pop();

        now=cur;

        for(int i=3;i>=1;i--)

            for(int j=3;j>=1;j--)

            {

                a[i][j]=now%10,now/=10;   //状态转矩阵

                if(a[i][j]==0) fx=i,fy=j;

            }

        for(int i=0;i<4;i++)

        {

            nx=fx+dx[i];  //移动

            ny=fy+dy[i];

            if(nx<1 || nx>3 || ny<1 || ny>3) continue;

            swap(a[fx][fy],a[nx][ny]);

            now=0;

            for(int p=1;p<=3;p++)

                for(int j=1;j<=3;j++)

                    now=now*10+a[p][j]; //矩阵转状态

            if(vis[now]==vis[cur]) //如果是相同方向已经到达过的,那么就跳过

            {

                swap(a[fx][fy],a[nx][ny]); //一定要先换回来(回溯)再跳过

                continue;

            }

            if(vis[now]+vis[cur]==3)        //说明新延伸出的点已被另一方向访问过

            {

                cout<<ans[cur]+ans[now]; 

//两方向步数和即为总步数,注意此时ans[now]并没有更新,因此才对应上一个方向的步数

                return;

            }

            ans[now]=ans[cur]+1;

            vis[now]=vis[cur];              //与上一状态的方向保持一致,即是从哪个方向上遍历过来的

            q.push(now);

            swap(a[fx][fy],a[nx][ny]); //重置

        }  

    }

}

int main()

{

    cin>>s;

    solve();

    return 0;

}

 

 

第二种方法:哈希链表

struct node{

    ll key;

    int step,head;

    node *next;

    node(){key=step=head=0,next=NULL;}

}ha[mod+1];

bool query(ll &n,int head,int step){

    int hash1=n%mod;

    if(!ha[hash1].key){

        ha[hash1].key=n;

        ha[hash1].head=head;

        ha[hash1].step=step;

        return true;

    }

    if(ha[hash1].key==n){

        if(head!=ha[hash1].head){

            flag=true;

            ans=step+ha[hash1].step;

        }

        return false;

    }

    else{

        node *now=&ha[hash1];

        while(now->next){

            now=now->next;

            if(now->key==n){

                if(head!=now->head){

                    flag=true;

                    ans=now->step+step;

                }

                return false;

            }

        }

        node *newnode=new node;

        newnode->key=n;

        newnode->head=head;

        now->next=newnode;

        return true;

    }

}

 

A*:

A*其实是优先队列,BFS再加上一个估价函数

inline ll read() {

    ll f = 1, x = 0;char ch;

    do {ch = getchar();if (ch == '-')f = -1;} while (ch > '9' || ch < '0');

    do {x = x * 10 + ch - '0';ch = getchar();} while (ch >= '0' && ch <= '9');

    return f * x;

}

 

int x, y, now;

string s;

string goal = "123804765";

//估价函数h:当前状态还有多少个位置与目标状态不对应

int h(string cur){

    int res = 0;

    for (int i = 0; i < 9; i ++ ){

        if (goal[i] != cur[i] && goal[i] != 0) res++;

    }

    return res;

}

struct node{  

    int f, step;  //step 是当前步数,f是每个点的总估价

    string now;  //即代表当前的状态

    bool operator < (const node &x) const {  //根据我们的估价来进行优先排序,所以说有点像dijkstra

        return f > x.f;        

    }  

};

int ans = 0x7fffffff;

priority_queue <node> q;

map <string, bool> mp; //排重

map <string, int> dis; //存储距离

int dx[4] = {0, 1, -1, 0};

int dy[4] = {1, 0, 0, -1};

void A_STAR(){

    while (!q.empty())

    {

        node t = q.top(); q.pop();

        string cur = t.now;

        if (cur == "123804765") {  //与终点相同

            cout<<t.step;

            return;

        }

        int sx, sy;

        for(int i = 0; i < 9; i ++ ){

            if(cur[i] - '0' == 0) sx = i / 3 + 1, sy = i % 3 + 1;  //找到0的位置

        }

        int tmp1 = (sx - 1) * 3 + sy - 1;  //矩阵上的坐标转到字符串上的坐标

        for (int i = 0; i < 4 ; i ++ ){

            int xx = dx[i] + sx, yy = dy[i] + sy;          

            if (xx < 1 || xx > 3 || yy < 1 || yy > 3) continue; //越界

            int tmp2 = (xx - 1) * 3 + yy - 1;

            swap(cur[tmp1], cur[tmp2]);

            if (mp[cur] == 0 || (mp[cur] == 1 && (t.step + 1) < dis[cur])) {//如果没有出现过,或者现在得到的这种方法的步数更小,更新

                dis[cur] = t.step + 1; //更新距离

                q.push((node){h(cur) + t.step + 1, t.step + 1, cur}); //h(cur) + t.step + 1,t.step + 1为到起点的距离,h(cur)为估计的到终点的距离

                mp[cur] = 1;

            }

            swap(cur[tmp1], cur[tmp2]); //相当于回溯

        }

    }

}

 

int main() {

    cin>>s;

    for(int i = 0; i < 9; ++ i )

        if(s[i] - '0' == 0) x = i / 3 + 1, y = i % 3 + 1;

    if (h(s) == 0) {cout<<0; return 0;}  //特判

    q.push((node){h(s), 0, s});

    mp[s] = 1;

    dis[s] = 0;

    A_STAR();

    return 0;

}

 

P2324

 


         IDA*:

迭代加深的A*算法

使k从1开始不断加深枚举, 作为最大步数进行迭代加深搜索判断,而对于不用移动的情况可以一开始直接特判;A*估价函数设置为当前状态还有多少个位置与目标状态不对应,若当前步数+估价函数值>枚举的最大步数则直接返回,并且在搜索中再加入最优性剪枝, 显然当前枚举下一个状态时如果回到上一个状态肯定不是最优, 所以我们在枚举下一状态时加入对这种情况的判断

int read()

{

    int f=1,x=0;

    char ss=getchar();

    while(ss<'0'||ss>'9'){if(ss=='-')f=-1;ss=getchar();}

    while(ss>='0'&&ss<='9'){x=x*10+ss-'0';ss=getchar();}

    return f*x;

}

 

string s;

int ans[4][4]=

{{0,0,0,0},

 {0,1,2,3},

 {0,8,0,4},

 {0,7,6,5}};

int a[5][5],k,judge;

int dx[]={0,1,-1,0};

int dy[]={1,0,0,-1};

 

int check()  //判断是不是已经到终点了

{

    for(int i=1;i<=3;++i)

        for(int j=1;j<=3;++j)

            if(ans[i][j]!=a[i][j])return 0;

    return 1;

}

 

int test(int step)  //进行估价

{

    int cnt=0;

    for(int i=1;i<=3;++i)

        for(int j=1;j<=3;++j)

            if(ans[i][j]!=a[i][j]){ if(++cnt+step>k) return 0;}  //++cnt就是在进行估值,如果这里预估出的步数大于我们限定的步数了就返回,剪枝

    return 1;

}

 

void A_star(int step,int x,int y,int pre)

{

    if(step==k){

        if(check()) judge=1;

        return;

    }//达到当前限制的最大深度,并且确实达到终点了才能返回1

    if(judge) return;

    for(int i=0;i<4;++i)//广搜

    {

        int nx=x+dx[i],ny=y+dy[i];

        if(nx<1||nx>3||ny<1||ny>3||pre+i==3) continue;//加入了上述最优性剪枝

        /*

        来具体解释一下这里的剪枝,我们的方向数组设成了

        int dx[]={0,1,-1,0};

        int dy[]={1,0,0,-1};

        观察一下可得方向数组下标和为3的两种移动相当于回到了原状态,这样相当于多走了2步,这样自然是能剪枝减掉的

        */

        swap(a[x][y],a[nx][ny]);

        if(test(step)&&!judge) A_star(step+1,nx,ny,i);//A*估价合法再向下搜索

        swap(a[x][y],a[nx][ny]);

    }

}

 

int main()

{

    int x,y;

    cin>>s;

    for(int i=0;i<9;++i)

    {

        a[i/3+1][i%3+1]=s[i]-'0';

        if(s[i]-'0'==0)x=i/3+1,y=i%3+1;

    }

    if(check()){cout<<0;return 0;}//特判不用移动

    while(++k)//枚举最大深度,这种枚举答案有点像二分?在能返回答案时的k一定是最小的那个k

    {

        A_star(0,x,y,-1);

        if(judge){cout<<k;break;}

    }

    return 0;

}

 

A*:

A * 算法,是一种在图形平面上,对于有多个节点的路径求出最低通过成本的算法

定义起点s,终点t,从起点(初始状态)开始的距离函数g(x),到终点(最终状态)的距离函数h(x),h*(x)(h(x)是我们定义的点x到终点的预估代价函数,h*(x)是点x到终点的实际代价函数),以及每个点的估价函数f(x)=g(x)+h(x)

A * 算法每次从优先队列中取出一个f最小的元素,然后更新相邻的状态,如果h<=h*,那么A*算法能找到最优解,上述条件下,如果h满足三角形不等式(松弛操作满足的条件),则A*算法不会将重复节点加入队列

当h=0时,A*算法变成Dijkstra;当h=0并且边权为1时变为BFS

(节点的扩展利用bfs来实现,每次选择当前节点用优先队列来实现,队列中节点的val值用估价函数和当前步数来实现)

P1379

 

P2324

在一个5*5的棋盘上有若干个起始,每个骑士都按照国际象棋中马的走法进行移动,给定一个初始的棋盘,要求移动到目标情况的最小移动次数,如果能在15步内(包括15步)达到目标状态,则输出步数,否则输出-1

这里的估价函数仍然设置成目前状态和目标状态不同的点的个数,如果当前的步数加上最小的移动步数(也就是我们的估价函数)就说明一定不能在限定步数内完成了

 

迭代加深:
所谓迭代加深就是每次限制搜索深度, 如果不行再扩展这个情况下的所有情况,这样可以在整个搜索树深度很大而答案深度又很小的情况下大大提高效率

 

迭代加深搜索的本质还是深度优先搜索,只不过在搜索的同时带上了一个深度d ,当d达到设定的深度时就返回,一般用于找最优解。如果一次搜索没有找到合法的解,就让设定的深度加一,重新从根开始。

既然是为了找最优解,为什么不用 BFS 呢?我们知道 BFS 的基础是一个队列,队列的空间复杂度很大,当状态比较多或者单个状态比较大时,使用队列的BFS 就显出了劣势。事实上,迭代加深就类似于用 DFS 方式实现的 BFS,它的空间复杂度相对较小

过程:
首先设定一个较小的深度作为全局变量,进行 DFS。每进入一次 DFS,将当前深度加一,当发现d 大于设定的深度limit就返回。如果在搜索的途中发现了答案就可以回溯,同时在回溯的过程中可以记录路径。如果没有发现答案,就返回到函数入口,增加设定深度,继续搜索

 

P2324

在一个5*5的棋盘上有若干个起始,每个骑士都按照国际象棋中马的走法进行移动,给定一个初始的棋盘,要求移动到目标情况的最小移动次数,如果能在15步内(包括15步)达到目标状态,则输出步数,否则输出-1

这里的估价函数仍然设置成目前状态和目标状态不同的点的个数,如果当前的步数加上最小的移动步数(也就是我们的估价函数)就说明一定不能在限定步数内完成了

同样,如果按照题目要求移动棋子,搜索分支会比较多,因此转成用空格走

IDA*就是带有迭代加深和估价函数优化的搜索

迭代加深就是每次限定一个maxdep最大深度,使搜索树的深度不超过maxdep

同时逐次增加当前的最大深度进行判断,类似于

for(R int maxdep=1;maxdep<=题目中给的最大步数;maxdep++){

dfs(0,maxdep);//0为出入函数中当前步数,maxdep为传入的最大深度。

if(success) break;//如果搜索成功则会在dfs函数中将success赋值为1。

}

使用于在一定限制条件中的使用,例如本题中如果能在15步内完成则输出步数,否则输出-1,并且可以输出任意一组解

降低复杂度的原因:我们可能会在一个没有解(或解很深)的地方无限递归,然而题目中要求输出任何的一组解,所以我们限制一个深度,让它去遍历更多的分支,去更广泛地求解,(其实和BFS有异曲同工之妙)。

A∗是用于对BFS的优化;

IDA∗是对结合迭代加深的DFS 的优化。

本质上只是在BFS和DFS上加上了一个估价函数。

const int dx[]={0,1,1,-1,-1,2,2,-2,-2};

const int dy[]={0,2,-2,2,-2,1,-1,1,-1};

const int goal[7][7]={

    {0,0,0,0,0,0},

    {0,1,1,1,1,1},

    {0,0,1,1,1,1},

    {0,0,0,2,1,1},

    {0,0,0,0,0,1},

    {0,0,0,0,0,0}

};

 

void solve(){

    vector<vector<int>> mp(7,vector<int>(7));

    int sx,sy;

    for(int i=1;i<=5;i++){

        for(int j=1;j<=5;j++){

            char a;

            cin>>a;

            if(a=='*'){

                mp[i][j]=2;

                sx=i,sy=j;

            }else{

                mp[i][j]=a-'0';

            }

        }

    }

    int maxdep=1;

    auto evaluate=[&]()->int{

        int res=0;

        for(int i=1;i<=5;i++){

            for(int j=1;j<=5;j++){

                if(mp[i][j]!=goal[i][j]) res++;

            }

        }

        return res;

    };

    auto A_star=[&](auto self,int dep,int x,int y)->bool{

        if(dep==maxdep){

            if(!evaluate()) return true;

            return false;

        }

        for(int i=1;i<=8;i++){

            int xx=x+dx[i],yy=y+dy[i];

            if(xx<1||xx>5||yy<1||yy>5) continue;

            swap(mp[x][y],mp[xx][yy]);

            if(evaluate()+dep<=maxdep){

                if(self(self,dep+1,xx,yy)) return true;

            }

            swap(mp[x][y],mp[xx][yy]);//回溯

        }

        return false;

    };

    for(;maxdep<=15;maxdep++){

        if(A_star(A_star,0,sx,sy)){

            cout<<maxdep<<'\n';

            return;

        }

    }

    cout<<-1<<'\n';

}  

 

 

 

 

dfs可以用来解决类似于暴力判断的问题

例如cf1681D,通过dfs来依次选择我们使用哪个数字去乘,通过剪枝优化来避免tle 

if(n+c-leg(x)>=ans)return;//最优情况剪枝

         if(leg(x)==n)

         {

                  ans=min(c,ans);//更新答案

                  que=true;

                  return;

         }

         vector<int>a(10);

         ll k=x;

         while(k)

         {

                  a[k%10]++;

                  k=k/10;

         }

         for(int i=9;i>1;i--)//从9开始

         {

                  if(a[i]>=1)

                  {

                          dfs(x*i,c+1);

                  }

         }

 

dfs用于解决路径寻找一类的问题

例如cf3c

解决井字棋(棋盘较小)问题需要3个参数,方向,以及这个方向上的棋子数目,用于判定这个方向上是否达到三个棋子,当前的坐标,用于后续的转移

int f,s,pf,ps;//f是'X'的个数,s是'0'的个数,pf是判断第一个人有没有赢,ps则是判断第二个人;

void dfs(int a,int b,int c,int d,int e,char t)//a,b是方向,c,d是当前的坐标,e是个数,t是棋子的类型('X'或'0')

{

  if(e==3){//如果3个连成了一条线

    if(t=='X')pf=1;//第一个人赢

    else ps=1;//第二个人赢

    return;//返回

  }

  else if(a+c<4&&a+c>0&&b+d<4&&b+d>0&&p[a+c][b+d]==t)//如果没出界,并且是同样的棋子

    dfs(a,b,a+c,b+d,e+1,t);//访问下一个坐标

}

 

IDA*

IDA * 为采用了迭代加深算法的 A * 算法

由于 IDA * 改成了深度优先的方式,相对于 A * 算法,它的优点有:不需要判重,不需要排序,利于深度剪枝;空间需求减少:每个深度下实际上是一个深度优先搜索,不过深度有限制,使用 DFS 可以减小空间消耗

缺点则是:重复搜索,即使前后两次搜索相差微小,回溯过程中每次深度变大都要再次从头搜索。

 

 

岛屿问题(网格图)(T200,可看记录)

常用的解决方法dfs(T2658),bfs,并查集

相较于bfs,dfs的解法会显得相对简单,因为不用去考虑数据结构以及维护队列

岛屿问题dfs的核心思想就是遍历各个相邻的区域即[i+1,j],[i-1,j],[i,j+1],[i,j-1],当这些区域是陆地(值为0),或者越过矩阵边界时结束dfs,并且同时将遍历过的节点数值改为0,避免重复搜索,而主循环就是遍历矩阵,当遇到节点值为1(是个陆地时),对其进行dfs搜索,根据dfs,我们一定可以将整块岛屿全部找出来,因此cnt+1

枚举上下左右四个方向时有时也可以直接用一个数组

const int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

for (auto &[dx, dy]: dirs) { // 枚举上下左右四个方向

int x = i + dx, y = j + dy;

 

 

因为岛屿问题本质上也是一个连通区块问题,因此可以用并查集来解决,可以将这里的矩阵看作是一个图,且每个相邻节点之间相互连接,本质上的思路是一样的,如果一个位置为1,则将其与相邻四个方向上的1在并查集中进行合并,这样最终岛屿的数量就是并查集中连通分量的数目,可以去套用并查集的模版

 

但如果是扩展问题,例如火会不断延伸,这与合并不同,更适合用bfs(特别是要记录被扩散到的时间时)去实现(T2258),因为这是有扩散的先后顺序的,相当于最开始的火是波纹的中心,那么之后会扩散到的区域相当于是子集,之后的扩散是要依靠子集去判断的,因此相当于是之后要遍历子集,因此适宜用bfs,但如果仅仅只是判断扩散的范围,那用dfs也是可行的,因为每次用dfs将周围为1的区域变为0也可以视作是一种扩散

 

当我们可以在网格图中四个方向随意移动时,我们有以下性质:要能从原点走出,我们可以在任意大于直接走到某一点的时间下走到该点(因为可以反复横跳消耗时间),并且到达一个点的时间的奇偶性是确定的,可以通过考虑曼哈顿距离得到,到达的时间与横坐标与纵坐标之和的奇偶性相同,因为每次反复横跳增加的时间是偶数次的,因此不会改变奇偶性(T2577)

 

图论:

依据一些关系连边构建图

Cf 2098D

行李领取区是一个大小为 n×m的长方形网格。行政部门建议,传送带的路径应穿过 p1,p2,…,p2k+1单元格,其中 pi=(xi,yi).

每个单元格 pi和下一个单元格 pi+1 (其中 1≤i≤2k),这些单元格必须共用一条边。此外,路径必须是简单的,即对于任何一对索引 i≠j

现在只保留了路径中奇数索引的单元格: p1,p3,p5,…,p2k+1 .你的任务是确定在这些 k+1单元格中,有多少种方法可以恢复原来的完整路径 p1,p2,…,p2k+1。

首先可以得到一些结论,对于任何已知的两个奇数点,由于完整路径之间必须相邻连接,因此如果它们之间的曼哈顿距离不为2,则路径不可能存在

否则,如果这两个奇数点在同一行或者同一列,那么这个偶数点的位置是确定的,也就是必须在这两点中间 ,否则,这两个奇数点存在于对角线上,那么此时这个偶数点有两个位置可以选择

综上,每个缺失位置有一组候选,也就是1个点或者两个点,并且同时,两个不同的缺失位置不能使用同一个位置,我们的目的是得到一组方案使得每个缺失位置都被正确的填充了

那我们可以对每一组候选进行连边:如果有两个候选,那么就在这两个点之间连一条边,如果只有一个候选,那么只需要在对应顶点上画一条自环即可

这样,每一条边就代表一次选择操作,最后的方案选择就是要给每条边指派一个端点

显然每个连通分量之间的选择都是相互独立的,因此最终答案是所有连通分量答案的乘积

因为自环实际上说明这个点的选择是确定的,因此我们额外用一个loop来维护连通分量内部是否有自环,此外我们维护s和e,分别用于表示连通分量中的顶点数以及边的数目,同时e也表示了需要选择的点的数目,从而如果e > s,就说明可用的点小于需求的点的数目,因此答案为0

从而,如果e==s,说明连通分量形成了一个环,如果有自环,那么就说明选择实际上确定的,因此答案为1;否则因为第一条边有两种选择,选择完后依据环的结构剩下边的选择也就都确定了,因此答案为2

如果e < s,因为保持连通,因此e = s – 1,也就是还有一个自由度,同样由于对于一条链上的点,选定一个起点后,剩下的点就会被自动选择

因此对于树,即选定了树根之后,剩下的点就是那些会被迫被选定的点,可以看作是按照树的形状自上而下确定,并且这样的方案是唯一的

树总共有 s 个点,每个点都可以被选为树根,因此一共有 s 种可能的方案

void solve() {

    int n, m, k;

    cin >> n >> m >> k;

    vector<int> x(k + 1), y(k + 1);

    for (int i = 0; i <= k; i++) {

        cin >> x[i] >> y[i];

        x[i]--, y[i]--;

    }

    DSU dsu(n * m);

    vector<int> loop(n * m);

    vector<int> e(n * m);

    for (int i = 0; i <= k; i++) { // 处理奇数点

        int u = x[i] * m + y[i];

        loop[u] = 1;

        e[u]++;

    }

    for (int i = 1; i <= k; i++) {

        if (abs(x[i] - x[i - 1]) + abs(y[i] - y[i - 1]) != 2) {

            cout << 0 << '\n';

            return;

        }

        int a, b, c, d;

        if (x[i] == x[i - 1]) {

            a = x[i];

            b = (y[i] + y[i - 1]) / 2;

            c = a;

            d = b;

        } else if (y[i] == y[i - 1]) {

            a = (x[i] + x[i - 1]) / 2;

            b = y[i];

            c = a;

            d = b;

        } else {

            a = x[i];

            b = y[i - 1];

            c = x[i - 1];

            d = y[i];

        }

        int u = a * m + b;

        int v = c * m + d;

        if (!dsu.same(u, v)) { // 连边

            e[dsu.find(u)] += e[dsu.find(v)];

            loop[dsu.find(u)] |= loop[dsu.find(v)];

            dsu.merge(u, v);

        }

        e[dsu.find(u)]++;

        if (u == v) {

            loop[dsu.find(u)] = 1;

        }

    }

    i64 ans = 1;

    for (int i = 0; i < n * m; i++) {

        if (dsu.find(i) == i) {

            int s = dsu.size(i);

            if (e[i] > s) {

                ans = 0;

            } else if (e[i] == s) {

                if (!loop[i]) {

                    ans *= 2;

                    ans %= mod;

                }

            } else {

                ans *= s;

                ans %= mod;

            }

        }

    }

    ans = (ans + mod) % mod;

    cout << ans << '\n';

}

 

 

遍历图进行预处理

Cf 2034c

给定一个矩阵,矩阵中的元素是L,R,U,D,表示到达这个位置之后下一次会向哪个方向移动,图中有一些?是可以被填入的位置,问如何进行操作能够使得有尽可能多的位置能让玩家走不出迷宫

可以对已有的元素进行预处理,因为如果通过这个位置不能走出迷宫,那么一定是一个回路,因此可以通过按照箭头指示找出它所对应的路径,如果是回路,那么标记为false,这样遍历每一个点就可以得到对应的图

之后再对每个?位置进行处理,依次遍历可填入的字符即可,如果有一个方案是可以通向回路的,那么就可行

关键在于考虑到预处理出回路,也就是路径是确定的情况下是遍历图是容易的

array<int,2> go(int x,int y,char c){

    if(c=='L'){

        y--;

    }else if(c=='R'){

        y++;

    }else if(c=='U'){

        x--;

    }else{

        x++;

    }

    return {x,y};

}

 

void solve(){

    int n,m;

    cin>>n>>m;

    vector<string> s(n);

    for(int i=0;i<n;i++){

        cin>>s[i];

    }

    auto valid=[&](int x,int y){

        return 0<=x && x<n && 0<=y && y<m;

    };

    vector vis(n,vector<bool>(m));

    vector f(n,vector<bool>(m));

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            if(s[i][j]=='?'){

                vis[i][j]=true;

            }

        }

    }

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            if(vis[i][j]){

                continue;

            }

            int x=i,y=j;

            vector<array<int,2>> a;

            while(valid(x,y) && !vis[x][y]){

                vis[x][y]=true;

                a.push_back({x,y});

                auto [xx,yy]=go(x,y,s[x][y]);

                x=xx;

                y=yy;

            }

            if(!valid(x,y) || f[x][y]){

                for(auto [x,y]:a){

                    f[x][y]=true;

                }

                // true表示这是通向外面的

            }

        }

    }

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            if(s[i][j]=='?'){

                bool ok=false;

                for(auto c:{'L','R','U','D'}){

                    auto [x,y]=go(i,j,c);

                    if(valid(x,y) && !f[x][y]){

                        ok=true;

                        break;

                    }

                }

                if(!ok){

                    f[i][j]=true;

                }

            }

        }

    }

    int ans=n*m;

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            if(f[i][j]){

                ans--;

            }

        }

    }

    cout<<ans<<'\n';

}

 

图论的很多算法都是基于边存在的,对于一些题目除了使用最基础的dfs和bfs算法(或进行记忆化)外,也可以尝试创造边,或者根据题目更改边的权值,进而尝试去使用效率更高的算法(T2304,将边值与节点值之和作为新的权值)

图论不仅仅是题干中给出图的描述才算是图论,一些数据与数据之间有关联的,都可以建图用图论的知识去解决(如数据之间可以交换时,在能交换的数据之间连边;一个数据与其他数据有关联时,在这两个数据之间连边来表示关系,如果是转化的关系,可以连上一条单向边P1037,如果是从这个点到另一点有花费的话就连一条有边权的边P1194,而且题目中表述这些元素之间的关系可以通过矩阵)

bfs

bfs不一定要用队列去实现,也可用双数组去实现(特别是要遍历矩阵中相连的节点时),例如vector<pair<int, int>> nq;(T2258)

要找到通向一个节点的路径但不需要实际记录下经过的节点时,也可以用bfs(T2258)

类似的不是去解决树中而是解决网格图中的节点问题,解决与某一节点最近的点(曼哈顿距离),也可以使用bfs(P1256),把所有值为1的点距离设为0(初始化),按位置依次进队,之后从队首依次查找(从这个节点向四周扩展)没有计算过距离的点,四周都扩展后队头后移,那么队头到1点的距离+1就是拓展的点的距离,这样保证了每次一定是用最小的距离去更新(曼哈顿距离保证了四个方向的距离是等价的)

在二叉树的层序遍历中,可以将二叉树视作一个单向图,我们能轻易得到父节点的子节点,但如果要对当前节点判断它们的父节点是否相同会显得较为复杂,因为我们往往还要通过另一个map数组去记录他们的父节点,因此我们可以换一个思路,对这一层的操作在上一层的父节点中完成,因为在父节点中我们能直接看出那些子节点是属于这一父节点的(T2641),这也是这类问题通用的思考方法,如果要考虑父节点,尝试能不能直接从父节点的角度去看子节点

层序遍历:可以根据题目的需要调整每次加入节点的顺序,如是先右再左leetcodeT513,技术列先左在右leetcodeT103等

实现方法双数组(cur,next)、队列,依次让节点进入队列,搜索后删除,并将子节点加入

若只删除当前层的节点,队列不能用下标来遍历,但可以用此时队列的大小用来限制操作进行的次数,如

for(int i=q.size();i;i--){

auto node=q.front();

q.pop();

vale.emplace_back(node->val);

if (node->left) q.push(node->left);

 if (node->right) q.push(node->right);

}

 

Cf 2070D
给定一棵树,每次移动时只能移动到下一层不属于它子树的节点上(对于根节点可以移动到子树上),问这样可能的移动序列有多少个(不一定需要走到最底层)

因为是一层层走,显然考虑层序遍历,每个节点的方案数是上一层总的方案数减去它父节点的方案数

因为可以在某一层中途停止,因此自上而下计算

void solve() {

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int fa;

        cin>>fa;

        fa--;

        adj[fa].emplace_back(i);

    }

    Z ans=1;

    Z pre=1;

    Z cur=0;

    vector<Z> dp(n);

    queue<int> q;

    vector<int> dfn(n);

    dfn[0]=0;

    int cnt=0;

    q.push(0);

    while(!q.empty()){

        int u=q.front();

        q.pop();

        for(int v:adj[u]){

            q.push(v);

            dfn[v]=dfn[u]+1;

            if(dfn[v]==cnt+1){

                cnt++;

                pre=cur;

                ans+=cur;

                cur=0;

            }

            if(u==0){

                dp[v]=1;

            }else{

                dp[v]=pre-dp[u];

            }

            cur+=dp[v];

        }

    }

    ans+=cur;

    cout<<ans<<'\n';

}

 

 

dfs(深度优先搜索):

正是因为dfs搜索可一个方向,并需要回溯,所以用递归的方式来实现是最方便的

(非图论)dfs利用数组的赋值可以替代回溯的重置步骤,在问题操作比较复杂影响较高时可用dfs来判断各个操作

int dfs(int x,int t1,int t2)//深搜确定乘号位置{

         if(x==n)

         {

                  memset(f,0,sizeof(f));//多次判断,记得清空数组

                  ans=max(ans,dp());

                  return 0;

         }

    //其实一个参数就够了,笔者这里还可以优化,不过这样方便理解

         if(t1<k)//枚举乘号

         {

                  s[x]=2;

                  dfs(x+1,t1+1,t2);

         }

         if(t2<n-k-1)//枚举加号

         {

                  s[x]=1;

                  dfs(x+1,t1,t2+1);

         }

}

 

dfs除了用来搜索一棵树的最大深度还能用来搜索类似的图的最大深度(联通分量的节点数 T2316)

g[y].push_back(x); // 建图    建图实际上也就是找相连的子节点,这样后续就能查找深度

vector<int> vis(n);

function<int(int)> dfs = [&](int x) -> int {

vis[x] = true; // 避免重复访问同一个点

    int size = 1;

for (int y: g[x]) {                         遍历相连节点,且不需要判断最后的子节点

if (!vis[y]) {

size += dfs(y);

        }

    }

    return size;

};

搜索一棵树的子节点,并且计算子树大小(T2477)

function<int(int, int)> dfs = [&](int x, int fa) -> int {

            int size = 1;

            for (int y: g[x]) {

                if (y != fa) { // 递归子节点,不能递归父节点

                    size += dfs(y, x); // 统计子树大小

                }

            }

            return size;

        };

        dfs(0, -1);

注意很多时候dfs如果不用记忆化往往会使得tle,因为要访问的路径往往比较大

 

2024哈尔滨 G

给出一张n个点的无向图,保证每个点的度都大于等于1,找出此图的一个生成树,并满足给定的k个节点的度为1

通过设置dfs的条件:什么情况下不能再继续搜索,这样跑完一次后再检查是否所有节点都被搜索过了即可

void solve(){

    int n,m,k;

    cin>>n>>m>>k;

    vector<int> flag(n);

    for(int i=0;i<k;i++){

        int x;

        cin>>x;

        x--;

        flag[x]=1;

    }

    vector<vector<int>> adj(n);

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    if(k==n){

        cout<<"No\n";

        return;

    }

    vector<int> vis(n);

    vector<pair<int,vector<int>>> ans;

    auto dfs=[&](auto self,int u)->void{

        vector<int> tmp;

        for(int v:adj[u]){

            if(!vis[v]){

                tmp.emplace_back(v);

                vis[v]=1;

            }

        }

        if(tmp.size()){

            ans.emplace_back(make_pair(u,tmp));

        }

        for(int v:tmp){

            if(flag[v]){

                continue;

            }

            self(self,v);

        }

    };

    for(int i=0;i<n;i++){

        if(!flag[i]){

            vis[i]=1;

            dfs(dfs,i);

            for(int i=0;i<n;i++){

                if(!vis[i]){

                    cout<<"No\n";

                    return;

                }                

            }

            cout<<"Yes\n";

            cout<<ans.size()<<'\n';

            for(auto [x,y]:ans){

                cout<<x+1<<' ';

                cout<<y.size()<<' ';

                for(auto v:y){

                    cout<<v+1<<' ';

                }

                cout<<'\n';

            }

            return;

        }

    }

}

官方题解:
可以将生成树中度数为1的点设置为特殊点,那么对于无向图的每一条边,如果所连接的点中有一个点是特殊点,那么进行缩点,将特殊点缩到非特殊点中,同时新图不保留这条边;如果两个点都是特殊点,那么新图中也不保留这条边

对于新图,如果为空或者不连通,则无法找到满足要求的生成树,否则一定可以找到,在输出时再将特殊点还原即可

 

状态数较少时直接进行暴力搜索

CCPC2024 济南B

有几种特殊牌,问手中的牌最多可以打出多少个同花顺

因为手牌来自于标准的扑克牌,因此通过一种花色最多只能凑出3幅同花顺

状态数只有4^4,可以直接搜索实现

在判断当前状态是否可行时,可以直接先使用对应的特殊牌,最后使用万能的特殊牌,因此可以贪心判断是否可行

void solve(){

    int n;

    cin>>n;

    array<int,4> cnt{};

    array<int,4> a{};

    for(int i=0;i<n;i++){

        string s;

        cin>>s;

        cnt[((string)"DCHS").find(s[1])]++;

    }

    for(int i=0;i<4;i++){

        cin>>a[i];

    }

    int xx,yy;

    cin>>xx>>yy;

    xx+=yy;

    auto modify=[&](int &x,int &y,int &z)->void{

        int d=min({x,y,z});

        x-=d,y-=d,z-=d;

    };

    auto dfs=[&](auto self,array<int,4> x,int d)->int{

        if(d==4){

            int left=0,tot=0,wild=xx;

            array<int,4> need;

            for(int i=0;i<4;i++){

                tot+=x[i]/5;

                if(cnt[i]>=x[i]) left+=cnt[i]-x[i],need[i]=0;

                else need[i]=x[i]-cnt[i];

            }

            for(int i=0;i<4;i++){

                if(a[i]){

                    int s=3;

                    modify(need[i],left,s);

                }

                modify(need[i],left,wild);

                if(need[i]) return 0;

            }

            return tot;

        }

        int res=0;

        for(int i=0;i<4;i++){

            x[d]=i*5;

            res=max(res,self(self,x,d+1));

        }

        return res;

    };

    int ans=dfs(dfs,cnt,0);

    cout<<ans<<'\n';

}

 

 

Cf 2029D

给定一个无向图,其中有n个顶点和m条边

每次操作选择三个不同的顶点,对于(a,b),(b,c),(c,a),如果这条边不存在,那么添加这条边,否则删除这条边

构造一个操作方案使得操作完成后该图没有边后者该图是一棵树

void solve(){

    int n,m;

    cin>>n>>m;

    vector<int> f(n,-1);

    vector<array<int,3>> ans;

    auto add=[&](auto self,int u,int v)->void{

        if(f[u]==v){//直接删除

            f[u]=-1;

            f[v]=-1;

            return;

        }

        if(f[u]!=-1){

            int w=f[u];

            ans.push_back({u,v,w});

            f[u]=-1;

            f[w]=-1;

            self(self,v,w);

            return;

        }

        if(f[v]!=-1){

            int w=f[v];

            ans.push_back({u,v,w});

            f[v]=-1;

            f[w]=-1;

            self(self,u,w);

            return;

        }

        f[u]=v;

        f[v]=u;

    };

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        add(add,u,v);

    }

    vector<array<int,2>>p;

    for(int i=0;i<n;i++){

        if(f[i]>i){

            p.push_back({i,f[i]});

        }

    }

    if(!p.empty()){

        auto [a,b]=p[0];

        for(int i=1;i<p.size();i++){

            auto [u,v]=p[i];

            ans.push_back({a,u,v});

        }

        for(int i=0;i<n;i++){

            if(f[i]!=-1){

                continue;

            }

            ans.push_back({a,b,i});

            b=i;

        }

    }

    cout<<ans.size()<<'\n';

    for(auto [u,v,w]:ans){

        cout<<u+1<<' '<<v+1<<' '<<w+1<<'\n';

    }

}

 

 

Cf 2040E

一棵树,n个点,初始机器人在一个不是1的点,也就是1视为根节点

如果是奇数,那么向根结点移动;否则需要花费一个金币才能向根节点移动,不然就随机向相邻节点移动

给定初始的位置和金币数,问最小预期步数

每一轮都可以构造成不会往回走的状态

反向,每一轮(奇数和偶数一起看),如果偶数时不花费金币,那么可能就会往下走,这样往上再往下就浪费了

奇数的点一定会往根走,偶数点可以考虑这个点浪费的期望次数,每次有1/deg的概率往上,那么浪费就是deg-1/deg,对于步数来说相当于原地停留,因此浪费了两步,于是看作浪费的期望是2*(deg-1)

因为有p个金币,因此我们可以消除其中较大的几次负面影响,也就是找到前p大并且删除即可

具体处理可以线性时间内完成

void solve(){

    int n,q;

    cin>>n>>q;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<int> p(n,-1),dep(n);

    auto dfs=[&](auto self,int u)->void{

        for(auto v:adj[u]){

            if(v==p[u]) continue;  

            p[v]=u;

            dep[v]=dep[u]+1;

            self(self,v);

        }

    };

    dfs(dfs,0);

    for(int i=0;i<q;i++){

        int v,c;

        cin>>v>>c;

        v--;

        int ans=dep[v];

        vector<int> cnt(n);

        for(int x=p[v];x>0;x=p[p[x]]){

            cnt[adj[x].size()]++;

            ans+=2*(adj[x].size()-1);

        }

        for(int d=n-1;d>0;d--){

            while(cnt[d]>0 && c>0){

                cnt[d]--;

                c--;

                ans-=2*(d-1);

            }

        }

        cout<<ans<<'\n';

    }

}

 

 

P1612

当路径上的点的权值都是正值时,就可以认识到路径的总值一定是递增的,这种单调性有时就可以使用二分了;同时要搜索全部的树上路径,可以可以考虑用dfs;每次都需要快速得到一条路径的值,因此可以用类似前缀和的思路求出到这个节点上的路径权值和,为了保证权值的唯一,将si为从根到节点i 的权值和;进行二分时要保证我们找到的点是在当前的路径上的(又用到那个思路了,从子节点找父节点比较困难,因此从父节点去向下取搜索),为了保证这一点,我们可以维护一个与此时的递归调用同步的栈,这样栈中的节点就一定都是路径上的点

int n, p, ans[100005], q[100005], tail; // q 是我们维护的栈

long long w[100005], c[100005], s[100005];

vector<int> e[100005];//佬常用的存储边的方式,边和边权分开存储,此处是用来记录该节点对应的子节点

bool cmp(const int &x, const long long &y) {

    return s[x] < y;

}//用于二分的函数

void dfs(int x) {

    q[tail++] = x;//与dfs同步的栈,因为路径上的顺序不可变,相当于一个栈

    ans[x] = q + tail - lower_bound(q, q+tail, s[x] - c[x], cmp) - 1; // 二分找到位置,算出距离

    for(const int &p : e[x]) s[p] = s[x] + w[p], dfs(p); //计算路径权值,以及继续向下搜索

    tail--;//有点类似于回溯?因为要和dfs同步,而dfs在到底后会返回

}

int main() {

    scanf("%d", &n);

    e->emplace_back(1); //用首地址进行操作,给根节点加一个父节点方便操作

    for(int i = 2; i <= n; i++)

        scanf("%d", &p), e[p].emplace_back(i);

    for(int i = 1; i <= n; i++) scanf("%lld", w+i);

    for(int i = 1; i <= n; i++) scanf("%lld", c+i);

    dfs(0);

    for(int i = 1; i <= n; i++) printf("%d ", ans[i]);

    return 0;

}

 

当数据量较小时,所谓的爆搜可以用dfs去写,即依次传递当前节点选还是不选

 

cf1948C

dfs要避免TLE,需要记忆化搜索,但如果当前需要终点已经搜索过了也可以跳出循环来减少搜索次数

if(vis[2][n]) return;

 

cf31D

对于大小不规则的情况,就只能用搜索去解决了,但无论如何,不要将其想成一个计算几何,即去找切割直线相互之间的交点,因为这样操作后后续判断围成的图形仍然比较困难

应当是从一个点开始向周围去搜索,碰到了我们切割过的边就停止,并且为了避免重复计算,可以增加一个标记数组或者同时规定搜索的方向只能是向下或向左,这样在搜索的过程中能够直接计算出面积

但因为这题保证了切割所得的形状比较规则,因此也可以之间for循环查找,在标记切割边时,记录某一个的格子的下方和右方是否有切口(因为for循环是顺序遍历)

 

反向建边,利用dfs查找有向无环图中一个节点的所有祖先节点,此时通过dfs访问到的点就是其祖先节点

或者也可以用正向dfs,关键就在于每次从不同的起点进行dfs,并且将start加入到当前访问到的点的祖先节点的数组中,无需每次 DFS 前都重新初始化 vis数组。我们会跑 n个DFS,每个 DFS 的start都是不同的。利用这一条件,当访问到节点x时,标记 vis[x]=start,表示 x是本轮DFS中访问到的节点。当我们访问到某个节点y时,如果发现vis[y]=start,就表示 y访问过了,否则没有访问过 (T2192)

 

深度优先搜索能够保证我们是对树上的一条路径在进行操作,因此找节点与其祖先之间的最大差值时可以使用深度优先搜索去传递这条路径上当前的最大值以及最小值

优化:对于一条从根出发向下的路径,我们要计算的实际上是这条路径上任意两点的最大差值。递归到空节点时,mx是从根到叶子的路径上的最大值,mn是从根到叶子的路径上的最小值,所以mx-mn就是从根到叶子的路径上任意两点的最大差值。所以无需每个节点都去更新答案,而是在递归到空节点时才去更新答案(T1026)

 

dfs要注意优化,常见的就是剪枝或者记忆化搜索,因为递归次数太多会导致t

并且注意如果要时刻维护循环节中的最大值,不能作为全局变量去改变,最好是作为参数的一部分

int dfs1(int p,int flag,int mx){

    // val: 8->1->9  k=3  8*3 > 8+1+9  一心往大的走不一定是最优的

    //优化,对应循环节中的最大值所在的位置

    //一旦到了最大值所在的位置,就不再往下走

    //cout<<p<<' '<<val[p]<<' '<<mx<<' '<<val[mx]<<'\n';

    if(vis[p]) return mx;

    if(val[p]>val[mx]) mx=p;

    vis[p]=flag;

    dfs1(a[p],flag,mx);

}

 

牛客24多校5 H

有一个无向图,每个点有点权并且互不相同

在这个无向图上做梯度下降算法,每次会选择周围点权最小的点走过去,如果比当前点权大就不走了,将经过的点的数量称为这次梯度下降的时间

可以自由选择起点和ai,最大化梯度下降的时间

这里n的范围是1到40

void solve(){

    int n,m;

    cin>>n>>m;

    vector<i64> adj(n);

    for(int i=1;i<=m;i++){

        int u,v;

        cin>>u>>v;

        --u,--v;

        adj[u]|=1LL<<v;

        adj[v]|=1LL<<u;

    }

    int ans=-1;

    auto dfs=[&](auto self,int u,int len,i64 ban)->void{

        ans=max(ans,len);

        i64 nxt=adj[u]&~ban;

        for(int v=0;v<n;v++){

            if((nxt>>v)&1){

                self(self,v,len+1,(ban|adj[u]));

            }

        }

    };

    for(int i=0;i<n;i++){

        dfs(dfs,i,1,0LL);

    }

    cout<<ans<<'\n';

}

因为n很小,因此我们可以用类似状态压缩的方式存图

同时因为点权可以任意确定,因此并不需要再确定每个点具体的权值,可以直接找路径,相当于找完路径后再将点权放上去

但因为我们每次选择的点都是当前可选点中点权最小的,因此一旦在这个点周围选择了一个点走之后,除了这个点之外的点都不能在后续的路径选择中被选择,于是我们需要用ban来记录哪些点是不能走的,这样每次可走的点是adj[u]&~ban

 

CCPC2024 济南 I

给定n个点的树,构造加边方案使得每条边恰在一个简单环上,且图中不存在重边和自环

题意可以转化为:找到若干条路径不重不漏地覆盖整棵树的边,且每条路径长度>=2

考虑树形dp判断合法性

F[x][0/1]表示只考虑以x为根的子树,其中第二维为0表示可以完成覆盖,为1表示可以留有一条链不完成覆盖,且链的一端是x

考虑转移

f[x][0]: 若有偶数个孩子,则为True;否则若有任意一个孩子y满足f[y][1]为True,则为True;否则奇数个孩子为false,那么一定有一条链无法被覆盖到

f[x][1]: 若有奇数个孩子,则为True;否则有任意一个孩子y满足f[y][1]为True,则为True;否则一定有偶数个孩子且他们都f[y][1]=false,此时为false

如果最后根r满足f[r][0]=True,那么就说明存在一组解

按照该转移完成dp,并在过程中记录方案即可

对上述方案进行优化,注意到取任意偶度数r为根时,必然有f[x][0]=True,也就是偶度数的点一定有解;证明:以偶度数点进行DFS,如果子树内有偶数个点就两两连起来,若有奇数个点则选择一个不连(例如选择子树的根不连)

因此只要有节点的度数为偶数那么就是有解的

相应的,若所有点都是奇度数,那么通过DP可以证明无解,具体来说是通过树的大小来归纳证明

(实际上就是构造一组方案,考虑让每个叶子和相邻的叶子相连,连接完毕后就构成了一个环,可以缩到根结点上,然后重复这样操作,显然如果一个根节点的有偶数个子节点则刚好,否则会多余出一个叶子,从缩完点的图来看就是剩下一条链,然后继续在当前的根节点的上一级继续操作,因此只要有一个点的度数为偶数,将它作为根节点,那么一定是可行的)

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=0;i<n-1;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<pair<int,int>> ans;

    int root=-1;

    for(int i=0;i<n;i++){

        if(adj[i].size()%2==0){

            root=i;

            break;

        }

    }

    if(root==-1){

        cout<<"-1\n";

        return;

    }

    auto dfs=[&](auto self,int u,int p)->int{

        int last=-1;

        for(int v:adj[u]){

            if(v==p){

                continue;

            }

            int cur=self(self,v,u);

            if(last==-1){

                last=cur;

            }else{

                ans.push_back({last,cur});

                last=-1;

            }

        }

        if(last==-1){

            return u;

        }else{

            return last;

        }

    };

    dfs(dfs,root,-1);

    cout<<ans.size()<<'\n';

    for(auto [u,v]:ans){

        cout<<u+1<<' '<<v+1<<'\n';

    }

}

 

 

边定向

无向图三元环计数(P1989)

让度数小的连向度数大的,如果相同,则编号小的连向编号大的

void solve(){

    int n,m;

    cin>>n>>m;

    vector<pair<int,int>> e(m);

    vector<int> d(n,0);

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        d[u]++;

        d[v]++;

        e[i]={u,v};

    }

    // int B=sqrt(m);

    vector<vector<int>> g(n);

    for(auto [u,v]:e){

        if(d[u]<d[v]){

            g[u].push_back(v);

        }else if(d[u]>d[v]){

            g[v].push_back(u);

        }else{

            if(u<v){

                g[u].push_back(v);

            }else{

                g[v].push_back(u);

            }

        }

    }

    i64 ans=0ll;

    for(int i=0;i<n;i++){

        vector<int> vis(n,0);

        for(int j:g[i]){

            vis[j]=1;

        }

        for(int j:g[i]){

            for(int k:g[j]){

                if(vis[k]){

                    ans++;

                }

            }

        }

    }

    cout<<ans<<'\n';

}

 

 

图上邻域,根号分治

P10559

类似拓扑排序的写法           

void solve(){

    int n,m,qu;

    cin>>n>>m>>qu;

    vector<vector<int>> adj(n);

    vector<int> d(n,0);

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        d[u]++;

        d[v]++;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    // int B=sqrt(m);

    vector<vector<int>> g(n);

    queue<int> q;

    for(int i=0;i<n;i++){

        if(d[i]<=10){

            q.push(i);

        }

    }

    vector<int> vis(n,0);

    while(!q.empty()){

        int x=q.front();

        q.pop();

        vis[x]=1;

        for(auto y:adj[x]){

            if(vis[y]) continue;

            g[x].emplace_back(y);

            if(--d[y]==10){

                q.push(y);

            }

        }

    }

    vector<int> a(n),b(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        for(int y:g[i]){

            b[y]+=a[i];

        }

    }

    while(qu--){

        int o;

        cin>>o;

        if(o==1){

            int x,val;

            cin>>x>>val;

            x--;

            a[x]+=val;

            for(int y:g[x]){

                b[y]+=val;

            }

        }else if(o==2){

            int x;

            cin>>x;

            x--;

            i64 ans=b[x];

            for(int y:g[x]){

                ans+=a[y];

            }

            cout<<ans<<'\n';

        }

    }

}

 

 

无向连通图的方案数

POJ 1737

f(n)是有n个顶点的连通图的个数,g(n)是有n个顶点的不连通的图的个数,h(n)是总方案数

h(n)是可以得到是

即对于任意可能存在的边,都有选和不选两种情况

要求出g(n),选择一个点作为中心,设为v1,那么和v1在一起成为一个连通块的方案数是sum(C(n-1,i-1)*f(i)*h(n-i)),这个式子表示n个点除了v1以外的点,用另外i-1个点和v1凑成i个点的方案数,图中只要有一部分是不连通的那么整个图一定是不连通的,因此还可以乘上h(n-i)

 

cf104065

令dp[u]表示第k天后位置在u的答案

dp[u]=min(dp[v]+e(u,v))

进行根号分治

度数小于B,直接操作,操作数不大于B

度数大于B的,用set维护,set的复杂度为logn,每次对连边的操作数不大于B(因为度数大于B的点同样不会超过根号n个)

在第6点会RE

constexpr i64 mod=998244353;

void solve(){

    int n,m,q;

    cin>>n>>m>>q;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<vector<pair<int,int>>> adj(n);

    vector<int> d(n,0);

    for(int i=0;i<m;i++){

        int u,v,w;

        cin>>u>>v>>w;

        u--,v--;

        d[u]++;

        d[v]++;

        adj[u].emplace_back(v,w);

        adj[v].emplace_back(u,w);

    }  

    int B=ceil(sqrt(m));

    // const int B=1005;

    vector<int> b(q);

    for(int i=0;i<q;i++){

        cin>>b[i];

        b[i]--;

    }

    vector<i64> dp(n,0ll);

    multiset<i64> st[n];

    vector<vector<pair<int,int>>> g;

    for(int i=0;i<n;i++){

        for(auto [y,w]:adj[i]){

            if(d[i]<=B && d[y]>B){

                st[y].insert(w);

            }

            if(d[i]>B && d[y]>B){

                g[y].emplace_back(i,w);

            }

        }

    }

    for(int i=q-1;i>=0;i--){

        int x=b[i];

        if(d[x]<=B){

            i64 val=inff;

            for(auto [y,w]:adj[x]){

                val=min<i64>(val,dp[y]+w);

            }

            for(auto [y,w]:adj[x]){

                if(d[y]>B){

                    st[y].erase(st[y].find(dp[x]+w));

                    st[y].insert(val+w);

                }

            }

            dp[x]=val;

        }else{

            i64 val=inff;

            for(auto [y,w]:g[x]){

                val=min<i64>(val,dp[y]+w);

            }

            val=min<i64>(val,*st[x].begin());

            dp[x]=val;

        }

    }

    i64 ans=0ll;

    for(int i=0;i<n;i++){

        ans=(ans+a[i]*dp[i]%mod)%mod;

    }

    cout<<ans<<'\n';

}

 

 

双连通

割点

模板 P3388

如果当前点是根,那么只有它至少有两个子树时,它才是割点

如果其并不是根,那么当low[v]>=dfn[u]时,它是割点

其余判断和边双什么的处理是类似的,如果当前点删除之后,它的子树不能够通过搜索树外的边回到上面,那么就说明图的连通性发生了变化,于是这个点就是割点

void solve(){

    int n,m;

    cin>>n>>m;

    vector<vector<int>> adj(n);

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<int> dfn(n),low(n);

    int tot=0;

    int cnt=0;

    vector<int> buc(n);

    auto tarjan=[&](auto self,int x,int R)->void{

        dfn[x]=low[x]=++tot;

        int son=0;

        for(int y:adj[x]){

            if(!dfn[y]){

                son++;

                self(self,y,R);

                low[x]=min(low[x],low[y]);

                if(low[y]>=dfn[x] && x!=R){

                    cnt+=!buc[x];

                    buc[x]=1;

                }

            }else{

                low[x]=min(low[x],dfn[y]);

            }

        }

        if(son>=2 && x==R){

            cnt+=!buc[x];

            buc[x]=1;

        }

    };

    for(int i=0;i<n;i++){

        if(!dfn[i]){

            tarjan(tarjan,i,i);

        }

    }

    cout<<cnt<<'\n';

    for(int i=0;i<n;i++){

        if(buc[i]){

            cout<<i+1<<' ';    

        }

    }

}

 

 

在连通无向图中,对于两个点,无论删除中间哪个点都不会使图不连通,称为点双连通;无论删除哪个边都不会使图不连通,成为边双连通

点双连通不具有传递性,边双连通具有传递性

极大边双连通子图,称为边双连通分量;极大点双连通子图,称为点双连通分量

对于连通的无向图,从任意一点开始dfs,得到一棵dfs生成树(开始dfs的点为根),这棵生成树上的边称作树边,否则称为非树边,可以保证所有非树边连接的两个点在生成树上都满足一个是另一个的祖先

割边:删除这条边后,图的连通分量个数增加

割点:删除这个点以及相连的边后,图的连通分量个数增加

 

求边双连通分量

tarjan求割边,判断一条边割去后图是否仍然连通

low[x],通过一条不在搜索树上的边能达到的节点中的最小编号

dfn[x],时间戳,按访问顺序编号

y是搜索树上的节点,low[x]=min(low[x],low[y])

否则,low[x]=min(low[x],dfn[y]),注意这里是dfn[y],因为是通过一条不在搜索树上的边

如果x到y的无向边是割边,当且仅当dfn[x]<low[y](x经过一条回边到不了y,那么删除x到y的边后连通性会发生变化,这条边就是割边)

建图时边的编号从2开始标,可以通过异或运算在O(1)时间复杂度内求出逆向边,并标记割边(因为连边时边和反边是同时连接的,因此编号相差1,如果从2开始标号,那么通过异或1可以相互转化)

因为边双连通分量的定义,可知边双连通分量中没有割边,因此可以删除所有割边来求边双

因此在割边求出之后,用dfs来分离出所有的边双连通分量

P8346

int cnt=1;

int head[500010],nxt[4000010],to[4000010];

void add(int u,int v){

    to[++cnt]=v;

    nxt[cnt]=head[u];

    head[u]=cnt;

}

void solve(){

    int n,m;

    cin>>n>>m;

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        if(u==v){

            continue;

        }

        add(u,v);

        add(v,u);

    }

    vector<int> dfn(n);

    vector<int> low(n);

    vector<int> b(cnt+10);

    int tot=0;

    auto tarjan=[&](auto self,int x,int fa)->void{

        dfn[x]=low[x]=++tot;

        for(int i=head[x];i;i=nxt[i]){

            int y=to[i];

            if(dfn[y]==0){

                self(self,y,i);

                //标记边

                if(dfn[x]<low[y]){

                    b[i]=b[i^1]=1;

                }

                low[x]=min(low[x],low[y]);

            }else if(i!=(fa^1)){

                low[x]=min(low[x],dfn[y]);

            }

        }

    };

    for(int i=0;i<n;i++){

        if(dfn[i]==0){

            tarjan(tarjan,i,0);

        }

    }

    vector<int> dcc(n);

    int ans=0;

    vector<vector<int>> Ans;  

    auto dfs=[&](auto self,int x,int id)->void{

        dcc[x]=id;

        Ans[id-1].push_back(x);  

        for(int i=head[x];i;i=nxt[i]){

            int y=to[i];

            if(dcc[y] || b[i]){

                continue;

            }

            self(self,y,id);

        }

    };  

    for(int i=0;i<n;i++){

        if(dcc[i]==0){//因此ans要从1开始

            Ans.push_back(vector<int>());

            dfs(dfs,i,++ans);

        }

    }

    cout<<(ans)<<'\n';

    for(int i=0;i<ans;i++){

        cout<<Ans[i].size()<<' ';

        for(int x:Ans[i]){

            cout<<x+1<<' ';

        }

        cout<<'\n';

    }

}

 

求点双连通分量(P8435)

两个性质:两个点双最多只有一个公共点(都有边与之相连的点),且这个点在两个点双和它形成的子图中是割点

对于一个点双,它在dfs搜索树中dfn值最小的点一定是割点或者树根

当这个点是割点时,那么它所属的点双必定不可能像它的父亲方向包括更多的点,因为一旦回溯,将称为新子图的一个割点,而这个新子图显然不再是一个点双

点双连通,换言之就是对于一个无向图,任意一个节点对于这个图本身都不是割点

同样可以用tarjan求割点

用dfs遍历无向图过程中形成的图称为搜索树,从u到未被搜索过的点的一条边称为树边,否则称为返祖边

在这里dfn和low的处理和上面的求割点是相同的

对于树边(u,v),如果low[v]>=dfn[u],也就是说v及其子树能够通过返祖边到达的最早时间戳只能是dfnu,因此如果把u相关的边全部删除,那么他们不可能再回溯到更早的节点,也就是不再连通,因此u就是割点(割边是low[v]>dfn[w])

但是一个连通分量的搜索树的根节点一定满足上面的条件,因为在一个搜索树中,不存在dfn比它小的节点,但是只有在其拥有两个及以上的子树时,它才会被称为割点

如果这个点只有一个子树,那么它一定属于它的直系儿子的点双,如果是一个独立点,那么它被视为一个单独的点双

用栈维护点,当遇到这两类点时,将子树内不属于其他点双的非割点或者在子树中的割点归到一个新的点双

因为这个点可能还是与其他点双的公共点,因此不能将其出栈

void solve(){

    int n,m;

    cin>>n>>m;

    vector<vector<int>> adj(n);

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    vector<int> dfn(n);

    vector<int> low(n);

    vector<vector<int>> Ans;

    int ans=0;

    int top=0;

    int tot=0;

    vector<int> st(m+10);

    auto tarjan=[&](auto self,int x,int fa)->void{

        int son=0;

        low[x]=dfn[x]=++tot;

        st[++top]=x;

        for(int y:adj[x]){

            if(dfn[y]==0){

                son++;

                self(self,y,x);

                low[x]=min(low[x],low[y]);

                if(low[y]>=dfn[x]){

                    ans++;

                    Ans.push_back(vector<int>());

                    while(st[top+1]!=y){

                        //子树出栈

                        //因为不是真的出栈,因此top+1实际上是判断上一个出栈的是不是子树的根y

                        Ans[ans-1].emplace_back(st[top--]);

                    }

                    //将割点/树根也放入点双

                    Ans[ans-1].emplace_back(x);

                }

            }else if(y!=fa){

                low[x]=min(low[x],dfn[y]);

            }

        }

        if(fa==-1 && son==0){//特判独立点

            ans++;

            Ans.push_back(vector<int>());

            Ans[ans-1].emplace_back(x);

        }

    };

    for(int i=0;i<n;i++){

        if(dfn[i]) continue;

        top=0;

        tarjan(tarjan,i,-1);

    }

    cout<<ans<<'\n';

    for(int i=0;i<ans;i++){

        cout<<Ans[i].size()<<' ';

        for(int x:Ans[i]){

            cout<<x+1<<' ';

        }

        cout<<'\n';

    }

}

 

牛客24多校6 D

给定一张简单连通无向图,边有黑白,移除任意多条边使得图仍然连通,且黑边都在环上,白边都不在环上,或者输出无解

首先所有黑边都在环上,所以考虑图最后输出的时候一定是所有的边双都是黑边构成的,所有的白边都是桥

先考虑黑边,只保留所有的黑边,跑一遍边双,会得到很多边双连通块

如果一个黑边是桥的话是一定要删掉的,因为否则只能用白边将其构成环或者边双,但白边又不能在任意一个环上,因此如果黑边是桥的话一定是不会出现在最后的图中,因此可以直接删除

而剩下的黑边一定是有用的,因为如果删除了这些黑边,会使桥的个数变多,也就是剩下的连通块个数会变多,而我们后续要用白边将这些连通块连起来,因此这样做一定是不优的操作

得到图之后,白边实际上就是在缩完边双的图上跑一个生成树即可

如果最终得到的图连通,则已经获得了一个解,否则无解

const int maxn = 500010, maxm = 4000010;

int cnt = 1, ans, id;

int k,dfn[maxn], low[maxn], head[maxn], dcc[maxn],f[maxn],p1[maxn],p2[maxn];

struct edge{

    int to, nxt;

}e[maxm];

bool b[maxm];

//边双模板

vector<vector<int>> Ans;

void add(int u,int v){

    e[++cnt].to = v;

    e[cnt].nxt = head[u];

    head[u] = cnt;

}

void tarjan(int node, int in_edge){

    dfn[node] = low[node] = ++id;

    for (int i = head[node]; i; i = e[i].nxt){

        const int y = e[i].to;

        if (dfn[y] == 0){

            tarjan(y, i);

            if (dfn[node] < low[y]) b[i] = b[i ^ 1] = 1;

            low[node] = min(low[node], low[y]);

        }

        else if (i != (in_edge ^ 1)){

            low[node] = min(low[node], dfn[y]);

        }

    }

}

void dfs(int node, int ndcc){

    dcc[node] = ndcc;

    Ans[ndcc - 1].push_back(node);

    for (int i = head[node]; i; i = e[i].nxt){

        int y = e[i].to;

        if (dcc[y] || b[i]) continue;

        dfs(y, ndcc);

    }

}

const int N=1e6+7;

int f1[N],f2[N],f3[N],f4[N],k1,k2;

string s;

int find(int u){

    if(f[u]==u) return u;

    return f[u]=find(f[u]);

}

void solve(){

    int n,m;

    cin>>n>>m;

    for (int i = 1; i <= m; ++i){

        int f, t;

        cin>>f>>t>>s;

        //黑白边分开存储

        if(s[0]=='L'){

            k1++,f1[k1]=f,f2[k1]=t;

        }

        else{

            k2++,f3[k2]=f,f4[k2]=t;

            continue;

        }

        if (f == t) continue;

        add(f, t);

        add(t, f);

    }

    //只需要对黑边跑点双即可,但白边一起判断不影响

    for (int i = 1; i <= n; ++i){

        if (dfn[i] == 0) tarjan(i, 0);

    }

    for (int i = 1; i <= n; ++i){

        if (dcc[i] == 0){

            Ans.push_back(vector <int>());

            dfs(i, ++ans);

        }

    }

    //对得到的边双进行缩点

    for (int i = 0; i < ans; ++i){

        for (int j = 0; j < Ans[i].size(); ++j){

            f[Ans[i][j]]=Ans[i][0];

        }

    }

    //是在边双中的黑边存到答案的图中

    for(int i=1;i<=k1;i++){

        if(find(f1[i])==find(f2[i])){

            k++,p1[k]=f1[i],p2[k]=f2[i];

        }

    }

    //通过白边建生成树,并查集判连通加边

    for(int i=1;i<=k2;i++){

        if(find(f3[i])==find(f4[i])) continue;

        f[find(f3[i])]=find(f4[i]);

        k++,p1[k]=f3[i],p2[k]=f4[i];

    }

    //判断最后的图是否连通

    for(int i=2;i<=n;i++){

        if(find(i)!=find(1)){

            cout<<"NO\n";

            return;

        }

    }

    cout<<"YES\n"<<k<<'\n';

    for(int i=1;i<=k;i++){

        cout<<p1[i]<<' '<<p2[i]<<'\n';

    }

}

 

矩阵

对于矩阵是整行整列进行操作的,往往可以通过其中一行的元素值来确定下整体的操作

cf1977D

cf1980E

按照行元素进行排序,按照列元素进行排序,如果一个矩阵能通过操作变成另一个矩阵,那么这样排序过后得到的应该是同一个矩阵,要注意的是最开始排序的时候应该选择具有同一元素的一行/列,不能单单是对第0元素进行操作

int n,m;

cin>>n>>m;

int xx,yy;

vector<vector<int>> a(n,vector<int>(m));

for(int i=0;i<n;++i){

    for(int j=0;j<m;++j){

        cin>>a[i][j];

        if(a[i][j]==1) xx=j;

    }

}

vector<vector<int>> b(n,vector<int>(m,0));

for(int i=0;i<n;++i){

    for(int j=0;j<m;++j){

        cin>>b[i][j];

        if(b[i][j]==1) yy=j;

    }

}

sort(all(a),[&](vector<int>x,vector<int>y){

    return x[xx]<y[xx];

});

sort(all(b),[&](vector<int>x,vector<int>y){

    return x[yy]<y[yy];

});

vector<vector<int>> aa(m,vector<int>(n));

for(int j=0;j<m;++j){

    for(int i=0;i<n;++i){

        aa[j][i]=a[i][j];

    }

}

sort(all(aa),[&](vector<int>x,vector<int>y){

    return x[0]<y[0];

});

vector<vector<int>> bb(m,vector<int>(n));

for(int j=0;j<m;++j){

    for(int i=0;i<n;++i){

        bb[j][i]=b[i][j];

    }

}

sort(all(bb),[&](vector<int>x,vector<int>y){

    return x[0]<y[0];

});

for(int i=0;i<n;++i){

    for(int j=0;j<m;++j){

        if(aa[j][i]!=bb[j][i]){

            cout<<"NO\n";

            return;

        }

    }

}

cout<<"YES\n";

确定操作后对于另一个矩阵复制操作,判断操作完后是否是同一个矩阵

 

矩阵上的路径问题

cf 1980G

我们需要保留靠左下的方格,于是在路径上对我们有限制的是相同c最大的r,或者相同r最小的c

也就是让r尽可能大,c尽可能小,因此可以按r排序,求c的后缀min;也可以按c从小到大排序,那就是求r的前缀max

判断删去了某个限制节点的话可以通过暴力解决

void solve() {

    int n,m,k;

    cin>>n>>m>>k;

    vector<array<int,3>> f(k);

    for(int i=0;i<k;i++){

        int r,c;

        cin>>r>>c;

        c--;//为了方便,将c-1,这样后续计算的时候就直接表示左侧的列数了

        f[i]={c,r,i};

    }

    sort(f.begin(),f.end());//默认按照关键字的顺序进行排序

 

    i64 ans=0;

    vector<i64> a(k);

    //类似于找前缀max的思路,但实际上是对路径有影响的节点都进行了标记

    int x=0,y=0;//x表示已经限制到哪一行了

    vector<int> v(k);

    for(auto [c,r,i]:f){

        if(r>x){

            ans+=1ll*(r-x)*c;//对应的矩阵面积

            x=r;

            y=c;

            v[i]=1;//对于关键节点进行标记

        }

    }

    ans+=1ll*(n-x)*m;//末位处理

 

    for(int j=0;j<k;j++){

        auto [c,r,i]=f[j];

        if(!v[i]){

            continue;//不是影响路径的节点,答案不变

        }

        //只有删除了关键节点才有可能改变答案

        int L=j-1,R=j+1;

        //array并不是vector而是一个线性的数组

        //因此这里是在查找列位置距离j最近的关键节点

        while(L>=0 && !v[f[L][2]]){

            L--;

        }

        while(R<k && !v[f[R][2]]){

            R++;

        }

        int x= L>=0?f[L][1]:0;

        int y= L>=0?f[L][0]:0;

        int rx= R<k?f[R][1]:n;

        int ry= R<k?f[R][0]:m;//记录可能发生变化的子矩阵的左上和右下顶点

        i64 res=0;

        //减去这块区域原来被计算的面积

        res-=1ll*(r-x)*c;//只有左侧的面积是被记录的

        res-=1ll*(rx-r)*ry;

        //加上现在这块区域的面积,这样res就是变化量

        for(int u=j+1;u<R;u++){

            auto [c,r,_]=f[u];

            if(r>x){//和之前一样的操作

                res+=1LL*(r-x)*c;

                x=r;

                y=c;

            }

        }

        res+=1LL*(rx-x)*ry;

        a[i]=res;

    }

    cout<<ans<<'\n';

    for(int i=0;i<k;i++){

        cout<<a[i]<<' ';

    }

    cout<<'\n';

}

 

最大二分图匹配

 

最大独立集

一个图是另一个图的子图,并且这个图中的任意两点都不相邻,那么这个图就被称为是独立集

对于图G=(V,E),若E’ E且E’中任意两条不同的边都没有公共的端点,并且E’中任意一条边都不是自环,则E’是图G的一个匹配,也可以叫作边独立集,如果一个点是匹配中某条边的一个端点,则称这个点是被匹配的,否则称这个点是不被匹配的

即独立集是点的集合,而匹配是边的集合

最大独立集=n-最大匹配

最大匹配=最小点覆盖

最大独立集=n-最小点覆盖

最大团=补图的最大独立集

最大独立集=补图的最大团

 

树上最大独立集

P1352 没有上司的舞会

利用树形dp解决

dp[x][0]表示以x为根的子树,且x不选择的最大值;dp[x][1]表示x选择

int a[100005];

vector<vector<int>> adj(100005);

int vis[100005];

int dp[100005][2];

void solve(){

    memset(vis,0,sizeof(vis));

    memset(dp,0,sizeof(dp));

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    for(int i=1;i<=n-1;i++){

        int u,v;

        cin>>u>>v;

        adj[v].emplace_back(u);

        vis[u]++;

    }

    int root=0;

    for(int i=1;i<=n;i++){

        if(!vis[i]){

            root=i;

            break;

        }

    }

    auto dfs=[&](auto &&self,int x)->void{

        dp[x][0]=0;

        dp[x][1]=a[x];

        for(int i=0;i<adj[x].size();i++){

            int y=adj[x][i];

            self(self,y);

            dp[x][0]+=max(dp[y][0],dp[y][1]);

            dp[x][1]+=dp[y][0];

        }

    };

    dfs(dfs,root);

    cout<<max(dp[root][0],dp[root][1])<<'\n';

}

 

基环树最大独立集

基环树,也即环套树,简单的说就是在树上再加一条边,形如一个环,环上每个点都有一棵子树的形式(类似于缩点的思想),因此对于基环树的处理就是对树处理和对环处理

P1453(见基环树板块)

 

HDU P4830 Party

按照题目所述进行连边,对于基环树,断环进行dp,对于端点枚举其中一个是否选择,然后取两种dp情况的最大值

int a[100005];

int head[100005],nxt[100005],to[100005],tot=0;

int fa[100005],vis[100005];

ll dp[100005][2];

ll ans=0;

void add(int u,int v){

    to[++tot]=v;

    nxt[tot]=head[u];

    head[u]=tot;

}

void solve(){

    tot=0;

    memset(head,0,sizeof(head));

    memset(vis,0,sizeof(0));

    memset(dp,0,sizeof(dp));

    int n;

    cin>>n;

    for(int i=1;i<=n;i++){

        int x;

        cin>>x;

        add(x,i);

        fa[i]=x;

    }

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    int root;

    auto dfs=[&](auto &&self,int x)->void{

        vis[x]=1;

        dp[x][0]=0,dp[x][1]=a[x];

        for(int i=head[x];i;i=nxt[i]){

            int y=to[i];

            if(y!=root){

                self(self,y);

                dp[x][0]+=max(dp[y][1],dp[y][0]);

                dp[x][1]+=dp[y][0];

            }else{

                dp[y][1]=-inf;

            }

        }

    };

    auto find_circle=[&](int x){

        vis[x]=1;

        root=x;

        while(!vis[fa[root]]){//找环

            root=fa[root];

            vis[root]=1;

        }

        dfs(dfs,root);

        ll t=max(dp[root][0],dp[root][1]);

        vis[root]=1;

        root=fa[root];

        dfs(dfs,root);

        ans+=max({t,dp[root][0],dp[root][1]});

    };

    for(int i=1;i<=n;i++){

        if(!vis[i]){

            find_circle(i);

        }

    }

    cout<<ans<<'\n';

}

 

仙人掌最大独立集

如果某个无向连通图的任意一条边至多只出现在一条简单回路中,我们就称这张图是仙人掌图,简单回路是指图上不重复经过任何一个顶点的回路

 

与基环树不同的是,基环树问题中可能实际是一个基环树森林,但每个基环树保证只有一个环,但是在仙人掌图中,保证了只有一个连通图,但是可能有多个环

 

小C的独立集

由于仙人掌没有一条边是在两个环上,于是我们依然可以把其转化成树和若干条返祖边。

由于这里的返祖边有很多条,显然枚举端点状态时间是吃不消的。

由于一条返祖边会形成一个环,于是我们可以把dp过程分成树dp和环dp两部分

问题是最多能选择几个点,相当于每个点的权值都是1

首先如果无视所有回边,那么仙人掌图就是一个树,因此先可以进行一次树dp,即tarjan过程中的dp1

然后再找环进行环dp

对环的处理依然是断环成链,然后进行两次链dp

因为我们连的是无向边,在链式前向星的存储过程中,由同一条边得到的两条边的序号是相邻的,又因为我们实际的边的编号是从2开始的,因此同一条无向边得到的两条无向边的序号可以通过异或1得到,为了避免对同一条边的操作(或者说对父节点的操作)因此判断是否(i^1)==k,等价于tarjan求割点中的(如果v访问过(且u不是v的父亲),就不需要继续DFS了,一定有dfn[v]<dfn[u],而且这条边就是回边,low[u]=min(low[u], dfn[v]))

int tot=1,head[200005];

int fa[200005];

struct Edge{

    int to,nxt;

}e[200005];

inline void add(int u,int v){

    e[++tot].to=v;

    e[tot].nxt=head[u];

    head[u]=tot;

}

int dfn[200005],low[200005],idx=0;

int dp1[200005][2];

int dp2[200005][2];

void solve(){

    int n,m;

    cin>>n>>m;

    for(int i=1;i<=m;i++){

        int u,v;

        cin>>u>>v;

        add(u,v);

        add(v,u);

    }

    auto dp=[&](int u,int v)->void{

        int cnt=0;

        for(int i=v;i!=fa[u];i=fa[i]){//如果到fa[u]说明已经遍历完环中节点,将要退出环了

            dp2[++cnt][0]=dp1[i][0];//已经得到了对于环(链)上每个点的dp值,现在还要对这个环上的点是否选择进行判断

            dp2[cnt][1]=dp1[i][1];

        }

        for(int i=2;i<=cnt;i++){

            dp2[i][0]+=max(dp2[i-1][0],dp2[i-1][1]);

            dp2[i][1]+=dp2[i-1][0];

        }

        dp1[u][0]=dp2[cnt][0];

        cnt=0;

        for(int i=v;i!=fa[u];i=fa[i]){

            dp2[++cnt][0]=dp1[i][0];

            dp2[cnt][1]=dp1[i][1];

        }

        dp2[1][1]=-inf;//保证不选

        for(int i=2;i<=cnt;i++){

            dp2[i][0]+=max(dp2[i-1][0],dp2[i-1][1]);

            dp2[i][1]+=dp2[i-1][0];

        }

        dp1[u][1]=dp2[cnt][1];

    };

    auto tarjan=[&](auto &&self,int x,int k)->void{//类似tarjan求割点

        dfn[x]=low[x]=++idx;

        dp1[x][1]=1;

        for(int i=head[x];i;i=e[i].nxt){

            int y=e[i].to;

            if(!dfn[y]){

                fa[y]=x;

                self(self,y,i);

                low[x]=min(low[x],low[y]);

            }else if((i^1)!=k){//x不是y的父节点

                low[x]=min(low[x],dfn[y]);

            }

            if(low[y]>dfn[x]){//x,y不形成环

                dp1[x][0]+=max(dp1[y][0],dp1[y][1]);

                dp1[x][1]+=dp1[y][0];

            }

        }

        for(int i=head[x];i;i=e[i].nxt){

            int y=e[i].to;

            if(dfn[x]<dfn[y] && fa[y]!=x){//如果是被搜索过的点,不会被标记fa[y]=x

            //因此x,y是环中相邻的两点

                dp(x,y);//环dp

            }

        }

    };

    tarjan(tarjan,1,0);

    cout<<max(dp1[1][0],dp1[1][1])<<'\n';

}

 

cf1984E

 

构造

对于元素之间相互有关系的建边是核心思想,使问题关注的点可视化从而可能能用图论的方式解决,如骑士,是与不能一起存在的人连边;最少交换次数,是与应该所在位置的元素连边
CF612E

定义了排列p的平方运算是q[i]=p[p[i]],现在要求进行逆运算,即给出一个平方后的结果,要求找出原数组

对p[p[i]]进行拆分,实际上就是i->p[i],p[i]->p[p[i]],这样就似乎找到了对应关系,我们对原排列建图,边是(i,p[i]),即下标为i的与下标为p[i]的连一条边,显然,对原排列建图,我们最终会得到一堆环,并且各个环之间是相互独立的,还原时不存在强制性的顺序要求

那么此时的平方运算就会发现这是在原来图的基础上隔一个点连边(平方运算就是将两条边变成一条边),并且这样操作之后,奇数环仍然是一个奇数环,而偶数环会分裂成两个大小相等的环

因此题目就变成了给出经过操作后的环,将操作还原回去,对于奇数环,比对操作的过程,可以发现还原环就是将节点每隔一个放回去,而如果是偶数环,就是要将两个环进行合并

 

int n, p[MAXN], ans[MAXN], vis[MAXN];

void update(vector<int>c) {//根据之前建边的过程,再将环变回排列

    for (int i = 1; i < c.size(); i++) {

        ans[c[i - 1]] = c[i];

    }

    ans[c.back()] = c[0];

}

vector<vector<int>>cycles[MAXN];

vector<int>c;

void dfs(int u) {//搜索建环

    if (vis[u])return;

    vis[u] = 1;

    c.push_back(u);

    dfs(p[u]);

}

void solve() {

    cin >> n;

    for (int i = 1; i <= n; i++)ans[i] = i, cin >> p[i];

    for (int i = 1; i <= n; i++) {

        if (!vis[i]) {

            c.clear();

            dfs(i);

            cycles[c.size()].push_back(c);//根据环的大小进行存储,方便后续合并以及判断是奇数还是偶数环

        }

    }

    for (int sz = 1; sz <= n; sz++)if (sz % 2 == 0 && cycles[sz].size() % 2 == 1) {//不可能还原

        cout << "-1" << endl;

        return;

    }

    for (int sz = 1; sz <= n; sz++) {

        if (sz % 2 == 1) {

            for (auto c : cycles[sz]) {

                vector<int>nc(c.size());

                int id = 0;

                for (int v : c) {

                    nc[id%nc.size()] = v;

                    id += 2;//不要用c中的下标去计算nc中的下标,比较复杂,因为添加元素都是隔一个添加,因此直接对nc中的下标单独维护即可

                }

                update(nc);

            }

        }

        else {

            for (int i = 0; i < cycles[sz].size(); i += 2) {//两个合并成一个

                vector<int>c1 = cycles[sz][i], c2 = cycles[sz][i + 1];

                vector<int>nc;

                for (int i = 0; i < c1.size(); i++) {//隔一个放置直接体现在一次放两个

                    nc.push_back(c1[i]);

                    nc.push_back(c2[i]);

                }

                update(nc);

            }

        }

    }

    for (int i = 1; i <= n; i++)cout << ans[i] << " ";

    cout << endl;

}

 

cf789E

对于排列a[],b[],构造排列p,则有a[i]的元素值为p[a[i]],b[i]的元素值为p[b[i]],现在要使a[i]-b[i]的总和最大,输出最大值

显然这里a[i]与b[i]我们建立了相减的关系,因此对于a[i]与b[i]我们连一条边

例如对于a=1 5 4 3 2 6    b=5 3 1 4 6 2,在i=1的位置,将1,5连在一起,对于i=2将5,3连在一起,这样全部操作完毕后,我们得到了两个环,一个环包含1,5,4,3,一个环包含2,6,我们所需要的a[i]-b[i],就是环上相邻两个数差的绝对值之和,所以我们要做的就是对所有环上的节点进行赋值,如果环的长度为1,那么其对于答案不会有贡献,反之,我们可以按照一个最大值,一个最小值来给环赋值(贪心的考虑,这样得到的差值最大

特别的,如果是一个奇数长度的环,不论赋什么值都不会改变其贡献,因此我们可以先不放奇数环的最后一个值(可以在for循环中写作c.size() - c.size() % 2)

void dfs(int u) {//找环

    if (vis[u])return;

    vis[u] = 1;

    c.push_back(u);

    dfs(to[u]);

}

int cal() {//计算环的贡献

    if (c.size() == 1)return 0;

    vector<int>v;

    for (int i = 0; i < c.size() - c.size() % 2; i++) {

        if (i % 2 == 0)v.push_back(mx--);

        else v.push_back(mi++);

    }

    int res = 0;

    for (int i = 1; i < v.size(); i++)res += abs(v[i] - v[i - 1]);

    res += abs(v.back() - v[0]);

    return res;

}

 

上海市赛2024M

void solve(){

    //总共有两张图,其中每个点出度和入度不超过1,即每张图都是若干条链并且两张图不能相交

    //构建一个二维的图,第一个只能往右,第二个只能往下

    //即尽可能把点按照从左到右,从上到下的顺序列出一个正方形

    int n;

    cin>>n;

    int s=ceil(sqrt(n));

    cout<<n-s<<'\n';//对于每一张图,填充满的正方形内只有每条边的最后一个点没有连边

    //由于(n+1)/2

    int cnt=n-s;

    for(int i=0;i<n-1;i++){

        if(i%s!=s-1 && cnt>0){

            cnt--;

            cout<<i+1<<' '<<i+2<<'\n';

        }

    }

    for(int i=0;i<n-s;i++){//竖着连的情况

        cout<<i+1<<' '<<i+s+1<<'\n';

    }

}

 

cf891B

    每次选择子序列,和一定不同,n很小

    首先至少要保证对应的每一个元素都不同,并且不是两两交换位置,或者一个区间里交换,于是要进行整体的交换同时这个交换必须是不能被分割成小区间的交换,考虑整体移动,如

    1 2 3 4 5 6

    2 3 4 5 6 1

    要每一个子序列都不同如果能保证每一个都满足小于关系,一定不同,这样就让最大和最小匹配,因为每个数都不同但总和是相同的,因此最大和最小的差需要整个序列中的其他元素全部被选择时才能补上,这样包含最大元素的情况一定满足,并且其他情况即每个元素都满足大小关系,因此所有情况都满足

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    vector<int> ans(n);

    for(auto &i:a){

        cin>>i;

    }

    vector<int> idx(n,0);

    iota(all(idx),0);

    sort(all(idx),[&](int x,int y){

        return a[x]<a[y];

    });

    // for(int i=0;i<n;i++){

    //     cout<<idx[i]<<' ';

    // }

    sort(all(a));

    for(int i=1;i<n;i++){

        ans[idx[i]]=a[i-1];

    }

    ans[idx[0]]=a.back();

    for(auto &i:ans){

        cout<<i<<' ';

    }

    cout<<'\n';

}

 

Cf 2068 A

由n个候选人,和若干个(未知的)投票,每个投票都是一个排列组合,如果a在b之前,那么就说明该投票人认为a比b更适合

给定m个数据对,其中(a,b)表示在所有的投票中,有超过半数的投票排列中,a在b之前

要求给出所有的投票使得数据对中的关系都能得到满足

结论是一定可以构造出合法的方案的

令{a,b}(ans)表示在所有投票中,认为a比b更合适减去认为b比a更合适的数量,只要保证每个给定的数据对{a,b}都满足{a,b}(ans)大于0,那么就是满足条件的投票序列

具体构造中,对每一对{a,b},都可以构造序列an1和an2,使得{a,b}({an1,an2))=2,其他所有的元素对{x,y}!={a,b}都有{x,y}({an1,an2})=0

那么显然,只要将每一对构造出来的an1,an2,加到ans中,ans这个投票序列就是符合要求的

同时可以发现,这样总共的投票序列大小最大为2*C(n,2),也就是对每个元素对都进行构造,这个数是小于n的平方的

void solve() {

    int n,m;

    cin>>n>>m;

    vector g(n,vector<bool>(n));

    vector<vector<int>> ans;

    for(int i=0;i<m;i++){

        int a,b;

        cin>>a>>b;

        a--,b--;

        g[a][b]=true;

        if(g[b][a]){

            cout<<"NO\n";

            return;

        }

        vector<int> p{a,b};

        for(int x=0;x<n;x++){

            if(x!=a && x!=b){

                p.push_back(x);

            }

        }

        ans.push_back(p);

        swap(p[0],p[1]);

        reverse(p.begin(),p.end());

        ans.push_back(p);

    }

    cout<<"YES\n";

    cout<<ans.size()<<"\n";

    for(auto p:ans){

        for(auto x:p){

            cout<<x+1<<" \n"[x==p.back()];

        }

    }

}

 

 

杭电24多校2 1001

用到的函数:

minmax函数参数和返回值:

a和b是要比较的两个值。

返回一个pair对象,包含了最小值和最大值,其中最小值在前,最大值在后。

minmax_element函数参数和返回值:

first和last是迭代器,表示要搜索的区间。

返回一个pair对象,其中第一个迭代器指向最小值,第二个迭代器指向最大值

因为要让输出的边字典序最小,因为连边的节点并不固定,因此可以人为规定度数越多的点标号越小,因此要让节点1的度数尽可能多,相同时要让节点2的度数尽可能多,进而可以贪心的考虑将1,2,...,n/3作为鸡爪的中心点

因此,除了1之外的所有中心点都只连接3条边,同时每个节点在连接的时候都优先和小节点连边

为了保证小节点连边的正确性,逆序遍历中心节点去加边

void solve(){

    int n;

    cin>>n;

    if(n<=2){

        for(int i=1;i<=n;i++){

            cout<<1<<' '<<i+1<<'\n';

        }

        return;

    }

    int m=n/3;

    vector<int> a(m+1,3);

    a[1]+=n%3;//因为字典序要最小,因此多余的节点全部与1相连

    set<pair<int,int>> s;

    //让1,2,3...n/3成为中心点

    //因为只有中心点会被计入使用,因此每个点都优先和小的连接

    for(int x=m;x>=1;x--){

        //从小到大连边

        for(int y=1;a[x]>0;y++){

            if(x!=y && !s.count(minmax(x,y))){

                //字典序小的在前

                s.insert(minmax(x,y));

                a[x]--;

            }

        }

    }

    for(auto [x,y]:s){

        cout<<x<<' '<<y<<'\n';

    }

}

 

牛客24多校8  J

构造一个长度为n的排列,满足恰有m个长度为3的子区间满足区间中的数可以是三角形的三边长度(a+b>c,a+c>b...)

从边界情况入手,记d=n/3

有一个k=0的构造(最小),形如

[d,2d,3d,d-1,2d-1,3d-1...1,d+1,2d+1]

有一个k=n-3的构造(最大),形如[1,2,...n]

k=n-2无解,因为对于整数x<y,则1+x<=y,因此含1的一定非法

因此,可以将n-k个数用k=0的构造方法,后k个数直接放到某一边

如果有1,又要满足三角形的三边长度关系,那么有1+a>b,以及1+b>a,那么只有可能a=b,但因为是排列,不能选出两个一样的数,因此这种情况是不可能的,于是如果一个子区间有1,那一定不是一个能够构成三角形的区间

也就是构造出长度为(n-m)的序列,并且不能构成三角形

然后在末尾按x+1,x+2,x+3的顺序(三个数一组构造)加入m个数,就能保证能凑出m个三角形

于是问题在于给定x,构造一个[1,x]排列使得不能构成三角形

(以x=9为例)

当x%3==0时 可以构造成1,4,7  2,5,8   3,6,9

当x%3==1时 可以直接将10放在最前面,这样一定构造不出  10,1,4,7,2,5,8,3,6,9

当x%3==2时 再将11放在最前面,可以通过前面含1必然不成立或者通过大小关系得到

或者也可以将11放在最后面
void solve(){

    int n,m;

    cin>>n>>m;

    if(m>n-3){

        cout<<-1<<'\n';

        return;

    }

    int k=n-m;

    vector<int> ans;

    if(k%3>0){

        ans.emplace_back(k/3*3+1);

    }

    int l=k/3;

    for(int i=l;i>0;i--){

        ans.emplace_back(i);

        ans.emplace_back(i+l);

        ans.emplace_back(i+2*l);

    }

    if(k%3==2){

        ans.emplace_back(k);

    }

    for(int i=n-m+1;i<=n;i++){

        ans.emplace_back(i);

    }

    if ((n-m)%3==1 && m>0) swap(ans[0], ans[n-m]);

    for(int i:ans){

        cout<<i<<' ';

    }

    cout<<'\n';

}

 

CCPC2024 重庆 I

构造出一种操作方案使得最终的结果最大

操作是在数组中任选两个数,删除这两个数,将这两个数的乘积或和写入数组中,重复这样操作直至数组中只剩下最后一个数

void solve(){

    vector<int> a(10);

    for(int i=0;i<9;i++){

        cin>>a[i];

    }

    while(a[0]>=2 && a[0]>a[1]){ //?!

        a[0]-=2;

        a[1]++;

    }

    for(int i=1;i<9;i++){

        int tmp=min(a[0],a[i]);

        a[0]-=tmp;

        a[i]-=tmp;

        a[i+1]+=tmp;

    }

    i64 ans=1ll;

    for(int i=0;i<10;i++){

        if(a[i]){

            (ans*=fp(i+1,a[i],mod))%=mod;

        }

    }

    cout<<ans<<'\n';

}

 

CCPC2024 重庆 C

string ans[8];

void solve(){

    int n;

    cin>>n;

    string fa,fb,a,b;

    cin>>a>>b;

    ans[1]=a;

    ans[7]=b;

    for(auto i:a){

        if(i=='.') fa+='#';

        else fa+='.';

    }

    for(auto i:b){

        if(i=='.') fb+='#';

        else fb+='.';

    }

    string std1(n,'#');

    if(std1==a&&std1!=b||std1==b&&std1!=a){

        cout<<"No"<<'\n';

        return ;

    }

    if(std1==a&&std1==b){

        cout<<"Yes"<<'\n';

        for(int i=1;i<=7;i++){

            cout<<std1<<'\n';

        }

        return;

    }

    ans[2]=fa;

    ans[6]=fb;

    string ss(n,'.');

    string s1=ss;

    ans[3]=s1;

    int pos1=0,pos2=0;

    for(int i=0;i<n;i++){

        if(((i>0&&ans[2][i-1]=='#')||(i<n-1&&ans[2][i+1]=='#'))&&ans[2][i]=='.'){

            ans[3][i]='#';

            pos1=i;

            break;

        }

    }

    ans[5]=s1;

    for(int i=0;i<n;i++){

        if(((i>0&&ans[6][i-1]=='#')||(i<n-1&&ans[6][i+1]=='#'))&&ans[6][i]=='.'){

            ans[5][i]='#';

            pos2=i;

            break;

        }

    }

    ans[4]=s1;

    if(pos1==pos2){

        ans[4][pos1]='#';

    }else if(abs(pos1-pos2)==1){

        ans[4][pos1]='#';

    }else{

        int min1=min(pos1,pos2);

        int max1=max(pos1,pos2);

        for(int i=min1+1;i<max1;i++){

            ans[4][i]='#';

        }

    }

    cout<<"Yes"<<'\n';

    for(int i=1;i<=7;i++){

        cout<<ans[i]<<'\n';

    }

}

 

 

贪心构造

cf1948E

题意:给定N个点与整数k,给每个点赋一个权值ai,使得ai是一个1~N的排列,对于|i-j|+|ai-aj|<=k的点对(i,j)有条边,求可以使得分成团数最小的ai的排列

要构造出团,因此首先去分析这个团可能有什么样的性质

团的大小:一个团的最大大小不能超过k,否则对于这其中的第1号节点和第k+1号节点,它们之间的运算显然不会满足点编号差+权值差小于等于k

又因为我们最终是要得到团数最小的构造方案,因此对于总结点数相同的情况下,我们显然是要让每个团的大小得到尽可能充分的利用

为此,我们要考虑放进一个团中的节点;想到如果一个团中的节点编号连续,那么对于相同的ai分配,这样子的分配使得|i-j|减小,那么会给于之后的分配更多的可能

同理,也可以发现对于一个团中的节点,所有节点的权值排序后应当是连续的(因为节点编号和节点权值实质上是等价的,因此在考虑具体安排时,二者可以替换看待)

又因为团中计算方式是差值,因此对于团中的节点权值,如果各节点的权值同时加上t,那么不影响最后是否连边的判定结果

因此最佳的划分的方案就是将1~N个节点划分成若干个部分,每个部分的大小为k,每个部分内的编号连续,并且节点权值的值域与节点编号的值域相等

因此接下来需要考虑的就是在k的限制下,判断图是否是一个团

可以证明令ai=m/2+1-i i<=m/2;m+m/2+1-i  时满足

(对整个区间根据区间中点分成两部分,每个部分中,顶点的索引越大,得到的整数越小)

for(int i=0;i<n;i++){

        a[i]=i+1;

        c[i]=i/k+1;

        //a用来表示节点编号,c用来表示团的编号

    }

    int q=*max_element(c.begin(),c.end());//最小要分成几块

    for(int i=1;i<=q;i++){

        int l=find(c.begin(),c.end(),i) - c.begin();

        int r=c.rend()-find(c.rbegin(),c.rend(),i);

        int m=(l+r)/2;//根据中间点分成两块

        reverse(a.begin()+l,a.begin()+m);

        reverse(a.begin()+m,a.begin()+r);

        //节点权值的值域与区间编号相同

    }

 

牛客24多校2 A

题意:构造一个n*m的平面,包含两种类型的地板

地板边缘中心点相连的曲线合并成一条曲线

问能否构造出仅包含恰好k条曲线的地板,同时给出有一个位置的地板是固定的

因为形状的特殊性,无论n,m的数值以及平面的结构,从整个二维平面边缘进入的曲线最终一定会从整个二维平面边缘出去,不可能进入内部的任意一个环

边缘的线头一共有2(n+m)个,因此这个类型的曲线数目是n+m个

因此对于一个大小为n*m的平面,其曲线总数是可以计算的,一共是n+m+平面结构内部环的个数

于是需要考虑的是如何构造环,如果没有环,显然可以用全A或者全B进行构造

考虑要构造特定K个环的结构

首先显然构造单位圆 的方式是最优的,可以用最小的数量构造出一个环

因此首先根据固定的一块,可以按照相邻相反贪心得到左上角地板的类型,以及可行解的答案区间

如果有解,可以根据左上角类型按照AB交替的方式进行填充,来构造最多的单位圆

然后从上到下,从左到右开始全填A或者B,覆盖单位圆,直到满足答案

vector<string> get(int n,int m,int k,int x,int y,char t){

    if(t=='B'){

        auto ans=get(n,m,k,x,m-1-y,'A');//翻转之后就可以当做t是A

        for(auto &s:ans){

            reverse(all(s));

            for(auto &x:s){

                x^='A'^'B';//通过字符的异或,A和B得以相互转化

            }

        }

        return ans;

    }

    vector ans(n,string(m,'A'));

    int p=0;

    if((x+y)%2==1){//通过固定的板块判断左上角放置的类型

        p=1;

    }

    //在二维平面上不相邻放置,位置坐标(i,j)

    //i+j的奇偶性始终相同

    for(int i=1;i<n;i++){

        for(int j=1;j<m;j++){

            if((i+j)%2==p && k>0){

                k--;

                ans[i-1][j]=ans[i][j-1]='B';

                //生成一个单位圆

            }

        }

    }

    if(k>0){//仍无法满足

        return {};

    }

    return ans;

}

 

void solve(){

    int n,m,k;

    cin>>n>>m>>k;

    int x,y;

    char t;

    cin>>x>>y>>t;

    x--;y--;

    k-=n+m;

    if(k<0){

        cout<<"NO\n";

        return;

    }

    int max=((n-1)*(m-1)+1)/2;

    if(k>max){

        cout<<"NO\n";

        return;

    }

    auto ans=get(n,m,k,x,y,t);

    if(ans.empty()){

        cout<<"NO\n";

        return;

    }

    cout<<"YES\n";

    for(int i=0;i<n;i++){

        cout<<ans[i]<<'\n';

    }

}

 

 

最近公共祖先(LCA)(T2846,T1483):

离线算法:Tarjan,把询问挂在端点上,先对每个点dfs一下,结束的时候并查集fa[i]变成原树父亲,然后一个询问当访问到dfs序较大的那个点时询问较小那个点的并查集

倍增算法:容易理解和实现,支持动态的树(增加叶子),比较适合询问次数少的情况;但占用空间较大,时间复杂度与询问次数相关

RMQ+ST:单次询问是O(1)的,在询问次数多时,该算法较好;但占用空间较大,算术实现相比倍增法略复杂

Tarjan:时间复杂度最优,但只适用于离线情况(已知要查找若干组,并且只需要计算最后的结果,T2646)

树上倍增算法(树节点的第k个祖先):

预处理出每个节点的第2i个祖先节点,即第2,4,8...个祖先节点(注意 x的第 1个祖先节点就是parent[x])。由于任意k可以分解为若干不同的2的幂(例如 13=8+4+1),所以只需要预处理出这些2i祖先节点,就可以快速地到达任意第 k个祖先节点。

例如 k=13=8+4+1=1101(2),可以先往上跳8步,再往上跳4步和1步;也可以先往上跳1步,再往上跳4步和8步。无论如何跳,都只需要跳3次就能到达第13个祖先节点

在构造函数TreeAncestor中,预处理出每个节点x的第2i个祖先节点,记作 pa[x][i](若第2i个祖先节点不存在,则pa[x][i]=−1)

class TreeAncestor {

    vector<vector<int>> pa;

public:

    TreeAncestor(int n, vector<int> &parent) {

        int m = 32 - __builtin_clz(n); // n 的二进制长度

        pa.resize(n, vector<int>(m, -1));

        for (int i = 0; i < n; i++)

            pa[i][0] = parent[i];

        for (int i = 0; i < m - 1; i++)

            for (int x = 0; x < n; x++)

                if (int p = pa[x][i]; p != -1)

                    pa[x][i + 1] = pa[p][i];

    }

 

    int getKthAncestor(int node, int k) {

        int m = 32 - __builtin_clz(k); // k 的二进制长度

        for (int i = 0; i < m; i++) {

            if ((k >> i) & 1) { // k 的二进制从低到高第 i 位是 1

                node = pa[node][i];

                if (node < 0) break;

            }

        }

        return node;

    }

 

    // 另一种写法,不断去掉 k 的最低位的 1

    int getKthAncestor2(int node, int k) {

        for (; k && node != -1; k &= k - 1) // 也可以写成 ~node

            node = pa[node][__builtin_ctz(k)];

        return node;

    }

};

 

如何计算树上任意两点x和y的最近公共祖先 lca

设节点 i的深度为 depth[i]。这可以通过一次 DFS 预处理出来。

假设 depth[x]≤depth[y](否则交换两点)。我们可以先把更靠下的y更新(替换)为y的第 depth[y]−depth[x]个祖先节点,这样x和y就处在同一深度了。

如果此时 x=y,那么x就是 lca。否则说明 lca在更上面,那么就把x和y一起往上跳。

由于不知道lca的具体位置,只能不断尝试,先尝试大步跳,再尝试小步跳。设i=log2n,循环直到 i<0。每次循环:

如果x的第 2i个祖先节点不存在,即 pa[x][i]=−1说明步子迈大了,将i减 1,继续循环。

如果 x的第2i个祖先节点存在,且 pa[x][i]≠pa[y][i],说明 lca在pa[x][i]的上面,那么更新x为 pa[x][i],更新 y为pa[y][i],将i减1,继续循环。否则,若pa[x][i]=pa[y][i],那么 lca可能在 pa[x][i]下面,由于无法向下跳,只能将i减 1,继续循环。

上述做法能跳就尽量跳,不会错过任何可以上跳的机会。所以循环结束时,x与 lca只有一步之遥,即 lca=pa[x][0]

也可以用二分来理解上述算法。在x到根节点的这条路径上猜一个点z当作 lca,且x与z相距2i步。那么把x和y同时向上跳2i步,如果x≠y,就说明lca在z的上面,否则lca要么是z,要么在z的下面。这样一种二段性既说明了二分的正确性,又说明了每次上跳之后,步长一定要减半(类比二分查找,把搜索的区间长度减半)(利用二分的思路就是先算步长,再减步长)

int get_lca(int x, int y) {

        if (depth[x] > depth[y])

            swap(x, y);

        // 使 y 和 x 在同一深度

        y = get_kth_ancestor(y, depth[y] - depth[x]);

        if (y == x)

            return x;

        for (int i = pa[x].size() - 1; i >= 0; i--) {

            int px = pa[x][i], py = pa[y][i];

            if (px != py) {

                x = px;

                y = py;

            }

        }

        return pa[x][0];

    }

求一个节点到另一个节点的路径时,往往要利用LCA(即路径在树上是“拐弯”的,由一个公共根节点的一个子树到另一个子树)(depth[a]−depth[lca])+(depth[b]−depth[lca])

 

RMQ求lca

对一棵树进行dfs,无论是第一次访问还是回溯,每次到达一个节点时都将编号记录下来,这样可以得到一个长度为2n-1的序列,这个序列被称为这棵树的欧拉序列(如果是奇数次在序列中,放上左括号,否则放上右括号,可以发现这是一个括号序列)

并且将u在欧拉序列中第一次出现位置编号记为pos(u),欧拉序列的深度记为depth,每个元素对应的真实节点记录为dfn

有了欧拉序列,Lca问题可以在线性时间中转化为RMQ问题

具体表现为u和v的lca是depth序列中pos(u)~pos(v)区间中的最小值

因为从u走到v过程中一定会经过lca(u,v),但不会经过lca(u,v)的祖先,同样也不会经过兄弟节点,因此从u走到v过程中经过的深度最小的节点就是Lca(u,v)

同时用dfs计算欧拉序列的时间复杂度是O(n),并且欧拉序列的长度也是O(n),因此这种方式在RMQ算法复杂度也为线性甚至常数的情况下,整体复杂度是线性的

void solve(){

    int n,q;

    cin>>n>>q;

    vector<vector<int>> adj(n);

    for(int u=1;u<n;u++){

        int v;

        cin>>v;

        adj[v].emplace_back(u);

        adj[u].emplace_back(v);

    }

    vector<int> dfn(n<<1),depth(n<<1),pos(n);

    int tot=0;

    auto dfs=[&](auto self,int x,int dep,int fa)->void{

        // cout<<x<<' '<<tot<<'\n';

        pos[x]=tot;

        dfn[tot]=x;

        depth[tot++]=dep;

        for(auto y:adj[x]){

            if(y==fa) continue;

            self(self,y,dep+1,x);

            // cout<<x<<' '<<tot<<'\n';

            dfn[tot]=x;

            depth[tot++]=dep;

        }

    };

    dfs(dfs,0,0,-1);

    // cout<<'\n';

    // for(int i=0;i<=tot;i++){

    //     cout<<dfn[i]<<' ';

    // }

    RMQ rmq(depth);

    auto LCA=[&](int u,int v)->int{

        int l=pos[u],r=pos[v];

        if(l>r) swap(l,r);

        return dfn[rmq(l,r)];

        //rmq返回这段区间中的最小值的下标

    };

    for(int i=1;i<=q;i++){

        int u,v;

        cin>>u>>v;

        cout<<LCA(u,v)<<'\n';

    }

}

 

 

Tarjan求lca

Tarjan算法是一种离线算法,需要使用并查集记录某个结点的祖先结点。做法如下:

首先接受输入边(邻接链表)、查询边(存储在另一个邻接链表内)。查询边其实是虚拟加上去的边,为了方便,每次输入查询边的时候,将这个边及其反向边都加入到 queryEdge 数组里。

然后对其进行一次DFS遍历,同时使用visited 数组进行记录某个结点是否被访问过、parent 记录当前结点的父亲结点。

其中涉及到了回溯思想,我们每次遍历到某个结点的时候,认为这个结点的根结点就是它本身。让以这个结点为根节点的 DFS 全部遍历完毕了以后,再将 这个结点的根节点设置为 这个结点的父一级结点。

回溯的时候,如果以该节点为起点,queryEdge 查询边的另一个结点也恰好访问过了,则直接更新查询边的 LCA 结果。

最后输出结果

  并查集模版(qs为待查询的边)

vector<int> root(n);

        iota(root.begin(), root.end(), 0);

        function<int(int)> find = [&](int x) -> int { return root[x] == x ? x : root[x] = find(root[x]); };

 

        vector<int> diff(n), father(n), color(n);

        function<void(int, int)> tarjan = [&](int x, int fa) {

            father[x] = fa;

            color[x] = 1; // 递归中

            for (int y: g[x]) {

                if (color[y] == 0) { // 未递归

                    tarjan(y, x);

                    root[y] = x; // 相当于把 y 的子树节点全部 merge 到 x

                }

            }

            for (int y: qs[x]) {

                // color[y] == 2 意味着 y 所在子树已经遍历完

                // 也就意味着 y 已经 merge 到它和 x 的 lca 上了

                // 此时 find(y) 就是 x 和 y 的 lca

                if (y == x || color[y] == 2) {

                    diff[x]++;

                    diff[y]++;

                    int lca = find(y);

                    diff[lca]--;

                    int f = father[lca];

                    if (f >= 0) {

                        diff[f]--;

                    }

                }

            }

            color[x] = 2; // 递归结束

        };

      tarjan(0,-1);

 

cf33D

其中一种做法是

对于起点和终点,分别对与各个圆进行判断,即该点是否在这个圆的内部,只有起点和终点只有一个在某一个圆内部,这个圆才是需要被穿过的,只有一个点在内部可以对两个check值进行异或实现

另一种做法:如果圆 A 包含圆 B ,且A 是包含圆 B 的圆中半径最小的,则将 A 、 B 连边,容易证得得到的是一个森林,而最外层的圆自然地连接到了虚根0号结点。然后我们计算出每一个点属于哪一个圆(这里的属于定义为:圆 A 包含点 B ,且圆 A 是包含点 B 的圆中半径最小的),查询的时候就通过 LCA 和树的深度求得点所属圆在树上的最短距离

为了具体判断圆A是包含圆B的圆中半径最小,可以先将所有圆以半径为关键字升序排序,并以下标为序搜索每一个圆

const int N=1e3+5,M=1E3+5;

struct sss{

    int q,w,e,nxt;

}a[M*2];

struct ssr{

    int x,y,r;

}O[N];

int head[N],p[N][25],d[N],n,T,cnt,mp[N][3],m,num[N],vis[N],vi[N],z[N],v[N];

void adde(int q,int w){

    a[++cnt].q=q;

    a[cnt].w=w;

    a[cnt].nxt=head[q];

    head[q]=cnt;

}

bool cmp(ssr a,ssr s){

    return a.r<s.r;

}

void dfs(int q,int fa){

    d[q]=d[fa]+1;

    p[q][0]=fa;

    bool o=1;

    for(int i=1;i<=20;i++)p[q][i]=p[p[q][i-1]][i-1];

    for(int i=head[q];i;i=a[i].nxt){

        if(a[i].w!=fa){

            o=0;

            dfs(a[i].w,q);

        }

    }

    if(o)vi[q]=1;

}

int LCA(int a,int s){//倍增

    if(d[a]>d[s])swap(a,s);

    for(int i=20;i>=0;i--){

        if(d[a]<=d[s]-(1<<i))s=p[s][i];

    }

    if(a==s)return a;

    for(int j=20;j>=0;j--){

        if(p[a][j]!=p[s][j])a=p[a][j],s=p[s][j];

    }

    return p[a][0];

}

bool check(int i,int j){

    return O[j].r>=O[i].r&&(long long)((long long)(O[i].x-O[j].x)*(O[i].x-O[j].x)+(long long)(O[i].y-O[j].y)*(O[i].y-O[j].y))<=(long long)O[j].r*O[j].r;

}

void df(int q){//搜索第q个圆

    if(v[q])return;//如果被搜索过了则return

    v[q]=1;//标记

    for(int i=1;i<=m;i++)//从小到大循环每一个圆

    {

        if(check(q,i)&&i!=q)//check(q,j)的意思是圆q能被圆i包含,下同

        {//如果圆q被圆i包含且i不等于q

            adde(q,i);

            adde(i,q);//加边

            df(i);//搜索地i个圆

            return;

        }

    }

}//这样就可以保证圆i是包含圆q的圆中半径最小的

int main(){

    scanf("%d%d%d",&n,&m,&T);

    for(int i=1;i<=n;i++)scanf("%d%d",&mp[i][1],&mp[i][2]);

    for(int i=1;i<=m;i++)scanf("%d%d%d",&O[i].r,&O[i].x,&O[i].y);

    sort(O+1,O+m+1,cmp);//排序

    for(int i=1;i<=m;i++){

        if(!v[i])df(i);

    }

    for(int i=1;i<=m;i++){//循环每一个圆

        bool p=1;

        for(int j=i+1;j<=m;j++){

            if(check(i,j)) p=0;//如果i能被其他圆包含,则一定不是最外层的圆

        }

        if(p) adde(i,0),adde(0,i);//加边

    }

    dfs(0,0);//求LCA

    //确定一个点属于哪个圆

    for(int i=1;i<=m;i++){//循环圆

        for(int j=1;j<=n;j++){//循环点

            if(!z[j]&&(long long)((long long)(O[i].x-mp[j][1])*(O[i].x-mp[j][1])+(long long)(O[i].y-mp[j][2])*(O[i].y-mp[j][2]))<=(long long)O[i].r*O[i].r) z[j]=i;//建立关系

        }//因为圆是按半径排列的,所以如果圆i包含了点j,那么作为i祖先的圆一定也包含,这样保证了没有重复建立关系

    }  

    for(int i=1;i<=n;i++)if(!z[i])z[i]=0;

    //如果没有圆与这个点建立关系就代表这个点与虚根相连

    while(T--){

        int q,w;

        scanf("%d%d",&q,&w);

        printf("%d\n",d[z[w]]+d[z[q]]-2*d[LCA(z[q],z[w])]);

        //找最小次数并输出

    }

}

 

 

tarjan求割点:
在无向连通图中,如果将其中一个点以及所有连接该点的边去掉,图就不再连通,那么这个点就叫做割点

关于dfs树:从1开始搜索整个图,对于每个点相邻的顶点,按照顶点编号从小到大搜索,对于图中的点依次被(第一次)访问的顺序就称为dfs序,最终得到一棵dfs树,其中的边分为绿边和回边,通过回边可以从一个点返回到之前访问过的点

而tarjan算法首先就是选定一个根节点,从该根节点开始遍历整个图

对于根节点,判断是不是割点很简单——计算其子树数量,如果有2棵即以上的子树,就是割点。因为如果去掉这个点,这两棵子树就不能互相到达

注意此处根节点的子树要保证这些子树是相互独立的,没有根节点就不能相互到达,因此n不一定等于与根节点相邻的顶点数,因此在程序中加入了vis[v]为false的条件,因为如果(u, v1)和(u, v2)在一棵子树里,对v1进行DFS,一定能去到v2,vis[v2]就会为true,此时就不会将v2作为子树的一个根节点了

而对于其他点,我们维护两个数据:dfn和low,dfn表示这个点的dfs序,而low表示该点及其子树中的点,通过非父子边(回边),能够回溯到的最早(dfn最小)的点的dfn值,对于边(u,v),如果low[v]>=dfn[u],此时u就是割点

对于边(u, v),如果low[v]>=dfn[u],即v即其子树能够(通过非父子边)回溯到的最早的点,最早也只能是u(dfs序大于u,说明通过回边到达的点在u的子树中,即子树中有部分点是能构成一个连通块的,而这个连通块与上面建立联系全部依靠u),要到u前面就需要u的回边或u的父子边。也就是说这时如果把u去掉,u的回边和父子边都会消失,那么v最早能够回溯到的最早的点,已经到了u后面,无法到达u前面的顶点了,此时u就是割点

而关于low的计算

假设当前顶点为u,则默认low[u]=dfn[u],即最早只能回溯到自身。

有一条边(u, v),如果v未访问过,继续DFS,DFS完之后,low[u]=min(low[u], low[v]);(根据定义,low表示该点及其子树中的点,通过非父子边(回边),能够回溯到的最早(dfn最小)的点的dfn值,v没访问过,说明v是u中的子树节点,或者说low不允许沿着父子边向上走,但允许向下走)

如果v访问过(且u不是v的父亲),就不需要继续DFS了,一定有dfn[v]<dfn[u],而且这条边就是回边,low[u]=min(low[u], dfn[v])

 

// v:当前点 r:本次搜索树的root

void tarjan(ll u, ll r) {

         dfn[u] = low[u] = ++deep;

         ll child = 0;

         for (unsigned i = 0; i < g[u].size(); i++) {

                  ll v = g[u][i];

                  if (!dfn[v]) {

                          tarjan(v, r);

                          low[u] = min(low[u], low[v]);

                          if (low[v] >= dfn[u] && u != r)cut[u] = 1;//不是根而且他的孩子无法跨越他回到祖先

                          if (r == u)child++; //如果是搜索树的根,统计孩子数目

                  }

                  low[u] = min(low[u], dfn[v]);//已经搜索过了

         }

         if (child >= 2 && u == r)cut[r] = 1;

}

 

 

类似的,我们可以用tarjan去求强连通分量

开个栈记录当前路径上的点

如果在这个点之后被遍历到的点已经能与其下面的一部分点(也可能就只有他一个点)已经构成强连通分量,即它已经是最大的,那么把它们一起从栈里弹出来就行了。

所以最后处理到点u时如果u的子孙没有指向其祖先的边,那么它之后的点肯定都已经处理好了,所以就可以保证栈里留下来u后的点都是能与它构成强连通分量的

关键在于对low的计算,在强连通分量中,low的定义是在  u的子树中能够回溯到的最早的已经在栈中的结点(即最大的祖先节点),对于已经搜索过的点,low的计算是low[v] = min(low[v], low[id])

如果遍历到的这个点已经被遍历过了,那么看它当前有没有在stack[ ]里,如果有那么low[u]=min{low[u],low[v]},如果已经被弹掉了,说明无论如何这个点也不能与u构成强连通分量,因为它不能到达u;如果还在栈里,说明这个点肯定能到达u,同样u能到达他,他俩强联通

#define MAX 10005

#define ll long long

 

vector<ll> g[MAX];

ll color[MAX], vis[MAX], stack[MAX], dfn[MAX], low[MAX], cnt[MAX];

//deep:节点编号 top:栈顶  sum:强连通分量数目

ll deep, top, sum, res = 0;

 

void tanjan(ll v) {

         dfn[v] = ++deep;

         low[v] = deep;   //(1)初始化dfn数组,同时将low设置为相同值

         vis[v] = 1;

         stack[++top] = v;//(2)入栈,作为栈顶元素,同时更新vis数组

 

         for (unsigned i = 0; i < g[v].size(); i++) {//(3)遍历所有可能到达的点

                  ll id = g[v][i];

                  if (!dfn[id]) {//如果这个点从没访问过,则先访问它,再用它更新low[v]的值

                          tanjan(id);

                          low[v] = min(low[v], low[id]); //他的儿子如果能连到更小的祖先节点,显然他也可以

                  }

                  else {

                          if (vis[id]) {//不在栈中的点,要么没有访问,要么不能到达id,所以只需要判断栈中的

                                   low[v] = min(low[v], low[id]);

                          }

                  }

         }

 

         if (low[v] == dfn[v]) {//(4)自己和子节点形成了强连通分量,或者只有自己孤身一人,因为是根据顺序dfs下去的,因此优先得到的是更大的连通分量(即如果u有子节点连到u的祖先,那么u进入该操作,只有dfs一层层退出到那个祖先节点时,才会将剩下的这些点一起计入一个连通分量)

                  color[v] = ++sum;

                  vis[v] = 0;

                  while (stack[top] != v) {//将从v开始所有的点取出

                          color[stack[top]] = sum;//给予同一颜色

                          vis[stack[top--]] = 0;//出栈要顺便修改vis

                  }

                  top--;

         }

}

 

tarjan缩点

因为强连通分量中的每两个点都是强连通的,因此可以将一个强连通分量当作一个超级点,具体权值按照题意来确定

对于一个有向无环图DAG,计算入度为0的点数a,出度为0的点数b,那么至少添加max(a,b)条边,就能将DAG变成一个强连通图,而如果要将一个图转换成DAG,因为给出的图可能存在环,那么我们就会想到把已经组成全连通的子图转换成一个点来看,那么我们最终的图就不会包含环了

因此我们要做的就是用Tarjan算法求解出强连通分量之后,使用一个color数组将同一个连通分量的点分配相同的数值,然后再次遍历一遍所有的边,如果边的两侧u->v不属于同一颜色,那么u对应颜色将会有一条边连向v对应的颜色。在此基础上我们可以计算缩点之后的出入度,得到max(a,b)或者其他一些信息

for (int i = 1; i <= N; i++) {

                  for (unsigned k = 0; k < g[i].size(); k++) {

                          ll v = g[i][k];

                          if (color[v] != color[i]) {//二者分属于不同的联通集

                                   outd[color[i]] += 1; //以颜色作为点,更新相应点的出度

                          }

                  }

         }

P3387

缩点,就是把有向有环图中的环缩成一个个点,形成一个有向无环图

根据题意,我们需要找出一条点权最大的路径,不限制重复选点,因此显然,如果环上的一点被选择了,显然可以把整个环都选入,这样更优,于是可以将环看做是一个点

tarjan过程就是dfs过程,对图进行dfs,遍历所有未遍历过的点,在遍历过程中将会得到一个有向树,显然有向树是没有环的(因为vis数组的存在,搜过的点不会再被搜)

会产生环的,只有反边的存在

对于深搜的过程,用一个栈保存当前所在路径上的点(栈中所有点一定是具有父子关系的)

在树中,会指向已经遍历过的边只有两种情况:横叉边和反边,横叉边连接的两个点没有父子关系,因此不形成环,体现在dfs过程中就是遍历当前点所连的点集时,发现一个点已经被遍历过,但是没有在栈中,因此不产生环

处理完环之后,就可以重新建立一张图,以每个环为节点(孤立的一个点也属于一个强连通分量),在这张图中进行dp即可,同时注意要用拓扑排序解决dp的无后效性

    int to,nxt,from;

}ed1[100005],ed2[100005];

int tot=0,tot1=0,head1[100005],head2[100005];

int a[100005];

inline void add(int u,int v){

    ed1[++tot].to=v;

    ed1[tot].from=u;

    ed1[tot].nxt=head1[u];

    head1[u]=tot;

}

inline void add1(int u,int v){

    ed2[++tot1].to=v;

    ed2[tot1].nxt=head2[u];

    head2[u]=tot1;

}

int low[100005],dfn[100005],vis[100005];

int st[100005],top=0;

int cnt=0;

int color[100005];

void tarjan(int x){

    low[x]=dfn[x]=++cnt;

    st[++top]=x;

    vis[x]=1;//表示是否在栈中

    for(int i=head1[x];i;i=ed1[i].nxt){

        int y=ed1[i].to;

        if(!dfn[y]){

            tarjan(y);

            low[x]=min(low[x],low[y]);

        }else if(vis[y]){

            low[x]=min(low[x],low[y]);

        }

    }

    if(dfn[x]==low[x]){

        int y;

        while(y=st[top--]){

            color[y]=x;

            vis[y]=0;

            if(x==y) break;

            a[x]+=a[y];

        }

    }

}

int dp[100005];

int in[100005];

void solve(){

    memset(vis,0,sizeof(vis));

    memset(dp,0,sizeof(dp));

    memset(in,0,sizeof(in));

    int n,m;

    cin>>n>>m;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    for(int i=1;i<=m;i++){

        int u,v;

        cin>>u>>v;

        add(u,v);

    }

    for(int i=1;i<=n;i++){

        if(!dfn[i]) tarjan(i);

    }

    for(int i=1;i<=m;i++){

        int u=ed1[i].from,v=ed1[i].to;

        if(color[u]!=color[v]){//建新图

            add1(color[u],color[v]);

            in[color[v]]++;

        }

    }

    auto topo=[&]()->void{

        queue<int> q;

        for(int i=1;i<=n;i++){

            if(color[i]==i && !in[i]){

                q.push(i);

                dp[i]=a[i];

            }

        }

        while(!q.empty()){

            int x=q.front();

            q.pop();

            for(int i=head2[x];i;i=ed2[i].nxt){

                int y=ed2[i].to;

                dp[y]=max(dp[y],dp[x]+a[y]);

                in[y]--;

                if(!in[y]) q.push(y);

            }

        }

    };

    topo();

    int ans=0;

    for(int i=1;i<=n;i++){

        ans=max(ans,dp[i]);

    }

    cout<<ans<<'\n';

}

 

 

 

树上差分(T2646):
数组上的区间加一操作,可以用差分数组解决,这一思想同样可以用到树上,把树上的一条路径上的节点值加一,也可以用差分数组解决

在树上x=start到y=end的路径往往可以看作从x到某一个点拐弯再向下到达y,这个拐弯的点就是x,y的lca,这段路径就可以看作是x->z->lca->y,其中z是lca到x上的儿子,由于要更新点,就可以将这段路径拆分成x->z和y->lca

对路径上的点的cnt加一(可用于计算各个节点经过的次数),转化成对差分数组diff的两个数的更新,规定把下面的点加一,把上面的点减一:

对于x->z,把diff[x]加一,diff[lca]减一,同时可以注意到,如果x就是lca,那么z是不存在的,而差分操作恰好对diff[lca]加一减一,相当于没有影响,因此不需要特判

对于y->lca,把diff[y]加一,diff[father[lca]]减一

最后更新完后,对这棵树进行dfs,自底向上累加diff,就能计算出cnt值

 

数链剖分:

树上路径问题可以考虑重链剖分

树链剖分用于将树分割成若干条链的形式,以维护树上路径的信息。

具体来说,将整棵树剖分为若干条链,使它组合成线性结构,然后用其他的数据结构维护信息

重链剖分可以将树上的任意一条路径划分成不超过O(logn) 条连续的链,每条链上的点深度互不相同(即是自底向上的一条链,链上所有点的 LCA 为链的一个端点)

因此可以用一些维护序列的数据结构(如线段树)来维护路径上的信息,具体来说就是类似于区间修改和区间查询,如修改树上两点路径上所有点的值,查询路径上节点权值的和/极值等(可用数据结构来维护这些利于合并的信息)

定义:

定义 重子节点 表示其子节点中子树个数最大的子结点。如果有多个子树最大的子结点,取其一。如果没有子节点,就无重子节点。

定义 轻子节点 表示剩余的所有子结点。

从这个结点到重子节点的边为 重边。

到其他轻子节点的边为 轻边。

若干条首尾衔接的重边构成 重链。

把落单的结点也当作重节点,那么整棵树就被剖分成若干条重链

一些数据及代表的含义:fa(x)表示节点x在树上的父亲         dep(x)表示节点x在树上的深度

siz(x)表示节点x的子树的节点个数             son(x)表示节点x的重儿子           top(x)表示x所在重链的顶部节点(深度最小的重节点)  dfn(x)表示节点x的dfs序,也就是在线段树中的编号  rnk(x)表示dfs序对应的节点编号,有rnk(dfn(x))=x

可以通过两次dfs来得到这些信息,第一次类似于建图,求出fa,dep,siz,son,第二次求出dfn,rnk

void dfs1(int o) {

son[o] = -1;

siz[o] = 1;

for (int j = h[o]; j; j = nxt[j])

if (!dep[p[j]]) {

dep[p[j]] = dep[o] + 1;

fa[p[j]] = o;

dfs1(p[j]);

siz[o] += siz[p[j]];

if (son[o] == -1 || siz[p[j]] > siz[son[o]]) son[o] = p[j];

}

}

void dfs2(int o, int t) {

top[o] = t;

cnt++;

dfn[o] = cnt;

rnk[cnt] = o;

if (son[o] == -1) return;

dfs2(son[o], t); // 优先对重儿子进行 DFS,可以保证同一条重链上的点 DFS 序连续

for (int j = h[o]; j; j = nxt[j])

if (p[j] != son[o] && p[j] != fa[o]) dfs2(p[j], p[j]);

}

重链剖分的性质:

树上每个节点都属于且仅属于一条重链;重链开头的结点不一定是重子节点(因为重边是对于每一个结点都有定义的);所有的重链将整棵树 完全剖分

在剖分时重边优先遍历,最后树的 DFS 序上,重链内的 DFS 序是连续的。按 DFN 排序后的序列即为剖分后的链。

一颗子树内的 DFS 序是连续的。

可以发现,当我们向下经过一条 轻边 时,所在子树的大小至少会除以二。

因此,对于树上的任意一条路径,把它拆分成从 LCA 分别向两边往下走,分别最多走O(logn)次,因此,树上的每条路径都可以被拆分成不超过O(logn)条重链

应用:

路径上维护:链上的 DFS 序是连续的,可以使用线段树、树状数组维护;每次选择深度较大的链往上跳,直到两点在同一条链上;同样的跳链结构适用于维护、统计路径上的其他信息。
子树维护:

有时会要求,维护子树上的信息,譬如将以x为根的子树的所有结点的权值增加v;在 DFS 搜索的时候,子树中的结点的 DFS 序是连续的;每一个结点记录 bottom 表示所在子树连续区间末端的结点;这样就把子树信息转化为连续的一段区间信息。
求最近公共祖先:
不断向上跳重链,当跳到同一条重链上时,深度较小的结点即为 LCA。

向上跳重链时需要先跳所在重链顶端深度较大的那个

int lca(int u, int v) {

while (top[u] != top[v]) {

if (dep[top[u]] > dep[top[v]])

u = fa[top[u]];

else

v = fa[top[v]];

}

return dep[u] > dep[v] ? v : u;

}

 

启发式合并:

启发式合并作为一种思想,表示我们在合并两个集合时,优先将小集合合并到大集合中去。这样就能够保证合并的总时间复杂度控制在nlogn以内

比较典型的启发式合并就是树上启发式合并

 

模板题:cf208E,cf600E

类似于U41492 树上数颜色

我们用cnt[i]表示颜色i 的出现次数,ans[u]表示结点u 的答案。

遍历一个节点 u,我们按以下的步骤进行遍历:

先遍历  u的轻(非重)儿子,并计算答案,但 不保留遍历后它对 cnt 数组的影响;

遍历它的重儿子,保留它对 cnt 数组的影响;

再次遍历u的轻儿子的子树结点,加入这些结点的贡献,以得到u的答案

这样,对于一个节点,我们遍历了一次重子树,两次非重子树,显然是最划算的

对于启发式合并,我们每次仍然完整地遍历了对应子树的所有子节点,并且若一个节点u被遍历了x次,则其重儿子会被遍历x次,轻儿子(如果有的话)会被遍历2x次。

注意除了重儿子,每次遍历完cnt要清零

(比较不严谨的考虑是轻边连接的子节点的子树大小小于父亲的一半,因此对其每次都逐个操作的开销并不大

根据贪心的思想我们当然要把节点数最多的子树(即重儿子形成的子树)放在最后,之后我们就有了一个看似比较快的算法,先遍历所有的轻儿子节点形成的子树,统计答案但是不保留数据,然后遍历重儿子,统计答案并且保留数据,最后再遍历轻儿子以及父节点,合并重儿子统计过的答案)

大致模板(cf208E)

il int check(int x)//统计答案

{

  int num=0,ret=0;

  for(re i=1;i<=n;i++){

      if(cnt[i]==num){ret+=i;}

      else if(cnt[i]>num){num=cnt[i],ret=i;}

    }

  return ret;

}

il void add(int x){cnt[col[x]]++;}//单点增加

il void del(int x){cnt[col[x]]--;}//单点减少

il void raise(int x){for(re i=seg[x];i<=seg[x]+size[x]-1;i++)add(rev[i]);}//增加x子树的贡献

il void clear(int x){for(re i=seg[x];i<=seg[x]+size[x]-1;i++)del(rev[i]);}//清空x子树的贡献

il void dfs1(int x,int fa){

  dep[x]=dep[fa]+1;father[x]=fa;//处理深度,父亲

  seg[x]=++seg[0];rev[seg[x]]=x;size[x]=1;//子树大小,dfs序

  for(re i=head[x],y;i,y=go[i];i=next[i]){

      if(y==fa)continue;dfs1(y,x);

      size[x]+=size[y];

      if(size[y]>size[son[x]])son[x]=y;//重儿子

    }

}

il void dfs2(int x,int flag)//flag表示是否为重儿子,1表示重儿子,0表示轻儿子

{

  for(re i=head[x],y;i,y=go[i];i=next[i]){

      if(y==son[x]||y==father[x])continue;

      dfs2(y,0);//先遍历轻儿子

    }

  if(son[x])dfs2(son[x],1);//再遍历重儿子

  for(re i=head[x],y;i,y=go[i];i=next[i]){

      if(y==son[x]||y==father[x])continue;

      raise(y);//更新轻儿子的贡献

    }add(x);//加上x结点本身的贡献

  ans[x]=check(x);//更新答案

  if(!flag)clear(x);//如果是轻儿子,就清空

}

 

蓝桥杯2023 颜色平衡树

暴力解法显然是对每棵子树dfs一遍,求出子树大小size,对应颜色出现次数的哈希表cnt,以及对应出现次数的次数的哈希表ccnt,通过判断cnt(u)*ccnt(u)是否等于这棵子树的大小来确定是否是颜色平衡树

但我们显然想要通过一些操作来避免这种重复操作,因为树天然有包含或者说相似关系,因此考虑能否用子节点的一些数据去传递给祖先节点来做到一次dfs统计完答案

这时候就要使用启发式合并

我们进行重链剖分,求出每个节点的重儿子 son,于是希望每个节点 u 能够从 son(u) 处继承 cnt 和 ccnt 的信息。,重儿子的定义是子树大小最大的儿子,轻儿子的定义是除了重儿子以外的所有儿子,重边的定义是该节点与重儿子之间的边,轻边的定义是该节点与轻儿子之间的边。

定义add(u,Δ) 表示将 u 子树的节点以 Δ 的贡献加入到 cnt 和ccnt 中,其中Δ=±1。于是有算法流程 calc(u,save),其中 u 是当前递归到的节点,save 是一个是否保存当前贡献的开关,一会会用到:

对于所有轻儿子 v,递归calc(v,false),也就是递归求出轻儿子子树的答案,并擦除这棵子树的贡献。

如果有重儿子,递归 calc(son(u),true),也就是递归求出重儿子子树的答案,并保留这棵子树的贡献。

将当前节点 u 贡献到 cnt 和ccnt 中。

对于所有轻儿子 v,调用add(v,+1),将轻儿子子树贡献计入。此时cnt 和ccnt 中的信息是 u 子树的。

统计 u 子树的答案。

如果 save=false,调用 add(v,−1) 擦除贡献(在进行calc轻儿子时,利用add(,-1)擦除贡献)。

算法正确性是显然的。由重儿子的定义,容易证明:根节点到任意节点路径上的轻边不超过O(logn) 条。

一个点会被暴力统计贡献,只有在 calc 搜到这个点,或者搜到这个点的某个作为轻儿子的祖先时才会发生。于是每个点被暴力到的次数为O(logn),总复杂度为O(nlogn),比每次都进行dfs要优

const int N=2e5+5;

int n,c[N],f[N],sz[N],son[N],cnt[N],ccnt[N],ans;

vector<int> e[N];

void dfs(int u){//一次dfs找重节点

    sz[u]=1;

    for(int v:e[u]){

        dfs(v);

        sz[u]+=sz[v];

        if(sz[v]>sz[son[u]]) son[u]=v;

    }

}

void add(int u,int dt){

    --ccnt[cnt[c[u]]];//双重哈希操作

    cnt[c[u]]+=dt;

    ++ccnt[cnt[c[u]]];

    for(int v:e[u]) add(v,dt);//将该节点代表的子树的节点都计入

}

void calc(int u,bool save){

    for(int v:e[u]) if(v!=son[u]) calc(v,0);//对轻节点递归计算各自的答案

    if(son[u]) calc(son[u],1);//递归重节点

    --ccnt[cnt[c[u]]];//记录该节点自身的数值

    cnt[c[u]]++;

    ++ccnt[cnt[c[u]]];

    for(int v:e[u]) if(v!=son[u]) add(v,1);//将轻节点对于这个节点的贡献加入

    if(cnt[c[u]]*ccnt[cnt[c[u]]]==sz[u]) ++ans;

    if(!save) add(u,-1);//轻节点不计入cnt数组,在答案计算完毕后再删除其贡献

}

void solve() {

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>c[i]>>f[i];

        if(f[i]) e[f[i]].emplace_back(i);

    }

    dfs(1);

    calc(1,1);

    cout<<ans<<'\n';

}

 

蓝桥杯 23 网络稳定性

设备A到设备B的一条路径的稳定性设定为经过的所有路径中稳定性最低的那个

设备A到设备B的稳定性为其所有可行路径中稳定性最高的那条

化简题意后发现,这题本质是将边按边权从大到小排序后依次插入原图,询问两个点在什么时候会连通,允许离线

每插入一条边本质就是在合并两个连通块,很自然地想到启发式合并。

利用启发式合并的思想,我们将每个询问挂在它的两个端点上,在合并两个联通块时,处理较小的连通块中的询问。

判断询问是否已成立的方法也很简单,只需查询该询问中的另一个端点是否在我们将要合并的连通块内即可,时间复杂度 O(α(n))。

对于未成立的询问,只需将其合并到另一个连通块内便于之后查询即可

int x[300005],y[300005],z[300005];

vector<vector<pair<int,int>>> to(100005);

int fa[100005],ans[100005];

void solve() {

    int n,m,q;

    cin>>n>>m>>q;

    for(int i=1;i<=m;i++){

        cin>>x[i]>>y[i]>>z[i];

    }

    for(int i=1;i<=q;i++){

        int u,v;

        cin>>u>>v;

        //在对应节点上存储询问

        to[u].emplace_back(v,i);

        to[v].emplace_back(u,i);

    }

    memset(fa,-1,sizeof(fa));

    function<int(int)> find=[&](int k)->int{

        return fa[k]<0?k:fa[k]=find(fa[k]);

        //类似并查集的查找

        //并且利用fa数组的负来一并存储集合大小,如果fa为负,说明这是其所在连通块的代表元,并且fa所对应的数值就是负的连通块大小,调用find函数时,返回该节点本身

    };

    vector<int> id(m+1);

    iota(id.begin()+1,id.end(),1);

    sort(id.begin()+1,id.end(),[&](int x,int y){

        return z[x]>z[y];

    });

    memset(ans,-1,sizeof(ans));

    for(int i=1;i<=m;i++){

        //将边按照边权从大到小加入图,正好使两个节点连通的边就是对应询问的答案

        int fx=find(x[id[i]]),fy=find(y[id[i]]);

        if(fx==fy) continue;//判断是否连通

        if(-fa[fx]<-fa[fy]) swap(fx,fy);//保证fy是较小的那个节点,将小的合并到大的中就是启发式合并的思路

        for(pair<int,int> &j:to[fy]){//处理较小块中的询问

            if(find(j.first)==fx) ans[j.second]=z[id[i]];//已经可以回答该询问了就记录答案

            else to[fx].push_back(j);//否则将询问保留

        }

        fa[fx]+=fa[fy],fa[fy]=fx;

    }

    for(int i=1;i<=q;i++) cout<<ans[i]<<'\n';//离线后回答询问

}

 

杭电24多校1  1003

max(au-av)-|au-av|实际上等于max^2-max*min

因为可以两两进行配对,因此max*min可以从sigma(a)^2中得到

因此问题在于求max^2
对于子树问题,可以想到按照每一条边依次加入

每次加入时,可以先计算待加入子树和当前子树间的答案,再加上待加入子树内部的答案,

然后将子树合并,合并过程中,可以采用线段树合并,也可以树上dsu,或者是启发式合并

只保留重孩子的影响,消除轻孩子的影响

const int N=5e5+20,M=1e6+20;

int t,n,m,k,a[N],flag[N],siz[N];

int dfn,id[N],l[N],sum[N],ans[N];

int c[M],d[M];

//c数组用于维护当前的元素个数

//d数组用于维护当前的权值平方和

vector<int> g[N],now;

inline int lowbit(int x){return x&-x;}

inline void add(int c[],int x,int y){

    for(int i=x;i<=1e6;i+=lowbit(i)){

        c[i]+=y;

    }

}

//以x为max,查询小于x的元素个数/权值和

inline int query(int c[],int x){

    int res=0;

    for(int i=x;i;i-=lowbit(i)){

        res+=c[i];

    }

    return res;

}

inline void dfs(int x,int fa){

    siz[x]=1;

    l[x]=++dfn;

    id[dfn]=x;

    int now=0,cnt=0,ans=0;

    sum[x]=a[x];

    for(auto y:g[x]){

        if(y==fa) continue;

        dfs(y,x);

        siz[x]+=siz[y];//树的大小

        sum[x]+=sum[y];//权值和

        if(siz[y]>cnt){

            cnt=siz[y];

            ans=y;

        }

    }

    if(ans){//最大子树

        flag[x]=ans;

    }

}

inline void calc(int x,int fa,int keep){//启发式合并

    for(auto y:g[x]){

        if(y==fa || y==flag[x]){

            continue;

        }

        calc(y,x,0);

        ans[x]+=ans[y];//每个节点的答案,直接加是计算子树内部的答案

    }

    if(flag[x]){//后操作重节点

        calc(flag[x],x,1);

        ans[x]+=ans[flag[x]];

    }

    int now=0;

    for(auto y:g[x]){

        if(y==fa || y==flag[x]) continue;

        for(int i=l[y];i<=l[y]+siz[y]-1;i++){//通过dfs序遍历当前子树的节点

            now+=query(d,1e6)-query(d,a[id[i]])+query(c,a[id[i]])*a[id[i]]*a[id[i]];

            //大于当前元素的权值平方和+小于当前元素的元素个数*当前元素的权值平方和

            //也就是当前元素加入后,对于max平方的贡献

        }

        for(int i=l[y];i<=l[y]+siz[y]-1;i++){

            add(c,a[id[i]],1);

            add(d,a[id[i]],a[id[i]]*a[id[i]]);

        }

    }

    //处理当前节点

    now+=query(d,1e6)-query(d,a[x])+query(c,a[x])*a[x]*a[x];

    ans[x]+=now*2+a[x]*a[x];//u,v是可以互换的,因此答案要乘2

    add(c,a[x],1);

    add(d,a[x],a[x]*a[x]);

    if(!keep){//消除影响

        for(int i=l[x];i<=l[x]+siz[x]-1;i++){

            add(c,a[id[i]],-1);

            add(d,a[id[i]],-a[id[i]]*a[id[i]]);

        }

    }

}

void solve(){

    cin>>n;

    for(int i=1;i<n;i++){

        int u,v;

        cin>>u>>v;

        g[u].push_back(v);

        g[v].push_back(u);

    }

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    dfs(1,0);

    calc(1,0,0);

    for(int i=1;i<=n;i++){

        ans[i]=ans[i]-sum[i]*sum[i];//减去权值和的平方

    }

    int cnt=ans[1];

    for(int i=2;i<=n;i++){

        cnt^=ans[i];

    }

    cout<<cnt<<'\n';

}

 

 

 

拓扑排序(T207):
在一个 DAG(有向无环图) 中,我们将图中的顶点以线性方式进行排序,使得对于任何的顶点u到v的有向边  , 都可以有u在v的前面。

还有给定一个 DAG(有向无环图),如果从x到y有边,则认为 y依赖于x。如果 x到y有路径(x可达y),则称y间接依赖于x。

拓扑排序的目标是将所有节点排序,使得排在前面的节点不能依赖于排在后面的节点

构造拓扑序列的步骤,从图中选择一个入度为0的点,输出该顶点,从图中删除此顶点以及其所有的出边,重复上面两步,直到所有顶点都输出,拓扑排序完成,或者图中不存在入度为0的点,说明此时图是有环图,拓扑排序无法完成,陷入死锁

最早发生时间和最迟发生时间的递推关系:按拓扑顺序求,最早是从前往后,前驱顶点的最早开始时间与边的权重之和最大者,最迟是从后往前,后继顶点的最迟开始时间与边的权重之差的最小者

因为活动开始的要求的是所有前驱节点都被完成,因此以某一顶点开始的活动的最早发生时间是初始点到该顶点的最长路径长度,因为要不推迟工期,因此权重越大的开始时间应该越早,因此最迟开始时间就是所有以该状态结束的活动的最晚能忍受的时间

这很像 BFS:让入度为 0 的课入列,它们是能直接选的课;然后逐个出列,出列代表着课被选,需要减小相关课的入度;如果相关课的入度新变为 0,安排它入列、再出列……直到没有入度为 0 的课可入列

BFS 前的准备工作:每门课的入度需要被记录,我们关心入度值的变化;课程之间的依赖关系也要被记录,我们关心选当前课会减小哪些课的入度;因此我们需要选择合适的数据结构,去存这些数据:

入度数组:课号 0 到 n - 1 作为索引,通过遍历先决条件表求出对应的初始入度。

邻接表:用哈希表(unordered_map<int, vector<int>> map;)记录依赖关系(也可以用二维矩阵,但有点大)

key:课号

value:依赖这门课的后续课(数组)

实现(Kahn 算法):
int n, m;

vector<int> G[MAXN];

int in[MAXN]; // 存储每个结点的入度

bool toposort() {

vector<int> L;

queue<int> S;

for (int i = 1; i <= n; i++)

if (in[i] == 0) S.push(i);

while (!S.empty()) {

int u = S.front();

S.pop();

L.push_back(u);

for (auto v : G[u]) {

if (--in[v] == 0) {

S.push(v);

}

}

}

if (L.size() == n) {

for (auto i : L) cout << i << ' ';

return true;

} else {

return false;

}

}

应用:拓扑排序可以判断图中是否有环,还可以用来判断图是否是一条链。拓扑排序可以用来求 AOE 网中的关键路径,估算工程完成的最短时间

求字典序最大/最小的拓扑排序:将 Kahn 算法中的队列替换成最大堆/最小堆实现的优先队列即可

 

25 杭电 春 7 1005

给定一个n个点的无向图,图中一共有m条边,每个点有价值a[i],求图中任意点开始移动,可以多次访问同一节点或者通道,但是不能连续两次通过同一条通道(也就是不能a->b,然后立马b->a),问经过点的权值之和最大是多少

显然环,或者进一步地,边双连通分量上的点是一定能取到的,此外,连接任意两个边双连通分量的路径也是一定能取到的

因此,如果把这些点都缩成一个点,那么就变成了一棵树上求最大路径,这可以用树dp实现

于是关键在于如何进行缩点

我们可以使用tarjan将所有边双连通分量找出来,并标记为1

因为剩下的那些残留在环上的树枝的末尾一定都是叶子节点

从而考虑使用拓扑排序(或者使用并查集合并边双连通分量或者使用bfs将外部的孤立点一个个剥离)

将并非边双连通分量的点并且度为1的点加入队列中,删除点时不删除那些与标记为1相连的点,这样能将一定能得到的点都找出来

然后建新图即可,将一定能取到的点放到一个新节点上,并将边进行连接

(并未通过所有数据点)

void solve(){

    int n,m;

    cin>>n>>m;

    vector<i64> a(n+1);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<vector<int>> adj(n);

    vector<int> deg(n);

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].push_back(v);

        adj[v].push_back(u);

        deg[u]++;

        deg[v]++;    

    }

    vector<int> mark(n,-1);

   

    {

        // Tarjan算法查找桥

        vector<int> dfn(n, -1), low(n, -1);

        int time = 0;

        set<pair<int, int>> bridges;

       

        function<void(int, int)> tarjan = [&](int u, int parent) {

            dfn[u] = low[u] = time++;

            for(int v : adj[u]) {

                if(v == parent) continue;

                if(dfn[v] == -1) {

                    tarjan(v, u);

                    low[u] = min(low[u], low[v]);

                    // 判断是否为桥

                    if(low[v] > dfn[u]) {

                        bridges.insert({min(u, v), max(u, v)});

                    }

                } else {

                    low[u] = min(low[u], dfn[v]);

                }

            }

        };

       

        // 对每个连通分量运行Tarjan算法

        for(int i = 0; i < n; i++) {

            if(dfn[i] == -1) {

                tarjan(i, -1);

            }

        }

       

        // 删除所有桥后DFS找边双连通分量

        vector<bool> visited(n, false);

       

        function<void(int, vector<int>&)> dfs = [&](int u, vector<int>& component) {

            visited[u] = true;

            component.push_back(u);

           

            for(int v : adj[u]) {

                // 检查(u,v)是否为桥

                if(bridges.find({min(u, v), max(u, v)}) != bridges.end())

                    continue;

               

                if(!visited[v]) {

                    dfs(v, component);

                }

            }

        };

       

        // 找出所有边双连通分量

        vector<vector<int>> components;

        for(int i = 0; i < n; i++) {

            if(!visited[i]) {

                vector<int> component;

                dfs(i, component);

                components.push_back(component);

            }

        }

       

        // 标记属于大小大于1的边双连通分量的节点

        for(const auto& component : components) {

            if(component.size() > 1) {

                for(int node : component) {

                    mark[node] = 1;

                }

            }

        }

   

    }

    // 边双都标记为1

    queue<int> q;

    for(int i=0;i<n;i++){

        if(mark[i]!=1 && deg[i]==1){

            q.push(i);

        }

    }

    while(!q.empty()){

        int u=q.front();

        q.pop();

        mark[u]=0; // 不是一定能被取到的

        for(int v:adj[u]){

            if(mark[v]==-1){

                deg[v]--;

                if(deg[v]==1){

                    q.push(v);

                }

            }

        }

    }

    // 缩点之后的新图,一定能取到的点放到新点n上

    vector<vector<int>> g(n+1);

    int st=-1; // 树上dp的根

    for(int i=0;i<n;i++){

        if(mark[i]==1 || mark[i]==-1){  // 说明是一定能取到的点

            a[n]+=a[i];

        }else{

            st=i;

            for(int v:adj[i]){

                if(mark[v]==0){

                    g[i].push_back(v);

                }else{

                    g[n].push_back(i);

                    g[i].push_back(n);

                }

            }

        }

    }

    // for(int i=0;i<n;i++){

    //     cout<<mark[i]<<" \n"[i==n-1];

    // }

    // cout<<a[n]<<'\n';

    // cout<<st<<'\n';

    {

        i64 ans=0;

        vector<i64> dp(n+1,0);

        auto dfs=[&](auto self,int u,int p)->void{

            i64 mx1=0,mx2=0;

            for(int v:g[u]){

                if(v==p) continue;

                self(self,v,u);

                if(dp[v]>mx1){

                    mx2=mx1;

                    mx1=dp[v];

                }else if(dp[v]>mx2){

                    mx2=dp[v];

                }

            }

            dp[u]=mx1+a[u];

            ans=max(ans,mx1+mx2+a[u]);

        };

        dfs(dfs,(st==-1?n:st),-1);

        // for(int i=0;i<n;i++){

        //     cout<<dp[i]<<" \n"[i==n-1];

        // }

        cout<<ans<<'\n';

    }

}

 

 

25 杭电 春2 1008

更简单的版本:

(要求树上进行这样染色的最后颜色序列(P2486,要使用树链剖分))
给定一条序列,可以进行k次染色,每次给一个子区间进行染色,后面染色的能覆盖前面染色的,给出最后的颜色序列,判断能不能通过该百年染色顺序达到最后的序列

将每一种颜色看成一个节点,第i个位置曾经染过的颜色连边到最后的颜色,最后在颜色上做拓扑排序,如果有环则非法

这是因为之后的颜色能够覆盖之前的颜色,也就是形成了偏序关系,而如果形成了环,那就无法构成偏序关系,因为这样就循环染色了,于是要求形成的一定要是一个DAG

这题也是类似的,第i行j列的格子会被j/2(上取整),2*n-i+1,2*n+i-j/2三个刷子经过,如果有格子颜色不是三者之一,那么可以直接输出No

否则把每个刷子看作是一个结点建图,对于每个已知颜色的格子,把所有“颜色不等于刷子颜色”的刷子向“颜色等于刷子颜色”的刷子连边,如果最终得到一个DAG,那么答案是YES,否则是NO

 

网格图的矩阵可以看作是一个有向图,如果图中个节点带有权值,要找最长递增路径(T329),可以在相邻的两个单元格之间存在一条从较小值指向较大值的有向边,问题转化成在有向图中寻找最长路径,一种自然使用网格图常用的dfs解法,但因为上下左右四个方向的选择过程中会有多次的重复,因此可以用记忆化dfs的方式

求最长自然可以想到用动态规划来解决,并且状态转移方程也十分清晰,就是周围与他相邻并且权值小于它的单元格中的max+1,但我们还需要边界条件(或者说初始化)来确定这个动态规划,往往是考虑特殊情况,即如果一个单元格的值比它的所有相邻单元格的值都要大,那么这个单元格对应的最长递增路径是 1,问题就在于这个条件不够直观,相当于每次还要根据单元格的值去进行判断

因此仍然考虑将矩阵看作是一个有向图,因为有向,我们就可以去计算出入度了,对于作为边界条件的单元格,该单元格的值比所有的相邻单元格的值都要大,因此作为边界条件的单元格的出度都是0,而基于出度的概念,就可以用拓扑排序解决了:从出度为0的单元格开始广度优先搜索,最后搜索的层数就是最长递增路径的长度

 

Cf 2052D

对于一个单位布尔寄存器,给定两种操作:set,如果值为true,则返回true;unset,如果值为true,则返回true,同时将其设置为false
给定n次操作,其中最多有两次操作返回true,同时部分操作顺序是一个DAG,问能否得到一个操作序列,使得其额能够满足给定的顺序,同时得到的结果和给定的结果相同

 

P1807

拓扑的特征:有向无环(DAG)

这题题干中保证了边:端点i,j,边权w,满足i<j

所以我们可以得到这个图是满足了无环条件的,至于有向,我们可以人为规定边的方向是从编号较小的点指向编号较大的点,所以易得点1绝对是一个没有入度的点(并且就是我们路径的起点)

但题目不能保证没有其他没有入度的点,对于这些点,一方面不能将他们加入队列,因为它们本身就是无法到达的点,所以实际上与它们有关的点并不会对答案产生影响,另一方面,又不能将它们直接不管,因为虽然它们相关的边的边权对于答案没有影响,但是这些边的存在使得相连的点的入度+1,如果不管这些点,那么它们的入度将一直大于0,可能会影响到我们正确答案路径上点的拓扑

因此合适的解决方法是先进行一遍for循环,找到那些点,再把那些点的入度-1,如果-1后变成了入度为0的点,那么再做同样的处理,因为前面提过了,如果有点入度变为0了,说明它实际上是不会与我们的答案有关联的,因此也要直接废除

这样接下来找最长路的思路也比较清晰了,可以说是dp的思路,到一个点的最长路显然是通过边的另一个点的最长值转移过来的

 

拓扑排序可以用来解决寻找有向图中有序路径的问题,并且保证如果存在一条节点A到节点B的边,那么在最后输出的路径中顶点A出现在顶点B的前面

24天梯选拔2-3

根据题意实际上就是要进行拓扑排序,将(u,v)间建立一条反边,一个点能被选择的前提就是这个点已经没有前驱了

如果最后当前的 DAG 图为空或当前图中不存在无前驱的顶点,那么就说明当前图已经搜索完毕或者当前图中存在环

vector<int> g[100005];

int in[100005];

int ans[100005];

void solve(){

  int n,m;

  cin>>n>>m;

  for(int i=1;i<=m;i++){

    int u,v;

    cin>>u>>v;

    g[v].emplace_back(u);

    in[u]++;

  }

  int cnt=0;

  priority_queue<int> q;

  for(int i=1;i<=n;i++){

    if(!in[i]) q.push(i);

  }

  while(q.size()){

    int u=q.top();q.pop();

    ans[++cnt]=u;

    for(auto v:g[u]){

      in[v]--;//与其相关的点入度-1

      if(!in[v]) q.push(v);

    }

  }

  if(cnt==n){

    for(int i=cnt;i>=1;i--) cout<<ans[i]<<' ';

    cout<<'\n';

  }

  else cout<<"TAI NAN LE!"<<'\n';

}

 

最小高度树(T310)

一棵树如果有多个节点,那么一定存在叶子节点,叶子节点是只有一个相邻节点的节点,并且树是无环的,假设父节点到子节点的边是有向边,那么树实际上就是一个DAG,因此可以利用拓扑排序,从内向外剥离叶子节点,当到达最后一层的时候,剩下的节点就可以作为最小高度树的根节点(因为这样做构建了尽可能多的叶子节点,因此能保证这样构建出的树是最小高度树)

vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {

        if (n == 1) {

            return {0};

        }

        vector<vector<int>> g(n);

        vector<int> degree(n);

        for (auto& e : edges) {

            int a = e[0], b = e[1];

            g[a].push_back(b);

            g[b].push_back(a);

            ++degree[a];

            ++degree[b];

        }

        queue<int> q;

        for (int i = 0; i < n; ++i) {

            if (degree[i] == 1) {

                q.push(i);

            }

        }

        vector<int> ans;

        while (!q.empty()) {//因为这里要记录最后一轮的节点(用a在存储),因此要层层进行操作

            ans.clear();

            for (int i = q.size(); i > 0; --i) {//相当于在进行bfs遍历,只对上一层的节点进行操作

                int a = q.front();

                q.pop();

                ans.push_back(a);

                for (int b : g[a]) {

                    if (--degree[b] == 1) {

                        q.push(b);

                    }

                }

            }

        }

        return ans;

 

T2192 这是一个有向无环图,并且要求的是一个节点的所有祖先节点,因此可以想到使用拓扑排序,因为拓扑排序可以遍历完全一个节点的所有节点:当遍历到一个节点其入度为0时,说明其所有祖先都已经遍历完毕了;一个节点的祖先节点是这个节点的父节点加上父节点的祖先节点,因此可以按照我们遍历的顺序进行传递

vector<vector<int>> getAncestors(int n, vector<vector<int>>& edges) {

        vector<unordered_set<int>> father(n);

        vector<int> degree(n);

        vector<vector<int>> graph(n);

        for (vector<int>& edge : edges) {

            graph[edge[0]].push_back(edge[1]);

            degree[edge[1]]++;

        }

        queue<int> q;

        for (int i = 0; i < n; i++) {

            if (!degree[i]) {

                q.push(i);

            }

        }

        while (q.size()) {

            int thisNode = q.front();

            q.pop();

            for (int nextNode : graph[thisNode]) {

                father[nextNode].insert(thisNode);

                for (int thisFather : father[thisNode]) {

                    father[nextNode].insert(thisFather);

                }

                degree[nextNode]--;

                if (!degree[nextNode]) {

                    q.push(nextNode);

                }

            }

        }

    }

 

GDCPC 2024I  (CSG1285)

约束条件,一种想法是连边表示这两者之间有关系,另一种就是考虑拓扑排序,例如这题只有当所有与其有关的两个元素确定下来的时候,这个元素具体为多少才可以被计算,因此相当于这个元素的计算是建立在与其相关元素的前提上,即有了先后顺序,因此用拓扑排序解决

我的写法中因为对于每一个输入的条件都将x加入了虚拟相连的边中,因此在后续计算的时候即使有重边也会在遍历的时候保证入度减少的值正确

int in[200005];

int a[200005];

vector<int> g[200005];

vector<vector<pair<int,int>>> adj(200005);

void solve() {

    int n,m;

    cin>>n>>m;

    memset(in,0,sizeof(in));

    memset(a,-1,sizeof(a));

    for(int i=1;i<=m;i++){

        int x,y,z;

        cin>>x>>y>>z;

        in[x]+=2;

        g[y].emplace_back(x);

        g[z].emplace_back(x);

        adj[x].push_back({y,z});

    }

    queue<int> q;

    for(int i=1;i<=n;i++){

        if(!in[i]) q.push(i);

    }

    while(q.size()){

        int u=q.front();

        q.pop();

        if(adj[u].empty()) a[u]=1;

        else{

            for(auto [x,y]:adj[u]){

                a[u]=max(a[x]+a[y],a[u]);

            }

        }

        for(auto v:g[u]){

            in[v]--;

            if(!in[v]) q.push(v);

        }

    }

    ll ans=0;

    for(int i=1;i<=n;i++){

        if(a[i]==-1){

            cout<<-1<<'\n';

            return;

        }else{

            ans+=a[i];

        }

    }

    if(ans<=1e9){

        cout<<ans<<'\n';

    }else{

        cout<<-1<<'\n';

    }

}

 

 

2-SAT

有n个布尔变量,有一些条件形如若a则b(a&b=1,a|b=1等这些都可以转成这种形式),求解

可以把每个变量都拆成两个点,一个是xi另一个是!xi,要求是两个点必须选一个,然后连边的时候若a则b就连边a->b

因为这些条件都是带逆否条件的,因此加边的时候可以同时加一个若!b则!a

然后一个O(nm)的做法是每次选择字典序最小的点并考虑后续贡献是否有影响,这样能求出字典序最小的方案

不要求字典序最小的话可以tarjan缩点然后每次任选一个出度为0的点(如果x和!x在同一个块里说明无解)

显然x的图和!x的图是对称的,因此当x的出度为0时,可以直接将!x删除

2-SAT,简单的说就是给出  n个集合,每个集合有两个元素,已知若干个<a,b>,表示a与b矛盾(其中a与 b 属于不同的集合)。然后从每个集合选择一个元素,判断能否一共选n个两两不矛盾的元素。显然可能有多种选择方案,一般题中只需要求出一种即可

Tarjan SCC 缩点

算法考究在建图这点,我们举个例子来讲:

假设有a1,a2和b1,b2两对,已知a1和b2间有矛盾,于是为了方案自洽,由于两者中必须选一个,所以我们就要拉两条有向边(a1,b1)和(b2,a2)表示选了a1则必须选b1 ,选了b2则必须选a2才能够自洽。

然后通过这样子建边我们跑一遍 Tarjan SCC 判断是否有一个集合中的两个元素在同一个 SCC 中,若有则输出不可能,否则输出方案。构造方案只需要把几个不矛盾的 SCC 拼起来就好了。

输出方案时可以通过变量在图中的拓扑序确定该变量的取值。如果变量x的拓扑序在 x之后,那么取x值为真。应用到 Tarjan 算法的缩点,即x所在 SCC 编号在 x之前时,取x为真。因为 Tarjan 算法求强连通分量时使用了栈,所以 Tarjan 求得的 SCC 编号相当于反拓扑序。

显然地,时间复杂度为O(n+m)

 

P4782

给出n个变量和m个需要满足的条件

条件的格式形如[xi为真或xj为真]

同一强连通分量内部的变量值一定是相等的,因此如果x和!x在同一个块里说明无解

确定给每个变量赋的值通过x所在的强连通分量的拓扑序在!x所在的强连通分量的拓扑序的前后关系,如果在其后,那么赋为真

但在使用tarjan缩点求强连通分量的过程中,因为标记的是反着的拓扑序,因此是color[x]<color[!x]

const int N=4e6+5;

vector<int> g[N];

int low[N],dfn[N],in[N],color[N],tot=0;

//因为每个变量都存了两次,因此要开两倍空间

stack<int> stk;

int scc=0;

void tarjan(int u){

    low[u]=dfn[u]=++tot;

    stk.push(u);

    in[u]=1;

    for(int v:g[u]){

        if(!dfn[v]){

            tarjan(v);

            low[u]=min(low[u],low[v]);

        }else if(in[v]){

            low[u]=min(low[u],dfn[v]);

        }

    }

    if(low[u]==dfn[u]){

        ++scc;

        do{

            color[u]=scc;

            u=stk.top();

            stk.pop();

            in[u]=0;

        }while(low[u]!=dfn[u]);

    }

}

 

void solve(){

    int n,m;

    cin>>n>>m;

    for(int i=0;i<m;i++){

        int a,va,b,vb;

        cin>>a>>va>>b>>vb;

        //将x标号为x,!x标号为n+x

        g[a+n*(va&1)].emplace_back(b+n*(vb^1));

        g[b+n*(vb&1)].emplace_back(a+n*(va^1));

    }

    for(int i=1;i<=(n<<1);i++){

        if(!dfn[i]){

            tarjan(i);

        }

        //tarjan找环得到的color是x所在的scc的拓扑逆序

    }

    for(int i=1;i<=n;i++){

        if(color[i]==color[i+n]){

            cout<<"IMPOSSIBLE\n";

            return;

            //x与!x在同一个scc中,无解

        }

    }

    cout<<"POSSIBLE\n";

    for(int i=1;i<=n;i++){

        cout<<(color[i]<color[i+n])<<' ';

        //如果不用tarjan找环,用大于号

    }

}

 

P5782

每个党派在委员会中都恰有一个代表,也就是说x和!x在同一个强连通分量中就可以输出无解

而两个代表彼此厌恶就是之前类似的或关系,比如每个委员会中有两个人,并且在A,B两个委员会中A1和B2相互厌恶,那么就A1和B1连边,A2和B2连边

const int N=2e4+5;

vector<int> g[N];

int low[N],dfn[N],in[N],color[N],tot=0;

//因为每个变量都存了两次,因此要开两倍空间

stack<int> stk;

int scc=0;

void tarjan(int u){

    low[u]=dfn[u]=++tot;

    stk.push(u);

    in[u]=1;

    for(int v:g[u]){

        if(!dfn[v]){

            tarjan(v);

            low[u]=min(low[u],low[v]);

        }else if(in[v]){

            low[u]=min(low[u],dfn[v]);

        }

    }

    if(low[u]==dfn[u]){

        ++scc;

        do{

            color[u]=scc;

            u=stk.top();

            stk.pop();

            in[u]=0;

        }while(low[u]!=dfn[u]);

    }

}

 

void solve(){

    int n,m;

    cin>>n>>m;

    for(int i=0;i<m;i++){

        int a,b;

        cin>>a>>b;

        g[a].emplace_back(b&1?b+1:b-1);

        g[b].emplace_back(a&1?a+1:a-1);

    }

    for(int i=1;i<=(n<<1);i++){

        if(!dfn[i]){

            tarjan(i);

        }

        //tarjan找环得到的color是x所在的scc的拓扑逆序

    }

    for(int i=1;i<=2*n;i+=2){

        if(color[i]==color[i+1]){

            cout<<"NIE\n";

            return;

            //x与!x在同一个scc中,无解

        }

    }

    for(int i=1;i<=2*n;i+=2){

        cout<<((color[i]<color[i+1])?i:i+1)<<'\n';

        //如果不用tarjan找环,用大于号

    }

}

要注意最后输出方案是的写法,对于用tarjan找环的,相反的两个元素哪个小就输出哪个

 

cf1971H

因为总共只有3列

因此要满足中间一行都是1,因此等价于三个值不能有两个同时为负数

由一个数只有两种取值可以很自然的想到 2-SAT。但是本题的要求是基于三元组的,因此要考虑如何构造二元关系使其满足条件

转换一下条件可得三数中至多有一个 −1。我们发现若任取两数必有至少一个数为 1,那么就至多只有一个数为−1,因为不可能同时取到两个−1。因此我们就找到了构造方式:三个数之间两两连「ai​为1 或aj 为1」的边,将负号看成取反(P4782)

 也即(x∧y)∨(x∧z)∨(y∧z)

 

并查集:

并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题(即所谓的并、查)。比如说,我们可以用并查集来判断一个森林中有几棵树、某个节点是否属于某棵树等

一个并查集中至少要有并查集大小-1条边

核心就在于能够求出图中的连通分量个数(T200)

要能够转换题目中的所求量,将其转化为求连通分量,如T765,将需要交换的情侣对之间连上一条边作为标记,问题就转化成了求各个连通分量的节点个数,这样操作的原因是我们可以证明需要交换的次数就是连通分量节点个数减1

并查集的核心就是去存储连通分量,并且一个连通分量至少需要n-1条边,可以通过这个性质来判断是否能删除某一条边而不改变图的连通性,这样需要维护各并查集的size这并不难,初始都设为1(自己构成一个单点并查集),合并时,被合并的设为0,合并的就是size1+size2

(这种合并主要是取决于题干所给的边直接将两个节点连接起来了,因此可以据此将两个点进行合并)

 

一般不对并查集中的边进行删除(cf 2060F)

如果要将一个并查集变成另一个并查集(指连通性相同),可以以将目标并查集作为标准,对原并查集进行操作,如果在目标并查集中要添加的边的两个端点不连通,那么就不需要加这条边

这样操作后,再计算需要添加的边的条数

只要将现有并查集的连通块数目减去目标并查集的连通块数目即可

连通块数目的维护可以通过设置初始为0,每次merge成功则-1,或者直接写作siz-=dsu.merge(u,v)

 

25 杭电 春3 1009

有3个操作,1 a b 表示a和b集落结盟,2 a b 表示野蛮人a移动到部落b中,3 a b表示a部落中的人和b部落中的人交换位置

因为除了一般的合并以外,可能存在集合中的一个人单独进行移动,因此不能仅仅通过并查集来维护相关的信息,需要额外维护一些信息,例如每个部落下的成员,以及每个成员所属的部落,这样在操作的时候就可以保证记录的完备性

虽然每次进行合并的时候需要对相关的数组中的每个元素都要修改,但整体的复杂度实际上是一个alpha常数,因此是可行的

https://acm.hdu.edu.cn/contest/view-code?cid=1152&rid=8597

 

cf 1950G(过了部分样例)

想到了连通块,对于每一首歌曲,如果歌手和流派有一个相同那么他们就可以放在一个连通块里面,但我们没法维护任意一个连通块所包含的数据,因此考虑将这样有关系的节点之间连一条边,这是通过类似哈希的存储完成,再后续构建并查集,维护其中大小最大的那个并查集,最后答案就是总的节点数减去那个最大值

void solve() {

    unordered_map<string,vector<int>> a;

    unordered_map<string,vector<int>> b;

    vector<vector<int>> g(20);

    int n;cin>>n;

    for(int i=1;i<=n;i++){

        string s1,s2;

        cin>>s1>>s2;

        a[s1].emplace_back(i);

        b[s2].emplace_back(i);

    }

    for(auto it=a.begin();it!=a.end();it++){

        for(int i=0;i<it->second.size();i++){

            for(int j=i+1;j<it->second.size();j++){

                g[it->second[i]].emplace_back(it->second[j]);

                g[it->second[j]].emplace_back(it->second[i]);

            }

        }

    }

    for(auto it=b.begin();it!=b.end();it++){

        for(int i=0;i<it->second.size();i++){

            for(int j=i+1;j<it->second.size();j++){

                g[it->second[i]].emplace_back(it->second[j]);

                g[it->second[j]].emplace_back(it->second[i]);

            }

        }

    }

    int pre[20],sz[20];

    function<int(int)> find=[&](int x)->int{

        return pre[x]==x?x:pre[x]=find(pre[x]);

    };

    int mx=1;

    for(int i=1;i<=n;i++) pre[i]=i,sz[i]=1;

    for(int i=1;i<=n;i++){

        for(auto j:g[i]){

            int x=find(i),y=find(j);

            if(x==y) continue;

            else{

                pre[y]=x,sz[x]+=sz[y],sz[y]=0;

                mx=max(mx,sz[x]);

            }

        }

    }

    cout<<(n-mx)<<'\n';

}

 

Cf 2064E

给定长度为n的一个排列组合p和一个数组c,对于每个i,在第i行上放置p[i]个颜色为c[i]的沙块,在所有元素都放置完毕后,对这些沙块施加重力,让他们自由下落,并构成一个图

问有多少种排列可以在这样操作后得到和当前相同的沙块图

首先可以注意到,排列后得到的第一列沙的颜色从上到下是固定的,也就是无论如何排列,颜色数组c一定是不变的,同时,对于颜色相同的相邻若干行,它们的p值是可以任意交换的,因为下落只需要保证这一列上的元素个数和相同

但除了这种情况之外,当i和j之间每种不同颜色的数量都严格小于p[i]和p[j]时,交换p[i]和p[j]也不会改变最后的沙盘布局,这是显然的,但若注意到p是一个排列,那么这种情况对于每个p[i]是有可以尝试匹配的p[j]的,或者说p[i]可以放置的位置是有限的,这些位置上可以任意交换,也就是类似于一个连通块,因此想到用并查集来处理,按照p[i]的顺序进行迭代,并且与所有可以到达的位置进行合并,为了加快我们考虑的速度,需要维护当前连通块的最左侧和最右侧的元素

同时这些范围是具有某种形式的树状结构的(例如类似于线段树的形式),因为按照这种计算方式,每个范围(指l[i]和r[i]之间的范围),要么是互不相交的,要么是其中一个包含另一个(这一点按照上面的处理方式也可以简单得到),这意味着如果我们按照大小顺序遍历,并每次“确定”下一个位置,就能得到最终答案

但实际上并不需要去考虑树状结构,可以按照顺序乘以每个DSU对应连通块的size,这表示了当前元素可以被放置的位置,然后减1,因为这些位置中这个元素必定会占据一个,因此对于后续元素来说,对应可选位置减少了1

同时因为虽然对于一个元素他的范围是[l[i],r[i]],但并不是这个范围中的所有位置都可以被选择,因此可以从小到大进行遍历,以删除这个范围中p较小的位置

void solve(){

    int n;

    cin>>n;

    vector<int> l(n),r(n);

    for(int i=0;i<n;i++){

        l[i]=i-1;

        r[i]=i+1;

    }

    r[n-1]=-1;

    vector<int> p(n);

    for(int i=0;i<n;i++){

        int x;

        cin>>x;

        x--;

        p[x]=i;

    }

    vector<int> c(n);

    DSU dsu(n);

    for(int i=0;i<n;i++){

        cin>>c[i];

        if(i && c[i]==c[i-1]){

            dsu.merge(i,i-1);

        }

    }

    i64 ans=1ll;

    for(auto x:p){

        ans*=(dsu.size(x)%mod);

        ans%=mod;

        dsu.siz[dsu.find(x)]--;

        int a=l[x],b=r[x];

        if(a!=-1){

            r[a]=b;

        }

        if(b!=-1){

            l[b]=a;

        }

        if(a!=-1 && b!=-1 && c[a]==c[b]){

            dsu.merge(a,b);

        }

    }

    cout<<ans<<'\n';

}

 

 

cf1515F

题意:有n个点,每个点有点权,同时有m条边以及定值x,需要选择一个生成树和一个树边的顺序,使得按照这个顺序建树时,连接每条边时,两边的连通块的点权和不小于x,同时建立一条边后新连通块的点权和要减少x

首先,如果图不连通,或者所有点权和不到(n-1)x,那么一定是无法完成目标的

否则,可以证明目标路径一定是存在的

首先,在n个点的树中,先随便选出一个叶子,考虑其点权a,如果a>=x,那么我们显然可以连上它与父节点之间的边,同时缩点,对于得到的新树显然依然满足我们上面的条件(相当于不等式左右两侧同减了x)

否则a<x,那么我们从树中删除这个叶子以及其父边(即先不连接该条边),因为对于点权和所对应的不等式来说,右侧减少了x,而左侧减少了a,显然不等式仍然满足,因此可以将其余边都连接完毕之后再修建这条边

因此可以随便选出一个生成树,进行一次dfs,如果一个点u的父节点为v,并且u的子树已经处理完毕,那么分类讨论,如果a[u]>=x,那么令a[v]=a[v]+a[u]-x,同时将(v,u)加入队列,否则将(v,u)加入一个栈中

最后先按序输出队列中的边,再按序输出栈中的边

或者也可以利用并查集,直接一边找生成树,一边存储选择的边

为了保证一定能连接边,我们用大根堆维护当前树中的节点,同时每次从队列中取出点时进行判断当前节点是否已经被合并到其他连通块中了(即它不是自己所在子树的根节点)

正确性:总共的点权大于等于(n-1)*x,总共有n个点,最坏情况就是尽可能多的用x-1覆盖,那么剩下一个点的大小是nx-x-(n-1)x+(n-1)=n-1,显然n大于等于2,则x-1+n-1>=x,因此只要满足当前数的点权和大于等于(n-1)*x,那么从当前点权最大的点进行合并一定是可行的

并且因为我们的连边基于原先图中存在的边,因此遍历子节点,如果已经包含在当前的连通块中,那么自然可以跳过

一旦可以连接,我们就合并这两个节点,对应上面的缩点,并且合并时将子节点少的并入子节点较多的节点中(启发式合并的思想,在合并两个集合时,优先将小集合合并到大集合中去。这样就能够保证合并的总时间复杂度控制在nlogn以内)

void solve(){

    int n,m,x;

    cin>>n>>m>>x;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    i64 sum=accumulate(all(a),0ll);

    if(sum<(i64)(n-1)*x){

        cout<<"NO\n";

        return;

    }

    DSU dsu(n);

    vector<vector<pair<int,int>>> e(n);

    for(int i=0;i<m;i++){//无边权,要记录的是边的编号

        int u,v;

        cin>>u>>v;

        --u,--v;

        e[u].emplace_back(v,i);

        e[v].emplace_back(u,i);

    }

    priority_queue<pair<i64,int>> h;

    //优先队列默认是大根堆

    for(int i=0;i<n;i++){

        h.emplace(a[i],i);

    }

    cout<<"YES\n";

    for(int r=1;r<=n-1;r++){

        int u=h.top().second;

        h.pop();

        while(dsu.find(u)!=u){

            u=h.top().second;

            h.pop();

        }

        while(dsu.same(u,e[u].back().first)){

            e[u].pop_back();

        }

        cout<<e[u].back().second+1<<'\n';

        int v=e[u].back().first;

        v=dsu.find(v);

        dsu.merge(u,v);

        a[u]+=a[v]-x;

        h.emplace(a[u],u);

        if(e[u].size()<e[v].size()){

            swap(e[u],e[v]);

        }

        e[u].insert(e[u].end(),all(e[v]));

        e[v].clear();

    }

}

 

较特殊的:cf25d

自然可以用并查集做

判断一条边连接的两个端点是否已经连通,如果联通,说明这条边是多余的,可以后续转化成其他边

最后并查集遍历构建完毕后,统计连通块的祖先可以用

for(int i=1;i<=n;i++) if(f[i]==i) gr[++cnt]=i;//统计连通块的祖宗

但这题有些特殊的是边的总数保持不变,并且n个节点,最后要两两联通,因此就是最后要形成一棵树

因此除去对整个图进行 DFS 所产生的搜索树上的边,剩余的边均为要删除的边,这些边可以在 DFS 的过程中得到,接下来就是相连,从每个连通分量中任取一个点,计作 v1,v2,v3...,然后将 v1 与 v2 相连,v2 与 v3 相连

 

主要构成:
并查集主要由一个整型数组pre[ ]和两个函数find( )、join( )构成。
数组 pre[ ] 记录了每个点的前驱节点是谁,函数 find(x) 用于查找指定节点 x 属于哪个集合,函数 join(x,y) 用于合并两个节点 x 和 y 

并查集的主要作用是求连通分支数(如果一个图中所有点都存在可达关系(直接或间接相连),则此图的连通分支数为1;如果此图有两大子图各自全部可达,则此图的连通分支数为2……)

用集合中的某个元素来代表这个集合,则该元素称为此集合的代表元

int find(int x)                                        //查找x的教主

{

         while(pre[x] != x)                       //如果x的上级不是自己(则说明找到的人不是教主)

                  x = pre[x];                                   //x继续找他的上级,直到找到教主为止

         return x;                                      //教主驾到~~~

}

 

void join(int x,int y)                    

{

    int fx=find(x), fy=find(y);            //寻找x,y的代表元

    if(fx != fy)                           //如果代表元不是同一个

        pre[fx]=fy;                        //随机选一个作为另一个的上级,这样就完成了合并

}

路径压缩算法

以原先find()的算法,如果查询结构为单支树结构,那么它的效率就会及其低下(树的深度过深,那么查询过程就必然耗时)

优化1

我们可以通过递归的方法来逐层修改返回时的某个节点的直接前驱(即pre[x]的值)。简单说来就是将x到根节点路径上的所有点的pre(上级)都设为根节点

int find(int x)                                   //查找结点 x的根结点

{

    if(pre[x] == x) return x;               //递归出口:x的上级为 x本身,即 x为根结点       

    return pre[x] = find(pre[x]);        //此代码相当于先找到根结点 rootx,然后pre[x]=rootx

}

或者可以简写为 pre[x]==x?x:pre[x]=find(pre[x])

该算法存在一个缺陷:只有当查找了某个节点的代表元(教主)后,才能对该查找路径上的各节点进行路径压缩。换言之,第一次执行查找操作的时候是实现没有压缩效果的,只有在之后才有效

 

优化2

加权标记法需要将树中所有节点都增设一个权值,用以表示该节点所在树中的高度(比如用rank[x]=3表示 x 节点所在树的高度为3)

在合并操作的时候,假设需要合并的两个集合的代表元分别为 x 和 y,则只需要令pre[x] = y 或者pre[y] = x 即可。但我们为了使合并后的树不产生退化(即:使树中左右子树的深度差尽可能小),那么对于每一个元素 x ,增设一个rank[x]数组,用以表达子树 x 的高度。在合并时,如果rank[x] < rank[y],则令pre[x] = y;否则令pre[y] = x 

(加权除了用深度以外,也可以用树中节点的大小来标记,具体使用哪种看题干所需,如T2316用节点数较为方便)

此时合并操作就为:

void union(int x,int y)

{

    x=find(x);                                                       //寻找 x的代表元

    y=find(y);                                                       //寻找 y的代表元

    if(x==y) return ;                                   //如果 x和 y的代表元一致,说明他们共属同一集合,则不需要合并,直接返回;否则,执行下面的逻辑

    if(rank[x]>rank[y]) pre[y]=x;               //如果 x的高度大于 y,则令 y的上级为 x

    else                                                               //否则

    {

        if(rank[x]==rank[y]) rank[y]++;   //如果 x的高度和 y的高度相同,则令 y的高度加1

        pre[x]=y;                                                //让 x的上级为 y

    }

}

初始化

void init(int n)                                 //初始化函数,对录入的 n个结点进行初始化

{

    for(int i = 0; i < n; i++){

        pre[i] = i;                         //每个结点的上级都是自己

        rank[i] = 1;                       //每个结点构成的树的高度为 1

    }

}

 

初始化后,通过题中输入的edges(即相连的点),使得点逐个合并,同时也会同时增加rank

即join(edge[0], edge[1])

 

并查集本质上可以用于存储具有某同一性质的数据元素

例如牛客24寒假6F,gcd不等于1的两个元素就应当看作是同一并查集里面的元素,在分配元素到b,c两个数组中时,同一个并查集中的元素一定要放到同一个数组中

因此现在的问题在于如何求出并查集的个数

对于有多次询问的问题,最好是能够预处理出一些信息,这样在后续进行操作的时候可以提升效率,例如此处,如果对每次询问都以O(n^2)的效率去处理各元素之间是否gcd为1,那么显然效率较低,但如果用素数的倍数去统计各个数,那么无论数组中具体元素如何,我们都能对此进行利用

void add(int w){

    int i=w,k;

    while(i>1){

        v[p[i]].push_back(w);

        //在对应质因子的并查集中加入该元素

       

        k=p[i];

        while(!(i%k))i/=k;//求出构成该元素的下一个质因子

    }

}

 

void del(int w){//清零

    int i=w,k;

    while(i>1){

        v[p[i]]={};

        k=p[i];

        while(!(i%k))i/=k;

    }

}

 

void dfs(int x,int T){//实际上就是在进行并查集的合并,因为题目中判断的实际上只是并查集数目是否大于1

    dfn[x]=T;//赋值dfs序

    int i=x,k;

    while(i>1){

        k=p[i];//k是它的质因子之一

        while(!(i%k))i/=k;

       

        if(dfn2[k]!=T){//说明没有遍历过

            dfn2[k]=T;

            for(auto j:v[k]) dfs(j,T);//这些元素与当前元素gcd都不为1,因此应当放入同一个并查集中

        }

    }

}

 

int main(){

    ios::sync_with_stdio(0); cin.tie(0);

    for(i=2;i<=N;i++){

        if(!p[i])for(j=i;j<=N;j+=i) p[j]=i;

        //每个数用它的最小质因子去表示(这个质因子实际上就是这个并查集的代表元)

    }

    cin>>t;

    for(int T=1;T<=t;T++){

        va=vb={};

        cin>>n;

        for(i=1;i<=n;i++){

            cin>>a[i];

            add(a[i]);

        }

       

        dfs(a[1],T);

       

        for(i=1;i<=n;i++){

            if(dfn[a[i]]==T)va.push_back(a[i]);

            else vb.push_back(a[i]);

        }

        if(vb.empty()){//说明实际上只有一个并查集

            cout<<"-1 -1\n";

        }

        else{

            cout<<va.size()<<' '<<vb.size()<<'\n';

            for(auto i:va){cout<<i<<' ';}cout<<'\n';

            for(auto i:vb){cout<<i<<' ';}cout<<'\n';

        }

       

        for(i=1;i<=n;i++)del(a[i]);

    }

}

 

杭电24多校2 1012

将一个具有n个点m条边的图复制d次,每个图相互独立,在这些图中随机加入无向边,可能会出现重边或者自环,问有多少个无序点对满足(u,v)在d+1张图上都连通,并且每次加边之后都要回答询问

复制的次数d比较小

实际上还是若干个等价类

记录一下每个点在每张图中是哪个连通块,这是一个长度为d+1的数组

如果点对满足条件,那么它们在d+1数组中的元素是相同的

要维护这个数组,可以维护哈希,需要启发式合并,总计会有k次合并

初始的时候,m=0,每个连通块的点本质上是一样的,可以认为没有边,只是每个连通块有大小

判断两个点是否在任意两张图都互相连通,可以转换成看两个点在每一张图中并查集所在集合的根(代表元)是否相同(因为是在初始相同的图上进行操作,因此通过合并策略的限制,可以保证代表元的一致性)

为了快速判断两个集合相等,可以对集合进行哈希,给每一张图的每个点随机一个ll范围内的数字作为哈希值,然后将所有点的哈希值进行求和或者异或作为集合的哈希值

因此只需要维护每一张图的并查集,同时对于每一个点,维护其所在集合的哈希值,因为不同的合并对于不同的点对之间的影响是不同的,因此在合并并查集的时候采用启发式合并去动态修改待合并点的哈希值,最后只需要通过哈希表来统计总共有多少个点对的哈希值相同即可

constexpr u64 P = u64(1e18)+9;//哈希

using Z = ModInt64<P>;

constexpr u64 B = 1145141LL;

void solve(){

    int n,m,d,k;

    cin>>n>>m>>d>>k;

    DSU init(n);//初始n个点

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        init.merge(u,v);

    }

    vector<int> bel(n);//初始的

    int cnt=0;//一开始有多少个连通块

    for(int i=0;i<n;i++){

        if(init.find(i)==i){

            bel[i]=cnt++;

        }

    }

    vector<int> siz(cnt);

    i64 ans=0;

    //每个点属于哪个并查集,用cnt进行标记

    for(int i=0;i<n;i++){

        bel[i]=bel[init.find(i)];

        ans+=siz[bel[i]]++;

        //每次在一个并查集中添加一个点,这个点和之前就在并查集中的点都能构成点对

    }

    //一共有d+1张图,每张图初始都可以视作是只有cnt个点的图

    vector g(d+1,DSU(cnt));

    vector vec(d+1,vector(cnt,list<int>{}));

    vector<Z> h(cnt);//hash

    unordered_map<u64,int> freq;

    vector<Z> pw(d+1);

    pw[0]=1ll;

    for(int i=1;i<=d;i++){

        pw[i]=pw[i-1]*B;

    }

    Z sum=accumulate(all(pw),Z{0LL});

    for(int i=0;i<cnt;i++){

        h[i]=sum*u64(i);//每个原始点的哈希值

        freq[h[i].val()]+=siz[i];

        for(int j=0;j<d+1;j++){

            vec[j][i].push_back(i);

        }//每个图中的点

        //freq存储对应哈希值的大小

    }

    while(k--){

        int u,v,w;

        cin>>u>>v>>w;

        u--,v--,w--;

        u=bel[u],v=bel[v];

        u=g[w].find(u);//在对应图中查找

        v=g[w].find(v);

        if(u!=v){

            if(g[w].size(u)<g[w].size(v)){//启发式合并

                swap(u,v);

            }

            for(auto y:vec[w][v]){

                freq[h[y].val()]-=siz[y];

                ans-=1ll*siz[y]*freq[h[y].val()];

                //删除这个点所减小的答案,siz[y]是这个点的大小

                //freq是这个哈希集合中除了y以外节点的个数

                h[y]+=i64(u-v)*pw[w];//y的哈希更新为与u相同

                ans+=1ll*siz[y]*freq[h[y].val()];

                freq[h[y].val()]+=siz[y];

                vec[w][u].push_back(y);

            }

            vec[w][v].clear();

            g[w].merge(u,v);

            //g是对应每个图的,freq是全局的

        }

        cout<<ans<<'\n';

    }

}

 

 

杭电24多校6 1001

把一个树分成两个菊花图,那么显然删去的点的度数一定是2

有一个n^2的做法就是检查所有度数为2的点,并且一次检查删去它们后,剩余的图是不是菊花图

但这样的复杂度显然有些假

因此考虑能不能减少检查的次数

考虑是否需要检查所有度数为2的点,反推回来,如果两个菊花图被一个点连接了起来,那么原图度数不为1的点不会有太多,通过推导可以发现应该不超过5个,因此每张图只需要检查五个不同的度数为2的点就可以了(当然多检查也无妨),这样整个的复杂度为线性的

(一棵树的度数是确定的,如果有n个点,那么就有n-1条边,那么总共的度数就是2*(n-1),因为删去一个度为2的点后得到两个菊花图,菊花图中至少有一个点的度数是那个图的点数-1,因此菊花图中作为中心的点的度数为n-1-2,这样能锁定的度数是2*(n-3),因此还剩余4个度数是可以任意分配的,再加上假设的一个点,因此度数大于2的点不超过5个)

检查整个图所有极大连通子图是不是菊花图可以用并查集,对每个连通块记录点的个数,边的个数和度数为1的点的个数,对于一个连通块,如果边的个数比点的个数少一个,且度数为1的点的个数和连通块的点数的个数差值不大于1,就说明这个图是菊花图

 

cf1994d

给定一张图,初始两两之间没有边

第x步选两个权值差是x的倍数的点连一条边,最终在n-1次操作后使得整张图连通

连边特点:实际上连边的两个点u和v是模x同余(等价类),可以看作是若干个团

这意味着一定有解,从后往前连边

因为根据抽屉原理,总共有n个点,那么一定至少有两个点模n-1相等,并且每一次操作之后,连通块的个数减1,同时下一次操作,模数的情况也会减1

因此每一次操作都至少能将未连通的点连上边

于是一定有解

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    DSU dsu(n);

    vector<int> u(n),v(n);

    for(int x=n-1;x>=1;x--){

        vector<int> b(x,-1);

        for(int i=0;i<n;i++){

            if(dsu.find(i)!=i){

                //因为保证了正确性,因此只找连通块的代表元

                continue;

            }

            if(b[a[i]%x]!=-1){

                u[x]=b[a[i]%x];

                v[x]=i;

                dsu.merge(u[x],v[x]);

                break;

            }

            b[a[i]%x]=i;

        }

    }

    cout<<"YES\n";

    for(int i=1;i<n;i++){

        cout<<u[i]+1<<' '<<v[i]+1<<'\n';

    }

}

 

 

cf2020D

给定一个数值n,初始状态从1到n每个点相互独立,每次操作给定三个整数a,d,k,将a,a+d,...a+k*d合并为一个连通块,问经过m次操作后的连通块个数

如果暴力的考虑,显然是每次操作对于每个数都进行合并,但这样的复杂度为O(nm)

考虑优化,因为要维护连通块,因此对k个数进行合并的操作显然是不能避免的,因此一方面虽然总共的操作次数m是无法被减少的,但考虑能否通过一些合并操作来减少实际的操作次数,另一方面是考虑到d的数据范围,能否围绕d进行一些优化操作

为了进行优化,显然不能在每次操作读取数据后就进行运算,因为这样仍不能避免m次操作,且无法综合地进行考虑,而且这并不是一个强制在线的回答,只是在所有的操作完成后返回连通块个数

因此用类似离线的想法,将每次操作的数存储下来统一操作

虽然不能避免对相隔为d的数都要两两合并,但即使直接遍历每个数去合并也只是O(n)的复杂度,因此实际上是考虑减少暴力想法中的m系数即可,这时想到d的数据范围为10,因此可以是以d为标志进行分组操作,外层是d的遍历,内部再逐个进行合并,具体合并的时候利用因为不再只是从a开始的等差数列,因此需要遍历整个1-n数组,这可以用类似前缀和的思想完成标记应当进行操作的数

void solve() {

    int n,m;

    cin>>n>>m;

    vector<int> a(m),d(m),k(m);

    for(int i=0;i<m;i++){

        cin>>a[i]>>d[i]>>k[i];

        a[i]--;

    }

    DSU dsu(n);

    int ans=n;

    for(int x=1;x<=10;x++){

        vector<int> f(n);

        //类似于前缀和的思想,只不过是相当于将相隔x的数作为实际的数组,然后计算这个数组中元素的前缀和

        for(int i=0;i<m;i++){

            if(d[i]==x){

                f[a[i]]++;

                f[a[i]+k[i]*x]--;

            }

        }

        for(int i=x;i<n;i++){

            f[i]+=f[i-x];

        }

        for(int i=0;i<n;i++){

            if(f[i]){

                ans-=dsu.merge(i,i+x);

            }

        }

    }

    cout<<ans<<'\n';

}

 

基于主席树的可持久化并查集(带权)

#include <bits/stdc++.h>

using namespace std;

struct SegmentTree {

int lc, rc, val, rnk;

};

const int MAXN = 100000 + 5;

const int MAXM = 200000 + 5;

 

SegmentTree t[MAXN * 2 + MAXM * 40]; //每次操作1会修改两次,一次修改父节点,一次修改父节点的秩

int rt[MAXM];

int n, m, tot;

 

int build(int l, int r) {//同样,P是节点编号,这里还是建树

int p = ++tot;

if (l == r) {

t[p].val = l;//区间中的位置就是val

t[p].rnk = 1;

return p;

}

int mid = (l + r) / 2;

t[p].lc = build(l, mid);

t[p].rc = build(mid + 1, r);

return p;

}

 

int getRnk(int p, int l, int r, int pos) { //查询秩

if (l == r) {

return t[p].rnk;

}

int mid = (l + r) / 2;

if (pos <= mid) {

return getRnk(t[p].lc, l, mid, pos);

}

else {

return getRnk(t[p].rc, mid + 1, r, pos);

}

}

 

int modifyRnk(int now, int l, int r, int pos, int val) { //修改秩(高度)

int p = ++tot;

t[p] = t[now];//同样是先复制上一个版本

if (l == r) {

t[p].rnk = max(t[p].rnk, val);

return p;

}

int mid = (l + r) / 2;

if (pos <= mid) {

t[p].lc = modifyRnk(t[now].lc, l, mid, pos, val);

}

else {

t[p].rc = modifyRnk(t[now].rc, mid + 1, r, pos, val);

}

return p;

}

 

int query(int p, int l, int r, int pos) { //查询父节点(序列中的值),p象征版本

if (l == r) {

return t[p].val;

}

int mid = (l + r) / 2;

if (pos <= mid) {

return query(t[p].lc, l, mid, pos);

}

else {

return query(t[p].rc, mid + 1, r, pos);

}

}

 

int findRoot(int p, int pos) { //查询根节点

int f = query(p, 1, n, pos);

if (pos == f) {

return pos;

}

return findRoot(p, f);

}

 

int modify(int now, int l, int r, int pos, int fa) { //修改父节点(合并)

int p = ++tot;

t[p] = t[now];

if (l == r) {

t[p].val = fa;

return p;

}

int mid = (l + r) / 2;

if (pos <= mid) {

t[p].lc = modify(t[now].lc, l, mid, pos, fa);

}

else {

[p].rc = modify(t[now].rc, mid + 1, r, pos, fa);

}

return p;

}

int main() {

scanf("%d%d", &n, &m);

rt[0] = build(1, n);

for (int i = 1; i <= m; i++) {

int op, a, b;

scanf("%d", &op);

if (op == 1) {

scanf("%d%d", &a, &b);

int fa = findRoot(rt[i - 1], a), fb = findRoot(rt[i - 1], b);

if (fa != fb) {

if (getRnk(rt[i - 1], 1, n, fa) >getRnk(rt[i - 1], 1, n, fb)) { //按秩合并

swap(fa, fb);

}

int tmp = modify(rt[i - 1], 1, n, fa, fb);

rt[i] = modifyRnk(tmp, 1, n, fb, getRnk(rt[i - 1], 1, n, fa) + 1);

}

else {

rt[i] = rt[i - 1];

}

}

else if (op == 2) {

scanf("%d", &a);

rt[i] = rt[a];

}

else {

scanf("%d%d", &a, &b);

rt[i] = rt[i - 1];

if (findRoot(rt[i], a) == findRoot(rt[i], b)) {

printf("1\n");

}

else {

printf("0\n");

}

}

}

return 0;

}

 

并查集判断图中是不是只有环:首先用并查集判断连通性,如果图中只有一个并查集那么就说明是满足条件,进一步的,如果边数等于顶点数,那么就说明只有一个环(看作是树,n-1条边,再加上一条边就形成了环),如果边数大于顶点数那么就说明有多个环 cf103B

 

树上并查集

可以用于处理树上动态连通性问题

 

可撤销并查集

Cf 2069f

如果图B的每个分量都是A的某个分量的子集,那么就说图A包括图B

现在有多次查询,每次相当于给定图A和图B,问图A需要添加的边数,以使得图A包括图B

对于给定的两个图,答案计算是简单的,需要添加的边数=A的连通块数目- 的连通块数目

证明是显然的,显然我们要进行操作的是针对那些在B中连通而在A中不连通的点,并通过加边将它们连接起来,注意到如果两个点u,v在A,B中任意一张图中连通,那么一定在 中也连通,而因为最终是要得到包括B的图,因此最终图的连通块数量就是 的连通块数量,每连接一条边就可以减少一个连通块数量,因此答案是A的连通块数目- 的连通块数目

以上是静态情况,但现在因为有多次查询,也就是要考虑进行增加修改边时图中连通块的情况

也就是动态图连通性问题,考虑离线使用线段树分治,需要使用启发式合并的可撤销并查集维护边联通情况

线段树分治是一种处理区间问题的技巧,主要用于处理带持久化信息的区间操作,核心思想是用线段树维护时间区间,在这个节点上记录这个时间区间内的所有事件,再递归处理每个区间

通过线段树分治来进行离线就是预先读取所有的加边和删边操作,然后记录在不同时刻有哪些边,也就是对时间区间加边,因为有加边和删边操作,因此存储这条边的加入时间t,当时刻i要删除这条边时,那么就说明[t,i)时间中,这条边是存在的

用线段树来离线存储边的类型(A图中还是B图中),然后再用可撤销并查集在时间区间上计算答案,同时因为是递归操作,在处理完当前情况后,撤销子区间操作,再返回到父节点计算另一个子区间

void solve() {

    int n,q;

    cin>>n>>q;

    map<array<int,2>,int> edges[2];

    vector<vector<array<int,3>>> f(4*q);

 

    auto addEdge = [&](auto self, int p, int l, int r, int x, int y, int t, int u, int v) {

        if (l >= y || r <= x) {

            return;

        }

        if (l >= x && r <= y) {

            f[p].push_back({t, u, v});

            return;

        }

        int m = (l + r) / 2;

        self(self, 2 * p, l, m, x, y, t, u, v);

        self(self, 2 * p + 1, m, r, x, y, t, u, v);

    };

 

    for(int i=0;i<q;i++){

        char o;

        cin>>o;

        int u,v;

        cin>>u>>v;

        u--,v--;

        if(u>v){

            swap(u,v);

        }

        int t=o-'A';

        if(edges[t].contains({u,v})){

            addEdge(addEdge,1,0,q,edges[t][{u,v}],i,t,u,v);

            edges[t].erase({u,v});

        }else{

            edges[t][{u,v}]=i;

        }

    }

    for(int t=0;t<2;t++){

        for(auto [e,i]:edges[t]){

            auto [u,v]=e;

            addEdge(addEdge,1,0,q,i,q,t,u,v);

        }

    }

 

    DSU dsu[2] {n,n};

    // A,B用于记录连通块数目,merge成功则减一

    // A代表图的并集,B代表a图

    auto work=[&](auto self,int p,int l,int r,int A,int B)->void{

        int tm[2]={(int)dsu[0].his.size(),(int)dsu[1].his.size()};

        for(auto [t,u,v]:f[p]){

            A-=dsu[0].merge(u,v);

            if(!t){

                B-=dsu[1].merge(u,v);

            }

        }

        if(r-l==1){

            cout<<B-A<<'\n';

        }else{

            int m=(l+r)/2;

            self(self,2*p,l,m,A,B);

            self(self,2*p+1,m,r,A,B);

        }

        for(int t=0;t<2;t++){

            dsu[t].undo(tm[t]);// 回溯

        }

    };

    work(work,1,0,q,n,n);  

}

 

 

并查集解决关系间的问题

食物链(P2024)

有3种关系,所以要开3倍大的数组,设:x+2n吃x+n,x+n吃x,x吃x+2n;y+2n吃y+n,y+n吃y,y吃y+2n

如果x与y同类则:link(x,y);link(x+n,y+n);link(x+2*n,y+2*n);

如果x吃y,则link(x+2*n,y);link(x+n,y+2*n);link(x,y+n);

就是将同一类的放在一个连通块中,总共三种类型因此设定三个连通块,pre表示是属于哪一类并且可据此得到他们之间的关系

每次输入后要先判断,再操作:

1 x y:判断x是否吃y,y是否吃x,都不满足的话,说明不是错误答案,则进行上述x与y同类的三步操作。

2 x y:判断x是否与y同类,y是否吃x,都不满足的话,说明不是错误答案,则进行上述x吃y的三步操作

int f(int x){

    return x==pre[x]?x:pre[x]=f(pre[x]);

}

void link(int a,int b){

    int root1=f(a),root2=f(b);

    pre[root1]=root2;

}

void solve(){

    int n,k,d,x,y;

    cin>>n>>k;

    int ans=0;

    for(int i=1;i<=3*n;i++){

        pre[i]=i;

    }

    for(int i=1;i<=n;i++){

        cin>>d>>x>>y;

        if(x>n||y>n){ans++;continue;}

        if(d==2&&x==y){ans++;continue;}

        if(d==1){//x,y同类

            if(f(y)==f(x+2*n)||f(x)==f(y+2*n)){ans++;continue;}

            link(x,y);

            link(x+n,y+n);

            link(x+2*n,y+2*n);

        }

        else{

            if(f(x)==f(y)||f(x)==f(y+2*n)){ans++;continue;}

            link(x+2*n,y);

            link(x+n,y+2*n);

            link(x,y+n);

        }

    }

    cout<<ans<<'\n';

}

//

法二(带权并查集):

带权并查集不仅记录集合之间的关系,还记录集合中每个元素的关系或者元素与父节点边之间的关系

通过设定父子关系进而通过路径长度来操作(d[x]用于表示当前节点与其根节点之间的关系,例如0是同类,1是吃,2是被吃,并且通过一般性的举例判断路径压缩时,权值的变化)

在同一集合中,我们可以通过每个点到根节点的距离来判断关系

比如两个点a,b在同一集合中如果( a − b ) % 3 = = 0 那么a和b就是同类,

因为存储的距离在运算中可能为负数,因此不用a % 3 = = b % 3

同理,( a − b − 1 ) % 3 = = 0 说明a吃b

int p[N],d[N];

int n,k;

int res=0;

int find(int x){

    if(x!=p[x]){//找祖先,不同的是要维护好路径

        int t=find(p[x]);//保存好p[x],以免直接将p[x]变为根节点

        d[x]+=d[p[x]];

        p[x]=t;

    }

    return p[x];

}

void solve(){

    cin>>n>>k;

    for(int i=1;i<=n;i++) p[i]=i;

    for(int i=1;i<=n;i++){

        int x,a,b;

        cin>>x>>a>>b;

        if(a>n||b>n) res++;

        else {

            int fa=find(a),fb=find(b);

            if(x==1) {

                if(fa==fb&&(d[a]-d[b])%3!=0) res++;//判断在同一集合时,判断其关系是否满足

                else if(fa!=fb){ //不在同一集合时,直接加入即可,处理好距离

                    p[fa]=fb;

                    d[fa]=d[b]-d[a];

                }

            }

            else{

                if(fa==fb&&(d[a]-d[b]-1)%3!=0) res++;

                else if(fa!=fb){

                    p[fa]=fb;

                    d[fa]=d[b]-d[a]+1;

                }

            }

        }

    }

    cout<<res<<'\n';

}

 

带权并查集

牛客24多校4 A

原题LCT    ICPC WF 2017 H

给定一棵有根树,每次询问前i条边组成的森林中,以第c[i]的点为根的树的深度

带权并查集维护每个点到当前根节点的距离deep[u]

每个根节点维护一个当前深度f[u]

当连接u,v,其中u为父亲时,设root[u]为此时u节点的根,那么可以更新此时的f(root[u])=max(f(root[u]),deep[u]+f(v)+1)

(f指的是以当前该节点为根的树的深度)

这样就可以在线维护答案,复杂度为并查集的复杂度

也可以将所有询问离线下来,这样想要知道deep[u],只需要在建完最终的树之后用u的深度减去root[u]的深度即可

(注意是边是有向的,因此以对应节点为根的树的深度是按照边向下走的深度,因此是h[q[i]]-dep[q[i]],而不用再判断它是否在最深的路径上,或者是否需要和到根节点的路径长度取max)

//离线

void solve(){

    int n;

    cin>>n;

    vector<int> f(n);

    for(int i=0;i<n;i++){

        f[i]=i;

    }

    auto find=[&](auto self,int x)->int{

        return f[x]==x?x:f[x]=self(self,f[x]);

    };

    vector<int> q(n),a(n),b(n);

    vector<vector<int>> adj(n);

    vector<int> not_root(n);

    for(int i=0;i<n-1;i++){

        cin>>a[i]>>b[i]>>q[i];

        --a[i];--b[i];--q[i];

        not_root[b[i]]=1;

        adj[a[i]].emplace_back(b[i]);

    }

    vector<int> dep(n),h(n);

    //h表示当前所在并查集中的最大深度,dep表示到根节点的距离

    auto dfs=[&](auto self,int u)->void{

        h[u]=dep[u];

        for(auto v:adj[u]){

            dep[v]=dep[u]+1;

            self(self,v);

        }

    };

    //建树

    for(int i=0;i<n;i++){

        if(!not_root[i]){

            dep[i]=0;

            dfs(dfs,i);

        }

    }

    for(int i=0;i<n-1;i++){

        int u=find(find,a[i]),v=find(find,b[i]);

        f[v]=u;

        h[u]=max(h[u],h[v]);

        cout<<h[q[i]]-dep[q[i]]<<' ';

    }

    cout<<'\n';

}

 

Cf 2104G

给定一个有向图,有n个点n条边,对于第i个点,它连接的点是g[i],可能有自环

起初,图中所有顶点的颜色都是1

对于给定的每个询问,给定x,y,k,表示首先将g[x]修改为y,也就是改变了连边,然后可以执行任意次操作,每次操作选择一个顶点和1到k中的任意一种颜色,将该顶点和所有这个顶点能达到的结点都修改成颜色k,问经过这样的操作后,可能得到的不同图着色方案数模3的结果,每次对边的修改是常驻的

首先仅考虑我们这个操作的性质

显然,如果一对顶点处于同一个强连通分量中,那么他们的颜色总是相同的,尽管我们可以进行任意次操作,只要我们确定最后每个强连通分量的颜色,那么总有一种方案使得我们得到这样的着色方案,因此任意有向图的答案是k^c,其中c是强连通分量的个数(也就是对强连通分量缩点后得到的DAG按照拓扑序进行染色,可以保证每个点都可以染成想要的颜色)

因为这里方案数比较特殊,仅仅考虑模3的情况

因此思考这个模3有没有什么其他的性质,如果 k mod 3 = 0 或者 k mod 3 = 1,那么k的任何幂都等于其本身,也就是唯一复杂的情况是k mod 3 = 2,这种情况下,偶数幂等于是1而奇数幂等于2

从而影响答案的实际上是强连通分量个数的奇偶性

由于原图每个点只有一个出边,因此上面提到的强连通分量只可能是环,进而强连通分量的个数取决于环上的点的个数,例如3个点缩成了1个点,那么连通分量个数的奇偶性没有发生变化,于是我们关注的是长度为偶数的环

因为每个点都只会有一条出边,于是有向边和无向边实际上是相同的,且我们关心的环上的点的数量也没有受到影响

因为每个点都有一条出边,因此每个连通分量中都会有一个环

所以我们实际关注的是没有奇环的连通分量的个数,换言之,需要维护的是无向图中二分图连通分量的数量,即将连通分量进行分类,要么是二分图要么不是二分图

二分图:一个图是二分图当且仅当它不含奇环

准确定义:二分图(Bipartite Graph)是一种特殊的图,其顶点可以被分成两个互不相交的集合 A 和 B,使得图中的每条边都连接一个 A 中的顶点和一个 B 中的顶点。也就是说,A 和 B 内部的顶点之间没有边相连。(可以用两种颜色进行着色,相邻顶点的颜色不同)

维护二分图可以使用带权二分图并查集,用于检测动态加边过程中图的二分性

具体在该题中的实现:
因为有边的修改,于是追踪每条边在哪些时间点存在,使用线段树来管理不同时间区间的边集,同时使用一个带有二分图检测功能的可撤销并查集来记录每次操作修改的变量及其原值(因为我们需要在遍历线段树时,每进入一个新节点都要基于父节点的状态添加新的边,然后退出时恢复到之前状态,因此需要可撤销并查集,二分图检测则是用来计算答案的),最后使用DFS遍历线段树,处理每个时间点的查询

void solve() {

    int n, q;

    cin >> n >> q;

    vector<int> g(n);

    for (int i = 0; i < n; i++) {

        cin >> g[i];

        g[i]--;

    }

 

    const int N = 4 << __lg(q);

    vector<vector<array<int, 2>>> edges(N);

    // edges[p]存储线段树节点p对应区间内存在的所有边

 

    // 添加边

    auto add = [&](auto self, int p, int l, int r, int x, int y, array<int, 2> e) {

        if (l >= y || r <= x) {

            return;

        }

        if (l >= x && r <= y) {

            edges[p].emplace_back(e);

            return;

        }

        int m = (l + r) >> 1;

        self(self, p << 1, l, m, x, y, e);

        self(self, p << 1 | 1, m, r, x, y, e);

    };

 

    // t[i]记录节点i最后一次修改的时间

    // k[i]存储第i个查询的数值

    vector<int> t(n), k(q);

    for (int i = 0; i < q; i++) {

        int x, y;

        cin >> x >> y >> k[i];

        x--, y--;

        add(add, 1, 0, q, t[x], i, {x, g[x]}); // g[x]作用的时间是[t[i],i)

        g[x] = y;

        t[x] = i;

    }

    for (int x = 0; x < n; x++) {

        add(add, 1, 0, q, t[x], q, {x, g[x]});

    }

 

    vector<int> ans(q);

    DSU dsu(n);

    auto dfs = [&](auto self, int p, int l, int r, int res) -> void {

        int t = dsu.timeStamp();

        int ores = res;

        // 维护添加前的状态,方便回滚

 

        // 将当前区间下的边加入dsu中,用于处理这些时间区间中的答案

        for (auto [x, y] : edges[p]) {

            dsu.merge(x, y, res);

        }

        // 叶子节点,计算该查询时间点的答案

        if (r - l == 1) {

            if (k[l] % 3 != 2) {

                ans[l] = k[l] % 3;

            } else { // 根据非二分图的数量的奇偶性计算答案

                ans[l] = (n - res) % 2 ? 2 : 1;

            }

        } else {

            // 继续递归

            int m = (l + r) >> 1;

            self(self, p << 1, l, m, res);

            self(self, p << 1 | 1, m, r, res);

        }

        // 回滚

        res = ores;

        dsu.rollback(t);

    };

    dfs(dfs, 1, 0, q, n);

    for (int i = 0; i < q; i++) {

        cout << ans[i] << "\n";

    }

}

 

 

欧拉回路:
欧拉回路:通过图中每条边恰好一次的回路

欧拉通路:通过图中每条边恰好一次的通路

欧拉图:具有欧拉回路的图

半欧拉图:具有欧拉通路但不具有欧拉回路的图

 

性质:

欧拉图中所有顶点的度数都是偶数。

若  G是欧拉图,则它为若干个环的并,且每条边被包含在奇数个环内

 

无向图是欧拉图当且仅当:

非零度顶点是连通的

顶点的度数都是偶数

无向图是半欧拉图当且仅当:

非零度顶点是连通的

恰有 2 个奇度顶点

有向图是欧拉图当且仅当:

非零度顶点是强连通的

每个顶点的入度和出度相等

有向图是半欧拉图当且仅当:

非零度顶点是弱连通的

至多一个顶点的出度与入度之差为 1

至多一个顶点的入度与出度之差为 1

其他顶点的入度和出度相等

 

无向图G存在欧拉通路的充要条件是:

G为连通图,并且G仅有两个奇度结点(度数为奇数的顶点)或者无奇度结点。

推论1:

1) 当G是仅有两个奇度结点的连通图时,G的欧拉通路必以此两个结点为端点。
2) 当G是无奇度结点的连通图时,G必有欧拉回路。
3)  G为欧拉图(存在欧拉回路)的充分必要条件是G为无奇度结点的连通图。

 

有向图D存在欧拉通路的充要条件是:

D为有向图,D的基图连通,并且所有顶点的出度与入度都相等;或者除两个顶点外,其余顶点的出度与入度都相等,而这两个顶点中一个顶点的出度与入度之差为1,另一个顶点的出度与入度之差为-1。

推论2:
1) 当D除出、入度之差为1,-1的两个顶点之外,其余顶点的出度与入度都相等时,D的有向欧拉通路必以出、入度之差为1的顶点作为始点,以出、入度之差为-1的顶点作为终点。
2) 当D的所有顶点的出、入度都相等时,D中存在有向欧拉回路。
3) 有向图D为有向欧拉图的充分必要条件是D的基图为连通图,并且所有顶点的出、入度都相等。

 

欧拉回路的求解

BEST定理:对于一张存在欧拉回路的有向图,欧拉回路条数为 其中deg表示x的出度,T为以x为根的内向树个数

 

求欧拉回路一个比较简单的求法是直接dfs,然后要求每条边只能被遍历到一次,比如dfs(u)找到了出边(u,v),则dfs(v)完后加上v->u这条边(就是在只有dfs要退出的时候加上出边)

void dfs(int u){

    for(auto [v,id]:e[u]){

        if(!vis[id]){

            vis[id]=1;

            dfs(v);

            output.push_back(id);

        }

    }

}

然后从输出栈(也就是output)的栈顶开始输出

 

fleury算法

也称避桥法,是一个偏暴力的算法。

算法流程为每次选择下一条边的时候优先选择不是桥的边

任取G中的一顶点v0,令P0=v0,假设Pi沿着v0e1v1e2...eivi走到顶点vi,选择ei+1的策略是:
ei+1与vi相关联,除非无别的边可以选择,否则ei+1不应是Gi=G-{e1,e2...ei}中的桥

当算法不能继续时,所得到的简单回路Pm为G中的一条欧拉回路

(桥:删除ei后所得到的子图由连通变成了不连通)

int ans[200];

int top;

int N,M;

int mp[200][200];

void dfs(int x){

    int i;

    top++;

    ans[top]=x;

    for (i=1; i<=N; i++){

        if(mp[x][i]>0){

            mp[x][i]=mp[i][x]=0;///删除此边

            dfs(i);

            break;

        }

    }

}

void fleury(int x){

    int brige,i;

    top=1;

    ans[top]=x;///将起点放入Euler路径中

    while(top>=0){

        brige=0;

        for (i=1; i<=N; i++){//搜索一条边不是割边(桥)

            if(mp[ans[top]][i]>0){//存在一条可以扩展的边

                brige=1;

                break;

            }

        }

        if(!brige){// 如果没有点可以扩展,算法结束,输出回路

            printf("%d ", ans[top]);

            top--;

        }

        else{

            top--;///为了回溯

            dfs(ans[top+1]);

        }

    }

}

 

int main(){

    int x,y,deg,num,start,i,j;

    scanf("%d%d",&N,&M);

    memset(mp,0,sizeof (mp));

    for(i=1;i<=M; i++){

        scanf("%d%d",&x,&y);

        mp[x][y]=1;

        mp[y][x]=1;

    }

    num=0;

    start=1;///这里初始化为1

    for(i=1; i<=N; i++){

        deg=0;

        for(j=1; j<=N; j++){

            deg+=mp[i][j];

        }

        if(deg%2==1){

            start=i;

            num++;

        }

    }

    if(num==0||num==2){

        fleury(start);

    }

    else{

        puts("No Euler path");

    }

    return 0;

}

 

cf1994F

构造一个欧拉回路,但特别的是,有些边是必须经过的,另外一些边是可选可不选的

但因为必须要选择的边是保证连通的,因此不需要考虑连通性只需要考虑度数

因此用可选的边将度数构造正确即可(通过在必须选的边所导出的图中添加边来保证每个点的度数都是偶数)

void solve(){

    int n,m;

    cin>>n>>m;

    vector<vector<int>> adj(n);

    vector<int> u(m),v(m),c(m);

    vector<int> deg(n);

    for(int i=0;i<m;i++){

        cin>>u[i]>>v[i]>>c[i];

        u[i]--,v[i]--;

        //记录节点度数的奇偶性

        deg[u[i]]^=c[i];

        deg[v[i]]^=c[i];

        //欧拉回路与边相关,直接记录相连的边

        adj[u[i]].emplace_back(i);

        adj[v[i]].emplace_back(i);

    }

    vector<bool> vis(n);

    auto dfs=[&](auto self,int x)->void{

        vis[x]=true;

        for(auto i:adj[x]){

            int y=u[i]^v[i]^x;//找被这条边所连接的另一个点

            //并没有父节点的限制

            if(vis[y] || c[i]==1){

                continue;

            }

            self(self,y);

            if(deg[y]==1){

            //那么这条边选上

                deg[x]^=1;

                deg[y]^=1;

                c[i]=1;

            }

        }

    };

    for(int x=0;x<n;x++){

        if(vis[x]){

            continue;

        }

        dfs(dfs,x);

        if(deg[x]==1){//有奇数度的点

            cout<<"NO\n";

            return;

        }

    }

    cout<<"YES\n";

    vector<int> ans;

    //欧拉回路的构建,每次选择未被选择过的边即可

    auto find=[&](auto self,int x)->void{

        while(!adj[x].empty()){

            int i=adj[x].back();

            adj[x].pop_back();

            int y=u[i]^v[i]^x;

            if(c[i]==0){//没被选上或者已经被选过了

                continue;

            }

            c[i]=0;

            self(self,y);

        }

        ans.emplace_back(x);

    };

    find(find,0);

    cout<<ans.size()-1<<'\n';

    for(int i=0;i<ans.size();i++){

        cout<<ans[i]+1<<' ';

    }

    cout<<'\n';

}

 

 

密顿回路(旅行商问题)

问题简介:
令G=(V, E)是一个带权重的有向图,顶点集V=(v0, v1, ..., vn-1)。从图中任一顶点vi出发,经图中所有其他顶点一次且只有一次,最后回到同一顶点vi的最短路径

动态规划方程(想到动态规划,就感觉和floyd求最短路有点类似了,关键就在于枚举中间转移点):
假设从顶点s出发,令d(i,V)表示从顶点i出发经过V(是一个点的集合)中每一个顶点一次且仅一次,最后回到出发点s的最短路径长度

如果V为空集,那么d(i,V)表示直接从i回到s了,那么d(i,V)=cost(i,s)

如果V不为空,那么可以转化成对子问题的求解,即先确定下一步要走到哪里,这需要遍历这个城市集合,在此之中选择一个最优方案

有d(i,V)=min(c(i,k)+d(k,V-(k)))

当数据量较小的情况时,我们显然可以用状态压缩的方法,将可以选择的集合转化为一个数字

并且计算好dp之后,我们可以从dp数组反推出我们经过的路径

static const int M = 1 << (N-1);//状态压缩

//保存顶点i到状态s最后回到起始点的最小距离

int dp[N][M] ;

//保存路径

vector<int> path;

void TSP(){

    //初始化dp[i][0]

    for(int i = 0 ; i < N ;i++){

        dp[i][0] = g[i][0];

    }

    //因为我们转移的时候,是从集合较小的转移到集合较大的,因此先枚举j再枚举i

    for(int j = 1 ; j < M ;j++){

        for(int i = 0 ; i < N ;i++ ){

            dp[i][j] = INF;

            //如果j中包含结点i,则不符合条件退出,因为我们是从i出发经过j中的节点,因此i不应该包括在j中

            if( ((j >> (i-1)) & 1) == 1){

                continue;

            }

            for(int k = 1 ; k < N ; k++){

                if( ((j >> (k-1)) & 1) == 0){

                    continue;

                }

                if( dp[i][j] > g[i][k] + dp[k][j^(1<<(k-1))]){ //以k为中间节点

                    dp[i][j] = g[i][k] + dp[k][j^(1<<(k-1))];

                }

            }

        }

    }

}

//判断结点是否都已访问,不包括0号结点

bool isVisited(bool visited[]){

    for(int i = 1 ; i<N ;i++){

        if(visited[i] == false){

            return false;

        }

    }

    return true;

}

//获取最优路径,保存在path中,根据动态规划公式反向找出最短路径结点

void getPath(){

    //标记访问数组

    bool visited[N] = {false};

    //前驱节点编号

    int pioneer = 0 ,min = INF, S = M - 1,temp ;

    //把起点结点编号加入容器

    path.push_back(0);

    while(!isVisited(visited)){

        for(int i=1; i<N;i++){

            if(visited[i] == false && (S&(1<<(i-1))) != 0){

                if(min > g[i][pioneer] + dp[i][(S^(1<<(i-1)))]){

                    min = g[i][pioneer] + dp[i][(S^(1<<(i-1)))] ;

                    temp = i;

                }

            }

        }

        pioneer = temp;

        path.push_back(pioneer);

        visited[pioneer] = true;

        S = S ^ (1<<(pioneer - 1));

        min = INF;

    }

}

//输出路径

void printPath(){

    cout<<"最小路径为:";

    vector<int>::iterator  it = path.begin();

    for(it ; it != path.end();it++){

        cout<<*it<<"--->";

    }

    //单独输出起点编号

    cout<<0;

}

 

哈密顿路径

牛客24多校6 F

给定一个森林,求其补图的Hamilton路径

(哈密顿路径恰好访问每个顶点一次,森林是一个没有循环的图形,补图是相同顶点上的图形H,使得两个不同的顶点当且仅当它们在G中不相邻时,在H中相邻)

补图往往是比较复杂的,因此将寻找补图中的Hamilton路径转化到在原图中寻找不相邻的点,因为在树中,同一层的节点之间一定是没有边相邻的,因此尝试建成一棵树,然后通过dfs求出深度(也就是bfs中的层)来进行标号,然后我们就可以根据编号进行分类了

因为除了菊花图外,补图一定是连通的,因此如果想要进行转化,我们必须将原图也变得的连通,否则连通块之间的处理可能会出现问题,因此我们人为的连接各个树之间的直径使得图连通

注意到一棵菊花是无解的(因为菊花的补图是有孤立点的),而剩下的情况都可以构造出答案

假设图是一个森林(特判n<=3),把图补成一棵树,做法是将每一棵树的直径连接起来,形成的树必然不是一棵菊花,所以下文只考虑树的情况

仍然考虑抽出树的直径,其上点数一定>=4,考虑从直径的一端开始bfs分层,把所有点按照到直径的距离分类,按照246,...,135...的顺序将所有层的点串起来即可,这样能保证构造方案中相邻点的绝对值差不是1,注意到同层之间的点不会有边,而按照这个顺序将所有点串起来,相邻的点间隔都>=2,也不会有边

另一种方法是进行二分图染色,因为这样得到的二分图一定不是完全二分图,那么这种情况下一定存在一条边是从左边的点到右边点但是不存在于原图中,这样我们只需要把左边点排在前面,右边点排在后面,中间用这条边连接

void solve(){

    int n,m;

    cin>>n>>m;

    vector<int> deg(n);

    vector<int> f(n);

    for(int i=0;i<n;i++){

        f[i]=i;

    }

    vector<vector<int>> adj(n);

    auto find=[&](auto self,int x)->int{

        return f[x]==x?x:f[x]=self(self,f[x]);

    };

    for(int i=0;i<m;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        deg[u]++;deg[v]++;

        adj[u].push_back(v);

        adj[v].push_back(u);

        u=find(find,u);

        v=find(find,v);

        f[u]=v;

    }

    if(n==1){

        cout<<1<<'\n';

        return;

    }

    if(n==2){

        if(m==1){

            cout<<-1<<'\n';

            return;

        }else{

            cout<<1<<' '<<2<<'\n';

            return;

        }

    }

    if(n==3){

        if(m==2){

            cout<<-1<<'\n';

            return;

        }else if(m==1){

            for(int i=0;i<3;i++){

                if(deg[i]){

                    cout<<i+1<<' ';

                    break;

                }

            }

            for(int i=0;i<3;i++){

                if(!deg[i]){

                    cout<<i+1<<' ';

                    break;

                }

            }

            for(int i=2;i>=0;i--){

                if(deg[i]){

                    cout<<i+1<<'\n';

                    return;

                }

            }

        }else{

            cout<<"1 2 3\n";

            return;

        }

    }

    for(int i=0;i<n;i++){//菊花图

        if(deg[i]==n-1){

            cout<<"-1\n";

            return;

        }

    }

    vector<int> dep(n);

    auto dfs=[&](auto self,int u,int fa,int d)->pair<int,int>{

        dep[u]=d;

        pair<int,int> res={d,u};

        for(int v:adj[u]){

            if(v==fa) continue;

            res=max(res,self(self,v,u,d+1));

        }

        return res;

    };

    int pre=-1;

    for(int i=0;i<n;i++){

        if(f[i]!=i) continue;//每棵树的根

        auto [d0,u]=dfs(dfs,i,-1,0);

        auto [d,v]=dfs(dfs,u,-1,0);

        //dfs找直径,u,v是直径的两端

        if(pre!=-1){

            //连接不同树的直径

            adj[u].emplace_back(pre);

            adj[pre].emplace_back(u);

        }

        pre=v;

    }

    //将森林建成树后的直径,dep是到直径的距离

    auto [d0,u]=dfs(dfs,0,-1,0);

    auto [d,v]=dfs(dfs,u,-1,0);

    vector<vector<int>> g(n);

    for(int i=0;i<n;i++){//按层分类

        g[dep[i]].emplace_back(i);

    }

    for(int i=1;i<n;i+=2){

        for(int x:g[i]){

            cout<<x+1<<' ';

        }

    }

    for(int i=0;i<n;i+=2){

        for(int x:g[i]){

            cout<<x+1<<' ';

        }

    }

    cout<<'\n';

}

 

 

分约束:
差分约束系统是一种特殊的n元一次不等式,其包含n个变量x1,x2,...xn以及m个约束条件,每个约束条件是由两个其中的变量作差构成的,形如xi-xj<=ck,我们要做的就是求一组解x1=a1,x2=a2...使得所有的约束条件得到满足

差分约束系统中的每个约束条件xi-xj<=ck可以变形为xi<=xj+ck,这和在计算单源最短路中的三角形不等式dis[y]<=val+dis[x]十分相似,因此我们就从这个形式去构图

这样转化为一个图论问题,对于每一个xi-xj<ck,我们都从xi向xj建一条边,边权为ck

这样建出的有向图,它的每个顶点都对应差分约束系统中的一个未知量,并且源点到每个顶点的最短路对应这些未知量的值,而每条边对应一个约束条件

现在要做的就是确定源点,实际上取哪个点为源点是无关紧要的,但是,有时候我们得到的图是不联通的,这样可能导致最后的答案错误,因此我们人为地增加一个超级源点,它向所有顶点连一条边权为0的边

这样我们只需要以0号点为源点求各点的最短路即可

因为我们实际上要求的非负解,因此反映在图形上就是所有点的最短路均小于等于0,因此一般使用Bellman–Ford 或队列优化的 Bellman–Ford(俗称 SPFA,在某些随机图跑得很快)判断图中是否存在负环

负环即如果一直沿着负环走,最短路径将会越来越小,最后到达 −∞。

这样我们得到的只是一组解,但显然,在符合差分约束系统的一组解上加上或减去同一个数,得到的解同样符合原系统

其实我们可以把 dist[0] 设为另一个数 w 而不是0(或者把从0号点连向各点的边权设为 w ),那么我们得到的便是满足 x1, x2, ...≤w的一组解。实际上,可以证明,它们是满足x1, x2, ...,≤w的最大解(每个变量取到能取到的最大值)

相应的,如果要求x1,x2,...≥w的最小解呢?只需要求最长路就行了。最长路满足三角形不等式 dist[u]≥dist[v]+w(即实现的时候,如果dis[i]+w>dis[j],那么就将dis[j]更新为dis[i]+w),所以相应的差分约束系统需要把小于等于符号换成大于等于符号。

对于Bellman-Ford或SPFA算法来说,只需要初始化为-INF而不是INF,然后把比较符号颠倒一下即可(即用最短路求时出现负环,或用最长路时出现正环这两种情况会使得答案无解)

 

有些题目中的约束条件很可能不会只局限于形如xj−xi​≤k,有可能会有 xj−xi≥k 或xj−xi=k 之类的式子存在。其实只需改动建边方法即可解决。xj−xi=k 时,只需将它拆成 xj−xi≤k 和 xj−xi≥k 两种情况建两条边即可

 

 

Bellman-Ford 算法描述:

除源点外的所有顶点最短距离初始化为 ∞,源点 d1 最短距离为 0。

对边集 E 进行n−1 次松弛操作。

检查是否存在负权边,即是否存在未收敛的点。若存在,说明图存在负环,原不等式组无解;否则di即为 xi 的一个解

 

因为图的最短路径不包含负环或正环,故显然最多只能包含 n−1 条边,因此进行n-1次松弛操作,一次松弛操作即描述点 s 到点 v 的最短路权值上界,反复进行松弛操作可求出两点最短路。举个例子,若要松弛 (u,v) 一边,即是取 s→v 的最短路与 s→u→v 的最短路的最小值;未收敛即为仍能松弛,此时路径仍非最短。若经过 n−1 轮松弛操作后仍能松弛,说明图存在负权回路

 

因此可以写作:

bool Bellman_Ford() {

    for (int i = 0; i < n; i++) {

    bool jud = false;

    for (int j = 1; j <= n; j++)

        for (int k = h[j]; ~k; k = nxt[k])

        if (dist[j] > dist[p[k]] + w[k])

            dist[j] = dist[p[k]] + w[k], jud = true;

    if (!jud) break;//已经不能继续松弛了,提前退出

    }

    for (int i = 1; i <= n; i++)

    for (int j = h[i]; ~j; j = nxt[j])

        if (dist[i] > dist[p[j]] + w[j]) return false;//发现仍能松弛,说明存在负环

    return true;

}

 

如果是spfa

bool spfa(int u) {

    memset(vis, false, sizeof(vis));

    vis[u] = true;

    memset(dis, -1, sizeof(dis));

    dis[u] = 0;

    memset(in, 0, sizeof(in));

    in[u] = 1;

    queue<int> q;

    q.push(u);

    while (!q.empty()) {

        u = q.front();

        q.pop();

        vis[u] = false;

        for (int j = head[u]; ~j; j = e[j].nxt) {

            int v = e[j].v;

            int w = e[j].w;

            if (dis[v] < dis[u] + w) {

                dis[v] = dis[u] + w;

                if (!vis[v]) {

                    q.push(v);

                    vis[v] = true;

                    ++in[v];

                    if (in[v] > n + 1) return true;

                    //入队不止n+1次,说明存在环

                }

            }

        }

    }

    return false;

}

 

P5960(差分约束模版)

struct edge {

    int v, w, fail;

    edge() {}

    edge(int _v, int _w, int _fail) {

        v = _v;

        w = _w;

        fail = _fail;

    }

} e[M];

int head[N], len;

void init() {

    memset(head, -1, sizeof(head));

    len = 0;

}

void add(int u, int v, int w) {

    e[len] = edge(v, w, head[u]);

    head[u] = len++;

}

int n, m;

int dis[N], in[N];

bool vis[N];

bool spfa(int u) {

    memset(vis, false, sizeof(vis));

    vis[u] = true;

    memset(dis, -1, sizeof(dis));

    dis[u] = 0;

    memset(in, 0, sizeof in);

    in[u] = 1;

    queue<int> q;

    q.push(u);

    while (!q.empty()) {

        u = q.front();

        q.pop();

        vis[u] = false;

        for (int j = head[u]; ~j; j = e[j].fail) {

            int v = e[j].v;

            int w = e[j].w;

            if (dis[v] < dis[u] + w) {

                dis[v] = dis[u] + w;

                if (!vis[v]) {

                    q.push(v);

                    vis[v] = true;

                    ++in[v];

                    if (in[v] > n + 1) return true;

                }

            }

        }

    }

    return false;

}

 

int main() {

    init();

    int u, v, w, op;

    cin >> n >> m;

    while (m--) {

        cin >> u >> v >> w;

        add(u, v, -w);

    }

    for (int i = 1; i <= n; ++i) add(0, i, 0);

    if (spfa(0)) cout << "NO" << endl;

    else for (int i = 1; i <= n; ++i) cout << dis[i] << " ";

    return 0;

}

 

 

图:
一般的建图

vector<vector<int>> g  建无向边

struct edge{

int to,v;

}

vector<vector<edge>> g 带有边权

int g[][] 直接记录边权

链式前向星:

用二维数组(邻接矩阵)进行存图就是稠密图

用链式前向星(领接表)进行存图就是稀疏图

可以在建图时使用

简单的写可以写作

int[] he = new int[N], e = new int[M], ne = new int[M], w = new int[M];

// 加边操作

//相当于这个链表是倒着建立的,最后一条边在访问时是第一个被访问到的,而它的next指向的是上一个以这个节点为起始节点的边

void add(int a, int b, int c) {

    e[idx] = b;

    ne[idx] = he[a];//以u为起点上一条边的编号,也就是与这个边起点相同的上一条边的编号

    w[idx] = c;

    he[a] = idx++;//更新以u为起点上一条边的编号,注意是idx++,所以是将当前的赋给了he

}

首先 idx 是用来对边进行编号的,然后对存图用到的几个数组作简单解释:

he 数组:表示以 i 为起点的最后一条边的编号;

e 数组:由于访问某一条边指向的节点;

ne 数组:由于是以链表的形式进行存边,该数组就是用于找到下一条边;

w 数组:用于记录某条边的权重为多少。

当我们想要遍历所有由 a 点发出的边时,可以使用如下方式

for (int i = he[a]; i != -1; i = ne[i]) {

    int b = e[i], c = w[i]; // 存在由 a 指向 b 的边,权重为 c

}

i^1是链式前向星找反向边的操作,即无向图中两条边的另一条

 

 

链式前向星其实就是静态建立的邻接表,时间效率为O(m),空间效率也为O(m)。遍历效率也为O(m)

#include<bits/stdc++.h>

using namespace std;

const int maxn = 1005;//点数最大值

int n, m, cnt;//n个点,m条边

struct Edge

{

    int to, w, next;//终点,边权,同起点的上一条边的编号

}edge[maxn];//边集

int head[maxn];//head[i],表示以i为起点的第一条边在边集数组的位置(编号)

void init()//初始化

{

    for (int i = 0; i <= n; i++) head[i] = -1;  //初始化head,也利于之后的加边操作

    cnt = 0;

}

void add_edge(int u, int v, int w)//加边,u起点,v终点,w边权

{

    edge[cnt].to = v; //终点

    edge[cnt].w = w; //权值

    edge[cnt].next = head[u];//以u为起点上一条边的编号,也就是与这个边起点相同的上一条边的编号(这里体现了类似链的感觉)

    head[u] = cnt++;//更新以u为起点上一条边的编号

}

int main()

{

    cin >> n >> m;

    int u, v, w;

    init();//初始化

    for (int i = 1; i <= m; i++)//输入m条边

    {

        cin >> u >> v >> w;

        add_edge(u, v, w);//加边

        /*

        加双向边

        add_edge(u, v, w);

        add_edge(v, u, w);

        */

    }

    for (int i = 1; i <= n; i++)//n个起点

    {

        cout << i << endl;

        for (int j = head[i]; j != -1; j = edge[j].next)

//遍历以i为起点的边,head为该起点最后一条边,根据next一次向上遍历

        {

            cout << i << " " << edge[j].to << " " << edge[j].w << endl;

        }

        cout << endl;

    }

    return 0;

}

 

基环树与拓扑排序:

从 i向 favorite[i]连边,我们可以得到一张有向图。由于每个大小为 k的连通块都有 k个点和 k条边,所以每个连通块必定有且仅有一个环,且由于每个点的出度均为 1,这样的有向图又叫做内向基环树 (pseudotree),由基环树组成的森林叫基环树森林 (pseudoforest)

每一个内向基环树(连通块)都由一个基环和其余指向基环的树枝组成,特别地,我们得到的基环可能只包含两个节点,即1<-->2

我们可以通过一次拓扑排序「剪掉」所有树枝,因为拓扑排序后,树枝节点的入度均为 0,基环节点的入度均为 1。这样就可以将基环和树枝分开,从而简化后续处理流程:

·如果要遍历基环,可以从拓扑排序后入度为 1的节点出发,在图上搜索;

·如果要遍历树枝,可以以基环与树枝的连接处为起点,顺着反图来搜索树枝(搜索入度为 0的节点),从而将问题转化成一个树形问题

例输入favorite = [2,2,1,2]   favorite[i]既是i指向的节点

int n = g.size();         // g就是内向基环森林favorite

        vector<vector<int>> rg(n);  // g 的反图,即将入改为出

        vector<int> deg(n); // g 上每个节点的入度

        for (int v = 0; v < n; ++v) {

            int w = g[v];

            rg[w].emplace_back(v);

            ++deg[w];

        }

        // 拓扑排序,剪掉 g 上的所有树枝

        queue<int> q;

        for (int i = 0; i < n; ++i) {

            if (deg[i] == 0) {    //树枝的子节点

                q.emplace(i);

            }

        }

        while (!q.empty()) {

            int v = q.front();

            q.pop();

            int w = g[v]; // v 只有一条出边

            if (--deg[w] == 0) {   //只有碰到了基环与树枝的连接点时,其入度-1后才不为1

                q.emplace(w);

            }

        }

// 通过反图 rg 寻找树枝上最深的链

        function<int(int)> rdfs = [&](int v) -> int {

            int max_depth = 1;

            for (int w: rg[v]) {  //相当于计算一般树的最大深度

                     if (deg[w] == 0) {

// 树枝上的点在拓扑排序后,入度均为 0,而基环上的点为-1

                    max_depth = max(max_depth, rdfs(w) + 1);

                }

            }

            return max_depth;

        };

 

        int max_ring_size = 0, sum_chain_size = 0;

        for (int i = 0; i < n; ++i) {

            if (deg[i] <= 0) {   //跳过树枝

                continue;

            }

            // 遍历基环上的点(拓扑排序后入度大于 0)

            deg[i] = -1;

            int ring_size = 1;

            for (int v = g[i]; v != i; v = g[v]) {

                deg[v] = -1; // 将基环上的点的入度标记为 -1,避免重复访问

                ++ring_size;

            }

            if (ring_size == 2) sum_chain_size += rdfs(i) + rdfs(g[i]); // 基环大小为 2,累加两条最长链的长度

            else max_ring_size = max(max_ring_size, ring_size); // 取所有基环的最大值

        }

同时,可以在拓扑排序的同时计算出最长链的长度,这样就不需要建反图和在反图上找最长链了,从而节省不少时间、空间和代码量

int max_depth[n]; memset(max_depth, 0, sizeof(max_depth));

        queue<int> q;

        for (int i = 0; i < n; ++i)

            if (deg[i] == 0) q.emplace(i);

        while (!q.empty()) {  // 拓扑排序,剪掉 g 上的所有树枝

            int v = q.front();

            q.pop();

            ++max_depth[v];

            int w = g[v]; // v 只有一条出边

            max_depth[w] = max(max_depth[w], max_depth[v]);

//v 指向 w(w 相当于是 v 的父节点),max_depth[w] = max(max_depth[w], max_depth[v]) 这个式子就是取所有儿子的最大深度

            if (--deg[w] == 0) q.emplace(w);

        }

 

当具有N个点N条边的连通图不保证联通时,就会成为基环树森林

内向树的定义是每个点有且只有一条出边,而外向树的定义是每个点有且只有一条入边

基环树的一种常用处理方式是断环,也就是断环成树(或者直接将环当作根节点),然后将若干棵树处理好之后,再考虑环对答案的影响。也就是将环、树分开讨论解决问题

 

基环树

基环树找环

蓝桥 2017 国B 发现环

因为是基环树,因此保证只有一个环

于是可以先使用并查集找到环,也就是每次将边的两个端点进行合并,如果合并失败,那么就说明这两个点是环上的点,分别作为路径查找的起点和终点

又因为是基环树上的环,也就是两个点之间的路径是唯一的,因此可以用回溯的方法进行dfs,如果子节点能够达到路径终点,那么就保留,否则在path中删除这个节点

void solve() {

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    vector<array<int,2>> edges(n);

    for(int i=0;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

        edges[i]={u,v};

    }

    DSU dsu(n);

    int st=-1,end=-1;

    for(int i=0;i<n;i++){

        auto [u,v]=edges[i];

        if(!dsu.merge(u,v)){

            st=u;

            end=v;

            break;

        }

    }

    vector<int> vis(n);

    vector<int> path;

    auto dfs=[&](auto self,int u,int p,vector<int>& path)->bool{

        vis[u]=1;

        path.emplace_back(u);

        if(u==end){

            return true;

        }

        for(int v:adj[u]){

            if(v != p && !vis[v]){

                if(self(self,v,u,path)){

                    return true;

                }

            }

        }

        path.pop_back();

        return false;

    };

    dfs(dfs,st,-1,path);

    sort(path.begin(),path.end());

    for(auto i:path){

        cout<<i+1<<" \n"[i==path.back()];

    }

}

或者可以使用类似于拓扑排序的思想

显然,基环树中的所有叶子节点(度为1的节点)一定不在环上,通过反复删除度为1的节点,最终留下的是在环上的所有节点

因此每次取出度为1的节点并加入队列,每次取出节点并相应更改节点度数

最终队列为空时,所有度大于1的节点就是那些在环上的点

 

CCPC2024 深圳F

给定一个n个点n条边的无向图,图没有重边和自环

要求有多少种选择图上一个点和一条边的方案,使得删去该边后图变成一棵树,且该树以选定的点为根节点时,每个节点的儿子个数均不超过3(题目保证至少存在一种这样的方案)

因为保证存在一个方案,所以给定的图一定是一个基环树(也就是在暑上添加一条边形成的图),且删除的边一定在环上

显然,我们需要枚举删除的边,同时需要快速确定选根的方案数

考虑一个点作为根的条件:每个点的儿子个数不超过3,也就是根的度数不能超过3,其余节点的度数不能超过4

因此可以维护每种度数的出现次数(有解时,所有节点的度数均不会超过5),删边时修改相邻两个节点的度数,当没有节点的度数超过5时,选根的方案数就是度数不超过3的节点个数

void solve(){

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    vector<int> deg(n);

    for(int i=0;i<n;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

        deg[u]++;

        deg[v]++;

    }

    vector<int> pa(n);

    pa[0]=-1;

    vector<int> vis(n);

    int start=-1,end=-1;

    auto dfs=[&](auto self,int x)->void{

        // 找环,同时确定一条环的路径

        vis[x]=1;

        for(auto y:adj[x]){

            if(y==pa[x]){

                continue;

            }

            if(!vis[y]){

                pa[y]=x;

                self(self,y);

            }else if(start==-1){

                start=x;

                end=y;

            }

        }

    };

    vector<int> cnt(6);

    for(int i=0;i<n;i++){

        cnt[min(5,deg[i])]++;

    }

    dfs(dfs,0);

    i64 ans=0;

    auto work=[&](int x,int y)->void{

        // 删除

        for(auto v:{x,y}){

            cnt[min(5,deg[v])]--;

            deg[v]--;

            cnt[min(5,deg[v])]++;

        }

        if(cnt[5]==0){

            ans+=n-cnt[4];

        }

        for(auto v:{x,y}){

            cnt[min(5,deg[v])]--;

            deg[v]++;

            cnt[min(5,deg[v])]++;

        }

    };

    work(start,end);

    for(int i=start;i!=end;i=pa[i]){

        work(i,pa[i]);

    }

    cout<<ans<<'\n';

}

 

Cf 2066 A

交互题

给定一个长度为n的数组x,同时有一个位置的长度也为n的数组y,保证对于任意一个位置i,x[i]!=y[i]
基于给定的x和未知的y,可能构成两种结构中的一种,其一是一个有n个点的有向图,图中的边是(x[i],y[i]),另一种是一个平面直角坐标系,坐标系上的点是(x[i],y[i])

可以进行两次询问,每次询问给出(i,j),要求i!=j,如果是结构一,会返回这两个节点的最短距离;如果是结构二,会返回这两个节点(x[i],y[i])和(x[j],y[j])的曼哈顿距离

首先考虑题目中给出的一些条件,因为保证了x[i]!=y[i],所以对于结构二,我们询问后返回的答案一定是不为0的,对于结构一,有n个点n条边,因此是类似于基环树的结构

考虑我们已知的数组x,如果数组x并不是一个排列,那么就说明存在k不存在于x中,那么根据结构一的定义,也就是这个点没有出边,因为定义的是有向边,因此它和另一个在x中的节点的最短路径是0(不可到达),由此和之前推得的关系可以得到结构一和结构2的区别

如果x是一个排列,那么对于结构一就确实是一个基环树了,并且因为每个节点都只有一条出边,因此是一个内向基环树

考虑最大值1和n

在平面直角坐标系上,横坐标为 1 和 n 的点都存在且仅有这一对,根据曼哈顿距离的表达式可知,询问 (1,n),若交互库想的是结构二,返回值一定 ≥n−1。

如果是结构一,此时的图是内向基环树森林,1 和 n 的距离 ≤n−1。重叠了一个 n−1。

但对于结构一,只有在图是一条链,并且 n 连回 1 的时候,dis(1,n) 取到最大值 n−1

不妨再询问 (n,1),如果是结构一,返回值就会变为 1;如果结果和之前一样,说明是结构二

 

基环树找环

拓扑排序(处理无向图,可以找出环上所有点)

void topsort(){

    int l=0,r=0;

    for (int i=1;i<=n;i++)

      if(in[i]==1) q[++r]=i;

    while(l<r) {

        int now=q[++l];

        for (int i=ls[now];i;i=a[i].next){

            int y=a[i].to;

            if(in[y]>1){

                in[y]--;

                if(in[y]==1) q[++r]=y;

            }

        }

    }

}

计算出入度>=2的点就是环上的点

 

dfs(处理有向图)

void check_c(int x)

{

    v[x]=true;

    if(v[d[x]]) mark=x;

    else check_c(father[x]);

    return;

}

mark就是环上的一个点

 

断环法:每次断开环上的一条边跑一遍答案,然后取最大值,适用于数据量较小,并且环不会影响答案的题目      P5022

二次dp法:可以像环形dp一样将一条边强行断开处理一遍,然后强行连上再处理一遍  P2607

断环复制法:对于环,我们依旧可以像环形dp一样断开环,然后再复制一遍放在后面 P4381

 

从另一个角度说,一个排列,是由若干个置换环构成的,而不同的置换环,会导出不同的排列,也就是说长度为n的排列的方案数是:把1,2,3...n分成若干个集合,每个集合形成一个置换环的方案数,而一个集合的数形成置换环的方案数显然就是这个集合大小的圆排列方案数

 

P1453

n个点,n条边,全部联通并且只有一个环,显然这是一个基环树

每个点有点权并且相邻两点不能同时选取,类似于树上最大带权子集

 

由于一棵基环树可以看作一棵树加了一条非重边在图上形成了唯一一个环,那么我们可以将该环上的每个点看作一个根节点,在不考虑环上的点与之联通的情况下,每个根节点都联通出一棵树。也就是说,一棵基环树等价于很多棵树的根节点被连在了一个环上的一张图

由此,我们便可以将这个问题分解为以环上的点为根节点的很棵多树的树形 dp + 这些环上的点形成的环形dp

 

因此我们首先要做的就是找环,首先,对于所有节点拓扑排序一遍,而后入度为2的点就是环上的点;其次,任意选择一个环上的点(此处选择的就是编号最小的节点)进行 dfs;最后,每次 dfs 到一个 未被记录入环并且入度为 2的节点,将其记录入环并进行递归

(关于这里为什么在求出入度之后仍然要用dfs去记录环上的点而不是直接遍历将入度为2的点加入环中,是因为我们之后需要进行环形 dp,需要保证环上依次连续的点在图上也是依次连续的,所以不能只进行一个简简单单的循环而需要 dfs)

接下来就是进行树上dp来求出最大值

设 fi,0 表示 i 点不选择,i 点子树的最大贡献;fi,1表示 i 点选择,i 点子树的最大贡献

最后就是进行一次环形dp

由于此时每棵树内部节点的关系均已满足题目的限定,剩下的就是环上的各个节点之间的关系,那么我们只需要对每棵树的根节点进行一次环形dp即可得出答案。

设ci为环上第 i 个点的编号,tot 为环上的节点总数。

那么此时,fci,0代表以 ci为根的树不选 ci最大贡献;fci,1代表以 ci为根的树选 ci最大贡献。

设gi,0表示点 ci不选,前 i 个节点的最大贡献,设gi,1表示点 ci 选,前 i 个节点的最大贡献。

由于是进行环形 dp,为了枚举全面所有的情况,我们需要进行两次 dp 过程:

规定 c1节点一定不选:

初始化 g 中的每一个元素为 −−inf;g1,0=fc1,0​。

 进行 dp,统计答案 ans=max(gtot,0,gtot,1)。

规定 c1节点一定选:

初始化 g 中的每一个元素为 −−inf;g1,1=fc1,1​。

进行 dp,统计答案ans=max(ans,gtot,0)

#include<bits/stdc++.h>

#define in inline

#define re register

#define N 100010

using namespace std;

int n;

int cnt,head[N];

struct edge

{

    int to,nxt;

};

edge e[N<<1];

bool b[N];

int tot;

int rd[N],c[N];

int f[N][2],g[N][2];

int ans;

double k;

in int qread()

//快读

{

    int x=0,y=1;

    int ch=getchar();

    while(ch<'0'||ch>'9')

    {

        if(ch=='-')

        {

            y=-1;

        }

        ch=getchar();

    }

    while(ch>='0'&&ch<='9')

    {

        x=(x<<1)+(x<<3)+(ch^48);

        ch=getchar();

    }

    return x*y;

}

in void mr(re int u,re int v)

//建边

{

    e[++cnt].to=v;

    e[cnt].nxt=head[u];

    head[u]=cnt;

    return ;

}

in void dfs0(re int u,re int fa)

//找环

{

    for(re int i=head[u];i;i=e[i].nxt)

    {

        int v=e[i].to;

        //父节点或已在环上就不算

        if(v==fa||b[v])

        {

            continue;

        }

        //找到一个点就加入环

        if(rd[v]==2)

        {

            b[v]=1;

            c[++tot]=v;

            //递归

            dfs0(v,u);

            //加入一个点后就跳出循环

            break;

        }

    }

    return ;

}

in void tppx()

//拓扑排序

{

    queue<int> q;

    for(re int i=1;i<=n;i++)

    {

        if(rd[i]==1)

        {

            q.push(i);

        }

    }

    while(!q.empty())

    {

        int u=q.front();

        q.pop();

        for(re int i=head[u];i;i=e[i].nxt)

        {

            int v=e[i].to;

            rd[v]--;

            if(rd[v]==1)

            {

                q.push(v);

            }

        }

    }

    for(re int i=1;i<=n;i++)

    {

        //找到第一个在环上的点加入环并进行 dfs

        if(rd[i]==2)

        {

            b[i]=1;

            c[++tot]=i;

            dfs0(i,0);

            //加入一个点后就跳出循环

            break;

        }

    }

    return ;

}

in void dfs(re int u,re int fa)

//树形 dp

{

    for(re int i=head[u];i;i=e[i].nxt)

    {

        int v=e[i].to;

        if(v==fa||b[v])

        //父节点或已在环上就不算

        {

            continue;

        }

        dfs(v,u);

        //转移

        f[u][0]+=max(f[v][1],f[v][0]);

        f[u][1]+=f[v][0];

    }

    return ;

}

int main()

{

    //读入

    n=qread();

    for(re int i=1;i<=n;i++)

    {

        f[i][1]=qread();

    }

    for(re int i=1;i<=n;i++)

    {

        int u=qread()+1;

        int v=qread()+1;

        //统计入度

        rd[u]++;

        rd[v]++;

        //建边

        mr(u,v);

        mr(v,u);

    }

    //拓扑排序

    tppx();

    //对于每棵树 树形 dp

    for(re int i=1;i<=tot;i++)

    {

        dfs(c[i],0);

    }

    //环形 dp

    memset(g,-0x3f,sizeof(g));

    g[1][0]=f[c[1]][0];

    for(re int i=2;i<=tot;i++)

    {

        g[i][1]=g[i-1][0]+f[c[i]][1];

        g[i][0]=max(g[i-1][0],g[i-1][1])+f[c[i]][0];

    }

    ans=max(g[tot][0],g[tot][1]);

    memset(g,-0x3f,sizeof(g));

    g[1][1]=f[c[1]][1];

    for(re int i=2;i<=tot;i++)

    {

        g[i][1]=g[i-1][0]+f[c[i]][1];

        g[i][0]=max(g[i-1][0],g[i-1][1])+f[c[i]][0];

    }

    ans=max(ans,g[tot][0]);

    //读入 k

    scanf("%lf",&k);

    //输出答案

    printf("%.1lf\n",ans*k);

    return 0;

}

 

 

P2607 骑士

显然就是一个基环树+没有上司的舞会的模型

根据题目可以知道, 每一个联通块里有且只有一个环, 所以我们找到这个环然后从中间把它断开, 对断开的两个端点u1, u2, 分别舞会。

设dp[u][0]为不选u, dp[u][1]为选u,

那么这个联通块答案就是max(dp[u1][0], dp[u2][0])

区别在于这里的边是有向边,因为限制条件并不是相邻而是不与自己指向的那个节点同时选

所以考虑这个有向图我们把x所讨厌的人y设为x的父亲节点,这样考虑每一个人都有且只有一条出边(仍然满足n个人n条边),所以对一个"联通块",环一定包含根节点,因为一个点的出度只能为1,考虑非根节点,它的出度一定是贡献给它的父亲的

#include<bits/stdc++.h>

#define maxn 2000000

using namespace std;

int n,cnt;

long long ans;

int root;

long long f[maxn][2];

int head[maxn],val[maxn],vis[maxn],fa[maxn];

struct edge{

    int pre,to;

}e[maxn];

inline void add(int from,int to){

    e[++cnt].pre=head[from];

    e[cnt].to=to;

    head[from]=cnt;

}

void dp(int now){

    vis[now]=1;

    f[now][0]=0,f[now][1]=val[now];

    //0选1不选

    for(int i=head[now];i;i=e[i].pre){

        int go=e[i].to;

        if(go!=root){//断环之后就是一般的树形dp了

            dp(go);

            f[now][0]+=max(f[go][1],f[go][0]);

            f[now][1]+=f[go][0];

        }

        else{

            f[go][1]=-maxn;

        }

    }

}

inline void find_circle(int x){

    vis[x]=1;

    root=x;

    while(!vis[fa[root]]){//父亲已经被遍历到过了说明这是当前所有边中成环的最后一条边

        root=fa[root];

        vis[root]=1;

    }//找环

    dp(root);

    long long t=max(f[root][0],f[root][1]);

    vis[root]=1;

    root=fa[root]; //断环后强制选择另一种情况

    dp(root);

    ans+=max(t,max(f[root][0],f[root][1]));

    return;

}

inline int in(){

    char a=getchar();

    while(a<'0'||a>'9'){

        a=getchar();

    }

    int t=0;

    while(a<='9'&&a>='0'){

        t=(t<<1)+(t<<3)+a-'0';

        a=getchar();

    }

    return t;

}

int main(){

    n=in();

    for(int i=1;i<=n;i++){

        val[i]=in();

        int x=in();

        add(x,i);

        fa[i]=x;

    }

    for(int i=1;i<=n;i++){

        if(!vis[i]){

            find_circle(i);

        }

    }

    printf("%lld\n",ans);

    return 0;

}

 

牛客24寒假1K

基环内向树,置换环

首先,每个 i 向 ai 连一条有向边,表示 ai 的答案被 i 限制住了;

这样得到的图是一个基环内向树森林

对于一个基环内向树,考虑怎么求答案:

首先所有连到环的链可以直接无视,它们不影响答案,这是因为,这个链所连接的环上

那个点确定答案后、链上每个点的答案可以由此反向传播依次得到(至于为何每个点答

案唯一?这是因为该点走到环的路径上所有排列复合得到的仍是排列),因此,环上点

确定方案、则链也随之确定唯一一种方案,所以可以忽略链;

对于环,我们随机选个起点,暴力枚举这个点选ABCDE中哪个选项然后暴力模拟来check这个选项是否能让这个环满足条件即可;

因此,每个基环内向树的答案是0~5之间整数;

最后的答案是每个基环内向树答案的乘积;

思想:有相互关系的点通过连边来表示两者之间的关系,而此处是有明确表示谁影响谁,因此连有向边,而题目中保证了每个点有且只有一条出边,因此构成了内向树,不保证联通,因此可能是基环树森林

#include "bits/stdc++.h"

 

#define range(a) begin(a), end(a)

 

using namespace std;

using i64 = long long;

constexpr int P = 998244353;

 

int main() {

    ios::sync_with_stdio(false);

    cin.tie(nullptr);

   

    int n;

    cin >> n;

    vector<int> a(n);

    vector<string> s(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i] >> s[i];

        a[i]--;//i是从0开始的,因此将a[i]也对应从0开始

    }

 

    i64 ans = 1;

    vector<int> vis(n);

    for (int i = 0; i < n; i++) {

        int j = i;

        vector<int> c;

        for (; !vis[j]; j = a[j]) {//将i和a[i]连边

            vis[j] = 1;

            c.push_back(j);

        }

        //退出时,说明j已经被查找过,即j是链与环的交点

        auto it = find(range(c), j);

        if (it != c.end()) {

            c.erase(c.begin(), it);//将链上的点删除,因为环确定后,链能够倒推得到唯一排列

            int res = 0;

            for (char st = 'A'; st < 'F'; st++) {//从该起点暴力枚举可能的答案,并判断是否可行

                char cur = st;

                for (auto x : c) {//因为是依次连接的,因此可以直接根据在c中的顺序去计算与其相关联的题目的答案

                    cur = s[x][cur - 'A'];

                }

                res += (cur == st);//判断是否确实满足了成环,即最终与最后是同一个点

            }

            ans = ans * res % P;//每个基环的方案都各自独立

        }

    }

    cout << ans << '\n';

   

    return 0;

}

 

 

最短图:

建图时一般是将连边的点放入g数组中,而g数组的大小一般是提前开好的,因此实际实现时可能不能使用n=g.size()来得到节点数,不过可以通过题目中的输入直接得到

 

要求选取字典序最小的路径,只需要用单调队列进行图的遍历即可,并且显然第一个入队的元素是1  cf1106D

 

int head[100005],nxt[200005],to[200005],tot;

int vis[100005],ans[100005];

void add(int u,int v){

    to[++tot]=v;

    nxt[tot]=head[u];

    head[u]=tot;

}  

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    memset(vis,0,sizeof(vis));

    int n,m;

    cin>>n>>m;

    for(int i=1;i<=m;i++){

        int x,y;

        cin>>x>>y;

        add(x,y);

        add(y,x);

    }

    priority_queue<int,vector<int>,greater<int> > q;

    q.push(1);

    int k=0;

    while(!q.empty()){

        int u=q.top();

        q.pop();

        vis[u]=1;

        ans[++k]=u;

        for(int i=head[u];i;i=nxt[i]){

            int v=to[i];

            if(!vis[v]){

                q.push(v);

                vis[v]=1;

            }

        }

    }

    for(int i=1;i<=n;i++){

        cout<<ans[i]<<' ';

    }

    return 0;

}

 

最短路:
cf 2057E

给定一个加权无向图,保证图中不包含自循环或者多条边,给定询问(a,b,k),要求找出从顶点a到顶点b的所有路径中,路径上权重第k大边的最小值

二分答案之后就是一个最短路问题

从小到大的顺序将边权修改为0,如果改完之后最短路变小了,这个就是第k+1大的边

首先,对于第k大的最小值这类问题,一般可以考虑使用二分答案,也就是对于固定的x,使得所有路径上的第k大值小于或者等于x,具体check时,将所有权重小于或等于x的边设定为0,将所有权重大于x的边设定为1,当且仅当a和b之间的最短路径严格小于k时,才可能存在满足条件的路径

于是,初始情况下,可以给所有边分配一个权重1,并且计算每对顶点之间最短路径的长度,然后,按照权重从小到大的顺序处理这些边,当遍历到某一条边的时候,就将这条边的权重修改为0,并且每次更改后都需要重新计算每对顶点之间的距离,但这个修改的实现是简单的,例如d[i][j]表示修改前的最短路径长度,修改的边是(a,b),那么修改后的最短路径长度就可以表示为d’[i][j]=min(d[i][j],d[i][a]+d[b][j],d[i][b]+d[a][j])

这样,只要我们维护了每一轮所修改的边的权值,以及当前轮次下最短路径的长度,那么预处理后,我们就可以通过二分轮次来得到对应的答案了

那么上述过程能否进一步优化?容易想到,如果我们将一条边从1修改到0时,这条边所连接的两个顶点本身最短距离已经是0了,那么我们就没有必要再去重新更新对应的最短距离矩阵了,这样可以减少一些时间复杂度,同时,因为没有发生变化,因此该轮次也就一定不会是被选择的答案,因为相同情况一定有更小的边权可以实现

由于顶点之间距离为0只可能是其中有一条路径所有边都已经被选择了,因此判断是否为0可以用并查集来实现,也就是只要两个端点处于同一个连通块中,那么这两个点之间的距离就是0

void solve(){

    int n,m,q;

    cin>>n>>m>>q;

    vector<array<int,3>> edges(m);

    vector dis(n,vector<vector<int>>(n,vector<int>(n,inf)));

    for(int i=0;i<m;i++){

        int v,u,w;

        cin>>v>>u>>w;

        v--,u--;

        edges[i]={w,v,u};

        dis[0][v][u]=dis[0][u][v]=1;

    }

    sort(edges.begin(),edges.end());

    for(int i=0;i<n;i++){

        dis[0][i][i]=0;

    }

    vector<int> value(n);

    for(int i=0;i<n;i++){

        for(int j=0;j<n;j++){

            for(int k=0;k<n;k++){

                dis[0][j][k]=min(dis[0][j][k],dis[0][j][i]+dis[0][i][k]);

            }

        }

    }

    int p=1;

    DSU dsu(n);

    for(auto edge:edges){

        int v=edge[1],u=edge[2],w=edge[0];

        if(dsu.merge(v,u)){

            for(int i=0;i<n;i++){

                for(int j=0;j<n;j++){

                    dis[p][i][j]=min({dis[p-1][i][j],dis[p-1][i][v]+dis[p-1][j][u],dis[p-1][i][u]+dis[p-1][j][v]});

                }

            }

            value[p++]=w;

        }

    }

    while(q--){

        int v,u,k;

        cin>>v>>u>>k;

        v--,u--;

        int low=0,high=n;

        while(low<high){

            int mid=(low+high)>>1;

            if(dis[mid][v][u]<k){

                high=mid;

            }else{

                low=mid+1;

            }

        }

        cout<<value[low]<<" \n"[q==0];

    }

}

 

 

01bfs
当边权只有0/1时,可以使用01bfs代替dijkstra

有些图论问题的关键在于构造

cf 1941G

问题实际上是求从s点到t点最少需要换乘地铁几次

换乘地铁的次数,也就是登上一辆新地铁的次数。这里的“新”指当前是第一辆地铁或当前地铁不同于上一辆地铁。所以我们只需要计算最小的上车次数即可。

我们不妨把每辆地铁都看成一个点。

如果存在一条边ui→vi 的边权为 ci,就代表这里存在一种上 ci车的方案。那么我们建一条新边 ui→ci 边权为 1,表示可以从ui点耗费 1的代价登上 ci 地铁。

同理,下车的次数我们是不需要考虑的,所以我们建 ci→vi边权为 0表示下车不需要消耗代价

vector<PII> g[N];

int dis[N];

bool st[N];

 

void solve() {

    int n, m; cin >> n >> m;

    int cnt = n;

   

    for (int i = 1; i <= n + m + 1; ++ i ) g[i].clear();

    map<int, int> col;

    while (m -- ) {

        int u, v, w; cin >> u >> v >> w;

        if (!col.count(w)) col[w] = ++ cnt;

        g[u].push_back({col[w], 1});

        g[v].push_back({col[w], 1});

        g[col[w]].push_back({u, 0});

        g[col[w]].push_back({v, 0});

    }

    int s, t; cin >> s >> t;

   

    deque<int> q;

    q.push_back(s);

    for (int i = 1; i <= n + cnt + 1; ++ i ) dis[i] = 1e9, st[i] = 0;

    dis[s] = 0;

    while (q.size()) {

        int u = q.front();

        q.pop_front();

        if (st[u]) continue;

        st[u] = true;

        for (auto [v, w] : g[u])

            if (dis[v] > dis[u] + w) {

                dis[v] = dis[u] + w;

                if (w) q.push_back(v);

                else q.push_front(v);

            }

    }

    cout << dis[t] << '\n';

}

 

牛客24多校10  L

一个n位0~9的转轮密码锁,拨动方式是每次选择一个区间,顺时针或者逆时针转动一位

从某个特定的密码出发,给定m条转动了t次到达了状态s的记录,问密码是否有解,如果有唯一解输出唯一解

因为转动是顺时针逆时针任意的,因此可以考虑是从给定的记录开始转动,是否存在一个状态是所有记录都可以达到的

并且在已知初始态和终态的情况下,最少操作次数是可知的

因此,如果一个状态能够在t步内到达s,那么首先其最短路一定不能超过t,其次,如果x步能到,那么x+2步也一定能到,但x+1不一定能到

因此可以按照距离的奇偶分开来维护最短路,用最短路求出可行答案的集合,对集合求交就能判断是否有解以及解的个数

因为每次移动都需要一次操作次数,因此可以用01bfs预处理出所有状态从初态0开始的最少操作次数

int pw[6]={1,10,100,1000,10000,100000};

 

void solve(){

    int n,m;

    cin>>n>>m;

    vector<bool> ok(pw[n],1);

    vector<int> dis(pw[n],-1);

    dis[0]=0;

    queue<int> q;

    q.push(0);

    while(!q.empty()){

        int x=q.front();

        q.pop();

        for(auto t:{9,1}){//9逆时针,1顺时针

            //枚举一次操作的区间,n<=5,可以n^2处理

            for(int l=0;l<n;l++){//n是位数

                int y=x;

                for(int r=l;r<n;r++){

                    int d=(y/pw[r])%10;

                    y+=pw[r]*((d+t)%10-d);

                    if(dis[y]==-1){

                        dis[y]=dis[x]+1;

                        q.push(y);

                    }

                }

            }

        }

    }

    for(int i=0;i<m;i++){

        string s;

        int t;

        cin>>s>>t;

        for(int y=0;y<pw[n];y++){

            int w=0;

            for(int i=0;i<n;i++){

                //将s看作是初始态,计算叠加上操作y之后的结果

                w+=(s[i]-'0'+y/pw[i]%10)%10*pw[i];

            }

            // assert(dis[y]!=-1);

            if(dis[y]>t){

                ok[w]=0;

            }

            if(y==0 && t==1){

                ok[w]=0;

            }

            if(n==1 && (y+t)%2!=0){

                ok[w]=0;

            }

        }

    }

    int cnt=count(ok.begin(),ok.end(),1);

    if(cnt==0){

        cout<<"IMPOSSIBLE\n";

    }else if(cnt>1){

        cout<<"MANY\n";

    }else{

        int x=find(ok.begin(),ok.end(),1)-ok.begin();

        for(int i=0;i<n;i++){

            cout<<x/pw[i]%10;

        }

        cout<<'\n';

    }

}

 

 

多层图最短路

ABC277E

朴素写法(好久没写手有点生,关键是要注意使用链式前向星遍历的时候,边权要用val[i],而连通的节点是to[i],i代表的是tot,即边对应的编号)

(这里项的是不同层要用不同的vis去记录这个点是否到达过)

 

int au[200005]{};

int n,m,k;

int vis[2][200005]{};

int to[200005],he[200005],nxt[200005],val[200005];

int tot=0;

void add(int a,int b,int v){

    to[++tot]=b;

    nxt[tot]=he[a];

    he[a]=tot;

    val[tot]=v;

}

int ans=INT_MAX;

void dfs(int now,int fa,int cnt,int cnt1){

    if(now==n){

        if(cnt<ans) ans=cnt;

        return;

    }

    for(int i=he[now];i!=-1;i=nxt[i]){

        int tmp=to[i];

        if(vis[cnt1][tmp] || tmp==fa) continue;

        if(val[i]^cnt1){

            vis[cnt1][tmp]=1;

            if(au[tmp]){

                dfs(tmp,now,cnt+1,cnt1^1);

            }

            dfs(tmp,now,cnt+1,cnt1);

        }

    }

}

void solve(){

    cin>>n>>m>>k;

    memset(he,-1,sizeof(he));

    for(int i=1;i<=m;i++){

        int a,b,v;

        cin>>a>>b>>v;

        add(a,b,v);

        add(b,a,v);

    }

    for(int i=1;i<=k;i++){

        int a;

        cin>>a;

        au[a]=1;

    }

    dfs(1,-1,0,0);

    if(ans==INT_MAX) cout<<-1<<'\n';

    else cout<<ans<<'\n';

}

 

dijkstra

显然这仍然是一个最短路问题

对于多层的情况,这里的解决方法是多开一倍的节点个数,另一层的点就是原来点对应的编号再加上一个n,对于那些能够进行切换的点,它们就是能以边权为0 的代价从i移动到i+n

这之后就是一个标准的dijkstra解决最短路

const int N = 4e5 + 5; //开2倍

struct Edge {int now, nxt, w;} e[N << 1];

int head[N], cur;

void add(int u, int v, int w){

    e[++cur].now = v;

    e[cur].nxt = head[u];

    e[cur].w = w;

    head[u] = cur;

}

void ADD(int u, int v, int w) {add(u, v, w), add(v, u, w);}

 

struct Node {int pos, dis;};

bool operator <(Node y, Node x) {return x.dis < y.dis;}

 

int dis[N];

bool vis[N];

void dijkstra(int s){

    memset(dis, 0x3f, sizeof dis);

    priority_queue <Node> q;

    dis[s] = 0, q.push((Node){s, 0});

    while (!q.empty()){

        int u = q.top().pos;

        q.pop();

        if (vis[u]) continue;

        vis[u] = true;

        for (int i = head[u]; i; i = e[i].nxt){

            int v = e[i].now;

            if (dis[u] + e[i].w < dis[v]){

                dis[v] = dis[u] + e[i].w;

                q.push((Node){v, dis[v]});//通过这个点能够使dis[v]变小,就加到优先队列中

            }

        }

    }

}

void solve(){

    int n, m, k;

    cin>>n>>m>>k;

    while (m--){

        int u, v, w;

        cin>>u>>v>>w;

        if (w == 1) ADD(u, v, 1); //第一层

        else ADD(u + n, v + n, 1); //第二层

    }

    while (k--){

        int u;

        cin>>u;

        ADD(u, u + n, 0); //切换层

    }

    dijkstra(1);

    int t = min(dis[n], dis[n + n]);

    if (t == 0x3f3f3f3f) cout<<-1<<'\n';

    else cout << t << '\n';

}

 

P4568

有部分边可以免费到达就想到使用多层图,每免费一条边就多开一层

int tot,head[110005],nxt[2500001],to[2500001],val[2500001];

void add_edge(int u,int v,int c=0){

    to[++tot]=v;

    val[tot]=c;

    nxt[tot]=head[u];

    head[u]=tot;

}

int dis[110005];

bool vis[110005];

void Dijkstra(int s){

    memset(dis,0x3f,sizeof(dis));

    dis[s]=0;

    priority_queue<pair<int,int>,vector<pair<int,int>>,greater<pair<int,int>> > q;

    q.push(make_pair(0,s));

    while(!q.empty()){

        int u=q.top().second;

        q.pop();

        if(!vis[u]){

            vis[u]=1;

            for(int i=head[u];i;i=nxt[i]){

                int v=to[i];

                if(dis[v]>dis[u]+val[i]) {

                    dis[v]=dis[u]+val[i];

                    q.push(make_pair(dis[v],v));

                }

            }

        }

    }

}

 

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    int n,m,k,s,t;

    cin>>n>>m>>k>>s>>t;

    int u,v,c;

    for(int i=0;i<m;++i){

        cin>>u>>v>>c;

        add_edge(u,v,c);

        add_edge(v,u,c);

        for(int j=1;j<=k;++j){

            add_edge(u+(j-1)*n,v+j*n);

            add_edge(v+(j-1)*n,u+j*n);

            add_edge(u+j*n,v+j*n,c);

            add_edge(v+j*n,u+j*n,c);

        }

    }

    for(int i=1;i<=k;++i){

        add_edge(t+(i-1)*n,t+i*n);

    }

    Dijkstra(s);

    cout<<dis[t+k*n]<<'\n';

    return 0;

}

 

 

Dijkstra

dijkstra要求边必须为正

在dijkstra中排序的不是所有边的最小值,而是以到各点的花费去排序,因此找到一条边后会去更新到其他点之间的距离,如果是按边权排序的话从一开始就全部排序完成了,不可能有后续的更新

将新图中点的数量记为 n,边数记为 m,朴素 Dijkstra 复杂度为 O(n2),堆优化的 Dijkstra 的复杂度为 O(mlogn),当 m<n2(相对稀疏)时,优先使用堆优化 Dijkstra

朴素写法适合数据稠密(即边的数量比较大)的题,所以采用邻接矩阵实现

本质上是一种贪心算法,利用了边权的非负性去贪心

定义start为起点,dis[i]表示从start到i的最短路的长度,初始时dis[start]=0,其余dis[i]都是无穷

首先从start出发,去更新邻居的最短路,即找除去dis[start]以外的dis的最小值,设这个点为x,那么dis[x]就是start到x的最短路长度,因为边权非负,如果从其他点(记为y)通过另外的路径到达x,那么这其中的花费一定会大于dis[x],因为本身从start到y的dis[y]就大于dis[x],更别说dis[y]还要加上一些边权才能表示到达x的花费,并且将已经更新过的节点进行标记(用一个visit数组),因为已经更新x 到其它点的最短路了,无需反复更新

 

如果要同时输出路径,可以在修改的时候记录,因为我们使用的一定是最短边,因此如果到这个点的最短边发生了变化,那么就更新path(相当于是在记录路径上的父节点)

 

//朴素Dijkstra写法

int shortestPath(int start, int end) {

        int n = g.size();    //总共有n个节点,g为图,记录了有边连接的两个的点的边权

        vector<int> dis(n+1, INT_MAX / 2), vis(n+1);  

//取最大值的一半是为了避免之后计算路径和时越界

//dis代表最短路径,vis用于记录一个点是否更新过

        dis[start] = 0;  //起点到起点的最短路径就是1

        for (;;) {

            // 找到当前最短路,去更新它的邻居的最短路

            // 根据数学归纳法,dis[x] 一定是最短路长度

            int x = -1;   //x就是这一轮循环我们将要赋值的节点

            for (int i = 1; i <=n; ++i)

                if (!vis[i] && (x < 0 || dis[i] < dis[x])) 

//x<0说明x还没有被赋过值,这个循环是为了找此时dis最小的那个节点

                    x = i;

            if (x < 0 || dis[x] == INT_MAX / 2) // 所有从 start 能到达的点都被更新了

                return -1;

            if (x == end) // 找到终点,提前退出

                return dis[x];

            vis[x] = true; // 标记,在后续的循环中无需反复更新 x 到其余点的最短路长度

            for (int y = 1; y <= n; ++y)

                dis[y] = min(dis[y], dis[x] + g[x][y]);

// 由x为起点计算到其他点的距离,更新最短路长度,下一个最短的路径一定是从x到达某一节点或者直接从start到达该节点的路径值

        }

}

 

//采用堆的写法(T2577,这题是网格图,因此dis是二维的),用优先队列维护dis中的最小值

static constexpr int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

int dis[m][n];

        memset(dis, 0x3f, sizeof(dis));

        dis[0][0] = 0;

        priority_queue<tuple<int, int, int>, vector<tuple<int, int, int>>, greater<>> pq;

        pq.emplace(0, 0, 0);

        for (;;) { // 可以等待,就一定可以到达终点,因此可以写一个死循环

            auto[d, i, j] = pq.top();

            pq.pop(); //出队

            if (d > dis[i][j]) continue;//说明已经被记录过了

            if (i == m - 1 && j == n - 1) // 找到终点,此时 d 一定是最短路

                return d;

            for (auto &q : dirs) { // 枚举周围四个格子

                int x = i + q[0], y = j + q[1];

                if (0 <= x && x < m && 0 <= y && y < n) {

                    int nd = max(d + 1, grid[x][y]);

                    nd += (nd - x - y) % 2; // nd 必须和 x+y 同奇偶

                    if (nd < dis[x][y]) {

                        dis[x][y] = nd; // 更新最短路

                        pq.emplace(nd, x, y); //由现在被修改的这个点到其他点的路径

                    }

                }

            }

        }

 

Dijkstra可以用于求单起点到图中其他任意点的最短距离,用堆进行维护,注意要把距离放在用堆存储的数据结构的第一位

同时可以通过对堆中数据量的变化,修改成多维的情况

Cf 2059D

给定两个连通无向图,有两个标记初始位于s1和s2,每次两个图中的标记分别进行移动,s1和s2移动到各自相邻的节点上,这样移动的代价是移动到的节点的权值之差

问经过无限次这样的移动后,代价之和的最少值是多少,或者代价总和是无限大

因为我们可以任意安排移动到的节点,因此如果我们移动到了一条两个图都有的边,同时所在的节点的编号也相同时,那么移动的代价就可以不再增加

同时,如果不存在两个图中都存在的边,显然代价一定是无穷

因此问题转化为从起点移动到两个图之间相同边的相同点上的最小值,这个最小代价的问题自然联想到了最短路问题(不需要考虑要选择那个相同边,相同边中的哪个顶点,依照最短路的求法,我们可以得到从起点集合到任意点集合的距离最小值,因此直接跑一遍最短路即可,因为一定没有什么策略可以得到判断标准,一定要求出距离,注意到图并不是很大,可以尝试直接运行),于是使用二维情况下的dijkstra算法求解

void solve() {

    int n,s1,s2;

    cin>>n>>s1>>s2;

    s1--;

    s2--;

    int m1;

    cin>>m1;

    vector<vector<int>> adj1(n);

    for(int i=0;i<m1;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj1[u].emplace_back(v);

        adj1[v].emplace_back(u);

    }

    int m2;

    cin>>m2;

    vector<vector<int>> adj2(n);

    for(int i=0;i<m2;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj2[u].emplace_back(v);

        adj2[v].emplace_back(u);

    }

    vector dis(n,vector<int>(n,-1));

    priority_queue<array<int,3>,vector<array<int,3>>,greater<>> pq;

    pq.push({0,s1,s2});

    while(!pq.empty()){

        auto [d,x1,x2]=pq.top();

        pq.pop();

        if(dis[x1][x2]!=-1){

            continue;

        }

        dis[x1][x2]=d;

        for(int y1:adj1[x1]){

            for(int y2:adj2[x2]){

                if(x1==x2 && y1==y2){

                    cout<<d<<'\n';

                    return;

                }

                pq.push({d+abs(y1-y2),y1,y2});

            }

        }

    }

    cout<<-1<<'\n';

}

 

25 杭电 春 1005

给定一个n*m的网格,每次通过某个网格的花费是t[i][j],同时在运行时有方向,每次转方向需要额外的d[i][j]的花费,问从(1,1)右方向行驶到(n,m)下方向行驶的最少时间

图(矩阵)+最短时间自然想到利用dijsktra,问题就在于转向怎么解决

可以考虑分层图,也就是每个方向各自存储一个方向下的情况,然后通过变转方向在图中进行转移,或者更简单地,直接在dis数组中存储方向,每次按照四个方向进行转移,同时如果方向不一致就增加对应的数值

void solve() {  

    int n,m;

    cin>>n>>m;

    vector<vector<i64>> t(n,vector<i64>(m));

    vector<vector<i64>> d(n,vector<i64>(m));

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            cin>>t[i][j];

        }

    }

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            cin>>d[i][j];

        }

    }

    const int dx[]={-1,0,1,0};

    const int dy[]={0,1,0,-1};

    vector dis(n,vector<vector<i64>>(m,vector<i64>(4,inff)));

    for(int i=0;i<4;i++){

        dis[0][0][i]=t[0][0]+(i==1?0:d[0][0]);

    }

    dis[0][0][1]=t[0][0];

    priority_queue< array<i64,4>,vector<array<i64,4>>,greater<> > pq;

    for(int i=0;i<4;i++){

        pq.push({dis[0][0][i],0,0,i});

    }

    // pq.push({dis[0][0][1],0,0,1});

    while(!pq.empty()){

        auto [tim,x,y,dir]=pq.top();

        pq.pop();

        if(tim>dis[x][y][dir]){

            continue;

        }

        for(int i=0;i<4;i++){

            int xx=x+dx[i];

            int yy=y+dy[i];

            if(xx>=0 && xx<n && yy>=0 && yy<m){

                i64 tmp=tim+t[xx][yy];

                if(i!=dir){

                    // tmp+=d[xx][yy];

                    tmp+=d[x][y];

                }

                if(tmp<dis[xx][yy][i]){

                    dis[xx][yy][i]=tmp;

                    pq.push({tmp,xx,yy,i});

                }

            }

        }

    }

    i64 ans=dis[n-1][m-1][2];

    for(int i=0;i<4;i++){

        ans=min(ans,dis[n-1][m-1][i]+d[n-1][m-1]);

    }

    cout<<ans<<'\n';

}

 

 

 

一定条件下修改一下松弛条件(通过对松弛条件的改写可以深刻地理解该算法)就可以用来求最长路(P1807)

这题中保证了“当为G中的一条边时有i < j”

这说明了当前没有被锁定的编号最小的点不会被更新

原本的dijkstra实际上的核心是基于当前没锁定的(这一轮次中寻找的)dis最小的点之后不会被更新,因此这题中我们可以向外拓展为没有被锁定的编号最小的点(如果用动规的话来说,就是找到一个没有后效性的特征)

又因为题面所需的是最长的路,所以将原来路径长度越小越好改为越大越好

int dis[10005];

bool vis[10005];

vector<int> g[10005];//表示第i个点的第j条边的目的地是g[i][j]

vector<int> gv[10005];//表示相应的边权

int cnt[10005];//表示第i个点有cnt[i]条边

 

int main(){

    int n,m;

    cin>>n>>m;

    memset(dis,-1,sizeof(dis));

    memset(cnt,0,sizeof(cnt));

    memset(vis,0,sizeof(vis));

    for(int i=1;i<=m;i++){

        int a,b,c;

        cin>>a>>b>>c;

        cnt[a]++;

        if(cnt[a]==1){

            g[a].emplace_back(-1);

            gv[a].emplace_back(-1);

            //为了使实际的边的下标从1开始

        }

        g[a].emplace_back(b);

        gv[a].emplace_back(c);

    }

    //dijkstra

    int s=1;//s为起点

    dis[s]=0;

    for(int t=1;t<=n;t++){

        int maxn=-1,x;

        for(int i=1;i<=n;i++){//找目前可以到达的节点中编号最小的那个

        //边中的i<j保证了对于最小的点现在到不了以后也一定到不了

            if(!vis[i] && dis[i]!=-1){

                maxn=dis[i];//已经遍历到这了,以后的扩展不会影响到到这个节点的最长边了

                x=i;

                break;

            }

        }

        vis[x]=1;

        for(int i=1;i <= cnt[x];i++){//根据这条边向外扩展

            if(dis[g[x][i]] == -1 || maxn+gv[x][i]>dis[g[x][i]]){

                dis[g[x][i]]=gv[x][i]+maxn;

            }

        }

    }

    cout<<dis[n];  //因为初始赋值为-1,如果不更改就说明到不了

}

 

牛客24寒假4I

本质上还是求最短路,但是可以进行一定的修改操作,想要最小,一定还是每次取当前所有可能路径中的最短路,保证边权非负,可以考虑使用dijkstra,因此现在的问题就在于可能不是已有的边是我们要选的最短边,对于这个问题,去采用一种欠费的思想,即先把费用欠着(不进行操作)走到y点,在要离开y点的时候再将y的某条出边改到x到y的边,在这个过程中计算花费并添加到堆中

类似dp的状态转移,考虑不同x的情况到y的转移,以及答案也是在两种情况种取最优

int main() {

    ios::sync_with_stdio(false);

    cin.tie(nullptr);

   

    int n, m;

    cin >> n >> m;

   

    vector<vector<pair<int, int>>> adj(n);//存边

    vector<array<i64, 2>> f(n, {inf, inf});

    for (int i = 0; i < m; i++) {

        int u, v, w;

        cin >> u >> v >> w;

        u--, v--;

        adj[u].emplace_back(v, w);

        //在具体走的时候,对于一个节点,要么是走最短的,要么是将最短的变成走到这个点的边

        //或者是即走了最短的边,又进行了边的操作

        //又对于每个节点一定只经过一次(边权非负)

        //因此只要保留最短的两条边即可

        if (w < f[u][0]) {

            f[u][1] = f[u][0];

            f[u][0] = w;

        } else if (w < f[u][1]) {

            f[u][1] = w;

        }

    }

   

    vector<std::array<i64, 2>> dis(n, {inf, inf});

    priority_queue<tuple<i64, int, int>, vector<tuple<i64, int, int>>, greater<>> q;//i64用于优先队列的排序

    q.emplace(0LL, 0, 0);

    i64 ans = inf;

    while (!q.empty()) {

        auto [d, x, t] = q.top();

        q.pop();

       

        if (dis[x][t] != inf) {//根据优先队列,先遍历到的同一情况花费更小

            continue;

        }

        dis[x][t] = d;

        if (adj[x].size() > t) {

            for (auto [y, w] : adj[x]) {

                q.emplace(d + w + (t ? f[x][w == f[x][0]] : 0), y, 0);//直接走,w==f[x][0]用于判断现在要走的是不是最短边,如果是就走次短边

                q.emplace(d + (t ? f[x][w == f[x][0]] : 0), y, 1);//欠费的情况

            }

        }

    }

    for (int i = 0; i < 2 && i <= adj[n - 1].size(); i++) {

        ans = min(ans, dis[n - 1][i] + (i == 0 ? 0 : f[n - 1][0]));//到终点的两种情况种取优

    }

    if (ans == inf) {

        ans = -1;

    }

    cout << ans << "\n";

   

    return 0;

}

 

acwing 340通讯线路

二分+dijkstra

实际上这道题是求一条路线,使这一条路线中第k+1大的值最

因此并不是先dijkstra再进行二分找支付哪条边,而是应该先找出选择支付哪条边再去判断是否存在这样一条路径使得免费的边个数<k

而免费的边个数可以用小于mid的设定为0,大于mid的设定为1,再计算路径上的总花费来得到边的条数

const int inf=0x3f3f3f3f;

const int N=1e4+5;

int n,m,k;

int h[N],e[N*2],ne[N*2],w[N*2],idx;

int dist[N];

bool vis[N];

void add(int a,int b,int c)//前向星

{

    e[idx]=b;

    w[idx]=c;

    ne[idx]=h[a];

    h[a]=idx++;

}

int dijkstra(int val)

{

    memset(vis,0,sizeof(vis));

    memset(dist,0x3f,sizeof dist);

    priority_queue<pair<int,int>,vector<pair<int,int>>,greater<>>heap;

    dist[1]=0;

    heap.push({0,1});

    while(heap.size())

    {

        auto t=heap.top();

        heap.pop();

       

        int u=t.second;

        if(vis[u]) continue;

        vis[u]=true;

       

        for(int i=h[u];i!=-1;i=ne[i])

        {

            int v=e[i];

            int x=w[i]>val;     //如果该边的权值大于val(mid),那么为1,否则为0.

            if(dist[v]>dist[u]+x)

            {

                dist[v]=dist[u]+x;

                heap.push({dist[v],v});

            }

        }

    }

    return dist[n];

}

bool check(int mid)

{

    if(dijkstra(mid)>k) return false;

    return true;

}

void solve()

{

    memset(h,-1,sizeof(h));

    cin>>n>>m>>k;

    while(m--){

        int a,b,c;

        cin>>a>>b>>c;

        add(a,b,c);

        add(b,a,c);

    }

    int l=0,r=1e6+1;

    if(!check(r)) {cout<<-1; return;}

    while(r>l){

        int mid=l+r>>1;

        if(check(mid)) r=mid;

        else l=mid+1;

    }

    cout<<r;

}

 

P3063

这题有所区别的是求最短路的同时还有另一个数据需要维护,因为这两个数据一个要求最小而另一个要求最大,因此没法同时计算,而在数据量不大的情况下,我们显然可以枚举另一个元素来分别求最短路,枚举时,相当于对边的选择进行了一些限制,最后对答案求最小值

struct node {

    int to,net,val,liu;

} e[200005];

 

inline void add(int u,int v,int w,int l) {

    e[++tot].to=v;

    e[tot].val=w;

    e[tot].liu=l;

    e[tot].net=head[u];

    head[u]=tot;

}

inline void dijkstra(int li) {

    for(int i=1;i<=n;i++) {

        vis[i]=0;

        dis[i]=inf;

    }

    dis[1]=0;

    q.push(make_pair(0,1));

    while(!q.empty()) {

        int xx=q.top().second;

        q.pop();

        if(vis[xx]) continue;

        vis[xx]=1;

        for(int i=head[xx];i;i=e[i].net) {

            int v=e[i].to;

            if(e[i].liu<li) continue;

            if(dis[v]>dis[xx]+e[i].val) {

                dis[v]=dis[xx]+e[i].val;

                q.push(make_pair(dis[v],v));

            }

        }

    }

}

int main() {

    ios::sync_with_stdio(0);cin.tie(0);

    cin>>n>>m>>x;

    for(register int i=1;i<=m;i++) {

        cin>>u>>v>>w>>c;

        flag[i]=c;

        add(u,v,w,c);

        add(v,u,w,c);

    }

    //最短路,C最大

    //路径中的C取决于其中最小的C

    for(register int i=1;i<=m;i++) {

        dijkstra(flag[i]);

        if(dis[n]!=inf) ans=min(ans,dis[n]+x/flag[i]);

    }

    cout<<ans<<'\n';

    return 0;

}

 

 

杭电24多校3  1008

跳跃有两种方式,一种是直接按照图中的边进行移动,另一种是特殊的直接移动

直接移动的花费是边权或再乘上一个系数

因此显然多次跳跃不如一次跳跃的性价比比较高

因此整体上可以采用单源最短路的计算方法

并且将特殊的跳跃边加入边集中

同时为了尽可能的减少花费,从一个点跳跃到另一个点时,尽可能保证一个点在二进制上是另一个点的子集

const int N=1e5+5;

int n,m,k,vis[N];

vector<int> pp[N];

i64 dis[N];

struct edge{

    int to,weight;

};

vector<edge> e[N];

struct node{

    int id;

    i64 dis;

    int operator>(const node& a) const { return dis > a.dis; }

};

priority_queue<node,vector<node>,greater<node> >q;

void dijkstra(int st){

    q.push({1, st});

    while (!q.empty()){

        int u=q.top().id;

        q.pop();

        if(vis[u])continue;

        vis[u]=1;

        if(u==1){//多次跳不如一次跳,因此从1开始移动

            for(int i=2;i<=n;i++){

                if(i&1) dis[i]=1ll*i*k;

                else dis[i]=1ll*(i+1)*k;

                q.push((node){i,dis[i]});

            }

        }

        for(int i=0;i<(int)e[u].size();i++){

            int v=e[u][i].to,w=e[u][i].weight;

            if(dis[v]<=dis[u]+w)continue;

            dis[v]=dis[u]+w;

            q.push((node){v,dis[v]});

        }

        if(u!=1){

            for(int i=0;i<(int)pp[u].size();i++){

                int v=pp[u][i];

                if(dis[v]<=dis[u]+1ll*k*v) continue;

                dis[v]=dis[u]+1ll*k*v;

                q.push((node){v,dis[v]});

            }

        }

    }

}

void solve(){

    cin>>n>>m>>k;

    for(int i=1;i<=n;++i) e[i].clear();

    for(int i=1;i<=m;i++){

        int u,v,t;

        cin>>u>>v>>t;

        e[u].push_back((edge){v,t});

        e[v].push_back((edge){u,t});

    }

    memset(vis,0,sizeof(vis));

    for(int i=1;i<=n;i++){

        dis[i]=inff;

    }

    dis[1]=0;

    dijkstra(1);

    for(int i=2;i<=n;++i){

        cout<<dis[i]<<" ";

    }

    cout<<'\n';

}

 

    for(int i=4;i<=1e5;i+=2){

        for(int j=(i-1)&i;j>1;j=(j-1)&i){

            pp[j].push_back(i);

        }

    }

分成两个部分,先不断往OR上去跳,这样的复杂度是loglog

nlog条边边权都只有0,可以不把这些放到dijkstra中

void solve() {

    int n,m,k;

    cin>>n>>m>>k;

    int N=1;

    while(N<=n){

        N*=2;

    }

    vector<vector<pair<int,int>>> adj(N);

    for(int i=0;i<m;i++){  

        int u,v,t;

        cin>>u>>v>>t;

        adj[u].push_back({v,t});

        adj[v].push_back({u,t});

    }

    vector<i64> dis(2*N,-1);

    priority_queue<pair<i64,int>,vector<pair<i64,int>>,greater<>> pq;

    pq.emplace(0ll,1);

    vector<bool> vis1(N),vis2(N);

    //往大的走,被包含的

    auto dfs1=[&](auto self,int x,i64 d){

        if(vis1[x]){

            return;

        }

        vis1[x]=true;

        pq.emplace(d+1ll*x*k,x+N);

        for(int i=1;i<N;i*=2){

            if((x&i)==0){

                self(self,x^i,d);

            }

        }

    };

    //往小枚举,包含的

    auto dfs2=[&](auto self,int x,i64 d){

        if(vis2[x]){

            return;

        }

        vis2[x]=true;

        if(1<=x && x<=n){

            pq.emplace(d,x);

        }

        for(int i=1;i<N;i*=2){

            if((x&i)==i){

                self(self,x^i,d);

            }

        }

    };

    while(!pq.empty()){

        auto [d,x]=pq.top();

        pq.pop();

        if(dis[x]!=-1){

            continue;

        }

        dis[x]=d;

        if(x<N){

            //直接走边

            for(auto [y,w]:adj[x]){

                pq.emplace(d+w,y);

            }

            dfs1(dfs1,x,d);

        }else{

            x-=N;

            // int c=__builtin_popcount(x);

            //因为是or上,因此不用减去最低位

            dfs2(dfs2,x,d);

        }

    }

    for(int i=2;i<=n;i++){

        cout<<dis[i]<<' ';

    }

    cout<<'\n';

}

 

 

有时可以参考dijkstra的思路,例如T2167,找矩阵中的最短路,也可以采用每次采取当前最小的步数,并距此进行更新

对于每一行和每一列,都有一个对应的最小堆。下面的 rowH维护的是第 iii 行的数对,colH维护的是第j列的数对。

 

对于行最小堆 rowH,如果堆顶列号(也就是之前保存的 g+j)小于当前列号 j,说明无法到达第 j列(以及更右的列),弹出堆顶,重复该过程直到堆为空或者堆顶列号大于等于 j。如果堆不为空,取堆顶的 f值加一,作为 (i,j)位置的 f。

对于列最小堆 colH,如果堆顶行号(也就是之前保存的g+i)小于当前行号 i,说明无法到达第 i行(以及更下的行),弹出堆顶,重复该过程直到堆为空或者堆顶行号大于等于 i。如果堆不为空,取堆顶的 f 值加一,作为 (i,j)位置的f。

这两种情况取最小值。

特别地,如果i=0且j=0,我们位于起点,f=1。

特别地,如果无法到达(i,j),则 f=∞

 

cf1937e

这种相互击败的关系显然是通过连边来实现

因此让一只pokemon登上舞台的所要支付的最低费用就是求最短路,因此问题就在于如何确定边权,因为此处除了直接的派上场ci的花费外还有可以进行属性增加的操作

如果直接暴力建图时间复杂度将是不能接受的

因此需要考虑的是有没有更优的建图方式

总的来说,我们分别考虑每个属性。对于 x-th属性,我们构建 2n个虚拟节点 X1,...,Xn和 Y1,...,Yn,并根据 x-th属性连接每个神奇宝贝

例如,"神奇宝贝 3增加了 1 属性 8 ,并打败了神奇宝贝 1 "可以表示为路径 3→X1→X2→X3→Y3→1

更一般地说,我们的图构建方法是:

分别考虑每个属性。假设我们处理的是 i -th 属性,将所有 a[1,i],…,a[n,i] 插入 val并排序(为方便起见,我们假设它们是成对不同的)。

构建 2n个虚拟节点 X1,...,Xn和 Y1,...,Yn ;

为 1≤i<n添加值为 (vali+1−vali)的边 Xi→Xi+1 ;//代表增加这两者的差值使得属性得到补足

为 1≤i<n添加值为 0的边 Yi+1→Yi ;//表示胜负关系

为 1≤i≤n 添加值为 0的边 Xi→Yi ;//要开始比赛了

为 1≤i≤n添加值为 ci的边 i→Xranki ;//派这只pokemon上场

为 1≤i≤n 添加值为 0的边 Yranki→i 。//表示现在派上场的pokemon打败了这只pokemon

然后在此图中运行 Dijkstra 算法。时间复杂度为 O(nmlog(nm))

 

较为复杂的边权计算时我们只考虑排序后相邻的元素(再之后的元素一定能被覆盖到,有点类似于动规的转移),并且利用队列去实现更直观

因此主要时先理清我们操作的逻辑,再对步骤进行简化代码化,如实际实现中并不需要真的建2n个节点,只是借用了上述思路中相邻节点间逐个连边的操作

void solve() {

    int n, m;

    cin >> n >> m;

    vector<int> c(n + 1);

    for (int i = 1; i <= n; i++) cin >> c[i];

    vector<vector<int>> a(n + 1, vector<int>(m + 1));

    vector<vector<pair<int, int>>> b(m + 1);

    for (int i = 1; i <= n; i++) {

        for (int j = 1; j <= m; j++) {

                cin >> a[i][j];

                b[j].push_back({a[i][j], i});

            }

    }

    vector<vector<int>> rank(n + 1, vector<int>(m + 1));

    vector<vector<int>> dec(n + 1, vector<int>(m + 1));

    for (int j = 1; j <= m; j++) {

        sort(b[j].begin(), b[j].end());

        for (int i = 0; i < n; i++) {

            auto [x, id] = b[j][i];

            rank[id][j] = i + 1;//rank[i][j]表示第i个pokemon在第j个属性中的位次

            dec[i + 1][j] = id;//再记录在第j个属性中排位为i+1的pokemon是哪一个

            //排位越高,属性值越大

        }

    }

    //单调队列dijkstra

    int ans = inff;

    vector<int> vis(n + 1, 0);

    vector<vector<int>> dist(n + 1, vector<int>(m + 1, inff));

    priority_queue<tuple<int, int, int>, vector<tuple<int, int, int>>, greater<tuple<int, int, int>>> pq;

    vis[1] = 1;

    for (int j = 1; j <= m; j++) dist[1][j] = 0, pq.push({dist[1][j], 1, j});//将第1只pokemon派上场,将用第j项属性进行对战

    while (!pq.empty()) {

        auto [w, x, t] = pq.top();

        pq.pop();

        if (dist[x][t] < w) continue;

        if (x == n) ans = min(ans, w + c[n]);//遇到目标Pokemon了,记录此时需要的费用,我们派上场的费用是这时再计算的

        if (rank[x][t] < n) {//如果不是属性值最强的

            int z = dec[rank[x][t] + 1][t];//因为队列以及代码的实现难度,我们只操作与其水平相近的

            if (w < dist[z][t]) {//连当前的Pokemon都能上场,那比它强的肯定也能,且不需要额外花费

                dist[z][t] = w;//更新

                pq.push({dist[z][t], z, t});

            }

        }

        if (rank[x][t] > 1) {//如果不是属性值最弱的

            int z = dec[rank[x][t] - 1][t];

            if (w + a[x][t] - a[z][t] < dist[z][t]) {//比他弱的要和他属性值相同所需要的花费

                dist[z][t] = w + a[x][t] - a[z][t];

                pq.push({dist[z][t], z, t});

            }

        }

        if (!vis[x]) {

            vis[x] = 1;

            for (int j = 1; j <= m; j++) {

                if (w + c[x] < dist[x][j]) {//第一次可能被派上场,初始化相关的费用,因为进行对决所使用的属性可以任选,因此全部加入队列

                    dist[x][j] = w + c[x];

                    pq.push({dist[x][j], x, j});

                }

            }

        }

    }

    cout << ans << '\n';

}

 

 

Floyd

解决多源最短路,记住本质是对中间节点的遍历(cf25c)

Floyd的本质是动态规划,其定义了这样一个状态:d[k][i][j],表示从i到j的最短路径长度,并且从i到j的路径上的中间节点(不包含i和j)的编号至多为k

状态转移:我们要求的是i到j的最短路径长度,因此后面两个参数肯定是不能变的,因此能进行改变的就是第一个参数k,根据选与不选的思路,我们很自然的能想到其中一种情况可以从k-1推导过来,表示i到j的最短路径中不含编号k的节点,那么还有一种情况自然就是i到j的路径上含有编号为k的节点,那么d[k][i][j]就是这两种情况中取最小值,但是在此时d[k][i][j]并没有被计算过,因此我们要进行转化因为k是i,j的中间节点,因此i到j的路径可以表示为i到k的路径再到k到j的路径,这两段路径中显然中间节点的编号肯定不大于k-1(否则就不符合d[k][i][j])的定义,因此这第二种情况又可以写为d[k-1][i][k]+d[k-1][k][j](此处k和k-1都是可行的,因为定义中注明了编号的判断不包括端点)

这样状态转移方程就构建出来了,又因为k的值可以只与k-1有关,因此我们可以进行空间优化,减少一个维度(但优化时要注意数组复用产生的相关问题,如01背包中需要进行逆序遍历),但因为k是从k-1转移过来的,因此最外层循环一定是枚举k

而初始值d[0][i][j]就是原图中i到j的边权,如果不存在边就赋值为INT_MAX,最终i到j的

最短路径长度就是d[n-1][i][j]

因此可以写为

d = vector<vector<int>>(n, vector<int>(n, INT_MAX / 3));  //dp数组

        for (int i = 0; i < n; ++i)  //为了之后加边服务

            d[i][i] = 0;

        for (auto &e: edges)

            d[e[0]][e[1]] = e[2]; // 添加一条边(输入保证没有重边和自环)

 

//这边的三个for循环是floyd的核心

        for (int k = 0; k < n; ++k) //枚举中间点

            for (int i = 0; i < n; ++i)

                for (int j = 0; j < n; ++j)

                    d[i][j] = min(d[i][j], d[i][k] + d[k][j]);

此时如果要进行加边(x,y),则只需要判断d[i][x]+edge[x][y]+d[y][j]是否小于原来的d[i][j]即可但有可能出现i=x,j=y的情况,因此初始也要对d[i][i]类型的进行赋值

可以写为
         void addEdge(vector<int> e) {

        int x = e[0], y = e[1], w = e[2], n = d.size();

        if (w >= d[x][y]) // 无需更新

            return;

        for (int i = 0; i < n; ++i)

            for (int j = 0; j < n; ++j)

                d[i][j] = min(d[i][j], d[i][x] + w + d[y][j]);

    }

 

要能进行转化,因为边权存在,因此,到一个点的最小边权不一定是经过边的个数的情况(T1334)

Floyd是全源图的最小路径,而Dijkstra是单点为起点的最小图路径,相当于Dijkstra要求n次才能得到相同的效果(T1334)

求解多源汇最短路的 Floyd 算法是 O(n3)的时间复杂度,在数据量较大的情况下会超时,并且如果我们并不是真的需要任意点之间的最短路而只是部分点到部分点(T2304,只是需要第一行到最后一行的最短路),我们可通过建立“虚拟源点”和“虚拟汇点”的方式,来将“多源汇最短路”问题转换为“单源最短路”问题(具体的,我们创建一个“虚拟源点”,该点向所有第一行的点连权值为 grid[0][i]的有向边;同时创建一个“虚拟汇点”,最后一行的所有点向该点连权值为 0 的有向边)

 

除了解决直观的图论,也可以用来解决一些相连问题(P1037,搜索当作图论问题,一个可转化的过程将转化前后的两个数连一条边,一个数到底能变换成哪些数,就可以通过枚举中间点求出)

inline void floyd() {  //弗洛伊德

  for (int k = 0;k <= 9;k++)

    for (int i = 0;i <= 9;i++)

      for (int j = 0;j <= 9;j++) vis[i][j] = vis[i][j] || (vis[i][k] && vis[k][j]); 

//vis为是否可行,直接或间接

}

//这里用了内联,之后如果在代码中要使用,直接写floyd()即可

 

bfs:

当所有边权都相同时,可以用bfs来寻找最短路

cf520B

int vis[200005];

int ans[200005];

void solve(){

    int n,m;

    cin>>n>>m;

    queue<int> q;

    q.push(n);

    ans[n]=0;

    vis[n]=1;

    while(!q.empty()){

        int sz=q.size();

        int p=q.front();

        q.pop();

        if(p<0 || p>100000) continue;

        if(!vis[p*2]){

            ans[p*2]=ans[p]+1;

            if(p*2==m){

                cout<<ans[p*2]<<'\n';

                return;

            }

            vis[p*2]=1;

            q.push(p*2);

        }

        if(!vis[p-1]){

            ans[p-1]=ans[p]+1;

            if(p-1==m){

                cout<<ans[p-1]<<'\n';

                return;

            }

            vis[p-1]=1;

            q.push(p-1);

        }

    }

}

 

ICPC2024网络赛第二场 E

保证图连通,没有自环和重边,要求玩家从房间1开始移动到房间n,期间不能在一次移动结束后和机器人处于同一房间,同时每个机器人都有自己的移动范围,也就是它从起始位置开始通过的通道数是有限制的

要求找到一条符合要求同时最短的线路

可以看成玩家和机器人都不能在原地停留,然后机器人有个活动范围限制

如果玩家和机器人可以在原地停留,那么玩家到达一个点肯定会尽可能早,同时时间必须必机器人到达这个点早,否则可以视作机器人能够在原地等到玩家到来

因此可以预处理每个点会被机器人到达的最早时间,作为每个点的限制条件,然后在这个基础上处理出1~n的最短路即可

由于所有机器人的活动半径是相同的,因此可以用多源最短路完成预处理,如果距离大于D就可以不入队跳过

现在考虑玩家和机器人都不能在原地停留,此时发现通过在相邻边上反复横跳可以让机器人到达一个点的时间加2,也就是在相同奇偶性的情况下,相当于可以在原地进行等待,因此可以处理到达一个点的奇数时间和偶数时间的最小值,然后就可以按照上面的做法进行操作,每次的限制是对应的奇数以及偶数时刻不能超过机器人的到达时间

void solve(){

    int n,m,D;

    cin>>n>>m>>D;

    vector<vector<int>> adj(n,vector<int>());

    for(int i=0;i<m;i++){

        int x,y;

        cin>>x>>y;

        x--,y--;

        adj[x].emplace_back(y);

        adj[y].emplace_back(x);

    }

    int k;

    cin>>k;

    vector<int> st(k);

    for(int i=0;i<k;i++){//机器人的初始位置

        cin>>st[i];

        st[i]--;

    }

    //bfs预处理多源最短路

    vector<array<int,2>> dis1(n,{inf,inf});//分别记录奇数和偶数时刻的最短路

    queue<pair<int,int>> q;

    for(int i=0;i<k;i++){

        dis1[st[i]][0]=0;

        q.push({st[i],0});

    }

    while(q.size()){

        auto [p,d]=q.front();

        q.pop();

        if(d>=D) continue;//移动范围不能超过d

        for(auto v:adj[p]){

            //通过设置第二维,相当于可以让一个点入队两次

            if(dis1[v][!(d&1)]>d+1){

                dis1[v][!(d&1)]=d+1;

                q.push({v,d+1});

            }

        }

    }

    //bfs最短路

    vector<array<int,2>> dis2(n,{inf,inf});

    vector<array<int,2>> pre(n,{0,0});

    vector<array<int,2>> vis(n,{0,0});

    q.push({0,0});

    dis2[0][0]=0;

    while(q.size()){

        auto [p,d]=q.front();

        q.pop();

        vis[p][d&1]=1;

        if(p==n-1) break;

        for(auto v:adj[p]){

            if(vis[v][!(d&1)]) continue;

            //必须比机器人到的时间早

            if(d+1<dis1[v][!(d&1)] && dis2[v][!(d&1)]>d+1){

                dis2[v][!(d&1)]=d+1;

                q.push({v,d+1});

                pre[v][!(d&1)]=p;

            }

        }

    }

    if(dis2[n-1][0]>=inf && dis2[n-1][1]>=inf){

        cout<<"-1\n";

        return;

    }

    int ans=min(dis2[n-1][0],dis2[n-1][1]);

    cout<<ans<<'\n';

    int cur=n-1;

    vector<int> path;//记录路径

    while(ans){

        path.push_back(cur+1);

        cur=pre[cur][ans&1];

        ans--;

    }

    path.emplace_back(1);

    reverse(path.begin(),path.end());

    for(auto x:path){

        cout<<x<<' ';

    }

    cout<<'\n';

}  

 

 

cf2004D

n个城市,有一些传送门,每个城市有两种颜色,有相同颜色的两个城市可以行走,花费是abs(i-j)(代价就是距离)

因为只有4个不同的颜色,并且每次移动的花费和两点之间的距离是成正比的,因此跳转时,只要我们前面的路径中有之前出现过的颜色,那么就可以省去那个点直接跳到此处

由此可以得到一个结论,路程中最多只会经过4个颜色

进而考虑起始点与终点,如果他们的颜色有重合,那么显然最小花费就是直接跳跃,如果两者颜色不同,那么我们需要找到中继点使得能通过它到达起点与终点(总共只有4中颜色,如果x的颜色和b的互不相同,那么它们已经包括了所有的四种颜色,因此一定存在一个点能够作为它们的中继点)

于是就考虑找出一个点两个颜色固定,使它到x的距离加上它到y的距离最小

首先考虑它和y在x的同一边,那么在x和y之间的点实际上是可以任选的,因为花费都是x与y之间的曼哈顿距离,否则就应该选择与y最近的点,因为这种情况下会额外加上距离y两倍的距离,总的来说就是选择距离x近的点

对于从y来说的点,在x,y之间的点已经在之前的情况中讨论过了,因此可以选择距离x近的点

结合这两种情况,就是选择距离x最近的左右边点,这可以预处理解决

const string color="BGRY";

 

int ha(int x,int y){

    if(x>y){

        swap(x,y);

    }

    return y*(y-1)/2+x;

}

 

void solve(){

    int n,q;

    cin>>n>>q;

    vector<array<int,2>> c(n);

    for(int i=0;i<n;i++){

        string s;

        cin>>s;

        for(int j=0;j<2;j++){

            c[i][j]=color.find(s[j]);

        }

    }

    vector<array<int,6>> pre(n),nxt(n);

    for(int i=0;i<n;i++){

        if(i==0){

            pre[i].fill(-1);

        }else{

            pre[i]=pre[i-1];

        }

        pre[i][ha(c[i][0],c[i][1])]=i;

    }

    for(int i=n-1;i>=0;i--){

        if(i==n-1){

            nxt[i].fill(n);

        }else{

            nxt[i]=nxt[i+1];

        }

        nxt[i][ha(c[i][0],c[i][1])]=i;

    }

    while(q--){

        int x,y;

        cin>>x>>y;

        x--;

        y--;

        if(x>y){

            swap(x,y);

        }

        int ans=inf;

        for(auto a:c[x]){

            for(auto b:c[y]){//枚举颜色选中继点,或者判断花费直接就是它们的距离

                if(a==b){

                    ans=y-x;

                }else{

                    int w=ha(a,b);

                    for(auto z:{nxt[x][w],pre[x][w]}){

                        if(0<=z && z<n){

                            ans=min(ans,abs(x-z)+abs(y-z));

                        }

                    }

                }

            }

        }

        if(ans==inf){

            ans=-1;

        }

        cout<<ans<<'\n';

    }

}

 

Cf 2041D

不同的是有所限制,也就是每个方向最多连续移动3步

因此我们可以对每一次移动额外记录我们移动的方向,并且在移动的时候同时加入移动的步数,这样在下次移动的时候只需要保证移动的方向不同即可

void solve(){

    int n,m;

    cin>>n>>m;

    vector<string> s(n);

    for(int i=0;i<n;i++){

        cin>>s[i];

    }

    array<int,2> start,end;

    for(int i=0;i<n;i++){

        for(int j=0;j<m;j++){

            if(s[i][j]=='S'){

                start={i,j};

            }

            if(s[i][j]=='T'){

                end={i,j};

            }

        }

    }

    vector dis(n,vector(m,array<int,4>{-1,-1,-1,-1}));

    queue<array<int,4>> q[4];

    for(int d=0;d<4;d++){

        q[0].push({0,start[0],start[1],d});

    }

    while(1){

        array<int,4> pre {inf,-1,-1,-1};

        int op=-1;

        for(int k=0;k<4;k++){

            if(!q[k].empty() && q[k].front()<pre){

                pre=q[k].front();

                op=k;

            }

        }

        if(pre[0]==inf){

            break;

        }

        q[op].pop();

        auto [ds,x,y,d1]=pre;

        if(dis[x][y][d1]!=-1){

            continue;

        }

        dis[x][y][d1]=ds;

        for(int d=0;d<4;d++){

            if(d==d1){

                continue;

            }

            for(int st=1;st<=3;st++){

                int nx=x+dx[d]*st;

                int ny=y+dy[d]*st;

                if(s[nx][ny]=='#'){

                    break;

                }

                q[st].push({ds+st,nx,ny,d});

            }

        }

    }

    int ans=-1;

    for(int d=0;d<4;d++){

        int res=dis[end[0]][end[1]][d];

        if(res!=-1 && (ans==-1 || res<ans)){

            ans=res;

        }

    }

    cout<<ans<<'\n';

}

 

 

最长路:
P1938

将点权化为边权,到达每个点都能得到一定的收入,可以转变为与这个点相连的边能得到的收入

要让收入最大因此就是跑一遍最长路

做最短路是我们是先将 dis 数组设为无穷大,再每次更新最小值。反过来,最长路就可以将dis 数组都设为 0 ,再每次更新最大值。

或者将所有边权都取反后,再求最短路

但因为题中可能出现负权环,因此不能用dijkstra计算,但可以用spfa

void Spfa(){

    q.push(s);

    w[s]=d; vis[s]=1; cnt[s]++;

    while(!q.empty()){

        int u=q.top(); q.pop();

        vis[u]=0;

        if(++cnt[u]>n){

            cout<<-1<<'\n';

            exit(0);

        }  

        for(int i=head[u];i;i=nex[i]){

            int v=to[i];

            if(w[v]<w[u]+dis[i]){

                w[v]=w[u]+dis[i];    

                if(!vis[v]){

                    q.push(v);

                    vis[v]=1;

                }

            }

        }

    }

}

int main(){

    cin>>d>>m>>n>>f>>s;

    for(int i=1;i<=m;++i){

        int x,y;

        cin>>x>>y;

        add_edge(x,y,d);                

    }

    for(int i=1;i<=f;++i){

        int x,y,z;

        cin>>x>>y>>z;

        add_edge(x,y,d-z);

    }

    Spfa();

    int ans=0;

    for(int i=1;i<=n;++i) ans=max(ans,w[i]);

    cout<<ans<<'\n';

    return 0;

}

 

生成树:
cf1583E

可以发现样例的路径构成了一个环,一个路径环可以使其中的每条路径经过两次,同时也说明环上的每个点作为起点或者终点都出现了两次(前面若干次路径构成一个环,最后一次选择路径时选择整个环)

这个结论也可以推广到偶数次

也就是若所有的起点终点都出现了偶数次,那么一定可以找一种路径方案使得每条边也被经过了偶数次

而对于不满足上述条件的点集,只要在奇数次数的点之间每两个加一条边即可,那么需要添加的路径条数就是作为起点或终点出现奇数次的点数/2

考虑如何具体求出路径

因为要求出现次数为2的一组点连成的路径构成一个环,因此任意两点之间走的应该是同一条路径,树满足这个条件(叶子节点成为端点的次数是偶数,那么其与父节点之间的边经过的次数也一定是偶数,因此只要是在树的路径上选择,一定能够满足最终每条边被经过两次)

因此可以建原图的任意生成树,然后跑dfs

void solve(){

    int n,m;

    cin>>n>>m;

    vector<vector<int>> adj(n+1);

    for(int i=1;i<=m;i++){

        int u,v;

        cin>>u>>v;

        adj[u].push_back(v);

        adj[v].push_back(u);

    }

    vector<vector<int>> g(n+1);

    vector<int> vis(n+1);

    auto dfs1=[&](auto self,int x)->void{

        vis[x]=1;

        for(auto y:adj[x]){

            if(vis[y]) continue;

            g[x].push_back(y);

            g[y].push_back(x);

            self(self,y);

        }

    };

    dfs1(dfs1,1);

    int q;

    cin>>q;

    vector<int> a(q+1),b(q+1);

    vector<int> cnt(n+1);

    for(int i=1;i<=q;i++){

        cin>>a[i]>>b[i];

        cnt[a[i]]^=1;

        cnt[b[i]]^=1;

    }

    int tot=0;

    for(int i=1;i<=n;i++){

        tot+=cnt[i];

    }

    vector<int> path,ans;

    auto dfs2=[&](auto self,int x,int fa,int end)->void{

        path.emplace_back(x);

        if(x==end){

            ans=path;

            return;

        }

        for(int y:g[x]){

            if(y==fa) continue;

            self(self,y,x,end);

        }

        path.pop_back();

        //爆搜,进行回溯

    };

    if(tot==0){

        cout<<"YES\n";

        for(int i=1;i<=q;i++){

            path.clear();

            dfs2(dfs2,a[i],-1,b[i]);

            cout<<ans.size()<<'\n';

            for(int j:ans){

                cout<<j<<' ';

            }

            cout<<'\n';

        }

    }else{

        cout<<"NO\n";

        cout<<(tot/2)<<'\n';

    }

}

 

 

求最小生成树(T1584):

Prim

抽象出两个集合,集合V和集合Vnew,集合V保存未加到最小生成树中的节点,最开始,所有节点都在集合V中

集合Vnew保存已经加入到最小生成树的节点,如果一个节点加入到了最小生成树中,则将其加入到Vnew中,因此,Vnew即最小生成树

Prim算法主要维护两个数组:lowcost数组,表示V中的节点,保存V中每个节点离集合Vnew中所有节点的最短距离;v数组表示所有节点的访问情况,为0表示未加入,为1表示已加入(这一步有点像Dijkstra)

随机选择一个起点,将其加入到集合 Vnew中。同时,更新此时的数组 lowcost和数组V

遍历 lowcost,寻找 lowcost中的最小值,假设下标为j,则j为集合V中离集合Vnew最近的点),将与下标j对应的节点加入到集合 Vnew中,并更新数组 lowcost和数组V。

找到lowcost中的最小值j后,此时数组lowcost中的所有节点都需要更新,因为此时集合 Vnew中的节点增加了节点j,集合V中的节点离 Vnew的最近距离可能会缩短。

根据新加入集合 Vnew中的节点j,更新集合 V中剩余所有节点的 lowcost。

重复步骤2,直到访问了所有的节点

最后需要计算的最小生成树中所有节点之间的距离之和 便是每一步迭代时求得的lowcost中的最小值min的和

时间复杂度O(n2)  空间复杂度O(n2)

int prim(vector<vector<int> >& points, int start) {

        int n = points.size();

        int res = 0;

 

        // 1. 邻接矩阵

        vector<vector<int> > g(n, vector<int>(n));

        for (int i = 0; i < n; i++) {

            for (int j = i + 1; j < n; j++) {

                int dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]);

                g[i][j] = g[j][i] = dist;

            }

        }

        // 记录V中的点到Vnew的最近距离

        vector<int> lowcost(n, INT_MAX);

        // 记录V中的点是否加入到了Vnew

        vector<int> v(n);

        // lowcost 和 v 可以优化成一个数组

 

        // 2. 先将start加入到Vnew

        v[start] = 1;

        for (int i = 0; i < n; i++) {

            if (i == start) continue;

            lowcost[i] = g[i][start];

        }

 

        // 3. 遍历剩余若干个未加入到Vnew的节点

        for (int _ = 1; _ < n; _++) {

            // 找出此时V中,离Vnew最近的点

            int minIdx = -1;

            int minVal = INT_MAX;

            for (int j = 0; j < n; j++) {

                if (v[j] == 0 && lowcost[j] < minVal) {

                    minIdx = j;

                    minVal = lowcost[j];

                }

            }

            // 将最近的点加入Vnew

            v[minIdx] = 1;

            res += minVal;

 

            // 更新集合V中剩余所有点的lowcost

            for (int j = 0; j < n; j++) {

                if (v[j] == 0 && g[j][minIdx] < lowcost[j]) {

                    lowcost[j] = g[j][minIdx];

                }

            }

        }

        return res;

 

Kruskal(并查集)(P1194

不同之处:Prim算法是以顶点为基础(每次寻找距离Vnew最近的节点);Kruskal是以边为基础,每次从边集合中找到最小的边(不管两个顶点属于V还是属于Vnew),然后判断该边的两个顶点是否同源(属于同一个连通分量)

Kruskal需要对所有的边进行排序,然后从小到大,依次遍历每条边,同时判断每条边是否同源,如果同源,跳过;如果不同源,将两个连通分量合并,直到所有顶点属于同一个连通分量,算法结束

因此显然我们要用到并查集

因为要求对所有边进行排序,因此需要知道每条边的两个端点以及这条边的长度,因此可以建立一个结构体来保存以上三个属性

之后可以遵循以下的步骤:初始化:将图(邻接矩阵或邻接表)转换成点-边式,并对点-边式按边的长度进行排序。同时,初始化并查集。

依次遍历所有的点-边式,每次取最小值。

作如下判断:如果该点-边式的两个顶点同源,跳过;如果该点-边式的两个顶点不同源,则将这两个源(连通分量)合并

重复步骤2,直到存在一个连通分量,包含了图中所有的节点

算法结束

时间复杂度:O(mlog(m)+ma(m))  空间复杂度:O(n)

class Djset {

public:

    vector<int> parent; // 记录节点的根

    vector<int> rank;   // 记录根节点的深度(用于优化)

    vector<int> size;   // 记录每个连通分量的节点个数

    vector<int> len;    // 记录每个连通分量里的所有边长度

    int num;            // 记录节点个数

    Djset(int n): parent(n), rank(n), len(n, 0), size(n, 1), num(n) {

        for (int i = 0; i < n; i++) {

            parent[i] = i;

        }

    }

 

    int find(int x) {

        // 压缩方式:直接指向根节点

        if (x != parent[x]) {

            parent[x] = find(parent[x]);

        }

        return parent[x];

    }

 

    int merge(int x, int y, int length) {

        int rootx = find(x);

        int rooty = find(y);

        if (rootx != rooty) {

            if (rank[rootx] < rank[rooty]) {

                swap(rootx, rooty);

            }

            parent[rooty] = rootx;

            if (rank[rootx] == rank[rooty]) rank[rootx] += 1;

            // rooty的父节点设置为rootx,同时将rooty的节点数和边长度累加到rootx,

            size[rootx] += size[rooty];

            len[rootx] += len[rooty] + length;

            // 如果某个连通分量的节点数 包含了所有节点,直接返回边长度

            if (size[rootx] == num) return len[rootx];

        }

        return -1;

    }

};

struct Edge {

    int start; // 顶点1

    int end;   // 顶点2

    int len;   // 长度

};

 

class Solution {

public:

    int minCostConnectPoints(vector<vector<int>>& points) {

        int res = 0;

        int n = points.size();

        Djset ds(n);

        vector<Edge> edges;

        // 建立点-边式数据结构

        for (int i = 0; i < n; i++) {

            for (int j = i + 1; j < n; j++) {

                Edge edge = {i, j, abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1])};

                edges.emplace_back(edge);

            }

        }

        // 按边长度排序

        sort(edges.begin(), edges.end(), [](const auto& a, const auto& b) {

            return a.len < b.len;

        });

 

        // 连通分量合并

        for (auto& e : edges) {

           res = ds.merge(e.start, e.end, e.len);

           if (res != -1) return res;

        }

        return 0;

    }

};

T1584跑出来还是Prim算法的速度更快点

 

要求小一点的基本模版(P1194),并且注意建图时如果没有关系不要连边,例如没有优惠边权为0,但不是指消费为0就能连这两条边,并且我们自行增加一个0节点其与其他各节点的边权指无优惠时的价格,或者说初始消费

struct node

{

int u,v,w;

}e[250000];

int n,k,tot=1,ans,f[555];

bool cmp(node x,node y){

        return x.w<y.w;

}int find(int x){

        if(f[x]==x) return x;

        return f[x]=find(f[x]);

}int hb(int x,int y){

        int xx=find(x);

        int yy=find(y);

        if(xx!=yy) f[xx]=yy;

}void build(int x,int y,int z){  //一开始k就++,因此下标是从1开始

        k++;

        e[k].u=x;

        e[k].v=y;

        e[k].w=z;

}void kruskal(){

        int j=1;

        while(j<=k&&tot<=n){  //还自行添加了一个0节点,因此实际的总结点数为n+1

            if(find(e[j].u)!=find(e[j].v))

            {

                    tot++;

                    ans+=e[j].w;

                    hb(e[j].u,e[j].v);

            }

            j++;

        }

}

 

cf891C

对于所有的最小生成树,其中每种权值的边的数量是一定的,因此如果加入了<wi的所有边(因为这些边中的一些边不加入最小生成树只会得到更劣解),而使边权与wi相同的边无法加入,说明这条边不在最小生成树上,即他们无法同时存在于同一棵生成树上

对于任意正确的加边方案,加完小于某权值的所有边后图的连通性是一样的

并且在同一组询问中,不同边权之间其实并不存在影响,因为比当前边权大的不在此时的判断范围内,而比当前边权小的此前已经全部被加入kruskal的贡献中

因此将每组询问拆成若干组边权相同的询问,对于每组边权相同的询问分别处理

可以对每一条边用kruskal预处理出加完小于这条边的权值的所有边后,这条边的两个端点所在的连通块,然后对于每个询问,每种权值分开考虑,看这些边连接的连通块是否构成环,如果构成环,那就不满足kruskal的条件

struct DSU{

    vector<int> f,siz;

    DSU(){};

    DSU(int n){

        init(n);

    }  

    void init(int n){

        f.resize(n);

        iota(f.begin(),f.end(),0);

        siz.assign(n,1);

    }

    int find(int x){

        while(x!=f[x]){

            x=f[x]=f[f[x]];

        }

        return x;

    }

    bool same(int x,int y){

        return find(x)==find(y);

    }

    bool merge(int x,int y){

        x=find(x);

        y=find(y);

        if(x==y){

            return false;

        }

        siz[x]+=siz[y];

        f[y]=x;

        return true;

    }

    int size(int x){

        return siz[find(x)];

    }

};

 

struct Edge{

    int x,y,val,id;

    int tx,ty;

    bool operator < (const Edge a)const{

        return val<a.val;

    }

}e[500005];

 

void solve(){

    int n,m;

    cin>>n>>m;

    DSU dsu(n+1);

    for(int i=1;i<=m;i++){

        cin>>e[i].x>>e[i].y>>e[i].val;

        e[i].id=i;

    }

    sort(e+1,e+m+1);

    e[0].val=-1;

    for(int i=1;i<=m;){

        int j=i;

        while(j<=m && e[j].val==e[i].val){

            e[j].tx=dsu.find(e[j].x);

            e[j].ty=dsu.find(e[j].y);

            j++;

        }

        while(i<j){//加入w[j+1]前的所有边

            while(i<j && (dsu.find(e[i].x)==dsu.find(e[i].y))) i++;

            if(i<j){

                dsu.merge(e[i].x,e[i].y);

                i++;

            }

        }

    }

    int q;

    cin>>q;

    sort(e+1,e+m+1,[&](const Edge x,const Edge y){

        return x.id<y.id;

    });//还原初始顺序

    dsu.init(n+1);

    while(q--){

        int k;

        cin>>k;

        vector<Edge> v;

        for(int i=1;i<=k;i++){

            int a;

            cin>>a;

            v.push_back({e[a].tx,e[a].ty,e[a].val});

        }

        sort(v.begin(),v.end());//按权值分组进行判断

        bool ok=1;

        for(int i=0,sz=v.size()-1;i<=sz && ok;){

            if(v[i].x==v[i].y){//成环

                ok=0;

                break;

            }

            dsu.merge(v[i].x,v[i].y);

            int j=i+1;

            while(j<=sz && v[j].val==v[i].val){//同权值的边进行判断

                if(dsu.find(v[j].x)==dsu.find(v[j].y)){

                    ok=0;

                    break;

                }

                dsu.merge(v[j].x,v[j].y);

                j++;

            }

            while(i<j){

                dsu.f[v[i].x]=v[i].x;

                dsu.f[v[i].y]=v[i].y;

                i++;

            }

        }

        cout<<(ok?"YES\n":"NO\n");

    }

}

或者  因为加入一组询问中边权相同的边后需要撤回这组边,因为可能会对后续的询问产生影响,于是需要一个可撤销并查集

 

CCPC2024 山东J

给一张完全图,每个点有颜色,共有n种颜色,其中第i中颜色的点有ai个,两点之间的边权由两点的颜色决定,求该完全图的最小生成树

对每种颜色需要维护:这个颜色的所有点是否在同一连通块中,以及颜色之间的并查集

模仿Kruskal的过程,按照边权从小到大考虑每对颜色(u,v)的连边,并分类讨论

如果u=v,若颜色u所有点已经在同一连通块中则跳过,否则连(a[u]-1)条边,同时标记该颜色已在同一连通块中

否则,若u和v已经在同一连通块中则跳过

若u和v都不在同一连通块中,则连a[u]+a[v]-1条边,否则若u所有点不在同一连通块内,连a[u]-1条边,v同理

否则只需要连1条边即可

连接完毕后,标记u和v的所有点在同一连通块内,并连接颜色的并查集

本质上还是直接进行kruskal,但相当于每个节点各自存在着siz,因此在连边时,节点内部也需要连边

void solve(){

    int n;

    cin>>n;

    i64 ans=0;

    DSU dsu(n);

    for(int i=0;i<n;i++){

        cin>>dsu.siz[i];

    }

    vector<array<int,3>> edges;

    for(int i=0;i<n;i++){

        for(int j=0;j<n;j++){

            int val;

            cin>>val;

            edges.push_back({val,i,j});

        }

    }

    sort(edges.begin(),edges.end());

    for(auto [w,u,v]:edges){

        u=dsu.find(u);

        v=dsu.find(v);

        if(u==v){

            ans+=1ll*w*(dsu.size(u)-1);

            dsu.siz[u]=1;

        }else{

            ans+=1ll*w*(dsu.size(u)+dsu.size(v)-1);

            dsu.merge(u,v);

            dsu.siz[u]=1;

            dsu.siz[v]=0;

        }

    }

    cout<<ans<<'\n';

}

 

 

牛客24多校2  B

题目大意:给定一个n个点的简单带权无向图

每次询问一个点集S,求S关于G导出子图的最小生成树

如果不存在则输出-1

用kruskal
直接拿出所给点集的边进行讨论,考虑加边

按照每个点出现的次数进行划分

两种暴力:
对于给定点集S,双重循环枚举S中的每个点,把其中有效的边取出来并排序,然后使用kruskal算法

对于给定点集S,循环枚举S中的每个点,再循环枚举该点的所有边,将合法的边取出来并排序,然后使用Kruskal算法求最小生成树

因为当出现次数小于根号n时,用第一种暴力的时间复杂度较优,而当出现次数大于根号n时,用第二种的时间复杂度较优

因此以根号为界进行划分

#include<bits/stdc++.h>

using namespace std;

using i64=long long;

#define ll long long

#define all(x) (x).begin(),(x).end()

const int inf=0x3f3f3f3f;//1e9

const ll inff=1e18;

constexpr int N=1e5;

constexpr int B=400;//根号划分

 

struct DSU{

    vector<int> f,siz;

    DSU(){};

    DSU(int n){

        init(n);

    }  

    void init(int n){

        f.resize(n);

        iota(f.begin(),f.end(),0);

        siz.assign(n,1);

    }

    int find(int x){

        while(x!=f[x]){

            x=f[x]=f[f[x]];

        }

        return x;

    }

    bool same(int x,int y){

        return find(x)==find(y);

    }

    bool merge(int x,int y){

        x=find(x);

        y=find(y);

        if(x==y){

            return false;

        }

        siz[x]+=siz[y];

        f[y]=x;

        return true;

    }

    int size(int x){

        return siz[find(x)];

    }

};

 

void solve(){

    int n,m,q;

    cin>>n>>m>>q;

    vector<array<int,3>> edges(m);

    for(int i=0;i<m;i++){

        int u,v,w;

        cin>>u>>v>>w;

        u--;v--;

        edges[i]={w,u,v};

    }

    sort(edges.begin(),edges.end());    

    vector<vector<int>> s(q);//s存储询问的点

    vector<vector<int>> e(q);

    vector<vector<int>> vec(n);

    for(int i=0;i<q;i++){

        int k;

        cin>>k;

        s[i].resize(k);

        for(int j=0;j<k;j++){

            cin>>s[i][j];

            s[i][j]--;

            vec[s[i][j]].emplace_back(i);//存储所在的询问

        }

        e[i].reserve(min(k*(k-1)/2,m));

        //第i个询问所取出的边

        //提前申请内存,在处理大量数据时提高效率

    }

    vector<bitset<N>*> bit(n);

    for(int i=0;i<n;i++){

        if(vec[i].size()>B){

            bit[i]=new bitset<N>();

            for(auto j:vec[i]){//对大于根号n的,将对应询问打上标记

                bit[i]->set(j);

            }

        }

    }

    //遍历原图中的边

    for(int i=0;i<m;i++){

        auto [w,u,v]=edges[i];

        if(vec[u].size()>vec[v].size()){

            swap(u,v);

        }

        //两个点的询问数均大于B,将边加到对应的询问中

        if(vec[v].size()>B){

            for(auto j:vec[u]){

                if((*bit[v])[j]){

                    e[j].emplace_back(i);

                }

            }

        }else{

            int k=0;

            for(auto j:vec[u]){

                while(k<vec[v].size() && vec[v][k]<j){

                    k++;

                }

                if(k<vec[v].size() && vec[v][k]==j){

                    e[j].emplace_back(i);

                }

            }

        }

    }

    DSU dsu(n);

    for(int i=0;i<q;i++){

        int comp=s[i].size();

        i64 ans=0;

        for(auto j:e[i]){

            auto [w,u,v]=edges[j];

            if(dsu.merge(u,v)){

                comp--;

                ans+=w;

            }

        }

        if(comp>1){

            ans=-1;

        }

        cout<<ans<<'\n';

        //还原

        for(auto x:s[i]){

            dsu.f[x]=x;

            dsu.siz[x]=1;

        }

    }

}

 

int main(){

    ios::sync_with_stdio(0);cin.tie(0);

    int t=1;

    // cin>>t;

    while(t--){

        solve();

    }

    return 0;

}

 

 

 

kruskal重构树

构造方法:

把最小生成树上的边从小到大排序,像最小生成树那样判断连通性即是否加边

假设可以连边,我们就把这条边的边权作为另外两点所在集合根节点的父节点

如果此时遍历完所有的边,即构造完成,得到了重构树

重构树的性质:

首先一定是一颗二叉树,因为所有的连边过程都是边权节点与两个不相连的根节点相连。其次原图中两个点间所有路径上的边最大权值的最小值一定是构造树上两点的 lca 权值。

 

cf2021E

定义两个点的距离为这两个路径上的边权的最大值

给定若干个黑点,选k个关键点使得每个黑点到最近的关键点的距离之和最小

只需要求最小生成树,然后直接重构,用重构树来计算,边权是单调的

可以认为边权是自上而下递减的,越靠近根,边权越大

因此p个点(黑点)一定是放在叶子,每一个点的代价就是它和关键点的lca,最深的那个节点之间的距离

一棵树,有点权,有一些叶子是关键点,选择k个点,使得路径值最小

对于每棵子树,如果子树至少有一个,里面就会有一个非负的价值,也就是里面的点数乘上边权,要做的就是最大化这些子树价值的和

长链剖分

void solve() {

    int n,m,p;

    cin>>n>>m>>p;

    vector<int> s(p);

    for(int i=0;i<p;i++){

        cin>>s[i];

        s[i]--;

    }

    vector<array<int,3>> edges(m);

    for(int i=0;i<m;i++){

        int u,v,w;

        cin>>u>>v>>w;

        u--;

        v--;

        edges[i]={w,u,v};

    }

    sort(edges.begin(),edges.end());

    DSU dsu(2*n-1);

    int cnt=n;

    vector<int> W(2*n-1);

    vector<vector<int>> adj(2*n-1);

    for(auto [w,u,v]:edges){

        if(!dsu.same(u,v)){

            int x=cnt++;

            W[x]=w;

            adj[x].push_back(dsu.find(u));

            adj[x].push_back(dsu.find(v));

            dsu.merge(x,u);

            dsu.merge(x,v);

        }

    }

    vector<int> siz(2*n-1);

    for(auto x:s){

        siz[x]++;

    }

    vector<i64> a;

    auto dfs=[&](auto self,int x,int p=-1)->i64{

        i64 v=0ll;

        for(auto y:adj[x]){

            i64 w=self(self,y,x);

            siz[x]+=siz[y];

            if(w>v){

                swap(v,w);

            }

            a.push_back(w);

        }

        if(p!=-1){

            v+=1ll*siz[x]*(W[p]-W[x]);

        }

        return v;

    };

    int rt=cnt-1;

    a.push_back(dfs(dfs,rt));

    sort(a.begin(),a.end(),greater());

    a.resize(n);

    i64 ans=1ll*p*W[rt];

    for(int i=0;i<n;i++){

        ans-=a[i];

        cout<<ans<<' ';

    }

    cout<<'\n';

}

 

 

P9235 蓝桥 23 网络稳定性

显然可以用floyd求出多源最短路来得到部分分数

memset(dp,-1,sizeof(dp));

cin>>n>>m>>q;

while(m--){

    int u,v,x;

    cin>>u>>v>>x;

    dp[u][v]=max(dp[u][v],x);   //选稳定性最大的一条路

    dp[v][u]=max(dp[v][u],x);

}

for(int k=1;k<=n;k++){

    for(int i=1;i<=n;i++){

        for(int j=1;j<=n;j++){

            if(i!=j&&j!=k&&i!=k){   //用 以K中转的路径 更新dp[i][j]

                dp[i][j]=max(dp[i][j],min(dp[i][k],dp[k][j]));

            }

        }

    }

}

因为这题求的是路径上的最大值的最小值,那么我们就在排序的时候把边按照边权从大到小排序就好了,具体的求 lca 我们使用倍增树剖都是可以的。代码实现的话需要注意构造出来的不一定是一颗树,而很可能是森林,所以需要每一次判断连通性去初始化树剖

重构树实际就是在 kruskal 建最大生成树的过程中额外建点、赋权。

比如,u 和 v 当前不在一个集合里,通过 w 这条边合并时。

新开一个点 x,令 x 是 u 和 v 的父亲,而 x 的权值为 w。

查询时,查 u 和 v 的 LCA 的权值即可,即为最大连通路径上的最小连通权值。

因为按权值从大到小遍历,已经通过权值大的边,使得点之间尽可能连通了

按照要求排序边,这里和启发式合并一样,都是从大到小排序,重构树的主要特点就是在连边时,将对应的边权作为被连接起来的两个连通块的父节点,然后再求一下lca即可

const int N=4e5+10,M=3e5+10,K=20;

int n,m,q,u,v,par[N],a[N],f[N][K],dep[N];

bool vis[N];

vector<int> E[N];

struct edge{

    int u,v,w;

}e[M];

int find(int x){//kruskal是基于并查集的生成

    return par[x]==x?x:par[x]=find(par[x]);

}

bool cmp(edge a,edge b){

    return a.w>b.w;//根据边权排序

}

void dfs(int u,int fa){//dfs建立dfs序

    vis[u]=1;

    f[u][0]=fa;

    dep[u]=dep[fa]+1;

    for(auto &v:E[u]){

        if(v==fa)continue;

        dfs(v,u);

    }

}

int lca(int u,int v){//倍增求lca

    if(dep[u]<dep[v]) swap(u,v);//使x的dfs较大,即深度较深

    int d=dep[u]-dep[v];

    for(int i=K-1;i>=0;--i){

        if(d>>i&1) u=f[u][i];

    }//使得u,v在同一深度

    if(u==v) return u;//如果直接相等了,说明v是u的祖先

    for(int i=K-1;i>=0;--i){//开始同步向上跳,先尝试大步跳,再尝试小步跳

        if(f[u][i]!=f[v][i]) u=f[u][i],v=f[v][i];

        //不等,说明lca还在f[u][i]上方,更新后继续循环

        //否则,说明lca在f[u][i]下方,因为没法向下跳,因此减小i

    }

    return f[u][0];

}

void solve() {

    cin>>n>>m>>q;

    for(int i=1;i<=n+m;++i){

        par[i]=i;

    }

    for(int i=1;i<=m;++i){

        cin>>e[i].u>>e[i].v>>e[i].w;

    }

    sort(e+1,e+m+1,cmp);

    int cur=n;

    for(int i=1;i<=m;++i){//kruskal生成树

        int u=e[i].u,v=e[i].v,w=e[i].w;

        u=find(u),v=find(v);

        if(u==v)continue;

        ++cur;

        par[u]=par[v]=cur;

        //cur是我们后续新建的节点,其权值是边权,作为连接u,v边,被视作是u,v的父节点

        E[cur].push_back(u);//加边/构建父子关系

        E[cur].push_back(v);

        //通过这样加边的方式能看出最后重构得到的是一棵二叉树

        a[cur]=w;

    }

    for(int i=cur;i>=1;--i){//因为可能是森林,对每棵树都建立dfs序

        if(!vis[i]) dfs(i,0);

    }

    for(int j=1;j<K;++j){//预处理倍增数组

        for(int i=1;i<=cur;++i){

            f[i][j]=f[f[i][j-1]][j-1];

        }

    }

    while(q--){

        //在线回答询问

        cin>>u>>v;

        if(find(u)!=find(v)){

            cout<<-1<<'\n';

            continue;

        }

        cout<<a[lca(u,v)]<<'\n';//稳定值就是最近公共祖先,本质是将边按边权从大到小排序后依次插入原图,询问两个点在什么时候会连通

    }

}

 

 

路径问题:
当题目允许任意方向移动时,考察的一般就是图论而不是dp了

从本质上说,DP 问题是一类特殊的图论问题,一些 DP 题目简单修改条件后,导致我们 DP 状态展开不再是一个拓扑序列,也就是我们的图不再是一个拓扑图,但对于不是拓扑图的图论问题,我们无法使用 DP 求解。

而此类看似 DP,实则图论的问题,通常是最小生成树或者最短路问题

当一道题我们决定往「图论」方向思考时,我们的重点应该放在「如何建图」上。

因为解决某个特定的图论问题(最短路/最小生成树/二分图匹配),我们都是使用特定的算法。

由于使用到的算法都有固定模板,因此编码难度很低,而「如何建图」的思维难度则很高

 

T1631(可见记录):相当于求从起点到终点,经过路径中的的「最小权重」最大的值是多少,而权重根据定义,可以写作是两个相邻节点的权重差值的绝对值

对于这种描述,可以想到利用二分法(结合DFS,BFS,通过判断下一个节点能否被添加来遍历,最后判断这个矩形终点是否被遍历到)去解决,也可以利用最短路(我们可以使用任一单源最短路径的算法(例如 Dijkstra 算法,并且可利用优先队列),只需要在维护当前路径长度时,将其修改为题目中的定义即可),也可以利用并查集

对集合进行排序,按照 w进行从小到大排序(Kruskal 部分)

当我们有了所有排好序的候选边集合之后,我们可以对边进行从前往后处理,每次加入一条边之后,使用并查集来查询「起点」和「终点」是否连通(并查集部分)。

当第一次判断「起点」和「终点」联通时,说明我们「最短路径」的所有边都已经应用到并查集上了,而且由于我们的边是按照「从小到大」进行排序,因此最后一条添加的边就是「最短路径」上权重最大的边

 

哈希表:

哈希表可以用于在O(1)时间内进行查询操作,一些情况下可以使用双重哈希来满足我们真正需要查找的数据(T2953,一个哈希表用于统计字母出现的次数,另一个哈希表用于统计字母出现次数的次数,而我们真正需要的判断的就是字母出现次数的情况,不过因为这里实际上只需要出现k的个数,因此可以只用一个变量去维护)

 

数组修改

ICPC2024 成都I

给定一个长度为n的序列,称一个好的划分大小满足:按照给定方式划分的每份序列单调不降,q次修改,每次修改序列中一个位置的值,询问每次修改前后的好的划分大小的数量

对于这种修改是被保留的,考虑一种特征来记录数组,然后每次修改的时候,先减周围和他相关的,修改完毕后再加上

首先考虑如何划分,划分的必要条件显然是要把所有不能构成降序序列的元素都分隔开,这是必须要满足的,因此可以在此基础上在进一步考虑如何去化简

显然,对于a[i]>a[i+1]的位置,划分过程中,a[i]和a[i+1]一定在不同的划分序列中,也就是i可以作为划分的一个断点

因此划分大小是断点i的因数,总的划分方案数就是所有断点的gcd的因数个数

可以利用哈希来进行存储

整个数列中的特殊点是确定的,因此如果c[i]==target,那么就说明i是所有断点的gcd

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> a(n+2);

    a[0]=0,a[n+1]=INT_MAX;

    vector<int> b[n+1];

    vector<int> f(n+1),c(n+1);

    int target=0;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    for(int i=1;i<=n;i++){

        if(a[i]>a[i+1]){

            target++;//特殊点个数

        }

    }

    for(int i=1;i<=n;i++){

        for(int j=i;j<=n;j+=i){

            if(a[j]>a[j+1]) c[i]++;//i作为因数的特殊点个数

            b[j].emplace_back(i);//记录j的因子

        }

        f[c[i]]++;

    }

    cout<<f[target]<<'\n';

    auto modify=[&](int x,int val)->void{

        if(a[x]<=a[x+1]) return;//不需要修改

        target+=val;

        for(int i=0;i<b[x].size();i++){

            int j=b[x][i];

            f[c[j]]--;

            c[j]+=val;

            f[c[j]]++;

        }

    };

    while(q--){

        int idx,x;

        cin>>idx>>x;

        if(idx){

            modify(idx-1,-1);//del

        }

        modify(idx,-1);

        a[idx]=x;

        if(idx>0){

            modify(idx-1,1);//update

        }

        modify(idx,1);

        cout<<f[target]<<'\n';

    }

}

 

 

将树边转换成哈希表时,如果树边的两个节点都是int类型,可以将其转化成ll类型,即

(LL) e[0] << 32 | e[1]) 两个4字节数压缩成一个8字节数

 

出现数组中某一元素出现的次数可以使用unordered_map,初始默认值为1,即不论之前这个数有否进行操作,都可以执行cnt[nums[right]]++;

 

用哈希法解决问题时,一般从三种数据结构中进行选择:set(集合)、map(映射)、数组

我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法,可以用于解决某个数据是否重复出现的问题

不同数据类型作为哈希表的key(T49)字符哈希,数组哈希

数据范围很大时,我们可以用一个哈希表 m记录每个下标对应的元素,另一个哈希表套平衡树 ms记录每个元素对应的下标集合(T2349)

 

STL中的几个数据类型

层实现

否有序

值是否可以重复

否更改数值

询效率

删效率

std::set

红黑树

有序

O(log n)

O(log n)

std::multiset

红黑树

有序

O(logn)

O(logn)

std::unordered_set

哈希表

无序

O(1)

O(1)

所以同样要用到集合时,用unordered_set效率更高

 

层实现

值是否有序

值是否可以重复

值能否更改

询效率

删效率

std::map  

红黑树

有序

不可重复

不可修改

O(logn)

O(logn)

std::multimap

红黑树

有序

可重复

不可修改

O(log n)

O(log n)

std::unordered_map

哈希表

无序

不可重复

不可修改

O(1)

O(1)

  补充:红黑树

红黑树是一种自平衡的二叉查找树,红黑树保证最长路径不超过最短路径的二倍,因而近似平衡(最短路径就是全黑节点,最长路径就是一个红节点一个黑节点,当从根节点到叶子节点的路径上黑色节点相同时,最长路径刚好是最短路径的两倍) 二叉搜索树

 

unordered_set 容器具有以下几个特性:不再以键值对的形式存储数据,而是直接存储数据的值;容器内部存储的各个元素的值都互不相等,且不能被修改;不会对内部存储的数据进行排序,一般只需要传入一个参数,最多需要四个,相当于是用下标来表示键

Key

确定容器存储元素的类型,如果读者将 unordered_set 看做是存储键和值相同的键值对的容器,则此参数则用于确定各个键值对的键和值的类型,因为它们是完全相同的,因此一定是同一数据类型的数据。

Hash = hash<Key>

指定 unordered_set 容器底层存储各个元素时,所使用的哈希函数。需要注意的是,默认哈希函数 hash<Key> 只适用于基本数据类型(包括 string 类型),而不适用于自定义的结构体或者类。

Pred = equal_to<Key>

unordered_set 容器内部不能存储相等的元素,而衡量 2 个元素是否相等的标准,取决于该参数指定的函数。 默认情况下,使用 STL 标准库中提供的 equal_to<key> 规则,该规则仅支持可直接用 == 运算符做比较的数据类型。

创建可变动的字符串unordered_set<char>  函数名称   例:名称为S

删除:s.erase(删除目标)      添加:s.insert()

判断是否存在,存在:s.find() != s.end()

基本的操作相同

若通过哈希函数得到两个数据的下标会相同,可以用线性链表来存储

vis是个unordered_set类型的数据

判断一个数据是否已经存在于该容器中:vis.insert(category).second,insert 会返回一个 pair,其中 first 表示指向插入值的迭代器,second 如果是 false 表示插入值已经在 vis 里面了,为 true 表示不在

Unordered_map

begin()

返回指向容器中第一个键值对的正向迭代器。

end() 

返回指向容器中最后一个键值对之后位置的正向迭代器

find(key)

查找以 key 为键的键值对,如果找到,则返回一个指向该键值对的正向迭代器;反之,则返回一个指向容器中最后一个键值对之后位置的迭代器(如果 end() 方法返回的迭代器)。

insert() 

向容器中添加新键值对。

erase()

删除指定键值对。

hash_function()

返回当前容器使用的哈希函数对象。

unordered_map 容器底层采用的是哈希表存储结构,该结构本身不具有对数据的排序功能,所以此容器内部不会自行对存储的键值对进行排序

创建类似python字典(无序,若是map函数会进行排序)  unordered_map<int,int> 函数名称   例:名称为S

在创建 unordered_map 容器的同时,可以完成初始化操作 即unordered_map<int,int> a{{1,2},{3,4})

赋值:s[i]=j    auto it = hashtable.find(target - nums[i]); return it->second;

用unordered_map会出现超时情况时,可以考虑采用换一种容器,如基于数组,将原先的键以及关键词转化为数组的下标,使得在查找的过程中可以不用遍历,其查找及插入的时间复杂度都为O(1)

Unordered_map中使用count实际上就是判断某个值是否存在于key中 如mp.count(rains[i])

或mp.find(rains[i])!=mp.end()

添加键值对 x.emplace(s,1)

遍历map  for (auto [na,ps]:x) 或for(pair<int,int> kv:map) 或

for(auto kv:map){

 cout<<kv.first<<kv.second<<endl;

}

for(auto& kv:map){

cout<<kv.first<<kv.second<<endl;

 }

for(auto it=map.begin();it!=map.end();it++){

 cout<<it->first<<it->second<<endl;

}

对map的遍历只能用迭代器不能用下标

使用auto循环时候,修改的值(利用迭代器的second进行修改)作用域仅仅循环之内,出去循环还会变成未修改的数值

 

std::unordered_map底层是hash,

而unordered_map没有专门的hash提供给std::pair,

因此如果要用std::pair作为key的话需要传入一个hash结构

struct pair_hash{

    template <class T1, class T2>

    size_t operator() (const pair<T1, T2> &pair) const{

        return hash<T1>()(pair.first) ^ hash<T2>()(pair.second);

    }

};

unordered_map<pair<int,int>, int, pair_hash> up;

 

利用map去实现值域上数据的统计(P4447)

相关的一些操作:

auto it=m.begin()

--(*i).second

j!=m.end();(*j).first==(*i).first

while(i!=m.end() && (*i).second==0){

m.erase((*i++).first)

}

计算满足条件的数据段的长度

it i=m.begin(),j=m.begin();

                  --(*i).second;

                  int t=1;

                  for(++j;j!=m.end()&&(*j).first==(*i).first+1&&(*j).second>(*i).second;++i,++j){

                       ++t;

                          --(*j).second;

                  }

 

 

Unordered_map无法直接排序,因此要借助sort函数,但需要将中的数值先放到放入vector中,再对vector进行排序,然后对vector进行输出,从而间接实现了对map的排序

vector<pair<char, int>> result(up.begin(), up.end());

sort(result.begin(), result.end(), cmp);

 

输出unordered_map中键值最大的数

bool cmp_value(const pair<int, int> left,const pair<int,int> right) {

return left.second < right.second;

 }

auto i= max_element(test.begin(),test.end(),cmp_value);

cout << i->first << i->second << endl;

 

或者对于一般的大于小于定义法则可以直接

auto m=min_element(cnt.begin(),cnt.end());

int i=m->second;

不过这样排序得到的是Key中最小的元素,所以如果要对键值进行排序,仍就需要重设<

可以合并写作

int k = min_element(cnt.begin(), cnt.end(), [](const auto& a, const auto& b) {

            return a.second < b.second;

})->second;

 

当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset

 

c++可以用==来判断两个map是否相同

 

判断数组中是否有几个数的和相等可以用哈希表,将各个数遍历一遍存储结果,下次判断数据是否出现时就可以以O(1)的效率查找

 

杭电24多校3 1003

每个单位时间,其位置+1或者-1

初始的时候是1

按照时间维护位置

还要讨论一下如果开头有若干个1,两个1中间距离是奇数是可能可以的,l那个时刻还没有开始

已知的信息p!=1就可以判断酒鬼移动了,首先要检验这些信息的奇偶性一致,同时其数值差和时间差满足条件,可以通过set(map)维护pair支持插入

插入时用emplace,同时利用f.emplace(q,p).first来返回插入位置的迭代器,prev返回上一个位置的迭代器,next返回下一个位置

 

可以用哈希来判断排列是否相同

cf1996C
统计子串中各字母的出现次数,以此计算将一个字符串变为与另一个相同的最小操作次数

void solve(){

    int n,q;

    cin>>n>>q;

    string a,b;

    cin>>a>>b;

    vector<array<int,26>> pre(n+1);

    for(int i=0;i<n;i++){

        pre[i+1]=pre[i];

        pre[i+1][a[i]-'a']++;

        pre[i+1][b[i]-'a']--;

    }

    for(int i=1;i<=q;i++){

        int l,r;

        cin>>l>>r;

        l--;

        int ans=0;

        for(int c=0;c<26;c++){

            ans+=max(0,pre[r][c]-pre[l][c]);

        }

        cout<<ans<<'\n';

    }

}

 

cf2021

动态维护数组b中每个元素第一个出现所构成的数组,并判断这个数组是否和a数组相同

void solve() {

    int n,m,q;

    cin>>n>>m>>q;

    //如何动态维护第一次出现元素的数组

    vector<int> a(n),b(m);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]--;

    }

    for(int i=0;i<m;i++){

        cin>>b[i];

        b[i]--;

    }

    //数组相同可以转化为第一个数相同然后相邻数的顺序相同

    vector<int> inva(n);

    for(int i=0;i<n;i++){

        inva[a[i]]=i;//数组元素的对应下标

    }

    int bad=0;

    vector<set<int>> pos(n);

    map<int,int> f;

    //f用于存储第一次出现元素的数组下标和对应元素值的映射

    //同时因为map有序,保证使用prev和next时的正确性

    auto insert=[&](int i){

        auto [it,suc]=f.emplace(i,b[i]);

        //第一个元素 it 是一个迭代器,指向插入的元素或没有插入时已存在具有相同键的元素

        //第二个元素 suc 是一个 bool 类型的值,指示是否插入成功

        if(!suc){//已经存在

            return;

        }

        auto r=next(it);

        if(r!=f.end()){

            bad+=(inva[r->second]!=inva[b[i]]+1);

        }

        if(it!=f.begin()){

            auto l=prev(it);

            bad+=(inva[b[i]]!=inva[l->second]+1);

            if(r!=f.end()){

                bad-=(inva[r->second]!=inva[l->second]+1);

            }

        }

        //只要有一组相邻的不同就不满足条件了

    };

 

    auto erase=[&](int i){

        auto it=f.find(i);

        if(it==f.end()){//不存在该位置

            return;

        }

        auto r=next(it);

        if(r!=f.end()){

            bad-=(inva[r->second]!=inva[b[i]]+1);

        }

        if(it!=f.begin()){

            auto l=prev(it);

            bad-=(inva[b[i]]!=inva[l->second]+1);

            if(r!=f.end()){

                bad+=(inva[r->second]!=inva[l->second]+1);

            }

        }

        f.erase(it);

    };

    for(int i=0;i<m;i++){

        pos[b[i]].insert(i);

    }

    for(int i=0;i<n;i++){

        if(!pos[i].empty()){

            insert(*pos[i].begin());

        }

    }

    auto answer=[&](){

        if(bad==0 && f.begin()->second==a[0]){

            cout<<"YA\n";

        }else{

            cout<<"TIDAK\n";

        }

    };

    answer();

    for(int i=0;i<q;i++){

        int s,t;

        cin>>s>>t;

        s--;

        t--;

        erase(s);

        pos[b[s]].erase(s);

        if(!pos[b[s]].empty()){

            insert(*pos[b[s]].begin());

        }

        //例如删除的不是第一次出现的位置,那么元素第一次出现的位置不会改变,同时在f中不会出现该位置

        b[s]=t;

        if(!pos[t].empty()){

            erase(*pos[t].begin());

        }

        pos[t].insert(s);

        insert(*pos[t].begin());

        answer();

    }

}

 

 

康拓展开:
康托展开是一个全排列到一个自然数的双射,常用于构建哈希表时的空间压缩。 

康托展开的实质是计算当前排列在所有由小到大全排列中的顺序,因此是可逆的。

康托展开运算: 
其中 为整数,并且 。
表示原数的第i位在当前未出现的元素中是排在第几个
举个例子说明:
在(1,2,3,4,5) 5个数的排列组合中,计算 34152的康托展开值。
首位是 3 ,小于 3的数有两个,因此 ,则首位小于 3的所有排列组合为 
第二位是 4,则小于 4 的数有两个,为 1和 2,注意这里 3并不能算,因为 3已经在第一位,已经出现过了,所以其实计算的是在第二位之后小于4的个数。因此 。
第三位是 1,则在其之后小于 1 的数有 0 个,所以  。
第四位是 5,则在其之后小于 5的数有 1个,为 2,所以 。
最后一位就不用计算啦,因为在它之后已经没有数了,所以   固定为 0
根据公式:

所以34152的康拓展值是61

P1379

long long hash1(string s)//康托展开的函数

{

    int f[9]={0,1,2,6,24,120,720,5040,40320}; //先将每一个阶乘的值存下来,方便直接运算

    int used[9]={0}; //标记数组

    long long ans=0,x=8;

    for(int i=0;i<s.length();i++)

    {

        int num=0;   //num存储小于第i个数的数的数目

        used[s[i]-'0']=1;

        for(int j=0;j<s[i]-'0';j++) //遍历s[i]之前的数,如果这个数没出现过说明在这个数位之后

            if(!used[j])  num++;

        ans+=num*f[x--];  //康托展开的公式

    }

    return ans;  //返回康托展开的值

}

 

异或哈希:

cf1996G
利用哈希标记不同状态的点,为了保证不同状态有对应的哈希值以及当一个点对被判断完毕时可以被消除,用异或哈希

所有原有的边构成一个环,因此两个点相连只有两种情况,一种是直接相连,另一种是前一种情况的补集
异或实际上是用于计算每一点对以直接相连的方式的补的情况需要的边数
总和减最大的补就是最小的集合
同时除了某一点对之外,以反向连接,必然能满足剩下点对的情况
0是用于计算补所有点对都直接连接的补的情况
例如:1......2..2......1
f(1)计算的是2和2连,1和1反向连的情况

mt19937_64 rng {std::chrono::steady_clock::now().time_since_epoch().count()};

//生成64位随机数

void solve(){

    //目标节点之间必须要有一条路径,每次连边只能连序号相邻的节点

    //求最小并集,用全集减去最大补集的大小

    //异或哈希,求最大留白区域(合法方案的补)

    int n,m;

    cin>>n>>m;

    vector<u64> f(n);

    for(int i=0;i<m;i++){

        int a,b;

        cin>>a>>b;

        a--,b--;

        u64 x=rng();

        f[a]^=x;

        f[b]^=x;

    }

    for(int i=1;i<n;i++){

        f[i]^=f[i-1];

    }

    map<u64,int> cnt;

    int ans=0;

    for(int i=0;i<n;i++){

        ans=max(ans,++cnt[f[i]]);

    }

    cout<<n-ans<<'\n';

}

 

25 杭电 春 10 1009

给定一个数组a,要求在这些数字中找出最长的连续子序列,使得这些数字的乘积是一个完全平方数

一个数是平方数当且仅当其可以拆成质因数偶次幂的乘积

因此一个数中原先就是偶次幂的那些质因子显然是不重要的,因此我们只需要观察那些平方因子

可以类似于求元素段为0的方式,利用前缀和加上哈希表的形式来判断一个前缀有没有出现过,如果出现过,就说明这一段元素的乘积是一个完全平方数

因为每个质因子只有出现或者不出现两种情况(为奇数或者偶数),因此可以使用异或来实现,从而考虑使用异或哈希来快速计算前缀状态

http://acm.hdu.edu.cn/contest/view-code?cid=1159&rid=7361

 

树哈希:
一种常见并且正确率比较真的做法是对括号序列哈希

如果只是判断两个子树是否相等,也可以用AHU算法:用map给子树编号,把叶子编号为1,然后对于非叶子点,把儿子的编号排个序扔进一个vector,然后把vector扔进map里得到一个新的编号(编号仅由形态决定),复杂度O(nlogn)

因为AHU算法是确定性的,因此不会被卡

cf1800G

对子树进行树哈希,然后用异或哈希判断是否有子树全部都是出现偶数次,最多只能有一个子树出现奇数次,如果有就判断一下这个子树是否也是对称的即可

void solve(){

    int n;

    cin>>n;

    vector adj(n,vector<int>());

    for(int i=0;i<n-1;i++){

        int u,v;

        cin>>u>>v;

        u--,v--;

        adj[u].emplace_back(v);

        adj[v].emplace_back(u);

    }

    map<vector<int>,int> res;

    vector<int> ha(n);

    vector<int> sym(n);

    int id=0;

    auto dfs=[&](auto self,int u,int fa)->void{

        vector<int> tmp;

        map<int,int> cnt;

        for(int v:adj[u]){

            if(v==fa) continue;

            self(self,v,u);

            tmp.emplace_back(ha[v]);

        }

        sort(tmp.begin(),tmp.end());

        if(!res[tmp]){

            res[tmp]=++id;

        }

        ha[u]=res[tmp];

        for(int v:adj[u]){

            if(v==fa) continue;

            ++cnt[ha[v]];

        }

        int ok=0,k;

        for(auto &[h,c]:cnt){

            if(c&1){

                ++ok;

                k=h;

            }

        }

        if(!ok){

            sym[u]=1;

        }else if(ok==1){

            for(int v:adj[u]){

                if(v!=fa && ha[v]==k){

                    sym[u]|=sym[v];

                }

            }

        }

    };

    dfs(dfs,0,-1);

    // for(int i=0;i<n;i++){

    //     cout<<i<<" "<<ha[i]<<" "<<sym[i]<<'\n';

    // }

    cout<<(sym[0]?"YES\n":"NO\n");

}

 

 

 

字符串哈希:
字符串变成一个数字

对于01串,可以直接将其视作一个二进制数(将这种对应视作是一个哈希函数)进行存储,而对于数字串,则一般直接将其视作是一个十进制数

那么依次类推,我们也可以将一个字符串看作是某种进制,并且在进行哈希映射时可以参考二进制转化的方式

抽象的说,就是将每一个字符对应一个数字,然后把它们按照顺序乘以进制(Base)的幂进行相加,再取余

取余的方式可以是自然溢出,即mod=unsigned long long,此时哈希公式即为hash[i]=hash[i−1]∗Base+idx(s[i])

可以是单哈希,一般mod>base,且两者都是素数,此时哈希公式为

hash[i]=(hash[i−1]∗Base+idx(s[i]))%MOD(例如base=13,mod=101)

进一步的,可以使用双哈希,将一个字符串用不同的Base和mod,hash两次,将这两个结果用一个二元组表示,作为一个总的Hash结果,一般mod1可取1e9+7,mod2可取1e9+9

 

但以上的方法都是单次计算一个字符串的哈希值,在这种方式下,如果要多次询问子串的哈希,每次计算会使得效率十分低下,而想到子串,自然考虑到去用类似一种前缀和的思路去解决,因此定义hash[i]表示字符串中[1..i]的子串的哈希值

于是对于子串[l...r],可以表示为:res=hash[r]−hash[l−1]∗Baser−l+1

但因为有取模的操作,因此进行适当的更改

res=((hash[r]−hash[l−1]∗Baser−l+1)%MOD+MOD)%MOD

因为会反复对子串进行求解,因此可以去预处理Base的n次方,p[i]=(Basei)%MOD

 

这样,确定字符串中不同字符串的数量,可以采用预处理哈希值,再遍历所有可能长度判断完成

int count_unique_substrings(string const& s) {

    int n = s.size();  

    const int b = 31;

    const int m = 1e9 + 9;

    vector<long long> b_pow(n);

    b_pow[0] = 1;

     for (int i = 1; i < n; i++) b_pow[i] = (b_pow[i - 1] * b) % m;

 

    vector<long long> h(n + 1, 0);

    for (int i = 0; i < n; i++) h[i + 1] = (h[i] + (s[i] - 'a' + 1) * b_pow[i]) % m;

 

    int cnt = 0;

    for (int l = 1; l <= n; l++) {

        set<long long> hs;

        for (int i = 0; i <= n - l; i++) {

            long long cur_h = (h[i + l] + m - h[i]) % m;

            cur_h = (cur_h * b_pow[n - i - 1]) % m;

            hs.insert(cur_h);

        }

        cnt += hs.size();

    }

   

    return cnt;

}

 

 

牛客2024寒假训练营3C

在两个字符串中找有特定要求的前后缀

//字符串哈希模版,N决定了是单哈希还是双哈希

template<int N>

struct stringhash{

    const int base[2]={13331,131};

    const int mod[2]={int(1e9+7),int(1e9+9)};

    vector<array<int,N>> p,h;

    stringhash()=default;

    stringhash(const string& s){

        int n=s.size()-1;

        p.resize(n+1),h.resize(n+1);//根据字符串大小重新设定base各幂次的预处理数组,字符哈希前缀数组

        fill(p[0].begin(),p[0].end(),1);

        for(int i=1;i<=n;i++){

            for(int j=0;j<N;j++){

                p[i][j]=1ll*p[i-1][j]*base[j]%mod[j];

            }

        }

        for(int i=1;i<=n;i++){

            for(int j=0;j<N;j++){

                h[i][j]=(1ll*h[i-1][j]*base[j]+s[i])%mod[j];

            }

        }

    }

 

    array<int,N> query(int l,int r){//查询子串哈希值

        assert(r>=l-1);

        array<int,N> ans;

        if(l>r) return {0,0};

        for(int i=0;i<N;i++){

            ans[i]=(h[r][i] - 1ll * h[l - 1][i] * p[r - l + 1][i] % mod[i] + mod[i]) % mod[i];

        }

        return ans;

    }

};

 

void solve(){

    int n,m;

    cin>>n>>m;

    string s,t;

    cin>>s>>t;

    string rs=s,rt=t;

    s=' '+s,t=' '+t;

    reverse(rs.begin(),rs.end()),reverse(rt.begin(),rt.end());

    rs=' '+rs,rt=' '+rt;

    stringhash<2> hs(s), hrs(rs), ht(t), hrt(rt);

    vector<int> f(n + 2), g(m + 2);

    for (int i = 1; i <= min(n, m); i++) {

        auto a = hs.query(1, i);

        auto b = hrs.query(n - i + 1, n);

        auto c = hrt.query(1, i);

        //a==b,判断是否是回文串,b==c,判断是否s前缀等于t的后缀

        if (a == b and b == c) f[i] = i;//哈希值相同表明是同一子串

        f[i] = max(f[i], f[i - 1]);//记录当前位置为止最大的合法前缀长度

    }

    for (int i = 1; i <= min(n, m); i++) {

        auto a = ht.query(1, i);

        auto b = hrs.query(1, i);

        auto c = hrt.query(m - i + 1, m);

        if (a == c and a == b) g[i] = i;

        g[i] = max(g[i], g[i - 1]);

    }

    int ans = -1;

    //要保证不相交的非空前缀和后缀,因此以较短的字符串为基准去查找

    if (n <= m) {

        for (int i = 1; i <= n; i++) {

            if (f[i] && g[n - i]) {

                ans = max(ans, 2 * (f[i] + g[n - i]));

            }

        }

    } else {

        for (int i = 1; i <= m; i++) {

            if (g[i] && f[m - i]) {

                ans = max(ans, 2 * (g[i] + f[m - i]));

            }

        }

    }

    cout << ans << '\n';

}

 

哈希链表:
通过哈希函数得到的两个数据的下标会相同时,可以考虑用线性链表去解决这个问题

首先先确定基础的哈希函数,可以直接选择取模法

P1379

struct node

{

         int key, step;

         bool head;

         Node *next;

         Node(){key = step = head = 0;next = NULL;}

    //构造函数初始化

}hash[mod1 + 2];//这里我的mod1取了1e5+7

各变量的含义:
key:关键值   step:步数   head:这个节点属于从目标来还是从初始来

*next:指向下一个节点的指针,用于构建链表

hash数组:以哈希值为下标去访问相应节点

hash数组是以哈希值为下标,而key值可以说是当不同值的hash值相同时用于存储其真实值

如:

if(hash[H].key != 0 && hash[H].key != c)//如果key == c说明这是同一个值 不需要插入

{

         node *newnode=new Node;

    newnode->key=c;

    ......;

    hash[H].next=newnode;

}

next 表示该节点的下一个链接节点

因为初始值赋为了 NULL 所以不能直接操作next 指针

需要附设一个newnode 指针 并用 new 动态分配空间

同时,如果这个节点已经连接了,那么就从第一个遍历到最后一个再插入即可

node *now = &hash[H];

while(now->next)//等价于 now->next != NULL

         now=now->next;

 

哈希表:
随机概率异或哈希(cf1977d)

Zobrist hashing

当我们确定 (i,j) 是 1 而第 j 列的其它位置是 0 时,我们就可以确定翻转的哪些行

即对于这种情况下的整个表格以及我们进行操作的行都是唯一确定的

因为一一对应性,我们可以将操作状态与通过该操作所得到的表格对应起来

利用01序列(即状态压缩为一个整数)记录我们的操作不可行,因此用每一行映射到一个随机数上的方式去表示我们操作的状态

并且保留一个计数器用于记录通过该状态对应的操作后得到的表格中的特殊列(即答案所关注的值),特殊列越多,其值越大;这可以通过枚举(i,j)实现,确定(i,j)是1而第j列的其他位置是0后,将对应的操作状态所对应的计数器的值+1

并且,如果得到了答案矩阵所对应的其中一个特殊列的元素值1所在的位置,可以直接输出操作状态

mt19937_64 rnd(chrono::steady_clock::now().time_since_epoch().count());

void solve() {

    int n, m;

    cin >> n >> m;

    vector<vector<bool>> table(n, vector<bool>(m));

    for (int i = 0; i < n; ++i) {//原始表

        for (int j = 0; j < m; ++j) {

            char c;

            cin >> c;

            table[i][j] = c - '0';

        }

    }

    vector<long long> rands(n), rands2(n);

    for (int i = 0; i < n; ++i) {//是对行操作,因此将每行映射到一个随机数上

        rands[i] = rnd();

        rands2[i] = rnd();

    }

    //是否进行操作就是01态,对应异或操作的性质

    map<pair<ll,ll>, int> ans;//计数器,执行对应操作之后的特殊列的个数

    int res = 0;

    pair<int, int> ind_ans = {0, 0};

    for (int j = 0; j < m; ++j) {

        ll summ = 0, summ2 = 0;

        for (int i = 0; i < n; ++i) {

            if (table[i][j]) {//先将所有行都变成0

                summ ^= rands[i];

                summ2 ^= rands2[i];

            }

        }

        for (int i = 0; i < n; ++i) {//枚举为1的行

            //将这行的元素变为1,即进行操作,异或上对应的值

            summ ^= rands[i];

            summ2 ^= rands2[i];

            ans[{summ, summ2}]++;//操作状态对应的答案+1

            if (res < ans[{summ, summ2}]) {

                res = ans[{summ, summ2}];

                ind_ans = {j, i};//更新答案

            }

            summ ^= rands[i];//复原为全为0的状态

            summ2 ^= rands2[i];

        }

    }

    cout << res << "\n";

    string inds(n, '0');

    for (int i = 0; i < n; ++i) {

        if (table[i][ind_ans.first]) {//原来是1,那么要变成0,即进行操作

            if (i != ind_ans.second) {

                inds[i] = '1';

            }

        } else if (ind_ans.second == i) {//目标(i,j)不是1,要变为1

            inds[i] = '1';

        }

    }

    cout << inds << "\n";

}

 

 

Set

erase(iterator)  ,删除定位器iterator指向的值

erase(first,second),删除定位器first和second之间的值

erase(key_value),删除键值key_value的值

lower_bound(key_value) ,返回第一个大于等于key_value的定位器

upper_bound(key_value),返回最后一个大于等于key_value的定位器

count() 用来查找set中某个某个键值出现的次数。这个函数在set并不是很实用,因为一个键值在set只可能出现0或1次,这样就变成了判断某一键值是否在set出现过了

添加元素,emplace,insert都可

set的迭代器只能用*it 不能用*(it+1)的操作

 

数组就是简单的哈希表,但是数组的大小是受限的,所以当目标值有限时(如均为英文字母,检查字符串a和字符串b能否相互组成),可以使用数组,因为使用map的空间消耗要比数组大一些,map要维护红黑树或者符号表,而且还要做哈希函数的运算,所以数组更加简单直接有效

当没有限制数值的大小时,就无法使用数组来做哈希表,这时候可以选择用set作为哈希表,因为数组的大小是有限的,受到系统栈空间(不是数据结构的栈)的限制,且如果数组空间够大,但哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费,所以此时一样的做映射的话,就可以使用set了,set和multiset的底层实现都是红黑树,而umordered_set的底层实现是哈希表,因此不需要对数据排序,并且数据不重复的情况下用unordered_set的读写效率最高

当不仅需要数据的数值,同时还需要数据的下标时,用set或数组就无法实现高效的存储了,因为同时要输出两个值,这个时候就可以使用map了,将数值作为键,将下标作为值,同理,std::map 和std::multimap 的key也是有序的,当题中并不需要key有序时,选择std::unordered_map 效率更高

set的遍历可以利用迭代器

int main()

{

    //生成待处理的数据

    for(int i=0;i<100;i++)

        all.insert(i);

    //遍历set,用迭代器类型

    for(set<int>::iterator i=all.begin();i!=all.end();i++)

        cout<<*i<<endl; //注意指针运算符

    return 0;

}

 

平衡树(set):即要存储一个数映射多个数并且我们时常要得到映射数中的最小值和最大值时,我们可以用set来进行存储,效率较高,即unordered_map<int,set<int>>(T2349)

 

传球问题,set用于存储当前的可能性(cf1941D)

关于set的实现,可以直接使用两个set,并标记为0和1,那么每次要么是从0到1,要么是1到0,因此直接异或上1即可

set <int> q[2];

int ix = 0;

q[ix].insert(a);

while (m--) {

    int x; char ch; cin >> x >> ch;

    while (!q[ix].empty()) {

        int u = *(q[ix].begin());

        q[ix].erase(u);

        if (ch == '?' || ch == '0') {

            q[ix ^ 1].insert((u + x - 1) % n + 1);

        }

        if (ch == '?' || ch == '1') {

            q[ix ^ 1].insert((u - x - 1 + n) % n + 1);

        }

    }

    ix ^= 1;

}

 

 

 

rope

引入

#include <ext/rope>

using namespace __gnu_cxx;

STL中的rope也可以起到块状链表的作用,采用可持久化平衡树实现,可完成随机访问和插入、删除元素的操作,持久化保证了不会因为大量重复性子串的生成伴随的效能退化

基本操作

rope <int > a                              初始化 rope(与 vector 等容器很相似)

a.push_back(x)                         在 a 的末尾添加元素 x

a.insert(pos, x)                        在 a 的 pos 个位置添加元素 x

a.erase(pos, x)                           在 a 的 pos 个位置删除 x 个元素

a.at(x) 或 a[x]                           访问 a 的第 x 个元素

a.length() 或 a.size()               获取 a 的大小

 

 

堆:

因为堆的底层实现容器是vector,因此back(),emplace_back(),pop_back(),下标调用等都是可以使用的

对于一个操作完之后一直需要返回当前最大值的数组,可以考虑建堆(大根堆),而不是每次操作完后都进行排序,这样效率太低,建立大根堆可以利用之前排序的结果(T2558)

make_heap()将区间内的元素转化为heap,可用于原地建堆

默认是大顶堆,第一个参数是指向开始元素的迭代器,第二个参数是指向最末尾元素的迭代器,第三个参数是less<>()或是greater<>(),前者用于生成大顶堆,后者用于生成小顶堆,第三个参数默认情况下为less<>(),less<int>()用于生成大顶堆

要使用less<int>(),以及greater<int>(),请添加头文件#include <functional>,且一定要加括号less<>(),如make_heap(q.begin(), q.end(), less<int>());

 

push_heap()对heap增加一个元素.

push_heap()用于把数据插入到堆中,但实际上相当于是将堆的大小扩大一位,因此应该提前将要加入的元素通过push_back()传入,而不是在push_heap()后再使用q.push_back(t)

 

pop_heap()对heap取出下一个元素.

pop_heap()用于将堆的第零个元素与最后一个元素交换位置,然后针对前n - 1个元素调用make_heap()函数(相当于最后一个元素不存在于堆中了),它也有三个参数,参数意义与make_heap()相同,第三个参数应与make_heap时的第三个参数保持一致

pop_heap()函数,只是交换了两个数据的位置,如果需要弹出这个数据,请记得在pop_heap()后加上q.pop_back();

 

sort_heap()对heap转化为一个已排序群集

sort_heap()是将堆进行排序,排序后,序列将失去堆的特性(子节点的键值总是小于或大于它的父节点)(堆排序排完后的堆和大顶堆、小顶堆不是一个概念),它也具有三个参数,参数意义与make_heap()相同,第三个参数应与make_heap时的第三个参数保持一致。大顶堆sort_heap()后是一个递增序列,小顶堆是一个递减序列,相当于进行了一次数据类型的变化

 

每个节点的键值都大于等于其父亲键值的堆叫做小根堆,否则叫做大根堆。STL 中的 priority_queue 其实就是一个大根堆

不加限定提到「堆」时往往都指二叉堆

 

2024哈尔滨J

找出汽车可以行进的最大距离

显然,一定是后续可以被加油的优先被使用,但因为没有被加油时一个油箱不能被多次使用,因此我们要维护当前序列中所有油箱第一次出现的数,同时需要支持不断删去前缀的数,并插入一个数,这个操作可以用堆来维护

void solve(){

    int n,m;

    cin>>n>>m;

    vector<int> a(n),b(n);

    vector<pair<int,int>> k(m);

    for(int i=0;i<n;i++){

        cin>>a[i];

        b[i]=a[i];

    }

    for(int i=0;i<m;i++){

        cin>>k[i].first>>k[i].second;

        k[i].second--;

    }

    sort(k.begin(),k.end());

    vector<int> nxt(m),mp(n,m+1);

    for(int i=m-1;i>=0;i--){

        nxt[i]=mp[k[i].second];

        mp[k[i].second]=i;

    }

    priority_queue<pair<int,int>,vector<pair<int,int>>,greater<pair<int,int>>> q;

    for(int i=0;i<n;i++){

        q.push({mp[i],i});

    }

    i64 ans=0ll,cnt=0;

    while(!q.empty()){

        auto [pos,id]=q.top();

        q.pop();

        i64 dis=k[cnt].first-ans;

        if(cnt<m && a[id]>=dis){

            a[id]-=dis;

            ans+=dis;

            a[k[cnt].second]=b[k[cnt].second];

            q.push({nxt[cnt],k[cnt].second});

            if(id!=k[cnt].second){

                q.push({pos,id});

            }

            cnt++;

        }else{

            ans+=a[id];

            a[id]=0;

        }

    }

    cout<<ans<<'\n';

}

 

 

二叉堆:

向上调整(插入操作):如果这个结点的权值大于它父亲的权值,就交换,重复此过程直到不满足或者到根

向下调整(删除操作):在该结点的儿子中,找一个最大的,与该结点交换,重复此过程直到底层

增加某个点的权值:直接修改后,向上调整一次即可

考虑使用一个序列h来表示堆。  hi的两个儿子分别是h2i和h2i+1 ,下标为1的是根结点

例:

void up(int x) {

  while (x > 1 && h[x] > h[x / 2]) {

    swap(h[x], h[x / 2]);

    x /= 2;

  }

}

 

void down(int x) {

  while (x * 2 <= n) {

    t = x * 2;

    if (t + 1 <= n && h[t + 1] > h[t]) t++;

    if (h[t] <= h[x]) break;

    std::swap(h[x], h[t]);

    x = t;

  }

}

 

建堆

方法一:使用 decreasekey(即,向上调整)

从根开始,按 BFS 序进行

void build_heap_1() { 

for (i = 1; i <= n; i++) up(i);

}

方法二:使用向下调整

这时换一种思路,从叶子开始,逐个向下调整

void build_heap_2() { 

for (i = n; i >= 1; i--) down(i);

}

换一种理解方法,每次「合并」两个已经调整好的堆,这说明了正确性。

注意到向下调整的复杂度,为  ,另外注意到叶节点无需调整,因此可从序列约n/2的位置开始调整,可减少部分常数但不影响复杂度

 

对顶堆

可用于解决类似于动态维护一个序列上第k大的数,且对应第k大的元素可能会发生变化的问题,最简单的情况即用来求中位数

对顶堆由一个大根堆与一个小根堆组成,小根堆维护大值即前k大的值(包含第 k 个),大根堆维护小值即比第 k大数小的其他数
维护:当小根堆的大小小于k时,不断将大根堆堆顶元素取出并插入小根堆,直到小根堆的大小等于k;当小根堆的大小大于k时,不断将小根堆堆顶元素取出并插入大根堆,直到小根堆的大小等于k ;

·插入元素:若插入的元素大于等于小根堆堆顶元素,则将其插入小根堆,否则将其插入大根堆,然后维护对顶堆;

·查询第k大元素:小根堆堆顶元素即为所求;

·删除第k大元素:删除小根堆堆顶元素,然后维护对顶堆;

· k值+1/-1  :根据新的k 值直接维护对顶堆

 

配对堆

配对堆是一棵满足堆性质的带权多叉树,即每个节点的权值都小于或等于他的所有儿子

结构体定义:

struct Node {

  T v;  // T为权值类型

  Node *child, *sibling;

  // child 指向该节点第一个儿子,sibling 指向该节点的下一个兄弟。

  // 若该节点没有儿子/下个兄弟则指针指向 nullptr。

};

一个节点的儿子链表是按插入时间排序的,即最右边的节点最早成为父节点的儿子,最左边的节点最近成为父节点的儿子

合并:

Node* meld(Node* x, Node* y) {

  // 若有一个为空则直接返回另一个

  if (x == nullptr) return y;

  if (y == nullptr) return x;

  if (x->v > y->v) std::swap(x, y);  // swap后x为权值小的堆,y为权值大的堆

  // 将y设为x的儿子

  y->sibling = x->child;

  x->child = y;

  return x;  // 新的根节点为 x

}

删除最小值:

根节点即为最小值,所以要删除的是根节点

Node* merges(Node* x) {

  if (x == nullptr || x->sibling == nullptr)

    return x;  // 如果该树为空或他没有下一个兄弟,就不需要合并了,return。

  Node* y = x->sibling;                // y 为 x 的下一个兄弟

  Node* c = y->sibling;                // c 是再下一个兄弟

  x->sibling = y->sibling = nullptr;   // 拆散

  return meld(merges(c), meld(x, y));  // 核心部分

}

该递归函数的实现已保证了从右往左合并的顺序

 

对于数据不可能完全存储情况下查找第k大的问题

acwing 146

先考虑N=2的这种特殊情况

我们发现,当A序列和B序列从小到大排序后,最小和肯定是A[1]+B[1],而次小和就是min(A[2]+B[1],A[1]+B[2]),也就是说当我们确定好A[i]+B[j]为K小和的话,那么第k+1小的和,就可能存在于A[i+1]+B[j],A[i]+B[j+1],这相当于用了两个指针分别指向A[i]和B[j],把其中一个指针向后移动一位,就可能产生下一个和,还要注意一点,由A[1]+B[2]和A[2]+B[1]都由可能得到A[2]+B[2],因此规定,如果把j+1产生新的备选答案,那么以后只能再增加j,不能再增加i,这样才能保证备选答案中的唯一性

这样我们可以建立一个小根堆,堆中每个节点存储一个三元组(i,j,last),其中last表示上一次移动的指针是不是j,堆的比较操作以a[i]+b[j]作为节点权值

每次操作时,取出堆顶(i,j,last),然后把(i,j+1,true)插入堆,如果last为false,再把(i+1,false)也插入堆,重复以上操作n次就能得到第n大的数值,时间复杂度为O(NlogN)

利用归纳法,我们可以先求出前2个序列的任取一个数相加构成的前n小和,把这n个和作为一个新的序列,再去和第3个序列进行求前n小和的操作,依次类推,最终能得到从m个序列中任取一个数相加的前n小和,时间复杂度为O(MNlogN)

 

利用上述思路的代码(两个指针+结构体)

struct node{

    int x,y;

    bool ok;

};

bool operator < (node as,node bs){

    return a[k][as.y]+now[as.x]>a[k][bs.y]+now[bs.x];//权值比较

}

priority_queue<node>p,kong;

inline void init(){

    cin>>m>>n;

    for(int i=1; i<=m; i++){

        for(int j=1; j<=n; j++)

            cin>>a[i][j];

        sort(a[i]+1,a[i]+1+n);

        if (i==1)

            for(int j=1; j<=n; j++)

                now[j]=a[i][j];//设为第一个序列

    }

}

int main(){

    ios::sync_with_stdio(false);

    cin>>t;

    while(t--){

        init();

        i=1,j=1;

        for (k=2; k<=m; k++)//从第二个开始

        {

            p=kong;

            p.push(node {1,1,true});

            for (int km=1; km<=n; km++){

                node pd=p.top();

                p.pop();//记得弹出堆顶

                ans[km]=now[pd.x]+a[k][pd.y];

                i=pd.x;

                j=pd.y;

                p.push(node {i,j+1,false});//第一种备选方案

                if (pd.ok==true)

                    p.push(node {i+1,j,true});//第二种备选方案

            }

            for (int km=1; km<=n; km++)

                now[km]=ans[km];

        }

        for (i=1; i<=n; i++)

            cout<<now[i]<<" ";

        cout<<endl;

    }

    return 0;

}

 

 

具体写法:可以直接用一个i指针指向a数组(即pair的second),将b数组直接全部放到堆中然后取出,更新堆中数据,这样用一个pair即可

// a和b是每次需要合并的序列, c是中间临时缓存的序列

int a[N], b[N], c[N];

void work() {

    // 默认是大根堆, 这样是小根堆

    priority_queue<PII, vector<PII>, greater<PII>> heap;

    for (int i = 0; i < n; i++) {     // 小根堆, 堆顶就是最小值

        heap.push({a[0] + b[i], 0});  // push进去的是一个pair

    }//最小的显然是a[0]去搭配

    for (int i = 0; i < n; i++){//操作n次得到前n小的和

        auto t = heap.top();

        heap.pop();

        int s = t.first, p = t.second;//p指针标记a数组

        c[i] = s;

        heap.push({s - a[p] + a[p + 1], p + 1});//因为开始时将b全部放入了,因此只移动a即可

    }

    // memcpy最后一个参数是字节, 一个int包含4个字节

    memcpy(a, c, 4 * n);  //拷贝数组

    /*等价于

    for (int i = 0; i < n; i++) {

        a[i] = c[i];

    }

    */

}

cin >> m >> n;

for (int i = 0; i < n; i++) {

     cin >> a[i];  //先读入一行

}

sort(a, a + n);

for (int i = 1; i < m; i++) {

for (int j = 0; j < n; j++) {

          cin >> b[j];

}

     // sort(b, b + n); 这里因为b是全部放入,因此排不排序都可

     work();  //每次合并两组, 共合并m-1次

}

 

堆模拟搜索

加强版LOJ6254,acwing 2843 伪光滑数

该思想被广泛用于求解某种类型的前 K大 / 小方案,当使用这种思想解题时,算法复杂度为 O(KlogN)。其大致流程如下:

使用结构体表示搜索状态(类似动态规划与搜索算法的状态设计)。堆模拟搜索的状态设计有两个要点:状态记录全面且便于转移;使用设计的状态进行转移时,可以使得转移过程不重不漏,即前 K次取出的状态一定是前 K优解

初始状态存至堆中。

取出堆顶状态,根据该状态扩展出其他可能会被计入答案的状态,令这些状态入堆。

重复以上流程 K次,得到的即为前 K优解

以acwing146为例

这题是给定 M个长度为 N的序列,从每个序列中各取一个数,可以构成 MN个和。求这些和中前 N小的值

将第x个序列中的第y个数记作(x,y)

首先,最优解一定是将每个序列中最小的数(即从小到大排序后的第一个元素,默认这些序列都已经排序完毕),因此该方案即为堆中的初始状态

下面思考状态的设计。考虑这样一种状态方案:用四元组 (val,x,y,p=0/1)代表一种状态,其中 val代表当前状态中 M个数的和,x代表我们人为地从前 x个序列中选出了一些数字,而第 x+1到第 M个序列中每个序列被选取的都是第一个数字。

 

此时,状态中值为 0 或 1的 p看起来有些多余。事实上,该变量的值与我们采取的状态扩展策略有关。对于某个状态,我们有三种扩展方向:

当前状态中 y<n ,在当前方案中去掉数字 (x,y)并替换为 (x,y+1),得到一种新的状态 (NewVal,x,y+1,0)。

当前状态中 x<m,在当前方案中去掉数字 (x+1,1) 并替换为 (x+1,2),得到一种新的状态 (NewVal,x+1,2,1)。

当前状态中 y=2 且由途径 2 或 3 转移得到,在当前方案中去掉 (x,y),替换为 (x,1),并选取 (x+1,2),得到一种新的状态 (NewVal,x+1,2,1)。

由以上方案,我们不难看出,p代表的就是当前状态是否由途径 2 或 3 转移得到。该变量的设置是为了进行下一步的转移,通过这样的转移方式,可以保证避免在堆模拟搜索过程中的重复扩展

但按照上述的步骤还仅仅只是满足了不重,但还没有解决不漏的情况,例如对于有三个序列的情况下,我们没法搜索到(1,1),(2,1),(3,2)的情况

为了解决这个问题,我们需要进行一种操作:在进行第一次扩展前,将每个序列以“次小值减去最小值”作为关键字从小到大进行排序。这一步操作的目的是为了保证我们在进行扩展时先扩展出能令新方案的元素和较当前方案增量较小的方案,这样能保证我们在从第一个数移动到第二个数时得到的是较优的情况。接下来,我们就可以进行转移了

这样我们设定初始状态为(mn,1,1,0),然后每次取出堆顶状态进行扩展,重复k(这里就是n)次即可

int p[1010],n,m,nt,t;

long long tot;

vector<int>vec[1010];

struct asdf{//以当前的选取的数的和为关键字进行排序

    long long val;

    int x,y;

    bool p;

    asdf(){}

    asdf(long long vall,int xe,int yo,bool pp){val=vall;x=xe;y=yo;p=pp;}

    bool operator <(const asdf &b)const{return val>b.val;}

};

priority_queue<asdf>q;//全部利用结构体构建优先队列

bool cmp2(int a,int b){return vec[a][1]-vec[a][0]<vec[b][1]-vec[b][0];}//以最大值和次大值的差值作为关键字排序

void solve(){

    tot=0;

    while(!q.empty())q.pop();//每次开始前清空

    m=read();n=read();nt=n;

    for(int i=1;i<=m;i++){

        vec[i].clear();

        for(int j=1;j<=n;j++)

            vec[i].push_back(read());

        sort(vec[i].begin(),vec[i].end());

        p[i]=i;

        tot+=vec[i][0];

    }

    sort(p+1,p+m+1,cmp2);

    q.push(asdf(tot,1,0,false));//初始状态压入

    while(nt--){

        asdf np=q.top();q.pop();

        cout<<np.val<<" ";//输出当前的最小和

        //注意是删除一个数然后选择另一个数,因为我们要保证每个序列中只选取一个数

        if(np.y+1<n)q.push(asdf(np.val-vec[p[np.x]][np.y]+vec[p[np.x]][np.y+1],np.x,np.y+1,false));

        if(np.x<m)q.push(asdf(np.val-vec[p[np.x+1]][0]+vec[p[np.x+1]][1],np.x+1,1,true));

        if(np.x<m&&np.p)q.push(asdf(np.val-vec[p[np.x]][np.y]+vec[p[np.x]][0]-vec[p[np.x+1]][0]+vec[p[np.x+1]][1],np.x+1,1,true));

    }

    cout<<'\n';

}

 

 

栈和队列:

Stack

C++中栈的数据类型是stack,头文件是#include<stack>

stack<int> q; //以int型为例

int x;

q.push(x);          //将x压入栈顶

q.top();       //返回栈顶的元素

q.pop();       //删除栈顶的元素

q.size();      //返回栈中元素的个数

q.empty();          //检查栈是否为空,若为空返回true,否则返回false

同样可以用s.emplace()加入元素

stack 容器没有任何 std::begin 或者 std::end 成员函数,所以不能将它与基于范围的 for 循环一起使用,只能将栈中的元素一个一个弹出来遍历

 

栈的核心思路是自底向上,结合这点可以进行一些判断

例如T331,前序遍历是按照根节点-左子树-右子树的顺序进行遍历的,只有当根节点所有左子树遍历完成后才会遍历右子树,这题要验证二叉树的前序遍历是否是有效的,因此可以倒推考虑,即先判断左子树是否有效,再判断右子树是否有效,这个过程从树上来说也是一种自底向上的方向

具体判断的时候,注意到如果一个节点是叶子节点,那么就需要满足两个孩子都是空,换言之,当两个孩子都是空的时候,说明这个节点是叶子节点,那么我们可以将这个节点弹出,然后再加入一个空节点,因为这个节点已经判断完毕,因此对于其父节点来说就相当于是一个空节点

vector<string> stk;         // 使用一个数组模拟栈

          stk.push_back(temp);

            int len = stk.size();

            while (len >= 3 && stk[len - 1] == "#" && stk[len - 2] == "#" && stk[len - 3] != "#")

            {

                stk.pop_back();

                stk.pop_back();

                stk.pop_back();

                stk.push_back("#");

                len = stk.size();

            }

 

括号序列

2024 东北四省 L

将基础的括号系列换成操作表示就是 1 1 1 1 1(最内层的括号)

并且2操作必须在对应的1已经构建完成后才能进行操作

例如第一个2

只有1 1已经存在的情况下才能添加,即原括号序列分割成1 1    1 1 1,我们能添加的位置是在被分隔之后的空余位置,因此可以选择逆序遍历,操作方案数是 4

2操作完也可以加入当前序列,作为新的基础序列

并且2操作实际上是等价的,2  2 序列两个2并没有本质区别,关注的是插入前的序列情况或者可插入的位置,因此逆序遍历是可行的

因为2操作本身与被操作区间是对应的,因此没必要具体记录对哪个区间进行操作

void solve(){

    string s;

    cin>>s;

    vector<int> k;

    stack<int> st;

    int n=s.size();

    for(int i=0;i<n;i++){

        if(s[i]=='('){

            st.push(i);

        }else{

            if(st.top()==i-1){

                k.emplace_back(1);

            }else{

                k.emplace_back(2);

            }

            st.pop();

        }

    }

    reverse(all(k));

    ll ans=1,cur=1;

    for(auto x:k){

        if(x==2){

            ans=(cur*ans)%mod;

        }

        cur++;

    }

    cout<<ans<<'\n';

}

 

 

单调栈

利用单调栈来进行一些特定数据的存储,如在数组中查找距离它最近的大于它的数(也可以等价成问数组中小于等于这个数的连续子数组长度)就可以利用单调栈,左侧第一个大于它的数,对于那个数来说,当前数也是右侧第一个小于它的数,但按照一个顺序操作完一遍后并不能保证右侧第一个小于它的数也已经维护好了例如 2 3 1,逆序操作一遍后,2的右侧第一个小于它的数并没有得到

单调栈和单调队列能使在对数组进行遍历的过程中直观有效的记录有关特征性的一些数字(T293滑动窗口最大值)

如果是通过一个栈的出队来得到一个新的字符串,我们可以一开始就将数据元素存在一个字符串而不是一个栈中,这样出栈得到的字符串只需要将这个字符串(栈)翻转即可(T2434)

单调栈的本质:及时去掉无用数据

因此可以用于记录右侧第一个比该元素大的位置(T1944,逆序遍历,大于栈顶元素的,那么那个栈顶之后一定不会被考虑到了,因此弹出栈顶)

 

单调栈的性质:元素值等于单调栈在此处的元素值的位置,其左侧的元素值一定大于该位置的元素值

可以将被选择的元素标记为1,未被选择的元素标记为0,再结合上面的性质,可用于实现状压RMQ

 

可用于解决下一个更大元素(T496,T739)

并且单调栈有两种写法,一种是逆序遍历,构建一个递减栈,边构建边修改ans,并且栈中最好是存放下标,这样泛用性更好

vector<int> dailyTemperatures(vector<int>& T) {

    vector<int> ans(T.size());

    stack<int> s; // 这里放元素索引,而不是元素

    for (int i = T.size() - 1; i >= 0; i--) {

        while (!s.empty() && T[s.top()] <= T[i]) {

            s.pop();

        }

        ans[i] = s.empty() ? 0 : (s.top() - i); // 得到索引间距

        s.push(i); // 加入索引,而不是元素

    }

    return ans;

}

一种是顺序遍历,也是构建一个递增栈,栈中存放的是还没有找到右侧比他大的元素

vector<int> dailyTemperatures(vector<int> &temperatures) {

        int n = temperatures.size();

        vector<int> ans(n);

        stack<int> st;

        for (int i = 0; i < n; i++) {

            int t = temperatures[i];

            while (!st.empty() && t > temperatures[st.top()]) {

                int j = st.top();

                st.pop();

                ans[j] = i - j;

            }

            st.push(i);

        }

        return ans;

    }

 

栈和队列很多时候可以用来解决匹配问题(括号匹配,删去相邻相同字母)

 

2024ICPC网络预选赛第一场F

每次选择数组中的一段,要求这一段中的元素不完全相同,将其中的元素替换成元素中最大的一个,问最多能进行几次操作

暴力的做法是每次找出最小的元素,将其替换为相邻元素中较小的一个

考虑能不能顺序实现

思考添加一个元素后对答案造成的影响,显然,对于左侧大于它的元素,一定能增加一个方案数((i,j)),而对于左侧小于它的元素,新增的元素能够组成答案

因此维护一个单调栈,方便查找对于这个元素来说左侧第一个严格大于它的数以及左侧大于它的元素个数

特别的,如果左侧第一个不小于它的数与它相等,那么无法增加与小于的它的元素构成的方案数,因为从左到右更新和从右到左进行更新是等价的

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    i64 ans=0ll;

    vector<int> st(n+5);

    int top=0;//top=0栈为空

    st[++top]=0;

    for(int i=1;i<n;i++){

        while(top!=0 && a[i]>a[st[top]]){

            top--;

        }

        if(top==0){//栈为空,左侧没有大于它的数字

            ans+=i;

            st[++top]=i;

        }else{

            if(a[i]==a[st[top]]){

                while(top!=0 && a[i]==a[st[top]]){

                    top--;

                }

            }else{

                ans+=(i-st[top])-1;//相等不能加

            }

            ans+=top;

            st[++top]=i;

        }

    }

    cout<<ans<<'\n';

}

 

25 杭电 春2 1009

给定一个长度为n的数组a,对于下标i,j定义偏序关系i<(a)j当且仅当i<j,同时a[i]>max(a[i+1],…,a[j])

现在可以选择一个排列,并令b[i]=a[p[i]],并类似的定义偏序关系i<(b)j

现在对于排列,有额外的约束,也就是对于任意下标i,j,p[i]<(a)p[j]当且仅当i<(b)j

(i<(b)j,即b[i]>max(b[j])等价于a[p[i]]>max(a[p[j]]),就是p[i]<(a)p[j])

求可行的数组b中字典序最大的一个

如果令给定的数组a下标是从1开始的,那么不妨令a[0]=inff,这显然是不影响我们的构造的,同时可以保证我们得到的是一棵树而不是森林,然后首先考虑这么样的排列p是合法的

规定pa(i)为最大的j<i使得j<(a)i,可以发现这实际上就是单调栈的写法,因为j就是左侧第一个大于a[i]的元素的下标

进而全体(pa(i),i)可以构成一棵树,不妨将其称为单调树,在这个定义下,j<(a)i当且仅当在单调树上j是i的祖先,因为数组a是固定的,因此树的结构(指父子关系)也是确定的,同时可以保证存在祖先的节点中,深度更小的点对应的元素一定是更大的

排列p是对数组a元素(或者说是下标的重排),但因为对于任意重排后的下标,任然要有p[i]<(a)p[j],也就是p合法当且仅当变换后,树的形态没有发生改变,只能修改子树之间的顺序(也就是两个子树能够交换当且仅当它们的根相同)

进而基于这个树,考虑我们实际上是要得到什么

树的结构实际上已经确定了大致的形式,也就是层与层元素之间的顺序是确定的,而同一层的元素是可以发生交换的,(注意到例如50 49 47 48的形式,层序遍历并不是最大的,也就是我们并不能保证深度小的点一定大于任意深度更大的点)因此我们是要保证前序遍历的最大值

因此考虑对每一层的节点进行排序,当元素大小确定时排序结果是显然的,也就是将值较小的放在前面(否则那个数左侧第一个大于它的数就变成同层的数了,也就是树的结构将会发生变化),当元素大小相等时,就需要对子树进行进一步的判断,也就是将子树能贡献较大字典序的放在前面,于是大树排序时要用到小树的排序结果,因此不妨从答案的n到1逐个确定节点的孩子顺序

(具体实现过程中的一些正确性,可以发现,从原数组a中的下标n开始,我们确实能够保证下标越大的越可能是叶节点,或者说深度一定是不增的,于是可以从下标进行逆序遍历,保证遍历到每个节点时子树已经处理完毕)

void solve() {  

    int n;

    cin>>n;

    vector<int> a(n+1);

    vector<int> pa(n+1);

    vector<vector<int>> adj(n+1);

    a[0]=INT_MAX;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        pa[i]=i-1;

        while(a[i]>=a[pa[i]]){

            pa[i]=pa[pa[i]];

        }

        adj[pa[i]].emplace_back(i);

    }

    vector<int> nxt(n+1);

    vector<int> lst(n+1);

    iota(lst.begin(),lst.end(),0);

    for(int i=n;i>=0;i--){

        if(adj[i].size()==0){

            continue;

        }

        sort(adj[i].begin(),adj[i].end(),[&](int x,int y){

            if(a[x]!=a[y]){

                return a[x]<a[y];

            }

            while(x!=0 && y!=0 && a[x]==a[y]){

                x=nxt[x];

                y=nxt[y];

            }

            return a[x]>a[y];

        });

        for(int j=0;j<adj[i].size()-1;j++){

            nxt[lst[adj[i][j]]]=adj[i][j+1];

        }

        nxt[i]=adj[i][0];

        lst[i]=lst[adj[i].back()];

    }

    for(int i=nxt[0];i!=0;i=nxt[i]){

        cout<<a[i]<<" \n"[nxt[i]==0];

    }

}  

 

 

类似于栈的想法:
cf2001D

求正整数序列a的每个元素恰好出现一次的子序列中,将奇数位上的项乘上-1后字典序最小的序列

不是很能判断每个数字应该出现在哪个位置,因为每次判断的时候会关联到之后的元素,因此考虑能不能顺序判断当前元素应不应该保留,也就是不要想着用map存储出现位置,这样考虑保留哪个是不可行的,要考虑如何根据数列进行操作

先考虑这样一个问题:求每个元素恰好出现一次的子序列中字典序最小的序列

维护一个栈

从前向后遍历,如果新加入的元素比栈尾元素小,那么就将其弹出,保证字典序最小

但又要避免弹出这个元素后这个元素就不会在后面出现过了,那这样会导致最后的序列补码组要求

因此加上两个特判,一是如果已经在栈中,就不做判断;否则如果该元素是最后一次在序列中出现,那么就不弹出

而这题是上面的加强版,要根据栈的大小改变大于小于号,奇数要是最大,偶数要为最小,也就是要维护一个波浪形的栈

洛谷上的题解

或者考虑使用滑动窗口

滑动窗口内的数字表示还没有被选择的数字

显然如果我们知道要在这些数字内选择,那么对于奇数位,我们一定是选择其中最大的那一个,对于偶数位,一定是选择其中最小的那个

因此我们需要考虑的就是什么我们需要作出选择

因为每个数字都要出现,同时我们选择的时候是不在乎滑窗内的出现顺序的

因此我们在一个数字最后出现的位置上做出选择,同时这样操作能够保证当前的滑窗中能保留至少一个元素

又因为每个数字都只能出现一次,因此当一个数字被选择时我们就将其cnt归零

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    vector<int> cnt(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

        a[i]--;

        cnt[a[i]]++;

    }

    int len=n-count(cnt.begin(),cnt.end(),0);

    //统计有多少种数字,len表示还有多少个数字没有被选过

    vector<int> ans;

    multiset<int> s;

    int l=0,r=0;

    int d=len;

    int t=-1;

    while(len>0){

        while(d>=len){

            if(cnt[a[r]]>0){

                s.insert(a[r]);

            }

            d-=(cnt[a[r]]==1);

            cnt[a[r]]--;

            r++;

        }

        int x=t==-1?*s.rbegin():*s.begin();

        ans.push_back(x);

        while(a[l]!=x){

            s.extract(a[l]);//仅会删除一个元素

            l++;

        }

        s.erase(x);//s中所有x都会被删除

        d-=(cnt[x]>0);

        cnt[x]=0;//不能再被选择

        len--;

        t*=-1;

    }

    cout<<ans.size()<<'\n';

    for(int x:ans){

        cout<<x+1<<' ';

    }

    cout<<'\n';

}

 

Tsinghua Bootcamp 2024 Day 4 M

cf 2013D

给定一个数组,能够进行任意次数的操作,每次操作选择一个位置i,使得a[i]-1以及a[i+1]+1。问max(a1,a2..an)-min(a1,a2...an)

首先,每次操作都不改变数组元素的和(于是和可以当做是一个特征,进而思考均值之类)

显然是要让整个数组尽可能平均,同时当ai>a[i+1]+1时,进行操作显然是有益的,在a[i]=a[i+1]+1时,进行操作相当于交换两个元素,因此操作完成后,数组一定可以是单调不减的

结论:通过无限次的这样操作,可以将数组转成排序后的结果

维护一个存放已排序数组的栈,堆栈中的每个元素都代表一对(x,cnt),其中x代表一个数组,其中x是值,cnt是出现的次数

当向堆栈中添加ai时,将记录从堆栈中删除的元素的sum以及其计数cnt

当最后一个元素大于sum/cnt时,将从堆栈中删除该元素,之后,重新计算sum和cnt,然后将(sum/cnt,cnt-sum mod cnt) 和(sum/cnt+1,sum mod cnt)添加到堆栈中

每次操作,若干元素的和不变,因此可以通过存储和以及其所涵盖的元素个数来表示数组的这一区间

例如当数组的第一个元素小于数组的平均值时,第一个元素并无法变成为数组均值,因此不能直接求平均值计算

但依据这一点,可以发现(推广到)按照整体的均值,如果前面的小于后面的,那么前面的无法进行操作使得整体的均值降低,于是可以按照数组的顺序遍历进行操作,如果前面数组的均值大于当前元素,那么自然可以把这个元素归入前面的一段中,这样一定能拉低整体的平均值;而如果前面一段小于当前的平均值,那我们无法在对前面的数组进行操作,因此结束

void solve(){

    int n;

    cin>>n;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<array<i64,2>> s;//stack

    for(int i=0;i<n;i++){

        i64 v=a[i];

        i64 c=1;

        while(!s.empty()){

            auto [x,y]=s.back();

            if(x/y<v/c){

                break;

            }

            v+=x;

            c+=y;

            s.pop_back();

        }

        s.push_back({v,c});

    }

    auto [x,y]=s.back();

    i64 mx=(x+y-1)/y;//上取整

    auto [u,v]=s[0];

    i64 mn=u/v;

    cout<<(mx-mn)<<'\n';

}

 

 

变化:双单调栈(T2454)

求第一个大于该数的元素就是利用单调栈,如果当前元素x比栈顶大,那么就说明找到了下一个更大元素,那么找下下个大于他的数可以利用相似的想法,就是再构建一个单调栈,进入这个栈的要求是从之前那个栈中弹出,对这个栈的判断逻辑是相似的,有大于的了,再弹出,此时让其弹出的数字y就是我们所需要的

初始化 ans数组,长度和 nums相同,初始值均为 −1。

初始化两个空栈 s和t。

从左到右遍历 nums。

设 x=nums[i]。如果 x比t的栈顶大,则栈顶元素对应的答案为 x,并弹出 t的栈顶。

如果 x比 s的栈顶大,则弹出 s的栈顶,并移入 t的栈顶。由于要保证 t也是一个递减的单调栈,我们可以直接把一整段从 s中弹出的元素插入到 t的末尾。(因为如果此时栈中有元素,说明能让现在要进栈的x不能让这个元素出栈,说明这个元素一定比现在要进栈的元素要大)

把 x加到 s的栈顶。代码实现时,为方便记录答案,改为把 x的下标 i加到栈顶

 

在栈运行中while 里面要先判断栈是否为空,然后才能进行top等操作,否则会报错(如T47接雨水)

 

可以用来解决找最值的问题,对于两个数组nums1,和nums2,以及一个二维数组queries,要求nums1,nums2分别大于queries[0],queries[1](T2736),对于这种对应的二维比较,可以直接先把nums1,nums2合并成一个新的pair数组a,并且将a和queries都进行排序(并且自行规定按照[0]的数的大小进行排序,这样我们比较时就可以把重心放在第二个维度的比较上)在一个维度已经有序的情况下,并且要求这两个维度的最大值,可以按顺序遍历数组a,因为这其中有单调性可利用,如果 nums2[j]比之前遍历过的 nums2[j’]要小,由于 nums1[j]已经从大到小排序,所以 nums1[j]+nums2[j]也比之前遍历过的 nums1[j’]+nums2[j’]要小。所以在回答询问时,最大值不可能是 nums1[j]+nums2[j][j],所以无需考虑这样的 nums2[j](这种单调性启发我们用单调栈来维护,两数相加求最值的常见考虑)

并且在操作时如果大于,就可以把 nums2[j] 入栈(同时把 nums1[j]+nums2[j]也入栈)(可用一个二维的进行维护)。在入栈前,去掉一些无效数据:如果 nums1[j]+nums2[j] 不低于栈顶的 nums1[j’]+nums2[j’]那么可以弹出栈顶。因为更大的 nums2[j] 更能满足 ≥yi的要求,所以栈顶的 nums1[j’]+nums2[j’]在后续的询问中,永远不会作为最大值

(要在大于某个值的数对中找目标答案,一般都可以排序然后二分查找,或者直接遍历解决,,如果要后续进行二分,可以尝试用数组去维护栈,数组头部是栈底,尾部是栈顶)

 

从前往后遍历 + 需要考虑相邻元素 + 有消除操作 = 栈,意思是题目中的操作可以用栈去模拟,但并不一定要使用栈(T2216)

 

queue

可以使用deque和list对queue初始化,但不能用vector作为容器,一般默认是deque,头文件

#include<queue>

queue<int>q1;> 初始化

常用函数

push() 在队尾插入一个元素

pop() 删除队列第一个元素

size() 返回队列中元素个数

empty() 如果队列空则返回true

front() 返回队列中的第一个元素

back() 返回队列中最后一个元素

不能初始化,因此在声明后逐个加入

queue<TreeNode *> q;       

q.push(root);

 

deque

作为栈stack和queue的底层实现容器,我们可以利用deque来定义我们所需要的具有特殊规则的栈或队列(如之前的单调队列)

vector 容器是单向开口的连续内存空间,deque 则是一种双向开口的连续线性空间。所谓的双向开口,意思是可以在头尾两端分别做元素的插入和删除操作

头文件 #include <deque>  初始化deque<int>d;

遍历

for(deque<int>::const_iterator it = d.begin(); it != d.end(); it++){

cout<<*it<<" ";

}

基本操作

push_back(elem);//在容器尾部添加一个数据

push_front(elem);//在容器头部插入一个数据

pop_back();//删除容器最后一个数据

pop_front();//删除容器第一个数据

at(idx);//返回索引idx所指的数据,如果idx越界,抛出out_of_range

operator[];//返回索引idx所指的数据,如果idx越界,不抛出异常,直接出错

front();//返回第一个数据

back();//返回最后一个数据

insert(pos,elem);//在pos位置插入一个elem元素的拷贝,返回新数据的位置

insert(pos,n,elem);//在pos位置插入n个elem数据,无返回值

insert(pos,beg,end);//在pos位置插入[beg,end)区间的数据,无返回值

clear();//移除容器的所有数据

erase(beg,end);//删除[beg,end)区间的数据,返回下一个数据的位置

erase(pos);//删除pos位置的数据,返回下一个数据的位置

可以用sort对deque进行排序

c.rbegin() 返回一个逆序迭代器,它指向容器c的最后一个元素

c.rend() 返回一个逆序迭代器,它指向容器c的第一个元素前面的位置

以字符串形式逆序输出原有的队列(vector等同理) string(q.rbegin(),q.rend())

 

可以使用双端队列来进行模拟类似循环的情况,特别是每次确定只移动一个单位,能够得到较为高效的实现

Cf 2094G

void solve() {

    int q;

    std::cin >> q;

   

    std::deque<int> a;

   

    i64 sum = 0;

    i64 ans = 0;

   

    bool rev = false;

   

    for (int i = 0; i < q; i++) {

        int s;

        std::cin >> s;

       

        if (s == 1) {

            if (!rev) {

                int x = a.back();

                ans += sum;

                ans -= i64(a.size()) * x;

                a.pop_back();

                a.push_front(x);

            } else {

                int x = a.front();

                ans -= sum;

                ans += i64(a.size()) * x;

                a.pop_front();

                a.push_back(x);

            }

        } else if (s == 2) {

            rev ^= 1;

        } else {

            int k;

            std::cin >> k;

            if (!rev) {

                ans += i64(a.size()) * k;

                sum += k;

                a.push_back(k);

            } else {

                ans += sum;

                sum += k;

                a.push_front(k);

            }

        }

       

        if (!rev) {

            std::cout << ans + sum << "\n";

        } else {

            std::cout << sum * i64(a.size()) - ans << "\n";

        }

    }

}

 

 

 

cf 1955C

对数组的操作是首尾交替进行的,显然可以维护一个双指针去操作,但更简单的,可以直接用deque去实现,之后只要进行模拟判断即可

void solve() {

    int n;

    ll k;

    cin >> n >> k;

    deque<ll> dq(n);

    for (int i = 0; i < n; ++i) {

        cin >> dq[i];

    }

    while (dq.size() > 1 && k) {

        ll mn = min(dq.front(), dq.back());

        if (k < 2 * mn) {

            dq.front() -= k / 2 + k % 2;//奇数次操作对第一艘多攻击一次

            dq.back() -= k / 2;

            k = 0;

        } else {

            dq.front() -= mn;

            dq.back() -= mn;

            k -= 2 * mn;

        }

        if (dq.front() == 0) {

            dq.pop_front();

        }

        if (dq.back() == 0) {

            dq.pop_back();

        }

    }

    int ans = n - dq.size();

    cout << ans + (dq.size() && dq.front() <= k) << '\n';

}

 

 

优先队列priority_queue(大顶堆,小顶堆):

对于一堆数据,我们显然是需要其从小到大的排列,并且每次数据操作后还要重新加入数组中时,可以直接使用小顶堆,因为反复使用sort往往会tle(P1090)

优先级队列,不满足先进先出的条件,优先级队列每次出队的元素是队列中优先级最高的那个元素,而不是队首的元素,如果最大的元素优先级最高,那么每次出队的就是当前队列中最大的元素,那么队列实际就相当于一个大顶堆,每次将堆根节点元素弹出,重新维护大顶堆,就可以实现一个优先级队列

priority_queue<typename, container, functional>

typename是数据的类型;container是容器类型,可以是vector,queue等用数组实现的容器,不能是list,默认可以用vector;functional是比较的方式,默认是大顶堆(就是元素值越大,优先级越高);

如果使用C++基本数据类型,可以直接使用自带的less和greater这两个仿函数(默认使用的是less,就是构造大顶堆,元素小于当前节点时下沉)。使用自定义的数据类型的时候,可以重写比较函数,也可以进行运算符重载(less重载小于“<”运算符,构造大顶堆;greater重载大于“>”运算符,构造小顶堆)。

定义一个优先级队列的示例如下:

 

//构造一个大顶堆,堆中小于当前节点的元素需要下沉,因此使用less

priority_queue<int, vector<int>, less<int>> priQueMaxFirst;

//构造一个小顶堆,堆中大于当前节点的元素需要下沉,因此使用greater

priority_queue<string, vector<string>, greater<string> > priQueMinFirst;  (最好多加一个空格避免为编译器识别为右移运算符)

 

优先级队列的基本操作与普通队列类似,不同的是每次获得队内的元素是优先级最高的元素(要从堆的顶部开始),因此使用的是top()方法,而不是front()方法。如下:

push() :入队。向队列添加一个元素,无返回值;

pop() :将队列中优先级最高的元素出队。将队列中优先级最高的元素删除(出队),无返回值;

top() :获得队列优先级最高的元素。此函数返回值为队列中优先级最高的元素,常与pop()函数一起,先通过top()获得队列中优先级最高的元素,然后将其从队列中删除;

size() :获得队列大小。此函数返回队列的大小,返回值是“size_t”类型的数据,“size_t”是“unsigned int”的别名。

empty() :判断队列是否为空。此函数返回队列是否为空,返回值是bool类型。队列空:返回true;不空:返回false

 

当优先队列的数据类型是pair时,比较中会先比较第一个,第一个相等再比较第二个

优先队列默认大根堆,所以比较时也可以将数值加个负号来达到变成小根堆的目的

 

写比较函数

struct cmp{

   bool operator()(vector<int>&a,vector<int>&b){

       return a[0]>b[0];

   }

};

priority_queue<vector<int>,vector<vector<int>>,cmp> q;//小顶堆

虽然不知道为什么,但注意优先队列中的比较函数是需要和正常情况下相反的,正如此处,如果是放在sort函数里得到的会是一个单调递减的数组,但这里得到的是一个小顶堆

struct cmp{

    bool operator()(int &x,int &y){

        if(s[x]==s[y]) return x>y;

        return s[x]<s[y];

    }

};

priority_queue<int,vector<int>,cmp> pq;

就像这样写,得到的才是一个堆顶是按照分数大小排序,分数最高的名次最高,并且分数相同时序号越小,名次越高的名次顺序

 

优先队列也可以用双向队列来模拟(T239,利用了单调栈的思想,但因为在窗口移动的过程中可能是栈顶的元素要弹出,因此要用到deque的pop_front)

 

懒删除最小堆(T2349):即原本需要直接删除的下标先不删除,等到之后查找时发现有必要了再弹出堆顶,懒的操作就是指原本要进行的操作在之后再进行(对于 find 操作,查看堆顶下标对应的元素是否和 number相同,若不相同则意味着对应的元素已被替换成了其他值,堆顶存的是个垃圾数据,直接弹出堆顶;否则堆顶就是答案),一般能提高时间和空间复杂度,因为堆是基于数组的查找的效率一般肯定比树要高

 

最小堆能够用于解决查找遍历过程中一些指定位置的右侧第一个大于他的元素的位置的下标(T2940)

也可以用来解决一些模拟相关的问题,例如上下车,将下车车站的编号作为小顶堆的依据,每次判断现在到达的车站编号和小顶堆堆顶来判断是否有人下车(T1094)

auto cmp = [](vector<int>& a, vector<int>& b){           

return a[2] > b[2];       

};       

// min heap       

priority_queue<vector<int>, vector<vector<int>>, decltype(cmp)> q(cmp);

 

单调队列的题应该也能用优先队列去写,同时通过结构体存储位置和值来起到维护区间中的RMQ来解决,但问题在于优先队列可能会超时(P1440)

优先队列:

struct node{

    int val,it;

};

 

struct cmp{

    bool operator()(node &x,node &y){

        return x.val>y.val;

    }

};

priority_queue<node,vector<node>,cmp> pq;

int n,m;

int main(){

    cin>>n>>m;

    for(int i=1;i<=n;i++){

        node a;

        cin>>a.val;

        a.it=i;

        if(i==1) cout<<0<<endl;

        else{

            while(pq.top().it<i-m) pq.pop();

            cout<<pq.top().val<<endl;

        }

        pq.push(a);

    }

    return 0;

}

 

单调队列:

首先满足的特点是队列,即先进先出,因此加入元素是从队列末尾添加的,其次,单调队列满足单调的特性,因此添加元素时要进行判断以维持单调性,因为单调是需求,比它大的元素在后续使用中一定用不到了


struct node{

    int val,it;

};

 

deque<node> dq;

int m,n;

int main(){

    n=read(),m=read();

    for(int i=1;i<=n;i++){

        node a;

        a.val=read(),a.it=i;

        if(i==1) cout<<0<<endl;

        else{

            while(!dq.empty() && dq.front().it<i-m) dq.pop_front();

            cout<<dq.front().val<<endl;

        }

        while(!dq.empty() && dq.back().val>a.val) dq.pop_back();

        dq.push_back(a);

    }

    return 0;

}

 

进行特殊的模拟时可以用使用结构体,例如cf1945G

定义结构体,主要是定义用于单调队列的比较函数

struct node{
             int k,t,s;
             inline bool operator <( node b ) const{
                          if( k != b.k ) return k < b.k;
                          if( t != b.t ) return t > b.t;
                          return s > b.s;
             }
};

 

 

cf 1969D

不要直接考虑缩小利润,从整体的形式上判断。因为可以白嫖,因此不选择利润最大的那些不一定会使对方的利润最小

例如 B A:100 99/ 10 1   如果按照利润去算,其选择白嫖1,这样A的盈利是0,但如果他白嫖99,虽然A单次的收入多了,但总体的盈利却是负的

整体形式   B选择的物品价格-A选择的物品价格

B要让A的利润尽可能小,显然他不会去选择那些价格最高的物品,但剩下的都是必须要买的,因此剩下部分的选择权在A手上,因此这个问题实际上是由两部分组成

Alice 的最优策略有两种:要么一个都不选,要么选择 k 个及以上。

那么对于已定集合 S,Bob 一定会选择前 k 大的 bi不支付,这样一定是最优的。

所以对原数组按 bi从小到大排序,枚举第 k 大的 bi的下标 i,那么 Alice 为了使得自己损失最小(这些是被白嫖的)一定会在i~n 中选择前 k 小的 ai​。

此时 Alice已经选择了 k 个商品了,那么接下来她可选择也可以不选,在最优策略下,她会选择在 1∼i−1 中 bi>ai的商品,这会使她盈利。

所以对于每一个 i:维护 i∼n 前k 小的 ai 值,并且维护 1~i−1 中bi−ai的和。

答案记得与 0取较大值,并且写的时候要注意数据范围,开int不够

(在一个数据范围中选择前k小,每次移动时进行改变(优先队列会自行进行动态维护),利用大根堆完成)

pair<int,int> a[200005];

int f[200005];

void solve(){

    int n,k;

    cin>>n>>k;

    for(int i=1;i<=n;++i){

        cin>>a[i].first;

    }

    for(int i=1;i<=n;++i){

        cin>>a[i].second;

    }

    sort(a+1,a+n+1,[&](const pair<int,int> &x,const pair<int,int> &y){

        return x.second<y.second;

    });

    int num=0;

    priority_queue<int> q;

    for(int i=n;i>=n-k+1;i--){

        q.emplace(a[i].first);

        num+=a[i].first;

    }

    a[0].first=-1;

    for(int i=n-k+1;i>=1;i--){

        f[i]=num;

        if(q.size()&&q.top()>a[i-1].first) num-=q.top(),q.pop(),q.push(a[i-1].first),num+=a[i-1].first;

    }

    int ans=0;

    for(int i=1,sum=0;i<=n-k+1;i++){

        ans=max(ans,sum-f[i]);

        if(a[i].second-a[i].first>0)

            sum+=a[i].second-a[i].first;

    }

    cout<<ans<<'\n';

}

 

 

 

 

 

分块:

涉及区间修改和区间查询一般采用分块和线段树的思想

分块的基本思想是,通过对原数据的适当划分,并在划分后的每一个块上预处理部分信息,从而较一般的暴力算法取得更优的时间复杂度

分块的时间复杂度主要取决于分块的块长,一般可以通过均值不等式求出某个问题下的最优块长,以及相应的时间复杂度(如Libreoj 6280,T239)

分完块后,每次查询和修改就有两种情况:一种是恰好都在一个块内,一种是有几个块的一部分组成(例如一个块的前缀,若干个块,以及一个块的后缀)

例(注意各变量的含义):

#include <cmath>

#include <iostream>

using namespace std;

int id[50005], len;

// id 表示块的编号, len=sqrt(n) , 即上述复杂度计算得出的s, sqrt的时候时间复杂度最优

long long a[50005], b[50005], s[50005];

// a 数组表示数据数组, b 数组记录每个块的整体赋值情况, 类似于 lazy_tag, s表示块内元素总和

void add(int l, int r, long long x) { // 区间加法

int sid = id[l], eid = id[r];  //注意sid和eid代表的均是块的编号

if (sid == eid) { // 在一个块中,说明只要对块中的元素进行操作即可

for (int i = l; i <= r; i++)

a[i] += x, s[sid] += x;

return;

}

for (int i = l; id[i] == sid; i++) a[i] += x, s[sid] += x; //不在一个块中,将这个块的后缀进行计算

for (int i = sid + 1; i < eid; i++)

b[i] += x, s[i] += len * x; // 更新区间和(完整的块),因此i用的是编号,更新的是b,相当于整体操作

for (int i = r; id[i] == eid; i--)

a[i] += x, s[eid] += x;

// 以上两行不完整的块直接简单求和,就OK

}

long long query(int l, int r, long long p) { // 区间查询

int sid = id[l], eid = id[r];

long long ans = 0;

if (sid == eid) { // 在一个块里直接暴力求和

for (int i = l; i <= r; i++) ans = (ans + a[i] + b[sid]) % p;

return ans;

}

for (int i = l; id[i] == sid; i++) ans = (ans + a[i] + b[sid]) % p;

for (int i = sid + 1; i < eid; i++) ans = (ans + s[i]) % p;

for (int i = r; id[i] == eid; i--) ans = (ans + a[i] + b[eid]) % p;

// 和上面的区间修改是一个道理

return ans

}

int main() {

int n;

cin >> n;

len = sqrt(n); // 均值不等式可知复杂度最优为根号n

for (int i = 1; i <= n; i++) { // 题面要求

cin >> a[i];

id[i] = (i - 1) / len + 1;

s[id[i]] += a[i];

}

for (int i = 1; i <= n; i++) {

int op, l, r, c;

cin >> op >> l >> r >> c;

if (op == 0)

add(l, r, c);

else

cout << query(l, r, c + 1) << endl;

}

return 0;

}

如果要在常数时间内完成询问,我们可以维护前缀和,不过在有修改的情况下,不方便维护,只能维护单个块内的前缀和以及整块作为一个单位的前缀和

类似的如果操作数量较少时,可以利用差分数组,记录操作(重点是在询问时再加上这些操作的影响)

 

分块思想也可以应用于其他整数相关问题:寻找零元素的数量、寻找第一个非零元素、计算满足某个性质的元素个数等等。

还有一些问题可以通过分块来解决,例如维护一组允许添加或删除数字的集合,检查一个数是否属于这个集合,以及查找第k大的数。要解决这个问题,必须将数字按递增顺序存储,并分割成多个块,每个块中包含 个数字。每次添加或删除一个数字时,必须通过在相邻块的边界移动数字来重新分块以及莫队算法

 

块状数组,即把一个数组分成几个块,块内信息整体保存,若查询是遇到两边不完整的块直接暴力查询,一般情况下,块的长度为O( ),可用于解决区间修改以及区间查询的操作(区间内小于等于某一个的数的个数)(洛谷 2801)

建立块状数组的一种代码

num = sqrt(n);

for (int i = 1; i <= num; i++)

st[i] = n / num * (i - 1) + 1, ed[i] = n / num * i;

//st[i]和ed[i]是块的起点和终点,size[i]为块的大小

ed[num] = n;

for (int i = 1; i <= num; i++) {

for (int j = st[i]; j <= ed[i]; j++) {

belong[j] = i;

}

size[i] = ed[i] - st[i] + 1;

}

 

块状链表就是一个链表,每个节点指向一个数组,可以将原来长度为n的数组分为个节点,每个节点对应的数组大小为。

定义结构体node,sqn即表示sqrt(n),pb表示push_back,即在node中添加一个元素

struct node {

node* nxt;

int size;

char d[(sqn << 1) + 5];

node() { size = 0, nxt = NULL, memset(d, 0, sizeof(d)); }

void pb(char c) { d[size++] = c; }

};

块状链表的所有操作的复杂度都是 的,并且,虽然随着元素的插入或删除,n会变,但我们保证每次块的大小是一个定值,主要是根据题目给的最大数值范围确定块的大小

块状链表支持分裂,插入,查找  分裂是先新建一个节点,再把被分裂的节点的后 个值 copy 到新节点,然后把被分裂的节点的后 个值删掉(size--),最后把新节点插入到被分裂节点的后面即可

例(POJ2887 Big String

#include <cctype>

#include <cstdio>

#include <cstring>

using namespace std;

static const int sqn = 1e3;

struct node { // 定义块状链表的节点

node* nxt;

int size;

char d[(sqn << 1) + 5];

node() { size = 0, nxt = NULL; }  //块的实际大小:包含元素的个数

void pb(char c) { d[size++] = c; }

}* head = NULL;

char inits[(int)1e6 + 5];

int llen, q;

void readch(char& ch) { // 读入字符

do ch = getchar();

while (!isalpha(ch));   //do...while循环,先读入,如果是字母则继续读入

}

void check(node* p) { // 判断,记得要分裂(根据当前的数据量避免node中的数组越界)

if (p->size >= (sqn << 1)) {

node* q = new node;

for (int i = sqn; i < p->size; i++) q->pb(p->d[i]);

p->size = sqn, q->nxt = p->nxt, p->nxt = q; //同一个逻辑块的语句用,写在一起

}

}

void insert(char c, int pos) { // 元素插入,借助链表来理解

node* p = head;

int tot, cnt;  //tot指已经走过了多少位置

if (pos > llen++) { // 根据题意,超过现有字符串大小,插到末尾

while (p->nxt != NULL) p = p->nxt; 

p->pb(c), check(p);

return;

}

for (tot = head->size; p != NULL && tot < pos; p = p->nxt, tot += p->size);

tot -= p->size, cnt = pos - tot - 1;

for (int i = p->size - 1; i >= cnt; i--) p->d[i + 1] = p->d[i]; //插入,因此要将元素后移

p->d[cnt] = c, p->size++;

check(p);

}

 

char query(int pos) { // 查询

node* p;

int tot;

for (p = head, tot = head->size; p != NULL && tot < pos;p = p->nxt, tot += p->size);

tot -= p->size;

return p->d[pos - tot - 1];

}

int main() {

scanf("%s %d", inits, &q), llen = strlen(inits);

node* p = new node;

head = p;

for (int i = 0; i < llen; i++) {

if (i % sqn == 0 && i) p->nxt = new node, p = p->nxt;

p->pb(inits[i]);

}

char a;

int k;

while (q--) {

readch(a);

if (a == 'Q')

scanf("%d", &k), printf("%c\n", query(k));

else

readch(a), scanf("%d", &k), insert(a, k);

}

return 0;

}

 

Cf 2026D

给定一个长度的为n的数组a,要求我们生成一个长度为(n)*(n+1)/2的数组b,数组b中的元素是数组a中所有的区间和,也就是sum(1,1),sum(1,2)…sum(1,n),sum(2,2)…sum(n,,n)

有q个询问,每个询问给定两个整数l和r,代表要求b中[l,r]的元素和

显然,如果我们能够处理出b数组的前缀和,那么这个区间和查询是显然的,但是因为原来a数组的n就比较大,导致我们并不能用数组来存储b中的元素

但我们还是得考虑使用前缀和,或者说需要维护一些东西,这样才能在做区间和的时候降低时间复杂度

对于区间询问,除了考虑线段树等数据结构外,也可以考虑分块,可以看作通过分块降低了目标数组的大小

因此按照b序列中s(l,r)中的l来分成n块,这样就可以使用前缀和,并且要查询的区间就可以拆成很多个完整的块和两端多出来的东西

前缀和,无法维护前缀和数组,仍考虑快速处理出(1,i)的前缀和(前缀和数组核心,O(1)查询得到数值,然后通过相减得到区间的元素和),注意到每段的形式都差不多,例如s(1,1)+…+s(1,n)和s(2,2)+…+s(2,n)实际上都是数组A的前缀和的前缀和的形式,只是相差了前面的若干元素,因此在这个的基础上去考虑用分块进行计算

另:
二分的两种写法:

while(lo < hi) {

    int u = (lo + hi + 1) / 2;  // 上取整

    if(x >= 1ll*u*(n+n-u+1)/2) {

        lo = u;      // 满足条件时保留中点

    } else {

        hi = u - 1;  // 不满足时缩小右边界

    }

}

采用的是上取整,因此得到的是最后一个满足条件的位置

while(lo < hi) {

    int u = (lo + hi) >> 1;     // 下取整

    if(condition) {

        hi = u;      // 满足条件时缩小右边界

    } else {

        lo = u + 1;  // 不满足时增大左边界

    }

}

采用的是下取整,因此得到的是第一个满足条件的位置

但相同的是这个两种写法,最后取到的值都是lo

这里要计算的最后一个满足x>=u*(n+n-u+1)/2的u,也就是被覆盖到的完整块,因为从1开始,每段的大小分别是n,n-1…,因此可以采用高斯求和公式

块之内的部分可以通过计算后得到

void solve() {

    int n;

    cin>>n;

    vector<i64> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<i64> pre(n+1);

    for(int i=0;i<n;i++){

        pre[i+1]=pre[i]+a[i];

    }

    vector<i64> s0(n+2),s1(n+2),ss(n+2);

    for(int i=0;i<=n;i++){

        s0[i+1]=s0[i]+pre[i];

        s1[i+1]=s1[i]+pre[i]*i;

    }

    for(int i=0;i<=n;i++){

        ss[i+1]=ss[i]+s0[i];

    }

    auto query=[&](i64 x)->i64{

        i64 lo=0,hi=n;

        while(lo<hi){

            int u=(lo+hi+1)/2;

            if(x>=1ll*u*(n+n-u+1)/2){

                lo=u;

            }else{

                hi=u-1;

            }

        }

        i64 res=0;

        i64 u=lo;

        res+=s0[n+1]*u;

        res-=ss[u];

        res-=s0[u]*(n+1);

        res+=s1[u];

        // 块之外的部分

        x-=1ll*u*(n+n-u+1)/2; //计算长度

        res+=s0[u+x+1]-s0[u]; //s(u+1,u+1),...,s(u+x)

        res-=pre[u]*(x+1); //减去每个s中多余的a[0],a[1]...a[u]

        return res;

    };

    int q;

    cin>>q;

    while(q--){

        i64 l,r;

        cin>>l>>r;

        l--;

        cout<<query(r)-query(l)<<"\n";

    }

}

 

 

sqrt tree
能做到O(nloglogn)的预处理时间复杂度(优于ST表的O(nlogn)的复杂度),同时在O(1)的时间内回答询问,可用于解决的问题:解决对区间内数据进行满足结合律的询问

 

RMQ问题:
表示区间最大(最小)值

一般的解决方法是单调栈,ST表,线段树等方法

主要是针对较大数据量情况下的多次查找进行处理

牛客24多校2 D

题意:

A,B两个人抢n盒糖果,第i盒糖果有ai颗糖

n次竞价,每次竞价若最后的出价是vm,则出价的人将vm个硬币给弃权的人,并且出价的人可以随意拿走一盒糖果

一开始A,B各有x和y个糖果,问最优策略下A最多拿多少糖果

首先因为每次竞价之后可以任意选择糖果,因此拿糖果的顺序显然是降序的

同时显然可以使得A,B的出价次数不超过1

并且因为每次都是将硬币给弃权的人,因此硬币总数在整个过程中是固定的

s平方写法

for(auto a:a){

    vector<i64> ndp(s+1);

    for(int p=0;p<=s;p++){

        int q=s-p;;

        for(int x=0;x<=p;x++){

            i64 res=dp[p-x]+a;

            if(x<q){

                res=min(res,dp[p+x+1]);

            }

            ndp[p]=max(ndp[p],res);

        }

    }

    dp=move(ndp);

}

cout<<dp[x]<<'\n';

枚举p,枚举x

当前拥有的硬币是p,且这一轮出价x

如果直接获得了,那么就是res=dp[p-x]+a

第二种情况是x<q,那么res=min(res,dp[p+x+1])

那么ndp[p]=max(res,ndp[p])

这样的总时间复杂度是n*s^2,需要优化掉一个s

首先dp数组是递增的,那么对于固定的p,x越大,两种情况的差也越大

每次res是取两种情况种较小的一种,因此当x越小的时候,取较小的,也就是后面一种情况,x越大,取前面一种情况,因为此时dp[p-x]过于小,即使再加上a还是小于dp[p+x+1]

于是可以二分出什么时候第一次取前面这种情况,同时当x=q的时候一定会取前面这种,因为这种情况下第二者无法赢下竞价

二分得到边界之后m=l后,遍历x,如果x<m,则res=max(res,dp[p+x+1]),x>=m时res=max(res,dp[p-x]+a)

可以将上面x的遍历看作是对两个区间的遍历,而res相当于是对这两个区间中找最值,因此可以用rmq进一步优化复杂度,将dp数组进行rmq预处理

如果0<m,那么res=max(res,rmq(p+1,p+m+1))

如果m<=p,那么res=max(res,rmq(0,p-m+1)+a)

void solve(){

    int n,x,y;

    cin>>n>>x>>y;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    sort(a.begin(),a.end());

    const int s=x+y;

    vector<i64> dp(s+1);

    for(auto a:a){

        RMQ<i64,greater<>> rmq(dp);

        vector<i64> ndp(s+1);

        for(int p=0;p<=s;p++){

            int q=s-p;

            int l=0,r=min(p+1,q);//一定是取前面一种情况

            while(l<r){

                int x=(l+r)>>1;

                if(dp[p-x]+a<dp[p+x+1]){

                    r=x;

                }else{

                    l=x+1;

                }

            }

            const int m=l;

            //二分边界

            i64 res=0;

            //对两个区间分别处理找区间最值

            if(0<m){

                res=max(res,rmq(p+1,p+m+1));

            }

            if(m<=p){

                res=max(res,rmq(0,p-m+1)+a);

            }

            ndp[p]=res;

        }

        dp=move(ndp);

    }

    cout<<dp[x]<<'\n';

}

 

Cf 2057D

给定一个长度为n的数组,定义一个区间的价值为这个区间的极差减去区间长度,问q次修改,每次所得的价值最大值为多少

将原来的价值拆分为max{a[l],…a[r]}-r-(max{a[l],…,a[r]}-l)

也就是基本可以将该位置对应的元素减去对应的下标

因为本质上是单点修改和区间查询最大值,因此可以用线段树来维护

于是关键就是设定线段树单个节点需要维护的信息

struct Info{

    i64 len;

    i64 maxl;

    i64 maxr;

    i64 minl;

    i64 minr;

    i64 ans;

    Info() : len(0),maxl(-inff),maxr(-inff),minl(inff),minr(inff),ans(0){}

    Info(int x,int i) : len(1),maxl(x+i),maxr(x-i),minl(x-i),minr(x+i),ans(0){}

};

 

Info operator+(const Info &a , const Info &b){

    Info c;

    c.len=a.len+b.len;

    c.maxl=max(a.maxl,b.maxl);

    c.maxr=max(a.maxr,b.maxr);

    c.minl=min(a.minl,b.minl);

    c.minr=min(a.minr,b.minr);

    c.ans=max({a.ans,b.ans,a.maxl-b.minr,b.maxr-a.minl});

    return c;

}

 

void solve(){

    int n,q;

    cin>>n>>q;

    SegmentTree<Info> seg(n);

    for(int i=0;i<n;i++){

        int a;

        cin>>a;

        seg.modify(i,Info(a,i));

    }

    cout<<seg.rangeQuery(0,n).ans<<'\n';

    for(int i=0;i<q;i++){

        int p,x;

        cin>>p>>x;

        p--;

        seg.modify(p,Info(x,p));

        cout<<seg.rangeQuery(0,n).ans<<'\n';

    }

}

 

 

 

静态区间最小值

常见解法:稀疏表

递推计算所有长度为2的幂的区间的结果

对于一个询问找到一个最大的k,对[l,l+2^k]和[r-2^k,r]计算最值

因为最小值重叠的部分多次计算是不会影响结果的

离线+单调栈

考虑栈顶为i单调栈和r=i+1的所有询问

在单调栈上二分查找最小的>=i的下标

将整个区间分成长度为B的块,那么询问就可以拆分成3部分,一部分是若干个整块,一部分是一个块的前缀,另一部分是块的后缀

 

ST表:

ST是用于解决可重复贡献问题的数据结构

可重复贡献问题是指对于运算opt,满足x opt x=x,并且要求opt要满足结合律,则对应的区间询问就是一个可重复贡献问题,例如区间RMQ问题(区间最大(最小)值)就是典型的可用ST表解决的问题(RMQ问题用ST表解决空间复杂度比线段树小)以及gcd问题(求最大公约数),区间按位与,区间按位或,但求区间和就不能用ST表了,因为如果求区间和的时候采用的预处理区间重叠了,则会导致重叠部分被计算两次,分析一下可以发现可重复贡献问题一般都带有某种类似RMQ的成分,例如区间按位与就是每一位取最小值,而区间GCD则是每一个质因数的指数取最小值

ST表基于倍增思想,可以做到O(nlogn)预处理,O(1)回答每个询问,但是不支持修改操作(静态区间RMQ)

因为可重复贡献问题的性质,即使用来求解的预处理区间有重叠部分,只要这些区间的并是所求的区间,最终计算出的答案就是正确的

令f(i,j)表示区间[i,i+2j-1]的最大值,显然f(i,0)=ai

对于状态转移,这个区间相当于倍增的时候跳了2j-1步,那么我们可以通过跳较小的两步来得到这个区间的状态值,即f(i,j)=max(f(i,j-1),f(i+2j-1,j-1))

预处理并不是以一个点为左端点不断倍增跳跃的步长,而是在以某一步长(例如1)跳完整个数组后,再从头倍增跳跃的步长,只不过此时可以借助已经处理过的部分了

以上是预处理,在进行询问时,任何一个区间都可以被表示为至多两个已经被预处理的区间的并,预处理区间的长度是第一个小于等于被询问的区间的长度的2的整数幂次

即对每个询问[l,r],可以分成[l,l+2s-1]和[r-2s+1,r],其中s=[log2(r-l+1)],两部分的结果的最大值就是答案

因为输入输出数据一般很多,因此可以开启输入输出优化

模版(洛谷 3856)

#include<cstdio>

#include<cmath>

#include<algorithm>

using namespace std;

int f[100001][40],a,x,LC,n,m,p,len,l,r;

int main(){

    scanf("%d%d",&n,&m);

    for(int i=1;i<=n;i++)

    {       

        scanf("%d",&a);

        f[i][0]=a;

    }

    LC=(int)(log(n)/log(2));

    for(int j=1;j<=LC;j++)

        for (int i=1;i<=n-(1<<j)+1;i++)

              f[i][j]=max(f[i][j-1],f[i+(1<<(j-1))][j-1]);

    for(int i=1;i<=m;i++)

    {

        scanf("%d%d",&l,&r);

                p=(int)(log(r-l+1)/log(2));

        printf("%d\n",max(f[l][p],f[r-(1<<p)+1][p]));

    }

    return 0;

}

 

改进:four russian算法

 

离线:

离线做法:把询问分组,不按照输入的顺序一个个回答,按照自己定义的顺序回答;在线做法:按照输入的顺序,一个个回答

离线做法:整理询问,使得能按照我们的思路去解决问题(T2940),这样能够使对于问题的思考变得更为清晰,特别需要注意的就是当一个查询能满足一个人可以立刻跳到另一个人身边时,就要提前填好这个查询的答案(需要知道原查询中的编号),并在后续处理中不考虑这个查询(弹出)

 

P1972 HH的项链

反复的区间查询用或者线段树,关键是要清楚要维护那个区间,用离线时,一般要先构造出一个结构体,用来存储原询问中的下标以及相应的信息,因为最后仍就需要按原来的顺序存储答案;一般利用离线的原因可以像这题一样,将询问的区间排序后再回答可以更为简便,要统计区间中不同的数字,自然可以想到进行区间差分,这样就想到了树状数组,因此用一个数据结构去维护一个区间内的不同数字的个数,这里的难点就在于一个数字可能会重复出现,那么如何进行计算会比较快捷呢,我们可以只记录一个数字最后出现的位置,因为在一个区间内,出现1次就代表了它对于不同数字个数的贡献,若对于不同的询问区间,右端点r相同,那么我们关心的一定是最右边的出现的位置,因为这决定了它到底应不应该被记录在询问区间里(最后的出现位置与区间左端点的大小关系),而这个思路也决定了我们为什么要离线的做法,因为我们显然不能再一开始就将数字的位置记录为最后一次出现的位置,而是要随着询问进行更改,具体实现时,tree[j]维护从i到j不同数字的个数有几个对于一个数字,记录其上一次碰到的位置,在遍历时,如果当前位置对应的数字之前遇到过,将上次出现位置的tree不同数字个数-1,将当前位置的出现位置+1,同时维护上一次出现位置的数组

//离线

struct que{

    int l,r,id;

}q[1000005];

 

inline int lowbit(int x){

    return x&(-x);

}

inline void modify(i64 x,i64 y){

    for(i64 i=x;i<=n;i+=lowbit(i)){

        tree[i]+=y;

    }

}

inline i64 query(int x){

    i64 res=0;

    for(i64 i=x;i;i-=lowbit(i)){

        res+=tree[i];

    }

    return res;

}

void solve(){

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    cin>>m;

    for(int i=1;i<=m;i++){

        cin>>q[i].l>>q[i].r;

        q[i].id=i;

    }

    sort(q+1,q+m+1,[](que a,que b){

        return a.r<b.r;

    });

    int pre=1;

    for(int i=1;i<=m;i++){

        for(int j=pre;j<=q[i].r;j++){

            if(vis[a[j]]){

                modify(vis[a[j]],-1);

            }

            modify(j,1);

            vis[a[j]]=j;//只记录当前元素在最右侧的位置

        }

        pre=q[i].r+1;

        ans[q[i].id]=query(q[i].r)-query(q[i].l-1);

        //区间查询

    }

    for(int i=1;i<=m;i++){

        cout<<ans[i]<<'\n';

    }

}

 

静态区间不同值个数(就是上面HH的项链)

给定长度为n的整数序列a0,a1,...an-1,执行以下q个询问:

l,r  输出al,al+1,...ar-1中不同值的个数

将每个区间看作是一个平面网格(将值离散化)

找到一条路径来证明复杂度,考虑右端点移动时,记录每个点出现的次数,每个点从0到1或者1到0都会影响答案,并且每次移动时都只会变更一个点

 

树上路径不同值的个数

给定n个节点的数,每个结点有值ai,执行以下q个询问

u,v 输出ap0,ap1...apk中不同值的个数,其中p0=u,p1,..pk=v是树上简单路径

和之前Lca问题一样,每次dfs进出节点时都进行标记,然后就能够得到一串序列

这样在查询路径时,就可以在这串序列上找到一个对应的区间,但这其中可能有些数字是多余的

为了消去这些数字,可以在最后一次离开的时候增加一个数值用来抵消,例如编号为3的在序列中出现了4次,那么就在最后一次出现的位置后加一个-4*3,这样再去询问,就会发现不在路径上的点都已经被抵消掉了(因为它们一定经过了完整的进出),然后可以再用之前的方法

 

单点修改,区间不同值个数(P1903)

给定长度为n的整数序列a0,a1,...an-1,执行以下q个询问

p,x:将ap修改为x

l,r 输出al,al+1,...ar-1不同值的个数

对一个静态区间多次询问求数字的种数,显然不能一个个求,因此想到由上一次查询的结果,通过对左右端点的移动,来得到新的解

也就是从[l,r]到[l+1,r],...[l,r-1],并且这样的转移都是O(1)的

同时如果把中括号改成小括号,也就是(l,r)->(l-1,r),相当于是在平面直角坐标系上进行点的移动

那么两点之间的曼哈顿距离就是从一个状态到另一个状态所需要的最小挪动

那么对于所有的询问,最快的方法就是在坐标系上找到曼哈顿最小生成树

但也可以弱化地进行分块操作

将其分成(n/sz)个长度为sz的块,在每个块中按照右端点从小到大来移动,在每一个块中按照右端点从小到大来移动,块的大小可以设定为根号n

在这题中,因为有单点修改的影响,因此不能和静态区间一样直接离线

(也就是需要把查询操作和修改操作分别记录下来,记录查询操作时,增加一个变量来记录离本次查询最近的位置)

但我们可以将序列看作是随着时间变化的

于是可以在坐标系上再加上一个时间维度,用(l,r,t)来表示一个查询

因此,我们需要分别按照l和r分块,在同一块中的询问按照t从小到大完成,块的大小为

(参照原版莫队,先将l分块,再将r分块,同一块的按照t排序)

所以总的来说,只需要在原有的普通莫队上加一个时间维度即可

const int N=2e5+5;

const int M=1e6+5;

int sum,cnt[M],a[N];

struct Q{

    int l,r,t,id;

}qq[N],qr[N];//分别记录每一个询问以及修改的状态

//普通莫队的操作

inline void add(int x){

    sum+=!cnt[x]++;

}

inline void del(int x){

    sum-=!--cnt[x];

}

inline void update(int x,int t){//对时间变化影响的维护

    if(qq[x].l<=qr[t].l && qr[t].l<=qq[x].r){

        del(a[qr[t].l]);

        add(qr[t].r);

    }//如果修改的值在[l,r]区间内,那么会对答案有影响

    swap(a[qr[t].l],qr[t].r);

    //如果多操作了,那么下一次是要撤销,因此交换修改前的数和修改为的数

}

void solve(){

    int n,m;

    cin>>n>>m;

    int sz=pow(n,0.666);//块的大小

    for(int i=1;i<=n;i++){

        cin>>a[i];

    }

    int cntq=0;

    int cntr=0;

    for(int i=1;i<=m;i++){

        char op;

        int l,r;

        cin>>op>>l>>r;

        if(op=='Q'){

            ++cntq;

            qq[cntq].id=cntq;

            qq[cntq].l=l;

            qq[cntq].r=r;

            qq[cntq].t=cntr;

            //询问的时间戳即为该询问之前已经执行了多少次操作

        }else{

            qr[++cntr].l=l;

            qr[cntr].r=r;

        }

    }

    sort(qq+1,qq+cntq+1,[&](const Q &a,const Q &b){

        return a.l/sz==b.l/sz?a.r/sz==b.r/sz?a.t<b.t:a.r<b.r:a.l<b.l;

        //需要判断t的大小

    });

    int lcur=1,rcur=0,tcur=0;

    int ans[N];

    for(int i=1;i<=cntq;i++){

        while(lcur>qq[i].l) add(a[--lcur]);

        while(lcur<qq[i].l) del(a[lcur++]);

        while(rcur>qq[i].r) del(a[rcur--]);

        while(rcur<qq[i].r) add(a[++rcur]);

        while(tcur<qq[i].t) update(i,++tcur);

        while(tcur>qq[i].t) update(i,tcur--);//t轴进行移动

        ans[qq[i].id]=sum;

    }

    for(int i=1;i<=cntq;i++){

        cout<<ans[i]<<'\n';

    }

}

 

 

静态区间众数

给定长度为n的整数序列a0,a1,...,an-1对于询问的区间,输出任意一个众数

对于增加的操作,很显然,按照上面的思路仍然是可做的,但是在删除的时候,这样的操作就不可行了,因为并没有维护之前的众数

如果是只需要输出出现次数(P1997),可以再开一个辅助数组用来记录出现i次的数的个数

如果当前删除的数的出现次数等于当前ans,并且只有这个数出现了ans次,那么ans减一(ans是出现次数)

 

莫队算法:
莫队算法可以解决一类离线区间询问问题,适用性极为广泛。同时将其加以扩展,便能轻松处理树上路径询问以及支持修改操作

一般莫队算法:对于序列上的区间询问问题,如果从[l,r]的答案能够O(1)扩展到[l-1,r],[l+1,r],[l,r+1],[l,r-1](即与[l,r]相邻的区间)的答案,那么可以在O(n )的复杂度内求出所有询问的答案,即离线后排序,顺序处理每个询问,暴力从上一个区间的答案转移到下一个区间答案(一步一步移动即可)

排序方法:对于区间[l,r],以l所在块的编号为第一关键字,r为第二关键字从小到大排序

例如SPOJ D-query

给出一个序列和若干查询l, r,问[l, r]中有多少个不同的数

显然可以用一个数组去维护当前区间内每个数的出现次数,然后区间移动时就更新cnt数组

const int MAXN = 30005, MAXQ = 200005, MAXM = 1000005;

int sq;

//如果是在线询问,这样转移的效果并不好,因此将询问离线下来,排序后解决

struct query // 把询问以结构体方式保存

{

    int l, r, id;

    bool operator<(const query &o) const // 重载<运算符,奇偶化排序

    {

        // 这里只需要知道每个元素归属哪个块,而块的大小都是sqrt(n),所以可以直接用l/sq

        if (l / sq != o.l / sq)

            return l < o.l;

        if (l / sq & 1)

            return r < o.r;

        return r > o.r;

    }

} Q[MAXQ];

int A[MAXN], ans[MAXQ], Cnt[MAXM], cur, l = 1, r = 0;

inline void add(int p)//添数,p为下标

{

    if (Cnt[A[p]] == 0)

        cur++;

    Cnt[A[p]]++;

}

inline void del(int p)

{

    Cnt[A[p]]--;

    if (Cnt[A[p]] == 0)

        cur--;

}

int main()

{

    int n = read();

    sq = sqrt(n);

    for (int i = 1; i <= n; ++i)

        A[i] = read();

    int q = read();

    for (int i = 0; i < q; ++i)

        Q[i].l = read(), Q[i].r = read(), Q[i].id = i; // 把询问离线下来

    sort(Q, Q + q); // 排序

    for (int i = 0; i < q; ++i)

    {

        while (l > Q[i].l)

            add(--l);

        while (r < Q[i].r)

            add(++r);

        while (l < Q[i].l)

            del(l++);

        while (r > Q[i].r)

            del(r--);

        ans[Q[i].id] = cur; // 储存答案

        //注意删数时是先删后移,添数时是先移后添

    }

    for (int i = 0; i < q; ++i)

        printf("%d\n", ans[i]); // 按编号顺序输出

    return 0;

}

 

莫队是将询问离线使得在一定时间复杂度内处理问题

P7216 众数的出现次数是否大于区间的一般,如果是,那么输出这个数

const int maxc = 1e4 + 5, maxn = 3e5 + 5, maxm = 1e4 + 5;

struct node {

    int l, r;

    int id;

};

node quary[maxm];

int book[maxc], color[maxn], belong[maxn], anss[maxm];

int n, m, c, sqrtn;

bool compare(node a, node b) {

    return belong[a.l] != belong[b.l] ? belong[a.l] < belong[b.l] : (belong[a.l] & 1 ? a.r < b.r : a.r > b.r);

}

void solve(){

    scanf("%d %d", &n, &c);

    sqrtn = pow(n, 0.5);

    for (int i = 1; i <= n; i++) {

        scanf("%d", color + i);

        belong[i] = i / sqrtn;

    }

    scanf("%d", &m);

    for (int i = 1; i <= m; i++) {

        scanf("%d %d", &quary[i].l, &quary[i].r);

        quary[i].id = i;

    }

    sort(quary + 1, quary + m + 1, compare);

    int l = 1, r = 0, maxi;

    for (int i = 1; i <= m; i++) {

        node q = quary[i];

        while (r < q.r) {

            r++;

            book[color[r]]++;

        }

        while (l > q.l) {

            l--;

            book[color[l]]++;

        }

        while (r > q.r) {

            book[color[r]]--;

            r--;

        }

        while (l < q.l) {

            book[color[l]]--;

            l++;

        }

        int maxid = 0;

        for (int j = 1; j <= c; j++) {

            if (book[j] > book[maxid]) maxid = j;

        }

        if (book[maxid] > (r - l + 1) >> 1) {

            anss[q.id] = maxid;

        }

        else anss[q.id] = -1;

    }

    for (int i = 1; i <= m; i++) {

        if (anss[i] == -1) printf("no\n");

        else printf("yes %d\n", anss[i]);

    }

}

 

回滚莫队

有些题目在区间转移时,可能出现增加或者删除无法实现的问题,贼只有增加不可实现或者只有删除不可实现的时候,可以用回滚莫队在O( )时间内解决问题

回滚莫队的核心思想是只能实现一个操作,就只使用一个操作,剩下的用回滚解决,回滚后再用增加计算,像下面就是保证用l向左移动替代一般莫队中的右移

只使用增加操作的回滚莫队

 

历史研究

给定一个长度为n的数组A和m个询问,每次询问区间中重要度最大的数字,要求输出器重要度

一个数字的重要度定义为i乘上i在区间内出现的次数

在增加过程中更新答案是显然的,因为如果增加会影响答案,那么心答案必然是刚刚增加的数字的重要度,但如果删除导致答案发生变化,就不能确定新的数字应该是哪个,因此一般的莫队不能解决,因此要用回滚莫队实现

首先仍然是一般莫队思路,对询问按照左端点编号升序为第一关键字,右端点升序为第二关键字,

按顺序处理询问(每段内询问的左端点在同一个块,右端点递增)

如果询问左端点所属块和上一个询问左端点所属块不同,那么将莫队区间的左端点初始化为B(也就是上一个询问对应的莫队区间)的右端点+1,莫队区间的右端点初始化为B的右端点,表示初始的空区间

如果询问的左右端点所属的块相同,那么直接扫描区间回答询问

如果询问的左右端点所属的块不同:
(左端点在块1中,右端点在块2中,我们初始化莫队区间的左右端点都在块1的右端点)如果询问的右端点大于莫队区间的右端点,那么不断增大莫队区间的右端点直至等于询问的右端点;不断扩展莫队区间的左端点直至莫队区间的左端点等于询问的左端点(这一步就全是增加,可以直接计算)(右端点向右移动,左端点向左移动)

回答询问

撤销莫队区间左端点的改动,使莫队区间的左端点回滚到B的右端点+1,用刚才保存的信息来恢复现场(对于左端点在同一个块中的,因为我们的设定,可以让l回到原点,r接着走)

如果询问区间的左右端点都在右端点的左侧,那么实际上可以不用具体处理,先走r,把计数的数组先减,并不用更新答案

然后再走l,一定会把减成负数的计数的数加回来,这时候再更新答案即可

const int M=2e5+100;

int a[M],b[M];

int L[M],R[M],blo[M];

struct Q{

    int l,r,id;

    bool operator<(const Q &q){

        return blo[l]==blo[q.l]?r<q.r:l<q.l;

    }

}q[M];

int cnt[M];

i64 tmp;

inline void add(int x,int val=1){

    cnt[a[x]]+=val;

    tmp=max(tmp,1ll*cnt[a[x]]*b[a[x]]);

}

inline i64 cal(int l,int r){

    static int c[M]{};

    //不加static会报错

    i64 res=0ll;

    for(int i=l;i<=r;i++) ++c[a[i]];

    for(int i=l;i<=r;i++) res=max(res,1ll*c[a[i]]*b[a[i]]);

    for(int i=l;i<=r;i++) --c[a[i]];

    return res;

}

void solve(){

    int n,m;

    cin>>n>>m;

    int sz=sqrt(n),bl=n/sz;    

    for(int i=1;i<=n;i++){

        cin>>a[i];

        b[i]=a[i];

        blo[i]=(i-1)/sz+1;

    }

    for(int i=1;i<=m;i++){

        cin>>q[i].l>>q[i].r;

        q[i].id=i;

    }

    sort(b+1,b+n+1);

    int tot=unique(b+1,b+n+1)-b-1;

    for(int i=1;i<=n;i++){

        a[i]=lower_bound(b+1,b+tot+1,a[i])-b;

    }

    sort(q+1,q+m+1);

    for(int i=1;i<=bl;i++){

        L[i]=R[i-1]+1;

        R[i]=L[i]+sz-1;

    }

    if(R[bl]<n){

        ++bl;

        L[bl]=R[bl-1]+1;

        R[bl]=n;//不完整的块

    }

    i64 ans[M]{};

    for(int i=1,pre=1;i<=bl;i++){

        memset(cnt,0,sizeof(cnt));

        int r=R[i],l;

        tmp=0ll;

        while(blo[q[pre].l]==i){

            l=R[i]+1;

            if(q[pre].r-q[pre].l<=sz){//在同一块中,暴力计算

                ans[q[pre].id]=cal(q[pre].l,q[pre].r);

                ++pre;

                continue;

            }

            while(q[pre].r>r) add(++r);

            i64 cur=tmp;

            while(q[pre].l<l) add(--l);

            ans[q[pre].id]=tmp;

            tmp=cur;

            while(l<=R[i]) --cnt[a[l++]];//重置,不用考虑减的问题

            ++pre;

        }

    }

    for(int i=1;i<=m;i++){

        cout<<ans[i]<<"\n";

    }

}

 

 

数学:

O(1) 求1到n的前缀异或和

int calc(int n){

    if(n%4==0){

        return n;

    }else if(n%4==1){

        return 1;

    }else if(n%4==2){

        return n+1;

    }else{

        return 0;

    }

}

 

Nicomachaus定理

 

 

Cf 2029A
给定一个数字集合[l,r],问其中有多少个数字能整除的数字个数大于等于k

除了从小到大进行枚举以外,可以直接计算满足要求的最大数字是多少从而直接得到结果

也就是ans=max(0,r/k-l+1)

 

Cf 2040 C

给定1到n的整数的排列组合,引入的和为每个区间中的最小值

给定n,问最大的和的所对应的排列中的第k个

要取得最大,因此一定是较小的元素在排列的边界,由此可以尝试去构造方案,再考虑到字典序就是根据元素大小进行划分的,因此可以逐个考虑

于是具体的排列就可以考虑1在当前可选位置的最前面还是最后面,同理,判断2是在最前面还是最后面,也就是将元素从小到大逐个填入,因此最大值对应的总的方案数是1<<(n-1)

void solve(){

    int n;

    cin>>n;

    i64 k;

    cin>>k;

    k--;

    if(n<=60 && k>=(1LL<<(n-1))){// avoid 1ll<<(n-1) overflow

        cout<<-1<<'\n';

        return;

    }

    vector<int> p(n);

    int l=0,r=n-1;

    // filing position based on the remaining options

    for(int i=1;i<n;i++){

        if(n-i-1>60 || k<(1ll<<(n-i-1))){

            p[l++]=i;

        }else{

            p[r--]=i;

            k-=(1ll<<(n-1-i));

        }

    }

    p[l]=n;

    for(int i=0;i<n;i++){

        cout<<p[i]<<" \n"[i==n-1];

    }

}

 

Cf 2053c

给定数字n和k,问对于区间[1,n],如果使用二分递归进行查找,对于奇数段,数值增加(n+1)/2,且当区间长度小于k时不再继续操作,则最终数字为多少

如果直接递归进行操作那么会爆内存

因为每次操作进行划分的过程中,得到的区间长度都是相同的,只是具体的数值上有所差距,因此考虑能否只根据一段来得到其他所有段的结果

注意到第一轮之后的分段分布时中心对称的,也就是x+y=n+1,于是可以通过直接模拟分段数和长度来简单计算

如果将一个线段[l,r]分割为[l,m-1]和[m+1,r],那么其左端点之和就会从l变为2*l+(r-l)/2+1,由于r-l是保持不变的,因此所有线段的左端点之和也可以同样保持不变

于是根据中心对称的和,我们只需要记录所有奇数情况下的段数即可,因为都可以两两配对成中间和的两倍

void solve(){

    i64 n,k;

    cin>>n>>k;

    i64 sum=n+1;

    i64 c0=1,cnt=0;

    while(n>=k){

        if(n&1){

            cnt+=c0;

        }

        n>>=1;

        c0<<=1;

    }

    cout<<sum*cnt/2<<'\n';

}

 

 

Cf 2048b

整除的判断

给定n和d,构造出一个由n!个d组成的数字,判断该数字能否被1,3,5,7,9整除

显然数字一定能够被1整除,被3整除,5整除,9整除的判断都是容易的

因此着重在于如何判断能否被7整除

被7整除的一种判断方法:如果把一个数字分成若干个3位数组(第一个数位组可能短于3位),这些数位组的符号交换和能被7整除,那么这个数字就能被7整除,例如123456789能被7整除,因为1-234+569能被7整除

同时,由于我们构造的数字长度是n!,因此n>=3时,可以被分为长度为6的若干块,而每一块内部的交替和都是0,因此当n>=3或者d=7时,这个数可以被7整除

另一种方法:
当取一个由等于d的n!位数组成的数字,它总是能被等于d的(n-1)!位数整除,因为相当于是用n个(n-1)!个d组成的数构成的

因此,如果n=k能被某个数整除,那么n=k+1也能被某个数整除

于是,存在着这样一个整数m,对于所有整数n>=m,判断的结果都是相同的,因此当我们的n>m时,直接使用m取判断即可

由样例可以得到7!个1得到的数就已经能被1,3,5,7,9整除了(其余数都可以看作是在这个数的基础上乘上d),于是m一定小于等于7,因为7也足够小了,因此可以将其视作是我们的边界,实际上这个边界应该是6

 

CCPC 2024重庆

引理:(x,y)=1,x/y是有限小数当且仅当y=

于是不妨设b= ,d=

那么有 ,从而,为了保证是有理分式,要求 ,因此所求的

因此可以计算出对应的逆元从而计算

void solve(){

    i64 a,b;

    cin>>a>>b;

    int t=1;

    while(b%2==0){

        b>>=1;

        t<<=1;

    }

    while(b%5==0){

        b/=5;

        t*=5;

    }

    if(b==1){// 已经满足了有理分式的条件

        cout<<"0 1"<<'\n';

        return;

    }

    i64 x,y;

    exgcd(b,t,x,y);

    // 此时t是原数,b是p,从而y就是得到的逆元

    y=y*a;

    y=(y%b+b)%b;

    a=y;

    pair<i64,i64> ans={LLONG_MAX,LLONG_MAX};

    for(i64 i=1;i*b<=1e9;i*=2){

        for(i64 j=1;i*j*b<=1e9;j*=5){

            i64 q=i*j;

            i64 c=((q*a+b-1)/b)*b-q*a;// 与-q*a(mod b)同余的最小正数

            i64 d=q*b;

            if(c<ans.first){

                ans={c,d};

            }

        }

    }

    cout<<ans.first<<' '<<ans.second<<'\n';

}

 

 

Cf 2038L

3种木板,长度分别为18,21,25,都需要n块

问最少需要多少块长度为60的木板才能得到

具体木板的长度是不重要的,可以化成a,b,c三种长度,每次如果有c,那么只能是a,b,c中选择一种和c进行组合,剩余情况下,除了3*b这种情况都是可以任取的

因为有c存在时,一块木板只能提供出两块我们所需要的板,因此先不考虑c,优先对a,b的搭配进行操作,a,b中,我们可以看作每次都能产生出3块我们所需要的板,具体来说,我们可以贪心地先进行2*b+a的搭配,再进行a*3的搭配,具体如何操作的并不关心,具体剩下的哪种板是哪种数量也并不影响,因为之后要让c进行搭配,此时无论是a,b还是c都只能选择其中一种去组合,因此只要计算出对a,b这样操作后剩余的个数即可

 

牛客24多校9 I

问[L,R]区间中有多少个数可以从正中间划分成两个完全平方数,L,R的数据范围都是1e60

因为将一个数字分成两部分后两边的取值都只有1e30的范围,这在__int128可表示的范围内

可以根据前半部分的取值是否在数据范围的边界来计算后半部分的取值范围

为了避免精度的误差可以手动二分开根

bool is_sqr(i128 x){

    i128 l=-1,r=1e16;

    while(r>l+1){

        i128 mid=(l+r)>>1;

        i128 m=mid*mid;

        if(m<=x){

            l=mid;

        }else{

            r=mid;

        }

    }

    if(l*l==x){

        return true;

    }else{

        return false;

    }

}

 

i64 cnt(i128 x){

    i128 l=-1,r=1e16;

    while(r>l+1){

        i128 mid=(l+r)>>1;

        i128 m=mid*mid;

        if(m>=x){

            r=mid;

        }else{

            l=mid;

        }

    }

    return r;

}

 

i64 range(i128 l,i128 r){//该区间中的平方数个数

    return cnt(r+1)-cnt(l);

}

 

void solve(){

    int n;

    cin>>n;

    string L,R;

    cin>>L>>R;

    int m=n/2;

    i128 l1=0,r1=0;

    for(int i=0;i<m;i++){

        l1=l1*10+L[i]-'0';

        r1=r1*10+R[i]-'0';

    }

    i128 l2=0,r2=0;

    for(int i=m;i<n;i++){

        l2=l2*10+L[i]-'0';

        r2=r2*10+R[i]-'0';

    }

    if(l1==r1){

        if(!is_sqr(l1)){

            cout<<0<<'\n';

            return;

        }else{

            cout<<range(l2,r2)<<'\n';

            return;

        }

    }

    i128 base=1;

    for(int i=0;i<m;i++){

        base*=10;

    }

    i128 ans=0;

    if(is_sqr(l1)){

        ans+=range(l2,base-1);

    }

    ans+=(i128)range(l1+1,r1-1)*range(0,base-1);

    if(is_sqr(r1)){

        ans+=range(0,r2);

    }

    // cout<<ans<<'\n';

    string s="";

    while(ans){

        char c=ans%10+'0';

        s=c+s;

        ans/=10;

    }

    cout<<s<<'\n';

}

 

 

牛顿迭代法:对于一个已知的x值,每一次根据函数在这一点的导数,把x移动到,切线与x轴相交的地方。即x[n+1]=x[n]-f(x)/f'(x),可以证明结果会趋近于函数的一个解

 

二分法解方程

CCPC2024 深圳 I

求不定方程a^k-b^k=n的正整数解的数量

因为 ,因此a-b<=n^(1/k),因此可以枚举a-b的值然后解剩下的多项式方程

具体求解可以使用二分解方程

void solve(){

    i64 n;

    int k;

    cin>>n>>k;

    int ans=0;

    int lim=pow(n,1.0/k)+10;// a-b的上限

    for(int i=1;i<=lim;i++){

        if(n%i==0){

            // 二分法解方程

            i64 l=1,r=pow((n/i/k),1.0/(k-1))+3;

            while(l<r){

                i64 mid=(l+r)>>1;

                if(fp(mid+i,k)-fp(mid,k)<n){

                    l=mid+1;

                }else{

                    r=mid;

                }

            }

            i64 x=fp(l+i,k)-fp(l,k);

            if(x==n){

                ans++;

            }

        }

    }

    cout<<ans<<'\n';

}

 

 

cf1954c

两数之和相等时,两个数越接近那么这两个数的乘积就越大

因此当一个数的高位大于另一个数的同一位,那么在这位之后,大的那个数应该尽可能拿小的数,这样才会让二者尽可能接近

void solve() {

    string x, y;

    cin >> x >> y;

    int n = x.size();

    int flag=0;

    for(int i=0;i<n;i++){

        if(flag==0 && x[i]!=y[i]){

            if(x[i]<y[i]) swap(x[i],y[i]);

            flag=1;

        }else if(flag){

            if(x[i]>y[i]) swap(x[i],y[i]);

        }else continue;

    }

    cout << x << '\n' << y << '\n';

}

 

牛客24多校E

求一个数使得与给定数的XOR和gcd相同,并且要求求得的数要严格小于给定数

规律:奇数就是异或上1,偶数是异或最低位1,特别的,如果偶数的二进制中只有一个1,那么是无解的

void solve(){

    i64 n;

    cin>>n;

    if(n==1){

        cout<<-1<<'\n';

        return;

    }

    if(n&1){

        cout<<(n^1)<<'\n';

        return;

    }

    if(__builtin_popcountll(n)==1){

        cout<<-1<<'\n';

        return;

    }else{

        for(int i=1;i<=59;i++){

            if((n>>i)&1){

                cout<<(n^(1ll<<i))<<'\n';

                return;

            }

        }

    }

    cout<<"-1\n";

}

如果是枚举因数后判gcd会T

 

cf23b

本质上是要构造出一种关系网(是自行构造,因此要考虑怎样构造能使最终结果最优),使得最终剩余的人最多,输出最多的人数

一个人如果要留下来,那么他的朋友中至少要离开一个,否则最终会被全部踢出,因此就是要牺牲最小的人数来保全剩下的人数,并且每一个牺牲的人的朋友数都要剩下的人的朋友数,否则是后者先离开

假如总共有 n 个人,我们牺牲一个人,为使剩下的人全部有可能留下,他需要与其他每一个人都为朋友,这样他就有 (n−1) 个朋友,这样显然是不行的,因为一个人最多只有(n−1) 个朋友,其他人不可能比他再多了

假如我们牺牲两个人,1和2。那么我们可以构造这样一种情况:1与 3∼n 都为朋友,2与 3∼n 也为朋友,3∼n 之间互为朋友

这样操作之后可以使剩下的所有人都留下了,因此这种情况是最优的

 

cf1945B

令t=lcm(a,b)(a,b的公倍数),显然在这个时刻,装置一和二都会在此时放出烟花

并且在此时,对于任意一个装置,我们能看到[m/b]+1(下取整)个烟花

证明最优性:这两个装置,实质上是等价的,因此只考虑其中一个即可,假设其中一个烟花在x时刻即将结束,那么对于x,x+1显然是在x时刻能够看到的烟花数更多,此时可以看到在x,x-a...x-a*[m/a]放出的烟花,因此可以同时看到不超过[m/a]+1个烟花,对于另一个装置同理,因此,在t时刻能看到的烟花数目就是最优解

(猜结论,公倍数公因数什么往上去套)

 

(有类似于辗转相减特征的问题可以往gcd去考虑)

牛客24多校3 B

给定n类操作,每次可将x更新为|x-ai|(保证一直是正的)

求从D开始可以得到的最小值

与gcd相关

首先显然如果只选择一个数a1进行操作,那么x会一直递减到小于等于a1,然后在两个数中循环

于是我们可以得到的最小的两个值是d mod a1,以及a1-(d mod a1)

不妨假设n=2,且a1<a2

令g=gcd(a1,a2)

通过x=abs(x-a1),我们可以将数x更新为a1-(x mod a1)

通过x=abs(x-a2),我们可以将数x更新为a2-(a1-x mod a1)=a2-a1+x mod a1

因此,我们能够生成a1内,所有模g与D同余的数,同理也能够生成a1内所有模g与-D同余的数,并且可以进一步推广到a2

于是通过数学归纳法就可以推广到任意的n(只需要取g=gcd(a1,a2,...,an)即可)

因此最终的答案就是D mod g和D-D mod g 两者取min即可

 

牛客24多校4 H

给两种操作:排序数组在值域上翻折一个前缀或翻折一个后缀,问任意次操作能做到的最小极差

首先给出是结论,将输入数组排序,则答案为g=gcd(a2-a1,a3-a2,...,an-a[n-1])

n=1时答案为0,所有数字一样答案也为0

证明:

首先显然答案不会比g更小

考虑差分数组d=a[i+1]-a[i],则初始d的元素都是g的倍数,因此每个数都可以表示成a[i]=a[1]+k[i]g的形式

那么在一次操作后,显然每个数仍然可以用这种形式表示,并且他们排序后求相邻项之差的gcd仍然是kg的形式,不会更小

证明总能达到g,将数组去重后排序得到新数组b,如果数组大小k>=3,那么选择下标p=k-1执行操作二,一定能减小极差

而k=2时,已经能够做到极差是g了

 

cf2007C

给定一个序列,可以任意次单点+A或者+B,最小化极差

常见的转化,单点+A或+B等价于单点+/-gcd(A,B)(根据贝祖定理,gcd能转变为两个数的线性组合)

于是令d=gcd(a,b),于是在mod d的意义下考虑

void solve(){

    int n,a,b;

    cin>>n>>a>>b;

    int g=gcd(a,b);

    vector<int> c(n);

    for(int i=0;i<n;i++){

        cin>>c[i];

        c[i]%=g;

    }

    sort(c.begin(),c.end());

    int ans=c[n-1]-c[0];

    //可以任意选择让哪两个数变为最大和最小

    for(int i=1;i<n;i++){

        ans=min(ans,c[i-1]+g-c[i]);//最小的答案一定是在相邻的中产生

    }

    cout<<ans<<'\n';

}

 

 

牛客24多校4 F

对树上节点u定义f(u)=sigma(dis(u,v)),给定x求满足存在两点u,v使得f(u)-f(v)=x成立的树最少有多少个节点

记sq=根号x取整

如果n=sq*sq,那么答案是2*sq+1

如果n>sq*(sq+1),那么答案是2*sq+3

否则如果n-sq*sq与sq模2同余,那么答案是2*sq+2否则答案为2*sq+3

因为树的形状不定,因此可以先只考虑u,v,具体来说,我们先固定u,然后考虑v,不过在考虑v之前要先确定u,v之间的距离,每次插点要让f(u)-f(v)尽可能大,可以发现要插到v的子树上,这样可以增加一个u,v之间的链长,因此我们构造出的距离显然是u到v的链长的若干倍,再反过来考虑,我们固定点的个数,最大能得到的长度是多少,这和点的个数有关,因为我们最终的结果是l1*l2,总的点的个数实际上是l2+l1+1,因此点数为2k+1,2k+2,2k+3都是可以得到确定的最大值分别为k^2,k*(k+1),(k+1)^2

因此之后我们要处理的就是这三个数中间的数字,也就是中间那些数字需要至少多少个点才能构成

 

概率最大

牛客24多校5 L

均匀分配

已知第i场比赛的获胜概率是a[i]/100

可以不停的执行如下操作:
选择第i+1场的胜率减少1/100,是第i场的胜率增加1/100

希望最大化赢得所有比赛的概率

显然每次进行操作时所有比赛的总和并没有发生改变

因此就是要让各个数值尽可能接近

于是当a[i]<a[i+1]时,给a[i]增加1,给a[i+1]减少1,答案一定不会变劣

const int mod=998244353;

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    //尽可能平均分配

    i64 sum=0ll;

    i64 ans=1ll;

    for(int k=1;k<1000;k++){

        bool ok=false;

        for(int i=0;i<n-1;i++){

            if(a[i]<a[i+1]){

                a[i]++;

                a[i+1]--;

                ok=true;

            }

        }

        if(!ok){

            break;

        }

    }

    for(int i=0;i<n;i++){

        ans*=(a[i]%mod);

        ans%=mod;

    }

    cout<<ans<<'\n';

}

在调整次数不会很多的情况下,可以直接暴力操作,并且判断是否还需要进一步修改即可

 

卷积:
FWT:
CCPC2024 重庆 A

 

 

矩阵:
POJ 3323

构建一个2n*2n的矩阵

A  I   ,该矩阵进行k+1次幂后得到的结果是  Ak+1   Ak+Ak-1+...+A+I  ,取右上角的矩阵然后减

0  I                                       0          I

一个单位矩阵就是所求结果

int n;

int mod;

struct martix {

    ll a[100][100];

};

martix mul(martix a, martix b) {

    martix ret;

    for (int i = 1; i <= 2 * n; i++) {

        for (int j = 1; j <= 2 * n; j++) {

            ret.a[i][j] = 0;

            for (int k = 1; k <= 2 * n; k++) {

                ret.a[i][j] = (ret.a[i][j] + a.a[i][k] * b.a[k][j]) % mod;

            }

        }

    }

    return ret;

}

martix powmod(martix a, int b) {

    martix ret;

    memset(ret.a, 0, sizeof(ret.a));

    for (int i = 0; i < 80; i++) ret.a[i][i] = 1;

    while (b) {

        if (b & 1)ret = mul(ret, a);

        a = mul(a, a);

        b >>= 1;

    }

    return ret;

}

int main() {

    martix A, S;

    memset(A.a, 0, sizeof(A.a));

    int k;

    cin >> n >> k >> mod;

    for (int i = 1; i <= n; i++) {

        for (int j = 1; j <= n; j++) {

            cin >> A.a[i][j];

        }

        A.a[i][i + n] = 1;

        A.a[i + n][i + n] = 1;

    }

    S = powmod(A, k + 1);

    for (int i = 1; i <= n; i++) {

        int flag = 0;

        for (int j = 1; j <= n; j++) {

            if (i == j)S.a[i][j + n]--;

            if (flag) cout << " ";

            flag = 1;

            cout << (S.a[i][j + n] + mod) % mod;

        }

        cout << endl;

    }

    return 0;

}

 

矩阵乘法:
矩阵乘法的性质:矩阵乘法不满足交换律;矩阵乘法满足结合律

应用:
给定n个点,m个操作,构造O(n+m)的算法输出m个操作后各点的位置,操作有平移、缩放、翻转和旋转

这里的操作是对所有点同时进行的,利用矩阵乘法可以在O(m)的时间内八所有操作合并为一个操作,然后每个点与该矩阵相乘即可直接得出最终该点的位置

 

利用类似快速幂的方式计算矩阵的幂(二分快速求幂)

 

POJ3233

给定矩阵A,求A+A^2+A^3+...+A^k

这题两次二分

首先由上面可知A^i可以二分求出,然后需要对整个题目的数据规模k进行二分,例如,当k=6时,有

A+A^2+A^3+A^4+A^5+A^6=(A+A^2+A^3)+A^3*(A+A^2+A^3)

应用这个式子后,规模k减小了一半,在二分求出A^3后再递归地计算A+A^2+A^3,即可得到原问题的答案

 

VOJ1049

顺次给出m个置换,反复使用这m个置换对初始序列进行操作,问k次置换后的序列

因为任何一个置换都可以表示成矩阵的形式,可以将这m个置换”合并“起来,然后执行这个置换k/m次,有余数就模拟

 

给定n和p,求第n个fibonacci数mod p的值,n不超过2^31

根据前面的思路,我们需要构造一个2x2的矩阵,使得它乘以(a,b)得到的结果是(b,a+b),这样每多乘一次这个矩阵,这两个数就会多迭代一次,于是第n个fibonacci数就是将这个矩阵自乘n次,然后再对这个结果乘(0,1)即可

这个2x2矩阵即为   [0 1;1 1]

 

VOJ1067

我们可以用上面的方法求出求出仍和一个线性递推式的第n项,对应矩阵的构造方法为:在右上角的(n-1)*(n-1)的小矩阵中的主对角线上填1,矩阵第n行填对应的系数,其他地方都填0,例如对于f(n)=4*f(n-1)-3*f(n-2)+2*f(n-4),其对应的矩阵是[0 1 0 0; 0 0 1 0;0 0 0 1;2 0 -3 0 4]

 

(离散数学中的知识点)给定一个有向图,问从A点恰好走k步(允许重复经过边)到达B点的方案数mod p的值

将给定图转为邻接矩阵,即A(i,j)=1当且仅当存在一条边i->j,这样要求经过k步的路径数,只需要求出A^k,然后其中的(a,b)就是答案

 

用1x2米的多米诺骨牌填满MxN的矩形有多少种方案,M<=5,N<2^31,输出答案mod p的结果

利用类似状态转移的思想,将各种状态的转移关系画成一个有向图,那么问题就等价于(以M=3,也就是3行为例)从状态111出发,恰好经过n步回到这个状态有多少种方案

 

POJ2778(ac自动机+矩阵快速幂)

检测所有可能的n位DNA串有多少个DNA串中不含有指定的病毒片段,合法的DNA只能由ACTG四个字符构成,题目将给出10个以内的病毒片段,每个片段长度不超过10(也就是长度为n且不包含m个子串的种类数)

 用病毒串构造AC自动机,用fail指针跑出字符串状态间的到达关系和危险点,危险点即到达即代表某串含有病毒的点。

所有病毒串构造一个AC自动机,这个AC自动机可以看作一张有向图,图上的每个顶点就是Trie树上的结点,每个结点都可以看作是某个病毒串的前缀,Trie树的根则是空字符串。

而从根出发,在AC自动机上跑,经过k次转移到达某个结点,这个结点所代表的病毒串前缀可以看作长度为k的字符串的后缀,如果接下去跑往ATCG四个方向转移,就能到达新的结点,转移到新的长k+1字符串的后缀。

用AC自动机构造出矩阵,初始矩阵中的mat[i][j]表示i点走一步到达j点的方法,由于危险点不可到达所以危险点的行和列为0。

只要算出后缀含有的点即可,因为从字符末尾添加字符进行转移的,因此能到这一步就说明其前缀中一定没有病毒串,而可能构成病毒串的就是在末尾继续添加字符,同时矩阵是通过相互到达传递的,其后的点只有这个危险点能到达,危险点的行和列全是0,则其后的点也不会参与运算。可以这样说,0行里危险点的列和其后的列从来都是0,因为不能到达。

 

偏序集

偏序集是由集合S和S上的偏序关系R构成的,记作(S,R)

对于二元关系 R⊆S×S,如果R 是自反的,反对称的,传递的,那么R 称为偏序关系。

自反性

a≤a,∀a∈S

反对称性

∀a,b∈S,若 a≤b 且 b≤a,则 a=b

传递性

∀a,b,c∈P,若 a≤b 且 b≤c,则 a≤c

注意:≤ 符号是偏序关系的符号,并不是“小于等于”的意思,但“小于等于”也是一个偏序关系,一个典型偏序集的例子便是(Z,≤),Z 表示整数集

对于元素 x,如果 x<y 且不存在z 使得x<z<y,那么y 就是x 的覆盖元素,在哈斯图中连出一条 x−>y 的有向边。通过覆盖关系生成的图就是哈斯图

 

由于偏序关系满足反对称性,所以哈斯图中一定不能出现回路(可利用传递性和类似向量的想法去说明)。

不难发现,哈斯图是一个有向无环图( DAG )

一个经典的应用就是拓扑排序:从偏序构造一个相容的全序

Dilworth 定理

对于任意有限偏序集,其最大反链中元素的数目必等于最小链划分中链的数目(最小链划分就是最少几条链可以将图覆盖)

补充一下对链和反链的定义:

设 C 是偏序集的一个子集,如果C 中元素互相可比(a≤b 或b≤a,则称a ,b 可比),那么称 C 是链;如果 C 中元素互相不可比,则称C 是反链。

这个定义非常形象,链对应到哈斯图上就是一条从下至上的 “链”(有向路径)

因此至少需要几条链才能覆盖哈斯图可以利用Dilworth 定理,所以求出最大反链即可。一个巧妙的做法就是将偏序关系置反,再次求一次最长链。(P1020)

 

质数相关(埃氏筛,线性筛等)

计算质数(T204),范围内最接近的两个质数(T2523,要找一个范围内的最小质数,可以先对数据范围的质数进行预处理,即全部找出来,然后利用二分查找即可)

暴力做法:将其对每一个比其小的数进行取余运算(除1),如果不存在模为0的数,说明是质数

优化暴力:用2~n开根内的数去除

厄拉多塞筛法(埃氏筛):

核心思想是筛掉那些已经被判断为质数的倍数

//初始默认所有数为质数

        vector<bool> signs(n, true);

        for (int i = 2; i < n; i++) {

            if (signs[i]) {

                count++;

                for (int j = i + i; j < n; j += i) {

                    //排除不是质数的数

                    signs[j] = false;

                }

            }

        }

//count是质数个数记录器

 

线性筛(欧拉筛):
核心思想:保证每个合数只被标记1次(本质是被最小质因子标记),如果把每个数都乘上小于等于它的质数,例如4,它的最小质因子是2如果乘上任何超过2的质数,结果的最小质因子都是2,会不符合被最小质因子划掉的要求例如12,仍会被4*3,6*2划掉两次,所以正确的做法是每个数乘上小于等于lpf[x]的质数,lpf[x]指的是x的最小质因子

并且我们需要用一个数组来存储,只需要得到第一个 x%i==0的数

int init = []() {

    bool np[MX + 1]{};

    for (int i = 2; i <= MX; ++i) {

        if (!np[i]) primes.push_back(i);

        for (int p: primes) {

            if (i * p > MX) break;

            np[i * p] = true;

            if (i % p == 0) break;

        }

}

 

离散数学:
牛客24多校6 B

给定一个正n边形,连接所有距离为k的顶点,求多边形内部被划分成了多少个区域

可以手画几个图,找规律可得2k=n时答案为n,否则答案为n*min(k,n-k)+1

证明可以用欧拉定理V-E+F=2,每条线段相交的数量可以从小的那侧的顶点数推出,这样同时可以计算出公式中V和E的值,也就可以解出F的值

(线段数量也就是里面的顶点数除以二,V和E就是对每条边统计上它上面的顶点数和这条边被切成的段数)

 

 

快速幂取模

long long pow_mod(long long a,long long b){

    long long res=1;

    while(b){

        if(b&1)res=res*a%mod;

        a=a*a%mod;

        b>>=1;

    }

    return res;

}

 

矩阵快速幂
        

const int num=2,mod=10000;

struct Mat{

    int m[num][num];

};

Mat I{

    1,0,

    0,1

};

Mat Mul(Mat a, Mat b){

    Mat ans;

    for(int i=0; i<num; i++)

        for (int j=0; j<num; j++){

            ans.m[i][j]=0;

            for (int k=0; k<num; k++){

                ans.m[i][j]+=(a.m[i][k]*b.m[k][j]);

                ans.m[i][j]%=mod;

            }

        }

    return ans;

}

Mat pow(Mat a, long long b){

    Mat ans=I;

    Mat sum=a;

    while (b>0){

        if (b & 1)

            ans=Mul(ans, sum);

        sum=Mul(sum, sum);

        b>>=1;

    }

    return ans;

}

 

 

快速乘取模

long long mul_mod(long long a,long long b){

    a%=mod;b%=mod;

    long long c=a*b/mod;

    long long ans=a*b-c*mod;

    if(ans<0)ans+=mod;

    else if(ans>=mod)ans-=mod;

    return ans;

}

 

真因数:

制作1-n的真因数的表

这个可以作为解题的预处理用

一种就是对每个数遍历求解,但效率太低

我们可以采取另一种方法,那就是枚举因子

vector<vector<int>> divisors(MX);

int init = [] {                                //预处理函数

    for (int i = 1; i < MX; i++) {

        for (int j = i * 2; j < MX; j += i) {

            divisors[j].push_back(i);

        }

    }

    return 0;

}();

自然数n平均有O(log n)个因子

 

gcd(最大公约数问题):
核心:gcd(a,b)=gcd(b,a mod b)

迭代法:
int gcd(int a, int b) {

while (b != 0) {

int tmp = a;

a = b;

b = tmp % b;

}

return a;

}

利用异或

int gcd(int x, int y){

while(y^=x^=y^=x%=y);

return x;

}

更相减损法:

大整数取模运算的时间复杂度较高,而加减法的时间复杂度较低,因此对于大整数,可以用加减代替乘除求出最大公约数,并且若2|a,2|b,则gcd(a,b)=2gcd(a/2,b/2)

Big gcd(Big a, Big b) {

// 记录a和b的公因数2出现次数

int atimes = 0, btimes = 0;

while (a % 2 == 0) {

a >>= 1;

atimes++;

}

while (b % 2 == 0) {

b >>= 1;

btimes++;

}

for (;;) {

// a和b公因数中的2已经计算过了,后面不可能出现a,b均为偶数的情况

while (a % 2 == 0) {

a >>= 1;

}

while (b % 2 == 0) {

b >>= 1;

}

if (a == b) break;

// 确保 a>=b

if (a < b) swap(a, b);

a -= b;

}

return a << min(atimes, btimes);

}

 

牛客24多校9 C

N*N的矩阵,i行j列的元素为gcd(i,j)

有q次操作,每次对于指定的行或者列乘一个成定的数

求每次操作后矩阵的和

对于gcd(i,j),有性质gcd(i,j)= ( 是欧拉函数)

 

25 杭电 春 10 1003

对于两个正整数,如果这两个数的最大公约数等于它们差的绝对值时,我们认为这是个好数对

给定一个长度为n的数组a,计算其中有多少个好数对

由gcd的性质,可以得到gcd(a,b) = gcd(b, a - b) = a – b

不妨令d = a – b,那么b = kd ,a = (k + 1)d,可以证明这样的a和b一定是满足要求的

因此我们只需要枚举d并枚举出kd和(k + 1)d即可,同时可以预先用桶来维护每个元素存在的个数

 

Cf 2048d

给定l,r,G,要求在l,r范围内,找到两个数a,b,使得a,b的gcd是G,且|a-b|最大

显然可以想到a和b都是G的倍数,同时可以简化成G的倍数系数问题

也就是考虑G为1对应的情况

相对于从具体的a,b开始考虑,因为要让两个数字的距离最大,因此可以以距离为枚举的部分进行判断,这样当判断到一个距离可行时,就可以直接退出循环,同时能保证最大值

如果是枚举a再枚举b,显然无法得到这样的结果

需要证明的是这样的复杂度是正确的,证明过程较为复杂,但可以通过两个素数之间的平均距离来进行猜测

void solve(){

    i64 l,r,g;

    cin>>l>>r>>g;

    l=(l+g-1)/g;

    r/=g;

    for(i64 d=r-l;d>=0;d--){

        for(i64 a=l;a+d<=r;a++){

            i64 b=a+d;

            if(__gcd(a,b)==1){

                cout<<a*g<<' '<<b*g<<'\n';

                return;

            }

        }

    }

    cout<<-1<<' '<<-1<<'\n';

}

 

 

 

cf2015E

给定一个数组,数组中的元素可以按照任意顺序进行排序,求出gcd(a1)+gcd(a1+a2)+..+(a1+...+an)的最小值

设g是数组a的最大公因子gcd,将每个元素ai除以g,最后将结果乘g即可

因为数组的gcd一定只会一直减小,于是可以构造出一种贪心算法:从一个初值为空的数组b开始,在数组b的末尾添加一个元素,该元素和已有数组的b的gcd值最小

结论:gcd最多经过10次迭代就会达到1,之后,其余元素可以按任意顺序添加

证明:设A是数组b当前前缀的最小可能gcd,设B是A<B的最优答案,在这种情况下,可以先放入A,再按照相同的顺序写出序列B,由于A+gcd(A,B)<=B,因此这样操作是最优的

void solve(){

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    int g=0;//当前的g

    i64 ans=0;

    int cnt=n;

    while(true){

        int m=a[0];

        for(int i=0;i<n;i++){

            int x=gcd(g,a[i]);//添加下一个元素

            if(x<m){

                m=x;

            }

        }

        if(m==g){

            break;

        }

        g=m;

        cnt--;

        ans+=m;

        for(int i=0;i<n;i++){

            a[i]=gcd(a[i],g);//之后都要都会与g取gcd,因此可以更新

        }

    }

    ans+=1ll*g*cnt;//剩余的前缀个数

    cout<<ans<<'\n';

}

 

Cf2039 D

给定一个整数n和一个有m个元素的整数集合S,找出其中最大的n个元素的整数数组,要求a[gcd(i,j)]!=gcd(a[i],a[j])

首先考虑将数组中的元素从大到下填入,这样能保证答案一定不是最差的

其次,gcd(I,j)<=I,j,且gcd(a[i],a[j])<=a[i],a[j]<=a[gcd(I,j)],也就是除非a[i]=a[j]=a[gcd(I,j)],那么一定能够保证不会相等

因此可以在该方案的基础上继续进行修改

如何进行构造使得上述情况成立

利用下标i的质因子个数:当对两个下标取gcd时,得到的结果的质因子个数只会小于等于两个下标中较小的那个,但因为我们数字是递减填入的,因此gcd(a[i],a[j])一定小于下标较小的对应的元素(小于等于a[j]),因此保证了一定成立(i的ps等于j的ps时,一定有gcd{I,j}的ps小于这两者,因为这两个不相等,因此一定是各个质因子的幂取min,总和一定变小)

std::vector<int> minp, primes, ps;

void sieve(int n) {

    minp.assign(n + 1, 0);

    primes.clear();

    ps.resize(n + 1);

   

    for (int i = 2; i <= n; i++) {

        if (minp[i] == 0) {

            minp[i] = i;

            primes.push_back(i);

        }

       

        for (auto p : primes) {

            if (i * p > n) {

                break;

            }

            minp[i * p] = p;

            if (p == minp[i]) {

                break;

            }

        }

    }

    for (int i = 2; i <= n; i++) {

        ps[i] = ps[i / minp[i]] + 1;

    }

}

 

void solve(){

    int n,m;

    cin>>n>>m;

    vector<int> a(m);

    for(int i=0;i<m;i++){

        cin>>a[i];

    }

    sort(a.begin(),a.end(),greater());

    int mx=*max_element(ps.begin()+1,ps.begin()+n+1);

    if(m<mx+1){

        cout<<-1<<'\n';

        return;

    }

    for(int i=1;i<=n;i++){

        cout<<a[ps[i]]<<" \n"[i==n];

    }

}

 

 

lcm(最大公倍数):
可以得到最小公倍数是两个数的所有因数的最大幂次的乘积

因为幂次中:ka+kb=max()+min()

故:gcd(a,b)*lcm(a,b)=a*b

因此要求最小公倍数,可以先求出最大公约数

并且注意lcm(a,b)=a/gcd(a,b)*b,不要写作a*b/gcd(a,b),因为a*b可能会炸

 

Cf 2091E

定义a,b是有趣的当lcm(a,b)/gcd(a,b)是质数,问1<=a<b<=n中有多少个合法的(a,b)对

Lcm是a,b所有质因子取max,gcd是所有质因子取min,于是a,b有趣的也就是b=a*一个质数,于是就是对所有可能的b,计算b中互不相同质因子的个数

https://codeforces.com/contest/2091/submission/312411539

 

cf 1977c

从小到大排序,如果lcm(a1,a2,...,an)!=an,那么答案就是n

并且如果lcm就是an,那么就知道对于每个ai都有ai|an,因此子序列的lcm必定是an的因子

因此我们可以遍历最大值的因数,检查是否存在一个子序列的lcm是该因数

 

结果为n还可以建图判断,n²遍历每一对,如果大的整除小的,就给大的向小的连一个有向边,这样如果存在两个入度为0的点,答案就是n

int a[1000009];

map<int,bool> p;

int n;

int ck(int x){

    int z=1;

    int s=0;

    for(int i=1;i<=n;i++){

        if(x%a[i]==0){

            int g;

            g=__gcd(a[i],z);

            z*=a[i];

            z/=g;

            s++;

        }

    }

    if(z==x){

        return s;

    }

    return 0;

}

void solve() {

    cin>>n;

    p.clear();

    for(int i=1;i<=n;i++){

        cin>>a[i];

        p[a[i]]=1;

    }

    int z;

    z=1;

    for(int i=1;i<=n;i++){

        int g;

        g=__gcd(a[i],z);

        z*=a[i];

        z/=g;

        if(z>1e9){

            cout<<n<<endl;

            return;

        }

    }

    if(!p[z]){

        cout<<n<<endl;

        return;

    }

    int ans;

    ans=0;

    for(int i=1;i<=sqrt(z);i++){

        if(z%i==0){

            if(!p[i]){

                ans=max(ans,ck(i));

            }

            if(!p[z/i]){

                ans=max(ans,ck(z/i));

            }

        }

       

    }

    cout<<ans<<endl;

}

 

 

扩展欧几里得算法:

用于解决ax+by=gcd(a,b)的一组可行解

利用矩阵的简洁迭代写法:
int exgcd(int a, int b, int &x, int &y) {

int x1 = 1, x2 = 0, x3 = 0, x4 = 1;

while (b != 0) {

int c = a / b;

std::tie(x1, x2, x3, x4, a, b) =std::make_tuple(x3, x4, x1 - x3 * c, x2 - x4 * c, b, a - b * c);

}

x = x1, y = x2;

return a;

}

 

裴蜀定理:对任何a,b∈Z和它们的最大公约数d,关于未知数x和y的线性不定方程(称为裴蜀等式):ax+by=c有整数解(x,y)当且仅当d∣c,可知有无穷多解。特别地,一定存在整数x,y,使ax+by=d成立。

推论:a,b互质的充要条件是存在整数x,y使ax+by=1

对于方程ax+by=c,设d=gcd(a,b),根据斐蜀定理可知ax+by=d一定有正整数解,那么问题就在于如何求解x,y

因为欧几里德算法停止的状态是:a=d,b=0,此时当x=1,y=0(实际任意值都可以)时d∗1+0∗0=d成立,这是最终状态,我们根据这个最终状态反推出初始状态的值即为答案

x,y和x1,y1是两组解,且满足:
 
  a*x+b*y=gcd(a,b)
             b*x1+(a%b)*y1=gcd(b,a%b)
  a*x+b*y=b*x1+(a%b)*y1
 
k=a/b,r=a%b,则r=a-k*b,代入上式得
             a*x+b*y=b*x1+(a-a/b*b)*y1
             a*x+b*y=a*y1+b*(x1-a/b*y1)
  x=y1
             y=x1-a/b*y1

因此可以定义函数

int exgcd(int a,int b){
             if(b==0){x=1;y=0;return a;}
             int tmp=exgcd(b,a%b);
             int t=x;
             x=y;y=t-a/b*y;
             return tmp;
}

可以进一步得到通解是

直接给出:x=x0+b/d∗k,y=y0−a/d∗k,k为任意整数

并且可以证明上述式子能无遗漏地表示所有整数解

 

利用扩展欧几里得,我们可以求解不定方程ax+by=c;求解线性同余方程;求解模的逆元

 

求解不定方程

由斐蜀定理知当d∣c时才有整数解

将方程两边同时除以gcd(a,b),设a′=a/gcd(a,b),b′=b/gcd(a,b),c′=c/gcd(a,b),则方程变形为a′x+b′y=c′,因为a′,b′互质,所以gcd(a′,b′)=1。

由扩展欧几里得定理知一定存在x0,y0使得a′x0+b′y0=1。

则可由exgcd求出x0,y0,将上式两边同时乘以gcd(a,b)得

a′gcd(a,b)x0+b′gcd(a,b)y0=gcd(a,b)

==>ax0+by0=gcd(a,b)

==> ax0+by0=c/c′,

所以

x1=x0∗c′=c/d∗x0,y1=y0∗c=c/d∗y0​为方程的另一组解

则方程ax+by=c的通解为

x=x1+b/d∗k=c/d∗x0+b/d∗k,y=y1−a/d∗k=c/d∗y0−a/d∗k

代码可以是:

             int d=exgcd(a,b);
             if(c%d!=0){printf("-1\n");return 0;}//如果无解
             x=c/d*x;
             y=c/d*y;//这里是求出k=0时的通解

解线性同余方程

对于线性同余方程:ax≡m(mod b)

ax%b=m

-> ax=by+m

->ax−by=m

-> ax+by=m

乘法逆元

存在x使得ax≡1(mod p),则称x的最小正整数解是a关于p的乘法逆元

定理:a关于p的乘法逆元存在的充要条件是gcd(a,p)=1

逆元的作用

要求(a/b)%p时,且a很大,我们就求b关于p的乘法逆元x,则有(a/b)%p = (a*x)%p
 
据b*x≡1 (mod p)有b*x=p*y+1
 
x=(p*y+1)/b
 
x代入(a*x) mod p,得:
(a*(p*y+1)/b) mod p
=((a*p*y)/b+a/b) mod p
=[((a*p*y)/b) mod p +(a/b)] mod p
=[(p*(a*y)/b) mod p +(a/b)] mod p

求解逆元的代码

int x,y;

void exgcd(int a,int b){

  if(!b){x=1;y=0;return;}

  else{

    exgcd(b,a%b);

    ll t=x;x=y;y=t-a/b*y;

  }

}

//a关于mod的逆元:inv(a,mod)

int inv(int a,int b){

  exgcd(a,b);

  while(x<0) x+=b;

  return x;

}

 

 

欧拉函数

数论中,对正整数n,欧拉函数 (n)是小于或等于n的正整数中与n互质的数的数目

当 n 是质数的时候,显然有 

欧拉函数是积性函数。

即对于任意满足gcd(a,b)=1的整数a,b,有

特别的,当n时奇数时,

同时

若n=pk,其中p是质数,那么

对于任意不全为0的整数m,n,

由唯一分解定理 若n= ,其中pi是质数,那么

求一个数的欧拉函数值

int euler_phi(int n) {

  int ans = n;

  for (int i = 2; i * i <= n; i++)

    if (n % i == 0) {

      ans = ans / i * (i - 1);

      while (n % i == 0) n /= i;

    }

  if (n > 1) ans = ans / n * (n - 1);

  return ans;

}

 

筛法求欧拉函数

在线性筛中,每一个合数都是被最小的质因子筛掉,比如P1是n的最小质因子,n’=n/p1,那么在线性筛的过程中n通过n’*p1筛掉

如果n’能够被p1整除,那么n’包含了n的所有质因子

于是

如果n’不能被p1整除,说明n’和p1是互质的,因此

vector<int> pri;

bool not_prime[N];

int phi[N];

 

void pre(int n) {

  phi[1] = 1;

  for (int i = 2; i <= n; i++) {

    if (!not_prime[i]) {

      pri.push_back(i);

      phi[i] = i - 1;

    }

    for (int pri_j : pri) {

      if (i * pri_j > n) break;

      not_prime[i * pri_j] = true;

      if (i % pri_j == 0) {

        phi[i * pri_j] = phi[i] * pri_j;

        break;

      }

      phi[i * pri_j] = phi[i] * phi[pri_j];

    }

  }

}

 

 

POJ 2480

gcd(i*j,n)=gcd(i,n)*gcd(j,n),所以gcd(i,n)为积性函数

我们所求f(n)=Σ(gcd(i,n)),由定理:积性函数的和函数也是积性函数,得该函数也是一个积性函数

若gcd(x,n)=g,那么gcd(x/g,n/g)=1,而满足gcd(x/g,n/g)=1的所有数实际上就是 ,因此gcd(x,n)=g对于答案的贡献就是g*

而实际上 fn= ,因此只需要枚举n的所有因数即可

ll euler_phi(ll n) {

    int m = sqrt(n + 0.5);

    ll ans = n;

    for (int i = 2; i <= m; i++) {

        if (n % i == 0) {

            ans = ans / i * (i - 1);

            while (n % i == 0) n /= i;

        }

    }

    if (n > 1) ans = ans / n * (n - 1);

    return ans;

}

 

void solve(){

    ll n;

    vector<int> fac;

    while (cin >> n) {

        fac.clear();

        for (ll i = 1; i * i <= n; i++)

            if (n % i == 0) {

                if (i * i == n) fac.push_back(i);

                else {

                    fac.push_back(i);

                    fac.push_back(n / i);

                }

            }

        ll ans = 0;

        for (int i = 0; i < fac.size(); i++) {

            ans += 1LL * fac[i] * euler_phi(n / fac[i]);

        }

        cout << ans << "\n";

    }

}

 

 

 

卡特兰数(Catalan数):

前几项为1,2,5,14,42

Catalan数一般的应用都有括号化、出栈次序(P1044)、凸多边形三角划分(P1375)、给定节点组成二叉搜索树、n对括号正确匹配数目等,定义如下:

设h(n)为Catalan数的第n+1项,令h(0)=1,h(1)=1,Catalan数满足递推式:

h(n)= h(0)*h(n-1)+h(1)*h(n-2) + ... + h(n-1)*h(0) (n>=2)

是否能使用卡特兰数就是看是否符合这类递推式,满足各项的两个参数相加为一个常值

 

例如:h(2)=h(0)*h(1)+h(1)*h(0)=1*1+1*1=2

h(3)=h(0)*h(2)+h(1)*h(1)+h(2)*h(0)=1*2+1*1+2*1=5

 

另有递推式:h(n)=h(n-1)*(4*n-2)/(n+1);

 

递推式的解为:h(n)=C(2n,n)/(n+1) (n=0,1,2,...)   即  

 

另有解为:h(n)=c(2n,n)-c(2n,n-1)(n=0,1,2,...)    即

 

当数据量较大时用快速幂取模和乘法逆元进行优化计算

 

计算卡特兰数

const int maxn=2e5+5,maxs=2e5+2,mod=1e9+7;

 

ll f[maxn],c[maxn],ans;

 

ll qpow(ll a,ll x){

     ll ans=1;

     while(x){

          if(x&1)ans=ans*a%mod;

          a=a*a%mod;

          x>>=1;

     }

     return ans;

}

 

void Init(){

     c[0]=1;

     for(int i=1;i<=maxs;i++)c[i]=c[i-1]*i%mod;

}

 

ll C(int n,int m){

     return c[n]*qpow(c[m],mod-2)%mod*qpow(c[n-m],mod-2)%mod;

}

 

ll Catalan(int n){

     return C(2*n,n)*qpow(n+1,mod-2)%mod;

}

 

int main(){

    ios::sync_with_stdio(false),cin.tie(nullptr);

     int n;cin>>n;

     Init();

     cout<<Catalan(n);

     return 0;

}

有一种比较直观的看卡特兰数的形式是将问题可视化,即将操作步数当作x轴,将与操作相关的元素当作y轴,如出栈入栈中y轴代表栈中元素,P1641中将1的个数和0的个数差作为y轴,这样每一步操作x+1,而y轴元素+1或-1,并且限制条件有路径不能经过y=-1

这样子,如果不考虑限制条件,就表示从(0,0)走n+m步到达(n+m,n−m),这相当于从n+m步中选出m步向右下走,也就是C(n+m,m)

而这条路径不能经过直线y=−1。可以通过对称性发现,从(0,0)走到直线y=−1上的一点,相当于从(0,−2)走到该点。也就是说,路径经过直线y=−1的方案数就是从(0,−2)走n+m步到达(n+m,n−m),这个方案数可以用组合数表示为C(n+m,m−1)

故最终结果就是C(n+m,m)−C(n+m,m−1),可以预处理阶乘后用乘法逆元计算

const int maxn = 2e6 + 5, mod = 20100403;

int n, m, fac[maxn ], inv[maxn ];

int qpow(int a, int b) {

    int res = 1;

    while (b) {

        if (b & 1) res = 1ll * res * a % mod;

        a = 1ll * a * a % mod;

        b >>= 1;

    }

    return res;

}

int C(int x, int y) {

    int z = 1ll * fac[x] * inv[y] % mod;

    return 1ll * z * inv[x - y] % mod;

}

int main() {

    int i; fac[0] = 1; n = read(); m = read();

    for (i = 1; i <= n + m; i++) fac[i] = 1ll * fac[i - 1] * i % mod;

    inv[n + m] = qpow(fac[n + m], mod - 2);

    for (i = n + m - 1; i >= 0; i--)

        inv[i] = 1ll * inv[i + 1] * (i + 1) % mod;

    printf("%d\n", (C(n + m, m) - C(n + m, m - 1) + mod) %mod);

    return 0;

}

 

概率以及期望

有理数取模:
在一些期望值的题目里,经常会遇到这么一句话:

输出期望值模 998244353 的结果。

有理数取余,它里面直接给出了定义:

这个值被定义为 bx≡a (mod p) 的解。

因此x=a/b,即我们要求出b的逆元

其中一种方法就是利用费马小定理

费马小定理:如果 p 是一个质数,而整数 a 不是 p 的倍数,则有 ap1≡1 (mod p)

因为有 ap1≡1,因此a的逆元就是ap-2

故有:

int fp(int x,int y){ // 快速幂

    int res=1;

    for(;y;y>>=1){

        if(y&1) res=(1ll*res*x)%P;

        x=(1ll*x*x)%P;

    }

    return res;

}

int p1=(1ll*a[i]*fp(b[i],P-2))%P; //费马小定理求出概率

 

cf1925d

每一对人被选择到的概率是 ,记s表示友谊值的总和,初始时s= ,每一轮选择时,选择到的友谊值的期望是 ,也即s*p,友谊值增加的期望是p*m(因为只有选中是朋友的那一对才会增加友谊值)

于是每一次操作ans=ans+p*s,s=s+p*m

const int mod=1e9+7;

ll fp(ll x,ll y){

    ll res=1;

    for(;y;y>>=1){

        if(y&1) res=(1ll*res*x)%mod;

        x=(1ll*x*x)%mod;

    }

    return res;

}

void solve(){

    ll ans=0,sum=0;

    ll n,m,k;

    cin>>n>>m>>k;

    ll p=n*(n-1)%mod;

    p=2*fp(p,mod-2)%mod;

    for(int i=1;i<=m;i++){

        int a,b,f;

        cin>>a>>b>>f;

        sum=(sum+f)%mod;

    }

    for(int i=1;i<=k;i++){

        ans=(ans+sum*p%mod)%mod;

        sum=(sum+m*p%mod)%mod;

    }

    cout<<ans<<'\n';

}

 

要注意乘的时候是否会超过int的范围

 

cf2020E

给定一个数组a,有一个多重集(也就是允许元素重复出现),起始为空,然后按照1-n的顺序将a[i]插入到S中,a[i]成功插入S的概率为p[i]/(1E4),定义f(S)为S中所有元素的异或和,求f(S)^2的期望值

看到异或,联想到能否拆分成每一位来进行计算

(一种操作技巧) 求E(f(s)2),对于f(s),用二进制表示为b[20]b[19]...b[0],那么f(s)^2就是 ,因此,如果能求出每一对(i,j)的b[i]b[j]期望值,那么就能得到最后的结果

这可以通过动态规划来实现,对于每一对i,j,b[i],b[j]显然只有四种可能的情况,并且对于每个可能的情况我们都能维护该值的概率

因为a的值很小,为1到1023,因此最多只会有9位,于是可以直接遍历i,j,其中因为i和j实际上是等价的,因此外层循环i从0到9,内层循环j从i到9,如果i和j不同,只需要在最后计入贡献的时候乘上2即可

using Z = ModInt<P>;

 

void solve() {

    int n;

    cin>>n;

    Z inv=Z(10000).inv();//取逆

    vector<int> a(n);

    vector<Z> p(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    for(int i=0;i<n;i++){

        int x;

        cin>>x;

        p[i]=x*inv;//模1e9+7下的概率

    }

    Z ans=0;

    for(int i=0;i<10;i++){

        for(int j=i;j<10;j++){

            array<array<Z,2>,2> dp{};

            dp[0][0]=1;

            //dp表示(u,v)的期望值

            for(int k=0;k<n;k++){//对f(s)的这两位进行处理

                auto ndp=dp;

                dp={};

                for(int u=0;u<2;u++){

                    for(int v=0;v<2;v++){

                        //异或的性质,对a[k]的每一位(特定位)独立处理

                        dp[u^(a[k]>>i & 1)][v^(a[k]>>j & 1)]+=ndp[u][v]*p[k];//成功加入

                        //dp[u][v]中u代表第一个f(s)的第u位

                        dp[u][v]+=ndp[u][v]*(1-p[k]);//现在操作的是a[k],选入概率是p[k]

                    }

                }

            }

            ans+=dp[1][1]*(1<<(i+j))*(i!=j?2:1);//只有i和j都是1时才会有贡献

        }

    }

    cout<<ans<<'\n';

}

 

CCPC2024 深圳 L

给定一棵只有根节点的有根树,每次操作时随机选取一个叶节点,并为该节点添加恰好M个子节点,求K次操作后整棵树节点深度总和的期望,答案对1e9+7取模,定义根节点的深度为0

每次添加的子节点的子节点深度为D[i],每次添加一定是当前树的叶子节点集合中进行的,因为最开始树只有一个根节点,因此可以每次单独进行操作,计算每轮操作以后叶节点深度和的期望

 

牛客24多校10 H

两个玩家分别有a和b的筹码,在一场游戏里互相全押,当赢家筹码不少于输家时,游戏立即结束,否则输家向赢家支付等同于赢家筹码的筹码

问两个玩家的胜率

结论是输出a/(a+b),以及b/(a+b)

因为一个玩家的胜率只和其筹码量有关,令f(x)表示拥有x筹码的玩家在总筹码固定为n的游戏里的胜率

显然有f(0)=0,f(n)=1

当x<n/2时,f(x)=1/2*f(0)+1/2*f(2x);

当x>=n/2时,f(x)=1/2*f(n)+1/2*f(2x-n);

可以验证f(x)=x/n是一组解,同时由于线性方程满秩,因此又是唯一解

void solve(){

    int n,a,b;

    cin>>a>>b;

    n=a+b;

    cout<<a*qpow(n,mod-2)%mod<<' ';

    cout<<b*qpow(n,mod-2)%mod<<'\n';

}

 

ccpc2024网络赛 E

随机生成n个长度为m的字符串,每个位置上的字符都是随机的

求这n个串插入字典树后(即对于字典树的任意叶子节点,存在这n个串中的某一个串使得与该叶子表示的字符串相等),求字典树上节点个数的最大值和期望

对于最大节点数,考虑贪心,即深度为i的节点最多能有多少个,如果26^i<=n,那么贡献为26^i,否则贡献为n

枚举所有的层,则答案为

对于期望节点数,显然每一层中所有节点出现在trie中的期望是一样的,因此可以考虑第i层某个特定点出现的概率,最后乘上总个数并求和即可

 

ICPC2024 网络赛第二场 G

因为遇到平局只是使游戏进入下一回合,对于双方的点数实际上并没有影响,因此可以认为双方的胜利概率就是a0:a1,也就是说Alice获胜的概率就是a0/(a0+a1)

游戏的过程是如果一个人输了,那么他就要减去另一个的分数,直到一方输了之后不能继续执行该操作(也就是分数小于对方并且输了)

因为双方的游戏过程实际上是一个辗转相减的过程,因此用辗转相除加速即可

int fp(int x,int y){ // 快速幂

    int res=1;

    for(;y;y>>=1){

        if(y&1) res=(1ll*res*x)%mod;

        x=(1ll*x*x)%mod;

    }

    return res;

}

//求a的模意义下的乘法逆元

int inv(int a){

    if(a%mod==0) return -1;

    else return fp(a,mod-2);

}

void solve(){

    int n,m;

    cin>>n>>m;

    int a,b,c;

    cin>>a>>b>>c;

    int p0=1ll*a*inv(a+b)%mod;

    int p1=1ll*b*inv(a+b)%mod;

    i64 ans=0,res=1;

    //ans是当前的概率,res表示当前的系数

    //辗转相减的写法,每一轮获胜的概率计算出,将系数传递给下一轮

    while(n && m){

        if(n>=m){

            int d=n/m,r=n%m;

            //当n>=m,在下一轮前就结束(获胜)的概率

            ans=(ans+res*(1-fp(p1,d)+mod)%mod)%mod;

            res=res*fp(p1,d)%mod;//乘上对应的系数

            n=r;

        }else{

            int d=m/n,r=m%n;

            //只能一直赢到下一轮开始为止

            if(m%n==0){//下一轮就结束了,因此该概率同时也是获胜的概率

                ans=(ans+res*fp(p0,d)%mod)%mod;

            }

            res=res*fp(p0,d)%mod;

            m=r;

        }

    }

    (ans+=mod)%=mod;

    cout<<ans<<'\n';

}

 

ICPC2024 网络赛第二场L

问题等价于每一步可以选择重置成[0,t)中的某个整数,或者减1,求最优策略下得到0的期望步数

最优策略一定是在[1,t]中选择一个阈值c,开始不停重置,一旦重置到一个小于c的数就选择直接减到0

显然重置到小于c的数的概率为p=c/t,根据期望线性拆解到计算重置k的概率(1-p)k之和, 得到期望步数为

这是一个对钩函数的形式,因此最小值在c*(c-1)=2*t,也就是sqrt(2*t)附近取到

实现时需要分数类,考虑到分数类中的最大公约数计算,也就是要输出最简分数

void solve(){

    int t;

    cin>>t;

    i64 c=sqrt(2*t);

    i64 fenzi=c*c+2*t-c;

    i64 fenmu=2*c;

    c+=1;

    i64 fenzi2=c*c+2*t-c;

    i64 fenmu2=2*c;

    if((double)fenzi/fenmu>(double)fenzi2/fenmu2){

        i64 g=gcd(fenzi2,fenmu2);

        cout<<fenzi2/g<<' '<<fenmu2/g<<'\n';

    }else{

        i64 g=gcd(fenzi,fenmu);

        cout<<fenzi/g<<' '<<fenmu/g<<'\n';    

    }

}  

 

 

数论:
CCPC2024 济南 F

在{1…m}的所有大小为n的子集中,询问每个子集最多能保留多少个数使得gcd!=min

先对一个集合进行考虑:
如果当前集合的gcd!=min,那么什么也不用做

否则,需要将集合内的所有数整除min,或者删除min

但删除min是不合法的操作,因此一直删除min使得条件满足或者集合为空

对于所有大小为n的子集进行操作,如果直接维护状态或者容斥计数都比较麻烦

因此可以考虑每个元素被删除了多少次,从而用原集合的大小减去即可

在一个集合中,如果一个元素x在集合内被删除了,就说明:比x小的数构成以x结尾的倍数链,比x的数都是x的倍数

小的部分可以通过枚举倍数求得f[c][x],表示倍数链长度为c,且当前数是x的方案数

大的部分是在x的倍数中任选

由此要删除的数的总数是

f[c][x]>0仅当x能拆成c个大于1的数的乘积,最多拆成 个乘积

 

2024东北四省邀请  F

对于p ,p/q在k进制下为一个有限小数,当且仅当q|p*k∞

也就是说,将q进行质因数分解后,其质因子qi以及其幂ai要满足下面任意条件

qi|k或者qi|p,并且p中qi的幂次要大于ai

可以通过对所有满足qi|k或者qi|p的因子,枚举去幂次去实现

void solve() {

    //要满足在k进制下是有限分数,就要求在p/q约分后的形式中,只能包含k所拥有的质因子

    //可以先分解k,看看有哪些互不相同的质因子

    //再求得p中的质因子

    //对于x,p中有的,但k中没有的质因子个数不能超过p中的个数

    i64 p,x,k;

    cin>>p>>x>>k;

    map<i64,int> cnt;

    //先分解一下K

    //在p里面只算一遍,在k里面算100遍就相当于保证了k中质因子的个数是足够的

    auto factor=[&](i64 n,int c){

        for(i64 x=2;x*x<=n;x++){

            while(n%x==0){

                cnt[x]+=c;

                n/=x;

            }

        }

        if(n>1){

            cnt[n]+=c;

        }

    };

    factor(k,100);

    factor(p,1);

    vector<pair<i64,int>> a(cnt.begin(),cnt.end());

    i64 ans=0;

    //是搜索个数而并非判断是否是有限分数

    //因此用dfs去分配x分解式中每个质因子的幂次

    auto dfs=[&](auto && self,int i,i64 n){

        if(i==a.size()){//所有质因子的幂都已经确定了

            ans++;

            return;

        }

        for(int j=0;j<= a[i].second;j++,n*=a[i].first){//枚举幂次

            self(self,i+1,n);

            if(n>x/a[i].first){

                break;//避免爆数据范围

            }

        }

    };

    dfs(dfs,0,1);

    cout<<ans<<'\n';

}

 

Cf 2110F

定义一个函数f(x, y) = (x mod y) + (y mod x)

一个数组b的美度定义为所有数对(b[i], b[j])中f(b[i], b[j])的最大值

现在给定一个长度为n的数组a,给出[1], [1,2], [1,2,3], … , [1, … , n]的美度

对于这种有定义的问题,首先肯定是找性质

首先,f(x, y) <= max(x, y)

证明:显然,若x = y,则f(x, y) = 0,否则设x <= y,那么x mod y = x,y mod x = y – (y/x) * x

于是f(x, y) = x + y – (y/x) * x

由于y / x >= 1,因此f(x, y) <= max(y, x) = y成立

由f(x, y)的表达式,也能得到f(x, y) >= x

第二,长度为k的前缀中最大的f(a[i], a[j]),a[i] >= a[j],在a[i] = max a[j]中取得

证明:设a[i]为长度为k的前缀中最大的元素,不妨考虑f(a[x], a[y]),a[x] <= a[y],则由结论一可以得到f(a[x], a[y]) <= a[y],再考虑f(a[i], a[y]),因为a[y] <= a[i],于是f(a[y], a[i]) <= a[y] <= f(a[i], a[y])

第三,若x < y < 2*x,则f(x, y) = y

证明:f(x, y) = x + y – (y/x) * x,若 x < y < 2 * x,则y/x = 1,于是f(x, y) = y

因此,在长度为k的前缀中,最大值为x,最大的f(x, y) 是ans

具体答案的更新可以看代码

每次在前缀最大值达到前一个最大值的两倍才会枚举数组中所有元素,因此时间复杂度可以证明是O(n log A)

void solve() {

    int n;

    cin >> n;

    vector<int> a(n);

    for (int i = 0; i < n; i++) {

        cin >> a[i];

    }

    int ans = 0;

    int mx = 0;

    for (int i = 0; i < n; i++) {

        if (a[i] <= mx) {

            ans = max(ans, mx % a[i] + a[i] % mx);

        } else if (a[i] < 2 * mx) {

            mx = a[i];

            ans = a[i];

        } else {

            mx = a[i];

            for (int j = 0; j < i; j++) {

                ans = max(ans, mx % a[j] + a[j] % mx);

            }

        }

        cout << ans << " \n"[i == n - 1];

    }

}

 

 

cf1981D

D(n)代表n的数位之和,并且D(k*n)=k*D(n),说明n是完全没有进位的

因此我们可以先计算出最大的数位,然后计算方案数

证明:

因此每一位上都有k*xi=(k*x)i,于是每一位都没有进位,这样每一位上都有 +1个数合法(因为0也合法),于是,根据乘法原理,如果一个数有k位,那么总共有 个数合法

 

cf1661B

因为32768是2的15次方,又我们要通过若干操作使得我们的数值可以被32678整除,再根据模运算的性质可知,无论是先乘再取模和先模再乘不影响最后的结果,因此最终答案显然是不会大于15的,在情况较少的时候,就可以考虑枚举了

具体枚举的时候再贪心,要让数字尽可能大显然是先加再乘的效率比较高

powm[0]=1;

    for(int i=1;i<=15;i++) powm[i]=powm[i-1]*2;

    for(int i=1;i<=n;i++){

        cin>>a[i];

        int ans=15;

        for(int j=0;j<=15;j++){//枚举加

            for(int k=0;k<=15-j;k++){//枚举乘二

                if(((a[i]+j)*powm[k])%32768==0){

                    ans=min(ans,j+k);

                    break;

                }

            }

        }

        cout<<ans<<' ';

    }

 

容斥定理:
基本思想是先不考虑重复的情况,把包含于某内容中所有的对象的数目先计算出来,然后再把计数时重复计算的数目排斥出去,使得最终的计算成果正确,当计数的种类被推到n类时,其统计规则遵循奇加偶减

如P3801,简单来说就是计算那些奇数次被覆盖到的区域面积

 

更严密的表述:

我们将元素按照属性进行分类(属性就是满足一些相互独立的条件,例如会cpp和会python就是两个属性),设 U 中元素有 n 种不同的属性,而第 i 种属性称为Pi  ,拥有属性Pi的元素(一个元素可能有多个属性)构成集合 Si,那么有

 

ai可以看作是对于那n种属性构成的有序数列的子数列,即有ai>=i

 

对于全集U下的集合的并可以用容斥定理计算,而集合的交可以用全集减去集合的补集的并得到

即对于任意Si的交,有

 

 

容斥定理的应用:

不定方程非负整数解计数

不定方程

如果xi没有限制(指有上限,如果是有下限那么只要等式左右都加下限即可,而且显然的,这个上限一定是指小于m的上限,之后说的无上限都保证了这一要素)那么不定方程的非负整数解的数目为

证明,相当于要将m个球放入n个盒子,并且允许某个盒子是空的 ,这个问题不能直接用组合数解决,于是我们再加入n个球,变成了在n+m-1个间隔中选n-1个间隔,将这个序列变成了n份,恰好可以一一对应放到n个盒子中(相当于将原来为0的那些情况变得保证不为0),这样进行选择的方案数就是

 

但对于xi有限制,即xi有上界的情况,因为我们已经得到了xi无限制的解,因此自然考虑转化成无上限的情况

以P2513的加强为例,实际上就是解一个不定方程

 

其中对任意

每个变量都有上限,因此尝试转化

我们不妨假设有某个位置不合法,设这个位置为p,那么显然有表达式:xp>p-1

那么我们考虑一个增量 ,如果我们在等式两侧同时去掉这个增量,那么对于这个位置来说,它一定是合法的,也就是说可以视作不再有上限了(超出上限的部分已经被 吸收了)

然而,在这种情况下,我们仍然无法保证剩下的位置都是合法的,因此尝试进一步操作:先统计出总的增量 ,然后再将这个增量分配给单独的 即可,于是有新的不定方程

 

这个不定方程的解的个数是易得的,为

接下来需要考虑的就是 的分配问题

我们先回顾一下 是怎么来的:对于那些不合法的位置,我们为了使其合法而提出了它们的超出部分

为了确定这些不合法的位置,我们先对原问题抽象出容斥原理的模型:

全集U:不定方程 的非负整数解

元素:变量xi

属性:即xi满足的条件,即对于位置i,xi<=i-1(一个位置对应一个属性)

那么这些不合法的位置就可以表述为 的交集,即 的解的集合

那么针对这个解集,我们再回到原来的不定方程

交集表示要满足这些条件,但同时也不能有其他位置加入这个交集,换言之,在这个解集对应的不定方程中,有部分变量有下界的限制,而有些没有限制,正如前文所述,要消除这些下界限制是容易的,我们把这些下界减掉,那么就变成了一个无上界限制的求非负整数解的不定方程了,对应形式为

 

 

将这个形式再与上面的形式进行对比,我们能够发现 = ( 就是不合法位置的下标,同时也是下界的值,对应着[1,n]中的某一个数,m就是不合法的个数)

我们用状态f[i][j]来表示用i个[1,n]之间的数求和,和为j的方案数

因此对于每一个总增量 ,它的分配方案数为

上界之所以是 ,是因为考虑m个互不相同的数求和的最小值为 ,由于 ,因此有效数字的个数应该是 级别的

接下来考虑转移

由于我们要求数组中每个数都不同,所以可以把操作看成对数组中每个数加一,因此有转移:

f[i][j]=f[i][j−i]+f[i−1][j−1]

原理:如果元素个数不变,那么每个数加一之前的值即为f[i][j−i]

如果元素个数改变,那么一定是原数组中每个数加一之后再放下一个1,从f[i−1][j−i]转移过来

但是我们注意到,每个元素有一个上界就是n,但是这样直接算很有可能某个元素超过了n

我们注意到每个元素+1的次数不能超过n,因此我们最后还需要去掉一个超过n的情况,也就是加上1之后某个数的大小超过n,变成了n+1

最终的表达式即为f[i][j]=f[i][j−i]+f[i−1][j−i]−f[i−1][j−n−1]

 

求最大公约数为k的数对个数

x,y的值在1~N之间,f(k)表示最大公约数为k的有序数对(x,y)的个数

由容斥原理可以得知,先找到所有以k为公约数的数对,再从中剔除所有以k 的倍数为公约数的数对,余下的数对就是以k为最大公约数的数对

因为以k为公约数的数起码是k的倍数,因此这些数构成的数对个数为[N/K]2,所以有

 

显然当k>K/2时,后面那项实际上就不存在了,即 ,于是我们可以倒过来,从f(N)算到f(1)即可

例P2158,分析这题能看到的学生具有什么特征,显然,我们能看到的只有一条直线上的第一个学生,因此我们实际要找的可以说是这个正方形矩阵内的点与原点的连线的斜率有多少种情况,坐标轴上的点可以表示为(x,y),与原点连线的斜率就是y/x,显然不能枚举求解,因此进一步思考如何进行转化,换个方向考虑,什么情况下,这个点计算出的斜率会已经出现过,那么自然是gcd(x,y) 1的情况,因为这个情况下显然有(x/gcd(x,y),y/gcd(,y))在这个点之前,因此问题转化成了求gcd(x,y)=1的点对,又x,y都可以视作属于1,N,因此就是求f(1)

void solve(int n){

    for(int i=n;i>=1;i--){

        f[i]=(n/i)*(n/i);

        for(int j=2*i;j<=n;j+=i) f[i]-=f[j];

    }

}

 

CF1935D

有两条限制,因为令其在S中显然比令其不在S中容易,因为在s中的数值是有限的,我们可以很容易地通过数学计算得到,因此尝试使用容斥原理

对于相对复杂的情况,即x+y=si,y-x=sj,可以计算得x=(si-sj)/2,y=(si+sj)/2,因此x,y应该是奇偶的,并且注意对于容斥原理所需的三种情况,我们都只需要计算出这些组合的数目而不需要具体的组合,因此可以用数学公式计算出组数就直接计算,因为满足任意si<c,所以一定有y<c,因此对于奇数个数cnt,(x,y)的方案数就是(cnt+1)(cnt)/2

 

欧拉函数:

欧拉函数 ,其中 =

首先对于n分解质因数, ,于是要求对于任意pi,x都不是pi的倍数,pi不整除x,将该条件当作属性,对应的集合为Si,因此有

 

全集大小U=n,而 表示的就是Pi|x的集合,显然Si的大小就是n/pi,于是可推出 ,用容斥定理展开就可得

 

莫比乌斯函数:见博客

数论分块

P2261

给定正整数n和k,计算

暴力很显然,但是会爆

要用除法分块来优化

a mod b= a -

将这个式子带入,可以得到

而k/i下取整在一定的区间内是相等的,因此对于每一块,答案需要减去这一块的和

若t=k/l(默认下取整),若t!=0则r=min(k/t,n),否则r=n

得到右边界后问题就显然了,当前快处理完后l=r+1

void solve(){

    i64 n,k;

    cin>>n>>k;

    i64 ans=n*k;

    for(i64 l=1,r;l<=n;l=r+1){

        if(k/l != 0){

            r=min(k/(k/l),n);

        }else{

            r=n;

        }

        ans-=(k/l)*(r-l+1)*(l+r)/2;

    }

    cout<<ans<<'\n';

}

 

 

狄利克雷卷积

 

莫比乌斯反演

 

连分数

 

在 时间里枚举所有因子

cf1925b

for(int i=1;i*i<=x;i++){

        if(x%i==0){

            if(n<=x/i) ans=max(ans,i);

            if(n<=i) ans=max(ans,x/i);

        }

    }

 

注意是平方而不是除以2

2024CCPC 哈尔滨M

void solve(){

    int n;

    cin>>n;

    vector<int> a;

    if(n==1){

        cout<<1<<'\n';

        return;

    }

    for(int i=1;i*i<=n;i++){

        if(n%i==0){

            a.emplace_back(i);

            if(i!=n/i) a.emplace_back(n/i);

        }

    }

    sort(a.begin(),a.end());

    i64 ans=0ll;

    for(int i=1;i<a.size();i++){

        ans+=1ll*(a[i]-a[i-1])*n/a[i-1];

    }

    ans+=1;

    cout<<ans<<'\n';

}

一般这样的时间复杂度就能过了

 

整除:

当a=kb+c时,a,b的公因数与b,c的公因数相同

如果a,b的任意公因数为d,由上述等式我们能够得到c=a-kb,于是c也有因数d,因此d也是b,c的公因数

如果b,c的公因数为d,那么显然a也有因数d,因此a,b具有公因数d
         因此a,b的公因数和b,c的公因数相同

 

ai|b的充分必要条件是A|b,A是lcm(a1,a2,a3...an)

充分性可由整数唯一分解定理的指数表示法证明

必要性,因为ai|lcm(a1,a2,a3...,an),A|b,于是ai|b

 

m!|

由组合数 知, ,组合数一定是正整数,于是上述性质成立


cf1972D1

如果将gcd(a,b)记作d,那么a,b就能分别表示为pd和qd,并且有gcd(p,q)=1;

考虑整除关系的时候往往可以设定倍数关系

例如(b*gcd(a,b))|(a+b) 那么就有(qd)|(p+q),因此可以假设p+q=kqd

于是p=(kd-1)q,因为p,q互质,但这两者之间又有倍数关系,说明q=1

在这种情况下一般可行的数就比较少了,并且p+1=kd是有上限的,因此可以用枚举的方法去检查

cf1972D2

这一个版本的要求(a+b)|(b*gcd(a,b))

因此同样有(p+q)|(qd)

因为gcd(p+q,q)=gcd(p,q)=1,因此(p+q)|d

故有p<d=(a/p)<=(n/p)
可知p^2<n,同样可知q的范围

因此(p,q)的个数是O( )=O(n+m),复杂度不高,因此可以枚举p,q组合

ll n,m; cin>>n>>m;

    ll sq = sqrt(n) + 2,sqm=sqrt(m)+2;

    vector<vector<bool>> bad(sq + 1, vector<bool>(sqm+1, 0));

    for (ll i = 2; i <= min(sq,sqm); i++) {//有点类似于素数筛的方法,这里相当于实在枚举gcd

        for (ll a = i; a <= sq; a += i) {

            for (ll b = i; b <= sqm; b += i) {

                bad[a][b] = true;

            }

        }

    }

    ll ans = 0;

    for (ll a = 1; a * a <= n; a++) {

        for (ll b = 1; b * b <= m; b++) {

            if (bad[a][b]) continue; //gcd不是1

            ans += min(n/(a+b)/a,m/(a+b)/b);

        }

    }

    cout << ans << '\n';

 

cf2039 C1

找到[1,m]范围内有多个数y,使得x^y|y或者x^y|x

因为要求x和y均大于零,因此两者异或的结果不会是其本身

因为p的任何被除数d都满足d<=p/2

因此,如果异或得到的结果大于p/2,那么这个数一定不会是p的除数

而/2体现在位运算中就是右移一位,因此如果异或的结果和原数的最高位相同,那么一定不可能是除数

因此,如果y>=2*x,那么一定不成立

由于x的数据范围只有1e6,因此可以直接暴力枚举

void solve(){

    i64 x,m;

    cin>>x>>m;

    m++;

    int n=1;

    while(n<=x){

        n<<=1;

    }

    i64 ans=0;

    for(i64 y=1;y<min<i64>(n,m);y++){

        i64 z=x^y;

        if(z!=0 && (x%z==0 || y%z==0)){

            ans++;

        }

    }

    cout<<ans<<'\n';

}

 

cf2039 C2

与c1版本的区别是现在是要求x^y能够被x或y整除

分别考虑被其中一者整除和被两者整除的情况:
被x整除:
不妨令p=x^y,那么y=p^x,从而问题转化为p被x或p^x整除的p的个数

因为可以将异或运算看作是不带进位的加法,因此p^x<=p+x

从而y=p^x<=p+x<=m,即要求p<=m-x

因此在这种情况下,限定为p为x的倍数,这样数的个数是(m-x)/x

而对于p>m-x的部分

类似地可以将异或运算看作是不借位的减法,从而p^x>=p-x,于是如果y>m,则p-x>m,也就是说p>m+x时,p的值都是不合法的

于是可以在(m-x,m+x)范围内手动检查x的倍数

被y整除:
已知x^y<=x+y,当x<y时,x^y<2*y,但y的不等于本身的最小倍数就是2*y,因此x<y时无解

因此考虑y<=x的情况,因为x的数据范围是1e6,因此可以直接枚举判断

根据容斥原理,再考虑既能被x又能被y整除的情况

进而,说明可以被lcm(x,y)整除

当x!=y时,lcm(x,y)>=2*max(x,y),但根据上面的推导,x^y<2*max(x,y)

因此只有y=x时才成立,只需要判断x是否小于等于m即可

void solve(){

    i64 x,m;

    cin>>x>>m;

    // x

    i64 p=m-m%x;

    i64 ans=p/x-(x<p);//p<=m-x 情况下的个数

    if((x^p)>=1 && (x^p)<=m){ //m-x <p <m+x

        ans++;

    }

    p+=x;

    if((x^p)>=1 && (x^p)<=m){

        ans++;

    }

    // y

    for(i64 y=1;y<=min(x,m);y++){

        i64 cur=x^y;

        if(cur%y==0){

            ans++;

        }

    }

    //x and y

    if(x<=m){

        ans--;

    }

    cout<<ans<<'\n';

}

 

 

利用因数分解

cf2b

题目中要求0的数目尽量少,其中一种情况就是数组元素只有0,那么就是一个0,初次之外便是依靠10得到,又因为10都是通过因数2、5得到,因此我们可以维护当前路径上的2和5各自的最小因数个数,0的个数就是min(f(0),f(5))

而这种需要维护的数值个数较少就可以利用dp解决了,并且通过dp对相邻的位置进行转移实际上也起到了遍历所有路径的过程

当判断好最少的0是几之后,再通过转移的方式逆推路径

并且特殊情况,即我们经过某一个位置其元素为0,我们只需要横向走到该元素所在的位置,再一直向下走走到终点即可,因为0保证了我们的路径可以任意选择

for(int i=1,k;i<=n;++i)

        for(int j=1;j<=n;++j) {        //预处理因子个数

            scanf("%d",&k);

            if(!k) {

                num[i][j][0]=num[i][j][1]=1;

                t=i;    //特判0,记录位置

            }

            else {

                for(;!(k%2);k/=2)

                    ++num[i][j][0];

                for(;!(k%5);k/=5)

                    ++num[i][j][1];

            }

        }

void Print(int i,int j,int k) {//逆推输出路径

    if(i==1&&j==1) {              

        putchar(k? 'D':'R');

        return ;            //边界

    }

    if(i==1)

        Print(i,j-1,0);

    else if(j==1)

        Print(i-1,j,1);

        //t代表最优路径中是2的个数少还是5的个数少

    else if(f[i][j][t]==f[i][j-1][t]+num[i][j][t])//通过dp数组判断是选择那条路径

        Print(i,j-1,0);

    else

        Print(i-1,j,1);

    if(i!=n||j!=n)

        putchar(k? 'D':'R');    //在(n,n)处不输出

}

 

蓝桥2023 平方差

一个数要表示成两个平方数的差,利用平方差公式不难得到x=(y-z)(y+z),易推得(y-z),(y+z)是同奇偶的,并且只要我们能确定(y-z),(y+z),我们一定能对应计算出y,z的值

因此对于奇数,我们可以直接选择拆成1和其本身

而对于偶数,因为我们要拆成两个偶数,因此这个数需要是4的倍数,因此l到r中能表示为两个平方数差值的数的个数为这个区间范围中奇数和4的倍数的数

 

数数题:
cf1976E

数有多少个初始排列使我们能得到目标序列,并且给出的数组已经保证是合法的

首先,处理 q=n−1的情况。在这种情况下,执行所有操作后,所有数组都只有一个元素,不能再拆分。在这种情况下可以唯一恢复元素的初始顺序。例如,我们可以从由单个元素组成的 n 数组开始(从 1 到 n 每个整数对应一个数组),然后 "撤销 "操作以恢复初始顺序。"撤销 "操作意味着将两个数组连接在一起,我们无法选择连接的数组和顺序(因为拆分前的数组总共只有两个元素,前一个元素归到L,而后一个归到R),因此我们的操作是唯一确定的。因此,在 q=n−1 的情况下,答案是 1

 q<n−1的情况,我们把从 1 到 n的所有整数分成两组:来自序列 l和/或 r 的整数,以及所有其他整数。我们可以用同样的方法来证明第一组元素(即至少出现在一个给定序列中的元素)的顺序是唯一的,我们可以用类似 DSU(从最后一个到第一个进行运算)或双链表(从第一个到最后一个进行运算)的方法来恢复它们的顺序

因此,假设我们恢复了输入中第一组元素的顺序;那么接下来我们需要插入所有剩余的整数,并且不会使之前的操作顺序失效。这等价于我们有一个由 q+1个元素组成的数组,其顺序是固定的;于是有 q+2 个 "桶",我们可以在这些桶中插入数据

对于每个 "桶",我们都要考虑桶边界上的元素之间的最大值(即我们插入其余元素的位置所相邻的固定元素之间的最大值)。很容易看出,我们插入 "桶 "中的每个元素都应该小于这个最大值:假设左边界上的固定元素是 y ,右边界上的固定元素是 z,它们之间有一个元素 x>max(y,z)。那么我们按照操作规则执行所有操作后, x必定要么与 y 位于同一数组中,要么与 z 位于同一数组中;因此,当我们在从L,R数组中还原初始数组时,我们将写入整数 x (或某个更大的整数),而不是 y 或 z

我们还可以证明相反的情况:如果每个 "桶 "中的每个元素都小于与桶相邻的两个元素的最大值,那么操作序列就是有效的。以为我们可以将每个 "桶 "中的元素与其边界上的元素中较大的元素合并,然后再次 "撤销 "所有操作,可以恢复元素的原始顺序

因此,对于每个剩余的元素,我们可以把它放在某些桶中,而不能放在某些桶中。不过,我们还必须考虑到在同一个桶中插入元素的相对顺序,即插入元素之后导致“桶”周围的元素发生了变化,可能导致之后插入的元素受到限制

让我们从 "固定 "元素的序列开始,从最大的整数到最小的整数,逐个插入其余的元素。我们可以证明,每当我们按照这个顺序插入一个元素时,它能去的地方的数量并不取决于我们插入前几个元素的位置。假设我们插入的元素 "适合" b个水桶,而在插入它之前,我们还插入了 k 个其他元素。那么正好有 b+k个地方可以容纳这个元素,因为我们之前插入的每个元素都可以视作进入了同一个 b 桶,并把这个桶一分为二。因此,只要维护可用桶的数量和已插入元素的数量即可

template<class T>

constexpr T power(T a, i64 b) {

    T res {1};

    for (; b; b /= 2, a *= a) {

        if (b % 2) {

            res *= a;

        }

    }

    return res;

}

 

constexpr i64 mul(i64 a, i64 b, i64 p) {

    i64 res = a * b - i64(1.L * a * b / p) * p;

    res %= p;

    if (res < 0) {

        res += p;

    }

    return res;

}

 

template<i64 P>

struct MInt {

    i64 x;

    constexpr MInt() : x {0} {}

    constexpr MInt(i64 x) : x {norm(x % getMod())} {}

   

    static i64 Mod;

    constexpr static i64 getMod() {

        if (P > 0) {

            return P;

        } else {

            return Mod;

        }

    }

    constexpr static void setMod(i64 Mod_) {

        Mod = Mod_;

    }

    constexpr i64 norm(i64 x) const {

        if (x < 0) {

            x += getMod();

        }

        if (x >= getMod()) {

            x -= getMod();

        }

        return x;

    }

    constexpr i64 val() const {

        return x;

    }

    constexpr MInt operator-() const {

        MInt res;

        res.x = norm(getMod() - x);

        return res;

    }

    constexpr MInt inv() const {

        return power(*this, getMod() - 2);

    }

    constexpr MInt &operator*=(MInt rhs) & {

        if (getMod() < (1ULL << 31)) {

            x = x * rhs.x % int(getMod());

        } else {

            x = mul(x, rhs.x, getMod());

        }

        return *this;

    }

    constexpr MInt &operator+=(MInt rhs) & {

        x = norm(x + rhs.x);

        return *this;

    }

    constexpr MInt &operator-=(MInt rhs) & {

        x = norm(x - rhs.x);

        return *this;

    }

    constexpr MInt &operator/=(MInt rhs) & {

        return *this *= rhs.inv();

    }

    friend constexpr MInt operator*(MInt lhs, MInt rhs) {

        MInt res = lhs;

        res *= rhs;

        return res;

    }

    friend constexpr MInt operator+(MInt lhs, MInt rhs) {

        MInt res = lhs;

        res += rhs;

        return res;

    }

    friend constexpr MInt operator-(MInt lhs, MInt rhs) {

        MInt res = lhs;

        res -= rhs;

        return res;

    }

    friend constexpr MInt operator/(MInt lhs, MInt rhs) {

        MInt res = lhs;

        res /= rhs;

        return res;

    }

    friend constexpr std::istream &operator>>(std::istream &is, MInt &a) {

        i64 v;

        is >> v;

        a = MInt(v);

        return is;

    }

    friend constexpr std::ostream &operator<<(std::ostream &os, const MInt &a) {

        return os << a.val();

    }

    friend constexpr bool operator==(MInt lhs, MInt rhs) {

        return lhs.val() == rhs.val();

    }

    friend constexpr bool operator!=(MInt lhs, MInt rhs) {

        return lhs.val() != rhs.val();

    }

    friend constexpr bool operator<(MInt lhs, MInt rhs) {

        return lhs.val() < rhs.val();

    }

};

 

template<>

i64 MInt<0>::Mod = 998244353;

 

constexpr int P = 998244353;

using Z = MInt<P>;

 

void solve(){

    int n,q;

    cin>>n>>q;

    vector<int> l(q),r(q);

    for(int i=0;i<q;i++){

        cin>>l[i];

    }

    for(int i=0;i<q;i++){

        cin>>r[i];

    }

    //从大到小插能够增加一个可行的位置

    //因为下一个插入的数比当前的数小,故插完之后其周围两个位置无论如何都会变得合法

    //因此下一个元素一定是任何位置都可插入

 

    //双链表恢复顺序

    //L表示前驱,R表示后继

    //L[x]中的x就代表原数组中的元素

    vector<int> L(n+2),R(n+2);

    R[0]=n;

    L[n]=0;

    R[n]=n+1;

    L[n+1]=n;

 

    for(int i=0;i<q;i++){

        int a=l[i],b=r[i];

        if(a>b){//如果l[i]>r[i],那么r[i]就应该在l[i]后面插入

            R[b]=R[a];

            L[R[b]]=b;

            R[a]=b;

            L[b]=a;

        }else{//a<b,a要插在b的前面

            L[a]=L[b];

            R[L[a]]=a;

            L[b]=a;

            R[a]=b;

        }

        //注意上面两种表述并不是等价的,因为本身就是前后缀的关系,因此a一定是在b前面

        //具体的差异是在于是固定a,把b放在a后面;还是固定b,再把a放在b前面、

        //因为是链表的节点,因此一个节点实际上还存有着雨其余节点的关系,因此不能无关大小都将b连在a后面

        //可结合样例感受上述过程  

        //l:6 4 4

        //r:5 5 2

    }

    vector<int> a;//属于第一组元素的序列

    for(int i=R[0];i!=n+1;i=R[i]){

        a.emplace_back(i);

    }

    vector<int> cnt(n+1),v(n+1);

    for(auto x:a){

        v[x]=1;//已经出现过了

    }

    //统计所有能插的位置

    //因为插入时要保证插入元素要小于插入位置左右的元素

    //因此一个位置是否能插入取决于左右元素中较大的那一个

    //于是cnt[x],表示x所主导的位置个数,特别的,在序列第一位和最后一位都有一个额外的位置可供选择

    cnt[a[0]]++;

    for(int i=1;i<a.size();i++){

        cnt[max(a[i-1],a[i])]++;

    }

    cnt[a.back()]++;

    Z ans=1;

    Z cur=0;

    for(int i=n;i>=1;i--){

        if(!v[i]){//把在第一组的元素去掉,对剩下的元素进行处理

            ans*=cur;

            cur+=1;//这个元素插入之后在其旁边也多了一个可以选择的位置

        }

        cur+=cnt[i];

    }

    cout<<ans<<'\n';

}

 

数;
1既不是素数也不是合数

2是唯一的偶素数

对于任意整数n>=3,n,n+1中必有一个不是素数

存在任意长的连续区间,其中所有数都是合数

素数定理:
素数分布函数:[1,n]中的素数的个数记作

定理1:素数分布存在渐进,即

定理2:int 范围内素数间隔小于319,long long范围内素数间隔小于1525

切比雪夫定理:对于任意正整数n>=4,存在质数p满足n<p<2n-2

推论:对于任意正整数n>=2,存在质数p满足n<p<2n

素数筛法:
埃氏筛:

时间复杂度O(nloglogn)   在2e7的数据量范围下能够在1秒内跑完

欧拉筛:
每个合数只会被最小质因子筛掉

对每个数只筛到其最小质因子倍,就能保证筛掉的每个数只会被其最小质因子筛一次

const int N = 1e7 + 7;

bool vis[N];

vector<int> prime;

void get_prime(int n) {

    for (int i = 2;i <= n;i++) {

        if (!vis[i]) prime.push_back(i);

        for (auto j : prime) {

            if (i * j > n) break;

            vis[i * j] = 1;

            if (!(i % j)) break;

        }

    }

}

时间复杂度和空间复杂度都是O(n)

 

反素数

顾名思义,素数就是因子只有两个的数,那么反素数,就是因子最多的数(并且因子个数相同的时候值最小),所以反素数是相对于一个集合来说的

在一个正整数集合中,因子最多并且值最小的数,就是反素数

对于任意整数 ,其质因子不会超过10个,并且质因子的指数之和不会超过31

反素数肯定是从2  开始的连续素数的幂次形式的乘积

当 ,则x是反素数的必要条件是 ,其中c1>=c2...>=c10>=0

根据该性质,我们可以通过dfs枚举每个质因子的指数,进而求出可能为反素数的数。再求其因子个数,根据定义,取相同因子个数中最小的那个数即可筛选出所有反素数

 

cf 27E

求具有给定除数的最小自然数,并且保证答案不超过1e18

可以以因子数作为dfs的返回条件基准,并且不断更新找到的最小值即可(即反素数的dfs求解形式)

证明:
设最小的拥有n个因子的正整数为Q,将其分解质因数之后为

因为是求因数的个数,因此就是对于各个指数进行排列组合,可以得到

n=

和反素数类似的思路,最终得到的n的分配方案一定是连续的指数序列,以2开头,同时指数序列要满足单调不升

具体就是进行dfs,质数最大不超过64(1e18<2^64),并且质数序列最大值为53(2*3*5*7...*53>1e18)

具体答案可能会爆long long 因此特判是否小于0

ll n;

ll ans=LLONG_MAX;

ll prime[20]={0,2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53};

void dfs(ll cur,ll cnt,ll p,ll k){

    //cur表示当前的数值

    //cnt表示因数个数

    //p表示枚举到第p个质数

    //k表示枚举到的第p个质数的指数为k,同时这是第p+1个质数的指数上限

    if(cur>ans) return;

    if(cur<0) return;//答案已经超过1e18了

    if(cnt>n) return;

    if(cnt==n){

        ans=min(ans,cur);

        return;

    }

    if(p>16) return;

    for(ll i=1;i<=k;i++){

        cur*=prime[p];

        dfs(cur,cnt*(i+1),p+1,i);

        //前面任何一个因数都可以和当前的质数组合形成一个新的因数,因此增量是cnt*i

    }

}

 

正整数结构:
唯一分解定理

任何一个大于1的整数可以被分解为有限个素数的乘积,即n=

勒让德定理:

p幂次数的定义  正整数x含有素数p的幂次数记作vp(x),称为x的p幂次数

p进制数位和的定义 正整数x在p进制下的数位之和记作Sp(x),称为x的p进制数位

若p为素数,则vp(n!)=

分解质因数

Pollard-Rho算法(模板P4718)

勒让德定理法

对于一个阶乘n!的分解质因数,可以通过勒让德定理,快速求得n!中某个质因子p的幂次,并且可以避免Pollard-Rho中可能的大数运算

需要O(n)预处理素数表

const int N = 1e6 + 7;

bool vis[N];

vector<int> prime;

void get_prime(int n) {

    for (int i = 2;i <= n;i++) {

        if (!vis[i]) prime.push_back(i);

        for (auto j : prime) {

            if (i * j > n) break;

            vis[i * j] = 1;

            if (!(i % j)) break;

        }

    }

}

int legendre_fact(int n, int p) {

    int cnt = 0;

    while (n) {

        cnt += n / p;

        n /= p;

    }

    return cnt;

}

void get_fact_pfactor(int n, vector<pair<int, int>> &pfactor) {

    for (auto i : prime) {

        int cnt = legendre_fact(n, i);

        if (cnt) pfactor.push_back({ i,cnt });

    }

}

 

因数:
设n>1,n=

由排列组合可以得到 个

nk的正因数有 个

n的正因数和是

[1,n]内正因数集合大小约为nlogn

1e9内的数,正因数最多有1344个,1e18内的数,正因数最多有103680个,正因数的上界约为2

n的正因数个数期望约为ln n

 

牛客24多校8 E

给定n,问有多少m<=n满足n mod m=S(m) 其中S(m)表示m的十进制下的数位和

同时由模运算可知,必然有S(m)<m,以及m|n-S(m),也就是m是约数

又因为S(m)<=9*(log m),由数据范围可知,数位和不会超过108

由于一个数的约数个数大约是O(n^(1/3))级别的,但我们不能直接根号n地枚举约数,而是应该先分解质因数后再组合起来

因此可以枚举S(m)后分解质因数

考虑对[n-108,n]直接进行质因数分解,使用区间筛或者Pollard_Rho

再枚举因数,判断数位和是否满足条件

const int MOD=998244353;

int ksm(int a,int b){

    int ret=1;a%=MOD;

    for(;b;b>>=1,a=(ll)a*a%MOD)if(b&1)ret=(ll)ret*a%MOD;

    return ret;

}

long long ksm(long long a,long long b,long long p){

    long long ret=1;

    for(;b;b>>=1,a=(__int128)a*a%p)

        if(b&1)ret=(__int128)ret*a%p;

    return ret;

}

bool Miller_Rabin(long long p){

    if(p<3||(p&1)==0)return p==2;

    long long s,a,k=0,t=p-1;

    while(!(t&1)){

        t>>=1;

        k++;

    }

    for(int tc=1;tc<=9;tc++){

        a=rand()%(p-2)+2;//生成[2,p-1]的质数

        s=ksm(a,t,p);

        if(s==1||s==p-1)continue;

        for(int i=0;i<k-1;i++){

            s=(__int128)s*s%p;

            if(s==p-1)break;

        }

        if(s!=p-1)return 0;

    }

    return 1;

}

long long Pollard_Rho(long long x){

    long long s=0,t=0;

    long long c=(long long)rand()%(x-1)+1;

    int step=0,goal=1;

    long long val=1;

    for(goal=1; ;goal<<=1,s=t,val=1){

        for(step=1;step<=goal;step++){

            t=((__int128)t*t+c)%x;

            val=(__int128)val*abs(t-s)%x;

            if(step%127==0){

                //隔一段时间算一次

                long long d=gcd(val,x);

                if(d>1)return d;

            }

        }

        long long d=gcd(val,x);

        if(d>1)return d;

    }

}

 

// decom存放的是质因数

vector<long long> decom;

 

void solve1(long long x){

    if(x<2)return;

    if(Miller_Rabin(x)){

        decom.push_back(x);// 这里的x是质因数

        return;

    }

    long long p=x;

    while(p>=x)p=Pollard_Rho(x);//may be p=x;

    while(x%p==0)x/=p;

    solve1(x);solve1(p);

}

int S(ll x){

    int ret=0;

    while(x){ret+=x%10;x/=10;}

    return ret;

}

ll p[105],q[105],ans=0;

void dfs(int step,ll m,int r){

    if(step==(int)decom.size()+1){

        if(m>r && r==S(m)) ans++;    

        return;

    }

    dfs(step+1,m,r);

    ll tmp=1;

    for(int i=1;i<=q[step];i++){

        tmp*=p[step];

        dfs(step+1,m*tmp,r);

    }

}

 

void solve(){

    ll n;cin>>n;

    ans=0;

    for(int r=1;r<=108;r++){

        ll t=n-r;

        decom.clear();

        solve1(t);

        int cnt=0;

        memset(p,0,sizeof p);

        memset(q,0,sizeof q);

        for(auto v:decom){

            p[++cnt]=v;

            while(t%v==0){

                t/=v;

                q[cnt]++;

            }

        }

        dfs(1,1,r);

    }

    cout<<ans<<"\n";

}

 

PR算法可以参考:

Pollard Rho算法_rollard rho-CSDN博客

Pollard-Rho - BBD186587 - 博客园 (cnblogs.com)

Miler_Rabin 素性检验

由费马小定理可以得到,如果p是质数,那么对于所有1<=x<p的x都有

考虑逆否定理,如果存在x使得 不成立,那么p一定不是质数,因此可以通过随机一些x来判定

二次探测定理:在p是质数的情况下,如果 ,则

于是我们将p分解为q2^(k)+1后可以先算出y=x^q,然后每次将y平方,用二次探测定理判定

 

Pollard-Rho(PR算法)

假设a是合数,考虑怎样将其分解为x*y的形式,其中x,y>1

设f(x)=(x^2+c) mod a,其中c是随机选的一个常数

(这样生成数列的轨迹很像一个希腊字母rho,因此称为pollard rho)

然后可以进行一下过程:

先设定初始值x=y,然后每次x=f(x),y=f(f(y)),直到进行完操作之后gcd(x-y,a)!=1或者x=y为止,如果是前者说明分解成功,否则分解失败,重新选c,x开始

这个过程可以用倍增将gcd的log优化掉(板子可以参考上面的题目)

P4718

判断是否是质数,如果不是就输出它的最大质因子

const int MOD=998244353;

int ksm(int a,int b){

    int ret=1;a%=MOD;

    for(;b;b>>=1,a=(ll)a*a%MOD)if(b&1)ret=(ll)ret*a%MOD;

    return ret;

}

i64 ksm(i64 a,i64 b,i64 p){

    i64 ret=1;

    for(;b;b>>=1,a=(__int128)a*a%p)

        if(b&1)ret=(__int128)ret*a%p;

    return ret;

}

bool Miller_Rabin(i64 p){

    if(p<3||(p&1)==0)return p==2;

    i64 s,a,k=0,t=p-1;

    while(!(t&1)){

        t>>=1;

        k++;

    }

    for(int tc=1;tc<=9;tc++){

        a=rand()%(p-2)+2;//生成[2,p-1]的质数

        s=ksm(a,t,p);

        if(s==1||s==p-1)continue;

        for(int i=0;i<k-1;i++){

            s=(__int128)s*s%p;

            if(s==p-1)break;

        }

        if(s!=p-1)return 0;

    }

    return 1;

}

i64 Pollard_Rho(i64 x){

    i64 s=0,t=0;

    i64 c=(i64)rand()%(x-1)+1;

    int step=0,goal=1;

    i64 val=1;

    for(goal=1; ;goal<<=1,s=t,val=1){

        for(step=1;step<=goal;step++){

            t=((__int128)t*t+c)%x;

            val=(__int128)val*abs(t-s)%x;

            if(step%127==0){

                //隔一段时间算一次

                i64 d=gcd(val,x);

                if(d>1)return d;

            }

        }

        i64 d=gcd(val,x);

        if(d>1)return d;

    }

}

// decom存放的是质因数

vector<i64> decom;

void cal(i64 x){

    if(x<2)return;

    if(Miller_Rabin(x)){

        decom.push_back(x);// 这里的x是质因数

        return;

    }

    i64 p=x;

    while(p>=x)p=Pollard_Rho(x);//may be p=x;

    while(x%p==0)x/=p;

    cal(x);cal(p);

}

//cal(n)后decom存放的是n的质因数

void solve(){

    i64 n;

    cin>>n;

    decom.clear();

    cal(n);

    i64 ans=*max_element(decom.begin(),decom.end());

    if(ans==n){

        cout<<"Prime\n";

    }else{

        cout<<ans<<'\n';

    }

}

 

 

计算几何:

abc198 c:每次移动都只能移动固定距离a,但实际上任意(小于a)的直线距离,都可以通过移动两次得到(构造等腰三角形)

二维计算几何:

在 C/C++ 语言中,一般取π为 acos(-1),只有这个值是最接近π的浮点数

平面直角坐标系与极坐标系的相互转换

点A( , )的直角坐标系(x,y)可表示为x= cos       y= sin 进而有 ,因为y/x对应的 有两个可能的值,因此要根据x,y的值来确定方向,可以调用库函数: =atan2(y,x) 值域为(-π,π]

平面向量的坐标表示,取与横轴和纵轴方向相同的单位向量i,j作为一组基底根据平面向量基本定理,平面上的所有向量与有序实数对(x,y)一一对应

 

图形的记录:

记录点就可以构造结构体,利用结构体去记录

直线与射线:用直线上一点和直线的方向向量来记录

对于特殊曲线,如函数图像一般记录解析式,对于圆,直接记录圆心和半径即可

 

判断一个点在直线的哪边:利用向量积的性质,计算一个端点与该点连线的向量与直线的方向向量的向量积(叉乘、外积),如果为负,说明在上方;如果为0,证明在直线上;如果为正,说明在下方

 

有时也可以直接用点坐标去表示向量,即

struct node{

int x,y;

}

node x1,x2;

node vec1;

vec1.x=x1.x-x2.x,vec1.y=x1.y-x2.y;

或者在定义结构体时直接定义成

struct point

{

    int x,y;

    point (){}

    point (int a,int b):x(a),y(b){}

    point operator +(const point &a)const

    {

        return point(x+a.x,y+a.y);

    }

    point operator -(const point &a)const

    {

        return point(x-a.x,y-a.y);

    }

    int operator ^(const point &a)

    {

        return x*a.y-y*a.x;

    }

}p[3],o;

这样计算叉积就可以写作

(o-p[i])^(o-p[(i+1)%3])

 

判断两条线段是否相交:先判断特殊情况:两线段平行、三点共线;快速排斥实验:规定「一条线段的区域」为以这条线段为对角线的,各边均与某一坐标轴平行的矩形所占的区域,那么可以发现,如果两条线段没有公共区域,则这两条线段一定不相交,未通过快速排斥实验是两线段无交点的 充分不必要条件;跨立实验:如果两线段a,b,b线段的两个端点分布在a线段所在直线的两侧,且a线段的两个端点分布在b线段所在直线的两侧,就说a,b两线段通过了跨立实验,即两线段相交(注意两线段共线但不相交也可以通过跨立实验,因此想要准确判断还需要与快速排斥实验结合)

 

判断一点是否在任意多边形内部:

光线投射算法:

我们先特判一些特殊情况,比如「这个点离多边形太远了」。考虑一个能够完全覆盖该多边形的最小矩形,如果这个点不在这个矩形范围内,那么这个点一定不在多边形内。这样的矩形很好求,只需要知道多边形横坐标与纵坐标的最小值和最大值,坐标两两组合成四个点,就是这个矩形的四个顶点了;以及点在多边形的某一边或顶点上

考虑以该点为端点引出一条射线,如果这条射线与多边形有奇数个交点,则该点在多边形的内部,否则在该点在多边形的外部,记为奇内偶外

可以随机取这条射线所在直线的斜率,建议为无理数以避免出现射线与多边形某边重合的情况

例:点是否在三角形内部P1355,设三角形的三个端点A,B,C,需要判断的点是D

可以用海伦公式计算面积SABD+SBCD+SACD与SABC的大小关系,易得只有当两者的面积相同时才有点在三角形内部

double hl(int a,int b,int c,int d,int e,int f){
    double x,y,z,p;

    x=sqrt((a-c)*(a-c)+(b-d)*(b-d));

    y=sqrt((a-e)*(a-e)+(b-f)*(b-f));

    z=sqrt((c-e)*(c-e)+(d-f)*(d-f));

    p=(x+y+z)/2;

    return sqrt(p*(p-x)*(p-y)*(p-z));

}

要注意的是精度问题,可能要将各个面积结果都乘100才能正确判断面积大小关系

即sum=(int)hl(a,b,c,d,e,f)*100;

还有一种方法是利用叉积

顺时针或逆时针求一遍叉积,所有的结果都大于0或小于0,则点在三角形内部,如果既有大于0又有小于0 的说明在外部,最后判断在边界上的,这种情况是有一个叉积为0,剩下的就是在定点的情况

 

求两直线的交点:
首先,我们需要确定两条直线相交,只需判断一下两条直线的方向向量是否平行即可。如果方向向量平行,则两条直线平行,交点个数为0。进一步地,若两条直线平行且过同一点,则两直线重合

如果两直线相交,则交点只有一个,我们记录了直线上一个点和直线的方向向量,所以只需要知道这个交点该点的距离l,再将这个点沿方向向量移动l个单位长度即可,可以构造三角形,利用正弦定理求l

 

以整点为顶点的线段,如果边dx和dy都不为0,经过的格点数是gcd(dx,dy)+1,当然,如果要算一整个图形的,多加的点会被上一条边计算吗,也就不需要加了,那么一条边覆盖的点的个数为gcd(dx,dy),其中dx,dy分别为线段横向占的点数和纵向占的点数,如果dx或dy为0,则覆盖的点数为dy或dx

 

求两圆的交点:将圆圆心与交点相连,求出两圆心连线与该连线所成角,这样,将两圆心连线的方向向量旋转这个角度,就是圆心与交点相连形成的半径的方向向量,之后沿方向向量向圆心评议半径长度即可

 

任意一个多边形的面积等于按顺序求相邻两个点与原点组成的向量的叉积之和的一半

即单个区域的面积为int area(int a, int b) { return p[a].x * p[b].y - p[a].y * p[b].x; }(P为以结构体点为元素的数组)

x[n+1]=x[1],y[n+1]=y[1];

for(int i=1;i<=n;i++) ans+=(x[i]*y[i+1]-x[i+1]*y[i]);

ans=abs(ans/2);

 

判断一个点在圆内,一个点在圆外时可以使用异或,即

(dist(x1,y1,x[i],y[i])<r[i])^(dist(x2,y2,x[i],y[i]))<r[i]              P1652

 

牛客24多校10 F

给定一个{1...N}*{1...N}的二维平面点的排列,从前到后一次插入一个初始为空集合;如果插入后会导致三点共线,那么会跳过这次插入,输出每次插入是否成功

因为每行每列都至多有2个点,因此最多插入2n个点(只是一个大致的值,n<=46时,一定能找到这2n个点,否则不确定)

如果一个点插入时会导致三点共线,那么也就是我们可以找到两个点使得经过这两个点的直线穿过

当前点

将判断某个点不能被插入提前到每次成功插入点时,维护被已有点的直线覆盖的点的集合

在插入点时,一种可能的跟新方式是枚举已有的点,去标记直线上的点

但另一种更快速的方式是按照斜率进行跳跃

将直线的斜率表示为(del(x),del(y)),其中gcd(del(x),del(y))=1,del(x)>0或者del(y)=1,由于不存在三点共线,因此斜率互不相同,每次标记的点数是

void solve(){

    int n;

    cin>>n;

    vector<array<int,2>> pts;

    auto valid=[&](int a,int b){

        return 0<=a && a<n && 0<=b && b<n;

    };

    vector<vector<bool>> ban(n,vector<bool>(n));

    for(int i=0;i<n*n;i++){

        int x,y;

        cin>>x>>y;

        x--,y--;

        if(ban[x][y]){

            cout<<0;

            continue;

        }

        cout<<1;

        for(auto [a,b]:pts){

            int u=a-x;

            int v=b-y;

            int g=gcd(u,v);

            u/=g;

            v/=g;

            a=x,b=y;

            while(valid(a,b)){

                ban[a][b]=1;

                a+=u;

                b+=v;

            }

            a=x,b=y;

            while(valid(a,b)){

                ban[a][b]=1;

                a-=u;

                b-=v;

            }

        }

        pts.push_back({x,y});

    }

    cout<<'\n';

}

 

25 杭电 春 9 1003

乌蒙是一种八边形,其中满足,A,B,C,D,E,F,G,H 八个点按逆时针构成简单凸多边形,且各个角的角度都是135度,AB=CD=EF=GH,BC=DE=FG=HA

问对于平面中的点,有多少种方法可以选择8个点构成乌蒙

基于两组相等的边,不难发现一个乌蒙可以拆成两个大小相等正方形

因此考虑枚举正方形,对于正方形ABCD,枚举A,B后C,D就确定了,因此可以O(n^2 log n)枚举所有正方形

因为可以构成乌蒙的正方形必须中心点是重叠的,因此同时使用map套map统计每个中心点,每种边长的正方形数量就可以得到答案

注意因为在处理中心点时会/2,可能导致下取整的情况,因此存储点时使用两倍点的坐标来避免这个问题

同时为了数正方形时没有重复,我们需要指定一个正方向,例如 u>0,v >= 0,限制y轴为正向

void solve() {

    int n;

    cin >> n;

    vector<int> x(n), y(n);

    vector<array<int, 2>> p(n);

    map<array<int, 2>, map<i64, int>> mp;

    for (int i = 0; i < n; i++) {

        int a, b;

        cin >> a >> b;

        a *= 2, b *= 2;

        p[i] = {a, b};

    }

    sort(p.begin(), p.end());

    for (int i = 0; i < n; i++) {

        for (int j = 0; j < n; j++) {

            if (i == j) {

                continue;

            }

            auto [x1, y1] = p[i];

            auto [x2, y2] = p[j];

            int u = y1 - y2;

            int v = x2 - x1;

            if (u > 0 && v >= 0) { // 唯一确定一个正向

                // 判断有没有这个长度的点

                if (lower_bound(p.begin(), p.end(), array<int, 2> {x1 + u, y1 + v})

                        != upper_bound(p.begin(), p.end(), array<int, 2> {x1 + u, y1 + v})) {

                    if (lower_bound(p.begin(), p.end(), array<int, 2> {x2 + u, y2 + v})

                            != upper_bound(p.begin(), p.end(), array<int, 2> {x2 + u, y2 + v})) {

                        int xx = (x1 + x2 + u) / 2;

                        int yy = (y1 + y2 + v) / 2;

                        mp[{xx, yy}][1ll * u * u + 1ll * v * v]++;

                    }

 

                }

            }

        }

    }

    i64 ans = 0;

    for (auto [_, v] : mp) {

        for (auto [_, num] : v) {

            ans += 1ll * num * (num - 1) / 2;

        }

    }

    cout << ans << '\n';

}

 

 

距离:

欧式距离:

n维空间的欧式距离:

曼哈顿距离:

n维的曼哈顿距离:

曼哈顿距离是一个非负数,点到自身的曼哈顿距离为0,从点i到j的直接距离不会大于途径的任何其他点k的距离

切比雪夫距离:

二维的切比雪夫距离:
n维的切比雪夫距离:

曼哈顿距离与切比雪夫距离之间的转化:
曼哈顿坐标系是通过切比雪夫坐标系旋转45°后,再缩小到原来的一半得到的

将一个点(x,y)的坐标变为(x+y,x-y)后,原坐标系中的曼哈顿距离等于新坐标系中的切比雪夫距离

将一个点(x,y)的坐标变为( )后,原坐标系中的切比雪夫距离等于新坐标系中的曼哈顿距离

 

碰到求切比雪夫距离或曼哈顿距离时,我们往往可以相互转化来求解,如洛谷5098,就从求两两点之间的曼哈顿距离 的最大值,转化成了求 ,这两个问题,显然是求后一个更为简单,因为只需要维护所有点中x,y的最大和最小值即可,不再需要两两配对去求值

 

杭电 25 春3 1010

有n个点和m个配送站,一个点和配送站之间的距离定义为两个点之间的曼哈顿距离

对于每一个配送站,定义其距离为到所有点距离中的最大值

问配送站中距离的最小值是多少

将曼哈顿距离转化为切比雪夫距离,也就是

|x1-x2|+|y1-y2|=max(x1-x2+y1-y2,x1-x2+y2-y1,…)=max(|(x1+y1)-(x2+y2)|,|(x1-y1)-(x2-y2)|),也就是

A(x1+y1,x1-y1)和B(x2+y2,x2-y2)之间的切比雪夫距离

将曼哈顿转化为切比雪夫距离后,两者的x,y坐标就相互独立了,因为我们是求两者差值中的较大者,也就是可以分别求出最小和最大的x,y坐标,然后就能得到最大距离了

再对每个配送站计算下距离它们最小和最大的x和y,可能的距离就是x-minx,maxx-x,maxy-y,y-miny取max就是距离最远的客户的距离

这样计算显然更方便且更容易维护

https://acm.hdu.edu.cn/contest/view-code?cid=1152&rid=5271

 

cf 1968e

给定 n,要求在n×n 的网格上选 n 个点,令 H 为这些点两两之间的曼哈顿距离的集合,要求给出一种构造方案,使得 H 中不同的元素个数最多

首先考虑 H 中不同的元素应该有哪些。显然曼哈顿距离的最大值是两个对着的角上放两个点,这两个点之间的曼哈顿距离为 2(n−1)。于是大胆猜想:正确的 H 应该包含 0 到2(n−1) 之间的全部值。随便找一个 n 试试

接着先从简单的形状自行构造

例如对于n=4时候情况,画出满足猜想的图

1100 1100 1100 1100
0000 0001 0000 0000
1000 0000 0010 0000
0001 0001 0001 0101

 

我们发现,上面四种正确的构造方案中,只有第三种看上去有点规律性:填入 (1,2) 和左上 - 右下对角线上除 (2,2) 外的所有点,并据此去进行构造n=5,6的形状,观察是否满足

证明其正确性:

首先2≤n≤4 时是正确的;

接着我们假设 n=k(k≥4) 时是正确的,即左上部的k×k 的部分中的 H 已经包含了0 至2×(k−1) 的所有整数,此时我们往右下角加的点(k+1,k+1) 与 (1,1) 和(1,2) 的曼哈顿距离恰好分别是 2k 和2k−1,这样当 n=k+1 时 H 中就包含了 0 至 2k 的所有数

 

cf 1979E

找一个三角形,使得其中两两之间的曼哈顿距离都是d

满足与一个点的曼哈顿距离为定值的点都分布在直线上

可以通过枚举这个被确定的点来寻找

void solve() {

    //首先,对于这三个点,我们画一个矩形,一定有一个点是在矩形的角上

    //枚举那个在角上的点,不妨设它是左下角,那么和它距离为d的点就在一条斜线上,曼哈顿距离和d那么就是x,y值的和为x+y+d(或者x+y-d)

    //只要在某个区间里面,有两个距离与其为d的,那么就说明找到了

    int n,d;

    cin>>n>>d;

    vector<int> x(n),y(n);

    for(int i=0;i<n;i++){

        cin>>x[i]>>y[i];

    }

    int I=0,J=0,K=0;

    auto work=[&](){

        vector<array<int,3>> a(n);//要记录下标  

        //先按总和排再按x排

        for(int i=0;i<n;i++){

            a[i]={x[i]+y[i],x[i],i};

        }

        sort(a.begin(),a.end());

        vector<array<int,4>> b;                        

        for(auto[s,x,i]:a){

            auto it=lower_bound(a.begin(),a.end(),array{s,x+d/2,0});

            if (it!=a.end() && (*it)[0]==s && (*it)[1]==x+d/2) {//说明这个点是(x-d/2,y+d/2)

                b.push_back({s,x,i,(*it)[2]});//添加找到的那个点的下标,这样就找到了一组满足曼哈顿距离要求的点对

            }

        }

        //现在枚举左下角那个点

        //因为我们后续进行了旋转,因此只需要考虑s为x+y+d即可,并且前面已经找到了s相同的点对,因此这里只要找到一个点就说明找到了满足要求的三角形

        for(auto[s,x,i]:a){

            auto it=lower_bound(b.begin(),b.end(),array{s+d,x,0,0});//现在搜索的这个点的s必须是s+d,并且它的x要大于等于x

            if (it!=b.end() && (*it)[0]==s+d && (*it)[1]<=x+d/2){//因为是左下角的点,因此其余两个点一定在其右上方,因此有x的范围要求

                I=i+1;

                J=(*it)[2]+1;

                K=(*it)[3]+1;

                return;

            }

        }

    };

    //先转90度再做

    for(int t=0;t<4;t++){

        work();

        if(I>0){

            break;

        }

        for(int i=0;i<n;i++){//所有点旋转90度

            swap(x[i],y[i]);

            x[i]*=-1;

        }

    }

    cout<<I<<' '<<J<<' '<<K<<'\n';  

}

 

cf 1971F

题意:

给定整数 r,求出与(0,0) 的欧几里得距离大于或等于r 但严格小于r+1 的格点的数量

考虑坐标系除原点外可以被分成四个部分,而r>=1,因此原点对答案无贡献,因此可以只计算第一象限部分的答案,再乘上4就是最终的答案

不妨枚举0<=x<=r,令ymax为满足条件的最大y,而ymin同理,那么该x对于答案的贡献就是ymax-ymin+1,于是ans=4*

于是接下来考虑如何求出对应的y的最值,由 可得

确定数量需要用不等式中较大值减较小值,又因为根号会自动向下取整,会漏掉一些数字,所以应向上取整

void solve(){

    ll r;

    cin>>r;

    ll ans=0;

    for(ll i=1;i<=r;i++){

        ans+=ceil(sqrt((r+1)*(r+1)-i*i))-sqrt(r*r-i*i);

    }

    cout<<ans*4<<'\n';

}

 

汉明距离:
汉明距离是两个字符串之间的距离,它表示两个长度相同的字符串对应位字符不同的数量;,即可以认为是对两个字符串进行异或运算,结果为1的数量就是两个之间的汉明距离

 

Pick 定理:平面上以整点为顶点的简单多边形的面积 = 边上的点数/2 + 内部的点数 - 1

取格点组成图形的面积为一单位,在平行四边形格点,皮克定理依然成立,套用于任意三角形格点,皮克定理则是A=2*i+b-2(面积为A,内部格点数目为i,边上格点数目为b)

对于非简单的多边形,皮克定理A=i+b/2-X(P),X(P)表示P的欧拉特征数

 

凸包:

凸多边形是指所有内角大小都在[0, ]范围内的简单多边形

在平面上能包含所有给定点的最小凸多边形叫做凸包

凸包用最小的周长围住了给定的所有点。如果一个凹多边形围住了所有的点,它的周长一定不是最小,如下图。根据三角不等式,凸多边形在周长上一定是最优的

Andrew算法求凸包

时间复杂度为O(nlogn),n为待求凸包点集的大小

首先把所有点以横坐标为第一关键字,纵坐标为第二关键字排序

显然排序之后最小的元素和最大的元素一定在凸包上,因为这是这些点集的边界。而且因为是凸多边形,对于下凸壳,我们如果从一个点出发逆时针走,轨迹一定是左拐的,一旦出现右拐,就说明这一段不在凸包上。因此我们可以用一个单调栈来维护上下凸壳

因为从左向右看,上下凸壳所旋转的方向不同,为了让单调栈起作用,我们首先升序枚举出下凸壳,然后降序求出上凸壳

求凸壳时,一旦发现即将进栈的点P和栈顶的两个点(S1,S2,其中S1为栈顶)行进的方向向右旋转,即叉积小于0: (如果不需要保留位于凸包边上的点,那么就将 改成 ,同理,后面一个条件改为 ),则弹出栈顶,回到上一步,继续检测,直到 或者栈内只剩一个元素为止

// stk[] 是整型,存的是下标

// p[] 存储向量或点

tp = 0; // 初始化栈

std::sort(p + 1, p + 1 + n); // 对点进行排序

stk[++tp] = 1;

// 栈内添加第一个元素,且不更新 used,使得 1 在最后封闭凸包时也对单调栈更新

for (int i = 2; i <= n; ++i) {

while (tp >= 2  && (p[stk[tp]] - p[stk[tp - 1]]) * (p[i] - p[stk[tp]]) <= 0)

// * 操作符重载为叉积

used[stk[tp--]] = 0;

used[i] = 1; // used 表示在凸壳上

stk[++tp] = i;

}

int tmp = tp; // tmp 表示下凸壳大小

for (int i = n - 1; i > 0; --i)

if (!used[i]) {

// ↓求上凸壳时不影响下凸壳

while (tp > tmp && (p[stk[tp]] - p[stk[tp - 1]]) * (p[i] - p[stk[tp]]) <= 0)

used[stk[tp--]] = 0;

used[i] = 1;

stk[++tp] = i;

}

for (int i = 1; i <= tp; ++i) // 复制到新数组中去

h[i] = p[stk[i]];

int ans = tp - 1

最后凸包上有ans个元素(额外存储了1号带你,因此h数组中有ans+1个元素),并且按逆时针方向排序

Graham扫描法

时间复杂度也为O(nlogn)

首先找到所有点中,纵坐标最小的一个点P,根据凸包的定义可以知道,这个点一定在凸包上,然后将所有点以相对于P的极角大小为关键字进行排序

同样,我们考虑从P点出发,在凸包上逆时针走,那么我们经过的所有节点一定都是「左拐」的。形式化地说,对于凸包逆时针方向上任意连续经过的三个点P1,P2,P3,一定满足

新建一个栈用于存储凸包的信息,先将  P 压入栈中,然后按照极角序依次尝试加入每一个点。如果进栈的点 P0  和栈顶的两个点  P1,P2(其中  P1 为栈顶)行进的方向「右拐」了,那么就弹出栈顶的 P1 ,不断重复上述过程直至进栈的点与栈顶的两个点满足条件,或者栈中仅剩下一个元素,再将  P0 压入栈中

struct Point {

double x, y, ang;

Point operator-(const Point& p) const { return {x - p.x, y - p.y, 0}; } //用于计算向量

} p[MAX];

 

double dis(Point p1, Point p2) {

return sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y));

}

 

bool cmp(Point p1, Point p2) {

if (p1.ang == p2.ang) {

return dis(p1, p[1]) < dis(p2, p[1]);

}

return p1.ang < p2.ang;

}

 

double cross(Point p1, Point p2) { return p1.x * p2.y - p1.y * p2.x; }

//叉乘

 

int main() {

for (int i = 2; i <= n; ++i) { //找纵坐标最小(相同时找横坐标更小的)点P,之后将以其为标准

if (p[i].y < p[1].y || (p[i].y == p[1].y && p[i].x < p[1].x)) {

std::swap(p[1], p[i]);

}

}

for (int i = 2; i <= n; ++i) {

p[i].ang = atan2(p[i].y - p[1].y, p[i].x - p[1].x); //利用库函数atan2计算极角

}

std::sort(p + 2, p + n + 1, cmp);

sta[++top] = 1;  //向栈内添加第一个元素,top总是指向当前栈顶的元素,因此是++top

for (int i = 2; i <= n; ++i) {

while (top >= 2 && cross(p[sta[top]] - p[sta[top - 1]], p[i] - p[sta[top]]) < 0) {

top--;

}

sta[++top] = i;

}

return 0;

}

 

旋转卡壳:
在凸包算法的基础上,通过枚举凸包上某一条边的同时维护其他需要的点,能够在线性时间内求解如凸包直径、最小矩形覆盖等和凸包性质相关的问题

可以理解为:根据我们枚举的边,可以从每个维护的点画出一条或平行或垂直的直线,为了确保对于当前枚举的边的最优性,我们的任务就是使这些直线能将凸包正好卡住。而边通常是按照向某一方向旋转的顺序来枚举,所以整个过程就是在边「旋转」,边「卡壳」

如:求凸包直径(P1452,求所有点对之间的最长距离)

用凸包算法求出给定所有点的凸包,有着最长距离的点对一定在凸包上

由于凸包的形状,我们发现,逆时针地遍历凸包上的边,对于每条边都找到离这条边最远的点,那么这时随着边的转动,对应的最远点也在逆时针旋转,不会有反向的情况(点一定是尽量远离这条边的,因此不可能是在边逆时针旋转时,最远点是顺时针旋转靠近的),这意味着我们可以在逆时针枚举凸包上的边时,记录并维护一个当前最远点,并不断计算、更新答案

因为遍历凸包的边时是逆时针遍历的,因此,存储凸包点的数组(栈)自然也是要按照逆时针旋转的顺序排列,不过要提前将最左下角的1节点补到数组最后,这样在挨个枚举边(i,i+1)时,才能把所有边都枚举到

因为最远点只会是逆时针旋转的,因此对于每条边,只要持续检查j+1和j哪个点距离当前边的距离更大,并且判断点到边的距离时,因为边是相同的,因此可以利用边围成的三角形面积去判断,即利用叉积去计算

int sta[N], top; // 将凸包上的节点编号存在栈里,第一个和最后一个节点编号相同

bool is[N];

 

ll pf(ll x) { return x * x; } //计算平方

ll dis(int p, int q) { return pf(a[p].x - a[q].x) + pf(a[p].y - a[q].y); }

ll sqr(int p, int q, int y) { return abs((a[q] - a[p]) * (a[y] - a[q])); } //计算围成的面积

ll mx;

void get_longest() { // 求凸包直径

int j = 3;

if (top < 4) {//特殊的凸包形状

mx = dis(sta[1], sta[2]);

return;

}

for (int i = 1; i <= top; ++i) {

while (sqr(sta[i], sta[i + 1], sta[j]) <=sqr(sta[i], sta[i + 1], sta[j % top + 1]))

j = j % top + 1;  //逆时针找距离最远的那个点

mx = max(mx, max(dis(sta[i + 1], sta[j]), dis(sta[i], sta[j]))); //更新最值

}

}

最小矩形覆盖(P3187)

给定一些点的坐标,求能够覆盖所有点的最小面积的矩形

因为这次要求的是面积,因此与之前求凸包直径相比,现在要维护三个点,一个点为所美剧直线对面对面的点,用于求矩形的一条边,因此仍然可以用叉积来计算,相当于就是求凸包的直径,两个点为在不同侧面的点,可以用点积来算,因为点积计算的就是投影的长度,左右两个投影长度相加可以代表矩形的另一个边长

如果没有要求求出四个顶点,就可以直接用叉积和点积算出矩形的面积,设枚举到的边的端点为A,B,维护的三个点依次为C,E,D,那么面积就是

 

牛客24多校9 H

两个凸包A和B,A固定,B可以任意平移旋转翻转,但始终要保证A和B至少有一个公共点,求B能扫到的最大图形的周长

首先,答案为A的周长+2*派*B的直径

一方面,考虑找到B的直径,让这条直径在A的周围覆盖一圈,可以发现此时的图形由若干条线段和若干条圆弧组成,线段部分的和就是A的周长,圆弧部分可以拼成一个整圆,半径就是B的直径

另一方面,对于该图形外部的点,到A内部点的距离均大于B的直径,显然没法通过B覆盖到

而关于凸多边形直径的求法,可以用旋转卡壳

int n,m;

struct Node{

    i64 x,y;

}a[1000005],b[1000005];

int sta[1000005],top;

//凸包直径

i64 pf(i64 x){

    return x*x;

}

i64 dis(int p,int q){

    return pf(a[p].x-a[q].x)+pf(a[p].y-a[q].y);

}

i64 sqr(int p0,int p1,int p2){//围成的面积

    return (a[p1].x-a[p0].x)*(a[p2].y-a[p0].y)-(a[p2].x-a[p0].x)*(a[p1].y-a[p0].y);

}

i64 mx;

void get_longest(){

    mx=0;

    int j=2;

    auto chk=[&](int i){

        mx=max(mx,max(dis(sta[i+1],sta[j]),dis(sta[i],sta[j])));

    };

    for(int i=0;i<top;i++){

        while(sqr(sta[i],sta[i+1],sta[j])<=sqr(sta[i],sta[i+1],sta[(j+1)%top])){

            chk(i);

            j=(j+1)%top;

        }

        chk(i);

    }

    for(int i=0;i<top;i++){

        while(sqr(sta[i],sta[i+1],sta[j])<=sqr(sta[i],sta[i+1],sta[(j+1)%top])){

            chk(i);

            j=(j+1)%top;

        }

        chk(i);

    }

}

const ld pi=acos(-1.0);

ld dis2(int p,int q){

    return sqrtl(pf(b[p].x-b[q].x)+pf(b[p].y-b[q].y));

}

ld sqr2(int p0,int p1,int p2){

    return (b[p1].x-b[p0].x)*(b[p2].y-b[p0].y)-(b[p2].x-b[p0].x)*(b[p1].y-b[p0].y);

}

void solve(){

    cin>>m;

    for(int i=1;i<=m;i++){

        cin>>b[i].x>>b[i].y;

    }

    cin>>n;

    top=n;

    for(int i=1;i<=n;i++){

        cin>>a[i].x>>a[i].y;

        sta[i-1]=i;

    }

    sta[n]=1;

    get_longest();

    ld mx1=sqrtl(mx);//返回的是距离的平方

    //凸包周长

    ld ans=dis2(1,m);

    for(int i=1;i<=m-1;i++){

        ans+=dis2(i,i+1);

    }

    ans+=mx1*2.0*pi;

    cout<<fixed<<setprecision(15)<<ans<<'\n';

}

 

扫描线(POJ1151):
基础问题:在二维坐标系上,给出多个矩形的左下以及右上坐标,求出所有矩形构成的图形的面积

将整个图形分割成若干个小的矩形,小矩形的高就是扫过的距离,而小矩形的长需要我们去维护

给每一个矩形的上下边进行标记,下面的边标记为1,上面的边标记为-1,遇到一个矩形时,知道了标记为1的边,将这条边加入,扫描到-1时,说明这条边需要被删除,相当于每次要进行区间的加减,因此可以用线段树去维护扫描线的长度(当前矩形的长)

因为边的数据范围可能会比较大,因此可以用离散化

在建立线段树时,左子树是(l.mid),右子树是(mid,r),不能是mid+1,不然会使mid+1到mid这一段统计不到,因此建树时的边界就是l+1==r

因为进行了离散化,因此建树时的l,r实际上代表的是序号,且线段树最后的叶子节点也不再只是代表一个点,而是代表了一个区间(离散化后序号相差为1的纵坐标的差值),这也是为何这次的线段树中增加了参数

#define maxn 300

using namespace std;

int lazy[maxn << 3]; // 标记了这段区间是否完整存在

double s[maxn << 3];

//sum用来记录扫描线的长度

struct node1 {

double l, r;

double sum;

} cl[maxn << 3]; // 线段树

//从左到右扫描,因此每条边应该有三个属性:位置,即这条边的横坐标;边的长度:从哪个点开始到哪个点,即两个纵坐标

struct node2 {

double x, y1, y2;

int flag;

} p[maxn << 3]; // 坐标

// 定义sort比较

bool cmp(node2 a, node2 b) { return a.x < b.x; }

 

// 上传,更准确的说应该是向上传递,之前是递归更改,而修改根节点需要下面的点的值,虽然实现时还是向下询问下去

void pushup(int rt) {

if (lazy[rt] > 0)  //说明这段区间全部包括进去了,就不需要继续向下询问了,直接返回这段区间代表的实际长度

cl[rt].sum = cl[rt].r - cl[rt].l;

else

cl[rt].sum = cl[rt * 2].sum + cl[rt * 2 + 1].sum; 

//这种情况是比较符合一般对线段树的印象的,大区间的值可以由左子树和右子树上的值转移而来

}

 

// 建树

void build(int rt, int l, int r) {

if (r - l > 1) {

cl[rt].l = s[l];

cl[rt].r = s[r];

build(rt * 2, l, (l + r) / 2);

build(rt * 2 + 1, (l + r) / 2, r);

pushup(rt);

} else {

cl[rt].l = s[l];

cl[rt].r = s[r];

cl[rt].sum = 0;

}

return;

}

 

// 初始传入的线段树为1,递归更新

void update(int rt, double y1, double y2, int flag) {

if (cl[rt].l == y1 && cl[rt].r == y2) {  

//传入的边恰好是这个节点管辖的范围,并且不再向下更新也确保了长度不会被重复计算(lazy>0,直接计算这个区间,小于0时才再向下询问,这样在出边时也会优先消除较高一级的)

lazy[rt] += flag;

pushup(rt);//因为矩形的边的范围可能会有重叠,因此大区间的消除(lazy=0),并不一定代表其子树的区间没有能给扫描线贡献长度的区间了

return;

} else {

if (cl[rt * 2].r > y1) update(rt * 2, y1, min(cl[rt * 2].r, y2), flag);

if (cl[rt * 2 + 1].l < y2)

update(rt * 2 + 1, max(cl[rt * 2 + 1].l, y1), y2, flag);

pushup(rt); //最终要反映在根节点的sum上,因此在子树修改后再修改自身,也确保了上面的节点询问到该节点时,返回的值时正确的,及时修改也避免了反复的递归,因为pushup只有一层询问关系

}

}

 

int main() {

int temp = 1, n;

double x1, y1, x2, y2, ans;

while (scanf("%d", &n) && n) {

ans = 0;

for (int i = 0; i < n; i++) {

scanf("%lf %lf %lf %lf", &x1, &y1, &x2, &y2);

p[i].x = x1;

p[i].y1 = y1;

p[i].y2 = y2;

p[i].flag = 1;       //1、-1来表示是入边还是出边

p[i + n].x = x2;

p[i + n].y1 = y1;

p[i + n].y2 = y2;

p[i + n].flag = -1;

s[i + 1] = y1;

s[i + n + 1] = y2;

}

sort(s + 1, s + (2 * n + 1)); // 把y值的边离散化

sort(p, p + 2 * n, cmp); // 因为是从左到右扫描,把矩形的边的横坐标从小到大排序

//这样遍历p就相当于在进行扫描了

build(1, 1, 2 * n); // 以离散化后的“数据范围”进行建树

memset(lazy, 0, sizeof(lazy));

update(1, p[0].y1, p[0].y2, p[0].flag);

for (int i = 1; i < 2 * n; i++) {

//碰到一条边,是先加ans,再更新,因为扫过的面积,是更新前的扫描线的长度扫出来的

ans += (p[i].x - p[i - 1].x) * cl[1].sum;  //最终都把边长反映到最大的区间:树的根节点上

update(1, p[i].y1, p[i].y2, p[i].flag);

}

printf("Test case #%d\nTotal explored area: %.2lf\n\n", temp++, ans);

}

return 0;

}

 

或者可以利用其来解决二维数点(二维正交范围)的问题

二维数点的主要思路就是数据结构维护一维,然后枚举另一维

给一个长为n的序列,有m次查询,每次查区间[l,r]中值在[x,y]内的元素个数

这个问题就叫做二维数点。我们可以发现等价于我们要查询一个二维平面上矩形内的点的数量和(一个点就相当于以下标为横坐标,其值为纵坐标的点,矩形就相当于是以[l,r],[x,y]为长宽)。这个问题最简单的处理方法,扫描线 + 树状数组。

很显然,这个问题是一个静态的二维问题,我们通过扫描线可以将静态的二维问题转换为动态的一维问题。维护动态的一维问题就使用数据结构维护序列,这里可以使用树状数组

先将所有询问离散化,用树状数组维护权值,对于每次询问的l和r ,我们在枚举到l-1时统计当前位于区间[x,y]内的数的数量a,继续向后枚举,枚举到r时统计当前位于区间[x,y]内的数的数量b ,b-a即为该次询问的答案(洛谷P2163),在扫描线从左向右扫的过程当中,会在数据结构维护的那一维上产生一些修改和查询,如果查询的信息可差分的话可直接使用差分(一般用树状数组或线段树维护),否则需要使用分治

P1908逆序对

逆序对可以用扫描线的思维来做,根据扫描线的定义,可以处理成从后向前枚举每个位置i,求在区间[i+1,n]中,大小在区间[0,ai]中的点的个数(因为就是要让前面元素的值大于后面元素的值),如果数据范围很大,就要进行离散化

而从后往前进行枚举本质上就是一个for,用了树状数组去维护对于已经查询过的数,小于等于它的个数(动态的一维数据)

 

cf1854E

题目大意:有 n 个怪兽站成一排,第 i 只血量为 ai​。当你的攻击力是 k 时,你可以选择一段极长的连续的存活的怪兽,使它们血量同时减少 k,对于每个 k 求出最少攻击多少次可以杀死所有的怪兽

提供一个非常简单直观的O(nlogn+V) 做法。首先可以发现最小攻击次数和顺序无关,我们每次都是随便选一个连续段攻击,因为攻击的衍生特性,这个连续段上的元素一定会被攻击到。

那么我们可以转换一下攻击的表达:

设存活的怪兽为 1,死亡的怪兽为 0,则每次可以花费连续段的数量的代价使得所有仍存活的怪兽的血量减少 k。

这时候可以发现,所有怪兽减少的血量总是相等,01 序列的取值只与造成伤害的总量有关,设总量为 x。显然,怪兽 i 在x<ai时取 1,在x≥ai时取 0。所以我们可以按照 x 扫描线,维护一个初始全为 1 的序列,支持将一个 1 改成 0,并维护 1 的极长连续段个数。直接简单讨论就可以O(1) 完成。这一部分复杂度 O(V+n)。

对于扫描的过程进一步解释:
假如只考虑求 k=1 的答案。如果把 a 数组放到坐标轴上((i,ai) 为点的坐标),可以理解为 y 轴上有一根水平线,从下往上扫描。我们希望求出该水平线从某个节点的ai上移到下一个节点的aj所花费的代价,设水平线在ai时,高于水平线的点组成了x 个极大联通段,那我们这次移动的代价即为 x(aj−ai)

然后就直接暴力模拟就可以了,复杂度为调和级数O(nlogn)

const int N=2e5+5;

int n,m,b[N],c[N],x;

vector<int> p[N];

void solve() {

    c[0]=1;//最开始全部存活,连续段数目为1

    cin>>n;

    for(int i=1;i<=n;i++){

        cin>>x;

        m=max(m,x);//求最大值

        p[x].emplace_back(i);//对应数值上添加位置序号

        b[i]=1;//当前该位置的状态仍是存活

    }

    //计算连续段数目

    for(int i=1;i<=m;i++){//因为血量最高只有m,因此我们记录的累计造成的伤害最高不超过m

        //我们计算的是操作后的连续段数目

        c[i]=c[i-1];

        for(auto y:p[i]){//对恰好为这个血量的怪物进行处理

            c[i]+=(b[y-1]&b[y+1])-(!(b[y-1]|b[y+1]));

            //这个点将要变成0,因此如果左右都为1,那么相当于在原来的连续段中间断开,增加了一个连续段

            //同理,左右都为0,说明这里只有这个点是1,那么其变为0后连续段数目减少1

            b[y]=0;//在该累计伤害后,该位置的怪物死亡

        }

    }

    for(int i=1;i<=m;i++){//此时每次的伤害k

        ll ans=0;

        for(int j=0;j<=m;j+=i){//从伤害为0开始

            //每次可以花费极长1的连续段的数量的代价使得所有怪兽的血量减少k

            //这里计算的实际上是下一次进行操作要多少花费

            ans+=c[j];

        }

        cout<<ans<<' ';

    }

}

 

Cf 2046C

给定平面直角坐标系,以及n对点对(a[i],b[i]),需要找到一个点,使得这个点把坐标系分成四部分,那么这个点的权值就是四个部分中各自点对数量的最小值

需要最大化这个最小值,同时如果某个点正好在划分上,那么就优先给靠右上的区域

最大化最小值可以先考虑二分,不妨令我们二分得到的答案是k,那么也就是此时划分出来的四部分的点对数都大于等于k

考虑如何check,枚举横坐标x,把横坐标小于等于x的点全部加入线段树,这一步可以借鉴扫描线的思想,左边的树不断加点,右边的树不断减点,在线段树上二分出最小的纵坐标(也就是每个y值增加1,这样就是二分线段树值大于k的y值),使得(x,y)左上方的点数量大于等于k,然后再查询剩下3个方位是否满足

更进一步地说,先预处理将x,y离散化,然后遍历x,每个x实际上都会把坐标系分成两部分,并且用树状数组维护y区间个数和的信息

这样问题就转化成:给定两个序列,从某一个pos把两个序列分开,最终答案是四个部分各自和的最小值,要最大化这个最小值

void solve() {

    int n;

    cin>>n;

    vector<int> x(n),y(n);

    for(int i=0;i<n;i++){

        cin>>x[i]>>y[i];

    }

    vector<int> xs=x,ys=y;

    sort(xs.begin(),xs.end());

    sort(ys.begin(),ys.end());

    xs.erase(unique(xs.begin(),xs.end()),xs.end());

    ys.erase(unique(ys.begin(),ys.end()),ys.end());

    for(int i=0;i<n;i++){

        x[i]=lower_bound(xs.begin(),xs.end(),x[i])-xs.begin();

        y[i]=lower_bound(ys.begin(),ys.end(),y[i])-ys.begin();

    }

    int k=0;

    int x0=0,y0=0;

    Fenwick<int> fl(n),fr(n);

    for(int i=0;i<n;i++){

        fr.add(y[i],1);

    }

    vector<int> p(n);

    iota(p.begin(),p.end(),0);

    sort(p.begin(),p.end(),[&](int i,int j){ //按x坐标排序

        return x[i]<x[j];

    });

    for(int j=0;j<n;j++){// 枚举x

        int i=p[j];

        fr.add(y[i],-1);

        fl.add(y[i],1);// 点放置到左侧

        if(j+1<n && x[p[j+1]]==x[i]){

            //下一个点的x坐标和当前点的x坐标相同,那么直接跳过

            // 为了保证判断时所有在划分线上的点都被加入了

            continue;

        }

        while(true){

            if(j+1<2*(k+1)){

                break;

            }

            if(n-j-1<2*(k+1)){

                break;

            }

            // 计算划分边界

            int yl=max(fl.select(k),fr.select(k));

            int yr=min(fl.select(j-k),fr.select(n-1-j-1-k));

            if(yl>=yr){ // 导致右上方点数不足

                break;

            }

            k++;

            x0=xs[x[i]]+1;

            y0=ys[yr];

        }

    }

    cout<<k<<'\n';

    cout<<x0<<' '<<y0<<'\n';

}

 

扫描线的核心想法是通过一条假想的直线沿着特定方向扫过平面,将二维问题转化为一系列一维问题

例如将问题简化为离散时间,通过一维的移动来解决二维问题,关注事件点而不是整个区域

在这题中就体现在通过按x坐标排序后的点集逐点处理,并且随着扫描线的移动动态维护一些数值,同时移动后都尝试对问题进行处理

 

博弈论

通常来说,一般规律是异或和为0,元素个数是奇数还是偶数,少数的需要用动态规划去遍历所有情况

博弈问题,都会假设两个游戏的玩家是聪明的,两个人都会 「每次选择前,分析后续所有可能的结果」。
当选择一种方案后,可以让自己稳赢的时候,一定会选择这种方案;当无论怎么选都必输,才不情愿地选择某一个方案

不能找规律的,往往都需要用递归去判断所有情况,每次选择都让自己最优而对方最劣

因为每次各个玩家进行操作是相同的,并且具体执行操作的时候都是将当前玩家作为“先手”,因此可以用递归去解决

同样,每次操作都是相似的子问题,递归重复计算消耗较大时也可以用动归去优化

 

零和博弈,两种表示,一种是当前能得到区间能得到的最大值,一种是与对手的分数差,本质上是等价的,知道最大值自然能求出分数差,零和博弈的特征就是双方分数的和为定值

 

阶梯博弈

在阶梯博弈中,玩家每次操作可以选择任意一级台阶(第i级),并将其上的若干石子(至少1个)移动到下一级台阶(第i-1级)。如果石子位于第1级,则可以将其移出游戏(相当于丢弃)。,最后没有点可以移动的人输

如果把把所有奇数堆石子看成N堆石子,就可以当成尼姆博弈来求解

解析:
阶梯博弈的核心结论是:游戏的胜负仅由奇数级台阶(第1、3、5…级)上的石子数决定,偶数级台阶的石子不影响胜负。

石子从奇数级移到偶数级(如 3 → 2):相当于在Nim游戏中从堆中取走石子,因为石子从“有效区域”(奇数级)进入“无效区域”(偶数级),不再影响后续游戏。

石子从偶数级移到奇数级(如 2 → 1):对手可以立即将这些石子继续移动到偶数级(如 1 → 移出游戏),相当于抵消你的操作,因此偶数级的石子不影响胜负。

如果当前Nim和(奇数级上的元素的异或和)不为0,那么必胜,否则必败

因为如果不为0,先手,那么一定能够使得移动后nim和为0;此时对手再如何操作,在你的那一步一定能使得Nim和为0

同时最终状态一定是只有第1级上有石子,此时再移动使得为0,那么游戏就结束了,因此不为0是必胜的

 

POJ 1704

有 n 个棋子分布在数轴上,位置分别为 P₁, P₂, ..., Pₙ(已排序)。

两名玩家轮流移动棋子,每次可以选择一个棋子向左移动任意步,但不能跨越其他棋子或移出边界(即不能移到 <=0 的位置)。

无法移动的一方输。

设排序后的棋子位置为 P₁ < P₂ < ... < Pₙ。

定义 dᵢ = Pᵢ - Pᵢ₋₁ - 1(即相邻棋子之间的空格数)

将棋子移动转化为阶梯博弈的移动:

每次移动棋子 Pᵢ 向左 k 步,相当于把 dᵢ 减少 k,同时 dᵢ₊₁ 增加 k。

这类似于阶梯博弈中,将石子从第 i 级台阶移动到第 i-1 级台阶

阶梯博弈的胜负仅由奇数级台阶的石子数的异或和(Nim和)决定。

因为现在是只能向做移动,相当于最右侧是取出石子

在本题中,从右往左编号台阶:最右边的棋子 Pₙ 对应第 1 级台阶,Pₙ₋₁ 对应第 2 级台阶,依此类推。

因此,计算 dₙ, dₙ₋₂, dₙ₋₄, ...(奇数级台阶)的异或和:

若 dₙ ^ dₙ₋₂ ^ ... ≠ 0,先手必胜(Georgia 赢)。

否则,后手必胜(Bob 赢)。

 

杭电 25 春 6 1005

数轴上有n个点,第i个点的坐标是x[i],玩家轮流进行操作:选择两个相邻的点,将选择的点向左移动任意正整数个单位长度,但在移动过程中选择的点不可以越过、撞上未被选择的点,并且需要保证移动后点的坐标依然是正整数,如果一个玩家无法进行操作,则另一个玩家胜利

给定n个点,问先手必胜吗

首先模拟一下这个操作过程,可以发现最终情况一定是a[1]=1,a[2]=2,a[3]=3…a[n]=?,也就是只有最后一个元素的位置不清楚,其他元素一定是相邻的

换个方向而言,就是a[n]没有实际用处(移动只能相邻的进行移动,因此最后一个点一定不会被限制),然后可以按照上面POJ那题类似的思路转换成一条线上有许多空格,我们需要移动空格

移动x[i],x[i+1]时,相当于将a[i-1]中的一些空格移动到a[i+1]中,也就是和上面的题目有所差异的是这里是有间隔地进行移动

因此可以考虑拆分成两个阶梯博弈,也就是有影响的是1,(3),5,(7),..;2,(4),6,(8)…打括号的指的是对应阶梯博弈中的无用的偶数级

1,2,3,4:4是无用的,移动3,4和移动2,3都相当于是移动若干个空格到无穷中,也就等价于nim游戏中的取出石子以及阶梯游戏中的第一级的移动

根据阶梯博弈的结论,取这些部分异或即可

void solve() {

    int n;

    cin>>n;

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    vector<vector<int>> g(2,vector<int>());

    for(int i=n-2;i>=1;i--){

        g[i&1].emplace_back(a[i]-a[i-1]-1);

    }

    int res=0;

    for(int i=0;i<g[0].size();i+=2){

        res^=g[0][i];

    }

    for(int i=0;i<g[1].size();i+=2){

        res^=g[1][i];

    }

    if (res){

        cout << "Taki" << "\n";

    }else{

        cout << "Maki" << "\n";

    }

}

 

 

ICG博弈

ICG博弈是这类博弈的总括性理论• 这类博弈的核心特征在于,只根据初始状态就可以判断双方的输嬴情況   经典的Bash,wythoff 以及 Nim 博弈问题,都属于所谓的公平组合博弈,ICG具有的的特征/性质如下(用局势 (position)描述游戏中的一个状态):
1. 两人参与,轮流进行,有限步内结束.
2. position空间只能划分为两个部分:奇异局
势 (P-position) 空间和非奇异局势 (N-position)空间.
3.面对同—position,两人具有相同的合法操作集合;即选手的操作只与 position有关而与选手无关
4. 对于任一P-position, 任意的合法操作都会使其变成 N-position;对于任一 N-position
至少存在一种合法操作使其变为P-position. (position空间的划分要满足此性
质)

5. 结束局势 (E-position)定义为 N-position空间中的一些,面对它的选手赢;或者定义为P-position 空问中的一些,面对它的选手输.(面对是指上一位选手操作完毕,轮到当前选手,而当前选手还未操作)
如果一个游戏满足上述性质,即为 ICG游戏.那么由于 P-position/N-position之间的这种转换
关系(性质4),我们就能,只根据初始局势就可以判断双方的输赢情况.通常判断先手是否赢,先手赢的条件是初始局势为 N-position.
启示:面对一个博弈论(Game theory) 问题,我们可以大胆猜测它是一个ICG问题.然后通过输
赢关系找出一些 N-position 和 P-position,并尝试划分出各自的空问.最后,证明第4个性质.当然在比赛中,如果比较难证明,我们可以直接通过提交代码来测试.

 

博弈论要最差情况

例如cf1929c

不能只关注我们连续输了x次后下注多少能确保整体是增的(并且恰好增1),而其他所有情况下我们都只投入1,要考虑实际上我们可能甚至遇不到连续输x次的情况

如果我们之前总共输掉了 z ,那么在下一轮中,我们需要下注 y ,即 y(k−1)>z,因为否则赌场可以让我们赢。在这种情况下,连续输钱不超过 x次的条件就会消失,我们最终会输得很惨

因此,解决方法如下:我们首先下注 1 ,然后下注最小的数字,使赢的钱能够弥补输的钱。如果我们有足够的资金下注 x+1,那么赌场的结果必须是负数,否则我们就赢不了

for(int i=2;i<=x+1;i++){

        int t=ceil((double)(sum+1)/(k-1));

        a-=t;

        if((i!=x+1 && a==0) || a<0){

            cout<<"NO\n";

            return;

        }

        sum+=t;

    }

 

类似的下注的题目

cf1979C

//对于每一个元素来说  x*ki>=accumulate(x)

//对于每一种总投入来说,我们的检查方式都是确定的,即在该位上要投入超过accumulate(x)/ki的值

//如果不要求是整数,那么不妨假设总投入是1,那么x等于1/ki

//因此accmulate(1/k)<1那么就可行,接下来只要保证是整数即可

//换言之,我们的总投入并不影响该序列是否存在解,解的存在只由ki确定

//因此相当于我们只要确保是整数,那么显然可以找所有ki的lcm作为我们的总投入

//又因为并不要求我们投入的硬币总数要最小,同时lcm(1~20)的值仅仅只有232792560

//于是我们可以固定投入的硬币总数是该值,接下来只要验证是否可行即可

constexpr int L = 232792560;

void solve() {

    int n;

    cin >> n;

    vector<int> k(n), x(n);

    for (int i = 0; i < n; i++) {

        cin >> k[i];

        x[i] = L / k[i];

    }

    if (accumulate(x.begin(), x.end(), 0LL) < L) {

        for (int i = 0; i < n; i++) {

            cout << x[i] << " ";

        }

        cout<<'\n';

    } else {

        cout << -1 << "\n";

    }

}

 

25 杭电 春 1007

船长分金币,全体n+1位船员(包括船长)投票表决,半数及以上通过就停,否则杀船长 重新分配。在所有人足够聪明贪婪且互相了解的情况下,假设最终船长的第i顺位继承人分到r[i]枚金币,求i*r[i]的总和

依次考虑2,3…人时的情况,n人时,称船长为第1人,第1顺位为第2人

2人时,显然第1人一定会通过自己的决议,这样第2人分到0枚金币

3人时,第2人显然是不会同意的(实际上,任何情况下第2顺位的人都不会同意,因为由前面一种情况可以知道如果不同意,他会得到近乎无穷的金币),于是第1人只能拉拢第三人,因为只有2个人的情况下第3人一个金币也得不到,因此只需要1个金币

4人时,第2人不同意,为了通过,需要再拉拢1个人,基于3人时的情况,4人下的第3人如果不同意则1个金币也得不到,而第4人会得到1个金币,因此可以给第3人1个金币来拉拢

依次类推可以得到金币的分配总是0,1,0,1…,直接遍历会统计会超时,稍微写一下式子可以发现就是2+4+…(n/2)*2=2*(1+2+..+n/2)=n/2*(n/2+1)

直接计算即可

 

25 杭电 春2 1006

桌上有 r 块红宝石、b 块蓝宝石和 m 个宝盒。Alice 和 Bob 要轮流操作,Alice 先手。操作结束后,桌面清空的玩家获胜。操作后若桌面自动出现物品,出现过程也看作操作的一部分。

每次操作,两人可以选择下列操作之一:

拿走 k 块红宝石(1≤k≤3)。

把一块蓝宝石变成红宝石。

拿走 1 块蓝宝石,然后拿走 0 或 1 块红宝石。

拿走 2 块蓝宝石,然后桌面上自动出现一块红宝石。

拿走一个宝盒说”蓝星“,然后桌面上自动出现一块蓝宝石。

拿走一个宝盒说”赤石“,然后桌面上自动出现一块红宝石。

拿走一个宝盒说”共生“,然后桌面上自动出现一块红宝石和一块蓝宝石。

注意,拿走宝盒后,必须说上述三个词之一。

问谁会获胜

首先肯定可以保证游戏局数是有限的

比较神奇的做法:将红宝石视作1元钱,蓝宝石视为2元钱,宝盒视为4元钱,那么问题可以完全等价于桌上有r+2b+4m元钱,两个人轮流取钱,每次可以取1~3元,问最后谁胜

这就可以用经典结论了,如果(r+2*b+4*m) mod 4不是0则alice胜,否则bob胜(就是每次先把模数取掉,然后对手取x,则我取4-x)

 

博弈论建模

25 杭电 春4 1007

童年小游戏

小 A 和小 B 两个人进行游戏,每个人伸出两只手,每只表达 1 到 9 的某个数字。

之后 A B 轮流行动,每次选择自己的一只手去碰对方的一只手,之后自己的这只手的数字变成二者相加对 10 取模。

当数字 = 0 就可以把手收回,率先将两只手都收回的获胜

将四只手分别表示位(x,y,x’,y’)

双方都足够聪明的前提下,求此时游戏是A必胜,B必胜还是会陷入循环

因为情况之间显然两两有转移关系,因此可以考虑建图

如果一个状态p能够变化到状态q,则连有向边p->q,得到一个有向图

于是双方实际上就是在图上轮流决定状态走到下一个状态,谁在操作后移动到(x,y,0,0),谁就获胜

考虑经典的博弈思路:一个点如果能到达必败态,那么它是必胜态,因为移动后下一个先手的人就是必败了,类似的,如果一个点只能到达必胜态,那么它就是必败态

否则就是平局态

考虑一个逆向迭代的过程,每次将非平局态的点压入队列,如果从队列取出的点为必败态,那么将能到达它的点标记为必胜态,如果取出的是必胜态,那么查看每个能到达它的点是否满足必败态的条件,类似于拓扑排序,从已经可以确定的点进行扩展

处理完毕之后,再依据当前的初始状态判断即可

https://acm.hdu.edu.cn/contest/view-code?cid=1153&rid=22521

 

博弈论一个比较关键的点就在于先后手

cf1966c

如果最小的一堆棋子的大小是 1 ,那么爱丽丝的第一步棋必须选择 k=1,先暂时不考虑经过这次操作之后下一堆最小的棋子数是不是1,这个操作代表了两者交换了先后手顺序

我们要重复这个过程并交换先后手,直到当前不再有大小为1 的棋堆

另外,我们可以推得,如果当前最小的一堆棋子的大小大于等于2,那么先手必胜(如果当前取x最后能赢,那么取x,否则说明是后手胜,那我们去x-1来强制转化先后手关系,使自己能够赢)

(这里实际上和动态规划有点像,我们并不需要去思考要如何操作才会赢,只要知道这里只存在两种状况,是先手必胜还是后手必胜,因此在这里我们推出某种情况是必胜之后,我们只需要将自己置于这个能必胜的情况即可)

除了模拟:a[0]如果是1,就不断判断是否是连续段,用c表示当前的先手,并且每次都取c=!c,也可以直接找mex(不考虑0)=b和最大值a

如果是 b>a ,那么爱丽丝和鲍勃将被迫选择 k=1直到游戏结束,因此 a的奇偶性决定了胜负。否则,他们最终会到达最小牌堆大小至少为 2的状态,因此 b的奇偶性决定了胜负

void solve(){

    int n;cin>>n;

    for (int i = 1; i <= n; i++) {

        cin>>su[i];

    }

    sort(su + 1, su + n + 1);

    int tot = unique(su + 1, su + n + 1) - su - 1;

    if (su[1] != 1) {

        cout<<"Alice\n";

        return;

    }

    int nn = 0;

    for (int i = 1; i < tot; i++) {

        if (su[i] == su[i + 1] - 1) {

            nn++;

        } else {

            break;

        }

    }

    if (nn == tot - 1) {

        cout<<((nn & 1) ? "Bob\n" : "Alice\n");

    } else {

        cout<<((nn & 1) ? "Alice\n" : "Bob\n");

    }

}

 

博弈论的有些题目所考察的结论是一个数据的奇偶性

例如cf1972c  可以通过枚举可能的情况来证明

 

cf1968d
因为k比较大而n比较小,因此最终肯定是停在某个位置上不动,因此可以枚举他们最终停下的点 pos。设从他们各自的起点 P 到当前点 pos 经历了 x 步(0≤x≤k),路径上经过的所有点的点权和为 sum,则最终的答案即为 sum+(k−x)×a[pos]。我们只要找到这个表达式的最大值,暴力枚举即可

这样并不需要依赖dfs去求他们的移动过程(一直t就要考虑是不是方法选错了,有时利用结论可以避免dfs求解,这题关键就在于最终一定是停留在某一点,但并不一定是停留在最大值的点,因此求出最大值后再dfs是不对的)

 

2024 东北四省邀请赛  D

手玩发现 n = 1 到 6 均为必败,此时可以猜结论:先手必败。考虑证明,n > 6 时,经过先后手若干次操作,Mandy 一定能拿到这样一个局 面:n = 3 ∼ 6,且 brz 之前的操作异或和为 0 ∼ 3。证明显然。 而通过打表或者手玩可以发现,这 16 种情况无论遇到哪种,Mandy 都可以操作使得 brz 必败。所以 brz 永远必败

 

cf150A

利用整数分解定理可知一个整数可以被分解成若干个质数的乘积

因此记q的质因子的个数为x,若x为2,玩家2必胜,而当x大于2时,可以一次取出x-2个因子的乘积作为当前操作的操作数,这样下次玩家2先手时将面临因子为2的情况,因此玩家1必赢

特别的,当其本身就是质数时,玩家1必胜

因为输入q可能极大,因此要用i64的数据类型进行存储

同时因为我们只关注是否有超过2个的质因子,因此并不需要求出所有的质因子,可以对素数判断程序进行一定的优化

i64 a[2],tot=0;

void check(i64 n){

    if(n==1) return;

    for(i64 i=2;i<=sqrt(n);i++){

        if(n%i==0){

            a[tot++]=i;

            n/=i;i--;

            if(tot==2) return;

        }

    }

}

void solve(){

    i64 n;

    cin>>n;

    check(n);

    if(tot==0){

        cout<<1<<'\n'<<0<<'\n';

    }else if(tot==1){

        cout<<2<<'\n';

    }else{

        cout<<1<<'\n';

        cout<<(a[0]*a[1])<<'\n';

    }

}

 

cf 257B

一共n+m个方块,就会有n+m-1对相邻的方块,又因为相邻的两个方块只有相同和不同两种情况,因此P和V的得分之和为n+m-1

因为n,m最多只有min(n,m)处不同,因此V最高得分即为min(n,m),并且这是可以达到的,因为可以每次取和上一次取出的方块不同的方块

int a[100005];

void solve(){

    int n,m;

    cin>>n>>m;

    if(m<n) swap(m,n);

    cout<<m-1<<' '<<n<<'\n';;

}

 

牛客24多校8 A

有一个集合,两个人玩游戏,每次一个人选择两个数,满足它们的gcd不在集合里,然后插入gcd的值,最后无法行动的人输

但这并不是一个博弈题,因为最终状态是确定的

考虑每个数d是否在终止状态里,当且仅当所有d的倍数的gcd为d

(因为一定是一直插入,直到所有可能出现的数都出现过了,因此只需要关注集合的大小)

有两种实现方法:

对于d枚举所有d的倍数并求它们的gcd,最后检查gcd是否等于d(也就是枚举d)

对于d检查是否存在k>1,使得输入中d的倍数数量等于kd的倍数数量

void solve(){

    int n;

    cin>>n;

    int d[100005]{};

    for(int i=1;i<=n;i++){

        int x;

        cin>>x;

        d[x]=1;

    }

    int cnt=0;

    for(int i=1;i<1e5+5;i++){

        if(d[i]) continue;

        int tmp=0;

        for(int j=i*2;j<1e5+5;j+=i){

            if(d[j]) tmp=gcd(j,tmp);

            if(tmp==i){

                cnt++;

                break;

            }

        }

    }

    if(cnt&1){

        cout<<"dXqwq\n";

    }else{

        cout<<"Haitang\n";

    }

}

 

公平组合游戏:
游戏有两个人参与,二者轮流做出决策,双方均知道游戏的完整信息

任意一个游戏者在某一确定状态可以做出的决策集合只与当前的状态有关,而与游戏者无关

游戏中的一个状态不可能多次抵达,游戏以玩家无法行动为结束,且游戏一定会在有限步后以非平局结束

 

Nim游戏

n堆物品,每堆有ai个,两个玩家轮流取走任意一堆的任意个物品,但不能不去

取走最后一个物品的人获胜

 

如果将每个状态视为一个节点,再从每个状态向它的后继状态连边,就可以得到一个博弈状态图

定义 必胜状态 为 先手必胜的状态,必败状态 为 先手必败的状态。

通过推理,我们可以得出下面三条定理:

定理 1:没有后继状态的状态是必败状态。

定理 2:一个状态是必胜状态当且仅当存在至少一个必败状态为它的后继状态。

定理 3:一个状态是必败状态当且仅当它的所有后继状态均为必胜状态。

 

定义Nim和=a1 a2 ... an

当且仅当NIm和为0时,该状态为必败状态,否则为必胜状态

为了证明这个结论,只需要证明:
没有后继状态的状态是必败状态

没有后继状态的状态只有一个,也就是全0局面,此时异或和为0

对于Nim和!=0的局面,一定存在某种移动使得Nim和=0

对于Nim和=0的局面,一定不存在某种移动使得Nim和=0

之后的证明可以通过更改后的数字能否从更改前的数字得到进行判断,例如从ai移动到ai’,就是ai’=ai k,同时要满足ai>ai’,k是当前的异或和,显然对于最高位的1,一定有奇数个ai的二进制在这位上是1

 

有向图游戏与SG函数(相当于SG是Nim游戏的ai?)

在一个有向无环图中,只有一个起点,上面有一个棋子,两个玩家轮流沿着有向边推动棋子,不能走的玩家判负

定义mex函数的值为不属于集合S中的最小非负整数,即mex(S)=min{x}

对于状态x和它的所有k个后继状态y1,y2,...,yk,定义SG函数:
SG(x)=mex{SG(y1),SG(y2),..,SG(yk)}

而对于有n个有向图游戏组成的组合游戏,设它们的起点分别为s1,s2...,sn,则有定理:当且仅当SG(s1) SG(s2) ... SG(sn)!=0时,这个游戏是先手必胜的,同时这是一个组合游戏的游戏状态x的SG值

证明中的一些步骤:这一个状态可以看作一个Nim游戏,对于某个节点si,可以移动到任意一个SG值比它小或比它大的节点

在有向图游戏中,当一方将某一节点si移动到SG值到它大的节点时,另一方可以移动回合SG值和SG(si)一样的节点,所以向SG值较大节点移动是无效操作

当移动到SG较小的节点时,情况则会和Nim游戏一样,能够到达任何一个游戏状态x’使得SG(x’)<SG(x),

但到达不了SG值为SG(s1) SG(s2) ... SG(sn)的节点

 

SG定理适用于任何公平的两人游戏,常被用于决定游戏的输赢结果

计算给定状态的Grundy值的步骤包括:
获取从此状态所有可能的转换

每个转换都可以导致一系列独立的博弈,计算每个独立博弈的Grundy值并对它们进行异或求和,也就是S(yk)

在为每个转换计算了Grundy值之后,状态的值是这些数字的mex

如果该值为0,则当前状态为输,否则为赢

 

将Nim游戏转换为有向图游戏

可以将一个有x个物品的堆视为节点x,当且仅当y<x时,节点x可以到达y,那么由n个堆组成的Nim游戏,就可以视为n个有向图游戏了

根据上面的推论,可以得出SG(x)=x,同样可以得到Nim和的结论

 

ccpc 2024网络赛

每次取的数字要小于等于k,同时每次取的数字是所有之前数字的gcd,先取完沙子的获胜

赛时代码

void solve() {

    int n,k;

    cin>>n>>k;

    if(k>=n){

        cout<<"Alice\n";

        return;

    }

    if(n&1){

        cout<<"Alice\n";

        return;

    }

    if(k==1){

        cout<<"Bob\n";

        return;

    }

    while(k){

        n/=2;

        k/=2;

        if(n&1){

            cout<<"Alice\n";

            return;

        }

        if(k==1){

            cout<<"Bob\n";

            return;

        }

    }

}

正解

若lowbit(n)小于等于k,则先手胜,否则后手胜

证明:
若lowbit(n)小于等于k,则先手取lowbit(n),之后后手无论取什么数,先手秩序重复侯树的取数即可

若lowbit(n)大于k,则先手无论取何数字,取之后的lowbit一定小于等于k,此时后手必胜

 

cf2004E

有i堆棋子,每次从中选取一堆拿出若干棋子,要求拿出的数与原来棋子的数目互质

问谁会赢

发现每堆游戏是相对独立,通过计算每堆的SG值,然后将这些值进行异或运算,以判断当前状态是否为必胜(也就是结果是否为0)

SG的求法(每一堆SG的计算):
可以先编写一个能简单计算SG值的解决方案,运行并生成几个初值,观察以指定更快的计算方法

显然当SG(0)=0,SG(1)=1,SG(2)=0,x为偶数的时候没有先手必胜策略,而奇数的情况没有那么好考虑,因为每次选的数字都要与当前数字互质,因此考虑质数的情况,因为当x为质数时,显然可以去1到x-1的任意数

于是SG(x)=mex{SG(y)},其中y=1...x-1

SG(3)=2,SG(x)=SG(x’)+1,也就是x是第几个质数

对于所有奇数,设其最小质因子为x’,那么SG(x)=SG(x’)

用数学归纳法进行证明

首先对于x<=2成立

对于x+1是质数,并且其是第i个质数,那么[1,x]就是其后继状态,然后[1,i-1]一定在之前出现过,所以SG(x+1)=i

对于x+1是偶数,对于一个数y,若gcd(x,y)=1,那么y一定是奇数,与是x+1-y一定是奇数,因为[1,x]中奇数的SG值都不是0,因此SG(x+1)=0

对于x+1是奇数,有某个质因子,那么p的倍数一定不能减去,于是对应的SG(p)一定是缺失的,因此[1,x]中的y满足SG(y)=SG(p)的一定有p|y,且SG(x+1)<SG(p),并且其中最小的SG(p)就是SG(x+1),因此就是最小质因子的SG

于是可以用过线性筛等方法对每个数预处理出其最小质因子,然后就能求得所有SG函数,借着再判断异或和即可

(即SG的计算是mex,是否必胜的判断是其所有状态的异或是否不为0)

constexpr int N=1e7;

int sg[N+1];

 

//处理最小质因子以及质数

vector<int> minp,primes;

void sieve(int n) {

    minp.assign(n + 1, 0);

    primes.clear();

    for (int i = 2; i <= n; i++) {

        if (minp[i] == 0) {

            minp[i] = i;

            primes.push_back(i);

        }

        for (auto p : primes) {

            if (i * p > n) {

                break;

            }

            minp[i * p] = p;

            if (p == minp[i]) {

                break;

            }

        }

    }

}

void solve(){

    int n;

    cin>>n;

    int ans=0;

    for(int i=0;i<n;i++){

        int a;

        cin>>a;

        ans^=sg[a];

    }

    if(ans!=0){

        cout<<"Alice\n";

    }else{

        cout<<"Bob\n";

    }

}

 

 

 

非公平组合游戏

在非公平组合游戏中,游戏者在某一确定状态可以做出的决策与游戏者有关,大部分的棋类游戏都不是公平组合游戏,因为双方都不能使用对方的棋子

 

反常游戏

反常游戏按照传统的游戏规则进行游戏,但是其胜者为第一个无法行动的玩家

以Nim游戏为例,Nim游戏中取走最后一颗石子的为胜者,而反常Nim游戏中取走最后一个石子的为败者

 

Ad-hoc

25 杭电 春4 1004

给定一棵树,树上的节点有点权,越往下点权越大(也就是整棵树构成了小根堆)

一个支线也就是一条根到叶子的路径,点权非严格递增,对于任意一条支线,它是充实的,当且仅当:
定义一个集合S,初始将路径上的所有点都放入集合中

每次可以从S中任取两个奇偶性相同的数x,y,并将(x+y)/2放入S中,该操作可以进行任意次

操作结束后S构成了min S到max S的完整值域

求树中有多少条支线是充实的

这种计数题都要考虑合法的充要条件,因为只有足够简洁的性质才能计数

显然可以将路径转成序列进行考虑

因为最后考虑的是能否填充满值域,我们将序列有序排列后,考虑差分(直接的数字并不能得到什么性质,因此从差值的角度进行考虑)

不难发现ai,aj奇偶性相同,等价于a[j]-a[i]是偶数,而插入(a[i]+a[j])/2则是将差值除以2

因为显然加入数越多越不劣,因此每当差分数组中有偶数时,将其不断/2一定是不劣的

因此要考虑的是差分为奇数的情况

如果相邻的a[i+1]-a[i]全是2的幂次,那么显然是充实的

但是例如差分数组为3,2也是可行的

因为可以依次变为3 2,3 1 1,2 1 1 1,1 1 1 1 1

也就是选择了差分数组中的两个奇数进行操作,这样操作本质是让较大的数减去了较小的数(以一个数为中间轴,实际差分分别是3和-1,加之后除2,实际上是减操作),因为两个奇数相减必定得到偶数,于是一定可以继续/2,选择操作的两个数(3,1),操作完成后得到了(2,1)

对于(a,b)->(a-b,b)这种辗转相除的形式一般都可以往gcd上考虑,因此上面的操作最终一定能得到gcd(a,b)的差分

于是考虑先通过操作消去所有能消除的奇数

那么序列合法的充要条件是gcd(d[1],d[2],…,d[n-1])、

于是只需要从根出发做一次dfs同时记录gcd即可

实际上因为在判断过程中可以任意进行减操作,于是可以不需要逐个加入元素后排序求差分,而是直接求当前节点和父节点的差值即可

特别的,要判断一个数是不是2的次幂,不要使用while循环,直接使用__builtin_popcount(check)<=1

void solve() {  

    int n;

    cin>>n;

    vector<vector<int>> adj(n);

    for(int i=1;i<n;i++){

        int x;

        cin>>x;

        x--;

        adj[x].push_back(i);

    }

    vector<int> a(n);

    for(int i=0;i<n;i++){

        cin>>a[i];

    }

    int ans=0;

    vector<int> tmp;

    auto dfs=[&](auto self,int u)->void{

        tmp.emplace_back(a[u]);

        if(adj[u].size()==0){

            sort(tmp.begin(),tmp.end());

            int check=0;

            for(int i=1;i<tmp.size();i++){

                check=__gcd(check,tmp[i]-tmp[i-1]);

            }

            ans+=__builtin_popcount(check)<=1;

            tmp.erase(find(tmp.begin(),tmp.end(),a[u]));

            return;

        }

        for(int v:adj[u]){

            self(self,v);

        }

        tmp.erase(find(tmp.begin(),tmp.end(),a[u]));

    };

    dfs(dfs,0);

    cout<<ans<<"\n";

}  

 

posted @ 2025-09-10 23:51  rdcamelot  阅读(2)  评论(0)    收藏  举报