标签 排序算法 下的文章

一、梳排序简介

梳排序是冒泡排序的一种优化方案,主要是为了解决冒泡排序中的尾部小数值问题。它主要的思想是通过比较元素和固定步长位置上的数据,先进行部分优化,然后逐步减少步长,以此来对数据进行预处理。

以数组[3,1 5, 2, 4]为例,假设步长是2,那么就分别处理[3, 5, 4]和[1, 2],先局部优化。优化完成后,再使用冒泡排序。

关于步长的选取

步长并不是逐步递减的,步长的选取一般通过一个步长因子来控制:

  1. 初始化时,步长等于数组长度除以步长因子。
  2. 后续的所有步长都等于前一次步长除以步长因子,直到步长小于等于1。

从实验结果来看,这个步长因子设置为1.3时能获得较好的性能,并且实验数据证明,梳排序的良好性能甚至可以与快速排序媲美。

排序示例

以数组[41, 11, 18, 7, 16, 25, 4, 23, 32, 31, 22, 9, 1, 22, 3, 7, 31, 6, 10]为例,它的长度是19,计算得到的步长分别是[14, 10, 7, 5, 3, 2],它的预处理过程可以整理为:

其中no表示第几趟排序,step表示步长,data是当前经过处理后的数组。很明显可以看出经过6次预处理后,数据已经基本有序了。

此时再通过冒泡排序来对整体排序就能减少很多工作量了(实际上只需要再做一次冒泡排序就能完成排序了)。如果没有第一阶段的处理,冒泡需要19趟排序才能完成,而经过处理后,第8趟排序就已经有序了。此时排序结束,节省了一半以上的时间。

二、代码

C++代码:

const float factor = 1.3;

template<typename T>
void comb_sort(T data[], const unsigned int n) {
    unsigned int i, j, step = n;
    bool flag = true;

    if (n < 2)
        return;
    
    // 步长处理
    while ((step = (unsigned int) (step / factor)) > 1) {
        for (i = n - 1; i >= step; i--) {
            j = i - step;
            if (data[i] < data[j])
                my_swap(data[i], data[j]);
        }
    }

    // 冒泡排序
    for (i = 0; flag && i < n - 1; i++) {
        flag = false;
        for (j = 1; j < n - i; j++) {
            if (data[j] < data[j - 1]) {
                my_swap(data[j], data[j - 1]);
                flag = true;
            }
        }
    }
}

一、归并排序

归并排序的思想是:依次把数组分成若干个小组进行排序,然后逐步把每个小组进行排序合并。

最经典的介绍归并排序的示例图为:

第一步:把8个元素分别分成4个小组排序,得到1, 2/3, 4/5, 6/7, 8四组。

第二步:把1, 23, 4合并,5, 67, 8合并,得到1, 2, 3, 45, 6, 7, 8两组。

第三部:把剩下的两组合并,得到有序的数组1 2 3 4 5 6 7 8,排序完成

归并排序的时间复杂度为O(lgn)

二、合并两个有序数组

合并两个有序数组需要用到一个辅助数组,保存排序后的元素。具体的逻辑是:分别使用两个指针,指向两个数组的首端,比较两个指针指向的元素,把小的放到辅助数组里面,指针后移,重复上面的操作。

例如上面合并1, 2, 3, 45, 6, 7, 8两组元素,设置指针i和j分别指向两个数组,此时a[i] > b[j],把b[j]放到数组里面去:

然后依次执行,直到b数组中的元素放完:

再把a中所有的元素放到有序数组中去,排序就完成了:

合并部分的代码:

// mid是i的结束位置,right是j的结束位置,tmp是临时数组
while (i <= mid && j <= right) {
    if (data[i] <= data[j])
        tmp[k++] = data[i++];
    else
        tmp[k++] = data[j++];
}

三、归并排序实现


/*
 * @data 待排序数组
 * @tmp 临时数组
 * @left 当前需要排序的区间开始
 * @right 当前需要排序的区间结束
 */
static void _merge_sort(int *data, int *tmp, uint left, uint right) {
    uint mid, i, j, k, cur;
    if (left == right)
        return;

    mid = (left + right) / 2;
    i = left;
    j = mid + 1;
    k = left;
    
    // 分组排序
    _merge_sort(data, tmp, left, mid);
    _merge_sort(data, tmp, mid + 1, right);

    // 合并有序分组
    while (i <= mid && j <= right) {
        if (data[i] <= data[j])
            tmp[k++] = data[i++];
        else
            tmp[k++] = data[j++];
    }

    // 处理两个数组多余的元素
    while (i <= mid)
        tmp[k++] = data[i++];
    while (j <= right)
        tmp[k++] = data[j++];

    // 拷贝已经排好序的到正式需要排序的数组
    for (i = left; i <= right; i++)
        data[i] = tmp[i];
}

