初阶数据结构(10)(​​​​​​​搜索树、搜索、Map 、Set 、哈希表、OJ—只出现一次的数字;存在重复元素;复制带随机指针的链表;宝石与石头;坏键盘打字;前K个高频单词、二叉搜索树与双向链表)

接上次博客:初阶数据结构(9)(排序的概念、常见的排序算法【直接插入排序,希尔排序,选择排序,堆排序,冒泡排序,快速排序和归并排序】、排序算法复杂度及稳定性分析、其他比较排序【计数排序、基数排序、桶排序】)_di-Dora的博客-CSDN博客

目录

搜索树

概念

操作-查找

操作-插入

操作-删除

性能分析

和 java 类集的关系

搜索

概念及场景

模型

Map 的使用

关于Map的说明

关于Map.Entry的说明

Map 的常用方法说明

TreeMap的使用案例

Set 的说明

Set 的常见方法说明

TreeSet的使用案例

哈希表

概念

冲突-概念

冲突-避免

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

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

冲突-解决

冲突-解决-闭散列

1. 线性探测

2. 二次探测

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

冲突严重时的解决办法

实现

性能分析

哈希表和 Java 类集的关系

HashMap的源码解析 

OJ练习

1、只出现一次的数字

存在重复元素:

存在重复元素 II:

2、复制带随机指针的链表

3、宝石与石头

4、坏键盘打字

5、前K个高频单词

6、二叉搜索树与双向链表


搜索树

概念

搜索树(Search Tree)是一种常见的数据结构,其中每个节点都包含一个键和一个值。搜索树的主要目的是支持高效地插入、删除和搜索操作。

二叉搜索树(Binary Search Tree)是搜索树的一种特殊形式,又称二叉排序数,它或者是一棵空树,或者具有以下性质:

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

注意:按中序遍历该树所得到的中序序列是一个递增的有序序列。

简而言之,二叉搜索树的左子树包含的键值小于根节点,右子树包含的键值大于根节点,而且这个规律递归地适用于整棵树的每个子树。

通过这种有序性质,二叉搜索树提供了一种高效的方式来执行插入、删除和搜索操作。对于插入操作,根据键值的大小关系,可以迅速确定要插入的位置。对于搜索操作,可以利用二叉搜索树的有序性质,在树中进行二分查找,从而快速找到目标节点。对于删除操作,可以通过合理地调整树的结构来维持有序性。

然而,需要注意的是,二叉搜索树的性能取决于树的形状。如果插入的节点是有序的,或者插入的节点导致树的不平衡,即左右子树的高度差过大,可能导致树的高度增加,从而降低了搜索、插入和删除操作的效率。为了解决这个问题,有一些改进的二叉搜索树结构,如平衡二叉搜索树(如AVL树和红黑树)和B树,可以在一定程度上保持树的平衡性,从而提高性能。

总结来说,二叉搜索树是一种常见的搜索树数据结构,具有左子树小、右子树大的有序性质,支持高效的插入、删除和搜索操作。它在许多应用中都有广泛的应用,特别是在需要动态地维护有序数据集合的场景中。

练习:

1、将整数序列(7-2-4-6-3-1-5)按所示顺序构建一棵二叉排序树a(亦称二叉搜索树),之后将整数8按照二叉排序树规则插入树a中,请问插入之后的树a中序遍历结果是( ) 

A.1-2-3-4-5-6-7-8

B.7-2-1-4-3-6-5-8

C.1-3-5-2-4-6-7-8

D.1-3-5-6-4-2-8-7

E.7-2-8-1-4-3-6-5

F.5-6-3-4-1-2-7-8

答案:A

解析:

我们先来创建一棵二叉搜索树,按照顺序,每读入一个元素。就建立一个新节点插入到当前已生成的二叉树中,即调用二叉搜索树的插入算法进行节点的插入。最后再插入 8 ,然后中序遍历就可以了。

这里我们也受到了一个启发:由于二叉搜索树是有序的,其中序遍历为升序,我们只要知道前序遍历或者后序遍历就能还原一棵二叉搜索树了! 

操作-查找

二叉搜索树的查找方法是一种基于二分查找的算法,通过比较目标键值与当前节点的键值大小,逐步在树中向下搜索,直到找到目标节点或者确定目标节点不存在。

以下是二叉搜索树查找方法的具体步骤:

1. 从根节点开始,将目标键值与当前节点的键值进行比较。
2. 如果目标键值等于当前节点的键值,则表示已经找到目标节点,返回该节点或执行其他操作。
3. 如果目标键值小于当前节点的键值,则说明目标节点可能位于当前节点的左子树中。此时,将当前节点的左子节点作为新的当前节点,然后回到步骤1。
4. 如果目标键值大于当前节点的键值,则说明目标节点可能位于当前节点的右子树中。此时,将当前节点的右子节点作为新的当前节点,然后回到步骤1。
5. 重复执行步骤1到步骤4,直到找到目标节点或者确定目标节点不存在。如果遍历到空节点,则表示目标节点不存在于二叉搜索树中。


    public class BinarySearchTree {
        static class TreeNode {
            public int val;
            public TreeNode left;
            public TreeNode right;

            public TreeNode(int val) {
                this.val = val;
            }
        }

        public TreeNode root = null;

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

    }

二叉搜索树的查找方法利用了树的有序性质,通过比较目标键值与当前节点的键值,可以确定目标节点可能存在的子树,从而缩小搜索范围。由于二叉搜索树的结构具有递归性质,查找操作可以递归地进行,从而实现高效的搜索。

需要注意的是,二叉搜索树的查找操作的平均时间复杂度为O(log n),其中n是树中节点的总数。这是因为每次查找操作都将搜索范围减半,类似于二分查找的过程。然而,最坏情况下,如果二叉搜索树不平衡,例如当树形成一个链表时,查找操作的时间复杂度可能为O(n),即线性时间复杂度。

因此,为了保持二叉搜索树的平衡性,提高查找操作的效率,可以使用平衡二叉搜索树(如AVL树和红黑树)或其他改进的数据结构。这些数据结构通过动态地调整树的结构来保持树的平衡性,从而确保查找操作的时间复杂度始终为O(log n)。

操作-插入

二叉搜索树的插入方法用于向树中添加新的节点,并保持树的有序性质。

插入操作涉及查找插入位置并创建新节点的过程。每次插入的节点都会是叶子节点,也就是说每次的 cur 节点是 null 的时候,才能代表我们找到位置了。所以如果是空树,直接插入,返回 true 。

以下是二叉搜索树插入方法的具体步骤:

1. 从根节点开始,将待插入节点的键值与当前节点的键值进行比较。
2. 如果待插入节点的键值小于当前节点的键值,则说明待插入节点应该位于当前节点的左子树中。
   a. 如果当前节点的左子节点为空,说明找到了插入位置。创建一个新的节点,并将待插入节点放置在当前节点的左子节点位置。
   b. 如果当前节点的左子节点不为空,将当前节点的左子节点作为新的当前节点,然后回到步骤1。
3. 如果待插入节点的键值大于当前节点的键值,则说明待插入节点应该位于当前节点的右子树中。
   a. 如果当前节点的右子节点为空,说明找到了插入位置。创建一个新的节点,并将待插入节点放置在当前节点的右子节点位置。
   b. 如果当前节点的右子节点不为空,将当前节点的右子节点作为新的当前节点,然后回到步骤1。
4. 重复执行步骤1到步骤3,直到找到合适的插入位置。


    public class BinarySearchTree {
        static class TreeNode {
            public int val;
            public TreeNode left;
            public TreeNode right;

            public TreeNode(int val) {
                this.val = val;
            }
        }

        public TreeNode root = null;

        public boolean insert(int val) {
            TreeNode node = new TreeNode(val);
            if(root == null) {
                root = node;
                return true;
            }
            TreeNode cur = root;
            TreeNode parent = null;
            while (cur != null) {
                if(cur.val <val) {
                    parent = cur;
                    cur = cur.right;
                }else if(cur.val > val) {
                    parent = cur;
                    cur = cur.left;
                }else {
                    return false;
                }
            }
            if(parent.val < val) {
                parent.right = node;
            }else {
                parent.left = node;
            }
            return true;
        }

    }

当然,你需要思考一下,如果我们想要插入的数,已经存在在原本的二叉树中,该怎么办呢?

这个时候我们是不能够再插入这个相同的数的。因为二叉搜索树的目的在于搜索,关注的是有或者没有,而不是有几个的问题,所以对于二叉搜索树本身来说,它是不能够存储相同数据的。

通过按照键值的大小关系逐步在树中向下搜索,插入方法能够找到适当的插入位置,并在该位置创建新的节点。由于二叉搜索树的有序性质,插入操作能够在O(log n)的平均时间复杂度内完成,其中n是树中节点的总数。

然而,需要注意的是,最坏情况下,如果二叉搜索树不平衡,例如当树形成一个链表时,插入操作的时间复杂度可能为O(n),即线性时间复杂度。

为了避免这种情况,可以使用平衡二叉搜索树(如AVL树和红黑树)或其他改进的数据结构,以保持树的平衡性并确保插入操作的时间复杂度始终为O(log n)。这些平衡树结构通过旋转和调整节点来保持树的平衡,从而提高了插入操作的效率。

操作-删除

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

二叉搜索树的删除操作是指从树中删除一个节点,并保持树的有序性质。删除操作涉及三种情况:待删除节点没有子节点、待删除节点只有一个子节点、待删除节点有两个子节点。下面对每种情况进行详细解释:

1. 如果待删除节点(cur)没有子节点,即cur.left == null且cur.right == null:
   - 如果cur是根节点,将根节点设为null即可完成删除操作。
   - 如果cur不是根节点,需要根据cur是父节点(parent)的左子节点还是右子节点来决定操作:

  • 如果cur是parent的左子节点,将parent.left设为null。
  • 如果cur是parent的右子节点,将parent.right设为null。

2. 如果待删除节点只有一个子节点,即cur.left == null或cur.right == null:
   -如果cur是根节点,将根节点设为其非空子节点即可完成删除操作。
   -如果cur不是根节点,需要根据cur是父节点的左子节点还是右子节点来决定操作:

