二叉树二叉堆 一些数据结构相关。
重点突破并输出。
书完成,总结并二刷?
搜集信息、 重点目标公司刷题。
学习AI 那本书!
引言
红黑树与AVL的区别
STL的底层基础实现
set与map映射、以及deque和priority_queue被低估
一、二叉堆图解实现
1.5节为重点总结 + 参考《算法第四版》 进行理解、运用和学习。
1.1-1.4 可以直接收看 《算法第四版》。
本节两部分——1.认识二叉堆会构造和删除;2.堆排序重点完成。
二叉堆(Binary Heap)没什么神秘,性质比二叉搜索树 BST 还简单。其主要操作就两个,sink(下沉)和swim(上浮),用以维护二叉堆的性质。其主要应用有两个,首先是一种排序方法「堆排序」,第二是一种很有用的数据结构「优先级队列」。
本文就以实现优先级队列(Priority Queue)为例,通过图片和人类的语言来描述一下二叉堆怎么运作的。
1.1 二叉堆概览
首先,二叉堆和二叉树有啥关系呢,为什么人们总数把二叉堆画成一棵二叉树?
因为,二叉堆其实就是一种特殊的二叉树(完全二叉树),只不过存储在数组里。一般的链表二叉树,我们操作节点的指针,而在数组里,我们把数组索引作为指针:
// 父节点的索引
int parent(int root) {
return root / 2;
}
// 左孩子的索引
int left(int root) {
return root * 2;
}
// 右孩子的索引
int right(int root) {
return root * 2 + 1;
}
画个图你立即就能理解了,注意数组的第一个索引 0 空着不用:
PS:因为数组索引是数字,为了方便区分,将字符作为数组元素。
你看到了,把 arr[1] 作为整棵树的根的话,每个节点的父节点和左右孩子的索引都可以通过简单的运算得到,这就是二叉堆设计的一个巧妙之处。为了方便讲解,下面都会画的图都是二叉树结构,相信你能把树和数组对应起来。
二叉堆还分为最大堆和最小堆。最大堆的性质是:每个节点都大于等于它的两个子节点。类似的,最小堆的性质是:每个节点都小于等于它的子节点。
两种堆核心思路都是一样的,本文以最大堆为例讲解。
对于一个最大堆,根据其性质,显然堆顶,也就是 arr[1] 一定是所有元素中最大的元素。
1.2 优先级队列概览
优先级队列这种数据结构有一个很有用的功能,你插入或者删除元素的时候,元素会自动排序,这底层的原理就是二叉堆的操作。
数据结构的功能无非增删查该,优先级队列有两个主要 API,分别是insert插入一个元素和delMax删除最大元素(如果底层用最小堆,那么就是delMin)。
下面我们实现一个简化的优先级队列,先看下代码框架:
PS:为了清晰起见,这里用到 Java 的泛型,Key可以是任何一种可比较大小的数据类型,你可以认为它是 int、char 等。
1.3 实现 swim 和 sink
为什么要有上浮 swim 和下沉 sink 的操作呢?为了维护堆结构。
我们要讲的是最大堆,每个节点都比它的两个子节点大,但是在插入元素和删除元素时,难免破坏堆的性质,这就需要通过这两个操作来恢复堆的性质了。
对于最大堆,会破坏堆性质的有有两种情况:
如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行下沉。
如果某个节点 A 比它的父节点大,那么 A 不应该做子节点,应该把父节点换下来,自己去做父节点,这就是对 A 的上浮。
当然,错位的节点 A 可能要上浮(或下沉)很多次,才能到达正确的位置,恢复堆的性质。所以代码中肯定有一个while循环。
上浮的代码实现:
画个图看一眼就明白了:
下沉的代码实现:
下沉比上浮略微复杂一点,因为上浮某个节点 A,只需要 A 和其父节点比较大小即可;但是下沉某个节点 A,需要 A 和其两个子节点比较大小,如果 A 不是最大的就需要调整位置,要把较大的那个子节点和 A 交换。
画个图看下就明白了:
至此,二叉堆的主要操作就讲完了,一点都不难吧,代码加起来也就十行。明白了sink和swim的行为,下面就可以实现优先级队列了。
1.4 实现 delMax 和 insert
这两个方法就是建立在swim和sink上的。
insert方法先把要插入的元素添加到堆底的最后,然后让其上浮到正确位置。
public void insert(Key e) {
N++;
// 先把新元素加到最后
pq[N] = e;
// 然后让它上浮到正确的位置
swim(N);
}
delMax方法先把堆顶元素 A 和堆底最后的元素 B 对调,然后删除 A,最后让 B 下沉到正确位置。
public Key delMax() {
// 最大堆的堆顶就是最大元素
Key max = pq[1];
// 把这个最大元素换到最后,删除之
exch(1, N);
pq[N] = null;
N--;
// 让 pq[1] 下沉到正确位置
sink(1);
return max;
}
至此,一个优先级队列就实现了,插入和删除元素的时间复杂度为 O(logK),K为当前二叉堆(优先级队列)中的元素总数。因为我们时间复杂度主要花费在sink或者swim上,而不管上浮还是下沉,最多也就树(堆)的高度,也就是 log 级别。
1.5 堆排序【重点】
堆排序过程:
其中,堆排序包括两个部分:
①堆的(自右向左、自下而上的下沉)构造;
②堆的删除,逐渐排出 大顶堆的递减、小顶堆的递增顺序。
堆排序属于
选择排序
。
二叉堆:本质上的实现是——一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使用数组的第一个位置)。
不用第一个位置、因为底层用
vector<T>
实现,方便完全二叉树索引找到:位置k
结点的父节点的位置为向下取整k/2
;而它的两个子节点的位置分别为2k
和2k+1
.
二叉堆:插入元素
和删除最大元素
的操作用时 和 队列的大小(N)成对数关系——lg2(N)
/lg(N)
承接上浮和下沉时间复杂度分析:对于一个含有
N
个元素的基于堆的优先队列,插入元素操作只需不超过(lgN+1)
次比较,删除最大元素操作需要不超过2lgN
次比较。(一次用来找出较大的子结点,一次用来确定孩子节点是否需要上浮。)参考:P202命题Q
堆排序的堆的构造时间:用下沉操作由N个元素构造堆只需少于
2N
次比较以及少于N
次交换。
参考:P206命题R=推导出P208命题S=>将N
个元素排序,堆排序只需少于2NlgN+2N
次比较(以及一半次数的交换)。
1.6 最后总结
二叉堆就是一种完全二叉树,所以适合存储在数组中,而且二叉堆拥有一些特殊性质。
二叉堆的操作很简单,主要就是上浮和下沉,来维护堆的性质(堆有序),核心代码也就十行。
优先级队列是基于二叉堆实现的,主要操作是插入和删除。插入是先插到最后,然后上浮到正确位置;删除是把第一个元素 pq[1](最值)调换到最后再删除,然后把新的 pq[1] 下沉到正确位置。核心代码也就十行。
也许这就是数据结构的威力,简单的操作就能实现巧妙的功能,真心佩服发明二叉堆算法的人!
PS:本文的动画示例参考自经典书籍《算法第 4 版》
二、算法题就像搭乐高:手把手带你拆解 LRU 算法
数据结构的设计秒啊!map里面存放的 iterator!让我对 迭代器的理解、嵌套和数据结构的设计更近了一步!牛的~
本文为 下一节 LFU 算法拆解与实现 做个预热。
LRU 算法就是一种缓存淘汰策略,原理不难,但是面试中写出没有 bug 的算法比较有技巧,需要对数据结构进行层层抽象和拆解,本文参考 labuladong + 《算法第四版》就给你写一手漂亮的代码。
计算机的缓存容量有限,如果缓存满了就要删除一些内容,给新内容腾位置。但问题是,删除哪些内容呢?我们肯定希望删掉哪些没什么用的缓存,而把有用的数据继续留在缓存里,方便之后继续使用。那么,什么样的数据,我们判定为「有用的」的数据呢?
LRU 的全称是 Least Recently Used,也就是说我们认为最近使用过的数据应该是是「有用的」,很久都没用过的数据应该是无用的,内存满了就优先删那些很久没用过的数据。
举个简单的例子,安卓手机都可以把软件放到后台运行,比如我先后打开了「设置」「手机管家」「日历」,那么现在他们在后台排列的顺序是这样的:
但是这时候如果我访问了一下「设置」界面,那么「设置」就会被提前到第一个,变成这样:
假设我的手机只允许我同时开 3 个应用程序,现在已经满了。那么如果我新开了一个应用「时钟」,就必须关闭一个应用为「时钟」腾出一个位置,关那个呢?
按照 LRU 的策略,就关最底下的「手机管家」,因为那是最久未使用的,然后把新开的应用放到最上面:
现在你应该理解 LRU(Least Recently Used)策略了。当然还有其他缓存淘汰策略,比如不要按访问的时序来淘汰,而是按访问频率(LFU 策略)来淘汰等等,各有应用场景。本文讲解 LRU 算法策略。
2.1 LRU 算法描述
力扣第 146 题「LRU缓存机制」就是让你设计数据结构:
首先要接收一个 capacity 参数作为缓存的最大容量,然后实现两个 API,一个是 put(key, val) 方法存入键值对,另一个是 get(key) 方法获取 key 对应的 val,如果 key 不存在则返回 -1。
注意哦,get 和 put 方法必须都是 O(1) 的时间复杂度,我们举个具体例子来看看 LRU 算法怎么工作。
/* 缓存容量为 2 */
LRUCache cache = new LRUCache(2);
// 你可以把 cache 理解成一个队列
// 假设左边是队头,右边是队尾
// 最近使用的排在队头,久未使用的排在队尾
// 圆括号表示键值对 (key, val)
cache.put(1, 1);
// cache = [(1, 1)]
cache.put(2, 2);
// cache = [(2, 2), (1, 1)]
cache.get(1); // 返回 1
// cache = [(1, 1), (2, 2)]
// 解释:因为最近访问了键 1,所以提前至队头
// 返回键 1 对应的值 1
cache.put(3, 3);
// cache = [(3, 3), (1, 1)]
// 解释:缓存容量已满,需要删除内容空出位置
// 优先删除久未使用的数据,也就是队尾的数据
// 然后把新的数据插入队头
cache.get(2); // 返回 -1 (未找到)
// cache = [(3, 3), (1, 1)]
// 解释:cache 中不存在键为 2 的数据
cache.put(1, 4);
// cache = [(1, 4), (3, 3)]
// 解释:键 1 已存在,把原始值 1 覆盖为 4
// 不要忘了也要将键值对提前到队头
2.2 LRU 算法设计
分析上面的操作过程,要让 put 和 get 方法的时间复杂度为 O(1),我们可以总结出 cache 这个数据结构必要的条件:
-
显然 cache 中的元素必须有时序,以区分最近使用的和久未使用的数据,当容量满了之后要删除最久未使用的那个元素腾位置。
-
我们要在 cache 中快速找某个 key 是否已存在并得到对应的 val;
-
每次访问 cache 中的某个 key,需要将这个元素变为最近使用的,也就是说 cache 要支持在任意位置快速插入和删除元素。
那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表 LinkedHashMap
。
LRU 缓存算法的核心数据结构就是哈希链表,双向链表和哈希表的结合体。这个数据结构长这样:
HashLinkedList; 下面是双向链表——存放迭代器;链表中也使用key和value存放,因为这样方便 从list 返回map时回应。
借助这个结构,我们来逐一分析上面的 3 个条件:
-
如果我们每次默认从链表尾部添加元素,那么显然越靠尾部的元素就是最近使用的,越靠头部的元素就是最久未使用的。
-
对于某一个 key,我们可以通过哈希表快速定位到链表中的节点,从而取得对应 val。
-
链表显然是支持在任意位置快速插入和删除的,改改指针就行。只不过传统的链表无法按照索引快速访问某一个位置的元素,而这里借助哈希表,可以通过 key 快速映射到任意一个链表节点,然后进行插入和删除。
2.3 代码实现
很多编程语言都有内置的哈希链表或者类似 LRU 功能的库函数(如:Java内置的 LinkedHashMap),以及dong哥从头到尾自造轮子实现的Java版本的LRU(很好的体现了实现细节)。
此处我们使用c++ STL中的list+hash_map 实现LRU。
思路如下
[c++] hash + list
就不自己写双链表了,用 c++ 自带的就行。
保持把新鲜数据往链表头移动。新鲜的定义:刚被修改(put),或者访问过(get),就算新鲜,就需要 splice 到链表头。
过期键直接 pop_back(),链表节点越往后,越陈旧。
代码要领:
map 中保存的是 <key, 链表节点的指针>,这样查找的时候就不用需要去遍历链表了,使用 unordered_map 就能很快找到链表节点指针。
判断容量的时候,最好不使用 std::list::size() 方法,在 c++ 里,这个方法可能不是 O(1) 的。
备注:
Constant or linear. (until C++11)
Constant. (since C++11)
我的题解
/**
* @Description:
* @param {*}
* @return {*}
* @notes: 关键:DoubleLinkedHashmap —— 尾入头出。
*/
class LRUCache {
public:
LRUCache(int capacity) {
this->_n = capacity;
}
int get(int key) {
auto it = _mapHash.find(key);
if(it != _mapHash.end()){
// 得到val,并转移到链表尾
int ans = it->second->second; // 相当于指针 已经指向了具体的位置 pair
_list.splice(_list.end(), _list, it->second); // 指向的当前查询的数值 插入到链表尾前面。
return ans;
}else{ // 不存在
return -1;
}
}
void put(int key, int value) {
auto it = _mapHash.find(key);
if(it != _mapHash.end()) { // 存在则变更数值,并放置到链表尾
it->second->second = value;
_list.splice(_list.end(), _list, it->second); // 指向的当前查询的数值 插入到链表尾前面。
return ;
}
// 不存在,两种情况 超出、未超出
if(_list.size() < _n){ // 未超出
// 直接插入到尾部
_list.push_back(make_pair(key, value));
// map新增
_mapHash.emplace(key, prev(_list.end(), 1));
return ;
}else{ // 超出
// 删除list头, 删除map key
_mapHash.erase(_list.front().first); // gai cuole1! 应该删除开头第一个!
_list.pop_front();
// 其它的和上面一样了。
_list.push_back(make_pair(key, value));
// map新增
_mapHash.emplace(key, prev(_list.end(), 1));
return ;
}
}
private:
unordered_map<int, list<pair<int, int>>::iterator> _mapHash; // 为存储 iterator 这个方法夭折了。
list<pair<int,int>> _list;
int _n; // 总的承受量
};
/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache* obj = new LRUCache(capacity);
* int param_1 = obj->get(key);
* obj->put(key,value);
*/
2.4 小结
- 数据结构的设计题目中,一方面要根据题目要的算法特性去选择 数据结构,另一方面 结合数据结构的优缺点 有的放矢的学会去再创造和熟练使用。
- 因此,这就需要我们平时对于
STL
模板的深层理解和API的使用程度。【一定要多看、多练、多想!】 - 此处学到了:①map中value储存
iterator
,相当于地址了,方便于操作【因此要掌握,什么时候下 迭代器会被摧毁】。②多种数据结构的嵌套、指向……要明确【可以画出来!方便快速索引】。
三、算法题就像搭乐高:手把手带你拆解 LFU 算法
上篇文章 算法题就像搭乐高:手把手带你拆解 LRU 算法 写了 LRU 缓存淘汰算法的实现方法,本文来写另一个著名的缓存淘汰算法:LFU 算法。
从实现难度上来说,LFU 算法的难度大于 LRU 算法,因为 LRU 算法相当于把数据按照时间排序,这个需求借助链表很自然就能实现,你一直从链表头部加入元素的话,越靠近头部的元素就是新的数据,越靠近尾部的元素就是旧的数据,我们进行缓存淘汰的时候只要简单地将尾部的元素淘汰掉就行了。
而 LFU 算法相当于是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。把数据按照访问频次进行排序,而且频次还会不断变化,这可不容易实现。
所以说 LFU 算法要复杂很多,dong哥进字节跳动的时候就被面试官问到了 LFU 算法。
话说回来,这种著名的算法的套路都是固定的,关键是由于逻辑较复杂,不容易写出漂亮且没有 bug 的代码。
那么本文 labuladong 就带你拆解 LFU 算法,自顶向下,逐步求精。
3.1 算法描述
要求你写一个类,接受一个capacity参数,实现get和put方法:
class LFUCache {
// 构造容量为 capacity 的缓存
public LFUCache(int capacity) {}
// 在缓存中查询 key
public int get(int key) {}
// 将 key 和 val 存入缓存
public void put(int key, int val) {}
}
get(key)方法会去缓存中查询键key,如果key存在,则返回key对应的val,否则返回 -1。
put(key, value)方法插入或修改缓存。如果key已存在,则将它对应的值改为val;如果key不存在,则插入键值对(key, val)。
当缓存达到容量capacity时,则应该在插入新的键值对之前,删除使用频次(后文用freq表示)最低的键值对。如果freq最低的键值对有多个,则删除其中最旧的那个。
// 构造一个容量为 2 的 LFU 缓存
LFUCache cache = new LFUCache(2);
// 插入两对 (key, val),对应的 freq 为 1
cache.put(1, 10);
cache.put(2, 20);
// 查询 key 为 1 对应的 val
// 返回 10,同时键 1 对应的 freq 变为 2
cache.get(1);
// 容量已满,淘汰 freq 最小的键 2
// 插入键值对 (3, 30),对应的 freq 为 1
cache.put(3, 30);
// 键 2 已经被淘汰删除,返回 -1
cache.get(2);
3.2 思路分析
参考1:官方两种方法的第二种方法。
参考2:图片参考这个。
一定先从最简单的开始,根据 LFU 算法的逻辑,我们分析需要的数据结构以及 需要添加的 struct和minFre等。
我们定义两个哈希表,第一个 freq_table 以频率 freq 为索引,每个索引存放一个双向链表,这个链表里存放所有使用频率为 freq 的缓存,缓存里存放三个信息,分别为键 key,值 value,以及使用频率 freq。第二个 key_table 以键值 key 为索引,每个索引存放对应缓存在 freq_table 中链表里的内存地址,这样我们就能利用两个哈希表来使得两个操作的时间复杂度均为 O(1)O(1)O(1)。同时需要记录一个当前缓存最少使用的频率 minFreq,这是为了删除操作服务的。
3.3 代码框架
对于 get(key) 操作,我们能通过索引 key 在 key_table 中找到缓存在 freq_table 中的链表的内存地址,如果不存在直接返回 -1,否则我们能获取到对应缓存的相关信息,这样我们就能知道缓存的键值还有使用频率,直接返回 key 对应的值即可。
但是我们注意到 get 操作后这个缓存的使用频率加一了,所以我们需要更新缓存在哈希表 freq_table 中的位置。已知这个缓存的键 key,值 value,以及使用频率 freq,那么该缓存应该存放到 freq_table 中 freq + 1 索引下的链表中。所以我们在当前链表中 O(1)O(1)O(1) 删除该缓存对应的节点,根据情况更新 minFreq 值,然后将其O(1)O(1)O(1) 插入到 freq + 1 索引下的链表头完成更新。这其中的操作复杂度均为 O(1)O(1)O(1)。你可能会疑惑更新的时候为什么是插入到链表头,这其实是为了保证缓存在当前链表中从链表头到链表尾的插入时间是有序的,为下面的删除操作服务。
对于 put(key, value) 操作,我们先通过索引 key在 key_table 中查看是否有对应的缓存,如果有的话,其实操作等价于 get(key) 操作,唯一的区别就是我们需要将当前的缓存里的值更新为 value。如果没有的话,相当于是新加入的缓存,如果缓存已经到达容量,需要先删除最近最少使用的缓存,再进行插入。
先考虑插入,由于是新插入的,所以缓存的使用频率一定是 1,所以我们将缓存的信息插入到 freq_table 中 1 索引下的列表头即可,同时更新 key_table[key] 的信息,以及更新 minFreq = 1。
那么剩下的就是删除操作了,由于我们实时维护了 minFreq,所以我们能够知道 freq_table 里目前最少使用频率的索引,同时因为我们保证了链表中从链表头到链表尾的插入时间是有序的,所以 freq_table[minFreq] 的链表中链表尾的节点即为使用频率最小且插入时间最早的节点,我们删除它同时根据情况更新 minFreq ,整个时间复杂度均为 O(1)O(1)O(1)。
3.4 代码实现
3.4.1 基于LRU的简单实现
/**
* @Description:
* @param {*}
* @return {*}
* @notes: 头出尾入 ; 但是还是要看 当前fre和新旧。关键:新旧通过 get() put() 进行比较选择排序。比较慢。
* 所以有了方法二,主要瓶颈在于 fre大小的查找,所以通过 使用另一个HashMap来存放 多个双向链表(每个fre下有一个链接)。O(1)即可找到。
*/
class LFUCache {
public:
LFUCache(int capacity) {
_n = capacity;
}
int get(int key) {
auto it = _mapHash.find(key);
if(it != _mapHash.end()){
// 得到val,并添加1个fre 寻找插入位置。
int ans = it->second->value; // 相当于指针 已经指向了具体的位置 pair
it->second->fre++;
// 开始更换位置
list<Node>::iterator it_node = it->second;
auto it_next_node = next(it_node, 1);
while(it_next_node != _list.end() && *it_node >= *it_next_node){
// 频率大的时候
it_next_node++;
}
// 当前频率下 插入到next前面
_list.splice(it_next_node, _list, it_node);
// 结束更换位置
return ans;
}else{ // 不存在
return -1;
}
}
void put(int key, int value) {
auto it = _mapHash.find(key);
if(it != _mapHash.end()) { // 存在则变更数值,fre++ 更换位置
it->second->value = value;
it->second->fre++;
// 开始更换位置
list<Node>::iterator it_node = it->second;
auto it_next_node = next(it_node, 1);
while(it_next_node != _list.end() && *it_node >= *it_next_node){
// 频率大的时候
it_next_node++;
}
// 当前频率下 插入到next前面
_list.splice(it_next_node, _list, it_node);
// 结束更换位置
return ;
}
// 不存在,两种情况 未超出和超出
if(_list.size() < _n){ // 未超出
// 直接插入到头部或再往前!因为自己为新的
Node *tmp_node = new Node(key, value, 1); // 新的 所以>=
// 开始更换位置
list<Node>::iterator it_next_node = _list.begin();
while(it_next_node != _list.end() && *tmp_node >= *it_next_node){
// 频率大的时候
it_next_node++;
}
// 当前频率下 插入到next前面
auto it_now = _list.insert(it_next_node, *tmp_node);
// 结束更换位置
// map新增
_mapHash.emplace(key, it_now);
return ;
}else{ // 超出
// 0 超出,不存在可以插入的
if(this->_n == 0)
return ;
// 删除list的头部fre低、不新的; 接着erase map中的key
_mapHash.erase(_list.front().key);
_list.pop_front();
// 直接插入到头部或再往前!因为自己为新的
Node *tmp_node = new Node(key, value, 1); // 新的 所以>=
// 开始更换位置
list<Node>::iterator it_next_node = _list.begin();
while(it_next_node != _list.end() && *tmp_node >= *it_next_node){
// 频率大的时候
it_next_node++;
}
// 当前频率下 插入到next前面
auto it_now = _list.insert(it_next_node, *tmp_node);
// 结束更换位置
// map新增
_mapHash.emplace(key, it_now);
return ;
}
}
private:
struct Node{
int key, value, fre;
Node(int _key, int _value, int _fre) : key(_key), value(_value), fre(_fre) {
} // 自动构造函数
bool operator>(const Node& bnt){
return this->fre > bnt.fre;
}
bool operator>=(const Node& bnt){
return this->fre >= bnt.fre;
}
};
unordered_map<int, list<Node>::iterator> _mapHash; // 存储list的地址,方便进行移动
list<Node> _list; // 维持一个从尾到头 有序的双向链表
int _n; // 总的承受量
};
3.4.2 基于层层分解高效解法
/**
* @Description: 方法二: 双哈希表,一个key_table 用来存放key和对应的node迭代器;另一个 fre_table 用来存放fre键和 对应的真实 双向链表list。
* 所以有了方法二,主要瓶颈在于 fre大小的查找,所以通过 使用另一个HashMap来存放 多个双向链表(每个fre下有一个链接)。O(1)即可找到。
* @param {*}
* @return {*}
* @notes:
*/
// 缓存的节点信息
class LFUCache {
private:
struct Node {
int key, val, freq;
Node(int _key,int _val,int _freq): key(_key), val(_val), freq(_freq){}
};
int minfreq, capacity;
unordered_map<int, list<Node>::iterator> key_table;
unordered_map<int, list<Node>> freq_table;
public:
LFUCache(int _capacity) {
minfreq = 0;
capacity = _capacity;
key_table.clear();
freq_table.clear();
}
int get(int key) {
if (capacity == 0) return -1;
auto it = key_table.find(key);
if (it == key_table.end()) return -1;
// 存在
list<Node>::iterator node = it -> second;
int val = node -> val, freq = node -> freq;
freq_table[freq].erase(node);
// 如果当前链表为空,我们需要在哈希表中删除,且更新minFreq
if (freq_table[freq].size() == 0) {
freq_table.erase(freq);
if (minfreq == freq) minfreq += 1;
}
// 插入到 freq + 1 中
freq_table[freq + 1].push_front(Node(key, val, freq + 1));
key_table[key] = freq_table[freq + 1].begin(); // 注意这里!
return val;
}
void put(int key, int value) {
if (capacity == 0) return;
auto it = key_table.find(key);
if (it == key_table.end()) { // 不存在这个key
// 缓存已满,需要进行删除操作
if (key_table.size() == capacity) {
// 通过 minFreq 拿到 freq_table[minFreq] 链表的末尾节点
auto it2 = freq_table[minfreq].back();
key_table.erase(it2.key); // 删除1
freq_table[minfreq].pop_back(); // 删除2
if (freq_table[minfreq].size() == 0) { // 删除3
freq_table.erase(minfreq);
}
}
freq_table[1].push_front(Node(key, value, 1));
key_table[key] = freq_table[1].begin();
minfreq = 1;
} else { //存在这个key
// 与 get 操作基本一致,除了需要更新缓存的值
list<Node>::iterator node = it -> second;
int freq = node -> freq;
freq_table[freq].erase(node); // 破坏了当前iterator,下面 新建了。
if (freq_table[freq].size() == 0) {
freq_table.erase(freq);
if (minfreq == freq) minfreq += 1;
}
freq_table[freq + 1].push_front(Node(key, value, freq + 1));
key_table[key] = freq_table[freq + 1].begin();
}
}
};
至此,经过层层拆解,LFU 算法就完成了。
剩余 union-find 和 单调栈、单调队列。。以及综合。3+1+1 == 5个
四、单调栈
单调栈实际上就是栈,只是利用了一些巧妙的逻辑,使得每次新元素入栈后,栈内的元素都保持有序(单调递增或单调递减)。
单调栈和单调队列都是保持了一些有序性, 一个从后向前解决下一个更大元素问题;另一个解决滑动窗口、每次向前挤压
小的。
核心:都是要将比自己小的 pop出来!要有 有序性。
两道leetcode题目:
这个问题可以这样抽象思考:把数组的元素想象成并列站立的人,元素大小想象成人的身高。这些人面对你站成一列,如何求元素「2」的 Next Greater Number 呢?很简单,如果能够看到元素「2」,那么他后面可见的第一个人就是「2」的 Next Greater Number,因为比「2」小的元素身高不够,都被「2」挡住了,第一个露出来的就是答案。
我的题解
class Solution {
public:
/**
* @Description: 把握好单调栈 核心——每次都push但是 每次都将比自己小的pop 出去。
* @param {*}
* @return {*}
* @notes: 【用于解决 下一个更大的元素问题】
*/
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
unordered_map<int, int> ans; // 一个放置 nums[i], 另一个防止ans 下一个更大的数
stack<int> s; // 存储单调栈 一个比一个大。// 从后往前 一个个大
int n2 = nums2.size(), n1 = nums1.size();
for(int i = n2-1; i>=0 ;i--){
while(!s.empty() && s.top()<=nums2[i]){ // 将比当前值小或等于的统统 删掉。
s.pop();
}
ans[nums2[i]] = s.empty() ? -1 : s.top() ;
s.push(nums2[i]);
}
// nums1 中寻找并返回
vector<int> ans1(n1, -1);
for(int i = 0;i<n1;i++){
ans1[i] = ans[nums1[i]];
}
return ans1;
}
};
这个问题肯定还是要用单调栈的解题模板,但难点在于,比如输入是[2,1,2,4,3],对于最后一个元素 3,如何找到元素 4 作为 Next Greater Number。
对于这种需求,常用套路就是将数组长度翻倍:
这样,元素 3 就可以找到元素 4 作为 Next Greater Number 了,而且其他的元素都可以被正确地计算。
有了思路,最简单的实现方式当然可以把这个双倍长度的数组构造出来,然后套用算法模板。但是,我们可以不用构造新数组,而是利用循环数组的技巧来模拟数组长度翻倍的效果。
我的题解
class Solution {
public:
/**
* @Description: 循环 基于496题目,进行 2倍数组扩展的% 取余模拟计算。下一个更大值。
* @param {*}
* @return {*}
* @notes: 使用单调栈。
*/
vector<int> nextGreaterElements(vector<int>& nums) {
int n = nums.size();
vector<int> ans(n, -1); // 注意 vector是固定长度的,需要预先分配好地址!
stack<int> s;
for(int i = 2*n-1; i>=0 ;i--){
while(!s.empty() && s.top()<=nums[i%n]){ // 将比当前值小或等于的统统 删掉。
s.pop();
}
ans[i%n] = s.empty() ? -1 : s.top();
s.push(nums[i%n]);
}
return ans;
}
};
五、单调队列
也许这种数据结构的名字你没听过,其实没啥难的,就是一个「队列」,只是使用了一点巧妙的方法,使得队列中的元素全都是单调递增(或递减)的。
这道题不复杂,难点在于如何在O(1)时间算出每个「窗口」中的最大值,使得整个算法在线性时间完成。这种问题的一个特殊点在于,「窗口」是不断滑动的,也就是你得动态地计算窗口中的最大值。
对于这种动态的场景,很容易得到一个结论:
在一堆数字中,已知最值为A,如果给这堆数添加一个数B,那么比较一下A和B就可以立即算出新的最值;但如果减少一个数,就不能直接得到最值了,因为如果减少的这个数恰好是A,就需要遍历所有数重新找新的最值。
观察滑动窗口的过程就能发现,实现「单调队列」必须使用一种数据结构支持在头部和尾部进行插入和删除,很明显双链表是满足这个条件的。
「单调队列」的核心思路和「单调栈」类似,push方法依然在队尾添加元素,但是要把前面比自己小的元素都删掉:
class MonotonicQueue {
// 双链表,支持头部和尾部增删元素
private LinkedList<Integer> q = new LinkedList<>();
public void push(int n) {
// 将前面小于自己的元素都删除
while (!q.isEmpty() && q.getLast() < n) {
q.pollLast();
}
q.addLast(n);
}
}
你可以想象,加入数字的大小代表人的体重,把前面体重不足的都压扁了,直到遇到更大的量级才停住。
如果每个元素被加入时都这样操作,最终单调队列中的元素大小就会保持一个单调递减的顺序,因此我们的max方法可以可以这样写:
public int max() {
// 队头的元素肯定是最大的
return q.getFirst();
}
pop方法在队头删除元素n,也很好写:
public void pop(int n) {
if (n == q.getFirst()) {
q.pollFirst();
}
}
之所以要判断data.front() == n,是因为我们想删除的队头元素n可能已经被「压扁」了,可能已经不存在了,所以这时候就不用删除了:
方法一:实现单调队列; 方法二:直接用
deque
关键在于push
的时候需要删除比自己小的末尾的技巧。方法三:基于方法二差不多简洁一些。
我的题解:
/**
* @Description: 新建 单调队列结构,解决 每次都要把队列尾入头出,每次push也要把 比当前入队元素大的值出队。
* 这样就保证了队的开头是最大的了。
* @param {*}
* @return {*}
* @notes: 【关键】 败在了push的删除上面!!!
*/
class MonoQueue
{
public:
void push(int num)
{
// 【核心部分】
// 队尾进, 一直压扁小于自己的
// list<int>::reverse_iterator rit = this->queue.rbegin();
// for(;rit!=this->queue.rend();++rit){
// if(*rit > num){
// break;
// }
// // 其它的直接pop
// this->queue.pop_back();
// }
// list<int>::iterator it=this->queue.begin();
// for(; it!=this->queue.end() ;it++){
// if(*it<num){
// break;
// }
// }
// // 直接在末尾加
// this->queue.insert(it, num);
// 巧妙地删除【关键!!!】
while (!queue.empty() && queue.back() < num)
{
this->queue.pop_back();
}
this->queue.push_back(num);
}
void pop(int num)
{
// 必要的加! 因为防止出队的是下一个大的, 出队应该出上一次已成的最大的。
if (this->queue.front() == num)
{
this->queue.pop_front();
}
}
int size()
{
return this->queue.size();
}
int max()
{
// 对头即为最大
return this->queue.front();
}
private:
// 内部使用 双向链表实现
list<int> queue;
};
class Solution
{
public:
/**
* @Description: 先将k个数组数据入队,再将 剩下的一步步比较和入队。
* @param {int} k
* @return {*}
* @notes:
*/
vector<int> maxSlidingWindow(vector<int> &nums, int k)
{
int all_in_ans = nums.size() - k + 1;
vector<int> ans(all_in_ans, 0);
MonoQueue *m_queue = new MonoQueue();
for (int i = 0; i < k; i++)
{
m_queue->push(nums[i]);
}
ans[0] = m_queue->max();
// 剩下的——开始滑动
for (int i = k; i < nums.size(); i++)
{
m_queue->pop(nums[i - k]);
m_queue->push(nums[i]);
ans[i - k + 1] = m_queue->max();
}
return ans;
}
};
/**
* @Description: 方法二: 单调队列——直接使用 deque 实现
* @param {*}
* @return {*}
* @notes:
*/
class Solution
{
public:
/**
* @Description: 先将k个数组数据入队,再将 剩下的一步步比较和入队。
* @param {int} k
* @return {*}
* @notes:
*/
vector<int> maxSlidingWindow(vector<int> &nums, int k)
{
int all_in_ans = nums.size() - k + 1;
vector<int> ans(all_in_ans, 0);
deque<int> q;
for (int i = 0; i < k; i++)
{
while (!q.empty() && q.back() < nums[i])
{
q.pop_back();
}
q.push_back(nums[i]);
}
ans[0] = q.front();
// 剩下的——开始滑动
for (int i = k; i < nums.size(); i++)
{
if(!q.empty() && q.front() == nums[i-k]) q.pop_front();
while (!q.empty() && q.back() < nums[i])
{
q.pop_back();
}
q.push_back(nums[i]);
ans[i - k + 1] = q.front();
}
return ans;
}
};
/**
* @Description: 方法三: 基于方法二 合并在一起。
* @param {*}
* @return {*}
* @notes:
*/
class Solution
{
public:
/**
* @Description: 先将k个数组数据入队,再将 剩下的一步步比较和入队。
* @param {int} k
* @return {*}
* @notes:
*/
vector<int> maxSlidingWindow(vector<int> &nums, int k)
{
int all_in_ans = nums.size() - k + 1;
vector<int> ans(all_in_ans, 0);
deque<int> q;
for (int i = 0; i < nums.size(); i++)
{
if(i>=k) { // pop
if(!q.empty() && q.front() == nums[i-k]) q.pop_front();
}
while (!q.empty() && q.back() < nums[i])
{
q.pop_back();
}
q.push_back(nums[i]);
if(i>=k-1){
ans[i - k + 1] = q.front();
}
}
return ans;
}
};
小结-复杂度
读者可能疑惑,push操作中含有 while 循环,时间复杂度应该不是O(1)呀,那么本算法的时间复杂度应该不是线性时间吧?
单独看push操作的复杂度确实不是O(1),但是算法整体的复杂度依然是O(N)线性时间。要这样想,nums中的每个元素最多被push_back和pop_back一次,没有任何多余操作,所以整体的复杂度还是O(N)。
空间复杂度就很简单了,就是窗口的大小O(k)。
六、Union-Find(并查集)
先介绍下并查集的C++实现,和优化过程。
再使用并查集完成一些题目。
6.1 UnionFind算法详解
Union-Find 算法,也就是常说的并查集算法,主要是解决图论中「动态连通性」问题的。名词很高端,其实特别好理解,等会解释,另外这个算法的应用都非常有趣。
说起这个 Union-Find,应该算是我的「启蒙算法」了,因为《算法4》的开头就介绍了这款算法,可是把我秀翻了,感觉好精妙啊!后来刷了 LeetCode,并查集相关的算法题目都非常有意思,而且《算法4》给的解法竟然还可以进一步优化,只要加一个微小的修改就可以把时间复杂度降到 O(1)。
废话不多说,直接上干货。先解释一下什么叫动态连通性吧。
6.1.1 问题介绍
简单说,动态连通性其实可以抽象成给一幅图连线。比如下面这幅图,总共有 10 个节点,他们互不相连,分别用 0~9 标记:
现在我们的 Union-Find 算法主要需要实现这两个 API:
class UF {
/* 将 p 和 q 连接 */
public void union(int p, int q);
/* 判断 p 和 q 是否连通 */
public boolean connected(int p, int q);
/* 返回图中有多少个连通分量 */
public int count();
}
这里所说的「连通」是一种等价关系,也就是说具有如下三个性质:
1、自反性:节点p和p是连通的。
2、对称性:如果节点p和q连通,那么q和p也连通。
3、传递性:如果节点p和q连通,q和r连通,那么p和r也连通。
比如说之前那幅图,0~9 任意两个不同的点都不连通,调用connected都会返回 false,连通分量为 10 个。
如果现在调用union(0, 1),那么 0 和 1 被连通,连通分量降为 9 个。
再调用union(1, 2),这时 0,1,2 都被连通,调用connected(0, 2)也会返回 true,连通分量变为 8 个。
判断这种「等价关系」非常实用,比如说编译器判断同一个变量的不同引用,比如社交网络中的朋友圈计算等等。
这样,你应该大概明白什么是动态连通性了,Union-Find 算法的关键就在于union和connected函数的效率。那么用什么模型来表示这幅图的连通状态呢?用什么数据结构来实现代码呢?
6.1.2 基本思路
注意我刚才把「模型」和具体的「数据结构」分开说,这么做是有原因的。因为我们使用森林(若干棵树)来表示图的动态连通性,用数组来具体实现这个森林。
怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。
比如说刚才那幅 10 个节点的图,一开始的时候没有相互连通,就是这样:
class UF {
// 记录连通分量
private int count;
// 节点 x 的节点是 parent[x]
private int[] parent;
/* 构造函数,n 为图的节点总数 */
public UF(int n) {
// 一开始互不连通
this.count = n;
// 父节点指针初始指向自己
parent = new int[n];
for (int i = 0; i < n; i++)
parent[i] = i;
}
/* 其他函数 */
}
如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上:
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也一样
count--; // 两个分量合二为一
}
/* 返回某个节点 x 的根节点 */
private int find(int x) {
// 根节点的 parent[x] == x
while (parent[x] != x)
x = parent[x];
return x;
}
/* 返回当前的连通分量个数 */
public int count() {
return count;
}
这样,如果节点p和q连通的话,它们一定拥有相同的根节点:
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
至此,Union-Find 算法就基本完成了。是不是很神奇?竟然可以这样使用数组来模拟出一个森林,如此巧妙的解决这个比较复杂的问题!
那么这个算法的复杂度是多少呢?我们发现,主要 APIconnected和union中的复杂度都是find函数造成的,所以说它们的复杂度和find一样。
find主要功能就是从某个节点向上遍历到树根,其时间复杂度就是树的高度。我们可能习惯性地认为树的高度就是logN,但这并不一定。logN的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成N。
所以说上面这种解法,find,union,connected的时间复杂度都是 O(N)。这个复杂度很不理想的,你想图论解决的都是诸如社交网络这样数据规模巨大的问题,对于union和connected的调用非常频繁,每次调用需要线性时间完全不可忍受。
**问题的关键在于,如何想办法避免树的不平衡呢?**只需要略施小计即可。
6.1.3 平衡性优化
我们要知道哪种情况下可能出现不平衡现象,关键在于union过程:
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也可以
count--;
我们一开始就是简单粗暴的把p所在的树接到q所在的树的根节点下面,那么这里就可能出现「头重脚轻」的不平衡状况,比如下面这种局面:
长此以往,树可能生长得很不平衡。我们其实是希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些。 解决方法是额外使用一个size数组,记录每棵树包含的节点数,我们不妨称为「重量」:
class UF {
private int count;
private int[] parent;
// 新增一个数组记录树的“重量”
private int[] size;
public UF(int n) {
this.count = n;
parent = new int[n];
// 最初每棵树只有一个节点
// 重量应该初始化 1
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
/* 其他函数 */
}
比如说size[3] = 5表示,以节点3为根的那棵树,总共有5个节点。这样我们可以修改一下union方法:
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 小树接到大树下面,较平衡
if (size[rootP] > size[rootQ]) {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
} else {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
}
count--;
}
这样,通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在logN这个数量级,极大提升执行效率。
此时,find,union,connected的时间复杂度都下降为 O(logN),即便数据规模上亿,所需时间也非常少。
6.1.4 路径压缩
这步优化特别简单,所以非常巧妙。我们能不能进一步压缩每棵树的高度,使树高始终保持为常数?
这样find就能以 O(1) 的时间找到某一节点的根节点,相应的,connected和union复杂度都下降为 O(1)。
要做到这一点,非常简单,只需要在find中加一行代码:
private int find(int x) {
while (parent[x] != x) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
这个操作有点匪夷所思,看个下面的过程图 就明白它的作用了(为清晰起见,这棵树比较极端):
可见,调用find函数每次向树根遍历的同时,顺手将树高缩短了,最终所有树高都不会超过 3(union的时候树高可能达到 3)。
PS:读者可能会问,这个 GIF 图的find过程完成之后,树高恰好等于 3 了,但是如果更高的树,压缩后高度依然会大于 3 呀?不能这么想。这个 GIF 的情景是我编出来方便大家理解路径压缩的,但是实际中,每次find都会进行路径压缩,所以树本来就不可能增长到这么高,你的这种担心应该是多余的。
6.1.5 最后总结
我们先来看一下完整代码:
class UF {
// 连通分量个数
private int count;
// 存储一棵树
private int[] parent;
// 记录树的“重量”
private int[] size;
public UF(int n) {
this.count = n;
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 小树接到大树下面,较平衡
if (size[rootP] > size[rootQ]) {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
} else {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
}
count--;
}
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
private int find(int x) {
while (parent[x] != x) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
}
Union-Find 算法的复杂度可以这样分析:构造函数初始化数据结构需要 O(N) 的时间和空间复杂度;连通两个节点union
、判断两个节点的连通性connected
、计算连通分量count
所需的时间复杂度均为 O(1)。
6.1.6 我的小结
主要用来:①分门别类;②过程中可 动态连接;③最后看 在不同类的进行处理。
我的实现C++
/**
* @Description: [用法]分类 、 建立动态联通关系。
* @param {*}
* @return {*}
* @notes:
*/
/**
* @Description: 【关键】 注意每次 重量都要去改变。
* @param {*}
* @return {*}
* @notes:
*/
class UnionFind
{
public:
UnionFind(int n)
{
this->cnt = n;
// 动态数组内存分配
this->parent = new int[n];
this->size = new int[n];
// this->parent = (int*)malloc(n*sizeof(int));
// this->size = (int*)malloc(n*sizeof(int));
// this->parent.resize(n); 也可以 积累!!!
// this->size.resize(n);
for (int i = 0; i < n; i++)
{
parent[i] = i;
size[i] = 1;
}
}
// ~UnionFind()
// {
// this->parent.clear();
// this->size.clear();
// }
/* 判断 p 和 q 是否连通 */
bool connected(int p, int q)
{
int rootP = find(p);
int rootQ = find(q);
// cout << rootP << "rootq:" << rootQ << endl;
return rootP == rootQ;
}
/* 返回图中有多少个连通分量 */
int count()
{
return this->cnt;
}
// 发现 父节点, 返回父节点
int find(int x)
{
// 经过三次修正 降低路径搜索难度
while (parent[x] != x)
{
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
// 将p和q连通
void unionRoot(int p, int q)
{
// 比较size,将轻的放在重的上面
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return; // cuo
if (size[rootP] > size[rootQ])
{
parent[rootQ] = rootP;
size[rootP] += size[rootQ]; // cuo
}
else
{
parent[rootP] = rootQ;
size[rootQ] += size[rootP]; //cuo
}
this->cnt--; // cuo
}
private:
int cnt; // 连通分量
int *parent; // 节点父节点索引 —— 方便并查集合并与使用
int *size; // 每个节点的重量是多少,方便 减少查找次数
};
6.2 UnionFind算法应用
将其他问题转换为使用
UnionFind
,这就要考察 我们 抽象题目、进行转换题目的能力了。
首先,Union-Find 算法解决的是图的动态连通性问题,这个算法本身不难,能不能应用出来主要是看你抽象问题的能力,是否能够把原始问题抽象成一个有关图论的问题。
算法的关键点有 3 个:
1、 用parent数组记录每个节点的父节点,相当于指向父节点的指针,所以parent数组内实际存储着一个森林(若干棵多叉树)。
2、 用size数组记录着每棵树的重量,目的是让union后树依然拥有平衡性,而不会退化成链表,影响操作效率。
3、 在find函数中进行路径压缩,保证任意树的高度保持在常数,使得union和connectedAPI 时间复杂度为 O(1)。
有的读者问,既然有了路径压缩,size数组的重量平衡还需要吗? 这个问题很有意思,因为路径压缩保证了树高为常数(不超过 3),那么树就算不平衡,高度也是常数,基本没什么影响。
我认为,论时间复杂度的话,确实,不需要重量平衡也是 O(1)。但是如果加上size数组辅助,效率还是略微高一些,比如下面这种情况:
如果带有重量平衡优化,一定会得到情况一,而不带重量优化,可能出现情况二。高度为 3 时才会触发路径压缩那个while循环,所以情况一根本不会触发路径压缩,而情况二会多执行很多次路径压缩,将第三层节点压缩到第二层。
也就是说,去掉重量平衡,虽然对于单个的find函数调用,时间复杂度依然是 O(1),但是对于 API 调用的整个过程,效率会有一定的下降。 当然,好处就是减少了一些空间,不过对于 Big O 表示法来说,时空复杂度都没变。
下面言归正传,来看看这个算法有什么实际应用。
6.2.1 DFS 的替代方案
很多使用 DFS 深度优先算法解决的问题,也可以用 Union-Find 算法解决。
比如第 130 题,被围绕的区域:给你一个 M×N 的二维矩阵,其中包含字符X和O,让你找到矩阵中完全被X围住的O,并且把它们替换成X。
void solve(char[][] board);
注意哦,必须是完全被围的O才能被换成X,也就是说边角上的O一定不会被围,进一步,与边角上的O相连的O也不会被X围四面,也不会被替换:
PS:这让我想起小时候玩的棋类游戏「黑白棋」,只要你用两个棋子把对方的棋子夹在中间,对方的子就被替换成你的子。可见,占据四角的棋子是无敌的,与其相连的边棋子也是无敌的(无法被夹掉)。
解决这个问题的传统方法也不困难,先用 for 循环遍历棋盘的四边,用 DFS 算法把那些与边界相连的O换成一个特殊字符,比如#;然后再遍历整个棋盘,把剩下的O换成X,把#恢复成O。这样就能完成题目的要求,时间复杂度 O(MN)。
这个问题也可以用 Union-Find 算法解决,虽然实现复杂一些,甚至效率也略低,但这是使用 Union-Find 算法的通用思想,值得一学。
你可以把那些不需要被替换的O看成一个拥有独门绝技的门派,它们有一个共同祖师爷叫dummy,这些O和dummy互相连通,而那些需要被替换的O与dummy不连通。
这就是 Union-Find 的核心思路,明白这个图,就很容易看懂代码了:
首先要解决的是,根据我们的实现,Union-Find 底层用的是一维数组,构造函数需要传入这个数组的大小,而题目给的是一个二维棋盘。
这个很简单, 二维坐标(x,y)可以转换成x * n + y这个数(m是棋盘的行数,n是棋盘的列数)。敲黑板,这是将二维坐标映射到一维的常用技巧。
其次,我们之前描述的「祖师爷」是虚构的,需要给他老人家留个位置。索引[0… mn-1]都是棋盘内坐标的一维映射,那就让这个虚拟的dummy节点占据索引mn好了。
/**
* @Description: 大材小用 —— 并查集分类 并动态连接 边界处的O为一类;虚拟出一个 dummy节点 进行处理分类。
* @param {*}
* @return {*}
* @notes: 我们的思路是把所有边界上的 OO 看做一个连通区域。遇到 OO 就执行并查集合并操作,这样所有的 OO 就会被分成两类
和边界上的 OO 在一个连通区域内的。这些 OO 我们保留。
不和边界上的 OO 在一个连通区域内的。这些 OO 就是被包围的,替换。
由于并查集我们一般用一维数组来记录,方便查找 parants,所以我们将二维坐标用 node 函数转化为一维坐标。
*/
class Solution {
public:
/**
* @Description: 首先将 边界处的O连通到dummy上;然后 附近的O与四周的O连通; 最后边界上一类的O不变,其它的O 变为X.
* @param {*}
* @return {*}
* @notes:
*/
void solve(vector<vector<char>>& board) {
vector<int> direction = {-1, 0, 1, 0, -1};
int m = board.size(), n = board[0].size();
UnionFind *unionSearch = new UnionFind(m*n+1);
int dummy = m*n;
for(int i = 0; i<m ;i++){
for(int j = 0; j<n ;j++){
if(board[i][j] == 'O'){
if( i == 0 || i==(m-1)||j==0||j==(n-1)){
// 边界处
unionSearch->unionRoot(dummy, node(i,j,n));
}else{
// 非边界处,与四周的O相连接
for(int k = 0;k<4;k++){
int iNew=i+direction[k], jNew=j+direction[k+1];
if(board[iNew][jNew] == 'O'){
// 当前新的是 O,则连接起来
unionSearch->unionRoot(node(i,j,n), node(iNew, jNew,n));
}
}
}
}
}
}
// 开始找非dummy一类的 O,替换为X
for(int i = 0; i<m ;i++){
for(int j = 0; j<n ;j++){
if(!unionSearch->connected(dummy, node(i,j,n))&& board[i][j] == 'O'){
board[i][j] = 'X';
}
}
}
}
// helper 将i,j二维数组变换成一维数组
int node(int i, int j, int n){
return i*n + j;
}
};
/**
* @Description:
* @param {*}
* @return {*}
* @notes: 把边界的'O'变成‘1’,然后遍历,遇见‘1’恢复成‘O’,其余都变成'X'
*/
class Solution {
public:
void solve(vector<vector<char>>& board) {
if(board.size()==0) return;
int m=board.size();
int n=board[0].size();
for(int i=0;i<m;i++)//左右边界
{
dfs(board,i,0);
dfs(board,i,n-1);
}
for(int j=1;j<n-1;j++)//上下边界
{
dfs(board,0,j);
dfs(board,m-1,j);
}
// 将1变位0, 其它的变为X
for(int i=0;i<m;i++)
{
for(int j=0;j<n;j++)
{
if(board[i][j]=='1') board[i][j]='O';//边界恢复成‘O’
else
{
board[i][j]='X';//其余全为'X'
}
}
}
}
void dfs(vector<vector<char>>& board,int i,int j)//把边界的‘O’变成'1’
{
if(i>=0&&j>=0&&i<board.size()&&j<board[0].size()&&board[i][j]=='O')
{
board[i][j]='1';
dfs(board,i+1,j);
dfs(board,i-1,j);
dfs(board,i,j+1);
dfs(board,i,j-1);
}
}
};
方法一:这段代码很长,其实就是刚才的思路实现,只有和边界O相连的O才具有和dummy的连通性,他们不会被替换。
方法二:直接使用dfs
将边缘的元素 置位1
,然后遍历,遇见‘1’恢复成‘O’,其余都变成’X’
说实话,Union-Find 算法解决这个简单的问题有点杀鸡用牛刀,它可以解决更复杂,更具有技巧性的问题,主要思路是适时增加虚拟节点,想办法让元素「分门别类」,建立动态连通关系。
6.2.2 判定合法算式
这个问题用 Union-Find 算法就显得十分优美了。题目是这样:
我们前文说过,动态连通性其实就是一种等价关系,具有「自反性」「传递性」和「对称性」,其实==关系也是一种等价关系,具有这些性质。所以这个问题用 Union-Find 算法就很自然。 【分门别类】
核心思想是**,将equations中的算式根据和!=分成两部分,先处理算式,使得他们通过相等关系各自勾结成门派;然后处理!=算式,检查不等关系是否破坏了相等关系的连通性。**
/**
* @Description: 【关键】 注意每次 重量都要去改变。
* @param {*}
* @return {*}
* @notes:
*/
class UnionFind
{
public:
UnionFind(int n)
{
this->cnt = n;
// 动态数组内存分配
this->parent = new int[n];
this->size = new int[n];
// this->parent = (int*)malloc(n*sizeof(int));
// this->size = (int*)malloc(n*sizeof(int));
// this->parent.resize(n); 也可以 积累!!!
// this->size.resize(n);
for (int i = 0; i < n; i++)
{
parent[i] = i;
size[i] = 1;
}
}
// ~UnionFind()
// {
// this->parent.clear();
// this->size.clear();
// }
/* 判断 p 和 q 是否连通 */
bool connected(int p, int q)
{
int rootP = find(p);
int rootQ = find(q);
// cout << rootP << "rootq:" << rootQ << endl;
return rootP == rootQ;
}
/* 返回图中有多少个连通分量 */
int count()
{
return this->cnt;
}
// 发现 父节点, 返回父节点
int find(int x)
{
// 经过三次修正 降低路径搜索难度
while (parent[x] != x)
{
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
// 将p和q连通
void unionRoot(int p, int q)
{
// 比较size,将轻的放在重的上面
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return; // cuo
if (size[rootP] > size[rootQ])
{
parent[rootQ] = rootP;
size[rootP] += size[rootQ]; // cuo
}
else
{
parent[rootP] = rootQ;
size[rootQ] += size[rootP]; //cuo
}
this->cnt--; // cuo
}
private:
int cnt; // 连通分量
int *parent; // 节点父节点索引 —— 方便并查集合并与使用
int *size; // 每个节点的重量是多少,方便 减少查找次数
};
class Solution
{
public:
/**
* @Description: 使用 并查集。因为分为两类、 中间动态链接。最后检查 联不联通即可。
* @param {*}
* @return {*}
* @notes:
*/
bool equationsPossible(vector<string> &equations)
{
UnionFind *equaEquality = new UnionFind(26); //26 个英文字母 来对应0--25 'a'-'a';
// 先联通分类
for (string e : equations)
{
if (e[1] == '=')
{
// 联通 0 3
int p = int(e[0] - 'a'), q = int(e[3] - 'a');
// cout << p << ',' << q << endl;
equaEquality->unionRoot(p, q);
}
}
// 然后检查
for (string e : equations)
{
if (e[1] == '!')
{
// p q是否联通
int p = int(e[0] - 'a'), q = int(e[3] - 'a');
// cout << p << '-' << q << endl;
if (equaEquality->connected(p, q))
{
// 连通
// cout << "连通" << endl;
return false;
}
}
}
return true;
}
};
至此,这道判断算式合法性的问题就解决了,借助 Union-Find 算法,是不是很简单呢?
6.2.3 简单总结
使用 Union-Find 算法,主要是如何把原问题转化成图的动态连通性问题。对于算式合法性问题,可以直接利用等价关系,对于棋盘包围问题,则是利用一个虚拟节点,营造出动态连通特性。
另外,将二维数组映射到一维数组,利用方向数组d来简化代码量,都是在写算法时常用的一些小技巧,如果没见过可以注意一下。
很多更复杂的 DFS 算法问题,都可以利用 Union-Find 算法更漂亮的解决。LeetCode 上 Union-Find 相关的问题也就二十多道,有兴趣的读者可以去做一做。