分类目录归档:算法

初次使用MATLAB写遗传算法(2011B)

虽然之前已经接触过遗传算法了,但这还是第一次 真刀真枪的在MATLAB上面写,记录一下,以供参考

总的来说遗传算法是一个基于随机过程的算法,其目标是为了max或min一个目标函数的值,求解的过程不是严格的推导,而是类似于随机产生个体,筛选最优解,不断的迭代,以此达到求解最优值。下面对其思想和理论做一些我个人的理解

遗传算法是为了求解目标函数的最优值(max=f(x1,x2,…xn)),而目标函数的最优值通过参数x1 x2…xn控制,那么我们把x1 x2 … xn等每个参数都表示为一个基因,如此一来,我们就有了n个基因构成一组解,我们把这n个基因构成的一组解称为染色体,而许多染色体组成了种群,下面梳理一下:

  • 基因——参数
  • 染色体——解
  • 种群——一系列解

所以,我们的目标就是,找到一个最优的染色体,那么如何找到这个最优的染色体呢?下面就讲解一下流程

1.首先,我们初始化一组解,这最开始的一组解可以随机的生成

2.然后我们计算这一组解的适应度,适应度其实就是我们要max或min的函数转换成的,一般来说,一个染色体的适应度越高,那么他存活的几率最高,所以,如果我们的目标是max f(x1 … xn) 那么可以直接用目标函数求出的解作为适应度,如果目标是min的话,用其倒数。

3.筛选,找到适应度后,从这一代的种群里面筛选,采用随机的方法,把适应度不太好的解都随机地筛掉,选取出适应度比较好的解

4.杂交,随机的选择染色体进行杂交,杂交是如何来理解的呢,可以类似的理解为,参数的随机组合,比如说现在有一个染色体x1 x2 x3… xn=a1 a2 a3 … an 另外有一个染色体是b1 b2 b3 … bn 那么这两条染色体进行杂交,产生的结果可能是两个染色体的随机组合,a1 a2 b3 b4 ……,会有什么作用呢?这就要想一下遗传算法的总过程了,遗传算法会筛选出优秀的解,那么这些优秀的解里面的参数可能就是一些比较优秀的参数,这样让他们直接互换,互相杂交,就可能会把两个解的优秀基因融合在一起,这样就生成了一个更优的个体,这个更优的染色体就更可能被遗传下去,如此反复迭代,最终生成一个接近于最优的染色体。

5.变异,变异是随机的使解里面的基因产生变化,这样就可以产生新的基因以供筛选

6.如果迭代到一定代数,或达到一个比较稳定的值(连续n带没有发生变化),那么可以结束遗传算法,并从最后一代里面选取最优染色体作为整个算法最终的解。如果没有达到这个值,那么就把筛选、杂交、变异产生的新的一组解作为新的一代,然后返回第2步,对着新的一代重新进行筛选、杂交、变异,如此反复;

这就是遗传算法的大致过程了,上面也掺杂了很多个人的理解,下面解释一下贯穿整个算法的问题——如何用基因表示参数,用染色体表示解,答案就是编码

编码——把一组解编制为一串特色的编码,方式有很多。较为常用的是二进制编码、实数编码、符号编码,以二进制编码为例,我们知道一组解是由参数组成的,那么参数应该有对应的取值范围和取值粒度,我们把这些参数离散化,比如说x1取值范围是0-1,精确到小数点后3位,那么离散化之后x1的取值就是0、0.001、0.002、0.003、……0.999、1.0,一共有1001个可能的取值,那么我们用一个10位的二进制串表示这1001个可能的取值,00000000对应0 00000001对应0.001、0000000010对应0.002、……

解码——编码的逆过程

杂交和变异都在这些被编码过后的串上面进行,生成新一代的种群后,在进行下一次评估的时候,把种群里面的基因解码为参数,然后计算适应度

筛选也有多种筛选方法,通常来说,各个方法基本都是筛选出适应度较大的解,可以通过随机选择个体比较、直接排序筛选、轮盘筛选等方法

杂交也有很多不同的方法,不过大同小异,基本上都是选择染色体上面的区间,两个染色体交换区间里面的内容

变异也有很大不同的方法,某个基因的某个位置变异、局部顺序颠倒等,变异的目的是为了基因的多样性,所以选择如何变异影响不大,主要是变异的概率,如果变异概率太高,那么优良基因很可能会遭到破坏,如果变异概率过低,种群多样性下降太快,要选取一个合适的值,一般可以取0.001·0.2