(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:
   在这种情况下,我们需要使用替罪羊法进行删除。

   具体步骤如下:
   - 从cur的右子树中找到中序遍历下的第一个结点(也就是右子树中的最左子节点),将其值复制到cur节点中。
   - 然后针对这个中序遍历下的第一个节点,递归地执行删除操作,即将这个节点作为待删除节点进行处理。因为这个节点要么没有子节点(情况1),要么只有一个右子节点(情况2)。

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


    public class BinarySearchTree {
        static class TreeNode {
            public int val;
            public TreeNode left;
            public TreeNode right;

            public TreeNode(int val) {
                this.val = val;
            }
        }

        public TreeNode root = null;

        public void remove(int val) {
            TreeNode cur = root;
            TreeNode parent = null;
            while (cur != null) {
                if(cur.val <val) {
                    parent = cur;
                    cur = cur.right;
                }else if(cur.val > val) {
                    parent = cur;
                    cur = cur.left;
                }else {
                    //删除的逻辑
                    removeNode(parent,cur);
                    return;
                }
            }
        }
        private void removeNode(TreeNode parent,TreeNode cur) {

            if(cur.left == null) {
                if(cur == root) {
                    root = cur.right;
                }else if(cur == parent.left) {
                    parent.left = cur.right;
                }else {
                    parent.right = cur.right;
                }
            }else if(cur.right == null) {
                if(cur == root) {
                    root = cur.left;
                }else if(cur == parent.left) {
                    parent.left = cur.left;
                }else {
                    parent.right = cur.left;
                }
            }else {

                TreeNode targetParent = cur;
                TreeNode target = cur.right;
                while (target.left != null) {
                    targetParent = target;
                    target = target.left;
                }
                cur.val = target.val;

                if(target == targetParent.left) {
                    targetParent.left = target.right;
                }else {
                    targetParent.right = target.right;
                }
            }
        }

    }

注意:如上步骤也可以等价替代为“左子树中最右子节点——75”。
   
需要注意的是,删除操作可能导致树的不平衡,进而影响搜索、插入和删除操作的性能。因此,类似于插入操作,可以使用平衡二叉搜索树或其他改进的数据结构来保持树的平衡性,并提高删除操作的效率。

练习:

2、下面关于二叉搜索树正确的说法是( )

A.待删除节点有左子树和右子树时,只能使用左子树的最大值节点替换待删除节点

B.给定一棵二叉搜索树的前序和中序遍率历结果,无法确定这棵二叉搜索树

C.给定一棵二叉搜索树,根据节点值大小排序所需时间复杂度是线性的

D.给定一棵二叉搜索树,可以在线性时间复杂度内转化为平衡二叉搜索树

答案:C

解析:

B:对于二叉搜索树,我们已经知道了它中序遍历的结果了,所以只要再知道其前序遍历就可以了。

C:中序遍历一遍就可以了

D:

平衡二叉搜索树(AVL树、红黑树等)的主要特点是:任意节点的左右子树的高度差(平衡因子)不超过1。而二叉搜索树在插入或删除操作后可能会变得不平衡,因此需要通过旋转等操作来保持平衡。在某些情况下,原始的二叉搜索树可能形成一条链,导致查询效率下降。为了提高搜索效率,需要将其转化为平衡二叉搜索树。

这是牛客网上一位大佬写的,我粘过来了:“如果允许额外的存储空间,可以先按照C生成一个排好序的数组,然后不断的找mid节点作为根来构造平衡树就是线性的,如果不允许额外空间只能靠旋转的话无法用线性时间。”

那么我们先来看看第一种方法:

**步骤**:
1. 中序遍历二叉搜索树,将遍历的节点保存在一个数组中。
2. 根据数组构建平衡二叉搜索树:
   a. 选取数组的中间节点作为根节点,这样可以保证左右子树高度平衡。
   b. 递归地在左子数组和右子数组中分别构建左子树和右子树,然后连接到根节点。

class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;

    TreeNode(int val) {
        this.val = val;
    }
}

public class ConvertToBalancedBST {

    public TreeNode balanceBST(TreeNode root) {
        List<TreeNode> sortedNodes = new ArrayList<>();
        inOrderTraversal(root, sortedNodes);

        return buildBalancedBST(sortedNodes, 0, sortedNodes.size() - 1);
    }

    private void inOrderTraversal(TreeNode root, List<TreeNode> sortedNodes) {
        if (root == null) {
            return;
        }

        inOrderTraversal(root.left, sortedNodes);
        sortedNodes.add(root);
        inOrderTraversal(root.right, sortedNodes);
    }

    private TreeNode buildBalancedBST(List<TreeNode> sortedNodes, int left, int right) {
        if (left > right) {
            return null;
        }

        int mid = left + (right - left) / 2;
        TreeNode root = sortedNodes.get(mid);
        root.left = buildBalancedBST(sortedNodes, left, mid - 1);
        root.right = buildBalancedBST(sortedNodes, mid + 1, right);

        return root;
    }
}

这样,在线性时间复杂度内,我们就能将一棵二叉搜索树转化为平衡二叉搜索树。

 而使用旋转的方法将二叉搜索树转化为平衡二叉搜索树通常指的是将其转换为AVL树或红黑树,这样可以保持树的平衡性。我们马上就会介绍到。现在先简单说一下:

**AVL树旋转方法**
AVL树是一种平衡二叉搜索树,它通过对节点进行旋转来保持树的平衡。AVL树的特点是任意节点的左右子树高度差不超过1。

AVL树的旋转操作分为两种:左旋和右旋。

- 左旋:对当前节点进行左旋,即将其右子树的根节点旋转为当前节点的父节点,当前节点成为其右子树的左子节点,右子树的左子节点成为当前节点的右子节点。

- 右旋:对当前节点进行右旋,即将其左子树的根节点旋转为当前节点的父节点,当前节点成为其左子树的右子节点,左子树的右子节点成为当前节点的左子节点。

**红黑树旋转方法**
红黑树也是一种平衡二叉搜索树,它通过对节点进行颜色变换和旋转来保持树的平衡。

红黑树的旋转操作包括左旋、右旋、颜色翻转等。

转换二叉搜索树为AVL树或红黑树的过程相对复杂,并不是一种线性时间复杂度的操作

一般情况下,我们可以通过构建新的平衡树的方式来实现在线性时间复杂度内转换为平衡二叉搜索树,而不是通过旋转操作直接将二叉搜索树转化为平衡树

性能分析

插入和删除操作在执行之前都需要进行查找操作,因为需要确定插入位置或要删除的节点的位置。查找操作的效率对于二叉搜索树中的各个操作的性能非常重要,所以查找效率代表了二叉搜索树中各个操作的性能。

对于具有n个节点的二叉搜索树,在假设每个元素的查找概率相等的情况下,平均查找长度是节点在二叉搜索树中的深度的函数。平均查找长度可以用于衡量二叉搜索树的性能,它表示在进行查找操作时平均需要比较的次数。当节点的深度越大,比较次数也就越多,导致查找效率下降。

对于同一个关键码集合,不同的关键码插入次序可能导致不同结构的二叉搜索树。

具体而言,最优情况下,二叉搜索树的结构为完全二叉树,每个节点的左右子树都具有相等的高度,这时平均比较次数最小。

在这种情况下,平均比较次数可以表示为log以2为底的N,其中N是节点的总数。

然而,在最差情况下,二叉搜索树可能退化为单支树。当关键码按照特定顺序插入时,可能导致每个新节点都成为前一个节点的左子节点或右子节点,使得树的结构呈现链表状,高度变大,节点越深,导致平均比较次数增加。

在这种情况下,平均比较次数可达到N/2。

为了改进二叉搜索树的性能,可以使用平衡二叉搜索树或自平衡二叉搜索树,如AVL树、红黑树、B树等。这些数据结构能够自动调整树的结构,保持树的平衡性,从而避免退化为单支树。它们通过在插入或删除操作时进行旋转、重构等操作,确保树的高度保持在较小的范围内,提供更好的查找性能。

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

当二叉搜索树退化为单支树时,性能确实会受到影响。为了改进这种情况,可以使用平衡二叉搜索树(Balanced Binary Search Tree)或自平衡二叉搜索树(Self-Balancing Binary Search Tree)。这些改进的数据结构能够自动调整树的结构,以保持树的平衡性,从而提供较为稳定的性能。

1、平衡二叉搜索树是指一种具有平衡性质的二叉搜索树,其中任何节点的左子树和右子树的高度差不超过一个特定的常数。通过在插入或删除节点时进行旋转、重构等操作,平衡二叉搜索树能够自动调整树的结构,以保持树的平衡性。常见的平衡二叉搜索树包括AVL树、红黑树、B树等。

举个例子——AVL树,它会通过树的旋转来降低树的高度,有左旋、右旋、左右双旋、右左双旋。比如:

再举个例子——红黑树,它得名于每个节点上的额外属性,即节点是红色或黑色。它也可以旋转,并具有以下特性:

  • 每个节点要么是红色,要么是黑色。
  • 根节点是黑色。
  • 每个叶子节点(NIL节点或空节点)都是黑色。
  • 如果一个节点是红色,则其子节点必须是黑色。
  • 对于每个节点,从该节点到其所有后代叶子节点的简单路径上,包含相同数量的黑色节点(即黑色平衡)。

红黑树比较难,是属于高阶数据结构中的内容,我们这里只做简单了解,总之你需要先了解二叉搜索树,再熟悉AVL树,最后才接触红黑树。 

2、自平衡二叉搜索树是一种特殊的平衡二叉搜索树,它在插入或删除节点时能够自动执行调整操作,而不需要外部干预。自平衡二叉搜索树通过在节点插入或删除后重新平衡树的结构,以保持树的平衡性。常见的自平衡二叉搜索树包括红黑树、AA树、Splay树、Treap等。

这些平衡树结构的目标是尽可能地保持树的平衡性,从而提供一致且较好的性能,无论关键码的插入顺序如何。它们通过自动调整节点的位置和树的结构,确保树的高度保持在较小的范围内,从而保持查找、插入和删除操作的时间复杂度始终为O(log n)。

通过使用平衡二叉搜索树或自平衡二叉搜索树,无论关键码的插入顺序如何,都能够保持二叉搜索树的性能最佳。这些数据结构的设计和算法旨在解决退化为单支树的问题,并提供一致的高效性能。它们在实际应用中得到广泛应用,尤其是在需要处理动态数据集合并保持快速操作的场景中。

和 java 类集的关系

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

搜索

概念及场景

Map和Set是专门用于进行搜索的容器或数据结构,其搜索的效率与其具体的实例化子类有关。它们提供了高效的动态查找操作,并且适用于需要插入和删除元素的场景。下面对Map和Set进行详细解释:

1. Map(映射):Map是一种键值对(Key-Value)的集合,它将每个键映射到对应的值。在Map中,每个键是唯一的,可以通过键来查找对应的值。Map提供了一种高效的方式来根据键进行搜索操作,因为它内部使用了散列(哈希)表或平衡树等数据结构来实现。常见的Map实现包括HashMap、TreeMap、LinkedHashMap等。这些实现根据具体的需求选择不同的底层数据结构,以提供快速的搜索操作。

