【数据结构】Map和Set

Map和Set

1. 搜索树

1.1 概念

二叉搜索树是左子树比根节点小,右子树比根节点大的二叉树。(如果左右子树不为空的话是这样,但是左右子树也可以为空)

1.2 操作——查找

查找的思想与二分查找类似。

如果根节点的值和所要查找的值相同,那么就返回。

如果根节点的值比查找的值小,那么就往这个结点的右子树走。

如果根节点的值比查找的值大,那么就往这个结点的左子树走。

(每次都可以筛选掉一半的数据)

1.2.1 代码
/**
 * 操作——搜索
 * @param val 搜索的值
 * @return boolean
 */
public boolean search(int val) {
// 1. 定义一个cur进行遍历
TreeNode cur = root;
// 2. 比较
while (cur != null) {
    if (cur.val < val) {
        cur = cur.right;
    } else if (cur.val > val) {
        cur = cur.left;
    } else  {
        return true;
    }
}
return false;
}

1.3 操作——插入

插入和查找差不多。

先找到要插入数据应该存放的位置(一定会是叶子结点)

然后看是放在这个结点的左边还是右边。

/**
 * 操作——插入
 * @param val
 */
public boolean insert(int val) {
    // 1. 如果是空树
    if (root == null) {
        root = new TreeNode(val);
        return true;
    }
    // 2. 定义一个cur进行遍历,一个parent进行保存上一步cur的位置
    TreeNode cur = root;
    TreeNode parent = cur;

    while (cur != null) {
        // 保存这次的cur结点,方便后续的插入结点
        parent = cur;
        if (cur.val < val) {
            cur = cur.right;
        } else if (cur.val > val) {
            cur = cur.left;
        } else  {
            return false;
        }
    }

    if (parent.val < val) {
        parent.right = new TreeNode(val);
    } else {
        parent.left = new TreeNode(val);
    }
    return true;
}

1.4 操作——删除(难点)

删除的情况分为三种:

规定 node为将要删除的结点,parent 为要删除结点的父节点。

  1. node.left = null

    1.1 如果 node是根节点,root.right = node.right;

    1.2 如果node不是根节点,是parent 的右子树, parent.right = node.right

    1.3 如果node不是根节点,是parent 的左子树, parent.left= node.left

  2. node.right = null

    1.1 如果 node是根节点,root.left= node.left;

    1.2 如果node不是根节点,是parent 的右子树, parent.right = node.right

    1.3 如果node不是根节点,是parent 的左子树, parent.left= node.left

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

    使用 “替罪羊” 方式进行删除:并灭有真正删除node结点,而是将node左子树的最大值进行替换上去,然后

/**
 * 删除的子函数
 * @param node
 * @param parent
 */
public void removeNode(TreeNode node, TreeNode parent) {
    // 叶子节点——直接删除
    if (node.left == null && node.right == null) {
        node = null;
    }
    // 1. node没有左子树
    if (node.left == null) {
        if (node == parent.left) {
            parent.left = node.right;
        } else {
            parent.right = node.left;
        }
    // 2. node 没有右子树
    } else if (node.right == null) {
        if (node == parent.left) {
            parent.left = node.right;
        } else {
        parent.right = node.left;
        }
    // 3. node 左右子树都有
        // “替罪羊” 删除方式
    } else {
        // 找到node左子树的最右边,即为左子树中最大的值,进行填补;也可以选择右子树的最大值进行填补(最左边
        // target即为替罪羊
        TreeNode target = node.left;
        TreeNode targetParent = target;
        while (target.right != null) {
            targetParent = target;
            target = target.right;
        }
        // 到达最右边
        node.val = target.val;// “虚假”的删除
        targetParent.right = target.left;// target没有右子树,直接接管左子树即可
    }
}

1.5 性能分析

最好的情况:为平衡二叉树的时候,即为log2N。

最坏的情况:为单分支树的时候,即为N(产生了AVL树来防止这种情况的发生,其可以左旋,右旋,左右双旋,右左双旋)

2. 搜索

2.1 场景

在以往学过的搜索思想中,我们只学过二分查找(log2N,必须是有序的数组)和直接遍历(n,对数据无要求,效率低下),这种思想对于静态的数据能够有效地检索,但是无法进行删除、添加操作,但是,Map和Set可以进行动态的操作。

2.2 模型

Map:存储的是key-value键值对,key就是所要存储的数据据,value就是这个数据所附带的值(可以是这个key出现的次数,也可以是这个key的关键字等信息)。

Set:存储的是key的集合,不能够有重复,底层仍然使用Map进行初始化。

3. Map的使用

3.1 关于Map的说明

