第十四节:排序算法详解2(归并排序、快速排序、堆排序)

一. 归并排序

【非常重要,必须手写出来,还必须理解,必须能快速手写出来】

 1. 定义

   它的基本思想是将待排序数组分成若干个子数组。然后将相邻的子数组归并成一个有序数组。最后再将这些有序数组归并(merge)成一个整体有序的数组。

2. 流程

 步骤一:分解(Divide):归并排序使用递归算法来实现分解过程,具体实现中可以分为以下几个步骤:

    ① 如果待排序数组长度为1,认为这个数组已经有序,直接返回;

    ② 将待排序数组分成两个长度相等的子数组,分别对这两个子数组进行递归排序;

    ③ 将两个排好序的子数组合并成一个有序数组,返回这个有序数组。

 步骤二:合并(Merge):合并过程中,需要比较每个子数组的元素并将它们有序地合并成一个新的数组:

    ① 可以使用两个指针 i 和 j 分别指向两个子数组的开头,比较它们的元素大小,并将小的元素插入到新的有序数组中。

    ② 如果其中一个子数组已经遍历完,就将另一个子数组的剩余部分直接插入到新的有序数组中

    ③ 最后返回这个有序数组。

 步骤三:归并排序的递归终止条件:

    归并排序使用递归算法来实现分解过程,当子数组的长度为1时,认为这个子数组已经有序,递归结束。

3.代码实操


/**
 *
 * @param arr 待排序的数组
 * @returns 有序的数组(由小到大)
 */
function mergeSort(arr: number[]): number[] {
	//1. 递归结束的条件:拆分到数组的长度为1
	if (arr.length <= 1) return arr;

	//2. 拆分数组
	//2.1 第1次拆分数组
	let mid = Math.floor(arr.length / 2); //向下取整
	let leftArray = arr.slice(0, mid); // [) 前闭后开
	let rightArray = arr.slice(mid); //前闭到结束
	//2.2 递归拆分
	let newLeftArray = mergeSort(leftArray);

	console.log('--------------------------------------');

	let newRightArray = mergeSort(rightArray);

	//3.合并数组
	let res: number[] = [];
	// 双指针
	let i = 0;
	let j = 0;
	//3.1 通过双指针比较两个数组元素, 合并成有序数组
	while (i < newLeftArray.length && j < newRightArray.length) {
		if (newLeftArray[i] <= newRightArray[j]) {
			res.push(newLeftArray[i]);
			i++;
		} else {
			res.push(newRightArray[j]);
			j++;
		}
	}
	//3.2 处理剩余的数组(下面的两个if不可能同时成立!!)
	//循环完左边还有剩余
	if (i < newLeftArray.length) {
		res.push(...newLeftArray.slice(i));
	}
	// 循环完右边还有剩余
	if (j < newRightArray.length) {
		res.push(...newRightArray.slice(j));
	}

	//4.将合并后的数组返回
	return res;
}

性能测试:

   10000 个元素 消耗时间为 111.60 毫秒

4. 时间复杂度

 ◼ 复杂度的分析过程:

     假设数组长度为 n,需要进行 logn 次归并操作;

     每次归并操作需要 O(n) 的时间复杂度;

     因此,归并排序的时间复杂度为 O(nlogn)。

 ◼ 最好情况: O(log n)

     最好情况下,待排序数组已经是有序的了,那么每个子数组都只需要合并一次,即只需要进行一次归并操作。

     因此,此时的时间复杂度是 O(log n)。

 ◼ 最坏情况: O(nlogn)

     最坏情况下,待排序数组是逆序的,那么每个子数组都需要进行多次合并。

     因此,此时的时间复杂度为 O(nlogn)。

 ◼ 平均情况: O(nlogn)

     在平均情况下,我们假设待排序数组中任意两个元素都是等概率出现的。

     此时,可以证明归并排序的时间复杂度为 O(nlogn)。

 

5.总结

  (1).归并排序是一种非常高效的排序算法,它的核心思想是分治,即将待排序数组分成若干个子数组,分别对这些子数组进行排序,   最后将排好序的子数组合并成一个有序数组。

  (2).归并排序的时间复杂度为 O(nlogn),并且在最好、最坏和平均情况下都可以达到这个时间复杂度。

  (3).虽然归并排序看起来比较复杂,但是只要理解了基本思路,实现起来并不困难,而且它是一种非常高效的排序算法。

 

 

二. 快速排序

