目录


模型


一、搜索


1. 场景


2. 搜索树


2.1 概念


2.2 查找


2.3 插入


2.4 删除


2.5 实现


2.6 性能分析


2.7 和Java的关系


二、Set


1. 常见方法


2. 注意


三、Map


1. 关于Map.Entry的说明,>


2. Map的常用方法说明


3. 注意


四、哈希表


1. 概念


2. 冲突


2.1 概念


2.2 避免


3. 降低 / 解决冲突的办法


3.1 降低冲突的办法(提前准备)


3.2 降低冲突的办法(解决冲突)


4. 冲突严重时的解决办法


5. 实现


6. 性能分析


7. 和java类集的关系


模型

一般把搜索的数据称为关键字(Key),和关键字对应的称为值(Value),将其称之为Key-value的键值对,所以模型会有两种:

  • 纯key模型:Set接口(集合)
  • key-value模型:Map接口(映射)

一、搜索

1. 场景


(1). 静态的有序数组

二分查找——支持随机访问+有序

查找快,插入删除慢,所以只适合静态数据集(数据变化很少的情况)


(2). 搜索树

平衡搜索树:

AVL树/红黑树(内存),B-树系列(B+树),R-树系列


(3). 哈希表

2. 搜索树

2.1 概念

二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:

  • 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
  • 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
  • 它的左右子树也分别为二叉搜索树

如图:



特点:中序遍历的结果是有序的。


2.2 查找

若根节点不为空:

  • 如果根节点key == 查找key,返回true。
  • 如果根节点key > 查找key,在其左子树查找。
  • 如果根节点key < 查找key,在其右子树查找。

否则,返回false。


2.3 插入


  • 如果树为空树,即 根 == null,直接插入。

  • 如果树不是空树,安装查找逻辑确定插入位置,插入新结点。

2.4 删除

设待删除结点为 cur, 待删除结点的双亲结点为 parent

1. cur.left == null

  • cur 是 root,则 root = cur.right
  • cur 不是 root,cur 是 parent.left,则 parent.left = cur.right
  • cur 不是 root,cur 是 parent.right,则 parent.right = cur.right

2. cur.right == null

  • cur 是 root,则 root = cur.left
  • cur 不是 root,cur 是 parent.left,则 parent.left = cur.left
  • cur 不是 root,cur 是 parent.right,则 parent.right = cur.left

3. cur.left != null && cur.right != null

  • 需要使用

    替换法

    进行删除,即在它的右子树中寻找中序下的第一个结点(关键码最小)(也可以是左子树中最大的),用它的值填补到被删除节点中,再来处理该结点的删除问题。


2.5 实现

public class BinarySearchTree {
    public static class Node {
        int key;
        Node left;
        Node right;
        public Node(int key) {
            this.key = key;
        }
    }

    private Node root = null;

    //查找的操作
    public Node search(int key) {
        Node cur = root;
        while (cur != null) {
            if (key == cur.key) {
                return cur;
            } else if (key < cur.key) {
                cur = cur.left;
            } else {
                cur = cur.right;
            }
        }
        return null;
    }

    //插入的操作
    //插入过程中可能会失败(key重复了)
    public boolean insert(int key) {
        if (root == null) {
            root = new Node(key);
            return true;
        }
        Node cur = root;
        //记录双亲结点
        Node parent = null;
        while (cur != null) {
            if (key == cur.key) {
                //key重复
                return false;
            } else if (key < cur.key) {
                parent = cur;
                cur = cur.left;
            } else {
                parent = cur;
                cur = cur.right;
            }
        }

        //cur == null
        Node node = new Node(key);
        if (key < parent.key) {
            parent.left = node;
        } else {
            parent.right = node;
        }
        return true;
    }

