您的位置:首页 > 编程语言 > C语言/C++

C++从零开始(八)——C++样例一

2004-09-19 17:38 274 查看
C++从零开始(八)
——C++样例一
    前篇说明了函数的部分实现方式,但并没有说明函数这个语法的语义,即函数有什么用及为什么被使用。对于此,本篇及后续会零散提到一些,在《C++从零开始(十二)》中再较详细地说明。本文只是就程序员的基本要求——拿得出算法,给得出代码——给出一些样例,以说明如何从算法编写出C++代码,并说明多个基础且重要的编程概念(即独立于编程语言而存在的概念)。

由算法得出代码

    本系列一开头就说明了何谓程序,并说明由于CPU的世界和人们存在的客观物理世界的不兼容而导致根本不能将人编写的程序(也就是算法)翻译成CPU指令,但为了能够翻译,就必须让人觉得CPU世界中的某些东西是人以为的算法所描述的某些东西。如电脑屏幕上显示的图片,通过显示器对不同象素显示不同颜色而让人以为那是一幅图片,而电脑只知道那是一系列数字,每个数字代表了一个象素的颜色值而已。
    为了实现上面的“让人觉得是”,得到算法后要做的的第一步就是找出算法中要操作的资源。前面已经说过,任何程序都是描述如何操作资源的,而C++语言本身只能操作内存的值这一种资源,因此编程要做的第一步就是将算法中操作的东西映射成内存的值。由于内存单元的值以及内存单元地址的连续性都可以通过二进制数表示出来,因此要做的第一步就是把算法中操作的东西用数字表示出来。
    上面做的第一步就相当于数学建模——用数学语言将问题表述出来,而这里只不过是用数字把被操作的资源表述出来罢了(应注意数字和数的区别,数字在C++中是一种操作符,其有相关的类型,由于最后对它进行计算得到的还是二进制数故使用数字进行表示而不是二进制数,以增强语义)。接着第二步就是将算法中对资源的所有操作都映射成语句或函数。
    用数学语言对算法进行表述时,比如将每10分钟到车站等车的人的数量映射为一随机变量,也就前述的第一步。随后定此随机变量服从泊松分布,也就是上面的第二步。到站等车的人的数量是被操作的资源,而给出的算法是每隔10分种改变这个资源,将它的值变成按给定参数的泊松函数分布的一随机值。
    在C++中,前面已经将资源映射成了数字,接着就要将对资源的操作映射成对数字的操作。C++中能操作数字的就只有操作符,也就是将算法中对资源的所有操作都映射成表达式语句。
    当上面都完成了,则算法中剩下的就只有执行顺序了,而执行顺序在C++中就是从上朝下书写,而当需要逻辑判断的介入而改变执行顺序时,就使用前面的if和goto语句(不过后者也可以通过if后接的语句来实现,这样可以减少goto语句的使用,因为goto的语义是跳转而不是“所以就”),并可考虑是否能够使用循环语句以简化代码。即第三步为将执行流程用语句表示出来。
    而前面第二步之所以还说可映射成函数,即可能某个操作比较复杂,还带有逻辑的意味,不能直接找到对应的操作符,这时就只好利用万能的函数操作符,对这个操作重复刚才上面的三个步骤以将此操作映射成多条语句(通过if等语句将逻辑信息表现出来),而将这些语句定义为一函数,供函数操作符使用以表示那个操作。
    上面如果未明不要紧,后面有两个例子,都将分别说明各自是如何进行上述步骤的。

