牛客网算法基础班笔记-Chapter1

时间复杂度

冒泡排序

public class BubbleSort {
	public void bubbleSort(int[] a) {
		if(a == null || a.length < 2) {
			return;
		}
		/*
		 * 由于每次排序都会确定一个元素的位置,所以对于n个元素的数组,要进行n-1轮排序
		 * 第i轮排序时,由于已有i个元素确定了位置,所以只需要比较a.length-1-i次
		 */
		for(int i=0; i<a.length-1; i++) {
			for(int j=0; j<a.length-1-i; j++) {
				if(a[j] > a[j+1]) swap(a, j, j+1);
			}
		}
	}
	
	public static void swap(int[] a, int i, int j) {
		int temp = a[i];
		a[i] = a[j];
		a[j] = temp;
	}
}

选择排序

public class SelectionSort {
	public static void selectionSort(int[] a) {
		if(a == null || a.length < 2) {
			return;
		}
		
		/*
		 * 插入排序每轮将一个元素放入正确的位置, 
		 * 因此第a.length-1轮只剩下2个元素未排序,
		 * 只要确定其中一个数的位置那么另一个数自然也就确定了
		 * 因此外层循环一共进行a.length-1轮 
		 * 内层循环每轮从外层循环i所指位置的下一个位置开始找未确定的元素中最小的那一个放入i所指的位置
		 */
		for(int i = 0; i < a.length - 1; i++) {
			int minIndex = i;
			for(int j = i + 1; j < a.length; j++) {
				minIndex = a[j] < a[minIndex] ? j : minIndex;
			}
			swap(a, i, minIndex); 
		}
	}
	
	public static void swap(int[] a, int i, int j) {
		int temp = a[i];
		a[i] = a[j];
		a[j] = temp;
	}
}

插入排序

public class InsertionSort {
	public static void insertionSort(int[] a) {
		if(a == null || a.length < 2) {
			return;
		}
		
		/*
		 * 插入排序是开始时(i==1时)以第一个元素为基准,默认其为有序区域,将第二个数插入有序区域正确的位置
		 * 然后第二轮(i==2时)默认前两个数为有序区域,将第三个数逐渐做交换插入到到正确位置...
		 * 外层每轮循环是将第i个元素插入到前j(即i-1)个元素组成的已经有序的区域中正确的位置
		 * 内层循环是将第i个元素不断与其前一个元素比较,若当前元素小于前一个元素则二者交换,直至到达正确位置
		 */
		for(int i = 1; i < a.length; i++) {
			for(int j = i - 1; j >= 0 && a[j] > a[j+1]; j--) {
				swap(a, j, j+1);
			}
		}
	}
	
	private static void swap(int[] a, int i, int j) {
		int temp = a[i];
		a[i] = a[j];
		a[j] = temp;
		
		/* 位运算交换a[i]和a[j]
		 * a[i] = a[i] ^ a[j]; 
		 * a[j] = a[i] ^ a[j]; 
		 * a[i] = a[i] ^ a[j];
		 */
	}
}

对于冒泡排序和选择排序,无论数据是怎样,其时间复杂度都是O(n^2)

但对于插入排序,若一开始数据就是有序的,如{1,2,3,4,5},那么插入排序时间复杂度为O(n);若一开始数据是完全逆序的,如{5,4,3,2,1},那么插入排序时间复杂度会退化成O(n^2),一般认为插入排序时间复杂度为O(n^2)

对数器

对数器是用来临场检测自己算法正确性的工具,相当于自己写的一个自动产生多个测试用例的迷你判断器,可以帮助我们快速了解算法哪里有误。这里以测设自己写的bubbleSort()方法为例:

import java.util.Arrays;

public class sortComparator {
	
	//一个绝对正确的方法,一般是调用API函数库
	public static void rightMethod(int[] a) {
		Arrays.sort(a);
	}
	
	//生成随机数组的方法
	public static int[] generateRandomArray(int size, int value) {
		int[] res = new int[(int) ((int)(size+1) * Math.random())];
		for(int i = 0; i < res.length; i++) {
			//这句话可以生成负数、0、正数
			//若直接res[i]=(int)(value * Math.random())只能生成0和正数
			res[i] = (int)((value+1) * Math.random() - (value) * Math.random()); 
		}
		return res;
	}
	
	//把数组a复制给copy
	public static int[] copyArray(int[] a) {
		if(a == null) 
			return null;
		int[] copy = new int[a.length];
		for(int i = 0; i < a.length; i++) {
			copy[i]= a[i]; 
		}
		return copy;
	}
	
