使用 C# 字典(Dictionary)提高代码性能:LeetCode 示例解析

273 阅读12分钟

在优化 C# 代码时,选择合适的数据结构至关重要。Dictionary 是最强大且常用的结构之一。本文将探讨如何巧妙地使用 Dictionary、它的优势,以及来自 LeetCode 的示例,展示它如何显著提高运行时性能,尤其是在面试场合。

什么是字典(Dictionary)?

C# 中的 Dictionary 是一个键值对的集合。字典中的每个键都是唯一的,并且映射到一个特定的值。内部而言,Dictionary 使用哈希表,这使得它在查找、插入和删除操作上具有平均时间复杂度 O(1)。这与列表或数组形成鲜明对比,在这些结构中,这些操作可能需要 O(n) 的时间,尤其是对于较大的数据集。


为什么选择字典(Dictionary)?

Dictionary 的主要优势在于它在基于键的查找中的性能。以下是一些关键场景,Dictionary 提供显著好处,以及你应该考虑使用它的情况:

  1. 快速查找
    如果你需要根据唯一键频繁检索值,比如通过 ID 查找用户,Dictionary 是理想选择。它在查找操作上提供平均 O(1) 的时间复杂度,这比在列表中搜索要快得多。

  2. 高效的插入和删除
    在需要频繁添加或删除元素的场景中,使用 Dictionary 比列表更快,因为列表可能需要移动元素或查找索引。Dictionary 允许高效的插入和删除,而无需重新索引。

  3. 键值映射
    如果你的数据自然适合键值对结构(例如,将用户名映射到电子邮件地址或产品 ID 映射到产品详细信息),Dictionary 是首选的数据结构。它让你可以使用唯一键高效地存储和检索值。

  4. 避免重复
    由于 Dictionary 中的键必须是唯一的,因此在你想确保不存储重复键时,它非常有用。例如,如果你想确保用户 ID 或产品代码是唯一的,Dictionary 可以直接处理这个问题。

  5. 动态和灵活的数据
    在处理动态数据集时,如果元素数量无法预先知道,Dictionary 会很有用。与数组不同,Dictionary 不需要指定初始大小,并且可以随着数据的添加而动态增长。

  6. 稀疏数据集
    如果你的数据集很大但大多数值未使用或未定义(如稀疏矩阵),使用 Dictionary 比像数组或列表这样的其他结构更高效,因为后者会为每个可能的值分配内存。

  7. 常量时间的成员检查
    如果你需要频繁检查集合中是否存在特定元素(例如,检查某个特定单词是否出现过),Dictionary 允许常量时间的成员检查,而列表需要扫描整个列表。

什么时候使用字典:

  • 小数据集:对于非常小的数据集,哈希和维护 Dictionary 的开销可能不会提供比列表等简单结构明显的性能提升。
  • 顺序数据:如果你的操作涉及按特定顺序迭代元素(例如,按顺序处理项目列表),Dictionary 不太适合,因为它不保证顺序。
  • 内存开销:对于高度敏感内存的应用程序,存储键值(及其哈希码)的开销可能比简单结构更大,因此它可能并不总是最佳选择。

通过考虑这些因素,你可以决定何时使用 Dictionary 将为你的应用程序提供最佳性能和灵活性。


比较字典(Dictionary)和列表(List)

让我们通过解决一个流行的编码问题来说明 Dictionary 相较于 List 的效率:两数之和

