簡體   English   中英

如何有效地編碼/解碼壓縮的位置描述?

[英]How can I effectively encode/decode a compressed position description?

我正在為日本國際象棋變體編寫一個表庫。 為了索引表基,我將每個國際象棋位置編碼為一個整數。 在其中一個編碼步驟中,我對棋子在棋盤上的位置進行編碼。 由於實際方法有點復雜,我就簡單的解釋一下問題。

編碼

在殘局表庫中,我有(比方說)六個不同的棋子,我想將它們分布在一個有 9 個方格的棋盤上。 我可以天真地用一個六元組( abcdef )來表示它們的位置,其中變量af中的每一個都是 0 到 8 范圍內的數字,指示相應棋子的位置.

然而,這種表示並不是最佳的:沒有兩個棋子可以占據同一個方塊,但上述編碼很高興地允許這樣做。 我們可以通過一個六元組[A,B“ C”,d“ E”,F“]編碼相同的位置,其中a是相同的一個以前一樣,b”為從0到7包括指示所述一個數第二塊所在的方格的編號。 這是通過為第一塊不在的每個方格分配一個從 0 到 7 的數字來工作的。 例如,如果第一個棋子在方格 3 上,則第二個棋子的方數為:

1st piece: 0 1 2 3 4 5 6 7 8
2nd piece: 0 1 2 - 3 4 5 6 7

其他部分的編碼方式類似, c'為 0 到 6 的數字, d'為 0 到 5 的數字等。例如,簡單的編碼 (5, 2, 3, 0, 7, 4) 產生緊湊的編碼(5、2、2、0、3、1):

1st: 0 1 2 3 4 5 6 7 8 --> 5
2nd: 0 1 2 3 4 - 5 6 7 --> 2
3rd: 0 1 - 2 3 - 4 5 6 --> 2
4th: 0 1 - - 2 - 3 4 5 --> 0
5th: - 0 - - 1 - 2 3 4 --> 3
6th: - 0 - - 1 - 2 - 3 --> 1

在我的實際編碼中,我要編碼的片段數是不固定的。 然而,棋盤上的方塊數是。

問題

如何有效地將朴素表示轉換為緊湊表示,反之亦然? 我對程序使用標准 C99。 在這個問題的上下文中,我對使用非標准構造、內聯匯編或內在函數的答案不感興趣。

問題澄清

因為這個問題似乎有些混亂:

  • 問題是找到一種實際有效的方法來實現朴素緊湊位置表示之間的轉換
  • 兩種表示都是特定范圍內整數的n元組。 問題不在於如何將這些表示編碼為其他任何內容。
  • 在我遇到的一種情況下,方格數為 25,件數最多為 12。然而,我對適用於合理參數空間的實現感興趣(例如,最多 64 個方格和最多 32 件) .
  • 我對替代表示或編碼不感興趣,尤其是不是最佳的表示或編碼。
  • 我也不對緊湊表示不值得努力的評論感興趣。

該問題的天真解決方案:創建一個數組,其中值最初等於索引。 使用正方形時,從數組中取出它的值,然后向右遞減所有值。 此解決方案的運行時間為O(n*p) ,其中n是棋盤上的方塊數, p是棋盤上的棋子數。

int codes[25];

void initCodes( void )
{
    for ( int i = 0; i < 25; i++ )
        codes[i] = i;
}

int getCodeForLocation( int location )
{
    for ( int i = location + 1; i < 25; i++ )
        codes[i]--;
    return codes[location];
}

您可以嘗試使用 binning 提高此代碼的性能。 將板上的位置視為 5 個 bin,每個 bin 有 5 個位置。 每個 bin 都有一個偏移量,並且 bin 中的每個位置都有一個值。 當值是從倉采取y在位置x ,則對於下面的所有二進制位的偏移y遞減。 並且 bin yx右側的所有值都遞減。

int codes[5][5];
int offset[5];