排序

    给出三张卡片,上面随便写了三个整数。有三个盒子,分别标号为1、2和3。将三张卡片随机放到1、2、3这三个盒子中,现在要求排序以使得1、2、3三个盒子中装的整数是由小到大的顺序。
    给出一最简单的算法:称1、2、3盒子中放的卡片上的整数分别为第一、二、三个数,则先将第一个数和第二个数比较,如果前者大则两个盒子内的卡片交换;再将第一个和第三个比较,如果前者大则交换,这样就保证第一个数是最小的。然后将第二个数和第三个数比较,如果前者大则交换,至此排序完成。
    第一步:算法中操作的资源是装在盒子中的卡片,为了将此卡片映射成数字,就注意算法中的卡片和卡片之前有什么不同。算法中区分不同卡片的唯一方法就是卡片上写的整数,因此在这里就使用一个long类型的数字来表示一个卡片。
    算法中有三张卡片,故用三个数字来表示。前面已经说过,数字是装在内存中的,不是变量中的,变量只不过是映射地址而已。在这里需要三个long类型数字,可以借用定义变量时编译器自动在栈上分配的内存来记录这些数字,故可以如此定义三个变量long a1, a2, a3;来记录三个数字,也就相当于装三张卡片的三个盒子。
    第二步:算法中的操作就是对卡片上的整数的比较和交换。前者很简单,使用逻辑操作符就可以实现(因为正好将卡片上的整数映射成变量a1、a2和a3中记录的数字)。后者是交换两个盒子中的卡片,可以先将一卡片从一盒子中取出来,放在桌子上或其他地方。然后将另一盒子中的卡片取出来放在刚才空出来的盒子。最后将先取出来的卡片放进刚空出来的盒子。前面说的“桌子上或其他地方”是用来存放取出的卡片,C++中只有内存能够存放数字,因此上面就必须再分配一临时内存来临时记录取出的数字。
    第三步:操作和资源都已经映射好了,算法中有如果的就用if替换,由什么重复多少次的就用for替换,有什么重复直到怎样的就用while或do while替换,如上照着算法映射过来就完了,如下:
void main()
{
    long a1 = 34, a2 = 23, a3 = 12;
    if( a1 > a2 )
    {
        long temp = a1;
        a1 = a2;
        a2 = temp;
    }
    if( a1 > a3 )
    {
        long temp = a1;
        a1 = a3;
        a3 = temp;
    }
    if( a2 > a3 )
    {
        long temp = a2;
        a2 = a3;
        a3 = temp;
    }
}
    上面就在每个if后面的复合语句中定义了一个临时变量temp以借助编译器的静态分配内存功能来提供临时存放卡片的内存。上面的元素交换并没有按照前面所说映射成函数,是因为在这里其只有三条语句且容易理解。如果要将交换操作定义为一函数,则应如下:
