「左程云-算法与数据结构笔记」| P4 认识O(NlogN)的排序

646 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第二天,点击查看活动详情

最近在看左神的数据结构与算法,考虑到视频讲的内容和给出的资料的PDF有些出入,不方便去复习,打算出一个左神的数据结构与算法的笔记系列供大家复习,同时也可以加深自己对于这些知识的掌握,该系列以视频的集数为分隔,今天是第二篇:P4|认识O(NlogN)的排序

一、Master公式

在算法中,递归是“神”的工具,尽管它代码更为简介,但是由于不断的调用自己,也导致函数的时间复杂度不方便计算,因此就有了Master公式

1、📜 公式

	T(N) = aT(N/b) + O(N^d) 

① 公式组成

  • T(N) 指母问题的数据量是 N 级别的,即母问题一共有 N 个数据
  • a 为子问题的调用次数
  • b 为样本的分层
  • N/b 为子问题的规模
  • d 为除去调用之外,剩下过程的时间复杂度

② 计算方法

  • log(b,a) > d ==> 时间复杂度为:O( N^log(b,a) )
  • log(b,a) < d ==> 时间复杂度为:O( N^d )
  • log(b,a) = d ==> 时间复杂度为:O( N^d * logN )

2、🎯 举例:找到数组中的最大值

	public static int process(int[] arr, int L, int R){
		if(L == R){
			return arr[L];
		}
		int mid = L + ((R-L) >> 1);
		int leftMaxNum = process(arr, L, mid);
		int rightMaxNum = process(arr, mid, R);
		return Math.max(leftMax, rightMax);
	}
  • 首先可以认为 process 为母问题
  • 对于一次递归,它对数据进行二分,即原有的样本量被分成了两份,即T(N) = aT(N/2) + O(N^d),因此我们可以得到 b = 2
  • 两份样本的样本量都是 N/2 ,即该样本执行了两次,即T(N) = 2T(N/2) + O(N^d),因此我们可以得到 a = 2
  • 处理完子问题之后,只有 Math.max 一个过程,时间复杂度为 O(1)
  • 其中
    • log(b,a) = log(2,2) = 1
    • d = 0 < log(b,a)
  • 故时间复杂度为 O(N^1) = O(N)

二、归并排序

1、💫 实现思路

image.png

  • 分别对左半部分和右半部分做排序,使左边和右边分别有序
    • 在对一半样本量做排序的时候,就使用递归
    • 递归退出的条件为 left == right
  • 左边和右边有序后,引入一个 help 数组,使用双指针对左半部分和右半部分做遍历,小的数据先进 help 数组,最后把 help 数组的数据拷贝到原数组

2、💻代码实现

	public static void mergeSort(int[] array) {  
	    if (array.length < 2){  
	        return;  
	    }  
	    mergeSort(array,0,array.length - 1);  
	}  
	  
	public static void mergeSort(int[] array, int left, int right){  
	    if (left == right){  
	        return;  
	    }  
	    int middle = left + (right - left) / 2;
	    // 对左右两边的部分,分别递归做好排序  
	    mergeSort(array,left,middle);  
	    mergeSort(array,middle + 1, right);  
	    merge(array,left,right,middle);  
	}  
	// 使用help数组,以及双指针做遍历 
	public static void merge(int[] array, int left, int right, int middle){
		// 左半部分的头指针p1 右半部分的头指针p2  
	    int p1 = left, p2 = middle + 1, i = 0;  
	    int[] help = new int[right - left + 1];
	    // 遍历原数组,把小的数先放进去  
	    while (p1 <= middle && p2 <= right){  
	        help[i++] = array[p1] < array[p2] ? array[p1++] : array[p2++];  
	    }
	    // 如果有一边所有都放进去了,就把另外一半直接放进去 
	    while (p1 <= middle){  
	        help[i++] = array[p1++];  
	    }  
	    while (p2 <= right){  
	        help[i++] = array[p2++];  
	    }
	    // 把 help 数组拷贝到原数组  
	    System.arraycopy(help, 0, array, left, help.length);  
	}

3、🚩 时间复杂度分析

