《算法实战笔记:滑动窗口、KMP、并查集等 5 类经典算法解析》

如下所示,跟着y总,学了一段基本算法,感觉收获良多。下面是基础数据结构算法 学习过程中的一些心路历程及部分总结。方便自己后续查看,也希望能够帮到大家一点点。

滑动窗口

kmp算法

与运算&

并查集

堆排序

哈希表

拉链法

开放寻址法

字符串哈希


滑动窗口

(每次都找窗口内最小值)

首先要保证的就是hh<=tt,也就是队列不为空。

假如下一步窗口移动到-3,那么1将不在窗口内,因此队列的头,应该++,即q[hh]++;

假如当前的元素小于-1,那么-1可以直接踢出窗口,因为后续再怎么着也不会选到它。即tt--,把它的索引替代。

宗旨就是把最小的放到队头,确保O(1)的复杂度就能 拿到最小值或最大值。

算法核心:

int a[] = new int[N];//记录原始元素值
int q[] = new int[N];//记录元素在a[]内的索引。
int hh=0,tt=-1;
StringBuilder sb = new StringBuilder();
​
for(int i=0;i<n;i++){
    //保证队列不为空,且队头始终在窗口内。
    while(hh<=tt&&q[hh]<i-k+1) hh++;
    //保证队列不为空,将最小的值始终留在队头。
    while(hh<=tt&&a[q[tt]]>a[i]) tt--;
    q[++tt] = i; // 队尾为当前元素。
    //窗口形成后 开始输出
    if(i>=k-1)
    sb.append(a[q[hh]]+" ");
}
System.out.println(sb);

kmp算法

用于匹配子串,假如是暴力匹配,挨个遍历。每次遇到问题,子串往后推进一个字符。

n为子串,m为大串。时间复杂度为O(n*m),是比较大的。

假如从0,能直接到2,那么就可以省略1的工作。

那么怎么精确的推进到呢?想到可以记录子串的前缀和后缀和。

比如ababca,next[]数组 索引从1开始。

next[1]=0,next[2]=0,next[3]=1,next[4]=2, next[5]=0,next[6] = 1

假如咱们能够在next[5]的时候,直接回溯到next[4],也就是回溯到前面的b了,下一个直接从打五角星的a开始比较。很显然后面的abca都是能匹配得上的。终结!

for(int i=1,j=0;i<=m;i++){
    while(j>0&&s[i]!=p[j+1]) j=ne[j];
    if(s[i]==p[j+1]) j++;
    if(n==j){
        j=ne[j];
        sb.append(i-n+" ");
    }
}

那么next数组该怎么求呢?很显然。

其实咱们要求的是前缀和后缀相等的最大长度。(前缀指:包含第一个元素的任意串、后缀指:包含最后一个元素的任意串)

也就是比如abab,next[4]为2。

首先第一个元素是不用匹配的,因为没有意义。假如匹配接着往后走。假如不匹配按照上述的规则回溯即可。

getNext代码如下:

static void getNext(int n, char[] p, int[] ne) {
    for(int i=2,j=0;i<=n;i++){
        // 不匹配 直接向前回溯。
        while(j>0&&p[i]!=p[j+1]) j = ne[j];
        // 匹配了,j++
        if(p[i]==p[j+1]) j++;
        // 该点的ne[i]为j,含义:当索引为i时,后续不匹配,可以回溯到j。
        // 为什么回溯到前缀和后缀相等的长度可以呢?因为前缀是从第一个元素开始的,也是从索引1开始,所以长度也代表了其索引。
        ne[i]=j;
    }
}

与运算&

咱们知道二进制数字是由01构成的,那么在遇到某种特定问题时,可以寻找某种规律,将不同数字直接建立联系。

比方说A1到AN,找出两数之间最大的异或值。

这个时候我们可以采用trie树进行解题,根据数字的01进行排布。