Map 是单独的一个接口,没有继承自Collection接口,并且存储的类型是,key不可以重复(搜索的时候就是按照key的值进行搜索,重复便不能够进行搜索)

3.2 关于Map.Entry的说明

Entry 是Map中的一个内部接口,其能够返回Map的key以及value,并且能够设置value(但是没有提供设置key的方法),Entry返回的是一个包含键值对的对象,能够用其初始化Iterator,可直接定义变量。

Map.Entry能够表示Map的映射项,而Map又没有实现iterator,所以通常用来遍历Map。

Map中有**values()方法来获得value,有keySet()**方法来获得key的一个Set,但是都一次只能访问一个内容,Entry很好地实现了这个一次访问两个的效果。

方法 解释
K getKey() 获得Map中的key
V getValue() 获得Map中的value
V setValue(V value); 设置Map中的value

使用方法:

Map<String, Integer> map = new TreeMap<>();
map.put("初中读了几年",3);
map.put("高中读了几年",3);
map.put("大学读了几年",2);

for (Map.Entry<String,Integer> entry1 : map.entrySet()) {
    System.out.println("String:" + entry1.getKey() + " Value:" + entry1.getValue());
}

// Map.Entry得到的是一个的变量,可直接看作
Map.Entry<String,Integer> entry;
Iterator<Map.Entry<String,Integer>> it = map.entrySet().iterator();
it.next().setValue(99);

for (Map.Entry<String,Integer> entry2 : map.entrySet()) {
    System.out.println("String:" + entry2.getKey() + " Value:" + entry2.getValue());
}
3.3 Map的常用方法说明
方法 解释
V put(K key, V value); 将K,V的元素放进Map
V get(Object key); 得到Map中某个key对应的value
Set keySet(); 返回一个全是key的Set
V remove(Object key); 删除key结点
Set> entrySet(); 返回键值对的Set集合(可用键值对遍历)
boolean containsKey(Object key); 返回Map中是否存在这个key
boolean containsValue(Object value); 返回Map中是否存在这个value
default V getOrDefault(Object key, V defaultValue); 返回Map中是否存在这个key,如果不存在,则返回defaultValue
  • 注意:
  1. 关于Set> entrySet()产生的代码如下,
Set<Map.Entry<String, Integer>> set = map.entrySet();
for(Map.Entry<String, Integer> e : set) {
    System.out.println("String:" + e.getKey() + " Value:" + e.getValue());
}
  1. Map有两种实现类,分别是HashMap和TreeMap。

    Map底层结构 TreeMap HashMap
    底层结构 红黑树 哈希桶
    插入/删除/查找时间复杂度 O(log2N) O(1)
    是否有序 有序(插入的时候对于key进行了排序) 无序(存储是按照HashCode进行存储)
    线程安全 不安全 不安全
    插入/查找/删除区别 按照key值插入,进行了元素之间的比较 通过哈希函数,按照哈希地址插入
    比较与覆写 key值必须能够比较,否则抛出ClassCastException异常(需要实现Comparable/Comparator接口) 自定义类型需要覆写equals和 hashCode方法(需要利用哈希函数进行插入)
    应用场景 需要key有序的时候 更需要效率的时候
  2. Map中不存在两个相同的key值,但是value可以重复,当多次put进同一个key时,会更新这个key的值。

  3. 在TreeMap中key值不能为空,但是HashMap的key可以为空

    主要是因为TreeMap中,put方法会调用比较器函数,如果传过来的是一个null,那么也就无法比较。

    但是HashMap中,虽然用的是哈希函数,但是在哈希函数的内部使用了hash()方法,里面对于key为null的情况作了处理:如果key为null,那么就将其置为0。(但是只能有一个key为0的结点,因为同样,一个0只能计算出一个hash地址,重复插入key为null的结点,只会更新其value值)

  4. Map中的key不能修改,要修改只能删除后再添加进行修改

4. Set的使用

Set与Map的不同点在于Set只存储key,并且Set继承自Collection接口。

4.1 常见方法说明

方法 解释
boolean add(E e); 向集合中添加元素e
Object[] toArray(); 将集合变为数组
boolean contains(Object o); 查看集合中是否存在o元素
Iterator iterator(); 返回迭代器
boolean containsAll(Collection c); 如果调用方包含c中的所有元素则返回true
boolean addAll(Collection c); 将集合c中的所有元素加到调用方中,可以达到去重的效果
boolean retainAll(Collection c); 使调用方成为与c的子集