void initCodes( void )
{
    int code = 0;
    for ( int row = 0; row < 5; row++ )
    {
        for ( int col = 0; col < 5; col++ )
            codes[row][col] = code++;
        offset[row] = 0;
    }
}

int getCodeForLocation( int location )
{
    int startRow = location / 5;
    int startCol = location % 5;
    for ( int col = startCol+1; col < 5; col++ )
        codes[startRow][col]--;
    for ( int row = startRow+1; row < 5; row++ )
        offset[row]--;
    return codes[startRow][startCol] + offset[startRow];
}

此解決方案的運行時間為O(sqrt(n) * p) 然而,在一個有 25 個方格的棋盤上,你不會看到太大的改進。 了解為什么要考慮由朴素解決方案與分箱解決方案完成的實際操作。 最壞的情況是,天真的解決方案更新了 24 個位置。 最壞的情況是,分箱解決方案更新offset數組中的 4 個條目和codes數組中的 4 個位置。 所以這似乎是一個 3:1 的加速。 但是,分箱代碼包含令人討厭的除法/取模指令,並且總體上更加復雜。 因此,如果幸運的話,您可能會獲得 2:1 的加速。

如果電路板尺寸很大,例如 256x256,則合並會很棒。 天真的解決方案的最壞情況是 65535 個條目,而分箱最多會更新 255+255=510 個數組條目。 所以這肯定會彌補令人討厭的划分和增加的代碼復雜性。

這就是嘗試優化小問題集的徒勞。 當您有n=25 sqrt(n)=5 log(n)=5時,您不會將O(n)更改為O(sqrt(n))O(log(n)) 您獲得了理論上的加速,但是當您考慮到 big-O 如此輕松地忽略的無數常數因素時,這幾乎總是錯誤的節省。


為完整起見,這里是可與上述任一片段一起使用的驅動程序代碼

int main( void )
{
    int locations[6] = { 5,2,3,0,7,4 };
    initCodes();
    for ( int i = 0; i < 6; i++ )
        printf( "%d ", getCodeForLocation(locations[i]) );
    printf( "\n" );
}

輸出: 5 2 2 0 3 1

我找到了一個更優雅的解決方案,最多 16 個位置,使用 64 位整數和一個用於編碼和解碼的循環:

#include <stdio.h>
#include <stdlib.h>

void encode16(int dest[], int src[], int n) {
    unsigned long long state = 0xfedcba9876543210;
    for (int i = 0; i < n; i++) {
        int p4 = src[i] * 4;
        dest[i] = (state >> p4) & 15;
        state -= 0x1111111111111110 << p4;
    }
}

void decode16(int dest[], int src[], int n) {
    unsigned long long state = 0xfedcba9876543210;
    for (int i = 0; i < n; i++) {
        int p4 = src[i] * 4;
        dest[i] = (state >> p4) & 15;
        unsigned long long mask = ((unsigned long long)1 << p4) - 1;
        state = (state & mask) | ((state >> 4) & ~mask);
    }
}

int main(int argc, char *argv[]) {
    int naive[argc], compact[argc];
    int n = argc - 1;

    for (int i = 0; i < n; i++) {
        naive[i] = atoi(argv[i + 1]);
    }

    encode16(compact, naive, n);
    for (int i = 0; i < n; i++) {
        printf("%d ", compact[i]);
    }
    printf("\n");

    decode16(naive, compact, n);
    for (int i = 0; i < n; i++) {
        printf("%d ", naive[i]);
    }
    printf("\n");
    return 0;
}

該代碼使用 64 位無符號整數來保存0..15范圍內的 16 個值的數組。 這樣的數組可以在一個步驟中並行更新,提取一個值很簡單,刪除一個值有點麻煩,但仍然只需幾步。

