| 晓涛님의 프로필思考,灵感,成功블로그리스트 | 도움말 |
思考,灵感,成功当梦想没有实现的时候,有什么比努力奋斗更有意义的呢? 12월 20일 一道算法导论上的证明题问题描述:Ex9.1-1 Show that the second smallest of n elements can be found with 证明在n个元素中找到第2小的元素在最坏情况下需要 证明: 刚接到题目的时候并没有什么想法,因为从常规的擂台方法求最小元素的方法如果作用在第2小的元素在最坏情况下需要2n-3次比较(一开始选取两个元素比较之,随后在最坏情况下,和后面的n-2个元素都需要比较两次,即有2*(n-1)+1=2n-3)。超出了我们的要求。 但是从分析的角度来看,题目给出的结论当中存在一个lg n,这通常是二分策略的标志,而且题目后面又提示说这个方法也能够找到最小的元素,而在寻找最小元素的方法中存在二分思想的方法就是著名的竞赛树方法,如下图所示: 简单地说,我们将所有的元素两两配对,在每一对中找到较小的那个数并提取出来,然后就像淘汰赛一样,将这提取出的较小的数在两两配对,并在每一对中找到较小的数并提取出来……如此反复,当最后只剩下一个数的时候,我们就最终得到了最小的数。 这个方法的效率其实是和朴素的擂台方法效率相同的,为n-1次比较,但是它的好处是利用了二分的思想,从而在对于数组中的每一个元素,最多和 我们可以发现在整棵树中和最小元素进行比较的元素在此之前都是该分支中最小的元素,它们是遭遇了最小的元素才“落败”的,也就是说,第2小的数一定就存在于他们之中(稍后给出详细证明),于是我们得到如下的算法: 首先,我们使用竞赛树方法得到整个竞赛树和最小的元素,注意竞赛树的保存方法,由于我们是从树叶向上逆向存储的,因此需要额外的空间保存结点的两个子女的编号。 随后,从树的根(也就是最小的元素)开始,向下遍历整个最小的元素经过的路径,观察整个路径中最小元素旁的兄弟结点(也就是在和最小元素比较中“落败”的结点)的值,找出其中最小的,即为第2小的元素。 证明算法的正确性: 初始状态:在一开始,我们从树的根开始寻找第2小的元素,最初在最小元素旁的兄弟结点是必然有的(很显然,否则为什么还要再比较一次?),而根据竞赛树的性质,竞赛树中的任意子树的根结点都是整个竞赛树中最小的元素,因此这个兄弟结点的值必然是它所在子树的最小值,然而它大于最小的值,因此在当前(根及它所在的子树),它确实是第2小的值。 保持:随着遍历过程的深入,我们不断地得到在最小元素旁的兄弟结点,它们也和先前的那个元素一样,在其所在的子树中是最小的,但大于最小的元素。而将它和当前的第2小元素进行比较,找到较小的值,我们就能得到当前树结构中的第2小的值。 结论:如此往复,当遍历到树叶时,我们得到的值就是第2小的了。 效率: 在前一部分生成竞赛树所需要的比较数为n-1,随后的遍历过程中需要的比较数取决于竞赛树的深度,由于竞赛树也满足平衡性,因此设竞赛树的深度为len,则有: 2^(len-1)<n<2^len,其中n为元素的个数,因此有 12월 17일 pku1011解题报告搜索的经典,巧妙的剪枝——pku1011解题报告问题描述给定n根木棒(n<=64),每根木棒的长度最大不超过50个单位长度,但长度不一,现在希望将它们合并成m根长度一样的木棒,要求在合并后得到木棒的可能的最小长度。 举例: N=9 5 2 1 5 2 1 5 2 1 那么最小长度为6,对应(5+1),(5+1),(5+1),(2+2+2)4根。 思路与分析第一个问题:什么算法? 问题的描述很简单,也容易理解。现在的问题就是要寻求一个合适的算法。 结合实际情况,我们想象一下人在解决这个问题时候的问题,无非就是一个反复枚举实验的过程。而且虽然本题是一个求最优解的问题,但是没有贪心策略。而由于对于一个状态其存储的信息量不足(也就是一个可达不可达的信息),又不能够有效地压缩其状态,因此使用DP并不能提高效率。同时由于问题的规模不大,因此我们最终采用搜索算法来解此题。 既然是搜索酸法,那么首先要解决的问题就是: 第二个问题:怎么搜索? 问题要求“合并后所得的木棒长度最短”,因此第一层枚举的时候肯定是从小到大枚举合并后的木棒长度,这没有什么异议。但是随后的搜索以判断该长度是否能够完成的过程却有两种不同的方法:搜索时先确定一根原木棒的位置(搜索的是原木棒应当在新合成的木棒中所在的位置,原木棒是搜索的主体),和首先合并完一个木棒后再合并下一根(搜索的是当前正在合并的木棒中应当加入的原木棒,以当前合并的木棒为搜索的主体。)这两种方法的效率相仿,但是从剪枝的角度来看两者剪枝的策略会有很大的差别,甚至有些剪枝是不能互通的,在怎么搜真个问题上我反复了几次,我会在剪枝的过程中详述。 我首先选择的方法是后者,不加剪枝的搜索策略的效率是O(n!*m) ,巨大无比,显然是超时的。所以说剪枝是必然的。 我们从简单的剪枝开始: 一些的简单剪枝。 剪枝1:首先,最明显的一个剪枝是,如果说当前所要求的木棒长度不是所有木棒总长度的约数的话那么当前所要求的木棒长度显然是不成立的。原因很明显:问题要求所有合并后的木棒长度一致,那么木棒的个数*木棒的长度的积应当等于原来木棒的总长度。 剪枝2:接下来一个剪枝还是关于枚举新的木棒长度的:所枚举的木棒长度必须要大于等于原有木棒中最长的那根木棒。其原因也很显然。 剪枝3:当然如果1/2总木棒长度都不能得到解的话就可以直接将总木棒长度作为答案了,仍然因为 问题要求所有合并后的木棒长度一致的缘故。 剪枝4:对于要拼n根木棒的情况,我们只要拼出其中的n-1根我们就能够得到解,因为有了剪枝1的保证,第四个剪枝也很容易理解:我们在开始进行搜索的时候已经保证了数据在数量上是完全合理的。严格地说这个剪枝并不强,但在有些时候确实能够起到很重要的作用。 上面的这些剪枝都是很明显的,也不需要什么特别的思考就能够想到,然而即使使用了上述剪枝,对于整个搜索的效率影响最大的一块——枚举以后的判断过程的效率仍然为O(n!),没有下降,仍旧是一个很大的问题。 联想人在进行拼接过程中所采用的方法,我们又得到一些启示: 剪枝5:首先对于一个正在拼接的新木棒来说,如果新木棒中的空余空间小于最短的原木棒的长度,那么显然当前的这根新木棒是得不到解的。其原因显而易见。 从我个人的角度我认为,如果是我进行拼接的过程,我会首先拼接比较长的一些木棒,因为相较于比较短那些木棒来说,比较长的木棒的灵活度要小一些,先解决困难的木棒可以保证如果本长度没有解的话可以避免一些没有必要的搜索过程。 所以说,我们首先可以做的是对原有的木棒按照其长度从大到小进行排序,这样可以在一定程度上提高算法的效率。 按照这个思路联想下去,我很快发现在所有的木棒中只要有一根木棒没有办法得到安放的话整个问题就是没有解的,那么是否一根一根地拼接木棒的策略是有问题的呢?我于是改用基于原木棒的顺序搜索的策略。在这种搜索策略下,原木棒是搜索主体,我们使用一个数组对每一根要合成木棒的当前长度进行记录。在这样的情形下,我观察了一些数据: 15 11 8 8 8 4 3 2 1 结果:拼成3根长度为20的木棍: 15+3+2=20 11+8+1=20 8+8+4=20 8 8 6 4 2 2 结果:拼成3根长度为10的木棍: 8+2=10 8+2=10 6+4=10 5 5 5 2 2 2 1 1 1 结果:拼成4根长度为6的木棍: 5+1=6 5+1=6 5+1=6 2+2+2=6 我发现所有长度超过计划合成的木棒长度的一半的原木棒必须唯一地出现在一个待合并的新木棒(剪枝6)中,因此我们可以先行确定这些木棒(很明显这些木棒的总数不可能超过待合并的新木棒的总数)。 紧接着我们得出后续结论:如果某两根原木棒可以组成一个待合并的新木棒,那么我们不需要更改这两根木棒的配置(剪枝7)。我给出这样的说明:首先,这两根原木棒要么长度一致,要么其中一根的长度超过当前要拼成的木棒的长度的一半。如果对于前一种情况,我们固然可以将其中的任意一根换成两根或者更多根较短的木棒,但是很明显较多的小木棒的灵活性比单根较长的木棒的灵活性来得高,因此可以“锁定”当前木棒;对于后一种情况,显然较长的那根木棒是不能移动的(根据上一段的推理),而那根较短的木棒当然也可以换成更多的木棒拼接而成,但是同样的,单根木棒可以得到的长度组合显然没有更多根木棒能拼接的长度组合来得多,因此同样可以“锁定”当前木棒。 但是这里必须说明一点,超过三根原木棒组成的新木棒是不能“锁定”的,我们来看以下数据: 15 11 8 8 8 4 3 2 1 正确答案应该为15+3+2,11+8+1,8+8+4这三根长度为20木棒,但是如果我们对木棒按长度进行排序后搜索到的第一根长度为20的木棒却是15+4+1=20,如果当时立刻“锁定”该木棒,那么剩下的11,8,8,8,3,2,1无论如何也拼不出一根长度为20的木棒,长度为20的枚举就会失败,我们只能在长度为30的枚举中得到15+11+4=30,8+8+8+3+2+1=30这样一组数据了。而实际上长度20是能够出解的。因此并不存在“短木棒的灵活性一定比长木棒的灵活性要高”的结论。 根据数据找原因 剪枝到此,我认为效率已经得到了很大程度的提高,然而再一次尝试提交之后我得到了TLE的结果,在无法想出新的剪枝的时候我想到了去寻找一些网上的讨论,于是我得到了如下的一组“委琐”数据: 64 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 43 42 42 41 10 4 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 (一共1个43,2个42,1个41,1个10,1个4,以及58个40) 正确的解是: 43+42+42+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+4=1251 41+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+40+10=1251 我的程序超时得厉害,按照我的想法进入动态调试查看整个搜索过程以发现重复的搜索,我于是发现每一层中的58个40的元素都被反复搜索了很多次,看来问题出在这里,必须剪掉这些冗余的搜索。 歧路的剪枝,错误的搜索策略 很明显这58个40元素应当都是等价的,也就是说从当前的搜索策略(将原木棒从大到小地一个一个放入可能的新木棒容器中)角度来说如果那么就有如下的策略:对于每一根原木棒,如果其前一根木棒的长度与当前木棒的长度相等,那么本木棒就必须从前一根木棒所“落户”的新木棒起向后搜索。这是根据排列组合中的加法原理得到的,很容易理解。 我期望这个剪枝能够很好地起到剪枝的效果,可事实让我失望了,效果并不明显。 其实问题很明显,对于值的元素如果在相同的状态下(在同样的第i根原木棒,投放到第j根新木棒,此时新木棒中已有长度为k的情况下)进行的搜索所得到的结果必然是相同的,其之间应该可以互相借鉴,是否可以认为,那么投放到相同的新木棒,且新木棒的已有长度为一定的情况下使用相同的元素进行搜索的所得到的结果是一样的呢?于是我决定列一个矩阵,存储当原木棒的长度为i,投放到第j根新木棒,此时新木棒中已有长度为k的情况下的解是否存在这样一个信息,结果很让人欣慰,达到了要求,可是1<=i<=50,1<=j<=64,0<=k<=3200,50*64*3200=(3200)^2=1.024MB,然而本题的空间限制恰为1MB,超空间了,我还想尝试一些新的剪枝,可是都没有办法,解题陷入了僵局。 这时我发现论坛上成功解出本题的人使用的搜索策略都是以一个一个地试探能否组成一根新的木棒为搜索策略的(也就是说以新木棒为搜索基础,和我提出的后一种搜索策略一致)。 是不是搜索策略有问题?我于是决定尝试改变搜索思路,很快发现剪枝6其实是可以加强的,其实对于当前最长的原木棒,必须要出现在一根崭新的新木棒中(剪枝6-1),这要如何理解呢?其实前面的剪枝都是剪去一些确定必然不存在的分支,而新的剪枝6是一个固定顺序的过程,即在很多种情况都可以得到解的情况下,我们确定出一个顺序来,将一些冗余的,重复出现的枝剪掉。我们再举15 11 8 8 8 4 3 2 1这个例子,其组成的三根新木棒(15+3+2=20, 11+8+1=20, 8+8+4=20)中顺序是不重要的,其实3+15+2, 1+8+11,8+4+8这样也是可以的,但是这样一个搜索过程和原解相比较是冗余的,完全可以剪掉,而剪掉冗余枝的过程就是确定一个顺序,在这里为:我们应当首先保证比较长的原木棒能够优先得到被分割的权利。 新剪枝加上去后,效率有了一定的提高,剪枝的思路也更加清晰了,直接导致了随后的强剪枝的诞生,而这个剪枝是使用以原木棒为基础的搜索策略难以使用的。 有的时候改变搜索策略竟然是如此的有效!后来我发现前面提出的“记忆化搜索”是有漏洞的,即使剪了也难免Wrong Answer,看来这种搜索策略果然是错误的。 强剪枝 其实前面的思路没有问题:减少冗余枝的搜索,其实如果一根原木棒在投入某一根新木棒的过程中失败了,那么其相同的长度的原木棒就没有必要再投入进去,直接跳过即可(剪枝8)。但这里有一个条件必须成立:在此之前的所有新木棒的状态要填满,如果没有填满,则我们完全有可能存在可以回填的原木棒,也就是说用前一种搜索策略是得不到这样一个剪枝方法的,至少说要想到它很困难,但是在我们现在的搜索策略中很容易想到。 剪枝8的效率很高,对于上面的那组BT数据,加了剪枝8之后只用了几十ms就解决了,无疑这是一个强剪枝。本题也因此得到了解。如此看来选择搜索策略果然是很重要的。 小结“搜索是硬道理”,在所有的问题中搜索问题完全是依靠程序员的经验和对问题的理解深度,完全凭真工夫。解搜索问题的过程中要解决两个问题:搜索策略,即搜什么的问题;剪枝策略,即哪些枝是不需要的。其中要剪枝策略可以分为两种:不能达到解的解分支和本质上相同的解分支。 12월 13일 pku1742 解题报告非典型DP——pku1742解题报告问题描述编写一个程序,读入n,m。有币值A1、A2、A3…、An的硬币,对应各有C1、C2、C3、…、Cn数量的硬币,问它们能够在1~m元中组合成多少中不同的币值? 举例: 对于输入: 3 10 1 2 4 2 1 1 即说明有2个1元硬币,1个2元硬币,1个4元硬币,求他们能够在10元中组合成多少中不同的币值。 显然能够组成1(1),2(2),3(2+1),4(4),5(4+1),6(4+2),7(4+2+1),8(4+2+1+1)8种不同的币值,于是输出为8。 再举一例: 2 5 1 4 2 1 即说明有2个1元硬币, 1个4元硬币,求他们能够在5元中组合成多少中不同的币值。 能够组成1(1),2(1+1),4(4),5(4+1),6(4+1+1)5种不同的币值,但是6这个值超过了限定,于是输出为5。 思考与分析第一个算法 1<=m<=100000,即使使用4个字节来表示,4*100000=391K,从空间的角度来说是完全没有问题的,因此我们完全可以用一个一元数组f[i]来表示使用题目提供的硬币能否组合成i元。对于第i种币值的第j枚硬币(即每一枚硬币),我们遍历整个f数组,标记所有可能到达的点,用动态转移方程来描述的话,就是下面的样子: 在问题的最后,我们只需要再遍历一次f数组,累计所有从1~m范围中f[i]值为1的元素的个数作为最后的答案就可以了。需要注意的是顺序,由于在同一个数组中进行操作,如果我们从低到高地遍历整个数组的话,会出现一枚硬币重复计算的情况(例如前面所举的例2,如果我们从低到高地遍历整个数组的话,会在第一个循环中就将F数组的所有元素的值均置为1),这在很大程度上是由于初态F[0]为1的缘故,也就是说,很明显地我们可以感觉到1这个信号是由低到高地“蔓延”的,硬币重复计算的原因是因为我们在向后拓展1的时候使用到了本次生成的1,因此我们使用从高向低地遍历就可以解决这个问题。 整个问题的时间复杂度为O(n*m*Max(Ci))。提交以后,结果为TLE。 优化 从经验来看n*m*Max(Ci)=O(100*100000*1000)=O(10^10),确实超时了,然而其实差距并不是特别的大,但是从状态的角度来看,哪些状态是可以压缩的呢? 压缩n很困难,因为原则上来说,这组数据有其下属的数据(即硬币的面值及个数),压缩的话会丢失数据。 那么压缩m和Ci那个容易呢?m的数据也很困难,毕竟从先前的朴素算法来看,这是一种类似于动态规划的算法,或者说这就是DP,只不过并不是一个很典型的DP而已(因为状态的类似于打表,也不同于一般的DP最后的答案往往是递推的终点)。既然是DP的话就要保证整个递推的过程的无后效性,而且由于我们DP最后的答案需要用到所有的状态,如果我们压缩m的话,我们如何保证信息的完整性?要保证信息的完整性,以及让m的数据能够顺利地完成递推是困难的。 于是主要的能够压缩的地方就集中在了Max(Ci)上,确实这里存在冗余,因为对于Ci中的每一个数字,我们都要将m个数遍历一遍,这很浪费,也不值得,完全可以压缩。 如何压缩呢?仔细观察整个1“蔓延”的过程你会发现,如果去除掉ci的限制,问题就变成了一个类似于的背包问题的问题,而的ci就是加在各个硬币上的一个限制而已,其实我们不妨换一个思路,为什么要先确定它的限制呢?——逆向思维之——我们完全可以不用先确定它的限制,而在要标记这个结点的时候对其使用了多少个当前币值的硬币进行记录,并对其进行判断,如果我们要构成当前币值已经超过了最大的当前币值硬币限制数,我们就舍弃这个答案,于是乎,一个新的动态转移方程就这样产生了: 其中prev[k]表示当得到值k的时候已经使用了多少当前币值的硬币。每当有一个新的F[k]置为1,则有prev[k]=prev[k-A[i]] + 1,并保证prev[0]=0。 F[k]的值可以从1开始由低向高遍历,因为已经有一个额外的数组prev[k]记录限制了。 这样的话在实际进行搜索的过程中我们就可以省去一维,从而似的整个问题的时间复杂度降为O(n*m),效率提高了不少。 小结1. 提高动态规划效率的方法最主要的是压缩状态,而增加维数在很多时候被认为是在状态方程无法列出的时候用于建模时才使用的技巧,可是在解本题的过程中,我们通过增加记录的东西,用空间换时间,成功地提高了效率。也验证了动态规划是记忆化的搜索这样一种说法。 2. 动态规划并不是一门单一的算法,把动态规划算法严格地定义成递推式DP和递归式DP会影响到DP的灵活性。其实DP应当是一种思想,本题在解题的过程中掺杂了一定的Ad Hoc成分,但究其本质仍然是DP,只不过不是很明显而已。虽然有经典DP存在,但是没有典型DP存在,DP本身就是非典型的。 12월 11일 pku1063解题报告一个小时的思考,5分钟的编程——pku1063解题报告问题描述这个迷题首先给出了一个由m个黑盘子和n个白盘子组成椭圆形的轨道,轨道中有一个能够滑动的十字转门(也就是说能够翻转),这个十字转门能容纳3个连续的盘子。在图一中,轨道上有8个黑色的盘子和10个白色的盘子。你可以旋转十字转门来翻转其中的三个盘子或者将十字转门在轨道上滑动一格。 这个迷题的目标是为了形成如下图所示的盘子的布局形式: 你被要求写一个程序来确定对于一个给定的序列能否达到目标。如果目标是可以到达的,输出“YES”,否则,输出“NO”。 思考与分析本题乍一看来似乎难以下手,但是向来迷题类的问题往往都能够转化成一个数学问题。我们需要对模型进行建模。 建模过程 观察整个问题后我们发现,整个轨道组成一个环,也就是说不存在端点,也就不一定要求整个轨道的最后状态一定要什么黑盘在左,白盘在右,其实问题只是要求黑盘和白盘分别“聚集”在一起就可以了,不一定一定要存在一个端点,而且由于所谓的“十字转门”是可以滑动的,这就是说我可以任意地“翻转”序列中任意的三个连续的元素。或者说,我可以将一个元素向前跳一格或者向后跳一格(隔一个元素)。而游戏的目的是要黑盘和白盘分置在序列的两侧(当然序列是循环的,我们这里这样做是为了方便后面的描述)。 这样,问题就转化为,给定一个序列,序列中有两种不同的元素,元素可以每隔一个元素向前(后)跳转,问我能否将两种不同的元素分置于两侧。 探测解法 其实在模型建立之前我就开始进行一些小数据的测试(比如最少要3个数字,010),为的是使模型更加清晰,现在我继续进行一些小数据的测试,以期找到解法: 实际过程中,我探测了以下的数据: 0101 无解,无法达成0011 01001 有解,将2位和4位交换即可 010101 无解 001101 有解,将3位和5位交换即可 1010101 有解,解法: 1010101=>(1位和6位交换) 0010111=>(6位和4位交换) 0011101=>(3位和1位交换) 1001101=>(1位和6位交换) 0001111 似乎只要序列的长度奇数的就一定有解? 对于这类的问题很多时候都可以归结到一个同余问题上来(一个经典的例子就是01年浙江省选的“青蛙的约会”),但是同余我并不熟悉,怎么办呢? 其实,从问题的规模和迷题的复杂性来看,这题的长度不会很长,查看网上的discuss中很多人也提到了通过奇偶性来判断有解。那么是不是就此展开分析呢? 思路分析 其实我探察的最后一个数据已经给出了大致的解迷题的思路——尽量地向一个方向靠拢,我们已经了解到迷题是否有解似乎与长度的奇偶性有关,那么我们是不是在这上面做一点文章呢? 如果涉及到奇偶性的问题往往有两种原因:1、问题本身存在两种状态,两种状态是互相不可达的(经典例子有八数码问题);2、奇偶性可能会使得有些状态不可达。 首先我们观察一个偶数数列(如n=6): 123456 我们发现,如果我从1出发,向前后跳转,我就只能够到达1、3、5这三个位置,也就是说在奇数位上出现的数字永远也不可能到偶数位上,由于我们所要的最终结果是要黑色(白色也是同样的)连续的出现,因此黑色(白色)中奇数和偶数的个数就要相等,最多奇数比偶数多一个或者偶数比奇数多一个。对于一个长度为偶数的数列,由于相同颜色的盘子所在位置的奇偶性是不可改变的,于是初始状态时的位置排列就决定了解的可能性。而对于一个奇数长度的数列(如n=7): 1234567 从任何一个位置出发都可以到达所有的位置,因此从这个方面看来,确实没有无解的情况。其实也一定可以保证有解,对于偶数数列的情况,相当于两个不同的数列,每个数列中的元素都可以与相邻的数据做交换,如n=6就形成了数列135和246,这样必然能够产生所有的排列;对于奇数数列的情况,不失一般性,我们使用n=5时的例子,如: 12345 我要交换3和4之间的位置,同时不影响别的数据,可以这样完成: 12345=> 14325=> 15324=> 15432=> 12435=> 此例同样适应于更大的数据,可以使用数学归纳法证明,显然可得。 结论 整个结果与数列的长度和布局有关,当数列长度为奇数时,一定有解,为偶数时,如果说对于某个颜色的盘子(如黑色),其在奇数位置上的盘子的个数与在偶数位置上的盘子的个数相差的绝对值小于等于1时,有解,反之则无解。 代码 非常简单的一个判断过程,代码略。 小结总共花了一个小时来思考整个解题的过程,其实在最终写报告之前仍然是有一些部分是我不能理解的,整个解题的编码过程十分简单,而思考及数学推理的过程却很长,这从一个侧面证明了在很多程序设计的过程中,代码的工作量其实只是很小的一部分,更大的部分在思考和数学推理上。 11월 27일 与计算机交流——C语言程序设计“语言是工具” 11월 26일 枫叶手札——写在最前面手札是什么东西?在我的理解当中可以简单地认为是笔记,或者某某人曾经大肆宣扬的所谓“回忆笔记”。即把对于某门课程的个人经验和理解用笔的方式写出来,同时添加上自己的理解。这被认为是非常有效的一种复习手段,我也这么认为。 3월 15일 Windows 2000技术内幕(试译)Windows 2000 技术内幕第一章 概念与工具在本章中,我们会介绍一些Microsoft Windows 2000中的一些关键概念和术语,比如说Microsoft Win32 API,进程(process)、线程(Thread)、虚拟内存,内核模式(kernel mode)和用户模式(user mode)、对象(object)、句柄(handle)、安全性(security)以及注册表(registry)。我们也会介绍一些用于探索Windows 2000内部的工具,如性能工具(Performance tool)、核心调试器(kernel debugger)、在配套CD中出现的特殊工具和许多附加工具包比如Windows 2000 support Tools、Windows 2000 debug Tools、Windows 2000 resource kits、以及the Platform Software Development Kit(SDK)。此外,我们也会向你介绍如何使用Windows 2000 Device Driver Kit (DDK)来寻找Windows 2000内部的其他内容。 保证你对本章中介绍所有内容都能够理解——本书的后续部分会基于这些内容。 基础概念和术语在本书的整个讲述过程中,我们涉及的一些结构或者概念可能对于某些读者来说是很陌生的。在本节中,我们会定义一些以后我们会用到的术语。在阅读后续章节以前,你应当熟悉它们。 Win32 APIWin32应用程序接口(Win32 API)是Windows家族(包括Windows 2000/95/98/ME/CE)中最主要的程序设计接口。尽管我们不会在本书中介绍有关Win32 API的内容,但是我们会介绍一些关键的Win32 API函数的内部行为和执行机制。如果读者希望全面地解Win32 API的内容,可以参考Jeffrey Richter的〈〈Programming Applications for Microsoft Windows (fourth edition, Microsoft Press, 1999)〉〉。 上述的每一个操作系统都会实现Win32子系统的一个子集。在大多数情况下,Windows2000是所有上述实现的Win32子系统的超集。至于那些关于哪些服务在哪些平台上实现这样的细节可以参考Win32 API的参考文档。这些文档可以在MSDN Library光盘上或者在http://msdn.microsoft.com免费查询到。这份文档在\Program Files\Microsoft Platform SDK\Lib\Win32api.csv(一个用逗号分割的文本文件)中查到。这个文件是作为SDK平台的一部分保存在MSDN数据库中,或者也可以在http://msdn.microsoft.com中免费下载。(你可以参看书后面的"Platform Software Development Kit (SDK)”这一节) 注意: MSDN是微软为开发人员提供的基于Microsoft Developer Network的支持程序。MSDN有三种CD-ROM发行版本:MSDN Lbraryi,MSDN Professional以及MSDN Universal。MSDN Library的内容也可以在MSDN的网站上免费浏览。如果需要得到更多的信息,可以参看http://msdn.microsoft.com。 为了本书的编写目的,我们引用了一些Win32 API中的基本内容,涵盖诸如进程、线程、内存管理、安全、输入输出、窗体以及绘图方面的内容。Win32其实是SDK平台的一部分。SDK平台涉及的另外一些主要内容,比如事件处理、数据库、消息传递、多媒体及网络服务并不在本书介绍的范围内。 尽管Windows2000能够支持许多不同种类程序的接口,Win32主要是面向操作系统的一种接口。Win32处于这样一种地位的原因是,在三种子系统环境(Win32,POSIX以及OS/2)中,Win32子系统是最接近于底层Windows2000服务的。正如我们将会在第二章中介绍的一样,在Windows2000下运行的应用程序是不能直接调用原始的Windows2000服务的——相对地,它们必须调用某个子系统环境中的某个API函数。 Win32 API的历史 有趣的是,Win32原来并不是设计作为最基本的Windows NT程序接口的。只是由于Windows NT是作为一个取代OS/2的一个方案来设计的,因此(Windows NT)的主要程序接口是32位的OS/2的IBM公司的表示层管理程序包的API。然而,Windows NT项目结束进行了一年以后,Windows3.0打入了市场并取代了Windows NT的地位。结果,微软改变了反展方向并把Windows NT作为Windows家用产品系列中用于对抗OS/2的一种新产品。正是这样一种形势促成了Win32 API的兴起——在此之前,Windows应用程序接口一直只是一种16位的程序接口。 尽管Win32 API中引入了不少Windows3.1中没有实现的新函数,微软打算设置一些能兼容16位Windows API函数的名字、语义、所用的函数参数类型的32位新函数来使在Windows NT下使用一些移值的16位Windows应用程序成为可能。所以那些首次见到Win32 API并且想知道为什么许多函数和接口的名字看上去不一致的人应当记住这种不一致性的一个原因是Win32 API必须保证它能够和旧的16位Windows API相兼容。 服务、函数和例程(routine)一些术语在Windows2000的用户和程序文档中根据不同的上下文有不同的意义。比如说,“服务(services)”这个词可以是指一个在操作系统中,或者在设备的驱动程序中、或者在服务进程中的一个可以被调用的例程。下面的这个表单描述立刻在本书中出现的一些术语的含义: Win32 API 函数:在Win32 API中文档化了的,可随时调用的子程序。例子有CreateProcess, CreateFile, 以及GetMessage函数。 系统服务(或者 管理型系统服务(executive system services)):Windows2000 操作系统中 可以在用户模式下被调用的基本函数(native functions,要了解基本函数的定义,可以参看本书第三章“系统服务调度”一节)。 核心支持函数(或者程序):在Windows2000操作系统中在内核模式(将在本章的稍后部分中介绍)中的子程序。比如,ExAllocatePool 是一个设备驱动程序用于向Windows2000系统堆请求分配内存空间的程序。 Win32服务: 被Windows2000服务控制管理器启动的进程。(尽管注册表将Windows2000的设备驱动程序定义为“服务”,在本书中,我们不参考这种定义)比如说,任务调度服务是一种用户模式下的进程,用于对at命令提供支持。(就向Unix命令at以及cron一样) DLL(动态连接库):一组可被条用的连接在一起的二进制文件,它能够动态地被应用程序读取,并使用其中的子程序。例子包括Msvcrt.dll(C的运行库)和Kernel32.dll(Win32 API子系统库之一)Windows2000 在用户模式下的组件和应用程序广泛地使用动态连接库。动态库相对于静态库的优势在于应用程序可以共享动态库,而且Windows2000保证在内存中,每一份被应用程序调用的动态连接库只会有一份拷贝。 进程、线程、以及作业(job)尽管程序和进程在表面上很相似,它们是根本不同的东西。程序是一组静态指令序列,而进程一个保存线程所使用的资源的容器。线程则是用来执行程序实例用的。在最高层次的抽象定义中,一个Windows2000进程由以下内容组成: l 一个私有的虚拟地址空间,它是一个该进程可以使用的内存地址的集合。 l 一个可执行的程序,它定义了初始的代码和数据以及它们是怎样被映射到进程的虚拟地址空间中的。 l 一组指向不同的系统资源的打开的句柄,像信号、通讯端口以及文件这样的系统资源,它们能够被该进程中的所有线程访问。 l 一组被称为访问令牌的安全上下文,用于识别与该进程相关的用户,安全组,以及特权。 l 一个被成为进程ID的唯一标识符(在内部的被称做客户ID)。 l 至少一个可执行的线程。 线程是一种在进程内的实体,它在Windows2000的时间表中等待被执行。没有它,带有进程的程序就无法运行。一个线程包含以下必须的组件: l 一组表示CPU状态的寄存器。 l 两个栈,一个在线程运行于内核模式下使用,一个在线程运行于用户模式下使用。 l 一个被称做线程局部存储空间(TLS)的私有存储空间,它被子系统,运行库,以及动态链接库所使用。 l 一个被称作线程ID的唯一标识符(在内部这个标识符也被称为客户ID——进程ID以及线程ID是在不同的名字空间中生成的,所以它们不会重叠) l 线程有时也会有自己的安全上下文,这种情况经常会在多线程服务器的应用程序中,用于模仿其客户端的安全上下文而使用。 那些寄存器,栈以及私有的存储空间被称为线程上下文。因为对于不同结构的计算机,这些信息是不同的,这个结构是一种必须的结构上的独立。事实上,上下文结构(有Win32 GetThreadContext函数返回的)是Win32 API中能够唯一的根据计算机硬件的不同而不同的数据结构。 尽管线程拥有他们自己的上下文,一个进程中的每一个线程共享进程的虚拟地址空间(除此之外还包括该进程所有的其他资源),这意味着该进程中的所有线程可以在对方的内存空间中进行读写操作。通常情况下,线程是不能引用自己所属进程以外的其他进程所使用的内存空间的,除非别的进程开放了一部分它的私有内存空间作为一个共享内存区(在Win32 API中被称为一个映射文件)或者自己所属的进程打开了其他的进程并且使用了ReadProcessMemory和WriteProcessMemory函数。 除了一个私有的内存地址空间以及一个以上的线程,每个进程有一个安全标识符和一个已经打开的句柄的列表。(指向诸如文件、共享内存区、或者同步对象(事件、信号或者mutexes(?),如图1-1所示)之一)
图1-1 一个进程和它的资源
每一个进程都有一个被保存在一个对象中的安全上下文,它被称作访问令牌(access token)。进程访问令牌包含该进程的安全标识复和凭证(credentials)。在默认情况下,线程没有自己的访问令牌,但是它们可以获得一个,从而允许单个线程去模仿其他进程的安全上下文——包括在远程Windows2000系统中运行的进程——而不会影响其他进程中的线程。(可以参看第八章得到更多关于进程和线程安全的细节。) 虚拟地址描述符(VAD)是一种内存管理器用于与当前进程正在使用的虚拟地址保持相关联的数据结构。这种数据结构会在第七章中被详细描述。 Windows2000引入了一种被称作作业(job)进程的扩展模型。一个作业对象的主要功能是允许一组进程能够被当作一个单元一样来管理和操作。一个作业对象允许控制当前作业中的进程的属性,并对当前作业中所有相关联的进程提供限制。它同时也记录了作业中所有相关联(包括那些相关联但是已经停止的)的进程的基本统计信息。从某些方面来说,作业对象是对Windows2000系统缺少一个结构化的进程树的一个补偿——尽管在许多方面这种方法比UNIX式的进程树强大的多。 你会在第六章中了解到更多关于作业,进程,线程的内部结构,以及关于进程的结构,线程的建立和线程的时间排序方面的算法。
虚拟内存Windows2000的虚拟内存系统是基于一个平面(线性的)32位地址空间上的。32位的地址能对4GB的虚拟内存进行译码。对于在大多数系统,Windows2000将其中一半的内存地址(4GB虚拟内存空间中低地址的一半,从00000000H到7FFFFFFFH)分配给进程作为他们的私有存储空间,而把另一半(4GB虚拟内存空间中高地址的一半,从80000000H到FFFFFFFFH)作为操作系统受保护的内存来使用。这些地址中的低一半被映射到每一个正在运行中进程的虚拟地址空间中,而高一半的地址映射则一直作为操作系统虚拟内存的组成部分。Wiundows2000高级服务器和数据服务器支持一种启动时选项(在Boot.ini中以 /3GB限定符出现),它提供那些正在运行被特定标识的程序(一个在可执行部分的头部有一个大地址空间标识符的程序)一个3GB的私有内存地址空间(留下1GB的空间给操作系统)。这个选项允许诸如数据库服务程序的应用程序能够在内存中保留更大的数据库分区,由此减少数据库索引的访问需要。图1-2描述了Windows2000支持的两种不同的虚拟内存地址布局。
图1-2 Windows2000所支持的两种地址布局 尽管3GB的空间比2GB的空间要好得多,但是对于一些非常大的数据库(比如上兆GB的),这些空间仍然不够。为了得到足够的地址,Windows 2000使用了一种被称为地址窗口扩展的结构,它允许一个32位的应用程序使用最多64GB的物理内存空间,并把它们的窗体或者视图映射到它的2GB虚拟地址空间中去。尽管使用AWE是将管理虚拟地址映射和物理内存的工作交给了程序员,它确实解决了应用程序需要一次性直接访问大于32位进程地址空间的物理内存紧迫需求。而解决内存地址空间限制的长期解决方案是64位的Windows操作系统。 一个进程虚拟地址空间是一个可以提供给进程所属线程使用的地址的集合。虚拟内存提供了一个内存的逻辑视图,它有可能与物理内存的实际布局是不同的。在运行的时候,内存管理器在硬件的辅助下,将虚拟地址转换或者映射到实际保存数据的物理地址上。通过控制保护和映射,操作系统可以保证单个的进程不会影响到别的进程,也不会覆盖了操作系统所需的数据。图1-3描述了三个在虚拟地连续的页在物理内存中被影射到三个不连续的页中的情况。
图1-3将虚拟内存映射到物理内存中
由于绝大多数系统中的物理内存比正在给运行中的进程使用的虚拟内存(每个进程可以有2GB或者3GB的空间)要小得多,内存管理器将会将某些内存中的内容转换或者调页到磁盘上。将不用数据从内存中调度到磁盘上,从而释放物理内存供其他进程或者操作系统本身使用。当一个线程访问到一个已经被保存在磁盘中的虚拟地址的时候,虚拟内存管理器会把它的信息从磁盘中读入内存。应用程序并不需要为了适应页式调度做任何的改变,因为硬件支持允许内存管理器在没有进程(线程)辅助的情况下使用页式调度。
关于内存管理器的实现的细节(包括地址是如何转换以及Windows2000如何管理物理内存)将在第七章中描述。 |
|
|||||||||
|
|