算法 第四版(二)符号表

342 阅读19分钟

参考书: 算法(第四版)

  1. 优秀的算法因为能解决实际问题而变得尤为重要
  2. 高效算法的代码也可以很简单
  3. 理解某个实现的性能特点是一项有趣而令人满足的挑战
  4. 在解决同一个问题的多种算法之间进行选择时,科学方法时一种重要的工具
  5. 迭代式改进能让算法的效率越来越高

2. 符号表

2.1 符号表

主要目的是将一个【键】和一个【值】联系起来 image.png image.png

2.1.1 相关规定

我们所有的实现都遵循以下规则:

  • 每个【键】都对应着一个值(表中不存在重复的键)
  • 当向表中存入键值和已有的键冲突时,覆盖对应【键】的值

这些规则定义了关联数组的抽象形式,你可以将符号表想象成一个数组,键即索引,值即数组的元素

【键】不能为空

我们还规定值不能为空,应为我们的API定义中,当键不存在时get()方法会返回null,这也意味着任何不在表中的键关联的值为空

2.1.2 删除操作

在符号表中,删除的实现有两种方法

  • 延时删除:将键对应的值置为null,然后在某个时候删去所有值为null的键
  • 即时删除:立刻从表中删除对应的键

2.2 基于链表实现的无序符号表

2.2.1 实现

public class SequentialSearchST<Key, Value> {
	private Node first;	//头结点
	
	private class Node{	//私有内部类
		Key key;
		Value val;
		Node next;
		public Node(Key key, Value val, Node next) {
			this.key = key;
			this.val = val;
			this.next = next;
		}
	}
	
	/**
	 * 查找给定的键,返回对应值
	 */
	public Value get(Key key) {
		for(Node x = first; x != null; x = x.next) {
			if(key.equals(x))
				return x.val;
		}
		return null;
	}
	
	/**
	 * 查找给定的键,找到则更新其值,否则表头新建节点
	 */
	public void put(Key key, Value val) {
		for(Node x = first; x != null; x = x.next) {
			if(key.equals(x))
				x.val = val;
		}
		first = new Node(key, val, first);	//未命中,新建节点
	}
}

命题:

在含有N对键值的基于无序链表的符号表中,未命中的查找和插入操作都需要N次比较。命中的查找在最坏情况下也需要N次比较。

像一个空表插入N个节点需要1+2+3+…+N次比较

上述命题说明了基于链表实现的顺序查找是非常低效的,根本无法满足大数据量操作的需求(时间复杂度达到了平方级别)

2.3 有序数组的二分查找

该算法实现的核心在rank()方法,它返回表中小于给定键的数量

而我们使用有序数组存储键的原因是二分查找法能够根据数组的索引大大减少每次查找所需要的比较次数

2.3.1 实现

	/**
	 * 思考?为什么循环结束时,lo值正好等于表中小于被查找的键的数量
	 */
	public int rank(Key key) {
		int lo = 0;
		int hi = N-1;
		while(lo <= hi) {
			int mid = (lo+hi)/2;
			int cmp = key.compareTo(keys[mid]);
			if(cmp < 0) hi = mid-1;
			else if(cmp > 0) lo = mid+1;
			else return mid;
		}
		return lo;
	}

2.3.2 复杂度分析

命题

在N个键的有序数组中进行二分查找最多需要(logN+1)次比较

尽管能够保证查找所需时间是对数级别的,该算法仍然无法保证处理大型问题,因为put()方法太慢了

二分查找虽然减少了比较次数,但无法减少运行所需时间,因为它无法改变以下事实:

在键是随机排列的情况下,构造一个基于有序数组的符号表需要访问数组的次数是数组长度的平方级别

image.png

命题

x向大小N的有序数组插入一个新的元素最坏情况下需要访问2N次数组,因此像一个空符号表插入N个元素需要 访问N2次数组

2.4 二叉查找树

image.png

2.4.1 定义

我们可以将二叉树定义为一个空链接,或者一个有左右两个链接的节点,每个连接都指向一个(独立的)子二叉树

一颗二叉查找树(BST)是一颗二叉树,其中每个节点都含有一个Comparable的键且每个节点的键都大于其左子树的任意节点,小于右子树的任意节点

2.4.2 实现

/**
 * 二叉查找树
 *
 */

public class BST<Key extends Comparable<Key>, Value> {
	private Node root;	//根节点
	
