数据结构之查找表

前言

今天学习数据结构看到了一个词-静态查找表,还有与之对应的动态查找表,然后我发现。

啊,这是个啥,好像知道又好像不知道,不就是查找吗,干嘛弄这些专业的说法,回头翻了一下数据结构的书,才发现......。

唉,小小的抱怨一下,不过,我从这两个词联想到了一门基础但是要精通又不简单的学问,就是查找,然后还有前天被面试官问到的一个查找题,题目很简单,如何查找单向链表中倒数第K个数?当然你先遍历一遍数组,获取数组的总长度,然后再遍历一遍找倒数第K个数,当然可以,但是既然是面试题,这样的答案基本是凉凉的,我自己都不满意,更别提人家面试官了,当你抛出这个答案之后,面试官会说如果只遍历一遍呢?你就懵逼了,如果你和我一样懵逼的话,那么恭喜你,你现在还来得及,当然正确方法我就不说了,感兴趣的可以去想想,实在想不出来,我相信你会有办法的,xixixi,也不难。

好了,说了这么多,今天既然发现了自己的一个薄弱区-查找,指不准以后哪天还会在查找这里栽倒,于是,何不在这之前好好梳理一下查找这块呢?

目录

1.静态查找表

2.动态查找表

3.哈希表

正文

首先我们需要明白下静态查找表和动态查找表的定义,所谓的静态查找表是指在查找过程中只进行查找操作,动态查找表就是在查找过程中还会进行插入和删除操作,例如,查找某个元素,若查找到了,则删除。

1.静态查找表

静态查找表可以有不同的表示方法,在不同的表示方法中,实现的查找操作方法也不同。

这里列举平时遇到的最为常见的两种情况

a.以顺序表或线性链表表示静态查找表,则查找可用顺序查找来实现

b.以有序表(排好顺序的顺序表) 表示静态查找表,则查找可用折半查找(二分查找)来实现

第一种,就是一个for循环的事,就不赘述了,这里看一下第二种情况,也就是二分查找的代码,思想就是从中间位置起开始查找,如果正好位于正中间,那么就找到了,如果比正中间数据大,那么在后半部分查找,假设整个数据是升序,如果比正中间数据小,那么在前半部分查找,依次类推,递归结束的标志就是左边界大于右边界,也就是已经不能再分了,如果此时还没查找到,那么就返回未查找到即可

public class BinarySearch {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		int[] a=new int[]{2,3,5,8,9};
		int index=search(a,50);
		System.out.println(index);
	}
	public static int search(int[] a,int data){
		return binarlSearch(a, 0, a.length-1,data);
	}
	public static int binarlSearch(int[] a,int left,int right,int data){
		if(left>right)
			return -1;
		int mid=(left+right)/2;
		if(a[mid]==data){
			return mid;
		}else if(a[mid]<data){
			return binarlSearch(a, mid+1, right, data);
		}else{
			return binarlSearch(a, left, mid-1, data);
		}
	}
}

上面的只是查找里的入门知识,学任何东西都像打怪升级一样,先从小怪开始,由简变难,下面我们升级来打一个大点的怪!

2.动态查找表

动态查找表的特点是:表结构本身是在查找过程中动态生成的,即对于给定的key值,若表中存在其关键字等于key的记录,则查找成功返回,否则插入关键字等于key的记录。

动态查找表也可有不同的表示方法,这里主要讨论以各种树结构表示时的实现方法。

a.二叉排序树(又叫  二叉搜索树  二叉查找树)

二叉排序树的特点是:

1.若它的左子树不为空,则左子树上所有节点的值均小于它的根节点的值

2.若它的右子树不为空,则右子树上所有节点的值均大于它的根节点的值

3.它的左右子树也分别为二叉排序树

