牛客网的三道简单练习

[编程题] 斐波那契数列
大家都知道斐波那契数列,现在要求输入一个整数n,请你输出斐波那契数列的第n项。
[c language=”++”]
class Solution {
public:
int Fibonacci(int n) {
if(n==0) return 0;
else if(n==1) return 1;
else
{
int dp1=0;
int dp2=1;
for(int i=2;i<=n;i++)
{
int tmp=dp2;
dp2=dp2+dp1;
dp1=tmp;
}
return dp2;
}
}
};
[/c]

[编程题] 二进制中1的个数
输入一个整数,输出该数二进制表示中1的个数。其中负数用补码表示。
[c language=”++”]
class Solution {
public:
int NumberOf1(int n) {
int result=0;
int check=1;
for(int i=0;i<sizeof(int)*8;i++)
{
if((check&n)!=0)
result++;
check=(check<<1);
}
return result;
}
};
[/c]

[编程题] 反转链表
输入一个链表,反转链表后,输出链表的所有元素。
[c language=”++”]
/*
struct ListNode {
int val;
struct ListNode *next;
ListNode(int x) :
val(x), next(NULL) {
}
};*/
class Solution {
public:
ListNode* ReverseList(ListNode* pHead) {
ListNode* now=pHead;
ListNode* preHead=new ListNode(0);
preHead->next=now;

if(now==NULL) return now;
while(now->next)
{
ListNode* tmp=now->next;
now->next=tmp->next;
tmp->next=preHead->next;
preHead->next=tmp;
}
return preHead->next;

}
};
[/c]

#1106 : Koch Snowflake

1.题目主要是给出了科赫雪花分形,给出变形的代数n和边的编号i,求出这条边属于第几代。

2.首先我们来看看从第n-1代到第n代的变化,设第n-1代中的边i,如下图:

QQ截图20151223091034

我们看到,在第n-1代中的边i,变形到第n代时,会演化成为4(i-1)+1、4(i-1)+2、4(i-1)+3、4(i-1)+4.

其中4(i-1)+2和4(i-1)+3是第n代的边,而4(i-1)+1、4(i-1)+4是第n-1代的边。

那么现在我们明确了,如果某边的编号i%4==2或==3,那么这条边就是第n代的,否则,返回到上一代中继续求。返回到上一代时,4(i-1)+1、4(i-1)+4对应的是i,那么假设边k需要返回到上一代,那么应该k/4向上取整(例如1/4=1,4/4=1),然后继续迭代。

需要使用递归求解。

时间限制:10000ms
单点时限:1000ms
内存限制:256MB

描述

14214811509136.png

Koch Snowflake is one of the most famous factal. It is built by starting with an equilateral triangle, removing the inner third of each side, building another equilateral triangle at the location where the side was removed, and then repeating the process indefinitely.

Let Kn be the Koch Snowflake after n-th iteration. It is obvious that the number of sides of Kn, Nn, is 3*4n. Let’s number the sides clockwisely from the top of Koch Snowflake.

Let si,n be the i-th side of Kn. The generation of si,n is defined as the smallest m satifying si,n is a part of the sides of Km. For example, in the above picture, the yellow sides are of generation 0; the blue sides are of generation 1; the red sides are of generation 2.

Given a side si,n, your task is to calculate its generation.

输入

The input contains several test cases.

The first line contains T(T <= 1000), the number of the test cases.

The following T lines each contain two numbers, i(1 <= i <= 10^9) and n(0 <= n <= 1000). Your task is to calculate the generation of side si,n.

输出

For each test case output the generation of the side.

样例输入
5
1 0
1 1
2 1
10 2
16 3
样例输出
0
0
1
2
0

 

下面是AC的源代码:

[c language=”++”]
//#include<string>
//#include <iomanip>
#include<fstream>
//#include<set>
//#include<queue>
#include<map>
//#include<unordered_set>
//#include<unordered_map>
//#include <sstream>
//#include "func.h"
//#include <list>
#include<stdio.h>
#include<iostream>
#include<string>
#include<memory.h>
#include<limits.h>
//#include<stack>
#include<vector>
#include <algorithm>
using namespace std;
/*
1.题目主要是给出了科赫雪花分形,给出变形的代数n和边的编号i,求出这条边属于第几代。

2.首先我们来看看从第n-1代到第n代的变化,设第n-1代中的边i,如下图:

4(i-1)+2 4(i-1)+3
/\
i 4(i-1)+1 / \ 4(i-1)+4
———- —– ——
第n-1代 第n代

我们看到,在第n-1代中的边i,变形到第n代时,会演化成为4(i-1)+1、4(i-1)+2、4(i-1)+3、4(i-1)+4.

其中4(i-1)+2和4(i-1)+3是第n代的边,而4(i-1)+1、4(i-1)+4是第n-1代的边。

那么现在我们明确了,如果某边的编号i%4==2或==3,那么这条边就是第n代的,否则,返回到上一代中继续求。
返回到上一代时,4(i-1)+1、4(i-1)+4对应的是i,那么假设边k需要返回到上一代,那么应该k/4向上取整(例如1/4=1,4/4=1),然后继续迭代。

*/
int dfs(int side, int generation)
{
if (generation == 0)
return 0;
else
{
if (side % 4 == 2 || side % 4 == 3)
return generation;
else
{
int i = side / 4;
if (side % 4) i++;
return dfs(i, generation – 1);
}
}
}
/*
函数名 :main
函数功能:主函数
*/
int main(void)
{
int n;
cin >> n;
while (n–)
{
int side, generation;
cin >> side >> generation;
cout << dfs(side, generation) << endl;
}
return 0;
}
[/c]

 