注意:

  1. Set中的key不能修改,要修改只能删除后再添加进行修改

  2. Set的key是唯一值

  3. TreeSet的底层是使用TreeMap进行初始化的,仍然使用键值对进行初始化,但是只传入了Set中的K,另外一个使用了Object对象。

    public TreeSet() {
        this(new TreeMap<E,Object>());
    }
    
  4. 同Map,TreeSet不能插入null的key,但是HashSet可以。

  5. Set的实现类有HashSet、TreeSet、LinkedHashSet(能够记录插入次序、继承了HashMSet、HashSet中有HashMap、HashMap中有这个结点,所以能够记录次序)

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    
  6. Set最大的功能就是对元素进行去重

    Set底层结构 TreeSet HashSet
    底层结构 红黑树 哈希桶
    插入/删除/查找时间复杂度 O(log2N) O(1)
    是否有序 关于key有序b 不一定有序
    插入/删除/查找区别 利用红黑树进行操作 计算出哈希地址后才会进行对应的操作
    比较与覆写 key必须能够比较,否则抛异常:ClassCastException 自定义类型需要覆写equals和HashCode方法
    应用场景 需要key有序 查找效率为先

5. 哈希表

在以往的搜索方法中,都是需要遍历(直接搜索、二分查找)进行,但是理想的搜索方式是不需要遍历,直接能够找到,就像抓中药的时候,药师会直接抽出一个箱子,取出对应的中药,而不是一个一个箱子拉开看是不是符合自己需要的中药。

所以,哈希表也应该像中药一样,能够建立起中药和中药箱上的关系,使得药师能够根据药箱的外表就能够找到中药。所以哈希表也需要有一个映射关系,使得数据的存储位置和它的关键码能够产生联系。

实现思路:

插入元素:

根据待插入元素的关键码,根据某个计算方法计算出一个地址,按此地址进行存放。

搜索元素:

对于待搜索元素的关键码进行同样的计算,根据得到的地址进行查找。

上述的计算方法即为哈希方法,地址即为哈希地址,数据按此方式进行存放组成的结构叫做散列表(HashTable)。

5.2 冲突——概念

当进行插入的时候,势必会对多个插入元素计算出相同的哈希地址,比如我们要存储int,采用的哈希函数是:hash(key) = key % array,length;那么当我们数组长度为10,插入一个4,再插入一个14的时候,他俩都需要往下标为4的地址上存放,这样的情况就叫做哈希冲突。

5.3 冲突——避免

我们可以采用更改哈希函数、增大容器容量等方法来进行避免冲突,但是这只是一时的避免,在日后的数据越来越多的情况下,势必会再次出现冲突,那时候就又需要进行处理新的冲突。

5.3.4 冲突——避免——哈希函数设计

引起冲突的一个原因可能就是哈希函数设置的不够合理,就比如上述:hash(key) = key % array,length这个函数简单易懂,但是去容易产生冲突,如果我们更改为:hash(key) = (key + i^2) % array,length(其中 i 为插入数据的个数)那就降低了冲突的可能性。

哈希函数的设计原则:

  1. 应该尽可能的简单

  2. 函数计算出来的地址应该能够使得数据能够均匀的分布在散列表当中

  3. 哈希函数的定义域必须包括预备存储的所有数据的关键码(这样才能对于所有的数据进行计算),

    而且值域(算出来的哈希地址)必须在散列表的容量之中。

常见的哈希函数:

  1. 直接定制法:(常用)

    使用线性函数:hash(key) = a * key +b

    优点:简单均匀

    缺点:需要事先知道数据的分布才能实现函数

  2. 除留余数法:(常用)

    hash(key) = key % array.length

    优点:简单

    缺点:如果数据分散,则空间利用率低,适合处理数据集中的情况

  3. 平方取中法(了解)

    对数据进行平方后,取中间的x位作为哈希地址。

    适合数据不是很大,又不知道其分布的情况

  4. 折叠法(了解)

    将数据折叠成几段长度相等的部分,然后叠加求和,对于后几位按照散列表的长度进行取余。

  5. 随机数法(了解)

    hash(key) = random(key)

  6. 数学分析法(了解)

    通过对于数据的分析,选择出不宜重复的几项,然后将其选做哈希地址。

    比如:如果要存储公司里员工及其手机号,那么就可以选择使用后四位作为散列表地址。

5.5 冲突——避免——负载因子调节(重点掌握)

负载因子:α = 已存储的数据个数 / 散列表的长度

一般负载因子不会超过0.8,超过0.8就会频繁出现冲突问题。

所以当负载因子超过0.8的时候,我们就需要对于散列表进行调整。

5.6 冲突——解决

常见的两种方法:开散列和闭散列。

5.7 闭散列

闭散列:也叫开放地址法,当发生冲突的时候,如果哈希表未被装满,那就可以把发生冲突的数据存放到下一个空位置

  1. 线性探测
    在这里插入图片描述

