堆
堆這種數據結構的應用很廣泛,比較常用的就是優先隊列。普通的隊列就是先進先出,后進后出。優先隊列就不太一樣,出隊順序和入隊順序沒有關系,只和這個隊列的優先級相關,比如去醫院看病,你來的早不一定是先看你,因為病情嚴重的病人可能需要優先接受治療,這就和時間順序沒有必然聯系。優先隊列最頻繁的應用就是操作系統,操作系統的執行是劃分成一個一個的時間片的,每一次在時間片里面的執行的任務是選擇優先級最高的隊列,如果一開始這個優先級是固定的可能就很好選,但是在操作系統里面這個優先級是動態變化的,隨著執行變化的,所以每一次如果要變化,就可以使用優先隊列來維護,每一次進或者出都動態著在優先隊列里面變化。在游戲中也有使用到,比如攻擊對象,也是一個優先隊列。所以優先隊列比較適合處理一些動態變化的問題,當然對于靜態的問題也可以求解,比如求解1000個數字的前100位出來,最簡單的方法就是排序了,,但是這樣多此一舉,直接構造一個優先隊列,然后出的時候出一百次最大的元素即可。這個時候算法的復雜度就是,如果使用優先隊列可以降到
的級別。
入隊 | 出隊 | |
---|---|---|
普通數組 | ||
順序數組 | ||
堆 |
現在就要用堆來實現一個優先隊列堆一般都是樹形結構:
template<typename item>
class MaxHeap {
private:
item *data;
int count = 0;
public:
MaxHeap(int capacity){
data = new item[capacity + 1];
count = 0;
}
~MaxHeap(){
delete[] data;
}
int size(){
return count;
}
bool isEmpty(){
return count == 0;
}
};
對于插入一個元素其實也很簡單,直接放到最后然后再一步一步的浮上來。新加入的那個元素和他的父親對比,如果是大于它父親那么就交換,只到是不大于或者是到了1。因為如果是小于父親節點那就本身是正確的,如果不終止,再往下比那就是比較其他的元素了,但是其他元素本來就是正確的,所以不需要比較直接結束好了。
void shiftUp(int index) {
while (index > 1 && data[index / 2] < data[index]) {
swap(data[index / 2], data[index]);
index /= 2;
}
}
void insert(item number) {
assert(count + 1 <= capacity);
data[count + 1] = number;
count++;
shiftUp(count);
}
這樣就完成了插入。對于彈出最大的元素就有點邊界問題要討論了。這是一個完全二叉樹,所以只有左節點沒有右節點,所以首先先要判斷是不是有做孩子,也就是越界的問題了,如果沒有就繼續判斷有沒有右孩子,有的話就左右孩子比較咯,哪個大就拿哪個出來,在把最大的拿出來和原節點比較即可。這里就需要一個額外的變量了。
void shiftDown(int index) {
while (2 * index <= count) {
int change = 2 * index;
if (change + 1 < count && data[change + 1] > data[change]) {
change++;
}
if (data[change] <= data[index]) {
break;
}
swap(data[change], data[index]);
index = change;
}
}
item pop() {
assert(count > 0);
item target = data[1];
swap(data[1], data[count]);
count--;
shiftDown(1);
return target;
}
這樣就完成了彈出,彈出的就是最大的數字。事實上這樣是可以直接做排序操作的。
現在使用的堆數據結構,是通過不斷的交換元素來達到符合堆這個數據結構的目的,但是如果堆里面的元素不是數字,而是字符串或者是很長很長的其他復雜的數據結構,那么交換起來效率就很低,而且這樣的話索引起來也有難度。為了解決這些問題,于是就引入了索引堆來解決:
每一個元素加上一個索引值
首先是對于索引堆的創建,我們不僅僅是要一個存儲數據的數組,還要一個存儲索引的數組,因為最后改變的是使用索引,索引對應下的數組是不變的。:
template<typename item>
class IndexHeap {
private:
item *indexes;
item *data;
int count;
int capacity;
shiftDown和shiftUp操作其實就是變一下,把交換的對象換成索引即可:
void shiftUp(int k) {
while (k > 1 && data[indexes[k / 2]] < data[indexes[k]]) {
swap(indexes[k / 2], indexes[k]);
k /= 2;
}
}
void shitDown(int k) {
while (2 * k <= count) {
int change = 2 * k;
if (change + 1 <= count && data[indexes[change]] < data[indexes[change + 1]]) {
change++;
}
if (data[indexes[change]] <= data[indexes[k]]) {
break;
}
swap(indexes[change], indexes[k]);
k = change;
}
}
彈出和插入都是一樣的:
void insert(int i, item itemNumber) {
assert(count + 1 <= capacity);
assert(i + 1 >= 1 && i + 1 < capacity);
i++;
data[i] = itemNumber;
indexes[++count] = i;
shiftUp(count);
}
item extractMax(){
item num = data[indexes[1]];
swap(indexes[1], indexes[count]);
count -- ;
shitDown(1);
return num;
}
主要的變化就是對于改變對應索引下的一個元素。改變了之后,我們要知道這個元素的索引是在這個堆的第幾個位置才可以進行shift操作,之后還要進行shifDown和shiftUp操作,因為不知道它是可以上浮還是下沉。
void change(int i, item itemNumber){
i += 1;
data[i] = itemNumber;
for (int j = 1; j <= count; ++j) {
if (indexes[j] == i){
shiftUp(j);
shitDown(j);
return;
}
}
}
但是這個種改變方法最差的情況下是,最后還可以使用一種反向查找的方法進行改進,可以將復雜度提升到
。使用一個reverse數組存儲當前索引所在的位置,只有在修改了元素之后就可以直接用
的復雜度從reverse中取出來了。所以有reverse[index[i]] = i,reverse[index]就可以找到當前的元素的位置了。修改代碼很簡單,reverse和index是綁定在一起的,所以只要index變的地方reverse也要變的。
template<typename Item>
class IndexMinHeap{
private:
Item *data;
int *indexes;
int *reverse;
int count;
int capacity;
void shiftUp( int k ){
while( k > 1 && data[indexes[k/2]] > data[indexes[k]] ){
swap( indexes[k/2] , indexes[k] );
reverse[indexes[k/2]] = k/2;
reverse[indexes[k]] = k;
k /= 2;
}
}
void shiftDown( int k ){
while( 2*k <= count ){
int j = 2*k;
if( j + 1 <= count && data[indexes[j]] > data[indexes[j+1]] )
j += 1;
if( data[indexes[k]] <= data[indexes[j]] )
break;
swap( indexes[k] , indexes[j] );
reverse[indexes[k]] = k;
reverse[indexes[j]] = j;
k = j;
}
}
public:
IndexMinHeap(int capacity){
data = new Item[capacity+1];
indexes = new int[capacity+1];
reverse = new int[capacity+1];
for( int i = 0 ; i <= capacity ; i ++ )
reverse[i] = 0;
count = 0;
this->capacity = capacity;
}
~IndexMinHeap(){
delete[] data;
delete[] indexes;
delete[] reverse;
}
int size(){
return count;
}
bool isEmpty(){
return count == 0;
}
void insert(int index, Item item){
assert( count + 1 <= capacity );
assert( index + 1 >= 1 && index + 1 <= capacity );
index += 1;
data[index] = item;
indexes[count+1] = index;
reverse[index] = count+1;
count++;
shiftUp(count);
}
Item extractMin(){
assert( count > 0 );
Item ret = data[indexes[1]];
swap( indexes[1] , indexes[count] );
reverse[indexes[count]] = 0;
reverse[indexes[1]] = 1;
count--;
shiftDown(1);
return ret;
}
int extractMinIndex(){
assert( count > 0 );
int ret = indexes[1] - 1;
swap( indexes[1] , indexes[count] );
reverse[indexes[count]] = 0;
reverse[indexes[1]] = 1;
count--;
shiftDown(1);
return ret;
}
Item getMin(){
assert( count > 0 );
return data[indexes[1]];
}
int getMinIndex(){
assert( count > 0 );
return indexes[1]-1;
}
bool contain( int index ){
return reverse[index+1] != 0;
}
Item getItem( int index ){
assert( contain(index) );
return data[index+1];
}
void change( int index , Item newItem ){
assert( contain(index) );
index += 1;
data[index] = newItem;
shiftUp( reverse[index] );
shiftDown( reverse[index] );
}
};
堆可以解決的一些問題,首先,就是使用堆來實現優先隊列,實現一些選擇優先級最高的任務執行??梢宰鳛橐恍┑图壒ぞ邅韺崿F一些高級數據結構。
Tree
二叉樹
二叉樹比較常用的地方就是查找了,其實就是類似于二分查找法,把數據分成兩份,使用這樣的復雜度來進行查找搜索,但是這樣就要求這個數組是有序的。比較常用的實現就是查找表的實現。如果使用順序數組進行查找,使用的復雜度是
,相對應的插入元素也是要
,因為它要遍歷所有的元素找到相對應的位置然后插入。但是二分搜索樹就更好一些,插入刪除查找都是
的復雜度。所以,二分搜索樹不僅可以查找數據,還可以高效的插入刪除等等,效率很高,適合動態維護數據。而且這種方便的數據結構也可以很好的回答數據關系之間的問題。
二分搜索樹首先是一顆二叉樹:
template<typename Key, typename Value>
class BST{
private:
struct Node{
Key key;
Value value;
Node *left;
Node *right;
Node(Key key, Value value){
this->key = key;
this->value = value;
this->left = this->right = NULL;
}
};
Node *root;
int count;
public:
BST(){
root = NULL;
count = 0;
}
~BST(){
//TODO
}
int size(){
return count;
}
bool isEmpty(){
return count == 0;
}
};
對于插入其實也很簡單。首先和當前根節點比較,如果小于就往左邊遞歸,大于就往右邊遞歸,當當前節點是NULL的時候就到達了遞歸終點,這個時候已經到頭了,new一個新的節點返回當前節點即可。
void insert(Key key, Value value){
root = insert(root, key, value);
}
private:
Node *insert(Node *node, Key key, Value value){
if (node == NULL){
count ++;
return new Node(key, value);
}
if (node->key == key){
node->value = value;
} else if(key < node->key){
node->left = insert(node->left, key, value);
} else{
node->right = insert(node->right, key, value);
}
return node;
}
二叉樹的包含,查找其實都很類似,都是小于往左邊找,大于往右邊找,只是返回值不一樣,操作很常規,比較復雜的是刪除操作,要旋轉之類的。
Value *search(Node *node, Key key){
if (NULL == node){
return NULL;
} else if(node->key == key){
return &(node->value);
} else if(key < node->key){
return search(node->left, key);
} else{
return search(node->right, key);
}
}
bool contain(Node *node, Key key){
if (NULL == node){
return false;
}
if (node->key == key){
return true;
} else if(key < node->key){
return contain(node->left, key);
} else{
return contain(node->right, key);
}
}
};
以上方法都是放在了私有方法里面的,要調用只需要在public里面加上調用即可:
Value *search(Key key){
return search(root, key);
}
bool contain(Key key){
return contain(root, key);
}
對于search的返回值,其實見仁見智,返回node,固定的一個值都可以。但是如果返回的是一個node,那么調用的時候就需要用戶知道程序的結構,比如這道直觀node節點是啥才能拿出來,這樣封裝性就不好了;如果返回的是一個值,那么如果為空的時候就回不來了,所以把它的指針作為返回值。
二叉樹的遍歷方式有三種,前序遍歷,中序遍歷和后續遍歷。前序遍歷先訪問當前節點,再訪問左右節點;中序遍歷先訪問左節點,再訪問自身,最后右節點;后序遍歷先訪問左右子樹最后才訪問自身節點。
如果是前序遍歷,那么輸出的位置就是第一個園點的位置。中序遍歷也是一樣:
void preOrder(Node *node){
if (node != NULL){
cout << node->value;
preOrder(node->left);
preOrder(node->right);
}
}
void inOrder(Node *node){
if (node != NULL){
inOrder(node->left);
cout << node->value;
inOrder(node->right);
}
}
void postOrder(Node * node){
if (node != NULL){
postOrder(node->left);
postOrder(node->right);
cout << node->value;
}
}
沒有什么難度的。注意到后序遍歷是左右才到中間,所以我們可以使用這種方法來對對整棵樹進行釋放。
void destory(Node *node){
if (node != NULL){
destory(node->left);
destory(node->right);
delete node;
count --;
}
}
之前我們遍歷二叉樹都是往深處遍歷,都是遍歷完一顆子樹再遍歷其他子樹,所以又叫深度遍歷。對于遍歷方式還有另外一種,就是廣度優先遍歷,對應到二叉樹里面就是層序遍歷。先遍歷完樹的一層再遍歷下一層。這種遍歷方式沒有往深處遍歷到底,而是更關注廣度的遍歷。要實現這種遍歷就需要使用到隊列,事實上很多廣度優先遍歷都是用隊列來實現,圖的廣度也是這樣實現。
void levelOrder(){
queue<Node *> q;
q.push(root);
while (!q.empty()){
Node *p = q.front();
q.pop();
cout << p->value;
if (p->left){
q.push(p->left);
}
if (p->right){
q.push(p->right);
}
}
}
這樣很容易就實現了。二叉樹的刪除操作應該是屬于二叉樹操作里面比較難的部分,難的不是因為·刪除本身,而是在于刪除之后要怎么保持樹的性質,所以是需要旋轉。首先從一個最簡單的問題開始,求二叉樹最大值和最小值。
Key minimum() {
assert(count != 0);
Node *node = minimum(root);
return node->key;
}
Key maximum(){
assert(count != 0);
Node *node = maximum(root);
return node->key;
}
最大值和最小值都是一樣。刪除最小值,刪除最小值有兩種情況,如果這個最小值剛剛好沒有任何節點,刪除就很簡單,如果是有的話那就需要一些操作了。
Node *removeMax(Node *node){
if (node->right == NULL){
Node *left = node->left;
delete node;
count --;
return left;
}
node->right = removeMax(node->right);
return node;
}
Node *removeMin(Node *node){
if (node->left == NULL){
Node *right = node->right;
delete node;
count --;
return right;
}
node->left = removeMin(node->left);
return node;
}
其實差不多的。最大值只會有左孩子,最小值只有右孩子。回到刪除節點,對于只有右孩子,因為右孩子本來就大于當前節點,只有左孩子也是一樣的。最難的就是左右孩子都有的情況,這樣就尷尬了:
Node *remove(Node *node, Key key){
if ( node == NULL){
return NULL;
} else if (key < node->key){
node->left = remove(node->left, key);
} else if (key > node->key){
node->right = remove(node->right, key);
} else{
if (node->left == NULL){
Node *right = node->right;
delete node;
count --;
return right;
} else if (node->right == NULL){
Node *left = node->left;
delete node;
count --;
return left;
} else{
Node *delNode = node;
Node *successor = new Node(minimum(node->right));
count ++;
successor->right = removeMin(node->right);
successor->left = node->left;
delete delNode;
count --;
return successor;
}
}
}
首先判斷一下是不是只有左子樹和右子樹,如果只是有一邊,那么可以直接把那一邊拉上去,如果是兩邊都有,那就需要找到右邊的最小值,代替要刪除的節點。這里有一個陷阱,使用successor得到最小的節點之后,后面又刪除了,這樣就會得到一個空指針,我們只是要得到它并不是要刪除,這個時候我們應該要復制一份過來,不能直接就拉過來。
以上的操作都是把二叉樹當成一個查找表來實現的,但是二叉樹還有一個很好的性質,二叉樹是有很好的順序性,所以二叉樹不僅僅可以用來實現查找,還可以找最小值最大值,前驅后繼等等。但是二叉樹并不是一成不變的,各種元素的插入順序的不同是可以導致二叉樹的結構不一樣,如果數據是基本有序的,插入基本就是一個鏈表的形狀了,退化成了一個順序鏈表。改進方法后面會提到紅黑樹AVL樹這些更加高級的數據結構。
并查集
這個數據結構并不算高級的數據結構,但是在后面的圖會用到來判斷是否形成環,如果兩個節點的根是相同的,那么就可以判斷這兩個節點是同一組的了,也就是已經連在了一起。之前的堆,二叉樹都是樹形的結構,而這個并查集也算是一種樹形結構,但是和上述的兩種不太一樣。并查集可以解決的問題有很多,首先就是連接問題:
首先要知道一個并查集要支持什么操作,并查集主要支持兩個操作,
用元素的下標是不是相同來表示這兩個元素是不是連接的:
class unionFind {
private:
int *id;
int count;
public:
unionFind(int n) {
count = n;
id = new int(n);
for (int i = 0; i < n; ++i) {
id[i] = i;
}
}
~unionFind() {
delete[] id;
}
一開始大家都是各自為政,沒有組團,所以都是不一樣的,直接按照序列號即可。查找就是很簡單了,就是直接返回id[]即可。
int find(int p) {
assert(p >= 0 && p <= count);
return id[p];
}
判斷是不是連在一起的,直接找到當前屬于的下標再比較即可,
void unionElements(int p, int q){
int pID = find(p);
int qID = find(q);
if (pID == qID){
return;
}
for (int i = 0; i < count; ++i) {
if (id[i] == pID){
id[i] = qID;
}
}
}
連接其實就是把兩個堆連在一起,掃描下面的坐標相同的元素賦值即可,賦值那邊賦值都可以。這種并查集查找很快,也叫quickUnion。
UF_version1::unionFind uF = UF_version1::unionFind(10);
uF.unionElements(1, 2);
uF.unionElements(5, 4);
uF.unionElements(3, 1);
cout << uF.isConnected(4, 5) << endl;
上面這一種實現方法雖然實現查找的時候很快,但是實現并操作的時候很慢,需要進行遍歷。并查集的另一種實現方式,而這種實現方式是后面實現并查集的一種常規實現方式。將每一個元素看成是一個節點,如果兩個元素是一起的,那么這兩個元素是一個指向另一個的。
int find(int p){
assert(p >= 0 && p <= count);
while (parent[p] != p){
p = parent[p];
}
return p;
}
bool isConnected(int p, int q){
return find(p) == find(q);
}
void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot){
return;
}
parent[pRoot] = qRoot;
}
不斷向上找到自己的根,自己等于自己的就是當前的根了,如果不是就向下尋找,直到找到為止。這個時候的合并效率就不低了。注意到代碼中的構造函數中有一個ecplicit,因為在c++中單個參數的構造函數是存在有隱式的轉換的,加上這個關鍵字就可以禁止了,只能顯示的構造,比如String = "Hello";就是一種隱式構造。
對于這種并查集,還是有優化空間的:
比如這兩個堆要和在一起,如果是9合到另外一個,
private:
int *parent;
int *sz;
int count;
增加一個sz的數組存儲當前這個集合的數量,用于后面插入的比較,哪個大就插哪個。只要改變一下unionElements就好了,其他的不用變。
void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) {
return;
}
if (sz[pRoot] < sz[pRoot]){
parent[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
} else{
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
}
其實基于數量的優化還不是最優的,如果一個堆過于分散那么合并起來的效率不是很高的,所以還有一種改進方法,就是對比兩者的層數插入即可。并查集的線路壓縮,以上的插入很多時候有些隨機的成分,如果對這個線路的結構進行調整會快很多。這種方法就叫路徑壓縮。
int find(int p) {
assert(p >= 0 && p <= count);
while (parent[p] != p) {
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
沒了,路徑壓縮就這樣。當前節點指向他的根就好了,而find就是找到它的根。遞歸求解即可。在經過了路徑壓縮的優化之后查找的復雜度幾乎就是復雜度了。
最后附上github地址:https://github.com/GreenArrow2017/DataStructure/tree/master/DataStucture