319. Bulb Switcher(Medium)

1.该问题是算法设计问题中的一个经典问题:开关翻转问题。

2.根据题意,我们可以把题目转化为,被翻转偶数次的灯是灭的,被翻转奇数次的灯是亮的。

3.那么问题可以继续转化为:什么编号的灯被翻转奇数次?

4.进一步转化:编号为n的灯,n有若干个因数(注意不是质因数),那么它会被它的因数进行翻转操作,也就是说,如果有奇数个因数,那么编号为n的灯就会被翻转奇数次,即最终是亮的。如数字4,因数为1、2、4;数字9号,因数为1、3、9;

5.问题可以继续转化:如果判断数字n的因数有奇数个?

每个数都可以化简成质因数的乘式n=(a[i]^p[i])*(a[i+1]^p[i+1])…….其中a[i]为质数,p[i]为数字n包含质数a[i]的多少次方。

那么n的因数总数为:(p[i]+1)*(p[i+1]+1)…….

使得因数的总数为奇数,那么每个项都应该是奇数,即p[i]+1为奇数,那么p[i]为偶数。

如果p[i]为偶数,那么a[i]^p[i]为a[i]^2、a[i]^4、a[i]^6等等,即这些数是为完全平方数,例如4,9,16,25等等。

6.所以,题目要求的是,n以内的完全平方数的个数。

AC代码如下:
[c language=”++”]
class Solution {
public:
int bulbSwitch(int n) {
int result = 0;
for (int i = 1; i*i <= n; i++)
result++;
return result;
}
};
[/c]
 

arp包导致的网络拥挤

1.现象

前些天实验室的网络开始变得很慢,一开始我们以为是断网,后来我们发现,其实是能够上网的,只是网速非常慢。而且隔壁两个实验室却没有这样的情况。

2.查找

听宁哥说他同学用wireshark抓包,发现有一台惠普的机器在疯狂的发送arp包,我们怀疑是不是实验室的惠普打印机出问题了。于是下载了wireshark来进行抓包。

IMG_1103

我们会发现,有两台机器在疯狂地发送arp包,其中有Microsof_02和Elitegro_e3,他们都在广播arp包,似乎在解析192.168.1.100地址的时候出现了问题。我们可以初步地定位网络拥挤的原因,就是因为这两台机器在不停地广播arp包。

我们来看看arp包的内容:

IMG_1098

 

似乎除了机器的MAC地址外,查不到有效的信息。

IMG_1105

 

我们看到黄色字,提示我们这个192.168.1.100的ip被别人使用了。那么问题就变成了,现在有两台不同mac地址的机器在占用同一个ip。

其中数据包中也没看到什么有用的信息了。

于是我使用了arp -a的命令,来查看,这些MAC地址对应的机器IP是多少:IMG_1104

 

结果发现,我们并没有找到这些MAC地址,所以我还是没办法定位这些机器。

直到,第二天早上,实验室的同学找到了其中一个mac地址对应的ip是172.18.219.240。而更神奇的是,宁哥刚还有另外实验室的同学电脑ip恰好就是240。172的ip是整个学院楼的ip段,我们当初也没有想到是其他实验室的机器导致我们网络拥挤。

后来,宁哥告诉我们,他的同学新买了一个路由器,然后把原来电脑的mac地址复制到路由器上面了,导致arp的时候发现有两台设备有回应,进一步导致了上述的arp广播风暴。后来把路由器拔掉了就没事了,拯救了整个实验室!

对于上面的过程,其实我还是有比较多的疑问:

1. 192网段的ARP包为什么会广播到172的网段?

2.为什么那两台机器会狂发arp包?而且从包中可以看出他们的mac地址明显不一样?

这些问题需要待后续深入研究和了解才能进行补充了~

 

 

 

