紅黑樹真的沒你想的那麼難
概述
TreeMap是紅黑樹的java實現,紅黑樹能保證增、刪、查等基本操作的時間複雜度為O(lgN)。
首先我們來看一張TreeMap的繼承體系圖:
還是比較直觀的,這裡來簡單說一下繼承體系中不常見的介面NavigableMap和SortedMap,這兩個介面見名知意。先說NavigableMap介面,NavigableMap介面聲明瞭一些列具有導航功能的方法,比如:
/**
* 返回紅黑樹中最小鍵所對應的 Entry
*/
Map.Entry<K,V> firstEntry();
/**
* 返回最大的鍵 maxKey,且 maxKey 僅小於引數 key
*/
K lowerKey(K key);
/**
* 返回最小的鍵 minKey,且 minKey 僅大於引數 key
*/
K higherKey(K key);
// 其他略
通過這些導航方法,我們可以快速定位到目標的 key 或 Entry。至於 SortedMap 介面,這個介面提供了一些基於有序鍵的操作,比如:
/**
* 返回包含鍵值在 [minKey, toKey) 範圍內的 Map
*/
SortedMap<K,V> headMap(K toKey);();
/**
* 返回包含鍵值在 [fromKey, toKey) 範圍內的 Map
*/
SortedMap<K,V> subMap(K fromKey, K toKey);
// 其他略
以上就是兩個介面的介紹,很簡單。至於AbstractMap和Map這裡就不說了,大家有興趣自己去看看Javadoc吧。關於TreeMap的繼承體系就這裡就說到這,接下來我們進入細節部分分析。
原始碼分析
新增
紅黑樹最複雜的無非就是增刪了,這邊我們先介紹增加一個元素,瞭解紅黑樹的都知道,當往 TreeMap 中放入新的鍵值對後,可能會破壞紅黑樹的性質。首先我們先鞏固一下紅黑樹的特性。
節點是紅色或黑色。
根節點是黑色。
每個葉子節點都是黑色的空節點(NIL節點)。
每個紅色節點的兩個子節點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)。
從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。
接下來我們看看新增到底做了什麼處理:
public V put(K key, V value) {
TreeMapEntry<K,V> t = root;
if (t == null) {
if (comparator != null) {
if (key == null) {
comparator.compare(key, key);
}
} else {
if (key == null) {
throw new NullPointerException("key == null");
} else if (!(key instanceof Comparable)) {
throw new ClassCastException(
"Cannot cast" + key.getClass().getName() + " to Comparable.");
}
}
root = new TreeMapEntry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
TreeMapEntry<K,V> parent;
Comparator<? super K> cpr = comparator;
if (cpr != null) {
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
TreeMapEntry<K,V> e = new TreeMapEntry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
這邊會先把根節點暫存依賴,如果根節點為null,則講新增的這個節點設為根節點。 如果初始化的時候指定了comparator比較器,則講其插入到指定位置,否則使用key進行比較並且插入。不斷的進行比較,找到沒有子節點的節點,將其插入到相應節點。(注:如果查找出有相同的值只會更新當前值,cmp小於0是沒有左節點,反之沒有右節點。)
新插入的樹破環的紅黑樹規則,我們會通過fixAfterInsertion去進行相應的調整,這也是TreeMap插入實現的重點,具體我們看看他是怎麼通過java實現的。
private void fixAfterInsertion(TreeMapEntry<K,V> x) {
x.color = RED;
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
TreeMapEntry<K,V> y = rightOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
} else {
TreeMapEntry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == leftOf(parentOf(x))) {
x = parentOf(x);
rotateRight(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateLeft(parentOf(parentOf(x)));
}
}
}
root.color = BLACK;
}
首先將新插入的節點設定為紅色,這邊做了一個判斷,新節點不為null,新節點不為根節點並且新節點的父節點為紅色。才會進入內部的判斷,因為其本身就是一個紅黑樹。如果新節點的父節點為黑色,則他依舊滿足紅黑樹的特性。所以當其父節點為紅色進入內部的判斷。
如果新節點是其祖父節點的左子孫,則拿到其祖父節點的右兒子,也就是新節點的叔叔。如果叔叔節點是紅色。則將其父節點設為黑色,講叔父節點設為黑色,然後講新節點直接其祖父節點。
否則如果新節點是其父節點的右節點,以其父節點進行左轉,將父節點設為黑色,祖父節點設為紅色,在通過祖父節點進行右轉。
else內容和上述基本一致。自己分析~~
最後我們還需要將跟節點設為黑色。
我們稍微看一下,他是怎麼進行左轉和右轉的。
// 右旋與左旋思路一致,只分析其一
// 結果相當於把p和p的兒子調換了
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
// 取出p的右兒子
Entry<K,V> r = p.right;
// 然後將p的右兒子的左兒子,也就是p的左孫子變成p的右兒子
p.right = r.left;
if (r.left != null)
// p的左孫子的父親現在是p
r.left.parent = p;
// 然後把p的父親,設定為p右兒子的父親
r.parent = p.parent;
// 這說明p原來是root節點
if (p.parent == null)
root = r;
else if (p.parent.left == p)
p.parent.left = r;
else
p.parent.right = r;
r.left = p;
p.parent = r;
}
}
//和左旋類似
private void rotateRight(Entry<K,V> p) {
// ...
}
下面我們通過圖解來看看如何插入一顆紅黑樹。
現有陣列int[] a = {1, 10, 9, 2, 3, 8, 7, 4, 5, 6};我們要將其變為一棵紅黑樹。
首先插入1,此時樹是空的,1就是根結點,根結點是黑色的:
然後插入元素10,此時依然符合規則,結果如下:
當插入元素9時,這時是需要調整的第一種情況,結果如下:
紅黑樹規則4中強調不能有兩個相鄰的紅色結點,所以此時我們需要對其進行調整。調整的原則有多個相關因素,這裡的情況是,父結點10是其祖父結點1(父結點的父結點)的右孩子,當前結點9是其父結點10的左孩子,且沒有叔叔結點(父結點的兄弟結點),此時需要進行兩次旋轉,第一次,以父結點10右旋:
然後將父結點(此時是9)染為黑色,祖父結點1染為紅色,如下所示:
然後以祖父結點1左旋:
下一步,插入元素2,結果如下:
此時情況與上一步類似,區別在於父結點1是祖父結點9的左孩子,當前結點2是父結點的右孩子,且叔叔結點10是紅色的。這時需要先將叔叔結點10染為黑色,再進行下一步操作,具體做法是將父結點1和叔叔結點10染為黑色,祖父結點9染為紅色,如下所示:
由於結點9是根節點,必須為黑色,將它染為黑色即可:
下一步,插入元素3,如下所示:
這和我們之前插入元素10的情況一模一樣,需要將父結點2染為黑色,祖父結點1染為紅色,如下所示:
然後左旋:
下一步,插入元素8,結果如下:
此時和插入元素2有些類似,區別在於父結點3是右孩子,當前結點8也是右孩子,這時也需要先將叔叔結點1染為黑色,具體操作是先將1和3染為黑色,再將祖父結點2染為紅色,如下所示:
此時樹已經平衡了,不需要再進行其他操作了,現在插入元素7,如下所示:
這時和之前插入元素9時一模一樣了,先將7和8右旋,如下所示:
然後將7染為黑色,3染為紅色,再進行左旋,結果如下:
下一步要插入的元素是4,結果如下:
這裡和插入元素2是類似的,先將3和8染為黑色,7染為紅色,如下所示:
但此時2和7相鄰且顏色均為紅色,我們需要對它們繼續進行調整。這時情況變為了父結點2為紅色,叔叔結點10為黑色,且2為左孩子,7為右孩子,這時需要以2左旋。這時左旋與之前不同的地方在於結點7旋轉完成後將有三個孩子,結果類似於下圖:
這種情況處理起來也很簡單,只需要把7原來的左孩子3,變成2的右孩子即可,結果如下:
然後再把2的父結點7染為黑色,祖父結點9染為紅色。結果如下所示:
此時又需要右旋了,我們要以9右旋,右旋完成後7又有三個孩子,這種情況和上述是對稱的,我們把7原有的右孩子8,變成9的左孩子即可,如下所示:
下一個要插入的元素是5,插入後如下所示:
有了上述一些操作,處理5變得十分簡單,將3染為紅色,4染為黑色,然後左旋,結果如下所示:
最後插入元素6,如下所示:
又是叔叔結點3為紅色的情況,這種情況我們處理過多次了,首先將3和5染為黑色,4染為紅色,結果如下:
此時問題向上傳遞到了元素4,我們看2、4、7、9的顏色和位置關係,這種情況我們也處理過,先將2和9染為黑色,7染為紅色,結果如下:
最後7是根結點,染為黑色即可,最終結果如下所示:
可以看到,在插入元素時,叔叔結點是主要影響因素,待插入結點與父結點的關係決定了是否需要多次旋轉。
刪除
除了新增操作,紅黑樹的刪除也是很麻煩的…我們看看怎麼通過java去實現紅黑樹的刪除。具體程式碼如下:
public V remove(Object key) {
TreeMapEntry<K,V> p = getEntry(key);
if (p == null)
return null;
V oldValue = p.value;
deleteEntry(p);
return oldValue;
}
其內部是通過deleteEntry去進行刪除的。所以我們具體看看deleteEntry的實現。
private void deleteEntry(TreeMapEntry<K,V> p) {
modCount++;
size--;
if (p.left != null && p.right != null) {
TreeMapEntry<K,V> s = successor(p);
p.key = s.key;
p.value = s.value;
p = s;
}
TreeMapEntry<K,V> replacement = (p.left != null ? p.left : p.right);
if (replacement != null) {
// Link replacement to parent
replacement.parent = p.parent;
if (p.parent == null)
root = replacement;
else if (p == p.parent.left)
p.parent.left = replacement;
else
p.parent.right = replacement;
p.left = p.right = p.parent = null;
// Fix replacement
if (p.color == BLACK)
fixAfterDeletion(replacement);
} else if (p.parent == null) {
root = null;
} else {
if (p.color == BLACK)
fixAfterDeletion(p);
if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
}
根據上述程式碼,我們可以看出,如果 p 有兩個孩子節點,則找到後繼節點,並把後繼節點的值複製到節點 P 中,並讓 p 指向其後繼節點。 然後將 replacement parent 引用指向新的父節點,同時讓新的父節點指向 replacement。
然後判斷如果刪除的節點 p 是黑色節點,則需要進行調整。刪除的是根結點並且樹中只有一個節點,我們將根結點置為null,否則,如果刪除的節點沒有子節點並且是黑色,則需要調整。最後將p從樹中移除。
刪除了一個元素,為了保證還是一個紅黑樹,我們需要將其進行調整,具體程式碼如下:
/** From CLR */
private void fixAfterDeletion(TreeMapEntry<K,V> x) {
while (x != root && colorOf(x) == BLACK) {
if (x == leftOf(parentOf(x))) {
TreeMapEntry<K,V> sib = rightOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateLeft(parentOf(x));
sib = rightOf(parentOf(x));
}
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
if (colorOf(rightOf(sib)) == BLACK) {
setColor(leftOf(sib), BLACK);
setColor(sib, RED);
rotateRight(sib);
sib = rightOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(rightOf(sib), BLACK);
rotateLeft(parentOf(x));
x = root;
}
} else { // symmetric
TreeMapEntry<K,V> sib = leftOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateRight(parentOf(x));
sib = leftOf(parentOf(x));
}
if (colorOf(rightOf(sib)) == BLACK &&
colorOf(leftOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
if (colorOf(leftOf(sib)) == BLACK) {
setColor(rightOf(sib), BLACK);
setColor(sib, RED);
rotateLeft(sib);
sib = leftOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(leftOf(sib), BLACK);
rotateRight(parentOf(x));
x = root;
}
}
}
setColor(x, BLACK);
}
如果替換節點是父節點的左節點,並且替換節點的兄弟節點是紅色,那我們需要將兄弟節點變成黑色,將父節點變成紅色,並且通過父節點進行左旋轉,然後將父節點的右節點設為兄弟節點。
如果兄弟節點的左右節點都是黑色的,那麼將兄弟節點置為紅色,並且將當前節點指向父節點。若兄弟節點的右節點是黑色,我們需要將兄弟節點的左節點設為黑色,將兄弟節點設為紅色,然後以兄弟節點進行右旋轉,然後更新兄弟節點。然後設定兄弟節點的顏色為右節點的顏色,然後將父節點和兄弟節點的左節點設為黑色,最後進行右旋轉。最後將根結點設為黑色。
下面我們依舊通過圖解來看看紅黑樹的刪除操作:要從一棵紅黑樹中刪除一個元素,主要分為三種情況。
待刪除元素沒有孩子
沒有孩子指的是沒有值不為NIL的孩子。這種情況下,如果刪除的元素是紅色的,可以直接刪除,如果刪除的元素是黑色的,就需要進行調整了。
例如我們從下圖中刪除元素1:
刪除元素1後,2的左孩子為NIL,這條支路上的黑色結點數就比其他支路少了,所以需要進行調整。
這時,我們的關注點從叔叔結點轉到兄弟結點,也就是結點4,此時4是紅色的,就把它染為黑色,把父結點2染為紅色,如下所示:
然後以2左旋,結果如下:
此時兄弟結點為3,且它沒有紅色的孩子,這時只需要把它染為紅色,父結點2染為黑色即可。結果如下所示:
待刪除元素有一個孩子
這應該是刪除操作中最簡單的一種情況了,根據紅黑樹的定義,我們可以推測,如果一個元素僅有一個孩子,那麼這個元素一定是黑色的,而且其孩子是紅色的。
假設我們有一個紅色節點,它是樹中的某一個節點,且僅有一個孩子,那麼根據紅色節點不能相鄰的條件,它的孩子一定是黑色的,如下所示:
但這個子樹的黑高卻不再平衡了(注意每個節點的葉節點都是一個NIL節點),因此紅色節點不可能只有一個孩子。
而若是一個黑色節點僅有一個孩子,如果其孩子是黑色的,同樣會打破黑高的平衡,所以其孩子只能是紅色的,如下所示:
只有這一種情況符合紅黑樹的定義,這時要刪除這個元素,只需要使用其孩子代替它,僅代替值而不代替顏色即可,上圖的情況刪除完後變為:
可以看到,樹的黑高並沒有發生變化,因此也不需要進行調整。
待刪除元素有兩個孩子
我們知道如果刪除一個有兩個孩子的元素,可以使用它的前驅或者後繼結點代替它。因為它的前驅或者後繼結點最多隻會有一個孩子,所以這種情況可以轉為上述兩種情況進行處理。
查詢
說了最複雜的新增和刪除,我們再來看看查詢,這裡就簡單很多了,具體程式碼如下:
public V get(Object key) {
Entry<K,V> p = getEntry(key);
return (p==null ? null : p.value);
}
final Entry<K,V> getEntry(Object key) {
// Offload comparator-based version for sake of performance
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
// 查詢操作的核心邏輯就在這個 while 迴圈裡
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
這個流程算比較簡單了,上面註釋標明瞭,這邊就不再解釋了。
總結
這邊通過圖+程式碼的形式將紅黑樹的特點展現出來。可以通過上面描述可見,紅黑樹並沒有那麼難…
參考
相關推薦
紅黑樹真的沒你想的那麼難!
寫本文的原由是昨晚做夢居然夢到了在看原始碼,於是便有了此文...... 雖然標題是關於紅黑樹的,不過此文是結合圖片,通過分析TreeMap的原始碼,讓你理解起來不是那麼枯燥(前方高能,此文圖片眾多,慎入)。 作者 | 馬雲飛 責編 | 胡巍巍
紅黑樹真的沒你想的那麼難
概述 TreeMap是紅黑樹的java實現,紅黑樹能保證增、刪、查等基本操作的時間複雜度為O(lgN)。 首先我們來看一張TreeMap的繼承體系圖: 還是比較直觀的,這裡來簡單說一下繼承體系中不常見的介面NavigableMap和SortedMa
JavaScript的執行上下文,真沒你想的那麼難
> 作者:小土豆 > 部落格園:https://www.cnblogs.com/HouJiao/ > 掘金:https://juejin.im/user/2436173500265335 # 前言 在正文開始前,先來看兩個`JavaScript`程式碼片段。 #### 程式碼一 ```java
教你透徹了解紅黑樹
black ade 我們 工作 key 針對 otn strong lean 教你透徹了解紅黑樹 作者:July、saturnman 2010年12月29日 本文參考:Google、算法導論、STL源碼剖析、計算機程序設計藝術。 推薦閱讀: Left-
WeCode線上少兒程式設計|計算思維VS繪畫,程式設計沒你想的那麼枯燥
01 — 初遇計算思維:它為什麼這麼重要? 21世紀,每個人都身處數字世界,我們的生活充斥著科技,我們的日常生活受到電腦科學的驅動。實際上,軟體和技術已經改變了從科學到藝術,甚至從醫學到心理學的每個工作領域。與此同時,一種被稱為“21世紀技能錦囊”的新型理念應運而生——計算思維。
大名鼎鼎的紅黑樹,你get了麼?2-3樹 絕對平衡 右旋轉 左旋轉 顏色反轉
前言 11.1新的一月加油!這個購物狂歡的季節,一看,已囊中羞澀!趕緊來惡補一下紅黑樹和2-3樹吧!紅黑樹真的算是大名鼎鼎了吧?即使你不瞭解它,但一定聽過吧?下面跟隨我來揭開神祕的面紗吧! 一、2-3樹 1、搶了紅黑樹的光環? 今天的主角是紅黑樹,是無疑的,主角光環在呢!那2-3樹
教你透徹瞭解紅黑樹---第一篇
文章《教你透徹瞭解紅黑樹—第二篇》,主要說明了紅黑樹的旋轉、插入、刪除等操作。 1 背景知識 1.1 二叉樹 二叉樹是每個節點最多有兩個子樹的樹結構。通常子樹被稱作“左子樹”(left subtree)和“右子樹”(right
一步一圖一程式碼,一定要讓你真正徹底明白紅黑樹(平衡二叉樹)
一步一圖一程式碼,一定要讓你真正徹底明白紅黑樹 作者:July 二零一一年一月九日 ----------------------------- 本文參考: I、 The Art of Computer Programming Volume I II、 I
一棵沒實現刪除的紅黑樹
除了刪除節點之外,把紅黑樹基本上看完了,順手用java實現了一棵紅黑樹import javax.swing.*; /** * Created by eminem on 16-11-15. */ public class brTree { private Nod
教你初步瞭解紅黑樹
教你初步瞭解紅黑樹作者:July、saturnman 2010年12月29日本文參考:Google、演算法導論、STL原始碼剖析、計算機程式設計藝術。推薦閱讀:一、紅黑樹的介紹先來看下演算法導論對R-B Tree的介紹:紅黑樹,一種二叉查
一步一圖一程式碼,一定要讓你真正徹底明白紅黑樹 (July演算法!!!)
情況5: 兄弟S為黑色,S 的左兒子是紅色,S 的右兒子是黑色,而N是它父親的左兒子。//此種情況,最後轉化到下面的情況6。 [對應我第二篇文章中,情況3:x的兄弟w是黑色的,w的左孩子是紅色,w的右孩子是黑色。] void delete_case5(struct node *n) { str
四面快手歸來,分享Java真題及面經:策略模式+紅黑樹+Java鎖+Redis+Kafka等分散式
看真題,瞭解差距,明確學習方向與目標。 看面經,提前準備,事半功倍。 ** 一面(一個小時十分鐘) ** 1.自我介紹 2.說說B+樹和B樹的區別,優缺點等? 3聊聊Spring,主要IOC等等 4多執行緒JUC包下的一些常見的類,比如CountDownLatch、Se
紅黑樹並沒有我們想象的那麼難(下)
// sgi stl _Rb_tree 插入演算法 insert_equal() 實現. // 策略概述: insert_equal() 在紅黑樹找到自己的位置, // 然後交由 _M_insert() 來處理接下來的工作. // _M_insert() 會將節點插入紅黑樹中, 接著調整紅黑樹, // 維持性
紅黑樹,並非想象中的那麼複雜
紅黑樹是非常popular的一種self-adjusted的平衡二叉排序樹。通常他給我們的印象是很複雜,有很多case,要小心的旋轉。有人說,曾將在某公司的面試時,被要求實現紅黑樹。他覺得這很沒有道理,幾乎很少有人能在不參考教科書的情況下,記清楚那麼多的case。在這一章裡,
紅黑樹並沒有我們想象的那麼難(上)
紅黑樹並沒有我們想象的那麼難 上、下兩篇已經完成, 希望能幫助到大家. 紅黑樹並沒有想象的那麼難, 初學者覺得晦澀難讀可能是因為情況太多. 紅黑樹的情況可以通過歸結, 通過合併來得到更少的情況, 如此可以加深對紅黑樹的理解. 網路上的大部分紅黑樹的講解因為沒有「合併」. 紅黑樹的五個
Web登入其實沒你想的那麼簡單
1. 一個簡單的HTML例子看看使用者資訊保安標準的HTML語法中,支援在form表單中使用&l
Select—沒你想的那麼複雜
Select語句的基本結構如下: Select [All | Distinct] select_list
正則表示式並沒你想的那麼難
首先正則表示式幹嘛用的呢。沒錯就是用來匹配字串的。記得資料庫中的like關鍵字嗎關鍵字中的like ‘%a%’中的’%a%’其實就是一個類正則表示式。%是一個萬用字元。那麼正則表示式中的有哪些類似的這種符號呢? . 可以匹配所有字元,類似於上例中的%吧 \d ,[0-9]
React+Redux開發實戰專案【美團App】,沒你想的那麼難
README.md 前言 開始學習React的時候,在網上找了一些文章,讀了官網的一些文件,後來覺得React上手還是蠻簡單的, 然後就在網上找了一個React實戰的練手專案,個人學完之後覺