又一面试题,又一伪命题 - 关于C中字符数组逆序的方法

38 篇文章 0 订阅
10 篇文章 0 订阅
最近土豆同学经常去参加各种面试和笔试,而我也获益不少,得以见识到这些"题目"的诡异.这次听到的,是一个关于C语言中字符串逆序的问题.问题的核心是: 用什么办法,可以最高效的把一个char[]内容的顺序逆转? 最好是不用额外的存储空间.

我陷入了沉思.土豆同学问我的时候,一再强调既不需要用"额外的存储空间,也不需要加减或者异或运算";跟算法没关系,而是与类似语言特性的特性相关.想想也是,要逆序,无论如何也要遍历整个字符数组,不可能达到比O(n)还好的下限.但是不使用"额外"的空间...

并且,假设函数原型是类似: [color=Blue]void[/color] reverse( [color=Blue]char[/color]* str );

于是我只想到了两种可能,要么利用了数组之前(也就是例如"array[-1]"之类)的"安全空间",要么利用数组最后的"\0".当然,前者不是语言规定要有,而是编译器特定的行为,不靠谱;后者则是C语言里字符串的标准表示方式所规定的: 字符串以'\0'表示结束,可以放心利用.想到这里,土豆点头了,说面试官提出的就是这么一种[i]使用结束位置上的0来完成交换的,"既没有使用额外的存储空间,又比加减或者异或运算快的方法"[/i].

...
我随即表示了反对.我从一开始就觉得这个方法很糟糕,所以没纳入考虑范围.不过面试官的思路我们也琢磨不透就是了.下面将说明我反对这种方法的理由.

把问题稍微简单化,将上面涉及的三种方案都写成代码如下.foo1()是面试官的建议版,foo2()是使用额外临时变量版,foo3()是运算版.
#include <stdio.h>
#include <string.h>

/*
* reverse string via the terminating zero
*/
void foo1(char* a) {
int len = strlen(a);
int i;

for (i = 0; i < len / 2; i++) {
a[len] = a[i];
a[i] = a[len - i - 1];
a[len - i - 1] = a[len];
}

a[len] = 0;
}

/*
* reverse string via a temp variable
*/
void foo2(char* a) {
char temp;
int len = strlen(a);
int i;

for (i = 0; i < len / 2; i++) {
temp = a[i];
a[i] = a[len - i - 1];
a[len - i - 1] = temp;
}
}

/*
* reverse string via XORs
*/
void foo3(char* a) {
int len = strlen(a);
int i;

for (i = 0; i < len / 2; i++) {
a[len - i - 1] ^= a[i];
a[i] ^= a[len - i - 1];
a[len - i - 1] ^= a[i];
}
}

void main(void) {
/* declare few strings to be put into test */
char* a = "abcd";
char* b = "abcde";
char* c = "abcdef";

/* reverse the strings above */
foo1(a);
foo2(b);
foo3(c);

/* print results */
printf("%s\n%s\n%s\n", a, b, c);
}


光从C语言的表象上看上面的代码,一时还真会觉得: foo1()与foo3()几乎一样,只是没有了"运算";而这两者又比foo2()要节省空间,因为没有多用一个"临时变量".

那么让我们来看看特定于一个编译器上,上面的代码实际表达了怎样的意思:
将上面的代码(str.c)以VC8的编译器编译,命令用的是: cl /Og /Ot /Ox str.c

foo1():
[code]00401000 /$ 8B4C24 04 mov ecx,dword ptr ss:[esp+4]
00401004 |. 55 push ebp
00401005 |. 56 push esi
00401006 |. 57 push edi
00401007 |. 8BF9 mov edi,ecx
00401009 |. 8D57 01 lea edx,dword ptr ds:[edi+1]
0040100C |. 8D6424 00 lea esp,dword ptr ss:[esp]
00401010 |> 8A07 /mov al,byte ptr ds:[edi]
00401012 |. 83C7 01 |add edi,1
00401015 |. 84C0 |test al,al
00401017 |.^ 75 F7 \jnz short str.00401010
00401019 |. 2BFA sub edi,edx
0040101B |. 8BC7 mov eax,edi
0040101D |. 99 cdq
0040101E |. 2BC2 sub eax,edx
00401020 |. 8BE8 mov ebp,eax
00401022 |. D1FD sar ebp,1
00401024 |. 33F6 xor esi,esi
00401026 |. 85ED test ebp,ebp
00401028 |. 7E 23 jle short str.0040104D
0040102A |. 8D540F FF lea edx,dword ptr ds:[edi+ecx-1]
0040102E |. 8BFF mov edi,edi
00401030 |> 0FB6040E /movzx eax,byte ptr ds:[esi+ecx]
00401034 |. 88040F |mov byte ptr ds:[edi+ecx],al
00401037 |. 0FB602 |movzx eax,byte ptr ds:[edx]
0040103A |. 88040E |mov byte ptr ds:[esi+ecx],al
0040103D |. 0FB6040F |movzx eax,byte ptr ds:[edi+ecx]
00401041 |. 8802 |mov byte ptr ds:[edx],al
00401043 |. 83C6 01 |add esi,1
00401046 |. 83EA 01 |sub edx,1
00401049 |. 3BF5 |cmp esi,ebp
0040104B |.^ 7C E3 \jl short str.00401030
0040104D |> C6040F 00 mov byte ptr ds:[edi+ecx],0
00401051 |. 5F pop edi
00401052 |. 5E pop esi
00401053 |. 5D pop ebp
00401054 \. C3 retn[/code]

