算法,一門既不容易入門,也不容易精通的學問。java
對於筆者來講算法算是我程序員生涯很不擅長的技能之一了,自從互聯網界招人進入平靜期後,愈來愈多的大廠在社招的時候不但會考驗面試者的工做所用到的技能,並且會用算法題來考驗面試者的邏輯思惟能力和基本數據結構的掌握能力。這也就讓想要社招進入大廠的部分同窗有了一些望而卻步的心理,畢竟工做中大部分時間在與UI層面的邏輯打交道,數據處理方面即便以前在學校中掌握的還還不錯,幾年的 CV 生活,估計也忘的差很少了。node
可是做爲一條有夢想的鹹魚,咱們仍是要重拾這些知識的。以前寫過一篇 搞懂單鏈表常見面試題,有興趣的同窗能夠跳轉瀏覽。今天筆者將會挑選幾道棧與隊列和位運算的相關題目來回顧下相關算法的基本知識。ios
棧與隊列分別是兩種數據結構,不一樣語言對於棧和隊列有着不一樣的聲明,在 java 中 Stack 類是繼承自 Vector 集合的子類,Queue 則是以接口形式存在,經常使用的其實現類是 LinkedList 這個雙向隊列。在C++的標準模版庫也是有這兩個數據結構定義的具體類的。程序員
棧數據結構的特色是 FILO(first in last out) 即先進後出,隊列則是 FIFO(first in first out)即先進先出。相信棧與隊列的數據結構的基本特色你們也是熟記於胸了。下面就帶你們看一道面試題來帶你們看下這二者在面試題中的形式。面試
題目難度兩顆星,主要考察了對於棧和隊列的數據結構特色。算法
前文介紹了,對於一個棧來講遵循 pop 操做時從棧的頂部取一個元素,對於隊列來講 poll 操做時從隊列隊首取一個元素。因此該題翻譯過來就是使用兩個棧定義一種先放入的元素,最早被取出的數據結構。編程
此題應考慮到兩種狀況,首先最簡單的一種狀況,假設有 1,2,3,4,5 個元素依次進入自定義的隊列,再依次取出。因爲是進棧操做都進行完了才進行出棧操做,因此咱們只需在元素出隊時,將進棧元素倒入另外一個空棧中便可。示意圖以下:數組
再一種狀況是,若是 add poll 操做是交替進行的,那麼如何保證數據結構先進先出的定義呢?好比先放入 1,2,3而後要進行一次取出操做取出 1,隨後在進行 add 操做放入4,5,這種狀況下如何操做兩個棧,才能保證以後再取出的時候元素爲 2,3,4,5 順序?實際上咱們只須要保證一下兩點就能夠:數據結構
爲了方便理解能夠看下邊這幅圖:函數
明白了須要注意的點後就是該寫代碼的時候了,須要注意的點在圖中已經用紅色字體標出了,也就是在存入元素一直往 StackA 中存,取元素是從 StackB 中取,但要要注意的是取的時候須要保證 StackB 爲空的時候要先將 StackA 中元素一次性壓如 StackB 中,在進行從 StackB 中取的操做。
public static class TwoStackQueue<E>{ private Stack<E> stackA; private Stack<E> stackB; public TwoStackQueue() { stackA = new Stack<>(); stackB = new Stack<>(); } /** * 添加元素邏輯 * @param e 要添加的元素 * @return 這裏只是遵循 Queue 的習慣,這裏簡單處理返回 true 便可 */ public boolean add(E e){ stackA.push(e); return true; } /** * 去除元素的時候須要判斷兩個地方,StackA & StackB 是否都爲空 * StackB 爲空的時候講StackA中的元素所有依次壓入 StackB * @return 返回隊列中的元素 若是隊列爲空返回 null */ public E poll(){ //若是隊列中沒有元素則直接返回空,也能夠選擇拋出異常 if (stackB.isEmpty() && stackA.isEmpty()){ return null; } if (stackB.isEmpty()){ while (!stackA.isEmpty()){ stackB.add(stackA.pop()); } } return stackB.pop(); } /** * peek 操做不取出元素,只返回隊列頭部的元素值 * @return 隊列頭部的元素值 */ public E peek(){ //若是隊列中沒有元素則直接返回空,也能夠選擇拋出異常 if (stackB.isEmpty() && stackA.isEmpty()){ return null; } if (stackB.isEmpty()){ while (!stackA.isEmpty()){ stackB.add(stackA.pop()); } } return stackB.peek(); } } 複製代碼
對應的 C++ 解法:
#include <stdio.h> #include <stack> using namespace std; template <typename T> class TStackQueue { public: void add(T t); T poll(); private: stack<T> stackA; stack<T> stackB; }; template <typename T> void TStackQueue<T>::add(T node) { stackA.push(node); } template<typename T> T TStackQueue<T>::poll(){ if (stackB.empty() && stackA.empty()) { return NULL; } if (stackB.empty()) { while (!stackA.empty()) { stackB.push(stackA.top()); stackA.pop(); } } T node = stackB.top(); stackB.pop(); return node; } 複製代碼
上道題咱們完成了兩個棧實現一個隊列的題目,那麼兩個隊列實現一個棧又該注意哪些呢?
首先隊列是先進先出,咱們能夠發現隊列不管怎麼倒,咱們不能逆序一個隊列。既然不能套用上題的解法,那麼就得另謀出路,可是能夠預知無非就是兩個隊列進行交替的入隊出隊操做,那麼惟一要作的就是判斷目前出隊的值是不是按照放入元素順序中最後放入的元素。 依舊畫圖舉例
這裏咱們只看首次取出操做,那麼須要注意一點, 如何判斷哪一次取出操做後 QueueA 爲空?
事實上做爲 Queue
做爲容器,咱們能夠經過事先定義好的方法 queue.size()
去判斷一個隊列中元素的個數,有人可能說這是犯規,其實不是的。題目中給出是讓你用隊列去實現,那麼隊列中公共 API 都是你能夠用的。因此能夠想象出下面的僞代碼:
//若是 queueA 的大小不爲 0 則循環取出元素 while(queueA.size() > 0){ //被取出的元素 int result = queueA.poll(); // 這裏注意咱們取出元素後再去判斷一次,隊列是否爲空,若是爲空表明是最後一個元素 if(queueA.size() != 0){ queueB.add(result) }else{ return result; } } 複製代碼
上文咱們只是說了一次取出操做,那麼一次取出操做後,再次放入元素應該怎麼放,咱們彷佛又遇到了困難。
與上題不一樣的是,咱們應該先思考若是連續兩次取出應該怎麼操做,上面一次取出後 QueueA
空了,因此咱們若是按照相同的思路將 B 中的元素倒入 A 中,那麼將會獲得 3 ,這看起來沒什麼問題。那麼若是下一步進行的 push 操做,那麼應該放入 QueueA
仍是 QueueB
中才能保證元素先進後出的規則呢,很容易想到是放入 B 中。 那麼總結一下操做要點:
接上圖咱們開看第一次取出操做後可能的兩種操做狀況:
思路屢清楚了,那麼時候寫代碼了:
public static class TwoQueueStack<E> { private Queue<E> queueA; private Queue<E> queueB; public TwoQueueStack() { queueA = new LinkedList<>(); queueB = new LinkedList<>(); } /** * 選一個非空的隊列入隊 * * @param e * @return */ public E push(E e) { if (queueA.size() != 0) { System.out.println("從 queueA 入隊 " + e); queueA.add(e); } else if (queueB.size() != 0) { System.out.println("從 queueB 入隊 " + e); queueB.add(e); } else { System.out.println("從 queueA 入隊 " + e); queueA.add(e); } return e; } public E pop() { if (queueA.size() == 0 && queueB.size() == 0) { return null; } E result = null; if (queueA.size() != 0) { while (queueA.size() > 0) { result = queueA.poll(); if (queueA.size() != 0) { System.out.println("從 queueA 出隊 並 queueB 入隊 " + result); queueB.add(result); } } System.out.println("從 queueA 出隊 " + result); } else { while (queueB.size() > 0) { result = queueB.poll(); if (queueB.size() != 0) { System.out.println("從 queueB 出隊 並 queueA 入隊 " + result); queueA.add(result); } } System.out.println("從 queueB 出隊" + result); } return result; } } 複製代碼
爲了方便你們理解我將文章進行下測試:
public static void main(String[] args) { TwoQueueStack<Integer> queueStack = new TwoQueueStack<>(); queueStack.push(1); queueStack.push(2); queueStack.push(3); queueStack.push(4); queueStack.pop(); queueStack.pop(); queueStack.push(5); queueStack.pop(); } 複製代碼
結果爲下面所示,看上去咱們的代碼是對的
從 queueA 入隊 1 從 queueA 入隊 2 從 queueA 入隊 3 從 queueA 入隊 4 從 queueA 出隊 並 queueB 入隊 1 從 queueA 出隊 並 queueB 入隊 2 從 queueA 出隊 並 queueB 入隊 3 從 queueA 出隊 4 從 queueB 出隊 並 queueA 入隊 1 從 queueB 出隊 並 queueA 入隊 2 從 queueB 出隊3 從 queueA 入隊 5 從 queueA 出隊 並 queueB 入隊 1 從 queueA 出隊 並 queueB 入隊 2 從 queueA 出隊 5 複製代碼
付C++ 代碼實現:
#include <stdio.h> #include<queue> #include<exception> using namespace std; template <typename T> class TQueueStack { public: void push(const T& node); T pop(); private: queue<T> queueA; queue<T> queueB; }; // 插入元素 template<typename T> void TQueueStack<T>::push(const T& node) { //插入到非空隊列,若是均爲空則插入到queueB中 if (queueA.size() == 0) { queueB.push(node); } else { queueA.push(node); } } template<typename T> T TQueueStack<T>::pop() { if (queueA.size() == 0 && queueB.size() == 0) { return NULL; } T head; if (queueA.size() > 0) { while (queueA.size()>1) { //queueA中的元素依次刪除,並插入到queueB中,其中queueA刪除最後一個元素 //至關於從棧中彈出隊尾元素 T& data = queueA.front(); queueA.pop(); queueB.push(data); } head = queueA.front(); queueA.pop(); } else { while (queueB.size()>1) { //queueB 中的元素依次刪除,並插入到 queueA 中,其中 queueB 刪除最後一個元素 //至關於從棧中彈出隊尾元素 T& data = queueB.front(); queueB.pop(); queueA.push(data); } head = queueB.front(); queueB.pop(); } return head; } 複製代碼
經歷了上兩道題,你們是否是感受對棧和隊列更反感,哦不對是更瞭解了呢。(額~ 一不當心把實話說出來了)。下面咱們來看第二道題這是一個有關於出棧順序的判斷的題目:
題目: 輸入兩個整數數組,第一個表示一個棧的壓入序列,請寫一個函數,判斷第二個數組是否爲該棧的出棧序列,假設數組中的全部數字均不相等。例如序列 1,2,3,4,5 是某棧的壓入順序,序列 4,5,3,2,1 是該壓棧序列對應的一個彈出序列,但 4,3,5,1,2 就不多是該壓棧序列的彈出序列。
看到這道題咱們首先應該去理解題目中的怎麼去判斷是否符合出棧順序,其實題目想要表達的意思是若是以數組 A 的方式進棧但並非一次所有進棧,好比咱們先進棧1,2,3,4 而後出棧 4,而後進棧 5,而後在出棧 5,3,2,1。 那麼什麼狀況下是不可能知足的出棧順序呢?好比 1,確定是比 2 先進棧的,因此 2確定比 1先出棧。因此解題的關鍵就在於,如何判斷數組2 中的元素,是按數組1 中某種進棧順序操做的出棧序列。
思路是若是咱們在進棧的同時維護一個出棧角標,若是棧頂元素等於 popA[popIndex]
的時候,將角標加一,並出棧該元素,並繼續判斷下一個棧頂元素,若是棧頂元素不等於 popA[popIndex]
的時候繼續入棧元素,直到全部元素入棧完畢若是,棧不爲空則表示 popA 不是一個出棧序列。經過下圖能夠更好的理解題目要考察的內容:
因此在編程的只須要注意一下三點:
下面看代碼實現:
public static class Solution { public boolean IsPopOrder(int[] pushA, int[] popA) { int len = pushA.length; Stack<Integer> stack = new Stack<>(); for (int pushIndex = 0, popIndex = 0; pushIndex < len; pushIndex++) { stack.push(pushA[pushIndex]); //若是棧頂元素等於 popA[popIndex] 則一直出棧且 popIndex++ while (popIndex < popA.length && popA[popIndex] == stack.peek()) { stack.pop(); popIndex++; } } return stack.isEmpty(); } } 複製代碼
C++實現以下
class Solution { public: bool IsPopOrder(vector<int> pushA, vector<int> popA) { if(pushA() == 0) return false; vector<int> stack; for(int i = 0,j = 0 ;i < pushA.size();){ stack.push_back(pushA[i++]); while(j < popA.size() && stack.back() == popA[j]){ stack.pop_back(); j++; } } return stack.empty(); } }; 複製代碼
測試結果以下:
public static void main(String[] args) { Solution solution = new Solution(); int[] pushA = new int[]{1, 2, 3, 4, 5}; int[] popA1 = new int[]{4, 3, 5, 1, 2}; int[] popA2 = new int[]{4, 5, 3, 2, 1}; System.out.println("popA1 是不是出棧隊列 " + solution.IsPopOrder(pushA, popA1)); System.out.println("popA2 是不是出棧隊列 " + solution.IsPopOrder(pushA, popA2)); } // 結果 //popA1 是不是出棧隊列 false //popA2 是不是出棧隊列 true 複製代碼
上一小節咱們用三道題了解一下面試過程當中棧和隊列的常見面試題。本小節筆者將經過幾個位運算的題目來帶你們熟悉下經常使用的位運算知識。
相比於棧和隊列來說,筆者自身認爲位運算須要掌握的知識就要多一些,包括對於數字的二進制表示,二進制的反碼,補碼。以及二進制的常見運算都須要瞭解。固然若是系統的去學,可能沒有經歷,也可能即便學完了,仍舊不會作題。因此筆者認爲經過直接去刷一些相應的題目,則是一個比較便捷的途徑。
該題目做爲後續題目的鋪墊,看上去仍是沒有任何難度的。主要考察了面試可否想到用二進制的位運算方法去解決。
首先整數能夠分爲正數,負數,0。也能夠分爲奇數和偶數。偶數的定義是:若是一個數是2的整數倍數,那麼這個數即是偶數。若是不使用位運算的方法,咱們徹底可使用下面的方式解決:
public boolean isOdd(int num){//odd 奇數 return num % 2 != 0; } 複製代碼
但是面試題不可能去簡單就考察這麼簡單的解法,進而咱們想到了二進制中若是 一個數是偶數那麼最後一個必定是 0 若是一個數是奇數那麼最後一位必定是 1;而十進制 1 在 8 位二進制中表示爲 0000 0001,咱們只需將一個數個 1相與(&) 獲得的結果若是是 1 則表示該數爲奇數,否知爲偶數。因此這道題的最佳解法以下:
public boolean isOdd(int num){ return num & 1 != 0; } 複製代碼
#include "iostream" using namespace std; //聲明 bool IsOdd(int num); bool IsOdd(int num) { int res = (num & 1); return res != 0; } 複製代碼
測試:
int main(int argc, const char * argv[]) { std::cout << "是不是奇數 : " << IsOdd(1) <<endl; std::cout << "是不是奇數 : " << IsOdd(4) <<endl; return 0; } //結果 是不是奇數 : 1//是 true 是不是奇數 : 0//不是 false 複製代碼
這道題仍舊考察面試者對於一個數的二進制的表示特色,一個整數若是是2的整數次冪,那麼他用二進制表示完確定有惟一一位爲1其他各位都爲 0,形如 0..0100...0。好比 8 是 2的3次冪,那麼這個數表示爲二進制位 0000 1000 。
除此以外咱們還應該想到,一個二進制若是表示爲 0..0100...0,那麼它減去1獲得的數二進制表示確定是 0..0011..1 的形式。那麼這個數與自本身減一後的數相與獲得結果確定爲0。
如:
因此該題最佳解法爲:
public boolean log2(int num){ return (num & (num - 1)) == 0; } 複製代碼
#include "iostream" using namespace std; //聲明 bool IsLog2(int num); //定義 bool IsLog2(int num) { return (num & (num -1)) == 0; } 複製代碼
測試:
int main(int argc, const char * argv[]) { std::cout << "是不是2的整數次冪 : " << IsLog2(1) <<endl; std::cout << "是不是2的整數次冪 : " << IsLog2(3) <<endl; return 0; } //結果 是不是2的整數次冪 : 1 //是 true 是不是2的整數次冪 : 0 //不是 false 複製代碼
此題較之上一題又再進一步,判斷一個整數二進制表示中1的個數,假設這個整數用32位表示,可正可負可0,那麼這個數中有多少個1,就須要考慮到符號位的問題了。
相信讀者應該都能想到最近基本的解法即經過右移運算後與 1 相與獲得的結果來計算結果,若是採用這種解法,那麼這個題的陷阱就在於存在負數的狀況,若是負數的話標誌位應該算一個1。因此右移的時候必定要採用無符號右移才能獲得正確的解法。
ps 對於正數右移和無符號右移獲得結果同樣,若是是負數,右移操做將在二進制補碼左邊添加追加1,而無符號右移則是補 0 。
因此此題一種解法以下:
public int count1(int n) { int res = 0; while (n != 0) { res += n & 1; n >>>= 1; } return res; } 複製代碼
#include "iostream" using namespace std; //注意C++中沒有無符號右移操做,因此這裏傳入一個 unsigned 數做爲 params int count1(unsigned int n){ int res = 0; while(n != 0){ res += n & 1; n >>= 1; } return res; } 複製代碼
測試結果:
int main(int argc, const char * argv[]) { std::cout << "二進制中1的個數 : " << count1(-1) <<endl; std::cout << "二進制中1的個數 : " << count1(1) <<endl; return 0; } //結果 二進制中1的個數 : 32 二進制中1的個數 : 1 複製代碼
能回答出上邊的答案你的面試確定是及格了,可是做爲練習來講,是否有額外的解法呢?首先上述結果最壞的狀況可能須要循環32次。上面咱們算過一道如何判斷一個數是不是2的整數倍,咱們用過了 n&(n-1)==0
的方法。其實該題的第二個解法也能夠用這個方法。爲何呢?咱們開看一次上邊的圖:
咱們是否能發現,每次與比本身小1的數與那麼該數的二進制表示最後一個爲1位上的1將將會被抹去。其實這是一個知道有這種原理才能想到的方法,因此你們也不用哀嘆說我怎麼想不到,經過此次記住有這個規律下次就多一個思路也不是很麼壞事。
下面咱們來看下判斷一個數中有多少個1的完整圖解:
因此咱們能夠經過以下方法來獲得題解,這樣咱們能夠減小移動次數
public int countA(int n){ int res = 0; while(n != 0){ n &= (n - 1); res++; } return res; } 複製代碼
#include "iostream" using namespace std; // 同上傳入無符號整數 int countA(unsigned int n){ int res = 0; while(n != 0){ n &= (n - 1); res++; } return res; } 複製代碼
測試結果:
int main(int argc, const char * argv[]) { std::cout << "二進制中1的個數 : " << countA(-1) <<endl; std::cout << "二進制中1的個數 : " << countA(1) <<endl; return 0; } //結果 二進制中1的個數 : 32 二進制中1的個數 : 1 複製代碼
這道題一樣是考察爲位運算的一道題,可是若是對於不熟悉位運算的朋友可能壓根都不會往這方面想,也許當場直接就下邊寫下了遍歷數組記每一個數出現次數的代碼了。其實這道題要求在時間複雜度在O(n) 空間複雜度爲O(1)的條件下,那種解法是不符合要求的。咱們來看下爲位運算的解題思路。
首先咱們應該知道二進制異或操做,異或結果是二進制中兩個位相同爲0,相異爲1。所以能夠有個規律:
任何整數 n 與 0 異或總等於其自己 n,一個數與其自己異或那麼結果確定是 0。
還須要知道一個規律:
多個數異或操做,遵循交換律和結合律。
對於第一條朋友們確定都很好理解,然而第二條規律纔是這道題的解題關鍵。若是咱們有一個變量 eO = 0
那麼在遍歷數組過程當中,使每一個數與 eO 異或獲得的值在賦值給額 eO 即 eO=eO ^ num
那麼遍歷結束後eO的值必定是那個出現一次的數的值。這是爲何呢?咱們能夠舉個例子:
假設有這麼一個序列: C B D A A B C 其中只有 D 出現一次,那麼由於異或知足交換律和結合律,因此咱們遍歷異或此序列的過程等價於
eO ^ (A ^ A ^ B ^ B ^ C ^ C ) ^ D = eO ^ 0 ^ D = D 複製代碼
因此對於任何排列的數組,若是隻有一個數只出現了奇數次,其餘的數都出現了歐數次,那麼最終異或的結果確定爲出現奇數次的那個數。
因此此題能夠有下面的這種解法:
java 解法
public int oddTimesNum(int[] arr) { int eO = 0; for (int cur : arr) { eO = eO ^ cur; } return eO; } 複製代碼
C++ 解法
int oddTimesNum(vector<int> arr) { int eO = 0; for (int cur : arr) { eO = eO ^ cur; } return eO; } 複製代碼
測試:
int main(int argc, const char * argv[]) { vector<int> arr = {2,1,3,3,2,1,4,5,4}; std::cout << "出現奇數次的那個數: " << oddTimesNum(arr) <<endl; return 0; } //結果 出現奇數次的那個數: 5 複製代碼
關於這道題還有個延伸版本,就是若是數組中出現1次的數有兩個,那麼該如何獲得這兩個數。
咱們順着上題的思路來思考,若是有兩個數得到的結果 eO 確定是 eO = a^b
,此題的關鍵就在於如何分別獲得 a,b 這兩個數。咱們應該想到,任何不相同的兩個除了跟本身異或外,不可能每個位都相同,也就是說不相同的兩個數 a b 異或獲得結果二進制表示上確定有一位爲 1。 這是關鍵。
咱們能夠假設第 k 位不爲 0 ,那麼就說明 a 與 b 在這位上數值不相同。咱們要作只是設置一個數第 k 位 爲 1,其他位爲 0 記爲 rightOne
。
這時須要拿 eOhasOne = 0
再異或遍歷一次數組,可是須要忽略與 rightOne
相與等於 0 的數。由於相與等於 0 則表明了這個數確定是兩個數中第 k 位不爲 1的那個。最終獲得的 eOhasOne
就是 a b 中第 k 爲爲 1 的那個。
那麼接下來就剩下一個問題要解決了,如何找到 rightOne
,這裏採用與自己補碼相與的方法獲得即 int rightOne = eO & (~eO + 1)
。
能夠參照下圖來理解下整個過程:
咱們來看下最終的代碼:
java 寫法
public void printOddTimesNum(int[] arr) { int eO = 0; int eOhasOne = 0; for (int cur : arr) { eO = eO ^ cur; } int rightOne = eO & (~eO + 1); for (int cur : arr) { if ((rightOne & cur) != 0) { eOhasOne = eOhasOne ^ cur; } } System.out.println("eOhasOne = " + eOhasOne + " " + (eOhasOne ^ eO)); } 複製代碼
C++ 寫法
void printOddTimesNum(vector<int> arr) { int eO = 0; int eOhasOne = 0; for (int cur : arr) { eO = eO ^ cur; } int rightOne = eO & (~eO + 1); for (int cur : arr) { if ((cur & rightOne) != 0) { eOhasOne = eOhasOne ^ cur; } } std::cout<<"一個出現1次的數 " << eOhasOne << endl; std::cout<<"二個出現1次的數 " << (eO ^ eOhasOne) <<endl; } 複製代碼
測試:
int main(int argc, const char * argv[]) { vector<int> arr1 = {2,1,3,3,2,1,4,5}; printOddTimesNum(arr1); return 0; } //結果: 一個出現1次的數 5 二個出現1次的數 4 複製代碼
本文列舉了棧隊列以及位運算的一些面試題目,經過這些面試題目咱們能夠了解到一些面試中算法的考點,對於位運算相關題目,咱們仍是須要多加練習,可是不要懼怕本身某些地方不會限制瞭解題思路,經過多加練習,記住見過的解題中的規律,相信通過一段時間練習後,也會感覺到自個人提升。
最後歡迎你們關注個人掘金專欄,不定時分享一些本身的學習工做總結。
《劍指 offer 第二版》 《程序員代碼面試指南 - 左程雲》