Trie树和并查集
目录
1 Trie(字典树)
Trie:字典树、前缀树;其特点不再是普通树中的二叉结构而是多叉结构。
Trie优点:
适合处理类似通讯录问题,当有n个条目时,使用树结构其查询的时间复杂度为O(logn),使用字典树查询每个条目的复杂度和字典中条目的数量无关只与每个条目的长度相关,其时间复杂度为O(w),w为查询的条目(字符串)的长度。
Trie结构:
每个节点有若干(具体数目根据不同语境得出)个指向下个节点的指针,其构造代码如下:
class Node{ //每个节点装载字符 char c; //利用map映射,便可以不用固定一个节点到底会有多少子节点 Map<Character,Node> next; }
对上面的进行优化:
(1)考虑搜索过程,当我们来到根节点要向下寻找时其实本质上我们已经知道了下一个字母所在的节点位置;
(2)英文中有些单词可能是长单词的前缀,比如pan和panda这样还需要标记一下当前节点是不是单词
class Node{ //是否是单词的标记 boolean isWorld; //不需要额外存储节点 Map<Character,Node> next; }
代码实现:
public class Trie{ //定义了内部节点 private class Node{ public boolean isWorld; //再具体实现时底层采用了hashmap,效率比较快 public HashMap<Character,Node> next; public Node(boolean isWorld){ this.isWorld = isWorld; next = new HashMap<>(); } public Node(){ this(false); } } private Node root; private int size; public Trie(){ root = new Node(); size = 0; } //获得Trie中存储的单词数量 public int getSize(){ return size; } //向Trie中添加一个新的单词word public void add(String word){ Node cur = root; for (int i = 0; i < word.length(); i++) { char c = word.charAt(i); if (cur.next.get(c) == null){ //如果下一个节点的映射中没有当前字母,则创建一个新的节点 cur.next.put(c,new Node()); }//沿着当前字母继续向下移动,注意这里不要用else语句,当上面创建好后还要继续移动使cur在新创建好的节点上 cur = cur.next.get(c); } //当遍历完单词的所有字符后,需要判读一下是否有单词标志,没有标志才置为true if (!cur.isWorld){ cur.isWorld = true; size ++; } } //查询单词word是否在Trie中 public boolean contains(String word){ Node cur = root; for (int i = 0; i < word.length(); i++) { char c = word.charAt(i); if (cur.next.get(c) == null){ //如果没有字母映射说明字典树中没有存储该单词 return false; }else { //继续移动节点 cur = cur.next.get(c); } } //并不是遍历完就一定是单词了,还要看字典树中是否存储了该单词的标志 return cur.isWorld; } }
2 前缀树
可以在上面代码的基础上继续扩展功能,如判断是否有某些字符串开头的单词
//查询是否在Trie中有单词以prefix为前缀,和上面查询单词是否在trie中很类似 public boolean isPrefix(String prefix){ Node cur = root; for(int i = 0 ; i < prefix.length() ; i ++){ char c = prefix.charAt(i); if(cur.next.get(c) == null){ return false; }else { cur = cur.next.get(c); } } //这里可以直接返回true,不用判断是否是单词了 return true; }
这里再给出程序员代码面试指南一书中的代码作为参考
public class TrieTree { public static class TrieNode { //有多少个单词共用该节点 public int path; //有多少个单词以该节点结尾 public int end; //map中key代表该节点的一条字符路径,value代表该节点的指向节点 public TrieNode[] map; public TrieNode() { path = 0; end = 0; //假设26个英文字符 map = new TrieNode[26]; } } public static class Trie { private TrieNode root; public Trie() { root = new TrieNode(); } public void insert(String word) { if (word == null) { return; } //把字符串转换为字符数组,转不转都可以的 char[] chs = word.toCharArray(); //下面的添加元素操作和上面的字典树是一样的,稍有不同在于需要维护两个变量, //当添加两个完全相同的字符串时也只需要自增,不需要额外处理 TrieNode node = root; //元素所在的索引 int index = 0; for (int i = 0; i < chs.length; i++) { index = chs[i] - 'a'; if (node.map[index] == null) { node.map[index] = new TrieNode(); } node = node.map[index]; //无论是否创建新节点还是继续向下面寻找都是在该节点的下面进行操作,因此需要加1 node.path++; } //当一个单词添加完在该单词的结尾加 node.end++; } public void delete(String word) { if (search(word)) { char[] chs = word.toCharArray(); TrieNode node = root; int index = 0; for (int i = 0; i < chs.length; i++) { index = chs[i] - 'a'; //这里先判断是否等于1再进行自减操作不要搞混了 if (node.map[index].path-- == 1) { node.map[index] = null; return; } node = node.map[index]; } node.end--; } } //这个查询和上面基本一致不再赘述解释 public boolean search(String word) { if (word == null) { return false; } char[] chs = word.toCharArray(); TrieNode node = root; int index = 0; for (int i = 0; i < chs.length; i++) { index = chs[i] - 'a'; if (node.map[index] == null) { return false; } node = node.map[index]; } return node.end != 0; } //返回有多少以该字符串结尾的字符 public int prefixNumber(String pre) { if (pre == null) { return 0; } char[] chs = pre.toCharArray(); TrieNode node = root; int index = 0; for (int i = 0; i < chs.length; i++) { index = chs[i] - 'a'; if (node.map[index] == null) { return 0; } node = node.map[index]; } //返回的是path值 return node.path; } } }
具体应用
LeetCode 208 实现Trie(前缀树)LeetCode 211 添加与搜索单词-数据结构设计 LeetCode 677 键值映射
3 并查集
连接问题(如下图判断某两个点是否相连)、集合问题。
以连接问题为例,在传统方法中可能是不断的去是两点之间的所有路径然后判断是否相连接,但是这样对于性能而言其实是浪费的,因为我们不需要知道两个点之间的路径,只需要知道是否相连。
做一个抽象,对于id不同的点进行标记,当两个点的标记都相同时便可以连接,在这种情况下即使不知道具体的路径也知道了是否连接。
public class UnionFind{ //处理的是对应元素的id,而不是处理元素本身 private int[] id; public UnionFind(int size){ id = new int[size]; //一开始没有合并元素,每个id[i]只是指向自己 for (int i = 0; i < size; i++) { id[i] = i; } } public int getSize(){ return id.length; } //查找元素p所对应的集合编号。O(1)复杂度 private int find(int p) { if(p < 0 || p >= id.length){ throw new IllegalArgumentException("p is out of bound."); } return id[p]; } //查看元素p和元素q是否所属一个集合。O(1)复杂度 public boolean isConnected(int p,int q){ return find(p) == find(q); } //合并元素p和元素q所属的集合 O(n) 复杂度 public void unionElements(int p, int q) { int pId = find(p); int qId = find(q); if (pId == qId){ return; } //合并过程需要遍历一遍所有元素, 将两个元素的所属集合编号合并,因为有可能pId集合中已经有好几个元素了 for (int i = 0; i < id.length; i++) { if (id[i] == pId){ id[i] = qId; } } } }
上面代码中的union操作的时间复杂度为O(N),这样的效率不高,可以采用树的方式进行优化,每个id存的是其父节点的id:
代码实现:
public class UnionFind{ // 使用一个数组构建一棵指向父节点的树,parent[i]表示第一个元素所指向的父节点 private int[] parent; // 构造函数 public UnionFind(int size){ //初始化数组的长度 parent = new int[size]; // 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合 for( int i = 0 ; i < size ; i ++ ){ parent[i] = i; } } // 查找过程, 查找元素p所对应的集合编号;O(h)复杂度, h为树的高度 private int find(int p){ if(p < 0 || p >= parent.length){ throw new IllegalArgumentException("p is out of bound."); } // 不断去查询自己的父亲节点, 直到到达根节点;根节点的特点: parent[p] == p while(p != parent[p]){ p = parent[p]; } return p; } // 查看元素p和元素q是否所属一个集合;O(h)复杂度, h为树的高度 public boolean isConnected( int p , int q ){ return find(p) == find(q); } // 合并元素p和元素q所属的集合;O(h)复杂度, h为树的高度 public void unionElements(int p, int q){ //合并的时候为了避免树退化成链表采用的根节点的合并方式 int pRoot = find(p); int qRoot = find(q); if( pRoot == qRoot ){ return; } parent[pRoot] = qRoot; } }
基于size的优化
在2中使得O(N)降为了O(h),但如果考虑这样一种情况,1与0合并,2与1合并,这样合并下去,最后就变成了一个链表,这种情况下反而采用树结构的效率要低了,因此需要对其进行优化 。
一种优化的思路是记录下集合的size,在合并的时候把元素个数少的集合合并到元素个数多的集合上
// sz[i]表示以i为根的集合中元素个数 private int[] sz; //初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合,这部分代码是放到构造方法中的 for(int i = 0 ; i < size ; i ++){ sz[i] = 1; } public void unionElements(int p, int q){ int pRoot = find(p); int qRoot = find(q); //当根节点相同时直接返回,不需要合并 if(pRoot == qRoot){ return; } // 根据两个元素所在树的元素个数不同判断合并方向:将元素个数少的集合合并到元素个数多的集合上 if(sz[pRoot] < sz[qRoot]){ parent[pRoot] = qRoot; sz[qRoot] += sz[pRoot]; } else{ // sz[qRoot] <= sz[pRoot] parent[qRoot] = pRoot; sz[pRoot] += sz[qRoot]; } }
基于Rank的优化
考虑下面的情况,如果要合并(2,4)根据size的优化方法应该是以7作为根节点,但可以明显的看到这样做反而使得树的高度增加了,因此说上面的基于size的优化是不彻底的。
//rank[i]表示以i为根的集合所表示的树的层数 private int[] rank; //这部分放在构造方法中 rank = new int[size]; //初始化高度为1 for( int i = 0 ; i < size ; i ++ ){ rank[i] = 1; } public void unionElements(int p, int q){ //各自找到根节点 int pRoot = find(p); int qRoot = find(q); if( pRoot == qRoot ){ return; } //根据两个元素所在树的rank不同判断合并方向:将rank低的集合合并到rank高的集合上 //两个有高度差的合并并不需要维护rank if(rank[pRoot] < rank[qRoot]){ parent[pRoot] = qRoot; } else if(rank[qRoot] < rank[pRoot]){ parent[qRoot] = pRoot; } else{ //如果高度相等随便指定一个即可,这里认定qRoot为根 parent[pRoot] = qRoot; //要维护下rank,高度相等的合并,使得rank自增1 rank[qRoot] += 1; } }
路径压缩
考虑下面三种情况,虽然三者的结构完全不相同,但在并查集中表示的意思是一样的,那么如何调整合并后的树,使其深度尽可能小:路径压缩。
考虑下图结构,从最底层出发不断执行改代码
最后结果如下图所示,这样就尽可能减少了树的深度
代码实现:
private int find(int p){ if(p < 0 || p >= parent.length){ throw new IllegalArgumentException("p is out of bound."); } while( p != parent[p] ){ parent[p] = parent[parent[p]]; p = parent[p]; } return p; }
在合并的时候并没有任何改变,只是在查找的时候增加了一句话,那么现在有一个问题:这样做相当于修改了树的深度,但是没有更新rank会不会出问题?
这种情况下rank并不代表树的深度了,可以认为是一个相对深度,但有一点可以确定,数值越高代表的深度也是越大的因此rank此时可以认为是一个优先级的排名,因为实时维护深度也是需要消耗的,但在合并的时候并不需要具体知道有多深,只需要知道两者之间哪个更深就好了。
递归算法:
private int find(int p){ if(p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } if(p != parent[p]){ parent[p] = find(parent[p]); } return parent[p]; }
由于递归算法直接追溯到了根节点,因此可以转换成下面的结构:
但这并不意味着循环实现的就做不到,在上面如果利用循环继续都子节点处理也可以做得到,但因为递归会有额外的开销,真实时间反而还要比递归慢一点。
public class UnionFind { public static class Node { //可以是任何类型的数据 } public static class DisjointSets{ //<A,B>表示A的父亲节点是B public HashMap<Node, Node> fatherMap; //表示以A为根节点的集合大小,<A,size>,若A不是父节点则无任何意义,类比rank[i],按理来说当不是根节点时可以删除信息,但是这样浪费操作 public HashMap<Node, Integer> rankMap; public DisjointSets() { fatherMap = new HashMap<Node, Node>(); rankMap = new HashMap<Node, Integer>(); } //初始化集合 public void makeSets(List<Node> nodes) { fatherMap.clear(); rankMap.clear(); for (Node node : nodes) { fatherMap.put(node, node); rankMap.put(node, 1); } } //使用递归,在寻找的过程中推平树 public Node findFather(Node n) { Node father = fatherMap.get(n); if (father != n) { father = findFather(father); } fatherMap.put(n, father); return father; } public void union(Node a, Node b) { if (a == null || b == null) { return; } Node aFather = findFather(a); Node bFather = findFather(b); if (aFather != bFather) { int aFrank = rankMap.get(aFather); int bFrank = rankMap.get(bFather); if (aFrank <= bFrank) { fatherMap.put(aFather, bFather); //合并后让集合大小更新 rankMap.put(bFather, aFrank + bFrank); } else { fatherMap.put(bFather, aFather); rankMap.put(aFather, aFrank + bFrank); } } } } }
复杂度分析:在前面提到其时间复杂度为O(h),因为我们在递归的时候一直在推平树,最终可以认为其平均时间复杂度为O(1)。这个复杂度分析比较难记住即可
0