下面以2011B实操一下:

待补充……

回溯法与八皇后问题

        昨天晚上写了一个OJ上的八皇后问题,然后居然TLE了。。。。(本人弱鸡)。。。然后就躺在床上想怎么优化,想着想着就睡着了,做了一个令人感到忧伤的梦,然后醒了,然后就想到了怎么优化了。。。。当然我很菜。。。用的还是简单回溯的算法,其实就是DFS,皇后多了还是很耗时的,所以,各位皇帝麻烦您们专一一点好吧。

        好吧,先说回溯法吧,回溯法和DFS差不多,其思想就是:“我就是要一条路走到黑,走到黑了然后感觉不对,就赶紧返回上一层,看看有没有其他的路,如果没有走到黑,反而走到白了,那就恭喜我,我成功搞出一条通天大道,那么,我。。我。。我我我还是要返回上一层。。。。看看有没有其他的路。。。“讲道理一条路就中了,但可恶的出题人太贪心,总喜欢让我们把所有的路都找到,这不是坑爹呢吗。。。。

大致的框架可以写为:

void func(int step){

    1判断路是不是死路,如果是就返回;

    2如果判断是不是已经走出一条通天大道了,如果已经是了,那就在这里搞一些操纵,然后。。。还是要返回上一层。。。

   for chioce in allOption{

       标记一下这个chioce被选过了(某些情况下需要)

        func(step+1);//走下一步        

     }

}

注意,一定不要把1嵌入2里面,如果这样做的话,会DFS出所有选择的全排列,然后再判断是否可行,这样就没有剪枝的效果了。

 

 

好吧我知道本人讲的很渣渣,很坑爹,应该没有人能看懂。。。。嗯。。。如果能画图就好了。。。。但是我很懒。。不想画完图用手机拍照然后再传到电脑上然后在传到博客上。。。。。。。


题目:

检查一个如下的6 x 6的跳棋棋盘,有六个棋子被放置在棋盘上,使得每行、每列有且只有一个,每条对角线(包括两条主对角线的所有平行线)上至多有一个棋子。

上面的布局可以用序列2 4 6 1 3 5来描述,第i个数字表示在第i行的相应位置有一个棋子,如下:

行号 1 2 3 4 5 6

列号 2 4 6 1 3 5

这只是跳棋放置的一个解。请编一个程序找出所有跳棋放置的解。并把它们以上面的序列方法输出。解按字典顺序排列。请输出前3个解。最后一行是解的总个数。

解法(代码里面的注释应该很清楚):

#include <iostream>
using namespace std;
void queen(int step);
int n;//有多少个皇后
int count;//记录成功的方案
int book[14];//标记
int a[14];//a[1]表示第一行的皇后在第几列,a[2]表示第二行的。。。。。
int main(){
    cin>>n;
    queen(n);
    cout<<count;
    return 0;
}
void queen(int step){
    int LastLine=n-step;//上一步摆下来的皇后,因为摆放皇后时候已经用了book数组进行标记约束,所以不需要判断行列
    for(int i=1;i<LastLine;i++){//判断是否在同同一斜线上
        if(a[LastLine]-a[i]==LastLine-i||a[LastLine]-a[i]==i-LastLine){
            return;
        }
    }
    /*for(int i=1;i<=n-step;i++){//这里是原来的判断是否在同一条斜线上的算法,但是算法复杂度很高,分析原因发现
        for(int j=i+1;j<=n-step;j++){//这个算法是,对于每一行,都有对比其他的行,重复了很多
            if(a[j]-a[i]==j-i||a[j]-a[i]==i-j){//仔细思考发现,只需要判断新加入的皇后符不符合要求就行,所以
                return;//换成了上面的算法,复杂度是o(N)
            }
        }
    }*/
    if(step==0){//拜访了最后一个皇后,已经得出了一种解法
        count++;
        if(count<=3){
            for(int i=1;i<=n;i++){
                cout<<a[i]<<' ';
            }
            cout<<endl;
        }
    }else{
        //进行本次放置,然后递归下一次
        for(int i=1;i<=n;i++){//每次摆放这一行的皇后的时候,都有N列可以摆,意思就是有N个岔路口。
                              //我们选一个深入,等这个返回之后,再选下一个进行深入,所以是一个for循环
            if(!book[i]){//如果第i列还没有黄后占场子
                book[i]=1;//标记一下我要占上
                a[n+1-step]=i;//摆放皇后
                queen(step-1);//深入下一个路口
                book[i]=0;//返回了,就要开始走下一个差路口(下一列),所以这一列的标记取消掉
            }
        }
    }
}

 

