array和list排序算法对比(二):归并排序

接着上一篇文章,这里简单讨论数组和链表的归并排序在算法设计上的区别。
归并排序的特点是采用二分的策略,将数组的子数组进行排序,然后将两个有序的子数组合并成一个大的有序数组。如果采用数组结构,二分是非常简单的操作,但二分后的合并空间开销相对较大。如果采用链表结构,合并的空间开销是相对较小的,但二分则需要精心设计。这也造成了两种数据结构在算法设计上会有一定的差别,复杂度也不同。

再次说明:
1. 排序算法的约定:
sort(begin, end)表示对[begin, end)之间的元素排序,包含begin,但不包含end
2. 在链表中,头部head和尾部tail是不存储数据的。所以,链表的begin对应head->next,end对应tail。

1 数组的归并排序

数组的归并排序算法如下:

void merge_sort(int *begin, int *end){
    if(end - begin <= 1)
        return;

    int *mid = begin + (end - begin) / 2;
    //split
    merge_sort(begin, mid);
    merge_sort(mid, end);
    //merge
    merge(begin, mid, end); 
}

//merge函数
void merge(int *begin, int *mid, int *end){
    int len = end - begin;
    int *tmp = new int[len];
    int *cur = tmp;
    int *left = begin;
    int *right = mid;

    //将较小的数放前面,如果相等,则左边的数放前面
    while(left < mid && right < end){
        if(*left <= *right){
            *cur++ = *left++;
        }else{
            *cur++ = *right++;
        }
    }
    //剩余的数
    while(left < mid){
        *cur++ = *left++;
    }
    while(right *cur++ = *right++;
    }
    //复制到原数组
    for(int i = 0; i < len; i++){
        begin[i] = tmp[i];
    }
    //删除临时数组
    delete[] tmp;
}

可见,归并排序的第一步就是int *mid = begin + (end - begin) / 2,这一步二分和后面复杂度为 O(n) 的合并操作,是使归并排序复杂度在 O(nlogn) 的两个关键操作。然而,合并操作的空间复杂度同样为 O(n) ,因此数组归并排序的空间复杂度同样为 O(nlogn) 。这是个不小的空间开销(相比之下,快排是 O(logn) ,堆排序是 O(1) ),有可能会限制归并排序的应用。
接下来,我们可以对照数组的归并排序,给出链表的归并排序。由于链表的内存组织结构与数组不同,链表的二分和归并两步操作都需要采用完全不同的形式来完成。

2 链表的归并排序

在链表的归并排序中,最让人头疼的是链表作为非随机访问的数据结构,很难对齐进行二分操作。如果直接寻找链表的中点,虽然复杂度在渐近意义上不变,但开销仍然让人不能满意。
其实,对链表进行归并排序,并不需要首先找出链表的中点,只需要预先给出链表的长度即可。可以想象,如果我们对链表的前半部分进行排序,排序完成后,自然就获得了链表的中点。而为了对链表的前半部分排序,我们可以先对链表的前1/4部分进行排序……以此类推,我们在排序的过程中,不断后移待排序数组的指针,就可以免去直接查找中点的问题。
在这里,我们给出链表排序函数的接口:

Node* merge_sort(Node *begin, int size);//给出第一个元素和链表的size,而不是给出链表尾部
void merge(Node *begin, Node *middle, Node *end);//[begin, middle)和[middle, end)合并

这里merge_sort的第二个参数是链表的长度,而非链表尾部end,这是为了更好地进行递归操作。有几点需要注意:
1. 在设计链表的时候,通常会直接维护链表的size,因此这个参数的获取可以认为是 O(1) 的复杂度;
2. 在递归过程中,size长度可能小于链表的长度,表示的是对从begin元素开始的size个元素进行归并排序。
3. merge_sort的返回值是链表的尾部,即end,这就是前面提到的“在排序结束时给出链表的end节点”。

给出了这些接口,就可以正式给出链表排序的算法了:

Node* merge_sort(Node *begin, int size){
    if(size == 0){
        return begin;
    }else if(size == 1){
        return begin->next;
    }else{
        Node *begin_prev = begin->prev;
        int left_size = size / 2;
        int right_size = size - left_size;
        Node *middle = merge_sort(begin, left_size);
        Node *middle_prev = middle->prev;
        Node *end = merge_sort(middle, right_size);
        //注意:在两次merge_sort之后,begin和middle指向的节点不再是起点和中点(见merge函数),所以要先保存它们前面的节点,用于找回merge_sort之后的起点和中点。
        merge(begin_prev->next, middle_prev->next, end);
        return end;
    }
}

void merge(Node *begin, Node *middle, Node *end){
    Node *cur1 = begin;
    Node *cur2 = middle;
    //循环结束条件:cur1 == cur2 || cur2 == end
    //cur1 == cur2表示左边列表的指针追上右边,即左边链表遍历结束;
    //cur2 == end表示右边指针到达end,即右边列表遍历结束
    while(cur1 != cur2 && cur2 != end){
        if(*cur1 <= *cur2){
            cur1 = cur1->next; //直接后移cur1指针
        }else{
            //备份cur2指针,然后向后移动cur2指针
            Node *tmp = cur2;
            cur2 = cur2->next;
            //在原链表中移除tmp
            tmp->prev->next = tmp->next;
            tmp->next->prev = tmp->prev;
            //将tmp移动到cur1的前面
            cur1->prev->next = tmp;
            tmp->prev = cur1->prev;
            tmp->next = cur1;
            cur1->prev = tmp;
        }
    }
}

以上即为链表归并排序的算法,可以看出,归并排序的二分过程中,并未实际寻找链表的中点,而是在前半部分排序结束后给出链表的中点。这个思路可以避免不必要的开销。
同时,可以发现,由于merge的时间复杂度为 O(n) ,而空间复杂度只有 O(1) ,因此链表归并排序的时间复杂度为 O(nlogn) ,空间复杂度为O( logn) ,比数组排序要小。因此,对于链表更加适合用归并排序。

你可能感兴趣的:(C++,算法)