「OF COURSE」每周推出一期主题代码检查,通过分享让更多人接触到优秀的创意编程代码,让大家最直观,最近距离的了解创意编程。
吴军先生在他的《智能时代》中曾经提到:“如果我们把资本和机械动能作为大航海时代以来全球近代化的推动力的话,那么数据将成为下一次技术革命和社会变革的核心动力”。
的确,现在这个时代是数据的时代。当人工智能需要的计算能力、算法都放到云上,成为一件人人可以得到的武器的时候,谁拥有了数据谁才取得了优势。Google的成功的原因之一就是它拥有以太级别的数据。
![3562701711556deae46f8eace1f79ade.png](https://i-blog.csdnimg.cn/blog_migrate/3315bdd94b51135e325c52a2a1abd890.jpeg)
而在这个人人上网的时代,网络可以说是数据的最大聚集地。如果说网络是一个蕴含大量数据的金库,那么网络爬虫就是获得这里面黄金的重要钥匙之一。
因为网络爬虫爬取网页的策略所对应的算法很有意思,所以我对其中三种用Processing进行了简单的可视化,下面大家可以先直观的感受一下。
接下来我首先给大家说明一下网络爬虫的原理,然后介绍如何用Processing实现该可视化效果,最后再给大家拓展一点计算机算法中图论的小知识。
心急的同学可以看一在线效果https://www.openprocessing.org/sketch/728303。
- 准备:网络爬虫
![6df6c3d6b7c9c768dfbae159f72ba108.png](https://i-blog.csdnimg.cn/blog_migrate/cd0308ea5a99ca53a45c689ce0ff114a.jpeg)
世界上第一个网络爬虫是由麻省理工学院的学生马林格雷在1993年写成的。他给自己的程序起了个名字叫做“互联网漫游者”(WWW Wanderer)。
虽然现在的网络爬虫越写越复杂,但原理其实是一样的。
我们所了解的网络虽然大,其实就是由一个个网页组成的,而这些网页之间由超链接相互链接。比如在网上购物商店官网的首页,我们可以点击感兴趣的商品所对应的超链接,然后跳转到该商品的详情页。下面蓝色的网址就是就是某宝网站首页的源代码中的超链接。
![968c0d49722a3783f870883bc8a5bede.png](https://i-blog.csdnimg.cn/blog_migrate/fdfcd12a4d4b07ec0002cf5bd60725c2.jpeg)
如果我们希望获得这个网上购物商店所有商品的详细信息,我们就可以在其官网的首页上放一个网络爬虫。该爬虫会首先会下载并解析该网页,获得想要的数据,找到其中的超链接,下载这些超链接对应的网页。然后解析这些新的网页,并分析获得它们的数据和新的超链接。重复以上的步骤,直到找到所有的商品信息。
这里需要说明的一点是,因为可能有不同的超链接指向同一个网页,所以网络爬虫必须记录哪些网页已经访问过了,防止爬取重复信息。
网络爬虫需要考虑的一个主要的问题就是它爬取网页的策略。考虑我们要从首都北京所对应的网页出发爬取下图这个简单网络的所有网页。其中每一个圆表示一个网页,每一条线表示一条超链接。
![69af9af7fe41710a1893eaee0d170f4d.png](https://i-blog.csdnimg.cn/blog_migrate/b07c9747fe4d7a80ccfe0e58fe7e8533.jpeg)
第一种策略,爬虫尽可能地“广”地访问与网页直接相连的网页。比如从北京开始,依次爬取呼和浩特、石家庄、济南、天津、沈阳这几个网页,然后再从石家庄这个网页出发,爬取太原和郑州所对应的网页。按这种策略一直爬下去,最后得到下图:图中的数字依次表示爬取的次序。
![852aa3e15de192ae8353a378ffe2d191.png](https://i-blog.csdnimg.cn/blog_migrate/8f33ce9f3bd8e781dc8838fb4351c1e9.jpeg)
第二种策略,尽可能的一条路走到黑。爬虫还是从北京所对应的网页出发,先爬取呼和浩特这个网页,发现不能再深入了,于是回到北京,换一个方向出发,向石家庄出发。到了太原这个网页发现又不能继续深入了,回到石家庄,换一个方向继续出发。按这种策略一直爬下去,最后得到下图:图中的数字依次表示爬取的次序。
![394d1f4d7c2840bc854a23f13ee7e71a.png](https://i-blog.csdnimg.cn/blog_migrate/53e62c3a4848936f612b2e7b46d5cb0b.jpeg)
当然还有第三种策略,就是结合上面两种,不要只尽可能地“广”地访问与网页直接相连的网页,也不要一条路走到黑。
下面我们就开始用Processing对上面提到的三种策略进行一下简单的可视化,看一看哪一种方案最适合网络爬虫。
- 代码
1. 主要思想
可视化的思想也简单。我们把整个网络抽象成一个棋盘,每一个格子代表一个网页。爬虫可以在这个棋盘上移动,如果它移动到一个格子就会对其染色,表示它已经爬取过该网页的信息了。
这样我们就把爬虫爬取网络这个过程变成了对该棋盘染色的过程,其爬取网页的顺序就对应对该棋盘上格子染色的顺序。我们要做的就是给爬虫指定一个移动的顺序或者规则,让整个棋盘被染上颜色。
这里有四点需要说明。
第一,假设任意相邻的格子之间都有超链接。也就说爬虫如果移动了下面图中这个蓝色的格子,它就可以移动该蓝色格子上下左右的相邻的四个黄色的个格子。
![84e16acd6b29230294dd98d239a0c5d7.png](https://i-blog.csdnimg.cn/blog_migrate/8b5906df8fbeecf08c31c2e5635cf16a.jpeg)
第二,假设行或列距离比较近的格子间在某些情况下也可以认为有超链接。也就说虽然第3列的格子和第5列的格子没有相邻,但某些情况下下也可以看作被超链接相连接。至于原因在后面我们会讲到。
第三,每个格子有以下三种状态:
- 没有被发现:初始化的时候格子全部都是这个状态。
- 发现了但是没有被访问:上面的黄色的格子就是这个状态,此时爬虫在蓝色这个格子,爬取了蓝色格子所对应的网页的信息,然后找到了指向它旁边四个格子的超链接。那么这四个格子在这种情况下就是发现了但是没有被访问。
- 被访问了:上面的蓝色的格子就是被访问了,因为爬虫爬取了它对应网页的信息,也染了色。
第四,最开始的格子都是白色的,爬虫对其染的颜色取决于该爬虫到达该格子所经过超链接的数量。
开始爬取之前,我们将所有格子标记为没有被爬取(或者访问过)。然后通过点击棋盘上的任意格子在该格子上放上一个爬虫并且激活该它,之后该爬虫会根据当前的爬取策略,在棋盘上移动。这样我们的棋盘就开始按照模式被染色了。
在了解了该可视化的主要思想后,我们就开始用Processing写代码思想该效果。
2.主要结构
第一步我们来看看代码的大体结构。这部分代码主要初始化了棋盘和颜色比例尺,以及确定该作品的交互方式。我们可以通过点击鼠标在该棋盘上的任意位置放置爬虫,通过按下键盘上的任意按键来改变爬虫的移动策略。
Grid g;
void setup() {
size(500, 500);
//初始化棋盘,将棋盘的宽度和高度设置为和作品一样,一个像素代表一个格子。
g = new Grid(width, height, 1);
colorMode(HSB, 360);
background(360);
}
void draw() {
//爬虫不停爬,直到格子完全被染色。
g.crawl();
}
void mousePressed() {
g.putCrawler(mouseX, mouseY);
}
void keyPressed() {
g.changeCrawlerType();
}
class Grid{}
//颜色比例尺,用于确定每个格子的颜色
int colorScale = {#6d3fa9, /*.....*/,#6d40aa};
3. Grid类
下面我们先来看棋盘这个类。每一个格子有一个对应的标号,该标号由它所在的行数和列数决定。棋盘类有两个很重要的成员变量:visited和frontier。
visited这个int类型的数组用于记录每个格子是否被访问过。如果该格子没有被访问过,那么在这个数组里面对应的值为0,否者为1。
frontier是个动态数组,用来存储被已经被发现的格子。每一次我们对一个格子染色后,会将它相邻且没有被访问过的的格子放入该动态数组。然后爬虫会按照某种策略选择这个动态数组中的一个格子,移动到该格子对其染色。爬虫从该数组里面选择格子的策略不同,导致了它对整个棋盘的染色模式不同,这个我们很快会看见。
在每一次染色开始的时候,我们都会把每个格子的状态设为没有被访问过,并且清空frontier动态数组中的格子,然后将爬虫爬的起始格子的标号放入froniter中,表示这个格子已经被发现了,可以被染色了。
这里需要注意的是我们把从起始格子到到达该格子的超链接数用一个depth数组存储,并且称其为这个格子的深度。
了解了这些,大家看懂下面的代码就很容易了。
class Grid {
//分别代表两种移动的策略
final int BFS = 0, DFS = 1;
float cellSize;
//记录已经发现的还没有来的即被访问的点
ArrayList<Integer> frontier;
int [] visited, depth;
int col, row;
//当前移动的策略
int type;
boolean isCrawling;
Grid(float _width, float _height, float _cellSize) {
cellSize = _cellSize;
row = int(_height / cellSize);
col = int(_width / cellSize);
isCrawling = false;
type = BFS;
}
void init() {
visited = new int[row * col]; //初始化为零,表示都没有来过
depth = new int[row * col];
frontier = new ArrayList();
}
void changeCrawlerType() {
type++;
type %= 2;
}
void putCrawler(int x, int y) {
//清空画布并且初始化格子
background(360);
init();
isCrawling = true;
//根据鼠标位置添加一个被发现的格子,等待爬虫移动到上面
int r = int(y / cellSize);
int c = int(x / cellSize);
int index = col * r + c;
frontier.add(index);
depth[index] = 0;
}
void crawl() {
if (isCrawling) {
if (type == BFS) {
randomizedBFS();
} else {
randomizedDFS();
}
}
}
//和移动策略相关的函数
}
在这里说明一下,以下所提到的函数都是Grid这个类的成员函数。
在我们看移动策略的代码之前,我们先看几个对数组进行操作的函数。
//交换数组中指定两个元素的位置。
void swap(ArrayList<Integer> array, int i, int j) {
int tmp = array.get(i);
array.set(i, array.get(j));
array.set(j, tmp);
}
//删除数组的最后一个元素,并且返回这个元素。
int pop(ArrayList<Integer> array) {
int n = array.size();
int t = array.get(n - 1);
array.remove(n - 1);
return t;
}
//删除该数组中的任意一个元素,并且返回该元素。
int popRandom(ArrayList<Integer> array) {
int n = array.size(), i = int(random(n));
int t = array.get(i);
swap(array, n - 1, i);
array.remove(n - 1);
return t;
}
//打乱数组指定范围元素的顺序
void shuffle(ArrayList<Integer> array, int left, int right) {
int m = right - left;
while (m > 0) {
int i = int(random(1) * m --);
swap(array, left + i, left + m);
}
}
4.第一种移动策略
接下来我们就来看如何用代码来实现第一种移动策略,就是之前提到的爬虫尽可能地“广”地访问与网页直接相连的网页。
大概的思路就是如何还有被发现但是没有被访问的格子的时候(frontier数组非空),我们从其中随机的挑选一个格子中进行染色,并且将它没有被访问过且相邻的格子放入frontier数组中,等待被爬虫“看上”。同时更新这些新的被发现的格子的深度。
这里需要注意的一点就是有些在frontier的格子可能已经被访问过。因为一个格子会和多个格子相连,它在被访问前可能被加入frontier数组多次。对于这种情况,我们在对格子染色前进行一下简单的判断即可,如果它被访问了,那么跳过这次循环,不对其进行染色。
void randomizedBFS() {
//k用于控制在每一次刷新屏幕的时候该算法进行多少次迭代,k越大,染色速度越快。
int k = 0;
while (++k < 1200 && !frontier.isEmpty()) {
int node = popRandom(frontier);
if(visited[node] == 1) continue;
int x = node % col, y = int(node / col);
//染色
fillCell(node);
//枚举该格子的上下左右的四个相邻格子。
int next;
//上面
if (y > 0 && visited[next = (node - col)] == 0) {
frontier.add(next);
depth[next] = depth[node] + 1;
}
//下面
if (y < row - 1 && visited[next = (node + col)] == 0) {
frontier.add(next);
depth[next] = depth[node] + 1;
}
//左边
if (x > 0 && visited[next = (node - 1)] == 0) {
frontier.add(next);
depth[next] = depth[node] + 1;
}
//右边
if (x < col - 1 && visited[next = (node + 1)] == 0 ) {
frontier.add(next);
depth[next] = depth[node] + 1;
}
//表示已经改节点已经被访问。
visited[node] = 1;
}
}
看完代码,细心的同学可能会发现:不是说好的尽可能广得访问直接相连的格子吗?但popRandom函数返回的格子不一定与上一个访问的格子相连或者深度相同?
理论上来将确实是这样,但是我们是为了简单所以只认为每个格子只与其相邻的格子相连。在实际的网络中,每一个网页会和它距离(从一个网页到另一个网页需要的超链接的数量)相近的网页都可能相连。
为了使得可视化的结果更加贴近实际情况,所以之前我们做了“行或列距离比较近的格子间某些情况下也可以认为有超链接 ”的这个假设。至于对格子染色的fillCell函数我们之后再讲。
5.第二种移动策略
下面我们来看一下爬虫的第二种移动方式,所谓的“一条路走到黑”。这种移动策略的核心是给一个格子染完色后,在它相邻并且未访问过的格子中随机选择一个进行染色。
而对这种策略的随机选择这个行为的实现比较有技巧。我们会对在这次迭代中frontier数组加入的格子打乱顺序,然后返回该数组的最后一个格子作为下一个染色的对象,从而到达随机选择的效果。
因为第二种移动策略和第一种类似很多地方类似,所以在这里我就不多讲了。
void randomizedDFS() {
int k = 0;
while (++k < 1200 && !frontier.isEmpty()) {
int node = pop(frontier);
if(visited[node] == 1) continue;
int x = node % col, y = int(node / col);
fillCell(node);
//枚举该格子的所有邻居
//m用于记录这一次循环加入了多少个未被访问的节点
int m = 0, next;
if (y > 0 && visited[next = node - col] == 0) {
frontier.add(next);
depth[next] = depth[node] + 1;
m++;
}
if (y < row - 1 && visited[next = node + col] == 0) {
frontier.add(next);
depth[next] = depth[node] + 1;
m++;
}
if (x > 0 && visited[next = node - 1] == 0) {
frontier.add(next);
depth[next] = depth[node] + 1;
m++;
}
if (x < col - 1 && visited[next = node + 1] == 0 ) {
frontier.add(next);
depth[next] = depth[node] + 1;
m++;
}
visited[node] = 1;
//打乱新加入节点的顺序
shuffle(frontier, frontier.size() - m, frontier.size());
}
}
6.颜色的选取
下面我们来看看如何对一个格子进行染色。这里的关键是对颜色的选取。在这个作品颜色的来源是下面这个色条。
![486e95b1b0c1e78dd52b5b34a6bfcb9f.png](https://i-blog.csdnimg.cn/blog_migrate/496e7fc18905cc280b214ebe874dc233.jpeg)
因为直接获得这个色条所对应的颜色序列的算法比较复杂,不方便给大家介绍,所以我用了一种最笨也是最直接的办法。我在这个色条中按从左到右的顺序选取了946个颜色,然后将它们的16进制的HSB表示方法放入了colorScale这个一个int类型的数组。
这样我们就可以通过该格子的深度来索引到对应的颜色。因为一共有946种颜色,所以我们在取色的时候需要首先对深度进行取模操作,防止超出数组的长度。
void fillCell(int i) {
int x = i % col, y = int(i / col);
noStroke();
//
int d = depth[i] % 946;
fill(colorScale[d]);
rect(x * cellSize, y * cellSize, cellSize, cellSize);
}
因为colorScale数组的长度太长,所以这里只展示一部分,感兴趣的同学可以去看源代码。
int [] colorScale = {
#6d3fa9, #6e3faa, #6f3faa, #703faa, #703fab, #713fab, #723fab, #733fac, #743fac, #753fac,
//...
#6d40ac, #6d40ab, #6d40aa};
- 分析
我们对网络爬虫爬取网页的可视化的代码部分就介绍完了,下面我们来看看可视化的结果,分析哪一种爬取策略更加适合它。
第一种爬取策略
下面是第一种策略的可视化结果。可以发现色彩的分布是一层一层的,也就说它对网站的数据进行一层一层的爬取。
在时间有限的情况下,这种方法尤其适合。因为一个对于一个网站来说,越重要的网页的层级越高,比如层级最高的就是首页,而里面的数据显然是最重要的。这样爬虫就可以在有限的时间内最多地爬下最重要的信息。
![4106ffc4a8f10c59500f8fc08faeb355.png](https://i-blog.csdnimg.cn/blog_migrate/6e78f3076f7b2715e5de638b476503f8.jpeg)
第二种爬取策略
下面我们再来看第二种爬取策略的结果,可以发现颜色的变化轨迹很明显,并且区域与区域间的轮廓很明显。
在需要效率的时候,可以选择第二种爬取策略。因为爬虫在爬取一个网站之前,首先要和该网站建立通信,这个过程叫做“握手”。握手需要时间,所以如果按照第一种爬取策略:“每个网站轮流下载5%,然后再回过头来下载第二批”,那握手次数太多,下载效率就降低了。所以可以一个网站一个网站的下载,这就想第二种爬取策略。
![e4939266f34c479e795c418be9c7ae35.png](https://i-blog.csdnimg.cn/blog_migrate/36b76c4252eda06bac5447071182a4f6.jpeg)
两者结合
经过上面的分析可得,在爬虫真正的爬取网站的过程中,不是单单采取前两种策略的其中一种,而是两种相结合。看具体的情况是更看中数据的质量还是数量。
同样很巧的是,将两种方式结合起来的可视化结果在我看来是也是最好看的,如下图。
它既没有第一种可视化结果大片相同色环域带来的平庸感,也没有第二种可视化结果弯弯曲曲的色条带来的极端感,更多的是一种不同色块拼接而成的柔和又强烈的舒适感。
得到这类可视化结果的方法也相当的简单,只需要在爬虫给整个棋盘上完色之前,按下键盘上的任意按键,让它在两种移动策略之前不停切换即可。按键的频率和时刻都会带来不同的结果。大家可以去尝试一下。
![1cd771b96525243667e01061e9dfe308.png](https://i-blog.csdnimg.cn/blog_migrate/86e81adffa0cfed1d851b88e17d9690e.jpeg)
- 拓展:图论
在不知不觉间,其实已经我给大家介绍了图论的一些基础知识了。
离散数学是当代数学的一个重要的分支,也是计算机科学的数学基础。它又包括四个分支,而图论便是其中一支,另外三个分别是:数理逻辑、集合论、近代数学。
;图论中的图由一些节点和边构成,就和我们前面提到的网络一样。其中每一个网页为节点,每一个超链接为边。
在图论中一个很基础的问题就是如何遍历一张图:通过边访问图的边到达图的各个节点,而最出名的遍历问题莫过于哥尼斯堡七桥问题。当时大数学家欧拉来到布鲁士的哥尼斯堡,发现当地人有一个消遣活动,试图将下图中的每座桥恰好走一遍并回到出发原点,但是从来没有人成功过。欧拉证明了这种走法不可能,并就此写了一篇论文,一般便认为这便是图论的开始。
![bbf31a0d48f76bb967a2fcc57a1a455f.png](https://i-blog.csdnimg.cn/blog_migrate/ad221f3a8b1524fd938f4fac4b4f79f1.jpeg)
不过,我们所说的遍历一张图没有每条边只能走一次这种约束条件。而前面提到的爬虫的两种移动策略其实分别对应图论中两种最基本的对图进行遍历的方法:广度优先搜索(Bread-First Search,简称BFS),深度优先搜索(Depth-First Search,简称DFS)。
只不过为了使得可视化的更加自然,给这两个基本的算法添加了一些随机性,但是大体的思想是完全一样的,感兴趣的同学可以去了解这两种算法更加一般的写法。
当然在图论中还有更多的算法,虽然它们的目的不是为了遍历整张图,但是很多时候为了达到目的它们不得不遍历整张图,而它们遍历图的顺序都不尽相同,对应的可视化结果也不同。比如寻找最小生成树的普里姆算法、寻找单源最短路的狄杰斯特拉算法,感兴趣的同学可以去了解一下这些算法,然后对其进行可视化。
- 作者小结
又到了和大家说再见的时候了,今天我们不仅揭开了网络爬虫的神秘面纱,还更直观的感受了算法之美和数学之美。
其实这些算法和数学魅力和背后蕴含的魅力不仅仅可以体现在作品当中,也会体现在生活的点点滴滴。比如其实我们生活中做很多事都是今天提到的BFS和DFS的取舍与博弈。举个简单的例子,马上我就要准备期末考试前的复习了,我可以按照BFS那样一门复习一点,交替复习,也可以按照DFS那样复习完一门再复习下一门。
其实生活很多事情都是这样,我们是选择“雨露均沾”?还是“一条路走到黑”?不过,没有什么是非黑即白的,我们一般会根据个人的情况对这两种方式进行不同程度上的融合。
所以大家对算法不要有惧怕的情绪,因为它们蕴含了无尽的智慧,它们有给我们的作品如虎添翼的能力!
![db5459934e2b13b5095a49b8e7c03ef6.png](https://i-blog.csdnimg.cn/blog_migrate/250b881c90eb26b5eab4b2a91a9c1555.jpeg)
参考资料
- 《数学这美》--吴军
- https://bl.ocks.org/mbostock/310c99e53880faec2434