那么怎么能更快的知道每个数字0或者1的分布呢?很简单。

先看一个数字,00110000。

00110000<<1,相信大家都不陌生,左移一位么,相当于数字扩大了一倍,01100000。

那看00110000>>1呢,同理,右移一位,相当于数字缩小了一倍,00011000。

那么假如咱们想得到每一位的数字该怎么求呢?

110>>2是不是001?也就是把最开头,也就是最高位的1移到0号位了?那么好,知道这一位,不就等同于知道最高位了吗?

怎么求?怎么知道?

001&1会得到什么?001&001=000;按位与的话,可以知道两个0号位的数字是相同的,即都是1。

这就可以快速的求出某位的数字为几了!

假如数字的范围为0到2的31次方,那么用如下公式即可求出每一位数字。

for(int i=30;i>=0;i--){
    int s = x>>i&1; // 看最后一位是0还是1
}

并查集

先贴一个板子,感觉这个算法非常精妙。

//并查集
//初始化
for(int i=1;i<=n;i++){
    p[i] = i;
    length[i]=1;
}
​
// 查看祖宗编号,即集合编号。
static int find(int x){
    if(x!=p[x]) p[x] = find(p[x]);//并查集+压缩路径
    return p[x];
}
// 合并集合
static void merge(int x,int y){
    //length[find(y)]+=getLength(x); 维护集合内的元素个数。
    //d[px] = d[y]-p[x];
    p[find(x)] = find(y);//x所在集合 接到y集合后了。
}
//判断两元素是否在同一集合。
static boolean isSameSet(int x,int y){
    if(find(x)==find(y)) return true;
    else return false;
}
// 获取集合内元素的个数
static int getLength(int x){
    return length[find(x)];
}

图示:

假如,两个集合要合并,那么只需要将x的祖宗直接指向y的祖宗。这也解决了暴力做法,两个较大集合不好合并的问题,从原来的O(n)复杂度,降到了O(1)。

堆排序

首先堆排序,咱们需要弄清楚什么是堆?通常来说,堆具有如下性质,堆顶是最小值。无论何时堆顶都是最小的。

当然了,最大堆相反,堆顶无论何时都是最大的。

堆的数据结构是一个二叉树,根比左右两侧的孩子都要小。

用数组模拟堆,假设节点为x,那么左孩子为2x,右孩子为2x+1;

实际上维护堆,只需要两个操作,也就是up(int k),down(int k);这么两个操作。

数字大了,只需让数字往下走,找到属于它的位置。数字小了只需让数字网上走,努力向堆顶看齐。

那么说一下堆的常见操作:

1.插入一个数字,很简单,h[++size] = x; 然后让该数字往上走。up(x);

2.获取最小值,即获取堆顶。h[1];(索引从1开始);

3.删除堆顶。先说结论:h[1] = h[size--];down(1);

假如删除堆顶,需要改动很多值的位置。我们不妨换个思路,假如能把堆顶和其他地方的值,都交换一下,悄咪咪的再把那个值给删掉,然后再用up或down来维护一下目前堆顶的元素。不就可以了吗?

好了,思路成立。那么该换谁呢?假如h[1]、h[2]换一下,那么删除h[2]显然也不是简单的事情,咱们这时候可以想到,可以从堆的最后来删除呀。size--即可,神不知鬼不觉,就可以将带有堆顶值的元素删掉。

接下来咱们只需要down(1)来维护堆即可。

那么进阶一点的操作有没有?比如说删除第k个插入的值,或者修改第k个插入的值。

显然,想实现这个需要有一个数组能够记录,第k个插入的值,在数组内是什么索引,我们此处记为ph[];

那么此时,假如要删除这个第k个值,同样,我们还是跟堆最后一个值,交换,然后size--;紧接着再up,down,维护堆。

