算法题目 | 4道基于分治模式、快排思想的算法题(1.奇数在左,偶数在右 2.查找第k小的数 3.寻找发帖"水王" 4.最小可用ID)

文章目录

    • 前期准备:自定义数组工具类MyArrays
    • 题目1:调整数组顺序使奇数位于偶数前面
    • 题目2:查找第k小的元素
    • 题目3:寻找发帖"水王"
    • 题目4:最小可用ID

前期准备:自定义数组工具类MyArrays

关于分治、快排的知识可以点这里简单学习一下
int[] getArr(int length, int min, int max):自动生成一个数组,方便测试时调用
void swap(int[] arr, int i, int j):交换数组两元素的位置

/**
 * 自定义数组工具类
 * 
 * @author LRRacac
 *
 */
public class MyArrays {
	/**
	 * 自动生成一个长度为length,元素值介于[min,max]之间的数组
	 * 
	 * @param length---待生成数组的长度
	 * @param min---数组元素的最小值
	 * @param max---数组元素的最大值
	 * @return int[]---返回生成的数组
	 */
	public static int[] getArr(int length, int min, int max) {
		int[] arr = new int[length];
		for (int i = 0; i < arr.length; i++) {
			// 随机生成一个元素值介于[min,max]内的一个元素
			arr[i] = (int) (Math.random() * (max - min + 1) + min);
		}
		return arr;
	}

	/**
	 * 交换数组arr中两元素的位置---即arr[i]与arr[j]位置对调
	 * 
	 * @param arr---待交换元素的数组
	 * @param i---待交换元素的位置
	 * @param j---待交换元素的位置
	 */
	public static void swap(int[] arr, int i, int j) {
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
	}
}

题目1:调整数组顺序使奇数位于偶数前面

题目描述
输入一个整数数组,调整数组中数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分,要求时间复杂度为O(n)

思路分析
① 定义两个扫描指针,左指针初试指向数组首元素,右指针初始指向最后一个元素
② 让左指针往右依次扫描,右指针依次往左扫描,若左指针扫描到的元素为奇数则左指针继续向右扫描,当扫描的元素为偶数则停止;同理,右指针扫描到偶数则继续向左扫描,否则停止。
此时左指针指向偶数,右指针指向奇数,将两指针所指元素进行位置交换,继续②的操作,直至左指针越过右指针

代码展示

/**
 * 奇数在左,偶数在右
 * 
 * @author LRRacac
 *
 */
import java.util.Arrays;
public class OddEvenNumber {
	public static void main(String[] args) {
		/*
		 * 测试:奇数在左,偶数在右
		 */
		int arr[] = MyArrays.getArr(10, 1, 30); // 获取一个长度为10,元素值介于[1,30]之间的数组
		System.out.println("处理前:" + Arrays.toString(arr));
		func(arr); // 调用处理方法
		System.out.println("处理后:" + Arrays.toString(arr));

	}
	
	/**
	 * 处理方法 奇数在左,偶数在右
	 * 
	 */
	public static void func(int[] arr) {
		int l_scan = 0; // 定义左指针,初始指向第一个元素
		int r_scan = arr.length - 1; // 定义右指针,初始指向最后一个元素
		while (l_scan < r_scan) {
			while (l_scan < r_scan && arr[l_scan] % 2 == 1) {
				l_scan++; // 当左指针所指元素为奇数时,左指针右移
			}
			while (l_scan < r_scan && arr[r_scan] % 2 == 0) {
				r_scan--; // 当右指针所指元素为偶数时,右指针左移
			}
			// 此时左指针第一次遇到偶数,右指针第一次遇到奇数
			swap(arr, l_scan, r_scan); // 交换两指针所指元素的位置
		}
	}
	
	/**
	 * 交换数组arr中两元素的位置---即arr[i]与arr[j]位置对调
	 * 
	 */
	public static void swap(int[] arr, int i, int j) {
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
	}
}

测试结果

题目2:查找第k小的元素

题目描述
以尽量高的效率求出一个乱序数组中按数值顺序的第k个元素

思路分析
① 利用快排的思路,进行区域划分,采用三点中值法找出主元,划分实现主元左侧所有元素小于等于主元,主元右侧所有元素大于主元(快排不熟悉的可以看这里)
② 分区后的主元所在数组中的位置,也就是其数值大小的排位,我们要找的第k小的元素,可以比较k与(主元索引+1)的大小,当k小于(主元索引+1)时说明我们要找的数在主元的左侧,否则在主元的右侧
③ 根据上述比较,可以采用二分的思路,递归地在主元的左侧或右侧查找

代码展示

