目录
一、引言
二、离散数学中的集合
三、位串和按位运算符
四、在编程中使用位串和按位运算符
五、练习题
六、拓展阅读
本文的示例代码用编程语言 Swift 5.0 实现,但只要你熟悉任何编程语言,都可以顺畅读完本文。
一、引言
在实际编程中,我们经常需要对某事物的某状态进行分类,比如把食品的口味分为酸,甜,苦和辣,把网络请求的过程分为初始,请求中,成功和失败,把用户的状态分为已登录和未登录。很多编程语言都提供 枚举(enums) 来让我们表达和处理这类问题,比如在 Swift 中,我们可以定义一个枚举 Taste
,来表达食品的 4 种不同口味:
enum Taste {
case sour, sweet, bitter, spicy
}
棒棒糖是甜的,咖啡是苦的,要将它们表达出来,很简单,只需要声明两个 Taste
类型的变量,值分别是 sweet
和 bitter
:
let lollipop: Taste = .sweet
let coffee: Taste = . bitter
如果我们想判断某食品是否好吃(假设带甜味的好吃),只需要一个方法,将食品的口味作为唯一参数,判断它是否等于 .sweet
,然后将判断结果返回:
func isDelicious(taste: Taste) -> Bool {
return taste == .sweet
}
isDelicious(taste: lollipop) // true, 棒棒糖好吃
isDelicious(taste: coffee) // false, 咖啡不好喝
目前看起来不错。
但很多食物,不是单一口味的,而是混合了多种口味,比如又酸又甜的酸奶。我们好像无法用一个 Taste
类型的变量来表示酸奶,因为大多数编程语言中的枚举只允许同时存在一个值。但我们可以用一个 Taste
类型的数组变量来表示:
let yoghourt: [Taste] = [.sweet, .sour]
这样写没问题,但我们不得不修改已经写好的 isDelicious(taste:)
方法,因为现在有一些食物的类型不是 Taste
,而是 [Taste]
,我们需要处理混合口味:
func isDelicious(tastes: [Taste]) {
return tastes.contains(.sweet)
}
由于我们将方法参数改成了 [Taste]
,我们也不得不修改已经声明的 lollipop
和 coffee
,使其类型为 [Taste]
,即使它们是单一口味的:
let lollipop: [Taste] = [.sweet]
let coffee: [Taste] = [.bitter]
let yoghourt: [Taste] = [.sweet, .sour]
isDelicious(tastes: lollipop) // true, 棒棒糖好吃
isDelicious(tastes: coffee) // false, 咖啡不好喝
isDelicious(tastes: yoghourt) // true, 酸奶好喝
当然,也可以不修改 lollipop
和 coffee
的类型,在使用 isDelicious(taste:)
的时候临时用一个数组来包裹它们,像这样:
let lollipop: Taste = .sweet
let coffee: Taste = .bitter
let yoghourt: [Taste] = [.sweet, .sour]
isDelicious(tastes: [lollipop]) // true, 棒棒糖好吃
isDelicious(tastes: [coffee]) // false, 咖啡不好喝
isDelicious(tastes: yoghourt) // true, 酸奶好喝
两种写法都可以,我们的目的不是讨论它们的优劣。现在我们的代码终于能同时处理单一口味和混合口味了,但产生了一些问题:
- 注意
isDelicious(taste:)
方法的内部实现,从简单的==
判断变成了判断数组是否包含某个元素。虽然我们使用了 Swift 提供的contains(_:)
方法来替代我们手动进行遍历判断,但在大多数语言里,这都不是一个高效的方式。事实上,Swift 官方文档 已经告诉我们contains(_:)
的时间复杂度是 O(n),其中 n 是数组元素的总个数。当数组包含大量元素时,contains(_:)
方法会造成明显的性能问题。 - 也许我们不仅仅需要判断某食品是否包含某种口味,还需要判断某食品是否不包含某种口味,甚至需要得出某种食品不包含的口味有哪些。
Taste
和[Taste]
都无法让我们高效简单地实现这些需求。
本文提供一种方法,允许我们更简单地表达单一口味和多种口味,同时,不增加后续各种检测的时间复杂度。在学习它之前,我们需要先了解一些数学知识。
二、离散数学中的集合
集合(Sets),在离散数学中,指一堆无序的元素,元素可以是重复的,元素可以是任何对象(指任何事物,并非面向对象中的对象)。我们可以用这样的方式来表示一个集合:
A = {a, b, c, d} A 是一个集合,A 包含 a,b,c 和 d 四个元素。
B = {a, b, e} B 也是一个集合,B 包含 a,b 和 e 三个元素。
现在我们来定义一些常用的集合运算。
集合 A 和 B 的并集(The Union of Sets),用符号 ∪ 表示。并集的结果是另一个集合,这个集合的元素或属于集合 A,或属于集合 B:
A ∪ B = {a, b, c, d, e} A 和 B 的并集的元素或者属于 A 或者属于 B
集合 A 和 B 的交集(The Intersection of Sets),用符号 ∩ 表示。交集的结果是另一个集合,这个集合的元素既属于集合 A,也属于集合 B:
A ∩ B = {a, b} A 和 B 的交集的元素既属于 A 也属于 B
集合 A 和 B 的差集(The Difference of Sets),用符号 - 表示。差集的结果是另一个集合,这个集合的元素只属于集合 A,不属于集合 B:
A - B = {c, d} A 和 B 的差集的元素只属于 A 不属于 B
集合 A 和 B 的差集,也可以说成是 在集合 A 下,集合 B 的补集。
事实上,我们可以用集合及其运算来描述“食品问题”。首先,我们用集合来重写之前用枚举定义的 Taste
类型
Sour = {sour} Sour 是代表酸的集合,只包含元素“酸”
Sweet = {sweet} Sweet 是代表甜的集合,只包含元素“甜”
Bitter = {bitter} Bitter 是代表苦的集合,只包含元素“苦”
Spicy = {spicy} Spicy 是代表辣的集合,只包含元素“辣”
Taste = Sour ∪ Sweet ∪ Bitter ∪ Spicy = {sour, sweet, bitter, spicy} Taste 是代表所有口味的集合
接着,我们就可以表达不同食品的口味了:
Lollipop = Sweet = {sweet} 棒棒糖是甜的,所以我们直接将代表甜的集合作为棒棒糖的口味
Coffee = Bitter = {bitter} 咖啡是苦的,所以我们直接将代表苦的集合作为咖啡的口味
Yoghourt = Sweet ∪ Sour = {sweet, sour} 我们用甜的集合与酸的集合的并集作为酸奶的口味
到现在,无论食品的口味是单一的,还是混合的,我们都能用集合将其表达出来,比如,某种黑暗料理可能是既苦又辣又甜的:
DarkFood = Sweet ∪ Bitter ∪ Spicy = {sweet, bitter, spicy}
注意,黑暗料理的口味包含除酸以外的所有口味,因此我们也可以这样表达黑暗料理:
DarkFood = Taste - Sour = {sweet, bitter, spicy}
当我们接受到一个任意的食品口味集合后,如何像 isDelicious(taste:)
一样判断它是否好吃呢:
假设 SomeFood 是某食品口味的集合
如果 SomeFood ∩ Sweet 的结果包含 sweet 元素,则 SomeFood 好吃,否则不好吃
Lollipop ∩ Sweet = {sweet} ∩ {sweet} = {sweet} 因此棒棒糖好吃
Coffee ∩ Sweet = {bitter} ∩ {sweet} = {} 因此咖啡不好喝
Yoghourt ∩ Sweet = {sour, sweet} ∩ {sweet} = {sweet} 因此酸奶好吃
如果仔细观察,我们会发现上面的交集结果,要么等于 Sweet 要么是一个空集(空集,指一个没有任何元素的集合)。我们也可以得出棒棒糖不包含的口味了:
Taste - Lollipop
= {sour, sweet, bitter, spicy} - {sweet}
= {sour, bitter, spicy}
用集合来处理“食品问题”是不错的选择,但问题是,上面的运算并不是编程语言代码,只是一堆数学符号而已!我们必须用编程语言来描述集合及其运算,才能真正使用它。
三、位串和按位运算符
紧接着上面的问题,要用编程语言来描述集合,我们有很多方式。比如,我们可以为集合定义一个类型然后实现并集交集等方法,事实上,一些编程语言内置提供了这样的类型,比如 Swift 中的 Set 和 Java 中的 HashSet。Swift 中的 Set
非常尊重集合在数学中的定义和相关操作,是用来处理集合相关问题的极佳方式。
但这里,我们介绍另一种办法来描述集合,它在“食品问题”中更加通用和高效, 也允许我们在 C/C++ 或 JavaScript 等等这些没有直接提供集合类型的语言中方便地进行集合的相关运算。
在离散数学中,位(Bits)是值为 0 或 1 的数字。 位串(Bit strings),是任意长度的连续的位。比如:
ABitString = 01011,ABitString 是长度为 5 的位串,从右到左的 5 个位的值分别是 1, 1, 0, 1, 0
BBitString = 10000,BBitString 是长度为 5 的位串,从右到左的 5 个位的值分别是 0, 0, 0, 0, 1
和集合一样,我们也定义一些位串的常用运算,这些运算被称为 按位运算符(Bitwise Operators)。
位串 ABitString 和 BBitString 的 按位并(Bitwise AND),用符号 & 表示,运算结果是另一个位串,从右到左,它的第 i 位的值是 1 如果 ABitString 的第 i 位是 1 且 BBitString 的第 i 位是 1,否则为 0:
ABitString & BBitString = 01011 & 10000 = 00000
位串 ABitString 和 BBitString 的 按位或(Bitwise OR),用符号 | 表示,运算结果是另一个位串,从右到左,它的第 i 位的值是 1 如果 ABitString 的第 i 位是 1 或 BBitString 的第 i 位是 1,否则为 0:
ABitString | BBitString = 01011 | 10000 = 11011
位串 ABitString 的 按位非(Bitwise NOT),用符号 ~ 表示,运算结果是另一个位串,从右到左,它的第 i 位的值是 1 如果 A 的第 i 位是 0,否则为 0:
~ABitString = ~01011 = 10100
注意,我们只能对长度相同的两个位串进行按位并和按位或,如果长度不同,我们通常会在长度小的位串前面补 0,直到它的长度等于另一个位串:
C = 01
D = 1110
C & D = 01 & 1110 = 0001 & 1110 = 0000
现在,我们用位串的方式来描述集合。先给出集合 Sour 的位串:
SourBitString = 1000
我们可以这么理解:SourBitString 的 4 个位,从左到右,分别代表元素 sour, sweet, bitter 和 spicy。位的值如果是 1,代表此元素属于当前集合,0 代表此元素不属于当前集合。同理,我们可以给出其他三个集合的位串:
SweetBitString = 0100
BitterBitString = 0010
SpicyBitString = 0001
我们已经用位串分别表示了集合 Sour, Sweet, Bitter 和 Taste。如果我们将这 4 个位串进行按位或运算:
SourBitString | SweetBitString | BitterBitString | SpicyBitString
= 1000 | 0100 | 0010 | 0001
= 1111
结果是另一个位串,它代表的集合正是集合 Taste。集合 Taste 是我们在第二节中通过集合 Sour, Sweet, Bitter 和 Taste 的并集运算得出的。我们好像可以得出,位串的按位或等同于集合的并集。
我们现在来表达不同食品的口味:
LollipopBitString = SweetBitString = 0100
CoffeeBitString = BitterBitString = 0010
YoghourtBitString = SweetBitString | SourBitString = 1100
DarkFoodBitString = SweetBitString | BitterBitString | SpicyBitString = 0111
或者我们也可以这样定义 DarkFoodBitString
DarkFoodBitString = ~SourBitString = 0111
从上面的运算中,我们可以得出,位串的按位非等同于集合与当前集合的差集
继续,我们用位串来判断某食品是否好吃:
LollipopBitString & SweetBitString = 0100 因此棒棒糖好吃
CoffeeBitString & SweetBitString = 0000 因此咖啡不好喝
YoghourtBitString & SweetBitString = 0100 因此酸奶好吃
从上面的运算中,我们可以得出,位串的按位并等同于集合的交集。
我们也可以用位串得出棒棒糖不包含的口味
~LollipopBitString = 1011
至此,我们成功地用位串和按位运算来替代了集合和集合运算。
四、在编程中使用位串和按位运算符
幸运的是,大多数语言,都直接提供了按位运算符。现在,我们用 Swift 来实现第三节的位串和按位运算。
首先,我们定义一个变量 sour 来表示 SourBitString:
// 我们用 Int 类型来表示位串,因为 Int 类型本质上是长度为 32 或 64 的二进制数字
// 在性能敏感的场景下,我们也可以选择 UInt8 等长度更小的类型
// 我们用 0b 前缀来告诉编译器后面的数字是二进制形式的
// 我们也可以直接使用十进制数字 8,甚至是十六进制形式 0x8,它们完全等同。
// 但在这里,二进制形式的写法更容易让我们理解现在发生了什么
let sour: Int = 0b1000
然后,同理,定义三个变量来表示其他三个位串:
let sweet: Int = 0b0100
let bitter: Int = 0b0010
let spicy: Int = 0b0001
上面的写法没问题,但其实,我们更倾向于这样写:
let spicy: Int = 1 << 0 // 等同于二进制形式 0b0001,或位串 0001
let bitter: Int = 1 << 1 // 等同于二进制形式 0b0010,或位串 0010
let sweet: Int = 1 << 2 // 等同于二进制形式 0b0100,或位串 0100
let sour: Int = 1 << 3 // 等同于二进制形式 0b1000,或位串 1000
注意,我们刚刚使用的是二进制形式的写法。但现在我们使用的是十进制数字 + 左移的写法。左移是另一个按位运算符 <<,我们称它为按位左移运算符(Bitwise Left Shift Operator)。它的作用是将位串的所有位向左移动 n 个位置。超出位串边界的位将被移除,右边留空的位用 0 补上。比如:
我们将位串 001101 向左移 2 位
001101 << 2 = 110100
在上面的定义中,我们先让 spicy 的值为十进制数字 1(等同于二进制 0b1
,如果我们在前面不断补 0,0b1
也等同于 0b0001
),然后对 1 进行按位左移 0 位,因此,spicy 的值依旧等同于 0b0001
。按同样的逻辑,我们分别对十进制数字 1 进行左移 1,2 和 3 位来得到了 bitter,sweet 和 sour。
相比给每个变量写固定的二进制形式的值,我们更喜欢对 1 进行左移来给不同的变量赋值。因为两种写法的实际效果虽然是等同的,但第一种写法更容易让我们写错(肉眼区分大量的 0 和 1 并不容易)。如果你有 Cocoa 和 Objective-C 编程经验,应该见过类似的用法,比如 UIView
头文件中的 UIViewAnimationOptions
。如果你有 Android 和 Java 编程经验,也应该见过类似的用法,比如 AlarmManager
中的 FLAG_WAKE_FROM_IDLE
。
继续,我们现在可以表示出 TasteBitString:
let taste = spicy | bitter | sweet | sour // taste 的值是 15,对应二进制 1111
接着是我们的食品:
let lollipop = sweet
let coffee = bitter
let yoghourt = sour | sweet
let darkFood = sweet | bitter | spicy
isDelicious(taste:)
也可以被重写:
func isDelicious(taste: Int) -> Bool {
return taste & sweet == sweet
}
至此,我们用位串和按位操作代表了集合和集合运算,最终替代了最开始的枚举方式。所有食品口味都是 Int 类型,避免了 Taste
和 [Taste]
,在检测的时候,我们也保证了极小的时间复杂度。
如果你使用的是其他语言,到此就结束了。但在 Swift 里,我们还可以进一步优化我们的代码。由于这样的写法是如此的频繁,Swift 内置提供了专门用来处理此类问题的类型 OptionSet,使用 OptionSet
可以避免我们手动进行按位运算,以及与其他 Swift 代码保持统一的规范。
现在我们可以用 OptionSet
来完成我们的最终代码:
struct TasteOptions: OptionSet {
let rawValue: Int
static let spicy = TasteOptions(rawValue: 1 << 0)
static let bitter = TasteOptions(rawValue: 1 << 1)
static let sweet = TasteOptions(rawValue: 1 << 2)
static let sour = TasteOptions(rawValue: 1 << 3)
static let all: TasteOptions = [.spicy, .bitter, .sweet, .sour]
}
let lollipop: TasteOptions = .sweet
let coffee: TasteOptions = .bitter
let yoghourt: TasteOptions = [.sour, .sweet]
let darkFood: TasteOptions = [.sweet, .bitter, .spicy]
func isDelicious(tasteOptions: TasteOptions) -> Bool {
return tasteOptions.contains(.sweet)
}
isDelicious(tasteOptions: lollipop)
isDelicious(tasteOptions: coffee)
isDelicious(tasteOptions: yoghourt)
isDelicious(tasteOptions: darkFood)
注意,虽然使用 OptionsSet
后,我们的检测方法依旧叫 contains(_:)
,但其内部实现采用的是按位运算,而非遍历比较。如果你想确认这一步,可以阅读 OptionSet 的源代码。
五、练习题
在第四节中,我们介绍了按位左移运算符。有左就有右,按位右移运算符(Bitwise Right Shift Operator) 的作用是将位串的所有位向右移动 n 个位置。超出位串边界的位将被移除,左边留空的位用 0 补上。比如:
我们将位串 001101 向右移 2 位
001101 >> 2 = 000011
- 你能否用按位右移运算符来改写下面的代码,同时保证每个变量的实际值不变?
let spicy: Int = 1 << 0 // 等同于二进制形式 0b0001,或位串 0001
let bitter: Int = 1 << 1 // 等同于二进制形式 0b0010,或位串 0010
let sweet: Int = 1 << 2 // 等同于二进制形式 0b0100,或位串 0100
let sour: Int = 1 << 3 // 等同于二进制形式 0b1000,或位串 1000
- 第四节中我们讨论过更倾向于使用按位左移运算符,而不是手动写二进制形式的数字。你能解释为什么我们更倾向于使用按位左移运算符,而不是按位右移运算符吗?
六、拓展阅读
- Swift 中 OptionSet 的源代码。
- Swift 中按位运算符的官方文档。
- Swift 中 Set 类型的官方文档。
- Bitmap 数据结构或算法。