zoukankan      html  css  js  c++  java
  • ZOJ 3505. Yet Another Set of Numbers 解题报告

        ZOJ 3505:Yet Another Set of Numbers

        地址:http://acm.zju.edu.cn/onlinejudge/showProblem.do?problemCode=3505

        题意:有一个数字集合,集合中的数遵循以下规则:

        ( 1 ). 每个数字的最高位不是 0 ;

        ( 2 ). 每个数字包含最多 N 位,且只有 0,1,2,3 这四个数字可能出现(0 < N < 20);

        ( 3 ). 每个数字的相邻位不同(例如:301是有效的,300不是);

        ( 4 ). 数字比较大小和他们的字符串比较方法相同(例如:1 < 123 < 20 < 21 < 3).

        问题是,给出这个集合中的一个数字 B,从数字 B 开始,在这个集合中向前数数到第 K 个数字时输出这个数字(K > 0);

        原文叙述:求满足以下条件的数字A:在集合中恰好有 ( K - 1 ) 个数字大于 A 小于 B (题目保证存在解);

        时空限制:Time Limit: 2 Seconds;  Memory Limit: 65,536 KB;

        例如:当 N = 2 时,这个集合中一共有 12 个数字,依次是:

        1 < 10 < 12 <  [ 13 ] < 2 < 20 < 21 < 23 < [ 3 ] < 30 < 31 < 32;   ----(序列中A和B用方括号标示)

        当 B = 3 时,K = 5 时,从集合中的 3 向前数到第 5 个数字时是 13,因此应该输出 13。

        (1)第一个解法,模拟法(Result:TLE)

        我想到的第一个解法是,观察这个序列的规律,然后用模拟数数的方法,依次向前数,数到第 K 个为止即可。

        考虑集合中最大的一个数字的字符串表示,显然是以下形式 "3232..."(长度为 N)。

        因此可以给出下面的模拟数数的第一个解,模拟法代码如下:  ( X. 以下代码算法时间超出题目限制 )

    zoj3505_analog(TLE)
    #include <stdio.h>
    #include <string.h>

    int length; /*当前数字的字符串长度*/
    char Num[24];

    /*检测当前数字是否合法*/
    int IsNumValid()
    {
    int i;

    // 第一位不能为0
    if(Num[0] == '0')
    return 0;

    //相邻位不能相同
    for(i = 0; i < (length - 1); i++)
    {
    if(Num[i] == Num[i+1])
    return 0;
    }
    return 1;
    }


    void GetNextNum(int N)
    {
    char _MaxChars[4] = "32";
    int i;
    do
    {
    /* 如果最后一个字符是0,则去掉结尾的0 */
    if(Num[length - 1] == '0')
    {
    Num[length - 1] = 0;
    length--;
    continue;
    }

    //ELSE: 当前字符递减后,立即扩展到 N 的长度
    Num[length - 1] = Num[length - 1] - 1;
    for(i = length; i < N; i++)
    {
    Num[i] = _MaxChars[ (i - length) & 1 ];
    }
    length = N;
    }
    while( !IsNumValid() );
    }


    int main(int argc, char* argv[])
    {
    int N = 0; /*字符串最大长度, 0 < N < 20 */
    int K = 0; /*往下数第K个数字*/
    int index; /*往下数多少个*/
    while(scanf("%d %d", &N, &K) != EOF)
    {
    scanf("%s", Num);
    length = strlen(Num);
    for(index = 0; index < K; index++)
    {
    GetNextNum(N);
    }
    printf("%s\n", Num);
    }
    return 0;
    }


        但第一个方法提交后超时。显然,如果 N 比较大,这个集合中的数字数量是非常大的,因此这时 K 也可以取得很大。而这个方法是一个笨方法,是一个一个数字向前找,且还要进行数字合法校验,时间效率很低。当 N 和 K 都比较大时,这个方法在速度上显然不能满足要求。因此不能使用笨方法,必须找出这个序列中蕴藏的内在规律。

        (2)速度更快的解法:使用三叉树模型(Result:MLE)

        观察这个序列的规律,考虑数字用字符串进行比较的特点,比较时,越高(靠左)的位越重要,越低的位则不重要。因此考虑把每一位看作一个节点,则低位属于高位的子节点,父子节点彼此属于相邻的位,显然有每个节点含有三个子节点(由于每一位上只能在 0,1,2,3 中选择,且不能与父节点数字相同),因此形成一个满的三叉树。

        每个节点代表了序列中的一个数字,把从根节点到该节点的路径所经过的节点依次输出,就是这个数字的字符串表示。当 N = 2 时,该树如下图所示:

        

        

        

        题意中的规则(2)规定了树的高度,规则(1)和(3)使该树从完全四叉树变成完全三叉树,规则(1)去掉了“0” 子树,规则(3)缩减了子节点个数。

        考虑两个树节点 N1 和 N2 之间的排序关系(比较时,深度小的节点比深度大的节点重要),注意和字符串比较算法进行对比:

        (a)如果 N1 节点位于 N2 的路径上,则路径短的节点小;( eg. 123 < 1230 )

        (b)如果 N1 和 N2 在节点 N' 处开始分离(从根节点到 N’ 处路径重合),则进入 N' 左侧子树的小于进入 N' 右侧子树的。( eg. 121230 < 123 )

        (c)从前两点可知,此树前序遍历(先遍历当前节点的所有子节点,然后访问当前节点),即为数集合的有序排列。(对比:二叉查找树的中序遍历为有序序列)

        图中节点圆圈中的黑色数字就是用于组成输出数字的位(num)。为了统计树中每个子树所代表的子序列中的数字个数,我们在每个节点上加一个属性 sum,表示以该节点为根的子树所表征的数字组成的子序列的数字个数,在图中用节点旁边的红色数字表示。显然,节点的 sum,就是以该节点为根的子树所包含的所有节点的数目,因为每个节点都一对一的代表集合中一个数字,用节点路径表示(类似贪心算法中的霍夫曼编码和霍夫曼树的关系)。由于输出节点路径时,根节点无需输出 ( dummy node ),因此根节点用阴影填充,和子节点的连线用虚线表示。

        显然,最底层的所有叶子节点(深度为 N + 1)仅代表一个数字,其 sum = 1,对非叶子节点,其三个子节点的 sum 值相同,再加上自身节点代表的数字,因此有:

        Node.sum = Node.Child[0].sum * 3 + 1 ;

        因此对于本题目来说,可以创建这样一个三叉树,对于给定的数字 B,设它对应的是树中的节点 B,从 B 节点开始,向前回退寻找目标节点 A。

        为了清楚的描述算法,我用节点游标的形式来阐述该算法(在代码中可以是一个节点的指针,表示当前所处的位置),并做如下定义:

        (a). K 的逻辑含义是,代表当前节点和目标节点 A 的“距离”。寻找过程中 K 的值将不断减小,直到为 K = 0 ,表示到达目标节点A。

        (b). 游标指向的改变称为 “移动到” 某个节点。

        (c). 如果节点需要处理(即调整 K 值),称为“访问” ( visit ) 该节点。

        (d). 如果节点不需要处理(即不需要调整 K 值),称为“经过” ( pass )  该节点。

        则算法(2)的基本步骤(GetNodeBySteps )如下:

        (2.1). 令游标指向起始节点 B; (这是准备工作,接下来将“向前”寻找节点 A)

        (2.2). 如果所在节点已经是兄弟节点中的老大(位于最左侧),则(向上)“访问”其父节点,令 K = K - 1;  跳到(2.6); (请思考为什么此处 K 的递减值是 1 而不是 Node.sum?)

        (2.3). 如果所在节点有哥哥节点,则(向左)移动到它的哥哥节点; (接下来在 (2.4)和(2.5)中一定能找到一个可访问节点,请思考这是为什么?)

        (2.4). 如果 Node.sum <= K ,则“访问”该节点,令 K = K - Node.sum,跳到(2.6); (备注:Node.sum < K 时说明 A 不位于该节点为根的子树中)

        (2.5). 如果 Node.sum > K,(备注:说明 A 位于 Node 为根的子树中) 则连续的(向下)移动到当前节点的(最右侧)最幼子节点,直到找到符合(2.4)中的条件(Node.Sum <= k)的节点为止,然后用和(2.3)的相同方法“访问”它:令 K = K - Node.sum; (这是一个嵌在内部的 while 循环)

        (2.6). 如果 K != 0,则重复(2.2)~ (2.5)。直到 K = 0 为止,此时我们到达的节点就是目标节点 A,输出节点 A 的路径就是问题的解;(这是最外层的 while 循环)

        总结上面的探查方法:如果一个节点是被访问节点,则绝不会再进入它的任何子树;当寻找下一个可访问节点时,如果左侧有哥哥节点,则一定先探查哥哥节点(如果哥哥节点没有超越目标,则访问它;否则进入它的幼子子树继续探查),否则访问父节点(此时父节点必是可访问节点)。

        上面的语言接近伪码,括号内的描述属于导航时的逻辑方向或备注性说明。显然(2.2)~(2.6)整体是一个 while 循环,其中(2.5)是嵌在其内的又一个 while 循环。上诉步骤即对应的是代码中的 GetNodeBySteps 方法,参数 step 即 K。 

        【思考】:为何(2.2)向父节点回退时的访问和(2.4)和(2.5)中的访问方法不同,前者令 K 递减 1,后者令 K 递减 Node.sum

        (提示:前者,当子节点向父节点“回退”时,子节点为根的子树位于其父节点所代表的子树中。后者则进入到了另一个独立的子树。)

        第二个解法的代码(三叉树方法),如下所示:  ( X. 以下代码内存使用超出题目限制 )

    zoj3505_trian_tree(MLE)
    #include <stdlib.h>
    #include <stdio.h>
    #include <string.h>

    typedef struct _NODE
    {
    char index; /* 在兄弟节点中的排行索引 */
    char num; /* 节点数字, 只能取下列值 0,1,2,3,为 'R' 时表示 root */

    int sum; /* 1 + 3 + 9 + ... + 3^k */

    _NODE* parent; /* 父节点 */
    _NODE* child[3]; /* 子节点 */
    } NODE, *PNODE;

    /* 递归函数: N - 树的深度(不含根节点的深度) */
    void AddChildren(PNODE pNode)
    {
    int i, _num = 0;;
    PNODE pChild = NULL;
    pNode->sum = pNode->sum * 3 + 1;

    if(pNode->child[0] == NULL)
    {
    for(i = 0; i < 3; i++)
    {
    pChild = (PNODE)malloc(sizeof(NODE));
    memset(pChild, 0, sizeof(NODE));
    pChild->parent = pNode;
    pChild->index = i;
    pChild->sum = 1;

    /* 回避父节点数字 */
    if(pNode->num == _num) _num++;
    pChild->num = _num++;

    pNode->child[i] = pChild;
    }
    }
    else
    {
    for(i = 0; i < 3; i++)
    AddChildren(pNode->child[i]);
    }
    }

    PNODE CreateTree(int depth)
    {
    int i;
    PNODE pRoot = (PNODE)malloc(sizeof(NODE));
    memset(pRoot, 0, sizeof(NODE));
    pRoot->num = 0; /*根节点数字为0,可以让第一层子节点为1,2,3 */
    pRoot->sum = 1;
    for(i = 0; i < depth; i++)
    {
    AddChildren(pRoot);
    }
    pRoot->num = 'R';
    return pRoot;
    }

    /* 释放树占用的内存(递归函数) */
    void FreeTree(PNODE pNode)
    {
    int i;
    for(i = 0; i < 3; i++)
    {
    if(pNode->child[i] != NULL)
    {
    FreeTree(pNode->child[i]);
    }
    }
    free(pNode);
    }

    /* 从节点 pStart 向前追溯到某个节点 */
    PNODE GetNodeBySteps(PNODE pRoot, PNODE pStart, int step)
    {
    int remainStep = step;
    int index;
    PNODE pCurNode = pStart, pNextNode = NULL;

    while(remainStep > 0)
    {
    /*找到下一个不大于 remainStep 距离的节点*/
    index = pCurNode->index;
    if(index == 0)
    {
    /* 向上返回一层时,只-1 */
    pCurNode = pCurNode->parent;
    remainStep--;
    continue;
    }
    pNextNode = pCurNode->parent->child[ index - 1];
    while(pNextNode->sum > remainStep)
    {
    /* 向下进入最右侧的最小子节点分支 */
    pNextNode = pNextNode->child[2];
    }
    pCurNode = pNextNode;
    remainStep -= pCurNode->sum;
    }
    return pCurNode;
    }

    /* path: 字符串形式例如“12301” */
    PNODE GetNodeByPath(PNODE pRoot, char* path)
    {
    int i;
    char *p = path;
    PNODE pCur = pRoot;
    while(*p)
    {
    for(i = 0; i < 3; i++)
    {
    if(pCur->child[i]->num == (*p - '0'))
    {
    pCur = pCur->child[i];
    break;
    }
    }
    ++p;
    }
    return pCur;
    }

    /* 借助辅助栈,输出找到的节点的路径(从根节点到该节点)*/
    void OutputNodePath(PNODE pNode)
    {
    char stack[24];
    int top = 0;
    PNODE pCur = pNode;
    while(pCur->parent != NULL)
    {
    stack[top++] = pCur->num;
    pCur = pCur->parent;
    }
    while(top > 0)
    {
    printf("%d", stack[ top - 1 ]);
    --top;
    }
    printf("\n");
    }

    int main(int argc, char* argv[])
    {
    int depth, step;
    char path[24];
    PNODE pRoot, pStart, pDest;
    while(scanf("%d %d\n", &depth, &step) != EOF)
    {
    gets(path);
    pRoot = CreateTree(depth);
    pStart = GetNodeByPath(pRoot, path);
    pDest = GetNodeBySteps(pRoot, pStart, step);
    OutputNodePath(pDest);
    FreeTree(pRoot);
    }
    return 0;
    }

        【分析】为什么算法(2)比算法(1)快?

        算法(1)永远是一个一个数字向前数,步进值(step)永远为 1 ; 当初我想改进算法(1)时发现,如果不借助数据结构,很难在原基础上进行改进。集合中的数也很难用数学公式计算或总结(这也是采用模拟法的原因)。

        算法(2)是在树中导航,注意树节点越往上(深度越小),节点的 sum 值越大(而且是随着深度减小,以极快的指数速度增长),所以算法(2)的数数方式是跳跃式的,而且能用很快速度探索到一个理想的 Step 值(该 Step 足够大,又满足 Step <= K ,即向目标迈进的步子足够大,而又不会超越目标节点),该 Step 由树节点的 sum 值表示,然后直接越过该子树所代表的子集合,无需遍历该子集合(所有数字或树节点)。因此访问的节点在树中的深度越浅(越靠近树根),则以该节点为根的子树的规模就越大,代表着跨越的数字个数就越多。由于每个节点最多只有两个哥哥节点,在同一深度向前移动时所消耗的时间不多,因此算法能以很快的速度上升到跨度尽可能大的可访问节点。因此算法(2)在时间上比算法(1)高效的多。

        

        算法(2)的另一种形象理解是:

        B > A,则水平方向 B 位于 A 子树的下方或 A 的右侧子树。则存在一个深度最大(设深度为 d )的公共子树 T ,满足 A, B ∈ T 。

        现在从节点 B 出发,寻找节点 A。由于 A 比 B 小,因此 B 不可能是 T 的根。

        (a). 如果 A 不是 T 的根,则必然 A 和 B 分别位于 T 的左右两个子树。因此从 B 所在的右侧子树上浮到 (d+1) 深度后,继续向前移动到 A 所在的左侧子树,然后靠右侧下沉到 A 。(搜寻路线类似梯形)

        (b). 如果 A 是 T 的根,则靠左上浮到 A 。

        上诉两种情况,在水平方向上在各个深度上都可能呈现一些向左的水平步进。因此在 向前向上 探寻时,探寻路径呈“锯齿形”。向下探寻时则是“直线”式下降。对平均情况,探索路径的长度(时间)和树的高度 N 为线性关系。

        

        

              Figure 1. 节点 B 到节点 A 的探寻路径;

        如 Figure 1 所示。图中红色虚线箭头相连得到的路径,即为从 B 到 A 的探寻路径。图中 A 和 B 的最深公共子树 T 是数字“10”所在节点,同时 T 被用粗线表示。灰色节点为可访问节点,斜线表示的节点为位于搜索路线上,但是被经过(Pass)的节点。图中 N = K = 4;从 A 到 B 的序列是:{ 1021, 1023, 103, 1030, 1031 } ;

        【缺陷】但算法(2)的结果是内存超出限制(MLE)。理由实际上同(1),当 N 很大时,树非常庞大。由于它是一个满三叉树,所以节点数量是一个等比数列的和:

        Sum  =   ( 3 ^ i )  =  1 + 3 + 9 + 27 + ... + 3^N  =  ( 3 ^ ( N + 1) - 1 ) / 2

            ----( 高度为 N + 1 的三叉树的所有节点数量 ) ;

        

        从上式粗略估算可知内存需求极大,产生 MLE 也就不足为奇了,考虑极限情况:

        

        假设 N = 19,则此树共含有 1,743,392,200 个节点。设每个节点占用 24 Bytes 则:在内存中建立该树需要:38.97 GB !!! @_@ ...

        

        (1)和(2)的解法一个超时一个超内存限制,都是因为该集合的元素数量非常大导致的。既要利用(2)中的三叉树模型的时间效率,但又不能采用在内存中完整建立树的方法,因此导出下面的第三个解法。

        (3)正确解:虚拟树 / 树枝法(Result:AC)

        

        为了时间效率,依然采用三叉树的模型,但是不能在内存中完整建树,因此启发我们,可以在内存中仅仅保留一条树枝,该树枝最长的长度(叶子在树中所处的深度)为 N。考察解法(2)中的方法,无非是在树之间进行导航,即从某个节点到其父节点,左侧的哥哥节点,右下方的幼子节点。同时,由于此树满足,相同深度的所有节点的 sum 值相同。因此我们可以仅用一条树枝即完成(2)中的算法。

        方法是:在内存中不建立整个树,而是仅仅存储一个当前树枝。树枝长度可伸缩,最大为N。树枝采用一个线性表结构保存,可以使用数组或链表(为了代码简便我们使用数组)。每个节点依然含有两个属性: num 和 sum。如果说算法(2)中的树是内存中的实际树(物理树),则算法(3)中的树是仅存在于思维模型中的“虚拟树”。

        一个树枝在树中的位置,以及它在数组中的存储状态如下图所示,图中树枝部分用实体粗线条表示,整个树由于不在内存中,因此用虚线表示:

        

        为了实现算法,我们必须很容易的确定该树枝在完整的树模型中的位置和形态。即容易实现以下问题:

        (3.1)求出某个节点在同辈兄弟中的排行索引( Index_In_Siblings ) ,∈ { 0,1,2 } ;

        (3.2)求出某个节点的哥哥节点(左侧)的 num ( Num_Of_Elder_Brother ),∈ { 0,1,2,3 } ;

        (3.3)求出某个节点的幼子节点(右下)的 num ( Num_Of_Right_Child ),∈ { 0,1,2,3 } ;

        其中(3.1)和(3.2)可以通过本节点的 num 和其父节点的 num 推断得出。(3.3)可以从本节点的 num 求出。

        算法执行过程中,随着游标在树中的节点之间移动,树枝在树中的伸展形态(在树中的位置和树枝的长度)也同时发生变化,当寻找到目标节点 A 以后,树枝静止在最终状态,根据方法返回的树枝长度即可确定最终的树枝。目标节点 A 即为最终树枝的末端叶子节点。

        因此解法(3)三叉树树枝法算法与(2)相同,但解决了空间复杂度的问题,代码如下:  ( √. 以下代码是可接受的 )

    zoj3505_branch(AC)
    #include <stdlib.h>
    #include <stdio.h>
    #include <string.h>

    typedef struct _NODE
    {
    int num; /* 节点数字, 取值 0,1,2,3 */
    int sum; /* 1 + 3 + 9 + ... + 3^k */
    } NODE, *PNODE;

    NODE nodes[20]; //树枝

    //初始化树枝各个节点的 sum 值
    void InitSums(int N)
    {
    int i;
    //叶子节点
    nodes[N-1].sum = 1;
    for(i = N - 2; i >=0 ; --i)
    {
    nodes[i].sum = nodes[i + 1].sum * 3 + 1;
    }
    }

    //初始化树枝各个节点的 num 值
    //path: "1231"

    int InitNodesByPath(char *path)
    {
    int depth = 0;
    char *p = path;
    while(*p)
    {
    nodes[depth].num = (*p - '0');
    ++depth;
    ++p;
    }
    return depth - 1;
    }

    //输出指定深度节点的路径(从根节点到该节点)
    void OutputNodePath(int depth)
    {
    int i;
    for(i = 0; i <= depth; i++)
    {
    printf("%d", nodes[i].num);
    }
    printf("\n");
    }

    //获取节点在兄弟节点中的排行索引
    int GetIndexInSibling(int depth)
    {
    if(depth == 0)
    return (nodes[depth].num - 1);
    else if(nodes[depth-1].num > nodes[depth].num)
    {
    return nodes[depth].num;
    }
    else
    {
    return nodes[depth].num - 1;
    }
    }

    //返回哥哥节点的数字
    int GetBrother(int depth)
    {
    int result = nodes[depth].num - 1;
    if(depth > 0 && nodes[depth-1].num == result)
    {
    --result;
    }
    return result;
    }

    //返回幼子的数字
    int GetRightChild(int depth)
    {
    int result = 3;
    if(nodes[depth].num == 3)
    result = 2;
    return result;
    }

    //从节点A开始向前追溯到节点B,返回对应深度
    //nStart: 节点A的深度
    //step:节点A和B之间的距离

    int GetNodeBySteps(int nStart, int nStep)
    {
    int remainStep = nStep;
    int depth = nStart;

    while(remainStep > 0)
    {
    //回退到父节点
    if(GetIndexInSibling(depth) == 0)
    {
    //向上返回一层时,只-1
    depth--;
    remainStep--;
    continue;
    }
    //进入哥哥节点
    nodes[depth].num = GetBrother(depth);
    while(nodes[depth].sum > remainStep)
    {
    //向下进入最右侧的最小子节点分支
    nodes[depth + 1].num = GetRightChild(depth);
    depth++;
    }
    remainStep -= nodes[depth].sum;
    }
    return depth;
    }

    int main(int argc, char* argv[])
    {
    int N, K;
    char path[24];
    int nStart, nDest;

    while(scanf("%d %d\n", &N, &K) != EOF)
    {
    gets(path);
    InitSums(N);
    nStart = InitNodesByPath(path);
    nDest = GetNodeBySteps(nStart, K);
    OutputNodePath(nDest);
    }
    return 0;
    }

        以上代码提交后的结果是 AC。运行消耗是时间 120 ms,空间 180 KB。空间需求微不足道可以说是O(1),但用时是解排行榜单上的其他 C++ 优秀解法用时(40ms ~ 60ms)的两三倍。一可能是我用了很多很小的函数,没有inline,多少带来一些函数调用的开销,二是可能别人的算法要比我更快更好。

        【分析】为什么可以使用树枝的方法?

        此题目中,树的规模非常大,所以逼迫我们使用了树枝法,那么为什么可以不在内存中建立树呢,这是因为树自身具有的内在规律,即节点不是完全随机和独立的,而是存在下面的关系,假设树根深度为 1,树的高度(叶子节点的深度)为(N + 1),则深度为 d 的节点:Node.sum = ( 3 ^ (N + 2 - d ) - 1 ) / 2;  对于非叶子节点,有三个子节点,按升序排列并与父节点不重复。因为有了这些已知条件,使我们能推断出我们需要的节点的信息,因此不需要一个完整的树来存储它。

        【说明】本文所有插图,使用 Microsoft Office Visio 绘制。

  • 相关阅读:
    leetcode------Search in Rotated Sorted ArrayII
    leetcode------Search in Rotated Sorted Array
    leetcode------Multiply Strings
    leetcode------Integer to Roman
    IIS7.5 自定义Html/shtml/htm...后缀映射
    [错误]试图加载格式不正确的程序。 (异常来自 HRESULT:0x8007000B)
    FolderBrowserDialog 成员
    [转载]DirectoryEntry配置IIS7出现ADSI Error:未知错误(0x80005000)
    C# textbox 滚动条 随文本输入 滚动
    C#文件对话框,一次多选文件设置
  • 原文地址:https://www.cnblogs.com/hoodlum1980/p/2243839.html
Copyright © 2011-2022 走看看