您可以使用不可移植的 128 位整數(gcc 和 clang 都支持__int128類型)將此方法擴展到 25 個位置,將每個位置編碼為 5 位,利用5 * 25 < 128的事實,但是神奇的常量寫起來比較麻煩。

您的編碼技術具有這樣的屬性:輸出元組的每個元素的值取決於相應元素的值以及輸入元組的所有前面元素的值。 我沒有看到一種在計算一個編碼元素期間積累部分結果的方法,這些結果可以在另一個編碼元素的計算中重用,沒有它,編碼的計算不能比 o(n 2 ) 更有效地擴展(時間)要編碼的元素數量。 因此,對於您描述的問題大小,我認為您不能做得比這更好:

typedef <your choice> element_t;

void encode(element_t in[], element_t out[], int num_elements) {
    for (int p = 0; p < num_elements; p++) {
        element_t temp = in[p];

        for (int i = 0; i < p; i++) {
            temp -= (in[i] < in[p]);
        }

        out[p] = temp;
    }
}

相應的解碼可以這樣完成:

void decode(element_t in[], element_t out[], int num_elements) {
    for (int p = 0; p < num_elements; p++) {
        element_t temp = in[p];

        for (int i = p - 1; i >= 0; i--) {
            temp += (in[i] <= temp);
        }

        out[p] = temp;
    }
}

有一些方法可以更好地擴展,其中一些在評論和其他答案中討論過,但我最好的猜測是您的問題規模不夠大,無法通過改進擴展來克服增加的開銷。

顯然,這些變換本身根本不會改變表示的大小。 編碼表示更容易驗證,但是,因為在一個元組的每個位置可以與其他獨立驗證。 因此,與解碼形式相比,在編碼形式中也可以更有效地枚舉有效元組的整個空間。

我仍然堅持認為,解碼后的表單幾乎可以與編碼后的表單一樣有效地存儲,尤其是如果您希望能夠處理單個職位描述。 如果編碼形式的目標是支持批量枚舉,那么您可以考慮以“編碼”形式枚舉元組,但以解碼形式存儲並隨后使用它們。 所需的少量額外空間可能非常值得,因為閱讀后無需執行解碼,特別是如果您打算閱讀大量這些內容。


更新:

為了回應您的評論,房間里的大象是如何將編碼形式轉換為您描述的單個索引的問題,以便盡可能少地使用未使用的索引。 我認為,正是這種脫節引發了如此多的討論,以至於您認為是題外話,而且我認為您對此有一些假設,因為您聲稱可以節省 24 倍的空間。

編碼形式容易轉化成一個緊湊的索引。 例如,您可以將位置視為以棋盤大小為基數的小端數:

#define BOARD_SIZE 25
typedef <big enough> index_t;

index_t to_index(element_t in[], int num_elements) {
    // The leading digit must not be zero
    index_t result = in[num_elements - 1] + 1;

    for (int i = num_elements - 1; i--; ) {
        result = result * BOARD_SIZE + in[i];
    }    
}

可以肯定的是,這方面仍然存在差距,但我估計它們在所使用的索引值的整體范圍中只占相當小的比例(並且為此進行安排是采用小端解釋的原因)。 我將逆向轉換留作練習:)。

在這個答案中,我想展示我自己的一些實現轉換的想法以及一些基准測試結果。

你可以在 Github 上找到代碼。 這些是我的主機上的結果:

algorithm   ------ total  time ------  ---------- per  call -----------
            decoding encoding total    decoding   encoding   total
