左哥算法 - 异或运算

342 阅读9分钟

1. 异或运算的基本性质

异或运算(用符号 ^ 表示)有以下重要性质:

  • 0 ^ N = N(任何数和0异或等于它本身)
  • N ^ N = 0(任何数和自己异或等于0)
  • 满足交换律和结合律:A ^ B = B ^ A,(A ^ B) ^ C = A ^ (B ^ C)

3. 解题思路流程图

graph TD
    A[遇到异或题目] --> B{是否涉及数字交换?}
    B -->|是| C[使用异或交换]
    B -->|否| D{数组中的特殊数字问题?}
    D -->|是| E{一个数还是两个数?}
    E -->|一个数| F[全体异或]
    E -->|两个数| G[分组异或法]
    D -->|否| H[其他解法]

4. 实战技巧总结

  1. 判断数字是否相等a ^ b == 0
  2. 提取最右侧的1n & (~n + 1)
  3. 消除最右侧的1n & (n - 1)

5. 常见题型解题步骤

  1. 单个特殊数字

    • 全体异或
    • 结果即为答案
  2. 成对特殊数字

    • 全体异或得到两个特殊数的异或值
    • 找到异或值中的任意为1的位
    • 按该位是否为1将数组分组
    • 分别异或得到两个答案

6. 注意事项

  1. 异或运算的结果与运算顺序无关
  2. 使用异或交换变量时,必须确保两个变量的内存地址不同
  3. 在处理数组时,注意数组越界问题

7.常见题型

1. 交换两个数

public class XORSwap {
    public static void swap(int[] arr, int i, int j) {
        // 注意:i和j不能相等,否则会将数字变成0
        if (i == j) return;
        arr[i] = arr[i] ^ arr[j];
        arr[j] = arr[i] ^ arr[j];  // 此时arr[j]得到原来的arr[i]
        arr[i] = arr[i] ^ arr[j];  // 此时arr[i]得到原来的arr[j]
    }
}

2. 找出数组中唯一出现奇数次的数

public class FindSingleNumber {
    public static int findOddTimesNum(int[] arr) {
        int eor = 0;
        for (int num : arr) {
            eor ^= num;
        }
        return eor;
    }
}

3. 找出数组中两个出现奇数次的数

public class FindTwoNumbers {
    public static int[] findTwoOddTimesNum(int[] arr) {
        int eor = 0;
        // 得到两个奇数次数的异或结果
        for (int num : arr) {
            eor ^= num;
        }
        
        // 找到eor中最右侧的1
        int rightOne = eor & (~eor + 1);
        
        // 将数组分成两组
        int onlyOne = 0;
        for (int num : arr) {
            // 该位置为0的组
            if ((num & rightOne) == 0) {
                onlyOne ^= num;
            }
        }
        
        return new int[]{onlyOne, eor ^ onlyOne};
    }
}

4. 实用工具类

public class XORUtils {
    /**
     * 判断两个数是否相等
     */
    public static boolean isEqual(int a, int b) {
        return (a ^ b) == 0;
    }
    
    /**
     * 获取数字最右侧的1
     */
    public static int getRightmostOne(int n) {
        return n & (~n + 1);
    }
    
    /**
     * 消除最右侧的1
     */
    public static int clearRightmostOne(int n) {
        return n & (n - 1);
    }
    
    /**
     * 打印数字的二进制形式
     */
    public static void printBinary(int num) {
        for (int i = 31; i >= 0; i--) {
            System.out.print((num & (1 << i)) == 0 ? "0" : "1");
        }
        System.out.println();
    }
}

5. 测试代码

public class XORTest {
    public static void main(String[] args) {
        // 测试交换
        int[] arr = {3, 5};
        System.out.println("交换前:" + arr[0] + ", " + arr[1]);
        XORSwap.swap(arr, 0, 1);
        System.out.println("交换后:" + arr[0] + ", " + arr[1]);
        
        // 测试找单个数
        int[] arr1 = {2, 2, 3, 3, 4, 4, 5};
        System.out.println("唯一出现奇数次的数:" + 
            FindSingleNumber.findOddTimesNum(arr1));
        
        // 测试找两个数
        int[] arr2 = {2, 2, 3, 3, 4, 5, 4, 6};
        int[] result = FindTwoNumbers.findTwoOddTimesNum(arr2);
        System.out.println("两个出现奇数次的数:" + 
            result[0] + ", " + result[1]);
    }
}

