算法第五记-归并排序

      今天我要讲的排序算法是归并排序,首先我想提出一个问题(很多算法题的思路都源于此),给定两个已排序的序列,如何将其两个序列合并为一个大序列并且依然保持有序。思路很简单每个小序列维持一个指针指向左边界,然后两个序列的左边界进行比较大小,小的那方加入新的序列中,同时小的那方左边界右移一个位置。以此类推,如果有一方序列已经到达尾部,则直接将另一方的序列直接放入新序列的尾部。

序列1:1,3,5,7,9

序列2:2,4,6,8,10 

开始时两个左边界分别指向1和2,然后比较大小,小的一方是1.将其装入新序列中,然后序列1的左边界指向到了3.然后继续3,2进行比较,小的一方是2,所以2进入新序列,序列2的左边界右移1个。直到一方序列到达了尾部。再直接把另一方的左边界至尾部的所有元素直接放入新序列中。因为我们每次放入新序列时都是两个小序列的左边界进行过比较的,同时每个小序列本身又是有序的,所以有一方提前达到了自己的序列尾,那么另一方剩余的元素肯定都是大于新序列中的元素的。

下面先给出归并操作的代码:

void merge(int arr[], int low, int mid, int high)
{
	int i = low, j = mid + 1;
	int* new_arr = new int[high - low + 1];
	int k = 0;
	while (i <= mid&&j <= high)
	{
		if (arr[i] <= arr[j])
			new_arr[k++] = arr[i++];
		else
			new_arr[k++] = arr[j++];
	}
	while (i <= mid)
		new_arr[k++] = arr[i++];
	while (j <= high)
		new_arr[k++] = arr[j++];
	for (int i = low, j = 0; i <= high;)
		arr[i++] = new_arr[j++];
	delete[]new_arr;
}

基本思路我刚已经讲过:我们可以看看这个算法的效率,首先由我们刚才所讲,我们需要一段新的的大序列存放我们归并之后的序列,这也就是为什么我们动态申请了一段内存,整体运行的时间复杂度是O(n)(假设我们两段序列分别是低〜中,中+ 1~高),因为第一个而循环的结束条件有两个,如果其中一个序列到了尾部,需要继续遍历另一个序列并将其元素复制到大序列中,这也是后面两个同时的作用(任意时刻只会进入其中一个而,原因很明显)。最后还有一个用于循环的作用,一般我们操作肯定想对原数组操作的对吧,所以将操作完毕的序列复制回原序列。这里有个需要注意的地方,我们新申请的数组是从0开始的,而低〜中期,中期+ 1 〜高我们这里把他们当作两个独立序列来看待,其实在归并排序时这就是你待排序的序列中的两段子序列 子序列在原数组中的下标可不一定是从0开始的哦。其实我们这里已经无意将归并的效率解析了一波,归并排序的时间复杂度是O(N),那么这个额外的空间复杂度是哪来的呢,从合并操作可以看出我们申请了一段额外的空间。归并排序的时间复杂度是O(nlogn),合并的时间复杂度我们提到了是O(N)的,其实这就是归并排序时间复杂度左边那个ñ的由来。

 我刚讲的这个算法其实就是归并的关键操作合并,那么归并排序究竟要做些什么呢?分治+归并归并就是我们刚讲的那个操作。那么分治是什么意思?它又做了什么呢?其实分治就是将一个大问题分为一个又一个的小问题,将小问题解决之后得到的结果进行整合。给出示意图体会一下分治的思路。

上半部是分解的步骤,下半部就是我之前写的归并步骤。

下面我给出完整的归并排序代码:

void merge(int arr[], int low, int mid, int high)
{
	int i = low, j = mid + 1;
	int* new_arr = new int[high - low + 1];
	int k = 0;
	while (i <= mid&&j <= high)
	{
		if (arr[i] <= arr[j])
			new_arr[k++] = arr[i++];
		else
			new_arr[k++] = arr[j++];
	}
	while (i <= mid)
		new_arr[k++] = arr[i++];
	while (j <= high)
		new_arr[k++] = arr[j++];
	for (int i = low, j = 0; i <= high;)
		arr[i++] = new_arr[j++];
	delete[]new_arr;
}
void merge_Sort_core(int arr[], int low, int high)
{
	if (high - low >0)
	{
		int mid = low + ((high - low) >> 1);
		merge_Sort_core(arr, low, mid);
		merge_Sort_core(arr, mid+1, high);
		merge(arr, low, mid, high);
	}
}
void merge_Sort(int arr[], int length)
{
	if (!arr || length <= 0)
		return;
	merge_Sort_core(arr, 0, length - 1);
}

