而最近也有一个新的 GAN 框架工具,并且是基于 Pytorch 实现的,项目地址如下:
https://github.com/torchgan/torchgan
对于习惯使用 Pytorch 框架的同学,现在可以采用这个开源项目快速搭建一个 GAN 网络模型了!
目前该开源项目有 400+ 星,它给出了安装的教程、API 文档以及使用教程,文档的地址如下:
https://torchgan.readthedocs.io/en/latest/
对于 TorchGAN 的安装,官网给出 3 种方法,但实际上目前仅支持两种安装方式,分别是pip
方式安装以及源码安装,采用conda
安装的方法目前还不支持。
安装最新的发布版本的命令如下:
1 | $ pip3 install torchgan |
而如果是最新版本:
1 | $ pip3 install git+https://github.com/torchgan/torchgan.git |
这是目前版本还不支持的安装方式,将会在v0.1
版本实现这种安装方法。
按照下列命令的顺序执行来进行从源码安装
1 | $ git clone https://github.com/torchgan/torchgan $ cd torchgan $ python setup.py install |
必须按照的依赖库:
可选
Tensorboard
来观察和记录实验结果。安装通过命令pip install tensorboardX
Xisdom
进行记录。安装通过命令pip install visdom
API 的文档目录如下:
从目录主要分为以下几个大类:
教程部分如下所示:
教程给出了几个例子,包括 DCGAN、Self-Attention GAN、CycleGAN 例子,以及如何自定义损伤的方法。
对于 Self-Attention GAN,还提供了一个在谷歌的 Colab 运行的例子,查看链接:
https://torchgan.readthedocs.io/en/latest/tutorials/sagan.html
最后,再给出 Github 项目的链接和文档的对应链接地址:
https://github.com/torchgan/torchgan
https://torchgan.readthedocs.io/en/latest/index.html
欢迎关注我的微信公众号—机器学习与计算机视觉,或者扫描下方的二维码,大家一起交流,学习和进步!
之前分享的资源和教程文章有:
]]>有许多方法可以衡量编程语言的流行程度。 在Octoverse报告中,GitHub使用了:
其中,2008 - 2018年创建的存储库的顶级编程语言
其中 JavaScript 在这十年内创建的仓库是最多的,而 Java 和 Python 则是紧随其后。
截至2018年9月30日,贡献者使用的顶级编程语言
同样地,JavaScript 也是公共和私有存储库中贡献者使用最多的编程语言。而 Java 和 Python 则继续位列第二和第三名。
实际上,随着目前人工智能的兴起和火热,Python 的使用率是越来越高了,除了可以在人工智能方面会应用 Python,它还可以应用于爬虫、数据分析等领域,应用是非常的广泛。
最近,有位国外友人在 Github 上分享了一份资源,包含了 15 个领域,总共 181 个 Python 的开源项目,为 Github 上的 Awesome 系列又增添了一个。
https://github.com/mahmoud/awesome-python-applications
这个 Awesome 项目如下图所示:
这个项目包含的 15 个领域分别是:
互联网、音频、视频、图形、游戏、生产力、组织、通讯、教育、科学、CMS、ERP、静态站点、开发和其他。
每个项目都给出了一个简单的介绍,以及项目地址,即 Github 主页(Repo),有的项目还附带项目成品主页的链接(Home),文档链接(Docs),维基百科页面(WP),比如对于互联网这个领域:
这就是本次分享的 Python 开源项目了!
欢迎关注我的微信公众号—机器学习与计算机视觉,或者扫描下方的二维码,大家一起交流,学习和进步!
之前分享的资源和教程文章有:
]]>有许多方法可以衡量编程语言的流行程度。 在Octoverse报告中,GitHub使用了:
以及原文的地址:
http://bamos.github.io/2016/08/09/deep-completion/
最后一部分的目录如下:
在第二步中,我们定义并训练了判别器D(x)
和生成器G(z)
,那接下来就是如何利用DCGAN
网络模型来完成图片的修复工作了。
在这部分,作者会参考论文“Semantic Image Inpainting with Perceptual and Contextual Losses” 提出的方法。
对于部分图片y
,对于缺失的像素部分采用最大化D(y)
这种看起来合理的做法并不成功,它会导致生成一些既不属于真实数据分布,也属于生成数据分布的像素值。如下图所示,我们需要一种合理的将y
映射到生成数据分布上。
首先我们先定义几个符号来用于图像修复。用M
表示一个二值的掩码(Mask),即只有 0 或者是 1 的数值。其中 1 数值表示图片中要保留的部分,而 0 表示图片中需要修复的区域。定义好这个 Mask 后,接下来就是定义如何通过给定一个 Mask 来修复一张图片y
,具体的方法就是让y
和M
的像素对应相乘,这种两个矩阵对应像素的方法叫做哈大马乘积),并且表示为 M ⊙ y
,它们的乘积结果会得到图片中原始部分,如下图所示:
接下来,假设我们从生成器G
的生成结果找到一张图片,如下图公式所示,第二项表示的是DCGAN
生成的修复部分:
根据上述公式,我们知道最重要的就是第二项生成部分,也就是需要实现很好修复图片缺失区域的做法。为了实现这个目的,这就需要回顾在第一步提出的两个重要的信息,上下文和感知信息。而这两个信息的获取主要是通过损失函数来实现。损失函数越小,表示生成的G(z)
越适合待修复的区域。
为了保证输入图片相同的上下文信息,需要让输入图片y
(可以理解为标签)中已知的像素和对应在G(z)
中的像素尽可能相似,因此需要对产生不相似像素的G(z)
做出惩罚。该损失函数如下所示,采用的是 L1 正则化方法:
这里还可以选择采用 L2 正则化方法,但论文中通过实验证明了 L1 正则化的效果更好。
理想的情况是y
和G(z)
的所有像素值都是相同的,也就是说它们是完全相同的图片,这也就让上述损失函数值为0
为了让修复后的图片看起来非常逼真,我们需要让判别器D
具备正确分辨出真实图片的能力。对应的损失函数如下所示:
因此,最终的损失函数如下所示:
这里 λ 是一个超参数,用于控制两个函数的各自重要性。
另外,论文还采用泊松混合(poisson blending) 方法来平滑重构后的图片。
代码实现的项目地址如下:
https://github.com/bamos/dcgan-completion.tensorflow
首先需要新添加的变量是表示用于修复的 mask,如下所示,其大小和输入图片一样
1 | self.mask = tf.placeholder(tf.float32, [None] + self.image_shape, name='mask') |
对于最小化损失函数的方法是采用常用的梯度下降方法,而在 TensorFlow 中已经实现了自动微分的方法,因此只需要添加待实现的损失函数代码即可。添加的代码如下所示:
1 | self.contextual_loss = tf.reduce_sum( tf.contrib.layers.flatten( tf.abs(tf.mul(self.mask, self.G) - tf.mul(self.mask, self.images))), 1) self.perceptual_loss = self.g_loss self.complete_loss = self.contextual_loss + self.lam*self.perceptual_loss self.grad_complete_loss = tf.gradients(self.complete_loss, self.z) |
接着,就是定义一个 mask。这里作者实现的是位置在图片中心部分的 mask,可以根据需求来添加需要的任意随机位置的 mask,实际上代码中实现了多种 mask
1 | if config.maskType == 'center': scale = 0.25 assert(scale <= 0.5) mask = np.ones(self.image_shape) l = int(self.image_size*scale) u = int(self.image_size*(1.0-scale)) mask[l:u, l:u, :] = 0.0 |
因为采用梯度下降,所以采用一个 mini-batch 的带有动量的映射梯度下降方法,将z
映射到[-1,1]
的范围。代码如下:
1 | for idx in xrange(0, batch_idxs): batch_images = ... batch_mask = np.resize(mask, [self.batch_size] + self.image_shape) zhats = np.random.uniform(-1, 1, size=(self.batch_size, self.z_dim)) v = 0 for i in xrange(config.nIter): fd = { self.z: zhats, self.mask: batch_mask, self.images: batch_images, } run = [self.complete_loss, self.grad_complete_loss, self.G] loss, g, G_imgs = self.sess.run(run, feed_dict=fd) # 映射梯度下降方法 v_prev = np.copy(v) v = config.momentum*v - config.lr*g[0] zhats += -config.momentum * v_prev + (1+config.momentum)*v zhats = np.clip(zhats, -1, 1) |
选择需要进行修复的图片,并放在文件夹dcgan-completion.tensorflow/your-test-data/raw
下面,然后根据之前第二步的做法来对人脸图片进行对齐操作,然后将操作后的图片放到文件夹dcgan-completion.tensorflow/your-test-data/aligned
。作者随机从数据集LFW
中挑选图片进行测试,并且保证其DCGAN
模型的训练集没有包含LFW
中的人脸图片。
接着可以运行下列命令来进行修复工作了:
1 | ./complete.py ./data/your-test-data/aligned/* --outDir outputImages |
上面的代码会将修复图片结果保存在--outDir
参数设置的输出文件夹下,接着可以采用ImageMagick
工具来生成动图。这里因为动图太大,就只展示修复后的结果图片:
而原始的输入待修复图片如下:
最后,再给出前两步的文章链接:
当然这个图片修复方法由于也是2016年提出的方法了,所以效果不算特别好,这两年其实已经新出了好多篇新的图片修复方法的论文,比如:
Deepfill 2018—Generative Image Inpainting with Contextual Attention
Deepfillv2—Free-Form Image Inpainting with Gated Convolution
2017CVPR—High-resolution image inpainting using multi-scale neural patch synthesis
2018年的 NIPrus收录论文—Image Inpainting via Generative Multi-column Convolutional Neural Networks
欢迎关注我的微信公众号—机器学习与计算机视觉,或者扫描下方的二维码,在后台留言,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
往期精彩推荐
]]>这篇文章将继续介绍原文的第二部分,利用对抗生成网络来快速生成假图片。目录如下:
同样的,标题带有 [ML-Heavy] 的会介绍比较多的细节,可以选择跳过。
与其考虑如何计算概率密度函数,现在在统计学中更好的方法是采用一个生成模型来学习如何生成新的、随机的样本。过去生成模型一直是很难训练或者非常难以实现,但最近在这个领域已经有了一些让人惊讶的进展。Yann LeCun在这篇 Quora 上的问题“最近在深度学习有什么潜在的突破的领域”中给出了一种训练生成模型(对抗训练)方法的介绍,并将其描述为过去十年内机器学习最有趣的想法:
Yann LeCun 在回答中简单介绍了 GAN 的基本原理,也就是两个网络相互博弈的过程。
实际上,深度学习还有其他方法来训练生成模型,比如 Variational Autoencoders(VAEs)。但在本文,主要介绍对抗生成网络(GANs)
GANs 这个想法是 Ian Goodfellow 在其带有里程碑意义的论文“Generative Adversarial Nets” (GANs)发表在 2014 年的 Neural Information Processing Systems (NIPS) 会议上后开始火遍整个深度学习领域的。这个想法就是我们首先定义一个简单并众所周知的概率分布,并表示为$p_z$,在本文后面,我们用 $p_z$ 表示在[-1,1)(包含-1,但不包含1)范围的均匀分布。用$z \thicksim p_z$表示从这个分布中采样,如果$p_z$是一个五维的,我们可以利用下面一行的 Python 代码来进行采样得到,这里用到 numpy这个库:
1 | z = np.random.uniform(-1, 1, 5) array([ 0.77356483, 0.95258473, -0.18345086, 0.69224724, -0.34718733]) |
现在我们有一个简单的分布来进行采样,接下来可以定义一个函数G(z)
来从原始的概率分布中生成样本,代码例子如下所示:
1 | def G(z): ... return imageSample z = np.random.uniform(-1, 1, 5) imageSample = G(z) |
那么问题来了,怎么定义这个G(Z)
函数,让它实现输入一个向量然后返回一张图片呢?答案就是采用一个深度神经网络。对于深度神经网络基础,网络上有很多的介绍,本文就不再重复介绍了。这里推荐的一些参考有斯坦福大学的 CS231n 课程、Ian Goodfellow 等人编著的《深度学习》书籍、形象解释图像的核心以及论文“A guide to convolution arithmetic for deep learning”。
通过深度学习可以有多种方法来实现G(z)
函数。在原始的 GAN 论文中提出一种训练方法并给出初步的实验结果,这个方法得到了极大的发展和改进。其中一种想法就是在论文“Unsupervised Representation Learning with Deep Convolutional Generative Adversarial Networks”中提出的,这篇论文的作者是 Alec Radford, Luke Metz, and Soumith Chintala,发表在 2016 年的 International Conference on Learning Representations (ICLR)会议上,这个方法因为提出采用深度卷积神经网络,被称为 DCGANs,它主要采用小步长卷积( fractionally-strided convolution)方法来上采样图像。
那么什么是小步长卷积以及如何实现对图片的上采样呢? Vincent Dumoulin and Francesco Visin’s 在论文“A guide to convolution arithmetic for deep learning”以及 Github 项目都给出了这种卷积算术的详细介绍,Github 地址如下:
https://github.com/vdumoulin/conv_arithmetic
上述 Github 项目给出了非常直观的可视化,如下图所示,这让我们可以很直观了解小步长卷积是如何工作的。
首先,你要知道一个正常的卷积操作是一个卷积核划过输入区域(下图中蓝色区域)后生成一个输出区域(下图的绿色区域)。这里,输出区域的尺寸是小于输入区域的。(当然,如果你还不知道,可以先看下斯坦福大学的CS231n 课程或者论文“A guide to convolution arithmetic for deep learning”。)
接下来,假设输入是 3x3。我们的目标是通过上采样让输出尺寸变大。你可以认为小步长卷积就是在像素之间填充 0 值来拓展输入区域的方法,然后再对输入区域进行卷积操作,正如下图所示,得到一个 5x5 的输出。
注意,对于作为上采样的卷积层有很多不同的名字,比如全卷积(full convolution), 网络内上采样(in-network upsampling), 小步长卷积(fractionally-strided convolution), 反向卷积(backwards convolution), 反卷积(deconvolution), 上卷积(upconvolution), 转置卷积(transposed convolution)。这里并不鼓励使用反卷积(deconvolution)这个词语,因为在数学运算或者计算机视觉的其他应用中,这个词语有着其他完全不同的意思,这是一个非常频繁使用的词语。
现在利用小步长卷积作为基础,我们可以实现G(z)
函数,让它接收一个$z \thicksim p_z$的向量输入,然后输出一张尺寸是 64x64x3 的彩色图片,其网络结构如下图所示:
在 DCGAN 这篇论文中还提出了其他的一些技巧和改进来训练 DCGANs,比如采用批归一化(batch normalization)或者是 leaky ReLUs 激活函数。
现在先让我们暂停并欣赏下这种G(z)
网络结构的强大,在 DCGAN 论文中给出了如何采用一个卧室图片数据集训练 一个 DCGAN 模型,然后采用G(z)
生成如下的图片,它们都是生成器网络 G 认为的卧室图片,注意,下面这些图片都是原始训练数据集没有的!
此外,你还可以对 z
输入实现一个向量算术操作,下图就是一个例子:
现在我们定义好了G(z)
,也知道它的能力有多强大,问题来了,怎么训练呢?我们需要确定很多隐变量(或者说参数),这也是采用对抗网络的原因了。
首先,我们先定义几个符号。$p_data$表示训练数据,但概率分布未知,$p_z$表示从已知的概率分布采样的样本,一般从高斯分布或者均匀分布采样,z
也被称为随机噪声,最后一个,$p_g$就是 G 网络生成的数据,也可以说是生成概率分布。
接着介绍下判别器(discriminator,D)网络,它是输入一批图片x
,然后返回该图片来自训练数据$p_{data}$的概率。如果来自训练数据,D 应该返回一个接近 1 的数值,否则应该是一个接近 0 的值来表示图片是假的,来自 G 网络生成的。在 DCGANs 中,D 网络是一个传统的卷积神经网络,如下图所示,一个包含4层卷积层和1层全连接层的卷积神经网络结构。
因此,训练 D 网络的目标有以下两个:
x
来自训练数据集,最大化D(x)
;x
是来自 G 生成的数据,最小化D(x)
。对应的 G 网络的目标就是要欺骗 D 网络,生成以假乱真的图片。它生成的图片也是 D 的输入,所以 G 的目标就是最大化D(G(z))
,也等价于最小化1-D(G(z))
,因为 D 其实是一个概率估计,且输出范围是在 0 到 1 之间。
正如论文提到的,训练对抗网络就如同在实现一个最小化最大化游戏(minimax game)。如下面的公式所示,第一项是对真实数据分布的期望,第二项是对生成数据的期望值。
训练的步骤如下图所示,具体可以看下我之前写的文章[GAN学习系列2] GAN的起源有简单介绍了这个训练过程,或者是看下 GAN 论文[5]的介绍
目前在 Github 上有许多 GAN 和 DCGAN 的实现(原文是写于2016年八月份,现在的话代码就更多了):
本文实现的代码是基于 https://github.com/carpedm20/DCGAN-tensorflow
这部分的实现的源代码可以在如下 Github 地址:
https://github.com/bamos/dcgan-completion.tensorflow
当然,主要实现部分代码是来自 https://github.com/carpedm20/DCGAN-tensorflow 。但采用这个项目主要是方便实现下一部分的图像修复工作。
主要实现代码是在model.py
中的类DCGAN
。采用类来实现模型是有助于训练后保存中间层的状态以及后续的加载使用。
首先,我们需要定义生成器和判别器网络结构。在ops.py
会定义网络结构用到的函数,如linear
,conv2d_transpose
, conv2d
以及 lrelu
。代码如下所示
1 | def generator(self, z): self.z_, self.h0_w, self.h0_b = linear(z, self.gf_dim*8*4*4, 'g_h0_lin', with_w=True) self.h0 = tf.reshape(self.z_, [-1, 4, 4, self.gf_dim * 8]) h0 = tf.nn.relu(self.g_bn0(self.h0)) self.h1, self.h1_w, self.h1_b = conv2d_transpose(h0, [self.batch_size, 8, 8, self.gf_dim*4], name='g_h1', with_w=True) h1 = tf.nn.relu(self.g_bn1(self.h1)) h2, self.h2_w, self.h2_b = conv2d_transpose(h1, [self.batch_size, 16, 16, self.gf_dim*2], name='g_h2', with_w=True) h2 = tf.nn.relu(self.g_bn2(h2)) h3, self.h3_w, self.h3_b = conv2d_transpose(h2, [self.batch_size, 32, 32, self.gf_dim*1], name='g_h3', with_w=True) h3 = tf.nn.relu(self.g_bn3(h3)) h4, self.h4_w, self.h4_b = conv2d_transpose(h3, [self.batch_size, 64, 64, 3], name='g_h4', with_w=True) return tf.nn.tanh(h4) def discriminator(self, image, reuse=False): if reuse: tf.get_variable_scope().reuse_variables() h0 = lrelu(conv2d(image, self.df_dim, name='d_h0_conv')) h1 = lrelu(self.d_bn1(conv2d(h0, self.df_dim*2, name='d_h1_conv'))) h2 = lrelu(self.d_bn2(conv2d(h1, self.df_dim*4, name='d_h2_conv'))) h3 = lrelu(self.d_bn3(conv2d(h2, self.df_dim*8, name='d_h3_conv'))) h4 = linear(tf.reshape(h3, [-1, 8192]), 1, 'd_h3_lin') return tf.nn.sigmoid(h4), h4 |
当初始化这个类的时候,就相当于用上述函数来构建了这个模型。我们需要创建两个 D 网络来共享参数,一个的输入是真实数据,另一个是来自 G 网络的生成数据。
1 | self.G = self.generator(self.z) self.D, self.D_logits = self.discriminator(self.images) self.D_, self.D_logits_ = self.discriminator(self.G, reuse=True) |
接下来是定义损失函数。这里采用的是 D 的输出之间的交叉熵函数,并且它的效果也不错。D 是期望对真实数据的预测都是 1,对生成的假数据预测都是 0,相反,生成器 G 希望 D 的预测都是 1。代码的实现如下:
1 | self.d_loss_real = tf.reduce_mean( tf.nn.sigmoid_cross_entropy_with_logits(self.D_logits, tf.ones_like(self.D))) self.d_loss_fake = tf.reduce_mean( tf.nn.sigmoid_cross_entropy_with_logits(self.D_logits_, tf.zeros_like(self.D_))) self.d_loss = self.d_loss_real + self.d_loss_fake self.g_loss = tf.reduce_mean( tf.nn.sigmoid_cross_entropy_with_logits(self.D_logits_, tf.ones_like(self.D_))) |
接着是分别对 G 和 D 的参数聚集到一起,方便后续的梯度计算:
1 | t_vars = tf.trainable_variables() self.d_vars = [var for var in t_vars if 'd_' in var.name] self.g_vars = [var for var in t_vars if 'g_' in var.name] |
现在才有 ADAM 作为优化器来计算梯度,ADAM 是一个深度学习中常用的自适应非凸优化方法,它相比于随机梯度下降方法,不需要手动调整学习率、动量(momentum)以及其他的超参数。
1 | d_optim = tf.train.AdamOptimizer(config.learning_rate, beta1=config.beta1) \ .minimize(self.d_loss, var_list=self.d_vars) g_optim = tf.train.AdamOptimizer(config.learning_rate, beta1=config.beta1) \ .minimize(self.g_loss, var_list=self.g_vars) |
定义好模型和训练策略后,接下来就是开始输入数据进行训练了。在每个 epoch 中,先采样一个 mini-batch 的图片,然后运行优化器来更新网络。有趣的是如果 G 只更新一次,D 的 loss 是不会变为0的。此外,在后面额外调用d_loss_fake
和d_loss_real
会增加不必要的计算量,并且也是多余的,因为它们的数值在d_optim
和g_optim
计算的时候已经计算到了。这里你可以尝试优化这部分代码,然后发送一个 PR 到原始的 Github 项目中。
1 | for epoch in xrange(config.epoch): ... for idx in xrange(0, batch_idxs): batch_images = ... batch_z = np.random.uniform(-1, 1, [config.batch_size, self.z_dim]) \ .astype(np.float32) # Update D network _, summary_str = self.sess.run([d_optim, self.d_sum], feed_dict={ self.images: batch_images, self.z: batch_z }) # Update G network _, summary_str = self.sess.run([g_optim, self.g_sum], feed_dict={ self.z: batch_z }) # Run g_optim twice to make sure that d_loss does not go to zero # (different from paper) _, summary_str = self.sess.run([g_optim, self.g_sum], feed_dict={ self.z: batch_z }) errD_fake = self.d_loss_fake.eval({self.z: batch_z}) errD_real = self.d_loss_real.eval({self.images: batch_images}) errG = self.g_loss.eval({self.z: batch_z}) |
完整的代码可以在 https://github.com/bamos/dcgan-completion.tensorflow/blob/master/model.py 中查看
如果你跳过上一小节,但希望运行一些代码:这部分的实现的源代码可以在如下 Github 地址:
https://github.com/bamos/dcgan-completion.tensorflow
当然,主要实现部分代码是来自 https://github.com/carpedm20/DCGAN-tensorflow 。但采用这个项目主要是方便实现下一部分的图像修复工作。但必须注意的是,如果你没有一个可以使用 CUDA 的 GPU 显卡,那么训练网络将会非常慢。
首先需要克隆两份项目代码,地址分别如下:
https://github.com/bamos/dcgan-completion.tensorflow
http://cmusatyalab.github.io/openface
第一份就是作者的项目代码,第二份是采用 OpenFace 的预处理图片的 Python 代码,并不需要安装它的 Torch 依赖包。先创建一个新的工作文件夹,然后开始克隆,如下所示:
1 | git clone https://github.com/cmusatyalab/openface.git git clone https://github.com/bamos/dcgan-completion.tensorflow.git |
接着是安装 Python2 版本的 OpenCV和 dlib(采用 Python2 版本是因为 OpenFace 采用这个版本,当然你也可以尝试修改为适应 Python3 版本)。对于 OpenFace 的 Python 库安装,可以查看其安装指导教程,链接如下:
http://cmusatyalab.github.io/openface/setup/
此外,如果你没有采用一个虚拟环境,那么需要加入sudo
命令来运行setup.py
实现全局的安装 OpenFace,当然如果安装这部分有问题,也可以采用 OpenFace 的 docker 镜像安装。安装的命令如下所示
1 | cd openface pip2 install -r requirements.txt python2 setup.py install models/get-models.sh cd .. |
接着就是下载一些人脸图片数据集了,这里并不要求它们是否带有标签,因为不需要。目前开源可选的数据集包括
然后将数据集放到目录dcgan-completion.tensorflow/data/your-dataset/raw
下表示其是原始的图片。
接着采用 OpenFace 的对齐工具来预处理图片并调整成64x64
的尺寸:
1 | ./openface/util/align-dlib.py data/dcgan-completion.tensorflow/data/your-dataset/raw align innerEyesAndBottomLip data/dcgan-completion.tensorflow/data/your-dataset/aligned --size 64 |
最后是整理下保存对齐图片的目录,保证只包含图片而没有其他的子文件夹:
1 | cd dcgan-completion.tensorflow/data/your-dataset/aligned find . -name '*.png' -exec mv {} . \; find . -type d -empty -delete cd ../../.. |
然后确保已经安装了 TensorFlow,那么可以开始训练 DCGAN了:
1 | ./train-dcgan.py --dataset ./data/your-dataset/aligned --epoch 20 |
在samples
文件夹中可以查看保存的由 G 生成的图片。这里作者是采用手上有的两个数据集 CASIA-WebFace 和 FaceScrub 进行训练,并在训练 14 个 epochs 后,生成的结果如下图所示:
还可以通过 TensorBoard 来查看 loss 的变化:
1 | tensorboard --logdir ./logs |
这就是本文的第二部分内容,主要是介绍了 DCGAN 的基本原理以及代码实现,还有就是训练前的准备和开始训练,训练的实验结果。
在下一篇将介绍最后一步内容,如何利用 DCGAN 来实现图像修复的工作!
欢迎关注我的微信公众号—机器学习与计算机视觉,或者扫描下方的二维码,在后台留言,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
推荐阅读
]]>原文是:
http://bamos.github.io/2016/08/09/deep-completion/
由于原文比较长,所以会分为 3 篇来介绍。
这篇文章的目录如下:
本文会先讲述背景和第一步的工作内容。
设计师和摄像师习惯使用一个非常强有力的工具—内容感知填充,来修复图片中不需要或者缺失的部分。图像修复是指用于修复图像中缺失或者毁坏的部分区域。实现图像的修复有很多种方法。在本文中,介绍的是在 2016年7月26日发表在 arXiv 上的论文“Semantic Image Inpainting with Perceptual and Contextual Losses”,这篇论文介绍如何采用 DCGAN 来实现图像修复。这篇文章会即兼顾非机器学习背景和有机器学习背景的读者,带有 [ML-Heavy] 标签的标题内容表示可以跳过这部分细节内容。我们只考虑有限制的修复带有缺失像素的人脸图片的例子。TensorFlow 实现的源代码可以在下面的 Github 地址上查看:
https://github.com/bamos/dcgan-completion.tensorflow
我们将从以下三个步骤来完成图片修复工作:
下面是两张修复前和修复后的图片例子:
下面是本文将用到的带有缺失区域的人脸例子:
对于上述几张图片例子,假设你正在设计一个系列来填充这些缺失的区域,你会选择如何做?你认为人脑会怎么处理它呢?你需要使用哪些信息来实现这个修复工作呢?
本文会主要关注下面两种信息:
这两种信息都非常重要。没有上下文信息,你怎么知道填充什么信息呢?没有感知信息,对于一个上下文来说会有很多种有效的填充方式。比如一些对于机器学习系统来说看上去是“正常”的填充信息,但对于我们人类来说其实就是非常奇怪的填充内容。
因此,有一个即精确又直观的捕获这两种属性,并且可以解释说明如何一步步实现图像修复的算法是再好不过了。创造出这样的算法可能只会适用于特殊的例子,但通常都没有人知道如何创造这样的算法。现在最佳的做法是使用统计数据和机器学习方法来实现一种近似的技术。
为了解释这个问题,首先介绍一个非常好理解而且能简明表示的概率分布:正态分布。下面是一个正态分布的概率密度函数(probability density function, PDF)的图示。你可以这么理解 PDF,它是水平方向表示输入空间的数值,在垂直方向上表示默写数值发生的概率。
上面这张图的绘制代码如下:
1 | # !/usr/bin/env python3 import numpy as np from scipy.stats import norm import matplotlib as mpl mpl.use('Agg') import matplotlib.pyplot as plt plt.style.use('bmh') import matplotlib.mlab as mlab np.random.seed(0) ### 绘制一个正态分布的概率密度函数图### # 生成数据 X范围是(-3,3),步进为0.001, Y的范围是(0,1) X = np.arange(-3, 3, 0.001) Y = norm.pdf(X, 0, 1) # 绘制 fig = plt.figure() plt.plot(X, Y) plt.tight_layout() plt.savefig("./images/normal-pdf.png") |
接着可以从上述分布中采样得到一些样本数据,如下图所示:
绘制代码如下:
1 | ### 绘制从正态分布采样的 1D 散点图例子 ### nSamples = 35 # np.random.normal 是从正态分布中随机采样指定数量的样本,这里指定 35个 X = np.random.normal(0, 1, nSamples) Y = np.zeros(nSamples) fig = plt.figure(figsize=(7, 3)) # 绘制散点图 plt.scatter(X, Y, color='k') plt.xlim((-3, 3)) frame = plt.gca() frame.axes.get_yaxis().set_visible(False) plt.savefig("./images/normal-samples.png") |
这是 1 维概率分布的例子,因为输入数据就只是一维数据,我们也可以实现二维的例子,如下图所示:
绘制代码如下:
1 | ### 绘制从正态分布采样的 2D 散点图例子### delta = 0.025 # 设置 X,Y 的数值范围和步长值,分别生成 240个数 x = np.arange(-3.0, 3.0, delta) y = np.arange(-3.0, 3.0, delta) print('x shape', x.shape) # 根据坐标向量来生成坐标矩阵 X, Y = np.meshgrid(x, y) # X, Y shape: (240, 240) print('X shape', X.shape) print('Y shape', Y.shape) # Bivariate Gaussian distribution for equal shape *X*, *Y* # 等形状的双变量高斯分布 Z = mlab.bivariate_normal(X, Y, 1.0, 1.0, 0.0, 0.0) # Z shape (240, 240) print('Z shape', Z.shape) plt.figure() # 绘制环形图轮廓 CS = plt.contour(X, Y, Z) plt.clabel(CS, inline=1, fontsize=10) nSamples = 200 mean = [0, 0] cov = [[1, 0], [0, 1]] # 从多元正态分布中采样,得到结果图中的黑点例子 X, Y = np.random.multivariate_normal(mean, cov, nSamples).T plt.scatter(X, Y, color='k') plt.savefig("./images/normal-2d.png") |
绘制上述三张图的完整代码如下所示,代码地址为:
https://github.com/bamos/dcgan-completion.tensorflow/blob/master/simple-distributions.py
图片和统计学之间的关键关系就是我们可以将图片解释为高维概率分布的样本。概率分布就体现在图片的像素上。假设你正采用你的相机进行拍照,照片的像素数量是有限的,当你用相机拍下一张照片的时候,就相当于从这个复杂的概率分布中进行采样的操作。而这个分布也是我们用来定义一张图片是否正常。和正态分布不同的是,只有图片,我们是不知道真实的概率分布,只是在收集样本而已。
在本文中,我们采用 RGB 颜色模型表示的彩色图片。我们采用的是宽和高都是 64 像素的图片,所以概率分布的维度应该是 64×64×3≈12k。
首先为了更加直观,我们先考虑之前介绍的多元正态分布。给定x=1
时,y
最有可能的取值是什么呢?这可以通过固定x=1
,然后最大化 PDF 的值来找到所有可能的y
的取值。如下图所示:
上图中垂直的黑色直线经过的黑点就是符合要求的y
值。
这个概念可以延伸到我们的图像概率分布中,当我们知道某些数值,然后想填补完成缺失的数值的时候。只需要将它当做寻找所有可能缺失数值的最大问题,那么找到的结果就是最有可能的图片。
从视觉上观察由正态分布采样得到的样本,仅凭它们就找到概率密度函数是一件似乎很合理的事情。我们只需要选择最喜欢的统计模型并将其与数据相适应即可。
然而,我们并不会应用这个方法。虽然从简单分布中恢复概率密度函数是很简单,但这对于图像的复杂分布是非常困难和棘手的事情。其复杂性一定程度上是来自于复杂的条件独立性:图像中的每个像素值之间都是相互依赖的。因此,最大化一个通用的概率密度函数是一个极其困难而且往往难以解决的非凸优化问题。
第一篇主要介绍了图像修复的简单背景,然后就是开始实现的第一步,也是比较偏理论,将我们待处理的图片数据作为一个概率分布的样本,并简单用代码实现了一维和二维的正态分布函数图。
在下一篇将介绍第二步内容,也就是快速生成假数据的工作。
欢迎关注我的微信公众号—机器学习与计算机视觉,或者扫描下方的二维码,在后台留言,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
我的个人博客:
CSDN 博客:
]]>本文大约 5000 字,阅读大约需要 10 分钟
这是 GAN 学习系列的第二篇文章,这篇文章将开始介绍 GAN 的起源之作,鼻祖,也就是 Ian Goodfellow 在 2014 年发表在 ICLR 的论文—Generative Adversarial Networks”,当然由于数学功底有限,所以会简单介绍用到的数学公式和背后的基本原理,并介绍相应的优缺点。
在[GAN学习系列] 初识GAN中,介绍了 GAN 背后的基本思想就是两个网络彼此博弈。生成器 G 的目标是可以学习到输入数据的分布从而生成非常真实的图片,而判别器 D 的目标是可以正确辨别出真实图片和 G 生成的图片之间的差异。正如下图所示:
上图给出了生成对抗网络的一个整体结构,生成器 G 和判别器 D 都是有各自的网络结构和不同的输入,其中 G 的输出,即生成的样本也是 D 的输入之一,而 D 则会为 G 提供梯度进行权重的更新。
那么问题来了,如果 D 是一个非常好的分类器,那么我们是否真的可以生成非常逼真的样本来欺骗它呢?
在正式介绍 GAN 的原理之前,先介绍一个概念—对抗样本(adversarial example),它是指经过精心计算得到的用于误导分类器的样本。例如下图就是一个例子,左边是一个熊猫,但是添加了少量随机噪声变成右图后,分类器给出的预测类别却是长臂猿,但视觉上左右两幅图片并没有太大改变。
所以为什么在简单添加了噪声后会误导分类器呢?
这是因为图像分类器本质上是高维空间的一个复杂的决策边界。当然涉及到图像分类的时候,由于是高维空间而不是简单的两维或者三维空间,我们无法画出这个边界出来。但是我们可以肯定的是,训练完成后,分类器是无法泛化到所有数据上,除非我们的训练集包含了分类类别的所有数据,但实际上我们做不到。而做不到泛化到所有数据的分类器,其实就会过拟合训练集的数据,这也就是我们可以利用的一点。
我们可以给图片添加一个非常接近于 0 的随机噪声,这可以通过控制噪声的 L2 范数来实现。L2 范数可以看做是一个向量的长度,这里有个诀窍就是图片的像素越多,即图片尺寸越大,其平均 L2 范数也就越大。因此,当添加的噪声的范数足够低,那么视觉上你不会觉得这张图片有什么不同,正如上述右边的图片一样,看起来依然和左边原始图片一模一样;但是,在向量空间上,添加噪声后的图片和原始图片已经有很大的距离了!
为什么会这样呢?
因为在 L2 范数看来,对于熊猫和长臂猿的决策边界并没有那么远,添加了非常微弱的随机噪声的图片可能就远离了熊猫的决策边界内,到达长臂猿的预测范围内,因此欺骗了分类器。
除了这种简单的添加随机噪声,还可以通过图像变形的方式,使得新图像和原始图像视觉上一样的情况下,让分类器得到有很高置信度的错误分类结果。这种过程也被称为对抗攻击(adversarial attack),这种生成方式的简单性也是给 GAN 提供了解释。
现在如果将上述说的分类器设定为二值分类器,即判断真和假,那么根据 Ian Goodfellow 的原始论文的说法,它就是判别器(Discriminator)。
有了判别器,那还需要有生成假样本来欺骗判别器的网络,也就是生成器 (Generator)。这两个网络结合起来就是生成对抗网络(GAN),根据原始论文,它的目标如下:
两个网络的工作原理可以如下图所示,D 的目标就是判别真实图片和 G 生成的图片的真假,而 G 是输入一个随机噪声来生成图片,并努力欺骗 D 。
简单来说,GAN 的基本思想就是一个最小最大定理,当两个玩家(D 和 G)彼此竞争时(零和博弈),双方都假设对方采取最优的步骤而自己也以最优的策略应对(最小最大策略),那么结果就已经预先确定了,玩家无法改变它(纳什均衡)。
因此,它们的损失函数,D 的是
G 的是
这里根据它们的损失函数分析下,G 网络的训练目标就是让 D(G(z)) 趋近于 1,这也是让其 loss 变小的做法;而 D 网络的训练目标是区分真假数据,自然是让 D(x) 趋近于 1,而 D(G(z)) 趋近于 0 。这就是两个网络相互对抗,彼此博弈的过程了。
那么,它们相互对抗的效果是怎样的呢?在论文中 Ian Goodfellow 用下图来描述这个过程:
上图中,黑色曲线表示输入数据 x 的实际分布,绿色曲线表示的是 G 网络生成数据的分布,我们的目标自然是希望着两条曲线可以相互重合,也就是两个数据分布一致了。而蓝色的曲线表示的是生成数据对应于 D 的分布。
在 a 图中是刚开始训练的时候,D 的分类能力还不是最好,因此有所波动,而生成数据的分布也自然和真实数据分布不同,毕竟 G 网络输入是随机生成的噪声;到了 b 图的时候,D 网络的分类能力就比较好了,可以看到对于真实数据和生成数据,它是明显可以区分出来,也就是给出的概率是不同的;
而绿色的曲线,即 G 网络的目标是学习真实数据的分布,所以它会往蓝色曲线方向移动,也就是 c 图了,并且因为 G 和 D 是相互对抗的,当 G 网络提升,也会影响 D 网络的分辨能力。论文中,Ian Goodfellow 做出了证明,当假设 G 网络不变,训练 D 网络,最优的情况会是:
也就是当生成数据的分布 $p_g(x)$ 趋近于真实数据分布 $p_{data}(x) $的时候,D 网络输出的概率 $D_G^*(x)$ 会趋近于 0.5,也就是 d 图的结果,这也是最终希望达到的训练结果,这时候 G 和 D 网络也就达到一个平衡状态。
论文给出的算法实现过程如下所示:
这里包含了一些训练的技巧和方法:
GAN 在巧妙设计了目标函数后,它就拥有以下两个优点。
虽然 GAN 避免了传统生成模型方法的缺陷,但是在它刚出来两年后,在 2016 年才开始逐渐有非常多和 GAN 相关的论文发表,其原因自然是初代 GAN 的缺点也是非常难解决:
当然,上述的问题在最近两年各种 GAN 变体中逐渐得到解决方法,比如对于训练太自由的,出现了 cGAN,即提供了一些条件信息给 G 网络,比如类别标签等信息;对于 loss 问题,也出现如 WGAN 等设计新的 loss 来解决这个问题。后续会继续介绍不同的 GAN 的变体,它们在不同方面改进原始 GAN 的问题,并且也应用在多个方面。
参考文章:
配图来自网络和论文 Generative Adversarial Networks
以上就是本文的主要内容和总结,可以留言给出你对本文的建议和看法。
欢迎关注我的微信公众号—机器学习与计算机视觉或者扫描下方的二维码,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
推荐阅读
1.机器学习入门系列(1)—机器学习概览(上)
2.机器学习入门系列(2)—机器学习概览(下)
3.[GAN学习系列] 初识GAN
本文大约 5000 字,阅读大约需要 10 分钟
这是 GAN 学习系列的第二篇文章,这篇文章将开始介绍 GAN 的起源之作,鼻祖,也就是 Ian Goodfellow 在 2014 年发表在 ICLR 的]]>
本文大约 3800 字,阅读大约需要 8 分钟
要说最近几年在深度学习领域最火的莫过于生成对抗网络,即 Generative Adversarial Networks(GANs)了。它是 Ian Goodfellow 在 2014 年发表的,也是这四年来出现的各种 GAN 的变种的开山鼻祖了,下图表示这四年来有关 GAN 的论文的每个月发表数量,可以看出在 2014 年提出后到 2016 年相关的论文是比较少的,但是从 2016 年,或者是 2017 年到今年这两年的时间,相关的论文是真的呈现井喷式增长。
那么,GAN 究竟是什么呢,它为何会成为这几年这么火的一个研究领域呢?
GAN,即生成对抗网络,是一个生成模型,也是半监督和无监督学习模型,它可以在不需要大量标注数据的情况下学习深度表征。最大的特点就是提出了一种让两个深度网络对抗训练的方法。
目前机器学习按照数据集是否有标签可以分为三种,监督学习、半监督学习和无监督学习,发展最成熟,效果最好的目前还是监督学习的方法,但是在数据集数量要求更多更大的情况下,获取标签的成本也更加昂贵了,因此越来越多的研究人员都希望能够在无监督学习方面有更好的发展,而 GAN 的出现,一来它是不太需要很多标注数据,甚至可以不需要标签,二来它可以做到很多事情,目前对它的应用包括图像合成、图像编辑、风格迁移、图像超分辨率以及图像转换等。
比如字体的转换,在 zi2zi 这个项目中,给出了对中文文字的字体的变换,效果如下图所示,GAN 可以学习到不同字体,然后将其进行变换。
除了字体的学习,还有对图片的转换, pix2pix 就可以做到,其结果如下图所示,分割图变成真实照片,从黑白图变成彩色图,从线条画变成富含纹理、阴影和光泽的图等等,这些都是这个 pix2pixGAN 实现的结果。
CycleGAN 则可以做到风格迁移,其实现结果如下图所示,真实照片变成印象画,普通的马和斑马的互换,季节的变换等。
上述是 GAN 的一些应用例子,接下来会简单介绍 GAN 的原理以及其优缺点,当然也还有为啥等它提出两年后才开始有越来越多的 GAN 相关的论文发表。
GAN 的思想其实非常简单,就是生成器网络和判别器网络的彼此博弈。
GAN 主要就是两个网络组成,生成器网络(Generator)和判别器网络(Discriminator),通过这两个网络的互相博弈,让生成器网络最终能够学习到输入数据的分布,这也就是 GAN 想达到的目的—学习输入数据的分布。其基本结构如下图所示,从下图可以更好理解G 和 D 的功能,分别为:
在训练的过程中,G 的目标是尽可能生成足够真实的数据去迷惑 D,而 D 就是要将 G 生成的图片都辨别出来,这样两者就是互相博弈,最终是要达到一个平衡,也就是纳什均衡。
(以下优点和缺点主要来自 Ian Goodfellow 在 Quora 上的回答,以及知乎上的回答)
Pg(G)
没有显式的表达GAN 的本质就是 G 和 D 互相博弈并最终达到一个纳什平衡点,但这只是一个理想的情况,正常情况是容易出现一方强大另一方弱小,并且一旦这个关系形成,而没有及时找到方法平衡,那么就会出现问题了。而梯度消失和模式奔溃其实就是这种情况下的两个结果,分别对应 D 和 G 是强大的一方的结果。
首先对于梯度消失的情况是D 越好,G 的梯度消失越严重,因为 G 的梯度更新来自 D,而在训练初始阶段,G 的输入是随机生成的噪声,肯定不会生成很好的图片,D 会很容易就判断出来真假样本,也就是 D 的训练几乎没有损失,也就没有有效的梯度信息回传给 G 让 G 去优化自己。这样的现象叫做 gradient vanishing,梯度消失问题。
其次,对于模式奔溃(mode collapse)问题,主要就是 G 比较强,导致 D 不能很好区分出真实图片和 G 生成的假图片,而如果此时 G 其实还不能完全生成足够真实的图片的时候,但 D 却分辨不出来,并且给出了正确的评价,那么 G 就会认为这张图片是正确的,接下来就继续这么输出这张或者这些图片,然后 D 还是给出正确的评价,于是两者就是这么相互欺骗,这样 G 其实就只会输出固定的一些图片,导致的结果除了生成图片不够真实,还有就是多样性不足的问题。
更详细的解释可以参考 令人拍案叫绝的Wasserstein GAN,这篇文章更详细解释了原始 GAN 的问题,主要就是出现在 loss 函数上。
(0.2, 0.3, 0.1,0.2,0.15,0.05)
,那么变为 onehot是(0,1,0,0,0,0),如果softmax输出是(0.2, 0.25, 0.2, 0.1,0.15,0.1 ),one-hot 仍然是(0, 1, 0, 0, 0, 0)
,所以对于生成器来说,G 输出了不同的结果, 但是 D 给出了同样的判别结果,并不能将梯度更新信息很好的传递到 G 中去,所以 D 最终输出的判别没有意义。对于鞍点,来自百度百科的解释是:
鞍点(Saddle point)在微分方程中,沿着某一方向是稳定的,另一条方向是不稳定的奇点,叫做鞍点。在泛函中,既不是极大值点也不是极小值点的临界点,叫做鞍点。在矩阵中,一个数在所在行中是最大值,在所在列中是最小值,则被称为鞍点。在物理上要广泛一些,指在一个方向是极大值,另一个方向是极小值的点。
鞍点和局部极小值点、局部极大值点的区别如下图所示:
训练的技巧主要来自Tips and tricks to make GANs work。
Tanh
激活函数在原始 GAN 论文中,损失函数 G 是要 $min (log(1-D))$, 但实际使用的时候是采用 $max(logD)$,作者给出的原因是前者会导致梯度消失问题。
但实际上,即便是作者提出的这种实际应用的损失函数也是存在问题,即模式奔溃的问题,在接下来提出的 GAN 相关的论文中,就有不少论文是针对这个问题进行改进的,如 WGAN 模型就提出一种新的损失函数。
参考文章:
注:配图来自网络和参考文章
以上就是本文的主要内容和总结,可以留言给出你对本文的建议和看法。
同时也欢迎关注我的微信公众号—机器学习与计算机视觉或者扫描下方的二维码,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
]]>本文大约 3800 字,阅读大约需要 8 分钟
要说最近几年在深度学习领域最火的莫过于生成对抗网络,即 Generative Adversarial Networks(GANs)了。它是 Ian Goodf]]>
本文大约 600 字, 阅读大约需要 2 分钟
吴恩达老师在上个月底宣布终于完成了他最新的书籍《Machine Learning Yearning》的最后几个章节:
而最近这本书也有了免费的完整中文版下载了,中文版的名称是《机器学习训练秘籍》,封面如下:
正如书名所言,这本书主要介绍的就是机器学习训练中的一些技巧和注意事项,包括如何设置训练集和测试集、处理偏差和方差问题等。目录如下所示:
英文版的官方下载地址是:
https://www.deeplearning.ai/machine-learning-yearning/
其官网是:
中文版的Github地址为
https://github.com/AcceptedDoge/machine-learning-yearning-cn
不过目前的中文版的翻译还并非最终版本,如果发现了问题,可以去 Github 上提出,帮助修改。
这里我已经分别下载好中英文两个版本的 pdf,并且简单做好目录标签,下载链接是:
https://pan.baidu.com/s/1D5y0-44pFh4KSMiviSNGRw
也可以在『公众号后台回复 “MLY” 或者 “机器学习训练秘籍”』获取下载链接和上述官网、中文版 Github 地址。
这里也非常感谢翻译并且免费分享中文版在 Github 上的几位大佬,也祝各位无论是刚入门还是在进阶机器学习方面知识和技能,都能从这本书中收获匪浅!最后如果你觉得这次分享的资源不错,可以点个赞或者分享出去,谢谢!
欢迎关注我的微信公众号—机器学习与计算机视觉或者扫描下方的二维码,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
推荐阅读
1.机器学习入门系列(1)—机器学习概览(上)
本文大约 600 字, 阅读大约需要 2 分钟
吴恩达老师在上个月底宣布终于完成了他最新的书籍《Machine Learning Yearning》的最后几个章节:
本文大约 5000 字, 阅读大约需要 10 分钟
在 Linux 下最常使用的文本编辑器就是 vi 或者 vim 了,如果能很好掌握这个编辑器,非常有利于我们更好的在 Linux 下面进行编程开发。
Vim是从 vi 发展出来的一个文本编辑器。代码补完、编译及错误跳转等方便编程的功能特别丰富,在程序员中被广泛使用。
简单的来说, vi 是老式的字处理器,不过功能已经很齐全了,但是还是有可以进步的地方。 vim 则可以说是程序开发者的一项很好用的工具。
下面是 vim 快捷键盘图:
基本上 vi/vim 共分为三种模式,分别是命令模式(Command mode),输入模式(Insert mode)和底线命令模式(Last line mode)。 这三种模式的作用分别是:
当使用 vi/vim 打开一个文件就进入了命令模式(也可称为一般模式),这是默认的模式。在这个模式,你可以采用『上下左右』按键来移动光标,你可以使用『删除字符』或『删除整行』来处理档案内容,也可以使用『复制、贴上』来处理你的文件数据。
在命令模式并不能编辑文件,需要输入如『i, I, o, O, a, A, r,R』等任何一个字母之后才会进入输入模式(也称为编辑模式)。注意了!通常在 Linux 中,按下这些按键时,在画面的左下方会出现『 INSERT 或 REPLACE 』的字样,此时才可以进行编辑。而如果要回到命令模式时,则必须要按下『Esc』这个按键即可退出编辑模式。
在命令模式下,按下『:,/,?』中任意一个,就可以将光标移动到最底下那一行,进入底线命令模式(也称为指令列命令模式)。在这个模式当中, 可以提供你『搜寻资料』的动作,而读取、存盘、大量取代字符、退出、显示行号等等的动作则是在此模式中达成的!同理,必须按下『Esc』按钮才可以退出该模式,返回命令模式
三种模式的切换和功能可以用下图来总结:
在命令行中输入如下命令:
1 | $ vim test.txt |
采用 vi 文件名
或者 vim 文件名
就可以打开文件并且进入了命令模式。这里文件名是必须添加的,当文件不存在的时候,也能打开,并且进行编辑保存后就是创建一个新的文件。打开后的界面如下图所示:
整个界面可以分为两个部分,最底下一行和上面的部分,最底下一行主要是显示当前文件名和文件的行数、列数,上图是一个新的文件,所以最底下显示的是文件名,而且后面括号也说是新文件,而下图是一个已经有内容的文件,那么上面部分就显示文件内容,最底下一行显示了文件名,文件的行数和列数,并且在最右侧部分会显示当前坐标的位置,比如图中是显示 (4,1) 表示当前坐标在第四行第一列的位置。
接下来就是开始对文件进行编辑,也就是需要进入编辑模式。只要按下『i, I, o, O, a, A, r,R』等字符就可以进入编辑模式了!在编辑模式当中,你可以发现在左下角状态栏中会出现 –INSERT- 的字样,那就是可以输入任意字符的提示啰!这个时候,键盘上除了 [Esc] 这个按键之外,其他的按键都可以视作为一般的输入按钮了,所以你可以进行任何的编辑!
如下图所示:
注意:在 vim/vi 中 [Tab] 键是向右移动 8 个空格字符。
如果对文件编辑完毕了,那么应该要如何退出呢?此时只需要按下 [Esc] 这个按钮即可!马上你就会发现画面左下角的 – INSERT – 不见了!并且返回了命令模式了
最后就是存盘并离开,指令很简单,输入『:wq』即可存档离开! (注意了,按下 : 该光标就会移动到最底下一行去!) ,如下图所示:
上述简易示例只是使用了简单的几个按键,但是从 vim 快捷键图可以知道 vim 是有很多快捷键的。
vim 更多快捷键可以如下思维导图所示:
题目是来自vim 程序编辑器的练习,如下所示,使用的操作文件 man_db.conf 可以在 http://linux.vbird.org/linux_basic/0310vi/man_db.conf
处获取。
1 | 1. 請在 /tmp 這個目錄下建立一個名為 vitest 的目錄; 2. 進入 vitest 這個目錄當中; 3. 將 /etc/man_db.conf 複製到本目錄底下(或由上述的連結下載 man_db.conf 檔案); 4. 使用 vi 開啟本目錄下的 man_db.conf 這個檔案; 5. 在 vi 中設定一下行號; 6. 移動到第 43 列,向右移動 59 個字元,請問你看到的小括號內是哪個文字? 7. 移動到第一列,並且向下搜尋一下『 gzip 』這個字串,請問他在第幾列? 8. 接著下來,我要將 29 到 41 列之間的『小寫 man 字串』改為『大寫 MAN 字串』,並且一個一個挑選是否需要修改,如何下達指令?如果在挑選過程中一直按『y』, 結果會在最後一列出現改變了幾個 man 呢? 9. 修改完之後,突然反悔了,要全部復原,有哪些方法? 10. 我要複製 66 到 71 這 6 列的內容(含有MANDB_MAP),並且貼到最後一列之後; 11. 113 到 128 列之間的開頭為 # 符號的註解資料我不要了,要如何刪除? 12. 將這個檔案另存成一個 man.test.config 的檔名; 13. 去到第 25 列,並且刪除 15 個字元,結果出現的第一個單字是什麼? 14. 在第一列新增一列,該列內容輸入『I am a student...』; 15. 儲存後離開吧! |
那么,整体步骤应该如下所示:1
1. mkdir vitest
2. cd vitest
3. mv /etc/man_db.conf .
4. vi man_db.conf
5. :set nu
6. 43G -> 59l ->括号内是 as 这个单词
7. gg 或 1G -> /gzip -> 在第 93 列
8. 输入命令 [:29,41s/man/MAN/gc] -> 然后一直点击 y ,总共需要替换 13 个
9. 一直按 u 键即可复原;更加简单粗暴的就是强制退出,也就是输入 :q!
10. 66G 跳到 66 行 -> 6yy 复制 6 行内容(输入后,屏幕最后一行会显示 6 lines yanked) -> G 跳到最后一行,输入 p 复制到最后一行的后面
11. 113G 跳到 113 行 -> 总共需要删除 16 行内容,所以输入 16dd,删除后光标所在行开头就是 ‘#Flags’
12. 输入 [:w man.test.config] 实现保存操作,接着可以输入 [:! ls -l],即显示查看当前文件夹内文件内容的命令 ls -l 显示的内容在 vim 内,再次按下回车键即回到 vim 命令模式
13. 输入 25G 到 25 行 -> 15x 删除 15 个字符,然后显示的是 tree
14. gg / 1G 到 第一行 -> O 在上方新增一行,然后输入 『I am a student...』-> Esc 键返回命令模式
15. [:wq] 或者 ZZ 保存离开文件
本文参考文章如下:
以上就是本文的主要内容和总结,欢迎关注我的微信公众号—机器学习与计算机视觉或者扫描下方的二维码,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
公众号后台回复“vim快捷键”可以获取 vim 思维导图!
推荐阅读
1.机器学习入门系列(1)—机器学习概览(上)
2.机器学习入门系列(2)—机器学习概览(下)
3.[实战] 图片转素描图
本文大约 5000 字, 阅读大约需要 10 分钟
在 Linux 下最常使用的文本编辑器就是 vi 或者 vim 了,如果能很好掌握这个编辑器,非常有利于我们更好的在 Linux 下面进行编程开发。]]>
本文大约 1933 字,阅读大约需要 6 分钟
最近刚刚更换了公众号名字,然后自然就需要更换下文章末尾的二维码关注图,但是之前是通过 windows 自带的画图软件做的,但是之前弄的时候其实还是比较麻烦的,所以我就想作为一名程序猿,当然要努力用代码解决这个问题。
而且最近发现了一个新的图像处理方面的库—Wand,它是 ImageMagick 库的 Python 接口。于是,我就打算用这个库来实现简单的制作一个二维码关注图,主要是完成以下几个工作:
Wand 是基于 ctypes 库的适用于 Python 的 ImageMagick 的封装库。
相比其他对 ImageMagick 的封装库,Wand 有以下几个优势:
在 ubuntu下,可以直接按照下列命令安装:
1 | $ apt-get install libmagickwand-dev $ pip install Wand |
对 Python 版本要求:
MagickWand library
主要还是参照第一篇文章来安装,并且主要是在 Windows 下安装,其中下载 ImageMagick 的时候,在下载地址中需要选择 6.9版本的 dll 的 exe 执行文件安装,而不能选择最新版本的 7.0+,否则在 Python 中调用的时候,会出现问题ImportError: MagickWand shared library not found.
,原因根据Python doesn’t find MagickWand Libraries (despite correct location?)中的说法是
A few sources said that Image Magick 7.x is not compatible with magick Wand so make sure you’re using 6.x. Additionally, “static” suffix versions do not work. The one that finally worked for me was “ImageMagick-6.9.8-10-Q8-x64-dll.exe”
也就是说 Image Magick 7.x 版本和 Wand 并不适配,所以只能采用 6+ 版本的。
安装完成后,这里首先需要准备一张或者几张要合成的图片,比如作为背景的图片和前景图片,这里我是先给定大小来生成背景图片,而前景图片自然是我的公众号二维码图片了:
首先是需要导入以下这些包:
1 | from wand.image import Image |
生成背景图片的代码如下所示:
1 | # 画一个纯白背景,并保存 |
这里就是根据传入的宽和高width,height
,以及背景颜色color
,生成指定大小和颜色的背景图片。
接着就是合成图片的函数代码:
1 | # 合成图片 |
首先是用read_image()
函数读取待合成的图片,然后利用composite_with_image
函数来合成输入的两张图片,其中img_back
表示背景图片,而img
就是前景图片,left, top
分别是前景图片在背景图片的左上角坐标位置。
这一步得到的结果如下所示,这里我设置的背景图片大小为:
1 | image_name = 'qrcode.jpg' |
最后一步就是添加文字了,前面两步其实都非常简单,直接调用接口即可,但是添加文字的时候,却出现问题了。是什么问题呢?
首先先给出wand
添加文字的代码:
1 | def draw_text(self, image, x, y, text, font_size=15, font_style='normal', text_alignment='left',text_color='Black', filename=None, is_display=False): |
刚刚说的问题,其实也是 Python 很常见的问题,就是如果使用到中文的字符串的问题,本来我认为也是编码问题,但是我发现设置一个只包含英文字符串,和包含有中文字符串的结果是这样的:
代码如下所示:
1 | text1 = 'Hello world' |
所以这应该不是编码问题,通过谷歌搜索后,发现应该是wand
默认不支持中文字符的原因,接着在看到参考文章4后,我发现可以通过wand.drawing.Drawing.font()
接口导入支持中文的字体来解决这个问题,而这些字体在哪里可以找到呢,其实在c:\windows\fonts\
目录下面就可以找到了,宋体、微软雅黑的字体,只要指定字体路径即可,因此更新后的代码如下:
1 | FONT_DICT = {'宋体': 'songti.ttc', |
最终合成的结果如下:
完整代码可以到我的Github上查看—image_composition.py
这次的实战练习其实非常简单,唯一比较有困难的就是解决如何添加中文的文字了,但是还是非常实用的,熟练学会这个Wand
后,就可以自己合成各种图片了,并且添加文字或者是其他图形等,具体可以查阅官方文档。
本文参考文章:
以上就是本文的主要内容和总结,欢迎留言给出你对本文的建议和看法。
同时也欢迎关注我的微信公众号—机器学习与计算机视觉或者扫描下方的二维码,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
推荐阅读
1.机器学习入门系列(1)—机器学习概览(上)
2.机器学习入门系列(2)—机器学习概览(下)
3.[实战] 图片转素描图
本文大约 1933 字,阅读大约需要 6 分钟
最近刚刚更换了公众号名字,然后自然就需要更换下文章末尾的二维码关注图,但是之前是通过 windows 自带的画图软件做的,但是之前弄的时候其实还是比较麻烦的]]>
本文大约 2000 字,阅读大约需要 6 分钟
我们知道图片除了最普通的彩色图,还有很多类型,比如素描、卡通、黑白等等,今天就介绍如何使用 Python 和 Opencv 来实现图片变素描图。
主要参考这篇文章来实现—How to create a beautiful pencil sketch effect with OpenCV and Python
如何将图片转换成素描图呢,只需要下面四个步骤即可:
事先准备,首先是安装好 opencv,可以直接通过 pip 进行安装:
1 | pip install opencv-python |
接着准备一张图片,最好是颜色鲜明一点的图片,方便对比转换的效果。
第一步变成灰度图,其实非常简单,直接调用 opencv 的函数即可,如下面代码所示:
1 | import cv2 |
图片转换效果如下所示:
上面的代码是读取图片后,再通过调用cv2.cvtColor
函数将图片转换成灰度图,实际上我们可以直接在读取图片时候就直接转换图片,即:
1 | img_gray = cv2.imread('example.jpg', cv2.IMREAD_GRAYSCALE) |
这里调用cv2.imread
函数时,设置了cv2.IMREAD_GRAYSCALE
的标志,表示加载灰度图。在imread
函数中是设置了三种标志,分别是
另外,如果觉得以上标志太长,可以简单使用 1,0,-1 代替,效果是相同的。
第二步就是对灰度图进行反色操作,其实就是非常简单的采用灰度图的最大像素值 255 减去当前像素值即可(因为灰度图的范围是[0, 255]),代码如下所示:
1 | img_gray_inv = 255 - img_gray |
结果如下所示:
其实就是原本比较暗的地方变光亮了,而比较亮的地方变暗了。
高斯模糊操作是一个有效减少图片噪音以及对图片进行平滑操作的方法,在数学上等价于对图像采用高斯核进行卷积的操作。我们可以直接调用cv2.GaussianBlur
来实现高斯模糊操作,这里需要设置参数ksize
,表示高斯核的大小,sigmaX
和sigmaY
分别表示高斯核在 X 和 Y 方向上的标准差。
1 | img_blur = cv2.GaussianBlur(img_gray_inv, ksize=(21, 21), |
效果如下所示,右边图是进行高斯模糊后的结果,是有了一定的模糊效果。
第四步,就是见证奇迹的时刻!这一步骤自然就是需要得到最终的素描图结果了。在传统照相技术中,当需要对图片某个区域变得更亮或者变暗,可以通过控制它的曝光时间,这里就用到亮化(Dodging)和暗化(burning)的技术。
在现代图像编辑工具,比如 PS 可以实现上述说的两种技术。比如对于颜色亮化技术,给定一张图片 A 和 蒙版 B,那么实现做法如下所示:
1 | (B[idx] == 255)?B[idx]:min(255, ((A[idx] << 8) / (255-B[idx]))) |
通过 python 代码实现上述公式,那么原始代码如下所示:
1 | import cv2 |
上述代码虽然实现了这个功能,但是很明显会非常耗时,中间采用了一个两层循环,计算复杂度是 O(w*h) ,也就是如果图片的宽和高的乘积越大,耗时就越长,所以就有了升级版的代码版本:
1 | def dodgeV2(image, mask): |
运行上述代码,得到的最终结果如下所示:
效果看起来还可以,除了右下角部分对于原图中黑色区域处理得不是很好。
而另一种技术—-暗化操作的代码如下所示:
1 | def burnV2(image, mask): |
效果如下图所示:
完整版代码如下所示:
1 | import cv2 |
最后,还有一种更加快速的实现,代码如下所示,仅需四行代码即可实现转换成素描图的效果。
1 | def rgb_to_sketch_v2(src_image_name): |
最后用本人比较喜欢的一个女演员的照片来看看这个转换的效果:
效果还是挺不错的!
以上就是本文的主要内容和总结,欢迎关注我的微信公众号—一个算法汪的技术成长之路或者扫描下方的二维码,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
]]>本文大约 2000 字,阅读大约需要 6 分钟
我们知道图片除了最普通的彩色图,还有很多类型,比如素描、卡通、黑白等等,今天就介绍如何使用 Python 和 Opencv 来实现图片变素描图。
在介绍基于模型学习算法的流程的时候,对于预测结果不好的问题分析,主要说了是数据问题还是模型问题,这同时也就是机器学习的效果不好的两个主要原因,即错误的数据和错误的算法。
第一个问题就是训练数据的数量问题,这是非常重要的问题。
因为即使是简单的问题,一般也需要数千的样本,这还是因为简单的问题一般采用简单的算法就可以解决,对于复杂的图像或语音问题,通常需要数百万的样本,特别是如果采用现在非常热门的深度学习算法,比如卷积神经网络模型,这些复杂的模型如果没有足够的数据量支持,非常容易陷入过拟合的情况。
实际上更多数量的训练集也是为了获得更有代表性的数据,能够学习到这类数据的所有特征。
但是,应该注意到,小型和中型的数据集仍然是非常常见的,获得额外的训练数据并不总是轻易和廉价的,所以不要抛弃算法。
无论采用基于实例还是基于模型的学习,让训练数据对新数据具有代表性是非常重要的。如果训练集没有代表性,那么训练得到的模型就是不可能得到准确性的模型,比如人脸识别中,模型没有学习到某个人最明显的代表性的特征,比如高鼻梁或者没有眉毛等突出特征,那么模型对这个人的识别率就不会很高。
使用具有代表性的训练集对于推广到新案例是非常重要的。但是做起来比说起来要难:如果样本太小,就会有样本噪声(即会有一定概率包含没有代表性的数据),但是即使是非常大的样本也可能没有代表性,如果取样方法错误的话。这叫做样本偏差。
低质量的数据指的是数据有错误、带有过多噪声或者是出现异常值等的数据,这种数据会影响系统整体的性能,因此,数据清洗对于构建一个机器学习系统或者一个机器学习项目来说都是必不可少的步骤。
对于这些低质量的数据,通常可以按照如下做法处理:
不相关的特征对于整个机器学习系统是有着反作用的效果,训练数据必须包含足够多的相关特征、非相关特征不多的情况下,才能训练出一个性能不错的模型。机器学习项目成功的关键之一是用好的特征进行训练。这个过程称作特征工程,包括:
上述四种情况都是坏数据的情况,接下来是两种算法问题,也是机器学习最常见的两种算法方面的问题,过拟合和欠拟合。
过拟合就是指算法模型在训练集上的性能非常好,但是泛化能力很差,即在测试集上的效果却很糟糕的情况。比如下图,采用一个高阶多项式回归模型来预测生活满意度和人均 GDP 的关系,很明显看出来,这个模型过拟合了训练数据,其预测效果并不会达到在训练数据上这么好的效果。
通常对于比较复杂的模型,比如深度神经网络,它能够检测和识别到数据中比较细微的规律和特征,但是如果训练集包含噪声,或者训练集数量太少(数量太少会引入样本噪声),这种情况下,模型同样会学习这种噪声,从而导致模型的泛化能力的下降。
一般解决过拟合的方法有:
其中正则化方法是比较常用的方法,它的作用就是限制模型,不让模型过于复杂,从而降低过拟合的风险或者是缓和过拟合的程度。常用的正则化方法是 L2 和 L1 正则化。正则化方法通常会采用一个超参数来控制其限制模型的强度。超参数是一个学习算法的参数(而不是模型的)。这样,它是不会被学习算法本身影响的,它优于训练,在训练中是保持不变的。如何调节超参数也是构建一个机器学习算法模型非常重要的一个步骤,也是让性能能够进一步提升的做法。
欠拟合和过拟合刚好相反,它就是模型的性能非常差,在训练数据和测试数据上的性能都不好。
通常也是因为模型过于简单,没有能够很好学习到数据的有效的相关的特征,解决方法有:
当训练好一个机器学习模型后,接下来就需要对模型进行预测和评估,判断得到的模型是否可用,是否还能进行提升,并进行错误分析等操作。
一般在训练模型前,我们会将数据集分成两个集合,分别是训练集和测试集,通常 8:2 的比例,也就是 80% 的数据作为训练集,剩余是测试集。然后采用训练集训练模型,在测试集上用按照学习的问题采用对应评估指标评估模型的性能,比如分类问题,一般就是采用分类的准确率或者错误率作为评估的标准。
但这种划分数据集的方法,存在一个问题,就是如果需要调节超参数,比如对于正则化的超参数、学习率等,继续采用测试集来进行评估不同超参数对模型性能的影响,这会导致最后在测试集上测试得到性能最好的模型,实际上是过拟合了测试集,那么模型的泛化能力也不会太好。
所以,为了解决这个问题,我们还需要为调节超参数划分一个专门的数据集,测试集应该是用于测试最终得到的模型的性能。因此,我们再划分一个叫做验证集的数据集。
一种做法是可以将所有数据按照一定比例划分为训练集、验证集和测试集,比如按照 6:2:2 的比例划分;当然更通常的做法是采用交叉验证:训练集分成互补的子集,每个模型用不同的子集训练,再用剩下的子集验证。一旦确定模型类型和超参数,最终的模型使用这些超参数和全部的训练集进行训练,用测试集得到推广误差率。
最后我们总结下:
机器学习的四个主要挑战是
以上就是本文的主要内容和总结,欢迎关注我的微信公众号—一个算法汪的技术成长之路或者扫描下方的二维码,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
]]>]]>
这是本系列的第一篇,也是机器学习概览的上半部分。
简单的定义,机器学习是通过编程让计算机从数据中进行学习的科学(和艺术)。
但还有另外两种定义,一个更广义的定义:
机器学习是让计算机具有学习的能力,无需进行明确编程。 —— 亚瑟·萨缪尔,1959
和一个工程性的定义:
计算机程序利用经验 E 学习任务 T,性能是 P,如果针对任务 T 的性能 P 随着经验 E 不断增长,则称为机器学习。 —— 汤姆·米切尔,1997
一个简单的例子,也是经常提及的例子:垃圾邮件过滤器。它可以根据垃圾邮件(比如,用户标记的垃圾邮件)和普通邮件(非垃圾邮件,也称作 ham)学习标记垃圾邮件。用来进行学习的样例称作训练集。每个训练样例称作训练实例(或样本)。在这个例子中,任务 T 就是标记新邮件是否是垃圾邮件,经验E是训练数据,性能 P 需要定义:例如,可以使用正确分类的比例。这个性能指标称为准确率,通常用在分类任务中。
为什么要用机器学习方法呢?
原因如下:
一些机器学习的应用例子:
机器学习有多种类型,可以根据如下规则进行分类:
第一种分类机器学习的方法是可以根据训练时监督的量和类型进行分类。主要有四类:监督学习、非监督学习、半监督学习和强化学习。
监督学习,顾名思义就是带有监督的学习,而监督就是体现在训练数据都是有标签的,所有在训练模型的时候可以根据数据的真实标签不断调整模型,从而得到一个性能更好的模型。
监督学习主要有两个常见的典型的任务—分类和回归。
分类问题主要就是预测新数据的类别问题。例如上文提到的垃圾邮件过滤器就是一个二分类问题,将邮件分为垃圾邮件还是正常的邮件,如下图所示。
回归问题主要是预测目标数值。比如给定预测房价的问题,给定一些特征,如房子大小、房间数量、地理位置等等,然后预测房子的价格。如下图所示:
注意,一些回归算法也可以用来进行分类,反之亦然。例如,逻辑回归通常用来进行分类,它可以生成一属于每个类别的概率值,然后选择最大概率的类别作为预测的类别。
常用的监督学习算法有:
和监督学习相反,非监督学习就是采用没有标签的数据集。
非监督主要有四个典型的任务,分别是聚类、降维、异常检测和关联规则学习。
聚类就是将数据根据一定的规则分成多个类,通常是采用相似性。比如对于博客访客的聚类,通过聚类算法,检测相似性访客的分组,如下图所示。不需要告诉算法访客是哪个类别,它会自动根据访客的属性找到相互间的关系,比如它可能找出访客的职业关系,将访客分为有 40% 的是上班族,有 50% 的是学生,或者对于技术博客,可能就是根据开发方向,划分为前端、后台、移动开发、人工智能等等。甚至,如果采用层次聚类分析算法,还可以继续对上述的分类进行更加详细的划分。这种做法可以帮助博主知道自己博客的主要群体是谁,更好规划自己博客发表的文章应该以什么方向为主。
可视化算法也是极佳的非监督学习案例:给算法大量复杂的且不加标签的数据,算法输出数据的2D或3D图像。如下图所示,算法会试图保留数据的结构(即尝试保留输入的独立聚类,避免在图像中重叠),这样就可以明白数据是如何组织起来的,也许还能发现隐藏的规律。
降维的目的是简化数据、但是不能失去大部分信息。做法之一是合并若干相关的特征。例如,汽车的里程数与车龄高度相关,降维算法就会将它们合并成一个,表示汽车的磨损。这叫做特征提取。
此外,在采用机器学习算法训练的时候,可以对训练集进行降维,这样有助于提高训练速度,降低占用的硬盘和内存空间,有时候也能提高算法的性能,但必须选择合适的降维算法,否则性能实际上是很有可能会下降的。
另一个重要的非监督任务是异常检测(anomaly detection)。例如,检测异常的信用卡转账以防欺诈,检测制造缺陷,或者在训练之前自动从训练数据集去除异常值。异常检测的系统使用正常值训练的,当它碰到一个新实例,它可以判断这个新实例是像正常值还是异常值。
最后,另一个常见的非监督任务是关联规则学习,它的目标是挖掘大量数据以发现属性间有趣的关系。例如,假设你拥有一个超市。在销售日志上运行关联规则,可能发现买了烧烤酱和薯片的人也会买牛排。因此,你可以将这些商品放在一起。
下面是一些最重要的非监督学习算法:
一些算法可以处理部分带标签的训练数据,通常是大量不带标签数据加上小部分带标签数据。这称作半监督学习。如下图所示,图中灰色圆点表示没有标签的数据,仅有几个三角形和正方形点表示带标签的数据。
多数半监督学习算法是非监督和监督算法的结合。例如,深度信念网络(deep belief networks)是基于被称为互相叠加的受限玻尔兹曼机(restricted Boltzmann machines,RBM)的非监督组件。RBM 是先用非监督方法进行训练,再用监督学习方法进行整个系统微调。
半监督学习的示例,如一些图片存储服务,比如 Google Photos,是半监督学习的好例子。一旦你上传了所有家庭相片,它就能自动识别相同的人 A 出现了相片1、5、11 中,另一个人 B 出现在了相片 2、5、7 中。这是算法的非监督部分(聚类)。现在系统需要的就是你告诉这两个人是谁。只要给每个人一个标签,算法就可以命名每张照片中的每个人,特别适合搜索照片。
强化学习和上述三种学习问题是非常不同的。学习系统在这里被称为智能体( agent),可以对环境进行观察,选择和执行动作,获得奖励(负奖励是惩罚,见下图)。然后它必须自己学习哪个是最佳方法(称为策略,policy),以得到长久的最大奖励。策略决定了智能体在给定情况下应该采取的行动 。
目前强化学习的应用还不算非常广,特别是结合了深度学习的强化学习,主要是应用在机器人方面,当然最著名的一个应用就是 DeepMind 的 AlphaGo 了,它是通过分析数百万盘棋局学习制胜策略,然后自己和自己下棋。要注意,在比赛中机器学习是关闭的;AlphaGo 只是使用它学会的策略。
第二种分类机器学习的准则是,它是否能从导入的数据流进行持续学习。也就是如果导入的是持续的数据流,机器学习算法能否在不断采用新数据来训练已经训练好的模型,并且新的模型对新旧数据都还有很好的性能。
在批量学习中,系统不能进行持续学习:必须用所有可用数据进行训练。这通常会占用大量时间和计算资源,所以一般是线下做的。首先是进行训练,然后部署在生产环境且停止学习,它只是使用已经学到的策略。这称为离线学习。
对于批量学习算法来说,当获取到新数据的时候,就需要重新重头训练整个数据集,然后更新模型,如果是应用该算法系统,那就相当于需要更新系统,需要停掉旧版本的系统,重新上线新版本的系统。
当然,一般训练、评估、部署一套机器学习的系统的整个过程可以自动进行,所以即便是批量学习也可以适应改变。只要有需要,就可以方便地更新数据、训练一个新版本。并且对于更新周期,可以选择每 24 小时或者每周更新一次。
但是,批量学习还是存在下面的缺点:
批量学习的缺陷和问题可以通过采用在线学习算法来解决。
在在线学习中,是用数据实例持续地进行训练,可以一次一个或一次几个实例(称为小批量)。每个学习步骤都很快且廉价,所以系统可以动态地学习到达的新数据。
在线学习虽然名字带着在线两个字,但是实际上它的训练过程也是离线的,因此应该说是持续学习或者增量学习。
在线学习有下面几个优点:
在线学习也存在两个挑战:
第三种分类机器学习的方法是判断它们是如何进行归纳推广的。大多机器学习任务是关于预测的。这意味着给定一定数量的训练样本,系统需要能推广到之前没见到过的样本。对训练数据集有很好的性能还不够,真正的目标是对新实例预测的性能。
有两种主要的归纳方法:基于实例学习和基于模型学习。
基于实例学习是系统先用记忆学习案例,然后使用相似度测量推广到新的例子,如下图所示:
这种学习算法可以说是机器学习中最简单的算法了,它实际上就是采用存储的数据集进行分类或者回归,典型的算法就是 KNN 算法,即 K 近邻算法,它就是将新的输入数据和已经保存的训练数据采用相似性度量(一般采用欧式距离)得到最近的 K 个训练样本,并采用 K 个训练样本中类别出现次数最多的类别作为预测的结果。
所以,这种算法的缺点就比较明显了:
和基于实例学习相反的就是基于模型学习:建立这些样本的模型,然后使用这个模型进行预测。如下图所示:
基于模型学习算法的流程一般如下所示:
最后,总结下:
以上就是本文的主要内容和总结,欢迎关注我的微信公众号—一个算法汪的技术成长之路或者扫描下方的二维码,和我分享你的建议和看法,指正文章中可能存在的错误,大家一起交流,学习和进步!
]]>参考文章/书籍:
CNN可以应用在场景分类,图像分类,现在还可以应用到自然语言处理(NLP)方面的很多问题,比如句子分类等。
LeNet是最早的CNN结构之一,它是由大神Yann LeCun所创造的,主要是用在字符分类问题。
下面是一个简单的CNN结构,图来自参考文章1。这个网络结构是用于一个四类分类的问题,分别是狗、猫、船和鸟,图中的输入图片是属于船一类。
该结构展示了四种运算,也可以说是由四种不同的层,分别是卷积层,非线性层(也就是使用了ReLU函数),Pooling层,全连接层,下面将一一介绍这几种网络层。
CNN的名字由来就是因为其使用了卷积运算的缘故。卷积的目的主要是为了提取图片的特征。卷积运算可以保持像素之间的空间关系。
每张图片可以当做是一个包含每个像素值的矩阵,像素值的范围是0~255,0表示黑色,255是白色。下面是一个$5 \times 5$大小的矩阵例子,它的值是0或者1。
接下来是另一个$3\times 3$矩阵:
上述两个矩阵通过卷积,可以得到如下图右侧粉色的矩阵结果。
黄色的矩阵在绿色的矩阵上从左到右,从上到下,每次滑动的步进值是1个像素,所以得到一个$3\times 3$的矩阵。
在CNN中,黄色的矩阵被叫做滤波器(filter)或者核(kernel)或者是特征提取器,而通过卷积得到的矩阵则是称为“特征图(Feature Map)”或者“Activation Map”。
另外,使用不同的滤波器矩阵是可以得到不同的 Feature Map ,例子如下图所示:
上图通过滤波器矩阵,实现了不同的操作,比如边缘检测,锐化以及模糊操作等。
在实际应用中,CNN是可以在其训练过程中学习到这些滤波器的值,不过我们需要首先指定好滤波器的大小,数量以及网络的结构。使用越多的滤波器,可以提取到更多的图像特征,网络也就能够有更好的性能。
Feature Map的尺寸是由以下三个参数来决定的:
上一小节简单介绍了卷积的操作和其实现的效果,接下来将介绍卷积运算的公式,以及CNN中卷积层的参数数量。
卷积是大自然中最常见的运算,一切信号观测、采集、传输和处理都可以用卷积过程实现,其用公式表达如下:
上述公式中$H(m,n)$表示卷积核。
在CNN中的卷积层的计算步骤与上述公式定义的二维卷积有点差异,首先是维度升至三维、四维卷积,跟二维卷积相比多了一个“通道”(channel),每个通道还是按照二维卷积方式计算,而多个通道与多个卷积核分别进行二维卷积,得到多通道输出,需要“合并”为一个通道;其次是卷积核在卷积计算时没有“翻转”,而是与输入图片做滑动窗口“相关”计算。用公式重新表达如下:
这里假定卷积层有$L$个输出通道和$K$个输入通道,于是需要有$KL$个卷积核实现通道数目的转换。其中$X^k$表示第$k$个输入通道的二维特征图,$Y^l$表示第$l$个输出通道的二维特征图,$H^{kl}$表示第$k$行、第$l$列二维卷积核。假定卷积核大小是$I*J$,每个输出通道的特征图大小是$M×N$,则该层每个样本做一次前向传播时卷积层的计算量是$Calculations(MAC)=I×J×M×N×K×L$。
卷积层的学习参数,也就是卷积核数目乘以卷积核的尺寸—$Params = I×J×K×L$。
这里定义计算量-参数量之比是CPR=$Calculations/Params=M×N$。
因此可以得出结论:卷积层的输出特征图尺寸越大,CPR越大,参数重复利用率越高。若输入一批大小为B的样本,则CPR值可提高B倍。
卷积神经网络通过『参数减少』与『权值共享』大大减少了连接的个数,也即需要训练的参数的个数。
假设我们的图像是1000*1000
的,则有10^6个隐层神经元,那么它们全连接的话,也就是每个隐层神经元都连接图像的每个像素点,就有10^12个连接,也即10^12个权值参数需要训练,这显然是不值得的。但是对于一个只识别特定feature的卷积核,需要大到覆盖整个图像的所有像素点吗?通常是不需要的,一个特定feature,尤其是第一层需要提取的feature,通常都相当基础,只占图像很小的一部分。所以我们设置一个较小的局部感受区域,比如10*10
,也即每个神经元只需要和这10*10
的局部图像相连接,所以10^6个神经元也就有10^8个连接。这就叫参数减少。
那什么叫权值共享呢?在上面的局部连接中,10^6个神经元,每个神经元都对应100个参数,所以是10^8个参数,那如果每个神经元所对应的参数都是相同的,那需要训练的参数就只有100个了。
这后面隐含的道理在于,这100个参数就是一个卷积核,而卷积核是提取feature的方式,与其在图像上的位置无关,图像一个局部的统计特征与其他局部的统计特征是一样的,我们用在这个局部抽取feature的卷积核也可以用在图像上的其它任何地方。
而且这100个参数只是一种卷积核,只能提取一种feature,我们完全可以采用100个卷积核,提取100种feature,而所需要训练的参数也不过10^4,最开始我们训练10^12个参数,还只能提取一种特征。选取100个卷积核,我们就能得到100张FM,每张FM可以看做是一张图像的不同通道。
CNN主要用来识别位移、缩放及其他形式扭曲不变性的二维图形。由于CNN特征检测层通过训练数据进行学习,在使用CNN时,避免了显式的特征抽取,而隐式地从训练数据中进行学习;再者,由于同一FM上的神经元权值相同,所以网络可以并行学习,这也是卷积网络相对于神经元彼此相连网络的一大优势。卷积神经网络以其局部权值共享的特殊结构在语音识别和图像处理方面有着独特的优越性,其布局更接近于实际的生物神经网络,权值共享降低了网络的复杂性,避免了特征提取和分类过程中数据重建的复杂度。
非线性修正函数ReLU(Rectified Linear Unit)如下图所示:
这是一个对每个像素点实现点乘运算,并用0来替换负值像素点。其目的是在CNN中加入非线性,因为使用CNN来解决的现实世界的问题都是非线性的,而卷积运算是线性运算,所以必须使用一个如ReLU的非线性函数来加入非线性的性质。
其他非线性函数还包括tanh和Sigmoid,但是ReLU函数已经被证明在大部分情况下性能最好。
空间合并(Spatial Pooling)也可以叫做子采样或者下采样,可以在保持最重要的信息的同时降低特征图的维度。它有不同的类型,如最大化,平均,求和等等。
对于Max Pooling操作,首先定义一个空间上的邻居,比如一个$2\times 2$的窗口,对该窗口内的经过ReLU的特征图提取最大的元素。除了提取最大的元素,还可以使用窗口内元素的平均值或者是求和的值。不过,Max Pooling的性能是最好的。例子可以如下图所示:
上图中使用的步进值是2。
根据相关理论,特征提取的误差主要来自两个方面:
一般来说,mean-pooling能减小第一种误差,更多的保留图像的背景信息,max-pooling能减小第二种误差,更多的保留纹理信息。
使用Pooling的原因有如下几点:
全连接层就是一个传统的多层感知器,它在输出层使用一个softmax激活函数。其主要作用就是将前面卷积层提取到的特征结合在一起然后进行分类。Softmax函数可以将输入是一个任意实数分数的向量变成一个值的范围是0~1的向量,但所有值的总和是1。
在CNN出现之前,最早的深度学习网络计算类型都是全连接形式的。
全连接层的主要计算类型是矩阵-向量乘(GEMV)。假设输入节点组成的向量是$x$,维度是$D$,输出节点组成的向量是$y$,维度是$V$,则全连接层计算可以表示为$y=Wx$。
其中$W$是$V×D$的权值矩阵。
全连接层的参数量为$Params=V×D$,其单个样本前向传播的计算量也是$Calculations(MAC)=V×D$,也就是$CPR=Calculations/Params=1$。也就是其权值利用率很低。
可以将一批大小为$B$的样本$x_i$逐列拼接成矩阵$X$,一次性通过全连接层,得到一批输出向量构成的矩阵$Y$,相应地前面的矩阵-向量乘运算升为矩阵-矩阵乘计算(GEMM):$Y=WX$。
这样全连接层前向计算量提高了$B$倍,CPR相应提高了$B$倍,权重矩阵在多个样本之间实现了共享,可提高计算速度。
比较卷积层和全连接层,卷积层在输出特征图维度实现了权值共享,这是降低参数量的重要举措,同时,卷积层局部连接特性(相比全连接)也大幅减少了参数量。因此卷积层参数量占比小,但计算量占比大,而全连接层是参数量占比大,计算量占比小。所以在进行计算加速优化时,重点放在卷积层;在进行参数优化、权值剪裁时,重点放在全连接层。
激活函数是给网络提供非线性的特性,在每个网络层中对输入数据进行非线性变换的作用,这有两个好处。
激活函数都有各自的取值范围,比如Sigmoid函数取值范围是[0,1],Tanh函数取值范围是[-1,1],这种好处对网络的正反向训练都有好处:
(1)正向计算网络的时候,由于输入数值的大小没有限制,其数值差距会非常大,第一个坏处是大数值会更被重视,而小数值的重要性会被忽视,其次,随着层数加深,这种大数值会不断累积到后面的网络层,最终可能导致数值爆炸溢出的情况;
(2)反向计算网络的时候,每层数值大小范围不同,有的在[0,1],有的在[0,10000],这在模型优化时会对设定反向求导的优化步长增加难度,设置过大会让梯度较大的维度因为过量更新而造成无法预期的结果;设置过小,梯度较小的维度会得不到充分的更新,就无法有所提升。
如果网络只有线性部分,那么叠加多个网络层是没有意义的,因为多层神经网络可以退化为一层神经网络。
CNN的整个训练过程如下所示:
这里简单介绍比较有名的网络结构。
在开头介绍了,这是最早使用的CNN网络结构之一,主要是用于字符分类;
特点如下:
这是在2012年的ImageNet视觉挑战比赛上获得第一名所使用的网络结构,这也是使得许多视觉问题取得重大突破,让CNN变得非常热门的原因。总结下其改进地方:
这是2013年ImageNet比赛的胜者,对AlexNet的结构超参数做出了调整。
2014年ImageNet比赛的胜者,其主要贡献是使用了一个Inception Module,可以大幅度减少网络的参数数量,其参数数量是4M,而AlexNet的则有60M。
这是一个更深的网络,使用了16层的结构。它是对原始图像进行3×3卷积,然后再进行3×3卷积,连续使用小的卷积核对图像进行多次卷积。VGG一开始提出的时候刚好与LeNet的设计原则相违背,因为LeNet相信大的卷积核能够捕获图像当中相似的特征(权值共享)。AlexNet在浅层网络开始的时候也是使用9×9、11×11卷积核,并且尽量在浅层网络的时候避免使用1×1的卷积核。但是VGG的神奇之处就是在于使用多个3×3卷积核可以模仿较大卷积核那样对图像进行局部感知。后来多个小的卷积核串联这一思想被GoogleNet和ResNet等吸收。
VGG相信如果使用大的卷积核将会造成很大的时间浪费,减少的卷积核能够减少参数,节省运算开销。虽然训练的时间变长了,但是总体来说预测的时间和参数都是减少的了。
随着网络的加深,出现了训练集准确率下降的现象,我们可以确定这不是由于Overfit过拟合造成的(过拟合的情况训练集应该准确率很高);所以作者针对这个问题提出了一种全新的网络,叫深度残差网络,它允许网络尽可能的加深,其中引入了全新的结构如下图;
残差指的是什么?
其中ResNet提出了两种mapping:一种是identity mapping,指的就是上图中”弯弯的曲线”,另一种residual mapping,指的就是除了”弯弯的曲线“那部分,所以最后的输出是 y=F(x)+x
identity mapping顾名思义,就是指本身,也就是公式中的x,而residual mapping指的是“差”,也就是y−x,所以残差指的就是F(x)部分。
为什么ResNet可以解决“随着网络加深,准确率不下降”的问题?
理论上,对于“随着网络加深,准确率下降”的问题,Resnet提供了两种选择方式,也就是identity mapping和residual mapping,如果网络已经到达最优,继续加深网络,residual mapping将被push为0,只剩下identity mapping,这样理论上网络一直处于最优状态了,网络的性能也就不会随着深度增加而降低了。
]]>参考文章/书籍:
对于2016年,最大的感觉还是自己还总是处于变化的过程吧,主要是对求职方向的变化,上半年主要是处于学习Android的过程,但是后来听了师兄的话后,大概就是6月底,就开始转变,想要往机器学习算法岗位准备,然后到了10月份左右,随着时间和自己准备两方面考虑,又决定应该往C++开发,但是跟师兄交流后,发现似乎这个方向需要准备的东西要更多,还是好好准备机器学习算法岗位吧。这么来来回回变化,也是自己性格上的缺点吧,缺乏恒心,不能坚持太久,或者其实是自己太过浮躁了,每次都有点过于冲动地考虑,又特别容易后悔自己做过的决定,这真的是需要在未来的一年里面去改进的,需要有恒心,专心和坚持完成一件事情。
科研进展方向,自己是写了一篇中文的论文,第一次投稿是被拒绝了,现在第二次投稿,尚处于审稿中,希望可以顺利收录;然后就是看了多篇论文啦,还看完一本介绍Caffe的,也尝试修改了一些代码,但是基本是根据网上别人的代码来修改的,复现的方法也能找到代码,现在毕设题目也是确定了,需要做的就是设计自己的算法,能够有足够的创新性吧。
编程方面,上半年也是看了一两本Android方面的书,然后C++方面,看完《C++ primer plus》,看完《大话数据结构》和《数据结构算法与应用:C++描述》,算法方面则看了《剑指offer》,看完《机器学习》(年终最后一天完成),《现代操作系统》则根据师兄的建议,看完前面比较重要的六章,而Linux方面,正在看鸟哥的《Linux私房菜—基础学习篇》,看完前面十一章内容,打算是看到第三部分的,目前就剩下两章内容,估计需要多两到三天的时间。这是书籍方面的阅读,此外,还有到LeetCode上刷题,目前做了20多道题目,然后在牛客网上也做了不少编程题目,数据结构和操作系统练习,都需要继续坚持。
此外,还是有坚持做了一些笔记,主要是数据结构和算法学习的笔记,需要继续保持。在博客方面,我的技术博客上是发表了30篇日志,当然主要是数据结构学习的比较和总结文章。而在CSDN博客方面,也写了有37篇博文,主要是下半年开始增多,前面主要是机器学习的时候的笔记,之后就是有一些论文阅读笔记和算法学习笔记。而Github方面,今年提交次数也有两百多近三百,当然提交的也都是上述的笔记内容。
其他,锻炼方面也是断断续续做着,还不能很好地坚持,所以基本上体重没有比去年减少多少,跑步方面,15年是总共跑了33次,总计里程是129.59km,而16年跑步次数增加到36次,总里程是144.08km,增加得不是很明显,按照每月跑步算是,月均3次而已;而在力量训练方面,总共锻炼时间是630分钟,总共锻炼次数是47次。
旅游方面,上半年去了武汉参加一次会议,然后都是省内游了。
首先是希望在上半年找到一份比较好的实习,目前求职岗位是机器学习算法,所以希望找到机器学习算法相关的岗位,所以这需要前面两个多月继续好好看书,刷题准备;
其次是希望投稿的论文可以顺利被收录,这样也可以达成毕业条件了。
接着就是下半年秋招可以找到一份好的工作,实习的时候也要争取能收到留下的offer。
最后就是搞定毕设,写好毕设论文了。
上述算是明年比较重要的四件事情了。在这其中,对于找实习还是工作,首先还是需要继续学习,书籍阅读方面,《统计学习方法》是需要好好看透,理解好的,然后《TCP/IP 协议》也要看,掌握网络知识;python方面的知识也要复习一下;然后就是看看《大话设计模式》和《STL源码剖析》,继续加强C++编程能力。
然后需要继续坚持做笔记,写博客,上传代码到Github。希望博客内容可以有更多干货,不只是阅读书籍的学习笔记,还有更多实践内容,比如一些自己做的项目代码,一些应用的实现等。
最后就是坚持锻炼身体,继续减脂训练,跑步和力量训练都要有序进行,计划跑步方面,总里程要达到300km左右,如果每次跑步在4km以上,那么需要总共跑75次,也就是平均每月6-7次左右;然后力量训练方面是总训练时间达到1200分钟,如果平均每次锻炼时间是30分钟,需要锻炼40次,平均到每月就是3-4次。
总而言之,希望能够更加专注,更加坚持,脚踏实地做好每件计划好的事情。
]]>对于2016年,最大的感觉还是自己还总是处于变化的过程吧,主要是对求职方向的变化,上半年主要是处于学]]>
假设含有n个记录的序列为${r_1,r_2,\cdots,r_n}$,其相应的关键字分别为${k_1,k_2,\cdots,k_n}$,需要确定$1,2, \cdots, n$的一种排列$p_1,p_2,\cdots,p_n$,使其相应的关键字满足$k_{p1}\le k_{p2}\le \cdots \le k_{pn}$非递减(或非递增)关系,即使得序列成为一个按关键字有序的序列${r_{p1}, r_{p2}, \cdots, r_{pn}}$,这样的操作就称为排序。
在排序问题中,通常将数据元素称为记录。
排序的依据是关键字之间的大小关系,那么,对同一个记录集合,针对不同的关键字进行排序,可以得到不同序列。
这里关键字$k_i$可以是记录$r$的主关键字,也可以是次关键字,甚至是若干数据项的组合。
由于排序不仅是针对主关键字,还有针对次关键字,因为待排序的记录序列中可能存在两个或两个以上的关键字相等的记录,排序结果可能会存在不唯一的情况,下面给出稳定与不稳定排序的定义。
假设$k_i = k_j \ (1\le i \le n, 1\le j\le n, i\neq j)$,且在排序前的序列中$r_i$领先于$r_j$(即$i \lt j$)。如果排序后$r_i$仍领先于$r_j$,则称所用的排序方法是稳定的;反之,若可能使得排序后的序列中$r_j$领先于$r_i$,则称所用的排序方法是不稳定的。
不稳定的排序算法有:希尔、快速、堆排和选择排序。
根据在排序过程中待排序的记录是否全部被放置在内存中,排序可以分为:内排序和外排序。
内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。
对于内排序来说,排序算法的性能主要是受到3个方面的影响:
在内排序中,主要进行两种操作:比较和移动。高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数。
辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。
这里指的是算法本身的复杂度,而不是算法的时间复杂度。
根据排序过程中借助的主要操作,我们把内排序分为:插入排序、交换排序、选择排序和归并排序。
这里先提供一个用于排序用的顺序表结构,这个结构将用于接下来介绍的所有排序算法。
1 | #define MAXSIZE 10 |
此外,由于排序最常用到的操作是数组两元素的交换,这里写成一个函数,如下所示:
1 | // 交换L中数组r的下标为i和j的值 |
冒泡排序(Bubble sort)是一种交换排序。它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,知道没有反序的记录为止。
首先介绍一个简单版本的冒泡排序算法的实现代码。
1 | // 冒泡排序初级版 |
这段代码不算是标准的冒泡排序算法,因为不满足“两两比较相邻记录”的冒泡排序思想,它更应该是最简单的交换排序。它的思路是让每一个关键字都和后面的每一个关键字比较,如果大或小则进行交换,这样关键字在一次循环后,第一个位置的关键字会变成最大值或者最小值。
这个最简单的实现算法效率是非常低的。
下面介绍正宗的冒泡排序算法实现。
1 | // 正宗的冒泡排序算法实现代码 |
这里改变的地方是在内循环中,j
是从数组最后往前进行比较,并且是逐个往前进行相邻记录的比较,这样最大值或者最小值会在第一次循环过后,从后面浮现到第一个位置,如同气泡一样浮到上面。
这段实现代码其实还是可以进行优化的,例如待排序数组是{2,1,3,4,5,6,7,8,9}
,需要进行递增排序,可以发现其实只需要交换前两个元素的位置即可完成,但是上述算法还是会在交换完这两者位置后继续进行循环,这样效率就不高了,所以可以在算法中增加一个标志,当有一次循环中没有进行数据交换,就证明数组已经是完成排序的,此时就可以退出算法,实现代码如下:
1 | // 改进版冒泡算法 |
冒泡排序算法的时间复杂度是$O(n^2)$。
完整的冒泡排序算法代码可以查看BubbleSort。
简单选择排序算法(Simple Selection Sort)就是通过$n-i$次关键字间的比较,从$n-i+1$个记录中选出关键字中最小的记录,并和第$i(1\le i \le n)$个记录进行交换。
下面是实现的代码:
1 | // 简单选择排序算法 |
简单选择排序的最大特点就是交换移动数据次数相当少。分析其时间复杂度发现,无论最好最差的情况,比较次数都是一样的,都需要比较$\sum_{i=1}^{n-1} (n-i) = (n-1)+(n-2)+\cdots+2+1=\frac{n(n-1)}{2}$次。对于交换次数,最好的时候是交换0次,而最差的情况是$n-1$次。因此,总的时间复杂度是$O(n^2)$,虽然与冒泡排序一样的时间复杂度,但是其性能上还是略好于冒泡排序。
直接插入排序(Straight Insertion Sort)的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增加1的有序表。
实现代码如下:
1 | // 直接插入排序 |
直接插入排序算法是需要有一个保存待插入数值的辅助空间。
在时间复杂度方面,最好的情况是待排序的表本身就是有序的,如{2,3,4,5,6},比较次数则是$n-1$次,然后不需要进行移动,时间复杂度是$O(n)$。
最差的情况就是待排序表是逆序的情况,如{6,5,4,3,2},此时需要比较$\sum_{i=2}^{n} i = \frac{(n+2)(n-1)}{2}$次,而记录的移动次数也达到最大值$\sum_{i=2}^{n} (i+1) = \frac{(n+4)(n-1)}{2}$次。
如果排序记录是随机的,那么根据概率相同的原则,平均比较和移动次数约为$\frac{n^2}{4}$。因此,可以得出直接插入排序算法的时间复杂度是$O(n^2)$。同时也可以看出,直接插入排序算法会比冒泡排序和简单选择排序算法的性能要更好一些。
上述三种排序算法的时间复杂度都是$O(n^2)$,而希尔排序是突破这个时间复杂度的第一批算法之一。
其实直接插入排序的效率在某些情况下是非常高效的,这些情况是指记录本来就很少或者待排序的表基本有序的情况,但是这两种情况都是特殊情况,在现实中比较少见。而希尔排序就是通过创造条件来改进直接插入排序的算法。
希尔排序的做法是将原本有大量记录数的记录进行分组,分割成若干个序列,这样每个子序列待排序的记录就比较少了,然后就可以对子序列分别进行直接插入排序,当整个序列基本有序时,再对全体记录进行一次直接插入排序。
这里的基本有序是指小的关键字基本在前面,大的基本在后面,不大不小的在中间。像{2,1,3,6,4,7,5,8,9}可以称为基本有序。
这里的关键就是如何进行分割,希尔排序采取的是跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
实现的代码如下:
1 | // 希尔排序 |
上述代码中增量的选取是increment = increment / 3 + 1
,实际上增量的选取是非常关键的,现在还没有人找到一种最好的增量序列,但是大量研究表明,当增量序列是$\delta [k] = 2^{t-k+1} - 1 (0\le k \le t \le \lfloor log_2(n+1)\rfloor)$时,可以获得不错的效率,其时间复杂度是$O(n^{\frac{3}{2}})$,要好于直接插入排序的$O(n^2)$。当然,这里需要注意的是增量序列的最后一个增量值必须等于1才行。此外,由于记录是跳跃式的移动,希尔排序是不稳定的排序算法。
有一个数量为Size个数的数组A,数组的值范围为(0 - Max),然后创建一个大小为Max+1
的数组B,每个元素都为0.从头遍历A,当读取到A[i]的时候,B[A[i]]的值+1,这样所有的A数组被遍历后,直接扫描B之后,输出表B就可以了。然后再根据B来对A进行排序。
实现代码如下:
1 | //获得未排序数组中最大的一个元素值 |
简单选择排序在待排序的$n$个记录中选择一个最小的记录需要比较$n-1$次,这是查找第一个数据,所以需要比较这么多次是比较正常的,但是可惜的是它没有把每一趟的比较结果保存下来,这导致在后面的比较中,实际有许多比较在前一趟中已经做过了。因此,如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会变得很高了。
堆排序(Heap Sort)就是对简单选择排序进行的一种改进,并且效果非常明显。
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为最大堆或者大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为最小堆或者小顶堆。
下图是一个例子,左边的是大顶堆,而右边的是小顶堆。
而根据堆的定义,可以知道根结点一定是堆中所有结点最大或者最小者。如果按照层遍历的方式给结点从1开始编号,则结点之间满足下列关系:
如果将上图按照层遍历存入数组,则一定满足上述关系表达,得到的数组如下图所示。
堆排序的基本思想是,将待排序的序列构成一个最大堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素进行交换,此时末尾元素就是最大值),然后将剩余的$n-1$个序列重新构成一个堆,这样就会得到$n$个元素中的次最大值。如此反复执行,便能得到一个有序序列。
下面将给出堆排序算法的代码实现。
1 | // 已知L->r[s...m]中记录的关键字除L->r[s]之外均满足堆的定义 |
从代码中可以看出,堆排序分两步走,首先是将待排序的序列构造成最大堆,这也是HeapSort()
中第一个循环所做的事情,第二个循环也就是第二步,进行堆排序,逐步将每个最大值的根结点和末尾元素进行交换,然后再调整成最大堆,重复执行。
而在第一步中构造最大堆的过程中,是从$\lfloor \frac{n}{2} \rfloor$的位置开始进行构造,这是从下往上、从右到左,将每个非叶结点当作根结点,将其和其子树调整成最大堆。
接下来就是分享堆排序的效率了。堆排序的运行时间主要是消耗在初始构造堆和在重建堆时的反复筛选上。
在构建堆的过程中,因为是从完全二叉树的最下层最右边的非叶结点开始构建,将它与其孩子进行比较和若有必要的交换,对每个非叶结点,最多进行两次比较和互换操作,这里需要进行这种操作的非叶结点数目是$\lfloor \frac{n}{2} \rfloor$个,所以整个构建堆的时间复杂度是$O(n)$。
在正式排序的时候,第$i$取堆顶记录重建堆需要用$O(log i)$的时间(完全二叉树的某个结点到根结点的距离是$\lfloor log_2i \rfloor + 1$),并且需要取$n-1$次堆顶记录,因此,重建堆的时间复杂度是$O(nlogn)$。
所以,总体上来说,堆排序的时间复杂度是$O(nlogn)$。由于堆排序对原始记录的排序状态并不敏感,因此它无论最好、最坏和平均时间复杂度都是$O(nlogn)$。同样由于记录的比较与交换是跳跃式进行,堆排序也不是稳定的排序算法。
另外,由于初始构建堆需要的比较次数较多,因此,它并不适合待排序序列个数较少的情况。
归并排序(Merging Sort)就是利用归并的思想实现的排序方法,它的原理是假设初始序列有$n$个记录,则可以看成是$n$个有序的子序列,每个子序列的长度为1,然后两两合并,得到$\lceil \frac{n}{2} \rceil$($\lceil x \rceil$表示不小于$x$的最小整数)个长度为2或1的有序子序列;再两两合并,$\cdots \cdots$,如此重复,直至得到一个长度为$n$的有序序列为止,这种排序方法称为2路归并排序。
下面是介绍实现的代码。
1 | // 归并排序,使用递归 |
上述代码是一个递归版本的归并排序实现算法,其中函数MSort()
的作用是将待排序序列进行分割,然后Merge()
函数会对需要归并的序列进行排序并两两归并在一起。
归并排序的时间复杂度是$O(nlogn)$,并且无论是最好、最坏还是平均都是同样的时间性能。另外,在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果,并且递归时需要深度为$log_2 n$的栈空间,因此空间复杂度是$O(n+logn)$。
另外,归并排序是使用两两比较,不存在跳跃,这在Merge()
中的语句if(SR[i]<SR[j])
可以看出,所以归并排序是一个稳定的排序算法。
总体来说,归并排序是一个比较占用内存,但效率高且稳定的算法。
下面会介绍一个非递归版本的归并排序算法实现。
1 | // 非递归版本的归并排序 |
非递归版本的归并排序算法避免了递归时深度为$log_2 n$的栈空间,空间复杂度是$O(n)$,并且避免递归也在时间性能上有一定的提升。应该说,使用归并排序时,尽量考虑用非递归方法。
在前面介绍的几种排序算法,希尔排序相当于直接插入排序的升级,它们属于插入排序类,而堆排序相当于简单选择排序的升级,它们是属于选择排序类,而接下来介绍的快速排序就是冒泡排序的升级,它们属于交换排序类。
快速排序(Quick Sort)的基本思想是:通过一趟排序将待排序记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
下面给出实现的快速排序算法代码:
1 | // 快速排序 |
上述代码同样是使用了递归,其中Partition()
函数要做的就是先选取待排序序列中的一个关键字,然后将其放在一个位置,这个位置左边的值小于它,右边的值都大于它,这样的值被称为枢轴。
快速排序的时间性能取决于快速排序递归的深度。在最优情况下,Partition()
每次都划分得很均匀,如果排序$n$个关键字,其递归树的深度技术$\lfloor log_ n \rfloor +1$,即需要递归$log_2n$次,其时间复杂度是$O(nlogn)$。而最坏的情况下,待排序的序列是正序或逆序,得到的递归树是斜树,最终其时间复杂度是$O(n^2)$。
平均情况可以得到时间复杂度是$O(nlogn)$,而空间复杂度的平均情况是$O(logn)$。但是由于关键字的比较和交换是跳跃进行的,所以快速排序也是不稳定排序。
快速排序算法是有许多地方可以优化的,下面给出一些优化的方案。
枢轴的值太大或者太小都会影响快速排序的性能,一个改进方法是三数取中法,即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数。
需要在Partition()
函数中做出下列修改:
1 | int pivot_key; |
三数取中对小数组有很大的概率取到一个比较好的枢轴值,但是对于非常大的待排序的序列还是不足以保证得到一个比较好的枢轴值,因此还有一个办法是九数取中法,它先从数组中分三次取样,每次去三个数,三个样品各自取出中数,然后从这三个中数当中再取出一个中数作为枢轴。
优化后的代码如下:
1 | pivot_key = L->r[low]; |
这里可以减少多次交换数据的操作,性能上可以得到一定的提高。
当数组比较小的时候,快速排序的性能其实还不如直接插入排序(直接插入排序是简单排序中性能最好的)。其原因是快速排序使用了递归操作,在有大量数据排序时,递归操作的影响是可以忽略的,但如果只有少数记录需要排序,这个影响就比较大,所以下面给出改进的代码。
1 | #define MAX_LENGTH_INSERT_SORT 7 |
上述代码是先进行一个判断,当数组的数量大于一个预设定的常数时,才进行快速排序,否则就进行直接插入排序。这样可以保证最大化地利用两种排序的优势来完成排序工作。
递归对性能是有一定影响的,QSort()
在其尾部有两次递归操作,如果待排序的序列划分极端不平衡,递归的深度将趋近于$n$,而不是平衡时的$log_2 n$,这就不仅仅是速度快慢的问题了。栈的大小是很有限的,每次递归调用都会耗费一定的栈空间,函数的参数越多,每次递归耗费的空间也越多。因此,如果能减少递归,将会大大提高性能。
下面给出对QSort()
实施尾递归优化的代码。
1 | // 对待排序序列L中的子序列L->r[low...high]做快速排序 |
上述代码中使用while
循环,并且去掉原来的对高子序列进行递归,改成代码low = privot + 1
,那么在进行一次递归后,再进行循环,就相当于原来的QSort(L,privot+1,high);
,结果相同,但是从递归变成了迭代,可以缩减堆栈深度,从而提高了整体性能。
上述总共介绍了7种排序算法,首先是根据排序过程中借助的主要操作,将内排序分为:插入排序、交换排序、选择排序和归并排序,如下图所示。
事实上,目前还没有十全十美的排序算法,都是各有优点和缺点,即使是快速排序算法,也只是整体上性能优越,它也存在排序不稳定、需要大量辅助空间、对少量数据排序无优势等不足。
下面对这7种算法的各种指标进行对比,如下图所示:
从算法的简单性来看,可以分为两类:
从平均情况看,快速、堆、归并三种改进算法都优于希尔排序,并远远胜过3种简单算法。
从最好情况看,冒泡和直接插入排序要更好一点,即当待排序序列是基本有序的时候,应该考虑这两种排序算法,而非4种复杂的改进算法。
从最坏情况看,堆和归并排序比其他排序算法都要更好。
从空间复杂度看,归并排序和快速排序都对空间有要求,而其他排序反而都只是$O(1)$的复杂度。
从稳定性上看,归并排序是改进算法中唯一稳定的算法。而不稳定的排序算法有“快些选堆”,即快速、希尔、选择和堆排序四种算法(书中给出的简单选择排序是不稳定的,但是从网上查找资料看到选择排序是一个不稳定的算法)。
排序算法的总结就到这里,实际上还是要根据实际问题来选择适合的排序算法。
全部排序算法的代码可以查看排序算法实现代码。
]]>图(Graph)是由顶点的有穷非空集合和顶点之间的集合组成,通常表示为:G(V, E),其中 G 表示一个图,V 是图 G 中顶点的集合,E 是图 G 中边的集合。
对于上述图的定义,需要注意的是:
接下来会介绍各自图的定义,包括无向图与有向图,有向完全图和无向完全图,稀疏与稠密图等。
无向边: 若顶点$v_i$ 到$v_j$之间的边没有方向,则称这条边为无向边(Edge),用无序偶对$(v_i, v_j)$来表示。
如果图中任意两个顶点之间的边都是无向边,则称该图是无向图。
如下图左边的图就是一个无向图$G_1$,$G_1 = (V_1,{E_1})$,其中顶点集合 $V_1 = {A,B,C,D}$,边集合是$E_1 = {(A, B), (B, C), (C, D), (D, A), (A, C)}$。
有向边: 若顶点$v_i$ 到$v_j$之间的边有方向,则称这条边为有向边,也称为弧(Arc)。用有序偶$
$来表示,$v_i$称为弧尾,$v_j$称为弧头。
如果图中任意两个顶点之间的边都是有向边,则称该图是有向图。
如下图右边的图就是一个有向图 $G_2$,$G_2 =(V_2, {E_2}) $,其中顶点集合 $V_2 = {A,B,C,D}$,边集合是$E_2 = {, ,
这里需要注意有向图中有向边的表示是不能随意乱写的,必须是按照定义中$
图中,若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图是简单图。
如下图所示都不是简单图,而我们主要讨论的都是简单图。
无向完全图是指在无向图中,任意两个顶点之间都存在边。
含有$n$个顶点的无向完全图有$\frac{n\times (n-1)}{2}$条边。
有向完全图是指在有向图中,任意两个顶点之间都存在方向互为相反的两条弧。
含有$n$个顶点的有向完全图有$n\times (n-1)$条边。
由此可以得到一个结论:
对于具有$n$个顶点和$e$条边数的图,无向图有$0 \le e \le \frac{n\times (n-1)}{2}$, 有向图有$0 \le e \le n \times (n-1)$。
有很少边或弧的图称为稀疏图,反之称为稠密图。
这里的稀疏与稠密都是相对而言的。
与图的边或弧相关的数值称为权(Weight),它可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网(Network)。
下图就是一个带权的图的例子。
假设有两个图 $G = (V,{E})$,和 $G^\prime = (V^\prime, {E^\prime}) $,如果$V^\prime \subseteq V$, 且 $E^\prime \subseteq E$,则称$G^\prime$是$G$的子图。
下面展示了无向图和有向图与其子图。
在无向图 $G=(V,{E})$,如果边 $(v, v^\prime) \in E$,则称顶点$v和v^\prime$互为邻接点(Adjacent),即$v 和 v^\prime$相邻接。边$(v, v^\prime)$依附(incident)于顶点$v 和 v^\prime$,或者说$(v,v^\prime)$与顶点$v 和 v^\prime$相关联。顶点$v$的度(Degree)是和$v$相关联的边的数目,记为TD($v$)。
例如对于上图中上方的无向图,顶点A与B互为邻接点,边(A, B)依附于顶点A与B上,顶点A的度为3。通过计算,可以知道,无向图的边数是各顶点度数和的一半,即$e = \frac{1}{2} \sum_{i=1}^n TD(v_i)$。
有向图 $G=(V,{E})$,如果弧$
\in E$, 则称顶点$v$邻接到顶点$v^\prime$,顶点$v^\prime$邻接自顶点$v$。弧$ $和顶点$v,v^\prime$相关联。以顶点$v$为头的弧的数目称为$v$的入度(InDegree),记为$ID(v)$;以顶点$v$为尾的弧的数目称为$v$的出度(OutDegree),记为$OD(v)$,因此顶点$v$的度为$TD(v) = ID(v) + OD(v)$。
例如对上图中下方的有向图,顶点A的入度是2(从B到A的弧,C到A的弧),出度是1(从A到D的弧),所以顶点A的度是3。同样通过计算,可以得到$e =\sum_{i=1}^n ID(v_i) = \sum_{i=1}^n OD(v_i) $。
在无向图 $G=(V,{E})$中从顶点$v$到$v^\prime$的路径(Path)是一个顶点序列$(v=v_{i,0},v_{i,1},\cdots,v_{i,m}=v^\prime),其中(v_{i,j-1},v_{i,j}) \in E, 1 \le j \le m$。
下图就展示了顶点B到顶点D的四种不同路径。
如果$G$是有向图,则路径也是有向的,顶点序列应满足$
路径的长度是路径上的边或弧的数目。
如上图有向图中,左侧的路径长度是2,经过两条弧,而右侧的路径长度是3,经过3条弧。
第一个顶点到最后一个顶点相同的路径称为回路或环(Cycle)。序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环。
下图中,两个图都是一个环,但左侧的图是一个简单环,而右侧图中顶点C重复出现,因此它不是一个简单环。
接下来会介绍有关连通图的定义和相关术语。
无向图$G$中,如果从顶点$v$到$v^\prime$有路径,则称$v$和$v^\prime$是连通的。如果对于图中任意两个顶点$v_i、v_j \in V$,$v_i$和$v_j$都是连通的,则称$G$是连通图。
无向图中的极大连通子图称为连通分量。这个连通分量的前提条件有:
有向图$G$中,如果对于每一对$v_i,v_j \in V, v_i \neq v_j$,从$v_i$到$v_j$和从$v_j$到$v_i$都存在路径,则称$G$是强连通图。有向图中的极大强连通子图称做有向图的强连通分量。
如下图所示,图1并不是强连通图,因为顶点A到顶点D存在路径,但不存在从顶点D到顶点A的路径。图2就是强连通图,而且显然图2是图1的极大强连通子图,即是它的强连通分量。
接下来是连通图的生成树定义:
一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的 n-1 条边。
如下图所示,图1是一个普通图,但是显然它不是生成树,当去掉两条构成环的边后,如图2或图3,就满足生成树的条件了,即n个顶点和n-1条边且连通的定义,它们都是一棵生成树。由此可以得知,如果一个图有n个顶点和小于n-1条边,则是非连通图;如果它多于n-1条边,则必定构成一个环。但有n-1条边也不一定是生成树,如图4。
接下来是有向树的定义:
如果一个有向图中恰有一个顶点的入度为0,其余顶点的入度均为1,则是一棵有向树。
这里入度为0的顶点就相当于树的根结点,而其余顶点的入度都是1,是因为树的非根结点的双亲只有1个。
一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。
如下图所示,图1是一个有向图,再去掉一些弧后,得到图2和图3,也就是分解成两棵有向树,即图2和图3,而这两棵有向树也是图1有向图的生成森林。
图的基本定义就简单总结到这里,图的术语还是不比树的少,需要多看几遍,同时多使用,接下来会继续总结图的存储结构、遍历等知识点。
]]>图(Graph)是由顶点的有穷非空集合和顶点之间的集合组成,通常表示为:G(V, E)
首先如下图所示的二叉树,其中^
符号表示空指针域,对于一个有n
个结点的二叉链表,每个结点都有指向左右孩子的两个指针域,所以一共有2n
个指针域。而n
个结点的二叉树是有n-1
条分支线数,也就是存在$2n-(n-1)=n+1$个空指针域,这些空间是不存储任何东西,也就是浪费内存的资源。
另一方面,在对上图的二叉树做中序遍历时,可以得到HDIBJEAFCG
这样的字符序列,通过这样的遍历,可以知道,结点I
的前驱是D
,后继是B
。即我们可以知道任意一个结点的前驱和后继分别是哪个,但这是在经过遍历之后的结果,即每次使用都需要先遍历一次,才可以知道任意结点的前驱和后继。
综合上述两种情况,为了更好利用内存资源,节省时间,就有了线索二叉树了,我们将指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就是线索二叉树了。
线索化是对二叉树以某种次序遍历使其变为线索二叉树的过程。
我们对上图的二叉树按照中序遍历的方式进行线索化,可以得到下图,其中虚线箭头是表示后继,实线箭头是前驱。这里设置二叉树的左指针是指向前驱,右指针指向后继。
但是增加了线索后,需要解决的问题就是如何判断当前结点的左指针是指向其左孩子,还是前驱呢。这里就需要在每个结点增加两个标志域ltag
和rtag
,用来表示左右指针指向的是左右孩子还是前驱或者后继。
二叉树的线索存储结构定义如下:
1 | // Link == 0 表示指向左右孩子指针; Thread == 1 表示指向前驱或者后继的线索 |
线索化的实质就是将二叉链表中的空指针改为指向前驱或者后继的线索,因此线索化的过程就是在遍历的过程中修改空指针的过程。
下面是中序遍历线索化的递归函数代码:
1 | // 全局变量,始终指向刚刚访问过的结点 |
通过上述代码可以得到线索二叉树,而对它进行遍历会发现相当于是操作一个双向链表一样。同样是在二叉线索链表上添加一个头结点,如下图所示:
这里令二叉树的中序序列中的第一个结点H
的左指针和最后一个结点G
的右指针指向头结点,令头结点的左指针指向根结点,右指针指向结点G
。这样做的好处是我们既可以从第一个结点开始顺其后继进行遍历,也可以从最后一个结点开始顺前驱进行遍历。
遍历的代码如下:
1 | bool InOrderTraverse_Thr(BiThrTree T){ |
线索二叉链表的存储结构适用于如果所用的二叉树需要经常遍历或查找结点时需要某种遍历序列中的前驱和后继。
树转换为二叉树的步骤如下:
上述步骤可以如下图所示一样:
步骤如下:
下图就是一个森林转为二叉树的例子。
二叉树转为树是树转为二叉树的逆过程,具体步骤如下:
下图是一个例子。
判断一棵二叉树能够转为森林还是一棵树的方法很简单,就是看其根结点是否有右孩子,如果有就是森林,没有就是一棵树。
转换为森林的步骤如下:
下图是一个例子。
最后是介绍树和森林的遍历问题。
树的遍历分为两种方式:
如对下图的树,它的先根遍历序列是ABEFCDG
,后根遍历序列是EFBCGDA
。
森林的遍历也是分两种:
ABCDEFGHJI
。BCDAFEJHIG
。对照上述例子中的二叉树的前序和中序遍历结果可以发现,森林的前序遍历和二叉树的前序遍历结果相同,森林的后序遍历和二叉树的中序遍历结果相同。同时,当以二叉链表作树的存储结构时,树的先根遍历和后根遍历完全可以借用二叉树的前序遍历和中序遍历的算法来实现。
定义:给定n个权值作为n个叶子结点,构造一棵二叉树,若带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为赫夫曼树(Huffman Tree)。赫夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
假设有$n$个权值,则构造出的赫夫曼树有$n$个叶子结点。 n个权值分别设为 $w_1,w_2,\ldots,w_n$,则赫夫曼树的构造规则为:
赫夫曼树的性质有:
这部分内容是之前看《数据结构算法与应用:C++语言描述》时没有记录到的知识点,但是在做有关树的练习题的时候却有涉及到,比如线索二叉树和赫夫曼树,特别是后者,一般会考察如何构造赫夫曼树以及求其带权路径长度。刚好在《大话数据结构》中看到,就做下笔记,总结下。
]]>本节首先介绍的是二叉搜索树的内容。
在跳表&散列1-字典&跳表介绍了抽象数据类型Dictionary,从中可以发现当用散列来描述一个字典时,字典操作(包括插入、删除和搜索)所需要的平均时间是$\theta(1)$。而这些操作最坏情况下的时间正比于字典中的元素个数$n$。如果扩充字典的抽象数据类型描述,增加以下操作,那么散列将不能再提供比较好的评价性能:
1) 按关键值的升序输出字典元素;
2)按升序找到第k个元素;
3)删除第k个元素。
为了执行操作1),需要从表中取出数据,将它们排序后输出。如果使用除数是D的链表,那么能在$\theta(D+n)$的时间内取出元素,在$O(nlogn)$时间内完成排序和$\theta(n)$时间内输出,因此共需时间$O(D+nlogn)$。如果对散列使用线性开型寻址,则取出元素所需时间是$\theta(b)$,b是桶的个数,这时需要时间是$O(b+nlogn)$。
如果使用链表,操作2)和3)可以在$O(D+n)$的时间内完成,如果使用线性开型寻址,它们可以在$\theta(b)$时间内完成。
如果使用平衡搜索树,那么对字典的基本操作(搜索、插入和删除)能够在$O(logn)$的时间内完成,操作1)能在$\theta(n)$的时间内完成。通过使用带索引的平衡搜索树,也能够在$O(logn)$的时间内完成操作2)和3)。
在学习平衡树之前,首先来看一种叫做二叉搜索树的简单结构。
定义 [二叉搜索树] 二叉搜索树(binary search tree)是一棵可能为空的二叉树,一棵非空的二叉搜索树满足以下特征:
1)每个元素有一个关键值,并且没有任意两个元素有相同的关键值;因此,所有的关键值都是唯一的。
2)根节点左子树的关键值(如果有的话)小于根节点的关键值。
3)根节点右子树的关键值(如果有的话)大于根节点的关键值。
4)根节点的左右子树也都是儿茶搜索树。
下图11-1给出3个含有不同关键值的二叉树,其中11-1a的二叉树满足了上述特征1-3,但是不满足特征4,而11-b和11-c的二叉树则是二叉搜索树。
在放弃二叉搜索树中所有元素必须拥有不同关键值的要求,然后用小于等于代替特征2)中的小于,用大于等于代替特征3)中的大于,这样就可以得到一棵有重复值的二叉搜索树。
带索引的二叉搜索树源于普通的二叉搜索树,它只是在每个节点中添加一个LeftSize域,这个域的值是该节点左子树的元素个数加1。下图11-2是两棵带索引的二叉搜索树。注意,LeftSize同时给出了一个元素在子树中的排名。
可以从二叉树的基本概念和实现中介绍的类BinaryTree中派生类BSTree,这样可以大大简化类BSTree的设计,实现如下程序所示。另外,为了访问BinaryTree类的私有成员root,需要将类BSTree定义为BinaryTree的友元。1
2
3
4
5
6
7
8template<class E,class K>
class BSTree : public BinaryTree<E>{
public:
bool Search(const K&k, E& e) const;
BSTree<E, K>& Insert(const E& e);
BSTree<E, K>& Delete(const K&k, E& e);
void Ascend(){ InOutput(); }
};
下面给出搜索元素的代码实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<class E,class K>
bool BSTree<E, K>::Search(const K&k, E &e) const{
// 搜索与k匹配的元素
BinaryTreeNode<E> *p = root;
while (p){
if (k < p->data)
p = p->LeftChild;
else if (k>p->data)
p = p->RightChild;
else{
// 找到元素
e = p->data;
return true;
}
}
return false;
}
若在二叉搜索树中插入一个新元素e,首先要验证e的关键值与树中已有元素的关键值是否相同,这可以通过用e的关键值对二叉树进行搜索来实现。如果搜索不成功,那么新元素将被插入到搜索的中端点,下面给出插入函数的代码实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28template<class E, class K>
BSTree<E, K>& BSTree<E, K>::Insert(const E& e){
// 如果不出现重复,则插入e
BinaryTreeNode<E> *p = root, *pp = 0; // p是搜索节点,pp是p的父节点
// 寻找插入点
while (p){
pp = p;
if (e < p->data)
p = p->LeftChild;
else if (e>p->data)
p = p->RightChild;
else
// 出现重复
throw BadInput();
}
BinaryTreeNode<E> *r = new BinaryTreeNode<E>(e);
if (root){
if (e < pp->data)
pp->LeftChild = r;
else
pp->RightChild = r;
}
else
root = r;
return *this;
}
对于删除操作,对包含被删除元素的节点p有三种情况:1)p是叶节点;2)p只有一个非空子树;3)p有两个非空子树。
对于第一种情况可以采用直接丢弃叶节点的方法来处理。
对于第二种情形,如果p没有父节点,即p是根节点,则将p丢弃,p的唯一孩子成为新的搜索树的根节点;如果p有父节点pp,则修改pp的指针,使其指向p的唯一孩子,然后删除节点p。
最后,对于第三种情形,只需要将元素替换成它的左子树中的最大元素或者右子树中的最小元素。注意,必须确保右子树中的最小元素以及左子树中的最大元素即不会在没有子树的节点中,也不会在只有一个子树的节点中。可以按下述方法来查找到左子树中的最大元素:首先移动到子树的根,然后沿着各节点的右孩子指针移动,直到右孩子指针为0为止。类似地,也可以找到右子树的最小元素:首先移动到子树的根,然后沿着各节点的左孩子指针移动,直到左孩子指针为0为止。
下面程序给出删除操作实现,它一般使用左子树的最大元素来进行替换。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53template<class E,class K>
BSTree<E, K>& BSTree<E, K>::Delete(const K& k, E& e){
// 删除关键值是k的元素,并将其放入e
// 将p指向关键值是k的节点
BinaryTreeNode<E> *p = root, *pp = 0;
while (p && p->data != k){
pp = p;
if (k < p->data)
p = p->LeftChild;
else
p = p->RightChild;
}
if (!p)
throw BadInput();
e = p->data;
// 对树进行重构,处理p有两个孩子的情形
if (p->LeftChild && p->RightChild){
// 转换成有0或1个孩子的情形,在p的左子树中寻找最大元素
BinaryTreeNode<E> *s = p->LeftChild, *ps = p;
while (s->RightChild){
ps = s;
s = s->RightChild;
}
// 将最大元素从s移动到p
p->data = s->data;
p = s;
pp = ps;
}
// 对于p最多有一个孩子
BinaryTreeNode<E> *c;
if (p->LeftChild)
c = p->LeftChild;
else
c = p->RightChild;
// 删除p
if (p == root)
root = c;
else{
if (p == pp->LeftChild)
pp->LeftChild = c;
else
pp->RightChild = c;
}
delete p;
return *this;
}
若二叉搜索树中的不同元素可以包含相同的关键值,则称这种树是DBSTree。在实现该类的时候,只需要把BSTree::Insert的while循环改成如下所示即可:1
2
3
4
5
6
7while (p){
pp = p;
if (e < p->data)
p = p->LeftChild;
else if (e>p->data)
p = p->RightChild;
}
更完整的例子可以查看二叉搜索树的实现
本节内容就简单介绍了二叉搜索树的代码实现。
]]>本节首先介绍的是二叉搜索树的内容。
上一节讲述的堆结构是一种隐式数据结构,用完全二叉树表示的堆在数组中时隐式存储的(即没有明确的指针或其他数据能够重构这种结构)。由于没有存储结构信息,这种描述方法空间利用率很高,事实上是没有空间浪费,尽管堆结构的时间和空间效率都很高,但它不适合所有优先队列的应用,尤其是当需要合并两个优先队列或多个长度不同的队列时,需要借助其他数据结构来实现这类应用,比如左高树(leftist tree)。
考察一棵二叉树,如下图9-6a所示,它有一类特殊的节点叫做外部节点,用来代替树中的空子树,其余节点叫做内部节点。增加了外部节点的二叉树被称为扩充二叉树,如下图9-6b所示,外部节点用阴影框表示,并且为了方便起见,这些节点用a~f标注。
令$s(x)$是从节点$x$到它的子树的外部节点的所有路径横纵最短的一条,根据其定义可知,如果$x$是外部节点,则$s$=0,若$x$是内部节点,则其$s$值为$min{s(L),s(R)}+1$,其中$L,R$分别是$x$的左右子树。所以上述扩充二叉树各节点的s值如上图9-c所示。
定义 [高度优先左高树] 当且仅当一棵二叉树的任何一个内部节点,其左孩子的$s$值大于等于右孩子的$s$值时,该二叉树是高度优先左高树(height-biased leftist tree,HBLT)。
定义 [最大(小)HBLT] 即同时是最大(小)树的HBLT;
图9-6a所示的二叉树并不是HBLT,因为外部节点a的父节点,其左孩子$s$=0,右孩子$s$=1,不满足条件,如果将这两个子树进行交换就可以满足HBLT的条件。
定理9-1 若x是一个HBLT的内部节点,则
1) 以$x$为根的子树的节点数目至少是$2^{s(x)}-1$.
2) 若子树$x$有$m$个节点,$s(x)$最多为$log_2(m+1)$
3) 通过最右路径(即路径是从$x$开始沿右孩子移动)从$x$到达外部节点的路径长度是$s(x)$。
可以通过考察子树的节点数目来得到另一类左高树。定义$x$的重量$w(x)$是以$x$为根的子树的内部节点数目。如果$x$是外部节点,则其重量为0;若$x$是内部节点,则其重量是其孩子节点的重量之和加1,如上图9-6d展示了二叉树各节点的重量。
定义 [重量优先左高树] 当且仅当一棵二叉树的任何一个内部节点,其左孩子的$w$值大于等于右孩子的$w$时,该二叉树为重量优先左高树(weight-biased leftist tree,WBLT);
[最大(小)WBLT]即同时又是最大(小)树的WBLT。
同HBLT类似,具有$m$个节点的WBLT的最右路径长度最多为$log_2(m+1)$。可以对WBLT和HBLT执行优先队列的查找、插入和删除操作,其时间复杂性与堆的相应操作相同。并且跟堆一样,WBLT和HBLT可以在线性时间内完成初始化。用WBLT或HBLT描述的两个优先队列可在对数时间内合并为一个,而堆描述的优先队列无法做到。
接下来将介绍HBLT的操作,而WBLT的查找、插入、删除、合并和初始化操作与HBLT非常相似。
插入操作可借助于合并操作来完成。它可以通过先建立一棵仅包含待插入元素的HBLT,然后与原来的HBLT合并即可。
根是最大元素,如果跟被删除,将留下分别以其左右孩子为根的两棵HBLT的子树,将其合并到一起,便得到包含除删除元素外所有元素的最大HBLT。
具有$n$个元素的最大HBLT,其最右路径的长度为$O(logn)$。合并操作操作仅需遍历欲合并的HBLT的最右路径,即仅需移动右孩子。
合并策略最好用递归来实现。令$A,B$是需要合并的两棵最大HBLT,假设两者均不为空,为实现合并,首先需要检查两个根元素,较大者是合并后HBLT的根。假设$A$具有较大的根,且其左子树是$L$,$C$是由$A$的右子树与$B$合并而成的HBLT。所以$A,B$合并的结果是以$A$的根为根,$L,C$为左右子树的最大HBLT。如果$L$的$s$值小于$C$的$s$值,则$C$是右子树,$L$是左子树。
通过将$n$个元素插入到最初为空的最大HBLT中来进行初始化,所需时间是$O(logn)$。为得到具有线性时间的初始化算法,首先创建$n$个最大HBLT,每个树中仅包含$n$个元素中的某一个,这$n$棵树排成一个FIFO队列,然后从队列中依次删除两个HBLT,将其合并,然后再加入队列末尾,直到最后只有一棵HBLT。
最大HBLT的每个节点均需要$data,LeftChild,RightChild和s$四个域,相应的节点类是$HBLTNode$,如下代码所示。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<class T>
class MaxHBLT;
template<class T>
class HBLTNode{
friend MaxHBLT<T>;
private:
int s;
T data;
HBLTNode<T>* LeftChild, *RightChild;
public:
HBLTNode(const T&e, const int sh){
data = e;
s = sh;
LeftChild = RightChild = 0;
}
};
而最大HBLT可用下面代码定义的类MaxHBLT来实现。类MaxHBLT的每个对象都有一个唯一的私有成员$root$,用来指向最大HBLT的根。构造函数在初始化时将其置为0,因此初始的最大HBLT是空。析构函数通过调用私有成员函数$Free$来删除HBLT中的所有节点,该函数按后序遍历整棵HBLT,每访问一个节点就删除该节点。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37template<class T>
class MaxHBLT{
private:
HBLTNode<T> *root;
void PostOrder(void(*Visit)(HBLTNode<T>*u), HBLTNode<T>* t){
// 后序遍历
if (t){
PostOrder(Visit, t->LeftChild);
PostOrder(Visit, t->RightChild);
Visit(t);
}
}
static void free(HBLTNode<T>* t){
delete t;
}
void Free(HBLTNode<T> *t){
PostOrder(free, t);
t = 0;
}
void Meld(HBLTNode<T> * &x, HBLTNode<T>* y);
public:
MaxHBLT(){ root = 0 };
~MaxHBLT(){ Free(root); }
T Max(){
if (!root)
throw OutOfBounds();
return root->data;
}
MaxHBLT<T>& Insert(const T& x);
MaxHBLT<T>& DeleteMax(T& x);
MaxHBLT<T>& Meld(MaxHBLT<T>& x){
Meld(root, x.root);
x.root = 0;
return *this;
}
void Initialize(T a[], int n);
};
接下来先给出合并操作的函数实现代码,该函数首先要处理合并的树中至少有一个为空的特殊情况。当没有空树时要确保$x$指向根值较大的树,如果$x$不是指向根值较大的树,则将$x$和$y$的指针进行交换。接下来把$x$的右子树与以$y$为根的最大HBLT进行递归合并。合并后为保证整棵树是最大HBLT,$x$的左右孩子可能需要交换,这是通过计算$x$的$s$值来确定的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32template<class T>
void MaxHBLT<T>::Meld(HBLTNode<T>* &x, HBLTNode<T>* y){
// 合并两棵根分别是*x和*y的左高树,返回指向新根 x的指针
if (!y)
return;
if (!x){
x = y;
return;
}
if (x->data < y->data){
// 交换x和y
HBLTNode<T> * temp = y;
y = x;
x = temp;
}
Meld(x->RightChild, y);
if (!x->LeftChild){
// 左子树为空,交换子树
x->LeftChild = x->RightChild;
x->RightChild = 0;
x->s = 1;
}
else{
if (x->LeftChild->s < x->RightChild->s){
// 交换左右子树
HBLTNode<T> * temp = x->LeftChild;
x->RightChild = x->LeftChild;
x->LeftChild = temp;
}
x->s = x->RightChild->s + 1;
}
}
最后给出插入,删除和初始化函数的代码实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47template<class T>
MaxHBLT<T>& MaxHBLT<T>::Insert(const T& x){
// 将x插入左高树
HBLTNode<T>* q = new HBLTNode<T>(x, 1);
// 将q与原树合并
Meld(root, q);
return *this;
}
template<class T>
MaxHBLT<T>& MaxHBLT<T>::DeleteMax(T& x){
// 删除最大元素,并将其放入x
if (!root)
throw OutOfBounds();
x = root->data;
HBLTNode<T>*L = root->LeftChild;
HBLTNode<T>*R = root->RightChild;
delete root;
root = L;
Meld(root, R);
return *this;
}
template<class T>
void MaxHBLT<T>::Initialize(T a[], int n){
// 初始化有n个元素的HBLT树
Queue<HBLTNode<T>*>Q(n);
// 删除老节点
Free(root);
for (int i = 1; i <= n; i++){
HBLTNode<T>* q = new HBLTNode<T>(a[i-1], 1);
Q.Add(q);
}
// 不断合并队列中的树;
HBLTNode<T>*b, *c;
for (int i = 1; i <= n - 1; i++) {
Q.Delete(b).Delete(c);
Meld(b, c);
// 将合并后得到的树放入对了
Q.Add(b);
}
if (n)
Q.Delete(root);
}
对于上述函数的复杂性,构造函数只需要耗时$\theta(1)$,而析构函数需要$\theta(n)$,其中$n$是要删除的最大HBLT中的元素个数。Max函数的复杂性是$\theta(1)$,Insert,DeleteMax及共享成员函数Meld的复杂性与私有成员函数Meld的复杂性相同,由于私有成员函数Meld仅在以$x$和$y$为根的树的右子树中移动,因此其复杂性是$O(x->s+y->s)$。又由于$x$和$y$的最大$s$值分别为$log_2(m+1)$和$log_2(n+1)$,其中$m,n$分别是以$x$和$y$为根的最大HBLT中的元素个数,所以私有成员函数Meld的复杂性是$O(logmn)$。
更完整的代码例子可以查看最大高度优先左高树的实现。
]]>