算法学习笔记:排序


0. 前言

算法真的是编程最核心的东西

所以今天整理几种比较喜欢的排序算法,所有代码基于java实现。

1. 冒泡法 bubble sort

1.1. 原理

每一个元素都和它后面的所有元素比较,如果有必要,则交换,以此类推,直到倒数第二个元素和最后一个元素比较并交换完毕。

这是最简单的排序法,虽然他的时间复杂度不是最小的,甚至可以说是比较大的。但是由于代码结构十分简单,所以实现起来十分容易,对于小规模数据的排序来说,bubble sort是很好的。

1.2. 代码

	/**
	 * bubble sort
	 * best:	O(n)
	 * worst:	O(n^2)
	 * average:	O(n^2)
	 */
	public static void bubbleSort() {
		
		int tmp;
		for (int i = 0; i < a.length - 1; i++) {
			for (int j = 0; j < a.length - 1; j++) {
				if (a[j] > a[j + 1]) {
					tmp = a[j];
					a[j] = a[j + 1];
					a[j + 1] = tmp;
				}
			}
		}
	}

2. 插入法排序 insertion sort

2.1. 原理

首先将整个数组看成两部分,前面是已经排序完成的数组,后面是还未排序完成的。每次从未排序的数组当中选择一个(其实就是第一个啦~~方便遍历嘛),对排序好的数组进行遍历对比,找到合适的位置并插入。

插入法排序其实和冒泡法从代码上看十分相似。区别在于冒泡法是两次同向遍历,而插入法是一前一后异向遍历

此法需要一点逆向思维,虽然不如冒泡法好记忆。但也是十分简单的一种排序法。

2.2. 代码

	/**
	 * insertion Sort
	 * best:	O(n)
	 * worst:	O(n^2)
	 * average:	O(n^2)
	 */
	public static void insertSort() {
		
		int tmp;
		for (int i = 1; i < a.length; i++) {
			for (int k = i; k > 0; k--) {
				if (a[k] < a[k - 1]) {
					tmp = a[k];
					a[k] = a[k - 1];
					a[k - 1] = tmp;
				}
			}
		}
	}

3. 选择排序 selection sort

3.1. 原理

首先从全部数组中选出最小的,放在第一位,再从剩下的数组当中选出最小的,放在第二位,依此类推直到所有的数都选择完毕。

选择排序可以说是最容易理解的自然方法(就是一般人在日常生活中使用的方法)了,但是在程序当中却可以说是最慢的,因为无论何种情况下,它的时间复杂度都是O(n^2)。

3.2. 代码

	/**
	 * Selection Sort
	 * best:	O(n^2)
	 * worst:	O(n^2)
	 * average:	O(n^2)
	 */
	public static void selectionSort() {
		
		for (int i = 0; i < a.length; i++) {
			int min = a[i];//假设当前元素就是最小数
			int p = i;//记录当前元素的坐标
			for (int j = i; j < a.length; j++) {//向后遍历,找到最小数,并记录坐标
				if (a[j] < min) {
					min = a[j];
					p = j;
				}
			}
			//将找到的最小数与当前元素交换
			int tmp = a[p];
			a[p] = a[i];
			a[i] = tmp;
		}
	}

4. 归并排序 merge sort

4.1. 原理:

归并排序,将整个数列两分,然后再将子数组两份,然后再两分,一直到不可分为止,然后再将这些子数组比较组合,直到得到完整的数组。
在这里插入图片描述
归并排序,就是所谓的Divide and Conquer,利用递归的优势,将整个数组"分而治之",最后再整合起来。

这个方法应该是比较快的(当然还有更快的所谓bucket sort,不过呵呵。。。那种方法局限性太大了,我本身是不会用的,本文也不会讲),就是对编程的要求略高。

