LeetCode 137. 只出现一次的数字 II:从基础到最优的两种解法详解

0 阅读7分钟

刷LeetCode中等题时,137. 只出现一次的数字 II 算是比较经典的位运算和哈希表应用题目,核心考点是「线性时间复杂度O(n)」和「常数级空间复杂度O(1)」,这两个要求直接限制了我们不能用暴力解法,也倒逼我们思考更高效的底层逻辑。今天就拆解这道题,分享两种可行解法,从易到难,帮大家吃透这道题的核心思路。

一、题目复盘:明确核心需求

先再看一遍题目,避免理解偏差:

给你一个整数数组 nums ,除某个元素仅出现一次 外,其余每个元素都恰出现 三次。请你找出并返回那个只出现了一次的元素。

关键约束(必看):

  • 时间复杂度必须是 O(n):意味着不能用嵌套循环(比如双重for循环枚举),只能遍历数组常数次(1次、32次这类固定次数都算)。

  • 空间复杂度必须是 O(1):意味着不能用额外的、随数组长度变化的空间(比如长度为n的哈希表、数组都不行),只能用固定数量的变量。

补充:数组中的元素可以是正数、负数,这一点在第二种解法中需要特别注意(不过代码已经处理好了,后面会说)。

二、解法一:哈希表统计频率(易懂但不满足空间约束)

首先想到的最直观的解法,就是用哈希表统计每个数字出现的次数,然后遍历哈希表,找到出现次数为1的数字。这种方法虽然简单易懂,但空间复杂度是 O(n),不满足题目要求的「常数级空间」,不过可以作为入门思路,帮我们理解题目,再过渡到最优解。

1. 代码实现(TypeScript)

function singleNumber_1(nums: number[]): number {
  const freq = new Map();
  // 第一步:遍历数组,统计每个数字的出现频率
  for (const num of nums) {
    // 若哈希表中已有该数字,频率+1;否则初始化为1
    freq.set(num, (freq.get(num) || 0) + 1);
  }
  let ans = 0;
  // 第二步:遍历哈希表,找到频率为1的数字
  for (const [num, occ] of freq.entries()) {
    if (occ === 1) {
      ans = num;
      break; // 找到后直接退出,提升效率
    }
  }
  return ans;
};

2. 思路解析

核心逻辑:利用哈希表的「键值对」特性,键存数字,值存该数字出现的次数。

  • 遍历数组nums,对每个数字num,更新其在哈希表中的频率(不存在则为1,存在则+1)。

  • 再遍历哈希表的entries,找到值为1的键,就是我们要找的「只出现一次的数字」。

3. 优缺点分析

优点:代码简洁、思路直观,几乎不需要思考底层逻辑,适合新手入门,调试也简单。

缺点:空间复杂度O(n),哈希表的空间会随着数组长度n的增加而增加,不满足题目「常数级空间」的要求,只能作为过渡解法。

三、解法二:位运算(最优解,满足所有约束)

这是这道题的核心解法,也是面试中常考的思路。核心原理是「二进制位统计」—— 因为除了目标数字,其余每个数字都出现3次,那么对于二进制的每一位(0~31位,因为JavaScript/TypeScript中数字是32位有符号整数),所有数字在该位上的1的总数,要么是3的倍数(全是出现3次的数字贡献),要么是3的倍数+1(目标数字贡献了1)。

1. 代码实现(TypeScript)

function singleNumber_2(nums: number[]): number {
  let ans = 0;
  // 遍历32位二进制的每一位(0~31位)
  for (let i = 0; i < 32; ++i) {
    let total = 0;
    // 统计所有数字在第i位上的1的总数
    for (const num of nums) {
      // 右移i位,将第i位移到最低位,再与1按位与,得到该位的值(0或1)
      total += ((num >> i) & 1);
    }
    // 若总数不能被3整除,说明目标数字在该位上是1,将该位设为1
    if (total % 3 !== 0) {
      ans |= (1 << i);
    }
  }
  return ans;
};

2. 思路拆解(逐行理解,不怕看不懂)

我们先明确一个前提:32位二进制中,每一位只有0和1两种状态,而出现3次的数字,其每一位的1都会被计算3次,总和是3的倍数;只有目标数字,其某几位的1会被多计算1次,导致该位的总和不能被3整除。

逐行解析代码:

  1. 初始化ans=0:ans用来存储最终的目标数字,初始值为0(二进制全0)。

  2. 循环i从0到31:遍历32位二进制的每一位(从最低位到最高位)。

  3. total=0:用来统计当前第i位上,所有数字的1的总数。

  4. 内层循环遍历nums:对每个数字num,计算其第i位是否为1——(num >> i) & 1

    • num >> i:将num的二进制右移i位,此时第i位会移动到最低位(第0位)。

    • & 1:与1按位与,若最低位是1,结果为1;若为0,结果为0。这样就得到了num在第i位上的值。

    • 将所有num的第i位值相加,得到total(即该位上1的总数)。

  5. 判断total % 3 !== 0:若总和不能被3整除,说明目标数字在第i位上是1,此时用ans |= (1 << i)将ans的第i位设为1。

    • 1 << i:将1的二进制左移i位,此时只有第i位是1,其余位是0。

    • ans |= ...:按位或运算,将ans的第i位设为1(不影响其他位)。

  6. 循环结束后,ans就是只出现一次的数字。

3. 关键细节:为什么能处理负数?

有同学可能会问:负数的二进制是补码,会不会影响计算?其实不需要额外处理,因为代码中遍历了32位(包括符号位),对于负数的符号位(第31位),统计方式和其他位完全一致,最终ans会自动还原为正确的负数。

举个例子:假设nums中有一个负数-4,其32位补码的第31位是1,其余相关位根据补码规则计算。统计时,符号位的1会被计入total,若total%3≠0,ans的第31位会被设为1,最终ans就是-4,完全正确。

4. 优缺点分析

优点:满足题目所有约束——时间复杂度O(n)(外层循环32次,内层循环n次,总次数是32n,属于O(n)),空间复杂度O(1)(只用到了ans、total、i三个固定变量),是这道题的最优解,也是面试重点考察的思路。

缺点:思路相对抽象,需要对二进制位运算有一定了解,新手可能需要多琢磨几遍才能吃透。

四、两种解法对比&刷题总结

解法时间复杂度空间复杂度核心思路适用场景
哈希表统计O(n)O(n)用哈希表记录频率,找频率为1的数字新手入门、快速解题、不要求空间约束
位运算O(n)O(1)统计每一位1的总数,利用3的倍数特性定位目标数字面试、满足题目所有约束、底层逻辑考察

刷题心得

这道题的核心是「常数空间」的约束,倒逼我们放弃直观的哈希表,转向位运算。其实位运算的思路本质是「利用数字出现的次数规律,通过二进制位的统计来剥离目标数字」,这种思路在类似题目(比如只出现一次的数字I、IV)中也会用到。