排序算法分析及代码实现——五类九种排序(面试考研必备)

一、前言

  排序算法是一类很经典的算法,在面试中经常被问到,需要我们手撕某个排序算法,或者分析其时间、空间复杂度并与其他算法比较优劣。而在考研中,也是数据结构必考知识,还可能涉及稳定性、内外排序问题,可见排序算法是基础中的基础。以下将仅仅简单分析各类排序算法并贴出代码,更详细地可具体百度某类算法。

二、插入排序

2.1 直接插入排序

  直接插入排序,可以把数组a[n]看做有序表无序表组成两部分。开始时有序表只要一个元素a[0],之后每次遍历时都从无序表取出一个元素,插入有序表中,直到n-1次后无序表就没有元素了,也就排序完成了。元素插入有序表的操作,就是查找该元素在有序表中合适的位置,从后往前遍历,未找到则数组后移,给插入元素空出位置插入。
  时间复杂度:O(n^2)。折半插入排序是稳定的排序。稳定:关键字相同的元素排序前后位置不变。

代码

//直接插入排序,有序表里存放已经排序好的数组,每次取出当前元素,从后开始遍历,
// 小于则数组往后移动a[j+1] = a[j];否则大于,直接赋值。
void straight_insertion_sort(int a[], int start, int end)
{
    int temp;
    int i, j;

    for (i = start + 1; i < end; i++)//从第二个元素开始:start+1
    {
        temp = a[i];	//保存当前元素
        for (j = i - 1; temp < a[j] && j >= start; j--)
        {
            a[j + 1] = a[j];
        }
        a[j + 1] = temp;	//空出的位置插入当前元素
    }
}

2.2 折半插入排序

  折半插入排序的基本思路和 2.1 直接插入排序 是一致的,只不过查找的方式采用了折半查找(二分法),比直接插入算法明显减少了关键字之间比较的次数,因此速度比直接插入排序算法快,但记录移动的次数没有变,所以折半插入排序算法的时间复杂度仍然为O(n^2),与直接插入排序算法相同。同样的,折半插入排序也是稳定的排序方式。

代码

//折半插入排序,思路与直接插入排序一致,不过查询用折半查找
void binary_insertion_sort(int a[], int start, int end)
{
    int temp;
    int i, j, l, r, mid;

    for (i = start + 1; i < end; i++)
    {
        temp = a[i];
        /* 折半查找 */
        l = start;
        r = i - 1;
        while (l <= r)
        {
            mid = (l + r) / 2;
            if (temp < a[mid])
            {
                r = mid - 1;
            }
            else
            {
                l = mid + 1;
            }
        }
        /* 折半查找 */
        for (j = i - 1; j >= l; j--)	//移动次数不变
        {
            a[j + 1] = a[j];
        }
        a[l] = temp;
    }
}

2.3 希尔插入排序

  直接插入排序的算法时间复杂度为O(n^2),但是,如果待排序的序列是正序的,或者说按关键字“基本有序”,那么时间复杂度可以接近O(n)。举个简单的例子,如果待排序序列已经是排序好的,那么直接插入排序内层循环每次只需执行一次,时间复杂度O(1),再加上外层循环就是O(n)的时间复杂度。虽然实际上待排序的序列不可能完完全全排序好,但是从另一方面来说,如果序列长度n足够小,是不是可以看成基本有序呢?所以希尔插入排序由此提出。
  希尔排序的基本思路:假设带排序序列有n个元素,那么我们可以取一个整数gap = [n/3] + 1 作为间隔,将全部元素分为gap个子序列,间隔为gap的元素是同一个子序列,在每个子序列分别进行直接插入排序。然后缩小gap,gap = [gap / 3] + 1,再重复上述操作,直到gap=1时就完成了全部排序。
如下图:
在这里插入图片描述
  希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比o(n^2)好一些。

两个函数代码

对于shell_insert,如果gap=1,就直接退化为直接插入排序。所以最坏情况下时间复杂度O(n^2),并且希尔排序不是稳定的。