下面来分析一下归并排序的效率:前面我们提到了在合并操作时需要开辟一段额外的空间,在上面的分解图中我们可以看到两个最长子序列归并时,长度刚好等于原序列的长度。其实每一层都需要开辟空间,但最后都释放了,我们取最大的空间复杂度为O(N)。接下来分析归并排序的稳定性,关于归并排序是否稳定这个问题取决于你的代码编写,在合并操作中如果第一个左边界的值小于等于另一个左边界的值,我们则将第一个左边界的值放入新序列中。这样处理的话,归并排序就是稳定的。否则不一定。下面我们来分析一下归并排序的时间复杂度,这里我们需要采用一种叫做“递归树分析法”的分析思路。原序列先对半分,分号的子序列继续分,直到子序列只剩一个元素。那么如上图所示,划分之后的样子如同一棵树一般。树的高度是O(logn)时间时间 时间,所以我花的时间就等于树的层数✖ - 层消耗的时间。每层消耗的时间其实可以简单地看出出(数学分析其实更好记忆推荐看算法导论的分析很棒),有没有注意到每一层的所有元素加起来就等于N,只是分成了更多份。层数越往下分的份数就越多但总和还是N,所以这也就不难理解归并排序的ö(nlogn)的由来了。

按照惯例给出相应的算法面试题和部分思考题:

  1.描述一个运行时间为O(nlogn)的算法,给定Ñ个整数的集合小号和另一个整数的X,该算法能确定小号是否存在两个其和刚好为X的元素。

  答:思路:题目没有空间复杂度的限制,所以我们用归并排序(其他nlogn的排序也行)排好序列,然后定义两个指针,一个指向数组头,一个指向数组尾然后我们来判断一下头指针和尾指针指向的值相加与X进行比较,如果大于的话,尾指针左移,如果小于的话,头指针右移。如果等于的话就结束。如果两个指针相遇还没出现等于的情况,那么返回未找到。

bool find_equal_x(int arr[], int length,int x)
{
	merge_Sort(arr, length);
	int i = 0, j = length - 1;
	while (i < j)
	{
		if ((arr[i] + arr[j]) == x)
			return true;
		else if ((arr[i] + arr[j]) > x)
			j--;
		else
			i++;
	}
	return false;
}

  2.想一想可以如何去改进一下归并排序?

  答:插入排序在规模较小的时候效率比较高,所以不一定时间复杂度大的排序就不好用,要看规模一般规模小于43(算法导论给出的结果个人觉得应该在20左右)的工作化的排序算法基本都是这样设计的采用快速排序,当递归深度过深采用堆排序,规模小于8时采用插入排序)

void merge_Sort_core(int arr[], int low, int high)
{
	if (high - low >8)//不一定为8 为其他小值都行
	{
		int mid = low + ((high - low) >> 1);
		merge_Sort_core(arr, low, mid);
		merge_Sort_core(arr, mid+1, high);
		merge(arr, low, mid, high);
	}
	else
		insert_Sort_1(arr + low, high - low+1);
}

  3.给出一个确定在Ñ个元素的任何排列中逆序对数量的算法,最坏情况需要O(nlogn)时间。

  答:此题也用到了分治的思路,先把数组进行划分求各个子序列各自的逆序对,然后合并的时候再接着计算逆序对,其实相当于一个特殊的归并排序当左边序列的左边界元素大于右边序列的左边界元素,则开始计算逆序对数量,因为序列都是有序的,所以如果此时左边序列的左边界元素大于右边序列的左边界元素时,左边序列包括左边界在内的往往的所有元素此时也都应该大于右边序列的左边界元素。所以此时逆序对数量应该加上中期I + 1.依次类推我们直接给出代码。

int num;
void merge(int arr[], int low, int mid, int high)
{
	int i = low, j = mid + 1;
	int* new_arr = new int[high - low + 1];
	int k = 0;
	while (i <= mid&&j <= high)
	{
		if (arr[i] <= arr[j])
			new_arr[k++] = arr[i++];
		else
		{
			num += (mid-i+1);
			new_arr[k++] = arr[j++];
			
		}
	}
	while (i <= mid)
	{
		new_arr[k++] = arr[i++];
	}
	while (j <= high)
		new_arr[k++] = arr[j++];
	for (int i = low, j = 0; i <= high;)
		arr[i++] = new_arr[j++];
	delete[]new_arr;
}

  4.输入一个整型数组,数组里有整数也有负数。数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。要求时间复杂度为O(nlogn) 

答:此题也可以使用归并的思路,求最大子数组,我们可以先求左半边的最大值,再求右半边的最大值,然后最后求跨越中间的子序列最大值,3个部分进行比较返回最大的。