	//判断两数组是否相同
	public static boolean isArrayEqual(int[] a, int[] b) {
		if((a == null && b != null) || (a != null && b == null)) 
			return false;
		if(a == null && b == null)
			return true;
		if(a.length != b.length)
			return false;
		for(int i = 0; i < a.length; i++) {
			if(a[i] != b[i])
				return false;
		}
		return true;
	}
	
	public static void printArray(int[] a) {
		if(a == null)
			return;
		for(int i = 0; i < a.length; i++) {
			System.out.print(a[i] + " " );
		}
		System.out.println();
	}

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		int testTime = 500000;
		int size = 10;
		int value = 100;
		boolean succeed = true;
		for(int i = 0; i < testTime; i++) {
			int[] arr1 = generateRandomArray(size, value);
			int[] arr2 = copyArray(arr1);
			int[] arr3 = copyArray(arr1);
			//用自己写的算法跑一遍数据,再用绝对正确的方法跑一遍,看两次结果是否相同
			BubbleSort.bubbleSort(arr1);
			rightMethod(arr2);
			//一旦不同,说明算法有漏洞,就跳出并打印当前用例
			if(!isArrayEqual(arr1, arr2)) {
				succeed = false;
				printArray(arr3);
				break;
			}
		}
		System.out.println(succeed? "Nice!" : "Fucking fucked!");
		
		
	}

}

在线做题,oj不会告知算法错的是哪个用例时,对数器可以帮助我们快速定位错误。

找工作前可以准备各种题型的对数器,比如二叉树、排序等。

 

递归

递归在逻辑上是函数不断调用自己直至边界条件,然后不断返回结果给上一层函数调用。但我一直不太明白递归在底层上到底是如何实现的,直至听了左神这节课...

递归是一种简单的分治思想,不断把大问题分割成子问题直至子问题可以轻松解决。实际上,递归在底层上是用系统栈实现的。比如一个用递归求数组中最大值问题:

public class TestRecursive1 {
	//从中间将数组一分为二,找到左段的最大值和右段的最大值,最后对二者取最大值就是所求结果
	public static int getMax(int[] a, int left, int right) {
		//递归终止条件: left和right指针指向同一个数
		//说明此时子问题为: 求left和right指向的这个数的最大值,即该数本身,直接返回
		if(left == right) 
			return a[left];
		
		int mid = (left + right)/2;
		int maxLeft = getMax(a, left, mid);
		int maxRight = getMax(a, mid+1, right);
		return Math.max(maxLeft, maxRight);
	}
	
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		int[] a = {3, 4, 9, 1, 0};
		System.out.println(getMax(a, 0, a.length-1));
	}
}

对于{3, 4, 9, 1, 0}这个数组a,初始时,问题是getMax(a, 0, 4),left==0, right==4, left!=right, 进入递归段,mid==2,问题被分隔为子问题getMax(a, 0, 2)和getMax(a, 3, 4)。

程序执行到int maxLeft = getMax(a, 0, 2)时,maxLeft要等getMax(a, 0, 2)的返回值传过来才能继续往下一行执行,那么当前任务——getMax(a, 0, 4)就暂时无法执行,系统会把该问题所有现场信息(如该函数各参数值,left, right, mid等;该函数执行到了第几行,这里就是int maxLeft = getMax(a, 0, 2)这行)全部压入栈中,转去先执行子问题getMax(a, 0, 2)。

同样类似上述过程,getMax(a, 0, 2)执行到int maxLeft = getMax(a, 0, 1)时也无法继续进行,要等子问题的返回值,先将当前问题的所有现场信息压入栈中(此时,栈底已有getMax(a, 3, 4)的信息),然后转去执行getMax(a, 0, 1)。同样,getMax(a, 0, 1)会执行到int maxLeft = getMax(a, 0, 0),getMax(a, 0, 1)的现场信息被压入栈中(此时,栈中从上到下依次已有getMax(a, 0, 2)、getMax(a, 3, 4)的信息),转去执行getMax(a, 0, 0)。

getMax(a, 0, 0)到达递归边界,直接返回a[0],此时弹出栈顶的getMax(a, 0, 1)问题,还原现场,执行int maxLeft = getMax(a, 0, 0),maxLeft被赋值为a[0]的值3,再执行int maxRight = getMax(a, 1, 1),maxRight被赋值为a[1]的值4,执行return Math.max(maxLeft, maxRight)得到getMax(a, 0, 1)的返回值为4。