/**
 * 最快效率求出乱序数组中第k小的数
 * 
 * @author LRRacac
 *
 */
import java.util.Arrays;
public class SelectK {
	public static void main(String[] args) {
		/*
		 * 测试:找第k个元素
		 */
		int arr[] = MyArrays.getArr(10, 1, 30); // 获取一个长度为10,元素值介于[1,30]之间的数组
		System.out.println("数组:" + Arrays.toString(arr));
		int random = (int) (Math.random() * 10 + 1);
		System.out.println("数值为第" + random + "位的元素是:" + selectK(arr, 0, arr.length - 1, random));
	}

	/**
	 * 查找方法---在乱序数组中找到数值大小为第k小的元素
	 * 
	 * @param arr---待处理的数组
	 * @param l---左边界
	 * @param r---右边界
	 * @param k---待找的位置
	 * @return int---返回待找元素
	 */
	public static int selectK(int[] arr, int l, int r, int k) {
		int pivotIndex = portion(arr, l, r); // 获取主元的索引
		// 由于主元的左侧都是小于等于主元的数,主元的右侧都是大于主元的数,所以主元的(索引+1)即为主元数值大小在数组中的排位
		if (pivotIndex == k - 1) { 
			return arr[pivotIndex]; // 当主元数值大小在数组排第k位时,返回主元
		} else if (pivotIndex > k - 1) {
			return selectK(arr, l, pivotIndex - 1, k); // 当主元数值大小在数组中的排位大于k时,在主元的左侧继续找
		} else {
			return selectK(arr, pivotIndex + 1, r, k); // 当主元数值大小在数组中的排位小于k时,在主元的右侧继续找
		}
	}

	/**
	 * 分区方法---利用三点中值法获取主元 
	 * 类似快速排序的处理
	 * 
	 * @param arr---待处理的数组
	 * @param l---左边界
	 * @param r---右边界
	 * @return int---返回主元的位置
	 */
	public static int portion(int[] arr, int l, int r) {
		// 三点中值法获取主元(见下)
		int midIndex = (l + r) >> 1; // 获取中间位置的索引
		int midValueIndex = -1; // 定义中值的索引,并初始化为-1
		if (arr[l] >= arr[midIndex] && arr[l] <= arr[r] || arr[l] <= arr[midIndex] && arr[l] >= arr[r]) {
			midValueIndex = l; // 当arr[l]介于arr[midIndex]与arr[r]之间时取arr[l]为中值
		} else if (arr[midIndex] >= arr[l] && arr[midIndex] <= arr[r]
				|| arr[midIndex] <= arr[l] && arr[midIndex] >= arr[r]) {
			midValueIndex = midIndex; // 当arr[midIndex]介于arr[l]与arr[r]之间时取arr[midIndex]为中值
		} else {
			midValueIndex = r; // 当arr[r]介于arr[midIndex]与arr[r]之间时取arr[r]为中值
		}
		MyArrays.swap(arr, l, midValueIndex);// 将选定的中值元素与左边界元素交换位置
		int pivot = arr[l]; // 设置主元为三点的中值
		// 三点中值法获取主元(见上)

		int l_scan = l + 1; // 定义左指针,初始指向第二个元素
		int r_scan = r; // 定义右指针,初始指向最后一个元素
		while (l_scan <= r_scan) {
			while (l_scan <= r_scan && arr[l_scan] <= pivot) {
				l_scan++; // 当左指针所指元素小于等于主元时,左指针右移
			}
			while (l_scan <= r_scan && arr[r_scan] > pivot) {
				r_scan--; // 当右指针所指元素大于主元时,右指针左移
			}
			// 至此,左指针指向大于主元的元素;右指针指向小于等于主元的元素
			if (l_scan <= r_scan)
				MyArrays.swap(arr, l_scan, r_scan); // 将左右两指针所指元素交换位置
		}
		MyArrays.swap(arr, l, r_scan);
		return r_scan; // 返回主元的索引位置
	}
}

测试结果

题目3:寻找发帖"水王"

题目描述
Tango是微软亚洲研究院的一个试验项目。研究院的员工和实习生们都很喜欢在Tango上面交流灌水。传说,Tango有一大“水王”,他不但喜欢发贴,还会回复其他ID发的每个帖子。坊间风闻该“水王”发帖数目超过了帖子总数的一半。如果你有一个当前论坛上所有帖子(包括回帖)的列表,其中帖子作者的ID也在表中,你能快速找出这个传说中的Tango水王吗?

