谈谈归并排序算法

2018-08-06

谈谈归并排序算法

归并排序算法是一个很重要的算法。在排序算法中,算是非常重要的一个算法。我们常说的数组归并排序,是指二路归并排序算法,时间复杂度是O(nlog(n)),空间负责度是O(n).核心的思想就是对两个有序数组合并成一个有序数组。对于一个未排序的数组怎么产生两个已排序的数组呢?其实就是对数组不断的分割,分割成多个只有一个元素的数组。只有一个元素的数组,当然这个数组就是有序的了。然后再将这些数组进行两两合并,合并后的数组还是有序数组,这样再次进行两两合并,最终可以合并成一个有序数组,这样排序就完成了。

归并排序的算法看起来不是很复杂,但是实现起来我觉得不是那么简单。首先它需要额外的空间,这个额外的空间怎么分配跟算法实现方式有关。然后算法的实现方式有递归方式和迭代方式两种。网上流行的都是递归方式。

递归方式

递归方式采用典型算法:分治法来进行。

  • 1.将数组分为两半,前一半和后一半。
  • 2.将这两半进行有序数组合并
  • 3.继续进行第1步,直到数组不能在分为止

这里明显采用了递归定义,递归定义其实不是那么好理解,也不是那么难理解。简单来说就是不断的将一个数组进行对半分,一直分到不能分为止,这样最终就会分得很多个元素个数为1的数组,然后再将这些数组进行两两合并。注意子数组可能是奇数个,但是这也没关系,没得合并的就暂时放在原位,下一次合并的时候总会用到,最终一个会合并成一个数组。

伪代码:

MergeSort(arr[], l,  r)
If r > l
     1. Find the middle point to divide the array into two halves:  
             middle m = (l+r)/2
     2. Call mergeSort for first half:   
             Call mergeSort(arr, l, m)
     3. Call mergeSort for second half:
             Call mergeSort(arr, m+1, r)
     4. Merge the two halves sorted in step 2 and 3:
             Call merge(arr, l, m, r)

伪代码来源:geeksforgeeks

这里特意提一下geeksforgeeks这个网址,这里专门讲算法的网站,很多算法有解析和实现,而且基本都有c和Java的实现,甚至Python。非常有用的网站。

我的实现代码:

#include <stdlib.h>

void _sorted_merge(int a[], int start, int mid, int end) {
    int len_l = mid - start + 1;
    int len_r = end - mid;
    int *arr_l = (int *)malloc(sizeof(int)*len_l);
    int *arr_r = (int *)malloc(sizeof(int)*len_r);

    for (int i=0; i<len_l; i++) {
        arr_l[i] = a[start+i];
    }
    for (int i=0; i<len_r; i++) {
        arr_r[i] = a[mid+i+1];
    }

    int i = 0;
    int j = 0;
    int k = start;
    while (i < len_l && j < len_r) {
        if (arr_l[i] <= arr_r[j]) {
            a[k++] = arr_l[i++];
        } else {
            a[k++] = arr_r[j++];
        }
    }
    while (i < len_l) a[k++] = arr_l[i++];
    while (j < len_r) a[k++] = arr_r[j++];

    free(arr_l);
    free(arr_r);
}

void _recursive_merge_sort(int a[], int start, int end) {
    if (start < end) {
        int mid = start + (end - start) / 2;
        _recursive_merge_sort(a, start, mid);
        _recursive_merge_sort(a, mid+1, end);
        _sorted_merge(a, start, mid, end);
    }
}

void recursive_merge_sort(int a[], int n) {
    if (a == 0 || n <= 1) return;
    _recursive_merge_sort(a, 0, n-1);
}

这里整个完整的实现是用了3个方法。暴露给外界使用的方法是 void recursive_merge_sort(int a[], int n)这个。递归分割数组是void _recursive_merge_sort(int a[], int start, int end)这个方法。注意方法参数是数组指针,要分割的开始坐标,要结束分割的坐标。另外核心的合并方法有序数组的方法是void _sorted_merge(int a[], int start, int mid, int end)。注意这里并不是传了两个数组进去,而是一个数组,给定了起始,中间分割,结束的坐标。中间分割坐标是归并在前一部分的。这些都是细节,没有搞清楚,程序就会出错。