【非常非常非常重要!!!!  必须手写,如果面试让你写个算法排序,首选快速排序!!!】

 1.定义

   快速排序(Quick Sort)是一种基于分治思想的排序算法:

   (1).基本思路是将一个大数组分成两个小数组,然后递归地对两个小数组进行排序

   (2) 具体实现方式是通过选择一个基准元素(pivot),将数组分成左右两部分,左部分的元素都小于或等于基准元素,右部分的元素都大于基准元素

   (3) 然后,对左右两部分分别进行递归调用快速排序,最终将整个数组排序

 

 2. 流程

    ① 首先,我们需要选择一个基准元素,通常选择第一个或最后一个元素作为基准元素。

    ② 然后,我们定义两个指针 i 和 j,分别指向数组的左右两端。(j指向是right-1的位置,right代表pivot的位置)

    ③ 接下来,我们从右侧开始,向左移动 j 指针,直到找到一个小于或等于基准元素的值。

    ④ 然后,我们从左侧开始,向右移动 i 指针,直到找到一个大于或等于基准元素的值。

    ⑤ 如果 i 指针小于或等于 j 指针,交换 i 和 j 指针所指向的元素。

    ⑥ 重复步骤 3-5,直到 i 指针大于 j 指针,这时,我们将基准元素与 j 指针所指向的元素交换位置,将基准元素放到中间位置。

    ⑦ 接着,我们将数组分为两部分,左侧部分包含小于或等于基准元素的元素,右侧部分包含大于基准元素的元素。

    ⑧ 然后,对左右两部分分别进行递归调用快速排序,直到左右两部分只剩下一个元素。

    ⑨ 最终,整个数组就变得有序了。

 

3.代码实操

这里分享三种递归:

方案1:

  在内部声明递归方法,可以保证arr至始至终是1个 

补充重点:

  (1). swap(arr, i, right); 为什么不是i+1?为什么不是j?

   答:此时的i指向的元素比pivot大,j指向的元素比pivot小,且i>j, 所以i和pivot对应的内容相互交换.

  (2). partition(left, j); 为什么是j,不是i,或者i+1?

   答:此处需要画图更明显,因为走完上面的while循环后,i>j,即j在i的左侧, 所以是left-j为一个区间

  (3). partition(i + 1, right); 为什么是i+1,而不是i?

    答:此处需要画图更明显,i和pivot指向的内容交换了,此时i指向的值 比其左侧都大,比起右侧都小,所以不需要考虑i了,直接从i+1开始排序后面     的即可。

查看代码
 /**
 * 快速排序-递归写法1
 * (在内部声明递归方法,可以保证arr至始至终是1个)
 * @param arr 待排序的数组
 * @returns 由小到大排序的数组
 */
function quickSort(arr: number[]): number[] {
	//调用递归排序
	partition(0, arr.length - 1);
	/**
	 * 分区递归排序方法
	 * @param left 左侧索引
	 * @param right 右侧索引
	 */
	function partition(left: number, right: number) {
		//递归结束条件
		if (left >= right) return;

		//1.找到基准元素(pivot轴心)
		//这里通常以最右侧的元素为轴心
		const pivot = arr[right];

		//2.双指针交换操作
		//(左边都是比pivot小的元素,右边都是大于等于pivot元素)
		let i = left;
		let j = right - 1;
		while (i <= j) {
			//左侧开始,找1个比pivot大(或等于)的元素
			while (arr[i] < pivot) {
				i++;
			}
			//右侧开始,找1个比pivot小(或等于)的元素
			while (arr[j] > pivot) {
				j--;
			}
			//此时,说明找到了比pivot大的元素、比pivot小的元素
			if (i <= j) {
				swap(arr, i, j); //交换位置
				i++;
				j--;
			}
		}

		//3.将pivot放到正确的位置   即和i交换(传递的是索引)
		swap(arr, i, right);

		//4. 左右继续划分区域进行递归
		partition(left, j);
		partition(i + 1, right);
	}

	return arr;
}

方案2:

  在外部声明递归方法,需要把arr传递进去 

查看代码
 /**
 * 快速排序-递归写法2
 * (在外部声明递归方法,需要把arr传递进去)
 * @param arr 待排序的数组
 * @returns 由小到大排序的数组
 */
function quickSort2(arr: number[]): number[] {
	//调用递归函数
	partFunc(arr, 0, arr.length - 1);

	return arr;
}
/**
 * 分区递归排序方法
 * @param left 左侧索引
 * @param right 右侧索引
 */
