查找操作是数据处理中使用最频繁的一项操作。在本章中,会介绍两种查找算法:顺序查找和折半查找,并带大家实现这两种算法。除了介绍算法本身,还会介绍比较算法性能的一个重要指标:平均查找长度。并教大家分析不同查找算法的平均查找指标。在本章的最后要用折半查找算法解决一道难题。
在这里一章里,你将会学习查找算法。想必你应该很熟悉“查找”这个词。那么“查找算法”中的“查找”是什么意思呢?查找(search) 是指在数据集合中寻找满足某种条件的数据元素的过程。用于查找的数据集合则称为 查找表(search table)。查找表中的数据元素类型是一致的,并且有能够唯一标识出元素的 关键字(keyword)。如果从查找表中找出了关键字等于某个给定值的数据元素,则称为 查找成功,否则称 查找不成功。
通常对查找表有 4 种操作:
-
查找:在查找表中查看某个特定的记录是否存在
-
检索:查找某个特定记录的各种属性
-
插入:将某个不存在的数据元素插入到查找表中
-
删除:从查找表中删除某个特定元素
如果对查找表只执行前两种操作,则称这类查找表为 静态查找表(static search table)。静态查找表建立以后,就不能再执行插入或删除操作,查找表也不再发生变化。对应的,如果对查找表还要执行后两种操作,则称这类查找表为 动态查找表(dynamic search table)。本章我们要介绍的查找算法都是针对静态查找表的,比如顺序查找、折半查找、分块查找等;而对于动态查找表,往往使用二叉平衡树、B-树或哈希表来处理。
对于各种各样的查找算法,我们要如何比较他们的优劣呢?通常,我们使用 平均查找长度(average search length, ASL) 来衡量查找算法的性能。对于含有 n 个元素的查找表,定义查找成功的平均查找长度为
ASL=∑i=0nPiCi
其中 Pi 是搜索查找表中第 i 个记录的概率,并且 ∑i=1nPi=1(通常我们认为每个元素被查找的概率相等,即 Pi=n1)。Ci 是指搜索查找表中第 i 个元素时直到查找成功为止,表中元素的比较次数。考虑到查找不成功的情况,查找算法的平均查找长度应该是查找成功的平均查找长度和查找不成功的平均查找长度之和。通常我们在说平均查找长度时,不考虑查找不成功的情况。
比如一个给定的查找表 A=[1,2,3,4,5],其中每个 Pi=51。若对于某个查找算法,每个元素到查找成功为止的比较次数 C=[1,2,3,4,5]。则
ASL=∑i=0nPiCi=51∑i=0nCi=3
所以,该查找算法的平均查找长度为 3。
在本章后面的课程中,我们会继续巩固平均查找长度的概念,并会给大家介绍顺序查找、折半查找和分块查找这三种查找算法,分析他们的算法性能,并带你在顺序表上实现前两种查找算法。在本章的最后要用折半查找算法解决一道难题。
顺序查找(又称线性查找,sequential search),是指在线性表中进行的查找算法。顺序查找算法是最直观的一种查找算法,它从线性表的一端出发,逐个比对关键字是否满足给定的条件。
顺序查找按照查找表中数据的性质,分为对一般的无序线性表的顺序查找和对按关键字有序的线性表的顺序查找。下面我们分别对这两种查找算法进行讲解。
对于一般线性表的查找,基本思想是从线性表的一端开始,逐个比对关键字是否满足给定的条件。若找到某个元素的关键字满足给定的要求,则查找成功;若一直找到线性表的另一端仍未有满足要求的元素,则查找不成功。让我们来一起分析一下在一般线性表上的查找算法的平均查找长度。
对于有 n 个元素 a0,a1,...,an−1 的查找表,每个元素的查找概率 Pi=n1。若每次查找都从第一个元素 a0 开始,则查找第 i 个元素 ai−1 时,需进行 Ci=i+1 次比较操作。因此,查找成功的平均查找长度为
ASLsuccess=∑i=0nPi∗(i+1)=2n+1
而当查找不成功时,与查找表中各个元素的比较次数为 n 次,因此查找不成功的平均查找长度为
ASLfailed=n
对于有序表的顺序查找,在查找成功时与一般线性表的查找是一样的。而对于查找不成功的情况,无需和表中所有元素都进行比对就可以确认查找不成功,这样能降低查找不成功时的平均查找长度。
具体来说,假设查找表 a0,a1,...,an−1 是从小到大排列的,查找的顺序是从左向右。若待检索的关键字为 key,当查找到第 i 个元素时,如果第 i 个元素的值大于 key,而之前并没有查找成功时,就可以认为查找不成功了。
很显然,通过这样的优化,我们能将查找不成功的平均查找长度降低。假设对于所有查找不成功的关键字 key,落在 (−∞,a0),(a0,a1),...,(an−2,an−1),(an−1,∞) 这 n+1 个区间的概率是相等的,都是 n+11。那么查找不成功的平均查找长度为
ASLfailed=n+11+2+3+...+n+n=2n+n+1n
大致是之前查找不成功的平均查找长度 n 的一半,效率提升还是很明显的。当然,这种方法只适用于有序表。
在接下来的课程中,将会继续巩固顺序查找的概念和平均查找长度的计算,并动手实现对有序表的顺序查找算法。
#include <iostream>
#include <cstring>
using namespace std;
class Vector {
private:
int size, length;
int *data;
public:
Vector(int input_size) {
size = input_size;
length = 0;
data = new int[size];
}
~Vector() {
delete[] data;
}
bool insert(int loc, int value) {
if (loc < 0 || loc > length) {
return false;
}
if (length >= size) {
expand();
}
for (int i = length; i > loc; --i) {
data[i] = data[i - 1];
}
data[loc] = value;
length++;
return true;
}
void expand() {
int * old_data = data;
size = size * 2;
data = new int[size];
for (int i = 0; i < length; ++i) {
data[i] = old_data[i];
}
delete[] old_data;
}
int search(int value) {
for(int i = 0; i < length; i++) {
if(data[i] == value) {
return i;
}
else if(data[i] > value) {
return -1;
}
}
return -1;
}
bool remove(int index) {
if (index < 0 || index >= length) {
return false;
}
for (int i = index + 1; i < length; ++i) {
data[i - 1] = data[i];
}
length = length - 1;
return true;
}
void print() {
for (int i = 0; i < length; ++i) {
if (i > 0) {
cout << " ";
}
cout << data[i];
}
cout << endl;
}
};
int main() {
Vector a(100);
a.insert(0, 2);
a.insert(1, 4);
a.insert(2, 6);
a.insert(3, 8);
a.insert(4, 10);
cout << a.search(4) << endl;
cout << a.search(5) << endl;
return 0;
}
折半查找:
前面的课程中我们讲到过,对于有序表,查找不成功的平均查找长度是可以优化的。那么对于查找成功的情况,能不能利用有序表的关键字有序的特性,对效率进行优化呢?
聪明的你可以先思考一下,再点击下一页哦。
这个更优的查找算法就是折半查找算法。它的基本流程如下:
-
首先确定待查关键字在有序(这里我们假设是升序,即从小到大)的查找表中的范围。通常用两个下标来表示范围:left=0,right=length−1。
-
然后用给定的关键字和查找表的正中间位置(下标为 mid=2left+right)元素的关键字进行比较,若相等,则查找成功;若待查关键字比正中间位置元素的关键字大,则继续对右子表(left=mid+1)进行折半查找;否则对左子表(right=mid−1)进行折半查找。
-
如此重复进行,直到查找成功或范围缩小为空(left>right)即查找不成功为止。
我们用一个例子来模拟折半查找算法。
已知一个有序表:
a=(1,3,5,7,16,32)
现在要查找关键字为 16 的数据元素。
首先初始化范围:left=0,right=5。此时 mid=2left+right=2,发现 amid=5<16,所以让 left=mid+1=3。
进行第二轮查找,此时 mid=2left+right=4,发现 amid=16 和关键字 16 相等,相等意味着查找成功,mid 的下标就是我们查找的结果。
下面我们来一起分析一下折半查找算法的平均查找长度 ASL。为了直观,我们把有序表折半查找的过程用一棵 二叉判定树(binary decision tree, BDT) 来表示,如下图:
通过这样一棵树,我们可以很容易计算出折半查找算法查找成功的平均查找长度
ASL=1∗61+2∗62+3∗63=37
而如果用顺序查找算法,平均查找长度为
ASL=2n+1=3>37
可以看出,折半查找是比顺序查找更高效的查找算法。
无论查找成功还是查找不成功,在有序表中查找某个关键字的过程就是从根节点出发走到该关键字对应结点的路径,而路径的长度就对应着查找长度。与此同时,这个路径长度也对应着该结点在树上的深度。由于树的深度是 ⌊O(log2n)⌋+1,所以每次查找不成功的比较次数不会超过 ⌊O(log2n)⌋+1。
因此,折半查找的时间复杂度为 O(log2n),明显优于时间复杂度为 O(n) 的顺序查找算法。不过一定要注意,折半查找只适用于关键字有序的顺序表,无序的线性表如果想使用折半查找要先进行排序操作,而链表因为无法随机存取所以没有办法使用折半查找。
后面的课程中,会继续巩固折半查找的概念,并带你动手实现关键字有序的顺序表上的折半查找算法。
#include <iostream>
#include <cstring>
using namespace std;
class Vector {
private:
int size, length;
int *data;
public:
Vector(int input_size) {
size = input_size;
length = 0;
data = new int[size];
}
~Vector() {
delete[] data;
}
bool insert(int loc, int value) {
if (loc < 0 || loc > length) {
return false;
}
if (length >= size) {
expand();
}
for (int i = length; i > loc; --i) {
data[i] = data[i - 1];
}
data[loc] = value;
length++;
return true;
}
void expand() {
int * old_data = data;
size = size * 2;
data = new int[size];
for (int i = 0; i < length; ++i) {
data[i] = old_data[i];
}
delete[] old_data;
}
int search(int value) {
int left = 0, right = length - 1;
while(left <= right) {
int mid = (left + right) / 2;
if(data[mid] == value) {
return mid;
}
else if(data[mid] < value) {
left = mid + 1;
}
else {
right = mid - 1;
}
}
return -1;
}
bool remove(int index) {
if (index < 0 || index >= length) {
return false;
}
for (int i = index + 1; i < length; ++i) {
data[i - 1] = data[i];
}
length = length - 1;
return true;
}
void print() {
for (int i = 0; i < length; ++i) {
if (i > 0) {
cout << " ";
}
cout << data[i];
}
cout << endl;
}
};
int main() {
Vector a(100);
a.insert(0, 2);
a.insert(1, 4);
a.insert(2, 6);
a.insert(3, 8);
a.insert(4, 10);
cout << a.search(4) << endl;
cout << a.search(5) << endl;
return 0;
}
排序:
什么是 排序(sorting) 算法?给定一个 n 个元素的线性表 (a0,a1,...,an−1),排序算法返回这个序列重新排列的结果 (A0,A1,...,An−1),满足 A0≤A1≤...≤An−1。这里的小于等于号可以替换为其他比较符号。
比如,对于一个线性表 (5,2,3,7,6),排序算法会返回 (2,3,5,6,7)。当然,也可以是 (7,6,5,3,2)。
计算机科学发展至今,已经出现了许多种不同的排序算法。在本章的课程中,我们会着重介绍 插入排序(insertion sort)、冒泡排序(bubble sort)、归并排序(merge sort)、选择排序(selection sort) 和 快速排序(quick sort)这 5 种排序算法。
对于排序算法,有很多种分类方法。
根据算法的时间复杂度,可以将排序算法分为复杂度为 O(nlogn)、O(n) 或 O(n2) 等时间复杂度的排序算法。比如 O(n) 的 基数排序(radix sort)、O(nlogn) 的归并排序、O(n2) 的冒泡排序。
根据排序过程中元素是否完全保存在内存中,可以将算法分为 内部排序(internal sort) 和 外部排序(external sort)。本章介绍的这 5 种排序算法都是内部排序算法。
对于一个排序算法,如果排序前的线性表中满足 i<j 且 ai=aj 的任意两个元素 ai 和 aj,在排序后 ai 仍在 aj 之前,则称这个排序算法为 稳定排序(stable sort),否则称这个排序算法为 不稳定排序(unstable sort)。
排序算法在数据结构和算法中有非常重要的地位。有很多算法都要基于排序算法的结果来进行,比如折半查找算法等。对于现有的排序算法,我们已经可以证明其中几种(堆排序、归并排序)的运行时间上界 O(nlogn) 和比较排序算法(不考虑不用通过比较获得排序结果的希尔排序等)的最坏情况下界是相等的,也就是说,这几种算法已经是渐进最优的比较排序算法。
在本章的课程里,我们会按照排序的稳定性,首先介绍三种稳定排序:插入排序、冒泡排序和归并排序;之后介绍两种不稳定排序:选择排序和快速排序。在本章的最后,要用排序算法解决一道难题。
我们回顾一下排序算法的稳定性的概念:如果线性表中的两个元素 ai 和 aj 满足 i<j 且 ai=aj,那么这两个元素在排序在经过稳定排序以后 ai 一定在 aj 的前面。
在稳定排序算法中,有三个最知名的排序算法:插入排序、冒泡排序和归并排序。这三个排序算法的名字都很能体现出它们各自的特点。
下面,我们就来一一学习这三种稳定的排序算法。
首先介绍的是插入排序算法。
插入排序是一种非常直观的排序算法,它的基本思想是将线性表分为已排序的前半部分和待排序的后半部分,从待排序部分选出第一个元素,插入到已排序部分的对应位置中,直到全部记录都插入到已排序部分中。
插入排序每次插入的时间复杂度为 O(n),一共执行 n−1 次,因此总体时间复杂度为 O(n2)。查找插入位置的过程可以使用折半查找算法将查找的时间复杂度优化到 O(logn),但因为还需要 O(n) 的时间复杂度来在顺序表上执行移动操作,所以总体时间复杂度依然是 O(n2)。
插入排序的算法演示如下:
接下来介绍冒泡排序算法。
和插入排序算法不同,冒泡排序算法是一种基于交换的排序算法。基于交换的排序,是指根据线性表中两个元素关键字的比较结果来对换这两个元素在序列中的位置。
冒泡排序算法的基本思想为:假如待排序线性表的长度为 n,从前往后两两比较相邻元素的关键字,若 ai−1>ai,则交换它们,直到线性表比较完成。每趟交换以后最后一个元素一定是最大的,不再参与下一趟交换。也就是对于第 i 趟交换,只需要比较到 an−i 即可。直到一趟比较内没有进行交换,算法结束。时间复杂度和插入排序一样,也为 O(n2)。
冒泡排序的算法演示如下:
这节课最后一个要介绍的排序算法是归并排序算法。
什么是“归并”?归并的意思是将两个有序的线性表组合成一个新的有序线性表。这次我们先来看算法的演示动画:
对于归并排序,若当前要排序的区间为 a0...an−1,则首先让 a0...a2n−1 和 a2n−1+1...an−1 这两个区间内的元素有序,再将这两个区间合并成一个更大的有序区间,直到整个线性表都被排序完成。
回顾一下刚才的动画,归并排序一共需要进行 O(logn) 层归并操作,每层归并操作的总时间复杂度为 O(n),因此总体的时间复杂度为 O(nlogn)。和其他排序有所不同,为了实现归并操作,每次合并都需要开辟额外的空间来临时保存合并后的排序结果,总共需要开辟 n 个元素的空间,所以归并排序的空间复杂度为 O(n)。
接下来,我们就在顺序表上依次实现这三种排序算法吧。
通常来讲, 我们不会真正的首先判断插入的位置,再进行移动。而是边移动边判断是否已经移动到对应的位置。
插入排序:
#include <iostream>
#include <cstring>
using namespace std;
class Vector {
private:
int size, length;
int * data;
public:
Vector(int input_size) {
size = input_size;
length = 0;
data = new int[size];
}
~Vector() {
delete[] data;
}
bool insert(int loc, int value) {
if (loc < 0 || loc > length) {
return false;
}
if (length >= size) {
return false;
}
for (int i = length; i > loc; --i) {
data[i] = data[i - 1];
}
data[loc] = value;
length++;
return true;
}
void print() {
for (int i = 0; i < length; ++i) {
if (i > 0) {
cout << " ";
}
cout << data[i];
}
cout << endl;
}
void sort() {
for(int i = 0; i < length; i++) {
for(int j = i - 1; j >= 0; j--) {
if(data[j] > data[j + 1]) {
swap(data[j], data[j + 1]);
}
else {
break;
}
}
}
}
};
int main() {
int n;
cin >> n;
Vector arr(n);
for (int i = 0; i < n; ++i) {
int x;
cin >> x;
arr.insert(i, x);
}
arr.sort();
arr.print();
return 0;
}
冒泡排序:
#include <iostream>#include <cstring>
using namespace std;
class Vector {
private:
int size, length;
int * data;
public:
Vector(int input_size) {
size = input_size;
length = 0;
data = new int[size];
}
~Vector() {
delete[] data;
}
bool insert(int loc, int value) {
if (loc < 0 || loc > length) {
return false;
}
if (length >= size) {
return false;
}
for (int i = length; i > loc; --i) {
data[i] = data[i - 1];
}
data[loc] = value;
length++;
return true;
}
void print() {
for (int i = 0; i < length; ++i) {
if (i > 0) {
cout << " ";
}
cout << data[i];
}
cout << endl;
}
void sort() {
for(int i = 0; i < length - 1; i++) {
bool swapped = false;
for(int j = 0; j < length - i -1; j++) {
if(data[j] > data[j + 1]) {
swap(data[j], data[j + 1]);
swapped = true;
}
}
if(swapped == false) {
break;
}
}
}
};
int main() {
int n;
cin >> n;
Vector arr(n);
for (int i = 0; i < n; ++i) {
int x;
cin >> x;
arr.insert(i, x);
}
arr.sort();
arr.print();
return 0;
}
归并排序!:
#include <iostream>
#include <cstring>
using namespace std;
class Vector {
private:
int size, length;
int * data, * temp;
void merge_sort(int l, int r) {
if(l == r) {
return;
}
int mid = (l + r) / 2;
merge_sort(l, mid);
merge_sort(mid + 1, r);
int x = l, y = mid + 1, loc = l;
while(x <= mid || y <= r) {
if(x <= mid && (y > r || data[x] <= data[y])) {
temp[loc] = data[x];
x++;
}
else {
temp[loc] = data[y];
y++;
}
loc++;
}
for(int i = l; i <= r; i++) {
data[i] = temp[i];
}
}
public:
Vector(int input_size) {
size = input_size;
length = 0;
data = new int[size];
temp = new int[size];
}
~Vector() {
delete[] data;
delete[] temp;
}
bool insert(int loc, int value) {
if (loc < 0 || loc > length) {
return false;
}
if (length >= size) {
return false;
}
for (int i = length; i > loc; --i) {
data[i] = data[i - 1];
}
data[loc] = value;
length++;
return true;
}
void print() {
for (int i = 0; i < length; ++i) {
if (i > 0) {
cout << " ";
}
cout << data[i];
}
cout << endl;
}
void sort() {
merge_sort(0, length - 1);
}
};
int main() {
int n;
cin >> n;
Vector arr(n);
for (int i = 0; i < n; ++i) {
int x;
cin >> x;
arr.insert(i, x);
}
arr.sort();
arr.print();
return 0;
}
和稳定的排序算法不同,如果线性表中的两个元素 ai 和 aj 满足 i<j 且 ai=aj,那么这两个元素在排序在经过不稳定的排序算法以后 ai 并不一定在 aj 的前面。
常见的不稳定排序算法有 选择排序(selection sort)、快速排序(quick sort)、堆排序(heap sort)、希尔排序(shell sort) 等。在本章的课程中,我们只介绍其中的选择排序和快速排序。
选择排序的思想是,每趟从线性表的待排序区域选取关键字最小的元素,将其放到已排序区域的最后。因为每趟可以让待排序区域的元素数量减少一个,所以总共需要 n−1 趟操作就可以将整个线性表排序完成。很显然,选择排序的时间复杂度也是 O(n2)。
在每次查找关键字最小的元素时,可以使用堆对效率进行优化。使用堆来优化的选择排序就是堆排序。由于一共要查找 n次最小值,每次查找的时间为 O(logn),所以堆排序的时间复杂度为 O(nlogn)。
选择排序的算法演示如下:
快速排序是目前应用最广泛的排序算法之一。它的基本思想是,每次从待排序区间选取一个元素(我们在后面的课程中都是选取第一个)作为基准记录,所有比基准记录小的元素都在基准记录的左边,而所有比基准记录大的元素都在基准记录的右边。之后分别对基准记录的左边和右边两个区间进行快速排序,直至将整个线性表排序完成。
快速排序的时间复杂度不是稳定的,可以证明快速排序的平均时间复杂度为 O(nlogn),最坏情况为 O(n2),可以通过随机选择基准记录来尽可能避免最坏情况的出现。
快速排序的算法演示如下:
选择排序:
#include <iostream>
#include <cstring>
using namespace std;
class Vector {
private:
int size, length;
int * data;
public:
Vector(int input_size) {
size = input_size;
length = 0;
data = new int[size];
}
~Vector() {
delete[] data;
}
bool insert(int loc, int value) {
if (loc < 0 || loc > length) {
return false;
}
if (length >= size) {
return false;
}
for (int i = length; i > loc; --i) {
data[i] = data[i - 1];
}
data[loc] = value;
length++;
return true;
}
void print() {
for (int i = 0; i < length; ++i) {
if (i > 0) {
cout << " ";
}
cout << data[i];
}
cout << endl;
}
void sort() {
for(int i = 0; i < length - 1; i++) {
for(int j = i + 1; j < length; j++) {
if(data[i] > data[j]) {
swap(data[i], data[j]);
}
}
}
}
};
int main() {
int n;
cin >> n;
Vector arr(n);
for (int i = 0; i < n; ++i) {
int x;
cin >> x;
arr.insert(i, x);
}
arr.sort();
arr.print();
return 0;
}
快速排序:
#include <iostream>
#include <cstring>
using namespace std;
class Vector {
private:
int size, length;
int * data;
void quick_sort(int l, int r) {
int pivot = data[l], i = l, j = r;
do{
while(i <= j && data[i] < pivot) {
i++;
}
while(i <= j && data[j] > pivot) {
j--;
}
if(i <= j) {
swap(data[i], data[j]);
i++;
j--;
}
}while(i <= j);
if(l < j) {
quick_sort(l, j);
}
if(i < r) {
quick_sort(i, r);
}
}
public:
Vector(int input_size) {
size = input_size;
length = 0;
data = new int[size];
}
~Vector() {
delete[] data;
}
bool insert(int loc, int value) {
if (loc < 0 || loc > length) {
return false;
}
if (length >= size) {
return false;
}
for (int i = length; i > loc; --i) {
data[i] = data[i - 1];
}
data[loc] = value;
length++;
return true;
}
void print() {
for (int i = 0; i < length; ++i) {
if (i > 0) {
cout << " ";
}
cout << data[i];
}
cout << endl;
}
void sort() {
quick_sort(0, length - 1);
}
};
int main() {
int n;
cin >> n;
Vector arr(n);
for (int i = 0; i < n; ++i) {
int x;
cin >> x;
arr.insert(i, x);
}
arr.sort();
arr.print();
return 0;
}