快速分割/快速划分
概念
快速分割是快速排序和快速选择的核心操作,因此单独拉出来说一说。
分割顾名思义,将东西分成多块,而快速分割,即是把一个数组通过分割线分成两个部分。这个分割线往往是数组第一个元素的值,分割完后,其中一部分的值都比分割线的值大,另一部分的值都比分割线的值小。至于具体分割完后是左大右小还是左小右大看我们的分割方式是升序还是降序的。
例如我们将一个无序的数组A[n]通过一次升序的快速分割,分割线A[0]的值在分割后会到A[i]处,将数组分成了A[0]到A[i-1] 和 A[i+1]到A[n-1]两部分,其中A[0]到A[i-1]的值都小于A[i],A[i+1]到A[n-1]的值都大于A[i]。那么此时A[i]的值也就是分割线的值,我们就可以称为这个无序数组中的第 i+1 小值(最小值称为第1小值,因此要 +1)。
过程
设我们有一个无序数组A[n],假设要做升序的快速分割,那么的操作有如下两种,具体原理差不多:
方法A:
- 设置两个变量l、r,排序开始的时候:l = 1,r = n-1。
- 以A[0]的值作为分割线的值,设 pivot = 0。
- 从 l 开始向后搜索,即由前开始向后搜索(l++),找到第一个大于A[0]的A[l]。
- 然后从 r 开始向前搜索,即由后开始向前搜索(r--),找到第一个小于A[0]的A[r]。
- 将A[l]和A[r]的值交换,此时A[1]到A[l]都小于A[0],A[r]到A[n-1]都大于A[0]。
- 然后我们把A[l+1]到A[r-1]这段重复第3,4,5步的操作,直到 l>r,结束循环。
- 上面操作结束后,A[1]到A[r]都小于A[0],A[l]到A[n-1]都大于A[0],我们将A[0]和A[r]的值交换,即可得到A[0]到A[r-1]都小于A[r],A[r+1]到A[n-1]都大于A[r]。分割成功,分割线下标为r,值为A[r],也就是一开始A[0]的值。
方法B:
- 设置两个变量l、r,排序开始的时候:l = 0,r = n-1。
- 以A[0]的值作为分割线的值,设 key = A[0]。注:此时 A[l] = A[0] = key。
- 从 r 开始向前搜索,即由后开始向前搜索(r--),找到第一个小于key的值A[r],将A[r]和A[l]的值交换,此时A[r] = key,且A[r]后面的数都大于key。
- l++,因为3操作后A[l]肯定是小于key的值,不需要多余判断了,直接从l+1开始即可。
- 从 l 开始向后搜索,即由前开始向后搜索(l++),找到第一个大于key的A[l],将A[l]和A[r]的值交换,此时A[l] = key,且A[l]前面的数都小于key。
- j++,同4。
- 重复第3-6步操作,直到 i = j,结束循环。那么此时A[i] = key,左边的数都小于key,右边的数都大于key,i即为分割线下标。
可以发现方法B是通过把分割线的值不断的交换位置来实现的,也更容易理解一些。
同时也可发现一次快速分割操作的时间复杂度为O(n)。
代码实现
方法A的代码如下:
/// <summary>
/// 快速分割
/// </summary>
/// <param name="array">排序数组</param>
/// <param name="left">起始下标</param>
/// <param name="right">结尾下标</param>
/// <param name="isAscend">是否是升序</param>
int QuickPartition(int[] array, int left, int right, bool isAscend = true)
{
int pivot = left; //分割线下标
int l = left + 1;
int r = right;
//将left+1到right间的元素进行分割
while (l <= r)
{
if (isAscend)
{
//升序操作
//从左边找到第一个大于分割线值的数
while (l <= r && array[l] <= array[pivot])
l++;
//从右边找到第一个小于分割线值的数
while (l <= r && array[r] >= array[pivot])
r--;
//上面两个循环最后会比较到l=r时,array[l]的值。
//如果此时array[l]<分割线的值,则l++,l=r+1,此时left+1到r的值小于分割线的值,r到right的值大于分割线的值
//否则r--,依旧l=r+1
//互换位置,这样left到l的值依旧小于分割线,r到right的值依旧大于分割线。我们只需要再处理i+1到r-1的部分即可。
if (l <= r && array[l] > array[pivot] && array[r] < array[pivot])
{
Swap(array, l++, r--);
//等价于
// Swap(array, l, r);
// l++;
// r--;
}
}
else
{
//降序操作,和升序相反
while (l <= r && array[l] >= array[pivot]) l++;
while (l <= r && array[r] <= array[pivot]) r--;
if (l <= r && array[l] < array[pivot] && array[r] > array[pivot])
Swap(array, l++, r--);
}
}
//一个比较需要思考的地方,要退出上面循环肯定是l>r,且l=r+1,那么就可以说明数组被分成了left+1到r和l到right两部分。
//且不管是升序还是降序,都是left+1到r的值都小于或大于l到right的值
//因此我们把分割线pivot(pivot = 0)和r的值相交换,可以保证0到r-1的值小于或大于array[r]的值,r+1到right的值大于或小于array[r]的值。
//下标r就是我们的最终分割线
Swap(array, pivot, r);
return r;
}
//交换数组中的值
void Swap(int[] nums, int index1, int index2) {
int temporary = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temporary;
}
方法B的代码如下:
int QuickPartition(int[] array, int left, int right, bool isAscend = true)
{
int key = array[left];
int l = left, r = right;
//l=r时退出循环,l或r即是分割线
while(l < r) {
//主要要先从右往左,再从左往右,因为每次从右往左时,array[l]的值都为key。
for(; l < r; r--) {
//从右往左,若升序找出第一个小于key的,降序则第一个大于key的。
if((isAscend && array[r] < key) || (!isAscend && array[r] > key)) {
//换key位置,原本array[l]=key变为array[r]=key。
array[l] = array[r];
array[r] = key;
l++;//此时array[l]肯定小于或大于key,我们后面可以直接从array[l+1]开始判断
break;
}
}
//上面的操作做完后array[r]=key,原理基本一样
for(; l < r; l++) {
if((isAscend && array[l] > key) || (!isAscend && array[l] < key)) {
array[r] = array[l];
array[l] = key;
r--;
break;
}
}
}
return l;
}
测试
升序测试
int[] array = new int[] { 10, 8, 11, 13, 5, 6, 1, 12, 3, 7 };
int index = QuickPartition(array, 0, array.Length - 1);
ShowArray(array);
Debug.Log($"升序分割后,10为第{index + 1}小的值");
//打印数组
void ShowArray(int[] array) {
System.Text.StringBuilder sb = new System.Text.StringBuilder();
sb.Append("分割后的数组:");
foreach(int v in array) {
sb.Append(string.Format("{0} ", v.ToString()));
}
Debug.Log(sb.ToString());
sb.Length = 0;
sb = null;
}
输出如下:
降序测试只需要将QuickPartition方法第四个参数设置为false即可。输出如下:
快速排序
概念
理解了快速分割后,快速排序理解起来就很简单了。我们一次分割后会将一个数组分成两部分,那么只要将这两部分再次进行分割,然后得到的部分再分割,无限递归进去,直到每次分割后的部分里只剩一个元素,即不能再分割了,就说明排序完成了。
在平均状况下,排序 n 个项目要Ο(n log n)次比较。在最好的情况下依旧为Ο(n log n),但最坏状况下则需要Ο(n * n)次比较,但这种状况并不常见。该排序是不稳定的。
注:排序的稳定性,即排序前,若有大小相同的元素,排序后其前后关系不变,称为排序稳定。
代码实现
//快速排序
void QuickSort(int[] array, bool isAscend = true)
{
QuickPartitionRecursion(array, 0, array.Length - 1, isAscend);
}
//快速分割递归
void QuickPartitionRecursion(int[] array, int left, int right, bool isAscend = true)
{
if (left >= right) return;//递归结束
int pivot = QuickPartition(array, left, right, isAscend);//进行一次快速分割
ShowArray(array);
//分割后的两部分进行递归
QuickPartitionRecursion(array, left, pivot - 1, isAscend);
QuickPartitionRecursion(array, pivot + 1, right, isAscend);
}
测试
int[] array = new int[] { 10, 8, 11, 13, 5, 6, 1, 12, 3, 7 };
QuickSort(array);
输出结果如下
快速选择
概念
快速排序需要把一串无序的数从大到小或从小到大排好,而快速选择则只需要选出第几大或第几小的数字。例如我们有一堆无序的数: ,我们要求出第
大的那个x的下标。
实际使用场景:在光线追踪的BVH(Bounding Volume Hierarchy)操作中,我们要将一堆位置不一的三角形按照位置进行两等分,即求出最中间的那个三角形的位置,小于这个位置的三角形分一组,大于或等于这个位置的三角形分一组即可。
常见的做法自然是我们将它们进行排序,然后第几大或第几小自然就是排序后的下标。但是我们知道我们其实并不需要知道除了要找的那个数外,其他数的顺序。而快速选择就是相比排序更简单的方法,它同样沿用着快速分割的思想。
前面我们知道快速排序使用快速分割时,要把分割后的两部分都递归去分割,但是快速选择不一样,它只需要递归进入其中的一部分即可(具体为什么只需要递归一部分,可见下面逻辑)。这降低了平均时间复杂度,从O(n log n)降至O(n),不过最坏情况仍然是O(n2)。
过程
假设我们有一个数组为A[0]……A[n-1],其为无序数组,要求出其第i大的元素。
前面我们知道一次降序的快速分割后,我们可以求出我们的分割线的下标,设为A[k],那么A[k]就是数组中第k+1大的元素(要是不理解,再看下快速分割)。就会出现如下几种情况:
- 若 k+1 = i,那就是我们要的结果。注:假设k=0,但是我们称之为第1大,因此需要+1。
- 若 k+1 > i,那么说明第i大的值还在0到k-1中,我们继续分割0到k-1这部分数据,而不需要管k+1到n-1这部分,因为肯定不在这里面了,这里也是和快速排序不相同的部分。
- 若 k+1 < i,那么说明第i大的值在k+1到n-1中,我们继续分割k+1到n-1这部分数据。
- 通过不停的往里分割,知道求到 k+1 = i 为止。
代码实现
/// <summary>
/// 快速旋转
/// </summary>
/// <param name="array">排序数组</param>
/// <param name="i">找第i个元素</param>
/// <param name="isAscend">是否是升序,true的话即第i小,false第i大</param>
int findIth(int[] array, int i, bool isAscend = true) {
int left = 0, right = array.Length - 1;
while (true) {
int k = QuickPartition(array, left, right, isAscend);//一次分割后,array[k]即第k大或小的数
if (i == k + 1)
return array[k];
if (i > k + 1)
left = k + 1;//继续分割k+1到right部分
else
right = k - 1;//继续分割left到k-1部分
}
}
测试
int[] array = new int[] { 10, 8, 11, 13, 5, 6, 1, 12, 3, 7 };
Debug.Log($"数组中第5小的数为{findIth(array, 5)}");
输出结果
冒泡排序
概念
顾名思义,冒泡排序的过程就像冒泡泡一样,通过依次比较相邻的数据,让最大的数据慢慢的冒到最后面(假设递增),来完成排序。在平均状况下,排序 n 个项目要Ο(n * n)次比较。在最好的情况下只需Ο(n)次,最坏状况下仍为Ο(n * n)。该排序是稳定的。
过程
假设我们要进行递增排序的数组为A[0]……A[n-1]
- 首先我们要比较A[0]和A[1]的大小,使其小的在前,大的在后。
- 然后我们继续比较A[1]和A[2]的大小,使其小的在前,大的在后。
- ...
- 直到比较完A[n-2]和A[n-1]的大小,使其小的在前,大的在后。
- 这样一轮下来后,数组中最大的值将会到A[n-1]当中(类似冒泡泡)。
- 接着我们继续从A[0]……A[n-2]中依次比较大小(重复1234步骤),其中最大的值会到A[n-2]中。
- 如此循环,直到全部比较结束(n-m = 0)。
代码实现
/// <summary>
/// 快速排序
/// </summary>
/// <param name="array">排序数组</param>
void BubbleSort(int[] array) {
int value;
//last的值,即为此次循环最大值要放的位置
for(int last = array.Length - 1; last > 0; last--) {
//通过一遍循环,最大的值到最后
for(int index = 0; index < last; index++) {
//小的在前,大的在后
if(array[index] > array[index + 1]){
value = array[index + 1];
array[index + 1] = array[index];
array[index] = value;
}
}
ShowArray(array);
}
}
输出结果如下
上面的结果会发现,即使排序在中途由于运气好,其实已经完成了,但是代码以及进行了后续的循环对比,所以我们可以对代码进行优化,即当相邻对比的全部结果发现,并没有大的值在小的值前面的时候,即排序完成,退出循环。
/// <summary>
/// 快速排序
/// </summary>
/// <param name="array">排序数组</param>
void BubbleSort(int[] array) {
int value;
bool isChange;
//last的值,即为此次循环最大值要放的位置
for(int last = array.Length - 1; last > 0; last--) {
//通过一遍循环,最大的值到最后
isChange = false;
for(int index = 0; index < last; index++) {
//小的在前,大的在后
if(array[index] > array[index + 1]){
value = array[index + 1];
array[index + 1] = array[index];
array[index] = value;
isChange = true;
}
}
if(!isChange) {
//没有换位,排序完成
return;
}
ShowArray(array);
}
}
输出结果如下
堆排序
概念
堆(heap)
堆是计算机科学中一类特殊的数据结构的统称。堆通常是一个可以被看做一棵树的数组对象。堆总是满足下列性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树。
堆是非线性数据结构,相当于一维数组,有两个直接后继,假设数组下标0开始,那么下标 i 对应的两个子节点的下标为 2*i+1 和 2*i+2,同理,下标 k 对应的父节点即为 (k - 1) / 2 ,堆的定义如下:
n个元素的序列,下标0开始,即 当且仅当满足下关系时,称之为堆。
,
或者
,
,(i = 0, 1, 2 ... (n - 1) / 2)
堆排序
即利用堆的性质(根节点一定是最大或最小的值),通过一步步取出最大或最小值,来进行的排序。其时间复杂度都为O(n log n)。该排序是不稳定的。
过程
假设我们要进行递增排序的数组为A[0]……A[n-1]
- 首先我们要把数组变为堆,即满足 A[i] > A[2*i+1] 与 A[i] > A[2*i+2] 。
- 我们从后(n-1)往前(0),若n-1为奇数,则 n-1和n-2同为 ((n-1)-1)/2 的子节点。我们比较他们三的大小,将最大值放在((n-1)-1)/2的位置上。若n-1为偶数,则其父节点((n-1)-1)/2 只有它一个子节点,我们比较他俩的大小,将最大值放在((n-1)-1)/2的位置上。
- 若n-1为奇数,n -= 2(奇数),若n-1为偶数,n --(奇数),然后重复2的步骤。直至n = 0,此时数组已经成为了堆,即父节点的值都 >= 子节点的值
- 此时A[0]即为数组中最大的值,我们将A[0] 和 A[n-1] 互换。
- 然后将A[0]……A[n-2]数组重复2,3,4步骤。直至n - m=0。
代码实现
int maxValue;//临时存放父节点和子节点的最大值
/// <summary>
/// 堆排序
/// </summary>
/// <param name="array">排序数组</param>
void HeapSort(int[] array) {
for(int i = array.Length - 1; i > 0; i--) {
ArrayToHeap(array, i);
//交换头尾
maxValue = array[0];
array[0] = array[i];
array[i] = maxValue;
ShowArray(array);
}
}
/// <summary>
/// 将数组转为堆(递归方法)
/// </summary>
/// <param name="array">要转换的数组</param>
/// <param name="sonIndex">子节点坐标</param>
void ArrayToHeap(int[] array, int sonIndex) {
if(sonIndex == 0) {
return;
}
if(sonIndex / 2 == 0) {
//偶数,父节点只有他一个子节点
if(array[(sonIndex - 1) / 2] < array[sonIndex]) {
//子节点大于父节点
maxValue = array[sonIndex];
array[sonIndex] = array[(sonIndex - 1) / 2];
array[(sonIndex - 1) / 2] = maxValue;
}
ArrayToHeap(array, --sonIndex);
} else {
//偶数,sonIndex和sonIndex-1同为(sonIndex - 1)/2的子节点
if(array[sonIndex] > array[sonIndex - 1]) {
if(array[(sonIndex - 1) / 2] < array[sonIndex]) {
//array[sonIndex] 为三个中的最大值
maxValue = array[sonIndex];
array[sonIndex] = array[(sonIndex - 1) / 2];
array[(sonIndex - 1) / 2] = maxValue;
}
} else {
if(array[(sonIndex - 1) / 2] < array[sonIndex - 1]) {
//array[sonIndex - 1] 为三个中的最大值
maxValue = array[sonIndex - 1];
array[sonIndex - 1] = array[(sonIndex - 1) / 2];
array[(sonIndex - 1) / 2] = maxValue;
}
}
ArrayToHeap(array, sonIndex-=2);
}
}
输出结果如下
归并排序
概念
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列。即先使每个子序列有序,再使子序列段间有序,将两个有序表合并成一个有序表。
- 假设数组为{6, 3, 9, 5, 1, 7, 4}
- 我们先将其分为{6, 3, 9, 5} 与 {1, 7, 4}
- 然后再分为{6, 3},{9, 5},{1, 7} 和 {4}
- 此时已经都拆分完毕,我们根据排序要求(例如升序)对每个子序列进行比大小,得到{3, 6},{5, 9},{1, 7} 和 {4}
- 后续我们需要进行将子序列进行合并操作,先得到{3, 5, 6, 9} 与 {1, 4, 7}
- 最后得到{1, 3, 4, 5, 6, 7, 9}完成排序
注:如何将有序的子序列{3, 6}和有序的子序列{5, 9}合并为有序的序列{3, 5, 6, 9},我们知道每个序列中的第一个元素肯定是最小的,所以我们先比较3 和 5的值,3 < 5将3先放入合并后的序列中,然后比较 6 和 5的值,6 > 5将5放入,最后比较6 和 9 的值,6 < 9依次放入6和9。
过程
假设我们要进行递增排序的数组为A[0]……A[n-1]
- 首先创建一个临时数组B0]……B[n-1]用于存放合并后的有序数据
- 将原数组分割成两个序列A[0]……A[(n-1)/2]和A[(n-1)/2+1]……A[n-1]
- 将分割后的数组继续分割,直至无法分割为止。
- 将分割后的序列按照分割的顺序,依次合并成有序的序列。
代码实现
/// <summary>
/// 归并排序
/// </summary>
/// <param name="array">需要排序的数组</param>
void MergeSort(int[] array) {
//生成一个临时数组,用于存放数据
int[] temp = new int[array.Length];
Split(array, 0, array.Length - 1, temp);
}
/// <summary>
/// 分割数组,通过递归的方式
/// </summary>
/// <param name="array">原始数组</param>
/// <param name="leftStart">需分割序列的开始下标</param>
/// <param name="rightEnd">需分割序列的结束下标</param>
/// <param name="temp">临时数组</param>
void Split(int[] array, int leftStart, int rightEnd, int[] temp) {
if(leftStart == rightEnd) {
//开始下标等于结束下标,无法分割
return;
}
//获取分割点,rightStart为分割后右边序列的起始下标,rightStart - 1 即为左边序列的结束下标
int rightStart = (rightEnd - leftStart) / 2 + leftStart + 1;
//递归分割,直至无法分割
Split(array, leftStart, rightStart - 1, temp);
Split(array, rightStart, rightEnd, temp);
//合并
Merge(array, leftStart, rightStart, rightEnd, temp);
ShowArray(array);
}
/// <summary>
/// 合并两个序列,使合并后的序列有序
/// </summary>
/// <param name="array">原始数组</param>
/// <param name="leftStart">左边序列的开始下标</param>
/// <param name="rightStart">右边序列的开始下标(左边边序列的结束下标即为 rightStart - 1)</param>
/// <param name="rightEnd">右边序列的结束下标</param>
/// <param name="temp">临时数组</param>
void Merge(int[] array, int leftStart, int rightStart, int rightEnd, int[] temp) {
//左序列下标i开始,右序列下标j开始。临时数组下标从k即,左序列的开始下标开始
//若左序列下标i的值小于右序列下标j的值,将下标i的值插入temp[k]中,i++;k++。反之,将下标j的值插入temp[k]中,j++;k++。
//若其中一个序列的值已全部插入temp中,则将另个序列剩下的值依次插入temp中。
for(int i = leftStart, j = rightStart, k = i; k <= rightEnd; k++) {
if(j > rightEnd || (i < rightStart && array[i] <= array[j])) {
temp[k] = array[i];
i++;
} else if(i == rightStart || (j <= rightEnd && array[i] > array[j])) {
temp[k] = array[j];
j++;
}
}
//temp中排序好的合并序列的值依次赋给原数组
while(leftStart <= rightEnd) {
array[leftStart] = temp[leftStart];
leftStart++;
}
}
输出结果如下
分割后的最小序列为 {8, 10} {11} {13, 5} {6, 1} {12} {3, 7},图中红色框选部分为每次合并后的有序序列。