void Swap( long *p1, long *p2 )             void Swap( long &r1, long &r2 )
{                                           {
    long temp = *p1;                            long temp = r1;
    *p1 = *p2;                                  r1 = r2;
    *p2 = temp;                                 r2 = temp;
}                                           }
void main()                                 void main()
{                                           {
    long a1 = 34, a2 = 23, a3 = 12;             long a1 = 34, a2 = 23, a3 = 12;
    if( a1 > a2 )                               if( a1 > a2 )
        Swap( &a1, &a2 );                           Swap( a1, a2 );
    if( a1 > a3 )                               if( a1 > a3 )
        Swap( &a1, &a3 );                           Swap( a1, a3 );
    if( a2 > a3 )                               if( a2 > a3 )
        Swap( &a2, &a3 );                           Swap( a2, a3 );
}                                           }
    先看左侧的程序。上面定义了函数来表示给定盒子之间的交换操作,注意参数类型使用了long*,这里指针表示引用(应注意指针不仅可以表示引用,还可有其它的语义,以后会提到)。
    什么是引用?注意这里不是指C++提出的那个引用变量,引用表示一个连接关系。比如你有手机,则手机号码就是“和你通话”的引用,即只要有你的手机号码,就能够实现“和你通话”。
    再比如Windows操作系统提供的快捷方式,其就是一个“对某文件执行操作”的引用,它可以指向某个文件,通过双击此快捷方式的图标就能够对其所指的文件进行“执行”操作(可能是用某软件打开这个文件或是直接执行此文件等),但如果删除此快捷方式却并不会删除其所指向的文件,因为它只是“对某文件执行操作”的引用。
    人的名字就是对“某人进行标识”的引用,即说某人考上大学通过说那个人的名字则大家就可以知道具体是哪个人。同样,变量也是引用,它是某块内存的引用,因为其映射了地址,而内存块可以通过地址来被唯一表明其存在,不仅仅是标识。注意其和前面的名字不同,因为任何对内存块的操作,只要知道内存块的首地址就可以了,而要和某人面对面讲话或吃饭,只知道他的名字是不够的。
    应注意对某个东西的引用可以不止一个,如人就可以有多个名字,变量也都有引用变量,手机号码也可以不止一个。
    注意上面引入了函数来表示交换,进而导致了盒子也就成了资源,因此必须将盒子映射成数字。而前面又将盒子里装的卡片映射成了long类型的数字,由于“装”这个操作,因此可以想到使用能够标识装某个代表卡片的数字的内存块来作为盒子映射的数字类型,也就是内存块的首地址,也就是long*类型(注意不是地址类型,因为地址类型的数字并不返回记录它的内存的地址)。所以上面的函数参数类型为long*。
    下面看右侧的程序。参数类型变成long&,和指针一样,依旧表示引用,但注意它们的不同。后者表示它是一个别名,即它是一个映射,映射的地址是记录作为参数的数字的地址,也就是说它要求调用此函数时,给出的作为参数的数字一定是有地址的数字。所谓的“有地址的数字”表示此数字是程序员创建的,不是编译器由于临时原因而生成的临时内存的地址,如Swap( a1++, a2 );就要报错。之前已经说明,因为a1++返回的地址是编译器内部定的,就程序逻辑而言,其是不存在的,而Swap( ++a1, a2 );就是正确的。Swap( 1 + 3, 34 );依旧要报错,因为记录1 + 3返回的数字的内存是编译器内部分配的,就程序逻辑上来说,它们并没有被程序员用某块内存记录起来,也就不会有内存。
    一个很简单的判定规则就是调用时给的参数类型如果是地址类型的数字,则可以,否则不行。
    还应注意上面是long&类型,表示所修饰的变量不分配内存,也就是编译器要静态地将参数r1、r2映射的地址定下来,对于Swap( a1, a2 );就分别是a1和a2的地址,但对于Swap( a2, a3 );就变成a2和a3的地址了,这样是无法一次就将r1、r2映射的地址定下来,即r1、r2映射的地址在程序运行时是变化的,也就不能且无法编译时静态一次确定。
    为了实现上面的要求,编译器实际将会在栈上分配内存,然后将地址传递到函数,再编写代码以使得好像动态绑定了r1、r2的地址。这实际和将参数类型定为long*是一样的效果,即上面的Swap( long&, long& );和Swap( long*, long* );是一样的,只是语法书写上不同,内部是相同的,连语义都相同,均表示引用(虽然指针不仅仅只带有引用的语义)。即函数参数类型为引用类型时,依旧会分配内存以传递参数的地址,即等效于指针类型为参数。

