Chapter3——常用的排序和查找算法

一.排序


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 大的数
具体问题描述:N个整数 Xi X i ,然后指定一个整数 k k ,找出里面第k大的数字。
输入格式:

第1行 两个整数 n,k n , k 以空格隔开;
第2行 有 N N 个整数(可出现相同数字,均为随机生成),同样以空格隔开。
数据规模:0<n5106,0<kn,1Xi108

样例说明:

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 大的数,那么很容易想到先逆序排序,然后直接取第k1个元素即可。这样做的话,时间复杂度是 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)把小于基准值元素的子数列和大于基准值元素的子数列排序。

    但是,是否需要把左右两边的子数列都排序?从以上分析可知,不必,若我要找的是第k大的数,如果一趟快速排序后确定基准数的位置 low<k1 l o w < k − 1 ,说明要找的数在low的右边,因此再递归的把右子数列排序即可,若 low>k1 l o w > k − 1 ,同理递归的把左子数列排序即可。而如果 low==k1 l o w == k − 1 ,说明已找到要找的数,直接输出即可。因此总结以上思路分析如下:

1.每次选取第一个元素(实际应用可采用随机算法随机选择基准元素以提高程序的运行效率)为基准元素,然后通过 Partition() P a r t i t i o n ( ) 的分区操作确定该基准数在数列中的位置 low l o w

2.

  • low<k1 l o w < k − 1 ,递归的将low的右子数列排序
  • low>k1 l o w > k − 1 ,递归的将low的左子数列排序
  • low==k1 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,,An1,使 A0An1 A 0 ≤ ⋅ ⋅ ⋅ ≤ A n − 1 ,以及目标值T,还有下列用来搜索 T T A中位置的子程序。

1.令 L L 0,R为 n1 n − 1
2.如果 L>R L > R ,则搜索以失败告终。
3.令m(中间值元素)为 (L+R)/2 ⌊ ( L + R ) / 2 ⌋
4.如果 Am<T A m < T ,令 L L m+1并回到步骤二。
5.如果 Am>T A m > T ,令 R R m1并回到步骤二。
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(log2n),空间复杂度:迭代为 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.哈希查找

(尚未截稿)

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值