37. Sudoku Solver(Hard)

1.这道题目主要是使用回溯法进行遍历,其中需要注意的是进行剪枝。剪枝情况如下:

首先是遍历可以填充的数字,如果遍历完后都没有可以填充,那么就直接回溯,不用继续遍历接下来的空格。

否则会出现这种情况,前面还没填充就去填充后面的:

QQ截图20151223091034

另外最基本的就是用hash检测行列和方块里面的数字是否已经被使用,其中方块数字的index应该为[i/3*3+j/3]。

AC代码如下:

[c language=”++”]
class Solution {
public:
vector<vector<bool>> HashRow;
vector<vector<bool>> HashRec;
vector<vector<bool>> HashCol;
void dfs(vector<vector<char>>& board,int&leftNumber,bool&isFilled)
{
//printfVectorInt2(board);
//printf("\n");
if (leftNumber == 0)
isFilled = true;
else
{
for (int i = 0; i < 9; i++)
{
for (int j = 0; j < 9; j++)
{
if (board[i][j] == ‘.’)
{
bool noNum = true;
for (int num = 1; num <= 9; num++)
{
if (!HashRow[i][num] && !HashCol[j][num] && !HashRec[i / 3 * 3 + j / 3][num])
{
noNum = false;
HashRow[i][num] = true;
HashCol[j][num] = true;
HashRec[i / 3 * 3 + j / 3][num] = true;

leftNumber–;
board[i][j] = num+’0′;
dfs(board, leftNumber, isFilled);
if (isFilled)
return;//已经填充成功

//填充失败,还原情况
board[i][j] = ‘.’;
leftNumber++;
HashRow[i][num] = false;
HashCol[j][num] = false;
HashRec[i / 3 * 3 + j / 3][num] = false;
noNum = true;
}
}
if (noNum) return;
}
}
}
}
}

void solveSudoku(vector<vector<char>>& board) {
HashRow = vector<vector<bool>>(10, vector<bool>(10, false));
HashRec = vector<vector<bool>>(10, vector<bool>(10, false));
HashCol = vector<vector<bool>>(10, vector<bool>(10, false));
vector<int> CountRow(9, 0);
vector<int> CountRec(9, 0);
vector<int> CountCol(9, 0);
int leftNumber = 0;//仍需要填充的数字个数
for (int i = 0; i < board.size(); i++)
{
for (int j = 0; j < board[i].size(); j++)
{
if (board[i][j] != ‘.’)
{//记录已经出现过的数字
CountRow[i]++;
CountCol[j]++;
CountRec[i / 3 * 3 + j / 3]++;
HashRow[i][board[i][j]-‘0’] = true;
HashCol[j][board[i][j]-‘0’] = true;
HashRec[i / 3 * 3 + j / 3][board[i][j] – ‘0’] = true;
}
else
leftNumber++;
}
}
bool isFilled = false;
dfs(board, leftNumber, isFilled);
}
};
[/c]

GPU编程–CUDA环境配置与简单例程

相关文章:
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(一)
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(二)
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(三)

最近需要使用回溯法来完成算法设计的作业,在经历了单线程编程和多线程编程后,发觉效果并不是很理想,于是考虑采用GPU编程,计划通过GPU写一个简单的DFS算法。

本文的目的是快速上手使用GPU和进行简单的编程。首先推荐一本书,在这本书的帮助,我迅速学会了基本的GPU编程。

《CUDA并行程序设计:GPU编程指南》

推荐

一、环境配置

1.由于达到快速开发的目的,我直接采用windows平台进行开发,IDE采用的是VS2013.

2.在英伟达的官网上,有CUDA toolkit的下载地址:https://developer.nvidia.com/cuda-downloads

3.CUDA,Compute Unified Device Architecture,是一个运算平台。我们借助这个运算平台去使用GPU进行数据处理。

4.windows版本的安装比较简单,直接在官网下载:

CUDA

5.接着按照提示安装即可,前提是安装了VS2013或者VS2010\2012。

二、运行例程

安装了一个toolkit后,比较麻烦的事情是配置环境路径,配置项目属性,幸好CUDA提供了一系列的例程,让我们免去了配置的麻烦。CUDA开发者网址:http://docs.nvidia.com/cuda/cuda-samples/index.html

安装完毕后,我们找到CUDA的例程,我的CUDA是安装在C盘:C:\ProgramData\NVIDIA Corporation\,CUDA的例程在C:\ProgramData\NVIDIA Corporation\CUDA Samples\v7.5中,我们可以把例程复制出来,然后打开例程

打开后,我们重点关注cppintegration,因为这个例程的划分比较合理,分为main.cpp和cppintegration.cu文件,main.cpp主要是电脑端的代码,cppintegration.cu则主要是设备端的代码。

