# 2023 年「[資訊科技產業專案設計](https://hackmd.io/@sysprog/info2023/)」課程第 2 次作業
> 乙二 - Two
> 👒: interviewer
> ◼️: interviewee
>
> 影片中戴帽子的是面試官,沒戴的是面試者
## 影片
- [第一題](https://youtu.be/KOVfvmkCNWE)
## [34. Find First and Last Position of Element in Sorted Array](https://leetcode.com/problems/find-first-and-last-position-of-element-in-sorted-array/)
👒: 乙二先生你好,歡迎來到本公司的面試。本公司主打的產品是音樂播放器,在年度回顧的時候會需要知道使用者今年第一次聽,以及最近一次聽某一首歌的時間。假設我們已經從資料庫中取出某位使用者今年的所有聽歌紀錄,你會怎麼找出這兩個時間?
◼️: 我想確認一下,我需要在某個使用者的所有聽歌紀錄中,找到第一次聽這首歌,以及最後一次聽的時間,對嗎?
👒: 是的。
◼️: 好,那麼聽歌紀錄的 schema 會有哪些欄位?
👒: 聽歌紀錄的 schema 會有使用者 ID、歌曲 ID、時間這三個欄位。
◼️: 那麼時間是使用 unix timestamp 儲存的嗎?
👒: 是的,你可以將它當作整數處理。
👒: 請問使用者與歌曲 ID 都是 serial int 嗎,我能不能將它們當成整數處理?
◼️: 是的,可以。
◼️: 請問資料會不會依照任何欄位排序過?
👒: 紀錄會根據時間,從小到大排序,但歌曲 ID 沒有排序。
◼️: 好,那請讓我舉個範例:如果使用者聽歌的 ID 順序分別為 `[1,2,3,1,2`],那麼尋找第一次聽的紀錄是在 index=0,最後一次聽是在 index=3,之後再取出時間就好,請問我理解的正確嗎?
👒: 是的,這樣沒有錯。
◼️: 好,那我的方法會是這樣:寫一個回傳 int vector 的函式,接收 records 和 songId 兩個參數。使用兩個變數儲存第一次和最後一次聽那首歌的 index,分別從開始與結束向中間搜尋,如果找到了歌曲就紀錄並停止搜尋,最後回傳這兩個 index。
👒: 好,你可以開始實作了
```cpp
vector<int> findFirstAndLastListenIndexes(vector<Record>records, int songId){
int firstIndex=-1;
int lastIndex=-1;
int currentIndex=0;
while(currentIndex<records.size()){
if(records[currentIndex].songId==songId){
firstIndex=currentIndex;
break;
}
currentIndex++;
}
currentIndex=records.size()-1;
while(currentIndex>=0){
if(records[currentIndex].songId==songId){
lastIndex=currentIndex;
break;
}
currentIndex--;
}
return {firstIndex,lastIndex};
}
```
👒: 好,請你舉出一些範例來驗證這個演算法。
◼️: 如同我一開始舉的範例,如果聽歌的順序是 `[1,2,3,1,2]`,找歌曲編號 1,last 在第一個迴圈變成 0,而 lastIndex 則會變成 3。
◼️: 而如果是極端情況,例如空陣列,則會直接不進入迴圈;而找不到的情況則是迴圈完全跑完,但不改變 first 和 last index,都會回傳 -1。
👒: 好,這樣看起來每一種情況都有考慮到。
👒: 但是我注意到一個問題,在第一個迴圈找不到值的時候,這個演算法在第二個迴圈可能會做不必要的檢查,造成效能的浪費,你能修正這個問題嗎?
◼️: 當我們找不到第一個 index 的時候,就代表這首歌不存在於紀錄之中,如果是這樣的話,就不需要找最後一個 index,直接用 -1 就可以了。
```diff
vector<int> findFirstAndLastIndexes(vector<Record>records, int songId){
int firstIndex=-1;
int lastIndex=-1;
int currentIndex=0;
while(currentIndex<records.size()){
if(records[currentIndex].songId==songId){
firstIndex=currentIndex;
break;
}
currentIndex++;
}
+ if(firstIndex==-1){
+ return {-1,-1};
+ }
currentIndex=records.size()-1;
while(currentIndex>=0){
if(records[currentIndex].songId==songId){
lastIndex=currentIndex;
break;
}
currentIndex--;
}
return {firstIndex,lastIndex};
}
```
👒: 好,那麼可以請你分析這個演算法的時間複雜度嗎?
◼️: 這個演算法在 worst case 會需要搜索整個陣列一輪,所以時間複雜度是 O(n)。
👒: 那接下來的題目是假設聽歌紀錄已經根據歌曲 ID 排序過的情況,你會怎麼修改這個演算法來改善時間複雜度?
◼️: 請問除了依照歌曲 ID 排序,還會跟原本一樣保持以時間排序嗎?
👒: 是的,會和原本一樣升冪排序。
◼️: 好,那舉例來說,陣列的歌曲 ID 可能是 `[1,1,2,3,3]`,但不可能是`[1,3,2,2,1]`,請問這樣對嗎?
👒: 對。
◼️: 那舉例來說,如果歌曲 ID 是 `[1,1,2,3,3]` 找 1,那就應該回傳 0 和 1,對嗎?
👒: 對
◼️: 好,那麼我認為這題可以使用 binary search,因為陣列是升冪排序的。而在找到了目標以後,再從那個地方往前後找尋第一個與最後一個 index。
👒: 好,你可以開始實作了。
```cpp!
vector<int> findIndexes(vector<Record> records, int songId)
{
int left = 0;
int right = records.size() - 1;
int first = -1;
int last = -1;
// O(log n)
while (left <= right)
{
int mid = left + (right - left) / 2; // prevent overflow
if (records[mid].songId == songId)
{
first = mid;
last = mid;
// worst case: loop every element O(n)
while (first - 1 >= 0 && records[first - 1].songId == songId)
{
first--;
}
while (last + 1 < records.size() && records[last + 1].songId == songId)
{
last++;
}
break;
}
else if (records[mid].songId > songId)
{
right = mid - 1;
}
else
{
left = mid + 1;
}
}
return {first, last}; // 0, 1
}
}
```
👒: 看起來沒有問題,你能舉出一些例子來證明演算法的正確性嗎?同時考慮極端情況。
◼️: 好,極端情況可能是空陣列或是找不到值,如果是空陣列,會直接不進入迴圈。
◼️: 而如果是找 5,會因為 `left > right` 而跳出迴圈,就會回傳 {-1,-1}。
◼️: 而如果沒有 overflow,則任何長度的陣列都是使用 binary search 找出值,並且向兩邊尋找。例如 `[1,1,2,3,3]` 找 1,mid=2、太大所以right=1, mid=0,找到,然後向左右找,找到 index 0 和 1。
👒: 好,這樣看起來程式是正確的。你能分析一下這個演算法的時間複雜度嗎?
◼️: 這個演算法使用了 binary search,在找到值的時候會花 $O(logn)$ 的時間,而向兩邊尋找的時間複雜度則是 O(n),因為有可能整個陣列都是同一個值。所以整個演算法的時間複雜度是 O(n)。
👒: 沒錯。我還有一個問題,有一些情況下,你可以利用歌曲 ID 升冪排序的特性,在binary search 以前就知道歌曲是否可能存在在陣列中,請問你知道應該怎麼實作嗎?
◼️: 如果歌曲 ID 是升冪的,那最小的值就是第一個,而最大的就是最後一個,我可以檢查目標是否超出了範圍,如果是的話,就可以直接回傳 {-1,-1}。
👒: 很好,請你替演算法加上這個功能。
```diff!
vector<int> findIndexes(vector<Record> records, int songId){
+ if (records.size() > 0 && (records[0].songId > songId || records[records.size() - 1].songId < songId))
+ {
+ return {-1, -1};
+ }
int firstIndex=-1;
int lastIndex=-1;
int left=0;
int right=records.size()-1;
...
```
◼️: 我們一開始需要檢查 `records` 的長度,確保我們的 index 不會超出去,而如果要找的 songId 超出了最大與最小的範圍,我們就不需要檢查,可以直接回傳找不到。
👒: 好,我們今天的面試到此結束,謝謝你今天的參與。
## 初步檢討
### interviewer
- 優點
- 有多個 follow up、提出能讓 interviewee 改進的地方
- 可改進的地方
- 詢問的問題不夠多種,基本上都是時間複雜度/快速處理特殊情況/驗證正確性
### interviewee
- 優點
- 有對於 database 的 schema 對題目做出變化(例如:比較紀錄的 songId)
- 有問清楚各種細節
- 可改進的地方
- 應該主動測試演算法的正確性(主動帶入範例)
- 有些口頭解釋太過繁雜,應該變得和 hackmd 上寫得一樣簡潔
## 對其他同學的批評
- 哈囉-Hi
有把範例用註解寫下來很好,但是寫程式的時候可能要注意 coding style 一致,學到自己可以先把範例寫在註解。
- 肥俠-Billy
英文單字發音需要加強,但是範例的寫法很清楚、有連續的 follow up,更能看出面試者的實力。
- 六六-Leo
面試者有把題目輸入的條件也記在文件上很好,但面試官問問題、面試者回答問題有一點太快跳到結論、filler word 有點太多了,我自己也可以注意這件事。
- 瑪奇朵-Macchiato
面試官問的問題很好,包含面試者為什麼要用某個方法、不同情況對於時間複雜度的影響等。
- 抹茶-Matcha
leetcode 原題直接寫在 Google Docs 上,可能會讓 interviewee 直接背題目通過,而面試者的舉例和分析都很詳細。
## 第四次作業
### 盲狗
- interviewer
- 有適當引導面試者應該朝哪個方向優化
- interviewee
- 要重複問題的時候,不需要說自己要重複一次,說自己要確認會比較自然
- 在講解自己的方法時,有寫出遞迴關係式,能展現自己有理解這個題目,不過可以另外寫下 n=1 時,S(n)=1, n=2, S(n)=2,讓面試官知道自己也有考慮到 edge case
### 節瓜
- interviewer
- 變化後的題目好像要素有點太多,可以去除一些資訊,變成:「有一場藝術品拍賣會,拍賣會上多出了很多複製品,而這些畫作的拍賣編號都相同,請問你應該如何找出哪些拍賣品有複製品。」
- interviewee
- 確認問題條件時,可以問是否會有多個拍賣品同時有複製品(陣列中有多個重複)