理解快速排序

前言

在学习数据结构的过程中,必然有这样一个专题:排序,一般都会接触到以下几种排序:

这里写图片描述

作为排序算法中效率最高的快速排序有着O(N*logN)的时间复杂度,同时它其中涉及的思想:分治也是十分的有用,因此无论是各种考试还是IT公司的面试一般都少不了它。更重要的是,快速排序被称为20世纪十大算法之一,所以我们有什么理由不好好去学习理解快排呢?

目录

这篇博客将会根据我自己的理解,尽量写完整有关快排常见的要点(包括库函数的使用等),希望对大家有帮助,如果有错误,也请指出。本文将会讲述以下几点:

  • 简要讲述C语言库中的qsort和STL中sort使用
  • 快排单趟排序(1.挖坑法 2.前后指针法 3.左右指针法)
  • 快速排序的递归和非递归实现
  • 快排的时间空间复杂度以及优化


C语言库中的qsort和STL中sort使用

C语言中的qsort和STL中的sort都基于快排的排序算法,当然不完全是快排,也包含了其他的一些排序,以及一些优化,尤其是STL中对快排的优化,一般来说,几乎不会有人能写个快排被它还快了,因此能用现成的当然是最好的(不要重复造轮子,嘿嘿。。。),当然被面试写个快排还是要会的 = =。

这两者原理等讲快排的时候再细看,我们先看看他们是怎么用的。

qsort

废话不多说,直接看它的接口:

void qsort( void *base, size_t num, size_t width, int (__cdecl *compare )(const void *elem1, const void *elem2 ) );

int compare (const void *elem1, const void *elem2 ) );

相对来说,qsort对于C语言新手有点不好用,因为它从某种程度实现了类似C++泛型的功能,能够对很多数据类型进行升序或者降序排序,而我们只需要传一个函数指针就可以了。现在我们来看一看qsort的各个参数意思:

第一个参数 base 是需要排序的目标数组名(或者也可以理解成开始排序的地址,可以写成&s[i])

第二个参数 num 是参与排序的目标数组元素个数

第三个参数 width 是单个元素的大小(或者目标数组中每一个元素长度),推荐使用sizeof(s[0])这样的表达式

第四个参数 compare 就是函数指针,也就是所谓的比较函数。

我们以一个例子来看看qsort是怎么使用:

排序一个数组,以升序或者降序,代码如下:

int Cmp1(const void* left, const void* right)
{
    return *(int*)left - *(int*)right;
    // if left element > right element  then return 1 and excute the swap operation
    // so it's in ascending order
}

int Cmp2(const void* left, const void* right)
{
    return *(int*)right - *(int*)left;
    // if right element > left element  then return 1 and excute the swap operation
    // so it's in descending order
}

void TestQsort()
{
    int a[] = { 2, 5, 4, 9, 3, 6, 8, 7, 1, 0 };
    int n = sizeof(a) / sizeof(a[0]);
    PrintArray(a, n);
    cout << endl;
    cout << "in ascending order :" << endl;
    qsort(a, n, sizeof(a[0]), Cmp1);
    PrintArray(a, n);

    cout << "in descending order :" << endl;
    qsort(a, n, sizeof(a[0]), Cmp2);
    PrintArray(a, n);
}

qsort的各个参数上面都写了,重点和难点就是最后一个参数cmp的比较函数int (__cdecl *compare )(const void *elem1, const void *elem2 )。

其实这个参数就是一个函数指针,指向一个返回值为int,参数为(const void *elem1, const void *elem2 )的函数,它的功能就是为了现在升序或者降序。因此我们需要自己写个这样的函数,比如上述代码:

int Cmp1(const void* left, const void* right)
{
    return *(int*)left - *(int*)right;
    // if left element > right element  then return 1 and excute the swap operation
    // so it's in ascending order
}

就是一个升序版本的。为什么是升序???我们来看看这个函数,它的参数为const void*,这是为了访问到任意的数据类型,因此我们需要强转类型,比如对于int类型,我们先转换(int *)left将这个指针转换指向int类型,然后解引用得到这个值。现在这个函数可以这么理解了return leftValue - rightValue,如果左边的值比右边的小,那么return 负数,相等return 0,否则return 正数。

还记得冒泡排序的交换吗?因为快排和冒泡都属于交换排序(见上图),我们可以这么理解:如果左边的数比右边的数大,那么返回正数,在冒泡排序中,这需要交换两个数了。因此大的数就被放到右边了,小数就被放到左边了,从左到右就是升序的过程了。

