1. 程式人生 > >一種稀疏矩陣的實現方法

一種稀疏矩陣的實現方法

本文簡單描述了一種稀疏矩陣的實現方式,並與一般矩陣的實現方式做了效能和空間上的對比

矩陣一般以二維陣列的方式實現,程式碼來看大概是這個樣子:

// C#
public class Matrix
{
    // methods

    // elements
    ElementType[,] m_elementBuffer;
}

實現方式簡單直觀,但是對於稀疏矩陣而言,空間上的浪費比較嚴重,所以可以考慮以不同的方式來儲存稀疏矩陣的各個元素.

一種可能的實現方式是將元素的數值和位置一起抽象為單獨的型別:

// C#
public struct ElementData
{
	uint row, col;
	ElementType val;
};

但是如何儲存上述的 ElementData 仍然存在問題,簡單使用列表儲存會導致元素訪問速度由之前的O(1)變為O(m)(m為稀疏矩陣中的非0元素個數),使用字典儲存應該是一種優化方案,但是同樣存在元素節點負載較大的問題.

定性的討論往往沒有結果,不如定量的來分析一下結果.

這裡嘗試使用字典儲存方式實現一下稀疏矩陣,考慮到需要提供字典鍵,我們可以將元素的位置資訊通過一一對映的方式轉換為鍵值(這裡採用簡單的拼接方式,細節見原始碼),同樣是因為一一對映的緣故,通過鍵值我們也可以獲得元素的位置資訊,基於此,字典中只需儲存元素的數值即可,無需再儲存元素的位置資訊,可以節省一部分記憶體消耗.

本以為相關實現應該比較簡單,但整個過程卻頗多意外,這裡簡單記下~

C#的泛型限制

由於矩陣的元素型別不定,使用泛型實現應該是比較合理的選擇,程式碼大概如此:

// C#
public class Matrix<T>
{
	public uint Row { get; private set; }
    public uint Col { get; private set; }

    public Matrix(uint row, uint col)
    {
        Row = row;
        Col = col;
        m_elementBuffer = new T[Row, Col];
    }
    
	public T this[uint row, uint col]
	{
	    get
	    {
	    	return m_elementBuffer[row, col];
	    }
	
	    set
	    {
	        m_elementBuffer[row, col] = value;
	    }
	}
	
    public static Matrix<T> operator +(Matrix<T> left, Matrix<T> right)
    {
        var result = new Matrix<T>(left.Row, left.Col);

        for (uint row = 0; row < left.Row; ++row)
        {
            for (uint col = 0; col < left.Col; ++col)
            {
                result[row, col] = left[row, col] + right[row, col];
            }
        }

        return result;
    }
    
    // more methods here
    
    T[,] m_elementBuffer;
}

但是編譯時卻提示: 運算子 “+” 無法應用於 “T” 和 “T” 型別的運算元(程式碼 result[row, col] = left[row, col] + right[row, col]),目前C#支援的泛型約束也不支援四則運算型別的constraints,這導致需要一些workaround的方式來讓上面的程式碼通過編譯,自己參照這篇文章的思路也嘗試實現了一下(程式碼),但是依然覺得邏輯偏於複雜了.

C#中型別的記憶體佔用

由於需要比較記憶體佔用,我需要獲取型別的記憶體大小,但C#中目前沒有直接獲取某一型別的記憶體佔用的方法,諸如sizeof,serialize等方式都比較受限,簡單嘗試了一下 GC.GetTotalMemory(true),發現結果也不準確,後面便沒有再深入瞭解了.

鑑於上面的原因,最終還是選擇使用C++實現了相關的程式程式碼,獲取記憶體佔用的方法採用了過載全域性 new 操作符的方式:

// C++
void* operator new(std::size_t count)
{
	mem_record::add_mem(count);
	return malloc(count);
}

比起之前C#的實現,C++的實現就顯的"底層"很多,需要考慮不少額外的程式碼細節,當然,程式的自由度也更高了.

實現過程中自然也有不少意外,其中一個覺得挺有意思:

C/C++ 中多維陣列的動態申請

C/C++ 中動態申請一維陣列對於大部分朋友來說應該是輕車熟路:

// C++
T* array = new T[array_size];

不想自己管理記憶體的朋友可能還會使用 std::vector<T> 之類的容器.

但是對於多維陣列,似乎動態申請的方式就沒有這麼直觀了:

// C++
int** array = new int*[row];
for (int i = 0; i < row; ++i)
{
	array[i] = new int[col];
}

概念上其實就是"陣列的陣列",同樣的,如果使用容器,你就需要 std::vector<std::vector<T>> 這樣的定義.

但如果考慮到資料快取,程式碼複雜度等因素,個人還是建議將多維陣列展平為一維陣列,並提供多維方式的訪問介面:

// C++

// create array
T* array = new T[row * col];
memset(array, T(), row * col * sizeof(T));

// get array
array[i * col + j];

最終的程式碼在這裡這裡.

比較結果

程式碼分別使用了 std::map 和 std::unordered_map 作為底層容器實現了稀疏矩陣,並與基於陣列實現的普通矩陣進行了程式效率和空間使用上的對比,下圖中的橫座標是矩陣的大小,縱座標是資料比值(普通矩陣的對應數值/稀疏矩陣的對應數值),各條折線代表不同的矩陣密度(矩陣非0元素個數/矩陣所有元素個數).

這是矩陣運算效率(執行加法)的比較結果:

圖

圖

這是矩陣記憶體空間佔用的比較結果:

圖

圖

結論

當矩陣密度較小時(<=0.016),稀疏矩陣在運算效率和記憶體佔用上都優於普通矩陣,在密度極小時(<=0.002),稀疏矩陣在這兩方面的優勢是普通矩陣的數十倍(甚至上百倍),但隨著矩陣密度的增加(>0.016),稀疏矩陣的運算效率便開始低於普通矩陣,並且記憶體佔用的優勢也變的不再明顯,甚至高於普通矩陣.考慮到矩陣的臨界密度較低(0.016,意味著10x10的矩陣只有1-2個非0元素),所以實際開發中不建議使用稀疏矩陣的實現方式,除非你能確定處理的矩陣密度大部分都小於臨界值.