在了解了这个树的性质之后,我们不难联想到二分查找的思想,只不过大同小异,首先和根节点比较,如果比根节点大,则在右子树中查找,如果比根节点小,则在左子树中查找,如果相等,那么正好查找到了,如果左子树也没有,右子树也没有,则查找失败。

好了,我们掌握了上面的思想,不难写出下面的代码

	public boolean contains(Node root, Node node) {
		if (root == null) {
			return false;
		}
		if (node.data == root.data) {
			return true;
		} else if (node.data > root.data) {
			return contains(root.rChild, node);
		} else {
			return contains(root.lChild, node);
		}
	}

代码不难理解,然后我们再考虑这样一种情况,就是一个有序的链表,是不是也符合二叉排序树的性质,也就是说我们查找的这棵二叉排序树是有可能退化为一个链表的,那么我们再对照着上面这个查找的代码去看,会发现查找的效率已经退化成了O(n),这显然不是我们想要看到的结果,我们想要的效果是尽可能的将一组数据分为等长的两部分,以达到类似“二分”的效果,只有这样最终的效率才可以达到O log(n)的最优效果,于是我们就引出了这样一个问题,如何保证二叉排序树查找的效率维持在O log(n)呢?接着往下看

b.平衡二叉树

在上面,我们发现如果不对二叉排序树做任何处理,发现查找的效率会有可能退化为链表的查找效率,所以我们期望有一种解决方案能避免效率的降低,现在再来想想,为什么我们的查找效率会降低,究其原因就是二叉树退化成了链表,那么我们必须以某种手段来防止退化,比如强制要求左右子树的高度差小于某个值等措施,于是我们自然而然想到了平衡二叉树。

 

所以解决方案就是让二叉排序树转换为平衡二叉树,这个就好说了,主要涉及到四个操作,左左旋(LL)、右右旋(RR)、左右旋(LR)、右左旋(RL)。通过在插入一个元素的时候,判断是否符合平衡二叉树的性质,也就是左右子树的高度差是否小于1,如果不符合,那么根据情况做相应的旋转处理即可。

由于几个旋转基本类似,只要掌握了一个,剩下的依葫芦画瓢即可,我们现在来看下左左旋的代码

	// 左左翻转 LL
	// 返回值为翻转后的根结点
	private Node leftLeftRotation(Node node) {
		// 首先获取待翻转节点的左子节点
		Node lNode = node.lChild;

		node.lChild = lNode.rChild;
		lNode.rChild = node;

		node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
		lNode.height = max(getHeight(lNode.lChild), node.height) + 1;

		if (node == root) {
			root = lNode;// 更新根结点
		}
		return lNode;
	}

剩下三个操作就不细说了,接下来我们再在插入和删除的时候,进行适当的判断,插入代码如下

	public void insert(int data) {
		if (root == null) {
			root = new Node(data);

		} else {
			insert(root, new Node(data));
		}
	}

	// node 为插入的树的根结点
	// insertNode 为插入的节点
	private Node insert(Node node, Node insertNode) {
		if (node == null) {
			node = insertNode;
		} else {
			if (insertNode.data < node.data) {// 将data插入到node的左子树
				node.lChild = insert(node.lChild, insertNode);
				// 如果插入后失衡
				if (getHeight(node.lChild) - getHeight(node.rChild) == 2) {
					if (insertNode.data < node.lChild.data) {// 如果插入的是在左子树的左子树上,即要进行LL翻转
						node = leftLeftRotation(node);
					} else {// 否则执行LR翻转
						node = leftRightRotation(node);
					}
				}
			} else if (insertNode.data > node.data) {
				// 将data插入到node的右子树
				node.rChild = insert(node.rChild, insertNode);
				// 如果插入后失衡
				if (getHeight(node.rChild) - getHeight(node.lChild) == 2) {
					if (insertNode.data > node.rChild.data) {
						node = rightRightRotation(node);
					} else {
						node = rightLeftRotation(node);
					}
				}
			} else {
				System.out.println("节点重复啦");
			}
			node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
		}
		return node;
	}

