算法基础-->排序查找

本篇博文将详细总结一些排序算法。

插入排序

基本思想

A(1n) 中的元素按非降次序分类, n1

插入排序:插入即表示将一个新的数据插入到一个有序 数组中,并继续保持有序。例如有一个长度为 N 的无序数组,进行 N1 次的插入即能完成排序;第一次,数组第 1 个数认为是有序的数组,将数组第二个元素插入仅有 1 个有序的数组中;第二次,数组前两个元素组成有序的数组,将数组第三个元素插入由两个元素构成的有序数组中……第 N1 次,数组前 N1 个元素组成有序的数组,将数组的第 N 个元素插入由 N1 个元素构成的有序数组中,则完成了整个插入排序。

插入排序就是每一步都将一个待排数据按其大小插入到已经排序的数据中的适当位置,直到全部插入完毕。

下图演示了对 4 个元素进行直接插入排序的过程,共需要 (a),(b),(c) 三次插入。

这里写图片描述

实现代码

void InsertSort(int* pDataArray, int iDataNum)
{
    for (int i = 1; i < iDataNum; i++)
    {
        int j = i - 1;
        int temp = pDataArray[i];//暂存当前要插入的数
        while (j>=0 && pDataArray[j]>temp)//从当前temp位置向前寻找小于等于temp数的位置
        {
            pDataArray[j + 1] = pDataArray[j];//大于temp的数依次向后滑动
            j--;
        }
        if (j != i - 1)//如果j!=i-1表示j向前滑动了
            pDataArray[j + 1] = temp;//把要插入的数放在小于等于它前的位置
    }
}

时空复杂度分析

平均时间复杂度: O(n2)

空间复杂度: O(1) (用于记录需要插入的数据)

稳定性:稳定

归并排序

基本思想

采用分治法(个人认为分治必然用到递归,否则分治无意义),将数组 A[0n1] 中的元素分成两个子数组: A1[0n/2] A2[n/2+1n1] 。分别对这两个子数组单独排序,然后将 已排序的两个子数组归并 成一个含有 n 个元素的有序数组。

这是一个递归的过程,递归的将数组分为两子数组,然后在各个子数组再分为两子数组,以此递归下去,当分出来的子数组只有一个数据时,可以认为这个子数组内已经达到了有序,然后再合并相邻的两个子数组就可以了。这样通过先递归的分解数组,再合并数组就完成了归并排序。

代码实现:

int temp[100];//用来暂存排序过程中数组
void Merge(int* a, int low,int mid,int high)//对有序的两个子数组进行归并
{
    int i = low;
    int j = mid + 1;
    int size = 0;
    for (; i <= mid &&j <= high; size++)
    {
        if (a[i] < a[j])//两子数组两两比较
            temp[size] = a[i++];
        else
            temp[size] = a[j++];
    }
    while (i<=mid)
        temp[size++] = a[i++];
    while (j <= high)
        temp[size++] = a[j++];

    for (i = 0; i < size; i++)
        a[low++] = temp[i];
}

void MergeSort(int* a, int low, int high)
{
    if (low >= high)
        return;
    int mid = (low + high) / 2;
    MergeSort(a, low, mid);
    MergeSort(a, mid + 1, high);
    Merge(a, low, mid, high);
}

时间复杂度分析

算法的递推关系:
   由上面的代码可知,在 MergeSort 方法中不断的将数组递归的分为两个子数组,一个子数组的时间复杂度为 T(n2) ,而在 Merge 方法中,代码虽然看起来长,但是其循环长度都是 size ,并且没有嵌套循环,故时间复杂度为 o(n) ,因为是线性的,故前面有系数 c

  由此我们可以得出:

T(n)=2T(n2)+cn

n=2k ,不断的递归拆分成两子数字,则有:


这里写图片描述


2k<n<2k+1 ,则 T(2k)<T(n)<T(2k+1)
所以得: T(n)=O(nlogn)

两点改进

  • 在数组长度比较短的情况下,不进行递归,而是选择其他排序方案:如插入排序;
  • 归并过程中,可以用记录数组下标的方式代替申请新内存空间;从而避免A和辅助数组间的频繁数据移动。

注:基于关键字比较 的排序算法的平均时间复杂度的下界 O(nlogn) ,也就是没有比它更小的时间复杂度了。

归并排序应用:逆序数问题

问题描述

给定一个数组 A[0N1] ,若对于某两个元素 a[i]a[j] ,若 ij a[i]a[j] ,则称 (a[i],a[j]) 为逆序对。一个数组中包含的逆序对的数目称为该数组的逆序数。试设计算法,求一个数组的逆序数。

