Java中的位运算优化:位域、位图棋盘..(zz)

[算法]Java中的位运算优化:位域、位图棋盘..

作者:Glen Pepicelli

译者: v_gyc



版权声明:任何获得Matrix授权的网站,转载时请务必以超链接形式标明文章原始出处和作者信息及本声明
作者: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运算符。 上面代码为什么能工作呢?使用X OR运算符,一个变量执行2次异 或运算与另一个变量,总是返回变 量自身。
image

虽然Java位操作的魔术不是很 普及,但是深入研究此技术有助于 改善程序性能。在作者的机器配置 下进行基准测试,重写版本需5. 2秒,使用逻辑判断的版本需5. 6秒。(测试代码参见资源部分) 。减少使用判断语句通常可以提高 在现代多管道处理器上的程序性能

对于Java程序员来说,特别有 益的是一些用来处理所谓bits ets位集的技巧。 Java语言中的原始类型int 和long也可被视为32位或6 4位的位集合。可以组合这些简单 的结构来表示大型数据结构,比如 数组。另外,还有一些特定的运算 (bit-parallel 并行位运算),可以有效的将多个 不同的运算压缩为一个运算。使用 以bit为单位的细粒度数据,而 不是以integer整数为运算 单位,我们会发现——对于int eger整数来说有时进行的仅仅 一步运算,如果使用bit位作为 运算单位实际上可能进行了许多操 作。可以认为Java语言中的位 并行运算是软件多通路技术的一种 应用。

高级对弈程序如Crafty(使 用C语言编写)使用了特殊的数据 结构——bitboard位图棋 盘来表示棋子的位置,这样比使用 数组的速度快很多。与C程序员相 比,Java程序员更不应该使用 数组。与C语言中数组的高效实现 相比,Java语言中的数组实现 (虽然提供了边界检查以及GC机 制等额外功能)效率低下。为比较 使用整数方案以及使用数组方案的 性能,在作者机器(Window s XP, AMD Athlon, 热部署Java虚拟机?)上进行 的简单测试显示使用数组方案的运 行时间是使用整数方案的160% ,并且此处未考虑无用单元回收( garbage collection)对性能的 影响。在某些情况下,可以使用整 数位集替代数组。

下面会探讨一下从汇编语言时代就 存在的古董技巧,同时特别关注这 些技巧在位集上的应用。Java 作为可移植语言本身对位运算提供 了良好的支持。进一步,Java Tiger版本的API中又添加 了一些用于位处理的方法。

典型Java环境下的位优化
Java程序运行在机器和操作系 统之上,但是与机器和操作系统中 充斥的位运算不同,Java程序 基本上不包含大量的位运算。虽然 现代CPU提供了特殊的位操作指 令,不过在Java语言中无法执 行这些特殊指令。JVM仅提供了 有符号移位、无符号移位、位异或 (bitewise XOR)、位或(bitwise OR)、位并(bitwise AND)以及位否(bitwis e NOT)等位运算。有意思的是, 并不仅仅是Java没有提供额外 的位运算,很多汇编程序员编写操 作CPU的程序时,也会发现缺少 同样的位运算指令。两类程序员在 位运算上的境遇其实一样。汇编程 序员不得不通过软件模拟来实现这 些指令,同时尽可能的保证效率。 Tiger版本的Java中许多 方法也是通过使用纯java代码 来模拟实现这些指令的。

进行性能微调操作的C程序员可能 会审查实际产生的汇编代码、参考 目标CPU的优化器手册、统计代 码执行的指令周期数目等方式来改 善程序性能。与之相对,在Jav a程序里进行这样的底层优化的一 个实际问题是无法确定优化的效果 。Sun公司的Java虚拟机规 范(JVM spec)未给出某特定操作执行 的相对速度。可以将自己认为执行 速度快的代码一部分再一部分的在 Java虚拟机中执行,结果只能 令人震惊——速度没有提高甚至优 化部分可能降低原来程序性能。 Java虚拟机可能会搅乱破坏你 做出的优化,也可能在内部对一般 的代码优化。 在任何情况下,通过JVM实现优 化, 即使编译过后的程序提供这样的支 持,相应得优化也需要进行基准测 试。

标准的查看字节码的工具是JDK 中包含的javap命令。Ecl ipse的使用者也可以利用由A ndrei 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),一直到11111 111(-1)。另外一种考虑负 的补数的方式有些古怪,当存在符 号位时,用前面都是1后面跟着个 0来代替前面都是0后面跟着个1 的方式。然而,你最后需要从结果 中减去1。 比如11111111时符号位后 面跟着7个1,(视为10000 000)表示负零(-0),然后 添加(也可以认为是减去)1,得 到-1,同样11111110( 视为10000001,加上1是 10000010)是-2,11 111101(视为100000 10,是-2,然后减去1)是- 3,以此类推。