foo2():
[code]00401060 /$ 53 push ebx
00401061 |. 56 push esi
00401062 |. 57 push edi
00401063 |. 8B7C24 10 mov edi,dword ptr ss:[esp+10]
00401067 |. 8BC7 mov eax,edi
00401069 |. 8D50 01 lea edx,dword ptr ds:[eax+1]
0040106C |. 8D6424 00 lea esp,dword ptr ss:[esp]
00401070 |> 8A08 /mov cl,byte ptr ds:[eax]
00401072 |. 83C0 01 |add eax,1
00401075 |. 84C9 |test cl,cl
00401077 |.^ 75 F7 \jnz short str.00401070
00401079 |. 2BC2 sub eax,edx
0040107B |. 8BD8 mov ebx,eax
0040107D |. 99 cdq
0040107E |. 2BC2 sub eax,edx
00401080 |. 8BF0 mov esi,eax
00401082 |. D1FE sar esi,1
00401084 |. 33C9 xor ecx,ecx
00401086 |. 85F6 test esi,esi
00401088 |. 7E 1A jle short str.004010A4
0040108A |. 8D543B FF lea edx,dword ptr ds:[ebx+edi-1]
0040108E |. 8BFF mov edi,edi
00401090 |> 8A1A /mov bl,byte ptr ds:[edx]
00401092 |. 8A0439 |mov al,byte ptr ds:[ecx+edi]
00401095 |. 881C39 |mov byte ptr ds:[ecx+edi],bl
00401098 |. 8802 |mov byte ptr ds:[edx],al
0040109A |. 83C1 01 |add ecx,1
0040109D |. 83EA 01 |sub edx,1
004010A0 |. 3BCE |cmp ecx,esi
004010A2 |.^ 7C EC \jl short str.00401090
004010A4 |> 5F pop edi
004010A5 |. 5E pop esi
004010A6 |. 5B pop ebx
004010A7 \. C3 retn[/code]

foo3():
[code]004010B0 /$ 53 push ebx
004010B1 |. 56 push esi
004010B2 |. 8B7424 0C mov esi,dword ptr ss:[esp+C]
004010B6 |. 8BC6 mov eax,esi
004010B8 |. 57 push edi
004010B9 |. 8D50 01 lea edx,dword ptr ds:[eax+1]
004010BC |. 8D6424 00 lea esp,dword ptr ss:[esp]
004010C0 |> 8A08 /mov cl,byte ptr ds:[eax]
004010C2 |. 83C0 01 |add eax,1
004010C5 |. 84C9 |test cl,cl
004010C7 |.^ 75 F7 \jnz short str.004010C0
004010C9 |. 2BC2 sub eax,edx
004010CB |. 8BD8 mov ebx,eax
004010CD |. 99 cdq
004010CE |. 2BC2 sub eax,edx
004010D0 |. 8BF8 mov edi,eax
004010D2 |. D1FF sar edi,1
004010D4 |. 33C9 xor ecx,ecx
004010D6 |. 85FF test edi,edi
004010D8 |. 7E 20 jle short str.004010FA
004010DA |. 8D5433 FF lea edx,dword ptr ds:[ebx+esi-1]
004010DE |. 8BFF mov edi,edi
004010E0 |> 0FB60431 /movzx eax,byte ptr ds:[ecx+esi]
004010E4 |. 3002 |xor byte ptr ds:[edx],al
004010E6 |. 8A02 |mov al,byte ptr ds:[edx]
004010E8 |. 300431 |xor byte ptr ds:[ecx+esi],al
004010EB |. 8A0431 |mov al,byte ptr ds:[ecx+esi]
004010EE |. 3002 |xor byte ptr ds:[edx],al
004010F0 |. 83C1 01 |add ecx,1
004010F3 |. 83EA 01 |sub edx,1
004010F6 |. 3BCF |cmp ecx,edi
004010F8 |.^ 7C E6 \jl short str.004010E0
004010FA |> 5F pop edi
004010FB |. 5E pop esi
004010FC |. 5B pop ebx
004010FD \. C3 retn[/code]

