Transcript 位向量

第九章 集合与字典
算法与数据结构
1
9.1集合及其运算
集合是一个基本数学概念,也是一种基本数
据结构。抽象地说集合是一些个体(值)的无序
汇集;而在实用中作为数据结构的集合可以有多
种形式,例如:
算法与数据结构
2
集合的抽象定义明确说其中的元素是无顺序的。
这样,只有对集合中所有的值进行比较之后,
才能判断两个集合是否相等。为了提高效率,
集合的许多实现中规定了在元素之间可以进行
比较和按顺序存储。
算法与数据结构
3
一些集合中保存的是实际的数据值,而另一些集
合保存的是元素是否存在于集合中的指示信息。
例如,如果一个集合的元素是字符型或取值范围
不太大的整数型,那么只要保存指示信息就足够
了,因为,如果需要的话,整个值的集合很容易
被重新建立起来;
但是如果集合包含的是学生记录学生记录或浮点
数数据,由于这些值不容易重建,所以这些元素
的实际数据值必须保存在集合里。
算法与数据结构
4
在集合的抽象概念实际要求每个元素只在集合中出
现一次。但有些实际应用却需要允许元素的重复出
现。
比如一个班级里的所有学生姓名的集合,实际上无
法排除同学之间重名的可能性。这时可以使用集合
的一种变体:称为多重集合。
多重集合(multiset,或称为包bag)是一种类似
集合的结构,其基本性质与集合类似,但允许元素
的重复出现。
算法与数据结构
5
如果在集合实现时采用的是保存实际数据值的方
法,只要加上一个相关值出现次数的计数器就可
以用于实现包;
如果集合用记录元素是否存在的指示信息的方式
实现,要改做包的实现就比较困难。
算法与数据结构
6
9.1.1集合运算
与前面讲到的许多数据结构类似,对一个集合
也可以定义增加一个元素,删除一个元素,或
者测试一个元素是否已在集合中等运算,但集
合作为一种抽象数据类型,其定义的特点应体
现在涉及两个(或多个)集合之间的一些运算。
这些运算主要有:
算法与数据结构
7
并集:两个集合的并集定义为由两个集合的所有
元素形成的集合。
交集:两个集合的交集由同时在两个集合中出现
的那些元素组成。
差集:求两个集合的差集与求交集相反。差集由
第一个集合中的所有不在第二个集合里出现的元
素组成。
子集:一个集合称为是另一个集合的子集,如果
这个集合的所有元素都出现在另一个集合里。
相等:两个集合相等,指的是第一个集合的元素
都出现在第二个集合里,而第二个集合的元素也
都出现在第一个集合里。这个概念的另一种表述
是:若两个集合互为子集则称这两个集合相等。
算法与数据结构
8
从抽象的观点考查上述定义的集合与集合之间的运算,
我们发现这些运算可以通过简单地增加元素,检测成
员,删除元素等运算定义。
已知集合A和B,求它们的并集,只要以集合A(或B)
为基础,把集合B(或A)中的元素逐个插入。
在求两个集合的交集时,只要从A(或B)集出发,
检查它的每个元素是否在B(或A)集中出现,若是,
则把该元素插入到结果集合(初态为空集)中即可。
求A与B的差集A-B 时,只要以A集为基础,对每个B
集中的元素做删除运算即可。
算法与数据结构
9
如果我们用a,i,和r分别代表增加元素,检测元
素,删除元素运算的执行时间,并假定集合中的
元素为n个。
依集合运算的抽象实现,可以估算出这些运算操
作的时间上限:
求两个集合的并时间为O(a n);
求差集的运行时间为O(r n);
求交集的运行时间为O((i+a)n)。
算法与数据结构
10
9.1.2集合类
我们可以定义一个集合类。定义这个类主
要是为了说明各种实现集合的数据结构都
应当具有的操作。这个类里没有为集合的
实现而设置的内部数据结构,因此也就不
需要构造函数和析构函数。
template <class T> class set {
public:
virtual int isEmpty(void) const = 0;
virtual int includes (const T val) const = 0;
virtual void add (const T val) = 0;
virtual void remove (const T val) = 0;
void intersectWith (const set<T>&) {};
void unionWith (const set<T>&) {};
void differenceFrom (const set<T>&) {};
int subset (const set<T>&) { return 0; };
int operator == (const set<T>&) { return 0; };
};
类9.1 集合类
我们无法把set中的方法都定义为纯虚函数,因
为在实现集合操作的成员函数里都包含了对集
合类的实例对象的引用参数。这种情况下函数
不能定义为纯虚的。这里为每个函数虚构了一
个函数体,它们是永远不会被真正使用的。
这里还有一个新情况,在set类的定义里面直接
写出intersectWith等成员函数的定义,而不是
只写它们的原型。C++允许这样做。当成员函
数很短小时,为紧凑或消除过多重复描述,常
采用这种写法
举一个例子讨论上面提出的问题。假设我们把
unionWith定义为纯虚函数,函数在类规范里
的原型应该是:
virtual void unionWith (const set<T>&) = 0;
在set的实际子类中就必须给出unionWith的实
现,而实际上我们无法给出具有同样函数原型
的函数的实际实现。
为说明这个问题,假设我们要把setA定义为set
的非抽象的子类,现在考虑它的定义以及其中
纯虚函数unionWith的规范。按照需要,我们应
该写:
template <class T> class setA : public set<T>
{
... ...
virtual void unionWith (const setA<T> & s);
... ...
}
但是,在这样定义好类规范并建立的setA所有成
员函数之后,会发现仍然无法建立setA的实例对
象。
例如,如果我们写出下面的变量定义
setA<int> s1;
程序编译时会发生错误,系统将告诉我们:在建
立setA的实例时,遇到了没有给出定义的纯虚函
数void unionWith(const set<int>&)。
因为在上面setA里定义的unionWith函数的参数
类型与set里unionWith的参数不同,这将被认为
是对原函数名的另一个重载定义,而原来的纯虚
函数仍然没有定义。
显然,在setA里把unionWith定义为:
virtual void unionWith (const set<T>& s);
是没有意义的,不会有抽象类的实例成为实际参
数,因为根本没有这种实例。
上述问题是可能通过其他途径绕过去的,但
那将使问题大大的复杂化,给出的定义很费事,
理解起来也比较困难。这里选择的是一种较自然
方便的做法。
9.2 位向量集合
9.2.1 位向量
算法与数据结构
18
可以用一个二进制向量来表示一个整数集合,用
向量的各个位指明该位置对应的整数值是否包含
在集合里。例如用一个位置放0表明对应的值不
在集合中;放1表示在集合中。这时实际数据并
不存放在集合的表示里。
算法与数据结构
19
一个位取值0或1,位的运算从一个或两个位出发,计算
出0/1结果。常见的运算有四个:
位“否定” 一个运算对象,对象为1时得到值0,对象值
0时得到值1
位“与” 两个运算对象,如果它们的值都是1时运算
结果是1,其他情况结果是0
位“或” 两个运算对象,如果它们的值都是0时运算
结果是0,其他情况结果是1
位“异或” 两个运算对象,如果恰有一个运算对象的值
为1时结果是1,否则结果是0
算法与数据结构
20
C++ 基于上述位运算定义了4个字位运算符,字位运
算符把整型对象当作二进制位的序列,对各个位分别
做位运算,得到结果的各个位。
另外还有字位左右移运算符,它们也把整数看作二进
制序列,将其内容向左或右移动,移出的丢掉,缺位
补 0。
这些运算采用的运算符如下:
字位“否定”
~
字位“与” &
字位“异或”
字位左移
字位右移
<<
字位“或” |
>>
算法与数据结构
^
21
无符号字符表示的二进制序列从低位向高位顺序编
号为第0到7位。
假设x和y都是8位字符,其值分别是:
x:
y:
01010111
11011010
对x和y做各种字位运算,得到的结果如下:
~x 10101000
x & y
01010010
x ^ y
10001101
x | y
11011111
x << 3
10111000
y >> 5
00000110
算法与数据结构
22
掩盖码(mask)是程序中专门写出或者设法构造的
二进制串,目的是为与其他值(作为二进制串)进行
位运算。
字位运算时通常要构造掩盖码:或直接用字面形式写
出,或利用已有的东西用字位运算计算出来。
直接写掩盖码时常用十六进制或八进制整数写法,这
些写法与二进制的对应一目了然,这也是C++ 中十六
进制(和八进制)整数表示的一种主要用途。
算法与数据结构
23
现在考虑定义一种新的结构——位向量,即每个
元素都是0/1值的向量。我们知道,计算机里所有
的值实际上最终都是用二进制方式存储的,所以
在定义位向量时可以采用许多不同的表示方式。
由于一个字符包含8个二进制位,用它可以表示位
向量中的8位值,这样用一系列字符就可以表示任
何位向量,其中每个字符表示位向量中的8个元素。
算法与数据结构
24
下面定义的类bitVector(类3.3)采用了这种编码方
式。在这个类定义中用了一个vector类型的数据
成分,其元素是无符号字符。这样,在这个新类
的实现中就不必再考虑数据存储的动态分配和回
收,以及下标取值的合法性检查等许多麻烦工作。
算法与数据结构
25
class bitVector {
public:
// 构造函数
bitVector (unsigned numOfElements);
bitVector (const bitVector & source);
// 合法下标值
unsigned
length ( ) const;
// 位操作
void
set (unsigned index);
void
clear (unsigned index);
int
test (unsigned index) const;
void
flip (unsigned index);
int
allZeros() const;
void reset();
friend class bitSet; // 定义bitSet为友类,以提高一些操作的效率
protected:
vector<unsigned char> bitValues;
// 位的字节定位和取值,内部使用
unsigned
byteNumber (unsigned index) const;
unsigned
mask (unsigned index) const;
}; 类9.2 bitVector类的规范说明算法与数据结构
26
下面考察位向量类里各成员函数的实现,这里的
构造函数不需要做什么特殊工作,只需要直接调
用vector类的功能。第一个构造函数里首先根据
实际位向量对位数的要求计算出所需要的字节数,
然后通过vector类的构造函数完成工作。由表达
式(num + 7)/8计算出来的字节数能够保证容纳下
所要求的位,各个字节首先都被赋初始值0。类的
复制构造函数也非常简单。
算法与数据结构
27
bitVector::bitVector (unsigned num)
: bitValues ((num + 7)/8, 0)
{ // 无需其他动作
}
bitVector::bitVector (const bitVector & s)
: bitValues (s.bitValues)
{ // 无需其他动作
}
算法与数据结构
28
在一个字节里可以存放8个二进制位,因此整个
位向量中能够存放的位数很容易借助bitValues
的长度计算出来。用下面方法计算出的值总是8
的倍数,这样得到的结果可能与建立位向量时所
给的位数值不同。需要的话我们也可以采用另一
种方式,改变位向量的定义,在位向量里增加一
个整数成分,记录实际保存的确切位数。
unsigned bitVector::length ( ) const
{ // 每个向量元素存储8个位
return 8 * bitValues.length ( );
}
算法与数据结构
29
位向量的基本操作是位运算,我们的类定义提供
了四个基本操作:set、clear、test和flip,它们
分别完成对指定“位”的设置、清除、测试和翻
转。这些操作的实现都使用了两个内部定义的辅
助函数。
算法与数据结构
30
第一个辅助函数是byteNumber,以位的指标作为
参数,计算出该位所在的向量元素的指标。
由于每个向量元素(字节)存储了8个位,所以只要用
位的位置指标除以8就可以得到相应的字节指标。除
以8的运算可以利用C++语言里的位右移运算方便地
实现,把一个整数(作为二进制串)向右移三位,就能
够得到所需要的结果。因此,这个函数可以简单地
定义为:
unsigned bitVector::byteNumber (unsigned
index) const
{
return index >> 3;
算法与数据结构
}
31
第二个辅助函数是mask,它从位指标出发计算出
一个字符掩盖码,这个掩盖码是一个8位的字符,
它只在所需要的位上取值1,在其他位上都取值0。
利用这个掩盖码能够实现对一个字节里的某个确定
位的操作。
为了得到掩盖码,函数mask首先取出位指标的最
低三位,得到一个0到7的整数值;然后按这个整数
值把数 1 向左移位。
算法与数据结构
32
unsigned bitVector::mask (unsigned index)
const
{ // 按 index 最低三位将 1 左移,得到掩盖码
return 1 << (index & 07);
}
算法与数据结构
33
要设置位向量中的某个位,首先要找到该位所在的向
量元素(字符),无论那里原来的值是什么,置位操作
都用语言中的位运算把所确定的位设置为1,在这个
操作中还必须保持其他的位不变。
利用辅助函数得到的掩盖码加上C++语言的“按位或”
运算(以及赋值)正好能够完成这个工作:
void bitVector::set (unsigned index)
{ // 将指定二进制位置 1
bitValues[byteNumber(index)] |= mask (index);
}
算法与数据结构
34
清除一个二进制位就是把这个位赋值为0,同时保持其他
位不变。
完成这个操作需要用C++语言的“按位与”运算。首先对
掩盖码按位求反,使其中唯一的那个1位转变为0,而其
他的位都变成1;然后用这个结果与原字节做“按位与”。
这样,字节里其他的位都不会改变,而指定位被清除:
void bitVector::clear (unsigned index)
{ //将指定位清0
bitValues[byteNumber(index)] &= (~
mask(index));
}
算法与数据结构
35
对一个位做测试的操作非常简单,用计算出的掩盖码
与原字节做按位与运算,将得到的结果与0比较,就可
以做出正确判断:
int bitVector:: test (unsigned index) const
{ //检测指定位
return 0 != (bitValues[byteNumber(index)] &
mask (index));
}
算法与数据结构
36
最后一个操作是将指定位翻转,无论它原来的值是什么。
利用C++语言的按位异或(按位加)操作,将掩盖码与原
字节运算,就能够完成这个工作。
void bitVector::flip (unsigned index)
{ // 翻转指定的位
bitValues[byteNumber(index)] ^= mask
(index);
}
算法与数据结构
37
函数reset将位向量中所有的位置为0,函数isAllZeros判断
是否所有的位都是0:
void bitVector::reset() {
int len = bitValues.length();
for (int i = 0; i < len; i++)
bitValues[i] = 0;
}
int bitVector::isAllZeros() const {
int len = bitValues.length();
for (int i = 0; i < len; i++)
if (bitValues[i] != 0)
return 0;
return 1;
}
位向量的应用:Eratosthenes筛法
早在公元前三世纪,古希腊的数学家和哲学家
Eratosthenes就提出了发现素数的一种经典方
法: Eratosthenes筛法,或简称为筛法,这实际
上已经是一种算法。下面把筛法的实现作为二进
制向量应用的一个例子。
筛法的基本思想就是从2开始取一段连续的整
数序列片段。在操作过程中逐个划掉那些不可能是
素数的数。当所有不是素数的数被去掉之后,序列
里剩下的就全都是素数了。
要实现筛法程序,首先要考虑数据的表示。
虽然我们可以直接存储整数值,但这样做太浪费空
间,因为每个数都要用一个整数表示。下面的算法
里采用二进制向量表示数的序列:如果一个数还在
序列中,则它对应的位的值就是1,如果一个数被从
序列中删除了,它对应的位就被清除为0。虽然需要
处理的整数总是由 2 开始,但为了简单起见,在算
法里仍然用指标0表示数0,向量里位置为0和1的两
个二进制位根本不用。
下面算法的基本思路是:用一个指标沿着二进制序
列移动,每步总把这个指标移到下一个没有被删除
的数的位置。开始时把指标放在2的位置(因为2是素
数)。在这第一步里,由指标指定位置开始,删除后
面所有的2的倍数。完成这一步后更新指标,使它移
到下一个没被删除的数的位置上(第一次移动将使指
标到达 3 的位置)。在每一次移动后,同样删除指标
所对应的数的所有倍数(实际上,只要从这个数的平
方的位置开始删除,因为小于这个数的平方值的该
数的所有倍数都已经在前面的处理中被删去了)。这
样一个过程一直进行,直到指标达到了序列里最大
的数的平方根所在的位置,在序列中余下的就都是
素数了。要说明这个结论,道理非常简单:如果一
个数不是素数,它必定有一个不大于它的平方根的
因数。
void sieve (unsigned imax)
{ int i, j;
bitVector data (imax);
// 把所有的位都置为1
for (i = 2; i < imax; i++) data.set(i);
// 顺序扫描
for (i = 2; i*i <= imax; i++) {
if (data.test(i)) { // 找到了下一个素数
for (j = i * i; j <= imax; j += i)
data.clear(j); // 清除i的所有倍数 } }
for (i = 2; i < imax; i++) // 输出
if (data.test(i)) cout << i << '\n'
}
程序9.1 筛法程序
9.2.2位向量集合
用二进制向量表示自然数的集合,用
向量的各个位指明该位对应的下标(是一个
自然数)是否包含在这个集合里:如果一个
位的值为0,就表明对应的自然数不在集合中,
1表示对应的自然数在集合中,而实际自然数
数值就不需要存放在集合表示里了。
实际上,Eratosthenes筛法采用的就是
这种方法,它最终确定的就是某个范围里所
有素数的集合。
把这个想法推广的一般情况,就得到
了位向量集合。
实际上,我们是想用位向量数据结构表示
一个有限集合的各种子集。只要事先给这
个有限集的元素编号,也就是说,给集合
的每个元素确定一个自然数作为编号,用
这个编号对应的位表示这个元素的存在性。
以位向量数据结构为基础实现集合,集合
的各种运算都可以通过对位向量的基本操作实
现,也就是通过对位向量中各个位的操作实现。
实际上,只有两个集合都是某个集合的子
集时,它们互相运算才是有效的。由于在位向
量里并没有关于集合元素的进一步信息,这里
只能要求参与运算的两个位向量位数相同。下
面首先给出位向量集合类的定义。
class bitSet : public set<unsigned>{
public:
bitSet(unsigned);
bitSet(const bitSet&);
~bitSet();
virtual int isEmpty() const { return vec.isAllZeros(); };
virtual int includes (const unsigned val) const { return vec.test(val);
virtual void add (const unsigned val) { vec.set(val); };
virtual void remove (const unsigned val) { vec.clear(val); };
void intersectWith (const bitSet&);
void unionWith (const bitSet&);
void differenceFrom (const bitSet&);
int subset (const bitSet&);
int operator == (const bitSet&);
int length() const { return len; };
void deleteAllValues() { vec.reset(); };
protected:
bitVector vec;
unsigned len;
};
把集合运算增加到位向量数据结构上是不难
的。各个运算都能通过对基本数据向量的循环操
作,在其中对向量中对应位的相应操作实现。这
里的要求是:只有在参与运算的两个位向量位数
相同时运算才有效。
算法与数据结构
47
两个位向量集合的求并运算,用按位“或”运
算实现。例如:
接受位向量为
参数位向量为
求并的结果位向量为
10011001
00111000
10111001
算法与数据结构
48
void bitSet::unionWith (const bitSet& s) {
assert(len == s.len);
const unsigned n = vec.bitValues.length();
for ( unsigned i = 0 ; i < n ; i++)
vec.bitValues[i] |=
s.vec.bitValues[i];
}
算法与数据结构
49
注意,上面函数定义里的length()求出接受
位向量的长度,即集合中的元素个数,而用
bitValues.length()求出的是位向量的数据表
示(即所用字符向量Vector<unsigned char>)
的长度,在这里是向量存储占据的字节数。
算法与数据结构
50
集合交集由两个集合的公共元素组成。可通过
位“与”操作实现。例如:
接受位向量为
10011001
参数位向量为
00111000
求交的结果位向量为 00011000
算法与数据结构
51
void bitSet::intersectWith (const bitSet& s) {
assert(len == s.len);
const unsigned n = vec.bitValues.length();
for (unsigned i = 0; i < n; i++)
vec.bitValues[i] &=
s.vec.bitValues[i];
}
算法与数据结构
52
一集合与另一集合的差集由所有存在于第一
个集合中且不在第二个集合中出现的元素组成,
可以用对第一个集合与第二个集合的逆做与运算
的方式求得。
接受位向量为
10011001
参数位向量为
00111000
参数的逆为
11000111
求差的结果位向量为
10000001
算法与数据结构
53
void bitSet::differenceFrom (const bitSet& s) {
assert(len == s.len);
const unsigned n = vec.bitValues.length();
for ( unsigned i = 0; i < n; i++)
vec.bitValues[i] &= ~s.vec.bitValues[i];
}
算法与数据结构
54
另外两个运算用于比较两个集合:
1)一个是相等性检测,用于判断两个集合是否相等;
其实现只需要比较两个集合的元素是否相同。
2)另一个是子集判断函数,当参数表示的集合是接
收集的子集时返回真值。
其实现方法是:首先用位“与”操作求交集,然后判
断该交集是否与参数集相等。
算法与数据结构
55
nt bitSet::operator == (const bitSet& s) {
if (len != s.len) return 0;
const unsigned n = vec.bitValues.length();
for (unsigned i = 0; i < n; i++)
if (vec.bitValues[i] != s.vec.bitValues[i])
return 0;
return 1;
}
算法与数据结构
56
int bitSet::subset (const bitSet& s) {
if (len == s.len) return 0;
const unsigned n =
vec.bitValues.length();
for (unsigned i = 0; i < n; i++)
if ( s.vec.bitValues[i] !=
(vec.bitValues[i] & s.vec.bitValues[i]) )
return 0;
return 1;
}
算法与数据结构
57
注意,以上各运算的运行时间与数据集合的大小
bitvalues.length()成正比,但与集合中元
素的个数(位向量中取值为1的位数)无关。
算法与数据结构
58
最后是求集合大小的函数size:
int bitSet::size () const {
int n = 0;
for (int i = 0; i < len; i++)
if ( vec.test (i) ) n++;
return n;
}
9.2.3字符集合
算法与数据结构
60
位向量集合的一个常见特例是字符集,也就
是以字符作为元素的集合。在字符串的一章里已经
讲过,C++ 语言中的字符是用小的整数(字符的编
码)实现的。由于被考虑字符集中只有256个元素
(这里考虑的是扩展的ASCII字符集),可以把字符
集类charSet定义为位向量集合类的子类。
算法与数据结构
61
class charSet : public bitVector
{
public:
//构造函数
charSet( );
charSet(char *);
charSet(const charSet &);
//集合运算
void
add(char ele);
void
deleteAllValues( );
int
includes(char ele)const;
void
remove(char ele);
};
类9.4 charSet类的规范说明
算法与数据结构
62
类charSet有一个构造函数,可以用字
符串来给字符集赋初值。
算法与数据结构
63
charSet::charSet(char *initstr) :
bitVector(256)
{ //用字符串中的字符初始化集合
while(*initstr) add(*initstr++);
}
算法与数据结构
64
字符集的插入,删除和检测操作在这里重新
定义,以便描述元素为字符的处理,但实现中都
转换为其父类(bitVector)的相关操作。为
此,首先将字符强制转换为整型量,然后再分别
处理。
算法与数据结构
65
void charSet::add(char ele)
{
set((unsigned int)ele);
}
算法与数据结构
66
int charSet::includes(char ele) const
{ //如果集合包含变元字符则返回真值
return test((unsigned int )ele);
}
算法与数据结构
67
void charSet::remove(char ele)
{ //从集合中除去参数字符
clear((unsigned int) ele);
}
算法与数据结构
68
因为字符集是位向量集类的子类,所以在前面
位向量集的各种集合运算(如求并,求交,子集检
测等)仍然可以使用。
算法与数据结构
69
9.2.4字符集类的应用——将字符串分解为单词
算法与数据结构
70
本小节将通过一个把字符串分解为单词的
例子来说明字符集的应用。首先需要建立两个
辅助函数:第一个函数skipOver,它以一个字
符串,一个位置和一个称为“无效字符集”的
字符集为参数。
算法与数据结构
71
位置参数假设是字符串文本的一个有效索引。它
的作用是跳过字符串里连续的所有在“无效字符
集”中出现的字符。方法就是只要位置指示的那
个字符属于“无效字符集”,位置就加1,直到
找到第一个不属于“无效字符集”中的字符或者
达到串的末尾时程序结束。
算法与数据结构
72
unsigned int skipOver(string & text,
unsigned int position, const charSet &
skipchars)
{ //只要字符属无效字符集则循环
while ((text[position] != ‘\0’) &&
skipchars.includes(text[position]))
position = position + 1;
return position; //返回当前位置
}
算法与数据结构
73
函数skipTo的功能正好相反,它跳到字符串
里下一个“无效字符”出现的字符处停止,返回
当前无效字符的位置。遇到文本字符串的结束符
时也结束。
算法与数据结构
74
unsigned int skipTo (string & text,
unsigned int position, const charSet &
haltset)
{
while ((text[position] != '\0') &&
!haltset.includes(text[position]))
position = position + 1;
return position;
}
算法与数据结构
75
用这些函数解决把字符串分解为一个个单词的
问题。为了实现分解过程,首先确定每个单词的起
始位置和长度,这些数据可以保存在一个“位置向
量”中,位置向量的每个元素属于一个仅定义了两
个数据项,而未定义任何操作的简单类
wordPosition:
算法与数据结构
76
class wordPosition
{
public:
int startingPos;
int length;
};
算法与数据结构
77
findSplitPositions函数以一个字符串,
一个分割字符集和一个位置向量为参数。函数
执行中如果必要的话,可以改变位置向量的大
小。这个函数的返回值是分解后得到的单词的
总数。
算法与数据结构
78
int findSplitPositions (string & text,
const charSet & separators,
vector<wordPosition> & splits)
{ // 确定每个单词的起始位置和长度
int lastpos = text.length();
int numberOfWords = 0;
算法与数据结构
79
//循环,寻找每一个单词
int position = 0;
while (position < lastpos) { // 跳过分割字符
position = skipOver( text, position , separators);
if (position < lastpos)
{
if (numberOfWords >= splits.length())
splits.setSize(splits.length() + 5);
splits[numberOfWords].startingPos = position;
//跳过非分割字符
position = skipTo(text,position, separators);
//计算单词长度
splits[numberOfWords].length =positionsplits[numberOfWords].startingPos;
numberOfWords += 1; } }
算法与数据结构
80
//返回单词总数
return numberOfWords;
}
程序9.2 在字符串中找出各单词的位置
算法与数据结构
81
现在可以用split函数解决我们提出的问题,
它先调用findSplitPositions函数决定单词
的起点和长度,然后用子串操作把每个单词从字
符串文本中抽取出来。
算法与数据结构
82
void split(string & text, const charSet & separators,
vector<string> & words)
{ //调用计算单词起始位置和长度的过程
vector<wordPosition> splitPos(2);
int numberOfWords =
findSplitPositions(text,separators,splitPos);
//现在可以进行分解
words.setSize(numberOfWords,“”);
for (int i = 0 ; i < numberOfWords; i++)
words[i] = text(splitPos[i].startingPos,
splitPos[i].length);
}
程序9.3 将字符串分解为单词
算法与数据结构
83
9.3 集合的表实现
算法与数据结构
84
在这节里,我们将考虑利用表实现集合的方法,
也就是说,在集合对象中用一个表数据成分,借
助它存储集合的元素。根据这个想法定义的
setList见类9.5。
算法与数据结构
85
template <class T> class setList : public set<T>
{
protected:
list<T> slist;
public:
setList();
// 构造函数
setList(const setList<T> &);
virtual inline int isEmpty() const { return slist.isEmpty(); };
virtual inline int includes (T val) const { slist.includes (val) }
virtual void add (T val); // 往集合里增加新元素
virtual void remove( T ); // 删除
void unionWith(const setList<T> &);
// 求并
void intersectWith(const setList<T> &);
// 求交
void differenceFrom(const setList<T> &);
// 求差
int subset(const setList<T> &) const;
// 子集判断
int operator == (const setList<T> &) const;
// 相等判断
void deleteAllValues() { slist.deleteAllValues(); };
friend class setListIterator<T>;
};
类9.5 setList类的规范说明
算法与数据结构
86
类setList的构造函数非常自然。其中的无参构造
函数建立空集合,也就是说,建立一个有着空表
slist的集合;复制构造函数建立一个相同的集合,
实际上就是在建立集合时做一次表的复制。这些
函数很容易利用表的构造函数实现。
算法与数据结构
87
由于集合对元素有唯一性要求,在向一个集合里
增加元素前,必须首先检查它是否已经存在于表
slist中。向集合里插入元素的函数实现如下:
算法与数据结构
88
template <class T> void setList<T>::add(T val)
{ //仅当元素不在集合中时作插入
if (!slist.includes (val) ) slist.add(val);
}
算法与数据结构
89
由于在插入前要检查存在性,所以这个增加新元
素的操作也具有线性时间的复杂性。
算法与数据结构
90
要实现在集合中删除一个元素的运算,需要借助
于表遍历器,通过它找到被删除元素,然后执行
实际删除动作。
算法与数据结构
91
template <class T> void setList<T>::remove(T val)
{
listIterator<T> itr (slist);
for (itr.init ( ); !itr; ++itr)
if (itr ( ) == val) {
itr.removeCurrent ( );
return;
}
}
算法与数据结构
92
removeCurrent是listIterator里定义的方法。显然,
函数remove的时间代价也是O(n)。
算法与数据结构
93
下面看求并集操作的实现。应该注意,setList类的成
员函数unionWith要求它的参数必须是同类型的,也
就是说,参数集合的元素必须与接受集合的元素具
有同样类型,是用同一个T类型定义的。如果不是同
样类型,本操作没有定义。
算法与数据结构
94
template <class T> void setList<T>::unionWith (const
setList<T> & s)
{
listIterator<T> itr (s.slist);
for (itr.init (); ! itr; ++itr)
add ( itr () );
}
算法与数据结构
95
由前面分析中已知add的执行时间为O(n),所以
unionWith的执行时间为O(n2)。用类似的方式可以实
现intersectWith和differenceWith等运算。下面给出子
集测试方法的实现,这里是按照子集概念直接给出的
实现。
算法与数据结构
96
template <class T> int setList<T>::subset (const
setList<T> & s) const
{
listIterator<T> itr (s.slist);
for (itr.init (); ! itr; ++itr)
if ( !slist.includes ( itr () ) ) return 0;
return 1;
}
算法与数据结构
97
template <class T> int setList<T>::operator ==
(const setList<T> & s) const
{ //两个集合互为子集时它们相等
return subset (s) && s.subset (*this);
}
算法与数据结构
98
集合还可以采用其他很多实现方式。例如,用排
序树实现集合也很常见。用排序树作为基础数据
表示方式,定义集合的方法与setList相似,这里
就不再重复了。读者可以把这个问题作为自己的
练习。
算法与数据结构
99
9.4 关联与字典
算法与数据结构
100
下面先讨论作为字典元素的关联,定义关联的类,
然后再讨论字典的有关问题。
算法与数据结构
101
字典(dictionary)是一类特殊集合,其元素是一种
二元组,二元组的两个成分分别被称为该元素的
“关键码”和“值”,这种数据元素通常被称为
“关联”。
算法与数据结构
102
字典是关联的集合,那么一个字典就是由一组关键
码(一个集合)到一组值(另一个集合)的对应关
系:
如果一个字典里所有的关键码互不相同,那么它可
以看作是由关键码集合K到值集合V的一个映射。在
实践中使用较多的就是这种关键码具有唯一性的字
典。人们有时也把字典称为映射(mapping) 。
算法与数据结构
103
以英汉词典为例,我们可以把其中的每个词条看
作一个元素,词条的有关单词看作是该元素的关
键码,词条中的其他信息(对单词的解释,例句
等的总和)是元素的值。
算法与数据结构
104
对字典元素的存取通过关键码进行,这一点与向量有
类似之处。存取字典元素可以看作是通过关键码这样
的(广义)“索引”进行元素访问,所以人们也把字
典看作是一种索引结构。与向量不同的是,字典中的
关键码可以具有任意类型,而向量的“关键码”只能
是整数。为了实现字典的有关功能,关键码也必须作
为字典元素的一部分进行存储,这一点也与向量不同
(在向量中只存储元素的值,下标并不存储,由元素
的位置隐含地规定)。
算法与数据结构
105
本小节首先介绍作为字典元素类型的关联类
association (如类9.6描述)。一个关联中的关键码取
某个确定值(具有KEY类型),一旦被设置后就不能
再改变了。而元素的值(具有VALUE类型)是公有的,
在该元素的存在期间可以改变,重新赋值。
算法与数据结构
106
两个关联可以进行比较。只要具有相同的关键码,
就认为是同一个关联。也允许用一个关联与一个
关键码比较,如果它们的关键码相同,这个比较
就返回“真”值。
算法与数据结构
107
类似地,程序里可以用一个关联给另一个关联赋值,
或者直接用一个值给关联赋值。要注意,这时只是
关联的值域被改变,而其关键码则始终保持不变。
此外,在类9.6里还定义了取关键码和取值域的两个
成员函数。
算法与数据结构
108
template <class KEY, class VALUE> class association
{
public :
VALUE valueField; // 值域能公开存取
// 构造函数
association (KEY initialKey, VALUE initialValue);
// 可以用关联或者单独的值进行赋值
void operator = (association<KEY, VALUE> &);
void operator = (VALUE val);
// 比较函数
int operator == (const association<KEY, VALUE> &);
int operator == (const KEY & key);
//取关键码和值的函数
KEY key ( ) const;
VALUE value ( ) const;
protected :
//关键码域一旦确定不能变动
const KEY keyField;
};
类9.6 association类的规范说明
算法与数据结构
109
关联类的所有函数都非常简单,请读者自己给出。
另外,关联比较函数和关联与关键码的比较函数
也可以作为普通函数定义。这里都不再重复了。
算法与数据结构
110
实现字典结构一个最直截了当的方法就是把字典定
义为关联的集合。但是必须注意一些问题,如果要
采用这种方式,我们就需要把集合中的元素比较函
数抽象出来,允许在定义字典的时候重新给以定义。
这是因为字典元素的比较必须用另外一种方式,在
这里只比较元素的关键码。
算法与数据结构
111
元素的提取本身也提出了一个新问题,因为在字
典操作中可能需要对元素的值域重新赋值,而又
不允许改变其中的关键码部分。按照这个思路整
合集合和字典数据结构的工作留给读者自己考虑。
算法与数据结构
112
下面讨论中采用了另一条途径。我们将首先定义
一个字典抽象类,描述字典类的公共界面,而后
考虑不同的实现。字典抽象类的定义见类9.7。
算法与数据结构
113
template <class KEY, class VALUE> class dictionary
{
public:
// 字典基本操作
virtual int isEmpty() const = 0;
virtual int includesKey(KEY key) = 0;
virtual VALUE & operator [](KEY key) = 0;
virtual void removeKey(KEY key) = 0;
virtual void deleteAllValues() = 0;
virtual void setInitValue(VALUE initValue) = 0;
protected:
// 查找与一个关键码对应的关联,内部使用
virtual association<KEY, VALUE>
*associatedWith(KEY) = 0;
};
类9.7 字典抽象类
算法与数据结构
114
基本操作isEmpty和includesKey的意义很清楚;
deleteAllValues用于清空字典,删除其中所有元素;
removeKey删除与一个关键码相关的字典元素。这里
还为元素集合设了一个默认初始值,新建立元素的值
域先用这个值设置。函数setInitValue用于改变字典元
素的初始值,这种改变则将影响以后的建立元素操作。
下标运算符[]用于从关键码出发对值进行的访问,它
返回一个引用,可以放在赋值号的左边或右边。
算法与数据结构
115
假设某字典d的关键码是字符串类型,值为整数,
d[“abc”] 表示要访问以“abc”为关键码的元素的值。
如果字典里没有这个元素,访问将建立一个取默认
值的新元素。类中的associatedWith是内部使用的函
数,以方便其他函数的实现。
算法与数据结构
116
9.5 字典的关联表实现
下面考虑字典实现的第一种技术:将字典里的元
素集合表示为一个以关联作为元素的表。类9.8给
出了有关定义。
算法与数据结构
117
template <class KEY, class VALUE>
class dictionaryList : public dictionary<KEY, VALUE>
{
public:
// 构造函数
dictionaryList();
dictionaryList(VALUE initVal);
dictionaryList(const dictionaryList & d);
// 基本操作
int isEmpty() const;
int includesKey(KEY k);
VALUE & operator [](KEY k);
void removeKey(KEY k);
void deleteAllvalues();
void setInitValue(VALUE initVal);
protected:
// 查找与一个关键码对应的关联,内部使用
association<KEY, VALUE> * associatedWith(KEY k);
// 数据存储在一个关联的表中
list<association<KEY, VALUE> *> data;
VALUE initValue;
// 友元
friend class dictionaryListIterator<KEY,
VALUE>;
算法与数据结构
};
118
字典的构造函数很简单,其主要工作通过调用list类
的构造函数完成,这里附加的操作就是设置字典元素
的初始值initValue。程序员可以显式地为字典的新元
素指定初值:既可以在构造字典对象时直接指定,也
可以在随后用setInitValue函数重新设置。
算法与数据结构
119
清除字典结构中所有元素的值,检测字典是否为
空等操作都可以很容易地映射到表的类似操作上。
另外的几个主要操作的定义如下。
associatedWith是个内部操作,许多其他操作的实
现中都调用了它。associatedWith根据给定关键码
返回一个指向对应关联的指针,要找的关联不存
在时函数返回空指针值。
算法与数据结构
120
template <class KEY, class VALUE>
association<KEY, VALUE> * dictionaryList<KEY,
VALUE>::associatedWith(KEY key)
{ // 返回具有给定关键码的关联,没有时返回空指针
listIterator<association<KEY, VALUE> *> itr(data);
// 循环,寻找匹配元素
for (itr.init(); ! itr; ++itr)
if (itr()->key() == key)
return itr(); // 返回到匹配元素的指针
return 0; // 没找到,返回空指针
}
算法与数据结构
121
公用操作includesKey判断字典中是否包含具有给
定关键码的关联项,函数以给定关键码作参数调
用associatedWith函数,返回“真”表示存在这种
项。
算法与数据结构
122
template <class KEY, class VALUE>
int dictionaryList<KEY, VALUE>::includesKey(KEY key)
{ // 若存在满足条件的关联,则说明元素在字典中
return associatedWith(key) != 0;
}
算法与数据结构
123
下标操作以关键码做参数调用associatedWith。从得到
的关联中取出值域部分。如果字典里当时并不存在与
关键码匹配的关联,那么这个操作中将建立这样一个
新关联。在这个情况下返回的元素值域里总是
initialValue值。注意,由于下标操作返回的是对
VALUE类型的引用,如果不建立新关联而直接把字
典类中initialValue数据域返回,对下标操作结果的后
继操作可能会造成错误(例如要对它赋值,实际就会
改变字典里的元素初始值)。
算法与数据结构
124
template <class KEY, class VALUE>
VALUE & dictionaryList<KEY, VALUE>::operator [] (KEY key)
{ // 返回由关键码指定的关联的值
// 先看关联是否已经存在
association<KEY, VALUE> * newassoc = associatedWith(key);
if (!newassoc) // 如果要找的关联不存在,那么就创建一个新关联
{
newassoc = new association<KEY, VALUE>(key, initValue);
assert(newassoc != 0);
data.add(newassoc);
}
// 返回对值域的引用
return newassoc->valueField;
}
算法与数据结构
125
要从字典中删去一个关联,操作的参数应指明被删
关联的关键码。函数里先按给定关键码搜索,寻找
与之匹配的关联。如果找到,就把这个关联从字典
中删掉。
算法与数据结构
126
template <class KEY, class VALUE>
void dictionaryList<KEY, VALUE>::removeKey(KEY key)
{
//通过循环,在元素中查找关键码
listIterator<association<KEY, VALUE> *> itr(data);
for (itr.init(); ! itr; ++itr)
if (itr()->key() == key)
itr.removeCurrent();
}
算法与数据结构
127
dictionaryList类的遍历器通过list遍历器生成。该
遍历器类继承list遍历器的所有功能,这里唯一需
要定义就是新构造函数,其他功能都由表遍历器
提供。
算法与数据结构
128
template <class KEY, class VALUE> class
dictionaryListIterator
: public listIterator<association<KEY, VALUE> *>
{
public:
//构造函数
dictionaryListIterator(dictionaryList<KEY, VALUE> &
dict);
};
类9.9 字典遍历器类的规范说明
算法与数据结构
129
新构造函数可以简单地利用listIterator的构造函数实现:
template <class KEY, class VALUE>
dictionaryListIterator<KEY, VALUE>::
dictionaryListIterator(dictionaryList<KEY, VALUE> & dic)
: listIterator<association<KEY, VALUE> *>(dic.data)
{ //没有其他初始化动作
}
算法与数据结构
130
由这个遍历器的取当前值操作返回的是一个关联,
通过使用key( )和value( )操作,就能够得到所需
要的数据成分。
算法与数据结构
131
小 结
集合数据结构的特点在于它的运算常常是针对整
个集合进行的。例如对两个集合求并、求交、求
差集、判断子集关系、做相等性检测等。
算法与数据结构
132
本章介绍了集合的许多不同实现方法。bitVector
用位向量方法实现集合。字符集charSet是这种集
合的特例。本章讨论的另一种集合的实现方法是
建立在表的基础上,也可以在其他结构的基础上
建立集合。
算法与数据结构
133
字典是关联的集合,其主要用途是存储和检索。
这里首先定义了字典抽象类,然后讨论了字典的
一种实现方法,基于表的实现,并讨论了字典的
若干应用。与集合的情况类似,字典也可以利用
前面讨论过的其他一些数据结构实现。
算法与数据结构
134
下一章里还要介绍集合和字典的另外一种常见实
现方式,其基本想法与本章介绍的方式完全不同。
算法与数据结构
135
dictionary和模版类orderedList基础上的,而后者
又是在list 类的基础上建立的。
(3) 排序字典中使用了的关联类cAssociation, 它建
立在association类基础之上。
(4) 排序字典的模板参数是string类和由整数组成
的setList 类型。setList 是在list 抽象类基础上建
立。
(5) split过程要求参数是字符串、一个字符串向量
和一字符集。在过程内部还使用了一个词组成的
向量。
(6) charSet类建立于bitSet类的基础上,后者又建
立在set和字符向量的基础之上。
此外这里还使用了一些辅助类,如链类以及被用
来打印最后结果的各种遍历器类等等。
算法与数据结构
136
下面对成员函数与普通函数的有关问题作一点讨论。
在仔细阅读本书的过程中,读者会发现,不同的类
里的各种运算函数有时候被定义为普通函数,有时
被定义为成员函数。只有少量的运算,如赋值运算,
在各种类里始终都定义为成员函数。在本章之前,
我们一直把相等性检测定义为普通函数。但在本章
的若干个类中,又把相等性检测等定义为成员函数。
算法与数据结构
137
把一个运算定义为普通函数,这样做的主要优点在于
运算的两个分量(参数)都能够使用隐式类型转换。而
对于成员函数来说,只有作为函数参数出现的分量才
能被转换。一般说,这种隐式类型转换在写算术或关
系表达式时通常是很重要的。
算法与数据结构
138
把一个运算定义为成员函数,得到的主要好处是运
算中能直接使用结构内部的数据域。这就是我们在
位向量集合类中把相等性检测定义为成员函数的原
因:因为它需要直接使用内部的字符向量。当然这
种需要也可以通过另一条途径实现,那就是把相等
性检测定义为位向量类的一个友元函数。
算法与数据结构
139
一般说来,对多数运算而言,究竟是作为普通函
数还是作为成员函数定义,这里并没有严格的界
限。通常是凭程序员的经验,根据实际情况的需
要决定。
算法与数据结构
140
主要概念
集合及其运算(并、交、差、子集、相等);位
向量;集合的位向量实现;字符集合;集合的表
实现;字典与关联;字典的关联表实现;可比较
关联;字典的排序表实现;稀疏矩阵。
算法与数据结构
141