如: 3,56,2,7 的逆序数为 3

算法分析

这里写图片描述

我们可以这样分析这个问题,把数组 A 分为两个有序的子数组,如上图所示,子数组 A[low,mid] 和子数组 A[mid+1,high] 分别有序,并且为升序排序。现在假设 A[i]>A[j] ,那么我们可以得出 A[i] A[mid] 中任何一个数都大于 A[mid+1] A[j] 中任何一个数。由此可以计算出数组 A 的逆序数。每遇到一对这样的 i,j ,其逆序数都加上 (midi+1) (jmid)

代码实现:

int temp[100];//用来暂存排序过程中数组
void Merge(int* a, int low,int mid,int high,int& count)
{
    int i = low;
    int j = mid + 1;
    int size = 0;
    for (; i <= mid &&j <= high; size++)
    {
        if (a[i] < a[j])
            temp[size] = a[i++];
        else if (a[i]>a[j])//说明存在逆序数
        {
            count += mid - i + 1;//和上面的归并排序代码几乎一样,只是这里多了一句
            //每遇到这样的(i,j) 都加上 (mid-i+1)或(j-mid)
            //count += j - mid;//本句和上一句代码效果完全一样。
            temp[size] = a[j++];
        }

    }
    while (i<=mid)
        temp[size++] = a[i++];
    while (j <= high)
        temp[size++] = a[j++];

    for (i = 0; i < size; i++)
        a[low++] = temp[i];
}

void MergeSort(int* a, int low, int high,int& count)    
{
    if (low >= high)
        return;
    int mid = (low + high) / 2;
    MergeSort(a, low, mid,count);
    MergeSort(a, mid + 1, high,count);
    Merge(a, low, mid, high,count);
}

int main()
{
    int a[] = { 3, 56, 2, 7 };
    int size = sizeof(a) / sizeof(int);
    int count = 0;
    MergeSort(a, 0, size - 1, count);
    cout << count << endl;
    return 0;
}

堆排序

基本思想

定义:对于一棵完全二叉树,若树中任一非叶子结点的关键字均不大于(或不小于)其左右孩子 (若存在)结点的关键字,则这棵二叉树,叫做小顶堆(大顶堆)。

完全二叉树可以用数组完美存储,对于长度为 n 的数组 a[0n1] ,若 0in1a[i]a[2i+1] a[i]a[2i+2] ,那么 a 是一个小顶堆。

重要结论:大顶堆的堆顶元素是最大的。

  1. 初始化操作:将 a[0..n1] 构造为堆(如大顶堆);
  2. i(ni1) 趟排序:将堆顶记录 a[0] a[ni] 交换,然后将 a[0ni1] 调整为堆(即:重建大顶堆),也即是不断的拿走堆顶元素,然后再把剩余的元素重建大顶堆;
  3. 进行 n1 趟,完成排序。

堆排序的调整示意图:


这里写图片描述

注:每第 i 次调整前都是先将 a[i] a[ni] 交换,然后再调整成大顶堆。上面示意图中只有调整后的图,没有显示详细的交换和调整过程。

堆的存储和树型表示

16,14,10,8,7,9,3,2,4,1
9,8,3,4,7,1,2


这里写图片描述

时间复杂度

初始化堆(建堆)的过程: O(N)
调整堆的过程:每次弹出堆顶元素,并且调整堆的时间复杂度为 logN ,对N个做堆排序故 O(NlogN)

堆的数据结构表示

堆可以用数组完美表示,数组元素的先后顺序以及其元素大小关系表示了这个堆是大顶堆还是小顶堆。我们在编写程序时经常以数组来存储堆

k 的孩子结点是 2k+1,2k+2 (如果存在)
k 的父结点:

  • k 为左孩子,则 k 的父结点为 k/2
  • k 为右孩子,则 k 的父结点为 (k/2)1

对于大小为 size A 数组里面的元素进行建堆,只需要从第 A[size/2] 元素到 A[size1] 元素进行插入建堆。

实现代码

//调用该函数前,n的左右孩子都是大顶堆,调整以n为顶的堆为大顶堆
void HeadAjust(int* a, int n, int size)
{
    int Lchild = 2 * n + 1;//左孩子
    int nChild = Lchild;
    int t;
    while (nChild<size)
    {
        if ((nChild+1<size) && (a[nChild+1]>a[nChild]))//找出左右孩子大的那个
            nChild += 1;
        if (a[nChild] < a[n])//如果孩子比父亲小,说明调整完毕
            break;
        //反正,孩子比父亲大,需要交换调整
        t = a[nChild];
        a[nChild] = a[n];
        a[n] = t;

        n = nChild;
        nChild = 2 * n + 1;//循环的继续向下调整
    }
}