此时getMax(a, 0, 2)的int maxLeft = getMax(a, 0, 1)得到返回值就被弹出栈还原现场继续执行...然后得到getMax(a, 0, 2)的返回值,弹出getMax(a, 0, 4)问题还原现场继续执行...直至得到最终结果。

综上,递归底层是通过栈来实现的。

对于递归算法的复杂度分析,有一个通用公式——Master公式

T(N)=aT(\frac{N}{b})+O(n^{d})

T(N)表示问题规模为N时的时间复杂度,aT(\frac{N}{b})表示将问题分割为a个规模为N/b的子问题后求解的复杂度(上例中将问题拆分为两个N/2规模的子问题,所以此项为2T(\frac{N}{2})),O(n^{d})表示除去调用子过程之外,剩下的过程的时间复杂度(上例中即为合并子问题解Math.max(maxLeft, maxRight)的时间复杂度为O(1))。

递归问题时间复杂度可通过上述公式根据以下三个规则判断出时间复杂度:

1.当d<logb a时,时间复杂度为O(n^(logb a))

2.当d=logb a时,时间复杂度为O((n^d)*logn)

3.当d>logb a时,时间复杂度为O(n^d)

关于这个性质可以看递归算法的复杂度推导这篇文章。

归并排序

时间复杂度:O(NlogN)

空间复杂度:O(N)

1.递归归并排序

public class MergeSort {
	public static void mergeSort(int[] a) {
		if(a == null || a.length < 2)
			return;
		sortProcess(a, 0, a.length-1);
	}

	/*
	 * 递归排序算法 
	 * 采用分治思想,将数组等分,分别对左右两段排好序 
	 * 最后再将左右两段归并为结果
	 */
	private static void sortProcess(int[] a, int left, int right) {
		if(left == right) 
			return;
		//left + ((right - left) >> 1)相当于(left + right) / 2
		int mid = left + ((right - left) >> 1);
		sortProcess(a, left, mid);
		sortProcess(a, mid+1, right);
		merge(a, left, mid, right);
	}
	
	/*
	 * 归并算法merge(int[] a, int left, int mid, int right)
	 * 传进来的数组a,从left到mid,和从mid+1到right两段分别已经有序 要将两段归并为一个整体有序的数组
	 * 开辟一个辅助数组help,设置两个指针p1,p2;p1初始指向left,p2初始指向mid+1
	 */
	private static void merge(int[] a, int left, int mid, int right) {
		int[] help = new int[right - left + 1];
		int i = 0;
		int p1 = left;
		int p2 = mid + 1;
		
		/*
		 * 循环遍历a数组的两段,p1和p2所指向的元素哪个小哪个拷贝进help[i]位置 
		 * 同时相应的p1或p2指针前进一位,i也前进一位
		 * p1或p2有一个遍历超过其所在的那一段末尾位置时就退出循环
		 * 此时说明至少有一段已经全部加入进help数组
		 */
		while(p1 <= mid && p2 <= right) {
			help[i++] = a[p1] < a[p2] ? a[p1++] : a[p2++];
		}
		//此时如果另一段尚未遍历完就全部加入help数组
		while(p1 <= mid) {
			help[i++] = a[p1++];
		}
		while(p2 <= right) {
			help[i++] = a[p2++];
		}
		for(int index = 0; index < help.length; index++) {
			a[left + index] = help[index];
		}
	}
}

归并排序算法的时间复杂度为T(N) = 2T(N/2) + O(N),即两段分别排序再加上归并的工作量。根据Master公式,归并排序算法时间复杂度为O(N*logN)

2.非递归归并排序

参考:5.比较排序之归并排序(非递归)

package chapter1;

/*
非递归实现归并排序
递归法是从完整数组一步步从中间位置划分直至每组只剩单一元素之后开始往上归并
而非递归法就是从一开始将数组按每个元素划分为长度为一的默认有序的分组,
之后将划分长度乘2,将数组划分为长度为2的分组,对组内两个元素进行归并...
*/

public class MergeSortNonRecursive {

	public static void mergeSort(int[] arr) {
		if(arr == null || arr.length < 2) {
			return;
		}
		sortProcess(arr);
	}
	
