基础算法

1.位运算

1.1.基础知识

一般只考虑自然数。

在k位二进制数中,通常称最低位为第0位,最高位为第k-1位。

1.1.1.算术位运算

and&\((1010)_2 \operatorname{and} (0110)_2=(0010)_2\)

or|\((1010)_2 \operatorname{or} (0110)_2=(1110)_2\)

not~!\(\operatorname{not}(1010)_2=(0101)_2\)

  • 在k位二进制数下,\(\operatorname{not}(x)=(2^k-1)-x\)

    not(x)可以视作在模\(2^k-1\)意义下的-x。

  • 在C++语法里,not!的对象是布尔变量;对于有符号整数x,~x=-1-x。

异或xor^\((1010)_2 \operatorname{xor} (0110)_2=(1100)_2\)

  • 异或可以看作不进位加法或模2意义下加法。a^b≤a+b。

    与可以看作模2意义下乘法。

  • \(x\operatorname{xor}y=z\Leftrightarrow y\operatorname{xor}z=x\Leftrightarrow x\operatorname{xor}y\operatorname{xor}z=0\)

  • 异或的加和:拆位。

  • 异或适合用线性基维护。

  • 要求复杂的二进制下计数问题:数位dp。复杂度=O(状态数)=O(log n)。

    \(x\operatorname{xor}y=z\Leftrightarrow x\operatorname{xor}y\operatorname{xor}z=0\):f_{pos,l_x,l_y,l_z}:第pos位,x,y,z最高位是否有限定,的方案数。

  • count(x xor y)=count(x)+count(y)-2*count(x and y)。

    当且仅当二进制下含奇数个1的数xor二进制下含偶数个1的数=二进制下含奇数个1的数。

  • 重要性质:位运算按位独立。

    • 证明位运算的运算性质只要证明对于一个数位成立,那么对于整个数都成立。
    • 拆位。
  • x+y=(x&y)+(x|y)。

  • x xor y=(x|y)-(x&y)。

    定义\(\bigoplus\limits_i a_i=\sum\limits_{k}[\exist i,a_i\&2^k\not=0][\exist i,a_i\&2^k=0]2^k\),则\(\bigoplus\limits_{i}a_i=\operatorname{or}\limits_ia_i-\operatorname{and}\limits_ia_i\)

  • \(x-y\equiv x+(2^k-y)\pmod{2^k}\)

  • x是y的子集(\(x\sube y\)\(\Leftrightarrow\)x|yy\(\Leftrightarrow\)y-x是y的子集\(\Leftrightarrow\)x&(y-x)0\(\Leftrightarrow\)y是x的超集\(\Leftrightarrow\)not(y)是not(x)的子集。

    • 推论:x是x+y的子集\(\Leftrightarrow\)y是not(x)的子集。

      证明:x是x+y的子集\(\Leftrightarrow\)not(x+y)是not(x)的子集\(\Leftrightarrow\)not(x)-not(x+y)=((2k-1)-x)-((2k-1)-(x+y))=y是not(x)的子集。

1.1.2.移位运算

左移<<\((10)_2<<k=(10\underbrace{\dots}_{\text{k个0}})_2\)

右移>>\((10\underbrace{\dots}_{\text{k个0和1}})_2>>k=(10)_2\)

  • \(1\operatorname{<<}n=2^n\)\(n<<1=2*n\)\(n>>1=\lfloor \dfrac{n}{2.0} \rfloor\)

  • 注意左/右移在变量类型下的位数限制。

    e.g.在int类型下,左/右移32位及以上是未定义行为。

    e.g.正确写法:1ll<<60ll!!!错误写法:1<<60

1.1.3.机器数

对于有符号整数x,~x=-1-x。

正数原码$(0|1010)_2 \(的反码是其本身\)(0|1010)_2 $。

负数原码$(1|1010)_2 \(的反码是在其原码的基础上,符号位不变,其余位取反\)(1|0101)_2 $。

正数原码$(0|1010)_2 \(的补码是其本身\)(0|1010)_2 $。

负数原码($1|1010)_2 \(的补码是其反码+1\)(1|0110)_2 $。

  • 对负数x取反码可类比\(x+(2^k-1)\)

    对负数x取补码可类比\(x+2^k\)

任何两个数值做加减法运算\(\Leftrightarrow\)减法看作加负数,先对两数取补码,再做最高位不进位的二进制加法,最后对结果取补码。

  • 可类比\(x-y=((x+2^k)\%2^k+(-y+2^k)\%2^k+2^k)\%2^k\)

1.1.4.常用技巧

  • unsigned long long(或unsigned int)发生算术溢出时相当于自动对\(2^{64}\)(或\(2^{32}\))取模。

  • 初始化最大值const int INF=0x3f3f3f3f;memset(a,0x3f,sizeof a);

  • 成对变换

    \(n \operatorname{xor} 1 = \begin{cases} n+1,(n为偶数)\\n-1,(n为奇数) \end{cases}\),“0与1”、“2与3”、“4与5”……关于\(\operatorname{xor}1\)运算“成对变换”。用于邻接表判断反向边。

  • 滚动数组(不是求max和min时):f[i&1]

  • 二进制拆分数或者集合。

  • 异或的加和:拆位。

1.2.lowbit\(O(1)\)

返回x二进制下“最低位的1和后边所有0”构成的数值

inline int lowbit(int x){
    return x&-x;  //或return x&(~x+1);
}//返回x的最后一位1

1.3.统计数中 1 的个数

在线求解:\(O(\log N)\)

int a,ans;

inline int lowbit(int x){
    return x&-x;  //或return x&(~x+1);
}//返回x的最后一位1

int main(){
  cin>>a;
  while(a){
    a-=lowbit(a);
    ans++;
  }
  cout<<ans<<' ';
  return 0;
}

离线预处理:\(O(N)\)

int siz[1<<N];
for(int i=0;i<=(1<<n);i++) siz[i]=siz[i>>1]+(i&1);

1.4.快速幂

1.4.1.整数域的快速幂\(O(\log N)\)

typedef long long ll; 

ll qpow(ll x,int y,int mod){  //x^y
  if(y<0) return qpow(qpow(x,MOD-2),-y);
  ll ans=1;
  while(y){
    if(y&1) ans=(ans*x)%mod;
    x=x*x%mod;
    y>>=1;
  }
  return ans;
}

1.4.2.有理数域的快速幂

\(x^{-y}=(x^{-1})^{y}\),即先求x的逆元再求快速幂。

1.4.3.光速幂

\(O(\sqrt {指数})\)预处理,\(O(1)\)查询。

适用条件:调用光速幂时底数相同,\(O(\sqrt{指数})\)不大。

\(B=\sqrt{指数}\),预处理p1[B]和p2[B]:\(p1[i]=x^i,p2[i]=(x^B)^i=x^{i*B}\)。调用光速幂时直接\(O(1)\)拼接。

1.5.龟速乘(用于先乘再取模会爆long long的情况)

\(O(1)\)

\(a*b\% p=a*b-\lfloor a*b/p\rfloor*p\)

#include<bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef unsigned long long ull;

ll mul_64(ll a,ll b,ll p){
    a%=p,b%=p;
    ull c=(long double)a*b/p;
    ull x=a*b,y=c*p;
    ll ans=(ll)(x%p)-(ll)(y%p);
    if(ans<0) ans+=p;
    return ans;
}

int main(){
    ll a,b,p;
    scanf("%lld%lld%lld",&a,&b,&p);
    printf("%lld",mul_64(a,b,p));
    return 0;
}

\(O(\log b)\)

#include<bits/stdc++.h>
using namespace std;

typedef long long ll;

ll mul_64(ll a,ll b,ll p){
    ll res=0;
    while(b){
        if(b&1) res=(res+a)%p;
        a=(a+a)%p;
        b>>=1;
    }
    return res;
}

int main(){
    ll a,b,p;
    scanf("%lld%lld%lld",&a,&b,&p);
    printf("%lld\n",mul_64(a,b,p));
    return 0;
}

1.6.状态压缩

  1. 取出n 在二进制下的第k位( k 从 0 开始计数 ) :(n>>k)&1
  2. 取出n 在二进制下的第0~k-1位:n&((1<<k)-1)
  3. 对n 在二进制下的第k位取反:n xor (1<<k)
  4. 对n在二进制下的第0~k-1位取反:n xor (1<<k)-1
  5. 对n 在二进制下的第k位赋值1:n|(1<<k)
  6. 对n 在二进制下的第k位赋值0:n&(!(1<<k))

1.7.模拟集合操作

一个数的二进制表示可以看作是一个集合(0表示不在集合中,1表示在集合中)。\(e.g.\)集合{1,3,4,8} ,可以表示成\((100011010)_2\)

而对应的位运算也就可以看作是对集合进行的操作:交集a&b、并集a|b、全集(1<<n)-1、补集~a、差集a&(~b)、对称差a^b

1.7.1.枚举集合\(O(2^N)\)

一般常用来代替递归型枚举或者状态压缩。

for(int state=1;state<(1<<k);state++)

1.7.2.枚举子集

对于上面枚举的一个集合state,遍历state的子集。

//枚举非空(真)子集
for(int s=state/*如果遍历非空真子集就是(state-1)&state*/;s;s=(s-1)&state/*注意这里是state而不是s*/)//s是state的一个非空子集

//枚举子集
for(int s=state;;s=(s-1)&state)
{
    //abaabaaba
    if(!s) break;
}

s-1:把 s的0~lowbit(s)位全部取反;(s-1)&state:依次消除s的lowbit(s)位上的1。

如果枚举集合的循环嵌套枚举子集的循环,复杂度是\(O(3^N)\)(一个元素有3种选择方式:不在集合内部、在集合内部但不在子集内部、在子集内部)。

应用

  1. 二进制下不存在某一位n为0而m为1\(\Leftrightarrow\)二进制下m是n的子集,n转移到m用子集转移。

  2. 枚举\(s1,s2\in (1<<n)-1\),且s1&s2==0即无交集:枚举集合s1,枚举(s1^((1<<n)-1))的子集s2,复杂度从\(O((2^n)^2)\)优化到\(O(3^n)\)

    如果还满足s1|s2==(1<<n)-1,可考虑子集卷积优化,复杂度从\(O(3^n)\)优化到\(O(n^2*2^n)\)

1.8.位集合优化\(O(\frac{?}{w})\)

适用条件:布尔数组的操作。

常用于很难继续优化复杂度的\(O(N^2)\)做法。

STL

《C++语言.4.2.位集合bitset》

手写

可以预处理所有长度为b的2^b个01串的复杂信息。复杂度是\(O(2^b+\frac{?}{b})\)

2.递推·递归·分治

递推在状态转移领域有很多涉猎,递归在搜索、树(包括图和数据结构)领域有很多涉猎。

2.1.递归实现指数型枚举

int n;
int chosen[N],idx;

void calc(int x){
    //问题边界,输出
    if(x==n+1){
        for(int i=1;i<=idx;i++) printf("%d ",chosen[i]);
        puts("");
        return ;
    }

    //选x
    chosen[++idx]=x;
    calc(x+1);
    idx--;  //还原现场

    //不选x
    calc(x+1);

    return ;
}

scanf("%d",&n);
calc(1);

2.2.递归实现组合型枚举

int n,m;
int chosen[N],idx;

void calc(int x){
    //剪枝
    if(idx>m || idx+(n-x+1)<m) return ;

    //问题边界,输出
    if(x==n+1){
        for(int i=1;i<=idx;i++) printf("%d ",chosen[i]);
        puts("");
        return ;
    }

    //选x
    chosen[++idx]=x;
    calc(x+1);
    idx--;  //还原现场

    //不选x
    calc(x+1);

    return ;
}

scanf("%d%d",&n,&m);
calc(1);

  • 2.3.递推实现组合型枚举
const int N=1<<21,inf=0x3f3f3f3f;
int n,m;
map<int ,int > m1;

struct point{
    vector<int > v1;
}p1[N];

int lowbit(int x)
{
    return x&(-x);
}

bool cmp(point x,point y){
    int ptail=0;
    while(x.v1[ptail]==y.v1[ptail])ptail++;
    return x.v1[ptail]<y.v1[ptail];
}

int main(){
    cin>>n>>m;
    bitset<26> b1;
    m1[1]=0;
    int q=2,tail=0;

    for(int i=1;i<=32;i++){
        m1[q]=i;
        q*=2;
    }

    for(int a=0;a<(1<<n);a++){
        b1|=a;
        if(b1.count()==m){
            int pa=a;
            while(pa){
            p1[tail].v1.push_back(m1[lowbit(pa)]+1);
            pa^=lowbit(pa);
            }
            tail++;
        }
        b1&=0;
    }
    sort(p1,p1+tail,cmp);
    for(int a=0;a<tail;a++){
        for(int b=0;b<m;b++)printf("%d ",p1[a].v1[b]);
        printf("\n");
    }
}

2.4.递归实现排列型枚举

int n;
int order[N];
bool vis[N];

void calc(int idx){
    //问题边界,输出
    if(idx==n+1){
        for(int i=1;i<=n;i++) printf("%d ",order[i]);
        puts("");
        return ;
    }

    //排列枚举
    for(int x=1;x<=n;x++){
        if(vis[x]) continue;
        order[idx]=x;
        vis[x]=true;
        calc(idx+1);
        vis[x]=false;
        //此处省略了:order[idx]=0;
        //不可以写:idx--;
    }

    return ;
}

scanf("%d",&n);
calc(1);

2.5.递归实现分治

void merge(int l,int r){
    if(l>=r) return ;
    int mid=(l+r)>>1;
    merge(l,mid);
    merge(mid+1,r);
    
    //abaabaaba
    
    return ;
}

4种分治模型:

  1. 每次可以把集合划分为两半,分别往下递归处理。\(e.g.\)利用分治转移dp、一维四边形不等式优化dp的离线分治写法。
  2. 有关元素与元素之间的配对的问题:2个元素都在左/右区间的情况递归处理,2个元素分别在左右两个区间的情况当前层处理。e.g.CDQ分治、点分治(1条经过根节点路径由2条从根节点出发的路径组成)。
  3. 计算每个点都需要一些信息,直接计算会超时:可以考虑借助分治结构充分利用信息、子区间利用父亲区间的信息,信息最多被分到\(O(\log N)\)个区间。e.g.线段树分治。
  4. 整体二分型分治(移动指针MID,指针移动距离是\(O(N\log N)\),且操作最多往下递归\(O(log N)\)层)。

2.5.1.分治求解平面最近点对\(O(N \log N)\)

  1. 将点按横坐标x排序。以便分治求解。
  2. 分治,把点分成左右两半部分递归处理。在当前层记录base=point[mid].x;d:当前的最近点对的距离。
  3. 最近点对的距离的可能值:左半部分最近点对、右半部分最近点对以及左半部分与右半部分构成的最近点对。
    • 左半部分最近点对以及右半部分最近点对

      递归时求解。

    • 左半部分与右半部分构成的最近点对

      暴力匹配左右两半部分的点对。复杂度是\(O(N^2)\)

      剪枝:

      1. 横坐标x的信息已经利用完。归并把点按纵坐标y排序,以便下面利用纵坐标y的信息。
      2. 排除不可能答案:点i的横坐标与base的绝对值都大于当前的最近点对的距离d,与另一半部分构成的点对的距离更不可能成为答案。
      3. 排除不可能答案:由于点按纵坐标排序,所以暴力匹配当点i与点j的距离大于当前的最近点对的距离d时,点j后面的点与点i构成的点对的距离更不可能成为答案。

      加上这3个剪枝后,数学几何可以证明该暴力匹配的复杂度是\(O(5*N)\)

typedef long double LD;
typedef pair<LD,LD> PLDLD;
int n;
PLDLD p[N],seq[N];

bool cmpx(PLDLD q,PLDLD w)  //按横坐标x排序
{
    return cmp(q.x,w.x)<0;
}

LD divide(int l,int r)
{
    //分治
    //最近点对的距离的可能值:左半部分最近点对、右半部分最近点对以及左半部分与右半部分构成的最近点对
    if(l==r) return INF;//边界
    int mid=(l+r)>>1;
    LD base=p[mid].x;   //一定在上面就记录,因为下面递归会把p[i]按照纵坐标y排序
    
    //左半部分最近点对以及右半部分最近点对:递归时求解
    LD d=min(divide(l,mid),divide(mid+1,r));    //d:当前的最近点对的距离
    
    //左半部分与右半部分构成的最近点对:暴力匹配左右两半部分的点对
    //剪枝1:归并按纵坐标y排序
    int i=l,j=mid+1,k=l;
    while(i<=mid && j<=r)
    {
        if(cmp(p[i].y,p[j].y)<0)
        {
            seq[k]=p[i];
            k++;
            i++;
        }
        else
        {
            seq[k]=p[j];
            k++;
            j++;
        }
    }
    while(i<=mid)
    {
        seq[k]=p[i];
        k++;
        i++;
    }
    while(j<=r)
    {
        seq[k]=p[j];
        k++;
        j++;
    }
    for(i=l;i<=r;i++) p[i]=seq[i];
    
    int sidx=0;
    for(i=l;i<=r;i++) if(cmp(fabs(p[i].x-base),d)<=0) seq[++sidx]=p[i]; //剪枝2:排除不可能答案:点i的横坐标与base的绝对值都大于当前的最近点对的距离d,与另一半部分构成的点对的距离更不可能成为答案
    for(i=1;i<=sidx;i++)    //暴力匹配左右两半部分的点对。加上3个剪枝后,数学几何可以证明复杂度是O(5*N)
        for(j=i+1;j<=sidx;j++)
        {
            if(cmp(fabs(seq[i].y-seq[j].y),d)>=0) break;    //剪枝3:排除不可能答案:由于点按纵坐标排序,所以当点i与点j的距离大于当前的最近点对的距离d时,点j后面的点与点i构成的点对的距离更不可能成为答案
            d=min(d,get_dis(seq[i],seq[j]));
        }
    
    return d;
}

for(int i=1;i<=n;i++) scanf("%Lf%Lf",&p[i].x,&p[i].y);
sort(p+1,p+n+1,cmpx);   //将点按横坐标x排序。以便分治求解
printf("%.4Lf\n",divide(1,n));

2.6.分形

  1. 新建字符数组a为画布。
  2. 思考如何在画布a的矩形(x1,y1),(x2,y2)范围内画第k阶图形u。
  3. 用空格初始化画布a。
  4. 分治画图,注意n,i,x是纵,m,j,y是横。输出。
//注意n,i,x是纵,m,j,y是横
void divide(int u,int k,int x1,int y1,int x2,int y2)//在画布a的矩形(x1,y1),(x2,y2)范围内画第k阶图形u

for(int i=1;i<=n;i++)
    for(int j=1;j<=m;j++)
        a[i][j]=' ';//注意初始化
divide(1,k,1,1,n,m);
for(int i=1;i<=n;i++,puts(""))
    for(int j=1;j<=m;j++)
        putchar(a[i][j]);

3.排序

3.1.快速排序

3.1.1.快速排序\(O(\log N*N*Compare)\)

STLsort

《C++语言.1.标准模板库(STL)函数和算法模板 排序sort

手写

void quick_sort(type a[],int l,int r)   //将数组a的下标在[l,r]内的元素升序排序
{
    if(l>=r) return ;
    int i=l-1,j=r+1;
    type x=a[(l+r)>>1];
    while(i<j)
    {
        do i++; while(a[i]<x);
        do j--; while(a[j]>x);
        if(i<j) swap(a[i],a[j]);
    }
    quick_sort(a,l,j);
    quick_sort(a,j+1,r);
    return ;
}

3.1.2.快速排序求第k小元素\(O(N*Compare)\)

STLnth_element

《C++语言.1.标准模板库(STL)函数和算法模板 第n小元素nth_element

手写

type quick_sort(type a[],int l,int r,int k)   //将数组a的下标在[l,r]内的元素重新分配顺序,第k小的元素分配在l+k-1,小于(等于)第k小的元素的元素分配在[l,l+k-1),大于(等于)第k小的元素的元素分配在(l+k-1,r],并返回第k小元素
{
    if(l>=r) return a[l];
    int i=l-1,j=r+1;
    type x=a[(l+r)>>1];
    while(i<j)
    {
        do i++; while(a[i]<x);
        do j--; while(a[j]>x);
        if(i<j) swap(a[i],a[j]);
    }
    if(j-l+1>=k) return quick_sort(a,l,j,k);
    else return quick_sort(a,j+1,r,k-(j-l+1));
}

cout<<quick_sort(a,1,n,k)<<'\n';
/*或者
quick_sort(a,1,n,k);
cout<<a[k]<<'\n';
*/

3.2.归并排序

3.2.1.归并排序\(O(\log N*N*Compare)\)

STLstable_sort

《C++语言.1.标准模板库(STL)函数和算法模板 稳定排序stable_sort

手写

type b[N];

void merge_sort(type a[],int l,int r)    //将数组a的下标在[l,r]内的元素升序排序
{
    if(l==r) return ;
    
    int mid=(l+r)>>1;
    merge_sort(a,l,mid);
    merge_sort(a,mid+1,r);
    
    int i=l,j=mid+1,k=l;
    while(i<=mid && j<=r)
    {
        if(a[i]<=a[j])
        {
            b[k]=a[i];
            i++,k++;
        }
        else
        {
            b[k]=a[j];
            j++,k++;
        }
    }
    while(i<=mid)
    {
        b[k]=a[i];
        i++,k++;
    }
    while(j<=r)
    {
        b[k]=a[j];
        j++,k++;
    }
    for(i=l;i<=r;i++) a[i]=b[i];
    
    return ;
}

3.2.2.归并排序求逆序对

3.2.2.1.逆序对

本质是二维偏序:满足的i<j且\(a_i>a_j\)元素对\((\{i,a_i\},\{j,a_j\})\)个数。

应用

  • 现实意义:给定1个数列,只能使用交换相邻两项的操作(冒泡排序),至少需要逆序对个数次操作次数。

  • 逆序对的奇偶性:

    • 交换序列中的两个元素,逆序对的奇偶性一定会改变。
    • 将序列中下标在[l,r]内的元素循环左移k位,当且仅当长度r-l+1为偶数并且k为奇数时,逆序对的奇偶性改变。
  • 给定1个pair数列,按照第一关键字排序得到的数列的以第二关键字为依据的逆序对个数res1=按照第二关键字排序得到的数列的以第一关键字为依据的逆序对个数res2。

    证明:由逆序对的现实意义,按照第一关键字排序得到的数列至少使用res1次交换相邻两项的操作才能得到按照第二关键字排序得到的数列,按照第二关键字排序得到的数列至少使用res2次交换相邻两项的操作才能得到按照第一关键字排序得到的数列。而这两个变换本质上是互逆的。

  • 奇数码游戏两个局面可达,当且仅当两个局面转化为序列(不考虑空格)后,逆序对的奇偶性相同。

3.2.2.2.归并排序求逆序对\(O(N\log N)\)

注意开long long!

只能最后一起求逆序对。

type b[N];

ll merge_sort(type a[],int l,int r)    //求出数组a的下标在[l,r]内的元素构成的逆序对
{
    if(l==r) return 0;
    
    ll res=0;
    int mid=(l+r)>>1;
    res+=merge_sort(a,l,mid);
    res+=merge_sort(a,mid+1,r);
    
    int i=l,j=mid+1,k=l;
    while(i<=mid && j<=r)
    {
        if(a[i]<=a[j])
        {
            b[k]=a[i];
            i++,k++;
        }
        else
        {
            b[k]=a[j];
            res+=mid-i+1;   //a[j]与a[i~mid]构成逆序对
            j++,k++;
        }
    }
    while(i<=mid)
    {
        b[k]=a[i];
        i++,k++;
    }
    while(j<=r)
    {
        b[k]=a[j];
        j++,k++;
    }
    for(i=l;i<=r;i++) a[i]=b[i];
    
    return res;
}

树状数组求逆序对:《数据结构7.1.树状数组求逆序对》

优点:可以一边插入一边随时求逆序对。

3.2.3.归并排序将2个有序的序列合并为1个有序的序列\(O((N_1+N_2)*Compare)\)

3.3.基数排序

3.3.1.计数排序\(O(N+W)\)

void counting_sort(int a[],int n)
{
    int c[W];
    int b[N];
    memset(c,0,sizeof c);
    for(int i=1;i<=n;i++) c[a[i]]++;
    for(int x=1;x<W/*注意这里是<W*/;x++) c[x]+=c[x-1];
    for(int i=n;i>=1;i--) b[c[a[i]]--]=a[i];    //从n到1倒序以保证排序稳定性
    for(int i=1;i<=n;i++) a[i]=b[i];
    return ;
}

3.3.2.k-关键字元素

\(a<b\Leftrightarrow \exist i\in[1,k],(\forall j\in[1,i-1],a_j=b_j)\land a_i<b_i\)

\(a=b\Leftrightarrow \forall i\in[1,k],a_i=b_i\)

3.3.3.基数排序\(O(K(N+W^{\frac1K}))\)

下面采用LSD(Least Significant Digit first)写法。

  1. 将元素转化为k-关键字元素,满足大小关系不变。
  2. 从k到1遍历i,依次以第i关键字为依据对所有元素执行计数排序。

代码以元素为10^9内的正整数为例。

struct K_keyword
{
    int key[K];
};

void counting_sort(K_keyword ak[],int n,int ik)
{
    int c[WK];
    K_keyword b[N];
    memset(c,0,sizeof c);
    for(int i=1;i<=n;i++) c[ak[i].key[ik]]++;
    for(int x=1;x<WK;x++) c[x]+=c[x-1];
    for(int i=n;i>=1;i--) b[c[ak[i].key[ik]]--]=ak[i];
    for(int i=1;i<=n;i++) ak[i]=b[i];
    return ;
}

void radix_sort(int a[],int n)
{
    int k=2,wk=sqrt(W);
    K_keyword ak[N];
    for(int i=1;i<=n;i++) ak[i].key[1]=a[i]/wk,ak[i].key[2]=a[i]%wk;
    for(int i=k;i>=1;i--) counting_sort(ak,n,i);
    for(int i=1;i<=n;i++) a[i]=ak[i].key[1]*wk+ak[i].key[2];
    return ;
}

3.4.严格弱序

比较必须满足严格弱序。以重载为例,必须满足以下严格弱序的四个条件:

\(\not<\):cmp函数判断是否x<y返回false。

  • 非自反性:“严格”:\(x\not< x\)

    因此小于号不能重载为小于等于。

  • 等价(不可比性)传递性:“弱”:若\(x\not<y,y\not<x\),则x=y。\(\Leftrightarrow\)\(x\not<y,y\not<x,y\not<z,z\not<y\),则\(x\not<z,z\not<x\)

  • 反对称性:“有序”:若x<y,则\(y\not<x\)

  • 传递性:“有序”:若x<y,y<z,则x<z。

若重载小于号时不能满足上面的严格弱序的条件,则必须构造一种既满足原比较关系,又满足严格弱序的比较关系。

方法一:一般最容易不满足严格弱序的条件是“弱”。如果原比较关系只不满足“弱”,则特判当两组元素在原比较关系相等时如何比较它们之间的大小。\(e.g.\)min(a[i],b[i+1])<min(a[i+1],b[i])不满足“弱”。只需要在min(a[i],b[i+1])==min(a[i+1],b[i])时判断a[i]<a[i+1]即可。

方法二:如果比较的元素是多元组,则先按照组内元素大小关系分类,讨论类与类之间的大小关系以及类内部组与组之间的大小关系。

方法三:某些比较关系可以等价变形。\(e.g.\)max(a[i],a[i+1]+b[i])<max(a[i+1],a[i]+b[i+1]):显然a[i]<a[i]+b[i+1],a[i+1]+b[i]>a[i+1],因此等价变形为a[i+1]+b[i]<a[i]+b[i+1]。

补充

  1. \((a_{i+1}-a_i)(b_{i+1}-b_i)>0\),则不交换\((b_i,b_{i+1})\)更优\(\Leftrightarrow\)在保证a不变的情况下,移动b使在同一个位置的a,b的两根火柴在a,b中的排名对应相等。

3.5.排序思想

3.5.1.冒泡排序

邻项交换、逆序对。

3.5.2.插入排序

数学归纳法(假设前i-1个数有序,考虑第i个数。)

4.二分

适用条件:具有单调性的问题。

4.1.STLlower_boundupper_bound

《C++语言.1.标准模板库(STL)函数和算法模板 二分lower_boundupper_bound

4.2.整数集合上的二分

4.2.1.整数集合上的二分

  • l=mid?r=mid?

    令长度为2的区间l=1,r=2。看是l=mid还是r=mid会缩小为长度为1区间。

  • 保左?保右?

    代码

    令长度为2的区间l=1,r=2。如果mid是偏向l(r),则该二分是保左(右)。

    如果mid是恰好可行的(==)且保左(右),就令r=mid(l=mid)。(因为mid本身就是偏左(右))

    问题

    保左(右):边界mid在答案位置ans的左(右)边。此时l(r)会向mid的右(左)边一单位跳到答案位置。

    4种情况(0代表check(mid)=false,1代表true):01找最后一个0、01找第一个1、10找最后一个1、10找第一个0。

    根据问题选择保左的二分还是保右的二分。

  • 无解?

    对于保左写法(mid=(l+r)>>1),mid不会取到r这个值,因此可以把最初的二分区间[1,n]扩大为[1,n+1],若最终l==n+1,无解。

    对于保右写法(mid=(l+r+1)>>1),mid不会取到l这个值,因此可以把最初的二分区间[1,n]扩大为[0,n],若最终l==0,无解。

在单调序列a中查找≥x最小的一个(x或x的后继、01找第一个1、10找第一个0),保左:

while(l<r)
{
    int mid=(l+r)>>1;
    if(a[mid]>=x) r=mid;
    else l=mid+1;
}
return a[l];//思维:此时l==r

在单调序列a中查找≤x最大的一个(x或x的前躯、01找最后一个0、10找最后一个1),保右:

while(l<r)
{
    int mid=(l+r+1)>>1;
    if(a[mid]<=x) l=mid;
    else r=mid-1;
}
return a[r];//思维:此时l==r
  • 万能二分

    01:

int ans;
while(l<=r)
{
    int mid=(l+r)>>1;
    if(check(mid)) r=mid-1,ans=mid;
    else l=mid+1;
}
return ans;//思维:此时l==r

10:

int ans;
while(l<=r)
{
    int mid=(l+r)>>1;
    if(check(mid)) l=mid+1,ans=mid;
    else r=mid-1;
}
return ans;//思维:此时l==r

4.2.2.二分答案转为判定

这个思想在很多算法中都有涉猎。

适用条件:具有“单调性”值域的问题:
设定义域为该问题下可行方案,则值域为该方案的评分,最优解就是评估分最高S的方案。\(\forall x>S\),都不存在一个合法的方案达到x分;\(\forall x≤S\),一定存在一个合法的方案达到或超过x分。
又或者说方案一段全部合法另一段全部不合法。

把最优性问题转化为可行性问题,简化题目。

注意保左保右的问题。详见《基础算法4.1.整数集合上的二分》

技巧

  • 求绝对值|c-x|最小值:可拆成小于c的最大x和大于c的最小x两个子问题。
  • 求check()方案时,要while(l<r)循环后****check(l),不可以在while(l<r)循环内的if(check(mid))后记录方案,因为有可能mid一直非法到最后l=r=1就没有记录方案了。

4.3.实数域上的二分

while(r-l>EPS)
{
    double mid=(l+r)/2;
    if(check(mid)) l=mid;//mid还需要再大一点
    else r=mid;//mid还需要再小一点
}
return l;//思维:此时l==r。偶尔取l(或r)会有精度问题,需要换着取r(或l)试试

4.4.斜率上的二分

凸单调问题。求一个点集中的点,使其与给定的点的斜率最值→求一个凸包上的点,使其与给定的点的斜率最值。

下面以下凸包(斜率最大)为例:下凸包存在栈s中:

int l=1,r=top,res;
while(l<=r)
{
    int mid=(l+r)>>1;
    if(sign(slope(s[mid-1],poi)/*slope(a,b):a、b两点连线的斜率*/,slope(s[mid],poi))<0) l=mid+1,res=mid;
    else r=mid-1;
}
return res;

4.5.三分

《搜索.三分》

搜索

4.6.制造可二分性

  • “找到第一个关键点”:前缀处理,使得满足可二分性。

5.倍增

适用条件:递推问题的状态空间关于2的次幂具有可划分性,或具有可二分性。

5.1.序列上的倍增——二分的兄弟

01找最后一个0、10找最后一个1。

若要“01找第一个1、10找第一个0”,则在倍增求得“01找最后一个0、10找最后一个1”后令答案加一。

5.1.1.二进制拆分写法

int r=l-1;
for(int k=19;k>=0;k--)
    if(r+(1<<k)<=n && check(l,r+(1<<k)))
        r+=1<<k;

//找最后一个0
if(r==l-1) puts("-1");    //不存在0,无解
printf("%d\n",r);

//找第一个1
r++;
if(r==n+1) puts("-1");    //不存在1,无解
printf("%d\n",r);

5.1.2.最远扩展写法

记len=r-l+1,check(l,r)的复杂度是\(O(f(len))\)。则此写法的复杂度是\(O(f(len)\log len)\)

int r=l-1,p=1;
while(r+p<=n && check(l,r+p))
{
    r+=p;
    p<<=1;
}
while(p)
{
    if(r+p<=n && check(l,r+p)) r+=p;
    p>>=1;
}

//找最后一个0
if(r==l-1) puts("-1");    //不存在0,无解
printf("%d\n",r);

//找第一个1
r++;
if(r==n+1) puts("-1");    //不存在1,无解
printf("%d\n",r);

5.1.3.倍增优于二分的情况

5.1.3.1.树状数组上从1向右/从n向左倍增

树状数组上二分是\(O(\log^2N)\),树状数组上二进制拆分写法的倍增是\(O(\log N)\)

5.1.3.2.最小划分问题

问题:求出将序列最小划分成几段,才能使每一段都满足条件。

根据贪心,为了让序列分成的段数最少,每一段都应该在满足条件的前提下,尽量包含更多的数。

于是现在需要高效解决的问题为:当确定一个左端点l之后,右端点r在[l,r]满足条件的前提下,最远能扩展到多少。

二分的复杂度是\(O(\sum f(N)\log N=f^2(N)\log N)\),最远扩展写法的倍增的复杂度是\(O(\sum f(len)\log len)=O(f(N)\log N)\)

5.2.ST表

适用条件:可重叠:RMQ问题:区间最值问题、区间GCD。

思维:计数问题:不重不漏;最值问题:不漏即可。

以静态询问区间最大值为例:

\(f[l,k]\):序列下标在\([l,l+2^k-1]\)里的数的最大值,即从\(l\)开始\(2^k\)个数的最大值。

递推边界:\(f[l,0]=a[l]\)

递推公式:\(f[l,k]=\max(f[l,k-1],f[l+2^{k-1},k-1])\)

const int N=2e5+10;
int n,q;
int a[N];
int f[N][20],log_2[N];

void ST_pre()
{
    for(int i=2;i<=n;i++) log_2[i]=log_2[i>>1]+1;
    for(int l=1;l<=n;l++) f[l][0]=a[l];
    for(int k=1;1+(1<<k)-1<=n;k++)
        for(int l=1;/*r=*/l+(1<<k)-1<=n;l++)
            f[l][k]=/*或min*/max(f[l][k-1],f[l+(1<<(k-1))/*注意这里不要-1!!!*/][k-1]);
    return ;
}

int ST_query(int l,int r)
{
    int k=log_2[r-l+1];
    return /*或min*/max(f[l][k],f[r-(1<<k)+1][k]);
}

如果还要求出最大值在哪个位置:

void st_pre()
{
    for(int i=2;i<=n;i++) log_2[i]=log_2[i>>1]+1;
    for(int i=1;i<=n;i++) f[i][0]=i;
    for(int k=1;1+(1<<k)-1<=n;k++)
        for(int l=1;l+(1<<k)-1<=n;l++)
        {
            int a=f[l][k-1],b=f[l+(1<<(k-1))][k-1];
            f[l][k]=sum[a]>sum[b] ? a : b;
        }
    return ;
}

int st_query(int l,int r)
{
    int k=log_2[r-l+1];
    int a=f[l][k],b=f[r-(1<<k)+1][k];
    return sum[a]>sum[b] ? a : b;
}

卡空间技巧

若st表要在struct上建立,开\(O(N\log N)\)struct可能会爆空间。其实只要开\(O(N)\)struct存原序列,st表开\(O(N\log N)\)int存最值对应的下标,比较大小时用映射后的值比较。

5.3.倍增数组

\(f[i][k]\):区间\([i,i+2^k-1]\)\([i-2^k+1,i]\)的某信息。

5.4.倍增的其他运用

  1. 树上倍增求LCA。

    《图论》

6.散列表(Hash)

6.1.数Hash

6.1.1.离散化\(O(N\log N)\)

有关区间覆盖[l,r]问题中的离散化的注意事项:

如果只把l、r加入到离散化,或错把l、l+1、r-1、r加入到离散化(即向内扩张),那么对于依次[1,5]、[1,2]、[4,6]区间覆盖,由于有关[1,5]的离散化的点都被覆盖,导致程序误判[1,5]被完全覆盖。

正确的做法是把l、r、r+1(或l-1)加入到离散化,即要向外扩张。如果求稳可以把l、l-1、l+1、r、r-1、r+1都加入到离散化。

6.1.1.1.基于相对大小

优点:仍能比较大小。

缺点:必须一起离线处理。

vector<int> nums;

//最小的元素->0
//注意,如果要令最小的元素->1,则查询离散化之前的值应该是nums[x-1]!!!
int Hash(int x)
{
    return lower_bound(nums.begin(),nums.end(),x)-nums.begin();
}

int main()
{
    scanf("%d",&x);
    nums.push_back(x);
    //继续输入abaabaaba...

    sort(nums.begin(),nums.end());
    nums.erase(unique(nums.begin(), nums.end()), nums.end());

    //O(1)查询使用离散化之后的值,先预处理:a->b
        x=Hash(x);
        //然后x就是离散化之后的值

    //查询离散化之前的值:a<-b
    int y=nums[x];

    return 0;
}

6.1.1.2.基于记忆化

优点:可以在线处理。

缺点:不能比较大小。

int hidx;
map<int,int> h;

int Hash(int x)
{
    if(h.count(x)) return h[x];
    return h[x]=++hidx;
}

int main()
{
    int x;
    scanf("%d",&x);
    int y=Hash(x);
    //abaabaaba...
}

6.1.2.Xor Shift\(O(1)\)

const ull MASK=mt19937_64(srd())();

ull xor_shift(ull x)
{
    x^=MASK;
    x^=x<<13;
    x^=x>>7;
    x^=x<<17;
    x^=MASK;
    return x;
}

6.2.字符串Hash

通过下面的内容可以实现一个简单的\(O(N\log^2N)\)的后缀数组求法。

6.2.1.判断两个子串是否相同

\(O(N)\)预处理母串,\(O(1)\)判断两个子串是否相同。

//模数=131
int m,len,l1,l2,r1,r2;
unsigned long long f[N],q[N];//f:字符串hash;q:预处理模数的次方
char s[N];

int main(){
    scanf("%s",s+1);
    scanf("%d",&m);
    
    len=strlen(s+1);
    q[0]=1;
    for(int i=1;i<=len;i++){
        f[i]=f[i-1]*131+(s[i]-'a'+1);//在字符串后面添加一个字符,+1不可省略!!!
        
        q[i]=q[i-1]*131;
    }
    
    while(m--){
        scanf("%d%d%d%d",&l1,&r1,&l2,&r2);
        if(f[r1]-f[l1-1]*q[r1-l1+1]/*求子字符串的hash'*/ == f[r2]-f[l2-1]*q[r2-l2+1]) puts("Yes");
        else puts("No");
    }
    return 0;
}

6.2.2.比较两个子串的大小与查询两个子串的最长公共前缀

\(O(N)\)预处理母串。

二分找到第一处两个子串不相同的位置。二分判断条件是《基础算法6.2.1.判断两个子串是否相同》中的哈希判断相同。然后比较第一处不相同的位置的字符的大小。若没有不相同的位置,则比较两个子串的长度。\(O(\log N)\)

bool cmp(int l1,int r1,int l2,int r2)
{
    int len=min(r1-l1+1,r2-l2+1);   //防止越界
    
    //二分找到第一处两个子串不相同的位置:str[l1+l]!=str[l2+l]
    int l=0,r=len;
    while(l<r)
    {
        int mid=(l+r)>>1;
        if(f[l1+mid]-f[l1-1]*p[(l1+mid)-(l1-1)]!=f[l2+mid]-f[l2-1]*p[(l2+mid)-(l2-1)]/*哈希判断相同*/) r=mid;
        else l=mid+1;
    }
    if(l==len) return r1-l1+1<r2-l2+1;  //若没有不相同的位置,则比较两个子串的长度
    else return str[l1+l]<str[l2+l];    //若有不相同的位置,比较第一处不相同的位置的字符的大小
}

同理可以查询两个子串的最长公共前缀。\(O(\log N)\)

6.3.树同构

树同构一般是判断无标号同构,所以不可用prufer有标号判断。

无根树同构转化为有根树同构

选定关键点为无根树的根,而一棵树的重心最多只有2个。

int n;
int head[2][N],e[2][M],ne[2][M],idx[2];

pii wc[2][2];

void add(int id,int u,int v)
{
    e[id][++idx[id]]=v,ne[id][idx[id]]=head[id][u],head[id][u]=idx[id];
    return ;
}

int dfs1(int id,int u,int father)
{
    int siz=1,ma=0;
    for(int i=head[id][u];i;i=ne[id][i])
    {
        int v=e[id][i];
        if(v==father) continue;
        int res=dfs1(id,v,u);
        siz+=res;
        ma=max(ma,res);
    }
    ma=max(ma,n-siz);
    if(ma<wc[id][0].y) wc[id][0]={u,ma},wc[id][1]={-1,n};
    else if(ma==wc[id][0].y) wc[id][1]={u,ma};
    return siz;
}

cin>>n;
for(int id=0;id<=1;id++)
{
    idx[id]=0,wc[id][0]=wc[id][1]={-1,n};
    for(int u=1;u<=n;u++) head[id][u]=0;
}
for(int id=0;id<=1;id++)
{
    for(int i=1,u,v;i<n;i++)
    {
        cin>>u>>v;
        add(id,u,v),add(id,v,u);
    }
    dfs1(id,1,0);
}

技巧

  1. 还需判断点权是否相同:把点权设计到点的初值。

对比

复杂度 是否绝对正确 是否好写/能否高效换根 能否在线处理 能否比较大小 能否逆向求出树的结构
AHU算法 基于树的最小括号序列表示 \(O(N^2)\) × √(能比较任何点的以其为根的子树的最小括号序列的大小)
基于点的儿子数组递推的数值表示 基于同一深度的点的以其为根的子树的“结构字典序”的相对大小 \(O(N)/O(N\log N)\) × √(能比较同一深度的点的以其为根的子树的“结构字典序”的大小) ×
基于记忆化 \(O(N\log N)\) × ×
树哈希 \(O(N)\) ×(但几乎正确,期望冲突率为\(O(\frac{N^2}{W_{Hash}})\) × ×

6.3.1.AHU算法

6.3.1.1.基于树的最小括号序列表示(树的最小表示)\(O(N^2)\)

(或者0代表进入一点,)或者1代表出去一点。

复杂度参考树形dp和合并子树信息模型。

string ahu(int id,int u,int fa)  //求出以点u为根的子树的最小括号序列
{
    vector<string> son_seqs;
    for(int i=head[id][u];i;i=ne[id][i])
    {
        int v=e[id][i];
        if(v==fa) continue;
        son_seqs.push_back(ahu(id,v,u));
    }
    sort(son_seqs.begin(),son_seqs.end());

    string res="("; //进入该点,回溯时该res会自动释放内存
    for(auto s : son_seqs) res+=s;
    res+=')';   //出去该点
    return res;
}

if(ahu(0,wc[0][0].x,0)==ahu(1,wc[1][0].x,0)) cout<<"YES\n";
else
{
    if(wc[0][1].x!=-1 && ahu(0,wc[0][1].x,0)==ahu(1,wc[1][0].x,0)) cout<<"YES\n";
    else cout<<"NO\n";
}

6.3.1.2.基于点的儿子数组递推的数值表示

6.3.1.2.1.基于同一深度的点的以其为根的子树的“结构字典序”的相对大小\(O(N)/O(N\log N)\)

有根树的“结构字典序”的大小:对于不同构的有根树\(T_1\)和有根树\(T_2\)\(T1,T2\)的根节点的儿子\(son_1,son_2\)都分别按照以其为根的子树的‘结构字典序’排序,满足“任何小于等于其的序号\(j\)都有以\(son_{1,j}\)为根的子树的‘结构字典序’等于以\(son_{2,j}\)为根的子树的‘结构字典序’(同构)”的最大序号为\(i\)\(T_1\)的“结构字典序”小于\(T_2\)的“结构字典序”当且仅当:\(i\)等于\(son_1\)的数量或者以\(son_{1,i+1}\)为根的子树的“结构字典序”小于以\(son_{2,i+1}\)为根的子树的“结构字典序”。

注意必须一起处理所有树的同一深度的点。

在下面的代码的cmp中,若采用暴力比较,则总体复杂度是\(O(N\log N)\)(参考树上启发式合并);若采用基数排序,则总体复杂度是\(O(N)\)

vector<pii> layer[N];
int fa[2][N];
vector<int> f[2][N];
int tag[2][N];

void dfs2(int id,int u,int d)
{
    layer[d].push_back({id,u});
    for(int i=head[id][u];i;i=ne[id][i])
    {
        int v=e[id][i];
        if(v==fa[id][u]) continue;
        fa[id][v]=u;
        dfs2(id,v,d+1);
    }
    return ;
}

bool cmp(pii x,pii y)
{
    return f[x.x][x.y]<f[y.x][y.y];
}

bool ahu(int rt[])
{
    for(int d=1;d<=n+1/*注意初始化到n+1!!!*/;d++) layer[d].clear();
    for(int id=0;id<=1;id++)
    {
        fa[id][rt[id]]=0;
        for(int u=1;u<=n;u++) f[id][u].clear();
    }
    for(int id=0;id<=1;id++) dfs2(id,rt[id],1);
    for(int d=n;d>=1;d--)
    {
        for(auto it : layer[d+1]) f[it.x][fa[it.x][it.y]].push_back(tag[it.x][it.y]);
        sort(layer[d].begin(),layer[d].end(),cmp);
        int tidx=0;
        for(int i=0;i<layer[d].size();i++)
        {
            if(i && f[layer[d][i].x][layer[d][i].y]!=f[layer[d][i-1].x][layer[d][i-1].y]) tidx++;
            tag[layer[d][i].x][layer[d][i].y]=tidx;
        }
    }
    return tag[0][rt[0]]==tag[1][rt[1]];
}

int rt[2]={wc[0][0].x,wc[1][0].x};
if(ahu(rt)) cout<<"YES\n";
else
{
    rt[0]=wc[0][1].x,rt[1]=wc[1][0].x;
    if(rt[0]!=-1 && ahu(rt)) cout<<"YES\n";
    else cout<<"NO\n";
}

6.3.1.2.2.基于记忆化\(O(N\log N)\)

int tidx;
map<vector<int>,int> h;
int tag[2][N];

void ahu(int id,int u,int fa)
{
    vector<int> son_tags;
    for(int i=head[id][u];i;i=ne[id][i])
    {
        int v=e[id][i];
        if(v==fa) continue;
        ahu(id,v,u);
        son_tags.push_back(tag[id][v]);
    }
    sort(son_tags.begin(),son_tags.end());
    if(!h.count(son_tags)) tag[id][u]=h[son_tags]=++tidx;
    else tag[id][u]=h[son_tags];
    return ;
}

ahu(0,wc[0][0].x,0),ahu(1,wc[1][0].x,0);
if(tag[0][wc[0][0].x]==tag[1][wc[1][0].x]) cout<<"YES\n";
else
{
    if(wc[0][1].x==-1) cout<<"NO\n";
    else
    {
        ahu(0,wc[0][1].x,0);
        if(tag[0][wc[0][1].x]==tag[1][wc[1][0].x]) cout<<"YES\n";
        else cout<<"NO\n";
    }
}

6.3.2.树哈希\(O(N)\)

定义以点u为根的子树的Hash值\(h(u)=1+\sum\limits_{v\in son_u}f(h(v))\),其中f是一个随机函数。

若f满足是一个随机函数,则几乎正确,期望冲突率为\(O(\frac{N^2}{W_{Hash}})\)(只需考虑最深的一对冲突点即可)。

还可以对最终的哈希值^=prime[siz[u]]来继续提高正确率。

能高效换根,第二次 dp 时只需把子树Hash减掉即可。

random_device srd;
const ull MASK=mt19937_64(srd())();
ull h[2][N];

//第一种随机函数f:xor shift
ull f(ull x)
{
    x^=MASK;
    x^=x<<13;
    x^=x>>7;
    x^=x<<17;
    x^=MASK;
    return x;
}
/*第二种随机函数f:一个随便找的无特殊性质的函数+扰动
ull shake(ull x)  //扰动,这里可以随便写无特殊性质的扰动。数字必须是质数,否则会因保持2^k的同余关系而增加冲突
{
    return x*x*x*3333331+19260817;
}

ull f(ull x)
{
    return shake(x&((1ull<<31ull)-1))+shake(x>>31ull); //注意不加ull会溢出
}
*/

void dfs2(int id,int u,int father)
{
    h[id][u]=1;
    for(int i=head[id][u];i;i=ne[id][i])
    {
        int v=e[id][i];
        if(v==father) continue;
        dfs2(id,v,u);
        h[id][u]+=f(h[id][v]);
    }
    return ;
}

dfs2(0,wc[0][0].x,0),dfs2(1,wc[1][0].x,0);
if(h[0][wc[0][0].x]==h[1][wc[1][0].x]) cout<<"YES\n";
else
{
    if(wc[0][1].x==-1) cout<<"NO\n";
    else
    {
        dfs2(0,wc[0][1].x,0);
        if(h[0][wc[0][1].x]==h[1][wc[1][0].x]) cout<<"YES\n";
        else cout<<"NO\n";
    }
}

6.4.异或Hash

适用条件:只关心每个元素出现次数的奇偶性+判断2个哈希值是否相等。

当元素种数较小时,用一个二进制数表示每个元素出现次数的奇偶性,该数二进制下第k位表示第k种元素出现次数的奇偶性。遇到一个第k种元素就令x^=(1<<k)

当元素种数较多时,考虑上面的方法相当于p[k]=1<<k;x^=p[k];,其实可以令每个p[k]随机一个long long范围的数for(int k=1;k<=n;k++) p[k]=r64(1e9,1e18),出现冲突的概率极小。遇到一个第k种元素就令x^=p[k],然后就像普通异或那样使用。

进一步提高正确率可以采用双哈希(两个随机数p[2][k])。

6.5.Hash

适用条件:一般将实际问题通过某种运算得到它的“特征数”,但是“特征数”过大或“特征数”会有小冲突

模数N应该是一个远离2的次幂的质数。

6.5.1.Hash设计思路

  1. 先在不考虑取模和哈希值较大的情况下,保证哈希值正确率100%。

    把判定相同的条件全部设计到哈希值。

    技巧:哈希值=状态1(\(≤10^x\))+状态2*一个质数(\(>10^x\))。

  2. 取模。

    技巧:多模数:

const int MOD[2]={651293939,492998489};//随机多个质数
LL h[2][N]; //h[i][x]:在模数是MOD[i]情况下x的哈希值
for(int midx=0;midx<2;midx++) h[midx][x]=calc(x)%MOD[midx];
if((h[0][x]==h[0][y]) && (h[1][x]==h[1][y])) puts("same");  //只有当多模数的所有哈希值都相同时,才判定x与y相同

6.5.1.1.pair Hash

pair.first*P+pair.second。其中P是一个大质数,最好大小超过pair.second的值域。

6.5.2.开放寻址法

注意模数N应该是大于题目数据范围的2~3倍的质数。

const int N=200003,INF=0x3f3f3f3f;
int n;
int h[N];

int find(int x)
{
    int res=(x%N+N)%N;
    while(h[res]!=INF && h[res]!=x)
    {
        res++;
        if(res==N) res=0;
    }
    return res;
}

int main()
{
    memset(h,0x3f,sizeof h);
    scanf("%d",&n);
    while(n--)
    {
        char op[2];
        int x;
        scanf("%s%d",op,&x);
        if(op[0]=='I') h[find(x)]=x;
        else
        {
            if(h[find(x)]==INF) puts("No");
            else puts("Yes");
        }
    }
    return 0;
}

6.5.3.链表法

const int N=100003;
int n;
int h[N],e[N],ne[N],idx;

void add(int u,int v)
{
    e[++idx]=v;
    ne[idx]=h[u];
    h[u]=idx;
    return ;
}

bool query(int u,int v)
{
    for(int i=h[u];i!=-1;i=ne[i]) if(e[i]==v) return true;
    return false;
}

int main()
{
    memset(h,-1,sizeof h);
    scanf("%d",&n);
    while(n--)
    {
        char op[2];
        int x;
        scanf("%s%d",op,&x);
        if(op[0]=='I') add((x%N+N)%N,x);
        else
        {
            if(query((x%N+N)%N,x)) puts("Yes");
            else puts("No");
        }
    }
    return 0;
}

7.双指针

r++区间内加入一个元素,l++区间内删除一个元素。

7.1.双指针

常见问题分类:

  1. 答案随着编号有单调性\(O(N)\)
  2. 对于一个序列,用两个指针维护一段区间
  3. 对于两个序列,维护某种次序,比如归并排序中合并两个有序序列的操作
for (int i = 0, j = 0; i < n; i ++ ){
    while (j < i && check(i, j)) j ++ ;
    // 具体问题的逻辑
}

7.2.回滚双指针(baka's trick)\(O(N)\)

前提条件:求解的信息具有结合律。

适用条件:r++区间内加入一个元素的复杂度较小,l++区间内删除一个元素的复杂度较大。

加入一个新指针mid,l≤mid≤r。

lres[i]:[i,mid)的信息。(mid是实时的mid。)

算法的核心。如何不赘余地维护lres[i]见下面的代码。

rres:[mid,r]的信息。(mid是实时的mid。)

当l<mid 时,[l,r]的信息=calc(lres[l],rres);当l==mid 时,[l,r]的信息=rres。

int lres[N],rres;

for(int l=1,r=1,mid=1;l<=n;l++)
{
    if(l>=mid)//>:特判上一轮l=mid=r的情况;=:lres[l]:[l,mid)。
    {
        if(l>r) r=l;//特判上一轮l=mid=r的情况。
        mid=r;
        
        rres=a[r];
        
        lres[mid-1]=a[mid-1];
        for(int i=mid-2;i>=l;i--) lres[i]=calc(a[i],lres[i+1]);
    }
    while(r+1<=n && ((l<mid && check(lres[l],rres,a[r+1])) || (l==mid && check(rres,a[r+1]))))
    {
        r++;
        rres=calc(rres,a[r]);
    }
   if((l<mid && check(lres[l],rres)) || (l==mid && check(rres))) ans[l]=r;
}

8.区间处理

8.1.区间合并

步骤:

  1. 将所有区间按左端点排序;
  2. 对于每个区间,它的后面的区间只有一下三种情况:a. 包含关系;b. 有交集,但是不包含;c. 毫无关系。解决方案:对于 a : 直接跳过;对于 b : 延长 ed;对于 c : 输出区间,新建一个新区间。
struct node{
    int x,y;
}a[100005];

bool cmp(node q,node w){
    return q.x<w.x;
}

int main(){
    int n,l=-1e9,r=-1e9,tot=0;
    cin>>n;
    for(int i=1;i<=n;i++) cin>>a[i].x>>a[i].y;
    
    sort(a+1,a+n+1,cmp);
    
    for(int i=1;i<=n;i++){
        if(a[i].x>r){
            tot++;
            l=a[i].x;
            r=a[i].y;
        }
        else if(a[i].y>r) r=a[i].y;
    }
    
    cout<<tot<<endl;
    return 0;
}

8.2.排除包含区间

若两个区间i\([l_i,r_i]\)和j\([l_j,r_j]\)满足\(l_j≤l_i≤r_i≤r_j\),则删除区间i。

  1. 按左端点递增,右端点递减排序区间。
  2. 记录右端点最大值r。依次遍历排序后的区间,若\(r_i≤r\),则排除区间i。

9.贪心

遇事不决先排序。

贪心的证明

  1. 微扰(邻项交换)
  2. 范围缩放:证明对任何局部最优策略作用范围的扩展都不会造成整体结果变差;
  3. 决策包容性;
  4. 反证法;
  5. 数学归纳法。

9.1.区间问题

9.1.1. 区间选点问题

9.1.1.1. 一个区间至少要包含一个点,求点的最少数量?

  1. 将每个区间按照右端点从小到大进行排序
  2. 从前往后枚举区间,last值初始化为无穷小
    • 如果本次区间不能覆盖掉上次区间的右端点last<a[i].l

      说明需要选择一个新的点ans++,last=a[i].r;

    • 如果本次区间可以覆盖掉上次区间的右端点,则直接continue进行下一轮循环

证明

int n,ans,last=-INF;
struct node{
    int l,r;
    
    bool operator < (const node &t) const{
        return r<t.r;
    }
}a[N];

int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d%d",&a[i].l,&a[i].r);
    
    sort(a+1,a+n+1);
    
    for(int i=1;i<=n;i++){
        if(a[i].l<=last) continue;
        ans++;
        last=a[i].r;
    }
    
    printf("%d\n",ans);
    
    return 0;
}

9.1.1.2. 晒衣服问题

任意区间不会重叠包含(但可能会有公共端点),一个区间至少包含两个夹子,求夹子的最少数量?

  1. 按右端点排序;
  2. 先把公共端点加夹子;
  3. 从左往右扫描,在未满足条件的区间上加夹子。

9.1.2. 区间不相交问题(选课、开会问题)

9.1.2.1.求最大不相交(包括端点)区间数量?

  1. 将每个区间按照右端点从小到大进行排序
  2. 从前往后枚举区间,last值初始化为无穷小
    • 如果本次区间不能覆盖掉上次区间的右端点last<a[i].l

      说明需要选择一个新的点ans++,last=a[i].r;

    • 如果本次区间可以覆盖掉上次区间的右端点,则直接continue进行下一轮循环

  • 证明正解\(ANS<=\)ans

    反证法。
    假设\(ANS>\)ans,则区间至少要用\(ANS\)\(>\)ans)个点才能被完全覆盖,这与《区间选点》矛盾。

int n,ans,last=-INF;
struct node{
    int l,r;
    
    bool operator < (const node &t) const{
        return r<t.r;
    }
}a[N];

int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d%d",&a[i].l,&a[i].r);
    
    sort(a+1,a+n+1);
    
    for(int i=1;i<=n;i++){
        if(a[i].l<=last) continue;
        ans++;
        last=a[i].r;
    }
    
    printf("%d\n",ans);
    
    return 0;
}

