数据结构与算法知识点总结(2)队列、栈与散列表
1. 队列
队列是一种FIFO的数据结构,它有两个出口,限定只能在表的一端进行插入(队尾插入)和在另一端进行删除(队头删除)操作,同样的它也没有遍历行为。
由于在队列的顺序存储中无法判断队列满的条件,一般地如果用数组实现队列,循环队列是必须的。一般设置一个队头指针front和队尾指针rear,初始化两变量均为0。为区分队空和队满,一般牺牲一个单元来区分队空和队满,这是种较为普遍的做法,约定以front在rear的下一个位置为队满标志(front指向队头元素的上一个元素,rear指向队尾元素)。如下为其重要操作:
- 队空标志:
front==rear
; 队满标志:(rear+1)%MAXSIZE==front
- 入队操作:
rear=(rear+1)%MAXSIZE; queue[rear]=x
- 出队操作:
front=(front+1)%MAXSIZE; x=queue[front];
在进行栈或队列操作时使用内存复制memcpy
的行为,并非原始的数据地址,如果把其应用在二叉树的遍历算法中是存在bug的。
队列应用在在层次遍历、计算机系统的资源请求中,它的特点就是在进行当前层的处理时就对下一层数据进行预处理。
/** * 循环队列结构定义 * 如果是固定长度的循环数组,一般建议牺牲一个单元来区分队空和队满 * 入队时少一个单元,因而一般设定front指向队头的上一个元素, * rear指向队尾元素 */ typedef struct { void *data; int capacity; int front; //指向队头元素的上一个元素 int rear; //指向队尾元素 int type_size; } queue_t;
2. 栈
栈是一种LIFO的数据结构,它只有一个出口,只允许在表的一端进行操作,如插入、删除、取得栈顶元素,不允许有其他方法可以存取栈的其他元素(没有遍历行为)。
在栈的顺序存储结构中,一般设置一个top变量指向栈顶元素的下一个位置,初始化为0。如下为其重要操作:
- 栈为空的条件:
top==0
- 压栈操作:
stack[top++]=x
- 出栈操作:
x=stack[--top]
/*基于动态数组的栈结构定义*/ typedef struct { void *data; int capacity; //允许容纳最大容量 int top; //当前栈顶的下一个位置 int type_size; //元素类型的字节大小 } stack_t;
栈在括号匹配、表达式计算(中缀表示转后缀表示、后缀表达式计算)、进制转换、迷宫求解都有应用,它一般也作为递归算法的非递归表示。
3. 散列表
散列表,又称哈希表,它是根据关键字而进行快速存取的技术,是一种典型的“空间换取时间”的做法。它是普通数组概念的推广,因为可以对数组进行直接寻址,故可以在O(1)时间内访问数组的任意元素。
它的思路是试图将关键字通过某种规则映射到数组的某个位置,以加快查找的速度。这种规则称之为散列函数,存放的数组称为散列表。散列表建立起了关键字和存储地址的一种直接映射关系,特别适合于关键字总数很多而存储在字典中的关键字集合很少的情形,尽管在最坏情况下,查找一个元素的时间为O(n),但在实际应用中,散列表的效率还是很高的,在一些合理的假设下,散列表查找的期望时间为O(1)。
使用散列的查找算法分为两步。第一步是用散列函数将查找的关键字转化为数组的一个索引。理想情况下,不同关键字都能映射为不同的索引值,当然,实际情况是我们需要面对多个不同的关键通过哈希函数得到相同的数组下标。我们称之为碰撞冲突。一方面,设计好的Hash函数应尽量减少这样的冲突,另一方面由于这样的冲突总是不可避免,所以我们要设计好处理碰撞冲突的方法。这是第二步。
3.1 散列函数和处理冲突的方法
构造散列函数要注意以下几点:
- Hash函数定义域必须包含全部关键字,而值域依赖于散列表的大小或地址范围
- 理想中的Hash函数计算出来的地址应该能等概率、均匀地分布在整个地址空间,以减少冲突的发生
- Hash函数应该尽量简单,能够在较短时间内计算出任意关键字的存储地址
- 所有散列函数都具有一个特性:如果两个散列值不想同,则两个散列值的原始输入也不相同
A 常用的散列函数
- 直接定址法,计算简单,适合关键字分布基本连续的情况,
H(key)= a*key+b
- 除留余数法,最简单最常用,关键是选好质数p,保证散列的关键字等概率地映射到任一地址,p是一个不大于散列表长m,但最接近或等于m的质数
H(key)= key %p
- 数字分析法,若关键字是r进制数,在某些位可能分布均匀,应选择数码分布均匀的若干位作为散列地址适合于一个已知的关键字集合
- 平方取中法
B 字符串Hash函数比较
还记得Java的字符串对象中hashCode的计算方法为什么经常用31来计算么?其实它是通过实验优化最终确定的。它是属于BKDRHash函数的一种,初始化种子为31。
例如一个日期类型,它对应的hashCode实现如下:
public class Date implements Comparable<Date> { private static final int[] DAYS={0,31,29,31,30,31,30,31,31,30,31,30,31}; /*成员变量均为final类型,表示一经初始化就不可变化*/ private final int month; private final int day; private final int year; public int hashCode() { int hash=17; hash=31*hash+month; hash=31*hash+day; hash=31*hash+year; return hash; }
常见的字符串Hash函数有BKDRHash、APHash、DJBHash、JsHash、RSHash、SDBHash、PJWHash、ELFHash。它的结果显示BKDRHash无论是在实际效果还是编码实现中,效果都是最突出的,可以看到BKDRHash的种子选取是很有规律的31 131 1313 13131 etc..
,非常适合记忆。
如下附有32位无符号的Hash函数的C代码:
// BKDR Hash Function unsigned int BKDRHash(char *str) { unsigned int seed = 131; // 31 131 1313 13131 131313 etc.. unsigned int hash = 0; while (*str) { hash = hash * seed + (*str++); } return (hash & 0x7FFFFFFF); } unsigned int SDBMHash(char *str) { unsigned int hash = 0; while (*str) { // equivalent to: hash = 65599*hash + (*str++); hash = (*str++) + (hash << 6) + (hash << 16) - hash; } return (hash & 0x7FFFFFFF); } // RS Hash Function unsigned int RSHash(char *str) { unsigned int b = 378551; unsigned int a = 63689; unsigned int hash = 0; while (*str) { hash = hash * a + (*str++); a *= b; } return (hash & 0x7FFFFFFF); } // JS Hash Function unsigned int JSHash(char *str) { unsigned int hash = 1315423911; while (*str) { hash ^= ((hash << 5) + (*str++) + (hash >> 2)); } return (hash & 0x7FFFFFFF); } // P. J. Weinberger Hash Function unsigned int PJWHash(char *str) { unsigned int BitsInUnignedInt = (unsigned int)(sizeof(unsigned int) * 8); unsigned int ThreeQuarters = (unsigned int)((BitsInUnignedInt * 3) / 4); unsigned int OneEighth = (unsigned int)(BitsInUnignedInt / 8); unsigned int HighBits = (unsigned int)(0xFFFFFFFF) << (BitsInUnignedInt - OneEighth); unsigned int hash = 0; unsigned int test = 0; while (*str) { hash = (hash << OneEighth) + (*str++); if ((test = hash & HighBits) != 0) { hash = ((hash ^ (test >> ThreeQuarters)) & (~HighBits)); } } return (hash & 0x7FFFFFFF); } // ELF Hash Function unsigned int ELFHash(char *str) { unsigned int hash = 0; unsigned int x = 0; while (*str) { hash = (hash << 4) + (*str++); if ((x = hash & 0xF0000000L) != 0) { hash ^= (x >> 24); hash &= ~x; } } return (hash & 0x7FFFFFFF); } // DJB Hash Function unsigned int DJBHash(char *str) { unsigned int hash = 5381; while (*str) { hash += (hash << 5) + (*str++); } return (hash & 0x7FFFFFFF); } // AP Hash Function unsigned int APHash(char *str) { unsigned int hash = 0; int i; for (i=0; *str; i++) { if ((i & 1) == 0) { hash ^= ((hash << 7) ^ (*str++) ^ (hash >> 3)); } else { hash ^= (~((hash << 11) ^ (*str++) ^ (hash >> 5))); } } return (hash & 0x7FFFFFFF); }
C 工业界比较知名的Hash算法
这些算法通常应用于信息安全领域(MD: message digest缩写)
- MD4: 一种用来测试信息完整性的密码散列函数的实现。一般128位长度的MD4散列函数被表示为32字长的数字,并用高速软件实现
- MD5: 一种符合工业标准的单向128位哈希方案。以结果唯一并且不能返回其原始格式的方法来转换数据(如密码)。速度相比MD4更慢,但更安全,在抗分析和抗查分表现更好
- SHA-1: 由美国国安局设计,从一个最大的2^64位元的信息中产生一串160元的摘要。
一般地这些哈希算法在符号表或字典实现中代价很大,应用并不多,它们在信息安全领域主要应用在文件校验、数字签名、数字指纹和存储密码中(MD4,MD5,SHA-1已被确定不安全,SHA-2,WHIRLPOOL)
D 处理冲突的方法
在前言中谈到了任何散列函数都不可避免地遇到冲突,此时必须考虑冲突发生时应该如何进行处理,即为产生的关键字寻找下一个空的Hash地址,于是有各种处理冲突的方法
- 拉链法
这种方法是在每个表格元素中维护一个list,把所有冲突的关键字存储在同一个list中。使用开链法,表格的负载系数(表中记录数n/散列表长度m)大于1,它适用于经常进行插入和删除的情况。STL里面的hash table便采用了这种做法
- 开放定址法
这种方法是指可存放新表项的空闲地址,既向它的同义词表项开放,又向它的非同义词表项开放。递推公式为
其中i=1,2,...,k-1,m为散列表表长,增量序列d它通常有以下几种取法:
-
线性探测法,特点是冲突发生时顺序查看表中的下一个单元,直至找到一个空单元或查遍全表,缺点是容易产生聚集现象
di=1,2,...,m−1di=1,2,...,m−1 -
二次探测法,表长m必须是4k+3的质数,可以很好的避免出现堆积问题,但无法探测到所有的单元
di=1,−1,4,−4,,...,k2,−k2,k<=m/2di=1,−1,4,−4,,...,k2,−k2,k<=m/2 -
序列为伪随机数序列时,称为伪随机探测法
-
当发生冲突时,利用另外一个哈希函数再次计算一个地址,直到不再发生冲突,称为再散列法
总结它有以下几个要点:
- 在开放定址法中,不能随便地物理删除表中已有元素,因为若删除元素将会阶段其他具有相同散列地址的元素的查找地址。建议是采用惰性删除,即只标记删除记号,实际删除操作则待表格重新整理时再进行(rehashing)。可看出它的负载系数永远小于1,经理论分析,当负载因素为0.5时,查找命中所需要的探测次数为3/2,未命中的需要约5/2,所以保持散列表的使用率小于1/2即可获得一个较好的查找性能。
- ASL(成功): 搜索到表中已有元素的平均探测次数,平均的概念是针对表中当前非空元素,并非整个表长;ASL(不成功): 表中可能散列到的位置上要插入新元素时为找到空桶的探测次数的平均值,平均的概念是针对散列函数映射到的位置总数(有时候存在表长与散列表质数的选取不一致的情形),一般是针对表长
- 拉链法更容易实现删除操作,如果散列函数设计得不好相比线性探测法对于聚集现象更不敏感;线性探测法更为节省内存空间,并且具有更好的Cache性能
- 散列表的查找效率取决于三个因素: 散列函数、处理冲突的方法和负载系数。
3.2 散列表的实现
如下为基于拉链法的散列表节点和散列表数据结构的定义:
/*散列表结点元素的定义*/ typedef struct hash_tbl_node { void *item; struct hash_tbl_node *next; } hash_tbl_node_t; typedef struct { int num_buckets; int num_elements; hash_tbl_node_t **buckets; int (*hash_fcn)(const void *,int); int (*comp_fcn)(const void *,const void *); } hash_tbl_t;
参数说明如下:
- num_elements: 散列表中元素(结点)的个数(用于动态调整表格大小,重建表格而用)
- buckets_num: 散列表的表格大小(表中的每个元素项称为桶),在STL中以质数来设计表格大小
- STL中甚至提供一个函数,用于查询在这28个作为表格大小的质数中,最接近某数并大于某数的质数
- buckets: 由指向链表的结点指针构成的数组
- hash_fcn: 针对元素表项键值的散列函数指针
- comp_fcn: 比较元素表项大小的函数指针
它的测试代码如下:
#define NUM_ITEMS 30 #define NUM_BUCKETS 17 #define RND_MAX 1000 typedef struct { int key; int val; } test_item_t; int comp_fcn(const void *a,const void *b){ return ((test_item_t *)a)->key-((test_item_t*)b)->key; } int hash_fcn(const void *item, int n){ /*将键值作为随机的种子*/ // srand(((test_item_t *)item)->key); // rand(); // return rand()%n; int key=((test_item_t *)item)->key; return key% n; } void visit(hash_tbl_node_t *cur){ if(cur){ test_item_t *item=(test_item_t *)cur->item; printf("(%d , %d) ",item->key,item->val); } } void test(){ test_item_t arr[NUM_ITEMS],*cur,*item; int i; for(i=0;i<NUM_ITEMS;i++){ arr[i].key=i*i; arr[i].val=rand()%RND_MAX; } printf("\n========以NUM_BUCKETS大小将散列表初始化========\n"); hash_tbl_t *tbl=hashtbl_alloc(NUM_BUCKETS,hash_fcn,comp_fcn); printf("\n========散列表的插入测试========\n"); for(i=0;i<NUM_ITEMS;i++){ item=&arr[i]; cur=hashtbl_insert(tbl,item); if(cur){ printf("Duplicate key-val pair: (%d , %d) detected, try again please !\n",cur->key,cur->val); i--; } else{ printf("Inserted key-val pair: (%d , %d)\n",item->key,item->val); } } printf("\n========散列表的重复插入测试========\n"); test_item_t test_dup; test_dup.key=2*2; if(hashtbl_insert(tbl,&test_dup)){ printf("Duplicate detected\n"); } else { printf("No Duplicate\n"); } printf("\n========散列表的查找测试========\n"); test_item_t find_dup; for(i=0;i<NUM_ITEMS;i++){ find_dup.key=i; if(hashtbl_find(tbl,&find_dup)){ printf("key %d found \n",i); } else{ printf("key %d not found \n",i); } } printf("\n========插入操作后散列表的遍历========\n"); hashtbl_foreach(tbl,visit); printf("\n========散列表的删除测试========\n"); test_item_t del_item; for(i=0;i<NUM_ITEMS;i++){ del_item.key=i; printf("key %d: ",del_item.key); if(hashtbl_delete(tbl,&del_item)){ printf("delete successfully\n"); } else{ printf("not exists\n"); } } printf("\n========删除操作后散列表的遍历========\n"); hashtbl_foreach(tbl,visit); printf("\n========释放散列表内存========\n"); hashtbl_free(tbl); printf("\n========释放散列表内存后散列表的遍历========\n"); }