从快速排序,堆排序到top k问题

快速排序,堆排序和 问题一直都是非常经典的算法知识点。并且他们在原理和算法思想上有相通的部分。这篇文章会简要复习和总结这三个算法知识,并且给出我自己的 实现。

快速排序

快速排序显然是最经典的排序算法,期望时间复杂度为 ,实际上最坏情况下可能退化乘 ,但是可以通过一些方法来避免或是减少最坏情况出现的概率。实际上快速排序相当快。

partition函数

快速排序最为关键的是 函数,这个函数选出一个枢纽 通过一次 扫描,将原序列分成左右两部分,左边部分的所有数都 ,右边部分的所有数都 。
函数的实现通过双指针,左指针 指向的是下一个存放 的数的位置,右指针向右扫描,每当找到一个 的数 ,则执行 ,即将这个数交换到左指针位置,然后 ,指向下一个存放的位置。

partition

template 
int partition(vector &vec, int first, int last) //* 对于[begin,end]范围内的元素做partition操作
{
    int pos = rng() % (last - first + 1); //* 随机选择pivot,使得不能构造出能达到最坏情况的数组
    swap(vec[first + pos], vec[last]);
    int i = first;             //* 第一个指针,指向下一个存放<=pivot元素的位置
    for (int j = i; j < last; j++) //* 第二个指针,遍历所有元素
    {
        if (vec[j] < vec[last]) //* 当前元素<=pivot,应存到i所在的位置,同时维护左指针
        {
            swap(vec[j], vec[i]);
            i++;
        }
    }
    swap(vec[last], vec[i]); //* pivot放回i指向的地方,完成一次partition
    return i;        //* 返回partition结束后分割的位置
}

上面的代码使用泛型对序列元素类型进行了抽象,可以进一步将第 行中的比较抽象成一个使用者传入的一个比较器。

快速排序实现

了解了 函数的原理和实现,快速排序也就很简单了。
快速排序每次对序列进行一次 ,这时候左侧元素一定都小于右侧元素,但是左右两侧的内部不一定有序,那么继续对左右两侧规模更小的序列进行快速排序。
如果每次随机选择 ,那么递归层数一定不会太多,期望复杂度 。
wikipedia中有详细解释
随机选择 很大程度上避免了最坏情况的出现,但是仍然有极小可能出现,如果想完全避免最坏情况,可以考虑每次选择 的时候先 求出中位数来当 。但是我怀疑这样常数会变大。

代码:

#include 
using namespace std;

mt19937 rng(time(0));

template 
int partition(vector &vec, int first, int last) //* 对于[begin,end]范围内的元素做partition操作
{
    int pos = rng() % (last - first + 1); //* 随机选择pivot,使得不能构造出能达到最坏情况的数组
    swap(vec[first + pos], vec[last]);
    int i = first;             //* 第一个指针,指向下一个存放<=pivot元素的位置
    for (int j = i; j < last; j++) //* 第二个指针,遍历所有元素
    {
        if (vec[j] < vec[last]) //* 当前元素<=pivot,应存到i所在的位置,同时维护左指针
        {
            swap(vec[j], vec[i]);
            i++;
        }
    }
    swap(vec[last], vec[i]); //* pivot放回i指向的地方,完成一次partition
    return i;        //* 返回partition结束后分割的位置
}

template 
void qsort(vector &vec, int first, int last)
{
    if (first >= last)
        return;
    int pos = partition(vec, first, last);
    if (first < pos - 1) //* 递归对两侧继续快排
        qsort(vec, first, pos - 1);
    if (pos + 1 < last)
        qsort(vec, pos + 1, last);
}

void test() //* 测试
{
    vector vec1 = {3, 6, 1, 0, 9, 2, 8, 4, 7, 5};
    vector vec2 = {"dyume", "hello_world", "arknights", "heapsort"};
    qsort(vec1, 0, vec1.size() - 1);
    qsort(vec2, 0, vec2.size() - 1);
    cout << "vec1 after sort:\n";
    for (const auto &x : vec1)
        cout << x << ' ';
    cout << '\n';
    cout << "vec2 after sort:\n";
    for (const auto &x : vec2)
        cout << x << ' ';
    cout << '\n';
}

int main()
{
    test();
}

堆排序

堆排序也是一种性能极佳的排序方法,复杂度 ,没有最坏情况。事实上, 使用的是一种将快速排序,堆排序和选择排序相结合的算法,在快速排序递归深度过深可能出现最坏情况时会改用堆排序。

