个人主页:C++忠实粉丝
欢迎 点赞 收藏 留言 加关注本文由 C++忠实粉丝 原创

排序算法(2)之交换排序----冒泡排序和堆排序

收录于专栏【数据结构初阶
本专栏旨在分享学习数据结构学习的一点学习笔记,欢迎大家在评论区交流讨论

目录

1.前置说明 

2.选择排序

2.1选择排序的基本思想

2.2直接选择排序

2.2.1直接选择排序的概念

2.2.2直接选择排序的示例

2.2.3动图演示

2.2.4代码展示 

2.2.4.1未经优化的选择排序

2.2.4.2优化后的选择排序

2.2.5测试代码 

2.2.5.1未经优化

2.2.5.2经过优化

2.2.6时间复杂度分析

2.3堆排序

2.3.1堆排序的概念

2.3.2 堆排序示例

2.3.3堆排序图形演示

2.3.4代码展示

2.3.4.1向下调整建堆

2.3.4.2向上调整建堆

2.3.5测试代码

2.3.6时间复杂度分析

2. 排序过程的时间复杂度

3.直接选择排序与堆排序的比较

总结比较

4.总结


1.前置说明 

关于排序的概念及其运用大家可以参考下面的文章,这里就不重复讲述:

今天讲的是选择排序中的直接选择排序和堆排序

排序算法(1)之插入排序----直接插入排序和希尔排序-CSDN博客

 

2.选择排序

2.1选择排序的基本思想

每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的 数据元素排完 。

2.2直接选择排序

2.2.1直接选择排序的概念

直接选择排序(Selection Sort)是一种简单直观的排序算法,其基本思想是每次从待排序的数据元素中选择最小(或最大)的一个元素,依次放到已排序序列的末尾,直到所有元素均排序完成为止。

具体步骤如下:

  • 首先,在未排序序列中找到最小(或最大)的元素,存放到排序序列的起始位置。
  • 然后,从剩余未排序元素中继续寻找最小(或最大)的元素,放到已排序序列的末尾。
  • 依次类推,直到所有元素均排序完毕。 

2.2.2直接选择排序的示例

假设我们有一个数组 [64, 25, 12, 22, 11] 需要按照从小到大的顺序进行排序。

第一步: 初始状态,整个数组为 [64, 25, 12, 22, 11]

  1. 第一次迭代

    • 找到当前未排序部分的最小值,即数组中的 11
    • 将 11 与第一个元素 64 交换位置,得到数组 [11, 25, 12, 22, 64]
    • 此时,第一个位置 [11] 可以看作已排序部分,而剩余的未排序部分为 [25, 12, 22, 64]
  2. 第二次迭代

    • 在剩余的数组 [25, 12, 22, 64] 中找到最小值 12
    • 将 12 与第二个位置的 25 交换位置,得到数组 [11, 12, 25, 22, 64]
    • 现在,前两个位置 [11, 12] 已排序,剩余的未排序部分为 [25, 22, 64]
  3. 第三次迭代

    • 在剩余的数组 [25, 22, 64] 中找到最小值 22
    • 将 22 与第三个位置的 25 交换位置,得到数组 [11, 12, 22, 25, 64]
    • 现在,前三个位置 [11, 12, 22] 已排序,剩余的未排序部分为 [25, 64]
  4. 第四次迭代

    • 在剩余的数组 [25, 64] 中找到最小值 25
    • 将 25 与第四个位置的 25(其实是自身,无需交换)位置,得到数组 [11, 12, 22, 25, 64]
    • 现在,整个数组都已排序完成。

排序过程总结为:

  • 第一次迭代找到 11,交换得到 [11, 25, 12, 22, 64]
  • 第二次迭代找到 12,交换得到 [11, 12, 25, 22, 64]
  • 第三次迭代找到 22,交换得到 [11, 12, 22, 25, 64]
  • 第四次迭代找到 25,无需交换,数组最终有序。

这样,通过反复选择未排序部分的最小元素,并与未排序部分的第一个元素交换位置,直到整个数组排序完成,即完成了直接选择排序的过程。

2.2.3动图演示

2.2.4代码展示 