最长上升子序列(LIS)问题

(iBus的输入法真的难用啊。。。用起来的感觉就像被电脑和键盘cj/(ㄒoㄒ)/~~,写到一半忍不住去换了fcitx+搜狗,然而可能是我的使用姿势不对,总出BUG,不过现在终于对上号了owo~)

 

这个问题让我想了N久N久N久,深深地感到智商欠费怀疑人生,我真是应该自挂东南枝。。


      问题:给出一个序列 例如(2 1 5 3 6 4 8 9 7),求该序列的最长上升子序列长度(1 3 4 8 9,长度为5)。

最长上升子序列问题,常用解法有两种,都用到了动态规划的思想,一种是直接用动态规划,比较好想,但是算法复杂度为O(n^2),另一种算法进行了优化,把时间复杂度降低到了O(nlogn),此两种方法本文都进行讲解,先看第一种

  • 通称O(n^2)算法

         设a为存储序列的数组,设f[i]为以i结尾最长子序列的长度,那么如何递推出f[i]的值呢?我们设一个变量j来表示,1<=j<=i-1,对于所有的a[j],只有当a[j]<a[i]的时候,我们才需要判断是否需要把a[i]添加到f[j]中,当然我们需要注意,f[i]的以i结尾的最长子序列的长度,所以我们要在所有可以添加a[i]的子序列中,找到最长的那个子序列,然后我们把a[i]添加进子序列中,另f[i]=f[j]+1即可。

         我在某篇博客上看到了f[i]=max(f[j](0<=j<i))+(a[i]>a[j]?1:0) 这样的公式,仔细研究这个公式其实是有BUG的,因为他找到的子序列max(dp[j])不一定能满足a[i]可以被添加进去,这点要注意仔细思考,不然很容易就出错。

         正确的推理公式应该是:f[i]=max( f[j] | (1<=j<=i-1,a[i]>a[j]) ) + 1

         我觉得这个算法是达不到O(n^2)的复杂度的。

代码:

int lis1(int a[],int n)
{
    int *f=new int[n+1];
    for(int i=0;i<=n;i++){f[i]=0;}//初始化
    int Max;
    for (int i = 1; i <= n; ++i){
        Max = 0;
        for (int j = 1; j <= i-1; ++j){
            if (a[i] > a[j]){
                Max = max(Max, f[j]);//找到可以把a[i]作为结尾的最长子序列
            }
        }
        f[i] = Max + 1;//把a[i]加入到这个子序列中
    }
    Max = 0;
    for (int i = 1; i <= n; ++i){//找到最长上升子序列
        if (f[i] > Max)    Max = f[i];
    }
    return Max;
}
  • O(nlogn算法)

         分析上面的算法发现,每次都要从i-1个数里面遍历,再找到最长的子序列,这就提高了计算时间,那么如何优化呢?是否有一种办法把这个寻找的过程简化?

        我们使用b[i]来记录长度为i的上升子序列的末尾元素的最低值 ,这是什么意思?长度为i的子序列可能有很多个,而我们为了寻找整个序列的最长上升序列,只需要关注长度为i的序列末尾元素的最低值,因为末尾元素的值越低,我们越可能得到更长的上升子序列,这是一种贪心算法的思想,显然b数组的递增的。用反证法简单地证明一下,假若存在i<j,b[i]=a,b[j]=b,且a>b,那么长度为j的上升序列里面一定包含一个长度为i的子上升序列,且b[i]<b[j],与假设矛盾。

         我们假设b最开始是没有长度的,通过依次读取每一个a[i]来更新b的长度以及b数组中存储的值。

         当我们读取到的a[i] 大于当前最长子序列的末尾元素最低值,那么a[i]可以被加入到这个子序列中,我们把当前最长子序列长度+1,并且更新b数组的值。

         如果条件不成立,那么我们无法添加a[i]到当前最长的子序列中,这时候我们用a[i]更新长度不是最长的子序列末尾元素的最低值,如何更新呢,我们在b数组中从前向后找到 第一个大于a[i]的数,然后用a[i]去更新这个值,这是什么诡异操作?假如b[1-5]=1 3 4 6 9,a[i]=5,那么a[i]可以加入的子序列就只有b[1-3]=1 3 4,不过我们既然要求最长子序列,那就让a[i]并入长度为3的子序列,并入后长度为4,这时我们要更新b[4]的值,忘记b存储的是什么了嘛?是长度为i的上升子序列的末尾元素的最低值,a[i]是小于原来的b[4]的,所以把a[i]并入长度为3的子序列之后及时用a[i]更新b[4]的值。

         如果对这里的操作还不是很明白的话,可以自己模拟一遍,在纸上写一写画一画~也可以点击这个链接:https://www.felix021.com/blog/read.php?1587 ,看一遍他的模拟过程。