9.1.2.2.拓展:选出的区间不能相交(包括端点),求选出的区间总长最大值

《错题本‧动态规划2022.9.7.【线性dp】饥饿的奶牛》

9.1.3. 区间分组问题(开会房间问题)

每组区间不能相互重叠包含,求最小组数?

例1:有若干个活动,第\(i\)个活动开始时间和结束时间是\([si,ei]\),同一个教室安排的活动之间不能重叠包含(包括公共端点),求要安排所有活动,至少需要几个教室?
例2: 畜栏预定

  1. 把所有区间按照左端点从小到大排序;
  2. 从前往后枚举每个区间,判断此区间能否将其放到现有的组中;
    • 如果一个区间的左端点比最小组的右端点要小,a[i].l<=heap.top(), 就开一个新组heap.push(a[i].r);
    • 如果一个区间的左端点比最小组的右端点要大,则放在该组:去除右端点最小的区间,只保留一个右端点较大的区间,这样heap有多少区间,就有多少组heap.pop(), heap.push(a[i].r);
  3. heap有多少区间,就有多少组;
  • 证明正解\(ANS>=\)ans

    a[i].l<=max_r,要给一个新的区间开一个新的第ans组时,由于按左端点排序l<=a[i].la[i].l<=max_r,所以当前ans个区间相互重叠包含,故至少要ans个分组。