2.2.4.1未经优化的选择排序
void Swap(int* p1, int* p2)
{
	int a = *p1;
	*p1 = *p2;
	*p2 = a;
}
void SelectSort1(int* a, int n)
{
	for (int i = 0; i < n; i++)
	{
		int mini = i;
		for (int j = i; j < n; j++)
		{
			if (a[mini] > a[j])
				mini = j;
		}
		Swap(&a[i], &a[mini]);
	}
}
2.2.4.2优化后的选择排序
void Swap(int* p1, int* p2)
{
	int a = *p1;
	*p1 = *p2;
	*p2 = a;
}
void SelectSort2(int* a, int n)
{
	int begin = 0, end = n - 1;

	while (begin < end)
	{
		int mini = begin, maxi = begin;
		for (int i = begin + 1; i <= end; ++i)
		{
			if (a[i] > a[maxi])
			{
				maxi = i;
			}

			if (a[i] < a[mini])
			{
				mini = i;
			}
		}

		Swap(&a[begin], &a[mini]);
		if (begin == maxi)
			maxi = mini;

		Swap(&a[end], &a[maxi]);
		++begin;
		--end;
	}
}

分析: 

  1. 函数定义

    void SelectSort2(int* a, int n)
    • 函数名为 SelectSort2,返回类型为 void,表示不返回任何值。
    • 函数接受两个参数:a 是指向整型数组的指针,n 是数组中元素的个数。
  2. 选择排序的变种实现

    • 首先定义了两个变量 begin 和 end,分别初始化为数组的起始索引 0 和末尾索引 n-1
    • 进入 while 循环,条件是 begin < end,即未排序部分至少有两个元素。
  3. 内部循环

    • 在每次循环开始时,初始化 mini 和 maxi 分别为 begin,意味着假定当前未排序部分的第一个元素为最小和最大。
    • 通过 for 循环遍历 begin+1 到 end 之间的元素:
      • 如果发现比当前 maxi 索引指向的元素大的元素,则更新 maxi
      • 如果发现比当前 mini 索引指向的元素小的元素,则更新 mini
  4. 交换操作

    • 在每一轮循环结束后,通过 Swap 函数交换 begin 索引和 mini 索引所指向的元素,确保当前未排序部分的最小元素被放置在已排序部分的末尾。
    • 如果 begin 等于 maxi,则在交换后需要更新 maxi 为 mini 的值。
    • 接着,再次通过 Swap 函数交换 end 索引和 maxi 索引所指向的元素,确保当前未排序部分的最大元素被放置在已排序部分的开头。
  5. 循环控制

    • 每完成一轮循环,即一个元素被正确地放置在已排序的位置上,递增 begin 并递减 end,缩小未排序部分的范围,直至 begin 不再小于 end,排序完成。
  6. 功能分析

    • 这种选择排序的变种在每一轮循环中同时找到未排序部分的最小值和最大值,并将它们分别放置在已排序部分的开头和末尾。
    • 效果上比传统的选择排序稍微快一些,因为减少了每轮循环中的交换次数,但其时间复杂度仍为 O(n^2),并未改变选择排序的基本特性。

注意: 

        Swap(&a[begin], &a[mini]);
        if (begin == maxi)
            maxi = mini;

这段代码是为了防止在第一次交换后,maxi和mini已经进行过交换,将它们回复原位

2.2.5测试代码 

--测试oj链接--912. 排序数组 - 力扣(LeetCode)

2.2.5.1未经优化

代码展示:

void Swap(int* p1, int* p2)
{
	int a = *p1;
	*p1 = *p2;
	*p2 = a;
}

void SelectSort1(int* a, int n)
{
	for (int i = 0; i < n; i++)
	{
		int mini = i;
		for (int j = i; j < n; j++)
		{
			if (a[mini] > a[j])
				mini = j;
		}
		Swap(&a[i], &a[mini]);
	}
}
int* sortArray(int* nums, int numsSize, int* returnSize) {
    (*returnSize) = numsSize;
    int* array = (int*)malloc(sizeof(int)*(*returnSize));
    for(int i = 0; i < numsSize; i++)
    {
        array[i] = nums[i];
    }
    SelectSort1(array, numsSize);
    return array;
}

结果分析: 

很遗憾,只过了10个例子就超时,比插入排序还要烂......

2.2.5.2经过优化

代码展示:

void Swap(int* p1, int* p2)
{
	int a = *p1;
	*p1 = *p2;
	*p2 = a;
}

void SelectSort2(int* a, int n)
{
	int begin = 0, end = n-1;
    while(begin < end)
    {
        int mini = begin, maxi = begin;
        for(int i = begin + 1; i <= end; i++)
        {
            if(a[i] < a[mini])
                mini = i;
            if(a[i] > a[maxi])
                maxi = i; 
        }
        Swap(&a[begin], &a[mini]);
        if(begin == maxi)
            maxi = mini;
        Swap(&a[end], &a[maxi]);
        ++begin;
        --end;
    }
}
int* sortArray(int* nums, int numsSize, int* returnSize) {
    (*returnSize) = numsSize;
    int* array = (int*)malloc(sizeof(int)*(*returnSize));
    for(int i = 0; i < numsSize; i++)
    {
        array[i] = nums[i];
    }
    SelectSort2(array, numsSize);
    return array;
}

