搜索树, 哈希表

目录

 一. 搜索树

1.1 概念

1.2 操作1 查找

 1.3 操作2 插入

1.4 操作3 删除

1.5 性能分析

1.6 和 java 类集的关系

二.哈希表

2.1 概念

2.2 哈希冲突

2.3常见哈希函数

2.4 负载因子

2.5 闭散列

 2.6 开散列

 2.7 哈希表的实现

push 添加数据

getVal() 获取key对应的val值

2.8 性能分析

2.9 和 java 类集的关系


 一. 搜索树

1.1 概念

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

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

1.2 操作1 查找

思路:

  1. 从根节点出发, 如果根节点大于val值, 说明要找的val小于根在根的左边, 所以cur = cur.left
  2. 如果根节点小于val值, 说明要找的val大于根在根的右边, 所以cur = cur.right
  3. 如果根节点等于val值, 说明找到了, 直接返回true
  4. 循环搜索下去, 直到cur == null停止, 说明找不到val值,返回false
 public boolean search(int val){
        TreeNode cur = root;
        while(cur != null){
            if(cur.val > val){
                cur = cur.left;
            }else if(cur.val < val){
                cur = cur.right;
            }else{
                return true;
            }
        }
        return false;
    }

 1.3 操作2 插入

思路:

  1. 如果是一颗空树, 直接将结点插入即可
  2. 找到合适的位置: 和查找的方法类似, 先循环找到该节点应该插入的位置, 如果根节点大于val值, cur = cur.left, 根节点小于val值, cur = cur.right, 根节点等于val值, 则直接返回, 因为搜索树是不允许有相同的节点的, 直到cur== null, 说明该节点应该插入在此
  3. 但是, 如果我们不知道此节点的父亲节点, 那么我们将没法插入, 所以我们要定义一个parent结点, parent始终等于cur的父亲节点
  4. 如果val值 < parent.val, 则插入到parent.left, 如果val值 > parent.val, 则插入到parent.right
public void insert(int val){
        if(root == null){
            root = new TreeNode(val);
            return;
        }
        TreeNode node = new TreeNode(val);
        TreeNode cur = root;
        TreeNode parent = null;
        while(cur != null){
            if(cur.val > val){
                parent = cur;
                cur = cur.left;
            }else if(cur.val < val){
                parent = cur;
                cur = cur.right;
            }else{
                return;
            }
        }
        if(parent.val > val){
            parent.left = node;
        }else{
            parent.right = node;
        }
    }

1.4 操作3 删除

思路:

设删除节点为cur, cur的父亲节点为parent

cur结点一共分为三种情况:

情况1. cur.left == null

cur.left == null时, 也分成三种情况:

        1)cur == root, 只需将root = cur.right

        2)cur ! =root && cur == parent.left, 将parent.left = cur.right

        3)cur ! =root && cur == parent.right, 将parent.right = cur.right

情况2. cur.right == null

cur.right == null时, 同样也分成三种情况:(与上面相对应)

        1)cur == root, 只需将root = cur.left

        2)cur ! =root && cur == parent.left,将parent.left = cur.left

        3)cur ! =root && cur == parent.right,将parent.right = cur.left

情况3. cur左右节点都不为空

当cur左右节点都不为空时, 我们采用的方法是替换法:

法一: 找到cur结点左子树的最大值, 将cur的值替换成这个值, 再将此结点删除

法二: 找到cur结点右子树的最小值, 将cur的值替换成这个值, 再将此结点删除

问题一:为什么要找左子树的最大值或右子树的最小值?

答:

上面这棵树, 删除15, 正常我们要找的14或20, 得到:

可以看到, 替换之后, 依旧满足搜索树的定义, 左子树都比根小, 右子树都比根大

如果我们选择11, 那么:

发现违背了搜索树的定义!

问题二:为什么要使用替换法?

答:假设我们使用法一, 因为此节点时左子树的最大值, 那么说明该节点没有右子树, cur.right == null, 那么删除该节点的方法就非常简单啦, 就是上面的第2种情况

思路(假设使用法一, 找左子树的最大值):

  1. 找到要删除的结点cur
  2. 找左子树最大值: 定义一个指针t, 令t = cur.left(因为我们要找左子树), 让t去找左子树的最大值, 即如果t有右子树, 则t = t.right, 直到t没有右子树
  3. 替换: 此时t为左子树的最大值, 将t.val = cur.val
  4. 删除: 还要定义一个tp, 使tp始终等于t的父亲节点, 就回到了情况2: 如果t == tp.left, tp.left = t.left ; 如果t == tp.right, tp.right = t.left