4.2. 代码

	/**
	 * Merge Sort
	 * best:	O(n*logn)
	 * worst:	O(n*logn)
	 * average:	O(n*logn)
	 */
	public static void mergeSort() {
		
		mergeSort(0, a.length - 1);
	}
	
	private static void mergeSort(int first, int last) {
		
		if (first < last) {
			int mid = (first + last) / 2;
			mergeSort(first, mid);//递归左边数组
			mergeSort(mid + 1, last);//递归右边数组
			merge(first, mid, last);//组合当前的数组
		}
	}
	
	private static void merge(int first, int mid, int last) {
		
		int[] tmp = new int[last - first + 1];
		int ll = first;
		int rl = mid + 1;
		int k = 0;
		
		//两个都不为空
		while (ll <= mid && rl <= last) {
			tmp[k++] = (a[ll] < a[rl]) ? a[ll++] : a[rl++];
		}
		
		//左边不空
		while (ll <= mid) {
			tmp[k++] = a[ll++];
		}
		
		//右边不空
		while (rl <= last) {
			tmp[k++] = a[rl++];
		}
		
		for (int i = 0; i < k; i++)
			a[first + i] = tmp[i];
			
	}

5. 快速排序 quick sort

5.1. 原理

先选定一个中心数pivot,随便选,可以是第一个,也可以是最后一个,甚至可以是中间随便一个,然后遍历整个数组,将大于pivot的数放在右边,小于pivot的数放在左边,然后再分别递归左右两边的子数组。

这个算法也是采用分治的思想(Divid and Conquer),基本上采用分治思想的算法,时间复杂度都和n*logn有关,所以效率都还行~~~~

这个算法其实好理解,难点应该在代码的实现上,如果你没有记住代码的话,重新编写可能会遇到两个问题

  • 数组的坐标要搞清楚,因为是直接在原数组上操作,一步错,步步错。
  • 怎么样将数组分成两段也是难点,我的思路是,遍历整个数组,将所有小于pivot的数都弄到数组前半段,并记录第一个大于pivot数的坐标s。那么一旦发现有新的小数,则可以直接和位于s的那个数交换,并重新记录,这样一来,就保证被记录的坐标之间的所有数都小于pivot,最后再把pivot换到s位置。实现了数组的分段。

5.2. 代码

	public static void quickSort() {
		
		quickSort(0, a.length - 1);
	}
	
	private static synchronized void quickSort(int first, int last) {
		
		if (first < last) {
			int pivot = partition(first, last);
			//这里要注意,递归的时候需要绕过已经选出来了的中心数pivot
			quickSort(first, pivot - 1);
			quickSort(pivot + 1, last);
		}
	}
	
	private static int partition(int first, int last) {
		
		int s = first;
		int tmp;
		
		for (int l = first; l < last; l++) {
			if (a[l] < a[last]) {
				tmp = a[l];
				a[l] = a[s];
				a[s] = tmp;
				s++;
			}
			
		}
		tmp = a[last];
		a[last] = a[s];
		a[s] = tmp;
		
		return s;
	}

6. 堆排序 heap sort

6.1. 原理:

1. 首先将整个数组重新排列成符合最大堆(或者最小堆)结构的数。
2. 既然这个数组符合堆结构,那么它的根元素必然是最大或者最小的,这时我们可以取出这个根元素,就可以得到整个数列的最大值或者最小值了。
3. 剩下的元素再重复刚才的步骤,直到所有的元素都取出,得到排列好的数组。

heap sort放在最后面讲,其实是因为它最难以理解,无论从代码实现还是理论知识方面都是。但是它的效率又相当优秀,所以不得不学习。。。

6.2. 二叉树与二叉堆

学习heap sort(堆排序)之前,首先要理解binary tree(二叉树)的概念,然后再理解binary heap(二叉堆)。如果懒得看wiki也没有关系,简单来说,所谓的二叉堆就是下面这个样子。
在这里插入图片描述

这是一个最大二叉堆,它有三个特性,我归纳如下:

  1. 每个结点最多两个子结点。
  2. 越上面越大(或者越小)。
  3. 除了最下面一层叶子,其余的结点必须长满!

理解二叉堆的概念以后,我们就可以知道,如果一个数组的排列符合二叉堆的结构,我们就可以轻易的得到它的最大值或是最小值,所谓排序,就是不断取出这些最值的过程

所以说堆排序的主要目的,其实就是把目标数组弄成二叉堆。

说起来简单,但是做起来却不容易,第一个难点,就是一般人很难将上图的二叉堆,和代码里面的数组联系起来,也就是说无法理解,这么一棵二维的树,怎么能放进一维的数组里面呢?

