仿佛一会儿,2017年就快过去一半了,研一立刻就要成为过去式了,我打算抓住研一的尾巴,好好梳理一下数据结构与算法,毕竟这些基础知识是很重要的嘛。因此准备在这里搞一个系列的文章,以期透彻。html
本系列将采用Java语言来进行描述。亦即总结常见的的数据结构,以及在Java中相应的实现方法,务求理论与实践一步总结到位。前端
首先给出Java集合框架的基本接口/类层次结构:java
java.util.Collection [I] +--java.util.List [I] +--java.util.ArrayList [C] +--java.util.LinkedList [C] +--java.util.Vector [C] //线程安全 +--java.util.Stack [C] //线程安全 +--java.util.Set [I] +--java.util.HashSet [C] +--java.util.SortedSet [I] +--java.util.TreeSet [C] +--Java.util.Queue[I] +--java.util.Deque[I] +--java.util.PriorityQueue[C] java.util.Map [I] +--java.util.SortedMap [I] +--java.util.TreeMap [C] +--java.util.Hashtable [C] //线程安全 +--java.util.HashMap [C] +--java.util.LinkedHashMap [C] +--java.util.WeakHashMap [C] [I]:接口 [C]:类 本图来源于网络。
数组是相同数据类型的元素按必定顺序排列的集合,是一块连续的内存空间。数组的优势是:get和set操做时间上都是O(1)的;缺点是:add和remove操做时间上都是O(N)的。node
Java中,Array就是数组,此外,ArrayList使用了数组Array做为其实现基础,它和通常的Array相比,最大的好处是,咱们在添加元素时没必要考虑越界,元素超出数组容量时,它会自动扩张保证容量。程序员
Vector和ArrayList相比,主要差异就在于多了一个线程安全性,可是效率比较低下。现在java.util.concurrent包提供了许多线程安全的集合类(好比 LinkedBlockingQueue),因此没必要再使用Vector了。算法
int[] ints = new int[10]; ints[0] = 5;//set int a = ints[2];//get int len = ints.length;//数组长度
链表是一种非连续、非顺序的结构,数据元素的逻辑顺序是经过链表中的指针连接次序实现的,链表由一系列结点组成。链表的优势是:add和remove操做时间上都是O(1)的;缺点是:get和set操做时间上都是O(N)的,并且须要额外的空间存储指向其余数据地址的项。数据库
查找操做对于未排序的数组和链表时间上都是O(N)。后端
Java中,LinkedList 使用链表做为其基础实现。api
LinkedList<String> linkedList = new LinkedList<>(); linkedList.add("addd");//add linkedList.set(0,"s");//set,必须先保证 linkedList中已经有第0个元素 String s = linkedList.get(0);//get linkedList.contains("s");//查找 linkedList.remove("s");//删除 //以上方法也适用于ArrayList
队列是一种特殊的线性表,特殊之处在于它只容许在表的前端进行删除操做,而在表的后端进行插入操做,亦即所谓的先进先出(FIFO)。数组
Java中,LinkedList实现了Deque,能够作为双向队列(天然也能够用做单向队列)。另外PriorityQueue实现了带优先级的队列,亦即队列的每个元素都有优先级,且元素按照优先级排序。
Deque<Integer> integerDeque = new LinkedList<>(); // 尾部入队,区别在于若是失败了 // add方法会抛出一个IllegalStateException异常,而offer方法返回false integerDeque.offer(122); integerDeque.add(122); // 头部出队,区别在于若是失败了 // remove方法抛出一个NoSuchElementException异常,而poll方法返回false int head = integerDeque.poll();//返回第一个元素,并在队列中删除 head = integerDeque.remove();//返回第一个元素,并在队列中删除 // 头部出队,区别在于若是失败了 // element方法抛出一个NoSuchElementException异常,而peek方法返回null。 head = integerDeque.peek();//返回第一个元素,不删除 head = integerDeque.element();//返回第一个元素,不删除
栈(stack)又名堆栈,它是一种运算受限的线性表。其限制是仅容许在表的一端进行插入和删除运算。这一端被称为栈顶,相对地,把另外一端称为栈底。它体现了后进先出(LIFO)
的特色。
Java中,Stack实现了这种特性,可是Stack也继承了Vector,因此具备线程安全线和效率低下两个特性,最新的JDK8中,推荐用Deque来实现栈,好比:
Deque<Integer> stack = new ArrayDeque<Integer>(); stack.push(12);//尾部入栈 stack.push(16);//尾部入栈 int tail = stack.pop();//尾部出栈,并删除该元素 tail = stack.peek();//尾部出栈,不删除该元素
集合是指具备某种特定性质的具体的或抽象的对象汇总成的集体,这些对象称为该集合的元素,其主要特性是元素不可重复。
在Java中,HashSet 体现了这种数据结构,而HashSet是在MashMap的基础上构建的。LinkedHashSet继承了HashSet,使用HashCode肯定在集合中的位置,使用链表的方式肯定位置,因此有顺序。TreeSet实现了SortedSet 接口,是排好序的集合(在TreeMap 基础之上构建),所以查找操做比普通的Hashset要快(log(N));插入操做要慢(log(N)),由于要维护有序。
HashSet<Integer> integerHashSet = new HashSet<>(); integerHashSet.add(12121);//添加 integerHashSet.contains(121);//是否包含 integerHashSet.size();//集合大小 integerHashSet.isEmpty();//是否为空
散列表也叫哈希表,是根据关键键值(Keyvalue)进行访问的数据结构,它经过把关键码值映射到表中一个位置来访问记录,以加快查找的速度,这个映射函数叫作散列函数。
Java中HashMap实现了散列表,而Hashtable比它多了一个线程安全性,可是因为使用了全局锁致使其性能较低,因此如今通常用ConcurrentHashMap来实现线程安全的HashMap(相似的,以上的数据结构在最新的java.util.concurrent的包中几乎都有对应的高性能的线程安全的类)。TreeMap实现SortMap接口,可以把它保存的记录按照键排序。LinkedHashMap保留了元素插入的顺序。WeakHashMap是一种改进的HashMap,它对key实行“弱引用”,若是一个key再也不被外部所引用,那么该key能够被GC回收,而不须要咱们手动删除。
HashMap<Integer,String> hashMap = new HashMap<>(); hashMap.put(1,"asdsa");//添加 hashMap.get(1);//得到 hashMap.size();//元素个数
树(tree)是包含n(n>0)个节点的有穷集合,其中:
树这种数据结构在计算机世界中有普遍的应用,好比操做系统中用到了红黑树,数据库用到了B+树,编译器中的语法树,内存管理用到了堆(本质上也是树),信息论中的哈夫曼编码等等等等,在Java中TreeSet和TreeMap用到了树来排序(二分查找提升检索速度),不过通常都须要程序员本身去定义一个树的类,并实现相关性质,而没有现成的API。下面就用Java来实现各类常见的树。
二叉树是一种基础并且重要的数据结构,其每一个结点至多只有二棵子树,二叉树有左右子树之分,第i层至多有2^(i-1)个结点(i从1开始);深度为k的二叉树至多有2^(k)-1)个结点,对任何一棵二叉树,若是其终端结点数为n0,度为2的结点数为n2,则n0=n2+1。
二叉树的性质:
1) 在非空二叉树中,第i层的结点总数不超过2^(i-1), i>=1;
2) 深度为h的二叉树最多有2^h-1个结点(h>=1),最少有h个结点;
3) 对于任意一棵二叉树,若是其叶结点数为N0,而度数为2的结点总数为N2,则N0=N2+1;
4) 具备n个结点的彻底二叉树的深度为log2(n+1);
5)有N个结点的彻底二叉树各结点若是用顺序方式存储,则结点之间有以下关系:
若I为结点编号则 若是I>1,则其父结点的编号为I/2;
若是2I<=N,则其左儿子(即左子树的根结点)的编号为2I;若2I>N,则无左儿子;
若是2I+1<=N,则其右儿子的结点编号为2I+1;若2I+1>N,则无右儿子。
6)给定N个节点,能构成h(N)种不一样的二叉树,其中h(N)为卡特兰数的第N项,h(n)=C(2*n, n)/(n+1)。
7)设有i个枝点,I为全部枝点的道路长度总和,J为叶的道路长度总和J=I+2i。
满二叉树:除最后一层无任何子节点外,每一层上的全部结点都有两个子结点;
彻底二叉树:若设二叉树的深度为h,除第 h 层外,其它各层 (1~(h-1)层) 的结点数都达到最大个数,第h层全部的结点都连续集中在最左边,这就是彻底二叉树;
满二叉树是彻底二叉树的一个特例。
二叉查找树,又称为是二叉排序树(Binary Sort Tree)或二叉搜索树。二叉排序树或者是一棵空树,或者是具备下列性质的二叉树:
1) 若左子树不空,则左子树上全部结点的值均小于它的根结点的值;
2) 若右子树不空,则右子树上全部结点的值均大于或等于它的根结点的值;
3) 左、右子树也分别为二叉排序树;
4) 没有键值相等的节点。
二叉查找树的性质:对二叉查找树进行中序遍历,便可获得有序的数列。
二叉查找树的时间复杂度:它和二分查找同样,插入和查找的时间复杂度均为O(logn),可是在最坏的状况下仍然会有O(n)的时间复杂度。缘由在于插入和删除元素的时候,树没有保持平衡。咱们追求的是在最坏的状况下仍然有较好的时间复杂度,这就是平衡二叉树设计的初衷。
二叉查找树能够这样表示:
public class BST<Key extends Comparable<Key>, Value> { private Node root; // 根节点 private class Node { private Key key; // 排序的间 private Value val; // 相应的值 private Node left, right; // 左子树,右子树 private int size; // 以该节点为根的树包含节点数量 public Node(Key key, Value val, int size) { this.key = key; this.val = val; this.size = size; } } public BST() {} public int size() {//得到该二叉树节点数量 return size(root); } private int size(Node x) {得到以该节点为根的树包含节点数量 if (x == null) return 0; else return x.size; } }
查找:
public Value get(Key key) { return get(root, key); } private Value get(Node x, Key key) {//在以x节点为根的树中查找key if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp < 0) return get(x.left, key);//递归左子树查找 else if (cmp > 0) return get(x.right, key);//递归右子树查找 else return x.val;//找到了 }
插入:
public void put(Key key, Value val) { root = put(root, key, val); } private Node put(Node x, Key key, Value val) {在以x节点为根的树中查找key,val if (x == null) return new Node(key, val, 1); int cmp = key.compareTo(x.key); if (cmp < 0) x.left = put(x.left, key, val);//递归左子树插入 else if (cmp > 0) x.right = put(x.right, key, val);//递归右子树插入 else x.val = val; x.size = 1 + size(x.left) + size(x.right); return x; }
删除:
public Key min() { return min(root).key; } private Node min(Node x) { if (x.left == null) return x; else return min(x.left); } public void deleteMin() { root = deleteMin(root); } private Node deleteMin(Node x) {//删除以x为根节点的子树最小值 if (x.left == null) return x.right; x.left = deleteMin(x.left); x.size = size(x.left) + size(x.right) + 1; return x; } public void delete(Key key) { root = delete(root, key); } private Node delete(Node x, Key key) { if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp < 0) x.left = delete(x.left, key);//递归删除左子树 else if (cmp > 0) x.right = delete(x.right, key);//递归删除右子树 else { //该节点就是所要删除的节点 if (x.right == null) return x.left;//没有右子树,把左子树挂在原节点父节点上 if (x.left == null) return x.right;//没有左子树,,把右子树挂在原节点父节点上 Node t = x;//用右子树中最小的节点来替代被删除的节点,仍然保证树的有序性 x = min(t.right); x.right = deleteMin(t.right); x.left = t.left; } x.size = size(x.left) + size(x.right) + 1; return x; }
平衡二叉树又被称为AVL树,具备如下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,而且左右两个子树都是一棵平衡二叉树。它的出现就是解决二叉查找树不平衡致使查找效率退化为线性的问题,由于在删除和插入之时会维护树的平衡,使得查找时间保持在O(logn),比二叉查找树更稳定。
ALLTree 的 Node 由 BST 的 Node 加上 private int height;
节点高度属性便可,这是为了便于判断树是否平衡。
维护树的平衡关键就在于旋转。对于一个平衡的节点,因为任意节点最多有两个儿子,所以高度不平衡时,此节点的两颗子树的高度差2.容易看出,这种不平衡出如今下面四种状况:
一、6节点的左子树3节点高度比右子树7节点大2,左子树3节点的左子树1节点高度大于右子树4节点,这种状况成为左左。
二、6节点的左子树2节点高度比右子树7节点大2,左子树2节点的左子树1节点高度小于右子树4节点,这种状况成为左右。
三、2节点的左子树1节点高度比右子树5节点小2,右子树5节点的左子树3节点高度大于右子树6节点,这种状况成为右左。
四、2节点的左子树1节点高度比右子树4节点小2,右子树4节点的左子树3节点高度小于右子树6节点,这种状况成为右右。
从图2中能够能够看出,1和4两种状况是对称的,这两种状况的旋转算法是一致的,只须要通过一次旋转就能够达到目标,咱们称之为单旋转。2和3两种状况也是对称的,这两种状况的旋转算法也是一致的,须要进行两次旋转,咱们称之为双旋转。
单旋转是针对于左左和右右这两种状况,这两种状况是对称的,只要解决了左左这种状况,右右就很好办了。图3是左左状况的解决方案,节点k2不知足平衡特性,由于它的左子树k1比右子树Z深2层,并且k1子树中,更深的一层的是k1的左子树X子树,因此属于左左状况。
为使树恢复平衡,咱们把k1变成这棵树的根节点,由于k2大于k1,把k2置于k1的右子树上,而本来在k1右子树的Y大于k1,小于k2,就把Y置于k2的左子树上,这样既知足了二叉查找树的性质,又知足了平衡二叉树的性质。
这样的操做只须要一部分指针改变,结果咱们获得另一颗二叉查找树,它是一棵AVL树,由于X向上一移动了一层,Y还停留在原来的层面上,Z向下移动了一层。整棵树的新高度和以前没有在左子树上插入的高度相同,插入操做使得X高度长高了。所以,因为这颗子树高度没有变化,因此通往根节点的路径就不须要继续旋转了。
代码:
private int height(Node t){ return t == null ? -1 : t.height; } //左左状况单旋转 private Node rotateWithLeftChild(Node k2){ Node k1 = k2.left; k2.left = k1.right; k1.right = k2; k1.size = k2.size; k2.size = size(k2.right)+size(k2.left)+1; k2.height = Math.max(height(k2.left), height(k2.right)) + 1; k1.height = Math.max(height(k1.left), k2.height) + 1; return k1; //返回新的根 } //右右状况单旋转 private Node rotateWithRightChild(Node k2){ Node k1 = k2.right; k2.right = k1.left; k1.left = k2; k1.size = k2.size; k2.size = size(k2.right)+size(k2.left)+1; k2.height = Math.max(height(k2.left), height(k2.right)) + 1; k1.height = Math.max(height(k1.right), k2.height) + 1; return k1; //返回新的根 }
双旋转是针对于左右和右左这两种状况,单旋转不能使它达到一个平衡状态,要通过两次旋转。一样的,这样两种状况也是对称的,只要解决了左右这种状况,右左就很好办了。图4是左右状况的解决方案,节点k3不知足平衡特性,由于它的左子树k1比右子树Z深2层,并且k1子树中,更深的一层的是k1的右子树k2子树,因此属于左右状况。
为使树恢复平衡,咱们须要进行两步,第一步,把k1做为根,进行一次右右旋转,旋转以后就变成了左左状况,因此第二步再进行一次左左旋转,最后获得了一棵以k2为根的平衡二叉树树。
代码:
//左右状况 private Node doubleWithLeftChild(Node k3){ try{ k3.left = rotateWithRightChild(k3.left); }catch(NullPointerException e){ System.out.println("k.left.right为:"+k3.left.right); throw e; } return rotateWithLeftChild(k3); } //右左状况 private Node doubleWithRightChild(Node k3){ try{ k3.right = rotateWithLeftChild(k3.right); }catch(NullPointerException e){ System.out.println("k.right.left为:"+k3.right.left); throw e; } return rotateWithRightChild(k3); }
AVL查找操做与BST相同,AVL的删除与插入操做在BST基础之上须要检查是否平衡,若是不平衡就要使用旋转操做来维持平衡:
private Node balance(Node x) { if (balanceFactor(x) < -1) {//右边高 if (balanceFactor(x.right) > 0) {//右左 x.right = rotateWithLeftChild(x.right); } x = rotateWithRightChild(x); } else if (balanceFactor(x) > 1) {//左边高 if (balanceFactor(x.left) < 0) {//左右 x.left = rotateWithRightChild(x.left); } x = rotateWithLeftChild(x); } return x; } private int balanceFactor(Node x) { return height(x.left) - height(x.right); }
堆是一颗彻底二叉树,在这棵树中,全部父节点都知足大于等于其子节点的堆叫大根堆,全部父节点都知足小于等于其子节点的堆叫小根堆。堆虽然是一颗树,可是一般存放在一个数组中,父节点和孩子节点的父子关系经过数组下标来肯定。以下图的小根堆及存储它的数组:
值: 7,8,9,12,13,11
数组索引: 0,1,2,3, 4, 5
经过一个节点在数组中的索引怎么计算出它的父节点及左右孩子节点的索引:
public int left(int i) { return (i + 1) * 2 - 1; } public int right(int i) { return (i + 1) * 2; } public int parent(int i) { // i为根结点 if (i == 0) { return -1; } return (i - 1) / 2; }
维护大根堆的性质:
public void heapify(T[] a, int i, int heapLength) { int l = left(i); int r = right(i); int largest = -1; //寻找根节点及其左右子节点,三个元素中的最大值 if (l < heapLength && a[i].compareTo(a[l]) < 0) { largest = l; } else { largest = i; } if (r < heapLength && a[largest].compareTo(a[r]) < 0) { largest = r; } // 若是i处元素不是最大的,就把i处的元素与最大处元素交换,使得i处元素变为最大的 if (i != largest) { T temp = a[i]; a[i] = a[largest]; a[largest] = temp; // 交换元素后,以a[i]为根的树可能不在知足大根堆性质,因而递归调用该方法 heapify(a, largest, heapLength); } }
构造堆:
public void buildHeap(T[] a, int heapLength) { //从后往前看lengthParent处的元素是第一个有子节点的元素,因此从它开始,进行堆得维护 int lengthParent = parent(heapLength - 1); for(int i = lengthParent; i >= 0; i--){ heapify(a, i, heapLength); } }
堆的用途:堆排序,优先级队列。此外因为调整代价较小,也适合实时类型的排序与变动。
写着写着就发现要想总结到位是一项很是庞大的工程,路漫漫其修远兮。吾将上下而求索啊。
欢迎访问个人主页 mageek