目 录
第1章 入门
第2章 必读!绝妙技巧
第3章 短码编程研究
第4章 语言对决
第5章 磨练自己
附录
A.1 问题一览
A.2 ASCII码表
A.3 运算符的优先级与结合规则
3.1 更高的编程技巧
3.1.1 开始
在第2章中,我们从一般的问题解决方法向前迈进了一步,从而成功地编写出了异常短的代码。本章讲述的技术是作者日夜苦思,加上从很多顶级的短码编程者那里获得智慧,并不断地尝试,通过大量的时间积累获得的内容。短码编程的世界是很深奥的,还存在很多未被发现的知识或技术。因此,要重新认识代码的整体结构和常见的架构。本章介绍在短码编程中一些很有趣的问题。
3.1.2 关于表示法
第2章把能在POJ系统上运行作为前提条件,因此使用的编译器是以C、GCC、C++、G++、Pascal、Java来表示的。第3章仍旧如此,会以C或GCC这种大写英文字母缩写代表POJ系统环境上的编译器。然而本章的内容不仅限于POJ系统,也包含更一般的短码编程环境。因此,需要区分POJ环境上的GNU C/C++编译器和平常使用的GNU C/C++编译器。如果指的是平常使用的GNU C/C++编译器,不限定POJ环境,会用GNU C和GNU C++来区别。另外,在提到C编程语言的时候,为了区别于POJ编译器,会改称“C语言”。
3.2 精简循环
3.2.1 精通之后就能成为顶尖的短码编程者
精简循环是指把多个循环精简成一个循环的技巧。到底能把循环(短码编程时主要是用for循环)缩到多短,也是检验短码编程能力的一个指标。迄今为止,读者也看到了几个把两层循环用一层循环替代的例子。在精简循环的时候,根据问题固有的条件和处理系统,有可以精简的也有不可以精简的。简单地替换,代码不一定会缩短。在本章中,我们将向大家介绍一些成功地把多个for循环精简成一个循环的例子。
3.2.2 简单的例子
1. 分离型循环
首先,用一个简单的例子来说明一下怎样把完全分离的两个循环精简成一个循环。下面展示的代码中,第1个循环用于输入字符串,第2个循环是用于输出整数值。
#include < stdio. h>
char v[4] [4] = { "ABC", "DEF", "GHI", "JKL" };
main ()
{
int i, j;
const int m=4, n=3;
for(i=0;i<m;++i)
puts(v[i]);
for(j=0;j<n;++j)
printf("%d\n",j);
}
★--------------★
执行结果
ABC
DEF
GHI
JKL
0
1
2
★--------------★
如果是完全分开的两个for循环(各个循环的计算和变量相互完全不影响),使用条件运算符就能够很容易地精简成一个for循环。
---------------------
for(i=j=0;i<m||j<n;)
i<m?puts(v[i]),++i:(printf("%d\n",j),++j);
---------------------
终止条件的部分是用逻辑OR运算符把两个循环的终止条件合在一起,并在循环内继续使用条件运算符,判断第一个循环是否终止。
虽然只是简单地变换,但本来是两个循环,却强制精简成一个循环。结合之后的代码看起来比最初的代码还要冗长。精简循环较难的地方就是,直接合并后反而会变得更难缩短。从这里开始,必须观察问题与执行环境的条件,将二者结合起来缩短代码,最后要比较精简后的代码和原有代码的长度。或许能缩短,或许不能缩短,但是一定要有能够继续缩短的信念。
接着再来关注一下这段代码。目前判断循环结束和输出处理是分开的。由于执行输出处理的时候,也会判断循环是否结束,所以先把这部分合并看看。
--------------------------
for(i=j=0;i<m?puts(v[i]),++i:j<n?printf("%d\n",j),++j:0;);
--------------------------
判断循环终止的表达式和输出处理合并,就能将代码缩短到同原始代码相近的字节长度。虽然执行这段代码能得到正确的输出结果,但是还有一个必须要注意的地方。例如,为了将这段代码进一步缩短,我们写成下面这样。
-------------------------
for(i=j=0;i<m?puts(v[i++]):j<n?printf("%d\n",j++):0;);
-------------------------
乍一看,好像没有什么问题,但是在puts函数返回0的时候,根据条件分支,调用puts函数,表达式的整体值就会成0(不成立),就会跳出循环 。
刚才的代码,在调用输出函数后,对i和j做了前置的递增运算,因此整个表达式计算后,可以确保是大于1的值(成立)。如果是在puts函数永远返回0的环境里,就需要写成下面这样:
-------------------------
for(i=j=0;i<m?!puts(v[i++]):j<n?printf("%d\n",j++):0;);
-------------------------
在不确定返回0还是非0的情况下,需要写成下面这样:
---------------------------
for(i=j=0;i<m?puts(v[i++]),1:j<n?printf("%d\n",j++):0;);
---------------------------
如果想要合并的for循环超过3个,就需要增加更多的条件分支,因此对于所有情况都需要仔细检查。条件分支越多对执行速度的影响也会越大。应用这个技巧以后,就会使短码编程的难度变得更大。但是,这种技巧在大多数情况下也是消除字节数的唯一手段,所以希望大家一定要熟练掌握这个技巧。
2. 嵌套循环
接着试试把两个嵌套的for循环精简成一个循环。首先,我们来写一个类似分离型循环的例子。
--------------------------
#include<stdio.h>
char v[4][4] = { "ABC", "DEF", "GHI", "JKL" };
main ()
{
int i, j ;
const int m=4, n=3;
for(i=0;i<m;puts(v[i++]))
for(j=0;j<n;)printf("%d\n",j++);
}
--------------------------
★--------------------------★
执行结果
0
1
2
ABC
0
1
2
DEF
0
1
2
GHI
0
1
2
JKL
★--------------------------★
嵌套循环经常要定时初始化变量,所以精简成一个for循环的时候,考虑怎么样初始化变量就变成很重要的问题了。
---------------------
for(j=i=0;i<m;)
j<n?printf("%d\n",j++):(puts(v[i++]),j=0);
---------------------
如果注意i递增的条件,就会发现替换不是那么难。只是改写成这样,长度就会与最初的代码几乎相同。对于这段代码来说,还可以把j<n写成j>=n这样的逆转条件,重新改写可以去掉括号。
---------------------
for(j=i=0;i<m;)
j>=n?puts(v[i++]),j=0:printf("%d\n",j++);
---------------------
在此以后根据情况来缩短代码。
嵌套循环的精简会随着应用环境的不同而产生很大的差异,所以要列出一般的方法有些困难,但是“常用结构”还是存在的。如果把这个结构作为一种类型来学习,就会成为短码编程时的强大武器。接下来就介绍这个“常用结构”。
3.2.3 常用结构
分析了很多问题之后,就会发现输入数据的格式有几种固定类型。尽管缩短代码是短码编程的乐趣所在,但是,每次都考虑同样的问题也是件很无聊的事情。为了尽量保留精力挑战困难,还是应把基本部分的结构记下来。
3.2.4 短码编程的基本类型
大多数的问题都是下面这样:
(1) 读取输入数据;
(2) 处理;
(3) 输出。
如果输入数据有很多组,就是:
(1) 读取输入数据;
(2) 处理;
(3) 输出;
(4) 如果有下一组数据就返回到(1)。
第一种情况不需要反复读取数据,不需要重复(1)~(3)的操作,所以对代码也不需要做特殊处理。第二种情况在先前解说过的问题里面已经出现过几次了。如果有许多测试数据,则有时会在第一行提供测试数据的数量,有时不会。如果第一行提供测试数据数量,则有两种处理方法,一种是使用gets函数来读取并略过第1行,另一种是直接读进来处理但不输出。如果在输入数据中不包括测试数据个数,则会在所有数据的最后以0或?1这些特殊整数代表数据结束。考虑以上这些情况,就可以归纳一下经常出现的几种类型。
(I)第1行里包含测试数据个数的情况
--------------------
main()
{
for( gets(…); 读取并略过 ; 输出 ) 处理
}
--------------------
或者是
--------------------
main(i)
{
for( ; 读取 ; --i&&输出 ) 处理
}
--------------------
(II)第1行里不包含测试数据个数的情况
(i)基本
-----------------
main(n)
{
for( ; ~scanf("%d",&n) ; 输出 ) 处理
}
-----------------
(ii)包含表示输入结束标志0的情况
---------------------
main(n)
{
for( ; ~scanf("%d",&n),n ; 输出) 处理
}
---------------------
对于可以省略gets函数的参数的情况,像下面这样写能缩短代码。
---------------------
main (n)
{
for( ; n=atoi(gets()) ; 输出) 处理
}
---------------------
(iii)包含表示输入结束标志?1的场合
如果利用scanf函数在读取一个整数时会返回1的特性,就可以用?1+1=0(不成立)来跳出循环。
---------------------
main(n)
{
for( ; scanf("%d",&n)+n ; 输出 ) 处理
}
---------------------
像上面那样,只要通过输入格式掌握代码整体结构,就能缩短代码。接着我们就来探讨一下稍微复杂一点的数据格式。
3.2.5 重要的短码语法
在解答最初会提供测试数据个数的问题时,忽略数据个数是基本做法。那么对于一组测试数据需要读取许多数据的时候,情况又会怎么样呢?如果在一行里包含有多个数,第一个数代表数据总数,那么第一个值就作为读取剩余数据的计数器,具有非常重大的意义。比如,考虑下面这组输入数据。
★---------★
4 0 1 2 3
6 -2 12 3 1 9 11
★---------★
第一行的第一个数是4,代表后面会有连续4个数据。同样,第二行的第一个数是6,代表后面会有连续6个数据。如果忽略这两个数,我们就很难判断后面还需要读取多少个数据。因此,在这个情况下,自然会写成下面这样:
---------------
main(n)
{
for(;~scanf("%d",&n);)
{
for(;n--;)scanf(…);
…
}
}
---------------
但是,在短码编程中,要尽量避免重复的内容,这是不变的原则。如果调用两次scanf函数就会使代码变得冗长,所以为了避免这种情况,需要记住这种类型。
首先,要确认一下跳出第二个循环的条件。它对数据个数n进行递减计算,直到n=0的时候跳出循环。也就是说,在n=0的时候会把测试数据分成两部分。利用条件运算符n=0的时候读完数据做处理,然后在移到下一组测试数据之前进行初始化。这种类型可以写成如下代码。
----------------
int n; // 初始值是0
main(k)
{
for(;~scanf("%d",&k);n||处理)
n--?计算(初始化,n=k);
}
----------------
通过将条件运算符写成“n||输出”,就能在循环开始的时候略过输出。让两个循环与一个scanf函数合成一个,就能写出非常短的“类型”代码。在解决实际问题的时候,看看这种类型的应用吧。
3.2.6 取数字游戏
1. POJ No.2234 Matches Game
这是一个简单的游戏。桌上有几堆火柴棍,两个玩家依次从任意一堆中取走火柴棍。不限制每次取走火柴棍的数量,但是一次只能从一堆中取,而且至少要取走1根火柴棍,直到火柴棍被取光为止。最后取走火柴棍的玩家获胜。请写出程序,在两个玩家都用最佳的方法取走火柴棍的情况下,判断先取走火柴棍的玩家是否能获胜。
2. 输入和输出
输入数据由多行构成。每行都是一组测试数据。最初是火柴棍的堆数n,之后的n个整数则是每堆火柴棍的数量。如果先动手的玩家获胜就输出Yes,否则就输出No。
★----------★
输入
2 45 45
3 3 6 9
★----------★
★----------★
输出
No
Yes
★----------★
这组输入数据表示第一组有两堆火柴棍,里面都有45根火柴棍;第二组有一堆3根、一堆6根、一堆9根的火柴堆,共3堆火柴棍。比如在第一组里,如果最初第一步取走其中一堆中的全部45根,另一个玩家也可以同样取走剩下一堆中的45根火柴棍,那么第一次动手的玩家就会输。不管第一次取走几根,只要另一个玩家在另一堆取走同样数量的火柴棍,就必定会输。
3. 解答
这个游戏是称为Nim的数学游戏的一种,起源不明,但是至少有几百年的历史了。关于必胜法的研究在100年前就已经完成,对数学游戏感兴趣的人应该不会不知道Nim。因为在许多地方可以找到数学证明法,所以在这里只是讲解有关判定必胜与否的算法。
首先,用二进制数表示每堆火柴棍的根数。以上面的例子来说就是45, 45和3, 6, 9。把用二进制表示的各个位分别相加,再求除以2的余数。
---------------
45 1 0 1 1 0 1
45 1 0 1 1 0 1
各位的合计% 2 0 0 0 0 0 0
---------------
-------------
3 0 0 1 1
6 0 1 1 0
9 1 0 0 1
-------------
第一组的各位的合计除以2的余数是0,第二组是12。Nim的必胜判定就是看这个余数是否等于0。
--------------
#include<stdio.h>
int n,k;
main()
{
for(;~scanf("%d",&n);)
{
int t=0;
for(;n--;)
{
scanf("%d",&k);
t^=k;
}
puts(t?"Yes":"No");
}
}
--------------
“除2的余数”只是判断1bit部分的个数是奇数还是偶数,实际上除了可以进行余数计算之外,还可以通过对位进行逻辑异或运算求得。如果知道必胜的判定方法,问题就变得非常简单了。
4. 套用短码写法
现在就根据前面提到的短码写法,对解答中的代码进行缩短。套用下面的类型:
-------------------
for(;~scanf("%d",&k);n||输出)
n--?处理:(初始化,n=k);
-------------------
换成下面的写法:
-------------------
n;
main(k,t)
{
for(;~scanf("%d",&k);n||puts(t?"Yes":"No"))
n--?t^=k:(t=0,n=k);
}
-------------------
令人惊奇地简洁,能够缩短到75字节。再稍微下点工夫,就能完成最短的72字节的代码。
------------------------
n;
main(k,t)
{
for(;~scanf("%d",&k);n||puts(t?"Yes":"No"))
t^=!n--?n=k,t:k;
}
------------------------
反转n--部分的真假后,进一步省略了(),接着将第二项t用t^=t 进行初始化。如果像上面这样,记住缩短代码的类型就能更快速地缩短代码。
3.2.7 葡萄酒买卖
1. POJ No.2940 Wine Trading in Gergovia
在一条街上有许多房屋,每间屋子里都住着人,并且都是做葡萄酒生意的商人,他们每天都要决定买卖多少瓶葡萄酒。有趣的地方是,供需总是完美地一致。商人总是能买到自己需要的葡萄酒,并且,他们从来不介意是从哪个商人那里购入的,只要求葡萄酒的搬运时间越少越好。如果把一瓶葡萄酒搬运到隔壁的成本是1,请求出全部葡萄酒买卖的最低搬运成本。
2. 输入和输出
输入数据由多行构成,每两行是一组测试数据。第一行是整条街上的店铺数n(2~100 000),第二行是n个整数,代表每间店面希望买卖的葡萄酒瓶数(?1 000~1 000),葡萄酒的瓶数为正值表示买进,负值表示卖出。输入数据的最后以0间店面做结束。请输出最小运送成本。
★------------------★
输入
5
5 -4 1 -3 1
6
-1000 -1000 -1000 1000 1000 1000
0
★------------------★
★------------------★
输出
9
9000
★------------------★
3. 解答
由于居民之间没有买卖限制,所以从左侧开始可以简单地求出买卖成立的最低运送成本。以第一组测试数据来说,第一间店铺想买入5瓶葡萄酒,假设隔壁就有5瓶葡萄酒要卖,也需要5份的运送成本。因此,先把搬运成本设置成5。由于第二间店铺想卖出4瓶葡萄酒,所以让4瓶葡萄酒交易成立。
总搬运成本加上剩下1瓶没有交易成功的运送成本变成6。第三间店铺想买入1瓶,所以至少需要1瓶的运送成本,因此,加上前面剩下还没有交易的1瓶,总共要加上2瓶的运送成本,这样就变成6 + 2 = 8。第四间店铺想卖出3瓶,所以其中2瓶的交易就成立了。剩下1瓶想卖出的葡萄酒就送到隔壁第五间店铺,需要加上1瓶的运送成本,最终结果就变成8 + 1 = 9。
如果稍微简单一点说,就是只要注意店铺间搬运葡萄酒的瓶数就可以了。所以用这种方法试着写一个基本代码:
-------------
#include<stdio.h>
double cost;
int a,b,n;
main()
{
for(;scanf("%d",&n),n;)
{
cost=b=0;
for(;n--;)
{
scanf("%d",&a);
b+=a;
cost+=abs(b);
}
printf("%.f\n",cost);
}
}
--------------
n最大是100 000,如果所有店铺都想交易1 000瓶,就不能放进32位整数了,所以就要用64位整数 或者double类型的变量。依次把输入的值进行加法运算,最后的绝对值就是运送成本了。
4. 应用短码结构
虽然输入数据的格式同前面的问题很相似,但是这次最后含有表示输入数据结束的0。所以要注意这一点,看一看缩短解答的代码吧。
-------------------------
double c;
b,n;
main(a,)
{
for(;~scanf("%d",&a);n||c&&printf("%.f\n",c))
n--?c+=abs(b+=a):(c=b=0,n=a);
}
-------------------------
利用n=0的时候最小成本c也是0的特点,以前的所有语法基本都适用。这种类型的场合,可以利用c=0的条件,这样不仅能避开最后输出数据,还避开最初输出数据。这样一来,就能将条件判断部分和难分离的输出部分合并到一起了。另外,由于b的值在处理结束后,合计值一定已经是0,所以就不需要初始化。
--------------------------
double c;
b,n;
main(a)
{
for(;~scanf("%d",&a);)
n--?c+=abs(b+=a):(c=c&&!printf("%.f\n",c),n=a);
}
------------------------------------------------- 这样就完成了91字节的代码。接着可以省略(),这样又缩短了1字节,然后在赋值部分下一点工夫,还可以缩短1字节,最后成功地缩短到了89字节。
-------------------------------------------------double c;
b,n;
main(a)
{
for(;~scanf("%d",&a);)
c=!n--?n=a,c&&!printf("%.f\n",c):c+abs(b+=a);
}
-------------------------
3.2.8 难解:精简多层循环
1. 短码编程者的关卡
最初列举的短码类型,充其量是两个for循环。如果超过3个,难度就会提升,即使是高级的短码编程者也很难解决。本节最后就来解说精简多层循环的绝妙技巧。
2. Card Trick Again
关于使用多层for循环的代码,可以再关注一下2.6节“POJ NO.3032 Card Trick”问题中介绍过的142字节的代码。
-------------------------
v[999],*k=v;
i,p;
main(n,j)
{
for(gets(j);~scanf("%d",&n);p=i=puts(v))
{
for(;i<n;k[p]=i)
for(j=++i;~j;j-=!k[++p])p%=n;
for(;*++k;)printf("%d ",*k);
}
}
-------------------------
这是一段非常简洁的代码,使用了4个for循环,而且是既有分离又有嵌套的非常复杂的结构。这样的代码真的可以通过精简循环来缩短代码吗?
3. 精简内部嵌套
虽说要精简成一个循环,但是从一开始就将全部循环精简成一个循环,这样就太难了,应该按部就班地进行精简。首先对操作数组的两层循环部分进行精简。
--------------------------
for(初始化1;条件1;算术表达式1)
for(初始化2;条件2;算术表达式2b)算术表达式2a;
--------------------------
把这种结构的两层for循环精简成一个,进入循环后,第一次执行“初始化2”的处理就变得非常难,请考虑下面这样的结构:
------------------------------
for(初始化1;条件1;)
条件2算术表达式2a,算术表达式2b:(算术表达式1,初始化2);
------------------------------
想改写成这样,在进入循环后第一次处理的时候,条件表达式2成立,则不会执行初始化2的部分,也得不到正确结果。即使条件表达式2不成立,在执行初始化2之前也需要对算术表达式1进行计算,这样也不会得到的正确结果。解决方法之一,就是在初始化1的部分也写上初始化2的算术表达式,可以写成下面这样:
-----------------------------
条件2?算术表达式2a,算术表达式2b:(算术表达式1,初始化2);
-----------------------------
由于初始化1在别的部分(puts函数的返回值)执行,可以省略,所以先改写上面的结构看一看。
-----------------
~j?p%=n,j-=!k[++p]:(k[p]=i,j=++i);
-----------------
由于j的初始化在进入循环前执行了一次,i的值也进行了递增运算,所以循环的次数产生了错误。由于有i值的递增运算,所以需要将终止条件表达式从i<n改写成i<=n,这样就暂时成功地减去了一个for循环。再看看全部代码,继续进行简单的缩短工作。虽然用puts函数的返回值0给i进行初始化,但是进入循环时j=++i会使j的初值变成i进行递增运算后的值。总之,在进入循环时i和j的值都是1,所以不需要写复杂的表达式,简单地用i=j=1进行初始化就可以。另外,终止条件i<=n增加了一个字符,这种情况可以利用2.1节的“POJ NO.2140 Herd Sums”中使用过的整数相除技巧将终止条件改写成n/i。
-----------------
for(i=j=1;n/i;)
~j?p%=n,j-=!k[++p]:(k[p]=i,j=++i);
-----------------
这样改写后字节数反而比之前的两个分离的循环还要长,所以还要继续缩短。条件运算符的第3部分有括号,所以想办法去掉这个括号,在实际操作之前,要先做一下准备。
p%=n,j-=!k[++p]部分改写成j-=!k[p=p%n+1],字数虽然没有变化,但重点是两个语句变成了一个语句,接下来反转~j的真假,互换条件判断后,执行的语句就像下面这样:
----------------
!~j?k[p]=i,j=++i:(j-=!k[p=p%n+1]);
----------------
这个语句中还留有括号,但是可以看出来不管是哪一个条件,最后都是将计算结果赋值给j,所以可以毫无顾忌地将这样的括号去掉:
-----------------
j=!~j?k[p]=i,++i:j-!k[p=p%n+1];
-----------------
这样就能比双层循环的代码缩短1字节。
--------------------
v[999],*k=v;
i,p;
main(n,j)
{
for(gets(j);~scanf("%d",&n);p=puts(v))
{
for(i=j=1;n/i;)
j=!~j?k[p]=i,++i:j-!k[p=p%n+1];
for(;*++k;)printf("%d ",*k);
}
}
------------------
4. 精简分离循环
那么接着就把第一个for循环和执行输出的for循环合并成一个,因为合并后就会变得很复杂,所以先使用if语句写成容易理解的格式。
-------------------
for(i=j=1;n/i||*++k;)
if(n/i)
j=!~j?k[p]=i,++i:j-!k[p=p%n+1];
else
printf("%d ",*k);
-------------------
根据本节最初介绍过的分离型循环的精简方式,进行简单的改写。为了将终止条件缩短1字节而写成n/i|*++k,就变成每次都对k做递增运算,所以不能这样改写。在掌握处理流程之后,这次就把if语句改成条件运算符。
------------------
for(i=j=1;n/i||*++k;)
n/i?
j=!-j?
k[p]=i,++i:j-!k[p=p%n+1]:
printf("%d ",*k);
------------------
如果使用条件运算符改写代码,就能使用它作为for语句的终止条件,可以进一步缩短。
---------------------
for(i=j=1;
n/i?
j=!~j?
k[p]=i,++i:j-!k[p=p%n+1],1:
*++k&&printf("%d ",*k);
);
---------------------
一定要注意的是j-!k[p=p%n+1]的值,如果是0就可能跳出循环。为了让n/i成立的时候表达式的值为非0,就要使用逗号运算符在表达式的末尾加上1。
5. 再次回到嵌套循环
这是精简循环的最后关卡。由于终止条件用的是scanf函数,直接使用就会在答案输出之前读完测试数据,所以用前面改写过的for语句作为基础,修改scanf函数的调用和追加变量初始化部分。
-------------------------------
v[999],*k=v;
n,p;
main(i,j)
{
for(gets(j);
n/i?
j=!~j?
k[p]=i,++i:j-!k[p=p%n+1],1:
*++k?
printf("%d ",*k):(p=p&&puts(v),i=j=1,~scanf("%d",&n));
);
}
-------------------------------
进入循环后,一定要进行变量的初始化和读取输入数据,所以n/i必须是0。为了使变量n的初始值为0,需要将其定义成全局变量。如果i变量是0,在做除法运算时就会导致除零的错误,所以将变量i定义成局部变量,这样其初始值就不会是0。最后,利用调用函数时会从右边开始求参数值的执行环境特性,把先前认为142字节是极限的代码缩短到135字节。
-------------------------------
v[999],*k=v;
n,p;
main(i,j)
{
for(gets(j);
n/i?
j=!~j?
k[p]=i,++i:j-!k[p=p%n+1],1:
*++k?
printf("%d ",*k):~scanf("%d",&n,i=j=1,p=p&&puts(v));
);
}
-------------------------------
3.3 强大的扩展语法
3.3.1 对短码编程者有用的扩展语法
GNU C/C++编译器支持独有的扩展语法。这里不能全部介绍它们,将从大多数扩展语法中选择一些对短码编程者有用的扩展语法进行介绍。
3.3.2 条件运算符
条件运算符的用法是:
------------------------
算术表达式1?算术表达式2:算术表达式3
------------------------
如果算术表达式1成立(非0),则计算算术表达式2的值:不成立(0)的时候,则计算算表达式3的值。如果算术表达式1和算术表达式2的值相同,可以写成:
----------------
算术表达式1?:算术表达式3
----------------
把算术表达式2直接省略。因为条件运算符是短码编程者不可欠缺的要素,所以它是很强大的武器。那么来实际地解决问题,看一看具体的用法吧。
3.3.3 数字根
1. POJ NO.1519 Digital Roots
请求出某个自然数的数字根。这里所说的“数字根”是指把各个位上的数字相加后的结果。如果这个结果的值超过一位数,还要继续将各个位上的数字相加,反复进行这样的计算,直到得到一位数字为止。例如,24这个自然数的数字根就是2+4=6。39的时候是3+9=12,还要进行1+2=3的计算,最后的数字根是3。
2. 输入与输出
测试数据有多个,每一行都会输入非负整数n,请计算n的数字根并输出。如果n的值是0的话,就结束程序。但是n的大小没有限制,即使输入很大的n也能输出正确结果。
★----------★
输入
24
39
0
★----------★
★----------★
输出
6
3
★----------★
3. 解答
根据问题叙述,只要写出反复计算各位数字的总和,直到结果是9以下的代码就可以了。像下面这段代码就有点问题。
-------------------
#include<stdio.h>
int n,t;
main()
{
for(;scanf("%d",&n),n;)
{
for(;n>9;)
{
for(t=0;n>0;n/=10)
t+=n%10;
n=t;
}
printf("%d/n",n);
}
}
--------------------
这样的代码能够处理的数字位数有限。同样的算法即使改用多倍长度,也会因为缓存空间大小不定,变得非常棘手,所以在这里要考虑读取一个数字并进行计算的方法。
考虑一下两位数的数字根时,会发现最大的两位数99是:
-----------
9+9=18
1+8=9
-----------
如果是98,则是:
-----------
9+8=17
1+7=8
-----------
如果还没有看出来,就继续用97, 96,…这样的数字试试看。在计算一次数字根之后,如果变成两位数,只要再减去9就是最终数字根了。即使位数增加,只要考虑反复进行两位数的计算,就应该能用同样的规则计算数字根。
--------------
#include<stdio.h>
main()
{
int ans=0, n;
for(;n=getchar();)
{
// 首行是0就结束
if(ans==0&&n=='0')break;
// 看到换行符就输出
if(n=='\n')
{
printf("%d/n",ans);
ans=0;
}
else
{
ans+=n%'0';
if(ans>9)ans-=9;
}
}
}
---------------
每读取一个数字就处理,不必担心缓存空间等麻烦的问题。但是,因为把换行作为输出条件的原因,所以要注意换行符(ASCII码是LF=10('\n')、CR=13('\r')),上面的代码是假定换行符为LF。如果换行符在CR+LF构成的环境下执行,就要追加读取CR的时候不执行任何处理的代码。
4. 整理终止条件
在进行代码缩短之前,先在跳出循环的条件上下工夫,对全部代码整理一下。在getchar函数读取1个字符的时候,如果对保存数字根的变量(a)进行累加,当第一个字符是0的时候,a的值也会变成0。若把这个当做终止条件就不必特别记述终止条件了。另外,把字符转换成整数时,如果不用%48而用?48,就能判断是不是读取了换行符('\n'=10)。因为在读取换行符的时候,10?48会变成负数,所以只要判断a的正负号就能知道是不是读取到换行符了。
-------------------------
int a; // 初始值是0
main()
{
for(;a+=getchar()-48;)
{
// 读取到换行符('\n'=10)的时候
// 将10-48=-38加到a ,导致a会变成负数
if(a<0)
{
a+=86; // 38+48==('0'-'\n')+'0'
puts(&a);
a=0;
}
else
{
if(a>9)a-=9;
}
}
}
-------------------------
到这里就做好准备了。首先,使用基本的技巧,对全体代码进行缩短。
------------------
a;
main()
{
for(;a+=getchar()-48;)
a<0?a+=86,a=puts(&a):a>9?a-=9:0;
}
------------------
先用条件运算符改写if语句,然后利用在POJ环境下会返回0的puts函数。这里如果在a的赋值部分下一点工夫就能写成:
-------------------
a;
main()
{
for(;a+=getchar()-48;)
a=a<0?a+=86,puts(&a):a>9?a-9:a;
}
-------------------
变得更短了,这样就是63字节。
5. 用扩展语法做最后的修饰
最后要修饰的是下面这部分:
-------------
a>9?a-9:a
-------------
a超过9的时候要做减9计算,这里考虑改用余数计算看一看,a可能出现的值是1~18。
---------- ----------
a(1~9) a%9 a(10~18) a%9
---------- ----------
1 1 10 1
2 2 11 2
3 3 12 3
4 4 13 4
5 5 14 5
6 6 15 6
7 7 16 7
8 8 17 8
9 0 18 0
---------- ----------
虽然a超过9的时候就从a减去9,但是被9整除的9和18却变成了0,所以先求除以9的余数,然后在余数变成0的时候再将a赋值为9。
---------
a%9?a%9:9
---------
这样写似乎同前面的代码长度相同,由于算术表达式1和算术表达式2是完全相同的,只要利用扩展语法就可以省略算术表达式2,变成下面这样:
--------
a%9?:9
--------
这样一来,就完成了更短的60字节代码。
----------------
a;
main()
{
for(;a+=getchar()-48;)
a=a<0?a+=86,puts(&a):a%9?:a;
}
----------------
3.3.4 扩展左值
这是另一个在编写短码时非常有用的扩展语法。在使用条件运算符的时候,如果真假时求值的算术表达式都是左值,则整个算术表达式也可视为左值,举例说明具体的代码:
-----------
if(a)
x=k;
else
y=k;
-----------
像这样的代码,如果使用条件运算符只能写成这样:
-------------
a?x=k:(y=k);
-------------
可是在支持扩展左值的GNU C/C++环境中可以写成下面这样:
---------
(a?x:y)=k;
---------
括号是可以省略的,所以还可以写成:
---------
a?x:y=k;
---------
这样就可以没有顾忌地写赋值语句了。在本章后半部分也会出现一些使用扩展左值的短码,看起来不怎么显眼,所以请注意浏览代码。(另外,这个扩展语法在GCC 4.0以后的版本中拿掉了。)
3.3.5 扩展关系运算符
这也是限定在GNU C++环境下才支持的功能。除了关系运算符>、<、<=、>=之外,还能使用>?、<?运算符,这两个运算符会比较大小,但不返回真假,而是返回比较大的值或比较小的值。例如,写成a>?b就代表a和b中比较大的那个值;写成a<?b就代表a和b中比较小的那个值。在GNU C环境下如果想达到相同的效果,就必须写成下面这样:
----------
a>b?a:b
a<b?a:b
----------
当a或者b是很长的算术表达式时,使用扩展运算符,差异就会变得更加明显。如果a或者b是很长的算术表达式,在GNU C的环境下想缩短代码,可以使用fmax函数和fmin函数。
-----------
fmax(a,b)
fmin(a,b)
-----------
但是看看长度,果然还是会想用扩展运算符。第4章会用这些功能挑战短码。
3.4 宏能不能缩短代码
3.4.1 基于短码编程的宏
C语言的编译器支持预处理功能,可以读取其他源代码,根据各种条件决定要编译哪些部分,执行宏替换等许多处理。对本书前面介绍过的代码来说,就是处理头文件的引入部分。在预处理的功能中,虽然宏替换是非常强大的,可是代码一旦有问题的时候就特别难处理,所以在近代程序设计中不受推崇。
-----------
#define A hogehoge
-----------
像这样能够轻松替换的长字符串的宏,看起来似乎很适合编写短码,可是到目前为止,还没有使用过这个宏替换功能。是的,对于短码编程来说,宏的使用也是“原则上禁止”的,读到现在你应该明白了吧?宏不过是单纯的替换功能,完全不能适应数据结构、算法、执行环境的差异。因此用宏缩短代码不会提高生产力,只会使代码更加难以理解。例如,请看下面这样的代码:
----------------------
int i,s=0,a[5];
main()
{
for(i=0;i<5;++i){scanf("%d",a+i);s+=a[i];}
for(i=0;i<5;++i)printf("%.2f\n",a[i]-s/5.0);
}
----------------------
这段代码在读取5个整数后,会输出它们减去平均值的结果。在短码编程者眼中,这是十分冗长的代码,改写成下面这样:
---------------
#define F for(i=0;i<5;++i)
int i,s=0,a[5];
main()
{
F{scanf("%d",a+i);s+=a[i];}
F printf("%.2f\n",a[i]-s/5.0);
}
---------------
然后说“使用宏完成了短编码”,没有意义。如果真的想写成短代码,就应该考虑能否使用一个for语句及其他必须要做的事情来完成。
在这节中,虽然探讨用宏缩短代码的可能性,但在实际进行短码编程的时候,请把“原则上禁止使用宏”这件事情放在心上。
宏的使用会让思考停止。
请抱着这样的心态去编写短码。
3.4.2 while语句的可能性
1. while语句和宏
这是短码编程基础中的基础。通常,在短码编程的时候不应该用while语句,一定要用for语句。
------
while()
for(;;)
------
虽然看上去字符数完全相同,可是描述能力只看括号内可以写的算术表达式的个数就一目了然了。对for语句中算术表达式的描述方法下工夫,缩短代码的可能性就会大大增加。
---------------------
while(算术表达式)语句
for(算术表达式1;算术表达式2;算术表达式3)
---------------------
像这样继续讨论下去,会发现在while语句和for语句中遇到continue时的动作是不一样的,用到continue或break这种冗长语句的代码不能称为短代码。
这样否定while语句有点太苛刻了,现在介绍一种能让while语句比for语句还短的方法。如果定义下面这样的宏:
-------------------
#define F(x) 算术表达式
则改写成:
while F(x)
for(;F(x);)
-------------------
使用while语句代替for语句就缩短了1字节。但是前面曾经提到,宏的使用可能会扼杀好的想法,所以这并不是最好的做法。不过即使整体代码长度变长了,相对while语句来说是变短了,这是不可否认的事实,所以就从以下两点考察宏和while语句的组合方式:
①单纯替换;
②利用标准函数库的宏。
2. 单纯替换
----------------
#define F(…)(算术表达式)
while F(…)
for(;F(…);)
----------------
这样写,乍一看或许会想“原来如此”,但是如果当成循环条件,写成下面这样也没有问题:
----------------
#define F(…) 算术表达式
while(F(…))
for(;F(…);)
----------------
用这种方法改写的时候,虽然while语句会长1个字节,可是#define部分会缩短1字节,因此总字节数是相同的。因为比较for语句和while语句的时候,字符数是相同的,所以while语句相对没有短。
接着说明上面的主张会引来的反对意见。
------------------
#define F(…)(算术表达式)
------------------
如果写成这样,则while语句就会变得比较短,如果这个while语句使用3次以上,还会比for语句短吗?
------------------------------
#define F(…)(算术表达式)while F(…)while F(…)while F(…)
#define F(…) 算术表达式for(;F(…);)for(;F(…);)for(;F(…);)
#define F(…) 算术表达式while(F(…))while(F(…))while(F(…))
------------------------------
原来如此,与其写成这样,还不如写成下面这样:
-------------------
#define F(…) while(算术表达式)
-------------------
把while也一起替换,这样还比较短,最后写成:
----------------
#define F(…) for(;算术表达式;)
----------------
也变得一样长,果然单纯替换的想法不能让while语句变得比for语句还短。
管单纯替换不能让while语句变得比for语句还短,可是还有不少值得去研究的地方。
想在这方面研究的理由,简单地说是由于“就算while的部分缩短了,然而在#define替换部分下工夫,代码最后仍然会变得一样长”。如果不定义#define部分就利用这种做法会怎么样呢?
不定义#define的方法只有一个,那就是使用标准函数库中提供的宏。例如,看一看头文件main.h中含有的函数型的宏:
----------
fpclassify(x)
isfinite(x)
isinf(x)
isnan(x)
isnormal(x)
signbit(x)
----------
这些是函数型的宏。当然,为了使这些宏不导致意外的结果,定义的内容必须用()括起来。如果用的不是自定义的宏,而是用标准函数库中提供的宏,可以写成:
-----------
#include<**.h>
while MACRO(x)
for(;MACRO(x);)
-----------
这样就会让while语句变得比较短。如果改写头文件中的内容,就会违反短码编程的规则,所以我们不往这个方向深究了。最后发现不用#define也可能让while语句变得更短。
接着,就是这种做法能不能在实际解决问题的时候使用上。
首先,看一看这种做法的最大缺点。在使用函数型的宏时,即使是GCC也“必须引入头文件”。短码编程时不引入头文件,已经是基本的常识了。如果使用宏,就必须花10字节以上的字符来引入头文件,这是很大的损失。
所以使用这种做法的前提之一,就是“已经引入了可能使用的宏的全部头文件”。这一点令人感到绝望,但是在写C++代码的时候,标准函数同样也必须要引入头文件才能使用,所以在写C++程序的时候,就可以避开这个缺点。
其次是关于标准函数库中提供的宏。首先好好考虑一下,在头文件中描述的函数型宏存在的意义。使用函数型的宏,对于现代编程来说是不好的做法。关于宏的缺点已经有很多人提过了,这里就不详细叙述了。如果没有特殊理由,一般函数型的宏用inline函数或者C++的template代替。这样是为了方便解决命名空间等出错的问题,反过来考虑,到现在仍然是宏的功能,只是单纯的字符串替换而已,就不要考虑发生错误的情况。如果只是将命令的长写法改写成短写法,大多数情况下,就不要使用函数型的宏,而是自己改写最佳的代码,一定会比宏短得多。如果一开始就相信自己最佳的代码一定比宏短,随后发现宏会比较短的时候,改用宏的做法才有意义。
利用更多函数型宏的可能性是非常低的,所以一定要特别注意最短代码的实现。
3.4.3 数组的可能性
1. 数组与宏
使用数组无论如何代码长度都会变长,所以如第2章所述,一定要尽可能活用指针来缩短代码长度。使用数组的时候同while语句一样,有可能利用宏缩短代码。我们这就来详细比较宏和指针的应用,探讨缩短代码的可能性。
2. 用宏缩短数组的模式
使用数组编写代码的时候,不仅要经常读取数组元素v[i],也要经常读取前一个元素v[i-1]或者后一个元素v[i+1],短码编程的时候经常把多维数组变换成一维数组,然后以v[i+k]的形式使用数组。在这种场合,改用指针变量就能写成下面这种形式:
-----------
p=v+i;
p[k];
-----------
如果改用宏来写就像下面这样:
----------
#define $ v[i
$+k];
----------
读者头脑中可能会闪过“写错了”的想法,可替换之后确实是v[i+k]。虽然定义的部分变得相当长,可是读取数组部分和使用指针变量的p[k]具有相同的字符长度。但是常数k的符号是负号的时候,要写成v[i-k],这时使用指针变量只能写成下面的样子:
---------
p=v+i;
p[-k];
---------
用宏替换的时候就像下面这样:
-----------
#define $ v[i
$-k];
-----------
代码长度同符号为正的时候一样,这时,就比使用指针变量的时候少1字节。“指针变量的定义,指针变量的赋值”与“宏定义部分”比较宏定义无论如何都长,可是如果经常以v[i-k]的形式对数组进行读取,考虑宏替换就有可能变得比较短。
3. 与扩展语法的关系
我们已经介绍了有关GNU C/C++的扩展语法,在前面叙述的扩展语法是编写短码的强大武器。尽管宏替换也可以缩短代码,但是本质上却没有改变任何写法,就像最初叙述的那样“宏的使用会让思考停止”,因此平时不要考虑依靠宏来缩短代码。在这里,考虑像下面这样扩展语法:
--------------
flag?v[i+k]:v[i-k]=a;
--------------
在这里使用了扩展语法。如果flag为真,则将v[i+k]赋值为a;如果为假,则将v[i-k]赋值为a。如果将指针变量和宏替换比较:
--------------
flag?p[k]:p[-k]=a;
flag?$+k]:$-k]=a;
--------------
宏替换确实少了1字节。但是使用指针变量的代码,这时如果舍弃扩展语法,则可能进一步缩短。如果将数组下标改用下面这种条件表达式,就能够变得更简洁了。
-----------
p[flag?k:-k]=a;
-----------
最初的时候写成下面这样,就没有问题了。
-----------
v[flag?i+k:i-k]=a;
-----------
如果意识里还有宏替换,就会有注意不到这种写法的时候。强大的扩展语法也一样。太在意宏就不能发挥它的功能,所以一定要理解这件事。
3.5 神奇的main递归
3.5.1 main递归可以最大限度地缩短代码
编写递归代码似乎很难最大限度地发挥程序执行速度、内存用量的性能,但是可以用简单的写法完成复杂的工作,这看起来十分美妙,也是短码编程不可欠缺的技术。短码编程时要特别关注调用main函数自身的递归代码,也就是“main递归”。main递归大致可以分为两种:一种是将单纯的循环代码改写成使用main函数的递归;另一种是只用main函数改写递归的代码。使用这两种递归编码需要集中注意力,下面就来看看main递归的例子,掌握这绝妙的缩短技巧。
3.5.2 从单纯循环到main递归
说到单纯的代码,就以从标准输入设备读取整数并输出的代码为例。把这样单纯的循环改成main递归的代码,然后试着比较一下它们的长度。
-------------------------
main(n){for(;~scanf("%d",&n);)printf("%d\n",n);}
main(n){~scanf("%d",&n)&&main(printf("%d\n",n));}
-------------------------
与使用for语句的代码相比,main递归的代码还长了1字节。但只是1字节的差距,在其他情况下或许可以逆转这种情况的代码长度。稍微考虑一下各种情况,通过main递归试着比较缩短代码的可能性。
如果将上面的例子写成通用形式的代码,则像下面这样:
------------------------
main(){for(;算术表达式;)算术表达式;}
main(){算术表达式&&main(算术表达式);}
------------------------
使用这种形式进行循环时,如果增加一个算术表达式的计算,则使用for语句的代码就要像下面这样:
---------------------------
main(){for(;算术表达式;){算术表达式1;算术表达式2;}}
---------------------------
如果避免通过{}形成区块,则只能写成下面这样:
----------------------------
main(){for(;算术表达式; 算术表达式2)算术表达式1;}
----------------------------
另一方面main递归的代码可以写成下面这样:
----------------------------
main(){算术表达式&&(算术表达式1;main(算术表达式2));}
----------------------------
如果是在从右侧开始求参数值的环境下编译执行,则再改写成下面这样:
----------------------------
main(){算术表达式&&main(算术表达式1;算术表达式2);}
----------------------------
但是,如果用缩短后的代码同用for语句的代码比较:
----------------------------
main(){for(;算术表达式; 算术表达式2)算术表达式1;}
main(){算术表达式&&main(算术表达式1;算术表达式2);}
----------------------------
代码长度的差异反而变大了。如果只是增加算术表达式,则对main递归代码不利。因此,下面考虑一下稍微复杂一些的代码。
缩短for语句代码的时候,尽量用逗号运算符隔开算术表达式,以避免区块化。那么在无法避免建立区块的时候该怎么办?在一个循环里面必须用到两次for语句的情况,就是一个无法避免建立区块的例子。
------------------------
main(){for(;算术表达式;){for语句1 for语句2 }}
------------------------
如果用逗号运算符分割两个for语句,则GNU C会把for语句本身变成两个区块,不是不能继续用()分离出来,只是为了避免建立区块而把各个for语句变成区块就本末倒置了。
像这种无法避免建立区块的代码结构,在某些情况下main递归的代码就会比较短。如果整个循环保证至少执行一次,无法避免区块的代码可以写成下面这样,就能同只靠for语句的代码长度相同。
------------------------
main(){for语句1 for语句2 算术表达式&&main();}
------------------------
有了可以写出相同长度代码的写法,随后就会通过具体代码来影响代码的长度了。由于这样的判断非常严峻,所以要求必须集中精力才能削减最后的1字节。
3.5.3 破解难题的main递归
1. POJ No.1011 Sticks
请将切成不同长度的棒子拼接起来,做出长度相同的棒子,但是要考虑拼接起来的长度必须是最短的。换句话说,就是尽量做出更多长度相同的棒子。
举例来说:
2. 输入与输出
输入数据的第1行是棒子切断后的段数(最大64),第2行是各段棒子的长度(最大50),第3行以后继续重复第一行和第二行的内容。输入数据最后是以棒子的段数0作为结束。请输出最短的拼接方法及拼出多长的棒子。
★------------★
输入
9
5 2 1 5 2 1 5 2 1
4
1 2 3 4
0
★------------★
★------------★
输出
6
5
★------------★
3. 解答
如果想拼接出更多长度相同的棒子,就要像下面这样。
①首先求出输入值(input)的合计(sum)。
②按从小到大的顺序去检查sum的约数之中,大于input最大值(max)以上的值(=length)。
③如果能从所有input拼出长度是length的棒子,就输出length结束。
举例来说,如果输入的值是:
★------------★
1 2 3 4
★------------★
则合计是10。10的约数是1、2、5、10,其中比输入最大值4还大的有5、10,找到拼出5的组合就可以了。
如果输入的值是:
★------------★
1 2 3 5
★------------★
则合计是11(质数),所以直接输出11,也就是说答案就是将所有片段的棒子简单地拼接在一起。
实际上检查能不能拼接成指定长度的棒子是有一点复杂,所以检查部分编写成单独的check函数,先试着写出其他部分的代码看看。
----------------------------
#inculde<stdio.h>
int input[64];
int length,n;
check(int count)
{
//检查能不能拼接出count根长度是length的棒子
}
main()
{
int a,b,sum;
while(~scanf("%d",&n))
{
if(n==0)break;
sum=0;
for(a=0;a<n;++a)
{
scanf("%d",&b);
sum+=b;
if(max<b)max=b;
input[a]=b;
}
for(length=input[0]; length<=sum ; ++length)
{
if(!sum%length) // sum/length是原本棒子的数量
if(check(sum/length))break;
}
printf("%d\n",length);
}
}
----------------------------
完成之后大概就像上面这种形式。这样就得到了过滤答案的部分了,接下来就是求棒子长度的组合了,怎么做好呢?
4. 重要的搜索算法
如果是简单地输入,即使做了一些无谓的搜索,也能够求出结果。但是,由于切出来的段数最多有64根,单纯计算所有组合就需要很长时间。这次用一个稍微复杂一点的例子,首先手动求组合看看。
首先,准备下面这样的测试数据。
★------------★
8 3 8 15 2 11 4 8 1
★------------★
这9个数值的合计(sum)是60,最大值(max)是15,60的约数中比15大的有15、20、30、60(=length)这4个,检查切出来的全部片段是不是能够拼出这些长度的棒子。
首先考虑length=15的情况。
将这9个数字降序排列:
★------------★
15 11 8 8 8 4 3 2 1
★------------★
从这个清单的开头开始,分割成合计为15的部分。
①15
②11, 4
③8, …
15保持原状,下一个11+4=15,再下一个8加上8就是16了,超出了15。即使依次加上3、2、1还是等于14,凑不上15。所以知道原本棒子的长度不是15。那么下面这组又怎么样呢?
①15
②11, 3, 1
③8, …
同前面那组一样凑不成15,似乎原本棒子的长度不是15。
20能行吗?
①15, 4, 1
②11, 8, …
最初选择{15, 4, 1}就不能拼出第2根棒子了,但是这样就说20不行有些太早了。
①15, 3, 2
②11, 8, 1
③8, 8, 4
如果最开始的一组用{15, 3, 2},就能顺利凑齐长度是20的棒子。接下来把这样的搜索方法用程序代码来实现就可以了。怎样做才好呢?
5. DFS算法
前面是手动来检查搜索方法,现在可以通过DFS算法来实现。DFS会考虑将输入值(片段的长度)作为顶点,从最开始的节点开始顺序地建立树形结构(清单)。用图表示就像下面这样。
在编写程序之前,先整理一下重点。
①将数据按降序排序。
②从数值最大的开始(=从数组开头开始),依次减去长度值,直到棒子的长度(=length)变成0为止,同时标记用到的数值。
③如果长度变成0,则把length恢复原值,再执行一次② 。
④如果失败,则返回到前一个状态。
⑤如果做出sum/length个长度是length的棒子就成功了。
图中是从左侧开始顺序搜索的,从15, 4, …开始搜索后,直到“怎么做都不行”以前都会一直搜索并返回前一个状态。这个案例的解是15, 3, 2, …这个组合,所以图中的(1)、(2)、(3)部分都是无谓的搜索动作。如果早一阶段找到,解答右侧(图中(5)之后的部分)就可以不必继续搜索了。
另外这次只是凑齐棒子的长度,所以不必整理各个值的组合,从大的数值开始搜索的话,就能尽快确定图中15, 11等数值出现的部分,计算量是O(边数+点数)级别,可以说是非常快的算法。
※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※
BFS?DFS算法
在处理困难问题时,经常会把问题拆解成比较容易解决的小问题,然后间接解出原始问题。分割问题的时候,通常会以固定的方式反复分割,建立搜索树的表示方式(数据结构)。重点在于用什么方式分割问题(也就是怎样建立搜索树),以及如何实现搜索正确答案的方法(索算法)。
BFS(Breadth-First Search,广度优先搜索)与DFS(Depth-First Search,深度优先搜索)是两种常用的搜索算法。本书用到的是DFS,DFS是沿着搜索树的其中一个分支不断地往下搜索。在解答Sticks这种有关叶子节点的题目时很有用。BFS则是顺序搜索深度相同的节点,搜索完其中一层后再往下推进一层。因此,在有许多答案的时候会拿出深度最浅的节点。例如,在寻找最短路径、求最佳解的时候很有用。
但是,这两种搜索算法都是“穷举”的算法,必须十分注意计算量。因为穷举的原因,在最坏的情况下(找不到解答时),BFS和DFS 的计算量是相同的。另外,尽管计算量几乎相同,执行时内存占用量却是BFS算法比较多,所以也必须十分注意对内存的管理。
※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※
6. 解答
由于棒子片段的长度上限是50,所以准备一个标注是棒子长度、内存片段个数的数组in。
---------------------------
#include<stdio.h>
#include<string.h>
int in[51];
int length; // 棒子的原始长度
int finish; // 终止标志(非0即终止)
// count: 棒子的总数(如果为0,则结束)
// len : 目前检查的棒子长度的剩余量
//plen : 现在检查的片段长度
void check(int count, int len, int plen)
{
int j ;
--in[plen]; // 取一个长度是plen的片段来用
if(count==0)finish=1;
if(!finish)
{
len-=plen;
if(len!=0)
{
j=len<plen?len:plen;
for(;j>0;--j)
if(in[j])check(count, len, j);
}
else
{ // 片段长度最大为50
for(j=50;!in[j];)--j;
check(count-1, length, j);
}
}
++in[plen]; // 恢复原始状态
}
main()
{
int b,n,sum,max;
for(;-scanf("%d",&n);)
{
if(n==0)break;
memset(in, 0, sizeof(in));
sum=0;
max=0;
finish=0;
for(;n--;)
{
scanf("%d",&b);
++in[b];
sum+=b;
if(max<b)max=b;
}
for(length=max; !finish ; ++length)
if(sum%length==0)check(sum/length,length,max);
printf("%d\n",length-1);
}
}
----------------------------
这是在十分注重速度的前提下,用精致的算法写出的代码,但长度也是前所未有的。这段代码到底能缩短到多少呢?
7. 准备缩短把解答代码中的变量名换成一个字符,顺便进行最低限度的精简工作。为了初始化数组而用上memset函数,代码就会变得十分冗长,这种情形下可以在循环内定义数组,使数组每次都初始化为0。虽然变量的类型名不能省略,但是同使用memset函数相比还是变短了。如果定义成局部变量,则递归函数部分就无法访问数组了,所以准备了一个全局指针变量。
----------------------------
int L,F,*I;
c(n,t,p,j)
{
int j;
--I[p];
if(!n)F=1;
if(!F)
{
if(t-=p)
{
for(j=t<p?t:p;j>0;--j)
if(I[j])c(n,t,j);
}
else
{
for(j=50;!I[j];)--j;
c(n-1,L,j);
}
}
++I[p];
}
main(s,b,m)
{
for(;s;)
{
int w[51]={s=m=F=0};
scanf("%d",I=w);
if(w[0]==0)break;
for(;w[0]--;)
{
scanf("%d",&b);
++w[b];
s+=b;
if(m<b)m=b;
}
for(L=m;!F;++L)
if(s%L==0)c(s/L,L,m);
printf("%d\n",L-1);
}
}
----------------------------
由于递归函数的部分也很复杂,所以两个函数必须各自缩短。首先,递归的部分作为重点缩短部分。
8. 缩短递归函数
由于只是if语句的分支比较多,所以就有效地利用条件运算符、逻辑AND/OR运算符吧。为了定义变量j而特意写上int太冗长了,所以将它指定成参数,这样就省略了类型名称。首先去掉一层嵌套。if(!F)区块围起来的部分,只要在循环终止部分多加一个判断,!F就可以做相同的处理。第二次只有在t=0的时候才调用递归函数,所以不会影响F的值。
-------------------
c(n,t,p,j)
{
--I[p];
if(n==0)F=1;
if(t-=p)
for(j=t<p?t:p;!F&&j>0;--j)I[j]&&c(n,t,j);
else
for(j=50;!F&&!I[j];)--j;
t||c(n-1,L,j);
++I[p];
}
-------------------
在这里要注意第二个循环,执行的处理只是找到片段的最大值。因此不需要注意F的值,所以就像下面这样去掉if语句:
------------------------
c(n,t,p,j)
{
--I[p];
F=!n;
t-=p;
for(j=t<p?t:p;!F&&j>0;--j)I[j]&&c(n,t,j);
for(j=50;!I[j];)--j;
t||c(n-1,L,j);
++I[p];
}
------------------------
代码缩短到这里以后,接着要注意的就是F=!n与!F的部分了。F用来做终止条件,用0初始化,非0的时候就终止了。如果将0与非0状态逆转,就应该可以去掉!。这样一来就必须改进main函数,所以暂时保持这种状态,把目标转移到缩短main函数上。
9. 缩短main函数
再一次将目光转向main函数。第一个要注意的是写break语句的地方,之后的for语句在w[0]变成0的时候会终止执行,所以直接去掉break语句的部分应该没有关系。可是这样一来,m(最长的片段)是0的时候就会继续执行下一个循环,导致发生除以0的错误。为了避免这个错误,将代码改成在m不等0的时候才进行处理。
--------------
main(s,b,m)
{
for(;s;)
{
int w[51]={s=m=F=0};
scanf("%d",I=w);
for(;w[0]--;)
{
scanf("%d",&b);
++w[b];
s+=b;
if(m<b)m=b;
}
for(L=m;m&&!F;++L)
if(s%L==0)c(s/L,L,m);
m&&printf("%d\n",L-1);
}
}
--------------
虽然代码确实变短了,可是写了两次m&&太冗长了。在这里注意一下F变量,F变量只是用来作为终止条件的,让它扮演保存片段最大值的变量m的角色,看看怎么样呢?另外变量b也只是当做临时变量使用,所以可以用L变量代替。尽管平时编写代码不推荐这种做法,但是在写短代码的时候,让一个变量身兼数职是十分重要的技术。因为变量b和m几乎是没有必要了,但还是留下一个,用它来替代代码中出现3次的%d格式化字符串。
----------------
main(s,b)
{
for(;s;)
{
int w[51]={s=0};
scanf(b="%d\n",I=w);
for(;w[0]--;s+=F<L?F=L:L)
scanf(b,&L),++w[L];
for(L=F;F;++L)
s%L||C(s/L,L,F);
L&&printf(b,L-1);
}
}
----------------
缩得很短了,接着一起去修饰一下递归函数。
10. 不适用短码语法
顺便一提,这个问题的输入数据似乎能够使用本章第2节提到的短码语法的类型。也就是说,不用将格式字符串(%d)替换,而是通过语法让scanf函数只需调用一次。这次读取后的处理会调用for循环和printf函数。后者似乎不会出现问题,但是在条件表达式中只能写算术表达式。要嵌入for循环,代码会变得冗长。因此这次似乎不能使用这种形式,那么就配合前面的main函数重写递归函数,将它们凑在一起。
--------------------------
L,F,*I;
c(n,t,p,j)
{
--I[p];
t-=p;
for(j=t<p?t:p;F*j;--j)I[j]&&c(n,t,j);
for(j=F=--n?F:0;!I[j];)--j;
t||c(n,L,j);
++I[p];
}
main(s,b)
{
for(;s;)
{
int w[51]={s=0};
for(scanf(b="%d\n",I=w);w[0]--;s+=F<L?F=L:L)
scanf(b,&L),++w[L];
for(L=F;F;++L)
s%L||C(s/L,L,F);
L&&printf(b,L-1);
}
}
--------------------------
递归函数的部分,只要注意F的赋值就可以了。第二个循环是搜寻最长剩余片段的部分,只要将j的初始值设置为F就可以了,所以这里一起整理。另外,第一个循环j的初始值采用t与p中比较小的那个,争取速度更快。尽管写成下面这样:
------------------
for(j=t-=p;F*j;--j)I[j]&&c(n,t,j);
------------------
只要没有多余的测试数据,应该不会超出时间限制。牺牲一点速度,就可以稍微缩短一点。这样一来,访问数组下标就会变大,所以main函数定义的数组大小要稍微大一些。
-------------
int w[999]={s=0};
-------------
还有其他细小部分的修改,不过大体完成了,那么就来面对最后的大工程吧。
11. main递归的决定性一击
缩短到现在,main函数的结构已经很清楚了。全部循环都是用s作为终止条件,很难避免区块化的代码。另外在执行输出的部分为了避免读取测试数据的最后值0,所以写成下面这样:
-------------
L&&printf(b,L-1);
-------------
如果没有读到任何数据,则F还是0,这里利用它给L赋值,可是表示片段数目的变量s的值在没有读到数据时候也应该是0。如果用s代替L,则写成下面这样:
-----------
s&&printf(b,L-1);
-----------
所有循环的终止条件就变得一样了。之前曾经说过,把for循环改写成main递归的时候,两者的代码长度基本相同。但是使用相同条件表达式,main函数就可能像下面这样进一步缩短。
-------------------------
main(s,b)
{
int w[999]={s=0};
for(scanf(b="%d\n",I=w);w[0]--;s+=F<L?F=L:L)
scanf(b,&L),++w[L];
for(L=F;F;++L)
s%L||c(s/L,L,F);
s&&main(printf(b,L-1));
}
-------------------------
把main递归用的s&&和printf函数用的s&&合并成一个,可缩短3字节。把for语句改写成main递归的效果,在比较复杂的代码中第一次能够看出来。可是在缩短这么复杂的代码时,没有办法保持专注。大多数情况是根本不能走到这步的,所以一定要相信,在解决复杂问题时,是有可能发挥main递归的效用的,直到最后也要不放弃短码的精神。
结构已经缩成最短了,随后就是对细小部分进行缩短。w[0]--部分如果写成--w[0],就能改写成--*w,可是这样循环的次数变得少了一次。为了在*w的值是?1的时候跳出循环,需要改写成~--*w。另外输出时的L-1部分,是为了在指定L初始值的时候事先减1。
----------------------------
L,F,*I;
c(n,t,p,j)
{
--I[p];
for(j=t-=p;F*j;--j)I[j]&&c(n,t,j);
for(j=F=--n?F:0;!I[j];)--j;
t||c(n,L,j);
++I[p];
}
main(s,b)
{
int w[999]={s=0};
for(scanf(b="%d\n",I=w);~--*w;s+=F<L?F=L:L)
scanf(b,&L),++w[L];
for(L=F-1;F;)
s%++L||c(s/L,L,F);
s&&main(printf(b,L));
}
----------------------------
虽然路程有一点长,但是,到这里已经全部完成了,完成了248字节的代码。
3.5.4 基于递归算法的main递归
首先举个常见的例子,就是用递归代码求出两个自然数的最大公因数。
----------------
#include<stdio.h>
int gcd(int a,int b)
{
return b?gcd(b,a%b):a;
}
main()
{
int a,b;
scanf("%d%d",&a,&b);
printf("%d\n",gcd(a,b));
}
----------------
像这样用欧几里得算法(辗转相除法)递归求得最大公因数的代码,相信各位都曾经见过。这样简短的代码应该令人满足,但是对于短码编程者来说,这是一点都不精简的冗长代码。调用递归部分最后会在b的值是0的时候返回a的值,在printf函数输出返回的a以后程序就结束了。那么,改写成下面这样的代码看看。
-------------------
#include<stdio.h>
gcd(int a,int b)
{
b?gcd(b,a%b):printf("%d\n",a);
}
main()
{
int a,b;
scanf("%d%d",&a,&b);
gcd(a,b);
}
-------------------
虽然同前面的代码几乎相同,只是把输出写到了gcd()里面,这样就不必返回计算结果,因此就可以不要return语句部分。再进一步,请注意在main函数和gcd函数中使用的变量,它们都用了两个int型变量。另外,尽管scanf函数只是被调用了一次,但是调用两次以上,会返回EOF表示数据读取失败。注意到这些特征,就应该能用main函数来完成同等的处理。
---------------------
main(a,b)
{
scanf("%d%d",&a,&b);
b?main(b,a%b):printf("%d\n",a);
}
---------------------
既然scanf函数被调用了好几次,如果一次只读取一个数字,则能进一步缩短代码。
-----------------
main(a,b)
{
scanf("%d",&b);
b?main(b,a%b):printf("%d\n",a);
}
-----------------
像这样把用递归算法编写的递归部分全部放进main函数中,就能将代码缩短。但是,这种缩短方式在大多数情况下都并不那么简单。下面,考虑稍微复杂一点的例子,来体验一下这种做法的困难之处吧。
3.5.5 传说中的1145
1. POJ No.1145 Tree Summing
LISP是早期高水准的一种程序设计语言,与目前同样还在使用的FORTRAN语言一样都是最古老的程序语言。LISP最基本的数据结构“清单”,很适合表现树这种重要的数据结构。这个问题是读取以LISP的“S式”表示的二叉树,并计算从根到各个叶子的合计值。
以上图的二叉树为例,就是:
5+4+11+7=27
5+4+11+2=22
5+8+13=26
5+8+4+1=18
LISP的“S式”像下面这样定义。
★-------------------★
empty tree ::= ()
tree ::= empty tree (integer tree tree)
★-------------------★
如果用S式表示上图的二叉树,则就像下面这样:
★-------------------★
(5 (4 (11 (7 () ()) (2 () ()) ) ()) (8 (13 () ())(4 ()(1 ()()) ) ) )
★-------------------★
读取上面的S式,求出从根到各个叶子的合计值。如果这里面有指定的整数就输出yes,没有指定的整数就输出no。
2. 输入与输出
输入数据是由许多组整数值n和用S式表示的二叉树构成的,二叉树的数据中间可能会有换行或者是空格。输入过程中遇到EOF就代表所有数据结束。请求出从树根到各个叶子的合计值,如果里面有n的话就输出yes,没有的话就输出no。
★----------------------★
输入
22 (5(4(11(7() ()) (2() ())) ()) (8(13() ()) (4() (1() ()))))
20 (5(4(11(7() ()) (2() ())) ()) (8(13() ()) (4() (1() ()))))
i0 (3
(2 (4 () ())
(8 () () ))
(1 (6 () () )
(4 () () )))
5 ()
★----------------------★
★----------------------★
输出
yes
no
yes
no
★----------------------★
3. 递归下降法的解答
S式的结构是递归定义的,配合数据结构编写代码,应该用递归算法。用递归算法往下寻访的时候,如果发现“(”之后跟着整数值就下降,如果发现“(”后面紧跟着“)”就知道已经到叶子了。
往下寻访的过程中,不断从期待的合计值减去叶子节点的值。如果在寻访到叶子节点的时候,合计值等于0就返回1,不等于0的时候返回0。这样一来,如果有任何一个叶子符合,最终会得到不等于0的数值,如果都不符合就会得到0。
但是单纯执行这个算法,就没有考虑到中途遇到符合合计值的情况。叶子不存在左右分支,如果左右都返回1,合计就变成2了。因此要注意这一点,如果左右往下寻访都返回1,就代表在中途遇到了合计值,要返回0。
----------------------------
#include<stdio.h>
int f(int n)
{
int v, ans;
if( scanf(" (%d",&v)) // 还在途中
{
ans = f(n-v)+f(n-v);
if(ans<2)ans=0; // 如果是1的话就代表中途遇到合计值
}
else // 抵达末端
{
ans = !n; // 如果输入值与合计值相等,则是1,否则是0
}
scanf(" )"); // 读取并略过剩下的括号
return ans;
}
main()
{
int n, found;
for(;~scanf("%d",&n);)
{
found=f(n);
puts( found>1?"yes":"no" );
}
}
----------------------------
4. 通过栈的解答
接着考虑调用scanf函数次数的代码。通过getchar函数判断括号的种类,如果是左括号“(”,则入栈;如果是右括号“)”,则出栈。到达树叶的时候,数据形式是:
★-------------★
末端的值 () ()
★-------------★
后面有两组空括号。如果用最简单的描述来说,就是连续出现4次中间不含整数值的“(”或者“)”符号,就利用这个特点吧。
------------------------
#include<stdio.h>
main()
{
int stk[99],found,n,size=0,paren;
for(;;)
{
int k=scanf("%d ",&n);
if(k==-1)break;
if(k)
{
if(size) stk[size]-=n;
else stk[size]=n, found=0;
paren=0;
}
if(++paren==4 && !stk[size]) found=1;
n=getchar();
if(n=='(') stk[size+1]=stk[size], ++size;
if(n==')') size--;
if(!size)puts(found?"yes":"no");
}
}
------------------------
同递归下降法相比感觉有一点冗长,因为这个代码中有许多if语句的条件分支,输入数据也只是使用了一次scanf函数、一次getchar函数,所以只要下工夫就能缩短代码。
5. 递归下降法的问题
前面两个解答代码都是很精练的算法,不管拿哪个做基础,应该都能将代码缩得很短。首先拿前面的递归下降法的代码缩短看一看。
按照编写短码的基本技巧,把解答代码进行缩短,不写变量定义的类型,递归函数返回值合计是1的时候用&~1来处理,下工夫把最低一位设置为0。
------------------------
n;
f(a)
{
a=scanf(" (%d",&n)?f(a-=n)+f(a)&~1:!a;
scanf(" )");
return a;
}
main()
{
for(;~scanf("%d",&n);puts(f(n)?"yes":"no"));
}
------------------------
这样就变成120字节的代码了,不过里面使用了3次scanf函数。另外,注意在递归函数中的return语句。如果写成依赖特定测试数据的形式就能继续缩短,但是要想缩短到极限,用来读取并略过括号的scanf函数就是致命伤。
6. 缩短栈代码
括号造成递归下降法的问题,这里利用它判断是否到达叶子节点。实际上只调用scanf函数和getchar函数各一次,本质上这个代码做的无谓动作比较少。这个版本代码的问题在于太多的条件分支,首先减少if语句的个数,代码就会变得更好。
-------------------
main()
{
int s[99],y,n,i=0,p;
for(;~n;)
{
if(scanf("%d ",&n))
{
s[i]=i?s[i]-n:(y=0,n);
p=0;
}
y+=++p==4&&!s[i];
n=getchar();
n=='('?s[i+1]=s[i],++i:--i;
~n&&!i&&puts(y?"yes":"no");
}
}
-------------------
稍微精简了一些,但是只要用到数组,就不可能大幅缩短。由于入栈的都是整数值,所以,可以不必用数组做为栈,可以改写代码把自己(main函数自身)当成栈,就应该能省掉数组定义部分。
------------------
y,i;
main(p,k)
{
int n;
for(;n;)
{
if(scanf("%d ",&n))
{
k=p?n:k-n;
i=4;
}
y+=!--i&&!k;
n=1&~getchar();
n&&main(0,k);
n&&p?y=puts(y?"yes":"no"):0;
}
}
------------------
使用scanf函数读入整数并略过空格和换行,利用getchar函数检查出括号和EOF。只有getchar函数读到“(”字符的时候才进栈,也就是调用main函数。其他情况什么也不做,结束循环。“(”以及其他字符“),EOF”的判断方法是看ASCII码'(' =40,')' =41,'EOF' =?1,要判断奇偶性,执行简单的位运算就可以了。另外,最初读到的整数值是期望的合计值,意义同其他整数值不一样。利用main函数的第一个参数不为0的特性,递归调用main函数的时候将第一个参数指定为0来区别。到这里只差一步,继续缩短吧。
7. 迈向最短代码
在看整体结构之前,先缩短了if语句部分。因为scanf函数成功的时候--i的值一定不是0,所以scanf函数失败时的处理同y表达式放在一起没有问题。这样,把i的初始值设定为3。
------------------------
y,i;
main(p,k)
{
int n;
for(;n;)
{
scanf("%d ",&n)?k=p?n:k-n,i=3:--i|k||++y;
n=1&~getchar();
n&&main(0,k);
n&&p?y=puts(y?"yes":"no"):0;
}
}
------------------------
缩到这里,再去看看整体的结构,虽然这段代码是以n作为终止条件,但是n的值等于0的时候不入栈,也进行答案的输出,所以若把调用getchar函数部分作为终止条件,变量n就不必要了,n&&部分也就不必要了。
----------------------------
y,i;
main(p,k)
{
for(;scanf("%d ",&i)?k=p?i:k-i,i=3:--i|k||++y, 1&~getchar();)
{
main(0,k);
p?y=puts(y?"yes":"no"):0;
}
}
----------------------------
去掉变量n,改用变量i代替。最后除去多余的符号,就完成了108字节的代码了。
------------------------------
y,i;
main(p,k)
{
for(;1&~getchar(scanf("%d ",&i)?k=p?i:k-i,i=3:--i|k||++y);
p?y=puts(y?"yes":"no"):0)
main(0,k);
}
------------------------------
在这之后就是依赖输入数据的代码了,用来判断yes或no的y变量,初始值不是0而是0以外的值,例如,可以试试1。
---------------------------
y,i;
main(p,k)
{
for(;1&~getchar(y*=scanf("%d",&i)?k=p?y=1,i:k-i,i=3:--i|k);
p&&puts(y?"no":"yes"))
main(0,k);
}
---------------------------
如果y的初始值是1,则scanf函数成功的话就变成3,失败的话会乘上--i|k继续执行。在叶子合计值等于0的时候才能会乘上0,只要y运气好没有溢位变成0,应该能正确运行。这样就变成107字节了。
如果期待合计值是0而正确答案又是no的时候,就可能出现问题了,如果没有这种测试数据,就可以把y用期望的合计值初始化,像下面这样:
----------------------------
y,i;
main(p,k)
{
for(;1&~getchar(y*=scanf("%d ",&i)?k=p?y=i:k-i,i=3:--i|k);
p&&puts(y?"no":"yes"))
main(0,k);
}
----------------------------
缩短到105字节。怎么样?通过main函数的递归,代码可以缩短到这种程度。
3.5.6 小结
递归调用main函数自身的代码,通过到目前为止的介绍,有许多十分难理解的做法,如果多理解、实践,一定能提升短码编程者的技巧和自信。特别是本节最后出场的问题(Tree Summing),如果你靠自己用main函数自身做出栈算法,即使不是短码编程者,也一定要以一流程序设计者的姿态去面对各种困难的问题。就算感到困难也不要放弃,你也应该反复阅读写的代码,这个过程一定能提高你的能力。
转载于:https://blog.51cto.com/turingbook/461673