不难发现,归并排序使用到了递归的算法,要计算时间复杂度,就需要用到前文中提到的 Master公式

  • 首先每次把数据分为两份,而且执行两次 即 a = 2,b = 2
  • 而除了子问题外,还有一个操作 merge ,该过程相当于对所有的数据做了遍历,即时间复杂度为 O(N) d = 1
  • 那么
    • log(a,b) = log(2,2) = 1 = d
    • 即时间复杂度为 O(N * logN)

三、小和问题

小和问题其实是归并排序衍生出来的题目,与归并排序的思想相似 题目:在一个数组中,每一个数左边比当前数小的数累加起来,叫做这个数组的小和 例子:[1,3,4,2,5] 1左边没有比1小的数,3左边比3小的数有:1;4左边比4小的数:1,3 2左边比2小的数:1;5左边比5小的数:1,3,4,2 因此小和为:1 + 1 + 3 + 1 + 1 + 3 + 4 + 2 = 16

1、💫 解题思路

  • 与其看左边有多少个数比自己小,不妨转变思路,看右边有多少个数比自己大
  • 我们在做 merge 的时候,会比较左右两边的数,只需要在做比较的时候,把右边比自己大的数记录下来并计算即可
  • 比如左边的数组为 1 2 3 右边为 2 5 6
    • 在左边指针为 1 的时候,发现比右边的数 2 小,就用最右边数的索引值减去当前右边指针所在位置,即 2 的位置的索引,得到 3,即有 3 个数比 1 大
    • 然后继续后移 左边指针为 2 右边数的指针为 2,然后把右边的 2 放进辅助数组 help,然后右指针继续移动,发现有 2 个数比 2 大
    • 依次类推…………

2、💻 代码实现

	public static int smallSum(int[] arr) {  
	   if (arr == null || arr.length < 2) {  
	      return 0;  
	   }  
	   return mergeSort(arr, 0, arr.length - 1);  
	}  
	  
	public static int mergeSort(int[] arr, int l, int r) {  
	   if (l == r) {  
	      return 0;  
	   }  
	   int mid = l + ((r - l) >> 1);  
	   return mergeSort(arr, l, mid)   
	         + mergeSort(arr, mid + 1, r)   
	         + merge(arr, l, mid, r);  
	}  
	  
	public static int merge(int[] arr, int l, int m, int r) {  
	   int[] help = new int[r - l + 1];  
	   int i = 0;  
	   int p1 = l;  
	   int p2 = m + 1;  
	   int res = 0;  
	   while (p1 <= m && p2 <= r) {  
	      res += arr[p1] < arr[p2] ? (r - p2 + 1) * arr[p1] : 0;  
	      help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];  
	   }  
	   while (p1 <= m) {  
	      help[i++] = arr[p1++];  
	   }  
	   while (p2 <= r) {  
	      help[i++] = arr[p2++];  
	   }  
	   System.arraycopy(help, 0, arr, l, help.length);
	   return res;  
	}
  • 不难发现,其实大体上的代码与归并排序一致,但是修改了一些逻辑
    • 首先可以发现 merge 有了返回值,就是本次合并中得到的小和
    • 然后 mergeSort 中也有了返回值,返回的是左边的小和、右边的小和、左边与右边结合起来得到的整体的小和
    • 此处是不会有重复的,大家可以去debug调试一下
    • 然后求小和的方法就如上面实现思路所说:res += arr[p1] < arr[p2] ? (r - p2 + 1) * arr[p1] : 0;
    • 采用下标相减,然后乘左边的数据的操作

3、🌈 逆序对

逆序对:在一个数组中,左边的数如果比右边的数大,那么两个数构成一个逆序对 其实不难发现,这个与小和问题有异曲同工之妙,同样是在merge的时候去比较两边的数,在做交换的时候,打印逆序对即可

四、荷兰国旗问题

荷兰国旗问题是为了后面的快速排序打基础

1、荷兰国旗问题一

①📑 题目

给定一个数组arr,和一个数num,请把小于等于num的数放在数组的左边,大于num的 数放在数组的右边。要求额外空间复杂度O(1),时间复杂度O(N)