cppIntegration

 

在写代码的时候需要注意,如果我们修改了cppintegration.cu文件里面的代码,那么IDE就会调用CUDA的编译器来进行编译和链接,这个编译链接过程是比较慢的,而只修改main.cpp文件的话,IDE只会重新编译main.cpp,然后重新把.cu生成的文件连接到主程序上,速度会比较快。

[c language=”++”]

// run the device part of the program
bTestResult = runTest(argc, (const char **)argv, str, i2, len);

[/c]

main.cpp代码中,这个代码是调用设备端(即显卡)的函数,我们可以在cppintegration.cu中看到这段代码的定义。

主要的流程如下:

申请显存–>把数据从内存复制到显存–>在显存中进行数据处理–>把数据从显存中复制到内存–>结束

其中,在复制内存的时候也要特别注意,一定需要连续的内存空间!相关文章:

CUDA的内存复制:关于GPU的数据复制问题

CUDA提供了非常丰富的例程中,如有时间,可以选择适合自己的进行研究。而接下来的程序修改,我们都将基于cppintegration项目中的环境。

三、GPU编程特点

(1)基本语法

在GPU编程里面,我们会把函数分为三个类型:__device__、__host__和__global__,对于这三个类型标识符,在关于CUDA里面的global、host、device文章里已经作了解释。

假如我们编写了GPU函数(也称之为内核):

__global__ void runTest() {int a,int b}

那么,如何让GPU来跑这个函数呢?

在调用GPU函数(内核)时,我们需要按照下面的格式:

runTest<<<blocks,threads>>>(a,b)

其中blocks表示需要开启的线程块数量,threads表示每个线程块上开启的线程数。例如:

runTest<<<2,128>>>(a,b)

我们相当于开启了2*128条线程,即并行执行runTest函数256次。

(2)如何标识每一条线程(每一个函数)?

同时开启了256次runTest后,我们如何区分不同线程的runTest?这个时候我们需要使用到blockIdx.x,blockDim.x和threadIdx.x,假设我们运行runTest<<<2,128>>>(a,b)

blockIdx.x表示我们开启的线程块ID,对应上面的程序,blockIdx.x的范围为0到1;

blockDim.x表示每个线程块开启的线程数量,对应上面的程序,blockDim.x为128;

threadIdx.x则表示当前线程在其所在的线程块中的编号。

我们通过const unsigned int tid = (blockIdx.x*blockDim.x) + threadIdx.x;就可以得到线程的id号,而接下来的编程,我们会把线程的id作为数组的下标,从而实现并行计算。

四、编程实践

有了上述的初步理解后,我们就可以进行GPU编程,我们来做一个简单的实现,计算数组a和数组b下标相同的对应元素的积,并存放到数组c中,即c[i]=a[i]*b[i],然后再使用for循环使c[i]++ 10240次

可能你们会想到,这使用for循环不是很容易解决吗?好的,为了凸显显卡强大的并行计算能力,我们把数组a和数组b的长度定义为1024*1024(略超过100万)。下面我们进行编程实现:

简单的for循环实现:

[c language=”++”]
void multipleCPU(int *const a_gpu, int *const b_gpu, int *const c_gpu)
{
for (int i = 0; i <1024*1024; i++)
{
c_gpu[i] = a_gpu[i] * b_gpu[i];
for (int j = 0; j <10240; j++)
c_gpu[i]++;
}
}
[/c]

而GPU编程,我们把计算放在内核中,开启1024个线程块,每个线程块开启1024条线程,即总共开启1024*1024(100万)条线程进行数据处理,显卡处理100万条线程还是十分轻松的。

需要注意的是,目前大部分英伟达显卡,单个线程块支持的最大线程数是1024条,我们可以通过打印显卡的信息获得相关信息:

最大线程数

 

下面是GPU的内核实现和接口调用代码:

 

[c language=”++”]
/*
函数名 :gpuTestSiukwan
函数功能:计算a_gpu[i] * b_gpu[i],并把结果存放到c_gpu[i]中,
输入参数:
a_gpu :被填充的矩阵
b_gpu :用来填充的图形
c_gpu :被填充矩阵的第一个空格位置x
*/
__global__ void gpuTestSiukwan(int *const a_gpu, int *const b_gpu, int *const c_gpu)
{
// write data to global memory
const unsigned int tid = (blockIdx.x*blockDim.x) + threadIdx.x;
c_gpu[tid] = a_gpu[tid] * b_gpu[tid];
for (int i = 0; i <10240; i++)
c_gpu[tid]++;
}