_sorted_merge这个方法内,新建了两个数组,将两半的元素拷贝到这两个数组中,然后合并到原始数组中。归并排序需要的额外空间是在这里花费的。这个算法实现完全是采用的是自顶向下的思维方式。一层一层的从顶往下分割,分割到最后在合并。

还有一种方式就是自底向上的思维方式。我们知道数组是可以随机访问的,因为坐标可以随时利用。如果一开始我们就只针对一个个的元素,那么这些元素就已经是独立的,换句话说就是已经分别好的。我们就针对每一个个体,直接跟它傍边的元素进行两两合并就好了。

迭代方式

迭代的方式就是把上面的void _recursive_merge_sort(int a[], int start, int end)这个方法换成循环迭代来实现。

void iteractive_merge_sort(int a[], int n) {
    if (a == 0 || n <= 1) return;

    int step = 1;
    while (step < n) {
        int start = 0, mid = 0, end = 0;
        while (start < n-1) {
            mid = start + step - 1;
            end = mid + step;
            if (end > n-1) end = n-1;
            _sorted_merge(a, start, mid, end);
            start += 2*step;
        }

        step = 2*step;
    }
}

迭代方式定义了一个step,这个是需要合并的子数组的长度,初始化为1,这样就不能分割数组,直接合并就好。步长每次翻倍,最终步长超出数组长度就退出。这里也有很多细节要注意,定了步长之后,要对整个数组按照这个步长分成多个子数组来合并,这里要注意最后一个子数组的元素个数可能个数不够,不能超过数组总长。

另一种迭代方式

我们这里的有序数组合并都是在在_sorted_merge这个方法内实现的,空间分配也在这。这个方法会被频繁调用。空间会被进行很多次分配和释放。在我大学的数据结构的课本里,归并排序采用的并不是上面的方法,而是采用了另外一种迭代方法来实现。

先上代码:

void _merge_array(int a[], int b[], int n, int step) {
    int k = 0;
    int start1 = 0;
    int end1, start2, end2;

    while (k < n) {
        end1 = start1 + step;
        start2 = end1;
        end2 = start2 + step;
        if (end2 > n) end2 = n;
        while (start1 < end1 && start2 < end2) {
            if (a[start1] <= a[start2]) {
                b[k++] = a[start1++];
            } else {
                b[k++] = a[start2++];
            }
        }
        while (start1 < end1) b[k++] = a[start1++];
        while (start2 < end2) b[k++] = a[start2++];

        start1 = end2;
    }
}

void iteractive_merge_sort2(int a[], int n) {
    if (a == 0 || n <= 1) return;

    int *b = (int *)malloc(sizeof(int)*n);
    int flag = 0;
    for (int i = 1; i<n; i=i*2) {
        if (flag == 0) {
            _merge_array(a, b, n, i);
            flag = 1;
        } else {
            _merge_array(b, a, n, i);
            flag = 0;
        }
    }

    if (flag == 1) {
        for (int i=0; i<n; i++) a[i] = b[i];
    }

    free(b);
}

void iteractive_merge_sort2(int a[], int n)这个方法中,直接申请了一个跟原始数组一样大小的空间,然后后面的数组的合并算法就在这两个数组之间进行。当然最后如果最终结果被合并到了b数组中的话,就把它拷贝到原始的a数组中。而核心的数组合并在void _merge_array(int a[], int b[], int n, int step)这个方法中。它接收两个数组,第一位原始数组,第二个为合并后的数组。他们的数组大小一样,还有一个关键参数是步长。这个步长就是分割好的已有序数组的元素个数。里面的实现细节还是数组的下标,不能超过总数组长度。 这个方法的特别之处就是它是一次性分配内存的。这个说是好还是不好呢,我也不太敢确定。但是感觉他的效率要高些,因为不用频繁分配和释放内存。