//希尔插入排序的一部分,和直接插入排序类似,不过间隔为gap
void shell_insert(int a[], int start, int end, int gap)
{
    int temp;
    int i, j;

    //与直接插入排序类似
    for(i = start + gap; i < end; i++)
    {
        temp = a[i];
        for (j = i - gap; j >= start && temp < a[j]; j -= gap)//原本1的位置改为gap
        {
            a[j + gap] = a[j];
        }
        a[j + gap] = temp;
    }
}
//希尔插入排序
void shell_sort(int a[], int start, int end)
{
    int gap = end - start;
    while (gap > 1)
    {
        gap = gap / 3 + 1;
        shell_insert(a, start, end, gap);//按gap直接插入排序
    }
}

三重for循环

d每次除以2,直到d=1。

void shell_sort(int a[], int start, int end)
{
    int d = end - start;
    for (d = 4; d >= 1; d /= 2)//希尔排序d
    {
        //以下是直接插入排序,注意d
        for (int i = start + d; i < end; i++)
        {
            int tmp = a[i];
            int j;
            for (j = i - d; j >= start && tmp < a[j]; j -= d)
            {
                a[j + d] = a[j];
            }
            a[j + d] = tmp;
        }
    }
}

三、交换排序

3.1 冒泡排序

  假设待排序序列的元素个数为n,那么冒泡排序的思路就是从后往前比较,如果发生逆序(如前一个比后一个大),就交换它们,直到结束。这称为一趟冒泡,结果就是最小的元素交换到第一个位置。下一趟冒泡,前面的最小元素不参与交换,第二个位置得到第二小的元素,如此n-1次冒泡就可以完成排序。
  时间复杂度:O(n).

代码

//冒泡排序
void bubble_sort(int a[], const int start, const int end)
{
    int exchange;
    int tmp;
    int i, j;

    for (i = start; i < end - 1; i++)
    {
        exchange = 0;
        for (j = end - 1; j > i; j--)
        {
            if (a[j - 1] > a[j])
            {
                tmp = a[j - 1];
                a[j - 1] = a[j];
                a[j] = tmp;
                exchange = 1;
            }
        }
        if (exchange == 0)//未发生交换,说明已经排序好
            return;
    }
}

3.2 快速排序

  快速排序的基本思想:任取待排序元素序列中的某个元素作为基准,根据该元素的关键字大小,将数组分为两个序列:左侧所有元素的关键字都小于该元素的关键字大小,右侧所有元素的关键字都大于该元素的关键字大小,基准元素在两者之间。然后重复上述操作,直到完成整个排序。
在这里插入图片描述

  • 最好时间复杂度:O(nlogn),最坏情况达到:O(n^2),平均时间复杂度:O(n ^ 2)。时间复杂度的计算和递归深度有关,O(n * 递归深度),可以将排序的元素用二叉排序树列出,通过计算二叉树的深度即为递归深度。
  • 最坏情况,当数组已经是有序或者逆序时,因为我们每次取第一个元素为基准元素,那么一轮快排后,左右两边极度不平衡,即一边无元素一边剩余的元素。所以退化为O(n ^ 2)。即坏情况出现在,选取的基准元素使得两边元素不平衡,效率极低。解决方案:随机选取,随机从概率选择到最小或最大值的元素可能性就降低了;取开头、中间、末尾元素中的中间值作为基准元素。
  • 不过在实际情况下,因为数据基本上都是无序的,所以快排的效率还是很高。快排是不稳定的。
  • 快排采用分治法的思想,且效率在几种排序中较高,常作为面试题。快排在数据量大的情况下效率较高,经常和堆排序、归并排序竞争。在快排达到最坏情况下可采取堆排序。

代码

//快排之一次划分
int partition(int a[], const int start, const int end)
{
    int i = start;
    int j = end - 1;
    const int pivot = a[i];//以pivot的值为界限分割

    while (i < j)
    {
    	//注意必须加=,因为如果等于的也加到左边,之后再出现一个小于pivot的就不是单边有序了
        while (i < j && a[j] >= pivot) j--;
        a[i] = a[j];
        while (i < j && a[i] <= pivot) i++;
        a[j] = a[i];
    }
    a[i] = pivot;
    return i;
}
//快速排序-递归
void quick_sort(int a[], const int start, const int end)
{
    if (start < end - 1)
    {
        const int pivot_pos = partition(a, start, end);//一次划分
        quick_sort(a, start, pivot_pos);//递归左边
        quick_sort(a, pivot_pos + 1, end);//递归右边
    }  
}

四、选择排序

