1.1 基础编程模型
1.2 数据抽象
1.3 背包丶队列和栈
1.4 算法分析
第3章 查找
3.1 符号表
3.1.1 API
3.1.2 有序符号表
3.1.3 无序链表实现
3.1.4 有序数组的二分查找
3.1.5 对二分查找的分析
3.2 二叉查找树
3.2.1 get()
3.2.2 put()
3.2.3 分析
3.2.4 floor()
3.2.5 rank()
3.2.6 min()
3.2.7 deleteMin()
3.2.8 delete()
3.2.9 keys()
3.2.10 性能分析
3.3 平衡查找树
3.3.1 2-3查找树
3.3.2 红黑二叉查找树
3.4 散列表
3.4.1 散列函数
3.4.2 基于拉链法的散列表
3.4.3 基于线性探测法的散列表
3.5 应用
第1章 基础
- 算法用来描述一种有限, 确定, 有效的并适合用计算机程序来实现的解决问题的方法.
1.1 基础编程模型
- 本书使用的Std标准库包括: StdIn, StdOut, StdDraw, StdRandom, StdStats, In, Out.
- 重定向: 将文件作为标准输入输出;
- 输出重定向:
java Main > data.txt
- 输入重定向:
java Main < data.txt
- 管道: 将一个程序的输出重定向到另一个程序的输入;
java Main1 | java Main2
- 一正一负两个整数的余数符号与第一个数相同.
- 没有数组类型, 所以System.out.println()方法打印数组只能打印出数组的地址.
- 二分查找
public static int rank(int key,int[]a){
int lo = 0, hi = a.length-1;
while(lo<=hi){
int mid = lo + (hi-lo)/2; // 不用(lo+hi)/2是为了防止加法溢出
if(key == a[mid]) return mid;
if(key<a[mid]) hi = mid-1;
else lo = mid+1;
}
return -1;
}
1.2 数据抽象
- 数据类型是一组值和一组对这些值得操作的集合, 抽象数据类型(ADT)是一种能够对使用者隐藏数据表示的数据类型.
- String的split()方法, 常用的参数是
"\\s+"
, 表示一个或者多个制表符, 空格, 换行, 或回车. - 等价性:①自反性②对称性③传递性④一致性: 当两个对象均未被修改, 反复测试结果不变;⑤非空性:与null的测试结果为false;
- java中==是测试是否为同一个引用, equal方法才是等价性的测试.
- java通过final关键字将一个变量声明为不可变的, 例如final int[] a; 但是不可变性对引用对象来说只是说明它不可以引用其它对象, 对象的值可以通过其它引用来进行改变. 例如
int[]b = {1,2};
final int[] a = b;
b[0] = 2;
1.3 背包丶队列和栈
- 泛型: 一份实现处理任意类型
- 迭代: 使用foreach访问集合中的每个元素, 而不用考虑是哪种类型
- 背包和集合类似, 只是不能删除元素
- 表达式求值:
① 将操作数压入操作数栈中;
② 将运算符压入运算符栈中;
③ 忽略左括号;
④ 遇到右括号, 弹出运算符, 并弹出合适数量的操作数, 将运算符和操作数的运算结果压入操作数栈中.
注: 以下实现没有考虑运算符优先级.
public class Evaluate {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
String s = in.nextLine();
String[] token = s.split("\\s+");
Stack<String> ops = new Stack<>();
Stack<Double> vals = new Stack<>();
for(String str:token){
if(str.equals("+")||str.equals("-")||str.equals("*")||str.equals("/")||str.equals("sqrt")){
ops.add(str);
}else if(str.equals(")")){
String op = ops.pop();
double val1 = vals.pop();
double newVal = 0;
if(op.equals("sqrt")){
newVal = Math.sqrt(val1);
}else{
double val2 = vals.pop();
if(op.equals("+")) newVal = val1+val2;
else if(op.equals("-")) newVal = val1-val2;
else if(op.equals("*")) newVal = val1*val2;
else if(op.equals("/")) newVal = val1/val2;
}
vals.add(newVal);
}else if(str.equals("(")){}
else vals.add(Double.parseDouble(str));
}
System.out.println(vals.pop());
}
}
- java只能通过以下方法创建泛型数组:
Item[] arr = (Item[]) new Object[N];
- 当不需要使用一个对象时, 需要将该对象的引用置为空, 让垃圾回收机制去回收它, 防止对象游离.
- 迭代器的实现: 实现Iterable接口, 该接口有一个iterator()方法, 该方法返回Iterator对象. Iterator类有三个方法,hasNext(),next()和remove(), 一般情况remove()不用实现.
- 栈的数组实现
import java.util.Iterator;
public class ResizeArrayStack<Item> implements Iterable<Item> {
private Item[] a = (Item[]) new Object[1];
private int N = 0;
public void push(Item item) {
if (N >= a.length) {
resize(2 * a.length);
}
a[N++] = item;
}
public Item pop() {
Item item = a[--N];
if (N <= a.length / 4) {
resize(a.length / 2);
}
return item;
}
private void resize(int size) {
Item[] tmp = (Item[]) new Object[size];
for (int i = 0; i < N; i++) {
tmp[i] = a[i];
}
a = tmp;
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
@Override
public Iterator<Item> iterator() {
return new ReverseArrayIterator();
}
private class ReverseArrayIterator implements Iterator<Item> {
private int i = N;
@Override
public boolean hasNext() {
return i > 0;
}
@Override
public Item next() {
return a[--i];
}
}
public static void main(String[] args) {
ResizeArrayStack<Integer> stack = new ResizeArrayStack<>();
stack.push(1);
stack.push(2);
stack.push(3);
for (int num : stack) {
System.out.println(num);
}
}
}
- 栈的链表实现
注: 栈需要在一端进行插入和删除操作, 只有在头部才能同时进行这两项操作, 因为在头部进行的操作, next指向的是前一个节点.
public class Stack<Item> {
private Node top = null;
private int N = 0;
private class Node {
Item item;
Node next;
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
public void push(Item item) {
Node newTop = new Node();
newTop.item = item;
newTop.next = top;
top = newTop;
N++;
}
public Item pop() {
Item item = top.item;
top = top.next;
N--;
return item;
}
}
- 队列的链表实现
注: 出队列操作需要知道前一个元素, 因此该操作在链表头进行, 因为链表头的前一个元素及next元素.
public class Queue<Item> {
private Node first;
private Node last;
int N = 0;
private class Node{
Item item;
Node next;
}
public boolean isEmpty(){
return N==0;
}
public int size(){
return N;
}
public void enqueue(Item item){
Node newNode = new Node();
newNode.item = item;
newNode.next = null;
if(isEmpty()){
last = newNode;
first = newNode;
}else{
last.next = newNode;
last = newNode;
}
N++;
}
public Item dequeue(){
Node node = first;
first = first.next;
N--;
return node.item;
}
public static void main(String[] args) {
Queue<Integer> queue = new Queue<>();
queue.enqueue(1);
queue.enqueue(2);
queue.enqueue(3);
while(!queue.isEmpty()){
System.out.println(queue.dequeue());
}
}
}
1.4 算法分析
- 计时器
public class StopWatch {
private final long start;
public StopWatch() {
start = System.currentTimeMillis();
}
public double elapsedTime() {
long now = System.currentTimeMillis();
return (now - start) / 1000.0;
}
public static void main(String[] args) {
StopWatch stopWatch = new StopWatch();
int n = 1000;
int[][] a = new int[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
a[i][j] = (int) Math.sqrt(Math.pow(i, 2) + Math.pow(i, 2));
}
}
System.out.println(stopWatch.elapsedTime() + " s");
}
}
- 指数函数可以转换为线性函数, 从而在函数图像上显示的更直观.
注:右图的自变量为lgN
第3章 查找
使用三种经典的数据类型来实现高效的符号表:二叉查找树、红黑树和散列表。
3.1 符号表
3.1.1 API
- 当一个键的值为null时,表示不存在这个键,因此可以使用put(key, null)作为delete(key)的一种延迟实现。
3.1.2 有序符号表
有序符号表的键需要实现Comparable接口。
查找的成本模型:键的比较次数,在不进行比较时使用数组的访问次数。
3.1.3 无序链表实现
- 复杂度:向一个空表中插入N个不同的键需要~N2/2次比较。
3.1.4 有序数组的二分查找
public class BinarySearchST<Key extends Comparable<Key>, Value> {
private Key[] keys;
private Value[] values;
private int N;
public BinarySearchST(int capacity) {
keys = (Key[]) new Comparable[capacity];
values = (Value[]) new Object[capacity];
}
public int size() {
return N;
}
public Value get(Key key) {
int i = rank(key);
if (i < N && keys[i].compareTo(key) == 0) {
return values[i];
}
return null;
}
public int rank(Key key) {
int lo = 0, hi = N - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
int cmp = key.compareTo(keys[mid]);
if (cmp == 0) return mid;
else if (cmp < 0) hi = mid - 1;
else lo = mid + 1;
}
return lo;
}
public void put(Key key, Value value) {
int i = rank(key);
if (i < N && keys[i].compareTo(key) == 0) {
values[i] = value;
return;
}
for (int j = N; j > i; j--) {
keys[j] = keys[j - 1];
values[j] = values[j - 1];
}
keys[i] = key;
values[i] = value;
N++;
}
public Key ceiling(Key key){
int i = rank(key);
return keys[i];
}
}
使用一对平行数组,一个存储键一个存储值。
需要创建一个Key类型的Comparable对象数组和一个Value类型的Object对象数组。
rank()方法至关重要,当键在表中时,它能够知道该键的位置;当键不在表中时,它也能知道在何处插入新键。
3.1.5 对二分查找的分析
复杂度:二分查找最多需要(lgN+1)次比较,使用二分查找实现的符号表的查找操作所需要的时间最多是对数级别的。但是插入操作需要移动数组元素,是线性级别的。
3.2 二叉查找树
定义:二叉树定义为一个空链接,或者是一个右左右两个链接的节点,每个链接都指向一颗子二叉树。二叉查找树(BST)是一颗二叉树,每个节点的键都大于其左子树中的任意节点的键而小于右子树的任意节点的键。
二叉查找树的查找操作每次迭代都会让区间减少一半,和二分查找类似。
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 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;
return x.N;
}
}
3.2.1 get()
如果树是空的,则查找未命中;如果被查找的键和根节点的键相等,查找命中,否则递归地在子树中查找:如果被查找的键较小就在左子树中查找,较大就在右子树中查找。
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 x.val;
else if (cmp < 0) return get(x.left, key);
else return get(x.right, key);
}
3.2.2 put()
当插入的键不存在于树中,需要创建一个新节点,并且更新上层节点的链接使得该节点正确链接到树中。
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);
int cmp = key.compareTo(x.key);
if (cmp == 0) x.val = val;
else if (cmp < 0) x.left = put(x.left, key, val);
else x.right = put(x.right, key, val);
x.N = size(x.left) + size(x.right) + 1;
return x;
}
3.2.3 分析
二叉查找树的算法运行时间取决于树的形状,而树的形状又取决于键被插入的先后顺序。最好的情况下树是完全平衡的,每条空链接和根节点的距离都为lgN。在最坏的情况下,树的高度为N。
复杂度:查找和插入操作都为对数级别。
3.2.4 floor()
如果key小于根节点的key,那么小于等于key的最大键节点一定在左子树中;如果key大于根节点的key,只有当根节点右子树中存在小于等于key的节点,小于等于key的最大键节点才在右子树中,否则根节点就是小于等于key的最大键节点。
public Key floor(Key key) {
Node x = floor(root, key);
if (x == null) return null;
return x.key;
}
private Node floor(Node x, Key key) {
if (x == null) return null;
int cmp = key.compareTo(x.key);
if (cmp == 0) return x;
if (cmp < 0) return floor(x.left, key);
Node t = floor(x.right, key);
if (t != null) {
return t;
} else {
return x;
}
}
3.2.5 rank()
public int rank(Key key) {
return rank(key, root);
}
private int rank(Key key, Node x) {
if (x == null) return 0;
int cmp = key.compareTo(x.key);
if (cmp == 0) return size(x.left);
else if (cmp < 0) return rank(key, x.left);
else return 1 + size(x.left) + rank(key, x.right);
}
3.2.6 min()
private Node min(Node x) {
if (x.left == null) return x;
return min(x.left);
}
3.2.7 deleteMin()
深入左子树知道遇到一个空链接,然后将指向该节点的链接指向右子树(在递归调用中返回右链接)。
public void deleteMin() {
root = deleteMin(root);
}
public Node deleteMin(Node x) {
if (x.left == null) return x.right;
x.left = deleteMin(x.left);
x.N = size(x.left) + size(x.right) + 1;
return x;
}
3.2.8 delete()
如果待删除的节点只有一个节点,那么只需要让指向节点的链接指向子节点即可;否则,让右子树的最小节点替换该节点。
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.N = size(x.left) + size(x.right) + 1;
return x;
}
3.2.9 keys()
返回的键使用中序遍历方法来获得,先遍历左子树,然后根节点,最后遍历右子树。
public Iterable<Key> keys(Key lo, Key hi) {
Queue<Key> queue = new LinkedList<>();
keys(root, queue, lo, hi);
return queue;
}
private void keys(Node x, Queue<Key> queue, Key lo, Key hi) {
if (x == null) return;
int cmpLo = lo.compareTo(x.key);
int cmpHi = hi.compareTo(x.key);
if (cmpLo < 0) keys(x.left, queue, lo, hi);
if (cmpLo <= 0 && cmpHi >= 0) queue.add(x.key);
if (cmpHi > 0) keys(x.right, queue, lo, hi);
}
3.2.10 性能分析
复杂度:二叉查找树所有操作在最坏的情况下所需要的时间都和树的高度成正比。
3.3 平衡查找树
3.3.1 2-3查找树
一颗完美平衡的2-3查找树的所有空链接到根节点的距离应该是相同的。
插入操作
当插入之后产生一个临时4-节点时,需要将4-节点分裂成3个2-节点,并将中间的2-节点移到上层节点中,如果上移操作继续产生临时4-节点则一直进行分裂上移,直到不存在临时4-节点。
性质
2-3树插入算法中的变换都是局部的,除了相关的节点和链接之外不必修改或者检查树的其它部分,而这些局部变换不会影响数的全局有序性和平衡性。
2-3数的查找插入操作复杂度和插入顺序无关,在最坏的情况下查找插入操作访问的节点必然不超过logN个。含有10亿个节点的2-3树最多只需要访问30个节点就能进行任意的查找和插入操作。
3.3.2 红黑二叉查找树
2-3查找树需要用到2-节点和-3节点,红黑树使用红链接来实现3-节点。指向一个节点的链接颜色如果为红色,那么这个节点和上层节点表示一个3-节点,而黑色则是普通链接。
红黑树具有以下性质:
- 红链接都为左链接;
- 完美黑色平衡,即任意空链接到根节点的路径上的黑链接数量相同。
画红黑树时可以将红链接画平。
public class RedBlackBST<Key extends Comparable<Key>, Value> {
private Node root;
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;
N = n;
this.color = color;
}
}
private boolean isRed(Node x) {
if (x == null) return false;
return x.color == RED;
}
}
左旋转
public Node rotateLeft(Node h) {
Node x = h.right;
h.right = x.left;
x.left = h;
x.color = h.color;
h.color = RED;
x.N = h.N;
h.N = 1 + size(h.left) + size(h.right);
return x;
}
右旋转
public Node rotateRight(Node h) {
Node x = h.left;
h.left = x.right;
x.color = h.color;
h.color = RED;
x.N = h.N;
h.N = 1 + size(h.left) + size(h.right);
return x;
}
颜色转换
一个4-节点在红黑树中表现为一个节点的左右子节点都是红色的。分裂4-节点除了需要将子节点的颜色由红变黑之外,同时需要将父节点的颜色由黑变红,从2-3树的角度看就是将中间节点移到上层节点。
void flipColors(Node h){
h.color = RED;
h.left.color = BLACK;
h.right.color = BLACK;
}
flipColors()会使得根节点的颜色变为红色,但是根节点一定为黑色,因为根节点没有上层节点,也就没有上层节点的左链接指向根节点。每当根节点由红色变成黑色时树的黑链接高度加1.
插入
插入算法:
- 如果右子节点是红色的而左子节点是黑色的,进行左旋转;
- 如果左子节点是红色的且它的左子节点也是红色的,进行右旋转;
- 如果左右子节点均为红色的,进行颜色转换。
public void put(Key key, Value val) {
root = put(root, key, val);
root.color = BLACK;
}
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.val = val;
else if (cmp < 0) x.left = put(x.left, key, val);
else x.right = put(x.right, key, val);
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;
}
可以看到该插入操作和BST的插入操作类似,只是在最后加入了旋转和颜色变换操作即可。
删除最小键
如果最小键在一个2-节点中,那么删除该键会留下一个空链接,就破坏了平衡性,因此要确保最小键不在2-节点中。将2-节点转换成3-节点或者4-节点有两种方法,一种是向上层节点拿一个key,或者向兄弟节点拿一个key。如果上层节点是2-节点,那么就没办法从上层节点拿key了,因此要保证删除路径上的所有节点都不是2-节点。
首先根节点是2-节点且它的两个子节点都是2-节点,直接将这三个节点变成一个4-节点;否则需要保证根节点的左子节点不是2-节点,如果有必要直接从右侧的兄弟节点拿一个key来。
在向下的过程中,保证以下情况之一发生:
- 如果当前节点的左子节点不是2-节点,完成;
- 如果当前节点的左子节点是2-节点而它的兄弟节点不是2-节点,向兄弟节点拿一个key过来;
- 如果当前节点的左子节点和它的兄弟节点都是2-节点,将左子节点、父节点中的最小键和最近的兄弟节点合并为一个4-节点。
最后得到一个含有最小键的3-节点或者4-节点,直接从中删除。然后再从头分解所有临时的4-节点。
分析
一颗大小为N的红黑树的高度不会超过2lgN。最坏的情况下是它所对应的2-3树中构成最左边的路径节点全部都是3-节点而其余都是2-节点。
红黑树大多数的操作所需要的时间都是对数级别的。
3.4 散列表
散列表类似于数组,可以把散列表的散列值看成数组的索引值。访问散列表和访问数组元素一样快速,它可以在常数时间内实现查找和插入的符号表。
由于无法通过散列值知道键的大小关系,因此散列表无法实现有序性操作。
3.4.1 散列函数
对于一个大小为M的散列表,散列函数能够把任意键转换为[0,M-1]内的正整数,该正整数即为hash值。
散列表有冲突的存在,也就是两个不同的键可能有相同的hash值。
散列函数应该满足以下三个条件:
- 一致性:相等的键应当有相等的hash值。
- 高效性:计算应当简便,有必要的话可以把hash值缓存起来,在调用hash函数时直接返回。
- 均匀性:所有键的hash值应当均匀地分布到[0,M-1]之间,这个条件至关重要,直接影响到散列表的性能。
除留余数法可以将整数散列到[0,M-1]之间,例如一个正整数k,计算k%M既可得到一个[0,M-1]之间的hash值。注意M必须是一个素数,否则无法利用键包含的所有信息。例如M为10k,那么只能利用键的后k位。
对于其它数,可以将其转换成整数的形式,然后利用除留余数法。例如对于浮点数,可以将其表示成二进制形式,然后使用二进制形式的整数值进行除留余数法。
对于有多部分组合的键,每部分都需要计算hash值,并且最后合并时需要让每部分hash值都具有同等重要的地位。可以将该键看成R进制的整数,这样就和处理整数时类似。
例如,字符串的散列函数实现如下
int hash = 0;
for( int i = 0; i < s.length(); i++)
hash = (R * hash + s.charAt(i)) % M;
再比如,拥有多个成员的自定义类的哈希函数如下
int hash = (((day * R + month)% M) * R + year) % M;
R的值不是很重要,通常取31。
Java中的hashCode()实现了hash函数,但是默认使用对象的内存地址值。在使用hashCode()函数时,应当结合除留余数法来使用。应为内存地址是32位整数,我们只需要31位的非负整数,因此应当屏蔽符号位之后再使用除留余数法。
int hash = (x.hashCode() & 0x7fffffff) % M;
除非需要自己实现哈希表,否则使用Java自带的HashMap等自带的哈希表实现时,只需要去实现Key类型的hashCode()函数即可,因此也就不需要考虑M的大小等。Java规定hashCode()能够将键均匀分布于所有的32位整数,Java中的String、Integer等对象的hashCode()都能实现这一点。以下展示了自定义类型如何实现hashCode(),该实现也是将各个成员看成R进制。
public class Transaction{
private final String who;
private final Date when;
private final double amount;
public int hashCode(){
int hash = 17;
hash = 31 * hash + who.hashCode();
hash = 31 * hash + when.hashCode();
hash = 31 * hash + ((Double)amount).hashCode();
return hash;
}
}
3.4.2 基于拉链法的散列表
拉链法使用链表来存储hash值相同的键,从而解决冲突。此时查找需要分两步,首先查找Key所在的链表,然后在链表中顺序查找。
对于N个键,M条链表(N>M),如果哈希函数能够满足均匀性的条件,每条链表的大小趋向于N/M,因此未命中的查找和插入操作所需要的比较次数为~N/M。
3.4.3 基于线性探测法的散列表
线性探测法使用空位来解决冲突,但冲突发生时,向前探测空位来存储冲突的键。要使得数组有空位,因此数组的大小M应当大于键的个数N(M>N)。
public class LinearProbingHashST<Key, Value> {
private int N;
private int M = 16;
private Key[] keys;
private Value[] vals;
public LinearProbingHashST() {
init();
}
public LinearProbingHashST(int M) {
this.M = M;
init();
}
private void init() {
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;
}
插入
public void put(Key key, Value val) {
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++;
resize();
}
删除
删除操作应当将右侧所有相连的键值重新插入散列表中。
public void delete(Key key) {
if (!contains(key)) return;
int i = hash(key);
while (!key.equals(keys[i])) {
i = (i + 1) % M;
}
keys[i] = null;
vals[i] = null;
i = (i + 1) % M;
while (keys[i] != null) {
Key keyToRedo = keys[i];
Value valToRedo = vals[i];
keys[i] = null;
vals[i] = null;
N--;
put(keyToRedo, valToRedo);
i = (i + 1) % M;
}
N--;
resize();
}
调整数组大小
线性探测法的成本取决于连续条目的长度,连续条目也叫聚簇。当聚簇很长时,在查找和插入时也需要进行很多次探测。
α=N/M,把α称为利用率。理论证明,当α小于1/2时他侧的预计次数只在1.5到2.5之间。
为了保证散列表的性能,应当调整数组的大小,使得α在[1/4,1/2]之间。
private void resize() {
if (N >= M / 2) resize(2 * M);
else if (N <= M / 8) resize(M / 2);
}
private void resize(int cap) {
LinearProbingHashST<Key, Value> t = new LinearProbingHashST<>(cap);
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;
}
虽然每次重新调整数组都需要重新把每个键值对插入到散列表,但是从摊还分析的角度来看,所需要的代价却是很小的。从下图可以看出,每次数组长度加倍后,累计平均值都会增加1,因为表中每个键都需要重新计算散列值,但是随后平均值会下降。
3.5 应用
应当优先考虑散列表,当需要有序性操作时使用红黑树。
Java的java.util.TreeMap和java.util.HashMap分别是基于红黑树和拉链法的散列表的符号表实现。
除了符号表,集合类型也经常使用,它只有键没有值,可以用集合类型来存储一系列的键然后判断一个键是否在集合中。
向量运算涉及到N次乘法,当向量为稀疏向量时,可以使用符号表来存储向量中的非0索引和值,使得乘法运算只需要对那些非0元素进行即可。
```java
import java.util.HashMap;
public class SparseVector {
private HashMap hashMap;
public SparseVector(double[] vector) {
hashMap = new HashMap<>();
for (int i = 0; i < vector.length; i++) {
if (vector[i] != 0) {
hashMap.put(i, vector[i]);
}
}
}
public double get(int i) {
return hashMap.getOrDefault(i, 0.0);
}
public double dot(SparseVector other) {
double sum = 0;
for (int i : hashMap.keySet()) {
sum += this.get(i) * other.get(i);
}
return sum;
}
}
```