商人过河问题

    3个商人带着3个仆人过河,过河的工具只有一艘小船,只能同时载两个人过河,包括划船的人。在河的任何一边,只要仆人的数量超过商人的数量,仆人就会联合起来将商人杀死并抢夺其财物,问应如何设计过河顺序才能让所有人都安全地过到河的另一边。
    给出最弱却万能的算法——枚举法。坐船过河及划船回来的可能方案为一个仆人、一个商人或两个商人、两个仆人及一个商人一个仆人。
    故每次从上述的五种方案中选择一个划过河去,然后检查河岸两侧的人数,看是否会发生仆人杀死商人,如果两边都不会,则再从上述的五个方案中选择一个让人把船划回来,然后再检查是否会发生仆人杀死商人,如果没有就又重新从五个方案中选一个划过河,如上重复直到所有人都过河了。
    上面在选方案时除了保证商人不被杀死,还要保证此方案运行(即过河或划回来)后,两岸的人数布局从来都没有出现过,否则就形成无限循环,且必须合理,即没有负数。如果有一次的方案选择失败,则退回去重新选另一个方案再试。如果所有方案都失败,则再退回到更上一次的方案选择。如果一直退到第一次的方案选择,并且已没有可选的方案,则说明上题无解。
    上面的算法又提出了两个基本又重要的概念——层次及容器。下面先说明容器。
    容器即装东西的东西,而C++中操作的东西只有数字,因此容器就是装数字的东西,也就是内存。容器就平常的理解是能装多个东西,即能装多个数字。这很简单,使用之前的数组的概念就行了。但如果一个盒子能装很多苹果,那它一定占很大的体积,即不管装了一个苹果还是两个苹果,那盒子都要占半立方米的体积。数组就好像盒子,不管装一个元素还是两个元素,它都是long[10]的类型而要占40个字节。
    容器是用来装东西的,那么要取出容器中装的东西,就必须有种手段标识容器中装的东西,对于数组,这个东西就是数组的下标,如long a[10]; a[3];就取出了第四个元素的值。由于有了标识,则还要有一种手段以表示哪些标识是有效的,如上面的a数组,只前面两个元素记录了数字,但是却a[3];,得到的将是错误的值,因为只有a[0]和a[1]是有意义的。
    因此上面的用数组作容器有很多的问题,但它非常简单,并能体现各元素之间的顺序关系,如元素被排序后的数组。但为了适应复杂算法,必须还要其他容器的支持,如链表、树、队列等。它们一般也被称做集合,都是用于管理多个元素用的,并各自给出了如何从众多的元素中快速找到给定标识所对应的元素,而且都能在各元素间形成一种关系,如后面将要提到的层次关系、前面数组的顺序关系等。关于那些容器的具体实现方式,请参考其他资料,在此不表。
    上面算法中提到“两岸的人数布局从来都没有出现过”,为了实现这点,就需要将其中的资源——人数布局映射为数字,并且还要将曾经出现过的所有人数布局全部记录下来,也就是用一个容器记录下来,由于还未说明结构等概念,故在此使用数组来实现这个容器。上面还提到从已有的方案中选择一个,则可选的方案也是一个容器,同上,依旧使用一数组来实现。
    层次,即关系,如希望小学的三年2班的XXX、中国的四川的成都的XXX等,都表现出一种层次关系,这种层次关系是多个元素之间的关系,因此就可以通过找一个容器,那个容器的各元素间已经是层次关系,则这个容器就代表了一种层次关系。树这种容器就是专门对此而设计的。
    上面算法中提到的“再退回到更上一次的方案选择”,也就是说第一次过河选择了一个商人一个仆人的方案,接着选择了一个商人回来的方案,此时如果选择两个仆人过河的方案将是错误的,则将重新选择过河的方案。再假设此时所有过河的方案都失败了,则只有再向后退以重新选择回来的方案,如选择一个仆人回来。对于此,由于这里只要求退回到上一次的状态,也就是人数布局及选择的方案,则可以将这些统一放在容器中,而它们各自都只依靠顺序关系,即第二次过河的方案一定在第一次过河的方案成功的前提下才可能考虑,因此使用数组这个带有顺序关系的容器即可。
    第一步:上面算法的资源有两个:坐船的方案和两岸的人数布局。坐船的方案最多五种,在此使用一个char类型的数字来映射它,即此8位二进制数的前4位用补码格式来解释得到的数字代表仆人的数量,后4位则代表商人的数量。因此一个商人和一个仆人就是( 1 << 4 ) | 1。两岸的人数布局,即两岸的商人数和仆人数,由于总共才3+3=6个人,这都可以使用char类型的数字就能映射,但只能映射一个人数,而两岸的人数实际共有4个(左右两岸的商人数和仆人数),则这里使用一个char[4]来实现(实际最好是使用结构来映射而不是char[4],下篇说明)。如char a[4];表示一人数布局,则a[0]表示河岸左侧的商人数,a[1]表示左侧的仆人数,a[2]表示河岸右侧的商人数,a[3]表示右侧的仆人数。
    注意前面说的容器,在此为了装可选的坐船方案故应有一容器,使用数组,如char sln[5];。在此还需要记录已用的坐船方案,由于数组的元素具备顺序关系,所以不用再生成一容器,直接使用一char数字记录一下标,当此数字为3时,表示sln[0]、sln[1]和sln[2]都已经用过且都失败了,当前可用的为sln[3]和sln[4]。同样,为了装已成功的坐船方案作用后的人数布局及当时所选的方案,就需要两个容器,在此使用数组(实际应该链表)char oldLayout[200][4], cur[200];。oldLayout就是记录已成功的方案的容器,其大小为200,表示假定在200次内,一定就已经得出结果了,否则就会因为超出数组上限而可能发生内存访问违规,而为什么是可能在《C++从零开始(十五)》中说明。
    前面说过数组这种容器无法确定里面的有效元素,必须依靠外界来确定,对此,使用一unsigned char curSln;来记录oldLayout和cur中的有效元素的个数。规定当curSln为3时,表示oldLayout[0][0~3]、oldLayout[1][0~3]和oldLayout[2][0~3]都有效,同样cur[0]、cur[1]和cur[2]都有效,而之后的如cur[3]等都无效。
    第二步:操作有:执行过河方案、执行回来方案、检查方案是否成功、退回到上一次方案选择、是否所有人都过河、判断人数布局是否相同。如下:
    前两个操作:将当前的左岸人数减去相应的方案定的人数,而右岸则加上人数。要表现当前左岸人数,可以用oldLayout[ curSln ][0]和oldLayout[ curSln ][1]表示,而相应方案的人数则为( sln[ cur[ curSln ] ] & 0xF0 ) >> 4和sln[ cur[ curSln ] ] & 0xF。由于这两个操作非常类似,只是一个是加则另一个就是减,故将其定义为函数,则为了在函数中能操作oldLayout、curSln等变量,就需要将这些变量定义为全局变量。
    检查是否成功:即看是否
    oldLayout[ curSln ][1] > oldLayout[ curSln ][0] && oldLayout[ curSln ][0]以及是否
    oldLayout[ curSln ][3] > oldLayout[ curSln ][2] && oldLayout[ curSln ][2]
    并且保证各自不为负数以及没有和原来的方案冲突。检查是否和原有方案相同就是枚举所有原由方案以和当前方案比较,由于比较复杂,在此将其定义为函数,通过返回bool类型来表示是否冲突。
    退回上一次方案或到下一个方案的选择,只用curSln--或curSln++即可。而是否所有人都过河,则只用oldLayout[ curSln ][0~1]都为0而oldLayout[ curSln ][2~3]都为3。而判断人数布局是否相同,则只用相应各元素是否相等即可。
    第三步:下面剩下的就没什么东西了,只需要按照算法说的顺序,将刚才的各操作拼凑起来,并注意“重复直到所有人都过河了”转成do while即可。如下:
