CSE2010 · HANYANG UNIV · 2016 SPRING

자료구조

15주치 랩 과제가 한 레포에 빠짐없이 들어 있다. 그런데 그중 상당수는 자기 자신이 커밋한 입력 파일에서 틀린 답을 내거나, 크래시하거나, 아예 컴파일되지 않는다.

파일84
C 소스23 / 4,493행
커밋105
기간2016.03–06
스택C99
D+종합
소견12 치명적13 중대4 경미합계 29

총평

2016년 1학기 자료구조 수업의 랩 15개 전부가 커밋되어 있다. 빠뜨린 주차가 없고, README의 과제 목록 15개가 디렉터리 15개와 정확히 대응한다. 문서가 코드보다 앞서가는 종류의 과장은 이 레포에 없다 — 대신 정반대의 문제가 있다. 코드가 문서의 이름값을 못 한다.

야심은 분명하다. 함수 포인터로 피벗 전략을 갈아끼우는 퀵소트(lab15/p15_2.c), 매크로 기반 제네릭 이중연결 리스트(lab13/p13.c), 자료구조를 ADT로 분리하려는 시도가 반복적으로 나타난다. 학부 2학년이 자발적으로 여기까지 밀어붙인 건 평가할 만하다. 문제는 그 야심이 검증을 전혀 동반하지 않았다는 것이다.

가장 치명적인 것 하나를 고르라면 Lab04 스택 계산기다. 레포에 함께 커밋된 자기 테스트 입력 4*(7+3%6)-(4/2)+9-(2*3)의 정답은 41인데, 프로그램은 result: 25#를 출력한다. 중위→후위 변환은 정확히 맞는데 후위식 평가에서 피연산자 순서가 뒤집혀 있다. 9-2를 넣으면 -7, 8/2를 넣으면 0이 나온다. 그리고 이 계산기 코드는 Lab06에 통째로 복사되어 같은 오답을 두 번 낸다. 스택 계산기의 유일한 존재 이유가 올바른 답을 내는 것인데, 저자는 자기 출력 숫자를 한 번도 손으로 검산하지 않았다.

가장 잘한 것 하나를 고르라면 Lab08 AVL과 Lab09 R-B 삽입이다. 이 둘은 회전 로그까지 남기며 정상 동작하고, fscanf 반환값을 소비 전에 확인하는 올바른 EOF 패턴을 쓰고, 종료 시 트리를 재귀적으로 해제한다. 같은 사람이 같은 달에 쓴 코드다. 그래서 Lab10이 더 뼈아프다.

과제주제핵심 판정등급
Lab01C Review동작. modulus만 0 검사 누락 — division은 검사함B
Lab02C Review (순열·집합)기대 출력을 커밋해 놓고 3개 중 2개가 그것과 불일치. 무경계 %sC
Lab03Linked Listsizeof(List)로 노드 할당 — ASan 힙 오버플로 확정. 해제 함수 미호출F
Lab04Stack Calculator자기 입력에서 오답(41→25). 피연산자 역전 + 인자 평가순서 UBF
Lab05Circular Queue ADT원형 큐가 아님. dequeue가 O(n) 시프트. 입력 파일에 머지 충돌 마커D
Lab06Postfix EvaluationLab04 라이브러리 통째 복사 — 같은 오답을 그대로 재생산F
Lab07-1BST순회·탐색 동작. 삭제 경로에 엉뚱한 노드 free — 입력이 그 경로를 안 탐C+
Lab07-2Max Heap1-based 힙에 capacity칸만 할당 — ASan 힙 오버플로 확정D
Lab08AVL Tree삽입·회전 정상. deleteValue 50행이 전부 죽은 코드B-
Lab09R-B Insertion정상 동작. EOF 처리가 레포에서 가장 올바름B
Lab10R-B Deletionc == 13 — 삭제 단계가 단 한 번도 실행되지 않음. 출처 불명 509행 동봉F
Lab11Hashingt->zsize — 컴파일 자체가 안 됨. 체인 노드 전량 누수F
Lab12Topological SortLinux에선 정답. 비-Linux에선 무출력. graphFree는 주석 처리된 빈 함수C
Lab13Shortest PathLinux에선 정답. 그러나 Dijkstra가 아님 — 힙 80행을 만들고 안 씀C
Lab14DFS / BFSgraph_create()return 누락 → 즉시 크래시F
Lab15-1Merge Sort재귀판 정답, 반복판 정렬 실패 + 음수 크기 mallocD
Lab15-2Quick SortLinux에선 3개 피벗 전략 모두 정답. XOR swap UB, 무경계 %sC+

Lab11 — 컴파일되지 않는 제출물

해싱(분리 연쇄법) 과제다. 레포에서 유일하게 컴파일 단계에서 실패하는 파일이고, 2016-05-18 Update struct type C89 커밋 이후 지금까지 그 상태로 master에 남아 있다.

치명적

t->zsize라는 멤버는 존재하지 않는다

struct Table의 멤버는 size다. destroy()zsize를 읽는다. 오타 하나지만 결과는 절대적이다 — 이 파일은 어떤 C 컴파일러로도 빌드되지 않는다. 즉 저자는 이 소스를 마지막으로 고친 뒤 단 한 번도 빌드하지 않고 커밋했다. 커밋 메시지가 Update struct type C89인 것으로 보아 구조체 선언을 손보다가 멤버명을 건드렸고, 그대로 push한 것으로 보인다.