int n;
struct node{
    int l,r;
    bool operator < (const node &t) const{
        return l<t.l;
    }
}a[N];
priority_queue<int,vector<int>,greater<int> > heap;

int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d%d",&a[i].l,&a[i].r);
    
    sort(a+1,a+n+1);
    
    for(int i=1;i<=n;i++){
        if(heap.empty() || heap.top()>=a[i].l) heap.push(a[i].r);
        else{
            heap.pop();
            heap.push(a[i].r);
        }
    }
    
    printf("%d\n",heap.size());
    
    return 0;
}

9.1.4. 区间覆盖问题

至少要用多少个区间才能覆盖给定的线段(或给定的区间上的所有点(注意不是边))?

  1. 将所有区间按照左端点从小到大进行排序;
  2. 从前往后枚举每个区间,在所有能覆盖st的区间中,选择右端点的最大区间,然后将st更新成右端点的最大值(如果要求覆盖的是点,则st更新成右端点的最大值+1)。
  • 证明

    正解\(ANS\)可以按照上述操作等价变换成ans

int n,st,ed,ans;
bool success;
struct node{
    int l,r;
    bool operator < (const node &t) const{
        return l<t.l;
    }
}a[N];

int main(){
    scanf("%d%d%d",&st,&ed,&n);
    for(int i=1;i<=n;i++) scanf("%d%d",&a[i].l,&a[i].r);
    
    sort(a+1,a+n+1);
    
    for(int i=1;i<=n;i++){
        int j=i,r=-INF;
        
        //在所有能覆盖st的区间中,选择右端点的最大区间
        while(j<=n && a[j].l<=st){
            r=max(r,a[j].r);
            j++;
        }
        ans++;
        
        //失败
        if(r<st) break;
        
        //成功
        if(r>=ed){
            success=true;
            break;
        }
        
        //更新
        st=r;//如果要求覆盖的是点,则st=r+1;!!!
        i=j-1;  //注意不是j,因为j++之后就跳到新的区间了
    }
    
    if(!success) puts("-1");
    else printf("%d\n",ans);
    
    return 0;
}

