第二次整理快速排序。理解了怎么使用分治和递归实现快速排序,在分区的过程中需要选定pivot,并巧妙利用双指针将数据重排,返回分区点的下标用于递归调用。时间复杂度的推导尽力理解了,它的时间复杂度与每次分区后遍历元素次数相关。值得注意的是递归调用的时间复杂度也是通过递归公式推导得到的。以上花费2小时。
再次巩固一下,这简直是灾难性遗忘。利用pivot进行分区,需要指针j遍历每一个元素与pivot进行比较,满足条件a[j]小于pivot则交换指针i和j指向的元素,同时i++。
归并排序的递归思想比较好理解,但是需要注意分区递归调用完需要合并到一起,即将两个有序区间重新排序。使用的方法比较常规,请帮我牢牢焊在脑子里。以上花费1个小时。
归并排序(merge sort)
一、什么是归并排序?
使用归并排序对一组数据排序,首先将数据分为两个部分,再对两部分数据重新排序,最后将排序后的两部分数据合并成一组数据并使它有序。
归并排序算法采用了分治思想,分治就是将一个大问题分解成各个小的子问题来解决,和用递归解决问题的思路很像,事实上使用分治思想解决问题也需要用到递归的编程技巧。
//p是数组区间的最左边元素的下标,r是最右边元素的下标,q是分区点下标。
void * merge(int a[], int p, int q, int r)
{
int *tmp;
int i, j, k;
tmp = (int *)malloc(sizeof(int)*(r - p + 1));
if (!tmp)
{
abort();
}
for (i = p, j = q + 1, k = 0; i <= q && j <= r ;)
{
if (a[i] <= a[j])
{
tmp[k++] = a[i++];
}
else
{
tmp[k++] = a[j++];
}
}
if (i = q + 1)//处理前半部分全部已排序的情况
{
for (; j <= r;)
{
tmp[k++] = a[j++];
}
}
else//处理后半部分全部已排序情况
{
for (; i <= q;)
{
tmp[k++] = a[i++];
}
}
memcpy(a, tmp, sizeof(int)*(r - p + 1));
free(tmp);
}
//下面函数体现了归并排序的分解和合并思想
//考虑测试用例:p=0 , r=1;p=0 , r=0代码适用
void merge_sort(int a[], int p, int r)
{
if (p >= r)
return;
int q = (p + r) / 2;
merge_sort(a, p, q);
merge_sort(a, q + 1, r);
merge(a, p , q , r);
}
leetcode 921.排序算法 归并实现
class Solution {
//归并排序:通过递归和归并实现
//首先通过递归将数组分成前后两个区间
//递归结束时对两个区间进行排序,并将排序后的两个区间合并成一个区间,并返回上一次递归调用
public:
void merge(vector<int>& nums, int l, int r, int mid) {
//malloc一个数组用于临时存放sort的结果
int *tmp = (int *)malloc((r-l+1) * sizeof(int));
int k = 0, i = l, j = mid + 1;
for( ; i <= mid && j <= r; )
{
//稳定的
if(nums[i] > nums[j]){
tmp[k++] = nums[j++];
}
else
{
tmp[k++] = nums[i++];
}
}
while(i <= mid){
tmp[k++] = nums[i++];
}
while(j <= r) {
tmp[k++] = nums[j++];
}
memcpy(&nums[l], tmp, (r - l + 1) * sizeof(int));
free(tmp);
}
void merge_sort(vector<int>& nums, int l, int r)
{
if(l >= r)
{
return;
}
int p = (r - l) / 2 + l;
merge_sort(nums, l, p);
merge_sort(nums, p+1 , r);
merge(nums, l, r, p);
}
vector<int> sortArray(vector<int>& nums) {
merge_sort(nums, 0, nums.size()-1);
return nums;
}
};
归并排序的其他特性
1、归并排序是一种稳定的算法:因为将一组数据分成前后两个部分,若比较的两个数据相等,我们可以让前面部分的数据先添加到合并后的数组中,从而保证了归并排序的稳定性。
2、归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。
3、归并排序不是原地排序算法:在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
快速排序
任意选取一个数据作为分区点(pivot),通常选数组的最后一个元素。我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。我们用一个分区函数partition() 来实现这一过程。
实现分区函数:
1、不考虑空间消耗:可以申请两个数组,然后遍历这组数据,将小于分区点的放到一个临时数组中,大于分区点的数据放到另一个临时数组中。然后将临时数组中的数据顺序拷贝到原来的数组中。
2、考虑空间消耗,原地排序实现:
伪代码:
partition(A, p, r) {
pivot := A[r]
//巧妙的利用双指针将小于pivot数据放到右边,将大于pivot的数据放到左边
//一次遍历后将pivot点放到正确的位置
i := p
for j := p to r-1 do {
if A[j] < pivot {
swap A[i] with A[j]
i := i+1
}
}
swap A[i] with A[r]
return i
示意图如下:
理解:
1、为了将小于分区点的数据放到分区点的右边,我们用两个指针,指针i用来指向可以用来交换的位置,所以只有当交换发生时,指针i才指向下一个用来待交换的位置。
2、我们需要用指针j遍历数组依次指向数组中的每一个元素,每个元素可能小于分区点也可能大于分区点,当j当前指向的元素大于分区点的时候不发生交换,指针j指向下一个位置的元素对其进行处理;当指针j当前指向的元素小于分区点的元素的时候需要交换元素,目的是将小于分区点的元素放到数组的右侧,我们利用指针i指向的位置,给我们提供交换的位置,依次需要将指针i和指针j指向的内容交换。
3、当指针j遍历到最后一个元素(分区点)时,所有小于8的元素都放在的数组的最右侧,这时指针i指向的位置就是排序后分区点元素应该所在的位置。
下面是c++代码实现:
//元素交换
void swap(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
//分区函数,将数组分为小于分区点、分区点、大于分区点三部分
//返回值:返回分区点元素的下标
int partition(int a[], int p, int r)
{
int i = p;
int pivot = a[r];
//这个for循环帮我牢牢的焊在脑子里,双指针,j遍历的每一个元素都要与pivot比较大小
//小于pivot,i指针与j指针指向的元素交换,i指针挪向下一个待交换的坑位
for (int j = p;; j <= r-1; j++)
{
if (a[j] < pivot)
{
swap(a+i, a+j);
++i;
}
}
swap(a + i, a + r);
return i;
}
//用递归技巧实现快速排序
void _quicksort(int a[], int p, int r)
{
if (p >= r)
{
return;
}
int q = partition(a , p , r);
_quicksort(a, p, q - 1);
_quicksort(a, q + 1, r);
}
void quick_sort(int a[], int size)
{
_quicksort(a, 0, size - 1);
}
leetcode 921.排序数组
class Solution {
//快速排序
//随机数种子发生器
//srand((unsigned)time(NULL));
//随机分区点下标计算方法
//int pivoit_idx = rand() % (l - r + 1) + r
public:
int partition(vector<int> &nums, int r, int p)
{
int i = r;
for(int j = r; j <= p - 1; j++){
if(nums[j] <= nums[p]){
swap(nums[i], nums[j]);
i++;
}
}
swap (nums[i], nums[p]);
return i;
}
int randomized_partition(vector<int> & nums, int r, int p ){
int i = rand() % (p - r + 1) + r; //随机选一个作为我们的主元
swap(nums[p], nums[i]);
return partition(nums, r, p);
}
void quick_sort(vector<int> &nums, int r, int p)
{
if(r >= p)
{
return;
}
int q = randomized_partition(nums, r, p);
quick_sort(nums, r, q-1);
quick_sort(nums, q+1, p);
}
vector<int> sortArray(vector<int>& nums) {
srand((unsigned)time(NULL));
quick_sort(nums, 0, nums.size() - 1);
return nums;
}
};
元素重复的大数组排序超时,需要进一步优化。
快速排序的时间复杂度分析
第一次分区查找我们需要遍历的数组长度为n。第二次分区查找,需要遍历 n/2 个元素。依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为 1。
如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+…+1。这是一个等比数列求和,最后的和等于 2n-1。所以,上述解决思路的时间复杂度就为 O(n)。
快速排序的其他特性
1、快排是一种原地的、不稳定的排序方式。原地排序的空间复杂度为O(1)。
2、它的时间复杂度最好情况O(n)、最坏情况O(n^2)、平均情况的时间复杂度都是O(nlogn)