数据结构与算法——排序

参考链接

  1. https://github.com/forthespada/InterviewGuide/

排序

选择排序

选择排序是一种简单直观的排序算法,它的基本原理如下:对于给定的一组记录,经过第一轮比较后得到最小的记录,然后将该记录与第一个记录进行交换;接着对不包括第一个记录外的其他记录进行第二轮的比较,得到最小的记录,并与第二个记录进行位置交换;重复该过程,直到进行比较的记录只有一个时为止

void selectSort(vector<int>& a, int n)
{
    int temp = 0;
    int flag = 0;
    for (int i = 0; i < n - 1; i++)
    {
        temp = a[i];
        flag = i;
        for (int j = i + 1; j < n; j++)
        {
            if (a[j] < temp)
            {
                temp = a[j];
                flag = j;
            }
        }
        if (flag != i)
        {
            a[flag] = a[i];
            a[i] = temp;
        }
    }
}

选择排序是一种不稳定的排序方法,最好、最坏和平均情况下的时间复杂度都为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1)

插入排序

对于给定的一组记录,初始时假设第一个记录自成一个有序序列,其余的记录为无序序列;接着从第二个记录开始,按照记录的大小依次将当前处理的记录插入到其之前的有序序列中,直至最后一个记录插入到有序序列中为止

void insertSort(vector<int>& a, int n)
{
    int temp;
    for (int i = 1; i < n; i++)
    {
        temp = a[i];
        for (int j = i - 1; j >= 0; j--)
        {
            if (temp < a[j])
            {
                a[j + 1] = a[j];
            }
            else
            {
                break;
            }
        }
        a[j + 1] = temp;
    }
}

插入排序是一种稳定的排序方法,最好情况下的时间复杂度为 O ( n ) O(n) O(n),最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2),平均情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。空间复杂度为 O ( 1 ) O(1) O(1)

冒泡排序

单向冒泡排序

单向冒泡排序的基本思想是(假设由小到大排序):对于给定的n个记录,从第一个记录开始依次对相邻的两个记录进行比较,当前面的记录大于后面的记录时,交换其位置,进行一轮比较和换位后,n个记录中的最大记录将位于第n位;然后对前(n-1)个记录进行第二轮比较;重复该过程,直到进行比较的记录只剩下一个时为止

void swap(int& a, int& b)
{
    int temp = a;
    a = b;
    b = temp;
}

void bubbleSort(vector<int>& a, int n)
{
    for (int i = 0; i < n - 1; ++i)
    {
        for (int j = n - 1; j > i; --j)
        {
            if (a[j] < a[j - 1])
            {
                swap(a[j], a[j - 1]);
            }
        }
    }
}

冒泡排序是一种稳定的排序方法,最好情况下的时间复杂度为 O ( n ) O(n) O(n),最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2),平均情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。空间复杂度为 O ( 1 ) O(1) O(1)

双向冒泡排序

双向冒泡排序是冒泡排序的一种优化,它的基本思想是:首先将第一个记录的关键字和第二个记录的关键字进行比较,若为逆序,则将两个记录交换,然后比较第二个记录和第三个记录的关键字。以此类推,直到第n-1个记录的关键字和第n个记录的关键字比较过为止。这是第一趟冒泡排序,其结果是使得关键字最大的记录被安置到最后一个记录的位置上

第一趟排序之后进行第二趟冒泡排序,将第n-2个记录的关键字和第n-1个记录的关键字进行比较,若为逆序,则将两个记录交换,然后比较第n-3个记录和第n-2个记录的关键字。以此类推,直至第1个记录的关键字和第2个记录的关键字比较过为止。其结果是使得关键字最小的记录被安置到第一个位置上

再对其余的n-2个记录进行上述同样的操作,其结果是使关键字次大的记录被安置到第n-1个记录的位置,使关键字次小的记录被安置到第2个记录的位置

整个排序过程需要进行K(1≤K<n)趟冒泡排序,同样判别冒泡排序结束的条件是“在一趟排序过程中没有进行过交换记录的操作”

void swap(int& a, int& b)
{
    int temp = a;
    a = b;
    b = temp;
}