	private class Node{
		private Key key;
		private Value val;
		private Node left;
		private Node right;
		private int N;	//节点总数
		public Node(Key key, Value val, int N) {
			this.key = key;
			this.val = val;
			this.N = N;
		}
		
	}
	//计算树的总节点数
	public int size() {
		return size(root);
	}
	private int size(Node x) {
		if(x == null)	return 0;
		else	return size(x.left)+size(x.right) + 1;
	}
	
	public Value get(Key key) {
		return get(root, key);
	}
	private Value get(Node x, Key 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;
	}
	
	/**
	 * 查找key, 找到则更新它的值,否则为它创建一个新的节点
	 */
	public void put(Key key, Value val) {
			root = put(root, key, val);
	}
	/**
	 * 如果key存在于以x为根节点的子树中更新它的值
	 * 否则将以key和val作为新节点插入该子树中
	 */
	private Node put(Node x,Key key, Value 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.N = size(x.left) + size(x.right) + 1;
		return x;
	}
}

2.4.3 插入

上述代码中的查找代码几乎和二分查找一样简单,这种间接性是二叉查找树的重要特性之一

而二叉查找树的另一个重要特性是插入的实现难度和查找差不多

当查找一个不存在于树中的节点并结束与一条空链接时,我们需要做的就时让链接指向一个新节点:

  • 如果树是空的,返回一个新节点
  • 如果被查找的键小于根节点的键,就继续递归地在左子树中插入该键
  • 否则,在右子树中插入该键

2.4.4 递归

因为树本身就是递归定义的,很自然地我们会想到用递归的思想来解决问题

我们可以将调用的代码想象成沿着树向下走:它会将给定的键和每个节点的键相比较并根据结果向左或者向右移动

对于调用的代码可以想象成沿着树向上爬(这时候会更新树结构) image.png

2.4.5 算法分析

使用二叉查找树的算法的运行时间取决于树的形状,而树的形状又取决于键被插入的先后顺序

最好的情况——N个节点的树是完全平衡的

最坏的情况——搜索路径上可能有N个节点,这就变成线性表了

image.png

对于很多应用来说,下图的简单模型都是适用的:

我们假设键的分布是(均匀)随机的,或者说他们的插入顺序是随机的

对于这个模型的分析而言,二叉查找树和快速排序几乎是双胞胎————树的根节点就是快速排序第一个切分的元素,左侧的键都小于它,右侧的键都大于它,而这对于所有的子树同样使用,这和快速排序中对子数组的第归排序是完全对应

image.png

命题

在由N个随机键构造的二叉查找树中,查找命中平均所需要的比较次数为~~2lnN(约1.39lgN)

image.png

2.4.6 最大键和最小键

如果根节点的左儿子为空,那么一颗二叉查找树的最小键就是根节点,否则,则一直递归到最左侧

	/**
	 * 递归的找最大键
	 */
	public Key max() {
		return max(root);
	}
	
	private Key max(Node x) {
		if(x.right == null)		return x.key;
		else return max(x.right);
	}
	
	/**
	 * 递归的找最小键
	 */
	public Key min() {
		return min(root);
	}
	
	private Key min(Node x) {
		if(x.left == null)		return x.key;
		else return min(x.left);
	}

2.4.7 向上取整和向下取整

  • 如果给定的键小于二叉查找树的根节点的键,那么小于等于key的最大键(floor)一定在根节点的左子树中
  • 如果给定的键key大于根节点,那么只有当根节点右子树中存在小于等于key的节点时,小于等于key的最大键才会出现在右子树中
	/**
	 * 小于等于key的最大键
	 */
	public Key floor(Key key) {
		return floor(root, key);
	}
	
	private Key floor(Node x, Key key) {
		if(x == null)	return null;
		int cmp = key.compareTo(x.key);
		if(cmp < 0)	return floor(x.left, key);
		else if(cmp == 0)	return x.key;
		Key t = floor(x.right, key);	//看小于等于key的最大键是否在右子树中,很精彩!
		if(t != null)	return t;
		else 	return x.key;
	}

2.4.8 选择

二叉查找树的选择操作和之前我们学过的(2.6.2)基于切分的数组的选择操作类似

我们在每个节点中维护子树节点数N就是用来支持此操作的

	/**
	 * 返回排名为k的节点
	 */
	public Key select(int k) {
		return select(root, k);
	}
	
	private Key select(Node x, int k) {
		if(x == null)	return null;
		int t = size(x.left);
		if(k < t )	return select(x.left, k);
		else if(k > t)	return select(x.right,k-t-1);
		else return x.key;
	}