/*
 * @data 待排序数组
 * @len 数组长度
 */
void merge_sort(int *data, const uint len) {
    // 为临时数组分配空间
    int *tmp = malloc(sizeof(int) * len);
    if (tmp == NULL)
        return;

    _merge_sort(data, tmp, 0, len - 1);

    // 删除临时数组的空间
    free(tmp);
    tmp = NULL;
}

一、计数排序

其基本思想为:假设n个输入的元素中的每一个都是在0到k之间的一个整数,对于每一个输入元素x,确定小于x的元素个数,直接把x放在它输出的数组中的位置上。例如有17个元素小于x,则x就应该在数组的第18个位置上。当有几个元素相同时,这一方案就要略作修改,不能都放在同一个位置上。

计数排序需要提供两个辅助数组用来计数,一个用来保存已经排好序的元素,一个用来保存每个元素在数组中出现的次数。计数排序是不稳定的排序算法,时间复杂度(n),空间复杂度是O(n + k),其中k是排序数组元素的范围。

例如以下数组A:

在通过计数后,得到的辅助数组C为:

对于元素1而言,它前面有两个0,因此排序后它的位置在第三个,即下标为2的位置上,但是它的数量为0,就不应该给他排序。而元素2是有的,它的位置应该是0的数量和1的数量(如果有元素1的话)之和的后面一位,在这里是第3位,下标为2。这里类似斐波那契数列:每个元素都等于前面两个的和。

因此,有必要对辅助数组C做处理,设置其所在的位置为前面所有元素数量之和,这样就不用每次都重新计算前面的数量了(参考斐波那契数列计算):

要注意的是,对于2这个元素来说,它出现了两次,如果不做特殊处理,它们处的位置都是第四个位置。要想办法把两个2分别放到自己合适的位置上去。可以采取的办法是从后往前排序,每排完一个数字,当前数字的值减一。

图例:

第一步,A的最后一个元素是3,得到C[3]的值为7,把3先放到第7个位置,并把C[3]的值减一:

第二步,现在A的最后一个元素(未排序的)是0,得到C[0]为2,放到第二个位置,C[0]减一:

第三步,重复以上步骤,直到A中所有的元素都排序完毕:

此时,整个数组就是有序的了。

二、计数排序的代码实现

// 数据可能出现的最大值
const unsigned int max_val = 100;
void count_sort(int data[], const unsigned int n) {
    // 计数数组
    int cnt[max_val] = {0};
    // 生成辅助数组
    int *tmp = new int[n];
    unsigned int i;

    // 初始化计数数组
    for (i = 0; i < n; i++) {
        cnt[data[i]]++;
    }

    // 累加所有的数量
    for (i = 1; i < max_val; i++) {
        cnt[i] += cnt[i - 1];
    }

    // 计数排序
    for (i = n - 1; i >= 0 && i < n; i--) {
        tmp[cnt[data[i]] - 1] = data[i];
        cnt[data[i]]--;
    }

    // 拷贝辅助数组到排序数组
    for (i = 0; i < n; i++) {
        data[i] = tmp[i];
    }
}

一、堆排序原理

通过最大堆的性质可以知道:一个堆中最大的元素总是在堆顶的,即数组下标0的位置。基于这一点,我们可以每次都把堆中的最大值提取出来,放到当前数组的后面。然后重新构建最大堆,重复这个过程,以此来完成一个数组的排序。

例如,一个已知的最大堆为:

堆排序-1

把最大的元素16提取出来,放到最后。然后重新建堆,此时堆中最大的元素为15,更新后的堆为:

堆排序-2

再把15提取出来,重新建堆,得到:

最大堆-3

如此往复,直到最后堆中的元素只有一个,就完成了整个数组的排序。

二、代码实现

堆排序的关键点在于构造堆,如何构造堆可参考数据结构之堆。基于模板的最大堆化函数实现:

template <typename T>
static void max_heapify(T *data, size_t len, size_t idx) {
    size_t largest, lchild, rchild;

    lchild = LCHILD(idx);
    rchild = RCHILD(idx);

    if (lchild < len && data[lchild] > data[idx])
        largest = lchild;
    else
        largest = idx;

    if (rchild < len && data[rchild] > data[largest])
        largest = rchild;

    if (idx != largest) {
        my_swap(data[idx], data[largest]);
        max_heapify(data, len, largest);
    }
}