//接口函数
extern "C" void runCUDA_AddOne(int blocks, int threads, int *const a_gpu, int *const b_gpu, int *const c_gpu)
{
gpuTestSiukwanAddOne<<<blocks, threads >>>(a_gpu, b_gpu, c_gpu);
}
[/c]

当然,还有基本的数据转移操作 ,从内存复制到显存,再从显存复制到内存:

[c language=”++”]
//定义gpu上的变量
int *a_gpu, *b_gpu, *c_gpu;

//在gpu上分配内存
cudaMalloc((void **)&amp;amp;a_gpu, sizeof(int) * 1024);
cudaMalloc((void **)&amp;amp;b_gpu, sizeof(int) * 1024);
cudaMalloc((void **)&amp;amp;c_gpu, sizeof(int) * 1024);

//把数据从内存复制到显存
cudaMemcpy(a_gpu, a_cpu, sizeof(int) * 1024, cudaMemcpyHostToDevice);
cudaMemcpy(b_gpu, b_cpu, sizeof(int) * 1024, cudaMemcpyHostToDevice);

//////////////////////////////////////////////////////
//CUDA运算
//////////////////////////////////////////////////////
runCUDA(blocks, threads,a_gpu, b_gpu, c_gpu);

//把在gpu中处理完毕的数据从显存复制到内存
cudaMemcpy(c_cpu, c_gpu, sizeof(int) * 1024, cudaMemcpyDeviceToHost);

//释放相关变量的显存
cudaFree(a_gpu);
cudaFree(b_gpu);
cudaFree(c_gpu);
[/c]

 

详细的代码,已经放到github上:https://github.com/siukwan/algorithm

大家可以上去下载下来,运行一下。

实验数据如下,可以看到单纯使用GPU运算,耗时45秒,而使用GPU运算,基本在1秒内:

实验数据

当然,GPU的每个线程并不能进行十分复杂的操作,例如我进行多次递归操作后,GPU驱动会崩溃,所以在设计算法的时候要注意这一点:

崩溃

五、GPU编程进阶

当初使用学习GPU编程,是为了完成算法设计的作业,相关文章如下:

相关文章:
智力拼图问题–关于回溯和并行:单线到多线程再到GPU编程的进阶(三)

这道题目还是十分有意思的,而实验结果也是十分惊人的,下面为在对这道题目进行实验室得出的部分实验数据对比举例:

gpu实验结果

gpu实验结果2

目前英伟达在官网上面提供了很多工具包,也就是说不需要亲自写这么底层的代码,对指针进行操作,对数据进行从内存到显存的复制和从显存到内存的复制。CUDA在深度学习、图像处理、大数据分析等等方面都有极为重要的应用,我们应该充分发挥英伟达显卡的性能。有兴趣的童鞋可以浏览CUDA的网址进一步了解:https://developer.nvidia.com/cuda-zone

cuda平台

智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(三)

相关文章:
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(一)
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(二)
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(三)
GPU编程–CUDA环境配置与简单例程

这一次,我们终于涉及到GPU了!通过GPU编程去求解智力拼图问题!

目前我在自身的windows8.1笔记本上面采用GPU编程,使用了英伟达CUDA7.5的toolkit,IDE采用的是VS2013.按照和示范教程可以参考:

 GPU编程–CUDA环境配置与简单例程

正式转入正文!

一、多线程的模型

通过上一篇文章,我们知道,多线程编程,就是把一开始的12个图形,分别使用一条线程去遍历,相当于本来的状态空间树是12层的,现在减少为11层。

而GPU编程中,涉及到上万条线程,都是很常见的,那我们应该如何把状态空间树划分为成千上万条线程呢?下面给出一个解决方案:

在多线程编程中,我们划分为12条线程,如下图:

多线程

那做一个简单的转换,我们多深入数层,岂不是就能够划分更多的线程?如下图:

深入到第二层:

第二层

深入到第三层:

第三层

存在的问题:到了这里,你可能意识到,存在一些情况,在第二层就已经不合适了,即没有必要进入第三层。这是这个方法的一个缺陷,但是基于GPU强大的并行计算能力,我们先忽略这个问题,讨论每个线程的工作任务。

二、进行实验

我们假设深入到第三层,开启11880条线程,这对GPU来说确实是很轻松的事情。那么我们接下来讨论每条线程该做些什么样的工作。深入到第三层,树的深度是12,意味着我们还需要在每条线程里面进行深度搜索,最大的递归深度为12-3=9层。我们每条线程需要DFS9层。

好的,我们来实际操作。但是实际上,你们会发现,GPU能够进行大量的并行计算,是以牺牲计算性能来实现的,也就是说,GPU的每条线程,其运算能力是很弱的,分配到每条线程的内存空间也是十分有限的。我们在实验过程中,递归到第三层,就会出现显卡驱动崩溃的情况!如下图:

