Swift 数据结构与算法(29)哈希表 + Leetcode242. 有效的字母异位词(频率计数)

133 阅读8分钟

Swift 数据结构与算法( ) + Leetcode

概念

哈希表

  • 哈希表(或称为字典、映射)是数据结构中非常重要的一个部分。
  • 在iOS开发中,NSDictionaryNSHashTable,以及Swift中的 Dictionary 都是基于哈希表实现的。
  • 它在处理需要快速查找、插入和删除操作的数据集时非常有效。
  • 哈希表常用于缓存、存储键值对等场景。

哈希表简单实现

我们先考虑最简单的情况,仅用一个数组来实现哈希表。在哈希表中,我们将数组中的每个空位称为「桶 Bucket」,每个桶可存储一个键值对。因此,查询操作就是找到 key 对应的桶,并在桶中获取 value 。

那么,如何基于 key 来定位对应的桶呢?这是通过「哈希函数 Hash Function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 key ,输出空间是所有桶(数组索引)。换句话说,输入一个 key ,我们可以通过哈希函数得到该 key 对应的键值对在数组中的存储位置

输入一个 key ,哈希函数的计算过程分为两步:

  1. 通过某种哈希算法 hash() 计算得到哈希值。
  2. 将哈希值对桶数量(数组长度)capacity 取模,从而获取该 key 对应的数组索引 index 。
index = hash(key) % capacity

随后,我们就可以利用 index 在哈希表中访问对应的桶,从而获取 value 。

设数组长度 capacity = 100 、哈希算法 hash(key) = key ,易得哈希函数为 key % 100 。下图以 key 学号和 value 姓名为例,展示了哈希函数的工作原理。

哈希函数工作原理

哈希冲突与扩容

本质上看,哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,理论上一定存在“多个输入对应相同输出”的情况

对于上述示例中的哈希函数,当输入的 key 后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,我们得到:

12836 % 100 = 36
20336 % 100 = 36

如下图所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为「哈希冲突 Hash Collision」。

哈希冲突示例

Fig. 哈希冲突示例

越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以通过扩容哈希表来减少哈希冲突。如下图所示,扩容前键值对 (136, A) 和 (236, D) 发生冲突,扩容后冲突消失。

类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。

哈希算法的目标

为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点:

  • 确定性:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
  • 效率高:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
  • 均匀分布:哈希算法应使得键值对平均分布在哈希表中。分布越平均,哈希冲突的概率就越低。

实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。举两个例子:

  • 密码存储:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。
  • 数据完整性检查:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整的。

对于密码学的相关应用,哈希算法需要满足更高的安全标准,以防止从哈希值推导出原始密码等逆向工程,包括:

  • 抗碰撞性:应当极其困难找到两个不同的输入,使得它们的哈希值相同。
  • 雪崩效应:输入的微小变化应当导致输出的显著且不可预测的变化。

请注意, “均匀分布”与“抗碰撞性”是两个独立的概念,满足均匀分布不一定满足抗碰撞性。例如,在随机输入 key 下,哈希函数 key % 100 可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的 key 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 key ,从而破解密码。

哈希算法的设计

哈希算法的设计是一个复杂且需要考虑许多因素的问题。然而对于简单场景,我们也能设计一些简单的哈希算法。以字符串哈希为例:

  • 加法哈希:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
  • 乘法哈希:利用了乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
  • 异或哈希:将输入数据的每个元素通过异或操作累积到一个哈希值中。
  • 旋转哈希:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。

使用场景与应用

核心概念:

这题的核心概念是频率计数。当我们想比较两个集合(在这里是字符串,但可以是任何集合)的元素及其出现的次数时,频率计数是一个非常有用的技巧。它使用哈希表(字典)来存储每个元素的出现次数。

实际应用场景:

  1. 文档相似度检查:当比较两份文档是否相似时,可以对每份文档中的词汇进行频率计数,并使用某种算法(例如余弦相似度)来比较它们。

    技术点:频率计数,余弦相似度

  2. 数据分析:在数据分析中,经常需要计算各种项目的频率。例如,统计一篇文章中每个单词的出现次数,或统计用户对每种产品的点击次数。

    技术点:频率计数,哈希表

