一步一步学习数据结构和算法(二) O(nlogn) 的排序算法

O(nlogn) 排序算法

文中使用的图片来自慕课网课程算法与数据结构

本章介绍的算法都是时间复杂度为 O(nlogn) 级别的算法.

归并排序 (Merge Sort)

归并排序算法思路: 将待排序数组分成两部分, 对每一部分进行排序, 然后再将两部分合并. 其中, 对于每一部分的排序, 又可以继续利用归并排序来完成.

image-20190527170913971

归并排序的分析

我们可以看出, 在有三个元素的时候, 我们分成了三个层级后, 每一个子序列中就只有一个元素了 (8 个元素, 每次二分, log_2(8) = 3 次后就完成划分).

归并的过程

image-20190527171729722

归并的过程需要额外开辟一个同等大小的空间, 使用三个指针来完成归并.

下方表示待归并的数组, 上方用于存储最终归并的结果. 蓝色指针指向下一个待归位的位置, 两个橙色指针分别指向两个待归并数组中下一个带归位的元素, 每一次比较两个橙色指针所指向的元素大小, 选择较小的那个元素填入蓝色指针位置, 并将蓝色指针后移一位, 同时将该橙色指针也后移一位, 知道完成归并.

这一步归并的时间复杂度为 O(n) 级别, 需要完成归并的次数为 O(logn), 算法总体的时间复杂度为 O(nlogn).

归并排序的递归实现

// arr 的 [l, mid] 和 [mid + 1, r] 合并
template<typename T>
void __merge(T arr[], int l, int mid, int r) {
    T aux[r - l + 1];
    for (int i = l; i <= r; ++i) {
        aux[i - l] = arr[i];
    }
    int i = l;
    int j = mid + 1;
    for (int k = l; k <= r; k++) {
        if (i > mid) {
            arr[k] = aux[j - l];
            j++;
        } else if (j > r) {
            arr[k] = aux[i - l];
            i++;
        } else if (aux[i - l] < aux[j - l]) {
            arr[k] = aux[i - l];
            i++;
        } else {
            arr[k] = aux[j - l];
            j++;
        }
    }
}

// 递归使用归并排序, 对 arr[l, ..., r] 的范围进行排序
template<typename T>
void __mergeSort(T arr[], int l, int r) {
    if (l >= r) {
        return;
    } else {
        int mid = (l + r) / 2;
        __mergeSort(arr, l, mid);
        __mergeSort(arr, mid + 1, r);
        __merge(arr, l, mid, r);
    }
}

template<typename T>
void mergeSort(T arr[], int n) {
    __mergeSort(arr, 0, n - 1);
}

改进1

增加了判断, 只在 arr[mid] > arr[mid + 1] 的情况下才进行 merge.

// 递归使用归并排序, 对 arr[l, ..., r] 的范围进行排序
template<typename T>
void __mergeSort(T arr[], int l, int r) {
    if (l >= r) {
        return;
    } else {
        int mid = (l + r) / 2;
        __mergeSort(arr, l, mid);
        __mergeSort(arr, mid + 1, r);
        if (arr[mid] > arr[mid + 1]) {
            __merge(arr, l, mid, r);
        }
    }
}

改进2

并不需要递归到底, 在只有 16 个元素的时候, 使用插入排序进行排序.

// 递归使用归并排序, 对 arr[l, ..., r] 的范围进行排序
template<typename T>
void __mergeSort(T arr[], int l, int r) {
//    if (l >= r) {
//        return;
//    }
    if (r - l <= 15) {
        insertionSort(arr, l, r);
        return;
    } else {
        int mid = (l + r) / 2;
        __mergeSort(arr, l, mid);
        __mergeSort(arr, mid + 1, r);
        if (arr[mid] > arr[mid + 1]) {
            __merge(arr, l, mid, r);
        }
    }
}

归并排序的自底向上实现

template<typename T>
void mergeSortBU(T arr[], int n) {
    // size = 1, 2, 4, 8, .... n
    for (int size = 1; size <= n; size += size) {

        for (int i = 0; i < n - size; i += size + size) {
            // merge [i, i + size]
            __merge(arr, i, i + size - 1, min(i + size + size - 1, n - 1));
        }
    }
}

自底向上的归并实现也非常简单, 设定 size 不断增大, 每一次迭代, 将两个 size 大小的元素进行归并, 直到 size 大于等于 n/2 小于等于 n.

快速排序

快速排序的基本思路

image-20190604191540979

快速排序的基本思路如上图所示, 选定一个元素, 使得该元素处在排序后的正确位置, 即, 在上图中, 4 左边的元素都是小于 4 的, 4 右边的元素都是大于 4 的.

要完成这个过程, 快速排序中的一个重要的步骤是 Partition 的过程.

image-20190604191846878