9.2.不等式

9.2.1. 排序不等式

让花费时间少的人先打水。

  • 证明

    \(n\)个人打水时间从小到大为\(t_1、t_2、t_3...t_{n-1}、t_n\)

    则他们按花费时间从小到大排序打水总的等待时间\(T = t_1 * (n-1) + t_2 * (n-2) + t_3 * (n-3) + ... + t_{n-1} * 1\)

    微扰(邻项交换) 的方法发现微扰后总的等待时间\(T\)增大,证毕。

int n;
LL ans;
int t[N];

int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d",&t[i]);

    sort(t+1,t+n+1);
    
    for(int i=1;i<=n;i++) ans+=(LL)t[i]*(n-i);
    
    printf("%lld\n",ans);
    
    return 0;
}

9.2.2. 绝对值不等式

利用中位数的性质。

  • 证明

    \(n\)个仓库坐标从小到大为\(x_1、x_2...x_{n-1}、x_n\),货仓坐标为\(x\)

    则货仓到每家商店的距离之和$dis = |x_1-x| + |x_2-x| + ... + |x_{n-1}-x| + |x_n-x| = (|x_1-x| + |x_n-x|) + (|x_2-x| + |x_{n-1}-x|) + ... $

    利用 数学上绝对值的几何意义 ,$dis \geqslant (x_n-x_1) + (x_{n-1}-x_2) + ... \(,当\)x \in [x_1,x_n]、[x_2,x_{n-1}]、...\(时取到等号,即\)x$为中位数,证毕。