删除代码如下

	public void remove(int data) {
		Node removeNode = new Node(data);
		if (contains(root, removeNode)) {
			remove(root, removeNode);
		} else {
			System.out.println("节点不存在,无法删除"+data);
		}
	}

	// 删除节点
	private Node remove(Node node, Node removeNode) {
		if (node == null) {
			return null;
		}
		// 待删除节点在node的左子树中
		if (removeNode.data < node.data) {
			node.lChild = remove(node.lChild, removeNode);
			// 删除节点后,若失去平衡
			if (getHeight(node.rChild) - getHeight(node.lChild) == 2) {
				Node rNode = node.rChild;// 获取右节点
				// 如果是左高右低
				if (getHeight(rNode.lChild) > getHeight(rNode.rChild)) {
					node = rightLeftRotation(node);
				} else {
					node = rightRightRotation(node);
				}
			}
		} else if (removeNode.data > node.data) {// 待删除节点在node的右子树中
			node.rChild = remove(node.rChild, removeNode);
			// 删除节点后,若失去平衡
			if (getHeight(node.lChild) - getHeight(node.rChild) == 2) {
				Node lNode = node.lChild;// 获取左节点
				// 如果是右高左低
				if (getHeight(lNode.rChild) > getHeight(lNode.lChild)) {
					node = leftRightRotation(node);
				} else {
					node = leftLeftRotation(node);
				}
			}
		} else {// 待删除节点就是node
				// 如果Node的左右子节点都非空
			if (node.lChild != null && node.rChild != null) {
				// 如果左高右低
				if (getHeight(node.lChild) > getHeight(node.rChild)) {
					// 用左子树中的最大值的节点代替node
					Node maxNode = maxNode(node.lChild);
					node.data = maxNode.data;
					// 在左子树中删除最大的节点
					node.lChild = remove(node.lChild, maxNode);
				} else {// 二者等高或者右高左低
					// 用右子树中的最小值的节点代替node
					Node minNode = minNode(node.rChild);
					node.data = minNode.data;
					// 在右子树中删除最小的节点
					node.rChild = remove(node.rChild, minNode);
				}
				
			} else {
				// 只要左或者右有一个为空或者两个都为空,直接将不为空的指向node
				// 两个都为空的话,想当于最后node也指向了空,逻辑仍然正确
				node = node.lChild == null ? node.rChild : node.lChild;// 赋予新的值
			}
		}
		if(node!=null) {
			node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
		}
		return node;
	}

 每次在插入和删除时进行这样的操作之后,我们的二叉排序树终于变成一颗平衡二叉树啦,这样我们再执行上面一模一样的查找代码时,查找的效率就可以稳定在O log(n),完美!

 

其实仔细想想,这个问题,也是一个取舍的问题,虽然我们最后让二叉排序树的查找效率稳定为O log(n),但是我们却付出了不小的代价,就是每次插入以及删除的时候,都要进行大量的判断以及节点转换,这肯定会大大降低插入和删除的效率,与之带来的收益就是查找效率高,这样再一想,发现有点类似数组和链表的优缺点了,hhhh,事物总是有两面性,所以我们在实际使用时,也要根据场景来适当的做出取舍。

3.哈希表

在说哈希表之前,先回忆一下两个最简单的容器,一个是数组,一个是链表,这二者的优缺点我就不啰嗦了,之前的博客我也说过这样一个问题,既然数组查询快,链表插入删除快,就不能发明一种容器能兼具这二者的优点吗?这样岂不时省事多了,答案是能,没错,这个神奇的容器就是HashMap,而其核心就是哈希表,这时候,你兴冲冲的去查询哈希表,可能会遇到很多晦涩难懂的概念,什么关键字冲突,线性探测再散列,链地址法,再哈希等等,一下子头就大了,放心,我不会去给你灌输这些概念,我喜欢以实际使用的东西,或者叫“成品”来学习某个新的东西,然后再反过来看这些概念,这样就自然而然懂了,所以我们先简单了解HashMap的实现原理,再来看哈希表,就自然而然懂了。