2.4.9 排名

rank()是select()的逆方法,它会返回给定键的排名

  • 如果给定的键和根节点的键相等,则返回左子树节点总数(小于当前节点的个数)
  • 如果小于根节点,我们则返回在左子树的排名(递归)
  • 同理,若大于根节点,则返回在(左子树的节点总数 + 1 + 右子树中的排名)

下面,我们需要考虑递归时的基准情况(Base Case):

  • 若根节点为null,返回0
  • 若根节点不为null,分为小于、等于和大于三种情况
	/**
	 * 返回小于root.key的数量
	 */
	public int rank(Key key) {
		return rank(root, key);
	}
	
	private int rank(Node x, Key key) {
		if(x == null)	return 0;
		int cmp = key.compareTo(x.key);
		if(cmp == 0) return size(x.left);
		else if(cmp < 0)	return rank(x.left, key);
		else return rank(x.right, key) + 1 + size(x.left);
	}

2.4.10 删除最大键和最小键

二叉查找树中最难实现的时delete()方法

作为热身,我们先从删除最小键和最大键开始(同样地,还是用递归思想来解决)

我们的递归方法接受一个指向节点的链接,并返回一个指向节点的链接,这样我们就能够方便地改变树的结构,将返回的链接赋给作为参数的链接

对于删除最小键,我们要不断深入树的左子树,直到遇到一个空节点。这时候我们返回该节点的右节点即可改变当前树的结构了(此时没有任何链接指向的节点会被回收)

image.png

	/**
	 * 删除最小键
	 */
	public void deleteMin() {
		root = deleteMin(root);
	}
	
	private Node deleteMin(Node x) {
		if(x.left == null)	return x.right;
		else {
			x.left = deleteMin(x.left);
			x.N = size(x.left) + size(x.right) + 1;
			return x;
		}
	}
	
	/**
	 * 删除最大键
	 */
	public void deleteMax() {
		root = deleteMax(root);
	}
	
	private Node deleteMax(Node x) {
		if(x.right == null)	return x.left;
		else {
			x.right = deleteMin(x.right);
			x.N = size(x.left) + size(x.right) + 1;
			return x;
		}
	}

2.4.11 删除操作

  • 删除算法实现并不简单请仔细理解正文的讲解

我们可以用类似的方法来删除任意一个只有一个子节点(或者没有子节点)的节点

但是我们怎样删除一个拥有两个子节点的节点呢?删除之后,我们要处理两个子树

解决方法:当删除了x后,将用x的右子树中最小节点来作为当前新的x

我们能用4个简单的步骤完成替换操作

  1. 将指向即将删除的节点保存为T
  2. 将x指向它的后继节点 min(x.right)
  3. 将x的有链接指向 deleteMin(t.right)
  4. 将x的左连接设为t.left

image.png

	/**
	 * 删除任意节点
	 */
	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.left == null)	return x.right;
			if(x.right == null)	return x.left;
			//处理有两个子节点的情况,需要用其右子树的最小节点作为当前的根节点
			Node t = x;
			x = minNode(t.right);
			deleteMin(t.right);
			x.left = t.left;
		}
		x.N = size(x.left) + size(x.right) + 1;	//更新节点计数器
		return x;
	}
	private Node minNode(Node x) {
		if(x.left == null)		return x;
		else return minNode(x);
	}

2.4.12 二叉树遍历

具体见《OneNote笔记》

思路:当每一轮遍历时,把当前该子树想象成一个新的树,不断地递归

(递归的实现很简单,那么迭代呢?)

2.4.13 性能分析

在一个二叉树中,树的高度决定了所有操作在最坏情况下的性能

总的来说,二叉查找树的实现并不困难,但是二叉查找树在最坏情况下的恶劣性能时不能接受的

如果所有键都是按顺序或逆序插入符号表就会使得二叉查找树的性能直接变成了线性符号表的等级

为了避免这种极端情况的发生,我们引入了————平衡二叉树

image.png

2.5 2-3查找树

定义:

  • 2- 节点,含有一个键、两条连接
  • 3- 节点,含有两个键、三条连接,左儿子小于节点,中儿子位于两键之间,右儿子大于节点

image.png

2.5.1 查找

image.png

2.5.2 插入

为了保持插入后树的平衡性:

  1. 如果未命中的查找结束于2- 节点,我们只需要把2- 节点换成3- 节点,将新键插入其中

image.png

  1. 如果未命中的查找结束于3- 节点,事情会麻烦一点