写法1:中位数

#include<bits/stdc++.h>
using namespace std;

const int N=1e5+5;
int n,ans;
int x[N];

int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d",&x[i]);
    
    sort(x+1,x+n+1);
    
    for(int i=1;i<=n;i++) ans+=abs(x[i]-x[n/2+1]);  //注意这里的n/2+1才是中位数
    
    printf("%d\n",ans);
    
    return 0;
}

写法2:利用最后推出的公式+双指针算法

#include<bits/stdc++.h>
using namespace std;

const int N=1e5+5;
int n,ans;
int x[N];

int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d",&x[i]);
    
    sort(x+1,x+n+1);
    
    int i=1,j=n;
    while(i<=j){
        ans+=(x[j]-x[i]);
        i++,j--;
    }
    
    printf("%d\n",ans);
    
    return 0;
}

9.2.3. 推公式

#include<bits/stdc++.h>
using namespace std;

typedef pair<int,int> PII;
const int N=5e4+5,INF=2e9;
int n,sum,ans=-INF;
PII cow[N];

int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++){
        int w,s;
        scanf("%d%d",&w,&s);
        cow[i]={w+s,w};
    }
    
    sort(cow+1,cow+n+1);
    
    for(int i=1;i<=n;i++){
        int w=cow[i].second,s=cow[i].first-cow[i].second;
        ans=max(ans,sum-s);
        sum+=w;
    }
    
    printf("%d\n",ans);
    
    return 0;
}
  • 例题2. 防晒

    1. 将所有奶牛按照 minSPF 从大到小 的顺序排序,然后依次考虑每头奶牛;
    2. 对于每头奶牛,扫描当前所有能用的防晒霜,选择 SPF 值最大的防晒霜来用;
    • 证明

      决策包容性 的方法:

      由于所有奶牛按照minSPF从大到小的顺序排序,所以每一个不低于当前奶牛\(minSPF\)值的防晒霜,都不会低于后面其他奶牛的\(minSPF\)值。

      换言之,对于当前奶牛可用任意两瓶防晒霜\(x\)\(y\)(设\(spf[x]<spf[y]\)),后面的奶牛只可能出现“\(x\)\(y\)都能用”、“\(x\)\(y\)都不能用”和“\(x\)能用,\(y\)****不能用”。因此当前奶牛选择\(spf\)比较大的\(y\)对整体的影响比\(x\)好。