我们选定第一个元素 v, 在当前考察过程中, 有三个下标点需要注意, l 表示我们所选定元素位置, j 左边的元素小于 v, j 右边的元素大于 v, i 表示我们要考察的下一个元素. 即, arr[l+1, j] < v, arr[j+1, i-1] > v.

当我们考察下一个元素 e 时, 比较 ev 的大小, 我们需要考虑两种情况, 当 e 大于 v 时, 直接将 i++ 即可; 当 e 小于 v 时, 交换 arr[i]arr[j], 然后 i++; j++.

当遍历完成后, 交换 arr[l]arr[j], 即完成了 partition 的过程.

快速排序的递归实现

可以使用递归操作完成快速排序.

递归的终止条件为 l >= r, 即排序的子集中只有一个元素.

每一次递归, 完成 partition 操作, 并以 partition 完成后的中间值点作为分界点, 对左半边和右半边分别进行快速排序, 直到排序完成.

// 返回 p, 为完成快速排序 partition 操作后, 中间元素的位置.
template<typename T>
int __partition(T arr[], int l, int r) {
    T v = arr[l];
    int j = l;
    for (int i = l + 1; i <= r; i++) {
        if (arr[i] < v) {
            swap(arr[++j], arr[i]);
        }
    }
    swap(arr[l], arr[j]);
    return j;
}

template<typename T>
void __quickSort(T arr[], int l, int r) {
    if (l >= r) {
        return;
    }

    int p = __partition(arr, l, r);
    __quickSort(arr, l, p - 1);
    __quickSort(arr, p + 1, r);
}

template<typename T>
void quickSort(T arr[], int n) {
    __quickSort(arr, 0, n - 1);
}

优化1

我们不需要递归到底, 在子序列较小时, 可以使用插入排序来进行优化.

template<typename T>
void __quickSort(T arr[], int l, int r) {
//    if (l >= r) {
//        return;
//    }
    if (r - l <= 15) {
        insertionSort(arr, l, r);
        return;
    }

    int p = __partition(arr, l, r);
    __quickSort(arr, l, p - 1);
    __quickSort(arr, p + 1, r);
}

优化2

上面的快速排序算法存在问题, 对于完全有序的数组, 其退化成为 O(n^2) 的算法. 原因是我们每一次选定第一个元素作为参考值完成 partition 操作, 相当于是将原数组分成两部分, 对于近乎有序的数组, 这两部分是极不平衡的. 那么最终我们的算法生成的树的深度是非常大的, 当数组完全有序时, 树的深度是 n, 因此会退化为 O(n^2) 的算法.

解决这个问题也非常简单, 我们只需要随机参考值即可, 这样, 退化为 O(n^2) 的算法的概率就非常小.

// 返回 p, 为完成快速排序 partition 操作后, 中间元素的位置.
template<typename T>
int __partition(T arr[], int l, int r) {
    // 随机选择元素
    swap(arr[l], arr[rand() % (r - l + 1) + l]);
    T v = arr[l];
    int j = l;
    for (int i = l + 1; i <= r; i++) {
        if (arr[i] < v) {
            swap(arr[++j], arr[i]);
        }
    }
    swap(arr[l], arr[j]);
    return j;
}

优化3: 双路快速排序

当数组中存在大量相同元素时, 我们上面 partition 的逻辑会将大量元素放在同一边中, 使得两边极度不平衡, 降低算法性能.

可以采用如下方法来解决该问题.

image-20190604204852345

将两部分放在数组的两边, 分别从两边进行生长, 当左边元素大于等于 v 时, 右边元素小于等于 v 时, 交换 arr[i]arr[j]. 然后继续增长.

template<typename T>
int __partition2(T arr[], int l, int r) {
    swap(arr[l], arr[rand() % (r - l + 1) + l]);
    T v = arr[l];
    // arr[l+1...i) <= v; arr(j...r] >= v
    int i = l + 1, j = r;
    while (true) {
        while (i <= r && arr[i] < v) i++;
        while (j >= l + 1 && arr[j] > v) j--;
        if (i > j) break;
        swap(arr[i++], arr[j--]);
    }

    swap(arr[l], arr[j]);
    return j;
}

优化4: 三路快速排序

image-20190605110914806

三路快速排序将数组分成三部分, 小于 v, 等于 v 和大于 v.

  • e == v: 只需要 i++ 即可.
  • e < v: 将 arr[i]arr[lt+1] 交换, 然后 lt++, i++.
  • e > v: 将 arr[i]arr[gt-1] 交换, 然后 gt--.
image-20190605112121939

停止条件是 i == gt, 结束时, 交换 arr[lt]arr[l] 的元素即可.

image-20190605112407313

归并排序和快速排序的衍生问题

  • 归并排序和快速排序都使用了分治算法的思想.

分治算法:

将原问题分解成结构相同的子问题, 进而得到解决.

  • 归并排序关键点在于合的问题. 快速排序关键点在于分的过程.