先说方法:
在这里插入图片描述
如图所示,根结点放在0位,左儿子放在1位,右儿子放在2位,以此类推,左儿子的左儿子放在3位,左儿子的右儿子放在4位。

直观点儿来讲,就是把二叉堆,从上到下,从左到右依次放入数组就行了。。。

但是这还是按照”人“的思路来操作的,那么如何在代码中实现呢?或者说,数学规律是什么呢?

总的来说,就是对于第 n n n 个结点,它的左儿子,放在 2 n + 1 2n+1 2n+1 位,右儿子则放在 2 n + 2 2n+2 2n+2了。

反过来讲,对于一个数组来说,无论它里面的元素如何排列,只要我们按照上面的规律来访问它,那么它就可以看成是一个二叉树

我至今还对想出这个方法的前辈表示无尽的膜拜 Orz。思路怎么来的,我就不讲了,数学证明什么的,你也不要想了。。。因为我也说不清楚,我只能拾人牙慧,直接讲解决方法了。

找到了这个规律,我们就可以把一个数组当成是binary tree(二叉树)来操作,并把它弄成binary heap(二叉堆)

6.3. 二叉树到二叉堆的转换

有两种方式:从上往下,或者从下往上,本文只介绍从下往上的方式。

以最大二叉堆为例
在这里插入图片描述

通过二叉堆的特性我们可以看出,如果把它放入数组当中,那么最后一个元素必定是叶子,而这个叶子的根,也应该是整个二叉堆当中的最后一个拥有叶子的结点,在它之前的所有结点,都应该拥有叶子!

如果我们从这个结点开始,遍历之前所有的结点,那么我们实际上就对所有的元素进行了操作。

根据数学规律,假设最后一个元素的坐标是 n = 2 i + 1 n=2i+1 n=2i+1 n = 2 i + 2 n=2i+2 n=2i+2,那么最后一个拥有叶子的结点的坐标就是 i = n / 2 − 1 i=n/2-1 i=n/21,因为无论 n = 2 i + 1 n=2i+1 n=2i+1 还是 n = 2 i + 2 n=2i+2 n=2i+2,程序运算 i=n/2-1;所得来的 i 都一样,因为 n / 2 n/2 n/2 是向下取整的。

知道了从哪里开始,还要知道怎么做。

我们需要做的就是保证以该结点为根,它下面所有的结点所组成的二叉树,是二叉堆。

我们先找出结点所对应的两片叶子当中较大的那片,再与结点比较,如果结点比该叶子大,则表明这是二叉堆,退出操作,如果结点比该叶子小,则交换结点与叶子,交换以后,因为叶子变成了较小的数,所以它可能比它的叶子还要小。所以如果这片叶子向下还有叶子,则应该将这片叶子看成结点,循环执行上面的操作,如果这片叶子下面没有叶子,则程退出循环。

moveDown流程图:

Created with Raphaël 2.2.0 开始 是否有叶子? 找出最大叶子 比结点大? 交换结点与叶子 结束 yes no yes no

moveDown函数代码:

	private static void moveDown(int i, int n) {
		
		int lc = 2 * i + 1;
		while (lc < n) {
			if (lc < n - 1 && a[lc] < a[lc + 1]) {
				lc++;
			}
			if (a[i] < a[lc]) {
				int tmp = a[i];
				a[i] = a[lc];
				a[lc] = tmp;
				
				i = lc;
				lc = 2 * i + 1;
			}
			else {
				lc = n;
			}
		}
	}

这里需要说明的是,如果是从上往下的做法,这个函数并不适用,因为它无法完全遍历整个二叉树,故而它没办法保证整个二叉树就是二叉堆,它只能保证,有交换动作的那一个分支符合二叉堆的特性,但由于我们是从下往上操作,在所有下层的数据率先符合二叉堆的前提下,这种不完全的遍历反而大大的提高了执行效率

主函数分成两步:

  1. 从下往上遍历所有的根,完成最大堆的初始化。
  2. 将根元素和最后一个叶元素交换,此时,整个数组的最后一个元素可以确定就是最大值,而前面N-1个元素当中,除了根元素(第1个元素),其余的元素都符合二叉堆的结构,所以我们只需要再执行一次moveDown操作,让根元素融入到整个二叉堆当中,就可以了。
	public static void heapSort() {
		
		for (int i = a.length / 2 - 1; i >= 0; i--) {
			moveDown(i, a.length);
		}
		
		for (int i = a.length - 1; i >= 1; i--) {
			int tmp = a[i];
			a[i] = a[0];
			a[0] = tmp;
			moveDown(0, i);
		}
	}