为了便于理解qsort,我写了个bubblesort带函数指针的版本,参数和qsort一样,只不过用的bubblesort算法相对来说理解更为简单。用法和qsort完全一样。

//swap实现letft和right指针指向的地方的值的互换
void swap(char *left, char *right, int width)//width为每个元素占的字节数
{
    char *temp = (char *)malloc(sizeof(char)* width);
    if (temp == NULL)
    {
        printf("malloc operation fails\n");
        exit(1);
    }
    for (int i = 0; i < width; ++i)
    {
        temp[i] = left[i];
        left[i] = right[i];
        right[i] = temp[i];
    }
}

void BubbleSort(void *base, size_t num, size_t width, int(*cmp)(const void* left, const void* right))
{
    char* a = (char*)base;
    for (int i = 0; i < (int)num; ++i)
    {
        for (int j = 0; j < (int)num - i - 1; ++j)
        {
            // equal to  cmp(a[j],a[j+1])
            if (cmp(a + j * width, a + (j + 1) * width) > 0)
            {
                swap(a + j * width, a + (j + 1) * width, width);
            }
        }
    }
}

void TestBubbleSort()
{
    int a[] = { 2, 5, 4, 9, 3, 6, 8, 7, 1, 0 };
    int n = sizeof(a) / sizeof(a[0]);
    PrintArray(a, n);
    cout << endl;
    cout << "in ascending order :" << endl;
    BubbleSort(a, n, sizeof(a[0]), Cmp1);
    PrintArray(a, n);

    cout << "in descending order :" << endl;
    BubbleSort(a, n, sizeof(a[0]), Cmp2);
    PrintArray(a, n);
}

STL中的sort

相对来说,我更喜欢用STL中sort,因为用起来相对方便点,在这里,我只介绍最基础的使用,因为本文不是讲解sort的。。。

sort算法的参数都需要输入一个范围,[begin, end)。这里使用的迭代器(iterator)都需是随机迭代器(RadomAccessIterator), 也就是说可以随机访问的迭代器。比如:

void TestSort()
{
    int a[] = { 2, 5, 4, 9, 3, 6, 8, 7, 1, 0 };
    int sz = sizeof(a) / sizeof(a[0]);
    vector<int> v(a, a + sz);
    PrintVector(v);
    cout << "in ascending order:" << endl;
    sort(v.begin(), v.end());//相当于sort(v.begin(), v.end(), less<int>()); 默认升序
    PrintVector(v);

    cout << "in descending order:" << endl;//sort需要#include <algorithm>
    sort(a, a + sz, greater<int>());//greater需要#include <functional>
    PrintArray(a, sz);
}

只需要调用一个sort就可以实现数组,vector内的元素排序,十分方便,当然自定义类型仍然需要传一个仿函数等等,这些可参考详细解说 STL 排序(Sort)。最后的话,其实sort可以看作是qsort的一个升级版,但是尽量使用sort而非c语言库里面的qsort。



快排单趟排序(1.挖坑法 2.前后指针法 3.左右指针法)

终于到了快速排序的算法讲解,前面扯了那么多,实际上也是我自己对于库里面的函数/算法的复习,毕竟如果参加线上笔试,使用现成的轮子sort肯定是比自己手写一个快排好多了,无论是时间,效率还是错误等等。

快速排序的基本思想:通过一趟排序将待排序记录分割成独立的两部分,其中一部分的关键字均比另一部分的关键字小,则分别对这两部分采用相同的方法排序,以达到有序

步骤如下:
1. 从数列中取出一个数作为基准数(pivot)。
2. 分区,将比这个数大的数全放到它的右边,小于它的数全放到它的左边,等于它的数随便放它左边或者右边。
3. 再对左右区间分别重复第二步,直到各区间只有一个数。

这里关键的一步就是第2步分区的过程,如何把大数放在右边,小边放在左边,这里我们介绍三种方法:

1. 左右指针法
2. 前后指针法
3. 挖坑法

注意:为了重点讲第二个步骤,我们一律选pivot为最右边的元素,比如下图,刚开始的基准数为5
待排序的数据如下:

这里写图片描述

左右指针法