②💫 实现思路

image.png

  • 在左边维护一个 Less 空间,遍历数组,与 num 做比较,如果小于等于 num 就把数据放进 less,并且 less++ pivot++
  • 如果大于 num,就不扩张 less 空间,只是指针右移,继续遍历

③💻 代码展示

	public static void netherlandsFlag(int[] array, int number){  
	    int left = 0;  
	    int less = -1;  
	    for (int i : array) {  
	        if (i <= number){  
	            swap(array, left++, ++less);  
	        }else {  
	            left++;  
	        }  
	    }  
	}

2、荷兰国旗问题二

①📑 题目

给定一个数组arr,和一个数num,请把小于num的数放在数组的左边,等于num的数放 在数组的中,大于num的数放在数组的右边。要求额外空间复杂度O(1),时间复杂度 O(N)

②💫 实现思路

  • 有了上面的问题一,其实我们难想到这题的解决方法:
  • 在右边也维护一个空间,用于存放大于 num 的数据
  • 即 如果小于就存在左边的空间 大于就存在右边的空间 等于就不存 只是 pivot++ 继续右移

③💻 代码展示

	public static void netherlandsFlag(int[] array, int number){  
	    int left = 0;  
	    int less = -1;  
	    int right = array.length - 1;  
	    int more = right + 1;  
	    while (left < more){  
	        if (array[left] > number){  
	            swap(array,left,--more);  
	        }else if (array[left] < number){  
	            swap(array,left++,++less);  
	        }else {  
	            left++;  
	        }  
	    }  
	}

五、快速排序

1、💫 实现思路

1️⃣ 快排 1.0

  • 在整个数组中,拿最后一个数做划分值,即作为 num,让最后一个数前面的一段做到,小于等于num的放左边,大于num的放右边
  • 然后把这个数num与大于num的第一个数做交换,此时就能做到左侧的都是小于等于num的数,右侧都是大于num的数
  • 然后让左侧和右侧都重复这个行为

image.png

2️⃣ 快排 2.0

  • 快排 2.0实际上就是荷兰国旗问题二,与快排 1.0 的区别在于,分段的时候是:小于num的放左边,等于num的放中间,大于num的放右边
  • 不管是快排1.0还是快排2.0,时间复杂度都是O(N²),因为可以举出最差的例子:1、2、3、4、5、6、7、8、9,当完全逆序的时候,分别拿9、8、7、6、5、4、3、2、1做划分值,每次partition都只搞定了一个数

3️⃣ 快排 3.0

  • 上述的差的情况是因为划分值打的很偏,好的情况其实就是打在中间的位置,比如上述的例子,打到5,那么其实左右两侧的规模是差不多的,此时的表达式为:
    • T(N) = 2T(N/2) + O(N)
    • 时间复杂度为O(N * logN)
  • 因此快排3.0从数组中随机选出一个值,放在最后的位置,那么此时好情况和差情况就变成了概率事件
  • 现在把所有的情况使用概率论并且求数学长期期望,就能得到时间复杂度为:O(N * logN)

2、💻代码实现

	public static void quickSort(int[] array) {  
	    if (array.length < 2){  
	        return;  
	    }  
	    quickSort(array,0,array.length - 1);  
	}  
	  
	public static void quickSort(int[] array, int left, int right){  
	    if (left >= right){  
	        return;  
	    }  
	    swap(array, left + (int) (Math.random() * (right - left + 1)), right);  
	    int[] partition = partition(array, left, right);  
	    quickSort(array, left, partition[0]);  
	    quickSort(array,partition[1], right);  
	}  
	  
	public static int[] partition(int[] array, int left, int right){  
	    int less = left - 1;  
	    int more = right;  
	    while (left < more){  
	        if (array[left] < array[right]){  
	            swap(array, ++less, left++);  
	        } else if (array[left] > array[right]){  
	            swap(array, left, --more);  
	        } else if (array[left] == array[right]){  
	            left ++;  
	        }  
	    }  
	    swap(array,more,right);  
	    return new int[]{less,more+1};  
	}