一、开篇说明
离写上一篇博客已经隔了半个月了。今天要输出的内容是查找算法。
二、算法及其概念
2.1 无序链表的顺序查找
链表这个算法结构在之前已经提到很多次了,他其实是数组的另一个重要的替代方式。链表就是一连串的节点,每个节点本身存储着一个键值对,同时也存储着一个引用或者说索引,它指向下一个节点的内存地址,但是要注意每个节点都是游离的,并不是连续的内存地址。无序链表指的是链表这个数据结构里存储的对象是无序的。通过无序链表实现查找只能沿着节点依次顺序的往下查找,然后判断每个节点key值是否与目标key值相等,相等则返回key所对应value,如果查找到最后一个即它的next索引为空了,还没有找到相同的key值,说明不存在,返回null。
2.1.1 算法实现
/**
* 无序链表的顺序查找
* @param <Key> 键
* @param <Value> 值
*/
public class SequentialSearchST<Key, Value> {
//第一个节点
private Node first;
private int n;
//链表的定义
private class Node {
private Key key;
Value value;
Node next;
public Node(Key key, Value value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}
}
/**
* 查找value
* @param key
* @return
*/
public Value get(Key key) {
for (Node x = first; x != null; x = x.next) {
if (key.equals(x.key))
return x.value;
}
return null;
}
/**
* 存储key,value键值对,如果key值相同则覆盖
* @param key
* @param value
*/
public void put(Key key, Value value) {
for (Node x = first; x != null; x = x.next) {
if (key.equals(x.key)) {
x.value = value;
return;
}
}
Node newFirst = new Node(key, value, first);
first = newFirst;
n++;
}
public int size() {
return n;
}
/**
* 删除操作
* @param key
*/
public void delete(Key key) {
//判断第一个key是否为目标key相同
//相同则直接返回,否则进入for循环
if (first.key.equals(key)) {
first = first.next;
n--;
return;
}
//for循环从第二个节点开始进行判断
for (Node x = first; x != null; x = x.next) {
Node next = x.next;
if (next != null && key.equals(next.key)) {
x.next = next.next;
n--;
return;
}
}
}
/**
* 返回一个迭代器
* @return
*/
public Iterable<Key> keys() {
return new KeysIterable();
}
/**
* 实现一个迭代器
*/
class KeysIterable implements Iterable<Key> {
Node current = first;
@Override
public Iterator iterator() {
return new Iterator() {
@Override
public boolean hasNext() {
return current != null;
}
@Override
public Key next() {
Node node = current;
current = current.next;
return node.key;
}
};
}
}
}
2.1.2 算法分析:
SequentialSearchST类维护了两个成员变量first和n,分别用来描述指向链表第一个节点的引用和链表的长度,其次还维护了一个内部类Node,它用来描述链表的节点,有三个属性key,value和next分别描述键,值和指向下一个节点内存地址的引用。
put(Key key, Value value)方法:
put()方法体先是一个for循环,它是遍历链表顺序查找是否包含相同的key值,如果命中相同的key,那就直接替换该key值对应value值并直接返回结束。否则创建新的节点将它赋值给first成员变量,并将新的first.next指向原来的first对象,这样就存入了新键值对,并存储在链表的第一个位置。偷个小懒,找了一副插入过程的分析轨迹截个大家,它所对应的测试类在下面
@org.junit.Test
public void testSequential() {
SequentialSearchST<String, Integer> st = new SequentialSearchST<>();
String[] a = {"S","E","A","R","C","H","E","X","A","M","P","L","E","Z"};
for (int i = 0; i < a.length; i++) {
st.put(a[i], i);
}
System.out.println(st.get("E"));
}
get(Key key)方法:
get()方法很简单,遍历整个链表顺序查找含有与目标key相同key的node节点,找到直接返回value值,否则直接返回null。
delete(Key key)方法:
delete()方法相对来说复杂一点,先是判断第一个节点first的key是否与目标key相等,如果相等就意味着命中,直接把first.next赋值给first,n-1完事(原来的first对象就处于游离的状态,会被回收,因为现在first引用指向的是新的节点);否则遍历链表顺序查找与目标key相同的节点,注意此时for循环里面要对比的节点是从第二个节点开始,找到了目标key以后处理的逻辑就跟上面一样。
size()方法就直接跳过吧!!
迭代器:就是一个实现了next()和hasNext()方法的类的对象,实现迭代器的关键就是Node类的next成员变量,不停地找下一个节点,直到为next为null
2.2 有序数组中的二分查找
2.2.1 基本思想
它使用的数据结构是两个平行的数组分别存储键和值,即如果两个数组下标相就意味着他两存储的就是一个键值对。不难看出这两个数组的大小始终相等。而实现查找和存储数据的真正核心是rank()方法(后面会详细分析),如果查找的目标在数组中,它可以快速找到目标key所对应的数组下标(即索引)并返回它。几乎所有其他的方法实现都需要使用它。对于get()方法,rank()返回的索引可以快速找到value值返回;对put()方法,可以快速确定是否包含相同的key,以作后续处理;对于delete(),快速定位目标自不必说。
2.2.2 算法实现
/**
* 有序数组中二分查找
* @param <Key>
* @param <Value>
*/
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 boolean isEmpty() {
return n==0;
}
/**
* 根据key获取value值
* @param key key
* @return
*/
public Value get(Key key) {
if (isEmpty())
return null;
int i = rank(key);
if (i < n && keys[i].compareTo(key) == 0)
return values[i];
else
return null;
}
/**
* 插入键值对
* @param key
* @param value
*/
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++;
}
/**
* while循环实现二分查找算法
* @param key
* @return
*/
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) {
hi = mid - 1;
} else if (cmp > 0) {
lo = mid + 1;
} else {
return mid;
}
}
return lo;
}
/**
* 递归实现二分查找
* @param key
* @param lo
* @param hi
* @return
*/
public int rank(Key key, int lo, int hi) {
if (lo > hi)
return lo;
int mid = lo + (hi - lo)/2;
int cmp = key.compareTo(keys[mid]);
if (cmp < 0)
return rank(key, lo, mid - 1);
else if (cmp > 0)
return rank(key, mid + 1, hi);
else
return mid;
}
/**
* 删除一个元素
* @param key
*/
public void delete(Key key) {
int i = rank(key);
if (i < n && keys[i].compareTo(key) == 0) {
for (int j = i; j < n; j++) {
keys[j]=keys[j+1];
values[j] = values[j+1];
}
keys[n] = null;
keys[n] = null;
n--;
}
}
/**
* 判断是否包含目标key
* @param k
* @return
*/
public boolean contains(Key k) {
int i = rank(k);
if (k.compareTo(keys[i]) == 0)
return true;
return false;
}
/**
* 迭代器,遍历链表
* @return
*/
public Iterable<Key> keys() {
return keys(keys[0], keys[n-1]);
}
/**
* 使用队列实现的迭代器
* @param lo
* @param hi
* @return
*/
public Iterable<Key> keys(Key lo, Key hi) {
Queue<Key> queue = new Queue<>();
for (int i = rank(lo); i < rank(hi); i++) {
queue.enqueue(keys[i]);
}
if (contains(hi))
queue.enqueue(hi);
return queue;
}
public Key select(int i) {
return keys[i];
}
}
2.2.2 算法分析
首先想说的是这个泛型类BinarySearchST<Key extends Comparable<Key>, Value>,Key继承了Comparable<Key>,看起来这泛型Key好像没有什么限制,但是仔细分析它其实隐藏了一个限制,就是这个具体的泛型必须实现了Comparable接口,即这个类必须实现compareTo方法,jdk本身有很多类(如包装类型Integer等,还包括文件相关类File等)实现了这个接口,但是如果自己定义的类想使用这个二分查找算法就必须自己去实现Comparable接口的compareTo方法。
rank()方法:
这里我实现了两个rank()方法,一个是通过while循环实现,另一个是通过递归实现。先看while循环实现的rank()方法,它接受一个key参数,定义了两个局部变量lo和hi,初始化时可以把它们理解为数组的第一个索引和最后一索引,while循环结束的条件就是这两个索值引相等或hi小于lo,while循环体先计算这两个索引值的中位数mid,然后将key和Key数组中下标为mid的值比较。如果这个比较的值cmp小于0,意味着这个key在Key数组的lo到mid-1之间,然后将mid-1赋值给hi;如果cmp>0,key就在mid+1到hi之间,然后将mid+1赋值给lo。如果满足循环条件,则继续循环,直到找到目标值则返回目标索引或者未命中目标跳出循环直接返回lo值。值得读者注意的是这个索引不一定是命中目标的索引,如果是未命中目标的索引,返回的这个索引值是大于目标key的索引值的,因为在最后一轮hi-lo一定是等于1,所以mid=lo+(hi-lo)/2=lo,而cmp>0,故最终返回的lo=mid+1,也就大于目标key的索引位置(这里根据各种情况可能有所不同,但是要知道最后返回的索引是目标key之后的那个大索引)。
希望读者能仔细研究这个过程,因为它是该算法的核心。rank()递归方法跟while循环其实执行逻辑都差不多,只是实现方式不同,读者可以根据代码自行研究一番。
put()方法:
它分为两种情况,一种是存在相同的值,另一种是不存在相同的值。首先调用rank(),拿到一个索引,判断它所对应的key是否与目标相同,如果是则替换key的value值。否则for循环将索引i以后的key-value往后移动一位(注意这里没有实现数组动态扩缩容),随后将新的键值对添加进来。
get()方法:
get()方法也是先调用rank()方法,拿到索引值,然后判断key是否为目标key,如果是就返回value值;不是直接返回null。
delete()方法:
还是一样调用rank()拿到数组索引值,判断是否与目标key相同,如果不同说明链表中不存在这个key,结束;否则将索引位置i开始以后的索引值向前移动一位,最后将两个数组索引为n的位置赋值为null(注意这个n是数组包含元素的个数,不是数组的大小)。
最后说一下迭代器,这里使用我之前写的队列实现的迭代器。想看的同志可以点 这里。
三、总结
在博客开始处说了这两个算法效率并不高,链表顺序查找算法的插入和查找都是线性级别的;有序数组二分查找算法的查找效率提高到对数级别,但是插入操作的效率还是线性级别的。
到这里就告一段落了,后续会继续带来二叉树查找和红黑二叉查找树。
参考资料:算法第四版
不忘初心,死抠细节。仅以此博献给我伟大的java语言,如有不当之处,欢迎大神指正。谢谢!!!