void HeadSort(int* a, int size, int k)//前k大
{
    int i;
    for (i = size / 2 - 1; i >= 0; i--)//初始建大顶堆
    {
        HeadAjust(a, i, size);
    }
    int t;
    int s = size - k;
    while (size>s)//依次找到最大的并放在数组末尾,然后重新调整建堆
    {
        t = a[size - 1];
        a[size - 1] = a[0];//将当前堆的最大值放在数组末尾
        a[0] = t;
        size--;
        HeadAjust(a, 0, size);//调整堆,也即是将新的a[0]插入到堆的适当位置
    }
}

N个数中,选择前k个最大的数

这个问题肯定要对这 N 个数进行排序,那么选择哪一种排序方式使得时间复杂度最小呢?

这里我们仅以堆排序为例。

解法一:

  • 建立一个小顶堆,小顶堆的大小为 k
  • for 每个数:

    if 这个数比小顶堆的堆顶元素大

        弹出小顶堆的最小元素
        把这个数插入到小顶堆,并且进行堆调整。

  • 小顶堆中的k个元素就是所要求的元素

小顶堆的作用:

  • 保持始终有 k 个最大元素——利于最后的输出
  • k 个元素中最小的元素在堆顶——利于后续元素的比较

时间复杂度:从大小为 k 的堆中弹出 1 个(最小)元素,并且调整堆的时间复杂度为 O(logK) 。遍历数组中 N 个元素,每次都要选择堆中最小的那个元素与数组中元素作替换,然后再调整堆,共 N 次。 O(Nlogk)

代码实现:

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<set>
#include<queue>
using namespace std;

//调用该函数前,n的左右孩子都是小顶堆,调整以n为顶的堆为小顶堆
void HeadAjust(int* a, int n, int size)
{
    int Lchild = 2 * n + 1;//左孩子
    int nChild = Lchild;
    int t;
    while (nChild<size)
    {
        if ((nChild + 1<size) && (a[nChild+1]<a[nChild]))//找出左右孩子小的那个
            nChild += 1;
        if (a[nChild] > a[n])//如果父亲比孩子小,说明调整完毕
            break;
        //反正,孩子比父亲小,需要交换调整
        t = a[nChild];
        a[nChild] = a[n];
        a[n] = t;
        n = nChild;
        nChild = 2 * n + 1;//循环的继续向下调整
    }
}

void Print(int* b, int k)
{
    for (int i = 0; i < k; i++)
        cout << b[i] << " ";
    cout << endl;
}

void HeadSort(int* a,int* b, int size, int k)//前k大
{
    int i;

    for (i = 0; i < k; i++)
        b[i] = a[i];

    for (i = k/2; i >= 0; i--)//根据数组b建大小为k的小顶堆
    {
        HeadAjust(b, i, k);
    }
    for (i = k; i < size; i++)
    {
        if (a[i]>b[0])//不断去除堆里面最小的元素,插入比它大的元素
        {
            b[0] = a[i];
            HeadAjust(b, 0, k);//调整堆,也即是将新的a[0]插入堆中适合的位置。
        }   
    }
}

int main()
{
    int a[] = { 7, 54,12, 8,53, 13, 32, 45, 19,101,100};
    int size = sizeof(a) / sizeof(int);
    int k = 4;
    int* b = new int[k];
    HeadSort(a, b, size, k);
    Print(b, k);

}

解法二:

算法描述:

  1. 建立全部 n 个元素的大顶堆;
  2. 利用堆排序,但得到前 k 个元素后即完成算法。

时间复杂度分析:

  1. 建堆 O(N)
  2. 选择 1 个元素的时间是 O(logN) ,所以,第二步的总时间复杂度为 O(klogN)
  3. 该算法时间复杂度为 O(N+klogN)

该方法的实现代码和上面差不多就不写了。

快速排序

基本思想

快速排序是一种基于划分 的排序方法; 划分 Partitioning :选取待排序集合 A 中的某个元素 t ,按照与 t 的大小关系重新整理 A 中元素,使得整理后的序列中所有在t以前出现的元素均小于 t ,而所有出现在 t 以后的元素均大于等于 t ;元素 t 称为划分元素。

快速排序:通过反复地对 A 进行划分达到排序的目的。

以一个数组作为示例:

 取区间第一个数 privoteKey=A[0]=72 为基准数。