结果分析:

优化后还是只能通过10个例子,效率还是很低....

2.2.6时间复杂度分析

  • 时间复杂度为 (O(n^2)),其中 (n) 是待排序元素的数量。
  • 空间复杂度为 (O(1)),因为直接选择排序是原地排序算法,不需要额外的存储空间。
  • 稳定性:直接选择排序是不稳定的,因为交换操作可能改变相同元素的相对顺序。

2.3堆排序

因为这里是具体讲排序的章节,所以不会具体分析堆的实现,大家可以去下面的链接自行查看

--数据结构之二叉树的超详细讲解(2)--(堆的概念和结构的实现,堆排序和堆排序的应用)-CSDN博客

2.3.1堆排序的概念

堆排序(Heap Sort)是一种基于堆数据结构的排序算法,其特点是在排序过程中使用了最大堆或最小堆这种特殊的完全二叉树结构。堆是一种满足堆属性的二叉树:对于最大堆,父节点的值始终大于或等于其子节点的值;对于最小堆,则父节点的值始终小于或等于其子节点的值。

堆排序的步骤:

  1. 构建堆

    • 将待排序的序列构建成一个堆。根据排序的要求,可以构建最大堆(升序排序使用)或最小堆(降序排序使用)。
    • 构建堆的过程可以通过自下而上的方式,从最后一个非叶子节点开始,依次向上调整每个子树,使其满足堆的性质。调整过程称为堆化(Heapify)。
  2. 堆排序

    • 利用堆的特性,每次从堆顶(根节点)取出最大或最小的元素,与堆的最后一个元素交换。
    • 交换后,堆的规模减小一,然后再通过堆调整(Heapify)重新将剩余的元素重新调整为堆。
    • 重复以上步骤,直到堆中只剩下一个元素,此时所有元素都已经有序排列。 

2.3.2 堆排序示例

当我们使用堆排序时,可以通过一个简单的例子来说明其工作原理。假设我们有一个待排序的数组 [4, 10, 3, 5, 1],我们希望按升序排序这个数组。

