# 2025 年「資訊科技產業專案設計」課程第 2 次作業
> 貢獻者 : ==法拉第 Faraday== , Hypatia
> [面試錄影-1](https://youtu.be/AZ_a5DFVZTo)
> [面試錄影-2](https://youtu.be/GeCwDBFH_Hw?si=nD0jkl7qS3Qqbpfy)
## [LeetCode : 83. Remove Duplicates from Sorted List](https://leetcode.com/problems/remove-duplicates-from-sorted-list/description/?envType=problem-list-v2&envId=linked-list)
> 👨💼 interviewer : Hypatia (暱稱)
> 👩💻 interviewee : Faraday (暱稱)
### 模擬面試過程
**👨💼 interviewer:**
您好,那我們就直接開始今天的面試。想要先請你用C語言實作一個演算法題目,給你一個"已經排序"的單向鏈結串列,請你移除重複的節點,讓每個元素只出現一次,並且要回傳處理後的鏈結串列head節點。請問這樣有理解題目嗎?
**👩💻 interviewee: ==(Repeat, Example)==**
好的,那我先重複一次題目,確定我理解正確。所以題目會給我一個排序好的鏈結串列,我需要移除裡面的重複值,讓每個值只出現一次,最後回傳更新後的串列。假設我的串列是 1 → 1 → 2 → 3 → 3,那在我執行完這個程式後,串列會變成 1 → 2 → 3,這樣對吧?
**👨💼 interviewer:**
很好。那你預期怎麼解決這個問題呢?
**👩💻 interviewee: ==(Approach)==**
好,因為這個串列是排序好的,所以所有重複的值都會相鄰。所以我只需要檢查「當前節點的值」是否等於「下一個節點的值」。如果相同,就把重複的節點略過。我的想法是使用一個「指向指標的指標」,一開始指向串列的開頭 head。我會一路往後走,對於每個節點檢查下一個節點是否與它相同。如果相同,讓指標繞過它;如果不同,就接續處理下一個節點。這樣重複做直到串列結尾,就能確保每個值只出現一次,最後回傳更新後的 head。
**👨💼 interviewer:**
大概了解,那請你開始示範你的實作
**👩💻 interviewee: ==(Code)==**
```c
struct ListNode {
int val;
struct ListNode *next;
};
struct ListNode* deleteDuplicates(struct ListNode* head) {
struct ListNode **ptr = &head;
while (*ptr != NULL && (*ptr)->next != NULL) {
if ((*ptr)->next->val == (*ptr)->val) {
(*ptr)->next = (*ptr)->next->next;
} else {
ptr = &(*ptr)->next;
}
}
return head;
}
```
**👨💼 interviewer:**
你這邊用了 `struct ListNode **ptr = &head;`,這樣設計的好處是什麼呢?和直接用一個 `current` 指標 `(ListNode *current = head;)` 的話差別在哪邊?
**👩💻 interviewee:**
因為使用指標的指標可以讓我統一處理頭節點的情況。
例如當重複的值出現在開頭時,我不需要特別寫額外的條件判斷。
我只要更新「指向當前節點的那個指標」,不論那是 head 還是某個節點的 next,都能正確更新串列。
**👨💼 interviewer:**
假設我們用 `ListNode *current = head;`,然後用 `current->next` 去比對的話,結果會有什麼不同呢?
**👩💻 interviewee:**
假設是使用 `struct ListNode *current = head`,等於是把 head 做一個複製,那當發現下一個節點的值等於目前節點的時候,就會改:`current->next = current->next->next;`,並不會動到 head 本身。這在重複不發生在開頭時不會有問題,但若重複發生在開頭,head 其實會需要被改掉,所以要另外寫一個程式來處理這個情況,要額外寫一個判斷式,然後執行:`head = head->next;`,這樣就需要特別處理 head 的情況。
**👨💼 interviewer:**
很好,你對於指標的理解很清楚。那妳所構寫的這段程式時間複雜度是什麼?空間複雜度又是多少呢?
**👩💻 interviewee:**
時間複雜度是 O(n),因為我們最多只會拜訪每個節點一次。空間複雜度是 O(1),只用了幾個指標變數,沒有額外記憶體開銷。
**👨💼 interviewer:**
那如果 linked list 有 n 個節點,你覺得會有什麼潛在邊界條件(edge case)需要特別注意嗎?例如:空鏈結串列或是只有一個節點時?
**👩💻 interviewee:**
需要特別注意的情況,就是在n=0。當n=0,也就是沒有任何節點,在存取下一個節點的時候就會出現 segmentation fault。所以如果鏈結串列是空的,也就是 `head = NULL`,就要讓他直接回傳自己。至於只有一個節點,其實就跟普通串列處理到最後一個節點時的狀況一樣,也就是當前節點的下一個節點是 `NULL`,因此我們要做的就是設定好判斷條件,使他在這個 case 的時候不會進入迴圈。
**👨💼 interviewer:**
現在請你舉一個小例子來驗證你的程式吧!
**👩💻 interviewee:**
假設一開始串列是:
1 → 1 → 2 → 3 → NULL
ptr 指向開頭。
首先,比較當前與下一個值:兩個都是 1,所以跳過重複的節點。
現在串列變成:
1 → 2 → 3 → NULL
ptr 暫時不移動。
接著比較 1 和 2,不同,於是 ptr 往下一個節點移動。
1 → 2 → 3 → NULL
ptr
接著比較 2 和 3,不同,再往下移。
1 → 2 → 3 → NULL
ptr
此時已經到達串列尾端,沒有下一個節點,結束。
最終結果就是 1 → 2 → 3 → NULL,正確答案。
**👨💼 interviewer:**
太好了!那如果我們把題目改成:要刪除所有重複的節點,只保留完全不重複的值,也就是說如果出現兩個一樣的值,這兩個都要刪掉。你會怎麼修改這段程式?
**👩💻 interviewee: ==(Optimization)==**
如果是這個情況,在遇到重複的值後,除了要刪除目前節點,還要刪除所有後面相同值的節點。所以我們會需要特別紀錄重複的值,一次把所有相同值的節點刪掉後,再前進到下一格。
```c
struct ListNode* deleteAllDuplicates(struct ListNode* head) {
struct ListNode **ptr = &head; // double pointer to handle head
while (*ptr != NULL && (*ptr)->next != NULL) {
if ((*ptr)->val == (*ptr)->next->val) {
int dup_val = (*ptr)->val;
// 跳過所有相同值的節點
while (*ptr != NULL && (*ptr)->val == dup_val) {
*ptr = (*ptr)->next; // 直接連到下一個不同節點
}
} else {
ptr = &(*ptr)->next; // 沒重複就前進
}
}
return head;
}
```
**👨💼 interviewer:**
這邊請妳同樣舉一個小例子來驗證你的程式
**👩💻 interviewee: ==(Test)==**
假設一開始串列是:
1 → 1 → 2 → 3 → NULL
ptr 指向開頭。
首先,比較當前與下一個值:兩個都是 1,所以 `dup_val` 就是1。`cur` 跟 `ptr` 在同一個位置:
1 → 1 → 2 → 3 → NULL
ptr
cur
接著比較 `cur` 會一直往後移動,直到他的值不是 `dup_val` ,`ptr` 就跑到 `cur` 的地方
2 → 3 → NULL
ptr
cur
繼續往下一個節點移動,都沒有重複的值,所以最後 2 → 3 → NULL,正確答案。
**👨💼 interviewer:**
如果題目改成:鏈結串列不是排序好的,你會怎麼做?這時候你還能用一樣的邏輯嗎?如果可以請直接修改妳的程式碼,不能的話,你可以敘述你的想法就好~
**👩💻 interviewee:**
如果鏈結串列不是排序好的,就不能使用這個方法。因為它的核心假設是「重複的值會相鄰出現」,我們才能透過一次掃描直接跳過重複區段。如果鏈結串列是未排序的,那這個假設就不成立了,重複的節點可能分散在整個串列中。這種情況下,我們就需要用額外的資料結構來協助判斷。一個常見的作法是使用 Hash Set 來記錄已經出現過的數值:當我們第一次遇到某個值時,就把它加入集合;如果之後再遇到相同的值,就代表它是重複的,我們就把那個節點刪掉。
**👨💼 interviewer:**
你覺得這樣的題目邏輯(包含有順序的沒有順序的)在實務上可以應用在哪些場景呢?
**👩💻 interviewee:**
其實這個題目的邏輯在實務上滿常見的,比如在資料清理或串流處理時,我們常常需要去除重複紀錄。如果資料是事先排序好的,比如說按照日期或時間,那我們就可以像這題一樣,用線性掃描的方式直接刪除相鄰重複項,時間複雜度是 O(n)。但如果資料是亂序的,那就比較接近實際情境,例如合併多個來源的用戶資料,我們就會用 hash set 來追蹤出現過的值,這樣雖然多用了一些空間,但可以保證一次遍歷完成。
**👨💼 interviewer:**
如果這個鏈結串列在多執行緒環境下(concurrent environment)被操作,這樣的實作會有什麼問題?
**👩💻 interviewee:**
在多執行緒環境下這段程式是不安全的,因為它直接修改指標,沒有加任何同步機制。假設一個執行緒正在刪除某個節點時,另一個執行緒也嘗試存取那個節點,就會造成指標錯亂。如果要讓它在 concurrent 環境下安全,可以互斥鎖,確保同時間只有一個執行緒能修改結構。不過這會影響效能。或是使用 Copy-on-Write 機制,就是修改時會建立一份新的鏈結串列版本,避免直接修改共享資料。
**👨💼 interviewer:**
非常好,感謝你的分享,今天的面試就到這裡。
### 同儕互評
針對 interviewer 的檢討
* [7:30](https://youtu.be/AZ_a5DFVZTo?si=Ec37eK5IeiiuoweV&t=450): 應該要定義 `current` 指標為何,再使用 `current->next` ,幫助 interviewee 理解題目。
針對 interviewee 的檢討
* [5:30](https://youtu.be/AZ_a5DFVZTo?si=CMjiNv5G7p5bHNVv&t=330): 「原本他的 next 要指向原本的下下一個」,此句主詞不完整,可以改為「目前節點的 next 連到下下一個節點」。
* [10:48](https://youtu.be/AZ_a5DFVZTo?si=VXKqLbpzn6TZpigA&t=648): `NULL` 讀作 [nʌl/] (音似 no),不能發「怒」。
* [13:10](https://youtu.be/AZ_a5DFVZTo?si=DkeQ5fusw7JEvuTn&t=790): 句子要完整,要講: 進入到下一個「節點」。
## [LeetCode : 977. Squares of a Sorted Array](https://leetcode.com/problems/squares-of-a-sorted-array/description/)
> 👨💼 interviewer : Faraday (暱稱)
> 👩💻 interviewee : Hypatia (暱稱)
### 模擬面試過程
**👨💼 interviewer:**
您好,那我們就直接開始今天的面試。想要先請你用C語言實作一個演算法題目,給定一個非遞減排序的整數陣列,請你回傳一個陣列,要包含每個元素的平方,並且仍要保持非遞減排序。請問這樣有理解題目嗎?
**👩💻 interviewee: ==(Repeat, Example)==**
好,那也就是說目前會有一個非遞減排序的整數陣列,那我的回傳結果是要讓每個數字平方過後,也要依照非遞減的排序方式來輸出。比如說題目如果給我 `[-3, -1, 0, 2, 4]` 的話,那我的 output 結果應該要是 `[0, 1, 4, 9, 16]` 那請問這樣這理解的正確嗎?
**👨💼 interviewer:**
很好。那請你說明一下你要如何設計演算法?
**👩💻 interviewee: ==(Approach)==**
我覺得最直觀的做法就是平方以後用常見的排序的演算法進行排序,但是因為常見的排序演算法時間複雜度都太高了,就是 O(nlog(n)),所以我會希望說可以利用這個題目本身的特性,讓整個排序的時間複雜度降低到 O(n)。我用一個例子來分享我的想法,假如 input 是 `[-3, -1, 0, 2, 4]` 那我注意到負數跟非負數其實可以變成兩個陣列,因為他有一個特性就是負數的他原本越小,但是他平方以後會越大,所以我可以先變成兩個陣列 `[-3, -1]` 然後再將另外一個陣列是放這個非負數的結果,那這樣我將他們兩個平方以後,因為負數越小會變越大,所以我在負數的這個矩陣,可以再把他們做一個 riverse,這樣就會得到兩個都是遞增的數列。最後我再把這兩個進行 merge,那 merge 的方就是,我會先用兩個指標,指向這個 t1 跟 t2 的第一個,那我們就從第一個開始比,比較小的先放進 list 裡面,然後一直比下去直到把全部的都放置完成,那 merge 完後也可以得到非遞減的數列結果。
**👨💼 interviewer:**
很好,那請你開始實作程式碼。
**👩💻 interviewee: ==(Code)==**
```c
static void reverse(int*a, int n){
int i = 0;
int j = n-1;
while(i<j){
int temp = a[j];
a[i] = a[j];
a[j] = temp;
i++;
j++;
}
}
int * sortedSquare(int* nums, int numsSize, int* returnSize){
int count = 0;
while(count<numSize && nums[count]<0){
count++;
}
int n1 = count;
int n2 = numsSize-count;
int* t1 =(int*)mallox(n1*sizeof(int));
int* t2 =(int*)mallox(n1*sizeof(int));
for(int k=0;k<n1;k++){
int x = nums[k];
t1[k] = x*x;
}
for(int k=0;k<n2;k++){
int x = nums[k];
t2[k] = x*x;
}
reverse(t1,n1);
int* ans = (int*)mallox(numsSize*sizeof(int));
int i = 0;
int j = 0;
while(i<n1 &&j<n2){
if(t1[i]<=t2[j]){
ans[target] = t1[i];
target++;
i++;
}else{
ans[target] = t2[j];
target++;
j++;
}
}
while(i<n1){
ans[target] = t1[i];
target++;
i++;
}
while(j<n2){
ans[target] = t2[i];
target++;
j++;
}
free(t1);
free(t2);
*returnSize = numsSize;
return ans;
}
```
**👨💼 interviewer:**
很好,你提供的步驟非常清楚。這部分是先製作兩個暫存陣列,t1跟t2,然後再把它們合併對吧。那這個方法的空間複雜度會比較高,請問你有沒有辦法只用一個陣列,然後使用指標操作的方式,來完成這個程式?
**👩💻 interviewee: ==(Optimization)==**
可以的沒問題,我有注意到我這個方法的話,因為我這裡有多增加另外兩個,所以他的時間複雜度會變成 O(n)+O(n1)+O(n2) ,相對的確實空間負大度有更高一些。那我這邊有另外一個想法,就是我可以用兩個指標的方式來進行實作。因為平方與過後,我會發現負數是越小平方過後會越大,那正數是越大平方跟過後會越大,所以他的最大值會在最兩邊。所以我可以用兩個指標的方式在最左右兩邊的指標,然後開始往內比,因為他的方向一定是從大排到小,所以我用兩個指標的話可以減少這個空間負大度。
```c
int * sortedSquare(int* nums, int numsSize, int* returnSize){
int* ans = (int*)malloc(numsSize*sizeof(int));
int left = 0;
int right = numsSize-1;
int target = numsSize-1;
*returnSize = numsSize;
while(left<right){
int leftSquare = nums[left]*nums[left];
int rightSquare = nums[right]*nums[right];
if(leftSquare>rightSquare){
ans[target] = leftSquare;
target--;
left++;
}else{
ans[target] = rightSquare;
target--;
right++;
}
}
return ans;
}
```
**👨💼 interviewer:**
請你舉一個小例子來驗證你的程式
**👩💻 interviewee: ==(Test)==**
```
[-3, -1, 0, 2, 4]
l r
[0, 1, 4, 9, 16]
t
```
**👨💼 interviewer:**
如果我不只取平方,而是取一個二次函數 f(x)=ax^2+bx+c,這個方法還能用嗎?如果可以,要如何根據你的程式修改?
**👩💻 interviewee: ==(Optimization)==**
因為二次函數他是一個拋物線圖形,所以他整體來說還是一個對稱圖形,所以一樣可以用這個雙指標的方式去進行,只要把平方的部分改成我要的二次函數。但要注意的事情是,因為二次函數有分,當他的 x 平方首項是負號的話他是一個向下的拋物線,然後正的話是向上,所以這時候我放的這個 target 就要注意一下,因為如果向上的話就是先從大的開始比較,向下的話就是從最小的開始比較,這裡是要注意的地方。
**👨💼 interviewer:**
那如果函數本身就是單調遞增,還需要兩個pointer嗎?
**👩💻 interviewee:**
就不用用到兩個 pointer,因為用到兩個 pointer 原因就是因為我有兩個方向的感覺,但是單調遞增他就只需要一個方向,而且他已經排序好了,原本的排序好的數字放進單調遞增函數他的順序還是會保留,所以我只要單方向的掃描去計算每一個元素的單調遞增函數值,再回傳結果就可以了。
**👨💼 interviewer:**
非常好,感謝你的分享,今天的面試就到這裡。
### 同儕互評
針對 interviewer 的檢討
* [16:14](https://youtu.be/GeCwDBFH_Hw?si=e-J9VC158P1T_RWt&t=974): 在 interviewee 說明完想法後,應該要主動引導其開始 code 的實作。
* [23:42](https://youtu.be/GeCwDBFH_Hw?si=3IT1unVFQ7hgNiC9&t=1422): 在接著問 follow-up 時前,應該要給 interviewee 前面的內容一些回饋,或是才不會感覺好像沒在聽。
針對 interviewee 的檢討
* [1:14](https://youtu.be/GeCwDBFH_Hw?si=na2Eqc5JYTxEL8P8&t=74): 應注意要將 Big O 唸出來。
* [1:46](https://youtu.be/GeCwDBFH_Hw?si=1TevfnOc28yHWNh1&t=106): 在舉例說明時要注意用句前後連貫。例如「我注意到負數跟非負數其實可以變成兩個陣列,因為他有一個特性就是負數的他原本越小,但是他平方以後會越大」此句,使用太多「他」,容易讓聽者搞不清楚主詞是誰;「變成兩個陣列 `[-3, -1]` 然後再將另外一個陣列是放這個非負數的結果」此句,第一個陣列是將元素念出,但第二個陣列是說「非負數」這個類別,這部分需要統一;「負數越小會變越大」此句,應清楚說明為「負數越小,平方後會變越大」。
* [2:29](https://youtu.be/GeCwDBFH_Hw?si=iALqyXPRJk2IOPfk&t=149): 注意是「陣列」非「矩陣」。
* [6:48](https://youtu.be/GeCwDBFH_Hw?si=07kGnuNINv67KyiM&t=408): `int* t2 = (int*)mallox(n1*sizeof(int));` 應改為 `int* t2 =(int*)mallox(n2*sizeof(int));`
* [15:22](https://youtu.be/GeCwDBFH_Hw?si=2NJHBuxHnpW0Cylh&t=922): 應清楚說明是另外兩個甚麼?
* [15:29](https://youtu.be/GeCwDBFH_Hw?si=ULqy4pHBRB4XVI3D&t=929): 應是「空間複雜度」。
* [23:58](https://youtu.be/GeCwDBFH_Hw?si=U29YymzFhxWX5-M5&t=1438): 面試時不適合使用「感覺」一詞。