既然是堆排序了,那么一定借助了堆这种数据结构。
首先堆是一颗完全二叉树,这使得堆能够使用数组来表示,而不需要像其他树形结构一样,通过指针来标识父亲节点和儿子节点。使用数组表示的树形结构显然更加方便简洁。
假设堆共有 个节点,堆的根存放在 号节点,每个节点 的左右儿子分别是 和 号节点。每个节点 的父亲则是 号节点。通过这些数量关系就可以通过编号来在树上随意跳转。

堆的数组表示

其次,堆满足性质:以大顶堆为例,每个节点的孩子的值都 节点的值。这样保证了堆的顶端一定是最大的。

堆的调整

堆的向下调整是堆较为关键的行为。当堆的顶部发生了改变,可能首部元素被取走,或者是发生了改变,那么可以从顶部向下调整来使堆恢复。
利用堆的性质,如果当前节点的值小于其孩子时,可以选出孩子中其中较大的一个与该节点进行交换,然后继续处理被交换的那个孩子。

向下调整

从网上扒了一张向下调整的图解,这图里的是小顶堆。

代码:

template 
void adjust(vector &vec, int pos, int len) //* pos位置的元素向下调整,大顶堆
{
    for (int child = pos * 2 + 1; child < len; pos = child, child = child * 2 + 1) //* 维护当前位置以及左孩子的位置
    {
        if (child + 1 < len && vec[child + 1] > vec[child]) //* 如果右孩子合法且更加大,则替换为右孩子
            child++;
        if (vec[pos] >= vec[child]) //* 如果当前已经比两个孩子都大了则退出
            break;
        swap(vec[pos], vec[child]); //* 否则交换值,继续对孩子调整
    }
}

堆排序的实现

堆排序分为两个步骤。

  1. 建堆:将无序的序列视为一个堆,从最后一个不是叶子的节点开始向下调整。每次调整相当于将以该节点为根的子树调整完毕。从最后一个不是叶子的节点开始到最开始的节点,每个都做一遍向下调整,调整完后就形成了一个堆。
  2. 排序:每次从堆的顶端取走一个元素,从后往前依次放置。由于堆的性质,每次取走的必定是剩下的序列中最大的元素,这样当堆被取完,排序自然就完成了。
#include 
using namespace std;

template 
void adjust(vector &vec, int pos, int len) //* pos位置的元素向下调整,大顶堆
{
    for (int child = pos * 2 + 1; child < len; pos = child, child = child * 2 + 1) //* 维护当前位置以及左孩子的位置
    {
        if (child + 1 < len && vec[child + 1] > vec[child]) //* 如果右孩子合法且更加大,则替换为右孩子
            child++;
        if (vec[pos] >= vec[child]) //* 如果当前已经比两个孩子都大了则退出
            break;
        swap(vec[pos], vec[child]); //* 否则交换值,继续对孩子调整
    }
}
template 
void heapsort(vector &vec) //* 原地排序,改变输入数组,时间复杂度O(nlogn),空间O(1)
{
    int sz = vec.size();
    for (int i = sz / 2 - 1; i >= 0; i--) //* 建堆过程,从最后一个不是叶子的元素开始向下调整
        adjust(vec, i, sz);
    for (int i = sz - 1; i >= 0; i--) //* 排序过程,每次从堆中取出最大的一个元素,然后调整
    {
        swap(vec[i], vec[0]);
        adjust(vec, 0, i);
    }
}

void test() //* 测试
{
    vector vec1 = {3, 6, 1, 0, 9, 2, 8, 4, 7, 5};
    vector vec2 = {"dyume", "hello_world", "arknights", "heapsort"};
    heapsort(vec1);
    heapsort(vec2);
    cout << "vec1 after sort:\n";
    for (const auto &x : vec1)
        cout << x << ' ';
    cout << '\n';
    cout << "vec2 after sort:\n";
    for (const auto &x : vec2)
        cout << x << ' ';
    cout << '\n';
}

int main()
{
    test();
}

top k 问题

问题也是非常常见的问题,比如在一堆玩家中需要选出分数最高的几个玩家显示在排行榜上,当玩家数量级非常多时,算法的复杂度直接影响了运行的速度。而将这个问题与堆排序和快速排序放在一起的原因是:他们其中蕴含的一些算法思想是类似的。

先给出几种复杂度较高的算法:

排序

先对所有元素进行一次排序,然后选出前 个元素,时间复杂度 。
我们其实只需要前 个最大的元素,至于所有元素是否有序,甚至这前 个元素是否有序和我们需要的关系不大。

