查找算法分为静态查找和动态查找,其中,静态查找包括:顺序查找、折半查找、分块查找;动态查找包括:二叉排序树和平衡二叉树。
1. 顺序查找
把待查找的key放到哨兵的位置,哨兵一般是下标为0的位置,在依次从后往前把表中的元素与key作比较,如果返回值为0,则查找失败,如果返回值为元素的下标值i(i是不等于0的),则查找成功。
哨兵的作用是加快执行速度,免去了每次在查找结束后都要判断查找位置是否越界的步骤。
优点
- 对表中记录的有序性没有要求
- 对存储结构没有要求,顺序存储和链式存储都可以。
缺点
- 当表长过长时,查找效率低下。
2. 折半查找(二分查找)
首先将给定的key值与表中的中间位置的元素作比较,若相等,则返回该元素的存储位置;若不相等,则表示所需查找的元素只能在中间元素以外的前半部分或者后半部分,然后缩小范围,在小范围内继续进行同样的查找,知道找到为止。若关键字不在表中时停止查找的标志是:查找范围的上界<=查找范围的下届
时间复杂度
O(logn)
缺点
只适用于顺序存储结构,且要求表中数据按关键字有序排列
目录
经典的二分查找
有重复值的二分查找变体
左侧边界的二分查找
右侧边界的二分查找
寻找最后一个小于等于value的位置
寻找第一个大于等于value的位置
经典的二分查找
二分查找是一种经典的查找算法,只适用在有序的数据上,时间复杂度为O(logn),可以说是相当高效的查找算法了。
2.2 代码实现
template<typename T>
int bsearch(T arr[], int size, T value) {
int left = 0, right = size - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] > value)
right = mid - 1;
else if (arr[mid] < value)
left = mid + 1;
else if (arr[mid] == value)
return mid;
}
return -1;
}
我们这里right=size-1,所以right是合法的下标,数组的范围是[left,right],是闭区间,因此while循环的判定条件是left <= right,同时如果走arr[mid] > value的分支,那么数组范围变为[left,mid-1];如果走arr[mid] < value的分支,那么数组范围变为[mid+1,right],注意这都是闭区间。
如果right=size,那么right不是合法的下标,数组的范围是[left,right),是开区间,那么while循环的判定条件需要改成left<right,同时如果arr[mid] > value的分支,那么数组范围变为[left,mid),这时候就要写成right=mid而不是right=mid-1了;如果走arr[mid] < value的分支,那么数组范围变为[mid+1,right)。
为了防止计算mid=(left+right)/2时(left+right)过大溢出,所以使用mid = left + (right - left) / 2这种写法。
有重复值的二分查找变体
左侧边界的二分查找
来看有重复值的情况,例如{1,2,2,2,5},现在我们希望查找第一个值为2的元素的下标即左侧边界,显然用上面经典的二分查找写法会找到中间那个2的位置,不是我们想要的。
我们只需要在经典写法上进行小小的修改即可:
//寻找arr中值为value的左侧边界,也就是arr中有重复的值,寻找第一个等于value的位置
template<typename T>
int bsearch_left(T arr[], int size, T value) {
int left = 0, right = size - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] > value)
right = mid - 1;
else if (arr[mid] < value)
left = mid + 1;
else if (arr[mid] == value) {
if (mid == 0 || arr[mid - 1] != value)
return mid;
else
right = mid - 1; //right往左侧收缩
}
}
return -1;
}
修改的地方在于arr[mid] == value的分支,arr[mid]等于value时,如果arr[mid]的前一个元素arr[mid-1]不等于value或者说mid就是第一个元素时,我们可以确定mid就是value的左侧边界的位置;否则right往左侧收缩。
这种写法应该比较清晰。
右侧边界的二分查找
同样的我们查找例如{1,2,2,2,5}中最后一个值为2的元素的下标位置,这种情况和左侧边界类似,也是在等于的判断分支那里修改一下即可:
//寻找arr中值为value的右侧边界,也就是arr中有重复的值,寻找最后一个等于value的位置
template<typename T>
int bsearch_right(T arr[], int size, T value) {
int left = 0, right = size - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] > value)
right = mid - 1;
else if (arr[mid] < value)
left = mid + 1;
else if (arr[mid] == value) {
if (mid == size - 1 || arr[mid + 1] != value)
return mid;
else
left = mid + 1; //left往右侧收缩
}
}
return -1;
}
当arr[mid]等于value时,我们需要看看arr[mid]的后一个元素arr[mid+1]是否也等于value,如果说arr[mid+1]不等于value或者mid就是数组最末尾的位置,说明mid就是value的右侧边界的位置,否则left往左侧收缩。
寻找最后一个小于等于value的位置
template<typename T>
int bsearch_less_equal(T arr[], int size, T value) { //寻找最后一个小于等于value的位置
int left = 0, right = size - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] <= value) {
if (mid == size - 1 || arr[mid +1] > value)
return mid;
else
left = mid + 1;
}
else
right = mid - 1;
}
return -1;
}
万变不离其宗,寻找小于等于value的位置,那我们就在arr[mid] <= value的分支上修改。【最后一个】那么就需要判断mid == size - 1 和 arr[mid +1] > value,注意因为是最后一个小于等于value的值,说明这个值的后面一个值必定是大于value,所以判断arr[mid +1] > value。
寻找第一个大于等于value的位置
template<typename T>
int bsearch_greater_equal(T arr[], int size, T value) { //寻找第一个大于等于value的位置
int left = 0, right = size - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] >= value) {
if (mid == 0 || arr[mid -1] < value)
return mid;
else
right = mid - 1;
}
else
left = mid + 1;
}
return -1;
}
寻找第一个大于等于value的位置,那我们就在arr[mid]>= value的分支上修改。【第一个】那么就需要判断mid == 0 和 arr[mid -1] < value,注意因为是第一个大于等于value的值,说明这个值的前一个值必定是小于value,所以判断arr[mid -1] < value。
分块查找(索引顺序查找)
先将查找表分成若干个子表,要求第一个块的最大关键字小于第二个块的所有关键字,以此类推,这样保证块间是有序的,把各个子表中的最大关键字组成一个索引表,表中还包含各子表的起始地址。表的特点就是块间有序,块内无序。因此,查找时块间进行顺序查找或折半查找,块内进行顺序查找。
动态查找表
二叉排序树(二叉查找树)
定义
若该树有左子树,则左子树的所有节点值小于根节点的值;若该树有右子树,则其右子树的所有节点的值都大于根节点的值,且左右子树也分别为二叉排序树
查找过程
当二叉排序树不为空时,首先将给定的值与根节点的关键字作比较,若相等,则查找成功,否则将依据给定的值和根节点的关键字之间的大小关系,分别在左子树或者右子树上继续查找,若树中不存在关键字等于给定值的节点时,则该给定值作为树的新节点进行插入。
注:中序遍历二叉排序树可以得到一个关键字的有序序列
平衡二叉树
或者是一颗空树,它的左子树和右子树的高度之差绝对值不大于1,且它的左右子树也是平衡二叉树。
采用二叉链表
目录
二叉树相关概念和术语
二叉树特殊类型
二叉树的存储
链式存储
顺序存储
二叉树的遍历
二叉查找树
查找
插入
删除
完整代码
时间复杂度分析
二叉树相关概念和术语
二叉树的递归定义为:二叉树是一棵空树,或者是一棵由一个根节点和两棵互不相交的,分别称作根的左子树和右子树组成的非空树;左子树和右子树又同样都是二叉树。
度:一个节点拥有子树的数目称为结点的度,叶子结点的度为0。
叶子结点:也称为终端结点,没有子树的结点或者度为零的结点。
结点的层次:从根结点开始,假设根结点为第1层,根节点的子结点为第2层,依此类推,如果某一个结点位于第L层,则其子结点位于第L+1层。
二叉树结点的深度:指从根节点到该结点的最长简单路径边的条数。
二叉树结点的高度:指从该节点到叶子结点的最长简单路径边的条数。
结点A的度为2,结点C的度为1,叶子结点为GHIJKL,度都为0。
结点的高度,深度,层数如图(注意高度和深度有的地方从0开始计数,有的地方从1开始计数)
二叉树特殊类型
斜树不多赘述,一般是退化成链表的形式时导致性能下降的情形。
完全二叉树:深度为k,有n个节点的二叉树当且仅当其每一个节点都与深度为k的满二叉树中编号从1到n的节点一一对应时,称为完全二叉树(叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部)。
满二叉树:如果一棵二叉树只有度为0的节点和度为2的节点,并且度为0的节点在同一层上。满二叉树是完全二叉树,完全二叉树不一定是满二叉树。
二叉树的存储
链式存储
template <typename T>
struct treeNode {
T data;
treeNode<T>* left;
treeNode<T>* right;
};
1
2
3
4
5
6
结点的定义:存储的数据,左子树指针,右子树指针。
顺序存储
我们把结点存储在数组中,父节点存储在下标为i的位置,那么左子树存储在2*i的位置,右子树存储在2*i+1的位置。举个例子,假设根结点存储在下标为i=1的位置,那么根结点的左子树存储在2*i=2的位置,根结点的右子树存储在2*i+1=3的位置,以此类推。
上图是一颗完全二叉树,使用顺序存储存储率就比较高,或者说空间利用率比较高。假设上图没有结点F,那么下标为6的位置就置空,不存储数据。
二叉树的遍历
前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
以上面顺序存储中的完全二叉树为例子,
前序遍历:ABDHIECFG
中序遍历:HDIBEAFCG
后序遍历:HIDEBFGCA
递归实现
void preOrder(Node* root) {
if (root == null) return; //递归结束条件
std::cout<<root->data<<std::endl;
preOrder(root->left);
preOrder(root->right);
}
void inOrder(Node* root) {
if (root == null) return; //递归结束条件
inOrder(root->left);
std::cout<<root->data<<std::endl;
inOrder(root->right);
}
void postOrder(Node* root) {
if (root == null) return; //递归结束条件
postOrder(root->left);
postOrder(root->right);
std::cout<<root->data<<std::endl;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
二叉查找树
二叉查找树又叫二叉搜索树,二叉排序树。
对于树中任意一个结点,左子树中的每一个结点的值都小于这个结点的值,右子树中的每一个结点的值都大于这个结点的值。下图就是一个二叉查找树的例子,可以看到根结点的左子树中每一个结点都小于12,根结点右子树中每一个结点都大于12。
查找
假设我们要查找的值为value,我们从根结点开始,比较结点的值和value的大小:
如果结点的值等于value,返回
如果结点的值比value小,那么在左子树递归查找
如果结点的值比value大,那么在右子树递归查找
template<typename T>
treeNode<T>* binarySearchTree<T>::find(T data) {
treeNode<T>* n = root;
while (n != nullptr) {
if (data > n->data)n = n->right;
else if (data < n->data)n = n->left;
else return n;
}
return nullptr;
}
1
2
3
4
5
6
7
8
9
10
这里使用了非递归的写法,逻辑应该是比较清晰了。
插入
插入就是查找到一个能插入的位置,所以和上面查找的过程很类似。
假设我们要插入的值为value,我们从根结点开始,比较结点的值和value的大小:
如果结点的值比value小,那么就看结点的右子树是否为空,如果为空就将value插入到结点的右子树,如果不为空就在右子树递归插入
如果结点的值比value大,那么就看结点的左子树是否为空,如果为空就将value插入到结点的左子树,如果不为空就在左子树递归插入
template<typename T>
void binarySearchTree<T>::insert(T data) {
if (root == nullptr) {
root = new treeNode<T>(data);
return;
}
treeNode<T> *n = root;
while (n != nullptr) {
if (data > n->data) {
if (n->right == nullptr) {
std::cout << n->data << "->right=" << data << std::endl;
n->right = new treeNode<T>(data);
return;
}
n = n->right;
}
else if (data < n->data) {
if (n->left == nullptr) {
std::cout << n->data << "->left=" << data << std::endl;
n->left = new treeNode<T>(data);
return;
}
n = n->left;
}
else //如果是相同的值就不用插入了,这里不支持重复值
return;
}
}
这里也使用了非递归的写法,逻辑也是比较清晰了,需要注意的是要先判断root是否为空,如果为空就直接将插入的结点作为根结点。
删除
删除相对来说就比较复杂了,需要分三种情况讨论:要删除的结点有0个、1个、2个子结点(假设要删除的结点为n):
n有0个子结点,即n是叶子结点:直接将父节点指向n的指针置为nullptr就可以了。
n有1个子结点,将父节点指向n的指针 重新赋值指向 n的子结点。
n有2个子结点,我们要找到一个结点顶替n,这个结点必须是大于n的第一个结点或者小于n的最后一个结点,分别对应【n的右子树】中最小的结点和【n的左子树】中最大的结点。这里我们使用【n的右子树】中最小的结点,就是遍历【n的右子树】的左子树,直到叶子结点,然后交换这个结点和n的位置,删除结点n。在代码的实现中我们用了一种取巧的方法,下面会讲述。
如果要删除结点20,那么将结点18的右子树置为nullptr,然后释放结点20的空间。
如果要删除结点14,那么将结点18的左子树指向结点13,然后释放结点14的空间。
如果要删除结点8,我们要先找到结点8的右子树中的最小值,也就是在结点9,交换结点8和结点9的位置,然后删除结点8。
template<typename T>
void binarySearchTree<T>::remove(T data) {
if (root == nullptr)return;
treeNode<T>* n = root, * parent = nullptr; //n指向要删除的结点,parent是n的父节点
while (n != nullptr && n->data != data) {
parent = n;
if (data > n->data)n = n->right;
else n = n->left;
}
if (n == nullptr) {
std::cout << "remove() : cant find data=" << data << std::endl;
return;
}
//情况一:要删除的结点n有两个子节点
if (n->left != nullptr && n->right != nullptr) {
treeNode<T>* min = n->right; //min:n右子树中最小的结点
treeNode<T>* min_parent = n; //min_parent:min的父节点
while (min->left != nullptr) {
min_parent = min;
min = min->left;
}
n->data = min->data; //这里取巧将min和n的data进行交换
n = min; //那么要删除的结点n变成了min
parent = min_parent; //注意此时parent已经是原来min(n)的父节点了
//注意这里比较巧妙的点:要删除的节点已经从 参数给定的data的节点 转移为 删除min了
//min是绝对没有左子树的,所以min只能是有一个子节点(右子树)或者没有子节点的,刚好符合下面的情况二和情况三
//所以接着运行就可以删除min(即n)
}
//情况二:要删除的结点n有且只有一个子节点
treeNode<T>* child; //n的子节点
if (n->left != nullptr)
child = n->left;
else if (n->right != nullptr)
child = n->right;
else //情况三:要删除的结点n没有子节点
child = nullptr;
if (parent == nullptr) //要删除的是根节点
root = child;
else if (parent->left == n)
parent->left = child;
else
parent->right = child;
delete n;
}
首先是要先找到要删除的结点,这里和查找类似,但是需要多一个变量parent要存储要删除的结点的父节点。
注意这里的处理不同情况的顺序和我们上面说的顺序相反,这里先处理有两个子结点的情况:和上面说的一样,要找到n的右子树中最小的结点,存储在min中,min_parent存储min的父节点。然后我们这里不是交换结点,而是交换n和min的值,没有任何指针的改变,那么现在问题就转换成了 要删除原来min的那个结点(因为交换,现在的值已经是n的值了)。
举个例子说明,我们现在要删除左图中的结点8,我们找到结点8的右子树中的最小值结点9,交换结点8和结点9的值变成了右图中的样子,现在的问题是不是就变成了删除右图中的结点8?注意右图中的结点8一定是叶子结点或者只有右子树的(不可能有左子树,因为如果有左子树,左子树才是最小值),那么现在就转换成了另外两种情况了,所以我们的代码中先写这种情况。
剩余的两种情况就简单了,看代码应该能看得懂了。完整代码
#ifndef BINARY_SEARCH_TREE
#define BINARY_SEARCH_TREE
#include <iostream>
template <typename T>
struct treeNode {
T data;
treeNode<T>* left=nullptr;
treeNode<T>* right=nullptr;
treeNode(T data) {
this->data = data;
}
};
template<typename T>
class binarySearchTree {
private:
treeNode<T>* root=nullptr;
public:
treeNode<T>* find(T data);
void insert(T data);
void remove(T data);
void preorder();
void inorder();
void postorder();
private:
void preorder(treeNode<T>* node);
void inorder(treeNode<T>* node);
void postorder(treeNode<T>* node);
};
template<typename T>
treeNode<T>* binarySearchTree<T>::find(T data) {
treeNode<T>* n = root;
while (n != nullptr) {
if (data > n->data)n = n->right;
else if (data < n->data)n = n->left;
else return n;
}
return nullptr;
}
template<typename T>
void binarySearchTree<T>::insert(T data) {
if (root == nullptr) {
root = new treeNode<T>(data);
return;
}
treeNode<T> *n = root;
while (n != nullptr) {
if (data > n->data) {
if (n->right == nullptr) {
std::cout << n->data << "->right=" << data << std::endl;
n->right = new treeNode<T>(data);
return;
}
n = n->right;
}
else if (data < n->data) {
if (n->left == nullptr) {
std::cout << n->data << "->left=" << data << std::endl;
n->left = new treeNode<T>(data);
return;
}
n = n->left;
}
else //如果是相同的值就不用插入了,这里不支持重复值
return;
}
}
template<typename T>
void binarySearchTree<T>::remove(T data) {
if (root == nullptr)return;
treeNode<T>* n = root, * parent = nullptr; //n指向要删除的结点,parent是n的父节点
while (n != nullptr && n->data != data) {
parent = n;
if (data > n->data)n = n->right;
else n = n->left;
}
if (n == nullptr) {
std::cout << "remove() : cant find data=" << data << std::endl;
return;
}
//情况一:要删除的结点n有两个子节点
if (n->left != nullptr && n->right != nullptr) {
treeNode<T>* min = n->right; //min:n右子树中最小的结点
treeNode<T>* min_parent = n; //min_parent:min的父节点
while (min->left != nullptr) {
min_parent = min;
min = min->left;
}
n->data = min->data; //这里取巧将min和n的data进行交换
n = min; //那么要删除的结点n变成了min
parent = min_parent; //注意此时parent已经是min(n)的父节点了
//注意这里比较巧妙的点:要删除的节点已经从 参数给定的data的节点 转移为 删除min了
//min是绝对没有左子树的,所以min只能是有一个子节点(右子树)或者没有子节点的,刚好符合下面的情况二和情况三
//所以接着运行就可以删除min(即n)
}
//情况二:要删除的结点n有且只有一个子节点
treeNode<T>* child; //n的子节点
if (n->left != nullptr)
child = n->left;
else if (n->right != nullptr)
child = n->right;
else //情况三:要删除的结点n没有子节点
child = nullptr;
if (parent == nullptr) //要删除的是根节点
root = child;
else if (parent->left == n)
parent->left = child;
else
parent->right = child;
delete n;
}
template<typename T>
void binarySearchTree<T>::preorder() {
if (root == nullptr)return;
preorder(root);
std::cout << std::endl;
}
template<typename T>
void binarySearchTree<T>::inorder() {
if (root == nullptr)return;
inorder(root);
std::cout << std::endl;
}
template<typename T>
void binarySearchTree<T>::postorder() {
if (root == nullptr)return;
postorder(root);
std::cout << std::endl;
}
template<typename T>
void binarySearchTree<T>::preorder(treeNode<T> *node) {
if (node== nullptr)return;
std::cout << node->data << " ";
preorder(node->left);
preorder(node->right);
}
template<typename T>
void binarySearchTree<T>::inorder(treeNode<T>* node) {
if (node == nullptr)return;
inorder(node->left);
std::cout << node->data << " ";
inorder(node->right);
}
template<typename T>
void binarySearchTree<T>::postorder(treeNode<T>* node) {
if (node == nullptr)return;
postorder(node->left);
postorder(node->right);
std::cout << node->data << " ";
}
#endif
时间复杂度分析
从上面查找、插入和删除的分析来看,二叉查找树的查找,插入和删除的时间复杂度其实和树的高度h成正比,所以时间复杂度都是O(h)。现在问题就转换成了求二叉查找树的高度h,让我们来看看下面几种情况:
斜树的高度明显就是n,因此查找,插入和删除的时间复杂度是O(n),此时的二叉查找树极度不平衡,退化成链表了。
假设完全二叉查找树有L层,第L层的叶子结点个数在[1, 2^(L-1)] 区间内(满二叉查找树第x层结点的个数为2^(x-1))。 从1到(L-1)层的结点个数总和为1+2+4+…+2^(L-2) = 2^(L-1)-1。
因此L层的完全二叉树的结点总个数在[2^(L-1) , 2^L-1]区间内,L在[log2(n+1),log2(n)+1]区间内,h=L-1,所以h在[log2(n+1)-1,log2(n)]区间内,所以完全二叉查找树的查找、插入和删除的时间复杂度是O(logn)。
可以看出,比较平衡的二叉查找树的性能是比较好的,但是在不平衡乃至极端的斜树的情况下,性能就下降的比较明显,因此为了避免性能的退化,就有了各种平衡的二叉查找树的设计,让性能稳定在O(logn),像AVL树,红黑树等等。
B树和B+树
B树(多路平衡查找树)
B树,又称多路平衡查找树,B树中所有节点的孩子个数的最大值称为B树的阶,通常用m表示。一颗m阶B树或为空树,或为满足如下特性的m叉树:
- 树中的每个节点至多有m颗子树,至少还有m-1个关键字
- 若根节点不是终端节点,则至少有两颗子树
- 除根节点外的所有非叶节点至少还有【m/2】颗子树,即至少含有【m/2】-1个关键字
- 所有的叶节点都出现在同一层次上,并且不带信息。
B+树
B+树是应数据库所需而出现的一种B树的变形树。
一颗m阶B+树需满足下列条件:
- 每个分支节点最多有m颗子树
- 非叶根节点至少有两颗子树,其他每个分支节点至少【m/2】颗子树
- 节点的子树个数与关键字个数相等
- 所有叶节点包含全部关键字及指向相应记录的指针
(散列表)哈希表查找
根据关键字码的值直接访问的数据结构,即通过关键字的值映射到表中的一个位置以加快查找速度,其中映射函数叫做散列函数,存放记录的数组叫做散列表。
散列函数的构造方法
- 直接定址法
- 除留余数法
- 平方取中法
- 数字分析法
- 折叠法
- 随机数法
哈希冲突的解决方法
- 线性探测法
- 平方探测法
- 再散列法
- 伪随机序列法
- 拉链法