	private static void sortProcess(int[] arr) {
		int groupLen = 1;//分组划分长度初始化为1,即每个单一元素为一组,默认每一组内有序
		while(groupLen <= arr.length) {
			for(int i = 0; i + groupLen <= arr.length; i += 2 * groupLen) {
				int left = i, mid = i + groupLen - 1, right = i + 2 * groupLen - 1;
				/*
				 * 若arr数组长度为奇数,则最后一组按groupLen划分的组长度一定是少一个
				 * 那么按groupLen分配的right指针就会指向arr[arr.length]导致数组越界
				 * 应该将right指针指向数组arr最后一个元素arr[arr.length - 1]
				 */		
				if(right > arr.length - 1) {
					right = arr.length - 1;
				}
				merge(arr, left, mid, right);
			}
			//每次划分度量groupLen增长一倍
			groupLen *= 2;
		}
	}
	
	private static void merge(int[] arr, int left, int mid, int right) {
		int[] help = new int[right - left + 1];
		int index = 0;//help数组的下标
		int p1 = left, p2 = mid + 1;
		
		while(p1 <= mid && p2 <= right) {
			help[index++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
		}
		while(p1 <= mid) {
			help[index++] = arr[p1++];
		}
		while(p2 <= right) {
			help[index++] = arr[p2++];
		}
		
		for(int i = 0; i < help.length; i++) {
			arr[left + i] = help[i];
		}
	}
}

归并排序的应用

1.求小和

题目描述:

一个数列,其中一个数p,其左边所有比p小的数的和,是数p的小和。求这个数列所有数的小和之和。

例如:数列{1, 3, 4, 2, 5}

1的左边比1小的数:无

3的左边比3小的数:1

4的左边比4小的数:1,3

2的左边比5小的数:1

2的左边比2小的数:1,3,4,2

所以该数组的小和为:1+1+3+1+1+3+4+2=16

 

思路:

利用归并排序,在merge过程中计算每个元素的小和。

举一个极端的例子{1, 3, 4, 5, 6, 7},就针对1来分析。根据归并排序算法,首先对数组进行了分割,直至{1}单独为一组,然后{1}需要和{3}合并,合并过程中1为左段,3为右段,且1<3,所以小和res加了1个1;下一步{1, 3}需要和{4}合并,合并过程中{1,3}为左段,{4}为右段,且1<4,所以小和res加了1个1(这里仅针对1分析,暂时忽略3,帮助理解);下一步根据归并排序的分组,{1,3,4}一定是要和{5,6,7}进行归并,{1,3,4}为左段,{5,6,7}为右段,且1<5,由于此时右段的{5,6,7}一定已经是有序的,那么右段中从5开始一直到最后一个元素一定都大于1,所以res加了3(3=right-p2+1)个1。这样归并排序结束,由1作为元素产生的小和也就加完了。

对于其它元素也类似于1的过程。

public class SmallSum {
	public static int smallSum(int[] a) {
		if(a == null || a.length < 2)
			return 0;
		return mergeSort(a, 0, a.length-1);
	}
	
	private static int mergeSort(int[] a, int left, int right) {
		if(left == right)
			return 0;
		/*
		 * (left + right) / 2的写法不安全,可能会溢出 
		 * 不会溢出的写法是left + (right - left) / 2 
		 * 根据位运算,a / 2就相当于a >> 1,即a的二进制数右移1位 
		 * 所以left + (right - left) / 2可以写成left + ((right - left) >> 1)
		 */
		int mid = left + ((right - left) >> 1);//等同于int mid = (left + right) / 2
		return mergeSort(a, left, mid) 
				+ mergeSort(a, mid + 1, right) 
				+ merge(a, left, mid, right);
	}
	
