一、快排(QuickSort)
原始的快排是通过在数组中进行一次Partition操作:取最后一个数作为基准,将小于等于这个基准的数放在数组的左边,大于这个基准的放在右边。
如:int[] a = new int {2,5,6,1,3}
怎么操作 达到分类效果呢?
定义两个变量less,more 表示小于等于基准数区间的右边界,大于基准数的区间的左边界。初始化时 让less指向要Partition的数组的最左端-1表示还没有小于等与基准值的数。让more 指向要Partition的数组的最右端+1表示还没有大于基准值的数。
用一个变量 l 指向当前要操作的数组的数,并分成两种情况操作:
1、a[l] <= 基准时 让a[l]和 小于等于区间右边界 +1下标的数字做交换,然后让小于等于区间向右移动一格,并且让 l 指向数组下一位。也就是进行 a[l] 和 a[less+1]的swap操作 然后 less++,l++。
2、a[i]>基准时 让a[i]和 大于区间的左边界-1下标的数字做交换,然后让大于等于区间向左移动一格,此时游标 l 不动(因为大于等于区间左边界-1的数字我们还没遍历到)。也就是进行a[l]和a[more-1]的swap操作,然后more--,l不动。
最后再把基准数和大于等于区间的第一个交换 得到的数组就是分成两部分的数组。
快排就是通过递归Partition操作 来把一段数组分成小于等于基准数的部分和大于基准数的部分(这两个部分内部不一定是排好序的!),然后再吧分成两部分的数组再进行Partition操作,一直递归到最后剩一个数字的时候返回他自己。
以上用左神(强推b站这个人的数据结构算法教程,我跟着他学的)的说法是快排1.0
时间复杂度o(n^2)
稍微加快一点速度的快排2.0则与快排1.0不同的地方是在于,分区域的时候把等与基准值的数也分出一个区间,也就是一共三个区间。每次递归只要在大于和小于区间进行再次迭代,等于区间就不需要操作了。 这样会稍微快一点 但是这样快排的最差时间复杂度依旧是 o(n^2)。
这是因为如果你输入的数据本身就是有序的,例如123456789, 每次取的数都是最右边最大的数。导致每次分区间都只少了一个数字,这样就变成和冒泡排序一样的o(n^2)的算法了。
那么怎么解决这个问题呢?
快排3.0版本:和2.0的区别在于Partition选取基准值是以数组区间内随机一个下标的数。
这样区间每个数被当做基准值的概率都是一样的,最终由复杂的数学推导可以知道,3.0版本的快排的时间复杂度是O(NlogN)。
以下是快排代码:
static void Main(string[] args)
{
//int[] a = new int[] { 5, 3,3, 6, 2, 4 };
int[] a = new int[] { 1, 3, 3, 5, 5, 2, 2, 7, 9, 2 };
//Sort(a, 0, a.Length - 1);
QuickSort(a,0,a.Length-1);
foreach(int temp in a)
{
Console.WriteLine(temp);
}
}
static void QuickSort(int[] arr,int l,int r)
{
if (l < r)
{
Random random = new Random();
//交换头尾
Swap(arr, l + random.Next(0, r - l + 1), r);
//定义一个长度为2的数组来存储相等区域的左边界和右边界下标
int[] p = Partition(arr, l, r);
QuickSort(arr, l, p[0]-1);
QuickSort(arr, p[1] + 1, r);
}
}
static int[] Partition(int[] arr, int l ,int r)
{
int less = l - 1;//指向小于区间的最右
int more = r;//指向大于区间的最左
while (l < more)
{
if(arr[l] < arr[r])
{
Swap(arr, ++less, l++);
}else if(arr[l] > arr[r])
{
Swap(arr, --more, l);
}
else
{
l++;
}
}
//返回前注意把最后一个节点和大于区间的最前面一个数交换
Swap(arr, more, r);
return new int[] { less + 1, more };
}
static void Swap(int[] arr,int l,int r)
{
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
}
二、堆排序
说堆排序之前要先说说大堆树:
大堆树是指一个树里面每个节点都比自己的子节点要大的完全二叉树,小堆树则相反。
我们可以把一个数组 逻辑表示 成大堆树的形式:2,3,5 转化成的树是以2为根节点,3,5分别为左子节点和右子节点。转化为大堆树则是 以5位根节点,3,2分别为左子节点和右子节点的树。我们把数组下标和树对应,下标0,1,2分别表示树的5,3,2节点。
我们可以由此得出:大堆树里的每个节点的父节点下标是这个节点下标(index-1)/2,而这个节点的左、右子节点下标分别是 index*2+1,index*2+2。
插入操作:
当我们在一个大堆树中的某个位置插入一个数时,是要让它和父亲节点、左右儿子节点比较。若比父节点大则交换两个数的位置并往上循环比较直到不大于父亲节点;若比左右儿子节点中大的一个小,则跟最大的交换,然后往下循环比较直到没有儿子节点或者儿子节点都没自己大。
由此,我们可以通过将一个数组转化成大堆树,再对大堆树进行排序,得到排序后的数组。
大堆树的排序操作:
我们把大堆树的长度记作HeapSize,并且初始值为1即数组的第一个数字;
我们可以遍历整个数组,每次遍历把当前数组元素通过 插入操作 并且插入位置是树的最后一位来把数组逻辑转换为大堆树。
因为大堆树的第一个一定是最大的,所以可以把大堆树的根节点和大堆树最后一个节点作交换,结果一定会把大堆树里面最大的一个数放在最后面,然后把大堆树的heapsize-- 由此这个交换后的位置离开大堆树结构也就是实际上在数组的最后一位;由于进行过交换,我们相当于对大堆树的根节点进行了一次插入操作,经过插入后我们再吧树转化为大堆树之后,大堆树的根节点就又是最大的值了,只不过比起上一次的数节点个数少了一位。循环这个操作就可以把数组排序好。
代码如下:
static void Main(string[] args)
{
int[] a = new int[] { 5, 4, 2, 1, 6, 9, 0 };
HeapSort(a);
foreach (int item in a)
{
Console.WriteLine(item);
}
}
static void HeapSort(int[] arr)
{
if(arr == null || arr.Length < 2) { return; }
int heapSize = arr.Length;
for (int i = 0; i < heapSize; i++)//O(N)
{
HeapInsert(arr, i);//O(logN)
}
Swap(arr, 0, --heapSize);
while(heapSize > 0)//O(N)
{
Heapify(arr, 0, heapSize);//O(logN)
Swap(arr, 0, --heapSize);//O(1)
}
}
//某个数在index位置 向下移动
static void Heapify(int[] arr,int index,int heapSize)
{
int left = index * 2 + 1;
while(left < heapSize)//下方还有孩子
{
//如果 有右孩子并且 右孩子大于左孩子 则孩子里最大的是右孩子
//否则 孩子里最大的为左孩子(可能没有右孩子 也可能右孩子小
int largest = (left + 1) < heapSize && arr[left + 1] > arr[left]
? left + 1 : left;
//比较父亲和较大孩子 如果父亲就是较大的 也就是largest == index 可以直接返回
//否则交换父亲和较大孩子 把index改为较大孩子的下标作为新父亲 继续遍历
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) { break; }
Swap(arr, largest, index);
index = largest;
left = largest * 2 + 1;
}
}
//某个数在index位置 向上移动
static void HeapInsert(int[] arr,int index)
{
while (arr[index] > arr[(index - 1) / 2])
{
Swap(arr, (index - 1) / 2, index);
index = (index - 1) / 2;
}
}
static void Swap(int[] arr,int l,int r)
{
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
}
时间复杂度和快排一样:O(NlogN)
熬了一点夜敲完,有点苦逼