一.排序
1.冒泡排序
对于冒泡排序的规范性定义可以参考维基百科:冒泡排序,下面冒泡排序的算法过程引自维基百科:
1.比较相邻的元素。如果第一个比第二个大,就交换他们两个。
2.对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
3.针对所有的元素重复以上的步骤,除了最后一个。
4.持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
代码实现:
#include <iostream>
using namespace std;
const int MAXSIZE = 10;
void bubbleSort(int a[]);
int main() {
int a[] = {1, 3, 5, 7, 9, 2, 4, 10, 8, 6};
bubbleSort(a);
for (int i = 0; i < MAXSIZE; i++) {
cout << a[i] << " ";
}
cout << endl;
return 0;
}
void bubbleSort(int a[]) {
int temp;
for (int i = 0; i < MAXSIZE; i++) { //比较的趟数
for (int j = 0; j < MAXSIZE - i - 1;j++) { //每趟比较的次数
if (a[j + 1] < a[j]) {
temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
}
}
}
}
时间复杂度为
O(n2)
O
(
n
2
)
,空间复杂度为
O(1)
O
(
1
)
,是稳定排序。由于冒泡排序比较简单,故这里不再累述,更多可参考维基百科或者网络上的博客。下面是维基百科上的冒泡排序示意图:
2.快速排序
对于快速排序的规范性定义可以参考维基百科:快速排序,快速排序采用“分而治之、各个击破”的观念。下面快速排序的算法过程引自维基百科:
1.从数列中挑出一个元素,称为”基准”(pivot)(一般情况下选择第一个数作为基准);
2.重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任何一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
3.递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
从以上快速排序的过程可知,快速排序的核心在于分治法 + “基准”数归位,关于分治法的定义可参考维基百科:对于快速排序的规范性定义可以参考维基百科:分治法
在计算机科学中,分治法是建基于多项分支递归的一种很重要的算法范式。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
而对于第二步,一次分区操作(一趟快速排序)该如何实现呢?下面是严蔚敏《数据结构》的做法,也是快速排序的经典做法:
1.设置两个指针low和high,它们的初值分别为low和high(实参传来的参数)。
2.设”基准”记录的关键字为key,首先从high所指位置起向前搜索找到第一个关键字小于key的记录和key互相交换,然后从low所指位置起向后搜索,找到第一个关键字大于key的记录和key互相交换。
3.重复1、2,直至low=high。
具体实现上述算法时,每交换一对记录需进行3次记录移动(赋值)的操作,但实际上对key赋值的操作是多余的,原因是只有在一趟排序结束时,即low=high的位置才是”基准”的最终位置。因此可暂存”基准”值,排序过程中只做low或high指向元素的单向移动,直到一趟排序结束后再将”基准”值移到正确的位置上。
以一个例子为例:
序列 2 3 5 4 8 1 6 7 共8个元素
初始状态: 2 3 5 4 8 1 6 7 2(选择 2 作为基准值),low = 0, high = 7
1 3 5 4 8 __ 6 7 2 high向左扫描,1 < 2,将1填入low所指的位置。
此时low = 0, high = 5
1 __ 5 4 8 3 6 7 2 low向右扫描,3 > 2,将3填入high所指的位置。
此时low = 1, high = 5
1 __ 5 4 8 3 6 7 2 high向左扫描,扫至high==low,退出循环。
此时low = 1,high = 1
最后,将基准元素填入low==high处,得到一趟排序后的结果
1 2 5 4 8 3 6 7
完整代码实现:
#include <iostream>
using namespace std;
const int MAXSIZE = 8;
void quickSort(int low, int high, int a[]);
int Partition(int low, int high, int a[]);
int main() {
int a[] = {2, 3, 5, 4, 8, 1, 6, 7};
quickSort(0, MAXSIZE - 1, a);
for (int i = 0; i < MAXSIZE; i++) {
cout << a[i] << " ";
}
cout << endl;
return 0;
}
void quickSort(int low, int high, int a[]) {
int keyPos;
if (low < high) {
keyPos = Partition(low, high, a);
quickSort(low, keyPos - 1, a); //递归的排序左边
quickSort(keyPos + 1, high, a); //递归的排序右边
}
}
//一趟快速排序过程
int Partition(int low, int high, int a[]) {
int key = a[low]; //基准元素
while (low < high) {
while (low < high && a[high] >= key) {
high--;
}
a[low] = a[high];
while (low < high && a[low] <= key) {
low++;
}
a[high] = a[low];
}
a[low] = key;
return low;
}
平均复杂度为
O(nlog2n)
O
(
n
l
o
g
2
n
)
,最坏复杂度为
O(n2)
O
(
n
2
)
,以上实现方式最坏空间复杂度为
O(n)
O
(
n
)
,是不稳定的排序。由于快速排序的实现方式有很多种,上面介绍的是比较权威也比较常用的一种朴素实现方式(未优化),更多可参考维基百科或者网络上的博客。下面是维基百科上的快速排序示意图:
关于快速排序的两点小结:
1.为什么快速排序会比较快?(相对于冒泡排序而言)
从直观上理解,快速排序每趟排序确定一个元素的最终位置(“基准”元素),快速排序每趟排序后,把比基准元素小的元素都放在了基准元素左边,比基准元素大的元素都放在了基准元素右边,并且快速排序的元素比较是”跳跃”式的,相对于冒泡排序能避免不少无效比较,而冒泡排序虽每趟排序也确定一个元素的最终位置,但是该元素的前面均是比该元素小的元素,而后面为空,或者是已确定位置的元素,直观说来,冒泡排序明显是”太偏”了。更多理解可参考:
快速排序为什么快?
快速排序的运行时间并不稳定,凭什么被命名作「快速」排序?
2.什么时候快速排序会退化到
O(n2)
O
(
n
2
)
的时间复杂度?
当元素基本有序或基本逆序时,快速排序会退化到
O(n2)
O
(
n
2
)
的时间复杂度。原因在于快速排序的核心在于分治策略。而当元素基本有序或基本逆序时,快速排序每趟排序的分区操作使得左子区间和右子区间的长度为0,这样的话分治策略的表现最差。相对而言,当每趟排序的分区操作使得左子区间和右子区间的长度越相近,快速排序的表现越佳。
3.桶排序
对于桶排序的规范性定义可以参考维基百科:桶排序,下面桶排序的算法过程引自维基百科:
1.设置一个定量的数组当作空桶子。
2.寻访序列,并且把项目一个一个放到对应的桶子去。
3.对每个不是空的桶子进行排序。
4.从不是空的桶子里把项目再放回原来的序列中。
由于桶排序不是基于比较的排序,本质上就是以时间换空间。并且桶排序实现并不难,故完整代码实现如下:
#include <iostream>
using namespace std;
const int MAXSIZE = 100;
void bucketSort(int a[], int arraySize);
int bucket[MAXSIZE];
int main() {
int a[] = {89, 98, 12, 23, 45, 55, 89, 19};
bucketSort(a, 8);
return 0;
}
void bucketSort(int a[], int arraySize) {
for (int i = 0; i < arraySize; i++) {
bucket[a[i]]++;
}
for (int i = 0; i < MAXSIZE; i++) {
if (bucket[i]) {
cout << i << " ";
}
}
cout << endl;
}
时间复杂度为
O(n+k)
O
(
n
+
k
)
,空间复杂度为
O(n)
O
(
n
)
(n为元素上限值,k为元素个数)。
关于桶排序的两点小结:
1.桶排序适用于数据范围不大,但数据规模较大的数据排序,如成绩排序。但这也是桶排序的缺点,如果数据量不大或者数据范围很大的话,那么桶排序则不适用。
2.桶排序空间换时间的思想很重要,很多时候会以一定空间上的牺牲以达到效率上的提升。
4.快速排序的一个简单应用
应用:查找大量无序元素中第
k
k
大的数
具体问题描述:个整数
Xi
X
i
,然后指定一个整数
k
k
,找出里面第大的数字。
输入格式:
第1行 两个整数 n,k n , k 以空格隔开;
第2行 有 N N 个整数(可出现相同数字,均为随机生成),同样以空格隔开。
数据规模:
样例说明:
Sample1:
5 2
5 4 1 3 1
输出:4
Sample2:
5 2
5 5 4 4 4
输出:5
Sample3:
5 3
5 5 4 4 3
输出:4
思路分析:
由于是找出无序元素中第
k
k
大的数,那么很容易想到先逆序排序,然后直接取第个元素即可。这样做的话,时间复杂度是
O(nlog2n)
O
(
n
l
o
g
2
n
)
,但是仔细想想,题目只是要求找出无序元素中第
k
k
大的数,需要”大动干戈”的对全部元素排序,然后仅仅只取一个元素吗?
回顾一下快速排序的过程,快速排序的每一趟先确定一个基准数,然后再将这个基准数归位。试想,如何确定该基准数位置的呢?通过以上快速排序过程可知,当low指针和high指针相遇时,low指针与high指针相遇的位置,即是基准数的最终位置。那么这个位置代表什么呢?
对于升序序列,例如一趟快速排序后的结果为:
3 2 4 5 8 7 9
那么5位于a[3]的位置,也正说明5是数组中第4小的数(下标从0开始)
对于降序序列,例如一趟快速排序后的结果为:
8 9 7 6 3 2 5
那么6位于a[3]的位置,也正说明6是数组中第4大的数(下标从0开始)
那么有了以上的分析思路可知,每趟快速排序确定一个第(low+1)大的数(以降序为例),在这个数左边都是比它大的数,右边都是比它小的数。而后快速排序的过程是什么?
递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
但是,是否需要把左右两边的子数列都排序?从以上分析可知,不必,若我要找的是第大的数,如果一趟快速排序后确定基准数的位置 low<k−1 l o w < k − 1 ,说明要找的数在low的右边,因此再递归的把右子数列排序即可,若 low>k−1 l o w > k − 1 ,同理递归的把左子数列排序即可。而如果 low==k−1 l o w == k − 1 ,说明已找到要找的数,直接输出即可。因此总结以上思路分析如下:
1.每次选取第一个元素(实际应用可采用随机算法随机选择基准元素以提高程序的运行效率)为基准元素,然后通过 Partition() P a r t i t i o n ( ) 的分区操作确定该基准数在数列中的位置 low l o w 。
2.
- 若 low<k−1 l o w < k − 1 ,递归的将low的右子数列排序
- 若 low>k−1 l o w > k − 1 ,递归的将low的左子数列排序
- 若 low==k−1 l o w == k − 1 ,直接输出a[low]
故完整代码实现如下:
#include <iostream>
using namespace std;
const int MAXSIZE = 5e6 + 100;
void quickSort(int low, int high, int a[], int k);
int Partition(int low, int high, int a[]);
int a[MAXSIZE];
int main() {
int n, k;
std::ios::sync_with_stdio(false);
while (cin >> n >> k) {
for (int i = 0;i < n;i++) {
cin >> a[i];
}
quickSort(0, n - 1, a, k - 1);
}
return 0;
}
void quickSort(int low, int high, int a[], int k) {
int keyPos;
if (low < high) {
keyPos = Partition(low, high, a);
if (keyPos < k) {
quickSort(keyPos + 1, high, a, k);
} else if (keyPos > k) {
quickSort(low, keyPos - 1, a, k);
} else {
cout << a[keyPos] << endl;
}
}
}
//一趟快速排序过程
int Partition(int low, int high, int a[]) {
int key = a[low]; //基准元素
while (low < high) {
while (low < high && a[high] <= key) {
high--;
}
a[low] = a[high];
while (low < high && a[low] >= key) {
low++;
}
a[high] = a[low];
}
a[low] = key;
return low;
}
期望的时间复杂度为
O(n)
O
(
n
)
,为了得到期望的时间复杂度为线性时间复杂度,快速排序中的基准元素的选择一般使用随机数。
当然,解决这个问题不止上面介绍的这一种方法,更多理解可参考:
寻找无序数组中第k大的数
二.查找
1.二分查找
二分查找也称二分搜索算法,对于二分搜索算法的规范性定义可参考维基百科:二分搜索算法,二分搜索算法是一种在有序数组中查找某一特定元素的搜索算法。下面二分搜索算法的过程引自维基百科:
1.搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;
2.如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。
3.如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
算法步骤(伪代码辅以文字描述,来自维基百科):
给予一个包含n个带值元素的数组
A
A
或是记录,使
A0≤⋅⋅⋅≤An−1
A
0
≤
⋅
⋅
⋅
≤
A
n
−
1
,以及目标值T,还有下列用来搜索
T
T
在中位置的子程序。
1.令 L L 为,R为 n−1 n − 1 。
2.如果 L>R L > R ,则搜索以失败告终。
3.令m(中间值元素)为 ⌊(L+R)/2⌋ ⌊ ( L + R ) / 2 ⌋
4.如果 Am<T A m < T ,令 L L 为并回到步骤二。
5.如果 Am>T A m > T ,令 R R 为并回到步骤二。
6.当 Am=T A m = T ,搜索结束;回传值 m m 。
图示说明:
故完整代码实现如下:
#include <iostream>
using namespace std;
int binarySearch(int a[], int low, int high, int key);
int main(int argc, char *argv[]) {
int key;
int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
cout << "Please input your search key: " << endl;
cin >> key;
int keyPos = binarySearch(a, 0, 8, key);
if (keyPos == -1) {
cout << key << " is not in this array" << endl;
} else {
cout << key << " is in the index of " << keyPos << endl;
}
}
int binarySearch(int a[], int low, int high, int key) {
while (low <= high) {
int mid = (low + high) / 2;
if (a[mid] == key) {
return mid;
} else if(a[mid] > key) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
递归过程实现:
#include <iostream>
using namespace std;
int binarySearch(int a[], int low, int high, int key);
int main(int argc, char *argv[]) {
int key;
int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
cout << "Please input your search key: " << endl;
cin >> key;
int keyPos = binarySearch(a, 0, 8, key);
if (keyPos == -1) {
cout << key << " is not in this array" << endl;
} else {
cout << key << " is in the index of " << keyPos << endl;
}
}
int binarySearch(int a[], int low, int high, int key) {
if (low > high) { //递归终止条件
return -1;
}
int mid = (low + high) / 2;
if (a[mid] == key) {
return mid;
} else if (a[mid] < key) {
return binarySearch(a, mid + 1, high, key);
} else {
return binarySearch(a, low, mid - 1, key);
}
}
时间复杂度为,空间复杂度:迭代为
O(1)
O
(
1
)
,递归为:
O(log2n)
O
(
l
o
g
2
n
)
。
总结:
1.二分查找的过程可用二叉树来描述,把当前查找区间的中间位置上的结点作为根,左子表和右子表中的结点分别作为根的左子树和右子树。由此得到的二叉树,称为描述二分查找的判定树(Decision Tree)或比较树(Comparison Tree)。
注意:
判定树的形态只与表结点个数n相关,而与输入实例中R[1..n].keys的取值无关。
【例】具有11个结点的有序表可用下图所示的判定树来表示。
- 圆结点即树中的内部结点。树中圆结点内的数字表示该结点在有序表中的位置。
- 外部结点:圆结点中的所有空指针均用一个虚拟的方形结点来取代,即外部结点。
- 树中某结点i与其左(右)孩子连接的左(右)分支上的标记”<”、”(“、”>”、”)”表示:当待查关键字K
int mid = (low + high) / 2; //可能存在加法溢出的问题
妥当写法应该是:
int mid = low + (high - low) / 2;
更多可参考:
二分查找有几种写法?它们的区别是什么?
2.哈希查找
(尚未截稿)