目錄
時間複雜度
二叉樹(Binary Tree)
存儲結構
二叉樹的存儲結構有兩種,順序存儲結構和鏈式存儲結構。
PS:鏈式存儲結構的二叉樹極端情況下會退化成單鏈表。
基本概念
二叉樹基本概念一覽 -> 結點的度,結點的種類,遍歷方式…
樹的高度和深度的區別:某結點的深度是指從根結點到該結點的最長簡單路徑邊的條數,而高度是指從該結點到葉子結點的最長簡單路徑邊的條數。(這裏規定根結點的深度和葉子結點的高度爲0)因此,樹的高度和深度是一樣的,但是對於某個結點的高度和深度是不一定相等。
二叉樹的深度 = max(左子樹深度,右子數深度) + 1,可用遞歸的方式實現(“左右根”,後序遍歷)。
二叉樹分類
前提:樹的高度h從1開始,根結點下標爲1。
滿二叉樹(perfect binary tree):每層結點個數都是最大值的二叉樹。如果二叉樹的結點個數爲個,則可以判斷爲滿二叉樹。(遍歷所有節點,計算節點個數,O(n))
完全二叉樹(complete binary tree):在完全二叉樹中,除了最底層結點可能沒填滿外,其餘每層結點數都達到最大值,並且最下面一層的結點都集中在該層最左邊的若干位置。若最底層爲第 h 層,則該層包含個結點。
完全二叉樹的節點個數 -> 利用完全二叉樹的性質,即左右子樹中必定有滿二叉樹,另一個子樹爲完全二叉樹,可以遞歸進行。滿二叉樹的節點個數可以通過樹的高度h直接計算得到。時間複雜度O((logn)^2),每層遞歸需要計算一次左右子樹的高度, -> O(h^2)。
PS:已知是完全二叉樹,判斷是否爲滿二叉樹,主要判斷樹最左邊和最右邊的結點高度是否相等,相等則是滿二叉樹。
判斷是否爲完全二叉樹:bfs找到第一個不含有孩子或者只含有一個左孩子的結點,那麼後續的結點必須是葉子結點才滿足完全二叉樹性質。
int countNodes(TreeNode* root) {
int h;
if(isFullTree(root, h)){
return (1<<h) -1;
}
return countNodes(root->left)+countNodes(root->right)+1; // ‘+1’是把root自身也算上
}
// 判斷完全二叉樹是否爲滿二叉樹
bool isFullTree(TreeNode* root, int& h){
if(root==nullptr){
h = 0;
return true;
}
TreeNode* p = root;
int countLeft = 1, countRight = 1;
while(p->left!=nullptr){
p = p->left;
countLeft++;
}
p = root;
while(p->right!=nullptr){
p = p->right;
countRight++;
}
h = countLeft;
return countLeft == countRight;
}
二叉搜索樹/二叉排序樹(binary search tree):它或者是一棵空樹,或者是具有下列性質的二叉樹: 若它的左子樹不空,則左子樹上所有結點的值均小於它的根結點的值; 若它的右子樹不空,則右子樹上所有結點的值均大於等於它的根結點的值; 它的左、右子樹也分別爲二叉排序樹。查找平均效率O(logn)。
二叉搜索樹的第k大節點 -> 利用二叉搜索樹性質,中序遍歷二叉搜索樹輸出的按非嚴格遞增或者遞減序排列的值。(遞增是左根右,遞減是右左根)
int count;
// 反向的中序遍歷,"右根左",結點的值按降序輸出
int kthLargest(TreeNode* root, int k) {
int re;
count = k;
traverse(root,&re);
return re;
}
void traverse(TreeNode* root, int* re){
if(root==nullptr){
return;
}
traverse(root->right,re);
if(count==1){
*re = root->val;
}
if(--count == 0){ // 剪枝
return;
}
traverse(root->left,re);
}
二叉搜索樹的最近公共祖先 -> 利用BST的右孩子>=根>左孩子的性質即可。
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if(root==nullptr || p->val < root->val && q->val >= root->val ||
(p->val >= root->val && q->val < root->val)||
root->val == p->val || root->val == q->val){
return root;
}
TreeNode* l = lowestCommonAncestor(root->left,p,q);
TreeNode* r = lowestCommonAncestor(root->right,p,q);
return l==nullptr ? r:l;
}
PS:二叉樹的最近公共祖先 -> 後序遍歷,左右孩子其中一個返回p或q指針,則將p或q指針向上傳遞;若左右孩子分別返回有p和q指針,則根爲LCA。(如果是p或q結點是它自己的祖先的情況,最終返回p或者q指針!)
// 後序遍歷
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if(root == nullptr || root == p || root == q){ // 遇到p和q指針或者空指針返回
return root;
}
TreeNode* left, *right;
left = lowestCommonAncestor(root->left,p,q);
right = lowestCommonAncestor(root->right,p,q);
if(left == p && right == q || (left == q && right == p)){ // root爲LCA,並將root指針本身向上傳遞
return root;
}
// left和right爲空指針表示以它們爲根的子樹沒有p和q結點,因此返回它們之中的非空指針,傳遞給root
return left==nullptr? right:left;
}
二叉搜索樹的查找效率取決於樹的高度,因此保持樹的高度最小,即可保證樹的查找效率。AVL樹和紅黑樹都是自平衡的二叉搜索樹。
平衡二叉樹/AVL樹:在AVL樹中,任一節點對應的左、右子樹的最大高度差爲1,因此它也被稱爲高度平衡樹。查找、插入和刪除在平均和最壞情況下的時間複雜度都是,但平衡樹結構的代價較大。什麼是平衡二叉樹(AVL)
判斷是否爲平衡二叉樹 -> 判斷樹中所有結點的子樹的高度差是否都不大於1。
bool isBalanced(TreeNode* root) {
bool flag = true; // 平衡二叉樹可以是空樹
traverse(root,&flag);
return flag;
}
// 從底向上求結點的高度
int traverse(TreeNode* root, bool* flag){
if(root==nullptr || !flag){ // 當已經判斷不是平衡二叉樹的時候可以直接剪枝返回了
return 0;
}
int l = traverse(root->left,flag);
int r = traverse(root->right,flag);
if(abs(l-r) > 1){
*flag = false;
}
return max(l,r)+1;
}
紅黑樹/RBT樹:從根節點到葉子節點的最長路徑不超過最短路徑的兩倍。查找效率基本維持在O(logn),但在最差情況下比AVL樹要遜色一點,遠遠好於BST樹。
漫畫:什麼是紅黑樹?
輕鬆搞定面試中的紅黑樹問題
PS:大量數據實踐證明,RBT的總體統計性能要好於平衡二叉樹。
STL裏哪些容器用到二叉樹存儲?
map、set的底層數據結構是紅黑樹,插入的數據是有序存儲的,默認按key的升序存儲,查找效率O(logn)。map和set是關聯容器,內部所有元素都是以結點的方式來存儲,爲鏈式存儲結構。(unordered_map和unorder_set的底層數據結構是哈希表,查找效率O(1),但插入數據是無序的,爲順序存儲結構)
相關練習
堆(heap)
堆以完全二叉樹的形式表示,用隊列(數組)存儲,隊列中允許的操作是先進先出(FIFO),在隊尾插入元素,在隊頭取出元素。堆也是一樣,在堆底插入元素,在堆頂取出元素,但是堆中元素的排列不是按照到來的先後順序,而是按照一定的優先順序排列的,因此也稱爲優先隊列(priority queue)。(若隊列中根結點下標爲 且 從1開始,則它的左孩子下標爲,右孩子下標爲)
堆分爲大頂堆和小頂堆。堆頂爲隊列的頭部,在堆頂取出元素,一般爲最大或者最小的元素;堆底爲隊列的尾部,在堆底插入元素。大頂堆要求根結點的值大於等於左右孩子節點的值,小頂堆要求根結點的值小於等於左右孩子節點。
建堆
自底向上建堆:從下標最大的非葉子結點開始,從右向左,從底至上調整堆,每次調整爲一次下沉操作。調整下標爲 的結點的子樹最多需要交換次, 爲樹的高度,爲結點 所處二叉樹中的層數(層數從1開始),可推得建堆的時間複雜度O(n)。爲什麼建立一個二叉堆的時間爲O(N)而不是O(Nlog(N))?
自頂向下建堆:從根結點開始,然後一個一個的把結點插入堆中。當把一個新的結點插入堆中時,需要對結點進行調整,以保證插入結點後的堆依然能維持堆的性質。建堆的時間複雜度O(nlogn)。
堆排序
以升序爲例,重複從大頂堆取出數值最大的結點,即堆頂(把根結點和最後一個結點交換,把交換後的最後一個結點移出堆),並調整剩餘的堆,使之維持大頂堆的性質。堆排序的時間複雜度是O(nlog n)。
堆的插入和刪除操作
最小堆 構建、插入、刪除的過程圖解
插入操作,插入在隊列底部k,則它的父結點爲k/2,然後至底向上遞歸調整,即上浮;刪除操作,刪除是對於堆頂而言,將堆頂與堆底交換,然後將堆底移出堆,對剩餘的對進行至頂向下遞歸調整,即下沉。插入和刪除操作時間複雜度都是O(logn)。
相關練習
- 排序數組 -> 手寫堆排序,不用priority_queue
// 堆排序
vector<int> sortArray(vector<int>& nums) {
int n = nums.size()-1;
// 自底向上建堆,O(n)
for(int i = (n-1)/2; i>=0; i--){
adjust_heap(nums,i,n);
}
// 堆排序,O(nlogn)
for(int i = n; i > 0; i--){
swap(nums[0],nums[i]); //將堆頂元素與堆尾交換
adjust_heap(nums,0,i-1);
}
return nums;
}
// 下沉(下慮)操作,維護大頂堆,O(logn)
void adjust_heap(vector<int>& nums, int k, int max_index){
for(int i = 2*k+1; i <= max_index; i = 2*i+1){
if(i+1 <= max_index && nums[i] < nums[i+1]){
i = i+1; // 選擇左右孩子中大的那一個
}
if(nums[i] > nums[k]){
swap(nums[i],nums[k]);
k = i;
}else{ // 維護之前,節點k的左右子子樹滿足大頂堆的性質
break;
}
}
}
- 最小的k個數 -> 建立k個元素的大頂堆
鏈表(list)
鏈表是一種線性表,但是並不會按線性的順序存儲數據,而是在每一個結點裏存儲一個指向下一個結點的指針。
PS:鏈表list是離散存儲,數組vector是連續存儲,雙端隊列deque是vector和list的折中實現,是多個內存塊組成的,每個內存塊存放的元素是連續存儲的,而內存塊之間像鏈表一樣連接起來。
參考:一文搞定常見的鏈表問題
鏈表的問題一般都可以靈活的應用雙指針來解決!
相關練習
- 刪除鏈表中間某個結點 -> 傳入指向待刪除結點的指針P。沒有指向P的前驅結點的指針,不能刪除P指針指向的結點,但可以將待刪除結點的下一個結點的值給當前結點,刪除下一個結點。
- 獲取鏈表中倒數第k個結點 -> 雙指針p和q,p先移動k個結點,然後p和q再一起移動,當p指向null時,q指向倒數第k個結點。
- 獲取鏈表的中間結點 -> 快慢指針fast和slow,fast移動兩步,slow移動一步,循環條件
fast!=nullptr && fast->next!=nullptr
。當鏈表結點爲奇數,循環退出時fast->next爲null,slow指向中間結點;當鏈表結點爲偶數,循環退出時fast爲null,slow指向中間靠右結點(第二個中間結點)。
PS:當結點爲偶數,獲得中間靠左結點,可以預先定義一個pre保存slow的前一步結果。 - 判斷鏈表是否存在環 -> 快慢指針fast和slow,fast每次移動兩個結點,slow每次移動一個結點,如果slow能追上fast則鏈表存在環。
bool hasCycle(ListNode *head) {
ListNode *fast=head, *slow=head;
while(fast != nullptr && fast->next!=nullptr){
fast = fast->next->next;
slow = slow -> next;
if(fast == slow){
return true;
}
}
return false;
}
- 求鏈表環的長度 -> fast和slow指針相遇後,繼續移動,並從0開始記錄移動次數k,當fast和slow指針再次相遇時的經過的移動次數k爲環的長度。
- 求鏈表環的入口結點 -> 在獲得環的長度k後,利用雙指針p和q,p先移動k個結點,然後p和q一起移動,當p和q相遇時指向的結點就是入口結點。(假設頭結點到入口結點需要移動L步,環長k,因此第二次到入口結點需要移動L+k步。q移動L步到入口結點,所以p要先比q多移動k步,p第二次經過入口才會和第一次經過入口的q相遇)
PS:還有更快的方式,見下圖
- 反轉鏈表,不能用中間數組 -> 【反轉鏈表】:雙指針,遞歸,妖魔化的雙指針
雙指針思路:指針p和q,p初始定義爲nullptr,q初始指向頭結點,然後p和q都不斷向後移,直到q爲nullptr,此時p指向反轉後鏈表的頭結點。(中間需要temp指向q的後一個結點)
ListNode* reverseList(ListNode* head) {
ListNode *p = nullptr, *q = head, *temp;
while(q!=nullptr){
temp = q->next;
q->next = p;
p = q;
q = temp;
}
return p;
}
- 兩個鏈表的第一個公共節點 -> 先分別計算兩個鏈表的長度,然後計算鏈表之間的長度差k。然後,雙指針分別指向兩個鏈表的頭結點,長的鏈表的指針先走k步,然後再一起走,相遇的時候就是在第一個公共的結點。這樣沒有利用額外的空間。
PS:更簡潔牛皮的解法見->雙指針法,浪漫相遇 - 分隔鏈表
ListNode* partition(ListNode* head, int x) {
ListNode *ph = new ListNode(0); // 鏈表ph存放小於x的節點
ListNode *qh = new ListNode(0); // 鏈表qh存放大於等於x的節點
ListNode *h = head, *p = ph, *q = qh;
while(h!=nullptr){
if(h->val < x){
p->next = h;
p = p->next;
}else{
q->next = h;
q = q->next;
}
h = h->next;
}
p->next = qh->next;
q->next = nullptr; // 注意:鏈表qh末尾指向鏈表ph中的節點(形成環),會造成堆內存的二次釋放,因此需要指向空
return ph->next;
}
雙指針
相關練習
面試題21. 調整數組順序使奇數位於偶數前面 -> 頭尾雙指針p和q,向中間靠攏,p的下標始終小於q的下標。
vector<int> exchange(vector<int>& nums) {
// 頭尾雙指針
int i = 0, j = nums.size()-1;
while(i < j){
// 先移動頭部的指正,直到遇見偶數
if(nums[i]%2==0){
// 再移動尾部的指針,直到遇見奇數
while(i < j && nums[j]%2==0){
j--;
}
swap(nums[i],nums[j]);
i++;
j--;
}else{
i++;
}
}
return nums;
}
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> re;
set<int> st;
// 排序,可以去重複,並且有序數組可以用雙指針
sort(nums.begin(),nums.end());
for(int i = 0; i < nums.size(); i++){
// 去重複
if(i > 0 && nums[i]==nums[i-1]){
continue;
}
int target = -nums[i];
vector<int> v(3);
v[0] = nums[i];
int l = i+1, r = nums.size()-1;
while(l < r){
if(nums[l]+nums[r]==target){
v[1] = nums[l];
v[2] = nums[r];
re.push_back(v);
l++;
r--;
// 如果已經找到三元組,雙指針移動過程中需要去重複
while(l < r && nums[l]==nums[l-1]){
l++;
}
while(l < r && nums[r]==nums[r+1]){
r--;
}
}else if(nums[l]+nums[r]<target){
l++;
}else{
r--;
}
}
}
return re;
}