刚开始设置基准值pivot为最右边的元素也就是5。
设置left指向第一个元素2,right指向最后一个元素5。

  1. 如果v[left] < pivot,将left指针右移,直到v[left] > pivot
  2. 如果v[right] > pivot,将left指针左移,直到v[left] < pivot
  3. 此时交换v[left]和v[right]
  4. 继续重复1,2,3的过程知道left和right相遇,此时left == right,交换v[left]和pivot的值,返回left或者right

下面是图示:

这里写图片描述

此时序列已经变成了 2 0 4 1 3 5 8 7 9 6
左边的值都比5小,右边的值都比5大,一趟排序之后达到近似有序,然后分别对左半区间和右半区间进行相同的调整即可。

//左右指针法--交换
int Partition(vector<int>& v, int left, int right)
{
    int& pivot =  v[right];
    while (left < right)
    {
        while (left < right && v[left] <= pivot)
        {
            ++left;
        }
        while (left < right && v[right] >= pivot)
        {
            --right;
        }
        swap(v[left], v[right]);
    }
    swap(pivot, v[left]);
    return left;
}

前后指针法

依旧是刚才的数据,刚开始设置基准值pivot为最右边的元素也就是5。

这里写图片描述

刚开始设置cur指向第一个元素2,prev指向空。

  1. 如果v[cur] < pivot,那么让prev指针右移,如果prev不等于cur,那么交换v[cur]和v[prev]的值
  2. cur指针右移
  3. 重复1,2的过程直到cur指针指到最右边,此时让prev再右移一次,最后交换pivot(也就是v[cur])和v[prev]的值,返回prev

下面是图示:

这里写图片描述

这里写图片描述

代码如下:

//前后指针法
int Partition(vector<int>& v, int left, int right)
{
    int prev = left - 1;
    int cur = left;
    int pivot& = v[right];
    while (cur < right)
    {
        if (v[cur] < pivot && ++prev != cur)
        {
            swap(v[cur], v[prev]);
        }
        ++cur;
    }
    swap(v[cur], v[++prev]);
    return prev;
}

挖坑法

依旧是刚才的数据,刚开始设置基准值pivot为最右边的元素也就是5。

这里写图片描述

刚开始,将最右边设置为“坑”。
同时设置left指向第一个元素2,right指向最后一个元素5。

  1. 如果v[left] <= pivot,那么left指针右移直到v[left] > pivot停下,那么此时需要填坑,将v[right]也就是pivot初始坑填满,v[right] = v[left],并且此时设置v[left]为坑
  2. 现在我们移动right,如果v[right] >=pivot,right指针左移直到v[right] < pivot停下,同理此时填坑,v[left] = v[right],并且设置v[right]为坑
  3. 重复1,2过程直到left,right相遇,此时将v[right] = pivot填满,最后返回right

图示如下:(填坑后的结果)

这里写图片描述

代码如下:

//挖坑法
int Partition2(vector<int>& v, int left, int right)
{
    int pivot = v[right];
    while (left < right)
    {
        while (left < right && v[left] <= pivot)
        {
            ++left;
        }
        v[right] = v[left];
        while (left < right && v[right] >= pivot)
        {
            --right;
        }
        v[left] = v[right];
    }
    v[left] = pivot;
    return left;
}

快速排序的递归和非递归实现

我们之前说了快排的步骤:

  1. 从数列中取出一个数作为基准数(pivot)。
  2. 分区,将比这个数大的数全放到它的右边,小于它的数全放到它的左边,等于它的数随便放它左边或者右边。
  3. 再对左右区间分别重复第二步,直到各区间只有一个数。

我们上面实现了某个区间的的分区过程,以pivot为界,左边区间的数都比它小,右边区间都比它大,我们可以重复这一过程,在左右区间再次实行分区,这样依次递归下去,最终可以完成排序的功能。

因此快排的核心代码如下:

void QuickSort(vector<int>& v, int left, int right)
{
    assert(v.size() > 0);
    if (left >= right)
    {
        return;
    }
    int div = Partition(v, left, right);
    QuickSort(v, left, div - 1);
    QuickSort(v, div + 1, right);
}

显然这是一个递归的写法,我们知道递归就带来效率的低下,而且一旦递归的层次很深有可能出现递归所用的栈溢出,因此我们可以用一个栈来模拟递归的过程。代码如下:

void QuickSortNonRecursion(vector<int>& v, int left, int right)
{
    stack<int> s;
    if (left >= right)
    {
        return;
    }
    s.push(right);
    s.push(left);
    while (!s.empty())
    {
        int low = s.top();
        s.pop();
        int high = s.top();
        s.pop();
        int div = Partition2(v, low, high);
        if (low < div - 1)
        {
            s.push(div - 1);
            s.push(low);
        }
        if (high > div + 1)
        {
            s.push(high);
            s.push(div + 1);
        }
    }
}

