回应CSDN肖舸《做程序,要“专注”和“客观”》,实验比较各离散采样算法
2010-05-27 23:57 Milo Yip 阅读(25741) 评论(73) 编辑 收藏 举报自从肖舸在其CSDN博客上说“拒绝回答博客园等网站网友的问题”,实质上不单是拒绝回答,而且还删去包括一些网友及本人对于纯粹技术探讨的评论。当然每位博主都有自由这么做,但个人认为这对于社区的交流发展有负面影响。为了探讨这个技术问题,本人唯有把回应发表于博客园内。本文会阐述之前的论点,评论肖舸的实现,并进行了兩个实验比较不同算法、实现的优劣之处。
之前的“交流”
约一个月前,肖舸于《实际中常用的一个随机数产生器(分类别概率随机)》(下简称《实》)一文中,介绍了一个宣称“0bug”的实现。而实质上这实现至少有两个bug,其中一个已被网友发现,而肖舸也随后更新,不过没有公开向该网友致谢,连网友的id或名字也没写。本人还发现另一个会做成崩溃bug,稍后再说。
因为有感《实》欠缺背后理论,而且该实现的需求不清楚、代码难读,所以本人于5日后撰写博文《用JavaScript玩转游戏编程(一)掉宝类型概率》(下简称《用》 ),从统计学解释这问题,并以JavaScript实作互动的示范程序。文中提及:
这里用了线性搜寻(linear search),……另外,也可以用二分搜寻(binary search),那么复杂度会降低为O(lg N),……那么,还有没有更快的方法呢?答案是肯定的,例如别名方法(alias method)、*似方法等,有兴趣的读者可参考……当然,在N很小的情况下,线性搜寻和二分搜寻也足够。
笔者撰写本文,灵感来自这篇博文(指《实》)。其算法实际上是储存CDF的逆函数采样,利用空间和有限的CDF精确度,换取O(1)的时间复杂度。衡量N的大小、精确度、空间需求、缓存延迟后,或许该方法也能适合某些个别需求。但对于该文作者说N最大为100,二分搜寻只需最多7次迭代,因缓存问题可能二分搜寻更快。
之后,肖舸在另一篇博文《做程序,要“专注”和“客观”》中,当中有一段似乎是回应上文:
就好比我前面写的一篇博文《实》,我在文中的代码里,明明实现了O(1)的复杂度,但是就有人,为了攻击我本人和我的书《……》,专门撰文,说用其他办法,O(7)的复杂度也可以实现,我这个办法不值得提倡。
我晕,我们做算法优化,有时候,O(n)这个值,能减少1都是巨大的成功,因为程序是有循环的,循环次数是被乘数哦,这是乘法关系,这个核心算法复杂度减少1,放出去就是几千万甚至几亿的时钟开销,效率提升就是巨大的。很多时候,我做优化,都在为了减少这个1在努力。
不过,这毕竟是少数人,准确的讲,说这话的人不能算技术人员,因为针对到科学的,算法的,优化的问题上,一是一,二是二,不能带着个人感情讨论。这是技术人员,特别是程序员基本的职业道德。
这里,我希望广大程序员朋友一定要养成一个习惯,“客观”和“严谨”是程序员的基本职业修养,也是我们能在这个行业里面立足的根本,千万不要丢掉了。
因为文中说“O(7)”,我想应该是回应我写的7次迭代,如果不是, 就当本人对号入座吧。对于算法是否属于“科学”,大家可以想想。我自问只谈到技术上的意见,不知道为什么会说到“个人感情”,甚至推理至是否一个技术人员、有没有职业道德问题了。
也许是本人写得不够清楚,也许是读者有误解,无论如何,本人也在此解释一下。
复杂度与性能
首先,肖舸说“O(n)这个值”、“ 核心算法复杂度减少1” 、“O(7)”,都是不正确的说法。
研究算法的运行时间,最常见是采用Big-O表示法,例如O(1)、O(n)等。这表示法是指,算法在n接*无限大的时候,其运行时间的渐*复杂度(asymptotic complexity)的上界(upper bound)。举个例子,例如解决同一问题的两个算法﹐,它们的运行时间(例如单位是秒)为:
用Big-O表示法,A算法是线性速度O(n),B算法是常数速度O(1)。也就是说n大到某个程度,算法A比算法B慢。那么n要有多大?只要联合两个函数,即可求出,当n > 4时,算法A比B慢。相反,当n<4时,算法A会比算法B快。如果有另一组算法,其方程求出来的n是少于或等于0,说明当中的其中一个算法永远比另一个优。
使用Big-O表示法的目的,是方便比较不同的算法。 Big-O会隐藏的算法实现时的系数(例如上面例子中的2、1、9)。而在实际应用中,设计或实现算法,还要考虑多个因素,例如
- 算法的应用场合: 算法所需的精确度、时间要求(通常二者须此消彼长)。如算法分为几个步骤(如本问题可分割为初始化和采样),每个步骤的实际使用次数。另外亦要考虑输入数据的分布。
- 内存的使用: 同时间内所需的内存最大值(可用Big-O表示法)、内存存取模式(access pattern)。
- 算法实现的难度: 除了开发及维护时间,还影响程序是否健壮(robust),例如复杂的浮点运算会做成误差。
- 硬件: 例如主频、核心数量、内存速度、特殊指令(如SIMD),或者不同架构的可编程系统(如GPU)、设备等
- 软件: 会执行于什么操作系统、编译器、库等等,这些软件对算法是否有影响。
很多时候,解决同一问题有许多算法,并没有绝对的好坏之分(当然有时候也有些能完全取胜,有些完全无用),每个算法都其优点缺点。程序员须要分析实际应用,去选择及实现算法。理想地,更可以实验多个算法,用实际数据去作出比较。
评论肖舸的实现
这个是肖舸在《实》公布的实现代码(本人加入了#ifdef MILO_HOTFIX及GetMemory()):
#define CTonyRandomArea_TOKEN_MAX 100 //最大类型数 #define CTonyRandomArea_TOKEN_AREA_MAX 10000 //类型数组单元数,精确到小数点后两位 //输入最大100个元素的数组,每个数组表示每类占有的百分比,内部自带百分比调整。 //即如果外部输入的数字之和不是整数100,内部会根据百分比,自动调整其比例,使总和=100.0 //然后内部建立10000个单元的类型数组,根据传入的每种类型的比例,在类型数组中批量填充对应的类型值 //总之,类型数组中每种类型的数量,占据的比例正好是输入的百分比 //最后,在0~10000中取随机,然后在对应的类型数组单元中取类型值,即为返回的类型 class CTonyRandomArea { public: CTonyRandomArea(double* pTokenPercentArray,char cTokenCount) { m_nTokenCount=cTokenCount; if(CTonyRandomArea_TOKEN_MAX<m_nTokenCount) m_nTokenCount=CTonyRandomArea_TOKEN_MAX; int i=0; for(i=0;i<m_nTokenCount;i++) { m_dTokenPercentArray[i]=*(pTokenPercentArray+i); } //动态调整内部的值 //有时候试验人员,测得几个状态出现的数字,可能懒得再计算成百分比 //程序帮忙自动计算 double dNumberCount=0; for(i=0;i<m_nTokenCount;i++) { dNumberCount+=m_dTokenPercentArray[i]; } if(100.0!=dNumberCount) { for(i=0;i<m_nTokenCount;i++) { m_dTokenPercentArray[i]/=dNumberCount; m_dTokenPercentArray[i]*=100; } } //以小数点后两位精度,开始计算在10000个总单元中,每种类型对应的数量。 for(i=0;i<m_nTokenCount;i++) { m_sTokenPercentArray[i]=(short)(m_dTokenPercentArray[i]*100); } //按比例填充类型数组 int j=0; int nFillMin=0; int nFillMax=0; #ifdef MILO_HOTFIX for(i=0;i<CTonyRandomArea_TOKEN_AREA_MAX;i++) { m_cTokenPercentArrayAreaUp[i]=0; } #else for(i=0;i<m_nTokenCount;i++) { m_cTokenPercentArrayAreaUp[i]=-1; } #endif for(i=0;i<m_nTokenCount;i++) { nFillMax=nFillMin+m_sTokenPercentArray[i]; for(j=nFillMin;j<nFillMax;j++) { m_cTokenPercentArrayAreaUp[j]=i; } nFillMin=nFillMax; } // m_cTokenPercentArrayAreaUp[CTonyRandomArea_TOKEN_AREA_MAX-1]=i-1; } ~CTonyRandomArea(){} void PrintfInfo(void) { int i=0; double dNumberCount=0; for(i=0;i<m_nTokenCount;i++) { dNumberCount+=m_dTokenPercentArray[i]; printf("%d = %f\n",i,m_dTokenPercentArray[i]); } printf("All = %f\n",dNumberCount); //打印10000个单元的类型分布,看看排得对不对 //这段打印起来太长,一般隐掉,需要了再打印 // int nOutMax=400; // int nInMax=25; //二者相乘为10000 // int j=0; // for(i=0;i<nOutMax;i++) // { // printf("%05d - ",i*nInMax); // for(j=0;j<nInMax;j++) // { // printf("%d ",m_cTokenPercentArrayAreaUp[i*nInMax+j]); // } // printf("\n"); // } } //取类型数组对应单元的值 char GetType(int nTypeIndex) //输入参数0~10000 { if(10000<=nTypeIndex) nTypeIndex=9999; if(0>nTypeIndex) nTypeIndex=0; return m_cTokenPercentArrayAreaUp[nTypeIndex]; } //真实的工作函数,利用输入的概率来产生随机type char GetRandomType(void) { return GetType(GetRandomBetween(0,10000)); } // MILO ADD { size_t GetMemory() const { return sizeof(*this); } // } MILO private: double m_dTokenPercentArray[CTonyRandomArea_TOKEN_MAX]; //比例数组 short m_sTokenPercentArray[CTonyRandomArea_TOKEN_MAX]; //比例数组,短整型,1~10000,相当于精确到小数点后两位 char m_nTokenCount; char m_cTokenPercentArrayAreaUp[CTonyRandomArea_TOKEN_AREA_MAX]; //类型数组 }; ////这是测试代码 //void TestCTonyRandomArea(void) //{ // double dTokenPercentArray[100]; // // dTokenPercentArray[0]=12.0; // dTokenPercentArray[1]=40.0; // dTokenPercentArray[2]=40.0; // dTokenPercentArray[3]=7.0; // dTokenPercentArray[4]=1.0; // // CTonyRandomArea Area1(dTokenPercentArray,5); // Area1.PrintfInfo(); // // int i=0; // for(i=0;i<20;i++) // { // printf("RandType = %d\n",Area1.GetRandomType()); // } //}
崩溃Bug
在测试这个实现时,发现GetRandomType()返回的值会>=N,而N为概率数组的大小。这样有机会做成程序崩溃。例如在代码中,把返回值作为索引,填进数组就会写到不合法的地址。
调试之后,发现程序的bug位于三个地方。第一,21-36行检查概率总和是否等于100个百分点,否则会自动进行正规化(normalization)。正规化后,浮点小数只能表示*似值。第二,在37-41行会把浮点小数乘上10000,再转做整数。因为这里是下取整,计算出来的整数总和有机会少于10000。于是m_cTokenPercentArrayAreaUp数组最后的几个元素未被填入。第三,第54-57行对m_cTokenPercentArrayAreaUp数组进行初始化,但用错循环次数,应为CTonyRandomArea_TOKEN_AREA_MAX而不是m_nTokenCount。
例如使用{ 3, 5, 7, 11, 13 } 作为输入,CTonyRandomArea_TOKEN_AREA_MAX数组就会填入前9998个元素,最后两个未被初始化。
这三个错处要完全更正会改动太多,不如重写。所以本人只是作最少改动,原整地初始化CTonyRandomArea_TOKEN_AREA_MAX数组为0。
用肉眼检验正确性?
测试函数TestCTonyRandomArea()的作用就是列印20个采样。该程序员或测试人员是用肉眼观测那20个采样,看看是否大概和输入的概率分布接*么?或是记下每个返回值的频率,笔算其频率除以20后的百分比?
这个问题本质上就是能编写测试代码去自动测试的。以下的实验就会测试各个实现的特性,包括其误差。
浪费内存
m_dTokenPercentArray和m_sTokenPercentArray都没必要作为成员变量。前者在PrintfInfo()函数还有一点用途,后者只在构造函数使用。
const-ness
构造函数的指针没加const。使用者用的时候要const_cast。 C语言的函数都要适当地使用const指针(可参考strlen原型),何况是C++呢。
乱数产生器不均分布
以下是其的乱数产生器代码:
inline int _GetNot0(void) { int nRet=rand(); if(!nRet) nRet++; return nRet; } inline int GetRandomBetween(int nBegin,int nEnd) { int n=_GetNot0(); int nBetween=0; if(0>nBegin) nBegin=-nBegin; if(0>nEnd) nEnd=-nEnd; if(nBegin>nEnd) { nBetween=nEnd; nEnd=nBegin; nBegin=nBetween; } else if(nBegin==nEnd) nEnd=nBegin+10; nBetween=nEnd-nBegin; n=n%nBetween; n+=nBegin; return n; }
这个在书评里已写了很多,原来只需三句代码都可以写成这样,更把乱数的分布变得更不*均,不再多说了。
算法实现
本来以为《用》一文中的说法,已足够解释。以上的情况,加上本人未试过实现别名方法(alias method),便做了二个实验去比较及分析各种算法。实验有其局限性,只是在某个客观环境(计算机、编译器等),对某个算法实现的测量。但本实验的结果可能可以支持我在《用》所提及的观点。
逆变换方法(线性搜寻)
这个算法于《用》有详细说明,以下是其C++代码,复杂度是O(N):
template <typename T, typename RandomGenerator> class InverseLinear { public: InverseLinear(const T* cdf, size_t N, RandomGenerator& random) : mCDF(cdf), mN(N), mRandom(random) { assert(N > 0); assert(cdf[N - 1] == (T)1.0); } size_t operator()() { T y = mRandom(); for (size_t x = 0; x < mN - 1; x++) if (y < mCDF[x]) return x; return mN - 1; } size_t GetMemory() const { return sizeof(*this) + mN * sizeof(T); } private: const T* mCDF; const size_t mN; RandomGenerator mRandom; };
逆变换方法(二元搜寻)
这个算法用二元搜寻代替线性搜寻,复杂度降低为O(lg N):
template <typename T, typename RandomGenerator> class InverseBinary { public: InverseBinary(const T* cdf, size_t N, RandomGenerator& random) : mCDF(cdf), mN(N), mRandom(random) { assert(N > 0); assert(cdf[N - 1] == (T)1.0); } size_t operator()() { T y = mRandom(); size_t left = 0, right = mN; while (left < right - 1) { size_t mid = (left + right) / 2; assert(mid - 1 < mN); if (mCDF[mid - 1] < y) left = mid; else right = mid; } return left; } size_t GetMemory() const { return sizeof(*this) + mN * sizeof(T); } private: const T* mCDF; const size_t mN; RandomGenerator mRandom; };
必须注意这个算法和一般的二元搜寻条件有些差异。
别名方法
别名方法能提供O(1)的复杂度,但初始化过程比较复杂。参照[1]附录的代码实作:
template <typename T, typename RandomGenerator> class Alias { public: Alias(const T* pdf, size_t N, RandomGenerator& random) : mN((T)N), mRandom(random), mAliasTable(N) { assert(N > 0); std::vector<size_t> smallBag, largeBag; smallBag.reserve(N); largeBag.reserve(N); const T stdWeight = (T)1 / N; for (size_t i = 0; i < N; i++) { mAliasTable[i].prob = pdf[i]; if (pdf[i] > stdWeight) largeBag.push_back(i); else smallBag.push_back(i); } while (!largeBag.empty() && !smallBag.empty()) { const size_t sm = smallBag.back(); const size_t lg = largeBag.back(); smallBag.pop_back(); largeBag.pop_back(); mAliasTable[lg].prob -= stdWeight - mAliasTable[sm].prob; mAliasTable[sm].prob *= (T)N; mAliasTable[sm].index = lg; if (mAliasTable[lg].prob > stdWeight) largeBag.push_back(lg); else smallBag.push_back(lg); } for (std::vector<size_t>::iterator itr = smallBag.begin(); itr != smallBag.end(); ++itr) mAliasTable[*itr].prob = (T)1; for (std::vector<size_t>::iterator itr = largeBag.begin(); itr != largeBag.end(); ++itr) mAliasTable[*itr].prob = (T)1; } __forceinline size_t operator()() { T u = mRandom() * mN; size_t i = (size_t)u; return u - i > mAliasTable[i].prob ? mAliasTable[i].index : i; } size_t GetMemory() const { return sizeof(*this) + sizeof(AliasItem) * mAliasTable.capacity(); } private: T mN; RandomGenerator mRandom; struct AliasItem { T prob; size_t index; }; std::vector<AliasItem> mAliasTable; };
伪随机数产生器
Visual C++的C运行时库(C runtime library, CRT)中,提供的rand()是线程安全的,因而有额外的开销(需要存取TLS)。而且,其精确度有限(只有15-bit)。因此,在实验中使用了两个伪乱数产生器,一个采用rand(),一个采用最普通的线性同余产生器(Linear congruential generator, LCG)。两个产生器都是产生半合区间[0,1)的匀均分布。
template <typename T> class RandomCRT { public: RandomCRT(unsigned seed = 0) { srand(seed); } T operator()() { return rand() * ((T)1 / (RAND_MAX + 1)); } };
template<typename T> class RandomLCG { public: RandomLCG(unsigned seed = 0) : mSeed(seed) {} T operator()() { mSeed = 214013 * mSeed + 2531011; return mSeed * ((T)1 / 4294967296); } private: unsigned mSeed; };
实验一: 方法
主要的测试方式如下。设N为4, 8, 16, ..., 256,用乱数产生一组N个元素的pdf,之后对每个算法,执行其初始化1,000次,采样1,000,000次,分别量度时间。此外,用采样来计算频表,之后用以下算式比较频表和pdf的总误差:
template <typename T> T ComputeError(const unsigned* histogram, const T* pdf, size_t N, size_t sampleCount) { T error = 0.0; for (size_t i = 0; i < N; i++) { T delta = (T)histogram[i] / sampleCount - pdf[i]; error += abs(delta); } return error; }
当中h_i是采样等于i的出现的次数除以总采样次数。
有些算法分别采用float和double两种类型,亦会使用CRT和LCG两种伪乱数产生器,所有测试组合如下:
- Tony
- Linear<CRT, double>
- Binary<CRT, double>
- Alias<CRT, double>
- Linear<LCG, double>
- Binary<LCG, double>
- Alias<LCG, double>
- Linear<LCG, float>
- Binary<LCG, float>
- Alias<LCG, float>
实验环境:
- Intel i7 920 @2.67Hz
- Microsoft Windows 7 64-bit
编译器及主要编译参数:
- Microsoft Visual Studio 2008 9.0.30729.1 SP
- Platform: Win32
- Optimization: Maximize Speed (/O2)
- Inline Function Expansion: Any Suitable (/Ob2)
- Enable Intrinsic Function: Yes (/Oi)
- Favor Size of Speed: Favor Fast Code (/Ot)
- Enable C++ Exception (No)
- Runtime Library: Multi-threaded DLL (/MD)
- Buffer Security Check: No (/GS-)
- Enable Enhanced Instruction Set: Streaming SIMD Extensions 2 (/arch:SSE2)
- Floating Point Model: Fast (/fp:fast)
- Default Char Unsigned: Yes (/J)
- Enable Run-Time Type Info: No (/GR-)
宏:
- #define _SECURE_SCL 0
实验一: 结果及分析
以下结果皆使用Google Visualization API显示,因此在RSS或转载中可能无法显示。我初次试用这API,其HTML和JavaScript代码是由C++测试程序中自动生成的。
原始数据表如下(init和sample的单位为秒,memory的单位为字节):
初始化时间
纵轴是时间,单位为秒。注意横轴为对数比例。
除了Tony和Alias,其余算法的初始化都接*零。 Tony和Alias都是以线性上升。大部份情况下,Tony的初始化性能较低。
采样时间
最好成绩的是Alias的LCG版本,接*常数性能,其float和double版本差异不大。 Tony亦为常数性能,比Alias慢30%左右。
一如所料,Linear和Binary的性能为O(N)和O(lg N)(因横轴为对数比例,对数在图里会接*直线,例如橙线的Binary)。值得注意的是,Linear和Binary的LCG版本在N=4时,超越Alias和Tony。这是由于这两个算法简单,系数低,所以有机会超越O(1)的对手。
需要较高性能的话,别用CRT的rand()。
准确度
如果要说Tony算法的主要问题,就在于其准确度远远差于其他算法的实现(在数百倍以上)。就算使用整数的百份比,用那N=5的例子,Tony的误差约为0.08,其他算法则介乎0.001至0.002。
内存用量
Tony算法所使用的内存是常量11008个字节。而其他的算法为O(N),Alias因为要多存一个别名索引,其内存是其他除Tony以外的两倍(事实上,Alias的索引可以因N的大小而采用unsigned short或unsigned char,以减低开销)。
这里亦要留意一点,Linear和Binary其实只储存cdf的常数指针、N和Random,实际上只有12字节。 cdf数组可供多个采样器共用,所以Linear和Binary的内存开销是非常少的。
N的范围
Tony算法的另一缺点是只支持至N=100。相反,其他的算法可支持较大的N,例如用Tony所需的內存,Alias的float版本可支持N=1375。
实验二: 方法
上一个实验,量度各个算法的采样器在不同的N时的性能。但是在应用上,很多时候需要同时使用多个采样器。肖舸在《实》中提及:
实话跟你讲吧,这段代码是有前提的,我们要做5000万条记录,中间有20万个设备的记录,每个设备的采样频率不一样,我要并发模拟,你再想想我写这么复杂有道理没?
那么就实际测试一下。建立M个采样器(M设为1、10、...、100000),对每个采样器轮流采样,M个采样器合共采样1百万次。而N则取Tony算法可接受的最大值100。测试的循环代码是这样的:
const size_t interleavedSample = sampleCount / M; size_t unused = 0; // prevent optimized out LARGE_INTEGER start, end; QueryPerformanceCounter(&start); for (size_t i = 0; i < interleavedSample; i++) for (size_t j = 0; j < M; j++) unused += (*samplers[j])(); QueryPerformanceCounter(&end); cout << unused << endl;
从理论上来说,用一个采样器去做一百万次采样,和用M个采样器合共采样一百万次,在效能上应该没有分别。但在实际的测试中又是否这样呢?
因为上一个实验已测量准确度,以及那些算法组合在这个情况下比较好,就只测试以下的组合:
- Tony
- Binary<LCG, float>
- Alias<LCG, float>
实验二:结果及分析
原始数据如下:
用图表显示采样性能(横轴为M個采样器,纵轴是采样1百万次时间,单位为秒):
可以发现,Alias在整个测试范围里,表现也是最佳的。若按理论内说,三条线应该都是*的,或接**的。为什么O(1)的Tony算法却在M=1000以后,输给O(lg N)的Binary?而且,如果用多个采样器是有额外开销的话,为何Alias和Binary不是一直随M上升而线性递增,反而会是个U形,有个甜蜜点(sweet spot)?
Tony算法除了准确度问题,最大问题是其内存开销。当同时使用多个采样器,其存取的内存超过CPU缓存空间,就会严重影响性能。这可以作为例子证实在某些情况下,我在《用》里说的假设:
但对于该文作者说N最大为100,二分搜寻只需最多7次迭代,因缓存问题可能二分搜寻更快。
至于甜蜜点,在实验之前也没预计过。我认为这也是由缓存做成的。因为缓存本身也有*行运作的特性,分散存取整个缓存空间,会比集中存取一小块缓存更有效率。观看上表,可发现甜蜜点的内存使用大约在40K-80K,这似乎刚能对上i7的64K L1缓存。但本人对硬件了解不足,还望各位网友指导。
那么在M=100,000之后,最终Tony是否又会再超越Binary呢?从理论上来说,只要两个算法都使用超过缓存大小的内存(以i7 920来说是8MB的L3),缓存就会慢慢失去效果。可是我没有再尝试,因为在M=100,000,Tony算法已使用了超过1.1GB内存,而Binary才约41MB。回想起来,肖舸说要做20万个设备,每个有不同的采样率。如果要同时使用,Tony算法要2.2GB内存啊,而Binary才82MB,后者比前者简单、准确、快。
总结
本文使用实验比较了4个离散随机变量的产生算法。实验量度的主要四种数据,可以帮助读者选择合适的算法。在于N比较大的情况下,建议使用别名方法,因为其采样性能最高、内存比Tony少、准确度比Tony高,只是初始化性能稍慢。而本人参照[1]的别名方法实现,也可以尝试进一步优化,例如用一个alloca()代替两个vector,减少配置内存的开销。
本人认为,使用类似Tony的算法,在某些情况是有用的。例如,可以设计一个采样器,其输入为整数,代表每个类别的相对比重。例如输入整数数组{ 1, 2, 3, 1, 2 },表示概率{ 1/9, 2/9, 1/3, 1/9, 2/9 },那么只需要9个元素的表就能实现快速的采样。这样的输入,大概十多行代码就能实现,而且准确度非常高,也不需浮点运算。缺点(或特征)是其复杂度与数组的元素总和成线性关系。这个实现留给读者尝试吧。
希望本文也能带出几个要点。在实际应用中,算法不能单靠Big-O时间复杂度来选择。以采样器为例,需同时考虑初始化和采样的时间。例如每次建立一个采样器之后,会执行50次采样,那么就可以比较各种算法的实际执行时间,也许Binary会比Alias好。另外,程序的准确性通常比性能更重要(当然也有例外,例如游戏中的粒子系统效果就不需要很准确),但无论要求如何,也应谨慎对待准确性。除了嵌入式设备有内存限制,因现代计算机的内存速度和缓存缺失(cache miss),编程时应尽量减少内存的开销。最后,也请注意不同算法,代码的复杂程度不尽相同。以上所说,皆是选择算化的重要考虑因素。
本人是否为一名技术人员、是否有职业道德,就不在此回应了。
有与趣的读者可下载本实验的源代码包(不保証0bug)。此外,C++ TR1有部份关于随机数的实现。
参考
- [1] Burke D, Bidirectional importance sampling for illumination from environment maps. Master's Thesis, University of British Columbia, 2004.
更新
- 2010-05-28: 肖舸在其CSDN博客上发表《请博客园立即停止侵权行为的公开信》。本人认为本文只是适当引用肖舸的文章,为介绍、评论其文章及说明问题,并在发表时已加上原文名称、链结及原作者名字。因此,按法律并不需得到原作者同意。
- 2010-05-29: 推友@DOTIAN做了一个关于别名方法(alias method)的详细分析。本文主要在性能比较,没有介绍别名方法原理,读者可参考这文章。
- 2010-06-02: 學生大本營中"編程之美"老師轉載本文。
- 2010-06-06: 肖舸刪去其CSDN博客所有文章。此非吾所願。
- 2010-06-10: 肖舸的CSDN博客和學生大本營帪戶(被?)關閉。CSDN要求其他幾位學生大本營老師刪除有關
- 2010-06-11: 學生大本營中"編程之美"老師轉載的本文被刪。