2.5.2.1 向一颗只含有一个3- 节点的树插入新键

为了将新键插入,我们要先临时建立一个4- 节点(含有三个键和四条链接)

然后将其转换成一个含有三个2- 节点的树(中键作为根)

image.png

2.5.2.2 向一个父节点为2-节点的3-节点插入新键

在这种情况下,我们先像刚才一样构造一个4- 节点,并将其分解

按我们此时不会为中键创建一个新节点,而是将其移动到原来的父节点中,你可以将这次转换看成:

指向原3- 节点的一条连接替换为新父节点的原中键左右两边的两条链接,并分别指向两个新的2- 节点

image.png

2.5.2.3 向一个父节点为3- 节点的3- 节点中插入新键

我们构造一个4- 节点然后分解它,然后将他的中键插入到父节点中

但是父节点仍然是一个3- 节点,需要再次构造一个4- 节点,在这个节点上做相同的变换,一直不断向上分解直到遇到一个2- 节点并替换为不需要再分解的3- 节点【即3.5.2.2的情况】

image.png

2.5.2.4 分解根节点

如果从插入节点到根节点的路径上全部是3- 节点,我们的根节点最终也会变成一个4- 节点

此时我们可以按照一颗只有一个3- 节点的树中插入新键的做法,将其分解为3个2- 节点(树高+1)

image.png

2.5.3 小结

image.png

4- 节点的分解是一次局部变换,不会影响树的有序性和平衡性

只有当根节点为4- 节点时,分解后树高才会+1 image.png

标准二叉查找树自上向下的生长:每次的插入都是无脑的放在叶子节点上,很容易导致树的“倾斜”

2-3树是自下向上生长的:插入的时候“稳一手”,防止其无脑向下“伸展”,用一个4-节点hold住,然后调整树的局部平衡性

一棵大小N的2-3树中,查找和插入操作访问的节点必然不超过lgN

2.5.4 不足

但是我们和真正的实现还有距离,因为需要处理的情况实在太多:

需要维护两种不同类型的节点,将节点从一种数据结构(2节点)换到另一种数据结构(3节点)很麻烦

幸运的是,我们只需要一点点代价就能用一种统一的方式完成————红黑树

2.6 红黑树

2.6.1 定义

红黑树背后的基本思想——就是用标准的二叉查找树(完全由2-节点构成)和一些额外的信息(替换3-节点)来表示2-3树

我们将连接分成两种类型:

  • 链接将两个2-节点连接起来构成3-节点

image.png

  • 链接则是2-3树的普通链接

另一种定义是:

  • 链接均为链接
  • 没有任何一个节点同时和两条红链接相连
  • 完美黑色平衡:任意空连接到根的路径上黑节点的数量相同

那么,红黑树就将二叉树高效的查找方法2-3树的高效平衡插入 两者的优点结合到了一起

image.png

2.6.2 节点表示

我们用布尔量表示颜色——true=红色,false=黑色(空节点默认黑色)

	private static final boolean RED = true;
	private static final boolean BLACK = false;

	private class Node{
        Key key;
        Value val;
        Node left,right;
        int N;	//节点总数
        boolean color;//颜色
        
        Node(Key key, Value val, int N, boolean color){
            this.key = key;
            this.val = val;
            this.N = N;
            this.color = color;
        }
    }

	private boolean isRed(Node x){
        if(x == null)	return false;
        return x.color == RED;
    }

2.6.3 旋转

我们在实现的某些操作中,可能会出现红色右连接或者两条连续的红链接,这种时候,我们需要小心地旋转并修复

首先,假设我们有一个红右连接,我们需要转换成红左连接————称为左旋转

image.png

同理,右旋转则相反

image.png

2.6.3.1 在旋转后重置父节点的链接

在旋转之后,我们总是会返回相应的链接,这也使得出现一种情况——产生连续两条的红链接

但我们的算法会继续用旋转在纠正它

2.6.3.2 向2- 节点插入新键

一棵只含有一个键的红黑树只含有1个2- 节点,插入另一个键后,我们马上就需要将它们旋转

  • 如果新键小于老键,我们只需要增加一个红节点,这于3- 节点完全等价
  • 如果新键大于老键,那么新增的红色节点会产生一条红链接,我们进行一次左旋转即可

image.png

2.6.3.3 向底部的2- 节点插入新键

用和二叉树相同的方式在红黑树中插入新键,会在树的底部新增一个节点