链表的排序

大家都很清楚数组的排序有很多种,方法各异,但是不知道你想过没有,链表是怎么排序的。链表因为只能顺序访问,不能想数组那样随机访问,所以基本上数组的排序算法很难直接用到链表上。但是这里有个算法天然的可以用在链表上,就是归并排序算法

归并算法的核心就是分割组和合并有序组。这个是可以用在链表上的。对两个有序链表的合并可以说比有序数组的合并还简单。而对链表的分割也有好办法。

链表的分割用到了链表的常用算法:快慢指针法 先看链表定义:

typedef struct _Node{
    int data;
    struct _Node *next;
}Node, *PNode;

再看链表分割法,我们分割肯定也是按照中间分割,那么关键点就是找到中间链表节点

#include <stdlib.h>
#include <stdio.h>
#include "CNode.h"

PNode _getMiddleNode(PNode head) {
    if (head ==  NULL || head->next == NULL) return head;
    Node *fast = head->next, *slow = head;
    while (fast != NULL) {
        fast = fast->next;
        if (fast != NULL) {
            fast = fast->next;
            slow = slow->next;
        }
    }
    return slow;
}

这个方法里的快指针的速度是慢指针的两倍,所有快指针到达终点的时候,慢指针刚好走了一半。这里需要注意的数快指针的初始位置是比慢指针多一步的。然后按照中间分割点的话,慢指针是算在前一半里面的。

再看一下我们是如何用这个方法将链表进行分割的。

void sortSingleList(Node  **head) {
    if (head == NULL || (*head) == NULL || (*head)->next == NULL) return;
    Node *midNode = _getMiddleNode(*head);
    Node *restNode = midNode->next;
    midNode->next = NULL;
    sortSingleList(head);
    sortSingleList(&restNode);
    *head = _mergeSortedList(*head, restNode);
}

这里可以看到,分割方法和数组的递归分割法是一样的,都是递归调用,只不过这里多了一步计算链表中间节点的步骤。

再来看核心算法归并算法:

PNode _mergeSortedList(PNode head1, PNode head2) {
    if (head1 == NULL) return head2;
    if (head2 == NULL) return head1;
    Node *newHead = NULL;
    Node *p1 = head1;
    Node *p2 = head2;
    if (head1->data > head2->data) {
        newHead = head2;
        p2 = head2->next;
    } else {
        newHead = head1;
        p1 = head1->next;
    }
    Node *cur = newHead;
    while (p1 != NULL && p2 != NULL) {
        if (p1->data < p2->data) {
            cur->next = p1;
            p1 = p1->next;
        } else {
            cur->next = p2;
            p2 = p2->next;
        }
        cur = cur->next;
    }
    if (p1 != NULL) cur->next = p1;
    if (p2 != NULL) cur->next = p2;

    return newHead;
}

这里我采用的是非递归算法,要点是先把头节点确定,接下来的跟数组的合并算法差不多,注意最后元素合并跟数组不一样,只连接next节点,不需要while循环。 当然还有递归方式来实现_mergeSortedList方法,链表天生适合递归。这里就不说了,有兴趣的可以到我的github上看,那里有各种实现。

归并排序的其他应用

这里我只讲一种场景。就是外部排序。如果我们的要排序的数据量很大,而内存有比较小,我们该如何排序呢?这个也是经典面试题目。这里肯定是要用到归并排序,而且是多路归并排序,因为你需要将数据分为N份来拿到内存中排序,然后对着N份进行归并排序。这里面整体算法就复杂了,也不是这里可以说的明白的。有兴趣可以到网上查查资料。

好了,就到这。这篇文章历时两个月终于写完了。。。。有兴趣可以看 我写的各种算法github工程


如果你觉得这篇文章有用,请打赏小钱喝杯咖啡^_^ 打赏

Category: 算法 Tagged: 算法 归并排序

Comments