这种处理方式的坏处是不能够随便删除元素,比如下表存储了12在2的后面,如果把2删除,那么32就会找不到,因为这种存储方式是通过依次遍历空位置来查找元素的,当22删除后,那个位置就为空,32就被误认为是没有尾数为2的元素的。
在这里插入图片描述


  1. 二次探测(二次方)

    线性探测对于在哈希地址同一个位置的数据进行了简单的处理,但是这种方式过于粗暴(直接挨着往后找),所以二次探测找下一个空位置的方式:Hi = (H0 ± i2) % array.length,其中 i = 1,2,3…,H0 为发生冲突的为位置。

    当发生冲突时,先取 i=1 试试看,如果这个位置仍然有元素,那就取 i = 2 看。

    【数据结构】Map和Set_第1张图片

研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不 会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容(增容后,全部元素需要重新哈希)

综上,闭散列的最大缺陷就是空间利用率低,这也是哈希的缺陷。

5.8 开散列/哈希桶

开散列法又叫链地址法,因为开散列是通过一个每个元素是一个链表结点的数组(哈希表)来存储数据的。

首先对关键码利用哈希函数进行计算散列地址,如果有相同的地址,那就归于一个下标,在这个下表下有一个单链表,多出来的元素往上串即可,各链表的头结点都在哈希表中。

在开散列中进行搜索是先找到下标,然后在这个下标存储的链表中进行遍历查找所需元素。

5.9 冲突严重时的解决办法

  1. 每个桶的背后是另一张哈希表
  2. 每个桶的背后是一棵搜索树

5.10 实现

public class HashBucket {
    // 一个结点就是一个桶,每个桶里装单链表的头结点
    static class Node {
        private int key;
        private int value;
        Node next;

        public Node() {
        }

        public Node(int key, int value, Node next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    public Node[] array;
    public int size;
    public static final double LOAD_FACTOR = 0.75;

    /**
     * 构造函数
     */
    public HashBucket() {
        array = new Node[8];
        size = 0;
    }
    /**
     * 插入函数
     * @param key
     * @param value
     */
    public int put(int key, int value) {
        // 这里使用最简单粗暴的哈希函数
        int index = key % array.length;

        //先找是否已经存在key
        for (Node node = array[index]; node != null; node = node.next) {
            if (node.key == key) {
                int oldValue = node.value;
                node.value = value;
                // 返回更新前的值
                return oldValue;
            }
        }


        Node newNode = new Node(key, value, null);

        if (array[index] == null) {
            array[index] = newNode;
        } else {
          // 尾插
          Node tail = array[index];
          while (tail.next != null) {
              tail = tail.next;
          }
          tail.next = newNode;
        }
        size++;

        // 判断是否需要扩容
        if (loadFactor() > LOAD_FACTOR) resize();// 使用函数的目的是为了每次进行判断都能重新计算
        return value;
    }

    double loadFactor() {
        return size * 1.0 / array.length;
    }
    /**
     * 扩容函数
     */
    private void resize() {
        // 扩容需要将全部元素进行重新哈希
        Node[] newArray = new Node[array.length * 2];

        /*这种方式连着空位置一起进行了迁移
        for (int i = 0; i < array.length; i++) {
            int newIndex = array[i].key % newArray.length;
            newArray[newIndex] = array[i];
        }*/

        for (int i = 0; i < array.length; i++) {
            Node next = null;
            // 把array的每个结点都遍历一遍,如果为空就直接跳过,不为空则一直遍历进行重新哈希
            for (Node cur = array[i]; cur != null; cur = next){
                next = cur.next;

                int newIndex = cur.key % newArray.length;
                newArray[newIndex] = array[i];
            }
        }

        array = newArray;
    }

    /**
     * 得到key对应的value值
     * @param key
     * @return
     */
    public int get (int key) {
        int index = key % array.length;

        Node cur = array[index];
        while (cur.key != key) {
            cur = cur.next;
        }
        if (cur != null) return cur.value;
        else return -1;
    }
}

5.11 性能分析

因为哈希表构建了数据关键码和散列表地址的映射,能够根据数据直接取出对应的值,虽然总是存在冲突,但是这并不是不可解决的问题,所以时间复杂度一般认为是O(1)。

5.12 和Java类集的关系

  1. HashMap和HashSet就是利用了哈希表实现的Map和Set
  2. Java中使用哈希桶解决冲突
  3. Java中负载因子超过0.75后会转变为红黑树提高效率
  4. Java中计算哈希地址就是使用 hashcode()方法进行计算,如果要使用自定义的类进行插入,那么必须覆写 equals()hashcode()

你可能感兴趣的:(数据结构,数据结构)