实现堆排序,堆排序的关键点在于从后往前排:

template <typename T>
static void heap_sort(T *data, size_t len) {
    size_t i, mid;
    mid = len / 2;

    // 建堆
    for (i = mid - 1; i >= 0 && i < len; i--) {
        max_heapify(data, len, i);
    }

    // 堆排序,从后往前
    for (i = len - 1; i >= 1; i--) {
        my_swap(data[i], data[0]);
        max_heapify(data, --len, 0);
    }
}

时间复杂度

堆排序的时间主要消耗再建堆上面,每次拿掉一个元素之后,都重新执行最大堆化

每次构造最大堆的时间复杂度为O(log(n)),因此堆排序的总时间复杂度为n(log(n)),n代表元素个数。

一、基本排序算法

  1. 插入排序
  2. 选择排序
  3. 冒泡排序
  4. 梳排序

二、高级排序算法

  1. 快速排序
  2. 堆排序
  3. 计数排序
  4. 归并排序

三、各排序算法的比较

各排序算法总结

排序算法平均时间复杂度最好情况时间复杂度最坏情况时间复杂度空间复杂度是否稳定适用场景
插入排序O($n^2$)O(n),当序列已经有序时O($n^2$),序列逆序时。O(1)数组大部分有序
选择排序O($n^2$)O($n^2$)O($n^2$)O(1)数据量较小的情况
冒泡排序O($n^2$)O($n^2$)O($n^2$)O(1)数据量较小的情况
希尔排序O(n^(1.3~2)​)O(n^(1.3~2))O(n^(1.3~2))O(1)增量序列的选取会影响排序时间,希尔排序没有快速排序算法快,中等大小规模表现良好,对规模非常大的数据排序不是最优选择,但是比O($n^2$)复杂度的算法快。
快速排序O(nlogn)O(nlogn)哨兵选择为边界值时,O($n^2$)O(nlogn)不适合元素较小的数组排序
堆排序O(nlogn)O(nlogn)O(nlogn)O(n)需要大量的移动操作,且要额外的空间保存已排序数组
计数排序O(n)O(n)O(n)O(n*2)假设数组元素都在0-k的范围内,并且需要两个辅助数组,如果数据分布不均匀,出现一个特别大的数据会导致额外的空间增加。
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)合并时需要额外的内存空间
基数排序O (nlog(r)m),其中r为所采取的基数,而m为堆数O (nlog(r)m)O (nlog(r)m)O(r*m)需要额外的m个队列的辅助空间

一、原理

原理很简单, 它重复地走访过要排序的元素列,依次比较两个相邻的元素,如果顺序(如从大到小、首字母从从Z到A)错误就把他们交换过来。 冒泡排序是一种稳定的排序算法。

冒泡排序不管在什么情况下,时间复杂度都是O($n^2$)。

对比插入排序来说,在平均的情况下,插入排序性能是冒泡排序的两倍。

图例:

以数组[54, 15, 34, 82, 15, 46, 85, 43]为例:

bubble_sort_1

第一次循环先比较54和15,54大于15,交换两个元素:

bubble_sort_2

比较54和34,54大于34,交换两个元素:

bubble_sort_3

比较54和82,54不大于82,不交换:

bubble_sort_4

比较82和15,82大于15,交换两个元素:

bubble_sort_5

按照相同的方式一直对比,直到最后,85被移动到最后:

bubble_sort_6

此时完成一轮冒泡排序,接下来将剩下的元素再次执行同样的操作即可完成对整个数组的遍历。

二、代码实现

2.1 C++实现

template<typename T>
void bubble_sort(vector<T> &data) {
    int i, j;

    // i的作用是控制遍历次数,n个元素的数组一共要执行n-1轮冒泡
    for (i = 0; i < data.size() - 1; i++) {
        // j从第二个元素开始,每次和前面的元素对比
        for (j = 1; j < data.size() - i; j++) {
            if (data[j] < data[j - 1]) {
                // 如果当前元素小于前一个元素,交换两个元素
                swap(data[j - 1], data[j]);
            }
        }
    }
}

2.2 python实现

def bubble_sort(data):
    size = len(data)
    for i in range(0, size - 1):
        for j in range(size - 1, i, -1):
            if data[j] < data[j - 1]:
                data[j - 1], data[j] = data[j], data[j - 1]

三、冒泡排序的优化