但是与此同时,会发生一个问题。我们让最后一个值,跟第k个插入的值交换,那么ph[]得维护吧,也就是说,咱们虽然知道最后一个值的索引,但它是第几个插入的呢??ph[i],这个i是不是无从得知了?那么ph也就没办法维护了。

因此咱们需要一个hp[]来维护,索引为i的值,对应的是第几个插入的。

比方说还是刚才的例子,

delete 5:删除第5个插入的数字。

int pos = ph[5]; //获取第5个插入的数字,在堆内的索引。

hp[pos] = hp[size]; // pos索引处 对应的是第几个插入的值。

ph[hp[size]] = pos; // 第hp[size]插入的值,现在在堆内的位置。

h[pos] = h[size--]; //将值和最后一个值进行交换。

up(pos);down(pos);

完事儿了!

哈希表

首先,咱们知道哈希Map的作业,就是存储键值对。能够在O(1)的时间复杂度内找到对应键的值。

那么它的底层原理是什么样的呢?

数字哈希和字符串哈希又有何不同呢?

首先第一点,假如数字的范围是10的-9次方到10的9次方,用数组存储空间是会溢出的,并且可能会有大量冗余空间。因此咱们想一种方法,将数字散列一下。

什么意思呢?也就是对数字进行取模存储,比方说对10的5次方取模,那么数字就会映射到0-10的5次方 - 1了。

大大减少了数组的空间,但是与此同时会有一个问题。数字是有可能重复的对吧,也就是取模后相同,不一定代表两个值相同。也就是发生了哈希冲突。

怎么解决呢?常用的方法有两种,拉链法以及开放寻址法。

拉链法顾名思义,就是在对应值的下面用链表将其连接起来。

一般拉链法会有两个关键函数:

拉链法
​
static int N = 100003;
static int[] h = new int[N];
static int[] e=new int[N],int[] ne = new int[N],idx;
//向哈希表中插入一个数
void insert(int x){
    int k = (x%N+N)%N; //因为可能涉及到负数,因此需要先取模,再+N,再取模。
    e[idx] = x;
    ne[idx] = h[k];
    h[k] = idx++;
}
//在哈希表查找对应的数字。
boolean query(int x){
    int k = (x%N+N)%N;
    for(int i=h[k];i!=-1;i=ne[i]){
        if(e[i]==x) return true;
    }
    return false;
}
public static void main(String[] args){
    Arrays.fill(h,-1);
}

此外还有 一种方法,名为开放寻址法。不需要另外的链表,而是寻坑。多开一点数组,假如位置被占了,就往后顺延。

开放寻址法
static int N = 200003;
static Integer[] h = new Integer[N];
int find(int x){
    int k = (x%N+N)%N;
    while(h[k]!=null&&h[k]!=x){
        k++;
        if(k==N) k=0;
    }
    return t;
}
public static void main(String[] args){
    Arrays.fill(h,null);
}
字符串哈希

顾名思义,前面咱们讲的是数字哈希。那么字符串没有办法通过简单的取模来散列。

字符串,可以用p进制的数字来表示,P可以用131。即131进制的数字。

比如说"abcd",可以表示为a131的3次方+b131的2次方+c131的1次方+d131的0次方。

假如再长一点“abcdefghijklmn”,咱们可以求出从a到任意字符的,p进制表示。

即h[r]即表示,从第一个字符到第r个字符。

那么假如想求[4,7]内的字符串呢?即defg该怎么求呢?

我们可以想到假如我们知道h[l-1],以及h[r];

可以用如下公式来进行计算。

h[l-1]*p[r-l+1]表示abc空空空空。

那么abcdefj-abc空空空空,不就可以求出defg的字符串哈希了吗?

好的代码如下:

static int P = 131;
static int h[N],p[N];
p[0]=1;
for(int i=1;i<=n;i++){
    h[i] = h[i-1]*P+str[i];
    p[i] = p[i-1]*P;
}
​
long get(int l,int r){
    return h[r]-h[l]*p[r-l+1];
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值