[07216257388460542683773848985]

   此时 i=0,j=9 j 从右往左的遍历找到第一个小于 privoteKey 的数,当 j=8 时对应的数值 48<72 ,此时将 48 72 互换位置并且执行 i++ 得:

[04816257388460542683773872985]

   注意此时 i=1,j=8

 此时 i 再从左向右遍历,找到第一个大于 privoteKey 的数,找为 i=3 时对应的数值 88>72 ,互换两个数的位置并且执行 j 得:

[04816257372460542683773888985]

   注意此时 i=3,j=7

 此时 j 再从右向左遍历,找到第一个小于 privoteKey 的数,找为 j=5 时对应的数值 42<72 ,互换两个数的位置并且执行 i++ 得:

[04816257342460572683773888985]

   注意此时 i=3,j=5

 此时 i 再从左向右遍历,当 i==j 时退出,此时, i=j=5 时,将 A[5]=privoteKey
这样第一轮的排序完成。

实现代码:

void quick_sort(int s[], int l, int r)//c++中 数组*a表示可修改原始数组,int& a表示可修改原始a值
{
    if (l < r)
    {
        int i = l, j = r, x = s[l];//x就是privoteKey,也就是当前基准数
        while (i<j)
        {
            while (i<j&&s[j]>x)//j从右向左找第一个小于x的值
                j--;
            if (i < j)
                //下面是先赋值,然后i再加1
                s[i++] = s[j];//之前s[i]为x,可以这样理解把s[j]的值赋值给s[i],s[j]里面可以理解为x
                //(或者把s[j]理解为下一个需要填的坑)

            while (i < j&&s[i] < x)//i从左向右找第一个大于x的值
                i++;
            if (i < j)
                s[j--] = s[i];//填坑
        }
        s[i] = x;//此时i==j
        //分治递归
        quick_sort(s, l, i - 1);
        quick_sort(s, i + 1, r);
    }
}


int main()
{
    int a[] = { 23, 54, 12, 4, 7, 1, 3, 54, 13 };
    int size = sizeof(a) / sizeof(int);
    quick_sort(a, 0, size - 1);
    Print(a, size);

}

时间复杂度

最好的情况:

  • 在最好的情况,每次运行一次分区,我们会把一个数列分为两个几近相等的片段。然后,递归调用两个一半大小的数列
  • 一次分区中, ij 一共遍历了 n 个数,即 O(n)
  • 记:快速排序的时间复杂度为 T(n) ,有: T(n)=2T(n/2)+cn c 是某常数,这个分析过程类似之前的归并排序。
  • T(n)=O(nlogn)

最坏的情况:

  • 在最坏的情况下,两个子数组的长度为 1 n1
  • T(n)=T(1)+T(n1)+cn
  • 计算得到 T(n)=O(n2)

快速排序与归并排序比较

  • 都是分治递归 的思想
  • 经过一次划分后,实现了对 A 的调整:其中一个子集合的所有元素均小于等于另外一个子集合的所有元素;
  • 两者按同样的策略对两个子集合进行分类处理。快速排序当子集合分类完毕后,整个集合的分类也完成了。这一过程避免了子集合的归并操作。
  • 因此我们可以想象,归并排序比快速排序要慢,因为多了一次归并过程。

快速排序与堆排序比较

  • 快速排序的最直接竞争者是堆排序。堆排序通常比快速排序稍微慢,但是最坏情况的运行时间总是 O(nlogn) 。快速排序是经常比较快,但仍然有最坏情况性能的机会。

  • 堆排序拥有重要的特点:仅使用固定额外的空间,即堆排序是原地排序,而快速排序需要 O(logn) 递归栈)的空间。

桶排序/基数排序

基本思想

  • 将元素分到若干个桶中,每个桶分别排序,然后归并

  • 由于桶之间往往是有序 的(如:洗牌中的 113 个点数,整数按照数位0-9基数排序等),所以,它们不是(完全)基于比较的,时间复杂度下限不是 O(NlogN)

桶排序应用:最大间隔

请查看最大间隔问题

2-sum问题

问题描述

输入一个数组 A[0N1] 和一个数字 Sum ,在数组中查找两个数 Ai,Aj ,使得 Ai+Aj=Sum

问题分析

之前我们分析过 Nsum 问题,采用的办法是深度优先搜索,也即是 递归深入+回溯 来搜索解空间里所有的可能解。这是一种暴力解法,在本问题中依然可以采用这种暴力解法, 从数组中任意选取两个数 x,y ,判定它们的和是否为输入的数字 Sum 。时间复杂度为 O(N2) ,空间复杂度 O(1) 。那么可有更好的解决方法呢?