崩溃

 

好吧,你们可能觉得我的显卡不够好,我的显卡型号是GeForce 840M,确实不是一个好显卡,那么我们来找一个好一点的显卡进行实验–GTX 960M!实验结果如下图:

高级崩溃

很遗憾,依然崩溃了。我们充分体会到每个GPU线程计算能力的脆弱性。

三、再对多线程模型进行探讨

通过上面的实验室我们知道GPU线程无法递归那么深的深度(超过2深度就崩溃),无论你开启了一条GPU线程还是1万条GPU线程,只要递归深度超过了2,就崩溃了。

那既然GPU运算能力有限,我们能不能改变我们的多线程计算模型(并行计算模型)?经过两天的实验和探索,我们确实是找到了!

对于上面的遍历,我们采用的是DFS(深度优先搜索),深度优先搜索通过递归不断遍历,必然会导致栈的开销较大。那么我们是否可以采用BFS(广度优先搜索)?采用广度优先搜索,我们就可以避免了调用函数而导致开启的栈的开销

还记得在第二点中提到的存在问题不?在每一层,都会有一些废弃的节点,假如我们按照bfs进行搜索,相当于对树进行层序遍历,这样就可以避免了废气的节点还会深入到下一层的情况!

既然有一定的合理性,那我们来重新编写程序来解决智力拼图!

四、使用BFS的并行计算模型

我们可以简单的理解为树的层序遍历。我们使用gpu对每一层的节点分配一个线程,而gpu运算只计算它下一层的可行解。

当该层的线程全部执行完毕,我们就有了下一层的可行解,也就是下一层的节点,这时,我们再次使用gpu对下一层的节点进行分配线程和计算。如此类推,理论上,我们使用调用gpu12次,那么就可以把这个12层的树给遍历完。

而且这种算法,在每一层遍历的时候,就已经去掉了一些不可能的情况,使得下一层的节点大大减少!如下图:
11111

222222334

按照这个思想,我们编写了程序,代码在github上面:https://github.com/siukwan/algorithm

BFS流程图

 

各层次线程开启数量:

线程数量

我们可以看到红框框住的几个数据,发现其需要处理的情况数量超过了200万,而实际测试知道我的显卡GT 840M最多只能开200万条线程,所以在这里我才用了循环拆分,为了减轻负担,我并没有同时开启200万条线程,设置了最多开启100万条线程,所以超过100万种情况的,我拆分为多次处理,每次最多处理100万种情况。

五、实验结果

使用了GPU后,其强大的线程并行能力着实让我们很吃惊,同时开启100万条线程毫无压力(1024个blocks,每个blocks开启1024条线程,共1024*1024)

gpu实验结果

gpu实验结果2

 

 

智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(二)

相关文章:
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(一)
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(二)
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(三)

这次介绍直立拼图问题的详细算法实现。

一、图形的数据结构

我采用了stl里面的vector来进行图形存储,并利用了C++11标准里面的大括号初始化来简化实现,如下:

图形数据结构

 

每个图形都是一个二维数组vector<vector<char>> ,因为bool也是占用一个字节,char也是占用一个字节,为了统一矩阵和图形的打印、旋转等函数,统一使用vector<vector<char>>来存储。由于采用vector,所以我们轻易的得到每个图形的长宽。

二、矩阵的数据结构

如上面描述,采用vector<vector<char>>进行存储,实验时,实际填充效果如下:
133399
133996
111796
c77766
ccc786
a5c888
a5bb84
a55bb4
a252b4
a22244

使用char字符来存储,初始化矩阵的每个参数都是0,注意是0不是’0’,那么我们检测某个坐标是否被填充时,直接判断该位是0还是非零即可,非零表示已经被填充。

三、深度搜索算法的框架:

算法伪代码

 

四、图形如何填充到矩阵mat中?

我们采用的是顺序填充算法,即首先搜索出矩阵第一个未被填充的空格,如下图:

111

我们知道矩阵的第一个未填充坐标,那么如何把图形填充上去?所以,我们还需要求出图形的第一个有数据的坐标,即第一个为1的位置,如下图红圈的标注:

firstfill

 

如下面的箭头指示,灰色表示空白部分

123123123

 

那么,我们有了矩阵的第一个未填充的位置,和图形的第一个有数据的的格子,只要把这个格子对着矩阵的未填充的位置填上去即可,如下图:

填充

填充后:
第一个填充

 

红色部分为冲突部分,证明填充不成功

 

五、单线程实现

单线程实现较为简单,相当于写一个深度搜索的递归程序。状态空间树如下,实际上这是不够准确的,因为每个节点有多种变形,下面为了简化只画了4层。上面的深度搜索代码框架就是根据单线程画的。

