接着上一篇文章,这里简单讨论数组和链表的归并排序在算法设计上的区别。
归并排序的特点是采用二分的策略,将数组的子数组进行排序,然后将两个有序的子数组合并成一个大的有序数组。如果采用数组结构,二分是非常简单的操作,但二分后的合并空间开销相对较大。如果采用链表结构,合并的空间开销是相对较小的,但二分则需要精心设计。这也造成了两种数据结构在算法设计上会有一定的差别,复杂度也不同。
再次说明:
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 <end){
*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)
,比数组排序要小。因此,对于链表更加适合用归并排序。