6.4. 完整代码

	public static void heapSort() {
		
		for (int i = a.length / 2 - 1; i >= 0; i--) {
			moveDown(i, a.length);
		}
		
		for (int i = a.length - 1; i >= 1; i--) {
			int tmp = a[i];
			a[i] = a[0];
			a[0] = tmp;
			moveDown(0, i);
		}
	}
	
	private static void moveDown(int i, int n) {
		
		int lc = 2 * i + 1;
		while (lc < n) {
			if (lc < n - 1 && a[lc] < a[lc + 1]) {
				lc++;
			}
			if (a[i] < a[lc]) {
				int tmp = a[i];
				a[i] = a[lc];
				a[lc] = tmp;
				
				i = lc;
				lc = 2 * i + 1;
			}
			else {
				lc = n;
			}
		}
	}

7. 关于时间复杂度的计算

计算步骤放这里方便以后复习用~~

以merge sort为例:

假设数组的长度是 2 k 2^k 2k,分治法会将这个数组一分为二,每段数组长度就是 2 k − 1 2^{k-1} 2k1

然后再将这两个数组组合起来,以上面的程序为例,组合的函数就是

	private static void merge(int first, int mid, int last) {
		
		int[] tmp = new int[last - first + 1];
		int ll = first;
		int rl = mid + 1;
		int k = 0;
		
		//两个都不为空
		while (ll <= mid && rl <= last) {
			tmp[k++] = (a[ll] < a[rl]) ? a[ll++] : a[rl++];
		}
		
		//左边不空
		while (ll <= mid) {
			tmp[k++] = a[ll++];
		}
		
		//右边不空
		while (rl <= last) {
			tmp[k++] = a[rl++];
		}
		
		for (int i = 0; i < k; i++)
			a[first + i] = tmp[i];
			
	}

当中,赋值有4次,循环n次,循环中赋值1次,循环中加法2次,最后把临时数组放入原数组又用了n次。

所以merge函数一共有 4 n + 4 4n+4 4n+4步。

这样一来,可以计算完成merge sort需要的运算次数:

T ( n ) = 2 T ( n 2 ) + 4 n + 4 T(n) = 2T({n\over2}) + 4n + 4 T(n)=2T(2n)+4n+4

展开上面的式子:

T ( n ) = 2 T ( n 2 ) + 4 n + 4 = 4 T ( n 4 ) + 2 ∗ 4 n + 4 ∗ ( 1 + 2 ) = 8 T ( n 8 ) + 3 ∗ 4 n + 4 ∗ ( 1 + 2 + 4 ) = . . . T(n) = 2T({n\over2}) + 4n + 4=4T({n\over4}) + 2*4n + 4*(1 + 2)=8T({n\over8}) + 3*4n + 4*(1 + 2 +4)=... T(n)=2T(2n)+4n+4=4T(4n)+24n+4(1+2)=8T(8n)+34n+4(1+2+4)=...

⇒ T ( n ) = 2 i T ( n 2 i ) + i ∗ 4 n + 4 ∗ ( 2 0 + 2 1 + 2 2 + . . . + 2 i − 1 ) \Rightarrow T(n) = 2^iT({n\over2^i}) + i*4n + 4*(2^0 + 2^1 + 2^2 + ... + 2^{i-1}) T(n)=2iT(2in)+i4n+4(20+21+22+...+2i1)

令 :

S = 2 0 + 2 1 + 2 2 + . . . + 2 i − 1 S = 2^0 + 2^1 + 2^2 + ... + 2^{i-1} S=20+21+22+...+2i1

⇒ 2 ∗ S = 2 1 + 2 2 + . . . + 2 i \Rightarrow 2*S = 2^1 + 2^2 + ... + 2^i 2S=21+22+...+2i

⇒ S = 2 ∗ S − S = 2 i − 1 \Rightarrow S = 2*S - S = 2^i - 1 S=2SS=2i1