问题:两数之和 (LeetCode #1)

给定一个整数数组,返回两个数字的索引,使它们的和等于特定目标。

我们将使用 ListDictionary 这两种方式来解决此问题,并比较它们的性能。

使用列表的解决方案 (O(n^2) 复杂度)

public int[] TwoSumList(int[] nums, int target) {
    for (int i = 0; i < nums.Length; i++) {
        for (int j = i + 1; j < nums.Length; j++) {
            if (nums[i] + nums[j] == target) {
                return new int[] { i, j };
            }
        }
    }
    return null;
}

在这个解决方案中,我们循环遍历数组两次,检查每一对数字。时间复杂度是 O(n^2),对于较大的数组来说,这可能会很慢。

使用字典的解决方案 (O(n) 复杂度)

public int[] TwoSumDictionary(int[] nums, int target) {
    Dictionary<int, int> map = new Dictionary<int, int>();
    for (int i = 0; i < nums.Length; i++) {
        int complement = target - nums[i];
        if (map.ContainsKey(complement)) {
            return new int[] { map[complement], i };
        }
        map[nums[i]] = i;
    }
    return null;
}

在这里,我们使用 Dictionary 存储每个数字的补数和索引。这使我们能够在 O(n) 的时间内找到解决方案,因为我们只需要遍历数组一次。

性能比较

  • 列表解决方案:O(n^2)
  • 字典解决方案:O(n)

通过使用 Dictionary,我们显著减少了运行时间,使这个解决方案在处理大型数据集时更加可扩展。这就是理解何时以及如何使用 Dictionary 对于编码面试至关重要的原因。


使用字典(Dictionary)的常见 LeetCode 问题

如果你正在为面试做准备,可能会遇到一些问题,其中 Dictionary 是理想的解决方案。以下是一些依赖于 Dictionary 的关键 LeetCode 问题:

1. 存在重复元素 (LeetCode #217)

问题描述:

给定一个整数数组 nums,如果数组中有任何值出现至少两次,则返回 true;如果每个元素都是唯一的,则返回 false

使用字典的解决方案:
public bool ContainsDuplicate(int[] nums) 
{
    Dictionary<int, int> nums_dict = new Dictionary<int, int>();
    foreach (int num in nums)
    {
        if (nums_dict.ContainsKey(num))
        {
            return true;  // 找到重复元素
        }
        else
        {
            nums_dict.Add(num, 0);  // 将数字添加到字典中
        }
    }

    return false;  // 未找到重复元素
}
解释:

在这个问题中,我们需要检测数组中是否存在任何重复值。使用 Dictionary 允许我们存储每个遇到的唯一数字,并在常数时间内(平均 O(1))检查某个数字是否已经出现过。

2. 最长和谐子序列 (LeetCode #594)

问题描述:

我们定义和谐数组为一个数组,其中最大值和最小值之间的差恰好为 1。 给定一个整数数组 nums,返回所有可能子序列中最长和谐子序列的长度。子序列是通过删除一些或没有元素而不改变剩余元素的顺序从数组中派生出的序列。

使用字典的解决方案:
public int FindLHS(int[] nums)
{
    Dictionary<int, int> nums_cnts = new Dictionary<int, int>();
    
    // 计算数组中每个数字的出现次数
    foreach (int num in nums)
    {
        if (nums_cnts.ContainsKey(num))
        {
            nums_cnts[num] += 1;
        }
        else
        {
            nums_cnts.Add(num, 1);
        }
    }

    int LHS = 0;
    
    // 遍历字典,寻找和谐子序列
    foreach (var kv in nums_cnts)
    {
        int num = kv.Key;

        // 检查 num + 1 是否存在于字典中
        if (nums_cnts.ContainsKey(num + 1))
        {
            int LHS_temp = kv.Value + nums_cnts[num + 1];
            LHS = Math.Max(LHS, LHS_temp);
        }
    }

    return LHS;
}
解释:

在这个问题中,我们需要找到最长和谐子序列的长度,其中最大值和最小值之间的差恰好为 1。使用 Dictionary 有助于我们高效地计算每个元素的频率,然后利用这个计数来识别和谐子序列。

3. 同构字符串 (LeetCode #205)

问题描述:

给定两个字符串 st,判断它们是否是同构的。
如果字符串 s 中的字符可以被替换以获得字符串 t,那么它们就是同构的。
字符的所有出现都必须被替换为另一个字符,同时保持字符的顺序。没有两个字符可以映射到同一个字符,但一个字符可以映射到它自己。

使用字典的解决方案:
public bool IsomorphicStrings(string s, string t)
{
    if (s.Length != t.Length)
    {
        return false;
    }

    // 创建字典以映射字符到它们的位置
    Dictionary<char, int> s_dict = new Dictionary<char, int>();
    Dictionary<char, int> t_dict = new Dictionary<char, int>();

    for (int i = 0; i < s.Length; i++)
    {
        char char_in_s = s[i];
        char char_in_t = t[i];

        // 如果两个字符都曾经出现过,它们的位置必须匹配
        if (s_dict.ContainsKey(char_in_s) && t_dict.ContainsKey(char_in_t))
        {
            if (s_dict[char_in_s] != t_dict[char_in_t])
            {
                return false;  // 发现不匹配
            }
        }
        // 如果两个字符都未曾出现过,将它们添加到各自的字典中
        else if (!s_dict.ContainsKey(char_in_s) && !t_dict.ContainsKey(char_in_t))
        {
            s_dict.Add(char_in_s, i);
            t_dict.Add(char_in_t, i);
        }
        else
        {
            return false;  // 有一个字符已出现,但另一个没有
        }
    }

    return true;
}
解释:

该问题要求我们检查两个字符串是否是“同构”的,这意味着两个字符串中的字符之间存在一一对应关系。使用 Dictionary 允许我们高效地将字符串 s 中的字符映射到字符串 t(反之亦然),确保没有两个字符映射到同一个字符。

4. 有效的字母异位词 (LeetCode #242)

问题描述:

给定两个字符串 st,如果 ts 的字母异位词,则返回 true,否则返回 false
字母异位词 是由重新排列另一个单词或短语的字母形成的,通常精确使用所有原始字母一次。

使用字典的解决方案:
public bool IsValidAnagram(string s, string t)
{
    if (s.Length != t.Length)
    {
        return false;
    }

    // 字典用于存储字符串 s 中字符的频率
    Dictionary<char, int> s_dict = new Dictionary<char, int>();
    
    foreach (char char_in_s in s)
    {
        if (s_dict.ContainsKey(char_in_s))
        {
            s_dict[char_in_s] += 1;
        }
        else
        {
            s_dict.Add(char_in_s, 1);
        }
    }

    // 减少字符串 t 中字符的频率
    foreach (char char_in_t in t)
    {
        if (s_dict.ContainsKey(char_in_t))
        {
            if (s_dict[char_in_t] == 0)
            {
                return false;
            }
            s_dict[char_in_t] -= 1;
        }
        else
        {
            return false;
        }
    }

    // 确保所有计数为零
    foreach (var kv in s_dict)
    {
        if (kv.Value != 0)
        {
            return false;
        }
    }

    return true;
}
解释:

这个问题要求我们检查一个字符串是否是另一个字符串的字母异位词,这意味着它们必须包含相同的字符及其相同的数量,但字符的顺序可以不同。使用 Dictionary 可以存储一个字符串(s)中每个字符的频率计数,然后在我们遍历第二个字符串(t)时调整这个计数。

5. 最长回文串 (LeetCode #409)

问题描述:

给定一个由小写或大写字母组成的字符串 s,返回可以用这些字母构建的最长回文串的长度。
字母是区分大小写的,这意味着 "Aa" 不被视为回文串。

使用字典的解决方案:
public int LongestPalindrome(string s)
{
    int result = 0;
    Dictionary<char, int> cnts = new Dictionary<char, int>();

    // 统计每个字符的频率
    foreach (char char_in_s in s)
    {
        if (cnts.ContainsKey(char_in_s))
        {
            cnts[char_in_s] += 1;
        }
        else
        {
            cnts.Add(char_in_s, 1);
        }
    }

    bool flagOddAdded = false;
    
    // 计算最长回文串的长度
    foreach (var kv in cnts)
    {
        int cnt = kv.Value;

        // 如果计数为偶数,则完全加到结果中
        if (cnt % 2 == 0)
        {
            result += cnt;
        }
        // 如果计数为奇数,则加(计数 - 1)使其变为偶数,并记录有奇数计数的字符
        else
        {
            result += cnt - 1;
            flagOddAdded = true;
        }
    }

    // 如果有任何字符的计数是奇数,则可以在回文串的中间添加一个
    if (flagOddAdded)
    {
        result += 1;
    }

    return result;
}
解释:

目标是找到可以用字符串中的字符形成的最长回文串的长度。回文串正读和反读相同,因此我们需要每个字符的偶数个数量,除了最多一个奇数计数,可以放置在中心位置。

6. 单词模式 (LeetCode #290)

问题描述:

给定一个模式字符串和一个字符串 s,确定 s 是否遵循相同的模式。这里,“遵循相同的模式”意味着模式中的字母与 s 中的非空单词之间存在双射关系(bijection)。

示例:

  • 输入: pattern = "abba", s = "dog cat cat dog"
  • 输出: true
  • 输入: pattern = "abba", s = "dog cat cat fish"
  • 输出: false
使用字典的解决方案:
public bool WordPattern(string pattern, string s) 
{
    char[] patternArr = pattern.ToCharArray(); 
    string[] words = s.Split(' ');

    Dictionary<char, string> kv1 = new Dictionary<char, string>();  
    Dictionary<string, char> kv2 = new Dictionary<string, char>();  

    // 检查模式长度与单词数量是否匹配
    if(patternArr.Length != words.Length)
    {
        return false;
    }

    for(int i = 0; i < words.Length; i++)
    {
        char p = patternArr[i];
        string word = words[i];

        // 确保模式到单词的双射关系
        if(kv1.ContainsKey(p))
        {
            if(kv1[p] != word)
            {
                return false;
            }
        }
        else
        {
            kv1.Add(p, word);
        }

        // 确保单词到模式的双射关系
        if(kv2.ContainsKey(word))
        {
            if(kv2[word] != p)
            {
                return false;
            }
        }
        else
        {
            kv2.Add(word, p);
        }
    }

    return true;
}
解释:

在这个问题中,我们检查字符串 s 是否遵循由 pattern 字符串定义的相同模式。模式中的每个字符必须精确映射到字符串中的一个单词,反之亦然,这意味着存在一一对应的关系(双射)。

7. 好对的数量 (LeetCode #1512)

问题描述:

给定一个整数数组 nums,返回好对的数量。对 (i, j) 称为好对当且仅当 nums[i] == nums[j] 并且 i < j

示例:

  • 输入: nums = [1,2,3,1,1,3]
  • 输出: 4
  • 解释: 有四个好对: (0,3), (0,4), (3,4), 和 (2,5)。
使用字典的解决方案:
public int NumIdenticalPairs(int[] nums) 
{
    int ans = 0;
    Dictionary<int, int> cnt = new Dictionary<int, int>();
    foreach (int x in nums)
    {
        if (cnt.ContainsKey(x))
        {
            // 如果数字在字典中已存在,则它已被配对
            ans += cnt[x]; 
            cnt[x]++;
        }
        else
        {
            // 如果这是第一次出现,则将其添加到字典中
            cnt[x] = 1;
        }
    }
    return ans;
}
解释:

此解决方案利用 Dictionary<int, int> 来跟踪每个数字在数组中出现的次数。当我们遍历数组时,对于每个元素 x,字典帮助计算它之前出现的次数,我们将结果 ans 增加该计数,以计算所有好对的数量。

8. 字母异位词分组 (LeetCode #49)

9. 单词拆分 (LeetCode #139)

10. 寻找差异 (LeetCode #389)

11. 和为 K 的子数组 (LeetCode #560)

12. 两个列表的最小索引和 (LeetCode #599)


结论

Dictionary 是一个高效的数据结构,适用于需要频繁查找、插入或删除的问题。在面试中,这尤其重要,因为优化时间复杂度可能意味着通过与否的区别。

通过练习可以充分利用 Dictionary 的问题,你将更深入地理解 C# 并提高你的面试表现。尝试上面列出的 LeetCode 问题,你将看到这个数据结构是多么强大!