当对第x个数据排序时,冒泡排序每次都会遍历剩下的n-x个元素,即使整个序列已经有序。

因此可以在这一个层面进行优化,如果序列已经有序了,则后面的排序就无需进行了,可以添加一个标记字段来处理这个问题。

template<typename T>
void bubble_sort(vector<T> &data) {
    int i, j;
    bool again = true;
    
    for (i = 0; again && i < data.size() - 1; i++) {
        // 每开始新一轮冒泡,把标记置为false。
        again = false;
        for (j = 1; j < data.size() - i; j++) {
            if (data[j] < data[j - 1]) {
                swap(data[j - 1], data[j]);
                // 只有出现了逆序,才把标记置为true
                again = true;
            }
        }
    }
}

一、原理

选择排序的原理是分治,把排序序列切分成若干个小组后分别排序。每次排序都以随机的一个元素作为哨兵(一般都以排序区间的中间元素或者首元素作为哨兵),比他大的元素都放到右边,比它小的都放到左边。然后分别对该元素左边和右边的元素再法排序,直到所有的元素都是有序状态。

具体的排序过程描述为:

  1. 选取一个哨兵元素temp,设置两个指针low和high分别从数组的两端开始遍历。
  2. high指针从右到左遍历,当遇到A[high] < temp的时候,设置A[low]的值为A[high]。
  3. low指针从左到右遍历,当遇到A[low] > temp的时候,设置A[high]的值为A[low]。
  4. 重复执行2、3步,直到low和high相遇,设置low元素的值为temp。此时low左边的元素都小于temp,low右边的元素都大于temp。再分别对左右子区间的元素分别排序。

图例:

以数组[54, 15, 34, 82, 15, 46, 85, 43]为例,初始状态时设置low指针指向第一个元素,high指针指向最后一个元素,temp为第一个元素的值54:

quick_sort_1

遍历high指针,从右到左找到第一个小于54的元素43,赋值给low:

quick_sort_2

遍历low指针,从左到右找到第一个大于54的元素82,赋值给high:

quick_sort_3

遍历high指针,从右到左找到一个小于54的元素46,赋值给low:

quick_sort_4

遍历low指针,继续寻找第一个大于54的元素。但是直到遇见high指针,都没有找到再比54大的元素,把temp值赋值给low:

quick_sort_5

此时第一轮排序完成,54左边的元素都比它小,右边的元素都比它大。因此按照这个操作,继续对左边和右边的区间再执行相同的操作,多次排序即可完成整个数组的排序。

复杂度分析:

最坏的情况下,数组无序,快速排序和冒泡排序差不多。
  • 时间复杂度:最坏情况下,时间复杂度为O($n^2$);最好的情况和平均情况下算法复杂度为O($n\log(n)$)​。
  • 空间复杂度:最坏情况下,空间复杂度为O($n$);平均情况下空间复杂度为O($\log(n)$)。

二、代码实现

快排的写法多样,以下实现了C++和Python两种语言的快速排序算法实现。

其中C++选取的哨兵是数组第一个元素,python选取的哨兵是数组中间元素。

2.1 C++实现

以区间中点作为排序标杆:

/*
 * 快速排序算法实现,对[left, right)部分的数组排序。
 * @data 待排序数组
 * @left 左区间,包含该下标元素
 * @right 右区间,不包含该下标元素
 */
template<typename T>
void quick_sort(vector<T> &data, int left, int right) {
    int i = left, j = right - 1;
    T temp;

    if (right <= left) {
        return;
    }

    temp = data[i];

    while (i < j) {
        // 从右到左遍历找到一个比temp大的
        while (i < j && data[j] >= temp) {
            j--;
        }
        if (i < j && data[j] < temp) {
            data[i] = data[j];
        }

        // 从左到右遍历找到一个比temp小的
        while (i < j && data[i] <= temp) {
            i++;
        }
        if (i < j && data[i] > temp) {
            data[j] = data[i];
        }
    }

    data[i] = temp;

    // 排序的区间是左闭右开
    quick_sort(data, left, i);
    quick_sort(data, i + 1, right);
}

2.2 python

以区间中点元素作为标杆:

import math


