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

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

快速排序

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

partition函数

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

partition

template <typename T>
int partition(vector<T> &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结束后分割的位置
}

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

快速排序实现

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

代码:

#include <bits/stdc++.h>
using namespace std;

mt19937 rng(time(0));

template <typename T>
int partition(vector<T> &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 <typename T>
void qsort(vector<T> &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<int> vec1 = {3, 6, 1, 0, 9, 2, 8, 4, 7, 5};
    vector<string> 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();
}

堆排序

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

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

堆的数组表示

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

堆的调整

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

向下调整

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

代码:

template <typename T>
void adjust(vector<T> &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 <bits/stdc++.h>
using namespace std;

template <typename T>
void adjust(vector<T> &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 <typename T>
void heapsort(vector<T> &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<int> vec1 = {3, 6, 1, 0, 9, 2, 8, 4, 7, 5};
    vector<string> 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 问题

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

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

排序

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

排序

暴力

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

暴力

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

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

建堆

维护

结果

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

partition

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

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

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

代码:

#include <bits/stdc++.h>
using namespace std;

/*
template <typename T>
void adjust(vector<T> &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 <typename T>
vector<T> topk(vector<T> &vec, int k)
{
    assert(k <= vec.size());
    vector<int> 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 <typename T>
int partition(vector<T> &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 <typename T>
int getk(vector<T> &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 <typename T>
void topk(vector<T> &vec, int k)
{
    int pos = getk(vec, k, 0, vec.size() - 1);
    partition(vec, 0, vec.size() - 1, pos);
}

void test()
{
    vector<int> 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();
}

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270