一種稀疏矩陣的實現方法
本文簡單描述了一種稀疏矩陣的實現方式,並與一般矩陣的實現方式做了效能和空間上的對比
矩陣一般以二維陣列的方式實現,程式碼來看大概是這個樣子:
// 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元素),所以實際開發中不建議使用稀疏矩陣的實現方式,除非你能確定處理的矩陣密度大部分都小於臨界值.