2. Set(集合):Set是一种存储不重复元素的集合,它提供了高效的查找操作,以判断元素是否存在于集合中。Set不允许存储重复的元素,因此在搜索时可以快速确定元素是否存在。Set内部通常使用散列表(HashSet)或平衡树(TreeSet)等数据结构来实现。与Map类似,Set的实现根据具体的需求选择适合的底层数据结构,以提供高效的查找操作。

以前常见的搜索方式有:

1. 直接遍历,时间复杂度为O(N),元素如果比较多效率会非常慢

2. 二分查找,时间复杂度为(log 以 2 为底的 N ),但搜索前必须要求序列是有序的

总之,上述排序比较适合静态类型的查找,即一般不会对区间进行插入和删除操作了,而现实中的查找比如:

1. 根据姓名查询考试成绩

2. 通讯录,即根据姓名查询联系方式

3. 不重复集合,即需要先搜索关键字是否已经在集合中

可能在查找时进行一些插入和删除的操作,即动态查找,那上述两种方式就不太适合了,而Map和Set恰好是一种适合动态查找的集合容器

相比于直接遍历和二分查找,Map和Set适用于动态查找,即在搜索过程中需要进行插入和删除操作的情况。通过使用散列表或平衡树等数据结构,它们能够在插入和删除元素时自动调整内部结构,以保持高效的搜索性能。无论是根据键查找值还是判断元素是否存在,Map和Set都能提供较好的时间复杂度,通常为O(1)或O(log n)

模型

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

1. 纯 key 模型,比如:

有一个英文词典,快速查找一个单词是否在词典中

快速查找某个名字在不在通讯录中

 该种模型主要适用于 TreeSet 和 HashSet

2. Key-Value 模型,比如:

  • 统计文件中每个单词出现的次数,统计结果是每个单词都有与其对应的次数:单词——单词出现的次数
  • 作者的笔名:每个作者都有一个笔名
  • 梁山好汉的江湖绰号:每个好汉都有自己的江湖绰号

 该种模型主要适用于 TreeMap 、 HashMap

简单来说,Map中存储的就是key-value的键值对,Set中只存储了Key。  

Map 的使用

关于Map的说明

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

注意,TreeSet 和 TreeMap 都实现了SortedSet,也就是说,它们都是需要排序比较的。

关于Map.Entry的说明

针对键值对(key-value pair)数据结构中的一个条目(entry)而言,在许多编程语言和数据结构中,例如Java的Map接口和相关实现类,都使用键值对来表示数据的存储和检索。

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

1. K getKey():

  • 这个方法用于获取条目(entry)中的键(key)。
  • 键是用来唯一标识条目的,它在Map或其他键值对数据结构中起到索引和唯一识别的作用。
  • 通过调用getKey()方法,可以获得键对应的值或进行其他操作,例如搜索、比较或进一步处理。

2. V getValue():

  • 这个方法用于获取条目(entry)中的值(value)。
  • 值是与键关联的数据,在键值对中存储具体的信息或数据。
  • 通过调用getValue()方法,可以获得与键对应的值,进行数据的检索、操作或其他处理。

3. V setValue(V value):

  • 这个方法用于将条目(entry)中的值(value)替换为指定的新值(value)。
  • 通过调用setValue()方法,可以更新或修改条目中的值。
  • 这个方法通常用于对键值对数据结构中的值进行更新操作,以保持数据的最新状态。

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

Map 的常用方法说明

1. V get(Object key):

  • 这个方法用于根据给定的键(key)返回对应的值(value)。
  • 它通过传入一个键作为参数来获取与该键相关联的值。
  • 如果键存在于键值对数据结构中,则返回对应的值;如果键不存在,则返回null。

2. V getOrDefault(Object key, V defaultValue):

  • 这个方法用于根据给定的键返回对应的值,但是当键不存在时,返回一个默认值。
  • 它类似于get()方法,但是在键不存在时不返回null,而是返回通过参数指定的默认值。
  • 如果键存在于键值对数据结构中,则返回对应的值;如果键不存在,则返回指定的默认值。

3. V put(K key, V value):

  • 这个方法用于将给定的键值对(key-value)添加到键值对数据结构中。
  • 它将指定的键与对应的值相关联,将其作为一个新的条目添加到键值对集合中。
  • 如果键已经存在,则会用新的值替换原有的值,并返回原有的值;如果键不存在,则直接添加键值对。

4. void remove(Object key):

  • 这个方法用于删除键值对数据结构中给定键所对应的映射关系(即删除条目)。
  • 它通过传入一个键作为参数来删除键值对集合中对应的条目。
  • 如果键存在,则删除对应的映射关系;如果键不存在,则不做任何操作。

5. Set<K> keySet():

  • 这个方法用于返回键值对数据结构中所有键的不重复集合。
  • 它返回一个Set集合,其中包含了键值对数据结构中所有键的不重复项。
  • 这个集合可以用于遍历所有的键,进行搜索、迭代或其他操作。

6. Collection<V> values():

  • 这个方法用于返回键值对数据结构中所有值的可重复集合。
  • 它返回一个Collection集合,其中包含了键值对数据结构中所有值的可重复项。
  • 这个集合可以用于遍历所有的值,进行搜索、迭代或其他操作。

7. Set<Map.Entry<K, V>> entrySet():

  • 这个方法用于返回键值对数据结构中所有的键值对映射关系的集合。
  • 它返回一个Set集合,其中包含了键值对数据结构中所有的键值对映射关系。
  • 每个映射关系都表示为Map.Entry对象,包含一个键和对应的值。

这里你可以观察一下,我们的TreeMap实现了Map接口,它的Entry要重写了Map.Entry,而这里的Entry的成员你可以仔细看看。

当然,还有别的方法的重写。

8. boolean containsKey(Object key):

  • 这个方法用于判断键值对数据结构中是否包含指定的键。
  • 如果键存在于键值对集合中,则返回true;如果键不存在,则返回false。

9. boolean containsValue(Object value):

  • 这个方法用于判断键值对数据结构中是否包含指定的值。
  • 如果值存在于键值对集合中,则返回true;如果值不存在,则返回false。