下面放代码:

int lis2(int a[],int n){
    int ans=0;//假设b最开始的长度为0
    int *b=new int[n+1];
    for(int i=0;i<=n;i++){b[i]=0;}//初始化b的值
    for(int i=1;i<=n;i++){//依次读取a[i]的值并更新数组b
        if(b[ans]<a[i]){//如果可以被添加到长度为ans的子序列里面
            b[++ans]=a[i];//添加并且ans++;
            continue;
        }else{
            b[insert_x(b,ans,a[i])]=a[i];//否则就使用a[i]更新b数组的值
        }
    }
    delete []b;
    return ans;//最后数组的长度ans就是答案。
}
int insert_x(int b[],int n,int x){//寻找插入点,插入第一个大于x的位置,b是数组,查找范围是[0,n]
    int mid=0;
    int front=0;
    int rear=n;
    while(front!=rear-1){//使用二分查找
        mid=(front+rear)/2;
        if(x>b[mid]){
            front=mid;
        }else{
            rear=mid;
        }
    }
    return front+1;
}

注意这个算法为什么比第一个算法快,因为b是递增的数列,所以查找插入点的时候可以使用二分查找,二分查找的速度远远快于遍历整个数组,所以,这就体现了数据结构对算法的重要程度。

下面贴一个完整可以运行的代码:

#include <iostream>
#include <cstdlib>
#include <algorithm>
using namespace std;
int lis1(int a[],int n);
int lis2(int a[],int n);
int insert_x(int b[],int n,int x);//在前N个数据里面,返回应该插入的位置
int main(){
    int n=0;
    cin>>n;
    int *a=new int[n+1];
    for(int i=1;i<=n;i++){
        cin>>a[i];
    }
    cout<<lis1(a,n)<<endl;
    cout<<lis2(a,n)<<endl;
    delete []a;
    return 0;
}
int lis1(int a[],int n)
{
    int *f=new int[n+1];
    for(int i=0;i<=n;i++){f[i]=0;}//初始化
    int Max;
    for (int i = 1; i <= n; ++i){
        Max = 0;
        for (int j = 1; j <= i-1; ++j){
            if (a[i] > a[j]){
                Max = max(Max, f[j]);//找到可以把a[i]作为结尾的最长子序列
            }
        }
        f[i] = Max + 1;//把a[i]加入到这个子序列中
    }
    Max = 0;
    for (int i = 1; i <= n; ++i){//找到最长上升子序列
        if (f[i] > Max)    Max = f[i];
    }
    return Max;
}
int lis2(int a[],int n){
    int ans=0;
    int *b=new int[n+1];
    for(int i=0;i<=n;i++){b[i]=0;}
    for(int i=1;i<=n;i++){
        if(b[ans]<a[i]){
            b[++ans]=a[i];
            continue;
        }else{
            b[insert_x(b,ans,a[i])]=a[i];
        }
    }
    delete []b;
    return ans;
}
int insert_x(int b[],int n,int x){
    int mid=0;
    int front=0;
    int rear=n;
    while(front!=rear-1){
        mid=(front+rear)/2;
        if(x>b[mid]){
            front=mid;
        }else{
            rear=mid;
        }
    }
    return front+1;
}

输入:第一行输入一个数字N,后面输入N个数字,使用tab、空格、回车进行间隔

输出:使用两个算法进行计算的最长上升子序列长度

样例:

    输入:

         9
         2 1 5 3 6 4 8 9 7

    输出:

         5

         5

 

同余定理与快速幂取模详解

      快速幂取模,是指求(a^n)%m , a和n都非常的大,如果直接计算a^n可能直接溢出,在这种情况下取模,需要用到同余定理的一些推论。

