算法通关村第五关——队列和Hash的特征(青铜)

算法通关村第五关——队列和Hash的特征(青铜)

    • 1. Hash基础知识
      • 1.1 Hash的概念和基本特征
        • 1.1.1 什么是哈希?
        • 1.1.2 Hash的特性
        • 1.1.3 Hash的用途
      • 1.2 碰撞的处理方法
        • 1.2.1 开放定址法
        • 1.2.2 链地址法
    • 2. 队列基础知识
      • 2.1 队列的概念和基本特征
      • 2.2 实现队列
        • 2.2.1 基于链表实现队列
        • 2.2.2 基于数组实现队列

1. Hash基础知识

1.1 Hash的概念和基本特征

1.1.1 什么是哈希?

hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,
该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间。

它其实就是一个算法,最简单的算法就是加减乘除,比方,我设计个数字算法,输入+7=输出,比如我输入1,输出为8;输入2,输出为9。

哈希算法不过是一个更为复杂的运算,它的输入可以是字符串,可以是数据,可以是任何文件,经过哈希运算后,变成一个固定长度的输出,
该输出就是哈希值。但是哈希算法有一个很大的特点,就是你不能从结果推算出输入,所以又称为不可逆的算法

下面使用一个简答的例子:

print(hash('我爱你'))
# 输出:3471388576844338423
print(hash('我也爱你'))
# 输出:5000768010434506639

如上所示,输入“我爱你”三个字,经过哈希运算后,会得到一个随机数列,而且不管你的输入文件多大,最后得到的结果都是这么一个固定长度的数列,即使你输入的是一部电影,输出也是这么大。而且通过数列不能推导出输入。

1.1.2 Hash的特性

不可逆:在具备编码功能的同时,哈希算法也作为一种加密算法存在。即,你无法通过分析哈希值计算出源文件的样子,换句话说:你不可能通过观察香肠的纹理推测出猪原来的样子。

计算极快:20G高清电影和一个5K文本文件复杂度相同,计算量都极小,可以在0.1秒内得出结果。也就是说,不管猪有多肥,骨头多硬,做成香肠都只要眨眨眼的时间。

1.1.3 Hash的用途

哈希算法的不可逆特性使其在以下领域使用广泛

  1. 密码,我们日常使用的各种电子密码本质上都是基于hash的,你不用担心支付宝的工作人员会把你的密码泄漏给第三方,因为你的登录密码是先经过 hash+各种复杂算法得出密文后 再存进支付宝的数据库里的

  2. 文件完整性校验,通过对文件进行hash,得出一段hash值 ,这样文件内容以后被修改了,hash值就会变。 MD5 Hash算法的”数字指纹”特性,使它成为应用最广泛的一种文件完整性校验和(Checksum)算法,不少Unix系统有提供计算md5 checksum的命令。

  3. 数字签名,数字签名技术是将摘要信息用发送者的私钥加密,与原文一起传送给接收者。接收者只有用发送者的公钥才能解密被加密的摘要信息,然后用HASH函数对收到的原文产生一个摘要信息,与解密的摘要信息对比。如果相同,则说明收到的信息是完整的,在传输过程中没有被修改,否则说明信息被修改过,因此数字签名能够验证信息的完整性。

1.2 碰撞的处理方法

在上面的例子中,我们发现有些在Hash中很多位置可能要存两个甚至多个元素,很明显单纯的数组是不行的,这种两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞,那该怎么解决呢?

常见的方法有: 开放定址法Java里的Threadlocal、链地址法Java里的ConcurrentHashMap、再哈希法(布隆过滤器)、建立公共溢出区。后两种用的比较少,我们重点看前两个。

1.2.1 开放定址法

开放地址法是什么?

开放定址法(Open Addressing)是一种解决哈希冲突的方法,在开放定址法中,所有的元素都存储在一个数组中。当发生哈希冲突时,即多个元素需要存储在同一个位置时,开放定址法会通过一定的探测方法查找下一个可用的位置,直到找到一个空闲的位置来存储冲突的元素。开放定址法的主要思想是通过线性探测、二次探测、双重散列等方法来寻找下一个可用的位置。

  • 线性探测:如果发生冲突,就检查下一个槽位,直到找到一个空闲槽位或者遍历完整个哈希表。
  • 二次探测:如果发生冲突,就以二次方的增长来计算下一个槽位的位置,直到找到一个空闲槽位或者遍历完整个哈希表。
  • 双重散列:如果发生冲突,就使用另外一个散列函数计算下一个槽位的位置,直到找到一个空闲槽位或者遍历完整个哈希表。

在查找或删除操作时,也需要按照同样的规则来查找目标元素。

使用场景