我们简单运用一下这些方法:



        public static void main2(String[] args) {
            Map<String,Integer> map = new TreeMap<>();
            TreeMap<String,Integer> map2 = new TreeMap<>();

            map2.put("this",3);

            map2.put("phe",5);
            
            map2.put("a",7);
            //这里val是会被更新的
            map2.put("a",8888);

            int val = map2.getOrDefault("phe8",1999);
            System.out.println(val);


            Set<String> set = map2.keySet();


            Collection<Integer> collection =  map2.values();

            System.out.println("===");

            Set<Map.Entry<String,Integer>> entries = map2.entrySet();

            for (Map.Entry<String,Integer> entry  : entries) {
                System.out.println("key: "+entry.getKey()+" value: "+entry.getValue());
            }

这些方法提供了对键值对数据结构进行检索、操作和判断的功能。通过这些方法,我们可以方便地根据键获取值、添加或删除键值对、获取键集合或值集合,以及判断键或值是否存在于键值对集合中。它们使得对键值对数据的处理更加简单和高效。

注意:

1. Map是一个接口,不能直接实例化对象:
   - Map是Java中表示键值对的接口,它定义了操作键值对数据的方法。
   - 由于Map是一个接口,不能直接实例化对象,需要使用它的具体实现类来创建Map对象,如TreeMap或HashMap。

2. Map中存放键值对的Key是唯一的,value是可以重复的
   - Map中的Key是用来唯一标识和索引键值对的,每个Key在Map中只能存在一个。
   - 不同的Key可以对应相同的值,因此Map中的value是可以重复的。

3. TreeMap和HashMap的区别:
   - TreeMap是基于红黑树(一种自平衡的二叉搜索树)实现的有序Map,它根据键的自然顺序或自定义的Comparator对键进行排序。
   - HashMap是基于散列表(哈希表)实现的无序Map,它使用键的哈希码来确定存储位置,没有固定的顺序。
   - TreeMap适用于需要按键排序的场景,而HashMap适用于无序的键值对存储和检索。
   - 由于红黑树的特性,TreeMap的查找、插入和删除操作的时间复杂度为O(log n),而HashMap的时间复杂度通常为O(1)。

4. 在TreeMap中插入键值对时,key不能为空,value可以为空:
   - 在TreeMap中插入键值对时,键(key)不能为空,否则就会抛NullPointerException异常,因为TreeMap使用红黑树来维护键的顺序,需要进行比较操作。
   - 值(value)可以为空,因为在TreeMap中,键值对的顺序是基于键的。

5. HashMap的key和value都可以为空:
   - 在HashMap中,键(key)和值(value)都可以为空,因为HashMap使用哈希码来确定存储位置,不依赖于键的比较操作。

6. Map中的Key和value的修改:
   - Map中的键(Key)一般是不可修改的,因为键用于唯一标识和索引键值对。
   - 如果需要修改键,只能先将原有的键删除,然后插入一个新的键值对。
   - 值(value)可以直接通过put()方法进行修改。

7. Map中的Key和value的分离:
   - Map中的Key可以使用keySet()方法将所有键分离出来,并存储到一个Set集合中,因为Key是唯一的。
   - Value可以使用values()方法将所有值分离出来,并存储到Collection的任何一个子集合中,因为Value可能有重复。

总结:Map是一种键值对数据结构,通过键来索引和唯一标识值。它有不同的实现类(如TreeMap和HashMap),可以根据需要选择合适的实现类。在使用Map时,需要注意各种操作的限制和特点,如键的唯一性、键值对的插入和删除、键和值的修改,以及分离出Key和Value等操作。

TreeMap的使用案例

import java.util.TreeMap;
import java.util.Map;

public class TreeMapExample {
    public static void main(String[] args) {
        Map<String, String> m = new TreeMap<>();

        // put(key, value): 插入key-value的键值对
        // 如果key不存在,会将key-value的键值对插入到map中,返回null
        m.put("林冲", "豹子头");
        m.put("鲁智深", "花和尚");
        m.put("武松", "行者");
        m.put("宋江", "及时雨");
        String str = m.put("李逵", "黑旋风");

        System.out.println(m.size());
        System.out.println(m);

        // put(key, value): 注意key不能为空,但是value可以为空
        // key如果为空,会抛出空指针异常
        // m.put(null, "花名");
        str = m.put("无名", null);
        System.out.println(m.size());

        // put(key, value):
        // 如果key存在,会使用value替换原来key所对应的value,返回旧value
        str = m.put("李逵", "铁牛");

        // get(key): 返回key所对应的value
        // 如果key存在,返回key所对应的value
        // 如果key不存在,返回null
        System.out.println(m.get("鲁智深"));
        System.out.println(m.get("史进"));

        // getOrDefault(): 如果key存在,返回与key所对应的value,如果key不存在,返回一个默认值
        System.out.println(m.getOrDefault("李逵", "铁牛"));
        System.out.println(m.getOrDefault("史进", "九纹龙"));

        System.out.println(m.size());

        // containsKey(key): 检测key是否包含在Map中,时间复杂度:O(logN)
        // 按照红黑树的性质来进行查找
        // 找到返回true,否则返回false
        System.out.println(m.containsKey("林冲"));
        System.out.println(m.containsKey("史进"));

        // containsValue(value): 检测value是否包含在Map中,时间复杂度: O(N)
        // 找到返回true,否则返回false
        System.out.println(m.containsValue("豹子头"));
        System.out.println(m.containsValue("九纹龙"));

        // 打印所有的key
        // keySet是将map中的key放在Set中返回的
        for (String s : m.keySet()) {
            System.out.print(s + " ");
        }
        System.out.println();

        // 打印所有的value
        // values()是将map中的value放在Collection的一个子集合中返回的
        for (String s : m.values()) {
            System.out.print(s + " ");
        }
        System.out.println();

        // 打印所有的键值对
        // entrySet(): 将Map中的键值对放在Set中返回了
        for (Map.Entry<String, String> entry : m.entrySet()) {
            System.out.println(entry.getKey() + "--->" + entry.getValue());
        }
    }
}

Set 的说明

Set是Java中的一种接口,它继承自Collection接口,用于表示一组不重复的元素集合。

与List不同,Set不允许存储重复的元素,每个元素在Set中只能出现一次。Set的主要特点包括:

1. 不允许重复元素:Set中的元素是唯一的,每个元素只能出现一次。当尝试向Set中添加重复的元素时,添加操作会被忽略,不会导致集合发生改变。

2. 继承自Collection接口:Set是Collection接口的一个子接口,它定义了一系列操作来管理集合中的元素,例如添加、删除、查询等。

Set与Map的主要不同点有两个:

1. 存储内容:Set只存储了Key,而不存储Key-Value键值对。它只关注集合中的元素本身,而不需要与其他值进行关联。Set中的元素通常用于表示一组独立的、不重复的对象。

2. 元素的重复性:Set不允许存储重复的元素,每个元素在Set中只能出现一次。这与Map不同,Map存储的是Key-Value键值对,其中Key是用来索引和唯一标识Value的,不同的Key可以对应相同的Value。

Set接口的常见实现类包括HashSet、TreeSet和LinkedHashSet。HashSet基于哈希表实现,具有较快的插入和查找速度;TreeSet基于红黑树实现,可以按照元素的自然顺序或自定义比较器进行排序;LinkedHashSet基于哈希表和链表实现,保持了元素插入的顺序。

Set在实际应用中常用于去重、元素唯一性的判断和集合运算等场景。通过使用Set,可以轻松地管理一组不重复的元素,快速进行元素的查找和操作,并确保集合中的元素保持唯一性。

Set 的常见方法说明

1. boolean add(E e):

  • 这个方法用于向Set中添加元素。
  • 如果元素在Set中不存在,则将元素添加到Set中,并返回true。
  • 如果元素在Set中已经存在,则添加操作会被忽略,并返回false。

2. void clear():

  • 这个方法用于清空Set,即移除Set中的所有元素。
  • 调用clear()方法后,Set将变为空集合。

3. boolean contains(Object o):

  • 这个方法用于判断给定的对象是否在Set中。
  • 如果Set包含给定对象,则返回true;否则返回false。

4. Iterator iterator():

  • 这个方法用于获取Set的迭代器,以便对Set中的元素进行迭代。
  • 迭代器可以用于依次访问Set中的每个元素。

5. boolean remove(Object o):

  • 这个方法用于从Set中移除指定的对象。
  • 如果Set包含给定对象,则将其从Set中移除,并返回true。
  • 如果Set不包含给定对象,则不做任何修改,并返回false。

6. int size():

  • 这个方法用于获取Set中的元素个数。
  • 返回Set中元素的数量。

7. boolean isEmpty():

  • 这个方法用于检测Set是否为空。
  • 如果Set为空,即没有任何元素,返回true;否则返回false。

8. Object[] toArray():

  • 这个方法将Set中的元素转换为数组,并返回该数组。
  • 返回的数组类型为Object[],可以根据需要进行强制类型转换。

9. boolean containsAll(Collection c):

  • 这个方法用于检查集合c中的所有元素是否都存在于Set中。
  • 如果Set包含集合c中的所有元素,则返回true;否则返回false。

10. boolean addAll(Collection c):

  • 这个方法用于将集合c中的元素添加到Set中。
  • 添加操作会保持Set的唯一性,重复的元素不会被重复添加。
  • 如果Set发生了变化(至少添加了一个新元素),则返回true;否则返回false。

这些方法提供了对Set集合进行操作和查询的常用功能。通过这些方法,我们可以向Set中添加元素、移除元素、判断元素是否存在、获取集合的大小、检查集合是否为空,以及进行集合间的操作,如合并、子集判断等。通过对Set的灵活使用,我们可以高效地管理不重复的元素集合。

注意:

1. Set是继承自Collection的一个接口类:
   - Set接口是Java集合框架中的一部分,它继承自Collection接口,定义了一组操作用于管理一组不重复的元素。

2. Set中只存储了key,并且要求key一定要唯一:
   - Set集合中只存储了键(key),没有对应的值(value)。
   - Set要求集合中的元素(key)是唯一的,每个元素只能出现一次(是因为它的底层是Map)。
   - 这意味着无论是Set的实现类还是Set接口本身,都会自动去除重复的元素,确保集合中没有重复的键。

3. TreeSet的底层是使用Map来实现的:
   - TreeSet是Set接口的一个实现类,它是基于红黑树(一种自平衡的二叉搜索树)实现的有序集合。
   - TreeSet内部使用TreeMap(基于红黑树的有序映射)来实现,它将元素作为键存储在TreeMap的键中,值则使用一个默认对象(Object)作为统一的值。
   - 通过TreeMap的有序性,TreeSet能够实现有序集合的特性。

 

4. Set最大的功能就是对集合中的元素进行去重:
   - Set的主要功能是确保集合中的元素唯一,它自动去除重复的元素。
   - 当试图向Set中添加重复的元素时,添加操作会被忽略,不会导致集合发生改变。

5. 实现Set接口的常用类有TreeSet和HashSet,还有一个LinkedHashSet:
   - TreeSet是基于红黑树实现的有序集合,它可以按照元素的自然顺序或自定义比较器进行排序。
   - HashSet是基于散列表实现的无序集合,它使用哈希码来确定存储位置,没有固定的顺序。
   - LinkedHashSet是HashSet的子类,它在HashSet的基础上维护了一个双向链表来记录元素的插入次序,保持了元素插入的顺序。

6. Set中的Key不能修改,如果要修改,先将原来的删除掉,然后再重新插入:
   - Set中的键(Key)一般是不可修改的,因为键用于唯一标识和索引元素。
   - 如果需要修改键,只能先将原有的键删除,然后重新插入一个新的键值对。

7. TreeSet中不能插入null的key,HashSet可以:
   - TreeSet是基于红黑树实现的有序集合,在插入元素时会进行排序,你需要进行比较。因此不允许插入null的键。
   - HashSet是基于散列表实现的无序集合,可以插入null作为键。

8. TreeSet和HashSet的区别:
   - TreeSet是有序集合,它根据元素的自然顺序或自定义比较器进行排序,内部使用红黑树实现。因此,它适合需要有序性的场景。
   - HashSet是无序集合,它使用散列表(哈希表)来存储元素,没有固定的顺序。由于散列表的特性,HashSet在插入和查找操作上具有较快的性能。它适用于不需要有序性的场景。
   - 另外,TreeSet和HashSet的迭代顺序也是不同的,TreeSet的迭代顺序是根据元素的排序顺序,而HashSet的迭代顺序是不确定的,可能会受到散列表的扩容和哈希冲突等因素的影响。

Set底层结构TreeSet HashSet
底层结构红黑树哈希桶
插入/删除/查找时间复杂度O( log 以 2 为底的 N)O(1)
是否有序关于Key有序不一定有序
线程安全不安全不安全
插入/删除/查找区别按照红黑树的特性来进行插入和删除

1. 先计算key哈希地址

2. 然后进行 插入和删除

比较与覆写key必须能够比较,否则会抛出 ClassCastException异常

自定义类型需要覆写

equals和 hashCode方法

应用场景需要Key有序场景下

Key是否有序不关心,

需要更高的时间性能

TreeSet的使用案例

import java.util.TreeSet;
import java.util.Iterator;
import java.util.Set;

public class StudentSetExample {
    public static void main(String[] args) {
        Set<Student> studentSet = new TreeSet<>();

        // 添加学生对象到集合中
        studentSet.add(new Student("Alice", 20));
        studentSet.add(new Student("Bob", 22));
        studentSet.add(new Student("Charlie", 18));
        studentSet.add(new Student("David", 21));

        System.out.println("学生集合中的人数:" + studentSet.size());
        System.out.println("学生集合中的学生信息:" + studentSet);

        // 添加重复的学生对象,查看插入结果
        boolean isIn = studentSet.add(new Student("Bob", 22));
        System.out.println("插入重复的学生对象是否成功:" + isIn);

        // 判断学生对象是否在集合中
        System.out.println("学生集合中是否包含Bob:" + studentSet.contains(new Student("Bob", 22)));
        System.out.println("学生集合中是否包含Eve:" + studentSet.contains(new Student("Eve", 20)));

        // 删除学生对象
        studentSet.remove(new Student("Charlie", 18));
        System.out.println("删除学生Charlie后的集合:" + studentSet);

        // 使用迭代器遍历学生集合
        Iterator<Student> iterator = studentSet.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }

    // 学生类,实现Comparable接口用于比较和排序
    static class Student implements Comparable<Student> {
        private String name;
        private int age;

        public Student(String name, int age) {
            this.name = name;
            this.age = age;
        }

        @Override
        public int compareTo(Student other) {
            // 按照年龄进行排序
            return Integer.compare(this.age, other.age);
        }

        @Override
        public String toString() {
            return name + " (" + age + ")";
        }
    }
}

哈希表

概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( log 以2为底的N ),搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。

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

当向该结构中:

  • 插入元素                                                                                                                                          根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
  • 搜索元素                                                                                                                                          对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置按照哈希表取元素比较,若关键码相等,则搜索成功

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

举例来说,考虑一个数据集合{1,7,6,4,5,9},

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

哈希表是一种高效的数据结构,它在插入、查找和删除等操作上具有快速的时间复杂度。通过合理选择和设计哈希函数,可以最大程度地减少元素的比较次数,提高搜索效率。然而,在实际应用中,哈希表也面临一些挑战,如哈希冲突、哈希函数设计和动态扩容等问题,需要综合考虑和解决。

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快 问题:按照上述哈希方式,向集合中插入元 素16,会出现什么问题?

6 % 10 = 6;   16 % 10 = 6,此时,两个不同的关键字,经过相同的哈希函数,找到了相同的位置——>这就是哈希冲突。

冲突-概念

对于两个数据元素的关键字ki 和 kj (ki != kj),有 != ,但有:Hash( ki ) == Hash( kj ),

即:不同关键字通过相同哈希函数,计算出相同的哈希地址,该种现象称为哈希冲突。

哈希冲突也被称为哈希碰撞。它是在哈希表中常见的现象,因为哈希函数的输出空间通常比输入空间要小,所以不同的关键字可能会映射到相同的哈希地址上。

具有不同关键码但具有相同哈希地址的数据元素被称为“同义词”(Synonyms)或者“冲突项”。这些冲突项会在哈希表中发生冲突,因为它们被映射到了相同的存储位置。

冲突-避免

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

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

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。 合理的哈希函数设计可以最大程度地减少冲突的发生,提高哈希表的性能和效率。

哈希函数设计原则:

1. 完全覆盖:哈希函数的定义域必须包括需要存储的全部关键码。确保所有可能的关键码都能够被哈希函数处理,避免出现关键码无法映射到哈希地址的情况。例如:如果散列表允许有m个地址时,其值域必须在0到m-1 之间

2. 均匀分布:哈希函数计算出来的地址应该能够均匀分布在整个哈希表的空间中。这样可以减少冲突的概率,避免出现某些地址集中存储大量元素,而其他地址却很少使用的情况。

3. 简单性:哈希函数应该具备一定的简单性,计算速度要快,避免过于复杂的计算过程导致性能下降。简单的哈希函数通常具有较低的计算复杂度,适用于高效的哈希表操作。

常见的哈希函数:

1. 直接定制法:这种方法通过定义关键字的线性函数来计算哈希地址,即取关键字的某个线性函数为散列地址: Hash(Key) = A * Key + B。其中A和B是常数。

直接定制法的优点是简单、均匀且易于实现,适用于关键字的分布比较小且连续的情况。

比如说:字符串中的第一个唯一字符:给定一个字符串s,找到它的第一个不重复的字符,并返回它的索引。如果不存在,则返回-1。

class Solution {
    public int firstUniqChar(String s) {
        char[] charrr=s.toCharArray();
        int [] array=new int[26];
        for(int i=0;i<charrr.length;i++){
                int ret=(int)(charrr[i]-'a');
                array[ret]++;
        }
        for(int j=0;j<charrr.length;j++){
            if(array[charrr[j]-'a']==1){
                return j;
            }
        }
        return  -1;
    }
}

缺点是我们需要事先知道关键字的分布情况。

但是需要注意的是,如果选择的线性函数不够随机或者不适合特定的数据集,可能会导致冲突率增加。

2. 除留余数法:设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数: Hash(key) = key% p(p<=m),将关键码转换成哈希地址。这样可以保证哈希地址在0到p-1的范围内。除留余数法的优点是简单且能够均匀分布元素。通常会选择与哈希表容量接近的质数作为除数,以减少冲突的发生。

3. 平方取中法:这种方法先对关键字进行平方操作,然后从平方结果中抽取中间的几位作为哈希地址。例如,对关键字1234进行平方得到1522756,然后取中间的3位227作为哈希地址。平方取中法适用于关键字的分布未知且位数不是很大的情况。通过平方操作可以更好地散列关键字,抽取中间位数可以避免取到极端值。

4. 折叠法:这种方法将关键字从左到右分割成位数相等的几部分,然后将这几部分叠加求和,并按照散列表的长度取后几位作为哈希地址。例如,对关键字的位数是7,散列表长度是1000,可以将关键字分割成3部分,每部分的位数是2,然后将这3部分叠加求和,再取后3位作为哈希地址。折叠法适用于关键字的分布未知且位数较多的情况。通过将关键字分割并求和,可以更好地分散关键字。

5. 随机数法:这种方法选择一个随机函数,将关键字的随机函数值作为哈希地址,即 Hash(Key) = Random(Key),其中Random为随机数函数。随机数法通常应用于关键字长度不等的情况。通过使用随机函数,可以增加哈希的随机性,避免某些特定关键字导致的冲突。

6. 数学分析法:这种方法根据关键字的分布情况选择合适的位作为散列地址。设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某 些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据 散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以 选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如  1234 改成 4321 )、右环位移(如 1234 改成 4123 )、左环移位、前两数与后两数叠加( 如 1234 改成12+34=46 )等方 法。数字分析法通常适合处理关键字位数比较大的情况和如果事先知道关键字的分布且关键字的若干位分布较均匀的情况