void bubble2Sort(vector<int>& a, int n)
{
    int left = 1;
    int right = n - 1;
    int t;
    do
    {
        //正向的部分
        for (int i = right; i >= left; i--)
        {
            if (a[i] < a[i - 1])
            {
                swap(a[i], a[i - 1]);
                t = i;
            }
        }
        left = t + 1;
        //反向的部分
        for (int i = left; i < right + 1; i++)
        {
            if (a[i] < a[i - 1])
            {
                swap(a[i], a[i - 1]);
                t = i;
            }
        }
        right = t - 1;
    }while(left <= right);
}

双向冒泡排序跟普通冒泡排序的时间复杂度相同。但是,双向冒泡排序能解决普通冒泡排序的乌龟问题。乌龟问题是指假设需要将序列按照升序序列排序,序列中的较小的数字又大量存在于序列的尾部,这样会让小数字向前移动得很缓慢

归并排序

归并排序是利用递归与分治技术将数据序列划分为越来越小的半子表,再对半子表排序,最后再用递归步骤将排好序的半子表合并成为越来越大的有序序列。其中,“归”代表的是递归的意思,即递归地将数组折半地分离为单个数组。“并”就是将分开的数据按照从小到大或者从大到小的顺序再放到一个数组中

具体而言,对于给定的一组记录(假设有n个记录),首先将每两个相邻的长度为1的子序列进行归并,得到n/2个长度为2或1的有序子序列,再将其两两归并,反复执行此过程,直到得到一个有序序列为止

所以,归并排序的关键就是:第一步,划分子表;第二步,合并半子表

void merge(vector<int>& a, int p, int q, int r)
{
    int n1 = q - p + 1;
    int n2 = r - q;
    int* L = new int[n1];
    int* R = new int[n2];

    for (int i = 0, k = p; i < n1; i++, k++)
    {
        L[i] = a[k];
    }

    for (int i = 0, k = q + 1; i < n2; i++, k++)
    {
        R[i] = a[k];
    }

    for (int k = p, i = 0, j = 0; i < n1 && j < n2; k++)
    {
        if (L[i] > R[j])
        {
            a[k] = L[i];
            i++;
        }
        else
        {
            a[k] = R[j];
            j++;
        }
    }
    if (i < n1)
    {
        for (int j = i; j < n1; j++, k++)
        {
            a[k] = L[j];
        }
    }
    if (j < n2)
    {
        for (int i = j; i < n2; i++, k++)
        {
            a[k] = R[i];
        }
    }
    delete[] L;
    delete[] R;
}

void mergeSort(vector<int>& a, int p, int r)
{
    if (p < r)
    {
        int q = (p + r) / 2;
        mergeSort(a, p, q);
        mergeSort(a, q + 1, r);
        merge(a, p, q, r);
    }
}

二路归并排序的过程需要进行logn趟。每趟归并排序的操作,就是将两个有序子序列进行归并,而每一对有序子序列归并时,记录的比较次数均小于等于记录的移动次数,记录移动次数均等于文件中记录的个数n,即每趟归并的时间复杂度为 O ( n ) O(n) O(n)。因此,二路归并排序在最好、最坏和平均情况的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),而且是一种稳定的排序方法,空间复杂度为 O ( 1 ) O(1) O(1)

快速排序

快速排序是一种非常高效的排序算法,它采用“分而治之”的思想,把大的拆分为小的。其原理是:对于一组给定的记录,通过一趟排序后,将原序列分为两部分,其中前部分的所有记录均比后部分的所有记录小,然后再依次对前后两部分的记录进行快速排序,递归该过程,直到序列中的所有记录均有序为止

具体算法步骤如下:

  1. 分解:将输入的序列a[m, …, n]划分成两个非空子序列a[m, …, k]和a[k+1, …, n],使a[m, …, k]中任一元素的值不大于array[k+1, …, n]中任一元素的值

  2. 递归求解:通过递归调用快速排序算法分别对a[m, …, k]和a[k+1, …, n]进行排序

  3. 合并:由于对分解出的两个子序列的排序是就地进行的,所以在a[m, …, k]和a[k+1, …, n]都排好序后,不需要执行任何计算,a[m, …, n]就已排好序