开放定址法的使用场景包括但不限于以下情况:

  1. 需要高效地存储和查找数据,在这种情况下使用哈希表可以提供常数时间的平均查找复杂度。
  2. 数据量较小、分布较均匀的情况下,开放定址法可以更好地利用内存空间。

优点:

  1. 实现简单,不需要额外的链表或其他数据结构来处理哈希冲突。
  2. 内存访问局部性好,对缓存友好,可以提高访问速度。

缺点:

  1. 当哈希表的装载因子较高时,容易导致聚集现象,即冲突较多的位置会被频繁使用,而其他位置则很少被使用,影响性能。
  2. 删除操作相对复杂,需要标记删除的元素并保持哈希表的完整性。

总体上说,开放定址法是一种高效的解决哈希冲突的方法,适用于数据量较小、分布较均匀的场景。但在面对大规模数据和高负载因子时,可能会导致性能下降。开放定址法的优点是不需要额外的内存来存储链表或指针,可以充分利用哈希表的空间。但是当槽位的装填因子过高时,会导致冲突的概率增加,进而影响性能。因此,在设计哈希表时需要合理选择哈希函数和解决冲突的方法,并考虑调整哈希表的大小来平衡性能和空间的消耗。

1.2.2 链地址法

链地址法是什么?

链地址法(Chaining)是一种解决哈希冲突的方法,用于实现哈希表。它的主要思想是将哈希桶中的每个位置都设置为一个链表或其他数据结构,当发生哈希冲突时,将冲突的元素插入到对应位置的链表中。在查找或删除操作时,首先计算元素的哈希值,并根据哈希值找到对应的槽。然后遍历槽对应的链表,查找或删除目标元素。

具体操作流程如下:

  1. 创建一个包含固定大小的哈希桶的数组。
  2. 当要插入一个元素时,首先计算元素的哈希值,并根据哈希值找到对应的桶。
  3. 如果桶为空,则直接将元素插入桶中。
  4. 如果桶不为空,表示发生了哈希冲突,此时将元素插入到桶中的链表末尾。
  5. 当要查找一个元素时,同样计算元素的哈希值,并根据哈希值找到对应的桶。
  6. 在对应的桶中搜索元素,如果在链表中找到了元素则返回,否则表示元素不存在。

使用场景

  1. 数据分布不均匀,可能存在较多的哈希冲突的情况。
  2. 需要高效地存储和查找数据,并且对内存空间的利用要求较低。
  3. 需要支持动态变化的数据集,不受固定大小的哈希桶限制。

优点:

  1. 解决哈希冲突的方式简单直观,只需要在桶中使用链表或其他数据结构存储冲突的元素。
  2. 对于动态变化的数据集更加灵活,不受固定大小的哈希桶限制。

缺点:

  1. 内存访问局部性较差,不如开放定址法对缓存友好,可能会导致较高的访问时间。
  2. 需要额外的链表结构来处理冲突,增加了空间开销。
  3. 当哈希冲突较多时,可能会导致长链表的出现,从而降低查找效率。

链地址法的优点是简单易实现,对于解决哈希冲突效果较好。但是需要额外的空间来存储链表节点,而且链表的查找、插入和删除操作的时间复杂度与链表的长度成正比,可能会导致性能下降。因此,在设计哈希表时需要合理选择哈希函数和解决冲突的方法,以平衡时间和空间的消耗。

2. 队列基础知识

2.1 队列的概念和基本特征

队列的特点是节点的排队次序和出队次序按入队时间先后确定,即先入队者先出队,后入队者后出队,即我们常说的FIFO(first in first out)先进先出。队列实现方式也有两种形式,基于数组和基于链表。对于基于链表,因为链表的长度是随时都可以变的,实现起来比较简单。如果是基于数组的,会有点麻烦.

2.2 实现队列

2.2.1 基于链表实现队列

基于链表实现队列还是比较好处理的,只要在尾部后插入元素,在front删除元素就行了。

public class LinkQueue {
    private Node front;
    private Node rear;
    private int size;

    public LinkQueue() {
        this.front = new Node(0);
        this.rear = new Node(0);
    }

    /**
     * 入队
     */
    public void push(int value) {
        Node newNode = new Node(value);
        Node temp = front;
        while (temp.next != null) {
            temp = temp.next;
        }
        temp.next = newNode;
        rear = newNode;
        size++;
    }

    /**
     * 出队
     */
    public int pull() {
        if (front.next == null) {
            System.out.println("队列已空");
        }
        Node firstNode = front.next;
        front.next = firstNode.next;
        size--;
        return firstNode.data;
    }