利用排序算法解决

两头扫:

  1. 如果数组是无序的,先排序 O(NlogN) ,然后用两个指针 ij ,各自指向数组的首尾两端,令 i=0j=n1 ,然后 i++j ,逐次判断 a[i]+a[j] 是否等于 Sum
  2. a[i]+a[j]>sum ,则 i 不变, j
  3. a[i]+a[j]<sum ,则 i++j 不变;
  4. a[i]+a[j]==sum ,如果只要求输出一个结果,则退出;否则,输出结果后 i++j

数组无序 的时候,时间复杂度最终为 O(NlogN+N)=O(NlogN)

实现代码

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<set>
#include<queue>
using namespace std;

void quick_sort(int *s, int l, int r)
{
    if (l < r)
    {
        int i = l, j = r, x = s[l];//x就是privoteKey,也就是当前基准数
        while (i<j)
        {
            while (i<j&&s[j]>x)//j从右向左找第一个小于x的值
                j--;
            if (i < j)
                //下面是先赋值,然后i再加1
                s[i++] = s[j];//之前s[i]为x,可以这样理解把s[j]的值赋值给s[i],s[j]里面为x(或者把它理解为下一个需要填的坑)

            while (i < j&&s[i] < x)//i从左向右找第一个大于x的值
                i++;
            if (i < j)
                s[j--] = s[i];
        }
        s[i] = x;//此时i==j
        //分治递归
        quick_sort(s, l, i-1);
        quick_sort(s, i + 1, r);
    }
}

void Print(int *a, int size)
{
    for (int i = 0; i < size; i++)
        cout << a[i] << " ";
    cout << endl;
}


void twoSum(int a[], int size,int sum)
{
    int i = 0, j = size - 1;
    while (i<j)
    {
        if (a[i] + a[j] > sum)
            j--;
        else if (a[i] + a[j] < sum)
            i++;
        else
        {
            cout << a[i] << " " << a[j] << endl;
            i++;
            j--;
        }
    }
}

int main()
{
    int a[] = { 8, 14, 12, 4, 7, 1, 3, 11,13 };
    int size = sizeof(a) / sizeof(int);
    quick_sort(a, 0, size - 1);
    Print(a, size);
    twoSum(a, size, 20);
    return 0;
}

外排序

外排序( Externalsorting )是指处理超过内存 限度的数据的排序算法。通常将中间结果放在读写较慢的外存储器(通常是硬盘)上。

外排序常采用“排序-归并”策略。

  • 排序阶段,读入能放在内存中的数据量,将其排序输出到临时文件,依次进行,将待排序数据组织为多个有序的临时文件。

  • 归并阶段,将这些临时文件组合为大的有序文件。

例如使用 100M 内存对 900MB 的数据进行排序:

  • 读入 100M 数据至内存,用常规方式(如堆排序)排序。

  • 将排序后的数据写入磁盘。

  • 重复前两个步骤,得到 9 100MB 的块(临时文件)中。

  • 100M 内存划分为 10 份,前 9 份中为输入缓冲区,第 10 份为输出缓冲区。如前 9 份各 8 M,第 10 18M ;或 10 份大小同时为 10M
    在上面得出的 10 个临时文件块中,取其前 9 块各 8 M,第 10 18 M;或 10 份大小同时为 10 M。

  • 执行九路归并算法(例如在上面获得的 9 个有序的块中,每块选取前 8M 有序内容到输入缓冲区,进行归并),将结果输出到输出缓冲区。
    若输出缓冲区满,将数据写至目标文件,清空缓冲区。
    若输入缓冲区空,读入相应文件的下一份数据。

排序总结

排序通常不是目的,而是手段,是为了方便求解其他问题的一个手段。比如上面说的 2sum 问题,通过对数组的排序使得问题很方便的得到解决。

各种排序算法的时间复杂度:

这里写图片描述

稳定性分析:

一般的说,如果排序过程中,只有相邻元素进行比较,是稳定的,如冒泡排序、归并排序;如果间隔元素进行了比较,往往是非稳定的,如堆排序、快速排序。

  • 归并排序是指针逐次后移,姑且算相邻元素的比较
  • 直接插入排序可以将新增数据放在排序的相等数据的后面,使得直接插入排序是稳定的;但二分插入排序本身不稳定,如果要稳定,需要向后探测

一般的说,如果能够方便整理数据,对于不稳定的排序,可以使用(A[i],i)键对来进行算法,可以使得不稳定排序变成稳定排序。

猜你喜欢

转载自blog.csdn.net/mr_tyting/article/details/77816185