iOS app 开发中的实际应用:

  1. 拼写检查器:在iOS应用中,拼写检查器可以用来比较用户输入的词汇与字典中的词汇,看看它们是否相似,从而提供拼写建议。

    技术应用:使用频率计数来比较用户输入的单词与字典中的单词,找出可能的匹配项。

  2. 推荐系统:在内容推荐应用中,可以根据用户的浏览、搜索和购买历史对内容进行频率计数,从而为用户提供个性化的推荐。

    技术应用:利用频率计数和哈希表,记录用户与各种内容的交互,然后使用这些数据来生成推荐。

  3. 搜索自动补全:在搜索框中,当用户开始输入时,应用可以使用频率计数来快速找出与用户输入最匹配的建议。

    技术应用:通过对过去的搜索查询进行频率计数,应用可以预测用户可能会输入的内容,并提供自动完成建议。

错误与反思

题目

242. 有效的字母异位词

思路

给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。

注意: 若 s 和 t **中每个字符出现的次数都相同,则称 s 和 t **互为字母异位词。

 

示例 1:

输入: s = "anagram", t = "nagaram"
输出: true

示例 2:

输入: s = "rat", t = "car"
输出: false

 

提示:

  • 1 <= s.length, t.length <= 5 * 104
  • s 和 t 仅包含小写字母
class Solution {
    func isAnagram(_ s: String, _ t: String) -> Bool {

    }
}

解题思路🙋🏻‍ ♀️

边界思考🤔

代码

第一遍

class Solution {
    func isAnagram(_ s: String, _ t: String) -> Bool {
        
        //   先进行判断特殊条件
        if s.count <= 0 || t.count <= 0 {
           return false
        }
        
        // 基础判断
        if s.count != t.count  {
           return false
        }
        
        
        var charCount = [Character:Int]()
        
        for char in s {
            if let count = charCount[char] {
                charCount[char] = count + 1
            }else {
                charCount[char] = 1
            }
        }
        
        for charT in t {
            if let count = charCount[charT] {
                let newCount = count - 1
                if newCount <= 0 {
                    charCount.removeValue(forKey: charT)
                } else {
                    charCount[charT] = newCount
                }
            }else {
                return false
            }
        }
        
         
        return charCount.count > 0 ? false : true
    }
}

代码优化

  1. 特殊条件判断

    if s.count <= 0 || t.count <= 0 {
        return false
    }
    

    这里的条件不是特别合适。首先,字符串的长度 count 不可能为负。其次,如果两个字符串都是空字符串,按照字母异位词的定义,它们应该是字母异位词。所以这个条件应该改为:

    if s.isEmpty && t.isEmpty {
        return true
    }
    
  2. 字符计数:在累加字符出现的次数时,你可以利用Swift的 default 语法来简化代码:

    for char in s {
        charCount[char, default: 0] += 1
    }
    
  3. 返回结果判断

    return charCount.count > 0 ? false : true
    

    这个判断是正确的,但它可以被简化为:

    return charCount.isEmpty
    

总结: 代码的基本逻辑是正确的,但在处理特殊情况和返回结果时存在一些不必要的复杂性。在编写代码时,建议多思考边界条件,并充分利用Swift的语言特性来简化代码。 优化后

**class** Solution {

    **func** isAnagram(_ s: String, _ t: String) -> Bool {

        

        //   先进行判断特殊条件

        **if** s.isEmpty && t.isEmpty {

           **return** **true**

        }

        

        // 基础判断

        **if** s.count != t.count  {

           **return** **false**

        }

        

        

        **var** charCount = [Character:Int]()

        

        **for** char **in** s {

            charCount[char,**default**: 0] += 1

        }

        

        **for** charT **in** t {

            **guard** **let** count = charCount[charT], count > 0 **else** { **return** **false** }

            charCount[charT]! -= 1

        }

        

        // 检查是否所有字符的计数都为0

         **for** count **in** charCount.values {

             **if** count != 0 {

                 **return** **false**

             }

         }

        

        **return** **true**

    }

}

时空复杂度分析

O(n)