def quick_sort(data, left, right):
    """
    快速排序算法,对[left, right)区间的元素排序
    :param data: 待排序数组
    :param left: 待排序数组的左区间,包含对对该下标元素排序
    :param right: 待排序数组的右区间,不包含对该下标元素排序
    :return:
    """
    i, j = left, right - 1

    if i >= j:
        return

    # 选择区间中点元素作为哨兵
    mid = math.floor((i + j) / 2)
    temp = data[mid]
    data[i], data[mid] = data[mid], data[i]

    while i < j:
        while i < j and data[j] >= temp:
            j -= 1
        if i < j and data[j] < temp:
            data[i] = data[j]

        while i < j and data[i] <= temp:
            i += 1
        if i < j and data[i] > temp:
            data[j] = data[i]

    data[i] = temp

    quick_sort(data, left, i)
    quick_sort(data, i + 1, right)

2.3 golang实现快速排序

2020-02-29刷leetcode添加,使用数组中间元素作为哨兵。排序边界是[left, right]
/*
 * 快速排序,对[left, right]区间内的元素排序
 * @nums 排序数组
 * @left 左边界
 * @right 右边界,包含该下标元素
 */
func doQuickSort(nums []int, left, right int) {
    i, j := left, right
    if left >= right {
        return
    }

    // 取中间元素作为哨兵
    mid := (i + j) / 2
    x := nums[mid]
    nums[mid], nums[i] = nums[i], nums[mid]

    for i < j {
        for nums[j] >= x && i < j {
            j -= 1
        }
        if i < j && nums[j] < x {
            nums[i] = nums[j]
        }
        for nums[i] <= x && i < j {
            i += 1
        }
        if i < j && nums[i] > x {
            nums[j] = nums[i]
        }
    }
    nums[i] = x
    doQuickSort(nums, left, i-1)
    doQuickSort(nums, i+1, right)
}

func quickSort(nums []int) []int {
    doQuickSort(nums, 0, len(nums)-1)
    return nums
}

一、原理

选择排序(Selection sort)是一种简单直观的排序算法,它的工作原理是:从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。以此类推,直到全部待排序的数据元素的个数为零。选择排序是不稳定的排序方法。

选择排序无论在最坏或是最好的情况,时间复杂度都是O($n^2$)

排序逻辑:

for i in (i, n-1)
    min = data[i]
    for j in (j, n)
        如果min>data[j],标记当前位置,重新设置min,继续循环
    把最小的元素min和data[i]替换

图解

以序列[3, 1, 5, 4, 7]为例,执行第一趟排序时,找到的最小元素是1,把它放到最前面和3对换:

第二趟排序,未排序数组为[3, 5, 4, 7],此时数组内最小的元素是3,刚好就是当前数组最开头的位置,因此无需替换,继续下一趟排序。

第三趟排序,未排序数组为[5, 4, 7],此时数组内最小的元素是4,放到最前面和5对换:

执行完第三趟排序后,数组就已经有序了。但是对计算机而言却并不知情,它还会继续判断后面的[5, 7],但是刚好每次排序的时候,他们就处于应该在的位置。

二、代码实现

2.1 C++实现

template <typename T>
void select_sort(vector<T> &v) {
    size_t i, j;
    // 记录最小的下标
    T min_idx;

    if (v.size() < 2)
        return;

    // 只用执行n-1趟排序
    for (i = 0; i < v.size() - 1; i++) {
        min_idx = i;

        // 找到最小的下标
        for (j = i + 1; j < v.size(); j++) {
            if (v[j] < v[min_idx])
                min_idx = j;
        }

        // 替换最小的
        if (min_idx != i) {
            my_swap(v[i], v[min_idx]);
        }
    }
}

2.2 python实现

def select_sort(data):
    size = len(data)
    for i in range(size - 1):
        k = i
        for j in range(i + 1, size):
            if data[j] < data[k]:
                k = j
        if k != i:
            data[k], data[i] = data[i], data[k]

2.3 c语言实现

int select_sort(int* data, unsigned int len) {
    unsigned int i, j, min;

    for (i = 0; i < len - 2; i++) {
        min = i;
        for (j = i + 1; j < len - 2; j++) {
            if (data[j] < data[i])
                min = j;
        }
        if (min != i)
            swap(data[min], data[i]);
    }
}

一、原理

从排序序列的第二个元素开始,依次往前面查询,直到找到一个合适的位置就把它插进去。

每个元素在交换完成之后[0, n]都是一个有序序列,它的时间复杂度为O(n^2)

最好的情况下,序列有序,时间复杂度为O(n)。

最坏的情况下,序列逆序,时间复杂度为O(n^2)。随机的情况下平均时间复杂度接近于最坏的情况。

排序逻辑:

for i in (1, n-1)
    使用data[i]前向查找,找到一个合适的位置插入

- 阅读剩余部分 -