注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

选择合适的哈希函数需要综合考虑数据集的特点、哈希表的大小和性能需求。对于不同类型的数据集和应用场景,可能需要尝试不同的哈希函数并进行性能评估,以选择最佳的哈希函数。

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

负载因子(Load Factor)是指哈希表(Hash Table)中已存储元素数量与哈希表总容量的比率。在哈希表中,元素被存储在数组中的特定位置,称为槽位(Bucket)。

负载因子的计算公式为:

负载因子 = 已存储元素数量 / 哈希表总容量

通常用符号 "α" 表示负载因子。例如,如果一个哈希表中已存储了10个元素,而哈希表的总容量是20,则负载因子为 10/20 = 0.5。

负载因子是用来衡量哈希表的装填程度。负载因子越大,表示哈希表中已存储的元素越多,装填程度越高。相反,如果负载因子较小,表示哈希表中的元素相对较少,装填程度较低。

负载因子的大小直接影响哈希表的性能。较小的负载因子通常意味着较少的冲突(Collision)发生,查找、插入和删除操作的平均时间复杂度较低。然而,负载因子过小可能导致哈希表的内存空间被浪费,因为大部分的槽位没有被利用。

通常情况下,负载因子的取值范围为0到1之间。为了保持哈希表的性能在可接受的范围内,可以设置一个负载因子阈值。当负载因子超过该阈值时,就触发哈希表的扩容操作,增加哈希表的总容量,从而减少负载因子,使得哈希表能够容纳更多的元素,并保持较低的装填程度。

综上所述,负载因子是哈希表中已存储元素数量与哈希表总容量的比率,用于衡量哈希表的装填程度,直接影响哈希表的性能。保持适度的负载因子可以提高哈希表的操作效率和内存利用率。

负载因子和冲突率的关系粗略演示: 

所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。

当你需要降低负载因子的时候,已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小,即增加散列表的长度。

冲突-解决

解决哈希冲突两种常见的方法是:闭散列开散列

冲突-解决-闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 把key存放到冲突位置中的“下一个” 空位置中去。

那如何寻找下一个空位置呢?

1. 线性探测

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

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

插入

  1. 通过哈希函数获取待插入元素在哈希表中的位置
  2. 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到 下一个空位置,插入新元素

但是看图,你会发现:更多冲突的元素都被聚集在了一起:

而且,采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响,当你查到下标为4的元素为NULL时,你可能会认为没有44,但是事实上它是有的。

因此线性探测采用标记的伪删除法来删除一个元素:当要删除元素4时,不会直接将其从哈希表中删除,而是将该位置标记为“已删除”。这样,对于查找操作,当遇到标记为“已删除”的位置时,仍会继续向后探测,以便找到可能存在的其他元素。只有当遇到真正的空位置时,查找操作才会停止。

2. 二次探测

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

如果要插入44,产生冲突,使用解决后的情况为:

插入44:(4+2^2)%10=8

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

因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

练习:

1、设哈希表长度为11,哈希函数H(K)=(K的第一个字母在字母表中的序号)MOD11,若输入顺序为(D,BA,TN,M,CI,I,K,X,TA),采用内散列表,处理冲突方法为线性探测法,要求构造哈希表,在等概率情况下查找成功平均查找长度为()

A. 4

B. 3

C. 20/9

D. 23/9

答案:C

解析:
K是的第一个字母在字母表中的序号 :

D=4 mode11=4,1次

B=2 mod11=2,1次

T=20 mod11=9,1次

M=13 mod11=2->3,2次

C=3 mod11=3->4->5,3次

I=9 mod11=9->10,2次

K=11mod11=0,1次

X=24 mod11=2->3->4->5->6,5次

T=20 mod11=9->10->0->1,4次

9个数字,共20次,所以20/9。

2、采用开放定址法处理散列表的冲突时,其平均查找长度高于链接法处理冲突。

3、用哈希(散列)方法处理冲突(碰撞)时可能出现堆积(聚集)现象,下列选项中,会受堆积现象直接影响的是 ( )

A.存储效率

B.数列函数

C.装填(装载)因子

D.平均查找长度

答案:D

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

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

数组+链表的结构:

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

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

当链表的长度超过8,并且数组的长度超过64的时候,链表会变成红黑树,此时会大大提高性能。

插入元素:JDK1.8的时候是尾插法,JDK1.8之前是头插法。

练习:

已知有一个关键字序列:(19,14,23,1,68,20,84,27,55,11,10,79)散列存储在一个哈希表中,若散列函数为H(key)=key%7,并采用链地址法来解决冲突,则在等概率情况下查找成功的平均查找长度为( )

A.1.5

B.1.7

C.2.0

D.2.3

答案:A

解析:

冲突严重时的解决办法

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

1. 每个桶的背后是另一个哈希表

2. 每个桶的背后是一棵搜索树

实现

先来一个简单的实现:

    public class HashBuck {

        static 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[] array;
        public int usedSize;

        public HashBuck() {
            array = new Node[10];
        }

        //头插法
        public void put(int key, int val) {
            int index = key % array.length;

            Node cur = array[index];
            //先遍历一遍整体的链表 是否已经存在当前key
            while (cur != null) {
                if (cur.key == key) {
                    cur.val = val;
                    return;
                }
                cur = cur.next;
            }
            
            //没有这个key
            Node node = new Node(key, val);
            node.next = array[index];
            array[index] = node;
            usedSize++;

            if (loadFactor() >= 0.75) {
                //超过最大容量——扩容
                resize();
            }
        }


        /**
         * 扩容需要注意的事项是什么?
         * 把所有的元素都要进行重新的哈希,因为本身链表的长度变了
         */
        private void resize() {
            Node[] tmpArr = new Node[array.length * 2];
            //遍历原来的数组 将所有的元素“重新哈希”到新的数组当中
            for (int i = 0; i < array.length; i++) {
                Node cur = array[i];
                while (cur != null) {
                    //记录当前节点的下个节点
                    Node curNext = cur.next;
                    int newIndex = cur.key % tmpArr.length;
                    //头插
                    cur.next = tmpArr[newIndex];
                    tmpArr[newIndex] = cur;
                    cur = curNext;
                }
            }
            array = tmpArr;
        }

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


        public int get(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;
        }
    }

