一、线性表的查找方法
1. 顺序查找(Sequential Search)
也称线性查找算法,是一种简单直观的搜索算法,适用于未排序或部分排序的数据集合,对于顺序存储方式和链式存储方式的查找表都适用。
(1)基本思想
① 从列表的第一个元素开始遍历,依次将每个元素与目标元素进行比较。
② 如果当前元素与目标元素相等,则返回该元素的下标;否则继续遍历下一个元素。
③ 如果遍历完整个列表仍未找到目标元素,则返回搜索失败的结果。
(2)动态演示
(3)代码实现
// C 语言,顺序存储方式:
#include <stdio.h>
int sequential_search(int arr[], int n, int target) { // 顺序查找函数
for (int i = 0; i < n; i++) {
if (arr[i] == target) {
return i; // 返回目标值的索引
}
}
return -1; // 没有找到目标值
}
int main(void) {
int target = 8; // 要查询的值
int my_list[6] = {2, 6, 8, 12, 4, 10};
int result = sequential_search(my_list, 6, target);
if (result != -1) {
printf("目标值:%d 的索引是:%d\n", target, result);
} else {
printf("未找到目标值:%d\n", target);
}
return 0;
}
// C++,链式存储方式:
#include <iostream>
struct Node { // 定义链表节点结构
int data;
Node* next;
};
int sequential_search(Node* head, int target) { // 顺序查找函数
int index = 0;
Node* current = head;
while (current != nullptr) {
if (current->data == target) {
return index; // 返回目标值的索引
}
current = current->next;
index++;
}
return -1; // 没有找到目标值
}
int main(void) {
int target = 8; // 要查询的值
// 创建链表
Node* head = new Node{3, new Node{6, new Node{8, new Node{12, nullptr}}}};
int result = sequential_search(head, target);
if (result != -1) {
std::cout << "目标值:" << target << " 的索引是:" << result << std::endl;
} else {
std::cout << "未找到目标值:" << target << std::endl;
}
Node* current = head; // 释放链表内存
while (current != nullptr) {
Node* temp = current;
current = current->next;
delete temp;
}
return 0;
}
(4)算法性能
顺序查找算法的时间复杂度为 【 O ( n )】 【O(n)】 【O(n)】, n n n 为数据集合的大小。缺点【顺序查找方法在 n n n 值较大时,其平均查找长度较大,查找效率较低】,优点【算法简单且适应面广;对查找表的结构没有要求,无论记录是否按关键字有序排列均可应用】。
2. 二分查找(Binary Search)
也称折半查找,元素必须是有序的,适用于有序列表,如果是无序的则要先进行排序操作。由于链表并不直接支持随机访问,因此折半查找在链表上的实现相对复杂,需要对链表进行适当的操作和算法设计。
(1)基本思想
① 初始化左右边界:将初始查找范围设定为整个数组,即左边界为 0,右边界为数组长度减一。
② 循环直至找到目标值或者范围缩小为空:
计算中间位置:计算当前查找范围的中间位置(mid)。
比较目标值:将目标值与中间位置的元素进行比较。
缩小查找范围:根据比较结果,将查找范围缩小为左半部分或右半部分,继续进行下一轮查找。
③ 返回结果:如果找到目标值,则返回其索引;否则返回未找到的标识。
(2)动态演示
(3)代码实现
// C语言:
#include <stdio.h>
int binary_search(int arr[], int left, int right, int target) { // 二分查找函数
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid; // 找到目标值,返回索引
} else if (arr[mid] < target) {
left = mid + 1; // 目标值在右半部分,更新左边界
} else {
right = mid - 1; // 目标值在左半部分,更新右边界
}
}
return -1; // 未找到目标值
}
int main(void) {
int target = 12; // 要查询的值
int arr[] = {2, 5, 7, 12, 18, 20, 35};
int n = sizeof(arr) / sizeof(arr[0]);
int result = binary_search(arr, 0, n - 1, target);
if (result != -1) {
printf("目标值:%d 的索引是:%d\n", target, result);
} else {
printf("未找到元素:%d\n", target);
}
return 0;
}
(4)算法性能
二分查找算法的时间复杂度为 【 O ( l o g n )】 【O(logn)】 【O(logn)】, n n n 为数据集合的大小。适用于表不易变动,且又经常进行查找的情况,优点【比顺序查找的效率要高】,缺点【仅适用于有序序列;插入和删除困难】。
3. 插值查找(Interpolation Search)
在二分查找的基础上优化了查找位置的方法,它根据查找关键字与查找区间的最大值和最小值进行比较,可以更快地找到指定元素。
(1)基本思想
① 初始化左右边界:将初始查找范围设定为整个数组,即左边界为 0,右边界为数组长度减一。
② 计算插值位置:根据要查找的值与数组中最大最小元素的比例关系,估计要查找的值可能在整个数组中的位置。
③ 循环直至找到目标值或者范围缩小为空:
比较目标值:将目标值与中间位置的元素进行比较。
缩小查找范围:根据比较结果,将查找范围缩小为左半部分或右半部分,继续进行下一轮查找。
④ 返回结果:如果找到目标值,则返回其索引;否则返回未找到的标识。
(2)动图演示
(3)代码实现
#include <stdio.h>
int interpolationSearch(int arr[], int n, int key) { // 插值查找函数,arr 为有序数组,n 为数组大小,key 为要查找的元素
int low = 0; // 初始化搜索范围的下限
int high = n - 1; // 初始化搜索范围的上限
while (low <= high && key >= arr[low] && key <= arr[high]) { // 只要搜索范围没有缩小到只有一个元素并且 key 值在搜索范围内,就继续循环
if (low == high) { // 如果搜索范围只剩下一个元素
if (arr[low] == key) // 如果这个元素就是要查找的元素
return low; // 返回它的下标
else
return -1; // 否则返回 -1 表示没找到
}
int pos = low + ((key - arr[low]) * (high - low)) / (arr[high] - arr[low]); // 计算插值的位置
if (arr[pos] == key) // 如果插值位置的元素就是要查找的元素
return pos; // 返回这个位置
else if (arr[pos] < key) // 如果插值位置的元素比要查找的元素小
low = pos + 1; // 缩小搜索范围的下限
else // 如果插值位置的元素比要查找的元素大
high = pos - 1; // 缩小搜索范围的上限
}
return -1; // 如果没找到,返回-1
}
int main(void) {
int arr[] = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20}; // 初始化有序数组
int n = sizeof(arr) / sizeof(arr[0]); // 计算数组大小
int key = 10; // 要查找的元素
int index = interpolationSearch(arr, n, key); // 插值查找,得到查找结果的下标
if (index != -1)
printf("元素:%d 在数组中的索引位置为:%d\n", key, index);
else
printf("未找到元素:%d\n", key);
return 0;
}
(4)算法性能
插值查找时间复杂度为 【 O ( l o g l o g n )】 【O(log log n)】 【O(loglogn)】。适用于数据均匀分布的有序列表,当有序序列中的元素呈现均匀分布时,插值查找算法的查找效率要优于二分查找算法;反之,如果有序序列不满足均匀分布的特征,插值查找算法的查找效率不如二分查找算法。
4. 斐波那契查找(Fibonacci Search)
又称斐波那契搜索,是区间中单峰函数的搜索技术。二分查找的基础上,利用黄金分割原理,通过斐波那契数列中的数值确定查找点位置,也是一种针对有序序列的查找算法。
斐波那契数列(Fibonacci sequence):又称黄金分割数列和 “兔子数列”。斐波那契数列的特点是每个数都是前两个数之和,例如:
0
,
1
,
1
,
2
,
3
,
5
,
8
,
13
,
21
,
.
.
.
0,1,1,2,3,5,8,13,21,...
0,1,1,2,3,5,8,13,21,...。
(1)基本思想
① 初始化斐波那契数列:根据要查找的数组长度确定一个比它大的斐波那契数列。
② 初始化左右边界:将初始查找范围设定为整个数组,即左边界为 0,右边界为数组长度减一。
③ 在斐波那契数列中找到不超过数组长度的最大数值
(记为
F
[
k
]
)
(记为F[k])
(记为F[k]),并将数组长度扩展至
F
[
k
]
F[k]
F[k] 。
④ 循环直至找到目标值或者范围缩小为空:
计算中间位置:计算当前查找范围的中间位置(mid)。
比较目标值:将目标值与中间位置的元素进行比较。
缩小查找范围:根据比较结果,将查找范围缩小为左半部分或右半部分,继续进行下一轮查找。
⑤ 返回结果:如果找到目标值,则返回其索引;否则返回未找到的标识。
(2)动态演示
(3)代码实现
#include <stdio.h>
void Fibonacci(int* F) // 构造一个斐波那契数组
{
F[0] = 0;
F[1] = 1;
for (int i = 2; i < MAX_SIZE; ++i)
F[i] = F[i - 1] + F[i - 2];
}
int FibonacciSearch(int* a, int n, int key) // 定义斐波那契查找法,a 为要查找的数组,n 为要查找的数组长度,key 为要查找的关键字
{
int low = 0;
int high = n - 1;
int k = 0;
int F[MAX_SIZE] = {0};
Fibonacci(F); // 构造一个斐波那契数组F
while (n > F[k] - 1) // 计算 n 位于斐波那契数列的位置
++k;
int* temp; // 将数组 a 扩展到 F[k]-1 的长度
temp = malloc(sizeof(int)*(F[k]-1));
memcpy(temp, a, n * sizeof(int)); // 将 a 中的元素进行拷贝至 temp
for (int i = n; i < F[k] - 1; ++i) // 填充数组
temp[i] = a[n - 1];
while (low <= high) // 终止条件和二分查找一致
{
int mid = low + F[k - 1] - 1; // 递推关系式,获取 mid 值
if (key < temp[mid])
{
high = mid - 1;
k -= 1;
}
else if (key > temp[mid])
{
low = mid + 1;
k -= 2;
}
else
{
free(temp); // 标记 free
if (mid < n)
return mid; // 若相等则说明 mid 即为查找到的位置
else
return n - 1; // 若 mid>=n 则说明是扩展的数值,返回 n-1
}
}
free(temp); // 标记 free
return -1;
}
int main(void) {
int arr[] = {10, 22, 35, 40, 45, 50, 80, 82, 85, 90, 100};
int n = sizeof(arr) / sizeof(arr[0]);
int x = 85;
int result = FibonacciSearch(arr, n, x);
if (result != -1)
printf("元素:%d 的索引为:%d\n", x, result);
else
printf("未找到元素:%d\n", x);
return 0;
}
(4)算法性能
斐波那契查找算法的时间复杂度为 【 O ( l o g n )】 【O(log n)】 【O(logn)】。适用于有序列表,优点【比较次数较少;适用于大型数组】,缺点【需要预先计算斐波那契数列;不适用于频繁变动的数据集合】。
5. 分块查找(Block Search)
又称索引顺序查找,是对顺序查找方法的一种改进,其效率介于顺序查找与折半查找之间。在分块查找过程中,将数据分成若干块,每块中的元素可以无序,但不同块之间必须按照某种顺序排序,通过索引表进行查找。
(1)基本思想
将待查找的数据分成若干个块,每个块包含若干个元素。块的数量可以根据实际情况来确定,通常选择块的大小相等或者差不多相等。对于每个块,选取一个代表性的元素作为该块的索引,索引按照顺序排列,通常是升序排列。这些索引构成一个索引表,用来记录每个块的代表元素和每个块在整个数据中的位置。在进行查找时,先在索引表中查找目标元素所在的块。如果目标元素小于最小块的代表元素,或大于最大块的代表元素,则说明目标元素不在数组中;否则,在目标块中进行顺序查找即可。
① 块划分:将有序数组分成若干个块,每个块包含一定数量的元素。块的大小可以根据具体情况进行设计,通常选择块的大小足够小,但保证块内的元素是有序的。
② 建立索引:对每个块建立索引,记录每个块的起始位置和结束位置,以及每个块的最大值(或其他关键信息)。索引可以使用数组、链表等数据结构表示。
③ 查找块:根据要查找的目标值与每个块的最大值进行比较,确定目标值可能所在的块。
④ 在块内查找:在确定的块内进行顺序查找,找到目标值所在的位置。
⑤ 返回结果:如果找到目标值,则返回其索引;否则返回未找到的标识。
(2)静图解析
(3)代码实现
// C语言:
#include <math.h>
#include <stdio.h>
int blockSearch(int arr[], int n, int x, int blockSize) {
int i;
int numBlocks = ceil((double)n / blockSize); // 计算块数
for (i = 0; i < numBlocks; i++) { // 在每个块中进行顺序查找
int start = i * blockSize; // 确定当前块的起始和结束索引
int end = (i + 1) * blockSize - 1;
if (x >= arr[start] && x <= arr[end]) { // 如果x在当前块范围内,则在当前块中进行顺序查找
int j;
for (j = start; j <= end; j++) { // 在当前块中进行顺序查找
if (arr[j] == x)
return j; // 找到了,返回索引
}
break; // 块中未找到,退出循环
}
}
return -1; // 未找到元素
}
int main(void) {
int target = 12; // 要查询的值
int arr[] = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20};
int n = sizeof(arr) / sizeof(arr[0]);
int blockSize = sqrt(n); // 块大小为平方根
int result = blockSearch(arr, target , x, blockSize);
if (result != -1)
printf("元素:%d 的索引为:%d\n", target , result);
else
printf("未找到元素:%d\n", x);
return 0;
}
(4)算法性能
分块查找算法的时间复杂度为 【 O ( s q r t ( n ))】 【O(sqrt(n))】 【O(sqrt(n))】 。常用于静态查找,适用于数据分块但内部无序的列表。优点【简单易实现,查询效率高】,缺点【只适用于静态数据集合,即数据集合不可修改;块大小的选择不当会影响效率;需要额外的存储空间】。
二、树表的查找方法
特点:表结构本身是在查找过程中动态生成的,即对于给定值 key,若表中存在关键字等于 key 的记录,则查找成功返回;否则插入关键字为 key 的记录。
1. 二叉查找树(Binary Search Tree)
又称二叉查找树,它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树非空,则左子树上所有结点的值均小于根结点的值。
- 若它的右子树非空,则右子树上所有结点的值均大于根结点的值。
- 左、右子树本身是二叉排序树。
(1)基本思想
二叉排序树非空时,将给定值与根结点的关键字值相比较,若相等,则查找成功;若不相等,则当根结点的关键字值大于给定值时,下一步到根的左子树中进行查找,否则到根的右子树中进行查找。若查找成功,则查找过程是走了一条从树根到所找到结点的路径;否则,查找过程终止于一棵空的子树。
(2)基本操作
① 插入操作:从根节点开始,比较要插入的值与当前节点的值的大小关系。如果要插入的值小于当前节点的值,就在左子树中继续查找;如果要插入的值大于当前节点的值,就在右子树中继续查找。直到找到一个空节点,将新节点插入到该位置。
② 删除操作:首先找到要删除的节点。如果要删除的节点没有子节点,直接将其删除即可。如果要删除的节点只有一个子节点,将子节点取代要删除的节点。如果要删除的节点有两个子节点,可以选择用其前驱或后继节点来替换。前驱节点是左子树中值最大的节点,后继节点是右子树中值最小的节点。替换后,再删除前驱或后继节点。
③ 查找操作:从根节点开始,比较要查找的值与当前节点的值的大小关系。如果要查找的值等于当前节点的值,则返回该节点。如果要查找的值小于当前节点的值,在左子树中继续查找;如果要查找的值大于当前节点的值,在右子树中继续查找。直到找到匹配的节点或遍历到叶子节点为止。
(3)静图解析
(4)代码实现
// C语言:
#include<stdio.h>
#include<stdlib.h>
typedef int DataType;
typedef struct binarytreenode { // 二叉排序树节点存储方式
DataType data; // 数据域
struct Tnode *left,*right; // 指向左、右子树的指针
}BTnode;
void Insert_node(BTnode** root, DataType data) { // 插入数据
if (*root == NULL) {
*root = (BTnode*)malloc(sizeof(BTnode));
if (!*root) {
printf("异常退出!\n");
exit(-1);
}
(*root)->data = data;
(*root)->left = NULL;
(*root)->right = NULL;
}
else if ((*root)->data <= data)
Insert_node(&(*root)->right, data);
else if ((*root)->data > data)
Insert_node(&(*root)->left, data);
}
BTnode* Create_sortBtree(DataType* arr, int size) { // 创建排序二叉树
if (!arr)
return NULL;
else {
BTnode* T = NULL;
for (int i = 0; i < size; i++)
Insert_node(&T, arr[i]);
return T;
}
}
void mid_travel(BTnode* T) // 中序遍历排序二叉树
{
if (!T)
return;
mid_travel(T->left);
printf("%d ", T->data);
mid_travel(T->right);
}
BTnode* Btree_search(BTnode* root, DataType target) { // 递归查找数据
if (!root)
return NULL;
if (target == root->data)
return root;
return target > root->data ? Btree_search(root->right, target) : Btree_search(root->left, target);
}
BTnode* Btree_search_fa(BTnode* T, DataType target) { // 非递归查找
BTnode* p = T, * f = NULL;
while (p) {
if (p->data == target)
return f;
f = p;
p = target > p->data ? p->right : p->left;
}
return NULL;
}
int Btree_max(BTnode* T) { // 获取最大值
BTnode* cur = T;
while (cur->right)
cur = cur->right;
return cur->data;
}
int Btree_min(BTnode* T) { // 获取最小值
BTnode* cur = T;
while (cur->left)
cur = cur->left;
return cur->data;
}
void Btree_del(BTnode* T, DataType l) { // 删除节点
if (!T) {
printf("删除失败,返回!\n");
return;
}
BTnode* p = T, * f = NULL; // 找到这个要删除节点的父节点
while (p) {
if (p->data == l)
break;
f = p;
p = l > p->data ? p->right : p->left;
}
if (!p)
{
printf("没有这个节点!\n");
return;
}
BTnode* target = p; // 此时的要删除目标节点
BTnode* par = f; // 此时要删除节点的父节点
if (!target->left && target->right != NULL) // 第一种情况:此节点只有一个子树的时候
{
if (target->data > par->data)
par->right = target->right;
else
par->left = target->right;
free(target); // 释放空间
target = NULL;
}
else if (target->left != NULL && !target->right) {
if (target->data > par->data) {
par->right = target->left;
}
else {
par->left = target->left;
}
free(target);
target = NULL;
}
else if (!target->left && !target->right) { // 第二种情况:如果删除的是叶节点,直接删除即可
if (target->data > par->data) {
par->right = NULL;
}
else {
par->left = NULL;
}
free(target);
target = NULL;
}
else // 第三种情况:如果左右子树都存在的话,可以用右子树的最小元素;或者左子树的最大元素来替代被删除的节点,直接去用左树的最大代替这个节点
{
BTnode* Lchild = target->left;
while (Lchild->right != NULL) {
Lchild = Lchild->right;
}
if (target->data > par->data) {
par->right = Lchild;
}
else {
par->left = Lchild;
}
free(target);
target = NULL;
}
printf("删除成功!\n");
}
void Destory_btree(BTnode* T) { // 销毁
if (!T)
return;
BTnode* cur = T;
if (cur->left)
Destory_btree(cur->left);
if (cur->right)
Destory_btree(cur->right);
free(T);
}
(5)算法性能
插入、删除、还是查找,二叉排序树的时间复杂度都等于二叉树的高度,最好的情况当然是满二叉树或完全二叉树,此时根据完全二叉树的特性,时间复杂度为 【 O ( l o g n )】 【 O(logn)】 【O(logn)】,性能相当好,最差的情况是二叉排序树退化为线性表(斜树),此时的时间复杂度为 【 O ( n )】 【 O(n)】 【O(n)】
2. 平衡二叉查找树(Balanced Binary Search Tree)
又称为AVL树,它或者是一棵空树,或者是具有下列性质的二叉树:
- 左子树和右子树也都是平衡二叉树。
- 左子树和右子树深度之差的绝对值不大于 1 1 1。
平衡因子(Balance Factor,BF):二叉树上结点的左子树的深度减去其右子树深度。平衡二叉树上所有结点的平衡因子只可能是: − 1 、 0 -1、0 −1、0 和 1 1 1。只要树上有一个结点的平衡因子的绝对值大于 1 1 1,则该二叉树就是不平衡的。
(1)基本思想
每当在二叉排序树中插入一个结点时,首先检查是否因插入破坏了平衡。若是,则找出其中的最小不平衡二叉树,在保持二叉排序树特性的情况下,调整最小不平衡子树【离插入结点最近且以平衡因子的绝对值大于 1 1 1 的结点作为根的子树】中结点之间的关系,以达到新的平衡。
(2)基本操作
① 插入操作:首先需要按照二叉查找树的规则找到要插入节点的位置,并将其插入到树中。然后,需要对从插入节点到根节点的路径上的所有节点进行检查,确保这些节点的平衡因子不超过1。如果某个节点的平衡因子超过了1,则需要通过旋转等操作来重新平衡树。
② 删除操作:首先需要按照二叉查找树的规则找到要删除的节点,并将其从树中删除。然后,需要对从删除节点到根节点的路径上的所有节点进行检查,以确保这些节点的平衡因子仍然满足要求。如果某个节点的平衡因子不满足要求,则需要通过旋转等操作来重新平衡树。
③ 查找操作:只需要按照二叉查找树的规则在树中查找目标节点即可。
④ 旋转操作:是平衡二叉查找树中最重要的操作之一,通过旋转可以使得树的平衡因子得到调整,从而使得树重新达到平衡状态。平衡二叉查找树中常见的旋转操作包括左旋和右旋。在左旋操作中,将一个节点的右子树提升为其父节点,同时将该节点下移到其原本的右子树的左子树中;在右旋操作中,则是将一个节点的左子树提升为其父节点,同时将该节点上移到其原本的左子树的右子树中。
(3)静图解析
(4)代码实现
#include <stdio.h>
#include <stdlib.h>
struct TreeNode { // 树节点结构
int data; // 节点数据
int height; // 节点高度
struct Tnode *left,*right; // 指向左、右子树的指针
};
int getHeight(struct TreeNode* node) { // 获取节点高度
if (node == NULL)
return 0;
return node->height;
}
int max(int a, int b) { // 获取两个数中较大的一个
return (a > b) ? a : b;
}
struct TreeNode* createNode(int data) { // 创建新节点
struct TreeNode* newNode = (struct TreeNode*)malloc(sizeof(struct TreeNode));
newNode->data = data;
newNode->left = NULL;
newNode->right = NULL;
newNode->height = 1; // 初始高度为 1
return newNode;
}
struct TreeNode* rotateRight(struct TreeNode* y) { // 执行右旋操作
struct TreeNode* x = y->left;
struct TreeNode* T2 = x->right;
x->right = y; // 执行旋转
y->left = T2;
y->height = max(getHeight(y->left), getHeight(y->right)) + 1; // 更新高度
x->height = max(getHeight(x->left), getHeight(x->right)) + 1;
return x;
}
struct TreeNode* rotateLeft(struct TreeNode* x) { // 执行左旋操作
struct TreeNode* y = x->right;
struct TreeNode* T2 = y->left;
y->left = x; // 执行旋转
x->right = T2;
x->height = max(getHeight(x->left), getHeight(x->right)) + 1; // 更新高度
y->height = max(getHeight(y->left), getHeight(y->right)) + 1;
return y;
}
int getBalanceFactor(struct TreeNode* node) { // 获取平衡因子
if (node == NULL)
return 0;
return getHeight(node->left) - getHeight(node->right);
}
struct TreeNode* insertNode(struct TreeNode* node, int data) { // 插入节点到树中
if (node == NULL) // 执行标准BST的插入操作
return createNode(data);
if (data < node->data)
node->left = insertNode(node->left, data);
else if (data > node->data)
node->right = insertNode(node->right, data);
else // 若节点数据相等,不插入
node->height = 1 + max(getHeight(node->left), getHeight(node->right)); // 更新节点高度
int balanceFactor = getBalanceFactor(node); // 获取平衡因子
// 如果节点不平衡,则根据四种情况进行旋转操作
if (balanceFactor > 1 && data < node->left->data) // 左左情况
return rotateRight(node);
if (balanceFactor < -1 && data > node->right->data) // 右右情况
return rotateLeft(node);
if (balanceFactor > 1 && data > node->left->data) { // 左右情况
node->left = rotateLeft(node->left);
return rotateRight(node);
}
if (balanceFactor < -1 && data < node->right->data) { // 右左情况
node->right = rotateRight(node->right);
return rotateLeft(node);
}
return node;
}
void inorderTraversal(struct TreeNode* root) { // 中序遍历树
if (root != NULL) {
inorderTraversal(root->left);
printf("%d ", root->data);
inorderTraversal(root->right);
}
}
(5)算法性能
插入、删除和查找等操作的平均时间复杂度为 【 O ( l o g n )】 【O(logn)】 【O(logn)】,但最坏情况下可能退化为 【 O ( n )】 【O(n)】 【O(n)】。优点【快速的查找,可以保持节点的有序性,支持动态的插入和删除操作】,缺点【维护平衡性开销大,不适合大规模数据,实现比较复杂】。
3. B 树(B Tree)
一棵 m m m 阶的 B _ B\_ B_ 树,或为空树,或为满足下列特性的 m m m叉树:
- 树中每个结点最多有 m m m 棵子树。
- 除根之外的所有非终端结点最少有 ⌈ m 2 ⌉ \lceil \frac{m}{2} \rceil ⌈2m⌉ 棵子树。
- 有 k k k 颗子树的非叶节点有 k − 1 k-1 k−1 个键,键按照递增顺序排列。
- 若根结点不是叶子结点,则最少有两棵子树(非叶子节点的根节点至少有两个子节点)。
- 所有有的叶子结点都出现在同一层次上,并且不带信息(可以看作是外部结点或查找失败的结点,实际上这些结点不存在,指向这些结点的指针为空)。
(1)基本思想
通过增加节点中元素的个数,减小树的高度,减小磁盘I/O的次数。
(2)基本操作
① 插入操作:首先在低层的某个非终端结点中添加一个关键字,若该结点中关键字的个数不超过
m
−
1
m-1
m−1,则完成插入;否则,要进行结点的"分裂"处理【把结点中处于中间位置上的关键字取出来插入到其父结点中,并以该关键字为分界线,把原结点分成两个结点,“分裂”过程可能会一直持续到树根】。
② 检索操作:首先在根结点所包含的关键字中查找给定的关键字,若找到则成功返回;否则确定待查找的关键字所在的子树并继续进行查找,直到查找成功或查找失败(指针为空)时为止。
③ 删除操作:首先找到关键字所在的结点,若该结点在含有信息的最后一层,且其中关键字的数目不少于
⌈
m
2
−
1
⌉
\lceil \frac{m}{2} - 1 \rceil
⌈2m−1⌉ 则完成删除;否则需进行结点的“合并”运算【若待删除的关键字所在的结点不在含有信息的最后一层,则将该关键字用其在B树中的后继替代,然后删除其后继元素,即将需要处理的情况统一转化为在含有信息的最后一层再进行删除运算】。
(3)静图解析
(4)代码实现
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define MAX_KEYS 4 // 每个节点最大的键值数量
struct BTreeNode { // B树节点的结构
int numKeys; // 当前节点中的键值数量
int keys[MAX_KEYS-1]; // 键值数组
struct BTreeNode* children[MAX_KEYS]; // 子节点数组
bool leaf; // 是否为叶子节点
};
struct BTree { // B树的结构
struct BTreeNode* root; // 根节点指针
};
struct BTreeNode* createNode(bool leaf) { // 创建一个新的B树节点(叶子节点或非叶子节点)
struct BTreeNode* newNode = (struct BTreeNode*)malloc(sizeof(struct BTreeNode));
newNode->numKeys = 0;
newNode->leaf = leaf;
for (int i = 0; i < MAX_KEYS; i++) {
newNode->children[i] = NULL;
}
return newNode;
}
void insertNonFull(struct BTreeNode* node, int key) { // 向非满节点中插入键值
int i = node->numKeys - 1;
if (node->leaf) { // 如果当前节点是叶子节点
while (i >= 0 && node->keys[i] > key) { // 将键值插入到正确的位置
node->keys[i + 1] = node->keys[i];
i--;
}
node->keys[i + 1] = key;
node->numKeys++;
} else { // 如果当前节点是非叶子节点
while (i >= 0 && node->keys[i] > key) // 找到合适的子节点进行递归插入
i--;
i++;
if (node->children[i]->numKeys == MAX_KEYS - 1) { // 如果子节点已满
splitChild(node, i); // 分裂子节点
if (node->keys[i] < key)
i++;
}
insertNonFull(node->children[i], key); // 递归插入键值到子节点
}
}
void splitChild(struct BTreeNode* parentNode, int childIndex) { // 分裂满节点的子节点
struct BTreeNode* childNode = parentNode->children[childIndex];
struct BTreeNode* newNode = createNode(childNode->leaf);
newNode->numKeys = MAX_KEYS / 2 - 1;
for (int j = 0; j < MAX_KEYS / 2 - 1; j++) // 将子节点的后一半键值移到新节点中
newNode->keys[j] = childNode->keys[j + MAX_KEYS / 2];
if (!childNode->leaf)
for (int j = 0; j < MAX_KEYS / 2; j++) // 将子节点的后一半子节点移到新节点中
newNode->children[j] = childNode->children[j + MAX_KEYS / 2];
childNode->numKeys = MAX_KEYS / 2 - 1;
for (int j = parentNode->numKeys; j > childIndex; j--) // 在父节点中腾出位置插入新节点和键值
parentNode->children[j + 1] = parentNode->children[j];
parentNode->children[childIndex + 1] = newNode;
for (int j = parentNode->numKeys - 1; j >= childIndex; j--)
parentNode->keys[j + 1] = parentNode->keys[j];
parentNode->keys[childIndex] = childNode->keys[MAX_KEYS / 2 - 1];
parentNode->numKeys++;
}
void insert(struct BTree* tree, int key) { // 向B树中插入键值
struct BTreeNode* root = tree->root;
if (root->numKeys == MAX_KEYS) { // 如果根节点已满
struct BTreeNode* newNode = createNode(false);
tree->root = newNode;
newNode->children[0] = root;
splitChild(newNode, 0); // 分裂根节点
insertNonFull(newNode, key); // 在新根节点中插入键值
} else
insertNonFull(root, key); // 插入键值到根节点或其子节点
}
void traverse(struct BTreeNode* node) { // 中序遍历输出B树节点的键值
int i;
for (i = 0; i < node->numKeys; i++) {
if (!node->leaf) // 如果当前节点不是叶子节点,递归遍历子节点
traverse(node->children[i]);
printf("%d ", node->keys[i]); // 输出键值
}
if (!node->leaf) // 遍历最右侧的子节点
traverse(node->children[i]);
}
void printBTree(struct BTree* tree) { // 输出整个B树
if (tree->root != NULL)
traverse(tree->root);
}
(5)算法性能
插入、删除和查找等操作的时间复杂度为 【 O ( l o g n )】 【O(logn)】 【O(logn)】。
4. B+ 树(B+ Tree)
一棵 m m m 阶的 B + B+ B+ 树,或为空树,或为满足下列特性的 m m m叉树:
- 叶节点都在同一层中。
- 每个节点最多只有 m m m 个子节点。
- 非叶子节点的根节点至少有两个子节点。
- 有 k k k 颗子树的非叶节点有 k k k 个键,键按照递增顺序排列。
- 除根节点外,每个非叶子节点具有至少有
⌊
m
2
⌋
\lfloor \frac{m}{2} \rfloor
⌊2m⌋ 个子节点。
(1)B+ 树与 B 树的差异
B+ 树 | B 树 |
---|---|
有 m 颗子树的节点中含有 m 个关键码 | 有 m 颗子树的节点中含有 m-1 个关键码 |
所有的叶子结点中包含了完整的索引信息,包括指向含有这些关键字记录的指针,中间节点每个元素不保存数据,只用来索引 | 非叶子节点的关键码与叶子结点的关键码均不重复,它们共同构成全部的索引信息 |
所有的非叶子节点可以看成是高层索引, 结点中仅含有其子树根结点中最大(或最小)关键字 | 非叶子节点包含需要查找的有效信息 |
(2)基本操作
① 插入操作:与 B 树的插入操作相似,总是插到叶子结点上。当叶节点中原关键码的个数等于
m
m
m 时,该节点分裂成两个节点,分别使关键码的个数为
⌈
m
+
1
2
⌉
\lceil \frac{m+1}{2} \rceil
⌈2m+1⌉ 和
⌊
m
+
1
2
⌋
\lfloor \frac{m+1}{2} \rfloor
⌊2m+1⌋。
② 检索操作:与 B 树的检索方式相似,但若在内部节点中找到检索的关键码时,检索并不会结束,要继续找到 B+ 树的叶子结点为止。
③ 删除操作:仅在叶节点删除关键码。若因为删除操作使得节点中关键码数少于
⌊
m
2
⌋
\lfloor \frac{m}{2} \rfloor
⌊2m⌋时,则需要调整或者和兄弟节点合并。合并的过程和 B 树类似,区别是父节点中作为分界的关键码不放入合并后的节点中。
三、 哈希表的查找方法
1. 哈希表的定义
哈希表(Hash Table):也叫散列表,是根据关键码值(key-value)而直接进行访问的一种特殊的数据结构,特点【可以快速实现查找、插入和删除】。
哈希表通过计算一个以记录的关键字为自变量的函数(称为哈希函数)来得到该记录的存储地址,在哈希表中进行查找操作时,需用同一哈希函数计算得到待查记录的存储地址,然后到相应的存储单元去获得有关信息再判定查找是否成功。
2. 哈希函数的构造方法
哈希函数:也称为是散列函数,是 Hash 表的映射函数,它可以把任意长度的输入变换成固定长度的输出,该输出就是哈希值。哈希函数能使对一个数据序列的访问过程变得更加迅速有效,通过哈希函数,数据元素能够被很快的进行定位。
对于哈希函数的构造,应解决好两个主要问题:
- 哈希函数应是一个压缩映像函数,它应具有较大的压缩性,以节省存储空间。
- 哈希函数应具有较好的散列性,虽然冲突是不可避免的,但应尽量减少。
要减少冲突,就要设法使哈希函数尽可能均匀地把关键字映射到存储区的各个存储单元,这样就可以提高查找效率。在构造哈希函数时,一般都要对关键字进行计算,且尽可能使关键字的所有组成部分都能起作用。
常用的哈希函数构造方法有:直接定址法、数字分析法、平方取中法、折叠法、随机数法和除留余数法等。
(1)直接定位法
H
a
s
h
(
k
e
y
)
=
a
×
k
e
y
+
b
(
a
、
b
为常数)
Hash(key)= a × key + b(a、b为常数)
Hash(key)=a×key+b(a、b为常数)
优点【以关键码 key 的某个线性函数值为哈希地址,不会产生冲突,简单、均匀(每个值都有一个唯一位置,效率很高,每个都是一次就能找到)】,缺点【要占用连续地址空间,空间效率低,需要事先知道关键字的分布情况】
(2)数字分析法
某关键字的某几位组合成哈希地址,所选的位应当是:各种符号在该位上出现的频率大致相同
有一组(例如80个)关键码,其样式如下:
- 第1、2位均是“3和4”,第3位也只有“7、8、9”,因此,前三位不能用,余下四位分布较均匀,可作为哈希地址选用。
- 若哈希地址取两位(因元素仅80个),则可取这四位中的任意两位组合成哈希地址,也可以取其中两位与其它两位叠加求和后,取低两位作哈希地址
(3)平方取中法
对关键码平方后,按哈希表大小,取中间的若干位作为哈希地址。
2589 的平方值为:6702921,可以取中间的 029 为地址
(4)折叠法
将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按哈希表表长,取后几位作为哈希地址。
方法一(移位法):将各部分的最后一位对齐相加。
方法二(间界叠加法):从一端向另一端沿分割界来回折叠后,最后一位对齐相加。
元素:42751896
方法一:427+518+96 = 1041
方法二:427 518 96 → \rightarrow → 724+518+69 =1311
(5)随机数法
H a s h ( k e y ) = r a n d o m ( k e y ),其中 r a n d o m 为随机数函数 Hash(key)= random(key),其中 random 为随机数函数 Hash(key)=random(key),其中random为随机数函数
(6)除留余数法
H
a
s
h
(
k
e
y
)
=
k
e
y
m
o
d
p
(
p
是一个整数)
Hash(key)= key \quad mod \quad p (p是一个整数)
Hash(key)=keymodp(p是一个整数)
以关键码除以 p 的余数作为哈希地址,若设计的哈希表长为 m,则一般取
p
≤
m
p ≤ m
p≤m 且为质数 (也可以是不包含小于20质因子的合数),优点【使用场景广泛,不受限制】,缺点【存在哈希冲突,需要解决哈希冲突,哈希冲突越多,效率下降越厉害】。
3. 处理冲突的方法
解决冲突就是为出现冲突的关键字找到另一个“空”的哈希地址。在处理冲突的过程中,可能得到一个地址序列
H
i
(
i
=
1
,
2
,
…
,
k
)
H_i(i = 1,2,…,k)
Hi(i=1,2,…,k)。
常用的解决冲突的方法主要有:开放定址法(开地址法)、 链地址法(拉链法)、 再哈希法(双哈希函数法)、 建立一个公共溢出区,最常用的是开发定址法和链地址法
。
(1)开放定址法(开地址法)
H
i
=
(
H
a
s
h
(
k
e
y
)
+
d
i
)
m
o
d
m
(
1
≤
i
<
m
),
H
a
s
h
(
k
e
y
)为哈希函数,
m
为哈希表长度
d
i
为增量序列
Hi =(Hash(key) + d_i)mod \quad m(1 ≤ i < m) ,Hash(key)为哈希函数,m 为哈希表长度 d_i 为增量序列
Hi=(Hash(key)+di)modm(1≤i<m),Hash(key)为哈希函数,m为哈希表长度di为增量序列
如果两个数据元素的哈希值相同,则在哈希表中为后插入的数据元素另外选择一个表项。当程序查找哈希表时,如果没有在第一个对应的哈希表项中找到符合查找要求的数据元素,程序就会继续往后查找,直到找到一个符合查找要求的数据元素,或者遇到一个空的表项。线性探测带来的最大问题就是冲突的堆积,你把别人预定的坑占了,别人也就要像你一样去找坑。改进的办法有二次方探测法和随机数探测法。开放地址法包括:线性探测、二次探测以及双重散列等方法。
一旦有冲突,就去寻找就找附近(下一个)空的哈希地址,只要哈希表足够大,空的哈希地址总能找到,并将数据元素存入。
哈希表表长为11、哈希函数为 H a s h ( k e y ) = k e y m o d 11 Hash(key)= key \quad mod \quad 11 Hash(key)=keymod11,对于关键码序列 “ 47 , 34 , 13 , 12 , 52 , 38 , 33 , 27 , 3 ” “47,34,13,12,52,38,33,27,3” “47,34,13,12,52,38,33,27,3”,则:
Hash(47)= 47 MOD 11 = 3,Hash(34)= 34 MOD 11 = 1,Hash(13)=13 MOD 11 = 2,
Hash(12)= 12 MOD 11 = 1,Hash(52)=52 MOD 11 = 8,Hash(38)= 38 MOD 11 = 5,
Hash(33)=33 MOD 11 = 0,Hash(27)= 27 MOD 11 = 5,Hash(3)=3 MOD 11 = 3。
使用线性探测法解决冲突构造的哈希表如下:
- 33、34、13、47、38、52均是由哈希函数得到的没有冲突的哈希地址
- Hash(27)= 5,哈希地址有冲突,需寻找下一个空的哈希地址:H1 =Hash(27)mod 11 + 1 = 6,哈希地址 6 为空,因此将 27 存入
- 12 和 3 同样在哈希地址上有冲突,也是由 H1 找到空的哈希地址的,其中12 和 3 还连续移动了两次(二次聚集)
优点【思路清楚,算法简单】,缺点【溢出处理需另编程序,线性探测法很容易产生聚集现象】,可以采取多种方法减少聚集现象的产生,二次探测再散列和随机探测再散列是两种有效的方法。
(2)链地址法(拉链法)
将具有相同哈希地址的记录链成一个单链表,m 个哈希地址就设 m 个单链表,然后用一个数组将 m 个单链表的表头指针存储起来,形成一个动态的结构。有冲突的元素可以插在表尾,也可以插在表头。
哈希表表长为11、哈希函数为 H a s h ( k e y ) = k e y m o d 11 Hash(key)= key \quad mod \quad 11 Hash(key)=keymod11,对于关键码序列 “ 47 , 34 , 13 , 12 , 52 , 38 , 33 , 27 , 3 ” “47,34,13,12,52,38,33,27,3” “47,34,13,12,52,38,33,27,3”,则:
Hash(47)= 47 MOD 11 = 3,Hash(34)= 34 MOD 11 = 1,Hash(13)=13 MOD 11 = 2,
Hash(12)= 12 MOD 11 = 1,Hash(52)=52 MOD 11 = 8,Hash(38)= 38 MOD 11 = 5,
Hash(33)=33 MOD 11 = 0,Hash(27)= 27 MOD 11 = 5,Hash(3)=3 MOD 11 = 3。
使用链地址法构造的哈希表如下:
哈希表中进行成功查找的平均查找长度 ASL 为: A S L = ( 6 × 1 + 3 × 2 ) / 9 ≈ 1.34 ASL = (6 × 1 + 3 × 2)/ 9 ≈ 1.34 ASL=(6×1+3×2)/9≈1.34
(3)再哈希法(双哈希函数法)
H
i
=
R
H
i
(
k
e
y
)(
i
=
1
,
2
,
…
,
k
)
H_i = RH_i(key)(i = 1,2,…,k)
Hi=RHi(key)(i=1,2,…,k)
R
H
i
RH_i
RHi 均是不同的哈希函数,即在同义词发生地址冲突时计算另一个哈希函数地址,直到冲突不再发生。优点【不易产生聚集】,缺点【增加了计算时间】。
(4)建立一个公共溢出区
除设立哈希基本表外,另设立一个溢出向量表。 所有关键字和基本表中关键字为同义词的记录,不管它们由哈希函数得到的地址是什么,一旦发生冲突,都填入溢出表。
4. 哈希表的查找(Hashing)
(1)基本思想
哈希表的主要目的是用于快速查找,且插入和删除操作都要用到查找。在哈希表中进行查找操作时,用与存入元素时相同的哈希函数和冲突处理方法计算得到待查记录的存储地址,然后到相应的存储单元获得有关信息再判定查找是否成功,哈希查找的特点:
- 虽然哈希表在关键字与记录的存储位置之间建立了直接映像,但由于“冲突”的产生,使得哈希表的查找过程仍然是一个给定值和关键字进行比较的过程。因此,仍需要以平均查找长度衡量哈希表的查找效率。
- 在查找过程中需要和给定值进行比较的关键字的个数取决于下列 3 个因素:哈希函数、处理冲突的方法和哈希表的装填因子。
一般情况下,冲突处理方法相同的哈希表,其平均查找长度依赖于哈希表的装填因子。哈希表的装填因子定义为: α = 表中装入的记录数 哈希表的长度 α = \frac{表中装入的记录数}{哈希表的长度} α=哈希表的长度表中装入的记录数
α α α 标志着哈希表的装满程度, α α α 越小,发生冲突的可能性就越小;反之, α α α 越大,表中已填入的记录越多,再填记录时,发生冲突的可能性就越大,则查找时,给定值需与之进行比较的关键字的个数也就越多。
(2)动态演示
- 链地址法
- 开放寻址法
(3)代码实现
// C语言:
// 1. 定义哈希表的结构体,包括表的大小和存储数据的数组。
#define SIZE 100
typedef struct {
int key;
int value;
} Node;
typedef struct {
Node array[SIZE];
} HashTable;
// 2. 实现哈希函数,将关键字映射到哈希表的索引位置。常见的哈希函数是将关键字除以表的大小取余数。
int hashFunc(int key) {
return key % SIZE;
}
// 3. 定义插入操作,将数据插入到哈希表中。根据哈希函数计算出索引位置,然后将数据存储到该位置。
void insert(HashTable* hashtable, int key, int value) {
int index = hashFunc(key);
hashtable->array[index].key = key;
hashtable->array[index].value = value;
}
// 4. 定义查找操作,根据关键字在哈希表中查找对应的值。根据哈希函数计算出索引位置,并与该位置的关键字进行比较,直到找到匹配的关键字或者遇到空位置。
int search(HashTable* hashtable, int key) {
int index = hashFunc(key);
int i = 0;
while (hashtable->array[(index + i) % SIZE].key != key) {
if (hashtable->array[(index + i) % SIZE].key == -1)
return -1; // 没有找到
i++;
}
return hashtable->array[(index + i) % SIZE].value;
}
// 5. 主函数
int main(void) {
HashTable hashtable;
for (int i = 0; i < SIZE; i++) {
hashtable.array[i].key = -1; // 初始化哈希表
}
insert(&hashtable, 1, 10);
insert(&hashtable, 2, 20);
insert(&hashtable, 3, 30);
insert(&hashtable, 4, 40);
insert(&hashtable, 5, 50);
insert(&hashtable, 6, 60);
int value = search(&hashtable, 2);
if (value != -1) {
printf("找到对应的值:%d\n", value);
} else {
printf("没有找到对应的值。\n");
}
return 0;
}
(4)算法性能
将数据按照哈希函数散列到各个存储位置,通过关键字快速定位目标元素。由于哈希函数有可能出现哈希冲突,因此需要解决冲突问题,常用的解决方法包括链式法和开放寻址法。适用于大规模的静态查找表,时间复杂度为 O(1)。