浅谈区间众数
区间众数问题
区间众数问题一般是指给定一个序列,每次询问 \([l,r]\) 区间的众数是几的问题。
当然了,带修改的区间众数问题比较难搞,这里不展开讨论,只研究静态的区间众数问题。
众数并不满足区间“可加性”,这导致它让全部基于二分的数据结构直接 gg (比如线段树、树状数组等),所以大部分研究区间众数的算法都是基于分块。
目前我知道的最优秀的求解区间众数的算法是数据结构带师 lxl 在 Ynoi 毒瘤模拟赛给出的 \(O(n^{1.485})\) 的在线算法。不过我是不会,今天只介绍一个 \(O(n^{1.5})\) 的离线做法和以及一个 \(O(n^{\frac 5 3})\) 的在线做法。
直接结合例题分析吧。
T1 faebdc 的烦恼
题目链接:Link
题目描述:
给定一个长度为 \(N\) 的序列,有 \(q\) 次询问,每次询问一个区间 \([l,r]\) 的众数出现的次数。
Solution:
这题比区间众数问题简化了一点,我们只需要求出众数出现的次数就行了,减少了一些麻烦。
看到“众数”直接考虑分块就行了。这题不强制在线,我选择了离线的莫队算法。发现向答案区间添加一个数实现比较简单,可以顺便更新众数出现次数。而删除操作比较操蛋,如果我们正好删除了区间的众数之一,可能导致众数改变,而我们在不扫描值域的情况下,不能得知新的众数出现次数是多少。
emmmm... 这不就是裸的回滚莫队吗?
回滚莫队我之前讲过,这是模板题所以不再详细注释代码了,想看详细注释的朋友移步这里。
Code:
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<iostream>
//using namespace std;
//Rool Back CaptianMo's Algorithm
#define int long long
const int maxn=200005;
template <typename _T>
inline _T const& read(_T &x){
  x=0;int f=1;
  char ch=getchar();
  while(!isdigit(ch)){
    if(ch=='-')
      f=-1;
    ch=getchar();
  }
  while(isdigit(ch)){
    x=(x<<3)+(x<<1)+ch-'0';
    ch=getchar();
  }
  return x*=f;
}
int n,q,len,tot;
int A[maxn],B[maxn];
int bel[maxn],L[maxn],R[maxn];
struct Node{
  int l,r,org;
};
struct Node query[maxn];
inline bool operator < (const Node a,const Node b){
  return bel[a.l]!=bel[b.l]?bel[a.l]<bel[b.l]:a.r<b.r;
}
void Init(){
  read(n),read(q);
  len=(int)std::sqrt(n);
  tot=n/len;
  for(int i=1;i<=tot;++i){
    if(i*len>n) break;
    L[i]=(i-1)*len+1;
    R[i]=i*len;
  }
  if(R[tot]<n)
    tot++,L[tot]=R[tot-1]+1,R[tot]=n;
  for(int i=1;i<=n;++i){
    bel[i]=(i-1)/len+1;
    B[i]=read(A[i]);
  }
  std::sort(B+1,B+1+n);
  int m=std::unique(B+1,B+1+n)-B-1;
  for(int i=1;i<=n;++i)
    A[i]=std::lower_bound(B+1,B+m+1,A[i])-B;
  for(int i=1;i<=q;++i)
    read(query[i].l),read(query[i].r),query[i].org=i;
}
int cnt[maxn],cnt1[maxn];
int ans;
inline void add(const int i){
  cnt[A[i]]++;
  ans=ans>cnt[A[i]]?ans:cnt[A[i]];
}//核心,添加的同时更新众数出现次数
inline void del(const int i){
  cnt[A[i]]--;//直接删除,不考虑影响
}
int ans1[maxn];
signed main(){
  Init();
  std::sort(query+1,query+q+1);
  int l=R[bel[query[1].l]]+1,r=R[bel[query[1].l]],last=bel[query[1].l];
  for(int i=1;i<=q;++i){
    if(bel[query[i].l]==bel[query[i].r]){
      int tmp=0;
      for(int j=query[i].l;j<=query[i].r;++j)
        cnt1[A[j]]++;
      for(int j=query[i].l;j<=query[i].r;++j)
        tmp=tmp>cnt1[A[j]]?tmp:cnt1[A[j]];
      for(int j=query[i].l;j<=query[i].r;++j)
        cnt1[A[j]]--;
      ans1[query[i].org]=tmp;
      continue;
    }
    if(bel[query[i].l]!=last){
      while(r>R[bel[query[i].l]])
        del(r--);
      while(l<R[bel[query[i].l]]+1)
        del(l++);
      ans=0,last=bel[query[i].l];
    }
    while(r<query[i].r)
      add(++r);
    int tmp=ans,l1=l;
    while(l1>query[i].l)
      add(--l1);
    ans1[query[i].org]=ans;
    while(l1<l)//回滚还原
      del(l1++);
    ans=tmp;
  }
  for(int i=1;i<=q;++i)
    printf("%d\n",ans1[i]);
  return 0;
}
T2 [Violet]蒲公英
题目链接:Link
题目描述:
给定一个长度为 \(N\) 的序列,有 \(M\) 次询问,每次询问一个区间 \([l,r]\) 的众数是多少。如果有多个数可以作为区间众数,那么输出最小的那一个。输入数据经过加密,强制在线。
Solution:
看到“众数”直接考虑分块就行了。这题强制在线,把莫队也废了,只能考虑普通的分块。
考虑每个询问 \([l,r]\) ,设 \(l\) 属于第 \(p\) 块,\(r\) 属于第 \(q\) 块。分块一般把一段区间 \([l,r]\) 分成 3 部分:
- 开头的零散段 \([l,L)\) ;
- 中间的由整块构成的段 \([L,R]\) ;
- 结尾的零散段 \((R,r]\) ;
根据分块“大段维护,局部暴力”的思想,应该重点考虑如何维护 \([L,R]\) ,剩下的交给暴力。
在区间求和问题中,分块预处理了每块的区间和。查询时零散段暴力求和,再加上预处理好的区间和,就得到了答案。受此启发,感觉上也可以预处理每块的众数是几。但是区间和可以相加得到更长区间的区间和,也就是区间和满足“可加性”。众数不满足可加性,所以预处理不能只处理每块的众数,要把所有以块的端点为端点的区间 \([L,R]\) 的众数预处理出来。这里设块长为 \(T\) ,符合条件的大区间长度从 \(N, N-T, N-2T...\) 到 \(T\),数量从 \(1, 2, 3...\) 到 \(\frac N T\) 。
现在考虑零散段暴力。零散段中的元素可能导致最终答案改变,所以还要记下来中间整段中每个元素出现了多少次(开桶,记为 \(cnt_{L,R}\) )。直接把两段零散段的元素加入桶中,同时更新众数的值。加入完零散段元素之后得到这次询问的答案。记录答案之后,要把加入的元素再删掉,恢复原来的 \(cnt_{L,R}\) 的环境,以便下次使用(这不是有点像回滚莫队吗?)。
思路口胡完了,来分析一下时空复杂度吧。
\(\text{upd:2025.9.11}\),有同学指出原来计算最佳块长的推导有问题,确实bushi,但是我重新推了一下发现这部分推导很难做,以我的水平有可能会存在错误,所以最后那一大堆总时间复杂度的部分需要读者审慎地看,辩证地看。另外这里强调一句,以下推导仅供娱乐,分块算法块长取 \(\sqrt N\) 可以应对 99% 的情况,不需要精打细算最佳的块长,有时候会很麻烦。
- 
预处理的所有大区间的总元素数量 \(S\) 为: \[S=N+2(N-T)+3(N-2T)+...+ \frac N T(T) \]\[S=1(N-0T)+2(N-1T)+3(N-2T)+... \]显然这是两个的等差数列的乘积和的形式,先把等差数列搞出来: 设 \(a_k=a_1+(k-1)d,b_k=b_1+(k-1)e, 1\leq k\leq \frac N T\),其中 \(a_1=1,b_1=N\) 为首项,\(d=1,e=-T\) 为公差,则 \(S=\sum^{\frac N T}_{k=1}a_kb_k\) 代入乘积和公式求和得到 \(S=\frac{N^3+2NT^2+3N^2T}{6T^2}\) 如果有同学不知道等差数列的乘积和公式,这里现推一下: 设 \(a_k = a_1+(k-1)d, b_k = b_1 + (k-1)e, k\geq 1\) \(S=\sum^n_{k=1} a_kb_k=\sum^n_{k=1}[a_1+(k-1)d][b_1+(k-1)e]=\sum^n_{k=1}a_1b_1+a_1e(k-1)+b_1d(k-1)+de(k-1)^2\) 第一项是 \(n\) 个常数求和,第二第三项很明显是等差数列求和,第四项是平方和(平方和公式 \(\sum^{n}_{k=1}k^2=\frac{n(n+1)(2n+1)}{6}\)),带入公式求和即可 \(S=na_1b_1+\frac{n(n-1)}{2}(a_1e+b_1d)+\frac{n(n-1)(2n-1)}{6}de\) 
- 
零散段长度 \(T\) ,每次暴力处理,回答询问时间复杂度 \(MT\) ; 
- 
每一个大区间内开了长度为 \(N\) 的桶,空间复杂度 \(\Theta(N\frac{(1+\frac N T)\frac N T}{2})=\Theta(\frac{N^2T+N^3}{2T^3})\) ; 
总时间复杂度 \(\Theta(S+MT)\) ,空间为 \(\Theta(\frac{N^2T+N^3}{2T^3})\) 。不妨设 \(M,N\) 同数量级,则时间复杂度为:
然后要求这个函数在 \(T\) 取多少的时候最小,不难发现当 \(T\) 较小时 \(T^{-2}\) 和 \(T^{-1}\)项占主导,当 \(T\) 较大时,一次项 \(T\) 占主导,不妨猜测 \(f(T)\) 先减后增。令 \(f(T) = \frac {N^3} 6T^{-2}+\frac 1 3N+\frac{N^2} 2T^{-1} + NT\),直接开导
令 \(f'(T) = 0\),又因为 \(0<T<N\) ,方程两边同时乘 \(\frac{T^3}{N}\),得:
这是一个三次方程,尝试在 \((0,N)\) 内找实数根
令 \(g(T)=T^3 - \frac N 2T-\frac {N^2} 3\),\(g'(T)=3T^2-\frac N 2\),显然 \(g(T)\) 在 \((0,\frac{\sqrt{6N}}{6})\) 单调递减,\((\frac{\sqrt {6N}}{6},N)\) 单调递增。
计算得 \(g(0)<0\),当 \(N>\frac 5 6\) 时 \(g(N)>0\) (在本算法中显然可以默认 \(N>\frac 5 6\),这里为了严谨提一嘴) ,故方程 \(g(T)=0\) 在 \((\frac{\sqrt {6N}}{6},N)\) 内有且只有一解。
由此可见,\(f'(T)=0\) 在 \((0,N)\) 上只有一个零点,且该零点是 \(f(T)\) 的极小值点。
所以存在一个最佳的块长 \(T\),可以使得时间复杂度最小。当 \(N,T\) 取较大值时,\(T^3,N^2\)项占主导,忽略较小项取近似解 \(T=\sqrt[3]{\frac{N^2}{3}}\)
这里取的块长在 \(N=40000\) 的时候大概在 \(800\) 左右,比 \(\sqrt N\) 的 \(200\) 左右大了不少。出现这种情况应该是因为我们的预处理阶段的时间花费占了大头,适当增大块长可以以减慢询问速度为代价提高预处理阶段的速度。
这怎么变成数学题了
Code:
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<iostream>
//using namespace std;
//Online Solve Range Mode Problem
#define ll long long
const int maxn=40005;
template <typename _T>
inline _T const& read(_T &x){
  x=0;int f=1;
  char ch=getchar();
  while(!isdigit(ch)){
    if(ch=='-')
      f=-1;
    ch=getchar();
  }
  while(isdigit(ch)){
    x=(x<<3)+(x<<1)+ch-'0';
    ch=getchar();
  }
  return x*=f;
}
int n,m,len,tot;
int A[maxn],B[maxn];
int bel[maxn],L[maxn],R[maxn];
int cnt[40][40][maxn];//cnt[L][R][0] refers to Range(L,R)'s mode appers times.
int mode[40][40];
int cnt1[maxn];
inline void add(const int _L,const int _R,const int i){//添加一个数,更新当前区间众数和众数出现的次数
  cnt[_L][_R][A[i]]++;
  mode[_L][_R] = cnt[_L][_R][A[i]] > cnt[_L][_R][0] ? B[A[i]] : mode[_L][_R];
  mode[_L][_R] = cnt[_L][_R][A[i]] == cnt[_L][_R][0] && B[A[i]] < mode[_L][_R] ? B[A[i]] : mode[_L][_R];
  cnt[_L][_R][0] = cnt[_L][_R][A[i]] > cnt[_L][_R][0] ? cnt[_L][_R][A[i]] : cnt[_L][_R][0];
}
inline void del(const int _L,const int _R,const int i){
  cnt[_L][_R][A[i]]--;
}
void Init(){
  read(n),read(m);
  len=n/pow(n,0.3333333333);
  tot=n/len;
  // printf("len=%d tot=%d",len,tot);
  for(int i=1;i<=tot;++i){
    if(i*len>n) break;
    L[i]=(i-1)*len+1;
    R[i]=i*len;
  }
  if(R[tot]<n)
    tot++,L[tot]=R[tot-1]+1,R[tot]=n;
  for(int i=1;i<=n;++i){
    bel[i]=(i-1)/len+1;
    B[i]=read(A[i]);
  }//原题值域较大,需要离散化
  std::sort(B+1,B+1+n);
  int l=std::unique(B+1,B+n+1)-B-1;
  for(int i=1;i<=n;++i)
    A[i]=std::lower_bound(B+1,B+1+l,A[i])-B;
//枚举区间L,R,进行预处理
  for(int i=1;i<=tot;++i)
    for(int j=i;j<=tot;++j){
      for(int k=L[i];k<=R[j];++k)
        add(i,j,k); 
}
    
int ans;//记录答案众数是多少
signed main(){
  Init();  
  for(int i=1,l0,r0;i<=m;++i){
    int l=(read(l0)+ans-1)%n+1,r=(read(r0)+ans-1)%n+1;//加密方式
    if(r<l) std::swap(l,r);
    int belongL=bel[l]+1,belongR=bel[r]-1;
    if(bel[l]==bel[r] || bel[l]+1==bel[r]){//两段相邻或在同一段,直接暴力.
      ans=0;
      for(int j=l;j<=r;++j){
        cnt1[A[j]]++;
        ans = cnt1[A[j]] > cnt1[0] ? B[A[j]] : ans;
        ans = cnt1[A[j]] == cnt1[0] && B[A[j]] < ans ? B[A[j]] :ans;
        cnt1[0] = cnt1[A[j]] > cnt1[0] ? cnt1[A[j]] : cnt1[0];
      }//暴力统计,更新区间众数
      printf("%d\n",ans);
      for(int j=l;j<=r;++j)
        cnt1[A[j]]--;
      cnt1[0]=0;//还原
      continue;
    }    
    int tmp1=mode[belongL][belongR];
    int tmp2=cnt[belongL][belongR][0];//类似回滚莫队,记录原值
    for(int j=l;j<=R[bel[l]];++j)
      add(belongL,belongR,j);
    for(int j=L[bel[r]];j<=r;++j)
      add(belongL,belongR,j);//暴力添加
    ans=mode[belongL][belongR];//统计答案
      
    mode[belongL][belongR]=tmp1;//回滚还原
    cnt[belongL][belongR][0]=tmp2;
    for(int j=l;j<=R[bel[l]];++j)
      del(belongL,belongR,j);
    for(int j=L[bel[r]];j<=r;++j)
      del(belongL,belongR,j);     
    printf("%d\n",ans);
  }
  return 0;
}
T3 大爷的字符串题
题目链接:Link
题目描述:
给一个长度为 \(N\) 的序列 \(A\) ,每次询问一段区间的最大 \(rp\) 。
\(rp\) 定义:
每次从区间中任意选择一个数 \(x\) ,把 \(x\) 从序列中删除,直到区间为空。要求维护一个集合 \(S\) 。
- 如果 \(S\) 为空,则你 \(rp\) 减 1 。
- 如果 \(S\) 中有一个数严格大于 \(x\) ,你 \(rp\) 减 1 ,清空 \(S\) 。
- 把 \(x\) 加入集合 \(S\) 。
询问之间互不影响,每次询问初始 \(rp=0\) 。
Solution:
发现第一次选择时,\(rp\) 一定减 1 ,之后第一条就没用了(之后的 \(S\) 不可能为空)。
考虑怎么选才能不掉 \(rp\) 。显然,只要每次选的数严格大于之前的数就行了,也就是说,选出的数应该构成一个严格上升的序列。
如果对要选择的区间从小到大排序,然后从最小的数开始选,相同的数只选一个(保证严格大于,不然会直接掉 \(rp\) ),一直选到最大的数。这样花费 1 的 \(rp\) 就把区间中每种数删除了一次。同上方法,从剩下的数种再选一次,又花了 1 的 \(rp\) 把区间中剩下的每种数删除了一次。这样每次每种数只能删掉一个,那我找找谁最抗删不就行了?
区间众数最抗删,所以要花费区间众数出现次数的 \(rp\) 删掉区间中所有数。
答案即为 T1 答案的相反数,代码不给了。

 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号