文章目录
分治思想
- 分治法(divide and conquer,C&D):将问题划分成若干个规模较小而结构与原问题一致的子问题;递归地解决这些问题,然后再合并其结果,就得到原问题的解。
- 容易确定运行时间,是分治算法的优点之一
- 分治模式在每一层递归上都有的三个步骤:
①分解
:将原问题分解成一系列子问题
②解决
:递归地解决各子问题,子问题足够小时,则直接有解(可以人工算出)
③合并
:将子问题的结果合并成原问题的解 - 分治的关键点:
① 原问题可以一直分解为形式相同的子问题,当子问题规模较小时,可以自然求解,如排序:当划分最后只剩下一个元素,而一个元素本身有序
② 子问题的解可以通过合并可以得到原问题的解
③ 子问题的分解以及解的合并一定是比较简单的,否则分解和合并所花的时间可能超出暴力解法,得不偿失
快速排序–3种实现方式
- 快速排序运用了分治思想
①分解
:将数组arr[ l…r ]划分为两个子数组arr[ l…p-1 ]和arr[ p+1…r ]。使得arr[ p ]为大小居中的数,即左侧arr[ l…p-1 ]中的每个元素都小于等于它;而右侧arr[ p+1…r ]中的每个元素都大于等于它。其中计算下标p也是划分过程的一部分。
②解决
:通过递归调用快速排序,对子数组arr[ l…p-1 ]和arr[ p+1…r ]进行排序。
③合并
:因为子数组都是原址排序的,所以不需要合并。即A[ l…r ]已经有序。 - 快排的重点在于划分区域:将小于中间值的元素全放在左边,大于中间值的元素放在右边
- 本文列出了三种快速排序的方法:
① 单向扫描分区法
② 双向扫描分区法
③ 三指针扫描分区法
前期准备:自定义数组工具MyArrays类
- 自定义了一个数组工具类MyArrays,包含几个数组处理函数,方便在快速排序中调用
①int[] getArr(int length, int min, int max)
:自动生成一个数组,方便测试时调用
②void swap(int[] arr, int i, int j)
:交换数组两元素的位置
③void insertSort(int[] arr, int l, int r)
:插入排序
④int getIndex(int[] arr, int target)
:获取指定元素的索引
⑤int getMidValue(int[] arr, int l, int r)
:获取数组的中值(是元素值位于中间的元素,不是索引位置在中间) - 具体代码见下:
/**
* 自定义数组工具类
*
* @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;
}
/**
* 实现插入排序,对数组的索引位置于[l,r]内的部分进行插入排序
*
* @param arr---待部分排序的数组
* @param l---待排序部分的左边界
* @param r---待排序部分的右边界
*/
public static void insertSort(int[] arr, int l, int r) {
for (int i = l + 1; i < r + 1; i++) {
// 定义当前数组最后一个元素的索引位置
int lastIndex = i - 1;
// 定义当前待插入的新值
int value = arr[i];
while (lastIndex >= l && arr[lastIndex] > value) {
// 当带插入的value小于当前数组最后一个元素时,最后一个元素后挪
arr[lastIndex + 1] = arr[lastIndex];
// 指针前移,循环判断,所有大于value的元素均后挪
lastIndex--;
}
// 至此,当前arr[lastIndex]<value,所以应当插入在这个元素后边
arr[lastIndex + 1] = value;
}
}
/**
* 获取数组中某元素值的索引,当数组中不含此元素时返回-1
*
* @param arr---数组
* @param target---目标值
* @return int---返回数组目标值的索引
*/
public static int getIndex(int[] arr, int target) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) {
return i;
}
}
return -1;
}
/**
* 获取数组的中值
*
* @param arr---待获取中值的数组
* @param l---左边界
* @param r---右边界
* @return int---返回数组的中值
*/
public static int getMidValue(int[] arr, int l, int r) {
int size = r - l + 1; // 数组长度
// 每5个元素分为一组,groupSize记录组数
int groupSize = (size % 5 == 0) ? (size / 5) : (size / 5 + 1);
// 创建midValues数组存放每组的中值
int[] midValues = new int[groupSize];
// 循环获取每组的中值
for (int i = 0; i < groupSize; i++) {
if (i == groupSize - 1) {
insertSort(arr, l + i * 5, r);
midValues[i] = arr[(l + r) / 2];
} else {
insertSort(arr, l + i * 5, l + i * 5 + 4);
midValues[i] = arr[l + i * 5 + 2];
}
}
// 对存放每组中值的数组进行插入排序
insertSort(midValues, 0, midValues.length - 1);
// 返回中值
return midValues[midValues.length / 2];
}
}
方式一:单向扫描分区法
-
分区方法:
int single_Portion(int[] arr, int l, int r)
① 定义数组第一个元素为主元
② 定义左指针指向数组第二个元素,右指针指向数组最后一个元素(最终目的是使左指针左侧都是小于等于主元的元素,右指针右侧都是大于主元的元素)
③ 判断左指针所指元素是否小于等于主元
1.若小于等于主元:左指针右移一位
2.若大于主元:将左指针所指元素与右指针所指元素交换位置,且右指针左移一位
④ 直到左指针超过右指针,此时左指针指向大于主元的第一个元素,右指针指向小于等于主元的最后一个元素
⑤ 将主元与右指针所指元素交换位置,此时即分区完毕,形成主元左侧元素小于等于主元,主元右侧元素大于主元 -
快速排序方法(递归实现):
void single_QuickSort(int[] arr, int l, int r)
① 调用分区方法Portion(),获取主元位置
② 将主元左侧与右侧分别进行快速排序,即实现了数组的排序 -
图解:
-
具体代码见下:
/**
* 快速排序-单向扫描分区法
*
* @author LRRacac
*
*/
public class Single_QuickSort {
/**
* 快速排序函数
*
* @param arr---待排序的数组
* @param l---待排序数组的左边界
* @param r---待排序数组的右边界
*/
public static void single_QuickSort(int[] arr, int l, int r) {
// 当左边界>=右边界时结束程序
if (l >= r) {
return;
}
// 获取分区后的主元位置
int pivotIndex = single_Portion(arr, l, r);
// 对主元左侧的所有元素进行快速排序
single_QuickSort(arr, l, pivotIndex - 1);
// 对主元右侧的所有元素进行快速排序
single_QuickSort(arr, pivotIndex + 1, r);
}
/**
* 划分区域函数-单向扫描分区法 划分数组中元素的排列,返回主元pivot的位置
* 形成:在pivot左侧元素值都小于pivot,在pivot右侧元素值都大于pivot
*
* @param arr---待划分的数组
* @param l---待划分数组的左边界
* @param r---待划分数组的右边界
* @return int---返回主元pivot的位置
*/
public static int single_Portion(int[] arr, int l, int r) {
// 初始化主元为l左边界元素
int pivot = arr[l];
// 定义左指针-扫描指针,初始指向为[l,r]区间第二个元素
int scan_left = l + 1;
// 定义右指针,初始指向为[l,r]区间最后一个元素
int Scan_right = r;
/*
* 边界判断: 当左指针与右指针相遇时(指向同一个元素,则该元素为最后一个待判断元素)
* 若该元素值<pivot,则左指针右移scan_left++
* 若该元素值>pivot,则右指针左移scan_right--
* 则不论是哪种情况scan_right都小于scan_left 即当scan_left >
* scan_right时跳出循环
*/
while (scan_left <= Scan_right) {
if (arr[scan_left] < pivot) {
// 当左指针所在元素小于pivot时指针右移
scan_left++;
} else {
// 当左指针所在元素大于pivot时,与右指针所指元素交换位置---即将大于pivot的元素放在pivot右边
MyArrays.swap(arr, scan_left, Scan_right);
// 右指针左移
Scan_right--;
}
}
// 当前右指针位于最后一个<=主元pivot的元素上,即主元应该所在的位置
MyArrays.swap(arr, l, Scan_right); // 此时形成在主元pivot的左侧都是小于pivot的元素;右侧都是大于pivot的元素
// 返回主元的位置
return Scan_right;
}
}
方式二:双向扫描分区法
- 分区方法概述(大部分与单向扫描法类似,不再赘述):
int double_Portion(int[] arr, int l, int r)
① 与单向扫描分区法大体类似,区别在于:双向扫描分区法的左指针与右指针同时扫描
② 左指针往右扫描,直到遇到第一个大于主元的元素停下;右指针往左扫描,知道遇到第一个小于等于主元的元素停下
③ 将两指针所指元素交换位置后,两指针继续扫描重复② - 快速排序函数:与单向扫描分区法相同
void double_QuickSort(int[] arr, int l, int r)
- 图解:
- 具体代码见下:
/**
* 快速排序-双向扫描分区法
* @author LRRacac
*
*/
public class Double_QuickSort {
/**
* 快速排序函数
*
* @param arr---待排序的数组
* @param l---待排序数组的左边界
* @param r---待排序数组的右边界
*/
public static void double_QuickSort(int[] arr, int l, int r) {
// 当左边界>=右边界时结束程序
if (l >= r) {
return;
}
// 获取分区后的主元位置
int pivotIndex = double_Portion(arr, l, r);
// 对主元左侧的所有元素进行快速排序
double_QuickSort(arr, l, pivotIndex - 1);
// 对主元右侧的所有元素进行快速排序
double_QuickSort(arr, pivotIndex + 1, r);
}
/**
* 划分区域函数-双向扫描分区法
* 划分数组中元素的排列,返回主元pivot的位置
* 形成:在pivot左侧元素值都小于pivot,在pivot右侧元素值都大于pivot
*
* @param arr---待划分的数组
* @param l---待划分数组的左边界
* @param r---待划分数组的右边界
* @return int---返回主元pivot的位置
*/
public static int double_Portion(int[] arr, int l, int r) {
// 初始化主元为l左边界元素
int pivot = arr[l];
// 定义左指针-扫描指针1,初始指向为[l,r]区间第二个元素
int scan_left = l + 1;
// 定义右指针-扫描指针2,初始指向为[l,r]区间最后一个元素
int scan_right = r;
while (scan_left <= scan_right) {
while (scan_left <= scan_right && arr[scan_left] <= pivot) // 循环退出时,scan_left一定指向着第一个大于主元的元素位置
scan_left++;
while (scan_left <= scan_right && arr[scan_right] > pivot)// 循环退出时,scan_right一定指向着最后一个小于等于主元的元素位置
scan_right--;
// 当左指针第一次指向>pivot的元素,且右指针第一次指向<=pivot的元素时,交换两指针的元素
if (scan_left < scan_right)
MyArrays.swap(arr, scan_left, scan_right);
}
// 此时scan_left指向第一个大于主元的元素位置,scan_right指向着最后一个小于等于主元的元素位置
// 且scan_right指向的位置即是主元pivot应该在的位置
MyArrays.swap(arr, l, scan_right);
// 返回主元的位置
return scan_right;
}
}
方式三:三指针分区法
- 分区方法概述:
int[] tri_Portion(int[] arr, int l, int r)
① 如果数组中存在很多与主元等值的元素,那么在对主元左、右侧进行快速排序时,可以不用对那些与主元等值的元素进行排序
② 增设一个等值指针,来标记第一个与主元相等的元素,最终将区域划分为三部分:
1.左侧为所有小于主元的元素
2.中间为所有等于主元的元素
3.右侧为所有大于主元的元素 - 快速排序方法概述:
void tri_QuickSort(int[] arr, int l, int r)
① 将第一个等于主元的元素的左侧进行快排,将最后一个等于主元的元素的右侧进行快排 - 图解:
- 具体代码见下:
/**
* 快速排序-三指针分区法
* @author LRRacac
*
*/
public class Tri_QuickSort {
/**
* 快速排序函数
*
* @param arr---待排序的数组
* @param l---待排序数组的左边界
* @param r---待排序数组的右边界
*/
public static void tri_QuickSort(int[] arr, int l, int r) {
// 当左边界>=右边界时结束程序
if (l >= r) {
return;
}
// 获取分区后的主元的左边界和右边界
int[] arr1 = tri_Portion(arr, l, r);
// 对主元左侧的所有元素进行快速排序
tri_QuickSort(arr, l, arr1[0] - 1);
// 对主元右侧的所有元素进行快速排序
tri_QuickSort(arr, arr1[1] + 1, r);
}
/**
* 划分区域函数-三指针分区法
* 划分数组中元素的排列,返回主元pivot的位置
* 形成:在pivot左侧元素值都小于pivot,在pivot右侧元素值都大于pivot
*
* @param arr---待划分的数组
* @param l---待划分数组的左边界
* @param r---待划分数组的右边界
* @return int[]---返回一个int类型数组,两个元素值分别为主元的左边界以及右边界
*/
public static int[] tri_Portion(int[] arr, int l, int r) {
// 初始化主元为l左边界元素
int pivot = arr[l];
// 定义左指针-扫描指针,初始指向为[l,r]区间第二个元素
int scan_left = l + 1;
// 定义等指针,初始指向为[l,r]区间第二个元素
int scan_equal = l + 1;
// 定义右指针,初始指向为[l,r]区间最后一个元素
int scan_right = r;
while (scan_left <= scan_right) {
while (scan_left <= scan_right && arr[scan_left] < pivot) {
// 当元素小于主元pivot时,左指针与等指针均右移
MyArrays.swap(arr, scan_left, scan_equal);
scan_left++;
scan_equal++;
}
while (scan_left <= scan_right && arr[scan_left] == pivot) {
// 左指针右移
scan_left++;
}
while (scan_left <= scan_right && arr[scan_left] > pivot) {
// 当元素大于主元时,交换左指针所指元素与右指针所指元素位置
if (scan_left < scan_right) {
MyArrays.swap(arr, scan_left, scan_right);
}
// 右指针左移,左指针右移
scan_right--;
}
}
// 将主元与等指针前一个元素交换位置,并且将等指针-1
MyArrays.swap(arr, --scan_equal, l);
// 用数组arr1来存放与主元等值的元素的左边界和右边界
int[] arr1 = { scan_equal, scan_right };
// 返回数组arr1
return arr1;
}
}
测试代码
import java.util.Arrays;
/**
* 快速排序测试类
* @author LRRacac
*
*/
public class QuickSortTest {
public static void main(String[] args) {
/*
* 测试---单向扫描分区法
*/
// 获取一个长度为10,元素值介于[1,30]之间的数组
int[] arr = MyArrays.getArr(10, 1, 30);
System.out.println("单向——快速排序前:" + Arrays.toString(arr));
// 对数组进行快速排序
Single_QuickSort.single_QuickSort(arr, 0, arr.length - 1);
System.out.println("单向——快速排序后:" + Arrays.toString(arr));
/*
* 测试---双向扫描分区法
*/
int[] arr1 = MyArrays.getArr(10, 1, 30);
System.out.println("双向——快速排序前:" + Arrays.toString(arr1));
Double_QuickSort.double_QuickSort(arr1, 0, arr1.length - 1);
System.out.println("双向——快速排序后:" + Arrays.toString(arr1));
/*
* 测试---三指针分区法
*/
int[] arr2 = MyArrays.getArr(10, 1, 50);
System.out.println("三指针——快速排序前:" + Arrays.toString(arr2));
Tri_QuickSort.tri_QuickSort(arr2, 0, arr2.length - 1);
System.out.println("三指针——快速排序后:" + Arrays.toString(arr2));
}
}
- 结果展示
快速排序–3种优化方式
- 上述三种快速排序的分区方法(单向扫描分区、双向扫描分区以及三指针分区法)都是直接设定主元为数组第一个元素,如果要让快速排序起到较好的作用,则要尽可能使主元位于数组元素的中间部分。假设最极端的一种情况,数组为{30,1,5,3,8},主元直接为数组最大元素,实际上递归操作的部分只剩下了左侧,快排没有起到O(nlogn)的效果。
- 因此下面列出了三种优化方式
① 方式1:三点中值法- - -针对主元选择的优化
② 方式2:绝对中值法- - -针对主元选择的优化
③ 方式3:当数组长度较小时,直接采用插入排序- - -针对排序的优化
优化一:三点中值法
- 三点中值法:即取数组左边界元素、中间位置元素以及右边界元素,进行比较,取三者的中值定为主元。三点中值法是实际开发中使用得比较多的,虽然具有一定的随机性,但是比下文即将提到的绝对中值法要简便,属于O(1)级别,但是绝对中值的优化本身就占用了O(n)
- 优化代码见下(仅需改动划分区域函数):
/**
* 划分区域函数-单向扫描分区法Pro
* 三点中值法优化版本
*
*/
public static int single_Portion(int[] arr, int l, int r) {
int scan_left = l + 1;
int Scan_right = r;
/*
* 三点中值法优化代码(见下)
*/
// 定义中值索引,并初始化中值索引为-1
int midValueIndex = -1;
// 定义数组中间元素索引
int midIndex = (l + r) / 2;
if ((arr[l] >= arr[midIndex] && arr[l] <= arr[r]) || (arr[l] <= arr[midIndex] && arr[l] >= arr[r])) {
midValueIndex = l;
} else if ((arr[midIndex] >= arr[l] && arr[midIndex] <= arr[r])
|| (arr[midIndex] <= arr[l] && arr[midIndex] >= arr[r])) {
midValueIndex = midIndex;
} else {
midValueIndex = r;
}
// 至此中值索引已经获取,为不影响后面程序,将中值交换到首位
MyArrays.swap(arr, l, midValueIndex);
// 定义主元为第一个元素(此时第一个元素及为三点中的中值)
int pivot = arr[l];
/*
* 三点中值法优化代码(见上)
*/
while (scan_left <= Scan_right) {
if (arr[scan_left] < pivot) {
scan_left++;
} else {
MyArrays.swap(arr, scan_left, Scan_right);
Scan_right--;
}
}
MyArrays.swap(arr, l, Scan_right);
return Scan_right;
}
优化二:绝对中值法
- 绝对中值法:取数组中真正的中值作为主元。取数组中值的代码放在了MyArrays工具类中(上文有提到,可以自行查看)。
- 优化代码见下(仅需改动划分区域函数):
/**
* 划分区域函数-单向扫描分区法Pro
* 绝对中值法优化版本
*
*/
public static int single_Portion(int[] arr, int l, int r) {
int scan_left = l + 1;
int Scan_right = r;
/*
* 绝对中值法优化代码(见下)
*/
// 获取绝对中值---使用了自定义数组工具类中的方法
int midValue = MyArrays.getMidValue(arr, l, r);
// 获取绝对中值的索引---使用了自定义数组工具类中的方法
int midValueIndex = MyArrays.getIndex(arr, midValue);
// 为不影响后续代码,将中值交换至首位
MyArrays.swap(arr, l, midValueIndex);
int pivot = arr[l];
/*
* 绝对中值法优化代码(见上)
*/
while (scan_left <= Scan_right) {
if (arr[scan_left] < pivot) {
scan_left++;
} else {
MyArrays.swap(arr, scan_left, Scan_right);
Scan_right--;
}
}
MyArrays.swap(arr, l, Scan_right);
return Scan_right;
}
优化三:待排序列较短时,直接用插入排序
- 大家都知道,快排的复杂度为O(nlogn),是在n趋于一个较大的数时估算而来的,但实际上应该是n*(logn + 1);而插入排序的复杂度为O(n^2),实际上是n(n-1)/2
当n=16时:快排n*(logn + 1) = 16*(4+1) - - - 插入排序n(n-1)/2 = 1615/2 - - - 快排更快
当n=8时:快排n(logn + 1) = 8*(3+1) - - - 插入排序n(n-1)/2 = 8*7/2 - - - 插入排序更快
因此,在数组长度较小时(长度小于或等于8时),直接使用插入排序来优化排序 - 优化代码见下(仅需改动快速排序函数部分):
/**
* 快速排序函数Pro
* 优化:待排序列较短时,直接用插入排序
*/
public static void single_QuickSort(int[] arr, int l, int r) {
if (l >= r) {
return;
}
/*
* 待排序列较短时,直接用插入排序,优化代码(见下)
*/
if (r - l + 1 <= 8) {
// 当数组长度小于等于8时直接使用插入排序
MyArrays.insertSort(arr, l, r);
} else {
int pivotIndex = single_Portion(arr, l, r);
single_QuickSort(arr, l, pivotIndex - 1);
single_QuickSort(arr, pivotIndex + 1, r);
}
}