快排的时间空间复杂度以及优化

快排的时间性能取决于快速排序递归的深度,可以用递归树来描述算法的执行情况,比如20,10,90,30,70,40,80,60,50,由于第一个关键字为50正好是序列的中间值,因此递归树是相对平衡的,性能比较好。

这里写图片描述

最好时间复杂度

最优的情况下,Partition每次划分所取的基准都是当前无序区的”中值”记录,划分的比较均匀,有点类似于二分,这样一来,递归树的深度为 logN + 1,仅需递归logn次,而每次Partition都需要对当前区间扫描一遍,次数必然不大于n次,那么时间复杂度为O(nlogn)

最差时间复杂度

最差的情况下,序列正序或者逆序,每次划分仅比上一次划分少一个一个记录,注意另一个区间为空,这样的递归树是一个斜树,此时需要执行n - 1次递归调用,每次的比较次数依然不大于n次,那么时间复杂度为O(n*n)

空间复杂度

空间复杂度主要是递归栈所用的空间,若每次划分较为均匀,则其递归树的高度为O(logn),故递归后需栈空间为O(logn)。最坏情况下,递归树的高度为O(n),所需的栈空间为O(n),平均情况,空间复杂度为O(logn)

由于关键字的比较交换是跳跃进行的,因此快速排序是一种不稳定的排序方法。例如27 23 27 3
以第一个27作为pivot中心点,则27与后面那个3交换,形成3 23 27 27,排序经过一次结束,但最后那个27在排序之初先于初始位置3那个27,所以不稳定。

优化–三数取中法

为了尽量获得最好的时间复杂度,如果每次划分的pivot都是当前序列的中间值,那么划分肯定相对均匀,性能也就最好。

最常见的优化方法就是三数取中法,取左端,右端和中间的三个数,将他们中间值作为枢轴pivot,这样pivot肯定不是最大或者最小的数,从概率上讲,取三个数均为最小或者最大数也是很小的,因此尽量保证好的时间复杂度。当然,如果数据量这样的方法不足以保证能够选择出一个好的pivot,这时候可以采用九数取中法,从数组中分三次取样,每次取三个数得到中数,再从这三个中数取一个中数作为pivot。原理和三叔取中法类似,在这里我们给出三数取中法的实现代码:

//三数取中法
int FindMidIndex(vector<int>& v, int left, int right)
{
    int mid = left + ((right - left) >> 1);
    if (v[left] > v[mid])
    {
        if (v[mid] > v[right]) //left > mid > right
        {
            return mid;
        }
        else //left > mid  mid <right
        {
            if (v[left] > v[right])
                return right;
            else
                return left;
        }
    }
    else //mid > left
    {
        if (v[left] > v[right]) //mid > left > right
        {
            return left;
        }
        else //mid > left  left < right
        {
            if (v[mid] > v[right])
                return right;
            else
                return mid;
        }
    }
}

//左右指针法调用三数取中法,其他类似
int Partition1(vector<int>& v, int left, int right)
{
    int midIndex = FindMidIndex(v, left, right);
    swap(v[midIndex], v[right]);
    int key = right;
    while (left < right)
    {
        while (left < right && v[left] <= v[key])
        {
            ++left;
        }
        while (left < right && v[right] >= v[key])
        {
            --right;
        }
        swap(v[left], v[right]);
    }
    swap(v[key], v[left]);
    return left;
}

优化–小区间优化法

前面我们提到,快速排序用到了递归,如果是数据量很大的排序,递归带来的性能对于它整体的算法优势可以忽略,但是如果本来数据记录就很少,我们用快速排序就有点大材小用了,此时还不如用直接插入排序来得好(直接插入排序是简单排序中性能最好的),因此当数据量很小的时候,比如小于13等等,因此快速排序改进一下,我们写出如下的代码:

void QuickSort(vector<int>& v, int left, int right)
{
    assert(v.size() > 0);
    if (right - low > 13)
    {
        int div = Partition3(v, left, right);
        QuickSort(v, left, div - 1);
        QuickSort(v, div + 1, right);
    }
    else
    {
        InsertSort(v, right - left + 1);
    }
}

PS:关于排序专题的代码可见我的github之Sort,求star。。。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值