作者:Glen Pepicelli; v_gyc
原文地址: http://www.onjava.com/pub/a/onjava/2005/02/02/bitsets.html
中文地址: http://www.matrix.org.cn/resource/article/43/43978_Java_Bitfields_Bitboards.html
关键词: Java Bitfields Bitboards
快速小测试:如何重写下面的语句?要求不使用条件判断语句交换两个常量的值。
if (x == a) x= b;
else x= a;
答案:
x= a ^ b ^ x;
//此处变量x等于a或者等于b
字符^是逻辑异或XOR运算符。上面代码为什么能工作呢?使用XOR运算符,一个变量执行2次异或运算与另一个变量,总是返回变量自身。
虽然Java位操作的魔术不是很普及,但是深入研究此技术有助于改善程序性能。在作者的机器配置下进行基准测试,重写版本需5.2秒,使用逻辑判断的版本需5.6秒。(测试代码参见资源部分)。减少使用判断语句通常可以提高在现代多管道处理器上的程序性能。
对于Java程序员来说,特别有益的是一些用来处理所谓bitsets位集的技巧。 Java语言中的原始类型int和long也可被视为32位或64位的位集合。可以组合这些简单的结构来表示大型数据结构,比如数组。另外,还有一些特定的运算(bit-parallel 并行位运算),可以有效的将多个不同的运算压缩为一个运算。使用以bit为单位的细粒度数据,而不是以integer整数为运算单位,我们会发现——对于integer整数来说有时进行的仅仅一步运算,如果使用bit位作为运算单位实际上可能进行了许多操作。可以认为Java语言中的位并行运算是软件多通路技术的一种应用。
高级对弈程序如Crafty(使用C语言编写)使用了特殊的数据结构——bitboard位图棋盘来表示棋子的位置,这样比使用数组的速度快很多。与C程序员相比,Java程序员更不应该使用数组。与C语言中数组的高效实现相比,Java语言中的数组实现(虽然提供了边界检查以及GC机制等额外功能)效率低下。为比较使用整数方案以及使用数组方案的性能,在作者机器(Windows XP, AMD Athlon, 热部署Java虚拟机?)上进行的简单测试显示使用数组方案的运行时间是使用整数方案的160%,并且此处未考虑无用单元回收(garbage collection)对性能的影响。在某些情况下,可以使用整数位集替代数组。
下面会探讨一下从汇编语言时代就存在的古董技巧,同时特别关注这些技巧在位集上的应用。Java作为可移植语言本身对位运算提供了良好的支持。进一步,Java Tiger版本的API中又添加了一些用于位处理的方法。
典型Java环境下的位优化
Java程序运行在机器和操作系统之上,但是与机器和操作系统中充斥的位运算不同,Java程序基本上不包含大量的位运算。虽然现代CPU提供了特殊的位操作指令,不过在Java语言中无法执行这些特殊指令。JVM仅提供了有符号移位、无符号移位、位异或(bitewise XOR)、位或(bitwise OR)、位并(bitwise AND)以及位否(bitwise NOT)等位运算。有意思的是,并不仅仅是Java没有提供额外的位运算,很多汇编程序员编写操作CPU的程序时,也会发现缺少同样的位运算指令。两类程序员在位运算上的境遇其实一样。汇编程序员不得不通过软件模拟来实现这些指令,同时尽可能的保证效率。 Tiger版本的Java中许多方法也是通过使用纯java代码来模拟实现这些指令的。
进行性能微调操作的C程序员可能会审查实际产生的汇编代码、参考目标CPU的优化器手册、统计代码执行的指令周期数目等方式来改善程序性能。与之相对,在Java程序里进行这样的底层优化的一个实际问题是无法确定优化的效果。Sun公司的Java虚拟机规范(JVM spec)未给出某特定操作执行的相对速度。可以将自己认为执行速度快的代码一部分再一部分的在Java虚拟机中执行,结果只能令人震惊——速度没有提高甚至优化部分可能降低原来程序性能。 Java虚拟机可能会搅乱破坏你做出的优化,也可能在内部对一般的代码优化。 在任何情况下,通过JVM实现优化, 即使编译过后的程序提供这样的支持,相应得优化也需要进行基准测试。
标准的查看字节码的工具是JDK中包含的javap命令。Eclipse的使用者也可以利用由Andrei Loskutov开发的字节码查看插件。 Sun的网站上提供了JVM设计和指令集合的参考书的在线版本。注意,每个Java虚拟机内包含两种类型的内存. 一种是栈(stack),用来存储局部原始类型变量和表达式;另一种是堆(heap),用来存储对象和数组。堆内存储的对象会被无用单元回收机制处理,而栈具有固定大小的内存块。虽然大多数程序仅仅使用了栈空间的很小部分,JVM规范声明用户可以认为栈至少有64k大小。请注意JVM可以被认为是一个32位或者64位的机器,所以字节byte以及位数少的原始类型的运算不大可能比整数int的运算更快。
2的补数
Java语言中,所有的整数原始类型都是有符号数字,使用2的补数形式表示。要操作这些原始类型,我们需要理解一些相关理论。
2的补数分成两种:非负补数和负补数。补数的最高位,也是符号位,在一般系统上在最左边。非负补数的此位是0。可以简单的从左到右读取这些普通的非负补数,将他们转换到任意进制的整数。如果符号位设置为1,此数就是负数,除去符号位的右边位集合与符号位一起表示此负数。
有两种方法来考察负的补数。 首先,可以从可能的最小负数数起直到-1,比如,8位字节,从最小的10000000(-128)开始,然后是10000001(-127),一直到11111111(-1)。另外一种考虑负的补数的方式有些古怪,当存在符号位时,用前面都是1后面跟着个0来代替前面都是0后面跟着个1的方式。然而,你最后需要从结果中减去1。 比如11111111时符号位后面跟着7个1,(视为10000000)表示负零(-0),然后添加(也可以认为是减去)1,得到-1,同样11111110(视为10000001,加上1是10000010)是-2,11111101(视为10000010,是-2,然后减去1)是-3,以此类推。
感觉上有些怪,我们可以混合位运算符号和数学运算符号来进行多种操作。比如将x变换为-x,可以表示为对x按位求反,然后加1,即(~x)+1,具体运算过程见下表。
布尔标记和标准布尔位集
如下的位标记模式(bit flag pattern)是很普通的技术常识,在图形用户界面GUI程序的公用API中得到了广泛应用。呵呵,我们可能正在为资源有限设备如蜂窝电话或者PDA编写一个Java GUI程序。对GUI中每个构件如按钮(button)和下拉列表(drop-down list)都拥有一些Boolean选择项标记。使用位标记,可以将许多选择项安排到一个变量中。
//The constants to use with our GUI widgets: GUI构件使用的选择项常量
final int visible = 1 << 0; // 1 可见?
final int enabled = 1 << 1; // 2 使能?
final int focusable = 1 << 2; // 4 focus?
final int borderOn = 1 << 3; // 8 有边界?
final int moveable = 1 << 4; // 16 可移动?
final int editable = 1 << 5; // 32 可编辑?
final int borderStyleA = 1 << 6; // 64 有样式A边界?
final int borderStyleB = 1 << 7; //128有样式B边界?
final int borderStyleC = 1 << 8; //256有样式C边界?
final int borderStyleD = 1 << 9; //512有样式D边界?
//etc.
myButton.setOptions( 1+2+4 );
//set a single widget.
int myDefault= 1+2+4; //A list of options.
int myExtras = 32+128; //Another list.
myButtonA.setOptions( myDefault );
myButtonB.setOptions( myDefault | myExtras );
在程序中可以将许多Boolean选择项的位标记组合成一个参数,在一次符值操作中全部传递,这基本上不需要什么时间。API可能会声明,每个组件在某时仅能使用边界样式A、边界样式B、边界样式C、或者边界样式D中的一种,那么可以通过使用掩码(mask)来获取对应的4位,然后检查这4位中至多有一个1。下面代码中的小技巧稍后会解释。
int illegalOptionCombo= //不合法的组合框选项
2+ 64+ 128+ 512; // 10 11000010
int borderOptionMask= //边界选项掩码
64+ 128+ 256+ 512; // 11 11000000
int temp= illegalOptionCombo & //获取4个边界选项的所有的1位
borderOptionMask // 10 11000000
int rightmostBit= //获得temp的最右边的1
temp & ( -temp ); // 00 01000000
如果变量temp与rightMostBit不相等,那么表明temp必然含有多个1位。因为如果rightMostBit为0那么temp也应该是0,否则temp仅有一个1位。
if (temp != rightmostBit)
throw new IllegalArgumentException();
上面的示例是个玩具程序。现实中,AWT和Swing使用了位标记模式,但是使用方式的不连贯。java.awt.geom.AffineTransform类中使用了很多,java.awt.Font 和java.awt.InputEvent也使用了。
通用的位运算以及JDK 1.5的方法
为了更好的应用位运算,需要掌握所谓的标准技巧,也就是那些可以应用的位运算方法。J2SE 5.0 Tiger版本内增加了一些新的位运算API。如果你使用的是老版本,只要剪切粘贴那些方法实现到你的代码中。最近由Henry S. Warren,Jr编写的书籍Hacker's Delight内包含了很多关于位运算算法的资料。
下表展示了一些运算,这些运算可以通过一行代码或者通过一次调用API方法实现
欲了解上面API方法的运行时间,可以阅读JDK中相应的源码。一些方法可能更难以理解一些。所有方法都在Hacker's Delight一书中进行了解释。这些API方法基本上都是一行代码或者很少几行代码实现的,比下面给出的的highestOneBit(int)方法代码。
public static int highestOneBit(int i)
{
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
高级秘笈,同志们!(棋盘的位图棋盘模式)
下面部分,就像烈酒伏特加和变幻莫测的镜子一样,其中的位运算变得很复杂。
在冷战发展到顶点的时期,国际象棋是计算机科学的一个研究热点。原苏联和美国各自独立的提出了新的象棋数据结构——位图棋盘。美国团队——Slate和Atkin,基于Chess 4.x软件出版了《人类和机器的国际象棋技能》一书,其中有一章讨论了位图棋盘算法,这可能是最早的关于位图棋盘算法的印刷品。 原苏联团队,包括Donskoy以及其他人员,开发了使用位图棋盘算法的程序Kaissa。这两个软件在世界范围都具有胜利性的竞争力。
在讨论位图棋盘算法前,我们先来看看使用Java语言(或其他许多语言)表示棋盘的标准方法。
//棋盘上64个格子所有可能状态的整数枚举
final int EMPTY = 0;
final int WHITE_PAWN = 1;
final int WHITE_KNIGHT = 2;
final int WHITE_BISHOP = 3;
final int WHITE_ROOK = 4;
final int WHITE_QUEEN = 5;
final int WHITE_KING = 6;
final int BLACK_PAWN = 7;
final int BLACK_KNIGHT = 8;
final int BLACK_BISHOP = 9;
final int BLACK_ROOK = 10;
final int BLACK_QUEEN = 11;
final int BLACK_KING = 12;
//使用含有64个元素的整数数组表示64个格子
int[] board= new int[64];
使用数组方法很直观,相反,位图棋盘算法的数据结构是使用12个64位的位集表示,每个表示一种类型的棋子(每方6种棋子,共12种)。 如下图,视觉上看上去好像是一个堆在另一个的上面。
//为棋盘声明12个64位整数
long WP, WN, WB, WR, WQ, WK, BP, BN, BB, BR, BQ, BK;
图1. 位图棋盘数据结构
空的位图棋盘在那里?由于EMPTY位图棋盘可以通过其他12计算出来,因此声明它会产生冗余数据。为计算空位图棋盘,将12个位图棋盘相加后求反即可。
long NOT_EMPTY=
WP | WN | WB | WR | WQ | WK |
BP | BN | BB | BR | BQ | BK ;
long EMPTY = ~NOT_EMPTY;
象棋程序运行时需要生成很多合理的走棋步骤,从中挑选最佳的。这需要完成一些计算,以确定棋盘上被攻击的棋格,避免棋子在这些棋格上被攻击,这样王棋子被将的棋格以及被将死的棋格能够确定下来。每个棋子具有不同的攻击方式。考察处理这些不同攻击方式的代码,可以看到位图棋盘算法的一些优缺点。使用位图棋盘方案可以很优雅的解决一些程序任务,但在另外一些方面却不是这样。
首先看王棋子,很简单的,王只攻击相邻棋格内的棋子。根据王在棋盘上的棋格的不同位置,被攻击的有3个到8个棋格。王可能位于棋盘中间格上、边上、或者角上,所有情况都需要代码处理。
程序在运行时计算王的可能的64种攻击方式,首先从基本的方式考虑,具有8种攻击方式,然后推出特殊的情形下的攻击方式。首先,在中间的棋格上生成掩码,比如在第10个即B2(从A1开始,A2,A3,到A8,然后B1,B2,…B8,依次类推)。图2 显示了几个表示掩码的long数值。
图2 确定王的攻击方式
long KING_ON_B2=
1L | 1L << 1 | 1L << 2 |
1L << 7 | 1L << 9 |
1L << 15 | 1L << 16 | 1L << 17;
//王在B2时,被攻击的格子。(Matrix注:2,3行好像不对,第2行应该是1L << 8 | 1L << 10 |,第3行也一样)
从图上可以看出,我们可能想将被攻击的棋格在棋盘上左右或者上下移动,不过向左和向右移动时要注意边界的影响。
SHIFTED_LEFT= KING_ON_B2 >>> 1; //左移一格
悠忽!我们将王从B2移动到了B1(见图2).象棋中一个垂直列称为纵线,将被攻击的棋格左移一列时,从图中可以看出最右边的纵线H上的棋格并未被攻击,相应的数字应该置0。代码如下
final long FILE_H=
1L | (1L<<8) | (1L<<16) | (1L<<24) |
(1L<<32) | (1L<<40) | (1L<<48) | (1L<<56);
//王左移到A2时,被攻击的棋格
KING_ON_A2= (KING_ON_B2 >>> 1) & ~FILE_H;
相应的,向右移的计算方式如下:
KING_ON_B3= (KING_ON_B2 >>> 1) & ~FILE_A;
向上和向下移动的版本如下:
KING_ON_B1= MASK_KING_ON_B2 << 8;
KING_ON_B3= MASK_KING_ON_B2 >>> 8;
实际上,我们可以避免使用硬编码的方式来获取王攻击棋格的64种可能情况,同时,也希望避免使用数组,因此,此处我们就不构建王攻击棋格的64个元素数组了。 一种替代方案是使用64路的switch语句——代码看起来不漂亮,不过可以很好的完成工作。
下面来看看“兵”,与每方仅有一个王不同,棋盘上总共有8个兵。可以参照上面计算计算王的攻击棋格的方法很容易的计算出所有8个兵的攻击棋格。注意,兵只能攻击对角线上相邻的棋格。如果向上或者向下移动兵,相应数值要移动8位,如果是左右移动,相应数值要移动1位。因此在对角线上数值要移动7(8-1)位或者9(8+1)位
PAWN_ATTACKS=
((WP << 7) & ~RANK_A) & ((WP <<9) & ~RANK_H)
图 3. 白方兵的攻击棋格
无论棋盘上有个兵,无论兵在棋盘那个的位置上,上面代码都有效。Robert Hyatt,Crafty程序的作者,称上面的算法为位并行运算(bit-parallel operation),它同时计算出了多个棋子的信息。位并行表达式功能强大,在你自己的程序中应该作为关键技术应用。进而,如果使用了很多位并行运算,那么这些运算可能是进行位运算优化的良好的候选。
作为对比,考虑如何使用数组来表达兵的攻击方式
for (int i=0; i<56; i++)
{
if (board[i]= WHITE_PAWN)
{
if ((i+1) % 8 != 0) pawnAttacks[i+9]= true;
if ((i+1) % 8 != 1) pawnAttacks[i+7]= true;
}
}
上面代码中,几乎对整个棋盘进行循环,速度不快。可以重新编写代码,标记各个兵的位置,对每个兵的位置循环确定攻击位置,而不需要对棋格进行循环。不过,这样使用位集方法,程序中还是会有更多的字节需要运行。
棋子马的计算方式与王和兵相近。同上面处理王的情形相同,可以使用一个预先计算出来的表来确定马的攻击棋格。由于每方具有多于一个马,因此计算马的攻击棋格的运算在技术上也是位并行运算。不过,现实中每方不大可能拥有多于两个的马,所以没有什么实践意义(选手可以选择提升兵为马,就拥有了多于两个马。实际上不大可能。译注:此处请参考国际象棋规则)。
棋子象、军(车)、后都可以在棋盘上移动多步。虽然它们各自的可能攻击棋格都是一样的,但实际的攻击取决于在各自的攻击路线上的棋子。为确定合理的移动方式,必须单独处理攻击路线上的每个棋格。这是最坏的情况,也没有可能的位并行算法可以使用,这样不得不同数组方式一样处理每个棋格。另外,使用位图棋盘访问一个个棋格同使用数组访问棋格相比更笨拙(例如,易出错)。
使用位图棋盘的优势就是可以使用掩码处理许多常见的象棋程序中的任务。拿棋子象来说, 想确定有多少个对手的兵在象的可能多步攻击范围(图4中,棋盘上的颜色)内。图4 演示了这个攻击掩码问题。
图4 . 有多少个兵在红色方格上
位图棋盘模式的优势和不足
国际象棋规则相当复杂,这也意味着用位图棋盘方法来处理这些规则时有优势也有不足。使用位图棋盘处理某些规则很快,处理另外一些时就比较慢。上面已经给出了使用位图棋盘方法的低效代码片断,位图棋盘算法并不是魔法粉,什么都可以高效实现。可以想象一种与国际象棋非常相近的游戏(可能有不同的棋子), 应用位集运算会导致相反的效果或者根本不需要这样复杂。使用位集运算进行优化必须经过审慎的考虑。
一般来说,位运算具有如下的优势和不足:
优势:
· 占用内存少
· 具有高效的拷贝、设值、比较运算等
· 位并行运算的可能
不足:
· 难于调试
· 访问单独位的复杂性,易出错
· 难于扩展
· 不符合面向对象的风格
· 不能处理所有的任务;需要大量的工作
位图棋盘模式的概括
为概括上面的象棋例子,可以将棋盘的水平和纵向的位置想象为两个独立的变量或者属性,这需要8x8一共64位来表示。另外,需要12层——每个棋子用一层表示。位图棋盘方案的扩展方式有两种:1)使用更多的位来扩展棋盘,添加更多的棋格 2)使用更多的层来增加棋子。实际对弈每方有64位的最大限制。但是假设我们拥有一个128位的JVM, 里面的具有128位的doublelong类型,有了这128位,棋盘上就有了足够的空间来在同一层中摆放黑白双方的16个兵(8*8*2=128)。如此可以减少需要的层数量,并且可能简化一些难以理解的运算,但是却会增加处理单独一方兵的运算的复杂度并降低其速度。所有的Java位运算都会操作基本类型的所有位。数据在自己所在层内仅使用本身的各位进行位运算或者函数调用时,效率会高一些。使用位并行运算处理层内的所有位的速度比处理其中一些位的速度要快。对于增加的64位,我们可以获得一些巧妙的使用方法,但是我们不希望将12个棋子也混合进来。
如果在同一层内使用多于2个变量,也可以同时改变一层的所有变量。考虑图5中表示的3D tic-tac-toe(译注:google)游戏,3个轴向的每个轴向的上面可能有3变量,一共有3*3*3一共27个可能值。这样对局的每方需要使用一个32位的位集合。
图 5. 3D tic-tac-toe 游戏的位模型
进一步,串联多个64位集合可以用于实现Conway生命游戏(Conway’s Game of Life, 见图6),一个在大的栅格上进行的模拟游戏。 游戏中新单元的生死由相邻单元的数量确定,游戏在一代代的单元繁衍中进行。当一个死去单元的周围具有3个生存单元时会复活。 一个单元的相邻单元中没有生存的或者仅有一个生存的,这个单元就死亡了(由于寂寞)。具有多于三个相邻生存单元的单元也会死亡(由于人口拥挤)。相邻单元的出生(复活)、生活,死亡,会对当前单元的状态造成很多改变。图6中显示了一个生命构造图,它会不断繁衍,生存下去,从而通过栅格。使用下面描述的算法我们可以生成模拟生存过程的下一步:
1. 首先,与象棋游戏相似,除主栅格外另外声明8个栅格层,每层表示某单元格的八个相邻单元格中的一个。通过移位运算计算相邻单元格的生存数量(某些移位数据必须从相邻的位中获得)。
2. 已经有了八个层次,需要计算每个单元格的运算和,声明9个额外的位集合来表示这些结果。比如,位集合变量SUM_IS_0到SUM_IS_8。这里可以使用递归算法,先计算2层的,然后计算3层、4层......直道第8层。
3. 获得相邻单元格生存数量后,可以容易的应用游戏规则产生单元格的下一代。
统计各层表示的相邻单元格生存数量
//S0表示“和为0”,其余类似
//L1 表示“第一层”,其余类似
//Look at just the first two layers: 层1和层2
S0= ~(L1 | L2);
S1= L1 ^ L2;
S2= L1 & L2;
//Now look at the third layer:第3层
S3 = L3 & S2;
S2 = (S2 & ~L3) | (S1 & L3);
S1 = (S1 & ~L3) | (S0 & L3);
S0 = S0 & ~L3;
//The fourth layer.第4层
S4 = S3 & L4;
S3 = (S3 & ~L4) | (S2 & L4);
S2 = (S2 & ~L4) | (S1 & L4);
S1 = (S1 & ~L4) | (S0 & L4);
S0 = S0 & ~L4;
//Repeat this pattern up to the 8th layer.重复此模式直到第8层
计算8层的全部代码有42行,如果需要也可以增加些。不过这42行代码有些优点,其中没有使用逻辑判断——逻辑判断会降低处理器的速度, 代码明了简单,可以通过即时(JIT)或者热部署(Hotspot)Java编译器的编译。最重要的是,对于全部64个单元格所需要的数值,是通过并行计算获得的。
图 6. 生命游戏的模型
与非游戏应用相比,位运算在游戏等应用中更容易得到应用。其原因是游戏应用如象棋的数据模型中位与位之间具有丰富的关系。象棋的数据模型具有12层64位的位集,合计共764位。768位中的每位基本上都同其余各位有一定形式的关联。在商业应用中,信息通常不具有这样紧密的关系。
结论
思想开放的程序员,可能在任何问题领域中应用位运算。然而,在特定情形下应用位运算合适与否取决于开发者的判断。老实说,可能根本就不需要使用这些技巧。不过,上面提到的方法在特定Java应用可能正是优化程序所需要的。如果不是这样,也可以使用这些方法以非常hacking的方式解决一些问题,同时迷惑你的朋友们!
享受位运算的快乐吧!
资源
·onjava.com: onjava.com
·Matrix-Java开发者社区: http://www.matrix.org.cn/
· 本文的代码
· Hacker's Delight 一书的链接
· Eclipse的字节码查看插件
Glen Pepicelli 是一位软件专家.他和他的狗生活在纽约州北部地区.