layout: post
title: 算法笔记(三)特殊数据结构——哈希表、有序表、并查集、KMP、Manacher、单调栈、位图、大数据类题
description: 算法笔记(三)特殊数据结构——哈希表、有序表、并查集、KMP、Manacher、单调栈、位图、大数据类题
tag: 算法
(1)输入无穷,输出有限
(2)相同输入得到相同输出
(3)不同输入,也有可能得到相同输出,称为哈希碰撞(这是(1)性质决定的)
(4)不管是否是有规律的输入,哈希函数返回值在整个有限输出域上是均匀离散分布的。
哈希函数应用时输出域一般较大,所以所得输出结果会再与m取模,转为m进制的数,所得的m进制数在0-m-1范围内也有均匀离散分布的。
利用哈希函数将无限的输入映射为有限的K个均匀离散小单元,每个小单元上构建链表存入输入的键值对。由于均匀离散性,每个小单元的链表长度几乎是一块变长的,但检测到某个链表长度大于特定值M时,构建一个新的哈希表,单元数扩容一倍,映射为有限的2×K个小单元,则每个小单元上链表长缩减为原来一半。每次查询某个键值对时,先查小单元地址,再查对应的链表。对于N个输入,要扩容的次数为O(LogN),每次扩充的代价需要重新计算哈希值(计算哈希值的代价是O(1)),重新存入链表,对于N个输入,代价为O(N)级别,总的代价为O(N×LogN),平均到每个输入的代价,再除以N,因此哈希表的增删改查的时间复杂度平均每个为O(LogN)级别。
维护两个大小相同,键值 相反哈希表和哈希表的大小size,一个存入key与对应的序号,另一个存入序号与对应的key(因为不重复加入,所以这样是可行的)。删除某个结构时(假如删除D),先根据哈希表的size取到最后一次记录(Z),将D改为Z,然后删除最后一条记录Z:25,这样的好处是可以一直维护一片连续的索引范围(0-size-1),由此就可以采用在0-size-1范围上生成随机数的方法随机返回key值。
/*
构建randomPool
【题目】
设计一种结构,在该结构中有如下三个功能:
insert(key):将某个key加入到该结构,做到不重复加入
delete(key):将原本在结构中的某个key移除
getRandom():等概率随机返回结构中的任何一个key。
【要求】
lnsert、delete和getRandom方法的时间复杂度都是0(1)
*/
class RandomPool
{
private:
unordered_map<string, int> keyIndexMap;
unordered_map<int, string> indexKeyMap;
int size;
public:
RandomPool();
~RandomPool();
void myInsert(string key) {
if (this->keyIndexMap.find(key) == this->keyIndexMap.end()) {
this->keyIndexMap.insert(pair<string, int>(key, this->size));
this->indexKeyMap.insert(pair<int, string>(this->size++, key));
cout << "插入:" << key << " size:" << this->size << endl;
this->myPrint();
}
}
void myDelete(string key) {
if (this->keyIndexMap.find(key) != this->keyIndexMap.end()) {
// 删除的索引
int deleteIndex = this->keyIndexMap.find(key)->second;
// 获取最后一个元素索引
int lastIndex = --this->size;
// 获取最后一个元素的key
string lastKey = this->indexKeyMap.find(lastIndex)->second;
// 接下来将最后一个元素的key更新到待删除的索引那里,再将待删除元素删掉
this->indexKeyMap[deleteIndex] = lastKey;
this->indexKeyMap.erase(lastIndex);
this->keyIndexMap[lastKey] = deleteIndex;
this->keyIndexMap.erase(key);
cout << "删除:" << key << " size:" << this->size << endl;
this->myPrint();
}
}
string getRandom() {
if (this->size == 0) {
return NULL;
}
srand((unsigned int)time(NULL)); //设置随机数种子为当前系统时间,(unsigned int)强制转换
int random = rand() % this->size; // 产生0-size的随机数
return this->indexKeyMap.find(random)->second;
}
void myPrint() {
if (this->indexKeyMap.size() < 1) {
return;
}
cout << "打印----" << endl;
for (unordered_map<string, int>::iterator i = this->keyIndexMap.begin(); i != this->keyIndexMap.end(); i++) {
cout << "key:" << i->first << " index:" << i->second << endl;
}
for (unordered_map<int, string>::iterator i = this->indexKeyMap.begin(); i != this->indexKeyMap.end(); i++) {
cout << "index:" << i->first << " key:" << i->second << endl;
}
}
};
RandomPool::RandomPool()
{
this->indexKeyMap = unordered_map<int, string>();
this->keyIndexMap = unordered_map<string, int>();
this->size = 0;
}
RandomPool::~RandomPool()
{
}
int main() {
RandomPool mypool = RandomPool();
mypool.myInsert("aaa");
mypool.myInsert("aca");
mypool.myInsert("ada");
mypool.myInsert("aea");
cout << mypool.getRandom() << endl;
mypool.myDelete("aaa");
cout << mypool.getRandom() << endl;
return 0;
}
布隆过滤器(英语:Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。主要用于判断一个元素是否在一个集合中。
通常我们会遇到很多要判断一个元素是否在某个集合中的业务场景,一般想到的是将集合中所有元素保存起来,然后通过比较确定。链表、树、散列表(又叫哈希表,Hash table)等等数据结构都是这种思路。但是随着集合中元素的增加,我们需要的存储空间也会呈现线性增长,最终达到瓶颈。同时检索速度也越来越慢,上述三种结构的检索时间复杂度分别为
O
(
n
)
O(n)
O(n),
O
(
l
o
g
n
)
O(logn)
O(logn),
O
(
1
)
O(1)
O(1)。
这个时候,布隆过滤器(Bloom Filter)就应运而生,它极大减少内存,代价是有一定失误率,但是失误只是一种,比如要屏蔽有害URL,布隆过滤器可能误杀正常的URL,但不会放过任意一个有害URL。
BloomFilter 是由一个固定大小的二进制向量或者位图(bitmap)和一系列映射函数组成的。
在初始状态时,对于长度为 M 的位数组,它的所有位都被置为0,如下图所示:
当有变量被加入集合时,通过 K 个映射函数将这个变量映射成位图中的 K 个点,把它们置为 1(假定有两个变量都通过 3 个映射函数)。
查询某个变量的时候我们只要看看这些点是不是都是 1 就可以大概率知道集合中有没有它了
如果这些点有任何一个 0,则被查询变量一定不在;
如果都是 1,则被查询变量很可能存在
为什么说是可能存在,而不是一定存在呢?那是因为映射函数本身就是哈希函数,哈希函数是会有碰撞的。
M是位图的位数目,K是哈希函数个数,每个输入进过K个哈希函数映射到位图上K个值,置为1。
M越大,可以承载的输入域越大,那么哈希冲突发生概率越小,误判率越小。
使用了K个哈希函数,每个根据位图上K个位置是否都是1来判定,这样保证了新的输入在部分哈希函数发生冲突的情况下,也不会全是1,减少误判率。
当然,K也不是越大越好,一方面K越多哈希值计算越多,代价越高,另一方面,K越多,位图上每次置1的位置越多,位图信息消耗越快,反而增加误判率。
布隆过滤器的误判是指多个输入经过哈希映射之后在相同的bit位置1,这样就无法判断究竟是哪个输入产生的,因此误判的根源在于相同的 bit 位被多次映射且置 1。
这种情况也造成了布隆过滤器的删除问题,因为布隆过滤器的每一个 bit 并不是独占的,很有可能多个元素共享了某一位。如果我们直接删除这一位的话,会影响其他的元素。(比如上图中的第 3 位)
相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数 O ( K ) O(K) O(K),另外,散列函数相互之间没有关系,方便由硬件并行实现。布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。
布隆过滤器可以表示全集,其它任何数据结构都不能;
在程序的世界中,布隆过滤器是程序员的一把利器,利用它可以快速地解决项目中一些比较棘手的问题。
如网页 URL 去重、垃圾邮件识别、大集合中重复元素的判断和缓存穿透等问题。
布隆过滤器的典型应用有:
不安全网页的黑名单包含100亿个黑名单网页,每个网页的URL最多占用64字节。现在想要实现一种网页过滤系统,可以根据网页的URL判断该网站是否在黑名单上,请设计该系统。要求该系统允许有万分之一以下的判断失误率,并且使用的额外空间不要超过30G。
对于分布式存储,不同机器上存储不同对象的数据,我们使用哈希函数建立从数据到服务器之间的映射关系。
传统做法使用简单的哈希函数
m = hash(o) mod n
其中,o为对象名称,n为机器的数量,m为机器编号。
考虑以下例子:
3个机器节点,10个数据 的哈希值分别为1,2,3,4,…,10。使用的哈希函数为:(m=hash(o) mod 3)
机器0 上保存的数据有:3,6,9
机器1 上保存的数据有:1,4,7,10
机器2 上保存的数据有:2,5,8
当增加一台机器后,此时n = 4,
需要重新构建哈希表:
各个机器上存储的数据分别为:
机器0 上保存的数据有:4,8
机器1 上保存的数据有:1,5,9
机器2 上保存的数据有:2,6,10
机器3 上保存的数据有:3,7
只有数据1和数据2没有移动,所以当集群中数据量很大时,采用一般的哈希函数,在节点数量动态变化的情况下会造成大量的数据迁移,导致网络通信压力的剧增,严重情况,还可能导致数据库宕机。
一致性hash算法正是为了解决此类问题的方法,它可以保证当机器增加或者减少时,节点之间的数据迁移只限于两个节点之间,不会造成全局的网络问题。
将哈希函数输出空间想象为一个环形域:
不同对象数据通过哈希映射到环上:
使用相同的哈希映射输入服务器的唯一标识码将机器也映射到这个环上:
对象与机器处于同一哈希空间中,这样按顺时针转动,:m1存储到了t3中,m3和m4存储到了t2中,m2存储到了t1中。
当需要增加一台机器t4时:仅需要修改m4-> t2 为
m4 -> t4
数据的移动仅发生在t2和t4之间,其他机器上的数据并未受到影响。
当删除一台机器t1时,需修改m2->t1 为 m2->t3,数据迁移仅发生在t1和t3之间。
问题1 机器节点数量少时,数据归属域不均衡
当集群中的节点数量较少时,可能会出现节点在哈希空间中分布不平衡的问题。如下图所示,图中节点A、B、C分布较为集中,造成hash环的倾斜。数据1、2、3、4、6全部被存储到了节点A上,节点B上只存储了数据5,而节点C上什么数据都没有存储。A、B、C三台机器的负载极其不均衡。
问题2 数据迁移时导致负载不均衡
在极端情况下,假如A节点出现故障,存储在A上的数据要全部转移到B上,大量的数据导可能会导致节点B的崩溃,之后A和B上所有的数据向节点C迁移,导致节点C也崩溃,由此导致整个集群宕机。这种情况被称为雪崩效应。
这两个问题都可以通过虚拟节点来解决。
每台实际机器(节点),分配大量的虚拟节点,虚拟节点量大,保证了在哈希输入环域上的均衡,只要记录好实际节点与虚拟节点的映射即可。
当增加或者减少机器时,同样增加或减少等多的大量虚拟节点,在虚拟节点上完成数据迁移即可。
同时还可根据实际机器内存的大小,分配不同数量的虚拟节点(假定每个虚拟节点的负载能力一致),以此用于负载的管理。
增删改查都是
O
(
l
o
g
N
)
O(logN)
O(logN)级别,红黑树,AVL树,SB树,跳表(skip list)都可以用来实现有序表
,且这些数据结构实现的有序表的时间复杂度是一致
的。
二插搜索树一般不能有重复的元素,但可以通过修改节点的结构,每个节点不仅放元素的值value,同时统计元素出现的次数count。
查询
查询的过程实际上与插入的过程是一致的。
删除
删除某个节点分为三种情况
(1)将该节点左子树的最右节点(6号),记录保存下来,因为它是最右边的节点,因此它一定满足上边的情况1和情况2,按照上边的做法,删除这个最右边节点,将待删除节点7号的父节点指向保存下来的6号节点,将6号节点的左指针指向7号的左子树,右指针指向7号的右子树,删除并释放7号节点。
简单讲就是:保存左子树最右节点,删除该节点,用保存下的左子树最右节点替换 待删除节点。
根据搜索二叉树的定义,左子树最右节点是左子树的最大值(潜台词,左子树除最右节点外所有节点都小于该最右节点),右子树上每个值都会大于左子树(潜台词,右子树上每个节点都大于左子树最右节点),因此这样替换后,可以保证6号节点左边都是小于6号节点的值,右边都是大于6号节点的节点,删除节点同时,保证了搜索二叉树的合法性。
同理,将右子树的最左节点保存记录后再删除,替换待删除节点也是可行方案
,因为右子树除最左节点外都大于该最左节点,左子树都小于该右子树的最左节点。
左旋:
想象将头结点A的右节点C拎起来,那么头结点A会向左移动,其他连接方式保持不变C的左子树E交由节点A的右指针
右旋:
想象将头结点A的左节点B拎起来,那么头结点A会向右移动,其他连接方式保持不变B的右子树D交由节点A的左指针(子树是三角,节点是圆圈)
引入左旋与右旋是为了使得树变平衡一些。
例如下边的树明显右子树比较重,左旋一下。
左旋与右旋操作可以增加树的平衡性,AVL树,红黑树提供了检查树的平衡性以及利用左旋、右旋操作来实现树的自平衡的功能的算法。
平衡二叉树的4种失衡状态:
如果在AVL树中进行插入或删除节点,可能导致AVL树失去平衡,这种失去平衡的二叉树可以概括为四种姿态:LL(左左)、RR(右右)、LR(左右)、RL(右左)。它们的示意图如下:
这四种失去平衡的姿态都有各自的定义:
LL
:LeftLeft,也称“左左”。左子树的左子树高度 - 右子树高度 >= 1;
插入或删除一个节点后,根节点的左孩子(Left Child)的左孩子
(Left Child)还有非空节点,导致根节点的左子树高度比右子树高度高2,AVL树失去平衡。
平衡方法:右旋
RR
:RightRight,也称“右右”。
右子树的右子树高度 - 左子树高度 >= 1
插入或删除一个节点后,根节点的右孩子(Right Child)的右孩子(Right Child)
还有非空节点,导致根节点的右子树高度比左子树高度高2,AVL树失去平衡。
平衡方法:左旋
LR
:LeftRight,也称“左右”。
左子树的右子树 - 右子树 >= 2
插入或删除一个节点后,根节点的左孩子(Left Child)的右孩子(Right Child)
还有非空节点,导致根节点的左子树高度比右子树高度高2,AVL树失去平衡。
平衡方法:
1、围绕根节点的左节点进行左旋(实际上转为了LL型) 2、围绕根节点右旋
RL
:RightLeft,也称“右左”。
右子树的左子树 - 左子树 >= 2
插入或删除一个节点后,根节点的右孩子(Right Child)的左孩子(Left Child)
还有非空节点,导致根节点的右子树高度比左子树高度高2,AVL树失去平衡。
平衡方法:
1、围绕根节点的右节点进行右旋旋(实际上转为了RR型) 2、围绕根节点左旋
AVL树
当中是通过左右两棵子树的树深
来判断的,两边的树深差不超过1,那么就认为是平衡了。而在SBT
当中,我们对二叉树平衡的定义是基于节点数量
的,也就是Size。
具体而言
SB的平衡性标准
每棵子树的大小,不小于其兄弟的子树大小。即每棵叔叔树的大小,不小于其任何侄子树的大小。
根据这个定义每个子树大小最多为2*兄弟子树大小 + 1。
SB树的不平衡性也可分为LL,RR,LR和RL四种情况。
LL
(左子树的左子树节点数 > 右子树):
平衡方法:右旋 + 递归平衡子树节点大小改变的节点
例如下边一开始T的左子树的左子树大于T的右子树,出现LL型不平衡,右旋T,右旋后,新的T节点位置子树变化,以及L节点子树变化,因此需要递归检查并处理T和L的平衡性。
LR
(左子树右子树节点数 > 右子树):
平衡方法:围绕根节点左节点左旋 + 围绕根节点右旋 + 递归平衡子树节点大小改变的节点
AVL的左右子树高度差不能超过1,每次进行插入/删除操作时,几乎都需要通过旋转操作保持平衡
在频繁进行插入/删除的场景中,频繁的旋转操作使得AVL的性能大打折扣
红黑树通过牺牲严格的平衡,换取插入/删除时少量的旋转操作,整体性能优于AVL树
红黑树的定义规则
跳表是对原始链表的改进,原始链表查询每个节点都要进行
O
(
n
)
O(n)
O(n)的遍历,而跳表借鉴了二分查找的思路对链表的索引进行了分级处理,跳表本质是可以实现二分查找的有序链表
。
添加一级索引:
添加二级索引
上边对数据进行二分插入索引的方式实际上是在已知数据规模的情况下,建立的静态二分层次索引,而实际中我们建立跳表的过程是一个个动态添加或者删除的。如一直往原始列表中添加数据,但是不更新索引,就可能出现两个索引节点之间数据非常多的情况,极端情况,跳表退化为单链表,从而使得查找效率从 O(logn) 退化为 O(n)。那这种问题该怎么解决呢?我们需要在插入数据的时候,索引节点也需要相应的增加、或者重建索引,来避免查找效率的退化。那我们该如何去维护这个索引呢?
最理想的索引就是二分查找的情形,在原始链表(假设长度为n),随机选 n/2 个元素做为一级索引、随机选 n/4 个元素做为二级索引、随机选 n/8 个元素做为三级索引,依次类推,一直到最顶层索引。
跳表中采用randomLevel() 方法
来实现上述索引建立过程,该方法会随机生成 1~MAX_LEVEL 之间的数(MAX_LEVEL表示索引的最高层数),且该方法有 1/2 的概率返回 1、1/4 的概率返回 2、1/8的概率返回 3,以此类推。(randomLevel()的实现可以是循环调用一个有0.5概率返回1的函数,如果是1,则继续调用该函数 ,直至为0。
)
跳表实际上是使用了这种随机产生元素的索引高度的方式,来打乱了输入的规律性,使得整体上按索引查询某个元素的时间复杂度为
O
(
l
o
g
N
)
O(logN)
O(logN)
假设新加入一个元素6,通过randomLevel()函数返回了1,即建立一层索引(一层索引只能建立在最下边的层),它的建立方式如下:
从表头节点的最高层索引出发,如果某个节点的下一个元素大于该插入元素(6),则停止,进入下一层索引,依次类推,直至当前层与元素(6)的索引层相同,将节点插入。
再如下边的例子,要插入元素70,建立两层索引:
依旧从最高层索引出发,找到当前层小于等于70的最后一个元素,判断索引层数是否一致,不一致则进入下一层,继续找到当前层小于等于70的最后一个元素,判断索引层数是否一致,直至一致,插入。
跳表删除数据时,要把索引中对应节点也要删掉。如下图所示,如果要删除元素 9,需要把原始链表中的 9 和第一级索引的 9 都删除掉。
并查集
并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。
并查集通常包含两种操作
初始每个集合都只有一个元素,指针指向自己。
实现isSameSet(a,b)时,将a和b向上遍历,直至发现指向自身,得到根节点,如果a和b的根不同,则a和b不属于同一集合。
实现union(a, b)时,先判断a,b是否属于同一个集合,不是则将b挂a上,union(c,d)时,d挂c上
(union(a,b)谁挂在谁下方实际上都可以)
若再union(b,e),先判断b,e是否同一集合,不是则将e挂在b的根上
若再unison(e,d),不是,将d的根挂e的根上。
由此实际上unison(a,b)操作,先向上遍历到自己的根,判断根是否一致以确定两者是否属于同一集合,若不是,则将b的根挂在a的根上。
template<class T>
class Element
{
public:
Element(T value) {
this->value = value;
};
~Element();
T value;
};
template<class T>
class UnionFindSet
{
public:
UnionFindSet(list<T> elementList) {
this->elementMap = unordered_map<T, Element<T>>();
this->fatherMap = unordered_map<Element<T>, Element<T>>;
this->sizeMap = unordered_map<Element<T>, int>();
for (int i = 0; i < elementList.size(); i++) {
Element<T> element = Element(elementList[i]);
this->elementMap[elementList[i]] = element;
this->fatherMap[element] = element;
this->sizeMap[element] = 1;
}
};
~UnionFindSet();
// base数据类型上封装为map的元素
unordered_map<T, Element<T>> elementMap;
// 使得子节点可以访问父节点的map (子:父)
unordered_map<Element<T>, Element<T>> fatherMap;
unordered_map<Element<T>, int> sizeMap;
// 寻找该元素的头元素
Element<T> findHead(Element<T> element) {
auto path = stack<Element<T>>();
// 根据fatherMap向上遍历直至根节点指向自身
while (element != this->fatherMap[element])
{
path.put(element);
element = this->fatherMap[element];
}
return element;
}
bool isSameSet(T a, T b) {
if (this->elementMap.find(a) != this->elementMap.end()
&& this->elementMap.find(b) != this->elementMap.end()) {
return findHead(this->elementMap[a]) == findHead(this->elementMap[b]);
}
return false;
}
void myUnion(T a, T b) {
// 先确定两元素的根节点
Element<T> a_root = findHead(this->elementMap[a]);
Element<T> b_root = findHead(this->elementMap[b]);
// 当两元素根节点不同时执行合并
if (a_root != b_root) {
// 先根据sizeMap,找到较大的那个集合,以便于将较小的挂在较大的根上
Element<T> big = this->sizeMap[a_root] >= this->sizeMap[b_root] ? a_root : b_root;
Element<T> small = big == a_root ? b_root : a_root;
// 更新fatherMap,将较小的根节点挂较大者
this->fatherMap.insert(pair<Element<T>, Element<T>>(small, big));
this->sizeMap.insert(pair<Element<T>, int>(big, this->sizeMap[a_root] + this->sizeMap[b_root]));
this->sizeMap.erase[small];
}
}
};
当并查集向上的链路过长会导致合并和查询都变慢,解决方法是在合并操作时进行路径压缩,遍历路径所有的元素都指向根节点
岛问题
【题目】
一个矩阵中只有0和1两种值,每个位置都可以和自己的上、下、左、右四个位置相连,如果有一片1连在一起,这个部分叫做一个岛,求一个矩阵中有多少个岛?
【举例】
001010
111010
100100
000000
这个矩阵中有三个岛
【进阶】
如何设计一个并行算法解决这个问题
定义一个infect(感染函数),遍历到1的元素将所有连成一片的1赋值为2(避免后序再次遍历),感染次数加1,最终得到的感染次数就是岛的数量。
感染过程infect()函数的实现就是将练成一片的区域都遍历标记下。
/*
island问题
【题目】
一个矩阵中只有0和1两种值,每个位置都可以和自己的上、下、左、右四个位置相连,如果有一片1连在一起,这个部分叫做一个岛,求一个矩阵中有多少个岛?
【举例】
001010
111010
100100
000000
这个矩阵中有三个岛
【进阶】
如何设计一个并行算法解决这个问题
*/
void infect(vector<vector<int>> &land, int i, int j, int m, int n) {
if (i < 0 || i >= n || j < 0 || j >= m || land[i][j] != 1) {
return;
}
// 检测到1才进入,将当前置2
land[i][j] = 2;
infect(land, i + 1, j, m, n); // 向下感染
infect(land, i - 1, j, m, n); // 向上感染
infect(land, i, j + 1, m, n); // 向右感染
infect(land, i, j - 1, m, n); // 向左感染
}
int islandSum(vector<vector<int>> land) {
if (land.size() == 0) {
return 0;
}
int n = land.size(); // n行
int m = land[0].size(); //m列
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (land[i][j] == 1) {
ans++;
infect(land, i, j, m, n);
}
}
}
return ans;
}
左右两个CPU分别计算一半找岛,左右都会找到两个岛。
为了计算整体岛屿的个数,需要在感染过程中记录下虚线边界的位置。
左右各找到两个岛,在虚线边界上记录下4个点的位置(每个位置有上下两个元素)。
用这四个点初始化并查集。{A}{B}{C}{D}
遍历两个CPU记录的边界点,当发现两个边界点相同时,若发现在并查集中属于不同的元素,则将其合并,岛数量减一。
这样最终得到岛的数量是1.
KMP算法要解决的问题就是在字符串(也叫主串)中的模式(pattern)定位问题。说简单点就是我们平时常说的关键字搜索。模式串就是关键字(接下来称它为P),如果它在一个主串中出现,就返回它的具体位置,否则返回-1(常用手段)。
例如判断str1 = “ABC1234de”是否有子串为str2=“1234”.
例如字符串“abbabb”的最长的前缀和后缀匹配长度为K=3
做法是:查看字符串前n个字符与后n个字符是否相等(字符依旧是从左至右排列),不考虑n为字符串长度的情况,因为此时前缀和后缀都是字符串本身,必然相等
对较短的字符串str2求它每个位置前边构成的子串的最长的前缀和后缀匹配长度K。
具体规则是,每次取str2的前i个字符,例如i=0是空,则令K=-1,i=1是‘a’,只有一个字符,而最大匹配长度K的求解要求不能全整个字符串,则令K=0,i=2是‘aa’,则K=1,i=3是‘aab’,则K=0,i=4是‘aaba’,则K=1,i=5是‘aabaa’,K=2 以此类推……最终得到str2的nextarr数组。
正常来讲求解nextarr数组外循环指针遍历每个位置,内循环计算每个位置前边部分构成的子串的K值。
实际上,这个nextarr数组可以递推,利用之前的nextarr中值计算出后边的。
首先nextarr[0] = -1,nextarr[1] = 0.
因为i=0,子串为空,i=1,子串只有一个元素,这两个K值是规定的值。
i=2时,子串是前两位,如果str2[0] = str2[1],那么nextarr[2] = 1,否则为0。
当来到i位置,先查看i-1的位置,发现nextarr[i-1]的值为8,
假如str[i-1] = str[8],那么nextarr[i]的值就是8 + 1 = 9,若不相等,再看i = 8位置,nextarr[8] = 3,假设str[3] = str[i-1],那么nextarr[i]的值就是3 + 1 = 4,若还不相等,查看i=3的位置,nextarr[3] = 0,若str[0] = str[i-1],那么nextarr[i]的值就是 0 + 1 = 1
当str2与str1从i位置开始的子串一一比对到出现不相等的情况时(假设此时str1的索引指向X,str2的索引指向Y),暴力匹配的做法是将str1的指针指向i+1的位置,把str2的指针再指向0的位置,继续一一比对。
而KMP的做法是查询str2当前的位置Y的nextarr的值,得到前边子串的最长前缀和后缀匹配长度K,将str2的指针指向K的位置开始继续一一比对。
可以这样做的原因有二:
[i,X-1]
与str2[0,Y-1]
是相等的[X-K, X-1]
与str2的[Y-K,Y-1]
部分也一定相等,所以不需要将str2的指针置0
开始比。[Y-K, Y-1](后缀)
与 str2的[0,K](前缀)
相等,[X-K, X-1]
部分与str2的[0,K]
(前缀)相等,这样KMP将str2的指针指向K
,就相当于利用了str2的K
前边的部分与str1的X
位置前边K
个字符已经相等的信息。i+1
的位置,而是直接来到X
前边K
个字符的位置(假设该位置为j
)来比较的原因是,可以证明,从str1的i位置与j位置之间
(左闭右开)开始一定得不到与str2匹配的字符串,因此可以直接跳到j
位置。 i
和j
间的k
位置开头可以得到与str2匹配的字符串,那么k
到X
的部分(假定长度为L
,L
一定大于K
,因为j
位置是由最长前缀和后缀匹配长度K
,由X
向前移动K
个得到的,而k
在i
和j
之间,则L
必定大于K
)必定与str2的前L
长部分相等,这样一来str2就推出了一个L
长的最长前缀与后缀匹配长度,这与K
的定义相悖,故假设错误。/*
KMP算法
str1 长字符串 abbac
str2 短字符串 bba
判断str2是否为str1的子串,如果是返回str2在str1中的位置(起始点,如例子中是1)
如果不是子串则返回-1
*/
vector<int> getNextArray(string str2) {
int n = str2.length(); // 主函数已经判断n不会小于1
if (n == 1) {
return vector<int>(n, -1);
}
// n > 1 时
auto next = vector<int>(n, -1);
// 0和1的位置人为规定
next[0] = -1;
next[1] = 0;
int i = 2; // 从i=2开始求next[]剩余的值,while循环遍历
int kn = 0; // 初始化i-1位置的next值,i从2开始,故kn初始 = next[1] = 0
while (i < str2.length())
{
// 每次查看i-1位置的值是否与kn位置相同
if (str2[i - 1] == str2[kn]) {
// 则next[i] = kn + 1, i++
next[i++] = ++kn;
}
// str2[i - 1] 与 str[kn]不等时,且kn > 0 还没有跳到开头,将kn向前移动到next[kn]
else if (kn > 0)
{
kn = next[kn];
}
// 如果kn已经跳到开头还不相等,则next[i] = 0,i++
else
{
next[i++] = 0;
}
}
return next;
}
int getSubIndexByKMP(string str1, string str2) {
if (str2.length() < 1 || str1.length() < str2.length()) {
return -1;
}
int i1 = 0;
int i2 = 0;
vector<int> next = getNextArray(str2);
while (i1 < str1.length() && i2 < str2.length()) {
// 如果比对相等,双指针后移继续比对
if (str1[i1] == str2[i2]) {
i1++;
i2++;
}
// 若不相等且i2还在起始,str1指针后移
else if (i2 == 0) { // next[i2] = -1 next[]数组中只有0位置是-1
i1++;
}
// 若不相等且i2不在起始,计算该位置的最长前缀和后缀匹配长度next[i2],将指针i2向前跳到此
else {
i2 = next[i2];
}
}
// 当i1或者i2到达边界时,如果此时遍历到str2的末尾则匹配成功,此时i2的值是str2的长度,将str1的指针前移str2的长度即为所求
return i2 == str2.length() ? i1 - i2 : -1;
}
Manacher 算法用于求解字符串中最长回文子串的问题。
中心扩散法的基本思路是以某个字符为中心向左右两边扩散直至发现两边不相等,计算每个字符的中心扩散结果取最大值就是最长回文子串的长度。
单纯以某个字符为中心无法得到偶数长度的回文子串。因此通常的做法是在原来的字符串首尾和字符之间插入特殊字符‘#’,然后再施展中心扩散,特殊字符‘#’相当于是虚拟对称中心。
注意:添加的特殊字符可以是任意字符,因为这样添加虚拟的特殊字符,中心扩散时一定是虚拟字符与虚拟字符对比,实际字符与实际字符对比,因为虚拟字符的具体值是什么不影响结果。
先解释一些概念:
Manacher解法:
base case:
初始从i=0开始,R=-1,C=-1
当以某个字符i为中心扩散时,假如该字符位置不在R的范围内,更新R和C,直接执行暴力扩散,以确定该字符的回文半径。
当以某个字符i为中心扩散时,该位置在R的范围内,则一定能找到i关于记录的中心点C对称的点i’,R关于C的对称点L。
此时又分三种情况:
4. i’的回文范围,左边界与L重合。
此时根据对称性,依旧有i位置的回文半径至少是r的结论。
1号!= 2号 1号!= 4号
而2号 = 3号 则 ->
1号!= 3号
3号与4号???
3号与4号都不与1号相等,所以3号和4号有可能相等,也有可能不相等
因此此时i的回文范围只是部分确认,需要继续中心扩散以确认i的回文半径的最终结果。
这相当于给了i位置的回文半径一个初始值r,然后每扩散一次符合回文则r++。
单调队列用于解决维护一段数据,可以随时很快的获取它的最值的问题。
采用一个滑动窗口由左右边界(L和R决定,其中L<=R),维护一个双端队列,存放数字的索引,每次R右移时,如果数字比队头的数字小或者队列中没有存放任何索引时,将R右移滑到的数字索引从队尾放入,如果数字比队头的数字大,将队列中所有元素弹出后(因为前边放入的元素先过期,而当存在比先过期的元素更大的元素后,这些先过期的元素永远不可能再成为最大的元素,所以不需要再维护这些元素),再将这个数字的索引从队尾放入。
L右移时,查看L左侧的元素是否为队列中的队头元素,如果是,将队列中的队头从队头弹出,如果不是,跳过。
单调栈用于解决:在一段数据中,为任意一个元素快速找左边和右边第一个比自己大/小的元素所在位置.
由于每个元素最多各自进出栈一次,复杂度是O(n).
单调栈使用流程:
维护栈从顶到底是升序的状态,每次入栈判断当前元素是否比栈中元素小,是则将其索引入栈,否则开始出栈并生成左右比自己大的元素所在位置信息。
令出栈的元素为当前元素,它的左边比它大的第一个数就是栈中位于该元素下边的元素(先入栈,故是左边,根据单调性,他比当前元素大),它的右边比它大的第一个数就是,目前待入栈的元素。当前元素完成出栈。
再将待入栈元素与当前栈顶元素相比,若还是比待入栈元素小,将栈顶元素出栈,并生成信息。
假如遇到栈空的情况则表明,左边比没有比该元素还大的元素。
将待入栈元素入栈。以此类推……
若没有元素需要再入栈了,开始清算栈中剩余元素,每个要出栈的元素他右边比它还大的元素是无,他左边还他还大的元素是栈中当前元素底下一个的元素。
同样的当栈空时,说明当前元素左边没有比它还大的元素。
基本流程一致,单调栈中存放链表,将相同元素压在一块,每次生成信息时,取链表尾部的元素索引。(尾部后入,离的最近)。
链表中出栈的顺序也是从尾部开始。
自栈底到栈顶单调增加的单调栈用于生成某个数的极大区间,因为可以找到该数左右两边第一个比它大的值的位置,在这个区间内所有值都是小于该数的。
而如果改变单调栈的单调性,自栈底到栈顶是单调下降的话,则用于生成某个数的极小区间,因为可以利用单调栈找到该数左右两边第一个比它小的值。
定义:数组中累加的和与最小值的乘积,假设叫做指标A。给定一个数组,请返回子数组中,指标A最大的值。
子数组实际上就是一个小区间,求该区间上累计和与最小值的乘积,那么我们需要的就是一个极小区间,通过构建单调栈,来找到每个元素的极小区间,然后计算指标A,从而求取出最大的A。
以题为例:
class Info {
public:
int height;
int maxDistance;
Info(int height, int maxDistance){
this -> height = height;
this -> maxDistance = maxDistance;
}
};
Info getInfo(TreeNode* node){
if (node == nullptr){
return Info(0, 0);
}
Info leftInfo = getInfo(node -> left);
Info rightInfo = getInfo(node -> right);
int height = max(leftInfo.height, rightInfo.height) + 1; //本树高是最大子树高 + 1
// 若经过根节点,最大值就是左子树高度加右子树高度,若不经过根节点,最大值就是左子树下的最大距离与右子树的最大距离中的较大者
int maxDistance = max((leftInfo.height + rightInfo.height), max(leftInfo.maxDistance, rightInfo.maxDistance));
return Info(height, maxDistance);
}
int diameterOfBinaryTree(TreeNode* root) {
if(root == nullptr){
return 0;
}
Info info = getInfo(root);
return info.maxDistance;
}
整体代码:
class Solution {
public:
class Info {
public:
int height;
int maxDistance;
Info(int height, int maxDistance){
this -> height = height;
this -> maxDistance = maxDistance;
}
};
Info getInfo(TreeNode* node){
if (node == nullptr){
return Info(0, 0);
}
Info leftInfo = getInfo(node -> left);
Info rightInfo = getInfo(node -> right);
int height = max(leftInfo.height, rightInfo.height) + 1; //本树高是最大子树高 + 1
// 若经过根节点,最大值就是左子树高度加右子树高度,若不经过根节点,最大值就是左子树下的最大距离与右子树的最大距离中的较大者
int maxDistance = max((leftInfo.height + rightInfo.height), max(leftInfo.maxDistance, rightInfo.maxDistance));
return Info(height, maxDistance);
}
int diameterOfBinaryTree(TreeNode* root) {
if(root == nullptr){
return 0;
}
Info info = getInfo(root);
return info.maxDistance;
}
};
一种通过原树中叶子节点的大量空闲指针的方式来达到节省空间的快速遍历二叉树的过程。
其实根节点为置为当前节点cur:
A、假如当前节点左子树,先找到左子树上最右边的节点mostRight,
(a)如果mostRight的右指针指向nullptr,将mostRight的右指针指向cur,cur向左移动,cur = 它的左节点。
(b)如果mostRight指针指向了cur,将mostRight的右指针指回nullptr。
B、假如当前节点没有左子树,cur向右移动, cur = 它的右节点。
C、将cur == nullptr时,停止遍历
Morris遍历时,每个节点如果它有左子树,则会到这个节点两次,如果没有左子树则只会遍历一次。
并且根据mostRight右指针指向为nullptr,判断cur是第一次来到某个节点,而cur的mostRight指向了自己,则是第二次来到该节点。
Morris序:1 2 4 2 5 1 3 6 3 7
先序:1 2 4 5 3 6 7
中序:4 2 5 1 6 3 7
后序:4 5 2 6 7 3 1
Morris序中只出现一次的元素直接打印,出现两次的元素打印第一个即为先序:
中序、后序有点复杂。。。
用基于的int类型数据结构,构建bit类型的数组。一个int4个字节,该bit数组就是32位长度,500+M,用这个数组来统计某个数是否出现。
【解题技巧】:
注:数据基础
1byte = 8bit
1KB = 1024B = 2^10 B
1MB = 1024KB = 2^20B
1Gb = 1024MB = 2^30B
【题目】有一个包含100亿个URL的大文件,假设每个URL占用64B,请找出其中所有重复的URL
哈希均匀分流是比较通用的解法,先将100亿个大文件通过哈希分流对应有内存允许的小文件,遍历小文件中有重复URL的文件,统计有重复的
有容错率的情况下,可以使用布隆过滤器
【题目】某搜索公司一天的用户搜索词汇是海量的(百亿数据量),请设计一种求出每天热门Top100词汇的可行办法
先哈希均匀分流成小文件,每个小文件构成大根堆,每个大根堆的堆顶再构成总堆。总堆堆顶就是TOP1,取出TOP1元素,维持总堆为大根堆,heapify,假如它来自小根堆X,将X中的堆顶取出,并heapify,维持它为大根堆。将小根堆X的堆顶再heapInsert到总堆,此时总堆的堆顶就是TOP2。以此类推求出TOP100
【题目】32位无符号整数的范围是0~4294967295,现在有40亿个无符号整数,可以使用最多1GB的内存,找出所有出现了两次的数。
哈希分流为小文件,在统计每个小文件中出现两次的数
也是可行解
位图本身只有0-1两种状态,但是可以通过编码的方式,使用多位来拓展状态数
例如该题,先看1GB能容纳的bit位为8 * 2^30 = 2 ^ 33位,每个数用两位表示,00 -> 出现0次 ,01-> 出现1次 ,10-> 出现2次,11-> 出现2次以上
,需要位数为40亿*2,小于2^33。
【题目】可以使用最多10MB的内存,怎么找到这40亿个整数的中位数
10MB / 8B = 2500,以2048的间隔将int表示的范围2 ^ 32 划分为2 ^ 32 / 2048 = 2 ^ 21段,构建长度为2 ^ 21的数组,遍历这40亿个数,数落在哪个范围内,该段对应数组的索引位置自增。最终得到的数组,将它的前n个值累计求和,当累计和第一次大于20亿,表明中位数就在该段中,在这个段内中再次遍历,找到中位数。
【题目】有10G的无序的int数据,给定5G的内存,将它输入为有序的int。
构建大根堆,堆中每个元素,存储int的value和频次count。堆的容量大小C依据5G内存来定,按照元素的value来构堆。
第一次遍历:
A、如果堆的size < C,直接heapInsert进堆
B、否则当堆已经满了,判断当前元素的value是否要小于堆的根节点
1、如果小于,大根堆头节点取出,heapify,将该元素heapInsert
如果大于
2、如果大于,说明该元素比堆中所有元素都大,所以这个元素必定不可能是前C个元素之一,直接跳过。
将第一次遍历结果根元素的值记录下来,假定为Y。
那么所得到的堆就可以将数据中所有小于等于Y的元素排好序。
第二次遍历的时候,如果只遍历大于Y的元素,将大于Y的元素按照流程继续构堆。
如果发现某次直至遍历结束堆都没有满,那么说明不用再执行下一次遍历。