4.1 简单选择排序

  简单选择排序,也叫直接选择排序。其基本思路是:从前往后遍历,在 [i+1, n] 中找到最小值,如果最小值不是i,则最小值与i交换,i++。重复之前的操作直到交换结束。

时间复杂度:O(n^2)。而且不论数组是有序、逆序,都需要n-1趟排序,也就是说时间复杂度不会因为数组的排列而改变。同时,简单选择排序是不稳定的。看个例子,221,第一次将1和2交换,这样同为关键字的2顺序就改变了,所以不稳定。

代码

//简单选择排序(直接选择排序)
void simple_select_sort(int a[], int start, int end)
{
    for (int i = start; i < end; ++i)
    {
        int min = i;    //最小值
        for (int j = end - 1; j > i; --j)
        {
            if (a[j] < a[min])
            {
                min = j;
            }
        }
        if (min != i) swap(a[min], a[i]);

    }
}

4.2 堆排序

堆定义

首先,我们需要明白什么是堆。堆(heap)是计算机科学中一类特殊的数据结构的统称。堆需要满足两个性质:

  • 堆是一棵完全二叉树。所以,节点a[i]的左子节点a[2i+1],右子节点a[2i+2]。
  • 堆中某个节点的值总是不大于或不小于其父节点的值。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

如下图就是一个大根堆。
在这里插入图片描述
对这棵二叉树进行层序遍历,对应数组arr:
在这里插入图片描述

堆排序思想

  堆排序的基本思想:首先将n个数组元素构建成一个大根堆,这时候,整个序列的最大值就是堆顶元素。然后,我们将堆顶元素和末尾元素交换,末尾就是最大值了。之后,将剩下的n-1个元素重新构建成一个堆,再延续之前的操作,就得到排序后的数组了。
  其实堆排序的关键就是两步:

  • 将待排序序列构建成大根堆(升序)。
  • 将大根堆与队尾元素交换。
  • 重复上述操作,直到整个序列有序。

分析

  堆排序需要n-1趟,属于选择排序的一种。时间复杂度:O(nlogn),生成大根堆用到了树的思想,树的深度:[logn] + 1。

代码1

//堆排序
#define left(x) (2 * x + 1)     //左子节点的数组下标
#define right(x) (2 * (x + 1))  //右子节点的数组下标
//构建一个大根堆
void max_heap(int a[], int i, int low, int high)
{
    int l = left(i);
    int r = right(i);
    int tmp;//交换
    int max = i;//保存 l、r、i 三者的最大值

    //找出最大值
    if (l <= high && a[l] > a[i]) max = l;
    if (r <= high && a[r] > a[max]) max = r;

    //是否修改
    if (max != i)
    {
        //修改大根堆中根为最大值
        tmp = a[i];
        a[i] = a[max];
        a[max] = tmp;

        max_heap(a, max, low, high);
    }
}
//堆排序
void heap_sort(int a[], int length)
{
    //1.建立最大堆
    for (int i = length / 2 - 1; i >= 0; i--)
        max_heap(a, i, 0, length - 1);

    int tmp;
    for (int i = length - 1; i >= 1; i--)
    {
        //因为最大堆根节点为最大值,所以交换根节点和i
        tmp = a[i];
        a[i] = a[0];
        a[0] = tmp;

        max_heap(a, 0, 0, i);
    }
}

代码2

void HeapAdjust(int a[], int k, int len)//将k为根的子树调整为大根堆
{
    int tmp = a[k];
    for (int i = 2 * k + 1; i < len; i = i * 2 + 1)//i=i*2+1的原因是:对交换后的节点进行左右子节点判断
    {
        if (i < len - 1 && a[i] < a[i + 1]) i++;//取左右结点中大的节点
        if (tmp >= a[i]) break;
        else
        {
            a[k] = a[i];//调整使得根最大
            k = i;//修改k值,继续筛选
        }
    }
    a[k] = tmp;
}

void BuildMaxHeap(int a[], int len)//建立大根堆
{
    for (int i = len / 2 - 1; i >= 0; --i)//对于二叉树,只取len/2调整,因为之后的是叶子节点
    {
        HeapAdjust(a, i, len);
    }
}

void heap_sort(int a[], int len)//堆排序,升序(大根堆)
{
    BuildMaxHeap(a, len);
    for (int i = len - 1; i > 0; --i)
    {
        swap(a[i], a[0]);//和堆顶元素交换
        HeapAdjust(a, 0, i);
    }
}

