最长连续序列
- 最长连续序列, leetCode 128, 难度: hard
- 首先想到的是基于数组的桶排序的方案(找出数组的最大的连续非空的元素个数), 并写了一个版本。但实际上桶排序是行不通的; 一方面桶排序申请的内存规模与样例数组的规模有关, 占用大量内存, 另一方面, 样例数组的数据有正有负, 而数组无法用负数索引,导致实际不可行。
- 尽管有的样例是都是整数,可以在某些环境执行通过,leedCode有sanitizer检测, 可以通过静态分析判定数组索引可能性的负值错误;
- 再次读题,发现这个题目实际上是个并查集,结果即是最大的那个集合的势; 所以按照了并查集的思路分别写了C++和Javascript的解法的版本。
- 回头再看了看七月君的标准答案, 发现实际上它在步骤上精简了一些,后面会做它和并查集解法的比较;
并查集的解法
并查集(Union-Find-Set)
-
并查集概念:一种树的数据结构, 用于处理不交的集合的元素的"合并"和"查询"的问题, 即合并-查找操作的算法(union-find-algorithm),并由此得名; 典型应用诸如判断图的点的连通性问题;
-
如何表示集合:
- 选出一个集合元素代表该集合; 该元素称为root;
- 用树形结构表示集合, 一个节点中放置一个字段表示当前节点与另一个节点是同属一个集合(当前节点可以由哪个节点"代表"), 这样从任意节点可以逐步查找到当前集合的root, root节点的该字段即表示它自己;
e.g.
// 用数组表示树
index: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
source : [1, 100, 3, 4, 200, 2, 5, 300, 6, 7]
uion-find-set: [0, 1, 0, 2, 4, 2, 3, 7, 6, 8]
source[0], source[1]的uion-find-set编号, union-find-set[0], union-find-set[1]分别是它们自身, source[2]的元素, union-find-set[2]的值是0, 表明有树的第0个节点表示该第2个元素, source[2]的代表是0, source[3]的代表是2, etc.
用上述数组编号表示树, 阅读不友好, 并且在合并操作时,还需要可以支持"按秩合并"的结构, 详见后面分析;
- 支持的操作
- 判root;
- MakeSet, 生成单个元素的集合;
- Find(x): 给任意一个元素, Find返回该元素所属集合的代表root; 查找的过程中, 需要做"路径压缩", 详见后面分析;
- Union(x, y): 对任意2个元素, 针对它们所属的集合做合并操作; 合并动作本身,容易导致并查集的树的形状不平衡,例如产生较长的链, 合并需要"按秩合并";
- 时间复杂度:
- Uion(x, y): 合并动作为O(1);
- Find(x): 考虑到路径压缩, 查找操作的时间复杂度为阿克曼函数的反函数, 阿克曼函数的值随着规模会猛的扩大, 其反函数的值域则总是在一个常量之内, 因此近似O(1);
解法的思路
- 针对所给的数据, 先把它们分成各自所属的集合: 两两判断元素是否符合条件(predicate函数),如符合则对2元素做union操作; 这是一个O(n^2)操作; union是针对调用的实参2个元素找出其root, 合并; Find操作则是一个递归实现, 从给定元素一直查找到root, 在返回路径上修改元素的代表路径(路径压缩); 经过这一层,集合划分完毕(但无法保证路径压缩至最优, 即路径长度都是1);
- 需要找出每个集合的元素个数, 返回最大的那个: 遍历源数组, 利用Find找出所属集合, 利用map记录集合合数及计数每个集合元素个数;
- 这个解法的时间复杂度不是O(n)的, 它是O(n^2)的;
// C++
#include <iostream>
#include <vector>
#include <functional>
#include <map>
class UnionFindSet {
public:
explicit UnionFindSet(const std::vector<int>& nums,
std::function<bool(int, int)> predicate):
source_set_(std::move(nums)),
predicate_(predicate) {
int n = source_set_.size();
union_find_set_ = std::move(std::vector<int>(n));
Initialize();
}
void Union(int x, int y) {
auto set_num_x = union_find_set_[x];
auto set_num_y = union_find_set_[y];
auto root_x = Find(set_num_x);
auto root_y = Find(set_num_y);
// the action of Union
// bad design: this will lead to unbalanced tree;
if (root_x < root_y) {
union_find_set_[root_y] = root_x;
} else {
union_find_set_[root_x] = root_y;
}
}
int Find(int x) {
auto curr_set_num = union_find_set_[x];
if (IsRoot(curr_set_num)) {
union_find_set_[x] = curr_set_num;
return curr_set_num;
}
auto root = Find(curr_set_num);
union_find_set_[root] = root;
return root;
}
bool IsRoot(int x) {
return union_find_set_[x] == x;
}
std::vector<int>& GetUnionFindSet() {
return union_find_set_;
}
private:
std::vector<int> source_set_;
std::vector<int> union_find_set_;
std::function<bool(int, int)> predicate_;
void Initialize() {
for (unsigned int i = 0; i < source_set_.size(); ++i) {
MakeSet(i);
}
MergeUionFindSet();
}
void MakeSet(int idx) {
union_find_set_[idx] = idx;
}
void MergeUionFindSet() {
for (int i = source_set_.size() - 1; i >= 0; --i) {
for (int j = i - 1; j >= 0; --j) {
CheckAndUion(i, j);
}
}
}
void CheckAndUion(int x, int y) {
if (predicate_(source_set_[x], source_set_[y])) {
Union(x, y);
}
}
};
// unique O(n^2)
std::vector<int>& unique(std::vector<int>& nums) {
for (unsigned int i = 0; i < nums.size(); ++i) {
auto it = nums[i];
auto j = i + 1;
while (j < nums.size()) {
if (it == nums[j]) {
nums.erase(nums.begin() + j);
continue;
}
j++;
}
}
return nums;
}
// unique O(n)
std::vector<int> unique2(std::vector<int>& nums) {
std::map<int, int> map;
for (unsigned int i = 0; i < nums.size(); ++i) {
auto item = nums[i];
map[item]++;
}
std::vector<int> result;
for (std::map<int, int>::iterator it = map.begin(); it != map.end(); ++it) {
result.push_back(it->first);
}
return result;
}
int getBiggestSetNum(UnionFindSet& union_find_set) {
auto &uf_set = union_find_set.GetUnionFindSet();
std::map<int, int> set_map;
for (unsigned int i = 0 ; i < uf_set.size(); ++i) {
auto root = union_find_set.Find(i);
++set_map[root];
}
int max = 0;
for (auto it : set_map) {
max = std::max(it.second, max);
}
return max;
}
class Solution {
public:
int longestConsecutive(std::vector<int> &nums) {
auto u = new UnionFindSet(unique(nums), [](int a, int b) -> bool {
return (a + 1 == b || a - 1 == b);
});
return getBiggestSetNum(*u);
}
};
std::ostream& operator<<(std::ostream& s, std::vector<int>& v) {
for (auto st = v.cbegin(); st != v.cend(); ++st) {
if (st == v.cend() - 1) {
s << *st << std::endl;
} else {
s << *st << ", ";
}
}
return s;
}
int main() {
Solution s;
// std::vector<int> nums = {1, 100, 3, 2, 200, 6, 5, 4};
std::vector<int> nums = {4,2,2,-4,0,-2,4,-3,-4,-4,-5,1,4,-9,5,0,6,-8,-1,-3,6,5,-8,-1,-5,-1,2,-9,1};
std::vector<int> v = unique2(nums);
std::cout << "unique2=" << v << std::endl;
auto n = s.longestConsecutive(nums);
std::cout << "longest consecutive=" << n << std::endl;
}
// Javascript
class UnionFindSet {
constructor(sourceArray, predicate) {
this.unionFindSet = [];
this.sourceSet = sourceArray;
this.predicate = predicate;
this.initialize();
}
initialize() {
// MakeSet for every single element;
for (let i = 0; i < this.soruceSet.length; ++i) {
this.unionFindSet[i] = i;
}
this._mergeUionFindSet();
}
union(x, y) {
const numx = this.unionFindSet[x];
const numy = this.unionFildSet[y];
const rootx = this.find(numx);
const rooty = this.find(numy);
if (rootx < rooty) {
this.unionFindSet[rooty] = rootx;
} else {
this.unionFindSet[rootx] = rooty;
}
}
find(x) {
const currSetNum = this.unionFindSet[x];
if (_isRoot(currSetNum)) {
return x
}
const root = find(currSetNum);
this.unionFindSet[x] = root;
return root;
}
_isRoot(index) {
return unionFindSet[index] == index;
}
_checkAndUion(x, y) {
if (this.predicate(this.sourceSet[x],
this.sourceSet[y])) {
this.union(x, y)
}
}
_mergeUionFindSet() {
for (let i = 1; i < this.sourceSet.length; ++i) {
let j = i;
while (j-- > 0) {
this._checkAndUion(i, j);
}
}
}
}
const unionSet = new UnionFindSet([1, 100, 3, 2, 200, 4, 6 5],
function(a, b) {
return (a + 1 == b || a - 1 == b);
});
console.log(unionSet.unionFindSet)
七月君林老师的答案
- 这个解法的时间复杂度显然更好; 它的思路只是行不通的桶排序的思路稍加修改, 利用hashmap的键值对来分组, 计数。针对每一个元素, 查找其所在集合的其他元素(这个predicate条件十分简单), 直到在hashmap中查找不到, 则一组集合个数计算完毕; 排除已查找的元素后,继续查找, 直到结束; 最后返回计数过程中最大值;
- 与并查集的解法相比, 这个解法时间复杂度的优点在于:
- 没有并查集的O(n^2)的建集合的过程,并查集的解法即使第一遍建完集合, 还需要再做一次O(n)的遍历;
- 并且通过Find()来得到集合个数以及元素计数; 而Find()本身, 是平均阿克曼函数的反函数的时间复杂度, 至少会经历几次树节点的查找; 之所以这样,是因为, 如果严格遵从并查集的数据结构组织, 从并查集的树的任意的节点,只能向上找到root, 而不是从任意节点找到同属集合的任意其他节点, e.g. 当你有root的节点时,你无法知道还有什么节点是属于同一个集合的; 但是从林老师的解法可以看到, 从任意节点, 只是利用hashmap的性质去查找到其他元素,因为所谓连续的predicate的条件很简单, 处理完一个节点所有查找到的相关节点也就处理完了一个集合. 其查找过程, 不考虑map的find操作,时间复杂度是O(n)的. 当然C++的std::map的find的一般是O(nlogn), 这个在有关hashmap的话题中在去详查;
- 实际执行来看, 2者时间差距还是比较大的;
// Javascript
const source = [100, 4, 200, 1, 3, 2];
const longestConecutive = (nums) => {
const neighbors = {}
for (const val of nums) {S
neighbors[val] = 1;
}
let result = 0;
for (const [prop, val] of Object.entries(neighbors)) {
console.log(`[prop, val]= ${prop}, ${val}`);
if (neighbors[prop]) {
let count = 1;
delete neighbors[prop];
let target = prop;
target++;
while (neighbors[target]) {
count++;
delete neighbors[target];
target++;
}
target = prop;
target--;
while (neighbors[target]) {
count++;
delete neighbors[target];
target--;
}
result = max(count, result);
}
}
}
// C++
#include <iostream>
#include <vector>
#include <map>
int longestConsecutive(std::vector<int> nums) {
std::map<int, int> neighbors;
for (auto it = nums.begin(); it != nums.end(); ++it) {
neighbors[*it]++;
}
for (auto it : neighbors) {
std::cout << "*it=" << *it
<< it.first << ", " << it.second << std::endl;
}
int result = 0;
for (auto st = neighbors.begin(); st != neighbors.end(); ++st) {
int target = st->first;
if (neighbors.find(target) != neighbors.end()) {
int count = 0;
count++;
target++;
while (neighbors.find(target) != neighbors.end()) {
count++;
neighbors.erase(target);
target++;
}
target = st->first;
target--;
while (neighbors.find(target) != neighbors.end()) {
count++;
neighbors.erase(target);
target--;
}
result = std::max(count, result);
}
}
return result;
}
int main() {
std::vector<int> nums = {1, 100, 3, 2, 200, 4, 6, 5};
int n = longestConsecutive(nums);
std::cout << "result: " << n << std::endl;
}
总结
- 并查集是挺有趣的结构, 前面例子的并查集数据结构无法优化"按秩合并", 下一篇博客再写一篇并查集的相关内容, 尝试解决一些其他应用;
- js的对象
{}
内置的hash map在C++上只是利用std::map, 有这些强大的结构的支持, 某些操作变得十分简单, 例如集合的分组, 去重, etc. - Find()是并查集解法的关键, 当遇到递归与非递归方法可选时, 要看具体问题递归实现容易还是非递归, 一般情况下递归解法较容易;
相关: 一个更好的版本
- 这个解法, 性能很好, 秒了林老师的版本;
- 看了一下它的实现,总结如下:
这个实现性能优势主要来源于3点:
- 作者去除了冗余的循环,在构建并查集的最外围一次循环中确定了结果;
- 它的核心是hashmap的设计, hashmap的key是源数组的某一个元素的值, value是该对应元素的在数组中的索引; 在构建并查集的过程中, 逐步填入hashmap的数据, 这也完成了去重的过程(set函数的作用);
- merge当前元素和num[i] - 1, nums[i] + 1做union是一个巧妙的设计, 外围的遍历保证了不会漏掉元素,同时不在原数组中的元素也不会出现在hashmap中;
- 同时,非常重要的是,在每一次做并查集合并的动作上完成计数, size数组的设计和计数也很巧妙; 注意, 无需在Find中做size元素之间的赋值, 从初始单元素集合的状态到合并完成, 只需在union动作时间点计数;
总结:
- 尽量去冗余, 例如n^2的遍历可否优化成n;
- 利用好hashmap
- 利用好安排好每次操作序列的顺序动作;