baseline    0.0391s  0.0312s  0.0703s    3.9062ns   3.1250ns   7.0312ns
count       1.5312s  1.4453s  2.9766s  153.1250ns 144.5312ns 297.6562ns
bitcount    1.5078s  0.0703s  1.5781s  150.7812ns   7.0312ns 157.8125ns
decrement   2.1875s  1.7969s  3.9844s  218.7500ns 179.6875ns 398.4375ns
bin4        2.1562s  1.7734s  3.9297s  215.6250ns 177.3438ns 392.9688ns
bin5        2.0703s  1.8281s  3.8984s  207.0312ns 182.8125ns 389.8438ns
bin8        2.0547s  1.8672s  3.9219s  205.4688ns 186.7188ns 392.1875ns
vector      0.3594s  0.2891s  0.6484s   35.9375ns  28.9062ns  64.8438ns
shuffle     0.1328s  0.3438s  0.4766s   13.2812ns  34.3750ns  47.6562ns
tree        2.0781s  1.7734s  3.8516s  207.8125ns 177.3438ns 385.1562ns
treeasm     1.4297s  0.7422s  2.1719s  142.9688ns  74.2188ns 217.1875ns
bmi2        0.0938s  0.0703s  0.1641s    9.3750ns   7.0312ns  16.4062ns

實現

  • 基線是一種除了讀取輸入之外什么都不做的實現。 它的目的是測量函數調用和內存訪問開銷。
  • count是一個“天真的”實現,它存儲一個占用地圖,指示哪些方塊上已經有碎片
  • bitcount是一回事,但占用圖存儲為位圖。 __builtin_popcount用於編碼,大大加快了速度。 如果改用手寫 popcount, bitcount仍然是最快的可移植編碼實現。
  • 遞減是第二個簡單的實現。 它存儲棋盤上每個方塊的編碼,在添加一塊之后,右邊的所有方塊數字都會遞減。
  • bin4bin5bin8 按照 user3386109 的建議使用 bin 大小為 4、5 和 8 條目的binning
  • shuffle基於Fisher-Yates shuffle計算略有不同的編碼。 它的工作原理是重建隨機值,這些隨機值會進入混洗,生成我們想要編碼的排列。 代碼是無分支且快速的,尤其是在解碼時。
  • vector使用chqrlie建議的五位數向量。
  • tree使用了一個差異樹,這是我編寫的數據結構。 它是一個深度為 ⌈log 2 n ⌉ 的完整二叉樹,其中葉子代表每個正方形,每個葉子的路徑上的內部節點與該正方形的代碼相加(僅添加您向右走的節點)。 平方數沒有被存儲,導致額外存儲n − 1 個字。

    使用這種數據結構,我們可以在 ⌈log 2 n ⌉ − 1 步中計算每個正方形的代碼,並將一個正方形標記為在相同的步數中占據。 內部循環非常簡單,包括一個分支和兩個動作,具體取決於您是向左下降還是向右下降。 在 ARM 上,此分支編譯為一些條件指令,從而實現非常快速的實現。 在 x86 上,gcc 和 clang 都不夠聰明,無法擺脫分支。

  • treeasm樹的一種變體,它使用內聯匯編通過仔細操作進位標志來實現沒有分支的的內部循環。
  • bmi2使用 BMI2 指令集中的pdeppext指令以非常快的方式實現算法。

對於我的實際項目,我可能會使用shuffle實現,因為它是最快的,不依賴於任何不可移植的擴展(例如 Intel 內在函數)或實現細節(例如 128 位整數的可用性)。

要將原始位置轉換為緊湊位置,您可以遍歷 n 元組並對每個位置p執行以下步驟:

  1. 可選地檢查位置p是否可用
  2. 將位置p設置為忙碌
  3. p減去忙碌的較低職位的數量
  4. 將結果存儲到目標 n 元組中

您可以通過為忙碌狀態維護一個包含n位的數組來實現此目的:

  • 步驟 1、2 和 4 在恆定時間內計算
  • 如果數組很小,即:64 位,則可以有效地計算步驟 3。

這是一個實現:

#include <stdio.h>
#include <stdlib.h>

/* version for up to 9 positions */
#define BC9(n)  ((((n)>>0)&1) + (((n)>>1)&1) + (((n)>>2)&1) + \
                 (((n)>>3)&1) + (((n)>>4)&1) + (((n)>>5)&1) + \
                 (((n)>>6)&1) + (((n)>>7)&1) + (((n)>>8)&1))