    //删除的操作
    public boolean remove(int key) {
        Node cur = root;
        Node parent = null;
        while (cur != null) {
            if (key == cur.key) {
                break;
            } else if (key < cur.key) {
                parent = cur;
                cur = cur.left;
            } else {
                parent = cur;
                cur = cur.right;
            }
        }
        // 该元素不在二叉搜索树中
        if(null == cur){
            return false;
        }
        /* 根据cur的孩子是否存在分四种情况
        1. cur左右孩子均不存在
        2. cur只有左孩子
        3. cur只有右孩子
        4. cur左右孩子均存在
        看起来有四种情况,实际情况1可以与情况2或者3进行合并,只需要处理是那种情况即可
        除了情况4之外,其他情况可以直接删除
        情况4不能直接删除,需要在其子树中找一个替代节点进行删除
        */
        if(cur.left == null){   //3.
            if(cur == root){    //parent == null
                root = cur.right;
            }else if(parent.left == cur){
                parent.left = cur.right;
            }else{
                //parent.right == cur;
                parent.right = cur.right;
            }
        }else if(cur.right == null){    //2.
            if(cur == root){
                root = cur.left;
            }else if(parent.left == cur){
                parent.left = cur.left;
            }else{
                parent.right = cur.left;
            }
        }else{  //4.
            //cur.left != null && cur.right != null
            //替换删除,此处选择右子树中最小的一个(即右子树中最左的一个)
            Node toDeleteParent = cur;
            Node toDelete = cur.right;
            while(toDelete.left != null){
                toDeleteParent = toDelete;
                toDelete = toDelete.left;
            }

            //此时toDelete是我们要删除的结点
            //先把值替换
            cur.key = toDelete.key;

            //删除toDelete
            //需要判断此时是toDelete左孩子还是右孩子
            //如果cur没有左孩子,那么此时toDelete就是cur的右孩子,此时toDeleteParent == cur
            if(toDeleteParent.left == toDelete){
                toDeleteParent.left = toDelete.right;
            }else{
                //tpDeleteParent.right == toDelete
                toDeleteParent.right = toDelete.right;
            }
        }
        return true;
    }
}

2.6 性能分析

插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。

对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。

但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

  • 最优情况下,二叉搜索树为完全二叉树,其平均比较次数为:
    log_{2}N
  • 最差情况下,二叉搜索树退化为单支树,其平均比较次数为:
    \frac{N}{2}

2.7 和Java的关系

TreeMap 和 TreeSet 即 java 中利用搜索树实现的 Map 和 Set;实际上用的是红黑树,而红黑树是一棵近似平衡的二叉搜索树,即在二叉搜索树的基础之上 + 颜色以及红黑树性质验证。

二、Set


Set官方文档

Set与Map主要的不同有两点:Set是继承自Collection的接口类,Set中只存储了Key。

1. 常见方法

方法 解释
boolean add(E e) 添加元素,但重复元素不会被添加成功
void clear() 清空集合
boolean contains(Object o) 判断 o 是否在集合中
Iterator<E> iterator() 返回迭代器
boolean remove(Object o) 删除集合中的 o
int size() 返回set中元素的个数
boolean isEmpty() 检测set是否为空,空返回true,否则返回false
Object[] toArray() 将set中的元素转换为数组返回
boolean containsAll(Collection<?> c) 集合c中的元素是否在set中全部存在,是返回true,否则返回

false
boolean addAll(Collection<? extends

E> c)
将集合c中的元素添加到set中,可以达到去重的效果

2. 注意

  • Set是继承自Collection的一个接口类。
  • Set中只存储了key,并且要求key一定要唯一。
  • Set的底层是使用Map来实现的,其使用key与Object的一个默认对象作为键值对插入到Map中。
  • Set最大的功能就是对集合中的元素进行去重。
  • 实现Set接口的常用类有TreeSet和HashSet,还有一个LinkedHashSet,LinkedHashSet是在HashSet的基础上维护了一个双向链表来记录元素的插入次序。
  • Set中的Key不能修改,如果要修改,先将原来的删除掉,然后再重新插入。
  • Set中不能插入null的key。

TreeSet和HashSet的区别:

Set底层结构 TreeSet HashSet
底层结构 红黑树 哈希桶
插入/删除/查找时间