总体看看这三份代码,可以看到strlen函数都被inline进来了,所以三份代码的开头部分都有一个小循环,用于计算数组的长度(题外话: 我见的最多的strlen实现是用rep实现的啊...这里为什么是显式循环,怪哉).
接下来,先对比foo1()与foo3().主要关注靠近代码尾部的循环,发现内容几乎是一样的,除了foo3()里出现了3个xor,而foo1()里的对应位置上指令是mov.查阅x86的手册可以知道,在i486,i586等机器上mov mem, reg需要1个时钟周期,而xor mem, reg需要3个.确实,在这里运算与否有那么点细微的性能差距,但是根据80/20法则,这点差距是否真的能体现在程序的实际运行里值得疑问.
然后,再看上面那对与foo2()的比较.在C源代码中,可以看到我们声明了一个临时变量char temp,按照土豆同学所说的"一般常识",这个变量应该被分配在栈上了.可是事实上呢? 从编译出来的结果,可以看到这个temp从来没有被保存在栈上,而是直接分配在了寄存器上.有人要问"具体分配到哪个寄存器了呢?",答案更有趣:其实是分配到2个寄存器,al与bl上了;换句话说,"temp"这个变量就像存在有两份一样,分别储存了被交换的数据的两边.要说"占用了额外的存储空间",那也只能说是多用了个寄存器(仔细观察会发现其实也没多用),而这其实是个好事.在如此简单的函数中,寄存器不会不够用,而是有多的没用到;能有效分配寄存器,实际上让程序更高效了.
硬要比较这3个编译结果,应该能看出,[b][color=Blue]foo2()才是最快而且不浪费(栈)空间的版本,另外两个都差不多.[/color][/b]当然这是吹毛求疵了,实际运行的话很可能看不出什么性能差别.
需要重申的是,[b]上面给出的结果是基于特定的编译器(VC8)与特定平台(Win32/i586)的组合下的结果,并不代表所有编译器的特性或所有平台的特性.[/b]

本来,只需要交换两个变量的值的话,用异或的方法是个相当不错的选择(本来xor reg, reg只需要1个时钟周期),但这里不巧遇上了数组而需要间接寻址,就(细微得)慢了.把结束标记的0(一个必须要分配,平时却没显著作用的空间)作为临时变量也是个不错的方案,但同样是遭遇到数组访问的问题而受到了拖累.反而在源代码里用了"额外的临时变量"的foo2()得到了不错的优化.这里的启示是: 没必要的时候,不要乱做优化.首先,凭"一般常识"而不是[b]profile[/b]做出的优化决定很可能并不会给程序带来显著的性能提升.其次,耍小聪明的优化反而可能干扰编译器的判断,从而阻挠了一些优化,反而使代码变得更慢.那就得不偿失了.

面试官们的水平也还需要加强啊...那是哪间公司来着? 反正我没记住名字,算了.

说到这里倒是想起我以前用过的BlowfishJ,一个Java的Blowfish算法实现.它做了些极端的优化来试图克服Java本身性能上的缺陷.其中,在编码与解码的方法里,它都是先把一个长度为18的int[] (也就是那个P box)全部赋值给局部变量,然后再进入循环运算.在寄存器比较多的机器上,这确实有助于JITter的优化,尽可能利用寄存器而减少间接寻址.
所以说我实在挺不理解,在一些trivial的地方用尽心思去减少局部变量的使用,到底能有什么好处... = =

=================================================================

另外,既然提到这个话题,希望能引起注意的是异或交换方法的简写形式.相信有一定C++经验的人都会见过这个:
inline void swap(int& a, int& b) {
a ^= b ^= a ^= b;
}