struct myval 
{
	myval(int l, int r,int ms) :left(l), right(r),max_sum(ms) {}
	int left;
	int right;
	int max_sum;
};
myval find_Max_crossing_subarray(int arr[], int low, int mid, int high)
{
	int left_sum = INT_MIN;
	int sum = 0;
	int max_left;
	for (int i = mid; i >= low; i--)
	{
		sum += arr[i];
		if (sum > left_sum)
		{
			left_sum = sum;
			max_left = i;
		}
	}
	int right_sum = INT_MIN;
	sum = 0;
	int max_right;
	for (int i = mid + 1; i <=high; i++)
	{
		sum += arr[i];
		if (sum > right_sum)
		{
			right_sum = sum;
			max_right = i;
		}
	}
	return myval(max_left, max_right, left_sum+right_sum);
}
myval find_maximum_subarray(int arr[], int low, int high)
{
	if (low == high)
		return myval(low, high, arr[low]);
	else
	{
		int mid = low + ((high - low) >> 1);
		myval left = find_maximum_subarray(arr, low, mid);
		myval right = find_maximum_subarray(arr, mid + 1, high);
		myval mix_lr = find_Max_crossing_subarray(arr, low, mid, high);
		if ((left.max_sum) >= (right.max_sum) && (left.max_sum) >= (mix_lr.max_sum))
			return left;
		else if ((right.max_sum) >= (left.max_sum) && (right.max_sum) >= (mix_lr.max_sum))
			return right;
		else
			return mix_lr;
	}
}

  5.合并两个排序的链表

答:此题的思路就很清晰了,就是我们前面用到的合并,就是操作链表的时候需要小心。

list_node* sorted_list_merge(list_node* l1, list_node* l2)
{
    if (!l1 || !l2) //如果任意一方为空 直接返回另一方的链表 
	{
		if (!l1&&l2)
			return l2;
		if (!l2&&l1)
			return l1;
		return nullptr;//如果两个都是空表 直接返回空
	}
    list_node* head=new list_node();
    list_node* tail=head;
    list_node* h1=l1;
    list_node* h2=l2;
    while(h1&&h2)
   {
       if((h1->val)<=(h2->val))
        { 
          tail->next=h1;
          tail=tail->next;
          h1=h1->next;
        }
       else
        {
           tail->next=h2;
           tail=tail->next;
           h2=h2->next;
        }
 
   }
     if(h1)
       tail->next=h1;
     if(h2)
       tail->next=h2;
return head;
}

  6.输入一个整型数组,数组里有整数也有负数。数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。要求时间复杂度为O(N)(注:此题与分治无关,只是在剑指提供上有发上来)

答:如果累积和大于0,接下来不断往后遍历并加上后面的值,如果大于greatest_sum则更新它,小于的话就正常跳过继续加后面的值如果累积和小于等于0,就拿后面的值覆盖当前的累积和并重新计算累积和。为什么要重新计算呢?因为如果当前的累积和为负数,后面拿来覆盖的值是正数的话正合我们的意,因为我们不就是要求最大序列么,一个负的累积和加上一个正数怎么会大于这个整数本身呢?所以我们不如从这个新的正数开始重新计算累积和。然后我们刚好还可以拿这个被正数覆盖之后的累积和与great_sum进行比较如果恰巧比他大的话咱么就更新great_sum,如果小于的话就正常跳过。但是如果拿来的是负数我们也不要担心,因为之前的great_sum已经记录了在此之前的最大和。因为如果是负数的话加在累积和上只会负得越多,这就违背了我们要 求最大和的目的了,所以我们同样开始重新计算累积和。

void find_max_array(int arr[],int length)
{
     if(!arr||length<=0)
       return;
    int cur_sum=0,greatest_sum=INT_MIN;
    for(int i=0;i<length;i++)
     {
         if(cur_sum<=0)
           cur_sum=arr[i];
         else
           cur_sum+=arr[i];
        if(cur_sum>greatest_sum)
           greatest_sum=cur_sum;
     }
}

8.链表的是否可以归并排序呢?

答:答案当然是可以的。只不过它与数组的归并排序相比在效率上稍稍差了一点。为什么呢?主要是由于数组的物理存储使得它可以随机定位。我们在找中间划分点时,数组可以在O(1)时间内找到,而链表由于不是顺序存储的,所以需要一个一个地走到中间位置。那么现在唯一的难点在于如何找到链表的中间点,这里就要运用到了一个额外的技巧——快慢指针。快慢指针常常用于判断链表中是否有环,简单来说就是有两个指针,快指针每次走两个节点,慢指针每次走一个节点,当快指针走到链表尽头时,慢指针才走到了链表的一半,此时就是我们想要的中间节点。归并排序链表实现与数组实现的效率差就差在这。在每次划分时,数组可以直接划分子序列。而链表需要花费O(n)的时间划分。那么按照递归树去分析的话,我们的树的高度为logn,合并过程数组和链表都是一样需要消耗O(n)的,所以综合来说 链表在每一层比数组多花了O(n)。也就是说最终的效率为O(2nlogn)忽略常系数的话,就是O(nlogn)。而数组的效率就抢在那个常系数比链表实现的更低。

猜你喜欢

转载自blog.csdn.net/qq_31984717/article/details/84173186