复杂度
O(1)
是否有序 关于Key有序 不一定有序
线程安全 不安全 不安全
  • 插入/删除/查找区别
按照红黑树的特性来进行插入和删除 1. 先计算key哈希地址 2. 然后进行

插入和删除
比较与覆写 key必须能够比较,否则会抛出

ClassCastException异常
自定义类型需要覆写equals和

hashCode方法
应用场景 需要Key有序场景下 Key是否有序不关心,需要更高的

时间性能

三、Map


Map官方文档



Map是一个接口类,该类没有继承自Collection,该类中存储的是<K,V>结构的键值对,并且K一定是唯一的,不能重复。

1. 关于Map.Entry<K, V>的说明

Map.Entry<K, V> 是Map内部实现的用来存放<key, value>键值对映射关系的内部类,该内部类中主要提供了<key, value>的获取,value的设置以及Key的比较方式。

方法 解释
K getKey() 返回 entry 中的 key
V getValue() 返回 entry 中的 value
V setValue(V value) 将键值对中的value替换为指定value


注意:Map.Entry<K,V>并没有提供设置Key的方法。

2. Map的常用方法说明

方法 解释
V get(Object key) 返回 key 对应的 value
V getOrDefault(Object key, V defaultValue) 返回 key 对应的 value,key 不存在,返回默认值
V put(K key, V value) 设置 key 对应的 value
V remove(Object key) 删除 key 对应的映射关系
Set<K> keySet() 返回所有 key 的不重复集合
Collection<V> values() 返回所有 value 的可重复集合
Set<Map.Entry<K, V>> entrySet() 返回所有的 key-value 映射关系
boolean containsKey(Object key) 判断是否包含 key
boolean containsValue(Object value) 判断是否包含 value

3. 注意

  • Map是一个接口,不能直接实例化对象,如果要实例化对象只能实例化其实现类TreeMap或者HashMap。
  • Map中存放键值对的Key是唯一的,value是可以重复的
  • Map中的Key可以全部分离出来,存储到Set中来进行访问(因为Key不能重复)。
  • Map中的value可以全部分离出来,存储在Collection的任何一个子集合中(value可能有重复)。
  • Map中键值对的Key不能直接修改,value可以修改,如果要修改key,只能先将该key删除掉,然后再来进行重新插入。
  • TreeMap和HashMap的区别:

    Map底层结构 TreeMap HashMap
    底层结构 红黑树 哈希桶
    插入/删除/查找时间

    复杂度
    O(1)
    是否有序 关于Key有序 无序
    线程安全 不安全 不安全
    插入/删除/查找区别 需要进行元素比较 通过哈希函数计算哈希地址
    比较与覆写 key必须能够比较,否则会抛出

    ClassCastException异常
    自定义类型需要覆写equals和

    hashCode方法
    应用场景 需要Key有序场景下 Key是否有序不关心,需要更高的

    时间性能

四、哈希表

哈希表:固定长度的数组(顺序表),也是一种搜索的数据结构。

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在

查找一个元素

时,必须要

经过关键码的多次比较



顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(
log_{2}N
)

,搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以

不经过任何比较



一次直接从表中得到要搜索的元素



如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

1. 概念

当向该结构中:


  • 插入元素

    :根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放

  • 搜索元素

    :对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。

该方式即为哈希(散列)方法,

哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)


例:

数据集合{1,7,6,4,5,9};

哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

但是这种方式可能会产生冲突(碰撞),称之为哈希冲突(哈希碰撞)。

2. 冲突

2.1 概念

对于两个数据元素的关键字 和 (i != j),有 != ,但有:Hash( ) == Hash( ),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。

把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

2.2 避免

哈希冲突是必然的,无法避免的。

虽然冲突是不可避免的,但是要想办法降低冲突率。

3. 降低 / 解决冲突的办法

3.1 降低冲突的办法(提前准备)


3.1.1 哈希函数的设计——除留余数法

常见哈希函数:直接定制法,除留余数法,平方取中法,折叠法,随机数法,数学分析法。


一般我们使用——除留余数法:

设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:

Hash(key) = key% p(p<=m),将关键码转换成哈希地址。


3.1.2 负载因子调节

散列表的载荷因子定义为:
\alpha
= 填入表中的元素个数 / 散列表的长度

降低冲突率——设定一个阈值,当负载因子超过阈值时,进行数组的扩容。

3.2 降低冲突的办法(解决冲突)


3.2.1 封闭区间中解决(闭散列)——线性探测和二次探测


a. 线性探测


例:

数据集合{1,7,6,4,5,9,44};

比如在之前的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。

线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

当插入44时,hash(44) = 44 % 4 = 4,但是4已经被占用,所以只能继续往后放,找到后面的第一个空位置即8位置放入44。


b. 二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:
H_{i} = (H_{0} \pm i^{2}) % m
,其中:i = 1,2,3….,
H_{0}
是通过散列函数Hash(x)对元素的关键码key进行计算得到的位置,m是表的大小。


平均查找长度(成功/失败)的计算:

成功:sum = (查找成功的总次数)/ 数据集合总个数个数

失败:sum = (查找失败的总次数)/ 数据集合总个数个数


3.2.2 不封闭区间中解决(开散列)——哈系桶(链地址法)

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

还是上面的例子:

数据集合{1,7,6,4,5,9};

开散列中每个桶中放的都是发生哈希冲突的元素。开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。

4. 冲突严重时的解决办法

哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:

  • 每个桶的背后是另一个哈希表
  • 每个桶的背后是一棵搜索树

5. 实现

哈希表的实现——除留余数法。

//元素类型:long类型
//元素取值范围:>= 0
//哈希函数:除留余数法:key % array.length
public class HashTableV1 {
    static class ListNode{
        long key;
        ListNode next;

        ListNode(long key){
            this.key = key;
        }
    }

    private ListNode[] array;
    private int size;

    public HashTableV1(){
        array = new ListNode[8];
        size = 0;
    }

    private int hashCode(long key){
        return (int)key % array.length;
    }

    public boolean add(long key){
        int index = hashCode(key);
        for (ListNode cur = array[index]; cur != null; cur = cur.next){
            if(cur.key == key){
                //重复
                return false;
            }
        }

        ListNode node = new ListNode(key);
        node.next = array[index];
        array[index] = node;
        size++;

        //由于负载因子过大,需要扩容
        if(1.0 * size / array.length > 0.75){
            grow();
        }
        return true;
    }

    private void grow(){
        ListNode[] newArray = new ListNode[this.array.length * 2];
        for (int i = 0; i < array.length; i++) {
            ListNode cur = array[i];
            while (cur != null){
                int index = (int) cur.key % newArray.length;

                ListNode next = cur.next;
                cur.next = newArray[index];
                newArray[index] = cur;

                cur = next;
            }
        }

        this.array = newArray;
    }

    public boolean remove(long key){
        int index = hashCode(key);
        if(array[index] == null){
            return false;
        }
        //头删
        if(array[index].key == key){
            array[index] = array[index].next;
            size--;
            return true;
        }

        ListNode prev = array[index];
        ListNode cur = array[index].next;
        while (cur != null){
            if(cur.key == key){
                prev.next = cur.next;
                size--;
                return true;
            }

            prev = cur;
            cur = cur.next;
        }

        return false;
    }

    //平均时间复杂度:O(1)
    public boolean contains(long key){
        //1. 把key转成下标——求哈希值的过程
        int index = hashCode(key);
        //2. 要查找的链表的头结点就是
        // 此时index不会越界
        ListNode head = array[index];
        for (ListNode cur = head; cur != null ; cur = cur.next) {
            if(cur.key == key){
                return true;
            }
        }
        return false;
    }
}

6. 性能分析

虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是O(1)。

7. 和java类集的关系

  • HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set。
  • java 中使用的是哈希桶方式解决冲突的。
  • java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)。
  • java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。


如有建议或想法,欢迎一起讨论学习~



版权声明:本文为lil_ghost_原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/lil_ghost_/article/details/130289515