逆序对

image-20190606093434464

上面的数组中, 2, 3 为顺序对, 2, 1, 为逆序对.

逆序对的数量可以衡量数组的有序程度.

可以使用归并排序的思路来完成逆序对的计算:

image-20190606105050984

对于每一个左右两边有序的数组, 我们只需要比较左右两边对应的元素的大小, 因为是有序的, 如果左边元素比右边对应元素大, 那么左边该元素之后的所有元素都与右边的这个元素构成逆序对.

//
// Created by dcr on 2019-06-06.
//
#include <algorithm>
#include "TestHelper.h"

// arr[l, mid], arr[mid + 1, r]
template<typename T>
long long __count(T arr[], int l, int mid, int r) {

    T aux[r - l + 1];

    for (int i = l; i <= r; i++) {
        aux[i - l] = arr[i];
    }

    int i = l;
    int j = mid + 1;
    long count = 0;
    for (int k = l; k < r; k++) {
        if (i > mid) {
            arr[k] = aux[j++ - l];
            continue;
        } else if (j > r) {
            arr[k] = aux[i++ - l];
        } else if (aux[i - l] > aux[j - l]) {
            arr[k] = aux[j++ - l];
            count += (long long) (mid - i + 1);
        } else {
            arr[k] = aux[i++ - l];
        }
    }
    return count;
}

template<typename T>
int inversionCount(T *arr, int n) {
    int count = 0;
    // size = 1, 2, 4, 8, ...
    for (int size = 1; size <= n; size += size) {
        for (int i = 0; i < n - size; i += size + size) {
            count += __count(arr, i, i + size - 1, std::min(i + size + size - 1, n - 1));
        }
    }
    return count;
}

int main() {
    int n = 100;

    // 测试1: 测试随机数组
    cout << "Test Inversion Count for Random Array, n = " << n << " :" << endl;
    int *arr = TestHelper::generateRandomArray(n, 0, n);
    cout << inversionCount(arr, n) << endl << endl;

    delete[] arr;
    arr = nullptr;

    // 测试2: 测试完全有序的数组
    // 结果应该为0
    cout << "Test Inversion Count for Ordered Array, n = " << n << " :" << endl;
    arr = TestHelper::generateOrderedArray(n);
    cout << inversionCount(arr, n) << endl << endl;
    delete[] arr;

    // 测试3: 测试完全逆序的数组
    // 结果应改为 N*(N-1)/2
    cout << "Test Inversion Count for Inversed Array, n = " << n << " :" << endl;
    arr = TestHelper::generateInversedArray(n);
    cout << inversionCount(arr, n) << endl;
    delete[] arr;
}

取出数组中第 i 大的元素

该问题的退化问题是取出数组中的最大 (最小) 元素, 我们只需要遍历一遍数组即可, 时间复杂度为 O(n).

对于取出数组中第 n 大元素的要求:

  • 排序: 复杂度为 O(nlogn)
  • 使用快排的思想, 每次归位一个元素, 看一下这个元素的位置, 比较我们要找的元素是在该元素右边还是左边, 进而对子数组重新进行 partition 操作, 直到找到我们所需的元素.
//
// Created by dcr on 2019-06-06.
//


#include <cstdlib>
#include <ctime>
#include <algorithm>
#include <iostream>
#include "TestHelper.h"
#include "SortTestHelper.h"
#include "Sort.h"

using namespace std;

int __topK(int arr[], int l, int r, int k_pos) {


    // partition
    std::swap(arr[l], arr[rand() % (r - l + 1) + l]);
    int v = arr[l];

    int lt = l;
    int gt = r + 1;
    int i = l + 1;

    while (i < gt) {

        if (arr[i] == v) {
            i++;
        } else if (arr[i] < v) {
            std::swap(arr[i++], arr[++lt]);
        } else if (arr[i] > v) {
            std::swap(arr[i], arr[--gt]);
        }
    }
    std::swap(arr[l], arr[lt]);
    if (k_pos >= lt && k_pos <= i - 1) {
        return arr[i - 1];
    } else if (i <= k_pos) {
        return __topK(arr, gt, r, k_pos);
    } else {
        return __topK(arr, l, lt - 1, k_pos);
    }
}

int topK(int arr[], int n, int k) {
    srand(time(nullptr));
    int k_pos = n - k;
    int m = __topK(arr, 0, n - 1, k_pos);
    return m;
}

int main() {
    int n = 100;
    int k = 3;

    // 测试1: 测试随机数组
    cout << "Top " << k << " for Random Array, n = " << n << " :" << endl;
    int *arr = TestHelper::generateRandomArray(n, 0, n);
    cout << topK(arr, n, 3) << endl << endl;

    quickSort3Ways(arr, n);

    cout << "True value is: " << arr[n - k] << endl;
    SortTestHelper::printArray(arr, n);


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