扩容之后:

这个时候有一个问题:刚刚写的代码只适用于 key 等于整数的情况,如果是一个自定义类型,你还能用“==”来比较吗?并且,如果我们的 key 是一个字符串这种引用类型,那么计算下标的时候还可以直接用长度来计算吗?

这样,我们就不得不重写我们的 equals()方法,而且还会涉及到 hashCode() 方法

hashCode() 是 Java 中定义在 Object 类中的一个方法,用于计算对象的哈希码(hash code)。哈希码是一个整数值,用于在哈希表等数据结构中快速定位对象。

在 Java 中,哈希表是一种常见的数据结构,用于存储键-值对,比如 HashMap 和 HashSet。在这些数据结构中,当你插入一个对象作为键或值时,Java 会调用该对象的 hashCode() 方法来计算哈希码,并将对象存储在相应的位置上。当你要查找一个对象时,Java 也会先计算其哈希码,然后定位到相应的位置来查找。

hashCode() 方法的默认实现位于 Object 类中,其实现如下:

public class Object {
    public native int hashCode();
    // ...
}

在这里,你会发现你是看不到它的具体实现方法的,里面的 native 关键字表示该方法的实现是由本地代码(Native Code)提供的,是用C++代码写的,而不是用 Java 代码实现的。这是因为每个对象的哈希码通常是由对象的内部表示和地址等信息计算得出的,而这些信息对于 Java 代码并不直接可见,需要通过本地代码来获取。

当我们自定义类时,通常会重写 hashCode() 方法,以便根据对象的内容来计算哈希码,而不仅仅是依赖于默认的 Object 类实现。这是因为,当两个对象的内容相同时,它们应该具有相同的哈希码。这样,当我们将自定义类的对象用作键或值存储在哈希表中时,能够正确地找到和检索对象。而默认的 Object 类中实现的 hashCode() 方法可能无法得到正确的结论。

在重写 hashCode() 方法时,通常需要遵循以下几个原则:

1. 一致性:相同对象多次调用 hashCode() 方法应该返回相同的哈希码。
2. 相等性:如果两个对象相等(根据 equals() 方法判断),则它们的哈希码也应该相等。
3. 效率:计算哈希码的过程应该尽可能高效,避免耗费大量计算资源。

为了满足以上原则,通常在重写 hashCode() 方法时会使用对象内部的一些属性来计算哈希码,例如:

public class MyClass {
    private int id;
    private String name;

    // constructors, getters, setters, etc.

    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + id;
        result = 31 * result + (name != null ? name.hashCode() : 0);
        return result;
    }
}

在上面的例子中,我们使用了经典的计算哈希码的方法:将一个质数(31)与当前哈希码相乘,然后再加上属性的哈希码。这种做法可以帮助我们尽量避免哈希码冲突,从而提高哈希表的性能。

总结:hashCode() 方法是 Java 中用于计算对象哈希码的方法,它对于存储对象在哈希表等数据结构中起着关键作用。在自定义类中,我们应该根据对象的内容来重写 hashCode() 方法,确保相等的对象具有相同的哈希码,并且尽可能避免哈希冲突,以提高数据结构的性能。

如下是一个更加具体的实现:

import java.util.Objects;

class Person {
    public String id;