我们总是用红链接将新节点和它的父节点相连

  • 如果父节点是2- 节点,那么上面的方法同样适用
  • 如果是父节点的左链接指向新节点,那么即成为了一个3- 节点; 如果是父节点的右链接指向新节点,我们则需要进行左旋转

image.png

2.6.3.4 向一颗双键树(即一个3- 节点)插入新键

分为三个子情况

  1. 新键小大原树中的两个键:则连接到3- 节点的右链接,此时树是平衡的,我们再将两个红链接变为黑色,就得到了一个3个节点组成的树(相当于3.5.2.1的情况)

image.png

  1. 新键小于原树中的两个键:它会被链接到最左边的空连接,这是就产生了两个连续的红链接,此时我们需要将上层的红链接右旋转即得到第一种情况

image.png

  1. 新键介于两键之间:这又会产生两条连续的红链接(即插入到红节点的右链接),此时我们需要将下层的两个红链接左旋转,即得到了第二种情况,由第二种情况我们又可以得到第一种情况(精彩!)

image.png

2.6.3.5 颜色转换

我们用flipColors()方法来转换一个节点的两个红色子节点的颜色,将其变为黑色,并将父节点的颜色变红

这项操作的重要性质在于:和旋转操作一样都是局部变换,不会影响整棵树的平衡性

	void flipColors(Node h){
        h.color = RED;
        h.left.color = BLACK;
        h.right.color = BLACK;
    }

2.6.3.6 根节点总是黑色

我们每次插入时,都会将根节点设为黑色

注意,每当根节点由红遍黑时,树的黑链接高度会+1

2.6.3.7 向树底部的3- 节点插入新键

现在假设我们需要在树的底部的一个3- 节点下加一个新节点,那么3.6.3.4的三种情况都会出现

  • 三者最大,此时转换颜色即可
  • 三者最小,上层右旋转后,转换颜色
  • 介于中间,下层左旋转,再上层右旋转后,转换颜色

(颜色转换意味着中间的节点会变红,相当于将它送入了父节点,这意味着再父节点中继续插入一个新键,我们也会用相同的办法解决)

2.6.3.8 将红链接在树中向上传递

之前讲述的三种情况说明了红黑树实现2-3树的插入算法的关键步骤:

要在一个3- 节点 插入新键,先建立一个临时的4- 节点,然后分解将红链接由中间键传给父节点

2.6.4 实现

因为保持树的平衡性所需的操作时由下向上在每个所经过的节点中进行的,将它们植入我们已有的实现中非常简单:只要在递归调用之后完成即可

	/**
	 * if there exists a node.key that equals this new key, make node.val = val
	 * otherwise, insert a new node into this tree 
	 */
	public void put(Key key, Value val) {
		root = put(root,  key, val);
	}
	
	private Node put(Node x, Key key, Value val) {
		if(x == null)	return new Node(key, val, 1, RED);
		
		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;
		
		//after putting the node, we must keep tree's balance
		//let's think about three conditions
		if(isRed(x.right) && !isRed(x.left))	x = rotateLeft(x);
		if(isRed(x.left) && isRed(x.left.left))	x = rotateRight(x);
		if(isRed(x.left) && isRed(x.right))		flipColors(x);
		
		x.N = size(x.left) + size(x.right) + 1;
		
		return x;
	}

关键代码在于三个 if 语句

  1. 第一条if:会将任意含有红色右链接的3- 节点左旋转
  2. 第二条if:会将连续两个红链接中上层右旋转
  3. 第三条if:转换颜色

2.6.5 删除

(实现更为复杂,作为进阶阶段学习)

2.6.6 性质

首先,无论键的插入顺序如何,红黑树几乎都是完美平衡的

一颗大小为N的红黑树的高度不会超过2lgN

一颗大小为N的红黑树,根节点到任意节点的平均路径长度为~1.00lgN

由于get()方法不会care节点的颜色,所以查找效率 > 插入效率

image.png

2.7 散列表

如果所有的数都是小整数,我们可以用数组来实现符号表

键——数组索引

值————索引对应的数组值

这样我们就可以快速访问任意键的值