步骤一:构建最大堆

  1. 初始数组[4, 10, 3, 5, 1]

  2. 从最后一个非叶子节点开始进行堆化

    • 最后一个非叶子节点索引是 (n // 2) - 1 = (5 // 2) - 1 = 1,对应元素为 10
    • 开始堆化过程,使得整个数组满足最大堆的性质。

    堆化后的数组:[10, 5, 3, 4, 1]

  3. 继续堆化

    • 对于根节点 10,将其与子节点 3 和 4 进行比较和交换,确保最大堆性质。
    • 堆化后的数组:[10, 5, 3, 4, 1]

步骤二:堆排序

  1. 交换堆顶元素与末尾元素

    • 将堆顶元素 10 与末尾元素 1 交换,此时数组末尾是已排序部分。
    • 数组变为:[1, 5, 3, 4, 10]
  2. 重新堆化

    • 对剩余的元素 [1, 5, 3, 4] 进行堆化,重新构建最大堆。

    堆化后的数组:[5, 4, 3, 1]

  3. 继续排序

    • 将堆顶元素 5 与末尾元素 1 交换,数组末尾再添加一个已排序元素。
    • 数组变为:[1, 4, 3, 5]
  4. 最后一步

    • 将堆顶元素 4 与末尾元素 3 交换。
    • 数组变为:[1, 3, 4, 5]

完成排序

最终排序后的数组为 [1, 3, 4, 5, 10],这就是堆排序按升序排列给定的数组的过程。

2.3.3堆排序图形演示

向上调整建堆

向下调整建堆

2.3.4代码展示

2.3.4.1向下调整建堆
void Swap(int* p1, int* p2)
{
	int a = *p1;
	*p1 = *p2;
	*p2 = a;
}

void AdjustDown(int* a, int n, int parent)
{
	// 先假设左孩子小
	int child = parent * 2 + 1;

	while (child < n)  // child >= n说明孩子不存在,调整到叶子了
	{
		// 找出小的那个孩子
		if (child + 1 < n && a[child + 1] > a[child])
		{
			++child;
		}

		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort(int* a, int n)
{
	// 向下调整建堆 O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}

	// O(N*logN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}
}

分析:

  1. Swap 函数:

    • 实现了两个整数指针指向的值的交换。
  2. AdjustDown 函数:

    • 用于向下调整堆结构,保持堆的性质(大顶堆),其中 a 是待调整的数组,n 是数组长度,parent 是需要调整的父节点索引。
    • 该函数首先假设左孩子较小,然后找出实际较大的孩子,将较大的孩子与父节点比较并交换,然后递归调整被交换的孩子节点。
  3. HeapSort 函数:

    • 先通过向下调整(AdjustDown 函数)建立初始的大顶堆。从最后一个非叶子节点开始,依次向上调整堆,确保每个节点都满足堆的性质。
    • 排序阶段,从堆顶(最大值)开始,将堆顶元素与当前未排序部分的末尾元素交换,然后重新调整堆顶,继续这个过程直到所有元素都被排序。

 

2.3.4.2向上调整建堆

代码展示:

void Swap(int* p1, int* p2)
{
	int a = *p1;
	*p1 = *p2;
	*p2 = a;
}

void AdjustUp(int* a, int n, int child)
{
	// 初始条件
	// 中间过程
	// 结束条件
	int parent = (child - 1) / 2;
	//while (parent >= 0)
	while (child > 0)
	{
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

void AdjustDown(int* a, int n, int parent)
{
	// 先假设左孩子小
	int child = parent * 2 + 1;

	while (child < n)  // child >= n说明孩子不存在,调整到叶子了
	{
		// 找出小的那个孩子
		if (child + 1 < n && a[child + 1] > a[child])
		{
			++child;
		}

		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort2(int* a, int n)
{
	// 向下上调整建堆 O(N*logN)
	for (int i = 0; i < n; i++)
	{
		AdjustUp(a, n, i);
	}

	printf("\n");

	// O(N*logN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}


}

分析:

  1. Swap 函数

    • 这个函数用于交换两个整数指针所指向的值,用于数组元素的位置交换。
  2. AdjustUp 函数

    • 这个函数用于向上调整堆结构,确保在数组的某个位置(child)插入新元素后,仍然保持堆的性质(大顶堆)。
    • 从给定的孩子节点开始,不断与其父节点比较并交换,直到满足堆的条件或者已经到达根节点。
  3. AdjustDown 函数

    • 这个函数用于向下调整堆结构,确保在删除堆顶元素后,剩余的数组依然是一个大顶堆。
    • 从指定的父节点开始,找出其两个孩子中较大的一个,然后与父节点比较并交换,重复这个过程直到满足堆的性质或者到达叶子节点。
  4. HeapSort2 函数

    • 这是堆排序的主函数。首先通过调用 AdjustUp 函数,将整个数组构建成一个大顶堆。
    • 然后,通过交换堆顶元素和当前堆的末尾元素,并调用 AdjustDown 函数来恢复堆的性质。重复这个过程直到整个数组排序完成。

2.3.5测试代码

oj链接:912. 排序数组 - 力扣(LeetCode)

代码展示:

void Swap(int* p1, int* p2)
{
	int a = *p1;
	*p1 = *p2;
	*p2 = a;
}

void AdjustUp(int* a, int n, int child)
{
	// 初始条件
	// 中间过程
	// 结束条件
	int parent = (child - 1) / 2;
	//while (parent >= 0)
	while (child > 0)
	{
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

void AdjustDown(int* a, int n, int parent)
{
	// 先假设左孩子小
	int child = parent * 2 + 1;

	while (child < n)  // child >= n说明孩子不存在,调整到叶子了
	{
		// 找出小的那个孩子
		if (child + 1 < n && a[child + 1] > a[child])
		{
			++child;
		}

		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort1(int* a, int n)
{
	// 向下调整建堆 O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}
	// O(N*logN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}
}


void HeapSort2(int* a, int n)
{
	// 向下上调整建堆 O(N*logN)
	for (int i = 0; i < n; i++)
	{
		AdjustUp(a, n, i);
	}

	// O(N*logN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}

}
int* sortArray(int* nums, int numsSize, int* returnSize) {
    (*returnSize) = numsSize;
    int* array = (int*)malloc(sizeof(int)*(*returnSize));
    for(int i = 0; i < numsSize; i++)
    {
        array[i] = nums[i];
    }
    HeapSort2(array, numsSize);
    return array;
}

结果分析: 

 

 可以看出无论是向上建堆还是向下建堆,堆排序都能够轻松通过

注意:
这里建议大家还是使用向下建堆实现堆排序,因为实现的时候只需要实现一个向下调整的算法,而且调整算法的时间复杂度为O(n),可谓是又方便,又高效.

2.3.6时间复杂度分析

堆排序的时间复杂度分析主要分为两个部分:建堆过程的时间复杂度和排序过程的时间复杂度。

1. 建堆过程的时间复杂度
--具体可以看这篇博客
数据结构之二叉树的超详细讲解(2)--(堆的概念和结构的实现,堆排序和堆排序的应用)-CSDN博客

建堆的过程是将一个无序的数组构建成一个堆,通常是最大堆或最小堆。建堆的时间复杂度为 O(n)

  • 建堆的详细步骤
    • 从最后一个非叶子节点开始,依次向前进行堆化操作,保证每个子树都满足堆的性质。
    • 最后一个非叶子节点的索引为 (n // 2) - 1,其中 n 是数组的长度。
    • 每次堆化的时间复杂度是 O(log n),因为堆的高度是 O(log n)

2. 排序过程的时间复杂度

排序过程主要包括将堆顶元素与末尾元素交换并重新调整堆的过程。排序过程的时间复杂度为 O(n log n)

  • 排序的详细步骤
    • 将堆顶元素与当前堆的最后一个元素交换,然后从根节点开始重新调整堆,使其重新满足堆的性质。
    • 每次重新调整堆的时间复杂度是 O(log n)
    • 总共需要进行 n-1 次交换和堆调整操作。

总结堆排序的时间复杂度:

  • 建堆时间复杂度:O(n)
  • 排序时间复杂度:O(n log n)

综合起来,堆排序的总时间复杂度是 O(n log n)。这使得堆排序在时间复杂度上表现出色,适合于大规模数据的排序操作。

3.直接选择排序与堆排序的比较

直接选择排序(Selection Sort)和堆排序(Heap Sort)都属于比较排序的算法,但它们在实现和性能上有显著的差异。

直接选择排序(Selection Sort)

  1. 基本原理

    • 直接选择排序通过每次从未排序的部分选出最小(或最大)的元素,然后放到已排序部分的末尾。
    • 每次选择过程都需要线性扫描未排序部分,找出最小元素,然后与当前位置交换。
  2. 时间复杂度

    • 最好情况、平均情况和最坏情况下的时间复杂度都是 O(n^2)
    • 每次选择都需要线性时间,总共进行了 n 次选择,每次选择需要线性时间。
  3. 空间复杂度

    • 额外空间复杂度是 O(1),因为只需要常数级别的额外空间用于交换元素。
  4. 稳定性

    • 直接选择排序是一种不稳定的排序算法,因为在选择的过程中可能破坏相同元素的相对顺序。

堆排序(Heap Sort)

  1. 基本原理

    • 堆排序利用堆的数据结构来实现排序。
    • 先构建一个最大堆(或最小堆),然后将堆顶元素(最大值或最小值)与堆的最后一个元素交换,并调整堆,使其满足堆的性质,重复这个过程直到整个数组排序完成。
  2. 时间复杂度:(向下调整建堆)

    • 建堆的时间复杂度是 O(n)
    • 每次调整堆的时间复杂度是 O(log n)
    • 总的时间复杂度是 O(n log n),堆排序是一种稳定的时间复杂度为 O(n log n) 的排序算法。
  3. 空间复杂度

    • 堆排序的空间复杂度是 O(1),因为所有操作都在原数组上进行,没有额外使用的空间。
  4. 稳定性

    • 堆排序在交换堆顶元素时可能破坏相同元素的相对顺序,因此是一种不稳定的排序算法。

总结比较

  • 性能:堆排序在大多数情况下比直接选择排序要快,特别是在处理大规模数据时,由于堆排序的时间复杂度为 O(n log n),比直接选择排序的 O(n^2) 要好。
  • 稳定性:直接选择排序可能比堆排序更容易实现为稳定排序,但通常它们都是不稳定的排序算法。
  • 实现复杂度:堆排序需要实现堆的构建和堆的调整过程,相对而言实现起来比直接选择排序稍微复杂一些,但在实际应用中通常都是可以接受的复杂度。

4.总结

直接选择排序的性能非常差,因为他每次遍历都需要完整遍历完整个数组,现实生活中基本不用,但堆排序时间复杂度能达到n*logn,特别是在top-k问题中,堆排序更是能到达O(n)的级别.

如果这篇文章对大家有帮助的话,麻烦点赞关注支持一下

我将会马上更新排序算法(3)之交换排序----冒泡排序和快速排序,记得点赞关注不迷路哦!

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部