#include<bits/stdc++.h>
using namespace std;

const int N=2505;
int n,m,ans;

struct Cow{
    int mi,ma;
    bool operator < (const Cow W) const{
        return mi>W.mi;
    }
}cow[N];

struct SPF{
    int s,c;
    bool operator < (const SPF W) const{
        return s>W.s;
    }
}spf[N];

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&cow[i].mi,&cow[i].ma);
    for(int i=1;i<=m;i++) scanf("%d%d",&spf[i].s,&spf[i].c);

    sort(cow+1,cow+n+1);
    sort(spf+1,spf+m+1);

    for(int i=1;i<=n;i++)
        for(int j=1;j<=m;j++)
            if(cow[i].mi<=spf[j].s && spf[j].s<=cow[i].ma && spf[j].c!=0){
                spf[j].c--;
                ans++;
                break;
            }

    printf("%d\n",ans);

    return 0;
}

9.3.数据特征值

9.3.1.平均数

适用条件:平均分。

定义:\(E(X)=\frac{\sum\limits_{i=1}^{n}x[i]}{n}\)

9.3.1.1.均分纸牌

算平均数ave。求每堆纸牌与平均数的关系(多1记为1,少1记为-1。即牌数-平均数)。当a[i](第i堆纸牌与平均数的关系)不等于0时,令a[i+1]=a[i+1]+a[i](当a[i]<0时从第i+1个牌堆向第i个牌堆移|a[i]|张纸牌,当a[i]>0时从第i个牌堆向第i+1个牌堆移|a[i]|张纸牌),牌堆的移动次数加1,牌的移动数量加|a[i]|。

int n,ave,ans1,ans2;    //ave:平均数;ans1:牌堆的最少移动次数;ans2:牌的最少移动数量
int a[N];

scanf("%d",&n);
for(int i=1;i<=n;i++)
{
    scanf("%d",&a[i]);
    ave+=a[i];
}
ave/=n;
for(int i=1;i<=n;i++) a[i]-=ave;
for(int i=1;i<=n;i++)
{
    if(a[i]==0) continue;
    a[i+1]+=a[i];
    ans1++;
    ans2+=abs(a[i]);
}
printf("%d %d\n",ans1,ans2);

9.3.2.中位数

适用条件:绝对值不等式。

定义:序列X排序后,\(\begin{cases} x_{\frac{n+1}{2}}&n\text{ is odd} \\\frac{x_{\frac{n}{2}}+x_{\frac{n}{2}+1}}{2}&n\text{ is even} \end{cases}\)

9.3.2.1.货仓选址

9.3.2.1.1.货仓选址

见《基础算法9.2.2. 绝对值不等式 》。

9.3.2.1.2.带权货仓选址

\(f(x)=\sum\limits_i b_i|a_i-x|\)

利用《思维题技巧12.绝对值的处理2.》的技巧:\(f(x)=(\sum\limits_{a_i\ge x} b_ia_i-\sum\limits_{a_i<x} b_ia_i)+x(\sum\limits_{a_i\ge x} b_i-\sum\limits_{a_i<x} b_i)\)

对于一个确定的x求\(f(x)\):利用建立在值域上、支持查询前缀和的数据结构,f(x)=总和-2*<x的前缀和。

性质

\(\min f(x)\):若\(b_i≥0\),f(x)是下凸函数且只有最值处有平台,所以可以使用三分求\(\min f(x)\)

例题。

9.3.2.2.环形均分纸牌

求牌的最少移动数量。

平均数与中位数的综合应用。

核心:将式子化为只有一个未知数。

设a[i]:第i个牌堆原有的牌数。ave:所有牌堆的牌数的平均数。

可将向左传和向右传合并:设x[i]:第i个牌堆向左传的牌数-第i-1(当i=1时为n)堆向右传的牌数。

有:a[i]+x[i+1]-x[i]=ave(1≤i<n)。(方程a[n]+x[1]-x[n]=ave无用,因为可被前面的方程线性表示。)

\(c[i]=(\sum\limits_{j=1}^{i} a[j])-i*ave\)。则对上面的式子变形有:x[i]=x[1]-c[i-1](特别地,c[0]=0)。

牌的移动数量ans=|x[1]|+|x[2]|+...+|x[n]|=|x[1]-c[0]|+|x[1]-c[1]|+...+|x[1]-c[n-1]|。由《基础算法9.3.2.1.货仓选址》得当x[1]取序列c[0~n-1]的中位数时,ans有最小值。

n=read();
for(int i=1;i<=n;i++)
{
    a[i]=read();
    ave+=a[i];
}
ave/=n;
c[0]=0;
for(int i=1;i<n;i++) c[i]=c[i-1]+a[i]-ave;
sort(c,c+n);
mid=c[n/2];
for(int i=0;i<n;i++) ans+=abs(mid-c[i]);
printf("%lld\n",ans);

9.3.3.众数

一般定义的众数不满足区间可加性和结合律。

9.3.3.1.摩尔投票法求绝对众数

前提条件:绝对众数:保证众数的个数严格大于总数的一半。

绝对众数满足区间可加性。

9.3.3.1.1.静态求全局绝对众数

时间复杂度:\(O(N)\)。空间复杂度:\(O(1)\)

记录2个变量:major,cnt:当前摩尔投票的结果和计数器。初始cnt=0。当cnt==0时,当前摩尔投票没有结果。

依次读入x和y,表示给定了y个x。若当前major==x,令cnt+=y;若当前major≠x,比较cnt和y的大小,若cnt≥y,令major不变,cnt-=y,否则更新当前摩尔投票的结果,令major=x,cnt=y-cnt。扫描完后major就是答案。

理解:现在有2伙人(是绝对众数的一伙,不是众数的另一伙)打架,每个人的战斗力都是1换1,由于保证前提条件“绝对众数的个数严格大于总数的一半”,所以摩尔投票法最后剩下的数一定是绝对众数。

int major,cnt;
for(int i=1;i<=m;i++)
{
    int x,y;
    scanf("%d%d",&x,&y);
    if(major==x) cnt+=y;
    else
    {
        if(cnt>=y) cnt-=y;
        else major=x,cnt=y-cnt;
    }
}
printf("%d\n",major);

9.3.3.1.2.静态求区间绝对众数

主席树的节点维护其对应的值域上的数的出现次数sum,版本维护区间。

  • 对于一个询问[l,r],若当前区间的左儿子的版本r减版本l的sum的2倍大于r-l+1,说明可能的答案只可能在左儿子。
  • 否则若当前区间的右儿子的版本r减版本l的sum的2倍大于r-l+1,说明可能的答案只可能在右儿子。
  • 否则[l,r]不存在绝对众数。

代码链接。

9.3.3.1.3.动态求全局绝对众数

摩尔投票法使得众数满足区间可加性。因此建立一棵权值线段树,线段树节点记录siz,major,cnt表示区间所有数的个数、当前区间摩尔投票的结果和计数器,根据摩尔投票的思想很容易写出pushup:

void pushup(int u)
{
    if(!u) return;
    tr[u].siz=tr[tr[u].lson].siz+tr[tr[u].rson].siz;
    if(tr[tr[u].lson].major==tr[tr[u].rson].major) tr[u].major=tr[tr[u].lson].major,tr[u].cnt=tr[tr[u].lson].cnt+tr[tr[u].rson].cnt;
    else if(tr[tr[u].lson].cnt>=tr[tr[u].rson].cnt) tr[u].major=tr[tr[u].lson].major,tr[u].cnt=tr[tr[u].lson].cnt-tr[tr[u].rson].cnt;
    else tr[u].major=tr[tr[u].rson].major,tr[u].cnt=tr[tr[u].rson].cnt-tr[tr[u].lson].cnt;
    return ;
}

如果有多棵权值线段树,则继续把它们根节点的摩尔投票结果再进行摩尔投票得到最后1个结果。

最后还要检验最终的那1个结果是否是绝对众数:每棵权值线段树都统计出最终的那1个结果的出现个数和所有数的个数,判断是否最终的那1个结果的个数严格大于所有数的一半。

9.3.3.1.4.动态求区间绝对众数

《基础算法9.3.3.1.2.动态求全局绝对众数》中的权值线段树(叶子节点i储存数值i的出现次数)替换成普通线段树(叶子节点i储存位置i上的数值)。对于动态查询区间内数值maj的出现次数,对于每一个数值maj开一个平衡树储存数值是maj的位置,在数值maj的平衡树查询位置小于等于r和小于等于l-1的大小,相减就可以了。

代码链接。

9.3.4.方差

  • \(D(X)=\frac{\sum\limits_{i=1}^n(x_i-\bar{x})^2}{n}=\frac{\sum\limits_{i=1}^nx_i^2}{n}-\bar{x}^2=E(X^2)-E^2(X)\)
  • 直观理解:数据的离散程度。数据越分散(集中),方差越大(小)。
  • 设S,T是两个(可重)集合,D(S)≤D(T),则D(S∪T)≥D(S)。

9.4.邻项交换

前提条件:交换前的答案与交换后的答案列出的不等式化简后不含除i、i+1其他的项。

适用条件:题目需要一个最优的序列顺序,因此需要排序的关键字。

假设得到了最优的排序方式\(a_1,a_2,...,a_n\),交换相邻的两项i和i+1,将交换前的答案与交换后的答案列一个不等式\(calc(a_i,a_{i+1})<calc(a_{i+1},a_i)\),化简整理后会得到\(f(a_i)<f(a_{i+1})\)。于是我们就得到了排序的关键字

  • 正确性

    根据冒泡排序的知识,任何一个序列都能通过邻项交换的方式变成有序序列。

注意

9.5.反悔贪心思想

前提条件:1.反悔涉及的决策不能过多,保证复杂度;2.花费只与当前决策有关。

适用条件:有时候在满足题目约束条件(\(e.g.\)物品有使用期限,或是需要选择k个物品)和达成最优解的时候会产生矛盾,但是不管题目条件,一味地追求达成最优解是很容易做到的。

图论上的反悔贪心:网络流。

  1. 思考选择一个决策会导致哪些决策不能被选择,如何在遍历到那些不能被选择的决策时用堆判断是否反悔之前的决策,如何反悔之前的决策。
  2. 用堆维护当前最优决策和反悔决策。(可能合并到一个就够了。也可能对于\(b_j-a_i\)的式子需要将式子拆开成\(b_j\)\(-a_i\)用两个堆维护,这样就可以把多个反悔决策缩小到一个反悔决策)
  3. 分题型:
    • 若题目对选择物品的总数无限制,且是一元条件:

      依次遍历排序后的决策。无论当前的最优决策是否全局最优都接受。如果不能接受当前的最优决策,将该决策与之前选过的决策(用堆维护)进行比较。

      例题:Luogu P2949 [USACO09OPEN]Work Scheduling G

    • 若题目要求选k个物品:

      1. for(int i=1;i≤k;i++):以当前选了i个物品为阶段。

      2. 比较当前是直接选择还是反悔。有时两者可以合并到一起。

        反悔时,因为当前是以选了i个物品为阶段,所以要保证i=选择物品总数。也就是相对i-1要有增量。\(e.g.\)反悔选之前的1个物品则还要同时再选2个物品(注意反悔之后决策的范围有变动),相当于之前和现在的决策分别选择这2个物品。

      3. 加入贡献。

      4. 选择了一个决策,会导致其他决策不能被选择,这时要把相关决策全部移出,把反悔费用累加加入到堆中。

        移动相关决策状态时,若物品的决策状态多(\(e.g.\)不选/选1个/选2个当前的物品),则可以考虑写一个函数insert_x(id):将第id个物品的决策状态纳入状态x;erase_x(id):将第id个物品的决策状态移出状态x。

      例题1:Luogu P3620 [APIO/CTSC2007] 数据备份

      例题2:CF436E Cardboard Box

9.6.\(Huffman\)

《数据结构4.2.\(Huffman\)树》

数据结构·序列

10.高精度

高精度封装。

一般用a表示低精度或字符串,A表示高精度。

算法流程模拟笔算。

两个高精度运算要比较大小避免特判。

10.1.可以不用高精度的情况

10.1.1.有模数的情况

适用条件:模数在long long范围内。对输入的数据取模答案没有影响。

秦九韶算法。

int n,len;
char s[N];

scanf("%s",s+1);
len=strlen(s+1);
for(int i=1;i<=len;i++) n=(n*10+s[i]-'0')%MOD;

10.1.2.__int128

适用条件:数值的绝对值小于\(2^{128}-1\)