感觉上有些怪,我们可以混合位运 算符号和数学运算符号来进行多种 操作。比如将x变换为-x,可以 表示为对x按位求反,然后加1, 即(~x)+1,具体运算过程见 下表。

image


布尔标记和标准布尔位集
如下的位标记模式(bit flag pattern)是很普通的技术 常识,在图形用户界面GUI程序 的公用API中得到了广泛应用。 呵呵,我们可能正在为资源有限设 备如蜂窝电话或者PDA编写一个 Java GUI程序。对GUI中每个构件 如按钮(button)和下拉列 表(drop-down list)都拥有一些Boole an选择项标记。使用位标记,可 以将许多选择项安排到一个变量中

//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 );


在程序中可以将许多Boolea n选择项的位标记组合成一个参数 ,在一次符值操作中全部传递,这 基本上不需要什么时间。API可 能会声明,每个组件在某时仅能使 用边界样式A、边界样式B、边界 样式C、或者边界样式D中的一种 ,那么可以通过使用掩码(mas k)来获取对应的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与rightM ostBit不相等,那么表明t emp必然含有多个1位。因为如 果rightMostBit为0 那么temp也应该是0,否则t emp仅有一个1位。

if (temp != rightmostBit)
  throw new IllegalArgumentException();


上面的示例是个玩具程序。现实中 ,AWT和Swing使用了位标 记模式,但是使用方式的不连贯。 java.awt.geom.A ffineTransform类 中使用了很多,java.awt .Font 和java.awt.Input Event也使用了。

通用的位运算以及JDK 1.5的方法
为了更好的应用位运算,需要掌握 所谓的标准技巧,也就是那些可以 应用的位运算方法。J2SE 5.0 Tiger版本内增加了一些新的 位运算API。如果你使用的是老 版本,只要剪切粘贴那些方法实现 到你的代码中。最近由Henry S. Warren,Jr编写的书籍H acker's Delight内包含了很多关于 位运算算法的资料。

下表展示了一些运算,这些运算可 以通过一行代码或者通过一次调用 API方法实现

image

欲了解上面API方法的运行时间 ,可以阅读JDK中相应的源码。 一些方法可能更难以理解一些。所 有方法都在Hacker's Delight一书中进行了解释 。这些API方法基本上都是一行 代码或者很少几行代码实现的,比 下面给出的的highestOn eBit(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和Atki n,基于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种棋子,共1 2种)。 如下图,视觉上看上去好像是一个 堆在另一个的上面。

//为棋盘声明12个64位整数
long WP, WN, WB, WR, WQ, WK, BP, BN, BB, BR, BQ, BK;


image
图1. 位图棋盘数据结构

空的位图棋盘在那里?由于EMP TY位图棋盘可以通过其他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开始,A 2,A3,到A8,然后B1,B 2,…B8,依次类推)。图2 显示了几个表示掩码的long数 值。