代入后,得到展开式:

T ( n ) = 2 i T ( n 2 i ) + i ∗ 4 n + 4 ∗ ( 2 i − 1 ) T(n) = 2^iT({n\over2^i}) + i*4n + 4*(2^i-1) T(n)=2iT(2in)+i4n+4(2i1)

令:

n = 2 k , i ≤ k , i ∈ N n=2^k,i\le k, i \in N n=2k,ik,iN

i = k i=k i=k

⇒ T ( n ) = 2 k T ( 1 ) + k ∗ 4 n + 4 ∗ ( 2 k − 1 ) \Rightarrow T(n)=2^kT(1)+k*4n+4*(2^k-1) T(n)=2kT(1)+k4n+4(2k1)

因为 T ( 1 ) T(1) T(1)时,数组不需要操作,故 T ( 1 ) = 0 T(1)=0 T(1)=0

⇒ T ( n ) = k ∗ 4 n + 4 ∗ ( 2 k − 1 ) \Rightarrow T(n)=k*4n + 4*(2^k-1) T(n)=k4n+4(2k1)

又因为 2 k = n ⇒ k = log ⁡ n 2^k = n\Rightarrow k = \log n 2k=nk=logn

T ( n ) = 4 n ∗ log ⁡ n + 4 ∗ ( n − 1 ) T(n)=4n*\log n + 4*(n-1) T(n)=4nlogn+4(n1)

最终得到时间复杂度 O ( n ) O(n) O(n)

O ( n ) = n ∗ log ⁡ n O(n)=n*\log n O(n)=nlogn

深度学习是机器学习的一个子领域,它基于人工神经网络的研究,特别是利用多层次的神经网络来进行学习和模式识别。深度学习模型能够学习数据的高层次特征,这些特征对于图像和语音识别、自然语言处理、医学图像分析等应用至关重要。以下是深度学习的一些关键概念和组成部分: 1. **神经网络(Neural Networks)**:深度学习的基础是人工神经网络,它是由多个层组成的网络结构,包括输入层、隐藏层和输出层。每个层由多个神经元组成,神经元之间通过权重连接。 2. **前馈神经网络(Feedforward Neural Networks)**:这是最常见的神经网络类型,信息从输入层流向隐藏层,最终到达输出层。 3. **卷积神经网络(Convolutional Neural Networks, CNNs)**:这种网络特别适合处理具有网格结构的数据,如图像。它们使用卷积层来提取图像的特征。 4. **循环神经网络(Recurrent Neural Networks, RNNs)**:这种网络能够处理序列数据,如时间序列或自然语言,因为它们具有记忆功能,能够捕捉数据中的时间依赖性。 5. **长短期记忆网络(Long Short-Term Memory, LSTM)**:LSTM 是一种特殊的 RNN,它能够学习长期依赖关系,非常适合复杂的序列预测任务。 6. **生成对抗网络(Generative Adversarial Networks, GANs)**:由两个网络组成,一个生成器和一个判别器,它们相互竞争,生成器生成数据,判别器评估数据的真实性。 7. **深度学习框架**:如 TensorFlow、Keras、PyTorch 等,这些框架提供了构建、训练和部署深度学习模型的工具和库。 8. **激活函数(Activation Functions)**:如 ReLU、Sigmoid、Tanh 等,它们在神经网络中用于添加非线性,使得网络能够学习复杂的函数。 9. **损失函数(Loss Functions)**:用于评估模型的预测与真实值之间的差异,常见的损失函数包括均方误差(MSE)、交叉熵(Cross-Entropy)等。 10. **优化算法(Optimization Algorithms)**:如梯度下降(Gradient Descent)、随机梯度下降(SGD)、Adam 等,用于更新网络权重,以最小化损失函数。 11. **正则化(Regularization)**:技术如 Dropout、L1/L2 正则化等,用于防止模型过拟合。 12. **迁移学习(Transfer Learning)**:利用在一个任务上训练好的模型来提高另一个相关任务的性能。 深度学习在许多领域都取得了显著的成就,但它也面临着一些挑战,如对大量数据的依赖、模型的解释性差、计算资源消耗大等。研究人员正在不断探索新的方法来解决这些问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值