    public Person(String id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return Objects.equals(id, person.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

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;
    public int usedSize;

    public HashBuck2() {
        array = (Node<K,V>[])new Node[10];
    }

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

    }

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

性能分析

哈希表是一种高效的数据结构,它的性能主要取决于哈希函数的质量和桶的设计。

1. 冲突率可控:在实际使用中,冲突率是可控的,这意味着哈希表中发生冲突的频率相对较低。这是通过选择合适的哈希函数和适当的桶数来实现的。

2. 桶中链表长度是常数:虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的, 也就是每个桶中的链表的长度是一个常数,这是关键因素之一,确保哈希表的性能保持在常数时间水平。如果链表长度始终保持在常数,那么查找、插入和删除操作的平均时间复杂度都将是 O(1)。

综合以上两点,哈希表的插入、删除和查找操作在平均情况下具有常数时间复杂度,也就是 O(1)。这使得哈希表成为处理大量数据的一种有效选择,尤其在需要高效查找和插入元素的情况下。但是,空间复杂度却提高了,所以HashMap的空间浪费很大,是典型的浪费空间来换取时间的过程。

然而,需要注意的是,虽然平均情况下哈希表的性能是 O(1),但在最坏情况下,哈希表的性能可能会退化。最坏情况下的时间复杂度取决于哈希函数的设计和冲突处理策略。

例如,在极端情况下,所有的元素都被哈希到同一个桶中,导致链表长度非常长,最坏情况下的时间复杂度可能退化为 O(n),其中 n 是哈希表中的元素数量。

因此,在设计哈希表时,需要综合考虑哈希函数的质量、冲突处理策略以及桶的设计,以确保哈希表在大多数情况下都能保持 O(1) 的性能。同时,适时地进行扩容和重新哈希操作,以防止哈希表过度填充而导致性能下降。

哈希表和 Java 类集的关系

1. HashMap 和 HashSet:

  • HashMap 是 Java 中实现的基于哈希表的 Map 接口的实现类,它允许将键值对存储在哈希表中,并能快速地根据键查找值。键和值都可以是任意类型的对象。
  • HashSet 是 Java 中实现的基于哈希表的 Set 接口的实现类,它使用哈希表来存储唯一的元素,不允许重复的元素存在。HashSet 内部实际上是使用 HashMap 来实现的,HashSet 的元素被存储在 HashMap 的键的位置,而值则是一个固定的常量对象。

2. 哈希桶方式解决冲突:
在 Java 中,哈希表使用哈希桶方式来解决哈希冲突。每个桶(bucket)是一个存储元素的容器,哈希冲突会导致多个元素映射到同一个桶中。当发生哈希冲突时,新的元素会被添加到对应的桶中,并通过链表或红黑树等数据结构来管理相同哈希值的元素。

3. 转变为搜索树(红黑树):
当哈希表中某个桶中链表的长度达到一定阈值(默认为 8)时,Java 8 之后的版本会将这个链表转变为一棵搜索树(红黑树),以提高在大量冲突元素时的查找性能。这样,查找、插入和删除的时间复杂度将从 O(n) 降低为 O(log n),其中 n 是链表或搜索树中的元素数量。

4. 自定义类作为 HashMap 的 key 或 HashSet 的值:
如果要将自定义类作为 HashMap 的键(key)或 HashSet 的值(value),必须覆写该类的 hashCode() 和 equals() 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。这是因为哈希表在插入和查找元素时依赖于对象的 hashCode() 方法确定存储位置(计算哈希值),进行 key 的相等性比较是调用 key 的 equals 方法。

  • hashCode() 方法的覆写应该保证:当两个对象通过 equals() 方法比较返回 true 时,它们的 hashCode() 方法返回的值也必须相同。
  • equals() 方法的覆写应该定义对象相等的条件,即对于两个不同的对象,通过 equals() 方法比较应该返回 false;对于两个相等的对象,equals() 方法应该返回 true。

HashMap的源码解析 

 

先来看看是怎么分配内存的: 

 

 

那么话又说回来,如果构造方法的初始容量我们给一个15,容量就会是15吗? 

 

OJ练习

1、只出现一次的数字

力扣链接:136. 只出现一次的数字 - 力扣(LeetCode)

给你一个非空整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。

首先,要在非空整数数组 nums 中找出只出现一次的元素,并且满足线性时间复杂度(O(n))和常数额外空间的要求,可以使用位运算中的异或操作(XOR)。

而异或操作的特性是:对于两个相同的数异或操作结果为 0,对于任意数与 0 进行异或操作结果还是其本身。因此,如果我们将数组中所有的元素进行异或操作,最终得到的结果就是只出现一次的元素。

因为异或操作满足交换律和结合律,所以数组中出现两次的元素在异或操作时会相互抵消,最终留下的就是只出现一次的元素。

这个方法很巧妙,而且我们以前已经写过一遍了:

public class Solution {
    public int singleNumber(int[] nums) {
        int result = 0;
        for (int num : nums) {
            result ^= num;
        }
        return result;
    }
}

我来解释一下代码逻辑:

1. 我们首先将 result 初始化为 0,因为任何数与 0 异或操作都是其本身。
2. 然后我们遍历整个数组 nums,对每个元素进行异或操作,将结果保存在 result 中。
3. 最终,result 中的值就是只出现一次的元素。

由于该算法只进行一次遍历,并且使用常数额外空间,因此满足了题目要求的线性时间复杂度和常数额外空间的条件。

但是,如果我们要求这里你必须用集合来写一个方法呢?

如果要求使用集合的方式实现找出只出现一次的元素,我们可以利用集合的特性来辅助解决问题。我们可以使用HashSet来实现,步骤如下:

1. 我们创建一个HashSet来存储数组中的元素。
2. 然后遍历整个数组 `nums`,对于每个元素:
   - 如果该元素不在HashSet中,将其添加到HashSet中。
   - 如果该元素已经在HashSet中,说明它是出现两次的元素,将其从HashSet中移除(保证HashSet中只保存出现一次的元素)。
3. 最终,HashSet中剩下的元素就是只出现一次的元素,由于题目中已经说明只有一个元素出现一次,因此直接使用 set.iterator().next() 获取这个元素并返回。

import java.util.HashSet;

//1
    public int singleNumber(int[] nums) {
        Set<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;
    }


//2

public class Solution {
    public int singleNumber(int[] nums) {
        HashSet<Integer> set = new HashSet<>();
        for (int num : nums) {
            if (!set.add(num)) {
                set.remove(num);
            }
        }
        // HashSet中剩下的元素就是只出现一次的元素
        return set.iterator().next();
    }
}

请注意,虽然这种方法也可以找出只出现一次的元素,但它的时间复杂度为O(n),其中n是数组的长度,因为需要遍历整个数组。而且它使用了额外的HashSet来辅助计算,因此不满足题目要求的常数额外空间的条件。使用位运算的方法仍然是更优的解决方案。

 这里延申出了两道相似的OJ题:

存在重复元素:

力扣链接:217. 存在重复元素 - 力扣(LeetCode)

给你一个整数数组 nums 。如果任一值在数组中出现 至少两次 ,返回 true ;如果数组中每个元素互不相同,返回 false 。

要判断一个整数数组中是否存在重复元素,可以使用哈希表来记录数组中出现过的元素。遍历数组的过程中,如果当前元素已经存在于哈希表中,就说明数组中存在重复元素,可以直接返回 true。如果遍历完整个数组都没有找到重复元素,那么返回 false。

import java.util.HashSet;
import java.util.Set;

public class Solution {
    public boolean containsDuplicate(int[] nums) {
        // 创建一个哈希表,用于记录数组中出现过的元素
        Set<Integer> seen = new HashSet<>();

        // 遍历数组,判断是否存在重复元素
        for (int num : nums) {
            if (seen.contains(num)) {
                return true;
            }
            seen.add(num);
        }

        // 遍历完数组没有找到重复元素,返回false
        return false;
    }
}

这个代码的时间复杂度为 O(n),其中 n 是数组的长度。

存在重复元素 II:

力扣链接:219. 存在重复元素 II - 力扣(LeetCode)

给你一个整数数组 nums 和一个整数 k ,判断数组中是否存在两个不同的索引 i 和 j ,满足 nums[i] == nums[j] 且 abs(i - j) <= k 。如果存在,返回 true ;否则,返回 false 。

要判断数组中是否存在满足条件的两个不同索引 i 和 j,使得 nums[i] == nums[j] 且 abs(i - j) <= k,可以使用哈希表来记录数组中出现过的元素及其对应的索引。

在遍历数组的过程中,对于每个元素 nums[i],我们查找哈希表中是否已经存在该元素,如果存在,我们再判断当前索引 i 和哈希表中记录的索引是否满足 abs(i - j) <= k 的条件,如果满足,说明找到了满足条件的两个索引,直接返回 true。

如果遍历完整个数组都没有找到满足条件的两个索引,那么返回 false。

import java.util.HashMap;
import java.util.Map;

public class Solution {
    public boolean containsNearbyDuplicate(int[] nums, int k) {
        // 创建一个哈希表,用于记录数组中出现过的元素及其对应的索引
        Map<Integer, Integer> indexMap = new HashMap<>();

        // 遍历数组,判断是否存在满足条件的两个不同索引
        for (int i = 0; i < nums.length; i++) {
            int num = nums[i];
            // 查找哈希表中是否存在该元素
            if (indexMap.containsKey(num)) {
                // 如果存在,判断当前索引 i 和哈希表中记录的索引是否满足 abs(i - j) <= k 的条件
                int j = indexMap.get(num);
                if (Math.abs(i - j) <= k) {
                    return true;
                }
            }
            // 将当前元素和索引加入哈希表
            indexMap.put(num, i);
        }

        // 遍历完数组没有找到满足条件的两个索引,返回false
        return false;
    }
}

时间复杂度为 O(n),其中 n 是数组的长度。

2、复制带随机指针的链表

力扣链接:138. 复制带随机指针的链表 - 力扣(LeetCode)

给你一个长度为 n 的链表,每个节点包含一个额外增加的随机指针 random ,该指针可以指向链表中的任何节点或空节点。

构造这个链表的深拷贝。 深拷贝应该正好由 n 个全新节点组成,其中每个新节点的值都设为其对应的原节点的值。新节点的 next 指针和 random 指针也都应指向复制链表中的新节点,并使原链表和复制链表中的这些指针能够表示相同的链表状态。复制链表中的指针都不应指向原链表中的节点 。

例如,如果原链表中有 X 和 Y 两个节点,其中 X.random --> Y 。那么在复制链表中对应的两个节点 x 和 y ,同样有 x.random --> y 。

返回复制链表的头节点。

用一个由 n 个节点组成的链表来表示输入/输出中的链表。每个节点用1个 [val,random_index] 表示:

  • val:一个表示 Node.val 的整数。
  • random_index:随机指针指向的节点索引(范围从 0 到 n-1);如果不指向任何节点,则为  null 。

你的代码只接受原链表的头节点 head 作为传入参数。

要实现深拷贝一个包含随机指针的链表,我们需要遍历原链表并逐一创建新节点,同时保持原链表节点和新链表节点的对应关系。为了实现这一点,我们可以使用HashMap来存储原链表节点和对应的新链表节点的映射关系,步骤如下:

1. 创建一个HashMap,用于存储原链表节点和对应的新链表节点的映射关系。
2. 第一次遍历原链表,创建新节点,并将原链表节点和新链表节点的映射关系存储到HashMap中。
3. 第二次遍历原链表,更新新链表节点的 next 和 random  指针,根据HashMap中存储的映射关系找到对应的新链表节点,并将其赋给新链表节点的 next 和 random 指针。
4. 返回新链表的头节点。

import java.util.HashMap;

class Node {
    int val;
    Node next;
    Node random;

    public Node(int val) {
        this.val = val;
        this.next = null;
        this.random = null;
    }
}

public class Solution {
    public Node copyRandomList(Node head) {
        if (head == null) {
            return null;
        }

        HashMap<Node, Node> map = new HashMap<>();
        Node current = head;

        // 第一次遍历,创建新节点并存储映射关系
        while (current != null) {
            Node newNode = new Node(current.val);
            map.put(current, newNode);
            current = current.next;
        }

        // 第二次遍历,更新新链表节点的 next 和 random 指针
        current = head;
        while (current != null) {
            Node newNode = map.get(current);
            newNode.next = map.get(current.next);
            newNode.random = map.get(current.random);
            current = current.next;
        }
        // 返回复制链表的头节点
        return map.get(head);
    }
}

这样,我们就完成了对包含随机指针的链表的深拷贝,并且保持了原链表节点和新链表节点的对应关系。

在第一次遍历时,我们创建了新的节点 newNode,并将 current 和 newNode 的映射关系存储在 map 哈希表中,即 map.put(current, newNode)。这样,在第二次遍历时,我们可以通过 map.get(current) 来获取与当前节点 current 对应的新节点 newNode。

也就是说,map.get(current) 就是从哈希表 map 中获取与 current 对应的新节点 newNode。在第二次遍历时,我们通过这种方式来更新新链表节点的 next 和 random 指针。

至于可不可以用TreeSet?放节点的时候肯定会比较大小,又涉及到自定义类型,所以还得给比较器啥的。而且树的效率肯定比哈希表的效率低。当然,TreeSet的底层是TreeMap,HashSet的底层是HashMap。

那有没有不用集合实现的方法?

有一种不使用集合的方法来解决这个问题——我们可以通过两次遍历原链表来实现。

这种方法不使用额外的数据结构,只需要修改原链表,不需要使用哈希表或优先队列。

这种算法的步骤如下:

第一次遍历:在原链表的每个节点后面插入一个新节点,新节点的值与原节点相同。例如,对于链表 1 -> 2 -> 3,插入新节点后得到 1 -> 1 -> 2 -> 2 -> 3 -> 3。

第二次遍历:更新新节点的 random 指针。对于原链表中的每个节点,新节点的 random 指针可以通过原节点的 random 指针找到。例如,如果原链表中的节点 A 的 random 指向节点 B,那么对应的新节点 A' 的 random 指向节点 B'(A' 是 A 的新节点,B' 是 B 的新节点)。

第三次遍历:将链表拆分成原链表和新链表。原链表节点和新链表节点交替出现,将它们分别连接起来,得到新的深拷贝链表。

class Node {
    int val;
    Node next;
    Node random;

    public Node(int val) {
        this.val = val;
        this.next = null;
        this.random = null;
    }
}

public class Solution {
    public Node copyRandomList(Node head) {
        if (head == null) {
            return null;
        }

        // 第一次遍历:在原链表的每个节点后面插入一个新节点
        Node current = head;
        while (current != null) {
            Node newNode = new Node(current.val);
            newNode.next = current.next;
            current.next = newNode;
            current = newNode.next;
        }

        // 第二次遍历:更新新节点的 random 指针
        current = head;
        while (current != null) {
            Node newNode = current.next;
            if (current.random != null) {
                newNode.random = current.random.next;
            }
            current = newNode.next;
        }

        // 第三次遍历:将链表拆分成原链表和新链表
        Node dummy = new Node(0); // 用于保存新链表的头节点
        Node newCurrent = dummy;
        current = head;
        while (current != null) {
            newCurrent.next = current.next;
            current.next = current.next.next;
            current = current.next;
            newCurrent = newCurrent.next;
        }

        return dummy.next; // 返回新链表的头节点
    }
}

3、宝石与石头

力扣链接:771. 宝石与石头 - 力扣(LeetCode)

 给你一个字符串 jewels 代表石头中宝石的类型,另有一个字符串 stones 代表你拥有的石头。 stones 中每个字符代表了一种你拥有的石头的类型,你想知道你拥有的石头中有多少是宝石。
字母区分大小写,因此 "a" 和 "A" 是不同类型的石头。

要计算你拥有的石头中有多少是宝石,首先我们可以使用简单的遍历方法。对于每个石头,检查它是否是宝石类型,如果是则增加宝石计数。

1. 创建一个变量 count,用于记录宝石的数量,初始化为 0。
2. 遍历 stones 字符串中的每个字符:
   - 如果该字符在 jewels 字符串中出现,则它是宝石,将 count 增加 1。
3. 完成遍历后,count 就是你拥有的石头中宝石的数量。

public class Solution {
    public int numJewelsInStones(String jewels, String stones) {
        int count = 0;
        for (char stone : stones.toCharArray()) {
            if (jewels.indexOf(stone) != -1) {
                count++;
            }
        }
        return count;
    }
}

这样,我们就能够快速计算出你拥有的石头中有多少是宝石。注意,这个方法是区分大小写的,即宝石的类型要与 jewels 字符串完全匹配。

但是如果要用集合来解决的话:

    public int numJewelsInStones(String jewels, String stones) {
        Set<Character> set = new HashSet<>();
        for (char ch : jewels.toCharArray()) {
            set.add(ch);
        }

        int count = 0;

        for (char ch : stones.toCharArray()) {
            if (set.contains(ch)) {
                count++;
            }
        }

        return count;
    }

4、坏键盘打字

牛客网链接:旧键盘 (20)__牛客网 (nowcoder.com)

旧键盘上坏了几个键,于是在敲一段文字的时候,对应的字符就不会出现。现在给出应该输入的一段文字、以及实际被输入的文字,请你列出肯定坏掉的那些键。 

输入描述:
输入在2行中分别给出应该输入的文字、以及实际被输入的文字。每段文字是不超过80个字符的串,由字母A-Z(包括大、小写)、数字0-9、
以及下划线“_”(代表空格)组成。题目保证2个字符串均非空。

输出描述:
按照发现顺序,在一行中输出坏掉的键。其中英文字母只输出大写,每个坏键只输出一次。题目保证至少有1个坏键。

为了找出坏掉的键,我们可以逐个比较两个字符串中的字符,找到不一致的字符即为坏掉的键。

算法步骤如下:

1. 创建一个HashSet或StringBuilder用于存储坏掉的键。HashSet是一个无序且不包含重复元素的集合。
2. 逐个比较两个字符串中的字符,如果对应位置的字符不一致,说明这个键坏了,将其加入HashSet或StringBuilder中。注意,为了保证英文字母只输出大写,我们在添加坏键时将其转换为大写形式。如果字符一致,则说明键没有坏掉,继续比较下一个字符。
3. 输出HashSet或StringBuilder中存储的坏掉的键。
 

import java.util.HashSet;

public class Solution {
    public static void main(String[] args) {
        // 输入数据
        String expected = "The quick brown fox";
        String actual = "The qucik brovn fox";

        HashSet<Character> badKeys = findBadKeys(expected, actual);
        for (char key : badKeys) {
            System.out.print(key + " ");
        }
    }

    public static HashSet<Character> findBadKeys(String expected, String actual) {
        HashSet<Character> badKeys = new HashSet<>();
        int len = Math.min(expected.length(), actual.length());

        for (int i = 0; i < len; i++) {
            char expectedChar = expected.charAt(i);
            char actualChar = actual.charAt(i);
            if (expectedChar != actualChar) {
                char badKey = Character.toUpperCase(expectedChar); // 保证英文字母只输出大写
                badKeys.add(badKey);
            }
        }

        // 处理可能剩余的字符
        for (int i = len; i < expected.length(); i++) {
            char expectedChar = expected.charAt(i);
            char badKey = Character.toUpperCase(expectedChar);
            badKeys.add(badKey);
        }

        return badKeys;
    }
}

以上代码输出为:"I N",表示键'I'和键'N'是坏掉的键。注意,输出的坏掉的键可能是无序的,因为HashSet是无序的集合。

还有另外一个不大一样的思路:

    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        // 注意 hasNext 和 hasNextLine 的区别
        while (in.hasNextLine()) { // 注意 while 处理多个 case
            String s1 = in.nextLine();
            String s2 = in.nextLine();
            func(s1, s2);
        }
    }