lab11/p11.c:15–18, 55–62
typedef struct Table{
	int size;
	node * lists;
}* table;
...
void destroy(table t)
{
	int i;
	for(i = 0; i < t->zsize; ++i)   // ← 그런 멤버 없음
		free(t->lists[i]);
lab11/p11.c:58:20: error: no member named 'zsize' in 'struct Table'
중대

체인에 매달린 노드는 전부 누수된다

zsizesize로 고쳐 컴파일되게 만들어도 destroy()는 각 버킷의 헤드 센티널 하나만 해제한다. insert()list->next에 이어붙인 실제 데이터 노드는 한 개도 해제되지 않는다. 분리 연쇄법 해시 테이블에서 해제해야 할 것의 대부분이 곧 체인인데, 그 체인을 순회하는 루프가 아예 없다.

같은 함수 안에 버킷 배열 할당 버그도 있다. t->listsnode*(포인터 배열)인데 sizeof(struct Node)로 크기를 잡았다 — 64비트에서 포인터당 8바이트면 되는 자리에 16바이트씩 잡는다. 크래시는 안 나지만 타입을 이해하지 못한 채 쓴 sizeof다.

lab11/p11.c:102
t->lists = (node*)malloc(sizeof(struct Node) * t->size);
// node* 배열이므로 sizeof(node)여야 한다

Lab04 · Lab06 — 틀린 답을 내는 계산기, 두 번

Lab04는 중위식을 후위식으로 바꾼 뒤 평가하고, Lab06은 후위식을 직접 평가한다. lab06/p6.clab04/p4.cmain()을 제외한 164행이 완전히 동일하다 — 스택 ADT, calc(), oper()가 글자 하나 다르지 않게 복사되어 있다. 따라서 Lab04의 평가 버그는 Lab06에 그대로 상속된다.

치명적

후위식 평가에서 피연산자 순서가 뒤집힌다

oper(e, pop(number), pop(number))는 C 표준에서 인자 평가 순서가 미지정(unspecified)이다. 두 pop 중 어느 쪽이 먼저 실행될지 컴파일러가 정한다. clang에서 실행한 결과 first에 스택 최상단(= 후위식의 오른쪽 피연산자)이 들어가, 프로그램은 right OP left를 계산한다. -, /, %처럼 교환법칙이 성립하지 않는 연산자 전부가 반대로 계산된다.

저자의 gcc 5.2.1에서는 반대 순서로 평가되어 우연히 맞는 답이 나왔을 수도 있다. 그것이 더 나쁘다 — 컴파일러가 버그를 가려준 것이지 코드가 맞은 게 아니기 때문이다.

lab04/p4.c:116
push(number, oper(e, pop(number), pop(number)));
// 두 pop의 실행 순서는 표준이 정하지 않는다
실행 결과커밋된 lab04/input 및 직접 만든 입력
$ ./p4 lab04/input
original infix form: 4*(7+3%6)-(4/2)+9-(2*3)#
converted postfix form: 4736%+*42/-9+23*-#
result: 25#            ← 정답은 41

$ echo '9-2' | ./p4 -   →  result: -7#   ← 정답 7
$ echo '8/2' | ./p4 -   →  result: 0#    ← 정답 4 (2/8을 계산)

변환된 후위식 4736%+*42/-9+23*-정확하다. 즉 Lab04 과제의 절반(중위→후위 변환)은 제대로 구현했고, 나머지 절반(평가)만 틀렸다. 그런데 출력 한 줄만 검산했으면 즉시 드러났을 오류다.

치명적

숫자 0은 피연산자로 인식되지 않는다

토크나이저가 >= 대신 >를 썼다. '0'은 조건을 통과하지 못하고 elseswitch로 떨어지는데 거기에도 case '0'이 없다. 결과적으로 입력에서 조용히 사라진다.

lab04/p4.c:170
if (ch > '0' && ch <= '9')   // >= 여야 한다
	push_back(posfix, ch - '0');
실행 결과
$ echo '10+5' | ./p4 -
original infix form: 10+5#
converted postfix form: 15+#     ← '0'이 증발, 1과 5만 남음
result: 6#
중대

empty()는 항상 마지막 노드를 놓친다

루프가 tmp를 한 칸 뒤처져 따라가며 해제하는데, 종료 시점에 tmp는 마지막 실제 노드를 가리키고 ptrNULL이다. 그리고 루프 밖에서 해제하는 것은 tmp가 아니라 ptr — 즉 free(NULL)이다. 저자가 꼬리 노드를 해제하려 한 의도는 명확하지만 변수를 잘못 골랐다. 호출할 때마다 노드 하나씩 누수된다.

여기에 더해 calc()가 내부에 만드는 number 리스트는 어디에서도 해제되지 않는다.

lab04/p4.c:140–146
void empty(Node * stack)
{
	Node * ptr = stack->next, *tmp = stack;
	for (;ptr!=NULL;tmp = ptr, ptr=ptr->next)
		free(tmp);
	free(ptr);   // ptr은 이 시점에 NULL — free(tmp)여야 한다
}
경미

죽은 함수와 반환값 없는 함수

back()(:58)과 pop_back()(:74)은 정의만 되고 호출되지 않는다. 둘 다 빈 리스트에서 널 역참조하는 버그를 품고 있으나 호출되지 않아 드러나지 않는다. getOper()(:10–26)는 switch의 모든 case를 벗어나면 반환값 없이 함수를 이탈한다 — -Wreturn-type이 잡는다. mainop(:164), calcfirst/second(:105)는 선언만 되고 쓰이지 않는다.

Lab10 — 과제 주제가 한 줄도 실행되지 않는다

README에 적힌 주제는 R-B tree deletion이다. 삭제 알고리즘(removeNode, removeUtil, 총 85행)은 실제로 구현되어 있다. 그런데 그 코드에 도달하는 경로가 커밋된 입력으로는 절대 열리지 않는다.

치명적

삽입/삭제 모드 전환 조건이 \r이다

main은 읽은 문자가 13(캐리지 리턴)일 때 삭제 모드로 전환한다. 그런데 함께 커밋된 lab10/input은 LF(0x0a)로 개행된 순수 Unix 텍스트다 — 파일 어디에도 0x0d가 없다. README는 "compile@Linux Ubuntu Gnome 15.10"이라고 적혀 있으니, 저자가 명시한 바로 그 환경에서도 이 조건은 영원히 거짓이다.

결과: 두 번째 줄의 21 3 5 11 23은 삭제 대상이 아니라 추가 삽입된다. 삭제 단계 진입을 알리는 printf("start delete\n")는 출력에 나타나지 않는다.

lab10/p10.c:341–356
char c = 0, cc = 0;
for (int ch = 0, chk = 0; !(chk < 0); inorderPrint(&tree))
{
	if (c == 13)              // CR — 입력 파일은 LF만 쓴다
	{
		printf("start delete\n");
		cc = 1;
	}

	chk = fscanf(fp, "%d", &ch);
	chk = fscanf(fp, "%c", &c);

	if (!cc)
		insertNode(&tree, ch);
	else
		removeNode(&tree, ch);
}
00000000: 3720 3520 3320 3130 2032 3320 3420 3230  7 5 3 10 23 4 20
00000010: 2032 3120 3232 2032 3320 3234 2032 350a   21 22 23 24 25.
00000020: 3231 2033 2035 2031 3120 3233 0a         21 3 5 11 23.
// 0x0d는 한 바이트도 없다
치명적

EOF에서 마지막 값이 한 번 더 삽입된다

루프가 fscanf의 반환값 chk를 검사하기 전에 insertNode를 호출한다. EOF에 도달하면 두 fscanf가 모두 실패하고 ch는 직전 값을 그대로 유지하는데, 그 값이 다시 삽입된 뒤에야 루프 조건이 평가된다.

이 버그는 입력 파일이 개행으로 끝나느냐에 따라 나타났다 사라진다. lab09/input은 개행 없이 끝나 Lab09에서는 증상이 없고, lab10/input은 개행으로 끝나 증상이 나타난다. Lab08·Lab09는 if (chk < 0) break;를 작업 앞에 두는 올바른 패턴을 쓴다. Lab10에서 오히려 퇴행했다.

실행 결과./p10 lab10/input 의 마지막 줄
3(r) 3(b) 4(r) 5(b) 5(r) 7(b) 10(b) 11(r) 20(b)
21(r) 21(r) 22(b) 23(b) 23(r) 23(b) 23(r) 24(r) 25(b)

입력 값 개수: 12 + 5 = 17
최종 노드 개수: 18
'start delete'는 끝내 출력되지 않음
lab09/p9.c:247–251 — 같은 저자, 같은 달, 올바른 패턴
do {
	chk = fscanf(fp, "%d", &ch);
	if (chk < 0)
		break;
	insert(&tree, ch);
중대

출처가 다른 509행짜리 두 번째 R-B 트리가 동봉되어 있다

lab10/p10-test.cp10.c와 아무 관계가 없는 완전히 독립적인 R-B 트리 구현이다. 전역 root, struct rbNode { int data, color; struct rbNode *link[2]; }의 배열형 자식 포인터, stack[98]/dir[98] 고정 배열을 쓴 반복적 삽입, 그리고 ASCII 아트로 회전을 그려 놓은 /* */ 주석 스타일 — 이 네 가지 특징은 레포의 나머지 22개 소스 어디에도 나타나지 않는다. 저자 본인의 코드는 일관되게 typedef한 포인터, left/right 명명, #if defined(_WIN32) && defined(_MSC_VER) 가드를 쓴다.

이 파일의 3행은 그 가드 없이 #pragma warning (disable :4996)를 노출한 채 시작한다 — 저자가 모든 파일에 붙이던 가드를 유독 여기만 빠뜨렸다. 외부에서 가져온 코드로 강하게 추정된다(단정은 하지 않는다 — 원출처를 확인하지 못했다). 어느 쪽이든 빌드에도, 보고서에도 쓰이지 않는 파일이 master에 남아 있다. Lab12에서는 같은 성격의 p12-test.c를 삭제하는 커밋(b294387)을 남겼으면서 이건 정리하지 않았다.

lab10/p10-test.c:3–12, 26
#pragma warning (disable  :4996)   // 다른 파일들엔 모두 #if 가드가 있다
enum {
	RED,
	BLACK
};

struct rbNode {
	int data, color;
	struct rbNode *link[2];      // 저자는 항상 left/right를 쓴다
};
...
	struct rbNode *stack[98], *ptr, *newnode, *xPtr, *yPtr;
	int dir[98], ht = 0, index;   // 경계 검사 없는 고정 크기 98

Lab03 · Lab07 · Lab12 — sizeof를 잘못 센 세 번

세 과제가 서로 다른 방식으로 같은 부류의 실수를 한다. 모두 ASan이 즉시 잡아내는 힙 경계 밖 쓰기이고, 모두 gcc -fsanitize=address로 한 번만 돌려봤으면 드러났을 것들이다.

치명적

Lab03: 모든 노드가 포인터 크기만큼만 할당된다

Liststruct Node *typedef다. 따라서 sizeof(List)포인터 크기(8바이트)이지 struct Node의 크기(16바이트)가 아니다. makeList()Create() 모두 이 값으로 malloc하므로, 리스트에 들어가는 모든 노드가 필요한 크기의 절반만 할당된다. next 필드(오프셋 8)에 쓰는 순간 힙 경계를 넘는다.

연결 리스트 과제에서 노드 할당 크기를 틀렸다는 것은, 자료구조의 가장 기본이 되는 한 줄이 틀렸다는 뜻이다. 32비트에서도 마찬가지로 절반만 잡힌다 — 우연히 맞을 플랫폼이 없다.

lab03/p3.c:5–6, 95–101, 120–126
typedef struct Node *PtrToNode;
typedef PtrToNode List;          // List는 '포인터'다
...
List makeList()
{
	List list = (List)malloc(sizeof(List));   // 8바이트
	list->value = -1;
	list->next = NULL;                        // ← 오프셋 8에 쓴다 = 경계 밖
	return list;
}
...
Position Create(ElementType value)
{
	Position node = (Position)malloc(sizeof(List));  // 모든 노드가 동일
ASan./p3 lab03/input
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 8 at 0x6020000000f8 thread T0
    #0 in makeList p3.c:99
    #1 in main p3.c:33
0x6020000000f8 is located 0 bytes after 8-byte region
allocated by thread T0 here:
    #1 in makeList p3.c:97
중대

Lab03: 해제 코드는 존재하지만 호출되지 않는다

DelList()(:151–160)는 리스트 전체를 순회하며 해제하도록 올바르게 작성되어 있다. 그런데 main에서 호출하지 않는다. makeEmpty()(:102–106)는 더 나쁘다 — 파라미터 list에 새 할당을 대입하는데, C는 값 전달이므로 호출자에게는 아무 효과가 없고 원래 리스트만 유실된다. 정의만 되고 호출되지 않는 것이 오히려 다행인 함수다. isEmpty(), isLast()도 호출부가 없다.

입력 파싱도 취약하다. :46–48의 do–whilefscanf의 반환값을 확인하지 않는다. 입력이 p로 끝나지 않으면 command가 갱신되지 않은 채 무한 루프에 들어간다. 커밋된 입력이 마침 p로 끝나서 드러나지 않을 뿐이다. :42의 fopen 결과도 NULL 검사가 없다 — 다른 랩들은 대부분 검사한다.

lab03/p3.c:102–106
void makeEmpty(List list)
{
	list = (List)malloc(sizeof(List));  // 지역 복사본에 대입 — 호출자는 그대로
	list->next = NULL;
}
치명적

Lab07-2: 1-based 힙에 capacity칸만 잡았다

배열 기반 이진 힙을 1번 인덱스부터 쓰려면 capacity + 1칸이 필요하다(0번은 센티널). 여기서는 정확히 capacity칸만 잡는다. 동시에 isFull()size >= capacity로 판정하므로 capacity개까지 삽입을 허용한다. 마지막 삽입에서 icapacity가 되어 배열 바로 뒤에 쓴다.

같은 코드가 lab13/p13.c:184, :215에 복사되어 있다. 거기서는 그 힙을 아무도 호출하지 않아 터지지 않는다.

lab07/p7_2.c:21, 41–43
heap->elements = (HeapType*)malloc(sizeof(HeapType) * heap->capacity);
// 1-based로 쓸 거면 (capacity + 1)이어야 한다
...
for (i = ++(heap->size); heap->elements[i / 2] < value; i /= 2)
	heap->elements[i] = heap->elements[i / 2];
heap->elements[i] = value;   // i == capacity 일 때 경계 밖
ASan./p7_2 lab07/input2 — 입력 첫 줄이 'n 7'
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4 at 0x603000001c7c thread T0
    #0 in insert p7_2.c:43
0x603000001c7c is located 0 bytes after 28-byte region
                                          // 28 = sizeof(int) * 7
allocated by thread T0 here:
    #1 in init p7_2.c:21
중대

Lab12: 정점 배열을 포인터 한 개 크기로 잡는다

g->vertexlist(포인터) size개를 담을 배열인데 sizeof(list), 즉 포인터 하나 분량만 할당한다. 바로 다음 줄의 루프가 size개를 채워 넣는다.

커밋 이력이 흥미롭다. b294387 "Crash Fix"(2016-05-26)에서 저자는 이 크래시를 만나 int mat[100][100]이라는 고정 인접 행렬을 구조체에 추가하는 것으로 우회했다. 원인인 malloc 크기는 손대지 않았고, 대신 정점 100개라는 하드코딩 상한과 graphAppend의 경계 검사 부재를 새로 들여왔다.

lab12/p12.c:110–112
g->vertex = (list*)malloc(sizeof(list));   // 포인터 1개 = 8바이트
for (i = 0; i < size; ++i)
	g->vertex[i] = listInit();               // size개를 쓴다
잘한 것

Lab08(p8.c:215)과 Lab10(p10.c:363)은 종료 시 deleteTree/destroy를 실제로 호출해 트리를 재귀적으로 해제한다. Lab07-2도 free(heap->elements); free(heap);로 마무리한다. 해제를 아예 생각하지 않은 게 아니라, 자료구조에 따라 챙긴 것과 못 챙긴 것이 갈린다.

Lab14 — 경고 하나가 크래시로 이어지는 전 과정

DFS와 BFS로 경로를 찾는 과제다. 실행하면 시작 직후 죽는다. 원인은 컴파일러가 이미 경고로 알려준 자리에 있다.

치명적

graph_create()return이 없다

함수는 pGraph를 반환한다고 선언되어 있고, 내부에서 구조체를 할당해 초기화까지 한다. 그리고 그것을 반환하지 않는다. 호출자가 받는 값은 미정의다 — 실제로는 레지스터에 남은 쓰레기값이다. 그 쓰레기 포인터의 size 필드를 읽어 graph_realloc에 넘기고, malloc0xffffffffbcc60608바이트를 요구하며 죽는다.

-Wall이 정확히 이 줄을 짚는다. 경고를 한 번도 켜지 않았다는 증거다.

lab14/p14.c:184–189
pGraph graph_create()
{
	pGraph graph = (pGraph)malloc(sizeof(struct Graph));
	graph->size = 0;
	graph->vertex = NULL;
}   // ← return graph; 가 없다
컴파일 경고 → 런타임 크래시
lab14/p14.c:189:1: warning: non-void function does not return a value
                            [-Wreturn-type]

ERROR: AddressSanitizer: requested allocation size 0xffffffffbcc60608
       exceeds maximum supported size of 0x10000000000
    #1 in graph_realloc p14.c:192
    #2 in graph_insert_vertex p14.c:201
    #3 in main p14.c:353
중대

경계 검사를 역참조 뒤에 한다

&&는 왼쪽부터 평가한다. 따라서 graph->vertex[now_index]->first->value를 먼저 읽고 그 다음에 now_index < graph->size를 확인한다. 찾는 정점이 없으면 배열 끝을 넘어 읽는다. 두 조건의 순서만 바꾸면 되는 문제다.

같은 함수에서 visited[now]는 배열 인덱스가 아니라 정점 으로 접근한다(:220, :222). 정점 번호가 배열 크기 이상이면 그대로 경계 밖이다. graph_append_edgeif (start > graph->size)(:207)도 값과 개수를 비교하는 같은 종류의 혼동이다. 그리고 graph_find_dfs_rec_utilint를 반환한다면서 루프를 다 돌고 나면 반환 없이 끝난다(:232).

lab14/p14.c:219
for (now_index = 0;
     graph->vertex[now_index]->first->value != now
     && now_index < graph->size; ++now_index);
// 역참조가 경계 검사보다 먼저 평가된다

Lab05 — 원형 큐라고 부르지만 원형이 아니다

README의 항목명은 Circular Queue ADT다. 원형 큐의 존재 이유는 배열을 재사용해 enqueue와 dequeue를 둘 다 O(1)로 만드는 것이다. 구현에는 frontrear도 없고 나머지 연산(%)도 없다.

치명적

dequeue가 배열 전체를 한 칸씩 밀어낸다

구조체에는 index 하나뿐이다. pop()은 0번을 꺼낸 뒤 나머지 전체를 앞으로 복사한다 — O(n)이다. 이건 원형 큐가 아니라 배열 기반 리스트다. 게다가 capacity만큼 채운 뒤 dequeue하면 뒤쪽 공간이 재사용되긴 하지만, 그것은 원형 인덱싱 덕분이 아니라 매번 전체를 시프트하기 때문이다. 과제가 가르치려던 개념이 구현에 하나도 없다.

그리고 ret는 빈 큐 검사보다 먼저 대입된다. 큐가 비어 있으면 초기화되지 않은 메모리를 읽는다 — 값은 버려지지만 읽기 자체가 일어난다.

lab05/p5.c:49–62
int pop(Node * stk)
{
	int i, ret = stk->value[0];     // 빈 큐 검사보다 먼저 읽는다
	if(stk->index==0)
	{
		printf("Dequeue Failed: Queue is Empty\n");
		return 0;
	}
	for (int i = 1; i < stk->index; i++)
		stk->value[i-1] = stk->value[i];   // O(n) 시프트 = 원형 큐가 아님
	stk->index--;
치명적

테스트 입력 파일에 머지 충돌 마커가 그대로 커밋되어 있다

lab05/input 16–21행에 해결되지 않은 Git 충돌 마커가 남아 있다. dfe0f97 "Update Directory" 커밋으로 들어왔고 2016년 이후 지금까지 master에 그대로 있다. 프로그램은 <, =, >, 16진 해시 문자를 명령으로 해석하지 못해 조용히 무시한다 — 그래서 아무도 눈치채지 못했다.

충돌을 해결하지 않고 커밋했다는 것 자체보다, 그 뒤로 이 파일로 프로그램을 한 번도 눈여겨 돌려보지 않았다는 사실이 더 문제다. 참고로 저자는 3e22981 "remove unmerged file"이라는 커밋도 남겼다 — 충돌 상황을 인지하고 있었지만 이 파일은 놓쳤다.

lab05/input:14–21 — 현재 master 상태
e 15
f
<<<<<<< HEAD
r

=======
r
>>>>>>> e2e69fc4bd26023e10c75c7fe77d8f83c4dc142f
중대

malloc은 초기화되지 않고 곧바로 버려진다

mainNode를 하나 할당하는데 value, capacity, index 어느 것도 초기화하지 않는다. 입력의 n 5 명령이 init(5)로 덮어쓰면서 이 할당은 누수된다. 반대로 입력에 n이 없으면 모든 연산이 초기화되지 않은 capacity와 널 아닌 쓰레기 value 포인터를 쓰게 된다.

해제 함수 empty()(:72–76)와 출력 함수 view()(:64–71)는 정의만 되고 호출되지 않는다. init()malloc(sizeof(int) * cap + 1)(:14)은 연산자 우선순위상 int 한 칸이 아니라 1바이트를 더 잡는다 — 의도가 무엇이었든 그대로 이루어지지 않았다.

lab05/p5.c:96, 117–120
Node * queue = (Node*)malloc(sizeof(Node));   // 초기화 없음
...
		case 'n':
			fscanf(fp, "%d", &arg);
			queue = init(arg);              // 위 할당은 여기서 유실

Lab12 · Lab13 — Linux에서만 맞는 답, 그리고 이름값을 못 하는 알고리즘

그래프 두 과제는 이 레포에서 드물게 알고리즘 자체가 정답을 내는 경우다. 다만 그 사실을 확인하려면 매크로 하나를 고쳐야 했다.

중대

줄바꿈 문자를 플랫폼별 상수로 하드코딩했다

파서가 입력의 "첫 줄=정점 목록, 둘째 줄=간선 목록" 구조를 줄바꿈 바이트를 직접 비교해서 판별한다. __linux___MSC_VER도 아닌 환경(macOS·BSD)에서는 LINE_CHAR13이 되고, LF로 끝나는 입력 파일에서 조건이 영원히 거짓이 된다. Lab12는 빈 줄 하나만 출력하고 끝나고, Lab13은 모든 토큰을 정점으로 오인해 비용이 전부 0 또는 INF인 쓰레기를 출력한다. lab15/p15_1.c, p15_2.c도 같은 매크로를 공유한다.

검증을 위해 #define LINE_CHAR 1310으로 바꾼 사본을 만들어 돌렸다. 그러자 두 과제 모두 정답을 냈다. 알고리즘은 맞았고 이식성만 틀린 것이다. 그러나 3분기 #if를 직접 작성해 놓고 마지막 분기를 검증하지 않은 것 자체가 결함이다 — 애초에 c == '\n' || c == '\r'이면 끝날 일이었다.

lab12/p12.c:9–15 (lab13/p13.c:9–15, lab15/p15_1.c, p15_2.c 동일)
#if defined(__linux__)
	#define LINE_CHAR 10
#elif defined(_MSC_VER)
	#define LINE_CHAR '\n'
#else
	#define LINE_CHAR 13        // macOS/BSD — LF 파일에서 절대 매치 안 됨
#endif
검증LINE_CHAR를 10으로 강제한 사본
$ ./p12 lab12/input
6	1	2	5	3	4          ← 유효한 위상 정렬 (9개 간선 제약 모두 만족)

$ ./p13 lab13/input
start at 9
to 9, cost 0    to 15, cost 2   to 3, cost 3   to 4, cost 1
to 5, cost 3    to 6, cost 6    to 7, cost 5   ← 손으로 검산한 최단거리와 일치
중대

Lab13은 Dijkstra가 아니다 — 힙을 만들어 놓고 쓰지 않는다

p13.c는 :173–251에 완전한 이진 최대 힙(heap_create, heap_push, heap_pop, heap_free, 약 80행)을 구현해 놓았다. 그런데 grep으로 확인한 결과 이 네 함수의 호출부가 파일 전체에 하나도 없다. dijkstra()pList heap = list_create(), 즉 평범한 연결 리스트를 쓰면서 변수 이름만 heap이다.

그래서 "최소 비용 정점 추출" 루프가 힙 연산이 아니다. 더 나쁜 것은 그 루프의 비교문이다. k는 파일 상단의 전역 int i, j, k; 중 하나로, main의 :375에서 마지막으로 읽은 간선 가중치가 들어 있다. 그 값으로 list_index(heap, k)를 계산해 인덱싱한다 — 의미 없는 비교다. 결국 최소값 선택이 성립하지 않는다.

그럼에도 정답이 나오는 이유는 :295의 list_push(heap, next)가 거리가 갱신될 때마다 정점을 다시 넣기 때문이다. 즉 이것은 Dijkstra가 아니라 큐 기반 반복 완화(Bellman–Ford/SPFA 계열)이고, 최악 복잡도는 O(E log V)가 아니라 O(V·E)다. 제출물이 Shortest Path라는 결과는 내지만, Dijkstra라는 자료구조 수업의 학습 목표는 달성하지 못했다.

lab13/p13.c:277–283
now = heap->first->next->value;
LIST_FOR_EACH(heap, maxer)
{
	if (dist[list_index(heap, maxer->value)] < dist[list_index(heap, k)])
		now = maxer->value;                        // k는 전역 — 마지막 간선 가중치
}
179:pHeap heap_create(int cap)
190:void heap_free(pHeap heap)
207:void heap_push(pHeap heap, HeapType value)
219:HeapType heap_pop(pHeap heap)
// 정의 4개, 호출 0개
경미

해제를 포기한다고 주석으로 선언한 함수

graphFree()는 본문이 주석 처리된 free(g) 한 줄뿐인 빈 함수다. 호출은 되지만 아무것도 하지 않는다. 정점 리스트, 노드, 그래프 구조체 전부가 누수된다. p13.cdijkstra()가 할당하는 dist 배열(:268)도 해제되지 않는다.

부수적으로, 두 파일 모두 int i, j, k;전역으로 두고 서로 다른 함수에서 루프 카운터로 재사용한다. 지금은 중첩 호출이 없어 터지지 않지만, graphIniti를 쓰는 동안 i로 도는 루프에서 그것을 호출하면 즉시 무한 루프가 된다.

lab12/p12.c:160–163
void graphFree(graph g)
{
	//free(g);
}

Lab15 — 정렬: 재귀는 맞고 반복은 틀리다

머지 소트를 재귀·반복 두 가지로, 퀵 소트를 피벗 선택 세 가지로 구현하는 과제다. 함수 포인터 typedef int (*Pivot)(int, int)로 피벗 전략을 갈아끼우는 설계는 학부 과제로서 좋은 판단이다.

치명적

반복형 머지 소트는 정렬하지 못하고, 음수 크기로 malloc한다

세 개의 off-by-one이 겹쳐 있다. merge_sort_mergelast포함(inclusive) 인덱스로 쓰는데(n2 = last - mid, ary[mid+1..last]를 복사), merge_sort_iterative_utilright배타적 끝으로 계산해 넘긴다. 게다가 호출부는 원소 개수 i가 아니라 i - 1을 넘기는데, 받는 쪽은 start < n으로 개수처럼 쓴다.

블록 크기가 커지면 midlast를 넘어서고 n2가 음수가 된다. malloc(sizeof(int) * -2)size_t로 승격되어 0xfffffffffffffff8이 된다 — ASan이 정확히 그 값을 보고한다.

lab15/p15_1.c:126–130, 163–166, 179
int n1 = mid - first + 1;
int n2 = last - mid;            // last가 mid보다 작으면 음수
int * left  = (int*)malloc(sizeof(int) * n1),
    * right = (int*)malloc(sizeof(int) * n2);
...
int mid   = start + current - 1;
int right = (start + 2 * current > n) ? (n) : (start + 2 * current);
merge_sort_merge(ary, start, mid, right);   // 배타적 끝을 포함 인덱스 자리에
...
merge_sort_iterative_util(ary, i - 1);        // 개수 자리에 마지막 인덱스
ASan + 실행 결과./p15_1 lab15/merge_input (원소 10개)
ERROR: AddressSanitizer: requested allocation size 0xfffffffffffffff8
    #1 in merge_sort_merge p15_1.c:130
    #2 in merge_sort_iterative_util p15_1.c:166

// ASan 없이 빌드해 끝까지 돌린 결과
merge_sort_iterative  → 1, 5, 11, 15, 26, 48, 19, 59, 61, 77   ← 정렬 실패
merge_sort_recursive  → 1, 5, 11, 15, 19, 26, 48, 59, 61, 77   ← 정답

두 함수가 같은 merge_sort_merge를 공유한다. 재귀판이 맞는다는 것은 병합 로직 자체는 옳고 반복판의 구간 계산만 틀렸다는 뜻이다. 그리고 두 결과를 나란히 출력해 놓고도 한쪽이 정렬되지 않았다는 것을 알아채지 못했다.

중대

XOR swap — 표준이 정의하지 않는 동작

a ^= b ^= (a ^= b)는 하나의 시퀀스 포인트 사이에서 같은 객체를 여러 번 수정한다. C에서 미정의 동작이며, 컴파일러가 바꾸면 결과가 바뀔 수 있다. i == j일 때 원소를 0으로 만들어 버리는 성질도 있다(:185의 if (i != j) 가드 덕에 여기서는 피해 간다). 같은 관용구가 lab02/p2_1.c:78에도 있는데, 거기엔 그 가드가 없다 — i < j가 보장되어 우연히 안전할 뿐이다.

세 줄 임시변수 교환이 더 짧고 빠르고 정의된 동작이다. 이 관용구는 2016년 기준으로도 이미 20년 묵은 안티패턴이었다.

lab15/p15_2.c:187, 194
ary[i] ^= ary[j] ^= (ary[i] ^= ary[j]);
...
ary[pivot] ^= ary[j] ^= (ary[pivot] ^= ary[j]);
중대

파티션 루프에 하한이 없고, %s에 길이 제한이 없다

:182의 while (ary[j] > ary[pivot]) --j;j >= first를 확인하지 않는다. 지금은 ary[pivot] 자신이 자기보다 크지 않아 멈추지만, :188–189에서 pivot이 이동한 뒤에는 그 보장이 약해진다. :181의 while (ary[i] <= ary[pivot] && i < last)는 Lab14와 같은 "역참조 후 경계 검사" 순서다.

:253의 char input_string[100]에 :259의 fscanf(fp, "%s", input_string)로 읽는다 — 길이 제한이 없다. 100자 넘는 토큰이 들어오면 스택을 넘어 쓴다. %99s 한 번이면 끝날 문제이고, 같은 패턴이 lab02/p2_1.c:67에도 있다.

잘한 것

LINE_CHAR를 10으로 고친 사본으로 돌리면 퀵 소트는 세 가지 피벗 전략 모두 정답을 낸다(leftmost / rightmost / middle). 게다가 매 파티션마다 <왼쪽> <피벗> <오른쪽> 형태로 분할 과정을 출력해, 채점자가 알고리즘의 동작을 눈으로 따라갈 수 있게 만들었다. 과제가 요구한 것 이상이다.

Lab01 · Lab02 · Lab07-1 · Lab08 — 나머지

중대

Lab08: deleteValue 50행 전체가 죽은 코드이고, 그 안에 오답 free가 있다

AVL 트리의 삽입과 회전은 정상 동작한다(실행 로그에 Single Rotate Left/Right, Double Rotate Left가 올바른 시점에 찍힌다). 그런데 deleteValue(:131–183)는 호출부가 없다. 그 안에서 preright = mom으로 읽는 전역 Tree * mom;(:18)은 어디서도 대입되지 않아 항상 NULL이다 — 죽은 코드 안의 죽은 분기다.

더 심각한 것은 :150이다. 중위 후속자 ret를 끌어올린 뒤 제거해야 하는데, 해제하는 것은 rret->right다. while 루프가 한 번이라도 돌았다면 retrret->left에 있고, rret->right트리에 여전히 연결된 다른 노드다. 살아 있는 노드를 해제하고, 그 자리를 NULL로 끊어 서브트리를 통째로 유실시킨다. 똑같은 코드가 lab07/p7_1.c:141에도 있다 — Lab08이 Lab07을 복사했고, 두 과제의 입력 모두 삭제 명령을 포함하지 않아 아무도 이 경로를 밟지 않았다.

lab08/p8.c:141–152 (lab07/p7_1.c:132–143 동일)
Tree * ret = now->right, *rret = now;
if (now->right != NULL)
{
	for (;ret->left != NULL; rret = ret, ret = ret->left);
	now->value = ret->value;
	if (ret->right != NULL)
		rret->left = ret->right;
	else
	{
		free(rret->right);      // free(ret) 이어야 한다 — 살아 있는 노드를 해제
		rret->right = NULL;     // 그리고 서브트리를 끊는다
	}
}
치명적

Lab02: 기대 출력을 커밋해 놓고 한 번도 대조하지 않았다

lab02/TestCase/에는 세 문제 각각의 input1/input2기대 출력 output1/output2가 들어 있다. 레포 전체에서 기대 출력이 존재하는 유일한 곳이다. 그래서 실제로 돌려 diff를 떠 봤다.

p2_1은 두 입력 모두 완전 일치한다. 그런데 p2_2p2_3네 케이스 전부 불일치한다. 숫자 값은 맞고, 틀리는 것은 라벨과 공백이다 — p2_3progAvg/examAvg여야 할 자리에 HwAvg/ExamAvg를 쓰고 탭 대신 공백을 넣는다. p2_2는 첫 열 머리글 앞 들여쓰기가 없고 줄 끝에 불필요한 탭이 붙는다.

출력 형식이 명세의 일부인 자동 채점 과제에서 이건 감점 사유다. 그러나 등급보다 중요한 것은 이것이다 — 정답 파일을 손에 쥐고도 diff를 한 번 돌리지 않았다. 명령 한 줄이면 끝났을 대조를, 정답을 커밋까지 해 놓고 하지 않았다. 이후 13개 랩에서 반복될 모든 결함의 원형이 여기 있다.

diff 실제출력 lab02/TestCase/p2_3/output1.txt< 프로그램 출력 / > 커밋된 기대 출력
2,3c2,3
< HwAvg 39.17
< ExamAvg 44.50
---
> progAvg	39.17
> examAvg	44.50
// 숫자는 맞는다. 라벨과 구분자가 다르다. 네 케이스 모두 동일한 양상.
중대

Lab02: 입력 길이를 믿고 파싱한다

집합 {a,b,c} 형태를 읽어 부분집합/순열을 만드는 과제다. fscanf(fin, "%s", ret)SIZE 버퍼에 길이 제한 없이 쓴다. 이어지는 while (ret[++ptr] != '}')는 종료 조건을 닫는 중괄호의 존재에만 의존한다 — 입력에 '}'가 없으면 힙 버퍼를 벗어나 우연히 0x7D 바이트를 만날 때까지 읽고, 그 사이의 모든 바이트를 string에 쓴다. 시작 인덱스가 ++ptr이라 여는 중괄호를 건너뛰는 건 의도적이지만, 그 의도를 문서화한 검사는 없다.

lab02/p2_1.c:66–73
char * ret = (char*)malloc(sizeof(char) * SIZE);
fscanf(fin, "%s", ret);          // 길이 제한 없음

while (ret[++ptr] != '}')        // 버퍼 끝 검사 없음
{
	if(ret[ptr] != ',')
		string[ttr++] = ret[ptr];
}
경미

Lab01: 나눗셈은 0을 검사하고 나머지는 검사하지 않는다

함수 포인터 배열로 연산자를 디스패치하는 계산기다. 구조는 깔끔한데, 인접한 두 함수의 방어 수준이 다르다. divisionsecond가 0이면 0을 반환하는데, 바로 아래 modulus는 그대로 %를 수행한다 — x % 0은 x86에서 SIGFPE다. 같은 화면에 보이는 두 함수 사이의 불일치라 더 눈에 띈다.

또한 printf(__FUNCTION__)(:13, 19, 25, 35, 41)은 사용자 입력이 아니라 컴파일 타임 리터럴이므로 실제 위험은 없지만, 포맷 문자열 자리에 변수를 놓는 습관 그 자체다(-Wformat-security 대상). printf("%s", __FUNCTION__)가 맞다.

lab01/p1_4.c:28–43
inline static int division(int first, int second, bool state) {
	if (!state)
	{
		if(!second)
			return 0;              // 0 검사 있음
		return first / second;
	}
	...
}
inline static int modulus(int first, int second, bool state) {
	if (!state)
		return first % second;    // 0 검사 없음 → SIGFPE
경미

레포 위생: 중복 제출 아카이브와 바이너리

lab06/lab06.zipp6.c, input, lab6.docx를 담고 있는데 셋 다 이미 같은 디렉터리에 개별 파일로 커밋되어 있다. 제출용으로 압축한 것을 지우지 않고 함께 올린 것이다. 나머지 14개 랩에는 zip이 없으니 실수로 보인다.

과제 명세 PDF/DOCX 30개가 함께 커밋되어 있다(lab15.pdf 278KB 등, 팩 전체 2.65MB). 학부 레포에서 명세를 같이 두는 것은 납득할 만하지만, .gitignore에는 *.out, log/, test/만 있고 바이너리 문서에 대한 정책은 없다. lab10/p10-test.c가 남은 것도 같은 맥락이다 — lab12/p12-test.c는 지웠으면서.

잘한 것

lab02/p2_1.c는 커밋된 기대 출력 두 케이스 모두와 바이트 단위로 일치한다. 이 레포에서 "정답이 파일로 남아 있고 프로그램이 그것을 실제로 재현하는" 유일한 사례다. 그리고 Lab02는 TestCase/ 디렉터리를 만들어 입력·기대출력 12개 파일을 함께 커밋했다 — 구조 자체는 옳았다. 이 습관이 Lab03 이후 완전히 사라진 것이, 그리고 Lab02 안에서조차 diff로 이어지지 않은 것이 이 레포의 결함 대부분을 설명한다.

반복되는 패턴

  1. 출력을 검산하지 않는다. Lab04는 25를, Lab15-1은 정렬되지 않은 배열을, Lab10은 입력보다 하나 많은 노드를 출력하고 그대로 커밋됐다. 세 경우 모두 프로그램이 정상 종료했기 때문에 넘어간 것으로 보인다. 결정적 증거는 Lab02다 — 기대 출력 파일을 직접 만들어 커밋해 놓고도 diff를 돌리지 않아, 3개 중 2개가 그 파일과 불일치하는 채로 남았다. "돌아간다 = 맞다"라는 등식이 이 레포의 결함 절반 이상의 뿌리다.
  2. 컴파일러 경고를 켜지 않았다. Lab11은 아예 컴파일되지 않고(error), Lab14의 크래시는 -Wreturn-type이 정확히 그 줄을 지목한다. gcc -Wall 한 번이면 Lab04·Lab14·Lab15-2의 반환값 누락, Lab05·Lab12의 미사용 변수, Lab08의 dangling-else, Lab09의 오해 소지 들여쓰기가 전부 나왔다.
  3. 파싱을 개행 바이트에 건다. Lab10은 c == 13, Lab12·13·15는 LINE_CHAR 매크로로 같은 일을 한다. 파일 형식이 바뀌거나 플랫폼이 바뀌면 에러 없이 기능이 조용히 사라진다. Lab10에서는 과제 주제 자체가 사라졌다.
  4. fscanf 반환값을 소비 후에 확인한다. Lab03(무한 루프 위험), Lab10(중복 삽입), Lab12·13·15(EOF 시 stale 값 사용)에서 반복된다. Lab08과 Lab09만 올바른 순서로 썼는데, 바로 다음 주 Lab10에서 다시 틀렸다 — 패턴이 정착되지 않았다는 뜻이다.
  5. sizeof를 타입이 아니라 감으로 쓴다. Lab03의 sizeof(List)(포인터 typedef), Lab11의 sizeof(struct Node)(포인터 배열 자리), Lab12의 sizeof(list)(배열을 1개로), Lab05의 sizeof(int) * cap + 1(우선순위). 네 번 모두 다른 방식으로 틀렸다.
  6. 쓰지 않는 코드를 남긴다. Lab08 deleteValue 50행, Lab13 힙 80행, Lab04 back/pop_back, Lab03 DelList/makeEmpty/isEmpty/isLast, Lab10 p10-test.c 509행. 공교롭게도 이 죽은 코드 안에 가장 심각한 버그들이 숨어 있다(Lab08의 오답 free) — 호출되지 않으니 영원히 발견되지 않는다.
  7. 먼저 쓴 코드를 복사해 다음 주에 재사용한다. Lab06은 Lab04의 164행을 그대로, Lab08은 Lab07-1의 삭제 루틴을 그대로, Lab13은 Lab07-2의 힙을 그대로 가져왔다. 코드 재사용 자체는 합리적이지만 버그도 함께 복제된다. Lab04의 오답과 Lab07-1의 오답 free가 정확히 그렇게 두 배가 됐다.

지금 손본다면

  1. lab11/p11.c:58zsizesize로 고친다. 1문자. 컴파일되지 않는 파일이 master에 남아 있는 상태를 없앤다. 그 다음 destroy에 체인 순회 루프를 넣는다(5행).
  2. lab04/p4.c:116pop 두 개를 지역 변수로 분리한다. int b = pop(number), a = pop(number); push(number, oper(e, a, b)); — 3행으로 미정의 평가 순서와 피연산자 역전을 동시에 없앤다. :170>>=로. lab06/p6.c에 같은 수정을 복사한다. 비용 10분, 효과는 F 두 개가 사라지는 것.
  3. 개행 비교를 전부 c == '\n' || c == '\r'로 바꾼다. lab10/p10.c:344, LINE_CHAR 매크로 4곳. Lab10은 과제 주제가 살아나고, Lab12·13·15는 어느 플랫폼에서도 같은 답을 낸다. 비용 20분.
  4. fscanf 검사를 소비 앞으로 옮긴다. Lab08·Lab09가 이미 쓰는 if (chk < 0) break; 패턴을 Lab03·10·12·13·15에 복사하면 된다. 자기 레포 안에 정답 예제가 있다.
  5. gcc -Wall -Wextra -fsanitize=address로 23개 파일을 전부 빌드한다. 이 보고서의 치명적 지적 중 Lab03·Lab07-2·Lab14·Lab15-1은 전부 이 한 줄이 자동으로 찾아준 것이다. 사람이 읽어서 찾은 게 아니다.
  6. Lab02의 TestCase/ 구조를 전 과제로 넓히고, 무엇보다 실제로 diff를 돌린다. 랩당 expected 파일 하나와 diff 한 줄이면 Lab02의 라벨 불일치, Lab04의 25, Lab15-1의 미정렬, Lab10의 노드 18개가 전부 그날 잡혔다. 정답 파일은 이미 커밋되어 있었다 — 실행하지 않았을 뿐이다. 이 레포에 가장 부족한 것은 알고리즘 지식이 아니라 이것이다.
  7. 죽은 코드를 지운다. lab10/p10-test.c(출처 불명 509행), lab06/lab06.zip(중복), lab08/p8.cdeleteValue+전역 mom, lab13/p13.c의 미사용 힙 80행. 그리고 lab05/input의 머지 충돌 마커를 해결한다.