#include <stdio.h>
// 分别表示一个商人、一个仆人、两个商人、两个仆人、一个商人一个仆人
char sln[5] = { ( 1 << 4 ), 1, ( 2 << 4 ), 2, ( 1 << 4 ) | 1 };
unsigned char curSln = 1;
char oldLayout[200][4], cur[200];

void DoSolution( char b )
{
    unsigned long oldSln = curSln - 1;  // 临时变量,出于效率
    oldLayout[ curSln ][0] =
        oldLayout[ oldSln ][0] - b * ( ( sln[ cur[ curSln ] ] & 0xF0 ) >> 4 );
    oldLayout[ curSln ][1] =
        oldLayout[ oldSln ][1] - b * ( sln[ cur[ curSln ] ] & 0xF );
    oldLayout[ curSln ][2] =
        oldLayout[ oldSln ][2] + b * ( ( sln[ cur[ curSln ] ] & 0xF0 ) >> 4 );
    oldLayout[ curSln ][3] =
        oldLayout[ oldSln ][3] + b * ( sln[ cur[ curSln ] ] & 0xF );
}
bool BeRepeated( char b )
{
    for( unsigned long i = 0; i < curSln; i++ )
        if( oldLayout[ curSln ][0] == oldLayout[ i ][0] &&  // 这里虽然4个数字比较是否相等
            oldLayout[ curSln ][1] == oldLayout[ i ][1] &&  // 但总共才4个字节长,实际可以
            oldLayout[ curSln ][2] == oldLayout[ i ][2] &&  // 通过一次4字节长数字比较替换
            oldLayout[ curSln ][3] == oldLayout[ i ][3] &&  // 四次1字节长数字比较来优化
            ( ( i & 1 ) ? 1 : -1 ) == b )  // 保证过河后的方案之间比较,回来后的方案之间比较
                                           // i&1等效于i%2,i&7等效于i%8,i&63等效于i%64
            return true;
    return false;
}
void main()
{
    char b = 1;
    oldLayout[0][0] = oldLayout[0][1] = 3;
    cur[0] = oldLayout[0][2] = oldLayout[0][3] = 0;
    for( unsigned char i = 0; i < 200; i++ )  // 初始化每次选择方案时的初始化方案为sln[0]
        cur[ i ] = 0;                         // 由于cur是全局变量,在VC中,其已经被赋值为0
                                              // 原因涉及到数据节,在此不表
    do
    {
        DoSolution( b );
        if( ( oldLayout[ curSln ][1] > oldLayout[ curSln ][0] && oldLayout[ curSln ][0] ) ||
            ( oldLayout[ curSln ][3] > oldLayout[ curSln ][2] && oldLayout[ curSln ][2] ) ||
            oldLayout[ curSln ][0] < 0 || oldLayout[ curSln ][1] < 0 ||
            oldLayout[ curSln ][2] < 0 || oldLayout[ curSln ][3] < 0 ||
            BeRepeated( b ) )
        {
        // 重新选择本次的方案
P:
            cur[ curSln ]++;
            if( cur[ curSln ] > 4 )
            {
                b = -b;
                cur[ curSln ] = 0;
                curSln--;
                if( !curSln )
                    break;  // 此题无解
                goto P;  // 重新检查以保证cur[ curSln ]的有效性
            }
            continue;
        }
        b = -b;
        curSln++;
    }
    while( !( oldLayout[ curSln - 1 ][0] == 0 && oldLayout[ curSln - 1 ][1] == 0 &&
              oldLayout[ curSln - 1 ][2] == 3 && oldLayout[ curSln - 1 ][3] == 3 ) );

    for( i = 0; i < curSln; i++ )
        printf( "%d  %d/t %d  %d/n",
                oldLayout[ i ][0],
                oldLayout[ i ][1],
                oldLayout[ i ][2],
                oldLayout[ i ][3] );
}
    上面数组sln[5]的初始化方式下篇介绍。上面的预编译指令#include将在《C++从零开始(十)》中说明,这里可以不用管它。上面使用的函数printf的用法,请参考其它资料,这里它只是将变量的值输出在屏幕上而已。
    前面说此法是枚举法,其基本上属于万能方法,依靠CPU的计算能力来实现,一般情况下程序员第一时间就会想到这样的算法。它的缺点就是效率极其低下,大量的CPU资源都浪费在无谓的计算上,因此也是产生瓶颈的大多数原因。由于它的万能,编程时很容易将思维陷在其中,如求和1到100,一般就写成如下:
    for( unsigned long i = 1, s = 0; i <= 100; i++ ) s += i;
    但更应该注意到还可unsigned long s = ( 1 + 100 ) * 100 / 2;,不要被枚举的万能占据了头脑。
    上面的人数布局映射成一结构是最好的,映射成char[4]所表现的语义不够强,代码可读性较差。下篇说明结构,并展示类型的意义——如何解释内存的值。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
相关文章推荐