数据结构专题学习笔记
哈希表 (Hash Table)
-
patr 1: 数字哈希
当我们遇到一个值域比较大,但是其实总数很小的数据时:一般都会想到离散化。但是离散化会多一个 \(\log\) 但是优在比较稳定。 但是有时确实无法处理一些卡常的数据。 那有没有什么趋近于 \(O(1)\) 的算法吗? 答案当然是有的。
数字哈希,即为把该数字转为一个大质数的模数。如果有冲突(尽量避免,不然会退化为 \(n^2\) 。关于模数:详见此处。可以在正确的范围内尽量小。
这个比较的简单,也是不多赘述。
例题(好题选讲)
-
P1102 :
解法:把A-B=C 移向得 A-C=B 从而通过哈希值找到存储的个数。关键代码:(cnt->存有几个,date->映射到的数字)
int has(int a) { int p=a%mod ; while(b[p].cnt>0&&b[p].date!=a) p=(p+1)%mod ; return p; }
-
part 2:字符哈希
是数字哈希的plus版本。相当于说把每一个字符串映射到一个数字里,从而达到快速查找的效果。比如在一些不能用substr的地方,就可以使用hash从而达到 \(O(1)\) 的查找。
然后就是关于字符串哈希的定义公式: $$s[0] \times b ^ {n -1} + s[1] \times b ^ {n -2} + \cdots + s[n -1]$$
(其实有点像b进制问题) 对于第i项,我们可以把它拆成:\(pre[i-1]\times b+s[i]\)。这样做的好处是可以直接求l~r的哈希值。因为我们观察公式,注意到 \([l,r]\) 的哈希值满足:\(pre[r]-pre[l-1]*b^{r-l+1}\) 。
其中的一些小技巧(淫巧): (不完整)
- 如果是两个字符串拼接的哈希值也是可以快速求出的(若假设我们要拼接一个[l,k]+[k,r]字符串的哈希值)这保证恒满足以下公式:
- 关于b进制的存储,其实每次取一个模数真的有点麻烦,而且还要注意模加模。(甚至还可能因为模的不对而挂分)所以可以直接使用ULL自然溢出,就可以避免冗杂的模数。但是数据如果卡常,就还是老老实实用模数吧()甚至考场需要双模哈希!!
- 关于基数b的选择:一般选择131 如若数据大可以考虑 1331。(就是容易T)
例题:(好题选讲)
-
P6739
这道题是很朴素且显然的哈希,主要注意到标准字符串是长度是\(\frac{(n-1)}{2}\),而很显然的是n是偶数的时候必然无解。 那么我们直接暴力两遍哈希,假设前一半是标准字符串那么就可以避免substr带来的\(O(\frac{n}{2})\)巨大复杂度。关键代码:
void check(int l1,int r1,int l2,int r2,int pos) { int ful=gethash(l2,r2) ; for(int i=l1;i<=r1;i++) if((gethash(l1,i-1)*powb[r1-i]+gethash(i+1,r1))%mod==ful) { //上述公式的体现 flag[pos]=1; ans[pos]=s.substr(l2,r2-l2+1) ; return ; } } -
P2312
这道题是一个结论题,下面直接上结论:若 \(x \equiv 0 \pmod M\),那 x 就有可能为 0。那么如若是双模一个巨大的数,那么这个数错的概率就会无限趋近于0。所以直接遍历 \([1,m]\) 暴力求值就可以了。(其实这道题仅仅使用了字符串哈希的思想,放到此处主要是为了阐述双模哈希的思想)关键代码:
int ans1=a1[n],ans2=a2[n] ; for(int j=n-1;j>=0;j--) { ans1=(ans1*i%mod1+a1[j])%mod1 ; ans2=(ans2*i%mod2+a2[j])%mod2 ; } if(ans1==0&&ans2==0) ans.push_back(i);
part3: 随机化哈希
当我们需要对一个结点给到一个特定编号以证明这个点的独立性和代表性时(但是这个点本身不能用来做代表)的时候,我们就可以用到这个东西。
某知名AI认为下面的场景可以用到:
-
树的同构判定(AHU算法替代):给每个子树分配随机权值,父节点的哈希值 = 其所有子节点哈希值(排序后)的随机组合。可快速判定两棵有根树是否同构。
-
动态集合判等(如滑动窗口内元素种类):维护窗口内元素权值的异或和,若两个窗口的异或和相等,则元素组成大概率相同。
-
图上环/路径的判定:给每个点赋随机权值,路径哈希 = 路径上点权异或和,可用于快速判断路径是否经过重复点。
思想很简单,就是用一个w数组存系统分配到的权值。当我们要用到这个结点的时候(或者合并)的时候,就可以直接使用这个w数组。
其中的一些小技巧(淫巧): (不完整)
- 加入srand(time(0));(随机种子)可以让每次调用给的随机值不一样。
- 如果觉得还是有可能重复的话可以在给初值的时候写一个rand()*rand()
扫描线 (scanning line)
-
扫描线的定义与实现流程
所谓扫描线,顾名思义就是扫描当前位置的信息,并加入答案中。 这种数据结构多用来处理类似求不规则图形(多由矩形组成)面积并,周长并这类问题的基本工具。所以我们将其视为一种数据结构。
首先先来讲用途更多的面积并。我们考虑将不规则图形分割成很多个规则的矩形,设第 \(i\) 个扫过的矩形长为 \(d_i\) 宽为 \(h_i\) ,那么它的面积就可以很轻易地改写为 \(d_i \times h_i\)。值得注意的是扫描线扫描方向并不唯一,如果一个方向不好构造,不妨换一个试试看。
这里是一张动图(从下往上),方便生动地了解做法:(from OI wiki)
割点
在下文定义割点为分割后长方形的高开始与结束的地方,即为图中红线停留有的地方
现在小矩阵的高很显然就是两个割点的距离。但是小矩阵的宽该如何求出是现在面临的问题。遇到区间加,最简单直白的方法就是差分,但是这种方法一眼应该就是假的因为还会出现两个区间不连续的情况,这就没办法判断了。所以应用更加高级的数据结构——线段树。每当我们新增一个矩形时,就将它的开头打上增加的标记,结尾打上减少的标记,类似差分思想。最后统计(查询)答案的时候如果该点的值大于0证明被覆盖,否则没有。
最后代码仅需维护两种操作:
- 区间加减。(注意不用也不能下放懒标记)
- 检查当前点是否为0。
P5490 板子题,做法和刚讲的无差异。另外 12倍经验 。代码放一下,方便找结合文字描述找板子。(reid离散化对应的真实值,to离散化。)
放一下代码
#include <bits/stdc++.h>
#define int long long
#define ls (p<<1)
#define rs (p<<1|1)
#define mid ((l+r)>>1)
using namespace std;
const int N=1e6+10 ;
struct LineNode{
int x,starty,endy,tag;
bool operator<(const LineNode &y) const{
return x==y.x?tag>y.tag:x<y.x;
}
} line[N<<1];
struct TreeNode{int l,r,sum,lz;} tr[N<<3];
int reid[N<<1],to[N<<1]; map<int,int> mp ;
void build(int p,int l,int r) {
tr[p]={l,r,0,0};
if(l==r) return ;
build(ls,l,mid); build(rs,mid+1,r) ;
}
void pushup(int p,int l,int r) {
if(tr[p].lz==0) tr[p].sum=tr[ls].sum+tr[rs].sum ;
else tr[p].sum=reid[r+1]-reid[l] ;
}
void pushdown(int p,int l,int r,int add) {
tr[p].lz+=add ;
if(tr[p].lz!=0) tr[p].sum=reid[r+1]-reid[l];
else pushup(p,l,r) ;
}
void modify(int p,int l,int r,int add) {
if(l>tr[p].r||r<tr[p].l) return ;
if(l<=tr[p].l&&r>=tr[p].r) {pushdown(p,tr[p].l,tr[p].r,add); return ;}
modify(ls,l,r,add); modify(rs,l,r,add) ;
pushup(p,tr[p].l,tr[p].r) ;
}
signed main() {
ios::sync_with_stdio(0);cin.tie(0); cout.tie(0) ;
int n,tot=0; cin>>n;
for(int i=1,x1,x2,y1,y2;i<=n;i++) {
cin>>x1>>y1>>x2>>y2 ;
line[++tot]={x1,y1,y2,1} ;
line[++tot]={x2,y1,y2,-1} ;
}
n=tot; tot=0;
for(int i=1;i<=n;i+=2) {
to[++tot]=line[i].starty ;
to[++tot]=line[i].endy ;
}
sort(to+1,to+1+tot) ;
int cnt=0;
for(int i=1;i<=tot;i++) if(!mp[to[i]]) {
mp[to[i]]=++cnt;
reid[cnt]=to[i] ;
}
build(1,1,cnt-1) ;
sort(line+1,line+1+n) ;
int ans=0 ;
for(int i=1;i<=n;i++) {
if(i!=1) ans+=tr[1].sum*(line[i].x-line[i-1].x) ;
modify(1,mp[line[i].starty],mp[line[i].endy]-1,line[i].tag) ;
}
cout<<ans;
return 0;
}
- 其中的一些小技巧(淫巧): (不完整)
- 一般默认从左到右(\(x\) 轴)或从下到上(\(y\) 轴)。但如果图形本身极度扁平或细长,沿着短边扫可以大幅减少扫描线长度。(思路有点类似启发式合并?)
- 其他的我还没遇到,待补充……
线性基
-
定义与实现
如果一个集合 \(S\) 满足:\(S\) 中任意多个数异或所得的结果均能表示为线性基中的元素互相异或的结果,则称其为线性基。这种数据结构,主要用于求 异或和 相关的问题。
接下来是操作,首先明确:$(a \oplus b)\oplus b = a $ 对于每一个新加进来的数 \(x\) ,如果我们能使用集合 \(S\) 中已有的数通过一系列操作将其变为0,那么这个数一定是没有贡献,所以直接把它删掉。 如果则呢么改都无法变为0, 那么这个数就是有贡献的,就可以把它加到线性基 \(S\) 里。
板子题P3812 。
放一下代码。
#include <bits/stdc++.h> #define int long long using namespace std ; const int N=55 ; int a[N],xxj[N] ; signed main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0) ; int n; cin>>n; for(int i=1;i<=n;i++) { cin>>a[i] ; for(int lg=49;lg>=0;lg--) if((a[i]>>lg)&1) { if(xxj[lg]>0) a[i]^=xxj[lg] ; else { xxj[lg]=a[i] ; break ; } } } int ans=0 ; for(int lg=49;lg>=0;lg--) if(((ans>>lg)&1)==0) ans^=xxj[lg] ; cout<<ans ; return 0; } -
其中的一些小技巧(淫巧): (不完整)
-
线性基的合并,其实讲起来很愚蠢。——@cwfxlh 但是其实真的也很愚蠢。就是把u的值尝试丢到v里面,然后没了?然后没了。
具体实现
void merge(int u[],int v[]) { for(int lg=Ln;lg>=0;lg--) if(v[lg]>0) { int x=v[lg] ; for(int bit=Ln;bit>=0;bit--) { if((x>>bit)&1) { if(u[bit]>0) x^=u[bit] ; else {u[bit]=x ;break ;} } } } } -
线性基求第k小的异或和(保证存在),就可以把线性基写成阶梯化形式再按常规取出答案。是否能被各种异或之后表示也简单,就是直接判一下是否为0就可以了。
具体实现
for(int i=49;i>=0;i--) for(int j=i-1;j>=0;j--) if((xxj[i]>>j)&1) xxj[i]^=xxj[j] ; vector<int> base ; for(int i=0;i<=49;i++) if(xxj[i]) base.push_back(xxj[i]) ; if(base.size() < n) k-- ; int ans=0 ; for(int i=0;i<(int)base.size();i++) if((k>>i)&1) ans^=base[i] ;//第k小 for(int lg=49;lg>=0;lg--) if((q>>lg)&1) q^=xxj[lg] ; //是否可以
-
-
例题 (好题选讲)
-
P3857
这道是一个类双倍经验,只需存线性基的数量,答案就是 \(2^{cnt}\) 。 -
P4570
看着很唬人的贪心,有些时候第一直觉往往是正确的。因为点权值不为负,所以加的点应该尽可能的多。所以对初始点权排序之后每个线性基的基本数去点权最大的再全部加起来就没了(很贪心了)。放一下代码(真的要吗?)
#include <bits/stdc++.h> #define int long long using namespace std ; const int N=1005; int xxj[N] ; struct Node{ int num,w; bool operator <(const Node &x) {return x.w<w ;} } a[N]; signed main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0) ; int n; cin>>n; for(int i=1;i<=n;i++) {cin>>a[i].num>>a[i].w; } sort(a+1,a+1+n) ; int ans=0; for(int i=1;i<=n;i++) { int x=a[i].num ; for(int lg=63;lg>=0;lg--) if((x>>lg)&1) { if(xxj[lg]>0) {x^=xxj[lg];} else { xxj[lg]=x ; break ; } } if(x!=0) ans+=a[i].w; } cout<<ans ; return 0; }
-
字典树 (Tire Tree)
-
朴素字典树
-
定义与实现
字典树,是一种树形结构。典型应用是用于统计和排序大量的字符串前缀来减少查询时间,最大限度地减少无谓的字符串比较。 从而实现空间换时间,适用于多字符匹配。
其加入 insert的实现过程主要是从根节点(类似超级源点)出发,设现在遍历到的结点编号为 \(u\) 那么如果现在走到的字符不在该结点的子结点上那么就需要把该结点加进去。如果存在,就直接走当前的字符。当遍历到该单词最后一个字符之后,就将该结点的 \(cnt\) 数组自增。查询 query是否有以当前字符串为前缀的字符串出现的函数的操作和上面加入的操作没有差异。就是如果遍历到一个没有出现过的字符就返回空,否则返回真。
P8306 板子
放一下代码
#include <bits/stdc++.h> //#define int long long using namespace std; const int N=3e6+10,M=70+10; int tire[N][M],cnt[N] ,tot=1; map<string,bool> vised; inline int getpos(char c) { if('a'<=c&&c<='z') return c-'a' ; else if('0'<=c&&c<='9') return (c-'0')+26 ; else return (c-'A')+36 ; } inline bool work(int v,int &p) { if(tire[p][v]==0) return 1; p=tire[p][v] ; return 0; } inline void insert(string s) { int p=1 ; for(auto v:s){ if(work(getpos(v),p)) {tire[p][getpos(v)]=++tot;p=tire[p][getpos(v)] ;} cnt[p]++ ; } } inline int search(string s) { int p=1; for(auto v:s) if(work(getpos(v),p)) return 0 ; return cnt[p] ; } inline void init(){ for(int i=0;i<=tot;i++){ cnt[i]=0; for(int j=0;j<70;j++) tire[i][j]=0; } tot=1; } inline void sovle() { init() ; int n,m; cin>>n>>m ; for(int i=1;i<=n;i++) {string s; cin>>s; insert(s) ;} while(m--) { string s; cin>>s; cout<<search(s)<<"\n" ; } } signed main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0) ; int T; cin>>T; while(T--) sovle() ; return 0; } -
其中的一些小技巧:
- v的转化是有偏移量的,要在转化的时候加上。面对 \(10^5\) 及以下不卡常的数据 ,可以直接用字典序。不然的话还是写一个 change 函数。
- 遇到多测不要用memset清空 ,因为开的数组比较死。直接用init手写循环清空。会快不只一个数量级。
-
这个比较简单,没有什么题。不讲了。
-
-
01字典树
-
定义与实现
将朴素字典树的每一位原存储于字符的结点改为当前数字的每一位的0/1。把每一个数字转换成二进制的 01 字符串插入字典树,再依次遍历整棵字典树。这样做快速即可求实现异或极值操作。加入操作与朴素字典树操作并无差异。主要以P10471为例题,讲解一下查找。首先一个数异或上一个和它一样的数是没有意义的。所以因为此题让我们找出最大值,那就需要贪心地找出结点u儿子中,权值与其不一样的走下去。如果儿子全部不满足此规则再走路径最长的儿子。(因为异或是不进位的加法,在权值非负的情况下,应是越加越优的。)
放一下代码
#include <bits/stdc++.h> //#define int long long using namespace std; const int N=2e6+10 ; int sum[N] ; vector<pair<int,int> > g[N] ; void dfs(int u,int f) { for(auto [v,w]:g[u]) { if(v==f) continue ; sum[v]=sum[u]^w ; dfs(v,u) ; } } int tire[N][2] ,tot=1,cnt[N]; void insert(int x) { int p=1; for(int i=31;i>=0;i--) { int j=(x>>i)&1 ; if(!tire[p][j]) tire[p][j]=++tot ; p=tire[p][j]; cnt[p]++ ; } } int query(int x) { int p=1,res=0; for(int i=31;i>=0;i--) { int j=(x>>i)&1 ; if(tire[p][j^1]) {res|=(1<<i) ;p=tire[p][j^1] ;} else p=tire[p][j] ; } return res ; } signed main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0) ; int n; cin>>n ; for(int i=1,u,v,w;i<n;i++) { cin>>u>>v>>w ; g[u].push_back({v,w}) ; g[v].push_back({u,w}) ; } dfs(1,0) ; for(int i=1;i<=n;i++) insert(sum[i]) ; int ans=0; for(int i=1;i<=n;i++) ans=max(ans,query(sum[i])) ; cout<<ans; return 0; } -
好题选讲 P4551
这道题让我觉得很棒的一点就是它把01字典树当成了一种工具。树上异或和最大,这个问题好像是没有太大突破口的。但是秉持着走重复路径的异或是没有意义的,所以因为树上两点之间路径是唯一的,所以两点之间的异或和是唯一的。那就把一个复杂问题转化为模板了,再用上01字典树这个就结束了。这个思维方式特别的妙呀。放一下代码
#include <bits/stdc++.h> //#define int long long using namespace std; const int N=2e6+10 ; int sum[N] ; vector<pair<int,int> > g[N] ; void dfs(int u,int f) { for(auto [v,w]:g[u]) { if(v==f) continue ; sum[v]=sum[u]^w ; dfs(v,u) ; // cout<<v<<":"<<sum[v] <<"\n"; } } int tire[N][2] ,tot=1,cnt[N]; void insert(int x) { int p=1; for(int i=31;i>=0;i--) { int j=(x>>i)&1 ; if(!tire[p][j]) tire[p][j]=++tot ; p=tire[p][j]; cnt[p]++ ; } } int query(int x) { int p=1,res=0; for(int i=31;i>=0;i--) { int j=(x>>i)&1 ; if(tire[p][j^1]) {res|=(1<<i) ;p=tire[p][j^1] ;} else p=tire[p][j] ; } return res ; } signed main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0) ; int n; cin>>n ; for(int i=1,u,v,w;i<n;i++) { cin>>u>>v>>w ; g[u].push_back({v,w}) ; g[v].push_back({u,w}) ; } dfs(1,0) ; for(int i=1;i<=n;i++) insert(sum[i]) ; int ans=0; for(int i=1;i<=n;i++) ans=max(ans,query(sum[i])) ; cout<<ans; return 0; }
-

浙公网安备 33010602011771号