目录

1.搜索树

1.1 概念

1.2 操作-查找

1.3 操作-插入

1.4 操作-删除(难点)

1. cur.left == null

​编辑

2. cur.right == null

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

1.6 性能分析

1.7 和 java 类集的关系

2. 搜索

2.1 概念及场景

2.2 模型

3. Map 的使用

3.1 关于Map的说明

3.2 关于Map.Entry的说明

3.3 Map 的常用方法说明

4. Set 的说明

5.哈希表

5.1 概念

5.2 冲突-概念

5.3 冲突-避免

5.4 冲突-避免-哈希函数设计

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

5.6 冲突-解决

5.8 冲突-解决-开散列/哈希桶(重点掌握)

哈希表(哈希桶)的实现


1.搜索树

1.1 概念

二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树 :
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树
int[] array =
{5,3,4,1,7,8,2,6,0,9};

1.2 操作-查找

    public TreeNode search(int key){
        TreeNode cur=root;
     while (cur!=null){
         if(cur.val==key){
             return cur;
         }
         else if(cur.val>key){
             cur=cur.right;
         }
         else {
             cur=cur.left;
         }
     }
    return  null;
    }

1.3 操作-插入

1. 如果树为空树,即根 == null ,直接插入
2. 如果树不是空树,按照查找逻辑确定插入位置,插入新结点
 public TreeNode root;
    public void insert(int key){
if (root==null){
    root = new TreeNode(key);
    return;
}
        TreeNode parent=null;
       TreeNode cur=root;
       TreeNode node=new TreeNode(key);
       while (cur!=null){
           if(cur.val<key){
               parent=cur;
               cur=cur.left;
           }
           else if(cur.val>key){
               parent=cur;
               cur=cur.right;
           }
           else{
               //不能有两个相同的值在二叉搜索树里面
               return;
           }
       }
       if(parent.val>key){
           parent.left=node;
       }
       else if(parent.val<key){
           parent.right=node;
       }
    }

1.4 操作-删除(难点)

设待删除结点为 cur, 待删除结点的双亲结点为 parent
1. 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
    if(cur.left==null){
if(cur==root){
    root=cur.right;
}
else if(parent.left==cur){
parent.left=cur.right;
}
else if(parent.right==cur){
    parent.right=cur.right;
}
    }
2. 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
  else if(cur.right==null){
        if(cur==root){
            root=cur.left;
        }
        else if(parent.left==cur){
            parent.left=cur.left;
        }
        else if(parent.right==cur){
            parent.right=cur.left;
        }
    }