排序

暴力

找 次最大的元素。时间复杂度 。这显然不太行, 很小只有个位数的时候大概行吧。。。

暴力

然后引出两个较为优秀的解决方案:堆和

因为元素是否有序和 无关,我们可以想到堆这种数据结构,堆只满足当前节点的值 孩子的值。所以我们可以维护一个小顶堆,那么堆顶一定是堆中最小的元素,然后逐个遍历所有元素,如果有 堆顶的元素,那么进行替换,然后进行一次向下调整,那么堆中保存的就是到目前为止最大的 个元素。
时间复杂度 ,因为每次向下调整最多进行 次交换。

建堆

维护

结果

堆的一个好处在于它是一种动态的数据结构,假设当前数据又新进了一个元素,那么不需要重新做一次 ,只需要维护当前堆即可。

partition

最后一种方法利用了快速排序中 的思想,期望复杂度为 。
单次 能够将序列分成两部分,这两部分整体是有序的,但是两部分内部并不有序,但是这样也足够了。假设当前这次 选出的值是 ,那么 后我们能够知道有多少个元素 ,假设是 个。

  1. 如果 ,那么说明 右部的元素数量还不够,还得在左边继续选出 个,那么对左侧继续 。
  2. 否则说明 右侧的元素太多了,在 个元素中只需要 个就够了,那么对右侧继续 。
    partition

如果每次 都是随机选择的,那么递归层数不会太多,这个递归的层数,可以考虑成二分查找到 所用的次数,而每次递归所执行的区间长度是不断减少的,所以期望复杂度是 。
可以对比一下快速排序的复杂度计算,同样是递归大概 次,但是每次都要 搞一层,那么总体复杂度就是 了,而这里的算法每层不是 ,而是跟着递归层数减少的。这就是分治法和减治法在时间复杂度上的区别。

代码:

#include 
using namespace std;

/*
template 
void adjust(vector &vec, int pos, int len)
{
    for (int l = pos * 2 + 1; l < len; pos = l, l = l * 2 + 1)
    {
        if (l + 1 < len && vec[l + 1] < vec[pos])
            l++;
        if (vec[pos] <= vec[l])
            break;
        swap(vec[pos], vec[l]);
    }
}

template 
vector topk(vector &vec, int k)
{
    assert(k <= vec.size());
    vector ans(vec.begin(), vec.begin() + k);
    for (int i = k / 2 - 1; i >= 0; i--)
        adjust(ans, i, k);

    for (int i = k; i < vec.size(); i++)
    {
        if (vec[i] > ans[0])
        {
            ans[0] = vec[i];
            adjust(ans, 0, k);
        }
    }
    return ans;
}
*/

mt19937 rng(time(nullptr));

template 
int partition(vector &vec, int begin, int end, int flag = -1)
{
    int pos = begin + rng() % (end - begin + 1);
    if (flag != -1)
        pos = flag;
    swap(vec[pos], vec[end]);
    int i = begin;
    for (int j = begin; j < end; j++)
    {
        if (vec[j] >= vec[end])
        {
            swap(vec[j], vec[i]);
            ++i;
        }
    }
    swap(vec[i], vec[end]);
    return i;
}

template 
int getk(vector &vec, int k, int begin, int end)
{
    if (begin == end)
        return begin;
    int pos = partition(vec, begin, end);
    int cnt = pos - begin + 1;
    if (cnt == k)
        return pos;
    else if (cnt > k)
        return getk(vec, k, begin, pos - 1);
    else
        return getk(vec, k - cnt, pos + 1, end);
}

template 
void topk(vector &vec, int k)
{
    int pos = getk(vec, k, 0, vec.size() - 1);
    partition(vec, 0, vec.size() - 1, pos);
}

void test()
{
    vector vec = {3, 6, 1, 0, 9, 2, 8, 4, 7, 5};

    topk(vec, 5);
    for (const auto &x : vec)
        cout << x << ' ';
    cout << endl;
}

int main()
{
    test();
}

上面被注释掉的是堆的实现,由于没有做封装,两种实现调用的方法参数有些不同。

先写到这里吧,要是有时间再进行补充和完善,从网上偷了不少图,尤其是从架构师之路上的一篇文章,学到了许多,这篇文章算是我对这几个问题的一个总结,网上将这三个问题放在一起讲的文章也比较少,但他们内在的算法原理其实是相关的,希望能帮到一些读者。

你可能感兴趣的:(从快速排序,堆排序到top k问题)