单线程

下面为更加准确的一个版本,每个节点包括了8种状态,但是后面为了简化表现,在画图的时候省去了这8种状态。

单线程旋转翻转

六、多线程实现

多线程实现,首先是要看电脑的CPU支持多少线程,我们的实验机器cpu是i7-4790K,是四核心八线程的,那么理论上最好是开7条线程,其中一条主线程用来检测7线线程的完成情况,一旦有线程工作完成并结束,马上开启另外一条。对于这道题目,我们可以把状态空间树的第一层(包含123456789abc节点的),分别划分为12条线程。如下图:

多线程

七、关于多线程的调度与优化问题

在六中,我们划分为12条线程,选取其中7条,先执行。但是实际上,这相当于人工调度线程,我们需要考虑挑选哪7条线程首先执行?按照顺序进行还是特意筛选

按照顺序,先执行0到6号线程,那么可能在7到11号线程中,有些线程执行时间相当久,假设0到6号的线程执行时间为1个单位时间,7号线程执行时间为2个单位时间,其余的为1个单位时间,那么顺序执行线程,理论上最终需要3个单位时间。而假如我们先执行线程7,那么最终只需要2个单位时间

特意筛选,这种方法有个明显的缺陷,就是假设初始化12个图形改变后,我们需要再次进行人工筛选。

那么最好的方法是什么?我们知道实验的电脑支持8线程,而实际上,电脑除了单纯执行我们的程序,还会执行其他的程序。线程数是超过8的,那电脑仍然能够运行起来,是因为操作系统本身就具有线程调度的功能。而实际上,我们只划分了12条线程,而不是12*11=132条线程,同时开启12条线程,CPU的上下文切换并没有占用太多的时间,对我们来说是可以接受的。

实际实验发现,状态少的线程会明显优先结束,CPU似乎是使用了时间片轮转的方法来执行线程。

八、实验数据对比

遍历总数和答案个数一致,证明两个算法是正确的,其中主要关注运行时间即可,减少超过一半的运行时间。

实验数据对比

 

九、源代码

由于编写的源代码兼容了windows和linux系统,并且兼容了单线程和多线程,代码较长,放在了github上面,网址为:https://github.com/siukwan/algorithm

十、更多的思考

经过上面的讨论,我们发现,这道题目的解法只有回溯(和暴力搜索没什么区别),但是通过改为多线程,我们看到了效率明显提升。既然要涉及到多线程编程,除了找核心更多的CPU还有别的方法吗?没错,多线程编程,实则和并行计算非常接近,既然涉及到并行计算,怎么可以忽略Nvdia的显卡!恰好我的电脑配置了4GB显存的GeForce 840M显卡,性能虽然中等,但是可以尝试!

在下一篇文章中我将会介绍如何把上面的问题通过GPU编程解决:
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(三)

 

智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(一)

相关文章:
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(一)
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(二)
智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(三)

最近算法设计上面有一个回溯的题目,要求使用回溯算法解答,题目如下:

智力拼图问题

设有12个平面图形(1,2,3,4,5,6,7,8,9,a,b,c)如下图所示。每个图形的形状互不相同,但它们都是由5个大小相同的正方形组成。如下图中这12个图形拼接成一个6*10的矩阵。设计一个算法,计算出用这12个图形拼接成给定矩形的拼接方案。

智力拼图

 

题目限制并没有很明确,但是通过网上搜索,可以知道有如下要求:

(1)12个图形就是上图所展示的图形;

(2)这些图形可以进行旋转(每次旋转90°)或者翻转

(3)每个图形使用1次

(4)矩阵的形状不一定为6*10,但是面积一定为60,即可以出现3*20,4*15,5*12等等的情况。

一、分析:

先对题目进行简单的分析,由浅到深:

(1)题目需要求出拼接方案,我们默认求出所有的拼接方案,那么没有办法使用动态规划或者贪心算法进行求解,只能够使用深度搜索,进行适当的剪枝。

(2)我们先考虑没有旋转和翻转的情况,那么这12个图形的排列组合情况一共有12!这么多,即479001600种。我们假设每秒遍历1000种组合情况,那么求解需要最多47900.16秒,换算一下,大概约13个小时。假如每秒遍历10万种情况,那么只需要7.98336分钟,约8分钟。实际上深度搜索的过程中,可以进行剪枝,所以运行时间远小于这个值。下面是6*10的矩阵,在图形没有旋转和翻转的情况下,所花费的时间:

只有1秒,因为没有旋转,很容易就剪枝了。

不旋转不反转