void sort(vector<int>& a, int low, int high)
{
    if (low >= high)
    {
        return;
    }

    int i = low;
    int j = high;
    int index = a[i];
    while (i < j)
    {
        while (i < j && a[j] >= index)
        {
            j--;
        }
        if (i < j)
        {
            a[i++] = a[j];
        }
        while (i < j && a[i] < index)
        {
            i++;
        }
        if (i < j)
        {
            a[j--] = a[i];
        }
    }
    a[i] = index;
    sort(a, low, i - 1);
    sort(a, i + 1, high);
}

void quickSort(vector<int>& a, int n)
{
    sort(a, 0, n - 1);
}

当初始的序列整体或局部有序时,快速排序的性能将会下降,此时快速排序将退化为冒泡排序

最坏时间复杂度

最坏情况是指每次区间划分的结果都是基准关键字的左边(或右边)序列为空,而另一边的区间中的记录项仅比排序前少了一项,即选择的基准关键字是待排序的所有记录中最小或最大的

最好时间复杂度

最好情况是指每次区间划分的结果都是基准关键字左右两步的序列长度相等或者相差为1,即选择的基准关键字为待排序的记录中的中间值,此时进行的比较次数总共为nlogn,所以在最好的情况下,快速排序的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)

平均时间复杂度

快速排序的平均复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。虽然快速排序在最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2),但是在所有平均时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)的算法中,快速排序的平均性能是最好的

空间复杂度

快速排序的过程中需要一个栈空间来实现递归。当每次对区间划分都比较均匀时(即最好情况),递归树的最大深度为 ⌈ l o g n ⌉ + 1 \left \lceil logn\right \rceil+1 logn+1;当每次区间划分都使得有一边的序列长度为0时,递归树的最大深度为n。在每轮排序结束后,比较基准关键字左右记录的个数,对记录多的一边先进行排序,此时,栈的最大深度可降为 l o g n logn logn。因此,快速排序的平均空间复杂度为 O ( l o g n ) O(logn) O(logn)

基准关键字的选取

基准关键字的选取是决定快速排序算法性能的关键。常用的基准关键字的选择有以下2种方式

  1. 三者取中。三者取中是指在当前序列中,将其首、尾和中间位置上的记录进行比较,选择三者的中值作为基准关键字,在划分开始前交换序列中的第一个记录与基准关键字的位置

  2. 取随机数。取left和right之间的一个随机数m(left≤m≤right),用n[m]作为基准关键字。这种方法使得n[left]~n[right]之间的记录是随机分布的,采用此方法得到的快速排序一般称为随机的快速排序

快速排序与归并排序

快速排序与归并排序的原理都是基于分治思想,即首先把待排序的元素分成两组,然后分别对这两组排序,最后把两组结果合并起来。

它们的不同点在于,进行的分组策略不同,后面的合并策略也不同。归并排序的分组策略是假设待排序的元素存放在数组中,那么其把数组前面一半元素作为一组,后面一半元素作为另外一组。而快速排序则是根据元素的值来分组,即大于某个值的元素放在一组,而小于某个值的元素放在另一组,该值称为基准值。对于快速排序和归并排序来说,分组策略越简单,后面的合并策略就越复杂,因为快速排序在分组时已经根据元素大小分组了,而合并的时候,只需把两个组合并起来就行了,归并排序则需要对两个有序的数组根据大小进行合并

希尔排序

希尔排序也称为“缩小增量排序”。它的基本原理是:首先将待排序的元素分成多个子序列,使得每个子序列的元素个数相对较少,对各个子序列分别进行直接插入排序,待整个待排序序列“基本有序后”,再对所有元素进行一次直接插入排序

具体步骤如下:

  1. 选择一个步长序列 t 1 , t 2 , . . . , t k t_1,t_2,...,t_k t1,t2,...,tk,满足 t i > t j ( i < j ) , t k = 1 t_i>t_j(i<j), t_k=1 ti>tj(i<j),tk=1

  2. 按步长序列个数k,对待排序序列进行k趟排序

  3. 每趟排序,根据对应的步长 t i t_i ti,将待排序列分割成 t i t_i ti个子序列,分别对各个子序列进行直接插入排序