思路分析

  • 题目简言之:数组中有一个数字出现的次数超过了数组长度的一半,找出这个数字
  • 方法1
    若数组长度为N,先排序,后直接返回arr[N/2](易得,当一个数超过数组一半,假设取极端情况:该数出现次数刚好是数组长度一半再多一个,那么分三种情况
    ① 假设该数K是数组中最小的数,那么排序后,该数占据数组前半部分再多一个。若N为偶数,那么该数K的个数为N/2+1,索引N/2就是数组的中间位置的右边那一位,即指向第N/2+1个数K(该位置左边全为这个数,右边就是别的数了),则arr[N/2]是该数。若N为奇数,那么该数K的个数为(int)(N/2+1)个,此时索引N/2指向也是指向的第(int)(N/2+1)个数K
    ② 同理,假设该数K是数组中最大的数,也可以证明排序后arr[N/2]就是这个数
    ③ 当数K的值介于数组最大与最小值之间时,更容易得到(int)(N/2+1)个
  • 方法2
    根据方法1,我们知道了按数值排序后索引为N/2的数就是该数K,那么可以直接使用题目2中的selectK方法,来直接查找数值为第(N/2+1)位的元素(这里要注意一下:有序数组索引为i,其数值排第i+1位,因为索引从0开始,排位从1开始)
  • 方法3
    方法1、2有一定的局限,即在查找的过程中会改变原数组的元素排列顺序,若题目限制不可动用原数组,则可以使用消除法,即将数组从左到右两两不同的数消除,最后剩下的一定是出现多余数组一半的那个数
    ① 假定首元素为候选数candidate,定义一个count变量来记录"假定的候选数candidate"经过两两消除后的剩余次数,count初始化为1
    ② 从第二个元素开始遍历所有元素,与假定的候选数candidate比较,若相同则count+1,若不同则count-1,当count减为0后,假定下一个待遍历的元素为candidate,依次处理下去,直到扫描完所有数,可以推知,最终count最小为1(每个数K都与别的数进行了一次两两消除,最后剩下一个数K),count最大即为数K的个数(前面都是别的数,分别两两消除完毕,后面全是数K)
    ③ 最后的候补数candidate即为数K

变种题目
当数组中出现次数最多的数K占数组长度的一半时,该怎么处理
① 此时上述均不适用
② 可以改进消除法,我们知道如果数K占数组长度的一半,那么该数组一定是偶数个元素。那么如果继续使用方法3中的消除法,则有可能最后count消减为0(取最极端的情况,两个数K中夹杂着一个别的数),那么count为0的情况我们要特殊讨论
③ 针对count为0的情况,我们只需比较candidate和最后一个元素是不是数K,可以增加一个countlast变量来统计最后一个元素出现的次数,当最后一个元素出现次数刚好为数组的一半时则最后一个数为数K,否则为candidate

代码展示

/**
 * 寻找发帖水王:寻找数组中次数出现超过半数的元素 
 * 解法1:排序后返回arr[N/2] 
 * 解法2:分区法直接查找中间位置的元素
 * 		解法1、2需要改动数组的内容 
 * 解法3:消除法(不需改动数组的内容)
 * 变种题目:改进版消除法
 * 
 * @author LRRacac
 *
 */
import java.util.Arrays;
public class MoreThanHalf {
	public static void main(String[] args) {
		int[] arr = { 6,4,6,2,7,1,6,6,6,8,6};
		System.out.println(Arrays.toString(arr));//打印这个数组
		// 测试方法1:先排序,后返回中间的元素
		System.out.println("解法1:超过半数的值为" + sortHalf(arr));
		// 测试方法2:利用分区法,找到中间位置的元素(利用上一个上一题的SelectK)
		System.out.println("解法2:超过半数的值为" + selectHalf(arr));
		// 测试方法3:消除法
		System.out.println("解法3:超过半数的值为" + remove(arr));
		// 测试变种题(水王个数占半):改进消除法
		int[] arr1 = {2, 10, 4, 10, 1, 10, 3, 10, 10, 5};
		System.out.println(Arrays.toString(arr1));//打印这个数组
		System.out.println("变种题:占数组一半的值为" + getHalf(arr1));
	}

	/**
	 * 方法1:先排序,后返回中间位置的元素 时间复杂度:O(nlogn)
	 * 
	 * @param arr
	 * @return
	 */
	public static int sortHalf(int[] arr) {
		Arrays.sort(arr);
		return arr[arr.length / 2];
	}

	/**
	 * 方法2:直接查找中间位置的元素 较方法1更优,因为我们不关心其他元素的顺序,方法一给每个元素排序的操作是没必要的 时间复杂度:O(n)
	 * 
	 * @param arr
	 * @return
	 */
	public static int selectHalf(int[] arr) {
		int result = SelectK.selectK(arr, 0, arr.length - 1, (arr.length / 2)+1);
		return result;
	}

	/**
	 * 方法3:消除法
	 * 将数组从左到右两两不同的数消除,最后剩下的一定是出现多余数组一半的那个数
	 * @param arr
	 * @return
	 */
	public static int remove(int[] arr) {
		int candidate = arr[0];//定义候选数
		int count = 1;//记录两两消除后的剩余次数
		for (int i = 1; i < arr.length; i++) {
			if(count==0) {
				candidate = arr[i];//若两两消减为零,应该把当前的元素作为新的候选值
				count = 1;
				continue;
			}
			if(arr[i]==candidate) 
				count++;//遇到和候选值相同的,次数+1
			else 
				count--; //不同的数,进行消减
		}
		return candidate;
	}
	
	/**
	 * 寻找发帖水王变题:当出现最多次的数占数组的一半,怎么找出该数
	 * 方法1、2已不适用,改进消除法
	 * @param arr
	 * @return
	 */
	public static int getHalf(int[] arr) {
		int candidate = arr[0];//定义候选数
		int count = 1;//记录两两消除后的次数
		int countlast = 0;//记录最后一个元素出现的次数
		for (int i = 1; i < arr.length; i++) {
			if(arr[i]==arr[arr.length-1]) {
				countlast++; //记录最后一个数出现次数
			}
			if(count==0) {
				candidate = arr[i];//若两两消减为零,应该把当前的元素作为新的候选值
				count = 1;
				continue;
			}
			if(arr[i]==candidate) 
				count++; //遇到和候选值相同的,次数+1
			else 
				count--; //不同的数,进行消减
		}
		if(countlast==arr.length/2) {
			return arr[arr.length-1]; //若最后一个数出现的次数为数组的一半,则它为水王
		}else {
			return candidate;//否则水王为候补者
		}
	}

测试结果

题目4:最小可用ID

题目描述
在非负数组(乱序)中找到最小的可分配的ID(从1开始编号),数据量为1000000

思路分析

  • 方法1
    先对数组进行排序,如果没有空缺,那么数组元素应该是1-n紧密排列,其索引为0-(n-1),也就是元素值为k,其索引应该为k-1
    遍历数组,判断其索引i+1是否等于其元素值,当找到第一个索引i,当索引i+1不等于对应元素值时,说明值为i+1的这个元素空缺,则返回i+1为待找的最小可用ID
    若数组中元素排列紧密,即扫描一遍之后均没有空缺,则直接返回arr.length+1
  • 方法2
    ① 假设数组长度为N,易得,若这N个数排列紧密,则为1~N,则最小可用ID为N+1;若这N个数排列不紧密,则最小可用ID一定小于等于N。
    ② 开辟一个辅助数组helper用来标记原数组中出现的元素,遍历原数组arr中的元素,将原数组中元素值(arr[i]-1)作为helper数组的索引 ,并把该值赋为1:helper[arr[i]-1]=1。使得helper数组中元素值为1的元素对应的(索引+1)就是原数组中出现的元素,helper数组中元素值为0的元素对应的(索引+1)就是元素组中未出现的元素。
    ③ 此时注意,helper长度设为与原数组长度相等时,在执行上述操作时可能会越界,假设数组为{1,2,3,4,60},则在扫描到arr最后一个元素时,helper[60-1]直接越界。根据①的分析,在数组不紧密的情况下,我们知道最小ID一定小于数组长度N。所以不妨在给helper数组赋值的时候,直接过滤掉arr[i]>N的元素值
    ④ 最后只需遍历helper数组,找到第一个元素值为0的元素,返回其下标+1
  • 方法3最高效
    若题目要求不能开辟辅助空间,并且尽可能高效实现。则可以基于方法1来改进,我们已经知道如果一个数组排列紧密,那么一个元素的元素值应该为其下标+1,那么可以利用题目2的selectK方法来直接找到中间元素的元素值是否为其下标+1如果是,则说明该元素左侧排列紧密,则去中间元素右侧找否则应该在中间元素左侧找

代码展示

/**
 * 找出最小可用ID
 * 
 * @author LRRacac
 *
 */
import java.util.Arrays;
public class MinFreeID {
	public static void main(String[] args) {
		int[] arr = {3,14,12,1,5,2,7,9,4,10};
		System.out.println(Arrays.toString(arr));//打印数组
		System.out.println("solve1——最小可用ID为:" + solve1(arr));//测试方法1
		System.out.println("solve2——最小可用ID为:" + solve2(arr));//测试方法2
		System.out.println("solve3——最小可用ID为:" + solve3(arr, 0, arr.length - 1));//测试方法3
	}

	/**
	 * 方法1:先排序,如果没有空缺那么数组元素就是1~n排列(其下标为0~n-1)
	 * 即找到第一个不在其位的元素,如果扫描到下标为i的元素,其元素值不等于i+1,则i+1即为最小可以ID
	 * 
	 * @param arr
	 * @return
	 */
	public static int solve1(int[] arr) {
		Arrays.sort(arr);// 对数组进行排序
		for (int i = 0; i < arr.length; i++) {
			if (arr[i] != i + 1) {
				return i + 1;// 逐个扫描数组中的元素,找到第一个不在其位的元素
			}
		}
		return arr.length + 1;// 如果数组中的元素没有空缺,即1-n紧密排列,则返回n+1
	}

	/**
	 * 方法2:开辟一个辅助数组用来标记原数组元素值的出现
	 * 
	 * @param arr
	 * @return
	 */
	public static int solve2(int[] arr) {
		int[] helper = new int[arr.length];// 定义一个辅助数组,长度与arr相等,用于标记arr中的某元素是否存在
		for (int i = 0; i < arr.length; i++) {
			if (arr[i] <= arr.length) {// 由于数组应该是紧密排列的,所以只需对arr元素值中小于等于数组长度的元素进行标记
				helper[arr[i] - 1] = 1;// 逐个扫描arr中的元素,若arr[i]=k,则将helper数组中的索引为k-1处元素值改为1
			}
		} // 至此,已将arr中所有存在的元素,对应在helper数组中标记为1
		for (int i = 0; i < helper.length; i++) {
			if (helper[i] == 0) {
				return i + 1;// 逐个扫描helper数组中的元素,当遇到第一个值为0的元素时返回i+1,即为对应最小可用ID
			}
		}
		return arr.length + 1;// 如果数组中的元素没有空缺,即1-n紧密排列,则返回n+1
	}
	
	/**
	 * 方法3:利用selectK函数直接获取某元素的实际元素值排位,判断是否在其位,类似二分操作
	 * @param arr
	 * @param l
	 * @param r
	 * @return
	 */
	public static int solve3(int[] arr, int l, int r) {
		if (l > r) {
			return l + 1;// 当左指大于右指针时,返回该指针索引+1
		}
		int midIndex = (l + r) / 2; // 取中间索引
		int mid = SelectK.selectK(arr, l, r, midIndex + 1); // 实际在中间位置的值
		if (midIndex + 1 == mid) {
			// 当中间索引值+1等于中间值的元素值时,说明中间值左侧索引0~midIndex之间的元素值(1~midIndex+1)都是排列紧密的
			return solve3(arr, midIndex + 1, r); // 因此应该往中间值右侧找
		} else {
			// 当中间索引值+1大于中间值的元素值时,说明中间值左侧索引0~midIndex之间的元素值并不紧密,则应该往中间值左侧找
			return solve3(arr, l, midIndex - 1);
		}
	}
}

测试结果

骚话时刻:
不知不觉在家已经待了50多天,也不知道是肉长得快一点还是知识hh
今天正儿八经简单总结一下
题目1:奇数在左偶数在右。这道题就是利用了左右两个扫描指针同时比较,交换位置–>与快排的双向扫描分区法类似

题目2:查找第k小的数。大家都能想到的是直接调用Arrays.sort来排序后找索引就得了,但是这并不是最高效的方法,因为我们只是要查找数值位在第k位的数,并不需要把其他数也排好顺。根据快排分区的方法我们知道分区后主元的左侧元素都小于等于主元,而主元的右侧元素都大于主元,因此主元的位置其实也就是其数值在数组中的排位(即主元是数组中第(下标+1)小的数),因此只需要比较(主元下标+1)与k的大小:当(主元下标+1)=k则说明主元就是第k小的数;当(主元下标+1)>k则说明我们要找的第k小的数在主元左侧;否则在右侧。这题的分区方法与快排一致,最后可以递归实现在主元左侧/右侧查找。

题目3、4:寻找发帖水王、查找最小可用ID。题目3的方法2以及题目4的方法3都是利用了题目2的查找第k小的数,即间接运用快排的分区方法

很容易发现,与此类似的很多查找、排序的题目都可以运用快排的一些思路与方法来高效实现。掌握一种算法,并不是掌握算法本身而已,而是掌握一种思想,这样才能灵活运用。

发现自己真是有点啰嗦hh
这让我想起班主任,永远的"我只说三点",嗯没错,三点钟过去了

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