补充说明:

  1. 在Java中实现异或运算时,要注意以下几点:

    • 使用 ^ 运算符进行异或运算
    • 数组交换时注意判断索引是否相等
    • 位运算优先级较低,必要时加括号
  2. Java中的一些特点:

    • 返回多个值时需要使用数组
    • 位运算符都是基本数据类型可以直接使用
    • 打印二进制可以使用 Integer.toBinaryString() 或自己实现
  3. 性能优化:

    • 异或运算比普通的加减运算要快
    • 在处理大量数据时,可以考虑使用并行流优化

8.补充一些题型

1. 不使用额外变量交换数组中两个数

这是最基础的面试题,考察对异或基本性质的理解。

public class SwapProblem {
    public static void swap(int[] arr, int i, int j) {
        // 重要前提:确保 i != j
        if (i == j) return;
        
        arr[i] = arr[i] ^ arr[j];
        arr[j] = arr[i] ^ arr[j];
        arr[i] = arr[i] ^ arr[j];
    }
}

面试要点:

  • 必须说明 i != j 的判断,否则会将数字变成0
  • 能够解释清楚为什么这样可以完成交换
  • 能够说明这种方法的优缺点(优点:不需要额外空间;缺点:可读性差)

2. 数组中只有一个数出现奇数次,其他数都出现偶数次,找出这个数

public class SingleNumber {
    public static int findSingle(int[] arr) {
        int result = 0;
        for (int num : arr) {
            result ^= num;
        }
        return result;
    }
    
    // 进阶:如何证明算法正确性?
    public static void test() {
        int[] arr = {2,2,3,3,3,4,4,5,5};
        System.out.println("出现奇数次的数字是:" + findSingle(arr)); // 输出3
    }
}

面试要点:

  • 理解为什么异或运算可以解决这个问题
  • 时间复杂度O(N),空间复杂度O(1)
  • 能够举例说明算法执行过程

3. 数组中有两个数出现奇数次,其他数都出现偶数次,找出这两个数

public class TwoSingleNumbers {
    public static int[] findTwoSingles(int[] arr) {
        // 1. 得到两个数的异或结果
        int xor = 0;
        for (int num : arr) {
            xor ^= num;
        }
        
        // 2. 找到最右边的1
        int rightmostOne = xor & (-xor); // 或者 xor & (~xor + 1)
        
        // 3. 分组异或
        int x = 0;
        for (int num : arr) {
            if ((num & rightmostOne) != 0) {
                x ^= num;
            }
        }
        
        return new int[]{x, xor ^ x};
    }
    
    public static void test() {
        int[] arr = {2,2,3,3,3,4,4,4,5,5,5,6};
        int[] result = findTwoSingles(arr);
        System.out.println("两个出现奇数次的数字是:" + 
            result[0] + ", " + result[1]); // 输出3, 5
    }
}

面试要点:

  • 理解分组的原理
  • 为什么要找最右边的1
  • 时间复杂度分析
1. 问题描述

给定一个整数数组,其中有两个数字出现奇数次,其他数字都出现偶数次,要找出这两个出现奇数次的数字。

例如:[2,2,3,3,3,4,4,4,5,5,5,6] 中,3和5出现了3次(奇数次),其他数字都出现偶数次。

2. 详细解题步骤
public class DetailedTwoOddNumbers {
    public static int[] findTwoOddNums(int[] arr) {
        // 步骤1:得到两个奇数次数的异或结果
        int eor = 0;
        for (int num : arr) {
            eor ^= num;
        }
        // 此时eor = a ^ b,其中a和b是两个出现奇数次的数
        
        // 步骤2:找到eor中最右侧的1
        int rightOne = eor & (-eor); // 提取出最右侧的1
        
        // 步骤3:分组
        int onlyOne = 0;
        for (int num : arr) {
            // 根据最右侧的1是否为1来分组
            if ((num & rightOne) != 0) {
                onlyOne ^= num;
            }
        }
        
        // 步骤4:得到两个数
        return new int[]{onlyOne, eor ^ onlyOne};
    }

    // 测试代码
    public static void main(String[] args) {
        int[] arr = {2,2,3,3,3,4,4,4,5,5,5,6};
        int[] result = findTwoOddNums(arr);
        System.out.println("两个出现奇数次的数字是:" + 
            result[0] + ", " + result[1]);
    }
}
3. 原理详解

让我们用具体例子来说明: 假设数组为 [2,2,3,3,3,4,4,4,5,5,5,6]

步骤1:异或所有数
eor = 2^2^3^3^3^4^4^4^5^5^5^6
    = (2^2)^(3^3^3)^(4^4^4)^(5^5^5)^6
    = 0^3^0^5^6
    = 3^5^6