void shellSort(vector<int>& a, int n)
{
    int temp;
    for (int h = n / 2; h > 0; h = h / 2)
    {
        for (int i = h; i < n; i++)
        {
            temp = a[i];
            for (int j = i - h; j >= 0; j -= h)
            {
                if (temp < a[j])
                {
                    a[j + h] = a[j];
                }
                else
                {
                    break;
                }
            }
            a[j + h] = temp;
        }
    }
}

希尔排序的关键并不是随便地分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。希尔排序是一种不稳定的排序方法,平均时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),最差情况下的时间复杂度为 O ( n s ) ( 1 < s < 2 ) O(n^s)(1<s<2) O(ns)(1<s<2),空间复杂度为 O ( 1 ) O(1) O(1)

堆排序

堆是一种特殊的树形数据结构,其每个结点都有一个值,通常提到的堆都是指一棵完全二叉树,根节点的值小于(或大于)两个子结点的值,同时根结点的两个子树也分别是一个堆

堆排序是一种树形选择排序,在排序过程中,将R[1,…,N]看成是一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点的内在关系来选择最小的元素

堆排序的思想是:对于给定的n个记录,初始时把这些记录看作一棵顺序存储的二叉树,然后将其调整为一个大顶堆,将堆的最后一个元素与堆顶元素进行交换后,堆的最后一个元素即为最大记录;接着将前(n-1)个元素(即不包括最大记录)重新调整为一个大顶堆,再将堆顶元素与当前堆的最后一个元素进行交换后得到次大的记录,重复该过程,直到调整的堆中只剩一个元素时为止,该元素即为最小记录,此时可得到一个有序序列

堆排序主要包括两个过程:一是建立堆;二是交换堆顶元素与最后一个元素的位置

void adjustMinHeap(vector<int>& a, int pos, int n)
{
    int temp;
    int child;
    for (temp = a[pos]; 2 * pos + 1 <= n; pos = child)
    {
        child = 2 * pos + 1;
        if (child < n && a[child] > a[child + 1])
        {
            child++;
        }
        if (a[child] < temp)
        {
            a[pos] = a[child];
        }
        else
        {
            break;
        }
    }
    a[pos] = temp;
}

void swap(int& a, int& b)
{
    int temp = a;
    a = b;
    b = temp;
}

void MinHeapSort(vector<int>& a, int n)
{
    for (int i = n / 2 - 1; i >= 0; i--)
    {
        adjustMinHeap(a, i, n - 1);
    }
    for (int i = n - 1; i >= 0; i--)
    {
        swap(a[0], a[i]);
        adjustMinHeap(a, 0, i - 1);
    }
}

堆排序方法对记录较少的文件效果一般,但对于记录较多的文件,还是很有效的,其运行时间主要耗费在创建堆和反复调整堆上。堆排序即使在最坏情况下,其时间复杂度也为 O ( n l o g n ) O(nlogn) O(nlogn)。它是一种不稳定的排序方法

各种排序算法优劣

  1. 所有相等的数经过某种排序方法后,仍能保持它们在排序之前的相对次序,就称这种排序方法是稳定的,反之就是非稳定的。各种排序算法中稳定的排序算法有直接插入排序、冒泡排序和归并排序,而不稳定的排序算法有希尔排序、快速排序、简单选择排序和堆排序

  2. 时间复杂度为 O ( n 2 ) O(n^2) O(n2)的排序算法有直接插入排序、冒泡排序、快速排序和简单选择排序,时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)的排序算法有堆排序和归并排序

  3. 空间复杂度为 O ( 1 ) O(1) O(1)的算法有简单选择排序、直接插入排序、冒泡排序、希尔排序和堆排序,空间复杂度为 O ( n ) O(n) O(n)的算法是归并排序,空间复杂度为 O ( l o g n ) O(logn) O(logn)的是快速排序

  4. 虽然直接插入排序和冒泡排序的速度比较慢,但是当初始序列整体或局部有序时,这两种排序算法会有较好的效率。当初始序列整体或局部有序时,快速排序算法的效率会下降。当排序序列较小且不要求稳定时,直接选择排序效率较好;要求稳定时,冒泡排序效率较好

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值