五、归并排序

  所谓“归并”,就是将两个或两个以上的有序序列合并成一个有序序列。归并排序就是建立在归并操作上的一个有效的排序算法,是采用分治法的一个典型应用。ps:快速排序也是基于分治法,速度仅次于 快速排序,为稳定排序算法。不过,快排从整个序列入手根据关键字大小分割成两个部分,递归直到序列很小为止;而归并排序更像从很多个小元素的归并,不断递归直到最后一个大序列。
  平均时间复杂度和最坏情况都是:O(nlogn)。归并排序是稳定的。

以下是一个二路归并的例子:
在这里插入图片描述

递归算法

以下两种代码,区别在于:第一种代码end等同于n,第二种代码end等同于n-1。
代码1

//将两个有序表合并成一个新的有序表
//[start, mid-1]为一个有序表,[mid, end]为另一个有序表
void merge(int a[], int tmp[], const int start, const int mid, const int end)
{
    int i, j, k;
    for (i = 0; i < end; i++)   //tmp[]辅助数组
        tmp[i] = a[i];

    for (i = start, j = mid, k = start; i < mid && j < end; k++)
    {
        if (tmp[i] < tmp[j])    //取两段中小的
            a[k] = tmp[i++];
        else
            a[k] = tmp[j++];
    }

    //如果两个表中有未检测完的表。
    while (i < mid)  a[k++] = tmp[i++];
    while (j < end)  a[k++] = tmp[j++];
}
//归并排序
void merge_sort(int a[], int tmp[], const int start, const int end)
{
    if (start < end - 1)//至少两个元素
    {
        const int mid = (start + end) / 2;
        merge_sort(a, tmp, start, mid);
        merge_sort(a, tmp, mid, end);//注意是mid
        merge(a, tmp, start, mid, end);//最后一层开始往前merge
    }
}

代码2:

int* B = (int*)malloc(8*sizeof(int));//辅助数组
void Merge(int a[], int start, int mid, int end)
{
    for (int i = start; i <= end; i++)
    {
        B[i] = a[i];
    }

    int i = start, j = mid + 1, k = start;
    while (i <= mid && j <= end && k <= end)
    {
        if (B[i] <= B[j])
        {
            a[k++] = B[i++];
        }
        else
        {
            a[k++] = B[j++];
        }
    }
    while (i <= mid) a[k++] = B[i++];
    while (j <= end) a[k++] = B[j++];
}

//start:0,end:n-1
void merge_sort(int a[], int start, int end)
{
    if (start < end)
    {
        int mid = (start + end) / 2;
        merge_sort(a, start, mid);
        merge_sort(a, mid + 1, end);
        Merge(a, start, mid, end);
    }
}

非递归算法

代码1:

void Merge(vector<int>& arr, int start, int mid, int end)
{
    vector<int> tmp = arr;
    int i = start, j = mid + 1, k = start;
    while (i <= mid && j <= end && k <= end)
    {
        if (tmp[i] < tmp[j])
        {
            arr[k++] = tmp[i++];
        }
        else
        {
            arr[k++] = tmp[j++];
        }
    }
    while (i <= mid) arr[k++] = tmp[i++];
    while (j <= end) arr[k++] = tmp[j++];
}

void merge_sort(vector<int>& arr, int n)
{
    int k = 1, i = 0;
    while (k < n)
    {
        //从前往后,将两个长度为k的子序列合并为1个
        for (i = 0; i + 2 * k - 1 < n; i += 2 * k)
        {
            Merge(arr, i, i + k - 1, i + 2 * k - 1);
        }
        //合并有序的左半部分以及不及一个步长的右半部分
        if (i < n - k)
        {
            Merge(arr, i, i + k - 1, n - 1);
        }
        k *= 2;
    }
}

代码2,使用sort:

void merge_sort(vector<int>& arr, int n)
{
    int k = 1;
    while (k < n)
    {
        k *= 2;
        for (int i = 0; i < n; i += k)
        {
            sort(arr.begin() + i, arr.begin() + min(i + k, n));
        }
    }
}

六、基数排序

  基数排序(radix sort)属于“分配式排序”,又称“桶子法”,是一种利用多关键字实现对单关键字排序的算法。有两种顺序,分为最高位优先 MSD 和最低位优先 LSD。以下介绍LSD:

(1)首先我们用静态链表存储n个元素,并且定义第一个元素a[0]为头指针,通过静态链表我们只需要修改每个元素的link值即可,不必移动元素。

静态链表定义:

typedef struct static_list_node_t
{
    int key;    //关键词
    int link;   //下一个结点
}static_list_node_t;

(2)每个位(0~9)设置一个(原理可参考计数排序or桶排序),桶采用静态链表结构存储,并且设定两个数组front[R]和rear[R],记录每个桶的头指针和尾指针。

(3)从头开始遍历,修改每个桶对应的头指针和尾指针,从而将相同值的桶用头指针到尾指针连接起来。

(4)接着,从小到大遍历所有有元素的桶,将前一个桶的尾指针和当前桶的头指针连接起来,遍历所有后就形成一个有序的序列。

(4)循环以上n次,n取决于最大数的位数。并且每次从最低位开始,最后完成排序。

基数排序时间复杂度:O(d * (n + R)),d为最大数的位数,R为元素个数。

代码

#define R 10
typedef struct static_list_node_t
{
    int key;    //关键词
    int link;   //下一个结点
}static_list_node_t;

//打印静态链表
static void static_list_print(const static_list_node_t a[])
{
    int i = a[0].link;
    while (i != 0)  //最后一个link = 0
    {
        printf("%d ", a[i].key);
        i = a[i].link;
    }
}

//获得十进制整数的某一位数字
static int get_digit(int n, const int index)
{
    int j;
    for (j = 1; j < index; j++)
    {
        n /= 10;
    }
    return n % 10;
}


//LSD 链式基数排序
//a 静态链表,a[0]头指针    n 待排序的总数    d 最大整数的位数
void radix_sort(static_list_node_t a[], const int n, const int d)
{
    int i, j, k, cur, last;
    int rear[R], front[R];  //front为头指针,rear为尾指针,头和尾之间的元素值一致

    //设置链表a各参数的link值
    for (i = 0; i < n; i++)
        a[i].link = i + 1;
    a[n].link = 0;

    for (i = 0; i < d; i++) //多少位遍历多少次
    {
        /* 分配 */
        //按计数排序的方式,存储了各元素,相同值之间用头指针和尾指针
        for (j = 0; j < R; j++) front[j] = 0;
        for (j = 0; j < R; j++) rear[j] = 0;
        for (cur = a[0].link; cur != 0; cur = a[cur].link)
        {
            k = get_digit(a[cur].key, i + 1);
            if (front[k] == 0)  //第一个值为k的元素
            {
                front[k] = cur;
                rear[k] = cur;
            }
            else
            {
                a[rear[k]].link = cur;  //前一个值为k的元素的link = cur
                rear[k] = cur;          //当前值作为下一个元素前驱
            }
        }

        /* 搜集 */
        j = 0;
        while (front[j] == 0)    j++;   //不等于0的才有值
        a[0].link = front[j];//下一次遍历的头
        last = rear[j]; //尾指针
        for (j = j + 1; j < R; j++)
        {
            if (front[j] != 0)  //不等于0的才有值
            {
                a[last].link = front[j];    //上一个的尾指针的link等于下一个的头指针,连接起来
                last = rear[j];
            }
        }
        a[last].link = 0;
    }
}

//基数排序测试
void radix_sort_test()
{
    static_list_node_t a[] = { {0, 0}, {373, 0}, {173, 0}, {273, 0}, {73, 0},
    {53, 0}, {184, 0}, {505, 0}, {269, 0}, {8, 0}, {83, 0}
    };
    radix_sort(a, R, 3);
    static_list_print(a);
}

七、对比

排序方法平均时间最坏情况辅助存储是否稳定
直接插入排序O(n^2)O(n^2)O(1)
折半插入排序O(n^2)O(n^2)O(1)
希尔插入排序N/AN/AO(1)
冒泡排序O(n^2)O(n^2)O(1)
快速排序O(nlogn)O(n^2)O(nlogn)
简单选择排序O(n^2)O(n^2)O(1)
堆排序O(nlogn)O(nlogn)O(1)
归并排序O(nlogn)O(nlogn)O(n)
基数排序O(d*(n+R))O(d*(n+R))O( R )
  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

暗夜无风

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值