	private static int merge(int[] a, int left, int mid, int right) {
		int[] help = new int[right - left + 1];
		int i = 0;
		int p1 = left;
		int p2 = mid + 1;
		int res = 0;
		
		/*
		 * merge过程中,每当左段指针p1指向的元素小于右段指针p2指向的元素时p1指向的元素就是和的元素
		 * 且此时右段中p2一直到right位置的元素都比p1指向元素大 
		 * 所以小和res += (right - p2 + 1) * a[p1] 否则res += 0
		 */
		while(p1 <= mid && p2 <= right) {
			//每轮merge过程中计算a[p1]所产生的小和
			res += a[p1] < a[p2] ? (right - p2 + 1) * a[p1] : 0;
			help[i++] = a[p1] < a[p2] ? a[p1++] : a[p2++];
		}
		while(p1 <= mid) {
			help[i++] = a[p1++];
		}
		//p2遍历终止条件是p2越过right位置,复制上一段代码时这里条件要注意同步更改
		while(p2 <= right) {
			help[i++] = a[p2++];
		}
		for(int index = 0; index < help.length; index++) {
			//这里经常犯错写成a[index] = help[index]
			//注意每次merge是将从left到right这个范围内的数组merge起来,并不是从a[0]开始
			a[left + index] = help[index];
		}
		
		return res;
	}
}

注意

(left + right) / 2的写法不安全,可能会溢出;不会溢出的写法是left + (right - left) / 2 ,根据位运算,a / 2就相当于a >> 1,即a的二进制数右移1位,所以left + (right - left) / 2可以写成left + ((right - left) >> 1)。

另,位运算与常数运算时间复杂度都是O(1),但位运算比常数运算快很多。

2.求逆序对

题目描述:

有一个由N个实数构成的数组,如果一对元素A[i]和A[j]是逆序的,即i<j但是A[i]>A[j]则称它们是一个逆序对,设计一个计算该数组中所有逆序对数量的算法。要求算法复杂度为O(nlogn)

思路:

和上述求小和方法完全相同,采用归并排序。

代码:

只需要将上述求小和的代码中

res += a[p1] < a[p2] ? (right - p2 + 1) * a[p1] : 0;

换成

res += a[p2] < a[p1] ? (mid - p1 + 1)  : 0;

即,每次归并过程中考察右组p2所指元素的值,

case1.若arr[p2] > arr[p1],不会产生逆序对,p2向右移动1

case2.若arr[p2] < arr[p1],产生了逆序对,并且由于此时左边组内一定是有序的,那么左边组中从arr[p1]到arr[mid]值都大于arr[p2],则会产生mid-p1+1个逆序对。如{2, 4, 7}, {1, 3, 5}进行归并时,p1指向2,p2指向1,那么对于1来说会产生[2, 1], [4, 1], [7, 1]共3个逆序对即mid-p1+1(2-1+1)。

 

每轮递归mergeSort的过程中产生的逆序对数=左边组的逆序对数 + 右边组的逆序对数 + 两组merge的过程中产生的小逆序对数,该过程中不会有遗漏,左右两组的逆序对数在上一轮merge形成它们时已经算过了。

package 逆序数;

public class MergeNumOfReverse {
    public static int numOfReverse(int[] a) {
        if(a == null || a.length < 2)
            return 0;
        return mergeSort(a, 0, a.length-1);
    }
    
    //返回left到right范围上的逆序对数
    private static int mergeSort(int[] a, int left, int right) {
        if(left == right)
            return 0;
        /*
         * (left + right) / 2的写法不安全,可能会溢出 
         * 不会溢出的写法是left + (right - left) / 2 
         * 根据位运算,a / 2就相当于a >> 1,即a的二进制数右移1位 
         * 所以left + (right - left) / 2可以写成left + ((right - left) >> 1)
         */
        int mid = left + ((right - left) >> 1);//等同于int mid = (left + right) / 2
        //逆序对数=左边组的逆序对数 + 右边组的逆序对数 + 两组merge的过程中产生的小逆序对数
        return mergeSort(a, left, mid) 
                + mergeSort(a, mid + 1, right) 
                + merge(a, left, mid, right);
    }
    
    private static int merge(int[] a, int left, int mid, int right) {
        int[] help = new int[right - left + 1];
        int i = 0;
        int p1 = left;
        int p2 = mid + 1;
        int res = 0;
        
        /*
         * merge过程中,每当左段指针p1指向的元素小于右段指针p2指向的元素时p1指向的元素就是和的元素
         * 且此时右段中p2一直到right位置的元素都比p1指向元素大 
         * 所以小和res += (right - p2 + 1) * a[p1] 否则res += 0
         */
        while(p1 <= mid && p2 <= right) {
            //每轮merge过程中计算a[p1]所产生的逆序对数
            //每次判断右组的p2所指向元素,若其值小于p1所指元素值,则对于当前p2元素,
            //有左边组中有mid-p1+1个比当期p2元素值大,即产生mid-p1+1个逆序对
            res += a[p2] < a[p1] ? (mid - p1 + 1)  : 0;
            help[i++] = a[p1] <= a[p2] ? a[p1++] : a[p2++];
        }
        while(p1 <= mid) {
            help[i++] = a[p1++];
        }
        //p2遍历终止条件是p2越过right位置,复制上一段代码时这里条件要注意同步更改
        while(p2 <= right) {
            help[i++] = a[p2++];
        }
        for(int index = 0; index < help.length; index++) {
            //这里经常犯错写成a[index] = help[index]
            //注意每次merge是将从left到right这个范围内的数组merge起来,并不是从a[0]开始
            a[left + index] = help[index];
        }
        
        return res;
    }
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值