    public static void func(String str1, String strAct) {
        Set<Character> set1 = new HashSet<>();
        for (char ch : strAct.toUpperCase().toCharArray()) {
            set1.add(ch);
        }

        Set<Character> set2 = new HashSet<>();

        for (char ch : str1.toUpperCase().toCharArray()) {
            //避免重复
            if (!set1.contains(ch) && !set2.contains(ch)) {
                System.out.print(ch);
                set2.add(ch);
            }
        }
    }

5、前K个高频单词

力扣链接:692. 前K个高频单词 - 力扣(LeetCode)

给定一个单词列表 words 和一个整数 k ,返回前 k 个出现次数最多的单词。
返回的答案应该按单词出现频率由高到低排序。如果不同的单词有相同出现频率, 按字典顺序排序。

我们先来写一个简单的实现方法:

    public static void main(String[] args) {
        String[] words = {"this", "man", "this", "woman", "happy", "day", "happy"};
        func(words);
    }
  
  public static void func(String[] words) {
        Map<String, Integer> map = new HashMap<>();
        for (String word : words) {
            if (map.get(word) == null) {
                map.put(word, 1);
            } else {
                int val = map.get(word);
                map.put(word, val + 1);
            }
        }

        System.out.println(map);
    }

现在我们回到这个题:

要找到前 k 个出现次数最多的单词,我们使用哈希表和优先队列(PriorityQueue)来实现。

算法大致步骤如下:

1. 首先,创建一个哈希表用于记录每个单词出现的次数。
2. 遍历单词列表 words,统计每个单词的出现次数,存储在刚刚创建的哈希表中。
3. 然后,我们创建一个优先队列,小根堆 minHeap,用于按照单词出现次数的升序进行排序。在优先队列中,我们可以自定义比较器来实现按照出现次数降序排列,如果出现次数相同,则按字典顺序排列。
4. 将哈希表中的单词和对应的出现次数加入 minHeap 中,因为我们使用了自定义比较器,所以在优先队列中,单词会按照出现次数升序排列,如果出现次数相同,则按字典顺序排列。
5. 逆置一遍集合,再取出前 k 个元素,即为出现次数最多的前 k 个单词。

    public List<String> topKFrequent(String[] words, int k) {


        //1、统计每个单词出现的次数
        Map<String, Integer> map = new HashMap<>();
        for (String word : words) {
            if (map.get(word) == null) {
                map.put(word, 1);
            } else {
                int val = map.get(word);
                map.put(word, val + 1);
            }
        }


        //2、高频,建立小根堆,指定比较的方式
        PriorityQueue<Map.Entry<String, Integer>> minHeap = new PriorityQueue<>
                (new Comparator<Map.Entry<String, Integer>>() {
                    @Override
                    public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {

                        if (o1.getValue().compareTo(o2.getValue()) == 0) {
                            //按照字母顺序建立大根堆
                            return o2.getKey().compareTo(o1.getKey());
                        }

                        return o1.getValue() - o2.getValue();
                    }
                });

        //3、遍历map 调整优先级队列

        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            if (minHeap.size() < k) {
                minHeap.offer(entry);
            } else {
                Map.Entry<String, Integer> top = minHeap.peek();
                //如果当前频率相同
                if (top.getValue().compareTo(entry.getValue()) == 0) {
                    // 字母顺序小的进来
                    if (top.getKey().compareTo(entry.getKey()) > 0) {
                        //出队
                        minHeap.poll();
                        minHeap.offer(entry);
                    }
                } else {
                    if (top.getValue().compareTo(entry.getValue()) < 0) {
                        minHeap.poll();
                        minHeap.offer(entry);
                    }
                }
            }
        }

        List<String> ret = new ArrayList<>();
        for (int i = 0; i < k; i++) {
            Map.Entry<String, Integer> top = minHeap.poll();
            ret.add(top.getKey());
        }

        Collections.reverse(ret);

        return ret;

    }

换一种写法,用大根堆: 

算法大致步骤如下:

1. 首先,创建一个哈希表 wordFrequency,用于记录每个单词出现的次数。
2. 遍历单词列表 words,统计每个单词的出现次数,存储在 wordFrequency 中。
3. 然后,我们创建一个优先队列 maxHeap,用于按照单词出现次数的降序进行排序。在优先队列中,我们可以自定义比较器来实现按照出现次数降序排列,如果出现次数相同,则按字典顺序排列。
4. 将 wordFrequency 中的单词和对应的出现次数加入 maxHeap 中,因为我们使用了自定义比较器,所以在优先队列中,单词会按照出现次数降序排列,如果出现次数相同,则按字典顺序排列。
5. 从 maxHeap 中取出前 k 个元素,即为出现次数最多的前 k 个单词。

import java.util.*;

public class Solution {
    public List<String> topKFrequentWords(String[] words, int k) {
        // 创建一个哈希表,用于记录单词出现次数
        Map<String, Integer> wordFrequency = new HashMap<>();
        for (String word : words) {
            wordFrequency.put(word, wordFrequency.getOrDefault(word, 0) + 1);
        }

        // 创建优先队列,按照出现次数和字典顺序进行降序排序
        PriorityQueue<String> maxHeap = new PriorityQueue<>(
                (a, b) -> wordFrequency.get(a).equals(wordFrequency.get(b)) ?
                        a.compareTo(b) : wordFrequency.get(b) - wordFrequency.get(a));

        // 将单词加入优先队列
        for (String word : wordFrequency.keySet()) {
            maxHeap.offer(word);
        }

        // 取出前 k 个出现次数最多的单词
        List<String> result = new ArrayList<>();
        for (int i = 0; i < k; i++) {
            result.add(maxHeap.poll());
        }

        return result;
    }
}

这样,我们就得到了前 k 个出现次数最多的单词,并且按照题目要求进行了排序。

6、二叉搜索树与双向链表

 牛客链接:二叉搜索树与双向链表_牛客题霸_牛客网 (nowcoder.com)

输入一棵二叉搜索树,将该二叉搜索树转换成一个排序的双向链表。如下图所示:

数据范围:输入二叉树的节点数 0 ≤ n ≤ 1000,二叉树中每个节点的值 0 ≤ val ≤1 000
要求:空间复杂度 O(1)(即在原树上操作),时间复杂度 O(n)

注意:
1.要求不能创建任何新的结点,只能调整树中结点指针的指向。当转化完成以后,树中节点的左指针需要指向前驱,树中节点的右指针需要指向后继
2.返回链表中的第一个节点的指针
3.函数返回的TreeNode,有左右指针,其实可以看成一个双向链表的数据结构
4.你不用输出双向链表,程序会根据你的返回值自动打印输出

输入描述:
二叉树的根节点

返回值描述:
双向链表的其中一个头节点。

为了将二叉搜索树转换成排序的双向链表,并且在原树上操作,我们可以使用中序遍历的方式来实现。在中序遍历的过程中,我们将当前节点的右指针指向后继节点,后继节点的左指针指向当前节点。

class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;

    public TreeNode(int val) {
        this.val = val;
    }
}

public class Solution {
    private TreeNode prev = null; // 上一个访问过的节点,用于构建双向链表的链接

    public TreeNode Convert(TreeNode pRootOfTree) {
        if (pRootOfTree == null) {
            return null;
        }

        // 将当前子树转换为排序的双向链表
        convertTreeToLinkedList(pRootOfTree);

        // 找到双向链表的头节点并返回
        TreeNode head = pRootOfTree;
        while (head.left != null) {
            head = head.left;
        }

        return head;
    }

    // 中序遍历二叉搜索树,将其转换为排序的双向链表
    private void convertTreeToLinkedList(TreeNode root) {
        if (root == null) {
            return;
        }

        // 处理左子树
        convertTreeToLinkedList(root.left);

        // 连接当前节点与前一个节点(后继节点的左指针指向前一个节点)
        root.left = prev;
        if (prev != null) {
            prev.right = root;
        }
        prev = root;

        // 处理右子树
        convertTreeToLinkedList(root.right);
    }
}

上述代码中,我们通过递归实现了中序遍历,并在遍历过程中对节点进行了链接操作。最后返回双向链表的头节点,即中序遍历的第一个节点。

该算法的时间复杂度是 O(n),其中 n 是二叉搜索树的节点数。空间复杂度是 O(1),因为我们只使用了有限的额外空间来保存几个辅助变量。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值