#define x4(m,n)    m(n), m((n)+1), m((n)+2), m((n)+3)
#define x16(m,n)   x4(m,n), x4(m,(n)+4), x4(m,(n)+8), x4(m,(n)+12)
#define x64(m,n)   x16(m,n), x16(m,(n)+16), x16(m,(n)+32), x16(m,(n)+48)
#define x256(m,n)  x64(m,n), x64(m,(n)+64), x64(m,(n)+128), x64(m,(n)+192)

static int const bc512[1 << 9] = {
    x256(BC9, 0),
    x256(BC9, 256),
};

int encode9(int dest[], int src[], int n) {
    unsigned int busy = 0;
    for (int i = 0; i < n; i++) {
        int p = src[i];
        unsigned int bit = 1 << p;
        //if (busy & bit) return 1;  // optional validity check
        busy |= bit;
        dest[i] = p - bc512[busy & (bit - 1)];
    }
    return 0;
}

/* version for up to 64 positions */
static inline int bitcount64(unsigned long long m) {
    m = m - ((m >> 1) & 0x5555555555555555);
    m = (m & 0x3333333333333333) + ((m >> 2) & 0x3333333333333333);
    m = (m + (m >> 4)) & 0x0f0f0f0f0f0f0f0f;
    m = m + (m >> 8);
    m = m + (m >> 16);
    m = m + (m >> 16 >> 16);
    return m & 0x3f;
}

int encode64(int dest[], int src[], int n) {
    unsigned long long busy = 0;
    for (int i = 0; i < n; i++) {
        int p = src[i];
        unsigned long long bit = 1ULL << p;
        //if (busy & bit) return 1;  // optional validity check
        busy |= bit;
        dest[i] = p - bitcount64(busy & (bit - 1));
    }
    return 0;
}

int main(int argc, char *argv[]) {
    int src[argc], dest[argc];
    int cur, max = 0, n = argc - 1;

    for (int i = 0; i < n; i++) {
        src[i] = cur = atoi(argv[i + 1]);
        if (max < cur)
            max = cur;
    }
    if (max < 9) {
        encode9(dest, src, n);
    } else {
        encode64(dest, src, n);
    }
    for (int i = 0; i < n; i++) {
        printf("%d ", dest[i]);
    }
    printf("\n");
    return 0;
}

核心優化在於bitcount()的實現,您可以通過將其專門化為實際位置數量來滿足您的需求。 我在上面發布了適用於最多 9 的小數和最多 64 的大數的有效解決方案,但您可以為 12 或 32 個位置制定更有效的解決方案。

就時間復雜度而言,在一般情況下,我們仍然有O(n 2 ) ,但對於較小的n值,它實際上運行在O(n.Log(n))或更好,因為實現了bitcount()並行可以減少到 log(n) 步或更少, n最多 64。

您可以查看http://graphics.stanford.edu/~seander/bithacks.html#CountBitsSetNaive以獲得靈感和驚奇。

不幸的是,我仍在尋找使用此方法或類似技巧進行解碼的方法......

要從 (5, 2, 3, 0, 7, 4) 到 (5, 2, 2, 0, 3, 1) 你只需要:

  • 從 (5, 2, 3, 0, 7, 4) 開始,在結果中推入 5 (5)
  • 取 2 並計算小於 2, 0 的前面值的數量,然后按 2-0: (5, 2)
  • 取 3,計算前面小於 3、1 的值的個數,然后按 3-1:(5, 2, 2)
  • 取 0,計算前面小於 0、0 的值的個數,然后按 0-0 (5,2, 2, 0)
  • 取 7, 數..., 4 然后按 7-4: (5,2,2,0,3)
  • 取 4, 數..., 3 然后按 4-3: (5,2,2,0,3,1)

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM