關于我的倉庫
- 這篇文章是我為面試準備的學習總結中的一篇
- 我將準備面試中找到的所有學習資料,寫的Demo,寫的博客都放在了這個倉庫里iOS-Engineer-Interview
- 歡迎star????
- 其中的博客在簡書,CSDN都有發(fā)布
- 博客中提到的相關的代碼Demo可以在倉庫里相應的文件夾里找到
前言
- 該系列為學習《數(shù)據結構與算法之美》的系列學習筆記
- 總結規(guī)律為一周一更,內容包括其中的重要知識帶你,以及課后題的解答
- 算法的學習學與刷題并進,希望能真正養(yǎng)成解算法題的思維
- LeetCode刷題倉庫:LeetCode-All-In
- 多說無益,你應該開始打代碼了
06講鏈表(上):如何實現(xiàn)LRU緩存淘汰算法
- 常見緩存淘汰策略:先進先出策略FIFO(First In,F(xiàn)irst Out)、最少使用策略LFU(Least Frequently Used)、最近最少使用策略LRU(Least Recently Used)。
- 刪除指定節(jié)點:由于刪除節(jié)點需要使用前一個節(jié)點的next指針,所以對于單鏈表,在執(zhí)行這樣的刪除,插入【前一個插入】的時候,都需要遍歷鏈表
- 而雙向鏈表對此就很有優(yōu)勢,包括對于有序鏈表查找,由于可以判定往后還是往前【此處存疑,怎么從中間開始?】
-
對于執(zhí)行較慢的程序,可以通過消耗更多的內存(空間換時間)來進行優(yōu)化;而消耗過多內存的程序,可以 通過消耗更多的時間(時間換空間)來降低內存的消耗。 BFB09C85-132D-417E-A24F-0D8F707D2F59
- 在實現(xiàn)上使用的是連續(xù)的內存空間,可以借助CPU的緩存機制,預讀數(shù)組中的數(shù)據,所以訪問效率更高。而 鏈表在內存中并不是連續(xù)存儲,所以對CPU緩存不友好,沒辦法有效預讀。CPU在從內存讀取數(shù)據的時候,會先把讀取到的數(shù)據加載到CPU的緩存中。而CPU每次從內存讀取數(shù)據并不是只讀取那個特定 要訪問的地址,而是讀取一個數(shù)據塊并保存到CPU緩存中,然后下次訪問內存數(shù)據的時候就會先從CPU緩存開始查找,如果找到就不需要再從內存中取。這樣就實現(xiàn)了比內存訪問速度更快的機制,也就是CPU緩存存在的意義:為了彌補內存訪問速度過慢與CPU執(zhí)行速度快之間的差異而引入。對于數(shù)組來說,存儲空間是連續(xù)的,所以在加載某個下標的時候可以把以后的幾個下標元素也加載到CPU緩存這樣執(zhí)行速度會 快于存儲空間不連續(xù)的鏈表存儲。
- 鏈表本身沒有大小的限制,天然地支持動態(tài)擴容,我覺得這也是它與數(shù)組最大的區(qū) 別。如果我們用ArrayList存儲了了1GB大小的數(shù)據,這個時候已經沒有空閑空間了,當我們再插入數(shù)據 的時候,ArrayList會申請一個1.5GB大小的存儲空間,并且把原來那1GB的數(shù)據拷?到新申請的空間上。聽起來是不是就很耗時?
實現(xiàn)LRU緩存淘汰算法
- 維護一個單向鏈表,越靠近尾節(jié)點越是遠古且不常用
- 插入數(shù)據時:
- 緩存已滿,直接刪除尾節(jié)點,將新數(shù)據插入頭節(jié)點
- 緩存不滿:
- 找到的該數(shù)據,刪除原來的,在頭節(jié)點加入
- 找不到,直接在頭節(jié)點加入
課后題:如何判斷一個字符串是否是回文字符串的問題,我想你應該聽過,我們今天的思題目就是基于這個問題的改造版本。如果字符串是通過單鏈表來存儲的,那該如何來判斷是一個回文串呢?你有什么好的解決思路呢?相應的時間空間復雜度又是多少呢?【LeetCode 234 回文鏈表】
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
bool isPalindrome(ListNode* head) {
if (head == NULL || head->next == NULL) {
return true;
}
ListNode *prev = NULL;
ListNode *slow = head;
ListNode *fast = head;
while (fast != NULL && fast->next != NULL) {
//pre:前一個
//slow:慢指針&&后一段鏈表的開頭
//fast:快指針,邊界工具人
//操作就是要把慢指針經過的逆置過來,所以讓pre作為前一個,slow作為當前工具人,使用next去記錄下一個,就是slow的下一位
//變換時,先把前面的逆過來,slow再往下走
fast = fast->next->next;
ListNode *next = slow->next;
slow->next = prev;
prev = slow;
slow = next;
}
if (fast != NULL) {
//!=NULL的這個情況就是奇數(shù)的情況,此時,slow工具人站在中間,pre就位,因此要讓slow往前一個
slow = slow->next;
}
while (slow != NULL) {
if (slow->val != prev->val) {
return false;
}
slow = slow->next;
prev = prev->next;
}
return true;
}
};
- 先通過快慢指針找到中點,此時鏈表分為兩部分,把前半部分逆置,然后比較兩列
07講鏈表(下):如何輕松寫出正確的鏈表代碼
鏈表六技
技巧一:理解指針或引用的含義
- 指針的作用就在于存儲對象的內存地址
- 將某個變量賦值給指針,實際上就是將這個變量的地址賦值給指針,或者反過來說,指針中存儲了這個變量的內存地址,指向了這個變量,通過指針就能找到這個變量。
- p->next=q:p結點中的next指針存儲了q結點的內存地址
- p->next=p->next->next:p結點的next指針存儲了p 結點的下下一個結點的內存地址。
技巧二:警惕指針丟失和內存泄漏
- 這一點其實就是由于鏈表單向性的特征,所以我們需要,我們一旦改變某一個節(jié)點的next指向,就會丟失掉原來那個
- 插入結點時,一定要注意操作的順序
技巧三:利用哨兵簡化實現(xiàn)難度
- 針對鏈表的插入,刪除操作,需要對插入第一個節(jié)點和刪除最后一個節(jié)點的情況進行特殊處理
- 一個使用哨兵的例子:
// 正常人寫的代碼
// 在數(shù)組a中,查找key,返回key所在的位置 // 其中,n表示數(shù)組a的?度
int find(char* a, int n, char key) {
// 邊界條件處理,如果a為空,或者n<=0,說明數(shù)組中沒有數(shù)據,就不用while循環(huán)比較了
if(a == null || n <= 0) {
return -1;
}
int i = 0;
// 這里有兩個比較操作:i<n和a[i]==key.
while (i < n) {
if (a[i] == key) {
return i;
}
++i;
}
return -1;
}
// 憨憨哨兵代碼
// 在數(shù)組a中,查找key,返回key所在的位置
// 其中,n表示數(shù)組a的?度
// 我舉2個例子,你可以拿例子走一下代碼 //a={4,2,3,5,9,6} n=6key=7 //a={4,2,3,5,9,6} n=6key=6
int find(char* a, int n, char key) {
if(a == null || n <= 0) {
return -1;
}
// 這里因為要將a[n-1]的值替換成key,所以要特殊處理這個值
if (a[n-1] == key) {
return n-1;
}
// 把a[n-1]的值臨時保存在變量tmp中,以便之后恢復。tmp=6。
// 之所以這樣做的目的是:希望find()代碼不要改變a數(shù)組中的內容 char tmp = a[n-1];
// 把key的值放到a[n-1]中,此時a = {4, 2, 3, 5, 9, 7}
a[n-1] = key;
int i = 0;
// while 循環(huán)比起代碼一,少了i<n這個比較操作
while (a[i] != key) {
++i;
}
// 恢復a[n-1]原來的值,此時a= {4, 2, 3, 5, 9, 6}
a[n-1] = tmp;
if (i == n-1) {
// 如果i == n-1說明,在0...n-2之間都沒有key,所以返回-1
return -1;
} else {
// 否則,返回i,就是等于key值的元素的下標
return i;
}
}
- 簡單來說,這么一整就可以使得少了i < n這個判斷,????
- 當然,這只是為了舉例說明哨兵的作用,你寫代碼的時候千萬不要寫第二段那樣的代碼,因為可讀性太差了。大部分情況下, 我們并不需要如此追求極致的性能。【誰寫誰憨說白了】
技巧四:重點留意邊界條件處理
- 如果鏈表為空時,代碼是否能正常工作?
- 如果鏈表僅包含一個結點時,代碼是否能正常工作?
- 如果鏈表只包含兩個結點時,代碼是否能正常工作?
- 代碼邏輯在處理頭結點和尾結點的時候,是否能正常工作?
技巧五:舉例畫圖,輔助思考
page7image39834288.jpg
技巧六:多寫多練,沒有捷徑
單鏈表反轉【LeetCode 206 反轉鏈表】
參考文章
題解
我的題解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode *pre = NULL;
ListNode *next = NULL;
ListNode *pNode = head;
while (pNode != NULL) {
next = pNode->next;
pNode->next = pre;
pre = pNode;
pNode = next;
}
return pre;
}
};
參考題解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == NULL || head->next == NULL) return head;
ListNode *p = reverseList(head->next);
head->next->next = head;
head->next = NULL;
return p;
}
};
反思
- 遞歸騷斷腿,實在難悟
- 妙
鏈表中環(huán)的檢測【LeetCode 141 環(huán)形鏈表】
參考文章
題解
我的題解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
bool hasCycle(ListNode *head) {
if (head == NULL || head->next == NULL) {
return false;
}
ListNode *slow = head;
ListNode *fast = head;
while (fast->next != NULL && fast->next->next != NULL) {
slow = slow->next;
fast = fast->next->next;
if (slow->val == fast->val) {
return true;
}
}
return false;
}
};
參考題解
public boolean hasCycle(ListNode head) {
Set<ListNode> nodesSeen = new HashSet<>();
while (head != null) {
if (nodesSeen.contains(head)) {
return true;
} else {
nodesSeen.add(head);
}
head = head.next;
}
return false;
}
反思
- 要想鏈表玩的溜,兩根指針少不了
兩個有序的鏈表合并【LeetCode 21 合并兩個有序鏈表】
參考文章
題解
我的題解
//這誰會呢
參考題解
//遞歸
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
if (l1 == NULL) {
//這一步不僅是一開始的判空,更是后面當一個鏈表已經遍歷到結束了
//就一直使用另一個里面的節(jié)點
return l2;
}
else if (l2 == NULL) {
return l1;
}
else if (l1->val < l2->val) {
//由于從小到大,因此
l1->next = mergeTwoLists(l1->next, l2);
return l1;
}
else {
l2->next = mergeTwoLists(l1, l2->next);
return l2;
}
}
};
//迭代
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
//這等于就是添加哨兵的思想,使得不用考慮開頭的數(shù)字,妙啊
ListNode *prehead = new ListNode(-1);
ListNode *prev = prehead;
while (l1 != NULL && l2 != NULL) {
if (l1->val <= l2->val) {
//把l1連上
prev->next = l1;
//接下來要做的僅僅是把l1指針指向下一個
l1 = l1->next;
} else {
prev->next = l2;
l2 = l2->next;
}
prev = prev->next;
}
prev->next = l1 == NULL ? l2 :l1;
return prehead->next;
}
};
反思
- 還是想太復雜了,其實只要進行一下鏈接就行,不用考慮辣么多
刪除鏈表倒數(shù)第n個結點【LeetCode 19 刪除鏈表的倒數(shù)第N個節(jié)點】
參考文章
題解
我的題解
//這誰會呢
參考題解
//兩次遍歷算法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode *dummy = new ListNode(0);
dummy->next = head;
int length = 0;
ListNode *first = head;
while (first != NULL) {
length++;
first = first->next;
}
length -= n;
first = dummy;
while (length > 0) {
length--;
first = first->next;
}
first->next = first->next->next;
return dummy->next;
}
};
//一次遍歷法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode *dummy = new ListNode(0);
dummy->next = head;
ListNode *first = dummy;
ListNode *second = dummy;
for (int i = 1; i <= n + 1; i++) {
first = first->next;
}
while (first != NULL) {
first = first->next;
second = second->next;
}
second->next = second->next->next;
return dummy->next;
}
};
反思
- 這又啥難的,驚了
求鏈表的中間結點【LeetCode 876 鏈表的中間結點】
參考文章
題解
我的題解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* middleNode(ListNode* head) {
ListNode *slowNode = head;
ListNode *fastNode = head;
while (fastNode->next != NULL && fastNode->next->next != NULL) {
fastNode = fastNode->next->next;
slowNode = slowNode->next;
}
if (fastNode->next != NULL) {
slowNode = slowNode->next;
}
return slowNode;
}
};
參考題解
//這要啥參考題解呢
反思
- 聯(lián)動題目,回文鏈表
課后題:今天我們講到用哨兵來簡化編碼實現(xiàn),你是否還能夠想到其他場景,利用哨兵可以大大地簡化編碼難度?
- 舉例:iOS開發(fā)中,對于autoreleasing Pool那一塊,在每個autoreleasingPool之間是靠哨兵對象nil隔開的
- 具體看我這篇博客從RunTime源碼回看autoreleasepool
08講棧:如何實現(xiàn)瀏覽器的前進和后退功能
- 后進者先出,先進者后出,這就是典型的“棧”結構
- 當某個數(shù)據集合只涉及在一端插入和刪除數(shù)據,并且滿足后進先出、先進后出的特性,我們就應該首選“棧”這種數(shù)據結構。
順序棧的基本實現(xiàn)
EF09A73A-1691-4D2F-9818-FEBADC0B04A9
- 存儲數(shù)據需要一個長度為n的數(shù)組并不是說空間復雜度就是O(n),因為這n個空間是必須的,空間復雜度應該是算法所需要的額外存儲空間
支持動態(tài)擴容的順序棧
page4image7848160.png
- 最好情況時間復雜度是O(1),最壞情況時間復雜度是O(n),使用攤還分析法分析
page5image7775648.png
- 總共涉及了K個數(shù)據的搬移,以及K次simple-push操作。將K個數(shù)據搬移均攤到K次入棧 操作,那每個入棧操作只需要一個數(shù)據搬移和一個simple-push操作。以此類推,入棧操作的均攤時間復雜度就為O(1)。
- 因為在大部分情況下,入 棧操作的時間復雜度O都是O(1),只有在個別時刻才會退化為O(n),所以把耗時多的入棧操作的時間均攤到其他入棧操作上, 平均情況下的耗時就接近O(1)。
典型使用
棧在函數(shù)調用中的應用【函數(shù)調用棧】
- 作系統(tǒng)給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構,用來存儲函數(shù)調用時的臨時變 量。每進入一個函數(shù),就會將臨時變量作為一個棧幀入棧,當被調用函數(shù)執(zhí)行完成,返回之后,將這個函數(shù)對應的棧幀出棧。
- 【程序員的自我修養(yǎng)p.287】:每一次函數(shù)的調用,都會在調用棧(call stack)上維護一個獨立的棧幀(stack frame).每個獨立的棧幀一般包括函數(shù)的返回地址和參數(shù), 臨時變量: 包括函數(shù)的非靜態(tài)局部變量以及編譯器自動生成的其他臨時變量, 保存的上下文【包括在函數(shù)調用前后需要不變的寄存器】
//
// main.cpp
// stack-Test
//
// Created by Kevin.J on 2019/9/11.
// Copyright ? 2019 姜凱文. All rights reserved.
//
#include <iostream>
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
int main(int argc, const char * argv[]) {
// insert code here...
std::cout << "Hello, World!\n";
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf("%d", res);
return 0;
}
- 我們在add中打上一個斷電,通過Xcode自帶的LLDB來查看下棧幀
//輸入 thread backtrace
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
* frame #0: 0x00000001000011d1 stack-Test`add(x=3, y=5) at main.cpp:13:11
frame #1: 0x0000000100001234 stack-Test`main(argc=1, argv=0x00007ffeefbff5f0) at main.cpp:24:11
frame #2: 0x00007fff6bf9f3d5 libdyld.dylib`start + 1
//當前頂上的就是add
page6image7649056.png
棧在表達式求值中的應用
- 編譯器就是通過兩個棧來實現(xiàn)的。其中一個保存操作數(shù)的棧,另一個是保存運算符的棧。我們從左向右遍歷表達式, 當遇到數(shù)字,我們就直接壓入操作數(shù)棧;當遇到運算符,就與運算符棧的棧頂元素進行比較。
page7image8074704.png
- 這里還是要借助下西郵的數(shù)據結構課來說明比較妥當表達式求值問題
- 數(shù)據優(yōu)先級:
0301B223-3494-44BE-B268-394991C9125E
- 這里的圖畫的不清楚,實際上在計算完8 * 5后得到40,會把40這個數(shù)重新壓入數(shù)據棧,接下來也不會急著讀取下一位,而是繼續(xù)比較,發(fā)現(xiàn)減號優(yōu)先級大于棧頂?shù)募犹枺蔷驮谔幚砑臃ǎ@時在比較遇到的是#,優(yōu)先級比減號大了,此時才會把減號壓入
棧在括號匹配中的應用
- 這個沒什么好說的,就是遇到做括號入棧,遇到右括號出棧
通過棧實現(xiàn)瀏覽器的前進和后退
- 我們使用兩個棧,X和Y,我們把首次瀏覽的?面依次壓入棧X,當點擊后退按鈕時,再依次從棧X中出棧,并將出棧的數(shù)據依 次放入棧Y。當我們點擊前進按鈕時,我們依次從棧Y中取出數(shù)據,放入棧X中。當棧X中沒有數(shù)據時,那就說明沒有?面可以 繼續(xù)后退瀏覽了。當棧Y中沒有數(shù)據,那就說明沒有?面可以點擊前進按鈕瀏覽了。
page8image7821888.png
- 當你通過瀏覽器的后退按鈕,從?面c后退到?面a之后,我們就依次把c和b從棧X中彈出,并且依次放入到棧Y。這個時候, 兩個棧的數(shù)據就是這個樣子:
page8image7823680.png
- 這個時候你又想看?面b,于是你又點擊前進按鈕回到b?面,我們就把b再從棧Y中出棧,放入棧X中。此時兩個棧的數(shù)據是這 個樣子:
page9image7397920.png
課后題
我們在講棧的應用時,講到用函數(shù)調用棧來保存臨時變量,為什么函數(shù)調用要用“棧”來保存臨時變量呢?用其他數(shù)據結構 不行嗎?
-
其實,我們不一定非要用棧來保存臨時變量,只不過如果這個函數(shù)調用符合后進先出的特性,用棧這種數(shù)據結構來實現(xiàn),是最順理成章的選擇。
從調用函數(shù)進入被調用函數(shù),對于數(shù)據來說,變化的是什么呢?是作用域。所以根本上,只要能保證每進入一個新的函數(shù),都是一個新的作用域就可以。而要實現(xiàn)這個,用棧就非常方便。在進入被調用函數(shù)的時候,分配一段棧空間給這個函數(shù)的變量,在函數(shù)結束的時候,將棧頂復位,正好回到調用函數(shù)的作用域內。
我們都知道,JVM內存管理中有個“堆棧”的概念。棧內存用來存儲局部變量和方法調用,堆內存用來存儲Java中的對象。 那JVM里面的“棧”跟我們這里說的“棧”是不是一回事呢?如果不是,那它為什么又叫作“棧”呢?
- JVM里面的棧和我們這里說的是一回事,被稱為方法棧。和前面函數(shù)調用的作用是一致的,用來存儲方法中的局部變量。
作業(yè)
- leetcode上關于棧的題目大家可以先做20,155,232,844,224,682,496
- 我就不貼過來了,在我的LeetCode倉庫里有
09講隊列:隊列在線程池等有限資源池中的應用
- 這一節(jié)沒啥新東西,完全是數(shù)據結構課上的知識,咱就總結總結過去
如何理解“隊列”?
- 隊列跟棧非常相似,支持的操作也很有限,最基本的操作也是兩個:入隊enqueue(),放一個數(shù)據到隊列尾部;出隊dequeue(),從隊列頭部取一個元素。
img
- 隊列的應用也非常廣泛,特別是一些具有某些額外特性的隊列,比如循環(huán)隊列、阻塞隊列、并發(fā)隊列。它們在很多偏底層系統(tǒng)、框架、中間件的開發(fā)中,起著關鍵性的作用。比如高性能隊列Disruptor、Linux環(huán)形緩存,都用到了循環(huán)并發(fā)隊列;Java concurrent并發(fā)包利用ArrayBlockingQueue來實現(xiàn)公平鎖等。
順序隊列和鏈式隊列
- 用數(shù)組實現(xiàn)的隊列叫作順序隊列,用鏈表實現(xiàn)的隊列叫作鏈式隊列。
順序隊列
// 順序隊列的實現(xiàn)
// 用數(shù)組實現(xiàn)的隊列
public class ArrayQueue {
// 數(shù)組:items,數(shù)組大小:n
private String[] items;
private int n = 0;
// head表示隊頭下標,tail表示隊尾下標
private int head = 0;
private int tail = 0;
// 申請一個大小為capacity的數(shù)組
public ArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入隊
public boolean enqueue(String item) {
// 如果tail == n 表示隊列已經滿了
if (tail == n) return false;
items[tail] = item;
++tail;
return true;
}
// 出隊
public String dequeue() {
// 如果head == tail 表示隊列為空
if (head == tail) return null;
// 為了讓其他語言的同學看的更加明確,把--操作放到單獨一行來寫了
String ret = items[head];
++head;
return ret;
}
}
- 當a、b、c、d依次入隊之后,隊列中的head指針指向下標為0的位置,tail指針指向下標為4的位置。
img
- 當我們調用兩次出隊操作之后,隊列中head指針指向下標為2的位置,tail指針仍然指向下標為4的位置。
img
- 解決順序隊列浪費空間的缺點:數(shù)據搬移【改寫入隊函數(shù)】
// 入隊操作,將item放入隊尾
public boolean enqueue(String item) {
// tail == n表示隊列末尾沒有空間了
if (tail == n) {
// tail ==n && head==0,表示整個隊列都占滿了
if (head == 0) return false;
// 數(shù)據搬移
for (int i = head; i < tail; ++i) {
items[i-head] = items[i];
}
// 搬移完之后重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
- 從代碼中我們看到,當隊列的tail指針移動到數(shù)組的最右邊后,如果有新的數(shù)據入隊,我們可以將head到tail之間的數(shù)據,整體搬移到數(shù)組中0到tail-head的位置。
img
- 這里入隊的時間復雜度,使用均攤計算法,肯定還是O(1)
鏈式隊列
img
循環(huán)隊列
- 循環(huán)隊列,顧名思義,它長得像一個環(huán)。原本數(shù)組是有頭有尾的,是一條直線。現(xiàn)在我們把首尾相連,扳成了一個環(huán)。我畫了一張圖,你可以直觀地感受一下。
img
img
- 隊列空的條件還是head = tail,滿的條件是(tail+1)%n=head
img
- tail=3,head=4,n=8,所以總結一下規(guī)律就是:(3+1)%8=4
- 此時tail不存放數(shù)據,浪費一個空間
public class CircularQueue {
// 數(shù)組:items,數(shù)組大小:n
private String[] items;
private int n = 0;
// head表示隊頭下標,tail表示隊尾下標
private int head = 0;
private int tail = 0;
// 申請一個大小為capacity的數(shù)組
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入隊
public boolean enqueue(String item) {
// 隊列滿了
if ((tail + 1) % n == head) return false;
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出隊
public String dequeue() {
// 如果head == tail 表示隊列為空
if (head == tail) return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
阻塞隊列和并發(fā)隊列
- 阻塞隊列其實就是在隊列基礎上增加了阻塞操作。簡單來說,就是在隊列為空的時候,從隊頭取數(shù)據會被阻塞。因為此時還沒有數(shù)據可取,直到隊列中有了數(shù)據才能返回;如果隊列已經滿了,那么插入數(shù)據的操作就會被阻塞,直到隊列中有空閑位置后再插入數(shù)據,然后再返回。
img
- 這就涉及到“生產者-消費者模型”【在實際的軟件開發(fā)過程中,經常會碰到如下場景:某個模塊負責產生數(shù)據,這些數(shù)據由另一個模塊來負責處理(此處的模塊是廣義的,可以是類、函數(shù)、線程、進程等)。產生數(shù)據的模塊,就形象地稱為生產者;而處理數(shù)據的模塊,就稱為消費者】
- 這種基于阻塞隊列實現(xiàn)的“生產者-消費者模型”,可以有效地協(xié)調生產和消費的速度。當“生產者”生產數(shù)據的速度過快,“消費者”來不及消費時,存儲數(shù)據的隊列很快就會滿了。這個時候,生產者就阻塞等待,直到“消費者”消費了數(shù)據,“生產者”才會被喚醒繼續(xù)“生產”。
img
- 線程安全的隊列我們叫作并發(fā)隊列。最簡單直接的實現(xiàn)方式是直接在enqueue()、dequeue()方法上加鎖,但是鎖粒度大并發(fā)度會比較低,同一時刻僅允許一個存或者取操作。實際上,基于數(shù)組的循環(huán)隊列,利用CAS原子操作,可以實現(xiàn)非常高效的并發(fā)隊列。這也是循環(huán)隊列比鏈式隊列應用更加廣泛的原因。
線程池沒有空閑線程時,新的任務請求線程資源時,線程池該如何處理?各種處理策略又是如何實現(xiàn)的呢?
- 我們一般有兩種處理策略。第一種是非阻塞的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排隊,等到有空閑線程時,取出排隊的請求繼續(xù)處理。
- 基于鏈表的實現(xiàn)方式,可以實現(xiàn)一個支持無限排隊的無界隊列(unbounded queue),但是可能會導致過多的請求排隊等待,請求處理的響應時間過長。所以,針對響應時間比較敏感的系統(tǒng),基于鏈表實現(xiàn)的無限排隊的線程池是不合適的。
- 而基于數(shù)組實現(xiàn)的有界隊列(bounded queue),隊列的大小有限,所以線程池中排隊的請求超過隊列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統(tǒng)來說,就相對更加合理。不過,設置一個合理的隊列大小,也是非常有講究的。隊列太大導致等待的請求太多,隊列太小會導致無法充分利用系統(tǒng)資源、發(fā)揮最大性能。
- 實際上,對于大部分資源有限的場景,當沒有空閑資源時,基本上都可以通過“隊列”這種數(shù)據結構來實現(xiàn)請求排隊。
課后題
除了線程池這種池結構會用到隊列排隊請求,你還知道有哪些類似的池結構或者場景中會用到隊列的排隊請求呢?
- iOS:GCD就是通過隊列的應用進行的線程管理
- 分布式應用中的消息隊列,也是一種隊列結構
今天講到并發(fā)隊列,關于如何實現(xiàn)無鎖并發(fā)隊列,網上有非常多的討論。對這個問題,你怎么看呢?
- 考慮使用CAS實現(xiàn)無鎖隊列,則在入隊前,獲取tail位置,入隊時比較tail是否發(fā)生變化,如果否,則允許入隊,反之,本次入隊失敗。出隊則是獲取head位置,進行cas。
作業(yè):循環(huán)隊列【LeetCode 622. 設計循環(huán)隊列】
- 我感覺我這代碼沒有任何問題呀,就是過不了
class MyCircularQueue {
public:
/** Initialize your data structure here. Set the size of the queue to be k. */
MyCircularQueue(int k) {
size = k;
head = 0;
tail = 0;
data.resize(k);
}
/** Insert an element into the circular queue. Return true if the operation is successful. */
bool enQueue(int value) {
if ((tail + 1) % size == head) {
return false;
}
data[tail] = value;
tail = (tail + 1) % size;
return true;
}
/** Delete an element from the circular queue. Return true if the operation is successful. */
bool deQueue() {
if (head == tail) {
return false;
}
head = (head + 1) % size;
return true;
}
/** Get the front item from the queue. */
int Front() {
if (head == tail) {
return -1;
}
return data[head];
}
/** Get the last item from the queue. */
int Rear() {
if (head == tail) {
return -1;
}
return data[(tail - 1 + size) % size];
}
/** Checks whether the circular queue is empty or not. */
bool isEmpty() {
if (head == tail) {
return true;
}
return false;
}
/** Checks whether the circular queue is full or not. */
bool isFull() {
if ((tail + 1) % size == head) {
return true;
}
return false;
}
private:
vector<int> data;
int size, head, tail;
};
/**
* Your MyCircularQueue object will be instantiated and called as such:
* MyCircularQueue* obj = new MyCircularQueue(k);
* bool param_1 = obj->enQueue(value);
* bool param_2 = obj->deQueue();
* int param_3 = obj->Front();
* int param_4 = obj->Rear();
* bool param_5 = obj->isEmpty();
* bool param_6 = obj->isFull();
*/
10講遞歸:如何用三行代碼找到“最終推薦人”
如何理解“遞歸”?
- 去的過程要將遞,來的過程叫歸
- 關鍵在與找出遞歸推導式
遞歸需要滿足的三個條件
- 一個問題的解可以分解為幾個子問題的解
- 這個問題與分解之后的子問題,除了數(shù)據規(guī)模不同,求解思路完全一樣
- 存在遞歸終止條件
- 這里我們用一個Leetcode上的簡單遞歸題來總結下:
Leetcode 687. 最長同值路徑
給定一個二叉樹,找到最長的路徑,這個路徑中的每個節(jié)點具有相同值。 這條路徑可以經過也可以不經過根節(jié)點。
注意:兩個節(jié)點之間的路徑長度由它們之間的邊數(shù)表示。
示例 1:
輸入:
5
/ \
4 5
/ \ \
1 1 5
輸出:
2
示例 2:
輸入:
1
/ \
4 5
/ \ \
4 4 5
輸出:
2
注意: 給定的二叉樹不超過10000個結點。 樹的高度不超過1000。
分析:
- 一個問題的解可以分解為幾個子問題的解
- 首先對于樹形結構,我們肯定是從根結點往下遍歷,對于每個結點,我們的長度本質是對其在左結點以及右結點路徑和上在+1
- 當然還是很難懂,怪不得說遞歸是最難的了,蝦米玩意
- 這個問題與分解之后的子問題,除了數(shù)據規(guī)模不同,求解思路完全一樣
- 這個在第一點也說明的很清楚了
- 存在遞歸終止條件
- 這個很簡單,對與樹形結構,在碰到結點為NULL肯定終止了
- 好難,感覺自己做還是整不出來
- 只能理解下
代碼:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
int ans;
int longestUnivaluePath(TreeNode* root) {
ans = 0;
arrowLength(root);
return ans;
}
int arrowLength(TreeNode* node) {
if (node == NULL) {
return 0;
}
int left = arrowLength(node->left);
int right = arrowLength(node->right);
int arrowLeft = 0, arrowRight = 0;
if (node->left != NULL && node->left->val == node->val) {
arrowLeft += left + 1;
}
if (node->right != NULL && node->right->val == node->val) {
arrowRight += right + 1;
}
ans = max(ans, arrowLeft + arrowRight);
return max(arrowLeft, arrowRight);
}
};
遞歸心法
- 寫遞歸代碼的關鍵就是找到如何將大問題分解為小問題的規(guī)律,并且基于此寫出遞推公式,然后再推敲終止條件,最后將遞推公式和終止條件翻譯成代碼
- 下面這一點我覺得說的很有道理,對于遞歸,關鍵在于不要糾結,想著吧所有代碼鋪開去思考整個過程,比如說對于上面這道題,我們在計算f(n)的時候,就假設n-1結果已經出來,直接用就完了,只思考一次的調用去寫我們的遞歸代碼,可能是個比較好的習慣
- 編寫遞歸代碼的關鍵是,只要遇到遞歸,我們就把它抽象成一個遞推公式,不用想一層層的調用關系,不要試圖用人腦去分解遞歸的每個步驟
警惕棧溢出
- 這一段在講了函數(shù)調用棧之后應該已經很好理解了,其實就是說由于在遞歸時,我們時函數(shù)套函數(shù),導致函數(shù)調用棧被占用很多內存,會出現(xiàn)溢出風險
- 這里通常做法是設置一下遞歸深度最大值
遞歸代碼警惕重復計算
img
- 我們看到為了計算一個f(6),我們計算了好幾次f(3),這就是重復計算
- 解決方法一般是使用一個哈希表來存儲值,下次用到的時候直接查詢使用就行
- 另外由于遞歸調用一次就會在內存棧中保存一次現(xiàn)場數(shù)據所以空間復雜度基本上不會是O(1)
迭代法與遞歸相互轉化
- 基本上所有遞歸代碼都可以轉化,由于遞歸的本質上是使用棧來實現(xiàn)的,只是他的使用是依靠系統(tǒng)做的,我們無法感知到
- 所以可以使用迭代法,手動實現(xiàn)出入棧
開篇解答:如何找到“最終推薦人”?
- 給定一個用戶ID,如何查找這個用戶的“最終推薦人”?
img
long findRootReferrerId(long actorId) {
Long referrerId = select referrer_id from [table] where actor_id = actorId;
if (referrerId == null) return actorId;
return findRootReferrerId(referrerId);
}
- 上述代碼的漏洞:
- 如果遞歸很深,可能會有堆棧溢出的問題
- 如果數(shù)據庫里存在臟數(shù)據,我們還需要處理由此產生的無限遞歸問題。比如demo環(huán)境下數(shù)據庫中,測試工程師為了方便測試,會人為地插入一些數(shù)據,就會出現(xiàn)臟數(shù)據。如果A的推薦人是B,B的推薦人是C,C的推薦人是A,這樣就會發(fā)生死循環(huán)
課后題:我們平時調試代碼喜歡使用IDE的單步跟蹤功能,像規(guī)模比較大、遞歸層次很深的遞歸代碼,幾乎無法使用這種調試方式。對于遞歸代碼,你有什么好的調試方法呢?
- 打印日志發(fā)現(xiàn),遞歸值;結合條件斷點進行調試