好吧,用异或运算本来就很难支持泛型(因为不是什么都能拿来算--除非cast成指针,那没话说了),所以这里只是简单的用了int型而没用template.这么写在C++里是(很可能)没问题,但并不意味着能广泛应用到其它C-like语言中.就不提C++的pass-by-reference语法不能在C或者Java里用,关键是中间的那句:
[code]a ^= b ^= a ^= b;[/code]
要是在Java或者C#执行这句,就会发现b虽然正确的得到了a原本的值,但a在结束时却总是0.所以同一个简写,换到Java与C#中得这样写:
[code]a = (b ^= a ^= b) ^ a;[/code]
原因也是与运算顺序的规定相关.C/C++中虽然没规定表达式的运算顺序,不过规定了赋值顺序一定是右结合的,所以那个简单版的简写(多半能)行得通(行不通的例子请参考[url=http://gcc.gnu.org/bugzilla/show_bug.cgi?id=39121]这里[/url]).但Java/C#严格定义了表达式的运算顺序一定是从左向右,赋值顺序是从右向左,所以在遇到^=运算符时,需要首先将左操作数装载,再装载右操作数.这么做的后果是最左边的^=的左操作数的值是"旧"的,因而在简单版简写中等同于与自身做了异或,结果自然是0.
以JVM bytecode来说明,简单版简写编译出来是这样:
[code]iload_1 // 关键差异
iload_2
iload_1
iload_2
ixor
dup
istore_1
ixor
dup
istore_2
ixor
istore_1[/code]

而带括号的版本是这样:
[code]iload_2
iload_1
iload_2
ixor
dup
istore_1
ixor
dup
istore_2
iload_1 // 关键差异
ixor
istore_1[/code]
可以观察到注释为"关键差异"的行出现的位置的不同,导致了最终运算结果的不同.

下面具体举几个例子:
·可以用简单版简写的:
C/C++: (Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 14.00.50727.42 for 80x86测试)
#include <stdio.h>

void main(void) {
int i = 1, j = 2, k = 3, l = 4;

i ^= j ^= i ^= j;
k = (l ^= k ^= l) ^ k;

printf("i = %d, j = %d\nk = %d, l = %d",
i, j, k, l); // i = 2, j = 1, k = 4, l = 3
}

D: (DMD 2.004测试)
[code]void main(char[][] args) {
int i = 1, j = 2, k = 3, l = 4;

i ^= j ^= i ^= j;
k = (l ^= k ^= l) ^ k;

printf("i = %d, j = %d\nk = %d, l = %d",
i, j, k, l); // i = 2, j = 1, k = 4, l = 3
}[/code]
TJS2: (KiriKiri 2.29测试)
[code][iscript]
tf.i = 1, tf.j = 2, tf.k = 3, tf.l = 4;
tf.i ^= tf.j ^= tf.i ^= tf.j;
tf.k = (tf.l ^= tf.k ^= tf.l) ^ tf.k;
[endscript]
tf.i = [emb exp="tf.i"], tf.j = [emb exp="tf.j"][r]
tf.k = [emb exp="tf.k"], tf.l = [emb exp="tf.l"][l][r]
; tf.i = 2, tf.j = 1, tf.k = 4, tf.l = 3[/code]

·需要用括号版简写的:
Java: (JRE 1.5.0/1.6.0测试)
public class Swap {
public static void main(String[] args) {
int i = 1, j = 2, k = 3, l = 4;

i ^= j ^= i ^= j;
k = (l ^= k ^= l) ^ k;

System.out.printf("i = %d, j = %d\nk = %d, l = %d",
i, j, k, l); // i = 0, j = 1, k = 4, j = 3
}
}

C#: (.NET Framework 2.0测试)
[code]using System;

public class Swap {
public static void Main(string[] args) {
int i = 1, j = 2, k = 3, l = 4;

i ^= j ^= i ^= j;
k = (l ^= k ^= l) ^ k;

Console.WriteLine("i = {0}, j = {1}{2}k = {3}, l = {4}",
i.ToString(), j.ToString(), Environment.NewLine,
k.ToString(), l.ToString()); // i = 0, j = 1, k = 4, j = 3
}
}
[/code]
JavaScript: (IE6/IE7/FF2测试)
[code]<html>
<body>
<script type="text/javascript">
var i = 1, j = 2, k = 3, l = 4;
i ^= j ^= i ^= j;
k = (l ^= k ^= l) ^ k;
document.write("i = " + i + ", j = " + j + "<br />k = " + k + ", l = " + l);
// i = 0, j = 1, k = 4, j = 3
</script>
</body>
</html>[/code]
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值