function partFunc(arr: number[], left: number, right: number) {
	//递归结束条件
	if (left >= right) return;

	//定义参考元素pivot
	let pivot = arr[right];

	//定义双指针
	let i = left;
	let j = right - 1;

	//开始遍历
	while (i <= j) {
		while (arr[j] > pivot) {
			j--;
		}
		while (arr[i] < pivot) {
			i++;
		}
		if (i <= j) {
			swap(arr, i, j); //交换位置
			i++;
			j--;
		}
	}

	//将pivot放到准确位置, 即和i交换(传递的是索引)
	swap(arr, right, i);

	//继续分区递归
	partFunc(arr, left, j);
	partFunc(arr, i + 1, right);
}

 方案3:

  自身递归,单指针,第1个元素作为pivot基准元素  【写法简洁,面试推荐】

/**
 * 快速排序-递归写法3
 * (自身递归,单指针,第1个元素作为pivot基准元素)
 * @param arr 待排序的数组
 * @returns 由小到大排序的数组
 */
function quickSort3(arr: number[]): number[] {
	//1.递归结束条件
	if (arr.length <= 1) return arr;

	//2.定义基础变量
	let pivot = arr[0];
	let leftArr = [];
	let rightArr = [];

	for (let i = 1; i < arr.length; i++) {
		if (arr[i] < pivot) {
			leftArr.push(arr[i]);
		} else {
			rightArr.push(arr[i]);
		}
	}

	return [...quickSort3(leftArr), pivot, ...quickSort3(rightArr)];
}

性能测试:

   排序 100000 个元素 消耗时间为 35.68 毫秒.

 

3. 时间复杂度

(1) 最好情况: O(nlogn)

    当每次划分后,两部分的大小都相等,即基准元素恰好位于数组的中间位置,此时递归的深度为 O(log n)。

    每一层需要进行 n 次比较,因此最好情况下的时间复杂度为 O(nlogn)。

(2) 最坏情况: O(n^2)

    当每次划分后,其中一部分为空,即基准元素是数组中的最大或最小值,此时递归的深度为 O(n)。

    每一层需要进行 n 次比较,因此最坏情况下的时间复杂度为 O(n^2)。

    需要注意的是,采用三数取中法或随机选择基准元素可以有效避免最坏情况的发生。

(3) 平均情况: O(nlogn)

    在平均情况下,每次划分后,两部分的大小大致相等,此时递归的深度为 O(log n)

    每一层需要进行大约 n 次比较,因此平均情况下的时间复杂度为 O(nlogn)。

注:快速排序是一个原地排序算法,不需要额外的数组空间。

 

 

4. 总结

   快速排序的性能优于许多其他排序算法,因为它具有良好的局部性和使用原地排序的优点。

   它在大多数情况下的时间复杂度为 O(n log n),但在最坏情况下会退化到 O(n^2)。

   为了避免最坏情况的发生,可以使用一些优化策略,比如随机选择基准元素和三数取中法。

 

 

三. 堆排序

 1. 定义

  堆排序(Heap Sort)是一种基于比较的排序算法,它的核心思想是使用"二叉堆"来维护一个有序序列

  二叉堆是一种完全二叉树,其中每个节点都满足父节点比子节点大(或小)的条件

  在堆排序中,我们使用"最大堆"来进行排序,也就是保证每个节点都比它的子节点大。

 

 2. 流程

  (1).首先构建一个最大堆(原地建堆)

  (2).然后,我们将堆的根节点(也就是最大值)与堆的最后一个元素交换,这样最大值就被放在了正确的位置上

  (3).接着,我们将堆的大小减一,并将剩余的元素进行下滤(从上而下)操作重新构建成一个最大堆

  (4).我们不断重复这个过程,直到堆的大小为 1。

  (5).这样,我们就得到了一个有序的序列。

 PS:  与选择排序的关系

   A.选择排序的基本思想是在待排序的序列中选出最小(或最大)的元素,然后将其放置到序列的起始位置。

   B.堆排序也是一种选择排序算法,它使用最大堆来维护一个有序序列,然后不断选择出最大的值。

 

 