此时eor就是两个出现奇数次的数(3和5)的异或结果。

步骤2:找最右侧的1

假设 3^5 的二进制为:

3 的二进制:011
5 的二进制:101
3^5的结果: 110

找到最右侧的1,即 rightOne = 010

步骤3:分组

根据这个位置是否为1,可以将数组分成两组:

  • 该位置为1的组:包含3
  • 该位置为0的组:包含5

为什么这样分组有效?

  • 因为3和5在这个位置上一定是不同的(否则异或结果这个位置就不会是1)
  • 其他出现偶数次的数字,不管分到哪组,最终都会被异或成0
步骤4:得到结果
  • onlyOne就是其中一个数(比如3)
  • eor ^ onlyOne就是另一个数(5)
4. 图解示例
原始数组:[2,2,3,3,3,4,4,4,5,5,5,6]

第一步:求异或和
2 ^ 2 = 0
3 ^ 3 ^ 3 = 3
4 ^ 4 ^ 4 = 4
5 ^ 5 ^ 5 = 5
最终:3 ^ 4 ^ 5 ^ 6

第二步:找最右的1
假设结果是 010

第三步:分组
该位是1的数:[3,6,...]
该位是0的数:[5,...]

第四步:分别异或
组1得到:3
组2得到:5
5. 复杂度分析
  • 时间复杂度:O(N),只需要遍历数组两次
  • 空间复杂度:O(1),只使用了常数级别的额外空间
6. 关键点总结
  1. 利用异或运算的性质:相同数字异或为0
  2. 利用二进制位的特性进行分组
  3. 通过 n & (-n) 获取最右侧的1
  4. 分组异或的巧妙运用
7. 实际应用场景
  • 数字电路设计
  • 数据校验
  • 加密算法
  • 数据恢复

这个算法的精妙之处在于巧妙地利用了异或运算的性质和二进制位的特点,通过分组的方式将问题转化为两个简单的子问题。理解这个算法对于掌握位运算的技巧很有帮助!

4. 二进制中1的个数(汉明重量)

public class HammingWeight {
    // 方法1:使用 n & (n-1) 消除最右边的1
    public static int countOnes1(int n) {
        int count = 0;
        while (n != 0) {
            n = n & (n - 1);
            count++;
        }
        return count;
    }
    
    // 方法2:逐位判断
    public static int countOnes2(int n) {
        int count = 0;
        while (n != 0) {
            count += n & 1;
            n >>>= 1;
        }
        return count;
    }
}

面试要点:

  • 两种方法的比较
  • n & (n-1) 的原理
  • 无符号右移和有符号右移的区别

5. 找出不大于N的最大的2的幂

public class PowerOfTwo {
    public static int largestPowerOf2(int n) {
        // 将最高位1后面全部变成1
        n |= n >> 1;
        n |= n >> 2;
        n |= n >> 4;
        n |= n >> 8;
        n |= n >> 16;
        
        // 返回最大的2的幂
        return (n + 1) >> 1;
    }
    
    public static void test() {
        System.out.println(largestPowerOf2(120)); // 输出64
    }
}

面试要点:

  • 位运算的技巧
  • 为什么这样操作可以得到结果
  • 时间复杂度分析

6. 判断一个数是否是2的幂

public class PowerOfTwoCheck {
    public static boolean isPowerOfTwo(int n) {
        return n > 0 && (n & (n - 1)) == 0;
    }
    
    // 进阶:判断是否是4的幂
    public static boolean isPowerOfFour(int n) {
        return n > 0 && (n & (n - 1)) == 0 && (n & 0xAAAAAAAA) == 0;
    }
}

面试要点:

  • 为什么 n & (n-1) == 0 可以判断2的幂
  • 4的幂的特殊性质
  • 位掩码 0xAAAAAAAA 的作用

面试技巧总结:

  1. 基础知识

    • 熟练掌握异或运算的性质
    • 理解位运算的基本操作
    • 能够手动模拟运算过程
  2. 解题思路

    • 先考虑简单情况
    • 画图辅助理解
    • 多举具体例子
  3. 代码优化

    • 考虑边界情况
    • 注意代码简洁性
    • 关注性能优化
  4. 常见陷阱

    • 注意判断特殊情况
    • 考虑数值范围
    • 注意运算符优先级
  5. 扩展思考

    • 能够举一反三
    • 思考其他解决方案
    • 比较不同方法的优劣