注意

  • __int128不可以使用cin、cout、scanf、printf等输入输出。

    读入和输出:使用把int改成__int128的快读和快写的模板。

    《生活小妙招 快读和快写》

  • __int128 res=(__int128)12345678901234567890123456789;仍然会溢出。(因为该代码的执行步骤是将long long类型的12345678901234567890123456789(此时已经溢出)转化为__int128)

  • __int128 res=1234567890123456789*1234567890132456789;会溢出。

    应改为__int128 res=(__int128)1234567890123456789*1234567890132456789;

10.2.高精度的读入和输出

//读入
char a[N];
int lena;
vector<int> A;
scanf("%s",a+1);
lena=strlen(a+1);
for(int i=lena;i>=1;i--) A.push_back(a[i]-'0');

//输出
for(int i=A.size()-1;i>=0;i--) printf("%d",A[i]);

10.3.高精度的比较

//判断A是否大于B
bool cmp(vector<int> &A,vector<int> &B)
{
    if(A.size()!=B.size()) return A.size()>B.size();
    for(int i=A.size()-1;i>=0;i--) if(A[i]!=B[i]) return A[i]>B[i];
    return false;    //注意这里要返回false,严格弱序化
}

10.4.高精度加法

10.4.1.高精度加法(不压位)

vector<int> add(vector<int> &A,vector<int> &B)//加引用效率会高一些
{
    if(A.size()<B.size()) return add(B,A);//要比较大小
    int t=0;//进位
    vector<int> C;
    for(int i=0;i<A.size();i++)
    {
        t+=A[i];
        if(i<B.size()) t+=B[i];
        C.push_back(t%10);
        t/=10;
    }
    if(t!=0) C.push_back(t);
    return C;
}

vector<int> A,B,C;
C=add(A,B);

10.5.高精度减法

vector<int> sub(vector<int> &A,vector<int> &B)
{
    if(cmp(B,A))//注意cmp是<。如果改为!cmp(A,B)则当A=B会输出-0
    {
        putchar('-');
        return sub(B,A);
    }
    int t=0;    //借位
    vector<int> C;
    for(int i=0;i<A.size();i++)
    {
        t=A[i]-t;
        if(i<B.size()) t-=B[i];
        C.push_back((t%10+10)%10);
        if(t<0) t=1;
        else t=0;
    }
    while(C.size()>1 && C.back()==0) C.pop_back();  //删除前导零,注意答案为0时要保留一个0
    return C;
}

vector<int> A,B,C;
C=sub(A,B);

10.5.高精度乘法

10.5.1.高精乘低精(不压位)

vector<int> mul(vector<int> &A,LL b)//不要忘记开long long
{
    LL t=0;    //进位
    vector<int> C;
    for(int i=0;i<A.size() || t!=0;i++)
    {
        if(i<A.size()) t+=A[i]*b;//不开long long这里会爆!!!
        C.push_back(t%10);
        t/=10;
    }
    while(C.size()>1 && C.back()==0) C.pop_back();  //删除前导零,例如A*0,注意答案为0时要保留一个0
    return C;
}

vector<int> A,C;
LL b;
C=mul(A,b);

10.5.2.高精乘高精:FFT\(O(N\log N)\)

《数学..1.1.FFT求高精度乘法\(O(N\log N)\)

10.6.高精度除法

10.6.1.高精除低精

输出商和余数。

vector<int> divi(vector<int> &A,LL b,LL &r)//不要忘记开long long
{
    r=0;    //余数
    vector<int> C;
    for(int i=A.size()-1;i>=0;i--)  //,注意为了少用reverse节省时间,从高位到低位
    {
        r=r*10+A[i];//不开long long这里会爆!!!
        C.push_back(r/b);
        r%=b;
    }
    reverse(C.begin(),C.end()); //注意记得翻转
    while(C.size()>1 && C.back()==0) C.pop_back();  //删除前导零,例如A*0,注意答案为0时要保留一个0
    return C;
}

vector<int> A,C;
LL b,r;

10.7.高精度短除法

《基础算法11.1.3.\(()_x→()_y\)

11.进位制

11.1.进制转化

11.1.1.\(()_x→()_{10}\)

秦九韶算法:\(e.g.\)\((615023)_7→()_{10}\):((((67+1)7+5)7+0)7+2)*7+3

11.1.2.\(()_{10}→()_x\)

短除法:\(a_i\%x\)\(\lfloor \dfrac{a_i}{x} \rfloor\)

11.1.3.\(()_x→()_y\)

方法一(更常用)

短除法:\(a_i\%y\)\(\lfloor \dfrac{a_i}{y} \rfloor\)。注意:除法借位时应该加x而不是10!!!

由于除法借位的问题,我们用高精度短除法实现。

vector<int> divi(vector<int> &C,int a,int b)
{
    vector<int> Res;
    while(C.size())
    {
        int r=0;    //余数
        for(int i=C.size()-1;i>=0;i--)
        {
            C[i]+=a*r;
            r=C[i]%b;
            C[i]/=b;
        }
        Res.push_back(r);
        while(!C.empty()/*注意这里不是size>1*/ && C.back()==0) C.pop_back();//可以在稿纸上模拟一下,此处不需要翻转
    }
    return Res; //可以在稿纸上模拟一下,此处不需要翻转
}

int main()
{
    char c[N];
    int lenc;
    vector<int> C,Res;
    scanf("%s",c+1);
    lenc=strlen(c+1);
    for(int i=lenc;i>=1;i--) C.push_back(c[i]-'0');
    int a,b;
    scanf("%d%d",&a,&b);
    Res=divi(C,a,b);
    for(int i=Res.size()-1;i>=0;i--) printf("%d",Res[i]);
    return 0;
}

方法二

\(()_x→()_{10}→()_y\)

12.启发式合并

适用条件:\(\sum=N\),无论合并的信息多么复杂。

时间复杂度:\(O(x*N \log N)\),其中x是合并一次的复杂度。

12.1.启发式合并

适用条件:初始多个集合,依次合并2个集合合并至最终1个集合。(不会把集合分裂,否则不能保证复杂度)

每次合并集合,操作小集合合并到大集合。

12.2.树上启发式合并

当一个子树的信息是数组级别,且要向上传递到父节点时(例如求出每个子树的最多节点颜色)。可以只使用一个数组,先递归求解“轻儿子”,回溯时清空数据。再递归求解“重儿子”并保留数据。然后再递归除“重儿子”以外的节点,把他们的信息加入这份数据,就得到了这颗子树的数据。

不能高效换根。

以求出每个子树的最多节点颜色为例:

int siz[N],son[N];  //son[u]:节点u的重儿子
int ma,color[N],cnt[N];LL sum;  //color[i]:节点i的颜色;cnt[i]、ma、sum:颜色统计,除重儿子外,回溯时将被清空
LL ans[N];

int dfs_son(int u,int fa)//求出每棵子树的"重儿子"
{
    siz[u]=1;
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==fa) continue;
        siz[u]+=dfs_son(v,u);
        if(siz[v]>siz[son[u]]) son[u]=v;
    }
    return siz[u];
}

void update(int u,int fa,int sign,int pson)//sign==1:二次递归求解;-1:数据清空器
{
    int c=color[u];
    cnt[c]+=sign;//二次递归求解或数据清空体现在这里
    if(cnt[c]>ma) ma=cnt[c],sum=c;
    else if(cnt[c]==ma) sum+=c;
    
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==fa || v==pson) continue;  //重儿子已经统计过了。注意是pson而不是son[u]
        update(v,u,sign,pson);
    }
    
    return ;
}

void dfs(int u,int fa,bool is_son)//is_son==true表示u是fa的重儿子,回溯时不清空数据
{
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==fa || v==son[u]) continue;//先求解“轻儿子”
        dfs(v,u,false);
    }
    if(son[u]) dfs(son[u],u,true);//求解“重儿子”并储存数据
    update(u,fa,1,son[u]);
    
    ans[u]=sum;
    
    if(!is_son) update(u,fa,-1,0),ma=sum=0;//“轻儿子”清空数据
    
    return ;
}

13.随机化算法

13.1.模拟退火和爬山法

《搜索5.模拟退火》

《搜索6.爬山法》

13.2.随机化dp

《动态规划7.5.随机化dp》

13.3.基于概率的随机化算法

题目的某个性质或做法虽然没有得到证明,但是通过计算发现其正确率可高达99%以上。

模型一

特征:答案集合占总集合的比例较大,现需要选出x(x较小)个在答案集合里的元素。

计算随机选出x个元素均在答案集合里的概率,再计算多次随机选出均失败的概率,发现极小。

多次随机选出x个元素,验证是否均在答案集合或求最值。

14.构造

一般答案不唯一,但是可以通过某种规律找到一种答案。可能需要用到贪心,也可能是借助dp输出方案来构造方案。

很多构造题都是思维题:

思维题

14.1.严格不等\(\Leftrightarrow\)非严格不等

给定一个整数序列 a1,a2,⋅⋅⋅,an。

请你求出一个递增序列 b1<b2<⋅⋅⋅<bn,使得|a1−b1|+|a2−b2|+⋅⋅⋅+|an−bn| 最小。

  1. 严格不等→非严格不等
    令$a_i $→ \(a_{i}'\)\(a_i'=a_i-i\)),$b_i $→ \(b_{i}'\)\(b_i'=b_i-i\))。

    此时只需求出b1'≤b2'≤...≤bn',且|a1−b1|+|a2−b2|+⋅⋅⋅+|an−bn| =|a1'−b1'|+|a2'−b2'|+⋅⋅⋅+|an'−bn'| 。

  2. 非严格不等→严格不等

    令上面的-i改成+i即可。

  • .2.构造奇数价幻方

    首先将 1 写在第一行的中间。

    之后,按如下方式从小到大依次填写每个数 K(K=2,3,…,N×N):

    1. 若 (K−1) 在第一行但不在最后一列,则将 K 填在最后一行,(K−1) 所在列的右一列; 
    2. 若 (K−1) 在最后一列但不在第一行,则将 K 填在第一列,(K−1) 所在行的上一行; 
    3. 若 (K−1) 在第一行最后一列,则将 K 填在 (K−1) 的正下方; 
    4. 若 (K−1) 既不在第一行,也不在最后一列,如果 (K−1) 的右上方还未填数,则将 K 填在 (K−1) 的右上方,否则将 K 填在 (K−1) 的正下方。

#include<bits/stdc++.h>
using namespace std;

const int N=40;
int n,x,y;
int a[N][N];

int main()
{
    scanf("%d",&n);
    int x=1,y=n/2+1;
    for(int i=1;i<=n*n;i++)
    {
        a[x][y]=i;
        if(x==1 && y==n) x++;
        else if(x==1) x=n,y++;
        else if(y==n) x--,y=1;
        else if(a[x-1][y+1]!=0) x++;
        else x--,y++;
    }
    
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=n;j++) printf("%d ",a[i][j]);
        puts("");
    }
    
    return 0;
}

14.2.抽屉原理

适用条件:遇到n/k这样的操作次数。

将所有的元素分成k个集合。这样根据抽屉原理,最小的集合的大小≤n/k,最大的集合的大小≥n/k。

有3个方向把所有的元素分类:把所有的元素分类后,

  1. 选择其中任意一个集合。

    要求分类后所有集合全都满足要求。

  2. 只对一个集合内所有的元素操作。

    要求每个集合都完全覆盖。

  3. 给每个集合定一个属性,把每个集合内的所有元素都改成其所在集合的属性。

    要求改完之后满足题目条件。

14.3.归纳法

适用条件:1.操作的执行有顺序;2.在“判断是否有解,有解输出方案”中的题目可能会用到归纳法。

有的题目可能要先找到充要条件,有的可能要先执行步骤2后才能发现充要条件。有的题目可能要通过充要条件判断是否有解,有的可能要在步骤2无法执行时判断无解。

  1. 找到有解的充要条件。它既可以有时判断原问题是否有解,又可以辅助思考solve(n)如何转化为solve(n-1)。
  2. 将原问题solve(n)转化为规模减一的一定有解的子问题solve(n-1)。既是归纳的证明,又是构造的方案。可能的归纳方向:
    1. 假设已经构造了solve(n-1)的方案,现在把第n个元素加入。
    2. 逆向思考:先处理第n个元素,使得solve(n-1)一定有解并递归处理solve(n-1)。
  3. 有的时候还需要配合:
    1. 增减法:分类讨论第n个元素的权值大小,决定如何处理第n个元素,得到总权值的变化,证明solve(n-1)满足充要条件。例题。
    2. 反证法:大胆猜测结论或特殊性质,通过反证法证明结论或特殊性质。例子。

14.4.dfs树

适用条件:无向连通图的题目中,数据范围给了树的特殊分,有时可以往“树的做法+返祖边→无向连通图的做法”的方向想。

dfs树的性质:非树边只有返祖边。

14.5.“两个任务一定可以完成其中一个任务”

适用条件:给定两个任务,一般任务都是要求找到由限制+本体组成的物体,选择其中任意一个任务完成,保证两个任务一定可以完成其中一个任务。

  1. 先考虑其中较为简单的任务。先不管限制找到本体,若找到的本体本来就满足限制,直接输出。
  2. 若找到的本体不满足限制,写出此时的不等式。在满足该不等式的情况下构造完成另一个任务的方案。

14.6.转化为图论

没有特定的适用条件。原题目可能与图论无关,但是可以利用特殊图的性质转化为图论的等价条件,从而构造方案。

  1. 环。

    可以此构造置换p[i],然后可列出等式\(\sum i=\sum p[i]\)

  2. 邻接矩阵。

    将只含0,1的矩阵乘法转化为bool的邻接矩阵。

    https://www.luogu.com.cn/problem/T268089

  3. 二分图

    二分图染色可以解决2选1问题。二分图本身还有建模应用。

  4. 2-sat