public void remove(int val){
        TreeNode cur = root;
        TreeNode parent = null;
        while(cur != null){
            if(cur.val > val){
                parent = cur;
                cur = cur.left;
            }else if(cur.val < val){
                parent = cur;
                cur = cur.right;
            }else{
                removeNode(parent,cur);
                return;
            }
        }
    }
    public void removeNode(TreeNode parent, TreeNode cur){
//情况1
        if(cur.left == null){
            if(cur == root){
                root = cur.right;
            }else if(cur == parent.left){
                parent.left = cur.right;
            }else if(cur== parent.right){
                parent.right = cur.right;
            }
//情况2
        }else if(cur.right == null){
            if(cur == root){
                root = cur.left;
            }else if(cur == parent.left){
                parent.left = cur.left;
            }else if(cur == parent.right){
                parent.right = cur.left;
            }
//情况3
        }else{
            TreeNode t = cur.left;
            TreeNode tp = cur;
            while(t.right != null){
                tp = t;
                t = t.right;
            }
            cur.val  = t.val;
            if(t == tp.right){
                tp.right = t.left;
            }else{
                tp.left = t.left;
            }
        }
    }

1.5 性能分析

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

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

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

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

最差情况下,二叉搜索树退化为单支树,其平均比较次数为:N/2

问题:如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,都可以是二叉搜索树的性能最佳?

答:AVL树(后续介绍)

1.6  java 类集的关系

(在下一篇博客介绍)

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

注意: 使用TreeMap和TreeSet时, 所插入的元素必须是可比较的

二.哈希表

2.1 概念

顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素时,必须要经过关键 码的多次比较 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即 O(\log_{2}N
) ,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素 如果构造一种存储结构,通过某种函 (hashFunc) 使元素的存储位置与它的关键码之间能够建立一一映射的关系, 那么在查找时通过该函数可以很快 找到该元素
当向该结构中:
  • 插入元素

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

  • 搜索元素

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