既然是了解HashMap的实现原理,最最正确的方式就是直接打开jdk的源码去看,重点看核心方法put和remove即可,限于篇幅,我就说下大致思想。

我们首先需要知道的是HashMap存储的数据是键值对的形式,也就是key-value形式,然后就是HashMap里的一些重要成员变量及类,其中最重要的就是Node对象,每个Node对象含有key和value字段,用于保存插入的key和value值,Node数组的默认长度是16,当插入一个元素的时候,首先计算key的hash值,然后直接和数组长度-1做与运算,这样就定位到一个具体的下标,然后判断下标处是否有元素值存在,如果有,则以尾插法在该处形成一个链表,否则,就直接放入这个插入元素即可,所以最终效果就是这样的

看了这个结构图后,我们再回到我们的主题--查找,我们再来看看HashMap是如何查找的,首先拿到key值,计算key的hash值,然后同样的方法,和长度减1做与运算,得到下标,如果该下标处为空,则返回找不到,如果不为空,则从链表头开始,逐个遍历该链表,直到找到对应的value值与给定value值相等,若链表遍历完了仍然没有找到,则返回找不到。

我们现在再来看看这个设计有什么巧妙的地方,当我们在查询一个元素时,发现,对于哈希表来说,首先会根据key值来定位一个下标,这个巧妙利用了数组的优势,这样就不用去逐个遍历所有元素,然后如果发现该下标处已经存在了元素,则形成一个链表,而在形成一个链表之后,对同一个下标处的元素来说,插入删除的效率也变高了。或者换种通俗的话就是,使用数组将一个链表分割成了多个小段。总的来说这种设计就是结合了数组和链表,利用了二者的优势所在,完美结合!

好了,到这里,其实就已经学习了哈希表的一种,如上图,就是链地址法解决冲突的哈希表,相信到这里你也明白了,所谓的链地址法的具体含义,就是形成一个链表来解决冲突问题。

链地址法也是最常见的一种设计哈希表的方法,我们现在再来看看另外的两种。

线性探测再散列法,这种设计方式设计的哈希表的原理就是,当插入一个元素的时候,同样的先定位到一个下标,然后如果该下标处已经存在了元素,则判断下标+1的地方是否有元素存在,同样的如果仍然有元素存在,则下标继续+1,直到对应下标处没有元素存在时,则将元素插在这个地方。

再哈希法,这个设计方式就比较简单粗暴了,直接在计算key的hash值的方法时,也就是在定位具体的下标时,用两次hash函数来计算,这样原本一次hash计算到相同地方的元素,因为有第二次hash计算,所以会在二次hash函数处理之后,再次判断是否定位到了同一下标,若还是定位在统一下标,则继续hash函数处理,直到冲突不再发生。

我们仍然回到我们的主旨--查找上来,对这两种方法设计的哈希表,我们在查找时就是先定位,然后如果不存在元素,则“查找返回空”,否则比对对应的value值,如果不相等,则根据设计方法(线性探测再散列或再哈希)得到“下一个地址”,然后判断“下一个地址”是否为要查找的元素。

好了,到这里,基本说完了三种设计哈希表的方式以及对应的查找方法,个人觉得每一种方式都有自己的特点和适用场景,没有孰好孰坏,只有当我们都掌握了之后,我们才可以去选择用哪种方式来实现哈希表,完成业务要求。

结语

这次说的主要是查找,内容相对简单,但是查找这个东西,一但结合实际场景之后,还是有很多需要注意和深究的地方,比如海量数据排序和查找,所以只有会了基础,才能有选择的余地,以后实际场景中若是遇到了相关的问题,再总结归纳一篇“实际场景版”的,今天就到这里了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章