1.1 联合数组:更直接、更有效的访问
1.2 词条(entry)~ 映射/词典(Map/Dictionary)
1.3 词典
template <typename K, typename V> //key、value
struct Dictionary {
virtual int size() = 0;
virtual bool put( K, V ) = 0;
virtual V* get( K ) = 0;
virtual bool remove( K ) = 0;
};
2.1 电话:号码 ~ 人
2.2 电话簿
2.3 散列表 / 散列函数
桶(bucket):直接存放或间接指向一个词条
Bucket array ~ Hashtable(哈希表)
定址/杂凑/散列
散列函数: hash():key->&entry
“直接”:expected-O(1)≠O(1)
2.4 实例
3.1 同义词(synonym)
k e y 1 ≠ k e y 2 key_1≠key_2 key1=key2 but h a s h ( k e y 1 ) = h a s h ( k e y 2 ) hash(key_1)=hash(key_2) hash(key1)=hash(key2)
3.2 装填因子 vs. 冲突
3.3 完美散列
在某些条件下,的确可以实现单射(injection)式散列
数据集已知且固定时,可实现完美散列(perfect hashing)
采用两级散列模式
仅需O(n)空间
关键码之间互不冲突
最坏情况下的查找时间也不过O(1)
不过在一般情况下,完美散列可期不可求
3.4 生日悖论
3.5 两项基本任务
1.1 评价标准 + 设计原则
确定(determinism):同一关键码总是被映射至同一地址
快速(efficiency):expected-O(1)
满射(surjection):尽可能充分地利用整个散列空间
均匀(uniformity):关键码映射到散列表各位置的概率尽量接近,有效避免聚集(clustering)现象
1.2 除余法
$hash(key)=key % M $
据说M为素数时,数据对散列表的覆盖最充分,分布最均匀 其实对于理想随机的序列,表长是否素数,无关紧要
序列的Kolmogorov复杂度:生成该序列的算法,最短可用多少行代码实现?
实际应用中的数据序列远非理想随机,上述规律性普遍存在
蝉的哲学:经长期自然选择,生命周期“取”作素数
1.3 MAD法
1.4 更多散列函数
数字分析(selecting digits) :抽取key中的某几位,构成地址
平方取中(mid-square) :取 k e y 2 key^2 key2的中间若干位,构成地址
折叠法(folding) :将key分割成等宽的若干段,取其总和作为地址
位异或法(XOR) :将key分割成等宽的二进制段,经异或运算得到地址
总之,越是随机,越是没有规律,越好
2.1 (伪)随机数法
循环: rand( x + 1 ) = [ a * rand( x ) ] % M //M素数,a % M ≠ 0
径取:hash(key) = rand(key) = [ r a n d ( 0 ) ∗ a k e y rand(0) * a^{key} rand(0)∗akey] % M
种子:rand(0) = ?
把难题推给伪随机数发生器,但是(伪)随机数发生器的实现,因具体平台、不同历史版本而异,创建的散列表可移植性差——故需慎用此法
unsigned long int next = 1; //sizeof(long int) = 8
void srand(unsigned int seed) { next = seed; } //sizeof(int) = 4 or 8
int rand(void) { //1103515245 = 3^5 * 5 * 7 * 129749
next = next * 1103515245 + 12345;
return (unsigned int)(next/65536) % 32768;
}
int rand() { int uninitialized; return uninitialized; }
char* rand( t_size n ) { return ( char* ) malloc( n ); }
2.2 就地随机置乱:任给一个数组A[0, n),理想地将其中元素的次序随机打乱
void shuffle( int A[], int n ) {
for ( ; 1 < n; --n ) //自后向前,依次将各元素
swap( A[ rand() % n ], A[ n - 1 ] ); //与随机选取的某一前驱(含自身)交换
} //20! < 2^64 < 21!
3.1 任意类型->整数型
3.2 冲突 ~ 巧合
h a s h C o d e ( S ) = ∑ c ∈ S c o d e ( u p p e r ( c ) ) hashCode(S)=\sum_{c∈S}code(upper(c)) hashCode(S)=∑c∈Scode(upper(c))
字符相对次序信息丢失,将引发大量冲突
即便字符不同、数目不等
1.1 多槽位(Multiple Slots)
Multiple Slots
只要槽位数目不太多 依然可以保证O(1)的时间效率
1.2 公共溢出区(Overflow Area)
1.3 独立链 (Linked-List Chaining / Separate Chaining)
2.1 开放定址
闭散列方法(Closed Hashing),必然对应于开放定址(Open Addressing)
检测序列(Probe Sequence/Chain):为每个词条,都需事先约定若干备用桶,优先级逐次下降
查找算法:沿试探链,逐个转向下一桶单元,直到命中成功,或者抵达一个空桶而失败
2.2 线性试探
Linear Probing
[ hash( key ) + 1 ] % M->[ hash( key ) + 2 ] % M->[ hash( key ) + 3 ] % M->[ hash( key ) + 4 ] % M
在散列表内部解决冲突,无需附加的指针、链表或溢出区等,整体结构保持简洁
只要还有空桶,迟早会找到
新增非同义词之间的冲突
数据堆积(clustering)现象严重
好在,试探链连续,数据局部性良好
通过装填因子,冲突与堆积都可有效控制
2.3 插入+删除
3.1 Lazy Removal:故居 ~ 空宅
Bitmap* removed
用Bitmap懒惰地标记被删除的桶int L
被标记桶的数目3.2 两种算法
template<typename K,typename V> int Hashtable::probe4Hit(const K& k) {
int r = hashCode(k) % M; //按除余法确定试探链起点
while ( ( ht[r] && (k != ht[r]->key) ) || removed->test(r) )
r = ( r + 1 ) % M; //线性试探(跳过带懒惰删除标记的桶)
return r; //调用者根据ht[r]是否为空及其内容,即可判断查找是否成功
}
template<typename K,typename V> int Hashtable::probe4Free(const K& k) {
int r = hashCode(k) % M; //按除余法确定试探链起点
while ( ht[r] ) r = (r + 1) % M; //线性试探,直到空桶(无论是否带有懒惰删除标记)
return r; //只要有空桶,线性试探迟早能找到
}
template<typename K,typename V> //随着装填因子增大,冲突概率、排解难度都将激增
void Hashtable::rehash() { //此时,不如“集体搬迁”至一个更大的散列表
int oldM = M; Entry** oldHt = ht;
ht = new Entry*[ M = primeNLT( 4 * N ) ]; N = 0; //新表“扩”容
memset( ht, 0, sizeof( Entry* ) * M ); //初始化各桶
release( removed ); removed = new Bitmap(M); L = 0; //懒惰删除标记
for ( int i = 0; i < oldM; i++ ) //扫描原表
if ( oldHt[i] ) //将每个非空桶中的词条
put( oldHt[i]->key, oldHt[i]->value ); //转入新表
release( oldHt ); //释放——因所有词条均已转移,故只需释放桶数组本身
}
template<typename K,typename V> bool Hashtable::put( K k, V v ) {
if ( ht[ probe4Hit( k ) ] ) return false; //雷同元素不必重复插入
int r = probe4Free( k ); //为新词条找个空桶(只要装填因子控制得当,必然成功)
ht[ r ] = new Entry( k, v ); ++N; //插入
if ( removed->test( r ) ) { removed->clear( r ); --L; } //懒惰删除标记
if ( (N + L)*2 > M ) rehash(); //若装填因子高于50%,重散列
return true; 插入
}
template<typename K,typename V> bool Hashtable::remove( K k ) {
int r = probe4Hit( k ); if ( !ht[r] ) return false; //确认目标词条确实存在
release( ht[r] ); ht[r] = NULL; --N; //清除目标词条
removed->set(r); ++L; //更新标记、计数器
if ( 3*N < L ) rehash(); //若懒惰删除标记过多,重散列
return true;
}
5.1 平方试探(Quadratic Probing)
Quadratic Probing:以平方数为距离,确定下一试探桶单元
数据聚集现象有所缓解
对于大散列表,I/O操作有所增加
5.2 素数表长时,只要λ<0.5就一定能够找出;否则,不见得
5.3 每一条试探链,都有足够长的无重前缀
6.1 策略:交替地沿两个方向试探,均按平方确定距离
6.2 子试探链
6.3 4k+3
6.4 费马二平方定理(Two-Square Theorem of Fermat)
任一素数p可表示为一对整数的平方和,当且仅当 p ≡ 1 ( m o d M ) p\equiv 1(mod M) p≡1(modM)
只要注意到: ( u 2 + v 2 ) ( s 2 + t 2 ) = ( u s + v t ) 2 + ( u t − v s ) 2 = ( u s − v t ) 2 + ( u t + v s ) 2 (u^2+v^2)(s^2+t^2)=(us+vt)^2+(ut-vs)^2=(us-vt)^2+(ut+vs)^2 (u2+v2)(s2+t2)=(us+vt)2+(ut−vs)2=(us−vt)2+(ut+vs)2
就不难推知:自然数n可表示为一对整数的平方和,当(且仅当)它的每一 M=4k+3类的素因子均为偶数次方
预先约定第二散列函数: h a s h 2 ( k e y , i ) hash_2(key,i) hash2(key,i)
冲突时,由其确定偏移增量,确定下一试探位置: [ h a s h ( k e y ) + ∑ i = 1 k h a s h 2 ( k e y , i ) ] % M [hash(key)+\sum_{i=1}^{k}hash_2(key,i)]\%M [hash(key)+∑i=1khash2(key,i)]%M
线性试探: h a s h 2 ( k e y , i ) ≡ 1 hash_2(key,i) \equiv 1 hash2(key,i)≡1
平方试探: h a s h 2 ( k e y , i ) = 2 i − 1 hash_2(key,i)=2i-1 hash2(key,i)=2i−1
更一般地,偏移增量同时还与key相关
1.1 简单情况
1.2 一般情况
2.1 最大缝隙(MaxGap)
2.2 线性算符
找到最左点、最右点 O(n) //一趟线性扫描
将有效范围均匀地划分为n-1段(n个桶) O(n) //相当于散列表
通过散列,将各点归入对应的桶 O(n) //模余法
在各桶中,动态记录最左点、最右点 O(n) //可能相同甚至没有
算出相邻(非空)桶之间的“距离” O(n) //一趟遍历足矣
最大的距离即MaxGap O(n) //画家算法
2.3 正确性
1.1 词典序
1.2 算法:自 k 1 k_1 k1到 k t k_t kt(低位优先),依次以各域为序做一趟桶排序
1.3 正确性
1.4 时间成本
时间成本 = 各趟桶排序所需时间之和 = n + 2 m 1 + n + 2 m 2 + . . . + n + 2 m d / / m 为各域 k 的取值范围 = O ( d ∗ ( n + m ) ) / / m = m a x { m 1 , . . . , m d } \begin{aligned} 时间成本&=各趟桶排序所需时间之和 \\ &=n + 2m_1 + n + 2m_2 + ... + n + 2m_d //m 为各域 k 的取值范围\\ &=O( d * (n + m) ) //m = max\{ m_1, ..., m_d \}\\ \end{aligned} 时间成本=各趟桶排序所需时间之和=n+2m1+n+2m2+...+n+2md//m为各域k的取值范围=O(d∗(n+m))//m=max{m1,...,md}
1.5 实现(以二进制无符号整数为例)
typedef unsigned int U; //约定:类型T或就是U;或可转换为U,并依此定序
template<typename T> void List<T>::radixSort( ListNodePosi p, int n ) {
ListNodePosi<T> head = p->pred; ListNodePosi<T> tail = p;
for ( int i = 0; i < n; i++ ) tail = tail->succ; //待排序区间为(head, tail)
for ( U radixBit = 0x1; radixBit && (p = head); radixBit <<= 1 ) //以下反复地
for ( int i = 0; i < n; i++ ) //根据当前基数位,将所有节点
radixBit & U (p->succ->data) ? //分拣为前缀(0)与后缀(1)
insert( remove( p->succ ), tail ) : p = p->succ;
} //为避免remove()、insert()的低效率,可拓展List::move(p,tail)接口,将节点p直接移至tail之前
1.6 实例
2.1 常对数密度的整数集
设d>1为常数
考查取自 [ 0 , n d ) [0,n^d) [0,nd)内的n个整数
亦即,这类整数集的对数密度不超过常数
若取d=4,则即便是64位整数,也只需 n > ( 2 64 ) 1 / 4 = 2 16 = 65 , 356 n>(2^{64})^{1/4}=2^{16}=65,356 n>(264)1/4=216=65,356
2.2 线性排序算法