关于同余定理,可以看这里,注意幂运算部分,“幂运算:如果a ≡ b (mod m),那么a^n ≡ b^n (mod m)“ ,若a、b取m同余,则a^n、b^n关于m同余,注意a与a%m是同余的,所以把b替换为a%m,得a^n ≡ (a%m)^n (mod m),即(a^n)%m=((a%m)^n)%m快速幂取模就是运用了这个公式,把a^n拆开运算。

  • 先来看直接运用这个公式。

         直接运用就是直接把aaaa……拆开,拆成(a%m)(a%m)(a%m)(a%m)……然后再取模,上代码

long long solve(int a, int n, int m) {求a^n的模
    long long ans = 1;
    a = a%m;//先取一次模把数变小,a%m%m=a%m
    for (int i = 1; i <= n; i++) {
        ans = ans*(a%m);
    }
    return ans%m;
}

注意到这里展开就变成了(a%m)(a%m)(a%m)*(a%m),值得警惕的是,当m和n比较大,还是有很大的可能会溢出,因为要连乘法n次,于是有了第二种写法。

long long solve(int a, int n, int m) {求a^n的模
    long long ans = 1;
    a = a%m;//先取一次模把数变小,a%m%m=a%m
    for (int i = 1; i <= n; i++) {
        ans = (ans*a)%m;
    }
    return ans%m;
}

只改动了代码的第5行,改动后展开变成了((((a%m)a)%ma)%ma)%m……稍微推一下就可以推出这个式子和上面的(a%m)(a%m)(a%m)(a%m)……是同余的,那这样写有什么好处呢,好处就是降低了溢出的可能性,每次*a后都要%m,相当于每次都把结果控制在了0~m-1的范围内,大大减小了溢出的可能性。所以这种写法是比第一种要更安全的。

但是这两种方法都有一个问题,那就是,时间复杂度为O(N),每次都要乘啊,这么写代码,难道计算机不会痛嘛!于是就有了第二种算法,快速幂取模。

 

  • 快速幂取模

         快速幂取模,是一种二分的思想,怎么说呢,举个栗子吧,6^16==36^8==(66)^8,怎么运用这个来快速取模呢,(6^16)%m=((66)^8)%m={[(66)^4][(6*6)^4]}%m,看起来好不方便,那我们直接上代码吧

ps:如果是6^17次方呢?怎么二分,答案是,我们先提出来一个6变成(66^16)%m==((6%m)(6^16%m))%m,这样就可以正常运算了。

long long solve(long long a, long long n, long long m)
{
    long long ans = 1;
    a = a % mode;
    while (n > 0) {
        if (n % 2 == 1) //判断是否是奇数,也可以使用&符合判断是否为奇数
            ans = (ans * a) % m; 
        a = (a * a) % m;// 不断的两两合并再取模
                n /= 2;//这里可以使用位运算 n>>=1 来除以2,使用位操作运算速度更快。
    }
    return ans%m;

让我们随便带个数进去看看~假若a是6,n是16,即求(6^16)%,在这个算法中会怎么走呢?

n=16;

a=6%m;

进入循环:

    a=( (6%m) * (6%m) )%m;// 即(6*6)%m  运用了公式(a*b)≡(a%m)*(b%m)(mod m),这个公式是因为a与a%m同余,b与b%m同余,所以a*b与a%m*b%m同余;

    n=8;

    a=(  (6*6)%m * (6*6)%m  )%m//即(6*6*6*6)%m

    n=4;

    a=(  (6*6*6*6)%m * (6*6*6*6)%m  )%m//即(6^8)%m;

    n=2;

    a=(6^16)%m;

    n=1;

    判断n是奇数,所以

    ans=(6^16)%m;

    后面对a的赋值已经没有比较管了,因为n/2=0会直接跳出循环,得到ans即为答案。

 

如果n是奇数会如何?假若a=6,n=17

n=17;

a=6%m;

进入循环:

    判断n(17)是奇数

    ans=6%m;

    a=( (6%m) * (6%m) )%m;// 即(6*6)%m  运用了公式(a*b)≡(a%m)*(b%m)(mod m);

    n=8;

    a=(  (6*6)%m * (6*6)%m  )%m//即(6*6*6*6)%m

    n=4;

    ……
可以看到,奇数的时候,程序先取出了一个6,然后剩下6^16正常处理
最后n=1时,ans = (ans * a) % m;会把第一个取出来的值也乘上去,
得到ans=(  (6%m) * ((6^16)%m)  )%m = (6^17)%m;即为所求

 

例题:

POJ1995

这题不仅用到了同余定理在乘法方面的推论,还用到了在加法方面的推论,堪称模板题。题解自行百度。