PS: 代碼涉及的隨機函數和一些容器雖然是C++的, 但算法是通用的, 這些容器java等其它語言裏也都能找到類似的存在.
1. 最樸素暴力的做法.
void cal1()
{
int i = 0, j = 0, num = 0;
int result[M];
result[0] = rand() % N; //第一個肯定不重複, 直接加進去
for (i = 1; i < M; i++) //獲得剩下的(M-1)個隨機數
{
num = rand() % N; //生成0 ~ N之間的隨機數字
for (j = 0; j < i; j++)
{
if (num == result[j]) //如果和result數組中某個元素重複了
{
i--; //重新開始此次循環
break;
}
}
if (j == i) // 說明新產生的數和數組裏原有的元素都不同, 則add進去
{
result[i] = num;
}
}
}
2. 在方法1的基礎上我們可以進行優化. 每輪遍歷n太耗時, 那麼優化成logn如何, 於是採用一下set作爲輔助, 爲了利用它logn的時間複雜度. 代價是多了一個M大小的set的空間.
void cal2()
{
set<int> s;
int num = 0, index = 0;
int result[M];
while (index < M)
{
num = rand() % N;
if (s.find(num) == s.end()) //如果沒找到
{
s.insert(num);
result[index++] = num;
}
}
}
3. 如果你被方法1和2無窮的重複比較弄煩了, 可能想到, 每次新產生的隨機數都要和已有的數去進行比較是否已存在, 越往後這種方法的效率越低, 不停在做無用功. 那麼反其道而行如何? 幾斤幾兩咱們都亮在牌面上, OK, 把所有數先都列出來, 從裏面往外篩選, 那麼每次選出來的, 肯定是不重複的. 只需要選M次就可以了.
void cal3()
{
int result[M] = {0};
deque<int> deq; //隊列
int i = 0, index;
for (i = 0; i < N; i++) //初始化, 把所有N個數都放到容器裏, 從這裏面往外挑, 每次必不重複
{
deq.push_back(i);
}
for (i = 0; i < M; i++) //挑選出M個數
{
index = rand() % deq.size(); //注意deq.size()是不斷變小的, 但是每次都符合隨機特性
result[i] = deq.at(index); //把deq數組index位置的元素賦給result[i]
deq.erase(deq.begin() + index); //從deq隊列中把該元素刪除
}
}
4. 方法3是思路比較理想化和直接, 實際操作中, 你會發現比方法1都要慢很多很多, 原因就出在容器的erase函數, 內部實現的本質是內存片的拷貝, 這個操作相當相當的耗時.這個和語言無關, 換成java等其它語言, 類似的這種函數都是同樣的原理. 其實思考到方法3, 真理已經呼之欲出了. 我們沿着這個思路繼續優化算法. 從中剔除元素的想法是好的, 但是方法不佳. 其實我們需要的本來就是基本的數組就可以了, 速度還快, 用deque, vector這些容器無非是爲了使用他們的erase函數把某個數剔除出去不參與下次的隨機過程. 隨着一個個數被選出, 容器的大小也在不停變小, 其實使用數組, 利用下標的偏移, 我們直接就可以做到了! 和用數組實現一個隊列或者棧不是一樣的嗎, 無非就是數組下標的移動! 於是沿着方法3的思想, 我們每次隨機出來一個下標index(0 <= index < size(size初值爲N)), 每次把arr[index]這個位置的元素甩到數組最後面就可以了, 就相當於剔除操作了!
void cal4()
{
int result[M] = {0};
int data[N] = {0};
int i = 0, index = 0;
for (i = 0; i < N; i++) //初始化
{
data[i] = i;
}
for (i = 0; i < M; i++)
{
index = rand() % (N - i);
result[i] = data[index]; //把data數組index位置的元素賦給result[i]
data[index] = data[N - i - 1]; //從data數組末尾(這個位置在不停前移)拿一個數替換到該位置, 相當於這個元素被剔除了
}
}
算法都寫出來了, 貼一下實際測試的時間數據, 增加一下直觀感受.
(注: 下面數據在vs2008平臺下測試, 用的GetTickCount()函數, 這個函數精度在10~16ms範圍內. 由於機器配置不同, 下面數據僅供參考, 只爲表達一種直觀上的時間差異)
N = 1,000, M = 1,000 : (可以看出, N值較小時, 方法1甚至優於方法2, 因爲這時logn相對於n的速度優勢並不明顯, 卻多了set的處理開銷, 方法3最慢)
N = 10,000, M = 10,000 : (方法2的logn優勢已經顯現, 方法3的龜速放大的很明顯)
N = 30,000, M = 30,000 : (方法2較之1的優勢更加明顯, 方法3依然最耗時)
N = 50,000, M = 50,000 : (方法1和2已經算不出結果了, 方法1下班沒關機, 執行了一宿第二天還是沒出結果, 方法2等了15分鐘也沒結果, 雖然會比1會快點, 原理是一樣的, 就是越到後面隨機出不重複數的機率越小了, 做太多無用功. 方法3雖然慢, 還是出的來結果的, 方法4依然堅挺, 0ms!)
N = 100,000, M = 100,000: (十萬, 依舊是0)
N = 1,000,000, M = 1,000,000: (1百萬, GetTickCount()函數有10~16ms的誤差, 從下面1千萬和1億的時間來看, 這個算法是絕對線性的, 因此此時實際時間應該在23ms左右. 當然, 這時開始已經換成了堆數組)
N = 10,000,000, M = 10,000,000: (1千萬)
N = 100,000,000, M = 100,000,000: (1億, 將近100M的內存啦, 十億的時候編譯器不支持了)
總結: 無論從代碼的實現來看, 還是實際的效率來看, 方法4都是當之無愧的佼佼者. 當然, 文中所測都是在M和N相等的情況, 這種測試用例數量級越大, 對於方法1和2來說越是艱難. 算法是死的, 實際情況是多變的, 實際應用中我們靈活選擇甚至混合使用這些方法, 比如N非常大, M相對N來說卻又比較小(比如N=1億, M=1萬), 這時候方法1也是完全可以接受的, 畢竟方法1比較省空間, 而方法4是用空間換了時間. 可以說M和N越趨近, 方法4的多餘空間開銷是越值得的, 否則也可能會有得不償失的情況呀.
鑑於此, 又一種思路浮現出來, 可以再繼續演化一下, 用於M比較大但是仍遠小於N的情況.
5. 我們把方法4的data數組去掉, 但是想象中是有這麼個數組的. 初始時這個數組裏的值都等於其下標. 循環的過程中, 如果某個座標index被隨機到了, 那麼把這個記錄加到map裏, key爲index, 從數組尾部拿來一個元素作爲value.
void cal5()
{
map<int, int> m;
int result[M] = {0};
int index = 0;
for (int i = 0; i < M; i++)
{
index = rand() % (N - i);
if (m.find(index) == m.end()) //如果沒找到, 說明該座標第一次被隨機到, 該位置的值沒有被改過, 值和座標相等
{
result[i] = index;
}
else //如果該位置的值被改過, 那麼值在m[index]是這個位置的值
{
result[i] = m[index];
}
//更新這個index對應的值, 和方法同樣道理, 用數組後面的數替代該位置的值
//這個地方比方法複雜點, 需要多個判斷, 因爲N-i-1位置的值的情況不同
if (m.find(N - i - 1) == m.end()) //如果map裏沒有N-i-1這個key
{
m[index] = N - i - 1;
}
else
{
m[index] = m[N - i - 1];
}
}
}
測試結果如下:
M = N = 100,000的時候仍然1秒多就可以出結果, 當然, 這種算法是應用於M遠小於N的情況, M,N都是十萬的時候有這個結果還是不錯的:
這是N = 10,000,000, M = 10,000時的結果, 可見純數組操作還是更快些, 雖然有很多輪無用功.
N = 10,000,000, M = 100,000時, 方法1就已經無響應了, 而方法5的優點則顯而易見了, 這個速度還是可以接受的.
可見, N越大, M和N越接近, 方法1越無力, 反之方法1甚至是個不錯的方法, 速度快, 省空間. 所以還是那句話, 算法是死的, 而實際情況是複雜的, 但是我們可以把死的算法進行靈活運用.沒有最好只有最適合的, 一切取捨依實際情況吧! : )
全文完.
版權所有, 轉載請標明出處. http://blog.csdn.net/aa2650/article/details/12507817