判断字符串中所有字符是否唯一是一个经典的算法面试题,在力扣(LeetCode)上编号为面试题01.01。题目要求实现一个算法,确定一个字符串的所有字符是否全都不同。假设字符串仅包含小写字母a-z,这意味着我们只需要处理26种可能的字符。
这道题看似简单,但考察了面试者对基础数据结构和位运算的理解深度。常规解法可能会使用哈希表或数组来记录字符出现情况,但使用位运算可以将空间复杂度优化到极致——仅需一个32位整型变量即可完成任务。
在实际工程中,类似的思想可以应用于布隆过滤器、权限控制系统等场景,因此掌握位运算技巧对程序员来说非常重要。接下来我将详细解析如何利用位运算高效解决这个问题。
位图(Bitmap)是一种极其紧凑的数据结构,它通过二进制位(bit)来标记元素的存在状态。与传统的数据结构相比:
对于本题的小写字母场景,26个字母只需要26个比特位。一个32位的int类型变量(在大多数现代系统中)完全足够存储这些信息,无需额外分配内存。
使用位运算解决这个问题有三大优势:
我们需要建立从小写字母到位图位置的映射关系:
这种映射可以通过简单的ASCII码运算实现:ch - 'a'。例如:
判断第x位是否为1的位运算公式:
cpp复制(b >> x) & 1
这个操作分为两步:
例如,检查b=6(二进制110)的第1位:
将第x位设置为1的位运算公式:
cpp复制b |= (1 << x)
这个操作分为两步:
例如,设置b=4(二进制100)的第1位:
cpp复制class Solution {
public:
bool isUnique(string astr) {
int bitmap = 0; // 初始化位图
for(char c : astr) {
int pos = c - 'a'; // 计算字符位置
if((bitmap >> pos) & 1) // 检查是否已存在
return false;
bitmap |= (1 << pos); // 设置位图标记
}
return true;
}
};
位图初始化:
cpp复制int bitmap = 0;
所有位初始为0,表示没有任何字符出现过。
字符位置计算:
cpp复制int pos = c - 'a';
将字符转换为0-25的索引,对应位图中的比特位。
存在性检查:
cpp复制if((bitmap >> pos) & 1)
return false;
如果对应位已经是1,说明字符重复,立即返回false。
位图标记设置:
cpp复制bitmap |= (1 << pos);
将字符对应的位设置为1,标记该字符已出现。
空字符串:
单个字符:
全相同字符:
最大长度字符串:
如果字符串长度超过26,必定有重复字符:
cpp复制if(astr.length() > 26)
return false;
这个优化可以将最坏情况时间复杂度从O(n)降到O(1)。
如果字符集扩大到所有ASCII字符(128个),可以使用:
在多线程环境下,位图操作需要加锁或使用原子操作,以避免竞态条件。
这种位图技术在实际开发中有广泛应用:
运算符优先级:
(bitmap >> pos) & 1不能省略括号>>优先级低于&位移溢出:
符号位问题:
打印二进制:
cpp复制std::bitset<32> bits(bitmap);
std::cout << bits << std::endl;
单元测试用例:
边界值测试:
我实际测试了三种实现方式的性能:
位运算版:
哈希表版:
数组版:
测试环境:Intel i7-9700K,GCC 9.3,-O2优化
结果显示位运算版本在时间和空间上都明显优于其他实现。
现代计算机使用补码表示有符号整数:
位运算之所以高效,是因为:
现代编译器会对位运算进行多种优化:
例如,1 << x在x为常量时会被编译时计算。
如前面所示,使用int类型和位运算符。
Java中没有无符号整数,需要注意算术右移:
java复制public boolean isUnique(String astr) {
int bitmap = 0;
for(char c : astr.toCharArray()) {
int pos = c - 'a';
if(((bitmap >> pos) & 1) == 1)
return false;
bitmap |= (1 << pos);
}
return true;
}
Python的整数没有固定位数,但实现方式类似:
python复制def isUnique(astr: str) -> bool:
bitmap = 0
for c in astr:
pos = ord(c) - ord('a')
if (bitmap >> pos) & 1:
return False
bitmap |= (1 << pos)
return True
可以使用两个位图,然后检查它们的与运算是否非零。
维护两个位图:
虽然位图只能记录是否出现,但可以扩展为:
设置第n位:
cpp复制x |= (1 << n);
清除第n位:
cpp复制x &= ~(1 << n);
切换第n位:
cpp复制x ^= (1 << n);
检查第n位:
cpp复制(x >> n) & 1;
快速乘除2:
x << 1 等价于 x*2x >> 1 等价于 x/2判断奇偶:
cpp复制if(x & 1) { /* 奇数 */ }
交换两个数:
cpp复制a ^= b; b ^= a; a ^= b;
位运算技巧源于计算机科学的早期。20世纪50年代,程序员们就开始利用位操作来节省宝贵的内存资源。随着计算机体系结构的发展,位运算不仅保持了空间效率优势,还因其极高的速度而成为性能关键代码的首选。
在现代算法竞赛和面试中,位运算题目经常出现,因为它们能很好地考察程序员对计算机底层原理的理解和创造性解决问题的能力。
书籍:
在线资源:
实践建议:
在实际使用位运算解决问题时,我有几点深刻体会:
调试困难:位运算代码往往难以调试,建议添加详细的二进制打印语句
可读性挑战:适当的注释非常重要,否则几个月后自己都难以理解
性能惊喜:在数据量大时,位运算带来的性能提升常常超出预期
思维转变:需要从传统的"数组/集合"思维转变为"位掩码"思维
一个实用的建议是:先使用传统方法实现功能,确保逻辑正确,然后再考虑用位运算优化。这样可以避免同时处理算法逻辑和位操作细节带来的复杂性。