3. cur.left != null && cur.right != null
1. 需要使用 替换法 进行删除,即在它的右子树中寻找中序下的第一个结点 ( 关键码最小 ) ,用它的值填补到被删除节点中,再来处理该结点的删除问题
 else{
        TreeNode temp=cur;
        TreeNode t=cur.right;
        while(t.left!=null){
            temp=t;
            t=t.left;
        }
        cur.val=t.val;
        if(temp.left==t){
            temp.left=t.right;
        }
        else {
            temp.right=t.right;
        }

我们去寻找一个合适的值去替换,那我们怎么去找呢,我的思路是去寻找左树的最大值,或者右树的最小值
 public void remove(int key){
        TreeNode cur=root;
        TreeNode parent=null;
        while (cur!=null){
         if(cur.val>key){
               parent=cur;
               cur=cur.left;
           }
           else if(cur.val<key){
               parent=cur;
               cur=cur.right;
           }
           else  {
               remodeNode(parent,cur);
               return;
           }
        }

    }

    private void remodeNode(TreeNode parent,TreeNode cur) {
    if(cur.left==null){
if(cur==root){
    root=cur.right;
}
else if(parent.left==cur){
parent.left=cur.right;
}
else if(parent.right==cur){
    parent.right=cur.right;
}
    }
    else if(cur.right==null){
        if(cur==root){
            root=cur.left;
        }
        else if(parent.left==cur){
            parent.left=cur.left;
        }
        else if(parent.right==cur){
            parent.right=cur.left;
        }
    }
    else{
        TreeNode temp=cur;
        TreeNode t=cur.right;
        while(t.left!=null){
            temp=t;
            t=t.left;
        }
        cur.val=t.val;
        if(temp.left==t){
            temp.left=t.right;
        }
        else {
            temp.right=t.right;
        }
    }

    }

1.6 性能分析

插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有 n 个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
最优情况下,二叉搜索树为完全二叉树,其平均比较次数为:
最差情况下,二叉搜索树退化为单支树,其平均比较次数为:n/2
问题:如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,都可以是二叉搜索树的性能最佳?

1.7 java 类集的关系

TreeMap TreeSet java 中利用搜索树实现的 Map Set ;实际上用的是红黑树,而红黑树是一棵近似平衡的
二叉搜索树,即在二叉搜索树的基础之上 + 颜色以及红黑树性质验证,关于红黑树的内容后序再进行讲解。

2. 搜索

2.1 概念及场景

Map set 是一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关 。以前常见的搜索方式有:
1. 直接遍历,时间复杂度为 O(N) ,元素如果比较多效率会非常慢
2. 二分查找,时间复杂度为 但搜索前必须要求序列是有序的
上述排序比较适合静态类型的查找,即一般不会对区间进行插入和删除操作了,而现实中的查找比如:
1. 根据姓名查询考试成绩
2. 通讯录,即根据姓名查询联系方式
3. 不重复集合,即需要先搜索关键字是否已经在集合中
可能在查找时进行一些插入和删除的操作,即动态查找,那上述两种方式就不太适合了,本节介绍的 Map Set 是一种适合动态查找的集合容器

2.2 模型

一般把搜索的数据称为关键字( Key ),和关键字对应的称为值( Value ),将其称之为 Key-value 的键值对,所以模型会有两种:
1. key 模型 ,比如:
有一个英文词典,快速查找一个单词是否在词典中快速查找某个名字在不在通讯录中
2. Key-Value 模型 ,比如:
统计文件中每个单词出现的次数,统计结果是每个单词都有与其对应的次数: < 单词,单词出现的次数 > 梁山好汉的江湖绰号:每个好汉都有自己的江湖绰号而Map 中存储的就是 key-value 的键值对, Set 中只存储了 Key

3. Map 的使用

一样的需要注意的是Set实现了iterable接口,是可以使用迭代器,而map没有实现,所以不行

3.1 关于Map的说明

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

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

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

3.3 Map 的常用方法说明

    Set<String> set=map.keySet();//返回所有key不重合集合
        map.values();//返回可以重复集合

同时有一个很重要的点,key是不能重复的而且不能为null

     TreeMap<String,Integer> map = new TreeMap<>();
        map.put("a",1);
        map.put(null,2);
        map.put("c",3);
        map.put("c",4);
注意:
1. Map 是一个接口,不能直接实例化对象 ,如果 要实例化对象只能实例化其实现类 TreeMap 或者 HashMap
2. Map 中存放键值对的 Key 是唯一的, value 是可以重复的
3. TreeMap 中插入键值对时, key 不能为空,否则就会抛 NullPointerException 异常 value 可以为空。但是HashMap key value 都可以为空。
4. Map 中的 Key 可以全部分离出来,存储到 Set 来进行访问 ( 因为 Key 不能重复 )
5. Map 中的 value 可以全部分离出来,存储在 Collection 的任何一个子集合中 (value 可能有重复 )
6. Map 中键值对的 Key 不能直接修改, value 可以修改,如果要修改 key ,只能先将该 key 删除掉,然后再来进行重新插入。

4. Set 的说明

Set Map 主要的不同有两点: Set 是继承自 Collection 的接口类, Set 中只存储了 Key
4.1 常见方法说明
  TreeSet<String> treeSet = new TreeSet<>();
        treeSet.add("abcd");
        treeSet.add("abcde");
        treeSet.add("abds");
treeSet.add("abds");
      Iterator<String> it = treeSet.iterator();
      while(it.hasNext()) {
          System.out.println(it.next());
      }

abcd
abcde
abds
注意:
1. Set 是继承自 Collection 的一个接口类
2. Set 中只存储了 key ,并且要求 key 一定要唯一
3. TreeSet 的底层是使用 Map 来实现的,其使用 key Object 的一个默认对象作为键值对插入到 Map 中的
4. Set 最大的功能就是对集合中的元素进行去重
5. 实现 Set 接口的常用类有 TreeSet HashSet ,还有一个 LinkedHashSet LinkedHashSet 是在 HashSet 的基础上维护了一个双向链表来记录元素的插入次序。
6. Set 中的 Key 不能修改,如果要修改,先将原来的删除掉,然后再重新插入
7. TreeSet 中不能插入 null key HashSet 可以
8.同样的Set里只存储了key,也是不能重复的,而map里面只是key不能重复,TreeSet底层实际上底层就是Treemap

5.哈希表

5.1 概念

顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素时,必须要经过关键 码的多次比较 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即 O(logn ) ,搜索的效率取决于搜索过程中元素的比较次数。 理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素 如果构造一种存储结构,通过某种函
(hashFunc) 使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快 找到该元素
当向该结构中
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,
则搜索成功该方式即为哈希(散列) 方法,
哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称为哈希表 (Hash Table)( 或者称散列表 ) 例如:数据集合{1 7 6 4 5 9}
哈希函数设置为: hash(key) = key % capacity ; capacity 为存储元素底层空间总的大小。

5.2 冲突-概念

对于两个数据元素的关键字 和 (i != j) ,有 != ,但有: Hash( ) == Hash( ) ,即: 不同关键字通过相同哈 希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞 把具有不同关键码而具有相同哈希地址的数据元素称为 同义词 。比如下面我们通过关键字的值进行函数计算出他们的函数存储地址,是一样的,14%10=4,4%10=4

5.3 冲突-避免

首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的 ,但我们能做的应该是尽量的 降低冲突率

5.4 冲突-避免-哈希函数设计

引起哈希冲突的一个原因可能是: 哈希函数设计不够合理 哈希函数设计原则
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有 m 个地址时,其值域必须在 0 m-1 之间
哈希函数计算出来的地址能均匀分布在整个空间中
哈希函数应该比较简单
常见哈希函数
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. 折叠法 --( 了解 )
折叠法是将关键字从左到右分割成位数相等的几部分 ( 最后一部分位数可以短些 ) ,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。 折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
5. 随机数法 --( 了解 )
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random(key), 其中 random 为随机数函数。 通常应用于关键字长度不等时采用此法
6. 数学分析法 --( 了解 )
设有 n d 位数,每一位可能有 r 种不同的符号,这 r 种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:

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

5.6 冲突-解决

解决哈希冲突 两种常见的方法是: 闭散列 开散列
冲突 - 解决 - 闭散
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 key 存放到冲突位置中的 下一个 空位置中去。
1. 线性探测
比如上面的场景,现在需要插入元素 44 ,先通过哈希函数计算哈希地址,下标为 4 ,因此 44 理论上应该插在该位置,但是该位置已经放了值为4 的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
但是线性探测会把冲突聚集到一起
2. 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为 , 或者:= (- )% m。其中:i = 1,2,3… ,是通过散列函数Hash(x) 对元素的关键码 key 进行计算得到的位置,m是表的大小。 对于 2.1 中如果要插入 44 ,产生冲突,使用解决后的情况

5.8 冲突-解决-开散列/哈希桶(重点掌握)

开散列法又叫链地址法 ( 开链法 ) ,首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子 集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
HashMap也是采用数组加链表形式,链表在特点情况下会变成红黑树,当数组长度超过64,链表长度超过8,
5.9 冲突严重时的解决办法
刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
1. 每个桶的背后是另一个哈希表
2. 每个桶的背后是一棵搜索树
我们认为 哈希表的插入 / 删除 / 查找时间复杂度是 O(1)

哈希表(哈希桶)的实现

1、初始化

采用链表数组

public class Hashbuck {
  static  class Node{
        public  int val;
        public  int key;
        public Node next;
        public Node(int val,int key){
            this.val=val;
            this.key=key;
        }
    }
    public static final double DE_FAC=0.75f;
    public Node[] array=new Node[10];
    public int usedSize;

2、插入

我们插入的时候,采用尾插法,以4下标为例,要插入的key=14,用key%array.length得到插入数组链表下标值,然后去遍历这个下面的链表,找到相同key,并更新,如果没有找到,则进行头插法

然后usedsize++,我们在算一下负载因子,usedsize是填入的个数去除以array.length,当负载因子大于0.75的时候,就需要进行扩容,而扩容的时候,我们的思路是定义一个新的链表数组,去遍历旧的数组链表每一个下标的链表每一个节点,用他的key去%新数组长度,然后采用头插

 public void put(int val,int key){
        int index=key%array.length;
        Node cur=array[index];
        while(cur!=null){
            if(cur.key==key){
                cur.val=val;
                return;
            }
            cur=cur.next;
        }
        //没有找到当前链表中有这个key节点
        //jdk8就是尾插
        Node node=new Node(key,val);
        node.next=array[index];
        array[index]=node;
        usedSize++;
        if(dloadFactor()>=DE_FAC){
        resize();
        }
    }

    private void resize() {
        //array= Arrays.copyOf(array,array.length)
    Node[] newArray=new Node[array.length*2];
    for(int i=0;i<array.length;i++){
        Node cur=array[i];
        while(cur!=null){
            int newindex=cur.key%newArray.length;
            Node curn=cur.next;
            cur.next=newArray[newindex];
            newArray[newindex]=cur;
            cur=curn;
        }
    }
    array=newArray;
    }

    private double dloadFactor(){
        return usedSize*1.0/array.length;
    }

3获取元素

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

下面是带有泛型的hashbuck

初始化

   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=(Node<K,V>[])new Node[10];
    public int usedsize;

插入

 public void put(K key,V val){
        int hashcode=key.hashCode();
        int index=hashcode%array.length;
        Node<K,V> cur=array[index];
        while(cur!=null){
            if(cur.equals(val)){
                cur.val=val;
            }
            cur=cur.next;
        }
        Node<K,V> node=new Node<>(key,val);
        node.next=array[index];
        array[index]=node;
        usedsize++;
    }

获取元素

   public V getval(K key){
        int hashcode=key.hashCode();
        int index=hashcode%array.length;
        Node<K,V> cur=array[index];
        while(cur!=null){
            if(cur.key.equals(key)){
                return cur.val;
            }
            cur=cur.next;
        }
        return null;
    }

只出现一次数字

我们去遍历这个数组,然后去判断集合里面没有相同的值set.contains,没有就用set.add,有就删除,最后集合里面只剩下一个元素,去遍历集合

    public int singleNumber(int[] nums) {
        HashSet<Integer> set=new HashSet<>();
        for(int i=0;i<nums.length;i++){
            if(set.contains(nums[i])){
                    set.remove(nums[i]);
            }
            else{
                    set.add(nums[i]);
            }
        }
             for(int i=0;i<nums.length;i++){
           if(set.contains(nums[i])){
            return nums[i];
           }
        }
        return -1;
    }

复制带随机指针链表

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

字符串常量池

所有的引号里面的内容都存放在字符串常量里面

围绕动态数据搜索场景,完整讲解了二叉搜索树的原理与核心操作、Java Map/Set 集合的搜索模型与使用规范,以及哈希表的底层逻辑、哈希冲突解决方案与代码实现,同时明确了这些数据结构在 Java 集合框架中的对应落地实现与关键使用要点。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