    /**
     * 遍历队列
     */
    public void traverse() {
        Node temp = front.next;
        while (temp != null) {
            System.out.print(temp.data + "\t");
            temp = temp.next;
        }
    }

    static class Node {
        public int data;
        public Node next;

        public Node(int data) {
            this.data = data;
        }
    }

    //测试main方法
    public static void main(String[] args) {
        LinkQueue linkQueue = new LinkQueue();
        linkQueue.push(1);
        linkQueue.push(2);
        linkQueue.push(3);
        System.out.println("第一个出队的元素为:" + linkQueue.pull());
        System.out.println("队列中的元素为:");
        linkQueue.traverse();
    }
}

2.2.2 基于数组实现队列

基于数组实现队列的算法需要维护两个指针,一个指向队列的头部(front),一个指向队列的尾部(rear)。初始时,front 和 rear 都设为 0。

以下是基于数组实现队列的算法步骤:

  1. 初始化队列:
    • 创建一个固定大小的数组,用于存储队列元素。
    • 初始化 front 和 rear 指针为 0。
  2. 入队操作(push):
    • 检查队列是否已满(即 rear 指针是否达到数组的末尾)。
    • 如果队列已满,则无法添加新元素。
    • 如果队列未满,则将要入队的元素添加到 rear 指针所在位置,并将 rear 指针后移一位。
  3. 出队操作(poll):
    • 检查队列是否为空(即 front 指针是否等于 rear 指针)。
    • 如果队列为空,则无法执行出队操作。
    • 如果队列不为空,则返回 front 指针所在位置的元素,并将 front 指针后移一位。
  4. 判空操作(isEmpty):
    • 检查队列是否为空(即 front 指针是否等于 rear 指针)。
    • 如果 front 等于 rear,则队列为空,返回 true;否则返回 false。
  5. 获取队头元素(getFront):
    • 返回队列头部(front 指针所指位置)的元素值。

基于数组实现队列的算法的时间复杂度如下:

  • 入队操作的时间复杂度为 O(1)。
  • 出队操作的时间复杂度为 O(1)。
  • 判空操作的时间复杂度为 O(1)。
  • 获取队头元素的时间复杂度为 O(1)。

需要注意的是,使用基于数组实现的队列时,当队列满时,无法添加新元素,即使队列中有空闲位置。这种情况称为“数组循环(Array Circular)”,可以通过循环利用数组中的空闲位置来解决此问题。

public class ArrayQueue {
    private int[] queue;  // 用于存储队列元素的数组
    private int front;  // 队头指针
    private int rear;  // 队尾指针

    public ArrayQueue() {
        queue = new int[10];  // 初始化数组大小为10
        front = -1;  // 初始化队头指针为-1
        rear = -1;  // 初始化队尾指针为-1
    }

    /**
     * 入队
     */
    public void push(int value) {
        if (isFull()) {
            System.out.println("队列已满,无法添加新元素!");
            return;
        }
        if (isEmpty()) {
            front = 0;  // 如果队列为空,则将队头指针设置为0
        }
        rear++;
        queue[rear] = value;
    }

    /**
     * 出队
     */
    public int pull() {
        if (isEmpty()) {
            throw new NoSuchElementException("队列为空!");
        }
        int element = queue[front];
        if (front == rear) {
            front = -1;  // 如果出队后队列为空,将队头指针和队尾指针重置为-1
            rear = -1;
        } else {
            front++;
        }
        return element;
    }

    /**
     * 判空操作
     */
    public boolean isEmpty() {
        return front == -1;
    }

    /**
     * 判满操作
     */
    public boolean isFull() {
        return rear == queue.length - 1;
    }

    /**
     * 获取队头元素
     */
    public int getFront() {
        if (isEmpty()) {
            throw new NoSuchElementException("队列为空!");
        }
        return queue[front];
    }

    //测试main方法
    public static void main(String[] args) {
        ArrayQueue arrayQueue = new ArrayQueue();
        arrayQueue.push(1);
        arrayQueue.push(2);
        arrayQueue.push(3);
        System.out.println("第一个出队的元素为:" + arrayQueue.pull());
        System.out.println("队列中的元素为:");
        while (!arrayQueue.isEmpty()) {
            System.out.print(arrayQueue.pull() + "\t");
        }
    }
}

使用示例:

ArrayQueue queue = new ArrayQueue(5);
queue.push(2);
queue.push(4);
queue.push(6);
System.out.println(queue.poll());  // 输出:2
System.out.println(queue.getFront());  // 输出:4
System.out.println(queue.isEmpty());  // 输出:false
System.out.println(queue.isFull());  // 输出:false

这样就over啦~

你可能感兴趣的:(数据结构,算法,算法,哈希算法,链表,笔记,java,数据结构)