image
图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移动到了B 1(见图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路的sw itch语句——代码看起来不漂 亮,不过可以很好的完成工作。

下面来看看“兵”,与每方仅有一 个王不同,棋盘上总共有8个兵。 可以参照上面计算计算王的攻击棋 格的方法很容易的计算出所有8个 兵的攻击棋格。注意,兵只能攻击 对角线上相邻的棋格。如果向上或 者向下移动兵,相应数值要移动8 位,如果是左右移动,相应数值要 移动1位。因此在对角线上数值要 移动7(8-1)位或者9(8+ 1)位

PAWN_ATTACKS=
((WP << 7) & ~RANK_A) & ((WP <<9) & ~RANK_H)


image
图 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 演示了这个攻击掩码问题。

image
图4 . 有多少个兵在红色方格上

位图棋盘模式的优势和不足
国际象棋规则相当复杂,这也意味 着用位图棋盘方法来处理这些规则 时有优势也有不足。使用位图棋盘 处理某些规则很快,处理另外一些 时就比较慢。上面已经给出了使用 位图棋盘方法的低效代码片断,位 图棋盘算法并不是魔法粉,什么都 可以高效实现。可以想象一种与国 际象棋非常相近的游戏(可能有不 同的棋子), 应用位集运算会导致相反的效果或 者根本不需要这样复杂。使用位集 运算进行优化必须经过审慎的考虑

一般来说,位运算具有如下的优势 和不足:

优势:
·        占用内存少
·        具有高效的拷贝、设值、比较运算
·        位并行运算的可能

不足:
·        难于调试
·        访问单独位的复杂性,易出错
·        难于扩展
·        不符合面向对象的风格
·        不能处理所有的任务;需要大量的 工作

位图棋盘模式的概括
为概括上面的象棋例子,可以将棋 盘的水平和纵向的位置想象为两个 独立的变量或者属性,这需要8x 8一共64位来表示。另外,需要 12层——每个棋子用一层表示。 位图棋盘方案的扩展方式有两种: 1)使用更多的位来扩展棋盘,添 加更多的棋格 2)使用更多的层来增加棋子。实 际对弈每方有64位的最大限制。 但是假设我们拥有一个128位的 JVM, 里面的具有128位的doubl elong类型,有了这128位 ,棋盘上就有了足够的空间来在同 一层中摆放黑白双方的16个兵( 8*8*2=128)。如此可以 减少需要的层数量,并且可能简化 一些难以理解的运算,但是却会增 加处理单独一方兵的运算的复杂度 并降低其速度。所有的Java位 运算都会操作基本类型的所有位。 数据在自己所在层内仅使用本身的 各位进行位运算或者函数调用时, 效率会高一些。使用位并行运算处 理层内的所有位的速度比处理其中 一些位的速度要快。对于增加的6 4位,我们可以获得一些巧妙的使 用方法,但是我们不希望将12个 棋子也混合进来。

如果在同一层内使用多于2个变量 ,也可以同时改变一层的所有变量 。考虑图5中表示的3D tic-tac-toe(译注: google)游戏,3个轴向的 每个轴向的上面可能有3变量,一 共有3*3*3一共27个可能值 。这样对局的每方需要使用一个3 2位的位集合。

image
图 5. 3D tic-tac-toe 游戏的位模型

进一步,串联多个64位集合可以 用于实现Conway生命游戏( Conway’s Game of Life, 见图6),一个在大的栅格上进行 的模拟游戏。 游戏中新单元的生死由相邻单元的 数量确定,游戏在一代代的单元繁 衍中进行。当一个死去单元的周围 具有3个生存单元时会复活。 一个单元的相邻单元中没有生存的 或者仅有一个生存的,这个单元就 死亡了(由于寂寞)。具有多于三 个相邻生存单元的单元也会死亡( 由于人口拥挤)。相邻单元的出生 (复活)、生活,死亡,会对当前 单元的状态造成很多改变。图6中 显示了一个生命构造图,它会不断 繁衍,生存下去,从而通过栅格。 使用下面描述的算法我们可以生成 模拟生存过程的下一步:

1.        首先,与象棋游戏相似,除主栅格 外另外声明8个栅格层,每层表示 某单元格的八个相邻单元格中的一 个。通过移位运算计算相邻单元格 的生存数量(某些移位数据必须从 相邻的位中获得)。
2.        已经有了八个层次,需要计算每个 单元格的运算和,声明9个额外的 位集合来表示这些结果。比如,位 集合变量SUM_IS_0到SU M_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 行代码有些优点,其中没有使用逻 辑判断——逻辑判断会降低处理器 的速度, 代码明了简单,可以通过即时(J IT)或者热部署(Hotspo t)Java编译器的编译。最重 要的是,对于全部64个单元格所 需要的数值,是通过并行计算获得 的。

image
图 6. 生命游戏的模型

与非游戏应用相比,位运算在游戏 等应用中更容易得到应用。其原因 是游戏应用如象棋的数据模型中位 与位之间具有丰富的关系。象棋的 数据模型具有12层64位的位集 ,合计共764位。768位中的 每位基本上都同其余各位有一定形 式的关联。在商业应用中,信息通 常不具有这样紧密的关系。

结论
思想开放的程序员,可能在任何问 题领域中应用位运算。然而,在特 定情形下应用位运算合适与否取决 于开发者的判断。老实说,可能根 本就不需要使用这些技巧。不过, 上面提到的方法在特定Java应 用可能正是优化程序所需要的。如 果不是这样,也可以使用这些方法 以非常hacking的方式解决 一些问题,同时迷惑你的朋友们!
享受位运算的快乐吧!

资源
·onjava.com: onjava.com
·Matrix-Java开发者 社区: http://www.matrix.org.cn/
· 本文的代码
· Hacker's Delight 一书的链接
· Eclipse的字节码查看插件


Glen Pepicelli 是一位软件专家.他和他的狗生活 在纽约州北部地区.
 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值