14.7.汉诺塔问题

核心:先移动除最大塔以外的其他塔,再移动最大塔,然后移动其他塔→先考虑最大塔,然后把问题递归为考虑除最大塔以外的其他塔的子问题。

14.7.1.求3柱汉诺塔从初始状态转移到目标状态的最小步数

结论:串aaa的长度为n,从aaa转移到bbb的最小步数\(=2^n-1\)

性质:基于3进制的性质,考虑第一步的决策后,后面可能的最优决策是唯一确定的。

先考虑最大塔的决策,从xxxa→xxxb(则第三柱c=6-a-b,x表示任意柱)有2种路线

  1. 从xxxa花费未知费用走到bbba,从bbba花费1费用走到bbbc,从bbbc花费\(2^{n-1}-1\)费用走到aaac,从aaac花费1费用走到aaab,从aaab花费未知费用走到xxxb。
  2. 从xxxa花费未知费用走到ccca,从ccca花费1费用走到cccb,从cccb花费未知费用走到xxxb。

现在的问题变成了如何求出从xxx走到aaa/bbb/ccc的费用(从aaa/bbb/ccc走到xxx的费用=从xxx走到aaa/bbb/ccc的费用)。由于最终目标是同一柱,所以可能的最优决策是唯一确定的:以求从xxa走到bbb的费用,xxa的长度是i为例:从xxa花费未知费用走到cca,从cca花费1费用走到ccb,从ccb花费\(2^{i-1}-1\)费用走到bbb。现在的子问题变成了求xx走到cc的费用,直接递归求解即可。

例题。

//原题要写压位高精。这里因为只突出汉诺塔的思想,所以没写压位高精
int n;
int a[N],b[N];
i128 ans1,ans2;

//路线1
void solve1()
{
    ans1+=1;    //从bbba花费1费用走到bbbc
    ans1+=(i128(1)<<(n-1))-1;   //从bbbc花费2^{n-1}-1费用走到aaac
    ans1+=1;    //从aaac花费1费用走到aaab
    
    /*
    从xxxa花费未知费用走到bbba、从aaab花费未知费用走到xxxb
    子问题都是求出从xxx走到aaa/bbb/ccc的费用(从aaa/bbb/ccc走到xxx的费用=从xxx走到aaa/bbb/ccc的费用)
    下面用迭代写法代替递归写法,实际上迭代写法也很可读
    */
    int c1=b[n],c2=a[n];    //当前子问题的最大塔的目标柱
    for(int i=n-1;i>=1;i--)
    {
        if(a[i]!=c1)    //如果初始就在目标柱的话就不用移动了
        {
            ans1+=1;    //从cca花费1费用走到ccb
            ans1+=(i128(1)<<(i-1))-1;   //从ccb花费2^{i-1}-1费用走到bbb
            c1=6-a[i]-c1;   //从xxa花费未知费用走到cca。下一轮的子问题变成了求xx走到cc的费用
        }
        if(b[i]!=c2)
        {
            ans1+=1;
            ans1+=(i128(1)<<(i-1))-1;
            c2=6-b[i]-c2;
        }
    }
    
    return ;
}

//路线2
void solve2()
{
    ans2+=1;    //从ccca花费1费用走到cccb
    
    //从xxxa花费未知费用走到ccca、从cccb花费未知费用走到xxxb
    int c1=6-a[n]-b[n],c2=6-a[n]-b[n];
    for(int i=n-1;i>=1;i--)
    {
        if(a[i]!=c1)
        {
            ans2+=1;
            ans2+=(i128(1)<<(i-1))-1;
            c1=6-a[i]-c1;
        }
        if(b[i]!=c2)
        {
            ans2+=1;
            ans2+=(i128(1)<<(i-1))-1;
            c2=6-b[i]-c2;
        }
    }
    
    return ;
}

scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1;i<=n;i++) scanf("%d",&b[i]);
while(n>=1 && a[n]==b[n]) n--;  //如果初始就在目标柱的话就不用移动了
if(n==0)
{
    puts("0");
    return 0;
}
solve1();
solve2();
write(min(ans1,ans2));

15.打表

Copied from IceAge

适用于打表的情况:

  • 输入数据情况很少(全打表)

  • 代码能处理大部分数据,但有些情况处理不了,对于处理不了的部分打表(部分打表)

  • 配合打表:某些预处理难写、容易TLE,但是容易手算,而且是固定的,我们会考虑把这些打表进去,然后再配合自己的正解。

    e.g.列数很少的插头dp问题,可以手动枚举一行的插头有哪些状态、哪些状态可以转移到哪些状态并存入邻接矩阵,然后直接利用邻接矩阵进行状态转移。(注意考虑清楚初始状态和结束状态

打表的注意问题:

  • 趁早做打表题,做的越早,你能暴搜到的答案可能就更多。

  • 注意压缩代码长度,代码过长交不上去的。

    \(e.g.\)《基础算法15.1.分块打表》

15.1.分块打表

当题目是一个序列问题时,可以把分块的思想和打表结合起来,打表求出一整块内的答案,对于零散部分再单独暴力求,这样就可以减少代码的长度(代码的长度从\(O(N)\)变成\(O(\sqrt N)\))了。

16.交互题

除特殊说明外,不用快读。

16.1.传统题在交互题上的应用

16.1.1.抽屉原理

16.1.2.博弈论

适用条件:给定初始局面,自选先后手,与系统交互,构造交互方案使得自己胜利。

先把该题看作传统博弈论题:判断先后手胜利条件,并证明。

根据初始局面与先后手胜利条件来选择先后手,根据先后手胜利条件的证明来构造交互方案。

16.1.3.动态规划

原本只是一道dp题,交互方案是该dp转移中的决策最优点就变成了一道交互题。

参见《基础算法·16.2.最优化操作次数》

16.2.最优化操作次数

适用条件:1.任务是猜集合中的一个元素,通过询问缩小答案候选集合;2.交互库是自适应的;3.最优化操作次数。

构造的重心在于构造询问方案。

维护集合的所有子集,对于每一个子集,求出它的最小询问次数及询问方案。求解的方法是dp。

  1. \(f[state]\):当前状态state的最坏情况下的最少询问次数。

    有时最小询问次数只与集合大小有关,此时dp只需记录集合大小。

    根据对当前状态state所有可能最优的询问设计dp的转移。

    先dp求出所有的f[state],同时还要记录转移到f[state]的最优决策(对于每一种询问得到的答案的得到的新集合的询问次数最大值最小)。

  2. 开始交互。设当前状态为state。转移到f[state]的最优决策即为当前最优询问的内容。根据询问得到的答案转移到新的状态。每一轮答案候选集合都会缩小。直到答案候选集合大小为1直接输出该集合的那一个元素。

    如果允许猜k次,则当答案候选集合大小≤k时直接猜。

例题。

16.2.1.交互器说谎题

适用条件:在给定的集合中猜交互库选择了哪个元素,可以输出一个子集询问那个元素是否在该子集中,“只保证交互器对任意两次连续询问的回复中至少有一次回复是真的。”

如果把关注点放在分别根据回答yes还是no判断它是否说谎,可以证明这样做无解。

因此需要构造一种方案,使得无论它回答yes/no都可以使用同一种策略缩小答案候选集合。

核心:排除法

一定可以排除元素x\(\Leftrightarrow\)连续2次回答元素x“不符”。“不符”:x在询问的子集中,回答no;或x不在询问的子集中,回答yes。

最优化交互次数:设计dp

\(f_{i,j}\):当前有i个连续0次“不符”的元素(设为集合\(S_0\)),j个连续1次“不符”的元素(设为集合\(T_0\)),最坏情况下的最少询问次数。

连续2次“不符”的元素已经被排除了。因此答案的候选集合\(=S_0+T_0\)

\(f_{i,j}=\min\limits_{k=0}^i\min\limits_{l=0}^j\max\{f_{k+l,i-k},f_{(i-k)+(j-l),k}\}+1\)。k:从\(S_0\)中挑k个(设该子集为集合\(S_1\),设\(S_1\)补集\(S_2\))放入即将询问的子集中;l:从\(T_0\)中挑l个(设该子集为集合\(T_1\),设\(T_1\)的补集为\(T_2\))放入即将询问的子集中;\(f_{k+l,i-k}\):交互库回答yes,\(S_2,T_2\)“不符”;\(f_{(i-k)+(j-l),k}\):交互库回答no,\(S_1,T_1\)“不符”。min:你在决定怎么问。max:交互器在决定怎么回答。

转移:i+j为第一关键字从小到大(因为询问一次后答案的候选集合将减小),j为第二关键字从大到小(转移有后效性,但是当i+j相等时,j越大状态越优。因为\(j=|T_0|\)越大,一次询问后排除的\(|T_1|\)\(|T_2|\)越多)。

边界:当i+j≤1时,\(f_{i,j}=0\)。答案的候选集合里只剩下1个元素了,直接猜。

答案:\(f_{n,0}\)

性质:i+j>20时打表发现决策k=i/2,l=j/2比最优决策差不了多少。

根据dp转移的最优策略决定实际策略

初始把所有元素放入\(S_0\)\(T_0\)为空(\(f_{n,0}\))。

设当前的状态是\(f_{i,j}\),转移到其的最优决策是k,l。则从\(S_0,T_0\)中随意挑\(k,l\)个元素放入\(S_{1},T_{1}\),剩下的元素放入\(S_2,T_2\)。询问\(S_1+T_1\)

若交互器回答yes,把\(S_1+T_1,S_2\)放入下一轮的集合\(S'_0,T'_0\),转移到\(f_{k+l,i-k}\);否则,把\(S_2+T_2,S_1\)放入下一轮的集合\(S'_0,T'_0\),转移到\(f_{(i-k)+(j-l),k}\)。进入下一轮循环,直到i+j≤1时跳出循环。

例题。

16.3.二进制分组

适用条件:1.可询问次数q较小;2.每次可询问n个元素的一个子集,交互器将返回该子集内所有元素的信息的综合;3.任务是确定该n个元素,\(n≤C_{q}^{\lfloor\frac{q}{2}\rfloor}\);4.信息满足可重性和结合律(\(e.g.\)|、max/min、连通性……)。

  1. 给n个元素分配不同的长度为询问次数q且含有\(\lfloor\frac{q}{2}\rfloor\)个1的01串。

  2. 元素x的01串第k位为0表示x不在第k次询问的子集中,反之则在。开始进行q次询问。

  3. q次询问结束后,在排除所有询问子集包含元素x的询问后,剩下的询问得到的信息综合起来,即为除元素x以外其他所有元素的信息的综合。

    证明:由于第1步构造的二进制分组,在排除所有询问子集包含元素x的询问后,其他任意一个元素y仍然一定会被至少一个询问子集所包含。

q次询问至多能确定\(C_{q}^{\lfloor\frac{q}{2}\rfloor}\)个元素。

例题。

16.4.随机化

利用随机化降低询问次数的复杂度。

题型1:随机询问使数据随机

适用条件:类似于猜测排列。

对于形如? u v的询问,每次随机选u,v,这就相当于要猜测的排列是随机的,询问次数变为期望的了。

题型2

适用条件:询问次数q略小于n且1次询问至少能确定1个元素。

思考如何1次询问尽可能地直接确定2个元素。

题型3

适用条件:任务是通过询问? i j交互器返回\(a_i\oplus a_j\)来猜出整个长度为n的序列,序列满足\(\forall x\in[l,l+n-1],\exist a_i=x\)。询问次数q=n+c。

  1. 花费q-n次询问+随机化,找到一个关键数key,满足通过一次询问? key i就能直接求出\(a_i\)

    找大质数:多次随机2个位置询问他们的lcm,找到所有返回值中的最大质因子。

    找重儿子:在子树u内随机1个点v,若v在u的儿子son的子树内,则son很大概率为u的重儿子。即使不是,son的子树大小也与重儿子的子树相近,询问次数复杂度仍然能保证。

  2. 花费n-1次询问? key i求出整个序列。

题型4

适用条件:有2种询问,且2种询问得到的信息量一致且近乎等价。交互库不是自适应的。询问次数q略小于n。

每次随机一种询问。一定要充分利用好得到的信息。

题型5

有时需要结合其他算法。

\(e.g.\)通过询问? u交互器返回子树u内的所有节点来猜出整棵树的形态,此时需要结合树上启发式合并,只需询问? u的轻儿子

17.通信题

  1. 充分利用信息量

    适用条件:所有可能的A的信息的总数<<A→B所需要的信息量。

    事先约定把所有可能的A的信息一一映射到唯一的编码。在A、B两个源程序里都预处理所有可能的A的信息唯一对应的编码。A→B直接传输编码。

    \(O(信息量)\),注意该做法无法继续优化。

  2. 不要把A→B/B→A局限于直接需要传递的信息。脑洞大开,有时B→A可以根据B的情况告诉A下一步怎么行动。

    \(e.g.\)要使得两个源程序的随机种子一样,可以A→B告诉B源程序A的随机种子是什么。

posted @ 2025-10-14 00:50  Brilliance_Z  阅读(16)  评论(0)    收藏  举报