该方式即为哈希 ( 散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称为哈希表 (Hash Table)( 或者称散列表 )
例如:
数据集合 {1 7 6 4 5 9}
哈希函数设置为: hash(key) = key % capacity; capacity为存储元素底层空间总的大小

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快

2.2 哈希冲突

问题:按照上述哈希方式,向集合中插入元素44 ,会出现什么问题?
不同关键字通过相同哈 希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞
首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的 ,但我们能做的应该是尽量的 降低冲突率
引起哈希冲突的一个原因可能是: 哈希函数设计不够合理

2.3常见哈希函数

1. 直接定制法 --( 常用 )
取关键字的某个线性函数为散列地址: Hash Key = A*Key + B
优点:简单、均匀
缺点:需要事先知道关 键字的分布情况
使用场景:适合查找比较小且连续的情况 
2. 除留余数法 --( 常用 )
设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数,按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址
3. 平方取中法 --( 了解 )
假设关键字为 1234 ,对它平方就是 1522756 ,抽取中间的 3 227 作为哈希地址; 再比如关键字为 4321 ,对它平方就是18671041 ,抽取中间的 3 671( 710) 作为哈希地址 平方取中法比较适合:不知道关键字的分 布,而位数又不是很大的情况
4. 折叠法 --( 了解 )
折叠法是将关键字从左到右分割成位数相等的几部分 ( 最后一部分位数可以短些 ) ,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
......
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

2.4 负载因子

负载因子 = 填入表中元素的长度  /   哈希表的长度
Java系统库中限制了负载因子为0.75
负载因子和冲突率的关系粗略演示
所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。
已知哈希表中已有的关键字个数是不可变的,那我们能 调整的就只有哈希表中的数组的大小。
解决哈希冲突 两种常见的方法是: 闭散列开散列

2.5 闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 key 存放到冲突位置中的 下一个 空位置中去。 那如何寻找下一个空位置呢?
1. 线性探测
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
比如上面的场景,现在需要插入元素 44 ,先通过哈希函数计算哈希地址,下标为 4 ,因此 44 理论上应该插在该位置,但是该位置已经放了值为4 的元素,即发生哈希冲突。

插入:

  • 通过哈希函数获取待插入元素在哈希表中的位置
  • 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
  • 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4 ,如果直接删除掉, 44 查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

 2. 二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的法为:Hi = (H0+ i^2)% m, 或者:Hi = (H0 -  i^2)% m。
其中: i = 1,2,3… ,i是冲突的次数
H0是通过散列函数 Hash(x) 对元素的关键码 key 进行计算得到的位置,
m 是表的大小。
对于 2.1 中如果要插入 44 ,产生冲突,使用解决后的情况为:
H1 = (4 + 1^2) % 10 = 6, 再次冲突
H2 = (4 + 2^2) % 10 = 8 不冲突, 放入
研究表明:当表的长度为质数且表装载因子 a 不超过 0.5 时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a 不超过 0.5 ,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

 2.6 开散列

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

 从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。

冲突严重时的解决方法:

刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
1. 每个桶的背后是另一个哈希表
2. 每个桶的背后是一棵搜索树, java中就是以这种方式进行组织的 

 2.7 哈希表的实现

(开散列)

规则:

  先做好准备工作, 定义好结点, 数组, usedSize, 负载因子

public class HashBuck {

    public class Node{
        public int key;
        public int val;
        public Node next;

        public Node(int key, int val) {
            this.key = key;
            this.val = val;
        }
    }
    public Node[] arr = new Node[10];
    public int usedSize;
    public static final double LOAD_FACTOR = 0.75;
    
}

push 添加数据

思路:

  1.  首先找到添加数据的位置index
  2. 创建结点node
  3. 定义一个cur去遍历链表, 如果有和key重复的, 就更新val值, 直到cur == null
  4. 假设我们使用头插法进行插入, 因为arr[index]中存放的是链表第一个元素的地址, 所以将node.next = arr[index], 然后再将arr[index] = node, 插入成功usedSize++
  5. 此时需要判断负载因子是否超过了0.75, 如果超过需要扩容, 方法为reSize()

reSize():

  1. 重新定义一个数组newArray, 将数组的长度定为arr.length*2
  2. 用cur遍历原数组arr, 将原数组中所有的数重新添加到newArray, 找到cur在新数组的下标, 直接进行头插法, 但需要用curNext记录cur.next的值, 因为头插法会修改cur.next, 直到cur == null, 继续遍历数组下一个下标
  3. 最后将新数组赋给原数组

代码:

public void push(int key,int val){
        Node node = new Node(key,val);
        int index = key % arr.length;
        Node cur = arr[index];
        while(cur != null){
            if(cur.key == key){
                cur.val = val;
                return ;
            }
            cur = cur.next;
        }
        node.next = arr[index];
        arr[index] = node;

        usedSize++;
        if(doLoadFactor() >= LOAD_FACTOR){
            reSize();
        }
    }
    private double doLoadFactor(){
        return usedSize*1.0 / arr.length;
    }
    private void reSize(){
        Node[] newArray = new Node[arr.length * 2];
        for (int i = 0; i < arr.length; i++) {
            Node cur = arr[i];
            while(cur != null){
                int index = cur.key % newArray.length;
                Node curNext = cur.next;
                cur.next = newArray[index];
                newArray[index] = cur;
                cur = curNext;
            }
        }
        arr = newArray;
    }

getVal() 获取key对应的val值

思路:

  1. 找到key对应的index值
  2. 定义cur遍历链表, cur.key == key, 返回val, 直到cur == null
  3. 找不到返回-1

代码:

public int getVal(int key){
        int index = key % arr.length;
        Node cur = arr[index];
        while(cur != null){
            if(cur.key == key){
                return cur.val;
            }
            cur = cur.next;
        }
        return -1;
    }

hashCode(): 

思考:

如果我们要想要传String类型的key, 那么找到他对应的下标呢?

答:hashCode() !  任何类都继承Object类, 这个类中有hashCode方法, 可以将其余类型转化成int, 这样就可以通过计算找到对应的下标

上述student1和student2是相同的id, 那么我们认为他们是同一个人, 但是我们通过查看他们的hashCode发现哈希码并不相同, 所以如果我们通过使用Object的hashCode不能达到目标, 我们就要重写hashCode方法

此时哈希码的生成和id有关, 所以此时生成的hashCode是相同的

重新写哈希表的实现, 使用泛型:

public class HashBuck2 <K,V>{
    static class Node<K,V>{
        public K key;
        public V val;
        public Node<K,V> next;

        public Node(K key, V val) {
            this.key = key;
            this.val = val;
        }
    }
    public Node<K,V>[] array = new Node[10];
    public int usedSize;
    public static final double LOAD_FACTOR = 0.75;

    public void push(K key,V val){
        Node<K,V> node = new Node<>(key,val);
//拿到key的哈希码
        int hashCode = key.hashCode();
//计算下标位置
        int index = hashCode % array.length;
        Node cur = array[index];
        while(cur != null){
//不能使用==判断是否相等, 用equals()
            if(cur.key.equals(key)){
                cur.val = val;
                return;
            }
            cur = cur.next;
        }
        node.next = array[index];
        array[index] = node;

        usedSize++;

    }
}

2.8 性能分析

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

2.9  java 类集的关系

(在下一篇博客介绍)

1. HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set

2. java 中使用的是哈希桶方式解决冲突的

3. java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)

4. java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode equals ,而且要做到 equals 相等的对象,hashCode 一定是一致的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值