3. 思路详细分析

  堆排序可以分成两大步骤:构建最大堆和排序

  ◼ 构建最大堆:

    ① 遍历待排序序列,从最后一个非叶子节点开始,依次对每个节点进行调整。

    ② 假设当前节点的下标为 i,左子节点的下标为 2i+1,右子节点的下标为 2i+2,父节点的下标为 (i-1)/2。

    ③ 对于每个节点 i,比较它和左右子节点的值,找出其中最大的值,并将其与节点 i 进行交换。

    ④ 重复进行这个过程,直到节点 i 满足最大堆的性质。

    ⑤ 依次对每个非叶子节点进行上述操作,直到根节点,这样我们就得到了一个最大堆。

  ◼ 排序:

    ① 将堆的根节点(也就是最大值)与堆的最后一个元素交换,这样最大值就被放在了正确的位置上。

    ② 将堆的大小减小一,并将剩余的元素重新构建成一个最大堆。

    ③ 重复进行步骤 ① 和步骤 ②,直到堆的大小为 1,这样我们就得到了一个有序的序列。

 

4.代码实操


import { testSort, measureSort, swap } from 'hy-algokit';

/**
 * 堆排序
 * @param arr 待排序的数组
 * @returns 排序好的,由大到小的数组
 */
function heapSort(arr: number[]): number[] {
	let n = arr.length;
	//1. 原地建堆
	//从第一个非叶子节点开始,进行下滤操作
	let start = Math.floor(n / 2 - 1); //公式,记住即可
	for (let i = start; i >= 0; i--) {
		//下滤操作
		heapify_down(arr, i, n);
	}

	//2. 执行堆排序
	for (let i = n - 1; i > 0; i--) {
		swap(arr, 0, i);
		heapify_down(arr, 0, i);
	}

	return arr;
}

/**
 * 下滤
 * @param arr 在该数组中进行下滤
 * @param index 从该位置开始进行下滤
 * @param n 下滤的范围(即数组的截至位置)
 */
function heapify_down(arr: number[], index: number, n: number) {
	//开始位置index的左子节点小于n的情况下,即左子节点存在,可以一直循环
	while (2 * index + 1 < n) {
		//1. 获取左右子节点索引
		let leftChildIndex = 2 * index + 1;
		let rightChildIndex = 2 * index + 2;

		//2. 获取左右子节点中较大的值
		let largerIndex = leftChildIndex; //先默认左子节点, 因为右子节点可能不存在
		if (rightChildIndex < n && arr[rightChildIndex] > arr[leftChildIndex]) {
			largerIndex = rightChildIndex;
		}

		//3. 判断index位置的值比更大的子节点, 直接break
		if (arr[index] >= arr[largerIndex]) {
			break; //终止循环
		}

		//4.交换位置,继续进行
		swap(arr, index, largerIndex);
		index = largerIndex;
	}
}

测试: 

//测试
{
	console.log('---------------------01-堆排序准确性测试--------------------------');
	testSort(heapSort);
}

{
	console.log('---------------------02-堆排序性能测试--------------------------');
	measureSort(heapSort);
}

 

5.时间复杂度

 步骤一:堆的建立过程

  堆的建立过程包括 n/2 次堆的向下调整操作,因此它的时间复杂度为 O(n)。

 步骤二:排序过程

  排序过程需要执行 n 次堆的删除最大值操作,每次操作都需要将堆的最后一个元素与堆顶元素交换,然后向下 调整堆。

  每次向下 调整操作的时间复杂度为 O(logn),因此整个排序过程的时间复杂度为 O(nlogn)。

 ◼ 综合起来,堆排序的时间复杂度为 O(nlogn)。

 

6.总结

  堆排序是一种高效的排序算法,它利用堆这种数据结构来实现排序。

  ◼ 堆排序具有时间复杂度为 O(nlogn) 的优秀性能,并且由于它只使用了常数个辅助变量来存储堆的信息,因此空间复杂度为 O(1)。

  ◼ 但是,由于堆排序的过程是不稳定的,即相同元素的相对位置可能会发生变化,因此在某些情况下可能会导致排序结果不符合要求。

  ◼ 总的来说,堆排序是一种高效的、通用的排序算法,它适用于各种类型的数据,并且可以应用于大规模数据的排序。

 

7. 非叶子节点的推导

 

 

 

 

 

!

  • 作       者 : Yaopengfei(姚鹏飞)
  • 博客地址 : http://www.cnblogs.com/yaopengfei/
  • 声     明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
  • 声     明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2024-01-24 21:17  Yaopengfei  阅读(19)  评论(3编辑  收藏  举报