(3)经历了上面的推算,我们尝试加入旋转和翻转,假设每个图像都可以进行旋转和翻转,那么每个图形都可以演化为8种情况,旋转0°+不反转,旋转0°+反转,旋转90°+不反转,旋转90°+反转。。。。。那么总的排列组合情况有12!*8^12,12!(479001600)在2^28(268435456)和2^29(536870912)之间,而8^12相当于2^36,把12!当作2^28,那么总的排列组合数量为2^28*2^36=2^64。

2^64是不是一个很熟悉的数字?没错,就是汉诺塔传说中的64个金片,每秒移动一个金片总共需要5845亿年,好吧,我们的计算机处理速度会更快,那么每秒遍历1000万种情况,那么仍需5.845万年

当然,实际上会剪枝的,这个只是给大家一个概念。

二、如何剪枝:

(1)从排列组合来看,12!*8^12种,实际上,有些图形旋转或者翻转后是重复的。例如题目图中的8号图形,无论怎么旋转或者翻转,都是一样的,那么遍历的时候,不用再去遍历它旋转或者翻转的情况。同样,1号和a号图形等等,在旋转或者翻转的时候都存在重复的情况,我们可以把这些重复的情况去掉,这样8^12就可降下来了。8图形

(2)第二个剪枝的情形属于正常的遍历排除,我们一个一个图案填充上去,即时检测是否能够填充,能够填充的就继续进行DFS,不能填充的就遍历下一个图形。

(3)会有帮助但是效果不是很明显的剪枝,在实际的填充过程中,我们发现,矩阵填充出出现一些孤立的连通区域,面积为1、2、3等等。实际上,我们每个图形的面积为5,那么假如矩阵出现面积小于5的连通区域,那这个方案也不用再继续DFS了。实际上,要检测连通区域,就会涉及到并查集,而并查集通过递归(也可以改成循环)进行集合的检测和划分,但是对于每种遍历的情况都使用并查集去划分搜索连通区域,会大大地增加时间复杂度。

简化的版本就是,搜索出矩阵的第一个未被填充的空格坐标,检测这个坐标的上下左右(实际上只检测下右即可)是否已经被填充,如果被填充就return,没有被填充就继续DFS。这个版本实际上是检测单个的面积为1的连通区域,对剪枝是有帮助的(实际上帮助比较少),而且只是多加两个判断,对时间复杂度影响不大。单一的空格

(4)除了上述的剪枝,我们还尝试另外一种填充方法。我们默认的填充方法,称之为顺序填充,是从左到右从上到下依次填充,这样做的缺点就是下方空余的连通区域较大,不利用使用(3)的方法进行剪枝。如何更好地利用(3)方法进行剪枝呢?我们提出了一种回旋填充的方法,一次填充4个角,这样可以从外围往中间逼近,从而更容易产生比顺序填充更小的连通区域。这些更小的连通区域就更有可能适合方法(3)的剪枝。

顺序与回旋

但是,通过实践证明,回旋填充会导致更加糟糕的时间复杂度。我们进行了对比实验,对6*10的矩阵进行填充。我们填充6*10的矩阵,使用单线程的DFS,总共需要耗时38分钟,答案个数为9356个,总共遍历了4358万种情况(省去了万位以下的数字)。而使用回旋填充的方法,我们填充6*10的矩阵,遍历71分钟后(程序并未结束),只搜索到了99个答案,遍历了5800万种情况。那么保守估计,等到遍历出9356个答案,大约需要再花费71*10分钟(假设每71分钟遍历出99个答案),遍历的情况总数将会远超4358万种。

仔细分析,每个图形只占用了5个面积,而矩阵的面积为60!进行回旋填充,我们期望出现面积为1的连通域,需要填充多次。而相比之下,回旋填充相当于选择了更为广阔的空间进行图形的填充,而顺序填充由于宽度的限制(例如宽度为3,4,5,6),很容易就对图形判断是否合适。而回旋填充每次选择了空间余量较大的角落进行填充,因此会遍历更多的次数!

 

综上所述,我们只能够利用方法(1)(2)(3)进行剪枝深度搜索,也就是使用回溯法。具体实现:

智力拼图问题–关于回溯和并行:单线程到多线程再到GPU编程的进阶(二)

CMakeList.txt中包含父目录文件

1.最近在apue编程中,需要规范项目,所以把错误函数统一放到根目录下,整个文件的假如如下图所示:

error

其中func_err.cpp和func.h是在根目录中,而CMakeLists.txt是在根目录的1stUNIXSystemOverview文件夹中,那么需要连接编译func.h和func_err.cpp时怎么办呢?

包含func.h很简单,直接在代码中include”../func.h”即可。

而连接func_err.cpp在cmake语法中也是一样的,如下:

SET(LIB_FUNC_ERR_SRC  ../func_err.cpp)