使用散列的查找算法分为两步:

  1. 散列函数将被查找的键转化为数组索引,因此我们可能会出现多个键散列到同一索引下
  2. 处理碰撞冲突的过程(拉链法、线性探测法

散列表是算法在时间和空间上作出权衡的经典例子

2.7.1 散列函数

这个过程会将键转化为数组的索引

2.7.1.1 正整数

选择一个大小为M的素数,计算任意 K%M的结果

2.7.1.2 字符串

除余法也可以处理较长的键,例如字符串

我们只需要将字符串当成大整数即可

int hash = 0;
for(int i=0; i<s.length(); i++){
    hash = (R * hash + s.charAt(i)) % M;
}

image.png

2.7.2 拉链法

当产生两个或多个键的散列值相同的情况,一种直接的方法就是将M个数组中的每个元素指向一条链表

这时的查找分为两步:

  • 找到散列值对应的链表
  • 按链表顺序查找
public class SeparateChainingHashST<Key, Value> {
	private int N;	//aoumt of <key,value>
	private int M;	//size of hash table
	
	private SequentialSearchST<Key, Value>[] st;	//hash array

	public SeparateChainingHashST() {
		init(997);
	}
	
	public void init(int M) {
		this.M = M;
		st = (SequentialSearchST<Key, Value>[]) new SequentialSearchST[M];
		for(int i=0; i<M; i++) {
			st[i] = new SequentialSearchST();
		}
	}
	
	private int hash(Key key) {
		return (key.hashCode() & 0x7fffffff) % M;	//屏蔽最高位,将整数转成一个 非负整数取余
	}
	
	public Value get(Key key) {
		return st[hash(key)].get(key);
	}
	
	public void put(Key key, Value val) {
		st[hash(key)].put(key, val);
	}
}

当能预先知道符号表的大小时,另一种更可靠的方案:动态调整数组大小

基于拉链法的散列表的实现简单,在键的顺序不重要的应用中,它可能时最快的(也是最广泛的)符号表实现

2.7.3 线性探测法

另一种方法就是用大小M的数组保存N个键值对,其中M>N

我们需要依靠数组中的空位解决碰撞冲突,基于这种策略的所有方法称为——开放地址散列表

其中一个最简单的方法叫做:线性探测法

当碰撞发生时,我们直接检查散列表中的下一个位置,这样可能产生三种情况:

  1. 命中,该位置的键和被查找的键相同
  2. 未命中,该位置没有键
  3. 继续查找,该位置键于被查找的键不同
public class LinearProbingHashST<Key, Value> {
	private int N;		//键值总对数
	private int M = 16;
	private Key[] keys;
	private Value[] vals;
	public LinearProbingHashST() {
		keys = (Key[]) new Object[M];
		vals = (Value[]) new Object[M];
	}
	
	private int hash(Key key) {
		return (key.hashCode() & 0x7fffffff) % M;	//屏蔽最高位,将整数转成一个 非负整数取余
	}
	
	public Value get(Key key) {
		for(int i=hash(key); keys[i] != null; i = (i+1)%M) {
			if(keys[i].equals(key))
				return vals[i];
		}
		return null;
	}
	
	private void resize(int cap) {
		LinearProbingHashST<Key, Value> t;
		t = new LinearProbingHashST<Key, Value>(n);
		for(int i=0; i<M; i++) {
			if(keys[i] != null) {
				t.put(keys[i], vals[i]);
			}
		}
		keys = t.keys;
		vals = t.vals;
		M = t.M;
	}
	
	public void put(Key key, Value val) {
		if(N >= M/2)
			resize(2*M);
		int i;
		for(i = hash(key); keys[i] != null; i = (i+1)%M) {
			if(keys[i].equals(key)) {
				vals[i] = val;
				return;
			}
		}
		keys[i] = key;
		vals[i] = val;
		N++;
	}
}

线性探测法的思路就是:

  • 插入: 如果散列值对应的索引已经不为null(发生了碰撞),就依次向后走,直到遇到一个空位置坐下
  • 查找:
    1. 如果散列值对应的索引处的键和被查找的键相同,命中
    2. 如果对应索引的键位空,未命中
    3. 如果不相等,继续向后走,直到遇到空位置(查找失败

2.7.3.1 删除操作

我们直接删除对应位置的键不行的,因为之后的键可能因此无法查找

所以我们需要将当前键删了之后,将后面直到为空之前的所有键重新插入

2.7.3.2 键簇

我们将一组连续的条目,叫做键簇

显然,当键簇越短时才能保证较高的效率,随着插入的键越来越多,这个要求很难满足,较长的键簇会越来越多

image.png

要去理解并内化知识的精髓,而不是单单去死记住这些概念性的东西,毕竟这些知识是别人的东西,别人的思想再怎么搬运还是别人的,要学会从别人那里领悟到本质,并转化为自己的东西,让自己也具备发现问题、解决问题的能力